@mega-yfue/eufy-sdk 0.2.0-beta.17 → 0.2.0-beta.19

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
@@ -1244,12 +1244,20 @@ var MegaHttpClient = class {
1244
1244
  return this.region;
1245
1245
  }
1246
1246
  /**
1247
- * The logged-in account's display name — the login email's local-part (e.g. `someone+tag` for
1248
- * `someone+tag@example.com`). This is the string the app writes into the ff09 command's acting
1249
- * "username" field (verified against a captured T8531 unlock frame). Falls back to the whole email
1250
- * if it has no `@`.
1247
+ * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
1248
+ * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
1249
+ * is the login email's local-part (e.g. `someone+tag` for `someone+tag@example.com`) and falls back
1250
+ * to the whole email if it has no `@`.
1251
+ *
1252
+ * The local-part is the string the app writes into the ff09 command's acting "username" field
1253
+ * (verified against a captured T8531 unlock frame), so it is the faithful default. An override is a
1254
+ * different LABEL for the same account, not a different identity: the session authenticates on the
1255
+ * token and the device record's member ids, neither of which this touches.
1251
1256
  */
1252
1257
  get accountName() {
1258
+ const pinned = this.cfg.accountName?.trim();
1259
+ if (pinned)
1260
+ return pinned;
1253
1261
  const email = this.cfg.email ?? "";
1254
1262
  const at = email.indexOf("@");
1255
1263
  return at > 0 ? email.slice(0, at) : email;
@@ -2035,7 +2043,12 @@ function parseSecureTopic(topic) {
2035
2043
  function solixDeviceTopics(appName, productCode, deviceSn) {
2036
2044
  const dt = `dt/${appName}/${productCode}/${deviceSn}`;
2037
2045
  const cmd = `cmd/${appName}/${productCode}/${deviceSn}`;
2038
- return { paramInfo: `${dt}/param_info`, cmdRes: `${cmd}/app/res`, req: `${cmd}/req` };
2046
+ return {
2047
+ paramInfo: `${dt}/param_info`,
2048
+ stateInfo: `${dt}/state_info`,
2049
+ cmdRes: `${cmd}/app/res`,
2050
+ req: `${cmd}/req`
2051
+ };
2039
2052
  }
2040
2053
  function solixUserTopics(appName, userId) {
2041
2054
  return { cmdRes: `cmd/${appName}/${userId}/res`, powerSite: `dt/${appName}/${userId}/power_site` };
@@ -13724,7 +13737,14 @@ var SOLIX_ENERGY_METER_MEMBERS = {
13724
13737
  };
13725
13738
  var CATEGORY_CAPABILITIES = {
13726
13739
  "Portable Power Station": ["battery", "acOutput", "solarInput"],
13727
- "Plug-in Home Battery": ["battery", "solarInput", "acOutput", "energyMeter"],
13740
+ /**
13741
+ * NOT `energyMeter`: a home battery measures its own grid/PV/output power, but that is a different
13742
+ * ff09 tag family than the AE1X0 Smart Meter's — the `energyMeter` members are AE1X0-specific, so a
13743
+ * Solarbank frame decoded through them mislabels (tag `0xac` is power on a Solarbank, voltage on the
13744
+ * meter — enforced by the meter-family gate in {@link SOLIX_METER_MODELS} / the transport decoder,
13745
+ * see PR #186). `energyMeter` is model-detected for the meter, not category-detected.
13746
+ */
13747
+ "Plug-in Home Battery": ["battery", "solarInput", "acOutput"],
13728
13748
  "Powered Cooler": ["battery", "cooler"],
13729
13749
  "Power Bank": ["battery"],
13730
13750
  "Smart EV Charger": ["evCharger"],
@@ -13732,7 +13752,7 @@ var CATEGORY_CAPABILITIES = {
13732
13752
  Accessory: []
13733
13753
  };
13734
13754
  var SOLIX_METER_MODELS = ["AE1X0"];
13735
- var SOLARBANK_MODELS = ["A1790", "A17C"];
13755
+ var SOLARBANK_MODELS = ["A1790", "A17C", "AE10"];
13736
13756
  function detectSolixCapabilities(rec, category) {
13737
13757
  const caps = /* @__PURE__ */ new Set(["identity"]);
13738
13758
  if (rec.device_sw_version)
@@ -13755,7 +13775,7 @@ function buildModelIndex(categories) {
13755
13775
  const index = /* @__PURE__ */ new Map();
13756
13776
  for (const category of categories) {
13757
13777
  for (const product of category.products ?? []) {
13758
- const entry = { name: product.name, category: category.name };
13778
+ const entry = { name: product.name, category: category.name.trim() };
13759
13779
  if (product.product_code)
13760
13780
  index.set(product.product_code, entry);
13761
13781
  for (const variant of product.p_codes ?? []) {
@@ -13768,6 +13788,26 @@ function buildModelIndex(categories) {
13768
13788
  return index;
13769
13789
  }
13770
13790
 
13791
+ // dist/model/solix-family.js
13792
+ var CATEGORY_FAMILY = {
13793
+ "Portable Power Station": "powerStation",
13794
+ "Power Bank": "powerBank",
13795
+ "Powered Cooler": "cooler",
13796
+ "Smart EV Charger": "evCharger",
13797
+ Charger: "charger"
13798
+ };
13799
+ var hasPrefix = (code, prefixes) => !!code && prefixes.some((p) => code.startsWith(p));
13800
+ var isSolixSolarbank = (input) => hasPrefix(input.product_code, SOLARBANK_MODELS) || input.category === "Plug-in Home Battery";
13801
+ var isSolixSmartMeter = (input) => hasPrefix(input.product_code, SOLIX_METER_MODELS);
13802
+ var isSolixPowerStation = (input) => input.category === "Portable Power Station";
13803
+ function solixProductFamily(input) {
13804
+ if (isSolixSmartMeter(input))
13805
+ return "smartMeter";
13806
+ if (isSolixSolarbank(input))
13807
+ return "solarbank";
13808
+ return (input.category ? CATEGORY_FAMILY[input.category] : void 0) ?? "unknown";
13809
+ }
13810
+
13771
13811
  // dist/model/solix-device.js
13772
13812
  var READ_ONLY_SINK = { dispatch: async () => {
13773
13813
  } };
@@ -13787,10 +13827,19 @@ var SolixDevice = class {
13787
13827
  serial: record.device_sn,
13788
13828
  productCode: record.product_code,
13789
13829
  name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
13790
- category: label?.category
13830
+ category: label?.category,
13831
+ family: solixProductFamily({ product_code: record.product_code, category: label?.category })
13791
13832
  };
13792
13833
  this.caps = detectSolixCapabilities(record, this.identity_.category);
13793
13834
  }
13835
+ /**
13836
+ * The device's {@link SolixProductFamily} — the classification a caller branches on to SORT devices
13837
+ * (a site's power stations vs its meters), the Solix analogue of eufy's `isHomeBase()`. Distinct from
13838
+ * {@link has}, which answers what the device can DO; family answers what KIND of device it is.
13839
+ */
13840
+ get family() {
13841
+ return this.identity_.family;
13842
+ }
13794
13843
  /** All capabilities this device carries. */
13795
13844
  get capabilities() {
13796
13845
  return [...this.caps];
@@ -13866,12 +13915,101 @@ var SolixDevice = class {
13866
13915
  return tags;
13867
13916
  }
13868
13917
  };
13918
+ function resolveSolixCatalog(client, opts) {
13919
+ return opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => []);
13920
+ }
13869
13921
  async function discoverSolixDevices(client, opts = {}) {
13870
- const [records, catalog] = await Promise.all([
13922
+ const [records, catalog] = await Promise.all([client.getDevices(), resolveSolixCatalog(client, opts)]);
13923
+ return records.map((r) => new SolixDevice(r, { catalog }));
13924
+ }
13925
+ function sceneNum(v) {
13926
+ if (typeof v === "number")
13927
+ return Number.isFinite(v) ? v : void 0;
13928
+ if (typeof v === "string" && v.trim() !== "") {
13929
+ const n = Number(v);
13930
+ return Number.isFinite(n) ? n : void 0;
13931
+ }
13932
+ return void 0;
13933
+ }
13934
+ function solarbankSceneReadings(scene) {
13935
+ const list = scene.solarbank_info?.solarbank_list ?? [];
13936
+ const out = [];
13937
+ for (const sb of list) {
13938
+ const deviceSn = typeof sb.device_sn === "string" ? sb.device_sn : void 0;
13939
+ if (!deviceSn)
13940
+ continue;
13941
+ const values = {};
13942
+ const temp = sceneNum(sb.bat_temperature);
13943
+ if (temp !== void 0)
13944
+ values.batteryTemperature = temp;
13945
+ const soc = sceneNum(sb.bat_soc);
13946
+ if (soc !== void 0)
13947
+ values.batterySoc = soc;
13948
+ if (Object.keys(values).length > 0)
13949
+ out.push({ deviceSn, values });
13950
+ }
13951
+ return out;
13952
+ }
13953
+
13954
+ // dist/model/solix-site.js
13955
+ var SolixSite = class {
13956
+ id;
13957
+ /** Friendly site name (e.g. "My Home"), or the site id when the record carries none. */
13958
+ name;
13959
+ /** Anker's site-type discriminator (e.g. 20 for a Solarbank-anchored home system), when present. */
13960
+ powerSiteType;
13961
+ record;
13962
+ devices;
13963
+ constructor(record, devices) {
13964
+ this.record = record;
13965
+ this.id = record.site_id;
13966
+ this.name = record.site_name || record.site_id;
13967
+ this.powerSiteType = record.power_site_type;
13968
+ this.devices = devices;
13969
+ }
13970
+ /** The member device with this serial, if it belongs to the site. */
13971
+ device(serial) {
13972
+ return this.devices.find((d) => d.serial === serial);
13973
+ }
13974
+ /** Member devices of a given product {@link SolixProductFamily} — the grouping accessor. */
13975
+ withFamily(family) {
13976
+ return this.devices.filter((d) => d.family === family);
13977
+ }
13978
+ /** Member devices that carry a given capability (e.g. every `battery` in the system). */
13979
+ withCapability(capability) {
13980
+ return this.devices.filter((d) => d.has(capability));
13981
+ }
13982
+ /** The Solarbank / plug-in home-battery members of the system. */
13983
+ solarbanks() {
13984
+ return this.withFamily("solarbank");
13985
+ }
13986
+ /** The smart-meter (grid-CT) members of the system. */
13987
+ smartMeters() {
13988
+ return this.withFamily("smartMeter");
13989
+ }
13990
+ /** The portable power-station members of the system. */
13991
+ powerStations() {
13992
+ return this.withFamily("powerStation");
13993
+ }
13994
+ };
13995
+ async function discoverSolixSites(client, opts = {}) {
13996
+ const [sites, records, catalog] = await Promise.all([
13997
+ client.getSites(),
13871
13998
  client.getDevices(),
13872
- opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => [])
13999
+ resolveSolixCatalog(client, opts)
13873
14000
  ]);
13874
- return records.map((r) => new SolixDevice(r, { catalog }));
14001
+ const bySerial = new Map(records.map((r) => [r.device_sn, r]));
14002
+ return sites.map((site) => {
14003
+ const members = (site.site_device_list ?? []).map((entry) => {
14004
+ const record = bySerial.get(entry.device_sn) ?? {
14005
+ device_sn: entry.device_sn,
14006
+ product_code: entry.device_model,
14007
+ device_name: entry.device_name
14008
+ };
14009
+ return new SolixDevice(record, { catalog });
14010
+ });
14011
+ return new SolixSite(site, members);
14012
+ });
13875
14013
  }
13876
14014
 
13877
14015
  // dist/client/map-channels.js
@@ -26366,15 +26504,43 @@ var SOLIX_ENDPOINTS = {
26366
26504
  /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26367
26505
  getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
26368
26506
  /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
26369
- productCategories: "/power_service/v1/product_categories"
26507
+ productCategories: "/power_service/v1/product_categories",
26508
+ /** POST (encrypted+signed): write device attributes, e.g. `{ambient_light_switch: 0|1}`. */
26509
+ setDeviceAttrs: "/power_service/v1/app/device/set_device_attrs",
26510
+ /** POST (plain authed): read device attributes, e.g. the display `screen_off_time` (seconds). */
26511
+ getDeviceAttrs: "/power_service/v1/app/device/get_device_attrs",
26512
+ /** POST (plain authed): the battery discharge-cutoff (minimum-SOC) preset options. */
26513
+ getPowerCutoff: "/power_service/v1/app/compatible/get_power_cutoff",
26514
+ /** POST (encrypted+signed): select the discharge-cutoff preset by `cutoff_data_id`. */
26515
+ setPowerCutoff: "/power_service/v1/app/compatible/set_power_cutoff",
26516
+ /**
26517
+ * POST (plain authed): the site "scene" snapshot — the same clean Solarbank/grid telemetry the app
26518
+ * reads on load/refresh. Used as a low-rate BACKSTOP for the fields the realtime `ff09` push doesn't
26519
+ * carry reliably (notably `bat_temperature`), NOT as the realtime source (that is the MQTT push).
26520
+ */
26521
+ getSiteScene: "/power_service/v2/site/platform_get_site_scene",
26522
+ /**
26523
+ * POST (plain authed): read a site "device param" block by `param_type`. Body is
26524
+ * `{ site_id, param_type, cmd: 246 }`; the response's `data.param_data` is a JSON STRING the caller
26525
+ * parses. The Solarbank's SOC-limit settings live under `param_type "27"` (charge/discharge limits,
26526
+ * backup reserve) — verified live on an AE103 (`"18"` returns empty for this device).
26527
+ */
26528
+ getSiteDeviceParam: "/power_service/v1/site/get_site_device_param",
26529
+ /**
26530
+ * POST (encrypted+signed): write a site "device param" block. Body is
26531
+ * `{ site_id, cmd: 246, param_type, param_data: <JSON string> }`. Used for the SOC-limit write
26532
+ * (`param_type "27"`, `param_data` = the SocSettingParam map) — see {@link SolixClient.setSafetySocParams}.
26533
+ */
26534
+ setSiteDeviceParam: "/power_service/v1/site/set_site_device_param"
26370
26535
  };
26371
26536
 
26372
26537
  // dist/transport/http/solix-client.js
26373
26538
  function solixSessionFresh(s) {
26374
26539
  return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
26375
26540
  }
26541
+ var SOLIX_TOKEN_KICKED_CODE = 26084;
26376
26542
  var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
26377
- var SolixClient = class {
26543
+ var SolixClient = class _SolixClient {
26378
26544
  email;
26379
26545
  password;
26380
26546
  country;
@@ -26552,8 +26718,34 @@ var SolixClient = class {
26552
26718
  await this.estimateHost();
26553
26719
  const kx = await this.keyExchange();
26554
26720
  const env = await this.postLogin(kx);
26721
+ this.assertLoginAccepted(env);
26555
26722
  return this.classifyLogin(this.decryptLogin(env, kx), false);
26556
26723
  }
26724
+ /**
26725
+ * On a rejected `/passport/login` (non-zero code, so `data` is an error envelope not the encrypted
26726
+ * payload), throw a diagnostic that names WHY the passport refused — the throttle (`26161`, "too
26727
+ * frequent") vs a challenge it wants the client to satisfy. The passport marks a required captcha with
26728
+ * a `captcha_id`/`item`; our headless client cannot answer one, so surfacing it distinguishes "wait
26729
+ * out the rate-limit" from "a captcha is required — clear it in the app". No secrets are logged, only
26730
+ * the code, message, and which challenge fields are present.
26731
+ */
26732
+ assertLoginAccepted(env) {
26733
+ if (env.code === 0)
26734
+ return;
26735
+ const d = env.data ?? {};
26736
+ const hints = [];
26737
+ if (typeof d === "object" && d) {
26738
+ if ("captcha_id" in d && d.captcha_id)
26739
+ hints.push("captcha_id present (captcha required)");
26740
+ if ("item" in d && d.item)
26741
+ hints.push(`item=${String(d.item).slice(0, 40)}`);
26742
+ const keys = Object.keys(d);
26743
+ if (keys.length && hints.length === 0)
26744
+ hints.push(`data keys: ${keys.join(",")}`);
26745
+ }
26746
+ const detail = hints.length ? ` [${hints.join("; ")}]` : "";
26747
+ throw new Error(`Solix login (${env.code}): ${env.msg}${detail}`);
26748
+ }
26557
26749
  /** Complete a `2fa` login with the code the passport sent. */
26558
26750
  async submitVerifyCode(code) {
26559
26751
  if (!this.pending2fa)
@@ -26565,14 +26757,26 @@ var SolixClient = class {
26565
26757
  /**
26566
26758
  * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
26567
26759
  * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
26760
+ *
26761
+ * Self-heals a **displaced session**: Anker allows ~one session per account, so another login (the app,
26762
+ * or a second client) invalidates this token and reads then fail with {@link SOLIX_TOKEN_KICKED_CODE}
26763
+ * ("token does not exist because it was kicked out"). On that code this re-logs in once and retries, so
26764
+ * a running client recovers on its own instead of failing every read until its session store is cleared.
26568
26765
  */
26569
- async authed(method2, path, body) {
26766
+ async authed(method2, path, body, reauthed = false) {
26570
26767
  if (!this.session_)
26571
26768
  throw new Error("not authenticated \u2014 call login() first");
26572
26769
  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);
26573
- if (env.code !== 0)
26574
- throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
26575
- return env.data ?? null;
26770
+ if (env.code === 0)
26771
+ return env.data ?? null;
26772
+ if (env.code === SOLIX_TOKEN_KICKED_CODE && !reauthed) {
26773
+ this.session_ = void 0;
26774
+ const r = await this.login();
26775
+ if (r.status !== "ok")
26776
+ throw new Error(`Solix ${path}: session was kicked and re-login did not complete (${r.status})`);
26777
+ return this.authed(method2, path, body, true);
26778
+ }
26779
+ throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
26576
26780
  }
26577
26781
  /**
26578
26782
  * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
@@ -26583,15 +26787,35 @@ var SolixClient = class {
26583
26787
  const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
26584
26788
  return Array.isArray(data) ? data : data?.data ?? [];
26585
26789
  }
26586
- /** The account's sites (systems); devices are typically grouped under a site. */
26790
+ /**
26791
+ * The account's sites (systems); devices are grouped under a site. Each record carries its
26792
+ * `site_device_list` (the member devices), which {@link discoverSolixSites} resolves into a
26793
+ * capability-driven `SolixSite`. Asserted to {@link SolixSiteRecord} at this trust boundary — and
26794
+ * `site_id` (the one field the model layer keys a `SolixSite` on) is validated here, so a record the
26795
+ * cloud returns without a usable id is dropped rather than surfacing a `SolixSite` with `id ===
26796
+ * undefined`; every other field is optional and read defensively.
26797
+ */
26587
26798
  async getSites() {
26588
26799
  const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
26589
- return data?.site_list ?? [];
26800
+ const list = data?.site_list ?? [];
26801
+ return list.filter((s) => typeof s?.site_id === "string" && s.site_id.length > 0);
26590
26802
  }
26591
26803
  /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26592
26804
  async getUserMqttInfo() {
26593
26805
  return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
26594
26806
  }
26807
+ /**
26808
+ * Read a site's "scene" snapshot — the app's dashboard read for a system, a plain authed read. Its
26809
+ * battery detail (`solarbank_info.solarbank_list[]`) carries clean, correctly-named fields including
26810
+ * `bat_temperature`, which the realtime `ff09` MQTT push does NOT reliably carry (the fast frame's BMS
26811
+ * blob is empty, so the decoder withholds temperature). This is therefore a low-rate BACKSTOP for those
26812
+ * gap fields — NOT the realtime source: live power/SOC still come from the MQTT push (which is what the
26813
+ * app itself refreshes from every ~5 s; there is no clean-JSON scene PUSH). Verified live against the
26814
+ * `ff09` floats — the two agree to the watt at the same instant.
26815
+ */
26816
+ async getSiteScene(siteId) {
26817
+ return this.authed("POST", SOLIX_ENDPOINTS.getSiteScene, { site_id: siteId });
26818
+ }
26595
26819
  /**
26596
26820
  * The pairable-product catalog (categories → products). This is Anker's product registry, not the
26597
26821
  * account's devices — fetch it to label a discovered device's model code with a marketing name and
@@ -26601,6 +26825,166 @@ var SolixClient = class {
26601
26825
  async getProductCatalog() {
26602
26826
  return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
26603
26827
  }
26828
+ /**
26829
+ * Write device attributes — a CONTROL write, e.g. the Solarbank ambient light
26830
+ * `{ ambient_light_switch: 0 | 1 }` (0 = on, 1 = off). Unlike the plain authenticated reads, a write
26831
+ * must be **encrypted + signed** with a freshly negotiated `algo_ecdh` key: the gateway accepts an
26832
+ * unsigned write with `code 0` but the device never applies it. The token-bearing request also must
26833
+ * NOT carry the device id (`openudid`), or the gateway answers `401 token error`. Both verified live
26834
+ * on an AE103 (the LED-enable bit in the `ba` telemetry flips exactly as commanded).
26835
+ */
26836
+ async setDeviceAttrs(deviceSn, attributes) {
26837
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setDeviceAttrs, "set_device_attrs", {
26838
+ device_sn: deviceSn,
26839
+ attributes
26840
+ });
26841
+ }
26842
+ /**
26843
+ * A CONTROL write: `algo_ecdh`-encrypted + signed, token-bearing but WITHOUT `openudid`. Every device
26844
+ * control the account performs (set_device_attrs, set_power_cutoff, …) goes through this — the gateway
26845
+ * accepts an unsigned/plain write with `code 0` but the device never applies it, and adding `openudid`
26846
+ * to the token-bearing request returns `401 token error`. Both verified live on an AE103.
26847
+ */
26848
+ async encryptedWrite(path, label, payload) {
26849
+ if (!this.session_)
26850
+ throw new Error("not authenticated \u2014 call login() first");
26851
+ const kx = await this.keyExchange();
26852
+ const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
26853
+ const ts = nowSec();
26854
+ const once = genId();
26855
+ const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
26856
+ "x-encryption-info": "algo_ecdh",
26857
+ "x-key-ident": kx.keyIdent,
26858
+ "x-request-ts": ts,
26859
+ "x-request-once": once,
26860
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26861
+ "x-auth-token": this.session_.authToken,
26862
+ gtoken: this.session_.gtoken
26863
+ }));
26864
+ if (env.code !== 0)
26865
+ throw new Error(`Solix ${label} failed (${env.code}): ${env.msg}`);
26866
+ }
26867
+ /** Turn the Solarbank's ambient LED on/off — a confirmed `set_device_attrs` write. */
26868
+ async setAmbientLight(deviceSn, on) {
26869
+ await this.setDeviceAttrs(deviceSn, { ambient_light_switch: on ? 0 : 1 });
26870
+ }
26871
+ /**
26872
+ * Read device attributes — a plain authenticated read (unlike the encrypted write). `attributes`
26873
+ * names the keys to fetch (e.g. `["screen_off_time"]`); an empty list asks for the device's default
26874
+ * set. Returns the gateway's attribute map as-is (values are device-typed — numbers, strings). Used
26875
+ * to reflect a control's live state, e.g. the display/light off-timeout.
26876
+ */
26877
+ async getDeviceAttrs(deviceSn, attributes = []) {
26878
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getDeviceAttrs, { device_sn: deviceSn, attributes });
26879
+ if (data && typeof data === "object" && "attributes" in data && data.attributes) {
26880
+ return data.attributes;
26881
+ }
26882
+ return data ?? {};
26883
+ }
26884
+ /**
26885
+ * Set the Solarbank display's screen-off timeout, in SECONDS (`screen_off_time`). The app's picker
26886
+ * offers 10/20/30 s and 1/5/30 min; the LCD backlight — and with it the ambient LED that the screen
26887
+ * gates — turns off after this idle period. This is the raw-seconds write; the caller maps its own UI
26888
+ * options to seconds. The "Never" (always-on) sentinel is device-defined and NOT assumed here — pass
26889
+ * the exact integer read back from {@link getDeviceAttrs} while the device is in that mode.
26890
+ */
26891
+ async setScreenOffTime(deviceSn, seconds) {
26892
+ await this.setDeviceAttrs(deviceSn, { screen_off_time: seconds });
26893
+ }
26894
+ /**
26895
+ * Read the Solarbank's battery discharge-cutoff (minimum-SOC) options — a plain authed read.
26896
+ * The gateway returns a preset list (`power_cutoff_data`): each entry is a selectable minimum
26897
+ * state-of-charge `output_cutoff_data` (percent) with its `id` and `is_selected` flag. The caller
26898
+ * presents these options and writes the chosen `id` back via {@link setPowerCutoff} — the values and
26899
+ * ids come from the device, never assumed. `siteId` is optional (the device knows its own cutoff).
26900
+ */
26901
+ async getPowerCutoff(deviceSn, siteId = "") {
26902
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getPowerCutoff, { site_id: siteId, device_sn: deviceSn });
26903
+ return data?.power_cutoff_data ?? [];
26904
+ }
26905
+ /**
26906
+ * Select the Solarbank's battery discharge-cutoff (minimum SOC) by option id — a control write.
26907
+ * `cutoffDataId` MUST be an `id` returned by {@link getPowerCutoff} for this device (the preset the
26908
+ * user picked), never a raw percentage; the gateway maps the id to its cutoff percent.
26909
+ */
26910
+ async setPowerCutoff(deviceSn, cutoffDataId) {
26911
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setPowerCutoff, "set_power_cutoff", {
26912
+ device_sn: deviceSn,
26913
+ cutoff_data_id: cutoffDataId
26914
+ });
26915
+ }
26916
+ /** The `param_type` under which the Solarbank's SOC-limit block lives (verified live on an AE103). */
26917
+ static SOC_PARAM_TYPE = "27";
26918
+ /** `cmd` value that scopes the `site/*_site_device_param` family (from the app's request builder). */
26919
+ static SITE_DEVICE_PARAM_CMD = 246;
26920
+ /**
26921
+ * Read one of a site's "device param" blocks by `param_type` — a plain authenticated read whose
26922
+ * `data.param_data` is itself a JSON STRING (the vendor double-encodes it). Returns the parsed inner
26923
+ * object, or `{}` when the block is empty (the gateway answers `code 0` with an empty `param_data`
26924
+ * for a `param_type` that does not apply to the site's hardware). The caller owns the inner shape.
26925
+ */
26926
+ async getSiteDeviceParam(siteId, paramType) {
26927
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteDeviceParam, {
26928
+ site_id: siteId,
26929
+ param_type: paramType,
26930
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD
26931
+ });
26932
+ const raw = data?.param_data;
26933
+ if (!raw)
26934
+ return {};
26935
+ try {
26936
+ return JSON.parse(raw);
26937
+ } catch {
26938
+ return {};
26939
+ }
26940
+ }
26941
+ /**
26942
+ * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
26943
+ * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
26944
+ * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
26945
+ * app-synced source (and the only source for `chargeUpperLimit` / `backupReserve`). Verified live
26946
+ * against a known AE103 setting (discharge 20 / charge 80).
26947
+ */
26948
+ async getSafetySocParams(siteId) {
26949
+ const p = await this.getSiteDeviceParam(siteId, _SolixClient.SOC_PARAM_TYPE);
26950
+ if (typeof p.charge_upper_limit !== "number" || typeof p.discharge_lower_limit !== "number") {
26951
+ return void 0;
26952
+ }
26953
+ return {
26954
+ chargeUpperLimit: p.charge_upper_limit,
26955
+ dischargeLowerLimit: p.discharge_lower_limit,
26956
+ backupReserve: typeof p.backup_reserve === "number" ? p.backup_reserve : 0,
26957
+ backupReserveSwitch: typeof p.backup_reserve_switch === "number" ? p.backup_reserve_switch : 0,
26958
+ socCalibrationEnable: typeof p.soc_calibration_enable === "number" ? p.soc_calibration_enable : 0
26959
+ };
26960
+ }
26961
+ /**
26962
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
26963
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
26964
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
26965
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
26966
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
26967
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
26968
+ */
26969
+ async setSafetySocParams(siteId, changes) {
26970
+ const current = await this.getSafetySocParams(siteId);
26971
+ if (!current)
26972
+ throw new Error(`Solix set SOC params: site has no param_type 27 block`);
26973
+ const merged = { ...current, ...changes };
26974
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setSiteDeviceParam, "set_site_device_param", {
26975
+ site_id: siteId,
26976
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD,
26977
+ param_type: _SolixClient.SOC_PARAM_TYPE,
26978
+ param_data: JSON.stringify({
26979
+ charge_upper_limit: merged.chargeUpperLimit,
26980
+ discharge_lower_limit: merged.dischargeLowerLimit,
26981
+ backup_reserve_switch: merged.backupReserveSwitch,
26982
+ backup_reserve: merged.backupReserve,
26983
+ soc_calibration_enable: merged.socCalibrationEnable
26984
+ })
26985
+ });
26986
+ return merged;
26987
+ }
26604
26988
  };
26605
26989
 
26606
26990
  // dist/transport/mqtt/solix-mqtt.js
@@ -26620,6 +27004,57 @@ var SOLIX_METER_FIELD_NAMES = {
26620
27004
  180: "meterExportEnergy"
26621
27005
  };
26622
27006
  var SOLIX_METER_PRODUCT_PREFIXES = ["AE1X0"];
27007
+ var SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
27008
+ var SOLIX_SOLARBANK_FIELD_NAMES = {
27009
+ 171: "photovoltaicPower",
27010
+ // total PV input across the strings
27011
+ 172: "batteryPower",
27012
+ 188: "chargePower",
27013
+ 173: "dischargePower",
27014
+ 174: "acPlugPower",
27015
+ 175: "socketPower",
27016
+ // the unit's own on-board AC socket (an appliance plugged into the Solarbank)
27017
+ 196: "gridInputPower",
27018
+ 197: "homeLoadPower",
27019
+ 198: "pv1Power",
27020
+ // the four PV-string inputs (0 when a string is unused / dark)
27021
+ 199: "pv2Power",
27022
+ 200: "pv3Power",
27023
+ 201: "pv4Power"
27024
+ };
27025
+ var SOLIX_STATE_FIELD_NAMES = {
27026
+ 169: "mode",
27027
+ // current operating (EMS) mode (1 custom, 2 self-consumption, 4 rapid charge, 7 smart, 8 dynamic tariff)
27028
+ 170: "maxLoad"
27029
+ // configured max home load (W) — matches get_site_device_param max_load
27030
+ // NOTE `0xab` is grid-in/out-related power but its exact meaning is not yet pinned, so it stays raw
27031
+ // `state_ab` (a diagnostic a consumer can watch) rather than being asserted under a guessed name.
27032
+ };
27033
+ function solixStateReadings(frame) {
27034
+ const out = {};
27035
+ for (const [tag2, value] of frame.fields) {
27036
+ if (tag2 < 165 || !value || value.length < 2)
27037
+ continue;
27038
+ const type = value[0];
27039
+ const pl = value.subarray(1);
27040
+ let num2;
27041
+ if (type === 5 && pl.length >= 4)
27042
+ num2 = pl.readFloatLE(0);
27043
+ else if (type === 2 && pl.length >= 2)
27044
+ num2 = pl.readUInt16LE(0);
27045
+ else if (type === 1 && pl.length >= 1)
27046
+ num2 = pl[0];
27047
+ else if (type === 3 && pl.length >= 2)
27048
+ num2 = pl[1];
27049
+ if (num2 === void 0)
27050
+ continue;
27051
+ out[`state_${tag2.toString(16)}`] = num2;
27052
+ const name = SOLIX_STATE_FIELD_NAMES[tag2];
27053
+ if (name)
27054
+ out[name] = num2;
27055
+ }
27056
+ return out;
27057
+ }
26623
27058
  function readSolixChannel(value) {
26624
27059
  if (!value || value.length < 1)
26625
27060
  return void 0;
@@ -26655,7 +27090,9 @@ function decodeSolixParamFrame(buf) {
26655
27090
  }
26656
27091
  function solixReadings(frame, productCode) {
26657
27092
  const out = {};
26658
- const named = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
27093
+ const isMeter = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
27094
+ const isSolarbank = productCode.startsWith(SOLIX_SOLARBANK_PRODUCT_PREFIX);
27095
+ const floatNames = isMeter ? SOLIX_METER_FIELD_NAMES : isSolarbank ? SOLIX_SOLARBANK_FIELD_NAMES : void 0;
26659
27096
  for (const [tag2, value] of frame.fields) {
26660
27097
  if (tag2 < 166)
26661
27098
  continue;
@@ -26663,12 +27100,30 @@ function solixReadings(frame, productCode) {
26663
27100
  if (ch?.type !== 5 || ch.float === void 0)
26664
27101
  continue;
26665
27102
  out[`channel_${tag2.toString(16)}`] = ch.float;
26666
- const name = named ? SOLIX_METER_FIELD_NAMES[tag2] : void 0;
27103
+ const name = floatNames?.[tag2];
26667
27104
  if (name)
26668
27105
  out[name] = ch.float;
26669
27106
  }
27107
+ if (isSolarbank)
27108
+ addSolarbankScalars(frame, out);
26670
27109
  return out;
26671
27110
  }
27111
+ function addSolarbankScalars(frame, out) {
27112
+ const a3 = frame.fields.get(163);
27113
+ const soc = a3 && a3.length >= 2 ? a3[1] : void 0;
27114
+ if (soc !== void 0) {
27115
+ out.batterySoc = soc;
27116
+ const body = frame.fields.get(164)?.subarray(1);
27117
+ if (body && body.length >= 8 && body[body.length - 6] === soc) {
27118
+ out.batteryTemperature = body[body.length - 8];
27119
+ }
27120
+ }
27121
+ const b5 = frame.fields.get(181);
27122
+ if (b5 && b5[0] === 4 && b5.length === 4) {
27123
+ out.dischargeLimit = b5[1];
27124
+ out.chargeLimit = b5[3];
27125
+ }
27126
+ }
26672
27127
  var SolixMqtt = class extends EventEmitter10 {
26673
27128
  transport;
26674
27129
  appName;
@@ -26712,9 +27167,14 @@ var SolixMqtt = class extends EventEmitter10 {
26712
27167
  * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
26713
27168
  * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
26714
27169
  *
26715
- * Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
26716
- * channels — never the `…/req` channels, which are the app→device request side this arms on, and would
26717
- * echo its own publishes back.
27170
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
27171
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
27172
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
27173
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
27174
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
27175
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
27176
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
27177
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
26718
27178
  *
26719
27179
  * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
26720
27180
  * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
@@ -26727,7 +27187,9 @@ var SolixMqtt = class extends EventEmitter10 {
26727
27187
  const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
26728
27188
  const granted = await this.transport.subscribe([
26729
27189
  topics.paramInfo,
27190
+ topics.stateInfo,
26730
27191
  topics.cmdRes,
27192
+ topics.req,
26731
27193
  ...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
26732
27194
  ]);
26733
27195
  if (!granted.includes(topics.paramInfo)) {
@@ -26752,6 +27214,19 @@ var SolixMqtt = class extends EventEmitter10 {
26752
27214
  this.watched.clear();
26753
27215
  await this.transport.disconnect();
26754
27216
  }
27217
+ /**
27218
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
27219
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
27220
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
27221
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
27222
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
27223
+ */
27224
+ async setDisplayTimeout(device, index) {
27225
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27226
+ const body = this.commandEnvelope(device, buildDisplayTimeoutFrame(index), {});
27227
+ await this.transport.publish(topic, body, { qos: 1 });
27228
+ this.logger?.debug?.(`[solix] display timeout set index=${index} on ${device.device_sn}`);
27229
+ }
26755
27230
  /**
26756
27231
  * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
26757
27232
  * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
@@ -26835,25 +27310,74 @@ var SolixMqtt = class extends EventEmitter10 {
26835
27310
  * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
26836
27311
  * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
26837
27312
  * own `a2` field wins for the serial when it carries one.
27313
+ *
27314
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
27315
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
27316
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
27317
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
27318
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
26838
27319
  */
26839
27320
  onMessage(msg) {
26840
27321
  const topic = msg.topic ?? "";
26841
27322
  const buf = extractFf09Payload(msg.raw);
26842
27323
  if (!buf)
26843
27324
  return;
27325
+ if (topic.endsWith("/req")) {
27326
+ this.handleCommand(topic, buf);
27327
+ return;
27328
+ }
26844
27329
  const frame = decodeSolixParamFrame(buf);
26845
27330
  if (!frame)
26846
27331
  return;
26847
27332
  const parts = topic.split("/");
26848
27333
  const productCode = parts[2] ?? "";
26849
- const reading = {
26850
- deviceSn: frame.deviceSn ?? parts[3] ?? "",
26851
- productCode,
26852
- topic,
26853
- frame,
26854
- values: solixReadings(frame, productCode)
26855
- };
26856
- this.emit("reading", reading);
27334
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27335
+ if (!this.watched.has(deviceSn)) {
27336
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27337
+ if (ofProduct.length === 1)
27338
+ deviceSn = ofProduct[0].device_sn;
27339
+ }
27340
+ const values = topic.endsWith("/state_info") ? solixStateReadings(frame) : (
27341
+ // Pass the product code so meter tag→name binding is applied only to a meter frame; a Solarbank's
27342
+ // tags stay raw channel_<hex> (the model names them per capability) rather than being mislabelled.
27343
+ solixReadings(frame, productCode)
27344
+ );
27345
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27346
+ }
27347
+ /**
27348
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
27349
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
27350
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
27351
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
27352
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
27353
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
27354
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
27355
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
27356
+ */
27357
+ handleCommand(topic, buf) {
27358
+ if (buf.length < 10 || buf[8] !== 104)
27359
+ return;
27360
+ const frame = decodeSolixParamFrame(buf);
27361
+ if (!frame)
27362
+ return;
27363
+ const values = {};
27364
+ const a4 = frame.fields.get(164);
27365
+ if (a4 && a4.length >= 2)
27366
+ values.ambientLightOn = a4[1] === 0 ? 1 : 0;
27367
+ const a5 = frame.fields.get(165);
27368
+ if (a5 && a5.length >= 2)
27369
+ values.displayTimeoutIndex = a5[1];
27370
+ if (Object.keys(values).length === 0)
27371
+ return;
27372
+ const parts = topic.split("/");
27373
+ const productCode = parts[2] ?? "";
27374
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27375
+ if (!this.watched.has(deviceSn)) {
27376
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27377
+ if (ofProduct.length === 1)
27378
+ deviceSn = ofProduct[0].device_sn;
27379
+ }
27380
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
26857
27381
  }
26858
27382
  };
26859
27383
  function extractFf09Payload(raw) {
@@ -26877,6 +27401,19 @@ function extractFf09Payload(raw) {
26877
27401
  const buf = Buffer.from(data, "base64");
26878
27402
  return buf.length ? buf : null;
26879
27403
  }
27404
+ function buildDisplayTimeoutFrame(index) {
27405
+ const body = Buffer.from([3, 0, 15, 0, 104, 161, 1, 34, 165, 2, 1, index & 255]);
27406
+ const frame = Buffer.alloc(body.length + 5);
27407
+ frame[0] = 255;
27408
+ frame[1] = 9;
27409
+ frame.writeUInt16LE(frame.length, 2);
27410
+ body.copy(frame, 4);
27411
+ let xor = 0;
27412
+ for (let i = 0; i < frame.length - 1; i++)
27413
+ xor ^= frame[i];
27414
+ frame[frame.length - 1] = xor;
27415
+ return frame;
27416
+ }
26880
27417
  function buildFf09Request(variant, atUnixSec) {
26881
27418
  const ts = Buffer.alloc(4);
26882
27419
  ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
@@ -27070,6 +27607,7 @@ export {
27070
27607
  SolixClient,
27071
27608
  SolixDevice,
27072
27609
  SolixMqtt,
27610
+ SolixSite,
27073
27611
  StateConvergenceError,
27074
27612
  StationKeyUnavailableError,
27075
27613
  StationUnreachableError,
@@ -27142,6 +27680,7 @@ export {
27142
27680
  detectionName,
27143
27681
  discoverReachableInstance,
27144
27682
  discoverSolixDevices,
27683
+ discoverSolixSites,
27145
27684
  encodeAiDetectType,
27146
27685
  encodeVarint,
27147
27686
  encryptBody,
@@ -27170,6 +27709,9 @@ export {
27170
27709
  isNotAuthorized,
27171
27710
  isPrivateIpv4,
27172
27711
  isSessionValid,
27712
+ isSolixPowerStation,
27713
+ isSolixSmartMeter,
27714
+ isSolixSolarbank,
27173
27715
  isV1Image,
27174
27716
  isV2Image,
27175
27717
  jpegGeometry,
@@ -27224,7 +27766,9 @@ export {
27224
27766
  secureTopic,
27225
27767
  signKey,
27226
27768
  signRequest,
27769
+ solarbankSceneReadings,
27227
27770
  solixDeviceTopics,
27771
+ solixProductFamily,
27228
27772
  solixUserTopics,
27229
27773
  structuralEqual,
27230
27774
  subscribeTopics,