@mega-yfue/eufy-sdk 0.2.0-beta.1 → 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.
Files changed (35) hide show
  1. package/dist/client/eufy-mega.d.ts +5 -5
  2. package/dist/core/contracts.d.ts +58 -3
  3. package/dist/core/crypto.d.ts +10 -0
  4. package/dist/core/index.d.ts +1 -0
  5. package/dist/core/logger.d.ts +5 -3
  6. package/dist/core/solix-types.d.ts +36 -0
  7. package/dist/core/store.d.ts +20 -9
  8. package/dist/index.js +1142 -71
  9. package/dist/index.js.map +4 -4
  10. package/dist/model/capabilities/arming.d.ts +58 -28
  11. package/dist/model/capabilities/display.d.ts +85 -0
  12. package/dist/model/capabilities/index.d.ts +11 -5
  13. package/dist/model/capabilities/solix.d.ts +75 -0
  14. package/dist/model/capabilities/types.d.ts +16 -4
  15. package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
  16. package/dist/model/index.d.ts +3 -0
  17. package/dist/model/param-dictionary.d.ts +24 -0
  18. package/dist/model/param-namespace.d.ts +1 -1
  19. package/dist/model/solix-catalog.d.ts +20 -0
  20. package/dist/model/solix-device.d.ts +102 -0
  21. package/dist/model/types.d.ts +5 -5
  22. package/dist/transport/ff09.d.ts +7 -0
  23. package/dist/transport/http/index.d.ts +1 -0
  24. package/dist/transport/http/solix-client.d.ts +158 -0
  25. package/dist/transport/http/solix-constants.d.ts +29 -0
  26. package/dist/transport/mqtt/index.d.ts +2 -0
  27. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  28. package/dist/transport/mqtt/solix-mqtt.d.ts +214 -0
  29. package/dist/transport/mqtt/topics.d.ts +20 -0
  30. package/dist/transport/p2p/command-router.d.ts +23 -0
  31. package/dist/transport/p2p/index.d.ts +1 -0
  32. package/dist/transport/p2p/live-trace.d.ts +100 -6
  33. package/dist/transport/p2p/media.d.ts +11 -0
  34. package/dist/transport/p2p/p2p-session.d.ts +4 -0
  35. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { createCipheriv, createDecipheriv, createECDH, createHash, createHmac, r
15
15
  var P256 = "prime256v1";
16
16
  var EUFY_MEGA_LOCAL_KEY_HEX = "2500a7d5617812f9d52515b2c8f20a3d";
17
17
  var EUFYLIFE_LOCAL_KEY_HEX = "118c12c81e211149304bd70a0c071d01";
18
+ var SOLIX_LOCAL_KEY_HEX = "e8ad18f61bbd3fbd52d5ed12d14d3b9c";
18
19
  var SERVER_STATIC_PUBLIC_KEY_HEX = "04c5c00c4f8d1197cc7c3167c52bf7acb054d722f0ef08dcd7e0883236e0d72a3868d9750cb47fa4619248f3d83f0f662671dadc6e2d31c2f41db0161651c7c076";
19
20
  function genId() {
20
21
  return randomBytes(16).toString("hex");
@@ -22,8 +23,11 @@ function genId() {
22
23
  function nowSec() {
23
24
  return Math.floor(Date.now() / 1e3).toString();
24
25
  }
26
+ function md5Hex(input) {
27
+ return createHash("md5").update(input, "utf-8").digest("hex");
28
+ }
25
29
  function gtoken(userId) {
26
- return createHash("md5").update(userId, "utf-8").digest("hex");
30
+ return md5Hex(userId);
27
31
  }
28
32
  function aesKey(shareKeyHex) {
29
33
  return Buffer.from(shareKeyHex, "hex").subarray(0, 16);
@@ -142,13 +146,16 @@ var FileSessionStore = class {
142
146
  }
143
147
  }
144
148
  };
149
+ function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
150
+ if (tokenExpiresAt && tokenExpiresAt > 0) {
151
+ return Math.floor(Date.now() / 1e3) < tokenExpiresAt - skewSec;
152
+ }
153
+ return true;
154
+ }
145
155
  function isSessionValid(s, skewSec = 300) {
146
156
  if (!s?.authToken || !s.shareKey || !s.keyIdent)
147
157
  return false;
148
- if (s.tokenExpiresAt && s.tokenExpiresAt > 0) {
149
- return Math.floor(Date.now() / 1e3) < s.tokenExpiresAt - skewSec;
150
- }
151
- return true;
158
+ return tokenNotExpired(s.tokenExpiresAt, skewSec);
152
159
  }
153
160
 
154
161
  // dist/core/logger.js
@@ -1810,6 +1817,14 @@ function parseSecureTopic(topic) {
1810
1817
  return void 0;
1811
1818
  return { root, category, model, sn, tail: parts.slice(4).join("/") };
1812
1819
  }
1820
+ function solixDeviceTopics(appName, productCode, deviceSn) {
1821
+ const dt = `dt/${appName}/${productCode}/${deviceSn}`;
1822
+ const cmd = `cmd/${appName}/${productCode}/${deviceSn}`;
1823
+ return { paramInfo: `${dt}/param_info`, cmdRes: `${cmd}/app/res`, req: `${cmd}/req` };
1824
+ }
1825
+ function solixUserTopics(appName, userId) {
1826
+ return { cmdRes: `cmd/${appName}/${userId}/res`, powerSite: `dt/${appName}/${userId}/power_site` };
1827
+ }
1813
1828
 
1814
1829
  // dist/transport/mqtt/secure-mqtt.js
1815
1830
  var SUBACK_FAILURE = 128;
@@ -1903,7 +1918,7 @@ var SecureMqtt = class extends EventEmitter {
1903
1918
  * four topics for `eufy_life`).
1904
1919
  *
1905
1920
  * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
1906
- * `SUBACK_FAILURE` (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
1921
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
1907
1922
  * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
1908
1923
  * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
1909
1924
  * line that grants its state channel but refuses (say) the OTA leg still works.
@@ -1912,9 +1927,8 @@ var SecureMqtt = class extends EventEmitter {
1912
1927
  if (!this.client)
1913
1928
  throw new Error("SecureMqtt not connected");
1914
1929
  const topics = [...subscribeTopics(device)];
1915
- const grants = await this.client.subscribeAsync(topics, { qos: 1 });
1930
+ const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
1916
1931
  const scope = this.o.credentials.app_name ?? "default";
1917
- const denied = grants.filter((g) => g.qos === SUBACK_FAILURE).map((g) => g.topic);
1918
1932
  if (denied.length === topics.length) {
1919
1933
  throw new Error(`subscribe ${device.sn}: every topic denied on credential scope "${scope}" \u2014 this line's topics are granted to a different scope (${denied.join(", ")})`);
1920
1934
  }
@@ -1922,6 +1936,29 @@ var SecureMqtt = class extends EventEmitter {
1922
1936
  this.emit("error", new Error(`subscribe ${device.sn}: "${topic}" denied on credential scope "${scope}"`));
1923
1937
  }
1924
1938
  }
1939
+ /**
1940
+ * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
1941
+ * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
1942
+ * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
1943
+ * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
1944
+ */
1945
+ async subscribe(topics) {
1946
+ if (!this.client)
1947
+ throw new Error("SecureMqtt not connected");
1948
+ return this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 })).granted;
1949
+ }
1950
+ /**
1951
+ * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
1952
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
1953
+ * this split and layer their own policy (drop vs report) on top.
1954
+ */
1955
+ partitionGrants(grants) {
1956
+ const granted = [];
1957
+ const denied = [];
1958
+ for (const g of grants)
1959
+ (g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
1960
+ return { granted, denied };
1961
+ }
1925
1962
  /**
1926
1963
  * Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
1927
1964
  * is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the
@@ -2556,6 +2593,28 @@ var CameraDisabledError = class extends Error {
2556
2593
  this.name = "CameraDisabledError";
2557
2594
  }
2558
2595
  };
2596
+ var StationKeyUnavailableError = class extends Error {
2597
+ stationSn;
2598
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
2599
+ retryable = true;
2600
+ constructor(stationSn, options) {
2601
+ super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
2602
+ this.stationSn = stationSn;
2603
+ this.name = "StationKeyUnavailableError";
2604
+ }
2605
+ };
2606
+ var StationUnreachableError = class extends Error {
2607
+ stationSn;
2608
+ waitedMs;
2609
+ /** Always true: a station unreachable now may answer on a later attempt. */
2610
+ retryable = true;
2611
+ constructor(stationSn, waitedMs, options) {
2612
+ super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
2613
+ this.stationSn = stationSn;
2614
+ this.waitedMs = waitedMs;
2615
+ this.name = "StationUnreachableError";
2616
+ }
2617
+ };
2559
2618
  var StationBusyError = class extends Error {
2560
2619
  servingChannel;
2561
2620
  /** Always true: the station is busy now, and stops being busy when the other stream is released. */
@@ -6890,6 +6949,26 @@ var KEYPAD = {
6890
6949
  // dist/model/capabilities/arming.js
6891
6950
  var STATION_CHANNEL2 = 255;
6892
6951
  var ArmingMode = {
6952
+ /** Armed — full protection, nobody home (wire value 0). */
6953
+ away: "away",
6954
+ /** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
6955
+ home: "home",
6956
+ /** Scheduled — the station follows the timetable configured in the app (wire value 2). */
6957
+ schedule: "schedule",
6958
+ /** Custom 1 — a user-defined posture configured in the app (wire value 3). */
6959
+ custom1: "custom1",
6960
+ /** Custom 2 — a user-defined posture configured in the app (wire value 4). */
6961
+ custom2: "custom2",
6962
+ /** Custom 3 — a user-defined posture configured in the app (wire value 5). */
6963
+ custom3: "custom3",
6964
+ /** Off — the station's alarm system is switched off entirely (wire value 6). */
6965
+ off: "off",
6966
+ /** Geofenced — the station follows the app's location-based rules (wire value 47). */
6967
+ geo: "geo",
6968
+ /** Disarmed — no alarms; sensors still report state (wire value 63). */
6969
+ disarmed: "disarmed"
6970
+ };
6971
+ var AlarmDelayMode = {
6893
6972
  /** Armed — full protection, nobody home (wire value 0). */
6894
6973
  away: "away",
6895
6974
  /** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
@@ -6905,10 +6984,11 @@ var ARMING_CMD = {
6905
6984
  * mValue3:0, `payload:{mode_type:<int>, user_name:<string>}`.
6906
6985
  *
6907
6986
  * ⚠️ Only 3 of the 9 modes were exercised in that capture — `mode_type` 0 (away), 63 (disarmed), 1
6908
- * (home), all confirmed byte-exact, and those three are the whole of {@link ArmingMode}. Re-confirmed
6909
- * live 2026-08-05: each reported its own MODE_SWITCH push within ~5s of the write. The remaining six are
6910
- * named by the app but never observed leaving it, so this capability reads them and refuses to send
6911
- * them. See `ARMING_MODE_WIRE` for the per-value breakdown.
6987
+ * (home), all confirmed byte-exact. Re-confirmed live 2026-08-05: each reported its own MODE_SWITCH push
6988
+ * within ~5s of the write. The remaining six are live confirmations rather than captures — `custom1` 3
6989
+ * first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
6990
+ * frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
6991
+ * the per-value evidence and the dates.
6912
6992
  */
6913
6993
  SET_ARMING: 1224,
6914
6994
  /**
@@ -6942,21 +7022,14 @@ var ARMING_MODE_WIRE = {
6942
7022
  away: 0,
6943
7023
  home: 1,
6944
7024
  schedule: 2,
6945
- // ⚠️ reportable, NOT settable — see the doc comment above
6946
7025
  custom1: 3,
6947
- // ⚠️ reportable, NOT settable — see the doc comment above
6948
7026
  custom2: 4,
6949
- // ⚠️ reportable, NOT settable — see the doc comment above
6950
7027
  custom3: 5,
6951
- // ⚠️ reportable, NOT settable — see the doc comment above
6952
7028
  off: 6,
6953
- // ⚠️ reportable, NOT settable — see the doc comment above
6954
7029
  geo: 47,
6955
- // ⚠️ reportable, NOT settable — see the doc comment above
6956
7030
  disarmed: 63
6957
7031
  };
6958
7032
  var ARMING_MODE_LABELS = enumLabels(ARMING_MODE_WIRE);
6959
- var SETTABLE_MODES = Object.values(ArmingMode).map((m) => ARMING_MODE_WIRE[m]);
6960
7033
  function armingModeOf(v) {
6961
7034
  const name = String(v);
6962
7035
  if (name in ArmingMode)
@@ -6965,11 +7038,10 @@ function armingModeOf(v) {
6965
7038
  return Object.values(ArmingMode).find((m) => ARMING_MODE_WIRE[m] === wire);
6966
7039
  }
6967
7040
  function armingCommand(mode, ctx) {
6968
- const modeType = ARMING_MODE_WIRE[mode];
6969
7041
  if (!ctx.accountName) {
6970
7042
  throw new Error(`arming: missing account identity (user_name) [${describeDevice(ctx)}]`);
6971
7043
  }
6972
- return setPayload(ARMING_CMD.SET_ARMING, { mode_type: modeType, user_name: ctx.accountName }, ctx, 0);
7044
+ return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0);
6973
7045
  }
6974
7046
  function alarmDelayCommand(mode, config, ctx) {
6975
7047
  const data = {
@@ -6986,11 +7058,10 @@ function alarmDelayCommand(mode, config, ctx) {
6986
7058
  }
6987
7059
  var ARMING_MEMBERS = {
6988
7060
  /**
6989
- * The one member whose write domain is NARROWER than its read: `enumValues` names all nine modes a
6990
- * station can report, and the argument's `values` publishes only the three whose wire was captured. That
6991
- * argument IS the domain the derived setter enforces and the refusal names, so an uncaptured mode is
6992
- * refused by naming the three that work — nine labels for the read and three for the write, off one
6993
- * declaration.
7061
+ * Read and write are the same nine modes, so `enumValues` is the whole domain: `writeDomain` falls back
7062
+ * to it, and the derived setter, the refusal message and the offered argument all read from that one
7063
+ * declaration. A member states an `args` entry only where the two sides DIFFER. See
7064
+ * {@link ARMING_MODE_WIRE} for the per-value evidence.
6994
7065
  *
6995
7066
  * `armingCommand` may also throw synchronously (missing account identity) and `bindMembers` turns that
6996
7067
  * into a rejection, so the builder stays plain.
@@ -7009,8 +7080,7 @@ var ARMING_MEMBERS = {
7009
7080
  kind: "enum",
7010
7081
  enumValues: ARMING_MODE_LABELS,
7011
7082
  provenance: "verified",
7012
- args: [{ name: "mode", kind: "enum", values: SETTABLE_MODES }],
7013
- description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads all 9 modes the app defines; SETS only the 3 whose write is wire-captured (away/home/disarmed) \u2014 schedule/custom1/custom2/custom3/off/geo are named by the app but no capture shows one being sent, so they are refused rather than guessed; see ARMING_MODE_WIRE in arming.ts for the breakdown.",
7083
+ description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads and SETS all 9 modes the app defines \u2014 away/home/schedule/custom1/custom2/custom3/off/geo/disarmed. Three are byte-captured writes and six are live-confirmed (each sent and observed to report its own MODE_SWITCH); see ARMING_MODE_WIRE in arming.ts for the per-value evidence.",
7014
7084
  observation: {
7015
7085
  event: "armingModeChanged",
7016
7086
  reflects: (value) => ({ param: ARMING_CMD.SET_ARMING, expected: ARMING_MODE_WIRE[armingModeOf(value)] }),
@@ -7031,9 +7101,10 @@ var ARMING_MEMBERS = {
7031
7101
  * this mode — there is no known GET to fetch it automatically, and a wrong guess here can silently
7032
7102
  * misconfigure which sensors arm/trigger for real.
7033
7103
  *
7034
- * Takes {@link ArmingMode}, so a delay can only be configured for a mode whose `mode_id` integer is
7035
- * captured. The frame carries that same integer, so a schedule/custom mode would be the identical guess
7036
- * `setMode` refuses.
7104
+ * Takes {@link AlarmDelayMode}, not {@link ArmingMode}: a delay is configurable only for a mode whose
7105
+ * integer is captured on THIS command, and `custom1` is confirmed on cmd 1224 only. The frame carries
7106
+ * that integer in `mode_id` with no runtime validation and no readback, so a mode outside this union
7107
+ * would be the same unverified guess `setMode` refuses.
7037
7108
  */
7038
7109
  setAlarmDelayConfig: method(({ ctx, sink }) => (mode, config) => {
7039
7110
  try {
@@ -7704,6 +7775,89 @@ var ModeCtrlMethod = {
7704
7775
  STOP_SMART_FOLLOW: 18,
7705
7776
  START_GLOBAL_CRUISE: 20
7706
7777
  };
7778
+ var ModeCtrlParamMethod = {
7779
+ /** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
7780
+ SELECT_ROOMS: { method: 1, param: 4 },
7781
+ /** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
7782
+ SELECT_ZONES: { method: 2, param: 5 },
7783
+ /** `START_GOTO_CLEAN` — drive to a point and clean around it. */
7784
+ GOTO: { method: 4, param: 7 },
7785
+ /** `START_SCENE_CLEAN` — run a saved scene by its id. */
7786
+ SCENE: { method: 24, param: 14 }
7787
+ };
7788
+ var SELECT_ROOMS_FIELD = {
7789
+ /** `rooms` — repeated, one entry per room. */
7790
+ ROOMS: 1,
7791
+ /** `clean_times` — how many passes to make. */
7792
+ CLEAN_TIMES: 2,
7793
+ /** `map_id` — WHICH saved map the room ids belong to. */
7794
+ MAP_ID: 3,
7795
+ /** `id` within a `Room`. */
7796
+ ROOM_ID: 1,
7797
+ /** `order` within a `Room` — the sequence to visit them in. */
7798
+ ROOM_ORDER: 2
7799
+ };
7800
+ var SELECT_ZONES_FIELD = {
7801
+ /** `zones` — repeated, one entry per rectangle. */
7802
+ ZONES: 1,
7803
+ /** `map_id` — which saved map the coordinates belong to. */
7804
+ MAP_ID: 2,
7805
+ /** `quadrangle` within a `Zone` — its four corners. */
7806
+ QUADRANGLE: 1,
7807
+ /** `clean_times` within a `Zone`. */
7808
+ ZONE_CLEAN_TIMES: 2,
7809
+ /** `x` within a `Point`, SIGNED centimetres. */
7810
+ POINT_X: 1,
7811
+ /** `y` within a `Point`, SIGNED centimetres. */
7812
+ POINT_Y: 2
7813
+ };
7814
+ var SCENE_CLEAN_ID = 1;
7815
+ function encodeModeCtrlParam(method2, paramField, build) {
7816
+ return rawDp((w) => {
7817
+ if (method2 !== 0)
7818
+ w.int(MODE_CTRL_FIELD.METHOD, method2);
7819
+ w.int(MODE_CTRL_FIELD.SEQ, nextModeCtrlSeq());
7820
+ w.sub(paramField, build);
7821
+ });
7822
+ }
7823
+ function encodeSelectRoomsClean(mapId, rooms, cleanTimes = 1) {
7824
+ const { method: method2, param } = ModeCtrlParamMethod.SELECT_ROOMS;
7825
+ return encodeModeCtrlParam(method2, param, (p) => {
7826
+ for (const room of rooms) {
7827
+ p.sub(SELECT_ROOMS_FIELD.ROOMS, (r) => {
7828
+ r.int(SELECT_ROOMS_FIELD.ROOM_ID, room.id);
7829
+ if (room.order !== void 0)
7830
+ r.int(SELECT_ROOMS_FIELD.ROOM_ORDER, room.order);
7831
+ });
7832
+ }
7833
+ p.int(SELECT_ROOMS_FIELD.CLEAN_TIMES, cleanTimes);
7834
+ p.int(SELECT_ROOMS_FIELD.MAP_ID, mapId);
7835
+ });
7836
+ }
7837
+ function encodeSelectZonesClean(mapId, zones) {
7838
+ const { method: method2, param } = ModeCtrlParamMethod.SELECT_ZONES;
7839
+ return encodeModeCtrlParam(method2, param, (p) => {
7840
+ for (const zone of zones) {
7841
+ p.sub(SELECT_ZONES_FIELD.ZONES, (z) => {
7842
+ z.sub(SELECT_ZONES_FIELD.QUADRANGLE, (q) => {
7843
+ for (const [i, corner] of zone.corners.entries()) {
7844
+ q.sub(i + 1, (pt) => {
7845
+ pt.sint(SELECT_ZONES_FIELD.POINT_X, corner.x);
7846
+ pt.sint(SELECT_ZONES_FIELD.POINT_Y, corner.y);
7847
+ });
7848
+ }
7849
+ });
7850
+ if (zone.cleanTimes !== void 0)
7851
+ z.int(SELECT_ZONES_FIELD.ZONE_CLEAN_TIMES, zone.cleanTimes);
7852
+ });
7853
+ }
7854
+ p.int(SELECT_ZONES_FIELD.MAP_ID, mapId);
7855
+ });
7856
+ }
7857
+ function encodeSceneClean(sceneId) {
7858
+ const { method: method2, param } = ModeCtrlParamMethod.SCENE;
7859
+ return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
7860
+ }
7707
7861
  var modeCtrlSeq = 111;
7708
7862
  function nextModeCtrlSeq() {
7709
7863
  return ++modeCtrlSeq;
@@ -9539,7 +9693,41 @@ var VACUUM_CLEAN_MEMBERS = {
9539
9693
  /** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
9540
9694
  returnToDock: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.START_GOHOME, nextModeCtrlSeq()))), "Return to the dock (ModeCtrlRequest method 6 over DP 152).", isAiotVacuum),
9541
9695
  /** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
9542
- pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum)
9696
+ pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum),
9697
+ /**
9698
+ * Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).
9699
+ *
9700
+ * The id is the device's own, as {@link VACUUM_CLEAN_MEMBERS.scenes} reports it — `VacuumScene.id`
9701
+ * off the `SceneResponse` on DP 180. A scene the device reports invalid stays reportable and running
9702
+ * it is still a well-formed request; `VacuumScene.invalidReason` says why the device will refuse.
9703
+ *
9704
+ * Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 24 has been
9705
+ * WATCHED: run on a T2351, it started the named scene.
9706
+ */
9707
+ startScene: method(({ sink }) => (sceneId) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSceneClean(sceneId))), "Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).", isAiotVacuum),
9708
+ /**
9709
+ * Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
9710
+ *
9711
+ * `mapId` has no default and that is deliberate: room ids are per map, so assuming the map a
9712
+ * single-floor home would have sends a two-floor home's ids against the wrong floor. `SceneInfo.mapid`
9713
+ * on DP 180 and a scheduled rooms-clean's `map_id` are the two real map ids the device reports.
9714
+ *
9715
+ * `cleanTimes` is how many passes to make over the set; rooms with no `order` are visited in the
9716
+ * order given.
9717
+ *
9718
+ * Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 1 has been
9719
+ * WATCHED: run on a T2351, it cleaned the rooms named.
9720
+ */
9721
+ cleanRooms: method(({ sink }) => (mapId, rooms, cleanTimes = 1) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectRoomsClean(mapId, rooms, cleanTimes))), "Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).", isAiotVacuum),
9722
+ /**
9723
+ * Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).
9724
+ *
9725
+ * Corners are SIGNED centimetres in the map's own frame, whose origin sits wherever the robot first
9726
+ * mapped from — negative coordinates are ordinary and are ZigZag-encoded, not written as plain
9727
+ * varints. Same `mapId` reasoning as {@link VACUUM_CLEAN_MEMBERS.cleanRooms}, and the same evidence:
9728
+ * method 2 was run on a T2351 and cleaned the rectangles given.
9729
+ */
9730
+ cleanZones: method(({ sink }) => (mapId, zones) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectZonesClean(mapId, zones))), "Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).", isAiotVacuum)
9543
9731
  };
9544
9732
  var VACUUM_CLEAN = {
9545
9733
  capability: "vacuum_clean",
@@ -9715,6 +9903,70 @@ function toNumberArray(arr) {
9715
9903
  return result;
9716
9904
  }
9717
9905
 
9906
+ // dist/model/capabilities/display.js
9907
+ var DISPLAY_PARAM = { BATTERY: 8001, SOFTWARE_VERSION: 8003 };
9908
+ var DISPLAY_MEMBERS = {
9909
+ /**
9910
+ * Battery level, 0-100.
9911
+ *
9912
+ * **On this capability rather than on `battery`, and that is the line partition doing its job.** The
9913
+ * security-line `battery` capability reads param 1101 and a Smart Display's charge is 8001 in its own
9914
+ * id space — two wires that happen to mean the same thing. One capability reading both would be a claim
9915
+ * that the two ecosystems share a param space, so a consumer reads a display's charge through
9916
+ * `dev.display()` and a camera's through `dev.battery()`.
9917
+ *
9918
+ * `verified` rather than `mega`: the id is in the device's cloud record, but the NAME came from the
9919
+ * maintainer's own knowledge of the hardware rather than from the cloud data-point list, and `"100"`
9920
+ * fits brightness, volume or charge equally.
9921
+ *
9922
+ * The scale is `percent` on the reading itself, not on convention alone: a full charge reads `255` on a
9923
+ * 0-255 scale and `1000` on a 0-1000 one, so `"100"` on a charged unit is positive evidence for 0-100
9924
+ * rather than merely consistent with it. What nobody has done is watch it MOVE, which is why a value
9925
+ * frozen at 100 would not yet be distinguishable from a healthy one.
9926
+ */
9927
+ battery: {
9928
+ param: DISPLAY_PARAM.BATTERY,
9929
+ type: "number",
9930
+ unit: "%",
9931
+ kind: "percent",
9932
+ provenance: "verified",
9933
+ description: "Battery level, 0-100 (param 8001)."
9934
+ },
9935
+ /**
9936
+ * Version-shaped, and that shape is the whole of the evidence — hence `guessed`, and hence no typed
9937
+ * getter: a caller reading this off a bound object cannot see the label.
9938
+ *
9939
+ * `unexposed` rather than absent, so the schema still carries its type and that label and
9940
+ * `getProperty("softwareVersion")` still answers. `dev.info()?.firmwareVersion` is the field to trust
9941
+ * where the cloud record carries one; on this display it does not, which is the only reason 8003 is
9942
+ * named at all.
9943
+ */
9944
+ softwareVersion: {
9945
+ param: DISPLAY_PARAM.SOFTWARE_VERSION,
9946
+ type: "string",
9947
+ kind: "text",
9948
+ provenance: "guessed",
9949
+ unexposed: true,
9950
+ description: "Version-shaped string (param 8003), meaning unconfirmed \u2014 prefer `info.firmwareVersion`."
9951
+ }
9952
+ };
9953
+ var DISPLAY = {
9954
+ capability: "display",
9955
+ line: "display",
9956
+ description: "Smart Display battery level (param 8001, display namespace).",
9957
+ members: DISPLAY_MEMBERS,
9958
+ properties: propertiesOf(DISPLAY_MEMBERS),
9959
+ /**
9960
+ * Claimed by CODEC, not by an evidence param.
9961
+ *
9962
+ * 8001 is in the device's cloud record, so the ordinary evidence gate would install the getter anyway
9963
+ * — but the capability should attach to a Smart Display that has reported nothing yet too, because a
9964
+ * device on this line has no other capability to carry it. The line partition keeps this off
9965
+ * everything else: `display` is the only codec in the `display` line.
9966
+ */
9967
+ detection: { codecs: ["display"] }
9968
+ };
9969
+
9718
9970
  // dist/model/capabilities/locate.js
9719
9971
  var LOCATE_DP = 160;
9720
9972
  var LEGACY_LOCATE_DP = TUYA_VACUUM_DP.LOOK_FOR_SWEEPER;
@@ -9843,6 +10095,7 @@ var MODULES = [
9843
10095
  VACUUM_DOCK,
9844
10096
  SUCTION,
9845
10097
  LOCATE,
10098
+ DISPLAY,
9846
10099
  INFO
9847
10100
  ];
9848
10101
  var BY_CAP = new Map(MODULES.map((m) => [m.capability, m]));
@@ -9887,11 +10140,11 @@ var CODEC_LINE = {
9887
10140
  mower: "clean",
9888
10141
  light: "life",
9889
10142
  printer: "print",
9890
- display: "security"
10143
+ display: "display"
9891
10144
  };
9892
10145
  function lineAllows(module, codec) {
9893
10146
  const line = module.line ?? "security";
9894
- return line === "any" || line === CODEC_LINE[codec];
10147
+ return line === "any" || codec !== void 0 && line === CODEC_LINE[codec];
9895
10148
  }
9896
10149
  function detectCapabilities(rec, codec) {
9897
10150
  const found = /* @__PURE__ */ new Set();
@@ -9921,10 +10174,10 @@ function detectCapabilities(rec, codec) {
9921
10174
  if (!matched && d.modelHints && haystack.length > 0) {
9922
10175
  matched = d.modelHints.some((re) => re.test(haystack));
9923
10176
  }
9924
- if (!matched && d.codecs) {
10177
+ if (!matched && d.codecs && codec !== void 0) {
9925
10178
  matched = d.codecs.includes(codec);
9926
10179
  }
9927
- if (!matched && d.detect) {
10180
+ if (!matched && d.detect && codec !== void 0) {
9928
10181
  try {
9929
10182
  matched = d.detect(rec, codec) === true;
9930
10183
  } catch {
@@ -11987,6 +12240,28 @@ var CLEAN_PARAMS = {
11987
12240
  provenance: "mega"
11988
12241
  }
11989
12242
  };
12243
+ var DISPLAY_PARAMS = {
12244
+ 8001: {
12245
+ name: "battery",
12246
+ type: "number",
12247
+ provenance: "verified"
12248
+ },
12249
+ 8003: {
12250
+ name: "softwareVersion",
12251
+ type: "string",
12252
+ provenance: "guessed"
12253
+ },
12254
+ 8005: {
12255
+ name: "modelName",
12256
+ type: "string",
12257
+ provenance: "mega"
12258
+ },
12259
+ 8006: {
12260
+ name: "modelCode",
12261
+ type: "string",
12262
+ provenance: "mega"
12263
+ }
12264
+ };
11990
12265
 
11991
12266
  // dist/model/life-params.js
11992
12267
  var LIFE_PARAMS = {
@@ -12014,7 +12289,10 @@ var TABLES = {
12014
12289
  // 3D-printer (ankermake) id space — empty until a live capture confirms the param↔semantic map
12015
12290
  // (printer-support plan Stage 3). Present so the printer codec resolves to its OWN namespace rather
12016
12291
  // than falling through to `security` and decoding another line's dictionary.
12017
- print: {}
12292
+ print: {},
12293
+ // Smart Display (T87Ax) — ids 8001-8006, four of them named. Same reason as `print`: its own
12294
+ // dictionary rather than a corner of another line's.
12295
+ display: DISPLAY_PARAMS
12018
12296
  };
12019
12297
  function paramDef(ns, paramType) {
12020
12298
  return TABLES[ns][paramType];
@@ -12029,7 +12307,7 @@ var NAMESPACE_BY_CODEC = {
12029
12307
  mower: "clean",
12030
12308
  light: "life",
12031
12309
  printer: "print",
12032
- display: "security"
12310
+ display: "display"
12033
12311
  };
12034
12312
  function namespaceForCodec(codec) {
12035
12313
  return NAMESPACE_BY_CODEC[codec];
@@ -13092,6 +13370,174 @@ function inspectParams(rec, sn) {
13092
13370
  };
13093
13371
  }
13094
13372
 
13373
+ // dist/model/capabilities/solix.js
13374
+ var SOLIX_ENERGY_METER_MEMBERS = {
13375
+ /**
13376
+ * Line-1 voltage (V), ff09 tag `0xAC` — the ONE confirmed meter binding, matched against a live
13377
+ * single-phase frame (a nominal mains voltage). Read-only; the evidence gate installs its getter only
13378
+ * once a frame carrying `0xAC` has landed, so it is absent (not a fabricated `0`) until then.
13379
+ */
13380
+ meterVoltageL1: {
13381
+ param: 172,
13382
+ type: "number",
13383
+ kind: "scalar",
13384
+ unit: "V",
13385
+ provenance: "verified",
13386
+ description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
13387
+ }
13388
+ };
13389
+ var CATEGORY_CAPABILITIES = {
13390
+ "Portable Power Station": ["battery", "acOutput", "solarInput"],
13391
+ "Plug-in Home Battery": ["battery", "solarInput", "acOutput", "energyMeter"],
13392
+ "Powered Cooler": ["battery", "cooler"],
13393
+ "Power Bank": ["battery"],
13394
+ "Smart EV Charger": ["evCharger"],
13395
+ Charger: ["charger"],
13396
+ Accessory: []
13397
+ };
13398
+ var SOLIX_METER_MODELS = ["AE1X0"];
13399
+ var SOLARBANK_MODELS = ["A1790", "A17C"];
13400
+ function detectSolixCapabilities(rec, category) {
13401
+ const caps = /* @__PURE__ */ new Set(["identity"]);
13402
+ if (rec.device_sw_version)
13403
+ caps.add("firmware");
13404
+ if (rec.wifi_online !== void 0 || rec.rssi != null || rec.wifi_name)
13405
+ caps.add("connectivity");
13406
+ for (const c of category && CATEGORY_CAPABILITIES[category] || [])
13407
+ caps.add(c);
13408
+ if (SOLIX_METER_MODELS.some((m) => rec.product_code?.startsWith(m)))
13409
+ caps.add("energyMeter");
13410
+ if (SOLARBANK_MODELS.some((m) => rec.product_code?.startsWith(m))) {
13411
+ caps.add("battery");
13412
+ caps.add("solarInput");
13413
+ }
13414
+ return caps;
13415
+ }
13416
+
13417
+ // dist/model/solix-catalog.js
13418
+ function buildModelIndex(categories) {
13419
+ const index = /* @__PURE__ */ new Map();
13420
+ for (const category of categories) {
13421
+ for (const product of category.products ?? []) {
13422
+ const entry = { name: product.name, category: category.name };
13423
+ if (product.product_code)
13424
+ index.set(product.product_code, entry);
13425
+ for (const variant of product.p_codes ?? []) {
13426
+ const code = typeof variant === "string" ? variant : variant?.product_code;
13427
+ if (code)
13428
+ index.set(code, entry);
13429
+ }
13430
+ }
13431
+ }
13432
+ return index;
13433
+ }
13434
+
13435
+ // dist/model/solix-device.js
13436
+ var READ_ONLY_SINK = { dispatch: async () => {
13437
+ } };
13438
+ var SolixDevice = class {
13439
+ serial;
13440
+ productCode;
13441
+ record;
13442
+ caps;
13443
+ identity_;
13444
+ values = {};
13445
+ constructor(record, opts = {}) {
13446
+ this.record = record;
13447
+ this.serial = record.device_sn;
13448
+ this.productCode = record.product_code;
13449
+ const label = opts.catalog ? buildModelIndex(opts.catalog).get(record.product_code) : void 0;
13450
+ this.identity_ = {
13451
+ serial: record.device_sn,
13452
+ productCode: record.product_code,
13453
+ name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
13454
+ category: label?.category
13455
+ };
13456
+ this.caps = detectSolixCapabilities(record, this.identity_.category);
13457
+ }
13458
+ /** All capabilities this device carries. */
13459
+ get capabilities() {
13460
+ return [...this.caps];
13461
+ }
13462
+ /** Whether the device carries a capability — the only correct way to branch on behaviour. */
13463
+ has(capability) {
13464
+ return this.caps.has(capability);
13465
+ }
13466
+ /**
13467
+ * Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
13468
+ * WHOLE reading, not just its values, and drops one addressed to a different device: the documented
13469
+ * wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
13470
+ * watched meter on the account — so without this filter two meters would cross-feed each other's floats.
13471
+ * A reading with no `deviceSn` (a hand-built one) is accepted as-is.
13472
+ */
13473
+ applyReading(reading) {
13474
+ if (reading.deviceSn && reading.deviceSn !== this.serial)
13475
+ return;
13476
+ this.values = { ...this.values, ...reading.values };
13477
+ }
13478
+ /** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
13479
+ telemetry() {
13480
+ return { ...this.values };
13481
+ }
13482
+ identity() {
13483
+ return { ...this.identity_ };
13484
+ }
13485
+ firmware() {
13486
+ return this.record.device_sw_version ? { version: this.record.device_sw_version } : void 0;
13487
+ }
13488
+ connectivity() {
13489
+ if (!this.has("connectivity"))
13490
+ return void 0;
13491
+ const rssi = this.record.rssi != null ? Number(this.record.rssi) : void 0;
13492
+ return {
13493
+ online: !!this.record.wifi_online,
13494
+ rssi: Number.isFinite(rssi) ? rssi : void 0,
13495
+ ssid: this.record.wifi_name
13496
+ };
13497
+ }
13498
+ /**
13499
+ * The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
13500
+ * installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
13501
+ * held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
13502
+ * time of this call, so re-call it to pick up a tag first seen since.
13503
+ */
13504
+ energyMeter() {
13505
+ if (!this.has("energyMeter"))
13506
+ return void 0;
13507
+ return bindMembers(SOLIX_ENERGY_METER_MEMBERS, this.meterDeps());
13508
+ }
13509
+ /**
13510
+ * The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
13511
+ * evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
13512
+ * codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
13513
+ * Solix telemetry is read-only, no member here dispatches a command.
13514
+ */
13515
+ meterDeps() {
13516
+ const read = (name) => {
13517
+ const v = this.values[name];
13518
+ return v === void 0 ? void 0 : { name, paramType: 0, value: v, ts: Date.now() };
13519
+ };
13520
+ return { ctx: { channel: 0, paramIds: this.seenTags() }, sink: READ_ONLY_SINK, read };
13521
+ }
13522
+ /** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
13523
+ seenTags() {
13524
+ const tags = /* @__PURE__ */ new Set();
13525
+ for (const key of Object.keys(this.values)) {
13526
+ const m = /^channel_([0-9a-f]+)$/.exec(key);
13527
+ if (m)
13528
+ tags.add(parseInt(m[1], 16));
13529
+ }
13530
+ return tags;
13531
+ }
13532
+ };
13533
+ async function discoverSolixDevices(client, opts = {}) {
13534
+ const [records, catalog] = await Promise.all([
13535
+ client.getDevices(),
13536
+ opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => [])
13537
+ ]);
13538
+ return records.map((r) => new SolixDevice(r, { catalog }));
13539
+ }
13540
+
13095
13541
  // dist/client/map-channels.js
13096
13542
  var reader = (kind, decode) => (raw, codec) => {
13097
13543
  const value = decode(raw, codec);
@@ -14190,19 +14636,27 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14190
14636
  * cameras from that second group streamed normally at level-1 — including one of the same firmware as an
14191
14637
  * own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
14192
14638
  * separates nothing on this path, and a start failure on such a session is not evidence about it.
14639
+ *
14640
+ * Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
14641
+ * `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
14642
+ * cipher and the cause are known, and re-stating it here would double every settled negotiation.
14193
14643
  */
14194
14644
  async awaitLevel2Key(graceMs, graceFrom = "call") {
14195
- if (this.closed)
14645
+ if (this.closed) {
14646
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14196
14647
  return false;
14648
+ }
14197
14649
  if (this.level2Key)
14198
14650
  return true;
14199
- if (!this.level2Pending)
14651
+ if (!this.level2Pending) {
14652
+ this.trace({ phase: "level2-unavailable", reason: "not-negotiating" });
14200
14653
  return false;
14654
+ }
14201
14655
  const since = graceFrom === "call" ? Date.now() : this.connectedAtMs ?? Date.now();
14202
14656
  const remaining = graceMs - (Date.now() - since);
14203
14657
  if (remaining <= 0) {
14204
14658
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no level-2 key and its ${graceMs}ms grace has elapsed`);
14205
- this.trace({ phase: "level2-absent", waitedMs: graceMs });
14659
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: graceMs });
14206
14660
  return false;
14207
14661
  }
14208
14662
  this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
@@ -14228,10 +14682,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14228
14682
  });
14229
14683
  if (outcome === "timeout") {
14230
14684
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 key did not arrive within its grace`);
14685
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: remaining });
14231
14686
  } else if (outcome === "terminal") {
14232
14687
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 negotiation concluded without a key`);
14233
14688
  } else if (outcome === "closed") {
14234
14689
  this.logger.debug(`[p2p] ${this.cfg.stationSn} session closed before the level-2 key arrived`);
14690
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14235
14691
  }
14236
14692
  return outcome === "key";
14237
14693
  }
@@ -14282,6 +14738,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14282
14738
  this.level2Negotiating = true;
14283
14739
  const generation = this.connectionGeneration;
14284
14740
  const cipherId = gatewayInfoCipherId(gwPayload);
14741
+ this.trace({ phase: "level2-negotiating", cipherId });
14285
14742
  void (async () => {
14286
14743
  try {
14287
14744
  const eccPrivHex = await this.cfg.resolveCipherKey?.(cipherId);
@@ -14289,11 +14746,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14289
14746
  return;
14290
14747
  if (!eccPrivHex) {
14291
14748
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no ECC key for cipher_id ${cipherId}`);
14749
+ this.trace({ phase: "level2-unavailable", reason: "no-cipher-key", cipherId });
14292
14750
  this.settleLevel2();
14293
14751
  return;
14294
14752
  }
14295
14753
  const key = deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivHex);
14296
14754
  if (!key) {
14755
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14297
14756
  this.settleLevel2();
14298
14757
  this.emit("error", new Error(`level-2 key derivation failed (cipher_id ${cipherId})`));
14299
14758
  return;
@@ -14305,6 +14764,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14305
14764
  } catch (e) {
14306
14765
  if (this.closed || generation !== this.connectionGeneration)
14307
14766
  return;
14767
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14308
14768
  this.settleLevel2();
14309
14769
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
14310
14770
  }
@@ -15655,19 +16115,23 @@ function parseFf09SettingsResponse(plain) {
15655
16115
  if (plain.length < 1)
15656
16116
  throw new Error("ff09: settings response too short (missing status byte)");
15657
16117
  const status = plain[0];
16118
+ const fields = walkFf09Tlv(plain, 1, plain.length);
16119
+ return { status, fields };
16120
+ }
16121
+ function walkFf09Tlv(buf, start, end) {
15658
16122
  const fields = /* @__PURE__ */ new Map();
15659
- let i = 1;
15660
- while (i + 2 <= plain.length) {
15661
- const sep = plain[i];
15662
- if (sep === 0)
16123
+ let i = start;
16124
+ while (i + 2 <= end) {
16125
+ const tag2 = buf[i];
16126
+ if (tag2 === 0)
15663
16127
  break;
15664
- const len = plain[i + 1];
15665
- if (i + 2 + len > plain.length)
16128
+ const len = buf[i + 1];
16129
+ if (i + 2 + len > end)
15666
16130
  break;
15667
- fields.set(sep, plain.subarray(i + 2, i + 2 + len));
16131
+ fields.set(tag2, buf.subarray(i + 2, i + 2 + len));
15668
16132
  i += 2 + len;
15669
16133
  }
15670
- return { status, fields };
16134
+ return fields;
15671
16135
  }
15672
16136
  function readFf09U16LE(field, name) {
15673
16137
  if (!field || field.length < 2)
@@ -16370,10 +16834,12 @@ var LiveStream = class extends EventEmitter3 {
16370
16834
  if (!this.listening || this.kaTimer)
16371
16835
  return;
16372
16836
  if (this.opts.reassertWanted?.() === false) {
16837
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "declined" });
16373
16838
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms, and its owner does not want this channel re-asserted \u2014 staying quiet`);
16374
16839
  this.armStallWatch();
16375
16840
  return;
16376
16841
  }
16842
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "reasserted" });
16377
16843
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms \u2014 re-asserting this camera's channel`);
16378
16844
  this.sendStart();
16379
16845
  this.kaTimer = setInterval(() => this.sendStart(), keepAliveMs);
@@ -16594,10 +17060,12 @@ async function captureSnapshotFromShared(source, opts = {}) {
16594
17060
  const timeoutMs = opts.timeoutMs ?? 2e4;
16595
17061
  const collectMs = opts.collectMs ?? 1500;
16596
17062
  const skip = opts.skipKeyframes ?? 1;
17063
+ opts.signal?.throwIfAborted();
16597
17064
  const consumer = source.attach();
16598
17065
  const primed = consumer.primed;
17066
+ let burst;
16599
17067
  try {
16600
- const burst = await new Promise((resolve, reject) => {
17068
+ burst = await new Promise((resolve, reject) => {
16601
17069
  const bufs = [];
16602
17070
  let sets = source.parameterSets;
16603
17071
  let codec = "h264";
@@ -16632,6 +17100,10 @@ async function captureSnapshotFromShared(source, opts = {}) {
16632
17100
  cleanup();
16633
17101
  reject(new LiveSnapshotUnavailableError("source-failed", `source ended before a keyframe (state: ${source.state})`));
16634
17102
  };
17103
+ const onAbandoned = () => {
17104
+ cleanup();
17105
+ reject(opts.signal?.reason);
17106
+ };
16635
17107
  const cleanup = () => {
16636
17108
  clearTimeout(timer);
16637
17109
  if (settle)
@@ -16639,19 +17111,21 @@ async function captureSnapshotFromShared(source, opts = {}) {
16639
17111
  consumer.off("video", onVideo);
16640
17112
  consumer.off("error", onError);
16641
17113
  consumer.off("stop", onStop);
17114
+ opts.signal?.removeEventListener("abort", onAbandoned);
16642
17115
  };
16643
17116
  consumer.on("video", onVideo);
16644
17117
  consumer.on("error", onError);
16645
17118
  consumer.on("stop", onStop);
16646
- });
16647
- return await annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
16648
- logger: opts.logger ?? noopLogger,
16649
- level: opts.ffmpegLevel,
16650
- executable: opts.ffmpegPath
17119
+ opts.signal?.addEventListener("abort", onAbandoned, { once: true });
16651
17120
  });
16652
17121
  } finally {
16653
17122
  consumer.detach();
16654
17123
  }
17124
+ return annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
17125
+ logger: opts.logger ?? noopLogger,
17126
+ level: opts.ffmpegLevel,
17127
+ executable: opts.ffmpegPath
17128
+ });
16655
17129
  }
16656
17130
  async function recordClip(session, seconds, opts = {}) {
16657
17131
  const timeoutMs = opts.timeoutMs ?? 2e4;
@@ -18858,6 +19332,11 @@ function abortable(work, signal) {
18858
19332
  }
18859
19333
  var LEVEL2_GRACE_MS = 25e3;
18860
19334
  var LEVEL2_SETTLE_MS = 8e3;
19335
+ var P2P_STATION_WAITS = {
19336
+ connect: CONNECT_WAIT_MS,
19337
+ level2Grace: LEVEL2_GRACE_MS,
19338
+ level2Settle: LEVEL2_SETTLE_MS
19339
+ };
18861
19340
  var RTSP_URL_READ_TIMEOUT_MS = 12e3;
18862
19341
  var SHARED_LIVE_OPT_KEYS = [
18863
19342
  "eccPrivateKey",
@@ -18909,6 +19388,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
18909
19388
  }
18910
19389
  return normalized;
18911
19390
  }
19391
+ /**
19392
+ * Emit a live trace under a station session's handle, for work this router does ON that session before
19393
+ * the session itself records anything — reaching the station, and resolving what a device is on it. Same
19394
+ * handle as everything the session goes on to trace, which is what groups one attempt.
19395
+ */
19396
+ traceOnStation(session, trace) {
19397
+ traceLiveStart(this.deps.logger ?? noopLogger, trace, session.traceId);
19398
+ }
18912
19399
  /**
18913
19400
  * Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
18914
19401
  * usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
@@ -19049,7 +19536,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
19049
19536
  let ecc;
19050
19537
  try {
19051
19538
  const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
19052
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key ?? ciphers[0]?.ecc_private_key;
19539
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
19540
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
19541
+ ecc = ciphers[0].ecc_private_key;
19542
+ this.traceOnStation(session, {
19543
+ phase: "cipher-fallback",
19544
+ cipherId,
19545
+ answeredCipherId: Number(ciphers[0].cipher_id)
19546
+ });
19547
+ }
19053
19548
  } catch (e) {
19054
19549
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19055
19550
  }
@@ -19203,12 +19698,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
19203
19698
  return {
19204
19699
  snapshotLive: async (opts) => {
19205
19700
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
19206
- return abortable(captureSnapshotFromShared(source, {
19701
+ return captureSnapshotFromShared(source, {
19207
19702
  ...opts,
19208
19703
  logger: this.deps.logger ?? noopLogger,
19209
19704
  ffmpegLevel: this.deps.ffmpegLogLevel,
19210
19705
  ffmpegPath: this.deps.ffmpegPath
19211
- }), opts?.signal);
19706
+ });
19212
19707
  },
19213
19708
  live: async (opts) => {
19214
19709
  const source = await this.sharedLiveSourceFor(sn, opts);
@@ -19680,9 +20175,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19680
20175
  s.sendStringPayloadCommand(P2P_ENVELOPE.CONTROL_PAYLOAD, json, ch);
19681
20176
  return Promise.resolve();
19682
20177
  },
19683
- l2: async ({ session: s, channel: ch }) => {
20178
+ l2: async ({ session: s, channel: ch, parentSn }) => {
19684
20179
  if (!await s.awaitLevel2Key(LEVEL2_GRACE_MS, "call")) {
19685
- throw new Error(`level-2 key not ready for ${sn} \u2014 cannot query`);
20180
+ throw new StationKeyUnavailableError(parentSn);
19686
20181
  }
19687
20182
  s.sendRawLevel2(json, ch, P2P_ENVELOPE.CONTROL_PAYLOAD);
19688
20183
  }
@@ -19849,15 +20344,28 @@ var P2PCommandRouter = class _P2PCommandRouter {
19849
20344
  }
19850
20345
  this.manager.bumpCommand(parentSn);
19851
20346
  const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
19852
- const accountId = raw.member?.admin_user_id ?? this.deps.mega.auth?.userId ?? "";
20347
+ const stationAdminId = raw.member?.admin_user_id;
20348
+ const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
19853
20349
  const t0 = Date.now();
19854
- while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
19855
- opts.signal?.throwIfAborted();
19856
- await sleep2(200);
20350
+ let waitedMs = 0;
20351
+ if (!session.isConnected) {
20352
+ this.traceOnStation(session, { phase: "session-connect-wait", waitMs: CONNECT_WAIT_MS });
20353
+ while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
20354
+ opts.signal?.throwIfAborted();
20355
+ await sleep2(200);
20356
+ }
20357
+ waitedMs = Date.now() - t0;
20358
+ this.traceOnStation(session, session.isConnected ? { phase: "session-connected", waitedMs } : { phase: "session-unreachable", waitedMs });
19857
20359
  }
19858
20360
  opts.signal?.throwIfAborted();
19859
20361
  if (!session.isConnected)
19860
- throw new Error(`P2P session for ${parentSn} did not connect`);
20362
+ throw new StationUnreachableError(parentSn, waitedMs);
20363
+ this.traceOnStation(session, {
20364
+ phase: "station-resolved",
20365
+ topology: homeBaseAttached ? "attached" : "own",
20366
+ channel,
20367
+ stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other"
20368
+ });
19861
20369
  if (opts.waitLevel2) {
19862
20370
  if (opts.waitLevel2 === "settle") {
19863
20371
  await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
@@ -19871,7 +20379,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19871
20379
  ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), opts.signal);
19872
20380
  }
19873
20381
  if (!ready)
19874
- throw new Error(`level-2 key not ready for ${parentSn}`);
20382
+ throw new StationKeyUnavailableError(parentSn);
19875
20383
  }
19876
20384
  return { session, parentSn, channel, accountId, homeBaseAttached };
19877
20385
  }
@@ -21358,8 +21866,8 @@ function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
21358
21866
  },
21359
21867
  format: "jwk"
21360
21868
  });
21361
- const md5Hex = createHash6("md5").update(password).digest("hex");
21362
- return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex)).toString("hex");
21869
+ const md5Hex2 = createHash6("md5").update(password).digest("hex");
21870
+ return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex2)).toString("hex");
21363
21871
  }
21364
21872
  var TuyaClient = class {
21365
21873
  signer;
@@ -23986,8 +24494,8 @@ var EufyMega = class extends EventEmitter9 {
23986
24494
  * @example
23987
24495
  * ```ts
23988
24496
  * const res = await eufy.login();
23989
- * if (res.status === "captcha") await eufy.solveCaptcha(await ask(res.image));
23990
- * else if (res.status === "2fa") await eufy.submitVerifyCode(await ask());
24497
+ * if (res.status === "captcha") await eufy.solveCaptcha(await promptUser(res.image));
24498
+ * else if (res.status === "2fa") await eufy.submitVerifyCode(await promptUser());
23991
24499
  * ```
23992
24500
  */
23993
24501
  async login(opts = {}) {
@@ -24312,7 +24820,7 @@ var EufyMega = class extends EventEmitter9 {
24312
24820
  * @example
24313
24821
  * ```ts
24314
24822
  * const dev = await eufy.getDevice(sn);
24315
- * if (dev.has("camera")) await dev.camera()?.snapshotStored();
24823
+ * if (dev.has("camera")) await dev.camera?.()?.snapshotStored?.();
24316
24824
  * console.log(dev.getProperty("battery"));
24317
24825
  * ```
24318
24826
  */
@@ -25272,6 +25780,553 @@ var EufyMega = class extends EventEmitter9 {
25272
25780
  }
25273
25781
  };
25274
25782
 
25783
+ // dist/transport/http/solix-constants.js
25784
+ var SOLIX_APP_NAME = "anker_power";
25785
+ var SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
25786
+ var SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
25787
+ var SOLIX_ENDPOINTS = {
25788
+ estimateDomain: "/passport/estimate_domain",
25789
+ keyExchange: "/openapi/oauth/key/exchange",
25790
+ login: "/passport/login",
25791
+ /** Bound devices for the account (flat list). */
25792
+ getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices",
25793
+ /** Sites (systems) the account owns; devices are grouped under a site. */
25794
+ getSiteList: "/power_service/v1/site/get_site_list",
25795
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
25796
+ getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
25797
+ /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
25798
+ productCategories: "/power_service/v1/product_categories"
25799
+ };
25800
+
25801
+ // dist/transport/http/solix-client.js
25802
+ function solixSessionFresh(s) {
25803
+ return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
25804
+ }
25805
+ var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
25806
+ var SolixClient = class {
25807
+ email;
25808
+ password;
25809
+ country;
25810
+ appVersion;
25811
+ doFetch;
25812
+ store;
25813
+ openudid;
25814
+ apiHost;
25815
+ session_;
25816
+ /** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
25817
+ pending2fa;
25818
+ /**
25819
+ * Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
25820
+ * not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
25821
+ * handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
25822
+ * override that also skips domain-estimate, and every read goes through `this.apiHost`.
25823
+ */
25824
+ constructor(opts) {
25825
+ this.email = opts.email;
25826
+ this.password = opts.password;
25827
+ this.country = (opts.countryCode ?? "US").toUpperCase();
25828
+ this.appVersion = opts.appVersion ?? "3.23.0";
25829
+ this.doFetch = opts.fetchImpl ?? fetch;
25830
+ this.apiHost = opts.apiHost ?? SOLIX_DEFAULT_API_HOST;
25831
+ this.store = opts.store;
25832
+ const saved = this.store?.load();
25833
+ this.openudid = opts.openudid ?? saved?.openudid ?? uuidFromHex(md5Hex(`anker-solix:${opts.email}`));
25834
+ if (saved?.session && solixSessionFresh(saved.session)) {
25835
+ this.session_ = saved.session;
25836
+ this.apiHost = opts.apiHost ?? saved.session.apiHost;
25837
+ }
25838
+ }
25839
+ /** Persist the current device id (+ session, if any) when a store is configured. */
25840
+ persist() {
25841
+ this.store?.save({ openudid: this.openudid, session: this.session_ });
25842
+ }
25843
+ /** The authenticated session, once {@link login} has resolved to `ok`. */
25844
+ get session() {
25845
+ return this.session_;
25846
+ }
25847
+ /**
25848
+ * Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
25849
+ * must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
25850
+ * (`401 token error`) — so those use {@link baseHeaders} directly.
25851
+ */
25852
+ authHeaders(extra = {}) {
25853
+ return this.baseHeaders({ openudid: this.openudid, "x-terminal-id": this.openudid, ...extra });
25854
+ }
25855
+ /** Base headers common to every Solix request. */
25856
+ baseHeaders(extra = {}) {
25857
+ return {
25858
+ "content-type": "application/json",
25859
+ "app-name": SOLIX_APP_NAME,
25860
+ "model-type": "PHONE",
25861
+ "os-type": "android",
25862
+ "os-version": "36",
25863
+ "app-version": this.appVersion,
25864
+ country: this.country,
25865
+ timezone: "GMT+00:00",
25866
+ language: "en",
25867
+ "user-agent": "ktor-client",
25868
+ accept: "application/json",
25869
+ ...extra
25870
+ };
25871
+ }
25872
+ /** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
25873
+ async send(method2, host, path, headers, body) {
25874
+ const res = await this.doFetch(`https://${host}${path}`, {
25875
+ method: method2,
25876
+ headers,
25877
+ body,
25878
+ signal: AbortSignal.timeout(2e4)
25879
+ });
25880
+ const text2 = await res.text();
25881
+ try {
25882
+ return JSON.parse(text2);
25883
+ } catch {
25884
+ throw new Error(`Solix ${path} \u2192 HTTP ${res.status}, non-JSON: ${text2.slice(0, 120)}`);
25885
+ }
25886
+ }
25887
+ /** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
25888
+ post(host, path, body, headers) {
25889
+ return this.send("POST", host, path, headers, body);
25890
+ }
25891
+ /** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
25892
+ async estimateHost() {
25893
+ try {
25894
+ const env = await this.post(SOLIX_ESTIMATE_HOST, SOLIX_ENDPOINTS.estimateDomain, JSON.stringify({ ab: this.country, mode: 1 }), this.baseHeaders());
25895
+ const domain = env.data?.domain;
25896
+ if (domain)
25897
+ this.apiHost = domain;
25898
+ } catch {
25899
+ }
25900
+ }
25901
+ /** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
25902
+ async keyExchange() {
25903
+ const prep = prepareKeyExchange(SOLIX_LOCAL_KEY_HEX);
25904
+ const env = await this.post(this.apiHost, SOLIX_ENDPOINTS.keyExchange, JSON.stringify({ client_public_key: prep.encryptedClientPublicKey }), this.authHeaders(prep.headers));
25905
+ const spk = env.data?.server_public_key;
25906
+ if (env.code !== 0 || !spk)
25907
+ throw new Error(`Solix key/exchange failed (${env.code}): ${env.msg}`);
25908
+ return finishKeyExchange(prep, spk);
25909
+ }
25910
+ /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
25911
+ async postLogin(kx, verifyCode, limitedToken) {
25912
+ const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
25913
+ const bodyObj = {
25914
+ email: this.email,
25915
+ password: encryptedPassword,
25916
+ ab: this.country,
25917
+ client_secret_info: { public_key: clientPublicKeyHex },
25918
+ answer: "",
25919
+ captcha_id: "",
25920
+ verify_code: verifyCode ?? "",
25921
+ login_id: ""
25922
+ };
25923
+ const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
25924
+ const ts = nowSec();
25925
+ const once = genId();
25926
+ return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
25927
+ "x-encryption-info": "algo_ecdh",
25928
+ "x-key-ident": kx.keyIdent,
25929
+ "x-request-ts": ts,
25930
+ "x-request-once": once,
25931
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
25932
+ ...limitedToken ? { "x-auth-token": limitedToken } : {}
25933
+ }));
25934
+ }
25935
+ /**
25936
+ * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
25937
+ * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
25938
+ * has been satisfied.
25939
+ */
25940
+ classifyLogin(data, isVerify) {
25941
+ const userId = data.ap_cloud_user_id ?? data.user_id;
25942
+ const authToken = data.auth_token;
25943
+ if (!userId || !authToken)
25944
+ throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
25945
+ const faInfo = data.fa_info ?? {};
25946
+ if (!isVerify && faInfo.info) {
25947
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
25948
+ return { status: "2fa", method: "code sent by the passport" };
25949
+ }
25950
+ this.pending2fa = void 0;
25951
+ this.session_ = {
25952
+ authToken,
25953
+ userId,
25954
+ gtoken: gtoken(userId),
25955
+ apiHost: this.apiHost,
25956
+ tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
25957
+ };
25958
+ this.persist();
25959
+ return { status: "ok", session: this.session_ };
25960
+ }
25961
+ /** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
25962
+ decryptLogin(env, kx) {
25963
+ if (typeof env.data !== "string")
25964
+ throw new Error(`Solix login (${env.code}): ${env.msg}`);
25965
+ return JSON.parse(decryptBody(env.data, kx.shareKey).toString("utf-8"));
25966
+ }
25967
+ /**
25968
+ * Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
25969
+ * when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
25970
+ * (adopted from a store) is answered without a handshake.
25971
+ */
25972
+ async login() {
25973
+ if (solixSessionFresh(this.session_)) {
25974
+ return { status: "ok", session: this.session_ };
25975
+ }
25976
+ await this.estimateHost();
25977
+ const kx = await this.keyExchange();
25978
+ const env = await this.postLogin(kx);
25979
+ return this.classifyLogin(this.decryptLogin(env, kx), false);
25980
+ }
25981
+ /** Complete a `2fa` login with the code the passport sent. */
25982
+ async submitVerifyCode(code) {
25983
+ if (!this.pending2fa)
25984
+ throw new Error("no 2FA login is pending");
25985
+ const kx = await this.keyExchange();
25986
+ const env = await this.postLogin(kx, code, this.pending2fa.limitedToken);
25987
+ return this.classifyLogin(this.decryptLogin(env, kx), true);
25988
+ }
25989
+ /**
25990
+ * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
25991
+ * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
25992
+ */
25993
+ async authed(method2, path, body) {
25994
+ if (!this.session_)
25995
+ throw new Error("not authenticated \u2014 call login() first");
25996
+ const env = await this.send(method2, this.apiHost, path, this.baseHeaders({ gtoken: this.session_.gtoken, "x-auth-token": this.session_.authToken }), body ? JSON.stringify(body) : void 0);
25997
+ if (env.code !== 0)
25998
+ throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
25999
+ return env.data ?? null;
26000
+ }
26001
+ /**
26002
+ * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
26003
+ * gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
26004
+ * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
26005
+ */
26006
+ async getDevices() {
26007
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
26008
+ return Array.isArray(data) ? data : data?.data ?? [];
26009
+ }
26010
+ /** The account's sites (systems); devices are typically grouped under a site. */
26011
+ async getSites() {
26012
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
26013
+ return data?.site_list ?? [];
26014
+ }
26015
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26016
+ async getUserMqttInfo() {
26017
+ return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
26018
+ }
26019
+ /**
26020
+ * The pairable-product catalog (categories → products). This is Anker's product registry, not the
26021
+ * account's devices — fetch it to label a discovered device's model code with a marketing name and
26022
+ * category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
26023
+ * baked-in table.
26024
+ */
26025
+ async getProductCatalog() {
26026
+ return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
26027
+ }
26028
+ };
26029
+
26030
+ // dist/transport/mqtt/solix-mqtt.js
26031
+ import { EventEmitter as EventEmitter10 } from "node:events";
26032
+ var SOLIX_METER_FIELD_NAMES = {
26033
+ 172: "meterVoltageL1"
26034
+ };
26035
+ function readSolixChannel(value) {
26036
+ if (!value || value.length < 1)
26037
+ return void 0;
26038
+ const raw = value.subarray(1);
26039
+ const ch = { type: value[0], raw };
26040
+ if (raw.length === 4) {
26041
+ ch.float = raw.readFloatLE(0);
26042
+ ch.uint = raw.readUInt32LE(0);
26043
+ }
26044
+ return ch;
26045
+ }
26046
+ function decodeSolixParamFrame(buf) {
26047
+ if (buf.length < 10 || buf[0] !== 255 || buf[1] !== 9)
26048
+ return null;
26049
+ const declaredLen = buf.readUInt16LE(2);
26050
+ if (declaredLen < 5 || declaredLen > buf.length)
26051
+ return null;
26052
+ let xor = 0;
26053
+ for (let i = 0; i < declaredLen; i++)
26054
+ xor ^= buf[i];
26055
+ if (xor !== 0)
26056
+ return null;
26057
+ const end = declaredLen - 1;
26058
+ const start = buf.indexOf(161, 4);
26059
+ if (start < 0 || start >= end)
26060
+ return { fields: /* @__PURE__ */ new Map() };
26061
+ const fields = walkFf09Tlv(buf, start, end);
26062
+ let deviceSn;
26063
+ const a2 = fields.get(162);
26064
+ if (a2 && a2.length > 1)
26065
+ deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
26066
+ return { deviceSn, fields };
26067
+ }
26068
+ function solixReadings(frame) {
26069
+ const out = {};
26070
+ for (const [tag2, value] of frame.fields) {
26071
+ if (tag2 < 166)
26072
+ continue;
26073
+ const ch = readSolixChannel(value);
26074
+ if (ch?.type !== 5 || ch.float === void 0)
26075
+ continue;
26076
+ out[`channel_${tag2.toString(16)}`] = ch.float;
26077
+ const name = SOLIX_METER_FIELD_NAMES[tag2];
26078
+ if (name)
26079
+ out[name] = ch.float;
26080
+ }
26081
+ return out;
26082
+ }
26083
+ var SolixMqtt = class extends EventEmitter10 {
26084
+ transport;
26085
+ appName;
26086
+ userId;
26087
+ appClientId;
26088
+ armIntervalMs;
26089
+ logger;
26090
+ siteId;
26091
+ watched = /* @__PURE__ */ new Map();
26092
+ seq = 0;
26093
+ armTimer;
26094
+ /**
26095
+ * Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
26096
+ * (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
26097
+ * restart presents itself to the broker as a new client, so it defaults deterministically from the user
26098
+ * id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
26099
+ */
26100
+ constructor(opts) {
26101
+ super();
26102
+ this.appName = opts.mqttInfo.app_name ?? "anker_power";
26103
+ this.userId = opts.userId ?? opts.mqttInfo.user_id;
26104
+ this.armIntervalMs = opts.armIntervalMs ?? 25e3;
26105
+ this.siteId = opts.siteId;
26106
+ this.logger = opts.logger;
26107
+ const uid = this.userId ?? "anonymous";
26108
+ this.appClientId = opts.appClientId ?? buildAppShapedClientId({
26109
+ appName: this.appName,
26110
+ uid,
26111
+ mqttUuid: opts.mqttUuid ?? mqttUuidFrom(`anker-solix-mqtt:${uid}`)
26112
+ });
26113
+ this.transport = new SecureMqtt({
26114
+ credentials: opts.mqttInfo,
26115
+ clientId: opts.clientId ?? opts.mqttInfo.thing_name,
26116
+ reconnectPeriod: 5e3,
26117
+ logger: opts.logger
26118
+ });
26119
+ this.transport.on("error", (e) => this.emit("error", e));
26120
+ this.transport.on("message", (msg) => this.onMessage(msg));
26121
+ }
26122
+ /**
26123
+ * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
26124
+ * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
26125
+ *
26126
+ * Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
26127
+ * channels — never the `…/req` channels, which are the app→device request side this arms on, and would
26128
+ * echo its own publishes back.
26129
+ *
26130
+ * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
26131
+ * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
26132
+ * success: the call would resolve and arm on every interval while no reading ever arrives.
26133
+ *
26134
+ * The re-arm timer is unreffed, so a caller that watches and returns can still exit.
26135
+ */
26136
+ async watch(device) {
26137
+ await this.transport.connect();
26138
+ const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
26139
+ const granted = await this.transport.subscribe([
26140
+ topics.paramInfo,
26141
+ topics.cmdRes,
26142
+ ...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
26143
+ ]);
26144
+ if (!granted.includes(topics.paramInfo)) {
26145
+ const scope = this.appName;
26146
+ throw new Error(`watch ${device.device_sn}: telemetry topic "${topics.paramInfo}" denied on credential scope "${scope}" \u2014 the subscription would arm but never deliver a reading`);
26147
+ }
26148
+ this.watched.set(device.device_sn, device);
26149
+ if (this.armIntervalMs > 0) {
26150
+ await this.armAll();
26151
+ if (!this.armTimer) {
26152
+ this.armTimer = setInterval(() => void this.armAll(), this.armIntervalMs);
26153
+ this.armTimer.unref?.();
26154
+ }
26155
+ }
26156
+ }
26157
+ /** Tear down the connection and stop the re-arm timer. */
26158
+ async close() {
26159
+ if (this.armTimer) {
26160
+ clearInterval(this.armTimer);
26161
+ this.armTimer = void 0;
26162
+ }
26163
+ this.watched.clear();
26164
+ await this.transport.disconnect();
26165
+ }
26166
+ /**
26167
+ * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
26168
+ * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
26169
+ * heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
26170
+ * (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
26171
+ * not thrown, so one bad device doesn't stop the rest or kill the timer.
26172
+ */
26173
+ async armAll() {
26174
+ for (const device of this.watched.values()) {
26175
+ try {
26176
+ await this.arm(device);
26177
+ } catch (e) {
26178
+ this.emit("error", e);
26179
+ }
26180
+ }
26181
+ if (this.userId) {
26182
+ try {
26183
+ await this.transport.publish(solixUserTopics(this.appName, this.userId).powerSite, this.heartbeatEnvelope(), {
26184
+ qos: 1
26185
+ });
26186
+ } catch (e) {
26187
+ this.emit("error", e);
26188
+ }
26189
+ }
26190
+ }
26191
+ /** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
26192
+ async arm(device) {
26193
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
26194
+ for (const variant of ["info", "realtime"]) {
26195
+ const body = this.commandEnvelope(device, buildFf09Request(variant), variant === "info" ? { encoding_type: 2 } : {});
26196
+ await this.transport.publish(topic, body, { qos: 1 });
26197
+ }
26198
+ this.logger?.debug?.(`[solix] armed ${device.device_sn} (param_info reporting requested)`);
26199
+ }
26200
+ /** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
26201
+ makeHead(cmd, extra) {
26202
+ return {
26203
+ version: "1.0.0.1",
26204
+ client_id: this.appClientId,
26205
+ timestamp: Math.floor(Date.now() / 1e3),
26206
+ cmd_status: 2,
26207
+ sign_code: 1,
26208
+ cmd,
26209
+ ...extra
26210
+ };
26211
+ }
26212
+ /**
26213
+ * Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
26214
+ * `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
26215
+ * from a real value, so sending `""` would claim an account this client does not have.
26216
+ */
26217
+ commandEnvelope(device, frame, extra) {
26218
+ this.seq += 1;
26219
+ return JSON.stringify({
26220
+ head: this.makeHead(17, {
26221
+ sess_id: genId(),
26222
+ msg_seq: this.seq,
26223
+ seed: genId(),
26224
+ device_pn: device.product_code,
26225
+ device_sn: device.device_sn
26226
+ }),
26227
+ payload: JSON.stringify({
26228
+ device_sn: device.device_sn,
26229
+ ...this.userId ? { account_id: this.userId } : {},
26230
+ data: frame.toString("base64"),
26231
+ ...extra
26232
+ })
26233
+ });
26234
+ }
26235
+ /**
26236
+ * The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
26237
+ * `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
26238
+ */
26239
+ heartbeatEnvelope() {
26240
+ return JSON.stringify({
26241
+ head: this.makeHead(10, { sess_id: "1", msg_seq: 1, seed: "1" }),
26242
+ payload: JSON.stringify({ user_id: this.userId ?? "", ...this.siteId ? { site_id: this.siteId } : {} })
26243
+ });
26244
+ }
26245
+ /**
26246
+ * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
26247
+ * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
26248
+ * own `a2` field wins for the serial when it carries one.
26249
+ */
26250
+ onMessage(msg) {
26251
+ const topic = msg.topic ?? "";
26252
+ const buf = extractFf09Payload(msg.raw);
26253
+ if (!buf)
26254
+ return;
26255
+ const frame = decodeSolixParamFrame(buf);
26256
+ if (!frame)
26257
+ return;
26258
+ const parts = topic.split("/");
26259
+ const reading = {
26260
+ deviceSn: frame.deviceSn ?? parts[3] ?? "",
26261
+ productCode: parts[2] ?? "",
26262
+ topic,
26263
+ frame,
26264
+ values: solixReadings(frame)
26265
+ };
26266
+ this.emit("reading", reading);
26267
+ }
26268
+ };
26269
+ function extractFf09Payload(raw) {
26270
+ if (Buffer.isBuffer(raw))
26271
+ return raw;
26272
+ if (!raw || typeof raw !== "object")
26273
+ return null;
26274
+ const env = raw;
26275
+ let payload = env.payload;
26276
+ if (typeof payload === "string") {
26277
+ try {
26278
+ payload = JSON.parse(payload);
26279
+ } catch {
26280
+ return null;
26281
+ }
26282
+ }
26283
+ const p = payload;
26284
+ const data = p?.data ?? p?.trans ?? env.data;
26285
+ if (typeof data !== "string")
26286
+ return null;
26287
+ const buf = Buffer.from(data, "base64");
26288
+ return buf.length ? buf : null;
26289
+ }
26290
+ function buildFf09Request(variant, atUnixSec) {
26291
+ const ts = Buffer.alloc(4);
26292
+ ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
26293
+ const body = variant === "info" ? Buffer.concat([Buffer.from([3, 0, 15, 0, 64, 161, 1, 34, 254, 4]), ts]) : Buffer.concat([
26294
+ Buffer.from([
26295
+ 3,
26296
+ 0,
26297
+ 15,
26298
+ 0,
26299
+ 87,
26300
+ 161,
26301
+ 1,
26302
+ 34,
26303
+ 162,
26304
+ 2,
26305
+ 1,
26306
+ 1,
26307
+ 163,
26308
+ 3,
26309
+ 2,
26310
+ 44,
26311
+ 1,
26312
+ 254,
26313
+ 5,
26314
+ 3
26315
+ ]),
26316
+ ts
26317
+ ]);
26318
+ const frame = Buffer.alloc(body.length + 5);
26319
+ frame[0] = 255;
26320
+ frame[1] = 9;
26321
+ frame.writeUInt16LE(frame.length, 2);
26322
+ body.copy(frame, 4);
26323
+ let xor = 0;
26324
+ for (let i = 0; i < frame.length - 1; i++)
26325
+ xor ^= frame[i];
26326
+ frame[frame.length - 1] = xor;
26327
+ return frame;
26328
+ }
26329
+
25275
26330
  // dist/transport/tuya/index.js
25276
26331
  var tuya_exports = {};
25277
26332
  __export(tuya_exports, {
@@ -25312,6 +26367,7 @@ export {
25312
26367
  AUDIO_MEMBERS,
25313
26368
  AccessUnitAssembler,
25314
26369
  AiDetectType,
26370
+ AlarmDelayMode,
25315
26371
  ArmingMode,
25316
26372
  BATTERY_MEMBERS,
25317
26373
  BIZ_CHANNEL,
@@ -25332,6 +26388,7 @@ export {
25332
26388
  CusPushEvent,
25333
26389
  CusPushMode,
25334
26390
  DEFAULT_KEEPALIVE_MS,
26391
+ DISPLAY_MEMBERS,
25335
26392
  DOCK_ACTIVITIES,
25336
26393
  DOCK_KINDS,
25337
26394
  DOORBELL_MEMBERS,
@@ -25385,6 +26442,7 @@ export {
25385
26442
  P256,
25386
26443
  P2PSession,
25387
26444
  P2P_ENVELOPE,
26445
+ P2P_STATION_WAITS,
25388
26446
  PRINTER_CATEGORY_RE,
25389
26447
  PTZ_MEMBERS,
25390
26448
  PowerSource,
@@ -25404,6 +26462,8 @@ export {
25404
26462
  SIREN_MEMBERS,
25405
26463
  SMART_LIGHT_MEMBERS,
25406
26464
  SMOKE_MEMBERS,
26465
+ SOLIX_ENERGY_METER_MEMBERS,
26466
+ SOLIX_LOCAL_KEY_HEX,
25407
26467
  STATE_EVENT_FIELDS,
25408
26468
  STATION_CHANNEL3 as STATION_CHANNEL,
25409
26469
  STATION_CHUNK_BYTES,
@@ -25416,8 +26476,13 @@ export {
25416
26476
  SirenAlarmDuration,
25417
26477
  SirenVolume,
25418
26478
  SmartDropPushEvent,
26479
+ SolixClient,
26480
+ SolixDevice,
26481
+ SolixMqtt,
25419
26482
  StateConvergenceError,
25420
26483
  StationBusyError,
26484
+ StationKeyUnavailableError,
26485
+ StationUnreachableError,
25421
26486
  StoredSnapshotUnavailableError,
25422
26487
  StreamingQuality,
25423
26488
  SuctionLevel,
@@ -25448,6 +26513,7 @@ export {
25448
26513
  buildDeviceNameBody,
25449
26514
  buildDirectBinaryBody,
25450
26515
  buildEventIndex,
26516
+ buildModelIndex,
25451
26517
  buildRealtimeInit,
25452
26518
  captureSnapshotFromShared,
25453
26519
  cellAtPoint,
@@ -25485,6 +26551,7 @@ export {
25485
26551
  detectCapabilities,
25486
26552
  detectionName,
25487
26553
  discoverReachableInstance,
26554
+ discoverSolixDevices,
25488
26555
  encodeAiDetectType,
25489
26556
  encodeVarint,
25490
26557
  encryptBody,
@@ -25522,6 +26589,7 @@ export {
25522
26589
  lz4BlockDecompress,
25523
26590
  mapCellValue,
25524
26591
  mapCellValueAt,
26592
+ md5Hex,
25525
26593
  mergeCandidateIps,
25526
26594
  mergeProperties,
25527
26595
  mqttAppName,
@@ -25566,9 +26634,12 @@ export {
25566
26634
  secureTopic,
25567
26635
  signKey,
25568
26636
  signRequest,
26637
+ solixDeviceTopics,
26638
+ solixUserTopics,
25569
26639
  structuralEqual,
25570
26640
  subscribeTopics,
25571
26641
  suctionLevelName,
26642
+ tokenNotExpired,
25572
26643
  tuya_exports as tuya,
25573
26644
  u16be,
25574
26645
  u16le,