@mega-yfue/eufy-sdk 0.2.0-beta.16 → 0.2.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2035,7 +2035,12 @@ function parseSecureTopic(topic) {
2035
2035
  function solixDeviceTopics(appName, productCode, deviceSn) {
2036
2036
  const dt = `dt/${appName}/${productCode}/${deviceSn}`;
2037
2037
  const cmd = `cmd/${appName}/${productCode}/${deviceSn}`;
2038
- return { paramInfo: `${dt}/param_info`, cmdRes: `${cmd}/app/res`, req: `${cmd}/req` };
2038
+ return {
2039
+ paramInfo: `${dt}/param_info`,
2040
+ stateInfo: `${dt}/state_info`,
2041
+ cmdRes: `${cmd}/app/res`,
2042
+ req: `${cmd}/req`
2043
+ };
2039
2044
  }
2040
2045
  function solixUserTopics(appName, userId) {
2041
2046
  return { cmdRes: `cmd/${appName}/${userId}/res`, powerSite: `dt/${appName}/${userId}/power_site` };
@@ -13631,11 +13636,7 @@ function inspectParams(rec, sn) {
13631
13636
 
13632
13637
  // dist/model/capabilities/solix.js
13633
13638
  var SOLIX_ENERGY_METER_MEMBERS = {
13634
- /**
13635
- * Line-1 voltage (V), ff09 tag `0xAC` — the ONE confirmed meter binding, matched against a live
13636
- * single-phase frame (a nominal mains voltage). Read-only; the evidence gate installs its getter only
13637
- * once a frame carrying `0xAC` has landed, so it is absent (not a fabricated `0`) until then.
13638
- */
13639
+ /** Line-1 voltage (V), ff09 tag `0xAC` — confirmed live (a nominal mains voltage). */
13639
13640
  meterVoltageL1: {
13640
13641
  param: 172,
13641
13642
  type: "number",
@@ -13643,11 +13644,99 @@ var SOLIX_ENERGY_METER_MEMBERS = {
13643
13644
  unit: "V",
13644
13645
  provenance: "verified",
13645
13646
  description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
13647
+ },
13648
+ /** Line-2 voltage (V), ff09 tag `0xAD` — the app's field; reads 0 until a multi-phase frame carries it. */
13649
+ meterVoltageL2: {
13650
+ param: 173,
13651
+ type: "number",
13652
+ kind: "scalar",
13653
+ unit: "V",
13654
+ provenance: "guessed",
13655
+ description: "Meter line-2 voltage (V) \u2014 ff09 tag 0xAD; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13656
+ },
13657
+ /** Line-3 voltage (V), ff09 tag `0xAE` — the app's field; reads 0 until a multi-phase frame carries it. */
13658
+ meterVoltageL3: {
13659
+ param: 174,
13660
+ type: "number",
13661
+ kind: "scalar",
13662
+ unit: "V",
13663
+ provenance: "guessed",
13664
+ description: "Meter line-3 voltage (V) \u2014 ff09 tag 0xAE; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13665
+ },
13666
+ /** Line-1 current (A), ff09 tag `0xAF` — confirmed live (the line's CT current). */
13667
+ meterCurrentL1: {
13668
+ param: 175,
13669
+ type: "number",
13670
+ kind: "scalar",
13671
+ unit: "A",
13672
+ provenance: "verified",
13673
+ description: "Meter line-1 current (A) \u2014 ff09 tag 0xAF, confirmed against a live single-phase frame."
13674
+ },
13675
+ /** Line-2 current (A), ff09 tag `0xB0` — the app's field; reads 0 until a multi-phase frame carries it. */
13676
+ meterCurrentL2: {
13677
+ param: 176,
13678
+ type: "number",
13679
+ kind: "scalar",
13680
+ unit: "A",
13681
+ provenance: "guessed",
13682
+ description: "Meter line-2 current (A) \u2014 ff09 tag 0xB0; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13683
+ },
13684
+ /** Line-3 current (A), ff09 tag `0xB1` — the app's field; reads 0 until a multi-phase frame carries it. */
13685
+ meterCurrentL3: {
13686
+ param: 177,
13687
+ type: "number",
13688
+ kind: "scalar",
13689
+ unit: "A",
13690
+ provenance: "guessed",
13691
+ description: "Meter line-3 current (A) \u2014 ff09 tag 0xB1; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13692
+ },
13693
+ /** Line-1 active power (W), ff09 tag `0xA8` — confirmed live; negative on export. */
13694
+ meterPowerL1: {
13695
+ param: 168,
13696
+ type: "number",
13697
+ kind: "scalar",
13698
+ unit: "W",
13699
+ provenance: "verified",
13700
+ description: "Meter line-1 active power (W) \u2014 ff09 tag 0xA8, confirmed live; negative on export."
13701
+ },
13702
+ /** Line-2 active power (W), ff09 tag `0xA9` — the app's field; reads 0 until a multi-phase frame carries it. */
13703
+ meterPowerL2: {
13704
+ param: 169,
13705
+ type: "number",
13706
+ kind: "scalar",
13707
+ unit: "W",
13708
+ provenance: "guessed",
13709
+ description: "Meter line-2 active power (W) \u2014 ff09 tag 0xA9; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13710
+ },
13711
+ /** Line-3 active power (W), ff09 tag `0xAA` — the app's field; reads 0 until a multi-phase frame carries it. */
13712
+ meterPowerL3: {
13713
+ param: 170,
13714
+ type: "number",
13715
+ kind: "scalar",
13716
+ unit: "W",
13717
+ provenance: "guessed",
13718
+ description: "Meter line-3 active power (W) \u2014 ff09 tag 0xAA; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13719
+ },
13720
+ /** Aggregate active power (W), ff09 tag `0xAB` — confirmed live; equals line-1 on a single phase. */
13721
+ meterPowerTotal: {
13722
+ param: 171,
13723
+ type: "number",
13724
+ kind: "scalar",
13725
+ unit: "W",
13726
+ provenance: "verified",
13727
+ description: "Meter total active power (W) \u2014 ff09 tag 0xAB, confirmed live; equals L1 on one phase."
13646
13728
  }
13647
13729
  };
13648
13730
  var CATEGORY_CAPABILITIES = {
13649
13731
  "Portable Power Station": ["battery", "acOutput", "solarInput"],
13650
- "Plug-in Home Battery": ["battery", "solarInput", "acOutput", "energyMeter"],
13732
+ /**
13733
+ * NOT `energyMeter`: a home battery measures its own grid/PV/output power, but that is a different
13734
+ * ff09 tag family than the AE1X0 Smart Meter's — the `energyMeter` members are AE1X0-specific, so a
13735
+ * Solarbank frame decoded through them mislabels (tag `0xac` is power on a Solarbank, voltage on the
13736
+ * meter — enforced by the meter-family gate in {@link SOLIX_METER_MODELS} / the transport decoder,
13737
+ * see PR #186). `energyMeter` is model-detected for the meter, not category-detected.
13738
+ */
13739
+ "Plug-in Home Battery": ["battery", "solarInput", "acOutput"],
13651
13740
  "Powered Cooler": ["battery", "cooler"],
13652
13741
  "Power Bank": ["battery"],
13653
13742
  "Smart EV Charger": ["evCharger"],
@@ -13655,7 +13744,7 @@ var CATEGORY_CAPABILITIES = {
13655
13744
  Accessory: []
13656
13745
  };
13657
13746
  var SOLIX_METER_MODELS = ["AE1X0"];
13658
- var SOLARBANK_MODELS = ["A1790", "A17C"];
13747
+ var SOLARBANK_MODELS = ["A1790", "A17C", "AE10"];
13659
13748
  function detectSolixCapabilities(rec, category) {
13660
13749
  const caps = /* @__PURE__ */ new Set(["identity"]);
13661
13750
  if (rec.device_sw_version)
@@ -13678,7 +13767,7 @@ function buildModelIndex(categories) {
13678
13767
  const index = /* @__PURE__ */ new Map();
13679
13768
  for (const category of categories) {
13680
13769
  for (const product of category.products ?? []) {
13681
- const entry = { name: product.name, category: category.name };
13770
+ const entry = { name: product.name, category: category.name.trim() };
13682
13771
  if (product.product_code)
13683
13772
  index.set(product.product_code, entry);
13684
13773
  for (const variant of product.p_codes ?? []) {
@@ -13691,6 +13780,26 @@ function buildModelIndex(categories) {
13691
13780
  return index;
13692
13781
  }
13693
13782
 
13783
+ // dist/model/solix-family.js
13784
+ var CATEGORY_FAMILY = {
13785
+ "Portable Power Station": "powerStation",
13786
+ "Power Bank": "powerBank",
13787
+ "Powered Cooler": "cooler",
13788
+ "Smart EV Charger": "evCharger",
13789
+ Charger: "charger"
13790
+ };
13791
+ var hasPrefix = (code, prefixes) => !!code && prefixes.some((p) => code.startsWith(p));
13792
+ var isSolixSolarbank = (input) => hasPrefix(input.product_code, SOLARBANK_MODELS) || input.category === "Plug-in Home Battery";
13793
+ var isSolixSmartMeter = (input) => hasPrefix(input.product_code, SOLIX_METER_MODELS);
13794
+ var isSolixPowerStation = (input) => input.category === "Portable Power Station";
13795
+ function solixProductFamily(input) {
13796
+ if (isSolixSmartMeter(input))
13797
+ return "smartMeter";
13798
+ if (isSolixSolarbank(input))
13799
+ return "solarbank";
13800
+ return (input.category ? CATEGORY_FAMILY[input.category] : void 0) ?? "unknown";
13801
+ }
13802
+
13694
13803
  // dist/model/solix-device.js
13695
13804
  var READ_ONLY_SINK = { dispatch: async () => {
13696
13805
  } };
@@ -13710,10 +13819,19 @@ var SolixDevice = class {
13710
13819
  serial: record.device_sn,
13711
13820
  productCode: record.product_code,
13712
13821
  name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
13713
- category: label?.category
13822
+ category: label?.category,
13823
+ family: solixProductFamily({ product_code: record.product_code, category: label?.category })
13714
13824
  };
13715
13825
  this.caps = detectSolixCapabilities(record, this.identity_.category);
13716
13826
  }
13827
+ /**
13828
+ * The device's {@link SolixProductFamily} — the classification a caller branches on to SORT devices
13829
+ * (a site's power stations vs its meters), the Solix analogue of eufy's `isHomeBase()`. Distinct from
13830
+ * {@link has}, which answers what the device can DO; family answers what KIND of device it is.
13831
+ */
13832
+ get family() {
13833
+ return this.identity_.family;
13834
+ }
13717
13835
  /** All capabilities this device carries. */
13718
13836
  get capabilities() {
13719
13837
  return [...this.caps];
@@ -13789,12 +13907,101 @@ var SolixDevice = class {
13789
13907
  return tags;
13790
13908
  }
13791
13909
  };
13910
+ function resolveSolixCatalog(client, opts) {
13911
+ return opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => []);
13912
+ }
13792
13913
  async function discoverSolixDevices(client, opts = {}) {
13793
- const [records, catalog] = await Promise.all([
13914
+ const [records, catalog] = await Promise.all([client.getDevices(), resolveSolixCatalog(client, opts)]);
13915
+ return records.map((r) => new SolixDevice(r, { catalog }));
13916
+ }
13917
+ function sceneNum(v) {
13918
+ if (typeof v === "number")
13919
+ return Number.isFinite(v) ? v : void 0;
13920
+ if (typeof v === "string" && v.trim() !== "") {
13921
+ const n = Number(v);
13922
+ return Number.isFinite(n) ? n : void 0;
13923
+ }
13924
+ return void 0;
13925
+ }
13926
+ function solarbankSceneReadings(scene) {
13927
+ const list = scene.solarbank_info?.solarbank_list ?? [];
13928
+ const out = [];
13929
+ for (const sb of list) {
13930
+ const deviceSn = typeof sb.device_sn === "string" ? sb.device_sn : void 0;
13931
+ if (!deviceSn)
13932
+ continue;
13933
+ const values = {};
13934
+ const temp = sceneNum(sb.bat_temperature);
13935
+ if (temp !== void 0)
13936
+ values.batteryTemperature = temp;
13937
+ const soc = sceneNum(sb.bat_soc);
13938
+ if (soc !== void 0)
13939
+ values.batterySoc = soc;
13940
+ if (Object.keys(values).length > 0)
13941
+ out.push({ deviceSn, values });
13942
+ }
13943
+ return out;
13944
+ }
13945
+
13946
+ // dist/model/solix-site.js
13947
+ var SolixSite = class {
13948
+ id;
13949
+ /** Friendly site name (e.g. "My Home"), or the site id when the record carries none. */
13950
+ name;
13951
+ /** Anker's site-type discriminator (e.g. 20 for a Solarbank-anchored home system), when present. */
13952
+ powerSiteType;
13953
+ record;
13954
+ devices;
13955
+ constructor(record, devices) {
13956
+ this.record = record;
13957
+ this.id = record.site_id;
13958
+ this.name = record.site_name || record.site_id;
13959
+ this.powerSiteType = record.power_site_type;
13960
+ this.devices = devices;
13961
+ }
13962
+ /** The member device with this serial, if it belongs to the site. */
13963
+ device(serial) {
13964
+ return this.devices.find((d) => d.serial === serial);
13965
+ }
13966
+ /** Member devices of a given product {@link SolixProductFamily} — the grouping accessor. */
13967
+ withFamily(family) {
13968
+ return this.devices.filter((d) => d.family === family);
13969
+ }
13970
+ /** Member devices that carry a given capability (e.g. every `battery` in the system). */
13971
+ withCapability(capability) {
13972
+ return this.devices.filter((d) => d.has(capability));
13973
+ }
13974
+ /** The Solarbank / plug-in home-battery members of the system. */
13975
+ solarbanks() {
13976
+ return this.withFamily("solarbank");
13977
+ }
13978
+ /** The smart-meter (grid-CT) members of the system. */
13979
+ smartMeters() {
13980
+ return this.withFamily("smartMeter");
13981
+ }
13982
+ /** The portable power-station members of the system. */
13983
+ powerStations() {
13984
+ return this.withFamily("powerStation");
13985
+ }
13986
+ };
13987
+ async function discoverSolixSites(client, opts = {}) {
13988
+ const [sites, records, catalog] = await Promise.all([
13989
+ client.getSites(),
13794
13990
  client.getDevices(),
13795
- opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => [])
13991
+ resolveSolixCatalog(client, opts)
13796
13992
  ]);
13797
- return records.map((r) => new SolixDevice(r, { catalog }));
13993
+ const bySerial = new Map(records.map((r) => [r.device_sn, r]));
13994
+ return sites.map((site) => {
13995
+ const members = (site.site_device_list ?? []).map((entry) => {
13996
+ const record = bySerial.get(entry.device_sn) ?? {
13997
+ device_sn: entry.device_sn,
13998
+ product_code: entry.device_model,
13999
+ device_name: entry.device_name
14000
+ };
14001
+ return new SolixDevice(record, { catalog });
14002
+ });
14003
+ return new SolixSite(site, members);
14004
+ });
13798
14005
  }
13799
14006
 
13800
14007
  // dist/client/map-channels.js
@@ -26289,15 +26496,43 @@ var SOLIX_ENDPOINTS = {
26289
26496
  /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26290
26497
  getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
26291
26498
  /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
26292
- productCategories: "/power_service/v1/product_categories"
26499
+ productCategories: "/power_service/v1/product_categories",
26500
+ /** POST (encrypted+signed): write device attributes, e.g. `{ambient_light_switch: 0|1}`. */
26501
+ setDeviceAttrs: "/power_service/v1/app/device/set_device_attrs",
26502
+ /** POST (plain authed): read device attributes, e.g. the display `screen_off_time` (seconds). */
26503
+ getDeviceAttrs: "/power_service/v1/app/device/get_device_attrs",
26504
+ /** POST (plain authed): the battery discharge-cutoff (minimum-SOC) preset options. */
26505
+ getPowerCutoff: "/power_service/v1/app/compatible/get_power_cutoff",
26506
+ /** POST (encrypted+signed): select the discharge-cutoff preset by `cutoff_data_id`. */
26507
+ setPowerCutoff: "/power_service/v1/app/compatible/set_power_cutoff",
26508
+ /**
26509
+ * POST (plain authed): the site "scene" snapshot — the same clean Solarbank/grid telemetry the app
26510
+ * reads on load/refresh. Used as a low-rate BACKSTOP for the fields the realtime `ff09` push doesn't
26511
+ * carry reliably (notably `bat_temperature`), NOT as the realtime source (that is the MQTT push).
26512
+ */
26513
+ getSiteScene: "/power_service/v2/site/platform_get_site_scene",
26514
+ /**
26515
+ * POST (plain authed): read a site "device param" block by `param_type`. Body is
26516
+ * `{ site_id, param_type, cmd: 246 }`; the response's `data.param_data` is a JSON STRING the caller
26517
+ * parses. The Solarbank's SOC-limit settings live under `param_type "27"` (charge/discharge limits,
26518
+ * backup reserve) — verified live on an AE103 (`"18"` returns empty for this device).
26519
+ */
26520
+ getSiteDeviceParam: "/power_service/v1/site/get_site_device_param",
26521
+ /**
26522
+ * POST (encrypted+signed): write a site "device param" block. Body is
26523
+ * `{ site_id, cmd: 246, param_type, param_data: <JSON string> }`. Used for the SOC-limit write
26524
+ * (`param_type "27"`, `param_data` = the SocSettingParam map) — see {@link SolixClient.setSafetySocParams}.
26525
+ */
26526
+ setSiteDeviceParam: "/power_service/v1/site/set_site_device_param"
26293
26527
  };
26294
26528
 
26295
26529
  // dist/transport/http/solix-client.js
26296
26530
  function solixSessionFresh(s) {
26297
26531
  return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
26298
26532
  }
26533
+ var SOLIX_TOKEN_KICKED_CODE = 26084;
26299
26534
  var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
26300
- var SolixClient = class {
26535
+ var SolixClient = class _SolixClient {
26301
26536
  email;
26302
26537
  password;
26303
26538
  country;
@@ -26475,8 +26710,34 @@ var SolixClient = class {
26475
26710
  await this.estimateHost();
26476
26711
  const kx = await this.keyExchange();
26477
26712
  const env = await this.postLogin(kx);
26713
+ this.assertLoginAccepted(env);
26478
26714
  return this.classifyLogin(this.decryptLogin(env, kx), false);
26479
26715
  }
26716
+ /**
26717
+ * On a rejected `/passport/login` (non-zero code, so `data` is an error envelope not the encrypted
26718
+ * payload), throw a diagnostic that names WHY the passport refused — the throttle (`26161`, "too
26719
+ * frequent") vs a challenge it wants the client to satisfy. The passport marks a required captcha with
26720
+ * a `captcha_id`/`item`; our headless client cannot answer one, so surfacing it distinguishes "wait
26721
+ * out the rate-limit" from "a captcha is required — clear it in the app". No secrets are logged, only
26722
+ * the code, message, and which challenge fields are present.
26723
+ */
26724
+ assertLoginAccepted(env) {
26725
+ if (env.code === 0)
26726
+ return;
26727
+ const d = env.data ?? {};
26728
+ const hints = [];
26729
+ if (typeof d === "object" && d) {
26730
+ if ("captcha_id" in d && d.captcha_id)
26731
+ hints.push("captcha_id present (captcha required)");
26732
+ if ("item" in d && d.item)
26733
+ hints.push(`item=${String(d.item).slice(0, 40)}`);
26734
+ const keys = Object.keys(d);
26735
+ if (keys.length && hints.length === 0)
26736
+ hints.push(`data keys: ${keys.join(",")}`);
26737
+ }
26738
+ const detail = hints.length ? ` [${hints.join("; ")}]` : "";
26739
+ throw new Error(`Solix login (${env.code}): ${env.msg}${detail}`);
26740
+ }
26480
26741
  /** Complete a `2fa` login with the code the passport sent. */
26481
26742
  async submitVerifyCode(code) {
26482
26743
  if (!this.pending2fa)
@@ -26488,14 +26749,26 @@ var SolixClient = class {
26488
26749
  /**
26489
26750
  * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
26490
26751
  * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
26752
+ *
26753
+ * Self-heals a **displaced session**: Anker allows ~one session per account, so another login (the app,
26754
+ * or a second client) invalidates this token and reads then fail with {@link SOLIX_TOKEN_KICKED_CODE}
26755
+ * ("token does not exist because it was kicked out"). On that code this re-logs in once and retries, so
26756
+ * a running client recovers on its own instead of failing every read until its session store is cleared.
26491
26757
  */
26492
- async authed(method2, path, body) {
26758
+ async authed(method2, path, body, reauthed = false) {
26493
26759
  if (!this.session_)
26494
26760
  throw new Error("not authenticated \u2014 call login() first");
26495
26761
  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);
26496
- if (env.code !== 0)
26497
- throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
26498
- return env.data ?? null;
26762
+ if (env.code === 0)
26763
+ return env.data ?? null;
26764
+ if (env.code === SOLIX_TOKEN_KICKED_CODE && !reauthed) {
26765
+ this.session_ = void 0;
26766
+ const r = await this.login();
26767
+ if (r.status !== "ok")
26768
+ throw new Error(`Solix ${path}: session was kicked and re-login did not complete (${r.status})`);
26769
+ return this.authed(method2, path, body, true);
26770
+ }
26771
+ throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
26499
26772
  }
26500
26773
  /**
26501
26774
  * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
@@ -26506,15 +26779,35 @@ var SolixClient = class {
26506
26779
  const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
26507
26780
  return Array.isArray(data) ? data : data?.data ?? [];
26508
26781
  }
26509
- /** The account's sites (systems); devices are typically grouped under a site. */
26782
+ /**
26783
+ * The account's sites (systems); devices are grouped under a site. Each record carries its
26784
+ * `site_device_list` (the member devices), which {@link discoverSolixSites} resolves into a
26785
+ * capability-driven `SolixSite`. Asserted to {@link SolixSiteRecord} at this trust boundary — and
26786
+ * `site_id` (the one field the model layer keys a `SolixSite` on) is validated here, so a record the
26787
+ * cloud returns without a usable id is dropped rather than surfacing a `SolixSite` with `id ===
26788
+ * undefined`; every other field is optional and read defensively.
26789
+ */
26510
26790
  async getSites() {
26511
26791
  const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
26512
- return data?.site_list ?? [];
26792
+ const list = data?.site_list ?? [];
26793
+ return list.filter((s) => typeof s?.site_id === "string" && s.site_id.length > 0);
26513
26794
  }
26514
26795
  /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26515
26796
  async getUserMqttInfo() {
26516
26797
  return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
26517
26798
  }
26799
+ /**
26800
+ * Read a site's "scene" snapshot — the app's dashboard read for a system, a plain authed read. Its
26801
+ * battery detail (`solarbank_info.solarbank_list[]`) carries clean, correctly-named fields including
26802
+ * `bat_temperature`, which the realtime `ff09` MQTT push does NOT reliably carry (the fast frame's BMS
26803
+ * blob is empty, so the decoder withholds temperature). This is therefore a low-rate BACKSTOP for those
26804
+ * gap fields — NOT the realtime source: live power/SOC still come from the MQTT push (which is what the
26805
+ * app itself refreshes from every ~5 s; there is no clean-JSON scene PUSH). Verified live against the
26806
+ * `ff09` floats — the two agree to the watt at the same instant.
26807
+ */
26808
+ async getSiteScene(siteId) {
26809
+ return this.authed("POST", SOLIX_ENDPOINTS.getSiteScene, { site_id: siteId });
26810
+ }
26518
26811
  /**
26519
26812
  * The pairable-product catalog (categories → products). This is Anker's product registry, not the
26520
26813
  * account's devices — fetch it to label a discovered device's model code with a marketing name and
@@ -26524,13 +26817,236 @@ var SolixClient = class {
26524
26817
  async getProductCatalog() {
26525
26818
  return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
26526
26819
  }
26820
+ /**
26821
+ * Write device attributes — a CONTROL write, e.g. the Solarbank ambient light
26822
+ * `{ ambient_light_switch: 0 | 1 }` (0 = on, 1 = off). Unlike the plain authenticated reads, a write
26823
+ * must be **encrypted + signed** with a freshly negotiated `algo_ecdh` key: the gateway accepts an
26824
+ * unsigned write with `code 0` but the device never applies it. The token-bearing request also must
26825
+ * NOT carry the device id (`openudid`), or the gateway answers `401 token error`. Both verified live
26826
+ * on an AE103 (the LED-enable bit in the `ba` telemetry flips exactly as commanded).
26827
+ */
26828
+ async setDeviceAttrs(deviceSn, attributes) {
26829
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setDeviceAttrs, "set_device_attrs", {
26830
+ device_sn: deviceSn,
26831
+ attributes
26832
+ });
26833
+ }
26834
+ /**
26835
+ * A CONTROL write: `algo_ecdh`-encrypted + signed, token-bearing but WITHOUT `openudid`. Every device
26836
+ * control the account performs (set_device_attrs, set_power_cutoff, …) goes through this — the gateway
26837
+ * accepts an unsigned/plain write with `code 0` but the device never applies it, and adding `openudid`
26838
+ * to the token-bearing request returns `401 token error`. Both verified live on an AE103.
26839
+ */
26840
+ async encryptedWrite(path, label, payload) {
26841
+ if (!this.session_)
26842
+ throw new Error("not authenticated \u2014 call login() first");
26843
+ const kx = await this.keyExchange();
26844
+ const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
26845
+ const ts = nowSec();
26846
+ const once = genId();
26847
+ const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
26848
+ "x-encryption-info": "algo_ecdh",
26849
+ "x-key-ident": kx.keyIdent,
26850
+ "x-request-ts": ts,
26851
+ "x-request-once": once,
26852
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26853
+ "x-auth-token": this.session_.authToken,
26854
+ gtoken: this.session_.gtoken
26855
+ }));
26856
+ if (env.code !== 0)
26857
+ throw new Error(`Solix ${label} failed (${env.code}): ${env.msg}`);
26858
+ }
26859
+ /** Turn the Solarbank's ambient LED on/off — a confirmed `set_device_attrs` write. */
26860
+ async setAmbientLight(deviceSn, on) {
26861
+ await this.setDeviceAttrs(deviceSn, { ambient_light_switch: on ? 0 : 1 });
26862
+ }
26863
+ /**
26864
+ * Read device attributes — a plain authenticated read (unlike the encrypted write). `attributes`
26865
+ * names the keys to fetch (e.g. `["screen_off_time"]`); an empty list asks for the device's default
26866
+ * set. Returns the gateway's attribute map as-is (values are device-typed — numbers, strings). Used
26867
+ * to reflect a control's live state, e.g. the display/light off-timeout.
26868
+ */
26869
+ async getDeviceAttrs(deviceSn, attributes = []) {
26870
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getDeviceAttrs, { device_sn: deviceSn, attributes });
26871
+ if (data && typeof data === "object" && "attributes" in data && data.attributes) {
26872
+ return data.attributes;
26873
+ }
26874
+ return data ?? {};
26875
+ }
26876
+ /**
26877
+ * Set the Solarbank display's screen-off timeout, in SECONDS (`screen_off_time`). The app's picker
26878
+ * offers 10/20/30 s and 1/5/30 min; the LCD backlight — and with it the ambient LED that the screen
26879
+ * gates — turns off after this idle period. This is the raw-seconds write; the caller maps its own UI
26880
+ * options to seconds. The "Never" (always-on) sentinel is device-defined and NOT assumed here — pass
26881
+ * the exact integer read back from {@link getDeviceAttrs} while the device is in that mode.
26882
+ */
26883
+ async setScreenOffTime(deviceSn, seconds) {
26884
+ await this.setDeviceAttrs(deviceSn, { screen_off_time: seconds });
26885
+ }
26886
+ /**
26887
+ * Read the Solarbank's battery discharge-cutoff (minimum-SOC) options — a plain authed read.
26888
+ * The gateway returns a preset list (`power_cutoff_data`): each entry is a selectable minimum
26889
+ * state-of-charge `output_cutoff_data` (percent) with its `id` and `is_selected` flag. The caller
26890
+ * presents these options and writes the chosen `id` back via {@link setPowerCutoff} — the values and
26891
+ * ids come from the device, never assumed. `siteId` is optional (the device knows its own cutoff).
26892
+ */
26893
+ async getPowerCutoff(deviceSn, siteId = "") {
26894
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getPowerCutoff, { site_id: siteId, device_sn: deviceSn });
26895
+ return data?.power_cutoff_data ?? [];
26896
+ }
26897
+ /**
26898
+ * Select the Solarbank's battery discharge-cutoff (minimum SOC) by option id — a control write.
26899
+ * `cutoffDataId` MUST be an `id` returned by {@link getPowerCutoff} for this device (the preset the
26900
+ * user picked), never a raw percentage; the gateway maps the id to its cutoff percent.
26901
+ */
26902
+ async setPowerCutoff(deviceSn, cutoffDataId) {
26903
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setPowerCutoff, "set_power_cutoff", {
26904
+ device_sn: deviceSn,
26905
+ cutoff_data_id: cutoffDataId
26906
+ });
26907
+ }
26908
+ /** The `param_type` under which the Solarbank's SOC-limit block lives (verified live on an AE103). */
26909
+ static SOC_PARAM_TYPE = "27";
26910
+ /** `cmd` value that scopes the `site/*_site_device_param` family (from the app's request builder). */
26911
+ static SITE_DEVICE_PARAM_CMD = 246;
26912
+ /**
26913
+ * Read one of a site's "device param" blocks by `param_type` — a plain authenticated read whose
26914
+ * `data.param_data` is itself a JSON STRING (the vendor double-encodes it). Returns the parsed inner
26915
+ * object, or `{}` when the block is empty (the gateway answers `code 0` with an empty `param_data`
26916
+ * for a `param_type` that does not apply to the site's hardware). The caller owns the inner shape.
26917
+ */
26918
+ async getSiteDeviceParam(siteId, paramType) {
26919
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteDeviceParam, {
26920
+ site_id: siteId,
26921
+ param_type: paramType,
26922
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD
26923
+ });
26924
+ const raw = data?.param_data;
26925
+ if (!raw)
26926
+ return {};
26927
+ try {
26928
+ return JSON.parse(raw);
26929
+ } catch {
26930
+ return {};
26931
+ }
26932
+ }
26933
+ /**
26934
+ * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
26935
+ * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
26936
+ * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
26937
+ * app-synced source (and the only source for `chargeUpperLimit` / `backupReserve`). Verified live
26938
+ * against a known AE103 setting (discharge 20 / charge 80).
26939
+ */
26940
+ async getSafetySocParams(siteId) {
26941
+ const p = await this.getSiteDeviceParam(siteId, _SolixClient.SOC_PARAM_TYPE);
26942
+ if (typeof p.charge_upper_limit !== "number" || typeof p.discharge_lower_limit !== "number") {
26943
+ return void 0;
26944
+ }
26945
+ return {
26946
+ chargeUpperLimit: p.charge_upper_limit,
26947
+ dischargeLowerLimit: p.discharge_lower_limit,
26948
+ backupReserve: typeof p.backup_reserve === "number" ? p.backup_reserve : 0,
26949
+ backupReserveSwitch: typeof p.backup_reserve_switch === "number" ? p.backup_reserve_switch : 0,
26950
+ socCalibrationEnable: typeof p.soc_calibration_enable === "number" ? p.soc_calibration_enable : 0
26951
+ };
26952
+ }
26953
+ /**
26954
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
26955
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
26956
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
26957
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
26958
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
26959
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
26960
+ */
26961
+ async setSafetySocParams(siteId, changes) {
26962
+ const current = await this.getSafetySocParams(siteId);
26963
+ if (!current)
26964
+ throw new Error(`Solix set SOC params: site has no param_type 27 block`);
26965
+ const merged = { ...current, ...changes };
26966
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setSiteDeviceParam, "set_site_device_param", {
26967
+ site_id: siteId,
26968
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD,
26969
+ param_type: _SolixClient.SOC_PARAM_TYPE,
26970
+ param_data: JSON.stringify({
26971
+ charge_upper_limit: merged.chargeUpperLimit,
26972
+ discharge_lower_limit: merged.dischargeLowerLimit,
26973
+ backup_reserve_switch: merged.backupReserveSwitch,
26974
+ backup_reserve: merged.backupReserve,
26975
+ soc_calibration_enable: merged.socCalibrationEnable
26976
+ })
26977
+ });
26978
+ return merged;
26979
+ }
26527
26980
  };
26528
26981
 
26529
26982
  // dist/transport/mqtt/solix-mqtt.js
26530
26983
  import { EventEmitter as EventEmitter10 } from "node:events";
26531
26984
  var SOLIX_METER_FIELD_NAMES = {
26532
- 172: "meterVoltageL1"
26985
+ 168: "meterPowerL1",
26986
+ 169: "meterPowerL2",
26987
+ 170: "meterPowerL3",
26988
+ 171: "meterPowerTotal",
26989
+ 172: "meterVoltageL1",
26990
+ 173: "meterVoltageL2",
26991
+ 174: "meterVoltageL3",
26992
+ 175: "meterCurrentL1",
26993
+ 176: "meterCurrentL2",
26994
+ 177: "meterCurrentL3",
26995
+ 179: "meterImportEnergy",
26996
+ 180: "meterExportEnergy"
26997
+ };
26998
+ var SOLIX_METER_PRODUCT_PREFIXES = ["AE1X0"];
26999
+ var SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
27000
+ var SOLIX_SOLARBANK_FIELD_NAMES = {
27001
+ 171: "photovoltaicPower",
27002
+ // total PV input across the strings
27003
+ 172: "batteryPower",
27004
+ 188: "chargePower",
27005
+ 173: "dischargePower",
27006
+ 174: "acPlugPower",
27007
+ 175: "socketPower",
27008
+ // the unit's own on-board AC socket (an appliance plugged into the Solarbank)
27009
+ 196: "gridInputPower",
27010
+ 197: "homeLoadPower",
27011
+ 198: "pv1Power",
27012
+ // the four PV-string inputs (0 when a string is unused / dark)
27013
+ 199: "pv2Power",
27014
+ 200: "pv3Power",
27015
+ 201: "pv4Power"
26533
27016
  };
27017
+ var SOLIX_STATE_FIELD_NAMES = {
27018
+ 169: "mode",
27019
+ // current operating (EMS) mode (1 custom, 2 self-consumption, 4 rapid charge, 7 smart, 8 dynamic tariff)
27020
+ 170: "maxLoad"
27021
+ // configured max home load (W) — matches get_site_device_param max_load
27022
+ // NOTE `0xab` is grid-in/out-related power but its exact meaning is not yet pinned, so it stays raw
27023
+ // `state_ab` (a diagnostic a consumer can watch) rather than being asserted under a guessed name.
27024
+ };
27025
+ function solixStateReadings(frame) {
27026
+ const out = {};
27027
+ for (const [tag2, value] of frame.fields) {
27028
+ if (tag2 < 165 || !value || value.length < 2)
27029
+ continue;
27030
+ const type = value[0];
27031
+ const pl = value.subarray(1);
27032
+ let num2;
27033
+ if (type === 5 && pl.length >= 4)
27034
+ num2 = pl.readFloatLE(0);
27035
+ else if (type === 2 && pl.length >= 2)
27036
+ num2 = pl.readUInt16LE(0);
27037
+ else if (type === 1 && pl.length >= 1)
27038
+ num2 = pl[0];
27039
+ else if (type === 3 && pl.length >= 2)
27040
+ num2 = pl[1];
27041
+ if (num2 === void 0)
27042
+ continue;
27043
+ out[`state_${tag2.toString(16)}`] = num2;
27044
+ const name = SOLIX_STATE_FIELD_NAMES[tag2];
27045
+ if (name)
27046
+ out[name] = num2;
27047
+ }
27048
+ return out;
27049
+ }
26534
27050
  function readSolixChannel(value) {
26535
27051
  if (!value || value.length < 1)
26536
27052
  return void 0;
@@ -26564,8 +27080,11 @@ function decodeSolixParamFrame(buf) {
26564
27080
  deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
26565
27081
  return { deviceSn, fields };
26566
27082
  }
26567
- function solixReadings(frame) {
27083
+ function solixReadings(frame, productCode) {
26568
27084
  const out = {};
27085
+ const isMeter = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
27086
+ const isSolarbank = productCode.startsWith(SOLIX_SOLARBANK_PRODUCT_PREFIX);
27087
+ const floatNames = isMeter ? SOLIX_METER_FIELD_NAMES : isSolarbank ? SOLIX_SOLARBANK_FIELD_NAMES : void 0;
26569
27088
  for (const [tag2, value] of frame.fields) {
26570
27089
  if (tag2 < 166)
26571
27090
  continue;
@@ -26573,12 +27092,30 @@ function solixReadings(frame) {
26573
27092
  if (ch?.type !== 5 || ch.float === void 0)
26574
27093
  continue;
26575
27094
  out[`channel_${tag2.toString(16)}`] = ch.float;
26576
- const name = SOLIX_METER_FIELD_NAMES[tag2];
27095
+ const name = floatNames?.[tag2];
26577
27096
  if (name)
26578
27097
  out[name] = ch.float;
26579
27098
  }
27099
+ if (isSolarbank)
27100
+ addSolarbankScalars(frame, out);
26580
27101
  return out;
26581
27102
  }
27103
+ function addSolarbankScalars(frame, out) {
27104
+ const a3 = frame.fields.get(163);
27105
+ const soc = a3 && a3.length >= 2 ? a3[1] : void 0;
27106
+ if (soc !== void 0) {
27107
+ out.batterySoc = soc;
27108
+ const body = frame.fields.get(164)?.subarray(1);
27109
+ if (body && body.length >= 8 && body[body.length - 6] === soc) {
27110
+ out.batteryTemperature = body[body.length - 8];
27111
+ }
27112
+ }
27113
+ const b5 = frame.fields.get(181);
27114
+ if (b5 && b5[0] === 4 && b5.length === 4) {
27115
+ out.dischargeLimit = b5[1];
27116
+ out.chargeLimit = b5[3];
27117
+ }
27118
+ }
26582
27119
  var SolixMqtt = class extends EventEmitter10 {
26583
27120
  transport;
26584
27121
  appName;
@@ -26622,9 +27159,14 @@ var SolixMqtt = class extends EventEmitter10 {
26622
27159
  * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
26623
27160
  * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
26624
27161
  *
26625
- * Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
26626
- * channels — never the `…/req` channels, which are the app→device request side this arms on, and would
26627
- * echo its own publishes back.
27162
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
27163
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
27164
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
27165
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
27166
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
27167
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
27168
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
27169
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
26628
27170
  *
26629
27171
  * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
26630
27172
  * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
@@ -26637,7 +27179,9 @@ var SolixMqtt = class extends EventEmitter10 {
26637
27179
  const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
26638
27180
  const granted = await this.transport.subscribe([
26639
27181
  topics.paramInfo,
27182
+ topics.stateInfo,
26640
27183
  topics.cmdRes,
27184
+ topics.req,
26641
27185
  ...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
26642
27186
  ]);
26643
27187
  if (!granted.includes(topics.paramInfo)) {
@@ -26662,6 +27206,19 @@ var SolixMqtt = class extends EventEmitter10 {
26662
27206
  this.watched.clear();
26663
27207
  await this.transport.disconnect();
26664
27208
  }
27209
+ /**
27210
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
27211
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
27212
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
27213
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
27214
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
27215
+ */
27216
+ async setDisplayTimeout(device, index) {
27217
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27218
+ const body = this.commandEnvelope(device, buildDisplayTimeoutFrame(index), {});
27219
+ await this.transport.publish(topic, body, { qos: 1 });
27220
+ this.logger?.debug?.(`[solix] display timeout set index=${index} on ${device.device_sn}`);
27221
+ }
26665
27222
  /**
26666
27223
  * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
26667
27224
  * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
@@ -26745,24 +27302,74 @@ var SolixMqtt = class extends EventEmitter10 {
26745
27302
  * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
26746
27303
  * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
26747
27304
  * own `a2` field wins for the serial when it carries one.
27305
+ *
27306
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
27307
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
27308
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
27309
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
27310
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
26748
27311
  */
26749
27312
  onMessage(msg) {
26750
27313
  const topic = msg.topic ?? "";
26751
27314
  const buf = extractFf09Payload(msg.raw);
26752
27315
  if (!buf)
26753
27316
  return;
27317
+ if (topic.endsWith("/req")) {
27318
+ this.handleCommand(topic, buf);
27319
+ return;
27320
+ }
26754
27321
  const frame = decodeSolixParamFrame(buf);
26755
27322
  if (!frame)
26756
27323
  return;
26757
27324
  const parts = topic.split("/");
26758
- const reading = {
26759
- deviceSn: frame.deviceSn ?? parts[3] ?? "",
26760
- productCode: parts[2] ?? "",
26761
- topic,
26762
- frame,
26763
- values: solixReadings(frame)
26764
- };
26765
- this.emit("reading", reading);
27325
+ const productCode = parts[2] ?? "";
27326
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27327
+ if (!this.watched.has(deviceSn)) {
27328
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27329
+ if (ofProduct.length === 1)
27330
+ deviceSn = ofProduct[0].device_sn;
27331
+ }
27332
+ const values = topic.endsWith("/state_info") ? solixStateReadings(frame) : (
27333
+ // Pass the product code so meter tag→name binding is applied only to a meter frame; a Solarbank's
27334
+ // tags stay raw channel_<hex> (the model names them per capability) rather than being mislabelled.
27335
+ solixReadings(frame, productCode)
27336
+ );
27337
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27338
+ }
27339
+ /**
27340
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
27341
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
27342
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
27343
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
27344
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
27345
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
27346
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
27347
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
27348
+ */
27349
+ handleCommand(topic, buf) {
27350
+ if (buf.length < 10 || buf[8] !== 104)
27351
+ return;
27352
+ const frame = decodeSolixParamFrame(buf);
27353
+ if (!frame)
27354
+ return;
27355
+ const values = {};
27356
+ const a4 = frame.fields.get(164);
27357
+ if (a4 && a4.length >= 2)
27358
+ values.ambientLightOn = a4[1] === 0 ? 1 : 0;
27359
+ const a5 = frame.fields.get(165);
27360
+ if (a5 && a5.length >= 2)
27361
+ values.displayTimeoutIndex = a5[1];
27362
+ if (Object.keys(values).length === 0)
27363
+ return;
27364
+ const parts = topic.split("/");
27365
+ const productCode = parts[2] ?? "";
27366
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27367
+ if (!this.watched.has(deviceSn)) {
27368
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27369
+ if (ofProduct.length === 1)
27370
+ deviceSn = ofProduct[0].device_sn;
27371
+ }
27372
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
26766
27373
  }
26767
27374
  };
26768
27375
  function extractFf09Payload(raw) {
@@ -26786,6 +27393,19 @@ function extractFf09Payload(raw) {
26786
27393
  const buf = Buffer.from(data, "base64");
26787
27394
  return buf.length ? buf : null;
26788
27395
  }
27396
+ function buildDisplayTimeoutFrame(index) {
27397
+ const body = Buffer.from([3, 0, 15, 0, 104, 161, 1, 34, 165, 2, 1, index & 255]);
27398
+ const frame = Buffer.alloc(body.length + 5);
27399
+ frame[0] = 255;
27400
+ frame[1] = 9;
27401
+ frame.writeUInt16LE(frame.length, 2);
27402
+ body.copy(frame, 4);
27403
+ let xor = 0;
27404
+ for (let i = 0; i < frame.length - 1; i++)
27405
+ xor ^= frame[i];
27406
+ frame[frame.length - 1] = xor;
27407
+ return frame;
27408
+ }
26789
27409
  function buildFf09Request(variant, atUnixSec) {
26790
27410
  const ts = Buffer.alloc(4);
26791
27411
  ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
@@ -26979,6 +27599,7 @@ export {
26979
27599
  SolixClient,
26980
27600
  SolixDevice,
26981
27601
  SolixMqtt,
27602
+ SolixSite,
26982
27603
  StateConvergenceError,
26983
27604
  StationKeyUnavailableError,
26984
27605
  StationUnreachableError,
@@ -27051,6 +27672,7 @@ export {
27051
27672
  detectionName,
27052
27673
  discoverReachableInstance,
27053
27674
  discoverSolixDevices,
27675
+ discoverSolixSites,
27054
27676
  encodeAiDetectType,
27055
27677
  encodeVarint,
27056
27678
  encryptBody,
@@ -27079,6 +27701,9 @@ export {
27079
27701
  isNotAuthorized,
27080
27702
  isPrivateIpv4,
27081
27703
  isSessionValid,
27704
+ isSolixPowerStation,
27705
+ isSolixSmartMeter,
27706
+ isSolixSolarbank,
27082
27707
  isV1Image,
27083
27708
  isV2Image,
27084
27709
  jpegGeometry,
@@ -27133,7 +27758,9 @@ export {
27133
27758
  secureTopic,
27134
27759
  signKey,
27135
27760
  signRequest,
27761
+ solarbankSceneReadings,
27136
27762
  solixDeviceTopics,
27763
+ solixProductFamily,
27137
27764
  solixUserTopics,
27138
27765
  structuralEqual,
27139
27766
  subscribeTopics,