diff --git a/manifest.json b/manifest.json index a971765..90814f6 100644 --- a/manifest.json +++ b/manifest.json @@ -2,8 +2,8 @@ "name" : "endoscope", "appid" : "__UNI__5A0A7D6", "description" : "", - "versionName" : "1.2.6", - "versionCode" : 121, + "versionName" : "1.2.7", + "versionCode" : 123, "transformPx" : false, /* 5+App特有相关 */ "app-plus" : { @@ -91,8 +91,7 @@ } } } - }, - "nativePlugins" : {} + } }, /* 快应用特有相关 */ "quickapp" : {}, diff --git a/pages/modbus/modbus.vue b/pages/modbus/modbus.vue index 9ecfda9..496f76a 100644 --- a/pages/modbus/modbus.vue +++ b/pages/modbus/modbus.vue @@ -1,57 +1,134 @@ @@ -393,11 +519,11 @@ export default { .page{ color: #fff; text-align: left; -} + } :deep(.uni-select){ min-height: 40px; color: rgba(0, 0, 0, .85); -} + } .uni-input{ height: 40px; border: 1px solid #d9d9d9; @@ -406,27 +532,27 @@ export default { text-align: left; padding: 0 6px; white-space: 6px; -} + } :deep(.uni-section){ background-color: transparent; -} + } .page :deep(.uni-section .uni-section-header__content){ line-height: 20px; font-weight: 800; text-align: right; -} + } :deep(.uni-section .uni-section-header){ padding: 10px; -} + } .ant-btn:hover, .ant-btn:focus, .ant-btn:active{ color: rgba(0, 0, 0, 0.85); -} + } .receive-item{ padding-left: 10px; height: 400px; overflow-y: auto; -} + } diff --git a/pages/setting.vue b/pages/setting.vue index c6d7f0e..3401deb 100644 --- a/pages/setting.vue +++ b/pages/setting.vue @@ -40,6 +40,7 @@ import PageHeader from '@/components/PageHeader.vue' import PageFooter from '@/components/PageFooter.vue' import Notice from '@/components/Notice.vue'; import storage from '@/utils/storage.js'; +import * as db from '@/db/sqlite.js' import * as Api from '@/api/index.js' export default { components: { diff --git a/service/serialService.js b/service/serialService.js index ade2116..bac4d23 100644 --- a/service/serialService.js +++ b/service/serialService.js @@ -1,7 +1,6 @@ /** * 全局串口通信服务 - * 从 start.vue 提取,脱离 Vue 组件生命周期 - * 解决 Android 10 后台 Native→JS Bridge 回调被阻塞导致的数据中断问题 + * 支持双模式:RS485串口直连 / TCP网口转485(塔石LAN869) */ import * as db from '@/db/sqlite.js' @@ -12,28 +11,35 @@ import * as Api from '@/api/index.js' import beeper from '@/utils/beeper.js' import store from '@/store/index.js' import wakeLock from '@/utils/wakeLock.js' +import tcp485 from '@/utils/tcp485.js' const serialService = { RS485: null, RS232: null, - timer: null, // 485轮询定时器 (T2) - taskTimer: null, // 业务任务定时器 (T3) - heartbeatTimer: null, // 心跳检测定时器 (T1) + tcp485: null, + timer: null, + taskTimer: null, + heartbeatTimer: null, last485DataTime: null, reconnectCount: 0, maxReconnect: 5, keepRunning: false, + get mode485() { + return storage.get('rs485Mode') || 'serial' + }, + // ========== 公开入口 ========== - /** - * 初始化串口服务(App.onLaunch 调用) - * 获取串口实例 → 初始化存储 → 打开双串口 → 启动轮询/心跳/Watcher/保活 - */ init() { - - this.RS485 = getApp().globalData.RS485 + if (this.mode485 === 'tcp') { + this.tcp485 = tcp485 + console.log('[serialService] TCP485模式') + } else { + this.RS485 = getApp().globalData.RS485 + console.log('[serialService] RS485串口模式') + } this.RS232 = getApp().globalData.RS232 this._initStorage() @@ -41,27 +47,19 @@ const serialService = { this._initDB() try { - this.startRS485() + this._startRS485() this.startRS232() } catch (e) { - console.error('[serialService] 初始化串口失败:', e) - uni.showToast({ title: '初始化串口失败', icon: 'error' }) + console.error('[serialService] 初始化通信失败:', e) + uni.showToast({ title: '初始化通信失败', icon: 'error' }) } this._initWatchers() - - // 申请WakeLock保持CPU活跃,防止后台Bridge回调被阻塞 wakeLock.acquire() - this.keepRunning = true console.log('[serialService] 已启动') - }, - /** - * 完全停止(去 modbus 等需要独占串口的场景调用) - * stopSerialPorts + 停止保活 - */ shutdown() { this.keepRunning = false this.stopHeartbeatIfNeeded() @@ -71,34 +69,41 @@ const serialService = { if (this.taskTimer) { clearInterval(this.taskTimer); this.taskTimer = null } cmd.clearQueue() wakeLock.release() - this.addLog('串口通信', '服务已停止(shutdown)') + this.addLog('串口通信', '服务已停止') }, - /** - * 从 modbus 返回后重启 - */ async restart() { - if (!this.RS485 || !this.RS232) { - this.RS485 = getApp().globalData.RS485 - this.RS232 = getApp().globalData.RS232 + if (this.mode485 === 'tcp') { + this.tcp485 = tcp485 + } else { + if (!this.RS485) this.RS485 = getApp().globalData.RS485 } + if (!this.RS232) this.RS232 = getApp().globalData.RS232 - // 重启前清理残留队列,避免旧任务使用过期串口引用 cmd.clearQueue() try { - this.startRS485() + await this._startRS485() this.startRS232() this.keepRunning = true wakeLock.acquire() - this.addLog('485通信', '服务已重启(restart)') + this.addLog('485通信', '服务已重启') } catch (e) { - uni.showToast({ title: '重启串口失败', icon: 'error' }) + uni.showToast({ title: '重启通信失败', icon: 'error' }) } }, // ========== 串口启停 ========== - startRS485() { + + async _startRS485() { + if (this.mode485 === 'tcp') { + await this._startRS485_TCP() + } else { + this._startRS485_Serial() + } + }, + + _startRS485_Serial() { let rs485Conf = storage.get('rs485') || { path: '/dev/ttyUSB2', baudrate: 9600 } this.RS485.setPath(rs485Conf.path) this.RS485.setBaudrate(rs485Conf.baudrate) @@ -113,16 +118,8 @@ const serialService = { this.addLog('485回调异常', e.message || e) } }) - // 重置并启动轮询定时器 clearInterval(this.timer) this.readData() - - // 心跳检测 - // this.startHeartbeat() - - // if (this.reconnectCount > 0) { - // this.reconnectCount-- - // } } else { this.stopHeartbeatIfNeeded() uni.showToast({ title: '485打开失败', icon: 'error' }) @@ -130,7 +127,43 @@ const serialService = { } }, - async startRS232() { + async _startRS485_TCP() { + let rs485Tcp = storage.get('rs485Tcp') || {} + const host = rs485Tcp.host + const port = rs485Tcp.port + + if (!host || !port) { + uni.showToast({ title: '请先配置TCP参数', icon: 'none' }) + throw new Error('[TCP485] 缺少TCP配置') + } + + this.tcp485.setHost(host, port) + + try { + const state = await this.tcp485.open() + if (state) { + uni.showToast({ title: 'TCP已连接', icon: 'none' }) + this.tcp485.onStartAutoReadData((hexStr) => { + try { + this.handle485HexData(hexStr) + } catch (e) { + this.addLog('TCP485回调异常', e.message || e) + } + }) + clearInterval(this.timer) + this.readData() + console.log('[TCP485] 连接成功', host + ':' + port) + } else { + throw new Error('[TCP485] 连接失败') + } + } catch (error) { + this.stopHeartbeatIfNeeded() + uni.showToast({ title: 'TCP连接失败', icon: 'error' }) + throw new Error('[重连] TCP485 连接失败: ' + (error.message || error)) + } + }, + + startRS232() { let rs232Conf = storage.get('rs232') || { path: '/dev/ttyUSB1', baudrate: 115200 } this.RS232.setPath(rs232Conf.path) this.RS232.setBaudrate(rs232Conf.baudrate) @@ -153,11 +186,18 @@ const serialService = { }, stopRS485() { - // this.stopHeartbeatIfNeeded() - this.addLog('485通信', '串口关闭') - if (this.RS485) { - this.RS485.stopReadPortData() - this.RS485.close() + if (this.mode485 === 'tcp') { + this.addLog('485通信', 'TCP连接关闭') + if (this.tcp485) { + this.tcp485.stopReadPortData() + this.tcp485.close() + } + } else { + this.addLog('485通信', '串口关闭') + if (this.RS485) { + this.RS485.stopReadPortData() + this.RS485.close() + } } }, @@ -168,7 +208,8 @@ const serialService = { } }, - // ========== 数据轮询 & 心跳 ========== + // ========== 数据轮询 ========== + readData() { this.timer = setInterval(async () => { await cmd.getTemp() @@ -178,10 +219,7 @@ const serialService = { }, startHeartbeat() { - if (this.heartbeatTimer) { - clearInterval(this.heartbeatTimer) - this.heartbeatTimer = null - } + if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null } this.addLog('485通信', '心跳检测已启动') this.heartbeatTimer = setInterval(() => { this.check485Heartbeat() @@ -195,10 +233,9 @@ const serialService = { this.addLog('485通信', '心跳检测已停止') }, - // 检测485心跳 check485Heartbeat() { if (!this.last485DataTime) { - console.log('[心跳] 等待首次485数据...') + console.log('[心跳] 等待首次数据...') return } const now = Date.now() @@ -207,105 +244,132 @@ const serialService = { if (now - this.last485DataTime > timeout) { if (this.reconnectCount >= this.maxReconnect) { this.addLog('485通信', '重连' + this.maxReconnect + '次失败,请检查硬件') - uni.showToast({ - title: '485通信中断,请检查硬件连接', - icon: 'none', - duration: 3000 - }) + uni.showToast({ title: '485通信中断,请检查硬件连接', icon: 'none', duration: 3000 }) return } this.addLog('485通信', '数据中断(第' + (this.reconnectCount + 1) + '次重连)') - uni.showToast({ - title: '485通信中断,正在恢复...', - icon: 'none', - duration: 2000 - }) - - this.resetSerialPorts() + uni.showToast({ title: '485通信中断,正在恢复...', icon: 'none', duration: 2000 }) + this.resetRS485Only() } }, - // ========== 统一重连 ========== + // ========== 485隔离重连(不影响RS232) ========== + + async resetRS485Only() { + try { + this.reconnectCount++ + + // 只清485相关的定时器(不清taskTimer!RS232业务继续运行) + if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null } + if (this.timer) { clearInterval(this.timer); this.timer = null } + + // 只停485通信(完全不动RS232) + if (this.mode485 === 'tcp') { + if (this.tcp485) { this.tcp485.stopReadPortData(); this.tcp485.close() } + } else { + if (this.RS485) { this.RS485.stopReadPortData(); this.RS485.close() } + } + + cmd.clearQueue() + + const backoff = Math.min(1000 * Math.pow(2, this.reconnectCount - 1), 30000) + const waitTime = Math.max(backoff, 2500) + console.log('[485重连] 第' + this.reconnectCount + '次, 等待' + waitTime + 'ms (RS232不受影响)') + await delay(waitTime) + + this.last485DataTime = null + + await this._startRS485() + + this.addLog('485通信', '485重连完成(第' + this.reconnectCount + '次)[RS232未受影响]') + } catch (error) { + this.addLog('485通信错误', error.message || '485重置失败') + await delay(5000) + if (this.reconnectCount < this.maxReconnect) { + this.resetRS485Only() + } else { + this.addLog('串口通信', '485重连' + this.maxReconnect + '次均失败,请检查485线路') + uni.showToast({ title: '485通信异常', icon: 'none', duration: 3000 }) + this.reconnectCount = 0 + } + } + }, + + // ========== 统一重连(全局故障用) ========== + async resetSerialPorts() { try { this.reconnectCount++ - // Step 1: 停止所有定时器 if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null } if (this.timer) { clearInterval(this.timer); this.timer = null } if (this.taskTimer) { clearInterval(this.taskTimer); this.taskTimer = null } - // Step 2: 停止两个串口 - if (this.RS485) { this.RS485.stopReadPortData(); this.RS485.close() } + // 停止485通信 + if (this.mode485 === 'tcp') { + if (this.tcp485) { this.tcp485.stopReadPortData(); this.tcp485.close() } + } else { + if (this.RS485) { this.RS485.stopReadPortData(); this.RS485.close() } + } + // 停止RS232 if (this.RS232) { this.RS232.stopReadPortData(); this.RS232.close() } - // Step 3: 清空队列 cmd.clearQueue() - // Step 4: 等待端口释放(指数退避) const backoff = Math.min(1000 * Math.pow(2, this.reconnectCount - 1), 30000) const waitTime = Math.max(backoff, 2500) - console.log(`[重连] 第${this.reconnectCount}次, 等待${waitTime}ms...`) + console.log('[重连] 第' + this.reconnectCount + '次, 等待' + waitTime + 'ms...') await delay(waitTime) - // Step 5: 重置时间戳 this.last485DataTime = null - // Step 6: 依次重启(失败时抛出异常触发重试) - if (!this.startRS485()) { - throw new Error('[重连] RS485 启动失败') - } + await this._startRS485() await delay(500) - if (!this.startRS232()) { - throw new Error('[重连] RS232 启动失败') - } - this.addLog('串口通信', `双串口重连完成(第${this.reconnectCount}次)`) + this.startRS232() + + this.addLog('串口通信', '通信重连完成(第' + this.reconnectCount + '次)') } catch (error) { this.addLog('串口通信错误', error.message || '重置失败') await delay(5000) if (this.reconnectCount < this.maxReconnect) { this.resetSerialPorts() } else { - this.addLog('串口通信', `重连${this.maxReconnect}次均失败,即将自动重启应用`) + this.addLog('串口通信', '重连' + this.maxReconnect + '次均失败,即将自动重启应用') uni.showToast({ title: '通信异常,3秒后重启', icon: 'none', duration: 3000 }) - - setTimeout(() => { - plus.runtime.restart() - }, 3000) + setTimeout(() => { plus.runtime.restart() }, 3000) } } }, // ========== 数据处理 ========== + handle485HexData(hex) { this.last485DataTime = Date.now() - - let data = cmd.parse485Data(hex) - if (data.hasOwnProperty('temp')) { - store.state.sensor.temp = data.temp + let d = cmd.parse485Data(hex) + if (d.hasOwnProperty('temp')) { + store.state.sensor.temp = d.temp } - if (data.hasOwnProperty('humi')) { - store.state.sensor.humi = data.humi + if (d.hasOwnProperty('humi')) { + store.state.sensor.humi = d.humi } - if (data.hasOwnProperty('pressure')) { - let pressure = parseFloat(data.pressure) + parseInt(store.state.run.pressureCom) + if (d.hasOwnProperty('pressure')) { + let pressure = parseFloat(d.pressure) + parseInt(store.state.run.pressureCom || 0) store.state.sensor.pressure = pressure } - // 门状态上报处理 - if (data.action == 'LeftDoor' && data.status == 'closed') { + if (d.action === 'LeftDoor' && d.status === 'closed') { cmd.LeftDoor(false) uni.showToast({ title: '左门已关闭', icon: 'none' }) this.addLog('左门触点触发', '左门已关闭') } - if (data.action == 'RightDoor' && data.status == 'closed') { + if (d.action === 'RightDoor' && d.status === 'closed') { cmd.RightDoor(false) uni.showToast({ title: '右门已关闭', icon: 'none' }) this.addLog('右门触点触发', '右门已关闭') } - if (data.action == 'door' && data.status == 'closed') { + if (d.action === 'door' && d.status === 'closed') { try { cmd.LeftDoor(false) cmd.RightDoor(false) @@ -317,19 +381,19 @@ const serialService = { }, handle232HexData(hex) { - let data = cmd.parse232dData(hex) - if (data === {}) return + let d = cmd.parse232dData(hex) + if (!d || Object.keys(d).length === 0) return - if (data.hasOwnProperty('number')) { - uni.$emit('ic', data.number) + if (d.hasOwnProperty('number')) { + uni.$emit('ic', d.number) - if (store.state.relay.door == false) { + if (store.state.relay.door === false) { // 门关闭状态 - this.checkPersonCard(data.number).then(res => { + this.checkPersonCard(d.number).then(res => { if (res) { - uni.$emit('showDoorSelect', data.number) + uni.$emit('showDoorSelect', d.number) } else { - this.checkEndoCard(data.number).then(res => { + this.checkEndoCard(d.number).then(res => { if (res) { uni.showToast({ title: '请先开门', icon: 'none' }) } else { @@ -340,13 +404,13 @@ const serialService = { }) } else { // 门打开状态 - this.checkPersonCard(data.number).then(res => { + this.checkPersonCard(d.number).then(res => { if (res) { - uni.$emit('showDoorSelect', data.number) + uni.$emit('showDoorSelect', d.number) } else { - this.checkEndoCard(data.number).then(res => { + this.checkEndoCard(d.number).then(res => { if (res) { - uni.$emit('showActionSelect', data.number) + uni.$emit('showActionSelect', d.number) } else { uni.showToast({ title: '无效卡片', icon: 'none' }) } @@ -357,7 +421,8 @@ const serialService = { } }, - // ========== 业务任务定时器 ========== + // ========== 业务任务 ========== + startAutoTask() { this.taskTimer = setInterval(() => { this.checkDoorOpenTimer() @@ -377,25 +442,25 @@ const serialService = { if (group[i].status) { let start = group[i].start.h + ':' + group[i].start.m let end = group[i].end.h + ':' + group[i].end.m - if (compareTimes(start, time) == 1 && compareTimes(time, end) == 1) { + if (compareTimes(start, time) === 1 && compareTimes(time, end) === 1) { isStart = true break } } } - if (store.state.relay.disinfect == false && isStart == true) { + if (store.state.relay.disinfect === false && isStart === true) { cmd.Disinfect(true) await delay(100) cmd.Wind(true) } - if (store.state.relay.disinfect == true && isStart == false && store.state.autoDisinfect == true) { + if (store.state.relay.disinfect === true && isStart === false && store.state.autoDisinfect === true) { cmd.Disinfect(false) } }, async vacuumTask() { let start = store.state.timer.vacuumStart - if (start != '') { + if (start !== '') { let startM = getTimeDifference(start, formatDateTime()) let runTime = store.state.run.vacuumRunTime if (startM.minutes >= runTime) { @@ -404,10 +469,10 @@ const serialService = { } } let end = store.state.timer.vacuumEnd - if (end != '') { + if (end !== '') { let endH = getTimeDifference(end, formatDateTime()) let perHour = store.state.run.vacuumPerHour - if (endH.hour >= perHour) { + if (endH.hours >= perHour) { store.state.timer.vacuumEnd = '' cmd.Vacuum(true) await delay(100) @@ -427,16 +492,17 @@ const serialService = { checkDoorOpenTimer() { let openTime = store.state.timer.door - if (openTime != '' && store.state.clean == false) { + if (openTime !== '' && store.state.clean === false) { let diff = getTimeDifference(formatDateTime(openTime), formatDateTime()) if (diff.minutes > 3) { store.state.timer.doorAlert = true uni.$emit('notice', { title: '未关门', content: '请及时关闭' }) } - } + } }, // ========== 门事件 ========== + async openDoorEvent() { await cmd.Light(true) await cmd.Vacuum(false) @@ -450,6 +516,7 @@ const serialService = { }, // ========== 卡片验证 ========== + async checkPersonCard(ic) { let res1 = await db.selectDataList('user', { ic: ic }) if (res1.length > 0) return true @@ -467,33 +534,27 @@ const serialService = { }, // ========== 日志 ========== + async addLog(name, action) { - try { - await db.addTabItem('log', { name, action }) - } catch (error) {} + try { await db.addTabItem('log', { name, action }) } catch (error) {} }, - // ========== 内部初始化方法 ========== + // ========== 内部初始化 ========== + _initStorage() { let runStorage = storage.get('run') - if (runStorage) { - store.commit('SET_RUN', { ...runStorage }) - } + if (runStorage) store.commit('SET_RUN', { ...runStorage }) let baseStorage = storage.get('base') - if (baseStorage) { - store.commit('SET_BASE', { ...baseStorage }) - } + if (baseStorage) store.commit('SET_BASE', { ...baseStorage }) let config = storage.get('config') - if (config) { - store.state.apiUrl = 'http://' + config.ip + ':' + config.port - } + if (config) store.state.apiUrl = 'http://' + config.ip + ':' + config.port }, _initTime() { store.state.time = formatDateTime() - if (store.state.apiUrl != '') { + if (store.state.apiUrl !== '') { Api.time().then(res => { - if (res && Date.parse(res) != NaN) { + if (res && !isNaN(Date.parse(res))) { store.state.time = res store.state.connect = true } @@ -502,11 +563,10 @@ const serialService = { }, async _initDB() { - await db.isTable('user').then(res => { if (!res) db.addUserTable() }) - await db.isTable('log').then(res => { if (!res) db.addLogTable() }) - await db.isTable('endo').then(res => { if (!res) db.addEndoTable() }) - let scope = await db.isTable('scope') - if (!scope) { + if (!await db.isTable('user')) await db.addUserTable() + if (!await db.isTable('log')) await db.addLogTable() + if (!await db.isTable('endo')) await db.addEndoTable() + if (!await db.isTable('scope')) { await db.addScopeTable() for (let i = 0; i < 16; i++) { await db.addTabItem('scope', { name: '', ic: '', key: i }) @@ -514,72 +574,59 @@ const serialService = { } }, - /** - * 使用 store.watch 替代 Vue $watch - * 监听器不绑定任何组件生命周期,随服务常驻内存 - */ _initWatchers() { - // 消毒状态监听 store.watch( state => state.relay.disinfect, - (newVal, oldVal) => { - if (newVal == true) { - store.state.timer.disinfect = formatDateTime() - } - if (newVal == false) { + (newVal) => { + if (newVal === true) store.state.timer.disinfect = formatDateTime() + if (newVal === false) { let timer = store.state.timer.disinfect - if (timer != '' && timer != null) { + if (timer) { let diff = getTimeDifference(timer, formatDateTime()) - let local = storage.get('disinfectRun') ? storage.get('disinfectRun') : 0 + let local = storage.get('disinfectRun') || 0 storage.set('disinfectRun', diff.minutes + local) } } } ) - // 风机状态监听 store.watch( state => state.relay.wind, (newVal, oldVal) => { - if (newVal == true && oldVal == false) { - store.state.timer.wind = formatDateTime() - } - if (newVal == false) { + if (newVal === true && oldVal === false) store.state.timer.wind = formatDateTime() + if (newVal === false) { let timer = store.state.timer.wind - if (timer != '' && timer != null) { + if (timer) { let diff = getTimeDifference(timer, formatDateTime()) - let local = storage.get('windRun') ? storage.get('windRun') : 0 + let local = storage.get('windRun') || 0 storage.set('windRun', diff.minutes + local) } } } ) - // 湿度→风机 store.watch( state => state.sensor.humi, (newVal) => { - if (newVal > store.state.run.humi && store.state.relay.wind == false) { + if (newVal > store.state.run.humi && store.state.relay.wind === false) { cmd.Wind(true) } } ) - // 温度→风机 store.watch( state => state.sensor.temp, (newVal) => { - if (newVal > store.state.run.temp && store.state.relay.wind == false) { + if (newVal > store.state.run.temp && store.state.relay.wind === false) { cmd.Wind(true) } } ) - // 门状态监听 store.watch( state => state.relay.door, (newVal, oldVal) => { - if (newVal == true) { + if (newVal === true) { this.openDoorEvent() store.state.timer.door = formatDateTime() } - if (newVal == false) { + if (newVal === false) { this.closeDoorEvent() this.addLog('门', '手动关门') store.state.timer.door = '' @@ -587,24 +634,18 @@ const serialService = { } } ) - // 开门报警 store.watch( state => state.timer.doorAlert, - (newVal, oldVal) => { - if (newVal == true) beeper.play() - if (newVal == false) beeper.stop() + (newVal) => { + if (newVal === true) beeper.play() + if (newVal === false) beeper.stop() } ) - // 真空泵状态 store.watch( state => state.relay.vacuum, - (newVal, oldVal) => { - if (newVal == true) { - store.state.timer.vacuumStart = formatDateTime() - } - if (newVal == false) { - store.state.timer.vacuumEnd = formatDateTime() - } + (newVal) => { + if (newVal === true) store.state.timer.vacuumStart = formatDateTime() + if (newVal === false) store.state.timer.vacuumEnd = formatDateTime() } ) }, diff --git a/utils/cmd.js b/utils/cmd.js index 17866b4..973bcd1 100644 --- a/utils/cmd.js +++ b/utils/cmd.js @@ -15,6 +15,8 @@ // 卡片放入和离开 起始位20, 所有卡片通过同一通道读取,通过数据库比对判断类型 // let RS232 = getApp().globalData.RS232 import store from '@/store/index.js' +import storage from '@/utils/storage.js' +import tcp485 from '@/utils/tcp485.js' // ========== 统一消息队列 START ========== // RS485 和 RS232 共用同一队列,通过 type 字段区分串口实例 @@ -30,6 +32,19 @@ const SEND_DELAY = 150 // 发送间隔150ms const MAX_QUEUE_SIZE = 5 // 队列最大长度 const SEND_TIMEOUT = 1000 // 统一超时1秒 +/** + * 获取当前485通信实例(根据模式动态选择) + * @returns {object} RS485实例或TCP485实例 + */ +const get485Instance = () => { + const mode = storage.get('rs485Mode') || 'serial' + if (mode === 'tcp') { + return tcp485 + } else { + return getApp().globalData.RS485 + } +} + /** * 将任务按优先级插入队列 * 规则: @@ -106,7 +121,7 @@ const sendWithTimeout = (instance, cmd) => { /** * 处理队列中的下一个任务 - * 根据 task.type 自动获取对应串口实例(RS485 或 RS232) + * 根据 task.type 自动获取对应串口实例(RS485/RS232/TCP485) */ const processQueue = async () => { if (isSending || queue.length === 0) { @@ -116,8 +131,17 @@ const processQueue = async () => { const task = queue.shift() try { - // 根据 type 获取对应的串口实例 - let instance = getApp().globalData[task.type] + // 根据 type 获取对应的通信实例 + let instance = null + + if (task.type === 'RS485') { + // 动态选择485通信实例(串口或TCP) + instance = get485Instance() + } else if (task.type === 'RS232') { + // RS232始终使用串口 + instance = getApp().globalData.RS232 + } + if (!instance) { throw new Error(`${task.type} not initialized`) } diff --git a/utils/tcp485.js b/utils/tcp485.js new file mode 100644 index 0000000..20a2d27 --- /dev/null +++ b/utils/tcp485.js @@ -0,0 +1,401 @@ +/** + * TCP485 - 基于 plus.android 直接调用 Java 原生 Socket + * + * 无需任何原生插件/UTS插件,纯 JS 调用 java.net.Socket + * 发送/接收均为纯二进制,不会自动添加 0A 等多余字符 + * + * 发送: hexStr → ByteArrayOutputStream → toByteArray() → write(byte[]) 一次性发出完整帧 + * 接收: available() + 逐字节 read() → 缓冲区累积 → 100ms超时判定帧结束 + */ + +import storage from '@/utils/storage.js' + +// ==================== Java 类引用(懒加载,仅Android) ==================== + +var _Socket = null // java.net.Socket +var _InetSocketAddress = null // java.net.InetSocketAddress +var _BAOS = null // java.io.ByteArrayOutputStream + +// ==================== 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 + + while (pos + 4 <= buffer.length) { + 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 + } 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) { + frameLen = 3 + bc + 2 + } + } + } 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++ + } + } + + return { frames: frames, remaining: buffer.slice(pos) } +} + +// ==================== TCP485 类 ==================== + +class TCP485 { + + // ---- 构造 & 属性 ---- + + constructor() { + this.isOpen = false + this.isConnecting = false + this.readCallback = null + this.reconnectTimer = null + this.reconnectDelay = 3000 + this.heartbeatTimer = null + this.host = '' + this.port = 0 + + // Promise 控制 + this._connectResolve = null + this._connectReject = null + + // Java 原生对象 + this._socket = null + this._outputStream = null + this._inputStream = null + + // 轮询相关 + this._pollTimer = null + } + + // ---- 公共接口 ---- + + setHost(host, port) { + this.host = host + this.port = parseInt(port, 10) + } + + open() { + var self = this + return new Promise(function(resolve, reject) { + 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.isConnecting = true + self._connectResolve = resolve + self._connectReject = reject + console.log('[TCP485] 连接 ' + host + ':' + port) + + self._openSocket(host, port) + }) + } + + close() { + console.log('[TCP485] 关闭') + 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.isOpen = false + this.isConnecting = false + this._connectResolve = null + this._connectReject = null + } + + sendDataString(hexStr) { + if (!this.isOpen || !this._outputStream) { + console.log('[TCP485] 未连接') + return false + } + return this._send(hexStr) + } + + onStartAutoReadData(cb) { + this.readCallback = cb + } + + stopReadPortData() { + this.readCallback = null + } + + // ---- 工具方法 ---- + + byte2HexString(b) { + if (!b) return '' + var h = '' + for (var i = 0; i < b.length; i++) { + h += ('0' + (b[i] & 0xFF).toString(16)).slice(-2).toUpperCase() + } + return h + } + + // ==================== 内部实现 ==================== + + // 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") + return true + } catch(e) { + console.error('[TCP485] Java类加载失败:', e) + return false + } + } + + // 建立 TCP 连接 + _openSocket(host, port) { + var self = this + if (!this._initJavaClasses()) { + self._onConnectDone(false, '仅支持Android平台') + return + } + + try { + this._socket = new _Socket() + var addr = new _InetSocketAddress(host, parseInt(port, 10)) + this._socket.connect(addr, 5000) + this._socket.setTcpNoDelay(true) + + this._outputStream = this._socket.getOutputStream() + this._inputStream = this._socket.getInputStream() + + console.log('[TCP485] 连接成功') + this.isOpen = true + + this._startPolling() + this._startHeartbeat() + + this._onConnectDone(true) + } catch(e) { + console.error('[TCP485] 连接异常:', String(e)) + this._onConnectDone(false, String(e)) + } + } + + // 发送:hex字符串 → ByteArrayOutputStream组装 → 原生byte[] → 一次性write + _send(hexStr) { + try { + 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) { + console.error('[TCP485] send err:', e) + 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 + + // 非标准帧的帧间超时(ms),比原来100ms短很多,避免粘包 + var UNKNOWN_FRAME_TIMEOUT = 20 + // 噪声阈值:小于此字节数且超时的视为干扰丢弃 + var NOISE_THRESHOLD = 4 + + 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) { + // 有数据 → 读入缓冲区 + for (var i = 0; i < avail && i < 256; i++) { + var b = plus.android.invoke(is, 'read') + if (b >= 0) { + recvBuffer.push(b & 0xFF) + lastRecvTime = Date.now() + } + } + + // ★ 先尝试精确切帧(已知Modbus格式) + var result = extractModbusFrames(recvBuffer) + if (result.frames.length > 0) { + for (var f = 0; f < result.frames.length; f++) { + var frame = result.frames[f] + var hexStr = self.byte2HexString(frame) + console.log('[TCP485] 收到: ' + hexStr + ' (' + frame.length + 'B)') + if (self.readCallback) self.readCallback(hexStr) + } + recvBuffer = result.remaining + } + // remaining 可能包含无法识别的非标准帧数据,走下面超时兜底 + } else if (recvBuffer.length > 0) { + // 无新数据 → 检查是否超时 + var idleMs = Date.now() - lastRecvTime + 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)') + } + recvBuffer = [] + } + } + } catch(e) { + console.warn('[TCP485] 读异常:', e) + } + }, 10) + } + + // 心跳:30s检查连接是否存活 + _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() + } + } + }, 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() + } + + _stopHeartbeat() { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = null + } + } + + _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) + } +} + +export default new TCP485()