Реалізація Web Serial API на сайті
Web Serial API відкриває браузеру прямий канал до COM-портів, USB-пристроїв з послідовним інтерфейсом та Bluetooth Serial Port Profile. Принтери етикеток, Arduino, промислові датчики, медичні прилади, POS-термінали — все це тепер доступне з веб-сторінки без нативних програм, Java-апплетів, без Electron. Тільки navigator.serial, тільки браузер.
Це не простий API. Він асинхронний, потоковий, вимагає явного користувацького жесту для вибору порту і працює виключно у Secure Context (HTTPS або localhost). Реалізація без розуміння ReadableStream та WritableStream перетворюється на переповнений буфер обіцянок і завислий UI.
Підтримка та обмеження
API підтримується в Chrome 89+, Edge 89+, Opera 75+. Firefox та Safari — не підтримують. Це означає, що сторінка з Web Serial повинна або вимагати Chromium-браузер, або надавати fallback-інтерфейс (ручний ввід, завантаження файлу).
Перевірка підтримки:
if (!('serial' in navigator)) {
throw new Error('Web Serial API не підтримується. Використовуйте Chrome 89+')
}
Дозвіл Origin: у продакшені обов'язково додайте в заголовки:
Permissions-Policy: serial=*
Або обмежте конкретним origin:
Permissions-Policy: serial=(self "https://app.example.com")
Архітектура сервісу
Усю роботу з портом ізолюємо в класі. UI-компонент не знає про потоки та буфери — він викликає методи сервісу та отримує дані через коллбеки або EventEmitter.
type SerialDataHandler = (data: Uint8Array) => void
type SerialErrorHandler = (error: Error) => void
interface SerialConfig {
baudRate: number
dataBits?: 7 | 8
stopBits?: 1 | 2
parity?: 'none' | 'even' | 'odd'
bufferSize?: number
flowControl?: 'none' | 'hardware'
}
class SerialService extends EventTarget {
private port: SerialPort | null = null
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null
private writer: WritableStreamDefaultWriter<Uint8Array> | null = null
private readLoopActive = false
async requestPort(filters: SerialPortFilter[] = []): Promise<void> {
this.port = await navigator.serial.requestPort({ filters })
}
async connect(config: SerialConfig): Promise<void> {
if (!this.port) throw new Error('Порт не вибраний')
await this.port.open({
baudRate: config.baudRate,
dataBits: config.dataBits ?? 8,
stopBits: config.stopBits ?? 1,
parity: config.parity ?? 'none',
bufferSize: config.bufferSize ?? 4096,
flowControl: config.flowControl ?? 'none',
})
this.writer = this.port.writable!.getWriter()
this.startReadLoop()
}
private async startReadLoop(): Promise<void> {
if (!this.port?.readable) return
this.readLoopActive = true
while (this.port.readable && this.readLoopActive) {
this.reader = this.port.readable.getReader()
try {
while (true) {
const { value, done } = await this.reader.read()
if (done) break
if (value) {
this.dispatchEvent(
Object.assign(new Event('data'), { detail: value })
)
}
}
} catch (error) {
if (this.readLoopActive) {
this.dispatchEvent(
Object.assign(new Event('error'), { detail: error })
)
}
} finally {
this.reader.releaseLock()
}
}
}
async write(data: Uint8Array | string): Promise<void> {
if (!this.writer) throw new Error('Порт не відкритий')
const bytes =
typeof data === 'string'
? new TextEncoder().encode(data)
: data
await this.writer.write(bytes)
}
async disconnect(): Promise<void> {
this.readLoopActive = false
this.reader?.cancel()
this.writer?.releaseLock()
await this.port?.close()
this.port = null
this.reader = null
this.writer = null
}
get isConnected(): boolean {
return this.port !== null && this.port.readable !== null
}
}
Робота з протоколами
Більшість пристроїв використовують текстові або бінарні протоколи поверх UART. Приклад для пристрою з протоколом запрос-відповідь через \r\n-розділювачі:
class LineProtocolAdapter {
private buffer = ''
private pendingResolvers: Array<(line: string) => void> = []
constructor(private serial: SerialService) {
serial.addEventListener('data', (e: Event) => {
const event = e as Event & { detail: Uint8Array }
this.buffer += new TextDecoder().decode(event.detail)
this.flushLines()
})
}
private flushLines(): void {
const lines = this.buffer.split('\r\n')
this.buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.trim()) {
const resolver = this.pendingResolvers.shift()
if (resolver) resolver(line.trim())
}
}
}
async sendCommand(command: string, timeoutMs = 2000): Promise<string> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingResolvers = this.pendingResolvers.filter(r => r !== resolve)
reject(new Error(`Timeout: нема відповіді на "${command}" за ${timeoutMs}ms`))
}, timeoutMs)
this.pendingResolvers.push((line) => {
clearTimeout(timer)
resolve(line)
})
this.serial.write(command + '\r\n').catch(reject)
})
}
}
// Використання:
const adapter = new LineProtocolAdapter(serialService)
const version = await adapter.sendCommand('VERSION')
const sensorData = await adapter.sendCommand('READ SENSOR 1')
Автоматичне переподключення
Пристрої відключаються. USB витягують. Кабелі болтаються. Потрібен reconnect:
navigator.serial.addEventListener('connect', (event) => {
const port = (event as Event & { target: SerialPort }).target
console.log('Пристрій підключено:', port.getInfo())
// Перевіримо, це наш порт, та переподключимось
})
navigator.serial.addEventListener('disconnect', (event) => {
const port = (event as Event & { target: SerialPort }).target
if (port === serialService.currentPort) {
serialService.handleDisconnect()
}
})
Запит за USB Vendor/Product ID
Щоб не пропонувати користувачу всі доступні порти, а одразу показати тільки потрібний пристрій:
// Список відомих пристроїв
const DEVICE_FILTERS: SerialPortFilter[] = [
{ usbVendorId: 0x2341 }, // Arduino
{ usbVendorId: 0x0483, usbProductId: 0x5740 }, // STM32 Virtual COM
{ usbVendorId: 0x10C4, usbProductId: 0xEA60 }, // CP2102 (Silicon Labs)
{ usbVendorId: 0x0403, usbProductId: 0x6001 }, // FTDI FT232
]
await serialService.requestPort(DEVICE_FILTERS)
Збереження вибраного порту
Після першого requestPort користувач дає дозвіл. При повторному відкритті сторінки порт можна отримати без нового діалогу:
async function autoConnect(config: SerialConfig): Promise<boolean> {
const ports = await navigator.serial.getPorts()
if (ports.length === 0) return false
// Беремо перший авторизований порт (або фільтруємо за getInfo())
serialService.port = ports[0]
await serialService.connect(config)
return true
}
React-хук
function useSerialPort(config: SerialConfig) {
const serviceRef = useRef(new SerialService())
const [connected, setConnected] = useState(false)
const [lastData, setLastData] = useState<Uint8Array | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const service = serviceRef.current
const onData = (e: Event) => {
setLastData((e as any).detail)
}
const onError = (e: Event) => {
setError((e as any).detail?.message ?? 'Помилка порту')
setConnected(false)
}
service.addEventListener('data', onData)
service.addEventListener('error', onError)
return () => {
service.removeEventListener('data', onData)
service.removeEventListener('error', onError)
}
}, [])
const connect = useCallback(async () => {
try {
setError(null)
await serviceRef.current.requestPort()
await serviceRef.current.connect(config)
setConnected(true)
} catch (e) {
setError(e instanceof Error ? e.message : 'Не удалось підключитися')
}
}, [config])
const disconnect = useCallback(async () => {
await serviceRef.current.disconnect()
setConnected(false)
}, [])
const write = useCallback((data: Uint8Array | string) => {
return serviceRef.current.write(data)
}, [])
return { connected, lastData, error, connect, disconnect, write }
}
Що входить у роботу
Аналіз протоколу цільового пристрою, настройка параметрів порту (baud rate, parity, flow control), реалізація класів SerialService та протокольного адаптера, React-хук або Vue composable, обробка переподключень, обробка помилок, UI статусу підключення.
Якщо пристрій використовує проприетарний бінарний протокол — додатковий час на реверс-інжиніринг або вивчення документації.
Строк: 2–4 дні залежно від складності протоколу пристрою.







