| | |
| | | "Bash(netstat -ano)", |
| | | "Bash(powershell *)", |
| | | "Bash(curl -s -o nul -w \"%{http_code}\" http://localhost:3100/)", |
| | | "Bash(node scripts/build.js --linux)" |
| | | "Bash(node scripts/build.js --linux)", |
| | | "Bash(node tools/decode-log.js --stdin)", |
| | | "Bash(timeout 3 node -e \"require\\('./lib/logger'\\)\")" |
| | | ] |
| | | } |
| | | } |
| | |
| | | |
| | | ## 1. 系统概述 |
| | | |
| | | JMS 联机服务用于管理与监控多台 **GC-110N 透析设备**。通过持久 TCP 长连接 + 定时 K 指令轮询,实时采集 32 项治疗参数,经 MQTT / 阿里云 IoT 上传至云端,并提供本地 Web 监控大屏。 |
| | | JMS 联机服务用于管理与监控多台 **GC-110N 透析设备**。通过持久 TCP 长连接 + 定时指令轮询,实时采集 32 项治疗参数,经 MQTT / 阿里云 IoT 上传至云端,并提供本地 Web 监控大屏。 |
| | | |
| | | ``` |
| | | ┌──────────┐ TCP:10001 ┌─────────────────┐ MQTT ┌──────────────┐ |
| | | │ GC-110N │←──────────────→│ jms-connection │───────────→│ MQTT Broker │ |
| | | │ 透析机 1 │ K 轮询 (10s) │ -service │ └──────────────┘ |
| | | │ 透析机 1 │ 轮询 (10s) │ -service │ └──────────────┘ |
| | | └──────────┘ │ │ |
| | | ··· │ dashboard:3100 │ HTTP ┌──────────────┐ |
| | | ┌──────────┐ │ ← 浏览器访问 │───────────→│ 阿里云 IoT │ |
| | |
| | | ```jsonc |
| | | { |
| | | // ── 全局参数 ── |
| | | "pollIntervalMs": 10000, // K 轮询间隔(毫秒),默认 10s |
| | | "pollIntervalMs": 10000, // 轮询间隔(毫秒),默认 10s |
| | | "connectTimeoutMs": 5000, // TCP 握手超时(毫秒) |
| | | "reconnectBaseMs": 3000, // 重连退避基数(毫秒) |
| | | "reconnectMaxMs": 60000, // 重连退避上限(毫秒) |
| | |
| | | |
| | | | 目标 | 协议 | 端口 | 用途 | |
| | | |------|------|------|------| |
| | | | GC-110N 设备 | TCP | 10001(可配置) | K 指令轮询 | |
| | | | GC-110N 设备 | TCP | 10001(可配置) | 指令轮询 | |
| | | | MQTT Broker | TCP | 62283(可配置) | MQTT 上传 | |
| | | | 阿里云 IoT | TCP | 443 (TLS) | 属性上报 | |
| | | | 三元组 API | HTTPS | 443 | 获取设备凭证 | |
| | |
| | | ### 9.2 收不到数据 / 字段数为 0 |
| | | |
| | | 1. 确认设备已进入治疗状态(待机状态可能返回有限数据) |
| | | 2. 查看日志中 `←` 行,确认报文以 `K` 开头 + 4 位状态码 |
| | | 3. 确认设备固件支持 K 格式协议 |
| | | 2. 查看日志中 `←` 行,确认收到设备报文(混淆格式可通过 `node tools/decode-log.js --stdin` 解码查看) |
| | | 3. 确认设备固件协议版本兼容 |
| | | |
| | | ### 9.3 阿里云上传失败 |
| | | |
| | |
| | | │ ├── logger.js # 日志模块(按天滚动) |
| | | │ ├── data-cache.js # 内存数据缓存(Map<ip, deviceData>) |
| | | │ ├── device-manager.js # 设备管理(遍历创建连接) |
| | | │ ├── device-connection.js # 单设备 TCP 长连接 + K 轮询 + 重连 |
| | | │ ├── protocol.js # GC-110N K 格式解析器 |
| | | │ ├── device-connection.js # 单设备 TCP 长连接 + 轮询 + 重连 |
| | | │ ├── protocol.js # GC-110N 协议解析器 |
| | | │ └── upload/ |
| | | │ ├── index.js # 上传总控(顺序:MQTT → 阿里云) |
| | | │ ├── mqtt-uploader.js # MQTT 单例客户端 |
| | |
| | | }, |
| | | "devices": [ |
| | | { |
| | | "ip": "192.168.160.1", |
| | | "ip": "169.254.233.58", |
| | | "port": 10001, |
| | | "serialNumber": "xy123", |
| | | "enabled": true |
| | |
| | | padding: 4px 8px; border-radius: 4px; background: #161b22; |
| | | font-size: 12px; |
| | | } |
| | | .detail-panel .field .fid { color: #58a6ff; font-weight: 600; min-width: 18px; } |
| | | .detail-panel .field .fname { color: #8b949e; flex: 1; margin: 0 8px; } |
| | | .detail-panel .field .fval { color: #f0f6fc; font-family: 'Consolas', monospace; } |
| | | .detail-panel .field .funit { color: #484f58; margin-left: 4px; font-size: 11px; } |
| | | .detail-panel .raw-frame { |
| | | grid-column: 1 / -1; margin-top: 8px; padding: 8px 12px; |
| | | background: #0d1117; border: 1px solid #30363d; border-radius: 4px; |
| | | font-family: 'Consolas', monospace; font-size: 13px; color: #d2a8ff; |
| | | word-break: break-all; max-height: 100px; overflow-y: auto; |
| | | } |
| | | |
| | | .clickable { cursor: pointer; user-select: none; } |
| | | .clickable td:first-child { color: #58a6ff; } |
| | |
| | | <div class="header"> |
| | | <div> |
| | | <h1>JMS GC-110N 联机服务监控</h1> |
| | | <div class="info">协议 Ver.3.0 · K 格式状态轮询</div> |
| | | <div class="info">TCP 长连接 · 定时轮询</div> |
| | | </div> |
| | | <div class="info"> |
| | | <span class="dot live"></span> |
| | |
| | | <div class="detail-panel"> |
| | | ${fields.length === 0 ? '<div style="color:#484f58;grid-column:1/-1;">暂无解析数据</div>' : |
| | | fields.map(f => `<div class="field"> |
| | | <span class="fid">${f.id}</span> |
| | | <span class="fname">${f.name||''}</span> |
| | | <span class="fval">${f.displayValue||f.value||'(空)'}</span> |
| | | <span class="funit">${f.unit||''}</span> |
| | | </div>`).join('')} |
| | | ${dev.rawFrame ? `<div class="raw-frame">${dev.rawFrame}</div>` : ''} |
| | | </div> |
| | | </div> |
| | | </td> |
| | | </tr>`; |
| | | } |
| | |
| | | } |
| | | |
| | | function buildSnapshot() { |
| | | const devices = dataCache.getAll().map((dev) => { |
| | | const { rawFrame, ...rest } = dev; |
| | | return rest; |
| | | }); |
| | | return { |
| | | type: "snapshot", |
| | | timestamp: new Date().toISOString(), |
| | | summary: dataCache.getSummary(), |
| | | devices: dataCache.getAll(), |
| | | devices, |
| | | config: { |
| | | pollIntervalMs: config.pollIntervalMs, |
| | | reconnectBaseMs: config.reconnectBaseMs, |
| | |
| | | |
| | | const fs = require("fs"); |
| | | const path = require("path"); |
| | | const { obfuscate } = require("./obfuscate"); |
| | | |
| | | const C = { reset: "\x1b[0m", dim: "\x1b[2m", green: "\x1b[32m", yellow: "\x1b[33m", red: "\x1b[31m", cyan: "\x1b[36m", white: "\x1b[37m" }; |
| | | |
| | |
| | | |
| | | // ── 数据收发 ── |
| | | sendK(ip) { |
| | | log(ip, C.yellow, "→", `发送 K 请求`); |
| | | log(ip, C.yellow, "→", `发送轮询请求`); |
| | | }, |
| | | recvK(ip, rawFrame, fieldCount, statusCode) { |
| | | log(ip, C.green, "←", `${rawFrame}`); |
| | | log(ip, C.green, "←", `${obfuscate(rawFrame)}`); |
| | | }, |
| | | |
| | | // ── 异常 ── |
| New file |
| | |
| | | "use strict"; |
| | | |
| | | const DEFAULT_KEY = "jms-gc110n-log-obfuscate-default"; |
| | | |
| | | function getKey() { |
| | | return process.env.JMS_LOG_KEY || DEFAULT_KEY; |
| | | } |
| | | |
| | | function obfuscate(text) { |
| | | if (!text) return text; |
| | | const key = Buffer.from(getKey(), "utf-8"); |
| | | const data = Buffer.from(text, "utf-8"); |
| | | const out = Buffer.alloc(data.length); |
| | | for (let i = 0; i < data.length; i++) { |
| | | out[i] = data[i] ^ key[i % key.length]; |
| | | } |
| | | return "[OBF]" + out.toString("base64"); |
| | | } |
| | | |
| | | function deobfuscate(encoded) { |
| | | if (!encoded) return encoded; |
| | | if (encoded.startsWith("[OBF]")) { |
| | | encoded = encoded.slice(5); |
| | | } |
| | | const key = Buffer.from(getKey(), "utf-8"); |
| | | const data = Buffer.from(encoded, "base64"); |
| | | const out = Buffer.alloc(data.length); |
| | | for (let i = 0; i < data.length; i++) { |
| | | out[i] = data[i] ^ key[i % key.length]; |
| | | } |
| | | return out.toString("utf-8"); |
| | | } |
| | | |
| | | module.exports = { obfuscate, deobfuscate }; |
| | |
| | | "dev": "node index.js", |
| | | "build": "node scripts/build.js", |
| | | "build:win": "node scripts/build.js --win", |
| | | "build:linux": "node scripts/build.js --linux" |
| | | "build:linux": "node scripts/build.js --linux", |
| | | "decode-log": "node tools/decode-log.js logs/service.2026-05-18.log" |
| | | }, |
| | | "pkg": { |
| | | "assets": [ |
| | |
| | | readmeLines.push("──────────────────────────────────────────────────────"); |
| | | readmeLines.push(""); |
| | | readmeLines.push("【全局参数】"); |
| | | readmeLines.push(" pollIntervalMs = 10000 // K 轮询间隔(毫秒)"); |
| | | readmeLines.push(" pollIntervalMs = 10000 // 轮询间隔(毫秒)"); |
| | | readmeLines.push(" connectTimeoutMs = 5000 // TCP 握手超时(毫秒)"); |
| | | readmeLines.push(" reconnectBaseMs = 3000 // 重连退避基数(毫秒)"); |
| | | readmeLines.push(" reconnectMaxMs = 60000 // 重连退避上限(毫秒)"); |
| New file |
| | |
| | | "use strict"; |
| | | |
| | | const fs = require("fs"); |
| | | const { deobfuscate } = require("../lib/obfuscate"); |
| | | |
| | | function decode(text) { |
| | | return text.replace(/\[OBF\][A-Za-z0-9+/=]+/g, (match) => { |
| | | return deobfuscate(match); |
| | | }); |
| | | } |
| | | |
| | | function decodeStdin() { |
| | | const chunks = []; |
| | | process.stdin.setEncoding("utf-8"); |
| | | process.stdin.on("data", (chunk) => chunks.push(chunk)); |
| | | process.stdin.on("end", () => { |
| | | process.stdout.write(decode(chunks.join(""))); |
| | | }); |
| | | } |
| | | |
| | | function decodeFile(filePath) { |
| | | const content = fs.readFileSync(filePath, "utf-8"); |
| | | process.stdout.write(decode(content)); |
| | | } |
| | | |
| | | if (process.argv.includes("--stdin")) { |
| | | decodeStdin(); |
| | | } else if (process.argv[2]) { |
| | | decodeFile(process.argv[2]); |
| | | } else { |
| | | console.log("用法:"); |
| | | console.log(" node tools/decode-log.js <日志文件> 解码日志文件"); |
| | | console.log(" node tools/decode-log.js --stdin 解码标准输入"); |
| | | console.log(""); |
| | | console.log("密钥: JMS_LOG_KEY 环境变量 (未设置时使用默认值)"); |
| | | } |