fix(desktop): handle serial port device disconnection event

This commit is contained in:
wj-xiao
2026-02-02 17:44:36 +08:00
parent 752941cf81
commit 43fca484e8
9 changed files with 139 additions and 30 deletions

View File

@@ -9,6 +9,7 @@ export enum IpcEvents {
OPEN_SERIAL_PORT = 'open-serial-port',
OPEN_SERIAL_PORT_RSP = 'open-serial-port-rsp',
CLOSE_SERIAL_PORT = 'close-serial-port',
SERIAL_PORT_DISCONNECTED = 'serial-port-disconnected',
SEND_KEYBOARD = 'send-keyboard',
SEND_MOUSE = 'send-mouse',

View File

@@ -1,43 +1,86 @@
import { SerialPort as SP } from 'serialport'
type Options = {
path: string
baudRate?: number
onDisconnect?: () => void
}
export class SerialPort {
port: SP | null
readonly TIMEOUT = 500 // 500ms
readonly SERIAL_BAUD_RATE = 57600
readonly READ_TIMEOUT = 500
private port: SP | null
private onDisconnect?: () => void
constructor() {
this.port = null
}
async init(
path: string,
baudRate: number = 57600,
onOpen: (err: Error | null) => void
): Promise<void> {
async init(options: Options): Promise<void> {
try {
if (this.port?.isOpen) {
console.log('Closing existing serial port before opening new one')
await this.close()
await new Promise((resolve) => setTimeout(resolve, 100))
}
console.log(`Opening serial port: ${path} at ${baudRate} baud`)
const path = options.path
const baudRate = options.baudRate || this.SERIAL_BAUD_RATE
this.port = new SP({ path, baudRate }, (err) => {
if (err) {
console.error('Error opening port: ', err.message)
} else {
console.log(`Serial port ${path} opened successfully at ${baudRate} baud`)
throw err
}
onOpen(err)
})
if (options.onDisconnect) {
this.onDisconnect = options.onDisconnect
}
this.port.on('close', () => {
console.warn('Serial port closed event received')
this.handleDisconnect()
})
this.port.on('error', (err) => {
console.error('Serial port error:', err)
if (this.isDisconnectError(err)) {
this.handleDisconnect()
}
})
this.port.on('data', () => {})
} catch (err) {
console.error('Error opening serial port:', err)
throw err
}
}
private handleDisconnect(): void {
if (this.port) {
this.port.removeAllListeners()
this.port = null
}
if (this.onDisconnect) {
this.onDisconnect()
this.onDisconnect = undefined
}
}
private isDisconnectError(err: Error): boolean {
const msg = err.message.toLowerCase()
return (
msg.includes('disconnected') ||
msg.includes('device has been lost') ||
msg.includes('has been closed') ||
msg.includes('no such device')
)
}
async write(data: number[]): Promise<void> {
if (!this.port?.isOpen) {
// throw new Error('Serial port not initialized')
return
}
@@ -54,7 +97,7 @@ export class SerialPort {
const startTime = Date.now()
while (result.length < minSize) {
if (Date.now() - startTime > this.TIMEOUT) {
if (Date.now() - startTime > this.READ_TIMEOUT) {
return []
}
@@ -77,7 +120,8 @@ export class SerialPort {
async close(): Promise<void> {
if (this.port?.isOpen) {
try {
console.log('Closing serial port...')
this.port.removeAllListeners()
await new Promise<void>((resolve, reject) => {
this.port!.close((err) => {
if (err) {
@@ -89,6 +133,8 @@ export class SerialPort {
}
})
})
this.port = null
} catch (error) {
console.error('close-serial-port error', error)
throw error

View File

@@ -15,7 +15,16 @@ export function registerSerialPort(): void {
async function getSerialPorts(): Promise<string[]> {
try {
const ports = await SerialPort.list()
return ports.map((port) => port.path)
const paths = ports.map((port) => port.path)
return paths.sort((a, b) => {
const aHasUSB = a.toLowerCase().includes('usb')
const bHasUSB = b.toLowerCase().includes('usb')
if (aHasUSB && !bHasUSB) return -1
if (!aHasUSB && bHasUSB) return 1
return a.localeCompare(b)
})
} catch (error) {
console.error('Error listing serial ports:', error)
return []
@@ -28,13 +37,18 @@ async function openSerialPort(
baudRate: number = 57600
): Promise<boolean> {
try {
await device.serialPort.init(path, baudRate, (err) => {
const msg = err ? err.message : ''
e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, msg)
})
const onDisconnect = () => {
e.sender.send(IpcEvents.SERIAL_PORT_DISCONNECTED)
}
await device.serialPort.init({ path, baudRate, onDisconnect })
e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, '')
return true
} catch (error) {
console.error('Error opening serial port:', error)
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
e.sender.send(IpcEvents.OPEN_SERIAL_PORT_RSP, errorMsg)
return false
}
}

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { IpcEvents } from '@common/ipc-events'
import { DeviceModal } from '@renderer/components/device-modal'
import { Device } from '@renderer/components/device'
import { Keyboard } from '@renderer/components/keyboard'
import { Menu } from '@renderer/components/menu'
import { Mouse } from '@renderer/components/mouse'
@@ -38,7 +38,6 @@ const App = (): ReactElement => {
const setResolution = useSetAtom(resolutionAtom)
const [state, setState] = useState<State>('loading')
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
const resolution = getVideoResolution()
@@ -54,10 +53,6 @@ const App = (): ReactElement => {
}
}, [])
useEffect(() => {
setIsConnected(videoState === 'connected' && serialPortState === 'connected')
}, [videoState, serialPortState])
async function requestMediaPermissions(resolution?: Resolution): Promise<void> {
try {
const platform = await window.electron.ipcRenderer.invoke(IpcEvents.GET_PLATFORM)
@@ -116,14 +111,14 @@ const App = (): ReactElement => {
return (
<>
{isConnected ? (
<Device />
{videoState === 'connected' && serialPortState === 'connected' && (
<>
<Menu />
<Mouse />
{isKeyboardEnable && <Keyboard />}
</>
) : (
<DeviceModal />
)}
<video

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import { SerialPort } from './serial-port'
import { Video } from './video'
export const DeviceModal = (): ReactElement => {
export const Connect = (): ReactElement => {
const { t } = useTranslation()
const [errMsg, setErrMsg] = useState('')

View File

@@ -0,0 +1,33 @@
import { ReactElement, useEffect } from 'react'
import { useSetAtom } from 'jotai'
import { IpcEvents } from '@common/ipc-events'
import {
serialPortAtom,
serialPortStateAtom,
videoDeviceIdAtom,
videoStateAtom
} from '@renderer/jotai/device'
export const Disconnect = (): ReactElement => {
const setVideoState = useSetAtom(videoStateAtom)
const setVideoDeviceId = useSetAtom(videoDeviceIdAtom)
const setSerialPortState = useSetAtom(serialPortStateAtom)
const setSerialPort = useSetAtom(serialPortAtom)
useEffect(() => {
const rmListener = window.electron.ipcRenderer.on(IpcEvents.SERIAL_PORT_DISCONNECTED, () => {
setVideoState('disconnected')
setSerialPortState('disconnected')
setVideoDeviceId('')
setSerialPort('')
})
return () => {
rmListener()
}
}, [setSerialPort, setSerialPortState, setVideoDeviceId, setVideoState])
return <></>
}

View File

@@ -0,0 +1,20 @@
import { ReactElement, useEffect, useState } from 'react'
import { useAtomValue } from 'jotai'
import { serialPortStateAtom, videoStateAtom } from '@renderer/jotai/device'
import { Connect } from './connect'
import { Disconnect } from './disconnect'
export const Device = (): ReactElement => {
const videoState = useAtomValue(videoStateAtom)
const serialPortState = useAtomValue(serialPortStateAtom)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
setIsConnected(videoState === 'connected' && serialPortState === 'connected')
}, [videoState, serialPortState])
return <>{isConnected ? <Disconnect /> : <Connect />}</>
}