@nolag/iot 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/dist/CommandManager.d.ts +41 -0
- package/dist/DeviceGroup.d.ts +68 -0
- package/dist/EventEmitter.d.ts +15 -0
- package/dist/NoLagIoT.d.ts +86 -0
- package/dist/PresenceManager.d.ts +40 -0
- package/dist/TelemetryStore.d.ts +43 -0
- package/dist/browser.d.ts +10 -0
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/index.cjs +975 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.mjs +968 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +57 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
import { NoLag } from '@nolag/js-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
5
|
+
*
|
|
6
|
+
* EventMap is a record of event name → tuple of handler arguments.
|
|
7
|
+
*/
|
|
8
|
+
class EventEmitter {
|
|
9
|
+
constructor() {
|
|
10
|
+
this._handlers = new Map();
|
|
11
|
+
}
|
|
12
|
+
on(event, handler) {
|
|
13
|
+
if (!this._handlers.has(event)) {
|
|
14
|
+
this._handlers.set(event, new Set());
|
|
15
|
+
}
|
|
16
|
+
this._handlers.get(event).add(handler);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
off(event, handler) {
|
|
20
|
+
if (handler) {
|
|
21
|
+
this._handlers.get(event)?.delete(handler);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this._handlers.delete(event);
|
|
25
|
+
}
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
removeAllListeners() {
|
|
29
|
+
this._handlers.clear();
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
emit(event, ...args) {
|
|
33
|
+
const handlers = this._handlers.get(event);
|
|
34
|
+
if (!handlers)
|
|
35
|
+
return;
|
|
36
|
+
for (const handler of handlers) {
|
|
37
|
+
try {
|
|
38
|
+
handler(...args);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
listenerCount(event) {
|
|
46
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Maps actorTokenId ↔ Device, filtering self.
|
|
52
|
+
*/
|
|
53
|
+
class PresenceManager {
|
|
54
|
+
constructor(localActorId) {
|
|
55
|
+
this._devices = new Map();
|
|
56
|
+
this._actorToDeviceId = new Map();
|
|
57
|
+
this._localActorId = localActorId;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Add or update a device from presence data.
|
|
61
|
+
* Returns the Device if it's a remote device, null if it's self.
|
|
62
|
+
*/
|
|
63
|
+
addFromPresence(actorTokenId, presence, joinedAt) {
|
|
64
|
+
const isLocal = actorTokenId === this._localActorId;
|
|
65
|
+
// Skip self
|
|
66
|
+
if (isLocal)
|
|
67
|
+
return null;
|
|
68
|
+
const existing = this._actorToDeviceId.get(actorTokenId);
|
|
69
|
+
const deviceId = presence.deviceId || existing || actorTokenId;
|
|
70
|
+
const device = {
|
|
71
|
+
deviceId,
|
|
72
|
+
actorTokenId,
|
|
73
|
+
deviceName: presence.deviceName,
|
|
74
|
+
role: presence.role,
|
|
75
|
+
metadata: presence.metadata,
|
|
76
|
+
joinedAt: joinedAt ?? Date.now(),
|
|
77
|
+
isLocal: false,
|
|
78
|
+
};
|
|
79
|
+
this._devices.set(deviceId, device);
|
|
80
|
+
this._actorToDeviceId.set(actorTokenId, deviceId);
|
|
81
|
+
return device;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Remove a device by actorTokenId.
|
|
85
|
+
* Returns the removed device, or null if not found / is self.
|
|
86
|
+
*/
|
|
87
|
+
removeByActorId(actorTokenId) {
|
|
88
|
+
if (actorTokenId === this._localActorId)
|
|
89
|
+
return null;
|
|
90
|
+
const deviceId = this._actorToDeviceId.get(actorTokenId);
|
|
91
|
+
if (!deviceId)
|
|
92
|
+
return null;
|
|
93
|
+
const device = this._devices.get(deviceId) ?? null;
|
|
94
|
+
this._devices.delete(deviceId);
|
|
95
|
+
this._actorToDeviceId.delete(actorTokenId);
|
|
96
|
+
return device;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get a device by deviceId.
|
|
100
|
+
*/
|
|
101
|
+
getDevice(deviceId) {
|
|
102
|
+
return this._devices.get(deviceId);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Get a device by actorTokenId.
|
|
106
|
+
*/
|
|
107
|
+
getDeviceByActorId(actorTokenId) {
|
|
108
|
+
const deviceId = this._actorToDeviceId.get(actorTokenId);
|
|
109
|
+
return deviceId ? this._devices.get(deviceId) : undefined;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Get all remote devices.
|
|
113
|
+
*/
|
|
114
|
+
getAll() {
|
|
115
|
+
return Array.from(this._devices.values());
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Get the devices Map (readonly view).
|
|
119
|
+
*/
|
|
120
|
+
get devices() {
|
|
121
|
+
return this._devices;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Clear all tracked devices.
|
|
125
|
+
*/
|
|
126
|
+
clear() {
|
|
127
|
+
this._devices.clear();
|
|
128
|
+
this._actorToDeviceId.clear();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Per-device, per-sensor time-series buffer.
|
|
134
|
+
*
|
|
135
|
+
* Readings are stored under a composite key `${deviceId}:${sensorId}` and
|
|
136
|
+
* bounded to `maxPoints` entries per key (oldest entries are dropped first).
|
|
137
|
+
*/
|
|
138
|
+
class TelemetryStore {
|
|
139
|
+
constructor(maxPoints) {
|
|
140
|
+
this._store = new Map();
|
|
141
|
+
this._ids = new Set();
|
|
142
|
+
this._maxPoints = maxPoints;
|
|
143
|
+
}
|
|
144
|
+
// ============ Public API ============
|
|
145
|
+
/**
|
|
146
|
+
* Add a telemetry reading to the store.
|
|
147
|
+
* Returns false if the reading id is a duplicate (idempotent).
|
|
148
|
+
*/
|
|
149
|
+
add(reading) {
|
|
150
|
+
if (this._ids.has(reading.id))
|
|
151
|
+
return false;
|
|
152
|
+
const key = this._key(reading.deviceId, reading.sensorId);
|
|
153
|
+
if (!this._store.has(key)) {
|
|
154
|
+
this._store.set(key, []);
|
|
155
|
+
}
|
|
156
|
+
const bucket = this._store.get(key);
|
|
157
|
+
bucket.push(reading);
|
|
158
|
+
// Enforce per-key cap
|
|
159
|
+
if (bucket.length > this._maxPoints) {
|
|
160
|
+
bucket.splice(0, bucket.length - this._maxPoints);
|
|
161
|
+
}
|
|
162
|
+
this._ids.add(reading.id);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Retrieve readings, optionally filtered by deviceId and/or sensorId.
|
|
167
|
+
*
|
|
168
|
+
* - No args → all readings across all devices and sensors
|
|
169
|
+
* - deviceId only → all readings for that device across all sensors
|
|
170
|
+
* - deviceId + sensorId → readings for that exact device/sensor pair
|
|
171
|
+
*/
|
|
172
|
+
getAll(deviceId, sensorId) {
|
|
173
|
+
if (deviceId !== undefined && sensorId !== undefined) {
|
|
174
|
+
return [...(this._store.get(this._key(deviceId, sensorId)) ?? [])];
|
|
175
|
+
}
|
|
176
|
+
const results = [];
|
|
177
|
+
for (const [key, bucket] of this._store.entries()) {
|
|
178
|
+
if (deviceId !== undefined && !key.startsWith(`${deviceId}:`))
|
|
179
|
+
continue;
|
|
180
|
+
results.push(...bucket);
|
|
181
|
+
}
|
|
182
|
+
return results;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Get the most recent reading for a specific device/sensor pair.
|
|
186
|
+
*/
|
|
187
|
+
getLatest(deviceId, sensorId) {
|
|
188
|
+
const bucket = this._store.get(this._key(deviceId, sensorId));
|
|
189
|
+
if (!bucket || bucket.length === 0)
|
|
190
|
+
return undefined;
|
|
191
|
+
return bucket[bucket.length - 1];
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Check whether a reading id already exists in the store.
|
|
195
|
+
*/
|
|
196
|
+
has(id) {
|
|
197
|
+
return this._ids.has(id);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Total number of readings across all device/sensor buckets.
|
|
201
|
+
*/
|
|
202
|
+
get size() {
|
|
203
|
+
let total = 0;
|
|
204
|
+
for (const bucket of this._store.values()) {
|
|
205
|
+
total += bucket.length;
|
|
206
|
+
}
|
|
207
|
+
return total;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Clear all stored readings and ids.
|
|
211
|
+
*/
|
|
212
|
+
clear() {
|
|
213
|
+
this._store.clear();
|
|
214
|
+
this._ids.clear();
|
|
215
|
+
}
|
|
216
|
+
// ============ Private ============
|
|
217
|
+
_key(deviceId, sensorId) {
|
|
218
|
+
return `${deviceId}:${sensorId}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function generateId() {
|
|
223
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
224
|
+
return crypto.randomUUID();
|
|
225
|
+
}
|
|
226
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
227
|
+
}
|
|
228
|
+
function createLogger(prefix, enabled) {
|
|
229
|
+
if (!enabled) {
|
|
230
|
+
return (..._args) => { };
|
|
231
|
+
}
|
|
232
|
+
return (...args) => {
|
|
233
|
+
console.log(`[${prefix}]`, ...args);
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Command dispatch with ack tracking and per-command timeout.
|
|
239
|
+
*
|
|
240
|
+
* Controllers call `send()` to dispatch a command and await the result.
|
|
241
|
+
* Devices call `ack()` to acknowledge receipt and report completion/failure.
|
|
242
|
+
*/
|
|
243
|
+
class CommandManager {
|
|
244
|
+
constructor(defaultTimeout) {
|
|
245
|
+
this._pending = new Map();
|
|
246
|
+
this._commands = new Map();
|
|
247
|
+
this._defaultTimeout = defaultTimeout;
|
|
248
|
+
}
|
|
249
|
+
// ============ Public API ============
|
|
250
|
+
/**
|
|
251
|
+
* Dispatch a command to a target device.
|
|
252
|
+
*
|
|
253
|
+
* Returns a Promise that resolves once the device acks with 'acked' or
|
|
254
|
+
* 'completed', or rejects on 'failed' status or timeout.
|
|
255
|
+
*/
|
|
256
|
+
send(targetDeviceId, command, params, sentBy, timeout) {
|
|
257
|
+
const id = generateId();
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
const cmd = {
|
|
260
|
+
id,
|
|
261
|
+
targetDeviceId,
|
|
262
|
+
command,
|
|
263
|
+
params,
|
|
264
|
+
status: 'pending',
|
|
265
|
+
sentBy,
|
|
266
|
+
sentAt: now,
|
|
267
|
+
};
|
|
268
|
+
this._commands.set(id, cmd);
|
|
269
|
+
const promise = new Promise((resolve, reject) => {
|
|
270
|
+
const ms = timeout ?? this._defaultTimeout;
|
|
271
|
+
const timer = setTimeout(() => {
|
|
272
|
+
const entry = this._pending.get(id);
|
|
273
|
+
if (!entry)
|
|
274
|
+
return;
|
|
275
|
+
entry.command.status = 'timeout';
|
|
276
|
+
this._commands.set(id, entry.command);
|
|
277
|
+
this._pending.delete(id);
|
|
278
|
+
reject(new Error(`Command "${command}" (${id}) timed out after ${ms}ms`));
|
|
279
|
+
}, ms);
|
|
280
|
+
this._pending.set(id, { command: cmd, resolve, reject, timer });
|
|
281
|
+
});
|
|
282
|
+
// Prevent unhandled rejection warnings when dispose() rejects orphaned commands.
|
|
283
|
+
// Callers who await/catch send() still see the rejection normally.
|
|
284
|
+
promise.catch(() => { });
|
|
285
|
+
return promise;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Acknowledge a command from the device side.
|
|
289
|
+
*
|
|
290
|
+
* `status` must be one of 'acked', 'completed', or 'failed'.
|
|
291
|
+
* Returns the updated DeviceCommand, or null if the command is unknown or
|
|
292
|
+
* already settled.
|
|
293
|
+
*/
|
|
294
|
+
ack(commandId, status, result) {
|
|
295
|
+
const entry = this._pending.get(commandId);
|
|
296
|
+
if (!entry)
|
|
297
|
+
return null;
|
|
298
|
+
clearTimeout(entry.timer);
|
|
299
|
+
this._pending.delete(commandId);
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const cmd = entry.command;
|
|
302
|
+
cmd.status = status;
|
|
303
|
+
if (status === 'acked') {
|
|
304
|
+
cmd.ackedAt = now;
|
|
305
|
+
}
|
|
306
|
+
else if (status === 'completed') {
|
|
307
|
+
cmd.ackedAt = cmd.ackedAt ?? now;
|
|
308
|
+
cmd.completedAt = now;
|
|
309
|
+
cmd.result = result;
|
|
310
|
+
}
|
|
311
|
+
else if (status === 'failed') {
|
|
312
|
+
cmd.ackedAt = cmd.ackedAt ?? now;
|
|
313
|
+
cmd.error = typeof result === 'string' ? result : 'Command failed';
|
|
314
|
+
}
|
|
315
|
+
this._commands.set(commandId, cmd);
|
|
316
|
+
if (status === 'acked' || status === 'completed') {
|
|
317
|
+
entry.resolve(cmd);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
entry.reject(new Error(cmd.error ?? 'Command failed'));
|
|
321
|
+
}
|
|
322
|
+
return cmd;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Get a command by id (includes settled commands).
|
|
326
|
+
*/
|
|
327
|
+
get(commandId) {
|
|
328
|
+
return this._commands.get(commandId);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Get all commands currently in 'pending' status.
|
|
332
|
+
*/
|
|
333
|
+
getPending() {
|
|
334
|
+
return Array.from(this._pending.values()).map((e) => e.command);
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Clear all pending timers and reject all outstanding promises.
|
|
338
|
+
* Call this when the group or client is torn down.
|
|
339
|
+
*/
|
|
340
|
+
dispose() {
|
|
341
|
+
for (const [id, entry] of this._pending.entries()) {
|
|
342
|
+
clearTimeout(entry.timer);
|
|
343
|
+
entry.command.status = 'timeout';
|
|
344
|
+
this._commands.set(id, entry.command);
|
|
345
|
+
entry.reject(new Error(`Command "${entry.command.command}" (${id}) was disposed`));
|
|
346
|
+
}
|
|
347
|
+
this._pending.clear();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Default app name for NoLag IoT SDK */
|
|
352
|
+
const DEFAULT_APP_NAME = 'iot';
|
|
353
|
+
/** Maximum telemetry readings to retain per device/sensor key */
|
|
354
|
+
const DEFAULT_MAX_TELEMETRY_POINTS = 1000;
|
|
355
|
+
/** Default command acknowledgement timeout in milliseconds */
|
|
356
|
+
const DEFAULT_COMMAND_TIMEOUT = 30000;
|
|
357
|
+
/** Topic name for telemetry readings */
|
|
358
|
+
const TOPIC_TELEMETRY = 'telemetry';
|
|
359
|
+
/** Topic name for dispatched commands */
|
|
360
|
+
const TOPIC_COMMANDS = 'commands';
|
|
361
|
+
/** Topic name for command acknowledgements */
|
|
362
|
+
const TOPIC_CMD_ACK = '_cmd_ack';
|
|
363
|
+
/** Lobby ID for global online presence */
|
|
364
|
+
const LOBBY_ID = 'online';
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* DeviceGroup — a single IoT group for telemetry streaming and command dispatch.
|
|
368
|
+
*
|
|
369
|
+
* Created via `NoLagIoT.joinGroup(name)`. Do not instantiate directly.
|
|
370
|
+
*/
|
|
371
|
+
class DeviceGroup extends EventEmitter {
|
|
372
|
+
/** @internal */
|
|
373
|
+
constructor(name, roomContext, localDevice, options, log) {
|
|
374
|
+
super();
|
|
375
|
+
this._receivedCommands = new Map();
|
|
376
|
+
this.name = name;
|
|
377
|
+
this._roomContext = roomContext;
|
|
378
|
+
this._localDevice = localDevice;
|
|
379
|
+
this._options = options;
|
|
380
|
+
this._log = log;
|
|
381
|
+
this._presenceManager = new PresenceManager(localDevice.actorTokenId);
|
|
382
|
+
this._telemetryStore = new TelemetryStore(options.maxTelemetryPoints);
|
|
383
|
+
this._commandManager = new CommandManager(options.commandTimeout);
|
|
384
|
+
}
|
|
385
|
+
// ============ Public Properties ============
|
|
386
|
+
/** All remote devices currently in this group */
|
|
387
|
+
get devices() {
|
|
388
|
+
return this._presenceManager.devices;
|
|
389
|
+
}
|
|
390
|
+
// ============ Telemetry ============
|
|
391
|
+
/**
|
|
392
|
+
* Publish a telemetry reading from this device.
|
|
393
|
+
*/
|
|
394
|
+
sendTelemetry(sensorId, value, opts = {}) {
|
|
395
|
+
const reading = {
|
|
396
|
+
id: generateId(),
|
|
397
|
+
deviceId: this._localDevice.deviceId,
|
|
398
|
+
sensorId,
|
|
399
|
+
value,
|
|
400
|
+
unit: opts.unit,
|
|
401
|
+
tags: opts.tags,
|
|
402
|
+
timestamp: Date.now(),
|
|
403
|
+
isReplay: false,
|
|
404
|
+
};
|
|
405
|
+
this._log('Sending telemetry:', sensorId, '=', value);
|
|
406
|
+
this._roomContext.emit(TOPIC_TELEMETRY, reading, { echo: false });
|
|
407
|
+
// Store locally so the sender also has it in the buffer
|
|
408
|
+
this._telemetryStore.add(reading);
|
|
409
|
+
return reading;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Retrieve buffered telemetry readings, optionally filtered by device/sensor.
|
|
413
|
+
*/
|
|
414
|
+
getTelemetry(deviceId, sensorId) {
|
|
415
|
+
return this._telemetryStore.getAll(deviceId, sensorId);
|
|
416
|
+
}
|
|
417
|
+
// ============ Commands ============
|
|
418
|
+
/**
|
|
419
|
+
* Dispatch a command to a target device.
|
|
420
|
+
* Resolves when the device acks the command, rejects on failure or timeout.
|
|
421
|
+
*/
|
|
422
|
+
sendCommand(targetDeviceId, command, params) {
|
|
423
|
+
this._log('Sending command:', command, '→', targetDeviceId);
|
|
424
|
+
const promise = this._commandManager.send(targetDeviceId, command, params, this._localDevice.deviceId, this._options.commandTimeout);
|
|
425
|
+
// We need the command id to publish it — grab it from pending after send
|
|
426
|
+
const pending = this._commandManager.getPending();
|
|
427
|
+
const cmd = pending[pending.length - 1];
|
|
428
|
+
if (cmd) {
|
|
429
|
+
this._roomContext.emit(TOPIC_COMMANDS, cmd, { echo: false, filter: targetDeviceId });
|
|
430
|
+
}
|
|
431
|
+
return promise;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Acknowledge a command on the device side.
|
|
435
|
+
* Typically called by the device after receiving a command event.
|
|
436
|
+
*/
|
|
437
|
+
ackCommand(commandId, status, result) {
|
|
438
|
+
this._log('Acking command:', commandId, status);
|
|
439
|
+
const ack = {
|
|
440
|
+
commandId,
|
|
441
|
+
status,
|
|
442
|
+
result,
|
|
443
|
+
ackedBy: this._localDevice.deviceId,
|
|
444
|
+
ackedAt: Date.now(),
|
|
445
|
+
};
|
|
446
|
+
// Route ack back to the controller that sent the command
|
|
447
|
+
const cmd = this._receivedCommands.get(commandId) || this._commandManager.get(commandId);
|
|
448
|
+
const ackFilter = cmd?.sentBy;
|
|
449
|
+
this._receivedCommands.delete(commandId);
|
|
450
|
+
this._roomContext.emit(TOPIC_CMD_ACK, ack, { echo: false, ...(ackFilter ? { filter: ackFilter } : {}) });
|
|
451
|
+
// Also settle locally if this device is the one that sent the command
|
|
452
|
+
this._commandManager.ack(commandId, status, result);
|
|
453
|
+
}
|
|
454
|
+
// ============ Devices ============
|
|
455
|
+
/**
|
|
456
|
+
* Get all remote devices in this group.
|
|
457
|
+
*/
|
|
458
|
+
getDevices() {
|
|
459
|
+
return this._presenceManager.getAll();
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Get a specific device by deviceId.
|
|
463
|
+
*/
|
|
464
|
+
getDevice(deviceId) {
|
|
465
|
+
return this._presenceManager.getDevice(deviceId);
|
|
466
|
+
}
|
|
467
|
+
// ============ Internal (called by NoLagIoT) ============
|
|
468
|
+
/** @internal Subscribe to all group topics and attach listeners */
|
|
469
|
+
_subscribe() {
|
|
470
|
+
this._log('Group subscribe:', this.name);
|
|
471
|
+
this._roomContext.subscribe(TOPIC_TELEMETRY);
|
|
472
|
+
// Commands: devices subscribe with their deviceId as filter so they only
|
|
473
|
+
// receive commands targeted at them. Controllers subscribe as wildcard
|
|
474
|
+
// to observe all commands.
|
|
475
|
+
if (this._options.role === 'device') {
|
|
476
|
+
this._roomContext.subscribe(TOPIC_COMMANDS, { filters: [this._localDevice.deviceId] });
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
this._roomContext.subscribe(TOPIC_COMMANDS);
|
|
480
|
+
}
|
|
481
|
+
// Acks: controllers subscribe with their deviceId as filter so they only
|
|
482
|
+
// receive acks for commands they sent. Devices subscribe as wildcard.
|
|
483
|
+
if (this._options.role === 'controller') {
|
|
484
|
+
this._roomContext.subscribe(TOPIC_CMD_ACK, { filters: [this._localDevice.deviceId] });
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
this._roomContext.subscribe(TOPIC_CMD_ACK);
|
|
488
|
+
}
|
|
489
|
+
this._roomContext.on(TOPIC_TELEMETRY, (data) => {
|
|
490
|
+
this._handleIncomingTelemetry(data);
|
|
491
|
+
});
|
|
492
|
+
this._roomContext.on(TOPIC_COMMANDS, (data) => {
|
|
493
|
+
this._handleIncomingCommand(data);
|
|
494
|
+
});
|
|
495
|
+
this._roomContext.on(TOPIC_CMD_ACK, (data) => {
|
|
496
|
+
this._handleIncomingCmdAck(data);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
/** @internal Set presence and fetch existing group members */
|
|
500
|
+
_activate() {
|
|
501
|
+
this._log('Group activate:', this.name);
|
|
502
|
+
this._setPresence();
|
|
503
|
+
this._roomContext.fetchPresence().then((actors) => {
|
|
504
|
+
this._log('Group presence fetched:', this.name, actors.length, 'actors');
|
|
505
|
+
for (const actor of actors) {
|
|
506
|
+
if (actor.presence) {
|
|
507
|
+
const device = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
|
|
508
|
+
if (device) {
|
|
509
|
+
this.emit('deviceJoined', device);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}).catch((err) => {
|
|
514
|
+
this._log('Failed to fetch group presence:', err);
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** @internal Re-set presence after reconnect */
|
|
518
|
+
_updateLocalPresence() {
|
|
519
|
+
this._setPresence();
|
|
520
|
+
}
|
|
521
|
+
/** @internal Handle a presence:join event */
|
|
522
|
+
_handlePresenceJoin(actorTokenId, presenceData) {
|
|
523
|
+
const device = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
524
|
+
if (device) {
|
|
525
|
+
this._log('Device joined group:', this.name, device.deviceId);
|
|
526
|
+
this.emit('deviceJoined', device);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** @internal Handle a presence:leave event */
|
|
530
|
+
_handlePresenceLeave(actorTokenId) {
|
|
531
|
+
const device = this._presenceManager.removeByActorId(actorTokenId);
|
|
532
|
+
if (device) {
|
|
533
|
+
this._log('Device left group:', this.name, device.deviceId);
|
|
534
|
+
this.emit('deviceLeft', device);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** @internal Handle a presence:update event */
|
|
538
|
+
_handlePresenceUpdate(actorTokenId, presenceData) {
|
|
539
|
+
this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
540
|
+
}
|
|
541
|
+
/** @internal Unsubscribe and clean up */
|
|
542
|
+
_cleanup() {
|
|
543
|
+
this._log('Group cleanup:', this.name);
|
|
544
|
+
this._roomContext.unsubscribe(TOPIC_TELEMETRY);
|
|
545
|
+
this._roomContext.unsubscribe(TOPIC_COMMANDS);
|
|
546
|
+
this._roomContext.unsubscribe(TOPIC_CMD_ACK);
|
|
547
|
+
this._roomContext.off(TOPIC_TELEMETRY);
|
|
548
|
+
this._roomContext.off(TOPIC_COMMANDS);
|
|
549
|
+
this._roomContext.off(TOPIC_CMD_ACK);
|
|
550
|
+
this._commandManager.dispose();
|
|
551
|
+
this._receivedCommands.clear();
|
|
552
|
+
this._presenceManager.clear();
|
|
553
|
+
this.removeAllListeners();
|
|
554
|
+
}
|
|
555
|
+
// ============ Private ============
|
|
556
|
+
_handleIncomingTelemetry(data) {
|
|
557
|
+
const reading = data;
|
|
558
|
+
this._log('Received telemetry:', reading.sensorId, '=', reading.value, 'from', reading.deviceId);
|
|
559
|
+
const isNew = this._telemetryStore.add(reading);
|
|
560
|
+
if (!isNew)
|
|
561
|
+
return; // duplicate — skip
|
|
562
|
+
this.emit('telemetry', reading);
|
|
563
|
+
}
|
|
564
|
+
_handleIncomingCommand(data) {
|
|
565
|
+
const cmd = data;
|
|
566
|
+
// Controllers observe all commands but don't process them as targets
|
|
567
|
+
if (this._options.role === 'controller')
|
|
568
|
+
return;
|
|
569
|
+
this._log('Received command:', cmd.command, 'from', cmd.sentBy);
|
|
570
|
+
this._receivedCommands.set(cmd.id, cmd);
|
|
571
|
+
this.emit('command', cmd);
|
|
572
|
+
}
|
|
573
|
+
_handleIncomingCmdAck(data) {
|
|
574
|
+
const ack = data;
|
|
575
|
+
this._log('Received command ack:', ack.commandId, ack.status);
|
|
576
|
+
const cmd = this._commandManager.ack(ack.commandId, ack.status, ack.result);
|
|
577
|
+
if (cmd) {
|
|
578
|
+
this.emit('commandAck', cmd);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
_setPresence() {
|
|
582
|
+
const presenceData = {
|
|
583
|
+
deviceId: this._localDevice.deviceId,
|
|
584
|
+
deviceName: this._localDevice.deviceName,
|
|
585
|
+
role: this._localDevice.role,
|
|
586
|
+
metadata: this._localDevice.metadata,
|
|
587
|
+
};
|
|
588
|
+
this._roomContext.setPresence(presenceData);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* NoLagIoT — high-level IoT telemetry and command dispatch SDK built on @nolag/js-sdk.
|
|
594
|
+
*
|
|
595
|
+
* Provides device presence, real-time telemetry streaming, and command dispatch
|
|
596
|
+
* with ack tracking — all framework-agnostic via events.
|
|
597
|
+
*
|
|
598
|
+
* @example
|
|
599
|
+
* ```typescript
|
|
600
|
+
* import { NoLagIoT } from '@nolag/iot';
|
|
601
|
+
*
|
|
602
|
+
* const iot = new NoLagIoT(token, { deviceId: 'sensor-01', role: 'device', debug: true });
|
|
603
|
+
*
|
|
604
|
+
* iot.on('connected', () => console.log('Connected!'));
|
|
605
|
+
* await iot.connect();
|
|
606
|
+
*
|
|
607
|
+
* const group = iot.joinGroup('factory-floor');
|
|
608
|
+
* group.on('command', (cmd) => {
|
|
609
|
+
* console.log('Received command:', cmd.command);
|
|
610
|
+
* group.ackCommand(cmd.id, 'completed', { ok: true });
|
|
611
|
+
* });
|
|
612
|
+
*
|
|
613
|
+
* // Send telemetry every second
|
|
614
|
+
* setInterval(() => {
|
|
615
|
+
* group.sendTelemetry('temperature', 22.5, { unit: '°C' });
|
|
616
|
+
* }, 1000);
|
|
617
|
+
* ```
|
|
618
|
+
*/
|
|
619
|
+
class NoLagIoT extends EventEmitter {
|
|
620
|
+
constructor(token, options = {}) {
|
|
621
|
+
super();
|
|
622
|
+
this._client = null;
|
|
623
|
+
this._localDevice = null;
|
|
624
|
+
this._groups = new Map();
|
|
625
|
+
this._lobby = null;
|
|
626
|
+
this._onlineDevices = new Map();
|
|
627
|
+
this._actorToDeviceId = new Map();
|
|
628
|
+
this._token = token;
|
|
629
|
+
this._deviceId = options.deviceId ?? generateId();
|
|
630
|
+
this._options = {
|
|
631
|
+
deviceId: this._deviceId,
|
|
632
|
+
deviceName: options.deviceName,
|
|
633
|
+
role: options.role ?? 'device',
|
|
634
|
+
metadata: options.metadata,
|
|
635
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
636
|
+
url: options.url,
|
|
637
|
+
maxTelemetryPoints: options.maxTelemetryPoints ?? DEFAULT_MAX_TELEMETRY_POINTS,
|
|
638
|
+
commandTimeout: options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT,
|
|
639
|
+
debug: options.debug ?? false,
|
|
640
|
+
reconnect: options.reconnect ?? true,
|
|
641
|
+
groups: options.groups ?? [],
|
|
642
|
+
};
|
|
643
|
+
this._log = createLogger('NoLagIoT', this._options.debug);
|
|
644
|
+
}
|
|
645
|
+
// ============ Public Properties ============
|
|
646
|
+
/** Whether the underlying connection is established */
|
|
647
|
+
get connected() {
|
|
648
|
+
return this._client?.connected ?? false;
|
|
649
|
+
}
|
|
650
|
+
/** The local device info (available after connect) */
|
|
651
|
+
get localDevice() {
|
|
652
|
+
return this._localDevice;
|
|
653
|
+
}
|
|
654
|
+
/** All currently joined groups */
|
|
655
|
+
get groups() {
|
|
656
|
+
return this._groups;
|
|
657
|
+
}
|
|
658
|
+
// ============ Lifecycle ============
|
|
659
|
+
/**
|
|
660
|
+
* Connect to NoLag and set up global presence.
|
|
661
|
+
*/
|
|
662
|
+
async connect() {
|
|
663
|
+
this._log('Connecting...');
|
|
664
|
+
const clientOptions = {
|
|
665
|
+
debug: this._options.debug,
|
|
666
|
+
reconnect: this._options.reconnect,
|
|
667
|
+
};
|
|
668
|
+
if (this._options.url) {
|
|
669
|
+
clientOptions.url = this._options.url;
|
|
670
|
+
}
|
|
671
|
+
this._client = NoLag(this._token, clientOptions);
|
|
672
|
+
// Wire client lifecycle events
|
|
673
|
+
this._client.on('connect', () => {
|
|
674
|
+
this._log('Connected');
|
|
675
|
+
if (this._groups.size > 0) {
|
|
676
|
+
this._log('Reconnected — restoring groups...');
|
|
677
|
+
this._restoreGroups();
|
|
678
|
+
this.emit('reconnected');
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
this._client.on('disconnect', (reason) => {
|
|
682
|
+
this._log('Disconnected:', reason);
|
|
683
|
+
this.emit('disconnected', reason);
|
|
684
|
+
});
|
|
685
|
+
this._client.on('reconnect', () => {
|
|
686
|
+
this._log('Reconnecting...');
|
|
687
|
+
});
|
|
688
|
+
this._client.on('error', (error) => {
|
|
689
|
+
this._log('Error:', error);
|
|
690
|
+
this.emit('error', error);
|
|
691
|
+
});
|
|
692
|
+
// Connect
|
|
693
|
+
await this._client.connect();
|
|
694
|
+
// Wire room-level presence events
|
|
695
|
+
this._client.on('presence:join', (data) => {
|
|
696
|
+
this._handleRoomPresenceJoin(data);
|
|
697
|
+
});
|
|
698
|
+
this._client.on('presence:leave', (data) => {
|
|
699
|
+
this._handleRoomPresenceLeave(data);
|
|
700
|
+
});
|
|
701
|
+
this._client.on('presence:update', (data) => {
|
|
702
|
+
this._handleRoomPresenceUpdate(data);
|
|
703
|
+
});
|
|
704
|
+
// Create local device record
|
|
705
|
+
this._localDevice = {
|
|
706
|
+
deviceId: this._deviceId,
|
|
707
|
+
actorTokenId: this._client.actorId,
|
|
708
|
+
deviceName: this._options.deviceName,
|
|
709
|
+
role: this._options.role,
|
|
710
|
+
metadata: this._options.metadata,
|
|
711
|
+
joinedAt: Date.now(),
|
|
712
|
+
isLocal: true,
|
|
713
|
+
};
|
|
714
|
+
this._log('Local device:', this._localDevice.deviceId, '→', this._localDevice.actorTokenId);
|
|
715
|
+
// Set up lobby for global presence
|
|
716
|
+
await this._setupLobby();
|
|
717
|
+
// Emit connected now that _localDevice and lobby are ready
|
|
718
|
+
this.emit('connected');
|
|
719
|
+
// Auto-join configured groups
|
|
720
|
+
for (const groupName of this._options.groups) {
|
|
721
|
+
this.joinGroup(groupName);
|
|
722
|
+
}
|
|
723
|
+
// Deferred lobby refetch to catch devices that joined during setup window
|
|
724
|
+
setTimeout(() => {
|
|
725
|
+
if (this._lobby && this._client?.connected) {
|
|
726
|
+
this._lobby.fetchPresence().then((state) => {
|
|
727
|
+
this._hydrateOnlineDevices(state);
|
|
728
|
+
}).catch(() => { });
|
|
729
|
+
}
|
|
730
|
+
}, 2000);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Disconnect from NoLag and clean up all groups.
|
|
734
|
+
*/
|
|
735
|
+
disconnect() {
|
|
736
|
+
this._log('Disconnecting...');
|
|
737
|
+
// Clean up groups
|
|
738
|
+
for (const name of [...this._groups.keys()]) {
|
|
739
|
+
this.leaveGroup(name);
|
|
740
|
+
}
|
|
741
|
+
// Unsubscribe from lobby
|
|
742
|
+
this._lobby?.unsubscribe();
|
|
743
|
+
this._lobby = null;
|
|
744
|
+
// Disconnect client
|
|
745
|
+
this._client?.disconnect();
|
|
746
|
+
this._client = null;
|
|
747
|
+
// Clear state
|
|
748
|
+
this._onlineDevices.clear();
|
|
749
|
+
this._actorToDeviceId.clear();
|
|
750
|
+
this._localDevice = null;
|
|
751
|
+
}
|
|
752
|
+
// ============ Group Management ============
|
|
753
|
+
/**
|
|
754
|
+
* Join a device group. Creates, subscribes, and activates it.
|
|
755
|
+
* Returns an existing group if already joined.
|
|
756
|
+
*/
|
|
757
|
+
joinGroup(name) {
|
|
758
|
+
if (!this._client || !this._localDevice) {
|
|
759
|
+
throw new Error('Not connected — call connect() first');
|
|
760
|
+
}
|
|
761
|
+
let group = this._groups.get(name);
|
|
762
|
+
if (!group) {
|
|
763
|
+
group = this._subscribeGroup(name);
|
|
764
|
+
group._activate();
|
|
765
|
+
}
|
|
766
|
+
return group;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Leave a device group. Fully unsubscribes and removes it.
|
|
770
|
+
*/
|
|
771
|
+
leaveGroup(name) {
|
|
772
|
+
const group = this._groups.get(name);
|
|
773
|
+
if (!group)
|
|
774
|
+
return;
|
|
775
|
+
this._log('Leaving group:', name);
|
|
776
|
+
group._cleanup();
|
|
777
|
+
this._groups.delete(name);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Get all joined groups.
|
|
781
|
+
*/
|
|
782
|
+
getGroups() {
|
|
783
|
+
return Array.from(this._groups.values());
|
|
784
|
+
}
|
|
785
|
+
// ============ Global Presence ============
|
|
786
|
+
/**
|
|
787
|
+
* Get all devices currently online across all groups.
|
|
788
|
+
*/
|
|
789
|
+
getOnlineDevices() {
|
|
790
|
+
return Array.from(this._onlineDevices.values());
|
|
791
|
+
}
|
|
792
|
+
// ============ Private: Group Setup ============
|
|
793
|
+
_subscribeGroup(name) {
|
|
794
|
+
if (!this._client || !this._localDevice) {
|
|
795
|
+
throw new Error('Not connected — call connect() first');
|
|
796
|
+
}
|
|
797
|
+
this._log('Subscribing group:', name);
|
|
798
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
799
|
+
const group = new DeviceGroup(name, roomContext, this._localDevice, this._options, createLogger(`DeviceGroup:${name}`, this._options.debug));
|
|
800
|
+
this._groups.set(name, group);
|
|
801
|
+
group._subscribe();
|
|
802
|
+
return group;
|
|
803
|
+
}
|
|
804
|
+
// ============ Private: Room Presence ============
|
|
805
|
+
_handleRoomPresenceJoin(data) {
|
|
806
|
+
if (data.actorTokenId === this._localDevice?.actorTokenId)
|
|
807
|
+
return;
|
|
808
|
+
const presenceData = data.presence;
|
|
809
|
+
if (!presenceData?.deviceId)
|
|
810
|
+
return;
|
|
811
|
+
const device = this._presenceToDevice(data.actorTokenId, presenceData);
|
|
812
|
+
this._actorToDeviceId.set(data.actorTokenId, device.deviceId);
|
|
813
|
+
if (!this._onlineDevices.has(device.deviceId)) {
|
|
814
|
+
this._onlineDevices.set(device.deviceId, device);
|
|
815
|
+
this.emit('deviceOnline', device);
|
|
816
|
+
}
|
|
817
|
+
// Route to all groups
|
|
818
|
+
for (const group of this._groups.values()) {
|
|
819
|
+
group._handlePresenceJoin(data.actorTokenId, presenceData);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
_handleRoomPresenceLeave(data) {
|
|
823
|
+
if (data.actorTokenId === this._localDevice?.actorTokenId)
|
|
824
|
+
return;
|
|
825
|
+
// Route to all groups
|
|
826
|
+
for (const group of this._groups.values()) {
|
|
827
|
+
group._handlePresenceLeave(data.actorTokenId);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
_handleRoomPresenceUpdate(data) {
|
|
831
|
+
if (data.actorTokenId === this._localDevice?.actorTokenId)
|
|
832
|
+
return;
|
|
833
|
+
const presenceData = data.presence;
|
|
834
|
+
if (!presenceData?.deviceId)
|
|
835
|
+
return;
|
|
836
|
+
if (this._onlineDevices.has(presenceData.deviceId)) {
|
|
837
|
+
const device = this._presenceToDevice(data.actorTokenId, presenceData);
|
|
838
|
+
this._onlineDevices.set(device.deviceId, device);
|
|
839
|
+
}
|
|
840
|
+
// Route to all groups
|
|
841
|
+
for (const group of this._groups.values()) {
|
|
842
|
+
group._handlePresenceUpdate(data.actorTokenId, presenceData);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// ============ Private: Lobby ============
|
|
846
|
+
async _setupLobby() {
|
|
847
|
+
if (!this._client)
|
|
848
|
+
return;
|
|
849
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
850
|
+
const lobbyHandler = (type) => (data) => {
|
|
851
|
+
const event = data;
|
|
852
|
+
if (type === 'join')
|
|
853
|
+
this._handleLobbyJoin(event);
|
|
854
|
+
else if (type === 'leave')
|
|
855
|
+
this._handleLobbyLeave(event);
|
|
856
|
+
else
|
|
857
|
+
this._handleLobbyUpdate(event);
|
|
858
|
+
};
|
|
859
|
+
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
860
|
+
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
861
|
+
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
862
|
+
try {
|
|
863
|
+
const initialState = await this._lobby.subscribe();
|
|
864
|
+
this._hydrateOnlineDevices(initialState);
|
|
865
|
+
this._log('Lobby subscribed, online devices:', this._onlineDevices.size);
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
this._log('Lobby subscription failed:', err);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
_handleLobbyJoin(event) {
|
|
872
|
+
const { actorId, data } = event;
|
|
873
|
+
if (actorId === this._localDevice?.actorTokenId)
|
|
874
|
+
return;
|
|
875
|
+
const presenceData = data;
|
|
876
|
+
if (!presenceData.deviceId)
|
|
877
|
+
return;
|
|
878
|
+
const device = this._presenceToDevice(actorId, presenceData);
|
|
879
|
+
this._actorToDeviceId.set(actorId, device.deviceId);
|
|
880
|
+
if (!this._onlineDevices.has(device.deviceId)) {
|
|
881
|
+
this._onlineDevices.set(device.deviceId, device);
|
|
882
|
+
this.emit('deviceOnline', device);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
_handleLobbyLeave(event) {
|
|
886
|
+
const { actorId, data } = event;
|
|
887
|
+
if (actorId === this._localDevice?.actorTokenId)
|
|
888
|
+
return;
|
|
889
|
+
const presenceData = data;
|
|
890
|
+
const deviceId = presenceData?.deviceId
|
|
891
|
+
|| this._actorToDeviceId.get(actorId)
|
|
892
|
+
|| this._findDeviceIdByActorId(actorId);
|
|
893
|
+
if (deviceId) {
|
|
894
|
+
const device = this._onlineDevices.get(deviceId);
|
|
895
|
+
if (device) {
|
|
896
|
+
this._onlineDevices.delete(deviceId);
|
|
897
|
+
this._actorToDeviceId.delete(actorId);
|
|
898
|
+
this.emit('deviceOffline', device);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
_handleLobbyUpdate(event) {
|
|
903
|
+
const { actorId, data } = event;
|
|
904
|
+
if (actorId === this._localDevice?.actorTokenId)
|
|
905
|
+
return;
|
|
906
|
+
const presenceData = data;
|
|
907
|
+
if (!presenceData.deviceId)
|
|
908
|
+
return;
|
|
909
|
+
const device = this._presenceToDevice(actorId, presenceData);
|
|
910
|
+
this._onlineDevices.set(device.deviceId, device);
|
|
911
|
+
}
|
|
912
|
+
_hydrateOnlineDevices(state) {
|
|
913
|
+
for (const roomId of Object.keys(state)) {
|
|
914
|
+
const roomPresence = state[roomId];
|
|
915
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
916
|
+
if (actorId === this._localDevice?.actorTokenId)
|
|
917
|
+
continue;
|
|
918
|
+
const raw = roomPresence[actorId];
|
|
919
|
+
const presenceData = (raw?.presence ?? raw);
|
|
920
|
+
if (presenceData?.deviceId) {
|
|
921
|
+
const device = this._presenceToDevice(actorId, presenceData);
|
|
922
|
+
this._actorToDeviceId.set(actorId, device.deviceId);
|
|
923
|
+
if (!this._onlineDevices.has(device.deviceId)) {
|
|
924
|
+
this._onlineDevices.set(device.deviceId, device);
|
|
925
|
+
this.emit('deviceOnline', device);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// ============ Private: Helpers ============
|
|
932
|
+
_presenceToDevice(actorTokenId, data) {
|
|
933
|
+
return {
|
|
934
|
+
deviceId: data.deviceId,
|
|
935
|
+
actorTokenId,
|
|
936
|
+
deviceName: data.deviceName,
|
|
937
|
+
role: data.role,
|
|
938
|
+
metadata: data.metadata,
|
|
939
|
+
joinedAt: Date.now(),
|
|
940
|
+
isLocal: false,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
_findDeviceIdByActorId(actorTokenId) {
|
|
944
|
+
for (const device of this._onlineDevices.values()) {
|
|
945
|
+
if (device.actorTokenId === actorTokenId)
|
|
946
|
+
return device.deviceId;
|
|
947
|
+
}
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
_restoreGroups() {
|
|
951
|
+
// On reconnect, js-sdk auto-restores subscriptions.
|
|
952
|
+
// Re-set presence on all active groups.
|
|
953
|
+
for (const group of this._groups.values()) {
|
|
954
|
+
group._updateLocalPresence();
|
|
955
|
+
}
|
|
956
|
+
// Re-fetch lobby presence
|
|
957
|
+
this._lobby?.fetchPresence().then((state) => {
|
|
958
|
+
this._onlineDevices.clear();
|
|
959
|
+
this._actorToDeviceId.clear();
|
|
960
|
+
this._hydrateOnlineDevices(state);
|
|
961
|
+
}).catch((err) => {
|
|
962
|
+
this._log('Failed to re-fetch lobby presence:', err);
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export { CommandManager, DeviceGroup, EventEmitter, NoLagIoT, PresenceManager, TelemetryStore };
|
|
968
|
+
//# sourceMappingURL=index.mjs.map
|