@intomind/sdk 0.0.0-stage → 1.4.1

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,2987 @@
1
+ /**
2
+ * The IntoMind BLE protocol, version 1.0.
3
+ *
4
+ * This module is the wire and nothing else. It has no device in it, no
5
+ * transport, no state, and no opinion about what a caller does with a
6
+ * message. Every function takes bytes and returns what the contract says
7
+ * those bytes mean, or throws a `ProtocolError` saying why they are not a
8
+ * message.
9
+ *
10
+ * It is held to `contract/conformance.json`, which the firmware emits from
11
+ * the codec it runs. Every message here decodes those vectors to the stated
12
+ * fields and refuses the malformed ones for the stated reason, so this
13
+ * implementation and the device cannot drift apart without a test failing.
14
+ *
15
+ * Three things are worth knowing before reading further.
16
+ *
17
+ * **Capabilities are the device's answer.** What a device can do is in the
18
+ * bits it reports, never in a table of hardware here. A device that does not
19
+ * claim a capability is not asked for it.
20
+ *
21
+ * **The timeline is never silently repaired.** A forward step in the sample
22
+ * index is a loss with an exact count. A step that is not forward is a break
23
+ * whose extent is not a number, and this module says so rather than
24
+ * returning a count that would be a fiction.
25
+ *
26
+ * **Nothing is assumed about width.** The number of channels, the converter
27
+ * resolution, the reference voltage, and the tick rate all come from the
28
+ * device, and the scaling helper takes them as arguments.
29
+ */
30
+
31
+ import { sha256 } from "./digest.ts";
32
+
33
+ export { Crc32, crc32, sha256 } from "./digest.ts";
34
+
35
+ /** The protocol version this module implements, major and minor. */
36
+ export const VERSION: readonly [number, number] = [1, 4];
37
+
38
+ /** The full identifier for one characteristic. */
39
+ export function uuid(fill: number): string {
40
+ return `f3a1${fill.toString(16).padStart(4, "0")}-2c4b-4d1e-9a6f-1b2c3d4e5f60`;
41
+ }
42
+
43
+ /**
44
+ * The sixteen bit fill each characteristic sits at. A notification is
45
+ * routed by this rather than by its full identifier, so nothing downstream
46
+ * has to format or compare strings.
47
+ */
48
+ export const FILL = {
49
+ SERVICE: 0x0001,
50
+ DEVICE_INFO: 0x0002,
51
+ CONTROL: 0x0003,
52
+ CONTROL_RESPONSE: 0x0004,
53
+ EEG_DATA: 0x0005,
54
+ STATUS: 0x0006,
55
+ /** Held by an earlier development firmware. Never reused. */
56
+ RESERVED_0007: 0x0007,
57
+ UPDATE_CONTROL: 0x0008,
58
+ UPDATE_DATA: 0x0009,
59
+ PREDICTIONS: 0x000a,
60
+ /** 1.2: the encoder's output for each window. */
61
+ EMBEDDINGS: 0x000b,
62
+ } as const;
63
+
64
+ /** The service's full identifier, advertised by every device. */
65
+ export const SERVICE = uuid(FILL.SERVICE);
66
+ /** Device Info's full identifier. */
67
+ export const DEVICE_INFO = uuid(FILL.DEVICE_INFO);
68
+ /** Control Point's full identifier. */
69
+ export const CONTROL = uuid(FILL.CONTROL);
70
+ /** Control Response's full identifier. */
71
+ export const CONTROL_RESPONSE = uuid(FILL.CONTROL_RESPONSE);
72
+ /** EEG Data's full identifier. */
73
+ export const EEG_DATA = uuid(FILL.EEG_DATA);
74
+ /** Status's full identifier. */
75
+ export const STATUS = uuid(FILL.STATUS);
76
+ /** Update Control's full identifier. */
77
+ export const UPDATE_CONTROL = uuid(FILL.UPDATE_CONTROL);
78
+ /** Update Data's full identifier. */
79
+ export const UPDATE_DATA = uuid(FILL.UPDATE_DATA);
80
+ /** Predictions' full identifier. */
81
+ export const PREDICTIONS = uuid(FILL.PREDICTIONS);
82
+ /** Embeddings' full identifier. */
83
+ export const EMBEDDINGS = uuid(FILL.EMBEDDINGS);
84
+
85
+ /** The name a device advertises under, before its own identifier. */
86
+ export const NAME_PREFIX = "IntoMind-";
87
+ /**
88
+ * 1.3: the product name a composed device name ends with. A 1.3 device
89
+ * advertises `[name's] [adjective] IntoMind One`; a 1.2 device advertised
90
+ * `IntoMind-` and four hex digits.
91
+ */
92
+ export const PRODUCT_NAME = "IntoMind One";
93
+
94
+ // --- refusals ---------------------------------------------------------------
95
+
96
+ /** Why bytes are not a message. The contract distinguishes these three. */
97
+ export type Refusal = "truncated" | "invalid" | "reserved";
98
+
99
+ /** Bytes that are not a message, with the reason they are not. */
100
+ export class ProtocolError extends Error {
101
+ /** Which of the three reasons this is. */
102
+ readonly reason: Refusal;
103
+
104
+ /** Build one with its reason and message. A decoder throws one of the three subclasses. */
105
+ constructor(reason: Refusal, message: string) {
106
+ super(message);
107
+ this.name = "ProtocolError";
108
+ this.reason = reason;
109
+ }
110
+ }
111
+
112
+ /** The message ends before the fields it promises. */
113
+ export class Truncated extends ProtocolError {
114
+ /** Build one with the message saying what is missing. */
115
+ constructor(message: string) {
116
+ super("truncated", message);
117
+ this.name = "Truncated";
118
+ }
119
+ }
120
+
121
+ /** A field holds a value the contract does not define. */
122
+ export class Invalid extends ProtocolError {
123
+ /** Build one with the message saying what is wrong. */
124
+ constructor(message: string) {
125
+ super("invalid", message);
126
+ this.name = "Invalid";
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A number the contract reserves. A device answers these unsupported rather
132
+ * than treating them as malformed, and so does a host.
133
+ */
134
+ export class Reserved extends ProtocolError {
135
+ /** Build one with the message naming the reserved number. */
136
+ constructor(message: string) {
137
+ super("reserved", message);
138
+ this.name = "Reserved";
139
+ }
140
+ }
141
+
142
+ // --- bytes ------------------------------------------------------------------
143
+
144
+ const EMPTY = new Uint8Array(0);
145
+
146
+ function view(b: Uint8Array): DataView {
147
+ return new DataView(b.buffer, b.byteOffset, b.byteLength);
148
+ }
149
+
150
+ function hex(b: Uint8Array): string {
151
+ let s = "";
152
+ for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, "0");
153
+ return s;
154
+ }
155
+
156
+ /** Bytes from a hex string, for tests and for anything that logs one. */
157
+ export function fromHex(s: string): Uint8Array {
158
+ if (s.length % 2 !== 0) throw new Invalid("a hex string has an even number of characters");
159
+ const out = new Uint8Array(s.length / 2);
160
+ for (let i = 0; i < out.length; i++) {
161
+ const byte = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
162
+ if (Number.isNaN(byte)) throw new Invalid(`${s.slice(i * 2, i * 2 + 2)} is not a hex byte`);
163
+ out[i] = byte;
164
+ }
165
+ return out;
166
+ }
167
+
168
+ export { hex as toHex };
169
+
170
+ /** A fixed width name field, without the padding the wire carries. */
171
+ function nameOf(b: Uint8Array): string {
172
+ let end = b.length;
173
+ while (end > 0 && b[end - 1] === 0) end--;
174
+ return new TextDecoder("utf-8").decode(b.subarray(0, end));
175
+ }
176
+
177
+ function paddedName(name: string, width: number): Uint8Array {
178
+ const out = new Uint8Array(width);
179
+ out.set(new TextEncoder().encode(name).subarray(0, width));
180
+ return out;
181
+ }
182
+
183
+ // --- the control point ------------------------------------------------------
184
+
185
+ /** Every control point opcode this contract defines, by name. */
186
+ export const OPCODES = {
187
+ start_stream: 0x01,
188
+ stop_stream: 0x02,
189
+ set_rate: 0x10,
190
+ set_gain: 0x11,
191
+ set_mode: 0x20,
192
+ set_leadoff: 0x30,
193
+ time_sync: 0x40,
194
+ set_samples_per_packet: 0x41,
195
+ get_battery: 0x42,
196
+ get_boot_info: 0x43,
197
+ reset_epoch: 0x50,
198
+ clear_bonds: 0x53,
199
+ set_predictions: 0x80,
200
+ select_head: 0x81,
201
+ list_heads: 0x82,
202
+ remove_head: 0x83,
203
+ get_model_info: 0x84,
204
+ // New in 1.1.
205
+ set_prediction_input: 0x85,
206
+ get_prediction_input: 0x86,
207
+ set_bias: 0x32,
208
+ get_bias_diagnostic: 0x33,
209
+ get_pipeline_catalog: 0x90,
210
+ get_pipeline: 0x91,
211
+ set_pipeline: 0x92,
212
+ clear_pipeline: 0x93,
213
+ restore_pipeline_default: 0x94,
214
+ // New in 1.2.
215
+ get_converter_registers: 0x34,
216
+ get_indicator: 0x44,
217
+ set_indicator: 0x45,
218
+ identify: 0x46,
219
+ set_embeddings: 0x87,
220
+ // New in 1.3.
221
+ set_model_interval: 0x88,
222
+ get_model_interval: 0x89,
223
+ get_name: 0x47,
224
+ set_name: 0x48,
225
+ // New in 1.4.
226
+ list_head_encoders: 0x8a,
227
+ soft_reset: 0xf0,
228
+ } as const;
229
+
230
+ /** The name of any opcode `OPCODES` defines. */
231
+ export type OpcodeName = keyof typeof OPCODES;
232
+
233
+ /** Opcode names by their number, the reverse of `OPCODES`. */
234
+ export const NAME_BY_OPCODE: ReadonlyMap<number, OpcodeName> = new Map(
235
+ (Object.entries(OPCODES) as [OpcodeName, number][]).map(([name, code]) => [code, name]),
236
+ );
237
+
238
+ /** Opcodes that take one argument byte, and require it. */
239
+ export const TAKES_ARG: ReadonlySet<OpcodeName> = new Set<OpcodeName>([
240
+ "set_rate",
241
+ "set_gain",
242
+ "set_mode",
243
+ "set_leadoff",
244
+ "set_samples_per_packet",
245
+ "set_predictions",
246
+ "select_head",
247
+ "remove_head",
248
+ "set_bias",
249
+ "set_indicator",
250
+ "identify",
251
+ "set_embeddings",
252
+ ]);
253
+
254
+ /**
255
+ * Opcodes new in 1.1 that carry a payload after the opcode byte, laid out
256
+ * as the processing section below says. The only requests longer than two
257
+ * bytes.
258
+ */
259
+ export const TAKES_PAYLOAD: ReadonlySet<OpcodeName> = new Set<OpcodeName>([
260
+ "set_pipeline",
261
+ "set_prediction_input",
262
+ "set_model_interval",
263
+ "set_name",
264
+ ]);
265
+
266
+ /**
267
+ * Opcodes a 1.0 device does not know. A host sends one only to a device
268
+ * whose Device Info claims the capability behind it.
269
+ */
270
+ export const NEW_IN_1_1: ReadonlySet<OpcodeName> = new Set<OpcodeName>([
271
+ "set_prediction_input",
272
+ "get_prediction_input",
273
+ "set_bias",
274
+ "get_bias_diagnostic",
275
+ "get_pipeline_catalog",
276
+ "get_pipeline",
277
+ "set_pipeline",
278
+ "clear_pipeline",
279
+ "restore_pipeline_default",
280
+ ]);
281
+
282
+ /** Opcodes a 1.1 device does not know, likewise gated on a capability. */
283
+ export const NEW_IN_1_2: ReadonlySet<OpcodeName> = new Set<OpcodeName>([
284
+ "get_converter_registers",
285
+ "get_indicator",
286
+ "set_indicator",
287
+ "identify",
288
+ "set_embeddings",
289
+ ]);
290
+ /** Opcodes a 1.2 device does not know: the model's cadence and the device's name. */
291
+ export const NEW_IN_1_3: ReadonlySet<OpcodeName> = new Set<OpcodeName>([
292
+ "set_model_interval",
293
+ "get_model_interval",
294
+ "get_name",
295
+ "set_name",
296
+ ]);
297
+
298
+ /**
299
+ * Numbers an earlier development firmware used, and numbers the contract
300
+ * keeps for factory and bench builds. Never reused for anything else.
301
+ */
302
+ export const RESERVED_OPCODES: ReadonlySet<number> = new Set([
303
+ 0x12,
304
+ 0x13,
305
+ 0x31,
306
+ 0x70,
307
+ 0x71,
308
+ ...Array.from({ length: 16 }, (_, i) => 0x60 + i),
309
+ ]);
310
+
311
+ /** A control response's status byte, in words. */
312
+ export const STATUS_CODES: Readonly<Record<number, string>> = {
313
+ 0: "ok",
314
+ 1: "invalid argument",
315
+ 2: "unsupported",
316
+ 3: "busy",
317
+ 4: "not streaming",
318
+ 5: "hardware",
319
+ // 1.4: a stream of the natural signal refused while USB power is present.
320
+ // 6 stays unassigned because the Update service uses it.
321
+ 7: "usb power",
322
+ };
323
+
324
+ /** The gain a `gain_code` of 0 to 6 selects. */
325
+ export const GAIN_BY_CODE: readonly number[] = [1, 2, 4, 6, 8, 12, 24];
326
+ /** The code for a gain, the reverse of `GAIN_BY_CODE`. */
327
+ export const CODE_BY_GAIN: ReadonlyMap<number, number> = new Map(GAIN_BY_CODE.map((g, i) => [g, i]));
328
+ /** The rate in samples per second a `rate_code` selects. */
329
+ export const RATE_BY_CODE: Readonly<Record<number, number>> = { 4: 1000, 5: 500, 6: 250 };
330
+ /** The code for a rate, the reverse of `RATE_BY_CODE`. */
331
+ export const CODE_BY_RATE: ReadonlyMap<number, number> = new Map([
332
+ [1000, 4],
333
+ [500, 5],
334
+ [250, 6],
335
+ ]);
336
+ /** The bit in Device Info's rate mask that stands for each rate. */
337
+ export const RATE_BY_INFO_BIT: Readonly<Record<number, number>> = { 0: 250, 1: 500, 2: 1000 };
338
+
339
+ /** 1.3 adds synthetic: the converter is not driven and the device generates the signal, on a device that claims it. */
340
+ export const MODES = { normal: 0, test: 1, short: 2, synthetic: 3 } as const;
341
+ /** The name of any mode `MODES` defines. */
342
+ export type ModeName = keyof typeof MODES;
343
+ /** Mode names by their code, the reverse of `MODES`. */
344
+ export const MODE_BY_CODE: Readonly<Record<number, ModeName>> = { 0: "normal", 1: "test", 2: "short", 3: "synthetic" };
345
+
346
+ /** What a charger state code means. */
347
+ export const CHARGER_STATES: Readonly<Record<number, string>> = {
348
+ 0: "no input",
349
+ 1: "charging",
350
+ 2: "complete",
351
+ 3: "fault",
352
+ 4: "standby",
353
+ };
354
+
355
+ /** Why the device last booted, by `boot_reason` code. */
356
+ export const BOOT_REASONS: Readonly<Record<number, string>> = {
357
+ 0: "power on",
358
+ 1: "reset pin",
359
+ 2: "software",
360
+ 3: "watchdog",
361
+ 4: "fault",
362
+ 5: "update",
363
+ 6: "rollback",
364
+ 0xff: "unknown",
365
+ };
366
+
367
+ /**
368
+ * One control point write.
369
+ *
370
+ * `opcode` is a name or a number. An opcode that takes an argument requires
371
+ * one, and one that does not takes none, because the device refuses either
372
+ * mistake and a host should not make it.
373
+ */
374
+ export function encodeRequest(
375
+ opcode: OpcodeName | number,
376
+ arg: number | Uint8Array | null = null,
377
+ ): Uint8Array {
378
+ const name = typeof opcode === "string" ? opcode : NAME_BY_OPCODE.get(opcode) ?? null;
379
+ const code = name === null ? undefined : OPCODES[name];
380
+ if (name === null || code === undefined) {
381
+ const shown = typeof opcode === "string" ? opcode : toByte(opcode);
382
+ throw new Invalid(`${shown} is not an opcode this contract defines`);
383
+ }
384
+ if (TAKES_PAYLOAD.has(name)) {
385
+ if (!(arg instanceof Uint8Array)) throw new Invalid(`${name} takes a payload of bytes`);
386
+ const out = new Uint8Array(1 + arg.length);
387
+ out[0] = code;
388
+ out.set(arg, 1);
389
+ return out;
390
+ }
391
+ if (arg instanceof Uint8Array) throw new Invalid(`${name} takes no payload`);
392
+ if (TAKES_ARG.has(name) !== (arg !== null)) {
393
+ throw new Invalid(`${name} takes ${TAKES_ARG.has(name) ? "an argument" : "no argument"}`);
394
+ }
395
+ return arg === null ? new Uint8Array([code]) : new Uint8Array([code, arg & 0xff]);
396
+ }
397
+
398
+ /** One control point write, as `decodeRequest` reads it. */
399
+ export interface Request {
400
+ /** The raw opcode byte, even one this contract does not define. */
401
+ readonly opcode: number;
402
+ /** The opcode's name. */
403
+ readonly name: OpcodeName;
404
+ /** The one argument byte, for an opcode that takes one. */
405
+ readonly arg: number | null;
406
+ /** The bytes after the opcode, for an opcode that takes a payload. */
407
+ readonly payload: Uint8Array | null;
408
+ }
409
+
410
+ /**
411
+ * A control point write, as the device reads it. For tests and for anything
412
+ * that has to speak both sides of the contract.
413
+ */
414
+ export function decodeRequest(data: Uint8Array): Request {
415
+ if (data.length === 0) throw new Truncated("an empty write is not a request");
416
+ const code = data[0]!;
417
+ if (RESERVED_OPCODES.has(code)) throw new Reserved(`${toByte(code)} is reserved`);
418
+ const name = NAME_BY_OPCODE.get(code);
419
+ if (name === undefined) throw new Invalid(`${toByte(code)} is not an opcode this contract defines`);
420
+ const rest = data.subarray(1);
421
+ if (TAKES_PAYLOAD.has(name)) {
422
+ // The payload's own decoder judges a chain; the 1.3 payloads are judged here.
423
+ if (name === "set_model_interval" && rest.length !== 2) {
424
+ throw new Invalid("set_model_interval takes two bytes, the seconds little-endian");
425
+ }
426
+ if (name === "set_name") decodeNameParts(rest);
427
+ return { opcode: code, name, arg: null, payload: rest.slice() };
428
+ }
429
+ if (TAKES_ARG.has(name)) {
430
+ if (rest.length !== 1) throw new Invalid(`${name} takes exactly one argument byte`);
431
+ return { opcode: code, name, arg: rest[0]!, payload: null };
432
+ }
433
+ if (rest.length !== 0) throw new Invalid(`${name} takes no argument`);
434
+ return { opcode: code, name, arg: null, payload: null };
435
+ }
436
+
437
+ /** One control response, as `decodeResponse` reads it. */
438
+ export interface Response {
439
+ /** The opcode this answers, echoed back even when it is not one this contract defines. */
440
+ readonly opcode: number;
441
+ /** Null for a number this contract does not define, which is echoed back. */
442
+ readonly name: OpcodeName | null;
443
+ /** The status byte, 0 for success. */
444
+ readonly status: number;
445
+ /** `status` in words, from `STATUS_CODES`. */
446
+ readonly statusName: string;
447
+ /** Whether `status` is 0. */
448
+ readonly ok: boolean;
449
+ /** The bytes after the opcode and status, specific to what was asked. */
450
+ readonly payload: Uint8Array;
451
+ }
452
+
453
+ /** One control response, as the device sends it. */
454
+ export function decodeResponse(data: Uint8Array): Response {
455
+ if (data.length < 2) throw new Truncated("a response is at least an opcode and a status");
456
+ const status = data[1]!;
457
+ return {
458
+ opcode: data[0]!,
459
+ name: NAME_BY_OPCODE.get(data[0]!) ?? null,
460
+ status,
461
+ statusName: STATUS_CODES[status] ?? `undefined status ${status}`,
462
+ ok: status === 0,
463
+ payload: data.slice(2),
464
+ };
465
+ }
466
+
467
+ /**
468
+ * One control response. The opcode is a number rather than a name because a
469
+ * device echoes back whatever it was sent, including a reserved number.
470
+ */
471
+ export function encodeResponse(opcode: number, status: number, payload: Uint8Array = EMPTY): Uint8Array {
472
+ const out = new Uint8Array(2 + payload.length);
473
+ out[0] = opcode;
474
+ out[1] = status;
475
+ out.set(payload, 2);
476
+ return out;
477
+ }
478
+
479
+ /** The device's own time, from the answer to a time request. */
480
+ export function decodeTimeSync(payload: Uint8Array): bigint {
481
+ if (payload.length < 8) throw new Truncated("a device time is eight bytes");
482
+ return view(payload).getBigUint64(0, true);
483
+ }
484
+
485
+ function toByte(n: number): string {
486
+ return `0x${n.toString(16).padStart(2, "0")}`;
487
+ }
488
+
489
+ // --- device info ------------------------------------------------------------
490
+
491
+ /** What a device can do, as the bits it reports. */
492
+ export const CAPABILITIES = {
493
+ battery_voltage: 1 << 0,
494
+ battery_low_flag: 1 << 1,
495
+ leadoff: 1 << 2,
496
+ test_signal: 1 << 3,
497
+ dcdc_mode: 1 << 4,
498
+ input_short: 1 << 5,
499
+ update: 1 << 6,
500
+ model: 1 << 7,
501
+ model_ready: 1 << 8,
502
+ heads: 1 << 9,
503
+ // New in 1.1.
504
+ pipeline: 1 << 10,
505
+ bias_drive: 1 << 11,
506
+ // New in 1.2.
507
+ embeddings: 1 << 12,
508
+ indicator: 1 << 13,
509
+ converter_registers: 1 << 14,
510
+ // New in 1.3.
511
+ model_cadence: 1 << 15,
512
+ } as const;
513
+
514
+ /** The name of any capability bit `CAPABILITIES` defines. */
515
+ export type CapabilityName = keyof typeof CAPABILITIES;
516
+
517
+ /** 1.3: capability bits 16 and up, the second word of Device Info at offset 76. Bit 16 of the contract is bit 0 here. */
518
+ export const CAPABILITIES_HIGH = {
519
+ synthetic: 1 << 0,
520
+ device_name: 1 << 1,
521
+ } as const;
522
+
523
+ /** The name of any capability bit `CAPABILITIES_HIGH` defines. */
524
+ export type CapabilityHighName = keyof typeof CAPABILITIES_HIGH;
525
+
526
+ /** Length of the v0.1 prefix, which 1.0 left where it was. */
527
+ export const INFO_V01_LEN = 27;
528
+ /** Length of the 1.0 layout. */
529
+ export const INFO_LEN_1_0 = 76;
530
+ /** 1.3: the 1.0 layout with a second capability word appended. */
531
+ export const INFO_LEN = 80;
532
+ /** What a device with no battery telemetry reports instead of a percent. */
533
+ export const BATTERY_UNKNOWN = 0xff;
534
+
535
+ /** What `DeviceInfo`'s constructor takes. The optional fields default to zero or null. */
536
+ export interface DeviceInfoFields {
537
+ /** Protocol major and minor. */
538
+ protocol: readonly [number, number];
539
+ /** Firmware major, minor, and patch. */
540
+ firmware: readonly [number, number, number];
541
+ /** Channel count. */
542
+ channels: number;
543
+ /** Converter resolution, in bits. */
544
+ adcBits: number;
545
+ /** Device time ticks per second. */
546
+ tickHz: number;
547
+ /** Converter reference voltage, in microvolts. */
548
+ vrefUv: number;
549
+ /** The sixteen bit capability mask. */
550
+ capabilities: number;
551
+ /** Which rates the device supports, as a bit mask. */
552
+ supportedRates: number;
553
+ /** The device's stable per-unit identifier, as hex. */
554
+ deviceId: string;
555
+ /** Hardware major, minor, and patch, when the device reports the 1.0 extension. */
556
+ hardware?: readonly [number, number, number] | null;
557
+ /** The exact firmware build, when reported. */
558
+ buildId?: string | null;
559
+ /** The model's embedding width, zero for no model runtime. */
560
+ modelEmbedDim?: number;
561
+ /** The model's native sample rate. */
562
+ modelNativeSps?: number;
563
+ /** Samples per prediction window at the model's native rate. */
564
+ modelWindowSamples?: number;
565
+ /** User head slots. */
566
+ headSlots?: number;
567
+ /** The largest `out_dim` a head may have. */
568
+ headMaxOutputs?: number;
569
+ /** Capacity of one head slot, in bytes. */
570
+ headSlotBytes?: number;
571
+ /** The largest Update Data write the device accepts. */
572
+ updateChunkMax?: number;
573
+ /** Capacity of one application slot, including its header. */
574
+ appSlotBytes?: number;
575
+ /** Capacity of the weights image, including its header. */
576
+ weightsImageBytes?: number;
577
+ /** 1.3: capability bits 16 and up. Zero on a device whose info ends at 76 bytes. */
578
+ capabilitiesHigh?: number;
579
+ }
580
+
581
+ /** Everything a device says about itself, and nothing a host assumed. */
582
+ export class DeviceInfo {
583
+ /** Protocol major and minor. */
584
+ readonly protocol: readonly [number, number];
585
+ /** Firmware major, minor, and patch. */
586
+ readonly firmware: readonly [number, number, number];
587
+ /** Channel count. */
588
+ readonly channels: number;
589
+ /** Converter resolution, in bits. */
590
+ readonly adcBits: number;
591
+ /** Device time ticks per second. */
592
+ readonly tickHz: number;
593
+ /** Converter reference voltage, in microvolts. */
594
+ readonly vrefUv: number;
595
+ /** The sixteen bit capability mask, bits 0 to 15. `can` is how to read it. */
596
+ readonly capabilities: number;
597
+ /** Which rates the device supports, as a bit mask. `rates` is how to read it. */
598
+ readonly supportedRates: number;
599
+ /** The device's stable per-unit identifier, as hex. */
600
+ readonly deviceId: string;
601
+ /** Present on a device that reports the 1.0 layout. */
602
+ readonly hardware: readonly [number, number, number] | null;
603
+ /** The exact firmware build, or null when the device reports no extension. */
604
+ readonly buildId: string | null;
605
+ /** The model's embedding width, zero for no model runtime. */
606
+ readonly modelEmbedDim: number;
607
+ /** The model's native sample rate. */
608
+ readonly modelNativeSps: number;
609
+ /** Samples per prediction window at the model's native rate. */
610
+ readonly modelWindowSamples: number;
611
+ /** User head slots. */
612
+ readonly headSlots: number;
613
+ /** The largest `out_dim` a head may have. */
614
+ readonly headMaxOutputs: number;
615
+ /** Capacity of one head slot, in bytes. */
616
+ readonly headSlotBytes: number;
617
+ /** The largest Update Data write the device accepts. */
618
+ readonly updateChunkMax: number;
619
+ /** Capacity of one application slot, including its header. */
620
+ readonly appSlotBytes: number;
621
+ /** Capacity of the weights image, including its header. */
622
+ readonly weightsImageBytes: number;
623
+ /** The second capability word, bits 16 and up (1.3). Zero on a device whose info ends at 76 bytes. */
624
+ readonly capabilitiesHigh: number;
625
+
626
+ /** Build from the fields `decodeDeviceInfo` read, or assembled by hand. */
627
+ constructor(f: DeviceInfoFields) {
628
+ this.protocol = [f.protocol[0], f.protocol[1]];
629
+ this.firmware = [f.firmware[0], f.firmware[1], f.firmware[2]];
630
+ this.channels = f.channels;
631
+ this.adcBits = f.adcBits;
632
+ this.tickHz = f.tickHz;
633
+ this.vrefUv = f.vrefUv;
634
+ this.capabilities = f.capabilities;
635
+ this.supportedRates = f.supportedRates;
636
+ this.deviceId = f.deviceId;
637
+ this.hardware = f.hardware ?? null;
638
+ this.buildId = f.buildId ?? null;
639
+ this.modelEmbedDim = f.modelEmbedDim ?? 0;
640
+ this.modelNativeSps = f.modelNativeSps ?? 0;
641
+ this.modelWindowSamples = f.modelWindowSamples ?? 0;
642
+ this.headSlots = f.headSlots ?? 0;
643
+ this.headMaxOutputs = f.headMaxOutputs ?? 0;
644
+ this.headSlotBytes = f.headSlotBytes ?? 0;
645
+ this.updateChunkMax = f.updateChunkMax ?? 0;
646
+ this.appSlotBytes = f.appSlotBytes ?? 0;
647
+ this.weightsImageBytes = f.weightsImageBytes ?? 0;
648
+ this.capabilitiesHigh = f.capabilitiesHigh ?? 0;
649
+ }
650
+
651
+ /**
652
+ * Whether the device claims a capability. Unknown names are false rather
653
+ * than an error, so a host written against a later version of this
654
+ * contract still runs.
655
+ */
656
+ can(capability: CapabilityName | CapabilityHighName | string): boolean {
657
+ const high = (CAPABILITIES_HIGH as Record<string, number>)[capability];
658
+ if (high !== undefined) return (this.capabilitiesHigh & high) !== 0;
659
+ const bit = (CAPABILITIES as Record<string, number>)[capability] ?? 0;
660
+ return (this.capabilities & bit) !== 0;
661
+ }
662
+
663
+ /** Whether the device reported the 1.0 extension. */
664
+ get extensionPresent(): boolean {
665
+ return this.hardware !== null;
666
+ }
667
+
668
+ /** The rates the device declares, from its own mask. */
669
+ get rates(): number[] {
670
+ return Object.entries(RATE_BY_INFO_BIT)
671
+ .map(([bit, sps]) => [Number(bit), sps] as const)
672
+ .filter(([bit]) => (this.supportedRates & (1 << bit)) !== 0)
673
+ .map(([, sps]) => sps)
674
+ .sort((a, b) => a - b);
675
+ }
676
+
677
+ /** `protocol` as `major.minor`. */
678
+ get protocolString(): string {
679
+ return `${this.protocol[0]}.${this.protocol[1]}`;
680
+ }
681
+
682
+ /** `firmware` as `major.minor.patch`. */
683
+ get firmwareString(): string {
684
+ return `${this.firmware[0]}.${this.firmware[1]}.${this.firmware[2]}`;
685
+ }
686
+
687
+ /** `hardware` as `major.minor.patch`, or null when the device reports no extension. */
688
+ get hardwareString(): string | null {
689
+ return this.hardware === null ? null : `${this.hardware[0]}.${this.hardware[1]}.${this.hardware[2]}`;
690
+ }
691
+
692
+ /** One count, in microvolts, at a gain. From the device's own reference and width. */
693
+ microvoltsPerCount(gain: number): number {
694
+ return (2.0 * this.vrefUv) / (gain * 2 ** this.adcBits);
695
+ }
696
+ }
697
+
698
+ /** Device Info, as the device sends it. */
699
+ export function decodeDeviceInfo(data: Uint8Array): DeviceInfo {
700
+ if (data.length < INFO_V01_LEN) throw new Truncated(`device info is at least ${INFO_V01_LEN} bytes`);
701
+ const v = view(data);
702
+ const head: DeviceInfoFields = {
703
+ protocol: [data[0]!, data[1]!],
704
+ firmware: [data[2]!, data[3]!, data[4]!],
705
+ channels: data[5]!,
706
+ adcBits: data[6]!,
707
+ tickHz: v.getUint32(7, true),
708
+ vrefUv: v.getUint32(11, true),
709
+ capabilities: v.getUint16(15, true),
710
+ supportedRates: data[17]!,
711
+ deviceId: hex(data.subarray(19, 27)),
712
+ };
713
+ if (data.length === INFO_V01_LEN) return new DeviceInfo(head);
714
+ const infoLen = data[27]!;
715
+ if (infoLen < INFO_LEN_1_0) {
716
+ throw new Invalid(
717
+ `a device speaking ${head.protocol[0]}.${head.protocol[1]} states its info is ${infoLen} bytes, ` +
718
+ `which is shorter than the ${INFO_LEN_1_0} the 1.0 layout defines`,
719
+ );
720
+ }
721
+ if (data.length < INFO_LEN_1_0) {
722
+ throw new Truncated(`device info promises ${infoLen} bytes and carries ${data.length}`);
723
+ }
724
+ // 1.3: the second capability word, when the device says its info reaches it.
725
+ const capabilitiesHigh = infoLen >= INFO_LEN && data.length >= INFO_LEN ? v.getUint32(76, true) : 0;
726
+ return new DeviceInfo({
727
+ ...head,
728
+ hardware: [data[28]!, data[29]!, data[30]!],
729
+ buildId: hex(data.subarray(32, 40)),
730
+ modelEmbedDim: v.getUint16(40, true),
731
+ modelNativeSps: v.getUint16(42, true),
732
+ modelWindowSamples: v.getUint16(44, true),
733
+ headSlots: data[46]!,
734
+ headMaxOutputs: data[47]!,
735
+ headSlotBytes: v.getUint16(48, true),
736
+ updateChunkMax: v.getUint16(50, true),
737
+ appSlotBytes: v.getUint32(52, true),
738
+ weightsImageBytes: v.getUint32(56, true),
739
+ capabilitiesHigh,
740
+ });
741
+ }
742
+
743
+ /**
744
+ * Device info as a device reports it. A device that has no extension to
745
+ * report encodes the v0.1 prefix, which is what an older one sends.
746
+ */
747
+ export function encodeDeviceInfo(info: DeviceInfo): Uint8Array {
748
+ // A device with nothing in its second word still writes the 1.3 length: the
749
+ // contract's current layout is 80 bytes and a 1.2 reader stops at 76.
750
+ const out = new Uint8Array(info.extensionPresent ? INFO_LEN : INFO_V01_LEN);
751
+ const v = view(out);
752
+ out[0] = info.protocol[0];
753
+ out[1] = info.protocol[1];
754
+ out[2] = info.firmware[0];
755
+ out[3] = info.firmware[1];
756
+ out[4] = info.firmware[2];
757
+ out[5] = info.channels;
758
+ out[6] = info.adcBits;
759
+ v.setUint32(7, info.tickHz, true);
760
+ v.setUint32(11, info.vrefUv, true);
761
+ v.setUint16(15, info.capabilities, true);
762
+ out[17] = info.supportedRates;
763
+ out.set(fromHex(info.deviceId), 19);
764
+ if (!info.extensionPresent) return out;
765
+ out[27] = INFO_LEN;
766
+ out[28] = info.hardware![0];
767
+ out[29] = info.hardware![1];
768
+ out[30] = info.hardware![2];
769
+ out.set(fromHex(info.buildId ?? "0000000000000000"), 32);
770
+ v.setUint16(40, info.modelEmbedDim, true);
771
+ v.setUint16(42, info.modelNativeSps, true);
772
+ v.setUint16(44, info.modelWindowSamples, true);
773
+ out[46] = info.headSlots;
774
+ out[47] = info.headMaxOutputs;
775
+ v.setUint16(48, info.headSlotBytes, true);
776
+ v.setUint16(50, info.updateChunkMax, true);
777
+ v.setUint32(52, info.appSlotBytes, true);
778
+ v.setUint32(56, info.weightsImageBytes, true);
779
+ v.setUint32(76, info.capabilitiesHigh, true);
780
+ return out;
781
+ }
782
+
783
+ // --- status -----------------------------------------------------------------
784
+
785
+ /** The length of a Status message, in bytes. */
786
+ export const STATUS_LEN = 12;
787
+ /** The bits of Status's flags byte. */
788
+ export const STATUS_FLAGS = { usb_present: 1 << 0, buffer_high_watermark: 1 << 1 } as const;
789
+
790
+ /** Status, decoded, read or notified. */
791
+ export interface Status {
792
+ /** 0 idle, 1 streaming. */
793
+ readonly state: number;
794
+ /** Whether `state` is streaming. */
795
+ readonly streaming: boolean;
796
+ /** The mode code in force. See `MODES`. */
797
+ readonly mode: number;
798
+ /** `mode` in words. */
799
+ readonly modeName: string;
800
+ /** The gain code in force. */
801
+ readonly gainCode: number;
802
+ /** `gainCode` as a gain, or null for a code this contract does not define. */
803
+ readonly gain: number | null;
804
+ /** The rate code in force. */
805
+ readonly rateCode: number;
806
+ /** `rateCode` as samples per second, or null for a code this contract does not define. */
807
+ readonly sps: number | null;
808
+ /** The charger state code. */
809
+ readonly chargerState: number;
810
+ /** `chargerState` in words. */
811
+ readonly chargerName: string;
812
+ /** Null when the device has no battery telemetry, never 255 percent. */
813
+ readonly batteryPercent: number | null;
814
+ /** The raw byte: 0xFF for unknown, rather than null. */
815
+ readonly batteryPercentRaw: number;
816
+ /** Lead-off status latched with the most recently acquired sample. Bit n is channel n+1. */
817
+ readonly loffStatp: number;
818
+ /** Whether USB power is present (1.4). */
819
+ readonly usbPresent: boolean;
820
+ /** Whether the device's buffer is at or above three quarters full. */
821
+ readonly bufferHighWatermark: boolean;
822
+ /** Telemetry. The account of what a host missed is the sample index. */
823
+ readonly droppedTotal: number;
824
+ /** Samples currently buffered on the device. */
825
+ readonly bufferFill: number;
826
+ }
827
+
828
+ /** Status, as the device sends it. */
829
+ export function decodeStatus(data: Uint8Array): Status {
830
+ if (data.length < STATUS_LEN) throw new Truncated(`status is ${STATUS_LEN} bytes`);
831
+ const v = view(data);
832
+ const gainCode = data[2]!;
833
+ const rateCode = data[3]!;
834
+ const charger = data[4]!;
835
+ const battery = data[5]!;
836
+ const flags = data[7]!;
837
+ return {
838
+ state: data[0]!,
839
+ streaming: data[0] === 1,
840
+ mode: data[1]!,
841
+ modeName: MODE_BY_CODE[data[1]!] ?? `mode ${data[1]}`,
842
+ gainCode,
843
+ gain: GAIN_BY_CODE[gainCode] ?? null,
844
+ rateCode,
845
+ sps: RATE_BY_CODE[rateCode] ?? null,
846
+ chargerState: charger,
847
+ chargerName: CHARGER_STATES[charger] ?? `charger state ${charger}`,
848
+ batteryPercent: battery === BATTERY_UNKNOWN ? null : battery,
849
+ batteryPercentRaw: battery,
850
+ loffStatp: data[6]!,
851
+ usbPresent: (flags & STATUS_FLAGS.usb_present) !== 0,
852
+ bufferHighWatermark: (flags & STATUS_FLAGS.buffer_high_watermark) !== 0,
853
+ droppedTotal: v.getUint16(8, true),
854
+ bufferFill: v.getUint16(10, true),
855
+ };
856
+ }
857
+
858
+ /** What `encodeStatus` takes. */
859
+ export interface StatusFields {
860
+ /** 0 idle, 1 streaming. */
861
+ state: number;
862
+ /** The mode code in force. */
863
+ mode: number;
864
+ /** The gain code in force. */
865
+ gainCode: number;
866
+ /** The rate code in force. */
867
+ rateCode: number;
868
+ /** The charger state code. */
869
+ chargerState: number;
870
+ /** Null for a device with no battery telemetry. */
871
+ batteryPercent: number | null;
872
+ /** Lead-off status latched with the most recently acquired sample. */
873
+ loffStatp: number;
874
+ /** Whether USB power is present. */
875
+ usbPresent?: boolean;
876
+ /** Whether the device's buffer is at or above three quarters full. */
877
+ bufferHighWatermark?: boolean;
878
+ /** Samples acquired but never delivered since power on. */
879
+ droppedTotal: number;
880
+ /** Samples currently buffered on the device. */
881
+ bufferFill: number;
882
+ }
883
+
884
+ /** Status, as the device sends it. */
885
+ export function encodeStatus(s: StatusFields): Uint8Array {
886
+ const out = new Uint8Array(STATUS_LEN);
887
+ const v = view(out);
888
+ out[0] = s.state;
889
+ out[1] = s.mode;
890
+ out[2] = s.gainCode;
891
+ out[3] = s.rateCode;
892
+ out[4] = s.chargerState;
893
+ out[5] = s.batteryPercent ?? BATTERY_UNKNOWN;
894
+ out[6] = s.loffStatp;
895
+ out[7] =
896
+ (s.usbPresent ? STATUS_FLAGS.usb_present : 0) |
897
+ (s.bufferHighWatermark ? STATUS_FLAGS.buffer_high_watermark : 0);
898
+ v.setUint16(8, s.droppedTotal, true);
899
+ v.setUint16(10, s.bufferFill, true);
900
+ return out;
901
+ }
902
+
903
+ // --- the stream -------------------------------------------------------------
904
+
905
+ /** The EEG data packet type. */
906
+ export const PACKET_EEG = 0x01;
907
+ /** The prediction packet type. */
908
+ export const PACKET_PREDICTION = 0x02;
909
+ /** 1.3: samples the device generated with its converter off, in exactly the layout of `PACKET_EEG`. */
910
+ export const PACKET_SYNTHETIC = 0x04;
911
+ /** The length of an EEG data packet's header, before the samples. */
912
+ export const DATA_HEADER_LEN = 20;
913
+ /** The single bit flags of an EEG data packet's flags byte. Mode occupies the other two, via `MODE_SHIFT` and `MODE_MASK`. */
914
+ export const DATA_FLAGS = { discontinuity: 1 << 0, leadoff_active: 1 << 1, usb_present: 1 << 4 } as const;
915
+ /** Where the mode bits sit in an EEG data packet's flags byte. */
916
+ export const MODE_SHIFT = 2;
917
+ /** The mode bits of an EEG data packet's flags byte, before shifting. */
918
+ export const MODE_MASK = 0b11 << MODE_SHIFT;
919
+ /** `samples_lost_before` saturates here. The sample index is authoritative. */
920
+ export const LOST_SATURATED = 0xffff;
921
+
922
+ /**
923
+ * One notification of samples, decoded but not scaled.
924
+ *
925
+ * `counts` is one row per sample across the channels, in converter counts.
926
+ * Scaling is the caller's, with the device's own reference and width,
927
+ * because this module assumes nothing about either.
928
+ */
929
+ export interface Packet {
930
+ /** 0x01 for a measured sample, 0x04 for one the device generated (1.3). */
931
+ readonly packetType: number;
932
+ /** Whether a break precedes this packet. */
933
+ readonly discontinuity: boolean;
934
+ /** Whether lead-off was active when this packet was built. */
935
+ readonly leadoffActive: boolean;
936
+ /** The mode code the samples were taken in. */
937
+ readonly mode: number;
938
+ /** `mode` in words. */
939
+ readonly modeName: string;
940
+ /** Whether USB power was present (1.4). */
941
+ readonly usbPresent: boolean;
942
+ /** A convenience count of samples lost before this packet, saturating at `LOST_SATURATED`. `index` continuity is authoritative. */
943
+ readonly samplesLostBefore: number;
944
+ /** The first sample's index, monotonic and wrapping at 2^32. */
945
+ readonly index: number;
946
+ /** The first sample's device time, in the device's ticks. */
947
+ readonly deviceTime: bigint;
948
+ /** Samples carried in this packet. */
949
+ readonly nSamples: number;
950
+ /** Lead-off status latched with the packet's last sample. */
951
+ readonly loffStatp: number;
952
+ /** The gain code the samples were taken at. */
953
+ readonly gainCode: number;
954
+ /** `gainCode` as a gain. */
955
+ readonly gain: number;
956
+ /** The rate code the samples were taken at. */
957
+ readonly rateCode: number;
958
+ /** `rateCode` as samples per second, or null for a code this contract does not define. */
959
+ readonly sps: number | null;
960
+ /** One row per sample, each row one converter count per channel. */
961
+ readonly counts: number[][];
962
+ /** 1.3: the samples were generated by the device, not measured (packet type 0x04). Never store one as a measurement. */
963
+ readonly synthetic: boolean;
964
+ }
965
+
966
+ /** One EEG notification. `channels` comes from the device. */
967
+ export function decodePacket(data: Uint8Array, channels: number): Packet {
968
+ if (data.length < DATA_HEADER_LEN) throw new Truncated("a data packet is at least its header");
969
+ const v = view(data);
970
+ const packetType = data[0]!;
971
+ if (packetType !== PACKET_EEG && packetType !== PACKET_SYNTHETIC) {
972
+ throw new Invalid(`packet type ${toByte(packetType)} is not sample data`);
973
+ }
974
+ const flags = data[1]!;
975
+ const n = data[16]!;
976
+ const gainCode = data[18]!;
977
+ const rateCode = data[19]!;
978
+ if (gainCode >= GAIN_BY_CODE.length) {
979
+ throw new Invalid(`gain code ${gainCode} is not one this contract defines`);
980
+ }
981
+ const body = data.subarray(DATA_HEADER_LEN);
982
+ const want = n * channels * 3;
983
+ if (body.length !== want) {
984
+ throw new Truncated(
985
+ `${n} samples of ${channels} channels is ${want} bytes, and this packet carries ${body.length}`,
986
+ );
987
+ }
988
+ const counts: number[][] = [];
989
+ for (let i = 0; i < n; i++) {
990
+ const row: number[] = [];
991
+ for (let c = 0; c < channels; c++) row.push(int24(body, (i * channels + c) * 3));
992
+ counts.push(row);
993
+ }
994
+ const mode = (flags & MODE_MASK) >> MODE_SHIFT;
995
+ return {
996
+ packetType,
997
+ discontinuity: (flags & DATA_FLAGS.discontinuity) !== 0,
998
+ leadoffActive: (flags & DATA_FLAGS.leadoff_active) !== 0,
999
+ mode,
1000
+ modeName: MODE_BY_CODE[mode] ?? `mode ${mode}`,
1001
+ usbPresent: (flags & DATA_FLAGS.usb_present) !== 0,
1002
+ samplesLostBefore: v.getUint16(2, true),
1003
+ index: v.getUint32(4, true),
1004
+ deviceTime: v.getBigUint64(8, true),
1005
+ nSamples: n,
1006
+ loffStatp: data[17]!,
1007
+ gainCode,
1008
+ gain: GAIN_BY_CODE[gainCode]!,
1009
+ rateCode,
1010
+ sps: RATE_BY_CODE[rateCode] ?? null,
1011
+ counts,
1012
+ synthetic: packetType === PACKET_SYNTHETIC,
1013
+ };
1014
+ }
1015
+
1016
+ /** What `encodePacket` takes. */
1017
+ export interface PacketFields {
1018
+ /** The first sample's index. */
1019
+ index: number;
1020
+ /** The first sample's device time, in the device's ticks. */
1021
+ deviceTime: bigint;
1022
+ /** The gain code the samples were taken at. */
1023
+ gainCode: number;
1024
+ /** The rate code the samples were taken at. */
1025
+ rateCode: number;
1026
+ /** One row per sample, each row one value per channel, in counts. */
1027
+ counts: ReadonlyArray<ArrayLike<number>>;
1028
+ /** Whether a break precedes this packet. */
1029
+ discontinuity?: boolean;
1030
+ /** Whether lead-off was active. */
1031
+ leadoffActive?: boolean;
1032
+ /** The mode code the samples were taken in. */
1033
+ mode?: number;
1034
+ /** Whether USB power was present. */
1035
+ usbPresent?: boolean;
1036
+ /** A convenience count of samples lost before this packet. */
1037
+ samplesLostBefore?: number;
1038
+ /** Lead-off status latched with the packet's last sample. */
1039
+ loffStatp?: number;
1040
+ /** 1.3: write the packet as synthetic samples, type 0x04. */
1041
+ synthetic?: boolean;
1042
+ }
1043
+
1044
+ /**
1045
+ * One EEG notification, as the device sends it. The payload is converter
1046
+ * order, big endian, which is why the device copies its bytes out without
1047
+ * swapping any of them.
1048
+ */
1049
+ export function encodePacket(p: PacketFields): Uint8Array {
1050
+ const n = p.counts.length;
1051
+ const channels = n === 0 ? 0 : p.counts[0]!.length;
1052
+ for (const row of p.counts) {
1053
+ if (row.length !== channels) throw new Invalid("every sample covers the same channels");
1054
+ }
1055
+ if (n > 0xff) throw new Invalid("a packet carries at most 255 samples");
1056
+ const out = new Uint8Array(DATA_HEADER_LEN + n * channels * 3);
1057
+ const v = view(out);
1058
+ out[0] = p.synthetic ? PACKET_SYNTHETIC : PACKET_EEG;
1059
+ out[1] =
1060
+ (p.discontinuity ? DATA_FLAGS.discontinuity : 0) |
1061
+ (p.leadoffActive ? DATA_FLAGS.leadoff_active : 0) |
1062
+ (((p.mode ?? 0) << MODE_SHIFT) & MODE_MASK) |
1063
+ (p.usbPresent ? DATA_FLAGS.usb_present : 0);
1064
+ v.setUint16(2, p.samplesLostBefore ?? 0, true);
1065
+ v.setUint32(4, p.index >>> 0, true);
1066
+ v.setBigUint64(8, p.deviceTime, true);
1067
+ out[16] = n;
1068
+ out[17] = p.loffStatp ?? 0;
1069
+ out[18] = p.gainCode;
1070
+ out[19] = p.rateCode;
1071
+ let at = DATA_HEADER_LEN;
1072
+ for (const row of p.counts) {
1073
+ for (let c = 0; c < channels; c++) {
1074
+ const value = row[c]! | 0;
1075
+ out[at++] = (value >> 16) & 0xff;
1076
+ out[at++] = (value >> 8) & 0xff;
1077
+ out[at++] = value & 0xff;
1078
+ }
1079
+ }
1080
+ return out;
1081
+ }
1082
+
1083
+ /** One big endian, two's complement int24, sign extended. */
1084
+ export function int24(b: Uint8Array, at = 0): number {
1085
+ return ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8)) >> 8;
1086
+ }
1087
+
1088
+ /**
1089
+ * One count in microvolts. Every term comes from the device: its reference,
1090
+ * the gain the packet was taken at, and the converter's width.
1091
+ */
1092
+ export function microvolts(raw: number, vrefUv: number, gain: number, adcBits: number): number {
1093
+ return (raw * (2.0 * vrefUv)) / (gain * 2 ** adcBits);
1094
+ }
1095
+
1096
+ /** What one packet's indexing says happened since the one before it. */
1097
+ export interface Continuity {
1098
+ /** The contract's own words for the three cases. */
1099
+ readonly verdict: "continuous" | "gap" | "break";
1100
+ /** Samples missing. Null for a break, whose extent is not a number. */
1101
+ readonly lost: number | null;
1102
+ /** Whether this is a gap or a break rather than continuous. */
1103
+ readonly broken: boolean;
1104
+ }
1105
+
1106
+ /**
1107
+ * Judge two packets by the contract's own rule.
1108
+ *
1109
+ * The subtraction is signed, so the index's own wrap at two to the thirty
1110
+ * second is exact. A step that is not forward is a break in the timeline
1111
+ * rather than a loss, and reporting the unsigned difference there claims a
1112
+ * loss of four billion samples that never happened.
1113
+ */
1114
+ export function continuity(
1115
+ previousIndex: number,
1116
+ previousN: number,
1117
+ index: number,
1118
+ discontinuity: boolean,
1119
+ ): Continuity {
1120
+ const expected = (previousIndex + previousN) >>> 0;
1121
+ let step = (index - expected) >>> 0;
1122
+ if (step >= 0x80000000) step -= 0x100000000;
1123
+ if (step === 0 && !discontinuity) return { verdict: "continuous", lost: 0, broken: false };
1124
+ if (step > 0) return { verdict: "gap", lost: step, broken: true };
1125
+ return { verdict: "break", lost: null, broken: true };
1126
+ }
1127
+
1128
+ // --- predictions ------------------------------------------------------------
1129
+
1130
+ /** The length of a prediction packet's header, before the outputs. */
1131
+ export const PREDICTION_HEADER_LEN = 28;
1132
+ /** The bits of a prediction packet's flags byte. */
1133
+ export const PREDICTION_FLAGS = {
1134
+ gap_in_window: 1 << 0,
1135
+ duty_reduced: 1 << 1,
1136
+ leadoff_in_window: 1 << 2,
1137
+ } as const;
1138
+
1139
+ /** One prediction notification, decoded. */
1140
+ export interface Prediction {
1141
+ /** 0x02 for a prediction. */
1142
+ readonly packetType: number;
1143
+ /** The slot of the head that produced this. */
1144
+ readonly headSlot: number;
1145
+ /** The identity of the head that produced this. */
1146
+ readonly headId: string;
1147
+ /** The window's first sample's index. */
1148
+ readonly index: number;
1149
+ /** The window's first sample's device time. */
1150
+ readonly deviceTime: bigint;
1151
+ /** The window's length, in raw samples at the current rate. */
1152
+ readonly windowSamples: number;
1153
+ /** Whether a gap fell inside the window. */
1154
+ readonly gapInWindow: boolean;
1155
+ /** Whether the device skipped windows to stay within its compute budget. */
1156
+ readonly dutyReduced: boolean;
1157
+ /** Whether an electrode was off at some point in the window. */
1158
+ readonly leadoffInWindow: boolean;
1159
+ /** The head's outputs for this window. */
1160
+ readonly outputs: number[];
1161
+ /**
1162
+ * 1.1: what the window was taken from, an `INPUT_SOURCES` value: the
1163
+ * stream, the natural signal, or the model's own chain. A 1.0 device
1164
+ * says the stream.
1165
+ */
1166
+ readonly inputSource: number;
1167
+ }
1168
+
1169
+ /** One prediction notification, as the device sends it. */
1170
+ export function decodePrediction(data: Uint8Array): Prediction {
1171
+ if (data.length < PREDICTION_HEADER_LEN) throw new Truncated("a prediction is at least its header");
1172
+ const v = view(data);
1173
+ const packetType = data[0]!;
1174
+ if (packetType !== PACKET_PREDICTION) throw new Invalid(`packet type ${toByte(packetType)} is not a prediction`);
1175
+ const flags = data[1]!;
1176
+ const n = data[3]!;
1177
+ const body = data.subarray(PREDICTION_HEADER_LEN);
1178
+ if (body.length !== n * 4) {
1179
+ throw new Truncated(`${n} outputs is ${n * 4} bytes, and this packet carries ${body.length}`);
1180
+ }
1181
+ const outputs: number[] = [];
1182
+ for (let i = 0; i < n; i++) outputs.push(v.getFloat32(PREDICTION_HEADER_LEN + i * 4, true));
1183
+ return {
1184
+ packetType,
1185
+ headSlot: data[2]!,
1186
+ headId: hex(data.subarray(18, 26)),
1187
+ index: v.getUint32(4, true),
1188
+ deviceTime: v.getBigUint64(8, true),
1189
+ windowSamples: v.getUint16(16, true),
1190
+ gapInWindow: (flags & PREDICTION_FLAGS.gap_in_window) !== 0,
1191
+ dutyReduced: (flags & PREDICTION_FLAGS.duty_reduced) !== 0,
1192
+ leadoffInWindow: (flags & PREDICTION_FLAGS.leadoff_in_window) !== 0,
1193
+ outputs,
1194
+ inputSource: data[26]!,
1195
+ };
1196
+ }
1197
+
1198
+ /** What `encodePrediction` takes. */
1199
+ export interface PredictionFields {
1200
+ /** The slot of the head that produced this. */
1201
+ headSlot: number;
1202
+ /** The identity of the head that produced this. */
1203
+ headId: string;
1204
+ /** The window's first sample's index. */
1205
+ index: number;
1206
+ /** The window's first sample's device time. */
1207
+ deviceTime: bigint;
1208
+ /** The window's length, in raw samples. */
1209
+ windowSamples: number;
1210
+ /** The head's outputs for this window. */
1211
+ outputs: ArrayLike<number>;
1212
+ /** Whether a gap fell inside the window. */
1213
+ gapInWindow?: boolean;
1214
+ /** Whether the device skipped windows to stay within its compute budget. */
1215
+ dutyReduced?: boolean;
1216
+ /** Whether an electrode was off at some point in the window. */
1217
+ leadoffInWindow?: boolean;
1218
+ /** What the window was taken from, an `INPUT_SOURCES` value. */
1219
+ inputSource?: number;
1220
+ }
1221
+
1222
+ /** One prediction notification, as the device sends it. */
1223
+ export function encodePrediction(p: PredictionFields): Uint8Array {
1224
+ const n = p.outputs.length;
1225
+ if (n > 0xff) throw new Invalid("a head has at most 255 outputs");
1226
+ const out = new Uint8Array(PREDICTION_HEADER_LEN + n * 4);
1227
+ const v = view(out);
1228
+ out[0] = PACKET_PREDICTION;
1229
+ out[1] =
1230
+ (p.gapInWindow ? PREDICTION_FLAGS.gap_in_window : 0) |
1231
+ (p.dutyReduced ? PREDICTION_FLAGS.duty_reduced : 0) |
1232
+ (p.leadoffInWindow ? PREDICTION_FLAGS.leadoff_in_window : 0);
1233
+ out[2] = p.headSlot;
1234
+ out[3] = n;
1235
+ v.setUint32(4, p.index >>> 0, true);
1236
+ v.setBigUint64(8, p.deviceTime, true);
1237
+ v.setUint16(16, p.windowSamples, true);
1238
+ out.set(fromHex(p.headId), 18);
1239
+ out[26] = p.inputSource ?? 0;
1240
+ for (let i = 0; i < n; i++) v.setFloat32(PREDICTION_HEADER_LEN + i * 4, p.outputs[i]!, true);
1241
+ return out;
1242
+ }
1243
+
1244
+ // --- what the device reports about itself beyond status ---------------------
1245
+
1246
+ /** GET_BATTERY's answer. */
1247
+ export interface Battery {
1248
+ /** The measured pack voltage, in millivolts. */
1249
+ readonly millivolts: number;
1250
+ /** A state of charge estimate, or null when the device has no battery telemetry. */
1251
+ readonly percent: number | null;
1252
+ /** The charger state code. */
1253
+ readonly chargerState: number;
1254
+ /** `chargerState` in words. */
1255
+ readonly chargerName: string;
1256
+ }
1257
+
1258
+ /** GET_BATTERY's answer, as the device sends it. */
1259
+ export function decodeBattery(payload: Uint8Array): Battery {
1260
+ if (payload.length < 4) throw new Truncated("a battery reading is four bytes");
1261
+ const v = view(payload);
1262
+ const charger = payload[3]!;
1263
+ return {
1264
+ millivolts: v.getUint16(0, true),
1265
+ percent: payload[2] === BATTERY_UNKNOWN ? null : payload[2]!,
1266
+ chargerState: charger,
1267
+ chargerName: CHARGER_STATES[charger] ?? `charger state ${charger}`,
1268
+ };
1269
+ }
1270
+
1271
+ /** GET_BATTERY's answer, as the device sends it. */
1272
+ export function encodeBattery(b: { millivolts: number; percent: number | null; chargerState: number }): Uint8Array {
1273
+ const out = new Uint8Array(4);
1274
+ view(out).setUint16(0, b.millivolts, true);
1275
+ out[2] = b.percent ?? BATTERY_UNKNOWN;
1276
+ out[3] = b.chargerState;
1277
+ return out;
1278
+ }
1279
+
1280
+ /** GET_BOOT_INFO's answer. */
1281
+ export interface BootInfo {
1282
+ /** Which application slot is running: 0 A, 1 B. */
1283
+ readonly activeSlot: number;
1284
+ /** The boot reason code. */
1285
+ readonly bootReason: number;
1286
+ /** `bootReason` in words. */
1287
+ readonly bootReasonName: string;
1288
+ /** 0 trial, the running image has not yet confirmed itself. 1 confirmed. */
1289
+ readonly slotState: number;
1290
+ /** A slot on trial has not yet been confirmed by the firmware in it. */
1291
+ readonly confirmed: boolean;
1292
+ /** Boots of this unit since manufacture. */
1293
+ readonly bootCount: number;
1294
+ }
1295
+
1296
+ /** GET_BOOT_INFO's answer, as the device sends it. */
1297
+ export function decodeBootInfo(payload: Uint8Array): BootInfo {
1298
+ if (payload.length < 8) throw new Truncated("boot information is eight bytes");
1299
+ const reason = payload[1]!;
1300
+ return {
1301
+ activeSlot: payload[0]!,
1302
+ bootReason: reason,
1303
+ bootReasonName: BOOT_REASONS[reason] ?? `reason ${reason}`,
1304
+ slotState: payload[2]!,
1305
+ confirmed: payload[2] === 1,
1306
+ bootCount: view(payload).getUint32(4, true),
1307
+ };
1308
+ }
1309
+
1310
+ /** GET_BOOT_INFO's answer, as the device sends it. */
1311
+ export function encodeBootInfo(b: {
1312
+ activeSlot: number;
1313
+ bootReason: number;
1314
+ slotState: number;
1315
+ bootCount: number;
1316
+ }): Uint8Array {
1317
+ const out = new Uint8Array(8);
1318
+ out[0] = b.activeSlot;
1319
+ out[1] = b.bootReason;
1320
+ out[2] = b.slotState;
1321
+ view(out).setUint32(4, b.bootCount, true);
1322
+ return out;
1323
+ }
1324
+
1325
+ /** A head slot's state, in words, by its `state` code. */
1326
+ export const HEAD_STATES: Readonly<Record<number, string>> = { 0: "empty", 1: "valid", 2: "invalid", 3: "width mismatch" };
1327
+ /** The model runtime's state, in words, by its `model_state` code. */
1328
+ export const MODEL_STATES: Readonly<Record<number, string>> = { 0: "no runtime", 1: "no weights", 2: "ready", 3: "updating" };
1329
+ /** An encoder id a head that does not name its encoder carries. */
1330
+ export const NO_ENCODER_ID = "0000000000000000";
1331
+ /** The slot byte meaning no head is selected. */
1332
+ export const NO_HEAD = 0xff;
1333
+ /** The length of one LIST_HEADS record, in bytes. */
1334
+ export const HEAD_ENTRY_LEN = 30;
1335
+
1336
+ /** One LIST_HEADS record: one head slot. */
1337
+ export interface HeadEntry {
1338
+ /** 0 for the built-in head, 1 and up for user slots. */
1339
+ readonly slot: number;
1340
+ /** The slot's state code. See `HEAD_STATES`. */
1341
+ readonly state: number;
1342
+ /** `state` in words. */
1343
+ readonly stateName: string;
1344
+ /** The head's output count. */
1345
+ readonly outDim: number;
1346
+ /** The head's identity, the first eight bytes of its SHA-256. Zero when empty. */
1347
+ readonly headId: string;
1348
+ /** The head's name. */
1349
+ readonly name: string;
1350
+ /** Whether `state` is valid. */
1351
+ readonly usable: boolean;
1352
+ /**
1353
+ * 1.3: the encoder the head was trained beside, as its file recorded it; `NO_ENCODER_ID` when it did not say.
1354
+ * From 1.4 the list carries none: `decodeHeadEncoders` reads them, and `withEncoders` puts them here.
1355
+ */
1356
+ readonly encoderId: string;
1357
+ }
1358
+
1359
+ /** LIST_HEADS's answer: the head slots, and which one is selected. */
1360
+ export interface HeadList {
1361
+ /** Null means no head is selected. */
1362
+ readonly activeSlot: number | null;
1363
+ /** The slots, in the order the device reports them. */
1364
+ readonly heads: HeadEntry[];
1365
+ }
1366
+
1367
+ /** The head slots, and which one is selected. */
1368
+ export function decodeHeads(payload: Uint8Array): HeadList {
1369
+ if (payload.length < 2) throw new Truncated("a head list is at least its count");
1370
+ const active = payload[0]!;
1371
+ const n = payload[1]!;
1372
+ const recordsEnd = 2 + n * HEAD_ENTRY_LEN;
1373
+ // 1.3 defined one eight byte encoder id per record after the records. No
1374
+ // device sent it, because the IntoMind One's list was too long with it,
1375
+ // and 1.4 withdraws it. One that comes is still read.
1376
+ const withIds = payload.length === recordsEnd + n * 8;
1377
+ if (payload.length !== recordsEnd && !withIds) {
1378
+ throw new Invalid(
1379
+ `a list of ${n} heads is ${recordsEnd} bytes, or ${recordsEnd + n * 8} with its encoder ids, and this one is ${payload.length}`,
1380
+ );
1381
+ }
1382
+ const heads: HeadEntry[] = [];
1383
+ for (let i = 0; i < n; i++) {
1384
+ const r = payload.subarray(2 + i * HEAD_ENTRY_LEN, 2 + (i + 1) * HEAD_ENTRY_LEN);
1385
+ const state = r[1]!;
1386
+ heads.push({
1387
+ slot: r[0]!,
1388
+ state,
1389
+ stateName: HEAD_STATES[state] ?? `state ${state}`,
1390
+ outDim: view(r).getUint16(2, true),
1391
+ headId: hex(r.subarray(4, 12)),
1392
+ name: nameOf(r.subarray(12, 28)),
1393
+ usable: state === 1,
1394
+ encoderId: withIds ? hex(payload.subarray(recordsEnd + i * 8, recordsEnd + i * 8 + 8)) : NO_ENCODER_ID,
1395
+ });
1396
+ }
1397
+ return { activeSlot: active === NO_HEAD ? null : active, heads };
1398
+ }
1399
+
1400
+ /** A LIST_HEADS payload as a device sends it: the records and nothing after them. */
1401
+ export function encodeHeads(
1402
+ activeSlot: number | null,
1403
+ heads: ReadonlyArray<{ slot: number; state: number; outDim: number; headId: string; name: string }>,
1404
+ ): Uint8Array {
1405
+ const out = new Uint8Array(2 + heads.length * HEAD_ENTRY_LEN);
1406
+ out[0] = activeSlot ?? NO_HEAD;
1407
+ out[1] = heads.length;
1408
+ heads.forEach((h, i) => {
1409
+ const at = 2 + i * HEAD_ENTRY_LEN;
1410
+ out[at] = h.slot;
1411
+ out[at + 1] = h.state;
1412
+ view(out).setUint16(at + 2, h.outDim, true);
1413
+ out.set(fromHex(h.headId), at + 4);
1414
+ out.set(paddedName(h.name, 16), at + 12);
1415
+ });
1416
+ return out;
1417
+ }
1418
+
1419
+ /** One LIST_HEAD_ENCODERS record (1.4): a slot and the encoder id its head names. */
1420
+ export const HEAD_ENCODER_LEN = 9;
1421
+
1422
+ /**
1423
+ * 1.4: the encoder each head names, by slot, from LIST_HEAD_ENCODERS: `NO_ENCODER_ID` for an empty slot or a head
1424
+ * that does not say.
1425
+ */
1426
+ export function decodeHeadEncoders(payload: Uint8Array): ReadonlyMap<number, string> {
1427
+ if (payload.length < 1) throw new Truncated("a list of head encoders is at least its count");
1428
+ const n = payload[0]!;
1429
+ if (payload.length !== 1 + n * HEAD_ENCODER_LEN) {
1430
+ throw new Invalid(`a list of ${n} head encoders is ${1 + n * HEAD_ENCODER_LEN} bytes, and this one is ${payload.length}`);
1431
+ }
1432
+ const out = new Map<number, string>();
1433
+ for (let i = 0; i < n; i++) {
1434
+ const at = 1 + i * HEAD_ENCODER_LEN;
1435
+ out.set(payload[at]!, hex(payload.subarray(at + 1, at + HEAD_ENCODER_LEN)));
1436
+ }
1437
+ return out;
1438
+ }
1439
+
1440
+ /** A LIST_HEAD_ENCODERS payload: one record for each head, in the order given. */
1441
+ export function encodeHeadEncoders(heads: ReadonlyArray<{ slot: number; encoderId: string }>): Uint8Array {
1442
+ const out = new Uint8Array(1 + heads.length * HEAD_ENCODER_LEN);
1443
+ out[0] = heads.length;
1444
+ heads.forEach((h, i) => {
1445
+ const at = 1 + i * HEAD_ENCODER_LEN;
1446
+ out[at] = h.slot;
1447
+ out.set(fromHex(h.encoderId), at + 1);
1448
+ });
1449
+ return out;
1450
+ }
1451
+
1452
+ /** The heads, each with the encoder `encoders` says it names. A slot it does not mention keeps what it had. */
1453
+ export function withEncoders(list: HeadList, encoders: ReadonlyMap<number, string>): HeadList {
1454
+ return {
1455
+ activeSlot: list.activeSlot,
1456
+ heads: list.heads.map((h) => ({ ...h, encoderId: encoders.get(h.slot) ?? h.encoderId })),
1457
+ };
1458
+ }
1459
+
1460
+ /** GET_MODEL_INFO's answer. */
1461
+ export interface ModelInfo {
1462
+ /** The model runtime's state code. See `MODEL_STATES`. */
1463
+ readonly modelState: number;
1464
+ /** `modelState` in words. */
1465
+ readonly modelStateName: string;
1466
+ /** Whether `modelState` is ready, so predictions can be enabled. */
1467
+ readonly ready: boolean;
1468
+ /** Null means no head is selected. */
1469
+ readonly activeHead: number | null;
1470
+ /** Whether predictions are enabled. */
1471
+ readonly predictionsOn: boolean;
1472
+ /** The loaded weights' identity. Zero unless ready. */
1473
+ readonly encoderId: string;
1474
+ /** The loaded weights' major, minor, and patch. */
1475
+ readonly weightsVersion: readonly [number, number, number];
1476
+ /**
1477
+ * 1.1: the signal classes the loaded model declares it takes, as
1478
+ * `INPUT_CLASSES` bits. A 1.0 device sends zero, which reads as the time
1479
+ * domain.
1480
+ */
1481
+ readonly inputClasses: number;
1482
+ /**
1483
+ * 1.2: tokens the loaded model produces per channel per window, the shape
1484
+ * of the token form of embeddings. A 1.1 device's message ends before this
1485
+ * byte and reads as zero: it sends no tokens.
1486
+ */
1487
+ readonly tokensPerChannel: number;
1488
+ /** 1.3: the time one pass of the encoder takes on the device, milliseconds; zero before the first pass. */
1489
+ readonly passMs: number;
1490
+ /** 1.3: the interval between described windows in force, seconds; zero is every window the device can. */
1491
+ readonly intervalS: number;
1492
+ /** 1.3: whether the device holds a generator, so it can offer the synthetic signal. */
1493
+ readonly generator: boolean;
1494
+ }
1495
+
1496
+ /** GET_MODEL_INFO's answer, as the device sends it. */
1497
+ export function decodeModelInfo(payload: Uint8Array): ModelInfo {
1498
+ if (payload.length < 16) throw new Truncated("model information is at least sixteen bytes");
1499
+ const state = payload[0]!;
1500
+ const active = payload[1]!;
1501
+ return {
1502
+ modelState: state,
1503
+ modelStateName: MODEL_STATES[state] ?? `state ${state}`,
1504
+ ready: state === 2,
1505
+ activeHead: active === NO_HEAD ? null : active,
1506
+ predictionsOn: payload[2] !== 0,
1507
+ encoderId: hex(payload.subarray(4, 12)),
1508
+ weightsVersion: [payload[12]!, payload[13]!, payload[14]!],
1509
+ inputClasses: payload[15]! || INPUT_CLASSES.time_domain,
1510
+ tokensPerChannel: payload.length >= 17 ? payload[16]! : 0,
1511
+ passMs: payload.length >= 22 ? view(payload).getUint16(17, true) : 0,
1512
+ intervalS: payload.length >= 22 ? view(payload).getUint16(19, true) : 0,
1513
+ generator: payload.length >= 22 && payload[21] !== 0,
1514
+ };
1515
+ }
1516
+
1517
+ /** GET_MODEL_INFO's answer, as the device sends it. */
1518
+ export function encodeModelInfo(m: {
1519
+ modelState: number;
1520
+ activeHead: number | null;
1521
+ predictionsOn: boolean;
1522
+ encoderId: string;
1523
+ weightsVersion: readonly [number, number, number];
1524
+ inputClasses?: number;
1525
+ /** 1.2: when given, the message is seventeen bytes. */
1526
+ tokensPerChannel?: number | undefined;
1527
+ /** 1.3: when given, the message is twenty two bytes. */
1528
+ passMs?: number | undefined;
1529
+ intervalS?: number | undefined;
1530
+ generator?: boolean | undefined;
1531
+ }): Uint8Array {
1532
+ const out = new Uint8Array(m.passMs === undefined ? (m.tokensPerChannel === undefined ? 16 : 17) : 22);
1533
+ out[0] = m.modelState;
1534
+ out[1] = m.activeHead ?? NO_HEAD;
1535
+ out[2] = m.predictionsOn ? 1 : 0;
1536
+ out.set(fromHex(m.encoderId), 4);
1537
+ out[12] = m.weightsVersion[0];
1538
+ out[13] = m.weightsVersion[1];
1539
+ out[14] = m.weightsVersion[2];
1540
+ out[15] = m.inputClasses ?? 0;
1541
+ if (m.tokensPerChannel !== undefined) out[16] = m.tokensPerChannel;
1542
+ if (m.passMs !== undefined) {
1543
+ const v = view(out);
1544
+ v.setUint16(17, m.passMs, true);
1545
+ v.setUint16(19, m.intervalS ?? 0, true);
1546
+ out[21] = m.generator ? 1 : 0;
1547
+ }
1548
+ return out;
1549
+ }
1550
+
1551
+ // --- the model's cadence and the device's name (1.3) --------------------------
1552
+
1553
+ /** GET_MODEL_INTERVAL's answer: the interval in force and the least the device keeps, seconds. */
1554
+ export interface ModelInterval {
1555
+ /** The interval in force, seconds. 0 is every window the device can. */
1556
+ readonly intervalS: number;
1557
+ /** The least interval the device keeps, seconds. */
1558
+ readonly minimumS: number;
1559
+ }
1560
+
1561
+ /** GET_MODEL_INTERVAL's answer, as the device sends it. */
1562
+ export function decodeModelInterval(payload: Uint8Array): ModelInterval {
1563
+ if (payload.length < 4) throw new Truncated("a model interval is four bytes");
1564
+ const v = view(payload);
1565
+ return { intervalS: v.getUint16(0, true), minimumS: v.getUint16(2, true) };
1566
+ }
1567
+
1568
+ /** SET_MODEL_INTERVAL's payload: the seconds, little endian. Zero is every window the device can. */
1569
+ export function encodeModelInterval(seconds: number): Uint8Array {
1570
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > 0xffff) throw new Invalid("an interval is 0 to 65535 seconds");
1571
+ const out = new Uint8Array(2);
1572
+ view(out).setUint16(0, seconds, true);
1573
+ return out;
1574
+ }
1575
+
1576
+ /** The most a composed name may be, in bytes: what a scan response carries. The same limit bounds each part. */
1577
+ export const NAME_MAX_COMPOSED = 29;
1578
+
1579
+ const utf8 = new TextEncoder();
1580
+ const utf8Decoder = new TextDecoder("utf-8");
1581
+
1582
+ /** A name or an adjective: UTF-8 of at most 29 bytes, no control characters, no leading or trailing space. Empty is allowed. */
1583
+ export function namePartOk(part: string): boolean {
1584
+ const b = utf8.encode(part);
1585
+ if (b.length > NAME_MAX_COMPOSED) return false;
1586
+ for (const c of b) if (c < 0x20 || c === 0x7f) return false;
1587
+ return part === part.replace(/^ +| +$/g, "");
1588
+ }
1589
+
1590
+ /** The device's name as it composes it: `name's adjective product`, the possessive only with a name, the adjective only when given. */
1591
+ export function composeName(name: string, adjective: string, product = PRODUCT_NAME): string {
1592
+ const parts: string[] = [];
1593
+ if (name) parts.push(`${name}'s`);
1594
+ if (adjective) parts.push(adjective);
1595
+ parts.push(product);
1596
+ return parts.join(" ");
1597
+ }
1598
+
1599
+ /** Whether the composition fits the air, so the device will accept it. */
1600
+ export function nameFits(name: string, adjective: string, product = PRODUCT_NAME): boolean {
1601
+ return namePartOk(name) && namePartOk(adjective) && utf8.encode(composeName(name, adjective, product)).length <= NAME_MAX_COMPOSED;
1602
+ }
1603
+
1604
+ /** SET_NAME's payload and GET_NAME's answer: each part as a length and its UTF-8 bytes. */
1605
+ export function encodeNameParts(name: string, adjective: string): Uint8Array {
1606
+ const n = utf8.encode(name);
1607
+ const a = utf8.encode(adjective);
1608
+ if (n.length > NAME_MAX_COMPOSED || a.length > NAME_MAX_COMPOSED) {
1609
+ throw new Invalid(`a name and an adjective are each at most ${NAME_MAX_COMPOSED} bytes`);
1610
+ }
1611
+ const out = new Uint8Array(2 + n.length + a.length);
1612
+ out[0] = n.length;
1613
+ out.set(n, 1);
1614
+ out[1 + n.length] = a.length;
1615
+ out.set(a, 2 + n.length);
1616
+ return out;
1617
+ }
1618
+
1619
+ /** GET_NAME's answer or SET_NAME's request, as the device reads or sends it: the name and the adjective. */
1620
+ export function decodeNameParts(payload: Uint8Array): { name: string; adjective: string } {
1621
+ if (payload.length === 0) throw new Truncated("a name carries at least its lengths");
1622
+ const nl = payload[0]!;
1623
+ if (payload.length < nl + 2) throw new Invalid("a name that ends inside its own parts");
1624
+ const al = payload[1 + nl]!;
1625
+ const adjective = payload.subarray(2 + nl);
1626
+ if (adjective.length !== al) throw new Invalid("a name whose adjective is not the length it declares");
1627
+ return { name: utf8Decoder.decode(payload.subarray(1, 1 + nl)), adjective: utf8Decoder.decode(adjective) };
1628
+ }
1629
+
1630
+ /**
1631
+ * Labels for a list a person picks devices from, in the list's order: each
1632
+ * device's own name, and when two in the list share one, a number after the
1633
+ * later ones (`Ada's Blue IntoMind One 2`). The number is the host's and
1634
+ * lasts as long as the list. The device knows nothing of it, since two
1635
+ * devices cannot agree on which is second without a host that sees them
1636
+ * both. A device heard before its name arrived is labeled with the product
1637
+ * name.
1638
+ */
1639
+ export function displayNames(names: ReadonlyArray<string | null | undefined>): string[] {
1640
+ const seen = new Map<string, number>();
1641
+ return names.map((n) => {
1642
+ const name = n || PRODUCT_NAME;
1643
+ const count = (seen.get(name) ?? 0) + 1;
1644
+ seen.set(name, count);
1645
+ return count === 1 ? name : `${name} ${count}`;
1646
+ });
1647
+ }
1648
+
1649
+ // --- processing (1.1) --------------------------------------------------------
1650
+ //
1651
+ // The device preprocesses its own signal. A chain is an ordered list of
1652
+ // stages, each a kind from the device's catalog with up to four sixteen-bit
1653
+ // parameters whose units the kind defines. The empty chain is the natural
1654
+ // signal. When a chain is in force only its output streams, and the chain
1655
+ // is what a recording stores to say what produced its samples: there is no
1656
+ // raw-or-filtered flag anywhere, because a flag says nothing about what a
1657
+ // signal is.
1658
+
1659
+ /** The processing stage kinds this version defines, by name. */
1660
+ export const STAGE_KINDS = { highpass: 1, lowpass: 2, notch: 3 } as const;
1661
+ /** The name of any stage kind `STAGE_KINDS` defines. */
1662
+ export type StageKindName = keyof typeof STAGE_KINDS;
1663
+ /** Stage kind names by their number, the reverse of `STAGE_KINDS`. */
1664
+ export const KIND_NAMES: Readonly<Record<number, StageKindName>> = { 1: "highpass", 2: "lowpass", 3: "notch" };
1665
+ /** The classes a stage kind belongs to: map, representation, or detector. */
1666
+ export const STAGE_CLASSES = { map: 0, representation: 1, detector: 2 } as const;
1667
+ /** Stage class names by their number, the reverse of `STAGE_CLASSES`. */
1668
+ export const CLASS_NAMES: Readonly<Record<number, string>> = { 0: "map", 1: "representation", 2: "detector" };
1669
+ /** 1.3 adds synthetic: reported by a device generating its signal, never requested. */
1670
+ export const INPUT_SOURCES = { stream: 0, natural: 1, own_chain: 2, synthetic: 3 } as const;
1671
+ /** The name of any source `INPUT_SOURCES` defines. */
1672
+ export type InputSourceName = keyof typeof INPUT_SOURCES;
1673
+ /** Input source names by their number, the reverse of `INPUT_SOURCES`. */
1674
+ export const INPUT_SOURCE_NAMES: Readonly<Record<number, InputSourceName>> = { 0: "stream", 1: "natural", 2: "own_chain", 3: "synthetic" };
1675
+ /** The signal classes a model declares it takes, as bits of `inputClasses`. */
1676
+ export const INPUT_CLASSES = { time_domain: 1 << 0, representation: 1 << 1 } as const;
1677
+ /** Whether the chain in force is the device's default or one a host set. */
1678
+ export const PIPELINE_ORIGINS = { default: 0, host: 1 } as const;
1679
+ /** The name of any origin `PIPELINE_ORIGINS` defines. */
1680
+ export type OriginName = keyof typeof PIPELINE_ORIGINS;
1681
+ /** Origin names by their number, the reverse of `PIPELINE_ORIGINS`. */
1682
+ export const ORIGIN_NAMES: Readonly<Record<number, OriginName>> = { 0: "default", 1: "host" };
1683
+ /** The bias drive's modes: off, on, or loop open. */
1684
+ export const BIAS_MODES = { off: 0, on: 1, loop_open: 2 } as const;
1685
+ /** The name of any mode `BIAS_MODES` defines. */
1686
+ export type BiasModeName = keyof typeof BIAS_MODES;
1687
+ /** The most stages one chain may hold. */
1688
+ export const MAX_STAGES = 12;
1689
+ /** The most parameters one stage may hold. */
1690
+ export const MAX_PARAMS = 4;
1691
+ /** The most notch stages one chain may hold. */
1692
+ export const MAX_NOTCHES = 8;
1693
+ /** The device's default high-pass corner, in tenths of a hertz. */
1694
+ export const DEFAULT_HIGH_PASS_DHZ = 5;
1695
+ /** Both mains fundamentals, second and third harmonics, four hertz wide: the device's default until a region is chosen. */
1696
+ export const DEFAULT_MAINS_BANDS_DHZ: ReadonlyArray<readonly [number, number]> = [
1697
+ [480, 520],
1698
+ [980, 1020],
1699
+ [1480, 1520],
1700
+ [580, 620],
1701
+ [1180, 1220],
1702
+ [1780, 1820],
1703
+ ];
1704
+
1705
+ /** One stage of a chain: a kind and its parameters, in tenths of a hertz for every kind this version defines. */
1706
+ export interface Stage {
1707
+ /** A `STAGE_KINDS` value. */
1708
+ readonly kind: number;
1709
+ /** The stage's parameters, in tenths of a hertz for every kind this version defines. */
1710
+ readonly params: readonly number[];
1711
+ }
1712
+
1713
+ /** `kind` in words, from `KIND_NAMES`. */
1714
+ export function stageName(s: Stage): string {
1715
+ return KIND_NAMES[s.kind] ?? `kind ${s.kind}`;
1716
+ }
1717
+
1718
+ function dhz(hz: number): number {
1719
+ const v = Math.round(hz * 10);
1720
+ if (!(v >= 0 && v <= 0xffff)) throw new Invalid(`${hz} Hz is outside what a stage parameter can hold`);
1721
+ return v;
1722
+ }
1723
+
1724
+ /** A high-pass at a corner, at least 0.1 Hz. */
1725
+ export function highpass(cornerHz: number): Stage {
1726
+ return { kind: STAGE_KINDS.highpass, params: [dhz(cornerHz)] };
1727
+ }
1728
+
1729
+ /** A low-pass at a corner, or the automatic corner at four tenths of the sample rate when none is given. */
1730
+ export function lowpass(cornerHz?: number): Stage {
1731
+ return { kind: STAGE_KINDS.lowpass, params: [cornerHz === undefined ? 0 : dhz(cornerHz)] };
1732
+ }
1733
+
1734
+ /** A notch over the band between two edges. Any band. */
1735
+ export function notch(lowHz: number, highHz: number): Stage {
1736
+ return { kind: STAGE_KINDS.notch, params: [dhz(lowHz), dhz(highHz)] };
1737
+ }
1738
+
1739
+ /** `u8 n_stages`, then per stage `u8 kind, u8 n_params, u16[n_params]`. */
1740
+ export function encodeChain(stages: readonly Stage[]): Uint8Array {
1741
+ if (stages.length > MAX_STAGES) throw new Invalid(`a chain holds at most ${MAX_STAGES} stages`);
1742
+ const out: number[] = [stages.length];
1743
+ for (const s of stages) {
1744
+ if (!(s.kind >= 1 && s.kind <= 255)) throw new Invalid("a stage kind is one byte and never zero");
1745
+ if (s.params.length > MAX_PARAMS) throw new Invalid(`a stage takes at most ${MAX_PARAMS} parameters`);
1746
+ out.push(s.kind, s.params.length);
1747
+ for (const p of s.params) out.push(p & 0xff, (p >>> 8) & 0xff);
1748
+ }
1749
+ return new Uint8Array(out);
1750
+ }
1751
+
1752
+ function decodeChainPrefix(data: Uint8Array): [Stage[], Uint8Array] {
1753
+ if (data.length === 0) throw new Truncated("a chain is at least its stage count");
1754
+ const n = data[0]!;
1755
+ if (n > MAX_STAGES) throw new Invalid(`${n} stages is more than a chain holds`);
1756
+ let at = 1;
1757
+ const stages: Stage[] = [];
1758
+ for (let i = 0; i < n; i++) {
1759
+ if (data.length < at + 2) throw new Truncated("a chain ends inside a stage");
1760
+ const kind = data[at]!;
1761
+ const nParams = data[at + 1]!;
1762
+ if (kind === 0) throw new Invalid("stage kind zero is not a kind");
1763
+ if (nParams > MAX_PARAMS) throw new Invalid(`a stage takes at most ${MAX_PARAMS} parameters`);
1764
+ at += 2;
1765
+ if (data.length < at + 2 * nParams) throw new Truncated("a stage promises parameters it does not carry");
1766
+ const params: number[] = [];
1767
+ for (let k = 0; k < nParams; k++) params.push(data[at + 2 * k]! | (data[at + 2 * k + 1]! << 8));
1768
+ at += 2 * nParams;
1769
+ stages.push({ kind, params });
1770
+ }
1771
+ return [stages, data.subarray(at)];
1772
+ }
1773
+
1774
+ /** A chain descriptor that is the whole of `data`. */
1775
+ export function decodeChain(data: Uint8Array): Stage[] {
1776
+ const [stages, rest] = decodeChainPrefix(data);
1777
+ if (rest.length !== 0) throw new Invalid("bytes after the last stage");
1778
+ return stages;
1779
+ }
1780
+
1781
+ /** One kind a device runs: its number, class, parameter count, how many instances a chain may hold, and its name. */
1782
+ export interface CatalogEntry {
1783
+ /** The stage kind's number. */
1784
+ readonly kind: number;
1785
+ /** The stage class's number. See `STAGE_CLASSES`. */
1786
+ readonly cls: number;
1787
+ /** `cls` in words. */
1788
+ readonly className: string;
1789
+ /** Parameters this kind takes. */
1790
+ readonly nParams: number;
1791
+ /** The most instances of this kind a chain may hold. */
1792
+ readonly maxInstances: number;
1793
+ /** The kind's name, as the device's catalog states it. */
1794
+ readonly name: string;
1795
+ }
1796
+
1797
+ /**
1798
+ * The kinds this version of the contract defines, with their limits. A
1799
+ * device's own catalog is the authority; this is what a host checks against
1800
+ * when it has not read one.
1801
+ */
1802
+ export const CONTRACT_CATALOG: readonly CatalogEntry[] = [
1803
+ { kind: 1, cls: 0, className: "map", nParams: 1, maxInstances: 1, name: "highpass" },
1804
+ { kind: 2, cls: 0, className: "map", nParams: 1, maxInstances: 1, name: "lowpass" },
1805
+ { kind: 3, cls: 0, className: "map", nParams: 2, maxInstances: MAX_NOTCHES, name: "notch" },
1806
+ ];
1807
+
1808
+ /** The length of one catalog record, in bytes. */
1809
+ export const CATALOG_ENTRY_LEN = 12;
1810
+
1811
+ /** GET_PIPELINE_CATALOG: `u8 n_kinds`, then records of twelve bytes. */
1812
+ export function decodeCatalog(payload: Uint8Array): CatalogEntry[] {
1813
+ if (payload.length === 0) throw new Truncated("a catalog is at least its count");
1814
+ const n = payload[0]!;
1815
+ if (payload.length !== 1 + CATALOG_ENTRY_LEN * n) throw new Invalid("a catalog is one byte and twelve per kind, exactly");
1816
+ const entries: CatalogEntry[] = [];
1817
+ for (let i = 0; i < n; i++) {
1818
+ const b = payload.subarray(1 + CATALOG_ENTRY_LEN * i, 13 + CATALOG_ENTRY_LEN * i);
1819
+ const raw = b.subarray(4, 12);
1820
+ let end = raw.indexOf(0);
1821
+ if (end < 0) end = raw.length;
1822
+ entries.push({
1823
+ kind: b[0]!,
1824
+ cls: b[1]!,
1825
+ className: CLASS_NAMES[b[1]!] ?? `class ${b[1]}`,
1826
+ nParams: b[2]!,
1827
+ maxInstances: b[3]!,
1828
+ name: new TextDecoder().decode(raw.subarray(0, end)),
1829
+ });
1830
+ }
1831
+ return entries;
1832
+ }
1833
+
1834
+ /** GET_PIPELINE_CATALOG's answer, as the device sends it. */
1835
+ export function encodeCatalog(entries: readonly CatalogEntry[]): Uint8Array {
1836
+ const out = new Uint8Array(1 + CATALOG_ENTRY_LEN * entries.length);
1837
+ out[0] = entries.length;
1838
+ entries.forEach((e, i) => {
1839
+ const at = 1 + CATALOG_ENTRY_LEN * i;
1840
+ out[at] = e.kind;
1841
+ out[at + 1] = e.cls;
1842
+ out[at + 2] = e.nParams;
1843
+ out[at + 3] = e.maxInstances;
1844
+ out.set(paddedName(e.name, 8), at + 4);
1845
+ });
1846
+ return out;
1847
+ }
1848
+
1849
+ /** The chain in force, and whether it is the device's own default for its current rate or one a host set. */
1850
+ export interface PipelineState {
1851
+ /** A `PIPELINE_ORIGINS` value. */
1852
+ readonly origin: number;
1853
+ /** `origin` in words. */
1854
+ readonly originName: OriginName;
1855
+ /** The chain in force. */
1856
+ readonly stages: Stage[];
1857
+ }
1858
+
1859
+ /** GET_PIPELINE's answer, as the device sends it. */
1860
+ export function decodePipelineState(payload: Uint8Array): PipelineState {
1861
+ if (payload.length === 0) throw new Truncated("a pipeline state is at least its origin");
1862
+ const origin = payload[0]!;
1863
+ const originName = ORIGIN_NAMES[origin];
1864
+ if (originName === undefined) throw new Invalid(`origin ${origin} is not one this contract defines`);
1865
+ return { origin, originName, stages: decodeChain(payload.subarray(1)) };
1866
+ }
1867
+
1868
+ /** GET_PIPELINE's answer, as the device sends it. */
1869
+ export function encodePipelineState(origin: number, stages: readonly Stage[]): Uint8Array {
1870
+ const chain = encodeChain(stages);
1871
+ const out = new Uint8Array(1 + chain.length);
1872
+ out[0] = origin;
1873
+ out.set(chain, 1);
1874
+ return out;
1875
+ }
1876
+
1877
+ /**
1878
+ * Where the model's input comes from: the stream as it is, the natural
1879
+ * signal, or a chain of the model's own. In an answer the stages are the
1880
+ * chain in effect for the model.
1881
+ */
1882
+ export interface PredictionInput {
1883
+ /** An `INPUT_SOURCES` value. */
1884
+ readonly source: number;
1885
+ /** `source` in words. */
1886
+ readonly sourceName: InputSourceName;
1887
+ /** The chain in effect for the model, when `source` is the model's own. */
1888
+ readonly stages: Stage[];
1889
+ }
1890
+
1891
+ /** SET_PREDICTION_INPUT's request, as the device reads it. */
1892
+ export function encodePredictionInput(source: number | InputSourceName, stages: readonly Stage[] = []): Uint8Array {
1893
+ const code = typeof source === "string" ? INPUT_SOURCES[source] : source;
1894
+ // Synthetic is reported by a device generating its signal, never requested.
1895
+ if (code === undefined || INPUT_SOURCE_NAMES[code] === undefined || code === INPUT_SOURCES.synthetic) {
1896
+ throw new Invalid(`${source} is not a source a host may ask for`);
1897
+ }
1898
+ const chain = encodeChain(stages);
1899
+ const out = new Uint8Array(1 + chain.length);
1900
+ out[0] = code;
1901
+ out.set(chain, 1);
1902
+ return out;
1903
+ }
1904
+
1905
+ /** GET_PREDICTION_INPUT's answer, as the device sends it. */
1906
+ export function decodePredictionInput(payload: Uint8Array): PredictionInput {
1907
+ if (payload.length === 0) throw new Truncated("a prediction input is at least its source");
1908
+ const source = payload[0]!;
1909
+ const sourceName = INPUT_SOURCE_NAMES[source];
1910
+ if (sourceName === undefined || source === INPUT_SOURCES.synthetic) {
1911
+ throw new Invalid(`source ${source} is not one a host may ask for`);
1912
+ }
1913
+ return { source, sourceName, stages: decodeChain(payload.subarray(1)) };
1914
+ }
1915
+
1916
+ /** The bias output over the device's own window, in millivolts. */
1917
+ export interface BiasDiagnostic {
1918
+ /** Mean bias output, in millivolts. */
1919
+ readonly meanMv: number;
1920
+ /** Standard deviation of the bias output, in millivolts. */
1921
+ readonly sdMv: number;
1922
+ /** Minimum bias output, in millivolts. */
1923
+ readonly minMv: number;
1924
+ /** Maximum bias output, in millivolts. */
1925
+ readonly maxMv: number;
1926
+ }
1927
+
1928
+ /** GET_BIAS_DIAGNOSTIC's answer, as the device sends it. */
1929
+ export function decodeBiasDiagnostic(payload: Uint8Array): BiasDiagnostic {
1930
+ if (payload.length < 8) throw new Truncated("a bias diagnostic is eight bytes");
1931
+ const v = view(payload);
1932
+ return { meanMv: v.getInt16(0, true), sdMv: v.getInt16(2, true), minMv: v.getInt16(4, true), maxMv: v.getInt16(6, true) };
1933
+ }
1934
+
1935
+ /** GET_BIAS_DIAGNOSTIC's answer, as the device sends it. */
1936
+ export function encodeBiasDiagnostic(d: BiasDiagnostic): Uint8Array {
1937
+ const out = new Uint8Array(8);
1938
+ const v = view(out);
1939
+ v.setInt16(0, d.meanMv, true);
1940
+ v.setInt16(2, d.sdMv, true);
1941
+ v.setInt16(4, d.minMv, true);
1942
+ v.setInt16(6, d.maxMv, true);
1943
+ return out;
1944
+ }
1945
+
1946
+ // --- the status lamp (1.2) ---------------------------------------------------
1947
+ //
1948
+ // One language at three levels of verbosity. A blink means the same thing
1949
+ // at every level that shows it; the level only decides what is shown. The
1950
+ // blink shapes are the device's and documented on its page; a host explains
1951
+ // the lamp in words from these tables and never decodes a light.
1952
+
1953
+ /** The status lamp's verbosity levels: silent, reserved, or verbose. */
1954
+ export const INDICATOR_LEVELS = { silent: 0, reserved: 1, verbose: 2 } as const;
1955
+ /** The name of any level `INDICATOR_LEVELS` defines. */
1956
+ export type IndicatorLevelName = keyof typeof INDICATOR_LEVELS;
1957
+ /** Indicator level names by their number, the reverse of `INDICATOR_LEVELS`. */
1958
+ export const INDICATOR_LEVEL_NAMES: Readonly<Record<number, IndicatorLevelName>> = { 0: "silent", 1: "reserved", 2: "verbose" };
1959
+ /** What each level shows, most important first. */
1960
+ export const INDICATOR_SHOWS: Readonly<Record<IndicatorLevelName, readonly string[]>> = {
1961
+ silent: [],
1962
+ reserved: ["converter fault", "low battery"],
1963
+ verbose: ["converter fault", "low battery", "update in progress", "streaming", "connected", "waiting for a host"],
1964
+ };
1965
+ /** Low battery begins below this percent of the device's own estimate and ends above the clear percent, or on external power. */
1966
+ export const LOW_BATTERY_PERCENT = 20;
1967
+ /** Low battery clears once the device's own estimate rises back above this percent. */
1968
+ export const LOW_BATTERY_CLEAR_PERCENT = 25;
1969
+ /** The most seconds one identify request may run. */
1970
+ export const IDENTIFY_MAX_SECONDS = 30;
1971
+
1972
+ // --- the converter's registers, read only (1.2) ------------------------------
1973
+ //
1974
+ // The raw configuration bytes of the analog converter, for debugging. The
1975
+ // contract carries the bytes and a family number. Family one is the Texas
1976
+ // Instruments ADS1299 family; the IntoMind One carries the ADS1299-4, as its
1977
+ // datasheet says. There is no write.
1978
+
1979
+ /** Which chip family a converter register file belongs to, by its `family` code. */
1980
+ export const CONVERTER_FAMILIES: Readonly<Record<number, string>> = { 1: "ads1299" };
1981
+
1982
+ /** GET_CONVERTER_REGISTERS's answer. */
1983
+ export interface ConverterRegisters {
1984
+ /** Which chip family the registers belong to. See `CONVERTER_FAMILIES`. */
1985
+ readonly family: number;
1986
+ /** `family` in words. */
1987
+ readonly familyName: string;
1988
+ /** Address of the first register carried. */
1989
+ readonly first: number;
1990
+ /** The registers themselves, consecutive addresses starting at `first`. */
1991
+ readonly values: Uint8Array;
1992
+ }
1993
+
1994
+ /** GET_CONVERTER_REGISTERS's answer, as the device sends it. */
1995
+ export function decodeConverterRegisters(payload: Uint8Array): ConverterRegisters {
1996
+ if (payload.length < 3) throw new Truncated("registers are at least a family, a first address, and a count");
1997
+ const family = payload[0]!;
1998
+ const count = payload[2]!;
1999
+ if (count === 0 || payload.length !== 3 + count) {
2000
+ throw new Invalid(`${count} registers declared and ${payload.length - 3} carried`);
2001
+ }
2002
+ return { family, familyName: CONVERTER_FAMILIES[family] ?? `family ${family}`, first: payload[1]!, values: payload.slice(3) };
2003
+ }
2004
+
2005
+ /** GET_CONVERTER_REGISTERS's answer, as the device sends it. */
2006
+ export function encodeConverterRegisters(r: { family: number; first: number; values: Uint8Array }): Uint8Array {
2007
+ if (r.values.length === 0 || r.values.length > 64) throw new Invalid("registers carry one to sixty four values");
2008
+ const out = new Uint8Array(3 + r.values.length);
2009
+ out[0] = r.family;
2010
+ out[1] = r.first;
2011
+ out[2] = r.values.length;
2012
+ out.set(r.values, 3);
2013
+ return out;
2014
+ }
2015
+
2016
+ const FAMILY_1_RATES: Readonly<Record<number, number>> = { 0: 16000, 1: 8000, 2: 4000, 3: 2000, 4: 1000, 5: 500, 6: 250 };
2017
+ const FAMILY_1_GAINS: Readonly<Record<number, number>> = { 0: 1, 1: 2, 2: 4, 3: 6, 4: 8, 5: 12, 6: 24 };
2018
+ const FAMILY_1_INPUTS = [
2019
+ "electrode",
2020
+ "shorted",
2021
+ "bias measurement",
2022
+ "supply",
2023
+ "temperature",
2024
+ "test signal",
2025
+ "bias drive, positive",
2026
+ "bias drive, negative",
2027
+ ] as const;
2028
+ const FAMILY_1_LOFF_THRESHOLD = [95, 92.5, 90, 87.5, 85, 80, 75, 70] as const;
2029
+ const FAMILY_1_LOFF_CURRENT = ["6 nA", "24 nA", "6 uA", "24 uA"] as const;
2030
+ const FAMILY_1_LOFF_FREQUENCY = ["dc", "7.8 Hz", "31.2 Hz", "data rate / 4"] as const;
2031
+
2032
+ function channelsOf(mask: number): number[] {
2033
+ const out: number[] = [];
2034
+ for (let c = 0; c < 8; c++) if (mask & (1 << c)) out.push(c + 1);
2035
+ return out;
2036
+ }
2037
+
2038
+ /**
2039
+ * An ADS1299 family register file in words, from address 0: the identity,
2040
+ * the data rate, the test signal, the reference and bias settings, lead-off
2041
+ * detection, each channel's gain and input, and the bias and lead-off sense
2042
+ * masks. Every field is the datasheet's (Texas Instruments SBAS499C). A
2043
+ * file shorter than 24 bytes is described as far as it goes.
2044
+ */
2045
+ export function describeAds1299Registers(values: Uint8Array): Record<string, unknown> {
2046
+ const at = (i: number): number | undefined => (i < values.length ? values[i] : undefined);
2047
+ const out: Record<string, unknown> = { family: CONVERTER_FAMILIES[1] };
2048
+ const ident = at(0);
2049
+ if (ident !== undefined) {
2050
+ out.identity = {
2051
+ device: (ident & 0x1c) === 0x1c ? "ADS1299" : `not an ADS1299 (0x${ident.toString(16)})`,
2052
+ familyMember: (ident & 0x1c) === 0x1c,
2053
+ revision: (ident >> 5) & 0x7,
2054
+ channels: ({ 0: 4, 1: 6, 2: 8 } as Record<number, number>)[ident & 0x3] ?? `code ${ident & 0x3}`,
2055
+ };
2056
+ }
2057
+ const c1 = at(1);
2058
+ if (c1 !== undefined) {
2059
+ out.dataRateSps = FAMILY_1_RATES[c1 & 0x7] ?? null;
2060
+ out.multipleReadback = (c1 & 0x40) !== 0;
2061
+ out.clockOutput = (c1 & 0x20) !== 0;
2062
+ }
2063
+ const c2 = at(2);
2064
+ if (c2 !== undefined) {
2065
+ out.testSignal = {
2066
+ internal: (c2 & 0x10) !== 0,
2067
+ amplitude: c2 & 0x04 ? 2 : 1,
2068
+ frequency: ({ 0: "fclk / 2^21", 1: "fclk / 2^20", 3: "dc" } as Record<number, string>)[c2 & 0x3] ?? "do not use",
2069
+ };
2070
+ }
2071
+ const c3 = at(3);
2072
+ if (c3 !== undefined) {
2073
+ out.referenceBufferOn = (c3 & 0x80) !== 0;
2074
+ out.bias = {
2075
+ amplifierOn: (c3 & 0x04) !== 0,
2076
+ referenceInternal: (c3 & 0x08) !== 0,
2077
+ measurement: (c3 & 0x10) !== 0,
2078
+ sense: (c3 & 0x02) !== 0,
2079
+ connected: (c3 & 0x01) === 0,
2080
+ };
2081
+ }
2082
+ const lo = at(4);
2083
+ if (lo !== undefined) {
2084
+ out.leadOff = {
2085
+ thresholdPercent: FAMILY_1_LOFF_THRESHOLD[(lo >> 5) & 0x7],
2086
+ current: FAMILY_1_LOFF_CURRENT[(lo >> 2) & 0x3],
2087
+ frequency: FAMILY_1_LOFF_FREQUENCY[lo & 0x3],
2088
+ };
2089
+ }
2090
+ const channels: Array<Record<string, unknown>> = [];
2091
+ for (let i = 0; i < 8; i++) {
2092
+ const ch = at(5 + i);
2093
+ if (ch === undefined) break;
2094
+ channels.push({
2095
+ channel: i + 1,
2096
+ poweredDown: (ch & 0x80) !== 0,
2097
+ gain: FAMILY_1_GAINS[(ch >> 4) & 0x7] ?? "do not use",
2098
+ referenceSwitch: (ch & 0x08) !== 0,
2099
+ input: FAMILY_1_INPUTS[ch & 0x7],
2100
+ });
2101
+ }
2102
+ if (channels.length) out.channels = channels;
2103
+ const masks: Array<[number, string]> = [
2104
+ [13, "biasSensePositive"],
2105
+ [14, "biasSenseNegative"],
2106
+ [15, "leadOffSensePositive"],
2107
+ [16, "leadOffSenseNegative"],
2108
+ [17, "leadOffFlip"],
2109
+ [18, "leadOffStatusPositive"],
2110
+ [19, "leadOffStatusNegative"],
2111
+ ];
2112
+ for (const [i, name] of masks) {
2113
+ const v = at(i);
2114
+ if (v !== undefined) out[name] = channelsOf(v);
2115
+ }
2116
+ const m1 = at(21);
2117
+ if (m1 !== undefined) out.commonReferenceSwitch = (m1 & 0x20) !== 0;
2118
+ const c4 = at(23);
2119
+ if (c4 !== undefined) {
2120
+ out.singleShot = (c4 & 0x08) !== 0;
2121
+ out.leadOffComparatorsOn = (c4 & 0x02) !== 0;
2122
+ }
2123
+ return out;
2124
+ }
2125
+
2126
+ // --- embeddings (1.2) --------------------------------------------------------
2127
+ //
2128
+ // The encoder's output for one window, in two forms. The window embedding is
2129
+ // the mean over live channels and over time of the tokens, and is what a
2130
+ // head consumes. The tokens are the encoder's output before that pooling,
2131
+ // one vector per channel per slice of the window, and are what reconstruction
2132
+ // turns back into signal. Both are quantized the way a head receives an
2133
+ // embedding (times 4096). The weights never leave the device.
2134
+
2135
+ /** The embedding packet type. */
2136
+ export const PACKET_EMBEDDING = 0x03;
2137
+ /** The length of an embedding notification's header, before its values. */
2138
+ export const EMBEDDING_HEADER_LEN = 28;
2139
+ /** The `token` byte meaning this is the window embedding, not one token. */
2140
+ export const WINDOW_TOKEN = 0xff;
2141
+ /** Which embeddings a device streams: off, window, tokens, or both. */
2142
+ export const EMBEDDING_FORMS = { off: 0, window: 1, tokens: 2, both: 3 } as const;
2143
+ /** The name of any form `EMBEDDING_FORMS` defines. */
2144
+ export type EmbeddingFormName = keyof typeof EMBEDDING_FORMS;
2145
+ /** The bits of an embedding notification's flags byte. */
2146
+ export const EMBEDDING_FLAGS = {
2147
+ gap_in_window: 1 << 0,
2148
+ duty_reduced: 1 << 1,
2149
+ leadoff_in_window: 1 << 2,
2150
+ more_parts: 1 << 3,
2151
+ } as const;
2152
+
2153
+ /** One embedding notification, decoded: the whole vector, or the part carried here. */
2154
+ export interface Embedding {
2155
+ /** 0x03 for an embedding. */
2156
+ readonly packetType: number;
2157
+ /** The window's first sample's index. */
2158
+ readonly index: number;
2159
+ /** The window's first sample's device time. */
2160
+ readonly deviceTime: bigint;
2161
+ /** The window's length, in raw samples at the current rate. */
2162
+ readonly windowSamples: number;
2163
+ /** The encoder that produced this, as `GET_MODEL_INFO` reports it. */
2164
+ readonly encoderId: string;
2165
+ /** An `INPUT_SOURCES` value. */
2166
+ readonly inputSource: number;
2167
+ /** Values in the whole vector. */
2168
+ readonly embedDim: number;
2169
+ /** Index of the first value carried here. */
2170
+ readonly first: number;
2171
+ /** Null for the window embedding; otherwise the token's index, channel-major over all channels. */
2172
+ readonly token: number | null;
2173
+ /** Whether another notification carries the rest of this vector. */
2174
+ readonly moreParts: boolean;
2175
+ /** Whether a gap fell inside the window. */
2176
+ readonly gapInWindow: boolean;
2177
+ /** Whether the device skipped windows to stay within its compute budget. */
2178
+ readonly dutyReduced: boolean;
2179
+ /** Whether an electrode was off at some point in the window. */
2180
+ readonly leadoffInWindow: boolean;
2181
+ /** Quantized values: the encoder's output times 4096. */
2182
+ readonly values: number[];
2183
+ }
2184
+
2185
+ /** One embedding notification, as the device sends it. */
2186
+ export function decodeEmbedding(data: Uint8Array): Embedding {
2187
+ if (data.length < EMBEDDING_HEADER_LEN) throw new Truncated("an embedding is at least its header");
2188
+ const v = view(data);
2189
+ const packetType = data[0]!;
2190
+ if (packetType !== PACKET_EMBEDDING) throw new Invalid(`packet type ${toByte(packetType)} is not an embedding`);
2191
+ const flags = data[1]!;
2192
+ const embedDim = data[2]!;
2193
+ const first = data[3]!;
2194
+ const body = data.subarray(EMBEDDING_HEADER_LEN);
2195
+ if (body.length === 0 || body.length % 2 !== 0) throw new Truncated("an embedding carries whole sixteen-bit values, at least one");
2196
+ const n = body.length / 2;
2197
+ if (first + n > embedDim) throw new Invalid(`values ${first} to ${first + n} of a vector of ${embedDim}`);
2198
+ const values: number[] = [];
2199
+ for (let i = 0; i < n; i++) values.push(v.getInt16(EMBEDDING_HEADER_LEN + i * 2, true));
2200
+ const token = data[27]!;
2201
+ return {
2202
+ packetType,
2203
+ index: v.getUint32(4, true),
2204
+ deviceTime: v.getBigUint64(8, true),
2205
+ windowSamples: v.getUint16(16, true),
2206
+ encoderId: hex(data.subarray(18, 26)),
2207
+ inputSource: data[26]!,
2208
+ embedDim,
2209
+ first,
2210
+ token: token === WINDOW_TOKEN ? null : token,
2211
+ moreParts: (flags & EMBEDDING_FLAGS.more_parts) !== 0,
2212
+ gapInWindow: (flags & EMBEDDING_FLAGS.gap_in_window) !== 0,
2213
+ dutyReduced: (flags & EMBEDDING_FLAGS.duty_reduced) !== 0,
2214
+ leadoffInWindow: (flags & EMBEDDING_FLAGS.leadoff_in_window) !== 0,
2215
+ values,
2216
+ };
2217
+ }
2218
+
2219
+ /** What `encodeEmbedding` takes. */
2220
+ export interface EmbeddingFields {
2221
+ /** The window's first sample's index. */
2222
+ index: number;
2223
+ /** The window's first sample's device time. */
2224
+ deviceTime: bigint;
2225
+ /** The window's length, in raw samples. */
2226
+ windowSamples: number;
2227
+ /** The encoder that produced this. */
2228
+ encoderId: string;
2229
+ /** An `INPUT_SOURCES` value. */
2230
+ inputSource?: number;
2231
+ /** Values in the whole embedding. */
2232
+ embedDim: number;
2233
+ /** Index of the first value carried here. */
2234
+ first?: number;
2235
+ /** Null for the window embedding, otherwise the token's index. */
2236
+ token?: number | null;
2237
+ /** Whether another notification carries the rest of this vector. */
2238
+ moreParts?: boolean;
2239
+ /** Whether a gap fell inside the window. */
2240
+ gapInWindow?: boolean;
2241
+ /** Whether the device skipped windows to stay within its compute budget. */
2242
+ dutyReduced?: boolean;
2243
+ /** Whether an electrode was off at some point in the window. */
2244
+ leadoffInWindow?: boolean;
2245
+ /** The values carried in this notification. */
2246
+ values: ArrayLike<number>;
2247
+ }
2248
+
2249
+ /** One embedding notification, as the device sends it. */
2250
+ export function encodeEmbedding(e: EmbeddingFields): Uint8Array {
2251
+ const n = e.values.length;
2252
+ const first = e.first ?? 0;
2253
+ if (n === 0 || first + n > e.embedDim) throw new Invalid("values must lie inside the vector they belong to");
2254
+ const out = new Uint8Array(EMBEDDING_HEADER_LEN + n * 2);
2255
+ const v = view(out);
2256
+ out[0] = PACKET_EMBEDDING;
2257
+ out[1] =
2258
+ (e.gapInWindow ? EMBEDDING_FLAGS.gap_in_window : 0) |
2259
+ (e.dutyReduced ? EMBEDDING_FLAGS.duty_reduced : 0) |
2260
+ (e.leadoffInWindow ? EMBEDDING_FLAGS.leadoff_in_window : 0) |
2261
+ (e.moreParts ? EMBEDDING_FLAGS.more_parts : 0);
2262
+ out[2] = e.embedDim;
2263
+ out[3] = first;
2264
+ v.setUint32(4, e.index >>> 0, true);
2265
+ v.setBigUint64(8, e.deviceTime, true);
2266
+ v.setUint16(16, e.windowSamples, true);
2267
+ out.set(fromHex(e.encoderId), 18);
2268
+ out[26] = e.inputSource ?? 0;
2269
+ out[27] = e.token === null || e.token === undefined ? WINDOW_TOKEN : e.token;
2270
+ for (let i = 0; i < n; i++) v.setInt16(EMBEDDING_HEADER_LEN + i * 2, e.values[i]!, true);
2271
+ return out;
2272
+ }
2273
+
2274
+ /** One window's embeddings, whole. */
2275
+ export interface EmbeddingWindow {
2276
+ /** The window's first sample's index. */
2277
+ readonly index: number;
2278
+ /** The window's first sample's device time. */
2279
+ readonly deviceTime: bigint;
2280
+ /** The window's length, in raw samples. */
2281
+ readonly windowSamples: number;
2282
+ /** The encoder that produced this. */
2283
+ readonly encoderId: string;
2284
+ /** An `INPUT_SOURCES` value. */
2285
+ readonly inputSource: number;
2286
+ /** Values in the whole embedding. */
2287
+ readonly embedDim: number;
2288
+ /** Whether a gap fell inside the window. */
2289
+ readonly gapInWindow: boolean;
2290
+ /** Whether the device skipped windows to stay within its compute budget. */
2291
+ readonly dutyReduced: boolean;
2292
+ /** Whether an electrode was off at some point in the window. */
2293
+ readonly leadoffInWindow: boolean;
2294
+ /** The window embedding, quantized, when asked for. */
2295
+ readonly embedding: number[] | null;
2296
+ /** The tokens, quantized, `channels × tokensPerChannel` vectors of `embedDim`, channel-major, when asked for. */
2297
+ readonly tokens: number[][] | null;
2298
+ }
2299
+
2300
+ /**
2301
+ * Puts notifications back together into whole windows. Built for a device's
2302
+ * channel count, the model's tokens per channel, and the form asked for;
2303
+ * `feed` takes each decoded notification and returns the window when its
2304
+ * last piece arrives. Windows older than the two most recent that never
2305
+ * completed are dropped and counted in `incomplete`.
2306
+ */
2307
+ export class EmbeddingAssembler {
2308
+ /** The device's channel count, as built. */
2309
+ readonly channels: number;
2310
+ /** Tokens per channel per window, as built. */
2311
+ readonly tokensPerChannel: number;
2312
+ /** Which form this was built for. */
2313
+ readonly form: EmbeddingFormName;
2314
+ /** Windows dropped before their last piece arrived. */
2315
+ incomplete = 0;
2316
+ readonly #windows = new Map<number, { meta: Embedding; embedding: Array<number | null>; tokens: Array<Array<number | null>> }>();
2317
+
2318
+ /** Build one for a device's channel count, the model's tokens per channel, and the form asked for. */
2319
+ constructor(channels: number, tokensPerChannel: number, form: EmbeddingFormName = "both") {
2320
+ if (form === "off") throw new Invalid("the form is window, tokens, or both");
2321
+ this.channels = channels;
2322
+ this.tokensPerChannel = tokensPerChannel;
2323
+ this.form = form;
2324
+ }
2325
+
2326
+ /** Tokens expected per window: `channels` times `tokensPerChannel`. */
2327
+ get nTokens(): number {
2328
+ return this.channels * this.tokensPerChannel;
2329
+ }
2330
+
2331
+ /** Feed one decoded notification in. Returns the window once its last piece has arrived, else null. */
2332
+ feed(e: Embedding): EmbeddingWindow | null {
2333
+ const nTokens = this.nTokens;
2334
+ let w = this.#windows.get(e.index);
2335
+ if (w === undefined) {
2336
+ w = {
2337
+ meta: e,
2338
+ embedding: new Array<number | null>(e.embedDim).fill(null),
2339
+ tokens: Array.from({ length: nTokens }, () => new Array<number | null>(e.embedDim).fill(null)),
2340
+ };
2341
+ this.#windows.set(e.index, w);
2342
+ while (this.#windows.size > 3) {
2343
+ const oldest = Math.min(...this.#windows.keys());
2344
+ this.#windows.delete(oldest);
2345
+ this.incomplete += 1;
2346
+ }
2347
+ }
2348
+ const target = e.token === null ? w.embedding : e.token < nTokens ? w.tokens[e.token]! : null;
2349
+ if (target === null || e.first + e.values.length > target.length) return null;
2350
+ for (let i = 0; i < e.values.length; i++) target[e.first + i] = e.values[i]!;
2351
+ const wantEmbedding = this.form === "window" || this.form === "both";
2352
+ const wantTokens = this.form === "tokens" || this.form === "both";
2353
+ const embeddingDone = !wantEmbedding || w.embedding.every((x) => x !== null);
2354
+ const tokensDone = !wantTokens || w.tokens.every((t) => t.every((x) => x !== null));
2355
+ if (!(embeddingDone && tokensDone)) return null;
2356
+ this.#windows.delete(e.index);
2357
+ const m = w.meta;
2358
+ return {
2359
+ index: m.index,
2360
+ deviceTime: m.deviceTime,
2361
+ windowSamples: m.windowSamples,
2362
+ encoderId: m.encoderId,
2363
+ inputSource: m.inputSource,
2364
+ embedDim: m.embedDim,
2365
+ gapInWindow: m.gapInWindow,
2366
+ dutyReduced: m.dutyReduced,
2367
+ leadoffInWindow: m.leadoffInWindow,
2368
+ embedding: wantEmbedding ? (w.embedding as number[]) : null,
2369
+ tokens: wantTokens ? (w.tokens as number[][]) : null,
2370
+ };
2371
+ }
2372
+ }
2373
+
2374
+ // The rules, the same ones the device applies, so a host can say before
2375
+ // sending whether a chain runs at a rate and explain a refusal in words.
2376
+
2377
+ /** The automatic low-pass corner for a rate, in tenths of a hertz. */
2378
+ export function lowPassAutoDhz(rateSps: number): number {
2379
+ return Math.trunc(rateSps) * 4;
2380
+ }
2381
+
2382
+ /** The first corner a rate refuses, in tenths of a hertz: nine tenths of Nyquist. */
2383
+ export function cornerLimitDhz(rateSps: number): number {
2384
+ return Math.floor((Math.trunc(rateSps) * 45) / 10);
2385
+ }
2386
+
2387
+ /** Whether a chain can run at a rate. Throws `Invalid` saying why not. */
2388
+ export function checkChain(stages: readonly Stage[], rateSps: number, catalog: readonly CatalogEntry[] = CONTRACT_CATALOG): void {
2389
+ const byKind = new Map(catalog.map((e) => [e.kind, e]));
2390
+ const limit = cornerLimitDhz(rateSps);
2391
+ const counts = new Map<number, number>();
2392
+ let hp: number | null = null;
2393
+ let lp: number | null = null;
2394
+ for (const s of stages) {
2395
+ const e = byKind.get(s.kind);
2396
+ if (e === undefined) throw new Invalid(`kind ${s.kind} is not in the device's catalog`);
2397
+ if (s.params.length !== e.nParams) throw new Invalid(`${e.name} takes ${e.nParams} parameter(s), not ${s.params.length}`);
2398
+ const count = (counts.get(s.kind) ?? 0) + 1;
2399
+ counts.set(s.kind, count);
2400
+ if (count > e.maxInstances) throw new Invalid(`a chain holds at most ${e.maxInstances} ${e.name} stage(s)`);
2401
+ const p = s.params;
2402
+ if (s.kind === STAGE_KINDS.highpass) {
2403
+ if (p[0] === 0) throw new Invalid("a high-pass corner is at least 0.1 Hz");
2404
+ if (p[0]! >= limit) throw new Invalid(`a high-pass at ${p[0]! / 10} Hz is at or above nine tenths of Nyquist at ${rateSps} SPS`);
2405
+ hp = p[0]!;
2406
+ } else if (s.kind === STAGE_KINDS.lowpass) {
2407
+ const c = p[0] || lowPassAutoDhz(rateSps);
2408
+ if (c >= limit) throw new Invalid(`a low-pass at ${c / 10} Hz is at or above nine tenths of Nyquist at ${rateSps} SPS`);
2409
+ lp = c;
2410
+ } else if (s.kind === STAGE_KINDS.notch) {
2411
+ if (p[0] === 0) throw new Invalid("a band starts above zero");
2412
+ if (p[1]! <= p[0]!) throw new Invalid("a band's high edge is above its low edge");
2413
+ if (p[1]! >= limit) throw new Invalid(`a band up to ${p[1]! / 10} Hz is at or above nine tenths of Nyquist at ${rateSps} SPS`);
2414
+ }
2415
+ }
2416
+ if (hp !== null && lp !== null && hp >= lp) throw new Invalid("the high-pass meets the low-pass: the band passes nothing");
2417
+ }
2418
+
2419
+ /** The device's own default for a rate: a 0.5 Hz high-pass, the mains bands the rate can represent, the automatic low-pass. */
2420
+ export function defaultChain(rateSps: number): Stage[] {
2421
+ const limit = cornerLimitDhz(rateSps);
2422
+ const stages: Stage[] = [{ kind: STAGE_KINDS.highpass, params: [DEFAULT_HIGH_PASS_DHZ] }];
2423
+ for (const [lo, hi] of DEFAULT_MAINS_BANDS_DHZ) if (hi < limit) stages.push({ kind: STAGE_KINDS.notch, params: [lo, hi] });
2424
+ stages.push({ kind: STAGE_KINDS.lowpass, params: [0] });
2425
+ return stages;
2426
+ }
2427
+
2428
+ /**
2429
+ * The notch stages for a region's mains: the fundamental, its second and its third harmonic, four hertz wide.
2430
+ * Given a rate, only the bands that rate can represent: at 250 samples a second the device refuses the higher harmonics.
2431
+ */
2432
+ export function mainsBands(mainsHz: number, rateSps?: number): Stage[] {
2433
+ const f = Math.trunc(mainsHz) * 10;
2434
+ const limit = rateSps === undefined ? Infinity : cornerLimitDhz(rateSps);
2435
+ return [1, 2, 3]
2436
+ .filter((k) => k * f + 20 < limit)
2437
+ .map((k) => ({ kind: STAGE_KINDS.notch, params: [k * f - 20, k * f + 20] }));
2438
+ }
2439
+
2440
+ /** The class of signal a chain produces: the time domain unless a stage is a representation change. */
2441
+ export function outputClass(stages: readonly Stage[], catalog: readonly CatalogEntry[] = CONTRACT_CATALOG): "time_domain" | "representation" {
2442
+ const byKind = new Map(catalog.map((e) => [e.kind, e]));
2443
+ return stages.some((s) => byKind.get(s.kind)?.cls === STAGE_CLASSES.representation) ? "representation" : "time_domain";
2444
+ }
2445
+
2446
+ /** A chain in words, for a manifest, a header, or a person. */
2447
+ export function describeChain(stages: readonly Stage[], rateSps?: number): string {
2448
+ if (stages.length === 0) return "natural signal";
2449
+ const words: string[] = [];
2450
+ for (const s of stages) {
2451
+ const p = s.params;
2452
+ if (s.kind === STAGE_KINDS.highpass && p.length > 0) words.push(`high-pass ${p[0]! / 10} Hz`);
2453
+ else if (s.kind === STAGE_KINDS.lowpass && p.length > 0) {
2454
+ if (p[0]) words.push(`low-pass ${p[0] / 10} Hz`);
2455
+ else if (rateSps) words.push(`low-pass ${lowPassAutoDhz(rateSps) / 10} Hz (automatic at ${rateSps} SPS)`);
2456
+ else words.push("low-pass automatic");
2457
+ } else if (s.kind === STAGE_KINDS.notch && p.length >= 2) words.push(`notch ${p[0]! / 10} to ${p[1]! / 10} Hz`);
2458
+ else words.push(`${stageName(s)} [${p.join(", ")}]`);
2459
+ }
2460
+ return words.join(", ");
2461
+ }
2462
+
2463
+ // --- the update service -----------------------------------------------------
2464
+
2465
+ /** The update service's operations, by name. */
2466
+ export const UPDATE_OPS = { start: 0x01, query: 0x02, finish: 0x03, activate: 0x04, abort: 0x05 } as const;
2467
+ /** The name of any operation `UPDATE_OPS` defines. */
2468
+ export type UpdateOpName = keyof typeof UPDATE_OPS;
2469
+ /** Operation names by their number, the reverse of `UPDATE_OPS`. */
2470
+ export const UPDATE_OP_BY_CODE: Readonly<Record<number, UpdateOpName>> = {
2471
+ 1: "start",
2472
+ 2: "query",
2473
+ 3: "finish",
2474
+ 4: "activate",
2475
+ 5: "abort",
2476
+ };
2477
+
2478
+ /** What a transfer carries: an application, weights, or a head. */
2479
+ export const UPDATE_TARGETS = { app: 1, weights: 2, head: 3 } as const;
2480
+ /** The name of any target `UPDATE_TARGETS` defines. */
2481
+ export type UpdateTargetName = keyof typeof UPDATE_TARGETS;
2482
+ /** Target names by their number, the reverse of `UPDATE_TARGETS`. */
2483
+ export const UPDATE_TARGET_BY_CODE: Readonly<Record<number, UpdateTargetName>> = {
2484
+ 1: "app",
2485
+ 2: "weights",
2486
+ 3: "head",
2487
+ };
2488
+
2489
+ /** What an update control response's status byte means. */
2490
+ export const UPDATE_STATUS: Readonly<Record<number, string>> = {
2491
+ 0: "ok",
2492
+ 1: "invalid argument",
2493
+ 2: "unsupported",
2494
+ 3: "busy",
2495
+ 4: "no transfer",
2496
+ 5: "flash",
2497
+ 6: "verification failed",
2498
+ };
2499
+
2500
+ /** A transfer's state, in words, by its QUERY `state` code. */
2501
+ export const UPDATE_STATES: Readonly<Record<number, string>> = {
2502
+ 0: "idle",
2503
+ 1: "receiving",
2504
+ 2: "complete",
2505
+ 3: "failed",
2506
+ };
2507
+
2508
+ /** Why FINISH accepted or refused an image, in words, by its `verify_result` code. */
2509
+ export const VERIFY_RESULTS: Readonly<Record<number, string>> = {
2510
+ 0: "verified",
2511
+ 1: "length",
2512
+ 2: "malformed",
2513
+ 3: "target",
2514
+ 4: "slot",
2515
+ 5: "hash",
2516
+ 6: "signature",
2517
+ 7: "security counter",
2518
+ 8: "head shape",
2519
+ 9: "flash",
2520
+ 10: "key id",
2521
+ };
2522
+
2523
+ /** The length of a START request, in bytes. */
2524
+ export const UPDATE_START_LEN = 15;
2525
+ /** The length of an image envelope's plain header, in bytes. */
2526
+ export const ENVELOPE_LEN = 32;
2527
+ /** An image envelope's magic bytes, as hex. */
2528
+ export const ENVELOPE_MAGIC = "494d5550";
2529
+ /** The only envelope version this contract defines. */
2530
+ export const ENVELOPE_VERSION = 1;
2531
+ /** The `slot_link` byte for an image that is not linked to an application slot. */
2532
+ export const SLOT_LINK_NONE = 0xff;
2533
+
2534
+ /** A START request, as the device reads it. */
2535
+ export function encodeUpdateStart(
2536
+ target: UpdateTargetName | number,
2537
+ slot: number,
2538
+ totalLen: number,
2539
+ transferId: Uint8Array | string,
2540
+ ): Uint8Array {
2541
+ const code = typeof target === "string" ? UPDATE_TARGETS[target] : target;
2542
+ if (code === undefined || UPDATE_TARGET_BY_CODE[code] === undefined) {
2543
+ throw new Invalid(`${target} is not something this contract transfers`);
2544
+ }
2545
+ const id = typeof transferId === "string" ? fromHex(transferId) : transferId;
2546
+ if (id.length !== 8) throw new Invalid("a transfer identifier is eight bytes");
2547
+ const out = new Uint8Array(UPDATE_START_LEN);
2548
+ out[0] = UPDATE_OPS.start;
2549
+ out[1] = code;
2550
+ out[2] = slot;
2551
+ view(out).setUint32(3, totalLen, true);
2552
+ out.set(id, 7);
2553
+ return out;
2554
+ }
2555
+
2556
+ /** A request with no body: QUERY, FINISH, ACTIVATE, or ABORT, as the device reads it. */
2557
+ export function encodeUpdateOp(op: UpdateOpName): Uint8Array {
2558
+ return new Uint8Array([UPDATE_OPS[op]]);
2559
+ }
2560
+
2561
+ /** An update control request, as `decodeUpdateRequest` reads it. */
2562
+ export interface UpdateRequest {
2563
+ /** The raw operation byte. */
2564
+ readonly op: number;
2565
+ /** `op` in words. */
2566
+ readonly opName: UpdateOpName;
2567
+ /** What is being transferred, for a START request. Null otherwise. */
2568
+ readonly target: number | null;
2569
+ /** `target` in words. */
2570
+ readonly targetName: UpdateTargetName | null;
2571
+ /** The slot, for a START request. Null otherwise. */
2572
+ readonly slot: number | null;
2573
+ /** The exact byte count the host will send, for a START request. Null otherwise. */
2574
+ readonly totalLen: number | null;
2575
+ /** The transfer's identifier, for a START request. Null otherwise. */
2576
+ readonly transferId: string | null;
2577
+ }
2578
+
2579
+ /**
2580
+ * An update control write, as the device reads it. An operation with no
2581
+ * body arrives as exactly one byte.
2582
+ */
2583
+ export function decodeUpdateRequest(data: Uint8Array): UpdateRequest {
2584
+ if (data.length === 0) throw new Truncated("an empty write is not a request");
2585
+ const op = data[0]!;
2586
+ const opName = UPDATE_OP_BY_CODE[op];
2587
+ if (opName === undefined) throw new Invalid(`${toByte(op)} is not an update operation`);
2588
+ const body = data.subarray(1);
2589
+ if (opName !== "start") {
2590
+ if (body.length !== 0) throw new Invalid(`${opName} takes no body`);
2591
+ return { op, opName, target: null, targetName: null, slot: null, totalLen: null, transferId: null };
2592
+ }
2593
+ if (data.length !== UPDATE_START_LEN) {
2594
+ throw new Invalid(`a start is ${UPDATE_START_LEN} bytes, and this one is ${data.length}`);
2595
+ }
2596
+ const target = body[0]!;
2597
+ const targetName = UPDATE_TARGET_BY_CODE[target];
2598
+ if (targetName === undefined) throw new Invalid(`target ${target} is not something this contract transfers`);
2599
+ return {
2600
+ op,
2601
+ opName,
2602
+ target,
2603
+ targetName,
2604
+ slot: body[1]!,
2605
+ totalLen: view(body).getUint32(2, true),
2606
+ transferId: hex(body.subarray(6, 14)),
2607
+ };
2608
+ }
2609
+
2610
+ /** An update control response, as `decodeUpdateResponse` reads it. */
2611
+ export interface UpdateResponse {
2612
+ /** The operation byte this answers. */
2613
+ readonly op: number;
2614
+ /** `op` in words, or null for a number this contract does not define. */
2615
+ readonly opName: UpdateOpName | null;
2616
+ /** The status byte, 0 for success. */
2617
+ readonly status: number;
2618
+ /** `status` in words. */
2619
+ readonly statusName: string;
2620
+ /** Whether `status` is 0. */
2621
+ readonly ok: boolean;
2622
+ /** FINISH says why it refused an image. No other operation carries one. */
2623
+ readonly verifyResult: number | null;
2624
+ /** `verifyResult` in words, or null when there is none. */
2625
+ readonly verifyResultName: string | null;
2626
+ /** The bytes after the operation and status. */
2627
+ readonly payload: Uint8Array;
2628
+ }
2629
+
2630
+ /** An update control response, as the device sends it. */
2631
+ export function decodeUpdateResponse(data: Uint8Array): UpdateResponse {
2632
+ if (data.length < 2) throw new Truncated("an update answer is at least an operation and a status");
2633
+ const op = data[0]!;
2634
+ const status = data[1]!;
2635
+ // An operation or a status this contract does not define is refused
2636
+ // rather than guessed at. A status is what says whether an image was
2637
+ // accepted, and an unknown one must never read as success.
2638
+ const opName = UPDATE_OP_BY_CODE[op];
2639
+ if (opName === undefined) throw new Invalid(`${toByte(op)} is not an update operation this contract defines`);
2640
+ const statusName = UPDATE_STATUS[status];
2641
+ if (statusName === undefined) throw new Invalid(`status ${status} is not one this contract defines`);
2642
+ const payload = data.slice(2);
2643
+ // Only FINISH carries a verify result. Reading the first payload byte of
2644
+ // any answer would label a start answer's slot number as a verdict.
2645
+ const verify = op === UPDATE_OPS.finish && payload.length > 0 ? payload[0]! : null;
2646
+ return {
2647
+ op,
2648
+ opName,
2649
+ status,
2650
+ statusName,
2651
+ ok: status === 0,
2652
+ verifyResult: verify,
2653
+ verifyResultName: verify === null ? null : VERIFY_RESULTS[verify] ?? `result ${verify}`,
2654
+ payload,
2655
+ };
2656
+ }
2657
+
2658
+ /** An update control response, as the device sends it. */
2659
+ export function encodeUpdateResponse(op: number, status: number, payload: Uint8Array = EMPTY): Uint8Array {
2660
+ const out = new Uint8Array(2 + payload.length);
2661
+ out[0] = op;
2662
+ out[1] = status;
2663
+ out.set(payload, 2);
2664
+ return out;
2665
+ }
2666
+
2667
+ /** START's answer: the slot, the chunk limit, and how much the device already has. */
2668
+ export interface UpdateStart {
2669
+ /** Which slot the device wants the image in. */
2670
+ readonly targetSlot: number;
2671
+ /** The largest write it takes. */
2672
+ readonly chunkMax: number;
2673
+ /** How much of this transfer it already has. */
2674
+ readonly resumeOffset: number;
2675
+ }
2676
+
2677
+ /** START's answer, as the device sends it. */
2678
+ export function decodeUpdateStart(payload: Uint8Array): UpdateStart {
2679
+ if (payload.length < 7) throw new Truncated("a start answer is seven bytes");
2680
+ const v = view(payload);
2681
+ return { targetSlot: payload[0]!, chunkMax: v.getUint16(1, true), resumeOffset: v.getUint32(3, true) };
2682
+ }
2683
+
2684
+ /** QUERY's answer: the transfer's state, how much the device holds, and its checksum over that. */
2685
+ export interface UpdateQuery {
2686
+ /** The transfer's state code. See `UPDATE_STATES`. */
2687
+ readonly state: number;
2688
+ /** `state` in words. */
2689
+ readonly stateName: string;
2690
+ /** Bytes the device has accepted so far. */
2691
+ readonly offset: number;
2692
+ /** The device's own checksum over every byte it has accepted. */
2693
+ readonly crc32: number;
2694
+ }
2695
+
2696
+ /** QUERY's answer, as the device sends it. */
2697
+ export function decodeUpdateQuery(payload: Uint8Array): UpdateQuery {
2698
+ if (payload.length < 9) throw new Truncated("a query answer is nine bytes");
2699
+ const v = view(payload);
2700
+ const state = payload[0]!;
2701
+ return {
2702
+ state,
2703
+ stateName: UPDATE_STATES[state] ?? `state ${state}`,
2704
+ offset: v.getUint32(1, true),
2705
+ crc32: v.getUint32(5, true),
2706
+ };
2707
+ }
2708
+
2709
+ /**
2710
+ * The plain head of a wrapped image. Everything after it is encrypted and
2711
+ * means nothing to a host, which is the point: a host carries an update
2712
+ * without being able to read or forge one.
2713
+ */
2714
+ export interface Envelope {
2715
+ /** What is being transferred: application or weights. */
2716
+ readonly target: number;
2717
+ /** `target` in words. */
2718
+ readonly targetName: UpdateTargetName;
2719
+ /** Which application slot this image is built for. `SLOT_LINK_NONE` for weights. */
2720
+ readonly slotLink: number;
2721
+ /** The key identifier the envelope names. */
2722
+ readonly keyId: number;
2723
+ /** The encryption nonce. */
2724
+ readonly nonce: Uint8Array;
2725
+ /** Length of the encrypted payload, in bytes. */
2726
+ readonly plainLen: number;
2727
+ /** The envelope's length plus the payload's. */
2728
+ readonly totalLen: number;
2729
+ }
2730
+
2731
+ /** An image envelope's plain header, as the device reads it. */
2732
+ export function decodeEnvelope(data: Uint8Array): Envelope {
2733
+ if (data.length < ENVELOPE_LEN) throw new Truncated("an envelope is thirty two bytes");
2734
+ if (hex(data.subarray(0, 4)) !== ENVELOPE_MAGIC || data[4] !== ENVELOPE_VERSION) {
2735
+ throw new Invalid("not an image envelope this contract knows");
2736
+ }
2737
+ const target = data[5]!;
2738
+ const targetName = target === 1 ? "app" : target === 2 ? "weights" : null;
2739
+ if (targetName === null) throw new Invalid(`target ${target} is not something an envelope carries`);
2740
+ const slotLink = data[6]!;
2741
+ if (targetName === "app" && slotLink > 1) {
2742
+ throw new Invalid("an application image is built for slot zero or one");
2743
+ }
2744
+ if (targetName === "weights" && slotLink !== SLOT_LINK_NONE) {
2745
+ throw new Invalid("weights are not built for a slot");
2746
+ }
2747
+ const plainLen = view(data).getUint32(24, true);
2748
+ return {
2749
+ target,
2750
+ targetName,
2751
+ slotLink,
2752
+ keyId: data[7]!,
2753
+ nonce: data.slice(8, 24),
2754
+ plainLen,
2755
+ totalLen: ENVELOPE_LEN + plainLen,
2756
+ };
2757
+ }
2758
+
2759
+ /** An image envelope's plain header, as the device reads it. */
2760
+ export function encodeEnvelope(e: {
2761
+ target: UpdateTargetName | number;
2762
+ slotLink: number;
2763
+ keyId: number;
2764
+ nonce: Uint8Array;
2765
+ plainLen: number;
2766
+ }): Uint8Array {
2767
+ const target = typeof e.target === "string" ? UPDATE_TARGETS[e.target] : e.target;
2768
+ if (e.nonce.length !== 16) throw new Invalid("a nonce is sixteen bytes");
2769
+ const out = new Uint8Array(ENVELOPE_LEN);
2770
+ out.set(fromHex(ENVELOPE_MAGIC), 0);
2771
+ out[4] = ENVELOPE_VERSION;
2772
+ out[5] = target;
2773
+ out[6] = e.slotLink;
2774
+ out[7] = e.keyId;
2775
+ out.set(e.nonce, 8);
2776
+ view(out).setUint32(24, e.plainLen, true);
2777
+ return out;
2778
+ }
2779
+
2780
+ // --- heads ------------------------------------------------------------------
2781
+
2782
+ /** A head blob's magic bytes, as hex. */
2783
+ export const HEAD_MAGIC = "494d4844";
2784
+ /** The length of a format 1 head header, in bytes. */
2785
+ export const HEAD_HEADER_LEN = 32;
2786
+ /** 1.3: format 2 appends the eight byte id of the encoder the head was trained beside. */
2787
+ export const HEAD_HEADER_LEN_2 = 40;
2788
+ /** The length of a head blob's trailing hash, in bytes. */
2789
+ export const HEAD_HASH_LEN = 32;
2790
+ /** The only head kind this contract defines. */
2791
+ export const HEAD_KIND_LINEAR = 1;
2792
+ /** The length of a head's name field, in bytes. */
2793
+ export const HEAD_NAME_LEN = 16;
2794
+
2795
+ /**
2796
+ * Counts per unit of embedding. Part of the contract: changing it would
2797
+ * change every head ever trained.
2798
+ */
2799
+ export const EMBED_SCALE = 4096.0;
2800
+
2801
+ /** The exact length of a head blob for a shape and format version. */
2802
+ export function headBlobLen(inDim: number, outDim: number, version = 2): number {
2803
+ return (version === 2 ? HEAD_HEADER_LEN_2 : HEAD_HEADER_LEN) + outDim * inDim + 8 * outDim + HEAD_HASH_LEN;
2804
+ }
2805
+
2806
+ /**
2807
+ * The integers a head receives for a float embedding. Saturates rather than
2808
+ * wrapping, so an extreme value is clipped and never reversed.
2809
+ */
2810
+ export function quantizeEmbedding(embedding: ArrayLike<number>): number[] {
2811
+ const out: number[] = [];
2812
+ for (let i = 0; i < embedding.length; i++) {
2813
+ const scaled = embedding[i]! * EMBED_SCALE;
2814
+ if (scaled >= 32767) out.push(32767);
2815
+ else if (scaled <= -32768) out.push(-32768);
2816
+ else out.push(Math.trunc(scaled >= 0 ? scaled + 0.5 : scaled - 0.5));
2817
+ }
2818
+ return out;
2819
+ }
2820
+
2821
+ /** A head, decoded: the weights, and everything needed to run them. */
2822
+ export interface HeadBlob {
2823
+ /** `HEAD_KIND_LINEAR`, the only kind this contract defines. */
2824
+ readonly kind: number;
2825
+ /** The embedding width this head takes. Must equal the loaded encoder's. */
2826
+ readonly inDim: number;
2827
+ /** Outputs this head produces. */
2828
+ readonly outDim: number;
2829
+ /** The head's name. */
2830
+ readonly name: string;
2831
+ /** The head's identity, the first eight bytes of its SHA-256. */
2832
+ readonly headId: string;
2833
+ /** One row per output, each row `inDim` signed byte values. */
2834
+ readonly weights: number[][];
2835
+ /** One integer per output. */
2836
+ readonly bias: number[];
2837
+ /** One float per output. */
2838
+ readonly scale: number[];
2839
+ /** The format the file was written in: 1, or 2 with an encoder id. */
2840
+ readonly version: number;
2841
+ /** 1.3: the encoder the head was trained beside; `NO_ENCODER_ID` when the file does not say. The device never judges it. */
2842
+ readonly encoderId: string;
2843
+ }
2844
+
2845
+ /**
2846
+ * A head blob, ready to upload.
2847
+ *
2848
+ * `weights` is one row per output, each row `inDim` values in the range a
2849
+ * signed byte holds. `bias` is one integer per output and `scale` one float
2850
+ * per output, which together carry whatever range the outputs need. The
2851
+ * hash is computed here, because a head the device cannot check is a head
2852
+ * the device refuses.
2853
+ *
2854
+ * `encoderId` names the encoder the head was trained beside (1.3, format 2),
2855
+ * so a host can warn when it is uploaded to a device running another;
2856
+ * `version` 1 writes the 1.0 header for a device that predates format 2.
2857
+ */
2858
+ export function buildHead(
2859
+ weights: ReadonlyArray<ArrayLike<number>>,
2860
+ bias: ArrayLike<number>,
2861
+ scale: ArrayLike<number>,
2862
+ options: { name?: string; inDim?: number; encoderId?: string; version?: number } = {},
2863
+ ): Uint8Array {
2864
+ const version = options.version ?? 2;
2865
+ if (version !== 1 && version !== 2) throw new Invalid("a head is written in format 1 or 2");
2866
+ const encoder = fromHex(options.encoderId ?? NO_ENCODER_ID);
2867
+ if (encoder.length !== 8) throw new Invalid("an encoder id is eight bytes, sixteen hex digits");
2868
+ if (version === 1 && options.encoderId !== undefined && options.encoderId !== NO_ENCODER_ID) {
2869
+ throw new Invalid("format 1 has no room for an encoder id");
2870
+ }
2871
+ const outDim = weights.length;
2872
+ if (outDim === 0) throw new Invalid("a head has at least one output");
2873
+ const inDim = options.inDim ?? weights[0]!.length;
2874
+ for (const row of weights) {
2875
+ if (row.length !== inDim) throw new Invalid("every row of a head is the same width");
2876
+ }
2877
+ if (bias.length !== outDim || scale.length !== outDim) {
2878
+ throw new Invalid("one bias and one scale per output");
2879
+ }
2880
+ const blob = new Uint8Array(headBlobLen(inDim, outDim, version));
2881
+ const v = view(blob);
2882
+ blob.set(fromHex(HEAD_MAGIC), 0);
2883
+ blob[4] = version;
2884
+ blob[5] = HEAD_KIND_LINEAR;
2885
+ v.setUint16(6, inDim, true);
2886
+ v.setUint16(8, outDim, true);
2887
+ blob.set(paddedName(options.name ?? "", HEAD_NAME_LEN), 10);
2888
+ if (version === 2) blob.set(encoder, 32);
2889
+ let at = version === 2 ? HEAD_HEADER_LEN_2 : HEAD_HEADER_LEN;
2890
+ for (const row of weights) {
2891
+ for (let i = 0; i < inDim; i++) {
2892
+ const w = row[i]!;
2893
+ if (!Number.isInteger(w) || w < -128 || w > 127) {
2894
+ throw new Invalid("head weights are signed bytes, so they are trained or scaled to fit");
2895
+ }
2896
+ blob[at++] = w & 0xff;
2897
+ }
2898
+ }
2899
+ for (let o = 0; o < outDim; o++, at += 4) v.setInt32(at, bias[o]!, true);
2900
+ for (let o = 0; o < outDim; o++, at += 4) v.setFloat32(at, scale[o]!, true);
2901
+ blob.set(sha256(blob.subarray(0, at)), at);
2902
+ return blob;
2903
+ }
2904
+
2905
+ /** Read a head back, and check it the way the device does. */
2906
+ export function decodeHead(blob: Uint8Array): HeadBlob {
2907
+ if (blob.length < HEAD_HEADER_LEN + HEAD_HASH_LEN) {
2908
+ throw new Truncated("a head is at least a header and a hash");
2909
+ }
2910
+ if (hex(blob.subarray(0, 4)) !== HEAD_MAGIC || (blob[4] !== 1 && blob[4] !== 2)) {
2911
+ throw new Invalid("not a head this contract knows");
2912
+ }
2913
+ const version = blob[4]!;
2914
+ if (blob[5] !== HEAD_KIND_LINEAR) {
2915
+ throw new Invalid(`head kind ${blob[5]} is not one this contract defines`);
2916
+ }
2917
+ const v = view(blob);
2918
+ const inDim = v.getUint16(6, true);
2919
+ const outDim = v.getUint16(8, true);
2920
+ if (blob.length !== headBlobLen(inDim, outDim, version)) {
2921
+ throw new Invalid(
2922
+ `a head of ${inDim} by ${outDim} is ${headBlobLen(inDim, outDim, version)} bytes, and this one is ${blob.length}`,
2923
+ );
2924
+ }
2925
+ const body = blob.subarray(0, blob.length - HEAD_HASH_LEN);
2926
+ const digest = blob.subarray(blob.length - HEAD_HASH_LEN);
2927
+ if (hex(sha256(body)) !== hex(digest)) {
2928
+ throw new Invalid("a head whose hash does not match its weights is refused, as the device refuses it");
2929
+ }
2930
+ let at = version === 2 ? HEAD_HEADER_LEN_2 : HEAD_HEADER_LEN;
2931
+ const weights: number[][] = [];
2932
+ for (let o = 0; o < outDim; o++) {
2933
+ const row: number[] = [];
2934
+ for (let i = 0; i < inDim; i++) row.push(v.getInt8(at + o * inDim + i));
2935
+ weights.push(row);
2936
+ }
2937
+ at += outDim * inDim;
2938
+ const bias: number[] = [];
2939
+ for (let o = 0; o < outDim; o++) bias.push(v.getInt32(at + o * 4, true));
2940
+ at += outDim * 4;
2941
+ const scale: number[] = [];
2942
+ for (let o = 0; o < outDim; o++) scale.push(v.getFloat32(at + o * 4, true));
2943
+ return {
2944
+ kind: blob[5]!,
2945
+ inDim,
2946
+ outDim,
2947
+ name: nameOf(blob.subarray(10, 26)),
2948
+ headId: hex(digest.subarray(0, 8)),
2949
+ weights,
2950
+ bias,
2951
+ scale,
2952
+ version,
2953
+ encoderId: version === 2 ? hex(blob.subarray(32, 40)) : NO_ENCODER_ID,
2954
+ };
2955
+ }
2956
+
2957
+ /**
2958
+ * What a head produces, computed the way the device computes it: the
2959
+ * accumulation is exact in integers and only the scale is a float.
2960
+ *
2961
+ * `embedding` is the integers a head receives, which is what
2962
+ * `quantizeEmbedding` returns. Floats are not taken here, because a head
2963
+ * that was handed floats would silently produce something the device never
2964
+ * would.
2965
+ */
2966
+ export function evaluateHead(head: HeadBlob, embedding: ArrayLike<number>): number[] {
2967
+ if (embedding.length !== head.inDim) {
2968
+ throw new Invalid(`this head takes an embedding of ${head.inDim}, and it was given ${embedding.length}`);
2969
+ }
2970
+ const out: number[] = [];
2971
+ for (let o = 0; o < head.outDim; o++) {
2972
+ // Every term is an integer under two to the fifty third, so the
2973
+ // accumulation is exact: a byte weight times a sixteen bit count is at
2974
+ // most two to the twenty second, and a head would need two billion
2975
+ // inputs before the sum could round.
2976
+ let acc = head.bias[o]!;
2977
+ for (let i = 0; i < head.inDim; i++) {
2978
+ const e = embedding[i]!;
2979
+ if (!Number.isInteger(e)) {
2980
+ throw new Invalid("a head takes the quantized embedding, from quantizeEmbedding");
2981
+ }
2982
+ acc += head.weights[o]![i]! * e;
2983
+ }
2984
+ out.push(head.scale[o]! * acc);
2985
+ }
2986
+ return out;
2987
+ }