@mega-yfue/eufy-sdk 0.2.0-beta.17 → 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/core/solix-types.d.ts +79 -0
- package/dist/index.js +565 -29
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/solix.d.ts +12 -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/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
|
@@ -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 {
|
|
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` };
|
|
@@ -13724,7 +13729,14 @@ var SOLIX_ENERGY_METER_MEMBERS = {
|
|
|
13724
13729
|
};
|
|
13725
13730
|
var CATEGORY_CAPABILITIES = {
|
|
13726
13731
|
"Portable Power Station": ["battery", "acOutput", "solarInput"],
|
|
13727
|
-
|
|
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"],
|
|
13728
13740
|
"Powered Cooler": ["battery", "cooler"],
|
|
13729
13741
|
"Power Bank": ["battery"],
|
|
13730
13742
|
"Smart EV Charger": ["evCharger"],
|
|
@@ -13732,7 +13744,7 @@ var CATEGORY_CAPABILITIES = {
|
|
|
13732
13744
|
Accessory: []
|
|
13733
13745
|
};
|
|
13734
13746
|
var SOLIX_METER_MODELS = ["AE1X0"];
|
|
13735
|
-
var SOLARBANK_MODELS = ["A1790", "A17C"];
|
|
13747
|
+
var SOLARBANK_MODELS = ["A1790", "A17C", "AE10"];
|
|
13736
13748
|
function detectSolixCapabilities(rec, category) {
|
|
13737
13749
|
const caps = /* @__PURE__ */ new Set(["identity"]);
|
|
13738
13750
|
if (rec.device_sw_version)
|
|
@@ -13755,7 +13767,7 @@ function buildModelIndex(categories) {
|
|
|
13755
13767
|
const index = /* @__PURE__ */ new Map();
|
|
13756
13768
|
for (const category of categories) {
|
|
13757
13769
|
for (const product of category.products ?? []) {
|
|
13758
|
-
const entry = { name: product.name, category: category.name };
|
|
13770
|
+
const entry = { name: product.name, category: category.name.trim() };
|
|
13759
13771
|
if (product.product_code)
|
|
13760
13772
|
index.set(product.product_code, entry);
|
|
13761
13773
|
for (const variant of product.p_codes ?? []) {
|
|
@@ -13768,6 +13780,26 @@ function buildModelIndex(categories) {
|
|
|
13768
13780
|
return index;
|
|
13769
13781
|
}
|
|
13770
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
|
+
|
|
13771
13803
|
// dist/model/solix-device.js
|
|
13772
13804
|
var READ_ONLY_SINK = { dispatch: async () => {
|
|
13773
13805
|
} };
|
|
@@ -13787,10 +13819,19 @@ var SolixDevice = class {
|
|
|
13787
13819
|
serial: record.device_sn,
|
|
13788
13820
|
productCode: record.product_code,
|
|
13789
13821
|
name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
|
|
13790
|
-
category: label?.category
|
|
13822
|
+
category: label?.category,
|
|
13823
|
+
family: solixProductFamily({ product_code: record.product_code, category: label?.category })
|
|
13791
13824
|
};
|
|
13792
13825
|
this.caps = detectSolixCapabilities(record, this.identity_.category);
|
|
13793
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
|
+
}
|
|
13794
13835
|
/** All capabilities this device carries. */
|
|
13795
13836
|
get capabilities() {
|
|
13796
13837
|
return [...this.caps];
|
|
@@ -13866,12 +13907,101 @@ var SolixDevice = class {
|
|
|
13866
13907
|
return tags;
|
|
13867
13908
|
}
|
|
13868
13909
|
};
|
|
13910
|
+
function resolveSolixCatalog(client, opts) {
|
|
13911
|
+
return opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => []);
|
|
13912
|
+
}
|
|
13869
13913
|
async function discoverSolixDevices(client, opts = {}) {
|
|
13870
|
-
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(),
|
|
13871
13990
|
client.getDevices(),
|
|
13872
|
-
|
|
13991
|
+
resolveSolixCatalog(client, opts)
|
|
13873
13992
|
]);
|
|
13874
|
-
|
|
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
|
+
});
|
|
13875
14005
|
}
|
|
13876
14006
|
|
|
13877
14007
|
// dist/client/map-channels.js
|
|
@@ -26366,15 +26496,43 @@ var SOLIX_ENDPOINTS = {
|
|
|
26366
26496
|
/** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
|
|
26367
26497
|
getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
|
|
26368
26498
|
/** GET: the pairable-product catalog (categories → products), for labelling model codes. */
|
|
26369
|
-
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"
|
|
26370
26527
|
};
|
|
26371
26528
|
|
|
26372
26529
|
// dist/transport/http/solix-client.js
|
|
26373
26530
|
function solixSessionFresh(s) {
|
|
26374
26531
|
return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
|
|
26375
26532
|
}
|
|
26533
|
+
var SOLIX_TOKEN_KICKED_CODE = 26084;
|
|
26376
26534
|
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 {
|
|
26535
|
+
var SolixClient = class _SolixClient {
|
|
26378
26536
|
email;
|
|
26379
26537
|
password;
|
|
26380
26538
|
country;
|
|
@@ -26552,8 +26710,34 @@ var SolixClient = class {
|
|
|
26552
26710
|
await this.estimateHost();
|
|
26553
26711
|
const kx = await this.keyExchange();
|
|
26554
26712
|
const env = await this.postLogin(kx);
|
|
26713
|
+
this.assertLoginAccepted(env);
|
|
26555
26714
|
return this.classifyLogin(this.decryptLogin(env, kx), false);
|
|
26556
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
|
+
}
|
|
26557
26741
|
/** Complete a `2fa` login with the code the passport sent. */
|
|
26558
26742
|
async submitVerifyCode(code) {
|
|
26559
26743
|
if (!this.pending2fa)
|
|
@@ -26565,14 +26749,26 @@ var SolixClient = class {
|
|
|
26565
26749
|
/**
|
|
26566
26750
|
* One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
|
|
26567
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.
|
|
26568
26757
|
*/
|
|
26569
|
-
async authed(method2, path, body) {
|
|
26758
|
+
async authed(method2, path, body, reauthed = false) {
|
|
26570
26759
|
if (!this.session_)
|
|
26571
26760
|
throw new Error("not authenticated \u2014 call login() first");
|
|
26572
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);
|
|
26573
|
-
if (env.code
|
|
26574
|
-
|
|
26575
|
-
|
|
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}`);
|
|
26576
26772
|
}
|
|
26577
26773
|
/**
|
|
26578
26774
|
* The account's bound Solix devices (flat list; may be empty when devices live under sites). The
|
|
@@ -26583,15 +26779,35 @@ var SolixClient = class {
|
|
|
26583
26779
|
const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
|
|
26584
26780
|
return Array.isArray(data) ? data : data?.data ?? [];
|
|
26585
26781
|
}
|
|
26586
|
-
/**
|
|
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
|
+
*/
|
|
26587
26790
|
async getSites() {
|
|
26588
26791
|
const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
|
|
26589
|
-
|
|
26792
|
+
const list = data?.site_list ?? [];
|
|
26793
|
+
return list.filter((s) => typeof s?.site_id === "string" && s.site_id.length > 0);
|
|
26590
26794
|
}
|
|
26591
26795
|
/** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
|
|
26592
26796
|
async getUserMqttInfo() {
|
|
26593
26797
|
return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
|
|
26594
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
|
+
}
|
|
26595
26811
|
/**
|
|
26596
26812
|
* The pairable-product catalog (categories → products). This is Anker's product registry, not the
|
|
26597
26813
|
* account's devices — fetch it to label a discovered device's model code with a marketing name and
|
|
@@ -26601,6 +26817,166 @@ var SolixClient = class {
|
|
|
26601
26817
|
async getProductCatalog() {
|
|
26602
26818
|
return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
|
|
26603
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
|
+
}
|
|
26604
26980
|
};
|
|
26605
26981
|
|
|
26606
26982
|
// dist/transport/mqtt/solix-mqtt.js
|
|
@@ -26620,6 +26996,57 @@ var SOLIX_METER_FIELD_NAMES = {
|
|
|
26620
26996
|
180: "meterExportEnergy"
|
|
26621
26997
|
};
|
|
26622
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"
|
|
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
|
+
}
|
|
26623
27050
|
function readSolixChannel(value) {
|
|
26624
27051
|
if (!value || value.length < 1)
|
|
26625
27052
|
return void 0;
|
|
@@ -26655,7 +27082,9 @@ function decodeSolixParamFrame(buf) {
|
|
|
26655
27082
|
}
|
|
26656
27083
|
function solixReadings(frame, productCode) {
|
|
26657
27084
|
const out = {};
|
|
26658
|
-
const
|
|
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;
|
|
26659
27088
|
for (const [tag2, value] of frame.fields) {
|
|
26660
27089
|
if (tag2 < 166)
|
|
26661
27090
|
continue;
|
|
@@ -26663,12 +27092,30 @@ function solixReadings(frame, productCode) {
|
|
|
26663
27092
|
if (ch?.type !== 5 || ch.float === void 0)
|
|
26664
27093
|
continue;
|
|
26665
27094
|
out[`channel_${tag2.toString(16)}`] = ch.float;
|
|
26666
|
-
const name =
|
|
27095
|
+
const name = floatNames?.[tag2];
|
|
26667
27096
|
if (name)
|
|
26668
27097
|
out[name] = ch.float;
|
|
26669
27098
|
}
|
|
27099
|
+
if (isSolarbank)
|
|
27100
|
+
addSolarbankScalars(frame, out);
|
|
26670
27101
|
return out;
|
|
26671
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
|
+
}
|
|
26672
27119
|
var SolixMqtt = class extends EventEmitter10 {
|
|
26673
27120
|
transport;
|
|
26674
27121
|
appName;
|
|
@@ -26712,9 +27159,14 @@ var SolixMqtt = class extends EventEmitter10 {
|
|
|
26712
27159
|
* Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
|
|
26713
27160
|
* start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
|
|
26714
27161
|
*
|
|
26715
|
-
* Subscribes
|
|
26716
|
-
*
|
|
26717
|
-
*
|
|
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.
|
|
26718
27170
|
*
|
|
26719
27171
|
* Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
|
|
26720
27172
|
* than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
|
|
@@ -26727,7 +27179,9 @@ var SolixMqtt = class extends EventEmitter10 {
|
|
|
26727
27179
|
const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
|
|
26728
27180
|
const granted = await this.transport.subscribe([
|
|
26729
27181
|
topics.paramInfo,
|
|
27182
|
+
topics.stateInfo,
|
|
26730
27183
|
topics.cmdRes,
|
|
27184
|
+
topics.req,
|
|
26731
27185
|
...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
|
|
26732
27186
|
]);
|
|
26733
27187
|
if (!granted.includes(topics.paramInfo)) {
|
|
@@ -26752,6 +27206,19 @@ var SolixMqtt = class extends EventEmitter10 {
|
|
|
26752
27206
|
this.watched.clear();
|
|
26753
27207
|
await this.transport.disconnect();
|
|
26754
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
|
+
}
|
|
26755
27222
|
/**
|
|
26756
27223
|
* Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
|
|
26757
27224
|
* a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
|
|
@@ -26835,25 +27302,74 @@ var SolixMqtt = class extends EventEmitter10 {
|
|
|
26835
27302
|
* Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
|
|
26836
27303
|
* product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
|
|
26837
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.
|
|
26838
27311
|
*/
|
|
26839
27312
|
onMessage(msg) {
|
|
26840
27313
|
const topic = msg.topic ?? "";
|
|
26841
27314
|
const buf = extractFf09Payload(msg.raw);
|
|
26842
27315
|
if (!buf)
|
|
26843
27316
|
return;
|
|
27317
|
+
if (topic.endsWith("/req")) {
|
|
27318
|
+
this.handleCommand(topic, buf);
|
|
27319
|
+
return;
|
|
27320
|
+
}
|
|
26844
27321
|
const frame = decodeSolixParamFrame(buf);
|
|
26845
27322
|
if (!frame)
|
|
26846
27323
|
return;
|
|
26847
27324
|
const parts = topic.split("/");
|
|
26848
27325
|
const productCode = parts[2] ?? "";
|
|
26849
|
-
|
|
26850
|
-
|
|
26851
|
-
productCode
|
|
26852
|
-
|
|
26853
|
-
|
|
26854
|
-
|
|
26855
|
-
|
|
26856
|
-
|
|
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 });
|
|
26857
27373
|
}
|
|
26858
27374
|
};
|
|
26859
27375
|
function extractFf09Payload(raw) {
|
|
@@ -26877,6 +27393,19 @@ function extractFf09Payload(raw) {
|
|
|
26877
27393
|
const buf = Buffer.from(data, "base64");
|
|
26878
27394
|
return buf.length ? buf : null;
|
|
26879
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
|
+
}
|
|
26880
27409
|
function buildFf09Request(variant, atUnixSec) {
|
|
26881
27410
|
const ts = Buffer.alloc(4);
|
|
26882
27411
|
ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
|
|
@@ -27070,6 +27599,7 @@ export {
|
|
|
27070
27599
|
SolixClient,
|
|
27071
27600
|
SolixDevice,
|
|
27072
27601
|
SolixMqtt,
|
|
27602
|
+
SolixSite,
|
|
27073
27603
|
StateConvergenceError,
|
|
27074
27604
|
StationKeyUnavailableError,
|
|
27075
27605
|
StationUnreachableError,
|
|
@@ -27142,6 +27672,7 @@ export {
|
|
|
27142
27672
|
detectionName,
|
|
27143
27673
|
discoverReachableInstance,
|
|
27144
27674
|
discoverSolixDevices,
|
|
27675
|
+
discoverSolixSites,
|
|
27145
27676
|
encodeAiDetectType,
|
|
27146
27677
|
encodeVarint,
|
|
27147
27678
|
encryptBody,
|
|
@@ -27170,6 +27701,9 @@ export {
|
|
|
27170
27701
|
isNotAuthorized,
|
|
27171
27702
|
isPrivateIpv4,
|
|
27172
27703
|
isSessionValid,
|
|
27704
|
+
isSolixPowerStation,
|
|
27705
|
+
isSolixSmartMeter,
|
|
27706
|
+
isSolixSolarbank,
|
|
27173
27707
|
isV1Image,
|
|
27174
27708
|
isV2Image,
|
|
27175
27709
|
jpegGeometry,
|
|
@@ -27224,7 +27758,9 @@ export {
|
|
|
27224
27758
|
secureTopic,
|
|
27225
27759
|
signKey,
|
|
27226
27760
|
signRequest,
|
|
27761
|
+
solarbankSceneReadings,
|
|
27227
27762
|
solixDeviceTopics,
|
|
27763
|
+
solixProductFamily,
|
|
27228
27764
|
solixUserTopics,
|
|
27229
27765
|
structuralEqual,
|
|
27230
27766
|
subscribeTopics,
|