@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/core/solix-types.d.ts +79 -0
- package/dist/index.js +577 -33
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/solix.d.ts +12 -1
- package/dist/model/capabilities/types.d.ts +5 -1
- package/dist/model/index.d.ts +3 -1
- package/dist/model/solix-catalog.d.ts +5 -0
- package/dist/model/solix-device.d.ts +35 -1
- package/dist/model/solix-family.d.ts +31 -0
- package/dist/model/solix-site.d.ts +70 -0
- package/dist/transport/http/mega-client.d.ts +17 -4
- package/dist/transport/http/solix-client.d.ts +109 -3
- package/dist/transport/http/solix-constants.d.ts +27 -0
- package/dist/transport/mqtt/solix-mqtt.d.ts +89 -6
- package/dist/transport/mqtt/topics.d.ts +13 -3
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1244,12 +1244,20 @@ var MegaHttpClient = class {
|
|
|
1244
1244
|
return this.region;
|
|
1245
1245
|
}
|
|
1246
1246
|
/**
|
|
1247
|
-
* The
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
13999
|
+
resolveSolixCatalog(client, opts)
|
|
13873
14000
|
]);
|
|
13874
|
-
|
|
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
|
|
26574
|
-
|
|
26575
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
26716
|
-
*
|
|
26717
|
-
*
|
|
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
|
-
|
|
26850
|
-
|
|
26851
|
-
productCode
|
|
26852
|
-
|
|
26853
|
-
|
|
26854
|
-
|
|
26855
|
-
|
|
26856
|
-
|
|
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,
|