@mega-yfue/eufy-sdk 0.6.0-beta.2 → 0.6.0-beta.21
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/dist/client/device-registry.d.ts +6 -0
- package/dist/client/eufy-mega.d.ts +84 -11
- package/dist/client/types.d.ts +12 -9
- package/dist/core/contracts.d.ts +3 -1
- package/dist/core/store.d.ts +12 -1
- package/dist/core/types.d.ts +4 -2
- package/dist/index.js +2719 -2120
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/camera.d.ts +4 -1
- package/dist/model/capabilities/mower.d.ts +3 -2
- package/dist/model/capabilities/vacuum-clean.d.ts +23 -0
- package/dist/model/device-family.d.ts +1 -1
- package/dist/model/device-types.d.ts +1 -0
- package/dist/transport/http/mega-client.d.ts +3 -2
- package/dist/transport/mqtt/biz-stream.d.ts +22 -25
- package/dist/transport/p2p/audio-frame.d.ts +13 -0
- package/dist/transport/p2p/codec.d.ts +2 -2
- package/dist/transport/p2p/command-router.d.ts +28 -3
- package/dist/transport/p2p/database-wire.d.ts +30 -0
- package/dist/transport/p2p/level2-settle.d.ts +9 -0
- package/dist/transport/p2p/live-stream.d.ts +34 -7
- package/dist/transport/p2p/live-trace.d.ts +6 -4
- package/dist/transport/p2p/p2p-session.d.ts +99 -15
- package/dist/transport/p2p/readable-egress.d.ts +3 -1
- package/dist/transport/p2p/recording-download.d.ts +26 -4
- package/dist/transport/p2p/shared-live-source.d.ts +9 -3
- package/dist/transport/p2p/video.d.ts +4 -2
- package/dist/transport/push/store.d.ts +7 -6
- package/dist/transport/rtc/command-router.d.ts +63 -3
- package/dist/transport/rtc/portal-packet.d.ts +3 -13
- package/dist/transport/rtc/session.d.ts +4 -2
- package/dist/transport/stored-image-cache.d.ts +14 -0
- package/package.json +1 -1
|
@@ -248,6 +248,12 @@ export declare class DeviceRegistry {
|
|
|
248
248
|
* for an expired token is the same lie {@link getDevices} stopped telling, one level down.
|
|
249
249
|
*/
|
|
250
250
|
refreshedList(sn: string): Promise<EufyDevice | undefined>;
|
|
251
|
+
/**
|
|
252
|
+
* Record that `sn` was heard on a realtime wire at `atMs`, advancing its {@link EufyDevice.lastSeenMs}.
|
|
253
|
+
*
|
|
254
|
+
* Only ever moves the time forward, and survives a {@link getDevices} rebuild.
|
|
255
|
+
*/
|
|
256
|
+
markSeen(sn: string, atMs: number): void;
|
|
251
257
|
/**
|
|
252
258
|
* Record state a device reported over its realtime wire, merging into whatever it last reported.
|
|
253
259
|
*
|
|
@@ -20,8 +20,8 @@ import { type P2PSession } from "../transport/p2p/p2p-session.js";
|
|
|
20
20
|
import { type CleanRecordPage } from "../model/clean-records.js";
|
|
21
21
|
import { type AvailabilityObservation, type EufyDevice } from "../core/types.js";
|
|
22
22
|
import { Device, type DeviceInspection } from "../model/index.js";
|
|
23
|
-
import type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, DeviceState, RealtimeReadiness, StationFace,
|
|
24
|
-
export type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, AnyDeviceEvent, DeviceState, RealtimePlaneReadiness, RealtimeReadiness, StationFace,
|
|
23
|
+
import type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, DeviceState, RealtimeReadiness, StationFace, StationReadOptions, WaitForRealtimeOptions } from "./types.js";
|
|
24
|
+
export type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, AnyDeviceEvent, DeviceState, RealtimePlaneReadiness, RealtimeReadiness, StationFace, StationReadOptions, WaitForRealtimeOptions, } from "./types.js";
|
|
25
25
|
export interface EufyMega {
|
|
26
26
|
on<E extends EufyMegaEvent>(event: E, listener: (...args: EufyMegaEventMap[E]) => void): this;
|
|
27
27
|
once<E extends EufyMegaEvent>(event: E, listener: (...args: EufyMegaEventMap[E]) => void): this;
|
|
@@ -54,6 +54,12 @@ export declare class EufyMega extends EventEmitter {
|
|
|
54
54
|
/** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
|
|
55
55
|
* publish await the SAME fully-connected transport instead of racing (and never open two). */
|
|
56
56
|
private readonly mqttReady;
|
|
57
|
+
/**
|
|
58
|
+
* Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
|
|
59
|
+
* subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
|
|
60
|
+
* starts empty.
|
|
61
|
+
*/
|
|
62
|
+
private readonly mqttSubscribed;
|
|
57
63
|
/** Device list/record/capability resolution + the frame→caps cache. */
|
|
58
64
|
private readonly registry;
|
|
59
65
|
private pushClient?;
|
|
@@ -123,6 +129,12 @@ export declare class EufyMega extends EventEmitter {
|
|
|
123
129
|
private readonly pollTimer;
|
|
124
130
|
/** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
|
|
125
131
|
private readonly pushRetryTimer;
|
|
132
|
+
/**
|
|
133
|
+
* Per wired station, the pending reopen of its session and the pause before the next one; see
|
|
134
|
+
* {@link reopenWiredStation}. Dropped for a station when its session connects, and wholly by
|
|
135
|
+
* {@link disconnect}.
|
|
136
|
+
*/
|
|
137
|
+
private readonly stationReopens;
|
|
126
138
|
constructor(opts: EufyMegaOptions);
|
|
127
139
|
/**
|
|
128
140
|
* Builds the inbound listener that converts ThingClips DP maps to realtime capability state.
|
|
@@ -307,7 +319,16 @@ export declare class EufyMega extends EventEmitter {
|
|
|
307
319
|
* manager's own log, since by then nothing is waiting to receive it.
|
|
308
320
|
*/
|
|
309
321
|
private recycleStandaloneSession;
|
|
310
|
-
/**
|
|
322
|
+
/**
|
|
323
|
+
* Reopen a wired station's session while the generation that warmed it is current: after
|
|
324
|
+
* {@link recycleStandaloneSession} closed it, after it closed on its own, or after its warm-up failed.
|
|
325
|
+
*
|
|
326
|
+
* A wired station's session is the persistent one realtime start opened, so a station left without one
|
|
327
|
+
* receives nothing until a command dials it again. The first reopen runs at once; one that fails is
|
|
328
|
+
* retried after {@link STATION_REOPEN_MIN_MS}, doubling up to {@link STATION_REOPEN_MAX_MS}, until the
|
|
329
|
+
* session connects or the generation ends. One reopen is pending per station at a time, and each failure
|
|
330
|
+
* is reported once, by {@link P2PCommandRouter.ensureStation}.
|
|
331
|
+
*/
|
|
311
332
|
private reopenWiredStation;
|
|
312
333
|
/**
|
|
313
334
|
* Whether the DECODED property now reads what the write asked for.
|
|
@@ -333,6 +354,8 @@ export declare class EufyMega extends EventEmitter {
|
|
|
333
354
|
* fans same-kind sensors out by channel, so the resolved serial is folded in; a payload's own
|
|
334
355
|
* fields still win on conflict.
|
|
335
356
|
*
|
|
357
|
+
* A station's camera-info table lands per channel, each channel's params on the device attached there.
|
|
358
|
+
*
|
|
336
359
|
* Params the station volunteered land as device state first. They arrive in the cloud record's own
|
|
337
360
|
* `param_type → value` shape, so no capability has to claim an id it does not own, and the reads
|
|
338
361
|
* they back stop waiting for the next cloud poll. A capability decode follows for the frames that
|
|
@@ -385,7 +408,9 @@ export declare class EufyMega extends EventEmitter {
|
|
|
385
408
|
/**
|
|
386
409
|
* On a successful login, kick off auto-realtime (unless `autoRealtime:false`). Fire-and-forget so
|
|
387
410
|
* `login()` returns as soon as the session is ready — realtime channels come up in the background and
|
|
388
|
-
* surface failures via `error`. Idempotent through the retained generation promise.
|
|
411
|
+
* surface failures via `error`. Idempotent through the retained generation promise. A login for a
|
|
412
|
+
* different account drops every stored image; one for the same account drops only the lifecycle
|
|
413
|
+
* failures recorded against the session it replaces.
|
|
389
414
|
*/
|
|
390
415
|
private afterLogin;
|
|
391
416
|
/**
|
|
@@ -458,7 +483,8 @@ export declare class EufyMega extends EventEmitter {
|
|
|
458
483
|
private rtcRouteFor;
|
|
459
484
|
/**
|
|
460
485
|
* The RTC media provider for a station on a media-track route, or a camera attached to a T9000 whose
|
|
461
|
-
* `device_channel` resolves back to it on the station; `undefined` otherwise
|
|
486
|
+
* `device_channel` resolves back to it on the station; `undefined` otherwise. A camera attached to a T8N00 gets the
|
|
487
|
+
* NVR's dedicated media call when {@link isNvrMediaCamera} admits it.
|
|
462
488
|
*/
|
|
463
489
|
private rtcMediaFor;
|
|
464
490
|
/**
|
|
@@ -534,6 +560,8 @@ export declare class EufyMega extends EventEmitter {
|
|
|
534
560
|
* first announces it and the others stay silent. Its timing says only when a caller happened to read; the
|
|
535
561
|
* value is the news. It applies what the device volunteered over realtime on top of the cloud half, which
|
|
536
562
|
* the registry keeps apart, so it can neither revert nor announce a revert of a report already landed.
|
|
563
|
+
* Nothing awaits that refresh, so its failure is reported like the other background paths' — a dead
|
|
564
|
+
* token as `sessionExpired`, anything else as `error` — and the read keeps its last-known value.
|
|
537
565
|
*
|
|
538
566
|
* The `Device` returned is held WEAKLY: it is what the inbound paths announce against, so a caller that
|
|
539
567
|
* wants property changes for a serial keeps its own reference. Dropping it stops the announcements, not
|
|
@@ -624,6 +652,12 @@ export declare class EufyMega extends EventEmitter {
|
|
|
624
652
|
* device absent from the previous pass is skipped — first sight is discovery, not a transition;
|
|
625
653
|
* {@link deviceState} answers an initial reading.
|
|
626
654
|
*
|
|
655
|
+
* A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
|
|
656
|
+
* of its params moved, since each re-resolution is a cloud param-list read.
|
|
657
|
+
*
|
|
658
|
+
* Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
|
|
659
|
+
* sized it from the roster at login, which is empty when the cloud was unreachable then.
|
|
660
|
+
*
|
|
627
661
|
* Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
|
|
628
662
|
* is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
|
|
629
663
|
* stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
|
|
@@ -737,7 +771,8 @@ export declare class EufyMega extends EventEmitter {
|
|
|
737
771
|
*/
|
|
738
772
|
keepStationWarm(stationSn: string): () => void;
|
|
739
773
|
/** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
|
|
740
|
-
* stations stay closed until an on-demand open. Best-effort per station
|
|
774
|
+
* stations stay closed until an on-demand open. Best-effort per station: one that fails is handed to
|
|
775
|
+
* {@link reopenWiredStation}. */
|
|
741
776
|
private warmWiredP2P;
|
|
742
777
|
/**
|
|
743
778
|
* A station's power tier for the P2P lifecycle: a HomeBase/station is `"wired"` (persistent); a
|
|
@@ -810,11 +845,20 @@ export declare class EufyMega extends EventEmitter {
|
|
|
810
845
|
/**
|
|
811
846
|
* Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
|
|
812
847
|
* unreachable device must not stop the rest of the roster from coming up).
|
|
848
|
+
* A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
|
|
849
|
+
* whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
|
|
850
|
+
* per transport.
|
|
813
851
|
*
|
|
814
852
|
* A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
|
|
815
853
|
* and the broker refuses its `eufy_home` topics on every credential.
|
|
816
854
|
*/
|
|
817
855
|
private subscribeMqttDevices;
|
|
856
|
+
/**
|
|
857
|
+
* Start every credential scope the roster now needs and has no transport for, and subscribe the
|
|
858
|
+
* devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
|
|
859
|
+
* the realtime generation is superseded or was never started.
|
|
860
|
+
*/
|
|
861
|
+
private syncMqttRoster;
|
|
818
862
|
/**
|
|
819
863
|
* Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
|
|
820
864
|
* state is pushed on change with no heartbeat, the request that makes its state readable before the
|
|
@@ -835,19 +879,45 @@ export declare class EufyMega extends EventEmitter {
|
|
|
835
879
|
*/
|
|
836
880
|
getP2pSessions(): Map<string, P2PSession>;
|
|
837
881
|
/**
|
|
838
|
-
* The people enrolled on a station, read from its own `person_basic_info` table
|
|
882
|
+
* The people enrolled on a station, read from its own `person_basic_info` table through
|
|
883
|
+
* {@link readStationTable}.
|
|
839
884
|
*
|
|
840
885
|
* The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
|
|
841
886
|
* station, because they were never uploaded — the station holds them.
|
|
842
887
|
*
|
|
843
|
-
* Resolves with the rows the station returned
|
|
844
|
-
*
|
|
845
|
-
* reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
|
|
888
|
+
* Resolves with the rows the station returned that are objects. Rejects as {@link readStationTable}
|
|
889
|
+
* does.
|
|
846
890
|
*
|
|
847
891
|
* `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
|
|
848
892
|
* returned as they arrive; a caller that wants only named people filters them.
|
|
849
893
|
*/
|
|
850
|
-
getStationFaces(stationSn: string, opts?:
|
|
894
|
+
getStationFaces(stationSn: string, opts?: StationReadOptions): Promise<StationFace[]>;
|
|
895
|
+
/**
|
|
896
|
+
* The rows of one of a station's own tables (`history_record_info`, `person_basic_info`, …), read over
|
|
897
|
+
* the transport the station speaks: the control channel of a HomeBase S1 Pro (T9000), P2P for any
|
|
898
|
+
* other station. The query is the table's whole content; the rows are the station's, shaped by it.
|
|
899
|
+
*
|
|
900
|
+
* Resolves with the rows the station returned. Rejects on a serial not in the device list, on a record
|
|
901
|
+
* stating no `admin_user_id`, on a station that cannot be reached, on a query the station refuses, and
|
|
902
|
+
* on the reply not arriving in time. Assembling the reply is the transport's; this resolves which
|
|
903
|
+
* station, which transport and which account id.
|
|
904
|
+
*/
|
|
905
|
+
readStationTable(stationSn: string, table: string, opts?: StationReadOptions): Promise<unknown[]>;
|
|
906
|
+
/**
|
|
907
|
+
* One image stored on a HomeBase S1 Pro (T9000), by the on-station path a table row names
|
|
908
|
+
* (`thumb_path`, …), read over the station's control channel.
|
|
909
|
+
*
|
|
910
|
+
* Resolves with the image bytes as the station holds them. Rejects on a serial not in the device list,
|
|
911
|
+
* on a record stating no `admin_user_id`, on a station other than a T9000, on a station that cannot be
|
|
912
|
+
* reached, and on the reply not arriving in time. Fetching the image is the transport's; this resolves
|
|
913
|
+
* which station and which account id.
|
|
914
|
+
*/
|
|
915
|
+
readStationImage(stationSn: string, filePath: string, opts?: StationReadOptions): Promise<Buffer>;
|
|
916
|
+
/**
|
|
917
|
+
* The station a database read goes to, its `admin_user_id` and, for a T9000, its RTC route. Throws,
|
|
918
|
+
* prefixed with `op`, on a serial the device list does not carry and on a record stating no admin id.
|
|
919
|
+
*/
|
|
920
|
+
private stationReadTarget;
|
|
851
921
|
/** The station's own `admin_user_id`, which its database refuses the query without. */
|
|
852
922
|
private adminUserIdOf;
|
|
853
923
|
/**
|
|
@@ -959,6 +1029,9 @@ export declare class EufyMega extends EventEmitter {
|
|
|
959
1029
|
* Returns the connected client rather than installing it. Registration can outlive a `disconnect()`,
|
|
960
1030
|
* and {@link ensureRealtime} owns the decision of whether a finished bring-up is still the current
|
|
961
1031
|
* one — so this never overwrites a channel a later login already brought up.
|
|
1032
|
+
*
|
|
1033
|
+
* Persisting the seen ids after each push reports a failed `save` through `error` rather than throwing:
|
|
1034
|
+
* the push handler runs inside the MCS socket's `data` callback, where a throw is an uncaught exception.
|
|
962
1035
|
*/
|
|
963
1036
|
private startPush;
|
|
964
1037
|
/**
|
package/dist/client/types.d.ts
CHANGED
|
@@ -202,9 +202,11 @@ export interface EufyMegaOptions extends MegaClientConfig {
|
|
|
202
202
|
* that choice on every host. The SDK reports when the device last spoke; the caller decides what that
|
|
203
203
|
* means — the same split as the snapshot cache TTL and the live power budget.
|
|
204
204
|
*
|
|
205
|
-
* P2P session
|
|
206
|
-
* when something needs one and closed when idle, so "no session" is the resting state of a healthy
|
|
207
|
-
* device.
|
|
205
|
+
* The absence of a P2P session is **not** a liveness signal and is not carried here: sessions are opened
|
|
206
|
+
* only when something needs one and closed when idle, so "no session" is the resting state of a healthy
|
|
207
|
+
* device. A station answering an open session is the opposite fact, positive evidence, and does advance
|
|
208
|
+
* the station's own {@link DeviceState.lastSeenMs} — never that of a device it covers, whose liveness its answer does
|
|
209
|
+
* not show. Transport visibility lives on `getP2pSessions()` and the `p2pConnect`/`p2pClose` events,
|
|
208
210
|
* station-scoped like the session itself.
|
|
209
211
|
*
|
|
210
212
|
* The cloud record carries no connectivity field either: it has no `status` / `device_online`, and the
|
|
@@ -215,8 +217,9 @@ export interface DeviceState {
|
|
|
215
217
|
/** The parent station whose P2P session covers this device (itself, when standalone). */
|
|
216
218
|
stationSn: string;
|
|
217
219
|
/**
|
|
218
|
-
* When the device last
|
|
219
|
-
*
|
|
220
|
+
* When the device was last heard from, in ms: its last report to the cloud, or a later one over a
|
|
221
|
+
* realtime wire, a station's heartbeat answer on its open P2P session included. The cloud half is bounded
|
|
222
|
+
* by the cloud's own slow refresh — minutes, not seconds — so it answers "is this device alive at all",
|
|
220
223
|
* not "what is it doing right now".
|
|
221
224
|
*/
|
|
222
225
|
lastSeenMs?: number;
|
|
@@ -240,9 +243,9 @@ export interface StationFace {
|
|
|
240
243
|
group_id?: number | string;
|
|
241
244
|
[column: string]: unknown;
|
|
242
245
|
}
|
|
243
|
-
/** How long to wait for a station's
|
|
244
|
-
export interface
|
|
245
|
-
/** Default 15s. A
|
|
246
|
+
/** How long to wait for a station's reply to a read, and how to give up early. */
|
|
247
|
+
export interface StationReadOptions {
|
|
248
|
+
/** Default 15s. A station that will not answer says nothing. */
|
|
246
249
|
timeoutMs?: number;
|
|
247
250
|
signal?: AbortSignal;
|
|
248
251
|
}
|
|
@@ -332,7 +335,7 @@ export type EufyMegaEventMap = {
|
|
|
332
335
|
map: VacuumMapSnapshot;
|
|
333
336
|
}];
|
|
334
337
|
/**
|
|
335
|
-
* A device
|
|
338
|
+
* A device was heard from since the last poll — its {@link DeviceState.lastSeenMs} advanced.
|
|
336
339
|
* Carries {@link DeviceState}; the host applies its own staleness threshold.
|
|
337
340
|
*
|
|
338
341
|
* Transport/session lifecycle is NOT this event: that's `p2pConnect`/`p2pClose`, station-scoped where
|
package/dist/core/contracts.d.ts
CHANGED
|
@@ -852,7 +852,9 @@ export interface MediaProvider {
|
|
|
852
852
|
* {@link ReceivedVideoFrame} envelopes whose original source-arrival time survives retained replay.
|
|
853
853
|
* Raw mode ignores arrivalTime. Audio is available separately through {@link live} or muxed through
|
|
854
854
|
* {@link recordFragments}; it is never interleaved into raw video. The caller owns the Readable's
|
|
855
|
-
* lifetime, and destroying it releases the shared pull.
|
|
855
|
+
* lifetime, and destroying it releases the shared pull. On a battery-budgeted source the Readable
|
|
856
|
+
* emits a `budget` event with a {@link StreamBudgetNotice}; unless `extend()` is called within its
|
|
857
|
+
* grace, the Readable ends.
|
|
856
858
|
*/
|
|
857
859
|
openReadable?(opts?: {
|
|
858
860
|
objectMode?: boolean;
|
package/dist/core/store.d.ts
CHANGED
|
@@ -48,7 +48,18 @@ export declare class MemorySessionStore<T = PersistedSession> implements Session
|
|
|
48
48
|
save(s: T): void;
|
|
49
49
|
clear(): void;
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* JSON-file store, e.g. new FileSessionStore("./.eufy-session.json").
|
|
53
|
+
*
|
|
54
|
+
* `save` resolves a symlinked path to its real file (the file a dangling symlink names, created with its
|
|
55
|
+
* directory), writes a fresh owner-only (`0o600`) temp file under a random name beside that file and
|
|
56
|
+
* renames it over it, so where the directory allows it the file on disk is always a complete record — the
|
|
57
|
+
* old one until the rename, the new one after — and the symlink itself stays in place. When the directory
|
|
58
|
+
* refuses the temp file or the rename (`EACCES`, `EPERM`, `EBUSY`, `EXDEV`, `EROFS`: a read-only directory
|
|
59
|
+
* around a writable file, a single-file bind mount), `save` rewrites the file in place instead, which is
|
|
60
|
+
* not atomic and keeps an existing file's mode. The atomic path always leaves `0o600`, whatever mode an
|
|
61
|
+
* existing file had. Any other failure removes the temp file and throws.
|
|
62
|
+
*/
|
|
52
63
|
export declare class FileSessionStore<T = PersistedSession> implements SessionStore<T> {
|
|
53
64
|
private readonly path;
|
|
54
65
|
constructor(path: string);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -80,8 +80,10 @@ export interface EufyDevice {
|
|
|
80
80
|
*/
|
|
81
81
|
paramUpdatedAt?: Record<number, number>;
|
|
82
82
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
83
|
+
* When the device was last heard from, in **milliseconds** (comparable to `Date.now()` directly): the
|
|
84
|
+
* most recent {@link paramUpdatedAt} across all of its params, or a later report over a realtime wire,
|
|
85
|
+
* including a station answering its own P2P session's heartbeat. Never moves backwards. `undefined` when
|
|
86
|
+
* neither has been seen.
|
|
85
87
|
*
|
|
86
88
|
* This is an observation — "the device last reported at T" — deliberately **not** a reachability
|
|
87
89
|
* verdict. How long a silence means "unreachable" depends on the device (a mains camera heartbeats
|