fix: handle serial port device disconnection event

This commit is contained in:
wj-xiao
2026-02-02 17:19:59 +08:00
parent 2a34573588
commit 752941cf81
6 changed files with 201 additions and 77 deletions

View File

@@ -82,7 +82,7 @@ const App = () => {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
sampleRate: 48000,
sampleRate: 48000
}
});
stream.getTracks().forEach((track) => track.stop());

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { Modal } from 'antd';
import { useAtomValue } from 'jotai';
import { useAtom, useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';
import { serialStateAtom, videoStateAtom } from '@/jotai/device.ts';
import { serialStateAtom, videoDeviceIdAtom, videoStateAtom } from '@/jotai/device.ts';
import { camera } from '@/libs/camera';
import { SerialPort } from './serial-port';
import { Video } from './video';
@@ -11,8 +12,9 @@ import { Video } from './video';
export const DeviceModal = () => {
const { t } = useTranslation();
const videoState = useAtomValue(videoStateAtom);
const serialState = useAtomValue(serialStateAtom);
const [videoState, setVideoState] = useAtom(videoStateAtom);
const [serialState, setSerialState] = useAtom(serialStateAtom);
const setVideoDeviceId = useSetAtom(videoDeviceIdAtom);
const [isOpen, setIsOpen] = useState(false);
const [errMsg, setErrMsg] = useState('');
@@ -28,11 +30,19 @@ export const DeviceModal = () => {
setIsOpen(true);
}, [videoState, serialState]);
const disconnect = () => {
setSerialState('disconnected');
setVideoState('disconnected');
setVideoDeviceId('');
camera.close();
};
return (
<Modal open={isOpen} title={t('modal.title')} footer={null} closable={false} destroyOnHidden>
<div className="flex flex-col items-center justify-center space-y-5 py-10">
<Video setErrMsg={setErrMsg} />
<SerialPort setErrMsg={setErrMsg} />
<SerialPort setErrMsg={setErrMsg} onDisconnect={disconnect} />
{errMsg && <span className="text-xs text-red-500">{errMsg}</span>}
</div>

View File

@@ -7,32 +7,29 @@ import { serialStateAtom } from '@/jotai/device.ts';
import { device } from '@/libs/device';
type SerialPortProps = {
onDisconnect: () => void;
setErrMsg: (msg: string) => void;
};
export const SerialPort = ({ setErrMsg }: SerialPortProps) => {
export const SerialPort = ({ setErrMsg, onDisconnect }: SerialPortProps) => {
const { t } = useTranslation();
const [serialState, setSerialState] = useAtom(serialStateAtom);
useEffect(() => {
checkSerialPort();
}, []);
function checkSerialPort() {
const isWebSerialSupported = 'serial' in navigator;
const state = isWebSerialSupported ? 'disconnected' : 'notSupported';
setSerialState(state);
}
}, [setSerialState]);
async function selectSerialPort() {
const selectSerialPort = async () => {
if (serialState === 'connecting') return;
setSerialState('connecting');
setErrMsg('');
try {
const port = await navigator.serial.requestPort();
await device.serialPort.init(port);
await device.serialPort.init({ port, onDisconnect });
setSerialState('connected');
} catch (err) {
@@ -40,20 +37,20 @@ export const SerialPort = ({ setErrMsg }: SerialPortProps) => {
setSerialState('disconnected');
setErrMsg(t('serial.failed'));
}
};
if (serialState === 'notSupported') {
return null;
}
return (
<>
{serialState !== 'notSupported' && (
<Button
type="primary"
className="w-[250px]"
loading={serialState === 'connecting'}
onClick={selectSerialPort}
>
{t('modal.selectSerial')}
</Button>
)}
</>
<Button
type="primary"
className="w-[250px]"
loading={serialState === 'connecting'}
onClick={selectSerialPort}
>
{t('modal.selectSerial')}
</Button>
);
};

View File

@@ -12,7 +12,7 @@ export const SerialPort = () => {
try {
const port = await navigator.serial.requestPort();
await device.serialPort.init(port);
await device.serialPort.init({ port });
} finally {
setIsLoading(false);
}

View File

@@ -1,41 +1,117 @@
export class SerialPort {
instance: any;
reader: ReadableStreamDefaultReader | null;
writer: WritableStreamDefaultWriter | null;
readonly TIMEOUT = 500; // 500ms
import { isDisconnectError, raceWithTimeout } from './utils';
type WebSerialPort = {
open: (options: { baudRate: number }) => Promise<void>;
close: () => Promise<void>;
readable: ReadableStream<Uint8Array> | null;
writable: WritableStream<Uint8Array> | null;
};
type Options = {
port: WebSerialPort;
baudRate?: number;
onDisconnect?: () => void;
};
export class SerialPort {
readonly SERIAL_BAUD_RATE = 57600;
readonly READ_TIMEOUT = 500;
readonly CLEANUP_TIMEOUT = 1000;
private instance: WebSerialPort | null = null;
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
private onDisconnect?: () => void;
private disconnectHandler = (event: Event) => {
if (event.target === this.instance) {
this.handleDisconnect();
}
};
async init(options: Options): Promise<void> {
if (this.instance) {
await this.close();
}
try {
this.instance = options.port;
const baudRate = options.baudRate || this.SERIAL_BAUD_RATE;
await this.instance.open({ baudRate });
} catch (err) {
this.instance = null;
console.error('Error opening serial port:', err);
throw err;
}
if (!this.instance.readable || !this.instance.writable) {
this.instance = null;
throw new Error('Serial port streams not available');
}
this.reader = this.instance.readable.getReader();
this.writer = this.instance.writable.getWriter();
if (options.onDisconnect) {
this.onDisconnect = options.onDisconnect;
}
navigator.serial.addEventListener('disconnect', this.disconnectHandler);
}
private handleDisconnect(): void {
if (!this.instance) return;
this.releaseReader();
this.releaseWriter();
constructor() {
this.instance = null;
if (this.onDisconnect) {
this.onDisconnect();
this.onDisconnect = undefined;
}
navigator.serial.removeEventListener('disconnect', this.disconnectHandler);
}
private releaseReader(): void {
if (!this.reader) return;
try {
this.reader.releaseLock();
} catch {
// Lock already released
}
this.reader = null;
}
private releaseWriter(): void {
if (!this.writer) return;
try {
this.writer.releaseLock();
} catch {
// Lock already released
}
this.writer = null;
}
async init(port: any, baudRate: number = 57600) {
async write(data: number[]): Promise<void> {
if (!this.writer) {
throw new Error('Serial port not initialized');
}
try {
if (this.instance) {
await this.close();
}
this.instance = port;
await this.instance.open({ baudRate });
this.reader = this.instance.readable!.getReader();
this.writer = this.instance.writable!.getWriter();
await this.writer.write(new Uint8Array(data));
} catch (err) {
console.error('Error opening serial port:', err);
if (isDisconnectError(err)) {
this.handleDisconnect();
throw new Error('Device disconnected');
}
throw err;
}
}
async write(data: number[]) {
if (!this.writer) {
throw new Error('Serial port not initialized');
}
const uint8Array = new Uint8Array(data);
await this.writer.write(uint8Array);
}
async read(minSize: number, sleep: number = 0): Promise<number[]> {
async read(minSize: number, delayAfterRead: number = 0): Promise<number[]> {
if (!this.reader) {
throw new Error('Serial port not initialized');
}
@@ -43,38 +119,57 @@ export class SerialPort {
const result: number[] = [];
const startTime = Date.now();
while (result.length < minSize) {
if (Date.now() - startTime > this.TIMEOUT) {
return [];
try {
while (result.length < minSize) {
const remainingTime = this.READ_TIMEOUT - (Date.now() - startTime);
if (remainingTime <= 0) return [];
const response = await raceWithTimeout(this.reader.read(), remainingTime);
if (!response || response.done || !response.value) break;
result.push(...Array.from(response.value));
}
const { value, done } = await this.reader.read();
if (done) {
break;
if (delayAfterRead > 0) {
await new Promise((resolve) => setTimeout(resolve, delayAfterRead));
}
const data = Array.from(value) as number[];
result.push(...data);
return result;
} catch (err) {
if (isDisconnectError(err)) {
this.handleDisconnect();
throw new Error('Device disconnected');
}
throw err;
}
if (sleep > 0) {
await new Promise((resolve) => setTimeout(resolve, sleep));
}
return result;
}
async close(): Promise<void> {
if (this.reader) {
await this.reader.cancel();
this.reader.releaseLock();
}
if (this.writer) {
await this.writer.close();
this.writer.releaseLock();
}
if (this.instance) {
await this.instance.close();
try {
if (this.reader) {
await raceWithTimeout(
this.reader.cancel().catch(() => {}),
this.CLEANUP_TIMEOUT
);
this.releaseReader();
}
if (this.writer) {
await raceWithTimeout(
this.writer.close().catch(() => {}),
this.CLEANUP_TIMEOUT
);
this.releaseWriter();
}
if (this.instance) {
await this.instance.close().catch(() => {});
this.instance = null;
}
navigator.serial.removeEventListener('disconnect', this.disconnectHandler);
} catch (err) {
console.error('Error during close:', err);
}
}
}

View File

@@ -0,0 +1,22 @@
export function raceWithTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
return Promise.race([
promise,
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), ms))
]);
}
export function isDisconnectError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const { name, message = '' } = err;
const msg = message.toLowerCase();
return (
name === 'NetworkError' ||
name === 'InvalidStateError' ||
name === 'NotFoundError' ||
msg.includes('disconnected') ||
msg.includes('device has been lost') ||
msg.includes('the device has been closed')
);
}