@intomind/sdk 0.0.0-stage → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +281 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +519 -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 +670 -0
- package/src/timebase.ts +138 -0
- package/src/transfer.ts +313 -0
- package/src/web-bluetooth.ts +419 -0
package/src/session.ts
ADDED
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One device, as a state machine.
|
|
3
|
+
*
|
|
4
|
+
* Bytes go in, events come out, and commands go back. Nothing here performs
|
|
5
|
+
* input or output, so the same code runs behind a browser's Bluetooth
|
|
6
|
+
* stack, a desktop one, or a test with no Bluetooth at all.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CODE_BY_GAIN,
|
|
11
|
+
CODE_BY_RATE,
|
|
12
|
+
DeviceInfo,
|
|
13
|
+
FILL,
|
|
14
|
+
Invalid,
|
|
15
|
+
type CapabilityName,
|
|
16
|
+
type OpcodeName,
|
|
17
|
+
type Embedding,
|
|
18
|
+
type Prediction,
|
|
19
|
+
type Response,
|
|
20
|
+
type Status,
|
|
21
|
+
continuity,
|
|
22
|
+
decodeDeviceInfo,
|
|
23
|
+
decodeEmbedding,
|
|
24
|
+
decodePacket,
|
|
25
|
+
decodePrediction,
|
|
26
|
+
decodeResponse,
|
|
27
|
+
decodeStatus,
|
|
28
|
+
decodeTimeSync,
|
|
29
|
+
encodeRequest,
|
|
30
|
+
microvolts,
|
|
31
|
+
BIAS_MODES,
|
|
32
|
+
EMBEDDING_FORMS,
|
|
33
|
+
IDENTIFY_MAX_SECONDS,
|
|
34
|
+
INDICATOR_LEVELS,
|
|
35
|
+
checkChain,
|
|
36
|
+
encodeChain,
|
|
37
|
+
encodePredictionInput,
|
|
38
|
+
MODES,
|
|
39
|
+
NAME_MAX_COMPOSED,
|
|
40
|
+
PRODUCT_NAME,
|
|
41
|
+
composeName,
|
|
42
|
+
encodeModelInterval,
|
|
43
|
+
encodeNameParts,
|
|
44
|
+
nameFits,
|
|
45
|
+
} from "./protocol.ts";
|
|
46
|
+
import type {
|
|
47
|
+
BiasModeName,
|
|
48
|
+
CapabilityHighName,
|
|
49
|
+
CatalogEntry,
|
|
50
|
+
EmbeddingFormName,
|
|
51
|
+
IndicatorLevelName,
|
|
52
|
+
InputSourceName,
|
|
53
|
+
ModeName,
|
|
54
|
+
Stage,
|
|
55
|
+
} from "./protocol.ts";
|
|
56
|
+
import { Exchange, Timebase, type Fit } from "./timebase.ts";
|
|
57
|
+
|
|
58
|
+
/** Something a caller asked of a device that the device has not offered. */
|
|
59
|
+
export class SessionError extends Error {
|
|
60
|
+
/** Build one with the message explaining what was asked for. */
|
|
61
|
+
constructor(message: string) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "SessionError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The device has not said what it is yet, so nothing can be decoded. */
|
|
68
|
+
export class NoDeviceInfo extends SessionError {
|
|
69
|
+
constructor() {
|
|
70
|
+
super("read device info first, so nothing has to be assumed");
|
|
71
|
+
this.name = "NoDeviceInfo";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The device does not claim the capability this needs. */
|
|
76
|
+
export class NotCapable extends SessionError {
|
|
77
|
+
/** Which capability was missing. */
|
|
78
|
+
readonly capability: string;
|
|
79
|
+
|
|
80
|
+
/** Build one naming the missing capability. */
|
|
81
|
+
constructor(capability: string) {
|
|
82
|
+
super(`this device does not claim the ${capability} capability`);
|
|
83
|
+
this.name = "NotCapable";
|
|
84
|
+
this.capability = capability;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Bytes to write, and where to write them. */
|
|
89
|
+
export interface Command {
|
|
90
|
+
/**
|
|
91
|
+
* The sixteen bit fill of the characteristic. `protocol.uuid` turns it
|
|
92
|
+
* into the full identifier your stack wants.
|
|
93
|
+
*/
|
|
94
|
+
readonly characteristic: number;
|
|
95
|
+
/** The bytes to write. */
|
|
96
|
+
readonly bytes: Uint8Array;
|
|
97
|
+
/** Whether the write must be acknowledged. */
|
|
98
|
+
readonly withResponse: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A run of samples that arrived together. */
|
|
102
|
+
export class Batch {
|
|
103
|
+
/** The device's own index for the first sample. */
|
|
104
|
+
readonly index: number;
|
|
105
|
+
/** The device time of the first sample, in the device's ticks. */
|
|
106
|
+
readonly deviceTime: bigint;
|
|
107
|
+
/** Channels per row. */
|
|
108
|
+
readonly channels: number;
|
|
109
|
+
/** Converter counts, one row per sample, laid out row after row. */
|
|
110
|
+
readonly counts: Int32Array;
|
|
111
|
+
/** Lead-off status latched with the most recent sample. */
|
|
112
|
+
readonly leadoff: number;
|
|
113
|
+
/** The gain the samples were taken at. */
|
|
114
|
+
readonly gain: number;
|
|
115
|
+
/** The rate the samples were taken at, or null for a code this contract does not define. */
|
|
116
|
+
readonly sampleRateHz: number | null;
|
|
117
|
+
/** The mode the samples were taken in: 0 normal, 1 test signal, 2 inputs shorted, 3 synthetic (1.3). */
|
|
118
|
+
readonly mode: number;
|
|
119
|
+
/** 1.3: the samples were generated by the device, not measured (packet type 0x04). Never store one as a measurement. */
|
|
120
|
+
readonly synthetic: boolean;
|
|
121
|
+
|
|
122
|
+
/** Build from decoded fields. `Session` is what calls this. */
|
|
123
|
+
constructor(f: {
|
|
124
|
+
index: number;
|
|
125
|
+
deviceTime: bigint;
|
|
126
|
+
channels: number;
|
|
127
|
+
counts: Int32Array;
|
|
128
|
+
leadoff: number;
|
|
129
|
+
gain: number;
|
|
130
|
+
sampleRateHz: number | null;
|
|
131
|
+
mode?: number;
|
|
132
|
+
synthetic?: boolean;
|
|
133
|
+
}) {
|
|
134
|
+
this.index = f.index;
|
|
135
|
+
this.deviceTime = f.deviceTime;
|
|
136
|
+
this.channels = f.channels;
|
|
137
|
+
this.counts = f.counts;
|
|
138
|
+
this.leadoff = f.leadoff;
|
|
139
|
+
this.gain = f.gain;
|
|
140
|
+
this.sampleRateHz = f.sampleRateHz;
|
|
141
|
+
this.mode = f.mode ?? 0;
|
|
142
|
+
this.synthetic = f.synthetic ?? false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Samples in this batch. */
|
|
146
|
+
get rows(): number {
|
|
147
|
+
return this.channels === 0 ? 0 : this.counts.length / this.channels;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One converter count. */
|
|
151
|
+
count(row: number, channel: number): number {
|
|
152
|
+
return this.counts[row * this.channels + channel]!;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One sample, in microvolts, with the device's own reference and width.
|
|
157
|
+
* Nothing is assumed about either.
|
|
158
|
+
*/
|
|
159
|
+
microvolts(row: number, channel: number, info: DeviceInfo): number {
|
|
160
|
+
return microvolts(this.count(row, channel), info.vrefUv, this.gain, info.adcBits);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A break in the timeline, written down rather than smoothed over. */
|
|
165
|
+
export interface Gap {
|
|
166
|
+
/** The last index before the break. */
|
|
167
|
+
readonly lastIndexBefore: number;
|
|
168
|
+
/** The first index after the break. */
|
|
169
|
+
readonly firstIndexAfter: number;
|
|
170
|
+
/** Samples missing. Null for a re-base, whose extent is not a number. */
|
|
171
|
+
readonly samplesLost: number | null;
|
|
172
|
+
/** Device time of the last sample before the break. */
|
|
173
|
+
readonly deviceTimeBefore: bigint;
|
|
174
|
+
/** Device time of the first sample after the break. */
|
|
175
|
+
readonly deviceTimeAfter: bigint;
|
|
176
|
+
/** Whether this is a re-base rather than a loss. See `samplesLost`. */
|
|
177
|
+
readonly isRebase: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What arrived. */
|
|
181
|
+
export type Event =
|
|
182
|
+
| { readonly type: "samples"; readonly batch: Batch }
|
|
183
|
+
/** Announced before the batch that follows it. */
|
|
184
|
+
| { readonly type: "gap"; readonly gap: Gap }
|
|
185
|
+
| { readonly type: "status"; readonly status: Status }
|
|
186
|
+
/** A control answer. A caller that issued a command matches on this. */
|
|
187
|
+
| { readonly type: "answer"; readonly answer: Response }
|
|
188
|
+
| { readonly type: "prediction"; readonly prediction: Prediction }
|
|
189
|
+
/** 1.2: one notification of the encoder's output, a whole vector or a part of one. `protocol.EmbeddingAssembler` puts them into whole windows. */
|
|
190
|
+
| { readonly type: "embedding"; readonly embedding: Embedding }
|
|
191
|
+
/** A notification that was not a message. Counted, never guessed at. */
|
|
192
|
+
| { readonly type: "undecodable"; readonly characteristic: number }
|
|
193
|
+
/**
|
|
194
|
+
* A data packet this session already passed on, delivered again by the
|
|
195
|
+
* host's Bluetooth stack: dropped, never passed on twice, and counted. It
|
|
196
|
+
* means another program on the same computer is listening to the device,
|
|
197
|
+
* since the system sends each notification once per subscriber and every
|
|
198
|
+
* subscriber hears all of the copies. `index` is its first sample.
|
|
199
|
+
*/
|
|
200
|
+
| { readonly type: "duplicate"; readonly index: number };
|
|
201
|
+
|
|
202
|
+
/** One device. */
|
|
203
|
+
export class Session {
|
|
204
|
+
#info: DeviceInfo | null = null;
|
|
205
|
+
#timebase: Timebase | null = null;
|
|
206
|
+
#previous: { index: number; nSamples: number; deviceTime: bigint } | null = null;
|
|
207
|
+
/** The first index and device time of the last few packets passed on, what a copy is recognized by. */
|
|
208
|
+
#recent: string[] = [];
|
|
209
|
+
|
|
210
|
+
/** Samples the device's own indexing says never arrived, over this session. */
|
|
211
|
+
samplesLost = 0;
|
|
212
|
+
/** Breaks counted as a re-base rather than a loss, over this session. */
|
|
213
|
+
rebases = 0;
|
|
214
|
+
/** Notifications that were not a message, over this session: see the "undecodable" event. */
|
|
215
|
+
undecodable = 0;
|
|
216
|
+
/** Data packets delivered twice by the host and dropped: see the "duplicate" event. */
|
|
217
|
+
duplicates = 0;
|
|
218
|
+
|
|
219
|
+
/** What was read from the device info characteristic. */
|
|
220
|
+
onDeviceInfo(bytes: Uint8Array): DeviceInfo {
|
|
221
|
+
const info = decodeDeviceInfo(bytes);
|
|
222
|
+
this.#timebase = new Timebase(info.tickHz);
|
|
223
|
+
this.#info = info;
|
|
224
|
+
return info;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What the device said it is, or null before it has been read. */
|
|
228
|
+
get info(): DeviceInfo | null {
|
|
229
|
+
return this.#info;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The same, for a caller that cannot proceed without it. */
|
|
233
|
+
requireInfo(): DeviceInfo {
|
|
234
|
+
if (this.#info === null) throw new NoDeviceInfo();
|
|
235
|
+
return this.#info;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether the device claims a capability. A device that has not said what
|
|
240
|
+
* it is claims nothing.
|
|
241
|
+
*/
|
|
242
|
+
can(capability: CapabilityName | string): boolean {
|
|
243
|
+
return this.#info?.can(capability) ?? false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The timebase fit from time exchanges, or null before `onDeviceInfo`. */
|
|
247
|
+
get timebase(): Timebase | null {
|
|
248
|
+
return this.#timebase;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Host time for a device time, once enough exchanges have been taken. */
|
|
252
|
+
hostTime(deviceTicks: bigint): number | null {
|
|
253
|
+
return this.#timebase?.hostTime(deviceTicks) ?? null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- commands -------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
#command(opcode: OpcodeName, arg: number | Uint8Array | null = null): Command {
|
|
259
|
+
return { characteristic: FILL.CONTROL, bytes: encodeRequest(opcode, arg), withResponse: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
#require(capability: CapabilityName): void {
|
|
263
|
+
if (!this.can(capability)) throw new NotCapable(capability);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Whether the device claims a capability from the second word of Device Info (1.3): synthetic, device_name. */
|
|
267
|
+
canHigh(capability: CapabilityHighName): boolean {
|
|
268
|
+
return this.#info !== null && this.#info.can(capability);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#requireHigh(capability: CapabilityHighName): void {
|
|
272
|
+
if (!this.canHigh(capability)) throw new NotCapable(capability);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Ask the device to stream. */
|
|
276
|
+
startStream(): Command {
|
|
277
|
+
return this.#command("start_stream");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Ask the device to stop streaming. */
|
|
281
|
+
stopStream(): Command {
|
|
282
|
+
return this.#command("stop_stream");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Set the rate, in samples per second. The device refuses a change while
|
|
287
|
+
* it is streaming, so every sample of a recording sits under one
|
|
288
|
+
* configuration.
|
|
289
|
+
*/
|
|
290
|
+
setRate(sps: number): Command {
|
|
291
|
+
const code = CODE_BY_RATE.get(sps);
|
|
292
|
+
if (code === undefined) throw new Invalid(`${sps} samples per second is not a rate this contract defines`);
|
|
293
|
+
return this.#command("set_rate", code);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Set the gain. Refused while streaming. */
|
|
297
|
+
setGain(gain: number): Command {
|
|
298
|
+
const code = CODE_BY_GAIN.get(gain);
|
|
299
|
+
if (code === undefined) throw new Invalid(`a gain of ${gain} is not one this contract defines`);
|
|
300
|
+
return this.#command("set_gain", code);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* What the amplifier listens to: 0 the electrodes, 1 the internal test
|
|
305
|
+
* signal, 2 shorted inputs. The last two are capabilities a device
|
|
306
|
+
* declares, so a device that does not offer one is not asked for it.
|
|
307
|
+
*/
|
|
308
|
+
setMode(mode: number | ModeName): Command {
|
|
309
|
+
const code = typeof mode === "string" ? MODES[mode] : mode;
|
|
310
|
+
if (code === 1) this.#require("test_signal");
|
|
311
|
+
else if (code === 2) this.#require("input_short");
|
|
312
|
+
// 1.3: the device generates the signal, converter off. Only a device
|
|
313
|
+
// that claims it; the device itself refuses it while streaming and at a
|
|
314
|
+
// rate other than 500 samples a second.
|
|
315
|
+
else if (code === 3) this.#requireHigh("synthetic");
|
|
316
|
+
else if (code !== 0) throw new Invalid(`mode ${mode} is not one this contract defines`);
|
|
317
|
+
return this.#command("set_mode", code);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Turn lead-off detection on or off. */
|
|
321
|
+
setLeadoff(on: boolean): Command {
|
|
322
|
+
this.#require("leadoff");
|
|
323
|
+
return this.#command("set_leadoff", on ? 1 : 0);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Ask the device what time it is. Note the host clock either side and
|
|
328
|
+
* give both to `onTimeSync`.
|
|
329
|
+
*/
|
|
330
|
+
timeSync(): Command {
|
|
331
|
+
return this.#command("time_sync");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The answer to a time request, with the host clock either side of it. */
|
|
335
|
+
onTimeSync(payload: Uint8Array, before: number, after: number): Fit | null {
|
|
336
|
+
const ticks = decodeTimeSync(payload);
|
|
337
|
+
if (this.#timebase === null) throw new NoDeviceInfo();
|
|
338
|
+
return this.#timebase.observe(new Exchange(before, after, ticks));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Zero the sample index and start a fresh epoch. */
|
|
342
|
+
resetEpoch(): Command {
|
|
343
|
+
this.#previous = null;
|
|
344
|
+
return this.#command("reset_epoch");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Ask for the measured battery voltage and charge. */
|
|
348
|
+
getBattery(): Command {
|
|
349
|
+
this.#require("battery_voltage");
|
|
350
|
+
return this.#command("get_battery");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Ask why and how many times the device has booted. */
|
|
354
|
+
getBootInfo(): Command {
|
|
355
|
+
return this.#command("get_boot_info");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Turn predictions on or off. */
|
|
359
|
+
setPredictions(on: boolean): Command {
|
|
360
|
+
this.#require("model");
|
|
361
|
+
return this.#command("set_predictions", on ? 1 : 0);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Ask for the model runtime's state. */
|
|
365
|
+
getModelInfo(): Command {
|
|
366
|
+
this.#require("model");
|
|
367
|
+
return this.#command("get_model_info");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Select a head slot. 0 is the built-in head. */
|
|
371
|
+
selectHead(slot: number): Command {
|
|
372
|
+
this.#require("heads");
|
|
373
|
+
return this.#command("select_head", slot);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Ask for the head slots and which one is selected. */
|
|
377
|
+
listHeads(): Command {
|
|
378
|
+
this.#require("heads");
|
|
379
|
+
return this.#command("list_heads");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Ask which encoder each head names (1.4): one record for each LIST_HEADS record, read with `decodeHeadEncoders`.
|
|
384
|
+
* A device before 1.4 is not asked, and its heads say nothing of their encoder.
|
|
385
|
+
*/
|
|
386
|
+
listHeadEncoders(): Command {
|
|
387
|
+
this.#require("heads");
|
|
388
|
+
const [major, minor] = this.#info?.protocol ?? [0, 0];
|
|
389
|
+
if (major < 1 || (major === 1 && minor < 4)) throw new NotCapable("head_encoders");
|
|
390
|
+
return this.#command("list_head_encoders");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Remove a user head slot's contents. */
|
|
394
|
+
removeHead(slot: number): Command {
|
|
395
|
+
this.#require("heads");
|
|
396
|
+
return this.#command("remove_head", slot);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- processing, on devices that run a chain ------------------------------
|
|
400
|
+
//
|
|
401
|
+
// The device preprocesses its own signal. When a chain is in force only
|
|
402
|
+
// its output streams, and the chain is readable at any time, so a
|
|
403
|
+
// recording can say exactly what produced its samples. The rules a chain
|
|
404
|
+
// must meet are the device's, restated in `protocol.checkChain`, so a
|
|
405
|
+
// chain that cannot run is refused here in words before anything is
|
|
406
|
+
// sent. The device refuses a change while streaming: stop, set, start.
|
|
407
|
+
|
|
408
|
+
/** Ask for the stage kinds the device runs. `protocol.decodeCatalog` reads the answer. */
|
|
409
|
+
pipelineCatalog(): Command {
|
|
410
|
+
this.#require("pipeline");
|
|
411
|
+
return this.#command("get_pipeline_catalog");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Ask for the chain in force. `protocol.decodePipelineState` reads the answer. */
|
|
415
|
+
pipeline(): Command {
|
|
416
|
+
this.#require("pipeline");
|
|
417
|
+
return this.#command("get_pipeline");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Put a chain on the device's signal path, checked here first against the rate the device runs at. */
|
|
421
|
+
setPipeline(stages: readonly Stage[], rateSps: number, catalog?: readonly CatalogEntry[]): Command {
|
|
422
|
+
this.#require("pipeline");
|
|
423
|
+
checkChain(stages, rateSps, catalog);
|
|
424
|
+
return this.#command("set_pipeline", encodeChain(stages));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The natural signal, as this host's choice. */
|
|
428
|
+
clearPipeline(): Command {
|
|
429
|
+
this.#require("pipeline");
|
|
430
|
+
return this.#command("clear_pipeline");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The device's own default chain for its current rate. */
|
|
434
|
+
restorePipelineDefault(): Command {
|
|
435
|
+
this.#require("pipeline");
|
|
436
|
+
return this.#command("restore_pipeline_default");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Ask where the model's input comes from. `protocol.decodePredictionInput` reads the answer. */
|
|
440
|
+
predictionInput(): Command {
|
|
441
|
+
this.#require("model");
|
|
442
|
+
return this.#command("get_prediction_input");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Point the model at the stream as it is, at the natural signal, or at a
|
|
447
|
+
* chain of its own, without touching the stream. Only the model's own
|
|
448
|
+
* source carries a chain, checked here against the rate.
|
|
449
|
+
*/
|
|
450
|
+
setPredictionInput(source: InputSourceName, stages: readonly Stage[] = [], rateSps = 500, catalog?: readonly CatalogEntry[]): Command {
|
|
451
|
+
this.#require("model");
|
|
452
|
+
if (source === "own_chain") checkChain(stages, rateSps, catalog);
|
|
453
|
+
else if (stages.length !== 0) throw new Invalid("only the model's own chain carries stages");
|
|
454
|
+
return this.#command("set_prediction_input", encodePredictionInput(source, stages));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The bias drive, on a device that claims one: off, on, or loop open. */
|
|
458
|
+
setBias(mode: BiasModeName): Command {
|
|
459
|
+
this.#require("bias_drive");
|
|
460
|
+
const code = BIAS_MODES[mode];
|
|
461
|
+
if (code === undefined) throw new Invalid(`${mode} is not a bias mode this contract defines`);
|
|
462
|
+
return this.#command("set_bias", code);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Ask for the bias output measured. `protocol.decodeBiasDiagnostic` reads the answer. */
|
|
466
|
+
biasDiagnostic(): Command {
|
|
467
|
+
this.#require("bias_drive");
|
|
468
|
+
return this.#command("get_bias_diagnostic");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// --- the lamp, the converter's registers, embeddings (1.2) ----------------
|
|
472
|
+
|
|
473
|
+
/** Ask for the status lamp's level. The answer's one byte is an `INDICATOR_LEVELS` value. */
|
|
474
|
+
indicator(): Command {
|
|
475
|
+
this.#require("indicator");
|
|
476
|
+
return this.#command("get_indicator");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Put the lamp at a level: silent, reserved (the default: low battery and a
|
|
481
|
+
* converter fault at power on), or verbose. The device keeps it across
|
|
482
|
+
* power cycles.
|
|
483
|
+
*/
|
|
484
|
+
setIndicator(level: IndicatorLevelName): Command {
|
|
485
|
+
this.#require("indicator");
|
|
486
|
+
const code = INDICATOR_LEVELS[level];
|
|
487
|
+
if (code === undefined) throw new Invalid(`${level} is not a lamp level this contract defines`);
|
|
488
|
+
return this.#command("set_indicator", code);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Blink the lamp so this device can be told from others, for 1 to 30 seconds at every level; 0 stops one in progress. */
|
|
492
|
+
identify(seconds = 5): Command {
|
|
493
|
+
this.#require("indicator");
|
|
494
|
+
if (!Number.isInteger(seconds) || seconds < 0 || seconds > IDENTIFY_MAX_SECONDS) {
|
|
495
|
+
throw new Invalid(`identify runs 1 to ${IDENTIFY_MAX_SECONDS} seconds, or 0 to stop`);
|
|
496
|
+
}
|
|
497
|
+
return this.#command("identify", seconds);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Ask for the converter's registers, raw, for debugging; read only, and only while not streaming. `protocol.decodeConverterRegisters` reads the answer. */
|
|
501
|
+
converterRegisters(): Command {
|
|
502
|
+
this.#require("converter_registers");
|
|
503
|
+
return this.#command("get_converter_registers");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Ask the device for its encoder's output for each window while it
|
|
508
|
+
* streams: "window" for the one vector a head consumes, "tokens" for the
|
|
509
|
+
* per-slice vectors reconstruction consumes, "both", or "off". Whole
|
|
510
|
+
* windows come from `protocol.EmbeddingAssembler` fed with embedding events.
|
|
511
|
+
*/
|
|
512
|
+
setEmbeddings(form: EmbeddingFormName): Command {
|
|
513
|
+
this.#require("embeddings");
|
|
514
|
+
const code = EMBEDDING_FORMS[form];
|
|
515
|
+
if (code === undefined) throw new Invalid(`${form} is not an embedding form this contract defines`);
|
|
516
|
+
return this.#command("set_embeddings", code);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --- 1.3: the model's cadence and the device's name -----------------------
|
|
520
|
+
|
|
521
|
+
/** Ask how often the model describes a window and the least interval the device keeps; `protocol.decodeModelInterval` reads the answer. */
|
|
522
|
+
modelInterval(): Command {
|
|
523
|
+
this.#require("model_cadence");
|
|
524
|
+
return this.#command("get_model_interval");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* One described window every `seconds`, on a grid from the stream's start;
|
|
529
|
+
* 0 is every window the device can. The device refuses a value below its
|
|
530
|
+
* minimum and keeps the setting across power cycles.
|
|
531
|
+
*/
|
|
532
|
+
setModelInterval(seconds: number): Command {
|
|
533
|
+
this.#require("model_cadence");
|
|
534
|
+
return this.#command("set_model_interval", encodeModelInterval(seconds));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Ask for the name and the adjective a host set; `protocol.decodeNameParts` reads the answer. */
|
|
538
|
+
getName(): Command {
|
|
539
|
+
this.#requireHigh("device_name");
|
|
540
|
+
return this.#command("get_name");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Name the device. It advertises `name's adjective IntoMind One` from the
|
|
545
|
+
* next advertisement and keeps both parts across power cycles. The
|
|
546
|
+
* composition must fit the 29 bytes a scan response carries, so every scan
|
|
547
|
+
* list shows the whole of it; refused here when it does not, or when a
|
|
548
|
+
* part has a leading or trailing space or a control character.
|
|
549
|
+
*/
|
|
550
|
+
setName(name: string, adjective: string, product: string = PRODUCT_NAME): Command {
|
|
551
|
+
this.#requireHigh("device_name");
|
|
552
|
+
if (!nameFits(name, adjective, product)) {
|
|
553
|
+
throw new Invalid(`"${composeName(name, adjective, product)}" does not fit the ${NAME_MAX_COMPOSED} bytes the air carries, or a part has a leading or trailing space or a control character`);
|
|
554
|
+
}
|
|
555
|
+
return this.#command("set_name", encodeNameParts(name, adjective));
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- notifications --------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Hand in one notification, and take whatever it meant.
|
|
562
|
+
*
|
|
563
|
+
* A batch that follows a break is preceded by the gap, so a caller that
|
|
564
|
+
* writes samples down writes the gap down first and can never splice.
|
|
565
|
+
*/
|
|
566
|
+
onNotification(characteristic: number, bytes: Uint8Array): Event[] {
|
|
567
|
+
switch (characteristic) {
|
|
568
|
+
case FILL.EEG_DATA:
|
|
569
|
+
return this.#onSamples(bytes);
|
|
570
|
+
case FILL.STATUS:
|
|
571
|
+
return this.#decoded(characteristic, () => ({ type: "status", status: decodeStatus(bytes) }));
|
|
572
|
+
case FILL.CONTROL_RESPONSE:
|
|
573
|
+
return this.#decoded(characteristic, () => ({ type: "answer", answer: decodeResponse(bytes) }));
|
|
574
|
+
case FILL.PREDICTIONS:
|
|
575
|
+
return this.#decoded(characteristic, () => ({
|
|
576
|
+
type: "prediction",
|
|
577
|
+
prediction: decodePrediction(bytes),
|
|
578
|
+
}));
|
|
579
|
+
case FILL.EMBEDDINGS:
|
|
580
|
+
return this.#decoded(characteristic, () => ({
|
|
581
|
+
type: "embedding",
|
|
582
|
+
embedding: decodeEmbedding(bytes),
|
|
583
|
+
}));
|
|
584
|
+
default:
|
|
585
|
+
return this.#undecodable(characteristic);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#decoded(characteristic: number, decode: () => Event): Event[] {
|
|
590
|
+
try {
|
|
591
|
+
return [decode()];
|
|
592
|
+
} catch {
|
|
593
|
+
return this.#undecodable(characteristic);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
#undecodable(characteristic: number): Event[] {
|
|
598
|
+
this.undecodable += 1;
|
|
599
|
+
return [{ type: "undecodable", characteristic }];
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
#onSamples(bytes: Uint8Array): Event[] {
|
|
603
|
+
const info = this.#info;
|
|
604
|
+
// A device that has not said how wide it is cannot have its samples
|
|
605
|
+
// read, because the payload length only means something against a
|
|
606
|
+
// channel count.
|
|
607
|
+
if (info === null) return this.#undecodable(FILL.EEG_DATA);
|
|
608
|
+
let packet;
|
|
609
|
+
try {
|
|
610
|
+
packet = decodePacket(bytes, info.channels);
|
|
611
|
+
} catch {
|
|
612
|
+
return this.#undecodable(FILL.EEG_DATA);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const events: Event[] = [];
|
|
616
|
+
const previous = this.#previous;
|
|
617
|
+
// A packet with the first index and the device time of one just passed
|
|
618
|
+
// on is that packet again, delivered twice by the host. Dropped and
|
|
619
|
+
// counted, so no sample is passed on twice and no break is announced
|
|
620
|
+
// that did not happen. A device that restarts its count carries a new
|
|
621
|
+
// device time, so a restart is never taken for a copy, and anything else
|
|
622
|
+
// is judged by the contract's continuity rule unchanged.
|
|
623
|
+
const key = `${packet.index}:${packet.deviceTime}`;
|
|
624
|
+
if (this.#recent.includes(key)) {
|
|
625
|
+
this.duplicates += 1;
|
|
626
|
+
return [{ type: "duplicate", index: packet.index }];
|
|
627
|
+
}
|
|
628
|
+
this.#recent.push(key);
|
|
629
|
+
if (this.#recent.length > 8) this.#recent.shift();
|
|
630
|
+
if (previous !== null) {
|
|
631
|
+
const verdict = continuity(previous.index, previous.nSamples, packet.index, packet.discontinuity);
|
|
632
|
+
if (verdict.broken) {
|
|
633
|
+
if (verdict.lost === null) this.rebases += 1;
|
|
634
|
+
else this.samplesLost += verdict.lost;
|
|
635
|
+
events.push({
|
|
636
|
+
type: "gap",
|
|
637
|
+
gap: {
|
|
638
|
+
lastIndexBefore: (previous.index + previous.nSamples - 1) >>> 0,
|
|
639
|
+
firstIndexAfter: packet.index,
|
|
640
|
+
samplesLost: verdict.lost,
|
|
641
|
+
deviceTimeBefore: previous.deviceTime,
|
|
642
|
+
deviceTimeAfter: packet.deviceTime,
|
|
643
|
+
isRebase: verdict.lost === null,
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
this.#previous = { index: packet.index, nSamples: packet.nSamples, deviceTime: packet.deviceTime };
|
|
649
|
+
|
|
650
|
+
const counts = new Int32Array(packet.nSamples * info.channels);
|
|
651
|
+
for (let row = 0; row < packet.nSamples; row++) {
|
|
652
|
+
for (let c = 0; c < info.channels; c++) counts[row * info.channels + c] = packet.counts[row]![c]!;
|
|
653
|
+
}
|
|
654
|
+
events.push({
|
|
655
|
+
type: "samples",
|
|
656
|
+
batch: new Batch({
|
|
657
|
+
index: packet.index,
|
|
658
|
+
deviceTime: packet.deviceTime,
|
|
659
|
+
channels: info.channels,
|
|
660
|
+
counts,
|
|
661
|
+
leadoff: packet.loffStatp,
|
|
662
|
+
gain: packet.gain,
|
|
663
|
+
sampleRateHz: packet.sps,
|
|
664
|
+
mode: packet.mode,
|
|
665
|
+
synthetic: packet.synthetic,
|
|
666
|
+
}),
|
|
667
|
+
});
|
|
668
|
+
return events;
|
|
669
|
+
}
|
|
670
|
+
}
|