@mega-yfue/eufy-sdk 0.2.0-beta.2 → 0.2.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/eufy-mega.d.ts +16 -11
- package/dist/client/types.d.ts +6 -2
- package/dist/core/contracts.d.ts +57 -27
- package/dist/core/crypto.d.ts +10 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/logger.d.ts +5 -3
- package/dist/core/solix-types.d.ts +115 -0
- package/dist/core/store.d.ts +38 -10
- package/dist/index.js +2713 -452
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/access.d.ts +22 -3
- package/dist/model/capabilities/arming.d.ts +33 -27
- package/dist/model/capabilities/battery.d.ts +32 -4
- package/dist/model/capabilities/contact.d.ts +4 -0
- package/dist/model/capabilities/display.d.ts +85 -0
- package/dist/model/capabilities/index.d.ts +18 -3
- package/dist/model/capabilities/ptz.d.ts +6 -2
- package/dist/model/capabilities/solix.d.ts +173 -0
- package/dist/model/capabilities/types.d.ts +21 -5
- package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
- package/dist/model/device.d.ts +15 -0
- package/dist/model/index.d.ts +5 -0
- package/dist/model/param-dictionary.d.ts +24 -0
- package/dist/model/param-namespace.d.ts +1 -1
- package/dist/model/solix-catalog.d.ts +25 -0
- package/dist/model/solix-device.d.ts +136 -0
- package/dist/model/solix-family.d.ts +31 -0
- package/dist/model/solix-site.d.ts +70 -0
- package/dist/model/types.d.ts +5 -5
- package/dist/transport/ff09.d.ts +7 -0
- package/dist/transport/http/decodeImageV2.d.ts +8 -14
- package/dist/transport/http/index.d.ts +1 -0
- package/dist/transport/http/jpeg-scan.d.ts +59 -0
- package/dist/transport/http/media-download.d.ts +3 -0
- package/dist/transport/http/mega-client.d.ts +89 -9
- package/dist/transport/http/solix-client.d.ts +269 -0
- package/dist/transport/http/solix-constants.d.ts +56 -0
- package/dist/transport/media-failure.d.ts +48 -0
- package/dist/transport/mqtt/index.d.ts +2 -0
- package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
- package/dist/transport/mqtt/solix-mqtt.d.ts +321 -0
- package/dist/transport/mqtt/topics.d.ts +30 -0
- package/dist/transport/p2p/command-router.d.ts +152 -15
- package/dist/transport/p2p/index.d.ts +1 -0
- package/dist/transport/p2p/live-stream.d.ts +5 -4
- package/dist/transport/p2p/live-trace.d.ts +118 -6
- package/dist/transport/p2p/media.d.ts +11 -0
- package/dist/transport/p2p/p2p-session.d.ts +13 -0
- package/dist/transport/p2p/session-manager.d.ts +57 -23
- package/dist/transport/p2p/shared-live-source.d.ts +10 -1
- package/dist/transport/stored-image-cache.d.ts +7 -1
- package/package.json +3 -2
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live telemetry for Anker Solix devices over the AWS-IoT MQTT plane.
|
|
3
|
+
*
|
|
4
|
+
* The transport is the shared `SecureMqtt` — the exact same anker AWS-IoT broker + per-user
|
|
5
|
+
* client-cert mutual TLS the eufy device path uses; a Solix account's `get_user_mqtt_info` result maps
|
|
6
|
+
* straight onto {@link SecureMqttCredentials}. Solix devices publish telemetry continuously on
|
|
7
|
+
* `dt/{app_name}/{product_code}/{device_sn}/param_info` as an **ff09 TLV frame** (the same framing
|
|
8
|
+
* family as {@link parseFf09SettingsResponse}), so this module only adds the Solix topic + a small
|
|
9
|
+
* ff09 param decoder on top of the reused transport.
|
|
10
|
+
*
|
|
11
|
+
* Frame layout (observed on a Smart Meter Gen 2 / AE1X0):
|
|
12
|
+
* ff09 | len(u16 LE, incl. trailing XOR checksum) | 5-byte header | TLV fields | xor
|
|
13
|
+
* each TLV field is `tag(1) | len(1) | value(len)`; measurement fields carry `type(1) | 4 bytes`,
|
|
14
|
+
* type `0x05` = float32 LE. Field `a2` is the device serial (ASCII after a leading type byte).
|
|
15
|
+
*/
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import { type SecureMqttCredentials } from "./secure-mqtt.js";
|
|
18
|
+
import { type Logger } from "../../core/index.js";
|
|
19
|
+
/** A decoded telemetry channel: the raw value plus float/uint interpretations of a 4-byte payload. */
|
|
20
|
+
export interface SolixChannel {
|
|
21
|
+
/** The leading type byte (`0x05` = float32 LE for the meter's measurement channels). */
|
|
22
|
+
type: number;
|
|
23
|
+
raw: Buffer;
|
|
24
|
+
/** Present when the payload is 4 bytes: little-endian float32. */
|
|
25
|
+
float?: number;
|
|
26
|
+
/** Present when the payload is 4 bytes: little-endian uint32. */
|
|
27
|
+
uint?: number;
|
|
28
|
+
}
|
|
29
|
+
/** A parsed ff09 param frame: the device serial (from `a2`) + the raw TLV field map keyed by tag. */
|
|
30
|
+
export interface SolixParamFrame {
|
|
31
|
+
deviceSn?: string;
|
|
32
|
+
/** tag byte → value bytes (still including the per-field leading type byte for measurement fields). */
|
|
33
|
+
fields: Map<number, Buffer>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Telemetry field tags for the Smart Meter (AE1X0) that we emit under a stable NAME, keyed by ff09 tag
|
|
37
|
+
* byte. These twelve are the meter fields the vendor app itself names, and their tag→name bindings are
|
|
38
|
+
* confirmed:
|
|
39
|
+
*
|
|
40
|
+
* - The app's field vocabulary is exactly these twelve — voltage, current and power per line
|
|
41
|
+
* (L1/L2/L3), a power total, and cumulative import/export energy — with no current total, no
|
|
42
|
+
* frequency and no power-factor field.
|
|
43
|
+
* - A live single-phase frame confirms the tag→field magnitudes: `0xac` a nominal mains voltage,
|
|
44
|
+
* `0xa8` == `0xab` an equal power pair (line power equals total on one phase, one of them going
|
|
45
|
+
* negative on export), `0xaf` the line current, `0xb3` a slowly-cumulative import counter; the L2/L3
|
|
46
|
+
* slots read 0 on a single-CT install.
|
|
47
|
+
*
|
|
48
|
+
* The frame carries sixteen float slots (`0xa8`..`0xb7`). The four that name no field — `0xb2`, `0xb5`,
|
|
49
|
+
* `0xb6`, `0xb7` — stay raw `channel_<hex tag>` (see {@link solixReadings}). `0xb2` in particular is NOT
|
|
50
|
+
* a current total: under a 1.371 A line current it reads 0.009, three orders of magnitude off. Both
|
|
51
|
+
* `0xb2` and `0xb7` read zero at idle and non-zero under load, so they carry *something* load-related;
|
|
52
|
+
* what, is not established.
|
|
53
|
+
*
|
|
54
|
+
* This table is **meter-family-specific**: the same tag carries a different quantity on another Solix
|
|
55
|
+
* device (a Solarbank's `0xac` reads a power value, not a voltage), so {@link solixReadings} applies
|
|
56
|
+
* these names ONLY to a frame from the meter family — see {@link SOLIX_METER_PRODUCT_PREFIXES}. Every
|
|
57
|
+
* measurement tag still surfaces as `channel_<hex tag>` regardless of device, so nothing on the wire is
|
|
58
|
+
* lost; the model layer names non-meter tags per capability.
|
|
59
|
+
*/
|
|
60
|
+
export declare const SOLIX_METER_FIELD_NAMES: Readonly<Record<number, string>>;
|
|
61
|
+
/**
|
|
62
|
+
* Product-code prefixes of the Smart Meter family that {@link SOLIX_METER_FIELD_NAMES} decodes. The table
|
|
63
|
+
* is meter-specific, so {@link solixReadings} applies its named fields ONLY to a frame whose product code
|
|
64
|
+
* starts with one of these; a Solarbank (`AE103`) reporting the same `0xac` tag would otherwise be
|
|
65
|
+
* mislabelled `meterVoltageL1` with a nonsensical (negative-power) value. These are product-code prefixes
|
|
66
|
+
* used to select a decode table — not a model import — so the `transport ⊥ model` rule is untouched.
|
|
67
|
+
*
|
|
68
|
+
* Keep this in lockstep with `SOLIX_METER_MODELS` in `model/capabilities/solix.ts` (the same meter
|
|
69
|
+
* prefixes, model-side): a prefix added there but not here grants `energyMeter` to a device whose frames
|
|
70
|
+
* this decoder then refuses to name, and no guard can catch the split (the model layer can't import
|
|
71
|
+
* transport). Add a meter prefix to both.
|
|
72
|
+
*/
|
|
73
|
+
export declare const SOLIX_METER_PRODUCT_PREFIXES: readonly string[];
|
|
74
|
+
/**
|
|
75
|
+
* Product-code prefix of the gen-4 Solarbank (the `ats_ax170` family, e.g. `AE103` Solarbank 4 E5000
|
|
76
|
+
* Pro) whose ff09 tag layout {@link SOLIX_SOLARBANK_FIELD_NAMES} + the SOC/temperature extraction
|
|
77
|
+
* describe. Like the meter table this is family-specific — the SAME tag carries a different quantity on
|
|
78
|
+
* the meter (`0xac` is line voltage there, battery power here), so the Solarbank names are applied ONLY
|
|
79
|
+
* to a frame from this family. A product-code prefix used to pick a decode table, not a model import.
|
|
80
|
+
* `AE10` covers the AE10x gen-4 Solarbanks and does NOT match the meter (`AE1X0`, whose 4th char is `X`).
|
|
81
|
+
*/
|
|
82
|
+
export declare const SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
|
|
83
|
+
/**
|
|
84
|
+
* Confirmed ff09 tag → field bindings for the gen-4 Solarbank (`ats_ax170`), correlated live against the
|
|
85
|
+
* app UI. Power values in watts; signed fields note their sign convention:
|
|
86
|
+
* - `0xac` battery power, SIGNED (+ charging / − discharging) — the measured net pack power.
|
|
87
|
+
* - `0xbc` charge power (0 unless charging); `0xad` discharge power (0 unless discharging).
|
|
88
|
+
* - `0xae` AC plug power, SIGNED (+ feeding the home / − drawing in to charge).
|
|
89
|
+
* - `0xaf` socket power — the unit's own on-board AC outlet (an appliance plugged into the Solarbank).
|
|
90
|
+
* - `0xc4` grid input power; `0xc5` home load power.
|
|
91
|
+
* SOC and temperature are NOT float channels — see {@link solixReadings}, which reads SOC from tag `0xa3`
|
|
92
|
+
* (a uint8) and temperature from the `0xa4` BMS status blob. The 4 PV-string channels (`0xc6`–`0xc9`),
|
|
93
|
+
* the AC currents (`0xb2`/`0xb3`) and export energy (`0xb4`) are not yet confirmed, so they stay raw
|
|
94
|
+
* `channel_<hex>` until a capture pins them.
|
|
95
|
+
*/
|
|
96
|
+
export declare const SOLIX_SOLARBANK_FIELD_NAMES: Readonly<Record<number, string>>;
|
|
97
|
+
/**
|
|
98
|
+
* Confirmed `state_info` tag → field bindings for the gen-4 Solarbank. `state_info` is a SEPARATE push
|
|
99
|
+
* topic from `param_info` and, though it shares the ff09 framing, its tags carry SETTINGS/targets, NOT
|
|
100
|
+
* live measurements — so the SAME tag byte means something different here than in
|
|
101
|
+
* {@link SOLIX_SOLARBANK_FIELD_NAMES} (e.g. `0xab` is live PV power in param_info, the mode's AC-socket
|
|
102
|
+
* export limit here). Mapped by live observation against the app's SOC-setting screen; everything else
|
|
103
|
+
* stays raw `state_<hex>` until confirmed the same way.
|
|
104
|
+
*/
|
|
105
|
+
export declare const SOLIX_STATE_FIELD_NAMES: Readonly<Record<number, string>>;
|
|
106
|
+
/**
|
|
107
|
+
* Decode a `state_info` ff09 frame to named + raw settings values. Skips the header tags (`< 0xa5`:
|
|
108
|
+
* request marker, serial, timestamps). Each settings tag is emitted under `state_<hex>` (a plain number
|
|
109
|
+
* so it's watchable in a consumer while more tags get mapped) AND, when confirmed, under its name from
|
|
110
|
+
* {@link SOLIX_STATE_FIELD_NAMES}. Value is read type-aware: `0x05` float32, `0x02` u16, `0x01` u8, and
|
|
111
|
+
* `0x03` the whole-number settings byte (`payload[1]`).
|
|
112
|
+
*
|
|
113
|
+
* The header cutoff is `0xa5` here, deliberately one lower than {@link solixReadings}' `0xa6` for
|
|
114
|
+
* `param_info`: the two frames are different layouts under the same ff09 framing — `state_info` carries
|
|
115
|
+
* a settings value at `0xa5` (SOC), where `param_info` has a header tag. The cutoffs are not meant to
|
|
116
|
+
* match; the spec pins `0xa5`'s treatment in each so they can't silently drift together.
|
|
117
|
+
*/
|
|
118
|
+
export declare function solixStateReadings(frame: SolixParamFrame): Record<string, number>;
|
|
119
|
+
/** Interpret one TLV value as a telemetry channel (leading type byte + payload). */
|
|
120
|
+
export declare function readSolixChannel(value: Buffer | undefined): SolixChannel | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Decode an ff09 Solix param frame into its serial + TLV field map. Returns `null` for a non-ff09
|
|
123
|
+
* buffer, a length field that doesn't fit, or a bad checksum. Validates the trailing XOR checksum first
|
|
124
|
+
* (so a corrupted frame is rejected rather than yielding plausible floats), then walks `tag|len|value`
|
|
125
|
+
* from the first `0xa1` tag to the declared length minus the checksum byte via the shared
|
|
126
|
+
* `walkFf09Tlv` (bounded by `end`, so a field length can't overrun into the checksum).
|
|
127
|
+
*/
|
|
128
|
+
export declare function decodeSolixParamFrame(buf: Buffer): SolixParamFrame | null;
|
|
129
|
+
/**
|
|
130
|
+
* Reduce a param frame to named + raw telemetry values. Tags below `0xa6` are skipped — `a1`/`a2`/`a3`
|
|
131
|
+
* carry the field count, the serial and the status, not measurements. A measurement channel is one whose
|
|
132
|
+
* leading type byte is `0x05` (float32 LE over a 4-byte payload); any other type is a non-measurement
|
|
133
|
+
* param and contributes nothing. Each measurement is emitted under `channel_<hex tag>`, and additionally
|
|
134
|
+
* under its name when the tag has a confirmed one AND `productCode` is from a known family — pass the
|
|
135
|
+
* telemetry topic's product code so a device outside the meter/Solarbank families keeps raw
|
|
136
|
+
* `channel_<hex>` rather than borrowing another family's tag→name table. `productCode` is required (it
|
|
137
|
+
* comes straight from the telemetry topic); pass `""` for a frame of unknown origin and no names apply.
|
|
138
|
+
* Meter family: {@link SOLIX_METER_PRODUCT_PREFIXES}; Solarbank: {@link SOLIX_SOLARBANK_PRODUCT_PREFIX}.
|
|
139
|
+
*/
|
|
140
|
+
export declare function solixReadings(frame: SolixParamFrame, productCode: string): Record<string, number>;
|
|
141
|
+
/** A live telemetry sample emitted by {@link SolixMqtt} as a `reading` event. */
|
|
142
|
+
export interface SolixReading {
|
|
143
|
+
deviceSn: string;
|
|
144
|
+
productCode: string;
|
|
145
|
+
topic: string;
|
|
146
|
+
frame: SolixParamFrame;
|
|
147
|
+
values: Record<string, number>;
|
|
148
|
+
}
|
|
149
|
+
/** The minimum device shape {@link SolixMqtt.watch} needs (as returned by `SolixClient.getDevices`). */
|
|
150
|
+
export interface SolixMqttDevice {
|
|
151
|
+
device_sn: string;
|
|
152
|
+
product_code: string;
|
|
153
|
+
}
|
|
154
|
+
/** Options for {@link SolixMqtt}. */
|
|
155
|
+
export interface SolixMqttOptions {
|
|
156
|
+
/** `get_user_mqtt_info` result — carries endpoint, cert/key, app_name, thing_name, user_id. */
|
|
157
|
+
mqttInfo: SecureMqttCredentials;
|
|
158
|
+
/** Override the MQTT clientId. Defaults to the cert CN (`thing_name`), distinct from the app's id. */
|
|
159
|
+
clientId?: string;
|
|
160
|
+
/**
|
|
161
|
+
* Account/user id (40-hex) for the arming `account_id` + heartbeat topic. Defaults to
|
|
162
|
+
* `mqttInfo.user_id`; set it if the credentials omit it.
|
|
163
|
+
*/
|
|
164
|
+
userId?: string;
|
|
165
|
+
/**
|
|
166
|
+
* How often (ms) to re-send the device-info arming request that keeps realtime telemetry flowing.
|
|
167
|
+
* The device stops pushing `param_info` when no client keeps requesting it (the app re-arms on every
|
|
168
|
+
* foreground resume + a periodic heartbeat), so a passive subscriber goes silent after the server's
|
|
169
|
+
* reporting window closes. Default 25s — inside the observed ~30s cadence with keepalive 60. Set `0`
|
|
170
|
+
* to disable arming (subscribe-only, the old behaviour).
|
|
171
|
+
*/
|
|
172
|
+
armIntervalMs?: number;
|
|
173
|
+
/**
|
|
174
|
+
* The `head.client_id` stamped into the command/heartbeat envelopes — the app-shaped
|
|
175
|
+
* `android-{app_name}-{user_id}-{mqttUuid}-{ts}` (see {@link buildAppShapedClientId}). Defaults to
|
|
176
|
+
* that shape built from {@link mqttUuid}. Pass this to pin the whole string.
|
|
177
|
+
*/
|
|
178
|
+
appClientId?: string;
|
|
179
|
+
/**
|
|
180
|
+
* Stable 16-hex install UUID for the app-shaped client id. Defaults to one derived deterministically
|
|
181
|
+
* from the user id ({@link mqttUuidFrom}) — no storage needed, so the broker sees one stable client
|
|
182
|
+
* across restarts.
|
|
183
|
+
*
|
|
184
|
+
* The trade this makes: the default seed is the **account** id, which every client on that account
|
|
185
|
+
* shares, so two clients on one account derive the same uuid → the same `client_id`, and the broker
|
|
186
|
+
* evicts one to admit the other (they take the channel from each other indefinitely). Restart
|
|
187
|
+
* stability is the common case and this is the deliberate default, but pass an explicit `mqttUuid`
|
|
188
|
+
* (per host/install) when more than one client runs on the same account, to be told apart.
|
|
189
|
+
*/
|
|
190
|
+
mqttUuid?: string;
|
|
191
|
+
/** The account's `site_id` for the `power_site` heartbeat. Omitted from the frame when unknown. */
|
|
192
|
+
siteId?: string;
|
|
193
|
+
logger?: Logger;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Subscribe to a Solix device's live telemetry and emit decoded `reading` events. Reuses
|
|
197
|
+
* `SecureMqtt` for the connection; adds only the Solix data topic + ff09 param decoding.
|
|
198
|
+
*
|
|
199
|
+
* const mqtt = new SolixMqtt({ mqttInfo: await solix.getUserMqttInfo() });
|
|
200
|
+
* mqtt.on("reading", (r) => console.log(r.deviceSn, r.values.meterVoltageL1));
|
|
201
|
+
* await mqtt.watch(device); // device = a SolixClient.getDevices() entry
|
|
202
|
+
*/
|
|
203
|
+
export declare class SolixMqtt extends EventEmitter {
|
|
204
|
+
private readonly transport;
|
|
205
|
+
private readonly appName;
|
|
206
|
+
private readonly userId?;
|
|
207
|
+
private readonly appClientId;
|
|
208
|
+
private readonly armIntervalMs;
|
|
209
|
+
private readonly logger?;
|
|
210
|
+
private readonly siteId?;
|
|
211
|
+
private readonly watched;
|
|
212
|
+
private seq;
|
|
213
|
+
private armTimer?;
|
|
214
|
+
/**
|
|
215
|
+
* Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
|
|
216
|
+
* (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
|
|
217
|
+
* restart presents itself to the broker as a new client, so it defaults deterministically from the user
|
|
218
|
+
* id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
|
|
219
|
+
*/
|
|
220
|
+
constructor(opts: SolixMqttOptions);
|
|
221
|
+
/**
|
|
222
|
+
* Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
|
|
223
|
+
* start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
|
|
224
|
+
*
|
|
225
|
+
* Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
|
|
226
|
+
* channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
|
|
227
|
+
* any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
|
|
228
|
+
* reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
|
|
229
|
+
* (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
|
|
230
|
+
* app's separate command path. `onMessage` filters these — our own arming/echoes carry no
|
|
231
|
+
* `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
|
|
232
|
+
* is non-fatal (only `param_info` is required); we just won't see app-side changes.
|
|
233
|
+
*
|
|
234
|
+
* Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
|
|
235
|
+
* than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
|
|
236
|
+
* success: the call would resolve and arm on every interval while no reading ever arrives.
|
|
237
|
+
*
|
|
238
|
+
* The re-arm timer is unreffed, so a caller that watches and returns can still exit.
|
|
239
|
+
*/
|
|
240
|
+
watch(device: SolixMqttDevice): Promise<void>;
|
|
241
|
+
/** Tear down the connection and stop the re-arm timer. */
|
|
242
|
+
close(): Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
|
|
245
|
+
* `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
|
|
246
|
+
* poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
|
|
247
|
+
* is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
|
|
248
|
+
* command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
|
|
249
|
+
*/
|
|
250
|
+
setDisplayTimeout(device: SolixMqttDevice, index: number): Promise<void>;
|
|
251
|
+
/**
|
|
252
|
+
* Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
|
|
253
|
+
* a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
|
|
254
|
+
* heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
|
|
255
|
+
* (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
|
|
256
|
+
* not thrown, so one bad device doesn't stop the rest or kill the timer.
|
|
257
|
+
*/
|
|
258
|
+
private armAll;
|
|
259
|
+
/** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
|
|
260
|
+
private arm;
|
|
261
|
+
/** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
|
|
262
|
+
private makeHead;
|
|
263
|
+
/**
|
|
264
|
+
* Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
|
|
265
|
+
* `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
|
|
266
|
+
* from a real value, so sending `""` would claim an account this client does not have.
|
|
267
|
+
*/
|
|
268
|
+
private commandEnvelope;
|
|
269
|
+
/**
|
|
270
|
+
* The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
|
|
271
|
+
* `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
|
|
272
|
+
*/
|
|
273
|
+
private heartbeatEnvelope;
|
|
274
|
+
/**
|
|
275
|
+
* Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
|
|
276
|
+
* product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
|
|
277
|
+
* own `a2` field wins for the serial when it carries one.
|
|
278
|
+
*
|
|
279
|
+
* Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
|
|
280
|
+
* carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
|
|
281
|
+
* a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
|
|
282
|
+
* watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
|
|
283
|
+
* resolved serial isn't a watched device, fall back to the single watched device of this product code.
|
|
284
|
+
*/
|
|
285
|
+
private onMessage;
|
|
286
|
+
/**
|
|
287
|
+
* Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
|
|
288
|
+
* app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
|
|
289
|
+
* display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
|
|
290
|
+
* broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
|
|
291
|
+
* emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
|
|
292
|
+
* - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
|
|
293
|
+
* bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
|
|
294
|
+
* - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
|
|
295
|
+
*/
|
|
296
|
+
private handleCommand;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Pull the ff09 binary frame out of a received message. Solix telemetry arrives as a `{head, payload}`
|
|
300
|
+
* envelope whose `payload` is a JSON string carrying base64 `data` (or `trans`); `SecureMqtt`
|
|
301
|
+
* has already JSON-parsed the outer envelope. Returns the decoded frame bytes, or `null`.
|
|
302
|
+
*/
|
|
303
|
+
export declare function extractFf09Payload(raw: unknown): Buffer | null;
|
|
304
|
+
/**
|
|
305
|
+
* Build the ff09 request frame the app base64-encodes into a `requestDeviceInfo` (cmd 17) command's
|
|
306
|
+
* `data`. Captured live from the Anker app — request-type tag `a1`=0x22; the `realtime` variant adds
|
|
307
|
+
* `a2`/`a3` params (this is the one that keeps `param_info` reporting flowing), while `info` is the bare
|
|
308
|
+
* device-info fetch. Frame:
|
|
309
|
+
* `ff09 | len(u16 LE, TOTAL bytes incl. ff09+len+xor) | 5-byte header | a1 01 22
|
|
310
|
+
* [| a2 02 01 01 | a3 03 02 2c 01] | fe … <ts32 LE> | xor`
|
|
311
|
+
* `fe` carries a fresh unix-timestamp nonce; the trailing byte is XOR of every preceding byte (the same
|
|
312
|
+
* checksum the meter's telemetry frames use — verified to reproduce the captured frames exactly).
|
|
313
|
+
*/
|
|
314
|
+
/**
|
|
315
|
+
* Build the display screen-off-timeout command frame (ff09 msgtype `0x68`, tag `a5 = [01, index]`),
|
|
316
|
+
* captured live from the app on `cmd/anker_power/<pc>/<sn>/req` (cmd 17). `index` is the 1-based
|
|
317
|
+
* position in the app dropdown `[10s,20s,30s,1m,5m,30m]` — live-confirmed 10s=1, 30s=3, 1m=4. "Never"
|
|
318
|
+
* is a separate command (not this one). Byte-identical to the captured frames modulo the index byte.
|
|
319
|
+
*/
|
|
320
|
+
export declare function buildDisplayTimeoutFrame(index: number): Buffer;
|
|
321
|
+
export declare function buildFf09Request(variant: "info" | "realtime", atUnixSec?: number): Buffer;
|
|
@@ -78,3 +78,33 @@ export interface ParsedTopic {
|
|
|
78
78
|
* so an unparsed topic leaves `deviceSn` unset instead of carrying a guess.
|
|
79
79
|
*/
|
|
80
80
|
export declare function parseSecureTopic(topic: string): ParsedTopic | undefined;
|
|
81
|
+
/** The per-device Solix topics for `{appName, productCode, deviceSn}`. */
|
|
82
|
+
export interface SolixDeviceTopics {
|
|
83
|
+
/** Telemetry the device pushes (SUBSCRIBE) — ff09 `param_info` frames (live measurements). */
|
|
84
|
+
paramInfo: string;
|
|
85
|
+
/**
|
|
86
|
+
* Settings/state the device pushes (SUBSCRIBE) — ff09 `state_info` frames. Same ff09 framing as
|
|
87
|
+
* `param_info` but the TAGS carry SETTINGS/targets (mode export limit, SOC limits, max_load, toggles),
|
|
88
|
+
* NOT live measurements — so it needs its own tag→name table, not the param_info one.
|
|
89
|
+
*/
|
|
90
|
+
stateInfo: string;
|
|
91
|
+
/** This device's command replies (SUBSCRIBE). */
|
|
92
|
+
cmdRes: string;
|
|
93
|
+
/**
|
|
94
|
+
* The device's requestDeviceInfo channel (cmd 17). PUBLISH to arm reporting; also SUBSCRIBE — the
|
|
95
|
+
* broker copies the APP's publishes here to any co-subscriber, which is the only way to observe a
|
|
96
|
+
* control the app changed that `param_info` does not reflect (ambient light, display timeout).
|
|
97
|
+
*/
|
|
98
|
+
req: string;
|
|
99
|
+
}
|
|
100
|
+
/** Build the per-device Solix topics. `param_info` is the telemetry we decode; `req` is arm + read-back. */
|
|
101
|
+
export declare function solixDeviceTopics(appName: string, productCode: string, deviceSn: string): SolixDeviceTopics;
|
|
102
|
+
/** The per-account Solix topics keyed by `user_id`. */
|
|
103
|
+
export interface SolixUserTopics {
|
|
104
|
+
/** Account-level command replies (SUBSCRIBE). */
|
|
105
|
+
cmdRes: string;
|
|
106
|
+
/** The `power_site` heartbeat channel (PUBLISH only). */
|
|
107
|
+
powerSite: string;
|
|
108
|
+
}
|
|
109
|
+
/** Build the per-account Solix topics. Note: the account `…/req` channel is publish-side and NOT subscribed. */
|
|
110
|
+
export declare function solixUserTopics(appName: string, userId: string): SolixUserTopics;
|
|
@@ -18,6 +18,25 @@ import type { FfmpegLevel } from "../ffmpeg.js";
|
|
|
18
18
|
import { SharedLiveSource } from "./shared-live-source.js";
|
|
19
19
|
import { type PowerTier, type SessionManagerOpts } from "./session-manager.js";
|
|
20
20
|
import { FragmentRecording } from "./fragment-recording.js";
|
|
21
|
+
/**
|
|
22
|
+
* What a caller's own deadline on a station call has to clear, in milliseconds.
|
|
23
|
+
*
|
|
24
|
+
* A caller that bounds one of these calls itself races these waits, and a bound below them reports the
|
|
25
|
+
* caller's own expiry in place of the reason this SDK was about to give — the two are indistinguishable to
|
|
26
|
+
* whoever reads the outcome, and they call for different next steps. Published so that bound can be derived
|
|
27
|
+
* rather than copied: a literal in a caller's source is a second source of truth that goes stale silently
|
|
28
|
+
* when these change.
|
|
29
|
+
*
|
|
30
|
+
* `connect` applies to every call on a station, because nothing can be addressed to one before its session is
|
|
31
|
+
* up, and it is the session's own connect deadline: a session that reaches it closes itself, so waiting past it
|
|
32
|
+
* waits on a connection that can no longer answer. `level2Grace` applies twice where the key is required: the
|
|
33
|
+
* negotiation is re-prompted once.
|
|
34
|
+
*/
|
|
35
|
+
export declare const P2P_STATION_WAITS: {
|
|
36
|
+
readonly connect: 15000;
|
|
37
|
+
readonly level2Grace: 25000;
|
|
38
|
+
readonly level2Settle: 8000;
|
|
39
|
+
};
|
|
21
40
|
/**
|
|
22
41
|
* Options accepted when warming a {@link SharedLiveSource} for a device (all optional).
|
|
23
42
|
*
|
|
@@ -71,7 +90,7 @@ export interface P2PRouterDeps {
|
|
|
71
90
|
}
|
|
72
91
|
export declare class P2PCommandRouter {
|
|
73
92
|
private readonly deps;
|
|
74
|
-
/**
|
|
93
|
+
/** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
|
|
75
94
|
private readonly manager;
|
|
76
95
|
/** Error objects already forwarded while a station startup awaits the same session signal. */
|
|
77
96
|
private readonly reportedErrors;
|
|
@@ -79,6 +98,21 @@ export declare class P2PCommandRouter {
|
|
|
79
98
|
private readonly liveSources;
|
|
80
99
|
/** The options each live source was built from, so a later caller's conflicting ones can be reported. */
|
|
81
100
|
private readonly liveSourceOpts;
|
|
101
|
+
/**
|
|
102
|
+
* The session each live source pulls over — the station's own serial, or the source's own media
|
|
103
|
+
* session key.
|
|
104
|
+
*
|
|
105
|
+
* Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
|
|
106
|
+
* a reservation rather than a record. Two cameras started together (the four-tile case this feature
|
|
107
|
+
* exists for) would otherwise both find the station's session free: a consumer attaches only after
|
|
108
|
+
* `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
|
|
109
|
+
* would take the shared connection and contend on it.
|
|
110
|
+
*
|
|
111
|
+
* A station entry is kept as well as a media one, because "is the station's own session already
|
|
112
|
+
* claimed" is the question being asked, and an entry that is present but not yet in
|
|
113
|
+
* {@link liveSources} is a claim in flight.
|
|
114
|
+
*/
|
|
115
|
+
private readonly liveSessionKeys;
|
|
82
116
|
/**
|
|
83
117
|
* The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
|
|
84
118
|
* time and the session carries one audio sequence, so this path is exclusive where a live pull is
|
|
@@ -90,6 +124,12 @@ export declare class P2PCommandRouter {
|
|
|
90
124
|
constructor(deps: P2PRouterDeps);
|
|
91
125
|
/** Forward one P2P failure once even when both the session listener and startup waiter observe it. */
|
|
92
126
|
private reportError;
|
|
127
|
+
/**
|
|
128
|
+
* Emit a live trace under a station session's handle, for work this router does ON that session before
|
|
129
|
+
* the session itself records anything — reaching the station, and resolving what a device is on it. Same
|
|
130
|
+
* handle as everything the session goes on to trace, which is what groups one attempt.
|
|
131
|
+
*/
|
|
132
|
+
private traceOnStation;
|
|
93
133
|
/**
|
|
94
134
|
* Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
|
|
95
135
|
* usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
|
|
@@ -99,7 +139,10 @@ export declare class P2PCommandRouter {
|
|
|
99
139
|
* `no P2P session`.
|
|
100
140
|
*/
|
|
101
141
|
static claimsDevice(dev: EufyDevice): boolean;
|
|
102
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The open P2P sessions by key — a station's own under its serial, a camera's media session under
|
|
144
|
+
* `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
|
|
145
|
+
*/
|
|
103
146
|
getSessions(): Map<string, P2PSession>;
|
|
104
147
|
/**
|
|
105
148
|
* Speculatively open + briefly hold a station's session (e.g. after a doorbell ring) so a
|
|
@@ -109,7 +152,7 @@ export declare class P2PCommandRouter {
|
|
|
109
152
|
*
|
|
110
153
|
* One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
|
|
111
154
|
* 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
|
|
155
|
+
* A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
|
|
113
156
|
* and the lookups are away, not once the peer has answered, so both would expire together.
|
|
114
157
|
*
|
|
115
158
|
* Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
|
|
@@ -142,7 +185,41 @@ export declare class P2PCommandRouter {
|
|
|
142
185
|
* when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
|
|
143
186
|
* on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
|
|
144
187
|
*/
|
|
145
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The key a camera's own media session is filed under, distinct from every station serial because a
|
|
190
|
+
* serial contains no `#`.
|
|
191
|
+
*/
|
|
192
|
+
private static mediaSessionKey;
|
|
193
|
+
/** Whether `key` names a media session rather than a station's own. */
|
|
194
|
+
private static isMediaSessionKey;
|
|
195
|
+
/**
|
|
196
|
+
* Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
|
|
197
|
+
*
|
|
198
|
+
* One session serves one camera: a station fans its cameras over a session and answers the most recent
|
|
199
|
+
* start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
|
|
200
|
+
* how a station serves another camera.
|
|
201
|
+
*
|
|
202
|
+
* The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
|
|
203
|
+
* captured opening one PPCS session per camera, with three cameras' video arriving in the same second
|
|
204
|
+
* at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
|
|
205
|
+
* Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
|
|
206
|
+
* full frame rate at once over a session each, where the same pair down one session could only take
|
|
207
|
+
* turns.
|
|
208
|
+
*
|
|
209
|
+
* It carries media alone. The station announces its state to every client that connects, so a session
|
|
210
|
+
* wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
|
|
211
|
+
* unannounced, and the station's own session stays the single source of connection state, control
|
|
212
|
+
* notifications and frames.
|
|
213
|
+
*
|
|
214
|
+
* The level-2 key is waited for here on the same terms the station's own session gets, because a
|
|
215
|
+
* connection is not usable for this without one: an attached camera's media start has no level-1 form,
|
|
216
|
+
* so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
|
|
217
|
+
* shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
|
|
218
|
+
* carry the start is not a connected session, so this refuses rather than returning one.
|
|
219
|
+
*/
|
|
220
|
+
private openMediaSession;
|
|
221
|
+
/** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
|
|
222
|
+
private openSession;
|
|
146
223
|
/**
|
|
147
224
|
* Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
|
|
148
225
|
*
|
|
@@ -154,6 +231,12 @@ export declare class P2PCommandRouter {
|
|
|
154
231
|
*
|
|
155
232
|
* The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
|
|
156
233
|
* source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
|
|
234
|
+
*
|
|
235
|
+
* A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
|
|
236
|
+
* level-2 readiness and inbound frames all reach the owner through the station's own session, and a
|
|
237
|
+
* station announces those to every client that connects — so fanning a second connection's copies out
|
|
238
|
+
* under the same station serial would report each one twice, and a close would tear down the station
|
|
239
|
+
* while its own session is still serving.
|
|
157
240
|
*/
|
|
158
241
|
private makeSession;
|
|
159
242
|
/**
|
|
@@ -262,22 +345,40 @@ export declare class P2PCommandRouter {
|
|
|
262
345
|
* {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
|
|
263
346
|
* reuse branch, so a reuse frees the station as a cold start does.
|
|
264
347
|
*
|
|
348
|
+
* `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
|
|
349
|
+
* connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
|
|
350
|
+
* frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
|
|
351
|
+
* so a still asked for while a sibling is being watched contends as it always did, and the caller's
|
|
352
|
+
* retained image answers it. A live view still outranks a tile; what changed is that two live views no
|
|
353
|
+
* longer have to outrank each other.
|
|
354
|
+
*
|
|
265
355
|
* The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
|
|
266
356
|
* place.
|
|
267
357
|
*/
|
|
268
|
-
sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts): Promise<SharedLiveSource>;
|
|
358
|
+
sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts, mayOpenOwnSession?: boolean): Promise<SharedLiveSource>;
|
|
269
359
|
/**
|
|
270
360
|
* Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
|
|
271
361
|
*
|
|
272
362
|
* 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
|
|
274
|
-
* serves one camera at a time leaves the new stream receiving nothing but the old camera's frames
|
|
275
|
-
* long as the linger lasts.
|
|
363
|
+
* re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
|
|
364
|
+
* which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
|
|
365
|
+
* for as long as the linger lasts.
|
|
276
366
|
*
|
|
277
367
|
* Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
|
|
278
368
|
* re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
|
|
279
369
|
* looking at competing with one somebody just asked for.
|
|
280
370
|
*
|
|
371
|
+
* An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
|
|
372
|
+
* has sent no media start and is competing for nothing. Without that, two cameras opened at the same
|
|
373
|
+
* moment destroy each other — the second finds the first's source built but not yet attached to, reads
|
|
374
|
+
* zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
|
|
375
|
+
* this whole path exists for.
|
|
376
|
+
*
|
|
377
|
+
* A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
|
|
378
|
+
* contention this releases is contention over one session, and that sibling is not on this one. Dropping
|
|
379
|
+
* it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
|
|
380
|
+
* competition that is not happening.
|
|
381
|
+
*
|
|
281
382
|
* A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
|
|
282
383
|
* pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
|
|
283
384
|
* retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
|
|
@@ -288,14 +389,27 @@ export declare class P2PCommandRouter {
|
|
|
288
389
|
*/
|
|
289
390
|
private releaseLingeringSiblings;
|
|
290
391
|
/**
|
|
291
|
-
*
|
|
392
|
+
* Whether another camera has already claimed this station's OWN session, ignoring `key`.
|
|
393
|
+
*
|
|
394
|
+
* The question a newcomer has to answer is not whether the station is busy — it can serve one camera
|
|
395
|
+
* per connection — but whether the connection it would otherwise share is taken. A camera on a media
|
|
396
|
+
* session of its own does not hold this one, so a station whose first camera has since stopped hands
|
|
397
|
+
* its own session to the next arrival rather than opening a socket beside an idle one.
|
|
398
|
+
*
|
|
399
|
+
* A claim counts while its source is still `idle`, source or no source. That is a start that has been
|
|
400
|
+
* handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
|
|
401
|
+
* at the same moment can see each other: consumers attach after this method has already run for both.
|
|
402
|
+
*
|
|
403
|
+
* The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
|
|
404
|
+
* session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
|
|
405
|
+
* socket against destroying a start someone is waiting on, which is not a close trade.
|
|
292
406
|
*
|
|
293
407
|
* A stopped source is skipped even when consumers are still attached to it. A failed start fails its
|
|
294
408
|
* 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
|
|
296
|
-
* restarted. Only a source that can still deliver holds a place.
|
|
409
|
+
* and counting that as a viewer would put every later camera on a connection of its own until the
|
|
410
|
+
* client restarted. Only a source that can still deliver holds a place.
|
|
297
411
|
*/
|
|
298
|
-
private
|
|
412
|
+
private stationSessionInUse;
|
|
299
413
|
/**
|
|
300
414
|
* Whether the stream on `key` should re-assert its channel to hold the station.
|
|
301
415
|
*
|
|
@@ -305,8 +419,8 @@ export declare class P2PCommandRouter {
|
|
|
305
419
|
* - Nothing attached: no. There is nobody to take the station for.
|
|
306
420
|
* - A live viewer attached: yes. That is the picture someone is looking at.
|
|
307
421
|
* - 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
|
|
309
|
-
* cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
|
|
422
|
+
* tile that is off screen while the live view is on it, and one session serving one camera at a time
|
|
423
|
+
* cannot satisfy both — and a still does not open a connection of its own. Measured: a still on a sibling halved a live view's frame rate for as long as
|
|
310
424
|
* it took, and its own capture then took fifteen seconds because it was contending.
|
|
311
425
|
*
|
|
312
426
|
* A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
|
|
@@ -321,8 +435,24 @@ export declare class P2PCommandRouter {
|
|
|
321
435
|
* not.
|
|
322
436
|
*/
|
|
323
437
|
private attachUnlessAborted;
|
|
324
|
-
/**
|
|
438
|
+
/**
|
|
439
|
+
* Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
|
|
440
|
+
* on a session goes with it, and a media session opened for this source alone is closed: nothing else
|
|
441
|
+
* can reach that connection, so leaving it open would hold a socket and a station keepalive for a
|
|
442
|
+
* camera no longer being pulled. A claim on the STATION's own session is released without closing
|
|
443
|
+
* anything — that connection carries the station's control traffic and outlives any one camera.
|
|
444
|
+
*/
|
|
325
445
|
private dropLiveSource;
|
|
446
|
+
/** Close and forget the media session `key`'s live source owned, if it owned one. */
|
|
447
|
+
private closeMediaSession;
|
|
448
|
+
/**
|
|
449
|
+
* Drop the live source a media session was carrying, after that session closed on its own.
|
|
450
|
+
*
|
|
451
|
+
* The source holds the closed connection and never re-resolves it, so it can only answer its retained
|
|
452
|
+
* keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
|
|
453
|
+
* a fresh source — which, finding the station busy again, opens a fresh media session for it.
|
|
454
|
+
*/
|
|
455
|
+
private tearDownMediaSession;
|
|
326
456
|
/**
|
|
327
457
|
* Drop everything that was riding a station's session, and report the station closed.
|
|
328
458
|
*
|
|
@@ -545,6 +675,13 @@ export declare class P2PCommandRouter {
|
|
|
545
675
|
* standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
|
|
546
676
|
* unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
|
|
547
677
|
* camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
|
|
678
|
+
*
|
|
679
|
+
* Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
|
|
680
|
+
* control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
|
|
681
|
+
* least able to afford a single dropped one — it has no reply, no readback here, and nothing that
|
|
682
|
+
* would tell a caller the write was lost rather than refused. The level-1 form reports delivery by
|
|
683
|
+
* throwing (`sendSetPayload` throws when the session has no address) rather than by returning a
|
|
684
|
+
* boolean, so the first pass carries the failure and the rest are repeats.
|
|
548
685
|
*/
|
|
549
686
|
private sendSetPayloadEnvelope;
|
|
550
687
|
/**
|
|
@@ -10,4 +10,5 @@ export * from "./envelope.js";
|
|
|
10
10
|
export * from "./write-commands.js";
|
|
11
11
|
export * from "./lan-ip.js";
|
|
12
12
|
export { LIVE_TRACE_MESSAGE, type LiveTrace } from "./live-trace.js";
|
|
13
|
+
export { P2P_STATION_WAITS } from "./command-router.js";
|
|
13
14
|
export * as p2pCodec from "./codec.js";
|