Browse Source

feat: 网络、倾角、pwm、蓝牙调整

master
WIN-87ES3P38OPV\EDY 1 month ago
parent
commit
7da9f67c97
  1. 3
      .gitignore
  2. 361
      main.js
  3. 4
      package.json
  4. 380
      preload.js
  5. 13
      src/business/bluetooth-select-preload.js
  6. 212
      src/business/sensor-controller.js
  7. 116
      src/business/serial-protocols.js
  8. 158
      src/business/usb-serial-bridge.js

3
.gitignore vendored

@ -2,4 +2,5 @@
/dist /dist
/.vscode /.vscode
out/ out/
release/ release/
.electron-builder-cache/

361
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 path = require('path')
const fs = require('fs') const fs = require('fs')
const { SensorController } = require('./src/business/sensor-controller') const { SensorController } = require('./src/business/sensor-controller')
@ -14,6 +15,14 @@ const fileRoot = path.join(cacheDir, 'files')
let mainWindow = null let mainWindow = null
let usbBridge = null let usbBridge = null
let sensorController = null let sensorController = null
let bluetoothSelectWindow = null
const topBarState = {
route: '',
network: null,
pwm: false,
targetUrl: ''
}
const pwmPorts = new Set()
function ensureDir(dir) { function ensureDir(dir) {
try { 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 `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>蓝牙连接</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#f4f7fb;color:#1f2937}
.bar{height:48px;display:flex;align-items:center;justify-content:space-between;padding:0 16px;background:#fff;border-bottom:1px solid #d8e0eb}
.title{font-size:16px;font-weight:600}
.actions{display:flex;gap:8px}
button{border:1px solid #b9c7d8;background:#fff;color:#1f3a5f;border-radius:6px;padding:7px 10px;cursor:pointer}
button.primary{background:#1677ff;border-color:#1677ff;color:#fff}
.content{padding:12px}
.hint{font-size:12px;color:#667085;margin-bottom:10px}
.list{display:flex;flex-direction:column;gap:8px}
.item{background:#fff;border:1px solid #d8e0eb;border-radius:8px;padding:10px 12px;display:flex;justify-content:space-between;gap:12px;align-items:center}
.name{font-size:14px;font-weight:600}
.meta{font-size:12px;color:#667085;margin-top:4px}
.empty{padding:28px 12px;text-align:center;color:#667085;background:#fff;border:1px dashed #c8d2df;border-radius:8px}
</style>
</head>
<body>
<div class="bar">
<div class="title">蓝牙连接</div>
<div class="actions">
<button id="settings">系统蓝牙设置</button>
<button id="refresh" class="primary">刷新</button>
</div>
</div>
<div class="content">
<div class="hint">选择已配对蓝牙设备${prefix ? `,过滤前缀:${prefix}` : ''}</div>
<div id="list" class="list"><div class="empty">正在扫描已配对设备...</div></div>
</div>
<script>
const prefix = ${JSON.stringify(prefix)}
const list = document.getElementById('list')
function render(devices) {
const filtered = prefix ? devices.filter(d => String(d.name || '').startsWith(prefix)) : devices
if (!filtered.length) {
list.innerHTML = '<div class="empty">未找到已配对设备。请先在 Windows 蓝牙设置中配对设备,然后刷新。</div>'
return
}
list.innerHTML = filtered.map((d, i) => '<div class="item"><div><div class="name">' + escapeHtml(d.name || '未知设备') + '</div><div class="meta">' + escapeHtml(d.address || d.status || '') + '</div></div><button class="primary" data-index="' + i + '">选择</button></div>').join('')
Array.from(list.querySelectorAll('button[data-index]')).forEach(btn => {
btn.onclick = () => window.bluetoothSelect.select(filtered[Number(btn.dataset.index)])
})
}
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]))
}
async function refresh() {
list.innerHTML = '<div class="empty">正在扫描已配对设备...</div>'
render(await window.bluetoothSelect.list())
}
document.getElementById('refresh').onclick = refresh
document.getElementById('settings').onclick = () => window.bluetoothSelect.openSettings()
refresh()
</script>
</body>
</html>`
}
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) { function lastSensorValue(id, opId) {
if (!sensorController) return null if (!sensorController) return null
const device = sensorController.devices.find(d => d.id === id) || const device = sensorController.devices.find(d => d.id === id) ||
@ -73,11 +367,19 @@ function lastSensorValue(id, opId) {
function createSensorController() { function createSensorController() {
sensorController = new SensorController({ sensorController = new SensorController({
onValue: data => emit('sensor-data', data), onValue: data => emit('sensor-data', data),
onDeviceAttached: device => emit('sensor-attached', { id: device.id, name: device.name }), onDeviceAttached: device => {
onDeviceDetached: device => emit('sensor-detached', { id: device.id, name: device.name }), 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 }) 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() { function createWindow() {
@ -95,12 +397,28 @@ function createWindow() {
createSensorController() createSensorController()
usbBridge = new UsbSerialBridge(mainWindow, { usbBridge = new UsbSerialBridge(mainWindow, {
onDeviceDiscovered: () => { onDeviceDiscovered: payload => {
if (payload?.probeId === 'remote_control') setPwmPort(payload.deviceName, true)
if (sensorController) sensorController.refresh().catch(() => {}) if (sensorController) sensorController.refresh().catch(() => {})
updateWindowTitle()
},
onDeviceDetached: device => {
if (device?.probeId === 'remote_control') {
setPwmPort(device.deviceName, false)
}
updateWindowTitle()
} }
}) })
usbBridge.startDiscovery({ keepAlive: true }).catch(err => console.error('[usb] startup discovery failed:', err.message))
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'))
const startupTarget = getStartupTarget()
if (startupTarget) {
topBarState.targetUrl = startupTarget
mainWindow.loadURL(startupTarget)
} else {
mainWindow.loadFile(path.join(__dirname, 'dist', 'index.html'))
}
updateWindowTitle()
} }
function registerAppIpc() { function registerAppIpc() {
@ -141,6 +459,22 @@ function registerAppIpc() {
ipcMain.on('app-offline-page', () => emit('app-page-mode', 'offline')) ipcMain.on('app-offline-page', () => emit('app-page-mode', 'offline'))
ipcMain.on('app-online-page', () => emit('app-page-mode', 'online')) 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) => { ipcMain.on('app-file-list-sync', (event, dir) => {
event.returnValue = fileList(dir) event.returnValue = fileList(dir)
@ -235,11 +569,18 @@ function registerLocationAndBluetoothIpc() {
ipcMain.on('app-location-close', () => {}) ipcMain.on('app-location-close', () => {})
ipcMain.on('bluetooth-device-select', (event, namePrefix) => { 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) => { ipcMain.on('bluetooth-device-open', (event, mac) => {
event.sender.send('bluetooth-device-opened', 'false', mac || '') event.sender.send('bluetooth-device-opened', mac ? 'true' : 'false', mac || '')
event.sender.send('bluetooth-device-state-changed', mac || '', 'UNSUPPORTED') if (mac) event.sender.send('bluetooth-device-state-changed', mac, 'CONNECTED')
}) })
ipcMain.on('bluetooth-device-write', () => {}) ipcMain.on('bluetooth-device-write', () => {})
ipcMain.on('bluetooth-device-close', () => {}) ipcMain.on('bluetooth-device-close', () => {})

4
package.json

@ -1,7 +1,7 @@
{ {
"name": "光伏pc端应用", "name": "photovoltaic-pc",
"version": "1.0.0", "version": "1.0.0",
"description": "光伏pc端应用photovoltaic-pc", "description": "Photovoltaic PC application",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
"start": "electron-forge start", "start": "electron-forge start",

380
preload.js

@ -46,26 +46,137 @@ const net = {
const target = normalizePingHost(host) const target = normalizePingHost(host)
if (!target) return false if (!target) return false
const wait = Math.max(1000, Number(timeout) || 2000) 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 { for (const currentHost of hosts) {
if (!isIp) await dns.promises.lookup(target) const isIp = nodeNet.isIP(currentHost) !== 0
} catch { try {
if (!await trySystemPing(target, wait)) return false 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]) { function currentRouteText() {
if (await tryConnect(target, port, wait)) return true return window.location.hash || window.location.pathname || '#/'
} }
return trySystemPing(target, wait)
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 = `
<div id="__electron_shell_handle__"></div>
<div id="__electron_shell_panel__">
<div id="__electron_shell_header__">壳层入口</div>
<div id="__electron_shell_status__"></div>
<div id="__electron_shell_row__">
<input id="__electron_shell_url__" type="text" placeholder="输入 http(s):// 地址" />
</div>
<div id="__electron_shell_row__">
<button class="__electron_shell_btn__ __electron_shell_btn_primary__" id="__electron_shell_open__">打开网址</button>
<button class="__electron_shell_btn__" id="__electron_shell_local__">本地包</button>
</div>
<div id="__electron_shell_row__">
<button class="__electron_shell_btn__" id="__electron_shell_copy__">复制当前页</button>
</div>
<div id="__electron_shell_routes__"></div>
</div>
`
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 = { const appCallbacks = {
onFileWrite: null, onFileWrite: null,
onFileRead: null, onFileRead: null,
onFileDelete: null, onFileDelete: null,
onPageModeChanged: null onPageModeChanged: null,
onShellOpenUrlDialog: null
} }
ipcRenderer.on('app-file-write-callback', (_event, token, filePath, success, err) => { 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) => { ipcRenderer.on('app-page-mode', (_event, mode) => {
if (typeof appCallbacks.onPageModeChanged === 'function') appCallbacks.onPageModeChanged(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 = { const app = {
clearCache() { return ipcRenderer.sendSync('app-clear-cache-sync') }, clearCache() { return ipcRenderer.sendSync('app-clear-cache-sync') },
versionCode() { return ipcRenderer.sendSync('app-version-code-sync') }, versionCode() { return ipcRenderer.sendSync('app-version-code-sync') },
setCache(key, value) { return ipcRenderer.sendSync('set-cache-sync', key, value) }, shellGetTargetUrl() { return ipcRenderer.invoke('app-shell-get-target-url') },
getCache(key) { return ipcRenderer.sendSync('get-cache-sync', key) }, 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') }, offlinePage() { ipcRenderer.send('app-offline-page') },
onlinePage() { ipcRenderer.send('app-online-page') }, onlinePage() { ipcRenderer.send('app-online-page') },
exportFile(filename, content, token) { ipcRenderer.send('app-export-file', filename, content, token) }, exportFile(filename, content, token) { ipcRenderer.send('app-export-file', filename, content, token) },
@ -100,7 +283,9 @@ const app = {
get onFileDelete() { return appCallbacks.onFileDelete }, get onFileDelete() { return appCallbacks.onFileDelete },
set onFileDelete(fn) { appCallbacks.onFileDelete = fn }, set onFileDelete(fn) { appCallbacks.onFileDelete = fn },
get onPageModeChanged() { return appCallbacks.onPageModeChanged }, 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 = { const sensorCallbacks = {
@ -139,30 +324,87 @@ const sensor = {
set onOperationCallback(fn) { sensorCallbacks.onOperationCallback = fn } set onOperationCallback(fn) { sensorCallbacks.onOperationCallback = fn }
} }
const appSensorCallbacks = { onValueCallback: null } const appSensorDataArr = []
const appSensor = { const nativeAppSensor = {
version: '1.0.0', version: '1.0.0',
name: 'appSensor', name: 'appSensor',
dataArr: [],
open() { return ipcRenderer.sendSync('app-sensor-open-sync') }, open() { return ipcRenderer.sendSync('app-sensor-open-sync') },
close() { return ipcRenderer.sendSync('app-sensor-close-sync') }, close() { return ipcRenderer.sendSync('app-sensor-close-sync') },
isOpen() { return ipcRenderer.sendSync('app-sensor-is-open-sync') }, isOpen() { return ipcRenderer.sendSync('app-sensor-is-open-sync') }
get onValueCallback() { return appSensorCallbacks.onValueCallback },
set onValueCallback(fn) { appSensorCallbacks.onValueCallback = fn }
} }
ipcRenderer.on('sensor-data', (_event, data) => { ipcRenderer.on('sensor-data', (_event, data) => {
if (data && data.sensor && data.method) { 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) { if (index >= 0) {
appSensor.dataArr.splice(index, 1, data) appSensorDataArr.splice(index, 1, data)
} else { } 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 = { const appLocationCallbacks = {
onOpened: null, onOpened: null,
onLocationChanged: null onLocationChanged: null
@ -206,8 +448,18 @@ for (const [eventName, cbName] of [
['usb-callback-device-data', 'onDeviceData'] ['usb-callback-device-data', 'onDeviceData']
]) { ]) {
ipcRenderer.on(eventName, (_event, jsonStr) => { 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] const cb = usbSerialCallbacks[cbName]
if (typeof cb === 'function') cb(jsonStr) 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 = { const bluetoothSppCallbacks = {
onDeviceSelected: null, onDeviceSelected: null,
onDeviceOpened: null, onDeviceOpened: null,
@ -289,7 +619,7 @@ const bluetoothSpp = {
contextBridge.exposeInMainWorld('app', app) contextBridge.exposeInMainWorld('app', app)
contextBridge.exposeInMainWorld('net', net) contextBridge.exposeInMainWorld('net', net)
contextBridge.exposeInMainWorld('sensor', sensor) contextBridge.exposeInMainWorld('sensor', sensor)
contextBridge.exposeInMainWorld('appSensor', appSensor) contextBridge.exposeInMainWorld('__electronNativeAppSensor', nativeAppSensor)
contextBridge.exposeInMainWorld('appLocation', appLocation) contextBridge.exposeInMainWorld('appLocation', appLocation)
contextBridge.exposeInMainWorld('usbSerial', usbSerial) contextBridge.exposeInMainWorld('__electronNativeUsbSerial', usbSerial)
contextBridge.exposeInMainWorld('bluetoothSpp', bluetoothSpp) contextBridge.exposeInMainWorld('bluetoothSpp', bluetoothSpp)

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

212
src/business/sensor-controller.js

@ -1,10 +1,13 @@
const { const {
buildReadRegisters, buildReadRegisters,
checkCrc, checkCrc,
closePort,
openPort,
parseRegisterValue, parseRegisterValue,
probes, probes,
runProbe, runProbe,
serialRequest, serialRequest,
serialRequestOnOpenPort,
toInt32 toInt32
} = require('./serial-protocols') } = require('./serial-protocols')
@ -60,6 +63,13 @@ const REQUIRED_METHODS = [
['SW_LDS20DA', 'read-range'] ['SW_LDS20DA', 'read-range']
] ]
const POLLING_INTERVAL_MS = {
CHC_CGI_430: 300,
TOF5000: 200,
HWT6053: 10,
SW_LDS20DA: 200
}
function sensorId(sensorName, portPath) { function sensorId(sensorName, portPath) {
return `${sensorName}:${portPath}` return `${sensorName}:${portPath}`
} }
@ -113,12 +123,12 @@ async function readRegisterValue(path, baudRate, slaveAddress, action, registerA
return parseRegisterValue(frame) return parseRegisterValue(frame)
} }
async function readHwtEuler(path) { async function readHwtEuler(path, timeoutMs = 120) {
const frame = await serialRequest({ const frame = await serialRequest({
path, path,
baudRate: 9600, baudRate: 9600,
write: buildReadRegisters(0x50, 0x03, 0x3D, 6), write: buildReadRegisters(0x50, 0x03, 0x3D, 6),
timeoutMs: 300, timeoutMs,
minBytes: 17 minBytes: 17
}) })
return parseHwtEuler(frame) return parseHwtEuler(frame)
@ -134,7 +144,10 @@ class SensorController {
this.lastValues = new Map() this.lastValues = new Map()
this.isOpen = false this.isOpen = false
this.timers = new Set() this.timers = new Set()
this.discoveryRunning = false this.refreshTimer = null
this.refreshing = false
this.hwtPort = null
this.hwtPortPath = null
} }
log(message) { log(message) {
@ -142,48 +155,60 @@ class SensorController {
} }
async refresh(portList = null) { async refresh(portList = null) {
if (this.refreshing) return this.devices
this.refreshing = true
const { SerialPort } = require('./serial-protocols') const { SerialPort } = require('./serial-protocols')
const ports = portList || await SerialPort.list() try {
const found = [] const ports = portList || await SerialPort.list()
const currentPortPaths = new Set(ports.map(port => port.path).filter(Boolean))
for (const portInfo of ports) { const keptOpenDevices = this.isOpen
const portPath = portInfo.path ? this.devices.filter(device => currentPortPaths.has(device.port))
if (!portPath) continue : []
for (const probe of probes) { const keptPorts = new Set(keptOpenDevices.map(device => device.port))
try { const found = [...keptOpenDevices]
const result = await runProbe(portPath, probe)
if (!result) continue for (const portInfo of ports) {
const meta = SENSOR_META[result.probeId] const portPath = portInfo.path
if (!meta) continue if (!portPath) continue
const device = { if (keptPorts.has(portPath)) continue
id: sensorId(result.probeId, portPath), for (const probe of probes) {
code: meta.code, try {
sensor: result.probeId, const result = await runProbe(portPath, probe)
name: meta.name, if (!result) continue
port: portPath, const meta = SENSOR_META[result.probeId]
baudRate: result.baudRate, if (!meta) continue
isOpen: this.isOpen, const device = {
operations: [] 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 oldIds = new Set(this.devices.map(d => d.id))
const newIds = new Set(found.map(d => d.id)) const newIds = new Set(found.map(d => d.id))
for (const device of found) { for (const device of found) {
if (!oldIds.has(device.id)) this.onDeviceAttached(device) if (!oldIds.has(device.id)) this.onDeviceAttached(device)
} }
for (const device of this.devices) { for (const device of this.devices) {
if (!newIds.has(device.id)) this.onDeviceDetached(device) 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() { list() {
@ -204,17 +229,24 @@ class SensorController {
} }
async open() { async open() {
if (this.isOpen) return true if (this.isOpen) {
this.startAutoRefresh()
return true
}
this.isOpen = true this.isOpen = true
if (this.devices.length === 0) await this.refresh() if (this.devices.length === 0) await this.refresh()
this.startPolling() this.startPolling()
this.startAutoRefresh()
return true return true
} }
close() { close() {
this.isOpen = false this.isOpen = false
if (this.refreshTimer) clearTimeout(this.refreshTimer)
this.refreshTimer = null
for (const timer of this.timers) clearTimeout(timer) for (const timer of this.timers) clearTimeout(timer)
this.timers.clear() this.timers.clear()
this.closeHwtPort()
return true return true
} }
@ -232,11 +264,44 @@ class SensorController {
startPolling() { startPolling() {
for (const [sensorName, method] of REQUIRED_METHODS) { for (const [sensorName, method] of REQUIRED_METHODS) {
if (sensorName === 'HWT6053') continue
this.scheduleMethod(sensorName, method) 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) { scheduleMethod(sensorName, method) {
const interval = sensorName === 'HWT6053' && method === 'read-version'
? 2000
: (POLLING_INTERVAL_MS[sensorName] || 300)
const tick = async () => { const tick = async () => {
if (!this.isOpen) return if (!this.isOpen) return
try { try {
@ -252,13 +317,72 @@ class SensorController {
this.log(`read ${sensorName}.${method} failed: ${err.message}`) this.log(`read ${sensorName}.${method} failed: ${err.message}`)
} finally { } finally {
if (this.isOpen) { if (this.isOpen) {
const timer = setTimeout(tick, 500) this.setManagedTimeout(tick, interval)
this.timers.add(timer)
} }
} }
} }
const timer = setTimeout(tick, 100) this.setManagedTimeout(tick, 100)
this.timers.add(timer) }
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) { async readDeviceMethod(device, method) {

116
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({ async function serialRequest({
path, path,
baudRate, baudRate,
@ -91,6 +108,24 @@ async function serialRequest({
timeoutMs = 300, timeoutMs = 300,
minBytes = 0, minBytes = 0,
encoding = null 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 port = await openPort(path, baudRate)
const chunks = [] 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 = [ 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', id: 'TOF5000',
baudRate: 115200, baudRate: 115200,
@ -164,23 +259,35 @@ const probes = [
] ]
async function runProbe(path, probe) { 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({ const response = await serialRequest({
path, path,
baudRate: probe.baudRate, baudRate: probe.baudRate,
write: probe.command, write: probe.command,
timeoutMs: probe.timeoutMs || 300, timeoutMs: probe.timeoutMs || 300,
minBytes: probe.command ? 5 : 0, minBytes: probe.minBytes || (probe.command ? 7 : 0),
encoding: probe.encoding || null encoding: probe.encoding || null
}) })
const responseHex = Buffer.isBuffer(response) const responseHex = Buffer.isBuffer(response)
? response.toString('hex').toUpperCase() ? response.toString('hex').toUpperCase()
: Buffer.from(String(response || ''), 'utf-8').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 { return {
probeId: probe.id, probeId: probe.id,
baudRate: probe.baudRate, baudRate: probe.baudRate,
response, response,
responseHex responseHex,
matched: true
} }
} }
@ -195,7 +302,10 @@ module.exports = {
parseRegisterValue, parseRegisterValue,
probes, probes,
runProbe, runProbe,
runProbeAttempt,
serialRequest, serialRequest,
serialRequestOnOpenPort,
withPortLock,
toInt16, toInt16,
toInt32 toInt32
} }

158
src/business/usb-serial-bridge.js

@ -5,7 +5,7 @@ const {
normalizeHex, normalizeHex,
openPort, openPort,
probes, probes,
runProbe, runProbeAttempt,
serialRequest serialRequest
} = require('./serial-protocols') } = require('./serial-protocols')
@ -18,6 +18,18 @@ function deviceIdFromPort(portInfo) {
return Math.abs(hash) 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 { class AsyncLockCenter {
constructor() { constructor() {
this.tails = new Map() this.tails = new Map()
@ -39,13 +51,17 @@ class AsyncLockCenter {
} }
class UsbSerialBridge { class UsbSerialBridge {
constructor(mainWindow, { onDeviceDiscovered } = {}) { constructor(mainWindow, { onDeviceDiscovered, onDeviceDetached } = {}) {
this.mainWindow = mainWindow this.mainWindow = mainWindow
this.onDeviceDiscovered = onDeviceDiscovered || (() => {}) this.onDeviceDiscovered = onDeviceDiscovered || (() => {})
this.onDeviceDetached = onDeviceDetached || (() => {})
this.activeConnections = new Map() this.activeConnections = new Map()
this.discoveredDevices = new Map() this.discoveredDevices = new Map()
this.lastKnownDevices = new Map()
this.probeAttempts = new Map()
this.discoveryTimer = null this.discoveryTimer = null
this.discoveryRunning = false this.discoveryRunning = false
this.keepAliveDiscovery = false
this.lockCenter = new AsyncLockCenter() this.lockCenter = new AsyncLockCenter()
this._setupIPC() this._setupIPC()
} }
@ -66,14 +82,18 @@ class UsbSerialBridge {
ipcMain.on('usb-device-write', (_e, p) => this.deviceWrite(p)) 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 if (this.discoveryRunning) return
this.discoveryRunning = true this.discoveryRunning = true
await this.scanOnce() 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 this.discoveryRunning = false
if (this.discoveryTimer) clearInterval(this.discoveryTimer) if (this.discoveryTimer) clearInterval(this.discoveryTimer)
this.discoveryTimer = null this.discoveryTimer = null
@ -92,10 +112,19 @@ class UsbSerialBridge {
for (const [deviceName, device] of this.discoveredDevices) { for (const [deviceName, device] of this.discoveredDevices) {
if (!current.has(deviceName)) { if (!current.has(deviceName)) {
this.discoveredDevices.delete(deviceName) this.discoveredDevices.delete(deviceName)
this._emit('usb-callback-device-detached', { const lastKnown = this.lastKnownDevices.get(deviceName) || {}
const mergedDevice = { ...lastKnown, ...device }
const payload = {
deviceName, 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()]) { for (const key of [...this.activeConnections.keys()]) {
if (key.startsWith(`${deviceName}:`)) this.deviceCloseKey(key) if (key.startsWith(`${deviceName}:`)) this.deviceCloseKey(key)
} }
@ -104,6 +133,7 @@ class UsbSerialBridge {
for (const portInfo of ports) { for (const portInfo of ports) {
if (!portInfo.path) continue if (!portInfo.path) continue
let shouldProbe = true
if (!this.discoveredDevices.has(portInfo.path)) { if (!this.discoveredDevices.has(portInfo.path)) {
const device = { const device = {
deviceName: portInfo.path, deviceName: portInfo.path,
@ -113,28 +143,98 @@ class UsbSerialBridge {
vendorId: portInfo.vendorId || '', vendorId: portInfo.vendorId || '',
productId: portInfo.productId || '' 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.discoveredDevices.set(portInfo.path, device)
this.probeAttempts.set(portInfo.path, 0)
this._emit('usb-callback-device-attached', device) 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) { async probePort(deviceName, portNumber) {
for (const probe of probes) { for (const probe of probes) {
try { try {
const result = await this.lockCenter.withLock(deviceName, () => runProbe(deviceName, probe)) const result = await this.lockCenter.withLock(deviceName, () => runProbeAttempt(deviceName, probe))
if (!result) continue 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, deviceName,
port: portNumber, deviceId: deviceIdFromPort({ path: deviceName }),
ports: [portNumber],
manufacturer: '',
vendorId: '',
productId: ''
}
Object.assign(device, {
probeId: result.probeId, probeId: result.probeId,
baudRate: result.baudRate, baudRate: result.baudRate,
responseHex: result.responseHex responseHex: result.responseHex,
} lastDiscoveredAt: Date.now()
this._emit('usb-callback-device-discovered', payload) })
this.onDeviceDiscovered(payload) this.discoveredDevices.set(deviceName, device)
return payload this.lastKnownDevices.set(deviceName, { ...device })
return this.emitDiscoveredDevice(device)
} catch (err) { } catch (err) {
// A probe miss is normal while scanning mixed serial devices. // A probe miss is normal while scanning mixed serial devices.
} }
@ -157,6 +257,7 @@ class UsbSerialBridge {
baudRate, baudRate,
success: responseHex.length > 0, success: responseHex.length > 0,
responseHex, responseHex,
probeId: this.discoveredDevices.get(deviceName)?.probeId || '',
error: responseHex.length > 0 ? '' : 'No response' error: responseHex.length > 0 ? '' : 'No response'
}) })
} catch (err) { } catch (err) {
@ -166,6 +267,7 @@ class UsbSerialBridge {
baudRate, baudRate,
success: false, success: false,
responseHex: '', responseHex: '',
probeId: this.discoveredDevices.get(deviceName)?.probeId || '',
error: err.message error: err.message
}) })
} }
@ -175,7 +277,15 @@ class UsbSerialBridge {
const key = `${deviceName}:${portNumber}` const key = `${deviceName}:${portNumber}`
const existing = this.activeConnections.get(key) const existing = this.activeConnections.get(key)
if (existing && existing.state === 'CONNECTED' && existing.port?.isOpen) { 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 return
} }
@ -185,7 +295,15 @@ class UsbSerialBridge {
const entry = { port, deviceName, portNumber, state: 'CONNECTED', generation: Date.now() } const entry = { port, deviceName, portNumber, state: 'CONNECTED', generation: Date.now() }
this.activeConnections.set(key, entry) 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' }) this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'CONNECTED' })
port.on('data', data => { port.on('data', data => {
@ -251,7 +369,7 @@ class UsbSerialBridge {
} }
disconnectAll() { disconnectAll() {
this.stopDiscovery() this.stopDiscovery({ force: true })
for (const key of [...this.activeConnections.keys()]) { for (const key of [...this.activeConnections.keys()]) {
this.deviceCloseKey(key) this.deviceCloseKey(key)
} }

Loading…
Cancel
Save