From b4a2e14b0c0ce1b05df32003235540e7d4fe3859 Mon Sep 17 00:00:00 2001 From: "WIN-87ES3P38OPV\\EDY" Date: Tue, 11 Aug 2026 15:43:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E7=AB=AF=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=BE=85=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.js | 240 +++++++++----------- preload.js | 59 ++++- src/business/sensor-controller.js | 325 ++++++++++++++++++++++++++++ sensor.js => src/business/sensor.js | 0 src/business/serial-protocols.js | 201 +++++++++++++++++ src/business/usb-serial-bridge.js | 261 ++++++++++++++++++++++ usb-serial-bridge.js | 239 -------------------- 7 files changed, 947 insertions(+), 378 deletions(-) create mode 100644 src/business/sensor-controller.js rename sensor.js => src/business/sensor.js (100%) create mode 100644 src/business/serial-protocols.js create mode 100644 src/business/usb-serial-bridge.js delete mode 100644 usb-serial-bridge.js diff --git a/main.js b/main.js index dd8ea74..fad6344 100644 --- a/main.js +++ b/main.js @@ -1,18 +1,8 @@ -/* - * @Author: 李皓 hao_li_work@163.com - * @Date: 2026-07-28 15:22:20 - * @LastEditors: 李皓 hao_li_work@163.com - * @LastEditTime: 2026-07-28 16:59:56 - * @FilePath: \pc\main.js - * @Description: - * @Version: 1.0.0 - */ const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') const fs = require('fs') -const { SerialPort } = require('serialport') -const { WitMotionSensor } = require('./sensor') -const { UsbSerialBridge } = require('./usb-serial-bridge') +const { SensorController } = require('./src/business/sensor-controller') +const { UsbSerialBridge } = require('./src/business/usb-serial-bridge') const pkg = require('./package.json') const cacheDir = app.getPath('userData') @@ -20,11 +10,8 @@ const cacheFile = path.join(cacheDir, 'app-cache.json') const fileRoot = path.join(cacheDir, 'files') let mainWindow = null -let sensor = null let usbBridge = null -let sensorOpen = false -let sensorDevices = [] -let lastSensorValues = {} +let sensorController = null function ensureDir(dir) { try { @@ -66,112 +53,36 @@ function fileList(dir) { } } -function sensorOperations(id) { - return [ - { id: 'read-version', name: '获取当前传感器版本' }, - { id: 'read-temp', name: '获取设备温度' }, - { id: 'read-euler', name: '获取设备角度' }, - { id: 'reset-euler', name: '重置角度传感器基准' }, - { id: 'read-heading-and-vertical', name: '获取朝向和垂直夹角' } - ].map(op => ({ ...op, sensorId: id })) -} - -async function refreshSensorDevices() { - try { - const ports = await SerialPort.list() - sensorDevices = ports - .filter(p => p.path && /^COM\d+$/i.test(p.path)) - .map(p => ({ - id: `HWT6053:${p.path}`, - name: 'HWT6053', - port: p.path, - baudRate: 9600, - isOpen: sensorOpen, - operations: sensorOperations(`HWT6053:${p.path}`) - })) - - for (const device of sensorDevices) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('sensor-attached', { id: device.id, name: device.name }) - } - } - } catch (err) { - console.error('[sensor] 刷新设备失败:', err.message) - sensorDevices = [] - } -} - -function normalizeSensorData(data) { - if (!data || data.sensor !== 'HWT6053') return data - if (data.method === 'temperature') { - const value = data.value && typeof data.value === 'object' ? data.value.temp : data.value - return { sensor: 'HWT6053', method: 'read-temp', value: String(value ?? '') } - } - if (data.method === 'angle') { - return { sensor: 'HWT6053', method: 'read-euler', value: data.value } - } - if (data.method === 'magnetic') { - return { sensor: 'HWT6053', method: 'read-heading-and-vertical', value: data.value } - } - return data -} - -function cacheSensorValue(data) { - const normalized = normalizeSensorData(data) - if (!normalized) return - lastSensorValues[normalized.method] = normalized.value +function emit(channel, ...args) { if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('sensor-data', normalized) + mainWindow.webContents.send(channel, ...args) } } -function ensureSensor() { - if (sensor) return sensor - sensor = new WitMotionSensor({ - onData: cacheSensorValue, - onLog: (level, msg) => { - console.log(`[sensor] ${msg}`) - if (mainWindow && !mainWindow.isDestroyed()) { - try { mainWindow.webContents.send('sensor-log', { level, msg }) } catch {} - } - } - }) - return sensor -} - -function openAppSensor() { - sensorOpen = true - refreshSensorDevices() - ensureSensor().connect() - return true +function lastSensorValue(id, opId) { + if (!sensorController) return null + const device = sensorController.devices.find(d => d.id === id) || + sensorController.devices.find(d => d.sensor === id) || + sensorController.devices.find(d => d.sensor === String(id || '').split(':')[0]) + if (!device) return null + return sensorController.lastValues.get(`${device.sensor}:${opId}`) ?? null } -function closeAppSensor() { - sensorOpen = false - if (sensor) sensor.disconnect() - sensor = null - return true -} - -function runSensorOperation(opId) { - if (opId === 'read-version') return pkg.version || '1' - if (opId === 'read-temp') return lastSensorValues['read-temp'] ?? null - if (opId === 'read-euler') { - const value = lastSensorValues['read-euler'] - return value == null ? null : JSON.stringify(value) - } - if (opId === 'read-heading-and-vertical') { - const value = lastSensorValues['read-heading-and-vertical'] - return value == null ? null : JSON.stringify(value) - } - if (opId === 'reset-euler') return '0' - return null +function createSensorController() { + sensorController = new SensorController({ + onValue: data => emit('sensor-data', data), + onDeviceAttached: device => emit('sensor-attached', { id: device.id, name: device.name }), + onDeviceDetached: device => emit('sensor-detached', { id: device.id, name: device.name }), + onLog: (level, msg) => emit('sensor-log', { level, msg }) + }) + sensorController.refresh().catch(err => console.error('[sensor] refresh failed:', err.message)) } function createWindow() { mainWindow = new BrowserWindow({ - width: 800, - height: 600, + width: 1280, + height: 800, + fullscreen: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -180,12 +91,17 @@ function createWindow() { } }) - mainWindow.loadFile('./dist/index.html') - usbBridge = new UsbSerialBridge(mainWindow) - refreshSensorDevices() + createSensorController() + usbBridge = new UsbSerialBridge(mainWindow, { + onDeviceDiscovered: () => { + if (sensorController) sensorController.refresh().catch(() => {}) + } + }) + + mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')) } -function registerIpc() { +function registerAppIpc() { ipcMain.on('app-clear-cache-sync', (event) => { writeCache({}) if (mainWindow && !mainWindow.isDestroyed()) mainWindow.reload() @@ -221,13 +137,8 @@ function registerIpc() { return true }) - ipcMain.on('app-offline-page', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('app-page-mode', 'offline') - }) - - ipcMain.on('app-online-page', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('app-page-mode', 'online') - }) + ipcMain.on('app-offline-page', () => emit('app-page-mode', 'offline')) + ipcMain.on('app-online-page', () => emit('app-page-mode', 'online')) ipcMain.on('app-file-list-sync', (event, dir) => { event.returnValue = fileList(dir) @@ -255,38 +166,87 @@ function registerIpc() { .catch(() => event.sender.send('app-file-delete-callback', token, target, false)) }) - ipcMain.on('app-sensor-open-sync', (event) => { event.returnValue = openAppSensor() }) - ipcMain.on('app-sensor-close-sync', (event) => { event.returnValue = closeAppSensor() }) - ipcMain.on('app-sensor-is-open-sync', (event) => { event.returnValue = sensorOpen }) + ipcMain.on('app-export-file', (event, filename, content, token) => { + const target = resolveAppFile(filename || `export-${Date.now()}.txt`) + fs.promises.mkdir(path.dirname(target), { recursive: true }) + .then(() => fs.promises.writeFile(target, String(content ?? ''), 'utf-8')) + .then(() => event.sender.send('app-file-write-callback', token || filename, target, true, '')) + .catch(err => event.sender.send('app-file-write-callback', token || filename, target, false, err.message)) + }) +} + +function registerSensorIpc() { + ipcMain.on('app-sensor-open-sync', (event) => { + if (sensorController) sensorController.open().catch(() => {}) + event.returnValue = true + }) + ipcMain.on('app-sensor-close-sync', (event) => { + event.returnValue = sensorController ? sensorController.close() : true + }) + ipcMain.on('app-sensor-is-open-sync', (event) => { + event.returnValue = sensorController ? sensorController.isOpen : false + }) ipcMain.on('sensor-init-all-sync', (event) => { - refreshSensorDevices() + if (sensorController) sensorController.initAll().catch(() => {}) event.returnValue = true }) ipcMain.on('sensor-flush-sync', (event) => { - refreshSensorDevices() + if (sensorController) sensorController.refresh().catch(() => {}) event.returnValue = true }) ipcMain.on('sensor-list-sync', (event) => { - event.returnValue = sensorDevices + event.returnValue = sensorController ? sensorController.list() : [] }) ipcMain.on('sensor-init-sync', (event, id) => { - event.returnValue = sensorDevices.some(device => device.id === id) + event.returnValue = sensorController ? sensorController.init(id) : false }) - ipcMain.on('sensor-open-sync', (event) => { event.returnValue = openAppSensor() }) - ipcMain.on('sensor-close-sync', (event) => { event.returnValue = closeAppSensor() }) - ipcMain.on('sensor-operation-sync', (event, _id, opId) => { - event.returnValue = runSensorOperation(opId) + ipcMain.on('sensor-open-sync', (event) => { + if (sensorController) sensorController.open().catch(() => {}) + event.returnValue = true }) - ipcMain.on('sensor-operation-async', (event, id, opId) => { - const result = runSensorOperation(opId) - event.sender.send('sensor-operation-callback', id, opId, result) + ipcMain.on('sensor-close-sync', (event) => { + event.returnValue = sensorController ? sensorController.close() : true }) + ipcMain.on('sensor-operation-sync', (event, id, opId) => { + const cached = lastSensorValue(id, opId) + if (sensorController) { + sensorController.operation(id, opId).catch(() => {}) + } + event.returnValue = cached + }) + ipcMain.on('sensor-operation-async', (event, id, opId, args) => { + if (!sensorController) { + event.sender.send('sensor-operation-callback', id, opId, null) + return + } + sensorController.operation(id, opId, args) + .then(result => event.sender.send('sensor-operation-callback', id, opId, result)) + .catch(err => event.sender.send('sensor-operation-callback', id, opId, err.message)) + }) +} +function registerLocationAndBluetoothIpc() { ipcMain.on('app-location-is-support-sync', (event) => { event.returnValue = false }) ipcMain.on('app-location-is-open-sync', (event) => { event.returnValue = false }) ipcMain.on('app-location-open', (event) => { event.sender.send('app-location-opened', 1) }) ipcMain.on('app-location-close', () => {}) + + ipcMain.on('bluetooth-device-select', (event, namePrefix) => { + event.sender.send('bluetooth-device-selected', 'false', namePrefix || '') + }) + ipcMain.on('bluetooth-device-open', (event, mac) => { + event.sender.send('bluetooth-device-opened', 'false', mac || '') + event.sender.send('bluetooth-device-state-changed', mac || '', 'UNSUPPORTED') + }) + ipcMain.on('bluetooth-device-write', () => {}) + ipcMain.on('bluetooth-device-close', () => {}) +} + +function registerIpc() { + registerAppIpc() + registerSensorIpc() + registerLocationAndBluetoothIpc() } app.whenReady().then(() => { @@ -295,7 +255,11 @@ app.whenReady().then(() => { createWindow() app.on('before-quit', () => { - if (sensor) sensor.disconnect() + if (sensorController) sensorController.close() if (usbBridge) usbBridge.disconnectAll() }) }) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) diff --git a/preload.js b/preload.js index 43280de..876cbc5 100644 --- a/preload.js +++ b/preload.js @@ -63,6 +63,7 @@ const app = { getCache(key) { return ipcRenderer.sendSync('get-cache-sync', key) }, offlinePage() { ipcRenderer.send('app-offline-page') }, onlinePage() { ipcRenderer.send('app-online-page') }, + exportFile(filename, content, token) { ipcRenderer.send('app-export-file', filename, content, token) }, fileList(dir) { return ipcRenderer.sendSync('app-file-list-sync', dir) }, fileWriteString(filePath, content, token) { ipcRenderer.send('app-file-write-string', filePath, content, token) }, fileReadString(filePath, token) { ipcRenderer.send('app-file-read-string', filePath, token) }, @@ -96,7 +97,10 @@ ipcRenderer.on('sensor-operation-callback', (_event, id, opId, result) => { const sensor = { initAll() { return ipcRenderer.sendSync('sensor-init-all-sync') }, flush() { return ipcRenderer.sendSync('sensor-flush-sync') }, - sensorList() { return ipcRenderer.sendSync('sensor-list-sync') }, + sensorList() { + const data = ipcRenderer.sendSync('sensor-list-sync') + return JSON.stringify({ code: 0, data: Array.isArray(data) ? data : [] }) + }, init(id) { return ipcRenderer.sendSync('sensor-init-sync', id) }, open(id) { return ipcRenderer.sendSync('sensor-open-sync', id) }, close(id) { return ipcRenderer.sendSync('sensor-close-sync', id) }, @@ -114,6 +118,7 @@ const appSensorCallbacks = { onValueCallback: null } const appSensor = { version: '1.0.0', name: 'appSensor', + dataArr: [], open() { return ipcRenderer.sendSync('app-sensor-open-sync') }, close() { return ipcRenderer.sendSync('app-sensor-close-sync') }, isOpen() { return ipcRenderer.sendSync('app-sensor-is-open-sync') }, @@ -122,6 +127,14 @@ const appSensor = { } ipcRenderer.on('sensor-data', (_event, data) => { + if (data && data.sensor && data.method) { + const index = appSensor.dataArr.findIndex(item => item.sensor === data.sensor && item.method === data.method) + if (index >= 0) { + appSensor.dataArr.splice(index, 1, data) + } else { + appSensor.dataArr.push(data) + } + } if (typeof appSensorCallbacks.onValueCallback === 'function') appSensorCallbacks.onValueCallback(data) }) @@ -205,9 +218,53 @@ const usbSerial = { } } +const bluetoothSppCallbacks = { + onDeviceSelected: null, + onDeviceOpened: null, + onDeviceDataReceived: null, + onDeviceStateChanged: null +} + +ipcRenderer.on('bluetooth-device-selected', (_event, success, address) => { + if (typeof bluetoothSppCallbacks.onDeviceSelected === 'function') { + bluetoothSppCallbacks.onDeviceSelected(success, address) + } +}) +ipcRenderer.on('bluetooth-device-opened', (_event, success, mac) => { + if (typeof bluetoothSppCallbacks.onDeviceOpened === 'function') { + bluetoothSppCallbacks.onDeviceOpened(success, mac) + } +}) +ipcRenderer.on('bluetooth-device-data-received', (_event, mac, data) => { + if (typeof bluetoothSppCallbacks.onDeviceDataReceived === 'function') { + bluetoothSppCallbacks.onDeviceDataReceived(mac, data) + } +}) +ipcRenderer.on('bluetooth-device-state-changed', (_event, mac, state) => { + if (typeof bluetoothSppCallbacks.onDeviceStateChanged === 'function') { + bluetoothSppCallbacks.onDeviceStateChanged(mac, state) + } +}) + +const bluetoothSpp = { + deviceSelect(namePrefix) { ipcRenderer.send('bluetooth-device-select', namePrefix) }, + deviceOpen(mac) { ipcRenderer.send('bluetooth-device-open', mac) }, + deviceWrite(mac, data) { ipcRenderer.send('bluetooth-device-write', mac, data) }, + deviceClose(mac) { ipcRenderer.send('bluetooth-device-close', mac) }, + get onDeviceSelected() { return bluetoothSppCallbacks.onDeviceSelected }, + set onDeviceSelected(fn) { bluetoothSppCallbacks.onDeviceSelected = fn }, + get onDeviceOpened() { return bluetoothSppCallbacks.onDeviceOpened }, + set onDeviceOpened(fn) { bluetoothSppCallbacks.onDeviceOpened = fn }, + get onDeviceDataReceived() { return bluetoothSppCallbacks.onDeviceDataReceived }, + set onDeviceDataReceived(fn) { bluetoothSppCallbacks.onDeviceDataReceived = fn }, + get onDeviceStateChanged() { return bluetoothSppCallbacks.onDeviceStateChanged }, + set onDeviceStateChanged(fn) { bluetoothSppCallbacks.onDeviceStateChanged = fn } +} + contextBridge.exposeInMainWorld('app', app) contextBridge.exposeInMainWorld('net', net) contextBridge.exposeInMainWorld('sensor', sensor) contextBridge.exposeInMainWorld('appSensor', appSensor) contextBridge.exposeInMainWorld('appLocation', appLocation) contextBridge.exposeInMainWorld('usbSerial', usbSerial) +contextBridge.exposeInMainWorld('bluetoothSpp', bluetoothSpp) diff --git a/src/business/sensor-controller.js b/src/business/sensor-controller.js new file mode 100644 index 0000000..1a38725 --- /dev/null +++ b/src/business/sensor-controller.js @@ -0,0 +1,325 @@ +const { + buildReadRegisters, + checkCrc, + parseRegisterValue, + probes, + runProbe, + serialRequest, + toInt32 +} = require('./serial-protocols') + +const SENSOR_META = { + HWT6053: { + code: '1', + name: 'HWT6053', + baudRate: 9600, + operations: [ + ['read-version', '获取版本号'], + ['read-temp', '获取温度'], + ['read-euler', '获取角度'], + ['reset-euler', '重置角度传感器基准'], + ['read-heading-and-vertical', '获取朝向和垂直夹角'] + ] + }, + TOF5000: { + code: '2', + name: 'TOF5000', + baudRate: 115200, + operations: [ + ['read-speed', '读取速度'], + ['read-range', '读取距离'] + ] + }, + CHC_CGI_430: { + code: '3', + name: 'CHC_CGI_430', + baudRate: 115200, + operations: [ + ['read-rtk', '读取RTK数据'] + ] + }, + SW_LDS20DA: { + code: '6', + name: 'SW_LDS20DA', + baudRate: 9600, + operations: [ + ['read-status', '读取状态'], + ['read-range', '读取距离'] + ] + } +} + +const REQUIRED_METHODS = [ + ['CHC_CGI_430', 'read-rtk'], + ['TOF5000', 'read-speed'], + ['TOF5000', 'read-range'], + ['HWT6053', 'read-version'], + ['HWT6053', 'read-euler'], + ['HWT6053', 'read-heading-and-vertical'], + ['SW_LDS20DA', 'read-status'], + ['SW_LDS20DA', 'read-range'] +] + +function sensorId(sensorName, portPath) { + return `${sensorName}:${portPath}` +} + +function makeOperations(device) { + const meta = SENSOR_META[device.sensor] + return (meta?.operations || []).map(([id, name]) => ({ + id, + name, + sensorId: device.id + })) +} + +function parseHwtEuler(frame) { + if (!checkCrc(frame) || frame.length < 17) return null + const data = frame.slice(3, -2) + const shorts = [] + for (let i = 0; i < data.length; i += 2) { + shorts.push(((data[i] & 0xFF) << 8) | (data[i + 1] & 0xFF)) + } + if (shorts.length < 6) return null + const roll = toInt32(shorts[1], shorts[0]) + const pitch = toInt32(shorts[3], shorts[2]) + const yaw = toInt32(shorts[5], shorts[4]) + return { + roll: +(roll / 1000).toFixed(2), + pitch: +(pitch / 1000).toFixed(2), + yaw: +(yaw / 1000).toFixed(2) + } +} + +function verticalFromEuler(euler) { + if (!euler) return null + const rollRadians = euler.roll * Math.PI / 180 + const pitchRadians = euler.pitch * Math.PI / 180 + const vertical = Math.acos(Math.cos(pitchRadians) * Math.cos(rollRadians)) * 180 / Math.PI + return { + vertical, + heading: euler.yaw + } +} + +async function readRegisterValue(path, baudRate, slaveAddress, action, registerAddress, timeoutMs = 300) { + const frame = await serialRequest({ + path, + baudRate, + write: buildReadRegisters(slaveAddress, action, registerAddress, 1), + timeoutMs, + minBytes: 7 + }) + return parseRegisterValue(frame) +} + +async function readHwtEuler(path) { + const frame = await serialRequest({ + path, + baudRate: 9600, + write: buildReadRegisters(0x50, 0x03, 0x3D, 6), + timeoutMs: 300, + minBytes: 17 + }) + return parseHwtEuler(frame) +} + +class SensorController { + constructor({ onValue, onDeviceAttached, onDeviceDetached, onLog } = {}) { + this.onValue = onValue || (() => {}) + this.onDeviceAttached = onDeviceAttached || (() => {}) + this.onDeviceDetached = onDeviceDetached || (() => {}) + this.onLog = onLog || (() => {}) + this.devices = [] + this.lastValues = new Map() + this.isOpen = false + this.timers = new Set() + this.discoveryRunning = false + } + + log(message) { + this.onLog('info', message) + } + + async refresh(portList = null) { + const { SerialPort } = require('./serial-protocols') + const ports = portList || await SerialPort.list() + const found = [] + + for (const portInfo of ports) { + const portPath = portInfo.path + if (!portPath) continue + for (const probe of probes) { + try { + const result = await runProbe(portPath, probe) + if (!result) continue + const meta = SENSOR_META[result.probeId] + if (!meta) continue + const device = { + id: sensorId(result.probeId, portPath), + code: meta.code, + sensor: result.probeId, + name: meta.name, + port: portPath, + baudRate: result.baudRate, + isOpen: this.isOpen, + operations: [] + } + device.operations = makeOperations(device) + found.push(device) + break + } catch (err) { + this.log(`probe ${probe.id} on ${portPath} failed: ${err.message}`) + } + } + } + + const oldIds = new Set(this.devices.map(d => d.id)) + const newIds = new Set(found.map(d => d.id)) + for (const device of found) { + if (!oldIds.has(device.id)) this.onDeviceAttached(device) + } + for (const device of this.devices) { + if (!newIds.has(device.id)) this.onDeviceDetached(device) + } + this.devices = found + return this.devices + } + + list() { + return this.devices.map(device => ({ + ...device, + isOpen: this.isOpen, + operations: makeOperations(device) + })) + } + + async initAll() { + await this.refresh() + return true + } + + init(id) { + return this.devices.some(device => device.id === id) + } + + async open() { + if (this.isOpen) return true + this.isOpen = true + if (this.devices.length === 0) await this.refresh() + this.startPolling() + return true + } + + close() { + this.isOpen = false + for (const timer of this.timers) clearTimeout(timer) + this.timers.clear() + return true + } + + async operation(id, opId, args = '') { + const device = this.devices.find(d => d.id === id) || + this.devices.find(d => d.sensor === id) || + this.devices.find(d => d.sensor === String(id).split(':')[0]) + if (!device) return null + const value = await this.readDeviceMethod(device, opId, args) + if (value != null && value !== '') { + this.lastValues.set(`${device.sensor}:${opId}`, value) + } + return value + } + + startPolling() { + for (const [sensorName, method] of REQUIRED_METHODS) { + this.scheduleMethod(sensorName, method) + } + } + + scheduleMethod(sensorName, method) { + const tick = async () => { + if (!this.isOpen) return + try { + const device = this.devices.find(d => d.sensor === sensorName) + if (device) { + const value = await this.readDeviceMethod(device, method, '') + if (value != null && value !== '') { + this.lastValues.set(`${sensorName}:${method}`, value) + this.onValue({ sensor: sensorName, method, value }) + } + } + } catch (err) { + this.log(`read ${sensorName}.${method} failed: ${err.message}`) + } finally { + if (this.isOpen) { + const timer = setTimeout(tick, 500) + this.timers.add(timer) + } + } + } + const timer = setTimeout(tick, 100) + this.timers.add(timer) + } + + async readDeviceMethod(device, method) { + if (device.sensor === 'TOF5000') { + if (method === 'read-speed') { + const value = await readRegisterValue(device.port, 115200, 0x01, 0x03, 0x00) + return value && value !== 0 ? String(value) : '' + } + if (method === 'read-range') { + const value = await readRegisterValue(device.port, 115200, 0x01, 0x04, 0x00) + return value && value !== 0 ? String(value) : '' + } + } + + if (device.sensor === 'SW_LDS20DA') { + if (method === 'read-status') { + const value = await readRegisterValue(device.port, 9600, 0x02, 0x03, 0x17) + return value == null ? '' : String(value) + } + if (method === 'read-range') { + const high = await readRegisterValue(device.port, 9600, 0x02, 0x03, 0x15) + const low = await readRegisterValue(device.port, 9600, 0x02, 0x03, 0x16) + if (high == null || low == null) return '' + return String((((high & 0xFFFF) << 16) | (low & 0xFFFF)) / 10) + } + } + + if (device.sensor === 'HWT6053') { + if (method === 'read-version') { + const value = await readRegisterValue(device.port, 9600, 0x50, 0x03, 0x2E) + return value && value !== 0 ? String(value) : '' + } + if (method === 'read-euler') { + const value = await readHwtEuler(device.port) + return value ? JSON.stringify(value) : '' + } + if (method === 'read-heading-and-vertical') { + const euler = await readHwtEuler(device.port) + const value = verticalFromEuler(euler) + return value ? JSON.stringify(value) : '' + } + if (method === 'read-temp') { + const value = await readRegisterValue(device.port, 9600, 0x50, 0x03, 0x43) + return value == null ? '' : String(value / 100) + } + if (method === 'reset-euler') return '0' + } + + if (device.sensor === 'CHC_CGI_430' && method === 'read-rtk') { + const text = await serialRequest({ + path: device.port, + baudRate: 115200, + write: null, + timeoutMs: 1000, + encoding: 'utf-8' + }) + return String(text || '').trim() + } + + return null + } +} + +module.exports = { SENSOR_META, SensorController } diff --git a/sensor.js b/src/business/sensor.js similarity index 100% rename from sensor.js rename to src/business/sensor.js diff --git a/src/business/serial-protocols.js b/src/business/serial-protocols.js new file mode 100644 index 0000000..977e1b2 --- /dev/null +++ b/src/business/serial-protocols.js @@ -0,0 +1,201 @@ +const { SerialPort } = require('serialport') + +const crc16Table = new Uint16Array(256) +for (let i = 0; i < 256; i++) { + let crc = i + for (let j = 0; j < 8; j++) { + crc = (crc & 1) ? (0xA001 ^ (crc >> 1)) : (crc >> 1) + } + crc16Table[i] = crc +} + +function crc16Modbus(data) { + let crc = 0xFFFF + for (const b of data) { + crc = crc16Table[(crc ^ b) & 0xFF] ^ (crc >> 8) + } + return Buffer.from([crc & 0xFF, (crc >> 8) & 0xFF]) +} + +function appendCrc(data) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) + return Buffer.concat([buf, crc16Modbus(buf)]) +} + +function checkCrc(frame) { + if (!frame || frame.length < 4) return false + const body = frame.slice(0, -2) + const crc = crc16Modbus(body) + return frame[frame.length - 2] === crc[0] && frame[frame.length - 1] === crc[1] +} + +function buildReadRegisters(slaveAddress, action, registerAddress, registerCount = 1) { + return appendCrc(Buffer.from([ + slaveAddress & 0xFF, + action & 0xFF, + (registerAddress >> 8) & 0xFF, + registerAddress & 0xFF, + (registerCount >> 8) & 0xFF, + registerCount & 0xFF + ])) +} + +function parseRegisterValue(frame) { + if (!checkCrc(frame) || frame.length < 7) return null + return ((frame[3] & 0xFF) << 8) | (frame[4] & 0xFF) +} + +function toInt16(value) { + return value >= 0x8000 ? value - 0x10000 : value +} + +function toInt32(high, low) { + const value = ((high & 0xFFFF) << 16) | (low & 0xFFFF) + return value >= 0x80000000 ? value - 0x100000000 : value +} + +function normalizeHex(hex) { + return String(hex || '').replace(/\s+/g, '').toUpperCase() +} + +function closePort(port) { + return new Promise((resolve) => { + if (!port || !port.isOpen) { + resolve() + return + } + port.close(() => resolve()) + }) +} + +function openPort(path, baudRate) { + const port = new SerialPort({ + path, + baudRate: baudRate || 9600, + dataBits: 8, + parity: 'none', + stopBits: 1, + autoOpen: false, + dtr: false, + rts: false + }) + return new Promise((resolve, reject) => { + port.open(err => err ? reject(new Error(err.message)) : resolve(port)) + }) +} + +async function serialRequest({ + path, + baudRate, + write, + timeoutMs = 300, + minBytes = 0, + encoding = null +}) { + const port = await openPort(path, baudRate) + const chunks = [] + let done = false + + try { + return await new Promise((resolve, reject) => { + const finish = (err) => { + if (done) return + done = true + clearTimeout(timer) + if (err) { + reject(err) + return + } + const buf = Buffer.concat(chunks) + resolve(encoding ? buf.toString(encoding) : buf) + } + + const timer = setTimeout(() => finish(), timeoutMs) + port.on('data', chunk => { + chunks.push(chunk) + if (minBytes > 0 && Buffer.concat(chunks).length >= minBytes) finish() + }) + port.on('error', err => finish(err)) + + if (write && write.length) { + port.write(write, err => { + if (err) finish(err) + }) + } + }) + } finally { + await closePort(port) + } +} + +const probes = [ + { + id: 'TOF5000', + baudRate: 115200, + command: buildReadRegisters(0x01, 0x03, 0x00, 1), + validate: response => { + const value = parseRegisterValue(response) + return value != null && value !== 0 + } + }, + { + id: 'SW_LDS20DA', + baudRate: 9600, + command: buildReadRegisters(0x02, 0x03, 0x17, 1), + validate: response => checkCrc(response) + }, + { + id: 'HWT6053', + baudRate: 9600, + command: buildReadRegisters(0x50, 0x03, 0x2E, 1), + validate: response => { + const value = parseRegisterValue(response) + return value != null && value !== 0 + } + }, + { + id: 'CHC_CGI_430', + baudRate: 115200, + command: null, + timeoutMs: 2000, + encoding: 'utf-8', + validate: response => String(response || '').includes('$') + } +] + +async function runProbe(path, probe) { + const response = await serialRequest({ + path, + baudRate: probe.baudRate, + write: probe.command, + timeoutMs: probe.timeoutMs || 300, + minBytes: probe.command ? 5 : 0, + encoding: probe.encoding || null + }) + const responseHex = Buffer.isBuffer(response) + ? response.toString('hex').toUpperCase() + : Buffer.from(String(response || ''), 'utf-8').toString('hex').toUpperCase() + if (!probe.validate(response)) return null + return { + probeId: probe.id, + baudRate: probe.baudRate, + response, + responseHex + } +} + +module.exports = { + SerialPort, + appendCrc, + buildReadRegisters, + checkCrc, + closePort, + normalizeHex, + openPort, + parseRegisterValue, + probes, + runProbe, + serialRequest, + toInt16, + toInt32 +} diff --git a/src/business/usb-serial-bridge.js b/src/business/usb-serial-bridge.js new file mode 100644 index 0000000..399b3ff --- /dev/null +++ b/src/business/usb-serial-bridge.js @@ -0,0 +1,261 @@ +const { ipcMain } = require('electron') +const { + SerialPort, + closePort, + normalizeHex, + openPort, + probes, + runProbe, + serialRequest +} = require('./serial-protocols') + +function deviceIdFromPort(portInfo) { + const raw = `${portInfo.path}|${portInfo.vendorId || ''}|${portInfo.productId || ''}` + let hash = 0 + for (let i = 0; i < raw.length; i++) { + hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0 + } + return Math.abs(hash) +} + +class AsyncLockCenter { + constructor() { + this.tails = new Map() + } + + async withLock(key, fn) { + const previous = this.tails.get(key) || Promise.resolve() + let release + const current = new Promise(resolve => { release = resolve }) + this.tails.set(key, previous.then(() => current)) + await previous + try { + return await fn() + } finally { + release() + if (this.tails.get(key) === current) this.tails.delete(key) + } + } +} + +class UsbSerialBridge { + constructor(mainWindow, { onDeviceDiscovered } = {}) { + this.mainWindow = mainWindow + this.onDeviceDiscovered = onDeviceDiscovered || (() => {}) + this.activeConnections = new Map() + this.discoveredDevices = new Map() + this.discoveryTimer = null + this.discoveryRunning = false + this.lockCenter = new AsyncLockCenter() + this._setupIPC() + } + + _emit(event, data) { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(event, JSON.stringify(data)) + } + } + + _setupIPC() { + ipcMain.on('usb-start-discovery', () => this.startDiscovery()) + ipcMain.on('usb-stop-discovery', () => this.stopDiscovery()) + ipcMain.handle('usb-get-discovered', () => JSON.stringify([...this.discoveredDevices.values()])) + ipcMain.on('usb-device-probe', (_e, p) => this.deviceProbe(p)) + ipcMain.on('usb-device-open', (_e, p) => this.deviceOpen(p)) + ipcMain.on('usb-device-close', (_e, p) => this.deviceClose(p)) + ipcMain.on('usb-device-write', (_e, p) => this.deviceWrite(p)) + } + + async startDiscovery() { + if (this.discoveryRunning) return + this.discoveryRunning = true + await this.scanOnce() + this.discoveryTimer = setInterval(() => this.scanOnce(), 2000) + } + + stopDiscovery() { + this.discoveryRunning = false + if (this.discoveryTimer) clearInterval(this.discoveryTimer) + this.discoveryTimer = null + } + + async scanOnce() { + let ports = [] + try { + ports = await SerialPort.list() + } catch (err) { + console.error('[usb] list failed:', err.message) + return + } + + const current = new Set(ports.map(p => p.path).filter(Boolean)) + for (const [deviceName, device] of this.discoveredDevices) { + if (!current.has(deviceName)) { + this.discoveredDevices.delete(deviceName) + this._emit('usb-callback-device-detached', { + deviceName, + deviceId: device.deviceId + }) + for (const key of [...this.activeConnections.keys()]) { + if (key.startsWith(`${deviceName}:`)) this.deviceCloseKey(key) + } + } + } + + for (const portInfo of ports) { + if (!portInfo.path) continue + if (!this.discoveredDevices.has(portInfo.path)) { + const device = { + deviceName: portInfo.path, + deviceId: deviceIdFromPort(portInfo), + ports: [1], + manufacturer: portInfo.manufacturer || '', + vendorId: portInfo.vendorId || '', + productId: portInfo.productId || '' + } + this.discoveredDevices.set(portInfo.path, device) + this._emit('usb-callback-device-attached', device) + } + this.probePort(portInfo.path, 1) + } + } + + async probePort(deviceName, portNumber) { + for (const probe of probes) { + try { + const result = await this.lockCenter.withLock(deviceName, () => runProbe(deviceName, probe)) + if (!result) continue + const payload = { + deviceName, + port: portNumber, + probeId: result.probeId, + baudRate: result.baudRate, + responseHex: result.responseHex + } + this._emit('usb-callback-device-discovered', payload) + this.onDeviceDiscovered(payload) + return payload + } catch (err) { + // A probe miss is normal while scanning mixed serial devices. + } + } + return null + } + + async deviceProbe({ deviceName, portNumber, baudRate, hexCmd, timeoutMs }) { + try { + const response = await this.lockCenter.withLock(deviceName, () => serialRequest({ + path: deviceName, + baudRate: baudRate || 9600, + write: normalizeHex(hexCmd) ? Buffer.from(normalizeHex(hexCmd), 'hex') : null, + timeoutMs: timeoutMs || 1000 + })) + const responseHex = response.toString('hex').toUpperCase() + this._emit('usb-callback-device-probe-result', { + deviceName, + port: portNumber, + baudRate, + success: responseHex.length > 0, + responseHex, + error: responseHex.length > 0 ? '' : 'No response' + }) + } catch (err) { + this._emit('usb-callback-device-probe-result', { + deviceName, + port: portNumber, + baudRate, + success: false, + responseHex: '', + error: err.message + }) + } + } + + async deviceOpen({ deviceName, portNumber, baudRate }) { + const key = `${deviceName}:${portNumber}` + const existing = this.activeConnections.get(key) + if (existing && existing.state === 'CONNECTED' && existing.port?.isOpen) { + this._emit('usb-callback-device-opened', { deviceName, port: portNumber, success: true, error: '' }) + return + } + + this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'CONNECTING' }) + try { + const port = await this.lockCenter.withLock(key, () => openPort(deviceName, baudRate || 9600)) + const entry = { port, deviceName, portNumber, state: 'CONNECTED', generation: Date.now() } + this.activeConnections.set(key, entry) + + this._emit('usb-callback-device-opened', { deviceName, port: portNumber, success: true, error: '' }) + this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'CONNECTED' }) + + port.on('data', data => { + const current = this.activeConnections.get(key) + if (!current || current.generation !== entry.generation) return + this._emit('usb-callback-device-data', { + deviceName, + port: portNumber, + hexData: data.toString('hex').toUpperCase() + }) + }) + port.on('error', err => { + console.error('[usb] connection error:', err.message) + this.deviceCloseKey(key) + }) + port.on('close', () => { + if (this.activeConnections.get(key) === entry) { + this.activeConnections.delete(key) + this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'DISCONNECTED' }) + } + }) + } catch (err) { + this._emit('usb-callback-device-opened', { + deviceName, + port: portNumber, + success: false, + error: err.message + }) + this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'DISCONNECTED' }) + } + } + + deviceClose({ deviceName, portNumber }) { + this.deviceCloseKey(`${deviceName}:${portNumber}`) + } + + async deviceCloseKey(key) { + const entry = this.activeConnections.get(key) + if (!entry) return + this.activeConnections.delete(key) + await closePort(entry.port) + this._emit('usb-callback-device-state', { + deviceName: entry.deviceName, + port: entry.portNumber, + state: 'DISCONNECTED' + }) + } + + async deviceWrite({ deviceName, portNumber, hexData }) { + const key = `${deviceName}:${portNumber}` + const entry = this.activeConnections.get(key) + if (!entry || entry.state !== 'CONNECTED' || !entry.port?.isOpen) return + + const normalized = normalizeHex(hexData) + if (!normalized) return + const buf = Buffer.from(normalized, 'hex') + await this.lockCenter.withLock(key, () => new Promise(resolve => { + entry.port.write(buf, err => { + if (err) console.error('[usb] write failed:', err.message) + resolve() + }) + })) + } + + disconnectAll() { + this.stopDiscovery() + for (const key of [...this.activeConnections.keys()]) { + this.deviceCloseKey(key) + } + } +} + +module.exports = { UsbSerialBridge } diff --git a/usb-serial-bridge.js b/usb-serial-bridge.js deleted file mode 100644 index 45f82a9..0000000 --- a/usb-serial-bridge.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * USB Serial JS 桥接模块 - * 实现 USB 串口设备的发现、探测、连接及数据读写 - * 接口规范: docs/webview-usb-serial.md - */ -const { SerialPort } = require('serialport') -const { ipcMain } = require('electron') - -class UsbSerialBridge { - constructor(mainWindow) { - this.mainWindow = mainWindow - this.activeConnections = new Map() - this.discoveredDevices = new Set() - this.activeProbes = new Map() - this._setupIPC() - } - - _emit(event, data) { - if (this.mainWindow && !this.mainWindow.isDestroyed()) { - this.mainWindow.webContents.send(event, JSON.stringify(data)) - } - } - - _setupIPC() { - ipcMain.on('usb-start-discovery', () => this._startDiscovery()) - ipcMain.on('usb-stop-discovery', () => this._stopDiscovery()) - ipcMain.handle('usb-get-discovered', () => this._getDiscoveredDevices()) - ipcMain.on('usb-device-probe', (_e, p) => this._deviceProbe(p)) - ipcMain.on('usb-device-open', (_e, p) => this._deviceOpen(p)) - ipcMain.on('usb-device-close', (_e, p) => this._deviceClose(p)) - ipcMain.on('usb-device-write', (_e, p) => this._deviceWrite(p)) - } - - async _startDiscovery() { - console.log('[usb] 开始发现串口设备...') - try { - const ports = await SerialPort.list() - for (const p of ports) { - if (!this.discoveredDevices.has(p.path)) { - this.discoveredDevices.add(p.path) - console.log('[usb] 发现设备:', p.path) - this._emit('usb-callback-device-attached', { - deviceName: p.path, - deviceId: parseInt(p.vendorId || '0', 16) || 0, - ports: [1] - }) - } - } - console.log('[usb] 发现完成, 共', ports.length, '个设备') - } catch (err) { - console.error('[usb] 发现失败:', err.message) - } - } - - _stopDiscovery() { - console.log('[usb] 停止发现') - } - - _getDiscoveredDevices() { - return JSON.stringify([]) - } - - async _deviceProbe({ deviceName, portNumber, baudRate, hexCmd, timeoutMs }) { - const probeId = deviceName + ':' + portNumber - console.log('[usb] 探测:', deviceName, 'port=', portNumber, 'baud=', baudRate) - - if (this.activeProbes.has(probeId)) { - const old = this.activeProbes.get(probeId) - clearTimeout(old.timeout) - try { old.port.close() } catch {} - this.activeProbes.delete(probeId) - } - - try { - const port = new SerialPort({ - path: deviceName, - baudRate: baudRate || 9600, - dataBits: 8, - parity: 'none', - stopBits: 1, - autoOpen: false, - dtr: false, - rts: false - }) - - await new Promise((resolve, reject) => { - port.open(err => err ? reject(new Error(err.message)) : resolve()) - }) - - let responseHex = '' - const timeout = setTimeout(() => { - this.activeProbes.delete(probeId) - try { port.close() } catch {} - const success = responseHex.length > 0 - console.log('[usb] 探测' + (success ? '成功' : '超时') + ':', deviceName, '响应=' + (responseHex || '无')) - this._emit('usb-callback-device-probe-result', { - deviceName, port: portNumber, baudRate, - success, responseHex, error: success ? '' : 'No response' - }) - }, timeoutMs || 1000) - - port.on('data', (data) => { - responseHex += data.toString('hex').toUpperCase() - }) - - port.on('error', (err) => { - console.error('[usb] 探测错误:', err.message) - }) - - this.activeProbes.set(probeId, { timeout, port }) - - if (hexCmd) { - const cmdBuf = Buffer.from(hexCmd, 'hex') - port.write(cmdBuf, (err) => { - if (err) { - clearTimeout(timeout) - this.activeProbes.delete(probeId) - try { port.close() } catch {} - this._emit('usb-callback-device-probe-result', { - deviceName, port: portNumber, baudRate, - success: false, responseHex: '', error: err.message - }) - } else { - console.log('[usb] 已发送探测命令:', hexCmd) - } - }) - } else { - console.log('[usb] 不发送探测命令, 等待自动上报...') - } - } catch (err) { - console.error('[usb] 探测失败:', err.message) - this._emit('usb-callback-device-probe-result', { - deviceName, port: portNumber, baudRate, - success: false, responseHex: '', error: err.message - }) - } - } - - async _deviceOpen({ deviceName, portNumber, baudRate }) { - const connKey = deviceName + ':' + portNumber - console.log('[usb] 打开:', connKey, 'baud=', baudRate) - - if (this.activeConnections.has(connKey)) { - try { this.activeConnections.get(connKey).port.close() } catch {} - this.activeConnections.delete(connKey) - } - - try { - const port = new SerialPort({ - path: deviceName, - baudRate: baudRate || 9600, - dataBits: 8, - parity: 'none', - stopBits: 1, - autoOpen: false, - dtr: false, - rts: false - }) - - await new Promise((resolve, reject) => { - port.open(err => err ? reject(new Error(err.message)) : resolve()) - }) - - const entry = { port, deviceName, portNumber } - this.activeConnections.set(connKey, entry) - - console.log('[usb] 打开成功:', connKey) - this._emit('usb-callback-device-opened', { - deviceName, port: portNumber, success: true, error: '' - }) - this._emit('usb-callback-device-state', { - deviceName, port: portNumber, state: 'CONNECTED' - }) - - port.on('data', (data) => { - this._emit('usb-callback-device-data', { - deviceName, - port: portNumber, - hexData: data.toString('hex').toUpperCase() - }) - }) - - port.on('error', (err) => { - console.error('[usb] 连接错误:', err.message) - }) - - port.on('close', () => { - console.log('[usb] 连接关闭:', connKey) - this.activeConnections.delete(connKey) - this._emit('usb-callback-device-state', { - deviceName, port: portNumber, state: 'DISCONNECTED' - }) - }) - } catch (err) { - console.error('[usb] 打开失败:', err.message) - this._emit('usb-callback-device-opened', { - deviceName, port: portNumber, success: false, error: err.message - }) - } - } - - _deviceClose({ deviceName, portNumber }) { - const connKey = deviceName + ':' + portNumber - console.log('[usb] 关闭:', connKey) - if (this.activeConnections.has(connKey)) { - const entry = this.activeConnections.get(connKey) - try { entry.port.close() } catch {} - this.activeConnections.delete(connKey) - } - } - - _deviceWrite({ deviceName, portNumber, hexData }) { - const connKey = deviceName + ':' + portNumber - console.log('[usb] 写入:', connKey, 'hex=' + hexData) - - if (!this.activeConnections.has(connKey)) { - console.error('[usb] 写入失败:', connKey, '未连接') - return - } - - const entry = this.activeConnections.get(connKey) - const buf = Buffer.from(hexData, 'hex') - entry.port.write(buf, (err) => { - if (err) { - console.error('[usb] 写入错误:', err.message) - } - }) - } - - disconnectAll() { - for (const [key, entry] of this.activeConnections) { - console.log('[usb] 断开:', key) - try { entry.port.close() } catch {} - } - this.activeConnections.clear() - } -} - -module.exports = { UsbSerialBridge }