import type { BleManager, Device, Subscription } from 'react-native-ble-plx'; import { COMMAND, COMMAND_TIMEOUT_MS, EVENTS, SERVICE } from './constants'; import { Framer } from './framer'; export type Message = Record; export type EventListener = (event: Message) => void; export class HubError extends Error { constructor( public code: string, message: string, ) { super(message); } } export function isUnsupported(error: unknown): boolean { return error instanceof HubError && error.code === 'unknown_command'; } interface Pending { resolve: (m: Message) => void; reject: (e: Error) => void; timer: ReturnType; } /** Owns one connected hub: framing, command matching and event fan-out (BLE_PROTOCOL.md section 12.1). */ export class HubConnection { private framer = new Framer(); private nextId = 1; private pending = new Map(); private listeners = new Set(); private chain: Promise = Promise.resolve(); private subscriptions: Subscription[] = []; private closed = false; constructor( private manager: BleManager, public device: Device, ) {} /** Subscribes to Events, which starts pairing on a new phone. */ start(onDisconnect: () => void) { this.subscriptions.push( this.device.monitorCharacteristicForService(SERVICE, EVENTS, (error, characteristic) => { if (error || !characteristic?.value) return; const message = this.framer.receive(characteristic.value); if (!message) return; if (typeof message.id === 'number' && message.event === undefined) { this.settle(message); } else { this.listeners.forEach((listener) => listener(message)); } }), this.manager.onDeviceDisconnected(this.device.id, () => { this.failAll(new Error('Disconnected from the hub')); if (!this.closed) onDisconnect(); }), ); } onEvent(listener: EventListener): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** Sends one command after the previous one has finished. Rejects with HubError when ok is false. */ send(command: Message, timeoutMs = COMMAND_TIMEOUT_MS): Promise { const run = () => this.sendNow(command, timeoutMs); const result = this.chain.then(run, run); this.chain = result.catch(() => undefined); return result; } get mtu(): number { return this.device.mtu || 23; } private async sendNow(command: Message, timeoutMs: number): Promise { if (this.closed) throw new Error('Not connected'); const id = this.nextId++; const response = new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`The hub did not answer (${command.cmd})`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); }); try { for (const packet of this.framer.frames({ ...command, id }, this.mtu)) { await this.device.writeCharacteristicWithResponseForService(SERVICE, COMMAND, packet); } } catch (e) { const entry = this.pending.get(id); if (entry) { clearTimeout(entry.timer); this.pending.delete(id); } throw e; } const message = await response; if (message.ok !== true) throw new HubError(message.error ?? 'internal', message.message ?? 'Failed'); return message; } private settle(message: Message) { const entry = this.pending.get(message.id); if (!entry) return; clearTimeout(entry.timer); this.pending.delete(message.id); entry.resolve(message); } private failAll(error: Error) { this.pending.forEach((entry) => { clearTimeout(entry.timer); entry.reject(error); }); this.pending.clear(); } async close() { this.closed = true; this.subscriptions.forEach((s) => s.remove()); this.subscriptions = []; this.listeners.clear(); this.failAll(new Error('Closed')); await this.manager.cancelDeviceConnection(this.device.id).catch(() => undefined); } }