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