@mega-yfue/eufy-sdk 0.0.4 → 0.1.0-beta.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.
Files changed (143) hide show
  1. package/README.md +31 -12
  2. package/dist/client/device-registry.d.ts +343 -0
  3. package/dist/client/eufy-mega.d.ts +872 -0
  4. package/dist/client/index.d.ts +1 -6
  5. package/dist/client/map-channels.d.ts +22 -0
  6. package/dist/client/types.d.ts +383 -0
  7. package/dist/core/contracts.d.ts +898 -0
  8. package/dist/core/crypto.d.ts +98 -0
  9. package/dist/core/index.d.ts +9 -7
  10. package/dist/core/logger.d.ts +53 -0
  11. package/dist/core/lz4-block.d.ts +35 -0
  12. package/dist/core/raw-dp-hex.d.ts +32 -0
  13. package/dist/core/raw-dp-writer.d.ts +83 -0
  14. package/dist/core/store.d.ts +43 -0
  15. package/dist/core/types.d.ts +169 -0
  16. package/dist/core/util.d.ts +78 -0
  17. package/dist/index.d.ts +4 -7
  18. package/dist/index.js +25526 -14
  19. package/dist/index.js.map +7 -1
  20. package/dist/model/capabilities/access.d.ts +127 -0
  21. package/dist/model/capabilities/arming.d.ts +201 -0
  22. package/dist/model/capabilities/audio.d.ts +154 -0
  23. package/dist/model/capabilities/battery.d.ts +363 -0
  24. package/dist/model/capabilities/camera.d.ts +564 -0
  25. package/dist/model/capabilities/co.d.ts +40 -0
  26. package/dist/model/capabilities/contact.d.ts +133 -0
  27. package/dist/model/capabilities/doorbell.d.ts +345 -0
  28. package/dist/model/capabilities/dp-catalog.d.ts +38 -0
  29. package/dist/model/capabilities/index.d.ts +561 -0
  30. package/dist/model/capabilities/info.d.ts +28 -0
  31. package/dist/model/capabilities/keypad.d.ts +61 -0
  32. package/dist/model/capabilities/leak.d.ts +43 -0
  33. package/dist/model/capabilities/light.d.ts +174 -0
  34. package/dist/model/capabilities/locate.d.ts +63 -0
  35. package/dist/model/capabilities/lock.d.ts +242 -0
  36. package/dist/model/capabilities/manifest.d.ts +107 -0
  37. package/dist/model/capabilities/members.d.ts +647 -0
  38. package/dist/model/capabilities/motion.d.ts +377 -0
  39. package/dist/model/capabilities/person-detection.d.ts +8 -0
  40. package/dist/model/capabilities/ptz.d.ts +289 -0
  41. package/dist/model/capabilities/rtsp.d.ts +221 -0
  42. package/dist/model/capabilities/siren.d.ts +218 -0
  43. package/dist/model/capabilities/smart-light.d.ts +172 -0
  44. package/dist/model/capabilities/smoke.d.ts +40 -0
  45. package/dist/model/capabilities/snapshot.d.ts +6 -0
  46. package/dist/model/capabilities/storage.d.ts +11 -0
  47. package/dist/model/capabilities/suction.d.ts +104 -0
  48. package/dist/model/capabilities/types.d.ts +484 -0
  49. package/dist/model/capabilities/vacuum-clean.d.ts +1946 -0
  50. package/dist/model/capabilities/vacuum-dock.d.ts +208 -0
  51. package/dist/model/capabilities/video.d.ts +6 -0
  52. package/dist/model/classify.d.ts +78 -0
  53. package/dist/model/clean-record-detail.d.ts +65 -0
  54. package/dist/model/clean-records.d.ts +69 -0
  55. package/dist/model/device-family.d.ts +73 -0
  56. package/dist/model/device-types.d.ts +123 -0
  57. package/dist/model/device.d.ts +265 -0
  58. package/dist/model/index.d.ts +29 -4
  59. package/dist/model/infer.d.ts +23 -0
  60. package/dist/model/inspect.d.ts +61 -0
  61. package/dist/model/life-params.d.ts +21 -0
  62. package/dist/model/map-pixels.d.ts +70 -0
  63. package/dist/model/param-dictionary.d.ts +28 -0
  64. package/dist/model/param-namespace.d.ts +21 -0
  65. package/dist/model/proto-read.d.ts +53 -0
  66. package/dist/model/push-events.d.ts +147 -0
  67. package/dist/model/registry.d.ts +54 -0
  68. package/dist/model/types.d.ts +301 -0
  69. package/dist/model/vacuum-map-store.d.ts +92 -0
  70. package/dist/model/vacuum-map.d.ts +286 -0
  71. package/dist/model/vacuum-scenes.d.ts +76 -0
  72. package/dist/model/vacuum-schedules.d.ts +85 -0
  73. package/dist/transport/dp-preset.d.ts +102 -0
  74. package/dist/transport/ff09.d.ts +444 -0
  75. package/dist/transport/ffmpeg.d.ts +86 -0
  76. package/dist/transport/http/decodeImageV1.d.ts +20 -0
  77. package/dist/transport/http/decodeImageV2.d.ts +19 -0
  78. package/dist/transport/http/index.d.ts +5 -0
  79. package/dist/transport/http/light-catalog.d.ts +62 -0
  80. package/dist/transport/http/media-download.d.ts +14 -0
  81. package/dist/transport/http/mega-client.d.ts +514 -0
  82. package/dist/transport/http/phone-model.d.ts +21 -0
  83. package/dist/transport/index.d.ts +10 -7
  84. package/dist/transport/mqtt/app-client-id.d.ts +16 -0
  85. package/dist/transport/mqtt/availability.d.ts +14 -0
  86. package/dist/transport/mqtt/bare-ip-tls.d.ts +46 -0
  87. package/dist/transport/mqtt/biz-stream.d.ts +98 -0
  88. package/dist/transport/mqtt/broker-discovery.d.ts +55 -0
  89. package/dist/transport/mqtt/clean-codec.d.ts +14 -0
  90. package/dist/transport/mqtt/command-router.d.ts +285 -0
  91. package/dist/transport/mqtt/dp-codec.d.ts +58 -0
  92. package/dist/transport/mqtt/dp-color.d.ts +14 -0
  93. package/dist/transport/mqtt/engine.d.ts +16 -0
  94. package/dist/transport/mqtt/index.d.ts +5 -0
  95. package/dist/transport/mqtt/secure-mqtt.d.ts +107 -0
  96. package/dist/transport/mqtt/topics.d.ts +80 -0
  97. package/dist/transport/p2p/adts.d.ts +91 -0
  98. package/dist/transport/p2p/annexb.d.ts +124 -0
  99. package/dist/transport/p2p/codec.d.ts +166 -0
  100. package/dist/transport/p2p/command-router.d.ts +655 -0
  101. package/dist/transport/p2p/commands.d.ts +550 -0
  102. package/dist/transport/p2p/envelope.d.ts +46 -0
  103. package/dist/transport/p2p/fmp4.d.ts +89 -0
  104. package/dist/transport/p2p/fragment-recording.d.ts +33 -0
  105. package/dist/transport/p2p/index.d.ts +13 -0
  106. package/dist/transport/p2p/lan-ip.d.ts +25 -0
  107. package/dist/transport/p2p/live-stream.d.ts +223 -0
  108. package/dist/transport/p2p/live-trace.d.ts +123 -0
  109. package/dist/transport/p2p/media.d.ts +105 -0
  110. package/dist/transport/p2p/p2p-session.d.ts +620 -0
  111. package/dist/transport/p2p/readable-egress.d.ts +27 -0
  112. package/dist/transport/p2p/session-manager.d.ts +154 -0
  113. package/dist/transport/p2p/shared-live-source.d.ts +431 -0
  114. package/dist/transport/p2p/talkback.d.ts +187 -0
  115. package/dist/transport/p2p/video.d.ts +150 -0
  116. package/dist/transport/p2p/write-commands.d.ts +21 -0
  117. package/dist/transport/protobuf.d.ts +5 -0
  118. package/dist/transport/push/fcm.d.ts +23 -0
  119. package/dist/transport/push/index.d.ts +6 -0
  120. package/dist/transport/push/message-tags.d.ts +26 -0
  121. package/dist/transport/push/parser.d.ts +27 -0
  122. package/dist/transport/push/proto.d.ts +11 -0
  123. package/dist/transport/push/push-client.d.ts +64 -0
  124. package/dist/transport/push/store.d.ts +23 -0
  125. package/dist/transport/push/types.d.ts +180 -0
  126. package/dist/transport/raw-dp.d.ts +6 -0
  127. package/dist/transport/stored-image-cache.d.ts +23 -0
  128. package/dist/transport/tuya/account.d.ts +44 -0
  129. package/dist/transport/tuya/client.d.ts +89 -0
  130. package/dist/transport/tuya/command-router.d.ts +79 -0
  131. package/dist/transport/tuya/dp-codec.d.ts +56 -0
  132. package/dist/transport/tuya/index.d.ts +28 -0
  133. package/dist/transport/tuya/request.d.ts +157 -0
  134. package/dist/transport/tuya/sign.d.ts +64 -0
  135. package/package.json +12 -13
  136. package/dist/client/index.js +0 -2
  137. package/dist/client/index.js.map +0 -1
  138. package/dist/core/index.js +0 -2
  139. package/dist/core/index.js.map +0 -1
  140. package/dist/model/index.js +0 -2
  141. package/dist/model/index.js.map +0 -1
  142. package/dist/transport/index.js +0 -2
  143. package/dist/transport/index.js.map +0 -1
@@ -0,0 +1,655 @@
1
+ /**
2
+ * P2P command router — the transport-side owner of the ThroughTek PPCS sessions and every wire
3
+ * operation over them: opening sessions, resolving a serial to its session + routing params,
4
+ * mapping a transport-neutral {@link Command} to a concrete frame (encryption level, wire shape),
5
+ * the fire-and-forget control senders, request/reply queries, and the media provider.
6
+ *
7
+ * Layering: this module knows P2P bytes; it does NOT know capabilities. Frame → semantic-event
8
+ * decoding is a model concern, so raw frames are handed back to the client via {@link P2PRouterDeps.onFrame}
9
+ * (the client gates them on device capabilities and emits typed events). This keeps transport free of
10
+ * any `model/` import — the capability↔transport decorrelation invariant.
11
+ */
12
+ import type { MegaHttpClient } from "../http/mega-client.js";
13
+ import type { EufyDevice } from "../../core/types.js";
14
+ import type { Command, AutoLockSnapshot, MediaProvider, SharedSourceHints, AbortableCall } from "../../core/contracts.js";
15
+ import { type Logger } from "../../core/logger.js";
16
+ import { P2PSession, type P2PFrame } from "./p2p-session.js";
17
+ import type { FfmpegLevel } from "../ffmpeg.js";
18
+ import { SharedLiveSource } from "./shared-live-source.js";
19
+ import { type PowerTier, type SessionManagerOpts } from "./session-manager.js";
20
+ import { FragmentRecording } from "./fragment-recording.js";
21
+ /**
22
+ * Options accepted when warming a {@link SharedLiveSource} for a device (all optional).
23
+ *
24
+ * {@link SharedSourceHints} are the members any media egress may supply, because any of them may be the
25
+ * call that opens the pull; the rest reach it only from a caller that warms a source directly.
26
+ */
27
+ export interface SharedLiveOpts extends SharedSourceHints, AbortableCall {
28
+ eccPrivateKey?: Buffer;
29
+ keepAliveMs?: number;
30
+ lingerMs?: number;
31
+ /** Battery/solar continuous-stream budget in ms (default 45000). */
32
+ batteryBudgetMs?: number;
33
+ /** Grace after the budget notice to `extend()` before auto-stop, in ms (default 10000). */
34
+ budgetGraceMs?: number;
35
+ }
36
+ /**
37
+ * The facade-side dependencies the router needs. It owns the sessions map and all wire logic, but
38
+ * defers device-list access + lifecycle/frame event fan-out to the client (which owns the typed
39
+ * EventEmitter and the model-coupled frame decode).
40
+ */
41
+ export interface P2PRouterDeps {
42
+ mega: MegaHttpClient;
43
+ /** Diagnostics sink, forwarded to every P2P session. Omit for silence. */
44
+ logger?: Logger;
45
+ /** ffmpeg `-loglevel` for the media (snapshot/record) paths. Default `"error"`. */
46
+ ffmpegLogLevel?: FfmpegLevel;
47
+ /** The ffmpeg executable the media paths run. Default: the bare name, looked up on `PATH`. */
48
+ ffmpegPath?: string;
49
+ /** Current (already-loaded) device list. */
50
+ listDevices: () => EufyDevice[];
51
+ /** Load the device list if it isn't loaded yet (delegates to the client's getDevices). */
52
+ ensureDevices: () => Promise<void>;
53
+ onConnect: (stationSn: string) => void;
54
+ onClose: (stationSn: string) => void;
55
+ onError: (err: Error) => void;
56
+ onLevel2Ready: (stationSn: string, cipherId: number) => void;
57
+ /** A raw decoded frame — the client emits the low-level `p2p` event + runs the semantic decode. */
58
+ onFrame: (stationSn: string, frame: P2PFrame) => void;
59
+ /**
60
+ * Power tier per parent-station serial (`"wired"` = persistent session, `"battery"` = on-demand +
61
+ * idle-detach). Injected by the facade from resolved capabilities — plain data, so transport never
62
+ * imports model. Default (absent): every station treated as `"wired"` (today's persistent behaviour).
63
+ */
64
+ poweredFor?: (parentSn: string) => PowerTier;
65
+ /** Idle/keepalive window overrides for the session lifecycle (see {@link SessionManagerOpts}). */
66
+ sessionIdle?: Pick<SessionManagerOpts, "batteryIdleMs">;
67
+ /** LAN address overrides for direct P2P, keyed by parent-station serial (host or host:port). */
68
+ localAddresses?: Record<string, string>;
69
+ /** Suppress the `255.255.255.255` local-lookup broadcast; cloud lookup and a known LAN address still run. */
70
+ noBroadcast?: boolean;
71
+ }
72
+ export declare class P2PCommandRouter {
73
+ private readonly deps;
74
+ /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
75
+ private readonly manager;
76
+ /** Error objects already forwarded while a station startup awaits the same session signal. */
77
+ private readonly reportedErrors;
78
+ /** One shared live source per `${parentSn}:${channel}` — collapses N live() calls to one pull. */
79
+ private readonly liveSources;
80
+ /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
81
+ private readonly liveSourceOpts;
82
+ /**
83
+ * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
84
+ * time and the session carries one audio sequence, so this path is exclusive where a live pull is
85
+ * shared — see {@link P2PCommandRouter.openTalkback}.
86
+ */
87
+ private readonly talkbacks;
88
+ /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
89
+ private readonly cipherKeyCache;
90
+ constructor(deps: P2PRouterDeps);
91
+ /** Forward one P2P failure once even when both the session listener and startup waiter observe it. */
92
+ private reportError;
93
+ /**
94
+ * Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
95
+ * usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
96
+ * transport-neutral command. Keyed on the endpoint, NOT `classifyDevice`'s `realtime` tag: that tag is
97
+ * `"p2p"` for the ENTIRE `eufy_security` category, so it can't tell a P2P lock (T8531, own `p2p_did`)
98
+ * from an MQTT-only lock/garage (T85D0, empty `p2p_did`) — routing the latter to P2P throws
99
+ * `no P2P session`.
100
+ */
101
+ static claimsDevice(dev: EufyDevice): boolean;
102
+ /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
103
+ getSessions(): Map<string, P2PSession>;
104
+ /**
105
+ * Speculatively open + briefly hold a station's session (e.g. after a doorbell ring) so a
106
+ * tap-to-view / talkback attaches to a warm session. Transport-neutral: the facade maps the semantic
107
+ * event → station and decides whether this station may be pre-warmed at all; the router never learns
108
+ * event semantics.
109
+ *
110
+ * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
111
+ * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
112
+ * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
113
+ * and the lookups are away, not once the peer has answered, so both would expire together.
114
+ *
115
+ * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
116
+ * session was deliberately closed underneath a speculative open, which is not a fault to report.
117
+ */
118
+ prewarm(parentSn: string, ms?: number): Promise<void>;
119
+ /** Close every P2P session and drop them. */
120
+ closeAll(): Promise<void>;
121
+ /** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
122
+ private recordFor;
123
+ /** The parent-station key a device's session lives under (its HomeBase, or itself if standalone). */
124
+ private stationKeyFor;
125
+ /**
126
+ * The parent-station serial a device serial's session lives under — the single source of truth for
127
+ * session keying, used by the facade (e.g. to pre-warm the right station for an event). Returns the
128
+ * serial itself if the device isn't loaded (a standalone device is its own station).
129
+ */
130
+ stationKeyOf(sn: string): string;
131
+ /** Reset only a standalone device's session; an attached device must not close its shared HomeBase. */
132
+ resetStandaloneSession(sn: string): Promise<void>;
133
+ /**
134
+ * Open (or reuse) the P2P session for a station **on demand**, coalescing concurrent cold opens via
135
+ * the {@link SessionManager}. A command / stream / pre-warm opens only the station it targets; idle
136
+ * battery stations auto-close. The station's own record carries the P2P creds — a serial with no
137
+ * record of its own throws, because every value the session carries comes from that one record (the
138
+ * endpoint dialled, its cloud and LAN addresses, the admin user id the cipher lookup quotes) and is
139
+ * keyed under that one serial, so there is no partial answer to give. A per-station DSK key is
140
+ * fetched best-effort (ThroughTek PPCS UDP, LAN broadcast fallback if the key lookup fails). The LAN
141
+ * address for a direct local lookup is a caller-supplied override ({@link P2PRouterDeps.localAddresses})
142
+ * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
143
+ * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
144
+ */
145
+ private openStation;
146
+ /**
147
+ * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
148
+ *
149
+ * `resolveCipherKey` auto-negotiates the level-2 session key from `CMD_GATEWAYINFO` by resolving the
150
+ * cipher's ECC private key via cloud `get_ciphers`, so signCode 2/8 frames decrypt live; results are
151
+ * cached on the router instance so a lazy re-open (after idle-detach) reuses the lookup. Only a
152
+ * SUCCESSFUL lookup is cached — caching `undefined` after a transient failure would permanently
153
+ * disable level-2 for the session's life.
154
+ *
155
+ * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
156
+ * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
157
+ */
158
+ private makeSession;
159
+ /**
160
+ * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
161
+ * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
162
+ */
163
+ ensureStation(parentSn: string, signal?: AbortSignal): Promise<void>;
164
+ /** Resolve a serial to its loaded device record, opening its station's P2P session on demand. */
165
+ deviceFor(sn: string): Promise<EufyDevice>;
166
+ /**
167
+ * Route a transport-neutral {@link Command} to its wire transport — the command-sink
168
+ * implementation. Capability modules emit intent; this is the one place that knows P2P.
169
+ */
170
+ dispatchCommand(sn: string, cmd: Command): Promise<void>;
171
+ /**
172
+ * Restart a HomeBase. `RESTART_HUB` (1034) is a station-scalar on the broadcast channel 255: a
173
+ * level-2 frame whose body is `[u32 value][account_id padded]` — the same shape as the hub
174
+ * alarm-volume control. ✅ Wire-confirmed byte-exact from a capture of the app's own Restart
175
+ * (2026-08-03) and HW-tested: the captured frame carried value `0` and rebooted the hub. Replays
176
+ * like every other level-2 control, so a single dropped datagram doesn't lose it.
177
+ */
178
+ rebootStation(sn: string): Promise<void>;
179
+ /**
180
+ * A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
181
+ *
182
+ * Every live egress here is a consumer of the SAME shared pull, so N `live()` calls collapse to one
183
+ * PPCS session and a live snapshot against a warm, keyframe-primed source costs no extra pull at all; a
184
+ * cold source warms one and waits for a clean keyframe. Each shared egress passes its complete options
185
+ * through because any of them may create the source, whose power and retention hints are fixed for
186
+ * everyone who joins later. The bounded {@link MediaProvider.record} clip is the exception: it opens its
187
+ * own pull, receives its session topology directly, and requires the level-2 key an attached camera's
188
+ * start has no level-1 form for.
189
+ */
190
+ mediaProviderFor(sn: string): MediaProvider;
191
+ /**
192
+ * Open a {@link Talkback} on a device's camera channel.
193
+ *
194
+ * **The camera only plays host audio while its media session is open** — verified live on three
195
+ * cameras: the identical start + audio frames produce silence with no media session and audible
196
+ * playback with one. So this attaches a consumer to the shared live source and holds it for the
197
+ * talkback's lifetime, releasing it on stop. The source is shared and refcounted, so an already-open
198
+ * stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
199
+ * instead of playing into silence.
200
+ *
201
+ * The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
202
+ * {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
203
+ * own-session camera that legitimately never negotiates one.
204
+ *
205
+ * Both of the media consumer's events are forwarded rather than left to default. An unhandled
206
+ * `error` on it would take the host process down, and a warm-up failure is exactly the condition
207
+ * that makes talkback silent, so it reaches the caller when one is listening and the log otherwise.
208
+ * A `budget` notice means a battery camera's session is about to auto-stop and take the audio with
209
+ * it mid-sentence; forwarding it lets a caller extend, while ignoring it stops on schedule and
210
+ * protects the battery. The budget belongs to the shared source rather than to one consumer, so a
211
+ * single `extend()` covers a live stream and a talkback running side by side.
212
+ *
213
+ * `stop` ends the talkback with it. The media session going away is the one condition under which
214
+ * audio cannot be heard no matter how well it is framed, so pacing on into a dead session would be
215
+ * silent failure rather than a shorter clip.
216
+ *
217
+ * **One talkback per camera at a time.** Unlike a live pull, this path cannot be fanned out: both
218
+ * handles would pace onto one session's single audio sequence, interleaving two AAC streams into
219
+ * something unplayable, and whichever stopped first would close the device's path under the other —
220
+ * with no error on either side. The second caller is refused rather than handed the first one's
221
+ * handle, which would silently discard its `encoder` and hand it a clip already in progress.
222
+ *
223
+ * The refusal is decided and RECORDED in one synchronous step, before the shared media source is awaited.
224
+ * Warming that source is a round-trip, so two concurrent callers would otherwise both find the map empty,
225
+ * both build a talkback, and the second would overwrite the first in the map — two paced streams on the one
226
+ * audio sequence, and the orphaned handle no longer reachable by {@link P2PCommandRouter.closeAll}. The
227
+ * entry is therefore claimed by the talkback itself, which the media consumer is wired into once it exists;
228
+ * a failure to warm or to open the audio path releases the claim.
229
+ *
230
+ * The claim is re-checked after the wait for the mirror case: a station close or `closeAll` in that window
231
+ * stops the talkback that is holding it, and starting the pacing tick on a stopped talkback would pace into
232
+ * a session nobody is listening on.
233
+ */
234
+ private openTalkback;
235
+ /**
236
+ * Resolve a serial to its **shared live source** — one underlying pull per `${parentSn}:${channel}`,
237
+ * fanned out to every consumer (see {@link SharedLiveSource}). Lazily warmed on the first consumer;
238
+ * the `makeStream` factory rebuilds a fresh {@link LiveStream} on each (re)warm so a reconnect can
239
+ * recover. Uses `waitLevel2:"soft"` — mirrors `live()`, no hard-fail on a standalone camera. Wires
240
+ * `onActive`/`onIdle` so an attached stream counts as a user of the station's P2P session (cancels
241
+ * the session idle-detach while streaming; its longer idle timer arms when the last consumer leaves).
242
+ *
243
+ * A source that has **stopped** (linger teardown, failed start, budget auto-stop, upstream error) is
244
+ * dropped here rather than re-used, whatever is still attached to it. Its pull is dead, so nothing is
245
+ * being protected by keeping it — and keeping it would leave the options of whichever egress created
246
+ * it first in force for the process lifetime, so a stray `powered` from the day's first snapshot would
247
+ * still be dictating the budget hours later. Dropping it lets the next caller build a fresh source
248
+ * from its own options.
249
+ *
250
+ * Attachment count is deliberately NOT part of that test. A failed start fails its consumers without
251
+ * detaching them, so a caller still holding its handle leaves the count non-zero — and requiring an
252
+ * empty source here would hand one dead source out for the life of the client. A caller must
253
+ * re-acquire through this method after a failure; `attach()` on the dropped source throws, because it
254
+ * has been disposed.
255
+ *
256
+ * Several cameras behind one station each get their own source: the station tags every media frame with the
257
+ * camera it belongs to, and {@link LiveStream} takes only its own.
258
+ *
259
+ * Whether they can be SERVED at the same time is the station's business, not this map's. Where it serves one
260
+ * camera at a time, a pull still lingering for a camera nobody is watching would go on re-issuing its own
261
+ * media start against the one being asked for, so opening a new channel releases those first — see
262
+ * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
263
+ * reuse branch, so a reuse frees the station as a cold start does.
264
+ *
265
+ * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
266
+ * place.
267
+ */
268
+ sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts): Promise<SharedLiveSource>;
269
+ /**
270
+ * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
271
+ *
272
+ * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
273
+ * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
274
+ * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
275
+ * long as the linger lasts.
276
+ *
277
+ * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
278
+ * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
279
+ * looking at competing with one somebody just asked for.
280
+ *
281
+ * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
282
+ * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
283
+ * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
284
+ * and the live consumer receiving nothing beyond the retained keyframe until its deadline fired. So a live
285
+ * request also takes the channel from a sibling held only by snapshots, while a snapshot request takes
286
+ * nothing from anyone: a home page must not fight itself, and a viewer outranks a thumbnail in one
287
+ * direction only.
288
+ */
289
+ private releaseLingeringSiblings;
290
+ /**
291
+ * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
292
+ *
293
+ * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
294
+ * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
295
+ * and counting that as a viewer would refuse every later stream on the station until the client
296
+ * restarted. Only a source that can still deliver holds a place.
297
+ */
298
+ private occupiedSiblingChannel;
299
+ /**
300
+ * Whether the stream on `key` should re-assert its channel to hold the station.
301
+ *
302
+ * A re-assert on an attached camera is a full media start, so it takes the station from whichever camera
303
+ * it was serving. Three answers, in order:
304
+ *
305
+ * - Nothing attached: no. There is nobody to take the station for.
306
+ * - A live viewer attached: yes. That is the picture someone is looking at.
307
+ * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
308
+ * tile that is off screen while the live view is on it, and a station serving one camera at a time
309
+ * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
310
+ * it took, and its own capture then took fifteen seconds because it was contending.
311
+ *
312
+ * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
313
+ * unaffected.
314
+ */
315
+ /**
316
+ * Attach a consumer, unless the caller has already abandoned the call.
317
+ *
318
+ * The acquisition it just waited through can outlast the caller's interest, and a consumer attached for
319
+ * somebody who has gone keeps the pull warm for nobody. Detaching immediately gives the pull back, which
320
+ * lets it linger and fall away if this was the only thing holding it, and leaves it untouched if it was
321
+ * not.
322
+ */
323
+ private attachUnlessAborted;
324
+ /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
325
+ private dropLiveSource;
326
+ /**
327
+ * Drop everything that was riding a station's session, and report the station closed.
328
+ *
329
+ * A live source holds the `P2PSession` it was BUILT with and never re-resolves it, so one left cached
330
+ * past its session is handed back to the next viewer over a dead connection: it answers the retained
331
+ * keyframe, then fails on the warm-up deadline. Talkbacks are the same shape. Both are therefore
332
+ * dropped whenever the session under them goes.
333
+ *
334
+ * Reached two ways, both idempotent: the session's own `close` event, when it died while still the
335
+ * station's registered session, and {@link SessionManagerOpts.onAutoClose}, when the manager closed it
336
+ * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} disposes its own
337
+ * sources first, and {@link replaceUnreachableSession} keeps its source alive on purpose to rewarm it
338
+ * on the replacement session.
339
+ */
340
+ private tearDownStation;
341
+ /**
342
+ * A live start produced no keyframe. Drop the source, and recycle the device's P2P session when doing so
343
+ * is safe.
344
+ *
345
+ * Rebuilding the stream alone is not enough when it is the session, or the per-device state carried on
346
+ * it, that has stopped serving this camera: every later attach builds another stream over the same
347
+ * cached session and fails identically, which is why only a client restart recovered it.
348
+ *
349
+ * What a recycle actually replaces is the `P2PSession` INSTANCE. `close()` discards the manager's entry
350
+ * first and invalidates that connection's level-2 key and sequence; the next acquisition builds a new
351
+ * instance with a new socket, a new RSA keypair offered as `encryptkey`, and fresh sequence windows.
352
+ *
353
+ * The close is issued BEFORE the source is dropped, because discarding the manager entry is synchronous:
354
+ * from that moment a concurrent acquisition resolves a fresh session rather than the doomed one. It would
355
+ * find the not-yet-dropped source in that window, which is exactly why a stopped source is replaced
356
+ * regardless of what is attached to it.
357
+ *
358
+ * Only a **standalone** device's session is recycled, resolved through {@link stationKeyOf} so this and
359
+ * {@link resetStandaloneSession} cannot disagree about what standalone means. An attached camera shares
360
+ * its HomeBase session with every other camera on it, and closing that to recover one would drop the
361
+ * rest, so an attached camera gets the stream rebuild and nothing more. Unlike
362
+ * {@link resetStandaloneSession} this does not wait for the station to fall idle: the failed source's own
363
+ * session user is still counted, so a deferred reset would never fire.
364
+ */
365
+ private onLiveStartFailed;
366
+ /**
367
+ * Replace the session under a warming source whose media start nothing acknowledged, and warm again on it.
368
+ *
369
+ * Only a STANDALONE device's session is replaced, for the reason {@link onLiveStartFailed} gives: an attached
370
+ * camera shares its HomeBase session with every other camera on it, and closing that to recover one would
371
+ * drop the rest. Such a source keeps the re-issue it always had.
372
+ *
373
+ * The source is left warming throughout, holding the deadline it started, so this either produces a stream
374
+ * within that window or fails exactly as it would have. A replacement that cannot be opened leaves the
375
+ * source to its deadline rather than failing it early — the window is the caller's contract.
376
+ */
377
+ private replaceUnreachableSession;
378
+ /**
379
+ * Warn when a caller asks for a shared source with options that disagree with the ones it was built
380
+ * from. A source is created once per `${parentSn}:${channel}` and every later caller simply joins it,
381
+ * so those options are dropped — the failure mode being a battery camera streaming unbounded because
382
+ * whichever egress opened the source first did not pass `powered`. Nothing can be re-applied to a
383
+ * pull that consumers are already attached to, so this reports the conflict rather than pretending to
384
+ * honour it; a source that has since stopped is dropped instead, in {@link sharedLiveSourceFor}.
385
+ */
386
+ private warnIgnoredLiveOpts;
387
+ /**
388
+ * **Continuous fragmented-MP4 recording** — attach a consumer to the device's shared live source and
389
+ * yield CMAF fragments (init segment first, then a `moof`+`mdat` per keyframe boundary) muxed by the
390
+ * dependency-free {@link Fmp4Muxer}. The returned recording handle exposes battery-budget notices
391
+ * and detaches its consumer on `stop`, iterator return, or iterator throw. No ffmpeg.
392
+ */
393
+ recordFragments(sn: string, opts?: {
394
+ fragmentSeconds?: number;
395
+ eccPrivateKey?: Buffer;
396
+ keepAliveMs?: number;
397
+ } & SharedSourceHints): FragmentRecording;
398
+ /**
399
+ * **Generic P2P request/reply query.** Sends a `SET_PAYLOAD` (1350) wrapper carrying `subCmd` on the
400
+ * device channel, then resolves with the reply frame's `payload` — the `NOTIFY_PAYLOAD` (1351)
401
+ * whose JSON `cmd` echoes `subCmd` — decoded off the session `data` event. Needs the level-2 key.
402
+ */
403
+ p2pQuery(sn: string, subCmd: number, opts?: {
404
+ timeoutMs?: number;
405
+ }): Promise<Record<string, unknown>>;
406
+ /**
407
+ * **Generic control-payload request/reply query.** Sends a `CONTROL_PAYLOAD` (1700) `{commandType,
408
+ * data}` (level chosen by topology, like {@link routeControl}), then resolves with the reply
409
+ * frame's `payload` — the `NOTIFY_PAYLOAD` (1351) whose JSON `cmd` echoes `param` — decoded off the
410
+ * session `data` event. The listener is armed BEFORE the send so a fast reply can't race it (same
411
+ * ordering as {@link p2pQuery}).
412
+ */
413
+ p2pControlQuery(sn: string, param: number, data: Record<string, unknown>, opts?: {
414
+ timeoutMs?: number;
415
+ }): Promise<Record<string, unknown>>;
416
+ /**
417
+ * Resolve a scalar `"set-param"` intent to a concrete P2P frame — the ONE place that maps a
418
+ * capability's *what* (param + value + {@link ScalarForm}) to the *how* (encryption level + wire):
419
+ * `"auto"` defers the level to {@link sendBySessionLevel} — the ONE decision point — while
420
+ * `"int-string"` pins L1 and `"direct-binary"` pins L2.
421
+ */
422
+ private resolveScalarParam;
423
+ /**
424
+ * Read the camera's LIVE authoritative RTSP URL — host, path, and the credentials it enforces
425
+ * RIGHT NOW — by writing the publish switch `CMD_NAS_SWITCH` (idempotent when already on, and never
426
+ * touching the credentials themselves, so a NAS/NVR consuming the stream elsewhere is undisturbed)
427
+ * plus `CMD_NAS_TEST` to start the livestream, then awaiting the `rtspUrl` event `P2PSession` emits
428
+ * for a matching-channel `CMD_NAS_SWITCH` push. This is the only source of the freshly-generated
429
+ * credentials: the vendor app regenerates them on every publish toggle and the cloud record lags.
430
+ *
431
+ * Both provokes go through {@link resolveScalarParam} `"auto"` — the ONE level decision — not a
432
+ * pinned level-1 send: a keyed HomeBase publishes 1145 on its level-2 seal, and the level-1 form is
433
+ * silently ignored there (the likely cause of attached-camera reads never answering). The shared
434
+ * path also repeats the datagram for RF resilience, exactly as the normal publish does.
435
+ *
436
+ * A single channel-filtered listener is armed BEFORE the provokes and torn down on either outcome,
437
+ * so a fast push cannot fall in a re-arm gap and a station that never answers leaks nothing. The
438
+ * station is shared by every channel (a HomeBase multiplexes its attached cameras over one session),
439
+ * so a push for another camera is filtered out rather than resolving this read.
440
+ *
441
+ * Bounded by {@link RTSP_URL_READ_TIMEOUT_MS}: the abort covers `resolveSession`'s connect wait and
442
+ * the URL wait. The device/station resolution ahead of them relies on its own HTTP timeouts.
443
+ *
444
+ * `undefined` on any failure: no route, no account id, or no matching push before the deadline.
445
+ */
446
+ readReportedRtspUrl(sn: string): Promise<string | undefined>;
447
+ /**
448
+ * One channel-filtered wait for the station's `rtspUrl` push: a single persistent listener, attached
449
+ * up front and removed on resolve or abort, so nothing leaks and no push falls in a re-arm gap. The
450
+ * station multiplexes every attached camera's channel over one session, so a push for another camera
451
+ * is ignored rather than resolving the wrong read.
452
+ */
453
+ private awaitRtspUrl;
454
+ /**
455
+ * The single point that turns a session into an encryption **level**: level-2 when the session HOLDS a
456
+ * level-2 key, level-1 otherwise. Both the `"auto"` scalar path and the JSON control path route
457
+ * through here, so the rule is defined exactly once.
458
+ *
459
+ * The discriminator is the key, NOT topology, because that is what the app does. Captured across five
460
+ * peers of four device families and both topologies, every peer used ONE seal for every command family
461
+ * it sent — level-2 for each keyed session including two own-session cameras, level-1 only for the two
462
+ * whose negotiation never completes. Reading attachment instead mispredicts those two own-session
463
+ * cameras, and a level-2-only wire chosen for a session that holds no key cannot be sent at all.
464
+ *
465
+ * The key is waited for softly: a session that will not have one falls through to level-1, which is a
466
+ * working wire here rather than a degraded guess, instead of spending a per-call grace to learn that.
467
+ */
468
+ private sendBySessionLevel;
469
+ /**
470
+ * Resolve a device serial to its P2P session + routing params: the HomeBase/parent session for an
471
+ * attached camera or the device's own, its `device_channel`, and the admin account id. Opens the
472
+ * station's P2P session on demand if needed and waits for it to connect, then holds it warm briefly
473
+ * (a command keepalive, so a burst of commands / a follow-up read reuses it instead of paying a fresh
474
+ * handshake — a no-op for a wired/persistent station).
475
+ *
476
+ * `waitLevel2` states what the caller does about the key:
477
+ *
478
+ * - `true` — cannot frame without it. Waits the full grace, re-prompts once, and throws if refused.
479
+ * - `"settle"` — picks its seal once from {@link P2PSession.hasLevel2Key}. Waits {@link LEVEL2_SETTLE_MS}
480
+ * session-scoped for the negotiation to conclude either way, then proceeds. Never throws.
481
+ * - `"soft"` — frames per send and is re-issued, so it does not wait at all.
482
+ * - `false` / absent — no wait; enough to read topology.
483
+ *
484
+ * `requireLevel2ForAttached` promotes a `"soft"` caller to `true` on a HomeBase-attached camera, whose media
485
+ * start has no level-1 form at all.
486
+ *
487
+ * A `"soft"` caller frames per send: an own-session start issued with no key rides level 1, and its own
488
+ * re-issue rides level 2 once the key lands. Nothing bounds an unanswered `CMD_GATEWAYINFO`, so a waiting
489
+ * caller's grace is the bound, charged from connect.
490
+ *
491
+ * Only a caller that REQUIRES the key re-prompts — see {@link P2PSession.repromptLevel2Key}, which explains
492
+ * why one settled negotiation is not the last word.
493
+ *
494
+ * A session whose {@link P2PSession.pathAnswering} is false is closed and re-resolved before it is handed
495
+ * over: the station answers every heartbeat, so a path silent past several of them is gone. A session
496
+ * reporting nothing about its path is not reporting that evidence and is handed over as it is. Replaced at
497
+ * most once per resolution, so a station whose replacement is silent too is returned rather than closed
498
+ * again.
499
+ */
500
+ private resolveSession;
501
+ /**
502
+ * Shared machinery for the fire-and-forget **level-2 control senders** (direct-binary, station
503
+ * scalar, set-payload envelope): resolve the device's HomeBase P2P session (waiting for connect +
504
+ * the level-2 key), then replay the one-shot `send` `DIRECT_CMD_SENDS`× at 200ms spacing for RF
505
+ * resilience. If NONE went out (no key / not connected) we throw, so a fully-dropped command
506
+ * surfaces as an error, not a false success.
507
+ *
508
+ * `resolved` lets a caller that already has a {@link ResolvedSession} (e.g. `sendFf09Autolock`,
509
+ * which resolves once up front to arm its GET-reply listener) skip a redundant re-resolve — cheap
510
+ * once the level-2 key is ready (a Map lookup + already-satisfied waits), but still wasted work the
511
+ * MQTT sibling doesn't do. Omit it to resolve fresh, as every other caller does.
512
+ */
513
+ private replayLevel2Send;
514
+ /**
515
+ * **"Direct" binary control command** (camera on/off `1035`, spotlight brightness `1401` / color-temp
516
+ * `1410` / enable `1403`, audio switches): the 136-byte body ({@link buildDirectBinaryBody} with the
517
+ * resolved device channel) on that channel at signCode 8, `outerCmd` = the param id.
518
+ */
519
+ private sendDirectBinary;
520
+ /**
521
+ * **Station-scoped scalar** (`p2p-station-scalar` intent): the 132-byte channel-less body
522
+ * ({@link buildDirectBinaryBody} with no `channel`) on an EXPLICIT channel, signCode 8. The
523
+ * HomeBase's own controls ride the station broadcast channel 255 (alarm/speaker volume 1235).
524
+ */
525
+ private sendStationScalar;
526
+ /** Send a level-1 int-plus-string frame with authenticated account identity injected by the transport. */
527
+ private sendIntString;
528
+ /**
529
+ * **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
530
+ * exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
531
+ * capture of the app's own SET_SNOOZE_TIME (1271) frame — see `param-dictionary.ts`'s `1271` entry
532
+ * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.ALARM_DELAY_CONFIG`) reuses
533
+ * the same bare-JSON shape.
534
+ */
535
+ private sendJsonRaw;
536
+ /**
537
+ * **`set-payload` intent** — a `SET_PAYLOAD` (1350) envelope (`{account_id,cmd,mChannel,mValue3:cmd,
538
+ * payload}`). The intent's `channel` is authoritative (resolveSession supplies only the session +
539
+ * account_id) — so a capability that targets a specific channel isn't overridden. `resolved` — see
540
+ * {@link replayLevel2Send}'s doc — lets a caller that already resolved the session skip a redundant
541
+ * re-resolve.
542
+ *
543
+ * **Level follows topology** when `form` is `"auto"`, as in {@link resolveScalarParam}: a
544
+ * HomeBase-attached device takes the GCM signCode-8 form, a standalone one the level-1 form. A
545
+ * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
546
+ * unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
547
+ * camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
548
+ */
549
+ private sendSetPayloadEnvelope;
550
+ /**
551
+ * **`ff09-actuate` intent** — build the `ff09` AES-128-CBC frame ({@link buildFf09Frame}, shared with the
552
+ * MQTT transport — see `transport/ff09.ts`) and dispatch it in the `1940` TRANSFER_PAYLOAD envelope
553
+ * (`{apiCommand, lock_payload, seq_num, time}`) as a `set-payload` (1350), `mValue3=0`, on the lock's
554
+ * device channel. This is the P2P envelope; the capability module only supplies identity, never wire
555
+ * bytes and never the routing channel — that's re-resolved here from the device record.
556
+ */
557
+ private sendFf09Actuate;
558
+ /**
559
+ * How long {@link sendFf09Autolock} waits for the device's settings **GET** reply before
560
+ * giving up. Unlike the MQTT sibling (`MqttCommandRouter.dispatchFf09Autolock`, one TCP publish), the P2P
561
+ * GET is replayed `DIRECT_CMD_SENDS`× over ~800ms by {@link sendSetPayloadEnvelope} for RF
562
+ * resilience before this wait even starts counting down the rest — so the budget only needs to cover
563
+ * the reply's own travel time, not the resend window.
564
+ */
565
+ private static readonly FF09_SETTINGS_GET_TIMEOUT_MS;
566
+ /**
567
+ * **`ff09-autolock` intent over P2P** — read-modify-write the T8531's auto-lock setting. The
568
+ * P2P sibling of `MqttCommandRouter.dispatchFf09Autolock`; same GET-then-SET shape, same `ff09`
569
+ * frame/cipher. ✅ LIVE-VERIFIED end-to-end (2026-07-18): `dev.lock()?.setAutoLock(false)` THEN
570
+ * `setAutoLock(true)` driven through this exact codepath against a real T8531, both directions
571
+ * confirmed via the app UI showing autolock off then on afterward — not just byte-exact against a
572
+ * capture. Differs from the MQTT flow only in the envelope + reply matching:
573
+ *
574
+ * Resolves the session ONCE up front (needed to arm the reply listener before sending) and passes it
575
+ * to both `sendSetPayloadEnvelope` calls (GET + SET) — skips the redundant re-resolve each would
576
+ * otherwise do internally (see {@link replayLevel2Send}'s doc).
577
+ *
578
+ * 1. Build the settings GET frame ({@link buildFf09QueryFrame}) and send it the same way
579
+ * `sendFf09Actuate` sends a lock/unlock — a `1940` TRANSFER_PAYLOAD `set-payload` (1350) on the
580
+ * lock's device channel. Arm a `session.on("data", …)` listener BEFORE sending (same
581
+ * arm-before-send ordering as {@link p2pQuery}), matching the reply by `f.json.cmd ===
582
+ * CMD_TRANSFER_PAYLOAD` (the device's `/res`-equivalent reply always carries this inner cmd,
583
+ * same as any other transfer-payload traffic on this channel — so `cmd` alone isn't enough) AND
584
+ * `f.json.payload.time` equal to the GET's own `time`. **Confirmed live (2026-07-17) against a
585
+ * real T8531 capture: the P2P reply's `time` field is a HEX STRING** (e.g. `"6A5908BD"`),
586
+ * identical to the MQTT reply's convention — NOT the decimal the outbound `time` field uses. No
587
+ * reply within {@link FF09_SETTINGS_GET_TIMEOUT_MS} throws (same rationale as the MQTT side:
588
+ * guessing A7/A8 would be worse than failing loud).
589
+ * 2+3. Decrypt the reply, preserve the current delay (`a2`) + `A7`/`A8` passthrough values (`a4`/
590
+ * `a5`), and build the SET frame — the decrypt→read→rebuild shared with the MQTT sibling as
591
+ * `transport/ff09.ts`'s {@link buildFf09AutolockSetFrame} — then send it the same fire-and-forget
592
+ * way as `sendFf09Actuate` (no ack wait, matching every other P2P write in this router; there is no
593
+ * `commandAck` event plumbing at this layer — that's an `EufyMega`-level concern the MQTT
594
+ * dispatcher happens to have because it owns its own MQTT connection lifecycle).
595
+ */
596
+ private sendFf09Autolock;
597
+ /**
598
+ * Shared GET-and-wait step behind both {@link sendFf09Autolock} (which reads to preserve A7/A8 across
599
+ * a write) and {@link getAutoLockState} (which reads for its own sake) — extracted so the two don't
600
+ * drift on the arm-before-send / listener-leak / keyTime-matching machinery. Arms a
601
+ * `session.on("data", …)` listener BEFORE sending the GET (same ordering as {@link p2pQuery}); the
602
+ * `cleanup`/`onData`/`timer` are hoisted out of the Promise executor so the try/catch can tear the
603
+ * listener down if the send itself throws (without it a send failure would leak `onData` on the
604
+ * long-lived shared session for the full timeout window). Matches the reply by inner
605
+ * `cmd === CMD_TRANSFER_PAYLOAD` AND `payload.time` (a hex string live) equal to the GET's own
606
+ * keyTime. Throws if no matching reply arrives within {@link FF09_SETTINGS_GET_TIMEOUT_MS}.
607
+ */
608
+ private fetchFf09SettingsGetReply;
609
+ /**
610
+ * **Read the T8531's current auto-lock settings over P2P** — the `Ff09SettingsReader` behind
611
+ * `dev.lock()?.getAutoLockState()`. A pure GET, no SET: reuses {@link fetchFf09SettingsGetReply} (the
612
+ * same GET step {@link sendFf09Autolock} runs internally to preserve A7/A8), then decrypts + decodes
613
+ * fields `a1`-`a5` per `transport/ff09.ts`'s response tag map (`a1`=enabled, `a2`=delaySeconds,
614
+ * `a3`=isSchedule, `a4`/`a5`=schedule start/end as raw `[hour,minute]` byte pairs — see
615
+ * {@link readFf09HourMinute}'s doc for why these aren't a packed number). Live-verified only insofar
616
+ * as the underlying GET step already is (`setAutoLock`'s own read) — the standalone read path itself
617
+ * has not been independently exercised against a real device yet.
618
+ */
619
+ getAutoLockState(sn: string, cmd: {
620
+ adminUserId: string;
621
+ deviceSn: string;
622
+ }): Promise<AutoLockSnapshot>;
623
+ /**
624
+ * **`ff09-setting-toggle` intent** — the COMPACT single-setting `SET_SETTINGS` write (currently:
625
+ * T8531 Rain Mode, `settingId` = `ff09.ts`'s `FF09_SETTING_ID.RAIN_MODE`). Unlike
626
+ * {@link sendFf09Autolock}, this is a pure blind write — no GET pass, no reply wait — since the
627
+ * compact frame ({@link buildFf09SettingToggleFrame}) only carries the one field being changed, same
628
+ * fire-and-forget shape as {@link sendFf09Actuate}. ✅ LIVE-VERIFIED end-to-end (2026-07-18):
629
+ * `dev.lock()?.setRainMode()` driven through this exact codepath against a real T8531, both
630
+ * directions confirmed via the app UI showing the new state afterward — not just byte-exact against a
631
+ * capture. See `transport/ff09.ts`'s "Rain Mode" doc section. The routing channel is re-resolved from
632
+ * the device record — the capability never supplies it.
633
+ */
634
+ private sendFf09SettingToggle;
635
+ /**
636
+ * Send a level-1 **int+string** command (floodlight/spotlight switch 1400 on IndoorOutdoor /
637
+ * SoloCam-spotlight / Cam2C-3). No level-2 key needed (standalone level-1 ECB). Fire-and-forget,
638
+ * repeated for RF resilience.
639
+ */
640
+ private sendIntStringCommand;
641
+ /**
642
+ * Play the **privacy-mode multi-frame burst** over P2P (the `p2p-privacy-burst` command). Privacy
643
+ * does NOT engage as one frame — the app sends a MULTI-CHANNEL BURST of level-2 (signCode 8) frames.
644
+ * Reversed from a live capture: 1103 precursor on ch255 → 6250 SET on ch0 ×2 → 6250 SET on the
645
+ * camera channel ×3 → 1103 companion on ch0. Every frame is signCode 8 (a signCode-1 header on a
646
+ * GCM body is silently dropped).
647
+ */
648
+ private sendPrivacyBurst;
649
+ /**
650
+ * Route a control command (`{commandType, data}`) to a device over P2P: HomeBase-attached →
651
+ * level-2 GCM (bare plaintext, channel in the frame header), standalone → level-1 ECB. Waits for
652
+ * the session to connect + (for HomeBase) the level-2 key.
653
+ */
654
+ private routeControl;
655
+ }