@mega-yfue/eufy-sdk 0.2.0-beta.0 → 0.2.0-beta.10
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 +17 -41
- package/dist/client/eufy-mega.d.ts +33 -6
- package/dist/core/contracts.d.ts +58 -3
- 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 +36 -0
- package/dist/core/store.d.ts +20 -9
- package/dist/index.js +1229 -102
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/arming.d.ts +58 -28
- package/dist/model/capabilities/display.d.ts +85 -0
- package/dist/model/capabilities/index.d.ts +11 -5
- package/dist/model/capabilities/solix.d.ts +75 -0
- package/dist/model/capabilities/types.d.ts +16 -4
- package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
- package/dist/model/index.d.ts +3 -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 +20 -0
- package/dist/model/solix-device.d.ts +102 -0
- package/dist/model/types.d.ts +5 -5
- package/dist/transport/ff09.d.ts +7 -0
- package/dist/transport/http/index.d.ts +1 -0
- package/dist/transport/http/mega-client.d.ts +10 -2
- package/dist/transport/http/solix-client.d.ts +158 -0
- package/dist/transport/http/solix-constants.d.ts +29 -0
- package/dist/transport/mqtt/app-client-id.d.ts +9 -3
- package/dist/transport/mqtt/command-router.d.ts +0 -3
- package/dist/transport/mqtt/index.d.ts +2 -0
- package/dist/transport/mqtt/secure-mqtt.d.ts +24 -1
- package/dist/transport/mqtt/solix-mqtt.d.ts +214 -0
- package/dist/transport/mqtt/topics.d.ts +20 -0
- package/dist/transport/p2p/command-router.d.ts +23 -0
- package/dist/transport/p2p/index.d.ts +1 -0
- package/dist/transport/p2p/live-trace.d.ts +100 -6
- package/dist/transport/p2p/media.d.ts +11 -0
- package/dist/transport/p2p/p2p-session.d.ts +4 -0
- package/package.json +3 -2
|
@@ -0,0 +1,214 @@
|
|
|
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. Only tags whose tag→name binding is CONFIRMED against a live frame live here:
|
|
38
|
+
*
|
|
39
|
+
* - `0xac` = `meterVoltageL1` — confirmed against live single-phase data (a nominal mains voltage).
|
|
40
|
+
*
|
|
41
|
+
* Every other measurement tag still surfaces as `channel_<hex tag>` (see {@link solixReadings}), so
|
|
42
|
+
* nothing on the wire is lost — a caller reads unconfirmed tags there. The names are deliberately NOT
|
|
43
|
+
* asserted for the rest: the app exposes the field *list*, but the tag→name *binding* below is a
|
|
44
|
+
* structural inference until a known-load capture pins it, and a mislabelled live float is worse than an
|
|
45
|
+
* honest `channel_<tag>`. The recovered candidates, to re-add one line each (moving the tag from this
|
|
46
|
+
* comment to the map above) as a known-load capture confirms each binding:
|
|
47
|
+
*
|
|
48
|
+
* 0xa8 meterPowerL1 0xa9 meterPowerL2 0xaa meterPowerL3 0xab meterPowerTotal
|
|
49
|
+
* 0xad meterVoltageL2 0xae meterVoltageL3 0xaf meterCurrentL1 0xb0 meterCurrentL2
|
|
50
|
+
* 0xb1 meterCurrentL3 0xb2 meterCurrentTotal 0xb3 meterImportEnergy 0xb4 meterExportEnergy
|
|
51
|
+
*/
|
|
52
|
+
export declare const SOLIX_METER_FIELD_NAMES: Readonly<Record<number, string>>;
|
|
53
|
+
/** Interpret one TLV value as a telemetry channel (leading type byte + payload). */
|
|
54
|
+
export declare function readSolixChannel(value: Buffer | undefined): SolixChannel | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Decode an ff09 Solix param frame into its serial + TLV field map. Returns `null` for a non-ff09
|
|
57
|
+
* buffer, a length field that doesn't fit, or a bad checksum. Validates the trailing XOR checksum first
|
|
58
|
+
* (so a corrupted frame is rejected rather than yielding plausible floats), then walks `tag|len|value`
|
|
59
|
+
* from the first `0xa1` tag to the declared length minus the checksum byte via the shared
|
|
60
|
+
* `walkFf09Tlv` (bounded by `end`, so a field length can't overrun into the checksum).
|
|
61
|
+
*/
|
|
62
|
+
export declare function decodeSolixParamFrame(buf: Buffer): SolixParamFrame | null;
|
|
63
|
+
/**
|
|
64
|
+
* Reduce a param frame to named + raw telemetry values. Tags below `0xa6` are skipped — `a1`/`a2`/`a3`
|
|
65
|
+
* carry the field count, the serial and the status, not measurements. A measurement channel is one whose
|
|
66
|
+
* leading type byte is `0x05` (float32 LE over a 4-byte payload); any other type is a non-measurement
|
|
67
|
+
* param and contributes nothing. Each measurement is emitted under `channel_<hex tag>`, and additionally
|
|
68
|
+
* under its name when the tag has a confirmed one in {@link SOLIX_METER_FIELD_NAMES}.
|
|
69
|
+
*/
|
|
70
|
+
export declare function solixReadings(frame: SolixParamFrame): Record<string, number>;
|
|
71
|
+
/** A live telemetry sample emitted by {@link SolixMqtt} as a `reading` event. */
|
|
72
|
+
export interface SolixReading {
|
|
73
|
+
deviceSn: string;
|
|
74
|
+
productCode: string;
|
|
75
|
+
topic: string;
|
|
76
|
+
frame: SolixParamFrame;
|
|
77
|
+
values: Record<string, number>;
|
|
78
|
+
}
|
|
79
|
+
/** The minimum device shape {@link SolixMqtt.watch} needs (as returned by `SolixClient.getDevices`). */
|
|
80
|
+
export interface SolixMqttDevice {
|
|
81
|
+
device_sn: string;
|
|
82
|
+
product_code: string;
|
|
83
|
+
}
|
|
84
|
+
/** Options for {@link SolixMqtt}. */
|
|
85
|
+
export interface SolixMqttOptions {
|
|
86
|
+
/** `get_user_mqtt_info` result — carries endpoint, cert/key, app_name, thing_name, user_id. */
|
|
87
|
+
mqttInfo: SecureMqttCredentials;
|
|
88
|
+
/** Override the MQTT clientId. Defaults to the cert CN (`thing_name`), distinct from the app's id. */
|
|
89
|
+
clientId?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Account/user id (40-hex) for the arming `account_id` + heartbeat topic. Defaults to
|
|
92
|
+
* `mqttInfo.user_id`; set it if the credentials omit it.
|
|
93
|
+
*/
|
|
94
|
+
userId?: string;
|
|
95
|
+
/**
|
|
96
|
+
* How often (ms) to re-send the device-info arming request that keeps realtime telemetry flowing.
|
|
97
|
+
* The device stops pushing `param_info` when no client keeps requesting it (the app re-arms on every
|
|
98
|
+
* foreground resume + a periodic heartbeat), so a passive subscriber goes silent after the server's
|
|
99
|
+
* reporting window closes. Default 25s — inside the observed ~30s cadence with keepalive 60. Set `0`
|
|
100
|
+
* to disable arming (subscribe-only, the old behaviour).
|
|
101
|
+
*/
|
|
102
|
+
armIntervalMs?: number;
|
|
103
|
+
/**
|
|
104
|
+
* The `head.client_id` stamped into the command/heartbeat envelopes — the app-shaped
|
|
105
|
+
* `android-{app_name}-{user_id}-{mqttUuid}-{ts}` (see {@link buildAppShapedClientId}). Defaults to
|
|
106
|
+
* that shape built from {@link mqttUuid}. Pass this to pin the whole string.
|
|
107
|
+
*/
|
|
108
|
+
appClientId?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Stable 16-hex install UUID for the app-shaped client id. Defaults to one derived deterministically
|
|
111
|
+
* from the user id ({@link mqttUuidFrom}) — no storage needed, so the broker sees one stable client
|
|
112
|
+
* across restarts.
|
|
113
|
+
*
|
|
114
|
+
* The trade this makes: the default seed is the **account** id, which every client on that account
|
|
115
|
+
* shares, so two clients on one account derive the same uuid → the same `client_id`, and the broker
|
|
116
|
+
* evicts one to admit the other (they take the channel from each other indefinitely). Restart
|
|
117
|
+
* stability is the common case and this is the deliberate default, but pass an explicit `mqttUuid`
|
|
118
|
+
* (per host/install) when more than one client runs on the same account, to be told apart.
|
|
119
|
+
*/
|
|
120
|
+
mqttUuid?: string;
|
|
121
|
+
/** The account's `site_id` for the `power_site` heartbeat. Omitted from the frame when unknown. */
|
|
122
|
+
siteId?: string;
|
|
123
|
+
logger?: Logger;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Subscribe to a Solix device's live telemetry and emit decoded `reading` events. Reuses
|
|
127
|
+
* `SecureMqtt` for the connection; adds only the Solix data topic + ff09 param decoding.
|
|
128
|
+
*
|
|
129
|
+
* const mqtt = new SolixMqtt({ mqttInfo: await solix.getUserMqttInfo() });
|
|
130
|
+
* mqtt.on("reading", (r) => console.log(r.deviceSn, r.values.meterVoltageL1));
|
|
131
|
+
* await mqtt.watch(device); // device = a SolixClient.getDevices() entry
|
|
132
|
+
*/
|
|
133
|
+
export declare class SolixMqtt extends EventEmitter {
|
|
134
|
+
private readonly transport;
|
|
135
|
+
private readonly appName;
|
|
136
|
+
private readonly userId?;
|
|
137
|
+
private readonly appClientId;
|
|
138
|
+
private readonly armIntervalMs;
|
|
139
|
+
private readonly logger?;
|
|
140
|
+
private readonly siteId?;
|
|
141
|
+
private readonly watched;
|
|
142
|
+
private seq;
|
|
143
|
+
private armTimer?;
|
|
144
|
+
/**
|
|
145
|
+
* Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
|
|
146
|
+
* (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
|
|
147
|
+
* restart presents itself to the broker as a new client, so it defaults deterministically from the user
|
|
148
|
+
* id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
|
|
149
|
+
*/
|
|
150
|
+
constructor(opts: SolixMqttOptions);
|
|
151
|
+
/**
|
|
152
|
+
* Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
|
|
153
|
+
* start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
|
|
154
|
+
*
|
|
155
|
+
* Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
|
|
156
|
+
* channels — never the `…/req` channels, which are the app→device request side this arms on, and would
|
|
157
|
+
* echo its own publishes back.
|
|
158
|
+
*
|
|
159
|
+
* Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
|
|
160
|
+
* than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
|
|
161
|
+
* success: the call would resolve and arm on every interval while no reading ever arrives.
|
|
162
|
+
*
|
|
163
|
+
* The re-arm timer is unreffed, so a caller that watches and returns can still exit.
|
|
164
|
+
*/
|
|
165
|
+
watch(device: SolixMqttDevice): Promise<void>;
|
|
166
|
+
/** Tear down the connection and stop the re-arm timer. */
|
|
167
|
+
close(): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
|
|
170
|
+
* a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
|
|
171
|
+
* heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
|
|
172
|
+
* (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
|
|
173
|
+
* not thrown, so one bad device doesn't stop the rest or kill the timer.
|
|
174
|
+
*/
|
|
175
|
+
private armAll;
|
|
176
|
+
/** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
|
|
177
|
+
private arm;
|
|
178
|
+
/** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
|
|
179
|
+
private makeHead;
|
|
180
|
+
/**
|
|
181
|
+
* Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
|
|
182
|
+
* `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
|
|
183
|
+
* from a real value, so sending `""` would claim an account this client does not have.
|
|
184
|
+
*/
|
|
185
|
+
private commandEnvelope;
|
|
186
|
+
/**
|
|
187
|
+
* The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
|
|
188
|
+
* `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
|
|
189
|
+
*/
|
|
190
|
+
private heartbeatEnvelope;
|
|
191
|
+
/**
|
|
192
|
+
* Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
|
|
193
|
+
* product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
|
|
194
|
+
* own `a2` field wins for the serial when it carries one.
|
|
195
|
+
*/
|
|
196
|
+
private onMessage;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Pull the ff09 binary frame out of a received message. Solix telemetry arrives as a `{head, payload}`
|
|
200
|
+
* envelope whose `payload` is a JSON string carrying base64 `data` (or `trans`); `SecureMqtt`
|
|
201
|
+
* has already JSON-parsed the outer envelope. Returns the decoded frame bytes, or `null`.
|
|
202
|
+
*/
|
|
203
|
+
export declare function extractFf09Payload(raw: unknown): Buffer | null;
|
|
204
|
+
/**
|
|
205
|
+
* Build the ff09 request frame the app base64-encodes into a `requestDeviceInfo` (cmd 17) command's
|
|
206
|
+
* `data`. Captured live from the Anker app — request-type tag `a1`=0x22; the `realtime` variant adds
|
|
207
|
+
* `a2`/`a3` params (this is the one that keeps `param_info` reporting flowing), while `info` is the bare
|
|
208
|
+
* device-info fetch. Frame:
|
|
209
|
+
* `ff09 | len(u16 LE, TOTAL bytes incl. ff09+len+xor) | 5-byte header | a1 01 22
|
|
210
|
+
* [| a2 02 01 01 | a3 03 02 2c 01] | fe … <ts32 LE> | xor`
|
|
211
|
+
* `fe` carries a fresh unix-timestamp nonce; the trailing byte is XOR of every preceding byte (the same
|
|
212
|
+
* checksum the meter's telemetry frames use — verified to reproduce the captured frames exactly).
|
|
213
|
+
*/
|
|
214
|
+
export declare function buildFf09Request(variant: "info" | "realtime", atUnixSec?: number): Buffer;
|
|
@@ -78,3 +78,23 @@ 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. */
|
|
84
|
+
paramInfo: string;
|
|
85
|
+
/** This device's command replies (SUBSCRIBE). */
|
|
86
|
+
cmdRes: string;
|
|
87
|
+
/** The device's requestDeviceInfo channel (PUBLISH only — the app arms reporting here). */
|
|
88
|
+
req: string;
|
|
89
|
+
}
|
|
90
|
+
/** Build the per-device Solix topics. `param_info` is the telemetry we decode; `req` is publish-only. */
|
|
91
|
+
export declare function solixDeviceTopics(appName: string, productCode: string, deviceSn: string): SolixDeviceTopics;
|
|
92
|
+
/** The per-account Solix topics keyed by `user_id`. */
|
|
93
|
+
export interface SolixUserTopics {
|
|
94
|
+
/** Account-level command replies (SUBSCRIBE). */
|
|
95
|
+
cmdRes: string;
|
|
96
|
+
/** The `power_site` heartbeat channel (PUBLISH only). */
|
|
97
|
+
powerSite: string;
|
|
98
|
+
}
|
|
99
|
+
/** Build the per-account Solix topics. Note: the account `…/req` channel is publish-side and NOT subscribed. */
|
|
100
|
+
export declare function solixUserTopics(appName: string, userId: string): SolixUserTopics;
|
|
@@ -18,6 +18,23 @@ 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. `level2Grace` applies twice where the key is required: the negotiation is re-prompted once.
|
|
32
|
+
*/
|
|
33
|
+
export declare const P2P_STATION_WAITS: {
|
|
34
|
+
readonly connect: 20000;
|
|
35
|
+
readonly level2Grace: 25000;
|
|
36
|
+
readonly level2Settle: 8000;
|
|
37
|
+
};
|
|
21
38
|
/**
|
|
22
39
|
* Options accepted when warming a {@link SharedLiveSource} for a device (all optional).
|
|
23
40
|
*
|
|
@@ -90,6 +107,12 @@ export declare class P2PCommandRouter {
|
|
|
90
107
|
constructor(deps: P2PRouterDeps);
|
|
91
108
|
/** Forward one P2P failure once even when both the session listener and startup waiter observe it. */
|
|
92
109
|
private reportError;
|
|
110
|
+
/**
|
|
111
|
+
* Emit a live trace under a station session's handle, for work this router does ON that session before
|
|
112
|
+
* the session itself records anything — reaching the station, and resolving what a device is on it. Same
|
|
113
|
+
* handle as everything the session goes on to trace, which is what groups one attempt.
|
|
114
|
+
*/
|
|
115
|
+
private traceOnStation;
|
|
93
116
|
/**
|
|
94
117
|
* Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
|
|
95
118
|
* usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
|
|
@@ -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";
|
|
@@ -63,12 +63,37 @@ export type LiveTrace =
|
|
|
63
63
|
phase: "sequence-restart";
|
|
64
64
|
dataType: number;
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Work on a station is holding for its session to connect, with the milliseconds it will wait.
|
|
68
|
+
*
|
|
69
|
+
* The earliest phase there is: nothing else on a station can be attempted until its session is up, and a
|
|
70
|
+
* caller whose own deadline expires inside this wait has this record and no other. Emitted only where a wait
|
|
71
|
+
* actually happens, so its absence states that the session was already connected.
|
|
72
|
+
*/
|
|
73
|
+
| {
|
|
74
|
+
phase: "session-connect-wait";
|
|
75
|
+
waitMs: number;
|
|
76
|
+
}
|
|
77
|
+
/** The session connected, after this long. */
|
|
78
|
+
| {
|
|
79
|
+
phase: "session-connected";
|
|
80
|
+
waitedMs: number;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The session did not connect within its wait, so nothing on this station can be attempted.
|
|
84
|
+
*
|
|
85
|
+
* The one outcome that is otherwise indistinguishable from a station that answered and then refused: both
|
|
86
|
+
* leave a caller with no media and no phase naming a station.
|
|
87
|
+
*/
|
|
88
|
+
| {
|
|
89
|
+
phase: "session-unreachable";
|
|
90
|
+
waitedMs: number;
|
|
91
|
+
}
|
|
66
92
|
/**
|
|
67
93
|
* A live start is holding for the station's level-2 key, with the milliseconds it will wait.
|
|
68
94
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* re-issued — and only these separate them.
|
|
95
|
+
* A start that looks slow is either waiting here, waiting for its session to connect, waiting for the station
|
|
96
|
+
* to serve the channel it was asked for, or being re-issued — and only these phases separate them.
|
|
72
97
|
*/
|
|
73
98
|
| {
|
|
74
99
|
phase: "level2-wait";
|
|
@@ -79,10 +104,63 @@ export type LiveTrace =
|
|
|
79
104
|
phase: "level2-ready";
|
|
80
105
|
cipherId: number;
|
|
81
106
|
}
|
|
82
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The station's key is not coming, why, and the cipher where a station named one.
|
|
109
|
+
*
|
|
110
|
+
* Every ending of a level-2 wait carries one of these reasons, so a start refused for want of a key is
|
|
111
|
+
* accounted for however it ended. `grace-elapsed` is a wait that ran out and states how long was waited;
|
|
112
|
+
* the rest are answered without waiting, because the negotiation is one-shot per connection and a
|
|
113
|
+
* concluded one is final. `no-cipher-key` and `derivation-failed` are about this account's cipher
|
|
114
|
+
* material, `not-negotiating` and `session-closed` about the station or its connection — and only a
|
|
115
|
+
* reason reached under a negotiation has a cipher to name.
|
|
116
|
+
*
|
|
117
|
+
* A `grace-elapsed` start proceeds at level 1 where it has such a form, and not at all where it does not.
|
|
118
|
+
*/
|
|
83
119
|
| {
|
|
84
|
-
phase: "level2-
|
|
85
|
-
|
|
120
|
+
phase: "level2-unavailable";
|
|
121
|
+
reason: "no-cipher-key" | "derivation-failed" | "not-negotiating" | "session-closed" | "grace-elapsed";
|
|
122
|
+
cipherId?: number;
|
|
123
|
+
waitedMs?: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A station's cipher was answered with material for a DIFFERENT cipher, which was used in its place.
|
|
127
|
+
*
|
|
128
|
+
* The one lookup outcome no other phase accounts for: material for the cipher the station named is followed
|
|
129
|
+
* by `level2-ready` or by `level2-unavailable` with `derivation-failed`, an answer holding none by
|
|
130
|
+
* `no-cipher-key`, and a lookup that threw is reported as an error. Substituted material derives to
|
|
131
|
+
* nothing and otherwise reads as a station fault. `cipherId` is the cipher the station asked for,
|
|
132
|
+
* `answeredCipherId` the one whose material was used.
|
|
133
|
+
*/
|
|
134
|
+
| {
|
|
135
|
+
phase: "cipher-fallback";
|
|
136
|
+
cipherId: number;
|
|
137
|
+
answeredCipherId: number;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The station answered its gateway-info prompt, so a key derivation has begun under the cipher it named.
|
|
141
|
+
*
|
|
142
|
+
* What separates a station that never answered the prompt from one that answered and produced no usable key:
|
|
143
|
+
* without it, `level2-unavailable` with `not-negotiating` covers both, and they are a station or network
|
|
144
|
+
* problem and an account cipher-material problem respectively.
|
|
145
|
+
*/
|
|
146
|
+
| {
|
|
147
|
+
phase: "level2-negotiating";
|
|
148
|
+
cipherId: number;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A station was resolved for a call, stating what the caller's device is on it and whose station it is.
|
|
152
|
+
*
|
|
153
|
+
* Emitted before anything is sent, so it is the only account of the intended topology on a call that fails
|
|
154
|
+
* during resolution: an attached camera's media start has no unencrypted form, so whether a device was taken
|
|
155
|
+
* as attached decides what its failure means. `stationAdmin` states whether the signed-in account is the
|
|
156
|
+
* station's administrator, which is what a key the account cannot resolve turns on; `unstated` is a device
|
|
157
|
+
* record that names no administrator, which is not the same as naming another.
|
|
158
|
+
*/
|
|
159
|
+
| {
|
|
160
|
+
phase: "station-resolved";
|
|
161
|
+
topology: "attached" | "own";
|
|
162
|
+
channel: number;
|
|
163
|
+
stationAdmin: "self" | "other" | "unstated";
|
|
86
164
|
}
|
|
87
165
|
/** A shared source began warming, with the interval it re-issues on and the deadline it fails at. */
|
|
88
166
|
| {
|
|
@@ -110,6 +188,22 @@ export type LiveTrace =
|
|
|
110
188
|
| {
|
|
111
189
|
phase: "path-stale";
|
|
112
190
|
silentMs: number;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* A stream received nothing on its own channel for the stall window, and what was done about it.
|
|
194
|
+
*
|
|
195
|
+
* A station that switches to a sibling leaves the stream it was serving with no frames, no error and no
|
|
196
|
+
* stop, so this silence is the only statement that it happened. `reasserted` re-issued the media start,
|
|
197
|
+
* which is the repair; `declined` left the channel alone because nothing is attached to this pull and
|
|
198
|
+
* taking the station back would take it from a camera someone is watching.
|
|
199
|
+
*
|
|
200
|
+
* Media still arriving means this never fires, so a picture that stopped advancing while this is silent
|
|
201
|
+
* stopped for a reason upstream of the station's attention.
|
|
202
|
+
*/
|
|
203
|
+
| {
|
|
204
|
+
phase: "channel-silent";
|
|
205
|
+
silentMs: number;
|
|
206
|
+
outcome: "reasserted" | "declined";
|
|
113
207
|
};
|
|
114
208
|
/**
|
|
115
209
|
* Record one startup observation at debug level.
|
|
@@ -34,11 +34,22 @@ export declare function openLiveStream(session: P2PSession, opts?: LiveStreamOpt
|
|
|
34
34
|
* The header states the stream's geometry when the capture started, and a stream that reconfigures
|
|
35
35
|
* mid-burst leaves it describing something the returned bytes contradict; the return value describes an
|
|
36
36
|
* image, so the image is its source of truth.
|
|
37
|
+
*
|
|
38
|
+
* The consumer is detached the moment the collected run is complete, and the decode that follows holds no
|
|
39
|
+
* station: it works on bytes already in memory. A station serves one camera at a time and the SDK refuses a
|
|
40
|
+
* second channel on one that is busy, so a still that kept its pull attached across its own decode would deny
|
|
41
|
+
* that station to every live request for the length of an FFmpeg run — measured on a real base as a live
|
|
42
|
+
* request refused 370ms after the still it was waiting on had already collected everything it needed.
|
|
43
|
+
*
|
|
44
|
+
* `signal` ends the collection itself, not only the wait for it, and rejects with the signal's own reason
|
|
45
|
+
* because the abandonment is the caller's fact and not a failure of the source. It reaches only the
|
|
46
|
+
* collection: past that the station is already free, so there is nothing left for it to release.
|
|
37
47
|
*/
|
|
38
48
|
export declare function captureSnapshotFromShared(source: SharedLiveSource, opts?: {
|
|
39
49
|
timeoutMs?: number;
|
|
40
50
|
collectMs?: number;
|
|
41
51
|
skipKeyframes?: number;
|
|
52
|
+
signal?: AbortSignal;
|
|
42
53
|
logger?: Logger;
|
|
43
54
|
ffmpegLevel?: FfmpegLevel;
|
|
44
55
|
ffmpegPath?: string;
|
|
@@ -208,6 +208,10 @@ export declare class P2PSession extends EventEmitter {
|
|
|
208
208
|
* cameras from that second group streamed normally at level-1 — including one of the same firmware as an
|
|
209
209
|
* own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
|
|
210
210
|
* separates nothing on this path, and a start failure on such a session is not evidence about it.
|
|
211
|
+
*
|
|
212
|
+
* Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
|
|
213
|
+
* `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
|
|
214
|
+
* cipher and the cause are known, and re-stating it here would double every settled negotiation.
|
|
211
215
|
*/
|
|
212
216
|
awaitLevel2Key(graceMs: number, graceFrom?: "call" | "session"): Promise<boolean>;
|
|
213
217
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mega-yfue/eufy-sdk",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.10",
|
|
4
4
|
"description": "One typed TypeScript client for the Anker eufy v6 cloud — capability-driven devices, realtime events over P2P/MQTT/push, and live media",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "mega-yfue",
|
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
"guard:docrefs": "bash scripts/ci/guard-doc-refs.sh",
|
|
65
65
|
"guard:lines": "bash scripts/ci/guard-lines.sh",
|
|
66
66
|
"check:esm": "node -e \"import('./dist/index.js').then(m=>console.log('ESM OK:',Object.keys(m).length,'exports'))\"",
|
|
67
|
-
"
|
|
67
|
+
"check:snippets": "node scripts/ci/check-doc-snippets.mjs",
|
|
68
|
+
"verify": "npm run format:check && npm run typecheck && npm run guard:decorrelation && npm run guard:lines && npm run guard:docrefs && npm run guard:consumer-agnostic && npm run guard:capability-ownership && npm run build && npm run check:esm && npm run check:snippets && npm run typecheck:examples && npm test",
|
|
68
69
|
"release": "bash scripts/release.sh"
|
|
69
70
|
},
|
|
70
71
|
"engines": {
|