From c0d7622132c4d01476ed7f600eab8da6f7190f04 Mon Sep 17 00:00:00 2001 From: zhangzhen Date: Tue, 16 Jun 2026 09:22:08 +0800 Subject: [PATCH] 0616 --- manifest.json | 2 +- service/serialService.js | 3 + utils/tcp485.js | 396 +++++++++++++++++++++++++-------------- 3 files changed, 258 insertions(+), 143 deletions(-) diff --git a/manifest.json b/manifest.json index 90814f6..52ff920 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "appid" : "__UNI__5A0A7D6", "description" : "", "versionName" : "1.2.7", - "versionCode" : 123, + "versionCode" : 125, "transformPx" : false, /* 5+App特有相关 */ "app-plus" : { diff --git a/service/serialService.js b/service/serialService.js index bac4d23..b9b7fd7 100644 --- a/service/serialService.js +++ b/service/serialService.js @@ -138,6 +138,9 @@ const serialService = { } this.tcp485.setHost(host, port) + this.tcp485.onLog((name, action) => { + this.addLog(name, action) + }) try { const state = await this.tcp485.open() diff --git a/utils/tcp485.js b/utils/tcp485.js index 20a2d27..23d7f69 100644 --- a/utils/tcp485.js +++ b/utils/tcp485.js @@ -1,41 +1,25 @@ /** * TCP485 - 基于 plus.android 直接调用 Java 原生 Socket * - * 无需任何原生插件/UTS插件,纯 JS 调用 java.net.Socket - * 发送/接收均为纯二进制,不会自动添加 0A 等多余字符 - * - * 发送: hexStr → ByteArrayOutputStream → toByteArray() → write(byte[]) 一次性发出完整帧 - * 接收: available() + 逐字节 read() → 缓冲区累积 → 100ms超时判定帧结束 + * 特点: + * 1. 纯 JS 调用 java.net.Socket + * 2. 发送/接收均为纯二进制,不自动追加 0A 等字符 + * 3. 支持 Modbus 帧切分 + * 4. 支持“最后接收时间”判定离线 + * 5. 支持断线自动重连 + * 6. 保留心跳机制,但心跳改为“接收心跳” */ import storage from '@/utils/storage.js' -// ==================== Java 类引用(懒加载,仅Android) ==================== +// ==================== Java 类引用(懒加载,仅 Android) ==================== -var _Socket = null // java.net.Socket -var _InetSocketAddress = null // java.net.InetSocketAddress -var _BAOS = null // java.io.ByteArrayOutputStream +var _Socket = null +var _InetSocketAddress = null +var _BAOS = null -// ==================== Modbus 帧切分工具(基于功能码帧长推断,无CRC依赖)==================== +// ==================== Modbus 帧切分工具(基于功能码帧长推断,无 CRC 依赖)==================== -/** - * 从缓冲区中提取所有完整的 Modbus 帧 - * - * 切帧策略(不依赖CRC校验): - * 1. 从位置0开始解析帧头: 地址 + 功能码 - * 2. 根据功能码 + 字节计数字段 推算整帧长度 - * 3. 数据足够则直接切出,继续处理剩余数据 - * 4. 无法推断帧长 → 跳过1字节继续试探 - * - * 支持的功能码: - * 0x03/0x04: 读寄存器响应 → addr(1)+func(1)+byteCount(1)+data(N)+tail(2) = 5+N - * 0x06/0x10: 写寄存器响应 → 固定8字节 - * 0x01/0x02: 读线圈/离散输入 → addr(1)+func(1)+byteCount(1)+data(N)+tail(2) = 5+N - * 0x05/0x0F: 写线圈响应 → 固定8字节 - * - * @param {number[]} buffer - 接收缓冲区 - * @returns {{ frames: number[][], remaining: number[] }} - */ function extractModbusFrames(buffer) { var frames = [] var pos = 0 @@ -44,26 +28,18 @@ function extractModbusFrames(buffer) { var func = buffer[pos + 1] var frameLen = -1 - // 根据功能码推算帧长度 if (func === 0x03 || func === 0x04) { - // 读保持/输入寄存器: - // 响应帧: addr+func+byteCount(N)+data(N*2)+校验(2) = 5+N - // 请求帧: addr+func+起始地址(2)+寄存器数(2)+CRC = 固定8字节 if (pos + 2 < buffer.length) { var byteCount = buffer[pos + 2] if (byteCount >= 1 && byteCount <= 125) { - // 有效 byteCount → 响应帧 - frameLen = 3 + byteCount + 2 // 5 + N + frameLen = 3 + byteCount + 2 } else if (pos + 8 <= buffer.length) { - // byteCount无效 → 按请求帧固定8字节处理 frameLen = 8 } } } else if (func === 0x06 || func === 0x10) { - // 写单/多寄存器响应/异常: 固定8字节 frameLen = 8 } else if (func === 0x01 || func === 0x02) { - // 读线圈/离散输入响应: addr+func+byteCount+data+校验 if (pos + 2 < buffer.length) { var bc = buffer[pos + 2] if (bc >= 1) { @@ -71,16 +47,13 @@ function extractModbusFrames(buffer) { } } } else if (func === 0x05 || func === 0x0F) { - // 写单个/多个线圈响应: 固定8字节 frameLen = 8 } - // 能确定帧长且数据够用 → 直接切帧 if (frameLen > 0 && pos + frameLen <= buffer.length) { frames.push(buffer.slice(pos, pos + frameLen)) pos += frameLen } else { - // 无法推断 或 数据不够 → 跳过1字节继续试探 pos++ } } @@ -91,19 +64,34 @@ function extractModbusFrames(buffer) { // ==================== TCP485 类 ==================== class TCP485 { - - // ---- 构造 & 属性 ---- - constructor() { + // 连接状态 this.isOpen = false this.isConnecting = false + + // 回调 this.readCallback = null + + // 重连 this.reconnectTimer = null this.reconnectDelay = 3000 + this.maxReconnectDelay = 30000 + this._reconnectAttempt = 0 + this._manualClosed = false + + // “接收心跳” this.heartbeatTimer = null + this.heartbeatCheckInterval = 8000 + this.noDataTimeout = 18000 // 18秒没有收到任何数据,就认为离线(ms) + this.recvGraceTimeout = 5000 // 刚连接后的宽限期,避免设备启动慢就误判 (ms) + + // 连接信息 this.host = '' this.port = 0 + // 最近接收时间 + this.lastRecvTime = 0 + // Promise 控制 this._connectResolve = null this._connectReject = null @@ -115,9 +103,12 @@ class TCP485 { // 轮询相关 this._pollTimer = null + + // 日志回调(供上层持久化) + this.onLogCallback = null } - // ---- 公共接口 ---- + // ==================== 公共接口 ==================== setHost(host, port) { this.host = host @@ -127,50 +118,51 @@ class TCP485 { open() { var self = this return new Promise(function(resolve, reject) { - if (self.isOpen) { resolve(true); return } - if (self.isConnecting) { reject(new Error('连接中')); return } + if (self.isOpen) { + resolve(true) + return + } + if (self.isConnecting) { + reject(new Error('连接中')) + return + } var cfg = storage.get('rs485Tcp') || {} var host = self.host || cfg.host var port = self.port || cfg.port + if (!host || !port) { uni.showToast({ title: '请配置TCP', icon: 'none' }) reject(new Error('缺配置')) return } + self._manualClosed = false self.isConnecting = true self._connectResolve = resolve self._connectReject = reject - console.log('[TCP485] 连接 ' + host + ':' + port) + console.log('[TCP485] 连接 ' + host + ':' + port) self._openSocket(host, port) }) } close() { console.log('[TCP485] 关闭') + this._manualClosed = true + this._stopHeartbeat() - if (this.reconnectTimer) { - clearTimeout(this.reconnectTimer) - this.reconnectTimer = null - } - try { - if (this._outputStream) this._outputStream.close() - if (this._inputStream) this._inputStream.close() - if (this._socket) this._socket.close() - } catch(e) {} - this._socket = null - this._outputStream = null - this._inputStream = null - if (this._pollTimer) { - clearInterval(this._pollTimer) - this._pollTimer = null - } + this._stopPolling() + this._clearReconnectTimer() + + this._safeCloseAll() + this.isOpen = false this.isConnecting = false this._connectResolve = null this._connectReject = null + this.lastRecvTime = 0 + this._reconnectAttempt = 0 } sendDataString(hexStr) { @@ -189,7 +181,18 @@ class TCP485 { this.readCallback = null } - // ---- 工具方法 ---- + onLog(callback) { + this.onLogCallback = callback || null + } + + _writeLog(action) { + console.log('[TCP485]', action) + if (this.onLogCallback) { + try { this.onLogCallback('TCP485', action) } catch (e) {} + } + } + + // ==================== 工具方法 ==================== byte2HexString(b) { if (!b) return '' @@ -200,25 +203,84 @@ class TCP485 { return h } + _touchRecvTime() { + this.lastRecvTime = Date.now() + } + + _safeCloseAll() { + try { + if (this._outputStream) this._outputStream.close() + } catch (e) {} + try { + if (this._inputStream) this._inputStream.close() + } catch (e) {} + try { + if (this._socket) this._socket.close() + } catch (e) {} + + this._socket = null + this._outputStream = null + this._inputStream = null + } + + _stopPolling() { + if (this._pollTimer) { + clearInterval(this._pollTimer) + this._pollTimer = null + } + } + + _clearReconnectTimer() { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + _scheduleReconnect() { + if (this._manualClosed) return + if (this.reconnectTimer) return + + var self = this + // var delay = Math.min(this.reconnectDelay * Math.pow(2, this._reconnectAttempt), this.maxReconnectDelay) + var delay = this.reconnectDelay + + this._writeLog(delay + 'ms后重连,第' + (this._reconnectAttempt + 1) + '次') + this.reconnectTimer = setTimeout(function() { + self.reconnectTimer = null + if (self._manualClosed) return + if (self.isOpen || self.isConnecting) return + + self.open() + .then(function() { + self._reconnectAttempt = 0 + }) + .catch(function(err) { + console.warn('[TCP485] 重连失败:', err && err.message ? err.message : err) + self._reconnectAttempt++ + self._scheduleReconnect() + }) + }, delay) + } + // ==================== 内部实现 ==================== - // Java 类懒加载 _initJavaClasses() { if (_Socket) return true try { - _Socket = plus.android.importClass("java.net.Socket") - _InetSocketAddress = plus.android.importClass("java.net.InetSocketAddress") - _BAOS = plus.android.importClass("java.io.ByteArrayOutputStream") + _Socket = plus.android.importClass('java.net.Socket') + _InetSocketAddress = plus.android.importClass('java.net.InetSocketAddress') + _BAOS = plus.android.importClass('java.io.ByteArrayOutputStream') return true - } catch(e) { + } catch (e) { console.error('[TCP485] Java类加载失败:', e) return false } } - // 建立 TCP 连接 _openSocket(host, port) { var self = this + if (!this._initJavaClasses()) { self._onConnectDone(false, '仅支持Android平台') return @@ -233,74 +295,93 @@ class TCP485 { this._outputStream = this._socket.getOutputStream() this._inputStream = this._socket.getInputStream() - console.log('[TCP485] 连接成功') + this._writeLog('连接成功') this.isOpen = true + this.isConnecting = false + // 连接成功后,初始化“最后收到数据时间” + // 这里不直接置 0,避免刚连上就被误判离线 + this.lastRecvTime = Date.now() + + this._stopPolling() + this._stopHeartbeat() this._startPolling() this._startHeartbeat() + this._reconnectAttempt = 0 this._onConnectDone(true) - } catch(e) { + } catch (e) { console.error('[TCP485] 连接异常:', String(e)) + this._safeCloseAll() + this.isOpen = false + this.isConnecting = false this._onConnectDone(false, String(e)) } } - // 发送:hex字符串 → ByteArrayOutputStream组装 → 原生byte[] → 一次性write _send(hexStr) { try { + if (!hexStr || hexStr.length % 2 !== 0) { + throw new Error('hex字符串长度必须为偶数') + } + var len = hexStr.length / 2 var baos = new _BAOS() + for (var i = 0; i < len; i++) { baos.write(parseInt(hexStr.substr(i * 2, 2), 16) & 0xFF) } + var javaBytes = baos.toByteArray() this._outputStream.write(javaBytes) this._outputStream.flush() console.log('[TCP485] 发送: ' + hexStr + ' (' + len + 'B)') return true - } catch(e) { + } catch (e) { console.error('[TCP485] send err:', e) + // 发送失败大概率就是连接已经不可用了 + this._handleDisconnect('发送失败') return false } } - // 接收轮询:available() + 逐字节read() → 缓冲区累积 → 双模式切帧 - // - // 双模式策略: - // 1) 已知Modbus功能码(0x01~0x10等) → 精确帧长推断,立即切帧,零延迟 - // 2) 未知/自定义协议数据 → 20ms短超时作为帧间隔,超时后原样输出给上层 - // 3) 真正的噪声碎片(<4字节且超时) → 丢弃并告警 _startPolling() { var self = this if (self._pollTimer) clearInterval(self._pollTimer) var recvBuffer = [] - var lastRecvTime = 0 + var lastByteTime = 0 - // 非标准帧的帧间超时(ms),比原来100ms短很多,避免粘包 + // 非标准帧的帧间超时 var UNKNOWN_FRAME_TIMEOUT = 20 - // 噪声阈值:小于此字节数且超时的视为干扰丢弃 var NOISE_THRESHOLD = 4 - self._pollTimer = setInterval(function () { + self._pollTimer = setInterval(function() { if (!self.isOpen || !self._inputStream) return + try { var is = self._inputStream var avail = plus.android.invoke(is, 'available') if (avail > 0) { - // 有数据 → 读入缓冲区 + var readCount = 0 + for (var i = 0; i < avail && i < 256; i++) { var b = plus.android.invoke(is, 'read') + if (b === -1) { + self._handleDisconnect('read返回-1') + return + } if (b >= 0) { recvBuffer.push(b & 0xFF) - lastRecvTime = Date.now() + readCount++ + lastByteTime = Date.now() + self._touchRecvTime() // 最底层收到信息后,更新时间戳 } } - // ★ 先尝试精确切帧(已知Modbus格式) + // 先尝试精确切帧(已知 Modbus 格式) var result = extractModbusFrames(recvBuffer) if (result.frames.length > 0) { for (var f = 0; f < result.frames.length; f++) { @@ -311,72 +392,65 @@ class TCP485 { } recvBuffer = result.remaining } - // remaining 可能包含无法识别的非标准帧数据,走下面超时兜底 } else if (recvBuffer.length > 0) { - // 无新数据 → 检查是否超时 - var idleMs = Date.now() - lastRecvTime + // 无新数据,但缓冲区里还有残留,按超时兜底切 + var idleMs = Date.now() - lastByteTime if (idleMs > UNKNOWN_FRAME_TIMEOUT) { if (recvBuffer.length >= NOISE_THRESHOLD) { - // 足够长 → 当作非标准/自定义协议帧,原样输出给上层 var hexStr = self.byte2HexString(recvBuffer) console.log('[TCP485] 收到(非标准): ' + hexStr + ' (' + recvBuffer.length + 'B)') if (self.readCallback) self.readCallback(hexStr) } else { - // 太短 → 视为噪声碎片丢弃 - console.warn('[TCP485] 丢弃噪声: ' + self.byte2HexString(recvBuffer) - + ' (' + recvBuffer.length + 'B)') + console.warn('[TCP485] 丢弃噪声: ' + self.byte2HexString(recvBuffer) + ' (' + recvBuffer.length + 'B)') } recvBuffer = [] } } - } catch(e) { + } catch (e) { console.warn('[TCP485] 读异常:', e) + self._handleDisconnect(String(e)) } }, 10) } - // 心跳:30s检查连接是否存活 + // 保留“心跳”,但心跳改成“接收心跳” + // 规则: + // 1. 只要持续收到数据,就认为链路存活 + // 2. 一段时间没有收到任何数据,就认为离线,主动断开并重连 + // 3. 这个逻辑不依赖设备回复心跳包,适合“只上报不应答”的485场景 _startHeartbeat() { this._stopHeartbeat() - var s = this - this.heartbeatTimer = setInterval(function () { - if (s.isOpen && s._socket && s._outputStream) { - try { - plus.android.invoke(s._inputStream, 'available') - } catch(e) { - s._handleDisconnect() + + var self = this + this.heartbeatTimer = setInterval(function() { + if (!self.isOpen) return + + try { + // 仅作为底层活性辅助检查,不能单独作为“是否在线”的判断 + if (!self._inputStream || !self._socket) { + self._handleDisconnect('socket或输入流为空') + return } + + // 这里不是请求式心跳,而是“接收超时心跳” + var now = Date.now() + + // 连接后宽限一小段时间,避免设备刚启动时误判 + if (self.lastRecvTime === 0) return + + var idleMs = now - self.lastRecvTime + if (idleMs < self.recvGraceTimeout) return + + if (idleMs > self.noDataTimeout) { + self._writeLog('断开: 接收超时') + self._handleDisconnect('接收超时') + return + } + } catch (e) { + console.warn('[TCP485] 心跳检查异常:', e) + self._handleDisconnect(String(e)) } - }, 30000) - } - - // ---- 回调 & 断线处理 ---- - - _onConnectDone(success, msg) { - if (!this._connectResolve) return - this.isConnecting = false - if (success) { - this._connectResolve(true) - } else { - console.error('[TCP485] 连接失败:', msg) - this._connectReject(new Error(msg || '连接失败')) - } - this._connectResolve = null - this._connectReject = null - } - - _handleDisconnect() { - if (!this.isOpen) return - console.log('[TCP485] 断开') - this.isOpen = false - this.isConnecting = false - this._stopHeartbeat() - if (this._pollTimer) { - clearInterval(this._pollTimer) - this._pollTimer = null - } - uni.showToast({ title: 'TCP断开', icon: 'none' }) - this._reconnect() + }, this.heartbeatCheckInterval) } _stopHeartbeat() { @@ -386,16 +460,54 @@ class TCP485 { } } + _onConnectDone(success, msg) { + if (!this._connectResolve && !this._connectReject) return + + this.isConnecting = false + + if (success) { + this._connectResolve && this._connectResolve(true) + } else { + console.error('[TCP485] 连接失败:', msg) + this._connectReject && this._connectReject(new Error(msg || '连接失败')) + + // 连接失败也进入重连 + if (!this._manualClosed) { + this._reconnectAttempt++ + this._scheduleReconnect() + } + } + + this._connectResolve = null + this._connectReject = null + } + + _handleDisconnect(reason) { + if (!this.isOpen && !this.isConnecting) return + + this._writeLog('断开: ' + (reason || '')) + + this.isOpen = false + this.isConnecting = false + + this._stopHeartbeat() + this._stopPolling() + this._safeCloseAll() + + uni.showToast({ title: 'TCP断开', icon: 'none' }) + + if (!this._manualClosed) { + this._reconnectAttempt++ + this._scheduleReconnect() + } + } + _reconnect() { - if (this.reconnectTimer) return - var s = this - var d = this.reconnectDelay - console.log('[TCP485] ' + d + 'ms后重连') - this.reconnectTimer = setTimeout(function() { - s.reconnectTimer = null - if (!s.isOpen && !s.isConnecting) s.open().catch(function() {}) - }, d) + // 保留这个方法名,兼容外部可能还会调用 + if (this._manualClosed) return + this._reconnectAttempt++ + this._scheduleReconnect() } } -export default new TCP485() +export default new TCP485() \ No newline at end of file