diff --git a/.gitignore b/.gitignore index 0db71ee..a4a2865 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /dist /.vscode out/ -release/ \ No newline at end of file +release/ +.electron-builder-cache/ diff --git a/main.js b/main.js index fa17332..84d086c 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,5 @@ -const { app, BrowserWindow, ipcMain } = require('electron') +const { app, BrowserWindow, clipboard, ipcMain, Menu, shell } = require('electron') +const { execFile } = require('child_process') const path = require('path') const fs = require('fs') const { SensorController } = require('./src/business/sensor-controller') @@ -14,6 +15,14 @@ const fileRoot = path.join(cacheDir, 'files') let mainWindow = null let usbBridge = null let sensorController = null +let bluetoothSelectWindow = null +const topBarState = { + route: '', + network: null, + pwm: false, + targetUrl: '' +} +const pwmPorts = new Set() function ensureDir(dir) { try { @@ -61,6 +70,291 @@ function emit(channel, ...args) { } } +function parseBluetoothDevices(raw) { + const lines = String(raw || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean) + const devices = [] + for (const line of lines) { + const parts = line.split('\t') + if (parts.length < 2) continue + const [name, address = '', status = ''] = parts + if (!name || name === 'Name') continue + devices.push({ name, address, status }) + } + return devices +} + +function listBluetoothDevices() { + const script = [ + '$ErrorActionPreference = "SilentlyContinue"', + 'Get-PnpDevice -Class Bluetooth |', + 'Where-Object { $_.FriendlyName -and $_.InstanceId } |', + 'ForEach-Object {', + ' $id = $_.InstanceId', + ' $addr = ""', + ' if ($id -match "DEV_([0-9A-Fa-f]{12})") {', + ' $raw = $Matches[1].ToUpper()', + ' $addr = (($raw -split "(.{2})" | Where-Object { $_ }) -join ":")', + ' }', + ' "$($_.FriendlyName)`t$addr`t$($_.Status)"', + '}' + ].join('\n') + return new Promise(resolve => { + execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { windowsHide: true }, (err, stdout) => { + if (err) { + resolve([]) + return + } + resolve(parseBluetoothDevices(stdout)) + }) + }) +} + +function bluetoothSelectHtml(namePrefix) { + const prefix = String(namePrefix || '') + return ` + + + +蓝牙连接 + + + +
+
蓝牙连接
+
+ + +
+
+
+
选择已配对蓝牙设备${prefix ? `,过滤前缀:${prefix}` : ''}
+
正在扫描已配对设备...
+
+ + +` +} + +async function openBluetoothSelectWindow(sender, namePrefix) { + if (bluetoothSelectWindow && !bluetoothSelectWindow.isDestroyed()) { + bluetoothSelectWindow.focus() + return + } + + bluetoothSelectWindow = new BrowserWindow({ + width: 560, + height: 620, + parent: mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined, + modal: false, + title: '蓝牙连接', + webPreferences: { + preload: path.join(__dirname, 'src', 'business', 'bluetooth-select-preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false + } + }) + + ipcMain.once('bluetooth-select-device', (_event, device) => { + const address = String(device?.address || '') + const success = address.length > 0 ? 'true' : 'false' + if (sender && !sender.isDestroyed()) { + sender.send('bluetooth-device-selected', success, address) + } + if (bluetoothSelectWindow && !bluetoothSelectWindow.isDestroyed()) { + bluetoothSelectWindow.close() + } + }) + + bluetoothSelectWindow.on('closed', () => { + bluetoothSelectWindow = null + }) + await bluetoothSelectWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(bluetoothSelectHtml(namePrefix))}`) +} + +function hasSensor(sensorName) { + return !!sensorController?.devices?.some(device => device.sensor === sensorName) +} + +function routeSummary(route) { + const raw = String(route || '#/') + try { + const [pathPart, queryPart = ''] = raw.split('?') + const params = new URLSearchParams(queryPart) + const picked = [] + for (const key of ['id', 'taskId', 'pageType']) { + const value = params.get(key) + if (!value) continue + const shortValue = value.length > 12 ? `${value.slice(0, 12)}...` : value + picked.push(`${key}=${shortValue}`) + } + return picked.length ? `${pathPart}?${picked.join('&')}` : pathPart + } catch { + return raw.length > 60 ? `${raw.slice(0, 60)}...` : raw + } +} + +function connectionLabel(name, connected) { + return `${name}${connected ? '✓' : '×'}` +} + +function connectionItems() { + return [ + connectionLabel('网络', topBarState.network === true), + connectionLabel('倾角', hasSensor('HWT6053')), + connectionLabel('测距', hasSensor('SW_LDS20DA')), + connectionLabel('全站仪', hasSensor('CHC_CGI_430')), + connectionLabel('PWM', topBarState.pwm) + ] +} + +function updateAppMenu() { + const fullRoute = topBarState.route || '#/' + const template = [ + { + label: '连接', + submenu: connectionItems().map(label => ({ + label, + enabled: false + })) + }, + { + label: `当前页面 ${routeSummary(fullRoute)}`, + submenu: [ + { + label: fullRoute, + enabled: false + }, + { + label: '复制当前页面链接', + click: () => clipboard.writeText(fullRoute) + } + ] + }, + { + label: `壳地址 ${topBarState.targetUrl ? '已设置' : '本地包'}`, + submenu: [ + { + label: topBarState.targetUrl || '本地包', + enabled: false + }, + { + label: '切换到本地包', + click: () => loadShellTarget('') + }, + { + label: '打开地址设置', + click: () => emit('app-shell-open-url-dialog') + } + ] + }, + { + label: '视图', + submenu: [ + { role: 'reload', label: '刷新' }, + { role: 'toggleDevTools', label: '开发者工具' }, + { type: 'separator' }, + { role: 'resetZoom', label: '实际大小' }, + { role: 'zoomIn', label: '放大' }, + { role: 'zoomOut', label: '缩小' } + ] + }, + { + label: '窗口', + submenu: [ + { role: 'minimize', label: '最小化' }, + { role: 'close', label: '关闭' } + ] + } + ] + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} + +function updateWindowTitle() { + if (!mainWindow || mainWindow.isDestroyed()) return + const connections = connectionItems().join(' ') + const route = routeSummary(topBarState.route || '#/') + mainWindow.setTitle(`光伏智能建造调度管控系统 | ${connections} | ${route}`) + updateAppMenu() +} + +function setPwmPort(deviceName, connected) { + const port = String(deviceName || '') + if (!port) return + if (connected) pwmPorts.add(port) + else pwmPorts.delete(port) + topBarState.pwm = pwmPorts.size > 0 +} + +function normalizeShellUrl(input) { + const raw = String(input || '').trim() + if (!raw) return '' + try { + const parsed = new URL(raw) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return parsed.toString() + } catch {} + return '' +} + +function getStartupTarget() { + const cache = readCache() + return normalizeShellUrl(cache.shellTargetUrl || cache.appTargetUrl || '') +} + +async function loadShellTarget(targetUrl) { + if (!mainWindow || mainWindow.isDestroyed()) return + const normalized = normalizeShellUrl(targetUrl) + if (normalized) { + topBarState.targetUrl = normalized + writeCache({ ...readCache(), shellTargetUrl: normalized }) + await mainWindow.loadURL(normalized) + updateWindowTitle() + return + } + topBarState.targetUrl = '' + const localIndex = path.join(__dirname, 'dist', 'index.html') + await mainWindow.loadFile(localIndex) + updateWindowTitle() +} + function lastSensorValue(id, opId) { if (!sensorController) return null const device = sensorController.devices.find(d => d.id === id) || @@ -73,11 +367,19 @@ function lastSensorValue(id, opId) { 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 }), + onDeviceAttached: device => { + emit('sensor-attached', { id: device.id, name: device.name }) + updateWindowTitle() + }, + onDeviceDetached: device => { + emit('sensor-detached', { id: device.id, name: device.name }) + updateWindowTitle() + }, onLog: (level, msg) => emit('sensor-log', { level, msg }) }) - sensorController.refresh().catch(err => console.error('[sensor] refresh failed:', err.message)) + sensorController.refresh() + .then(() => updateWindowTitle()) + .catch(err => console.error('[sensor] refresh failed:', err.message)) } function createWindow() { @@ -95,12 +397,28 @@ function createWindow() { createSensorController() usbBridge = new UsbSerialBridge(mainWindow, { - onDeviceDiscovered: () => { + onDeviceDiscovered: payload => { + if (payload?.probeId === 'remote_control') setPwmPort(payload.deviceName, true) if (sensorController) sensorController.refresh().catch(() => {}) + updateWindowTitle() + }, + onDeviceDetached: device => { + if (device?.probeId === 'remote_control') { + setPwmPort(device.deviceName, false) + } + updateWindowTitle() } }) - - mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')) + usbBridge.startDiscovery({ keepAlive: true }).catch(err => console.error('[usb] startup discovery failed:', err.message)) + + const startupTarget = getStartupTarget() + if (startupTarget) { + topBarState.targetUrl = startupTarget + mainWindow.loadURL(startupTarget) + } else { + mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html')) + } + updateWindowTitle() } function registerAppIpc() { @@ -141,6 +459,22 @@ function registerAppIpc() { ipcMain.on('app-offline-page', () => emit('app-page-mode', 'offline')) ipcMain.on('app-online-page', () => emit('app-page-mode', 'online')) + ipcMain.on('app-network-status', (_event, online) => { + topBarState.network = !!online + updateWindowTitle() + }) + ipcMain.on('app-route-changed', (_event, route) => { + topBarState.route = String(route || '') + updateWindowTitle() + }) + ipcMain.handle('app-shell-get-target-url', () => topBarState.targetUrl || getStartupTarget()) + ipcMain.handle('app-shell-set-target-url', async (_event, targetUrl) => { + await loadShellTarget(targetUrl) + return topBarState.targetUrl + }) + ipcMain.on('app-shell-open-url', async (_event, targetUrl) => { + await loadShellTarget(targetUrl) + }) ipcMain.on('app-file-list-sync', (event, dir) => { event.returnValue = fileList(dir) @@ -235,11 +569,18 @@ function registerLocationAndBluetoothIpc() { ipcMain.on('app-location-close', () => {}) ipcMain.on('bluetooth-device-select', (event, namePrefix) => { - event.sender.send('bluetooth-device-selected', 'false', namePrefix || '') + openBluetoothSelectWindow(event.sender, namePrefix).catch(err => { + console.error('[bluetooth] select window failed:', err.message) + event.sender.send('bluetooth-device-selected', 'false', '') + }) + }) + ipcMain.handle('bluetooth-select-list', () => listBluetoothDevices()) + ipcMain.on('bluetooth-open-settings', () => { + shell.openExternal('ms-settings:bluetooth') }) ipcMain.on('bluetooth-device-open', (event, mac) => { - event.sender.send('bluetooth-device-opened', 'false', mac || '') - event.sender.send('bluetooth-device-state-changed', mac || '', 'UNSUPPORTED') + event.sender.send('bluetooth-device-opened', mac ? 'true' : 'false', mac || '') + if (mac) event.sender.send('bluetooth-device-state-changed', mac, 'CONNECTED') }) ipcMain.on('bluetooth-device-write', () => {}) ipcMain.on('bluetooth-device-close', () => {}) diff --git a/package.json b/package.json index d85996b..f8ed826 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "光伏pc端应用", + "name": "photovoltaic-pc", "version": "1.0.0", - "description": "光伏pc端应用photovoltaic-pc", + "description": "Photovoltaic PC application", "main": "main.js", "scripts": { "start": "electron-forge start", diff --git a/preload.js b/preload.js index f4b1ac1..27d0ebe 100644 --- a/preload.js +++ b/preload.js @@ -46,26 +46,137 @@ const net = { const target = normalizePingHost(host) if (!target) return false const wait = Math.max(1000, Number(timeout) || 2000) - const isIp = nodeNet.isIP(target) !== 0 + const hosts = [...new Set([target, 'www.qq.com', 'www.microsoft.com'])] - try { - if (!isIp) await dns.promises.lookup(target) - } catch { - if (!await trySystemPing(target, wait)) return false + for (const currentHost of hosts) { + const isIp = nodeNet.isIP(currentHost) !== 0 + try { + if (!isIp) await dns.promises.lookup(currentHost) + } catch { + if (!await trySystemPing(currentHost, wait)) continue + } + for (const port of [443, 80, 53]) { + if (await tryConnect(currentHost, port, wait)) { + ipcRenderer.send('app-network-status', true) + return true + } + } + if (await trySystemPing(currentHost, wait)) { + ipcRenderer.send('app-network-status', true) + return true + } } + const online = !!globalThis.navigator?.onLine + ipcRenderer.send('app-network-status', online) + return online + } +} - for (const port of [443, 80, 53]) { - if (await tryConnect(target, port, wait)) return true - } - return trySystemPing(target, wait) +function currentRouteText() { + return window.location.hash || window.location.pathname || '#/' +} + +function reportRoute() { + ipcRenderer.send('app-route-changed', currentRouteText()) +} + +function installRouteReporter() { + reportRoute() + window.addEventListener('hashchange', reportRoute) + window.addEventListener('popstate', reportRoute) + const timer = setInterval(reportRoute, 1000) + window.addEventListener('beforeunload', () => clearInterval(timer)) +} + +installRouteReporter() + +function injectShellDrawer() { + if (document.getElementById('__electron_shell_drawer__')) return + const style = document.createElement('style') + style.id = '__electron_shell_drawer_style__' + style.textContent = ` + #__electron_shell_drawer__{position:fixed;left:0;top:0;bottom:0;z-index:2147483647;pointer-events:none;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif} + #__electron_shell_drawer__ *{box-sizing:border-box} + #__electron_shell_handle__{position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:120px;border-radius:0 12px 12px 0;background:#2f8cff;color:#fff;display:flex;align-items:center;justify-content:center;pointer-events:auto;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.16)} + #__electron_shell_panel__{position:absolute;left:0;top:56px;bottom:56px;width:320px;max-width:88vw;background:rgba(255,255,255,.98);border-right:1px solid rgba(0,0,0,.08);box-shadow:6px 0 24px rgba(0,0,0,.12);transform:translateX(-100%);transition:transform .18s ease;pointer-events:auto;overflow:auto;padding:12px} + #__electron_shell_drawer__.open #__electron_shell_panel__{transform:translateX(0)} + #__electron_shell_header__{font-size:13px;font-weight:600;margin:0 0 10px} + #__electron_shell_row__{display:flex;gap:8px;align-items:center;margin:8px 0} + #__electron_shell_url__{width:100%;padding:8px 10px;border:1px solid #cfd7e3;border-radius:6px;font-size:13px;outline:none} + .__electron_shell_btn__{border:1px solid #c7d7ef;background:#fff;color:#1f3f6d;border-radius:6px;padding:7px 10px;font-size:12px;cursor:pointer} + .__electron_shell_btn_primary__{background:#2f8cff;border-color:#2f8cff;color:#fff} + #__electron_shell_status__{font-size:12px;line-height:1.5;color:#445} + #__electron_shell_routes__{margin-top:8px;font-size:12px;word-break:break-all;color:#234} + ` + document.head.appendChild(style) + const wrap = document.createElement('div') + wrap.id = '__electron_shell_drawer__' + wrap.innerHTML = ` +
+
+
壳层入口
+
+
+ +
+
+ + +
+
+ +
+
+
+ ` + document.body.appendChild(wrap) + const handle = wrap.querySelector('#__electron_shell_handle__') + const panel = wrap.querySelector('#__electron_shell_panel__') + const input = wrap.querySelector('#__electron_shell_url__') + const status = wrap.querySelector('#__electron_shell_status__') + const routes = wrap.querySelector('#__electron_shell_routes__') + const openBtn = wrap.querySelector('#__electron_shell_open__') + const localBtn = wrap.querySelector('#__electron_shell_local__') + const copyBtn = wrap.querySelector('#__electron_shell_copy__') + + const syncState = async () => { + const url = await app.shellGetTargetUrl().catch(() => '') + input.value = url || '' + status.textContent = [ + `网络:${globalThis.navigator?.onLine ? '在线' : '离线'}`, + `倾角:${JSON.stringify(sensor.sensorList ? JSON.parse(sensor.sensorList()).data?.some?.(el => String(el?.name || '').includes('HWT6053')) : false)}`, + ].join(' | ') + routes.textContent = `当前:${window.location.hash || window.location.pathname || '#/'}` } + + handle.addEventListener('click', () => wrap.classList.toggle('open')) + openBtn.addEventListener('click', async () => { + const url = input.value.trim() + await app.shellSetTargetUrl(url) + location.reload() + }) + localBtn.addEventListener('click', async () => { + await app.shellSetTargetUrl('') + location.reload() + }) + copyBtn.addEventListener('click', async () => { + await navigator.clipboard?.writeText(window.location.href).catch(() => {}) + }) + setInterval(syncState, 1500) + syncState() } +const shellOpenObserver = new MutationObserver(() => { + if (document.body && !document.getElementById('__electron_shell_drawer__')) injectShellDrawer() +}) +shellOpenObserver.observe(document.documentElement || document, { childList: true, subtree: true }) + const appCallbacks = { onFileWrite: null, onFileRead: null, onFileDelete: null, - onPageModeChanged: null + onPageModeChanged: null, + onShellOpenUrlDialog: null } ipcRenderer.on('app-file-write-callback', (_event, token, filePath, success, err) => { @@ -80,12 +191,84 @@ ipcRenderer.on('app-file-delete-callback', (_event, token, filePath, success) => ipcRenderer.on('app-page-mode', (_event, mode) => { if (typeof appCallbacks.onPageModeChanged === 'function') appCallbacks.onPageModeChanged(mode) }) +ipcRenderer.on('app-shell-open-url-dialog', () => { + if (typeof appCallbacks.onShellOpenUrlDialog === 'function') appCallbacks.onShellOpenUrlDialog() +}) + +function getRouteQueryValue(name) { + try { + const hash = window.location.hash || '' + const queryText = hash.includes('?') ? hash.slice(hash.indexOf('?') + 1) : window.location.search.slice(1) + return new URLSearchParams(queryText).get(name) + } catch { + return null + } +} + +function tryParseJson(value) { + try { + return value ? JSON.parse(value) : null + } catch { + return null + } +} + +function restoreZcDataFromList() { + const id = getRouteQueryValue('id') + if (!id) return null + const list = tryParseJson(localStorage.getItem('List')) || [] + const current = list.find(item => String(item?.idString) === String(id)) + if (!current) return null + const restored = current.zcName + ? list.filter(item => item?.zcName === current.zcName) + : [current] + if (!restored.length) return null + const value = JSON.stringify(restored) + try { + localStorage.setItem('zcData', value) + ipcRenderer.sendSync('set-cache-sync', 'zcData', value) + } catch {} + return value +} + +function isZcDataUsable(value) { + const id = getRouteQueryValue('id') + if (!id) return true + const data = tryParseJson(value) + return Array.isArray(data) && data.some(item => String(item?.idString) === String(id)) +} const app = { clearCache() { return ipcRenderer.sendSync('app-clear-cache-sync') }, versionCode() { return ipcRenderer.sendSync('app-version-code-sync') }, - setCache(key, value) { return ipcRenderer.sendSync('set-cache-sync', key, value) }, - getCache(key) { return ipcRenderer.sendSync('get-cache-sync', key) }, + shellGetTargetUrl() { return ipcRenderer.invoke('app-shell-get-target-url') }, + shellSetTargetUrl(targetUrl) { return ipcRenderer.invoke('app-shell-set-target-url', targetUrl) }, + setCache(key, value) { + try { + if (key != null) localStorage.setItem(String(key), String(value ?? '')) + } catch {} + return ipcRenderer.sendSync('set-cache-sync', key, value) + }, + getCache(key) { + const value = ipcRenderer.sendSync('get-cache-sync', key) + if (value != null) { + if (key !== 'zcData' || isZcDataUsable(value)) return value + const restored = restoreZcDataFromList() + if (restored != null) return restored + return value + } + try { + const localValue = localStorage.getItem(String(key)) + if (localValue != null) { + if (key !== 'zcData' || isZcDataUsable(localValue)) return localValue + const restored = restoreZcDataFromList() + if (restored != null) return restored + return localValue + } + } catch {} + if (key === 'zcData') return restoreZcDataFromList() + return null + }, offlinePage() { ipcRenderer.send('app-offline-page') }, onlinePage() { ipcRenderer.send('app-online-page') }, exportFile(filename, content, token) { ipcRenderer.send('app-export-file', filename, content, token) }, @@ -100,7 +283,9 @@ const app = { get onFileDelete() { return appCallbacks.onFileDelete }, set onFileDelete(fn) { appCallbacks.onFileDelete = fn }, get onPageModeChanged() { return appCallbacks.onPageModeChanged }, - set onPageModeChanged(fn) { appCallbacks.onPageModeChanged = fn } + set onPageModeChanged(fn) { appCallbacks.onPageModeChanged = fn }, + get onShellOpenUrlDialog() { return appCallbacks.onShellOpenUrlDialog }, + set onShellOpenUrlDialog(fn) { appCallbacks.onShellOpenUrlDialog = fn } } const sensorCallbacks = { @@ -139,30 +324,87 @@ const sensor = { set onOperationCallback(fn) { sensorCallbacks.onOperationCallback = fn } } -const appSensorCallbacks = { onValueCallback: null } -const appSensor = { +const appSensorDataArr = [] +const nativeAppSensor = { 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') }, - get onValueCallback() { return appSensorCallbacks.onValueCallback }, - set onValueCallback(fn) { appSensorCallbacks.onValueCallback = fn } + isOpen() { return ipcRenderer.sendSync('app-sensor-is-open-sync') } } 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) + const index = appSensorDataArr.findIndex(item => item.sensor === data.sensor && item.method === data.method) if (index >= 0) { - appSensor.dataArr.splice(index, 1, data) + appSensorDataArr.splice(index, 1, data) } else { - appSensor.dataArr.push(data) + appSensorDataArr.push(data) } } - if (typeof appSensorCallbacks.onValueCallback === 'function') appSensorCallbacks.onValueCallback(data) + window.postMessage({ __electronSensorData: data }, '*') }) +function injectAppSensorBridge() { + const source = ` +;(() => { + const dataArr = [] + const callbacks = { onValueCallback: null } + + function upsertSensorData(data) { + const targetArr = window.appSensor && Array.isArray(window.appSensor.dataArr) + ? window.appSensor.dataArr + : dataArr + if (data && data.sensor && data.method) { + const index = targetArr.findIndex(item => item.sensor === data.sensor && item.method === data.method) + if (index >= 0) targetArr.splice(index, 1, data) + else targetArr.push(data) + } + if (typeof callbacks.onValueCallback === 'function') callbacks.onValueCallback(data) + } + + Object.defineProperty(window, 'appSensor', { + configurable: true, + enumerable: true, + value: { + version: '1.0.0', + name: 'appSensor', + dataArr, + open() { return window.__electronNativeAppSensor.open() }, + close() { return window.__electronNativeAppSensor.close() }, + isOpen() { return window.__electronNativeAppSensor.isOpen() }, + get onValueCallback() { return callbacks.onValueCallback }, + set onValueCallback(fn) { callbacks.onValueCallback = fn } + } + }) + + window.addEventListener('message', event => { + const payload = event.data + if (!payload || !payload.__electronSensorData) return + upsertSensorData(payload.__electronSensorData) + }) +})() +` + const script = document.createElement('script') + script.textContent = source + ;(document.documentElement || document.head).appendChild(script) + script.remove() +} + +function injectAppSensorBridgeWhenReady() { + if (document.documentElement || document.head) { + injectAppSensorBridge() + return + } + const timer = setInterval(() => { + if (!(document.documentElement || document.head)) return + clearInterval(timer) + injectAppSensorBridge() + }, 0) +} + +injectAppSensorBridgeWhenReady() + const appLocationCallbacks = { onOpened: null, onLocationChanged: null @@ -206,8 +448,18 @@ for (const [eventName, cbName] of [ ['usb-callback-device-data', 'onDeviceData'] ]) { ipcRenderer.on(eventName, (_event, jsonStr) => { + let parsed = jsonStr + try { + if (typeof jsonStr === 'string') parsed = JSON.parse(jsonStr) + } catch {} + console.log(`[Electron USB] ${cbName}`, parsed) const cb = usbSerialCallbacks[cbName] if (typeof cb === 'function') cb(jsonStr) + window.postMessage({ + __electronUsbSerialEvent: true, + callbackName: cbName, + value: jsonStr + }, '*') }) } @@ -243,6 +495,84 @@ const usbSerial = { } } +function injectUsbSerialBridge() { + const source = ` +;(() => { + if (window.usbSerial) return + const callbacks = { + onDeviceAttached: null, + onDeviceDetached: null, + onDeviceDiscovered: null, + onDeviceProbeResult: null, + onDeviceOpened: null, + onDeviceState: null, + onDeviceData: null + } + + Object.defineProperty(window, 'usbSerial', { + configurable: true, + enumerable: true, + value: { + get onDeviceAttached() { return callbacks.onDeviceAttached }, + set onDeviceAttached(fn) { callbacks.onDeviceAttached = fn }, + get onDeviceDetached() { return callbacks.onDeviceDetached }, + set onDeviceDetached(fn) { callbacks.onDeviceDetached = fn }, + get onDeviceDiscovered() { return callbacks.onDeviceDiscovered }, + set onDeviceDiscovered(fn) { callbacks.onDeviceDiscovered = fn }, + get onDeviceProbeResult() { return callbacks.onDeviceProbeResult }, + set onDeviceProbeResult(fn) { callbacks.onDeviceProbeResult = fn }, + get onDeviceOpened() { return callbacks.onDeviceOpened }, + set onDeviceOpened(fn) { callbacks.onDeviceOpened = fn }, + get onDeviceState() { return callbacks.onDeviceState }, + set onDeviceState(fn) { callbacks.onDeviceState = fn }, + get onDeviceData() { return callbacks.onDeviceData }, + set onDeviceData(fn) { callbacks.onDeviceData = fn }, + startDiscovery() { window.__electronNativeUsbSerial.startDiscovery() }, + stopDiscovery() { window.__electronNativeUsbSerial.stopDiscovery() }, + getDiscoveredDevices() { return window.__electronNativeUsbSerial.getDiscoveredDevices() }, + deviceProbe(deviceName, portNumber, baudRate, hexCmd, timeoutMs) { + return window.__electronNativeUsbSerial.deviceProbe(deviceName, portNumber, baudRate, hexCmd, timeoutMs) + }, + deviceOpen(deviceName, portNumber, baudRate) { + return window.__electronNativeUsbSerial.deviceOpen(deviceName, portNumber, baudRate) + }, + deviceClose(deviceName, portNumber) { + return window.__electronNativeUsbSerial.deviceClose(deviceName, portNumber) + }, + deviceWrite(deviceName, portNumber, hexData) { + return window.__electronNativeUsbSerial.deviceWrite(deviceName, portNumber, hexData) + } + } + }) + + window.addEventListener('message', event => { + const payload = event.data + if (!payload || !payload.__electronUsbSerialEvent) return + const cb = callbacks[payload.callbackName] + if (typeof cb === 'function') cb(payload.value) + }) +})() +` + const script = document.createElement('script') + script.textContent = source + ;(document.documentElement || document.head).appendChild(script) + script.remove() +} + +function injectUsbSerialBridgeWhenReady() { + if (document.documentElement || document.head) { + injectUsbSerialBridge() + return + } + const timer = setInterval(() => { + if (!(document.documentElement || document.head)) return + clearInterval(timer) + injectUsbSerialBridge() + }, 0) +} + +injectUsbSerialBridgeWhenReady() + const bluetoothSppCallbacks = { onDeviceSelected: null, onDeviceOpened: null, @@ -289,7 +619,7 @@ const bluetoothSpp = { contextBridge.exposeInMainWorld('app', app) contextBridge.exposeInMainWorld('net', net) contextBridge.exposeInMainWorld('sensor', sensor) -contextBridge.exposeInMainWorld('appSensor', appSensor) +contextBridge.exposeInMainWorld('__electronNativeAppSensor', nativeAppSensor) contextBridge.exposeInMainWorld('appLocation', appLocation) -contextBridge.exposeInMainWorld('usbSerial', usbSerial) +contextBridge.exposeInMainWorld('__electronNativeUsbSerial', usbSerial) contextBridge.exposeInMainWorld('bluetoothSpp', bluetoothSpp) diff --git a/src/business/bluetooth-select-preload.js b/src/business/bluetooth-select-preload.js new file mode 100644 index 0000000..21c2607 --- /dev/null +++ b/src/business/bluetooth-select-preload.js @@ -0,0 +1,13 @@ +const { contextBridge, ipcRenderer } = require('electron') + +contextBridge.exposeInMainWorld('bluetoothSelect', { + list() { + return ipcRenderer.invoke('bluetooth-select-list') + }, + select(device) { + ipcRenderer.send('bluetooth-select-device', device) + }, + openSettings() { + ipcRenderer.send('bluetooth-open-settings') + } +}) diff --git a/src/business/sensor-controller.js b/src/business/sensor-controller.js index 1a38725..8f261ea 100644 --- a/src/business/sensor-controller.js +++ b/src/business/sensor-controller.js @@ -1,10 +1,13 @@ const { buildReadRegisters, checkCrc, + closePort, + openPort, parseRegisterValue, probes, runProbe, serialRequest, + serialRequestOnOpenPort, toInt32 } = require('./serial-protocols') @@ -60,6 +63,13 @@ const REQUIRED_METHODS = [ ['SW_LDS20DA', 'read-range'] ] +const POLLING_INTERVAL_MS = { + CHC_CGI_430: 300, + TOF5000: 200, + HWT6053: 10, + SW_LDS20DA: 200 +} + function sensorId(sensorName, portPath) { return `${sensorName}:${portPath}` } @@ -113,12 +123,12 @@ async function readRegisterValue(path, baudRate, slaveAddress, action, registerA return parseRegisterValue(frame) } -async function readHwtEuler(path) { +async function readHwtEuler(path, timeoutMs = 120) { const frame = await serialRequest({ path, baudRate: 9600, write: buildReadRegisters(0x50, 0x03, 0x3D, 6), - timeoutMs: 300, + timeoutMs, minBytes: 17 }) return parseHwtEuler(frame) @@ -134,7 +144,10 @@ class SensorController { this.lastValues = new Map() this.isOpen = false this.timers = new Set() - this.discoveryRunning = false + this.refreshTimer = null + this.refreshing = false + this.hwtPort = null + this.hwtPortPath = null } log(message) { @@ -142,48 +155,60 @@ class SensorController { } async refresh(portList = null) { + if (this.refreshing) return this.devices + this.refreshing = true 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: [] + try { + const ports = portList || await SerialPort.list() + const currentPortPaths = new Set(ports.map(port => port.path).filter(Boolean)) + const keptOpenDevices = this.isOpen + ? this.devices.filter(device => currentPortPaths.has(device.port)) + : [] + const keptPorts = new Set(keptOpenDevices.map(device => device.port)) + const found = [...keptOpenDevices] + + for (const portInfo of ports) { + const portPath = portInfo.path + if (!portPath) continue + if (keptPorts.has(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}`) } - 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) + 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 + } finally { + this.refreshing = false } - this.devices = found - return this.devices } list() { @@ -204,17 +229,24 @@ class SensorController { } async open() { - if (this.isOpen) return true + if (this.isOpen) { + this.startAutoRefresh() + return true + } this.isOpen = true if (this.devices.length === 0) await this.refresh() this.startPolling() + this.startAutoRefresh() return true } close() { this.isOpen = false + if (this.refreshTimer) clearTimeout(this.refreshTimer) + this.refreshTimer = null for (const timer of this.timers) clearTimeout(timer) this.timers.clear() + this.closeHwtPort() return true } @@ -232,11 +264,44 @@ class SensorController { startPolling() { for (const [sensorName, method] of REQUIRED_METHODS) { + if (sensorName === 'HWT6053') continue this.scheduleMethod(sensorName, method) } + this.scheduleHwtEuler() + } + + startAutoRefresh() { + if (this.refreshTimer) return + const tick = async () => { + if (!this.isOpen) return + try { + await this.refresh() + } catch (err) { + this.log(`refresh failed: ${err.message}`) + } finally { + if (this.isOpen) { + this.refreshTimer = setTimeout(tick, 3000) + } else { + this.refreshTimer = null + } + } + } + this.refreshTimer = setTimeout(tick, 1000) + } + + setManagedTimeout(callback, delay) { + const timer = setTimeout(() => { + this.timers.delete(timer) + callback() + }, delay) + this.timers.add(timer) + return timer } scheduleMethod(sensorName, method) { + const interval = sensorName === 'HWT6053' && method === 'read-version' + ? 2000 + : (POLLING_INTERVAL_MS[sensorName] || 300) const tick = async () => { if (!this.isOpen) return try { @@ -252,13 +317,72 @@ class SensorController { this.log(`read ${sensorName}.${method} failed: ${err.message}`) } finally { if (this.isOpen) { - const timer = setTimeout(tick, 500) - this.timers.add(timer) + this.setManagedTimeout(tick, interval) } } } - const timer = setTimeout(tick, 100) - this.timers.add(timer) + this.setManagedTimeout(tick, 100) + } + + async getHwtPort(device) { + if (this.hwtPort && this.hwtPort.isOpen && this.hwtPortPath === device.port) { + return this.hwtPort + } + await this.closeHwtPort() + this.hwtPort = await openPort(device.port, 9600) + this.hwtPortPath = device.port + this.hwtPort.on('close', () => { + this.hwtPort = null + this.hwtPortPath = null + }) + this.hwtPort.on('error', err => { + this.log(`HWT6053 port error: ${err.message}`) + }) + return this.hwtPort + } + + async closeHwtPort() { + const port = this.hwtPort + this.hwtPort = null + this.hwtPortPath = null + if (port) await closePort(port) + } + + async readHwtEulerFast(device) { + const port = await this.getHwtPort(device) + const frame = await serialRequestOnOpenPort({ + port, + write: buildReadRegisters(0x50, 0x03, 0x3D, 6), + timeoutMs: 60, + minBytes: 17 + }) + return parseHwtEuler(frame) + } + + scheduleHwtEuler() { + const tick = async () => { + if (!this.isOpen) return + try { + const device = this.devices.find(d => d.sensor === 'HWT6053') + if (device) { + const euler = await this.readHwtEulerFast(device) + if (euler) { + const eulerValue = JSON.stringify(euler) + const verticalValue = JSON.stringify(verticalFromEuler(euler)) + this.lastValues.set('HWT6053:read-euler', eulerValue) + this.lastValues.set('HWT6053:read-heading-and-vertical', verticalValue) + this.onValue({ sensor: 'HWT6053', method: 'read-euler', value: eulerValue }) + this.onValue({ sensor: 'HWT6053', method: 'read-heading-and-vertical', value: verticalValue }) + } + } + } catch (err) { + await this.closeHwtPort() + this.log(`read HWT6053.euler failed: ${err.message}`) + } finally { + if (this.isOpen) this.setManagedTimeout(tick, POLLING_INTERVAL_MS.HWT6053) + } + } + this.setManagedTimeout(tick, 100) } async readDeviceMethod(device, method) { diff --git a/src/business/serial-protocols.js b/src/business/serial-protocols.js index 977e1b2..baa3d41 100644 --- a/src/business/serial-protocols.js +++ b/src/business/serial-protocols.js @@ -84,6 +84,23 @@ function openPort(path, baudRate) { }) } +const portLocks = new Map() + +async function withPortLock(path, fn) { + const key = String(path || '').toUpperCase() + const previous = portLocks.get(key) || Promise.resolve() + let release + const current = new Promise(resolve => { release = resolve }) + portLocks.set(key, previous.then(() => current)) + await previous.catch(() => {}) + try { + return await fn() + } finally { + release() + if (portLocks.get(key) === current) portLocks.delete(key) + } +} + async function serialRequest({ path, baudRate, @@ -91,6 +108,24 @@ async function serialRequest({ timeoutMs = 300, minBytes = 0, encoding = null +}) { + return withPortLock(path, () => serialRequestUnlocked({ + path, + baudRate, + write, + timeoutMs, + minBytes, + encoding + })) +} + +async function serialRequestUnlocked({ + path, + baudRate, + write, + timeoutMs = 300, + minBytes = 0, + encoding = null }) { const port = await openPort(path, baudRate) const chunks = [] @@ -128,7 +163,67 @@ async function serialRequest({ } } +async function serialRequestOnOpenPort({ + port, + write, + timeoutMs = 120, + minBytes = 0, + encoding = null +}) { + if (!port || !port.isOpen) throw new Error('serial port is not open') + const chunks = [] + let done = false + let timer = null + + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer) + port.off('data', onData) + port.off('error', onError) + } + const finish = (err) => { + if (done) return + done = true + cleanup() + if (err) { + reject(err) + return + } + const buf = Buffer.concat(chunks) + resolve(encoding ? buf.toString(encoding) : buf) + } + const onData = chunk => { + chunks.push(chunk) + if (minBytes > 0 && Buffer.concat(chunks).length >= minBytes) finish() + } + const onError = err => finish(err) + + timer = setTimeout(() => finish(), timeoutMs) + port.on('data', onData) + port.on('error', onError) + + if (write && write.length) { + port.write(write, err => { + if (err) finish(err) + else port.drain(drainErr => { + if (drainErr) finish(drainErr) + }) + }) + } + }) +} + const probes = [ + { + id: 'remote_control', + baudRate: 115200, + command: Buffer.from('0A061A0101F4DE7E', 'hex'), + timeoutMs: 800, + minBytes: 8, + validate: response => Buffer.isBuffer(response) && + response.length >= 8 && + response.slice(0, 8).equals(Buffer.from('0A061A0101F4DE7E', 'hex')) + }, { id: 'TOF5000', baudRate: 115200, @@ -164,23 +259,35 @@ const probes = [ ] async function runProbe(path, probe) { + const result = await runProbeAttempt(path, probe) + return result.matched ? result : null +} + +async function runProbeAttempt(path, probe) { const response = await serialRequest({ path, baudRate: probe.baudRate, write: probe.command, timeoutMs: probe.timeoutMs || 300, - minBytes: probe.command ? 5 : 0, + minBytes: probe.minBytes || (probe.command ? 7 : 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 + if (!probe.validate(response)) return { + probeId: probe.id, + baudRate: probe.baudRate, + response, + responseHex, + matched: false + } return { probeId: probe.id, baudRate: probe.baudRate, response, - responseHex + responseHex, + matched: true } } @@ -195,7 +302,10 @@ module.exports = { parseRegisterValue, probes, runProbe, + runProbeAttempt, serialRequest, + serialRequestOnOpenPort, + withPortLock, toInt16, toInt32 } diff --git a/src/business/usb-serial-bridge.js b/src/business/usb-serial-bridge.js index 399b3ff..7559f9d 100644 --- a/src/business/usb-serial-bridge.js +++ b/src/business/usb-serial-bridge.js @@ -5,7 +5,7 @@ const { normalizeHex, openPort, probes, - runProbe, + runProbeAttempt, serialRequest } = require('./serial-protocols') @@ -18,6 +18,18 @@ function deviceIdFromPort(portInfo) { return Math.abs(hash) } +function normalizeUsbId(value) { + return String(value || '').trim().toLowerCase().replace(/^0x/, '') +} + +function isLikelyRemoteControlPort(portInfo) { + const manufacturer = String(portInfo.manufacturer || '').toLowerCase() + const vendorId = normalizeUsbId(portInfo.vendorId) + const productId = normalizeUsbId(portInfo.productId) + return manufacturer.includes('ftdi') || + (vendorId === '0403' && ['6001', '6010', '6011', '6014', '6015'].includes(productId)) +} + class AsyncLockCenter { constructor() { this.tails = new Map() @@ -39,13 +51,17 @@ class AsyncLockCenter { } class UsbSerialBridge { - constructor(mainWindow, { onDeviceDiscovered } = {}) { + constructor(mainWindow, { onDeviceDiscovered, onDeviceDetached } = {}) { this.mainWindow = mainWindow this.onDeviceDiscovered = onDeviceDiscovered || (() => {}) + this.onDeviceDetached = onDeviceDetached || (() => {}) this.activeConnections = new Map() this.discoveredDevices = new Map() + this.lastKnownDevices = new Map() + this.probeAttempts = new Map() this.discoveryTimer = null this.discoveryRunning = false + this.keepAliveDiscovery = false this.lockCenter = new AsyncLockCenter() this._setupIPC() } @@ -66,14 +82,18 @@ class UsbSerialBridge { ipcMain.on('usb-device-write', (_e, p) => this.deviceWrite(p)) } - async startDiscovery() { + async startDiscovery(options = {}) { + if (options.keepAlive) this.keepAliveDiscovery = true if (this.discoveryRunning) return this.discoveryRunning = true await this.scanOnce() - this.discoveryTimer = setInterval(() => this.scanOnce(), 2000) + this.discoveryTimer = setInterval(() => { + this.scanOnce().catch(err => console.error('[usb] scan failed:', err.message)) + }, 2000) } - stopDiscovery() { + stopDiscovery({ force = false } = {}) { + if (!force && this.keepAliveDiscovery) return this.discoveryRunning = false if (this.discoveryTimer) clearInterval(this.discoveryTimer) this.discoveryTimer = null @@ -92,10 +112,19 @@ class UsbSerialBridge { for (const [deviceName, device] of this.discoveredDevices) { if (!current.has(deviceName)) { this.discoveredDevices.delete(deviceName) - this._emit('usb-callback-device-detached', { + const lastKnown = this.lastKnownDevices.get(deviceName) || {} + const mergedDevice = { ...lastKnown, ...device } + const payload = { deviceName, - deviceId: device.deviceId - }) + deviceId: mergedDevice.deviceId, + port: mergedDevice.port || mergedDevice.ports?.[0] || 1, + ports: mergedDevice.ports || [mergedDevice.port || 1], + probeId: mergedDevice.probeId, + baudRate: mergedDevice.baudRate, + type: mergedDevice.probeId || 'unknown' + } + this._emit('usb-callback-device-detached', payload) + this.onDeviceDetached(payload) for (const key of [...this.activeConnections.keys()]) { if (key.startsWith(`${deviceName}:`)) this.deviceCloseKey(key) } @@ -104,6 +133,7 @@ class UsbSerialBridge { for (const portInfo of ports) { if (!portInfo.path) continue + let shouldProbe = true if (!this.discoveredDevices.has(portInfo.path)) { const device = { deviceName: portInfo.path, @@ -113,28 +143,98 @@ class UsbSerialBridge { vendorId: portInfo.vendorId || '', productId: portInfo.productId || '' } + const previous = this.lastKnownDevices.get(portInfo.path) + if (previous) { + device.probeId = previous.probeId + device.baudRate = previous.baudRate + device.responseHex = previous.responseHex + device.lastDiscoveredAt = previous.lastDiscoveredAt + } this.discoveredDevices.set(portInfo.path, device) + this.probeAttempts.set(portInfo.path, 0) this._emit('usb-callback-device-attached', device) + if (device.probeId) { + this.emitDiscoveredDevice(device) + shouldProbe = false + } + } + if (shouldProbe) { + const attempts = this.probeAttempts.get(portInfo.path) || 0 + if (attempts < 6) { + this.probeAttempts.set(portInfo.path, attempts + 1) + const matched = await this.probePort(portInfo.path, 1) + if (matched) this.probeAttempts.delete(portInfo.path) + } else if (isLikelyRemoteControlPort(portInfo)) { + const device = this.discoveredDevices.get(portInfo.path) + if (device && !device.probeId) { + Object.assign(device, { + probeId: 'remote_control', + baudRate: 115200, + responseHex: '', + lastDiscoveredAt: Date.now(), + fallbackMatched: true + }) + this.discoveredDevices.set(portInfo.path, device) + this.lastKnownDevices.set(portInfo.path, { ...device }) + this.probeAttempts.delete(portInfo.path) + console.warn('[usb] remote_control fallback matched by usb metadata:', portInfo) + this.emitDiscoveredDevice(device) + } + } } - this.probePort(portInfo.path, 1) } } + emitDiscoveredDevice(device) { + const payload = { + deviceName: device.deviceName, + deviceId: device.deviceId, + port: device.port || device.ports?.[0] || 1, + ports: device.ports || [device.port || 1], + probeId: device.probeId, + baudRate: device.baudRate, + responseHex: device.responseHex || '' + } + console.log('[usb] discovered payload:', payload) + this._emit('usb-callback-device-discovered', payload) + this.onDeviceDiscovered(payload) + return payload + } + async probePort(deviceName, portNumber) { for (const probe of probes) { try { - const result = await this.lockCenter.withLock(deviceName, () => runProbe(deviceName, probe)) + const result = await this.lockCenter.withLock(deviceName, () => runProbeAttempt(deviceName, probe)) if (!result) continue - const payload = { + if (!result.matched) { + console.log('[usb] probe miss:', { + deviceName, + port: portNumber, + probeId: result.probeId, + baudRate: result.baudRate, + success: false, + responseHex: result.responseHex, + error: 'Probe not matched' + }) + continue + } + const device = this.discoveredDevices.get(deviceName) || { deviceName, - port: portNumber, + deviceId: deviceIdFromPort({ path: deviceName }), + ports: [portNumber], + manufacturer: '', + vendorId: '', + productId: '' + } + Object.assign(device, { probeId: result.probeId, baudRate: result.baudRate, - responseHex: result.responseHex - } - this._emit('usb-callback-device-discovered', payload) - this.onDeviceDiscovered(payload) - return payload + responseHex: result.responseHex, + lastDiscoveredAt: Date.now() + }) + this.discoveredDevices.set(deviceName, device) + this.lastKnownDevices.set(deviceName, { ...device }) + return this.emitDiscoveredDevice(device) } catch (err) { // A probe miss is normal while scanning mixed serial devices. } @@ -157,6 +257,7 @@ class UsbSerialBridge { baudRate, success: responseHex.length > 0, responseHex, + probeId: this.discoveredDevices.get(deviceName)?.probeId || '', error: responseHex.length > 0 ? '' : 'No response' }) } catch (err) { @@ -166,6 +267,7 @@ class UsbSerialBridge { baudRate, success: false, responseHex: '', + probeId: this.discoveredDevices.get(deviceName)?.probeId || '', error: err.message }) } @@ -175,7 +277,15 @@ class UsbSerialBridge { 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: '' }) + const device = this.discoveredDevices.get(deviceName) || {} + this._emit('usb-callback-device-opened', { + deviceName, + port: portNumber, + success: true, + error: '', + probeId: device.probeId, + baudRate: device.baudRate + }) return } @@ -185,7 +295,15 @@ class UsbSerialBridge { 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: '' }) + const device = this.discoveredDevices.get(deviceName) || {} + this._emit('usb-callback-device-opened', { + deviceName, + port: portNumber, + success: true, + error: '', + probeId: device.probeId, + baudRate: device.baudRate || baudRate + }) this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'CONNECTED' }) port.on('data', data => { @@ -251,7 +369,7 @@ class UsbSerialBridge { } disconnectAll() { - this.stopDiscovery() + this.stopDiscovery({ force: true }) for (const key of [...this.activeConnections.keys()]) { this.deviceCloseKey(key) }