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)
}