@nolag/iot 1.0.0 → 1.2.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.
@@ -0,0 +1,1245 @@
1
+ /**
2
+ * Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
3
+ *
4
+ * EventMap is a record of event name → tuple of handler arguments.
5
+ */
6
+ class EventEmitter {
7
+ constructor() {
8
+ this._handlers = new Map();
9
+ }
10
+ on(event, handler) {
11
+ if (!this._handlers.has(event)) {
12
+ this._handlers.set(event, new Set());
13
+ }
14
+ this._handlers.get(event).add(handler);
15
+ return this;
16
+ }
17
+ off(event, handler) {
18
+ if (handler) {
19
+ this._handlers.get(event)?.delete(handler);
20
+ }
21
+ else {
22
+ this._handlers.delete(event);
23
+ }
24
+ return this;
25
+ }
26
+ removeAllListeners() {
27
+ this._handlers.clear();
28
+ return this;
29
+ }
30
+ emit(event, ...args) {
31
+ const handlers = this._handlers.get(event);
32
+ if (!handlers)
33
+ return;
34
+ for (const handler of handlers) {
35
+ try {
36
+ handler(...args);
37
+ }
38
+ catch (e) {
39
+ console.error(`Error in ${String(event)} handler:`, e);
40
+ }
41
+ }
42
+ }
43
+ listenerCount(event) {
44
+ return this._handlers.get(event)?.size ?? 0;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Maps actorTokenId ↔ Device, filtering self.
50
+ */
51
+ class PresenceManager {
52
+ constructor(localActorId) {
53
+ this._devices = new Map();
54
+ this._actorToDeviceId = new Map();
55
+ this._localActorId = localActorId;
56
+ }
57
+ /**
58
+ * Add or update a device from presence data.
59
+ * Returns the Device if it's a remote device, null if it's self.
60
+ */
61
+ addFromPresence(actorTokenId, presence, joinedAt) {
62
+ const isLocal = actorTokenId === this._localActorId;
63
+ // Skip self
64
+ if (isLocal)
65
+ return null;
66
+ const existing = this._actorToDeviceId.get(actorTokenId);
67
+ const deviceId = presence.deviceId || existing || actorTokenId;
68
+ const device = {
69
+ deviceId,
70
+ actorTokenId,
71
+ deviceName: presence.deviceName,
72
+ role: presence.role,
73
+ metadata: presence.metadata,
74
+ joinedAt: joinedAt ?? Date.now(),
75
+ isLocal: false,
76
+ };
77
+ this._devices.set(deviceId, device);
78
+ this._actorToDeviceId.set(actorTokenId, deviceId);
79
+ return device;
80
+ }
81
+ /**
82
+ * Remove a device by actorTokenId.
83
+ * Returns the removed device, or null if not found / is self.
84
+ */
85
+ removeByActorId(actorTokenId) {
86
+ if (actorTokenId === this._localActorId)
87
+ return null;
88
+ const deviceId = this._actorToDeviceId.get(actorTokenId);
89
+ if (!deviceId)
90
+ return null;
91
+ const device = this._devices.get(deviceId) ?? null;
92
+ this._devices.delete(deviceId);
93
+ this._actorToDeviceId.delete(actorTokenId);
94
+ return device;
95
+ }
96
+ /**
97
+ * Get a device by deviceId.
98
+ */
99
+ getDevice(deviceId) {
100
+ return this._devices.get(deviceId);
101
+ }
102
+ /**
103
+ * Get a device by actorTokenId.
104
+ */
105
+ getDeviceByActorId(actorTokenId) {
106
+ const deviceId = this._actorToDeviceId.get(actorTokenId);
107
+ return deviceId ? this._devices.get(deviceId) : undefined;
108
+ }
109
+ /**
110
+ * Get all remote devices.
111
+ */
112
+ getAll() {
113
+ return Array.from(this._devices.values());
114
+ }
115
+ /**
116
+ * Get the devices Map (readonly view).
117
+ */
118
+ get devices() {
119
+ return this._devices;
120
+ }
121
+ /**
122
+ * Clear all tracked devices.
123
+ */
124
+ clear() {
125
+ this._devices.clear();
126
+ this._actorToDeviceId.clear();
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Per-device, per-sensor time-series buffer.
132
+ *
133
+ * Readings are stored under a composite key `${deviceId}:${sensorId}` and
134
+ * bounded to `maxPoints` entries per key (oldest entries are dropped first).
135
+ */
136
+ class TelemetryStore {
137
+ constructor(maxPoints) {
138
+ this._store = new Map();
139
+ this._ids = new Set();
140
+ this._maxPoints = maxPoints;
141
+ }
142
+ // ============ Public API ============
143
+ /**
144
+ * Add a telemetry reading to the store.
145
+ * Returns false if the reading id is a duplicate (idempotent).
146
+ */
147
+ add(reading) {
148
+ if (this._ids.has(reading.id))
149
+ return false;
150
+ const key = this._key(reading.deviceId, reading.sensorId);
151
+ if (!this._store.has(key)) {
152
+ this._store.set(key, []);
153
+ }
154
+ const bucket = this._store.get(key);
155
+ bucket.push(reading);
156
+ // Enforce per-key cap
157
+ if (bucket.length > this._maxPoints) {
158
+ bucket.splice(0, bucket.length - this._maxPoints);
159
+ }
160
+ this._ids.add(reading.id);
161
+ return true;
162
+ }
163
+ /**
164
+ * Retrieve readings, optionally filtered by deviceId and/or sensorId.
165
+ *
166
+ * - No args → all readings across all devices and sensors
167
+ * - deviceId only → all readings for that device across all sensors
168
+ * - deviceId + sensorId → readings for that exact device/sensor pair
169
+ */
170
+ getAll(deviceId, sensorId) {
171
+ if (deviceId !== undefined && sensorId !== undefined) {
172
+ return [...(this._store.get(this._key(deviceId, sensorId)) ?? [])];
173
+ }
174
+ const results = [];
175
+ for (const [key, bucket] of this._store.entries()) {
176
+ if (deviceId !== undefined && !key.startsWith(`${deviceId}:`))
177
+ continue;
178
+ results.push(...bucket);
179
+ }
180
+ return results;
181
+ }
182
+ /**
183
+ * Get the most recent reading for a specific device/sensor pair.
184
+ */
185
+ getLatest(deviceId, sensorId) {
186
+ const bucket = this._store.get(this._key(deviceId, sensorId));
187
+ if (!bucket || bucket.length === 0)
188
+ return undefined;
189
+ return bucket[bucket.length - 1];
190
+ }
191
+ /**
192
+ * Check whether a reading id already exists in the store.
193
+ */
194
+ has(id) {
195
+ return this._ids.has(id);
196
+ }
197
+ /**
198
+ * Total number of readings across all device/sensor buckets.
199
+ */
200
+ get size() {
201
+ let total = 0;
202
+ for (const bucket of this._store.values()) {
203
+ total += bucket.length;
204
+ }
205
+ return total;
206
+ }
207
+ /**
208
+ * Clear all stored readings and ids.
209
+ */
210
+ clear() {
211
+ this._store.clear();
212
+ this._ids.clear();
213
+ }
214
+ // ============ Private ============
215
+ _key(deviceId, sensorId) {
216
+ return `${deviceId}:${sensorId}`;
217
+ }
218
+ }
219
+
220
+ function generateId() {
221
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
222
+ return crypto.randomUUID();
223
+ }
224
+ return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
225
+ }
226
+ function createLogger(prefix, enabled) {
227
+ if (!enabled) {
228
+ return (..._args) => { };
229
+ }
230
+ return (...args) => {
231
+ console.log(`[${prefix}]`, ...args);
232
+ };
233
+ }
234
+ // ============ Filters ============
235
+ /**
236
+ * Build the filter fragment of an emit options object.
237
+ *
238
+ * `filter` wins over `filters`: a publish is routed to exactly one topic, so
239
+ * honouring both would silently drop one of them.
240
+ */
241
+ function filterEmitOptions(opts) {
242
+ if (opts?.filter)
243
+ return { filter: opts.filter };
244
+ if (opts?.filters && opts.filters.length > 0)
245
+ return { filters: opts.filters };
246
+ return {};
247
+ }
248
+ /**
249
+ * Merge OR terms into an existing filter set. AND groups (nested arrays) are
250
+ * preserved as-is — only plain string terms are deduplicated.
251
+ */
252
+ function mergeFilters(existing, add) {
253
+ const simple = new Set();
254
+ const groups = [];
255
+ for (const f of existing) {
256
+ if (typeof f === 'string')
257
+ simple.add(f);
258
+ else
259
+ groups.push(f);
260
+ }
261
+ for (const v of add)
262
+ simple.add(v);
263
+ return [...simple, ...groups];
264
+ }
265
+ /**
266
+ * Drop OR terms from a filter set. AND groups are left untouched — remove
267
+ * those by calling `setFilters` with the set you want.
268
+ */
269
+ function withoutFilters(existing, remove) {
270
+ const drop = new Set(remove);
271
+ return existing.filter((f) => typeof f !== 'string' || !drop.has(f));
272
+ }
273
+ // ============ Wrapper registry ============
274
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
275
+ // one connection would collide on topics, presence and the online lobby.
276
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
277
+ const wrapperRegistry = new WeakMap();
278
+ /** Register a wrapper against a client + appName; warns on collision. */
279
+ function registerWrapper(client, appName, wrapperName) {
280
+ let apps = wrapperRegistry.get(client);
281
+ if (!apps) {
282
+ apps = new Map();
283
+ wrapperRegistry.set(client, apps);
284
+ }
285
+ const existing = apps.get(appName);
286
+ if (existing) {
287
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
288
+ `Use one wrapper per (client, app) — detach the other instance first.`);
289
+ }
290
+ apps.set(appName, wrapperName);
291
+ }
292
+ /** Release a wrapper's (client, appName) registration on detach. */
293
+ function releaseWrapper(client, appName) {
294
+ wrapperRegistry.get(client)?.delete(appName);
295
+ }
296
+
297
+ /**
298
+ * Command dispatch with ack tracking and per-command timeout.
299
+ *
300
+ * Controllers call `send()` to dispatch a command and await the result.
301
+ * Devices call `ack()` to acknowledge receipt and report completion/failure.
302
+ */
303
+ class CommandManager {
304
+ constructor(defaultTimeout) {
305
+ this._pending = new Map();
306
+ this._commands = new Map();
307
+ this._defaultTimeout = defaultTimeout;
308
+ }
309
+ // ============ Public API ============
310
+ /**
311
+ * Dispatch a command to a target device.
312
+ *
313
+ * Returns a Promise that resolves once the device acks with 'acked' or
314
+ * 'completed', or rejects on 'failed' status or timeout.
315
+ */
316
+ send(targetDeviceId, command, params, sentBy, timeout) {
317
+ const id = generateId();
318
+ const now = Date.now();
319
+ const cmd = {
320
+ id,
321
+ targetDeviceId,
322
+ command,
323
+ params,
324
+ status: 'pending',
325
+ sentBy,
326
+ sentAt: now,
327
+ };
328
+ this._commands.set(id, cmd);
329
+ const promise = new Promise((resolve, reject) => {
330
+ const ms = timeout ?? this._defaultTimeout;
331
+ const timer = setTimeout(() => {
332
+ const entry = this._pending.get(id);
333
+ if (!entry)
334
+ return;
335
+ entry.command.status = 'timeout';
336
+ this._commands.set(id, entry.command);
337
+ this._pending.delete(id);
338
+ reject(new Error(`Command "${command}" (${id}) timed out after ${ms}ms`));
339
+ }, ms);
340
+ this._pending.set(id, { command: cmd, resolve, reject, timer });
341
+ });
342
+ // Prevent unhandled rejection warnings when dispose() rejects orphaned commands.
343
+ // Callers who await/catch send() still see the rejection normally.
344
+ promise.catch(() => { });
345
+ return promise;
346
+ }
347
+ /**
348
+ * Acknowledge a command from the device side.
349
+ *
350
+ * `status` must be one of 'acked', 'completed', or 'failed'.
351
+ * Returns the updated DeviceCommand, or null if the command is unknown or
352
+ * already settled.
353
+ */
354
+ ack(commandId, status, result) {
355
+ const entry = this._pending.get(commandId);
356
+ if (!entry)
357
+ return null;
358
+ clearTimeout(entry.timer);
359
+ this._pending.delete(commandId);
360
+ const now = Date.now();
361
+ const cmd = entry.command;
362
+ cmd.status = status;
363
+ if (status === 'acked') {
364
+ cmd.ackedAt = now;
365
+ }
366
+ else if (status === 'completed') {
367
+ cmd.ackedAt = cmd.ackedAt ?? now;
368
+ cmd.completedAt = now;
369
+ cmd.result = result;
370
+ }
371
+ else if (status === 'failed') {
372
+ cmd.ackedAt = cmd.ackedAt ?? now;
373
+ cmd.error = typeof result === 'string' ? result : 'Command failed';
374
+ }
375
+ this._commands.set(commandId, cmd);
376
+ if (status === 'acked' || status === 'completed') {
377
+ entry.resolve(cmd);
378
+ }
379
+ else {
380
+ entry.reject(new Error(cmd.error ?? 'Command failed'));
381
+ }
382
+ return cmd;
383
+ }
384
+ /**
385
+ * Get a command by id (includes settled commands).
386
+ */
387
+ get(commandId) {
388
+ return this._commands.get(commandId);
389
+ }
390
+ /**
391
+ * Get all commands currently in 'pending' status.
392
+ */
393
+ getPending() {
394
+ return Array.from(this._pending.values()).map((e) => e.command);
395
+ }
396
+ /**
397
+ * Clear all pending timers and reject all outstanding promises.
398
+ * Call this when the group or client is torn down.
399
+ */
400
+ dispose() {
401
+ for (const [id, entry] of this._pending.entries()) {
402
+ clearTimeout(entry.timer);
403
+ entry.command.status = 'timeout';
404
+ this._commands.set(id, entry.command);
405
+ entry.reject(new Error(`Command "${entry.command.command}" (${id}) was disposed`));
406
+ }
407
+ this._pending.clear();
408
+ }
409
+ }
410
+
411
+ /** Default app name for NoLag IoT SDK */
412
+ const DEFAULT_APP_NAME = 'iot';
413
+ /** Maximum telemetry readings to retain per device/sensor key */
414
+ const DEFAULT_MAX_TELEMETRY_POINTS = 1000;
415
+ /** Default command acknowledgement timeout in milliseconds */
416
+ const DEFAULT_COMMAND_TIMEOUT = 30000;
417
+ /** Topic name for telemetry readings */
418
+ const TOPIC_TELEMETRY = 'telemetry';
419
+ /** Topic name for dispatched commands */
420
+ const TOPIC_COMMANDS = 'commands';
421
+ /** Topic name for command acknowledgements */
422
+ const TOPIC_CMD_ACK = '_cmd_ack';
423
+ /** Lobby ID for global online presence */
424
+ const LOBBY_ID = 'online';
425
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
426
+ const LOBBY_REFRESH_DELAY_MS = 2000;
427
+
428
+ /**
429
+ * DeviceGroup — a single IoT group for telemetry streaming and command dispatch.
430
+ *
431
+ * Created via `NoLagIoT.joinGroup(name)`. Do not instantiate directly.
432
+ */
433
+ class DeviceGroup extends EventEmitter {
434
+ /** @internal */
435
+ constructor(name, roomContext, localDevice, options, log, isConnected) {
436
+ super();
437
+ this._receivedCommands = new Map();
438
+ // Stored topic handler refs — cleanup removes exactly these, never all
439
+ // handlers for a topic (the client may be shared with other consumers).
440
+ this._onTelemetryRef = null;
441
+ this._onCommandsRef = null;
442
+ this._onCmdAckRef = null;
443
+ /**
444
+ * Filter values applied to the telemetry subscription. Commands and acks are
445
+ * deliberately excluded: they route by deviceId internally.
446
+ */
447
+ this._filters = [];
448
+ this.name = name;
449
+ this._roomContext = roomContext;
450
+ this._localDevice = localDevice;
451
+ this._options = options;
452
+ this._log = log;
453
+ this._isConnected = isConnected;
454
+ this._presenceManager = new PresenceManager(localDevice.actorTokenId);
455
+ this._telemetryStore = new TelemetryStore(options.maxTelemetryPoints);
456
+ this._commandManager = new CommandManager(options.commandTimeout);
457
+ }
458
+ // ============ Public Properties ============
459
+ /** All remote devices currently in this group */
460
+ get devices() {
461
+ return this._presenceManager.devices;
462
+ }
463
+ // ============ Telemetry ============
464
+ /**
465
+ * Publish a telemetry reading from this device.
466
+ */
467
+ sendTelemetry(sensorId, value, opts = {}) {
468
+ const reading = {
469
+ id: generateId(),
470
+ deviceId: this._localDevice.deviceId,
471
+ sensorId,
472
+ value,
473
+ unit: opts.unit,
474
+ tags: opts.tags,
475
+ timestamp: Date.now(),
476
+ isReplay: false,
477
+ };
478
+ this._log('Sending telemetry:', sensorId, '=', value);
479
+ this._roomContext.emit(TOPIC_TELEMETRY, reading, { echo: false, ...filterEmitOptions(opts) });
480
+ // Store locally so the sender also has it in the buffer
481
+ this._telemetryStore.add(reading);
482
+ return reading;
483
+ }
484
+ /**
485
+ * Retrieve buffered telemetry readings, optionally filtered by device/sensor.
486
+ */
487
+ getTelemetry(deviceId, sensorId) {
488
+ return this._telemetryStore.getAll(deviceId, sensorId);
489
+ }
490
+ // ============ Commands ============
491
+ /**
492
+ * Dispatch a command to a target device.
493
+ * Resolves when the device acks the command, rejects on failure or timeout.
494
+ */
495
+ sendCommand(targetDeviceId, command, params) {
496
+ this._log('Sending command:', command, '→', targetDeviceId);
497
+ const promise = this._commandManager.send(targetDeviceId, command, params, this._localDevice.deviceId, this._options.commandTimeout);
498
+ // We need the command id to publish it — grab it from pending after send
499
+ const pending = this._commandManager.getPending();
500
+ const cmd = pending[pending.length - 1];
501
+ if (cmd) {
502
+ this._roomContext.emit(TOPIC_COMMANDS, cmd, { echo: false, filter: targetDeviceId });
503
+ }
504
+ return promise;
505
+ }
506
+ /**
507
+ * Acknowledge a command on the device side.
508
+ * Typically called by the device after receiving a command event.
509
+ */
510
+ ackCommand(commandId, status, result) {
511
+ this._log('Acking command:', commandId, status);
512
+ const ack = {
513
+ commandId,
514
+ status,
515
+ result,
516
+ ackedBy: this._localDevice.deviceId,
517
+ ackedAt: Date.now(),
518
+ };
519
+ // Route ack back to the controller that sent the command
520
+ const cmd = this._receivedCommands.get(commandId) || this._commandManager.get(commandId);
521
+ const ackFilter = cmd?.sentBy;
522
+ this._receivedCommands.delete(commandId);
523
+ this._roomContext.emit(TOPIC_CMD_ACK, ack, { echo: false, ...(ackFilter ? { filter: ackFilter } : {}) });
524
+ // Also settle locally if this device is the one that sent the command
525
+ this._commandManager.ack(commandId, status, result);
526
+ }
527
+ // ============ Devices ============
528
+ /**
529
+ * Get all remote devices in this group.
530
+ */
531
+ getDevices() {
532
+ return this._presenceManager.getAll();
533
+ }
534
+ /**
535
+ * Get a specific device by deviceId.
536
+ */
537
+ getDevice(deviceId) {
538
+ return this._presenceManager.getDevice(deviceId);
539
+ }
540
+ // ============ Filters ============
541
+ /** The filter values currently applied to this group's telemetry. */
542
+ get filters() {
543
+ return [...this._filters];
544
+ }
545
+ /**
546
+ * Replace this group's telemetry filters — only readings published with one
547
+ * of these values are delivered. Use it to watch one site or sensor class
548
+ * instead of every device in the group.
549
+ *
550
+ * Commands and command acks are not affected: those route by deviceId
551
+ * internally, and repointing them would break command delivery.
552
+ *
553
+ * Passing an empty array clears filtering and restores the wildcard
554
+ * subscription, which receives all telemetry.
555
+ *
556
+ * @example
557
+ * ```ts
558
+ * group.setFilters(['site-a']); // one site
559
+ * group.setFilters([['site-a', 'critical']]); // site-a AND critical
560
+ * group.setFilters([]); // all telemetry
561
+ * ```
562
+ */
563
+ setFilters(values) {
564
+ this._filters = [...values];
565
+ // The core types filters as `string[]`, but both its implementation and
566
+ // the wire protocol accept AND groups (nested arrays).
567
+ this._roomContext.setFilters(TOPIC_TELEMETRY, this._filters);
568
+ }
569
+ /** Add filter values to the existing set. Existing AND groups are kept. */
570
+ addFilters(values) {
571
+ this.setFilters(mergeFilters(this._filters, values));
572
+ }
573
+ /**
574
+ * Remove filter values from the existing set. Removing the last value
575
+ * restores the wildcard subscription.
576
+ */
577
+ removeFilters(values) {
578
+ this.setFilters(withoutFilters(this._filters, values));
579
+ }
580
+ // ============ Internal (called by NoLagIoT) ============
581
+ /** @internal Subscribe to all group topics and attach listeners */
582
+ _subscribe(filters) {
583
+ this._log('Group subscribe:', this.name);
584
+ this._filters = filters ? [...filters] : [];
585
+ if (this._filters.length > 0) {
586
+ this._roomContext.subscribe(TOPIC_TELEMETRY, { filters: this._filters });
587
+ }
588
+ else {
589
+ this._roomContext.subscribe(TOPIC_TELEMETRY);
590
+ }
591
+ // Commands: devices subscribe with their deviceId as filter so they only
592
+ // receive commands targeted at them. Controllers subscribe as wildcard
593
+ // to observe all commands.
594
+ if (this._options.role === 'device') {
595
+ this._roomContext.subscribe(TOPIC_COMMANDS, { filters: [this._localDevice.deviceId] });
596
+ }
597
+ else {
598
+ this._roomContext.subscribe(TOPIC_COMMANDS);
599
+ }
600
+ // Acks: controllers subscribe with their deviceId as filter so they only
601
+ // receive acks for commands they sent. Devices subscribe as wildcard.
602
+ if (this._options.role === 'controller') {
603
+ this._roomContext.subscribe(TOPIC_CMD_ACK, { filters: [this._localDevice.deviceId] });
604
+ }
605
+ else {
606
+ this._roomContext.subscribe(TOPIC_CMD_ACK);
607
+ }
608
+ // Listeners: refs stored for handler-specific removal (the client may be
609
+ // shared with other consumers on the same topic).
610
+ this._onTelemetryRef = (data) => {
611
+ this._handleIncomingTelemetry(data);
612
+ };
613
+ this._roomContext.on(TOPIC_TELEMETRY, this._onTelemetryRef);
614
+ this._onCommandsRef = (data) => {
615
+ this._handleIncomingCommand(data);
616
+ };
617
+ this._roomContext.on(TOPIC_COMMANDS, this._onCommandsRef);
618
+ this._onCmdAckRef = (data) => {
619
+ this._handleIncomingCmdAck(data);
620
+ };
621
+ this._roomContext.on(TOPIC_CMD_ACK, this._onCmdAckRef);
622
+ }
623
+ /** @internal Set presence and fetch existing group members */
624
+ _activate() {
625
+ this._log('Group activate:', this.name);
626
+ this._setPresence();
627
+ this._roomContext.fetchPresence().then((actors) => {
628
+ this._log('Group presence fetched:', this.name, actors.length, 'actors');
629
+ for (const actor of actors) {
630
+ if (actor.presence) {
631
+ const device = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
632
+ if (device) {
633
+ this.emit('deviceJoined', device);
634
+ }
635
+ }
636
+ }
637
+ }).catch((err) => {
638
+ this._log('Failed to fetch group presence:', err);
639
+ });
640
+ }
641
+ /** @internal Re-set presence after reconnect */
642
+ _updateLocalPresence() {
643
+ this._setPresence();
644
+ }
645
+ /** @internal Handle a presence:join event */
646
+ _handlePresenceJoin(actorTokenId, presenceData) {
647
+ const device = this._presenceManager.addFromPresence(actorTokenId, presenceData);
648
+ if (device) {
649
+ this._log('Device joined group:', this.name, device.deviceId);
650
+ this.emit('deviceJoined', device);
651
+ }
652
+ }
653
+ /** @internal Handle a presence:leave event */
654
+ _handlePresenceLeave(actorTokenId) {
655
+ const device = this._presenceManager.removeByActorId(actorTokenId);
656
+ if (device) {
657
+ this._log('Device left group:', this.name, device.deviceId);
658
+ this.emit('deviceLeft', device);
659
+ }
660
+ }
661
+ /** @internal Handle a presence:update event */
662
+ _handlePresenceUpdate(actorTokenId, presenceData) {
663
+ this._presenceManager.addFromPresence(actorTokenId, presenceData);
664
+ }
665
+ /** @internal Unsubscribe and clean up */
666
+ _cleanup() {
667
+ this._log('Group cleanup:', this.name);
668
+ // Server unsubscribes need a live socket; skip when disconnected
669
+ // (best-effort — the core would no-op with an error callback anyway).
670
+ if (this._isConnected()) {
671
+ this._roomContext.unsubscribe(TOPIC_TELEMETRY);
672
+ this._roomContext.unsubscribe(TOPIC_COMMANDS);
673
+ this._roomContext.unsubscribe(TOPIC_CMD_ACK);
674
+ }
675
+ // Handler-specific removal only: the client may be shared, and a bare
676
+ // off(topic) would strip other consumers' handlers too.
677
+ if (this._onTelemetryRef)
678
+ this._roomContext.off(TOPIC_TELEMETRY, this._onTelemetryRef);
679
+ if (this._onCommandsRef)
680
+ this._roomContext.off(TOPIC_COMMANDS, this._onCommandsRef);
681
+ if (this._onCmdAckRef)
682
+ this._roomContext.off(TOPIC_CMD_ACK, this._onCmdAckRef);
683
+ this._onTelemetryRef = null;
684
+ this._onCommandsRef = null;
685
+ this._onCmdAckRef = null;
686
+ // Clears all pending command-timeout timers and rejects orphaned commands.
687
+ this._commandManager.dispose();
688
+ this._receivedCommands.clear();
689
+ this._presenceManager.clear();
690
+ this.removeAllListeners();
691
+ }
692
+ // ============ Private ============
693
+ _handleIncomingTelemetry(data) {
694
+ const reading = data;
695
+ this._log('Received telemetry:', reading.sensorId, '=', reading.value, 'from', reading.deviceId);
696
+ const isNew = this._telemetryStore.add(reading);
697
+ if (!isNew)
698
+ return; // duplicate — skip
699
+ this.emit('telemetry', reading);
700
+ }
701
+ _handleIncomingCommand(data) {
702
+ const cmd = data;
703
+ // Controllers observe all commands but don't process them as targets
704
+ if (this._options.role === 'controller')
705
+ return;
706
+ this._log('Received command:', cmd.command, 'from', cmd.sentBy);
707
+ this._receivedCommands.set(cmd.id, cmd);
708
+ this.emit('command', cmd);
709
+ }
710
+ _handleIncomingCmdAck(data) {
711
+ const ack = data;
712
+ this._log('Received command ack:', ack.commandId, ack.status);
713
+ const cmd = this._commandManager.ack(ack.commandId, ack.status, ack.result);
714
+ if (cmd) {
715
+ this.emit('commandAck', cmd);
716
+ }
717
+ }
718
+ _setPresence() {
719
+ const presenceData = {
720
+ deviceId: this._localDevice.deviceId,
721
+ deviceName: this._localDevice.deviceName,
722
+ role: this._localDevice.role,
723
+ metadata: this._localDevice.metadata,
724
+ // Scope tag: on a shared client, other apps' wrappers filter our
725
+ // presence out by this (and we filter theirs).
726
+ __scope: this._options.appName,
727
+ };
728
+ this._roomContext.setPresence(presenceData);
729
+ }
730
+ }
731
+
732
+ /**
733
+ * NoLagIoT — high-level IoT telemetry and command dispatch SDK built on @nolag/js-sdk.
734
+ *
735
+ * Provides device presence, real-time telemetry streaming, and command dispatch
736
+ * with ack tracking — all framework-agnostic via events.
737
+ *
738
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
739
+ * client (shared by any number of wrappers on distinct apps) and the
740
+ * wrapper attaches to it at construction and releases it via `detach()`.
741
+ *
742
+ * @example
743
+ * ```typescript
744
+ * import { NoLag } from '@nolag/js-sdk';
745
+ * import { NoLagIoT } from '@nolag/iot';
746
+ *
747
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
748
+ * const iot = new NoLagIoT({ client, deviceId: 'sensor-01', role: 'device' });
749
+ *
750
+ * iot.on('connected', () => console.log('Connected!'));
751
+ *
752
+ * await client.connect(); // the app owns the connection
753
+ * await iot.ready(); // wrapper setup done (identity, lobby, groups)
754
+ *
755
+ * const group = iot.joinGroup('factory-floor');
756
+ * group.on('command', (cmd) => {
757
+ * group.ackCommand(cmd.id, 'completed', { ok: true });
758
+ * });
759
+ * group.sendTelemetry('temperature', 22.5, { unit: '°C' });
760
+ *
761
+ * iot.detach(); // wrapper releases its handlers and topics
762
+ * client.disconnect(); // the app closes the socket
763
+ * ```
764
+ */
765
+ class NoLagIoT extends EventEmitter {
766
+ constructor(options) {
767
+ super();
768
+ this._localDevice = null;
769
+ this._groups = new Map();
770
+ this._lobby = null;
771
+ this._onlineDevices = new Map();
772
+ this._actorToDeviceId = new Map();
773
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
774
+ this._epoch = 0;
775
+ this._detached = false;
776
+ this._isReady = false;
777
+ this._lobbyRefreshTimer = null;
778
+ // Stored client handler refs. INVARIANT: every client.on() below has a
779
+ // matching client.off() in detach() — never bare off(event), never inline
780
+ // closures on the client.
781
+ this._onConnectRef = () => this._onConnect();
782
+ this._onDisconnectRef = (reason) => {
783
+ this._log('Disconnected:', reason);
784
+ this.emit('disconnected', reason);
785
+ };
786
+ this._onReconnectRef = () => {
787
+ this._log('Reconnecting...');
788
+ this.emit('reconnecting');
789
+ };
790
+ this._onErrorRef = (error) => {
791
+ this._log('Error:', error);
792
+ this.emit('error', error);
793
+ };
794
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
795
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
796
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
797
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
798
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
799
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
800
+ if (!options?.client) {
801
+ throw new TypeError('NoLagIoT requires an injected NoLag client: new NoLagIoT({ client, deviceId, ... })');
802
+ }
803
+ this._client = options.client;
804
+ this._deviceId = options.deviceId ?? generateId();
805
+ this._options = {
806
+ deviceId: this._deviceId,
807
+ deviceName: options.deviceName,
808
+ role: options.role ?? 'device',
809
+ metadata: options.metadata,
810
+ appName: options.appName ?? DEFAULT_APP_NAME,
811
+ maxTelemetryPoints: options.maxTelemetryPoints ?? DEFAULT_MAX_TELEMETRY_POINTS,
812
+ commandTimeout: options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT,
813
+ debug: options.debug ?? false,
814
+ groups: options.groups ?? [],
815
+ };
816
+ this._log = createLogger('NoLagIoT', this._options.debug);
817
+ this._readyPromise = new Promise((resolve, reject) => {
818
+ this._readyResolve = resolve;
819
+ this._readyReject = reject;
820
+ });
821
+ // ready() rejection is only meaningful to callers that await it
822
+ this._readyPromise.catch(() => { });
823
+ registerWrapper(this._client, this._options.appName, 'NoLagIoT');
824
+ // Construction = attach: wire everything now, with stored refs.
825
+ this._client.on('connect', this._onConnectRef);
826
+ this._client.on('disconnect', this._onDisconnectRef);
827
+ this._client.on('reconnect', this._onReconnectRef);
828
+ this._client.on('error', this._onErrorRef);
829
+ this._client.on('presence:join', this._onPresenceJoinRef);
830
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
831
+ this._client.on('presence:update', this._onPresenceUpdateRef);
832
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
833
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
834
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
835
+ // Attach-to-connected: if the client is already authenticated, run setup.
836
+ // The microtask lets the caller wire wrapper event handlers synchronously
837
+ // first; a racing real 'connect' event wins via the epoch guard.
838
+ queueMicrotask(() => {
839
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
840
+ this._onConnect();
841
+ }
842
+ });
843
+ }
844
+ // ============ Public Properties ============
845
+ /** Whether the underlying connection is established (connected ≠ ready) */
846
+ get connected() {
847
+ return !this._detached && this._client.connected;
848
+ }
849
+ /** The injected core client (owned by the app, not the wrapper) */
850
+ get client() {
851
+ return this._client;
852
+ }
853
+ /** The local device info (available after ready) */
854
+ get localDevice() {
855
+ return this._localDevice;
856
+ }
857
+ /** All currently joined groups */
858
+ get groups() {
859
+ return this._groups;
860
+ }
861
+ // ============ Lifecycle ============
862
+ /**
863
+ * Resolves once the wrapper's first setup completed (identity, lobby and
864
+ * configured groups ready — equivalently, once 'connected' has fired).
865
+ * Rejects only if detach() is called before that. Client auth failures
866
+ * surface via the app's own `await client.connect()`, not here.
867
+ */
868
+ ready() {
869
+ return this._readyPromise;
870
+ }
871
+ /**
872
+ * Detach from the client: remove every handler this wrapper added,
873
+ * unsubscribe its topics and lobby (when connected), clear state. Also
874
+ * clears any pending command-timeout timers on every group. Terminal and
875
+ * idempotent; never touches the socket. To use IoT again, construct a new
876
+ * instance.
877
+ */
878
+ detach() {
879
+ if (this._detached)
880
+ return;
881
+ this._log('Detaching...');
882
+ this._detached = true;
883
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
884
+ if (this._lobbyRefreshTimer) {
885
+ clearTimeout(this._lobbyRefreshTimer);
886
+ this._lobbyRefreshTimer = null;
887
+ }
888
+ // Remove all client handlers by stored ref
889
+ this._client.off('connect', this._onConnectRef);
890
+ this._client.off('disconnect', this._onDisconnectRef);
891
+ this._client.off('reconnect', this._onReconnectRef);
892
+ this._client.off('error', this._onErrorRef);
893
+ this._client.off('presence:join', this._onPresenceJoinRef);
894
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
895
+ this._client.off('presence:update', this._onPresenceUpdateRef);
896
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
897
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
898
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
899
+ // Groups: handler-specific off + connected-gated server unsubscribe.
900
+ // _cleanup() also disposes each group's command-timeout timers.
901
+ for (const name of [...this._groups.keys()]) {
902
+ this._groups.get(name)._cleanup();
903
+ this._groups.delete(name);
904
+ }
905
+ // Lobby: server unsubscribe is best-effort and needs a live socket
906
+ if (this._lobby && this._client.connected) {
907
+ try {
908
+ this._lobby.unsubscribe();
909
+ }
910
+ catch {
911
+ /* best-effort */
912
+ }
913
+ }
914
+ this._lobby = null;
915
+ this._onlineDevices.clear();
916
+ this._actorToDeviceId.clear();
917
+ this._localDevice = null;
918
+ releaseWrapper(this._client, this._options.appName);
919
+ if (!this._isReady) {
920
+ this._readyReject(new Error('NoLagIoT detached before ready'));
921
+ }
922
+ }
923
+ // ============ Private: Epoch Setup ============
924
+ _onConnect() {
925
+ this._epoch++;
926
+ void this._runSetup(this._epoch);
927
+ }
928
+ /**
929
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
930
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
931
+ * epoch started or the wrapper detached — checked after every await.
932
+ */
933
+ async _runSetup(epoch) {
934
+ const stale = () => epoch !== this._epoch || this._detached;
935
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
936
+ // Identity (client.actorId is guaranteed post-auth)
937
+ if (!this._localDevice) {
938
+ this._localDevice = {
939
+ deviceId: this._deviceId,
940
+ actorTokenId: this._client.actorId,
941
+ deviceName: this._options.deviceName,
942
+ role: this._options.role,
943
+ metadata: this._options.metadata,
944
+ joinedAt: Date.now(),
945
+ isLocal: true,
946
+ };
947
+ this._log('Local device:', this._localDevice.deviceId, '→', this._localDevice.actorTokenId);
948
+ }
949
+ else {
950
+ this._localDevice.actorTokenId = this._client.actorId;
951
+ }
952
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
953
+ // from the returned snapshot — one path for setup and restore.
954
+ if (!this._lobby) {
955
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
956
+ }
957
+ try {
958
+ const state = await this._lobby.subscribe();
959
+ if (stale())
960
+ return;
961
+ this._diffHydrateOnlineDevices(state);
962
+ this._log('Lobby subscribed, online devices:', this._onlineDevices.size);
963
+ }
964
+ catch (err) {
965
+ if (stale())
966
+ return;
967
+ this._log('Lobby subscription failed:', err);
968
+ }
969
+ if (!this._isReady) {
970
+ // First successful setup: pre-join configured groups.
971
+ for (const groupName of this._options.groups) {
972
+ const group = this._subscribeGroup(groupName);
973
+ group._activate();
974
+ }
975
+ }
976
+ else {
977
+ // Server auto-restored topic subscriptions; only room-scoped presence
978
+ // needs re-applying (the core does not restore it) — persistent-presence
979
+ // semantics across reconnects.
980
+ for (const group of this._groups.values()) {
981
+ group._updateLocalPresence();
982
+ }
983
+ }
984
+ if (stale())
985
+ return;
986
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
987
+ // epoch aborted by a racing reconnect must not strand ready().
988
+ if (!this._isReady) {
989
+ this._isReady = true;
990
+ this._readyResolve();
991
+ this.emit('connected');
992
+ }
993
+ else {
994
+ this.emit('reconnected');
995
+ }
996
+ // Deferred lobby refetch: catches devices who joined during the setup
997
+ // window (e.g. simultaneous multi-tab connects).
998
+ this._scheduleLobbyRefresh(epoch);
999
+ }
1000
+ _scheduleLobbyRefresh(epoch) {
1001
+ if (this._lobbyRefreshTimer)
1002
+ clearTimeout(this._lobbyRefreshTimer);
1003
+ this._lobbyRefreshTimer = setTimeout(() => {
1004
+ this._lobbyRefreshTimer = null;
1005
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
1006
+ return;
1007
+ }
1008
+ this._lobby
1009
+ .fetchPresence()
1010
+ .then((state) => {
1011
+ if (epoch !== this._epoch || this._detached)
1012
+ return;
1013
+ this._diffHydrateOnlineDevices(state);
1014
+ })
1015
+ .catch(() => {
1016
+ /* best-effort */
1017
+ });
1018
+ }, LOBBY_REFRESH_DELAY_MS);
1019
+ }
1020
+ // ============ Group Management ============
1021
+ /**
1022
+ * Join a device group. Creates, subscribes, and activates it.
1023
+ * Returns an existing group if already joined.
1024
+ */
1025
+ joinGroup(name, opts) {
1026
+ this._assertUsable();
1027
+ let group = this._groups.get(name);
1028
+ if (!group) {
1029
+ group = this._subscribeGroup(name, opts?.filters);
1030
+ group._activate();
1031
+ }
1032
+ else if (opts?.filters) {
1033
+ // Already joined — re-point its filters rather than ignoring them.
1034
+ group.setFilters(opts.filters);
1035
+ }
1036
+ return group;
1037
+ }
1038
+ /**
1039
+ * Leave a device group. Fully unsubscribes and removes it.
1040
+ */
1041
+ leaveGroup(name) {
1042
+ const group = this._groups.get(name);
1043
+ if (!group)
1044
+ return;
1045
+ this._log('Leaving group:', name);
1046
+ group._cleanup();
1047
+ this._groups.delete(name);
1048
+ }
1049
+ /**
1050
+ * Get all joined groups.
1051
+ */
1052
+ getGroups() {
1053
+ return Array.from(this._groups.values());
1054
+ }
1055
+ // ============ Global Presence ============
1056
+ /**
1057
+ * Get all devices currently online across all groups.
1058
+ */
1059
+ getOnlineDevices() {
1060
+ return Array.from(this._onlineDevices.values());
1061
+ }
1062
+ // ============ Private: Guards ============
1063
+ _assertUsable() {
1064
+ if (this._detached) {
1065
+ throw new Error('NoLagIoT has been detached — construct a new instance');
1066
+ }
1067
+ if (!this._isReady || !this._localDevice) {
1068
+ throw new Error('NoLagIoT not ready — await ready() or the "connected" event');
1069
+ }
1070
+ }
1071
+ // ============ Private: Group Setup ============
1072
+ _subscribeGroup(name, filters) {
1073
+ this._log('Subscribing group:', name);
1074
+ const roomContext = this._client.setApp(this._options.appName).setRoom(name);
1075
+ const group = new DeviceGroup(name, roomContext, this._localDevice, this._options, createLogger(`DeviceGroup:${name}`, this._options.debug), () => this._client.connected);
1076
+ this._groups.set(name, group);
1077
+ group._subscribe(filters);
1078
+ return group;
1079
+ }
1080
+ // ============ Private: Scope Filtering ============
1081
+ /**
1082
+ * On a shared client, presence events from other apps' wrappers arrive on
1083
+ * the same connection-level events. Wrappers stamp their presence with a
1084
+ * `__scope` (their appName); a mismatched tag means another app's data.
1085
+ * Untagged presence is accepted (older peers in this same app).
1086
+ */
1087
+ _foreignScope(data) {
1088
+ const scope = data?.__scope;
1089
+ return typeof scope === 'string' && scope !== this._options.appName;
1090
+ }
1091
+ // ============ Private: Room Presence ============
1092
+ _handleRoomPresenceJoin(data) {
1093
+ if (data.actorTokenId === this._localDevice?.actorTokenId)
1094
+ return;
1095
+ const presenceData = data.presence;
1096
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
1097
+ return;
1098
+ const device = this._presenceToDevice(data.actorTokenId, presenceData);
1099
+ this._actorToDeviceId.set(data.actorTokenId, device.deviceId);
1100
+ if (!this._onlineDevices.has(device.deviceId)) {
1101
+ this._onlineDevices.set(device.deviceId, device);
1102
+ this.emit('deviceOnline', device);
1103
+ }
1104
+ // Route to all groups
1105
+ for (const group of this._groups.values()) {
1106
+ group._handlePresenceJoin(data.actorTokenId, presenceData);
1107
+ }
1108
+ }
1109
+ _handleRoomPresenceLeave(data) {
1110
+ if (data.actorTokenId === this._localDevice?.actorTokenId)
1111
+ return;
1112
+ // Route to all groups
1113
+ for (const group of this._groups.values()) {
1114
+ group._handlePresenceLeave(data.actorTokenId);
1115
+ }
1116
+ }
1117
+ _handleRoomPresenceUpdate(data) {
1118
+ if (data.actorTokenId === this._localDevice?.actorTokenId)
1119
+ return;
1120
+ const presenceData = data.presence;
1121
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
1122
+ return;
1123
+ if (this._onlineDevices.has(presenceData.deviceId)) {
1124
+ const device = this._presenceToDevice(data.actorTokenId, presenceData);
1125
+ this._onlineDevices.set(device.deviceId, device);
1126
+ }
1127
+ // Route to all groups
1128
+ for (const group of this._groups.values()) {
1129
+ group._handlePresenceUpdate(data.actorTokenId, presenceData);
1130
+ }
1131
+ }
1132
+ // ============ Private: Lobby ============
1133
+ _handleLobbyJoin(event) {
1134
+ const { actorId, data } = event;
1135
+ if (actorId === this._localDevice?.actorTokenId)
1136
+ return;
1137
+ const presenceData = data;
1138
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
1139
+ return;
1140
+ const device = this._presenceToDevice(actorId, presenceData);
1141
+ this._actorToDeviceId.set(actorId, device.deviceId);
1142
+ if (!this._onlineDevices.has(device.deviceId)) {
1143
+ this._onlineDevices.set(device.deviceId, device);
1144
+ this.emit('deviceOnline', device);
1145
+ }
1146
+ }
1147
+ _handleLobbyLeave(event) {
1148
+ const { actorId, data } = event;
1149
+ if (actorId === this._localDevice?.actorTokenId)
1150
+ return;
1151
+ const presenceData = data;
1152
+ if (this._foreignScope(presenceData))
1153
+ return;
1154
+ const deviceId = presenceData?.deviceId
1155
+ || this._actorToDeviceId.get(actorId)
1156
+ || this._findDeviceIdByActorId(actorId);
1157
+ if (deviceId) {
1158
+ const device = this._onlineDevices.get(deviceId);
1159
+ if (device) {
1160
+ this._onlineDevices.delete(deviceId);
1161
+ this._actorToDeviceId.delete(actorId);
1162
+ this.emit('deviceOffline', device);
1163
+ }
1164
+ }
1165
+ }
1166
+ _handleLobbyUpdate(event) {
1167
+ const { actorId, data } = event;
1168
+ if (actorId === this._localDevice?.actorTokenId)
1169
+ return;
1170
+ const presenceData = data;
1171
+ if (!presenceData?.deviceId || this._foreignScope(presenceData))
1172
+ return;
1173
+ const device = this._presenceToDevice(actorId, presenceData);
1174
+ this._onlineDevices.set(device.deviceId, device);
1175
+ }
1176
+ /**
1177
+ * Reconcile the online-device map against a fresh lobby snapshot, emitting
1178
+ * only the deltas (deviceOffline for vanished, deviceOnline for new). One
1179
+ * path for initial hydration, reconnect restore, and the deferred refetch.
1180
+ */
1181
+ _diffHydrateOnlineDevices(state) {
1182
+ // Build the fresh device set from the snapshot
1183
+ const fresh = new Map();
1184
+ const freshActors = new Map();
1185
+ for (const roomId of Object.keys(state)) {
1186
+ const roomPresence = state[roomId];
1187
+ for (const actorId of Object.keys(roomPresence)) {
1188
+ if (actorId === this._localDevice?.actorTokenId)
1189
+ continue;
1190
+ const raw = roomPresence[actorId];
1191
+ // Server returns full actor records with presence nested under .presence
1192
+ const presenceData = (raw?.presence ?? raw);
1193
+ if (presenceData?.deviceId && !this._foreignScope(presenceData)) {
1194
+ if (!fresh.has(presenceData.deviceId)) {
1195
+ fresh.set(presenceData.deviceId, this._presenceToDevice(actorId, presenceData));
1196
+ }
1197
+ freshActors.set(actorId, presenceData.deviceId);
1198
+ }
1199
+ }
1200
+ }
1201
+ // Vanished devices
1202
+ for (const [deviceId, device] of [...this._onlineDevices]) {
1203
+ if (!fresh.has(deviceId)) {
1204
+ this._onlineDevices.delete(deviceId);
1205
+ for (const [actorId, mappedDeviceId] of [...this._actorToDeviceId]) {
1206
+ if (mappedDeviceId === deviceId)
1207
+ this._actorToDeviceId.delete(actorId);
1208
+ }
1209
+ this.emit('deviceOffline', device);
1210
+ }
1211
+ }
1212
+ // New devices
1213
+ for (const [deviceId, device] of fresh) {
1214
+ if (!this._onlineDevices.has(deviceId)) {
1215
+ this._onlineDevices.set(deviceId, device);
1216
+ this.emit('deviceOnline', device);
1217
+ }
1218
+ }
1219
+ for (const [actorId, deviceId] of freshActors) {
1220
+ this._actorToDeviceId.set(actorId, deviceId);
1221
+ }
1222
+ }
1223
+ // ============ Private: Helpers ============
1224
+ _presenceToDevice(actorTokenId, data) {
1225
+ return {
1226
+ deviceId: data.deviceId,
1227
+ actorTokenId,
1228
+ deviceName: data.deviceName,
1229
+ role: data.role,
1230
+ metadata: data.metadata,
1231
+ joinedAt: Date.now(),
1232
+ isLocal: false,
1233
+ };
1234
+ }
1235
+ _findDeviceIdByActorId(actorTokenId) {
1236
+ for (const device of this._onlineDevices.values()) {
1237
+ if (device.actorTokenId === actorTokenId)
1238
+ return device.deviceId;
1239
+ }
1240
+ return undefined;
1241
+ }
1242
+ }
1243
+
1244
+ export { CommandManager, DeviceGroup, EventEmitter, NoLagIoT, PresenceManager, TelemetryStore };
1245
+ //# sourceMappingURL=react-native.js.map