mirror of
https://github.com/sipeed/NanoKVM-USB.git
synced 2026-09-11 05:59:57 -05:00
fix: handle serial port device disconnection event
This commit is contained in:
@@ -82,7 +82,7 @@ const App = () => {
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
sampleRate: 48000,
|
||||
sampleRate: 48000
|
||||
}
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
browser/src/libs/device/utils.ts
Normal file
22
browser/src/libs/device/utils.ts
Normal 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')
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user