@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.
- package/CLA.md +71 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +662 -0
- package/LICENSING.md +45 -0
- package/README.md +147 -2
- package/dist/digest.d.ts +22 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +109 -0
- package/dist/digest.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +1433 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +2227 -0
- package/dist/protocol.js.map +1 -0
- package/dist/session.d.ts +284 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +523 -0
- package/dist/session.js.map +1 -0
- package/dist/timebase.d.ts +65 -0
- package/dist/timebase.d.ts.map +1 -0
- package/dist/timebase.js +114 -0
- package/dist/timebase.js.map +1 -0
- package/dist/transfer.d.ts +139 -0
- package/dist/transfer.d.ts.map +1 -0
- package/dist/transfer.js +269 -0
- package/dist/transfer.js.map +1 -0
- package/dist/web-bluetooth.d.ts +172 -0
- package/dist/web-bluetooth.d.ts.map +1 -0
- package/dist/web-bluetooth.js +310 -0
- package/dist/web-bluetooth.js.map +1 -0
- package/package.json +74 -4
- package/src/digest.ts +115 -0
- package/src/index.ts +103 -0
- package/src/protocol.ts +2987 -0
- package/src/session.ts +675 -0
- package/src/timebase.ts +138 -0
- package/src/transfer.ts +313 -0
- package/src/web-bluetooth.ts +419 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,2227 @@
|
|
|
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
|
+
import { sha256 } from "./digest.js";
|
|
31
|
+
export { Crc32, crc32, sha256 } from "./digest.js";
|
|
32
|
+
/** The protocol version this module implements, major and minor. */
|
|
33
|
+
export const VERSION = [1, 4];
|
|
34
|
+
/** The full identifier for one characteristic. */
|
|
35
|
+
export function uuid(fill) {
|
|
36
|
+
return `f3a1${fill.toString(16).padStart(4, "0")}-2c4b-4d1e-9a6f-1b2c3d4e5f60`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The sixteen bit fill each characteristic sits at. A notification is
|
|
40
|
+
* routed by this rather than by its full identifier, so nothing downstream
|
|
41
|
+
* has to format or compare strings.
|
|
42
|
+
*/
|
|
43
|
+
export const FILL = {
|
|
44
|
+
SERVICE: 0x0001,
|
|
45
|
+
DEVICE_INFO: 0x0002,
|
|
46
|
+
CONTROL: 0x0003,
|
|
47
|
+
CONTROL_RESPONSE: 0x0004,
|
|
48
|
+
EEG_DATA: 0x0005,
|
|
49
|
+
STATUS: 0x0006,
|
|
50
|
+
/** Held by an earlier development firmware. Never reused. */
|
|
51
|
+
RESERVED_0007: 0x0007,
|
|
52
|
+
UPDATE_CONTROL: 0x0008,
|
|
53
|
+
UPDATE_DATA: 0x0009,
|
|
54
|
+
PREDICTIONS: 0x000a,
|
|
55
|
+
/** 1.2: the encoder's output for each window. */
|
|
56
|
+
EMBEDDINGS: 0x000b,
|
|
57
|
+
};
|
|
58
|
+
/** The service's full identifier, advertised by every device. */
|
|
59
|
+
export const SERVICE = uuid(FILL.SERVICE);
|
|
60
|
+
/** Device Info's full identifier. */
|
|
61
|
+
export const DEVICE_INFO = uuid(FILL.DEVICE_INFO);
|
|
62
|
+
/** Control Point's full identifier. */
|
|
63
|
+
export const CONTROL = uuid(FILL.CONTROL);
|
|
64
|
+
/** Control Response's full identifier. */
|
|
65
|
+
export const CONTROL_RESPONSE = uuid(FILL.CONTROL_RESPONSE);
|
|
66
|
+
/** EEG Data's full identifier. */
|
|
67
|
+
export const EEG_DATA = uuid(FILL.EEG_DATA);
|
|
68
|
+
/** Status's full identifier. */
|
|
69
|
+
export const STATUS = uuid(FILL.STATUS);
|
|
70
|
+
/** Update Control's full identifier. */
|
|
71
|
+
export const UPDATE_CONTROL = uuid(FILL.UPDATE_CONTROL);
|
|
72
|
+
/** Update Data's full identifier. */
|
|
73
|
+
export const UPDATE_DATA = uuid(FILL.UPDATE_DATA);
|
|
74
|
+
/** Predictions' full identifier. */
|
|
75
|
+
export const PREDICTIONS = uuid(FILL.PREDICTIONS);
|
|
76
|
+
/** Embeddings' full identifier. */
|
|
77
|
+
export const EMBEDDINGS = uuid(FILL.EMBEDDINGS);
|
|
78
|
+
/** The name a device advertises under, before its own identifier. */
|
|
79
|
+
export const NAME_PREFIX = "IntoMind-";
|
|
80
|
+
/**
|
|
81
|
+
* 1.3: the product name a composed device name ends with. A 1.3 device
|
|
82
|
+
* advertises `[name's] [adjective] IntoMind One`; a 1.2 device advertised
|
|
83
|
+
* `IntoMind-` and four hex digits.
|
|
84
|
+
*/
|
|
85
|
+
export const PRODUCT_NAME = "IntoMind One";
|
|
86
|
+
/** Bytes that are not a message, with the reason they are not. */
|
|
87
|
+
export class ProtocolError extends Error {
|
|
88
|
+
/** Which of the three reasons this is. */
|
|
89
|
+
reason;
|
|
90
|
+
/** Build one with its reason and message. A decoder throws one of the three subclasses. */
|
|
91
|
+
constructor(reason, message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = "ProtocolError";
|
|
94
|
+
this.reason = reason;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The message ends before the fields it promises. */
|
|
98
|
+
export class Truncated extends ProtocolError {
|
|
99
|
+
/** Build one with the message saying what is missing. */
|
|
100
|
+
constructor(message) {
|
|
101
|
+
super("truncated", message);
|
|
102
|
+
this.name = "Truncated";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** A field holds a value the contract does not define. */
|
|
106
|
+
export class Invalid extends ProtocolError {
|
|
107
|
+
/** Build one with the message saying what is wrong. */
|
|
108
|
+
constructor(message) {
|
|
109
|
+
super("invalid", message);
|
|
110
|
+
this.name = "Invalid";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A number the contract reserves. A device answers these unsupported rather
|
|
115
|
+
* than treating them as malformed, and so does a host.
|
|
116
|
+
*/
|
|
117
|
+
export class Reserved extends ProtocolError {
|
|
118
|
+
/** Build one with the message naming the reserved number. */
|
|
119
|
+
constructor(message) {
|
|
120
|
+
super("reserved", message);
|
|
121
|
+
this.name = "Reserved";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// --- bytes ------------------------------------------------------------------
|
|
125
|
+
const EMPTY = new Uint8Array(0);
|
|
126
|
+
function view(b) {
|
|
127
|
+
return new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
128
|
+
}
|
|
129
|
+
function hex(b) {
|
|
130
|
+
let s = "";
|
|
131
|
+
for (let i = 0; i < b.length; i++)
|
|
132
|
+
s += b[i].toString(16).padStart(2, "0");
|
|
133
|
+
return s;
|
|
134
|
+
}
|
|
135
|
+
/** Bytes from a hex string, for tests and for anything that logs one. */
|
|
136
|
+
export function fromHex(s) {
|
|
137
|
+
if (s.length % 2 !== 0)
|
|
138
|
+
throw new Invalid("a hex string has an even number of characters");
|
|
139
|
+
const out = new Uint8Array(s.length / 2);
|
|
140
|
+
for (let i = 0; i < out.length; i++) {
|
|
141
|
+
const byte = Number.parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
|
142
|
+
if (Number.isNaN(byte))
|
|
143
|
+
throw new Invalid(`${s.slice(i * 2, i * 2 + 2)} is not a hex byte`);
|
|
144
|
+
out[i] = byte;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
export { hex as toHex };
|
|
149
|
+
/** A fixed width name field, without the padding the wire carries. */
|
|
150
|
+
function nameOf(b) {
|
|
151
|
+
let end = b.length;
|
|
152
|
+
while (end > 0 && b[end - 1] === 0)
|
|
153
|
+
end--;
|
|
154
|
+
return new TextDecoder("utf-8").decode(b.subarray(0, end));
|
|
155
|
+
}
|
|
156
|
+
function paddedName(name, width) {
|
|
157
|
+
const out = new Uint8Array(width);
|
|
158
|
+
out.set(new TextEncoder().encode(name).subarray(0, width));
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
// --- the control point ------------------------------------------------------
|
|
162
|
+
/** Every control point opcode this contract defines, by name. */
|
|
163
|
+
export const OPCODES = {
|
|
164
|
+
start_stream: 0x01,
|
|
165
|
+
stop_stream: 0x02,
|
|
166
|
+
set_rate: 0x10,
|
|
167
|
+
set_gain: 0x11,
|
|
168
|
+
set_mode: 0x20,
|
|
169
|
+
set_leadoff: 0x30,
|
|
170
|
+
time_sync: 0x40,
|
|
171
|
+
set_samples_per_packet: 0x41,
|
|
172
|
+
get_battery: 0x42,
|
|
173
|
+
get_boot_info: 0x43,
|
|
174
|
+
reset_epoch: 0x50,
|
|
175
|
+
clear_bonds: 0x53,
|
|
176
|
+
set_predictions: 0x80,
|
|
177
|
+
select_head: 0x81,
|
|
178
|
+
list_heads: 0x82,
|
|
179
|
+
remove_head: 0x83,
|
|
180
|
+
get_model_info: 0x84,
|
|
181
|
+
// New in 1.1.
|
|
182
|
+
set_prediction_input: 0x85,
|
|
183
|
+
get_prediction_input: 0x86,
|
|
184
|
+
set_bias: 0x32,
|
|
185
|
+
get_bias_diagnostic: 0x33,
|
|
186
|
+
get_pipeline_catalog: 0x90,
|
|
187
|
+
get_pipeline: 0x91,
|
|
188
|
+
set_pipeline: 0x92,
|
|
189
|
+
clear_pipeline: 0x93,
|
|
190
|
+
restore_pipeline_default: 0x94,
|
|
191
|
+
// New in 1.2.
|
|
192
|
+
get_converter_registers: 0x34,
|
|
193
|
+
get_indicator: 0x44,
|
|
194
|
+
set_indicator: 0x45,
|
|
195
|
+
identify: 0x46,
|
|
196
|
+
set_embeddings: 0x87,
|
|
197
|
+
// New in 1.3.
|
|
198
|
+
set_model_interval: 0x88,
|
|
199
|
+
get_model_interval: 0x89,
|
|
200
|
+
get_name: 0x47,
|
|
201
|
+
set_name: 0x48,
|
|
202
|
+
// New in 1.4.
|
|
203
|
+
list_head_encoders: 0x8a,
|
|
204
|
+
soft_reset: 0xf0,
|
|
205
|
+
};
|
|
206
|
+
/** Opcode names by their number, the reverse of `OPCODES`. */
|
|
207
|
+
export const NAME_BY_OPCODE = new Map(Object.entries(OPCODES).map(([name, code]) => [code, name]));
|
|
208
|
+
/** Opcodes that take one argument byte, and require it. */
|
|
209
|
+
export const TAKES_ARG = new Set([
|
|
210
|
+
"set_rate",
|
|
211
|
+
"set_gain",
|
|
212
|
+
"set_mode",
|
|
213
|
+
"set_leadoff",
|
|
214
|
+
"set_samples_per_packet",
|
|
215
|
+
"set_predictions",
|
|
216
|
+
"select_head",
|
|
217
|
+
"remove_head",
|
|
218
|
+
"set_bias",
|
|
219
|
+
"set_indicator",
|
|
220
|
+
"identify",
|
|
221
|
+
"set_embeddings",
|
|
222
|
+
]);
|
|
223
|
+
/**
|
|
224
|
+
* Opcodes new in 1.1 that carry a payload after the opcode byte, laid out
|
|
225
|
+
* as the processing section below says. The only requests longer than two
|
|
226
|
+
* bytes.
|
|
227
|
+
*/
|
|
228
|
+
export const TAKES_PAYLOAD = new Set([
|
|
229
|
+
"set_pipeline",
|
|
230
|
+
"set_prediction_input",
|
|
231
|
+
"set_model_interval",
|
|
232
|
+
"set_name",
|
|
233
|
+
]);
|
|
234
|
+
/**
|
|
235
|
+
* Opcodes a 1.0 device does not know. A host sends one only to a device
|
|
236
|
+
* whose Device Info claims the capability behind it.
|
|
237
|
+
*/
|
|
238
|
+
export const NEW_IN_1_1 = new Set([
|
|
239
|
+
"set_prediction_input",
|
|
240
|
+
"get_prediction_input",
|
|
241
|
+
"set_bias",
|
|
242
|
+
"get_bias_diagnostic",
|
|
243
|
+
"get_pipeline_catalog",
|
|
244
|
+
"get_pipeline",
|
|
245
|
+
"set_pipeline",
|
|
246
|
+
"clear_pipeline",
|
|
247
|
+
"restore_pipeline_default",
|
|
248
|
+
]);
|
|
249
|
+
/** Opcodes a 1.1 device does not know, likewise gated on a capability. */
|
|
250
|
+
export const NEW_IN_1_2 = new Set([
|
|
251
|
+
"get_converter_registers",
|
|
252
|
+
"get_indicator",
|
|
253
|
+
"set_indicator",
|
|
254
|
+
"identify",
|
|
255
|
+
"set_embeddings",
|
|
256
|
+
]);
|
|
257
|
+
/** Opcodes a 1.2 device does not know: the model's cadence and the device's name. */
|
|
258
|
+
export const NEW_IN_1_3 = new Set([
|
|
259
|
+
"set_model_interval",
|
|
260
|
+
"get_model_interval",
|
|
261
|
+
"get_name",
|
|
262
|
+
"set_name",
|
|
263
|
+
]);
|
|
264
|
+
/**
|
|
265
|
+
* Numbers an earlier development firmware used, and numbers the contract
|
|
266
|
+
* keeps for factory and bench builds. Never reused for anything else.
|
|
267
|
+
*/
|
|
268
|
+
export const RESERVED_OPCODES = new Set([
|
|
269
|
+
0x12,
|
|
270
|
+
0x13,
|
|
271
|
+
0x31,
|
|
272
|
+
0x70,
|
|
273
|
+
0x71,
|
|
274
|
+
...Array.from({ length: 16 }, (_, i) => 0x60 + i),
|
|
275
|
+
]);
|
|
276
|
+
/** A control response's status byte, in words. */
|
|
277
|
+
export const STATUS_CODES = {
|
|
278
|
+
0: "ok",
|
|
279
|
+
1: "invalid argument",
|
|
280
|
+
2: "unsupported",
|
|
281
|
+
3: "busy",
|
|
282
|
+
4: "not streaming",
|
|
283
|
+
5: "hardware",
|
|
284
|
+
// 1.4: a stream of the natural signal refused while USB power is present.
|
|
285
|
+
// 6 stays unassigned because the Update service uses it.
|
|
286
|
+
7: "usb power",
|
|
287
|
+
};
|
|
288
|
+
/** The gain a `gain_code` of 0 to 6 selects. */
|
|
289
|
+
export const GAIN_BY_CODE = [1, 2, 4, 6, 8, 12, 24];
|
|
290
|
+
/** The code for a gain, the reverse of `GAIN_BY_CODE`. */
|
|
291
|
+
export const CODE_BY_GAIN = new Map(GAIN_BY_CODE.map((g, i) => [g, i]));
|
|
292
|
+
/** The rate in samples per second a `rate_code` selects. */
|
|
293
|
+
export const RATE_BY_CODE = { 4: 1000, 5: 500, 6: 250 };
|
|
294
|
+
/** The code for a rate, the reverse of `RATE_BY_CODE`. */
|
|
295
|
+
export const CODE_BY_RATE = new Map([
|
|
296
|
+
[1000, 4],
|
|
297
|
+
[500, 5],
|
|
298
|
+
[250, 6],
|
|
299
|
+
]);
|
|
300
|
+
/** The bit in Device Info's rate mask that stands for each rate. */
|
|
301
|
+
export const RATE_BY_INFO_BIT = { 0: 250, 1: 500, 2: 1000 };
|
|
302
|
+
/** 1.3 adds synthetic: the converter is not driven and the device generates the signal, on a device that claims it. */
|
|
303
|
+
export const MODES = { normal: 0, test: 1, short: 2, synthetic: 3 };
|
|
304
|
+
/** Mode names by their code, the reverse of `MODES`. */
|
|
305
|
+
export const MODE_BY_CODE = { 0: "normal", 1: "test", 2: "short", 3: "synthetic" };
|
|
306
|
+
/** What a charger state code means. */
|
|
307
|
+
export const CHARGER_STATES = {
|
|
308
|
+
0: "no input",
|
|
309
|
+
1: "charging",
|
|
310
|
+
2: "complete",
|
|
311
|
+
3: "fault",
|
|
312
|
+
4: "standby",
|
|
313
|
+
};
|
|
314
|
+
/** Why the device last booted, by `boot_reason` code. */
|
|
315
|
+
export const BOOT_REASONS = {
|
|
316
|
+
0: "power on",
|
|
317
|
+
1: "reset pin",
|
|
318
|
+
2: "software",
|
|
319
|
+
3: "watchdog",
|
|
320
|
+
4: "fault",
|
|
321
|
+
5: "update",
|
|
322
|
+
6: "rollback",
|
|
323
|
+
0xff: "unknown",
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* One control point write.
|
|
327
|
+
*
|
|
328
|
+
* `opcode` is a name or a number. An opcode that takes an argument requires
|
|
329
|
+
* one, and one that does not takes none, because the device refuses either
|
|
330
|
+
* mistake and a host should not make it.
|
|
331
|
+
*/
|
|
332
|
+
export function encodeRequest(opcode, arg = null) {
|
|
333
|
+
const name = typeof opcode === "string" ? opcode : NAME_BY_OPCODE.get(opcode) ?? null;
|
|
334
|
+
const code = name === null ? undefined : OPCODES[name];
|
|
335
|
+
if (name === null || code === undefined) {
|
|
336
|
+
const shown = typeof opcode === "string" ? opcode : toByte(opcode);
|
|
337
|
+
throw new Invalid(`${shown} is not an opcode this contract defines`);
|
|
338
|
+
}
|
|
339
|
+
if (TAKES_PAYLOAD.has(name)) {
|
|
340
|
+
if (!(arg instanceof Uint8Array))
|
|
341
|
+
throw new Invalid(`${name} takes a payload of bytes`);
|
|
342
|
+
const out = new Uint8Array(1 + arg.length);
|
|
343
|
+
out[0] = code;
|
|
344
|
+
out.set(arg, 1);
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
if (arg instanceof Uint8Array)
|
|
348
|
+
throw new Invalid(`${name} takes no payload`);
|
|
349
|
+
if (TAKES_ARG.has(name) !== (arg !== null)) {
|
|
350
|
+
throw new Invalid(`${name} takes ${TAKES_ARG.has(name) ? "an argument" : "no argument"}`);
|
|
351
|
+
}
|
|
352
|
+
return arg === null ? new Uint8Array([code]) : new Uint8Array([code, arg & 0xff]);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* A control point write, as the device reads it. For tests and for anything
|
|
356
|
+
* that has to speak both sides of the contract.
|
|
357
|
+
*/
|
|
358
|
+
export function decodeRequest(data) {
|
|
359
|
+
if (data.length === 0)
|
|
360
|
+
throw new Truncated("an empty write is not a request");
|
|
361
|
+
const code = data[0];
|
|
362
|
+
if (RESERVED_OPCODES.has(code))
|
|
363
|
+
throw new Reserved(`${toByte(code)} is reserved`);
|
|
364
|
+
const name = NAME_BY_OPCODE.get(code);
|
|
365
|
+
if (name === undefined)
|
|
366
|
+
throw new Invalid(`${toByte(code)} is not an opcode this contract defines`);
|
|
367
|
+
const rest = data.subarray(1);
|
|
368
|
+
if (TAKES_PAYLOAD.has(name)) {
|
|
369
|
+
// The payload's own decoder judges a chain; the 1.3 payloads are judged here.
|
|
370
|
+
if (name === "set_model_interval" && rest.length !== 2) {
|
|
371
|
+
throw new Invalid("set_model_interval takes two bytes, the seconds little-endian");
|
|
372
|
+
}
|
|
373
|
+
if (name === "set_name")
|
|
374
|
+
decodeNameParts(rest);
|
|
375
|
+
return { opcode: code, name, arg: null, payload: rest.slice() };
|
|
376
|
+
}
|
|
377
|
+
if (TAKES_ARG.has(name)) {
|
|
378
|
+
if (rest.length !== 1)
|
|
379
|
+
throw new Invalid(`${name} takes exactly one argument byte`);
|
|
380
|
+
return { opcode: code, name, arg: rest[0], payload: null };
|
|
381
|
+
}
|
|
382
|
+
if (rest.length !== 0)
|
|
383
|
+
throw new Invalid(`${name} takes no argument`);
|
|
384
|
+
return { opcode: code, name, arg: null, payload: null };
|
|
385
|
+
}
|
|
386
|
+
/** One control response, as the device sends it. */
|
|
387
|
+
export function decodeResponse(data) {
|
|
388
|
+
if (data.length < 2)
|
|
389
|
+
throw new Truncated("a response is at least an opcode and a status");
|
|
390
|
+
const status = data[1];
|
|
391
|
+
return {
|
|
392
|
+
opcode: data[0],
|
|
393
|
+
name: NAME_BY_OPCODE.get(data[0]) ?? null,
|
|
394
|
+
status,
|
|
395
|
+
statusName: STATUS_CODES[status] ?? `undefined status ${status}`,
|
|
396
|
+
ok: status === 0,
|
|
397
|
+
payload: data.slice(2),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* One control response. The opcode is a number rather than a name because a
|
|
402
|
+
* device echoes back whatever it was sent, including a reserved number.
|
|
403
|
+
*/
|
|
404
|
+
export function encodeResponse(opcode, status, payload = EMPTY) {
|
|
405
|
+
const out = new Uint8Array(2 + payload.length);
|
|
406
|
+
out[0] = opcode;
|
|
407
|
+
out[1] = status;
|
|
408
|
+
out.set(payload, 2);
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
/** The device's own time, from the answer to a time request. */
|
|
412
|
+
export function decodeTimeSync(payload) {
|
|
413
|
+
if (payload.length < 8)
|
|
414
|
+
throw new Truncated("a device time is eight bytes");
|
|
415
|
+
return view(payload).getBigUint64(0, true);
|
|
416
|
+
}
|
|
417
|
+
function toByte(n) {
|
|
418
|
+
return `0x${n.toString(16).padStart(2, "0")}`;
|
|
419
|
+
}
|
|
420
|
+
// --- device info ------------------------------------------------------------
|
|
421
|
+
/** What a device can do, as the bits it reports. */
|
|
422
|
+
export const CAPABILITIES = {
|
|
423
|
+
battery_voltage: 1 << 0,
|
|
424
|
+
battery_low_flag: 1 << 1,
|
|
425
|
+
leadoff: 1 << 2,
|
|
426
|
+
test_signal: 1 << 3,
|
|
427
|
+
dcdc_mode: 1 << 4,
|
|
428
|
+
input_short: 1 << 5,
|
|
429
|
+
update: 1 << 6,
|
|
430
|
+
model: 1 << 7,
|
|
431
|
+
model_ready: 1 << 8,
|
|
432
|
+
heads: 1 << 9,
|
|
433
|
+
// New in 1.1.
|
|
434
|
+
pipeline: 1 << 10,
|
|
435
|
+
bias_drive: 1 << 11,
|
|
436
|
+
// New in 1.2.
|
|
437
|
+
embeddings: 1 << 12,
|
|
438
|
+
indicator: 1 << 13,
|
|
439
|
+
converter_registers: 1 << 14,
|
|
440
|
+
// New in 1.3.
|
|
441
|
+
model_cadence: 1 << 15,
|
|
442
|
+
};
|
|
443
|
+
/** 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. */
|
|
444
|
+
export const CAPABILITIES_HIGH = {
|
|
445
|
+
synthetic: 1 << 0,
|
|
446
|
+
device_name: 1 << 1,
|
|
447
|
+
};
|
|
448
|
+
/** Length of the v0.1 prefix, which 1.0 left where it was. */
|
|
449
|
+
export const INFO_V01_LEN = 27;
|
|
450
|
+
/** Length of the 1.0 layout. */
|
|
451
|
+
export const INFO_LEN_1_0 = 76;
|
|
452
|
+
/** 1.3: the 1.0 layout with a second capability word appended. */
|
|
453
|
+
export const INFO_LEN = 80;
|
|
454
|
+
/** What a device with no battery telemetry reports instead of a percent. */
|
|
455
|
+
export const BATTERY_UNKNOWN = 0xff;
|
|
456
|
+
/** Everything a device says about itself, and nothing a host assumed. */
|
|
457
|
+
export class DeviceInfo {
|
|
458
|
+
/** Protocol major and minor. */
|
|
459
|
+
protocol;
|
|
460
|
+
/** Firmware major, minor, and patch. */
|
|
461
|
+
firmware;
|
|
462
|
+
/** Channel count. */
|
|
463
|
+
channels;
|
|
464
|
+
/** Converter resolution, in bits. */
|
|
465
|
+
adcBits;
|
|
466
|
+
/** Device time ticks per second. */
|
|
467
|
+
tickHz;
|
|
468
|
+
/** Converter reference voltage, in microvolts. */
|
|
469
|
+
vrefUv;
|
|
470
|
+
/** The sixteen bit capability mask, bits 0 to 15. `can` is how to read it. */
|
|
471
|
+
capabilities;
|
|
472
|
+
/** Which rates the device supports, as a bit mask. `rates` is how to read it. */
|
|
473
|
+
supportedRates;
|
|
474
|
+
/** The device's stable per-unit identifier, as hex. */
|
|
475
|
+
deviceId;
|
|
476
|
+
/** Present on a device that reports the 1.0 layout. */
|
|
477
|
+
hardware;
|
|
478
|
+
/** The exact firmware build, or null when the device reports no extension. */
|
|
479
|
+
buildId;
|
|
480
|
+
/** The model's embedding width, zero for no model runtime. */
|
|
481
|
+
modelEmbedDim;
|
|
482
|
+
/** The model's native sample rate. */
|
|
483
|
+
modelNativeSps;
|
|
484
|
+
/** Samples per prediction window at the model's native rate. */
|
|
485
|
+
modelWindowSamples;
|
|
486
|
+
/** User head slots. */
|
|
487
|
+
headSlots;
|
|
488
|
+
/** The largest `out_dim` a head may have. */
|
|
489
|
+
headMaxOutputs;
|
|
490
|
+
/** Capacity of one head slot, in bytes. */
|
|
491
|
+
headSlotBytes;
|
|
492
|
+
/** The largest Update Data write the device accepts. */
|
|
493
|
+
updateChunkMax;
|
|
494
|
+
/** Capacity of one application slot, including its header. */
|
|
495
|
+
appSlotBytes;
|
|
496
|
+
/** Capacity of the weights image, including its header. */
|
|
497
|
+
weightsImageBytes;
|
|
498
|
+
/** The second capability word, bits 16 and up (1.3). Zero on a device whose info ends at 76 bytes. */
|
|
499
|
+
capabilitiesHigh;
|
|
500
|
+
/** Build from the fields `decodeDeviceInfo` read, or assembled by hand. */
|
|
501
|
+
constructor(f) {
|
|
502
|
+
this.protocol = [f.protocol[0], f.protocol[1]];
|
|
503
|
+
this.firmware = [f.firmware[0], f.firmware[1], f.firmware[2]];
|
|
504
|
+
this.channels = f.channels;
|
|
505
|
+
this.adcBits = f.adcBits;
|
|
506
|
+
this.tickHz = f.tickHz;
|
|
507
|
+
this.vrefUv = f.vrefUv;
|
|
508
|
+
this.capabilities = f.capabilities;
|
|
509
|
+
this.supportedRates = f.supportedRates;
|
|
510
|
+
this.deviceId = f.deviceId;
|
|
511
|
+
this.hardware = f.hardware ?? null;
|
|
512
|
+
this.buildId = f.buildId ?? null;
|
|
513
|
+
this.modelEmbedDim = f.modelEmbedDim ?? 0;
|
|
514
|
+
this.modelNativeSps = f.modelNativeSps ?? 0;
|
|
515
|
+
this.modelWindowSamples = f.modelWindowSamples ?? 0;
|
|
516
|
+
this.headSlots = f.headSlots ?? 0;
|
|
517
|
+
this.headMaxOutputs = f.headMaxOutputs ?? 0;
|
|
518
|
+
this.headSlotBytes = f.headSlotBytes ?? 0;
|
|
519
|
+
this.updateChunkMax = f.updateChunkMax ?? 0;
|
|
520
|
+
this.appSlotBytes = f.appSlotBytes ?? 0;
|
|
521
|
+
this.weightsImageBytes = f.weightsImageBytes ?? 0;
|
|
522
|
+
this.capabilitiesHigh = f.capabilitiesHigh ?? 0;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Whether the device claims a capability. Unknown names are false rather
|
|
526
|
+
* than an error, so a host written against a later version of this
|
|
527
|
+
* contract still runs.
|
|
528
|
+
*/
|
|
529
|
+
can(capability) {
|
|
530
|
+
const high = CAPABILITIES_HIGH[capability];
|
|
531
|
+
if (high !== undefined)
|
|
532
|
+
return (this.capabilitiesHigh & high) !== 0;
|
|
533
|
+
const bit = CAPABILITIES[capability] ?? 0;
|
|
534
|
+
return (this.capabilities & bit) !== 0;
|
|
535
|
+
}
|
|
536
|
+
/** Whether the device reported the 1.0 extension. */
|
|
537
|
+
get extensionPresent() {
|
|
538
|
+
return this.hardware !== null;
|
|
539
|
+
}
|
|
540
|
+
/** The rates the device declares, from its own mask. */
|
|
541
|
+
get rates() {
|
|
542
|
+
return Object.entries(RATE_BY_INFO_BIT)
|
|
543
|
+
.map(([bit, sps]) => [Number(bit), sps])
|
|
544
|
+
.filter(([bit]) => (this.supportedRates & (1 << bit)) !== 0)
|
|
545
|
+
.map(([, sps]) => sps)
|
|
546
|
+
.sort((a, b) => a - b);
|
|
547
|
+
}
|
|
548
|
+
/** `protocol` as `major.minor`. */
|
|
549
|
+
get protocolString() {
|
|
550
|
+
return `${this.protocol[0]}.${this.protocol[1]}`;
|
|
551
|
+
}
|
|
552
|
+
/** `firmware` as `major.minor.patch`. */
|
|
553
|
+
get firmwareString() {
|
|
554
|
+
return `${this.firmware[0]}.${this.firmware[1]}.${this.firmware[2]}`;
|
|
555
|
+
}
|
|
556
|
+
/** `hardware` as `major.minor.patch`, or null when the device reports no extension. */
|
|
557
|
+
get hardwareString() {
|
|
558
|
+
return this.hardware === null ? null : `${this.hardware[0]}.${this.hardware[1]}.${this.hardware[2]}`;
|
|
559
|
+
}
|
|
560
|
+
/** One count, in microvolts, at a gain. From the device's own reference and width. */
|
|
561
|
+
microvoltsPerCount(gain) {
|
|
562
|
+
return (2.0 * this.vrefUv) / (gain * 2 ** this.adcBits);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
/** Device Info, as the device sends it. */
|
|
566
|
+
export function decodeDeviceInfo(data) {
|
|
567
|
+
if (data.length < INFO_V01_LEN)
|
|
568
|
+
throw new Truncated(`device info is at least ${INFO_V01_LEN} bytes`);
|
|
569
|
+
const v = view(data);
|
|
570
|
+
const head = {
|
|
571
|
+
protocol: [data[0], data[1]],
|
|
572
|
+
firmware: [data[2], data[3], data[4]],
|
|
573
|
+
channels: data[5],
|
|
574
|
+
adcBits: data[6],
|
|
575
|
+
tickHz: v.getUint32(7, true),
|
|
576
|
+
vrefUv: v.getUint32(11, true),
|
|
577
|
+
capabilities: v.getUint16(15, true),
|
|
578
|
+
supportedRates: data[17],
|
|
579
|
+
deviceId: hex(data.subarray(19, 27)),
|
|
580
|
+
};
|
|
581
|
+
if (data.length === INFO_V01_LEN)
|
|
582
|
+
return new DeviceInfo(head);
|
|
583
|
+
const infoLen = data[27];
|
|
584
|
+
if (infoLen < INFO_LEN_1_0) {
|
|
585
|
+
throw new Invalid(`a device speaking ${head.protocol[0]}.${head.protocol[1]} states its info is ${infoLen} bytes, ` +
|
|
586
|
+
`which is shorter than the ${INFO_LEN_1_0} the 1.0 layout defines`);
|
|
587
|
+
}
|
|
588
|
+
if (data.length < INFO_LEN_1_0) {
|
|
589
|
+
throw new Truncated(`device info promises ${infoLen} bytes and carries ${data.length}`);
|
|
590
|
+
}
|
|
591
|
+
// 1.3: the second capability word, when the device says its info reaches it.
|
|
592
|
+
const capabilitiesHigh = infoLen >= INFO_LEN && data.length >= INFO_LEN ? v.getUint32(76, true) : 0;
|
|
593
|
+
return new DeviceInfo({
|
|
594
|
+
...head,
|
|
595
|
+
hardware: [data[28], data[29], data[30]],
|
|
596
|
+
buildId: hex(data.subarray(32, 40)),
|
|
597
|
+
modelEmbedDim: v.getUint16(40, true),
|
|
598
|
+
modelNativeSps: v.getUint16(42, true),
|
|
599
|
+
modelWindowSamples: v.getUint16(44, true),
|
|
600
|
+
headSlots: data[46],
|
|
601
|
+
headMaxOutputs: data[47],
|
|
602
|
+
headSlotBytes: v.getUint16(48, true),
|
|
603
|
+
updateChunkMax: v.getUint16(50, true),
|
|
604
|
+
appSlotBytes: v.getUint32(52, true),
|
|
605
|
+
weightsImageBytes: v.getUint32(56, true),
|
|
606
|
+
capabilitiesHigh,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Device info as a device reports it. A device that has no extension to
|
|
611
|
+
* report encodes the v0.1 prefix, which is what an older one sends.
|
|
612
|
+
*/
|
|
613
|
+
export function encodeDeviceInfo(info) {
|
|
614
|
+
// A device with nothing in its second word still writes the 1.3 length: the
|
|
615
|
+
// contract's current layout is 80 bytes and a 1.2 reader stops at 76.
|
|
616
|
+
const out = new Uint8Array(info.extensionPresent ? INFO_LEN : INFO_V01_LEN);
|
|
617
|
+
const v = view(out);
|
|
618
|
+
out[0] = info.protocol[0];
|
|
619
|
+
out[1] = info.protocol[1];
|
|
620
|
+
out[2] = info.firmware[0];
|
|
621
|
+
out[3] = info.firmware[1];
|
|
622
|
+
out[4] = info.firmware[2];
|
|
623
|
+
out[5] = info.channels;
|
|
624
|
+
out[6] = info.adcBits;
|
|
625
|
+
v.setUint32(7, info.tickHz, true);
|
|
626
|
+
v.setUint32(11, info.vrefUv, true);
|
|
627
|
+
v.setUint16(15, info.capabilities, true);
|
|
628
|
+
out[17] = info.supportedRates;
|
|
629
|
+
out.set(fromHex(info.deviceId), 19);
|
|
630
|
+
if (!info.extensionPresent)
|
|
631
|
+
return out;
|
|
632
|
+
out[27] = INFO_LEN;
|
|
633
|
+
out[28] = info.hardware[0];
|
|
634
|
+
out[29] = info.hardware[1];
|
|
635
|
+
out[30] = info.hardware[2];
|
|
636
|
+
out.set(fromHex(info.buildId ?? "0000000000000000"), 32);
|
|
637
|
+
v.setUint16(40, info.modelEmbedDim, true);
|
|
638
|
+
v.setUint16(42, info.modelNativeSps, true);
|
|
639
|
+
v.setUint16(44, info.modelWindowSamples, true);
|
|
640
|
+
out[46] = info.headSlots;
|
|
641
|
+
out[47] = info.headMaxOutputs;
|
|
642
|
+
v.setUint16(48, info.headSlotBytes, true);
|
|
643
|
+
v.setUint16(50, info.updateChunkMax, true);
|
|
644
|
+
v.setUint32(52, info.appSlotBytes, true);
|
|
645
|
+
v.setUint32(56, info.weightsImageBytes, true);
|
|
646
|
+
v.setUint32(76, info.capabilitiesHigh, true);
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
// --- status -----------------------------------------------------------------
|
|
650
|
+
/** The length of a Status message, in bytes. */
|
|
651
|
+
export const STATUS_LEN = 12;
|
|
652
|
+
/** The bits of Status's flags byte. */
|
|
653
|
+
export const STATUS_FLAGS = { usb_present: 1 << 0, buffer_high_watermark: 1 << 1 };
|
|
654
|
+
/** Status, as the device sends it. */
|
|
655
|
+
export function decodeStatus(data) {
|
|
656
|
+
if (data.length < STATUS_LEN)
|
|
657
|
+
throw new Truncated(`status is ${STATUS_LEN} bytes`);
|
|
658
|
+
const v = view(data);
|
|
659
|
+
const gainCode = data[2];
|
|
660
|
+
const rateCode = data[3];
|
|
661
|
+
const charger = data[4];
|
|
662
|
+
const battery = data[5];
|
|
663
|
+
const flags = data[7];
|
|
664
|
+
return {
|
|
665
|
+
state: data[0],
|
|
666
|
+
streaming: data[0] === 1,
|
|
667
|
+
mode: data[1],
|
|
668
|
+
modeName: MODE_BY_CODE[data[1]] ?? `mode ${data[1]}`,
|
|
669
|
+
gainCode,
|
|
670
|
+
gain: GAIN_BY_CODE[gainCode] ?? null,
|
|
671
|
+
rateCode,
|
|
672
|
+
sps: RATE_BY_CODE[rateCode] ?? null,
|
|
673
|
+
chargerState: charger,
|
|
674
|
+
chargerName: CHARGER_STATES[charger] ?? `charger state ${charger}`,
|
|
675
|
+
batteryPercent: battery === BATTERY_UNKNOWN ? null : battery,
|
|
676
|
+
batteryPercentRaw: battery,
|
|
677
|
+
loffStatp: data[6],
|
|
678
|
+
usbPresent: (flags & STATUS_FLAGS.usb_present) !== 0,
|
|
679
|
+
bufferHighWatermark: (flags & STATUS_FLAGS.buffer_high_watermark) !== 0,
|
|
680
|
+
droppedTotal: v.getUint16(8, true),
|
|
681
|
+
bufferFill: v.getUint16(10, true),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
/** Status, as the device sends it. */
|
|
685
|
+
export function encodeStatus(s) {
|
|
686
|
+
const out = new Uint8Array(STATUS_LEN);
|
|
687
|
+
const v = view(out);
|
|
688
|
+
out[0] = s.state;
|
|
689
|
+
out[1] = s.mode;
|
|
690
|
+
out[2] = s.gainCode;
|
|
691
|
+
out[3] = s.rateCode;
|
|
692
|
+
out[4] = s.chargerState;
|
|
693
|
+
out[5] = s.batteryPercent ?? BATTERY_UNKNOWN;
|
|
694
|
+
out[6] = s.loffStatp;
|
|
695
|
+
out[7] =
|
|
696
|
+
(s.usbPresent ? STATUS_FLAGS.usb_present : 0) |
|
|
697
|
+
(s.bufferHighWatermark ? STATUS_FLAGS.buffer_high_watermark : 0);
|
|
698
|
+
v.setUint16(8, s.droppedTotal, true);
|
|
699
|
+
v.setUint16(10, s.bufferFill, true);
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
// --- the stream -------------------------------------------------------------
|
|
703
|
+
/** The EEG data packet type. */
|
|
704
|
+
export const PACKET_EEG = 0x01;
|
|
705
|
+
/** The prediction packet type. */
|
|
706
|
+
export const PACKET_PREDICTION = 0x02;
|
|
707
|
+
/** 1.3: samples the device generated with its converter off, in exactly the layout of `PACKET_EEG`. */
|
|
708
|
+
export const PACKET_SYNTHETIC = 0x04;
|
|
709
|
+
/** The length of an EEG data packet's header, before the samples. */
|
|
710
|
+
export const DATA_HEADER_LEN = 20;
|
|
711
|
+
/** The single bit flags of an EEG data packet's flags byte. Mode occupies the other two, via `MODE_SHIFT` and `MODE_MASK`. */
|
|
712
|
+
export const DATA_FLAGS = { discontinuity: 1 << 0, leadoff_active: 1 << 1, usb_present: 1 << 4 };
|
|
713
|
+
/** Where the mode bits sit in an EEG data packet's flags byte. */
|
|
714
|
+
export const MODE_SHIFT = 2;
|
|
715
|
+
/** The mode bits of an EEG data packet's flags byte, before shifting. */
|
|
716
|
+
export const MODE_MASK = 0b11 << MODE_SHIFT;
|
|
717
|
+
/** `samples_lost_before` saturates here. The sample index is authoritative. */
|
|
718
|
+
export const LOST_SATURATED = 0xffff;
|
|
719
|
+
/** One EEG notification. `channels` comes from the device. */
|
|
720
|
+
export function decodePacket(data, channels) {
|
|
721
|
+
if (data.length < DATA_HEADER_LEN)
|
|
722
|
+
throw new Truncated("a data packet is at least its header");
|
|
723
|
+
const v = view(data);
|
|
724
|
+
const packetType = data[0];
|
|
725
|
+
if (packetType !== PACKET_EEG && packetType !== PACKET_SYNTHETIC) {
|
|
726
|
+
throw new Invalid(`packet type ${toByte(packetType)} is not sample data`);
|
|
727
|
+
}
|
|
728
|
+
const flags = data[1];
|
|
729
|
+
const n = data[16];
|
|
730
|
+
const gainCode = data[18];
|
|
731
|
+
const rateCode = data[19];
|
|
732
|
+
if (gainCode >= GAIN_BY_CODE.length) {
|
|
733
|
+
throw new Invalid(`gain code ${gainCode} is not one this contract defines`);
|
|
734
|
+
}
|
|
735
|
+
const body = data.subarray(DATA_HEADER_LEN);
|
|
736
|
+
const want = n * channels * 3;
|
|
737
|
+
if (body.length !== want) {
|
|
738
|
+
throw new Truncated(`${n} samples of ${channels} channels is ${want} bytes, and this packet carries ${body.length}`);
|
|
739
|
+
}
|
|
740
|
+
const counts = [];
|
|
741
|
+
for (let i = 0; i < n; i++) {
|
|
742
|
+
const row = [];
|
|
743
|
+
for (let c = 0; c < channels; c++)
|
|
744
|
+
row.push(int24(body, (i * channels + c) * 3));
|
|
745
|
+
counts.push(row);
|
|
746
|
+
}
|
|
747
|
+
const mode = (flags & MODE_MASK) >> MODE_SHIFT;
|
|
748
|
+
return {
|
|
749
|
+
packetType,
|
|
750
|
+
discontinuity: (flags & DATA_FLAGS.discontinuity) !== 0,
|
|
751
|
+
leadoffActive: (flags & DATA_FLAGS.leadoff_active) !== 0,
|
|
752
|
+
mode,
|
|
753
|
+
modeName: MODE_BY_CODE[mode] ?? `mode ${mode}`,
|
|
754
|
+
usbPresent: (flags & DATA_FLAGS.usb_present) !== 0,
|
|
755
|
+
samplesLostBefore: v.getUint16(2, true),
|
|
756
|
+
index: v.getUint32(4, true),
|
|
757
|
+
deviceTime: v.getBigUint64(8, true),
|
|
758
|
+
nSamples: n,
|
|
759
|
+
loffStatp: data[17],
|
|
760
|
+
gainCode,
|
|
761
|
+
gain: GAIN_BY_CODE[gainCode],
|
|
762
|
+
rateCode,
|
|
763
|
+
sps: RATE_BY_CODE[rateCode] ?? null,
|
|
764
|
+
counts,
|
|
765
|
+
synthetic: packetType === PACKET_SYNTHETIC,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* One EEG notification, as the device sends it. The payload is converter
|
|
770
|
+
* order, big endian, which is why the device copies its bytes out without
|
|
771
|
+
* swapping any of them.
|
|
772
|
+
*/
|
|
773
|
+
export function encodePacket(p) {
|
|
774
|
+
const n = p.counts.length;
|
|
775
|
+
const channels = n === 0 ? 0 : p.counts[0].length;
|
|
776
|
+
for (const row of p.counts) {
|
|
777
|
+
if (row.length !== channels)
|
|
778
|
+
throw new Invalid("every sample covers the same channels");
|
|
779
|
+
}
|
|
780
|
+
if (n > 0xff)
|
|
781
|
+
throw new Invalid("a packet carries at most 255 samples");
|
|
782
|
+
const out = new Uint8Array(DATA_HEADER_LEN + n * channels * 3);
|
|
783
|
+
const v = view(out);
|
|
784
|
+
out[0] = p.synthetic ? PACKET_SYNTHETIC : PACKET_EEG;
|
|
785
|
+
out[1] =
|
|
786
|
+
(p.discontinuity ? DATA_FLAGS.discontinuity : 0) |
|
|
787
|
+
(p.leadoffActive ? DATA_FLAGS.leadoff_active : 0) |
|
|
788
|
+
(((p.mode ?? 0) << MODE_SHIFT) & MODE_MASK) |
|
|
789
|
+
(p.usbPresent ? DATA_FLAGS.usb_present : 0);
|
|
790
|
+
v.setUint16(2, p.samplesLostBefore ?? 0, true);
|
|
791
|
+
v.setUint32(4, p.index >>> 0, true);
|
|
792
|
+
v.setBigUint64(8, p.deviceTime, true);
|
|
793
|
+
out[16] = n;
|
|
794
|
+
out[17] = p.loffStatp ?? 0;
|
|
795
|
+
out[18] = p.gainCode;
|
|
796
|
+
out[19] = p.rateCode;
|
|
797
|
+
let at = DATA_HEADER_LEN;
|
|
798
|
+
for (const row of p.counts) {
|
|
799
|
+
for (let c = 0; c < channels; c++) {
|
|
800
|
+
const value = row[c] | 0;
|
|
801
|
+
out[at++] = (value >> 16) & 0xff;
|
|
802
|
+
out[at++] = (value >> 8) & 0xff;
|
|
803
|
+
out[at++] = value & 0xff;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
/** One big endian, two's complement int24, sign extended. */
|
|
809
|
+
export function int24(b, at = 0) {
|
|
810
|
+
return ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8)) >> 8;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* One count in microvolts. Every term comes from the device: its reference,
|
|
814
|
+
* the gain the packet was taken at, and the converter's width.
|
|
815
|
+
*/
|
|
816
|
+
export function microvolts(raw, vrefUv, gain, adcBits) {
|
|
817
|
+
return (raw * (2.0 * vrefUv)) / (gain * 2 ** adcBits);
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Judge two packets by the contract's own rule.
|
|
821
|
+
*
|
|
822
|
+
* The subtraction is signed, so the index's own wrap at two to the thirty
|
|
823
|
+
* second is exact. A step that is not forward is a break in the timeline
|
|
824
|
+
* rather than a loss, and reporting the unsigned difference there claims a
|
|
825
|
+
* loss of four billion samples that never happened.
|
|
826
|
+
*/
|
|
827
|
+
export function continuity(previousIndex, previousN, index, discontinuity) {
|
|
828
|
+
const expected = (previousIndex + previousN) >>> 0;
|
|
829
|
+
let step = (index - expected) >>> 0;
|
|
830
|
+
if (step >= 0x80000000)
|
|
831
|
+
step -= 0x100000000;
|
|
832
|
+
if (step === 0 && !discontinuity)
|
|
833
|
+
return { verdict: "continuous", lost: 0, broken: false };
|
|
834
|
+
if (step > 0)
|
|
835
|
+
return { verdict: "gap", lost: step, broken: true };
|
|
836
|
+
return { verdict: "break", lost: null, broken: true };
|
|
837
|
+
}
|
|
838
|
+
// --- predictions ------------------------------------------------------------
|
|
839
|
+
/** The length of a prediction packet's header, before the outputs. */
|
|
840
|
+
export const PREDICTION_HEADER_LEN = 28;
|
|
841
|
+
/** The bits of a prediction packet's flags byte. */
|
|
842
|
+
export const PREDICTION_FLAGS = {
|
|
843
|
+
gap_in_window: 1 << 0,
|
|
844
|
+
duty_reduced: 1 << 1,
|
|
845
|
+
leadoff_in_window: 1 << 2,
|
|
846
|
+
};
|
|
847
|
+
/** One prediction notification, as the device sends it. */
|
|
848
|
+
export function decodePrediction(data) {
|
|
849
|
+
if (data.length < PREDICTION_HEADER_LEN)
|
|
850
|
+
throw new Truncated("a prediction is at least its header");
|
|
851
|
+
const v = view(data);
|
|
852
|
+
const packetType = data[0];
|
|
853
|
+
if (packetType !== PACKET_PREDICTION)
|
|
854
|
+
throw new Invalid(`packet type ${toByte(packetType)} is not a prediction`);
|
|
855
|
+
const flags = data[1];
|
|
856
|
+
const n = data[3];
|
|
857
|
+
const body = data.subarray(PREDICTION_HEADER_LEN);
|
|
858
|
+
if (body.length !== n * 4) {
|
|
859
|
+
throw new Truncated(`${n} outputs is ${n * 4} bytes, and this packet carries ${body.length}`);
|
|
860
|
+
}
|
|
861
|
+
const outputs = [];
|
|
862
|
+
for (let i = 0; i < n; i++)
|
|
863
|
+
outputs.push(v.getFloat32(PREDICTION_HEADER_LEN + i * 4, true));
|
|
864
|
+
return {
|
|
865
|
+
packetType,
|
|
866
|
+
headSlot: data[2],
|
|
867
|
+
headId: hex(data.subarray(18, 26)),
|
|
868
|
+
index: v.getUint32(4, true),
|
|
869
|
+
deviceTime: v.getBigUint64(8, true),
|
|
870
|
+
windowSamples: v.getUint16(16, true),
|
|
871
|
+
gapInWindow: (flags & PREDICTION_FLAGS.gap_in_window) !== 0,
|
|
872
|
+
dutyReduced: (flags & PREDICTION_FLAGS.duty_reduced) !== 0,
|
|
873
|
+
leadoffInWindow: (flags & PREDICTION_FLAGS.leadoff_in_window) !== 0,
|
|
874
|
+
outputs,
|
|
875
|
+
inputSource: data[26],
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
/** One prediction notification, as the device sends it. */
|
|
879
|
+
export function encodePrediction(p) {
|
|
880
|
+
const n = p.outputs.length;
|
|
881
|
+
if (n > 0xff)
|
|
882
|
+
throw new Invalid("a head has at most 255 outputs");
|
|
883
|
+
const out = new Uint8Array(PREDICTION_HEADER_LEN + n * 4);
|
|
884
|
+
const v = view(out);
|
|
885
|
+
out[0] = PACKET_PREDICTION;
|
|
886
|
+
out[1] =
|
|
887
|
+
(p.gapInWindow ? PREDICTION_FLAGS.gap_in_window : 0) |
|
|
888
|
+
(p.dutyReduced ? PREDICTION_FLAGS.duty_reduced : 0) |
|
|
889
|
+
(p.leadoffInWindow ? PREDICTION_FLAGS.leadoff_in_window : 0);
|
|
890
|
+
out[2] = p.headSlot;
|
|
891
|
+
out[3] = n;
|
|
892
|
+
v.setUint32(4, p.index >>> 0, true);
|
|
893
|
+
v.setBigUint64(8, p.deviceTime, true);
|
|
894
|
+
v.setUint16(16, p.windowSamples, true);
|
|
895
|
+
out.set(fromHex(p.headId), 18);
|
|
896
|
+
out[26] = p.inputSource ?? 0;
|
|
897
|
+
for (let i = 0; i < n; i++)
|
|
898
|
+
v.setFloat32(PREDICTION_HEADER_LEN + i * 4, p.outputs[i], true);
|
|
899
|
+
return out;
|
|
900
|
+
}
|
|
901
|
+
/** GET_BATTERY's answer, as the device sends it. */
|
|
902
|
+
export function decodeBattery(payload) {
|
|
903
|
+
if (payload.length < 4)
|
|
904
|
+
throw new Truncated("a battery reading is four bytes");
|
|
905
|
+
const v = view(payload);
|
|
906
|
+
const charger = payload[3];
|
|
907
|
+
return {
|
|
908
|
+
millivolts: v.getUint16(0, true),
|
|
909
|
+
percent: payload[2] === BATTERY_UNKNOWN ? null : payload[2],
|
|
910
|
+
chargerState: charger,
|
|
911
|
+
chargerName: CHARGER_STATES[charger] ?? `charger state ${charger}`,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
/** GET_BATTERY's answer, as the device sends it. */
|
|
915
|
+
export function encodeBattery(b) {
|
|
916
|
+
const out = new Uint8Array(4);
|
|
917
|
+
view(out).setUint16(0, b.millivolts, true);
|
|
918
|
+
out[2] = b.percent ?? BATTERY_UNKNOWN;
|
|
919
|
+
out[3] = b.chargerState;
|
|
920
|
+
return out;
|
|
921
|
+
}
|
|
922
|
+
/** GET_BOOT_INFO's answer, as the device sends it. */
|
|
923
|
+
export function decodeBootInfo(payload) {
|
|
924
|
+
if (payload.length < 8)
|
|
925
|
+
throw new Truncated("boot information is eight bytes");
|
|
926
|
+
const reason = payload[1];
|
|
927
|
+
return {
|
|
928
|
+
activeSlot: payload[0],
|
|
929
|
+
bootReason: reason,
|
|
930
|
+
bootReasonName: BOOT_REASONS[reason] ?? `reason ${reason}`,
|
|
931
|
+
slotState: payload[2],
|
|
932
|
+
confirmed: payload[2] === 1,
|
|
933
|
+
bootCount: view(payload).getUint32(4, true),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
/** GET_BOOT_INFO's answer, as the device sends it. */
|
|
937
|
+
export function encodeBootInfo(b) {
|
|
938
|
+
const out = new Uint8Array(8);
|
|
939
|
+
out[0] = b.activeSlot;
|
|
940
|
+
out[1] = b.bootReason;
|
|
941
|
+
out[2] = b.slotState;
|
|
942
|
+
view(out).setUint32(4, b.bootCount, true);
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
/** A head slot's state, in words, by its `state` code. */
|
|
946
|
+
export const HEAD_STATES = { 0: "empty", 1: "valid", 2: "invalid", 3: "width mismatch" };
|
|
947
|
+
/** The model runtime's state, in words, by its `model_state` code. */
|
|
948
|
+
export const MODEL_STATES = { 0: "no runtime", 1: "no weights", 2: "ready", 3: "updating" };
|
|
949
|
+
/** An encoder id a head that does not name its encoder carries. */
|
|
950
|
+
export const NO_ENCODER_ID = "0000000000000000";
|
|
951
|
+
/** The slot byte meaning no head is selected. */
|
|
952
|
+
export const NO_HEAD = 0xff;
|
|
953
|
+
/** The length of one LIST_HEADS record, in bytes. */
|
|
954
|
+
export const HEAD_ENTRY_LEN = 30;
|
|
955
|
+
/** The head slots, and which one is selected. */
|
|
956
|
+
export function decodeHeads(payload) {
|
|
957
|
+
if (payload.length < 2)
|
|
958
|
+
throw new Truncated("a head list is at least its count");
|
|
959
|
+
const active = payload[0];
|
|
960
|
+
const n = payload[1];
|
|
961
|
+
const recordsEnd = 2 + n * HEAD_ENTRY_LEN;
|
|
962
|
+
// 1.3 defined one eight byte encoder id per record after the records. No
|
|
963
|
+
// device sent it, because the IntoMind One's list was too long with it,
|
|
964
|
+
// and 1.4 withdraws it. One that comes is still read.
|
|
965
|
+
const withIds = payload.length === recordsEnd + n * 8;
|
|
966
|
+
if (payload.length !== recordsEnd && !withIds) {
|
|
967
|
+
throw new Invalid(`a list of ${n} heads is ${recordsEnd} bytes, or ${recordsEnd + n * 8} with its encoder ids, and this one is ${payload.length}`);
|
|
968
|
+
}
|
|
969
|
+
const heads = [];
|
|
970
|
+
for (let i = 0; i < n; i++) {
|
|
971
|
+
const r = payload.subarray(2 + i * HEAD_ENTRY_LEN, 2 + (i + 1) * HEAD_ENTRY_LEN);
|
|
972
|
+
const state = r[1];
|
|
973
|
+
heads.push({
|
|
974
|
+
slot: r[0],
|
|
975
|
+
state,
|
|
976
|
+
stateName: HEAD_STATES[state] ?? `state ${state}`,
|
|
977
|
+
outDim: view(r).getUint16(2, true),
|
|
978
|
+
headId: hex(r.subarray(4, 12)),
|
|
979
|
+
name: nameOf(r.subarray(12, 28)),
|
|
980
|
+
usable: state === 1,
|
|
981
|
+
encoderId: withIds ? hex(payload.subarray(recordsEnd + i * 8, recordsEnd + i * 8 + 8)) : NO_ENCODER_ID,
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
return { activeSlot: active === NO_HEAD ? null : active, heads };
|
|
985
|
+
}
|
|
986
|
+
/** A LIST_HEADS payload as a device sends it: the records and nothing after them. */
|
|
987
|
+
export function encodeHeads(activeSlot, heads) {
|
|
988
|
+
const out = new Uint8Array(2 + heads.length * HEAD_ENTRY_LEN);
|
|
989
|
+
out[0] = activeSlot ?? NO_HEAD;
|
|
990
|
+
out[1] = heads.length;
|
|
991
|
+
heads.forEach((h, i) => {
|
|
992
|
+
const at = 2 + i * HEAD_ENTRY_LEN;
|
|
993
|
+
out[at] = h.slot;
|
|
994
|
+
out[at + 1] = h.state;
|
|
995
|
+
view(out).setUint16(at + 2, h.outDim, true);
|
|
996
|
+
out.set(fromHex(h.headId), at + 4);
|
|
997
|
+
out.set(paddedName(h.name, 16), at + 12);
|
|
998
|
+
});
|
|
999
|
+
return out;
|
|
1000
|
+
}
|
|
1001
|
+
/** One LIST_HEAD_ENCODERS record (1.4): a slot and the encoder id its head names. */
|
|
1002
|
+
export const HEAD_ENCODER_LEN = 9;
|
|
1003
|
+
/**
|
|
1004
|
+
* 1.4: the encoder each head names, by slot, from LIST_HEAD_ENCODERS: `NO_ENCODER_ID` for an empty slot or a head
|
|
1005
|
+
* that does not say.
|
|
1006
|
+
*/
|
|
1007
|
+
export function decodeHeadEncoders(payload) {
|
|
1008
|
+
if (payload.length < 1)
|
|
1009
|
+
throw new Truncated("a list of head encoders is at least its count");
|
|
1010
|
+
const n = payload[0];
|
|
1011
|
+
if (payload.length !== 1 + n * HEAD_ENCODER_LEN) {
|
|
1012
|
+
throw new Invalid(`a list of ${n} head encoders is ${1 + n * HEAD_ENCODER_LEN} bytes, and this one is ${payload.length}`);
|
|
1013
|
+
}
|
|
1014
|
+
const out = new Map();
|
|
1015
|
+
for (let i = 0; i < n; i++) {
|
|
1016
|
+
const at = 1 + i * HEAD_ENCODER_LEN;
|
|
1017
|
+
out.set(payload[at], hex(payload.subarray(at + 1, at + HEAD_ENCODER_LEN)));
|
|
1018
|
+
}
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
/** A LIST_HEAD_ENCODERS payload: one record for each head, in the order given. */
|
|
1022
|
+
export function encodeHeadEncoders(heads) {
|
|
1023
|
+
const out = new Uint8Array(1 + heads.length * HEAD_ENCODER_LEN);
|
|
1024
|
+
out[0] = heads.length;
|
|
1025
|
+
heads.forEach((h, i) => {
|
|
1026
|
+
const at = 1 + i * HEAD_ENCODER_LEN;
|
|
1027
|
+
out[at] = h.slot;
|
|
1028
|
+
out.set(fromHex(h.encoderId), at + 1);
|
|
1029
|
+
});
|
|
1030
|
+
return out;
|
|
1031
|
+
}
|
|
1032
|
+
/** The heads, each with the encoder `encoders` says it names. A slot it does not mention keeps what it had. */
|
|
1033
|
+
export function withEncoders(list, encoders) {
|
|
1034
|
+
return {
|
|
1035
|
+
activeSlot: list.activeSlot,
|
|
1036
|
+
heads: list.heads.map((h) => ({ ...h, encoderId: encoders.get(h.slot) ?? h.encoderId })),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
/** GET_MODEL_INFO's answer, as the device sends it. */
|
|
1040
|
+
export function decodeModelInfo(payload) {
|
|
1041
|
+
if (payload.length < 16)
|
|
1042
|
+
throw new Truncated("model information is at least sixteen bytes");
|
|
1043
|
+
const state = payload[0];
|
|
1044
|
+
const active = payload[1];
|
|
1045
|
+
return {
|
|
1046
|
+
modelState: state,
|
|
1047
|
+
modelStateName: MODEL_STATES[state] ?? `state ${state}`,
|
|
1048
|
+
ready: state === 2,
|
|
1049
|
+
activeHead: active === NO_HEAD ? null : active,
|
|
1050
|
+
predictionsOn: payload[2] !== 0,
|
|
1051
|
+
encoderId: hex(payload.subarray(4, 12)),
|
|
1052
|
+
weightsVersion: [payload[12], payload[13], payload[14]],
|
|
1053
|
+
inputClasses: payload[15] || INPUT_CLASSES.time_domain,
|
|
1054
|
+
tokensPerChannel: payload.length >= 17 ? payload[16] : 0,
|
|
1055
|
+
passMs: payload.length >= 22 ? view(payload).getUint16(17, true) : 0,
|
|
1056
|
+
intervalS: payload.length >= 22 ? view(payload).getUint16(19, true) : 0,
|
|
1057
|
+
generator: payload.length >= 22 && payload[21] !== 0,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/** GET_MODEL_INFO's answer, as the device sends it. */
|
|
1061
|
+
export function encodeModelInfo(m) {
|
|
1062
|
+
const out = new Uint8Array(m.passMs === undefined ? (m.tokensPerChannel === undefined ? 16 : 17) : 22);
|
|
1063
|
+
out[0] = m.modelState;
|
|
1064
|
+
out[1] = m.activeHead ?? NO_HEAD;
|
|
1065
|
+
out[2] = m.predictionsOn ? 1 : 0;
|
|
1066
|
+
out.set(fromHex(m.encoderId), 4);
|
|
1067
|
+
out[12] = m.weightsVersion[0];
|
|
1068
|
+
out[13] = m.weightsVersion[1];
|
|
1069
|
+
out[14] = m.weightsVersion[2];
|
|
1070
|
+
out[15] = m.inputClasses ?? 0;
|
|
1071
|
+
if (m.tokensPerChannel !== undefined)
|
|
1072
|
+
out[16] = m.tokensPerChannel;
|
|
1073
|
+
if (m.passMs !== undefined) {
|
|
1074
|
+
const v = view(out);
|
|
1075
|
+
v.setUint16(17, m.passMs, true);
|
|
1076
|
+
v.setUint16(19, m.intervalS ?? 0, true);
|
|
1077
|
+
out[21] = m.generator ? 1 : 0;
|
|
1078
|
+
}
|
|
1079
|
+
return out;
|
|
1080
|
+
}
|
|
1081
|
+
/** GET_MODEL_INTERVAL's answer, as the device sends it. */
|
|
1082
|
+
export function decodeModelInterval(payload) {
|
|
1083
|
+
if (payload.length < 4)
|
|
1084
|
+
throw new Truncated("a model interval is four bytes");
|
|
1085
|
+
const v = view(payload);
|
|
1086
|
+
return { intervalS: v.getUint16(0, true), minimumS: v.getUint16(2, true) };
|
|
1087
|
+
}
|
|
1088
|
+
/** SET_MODEL_INTERVAL's payload: the seconds, little endian. Zero is every window the device can. */
|
|
1089
|
+
export function encodeModelInterval(seconds) {
|
|
1090
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 0xffff)
|
|
1091
|
+
throw new Invalid("an interval is 0 to 65535 seconds");
|
|
1092
|
+
const out = new Uint8Array(2);
|
|
1093
|
+
view(out).setUint16(0, seconds, true);
|
|
1094
|
+
return out;
|
|
1095
|
+
}
|
|
1096
|
+
/** The most a composed name may be, in bytes: what a scan response carries. The same limit bounds each part. */
|
|
1097
|
+
export const NAME_MAX_COMPOSED = 29;
|
|
1098
|
+
const utf8 = new TextEncoder();
|
|
1099
|
+
const utf8Decoder = new TextDecoder("utf-8");
|
|
1100
|
+
/** A name or an adjective: UTF-8 of at most 29 bytes, no control characters, no leading or trailing space. Empty is allowed. */
|
|
1101
|
+
export function namePartOk(part) {
|
|
1102
|
+
const b = utf8.encode(part);
|
|
1103
|
+
if (b.length > NAME_MAX_COMPOSED)
|
|
1104
|
+
return false;
|
|
1105
|
+
for (const c of b)
|
|
1106
|
+
if (c < 0x20 || c === 0x7f)
|
|
1107
|
+
return false;
|
|
1108
|
+
return part === part.replace(/^ +| +$/g, "");
|
|
1109
|
+
}
|
|
1110
|
+
/** The device's name as it composes it: `name's adjective product`, the possessive only with a name, the adjective only when given. */
|
|
1111
|
+
export function composeName(name, adjective, product = PRODUCT_NAME) {
|
|
1112
|
+
const parts = [];
|
|
1113
|
+
if (name)
|
|
1114
|
+
parts.push(`${name}'s`);
|
|
1115
|
+
if (adjective)
|
|
1116
|
+
parts.push(adjective);
|
|
1117
|
+
parts.push(product);
|
|
1118
|
+
return parts.join(" ");
|
|
1119
|
+
}
|
|
1120
|
+
/** Whether the composition fits the air, so the device will accept it. */
|
|
1121
|
+
export function nameFits(name, adjective, product = PRODUCT_NAME) {
|
|
1122
|
+
return namePartOk(name) && namePartOk(adjective) && utf8.encode(composeName(name, adjective, product)).length <= NAME_MAX_COMPOSED;
|
|
1123
|
+
}
|
|
1124
|
+
/** SET_NAME's payload and GET_NAME's answer: each part as a length and its UTF-8 bytes. */
|
|
1125
|
+
export function encodeNameParts(name, adjective) {
|
|
1126
|
+
const n = utf8.encode(name);
|
|
1127
|
+
const a = utf8.encode(adjective);
|
|
1128
|
+
if (n.length > NAME_MAX_COMPOSED || a.length > NAME_MAX_COMPOSED) {
|
|
1129
|
+
throw new Invalid(`a name and an adjective are each at most ${NAME_MAX_COMPOSED} bytes`);
|
|
1130
|
+
}
|
|
1131
|
+
const out = new Uint8Array(2 + n.length + a.length);
|
|
1132
|
+
out[0] = n.length;
|
|
1133
|
+
out.set(n, 1);
|
|
1134
|
+
out[1 + n.length] = a.length;
|
|
1135
|
+
out.set(a, 2 + n.length);
|
|
1136
|
+
return out;
|
|
1137
|
+
}
|
|
1138
|
+
/** GET_NAME's answer or SET_NAME's request, as the device reads or sends it: the name and the adjective. */
|
|
1139
|
+
export function decodeNameParts(payload) {
|
|
1140
|
+
if (payload.length === 0)
|
|
1141
|
+
throw new Truncated("a name carries at least its lengths");
|
|
1142
|
+
const nl = payload[0];
|
|
1143
|
+
if (payload.length < nl + 2)
|
|
1144
|
+
throw new Invalid("a name that ends inside its own parts");
|
|
1145
|
+
const al = payload[1 + nl];
|
|
1146
|
+
const adjective = payload.subarray(2 + nl);
|
|
1147
|
+
if (adjective.length !== al)
|
|
1148
|
+
throw new Invalid("a name whose adjective is not the length it declares");
|
|
1149
|
+
return { name: utf8Decoder.decode(payload.subarray(1, 1 + nl)), adjective: utf8Decoder.decode(adjective) };
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Labels for a list a person picks devices from, in the list's order: each
|
|
1153
|
+
* device's own name, and when two in the list share one, a number after the
|
|
1154
|
+
* later ones (`Ada's Blue IntoMind One 2`). The number is the host's and
|
|
1155
|
+
* lasts as long as the list. The device knows nothing of it, since two
|
|
1156
|
+
* devices cannot agree on which is second without a host that sees them
|
|
1157
|
+
* both. A device heard before its name arrived is labeled with the product
|
|
1158
|
+
* name.
|
|
1159
|
+
*/
|
|
1160
|
+
export function displayNames(names) {
|
|
1161
|
+
const seen = new Map();
|
|
1162
|
+
return names.map((n) => {
|
|
1163
|
+
const name = n || PRODUCT_NAME;
|
|
1164
|
+
const count = (seen.get(name) ?? 0) + 1;
|
|
1165
|
+
seen.set(name, count);
|
|
1166
|
+
return count === 1 ? name : `${name} ${count}`;
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
// --- processing (1.1) --------------------------------------------------------
|
|
1170
|
+
//
|
|
1171
|
+
// The device preprocesses its own signal. A chain is an ordered list of
|
|
1172
|
+
// stages, each a kind from the device's catalog with up to four sixteen-bit
|
|
1173
|
+
// parameters whose units the kind defines. The empty chain is the natural
|
|
1174
|
+
// signal. When a chain is in force only its output streams, and the chain
|
|
1175
|
+
// is what a recording stores to say what produced its samples: there is no
|
|
1176
|
+
// raw-or-filtered flag anywhere, because a flag says nothing about what a
|
|
1177
|
+
// signal is.
|
|
1178
|
+
/** The processing stage kinds this version defines, by name. */
|
|
1179
|
+
export const STAGE_KINDS = { highpass: 1, lowpass: 2, notch: 3 };
|
|
1180
|
+
/** Stage kind names by their number, the reverse of `STAGE_KINDS`. */
|
|
1181
|
+
export const KIND_NAMES = { 1: "highpass", 2: "lowpass", 3: "notch" };
|
|
1182
|
+
/** The classes a stage kind belongs to: map, representation, or detector. */
|
|
1183
|
+
export const STAGE_CLASSES = { map: 0, representation: 1, detector: 2 };
|
|
1184
|
+
/** Stage class names by their number, the reverse of `STAGE_CLASSES`. */
|
|
1185
|
+
export const CLASS_NAMES = { 0: "map", 1: "representation", 2: "detector" };
|
|
1186
|
+
/** 1.3 adds synthetic: reported by a device generating its signal, never requested. */
|
|
1187
|
+
export const INPUT_SOURCES = { stream: 0, natural: 1, own_chain: 2, synthetic: 3 };
|
|
1188
|
+
/** Input source names by their number, the reverse of `INPUT_SOURCES`. */
|
|
1189
|
+
export const INPUT_SOURCE_NAMES = { 0: "stream", 1: "natural", 2: "own_chain", 3: "synthetic" };
|
|
1190
|
+
/** The signal classes a model declares it takes, as bits of `inputClasses`. */
|
|
1191
|
+
export const INPUT_CLASSES = { time_domain: 1 << 0, representation: 1 << 1 };
|
|
1192
|
+
/** Whether the chain in force is the device's default or one a host set. */
|
|
1193
|
+
export const PIPELINE_ORIGINS = { default: 0, host: 1 };
|
|
1194
|
+
/** Origin names by their number, the reverse of `PIPELINE_ORIGINS`. */
|
|
1195
|
+
export const ORIGIN_NAMES = { 0: "default", 1: "host" };
|
|
1196
|
+
/** The bias drive's modes: off, on, or loop open. */
|
|
1197
|
+
export const BIAS_MODES = { off: 0, on: 1, loop_open: 2 };
|
|
1198
|
+
/** The most stages one chain may hold. */
|
|
1199
|
+
export const MAX_STAGES = 12;
|
|
1200
|
+
/** The most parameters one stage may hold. */
|
|
1201
|
+
export const MAX_PARAMS = 4;
|
|
1202
|
+
/** The most notch stages one chain may hold. */
|
|
1203
|
+
export const MAX_NOTCHES = 8;
|
|
1204
|
+
/** The device's default high-pass corner, in tenths of a hertz. */
|
|
1205
|
+
export const DEFAULT_HIGH_PASS_DHZ = 5;
|
|
1206
|
+
/** Both mains fundamentals, second and third harmonics, four hertz wide: the device's default until a region is chosen. */
|
|
1207
|
+
export const DEFAULT_MAINS_BANDS_DHZ = [
|
|
1208
|
+
[480, 520],
|
|
1209
|
+
[980, 1020],
|
|
1210
|
+
[1480, 1520],
|
|
1211
|
+
[580, 620],
|
|
1212
|
+
[1180, 1220],
|
|
1213
|
+
[1780, 1820],
|
|
1214
|
+
];
|
|
1215
|
+
/** `kind` in words, from `KIND_NAMES`. */
|
|
1216
|
+
export function stageName(s) {
|
|
1217
|
+
return KIND_NAMES[s.kind] ?? `kind ${s.kind}`;
|
|
1218
|
+
}
|
|
1219
|
+
function dhz(hz) {
|
|
1220
|
+
const v = Math.round(hz * 10);
|
|
1221
|
+
if (!(v >= 0 && v <= 0xffff))
|
|
1222
|
+
throw new Invalid(`${hz} Hz is outside what a stage parameter can hold`);
|
|
1223
|
+
return v;
|
|
1224
|
+
}
|
|
1225
|
+
/** A high-pass at a corner, at least 0.1 Hz. */
|
|
1226
|
+
export function highpass(cornerHz) {
|
|
1227
|
+
return { kind: STAGE_KINDS.highpass, params: [dhz(cornerHz)] };
|
|
1228
|
+
}
|
|
1229
|
+
/** A low-pass at a corner, or the automatic corner at four tenths of the sample rate when none is given. */
|
|
1230
|
+
export function lowpass(cornerHz) {
|
|
1231
|
+
return { kind: STAGE_KINDS.lowpass, params: [cornerHz === undefined ? 0 : dhz(cornerHz)] };
|
|
1232
|
+
}
|
|
1233
|
+
/** A notch over the band between two edges. Any band. */
|
|
1234
|
+
export function notch(lowHz, highHz) {
|
|
1235
|
+
return { kind: STAGE_KINDS.notch, params: [dhz(lowHz), dhz(highHz)] };
|
|
1236
|
+
}
|
|
1237
|
+
/** `u8 n_stages`, then per stage `u8 kind, u8 n_params, u16[n_params]`. */
|
|
1238
|
+
export function encodeChain(stages) {
|
|
1239
|
+
if (stages.length > MAX_STAGES)
|
|
1240
|
+
throw new Invalid(`a chain holds at most ${MAX_STAGES} stages`);
|
|
1241
|
+
const out = [stages.length];
|
|
1242
|
+
for (const s of stages) {
|
|
1243
|
+
if (!(s.kind >= 1 && s.kind <= 255))
|
|
1244
|
+
throw new Invalid("a stage kind is one byte and never zero");
|
|
1245
|
+
if (s.params.length > MAX_PARAMS)
|
|
1246
|
+
throw new Invalid(`a stage takes at most ${MAX_PARAMS} parameters`);
|
|
1247
|
+
out.push(s.kind, s.params.length);
|
|
1248
|
+
for (const p of s.params)
|
|
1249
|
+
out.push(p & 0xff, (p >>> 8) & 0xff);
|
|
1250
|
+
}
|
|
1251
|
+
return new Uint8Array(out);
|
|
1252
|
+
}
|
|
1253
|
+
function decodeChainPrefix(data) {
|
|
1254
|
+
if (data.length === 0)
|
|
1255
|
+
throw new Truncated("a chain is at least its stage count");
|
|
1256
|
+
const n = data[0];
|
|
1257
|
+
if (n > MAX_STAGES)
|
|
1258
|
+
throw new Invalid(`${n} stages is more than a chain holds`);
|
|
1259
|
+
let at = 1;
|
|
1260
|
+
const stages = [];
|
|
1261
|
+
for (let i = 0; i < n; i++) {
|
|
1262
|
+
if (data.length < at + 2)
|
|
1263
|
+
throw new Truncated("a chain ends inside a stage");
|
|
1264
|
+
const kind = data[at];
|
|
1265
|
+
const nParams = data[at + 1];
|
|
1266
|
+
if (kind === 0)
|
|
1267
|
+
throw new Invalid("stage kind zero is not a kind");
|
|
1268
|
+
if (nParams > MAX_PARAMS)
|
|
1269
|
+
throw new Invalid(`a stage takes at most ${MAX_PARAMS} parameters`);
|
|
1270
|
+
at += 2;
|
|
1271
|
+
if (data.length < at + 2 * nParams)
|
|
1272
|
+
throw new Truncated("a stage promises parameters it does not carry");
|
|
1273
|
+
const params = [];
|
|
1274
|
+
for (let k = 0; k < nParams; k++)
|
|
1275
|
+
params.push(data[at + 2 * k] | (data[at + 2 * k + 1] << 8));
|
|
1276
|
+
at += 2 * nParams;
|
|
1277
|
+
stages.push({ kind, params });
|
|
1278
|
+
}
|
|
1279
|
+
return [stages, data.subarray(at)];
|
|
1280
|
+
}
|
|
1281
|
+
/** A chain descriptor that is the whole of `data`. */
|
|
1282
|
+
export function decodeChain(data) {
|
|
1283
|
+
const [stages, rest] = decodeChainPrefix(data);
|
|
1284
|
+
if (rest.length !== 0)
|
|
1285
|
+
throw new Invalid("bytes after the last stage");
|
|
1286
|
+
return stages;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* The kinds this version of the contract defines, with their limits. A
|
|
1290
|
+
* device's own catalog is the authority; this is what a host checks against
|
|
1291
|
+
* when it has not read one.
|
|
1292
|
+
*/
|
|
1293
|
+
export const CONTRACT_CATALOG = [
|
|
1294
|
+
{ kind: 1, cls: 0, className: "map", nParams: 1, maxInstances: 1, name: "highpass" },
|
|
1295
|
+
{ kind: 2, cls: 0, className: "map", nParams: 1, maxInstances: 1, name: "lowpass" },
|
|
1296
|
+
{ kind: 3, cls: 0, className: "map", nParams: 2, maxInstances: MAX_NOTCHES, name: "notch" },
|
|
1297
|
+
];
|
|
1298
|
+
/** The length of one catalog record, in bytes. */
|
|
1299
|
+
export const CATALOG_ENTRY_LEN = 12;
|
|
1300
|
+
/** GET_PIPELINE_CATALOG: `u8 n_kinds`, then records of twelve bytes. */
|
|
1301
|
+
export function decodeCatalog(payload) {
|
|
1302
|
+
if (payload.length === 0)
|
|
1303
|
+
throw new Truncated("a catalog is at least its count");
|
|
1304
|
+
const n = payload[0];
|
|
1305
|
+
if (payload.length !== 1 + CATALOG_ENTRY_LEN * n)
|
|
1306
|
+
throw new Invalid("a catalog is one byte and twelve per kind, exactly");
|
|
1307
|
+
const entries = [];
|
|
1308
|
+
for (let i = 0; i < n; i++) {
|
|
1309
|
+
const b = payload.subarray(1 + CATALOG_ENTRY_LEN * i, 13 + CATALOG_ENTRY_LEN * i);
|
|
1310
|
+
const raw = b.subarray(4, 12);
|
|
1311
|
+
let end = raw.indexOf(0);
|
|
1312
|
+
if (end < 0)
|
|
1313
|
+
end = raw.length;
|
|
1314
|
+
entries.push({
|
|
1315
|
+
kind: b[0],
|
|
1316
|
+
cls: b[1],
|
|
1317
|
+
className: CLASS_NAMES[b[1]] ?? `class ${b[1]}`,
|
|
1318
|
+
nParams: b[2],
|
|
1319
|
+
maxInstances: b[3],
|
|
1320
|
+
name: new TextDecoder().decode(raw.subarray(0, end)),
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
return entries;
|
|
1324
|
+
}
|
|
1325
|
+
/** GET_PIPELINE_CATALOG's answer, as the device sends it. */
|
|
1326
|
+
export function encodeCatalog(entries) {
|
|
1327
|
+
const out = new Uint8Array(1 + CATALOG_ENTRY_LEN * entries.length);
|
|
1328
|
+
out[0] = entries.length;
|
|
1329
|
+
entries.forEach((e, i) => {
|
|
1330
|
+
const at = 1 + CATALOG_ENTRY_LEN * i;
|
|
1331
|
+
out[at] = e.kind;
|
|
1332
|
+
out[at + 1] = e.cls;
|
|
1333
|
+
out[at + 2] = e.nParams;
|
|
1334
|
+
out[at + 3] = e.maxInstances;
|
|
1335
|
+
out.set(paddedName(e.name, 8), at + 4);
|
|
1336
|
+
});
|
|
1337
|
+
return out;
|
|
1338
|
+
}
|
|
1339
|
+
/** GET_PIPELINE's answer, as the device sends it. */
|
|
1340
|
+
export function decodePipelineState(payload) {
|
|
1341
|
+
if (payload.length === 0)
|
|
1342
|
+
throw new Truncated("a pipeline state is at least its origin");
|
|
1343
|
+
const origin = payload[0];
|
|
1344
|
+
const originName = ORIGIN_NAMES[origin];
|
|
1345
|
+
if (originName === undefined)
|
|
1346
|
+
throw new Invalid(`origin ${origin} is not one this contract defines`);
|
|
1347
|
+
return { origin, originName, stages: decodeChain(payload.subarray(1)) };
|
|
1348
|
+
}
|
|
1349
|
+
/** GET_PIPELINE's answer, as the device sends it. */
|
|
1350
|
+
export function encodePipelineState(origin, stages) {
|
|
1351
|
+
const chain = encodeChain(stages);
|
|
1352
|
+
const out = new Uint8Array(1 + chain.length);
|
|
1353
|
+
out[0] = origin;
|
|
1354
|
+
out.set(chain, 1);
|
|
1355
|
+
return out;
|
|
1356
|
+
}
|
|
1357
|
+
/** SET_PREDICTION_INPUT's request, as the device reads it. */
|
|
1358
|
+
export function encodePredictionInput(source, stages = []) {
|
|
1359
|
+
const code = typeof source === "string" ? INPUT_SOURCES[source] : source;
|
|
1360
|
+
// Synthetic is reported by a device generating its signal, never requested.
|
|
1361
|
+
if (code === undefined || INPUT_SOURCE_NAMES[code] === undefined || code === INPUT_SOURCES.synthetic) {
|
|
1362
|
+
throw new Invalid(`${source} is not a source a host may ask for`);
|
|
1363
|
+
}
|
|
1364
|
+
const chain = encodeChain(stages);
|
|
1365
|
+
const out = new Uint8Array(1 + chain.length);
|
|
1366
|
+
out[0] = code;
|
|
1367
|
+
out.set(chain, 1);
|
|
1368
|
+
return out;
|
|
1369
|
+
}
|
|
1370
|
+
/** GET_PREDICTION_INPUT's answer, as the device sends it. */
|
|
1371
|
+
export function decodePredictionInput(payload) {
|
|
1372
|
+
if (payload.length === 0)
|
|
1373
|
+
throw new Truncated("a prediction input is at least its source");
|
|
1374
|
+
const source = payload[0];
|
|
1375
|
+
const sourceName = INPUT_SOURCE_NAMES[source];
|
|
1376
|
+
if (sourceName === undefined || source === INPUT_SOURCES.synthetic) {
|
|
1377
|
+
throw new Invalid(`source ${source} is not one a host may ask for`);
|
|
1378
|
+
}
|
|
1379
|
+
return { source, sourceName, stages: decodeChain(payload.subarray(1)) };
|
|
1380
|
+
}
|
|
1381
|
+
/** GET_BIAS_DIAGNOSTIC's answer, as the device sends it. */
|
|
1382
|
+
export function decodeBiasDiagnostic(payload) {
|
|
1383
|
+
if (payload.length < 8)
|
|
1384
|
+
throw new Truncated("a bias diagnostic is eight bytes");
|
|
1385
|
+
const v = view(payload);
|
|
1386
|
+
return { meanMv: v.getInt16(0, true), sdMv: v.getInt16(2, true), minMv: v.getInt16(4, true), maxMv: v.getInt16(6, true) };
|
|
1387
|
+
}
|
|
1388
|
+
/** GET_BIAS_DIAGNOSTIC's answer, as the device sends it. */
|
|
1389
|
+
export function encodeBiasDiagnostic(d) {
|
|
1390
|
+
const out = new Uint8Array(8);
|
|
1391
|
+
const v = view(out);
|
|
1392
|
+
v.setInt16(0, d.meanMv, true);
|
|
1393
|
+
v.setInt16(2, d.sdMv, true);
|
|
1394
|
+
v.setInt16(4, d.minMv, true);
|
|
1395
|
+
v.setInt16(6, d.maxMv, true);
|
|
1396
|
+
return out;
|
|
1397
|
+
}
|
|
1398
|
+
// --- the status lamp (1.2) ---------------------------------------------------
|
|
1399
|
+
//
|
|
1400
|
+
// One language at three levels of verbosity. A blink means the same thing
|
|
1401
|
+
// at every level that shows it; the level only decides what is shown. The
|
|
1402
|
+
// blink shapes are the device's and documented on its page; a host explains
|
|
1403
|
+
// the lamp in words from these tables and never decodes a light.
|
|
1404
|
+
/** The status lamp's verbosity levels: silent, reserved, or verbose. */
|
|
1405
|
+
export const INDICATOR_LEVELS = { silent: 0, reserved: 1, verbose: 2 };
|
|
1406
|
+
/** Indicator level names by their number, the reverse of `INDICATOR_LEVELS`. */
|
|
1407
|
+
export const INDICATOR_LEVEL_NAMES = { 0: "silent", 1: "reserved", 2: "verbose" };
|
|
1408
|
+
/** What each level shows, most important first. */
|
|
1409
|
+
export const INDICATOR_SHOWS = {
|
|
1410
|
+
silent: [],
|
|
1411
|
+
reserved: ["converter fault", "low battery"],
|
|
1412
|
+
verbose: ["converter fault", "low battery", "update in progress", "streaming", "connected", "waiting for a host"],
|
|
1413
|
+
};
|
|
1414
|
+
/** Low battery begins below this percent of the device's own estimate and ends above the clear percent, or on external power. */
|
|
1415
|
+
export const LOW_BATTERY_PERCENT = 20;
|
|
1416
|
+
/** Low battery clears once the device's own estimate rises back above this percent. */
|
|
1417
|
+
export const LOW_BATTERY_CLEAR_PERCENT = 25;
|
|
1418
|
+
/** The most seconds one identify request may run. */
|
|
1419
|
+
export const IDENTIFY_MAX_SECONDS = 30;
|
|
1420
|
+
// --- the converter's registers, read only (1.2) ------------------------------
|
|
1421
|
+
//
|
|
1422
|
+
// The raw configuration bytes of the analog converter, for debugging. The
|
|
1423
|
+
// contract carries the bytes and a family number. Family one is the Texas
|
|
1424
|
+
// Instruments ADS1299 family; the IntoMind One carries the ADS1299-4, as its
|
|
1425
|
+
// datasheet says. There is no write.
|
|
1426
|
+
/** Which chip family a converter register file belongs to, by its `family` code. */
|
|
1427
|
+
export const CONVERTER_FAMILIES = { 1: "ads1299" };
|
|
1428
|
+
/** GET_CONVERTER_REGISTERS's answer, as the device sends it. */
|
|
1429
|
+
export function decodeConverterRegisters(payload) {
|
|
1430
|
+
if (payload.length < 3)
|
|
1431
|
+
throw new Truncated("registers are at least a family, a first address, and a count");
|
|
1432
|
+
const family = payload[0];
|
|
1433
|
+
const count = payload[2];
|
|
1434
|
+
if (count === 0 || payload.length !== 3 + count) {
|
|
1435
|
+
throw new Invalid(`${count} registers declared and ${payload.length - 3} carried`);
|
|
1436
|
+
}
|
|
1437
|
+
return { family, familyName: CONVERTER_FAMILIES[family] ?? `family ${family}`, first: payload[1], values: payload.slice(3) };
|
|
1438
|
+
}
|
|
1439
|
+
/** GET_CONVERTER_REGISTERS's answer, as the device sends it. */
|
|
1440
|
+
export function encodeConverterRegisters(r) {
|
|
1441
|
+
if (r.values.length === 0 || r.values.length > 64)
|
|
1442
|
+
throw new Invalid("registers carry one to sixty four values");
|
|
1443
|
+
const out = new Uint8Array(3 + r.values.length);
|
|
1444
|
+
out[0] = r.family;
|
|
1445
|
+
out[1] = r.first;
|
|
1446
|
+
out[2] = r.values.length;
|
|
1447
|
+
out.set(r.values, 3);
|
|
1448
|
+
return out;
|
|
1449
|
+
}
|
|
1450
|
+
const FAMILY_1_RATES = { 0: 16000, 1: 8000, 2: 4000, 3: 2000, 4: 1000, 5: 500, 6: 250 };
|
|
1451
|
+
const FAMILY_1_GAINS = { 0: 1, 1: 2, 2: 4, 3: 6, 4: 8, 5: 12, 6: 24 };
|
|
1452
|
+
const FAMILY_1_INPUTS = [
|
|
1453
|
+
"electrode",
|
|
1454
|
+
"shorted",
|
|
1455
|
+
"bias measurement",
|
|
1456
|
+
"supply",
|
|
1457
|
+
"temperature",
|
|
1458
|
+
"test signal",
|
|
1459
|
+
"bias drive, positive",
|
|
1460
|
+
"bias drive, negative",
|
|
1461
|
+
];
|
|
1462
|
+
const FAMILY_1_LOFF_THRESHOLD = [95, 92.5, 90, 87.5, 85, 80, 75, 70];
|
|
1463
|
+
const FAMILY_1_LOFF_CURRENT = ["6 nA", "24 nA", "6 uA", "24 uA"];
|
|
1464
|
+
const FAMILY_1_LOFF_FREQUENCY = ["dc", "7.8 Hz", "31.2 Hz", "data rate / 4"];
|
|
1465
|
+
function channelsOf(mask) {
|
|
1466
|
+
const out = [];
|
|
1467
|
+
for (let c = 0; c < 8; c++)
|
|
1468
|
+
if (mask & (1 << c))
|
|
1469
|
+
out.push(c + 1);
|
|
1470
|
+
return out;
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* An ADS1299 family register file in words, from address 0: the identity,
|
|
1474
|
+
* the data rate, the test signal, the reference and bias settings, lead-off
|
|
1475
|
+
* detection, each channel's gain and input, and the bias and lead-off sense
|
|
1476
|
+
* masks. Every field is the datasheet's (Texas Instruments SBAS499C). A
|
|
1477
|
+
* file shorter than 24 bytes is described as far as it goes.
|
|
1478
|
+
*/
|
|
1479
|
+
export function describeAds1299Registers(values) {
|
|
1480
|
+
const at = (i) => (i < values.length ? values[i] : undefined);
|
|
1481
|
+
const out = { family: CONVERTER_FAMILIES[1] };
|
|
1482
|
+
const ident = at(0);
|
|
1483
|
+
if (ident !== undefined) {
|
|
1484
|
+
out.identity = {
|
|
1485
|
+
device: (ident & 0x1c) === 0x1c ? "ADS1299" : `not an ADS1299 (0x${ident.toString(16)})`,
|
|
1486
|
+
familyMember: (ident & 0x1c) === 0x1c,
|
|
1487
|
+
revision: (ident >> 5) & 0x7,
|
|
1488
|
+
channels: { 0: 4, 1: 6, 2: 8 }[ident & 0x3] ?? `code ${ident & 0x3}`,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const c1 = at(1);
|
|
1492
|
+
if (c1 !== undefined) {
|
|
1493
|
+
out.dataRateSps = FAMILY_1_RATES[c1 & 0x7] ?? null;
|
|
1494
|
+
out.multipleReadback = (c1 & 0x40) !== 0;
|
|
1495
|
+
out.clockOutput = (c1 & 0x20) !== 0;
|
|
1496
|
+
}
|
|
1497
|
+
const c2 = at(2);
|
|
1498
|
+
if (c2 !== undefined) {
|
|
1499
|
+
out.testSignal = {
|
|
1500
|
+
internal: (c2 & 0x10) !== 0,
|
|
1501
|
+
amplitude: c2 & 0x04 ? 2 : 1,
|
|
1502
|
+
frequency: { 0: "fclk / 2^21", 1: "fclk / 2^20", 3: "dc" }[c2 & 0x3] ?? "do not use",
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
const c3 = at(3);
|
|
1506
|
+
if (c3 !== undefined) {
|
|
1507
|
+
out.referenceBufferOn = (c3 & 0x80) !== 0;
|
|
1508
|
+
out.bias = {
|
|
1509
|
+
amplifierOn: (c3 & 0x04) !== 0,
|
|
1510
|
+
referenceInternal: (c3 & 0x08) !== 0,
|
|
1511
|
+
measurement: (c3 & 0x10) !== 0,
|
|
1512
|
+
sense: (c3 & 0x02) !== 0,
|
|
1513
|
+
connected: (c3 & 0x01) === 0,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
const lo = at(4);
|
|
1517
|
+
if (lo !== undefined) {
|
|
1518
|
+
out.leadOff = {
|
|
1519
|
+
thresholdPercent: FAMILY_1_LOFF_THRESHOLD[(lo >> 5) & 0x7],
|
|
1520
|
+
current: FAMILY_1_LOFF_CURRENT[(lo >> 2) & 0x3],
|
|
1521
|
+
frequency: FAMILY_1_LOFF_FREQUENCY[lo & 0x3],
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const channels = [];
|
|
1525
|
+
for (let i = 0; i < 8; i++) {
|
|
1526
|
+
const ch = at(5 + i);
|
|
1527
|
+
if (ch === undefined)
|
|
1528
|
+
break;
|
|
1529
|
+
channels.push({
|
|
1530
|
+
channel: i + 1,
|
|
1531
|
+
poweredDown: (ch & 0x80) !== 0,
|
|
1532
|
+
gain: FAMILY_1_GAINS[(ch >> 4) & 0x7] ?? "do not use",
|
|
1533
|
+
referenceSwitch: (ch & 0x08) !== 0,
|
|
1534
|
+
input: FAMILY_1_INPUTS[ch & 0x7],
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
if (channels.length)
|
|
1538
|
+
out.channels = channels;
|
|
1539
|
+
const masks = [
|
|
1540
|
+
[13, "biasSensePositive"],
|
|
1541
|
+
[14, "biasSenseNegative"],
|
|
1542
|
+
[15, "leadOffSensePositive"],
|
|
1543
|
+
[16, "leadOffSenseNegative"],
|
|
1544
|
+
[17, "leadOffFlip"],
|
|
1545
|
+
[18, "leadOffStatusPositive"],
|
|
1546
|
+
[19, "leadOffStatusNegative"],
|
|
1547
|
+
];
|
|
1548
|
+
for (const [i, name] of masks) {
|
|
1549
|
+
const v = at(i);
|
|
1550
|
+
if (v !== undefined)
|
|
1551
|
+
out[name] = channelsOf(v);
|
|
1552
|
+
}
|
|
1553
|
+
const m1 = at(21);
|
|
1554
|
+
if (m1 !== undefined)
|
|
1555
|
+
out.commonReferenceSwitch = (m1 & 0x20) !== 0;
|
|
1556
|
+
const c4 = at(23);
|
|
1557
|
+
if (c4 !== undefined) {
|
|
1558
|
+
out.singleShot = (c4 & 0x08) !== 0;
|
|
1559
|
+
out.leadOffComparatorsOn = (c4 & 0x02) !== 0;
|
|
1560
|
+
}
|
|
1561
|
+
return out;
|
|
1562
|
+
}
|
|
1563
|
+
// --- embeddings (1.2) --------------------------------------------------------
|
|
1564
|
+
//
|
|
1565
|
+
// The encoder's output for one window, in two forms. The window embedding is
|
|
1566
|
+
// the mean over live channels and over time of the tokens, and is what a
|
|
1567
|
+
// head consumes. The tokens are the encoder's output before that pooling,
|
|
1568
|
+
// one vector per channel per slice of the window, and are what reconstruction
|
|
1569
|
+
// turns back into signal. Both are quantized the way a head receives an
|
|
1570
|
+
// embedding (times 4096). The weights never leave the device.
|
|
1571
|
+
/** The embedding packet type. */
|
|
1572
|
+
export const PACKET_EMBEDDING = 0x03;
|
|
1573
|
+
/** The length of an embedding notification's header, before its values. */
|
|
1574
|
+
export const EMBEDDING_HEADER_LEN = 28;
|
|
1575
|
+
/** The `token` byte meaning this is the window embedding, not one token. */
|
|
1576
|
+
export const WINDOW_TOKEN = 0xff;
|
|
1577
|
+
/** Which embeddings a device streams: off, window, tokens, or both. */
|
|
1578
|
+
export const EMBEDDING_FORMS = { off: 0, window: 1, tokens: 2, both: 3 };
|
|
1579
|
+
/** The bits of an embedding notification's flags byte. */
|
|
1580
|
+
export const EMBEDDING_FLAGS = {
|
|
1581
|
+
gap_in_window: 1 << 0,
|
|
1582
|
+
duty_reduced: 1 << 1,
|
|
1583
|
+
leadoff_in_window: 1 << 2,
|
|
1584
|
+
more_parts: 1 << 3,
|
|
1585
|
+
};
|
|
1586
|
+
/** One embedding notification, as the device sends it. */
|
|
1587
|
+
export function decodeEmbedding(data) {
|
|
1588
|
+
if (data.length < EMBEDDING_HEADER_LEN)
|
|
1589
|
+
throw new Truncated("an embedding is at least its header");
|
|
1590
|
+
const v = view(data);
|
|
1591
|
+
const packetType = data[0];
|
|
1592
|
+
if (packetType !== PACKET_EMBEDDING)
|
|
1593
|
+
throw new Invalid(`packet type ${toByte(packetType)} is not an embedding`);
|
|
1594
|
+
const flags = data[1];
|
|
1595
|
+
const embedDim = data[2];
|
|
1596
|
+
const first = data[3];
|
|
1597
|
+
const body = data.subarray(EMBEDDING_HEADER_LEN);
|
|
1598
|
+
if (body.length === 0 || body.length % 2 !== 0)
|
|
1599
|
+
throw new Truncated("an embedding carries whole sixteen-bit values, at least one");
|
|
1600
|
+
const n = body.length / 2;
|
|
1601
|
+
if (first + n > embedDim)
|
|
1602
|
+
throw new Invalid(`values ${first} to ${first + n} of a vector of ${embedDim}`);
|
|
1603
|
+
const values = [];
|
|
1604
|
+
for (let i = 0; i < n; i++)
|
|
1605
|
+
values.push(v.getInt16(EMBEDDING_HEADER_LEN + i * 2, true));
|
|
1606
|
+
const token = data[27];
|
|
1607
|
+
return {
|
|
1608
|
+
packetType,
|
|
1609
|
+
index: v.getUint32(4, true),
|
|
1610
|
+
deviceTime: v.getBigUint64(8, true),
|
|
1611
|
+
windowSamples: v.getUint16(16, true),
|
|
1612
|
+
encoderId: hex(data.subarray(18, 26)),
|
|
1613
|
+
inputSource: data[26],
|
|
1614
|
+
embedDim,
|
|
1615
|
+
first,
|
|
1616
|
+
token: token === WINDOW_TOKEN ? null : token,
|
|
1617
|
+
moreParts: (flags & EMBEDDING_FLAGS.more_parts) !== 0,
|
|
1618
|
+
gapInWindow: (flags & EMBEDDING_FLAGS.gap_in_window) !== 0,
|
|
1619
|
+
dutyReduced: (flags & EMBEDDING_FLAGS.duty_reduced) !== 0,
|
|
1620
|
+
leadoffInWindow: (flags & EMBEDDING_FLAGS.leadoff_in_window) !== 0,
|
|
1621
|
+
values,
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
/** One embedding notification, as the device sends it. */
|
|
1625
|
+
export function encodeEmbedding(e) {
|
|
1626
|
+
const n = e.values.length;
|
|
1627
|
+
const first = e.first ?? 0;
|
|
1628
|
+
if (n === 0 || first + n > e.embedDim)
|
|
1629
|
+
throw new Invalid("values must lie inside the vector they belong to");
|
|
1630
|
+
const out = new Uint8Array(EMBEDDING_HEADER_LEN + n * 2);
|
|
1631
|
+
const v = view(out);
|
|
1632
|
+
out[0] = PACKET_EMBEDDING;
|
|
1633
|
+
out[1] =
|
|
1634
|
+
(e.gapInWindow ? EMBEDDING_FLAGS.gap_in_window : 0) |
|
|
1635
|
+
(e.dutyReduced ? EMBEDDING_FLAGS.duty_reduced : 0) |
|
|
1636
|
+
(e.leadoffInWindow ? EMBEDDING_FLAGS.leadoff_in_window : 0) |
|
|
1637
|
+
(e.moreParts ? EMBEDDING_FLAGS.more_parts : 0);
|
|
1638
|
+
out[2] = e.embedDim;
|
|
1639
|
+
out[3] = first;
|
|
1640
|
+
v.setUint32(4, e.index >>> 0, true);
|
|
1641
|
+
v.setBigUint64(8, e.deviceTime, true);
|
|
1642
|
+
v.setUint16(16, e.windowSamples, true);
|
|
1643
|
+
out.set(fromHex(e.encoderId), 18);
|
|
1644
|
+
out[26] = e.inputSource ?? 0;
|
|
1645
|
+
out[27] = e.token === null || e.token === undefined ? WINDOW_TOKEN : e.token;
|
|
1646
|
+
for (let i = 0; i < n; i++)
|
|
1647
|
+
v.setInt16(EMBEDDING_HEADER_LEN + i * 2, e.values[i], true);
|
|
1648
|
+
return out;
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* Puts notifications back together into whole windows. Built for a device's
|
|
1652
|
+
* channel count, the model's tokens per channel, and the form asked for;
|
|
1653
|
+
* `feed` takes each decoded notification and returns the window when its
|
|
1654
|
+
* last piece arrives. Windows older than the two most recent that never
|
|
1655
|
+
* completed are dropped and counted in `incomplete`.
|
|
1656
|
+
*/
|
|
1657
|
+
export class EmbeddingAssembler {
|
|
1658
|
+
/** The device's channel count, as built. */
|
|
1659
|
+
channels;
|
|
1660
|
+
/** Tokens per channel per window, as built. */
|
|
1661
|
+
tokensPerChannel;
|
|
1662
|
+
/** Which form this was built for. */
|
|
1663
|
+
form;
|
|
1664
|
+
/** Windows dropped before their last piece arrived. */
|
|
1665
|
+
incomplete = 0;
|
|
1666
|
+
#windows = new Map();
|
|
1667
|
+
/** Build one for a device's channel count, the model's tokens per channel, and the form asked for. */
|
|
1668
|
+
constructor(channels, tokensPerChannel, form = "both") {
|
|
1669
|
+
if (form === "off")
|
|
1670
|
+
throw new Invalid("the form is window, tokens, or both");
|
|
1671
|
+
this.channels = channels;
|
|
1672
|
+
this.tokensPerChannel = tokensPerChannel;
|
|
1673
|
+
this.form = form;
|
|
1674
|
+
}
|
|
1675
|
+
/** Tokens expected per window: `channels` times `tokensPerChannel`. */
|
|
1676
|
+
get nTokens() {
|
|
1677
|
+
return this.channels * this.tokensPerChannel;
|
|
1678
|
+
}
|
|
1679
|
+
/** Feed one decoded notification in. Returns the window once its last piece has arrived, else null. */
|
|
1680
|
+
feed(e) {
|
|
1681
|
+
const nTokens = this.nTokens;
|
|
1682
|
+
let w = this.#windows.get(e.index);
|
|
1683
|
+
if (w === undefined) {
|
|
1684
|
+
w = {
|
|
1685
|
+
meta: e,
|
|
1686
|
+
embedding: new Array(e.embedDim).fill(null),
|
|
1687
|
+
tokens: Array.from({ length: nTokens }, () => new Array(e.embedDim).fill(null)),
|
|
1688
|
+
};
|
|
1689
|
+
this.#windows.set(e.index, w);
|
|
1690
|
+
while (this.#windows.size > 3) {
|
|
1691
|
+
const oldest = Math.min(...this.#windows.keys());
|
|
1692
|
+
this.#windows.delete(oldest);
|
|
1693
|
+
this.incomplete += 1;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
const target = e.token === null ? w.embedding : e.token < nTokens ? w.tokens[e.token] : null;
|
|
1697
|
+
if (target === null || e.first + e.values.length > target.length)
|
|
1698
|
+
return null;
|
|
1699
|
+
for (let i = 0; i < e.values.length; i++)
|
|
1700
|
+
target[e.first + i] = e.values[i];
|
|
1701
|
+
const wantEmbedding = this.form === "window" || this.form === "both";
|
|
1702
|
+
const wantTokens = this.form === "tokens" || this.form === "both";
|
|
1703
|
+
const embeddingDone = !wantEmbedding || w.embedding.every((x) => x !== null);
|
|
1704
|
+
const tokensDone = !wantTokens || w.tokens.every((t) => t.every((x) => x !== null));
|
|
1705
|
+
if (!(embeddingDone && tokensDone))
|
|
1706
|
+
return null;
|
|
1707
|
+
this.#windows.delete(e.index);
|
|
1708
|
+
const m = w.meta;
|
|
1709
|
+
return {
|
|
1710
|
+
index: m.index,
|
|
1711
|
+
deviceTime: m.deviceTime,
|
|
1712
|
+
windowSamples: m.windowSamples,
|
|
1713
|
+
encoderId: m.encoderId,
|
|
1714
|
+
inputSource: m.inputSource,
|
|
1715
|
+
embedDim: m.embedDim,
|
|
1716
|
+
gapInWindow: m.gapInWindow,
|
|
1717
|
+
dutyReduced: m.dutyReduced,
|
|
1718
|
+
leadoffInWindow: m.leadoffInWindow,
|
|
1719
|
+
embedding: wantEmbedding ? w.embedding : null,
|
|
1720
|
+
tokens: wantTokens ? w.tokens : null,
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
// The rules, the same ones the device applies, so a host can say before
|
|
1725
|
+
// sending whether a chain runs at a rate and explain a refusal in words.
|
|
1726
|
+
/** The automatic low-pass corner for a rate, in tenths of a hertz. */
|
|
1727
|
+
export function lowPassAutoDhz(rateSps) {
|
|
1728
|
+
return Math.trunc(rateSps) * 4;
|
|
1729
|
+
}
|
|
1730
|
+
/** The first corner a rate refuses, in tenths of a hertz: nine tenths of Nyquist. */
|
|
1731
|
+
export function cornerLimitDhz(rateSps) {
|
|
1732
|
+
return Math.floor((Math.trunc(rateSps) * 45) / 10);
|
|
1733
|
+
}
|
|
1734
|
+
/** Whether a chain can run at a rate. Throws `Invalid` saying why not. */
|
|
1735
|
+
export function checkChain(stages, rateSps, catalog = CONTRACT_CATALOG) {
|
|
1736
|
+
const byKind = new Map(catalog.map((e) => [e.kind, e]));
|
|
1737
|
+
const limit = cornerLimitDhz(rateSps);
|
|
1738
|
+
const counts = new Map();
|
|
1739
|
+
let hp = null;
|
|
1740
|
+
let lp = null;
|
|
1741
|
+
for (const s of stages) {
|
|
1742
|
+
const e = byKind.get(s.kind);
|
|
1743
|
+
if (e === undefined)
|
|
1744
|
+
throw new Invalid(`kind ${s.kind} is not in the device's catalog`);
|
|
1745
|
+
if (s.params.length !== e.nParams)
|
|
1746
|
+
throw new Invalid(`${e.name} takes ${e.nParams} parameter(s), not ${s.params.length}`);
|
|
1747
|
+
const count = (counts.get(s.kind) ?? 0) + 1;
|
|
1748
|
+
counts.set(s.kind, count);
|
|
1749
|
+
if (count > e.maxInstances)
|
|
1750
|
+
throw new Invalid(`a chain holds at most ${e.maxInstances} ${e.name} stage(s)`);
|
|
1751
|
+
const p = s.params;
|
|
1752
|
+
if (s.kind === STAGE_KINDS.highpass) {
|
|
1753
|
+
if (p[0] === 0)
|
|
1754
|
+
throw new Invalid("a high-pass corner is at least 0.1 Hz");
|
|
1755
|
+
if (p[0] >= limit)
|
|
1756
|
+
throw new Invalid(`a high-pass at ${p[0] / 10} Hz is at or above nine tenths of Nyquist at ${rateSps} SPS`);
|
|
1757
|
+
hp = p[0];
|
|
1758
|
+
}
|
|
1759
|
+
else if (s.kind === STAGE_KINDS.lowpass) {
|
|
1760
|
+
const c = p[0] || lowPassAutoDhz(rateSps);
|
|
1761
|
+
if (c >= limit)
|
|
1762
|
+
throw new Invalid(`a low-pass at ${c / 10} Hz is at or above nine tenths of Nyquist at ${rateSps} SPS`);
|
|
1763
|
+
lp = c;
|
|
1764
|
+
}
|
|
1765
|
+
else if (s.kind === STAGE_KINDS.notch) {
|
|
1766
|
+
if (p[0] === 0)
|
|
1767
|
+
throw new Invalid("a band starts above zero");
|
|
1768
|
+
if (p[1] <= p[0])
|
|
1769
|
+
throw new Invalid("a band's high edge is above its low edge");
|
|
1770
|
+
if (p[1] >= limit)
|
|
1771
|
+
throw new Invalid(`a band up to ${p[1] / 10} Hz is at or above nine tenths of Nyquist at ${rateSps} SPS`);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
if (hp !== null && lp !== null && hp >= lp)
|
|
1775
|
+
throw new Invalid("the high-pass meets the low-pass: the band passes nothing");
|
|
1776
|
+
}
|
|
1777
|
+
/** 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. */
|
|
1778
|
+
export function defaultChain(rateSps) {
|
|
1779
|
+
const limit = cornerLimitDhz(rateSps);
|
|
1780
|
+
const stages = [{ kind: STAGE_KINDS.highpass, params: [DEFAULT_HIGH_PASS_DHZ] }];
|
|
1781
|
+
for (const [lo, hi] of DEFAULT_MAINS_BANDS_DHZ)
|
|
1782
|
+
if (hi < limit)
|
|
1783
|
+
stages.push({ kind: STAGE_KINDS.notch, params: [lo, hi] });
|
|
1784
|
+
stages.push({ kind: STAGE_KINDS.lowpass, params: [0] });
|
|
1785
|
+
return stages;
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* The notch stages for a region's mains: the fundamental, its second and its third harmonic, four hertz wide.
|
|
1789
|
+
* Given a rate, only the bands that rate can represent: at 250 samples a second the device refuses the higher harmonics.
|
|
1790
|
+
*/
|
|
1791
|
+
export function mainsBands(mainsHz, rateSps) {
|
|
1792
|
+
const f = Math.trunc(mainsHz) * 10;
|
|
1793
|
+
const limit = rateSps === undefined ? Infinity : cornerLimitDhz(rateSps);
|
|
1794
|
+
return [1, 2, 3]
|
|
1795
|
+
.filter((k) => k * f + 20 < limit)
|
|
1796
|
+
.map((k) => ({ kind: STAGE_KINDS.notch, params: [k * f - 20, k * f + 20] }));
|
|
1797
|
+
}
|
|
1798
|
+
/** The class of signal a chain produces: the time domain unless a stage is a representation change. */
|
|
1799
|
+
export function outputClass(stages, catalog = CONTRACT_CATALOG) {
|
|
1800
|
+
const byKind = new Map(catalog.map((e) => [e.kind, e]));
|
|
1801
|
+
return stages.some((s) => byKind.get(s.kind)?.cls === STAGE_CLASSES.representation) ? "representation" : "time_domain";
|
|
1802
|
+
}
|
|
1803
|
+
/** A chain in words, for a manifest, a header, or a person. */
|
|
1804
|
+
export function describeChain(stages, rateSps) {
|
|
1805
|
+
if (stages.length === 0)
|
|
1806
|
+
return "natural signal";
|
|
1807
|
+
const words = [];
|
|
1808
|
+
for (const s of stages) {
|
|
1809
|
+
const p = s.params;
|
|
1810
|
+
if (s.kind === STAGE_KINDS.highpass && p.length > 0)
|
|
1811
|
+
words.push(`high-pass ${p[0] / 10} Hz`);
|
|
1812
|
+
else if (s.kind === STAGE_KINDS.lowpass && p.length > 0) {
|
|
1813
|
+
if (p[0])
|
|
1814
|
+
words.push(`low-pass ${p[0] / 10} Hz`);
|
|
1815
|
+
else if (rateSps)
|
|
1816
|
+
words.push(`low-pass ${lowPassAutoDhz(rateSps) / 10} Hz (automatic at ${rateSps} SPS)`);
|
|
1817
|
+
else
|
|
1818
|
+
words.push("low-pass automatic");
|
|
1819
|
+
}
|
|
1820
|
+
else if (s.kind === STAGE_KINDS.notch && p.length >= 2)
|
|
1821
|
+
words.push(`notch ${p[0] / 10} to ${p[1] / 10} Hz`);
|
|
1822
|
+
else
|
|
1823
|
+
words.push(`${stageName(s)} [${p.join(", ")}]`);
|
|
1824
|
+
}
|
|
1825
|
+
return words.join(", ");
|
|
1826
|
+
}
|
|
1827
|
+
// --- the update service -----------------------------------------------------
|
|
1828
|
+
/** The update service's operations, by name. */
|
|
1829
|
+
export const UPDATE_OPS = { start: 0x01, query: 0x02, finish: 0x03, activate: 0x04, abort: 0x05 };
|
|
1830
|
+
/** Operation names by their number, the reverse of `UPDATE_OPS`. */
|
|
1831
|
+
export const UPDATE_OP_BY_CODE = {
|
|
1832
|
+
1: "start",
|
|
1833
|
+
2: "query",
|
|
1834
|
+
3: "finish",
|
|
1835
|
+
4: "activate",
|
|
1836
|
+
5: "abort",
|
|
1837
|
+
};
|
|
1838
|
+
/** What a transfer carries: an application, weights, or a head. */
|
|
1839
|
+
export const UPDATE_TARGETS = { app: 1, weights: 2, head: 3 };
|
|
1840
|
+
/** Target names by their number, the reverse of `UPDATE_TARGETS`. */
|
|
1841
|
+
export const UPDATE_TARGET_BY_CODE = {
|
|
1842
|
+
1: "app",
|
|
1843
|
+
2: "weights",
|
|
1844
|
+
3: "head",
|
|
1845
|
+
};
|
|
1846
|
+
/** What an update control response's status byte means. */
|
|
1847
|
+
export const UPDATE_STATUS = {
|
|
1848
|
+
0: "ok",
|
|
1849
|
+
1: "invalid argument",
|
|
1850
|
+
2: "unsupported",
|
|
1851
|
+
3: "busy",
|
|
1852
|
+
4: "no transfer",
|
|
1853
|
+
5: "flash",
|
|
1854
|
+
6: "verification failed",
|
|
1855
|
+
};
|
|
1856
|
+
/** A transfer's state, in words, by its QUERY `state` code. */
|
|
1857
|
+
export const UPDATE_STATES = {
|
|
1858
|
+
0: "idle",
|
|
1859
|
+
1: "receiving",
|
|
1860
|
+
2: "complete",
|
|
1861
|
+
3: "failed",
|
|
1862
|
+
};
|
|
1863
|
+
/** Why FINISH accepted or refused an image, in words, by its `verify_result` code. */
|
|
1864
|
+
export const VERIFY_RESULTS = {
|
|
1865
|
+
0: "verified",
|
|
1866
|
+
1: "length",
|
|
1867
|
+
2: "malformed",
|
|
1868
|
+
3: "target",
|
|
1869
|
+
4: "slot",
|
|
1870
|
+
5: "hash",
|
|
1871
|
+
6: "signature",
|
|
1872
|
+
7: "security counter",
|
|
1873
|
+
8: "head shape",
|
|
1874
|
+
9: "flash",
|
|
1875
|
+
10: "key id",
|
|
1876
|
+
};
|
|
1877
|
+
/** The length of a START request, in bytes. */
|
|
1878
|
+
export const UPDATE_START_LEN = 15;
|
|
1879
|
+
/** The length of an image envelope's plain header, in bytes. */
|
|
1880
|
+
export const ENVELOPE_LEN = 32;
|
|
1881
|
+
/** An image envelope's magic bytes, as hex. */
|
|
1882
|
+
export const ENVELOPE_MAGIC = "494d5550";
|
|
1883
|
+
/** The only envelope version this contract defines. */
|
|
1884
|
+
export const ENVELOPE_VERSION = 1;
|
|
1885
|
+
/** The `slot_link` byte for an image that is not linked to an application slot. */
|
|
1886
|
+
export const SLOT_LINK_NONE = 0xff;
|
|
1887
|
+
/** A START request, as the device reads it. */
|
|
1888
|
+
export function encodeUpdateStart(target, slot, totalLen, transferId) {
|
|
1889
|
+
const code = typeof target === "string" ? UPDATE_TARGETS[target] : target;
|
|
1890
|
+
if (code === undefined || UPDATE_TARGET_BY_CODE[code] === undefined) {
|
|
1891
|
+
throw new Invalid(`${target} is not something this contract transfers`);
|
|
1892
|
+
}
|
|
1893
|
+
const id = typeof transferId === "string" ? fromHex(transferId) : transferId;
|
|
1894
|
+
if (id.length !== 8)
|
|
1895
|
+
throw new Invalid("a transfer identifier is eight bytes");
|
|
1896
|
+
const out = new Uint8Array(UPDATE_START_LEN);
|
|
1897
|
+
out[0] = UPDATE_OPS.start;
|
|
1898
|
+
out[1] = code;
|
|
1899
|
+
out[2] = slot;
|
|
1900
|
+
view(out).setUint32(3, totalLen, true);
|
|
1901
|
+
out.set(id, 7);
|
|
1902
|
+
return out;
|
|
1903
|
+
}
|
|
1904
|
+
/** A request with no body: QUERY, FINISH, ACTIVATE, or ABORT, as the device reads it. */
|
|
1905
|
+
export function encodeUpdateOp(op) {
|
|
1906
|
+
return new Uint8Array([UPDATE_OPS[op]]);
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* An update control write, as the device reads it. An operation with no
|
|
1910
|
+
* body arrives as exactly one byte.
|
|
1911
|
+
*/
|
|
1912
|
+
export function decodeUpdateRequest(data) {
|
|
1913
|
+
if (data.length === 0)
|
|
1914
|
+
throw new Truncated("an empty write is not a request");
|
|
1915
|
+
const op = data[0];
|
|
1916
|
+
const opName = UPDATE_OP_BY_CODE[op];
|
|
1917
|
+
if (opName === undefined)
|
|
1918
|
+
throw new Invalid(`${toByte(op)} is not an update operation`);
|
|
1919
|
+
const body = data.subarray(1);
|
|
1920
|
+
if (opName !== "start") {
|
|
1921
|
+
if (body.length !== 0)
|
|
1922
|
+
throw new Invalid(`${opName} takes no body`);
|
|
1923
|
+
return { op, opName, target: null, targetName: null, slot: null, totalLen: null, transferId: null };
|
|
1924
|
+
}
|
|
1925
|
+
if (data.length !== UPDATE_START_LEN) {
|
|
1926
|
+
throw new Invalid(`a start is ${UPDATE_START_LEN} bytes, and this one is ${data.length}`);
|
|
1927
|
+
}
|
|
1928
|
+
const target = body[0];
|
|
1929
|
+
const targetName = UPDATE_TARGET_BY_CODE[target];
|
|
1930
|
+
if (targetName === undefined)
|
|
1931
|
+
throw new Invalid(`target ${target} is not something this contract transfers`);
|
|
1932
|
+
return {
|
|
1933
|
+
op,
|
|
1934
|
+
opName,
|
|
1935
|
+
target,
|
|
1936
|
+
targetName,
|
|
1937
|
+
slot: body[1],
|
|
1938
|
+
totalLen: view(body).getUint32(2, true),
|
|
1939
|
+
transferId: hex(body.subarray(6, 14)),
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
/** An update control response, as the device sends it. */
|
|
1943
|
+
export function decodeUpdateResponse(data) {
|
|
1944
|
+
if (data.length < 2)
|
|
1945
|
+
throw new Truncated("an update answer is at least an operation and a status");
|
|
1946
|
+
const op = data[0];
|
|
1947
|
+
const status = data[1];
|
|
1948
|
+
// An operation or a status this contract does not define is refused
|
|
1949
|
+
// rather than guessed at. A status is what says whether an image was
|
|
1950
|
+
// accepted, and an unknown one must never read as success.
|
|
1951
|
+
const opName = UPDATE_OP_BY_CODE[op];
|
|
1952
|
+
if (opName === undefined)
|
|
1953
|
+
throw new Invalid(`${toByte(op)} is not an update operation this contract defines`);
|
|
1954
|
+
const statusName = UPDATE_STATUS[status];
|
|
1955
|
+
if (statusName === undefined)
|
|
1956
|
+
throw new Invalid(`status ${status} is not one this contract defines`);
|
|
1957
|
+
const payload = data.slice(2);
|
|
1958
|
+
// Only FINISH carries a verify result. Reading the first payload byte of
|
|
1959
|
+
// any answer would label a start answer's slot number as a verdict.
|
|
1960
|
+
const verify = op === UPDATE_OPS.finish && payload.length > 0 ? payload[0] : null;
|
|
1961
|
+
return {
|
|
1962
|
+
op,
|
|
1963
|
+
opName,
|
|
1964
|
+
status,
|
|
1965
|
+
statusName,
|
|
1966
|
+
ok: status === 0,
|
|
1967
|
+
verifyResult: verify,
|
|
1968
|
+
verifyResultName: verify === null ? null : VERIFY_RESULTS[verify] ?? `result ${verify}`,
|
|
1969
|
+
payload,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
/** An update control response, as the device sends it. */
|
|
1973
|
+
export function encodeUpdateResponse(op, status, payload = EMPTY) {
|
|
1974
|
+
const out = new Uint8Array(2 + payload.length);
|
|
1975
|
+
out[0] = op;
|
|
1976
|
+
out[1] = status;
|
|
1977
|
+
out.set(payload, 2);
|
|
1978
|
+
return out;
|
|
1979
|
+
}
|
|
1980
|
+
/** START's answer, as the device sends it. */
|
|
1981
|
+
export function decodeUpdateStart(payload) {
|
|
1982
|
+
if (payload.length < 7)
|
|
1983
|
+
throw new Truncated("a start answer is seven bytes");
|
|
1984
|
+
const v = view(payload);
|
|
1985
|
+
return { targetSlot: payload[0], chunkMax: v.getUint16(1, true), resumeOffset: v.getUint32(3, true) };
|
|
1986
|
+
}
|
|
1987
|
+
/** QUERY's answer, as the device sends it. */
|
|
1988
|
+
export function decodeUpdateQuery(payload) {
|
|
1989
|
+
if (payload.length < 9)
|
|
1990
|
+
throw new Truncated("a query answer is nine bytes");
|
|
1991
|
+
const v = view(payload);
|
|
1992
|
+
const state = payload[0];
|
|
1993
|
+
return {
|
|
1994
|
+
state,
|
|
1995
|
+
stateName: UPDATE_STATES[state] ?? `state ${state}`,
|
|
1996
|
+
offset: v.getUint32(1, true),
|
|
1997
|
+
crc32: v.getUint32(5, true),
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
/** An image envelope's plain header, as the device reads it. */
|
|
2001
|
+
export function decodeEnvelope(data) {
|
|
2002
|
+
if (data.length < ENVELOPE_LEN)
|
|
2003
|
+
throw new Truncated("an envelope is thirty two bytes");
|
|
2004
|
+
if (hex(data.subarray(0, 4)) !== ENVELOPE_MAGIC || data[4] !== ENVELOPE_VERSION) {
|
|
2005
|
+
throw new Invalid("not an image envelope this contract knows");
|
|
2006
|
+
}
|
|
2007
|
+
const target = data[5];
|
|
2008
|
+
const targetName = target === 1 ? "app" : target === 2 ? "weights" : null;
|
|
2009
|
+
if (targetName === null)
|
|
2010
|
+
throw new Invalid(`target ${target} is not something an envelope carries`);
|
|
2011
|
+
const slotLink = data[6];
|
|
2012
|
+
if (targetName === "app" && slotLink > 1) {
|
|
2013
|
+
throw new Invalid("an application image is built for slot zero or one");
|
|
2014
|
+
}
|
|
2015
|
+
if (targetName === "weights" && slotLink !== SLOT_LINK_NONE) {
|
|
2016
|
+
throw new Invalid("weights are not built for a slot");
|
|
2017
|
+
}
|
|
2018
|
+
const plainLen = view(data).getUint32(24, true);
|
|
2019
|
+
return {
|
|
2020
|
+
target,
|
|
2021
|
+
targetName,
|
|
2022
|
+
slotLink,
|
|
2023
|
+
keyId: data[7],
|
|
2024
|
+
nonce: data.slice(8, 24),
|
|
2025
|
+
plainLen,
|
|
2026
|
+
totalLen: ENVELOPE_LEN + plainLen,
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
/** An image envelope's plain header, as the device reads it. */
|
|
2030
|
+
export function encodeEnvelope(e) {
|
|
2031
|
+
const target = typeof e.target === "string" ? UPDATE_TARGETS[e.target] : e.target;
|
|
2032
|
+
if (e.nonce.length !== 16)
|
|
2033
|
+
throw new Invalid("a nonce is sixteen bytes");
|
|
2034
|
+
const out = new Uint8Array(ENVELOPE_LEN);
|
|
2035
|
+
out.set(fromHex(ENVELOPE_MAGIC), 0);
|
|
2036
|
+
out[4] = ENVELOPE_VERSION;
|
|
2037
|
+
out[5] = target;
|
|
2038
|
+
out[6] = e.slotLink;
|
|
2039
|
+
out[7] = e.keyId;
|
|
2040
|
+
out.set(e.nonce, 8);
|
|
2041
|
+
view(out).setUint32(24, e.plainLen, true);
|
|
2042
|
+
return out;
|
|
2043
|
+
}
|
|
2044
|
+
// --- heads ------------------------------------------------------------------
|
|
2045
|
+
/** A head blob's magic bytes, as hex. */
|
|
2046
|
+
export const HEAD_MAGIC = "494d4844";
|
|
2047
|
+
/** The length of a format 1 head header, in bytes. */
|
|
2048
|
+
export const HEAD_HEADER_LEN = 32;
|
|
2049
|
+
/** 1.3: format 2 appends the eight byte id of the encoder the head was trained beside. */
|
|
2050
|
+
export const HEAD_HEADER_LEN_2 = 40;
|
|
2051
|
+
/** The length of a head blob's trailing hash, in bytes. */
|
|
2052
|
+
export const HEAD_HASH_LEN = 32;
|
|
2053
|
+
/** The only head kind this contract defines. */
|
|
2054
|
+
export const HEAD_KIND_LINEAR = 1;
|
|
2055
|
+
/** The length of a head's name field, in bytes. */
|
|
2056
|
+
export const HEAD_NAME_LEN = 16;
|
|
2057
|
+
/**
|
|
2058
|
+
* Counts per unit of embedding. Part of the contract: changing it would
|
|
2059
|
+
* change every head ever trained.
|
|
2060
|
+
*/
|
|
2061
|
+
export const EMBED_SCALE = 4096.0;
|
|
2062
|
+
/** The exact length of a head blob for a shape and format version. */
|
|
2063
|
+
export function headBlobLen(inDim, outDim, version = 2) {
|
|
2064
|
+
return (version === 2 ? HEAD_HEADER_LEN_2 : HEAD_HEADER_LEN) + outDim * inDim + 8 * outDim + HEAD_HASH_LEN;
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* The integers a head receives for a float embedding. Saturates rather than
|
|
2068
|
+
* wrapping, so an extreme value is clipped and never reversed.
|
|
2069
|
+
*/
|
|
2070
|
+
export function quantizeEmbedding(embedding) {
|
|
2071
|
+
const out = [];
|
|
2072
|
+
for (let i = 0; i < embedding.length; i++) {
|
|
2073
|
+
const scaled = embedding[i] * EMBED_SCALE;
|
|
2074
|
+
if (scaled >= 32767)
|
|
2075
|
+
out.push(32767);
|
|
2076
|
+
else if (scaled <= -32768)
|
|
2077
|
+
out.push(-32768);
|
|
2078
|
+
else
|
|
2079
|
+
out.push(Math.trunc(scaled >= 0 ? scaled + 0.5 : scaled - 0.5));
|
|
2080
|
+
}
|
|
2081
|
+
return out;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* A head blob, ready to upload.
|
|
2085
|
+
*
|
|
2086
|
+
* `weights` is one row per output, each row `inDim` values in the range a
|
|
2087
|
+
* signed byte holds. `bias` is one integer per output and `scale` one float
|
|
2088
|
+
* per output, which together carry whatever range the outputs need. The
|
|
2089
|
+
* hash is computed here, because a head the device cannot check is a head
|
|
2090
|
+
* the device refuses.
|
|
2091
|
+
*
|
|
2092
|
+
* `encoderId` names the encoder the head was trained beside (1.3, format 2),
|
|
2093
|
+
* so a host can warn when it is uploaded to a device running another;
|
|
2094
|
+
* `version` 1 writes the 1.0 header for a device that predates format 2.
|
|
2095
|
+
*/
|
|
2096
|
+
export function buildHead(weights, bias, scale, options = {}) {
|
|
2097
|
+
const version = options.version ?? 2;
|
|
2098
|
+
if (version !== 1 && version !== 2)
|
|
2099
|
+
throw new Invalid("a head is written in format 1 or 2");
|
|
2100
|
+
const encoder = fromHex(options.encoderId ?? NO_ENCODER_ID);
|
|
2101
|
+
if (encoder.length !== 8)
|
|
2102
|
+
throw new Invalid("an encoder id is eight bytes, sixteen hex digits");
|
|
2103
|
+
if (version === 1 && options.encoderId !== undefined && options.encoderId !== NO_ENCODER_ID) {
|
|
2104
|
+
throw new Invalid("format 1 has no room for an encoder id");
|
|
2105
|
+
}
|
|
2106
|
+
const outDim = weights.length;
|
|
2107
|
+
if (outDim === 0)
|
|
2108
|
+
throw new Invalid("a head has at least one output");
|
|
2109
|
+
const inDim = options.inDim ?? weights[0].length;
|
|
2110
|
+
for (const row of weights) {
|
|
2111
|
+
if (row.length !== inDim)
|
|
2112
|
+
throw new Invalid("every row of a head is the same width");
|
|
2113
|
+
}
|
|
2114
|
+
if (bias.length !== outDim || scale.length !== outDim) {
|
|
2115
|
+
throw new Invalid("one bias and one scale per output");
|
|
2116
|
+
}
|
|
2117
|
+
const blob = new Uint8Array(headBlobLen(inDim, outDim, version));
|
|
2118
|
+
const v = view(blob);
|
|
2119
|
+
blob.set(fromHex(HEAD_MAGIC), 0);
|
|
2120
|
+
blob[4] = version;
|
|
2121
|
+
blob[5] = HEAD_KIND_LINEAR;
|
|
2122
|
+
v.setUint16(6, inDim, true);
|
|
2123
|
+
v.setUint16(8, outDim, true);
|
|
2124
|
+
blob.set(paddedName(options.name ?? "", HEAD_NAME_LEN), 10);
|
|
2125
|
+
if (version === 2)
|
|
2126
|
+
blob.set(encoder, 32);
|
|
2127
|
+
let at = version === 2 ? HEAD_HEADER_LEN_2 : HEAD_HEADER_LEN;
|
|
2128
|
+
for (const row of weights) {
|
|
2129
|
+
for (let i = 0; i < inDim; i++) {
|
|
2130
|
+
const w = row[i];
|
|
2131
|
+
if (!Number.isInteger(w) || w < -128 || w > 127) {
|
|
2132
|
+
throw new Invalid("head weights are signed bytes, so they are trained or scaled to fit");
|
|
2133
|
+
}
|
|
2134
|
+
blob[at++] = w & 0xff;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
for (let o = 0; o < outDim; o++, at += 4)
|
|
2138
|
+
v.setInt32(at, bias[o], true);
|
|
2139
|
+
for (let o = 0; o < outDim; o++, at += 4)
|
|
2140
|
+
v.setFloat32(at, scale[o], true);
|
|
2141
|
+
blob.set(sha256(blob.subarray(0, at)), at);
|
|
2142
|
+
return blob;
|
|
2143
|
+
}
|
|
2144
|
+
/** Read a head back, and check it the way the device does. */
|
|
2145
|
+
export function decodeHead(blob) {
|
|
2146
|
+
if (blob.length < HEAD_HEADER_LEN + HEAD_HASH_LEN) {
|
|
2147
|
+
throw new Truncated("a head is at least a header and a hash");
|
|
2148
|
+
}
|
|
2149
|
+
if (hex(blob.subarray(0, 4)) !== HEAD_MAGIC || (blob[4] !== 1 && blob[4] !== 2)) {
|
|
2150
|
+
throw new Invalid("not a head this contract knows");
|
|
2151
|
+
}
|
|
2152
|
+
const version = blob[4];
|
|
2153
|
+
if (blob[5] !== HEAD_KIND_LINEAR) {
|
|
2154
|
+
throw new Invalid(`head kind ${blob[5]} is not one this contract defines`);
|
|
2155
|
+
}
|
|
2156
|
+
const v = view(blob);
|
|
2157
|
+
const inDim = v.getUint16(6, true);
|
|
2158
|
+
const outDim = v.getUint16(8, true);
|
|
2159
|
+
if (blob.length !== headBlobLen(inDim, outDim, version)) {
|
|
2160
|
+
throw new Invalid(`a head of ${inDim} by ${outDim} is ${headBlobLen(inDim, outDim, version)} bytes, and this one is ${blob.length}`);
|
|
2161
|
+
}
|
|
2162
|
+
const body = blob.subarray(0, blob.length - HEAD_HASH_LEN);
|
|
2163
|
+
const digest = blob.subarray(blob.length - HEAD_HASH_LEN);
|
|
2164
|
+
if (hex(sha256(body)) !== hex(digest)) {
|
|
2165
|
+
throw new Invalid("a head whose hash does not match its weights is refused, as the device refuses it");
|
|
2166
|
+
}
|
|
2167
|
+
let at = version === 2 ? HEAD_HEADER_LEN_2 : HEAD_HEADER_LEN;
|
|
2168
|
+
const weights = [];
|
|
2169
|
+
for (let o = 0; o < outDim; o++) {
|
|
2170
|
+
const row = [];
|
|
2171
|
+
for (let i = 0; i < inDim; i++)
|
|
2172
|
+
row.push(v.getInt8(at + o * inDim + i));
|
|
2173
|
+
weights.push(row);
|
|
2174
|
+
}
|
|
2175
|
+
at += outDim * inDim;
|
|
2176
|
+
const bias = [];
|
|
2177
|
+
for (let o = 0; o < outDim; o++)
|
|
2178
|
+
bias.push(v.getInt32(at + o * 4, true));
|
|
2179
|
+
at += outDim * 4;
|
|
2180
|
+
const scale = [];
|
|
2181
|
+
for (let o = 0; o < outDim; o++)
|
|
2182
|
+
scale.push(v.getFloat32(at + o * 4, true));
|
|
2183
|
+
return {
|
|
2184
|
+
kind: blob[5],
|
|
2185
|
+
inDim,
|
|
2186
|
+
outDim,
|
|
2187
|
+
name: nameOf(blob.subarray(10, 26)),
|
|
2188
|
+
headId: hex(digest.subarray(0, 8)),
|
|
2189
|
+
weights,
|
|
2190
|
+
bias,
|
|
2191
|
+
scale,
|
|
2192
|
+
version,
|
|
2193
|
+
encoderId: version === 2 ? hex(blob.subarray(32, 40)) : NO_ENCODER_ID,
|
|
2194
|
+
};
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* What a head produces, computed the way the device computes it: the
|
|
2198
|
+
* accumulation is exact in integers and only the scale is a float.
|
|
2199
|
+
*
|
|
2200
|
+
* `embedding` is the integers a head receives, which is what
|
|
2201
|
+
* `quantizeEmbedding` returns. Floats are not taken here, because a head
|
|
2202
|
+
* that was handed floats would silently produce something the device never
|
|
2203
|
+
* would.
|
|
2204
|
+
*/
|
|
2205
|
+
export function evaluateHead(head, embedding) {
|
|
2206
|
+
if (embedding.length !== head.inDim) {
|
|
2207
|
+
throw new Invalid(`this head takes an embedding of ${head.inDim}, and it was given ${embedding.length}`);
|
|
2208
|
+
}
|
|
2209
|
+
const out = [];
|
|
2210
|
+
for (let o = 0; o < head.outDim; o++) {
|
|
2211
|
+
// Every term is an integer under two to the fifty third, so the
|
|
2212
|
+
// accumulation is exact: a byte weight times a sixteen bit count is at
|
|
2213
|
+
// most two to the twenty second, and a head would need two billion
|
|
2214
|
+
// inputs before the sum could round.
|
|
2215
|
+
let acc = head.bias[o];
|
|
2216
|
+
for (let i = 0; i < head.inDim; i++) {
|
|
2217
|
+
const e = embedding[i];
|
|
2218
|
+
if (!Number.isInteger(e)) {
|
|
2219
|
+
throw new Invalid("a head takes the quantized embedding, from quantizeEmbedding");
|
|
2220
|
+
}
|
|
2221
|
+
acc += head.weights[o][i] * e;
|
|
2222
|
+
}
|
|
2223
|
+
out.push(head.scale[o] * acc);
|
|
2224
|
+
}
|
|
2225
|
+
return out;
|
|
2226
|
+
}
|
|
2227
|
+
//# sourceMappingURL=protocol.js.map
|