@mega-yfue/eufy-sdk 0.2.0-beta.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/client/device-registry.d.ts +14 -28
  2. package/dist/client/eufy-mega.d.ts +11 -6
  3. package/dist/client/types.d.ts +6 -2
  4. package/dist/core/contracts.d.ts +23 -27
  5. package/dist/core/crypto.d.ts +10 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/solix-types.d.ts +121 -0
  8. package/dist/core/store.d.ts +38 -10
  9. package/dist/index.js +2690 -485
  10. package/dist/index.js.map +4 -4
  11. package/dist/model/capabilities/access.d.ts +22 -3
  12. package/dist/model/capabilities/arming.d.ts +4 -0
  13. package/dist/model/capabilities/battery.d.ts +32 -4
  14. package/dist/model/capabilities/contact.d.ts +4 -0
  15. package/dist/model/capabilities/doorbell.d.ts +24 -14
  16. package/dist/model/capabilities/index.d.ts +14 -3
  17. package/dist/model/capabilities/lock.d.ts +15 -12
  18. package/dist/model/capabilities/ptz.d.ts +6 -2
  19. package/dist/model/capabilities/solix.d.ts +173 -0
  20. package/dist/model/capabilities/types.d.ts +60 -10
  21. package/dist/model/capabilities/vacuum-clean.d.ts +59 -0
  22. package/dist/model/classify.d.ts +3 -1
  23. package/dist/model/device-family.d.ts +2 -1
  24. package/dist/model/device-types.d.ts +1 -0
  25. package/dist/model/device.d.ts +15 -0
  26. package/dist/model/index.d.ts +5 -0
  27. package/dist/model/solix-catalog.d.ts +25 -0
  28. package/dist/model/solix-device.d.ts +137 -0
  29. package/dist/model/solix-family.d.ts +31 -0
  30. package/dist/model/solix-site.d.ts +70 -0
  31. package/dist/transport/ff09.d.ts +7 -0
  32. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  33. package/dist/transport/http/index.d.ts +1 -0
  34. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  35. package/dist/transport/http/media-download.d.ts +3 -0
  36. package/dist/transport/http/mega-client.d.ts +89 -9
  37. package/dist/transport/http/solix-client.d.ts +270 -0
  38. package/dist/transport/http/solix-constants.d.ts +56 -0
  39. package/dist/transport/media-failure.d.ts +48 -0
  40. package/dist/transport/mqtt/index.d.ts +3 -0
  41. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  42. package/dist/transport/mqtt/solix-mqtt.d.ts +360 -0
  43. package/dist/transport/mqtt/topics.d.ts +30 -0
  44. package/dist/transport/p2p/command-router.d.ts +131 -19
  45. package/dist/transport/p2p/live-stream.d.ts +5 -4
  46. package/dist/transport/p2p/live-trace.d.ts +32 -5
  47. package/dist/transport/p2p/media.d.ts +2 -2
  48. package/dist/transport/p2p/p2p-session.d.ts +9 -0
  49. package/dist/transport/p2p/session-manager.d.ts +57 -23
  50. package/dist/transport/p2p/shared-live-source.d.ts +10 -1
  51. package/dist/transport/p2p/station-channels.d.ts +54 -0
  52. package/dist/transport/stored-image-cache.d.ts +7 -1
  53. package/package.json +5 -5
@@ -50,9 +50,28 @@ export declare function setJsonRaw(cmd: number, data: Record<string, unknown>, c
50
50
  * Set a param carried in the `SET_PAYLOAD` (1350) envelope — `{account_id,cmd,mChannel,mValue3:cmd,
51
51
  * payload}`, GCM signCode 8 — NOT the bare `{commandType,data}` 1700 wrapper `setJson` uses. The wire
52
52
  * eufy uses for a few doorbell controls (status-LED 1716 `{light_enable}`). Level-2 by default; the
53
- * sink resolves the session/account_id and replays the frame. Pass `form: "auto"` when the envelope is
54
- * also valid at level 1, so a STANDALONE device — which never negotiates a level-2 key — can receive it
55
- * instead of failing outright.
53
+ * sink resolves the session/account_id and replays the frame.
54
+ *
55
+ * ## When this envelope takes `form: "auto"`
56
+ *
57
+ * Left at the default the frame is level-2 ONLY, and on a station holding no level-2 key that does not
58
+ * fail — it WAITS: the transport spends the full level-2 grace, re-prompts, spends it again, and only
59
+ * then throws. Every caller with a shorter bound sees a hang rather than a refusal, so a control on a
60
+ * device that may be its own keyless station is effectively unreachable. `"auto"` hands the choice to
61
+ * the session (`sendBySessionLevel`), which seals level-2 wherever a key exists — unchanged for a
62
+ * HomeBase — and level-1 where none does.
63
+ *
64
+ * Two conditions, and BOTH have to hold:
65
+ *
66
+ * 1. **`mValue3` is passed explicitly as 0.** The level-1 form of this envelope writes `mValue3:0`
67
+ * itself, while the level-2 form defaults it to the sub-command — so a command that passes 0 sends
68
+ * byte-identical JSON either way and `"auto"` only changes the seal. A command that OMITS `mValue3`
69
+ * would send a DIFFERENT object at level 1 than the one captured at level 2; that is a new wire
70
+ * needing its own evidence, not a downgrade, and it stays pinned until something captures it.
71
+ * 2. **The device can be its own station.** A camera or doorbell may be standalone; a HomeBase, and an
72
+ * accessory whose session IS its HomeBase's, always holds a key. Where a key is structurally
73
+ * guaranteed, staying pinned is the honest behaviour: a keyless station there is an anomaly, and
74
+ * throwing says so where a silently-ignored level-1 frame would look like success.
56
75
  */
57
76
  export declare function setPayload(cmd: number, payload: Record<string, unknown>, ctx: CommandContext, mValue3?: number, channel?: number, form?: ScalarForm): Command;
58
77
  /**
@@ -72,6 +72,10 @@ export declare const ARMING_CMD: {
72
72
  * first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
73
73
  * frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
74
74
  * the per-value evidence and the dates.
75
+ *
76
+ * The ENCRYPTION LEVEL is the session's to pick (`"auto"`), not this command's: the T8030 the envelope
77
+ * was captured on holds a level-2 key and seals it level-2, while an own-session camera that never
78
+ * negotiates one carries the same envelope level-1. See `armingCommand`.
75
79
  */
76
80
  readonly SET_ARMING: 1224;
77
81
  /**
@@ -90,8 +90,33 @@ export type BatteryActions = Surface<typeof BATTERY_MEMBERS>;
90
90
  * The richer raw `APP_CMD_SET_POWER_SOURCE` blob is a separate param, surfaced as `powerSourceInfo`.
91
91
  */
92
92
  declare function decodePowerSource(raw: string | number | boolean): number | string;
93
- /** False for a mains camera that only reports 1101 as a sentinel — used to gate the physical reads. */
94
- declare const notMainsCamera: (ctx: AvailabilityContext) => boolean;
93
+ /**
94
+ * The params whose subject IS the physical cell — so every member reading one must carry
95
+ * {@link notMainsCamera}.
96
+ *
97
+ * The ONE place that fact is declared. {@link cellGated} applies {@link notMainsCamera} from this list
98
+ * when the table is built, so a member never states the gate itself: adding a cell read is adding its
99
+ * param here, and there is no second place for it to be missing from. A per-member `available` was the
100
+ * alternative and is what the first two passes of this guard got wrong, in both directions — first by
101
+ * covering two of the seven, then by reading `unexposed` as covering a third.
102
+ *
103
+ * What is NOT here matters as much.
104
+ *
105
+ * - `workingMode` and the three `record*` settings describe how hard the camera works, not what powers
106
+ * it, and a mains camera genuinely has them. They are the reason the capability stays attached.
107
+ * - `cameraInfo` (1103) is a number whose meaning is unevidenced. Gating it would assert it is a
108
+ * battery fact, which is the kind of claim this guard exists to stop making.
109
+ * - `powerSource` (1293) is the open one. Both values it names — `Battery` and `External Solar Panel` —
110
+ * describe how a CELL is fed, so by this list's own rule it arguably belongs here. It is out because
111
+ * every param above is one a live mains camera was observed to publish and 1293 was not among them,
112
+ * and because it is `requires`-gated on its own param, so it appears only where the device reports
113
+ * it. Gating it would also withhold a described WRITE rather than a read, which is a different class
114
+ * of change. Unresolved rather than decided: see the note in the pull request.
115
+ *
116
+ * The two solar params ARE here: a panel exists to charge a cell, so a device without one has no solar
117
+ * harvest to report either.
118
+ */
119
+ export declare const CELL_PARAMS: readonly number[];
95
120
  /**
96
121
  * Every `battery` feature, declared once — the property schema, the evidence-gated getters, the derived
97
122
  * setters, the intent routes and the descriptions all come out of this table. Order is schema order.
@@ -117,7 +142,6 @@ export declare const BATTERY_MEMBERS: {
117
142
  readonly unit: "%";
118
143
  readonly kind: "percent";
119
144
  readonly provenance: "verified";
120
- readonly available: typeof notMainsCamera;
121
145
  readonly description: "Battery level 0-100 (verified: param 1101).";
122
146
  };
123
147
  /**
@@ -130,7 +154,6 @@ export declare const BATTERY_MEMBERS: {
130
154
  readonly type: "bool";
131
155
  readonly kind: "boolean";
132
156
  readonly provenance: "apk";
133
- readonly available: typeof notMainsCamera;
134
157
  readonly coerce: (v: string | number | boolean) => boolean;
135
158
  readonly description: string;
136
159
  };
@@ -300,6 +323,11 @@ export declare const BATTERY_MEMBERS: {
300
323
  * Reported, so it stays in the schema and answers through `getProperty` — but given no typed getter:
301
324
  * the payload's fields have never been decoded, and a getter would hand back an opaque blob typed as
302
325
  * though it meant something.
326
+ *
327
+ * `unexposed` is NOT a substitute for the cell gate. It suppresses the fluent GETTER; `propertiesOf`
328
+ * filters `writeOnly` and `available` and deliberately not `unexposed`, because a schema entry
329
+ * reachable through `getProperty` is the whole point of the mark. So a cell param needs
330
+ * {@link CELL_PARAMS} either way, or a mains camera publishes "battery power history" and answers it.
303
331
  */
304
332
  readonly batteryPowerStats: {
305
333
  readonly param: 3100;
@@ -106,6 +106,10 @@ export declare const CONTACT_MEMBERS: {
106
106
  * `1350` SET_PAYLOAD, `mChannel` = the device channel, `mValue3` 0, payload carrying the channel and a
107
107
  * transaction stamp — the byte-shape of the app's own captured frame. Out of range is refused rather
108
108
  * than clamped: the app's slider has no values outside it, so one is a caller error, not a nudge.
109
+ *
110
+ * Level-2 only, though the `mValue3` 0 would allow `"auto"`: an entry sensor's session IS its
111
+ * HomeBase's, which always holds a key — the second of `setPayload`'s two conditions, not an oversight
112
+ * of the first.
109
113
  */
110
114
  readonly alarmVolume: {
111
115
  readonly param: 1508;
@@ -14,8 +14,9 @@ export declare const DOORBELL_CMD: {
14
14
  */
15
15
  readonly QUICK_RESPONSE: 1706;
16
16
  /**
17
- * Mechanical (wired) chime enable/disable — whether the doorbell drives an existing wired chime box
18
- * (as opposed to / in addition to the wireless indoor chime, param 1702 `chimeSwitch` above). Same
17
+ * Mechanical (wired) chime enable/disable — whether the doorbell drives an existing wired chime box,
18
+ * the app's "existing doorbell chime" (as opposed to / in addition to the HomeBase acting as the chime,
19
+ * param 1702 `chimeSwitch` above; toggling each switch alone wrote one parameter and named the pair). Same
19
20
  * 136-byte direct-binary shape as `QUICK_RESPONSE`/camera on-off: `[u32 channel][u32 value][account_id
20
21
  * ASCII, zero-padded to 128 bytes]`, outer P2P cmd = 1703 itself, signCode 8, on the device's own
21
22
  * channel. Reversed from a live capture (outer-cmd 1703, signCode 8, captured on the doorbell's
@@ -24,6 +25,19 @@ export declare const DOORBELL_CMD: {
24
25
  * App `APP_CMD_BAT_DOORBELL_MECHANICAL_CHIME_SWITCH`.
25
26
  */
26
27
  readonly MECHANICAL_CHIME_SWITCH: 1703;
28
+ /**
29
+ * Whether the HOMEBASE acts as the doorbell's chime — the app's "HomeBase as chime" switch, as opposed
30
+ * to the wired chime box {@link DOORBELL_CMD.MECHANICAL_CHIME_SWITCH} drives. The app constant reads
31
+ * `CHIME_SWITCH`, which is why this was long described as a separate plug-in chime; toggling that one
32
+ * switch alone on a T8210 behind a HomeBase wrote 1702 and nothing else, and the account has no
33
+ * plug-in chime at all. Same 136-byte direct-binary shape as
34
+ * that sibling: `[u32 channel][u32 value][account_id ASCII, zero-padded to 128 bytes]`, outer P2P cmd
35
+ * 1702 itself, signCode 8, on the device's own channel. Reversed from a live capture (outer-cmd 1702,
36
+ * signCode 8, on the doorbell's `device_channel` — 2 for that unit, NOT a wire constant, always use
37
+ * `ctx.channel`): turning OFF sent `value=0`, turning ON sent `value=1` — a plain boolean.
38
+ * App `APP_CMD_BAT_DOORBELL_CHIME_SWITCH`.
39
+ */
40
+ readonly CHIME_SWITCH: 1702;
27
41
  /**
28
42
  * Wide Dynamic Range (WDR) image switch — a video/image tone-mapping setting that widens the
29
43
  * exposure range in high-contrast scenes (bright sky behind a visitor, etc). SAME 136-byte
@@ -158,13 +172,10 @@ export declare function parseQuickResponses(voiceList: Array<{
158
172
  /**
159
173
  * Every `doorbell` feature, declared once.
160
174
  *
161
- * Three of the seven reads are read-ONLY here even though the device accepts a write, because the write
175
+ * Two of the seven reads are read-ONLY here even though the device accepts a write, because the write
162
176
  * does not belong to this capability or is not confirmed:
163
177
  * - `ringtoneVolume` (1708) — the WRITE lives on `audio`, which owns every volume wire. 1708 leaks
164
178
  * onto non-doorbell cameras, so `audio` gates its setter on the doorbell capability instead.
165
- * - `chimeSwitch` (1702) — READ confirmed on a T8214, the WRITE wire never captured. Its 1703/1704
166
- * siblings share the param range but that is NOT evidence of a shared frame, and a wrong guess on a
167
- * fire-and-forget P2P write looks exactly like success.
168
179
  * - `notificationMode` (1710) — a config JSON the device reports whole; no write is captured.
169
180
  *
170
181
  * Exported but NOT published: each entry states its wire id and the evidence it was confirmed on,
@@ -173,19 +184,18 @@ export declare function parseQuickResponses(voiceList: Array<{
173
184
  */
174
185
  export declare const DOORBELL_MEMBERS: {
175
186
  /**
176
- * The WIRELESS indoor chime — the separate plug-in unit, not the wired chime box
177
- * `mechanicalChimeSwitch` drives. `unverified: true` with no `write` at all: the read is confirmed but
178
- * the write frame has never been captured, so the setter is absent from the surface (a compile-time
179
- * signal) and the intent path throws rather than reporting the doorbell as lacking the feature.
180
- * Sharing the 1702-1719 range with its captured siblings is not evidence of a shared frame shape.
187
+ * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
188
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame,
189
+ * not merely the param id observed live: direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape
190
+ * as its 1703 sibling — captured, not inferred from the shared param range.
181
191
  */
182
192
  readonly chimeSwitch: {
183
193
  readonly param: 1702;
184
194
  readonly type: "bool";
185
195
  readonly kind: "boolean";
186
- readonly provenance: "mega";
187
- readonly unverified: true;
196
+ readonly provenance: "verified";
188
197
  readonly description: string;
198
+ readonly write: (v: string | number | boolean, ctx: import("./types.js").CommandContext) => import("../../core/contracts.js").Command;
189
199
  };
190
200
  /**
191
201
  * `provenance` is "verified" not "mega": the actual write wire is confirmed (
@@ -197,7 +207,7 @@ export declare const DOORBELL_MEMBERS: {
197
207
  readonly type: "bool";
198
208
  readonly kind: "boolean";
199
209
  readonly provenance: "verified";
200
- readonly description: "Mechanical chime enabled (1703; confirmed on T8214).";
210
+ readonly description: string;
201
211
  readonly write: (v: string | number | boolean, ctx: import("./types.js").CommandContext) => import("../../core/contracts.js").Command;
202
212
  };
203
213
  /** Same reasoning and the same capture session as {@link DOORBELL_MEMBERS.mechanicalChimeSwitch}. */
@@ -54,6 +54,15 @@ export declare function getCapabilityModule(cap: Capability): CapabilityModule |
54
54
  * @internal
55
55
  */
56
56
  export declare const CAPABILITY_MODULES: Record<Capability, CapabilityModule>;
57
+ /**
58
+ * Every param these capabilities declare before any gate — the set a device's schema is a subset of.
59
+ *
60
+ * A param in here that a device's schema does NOT carry is one a gate withheld: the capability resolved,
61
+ * and its member decided the read does not describe this device — a cell reading on a mains model, a mode
62
+ * a family does not carry. Read-aliases count, since a member reads them under its own name.
63
+ * @internal
64
+ */
65
+ export declare function claimedParams(caps: Capability[]): Set<number>;
57
66
  /**
58
67
  * Merge the property schemas of several capabilities into one flat, de-duplicated list.
59
68
  *
@@ -75,10 +84,12 @@ export declare function mergeProperties(caps: Capability[], ctx?: AvailabilityCo
75
84
  * - a `modelHints` regex matches the model/category/name haystack,
76
85
  * - `codecs` includes `codec`,
77
86
  * - `detect(rec, codec)` returns true.
78
- * Never throws. Returns a de-duplicated array.
87
+ *
88
+ * An absent `codec` belongs to no line, so only the line-agnostic capabilities can match — the truthful
89
+ * answer for a device outside the eufy families entirely. Never throws. Returns a de-duplicated array.
79
90
  * @internal
80
91
  */
81
- export declare function detectCapabilities(rec: CloudRecord, codec: Codec): Capability[];
92
+ export declare function detectCapabilities(rec: CloudRecord, codec?: Codec): Capability[];
82
93
  /**
83
94
  * The baseline capabilities a codec grants every device of that family — derived from the modules
84
95
  * that declare the codec in their {@link import("./types").DetectionSpec} `codecs`. Each capability
@@ -470,7 +481,7 @@ export declare function buildActions(caps: readonly Capability[], deps: MemberDe
470
481
  * @internal
471
482
  */
472
483
  export declare function describeCapabilities(bound: Partial<DeviceActionMap>, ctx?: AvailabilityContext): CapabilityDescriptor[];
473
- export type { CommandContext, CapabilityActions, CapabilityStateReader, CapabilityModule, DetectionSpec, CapabilityFrame, CapabilityEvent, InboundSignal, EventMapping, ProductLine, ActionArgSpec, DecodedState, } from "./types.js";
484
+ export type { CommandContext, CapabilityActions, CapabilityStateReader, CapabilityModule, DetectionSpec, CapabilityFrame, CapabilityEvent, InboundSignal, EventClaim, EventMapping, ProductLine, ActionArgSpec, DecodedState, } from "./types.js";
474
485
  /**
475
486
  * The value-kind vocabulary a read is annotated with, re-exported from the barrel that publishes the
476
487
  * read itself, so the union a member's `kind` is drawn from is reachable from the same import.
@@ -66,32 +66,34 @@ declare const overP2p: (ctx: AvailabilityContext) => boolean;
66
66
  */
67
67
  export declare const LOCK_MEMBERS: {
68
68
  /**
69
- * The lock's own state, and the weakest thing in this table: 1200 is a `guessed` placeholder because
70
- * the lock announces (un)locking as a pushed event rather than holding a param, so the evidence gate
71
- * will normally leave this getter uninstalled and `lockState` is the real read. `writtenElsewhere`
72
- * points at the `lock`/`unlock` methods — a single `write` cannot express two verbs that carry no
73
- * value.
69
+ * The lock's own state, verified on param 6000: "4" = locked, "3" = unlocked.
70
+ * `writtenElsewhere` points at the `lock`/`unlock` methods — a single `write` cannot express two
71
+ * verbs that carry no value.
74
72
  */
75
73
  readonly locked: {
76
- readonly param: 1200;
74
+ readonly param: 6000;
77
75
  readonly type: "bool";
78
76
  readonly kind: "boolean";
79
- readonly provenance: "guessed";
77
+ readonly provenance: "verified";
80
78
  readonly writtenElsewhere: true;
81
- readonly description: "Lock state, true=locked. UNVERIFIED: no stable state param to key off; placeholder id pending verification.";
79
+ readonly coerce: (raw: string | number | boolean) => boolean;
80
+ readonly description: "Lock state, true=locked (verified: param 6000, 4=locked, 3=unlocked).";
82
81
  };
83
82
  /**
84
- * Cell charge as a percentage, on the same param 1101 every battery device reports. Named `battery`
83
+ * Cell charge as a percentage, on param 1101 or smart-lock param 6001. Named `battery`
85
84
  * within this capability rather than deferring to the `battery` capability: a lock resolves as a lock,
86
85
  * so the accessor is `dev.lock().battery`.
87
86
  */
88
87
  readonly battery: {
89
88
  readonly param: 1101;
89
+ readonly readAliases: readonly [{
90
+ readonly paramType: 6001;
91
+ }];
90
92
  readonly type: "number";
91
93
  readonly unit: "%";
92
94
  readonly kind: "percent";
93
95
  readonly provenance: "verified";
94
- readonly description: "Lock battery level 0-100 (verified: param 1101).";
96
+ readonly description: "Lock battery level 0-100 (verified: param 1101, alias 6001).";
95
97
  };
96
98
  /**
97
99
  * Link quality in dBm as the lock measures it, on the shared param 1141. Both actuation methods here
@@ -235,8 +237,9 @@ export declare const LOCK_MEMBERS: {
235
237
  };
236
238
  };
237
239
  /**
238
- * `lock` — smart lock. `locked` is the reported lock state, but there's no stable state param to
239
- * key off (only pushed via a CommandType-style event), so the param id here is a placeholder.
240
+ * `lock` — smart lock. `locked` reports lock state via verified param 6000 (4=locked, 3=unlocked);
241
+ * a device that reports no stable state param carries its transitions on the `lockState` event instead,
242
+ * whose payload names the decoded `locked`.
240
243
  */
241
244
  export declare const LOCK: CapabilityModule;
242
245
  export {};
@@ -199,8 +199,12 @@ export declare function savePresetCommand(id: number, ctx: CommandContext): [Com
199
199
  *
200
200
  * 6242 sets the default to the preset the camera is **currently parked on**. To move the default, park
201
201
  * the camera on `presetId` first — `preview(presetId)`, let the pan finish, then `setDefault(presetId)`.
202
- * Sent while the camera is elsewhere, it has no effect. The parser is topology-agnostic; the router
203
- * picks the encryption level (L1/L2) by topology.
202
+ * Sent while the camera is elsewhere, it has no effect.
203
+ *
204
+ * Level-2 only, unlike `zoom` beside it: this frame carries the envelope's DEFAULT `mValue3` (the
205
+ * sub-command), which the level-1 form cannot express — it writes 0. Downgrading it would send an
206
+ * object nothing has captured, so it stays pinned and is unreachable on a keyless standalone camera
207
+ * until one is. See `setPayload`'s two conditions.
204
208
  */
205
209
  export declare function setDefaultPositionCommand(presetId: number, ctx: CommandContext): Command;
206
210
  /**
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Anker **Solix** capability surface. Solix is a separate ecosystem — its own Anker account, backend
3
+ * and product catalog — so it keeps its own capability id union rather than joining eufy's `Capability`
4
+ * / `Codec` unions, and detection is by Anker catalog CATEGORY + product-code prefix (see
5
+ * {@link detectSolixCapabilities}) rather than eufy param ids.
6
+ *
7
+ * What it shares is the `members` engine: the one feature with a readable wire declares ONE `members`
8
+ * table, and its property schema, evidence gate and typed surface all derive from it through
9
+ * `members.ts` (`bindMembers` / `Surface`), exactly as a eufy capability does.
10
+ *
11
+ * @module model/capabilities/solix
12
+ */
13
+ import type { Surface } from "./members.js";
14
+ /** Every capability a Solix device may carry. Solix's OWN union (not eufy's `Capability`). */
15
+ export type SolixCapability = "identity" | "firmware" | "connectivity" | "energyMeter" | "battery" | "solarInput" | "acOutput" | "evCharger" | "charger" | "cooler";
16
+ /**
17
+ * The `energyMeter` surface — the electrical readings the vendor app names, as typed members. Each is
18
+ * read-only and evidence-gated: `bindMembers` installs a getter only once a frame that REPORTS that tag
19
+ * has landed, and answers `undefined` (not a fabricated `0`) before any has. The gate is "reported", not
20
+ * "non-zero": a single-phase / single-CT meter still reports its L2/L3 slots as `0.0`, so those members
21
+ * install and read `0` rather than staying absent — a caller sees `0` for an idle phase, not `undefined`.
22
+ *
23
+ * Provenance splits by what the evidence actually pins. The app's field VOCABULARY (these twelve names)
24
+ * is authoritative. For the tag→field binding, a live single-phase frame confirmed the L1 and total
25
+ * magnitudes (a nominal mains voltage, an equal line/total power pair, the line current), so those four
26
+ * positions are `verified`. The L2/L3 tags are never non-zero on a single-CT install and the app itself
27
+ * receives the meter as named JSON — there is no tag→phase decoder in the app binary — so their phase
28
+ * assignment is inferred from the block ordering and is marked `guessed`, not `apk`. The energy counters (`meterImportEnergy`/`meterExportEnergy`) are named
29
+ * on the wire but are NOT members here: their tag→name is confirmed, but their unit SCALE is not (a live
30
+ * reading is consistent with either Wh or kWh), so they stay raw named values via
31
+ * {@link SOLIX_METER_FIELD_NAMES} until a capture pins the scale, rather than ship a member with a guessed unit.
32
+ *
33
+ * @internal — the declaration `SolixEnergyMeterReads` derives from; exported (like the eufy `*_MEMBERS`
34
+ * tables) so it is a known symbol, but excluded from the rendered API reference.
35
+ */
36
+ export declare const SOLIX_ENERGY_METER_MEMBERS: {
37
+ /** Line-1 voltage (V), ff09 tag `0xAC` — confirmed live (a nominal mains voltage). */
38
+ readonly meterVoltageL1: {
39
+ readonly param: 172;
40
+ readonly type: "number";
41
+ readonly kind: "scalar";
42
+ readonly unit: "V";
43
+ readonly provenance: "verified";
44
+ readonly description: "Meter line-1 voltage (V) — ff09 tag 0xAC, confirmed against a live single-phase frame.";
45
+ };
46
+ /** Line-2 voltage (V), ff09 tag `0xAD` — the app's field; reads 0 until a multi-phase frame carries it. */
47
+ readonly meterVoltageL2: {
48
+ readonly param: 173;
49
+ readonly type: "number";
50
+ readonly kind: "scalar";
51
+ readonly unit: "V";
52
+ readonly provenance: "guessed";
53
+ readonly description: "Meter line-2 voltage (V) — ff09 tag 0xAD; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install.";
54
+ };
55
+ /** Line-3 voltage (V), ff09 tag `0xAE` — the app's field; reads 0 until a multi-phase frame carries it. */
56
+ readonly meterVoltageL3: {
57
+ readonly param: 174;
58
+ readonly type: "number";
59
+ readonly kind: "scalar";
60
+ readonly unit: "V";
61
+ readonly provenance: "guessed";
62
+ readonly description: "Meter line-3 voltage (V) — ff09 tag 0xAE; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install.";
63
+ };
64
+ /** Line-1 current (A), ff09 tag `0xAF` — confirmed live (the line's CT current). */
65
+ readonly meterCurrentL1: {
66
+ readonly param: 175;
67
+ readonly type: "number";
68
+ readonly kind: "scalar";
69
+ readonly unit: "A";
70
+ readonly provenance: "verified";
71
+ readonly description: "Meter line-1 current (A) — ff09 tag 0xAF, confirmed against a live single-phase frame.";
72
+ };
73
+ /** Line-2 current (A), ff09 tag `0xB0` — the app's field; reads 0 until a multi-phase frame carries it. */
74
+ readonly meterCurrentL2: {
75
+ readonly param: 176;
76
+ readonly type: "number";
77
+ readonly kind: "scalar";
78
+ readonly unit: "A";
79
+ readonly provenance: "guessed";
80
+ readonly description: "Meter line-2 current (A) — ff09 tag 0xB0; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install.";
81
+ };
82
+ /** Line-3 current (A), ff09 tag `0xB1` — the app's field; reads 0 until a multi-phase frame carries it. */
83
+ readonly meterCurrentL3: {
84
+ readonly param: 177;
85
+ readonly type: "number";
86
+ readonly kind: "scalar";
87
+ readonly unit: "A";
88
+ readonly provenance: "guessed";
89
+ readonly description: "Meter line-3 current (A) — ff09 tag 0xB1; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install.";
90
+ };
91
+ /** Line-1 active power (W), ff09 tag `0xA8` — confirmed live; negative on export. */
92
+ readonly meterPowerL1: {
93
+ readonly param: 168;
94
+ readonly type: "number";
95
+ readonly kind: "scalar";
96
+ readonly unit: "W";
97
+ readonly provenance: "verified";
98
+ readonly description: "Meter line-1 active power (W) — ff09 tag 0xA8, confirmed live; negative on export.";
99
+ };
100
+ /** Line-2 active power (W), ff09 tag `0xA9` — the app's field; reads 0 until a multi-phase frame carries it. */
101
+ readonly meterPowerL2: {
102
+ readonly param: 169;
103
+ readonly type: "number";
104
+ readonly kind: "scalar";
105
+ readonly unit: "W";
106
+ readonly provenance: "guessed";
107
+ readonly description: "Meter line-2 active power (W) — ff09 tag 0xA9; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install.";
108
+ };
109
+ /** Line-3 active power (W), ff09 tag `0xAA` — the app's field; reads 0 until a multi-phase frame carries it. */
110
+ readonly meterPowerL3: {
111
+ readonly param: 170;
112
+ readonly type: "number";
113
+ readonly kind: "scalar";
114
+ readonly unit: "W";
115
+ readonly provenance: "guessed";
116
+ readonly description: "Meter line-3 active power (W) — ff09 tag 0xAA; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install.";
117
+ };
118
+ /** Aggregate active power (W), ff09 tag `0xAB` — confirmed live; equals line-1 on a single phase. */
119
+ readonly meterPowerTotal: {
120
+ readonly param: 171;
121
+ readonly type: "number";
122
+ readonly kind: "scalar";
123
+ readonly unit: "W";
124
+ readonly provenance: "verified";
125
+ readonly description: "Meter total active power (W) — ff09 tag 0xAB, confirmed live; equals L1 on one phase.";
126
+ };
127
+ };
128
+ /** Bound `energyMeter` reads (the members-derived half of `dev.energyMeter()`). Read-only. */
129
+ export type SolixEnergyMeterReads = Surface<typeof SOLIX_ENERGY_METER_MEMBERS>;
130
+ /**
131
+ * The capabilities each Anker catalog category implies. Category is a detection SIGNAL (like eufy's
132
+ * `deviceTypes`), not the model's identity — a device still resolves `energyMeter` from its product code
133
+ * even though its category is "Accessory", which is why that category maps to nothing on its own.
134
+ * Unlisted categories contribute nothing here.
135
+ */
136
+ export declare const CATEGORY_CAPABILITIES: Readonly<Record<string, readonly SolixCapability[]>>;
137
+ /**
138
+ * Product-code prefixes known to be grid/energy meters (detects `energyMeter` regardless of category).
139
+ * Keep in lockstep with `SOLIX_METER_PRODUCT_PREFIXES` in `transport/mqtt/solix-mqtt.ts` (the same meter
140
+ * prefixes, transport-side, that gate the tag→name table): a prefix added here but not there grants
141
+ * `energyMeter` to a device whose frames the decoder then refuses to name. Add a meter prefix to both.
142
+ */
143
+ export declare const SOLIX_METER_MODELS: readonly string[];
144
+ /**
145
+ * Product-code prefixes for the grid-tie Solarbank / home-battery family (detects `battery` +
146
+ * `solarInput` regardless of category, so a caller that builds a device without the catalog still gets
147
+ * them): `A1790` = Solarbank E1600 gen-1, `A17C*` = Solarbank 2 / 3, `AE10*` = Solarbank 4 E5000 Pro /
148
+ * SOLIX Power Dock.
149
+ *
150
+ * Grounded in the live `product_categories` catalog: `A17C0`–`A17C5` and `AE100` all list under
151
+ * category `"Plug-in Home Battery "`, and `AE103` by the device spec. `AE1X0`/`AE1R0` meters start
152
+ * `AE1X`/`AE1R`, so `AE10` does not catch them.
153
+ *
154
+ * The speculative `A17E` ("Solarbank Max AC") and `AE11` ("Solarbank Max") were dropped: neither is in
155
+ * the catalog, and the only `AE11x` product there — `AE113` "XE 6/8kW" — is a Residential Storage
156
+ * System, a different family whose telemetry is unverified, so granting it `battery`/`solarInput` would
157
+ * be an unevidenced false positive (exactly what this detection is otherwise careful to avoid).
158
+ */
159
+ export declare const SOLARBANK_MODELS: readonly string[];
160
+ /** The minimum device shape {@link detectSolixCapabilities} reads. */
161
+ export interface SolixDetectionInput {
162
+ product_code: string;
163
+ device_sw_version?: string;
164
+ wifi_online?: boolean;
165
+ wifi_name?: string;
166
+ rssi?: string | number;
167
+ }
168
+ /**
169
+ * Resolve a Solix device's capability set from its record fields, catalog category, and product-code
170
+ * prefix — the Solix analogue of eufy's `detectCapabilities`, kept Solix-scoped so eufy detection is
171
+ * untouched. `identity` is universal; the rest are OR-ed evidence.
172
+ */
173
+ export declare function detectSolixCapabilities(rec: SolixDetectionInput, category?: string): Set<SolixCapability>;
@@ -136,6 +136,45 @@ export interface EventMapping {
136
136
  * capture. Return `{}` when this signal doesn't carry the field, so nothing is invented.
137
137
  */
138
138
  derive?(signal: InboundSignal): Record<string, unknown>;
139
+ /**
140
+ * Evidence that this device deals in the classification the id names, for an id whose presence in
141
+ * the wire vocabulary does not prove it.
142
+ *
143
+ * The AI-detection ids are shared verbatim across the camera families, so the id space says what an
144
+ * integer MEANS and never which units classify that way. A claim is how a mapping states the
145
+ * evidence that separates them, and it narrows the DESCRIPTION only: {@link EventMapping} stays in
146
+ * the dispatch index unclaimed, so a device that sends the push still gets the event. Under-reporting
147
+ * what a device is expected to emit is recoverable; dropping an event it did emit is not.
148
+ *
149
+ * Every field must hold — the AND to {@link DetectionSpec}'s OR, because a claim rules a family OUT
150
+ * rather than finding one more reason to say yes. A fact the context does not carry rules nothing
151
+ * out: only evidence that positively contradicts the claim withdraws the event.
152
+ */
153
+ claim?: EventClaim;
154
+ }
155
+ /**
156
+ * Evidence separating the families that share one inbound id.
157
+ *
158
+ * Both fields are optional and independent; an empty claim asserts nothing and is the same as none.
159
+ */
160
+ export interface EventClaim {
161
+ /**
162
+ * The codecs whose devices issue the id. For an id drawn from a vocabulary one device family owns:
163
+ * the AI-detection ids belong to the camera families, and a standalone sensor announces its own
164
+ * motion under a different id entirely.
165
+ */
166
+ codecs?: readonly Codec[];
167
+ /**
168
+ * Member names whose INSTALLED getter is the evidence — the device reported the parameter behind
169
+ * the classification, which is the same bar every typed read is held to.
170
+ */
171
+ reads?: readonly string[];
172
+ /**
173
+ * The topology the id belongs to: `true` for an id only a station's attached device sends, `false`
174
+ * for one only a standalone unit sends. Compared against {@link AvailabilityContext.homeBaseAttached},
175
+ * and ignored where that is absent.
176
+ */
177
+ homeBaseAttached?: boolean;
139
178
  }
140
179
  /**
141
180
  * A decoded inbound event a capability wants surfaced on the SDK. `event` is the EufyMega event
@@ -169,8 +208,13 @@ export interface DecodedState {
169
208
  * the manifest path and the command path.
170
209
  */
171
210
  export interface AvailabilityContext {
172
- /** Resolved codec/family. */
173
- codec: Codec;
211
+ /**
212
+ * Resolved codec/family. Absent for a device outside the eufy device model entirely — the codecs are
213
+ * the eufy transport families, so an ecosystem with its own backend has no truthful value here and
214
+ * says so by omission rather than borrowing another family's. Every gate that reads it compares
215
+ * against a specific codec, so an absent one matches none.
216
+ */
217
+ codec?: Codec;
174
218
  /** eufy DeviceType, when known. */
175
219
  deviceType?: number;
176
220
  /** Model / T-code, when known. */
@@ -192,6 +236,15 @@ export interface AvailabilityContext {
192
236
  * `undefined` as an empty set — `ctx.paramIds?.has(dp) ?? false`.
193
237
  */
194
238
  paramIds?: ReadonlySet<number>;
239
+ /**
240
+ * Whether the device hangs off a HomeBase (a `parent_sn` other than its own) rather than standing
241
+ * alone. A DEVICE fact, not a transport one — the same class of routing evidence as {@link hasP2p} —
242
+ * which is why it sits here rather than on {@link CommandContext}: it is as true of a described
243
+ * device as of a commanded one. The `rtsp` capability gates on it because a station serves an
244
+ * attached camera's stream itself and ignores that camera's authentication setting, so the write
245
+ * cannot do what its name promises there.
246
+ */
247
+ homeBaseAttached?: boolean;
195
248
  }
196
249
  export interface CommandContext extends AvailabilityContext {
197
250
  /** Device channel (0 for standalone, `device_channel` on a HomeBase). */
@@ -242,7 +295,11 @@ export interface CommandContext extends AvailabilityContext {
242
295
  adminUserId?: string;
243
296
  /** The acting member's short id (`member.short_user_id`, hex, e.g. `"0003"`) — the lock cmd `A5` field. */
244
297
  shortUserId?: string;
245
- /** The logged-in account's display name (email local-part) — the lock cmd acting-username `A4` field. */
298
+ /**
299
+ * The acting name a command attributes itself to — the lock cmd acting-username `A4` field, and the
300
+ * `user_name` of the guard-mode and HomeBase-alarm writes. The logged-in account's display name
301
+ * (email local-part) unless the client pins a different label for it.
302
+ */
246
303
  accountName?: string;
247
304
  /**
248
305
  * Whether the device has a usable P2P endpoint (a non-empty `p2p_did`). A HomeBase-attached lock
@@ -250,13 +307,6 @@ export interface CommandContext extends AvailabilityContext {
250
307
  * capability uses this to route lock/unlock to P2P vs. reject with a clear MQTT-not-wired error.
251
308
  */
252
309
  hasP2p?: boolean;
253
- /**
254
- * Whether the device hangs off a HomeBase (a `parent_sn` other than its own) rather than standing
255
- * alone. A DEVICE fact, not a transport one — the same class of routing evidence as {@link hasP2p}.
256
- * The `rtsp` capability gates on it because a station serves an attached camera's stream itself and
257
- * ignores that camera's authentication setting, so the write cannot do what its name promises there.
258
- */
259
- homeBaseAttached?: boolean;
260
310
  /**
261
311
  * Parsed `get_product_data_point` catalog for this device's SKU — present for vacuum/mower devices,
262
312
  * absent for all other codecs. Capabilities use it for per-model feature-availability and value-range