@intomind/sdk 0.0.0-stage → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ /**
2
+ * The browser transport.
3
+ *
4
+ * This is the only file that touches `navigator.bluetooth`. Everything
5
+ * else in this package is bytes in and events out, so the protocol, the
6
+ * session, and the timebase are all testable without a browser and without
7
+ * a device.
8
+ *
9
+ * It stays thin on purpose: find a device, connect, resolve the
10
+ * characteristics, subscribe, and hand every notification to the session.
11
+ * What the notifications mean is the session's business.
12
+ */
13
+
14
+ import {
15
+ DEVICE_INFO,
16
+ FILL,
17
+ OPCODES,
18
+ SERVICE,
19
+ uuid,
20
+ type DeviceInfo,
21
+ type Response,
22
+ } from "./protocol.ts";
23
+ import { Session, type Command, type Event as SessionEvent } from "./session.ts";
24
+ import type { Fit } from "./timebase.ts";
25
+ import type { Transfer } from "./transfer.ts";
26
+
27
+ // The Web Bluetooth surface this file uses, and no more. Declaring it here
28
+ // rather than depending on a types package keeps the dependency count at
29
+ // zero and states exactly what the transport touches.
30
+
31
+ /** The part of a GATT characteristic this transport uses. */
32
+ export interface BluetoothRemoteGATTCharacteristicLike extends EventTarget {
33
+ /** The characteristic's full identifier. */
34
+ readonly uuid: string;
35
+ /** The last value read or notified. */
36
+ readonly value?: DataView;
37
+ /** Read the current value. */
38
+ readValue(): Promise<DataView>;
39
+ /** Write a value and wait for the device's acknowledgement. */
40
+ writeValueWithResponse(value: BufferSource): Promise<void>;
41
+ /** Write a value without waiting for an acknowledgement. */
42
+ writeValueWithoutResponse(value: BufferSource): Promise<void>;
43
+ /** Subscribe to value-changed notifications. */
44
+ startNotifications(): Promise<BluetoothRemoteGATTCharacteristicLike>;
45
+ /** Unsubscribe from value-changed notifications. */
46
+ stopNotifications(): Promise<BluetoothRemoteGATTCharacteristicLike>;
47
+ }
48
+
49
+ /** The part of a GATT service this transport uses. */
50
+ export interface BluetoothRemoteGATTServiceLike {
51
+ /** Resolve one of the service's characteristics by its full identifier. */
52
+ getCharacteristic(uuid: string): Promise<BluetoothRemoteGATTCharacteristicLike>;
53
+ }
54
+
55
+ /** The part of a GATT server this transport uses. */
56
+ export interface BluetoothRemoteGATTServerLike {
57
+ /** Whether the link is currently connected. */
58
+ readonly connected: boolean;
59
+ /** Open the link. */
60
+ connect(): Promise<BluetoothRemoteGATTServerLike>;
61
+ /** Drop the link. */
62
+ disconnect(): void;
63
+ /** Resolve the device's primary service by its full identifier. */
64
+ getPrimaryService(uuid: string): Promise<BluetoothRemoteGATTServiceLike>;
65
+ }
66
+
67
+ /** The part of a chosen Bluetooth device this transport uses. */
68
+ export interface BluetoothDeviceLike extends EventTarget {
69
+ /** The browser's identifier for this device. */
70
+ readonly id: string;
71
+ /** The device's advertised name, when known. */
72
+ readonly name?: string;
73
+ /** The device's GATT server, when it has one. */
74
+ readonly gatt?: BluetoothRemoteGATTServerLike;
75
+ }
76
+
77
+ /** The part of `navigator.bluetooth` this transport uses. */
78
+ export interface BluetoothLike {
79
+ /** Put the browser's device chooser up. */
80
+ requestDevice(options: {
81
+ filters?: Array<{ services?: string[]; namePrefix?: string }>;
82
+ optionalServices?: string[];
83
+ acceptAllDevices?: boolean;
84
+ }): Promise<BluetoothDeviceLike>;
85
+ /** Whether this browser has Bluetooth available at all, when the browser reports it. */
86
+ getAvailability?(): Promise<boolean>;
87
+ }
88
+
89
+ /** Thrown when the page cannot reach Web Bluetooth at all. */
90
+ export class NoBluetooth extends Error {
91
+ constructor() {
92
+ super("this page has no Web Bluetooth: it needs a supporting browser, a secure context, and a user gesture");
93
+ this.name = "NoBluetooth";
94
+ }
95
+ }
96
+
97
+ function bluetooth(): BluetoothLike {
98
+ const nav = (globalThis as { navigator?: { bluetooth?: BluetoothLike } }).navigator;
99
+ if (nav?.bluetooth === undefined) throw new NoBluetooth();
100
+ return nav.bluetooth;
101
+ }
102
+
103
+ /**
104
+ * The clock the exchanges are read on, in seconds.
105
+ *
106
+ * Monotonic where the runtime has one, because the fit between the two
107
+ * clocks is a line and a wall clock step would bend it. A caller that puts
108
+ * its own events on the same ruler reads this same function.
109
+ */
110
+ export function hostClock(): number {
111
+ const p = (globalThis as { performance?: { now(): number; timeOrigin?: number } }).performance;
112
+ if (p !== undefined) return (p.timeOrigin ?? 0) / 1000 + p.now() / 1000;
113
+ return Date.now() / 1000;
114
+ }
115
+
116
+ /** Options for `requestDevice`. */
117
+ export interface RequestOptions {
118
+ /** Narrow the chooser further, for instance to one unit on a bench. */
119
+ namePrefix?: string;
120
+ }
121
+
122
+ /**
123
+ * Put the browser's device chooser up, filtered to devices that advertise
124
+ * the IntoMind service. This has to be called from a user gesture.
125
+ */
126
+ export function requestDevice(options: RequestOptions = {}): Promise<BluetoothDeviceLike> {
127
+ const filter: { services: string[]; namePrefix?: string } = { services: [SERVICE] };
128
+ if (options.namePrefix !== undefined) filter.namePrefix = options.namePrefix;
129
+ return bluetooth().requestDevice({ filters: [filter], optionalServices: [SERVICE] });
130
+ }
131
+
132
+ /** Options for `connect`. */
133
+ export interface ConnectOptions {
134
+ /** Drive an existing session rather than a new one. */
135
+ session?: Session;
136
+ /** Called with every event, in the order the session produced them. */
137
+ onEvent?: (event: SessionEvent) => void;
138
+ /** Called when the link drops, for whatever reason. */
139
+ onDisconnect?: () => void;
140
+ /** How long a control request waits for its answer. */
141
+ timeoutMs?: number;
142
+ }
143
+
144
+ /** A live link to one device. */
145
+ export class Connection {
146
+ /** The device this link is to. */
147
+ readonly device: BluetoothDeviceLike;
148
+ /** The session reading this device's notifications. */
149
+ readonly session: Session;
150
+ /** What the device said it is, read before anything else was asked of it. */
151
+ readonly info: DeviceInfo;
152
+
153
+ readonly #server: BluetoothRemoteGATTServerLike;
154
+ readonly #characteristics: Map<number, BluetoothRemoteGATTCharacteristicLike>;
155
+ readonly #listeners = new Set<(event: SessionEvent) => void>();
156
+ readonly #waiting: Array<{ opcode: number; settle: (answer: Response, at: number) => void }> = [];
157
+ /** Whoever is waiting for the next update answer, in order. */
158
+ readonly #updateWaiting: Array<(bytes: Uint8Array) => void> = [];
159
+ readonly #timeoutMs: number;
160
+ /**
161
+ * Verifying a finished image takes the device longer than answering a
162
+ * command, because it hashes and checks a signature over the whole of
163
+ * it before it says anything.
164
+ */
165
+ readonly #updateTimeoutMs = 30_000;
166
+
167
+ /** Build one from an open server and the characteristics `connect` resolved. */
168
+ constructor(f: {
169
+ device: BluetoothDeviceLike;
170
+ server: BluetoothRemoteGATTServerLike;
171
+ session: Session;
172
+ info: DeviceInfo;
173
+ characteristics: Map<number, BluetoothRemoteGATTCharacteristicLike>;
174
+ timeoutMs: number;
175
+ }) {
176
+ this.device = f.device;
177
+ this.session = f.session;
178
+ this.info = f.info;
179
+ this.#server = f.server;
180
+ this.#characteristics = f.characteristics;
181
+ this.#timeoutMs = f.timeoutMs;
182
+ }
183
+
184
+ /** Whether the link is currently connected. */
185
+ get connected(): boolean {
186
+ return this.#server.connected;
187
+ }
188
+
189
+ /** Take every event. The returned function stops taking them. */
190
+ on(listener: (event: SessionEvent) => void): () => void {
191
+ this.#listeners.add(listener);
192
+ return () => this.#listeners.delete(listener);
193
+ }
194
+
195
+ /** Write a command. The session says which characteristic it goes to. */
196
+ async send(command: Command): Promise<void> {
197
+ const c = this.#characteristics.get(command.characteristic);
198
+ if (c === undefined) {
199
+ throw new Error(`this device has no characteristic ${uuid(command.characteristic)}`);
200
+ }
201
+ const bytes = command.bytes.slice();
202
+ if (command.withResponse) await c.writeValueWithResponse(bytes);
203
+ else await c.writeValueWithoutResponse(bytes);
204
+ }
205
+
206
+ /** Write a command and wait for the device's answer to it. */
207
+ async request(command: Command): Promise<Response> {
208
+ const answer = this.#await(command.bytes[0]!);
209
+ await this.send(command);
210
+ return (await answer).answer;
211
+ }
212
+
213
+ /**
214
+ * One time exchange, with the host clock read either side of it. The
215
+ * device's answer is known to lie between those two readings, which is
216
+ * the whole uncertainty in this one exchange.
217
+ */
218
+ async timeSync(): Promise<Fit | null> {
219
+ const waiting = this.#await(OPCODES.time_sync);
220
+ const before = hostClock();
221
+ await this.send(this.session.timeSync());
222
+ const { answer, at } = await waiting;
223
+ return this.session.onTimeSync(answer.payload, before, at);
224
+ }
225
+
226
+ /**
227
+ * Carry an image or a head to the device and have it verified there.
228
+ *
229
+ * Nothing is activated by this. Use `activate` afterwards, and expect
230
+ * the link to drop when you do.
231
+ *
232
+ * ```javascript
233
+ * const t = Transfer.head(link.session, 1, blob);
234
+ * const result = await link.transfer(t, blob, ({ taken, total }) => {
235
+ * console.log(`${taken} of ${total}`);
236
+ * });
237
+ * ```
238
+ */
239
+ async transfer(
240
+ transfer: Transfer,
241
+ image: Uint8Array,
242
+ onProgress?: (progress: { taken: number; total: number }) => void,
243
+ ): Promise<string | null> {
244
+ try {
245
+ for (;;) {
246
+ const step = transfer.step(image);
247
+ if (step.type === "verified") return step.result;
248
+ if (step.type === "send") {
249
+ const answer = this.#awaitUpdate();
250
+ await this.send(step.command);
251
+ transfer.onAnswer(await answer, image);
252
+ onProgress?.(transfer.progress);
253
+ continue;
254
+ }
255
+ await this.send({
256
+ characteristic: step.characteristic,
257
+ bytes: image.subarray(step.from, step.to),
258
+ withResponse: false,
259
+ });
260
+ transfer.sent(step.to - step.from);
261
+ }
262
+ } catch (e) {
263
+ // A transfer that stopped leaves the device holding a part of
264
+ // something. Tell it to drop that, then report what went wrong.
265
+ try {
266
+ await this.send(transfer.abort());
267
+ this.#updateWaiting.shift();
268
+ } catch {
269
+ // The link is what failed. Nothing further to say to it.
270
+ }
271
+ throw e;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * End every subscription this link started, then drop the link. The
277
+ * order matters: on some platforms the browser keeps a subscribed link
278
+ * open after a disconnect, and the next program on the same computer
279
+ * then hears every packet twice.
280
+ */
281
+ async disconnect(): Promise<void> {
282
+ for (const fill of NOTIFYING) {
283
+ const c = this.#characteristics.get(fill);
284
+ if (c === undefined) continue;
285
+ try {
286
+ await c.stopNotifications();
287
+ } catch {
288
+ // A link that is already gone has nothing left to stop.
289
+ }
290
+ }
291
+ if (this.#server.connected) this.#server.disconnect();
292
+ }
293
+
294
+ #awaitUpdate(): Promise<Uint8Array> {
295
+ return new Promise((resolve, reject) => {
296
+ const timer = setTimeout(() => {
297
+ const i = this.#updateWaiting.indexOf(settle);
298
+ if (i >= 0) this.#updateWaiting.splice(i, 1);
299
+ reject(new Error("the device did not answer the update service in time"));
300
+ }, this.#updateTimeoutMs);
301
+ const settle = (bytes: Uint8Array) => {
302
+ clearTimeout(timer);
303
+ resolve(bytes);
304
+ };
305
+ this.#updateWaiting.push(settle);
306
+ });
307
+ }
308
+
309
+ #await(opcode: number): Promise<{ answer: Response; at: number }> {
310
+ return new Promise((resolve, reject) => {
311
+ const waiter = {
312
+ opcode,
313
+ settle: (answer: Response, at: number) => {
314
+ clearTimeout(timer);
315
+ resolve({ answer, at });
316
+ },
317
+ };
318
+ const timer = setTimeout(() => {
319
+ const i = this.#waiting.indexOf(waiter);
320
+ if (i >= 0) this.#waiting.splice(i, 1);
321
+ reject(new Error(`the device did not answer opcode 0x${opcode.toString(16)} in time`));
322
+ }, this.#timeoutMs);
323
+ this.#waiting.push(waiter);
324
+ });
325
+ }
326
+
327
+ /** @internal Called by `connect` for every notification that arrives. */
328
+ deliver(characteristic: number, bytes: Uint8Array): void {
329
+ const at = hostClock();
330
+ if (characteristic === FILL.UPDATE_CONTROL) {
331
+ // An update answer is not a session event. It goes to whichever
332
+ // transfer asked for it.
333
+ const settle = this.#updateWaiting.shift();
334
+ if (settle !== undefined) settle(bytes);
335
+ return;
336
+ }
337
+ for (const event of this.session.onNotification(characteristic, bytes)) {
338
+ if (event.type === "answer") {
339
+ const i = this.#waiting.findIndex((w) => w.opcode === event.answer.opcode);
340
+ if (i >= 0) this.#waiting.splice(i, 1)[0]!.settle(event.answer, at);
341
+ }
342
+ for (const listener of this.#listeners) listener(event);
343
+ }
344
+ }
345
+ }
346
+
347
+ /** The characteristics this transport subscribes to, in the order it tries them. */
348
+ const NOTIFYING = [
349
+ FILL.EEG_DATA,
350
+ FILL.STATUS,
351
+ FILL.CONTROL_RESPONSE,
352
+ FILL.PREDICTIONS,
353
+ // The update service answers on its own characteristic, by indication.
354
+ // Without this subscription a transfer writes into silence.
355
+ FILL.UPDATE_CONTROL,
356
+ ];
357
+ const WRITABLE = [FILL.CONTROL, FILL.UPDATE_CONTROL, FILL.UPDATE_DATA];
358
+
359
+ /**
360
+ * Connect to a device the user chose, read what it is, and subscribe to
361
+ * everything it notifies.
362
+ */
363
+ export async function connect(device: BluetoothDeviceLike, options: ConnectOptions = {}): Promise<Connection> {
364
+ if (device.gatt === undefined) throw new NoBluetooth();
365
+ const server = await device.gatt.connect();
366
+ const service = await server.getPrimaryService(SERVICE);
367
+
368
+ const session = options.session ?? new Session();
369
+ const infoCharacteristic = await service.getCharacteristic(DEVICE_INFO);
370
+ const info = session.onDeviceInfo(asBytes(await infoCharacteristic.readValue()));
371
+
372
+ const characteristics = new Map<number, BluetoothRemoteGATTCharacteristicLike>();
373
+ characteristics.set(FILL.DEVICE_INFO, infoCharacteristic);
374
+ // A device carries the characteristics its firmware offers, so one that
375
+ // is absent is not an error. What is absent shows up as a capability the
376
+ // device never claimed.
377
+ for (const fill of [...NOTIFYING, ...WRITABLE]) {
378
+ const c = await optional(service, fill);
379
+ if (c !== null) characteristics.set(fill, c);
380
+ }
381
+
382
+ const connection = new Connection({ device, server, session, info, characteristics, timeoutMs: options.timeoutMs ?? 5000 });
383
+ if (options.onEvent !== undefined) connection.on(options.onEvent);
384
+
385
+ for (const fill of NOTIFYING) {
386
+ const c = characteristics.get(fill);
387
+ if (c === undefined) continue;
388
+ c.addEventListener("characteristicvaluechanged", (event: Event) => {
389
+ const value = (event.target as BluetoothRemoteGATTCharacteristicLike).value;
390
+ if (value !== undefined) connection.deliver(fill, asBytes(value));
391
+ });
392
+ await c.startNotifications();
393
+ }
394
+
395
+ if (options.onDisconnect !== undefined) {
396
+ device.addEventListener("gattserverdisconnected", options.onDisconnect, { once: true });
397
+ }
398
+ return connection;
399
+ }
400
+
401
+ /** Ask for a device and connect to it, which is what most callers want. */
402
+ export async function requestAndConnect(options: RequestOptions & ConnectOptions = {}): Promise<Connection> {
403
+ return connect(await requestDevice(options), options);
404
+ }
405
+
406
+ async function optional(
407
+ service: BluetoothRemoteGATTServiceLike,
408
+ fill: number,
409
+ ): Promise<BluetoothRemoteGATTCharacteristicLike | null> {
410
+ try {
411
+ return await service.getCharacteristic(uuid(fill));
412
+ } catch {
413
+ return null;
414
+ }
415
+ }
416
+
417
+ function asBytes(value: DataView): Uint8Array {
418
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();
419
+ }