@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.
- package/README.md +31 -12
- package/dist/client/device-registry.d.ts +343 -0
- package/dist/client/eufy-mega.d.ts +872 -0
- package/dist/client/index.d.ts +1 -6
- package/dist/client/map-channels.d.ts +22 -0
- package/dist/client/types.d.ts +383 -0
- package/dist/core/contracts.d.ts +898 -0
- package/dist/core/crypto.d.ts +98 -0
- package/dist/core/index.d.ts +9 -7
- package/dist/core/logger.d.ts +53 -0
- package/dist/core/lz4-block.d.ts +35 -0
- package/dist/core/raw-dp-hex.d.ts +32 -0
- package/dist/core/raw-dp-writer.d.ts +83 -0
- package/dist/core/store.d.ts +43 -0
- package/dist/core/types.d.ts +169 -0
- package/dist/core/util.d.ts +78 -0
- package/dist/index.d.ts +4 -7
- package/dist/index.js +25526 -14
- package/dist/index.js.map +7 -1
- package/dist/model/capabilities/access.d.ts +127 -0
- package/dist/model/capabilities/arming.d.ts +201 -0
- package/dist/model/capabilities/audio.d.ts +154 -0
- package/dist/model/capabilities/battery.d.ts +363 -0
- package/dist/model/capabilities/camera.d.ts +564 -0
- package/dist/model/capabilities/co.d.ts +40 -0
- package/dist/model/capabilities/contact.d.ts +133 -0
- package/dist/model/capabilities/doorbell.d.ts +345 -0
- package/dist/model/capabilities/dp-catalog.d.ts +38 -0
- package/dist/model/capabilities/index.d.ts +561 -0
- package/dist/model/capabilities/info.d.ts +28 -0
- package/dist/model/capabilities/keypad.d.ts +61 -0
- package/dist/model/capabilities/leak.d.ts +43 -0
- package/dist/model/capabilities/light.d.ts +174 -0
- package/dist/model/capabilities/locate.d.ts +63 -0
- package/dist/model/capabilities/lock.d.ts +242 -0
- package/dist/model/capabilities/manifest.d.ts +107 -0
- package/dist/model/capabilities/members.d.ts +647 -0
- package/dist/model/capabilities/motion.d.ts +377 -0
- package/dist/model/capabilities/person-detection.d.ts +8 -0
- package/dist/model/capabilities/ptz.d.ts +289 -0
- package/dist/model/capabilities/rtsp.d.ts +221 -0
- package/dist/model/capabilities/siren.d.ts +218 -0
- package/dist/model/capabilities/smart-light.d.ts +172 -0
- package/dist/model/capabilities/smoke.d.ts +40 -0
- package/dist/model/capabilities/snapshot.d.ts +6 -0
- package/dist/model/capabilities/storage.d.ts +11 -0
- package/dist/model/capabilities/suction.d.ts +104 -0
- package/dist/model/capabilities/types.d.ts +484 -0
- package/dist/model/capabilities/vacuum-clean.d.ts +1946 -0
- package/dist/model/capabilities/vacuum-dock.d.ts +208 -0
- package/dist/model/capabilities/video.d.ts +6 -0
- package/dist/model/classify.d.ts +78 -0
- package/dist/model/clean-record-detail.d.ts +65 -0
- package/dist/model/clean-records.d.ts +69 -0
- package/dist/model/device-family.d.ts +73 -0
- package/dist/model/device-types.d.ts +123 -0
- package/dist/model/device.d.ts +265 -0
- package/dist/model/index.d.ts +29 -4
- package/dist/model/infer.d.ts +23 -0
- package/dist/model/inspect.d.ts +61 -0
- package/dist/model/life-params.d.ts +21 -0
- package/dist/model/map-pixels.d.ts +70 -0
- package/dist/model/param-dictionary.d.ts +28 -0
- package/dist/model/param-namespace.d.ts +21 -0
- package/dist/model/proto-read.d.ts +53 -0
- package/dist/model/push-events.d.ts +147 -0
- package/dist/model/registry.d.ts +54 -0
- package/dist/model/types.d.ts +301 -0
- package/dist/model/vacuum-map-store.d.ts +92 -0
- package/dist/model/vacuum-map.d.ts +286 -0
- package/dist/model/vacuum-scenes.d.ts +76 -0
- package/dist/model/vacuum-schedules.d.ts +85 -0
- package/dist/transport/dp-preset.d.ts +102 -0
- package/dist/transport/ff09.d.ts +444 -0
- package/dist/transport/ffmpeg.d.ts +86 -0
- package/dist/transport/http/decodeImageV1.d.ts +20 -0
- package/dist/transport/http/decodeImageV2.d.ts +19 -0
- package/dist/transport/http/index.d.ts +5 -0
- package/dist/transport/http/light-catalog.d.ts +62 -0
- package/dist/transport/http/media-download.d.ts +14 -0
- package/dist/transport/http/mega-client.d.ts +514 -0
- package/dist/transport/http/phone-model.d.ts +21 -0
- package/dist/transport/index.d.ts +10 -7
- package/dist/transport/mqtt/app-client-id.d.ts +16 -0
- package/dist/transport/mqtt/availability.d.ts +14 -0
- package/dist/transport/mqtt/bare-ip-tls.d.ts +46 -0
- package/dist/transport/mqtt/biz-stream.d.ts +98 -0
- package/dist/transport/mqtt/broker-discovery.d.ts +55 -0
- package/dist/transport/mqtt/clean-codec.d.ts +14 -0
- package/dist/transport/mqtt/command-router.d.ts +285 -0
- package/dist/transport/mqtt/dp-codec.d.ts +58 -0
- package/dist/transport/mqtt/dp-color.d.ts +14 -0
- package/dist/transport/mqtt/engine.d.ts +16 -0
- package/dist/transport/mqtt/index.d.ts +5 -0
- package/dist/transport/mqtt/secure-mqtt.d.ts +107 -0
- package/dist/transport/mqtt/topics.d.ts +80 -0
- package/dist/transport/p2p/adts.d.ts +91 -0
- package/dist/transport/p2p/annexb.d.ts +124 -0
- package/dist/transport/p2p/codec.d.ts +166 -0
- package/dist/transport/p2p/command-router.d.ts +655 -0
- package/dist/transport/p2p/commands.d.ts +550 -0
- package/dist/transport/p2p/envelope.d.ts +46 -0
- package/dist/transport/p2p/fmp4.d.ts +89 -0
- package/dist/transport/p2p/fragment-recording.d.ts +33 -0
- package/dist/transport/p2p/index.d.ts +13 -0
- package/dist/transport/p2p/lan-ip.d.ts +25 -0
- package/dist/transport/p2p/live-stream.d.ts +223 -0
- package/dist/transport/p2p/live-trace.d.ts +123 -0
- package/dist/transport/p2p/media.d.ts +105 -0
- package/dist/transport/p2p/p2p-session.d.ts +620 -0
- package/dist/transport/p2p/readable-egress.d.ts +27 -0
- package/dist/transport/p2p/session-manager.d.ts +154 -0
- package/dist/transport/p2p/shared-live-source.d.ts +431 -0
- package/dist/transport/p2p/talkback.d.ts +187 -0
- package/dist/transport/p2p/video.d.ts +150 -0
- package/dist/transport/p2p/write-commands.d.ts +21 -0
- package/dist/transport/protobuf.d.ts +5 -0
- package/dist/transport/push/fcm.d.ts +23 -0
- package/dist/transport/push/index.d.ts +6 -0
- package/dist/transport/push/message-tags.d.ts +26 -0
- package/dist/transport/push/parser.d.ts +27 -0
- package/dist/transport/push/proto.d.ts +11 -0
- package/dist/transport/push/push-client.d.ts +64 -0
- package/dist/transport/push/store.d.ts +23 -0
- package/dist/transport/push/types.d.ts +180 -0
- package/dist/transport/raw-dp.d.ts +6 -0
- package/dist/transport/stored-image-cache.d.ts +23 -0
- package/dist/transport/tuya/account.d.ts +44 -0
- package/dist/transport/tuya/client.d.ts +89 -0
- package/dist/transport/tuya/command-router.d.ts +79 -0
- package/dist/transport/tuya/dp-codec.d.ts +56 -0
- package/dist/transport/tuya/index.d.ts +28 -0
- package/dist/transport/tuya/request.d.ts +157 -0
- package/dist/transport/tuya/sign.d.ts +64 -0
- package/package.json +12 -13
- package/dist/client/index.js +0 -2
- package/dist/client/index.js.map +0 -1
- package/dist/core/index.js +0 -2
- package/dist/core/index.js.map +0 -1
- package/dist/model/index.js +0 -2
- package/dist/model/index.js.map +0 -1
- package/dist/transport/index.js +0 -2
- 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
|
+
}
|