diff --git a/browser/src/App.tsx b/browser/src/App.tsx index 0cd82fc..59a73a9 100644 --- a/browser/src/App.tsx +++ b/browser/src/App.tsx @@ -82,7 +82,7 @@ const App = () => { echoCancellation: false, noiseSuppression: false, autoGainControl: false, - sampleRate: 48000, + sampleRate: 48000 } }); stream.getTracks().forEach((track) => track.stop()); diff --git a/browser/src/components/device-modal/index.tsx b/browser/src/components/device-modal/index.tsx index 8b2a43f..9e254ec 100644 --- a/browser/src/components/device-modal/index.tsx +++ b/browser/src/components/device-modal/index.tsx @@ -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 (
diff --git a/browser/src/components/device-modal/serial-port.tsx b/browser/src/components/device-modal/serial-port.tsx index 3d66028..cdfe75e 100644 --- a/browser/src/components/device-modal/serial-port.tsx +++ b/browser/src/components/device-modal/serial-port.tsx @@ -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' && ( - - )} - + ); }; diff --git a/browser/src/components/menu/serial-port/index.tsx b/browser/src/components/menu/serial-port/index.tsx index 49d37a6..425e688 100644 --- a/browser/src/components/menu/serial-port/index.tsx +++ b/browser/src/components/menu/serial-port/index.tsx @@ -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); } diff --git a/browser/src/libs/device/serial-port.ts b/browser/src/libs/device/serial-port.ts index 7e191d7..78c1b14 100644 --- a/browser/src/libs/device/serial-port.ts +++ b/browser/src/libs/device/serial-port.ts @@ -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; + close: () => Promise; + readable: ReadableStream | null; + writable: WritableStream | 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 | null = null; + private writer: WritableStreamDefaultWriter | null = null; + private onDisconnect?: () => void; + + private disconnectHandler = (event: Event) => { + if (event.target === this.instance) { + this.handleDisconnect(); + } + }; + + async init(options: Options): Promise { + 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 { + 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 { + async read(minSize: number, delayAfterRead: number = 0): Promise { 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 { - 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); } } } diff --git a/browser/src/libs/device/utils.ts b/browser/src/libs/device/utils.ts new file mode 100644 index 0000000..1065212 --- /dev/null +++ b/browser/src/libs/device/utils.ts @@ -0,0 +1,22 @@ +export function raceWithTimeout(promise: Promise, ms: number): Promise { + return Promise.race([ + promise, + new Promise((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') + ); +}