7 changed files with 947 additions and 378 deletions
@ -0,0 +1,325 @@ |
|||||||
|
const { |
||||||
|
buildReadRegisters, |
||||||
|
checkCrc, |
||||||
|
parseRegisterValue, |
||||||
|
probes, |
||||||
|
runProbe, |
||||||
|
serialRequest, |
||||||
|
toInt32 |
||||||
|
} = require('./serial-protocols') |
||||||
|
|
||||||
|
const SENSOR_META = { |
||||||
|
HWT6053: { |
||||||
|
code: '1', |
||||||
|
name: 'HWT6053', |
||||||
|
baudRate: 9600, |
||||||
|
operations: [ |
||||||
|
['read-version', '获取版本号'], |
||||||
|
['read-temp', '获取温度'], |
||||||
|
['read-euler', '获取角度'], |
||||||
|
['reset-euler', '重置角度传感器基准'], |
||||||
|
['read-heading-and-vertical', '获取朝向和垂直夹角'] |
||||||
|
] |
||||||
|
}, |
||||||
|
TOF5000: { |
||||||
|
code: '2', |
||||||
|
name: 'TOF5000', |
||||||
|
baudRate: 115200, |
||||||
|
operations: [ |
||||||
|
['read-speed', '读取速度'], |
||||||
|
['read-range', '读取距离'] |
||||||
|
] |
||||||
|
}, |
||||||
|
CHC_CGI_430: { |
||||||
|
code: '3', |
||||||
|
name: 'CHC_CGI_430', |
||||||
|
baudRate: 115200, |
||||||
|
operations: [ |
||||||
|
['read-rtk', '读取RTK数据'] |
||||||
|
] |
||||||
|
}, |
||||||
|
SW_LDS20DA: { |
||||||
|
code: '6', |
||||||
|
name: 'SW_LDS20DA', |
||||||
|
baudRate: 9600, |
||||||
|
operations: [ |
||||||
|
['read-status', '读取状态'], |
||||||
|
['read-range', '读取距离'] |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const REQUIRED_METHODS = [ |
||||||
|
['CHC_CGI_430', 'read-rtk'], |
||||||
|
['TOF5000', 'read-speed'], |
||||||
|
['TOF5000', 'read-range'], |
||||||
|
['HWT6053', 'read-version'], |
||||||
|
['HWT6053', 'read-euler'], |
||||||
|
['HWT6053', 'read-heading-and-vertical'], |
||||||
|
['SW_LDS20DA', 'read-status'], |
||||||
|
['SW_LDS20DA', 'read-range'] |
||||||
|
] |
||||||
|
|
||||||
|
function sensorId(sensorName, portPath) { |
||||||
|
return `${sensorName}:${portPath}` |
||||||
|
} |
||||||
|
|
||||||
|
function makeOperations(device) { |
||||||
|
const meta = SENSOR_META[device.sensor] |
||||||
|
return (meta?.operations || []).map(([id, name]) => ({ |
||||||
|
id, |
||||||
|
name, |
||||||
|
sensorId: device.id |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
function parseHwtEuler(frame) { |
||||||
|
if (!checkCrc(frame) || frame.length < 17) return null |
||||||
|
const data = frame.slice(3, -2) |
||||||
|
const shorts = [] |
||||||
|
for (let i = 0; i < data.length; i += 2) { |
||||||
|
shorts.push(((data[i] & 0xFF) << 8) | (data[i + 1] & 0xFF)) |
||||||
|
} |
||||||
|
if (shorts.length < 6) return null |
||||||
|
const roll = toInt32(shorts[1], shorts[0]) |
||||||
|
const pitch = toInt32(shorts[3], shorts[2]) |
||||||
|
const yaw = toInt32(shorts[5], shorts[4]) |
||||||
|
return { |
||||||
|
roll: +(roll / 1000).toFixed(2), |
||||||
|
pitch: +(pitch / 1000).toFixed(2), |
||||||
|
yaw: +(yaw / 1000).toFixed(2) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function verticalFromEuler(euler) { |
||||||
|
if (!euler) return null |
||||||
|
const rollRadians = euler.roll * Math.PI / 180 |
||||||
|
const pitchRadians = euler.pitch * Math.PI / 180 |
||||||
|
const vertical = Math.acos(Math.cos(pitchRadians) * Math.cos(rollRadians)) * 180 / Math.PI |
||||||
|
return { |
||||||
|
vertical, |
||||||
|
heading: euler.yaw |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function readRegisterValue(path, baudRate, slaveAddress, action, registerAddress, timeoutMs = 300) { |
||||||
|
const frame = await serialRequest({ |
||||||
|
path, |
||||||
|
baudRate, |
||||||
|
write: buildReadRegisters(slaveAddress, action, registerAddress, 1), |
||||||
|
timeoutMs, |
||||||
|
minBytes: 7 |
||||||
|
}) |
||||||
|
return parseRegisterValue(frame) |
||||||
|
} |
||||||
|
|
||||||
|
async function readHwtEuler(path) { |
||||||
|
const frame = await serialRequest({ |
||||||
|
path, |
||||||
|
baudRate: 9600, |
||||||
|
write: buildReadRegisters(0x50, 0x03, 0x3D, 6), |
||||||
|
timeoutMs: 300, |
||||||
|
minBytes: 17 |
||||||
|
}) |
||||||
|
return parseHwtEuler(frame) |
||||||
|
} |
||||||
|
|
||||||
|
class SensorController { |
||||||
|
constructor({ onValue, onDeviceAttached, onDeviceDetached, onLog } = {}) { |
||||||
|
this.onValue = onValue || (() => {}) |
||||||
|
this.onDeviceAttached = onDeviceAttached || (() => {}) |
||||||
|
this.onDeviceDetached = onDeviceDetached || (() => {}) |
||||||
|
this.onLog = onLog || (() => {}) |
||||||
|
this.devices = [] |
||||||
|
this.lastValues = new Map() |
||||||
|
this.isOpen = false |
||||||
|
this.timers = new Set() |
||||||
|
this.discoveryRunning = false |
||||||
|
} |
||||||
|
|
||||||
|
log(message) { |
||||||
|
this.onLog('info', message) |
||||||
|
} |
||||||
|
|
||||||
|
async refresh(portList = null) { |
||||||
|
const { SerialPort } = require('./serial-protocols') |
||||||
|
const ports = portList || await SerialPort.list() |
||||||
|
const found = [] |
||||||
|
|
||||||
|
for (const portInfo of ports) { |
||||||
|
const portPath = portInfo.path |
||||||
|
if (!portPath) continue |
||||||
|
for (const probe of probes) { |
||||||
|
try { |
||||||
|
const result = await runProbe(portPath, probe) |
||||||
|
if (!result) continue |
||||||
|
const meta = SENSOR_META[result.probeId] |
||||||
|
if (!meta) continue |
||||||
|
const device = { |
||||||
|
id: sensorId(result.probeId, portPath), |
||||||
|
code: meta.code, |
||||||
|
sensor: result.probeId, |
||||||
|
name: meta.name, |
||||||
|
port: portPath, |
||||||
|
baudRate: result.baudRate, |
||||||
|
isOpen: this.isOpen, |
||||||
|
operations: [] |
||||||
|
} |
||||||
|
device.operations = makeOperations(device) |
||||||
|
found.push(device) |
||||||
|
break |
||||||
|
} catch (err) { |
||||||
|
this.log(`probe ${probe.id} on ${portPath} failed: ${err.message}`) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const oldIds = new Set(this.devices.map(d => d.id)) |
||||||
|
const newIds = new Set(found.map(d => d.id)) |
||||||
|
for (const device of found) { |
||||||
|
if (!oldIds.has(device.id)) this.onDeviceAttached(device) |
||||||
|
} |
||||||
|
for (const device of this.devices) { |
||||||
|
if (!newIds.has(device.id)) this.onDeviceDetached(device) |
||||||
|
} |
||||||
|
this.devices = found |
||||||
|
return this.devices |
||||||
|
} |
||||||
|
|
||||||
|
list() { |
||||||
|
return this.devices.map(device => ({ |
||||||
|
...device, |
||||||
|
isOpen: this.isOpen, |
||||||
|
operations: makeOperations(device) |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
async initAll() { |
||||||
|
await this.refresh() |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
init(id) { |
||||||
|
return this.devices.some(device => device.id === id) |
||||||
|
} |
||||||
|
|
||||||
|
async open() { |
||||||
|
if (this.isOpen) return true |
||||||
|
this.isOpen = true |
||||||
|
if (this.devices.length === 0) await this.refresh() |
||||||
|
this.startPolling() |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
close() { |
||||||
|
this.isOpen = false |
||||||
|
for (const timer of this.timers) clearTimeout(timer) |
||||||
|
this.timers.clear() |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
async operation(id, opId, args = '') { |
||||||
|
const device = this.devices.find(d => d.id === id) || |
||||||
|
this.devices.find(d => d.sensor === id) || |
||||||
|
this.devices.find(d => d.sensor === String(id).split(':')[0]) |
||||||
|
if (!device) return null |
||||||
|
const value = await this.readDeviceMethod(device, opId, args) |
||||||
|
if (value != null && value !== '') { |
||||||
|
this.lastValues.set(`${device.sensor}:${opId}`, value) |
||||||
|
} |
||||||
|
return value |
||||||
|
} |
||||||
|
|
||||||
|
startPolling() { |
||||||
|
for (const [sensorName, method] of REQUIRED_METHODS) { |
||||||
|
this.scheduleMethod(sensorName, method) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
scheduleMethod(sensorName, method) { |
||||||
|
const tick = async () => { |
||||||
|
if (!this.isOpen) return |
||||||
|
try { |
||||||
|
const device = this.devices.find(d => d.sensor === sensorName) |
||||||
|
if (device) { |
||||||
|
const value = await this.readDeviceMethod(device, method, '') |
||||||
|
if (value != null && value !== '') { |
||||||
|
this.lastValues.set(`${sensorName}:${method}`, value) |
||||||
|
this.onValue({ sensor: sensorName, method, value }) |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
this.log(`read ${sensorName}.${method} failed: ${err.message}`) |
||||||
|
} finally { |
||||||
|
if (this.isOpen) { |
||||||
|
const timer = setTimeout(tick, 500) |
||||||
|
this.timers.add(timer) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
const timer = setTimeout(tick, 100) |
||||||
|
this.timers.add(timer) |
||||||
|
} |
||||||
|
|
||||||
|
async readDeviceMethod(device, method) { |
||||||
|
if (device.sensor === 'TOF5000') { |
||||||
|
if (method === 'read-speed') { |
||||||
|
const value = await readRegisterValue(device.port, 115200, 0x01, 0x03, 0x00) |
||||||
|
return value && value !== 0 ? String(value) : '' |
||||||
|
} |
||||||
|
if (method === 'read-range') { |
||||||
|
const value = await readRegisterValue(device.port, 115200, 0x01, 0x04, 0x00) |
||||||
|
return value && value !== 0 ? String(value) : '' |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (device.sensor === 'SW_LDS20DA') { |
||||||
|
if (method === 'read-status') { |
||||||
|
const value = await readRegisterValue(device.port, 9600, 0x02, 0x03, 0x17) |
||||||
|
return value == null ? '' : String(value) |
||||||
|
} |
||||||
|
if (method === 'read-range') { |
||||||
|
const high = await readRegisterValue(device.port, 9600, 0x02, 0x03, 0x15) |
||||||
|
const low = await readRegisterValue(device.port, 9600, 0x02, 0x03, 0x16) |
||||||
|
if (high == null || low == null) return '' |
||||||
|
return String((((high & 0xFFFF) << 16) | (low & 0xFFFF)) / 10) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (device.sensor === 'HWT6053') { |
||||||
|
if (method === 'read-version') { |
||||||
|
const value = await readRegisterValue(device.port, 9600, 0x50, 0x03, 0x2E) |
||||||
|
return value && value !== 0 ? String(value) : '' |
||||||
|
} |
||||||
|
if (method === 'read-euler') { |
||||||
|
const value = await readHwtEuler(device.port) |
||||||
|
return value ? JSON.stringify(value) : '' |
||||||
|
} |
||||||
|
if (method === 'read-heading-and-vertical') { |
||||||
|
const euler = await readHwtEuler(device.port) |
||||||
|
const value = verticalFromEuler(euler) |
||||||
|
return value ? JSON.stringify(value) : '' |
||||||
|
} |
||||||
|
if (method === 'read-temp') { |
||||||
|
const value = await readRegisterValue(device.port, 9600, 0x50, 0x03, 0x43) |
||||||
|
return value == null ? '' : String(value / 100) |
||||||
|
} |
||||||
|
if (method === 'reset-euler') return '0' |
||||||
|
} |
||||||
|
|
||||||
|
if (device.sensor === 'CHC_CGI_430' && method === 'read-rtk') { |
||||||
|
const text = await serialRequest({ |
||||||
|
path: device.port, |
||||||
|
baudRate: 115200, |
||||||
|
write: null, |
||||||
|
timeoutMs: 1000, |
||||||
|
encoding: 'utf-8' |
||||||
|
}) |
||||||
|
return String(text || '').trim() |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
module.exports = { SENSOR_META, SensorController } |
||||||
@ -0,0 +1,201 @@ |
|||||||
|
const { SerialPort } = require('serialport') |
||||||
|
|
||||||
|
const crc16Table = new Uint16Array(256) |
||||||
|
for (let i = 0; i < 256; i++) { |
||||||
|
let crc = i |
||||||
|
for (let j = 0; j < 8; j++) { |
||||||
|
crc = (crc & 1) ? (0xA001 ^ (crc >> 1)) : (crc >> 1) |
||||||
|
} |
||||||
|
crc16Table[i] = crc |
||||||
|
} |
||||||
|
|
||||||
|
function crc16Modbus(data) { |
||||||
|
let crc = 0xFFFF |
||||||
|
for (const b of data) { |
||||||
|
crc = crc16Table[(crc ^ b) & 0xFF] ^ (crc >> 8) |
||||||
|
} |
||||||
|
return Buffer.from([crc & 0xFF, (crc >> 8) & 0xFF]) |
||||||
|
} |
||||||
|
|
||||||
|
function appendCrc(data) { |
||||||
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) |
||||||
|
return Buffer.concat([buf, crc16Modbus(buf)]) |
||||||
|
} |
||||||
|
|
||||||
|
function checkCrc(frame) { |
||||||
|
if (!frame || frame.length < 4) return false |
||||||
|
const body = frame.slice(0, -2) |
||||||
|
const crc = crc16Modbus(body) |
||||||
|
return frame[frame.length - 2] === crc[0] && frame[frame.length - 1] === crc[1] |
||||||
|
} |
||||||
|
|
||||||
|
function buildReadRegisters(slaveAddress, action, registerAddress, registerCount = 1) { |
||||||
|
return appendCrc(Buffer.from([ |
||||||
|
slaveAddress & 0xFF, |
||||||
|
action & 0xFF, |
||||||
|
(registerAddress >> 8) & 0xFF, |
||||||
|
registerAddress & 0xFF, |
||||||
|
(registerCount >> 8) & 0xFF, |
||||||
|
registerCount & 0xFF |
||||||
|
])) |
||||||
|
} |
||||||
|
|
||||||
|
function parseRegisterValue(frame) { |
||||||
|
if (!checkCrc(frame) || frame.length < 7) return null |
||||||
|
return ((frame[3] & 0xFF) << 8) | (frame[4] & 0xFF) |
||||||
|
} |
||||||
|
|
||||||
|
function toInt16(value) { |
||||||
|
return value >= 0x8000 ? value - 0x10000 : value |
||||||
|
} |
||||||
|
|
||||||
|
function toInt32(high, low) { |
||||||
|
const value = ((high & 0xFFFF) << 16) | (low & 0xFFFF) |
||||||
|
return value >= 0x80000000 ? value - 0x100000000 : value |
||||||
|
} |
||||||
|
|
||||||
|
function normalizeHex(hex) { |
||||||
|
return String(hex || '').replace(/\s+/g, '').toUpperCase() |
||||||
|
} |
||||||
|
|
||||||
|
function closePort(port) { |
||||||
|
return new Promise((resolve) => { |
||||||
|
if (!port || !port.isOpen) { |
||||||
|
resolve() |
||||||
|
return |
||||||
|
} |
||||||
|
port.close(() => resolve()) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function openPort(path, baudRate) { |
||||||
|
const port = new SerialPort({ |
||||||
|
path, |
||||||
|
baudRate: baudRate || 9600, |
||||||
|
dataBits: 8, |
||||||
|
parity: 'none', |
||||||
|
stopBits: 1, |
||||||
|
autoOpen: false, |
||||||
|
dtr: false, |
||||||
|
rts: false |
||||||
|
}) |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
port.open(err => err ? reject(new Error(err.message)) : resolve(port)) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
async function serialRequest({ |
||||||
|
path, |
||||||
|
baudRate, |
||||||
|
write, |
||||||
|
timeoutMs = 300, |
||||||
|
minBytes = 0, |
||||||
|
encoding = null |
||||||
|
}) { |
||||||
|
const port = await openPort(path, baudRate) |
||||||
|
const chunks = [] |
||||||
|
let done = false |
||||||
|
|
||||||
|
try { |
||||||
|
return await new Promise((resolve, reject) => { |
||||||
|
const finish = (err) => { |
||||||
|
if (done) return |
||||||
|
done = true |
||||||
|
clearTimeout(timer) |
||||||
|
if (err) { |
||||||
|
reject(err) |
||||||
|
return |
||||||
|
} |
||||||
|
const buf = Buffer.concat(chunks) |
||||||
|
resolve(encoding ? buf.toString(encoding) : buf) |
||||||
|
} |
||||||
|
|
||||||
|
const timer = setTimeout(() => finish(), timeoutMs) |
||||||
|
port.on('data', chunk => { |
||||||
|
chunks.push(chunk) |
||||||
|
if (minBytes > 0 && Buffer.concat(chunks).length >= minBytes) finish() |
||||||
|
}) |
||||||
|
port.on('error', err => finish(err)) |
||||||
|
|
||||||
|
if (write && write.length) { |
||||||
|
port.write(write, err => { |
||||||
|
if (err) finish(err) |
||||||
|
}) |
||||||
|
} |
||||||
|
}) |
||||||
|
} finally { |
||||||
|
await closePort(port) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const probes = [ |
||||||
|
{ |
||||||
|
id: 'TOF5000', |
||||||
|
baudRate: 115200, |
||||||
|
command: buildReadRegisters(0x01, 0x03, 0x00, 1), |
||||||
|
validate: response => { |
||||||
|
const value = parseRegisterValue(response) |
||||||
|
return value != null && value !== 0 |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'SW_LDS20DA', |
||||||
|
baudRate: 9600, |
||||||
|
command: buildReadRegisters(0x02, 0x03, 0x17, 1), |
||||||
|
validate: response => checkCrc(response) |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'HWT6053', |
||||||
|
baudRate: 9600, |
||||||
|
command: buildReadRegisters(0x50, 0x03, 0x2E, 1), |
||||||
|
validate: response => { |
||||||
|
const value = parseRegisterValue(response) |
||||||
|
return value != null && value !== 0 |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'CHC_CGI_430', |
||||||
|
baudRate: 115200, |
||||||
|
command: null, |
||||||
|
timeoutMs: 2000, |
||||||
|
encoding: 'utf-8', |
||||||
|
validate: response => String(response || '').includes('$') |
||||||
|
} |
||||||
|
] |
||||||
|
|
||||||
|
async function runProbe(path, probe) { |
||||||
|
const response = await serialRequest({ |
||||||
|
path, |
||||||
|
baudRate: probe.baudRate, |
||||||
|
write: probe.command, |
||||||
|
timeoutMs: probe.timeoutMs || 300, |
||||||
|
minBytes: probe.command ? 5 : 0, |
||||||
|
encoding: probe.encoding || null |
||||||
|
}) |
||||||
|
const responseHex = Buffer.isBuffer(response) |
||||||
|
? response.toString('hex').toUpperCase() |
||||||
|
: Buffer.from(String(response || ''), 'utf-8').toString('hex').toUpperCase() |
||||||
|
if (!probe.validate(response)) return null |
||||||
|
return { |
||||||
|
probeId: probe.id, |
||||||
|
baudRate: probe.baudRate, |
||||||
|
response, |
||||||
|
responseHex |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
SerialPort, |
||||||
|
appendCrc, |
||||||
|
buildReadRegisters, |
||||||
|
checkCrc, |
||||||
|
closePort, |
||||||
|
normalizeHex, |
||||||
|
openPort, |
||||||
|
parseRegisterValue, |
||||||
|
probes, |
||||||
|
runProbe, |
||||||
|
serialRequest, |
||||||
|
toInt16, |
||||||
|
toInt32 |
||||||
|
} |
||||||
@ -0,0 +1,261 @@ |
|||||||
|
const { ipcMain } = require('electron') |
||||||
|
const { |
||||||
|
SerialPort, |
||||||
|
closePort, |
||||||
|
normalizeHex, |
||||||
|
openPort, |
||||||
|
probes, |
||||||
|
runProbe, |
||||||
|
serialRequest |
||||||
|
} = require('./serial-protocols') |
||||||
|
|
||||||
|
function deviceIdFromPort(portInfo) { |
||||||
|
const raw = `${portInfo.path}|${portInfo.vendorId || ''}|${portInfo.productId || ''}` |
||||||
|
let hash = 0 |
||||||
|
for (let i = 0; i < raw.length; i++) { |
||||||
|
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0 |
||||||
|
} |
||||||
|
return Math.abs(hash) |
||||||
|
} |
||||||
|
|
||||||
|
class AsyncLockCenter { |
||||||
|
constructor() { |
||||||
|
this.tails = new Map() |
||||||
|
} |
||||||
|
|
||||||
|
async withLock(key, fn) { |
||||||
|
const previous = this.tails.get(key) || Promise.resolve() |
||||||
|
let release |
||||||
|
const current = new Promise(resolve => { release = resolve }) |
||||||
|
this.tails.set(key, previous.then(() => current)) |
||||||
|
await previous |
||||||
|
try { |
||||||
|
return await fn() |
||||||
|
} finally { |
||||||
|
release() |
||||||
|
if (this.tails.get(key) === current) this.tails.delete(key) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class UsbSerialBridge { |
||||||
|
constructor(mainWindow, { onDeviceDiscovered } = {}) { |
||||||
|
this.mainWindow = mainWindow |
||||||
|
this.onDeviceDiscovered = onDeviceDiscovered || (() => {}) |
||||||
|
this.activeConnections = new Map() |
||||||
|
this.discoveredDevices = new Map() |
||||||
|
this.discoveryTimer = null |
||||||
|
this.discoveryRunning = false |
||||||
|
this.lockCenter = new AsyncLockCenter() |
||||||
|
this._setupIPC() |
||||||
|
} |
||||||
|
|
||||||
|
_emit(event, data) { |
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) { |
||||||
|
this.mainWindow.webContents.send(event, JSON.stringify(data)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_setupIPC() { |
||||||
|
ipcMain.on('usb-start-discovery', () => this.startDiscovery()) |
||||||
|
ipcMain.on('usb-stop-discovery', () => this.stopDiscovery()) |
||||||
|
ipcMain.handle('usb-get-discovered', () => JSON.stringify([...this.discoveredDevices.values()])) |
||||||
|
ipcMain.on('usb-device-probe', (_e, p) => this.deviceProbe(p)) |
||||||
|
ipcMain.on('usb-device-open', (_e, p) => this.deviceOpen(p)) |
||||||
|
ipcMain.on('usb-device-close', (_e, p) => this.deviceClose(p)) |
||||||
|
ipcMain.on('usb-device-write', (_e, p) => this.deviceWrite(p)) |
||||||
|
} |
||||||
|
|
||||||
|
async startDiscovery() { |
||||||
|
if (this.discoveryRunning) return |
||||||
|
this.discoveryRunning = true |
||||||
|
await this.scanOnce() |
||||||
|
this.discoveryTimer = setInterval(() => this.scanOnce(), 2000) |
||||||
|
} |
||||||
|
|
||||||
|
stopDiscovery() { |
||||||
|
this.discoveryRunning = false |
||||||
|
if (this.discoveryTimer) clearInterval(this.discoveryTimer) |
||||||
|
this.discoveryTimer = null |
||||||
|
} |
||||||
|
|
||||||
|
async scanOnce() { |
||||||
|
let ports = [] |
||||||
|
try { |
||||||
|
ports = await SerialPort.list() |
||||||
|
} catch (err) { |
||||||
|
console.error('[usb] list failed:', err.message) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const current = new Set(ports.map(p => p.path).filter(Boolean)) |
||||||
|
for (const [deviceName, device] of this.discoveredDevices) { |
||||||
|
if (!current.has(deviceName)) { |
||||||
|
this.discoveredDevices.delete(deviceName) |
||||||
|
this._emit('usb-callback-device-detached', { |
||||||
|
deviceName, |
||||||
|
deviceId: device.deviceId |
||||||
|
}) |
||||||
|
for (const key of [...this.activeConnections.keys()]) { |
||||||
|
if (key.startsWith(`${deviceName}:`)) this.deviceCloseKey(key) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const portInfo of ports) { |
||||||
|
if (!portInfo.path) continue |
||||||
|
if (!this.discoveredDevices.has(portInfo.path)) { |
||||||
|
const device = { |
||||||
|
deviceName: portInfo.path, |
||||||
|
deviceId: deviceIdFromPort(portInfo), |
||||||
|
ports: [1], |
||||||
|
manufacturer: portInfo.manufacturer || '', |
||||||
|
vendorId: portInfo.vendorId || '', |
||||||
|
productId: portInfo.productId || '' |
||||||
|
} |
||||||
|
this.discoveredDevices.set(portInfo.path, device) |
||||||
|
this._emit('usb-callback-device-attached', device) |
||||||
|
} |
||||||
|
this.probePort(portInfo.path, 1) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async probePort(deviceName, portNumber) { |
||||||
|
for (const probe of probes) { |
||||||
|
try { |
||||||
|
const result = await this.lockCenter.withLock(deviceName, () => runProbe(deviceName, probe)) |
||||||
|
if (!result) continue |
||||||
|
const payload = { |
||||||
|
deviceName, |
||||||
|
port: portNumber, |
||||||
|
probeId: result.probeId, |
||||||
|
baudRate: result.baudRate, |
||||||
|
responseHex: result.responseHex |
||||||
|
} |
||||||
|
this._emit('usb-callback-device-discovered', payload) |
||||||
|
this.onDeviceDiscovered(payload) |
||||||
|
return payload |
||||||
|
} catch (err) { |
||||||
|
// A probe miss is normal while scanning mixed serial devices.
|
||||||
|
} |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
async deviceProbe({ deviceName, portNumber, baudRate, hexCmd, timeoutMs }) { |
||||||
|
try { |
||||||
|
const response = await this.lockCenter.withLock(deviceName, () => serialRequest({ |
||||||
|
path: deviceName, |
||||||
|
baudRate: baudRate || 9600, |
||||||
|
write: normalizeHex(hexCmd) ? Buffer.from(normalizeHex(hexCmd), 'hex') : null, |
||||||
|
timeoutMs: timeoutMs || 1000 |
||||||
|
})) |
||||||
|
const responseHex = response.toString('hex').toUpperCase() |
||||||
|
this._emit('usb-callback-device-probe-result', { |
||||||
|
deviceName, |
||||||
|
port: portNumber, |
||||||
|
baudRate, |
||||||
|
success: responseHex.length > 0, |
||||||
|
responseHex, |
||||||
|
error: responseHex.length > 0 ? '' : 'No response' |
||||||
|
}) |
||||||
|
} catch (err) { |
||||||
|
this._emit('usb-callback-device-probe-result', { |
||||||
|
deviceName, |
||||||
|
port: portNumber, |
||||||
|
baudRate, |
||||||
|
success: false, |
||||||
|
responseHex: '', |
||||||
|
error: err.message |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async deviceOpen({ deviceName, portNumber, baudRate }) { |
||||||
|
const key = `${deviceName}:${portNumber}` |
||||||
|
const existing = this.activeConnections.get(key) |
||||||
|
if (existing && existing.state === 'CONNECTED' && existing.port?.isOpen) { |
||||||
|
this._emit('usb-callback-device-opened', { deviceName, port: portNumber, success: true, error: '' }) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'CONNECTING' }) |
||||||
|
try { |
||||||
|
const port = await this.lockCenter.withLock(key, () => openPort(deviceName, baudRate || 9600)) |
||||||
|
const entry = { port, deviceName, portNumber, state: 'CONNECTED', generation: Date.now() } |
||||||
|
this.activeConnections.set(key, entry) |
||||||
|
|
||||||
|
this._emit('usb-callback-device-opened', { deviceName, port: portNumber, success: true, error: '' }) |
||||||
|
this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'CONNECTED' }) |
||||||
|
|
||||||
|
port.on('data', data => { |
||||||
|
const current = this.activeConnections.get(key) |
||||||
|
if (!current || current.generation !== entry.generation) return |
||||||
|
this._emit('usb-callback-device-data', { |
||||||
|
deviceName, |
||||||
|
port: portNumber, |
||||||
|
hexData: data.toString('hex').toUpperCase() |
||||||
|
}) |
||||||
|
}) |
||||||
|
port.on('error', err => { |
||||||
|
console.error('[usb] connection error:', err.message) |
||||||
|
this.deviceCloseKey(key) |
||||||
|
}) |
||||||
|
port.on('close', () => { |
||||||
|
if (this.activeConnections.get(key) === entry) { |
||||||
|
this.activeConnections.delete(key) |
||||||
|
this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'DISCONNECTED' }) |
||||||
|
} |
||||||
|
}) |
||||||
|
} catch (err) { |
||||||
|
this._emit('usb-callback-device-opened', { |
||||||
|
deviceName, |
||||||
|
port: portNumber, |
||||||
|
success: false, |
||||||
|
error: err.message |
||||||
|
}) |
||||||
|
this._emit('usb-callback-device-state', { deviceName, port: portNumber, state: 'DISCONNECTED' }) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
deviceClose({ deviceName, portNumber }) { |
||||||
|
this.deviceCloseKey(`${deviceName}:${portNumber}`) |
||||||
|
} |
||||||
|
|
||||||
|
async deviceCloseKey(key) { |
||||||
|
const entry = this.activeConnections.get(key) |
||||||
|
if (!entry) return |
||||||
|
this.activeConnections.delete(key) |
||||||
|
await closePort(entry.port) |
||||||
|
this._emit('usb-callback-device-state', { |
||||||
|
deviceName: entry.deviceName, |
||||||
|
port: entry.portNumber, |
||||||
|
state: 'DISCONNECTED' |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
async deviceWrite({ deviceName, portNumber, hexData }) { |
||||||
|
const key = `${deviceName}:${portNumber}` |
||||||
|
const entry = this.activeConnections.get(key) |
||||||
|
if (!entry || entry.state !== 'CONNECTED' || !entry.port?.isOpen) return |
||||||
|
|
||||||
|
const normalized = normalizeHex(hexData) |
||||||
|
if (!normalized) return |
||||||
|
const buf = Buffer.from(normalized, 'hex') |
||||||
|
await this.lockCenter.withLock(key, () => new Promise(resolve => { |
||||||
|
entry.port.write(buf, err => { |
||||||
|
if (err) console.error('[usb] write failed:', err.message) |
||||||
|
resolve() |
||||||
|
}) |
||||||
|
})) |
||||||
|
} |
||||||
|
|
||||||
|
disconnectAll() { |
||||||
|
this.stopDiscovery() |
||||||
|
for (const key of [...this.activeConnections.keys()]) { |
||||||
|
this.deviceCloseKey(key) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
module.exports = { UsbSerialBridge } |
||||||
@ -1,239 +0,0 @@ |
|||||||
/** |
|
||||||
* USB Serial JS 桥接模块 |
|
||||||
* 实现 USB 串口设备的发现、探测、连接及数据读写 |
|
||||||
* 接口规范: docs/webview-usb-serial.md |
|
||||||
*/ |
|
||||||
const { SerialPort } = require('serialport') |
|
||||||
const { ipcMain } = require('electron') |
|
||||||
|
|
||||||
class UsbSerialBridge { |
|
||||||
constructor(mainWindow) { |
|
||||||
this.mainWindow = mainWindow |
|
||||||
this.activeConnections = new Map() |
|
||||||
this.discoveredDevices = new Set() |
|
||||||
this.activeProbes = new Map() |
|
||||||
this._setupIPC() |
|
||||||
} |
|
||||||
|
|
||||||
_emit(event, data) { |
|
||||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) { |
|
||||||
this.mainWindow.webContents.send(event, JSON.stringify(data)) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
_setupIPC() { |
|
||||||
ipcMain.on('usb-start-discovery', () => this._startDiscovery()) |
|
||||||
ipcMain.on('usb-stop-discovery', () => this._stopDiscovery()) |
|
||||||
ipcMain.handle('usb-get-discovered', () => this._getDiscoveredDevices()) |
|
||||||
ipcMain.on('usb-device-probe', (_e, p) => this._deviceProbe(p)) |
|
||||||
ipcMain.on('usb-device-open', (_e, p) => this._deviceOpen(p)) |
|
||||||
ipcMain.on('usb-device-close', (_e, p) => this._deviceClose(p)) |
|
||||||
ipcMain.on('usb-device-write', (_e, p) => this._deviceWrite(p)) |
|
||||||
} |
|
||||||
|
|
||||||
async _startDiscovery() { |
|
||||||
console.log('[usb] 开始发现串口设备...') |
|
||||||
try { |
|
||||||
const ports = await SerialPort.list() |
|
||||||
for (const p of ports) { |
|
||||||
if (!this.discoveredDevices.has(p.path)) { |
|
||||||
this.discoveredDevices.add(p.path) |
|
||||||
console.log('[usb] 发现设备:', p.path) |
|
||||||
this._emit('usb-callback-device-attached', { |
|
||||||
deviceName: p.path, |
|
||||||
deviceId: parseInt(p.vendorId || '0', 16) || 0, |
|
||||||
ports: [1] |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
console.log('[usb] 发现完成, 共', ports.length, '个设备') |
|
||||||
} catch (err) { |
|
||||||
console.error('[usb] 发现失败:', err.message) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
_stopDiscovery() { |
|
||||||
console.log('[usb] 停止发现') |
|
||||||
} |
|
||||||
|
|
||||||
_getDiscoveredDevices() { |
|
||||||
return JSON.stringify([]) |
|
||||||
} |
|
||||||
|
|
||||||
async _deviceProbe({ deviceName, portNumber, baudRate, hexCmd, timeoutMs }) { |
|
||||||
const probeId = deviceName + ':' + portNumber |
|
||||||
console.log('[usb] 探测:', deviceName, 'port=', portNumber, 'baud=', baudRate) |
|
||||||
|
|
||||||
if (this.activeProbes.has(probeId)) { |
|
||||||
const old = this.activeProbes.get(probeId) |
|
||||||
clearTimeout(old.timeout) |
|
||||||
try { old.port.close() } catch {} |
|
||||||
this.activeProbes.delete(probeId) |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const port = new SerialPort({ |
|
||||||
path: deviceName, |
|
||||||
baudRate: baudRate || 9600, |
|
||||||
dataBits: 8, |
|
||||||
parity: 'none', |
|
||||||
stopBits: 1, |
|
||||||
autoOpen: false, |
|
||||||
dtr: false, |
|
||||||
rts: false |
|
||||||
}) |
|
||||||
|
|
||||||
await new Promise((resolve, reject) => { |
|
||||||
port.open(err => err ? reject(new Error(err.message)) : resolve()) |
|
||||||
}) |
|
||||||
|
|
||||||
let responseHex = '' |
|
||||||
const timeout = setTimeout(() => { |
|
||||||
this.activeProbes.delete(probeId) |
|
||||||
try { port.close() } catch {} |
|
||||||
const success = responseHex.length > 0 |
|
||||||
console.log('[usb] 探测' + (success ? '成功' : '超时') + ':', deviceName, '响应=' + (responseHex || '无')) |
|
||||||
this._emit('usb-callback-device-probe-result', { |
|
||||||
deviceName, port: portNumber, baudRate, |
|
||||||
success, responseHex, error: success ? '' : 'No response' |
|
||||||
}) |
|
||||||
}, timeoutMs || 1000) |
|
||||||
|
|
||||||
port.on('data', (data) => { |
|
||||||
responseHex += data.toString('hex').toUpperCase() |
|
||||||
}) |
|
||||||
|
|
||||||
port.on('error', (err) => { |
|
||||||
console.error('[usb] 探测错误:', err.message) |
|
||||||
}) |
|
||||||
|
|
||||||
this.activeProbes.set(probeId, { timeout, port }) |
|
||||||
|
|
||||||
if (hexCmd) { |
|
||||||
const cmdBuf = Buffer.from(hexCmd, 'hex') |
|
||||||
port.write(cmdBuf, (err) => { |
|
||||||
if (err) { |
|
||||||
clearTimeout(timeout) |
|
||||||
this.activeProbes.delete(probeId) |
|
||||||
try { port.close() } catch {} |
|
||||||
this._emit('usb-callback-device-probe-result', { |
|
||||||
deviceName, port: portNumber, baudRate, |
|
||||||
success: false, responseHex: '', error: err.message |
|
||||||
}) |
|
||||||
} else { |
|
||||||
console.log('[usb] 已发送探测命令:', hexCmd) |
|
||||||
} |
|
||||||
}) |
|
||||||
} else { |
|
||||||
console.log('[usb] 不发送探测命令, 等待自动上报...') |
|
||||||
} |
|
||||||
} catch (err) { |
|
||||||
console.error('[usb] 探测失败:', err.message) |
|
||||||
this._emit('usb-callback-device-probe-result', { |
|
||||||
deviceName, port: portNumber, baudRate, |
|
||||||
success: false, responseHex: '', error: err.message |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
async _deviceOpen({ deviceName, portNumber, baudRate }) { |
|
||||||
const connKey = deviceName + ':' + portNumber |
|
||||||
console.log('[usb] 打开:', connKey, 'baud=', baudRate) |
|
||||||
|
|
||||||
if (this.activeConnections.has(connKey)) { |
|
||||||
try { this.activeConnections.get(connKey).port.close() } catch {} |
|
||||||
this.activeConnections.delete(connKey) |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const port = new SerialPort({ |
|
||||||
path: deviceName, |
|
||||||
baudRate: baudRate || 9600, |
|
||||||
dataBits: 8, |
|
||||||
parity: 'none', |
|
||||||
stopBits: 1, |
|
||||||
autoOpen: false, |
|
||||||
dtr: false, |
|
||||||
rts: false |
|
||||||
}) |
|
||||||
|
|
||||||
await new Promise((resolve, reject) => { |
|
||||||
port.open(err => err ? reject(new Error(err.message)) : resolve()) |
|
||||||
}) |
|
||||||
|
|
||||||
const entry = { port, deviceName, portNumber } |
|
||||||
this.activeConnections.set(connKey, entry) |
|
||||||
|
|
||||||
console.log('[usb] 打开成功:', connKey) |
|
||||||
this._emit('usb-callback-device-opened', { |
|
||||||
deviceName, port: portNumber, success: true, error: '' |
|
||||||
}) |
|
||||||
this._emit('usb-callback-device-state', { |
|
||||||
deviceName, port: portNumber, state: 'CONNECTED' |
|
||||||
}) |
|
||||||
|
|
||||||
port.on('data', (data) => { |
|
||||||
this._emit('usb-callback-device-data', { |
|
||||||
deviceName, |
|
||||||
port: portNumber, |
|
||||||
hexData: data.toString('hex').toUpperCase() |
|
||||||
}) |
|
||||||
}) |
|
||||||
|
|
||||||
port.on('error', (err) => { |
|
||||||
console.error('[usb] 连接错误:', err.message) |
|
||||||
}) |
|
||||||
|
|
||||||
port.on('close', () => { |
|
||||||
console.log('[usb] 连接关闭:', connKey) |
|
||||||
this.activeConnections.delete(connKey) |
|
||||||
this._emit('usb-callback-device-state', { |
|
||||||
deviceName, port: portNumber, state: 'DISCONNECTED' |
|
||||||
}) |
|
||||||
}) |
|
||||||
} catch (err) { |
|
||||||
console.error('[usb] 打开失败:', err.message) |
|
||||||
this._emit('usb-callback-device-opened', { |
|
||||||
deviceName, port: portNumber, success: false, error: err.message |
|
||||||
}) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
_deviceClose({ deviceName, portNumber }) { |
|
||||||
const connKey = deviceName + ':' + portNumber |
|
||||||
console.log('[usb] 关闭:', connKey) |
|
||||||
if (this.activeConnections.has(connKey)) { |
|
||||||
const entry = this.activeConnections.get(connKey) |
|
||||||
try { entry.port.close() } catch {} |
|
||||||
this.activeConnections.delete(connKey) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
_deviceWrite({ deviceName, portNumber, hexData }) { |
|
||||||
const connKey = deviceName + ':' + portNumber |
|
||||||
console.log('[usb] 写入:', connKey, 'hex=' + hexData) |
|
||||||
|
|
||||||
if (!this.activeConnections.has(connKey)) { |
|
||||||
console.error('[usb] 写入失败:', connKey, '未连接') |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
const entry = this.activeConnections.get(connKey) |
|
||||||
const buf = Buffer.from(hexData, 'hex') |
|
||||||
entry.port.write(buf, (err) => { |
|
||||||
if (err) { |
|
||||||
console.error('[usb] 写入错误:', err.message) |
|
||||||
} |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
disconnectAll() { |
|
||||||
for (const [key, entry] of this.activeConnections) { |
|
||||||
console.log('[usb] 断开:', key) |
|
||||||
try { entry.port.close() } catch {} |
|
||||||
} |
|
||||||
this.activeConnections.clear() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
module.exports = { UsbSerialBridge } |
|
||||||
Loading…
Reference in new issue