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