@mega-yfue/eufy-sdk 0.6.0-beta.3 → 0.6.0-beta.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/client/device-registry.d.ts +6 -0
  2. package/dist/client/eufy-mega.d.ts +98 -14
  3. package/dist/client/types.d.ts +12 -9
  4. package/dist/core/contracts.d.ts +5 -1
  5. package/dist/core/store.d.ts +12 -1
  6. package/dist/core/types.d.ts +4 -2
  7. package/dist/index.js +2801 -2158
  8. package/dist/index.js.map +4 -4
  9. package/dist/model/capabilities/access.d.ts +3 -2
  10. package/dist/model/capabilities/arming.d.ts +2 -1
  11. package/dist/model/capabilities/battery.d.ts +5 -1
  12. package/dist/model/capabilities/camera.d.ts +8 -1
  13. package/dist/model/capabilities/index.d.ts +1 -1
  14. package/dist/model/capabilities/members.d.ts +3 -1
  15. package/dist/model/capabilities/mower.d.ts +3 -2
  16. package/dist/model/capabilities/ptz.d.ts +11 -5
  17. package/dist/model/capabilities/vacuum-clean.d.ts +23 -0
  18. package/dist/model/device-family.d.ts +1 -1
  19. package/dist/model/device-types.d.ts +1 -0
  20. package/dist/transport/http/mega-client.d.ts +3 -2
  21. package/dist/transport/mqtt/biz-stream.d.ts +22 -25
  22. package/dist/transport/p2p/audio-frame.d.ts +13 -0
  23. package/dist/transport/p2p/codec.d.ts +2 -2
  24. package/dist/transport/p2p/command-router.d.ts +31 -3
  25. package/dist/transport/p2p/database-wire.d.ts +30 -0
  26. package/dist/transport/p2p/level2-settle.d.ts +9 -0
  27. package/dist/transport/p2p/live-stream.d.ts +34 -7
  28. package/dist/transport/p2p/live-trace.d.ts +6 -4
  29. package/dist/transport/p2p/p2p-session.d.ts +99 -15
  30. package/dist/transport/p2p/readable-egress.d.ts +3 -1
  31. package/dist/transport/p2p/recording-download.d.ts +26 -4
  32. package/dist/transport/p2p/shared-live-source.d.ts +9 -3
  33. package/dist/transport/p2p/video.d.ts +4 -2
  34. package/dist/transport/push/store.d.ts +7 -6
  35. package/dist/transport/rtc/command-router.d.ts +63 -3
  36. package/dist/transport/rtc/portal-packet.d.ts +3 -13
  37. package/dist/transport/rtc/session.d.ts +4 -2
  38. package/dist/transport/stored-image-cache.d.ts +14 -0
  39. 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, StationFacesOptions, WaitForRealtimeOptions } from "./types.js";
24
- export type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, AnyDeviceEvent, DeviceState, RealtimePlaneReadiness, RealtimeReadiness, StationFace, StationFacesOptions, WaitForRealtimeOptions, } from "./types.js";
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,20 @@ 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;
138
+ /**
139
+ * Refuse the one misnamed option that misroutes silently. `country` is not read — the account country
140
+ * is {@link EufyMegaOptions.countryCode}, which defaults to `"US"` — so a caller that passes `country`
141
+ * has it ignored. We throw only for the case that changes routing: a non-`"US"` `country` with no
142
+ * `countryCode`, which would run on the US shard (login succeeds, but the house list comes back an empty
143
+ * placeholder). `country: "US"` already matches the default route, so it is left to pass through.
144
+ */
145
+ private static assertCountryOption;
126
146
  constructor(opts: EufyMegaOptions);
127
147
  /**
128
148
  * Builds the inbound listener that converts ThingClips DP maps to realtime capability state.
@@ -307,7 +327,17 @@ export declare class EufyMega extends EventEmitter {
307
327
  * manager's own log, since by then nothing is waiting to receive it.
308
328
  */
309
329
  private recycleStandaloneSession;
310
- /** Reopen a wired station that {@link recycleStandaloneSession} closed, while the generation that warmed it is current. */
330
+ /**
331
+ * Reopen a wired station's session while the generation that warmed it is current: after
332
+ * {@link recycleStandaloneSession} closed it, after it closed on its own, or after its warm-up failed.
333
+ *
334
+ * A wired station's session is the persistent one realtime start opened, so a station left without one
335
+ * receives nothing until a command dials it again. The first reopen runs at once; one that fails is
336
+ * retried after {@link STATION_REOPEN_MIN_MS}, doubling up to {@link STATION_REOPEN_MAX_MS}, until the
337
+ * session connects or the generation ends. One reopen is pending per station at a time, and each failure
338
+ * is reported once, by {@link P2PCommandRouter.ensureStation}. A HomeBase S1 Pro (T9000) answers no P2P
339
+ * lookup, so it is never reopened.
340
+ */
311
341
  private reopenWiredStation;
312
342
  /**
313
343
  * Whether the DECODED property now reads what the write asked for.
@@ -333,6 +363,8 @@ export declare class EufyMega extends EventEmitter {
333
363
  * fans same-kind sensors out by channel, so the resolved serial is folded in; a payload's own
334
364
  * fields still win on conflict.
335
365
  *
366
+ * A station's camera-info table lands per channel, each channel's params on the device attached there.
367
+ *
336
368
  * Params the station volunteered land as device state first. They arrive in the cloud record's own
337
369
  * `param_type → value` shape, so no capability has to claim an id it does not own, and the reads
338
370
  * they back stop waiting for the next cloud poll. A capability decode follows for the frames that
@@ -385,7 +417,9 @@ export declare class EufyMega extends EventEmitter {
385
417
  /**
386
418
  * On a successful login, kick off auto-realtime (unless `autoRealtime:false`). Fire-and-forget so
387
419
  * `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.
420
+ * surface failures via `error`. Idempotent through the retained generation promise. A login for a
421
+ * different account drops every stored image; one for the same account drops only the lifecycle
422
+ * failures recorded against the session it replaces.
389
423
  */
390
424
  private afterLogin;
391
425
  /**
@@ -458,7 +492,8 @@ export declare class EufyMega extends EventEmitter {
458
492
  private rtcRouteFor;
459
493
  /**
460
494
  * 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, unless an NVR camera has its own dedicated media call.
495
+ * `device_channel` resolves back to it on the station; `undefined` otherwise. A camera attached to a T8N00 gets the
496
+ * NVR's dedicated media call when {@link isNvrMediaCamera} admits it.
462
497
  */
463
498
  private rtcMediaFor;
464
499
  /**
@@ -534,6 +569,8 @@ export declare class EufyMega extends EventEmitter {
534
569
  * first announces it and the others stay silent. Its timing says only when a caller happened to read; the
535
570
  * value is the news. It applies what the device volunteered over realtime on top of the cloud half, which
536
571
  * the registry keeps apart, so it can neither revert nor announce a revert of a report already landed.
572
+ * Nothing awaits that refresh, so its failure is reported like the other background paths' — a dead
573
+ * token as `sessionExpired`, anything else as `error` — and the read keeps its last-known value.
537
574
  *
538
575
  * The `Device` returned is held WEAKLY: it is what the inbound paths announce against, so a caller that
539
576
  * wants property changes for a serial keeps its own reference. Dropping it stops the announcements, not
@@ -624,6 +661,12 @@ export declare class EufyMega extends EventEmitter {
624
661
  * device absent from the previous pass is skipped — first sight is discovery, not a transition;
625
662
  * {@link deviceState} answers an initial reading.
626
663
  *
664
+ * A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
665
+ * of its params moved, since each re-resolution is a cloud param-list read.
666
+ *
667
+ * Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
668
+ * sized it from the roster at login, which is empty when the cloud was unreachable then.
669
+ *
627
670
  * Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
628
671
  * is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
629
672
  * stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
@@ -737,7 +780,9 @@ export declare class EufyMega extends EventEmitter {
737
780
  */
738
781
  keepStationWarm(stationSn: string): () => void;
739
782
  /** 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. */
783
+ * stations stay closed until an on-demand open, and a HomeBase S1 Pro (T9000), which answers no P2P
784
+ * lookup and is driven over RTC, is never opened here. Best-effort per station: one that fails is
785
+ * handed to {@link reopenWiredStation}. */
741
786
  private warmWiredP2P;
742
787
  /**
743
788
  * A station's power tier for the P2P lifecycle: a HomeBase/station is `"wired"` (persistent); a
@@ -753,10 +798,11 @@ export declare class EufyMega extends EventEmitter {
753
798
  * semantic event in — so a stream or talkback opened right after a doorbell ring or a detection starts
754
799
  * warm instead of paying a cold open.
755
800
  *
756
- * Four gates. `autoRealtime: false` means the SDK opens nothing on its own initiative at all;
801
+ * Five gates. `autoRealtime: false` means the SDK opens nothing on its own initiative at all;
757
802
  * {@link EufyMegaOptions.prewarmEvents} must name the event, and it names none by default, which is
758
- * what makes pre-warm opt-in; the station must be one the account actually reports; and its power tier
759
- * must be one {@link EufyMegaOptions.prewarmTiers} allows.
803
+ * what makes pre-warm opt-in; the station must be one the account actually reports; it must not be a
804
+ * HomeBase S1 Pro (T9000), which answers no P2P lookup and is driven over RTC; and its power tier must
805
+ * be one {@link EufyMegaOptions.prewarmTiers} allows.
760
806
  *
761
807
  * The tier is resolved for the STATION whose session would open, which is why an attached camera is
762
808
  * judged by its base — {@link P2PCommandRouter.stationKeyOf} is the single source of that mapping, and
@@ -810,11 +856,20 @@ export declare class EufyMega extends EventEmitter {
810
856
  /**
811
857
  * Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
812
858
  * unreachable device must not stop the rest of the roster from coming up).
859
+ * A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
860
+ * whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
861
+ * per transport.
813
862
  *
814
863
  * A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
815
864
  * and the broker refuses its `eufy_home` topics on every credential.
816
865
  */
817
866
  private subscribeMqttDevices;
867
+ /**
868
+ * Start every credential scope the roster now needs and has no transport for, and subscribe the
869
+ * devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
870
+ * the realtime generation is superseded or was never started.
871
+ */
872
+ private syncMqttRoster;
818
873
  /**
819
874
  * Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
820
875
  * state is pushed on change with no heartbeat, the request that makes its state readable before the
@@ -835,19 +890,45 @@ export declare class EufyMega extends EventEmitter {
835
890
  */
836
891
  getP2pSessions(): Map<string, P2PSession>;
837
892
  /**
838
- * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
893
+ * The people enrolled on a station, read from its own `person_basic_info` table through
894
+ * {@link readStationTable}.
839
895
  *
840
896
  * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
841
897
  * station, because they were never uploaded — the station holds them.
842
898
  *
843
- * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
844
- * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
845
- * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
899
+ * Resolves with the rows the station returned that are objects. Rejects as {@link readStationTable}
900
+ * does.
846
901
  *
847
902
  * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
848
903
  * returned as they arrive; a caller that wants only named people filters them.
849
904
  */
850
- getStationFaces(stationSn: string, opts?: StationFacesOptions): Promise<StationFace[]>;
905
+ getStationFaces(stationSn: string, opts?: StationReadOptions): Promise<StationFace[]>;
906
+ /**
907
+ * The rows of one of a station's own tables (`history_record_info`, `person_basic_info`, …), read over
908
+ * the transport the station speaks: the control channel of a HomeBase S1 Pro (T9000), P2P for any
909
+ * other station. The query is the table's whole content; the rows are the station's, shaped by it.
910
+ *
911
+ * Resolves with the rows the station returned. Rejects on a serial not in the device list, on a record
912
+ * stating no `admin_user_id`, on a station that cannot be reached, on a query the station refuses, and
913
+ * on the reply not arriving in time. Assembling the reply is the transport's; this resolves which
914
+ * station, which transport and which account id.
915
+ */
916
+ readStationTable(stationSn: string, table: string, opts?: StationReadOptions): Promise<unknown[]>;
917
+ /**
918
+ * One image stored on a HomeBase S1 Pro (T9000), by the on-station path a table row names
919
+ * (`thumb_path`, …), read over the station's control channel.
920
+ *
921
+ * Resolves with the image bytes as the station holds them. Rejects on a serial not in the device list,
922
+ * on a record stating no `admin_user_id`, on a station other than a T9000, on a station that cannot be
923
+ * reached, and on the reply not arriving in time. Fetching the image is the transport's; this resolves
924
+ * which station and which account id.
925
+ */
926
+ readStationImage(stationSn: string, filePath: string, opts?: StationReadOptions): Promise<Buffer>;
927
+ /**
928
+ * The station a database read goes to, its `admin_user_id` and, for a T9000, its RTC route. Throws,
929
+ * prefixed with `op`, on a serial the device list does not carry and on a record stating no admin id.
930
+ */
931
+ private stationReadTarget;
851
932
  /** The station's own `admin_user_id`, which its database refuses the query without. */
852
933
  private adminUserIdOf;
853
934
  /**
@@ -959,6 +1040,9 @@ export declare class EufyMega extends EventEmitter {
959
1040
  * Returns the connected client rather than installing it. Registration can outlive a `disconnect()`,
960
1041
  * and {@link ensureRealtime} owns the decision of whether a finished bring-up is still the current
961
1042
  * one — so this never overwrites a channel a later login already brought up.
1043
+ *
1044
+ * Persisting the seen ids after each push reports a failed `save` through `error` rather than throwing:
1045
+ * the push handler runs inside the MCS socket's `data` callback, where a throw is an uncaught exception.
962
1046
  */
963
1047
  private startPush;
964
1048
  /**
@@ -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 state is **not** a liveness signal and is not carried here: sessions are opened only
206
- * when something needs one and closed when idle, so "no session" is the resting state of a healthy
207
- * device. Transport visibility lives on `getP2pSessions()` and the `p2pConnect`/`p2pClose` events,
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 reported to the cloud, in ms. Bounded by
219
- * the cloud's own slow refresh — minutes, not seconds — so it answers "is this device alive at all",
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 database reply, and how to give up early. */
244
- export interface StationFacesOptions {
245
- /** Default 15s. A full table is a handful of frames; a station that will not answer says nothing. */
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 reported to the cloud since the last poll — its {@link DeviceState.lastSeenMs} advanced.
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
@@ -344,6 +344,8 @@ export type Command = {
344
344
  cmd: number;
345
345
  value: number;
346
346
  channel: number;
347
+ /** `"auto"`: the seal follows the session, level-2 with a key and level-1 without. Absent, level-2 only. */
348
+ form?: Extract<ScalarForm, "auto">;
347
349
  }
348
350
  /** P2P int-plus-string frame; the transport injects the authenticated account id string. */
349
351
  | {
@@ -852,7 +854,9 @@ export interface MediaProvider {
852
854
  * {@link ReceivedVideoFrame} envelopes whose original source-arrival time survives retained replay.
853
855
  * Raw mode ignores arrivalTime. Audio is available separately through {@link live} or muxed through
854
856
  * {@link recordFragments}; it is never interleaved into raw video. The caller owns the Readable's
855
- * lifetime, and destroying it releases the shared pull.
857
+ * lifetime, and destroying it releases the shared pull. On a battery-budgeted source the Readable
858
+ * emits a `budget` event with a {@link StreamBudgetNotice}; unless `extend()` is called within its
859
+ * grace, the Readable ends.
856
860
  */
857
861
  openReadable?(opts?: {
858
862
  objectMode?: boolean;
@@ -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
- /** JSON-file store, e.g. new FileSessionStore("./.eufy-session.json"). */
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);
@@ -80,8 +80,10 @@ export interface EufyDevice {
80
80
  */
81
81
  paramUpdatedAt?: Record<number, number>;
82
82
  /**
83
- * The most recent {@link paramUpdatedAt} across all of this device's params, in **milliseconds**
84
- * (comparable to `Date.now()` directly). `undefined` when the record carried no timestamp at all.
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