@makeitnow/jumpitnow 0.0.0-stage → 0.1.0-alpha.3

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +367 -2
  3. package/dist/cjs/index.d.ts +6 -0
  4. package/dist/cjs/index.js +29 -0
  5. package/dist/cjs/internal/errors.d.ts +15 -0
  6. package/dist/cjs/internal/errors.js +44 -0
  7. package/dist/cjs/internal/gateway.d.ts +41 -0
  8. package/dist/cjs/internal/gateway.js +586 -0
  9. package/dist/cjs/internal/link.d.ts +13 -0
  10. package/dist/cjs/internal/link.js +111 -0
  11. package/dist/cjs/internal/protocol.d.ts +29 -0
  12. package/dist/cjs/internal/protocol.js +38 -0
  13. package/dist/cjs/internal/simulator.d.ts +15 -0
  14. package/dist/cjs/internal/simulator.js +117 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/cjs/types.d.ts +182 -0
  17. package/dist/cjs/types.js +2 -0
  18. package/dist/esm/index.d.ts +6 -0
  19. package/dist/esm/index.js +22 -0
  20. package/dist/esm/internal/errors.d.ts +15 -0
  21. package/dist/esm/internal/errors.js +37 -0
  22. package/dist/esm/internal/gateway.d.ts +41 -0
  23. package/dist/esm/internal/gateway.js +582 -0
  24. package/dist/esm/internal/link.d.ts +13 -0
  25. package/dist/esm/internal/link.js +106 -0
  26. package/dist/esm/internal/protocol.d.ts +29 -0
  27. package/dist/esm/internal/protocol.js +32 -0
  28. package/dist/esm/internal/simulator.d.ts +15 -0
  29. package/dist/esm/internal/simulator.js +113 -0
  30. package/dist/esm/types.d.ts +182 -0
  31. package/dist/esm/types.js +1 -0
  32. package/docs/API.md +106 -0
  33. package/docs/COMPATIBILITY.md +11 -0
  34. package/docs/SECURITY.md +17 -0
  35. package/docs/index.html +30 -0
  36. package/examples/browser/app.js +104 -0
  37. package/examples/browser/index.html +37 -0
  38. package/examples/react/GatewayPanel.tsx +66 -0
  39. package/package.json +55 -4
@@ -0,0 +1,582 @@
1
+ import { SDKError, asError, fail, integer } from './errors.js';
2
+ import { parse, u16 } from './protocol.js';
3
+ export const VERSION = '0.1.0-alpha.3';
4
+ const INTERFACE = '1';
5
+ const clone = (value) => {
6
+ try {
7
+ return structuredClone(value);
8
+ }
9
+ catch {
10
+ throw new SDKError('INVALID_ARGUMENT');
11
+ }
12
+ };
13
+ const signals = ['vibrate', 'buzzer', 'displayId'];
14
+ export class GatewayEngine {
15
+ #link;
16
+ #simulated;
17
+ #options;
18
+ #state = 'disconnected';
19
+ #listeners = new Map();
20
+ #devices = new Map();
21
+ #pending = null;
22
+ #active = null;
23
+ #activeBackground = false;
24
+ #queued = false;
25
+ #connection = null;
26
+ #disconnecting = null;
27
+ #closing = false;
28
+ #blocked = new Set();
29
+ #owned = new Set();
30
+ #recent = [];
31
+ #poll = null;
32
+ #pollIndex = 0;
33
+ #reads = new Map();
34
+ #scanSeen = null;
35
+ #cancelPause = null;
36
+ #counter = 0;
37
+ #prefix = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
38
+ constructor(link, options = {}, simulated = false) {
39
+ this.#link = link;
40
+ this.#simulated = simulated;
41
+ if (!options || typeof options !== 'object' || Array.isArray(options))
42
+ fail('INVALID_ARGUMENT');
43
+ if (options.identifySignals && (!Array.isArray(options.identifySignals) || options.identifySignals.some(v => !signals.includes(v))))
44
+ fail('INVALID_ARGUMENT');
45
+ this.#options = {
46
+ requestTimeoutMs: integer(options.requestTimeoutMs ?? 1500, 50, 30000),
47
+ scanWindowMs: integer(options.scanWindowMs ?? 2000, 50, 30000),
48
+ pollIntervalMs: integer(options.pollIntervalMs ?? 250, 20, 30000),
49
+ staleAfterMs: integer(options.staleAfterMs ?? 5000, 50, 300000),
50
+ identifySignals: [...new Set(options.identifySignals ?? [])]
51
+ };
52
+ }
53
+ #id(prefix) { return `${prefix}-${this.#prefix}-${++this.#counter}`; }
54
+ #emit(name, value) {
55
+ for (const listener of this.#listeners.get(name) ?? []) {
56
+ try {
57
+ listener(clone(value));
58
+ }
59
+ catch { /* A UI callback must not break transport cleanup or other listeners. */ }
60
+ }
61
+ }
62
+ on(name, handler) {
63
+ if (!['measurement', 'deviceChanged', 'connectionChanged', 'error'].includes(name) || typeof handler !== 'function')
64
+ fail('INVALID_ARGUMENT');
65
+ if (!this.#listeners.has(name))
66
+ this.#listeners.set(name, new Set());
67
+ const listeners = this.#listeners.get(name);
68
+ const callback = handler;
69
+ listeners.add(callback);
70
+ return () => { listeners.delete(callback); };
71
+ }
72
+ #change(state) {
73
+ const previousState = this.#state;
74
+ this.#state = state;
75
+ if (state !== previousState)
76
+ this.#emit('connectionChanged', { state, previousState });
77
+ }
78
+ #deviceState(device, state) {
79
+ const previousState = device.public.state;
80
+ device.public.state = state;
81
+ if (previousState !== state)
82
+ this.#emit('deviceChanged', { device: device.public, previousState });
83
+ }
84
+ #lost() {
85
+ if (this.#closing || this.#state === 'lost' || this.#state === 'disconnected')
86
+ return;
87
+ this.#change('lost');
88
+ this.#stopPoll();
89
+ this.#pending?.cancel(new SDKError('CONNECTION_LOST'));
90
+ this.#cancelPause?.();
91
+ for (const device of this.#devices.values())
92
+ this.#deviceState(device, this.#owned.has(device.public.deviceId) ? 'unknown' : 'offline');
93
+ this.#emit('error', new SDKError('CONNECTION_LOST').toJSON());
94
+ }
95
+ #receive(line) {
96
+ const frame = parse(line);
97
+ if (!frame)
98
+ return;
99
+ if (frame.kind === 'rx' && frame.bytes[0] === 1 && frame.bytes.length === 2)
100
+ this.#scanSeen?.set(frame.endpoint, frame.number);
101
+ this.#pending?.receive(frame);
102
+ }
103
+ #request(command, match, timeoutMs = this.#options.requestTimeoutMs, key) {
104
+ if (this.#pending)
105
+ return Promise.reject(new SDKError('BUSY'));
106
+ if (key && this.#blocked.has(key))
107
+ return Promise.reject(new SDKError('INVALID_STATE'));
108
+ const requestId = this.#id('request');
109
+ const startedAt = Date.now();
110
+ return new Promise((resolve, reject) => {
111
+ let settled = false;
112
+ const finish = (error, value) => {
113
+ if (settled)
114
+ return;
115
+ settled = true;
116
+ clearTimeout(timer);
117
+ this.#pending = null;
118
+ if (key && error && ['TIMEOUT', 'CONNECTION_LOST', 'PROTOCOL_ERROR'].includes(error.code))
119
+ this.#blocked.add(key);
120
+ this.#recent.push({ requestId, durationMs: Date.now() - startedAt, errorCode: error?.code ?? null });
121
+ if (this.#recent.length > 30)
122
+ this.#recent.shift();
123
+ if (error)
124
+ reject(new SDKError(error.code, { requestId }));
125
+ else
126
+ resolve(value);
127
+ };
128
+ const timer = setTimeout(() => finish(new SDKError('TIMEOUT')), timeoutMs);
129
+ this.#pending = {
130
+ receive: frame => {
131
+ try {
132
+ if (frame.kind === 'error')
133
+ return finish(new SDKError('DEVICE_REJECTED'));
134
+ const value = match(frame);
135
+ if (value !== undefined)
136
+ finish(null, value);
137
+ }
138
+ catch (error) {
139
+ finish(asError(error));
140
+ }
141
+ },
142
+ cancel: error => finish(error)
143
+ };
144
+ // Subscribe first: some ports deliver their response before write() resolves.
145
+ void this.#link.write(command).catch(() => { finish(new SDKError('CONNECTION_LOST')); this.#lost(); });
146
+ });
147
+ }
148
+ connect() {
149
+ if (this.#state === 'connected')
150
+ return Promise.resolve(this.getInfo());
151
+ if (this.#connection)
152
+ return this.#connection;
153
+ if (this.#closing || this.#state === 'lost')
154
+ return Promise.reject(new SDKError('INVALID_STATE'));
155
+ this.#change('connecting');
156
+ this.#connection = (async () => {
157
+ try {
158
+ await this.#link.open(line => this.#receive(line), () => this.#lost());
159
+ let commands = false;
160
+ let registry = false;
161
+ let targets = false;
162
+ await this.#request('help', frame => {
163
+ if (frame.kind !== 'help')
164
+ return;
165
+ commands ||= frame.text.includes('getData|status|fw');
166
+ registry ||= frame.text.includes('getList|clearList|scanList|refreshList');
167
+ targets ||= frame.text.includes('target=all|0|id|#index|!addr');
168
+ if (commands && registry && targets)
169
+ return true;
170
+ });
171
+ await this.#list();
172
+ if (this.#state === 'lost')
173
+ throw new SDKError('CONNECTION_LOST');
174
+ // Keep unresolved ownership across a reconnect so it cannot be silently overwritten.
175
+ for (const device of this.#devices.values()) {
176
+ device.mode = null;
177
+ this.#deviceState(device, this.#owned.has(device.public.deviceId) ? 'unknown' : 'offline');
178
+ }
179
+ this.#change('connected');
180
+ return this.getInfo();
181
+ }
182
+ catch (error) {
183
+ this.#closing = true;
184
+ await this.#link.close().catch(() => { });
185
+ this.#closing = false;
186
+ this.#change('disconnected');
187
+ const e = asError(error);
188
+ throw e.code === 'TIMEOUT' || e.code === 'DEVICE_REJECTED' ? new SDKError('UNSUPPORTED_GATEWAY') : e;
189
+ }
190
+ finally {
191
+ this.#connection = null;
192
+ }
193
+ })();
194
+ return this.#connection;
195
+ }
196
+ #run(work, background = false) {
197
+ if (this.#state !== 'connected')
198
+ return Promise.reject(new SDKError('NOT_CONNECTED'));
199
+ if (this.#active && this.#activeBackground && !background && !this.#queued && !this.#closing) {
200
+ // An invisible automatic read must not make the teacher's stop button fail BUSY.
201
+ this.#queued = true;
202
+ this.#stopPoll();
203
+ return this.#active.catch(() => { }).then(() => { this.#queued = false; return this.#run(work); });
204
+ }
205
+ if (this.#active || this.#closing)
206
+ return Promise.reject(new SDKError('BUSY'));
207
+ const operation = Promise.resolve().then(() => {
208
+ if (this.#closing || this.#state !== 'connected')
209
+ throw new SDKError('CONNECTION_LOST');
210
+ return work();
211
+ }).finally(() => { this.#active = null; this.#activeBackground = false; this.#schedulePoll(); });
212
+ this.#active = operation;
213
+ this.#activeBackground = background;
214
+ return operation;
215
+ }
216
+ #list() {
217
+ let begun = false;
218
+ const records = [];
219
+ return this.#request('getList', frame => {
220
+ if (frame.kind === 'list' && frame.phase === 'BEGIN') {
221
+ begun = true;
222
+ records.length = 0;
223
+ }
224
+ else if (begun && frame.kind === 'device') {
225
+ if (records.length >= 40)
226
+ throw new SDKError('PROTOCOL_ERROR');
227
+ records.push(frame);
228
+ }
229
+ else if (begun && frame.kind === 'list' && frame.phase === 'END')
230
+ return records;
231
+ });
232
+ }
233
+ #pause(ms) {
234
+ return new Promise((resolve, reject) => {
235
+ const timer = setTimeout(() => { this.#cancelPause = null; resolve(); }, ms);
236
+ this.#cancelPause = () => { clearTimeout(timer); this.#cancelPause = null; reject(new SDKError('CONNECTION_LOST')); };
237
+ });
238
+ }
239
+ scan(options = {}) {
240
+ return this.#run(async () => {
241
+ if (this.#owned.size)
242
+ fail('BUSY');
243
+ if (!options || typeof options !== 'object')
244
+ fail('INVALID_ARGUMENT');
245
+ const windowMs = integer(options.timeoutMs ?? this.#options.scanWindowMs, 50, 30000);
246
+ const seen = new Map();
247
+ this.#scanSeen = seen;
248
+ try {
249
+ await this.#request('discover all', frame => {
250
+ if (frame.kind === 'tx' && frame.target === 'all' && frame.bytes.length === 1 && frame.bytes[0] === 1) {
251
+ if (!frame.ok)
252
+ throw new SDKError('DEVICE_REJECTED');
253
+ return true;
254
+ }
255
+ });
256
+ await this.#pause(windowMs);
257
+ const list = await this.#list();
258
+ for (const device of this.#devices.values())
259
+ this.#deviceState(device, 'offline');
260
+ for (const row of list) {
261
+ if (seen.get(row.endpoint) !== row.number)
262
+ continue;
263
+ let device = [...this.#devices.values()].find(d => d.endpoint === row.endpoint && d.number === row.number);
264
+ if (!device) {
265
+ const deviceId = this.#id('rope');
266
+ device = { endpoint: row.endpoint, number: row.number, mode: null, session: this.#id('session'), segment: 0, last: null,
267
+ public: { deviceId, displayNumber: row.number >= 1 && row.number <= 40 ? row.number : null,
268
+ state: 'available', firmwareVersion: row.version === '0.0.0' ? null : row.version,
269
+ battery: this.#battery(row.battery), simulated: this.#simulated,
270
+ capabilities: { modes: ['free', 'count', 'time'], maxTargetCount: 65535, maxDurationSeconds: 65535,
271
+ identifySignals: [...this.#options.identifySignals], hardwareVerified: false } } };
272
+ this.#devices.set(deviceId, device);
273
+ this.#emit('deviceChanged', { device: device.public, previousState: null });
274
+ }
275
+ else {
276
+ device.mode = null;
277
+ device.session = this.#id('session');
278
+ device.segment = 0;
279
+ device.last = null;
280
+ device.public.firmwareVersion = row.version === '0.0.0' ? null : row.version;
281
+ device.public.battery = this.#battery(row.battery);
282
+ this.#deviceState(device, 'available');
283
+ }
284
+ }
285
+ return { devices: this.getDevices(), status: 'complete', windowMs };
286
+ }
287
+ finally {
288
+ this.#scanSeen = null;
289
+ }
290
+ });
291
+ }
292
+ #device(id) {
293
+ if (typeof id !== 'string' || !this.#devices.has(id))
294
+ fail('INVALID_ARGUMENT');
295
+ return this.#devices.get(id);
296
+ }
297
+ #targets(ids, allowOffline = false) {
298
+ if (!Array.isArray(ids) || ids.length < 1 || ids.length > 40 || new Set(ids).size !== ids.length)
299
+ fail('INVALID_ARGUMENT');
300
+ return ids.map(id => {
301
+ const device = this.#device(id);
302
+ if (!allowOffline && device.public.state === 'offline')
303
+ fail('DEVICE_OFFLINE');
304
+ if (device.public.displayNumber === null || [...this.#devices.values()].filter(d => d.number === device.number && d.public.state !== 'offline').length > 1)
305
+ fail('AMBIGUOUS_DEVICE');
306
+ return device;
307
+ });
308
+ }
309
+ #mode(mode) {
310
+ if (!mode || typeof mode !== 'object')
311
+ fail('INVALID_ARGUMENT');
312
+ switch (mode.type) {
313
+ case 'free': return { mode: { type: 'free' }, command: 'free', code: 16 };
314
+ case 'count': return { mode: { type: 'count', targetCount: integer(mode.targetCount, 1, 65535) }, command: 'count', code: 17 };
315
+ case 'time': return { mode: { type: 'time', durationSeconds: integer(mode.durationSeconds, 1, 65535) }, command: 'time', code: 18 };
316
+ default: return fail('INVALID_ARGUMENT');
317
+ }
318
+ }
319
+ #assertUnblocked(devices, code) {
320
+ if (devices.some(d => this.#blocked.has(`${d.endpoint}/${code}`)))
321
+ fail('INVALID_STATE');
322
+ }
323
+ async #exchange(device, command, code, parameter, timeoutMs) {
324
+ const target = `!${device.endpoint}`;
325
+ const data = await this.#request(`${command} ${target}${parameter === undefined ? '' : ` ${parameter}`}`, frame => {
326
+ if (frame.kind === 'tx' && frame.endpoint === device.endpoint && frame.bytes[0] === code && !frame.ok)
327
+ throw new SDKError('DEVICE_REJECTED');
328
+ if (frame.kind !== 'rx' || frame.endpoint !== device.endpoint || frame.number !== device.number)
329
+ return;
330
+ if (frame.bytes[0] === 127 && frame.bytes.length >= 4 && frame.bytes[3] === code)
331
+ throw new SDKError('DEVICE_REJECTED');
332
+ if (frame.bytes[0] !== code)
333
+ return;
334
+ const expectedLength = code === 21 ? 9 : code === 6 ? 3 : 2;
335
+ if (frame.bytes.length !== expectedLength)
336
+ throw new SDKError('PROTOCOL_ERROR');
337
+ if (code === 6 && frame.bytes[2] > 100)
338
+ throw new SDKError('PROTOCOL_ERROR');
339
+ return frame.bytes;
340
+ }, timeoutMs, `${device.endpoint}/${code}`);
341
+ return data;
342
+ }
343
+ async #command(device, command, code, parameter, cleanup = false) {
344
+ if (this.#state !== 'connected' || (this.#closing && !cleanup))
345
+ return { deviceId: device.public.deviceId, status: 'failed', errorCode: 'CONNECTION_LOST' };
346
+ try {
347
+ await this.#exchange(device, command, code, parameter);
348
+ return { deviceId: device.public.deviceId, status: 'acknowledged' };
349
+ }
350
+ catch (error) {
351
+ const e = asError(error);
352
+ const uncertain = ['TIMEOUT', 'CONNECTION_LOST', 'PROTOCOL_ERROR'].includes(e.code);
353
+ if (uncertain)
354
+ this.#deviceState(device, 'unknown');
355
+ return { deviceId: device.public.deviceId, status: uncertain ? 'unknown' : 'failed', errorCode: e.code };
356
+ }
357
+ }
358
+ #result(results) { return { requestId: this.#id('operation'), ok: results.every(r => r.status === 'acknowledged'), results }; }
359
+ async configure(options) {
360
+ // Snapshot caller-owned input before asynchronous execution.
361
+ const input = clone(options);
362
+ return this.#run(async () => {
363
+ const devices = this.#targets(input?.deviceIds);
364
+ const mode = this.#mode(input?.mode);
365
+ if (devices.some(d => this.#owned.has(d.public.deviceId)))
366
+ fail('BUSY');
367
+ this.#assertUnblocked(devices, mode.code);
368
+ const parameter = mode.mode.type === 'count' ? mode.mode.targetCount : mode.mode.type === 'time' ? mode.mode.durationSeconds : undefined;
369
+ const results = [];
370
+ for (const device of devices) {
371
+ const result = await this.#command(device, mode.command, mode.code, parameter);
372
+ device.mode = result.status === 'acknowledged' ? clone(mode.mode) : null;
373
+ if (result.status === 'acknowledged')
374
+ this.#deviceState(device, 'available');
375
+ results.push(result);
376
+ }
377
+ return this.#result(results);
378
+ });
379
+ }
380
+ async start(options) {
381
+ const ids = clone(options?.deviceIds);
382
+ return this.#run(async () => {
383
+ const devices = this.#targets(ids);
384
+ if (devices.some(d => this.#owned.has(d.public.deviceId)))
385
+ fail('BUSY');
386
+ if (devices.some(d => !d.mode || d.public.state !== 'available'))
387
+ fail('INVALID_STATE');
388
+ this.#assertUnblocked(devices, 19);
389
+ // A rollback must remain possible before admitting a new start.
390
+ this.#assertUnblocked(devices, 20);
391
+ const results = [];
392
+ for (const device of devices) {
393
+ const result = await this.#command(device, 'start', 19);
394
+ results.push(result);
395
+ if (result.status !== 'failed') {
396
+ this.#owned.add(device.public.deviceId);
397
+ device.session = this.#id('session');
398
+ device.segment = 0;
399
+ device.last = null;
400
+ this.#deviceState(device, result.status === 'acknowledged' ? 'running' : 'unknown');
401
+ }
402
+ }
403
+ if (results.some(r => r.status !== 'acknowledged')) {
404
+ for (const result of results) {
405
+ if (result.status === 'failed')
406
+ continue;
407
+ const recovery = await this.#stopOne(this.#device(result.deviceId), false);
408
+ result.recovery = { action: 'stop', status: recovery.status };
409
+ }
410
+ }
411
+ return this.#result(results);
412
+ });
413
+ }
414
+ async #stopOne(device, finalRead) {
415
+ const result = await this.#command(device, 'stop', 20, undefined, true);
416
+ if (result.status === 'acknowledged') {
417
+ this.#owned.delete(device.public.deviceId);
418
+ this.#deviceState(device, 'available');
419
+ }
420
+ else if (this.#owned.has(device.public.deviceId))
421
+ this.#deviceState(device, 'unknown');
422
+ if (finalRead) {
423
+ let measurement = null;
424
+ if (result.status === 'acknowledged') {
425
+ try {
426
+ measurement = await this.#measure(device);
427
+ }
428
+ catch { /* Stop ACK remains distinct from final data. */ }
429
+ }
430
+ result.finalMeasurement = { available: measurement !== null, measurement };
431
+ }
432
+ return result;
433
+ }
434
+ async stop(options) {
435
+ const ids = clone(options?.deviceIds);
436
+ return this.#run(async () => {
437
+ const devices = this.#targets(ids, true);
438
+ this.#assertUnblocked(devices, 20);
439
+ const results = [];
440
+ for (const device of devices)
441
+ results.push(await this.#stopOne(device, true));
442
+ return this.#result(results);
443
+ });
444
+ }
445
+ async #measure(device, timeoutMs) {
446
+ const data = await this.#exchange(device, 'getData', 21, undefined, timeoutMs);
447
+ const count = u16(data, 3);
448
+ const elapsedMs = u16(data, 5) * 100;
449
+ if (device.last && (count < device.last.count || elapsedMs < device.last.elapsedMs))
450
+ device.segment++;
451
+ const measurement = { deviceId: device.public.deviceId, displayNumber: device.public.displayNumber,
452
+ sessionId: device.session, segmentId: `${device.session}/${device.segment}`, count, elapsedMs,
453
+ pauseCount: u16(data, 7), receivedAt: Date.now(), source: 'live', stale: false, simulated: this.#simulated };
454
+ device.last = measurement;
455
+ this.#emit('measurement', measurement);
456
+ return clone(measurement);
457
+ }
458
+ async readMeasurement(options) {
459
+ return this.#readMeasurement(options);
460
+ }
461
+ async #readMeasurement(options, background = false) {
462
+ const input = clone(options);
463
+ const pending = this.#reads.get(input?.deviceId);
464
+ if (pending)
465
+ return pending.then(clone);
466
+ const result = this.#run(async () => {
467
+ const device = this.#targets([input?.deviceId])[0];
468
+ const timeout = integer(input.timeoutMs ?? this.#options.requestTimeoutMs, 50, 30000);
469
+ try {
470
+ return await this.#measure(device, timeout);
471
+ }
472
+ catch (error) {
473
+ const e = asError(error);
474
+ if (e.code === 'TIMEOUT')
475
+ this.#deviceState(device, 'unknown');
476
+ throw new SDKError(e.code, { deviceId: device.public.deviceId, requestId: e.requestId });
477
+ }
478
+ }, background);
479
+ this.#reads.set(input?.deviceId, result);
480
+ void result.finally(() => { this.#reads.delete(input?.deviceId); }).catch(() => { });
481
+ return result.then(clone);
482
+ }
483
+ getLastMeasurement(options) {
484
+ const device = this.#device(options?.deviceId);
485
+ const last = device.last;
486
+ return last ? { ...last, source: 'cache', stale: this.#state !== 'connected' || device.public.state === 'offline' || device.public.state === 'unknown' || Date.now() - last.receivedAt > this.#options.staleAfterMs } : null;
487
+ }
488
+ #battery(value) { return Number.isInteger(value) && value >= 0 && value <= 100 ? { kind: 'percent', value } : { kind: 'unknown' }; }
489
+ async readBattery(options) {
490
+ const input = clone(options);
491
+ return this.#run(async () => {
492
+ const device = this.#targets([input?.deviceId])[0];
493
+ const timeout = integer(input.timeoutMs ?? this.#options.requestTimeoutMs, 50, 30000);
494
+ const data = await this.#exchange(device, 'getBattery', 6, undefined, timeout);
495
+ const battery = this.#battery(data[2]);
496
+ if (battery.kind === 'unknown')
497
+ fail('PROTOCOL_ERROR');
498
+ device.public.battery = battery;
499
+ return clone(battery);
500
+ });
501
+ }
502
+ async identify(options) {
503
+ const input = clone(options);
504
+ return this.#run(async () => {
505
+ const device = this.#targets([input?.deviceId])[0];
506
+ if (!this.#options.identifySignals.includes(input.signal))
507
+ fail('UNSUPPORTED_FEATURE');
508
+ if (this.#owned.has(device.public.deviceId))
509
+ fail('BUSY');
510
+ const operation = { vibrate: ['vibrate', 49], buzzer: ['buzzer', 48], displayId: ['showId', 51] };
511
+ const [command, code] = operation[input.signal];
512
+ this.#assertUnblocked([device], code);
513
+ return this.#result([await this.#command(device, command, code)]);
514
+ });
515
+ }
516
+ #stopPoll() { if (this.#poll)
517
+ clearTimeout(this.#poll); this.#poll = null; }
518
+ #schedulePoll() {
519
+ this.#stopPoll();
520
+ if (this.#closing || this.#queued || this.#state !== 'connected' || !this.#owned.size)
521
+ return;
522
+ this.#poll = setTimeout(() => {
523
+ this.#poll = null;
524
+ if (this.#active) {
525
+ this.#schedulePoll();
526
+ return;
527
+ }
528
+ const devices = [...this.#owned].map(id => this.#device(id)).filter(d => !this.#blocked.has(`${d.endpoint}/21`));
529
+ if (!devices.length)
530
+ return;
531
+ const device = devices[this.#pollIndex++ % devices.length];
532
+ void this.#readMeasurement({ deviceId: device.public.deviceId }, true).catch(error => this.#emit('error', asError(error).toJSON()));
533
+ }, this.#options.pollIntervalMs);
534
+ }
535
+ disconnect() {
536
+ if (this.#disconnecting)
537
+ return this.#disconnecting;
538
+ if (this.#connection)
539
+ return Promise.reject(new SDKError('BUSY'));
540
+ if (this.#state === 'disconnected')
541
+ return Promise.resolve({ closed: true, stopResults: null });
542
+ this.#closing = true;
543
+ this.#stopPoll();
544
+ this.#pending?.cancel(new SDKError('CONNECTION_LOST'));
545
+ this.#cancelPause?.();
546
+ this.#disconnecting = (async () => {
547
+ try {
548
+ await this.#active?.catch(() => { });
549
+ const results = [];
550
+ for (const id of [...this.#owned])
551
+ results.push(await this.#stopOne(this.#device(id), false));
552
+ this.#change('disconnecting');
553
+ let closed = true;
554
+ try {
555
+ await this.#link.close();
556
+ }
557
+ catch {
558
+ closed = false;
559
+ this.#emit('error', new SDKError('PORT_UNAVAILABLE').toJSON());
560
+ }
561
+ for (const device of this.#devices.values())
562
+ this.#deviceState(device, this.#owned.has(device.public.deviceId) ? 'unknown' : 'offline');
563
+ this.#change(closed ? 'disconnected' : 'lost');
564
+ return { closed, stopResults: results.length ? this.#result(results) : null };
565
+ }
566
+ finally {
567
+ this.#closing = false;
568
+ this.#disconnecting = null;
569
+ }
570
+ })();
571
+ return this.#disconnecting;
572
+ }
573
+ getDevices() { return [...this.#devices.values()].map(d => clone(d.public)); }
574
+ getInfo() {
575
+ return { connection: this.#state, sdkVersion: VERSION, interfaceVersion: INTERFACE, firmwareVersion: null,
576
+ simulated: this.#simulated, features: ['scan', 'configure', 'start', 'stop', 'measurement', 'battery', ...this.#options.identifySignals] };
577
+ }
578
+ getDiagnostics() {
579
+ return { sdkVersion: VERSION, interfaceVersion: INTERFACE, connection: this.#state, simulated: this.#simulated,
580
+ deviceCount: this.#devices.size, blockedOperationCount: this.#blocked.size, recentRequests: clone(this.#recent) };
581
+ }
582
+ }
@@ -0,0 +1,13 @@
1
+ import type { Support } from '../types.js';
2
+ export interface Link {
3
+ open(line: (line: string) => void, lost: () => void): Promise<void>;
4
+ write(command: string): Promise<void>;
5
+ close(): Promise<void>;
6
+ }
7
+ export declare function getSupport(): Support;
8
+ export declare class BrowserLink implements Link {
9
+ #private;
10
+ open(line: (line: string) => void, lost: () => void): Promise<void>;
11
+ write(command: string): Promise<void>;
12
+ close(): Promise<void>;
13
+ }