133 lines
4.1 KiB
TypeScript
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);
|
|
}
|
|
}
|