This repository has been archived on 2026-10-08. You can view files and clone it, but cannot push or open issues or pull requests.
allotment/packages/app/src/ble/HubConnection.ts

133 lines
4.1 KiB
TypeScript

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<string, any>;
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<typeof setTimeout>;
}
/** 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<number, Pending>();
private listeners = new Set<EventListener>();
private chain: Promise<unknown> = 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<Message> {
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<Message> {
if (this.closed) throw new Error('Not connected');
const id = this.nextId++;
const response = new Promise<Message>((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);
}
}