@mega-yfue/eufy-sdk 0.2.0-beta.0 → 0.2.0-beta.10
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/README.md +17 -41
- package/dist/client/eufy-mega.d.ts +33 -6
- package/dist/core/contracts.d.ts +58 -3
- package/dist/core/crypto.d.ts +10 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/logger.d.ts +5 -3
- package/dist/core/solix-types.d.ts +36 -0
- package/dist/core/store.d.ts +20 -9
- package/dist/index.js +1229 -102
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/arming.d.ts +58 -28
- package/dist/model/capabilities/display.d.ts +85 -0
- package/dist/model/capabilities/index.d.ts +11 -5
- package/dist/model/capabilities/solix.d.ts +75 -0
- package/dist/model/capabilities/types.d.ts +16 -4
- package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
- package/dist/model/index.d.ts +3 -0
- package/dist/model/param-dictionary.d.ts +24 -0
- package/dist/model/param-namespace.d.ts +1 -1
- package/dist/model/solix-catalog.d.ts +20 -0
- package/dist/model/solix-device.d.ts +102 -0
- package/dist/model/types.d.ts +5 -5
- package/dist/transport/ff09.d.ts +7 -0
- package/dist/transport/http/index.d.ts +1 -0
- package/dist/transport/http/mega-client.d.ts +10 -2
- package/dist/transport/http/solix-client.d.ts +158 -0
- package/dist/transport/http/solix-constants.d.ts +29 -0
- package/dist/transport/mqtt/app-client-id.d.ts +9 -3
- package/dist/transport/mqtt/command-router.d.ts +0 -3
- package/dist/transport/mqtt/index.d.ts +2 -0
- package/dist/transport/mqtt/secure-mqtt.d.ts +24 -1
- package/dist/transport/mqtt/solix-mqtt.d.ts +214 -0
- package/dist/transport/mqtt/topics.d.ts +20 -0
- package/dist/transport/p2p/command-router.d.ts +23 -0
- package/dist/transport/p2p/index.d.ts +1 -0
- package/dist/transport/p2p/live-trace.d.ts +100 -6
- package/dist/transport/p2p/media.d.ts +11 -0
- package/dist/transport/p2p/p2p-session.d.ts +4 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { createCipheriv, createDecipheriv, createECDH, createHash, createHmac, r
|
|
|
15
15
|
var P256 = "prime256v1";
|
|
16
16
|
var EUFY_MEGA_LOCAL_KEY_HEX = "2500a7d5617812f9d52515b2c8f20a3d";
|
|
17
17
|
var EUFYLIFE_LOCAL_KEY_HEX = "118c12c81e211149304bd70a0c071d01";
|
|
18
|
+
var SOLIX_LOCAL_KEY_HEX = "e8ad18f61bbd3fbd52d5ed12d14d3b9c";
|
|
18
19
|
var SERVER_STATIC_PUBLIC_KEY_HEX = "04c5c00c4f8d1197cc7c3167c52bf7acb054d722f0ef08dcd7e0883236e0d72a3868d9750cb47fa4619248f3d83f0f662671dadc6e2d31c2f41db0161651c7c076";
|
|
19
20
|
function genId() {
|
|
20
21
|
return randomBytes(16).toString("hex");
|
|
@@ -22,8 +23,11 @@ function genId() {
|
|
|
22
23
|
function nowSec() {
|
|
23
24
|
return Math.floor(Date.now() / 1e3).toString();
|
|
24
25
|
}
|
|
26
|
+
function md5Hex(input) {
|
|
27
|
+
return createHash("md5").update(input, "utf-8").digest("hex");
|
|
28
|
+
}
|
|
25
29
|
function gtoken(userId) {
|
|
26
|
-
return
|
|
30
|
+
return md5Hex(userId);
|
|
27
31
|
}
|
|
28
32
|
function aesKey(shareKeyHex) {
|
|
29
33
|
return Buffer.from(shareKeyHex, "hex").subarray(0, 16);
|
|
@@ -142,13 +146,16 @@ var FileSessionStore = class {
|
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
};
|
|
149
|
+
function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
|
|
150
|
+
if (tokenExpiresAt && tokenExpiresAt > 0) {
|
|
151
|
+
return Math.floor(Date.now() / 1e3) < tokenExpiresAt - skewSec;
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
145
155
|
function isSessionValid(s, skewSec = 300) {
|
|
146
156
|
if (!s?.authToken || !s.shareKey || !s.keyIdent)
|
|
147
157
|
return false;
|
|
148
|
-
|
|
149
|
-
return Math.floor(Date.now() / 1e3) < s.tokenExpiresAt - skewSec;
|
|
150
|
-
}
|
|
151
|
-
return true;
|
|
158
|
+
return tokenNotExpired(s.tokenExpiresAt, skewSec);
|
|
152
159
|
}
|
|
153
160
|
|
|
154
161
|
// dist/core/logger.js
|
|
@@ -967,7 +974,15 @@ var MegaHttpClient = class {
|
|
|
967
974
|
* restored-session short-circuit must NOT treat it as a usable session. Cleared on Ok/reset. */
|
|
968
975
|
pending2fa = false;
|
|
969
976
|
tokenExpiresAt = 0;
|
|
970
|
-
/**
|
|
977
|
+
/**
|
|
978
|
+
* Stable per-install device id: `openudid` as configured, as restored from the session store, or as
|
|
979
|
+
* derived from the ACCOUNT when neither supplied one — two clients that configure none therefore
|
|
980
|
+
* share it, and are one install as far as everything keyed on this is concerned.
|
|
981
|
+
*
|
|
982
|
+
* Two things are keyed on it, and both fail the same way when it is shared: the auth token is bound
|
|
983
|
+
* to it, so each login displaces the other's session, and the secure-MQTT client id is built from it,
|
|
984
|
+
* so each connection evicts the other's channel.
|
|
985
|
+
*/
|
|
971
986
|
openudid;
|
|
972
987
|
/** The device model reported to the cloud (explicit `phoneModel`, else a stable random one). */
|
|
973
988
|
phoneModel;
|
|
@@ -1802,9 +1817,20 @@ function parseSecureTopic(topic) {
|
|
|
1802
1817
|
return void 0;
|
|
1803
1818
|
return { root, category, model, sn, tail: parts.slice(4).join("/") };
|
|
1804
1819
|
}
|
|
1820
|
+
function solixDeviceTopics(appName, productCode, deviceSn) {
|
|
1821
|
+
const dt = `dt/${appName}/${productCode}/${deviceSn}`;
|
|
1822
|
+
const cmd = `cmd/${appName}/${productCode}/${deviceSn}`;
|
|
1823
|
+
return { paramInfo: `${dt}/param_info`, cmdRes: `${cmd}/app/res`, req: `${cmd}/req` };
|
|
1824
|
+
}
|
|
1825
|
+
function solixUserTopics(appName, userId) {
|
|
1826
|
+
return { cmdRes: `cmd/${appName}/${userId}/res`, powerSite: `dt/${appName}/${userId}/power_site` };
|
|
1827
|
+
}
|
|
1805
1828
|
|
|
1806
1829
|
// dist/transport/mqtt/secure-mqtt.js
|
|
1807
1830
|
var SUBACK_FAILURE = 128;
|
|
1831
|
+
function isNotAuthorized(err) {
|
|
1832
|
+
return err instanceof Error && /connection refused/i.test(err.message) && /not authori[sz]ed/i.test(err.message);
|
|
1833
|
+
}
|
|
1808
1834
|
var SecureMqtt = class extends EventEmitter {
|
|
1809
1835
|
kind = "smqtt";
|
|
1810
1836
|
client;
|
|
@@ -1864,6 +1890,7 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1864
1890
|
}
|
|
1865
1891
|
this.emit("connect");
|
|
1866
1892
|
});
|
|
1893
|
+
client.on("connect", () => this.logger.info(`[smqtt] connected (${c.app_name ?? "default"})`));
|
|
1867
1894
|
client.on("reconnect", () => this.logger.warn("[smqtt] reconnecting"));
|
|
1868
1895
|
client.on("close", () => this.emit("disconnect", "close"));
|
|
1869
1896
|
client.on("error", (err) => {
|
|
@@ -1891,7 +1918,7 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1891
1918
|
* four topics for `eufy_life`).
|
|
1892
1919
|
*
|
|
1893
1920
|
* The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
|
|
1894
|
-
*
|
|
1921
|
+
* SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
|
|
1895
1922
|
* whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
|
|
1896
1923
|
* topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
|
|
1897
1924
|
* line that grants its state channel but refuses (say) the OTA leg still works.
|
|
@@ -1900,9 +1927,8 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1900
1927
|
if (!this.client)
|
|
1901
1928
|
throw new Error("SecureMqtt not connected");
|
|
1902
1929
|
const topics = [...subscribeTopics(device)];
|
|
1903
|
-
const
|
|
1930
|
+
const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
|
|
1904
1931
|
const scope = this.o.credentials.app_name ?? "default";
|
|
1905
|
-
const denied = grants.filter((g) => g.qos === SUBACK_FAILURE).map((g) => g.topic);
|
|
1906
1932
|
if (denied.length === topics.length) {
|
|
1907
1933
|
throw new Error(`subscribe ${device.sn}: every topic denied on credential scope "${scope}" \u2014 this line's topics are granted to a different scope (${denied.join(", ")})`);
|
|
1908
1934
|
}
|
|
@@ -1910,6 +1936,29 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1910
1936
|
this.emit("error", new Error(`subscribe ${device.sn}: "${topic}" denied on credential scope "${scope}"`));
|
|
1911
1937
|
}
|
|
1912
1938
|
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
|
|
1941
|
+
* comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
|
|
1942
|
+
* instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
|
|
1943
|
+
* vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
|
|
1944
|
+
*/
|
|
1945
|
+
async subscribe(topics) {
|
|
1946
|
+
if (!this.client)
|
|
1947
|
+
throw new Error("SecureMqtt not connected");
|
|
1948
|
+
return this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 })).granted;
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
|
|
1952
|
+
* SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
|
|
1953
|
+
* this split and layer their own policy (drop vs report) on top.
|
|
1954
|
+
*/
|
|
1955
|
+
partitionGrants(grants) {
|
|
1956
|
+
const granted = [];
|
|
1957
|
+
const denied = [];
|
|
1958
|
+
for (const g of grants)
|
|
1959
|
+
(g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
|
|
1960
|
+
return { granted, denied };
|
|
1961
|
+
}
|
|
1913
1962
|
/**
|
|
1914
1963
|
* Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
|
|
1915
1964
|
* is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the
|
|
@@ -1932,6 +1981,16 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1932
1981
|
}
|
|
1933
1982
|
};
|
|
1934
1983
|
|
|
1984
|
+
// dist/transport/mqtt/app-client-id.js
|
|
1985
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1986
|
+
function buildAppShapedClientId(input) {
|
|
1987
|
+
const ts = input.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
1988
|
+
return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
|
|
1989
|
+
}
|
|
1990
|
+
function mqttUuidFrom(installId) {
|
|
1991
|
+
return createHash4("sha256").update(installId).digest("hex").slice(0, 16);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1935
1994
|
// dist/core/util.js
|
|
1936
1995
|
var Timer = class {
|
|
1937
1996
|
handle;
|
|
@@ -2534,6 +2593,28 @@ var CameraDisabledError = class extends Error {
|
|
|
2534
2593
|
this.name = "CameraDisabledError";
|
|
2535
2594
|
}
|
|
2536
2595
|
};
|
|
2596
|
+
var StationKeyUnavailableError = class extends Error {
|
|
2597
|
+
stationSn;
|
|
2598
|
+
/** Always true: the negotiation is per connection, so a later one may still produce a key. */
|
|
2599
|
+
retryable = true;
|
|
2600
|
+
constructor(stationSn, options) {
|
|
2601
|
+
super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
|
|
2602
|
+
this.stationSn = stationSn;
|
|
2603
|
+
this.name = "StationKeyUnavailableError";
|
|
2604
|
+
}
|
|
2605
|
+
};
|
|
2606
|
+
var StationUnreachableError = class extends Error {
|
|
2607
|
+
stationSn;
|
|
2608
|
+
waitedMs;
|
|
2609
|
+
/** Always true: a station unreachable now may answer on a later attempt. */
|
|
2610
|
+
retryable = true;
|
|
2611
|
+
constructor(stationSn, waitedMs, options) {
|
|
2612
|
+
super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
|
|
2613
|
+
this.stationSn = stationSn;
|
|
2614
|
+
this.waitedMs = waitedMs;
|
|
2615
|
+
this.name = "StationUnreachableError";
|
|
2616
|
+
}
|
|
2617
|
+
};
|
|
2537
2618
|
var StationBusyError = class extends Error {
|
|
2538
2619
|
servingChannel;
|
|
2539
2620
|
/** Always true: the station is busy now, and stops being busy when the other stream is released. */
|
|
@@ -6868,6 +6949,26 @@ var KEYPAD = {
|
|
|
6868
6949
|
// dist/model/capabilities/arming.js
|
|
6869
6950
|
var STATION_CHANNEL2 = 255;
|
|
6870
6951
|
var ArmingMode = {
|
|
6952
|
+
/** Armed — full protection, nobody home (wire value 0). */
|
|
6953
|
+
away: "away",
|
|
6954
|
+
/** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
|
|
6955
|
+
home: "home",
|
|
6956
|
+
/** Scheduled — the station follows the timetable configured in the app (wire value 2). */
|
|
6957
|
+
schedule: "schedule",
|
|
6958
|
+
/** Custom 1 — a user-defined posture configured in the app (wire value 3). */
|
|
6959
|
+
custom1: "custom1",
|
|
6960
|
+
/** Custom 2 — a user-defined posture configured in the app (wire value 4). */
|
|
6961
|
+
custom2: "custom2",
|
|
6962
|
+
/** Custom 3 — a user-defined posture configured in the app (wire value 5). */
|
|
6963
|
+
custom3: "custom3",
|
|
6964
|
+
/** Off — the station's alarm system is switched off entirely (wire value 6). */
|
|
6965
|
+
off: "off",
|
|
6966
|
+
/** Geofenced — the station follows the app's location-based rules (wire value 47). */
|
|
6967
|
+
geo: "geo",
|
|
6968
|
+
/** Disarmed — no alarms; sensors still report state (wire value 63). */
|
|
6969
|
+
disarmed: "disarmed"
|
|
6970
|
+
};
|
|
6971
|
+
var AlarmDelayMode = {
|
|
6871
6972
|
/** Armed — full protection, nobody home (wire value 0). */
|
|
6872
6973
|
away: "away",
|
|
6873
6974
|
/** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
|
|
@@ -6883,10 +6984,11 @@ var ARMING_CMD = {
|
|
|
6883
6984
|
* mValue3:0, `payload:{mode_type:<int>, user_name:<string>}`.
|
|
6884
6985
|
*
|
|
6885
6986
|
* ⚠️ Only 3 of the 9 modes were exercised in that capture — `mode_type` 0 (away), 63 (disarmed), 1
|
|
6886
|
-
* (home), all confirmed byte-exact
|
|
6887
|
-
*
|
|
6888
|
-
*
|
|
6889
|
-
*
|
|
6987
|
+
* (home), all confirmed byte-exact. Re-confirmed live 2026-08-05: each reported its own MODE_SWITCH push
|
|
6988
|
+
* within ~5s of the write. The remaining six are live confirmations rather than captures — `custom1` 3
|
|
6989
|
+
* first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
|
|
6990
|
+
* frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
|
|
6991
|
+
* the per-value evidence and the dates.
|
|
6890
6992
|
*/
|
|
6891
6993
|
SET_ARMING: 1224,
|
|
6892
6994
|
/**
|
|
@@ -6920,21 +7022,14 @@ var ARMING_MODE_WIRE = {
|
|
|
6920
7022
|
away: 0,
|
|
6921
7023
|
home: 1,
|
|
6922
7024
|
schedule: 2,
|
|
6923
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6924
7025
|
custom1: 3,
|
|
6925
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6926
7026
|
custom2: 4,
|
|
6927
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6928
7027
|
custom3: 5,
|
|
6929
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6930
7028
|
off: 6,
|
|
6931
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6932
7029
|
geo: 47,
|
|
6933
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6934
7030
|
disarmed: 63
|
|
6935
7031
|
};
|
|
6936
7032
|
var ARMING_MODE_LABELS = enumLabels(ARMING_MODE_WIRE);
|
|
6937
|
-
var SETTABLE_MODES = Object.values(ArmingMode).map((m) => ARMING_MODE_WIRE[m]);
|
|
6938
7033
|
function armingModeOf(v) {
|
|
6939
7034
|
const name = String(v);
|
|
6940
7035
|
if (name in ArmingMode)
|
|
@@ -6943,11 +7038,10 @@ function armingModeOf(v) {
|
|
|
6943
7038
|
return Object.values(ArmingMode).find((m) => ARMING_MODE_WIRE[m] === wire);
|
|
6944
7039
|
}
|
|
6945
7040
|
function armingCommand(mode, ctx) {
|
|
6946
|
-
const modeType = ARMING_MODE_WIRE[mode];
|
|
6947
7041
|
if (!ctx.accountName) {
|
|
6948
7042
|
throw new Error(`arming: missing account identity (user_name) [${describeDevice(ctx)}]`);
|
|
6949
7043
|
}
|
|
6950
|
-
return setPayload(ARMING_CMD.SET_ARMING, { mode_type:
|
|
7044
|
+
return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0);
|
|
6951
7045
|
}
|
|
6952
7046
|
function alarmDelayCommand(mode, config, ctx) {
|
|
6953
7047
|
const data = {
|
|
@@ -6964,11 +7058,10 @@ function alarmDelayCommand(mode, config, ctx) {
|
|
|
6964
7058
|
}
|
|
6965
7059
|
var ARMING_MEMBERS = {
|
|
6966
7060
|
/**
|
|
6967
|
-
*
|
|
6968
|
-
*
|
|
6969
|
-
*
|
|
6970
|
-
*
|
|
6971
|
-
* declaration.
|
|
7061
|
+
* Read and write are the same nine modes, so `enumValues` is the whole domain: `writeDomain` falls back
|
|
7062
|
+
* to it, and the derived setter, the refusal message and the offered argument all read from that one
|
|
7063
|
+
* declaration. A member states an `args` entry only where the two sides DIFFER. See
|
|
7064
|
+
* {@link ARMING_MODE_WIRE} for the per-value evidence.
|
|
6972
7065
|
*
|
|
6973
7066
|
* `armingCommand` may also throw synchronously (missing account identity) and `bindMembers` turns that
|
|
6974
7067
|
* into a rejection, so the builder stays plain.
|
|
@@ -6987,8 +7080,7 @@ var ARMING_MEMBERS = {
|
|
|
6987
7080
|
kind: "enum",
|
|
6988
7081
|
enumValues: ARMING_MODE_LABELS,
|
|
6989
7082
|
provenance: "verified",
|
|
6990
|
-
|
|
6991
|
-
description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads all 9 modes the app defines; SETS only the 3 whose write is wire-captured (away/home/disarmed) \u2014 schedule/custom1/custom2/custom3/off/geo are named by the app but no capture shows one being sent, so they are refused rather than guessed; see ARMING_MODE_WIRE in arming.ts for the breakdown.",
|
|
7083
|
+
description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads and SETS all 9 modes the app defines \u2014 away/home/schedule/custom1/custom2/custom3/off/geo/disarmed. Three are byte-captured writes and six are live-confirmed (each sent and observed to report its own MODE_SWITCH); see ARMING_MODE_WIRE in arming.ts for the per-value evidence.",
|
|
6992
7084
|
observation: {
|
|
6993
7085
|
event: "armingModeChanged",
|
|
6994
7086
|
reflects: (value) => ({ param: ARMING_CMD.SET_ARMING, expected: ARMING_MODE_WIRE[armingModeOf(value)] }),
|
|
@@ -7009,9 +7101,10 @@ var ARMING_MEMBERS = {
|
|
|
7009
7101
|
* this mode — there is no known GET to fetch it automatically, and a wrong guess here can silently
|
|
7010
7102
|
* misconfigure which sensors arm/trigger for real.
|
|
7011
7103
|
*
|
|
7012
|
-
* Takes {@link
|
|
7013
|
-
* captured
|
|
7014
|
-
* `
|
|
7104
|
+
* Takes {@link AlarmDelayMode}, not {@link ArmingMode}: a delay is configurable only for a mode whose
|
|
7105
|
+
* integer is captured on THIS command, and `custom1` is confirmed on cmd 1224 only. The frame carries
|
|
7106
|
+
* that integer in `mode_id` with no runtime validation and no readback, so a mode outside this union
|
|
7107
|
+
* would be the same unverified guess `setMode` refuses.
|
|
7015
7108
|
*/
|
|
7016
7109
|
setAlarmDelayConfig: method(({ ctx, sink }) => (mode, config) => {
|
|
7017
7110
|
try {
|
|
@@ -7682,6 +7775,89 @@ var ModeCtrlMethod = {
|
|
|
7682
7775
|
STOP_SMART_FOLLOW: 18,
|
|
7683
7776
|
START_GLOBAL_CRUISE: 20
|
|
7684
7777
|
};
|
|
7778
|
+
var ModeCtrlParamMethod = {
|
|
7779
|
+
/** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
|
|
7780
|
+
SELECT_ROOMS: { method: 1, param: 4 },
|
|
7781
|
+
/** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
|
|
7782
|
+
SELECT_ZONES: { method: 2, param: 5 },
|
|
7783
|
+
/** `START_GOTO_CLEAN` — drive to a point and clean around it. */
|
|
7784
|
+
GOTO: { method: 4, param: 7 },
|
|
7785
|
+
/** `START_SCENE_CLEAN` — run a saved scene by its id. */
|
|
7786
|
+
SCENE: { method: 24, param: 14 }
|
|
7787
|
+
};
|
|
7788
|
+
var SELECT_ROOMS_FIELD = {
|
|
7789
|
+
/** `rooms` — repeated, one entry per room. */
|
|
7790
|
+
ROOMS: 1,
|
|
7791
|
+
/** `clean_times` — how many passes to make. */
|
|
7792
|
+
CLEAN_TIMES: 2,
|
|
7793
|
+
/** `map_id` — WHICH saved map the room ids belong to. */
|
|
7794
|
+
MAP_ID: 3,
|
|
7795
|
+
/** `id` within a `Room`. */
|
|
7796
|
+
ROOM_ID: 1,
|
|
7797
|
+
/** `order` within a `Room` — the sequence to visit them in. */
|
|
7798
|
+
ROOM_ORDER: 2
|
|
7799
|
+
};
|
|
7800
|
+
var SELECT_ZONES_FIELD = {
|
|
7801
|
+
/** `zones` — repeated, one entry per rectangle. */
|
|
7802
|
+
ZONES: 1,
|
|
7803
|
+
/** `map_id` — which saved map the coordinates belong to. */
|
|
7804
|
+
MAP_ID: 2,
|
|
7805
|
+
/** `quadrangle` within a `Zone` — its four corners. */
|
|
7806
|
+
QUADRANGLE: 1,
|
|
7807
|
+
/** `clean_times` within a `Zone`. */
|
|
7808
|
+
ZONE_CLEAN_TIMES: 2,
|
|
7809
|
+
/** `x` within a `Point`, SIGNED centimetres. */
|
|
7810
|
+
POINT_X: 1,
|
|
7811
|
+
/** `y` within a `Point`, SIGNED centimetres. */
|
|
7812
|
+
POINT_Y: 2
|
|
7813
|
+
};
|
|
7814
|
+
var SCENE_CLEAN_ID = 1;
|
|
7815
|
+
function encodeModeCtrlParam(method2, paramField, build) {
|
|
7816
|
+
return rawDp((w) => {
|
|
7817
|
+
if (method2 !== 0)
|
|
7818
|
+
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
7819
|
+
w.int(MODE_CTRL_FIELD.SEQ, nextModeCtrlSeq());
|
|
7820
|
+
w.sub(paramField, build);
|
|
7821
|
+
});
|
|
7822
|
+
}
|
|
7823
|
+
function encodeSelectRoomsClean(mapId, rooms, cleanTimes = 1) {
|
|
7824
|
+
const { method: method2, param } = ModeCtrlParamMethod.SELECT_ROOMS;
|
|
7825
|
+
return encodeModeCtrlParam(method2, param, (p) => {
|
|
7826
|
+
for (const room of rooms) {
|
|
7827
|
+
p.sub(SELECT_ROOMS_FIELD.ROOMS, (r) => {
|
|
7828
|
+
r.int(SELECT_ROOMS_FIELD.ROOM_ID, room.id);
|
|
7829
|
+
if (room.order !== void 0)
|
|
7830
|
+
r.int(SELECT_ROOMS_FIELD.ROOM_ORDER, room.order);
|
|
7831
|
+
});
|
|
7832
|
+
}
|
|
7833
|
+
p.int(SELECT_ROOMS_FIELD.CLEAN_TIMES, cleanTimes);
|
|
7834
|
+
p.int(SELECT_ROOMS_FIELD.MAP_ID, mapId);
|
|
7835
|
+
});
|
|
7836
|
+
}
|
|
7837
|
+
function encodeSelectZonesClean(mapId, zones) {
|
|
7838
|
+
const { method: method2, param } = ModeCtrlParamMethod.SELECT_ZONES;
|
|
7839
|
+
return encodeModeCtrlParam(method2, param, (p) => {
|
|
7840
|
+
for (const zone of zones) {
|
|
7841
|
+
p.sub(SELECT_ZONES_FIELD.ZONES, (z) => {
|
|
7842
|
+
z.sub(SELECT_ZONES_FIELD.QUADRANGLE, (q) => {
|
|
7843
|
+
for (const [i, corner] of zone.corners.entries()) {
|
|
7844
|
+
q.sub(i + 1, (pt) => {
|
|
7845
|
+
pt.sint(SELECT_ZONES_FIELD.POINT_X, corner.x);
|
|
7846
|
+
pt.sint(SELECT_ZONES_FIELD.POINT_Y, corner.y);
|
|
7847
|
+
});
|
|
7848
|
+
}
|
|
7849
|
+
});
|
|
7850
|
+
if (zone.cleanTimes !== void 0)
|
|
7851
|
+
z.int(SELECT_ZONES_FIELD.ZONE_CLEAN_TIMES, zone.cleanTimes);
|
|
7852
|
+
});
|
|
7853
|
+
}
|
|
7854
|
+
p.int(SELECT_ZONES_FIELD.MAP_ID, mapId);
|
|
7855
|
+
});
|
|
7856
|
+
}
|
|
7857
|
+
function encodeSceneClean(sceneId) {
|
|
7858
|
+
const { method: method2, param } = ModeCtrlParamMethod.SCENE;
|
|
7859
|
+
return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
|
|
7860
|
+
}
|
|
7685
7861
|
var modeCtrlSeq = 111;
|
|
7686
7862
|
function nextModeCtrlSeq() {
|
|
7687
7863
|
return ++modeCtrlSeq;
|
|
@@ -9517,7 +9693,41 @@ var VACUUM_CLEAN_MEMBERS = {
|
|
|
9517
9693
|
/** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
|
|
9518
9694
|
returnToDock: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.START_GOHOME, nextModeCtrlSeq()))), "Return to the dock (ModeCtrlRequest method 6 over DP 152).", isAiotVacuum),
|
|
9519
9695
|
/** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
|
|
9520
|
-
pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum)
|
|
9696
|
+
pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum),
|
|
9697
|
+
/**
|
|
9698
|
+
* Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).
|
|
9699
|
+
*
|
|
9700
|
+
* The id is the device's own, as {@link VACUUM_CLEAN_MEMBERS.scenes} reports it — `VacuumScene.id`
|
|
9701
|
+
* off the `SceneResponse` on DP 180. A scene the device reports invalid stays reportable and running
|
|
9702
|
+
* it is still a well-formed request; `VacuumScene.invalidReason` says why the device will refuse.
|
|
9703
|
+
*
|
|
9704
|
+
* Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 24 has been
|
|
9705
|
+
* WATCHED: run on a T2351, it started the named scene.
|
|
9706
|
+
*/
|
|
9707
|
+
startScene: method(({ sink }) => (sceneId) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSceneClean(sceneId))), "Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).", isAiotVacuum),
|
|
9708
|
+
/**
|
|
9709
|
+
* Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
|
|
9710
|
+
*
|
|
9711
|
+
* `mapId` has no default and that is deliberate: room ids are per map, so assuming the map a
|
|
9712
|
+
* single-floor home would have sends a two-floor home's ids against the wrong floor. `SceneInfo.mapid`
|
|
9713
|
+
* on DP 180 and a scheduled rooms-clean's `map_id` are the two real map ids the device reports.
|
|
9714
|
+
*
|
|
9715
|
+
* `cleanTimes` is how many passes to make over the set; rooms with no `order` are visited in the
|
|
9716
|
+
* order given.
|
|
9717
|
+
*
|
|
9718
|
+
* Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 1 has been
|
|
9719
|
+
* WATCHED: run on a T2351, it cleaned the rooms named.
|
|
9720
|
+
*/
|
|
9721
|
+
cleanRooms: method(({ sink }) => (mapId, rooms, cleanTimes = 1) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectRoomsClean(mapId, rooms, cleanTimes))), "Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).", isAiotVacuum),
|
|
9722
|
+
/**
|
|
9723
|
+
* Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).
|
|
9724
|
+
*
|
|
9725
|
+
* Corners are SIGNED centimetres in the map's own frame, whose origin sits wherever the robot first
|
|
9726
|
+
* mapped from — negative coordinates are ordinary and are ZigZag-encoded, not written as plain
|
|
9727
|
+
* varints. Same `mapId` reasoning as {@link VACUUM_CLEAN_MEMBERS.cleanRooms}, and the same evidence:
|
|
9728
|
+
* method 2 was run on a T2351 and cleaned the rectangles given.
|
|
9729
|
+
*/
|
|
9730
|
+
cleanZones: method(({ sink }) => (mapId, zones) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectZonesClean(mapId, zones))), "Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).", isAiotVacuum)
|
|
9521
9731
|
};
|
|
9522
9732
|
var VACUUM_CLEAN = {
|
|
9523
9733
|
capability: "vacuum_clean",
|
|
@@ -9693,6 +9903,70 @@ function toNumberArray(arr) {
|
|
|
9693
9903
|
return result;
|
|
9694
9904
|
}
|
|
9695
9905
|
|
|
9906
|
+
// dist/model/capabilities/display.js
|
|
9907
|
+
var DISPLAY_PARAM = { BATTERY: 8001, SOFTWARE_VERSION: 8003 };
|
|
9908
|
+
var DISPLAY_MEMBERS = {
|
|
9909
|
+
/**
|
|
9910
|
+
* Battery level, 0-100.
|
|
9911
|
+
*
|
|
9912
|
+
* **On this capability rather than on `battery`, and that is the line partition doing its job.** The
|
|
9913
|
+
* security-line `battery` capability reads param 1101 and a Smart Display's charge is 8001 in its own
|
|
9914
|
+
* id space — two wires that happen to mean the same thing. One capability reading both would be a claim
|
|
9915
|
+
* that the two ecosystems share a param space, so a consumer reads a display's charge through
|
|
9916
|
+
* `dev.display()` and a camera's through `dev.battery()`.
|
|
9917
|
+
*
|
|
9918
|
+
* `verified` rather than `mega`: the id is in the device's cloud record, but the NAME came from the
|
|
9919
|
+
* maintainer's own knowledge of the hardware rather than from the cloud data-point list, and `"100"`
|
|
9920
|
+
* fits brightness, volume or charge equally.
|
|
9921
|
+
*
|
|
9922
|
+
* The scale is `percent` on the reading itself, not on convention alone: a full charge reads `255` on a
|
|
9923
|
+
* 0-255 scale and `1000` on a 0-1000 one, so `"100"` on a charged unit is positive evidence for 0-100
|
|
9924
|
+
* rather than merely consistent with it. What nobody has done is watch it MOVE, which is why a value
|
|
9925
|
+
* frozen at 100 would not yet be distinguishable from a healthy one.
|
|
9926
|
+
*/
|
|
9927
|
+
battery: {
|
|
9928
|
+
param: DISPLAY_PARAM.BATTERY,
|
|
9929
|
+
type: "number",
|
|
9930
|
+
unit: "%",
|
|
9931
|
+
kind: "percent",
|
|
9932
|
+
provenance: "verified",
|
|
9933
|
+
description: "Battery level, 0-100 (param 8001)."
|
|
9934
|
+
},
|
|
9935
|
+
/**
|
|
9936
|
+
* Version-shaped, and that shape is the whole of the evidence — hence `guessed`, and hence no typed
|
|
9937
|
+
* getter: a caller reading this off a bound object cannot see the label.
|
|
9938
|
+
*
|
|
9939
|
+
* `unexposed` rather than absent, so the schema still carries its type and that label and
|
|
9940
|
+
* `getProperty("softwareVersion")` still answers. `dev.info()?.firmwareVersion` is the field to trust
|
|
9941
|
+
* where the cloud record carries one; on this display it does not, which is the only reason 8003 is
|
|
9942
|
+
* named at all.
|
|
9943
|
+
*/
|
|
9944
|
+
softwareVersion: {
|
|
9945
|
+
param: DISPLAY_PARAM.SOFTWARE_VERSION,
|
|
9946
|
+
type: "string",
|
|
9947
|
+
kind: "text",
|
|
9948
|
+
provenance: "guessed",
|
|
9949
|
+
unexposed: true,
|
|
9950
|
+
description: "Version-shaped string (param 8003), meaning unconfirmed \u2014 prefer `info.firmwareVersion`."
|
|
9951
|
+
}
|
|
9952
|
+
};
|
|
9953
|
+
var DISPLAY = {
|
|
9954
|
+
capability: "display",
|
|
9955
|
+
line: "display",
|
|
9956
|
+
description: "Smart Display battery level (param 8001, display namespace).",
|
|
9957
|
+
members: DISPLAY_MEMBERS,
|
|
9958
|
+
properties: propertiesOf(DISPLAY_MEMBERS),
|
|
9959
|
+
/**
|
|
9960
|
+
* Claimed by CODEC, not by an evidence param.
|
|
9961
|
+
*
|
|
9962
|
+
* 8001 is in the device's cloud record, so the ordinary evidence gate would install the getter anyway
|
|
9963
|
+
* — but the capability should attach to a Smart Display that has reported nothing yet too, because a
|
|
9964
|
+
* device on this line has no other capability to carry it. The line partition keeps this off
|
|
9965
|
+
* everything else: `display` is the only codec in the `display` line.
|
|
9966
|
+
*/
|
|
9967
|
+
detection: { codecs: ["display"] }
|
|
9968
|
+
};
|
|
9969
|
+
|
|
9696
9970
|
// dist/model/capabilities/locate.js
|
|
9697
9971
|
var LOCATE_DP = 160;
|
|
9698
9972
|
var LEGACY_LOCATE_DP = TUYA_VACUUM_DP.LOOK_FOR_SWEEPER;
|
|
@@ -9821,6 +10095,7 @@ var MODULES = [
|
|
|
9821
10095
|
VACUUM_DOCK,
|
|
9822
10096
|
SUCTION,
|
|
9823
10097
|
LOCATE,
|
|
10098
|
+
DISPLAY,
|
|
9824
10099
|
INFO
|
|
9825
10100
|
];
|
|
9826
10101
|
var BY_CAP = new Map(MODULES.map((m) => [m.capability, m]));
|
|
@@ -9865,11 +10140,11 @@ var CODEC_LINE = {
|
|
|
9865
10140
|
mower: "clean",
|
|
9866
10141
|
light: "life",
|
|
9867
10142
|
printer: "print",
|
|
9868
|
-
display: "
|
|
10143
|
+
display: "display"
|
|
9869
10144
|
};
|
|
9870
10145
|
function lineAllows(module, codec) {
|
|
9871
10146
|
const line = module.line ?? "security";
|
|
9872
|
-
return line === "any" || line === CODEC_LINE[codec];
|
|
10147
|
+
return line === "any" || codec !== void 0 && line === CODEC_LINE[codec];
|
|
9873
10148
|
}
|
|
9874
10149
|
function detectCapabilities(rec, codec) {
|
|
9875
10150
|
const found = /* @__PURE__ */ new Set();
|
|
@@ -9899,10 +10174,10 @@ function detectCapabilities(rec, codec) {
|
|
|
9899
10174
|
if (!matched && d.modelHints && haystack.length > 0) {
|
|
9900
10175
|
matched = d.modelHints.some((re) => re.test(haystack));
|
|
9901
10176
|
}
|
|
9902
|
-
if (!matched && d.codecs) {
|
|
10177
|
+
if (!matched && d.codecs && codec !== void 0) {
|
|
9903
10178
|
matched = d.codecs.includes(codec);
|
|
9904
10179
|
}
|
|
9905
|
-
if (!matched && d.detect) {
|
|
10180
|
+
if (!matched && d.detect && codec !== void 0) {
|
|
9906
10181
|
try {
|
|
9907
10182
|
matched = d.detect(rec, codec) === true;
|
|
9908
10183
|
} catch {
|
|
@@ -11965,6 +12240,28 @@ var CLEAN_PARAMS = {
|
|
|
11965
12240
|
provenance: "mega"
|
|
11966
12241
|
}
|
|
11967
12242
|
};
|
|
12243
|
+
var DISPLAY_PARAMS = {
|
|
12244
|
+
8001: {
|
|
12245
|
+
name: "battery",
|
|
12246
|
+
type: "number",
|
|
12247
|
+
provenance: "verified"
|
|
12248
|
+
},
|
|
12249
|
+
8003: {
|
|
12250
|
+
name: "softwareVersion",
|
|
12251
|
+
type: "string",
|
|
12252
|
+
provenance: "guessed"
|
|
12253
|
+
},
|
|
12254
|
+
8005: {
|
|
12255
|
+
name: "modelName",
|
|
12256
|
+
type: "string",
|
|
12257
|
+
provenance: "mega"
|
|
12258
|
+
},
|
|
12259
|
+
8006: {
|
|
12260
|
+
name: "modelCode",
|
|
12261
|
+
type: "string",
|
|
12262
|
+
provenance: "mega"
|
|
12263
|
+
}
|
|
12264
|
+
};
|
|
11968
12265
|
|
|
11969
12266
|
// dist/model/life-params.js
|
|
11970
12267
|
var LIFE_PARAMS = {
|
|
@@ -11992,7 +12289,10 @@ var TABLES = {
|
|
|
11992
12289
|
// 3D-printer (ankermake) id space — empty until a live capture confirms the param↔semantic map
|
|
11993
12290
|
// (printer-support plan Stage 3). Present so the printer codec resolves to its OWN namespace rather
|
|
11994
12291
|
// than falling through to `security` and decoding another line's dictionary.
|
|
11995
|
-
print: {}
|
|
12292
|
+
print: {},
|
|
12293
|
+
// Smart Display (T87Ax) — ids 8001-8006, four of them named. Same reason as `print`: its own
|
|
12294
|
+
// dictionary rather than a corner of another line's.
|
|
12295
|
+
display: DISPLAY_PARAMS
|
|
11996
12296
|
};
|
|
11997
12297
|
function paramDef(ns, paramType) {
|
|
11998
12298
|
return TABLES[ns][paramType];
|
|
@@ -12007,7 +12307,7 @@ var NAMESPACE_BY_CODEC = {
|
|
|
12007
12307
|
mower: "clean",
|
|
12008
12308
|
light: "life",
|
|
12009
12309
|
printer: "print",
|
|
12010
|
-
display: "
|
|
12310
|
+
display: "display"
|
|
12011
12311
|
};
|
|
12012
12312
|
function namespaceForCodec(codec) {
|
|
12013
12313
|
return NAMESPACE_BY_CODEC[codec];
|
|
@@ -13070,6 +13370,174 @@ function inspectParams(rec, sn) {
|
|
|
13070
13370
|
};
|
|
13071
13371
|
}
|
|
13072
13372
|
|
|
13373
|
+
// dist/model/capabilities/solix.js
|
|
13374
|
+
var SOLIX_ENERGY_METER_MEMBERS = {
|
|
13375
|
+
/**
|
|
13376
|
+
* Line-1 voltage (V), ff09 tag `0xAC` — the ONE confirmed meter binding, matched against a live
|
|
13377
|
+
* single-phase frame (a nominal mains voltage). Read-only; the evidence gate installs its getter only
|
|
13378
|
+
* once a frame carrying `0xAC` has landed, so it is absent (not a fabricated `0`) until then.
|
|
13379
|
+
*/
|
|
13380
|
+
meterVoltageL1: {
|
|
13381
|
+
param: 172,
|
|
13382
|
+
type: "number",
|
|
13383
|
+
kind: "scalar",
|
|
13384
|
+
unit: "V",
|
|
13385
|
+
provenance: "verified",
|
|
13386
|
+
description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
|
|
13387
|
+
}
|
|
13388
|
+
};
|
|
13389
|
+
var CATEGORY_CAPABILITIES = {
|
|
13390
|
+
"Portable Power Station": ["battery", "acOutput", "solarInput"],
|
|
13391
|
+
"Plug-in Home Battery": ["battery", "solarInput", "acOutput", "energyMeter"],
|
|
13392
|
+
"Powered Cooler": ["battery", "cooler"],
|
|
13393
|
+
"Power Bank": ["battery"],
|
|
13394
|
+
"Smart EV Charger": ["evCharger"],
|
|
13395
|
+
Charger: ["charger"],
|
|
13396
|
+
Accessory: []
|
|
13397
|
+
};
|
|
13398
|
+
var SOLIX_METER_MODELS = ["AE1X0"];
|
|
13399
|
+
var SOLARBANK_MODELS = ["A1790", "A17C"];
|
|
13400
|
+
function detectSolixCapabilities(rec, category) {
|
|
13401
|
+
const caps = /* @__PURE__ */ new Set(["identity"]);
|
|
13402
|
+
if (rec.device_sw_version)
|
|
13403
|
+
caps.add("firmware");
|
|
13404
|
+
if (rec.wifi_online !== void 0 || rec.rssi != null || rec.wifi_name)
|
|
13405
|
+
caps.add("connectivity");
|
|
13406
|
+
for (const c of category && CATEGORY_CAPABILITIES[category] || [])
|
|
13407
|
+
caps.add(c);
|
|
13408
|
+
if (SOLIX_METER_MODELS.some((m) => rec.product_code?.startsWith(m)))
|
|
13409
|
+
caps.add("energyMeter");
|
|
13410
|
+
if (SOLARBANK_MODELS.some((m) => rec.product_code?.startsWith(m))) {
|
|
13411
|
+
caps.add("battery");
|
|
13412
|
+
caps.add("solarInput");
|
|
13413
|
+
}
|
|
13414
|
+
return caps;
|
|
13415
|
+
}
|
|
13416
|
+
|
|
13417
|
+
// dist/model/solix-catalog.js
|
|
13418
|
+
function buildModelIndex(categories) {
|
|
13419
|
+
const index = /* @__PURE__ */ new Map();
|
|
13420
|
+
for (const category of categories) {
|
|
13421
|
+
for (const product of category.products ?? []) {
|
|
13422
|
+
const entry = { name: product.name, category: category.name };
|
|
13423
|
+
if (product.product_code)
|
|
13424
|
+
index.set(product.product_code, entry);
|
|
13425
|
+
for (const variant of product.p_codes ?? []) {
|
|
13426
|
+
const code = typeof variant === "string" ? variant : variant?.product_code;
|
|
13427
|
+
if (code)
|
|
13428
|
+
index.set(code, entry);
|
|
13429
|
+
}
|
|
13430
|
+
}
|
|
13431
|
+
}
|
|
13432
|
+
return index;
|
|
13433
|
+
}
|
|
13434
|
+
|
|
13435
|
+
// dist/model/solix-device.js
|
|
13436
|
+
var READ_ONLY_SINK = { dispatch: async () => {
|
|
13437
|
+
} };
|
|
13438
|
+
var SolixDevice = class {
|
|
13439
|
+
serial;
|
|
13440
|
+
productCode;
|
|
13441
|
+
record;
|
|
13442
|
+
caps;
|
|
13443
|
+
identity_;
|
|
13444
|
+
values = {};
|
|
13445
|
+
constructor(record, opts = {}) {
|
|
13446
|
+
this.record = record;
|
|
13447
|
+
this.serial = record.device_sn;
|
|
13448
|
+
this.productCode = record.product_code;
|
|
13449
|
+
const label = opts.catalog ? buildModelIndex(opts.catalog).get(record.product_code) : void 0;
|
|
13450
|
+
this.identity_ = {
|
|
13451
|
+
serial: record.device_sn,
|
|
13452
|
+
productCode: record.product_code,
|
|
13453
|
+
name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
|
|
13454
|
+
category: label?.category
|
|
13455
|
+
};
|
|
13456
|
+
this.caps = detectSolixCapabilities(record, this.identity_.category);
|
|
13457
|
+
}
|
|
13458
|
+
/** All capabilities this device carries. */
|
|
13459
|
+
get capabilities() {
|
|
13460
|
+
return [...this.caps];
|
|
13461
|
+
}
|
|
13462
|
+
/** Whether the device carries a capability — the only correct way to branch on behaviour. */
|
|
13463
|
+
has(capability) {
|
|
13464
|
+
return this.caps.has(capability);
|
|
13465
|
+
}
|
|
13466
|
+
/**
|
|
13467
|
+
* Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
|
|
13468
|
+
* WHOLE reading, not just its values, and drops one addressed to a different device: the documented
|
|
13469
|
+
* wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
|
|
13470
|
+
* watched meter on the account — so without this filter two meters would cross-feed each other's floats.
|
|
13471
|
+
* A reading with no `deviceSn` (a hand-built one) is accepted as-is.
|
|
13472
|
+
*/
|
|
13473
|
+
applyReading(reading) {
|
|
13474
|
+
if (reading.deviceSn && reading.deviceSn !== this.serial)
|
|
13475
|
+
return;
|
|
13476
|
+
this.values = { ...this.values, ...reading.values };
|
|
13477
|
+
}
|
|
13478
|
+
/** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
|
|
13479
|
+
telemetry() {
|
|
13480
|
+
return { ...this.values };
|
|
13481
|
+
}
|
|
13482
|
+
identity() {
|
|
13483
|
+
return { ...this.identity_ };
|
|
13484
|
+
}
|
|
13485
|
+
firmware() {
|
|
13486
|
+
return this.record.device_sw_version ? { version: this.record.device_sw_version } : void 0;
|
|
13487
|
+
}
|
|
13488
|
+
connectivity() {
|
|
13489
|
+
if (!this.has("connectivity"))
|
|
13490
|
+
return void 0;
|
|
13491
|
+
const rssi = this.record.rssi != null ? Number(this.record.rssi) : void 0;
|
|
13492
|
+
return {
|
|
13493
|
+
online: !!this.record.wifi_online,
|
|
13494
|
+
rssi: Number.isFinite(rssi) ? rssi : void 0,
|
|
13495
|
+
ssid: this.record.wifi_name
|
|
13496
|
+
};
|
|
13497
|
+
}
|
|
13498
|
+
/**
|
|
13499
|
+
* The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
|
|
13500
|
+
* installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
|
|
13501
|
+
* held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
|
|
13502
|
+
* time of this call, so re-call it to pick up a tag first seen since.
|
|
13503
|
+
*/
|
|
13504
|
+
energyMeter() {
|
|
13505
|
+
if (!this.has("energyMeter"))
|
|
13506
|
+
return void 0;
|
|
13507
|
+
return bindMembers(SOLIX_ENERGY_METER_MEMBERS, this.meterDeps());
|
|
13508
|
+
}
|
|
13509
|
+
/**
|
|
13510
|
+
* The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
|
|
13511
|
+
* evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
|
|
13512
|
+
* codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
|
|
13513
|
+
* Solix telemetry is read-only, no member here dispatches a command.
|
|
13514
|
+
*/
|
|
13515
|
+
meterDeps() {
|
|
13516
|
+
const read = (name) => {
|
|
13517
|
+
const v = this.values[name];
|
|
13518
|
+
return v === void 0 ? void 0 : { name, paramType: 0, value: v, ts: Date.now() };
|
|
13519
|
+
};
|
|
13520
|
+
return { ctx: { channel: 0, paramIds: this.seenTags() }, sink: READ_ONLY_SINK, read };
|
|
13521
|
+
}
|
|
13522
|
+
/** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
|
|
13523
|
+
seenTags() {
|
|
13524
|
+
const tags = /* @__PURE__ */ new Set();
|
|
13525
|
+
for (const key of Object.keys(this.values)) {
|
|
13526
|
+
const m = /^channel_([0-9a-f]+)$/.exec(key);
|
|
13527
|
+
if (m)
|
|
13528
|
+
tags.add(parseInt(m[1], 16));
|
|
13529
|
+
}
|
|
13530
|
+
return tags;
|
|
13531
|
+
}
|
|
13532
|
+
};
|
|
13533
|
+
async function discoverSolixDevices(client, opts = {}) {
|
|
13534
|
+
const [records, catalog] = await Promise.all([
|
|
13535
|
+
client.getDevices(),
|
|
13536
|
+
opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => [])
|
|
13537
|
+
]);
|
|
13538
|
+
return records.map((r) => new SolixDevice(r, { catalog }));
|
|
13539
|
+
}
|
|
13540
|
+
|
|
13073
13541
|
// dist/client/map-channels.js
|
|
13074
13542
|
var reader = (kind, decode) => (raw, codec) => {
|
|
13075
13543
|
const value = decode(raw, codec);
|
|
@@ -14168,19 +14636,27 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14168
14636
|
* cameras from that second group streamed normally at level-1 — including one of the same firmware as an
|
|
14169
14637
|
* own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
|
|
14170
14638
|
* separates nothing on this path, and a start failure on such a session is not evidence about it.
|
|
14639
|
+
*
|
|
14640
|
+
* Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
|
|
14641
|
+
* `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
|
|
14642
|
+
* cipher and the cause are known, and re-stating it here would double every settled negotiation.
|
|
14171
14643
|
*/
|
|
14172
14644
|
async awaitLevel2Key(graceMs, graceFrom = "call") {
|
|
14173
|
-
if (this.closed)
|
|
14645
|
+
if (this.closed) {
|
|
14646
|
+
this.trace({ phase: "level2-unavailable", reason: "session-closed" });
|
|
14174
14647
|
return false;
|
|
14648
|
+
}
|
|
14175
14649
|
if (this.level2Key)
|
|
14176
14650
|
return true;
|
|
14177
|
-
if (!this.level2Pending)
|
|
14651
|
+
if (!this.level2Pending) {
|
|
14652
|
+
this.trace({ phase: "level2-unavailable", reason: "not-negotiating" });
|
|
14178
14653
|
return false;
|
|
14654
|
+
}
|
|
14179
14655
|
const since = graceFrom === "call" ? Date.now() : this.connectedAtMs ?? Date.now();
|
|
14180
14656
|
const remaining = graceMs - (Date.now() - since);
|
|
14181
14657
|
if (remaining <= 0) {
|
|
14182
14658
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} no level-2 key and its ${graceMs}ms grace has elapsed`);
|
|
14183
|
-
this.trace({ phase: "level2-
|
|
14659
|
+
this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: graceMs });
|
|
14184
14660
|
return false;
|
|
14185
14661
|
}
|
|
14186
14662
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
|
|
@@ -14206,10 +14682,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14206
14682
|
});
|
|
14207
14683
|
if (outcome === "timeout") {
|
|
14208
14684
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 key did not arrive within its grace`);
|
|
14685
|
+
this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: remaining });
|
|
14209
14686
|
} else if (outcome === "terminal") {
|
|
14210
14687
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 negotiation concluded without a key`);
|
|
14211
14688
|
} else if (outcome === "closed") {
|
|
14212
14689
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} session closed before the level-2 key arrived`);
|
|
14690
|
+
this.trace({ phase: "level2-unavailable", reason: "session-closed" });
|
|
14213
14691
|
}
|
|
14214
14692
|
return outcome === "key";
|
|
14215
14693
|
}
|
|
@@ -14260,6 +14738,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14260
14738
|
this.level2Negotiating = true;
|
|
14261
14739
|
const generation = this.connectionGeneration;
|
|
14262
14740
|
const cipherId = gatewayInfoCipherId(gwPayload);
|
|
14741
|
+
this.trace({ phase: "level2-negotiating", cipherId });
|
|
14263
14742
|
void (async () => {
|
|
14264
14743
|
try {
|
|
14265
14744
|
const eccPrivHex = await this.cfg.resolveCipherKey?.(cipherId);
|
|
@@ -14267,11 +14746,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14267
14746
|
return;
|
|
14268
14747
|
if (!eccPrivHex) {
|
|
14269
14748
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} no ECC key for cipher_id ${cipherId}`);
|
|
14749
|
+
this.trace({ phase: "level2-unavailable", reason: "no-cipher-key", cipherId });
|
|
14270
14750
|
this.settleLevel2();
|
|
14271
14751
|
return;
|
|
14272
14752
|
}
|
|
14273
14753
|
const key = deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivHex);
|
|
14274
14754
|
if (!key) {
|
|
14755
|
+
this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
|
|
14275
14756
|
this.settleLevel2();
|
|
14276
14757
|
this.emit("error", new Error(`level-2 key derivation failed (cipher_id ${cipherId})`));
|
|
14277
14758
|
return;
|
|
@@ -14283,6 +14764,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14283
14764
|
} catch (e) {
|
|
14284
14765
|
if (this.closed || generation !== this.connectionGeneration)
|
|
14285
14766
|
return;
|
|
14767
|
+
this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
|
|
14286
14768
|
this.settleLevel2();
|
|
14287
14769
|
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
14288
14770
|
}
|
|
@@ -15633,19 +16115,23 @@ function parseFf09SettingsResponse(plain) {
|
|
|
15633
16115
|
if (plain.length < 1)
|
|
15634
16116
|
throw new Error("ff09: settings response too short (missing status byte)");
|
|
15635
16117
|
const status = plain[0];
|
|
16118
|
+
const fields = walkFf09Tlv(plain, 1, plain.length);
|
|
16119
|
+
return { status, fields };
|
|
16120
|
+
}
|
|
16121
|
+
function walkFf09Tlv(buf, start, end) {
|
|
15636
16122
|
const fields = /* @__PURE__ */ new Map();
|
|
15637
|
-
let i =
|
|
15638
|
-
while (i + 2 <=
|
|
15639
|
-
const
|
|
15640
|
-
if (
|
|
16123
|
+
let i = start;
|
|
16124
|
+
while (i + 2 <= end) {
|
|
16125
|
+
const tag2 = buf[i];
|
|
16126
|
+
if (tag2 === 0)
|
|
15641
16127
|
break;
|
|
15642
|
-
const len =
|
|
15643
|
-
if (i + 2 + len >
|
|
16128
|
+
const len = buf[i + 1];
|
|
16129
|
+
if (i + 2 + len > end)
|
|
15644
16130
|
break;
|
|
15645
|
-
fields.set(
|
|
16131
|
+
fields.set(tag2, buf.subarray(i + 2, i + 2 + len));
|
|
15646
16132
|
i += 2 + len;
|
|
15647
16133
|
}
|
|
15648
|
-
return
|
|
16134
|
+
return fields;
|
|
15649
16135
|
}
|
|
15650
16136
|
function readFf09U16LE(field, name) {
|
|
15651
16137
|
if (!field || field.length < 2)
|
|
@@ -16348,10 +16834,12 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
16348
16834
|
if (!this.listening || this.kaTimer)
|
|
16349
16835
|
return;
|
|
16350
16836
|
if (this.opts.reassertWanted?.() === false) {
|
|
16837
|
+
this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "declined" });
|
|
16351
16838
|
this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms, and its owner does not want this channel re-asserted \u2014 staying quiet`);
|
|
16352
16839
|
this.armStallWatch();
|
|
16353
16840
|
return;
|
|
16354
16841
|
}
|
|
16842
|
+
this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "reasserted" });
|
|
16355
16843
|
this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms \u2014 re-asserting this camera's channel`);
|
|
16356
16844
|
this.sendStart();
|
|
16357
16845
|
this.kaTimer = setInterval(() => this.sendStart(), keepAliveMs);
|
|
@@ -16572,10 +17060,12 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
16572
17060
|
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
16573
17061
|
const collectMs = opts.collectMs ?? 1500;
|
|
16574
17062
|
const skip = opts.skipKeyframes ?? 1;
|
|
17063
|
+
opts.signal?.throwIfAborted();
|
|
16575
17064
|
const consumer = source.attach();
|
|
16576
17065
|
const primed = consumer.primed;
|
|
17066
|
+
let burst;
|
|
16577
17067
|
try {
|
|
16578
|
-
|
|
17068
|
+
burst = await new Promise((resolve, reject) => {
|
|
16579
17069
|
const bufs = [];
|
|
16580
17070
|
let sets = source.parameterSets;
|
|
16581
17071
|
let codec = "h264";
|
|
@@ -16610,6 +17100,10 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
16610
17100
|
cleanup();
|
|
16611
17101
|
reject(new LiveSnapshotUnavailableError("source-failed", `source ended before a keyframe (state: ${source.state})`));
|
|
16612
17102
|
};
|
|
17103
|
+
const onAbandoned = () => {
|
|
17104
|
+
cleanup();
|
|
17105
|
+
reject(opts.signal?.reason);
|
|
17106
|
+
};
|
|
16613
17107
|
const cleanup = () => {
|
|
16614
17108
|
clearTimeout(timer);
|
|
16615
17109
|
if (settle)
|
|
@@ -16617,19 +17111,21 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
16617
17111
|
consumer.off("video", onVideo);
|
|
16618
17112
|
consumer.off("error", onError);
|
|
16619
17113
|
consumer.off("stop", onStop);
|
|
17114
|
+
opts.signal?.removeEventListener("abort", onAbandoned);
|
|
16620
17115
|
};
|
|
16621
17116
|
consumer.on("video", onVideo);
|
|
16622
17117
|
consumer.on("error", onError);
|
|
16623
17118
|
consumer.on("stop", onStop);
|
|
16624
|
-
|
|
16625
|
-
return await annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
|
|
16626
|
-
logger: opts.logger ?? noopLogger,
|
|
16627
|
-
level: opts.ffmpegLevel,
|
|
16628
|
-
executable: opts.ffmpegPath
|
|
17119
|
+
opts.signal?.addEventListener("abort", onAbandoned, { once: true });
|
|
16629
17120
|
});
|
|
16630
17121
|
} finally {
|
|
16631
17122
|
consumer.detach();
|
|
16632
17123
|
}
|
|
17124
|
+
return annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
|
|
17125
|
+
logger: opts.logger ?? noopLogger,
|
|
17126
|
+
level: opts.ffmpegLevel,
|
|
17127
|
+
executable: opts.ffmpegPath
|
|
17128
|
+
});
|
|
16633
17129
|
}
|
|
16634
17130
|
async function recordClip(session, seconds, opts = {}) {
|
|
16635
17131
|
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
@@ -18836,6 +19332,11 @@ function abortable(work, signal) {
|
|
|
18836
19332
|
}
|
|
18837
19333
|
var LEVEL2_GRACE_MS = 25e3;
|
|
18838
19334
|
var LEVEL2_SETTLE_MS = 8e3;
|
|
19335
|
+
var P2P_STATION_WAITS = {
|
|
19336
|
+
connect: CONNECT_WAIT_MS,
|
|
19337
|
+
level2Grace: LEVEL2_GRACE_MS,
|
|
19338
|
+
level2Settle: LEVEL2_SETTLE_MS
|
|
19339
|
+
};
|
|
18839
19340
|
var RTSP_URL_READ_TIMEOUT_MS = 12e3;
|
|
18840
19341
|
var SHARED_LIVE_OPT_KEYS = [
|
|
18841
19342
|
"eccPrivateKey",
|
|
@@ -18887,6 +19388,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18887
19388
|
}
|
|
18888
19389
|
return normalized;
|
|
18889
19390
|
}
|
|
19391
|
+
/**
|
|
19392
|
+
* Emit a live trace under a station session's handle, for work this router does ON that session before
|
|
19393
|
+
* the session itself records anything — reaching the station, and resolving what a device is on it. Same
|
|
19394
|
+
* handle as everything the session goes on to trace, which is what groups one attempt.
|
|
19395
|
+
*/
|
|
19396
|
+
traceOnStation(session, trace) {
|
|
19397
|
+
traceLiveStart(this.deps.logger ?? noopLogger, trace, session.traceId);
|
|
19398
|
+
}
|
|
18890
19399
|
/**
|
|
18891
19400
|
* Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
|
|
18892
19401
|
* usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
|
|
@@ -19027,7 +19536,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19027
19536
|
let ecc;
|
|
19028
19537
|
try {
|
|
19029
19538
|
const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
|
|
19030
|
-
ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key
|
|
19539
|
+
ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
|
|
19540
|
+
if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
|
|
19541
|
+
ecc = ciphers[0].ecc_private_key;
|
|
19542
|
+
this.traceOnStation(session, {
|
|
19543
|
+
phase: "cipher-fallback",
|
|
19544
|
+
cipherId,
|
|
19545
|
+
answeredCipherId: Number(ciphers[0].cipher_id)
|
|
19546
|
+
});
|
|
19547
|
+
}
|
|
19031
19548
|
} catch (e) {
|
|
19032
19549
|
this.deps.onError(e instanceof Error ? e : new Error(String(e)));
|
|
19033
19550
|
}
|
|
@@ -19181,12 +19698,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19181
19698
|
return {
|
|
19182
19699
|
snapshotLive: async (opts) => {
|
|
19183
19700
|
const source = await this.sharedLiveSourceFor(sn, opts ?? {});
|
|
19184
|
-
return
|
|
19701
|
+
return captureSnapshotFromShared(source, {
|
|
19185
19702
|
...opts,
|
|
19186
19703
|
logger: this.deps.logger ?? noopLogger,
|
|
19187
19704
|
ffmpegLevel: this.deps.ffmpegLogLevel,
|
|
19188
19705
|
ffmpegPath: this.deps.ffmpegPath
|
|
19189
|
-
})
|
|
19706
|
+
});
|
|
19190
19707
|
},
|
|
19191
19708
|
live: async (opts) => {
|
|
19192
19709
|
const source = await this.sharedLiveSourceFor(sn, opts);
|
|
@@ -19658,9 +20175,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19658
20175
|
s.sendStringPayloadCommand(P2P_ENVELOPE.CONTROL_PAYLOAD, json, ch);
|
|
19659
20176
|
return Promise.resolve();
|
|
19660
20177
|
},
|
|
19661
|
-
l2: async ({ session: s, channel: ch }) => {
|
|
20178
|
+
l2: async ({ session: s, channel: ch, parentSn }) => {
|
|
19662
20179
|
if (!await s.awaitLevel2Key(LEVEL2_GRACE_MS, "call")) {
|
|
19663
|
-
throw new
|
|
20180
|
+
throw new StationKeyUnavailableError(parentSn);
|
|
19664
20181
|
}
|
|
19665
20182
|
s.sendRawLevel2(json, ch, P2P_ENVELOPE.CONTROL_PAYLOAD);
|
|
19666
20183
|
}
|
|
@@ -19827,15 +20344,28 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19827
20344
|
}
|
|
19828
20345
|
this.manager.bumpCommand(parentSn);
|
|
19829
20346
|
const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
|
|
19830
|
-
const
|
|
20347
|
+
const stationAdminId = raw.member?.admin_user_id;
|
|
20348
|
+
const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
|
|
19831
20349
|
const t0 = Date.now();
|
|
19832
|
-
|
|
19833
|
-
|
|
19834
|
-
|
|
20350
|
+
let waitedMs = 0;
|
|
20351
|
+
if (!session.isConnected) {
|
|
20352
|
+
this.traceOnStation(session, { phase: "session-connect-wait", waitMs: CONNECT_WAIT_MS });
|
|
20353
|
+
while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
|
|
20354
|
+
opts.signal?.throwIfAborted();
|
|
20355
|
+
await sleep2(200);
|
|
20356
|
+
}
|
|
20357
|
+
waitedMs = Date.now() - t0;
|
|
20358
|
+
this.traceOnStation(session, session.isConnected ? { phase: "session-connected", waitedMs } : { phase: "session-unreachable", waitedMs });
|
|
19835
20359
|
}
|
|
19836
20360
|
opts.signal?.throwIfAborted();
|
|
19837
20361
|
if (!session.isConnected)
|
|
19838
|
-
throw new
|
|
20362
|
+
throw new StationUnreachableError(parentSn, waitedMs);
|
|
20363
|
+
this.traceOnStation(session, {
|
|
20364
|
+
phase: "station-resolved",
|
|
20365
|
+
topology: homeBaseAttached ? "attached" : "own",
|
|
20366
|
+
channel,
|
|
20367
|
+
stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other"
|
|
20368
|
+
});
|
|
19839
20369
|
if (opts.waitLevel2) {
|
|
19840
20370
|
if (opts.waitLevel2 === "settle") {
|
|
19841
20371
|
await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
|
|
@@ -19849,7 +20379,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19849
20379
|
ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), opts.signal);
|
|
19850
20380
|
}
|
|
19851
20381
|
if (!ready)
|
|
19852
|
-
throw new
|
|
20382
|
+
throw new StationKeyUnavailableError(parentSn);
|
|
19853
20383
|
}
|
|
19854
20384
|
return { session, parentSn, channel, accountId, homeBaseAttached };
|
|
19855
20385
|
}
|
|
@@ -20168,17 +20698,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
20168
20698
|
};
|
|
20169
20699
|
|
|
20170
20700
|
// dist/transport/mqtt/command-router.js
|
|
20171
|
-
import { randomBytes as randomBytes4 } from "node:crypto";
|
|
20172
|
-
|
|
20173
|
-
// dist/transport/mqtt/app-client-id.js
|
|
20174
20701
|
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
20175
|
-
function buildAppShapedClientId(input) {
|
|
20176
|
-
const ts = input.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
20177
|
-
return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
|
|
20178
|
-
}
|
|
20179
|
-
function generateMqttUuid() {
|
|
20180
|
-
return randomBytes3(8).toString("hex");
|
|
20181
|
-
}
|
|
20182
20702
|
|
|
20183
20703
|
// dist/transport/mqtt/broker-discovery.js
|
|
20184
20704
|
import { resolve4 } from "node:dns/promises";
|
|
@@ -20556,9 +21076,9 @@ function buildFf09MqttEnvelope(input) {
|
|
|
20556
21076
|
const head = {
|
|
20557
21077
|
version: "1.0.0.1",
|
|
20558
21078
|
client_id: input.clientId,
|
|
20559
|
-
sess_id: input.sessId ??
|
|
21079
|
+
sess_id: input.sessId ?? randomBytes3(2).toString("hex"),
|
|
20560
21080
|
msg_seq: 1,
|
|
20561
|
-
seed: input.seed ??
|
|
21081
|
+
seed: input.seed ?? randomBytes3(16).toString("hex"),
|
|
20562
21082
|
timestamp: input.timestamp ?? Math.floor(Date.now() / 1e3),
|
|
20563
21083
|
cmd_status: 2,
|
|
20564
21084
|
cmd: 9,
|
|
@@ -20569,9 +21089,6 @@ function buildFf09MqttEnvelope(input) {
|
|
|
20569
21089
|
var MqttCommandRouter = class _MqttCommandRouter {
|
|
20570
21090
|
deps;
|
|
20571
21091
|
logger;
|
|
20572
|
-
/** Stable per-process install id for the app-shaped MQTT client_id (see {@link buildAppShapedClientId}) —
|
|
20573
|
-
* generated once, reused for every security-MQTT connect this router makes. */
|
|
20574
|
-
mqttUuid;
|
|
20575
21092
|
constructor(deps) {
|
|
20576
21093
|
this.deps = deps;
|
|
20577
21094
|
this.logger = deps.logger ?? noopLogger;
|
|
@@ -20678,9 +21195,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
|
|
|
20678
21195
|
*/
|
|
20679
21196
|
async ensureSecurityMqttFor(dev) {
|
|
20680
21197
|
const creds = await this.deps.mega.getUserMqttInfo("eufy_security");
|
|
20681
|
-
|
|
20682
|
-
this.mqttUuid = generateMqttUuid();
|
|
20683
|
-
const mqttUuid = this.mqttUuid;
|
|
21198
|
+
const mqttUuid = mqttUuidFrom(this.deps.mega.openudid);
|
|
20684
21199
|
const clientIdFor = () => buildAppShapedClientId({ appName: "eufy_security", uid: creds.user_id ?? "", mqttUuid });
|
|
20685
21200
|
const results = await discoverReachableInstance({
|
|
20686
21201
|
hostname: creds.endpoint_addr,
|
|
@@ -21175,7 +21690,7 @@ function deriveTuyaAccount(eufyUserId, phoneCode) {
|
|
|
21175
21690
|
}
|
|
21176
21691
|
|
|
21177
21692
|
// dist/transport/tuya/sign.js
|
|
21178
|
-
import { createHash as
|
|
21693
|
+
import { createHash as createHash5, createHmac as createHmac3 } from "node:crypto";
|
|
21179
21694
|
var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
21180
21695
|
"a",
|
|
21181
21696
|
"v",
|
|
@@ -21199,7 +21714,7 @@ var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
|
21199
21714
|
"sp"
|
|
21200
21715
|
]);
|
|
21201
21716
|
function swapMd5(postData) {
|
|
21202
|
-
const h =
|
|
21717
|
+
const h = createHash5("md5").update(postData, "utf-8").digest("hex");
|
|
21203
21718
|
return h.slice(8, 16) + h.slice(0, 8) + h.slice(24, 32) + h.slice(16, 24);
|
|
21204
21719
|
}
|
|
21205
21720
|
function buildSignPreimage(params) {
|
|
@@ -21224,7 +21739,7 @@ var HmacSigner = class {
|
|
|
21224
21739
|
var TUYA_CHKEY = "7cbfe6d8";
|
|
21225
21740
|
|
|
21226
21741
|
// dist/transport/tuya/client.js
|
|
21227
|
-
import { randomBytes as
|
|
21742
|
+
import { randomBytes as randomBytes4, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
|
|
21228
21743
|
|
|
21229
21744
|
// dist/transport/tuya/request.js
|
|
21230
21745
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
@@ -21336,7 +21851,7 @@ function buildUidTokenCreateAction(countryCode, uid) {
|
|
|
21336
21851
|
|
|
21337
21852
|
// dist/transport/tuya/client.js
|
|
21338
21853
|
function genDeviceId() {
|
|
21339
|
-
return
|
|
21854
|
+
return randomBytes4(22).toString("hex");
|
|
21340
21855
|
}
|
|
21341
21856
|
function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
|
|
21342
21857
|
const bigintToBuffer = (n) => {
|
|
@@ -21351,8 +21866,8 @@ function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
|
|
|
21351
21866
|
},
|
|
21352
21867
|
format: "jwk"
|
|
21353
21868
|
});
|
|
21354
|
-
const
|
|
21355
|
-
return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(
|
|
21869
|
+
const md5Hex2 = createHash6("md5").update(password).digest("hex");
|
|
21870
|
+
return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex2)).toString("hex");
|
|
21356
21871
|
}
|
|
21357
21872
|
var TuyaClient = class {
|
|
21358
21873
|
signer;
|
|
@@ -22443,7 +22958,7 @@ var PushClient = class _PushClient extends EventEmitter8 {
|
|
|
22443
22958
|
};
|
|
22444
22959
|
|
|
22445
22960
|
// dist/transport/push/fcm.js
|
|
22446
|
-
import { randomBytes as
|
|
22961
|
+
import { randomBytes as randomBytes5 } from "node:crypto";
|
|
22447
22962
|
import { setTimeout as sleep3 } from "node:timers/promises";
|
|
22448
22963
|
var FCM = {
|
|
22449
22964
|
PROJECT_ID: "batterycam-3250a",
|
|
@@ -22456,7 +22971,7 @@ var FCM = {
|
|
|
22456
22971
|
SDK_VERSION: "a:16.3.1"
|
|
22457
22972
|
};
|
|
22458
22973
|
function generateFid() {
|
|
22459
|
-
const b =
|
|
22974
|
+
const b = randomBytes5(17);
|
|
22460
22975
|
b[0] = 112 + b[0] % 16;
|
|
22461
22976
|
return b.toString("base64url").slice(0, 22);
|
|
22462
22977
|
}
|
|
@@ -23979,8 +24494,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
23979
24494
|
* @example
|
|
23980
24495
|
* ```ts
|
|
23981
24496
|
* const res = await eufy.login();
|
|
23982
|
-
* if (res.status === "captcha") await eufy.solveCaptcha(await
|
|
23983
|
-
* else if (res.status === "2fa") await eufy.submitVerifyCode(await
|
|
24497
|
+
* if (res.status === "captcha") await eufy.solveCaptcha(await promptUser(res.image));
|
|
24498
|
+
* else if (res.status === "2fa") await eufy.submitVerifyCode(await promptUser());
|
|
23984
24499
|
* ```
|
|
23985
24500
|
*/
|
|
23986
24501
|
async login(opts = {}) {
|
|
@@ -24305,7 +24820,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24305
24820
|
* @example
|
|
24306
24821
|
* ```ts
|
|
24307
24822
|
* const dev = await eufy.getDevice(sn);
|
|
24308
|
-
* if (dev.has("camera")) await dev.camera()?.snapshotStored();
|
|
24823
|
+
* if (dev.has("camera")) await dev.camera?.()?.snapshotStored?.();
|
|
24309
24824
|
* console.log(dev.getProperty("battery"));
|
|
24310
24825
|
* ```
|
|
24311
24826
|
*/
|
|
@@ -24750,9 +25265,21 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24750
25265
|
* {@link ensureMqttStarted} owns installing it, subscribing devices, and the epoch check, so that
|
|
24751
25266
|
* lifecycle lives in exactly one place. Only ever called through {@link ensureMqttStarted}.
|
|
24752
25267
|
*
|
|
24753
|
-
*
|
|
24754
|
-
*
|
|
24755
|
-
*
|
|
25268
|
+
* Identified by a client id built from this client's `openudid`, not by the certificate's name:
|
|
25269
|
+
* that name is `{user_id}-{app_name}`, which every client on the account shares per line, and a
|
|
25270
|
+
* duplicate client id is a takeover the broker resolves by evicting the incumbent. The id shape is
|
|
25271
|
+
* the app's own (`android-{app_name}-{uid}-{uuid}-{ts}`, see {@link buildAppShapedClientId}), which
|
|
25272
|
+
* the broker grants on the `eufy_security` credential.
|
|
25273
|
+
*
|
|
25274
|
+
* It separates two clients exactly as far as their `openudid` does: a caller that supplies none
|
|
25275
|
+
* gets the value derived from the account, which every such client shares — the same condition
|
|
25276
|
+
* under which their logins already displace each other (`MegaClientConfig.openudid`).
|
|
25277
|
+
*
|
|
25278
|
+
* A client id the broker REFUSES falls back to the certificate's name, since a shared channel beats
|
|
25279
|
+
* none, and that transport then keeps that name until {@link disconnect}. Only a refusal: a connect
|
|
25280
|
+
* that fails for any other reason rejects the bring-up, which clears its memo in
|
|
25281
|
+
* {@link ensureMqttStarted} so the next one asks under this client's own id again — a dropped
|
|
25282
|
+
* socket must not be what moves a process onto the shared name for good.
|
|
24756
25283
|
*/
|
|
24757
25284
|
async startMqtt(scope) {
|
|
24758
25285
|
const auth = this.mega.auth;
|
|
@@ -24761,7 +25288,41 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24761
25288
|
if (!this.registry.list().length)
|
|
24762
25289
|
await this.getDevices();
|
|
24763
25290
|
const creds = await this.getUserMqttInfo(mqttAppName(scope));
|
|
24764
|
-
const
|
|
25291
|
+
const ownClientId = buildAppShapedClientId({
|
|
25292
|
+
appName: creds.app_name ?? mqttAppName(scope) ?? "eufy_mega",
|
|
25293
|
+
uid: creds.user_id ?? auth.userId,
|
|
25294
|
+
mqttUuid: mqttUuidFrom(this.mega.openudid)
|
|
25295
|
+
});
|
|
25296
|
+
try {
|
|
25297
|
+
return await this.connectMqtt(creds, ownClientId);
|
|
25298
|
+
} catch (e) {
|
|
25299
|
+
if (!isNotAuthorized(e))
|
|
25300
|
+
throw e;
|
|
25301
|
+
this.opts.logger?.warn("[smqtt] the broker refused this client's own id; connecting under the certificate's name, which another client signed in to this account takes over", e);
|
|
25302
|
+
return await this.connectMqtt(creds);
|
|
25303
|
+
}
|
|
25304
|
+
}
|
|
25305
|
+
/**
|
|
25306
|
+
* Connect one secure-MQTT transport under `clientId`, or under the certificate's own name when it is
|
|
25307
|
+
* omitted, and wire its decode and fan-out. See {@link startMqtt} for which id is used and why.
|
|
25308
|
+
*
|
|
25309
|
+
* The fan-out is wired once the connection stands, so an attempt that is discarded — a client id the
|
|
25310
|
+
* broker refuses, a socket that dies mid-handshake — never reports a connection a consumer never had;
|
|
25311
|
+
* the connect it just completed is announced here instead. Errors raised while connecting are held
|
|
25312
|
+
* only to keep an emitter without an `error` listener from throwing, and are reported once the
|
|
25313
|
+
* transport is one a consumer owns.
|
|
25314
|
+
*
|
|
25315
|
+
* The inbound decode is gated by the reporting device's own capabilities, so one line's decoder never
|
|
25316
|
+
* runs against another's traffic, and the DP frame is unwrapped here — the layer that may import the
|
|
25317
|
+
* transport — so a capability reads tags without owning any framing.
|
|
25318
|
+
*/
|
|
25319
|
+
async connectMqtt(creds, clientId) {
|
|
25320
|
+
const transport = new SecureMqtt({ credentials: creds, clientId, logger: this.opts.logger });
|
|
25321
|
+
const whileConnecting = [];
|
|
25322
|
+
const hold = (e) => void whileConnecting.push(e);
|
|
25323
|
+
transport.on("error", hold);
|
|
25324
|
+
await transport.connect();
|
|
25325
|
+
transport.off("error", hold);
|
|
24765
25326
|
transport.on("connect", () => this.emit("connect"));
|
|
24766
25327
|
transport.on("disconnect", (r) => this.emit("disconnect", r));
|
|
24767
25328
|
transport.on("message", (m) => {
|
|
@@ -24800,7 +25361,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24800
25361
|
this.emitSemantic(out.event, out.payload, { edge: true, refresh: out.refresh });
|
|
24801
25362
|
});
|
|
24802
25363
|
transport.on("error", (e) => this.reportError(e));
|
|
24803
|
-
|
|
25364
|
+
this.emit("connect");
|
|
25365
|
+
for (const e of whileConnecting)
|
|
25366
|
+
this.reportError(e);
|
|
24804
25367
|
return transport;
|
|
24805
25368
|
}
|
|
24806
25369
|
/**
|
|
@@ -25217,6 +25780,553 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25217
25780
|
}
|
|
25218
25781
|
};
|
|
25219
25782
|
|
|
25783
|
+
// dist/transport/http/solix-constants.js
|
|
25784
|
+
var SOLIX_APP_NAME = "anker_power";
|
|
25785
|
+
var SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
|
|
25786
|
+
var SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
|
|
25787
|
+
var SOLIX_ENDPOINTS = {
|
|
25788
|
+
estimateDomain: "/passport/estimate_domain",
|
|
25789
|
+
keyExchange: "/openapi/oauth/key/exchange",
|
|
25790
|
+
login: "/passport/login",
|
|
25791
|
+
/** Bound devices for the account (flat list). */
|
|
25792
|
+
getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices",
|
|
25793
|
+
/** Sites (systems) the account owns; devices are grouped under a site. */
|
|
25794
|
+
getSiteList: "/power_service/v1/site/get_site_list",
|
|
25795
|
+
/** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
|
|
25796
|
+
getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
|
|
25797
|
+
/** GET: the pairable-product catalog (categories → products), for labelling model codes. */
|
|
25798
|
+
productCategories: "/power_service/v1/product_categories"
|
|
25799
|
+
};
|
|
25800
|
+
|
|
25801
|
+
// dist/transport/http/solix-client.js
|
|
25802
|
+
function solixSessionFresh(s) {
|
|
25803
|
+
return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
|
|
25804
|
+
}
|
|
25805
|
+
var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
25806
|
+
var SolixClient = class {
|
|
25807
|
+
email;
|
|
25808
|
+
password;
|
|
25809
|
+
country;
|
|
25810
|
+
appVersion;
|
|
25811
|
+
doFetch;
|
|
25812
|
+
store;
|
|
25813
|
+
openudid;
|
|
25814
|
+
apiHost;
|
|
25815
|
+
session_;
|
|
25816
|
+
/** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
|
|
25817
|
+
pending2fa;
|
|
25818
|
+
/**
|
|
25819
|
+
* Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
|
|
25820
|
+
* not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
|
|
25821
|
+
* handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
|
|
25822
|
+
* override that also skips domain-estimate, and every read goes through `this.apiHost`.
|
|
25823
|
+
*/
|
|
25824
|
+
constructor(opts) {
|
|
25825
|
+
this.email = opts.email;
|
|
25826
|
+
this.password = opts.password;
|
|
25827
|
+
this.country = (opts.countryCode ?? "US").toUpperCase();
|
|
25828
|
+
this.appVersion = opts.appVersion ?? "3.23.0";
|
|
25829
|
+
this.doFetch = opts.fetchImpl ?? fetch;
|
|
25830
|
+
this.apiHost = opts.apiHost ?? SOLIX_DEFAULT_API_HOST;
|
|
25831
|
+
this.store = opts.store;
|
|
25832
|
+
const saved = this.store?.load();
|
|
25833
|
+
this.openudid = opts.openudid ?? saved?.openudid ?? uuidFromHex(md5Hex(`anker-solix:${opts.email}`));
|
|
25834
|
+
if (saved?.session && solixSessionFresh(saved.session)) {
|
|
25835
|
+
this.session_ = saved.session;
|
|
25836
|
+
this.apiHost = opts.apiHost ?? saved.session.apiHost;
|
|
25837
|
+
}
|
|
25838
|
+
}
|
|
25839
|
+
/** Persist the current device id (+ session, if any) when a store is configured. */
|
|
25840
|
+
persist() {
|
|
25841
|
+
this.store?.save({ openudid: this.openudid, session: this.session_ });
|
|
25842
|
+
}
|
|
25843
|
+
/** The authenticated session, once {@link login} has resolved to `ok`. */
|
|
25844
|
+
get session() {
|
|
25845
|
+
return this.session_;
|
|
25846
|
+
}
|
|
25847
|
+
/**
|
|
25848
|
+
* Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
|
|
25849
|
+
* must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
|
|
25850
|
+
* (`401 token error`) — so those use {@link baseHeaders} directly.
|
|
25851
|
+
*/
|
|
25852
|
+
authHeaders(extra = {}) {
|
|
25853
|
+
return this.baseHeaders({ openudid: this.openudid, "x-terminal-id": this.openudid, ...extra });
|
|
25854
|
+
}
|
|
25855
|
+
/** Base headers common to every Solix request. */
|
|
25856
|
+
baseHeaders(extra = {}) {
|
|
25857
|
+
return {
|
|
25858
|
+
"content-type": "application/json",
|
|
25859
|
+
"app-name": SOLIX_APP_NAME,
|
|
25860
|
+
"model-type": "PHONE",
|
|
25861
|
+
"os-type": "android",
|
|
25862
|
+
"os-version": "36",
|
|
25863
|
+
"app-version": this.appVersion,
|
|
25864
|
+
country: this.country,
|
|
25865
|
+
timezone: "GMT+00:00",
|
|
25866
|
+
language: "en",
|
|
25867
|
+
"user-agent": "ktor-client",
|
|
25868
|
+
accept: "application/json",
|
|
25869
|
+
...extra
|
|
25870
|
+
};
|
|
25871
|
+
}
|
|
25872
|
+
/** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
|
|
25873
|
+
async send(method2, host, path, headers, body) {
|
|
25874
|
+
const res = await this.doFetch(`https://${host}${path}`, {
|
|
25875
|
+
method: method2,
|
|
25876
|
+
headers,
|
|
25877
|
+
body,
|
|
25878
|
+
signal: AbortSignal.timeout(2e4)
|
|
25879
|
+
});
|
|
25880
|
+
const text2 = await res.text();
|
|
25881
|
+
try {
|
|
25882
|
+
return JSON.parse(text2);
|
|
25883
|
+
} catch {
|
|
25884
|
+
throw new Error(`Solix ${path} \u2192 HTTP ${res.status}, non-JSON: ${text2.slice(0, 120)}`);
|
|
25885
|
+
}
|
|
25886
|
+
}
|
|
25887
|
+
/** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
|
|
25888
|
+
post(host, path, body, headers) {
|
|
25889
|
+
return this.send("POST", host, path, headers, body);
|
|
25890
|
+
}
|
|
25891
|
+
/** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
|
|
25892
|
+
async estimateHost() {
|
|
25893
|
+
try {
|
|
25894
|
+
const env = await this.post(SOLIX_ESTIMATE_HOST, SOLIX_ENDPOINTS.estimateDomain, JSON.stringify({ ab: this.country, mode: 1 }), this.baseHeaders());
|
|
25895
|
+
const domain = env.data?.domain;
|
|
25896
|
+
if (domain)
|
|
25897
|
+
this.apiHost = domain;
|
|
25898
|
+
} catch {
|
|
25899
|
+
}
|
|
25900
|
+
}
|
|
25901
|
+
/** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
|
|
25902
|
+
async keyExchange() {
|
|
25903
|
+
const prep = prepareKeyExchange(SOLIX_LOCAL_KEY_HEX);
|
|
25904
|
+
const env = await this.post(this.apiHost, SOLIX_ENDPOINTS.keyExchange, JSON.stringify({ client_public_key: prep.encryptedClientPublicKey }), this.authHeaders(prep.headers));
|
|
25905
|
+
const spk = env.data?.server_public_key;
|
|
25906
|
+
if (env.code !== 0 || !spk)
|
|
25907
|
+
throw new Error(`Solix key/exchange failed (${env.code}): ${env.msg}`);
|
|
25908
|
+
return finishKeyExchange(prep, spk);
|
|
25909
|
+
}
|
|
25910
|
+
/** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
|
|
25911
|
+
async postLogin(kx, verifyCode, limitedToken) {
|
|
25912
|
+
const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
|
|
25913
|
+
const bodyObj = {
|
|
25914
|
+
email: this.email,
|
|
25915
|
+
password: encryptedPassword,
|
|
25916
|
+
ab: this.country,
|
|
25917
|
+
client_secret_info: { public_key: clientPublicKeyHex },
|
|
25918
|
+
answer: "",
|
|
25919
|
+
captcha_id: "",
|
|
25920
|
+
verify_code: verifyCode ?? "",
|
|
25921
|
+
login_id: ""
|
|
25922
|
+
};
|
|
25923
|
+
const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
|
|
25924
|
+
const ts = nowSec();
|
|
25925
|
+
const once = genId();
|
|
25926
|
+
return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
|
|
25927
|
+
"x-encryption-info": "algo_ecdh",
|
|
25928
|
+
"x-key-ident": kx.keyIdent,
|
|
25929
|
+
"x-request-ts": ts,
|
|
25930
|
+
"x-request-once": once,
|
|
25931
|
+
"x-signature": signRequest(kx.shareKey, ts, once, encBody),
|
|
25932
|
+
...limitedToken ? { "x-auth-token": limitedToken } : {}
|
|
25933
|
+
}));
|
|
25934
|
+
}
|
|
25935
|
+
/**
|
|
25936
|
+
* Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
|
|
25937
|
+
* `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
|
|
25938
|
+
* has been satisfied.
|
|
25939
|
+
*/
|
|
25940
|
+
classifyLogin(data, isVerify) {
|
|
25941
|
+
const userId = data.ap_cloud_user_id ?? data.user_id;
|
|
25942
|
+
const authToken = data.auth_token;
|
|
25943
|
+
if (!userId || !authToken)
|
|
25944
|
+
throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
|
|
25945
|
+
const faInfo = data.fa_info ?? {};
|
|
25946
|
+
if (!isVerify && faInfo.info) {
|
|
25947
|
+
this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
|
|
25948
|
+
return { status: "2fa", method: "code sent by the passport" };
|
|
25949
|
+
}
|
|
25950
|
+
this.pending2fa = void 0;
|
|
25951
|
+
this.session_ = {
|
|
25952
|
+
authToken,
|
|
25953
|
+
userId,
|
|
25954
|
+
gtoken: gtoken(userId),
|
|
25955
|
+
apiHost: this.apiHost,
|
|
25956
|
+
tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
|
|
25957
|
+
};
|
|
25958
|
+
this.persist();
|
|
25959
|
+
return { status: "ok", session: this.session_ };
|
|
25960
|
+
}
|
|
25961
|
+
/** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
|
|
25962
|
+
decryptLogin(env, kx) {
|
|
25963
|
+
if (typeof env.data !== "string")
|
|
25964
|
+
throw new Error(`Solix login (${env.code}): ${env.msg}`);
|
|
25965
|
+
return JSON.parse(decryptBody(env.data, kx.shareKey).toString("utf-8"));
|
|
25966
|
+
}
|
|
25967
|
+
/**
|
|
25968
|
+
* Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
|
|
25969
|
+
* when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
|
|
25970
|
+
* (adopted from a store) is answered without a handshake.
|
|
25971
|
+
*/
|
|
25972
|
+
async login() {
|
|
25973
|
+
if (solixSessionFresh(this.session_)) {
|
|
25974
|
+
return { status: "ok", session: this.session_ };
|
|
25975
|
+
}
|
|
25976
|
+
await this.estimateHost();
|
|
25977
|
+
const kx = await this.keyExchange();
|
|
25978
|
+
const env = await this.postLogin(kx);
|
|
25979
|
+
return this.classifyLogin(this.decryptLogin(env, kx), false);
|
|
25980
|
+
}
|
|
25981
|
+
/** Complete a `2fa` login with the code the passport sent. */
|
|
25982
|
+
async submitVerifyCode(code) {
|
|
25983
|
+
if (!this.pending2fa)
|
|
25984
|
+
throw new Error("no 2FA login is pending");
|
|
25985
|
+
const kx = await this.keyExchange();
|
|
25986
|
+
const env = await this.postLogin(kx, code, this.pending2fa.limitedToken);
|
|
25987
|
+
return this.classifyLogin(this.decryptLogin(env, kx), true);
|
|
25988
|
+
}
|
|
25989
|
+
/**
|
|
25990
|
+
* One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
|
|
25991
|
+
* the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
|
|
25992
|
+
*/
|
|
25993
|
+
async authed(method2, path, body) {
|
|
25994
|
+
if (!this.session_)
|
|
25995
|
+
throw new Error("not authenticated \u2014 call login() first");
|
|
25996
|
+
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);
|
|
25997
|
+
if (env.code !== 0)
|
|
25998
|
+
throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
|
|
25999
|
+
return env.data ?? null;
|
|
26000
|
+
}
|
|
26001
|
+
/**
|
|
26002
|
+
* The account's bound Solix devices (flat list; may be empty when devices live under sites). The
|
|
26003
|
+
* gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
|
|
26004
|
+
* beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
|
|
26005
|
+
*/
|
|
26006
|
+
async getDevices() {
|
|
26007
|
+
const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
|
|
26008
|
+
return Array.isArray(data) ? data : data?.data ?? [];
|
|
26009
|
+
}
|
|
26010
|
+
/** The account's sites (systems); devices are typically grouped under a site. */
|
|
26011
|
+
async getSites() {
|
|
26012
|
+
const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
|
|
26013
|
+
return data?.site_list ?? [];
|
|
26014
|
+
}
|
|
26015
|
+
/** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
|
|
26016
|
+
async getUserMqttInfo() {
|
|
26017
|
+
return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
|
|
26018
|
+
}
|
|
26019
|
+
/**
|
|
26020
|
+
* The pairable-product catalog (categories → products). This is Anker's product registry, not the
|
|
26021
|
+
* account's devices — fetch it to label a discovered device's model code with a marketing name and
|
|
26022
|
+
* category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
|
|
26023
|
+
* baked-in table.
|
|
26024
|
+
*/
|
|
26025
|
+
async getProductCatalog() {
|
|
26026
|
+
return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
|
|
26027
|
+
}
|
|
26028
|
+
};
|
|
26029
|
+
|
|
26030
|
+
// dist/transport/mqtt/solix-mqtt.js
|
|
26031
|
+
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
26032
|
+
var SOLIX_METER_FIELD_NAMES = {
|
|
26033
|
+
172: "meterVoltageL1"
|
|
26034
|
+
};
|
|
26035
|
+
function readSolixChannel(value) {
|
|
26036
|
+
if (!value || value.length < 1)
|
|
26037
|
+
return void 0;
|
|
26038
|
+
const raw = value.subarray(1);
|
|
26039
|
+
const ch = { type: value[0], raw };
|
|
26040
|
+
if (raw.length === 4) {
|
|
26041
|
+
ch.float = raw.readFloatLE(0);
|
|
26042
|
+
ch.uint = raw.readUInt32LE(0);
|
|
26043
|
+
}
|
|
26044
|
+
return ch;
|
|
26045
|
+
}
|
|
26046
|
+
function decodeSolixParamFrame(buf) {
|
|
26047
|
+
if (buf.length < 10 || buf[0] !== 255 || buf[1] !== 9)
|
|
26048
|
+
return null;
|
|
26049
|
+
const declaredLen = buf.readUInt16LE(2);
|
|
26050
|
+
if (declaredLen < 5 || declaredLen > buf.length)
|
|
26051
|
+
return null;
|
|
26052
|
+
let xor = 0;
|
|
26053
|
+
for (let i = 0; i < declaredLen; i++)
|
|
26054
|
+
xor ^= buf[i];
|
|
26055
|
+
if (xor !== 0)
|
|
26056
|
+
return null;
|
|
26057
|
+
const end = declaredLen - 1;
|
|
26058
|
+
const start = buf.indexOf(161, 4);
|
|
26059
|
+
if (start < 0 || start >= end)
|
|
26060
|
+
return { fields: /* @__PURE__ */ new Map() };
|
|
26061
|
+
const fields = walkFf09Tlv(buf, start, end);
|
|
26062
|
+
let deviceSn;
|
|
26063
|
+
const a2 = fields.get(162);
|
|
26064
|
+
if (a2 && a2.length > 1)
|
|
26065
|
+
deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
|
|
26066
|
+
return { deviceSn, fields };
|
|
26067
|
+
}
|
|
26068
|
+
function solixReadings(frame) {
|
|
26069
|
+
const out = {};
|
|
26070
|
+
for (const [tag2, value] of frame.fields) {
|
|
26071
|
+
if (tag2 < 166)
|
|
26072
|
+
continue;
|
|
26073
|
+
const ch = readSolixChannel(value);
|
|
26074
|
+
if (ch?.type !== 5 || ch.float === void 0)
|
|
26075
|
+
continue;
|
|
26076
|
+
out[`channel_${tag2.toString(16)}`] = ch.float;
|
|
26077
|
+
const name = SOLIX_METER_FIELD_NAMES[tag2];
|
|
26078
|
+
if (name)
|
|
26079
|
+
out[name] = ch.float;
|
|
26080
|
+
}
|
|
26081
|
+
return out;
|
|
26082
|
+
}
|
|
26083
|
+
var SolixMqtt = class extends EventEmitter10 {
|
|
26084
|
+
transport;
|
|
26085
|
+
appName;
|
|
26086
|
+
userId;
|
|
26087
|
+
appClientId;
|
|
26088
|
+
armIntervalMs;
|
|
26089
|
+
logger;
|
|
26090
|
+
siteId;
|
|
26091
|
+
watched = /* @__PURE__ */ new Map();
|
|
26092
|
+
seq = 0;
|
|
26093
|
+
armTimer;
|
|
26094
|
+
/**
|
|
26095
|
+
* Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
|
|
26096
|
+
* (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
|
|
26097
|
+
* restart presents itself to the broker as a new client, so it defaults deterministically from the user
|
|
26098
|
+
* id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
|
|
26099
|
+
*/
|
|
26100
|
+
constructor(opts) {
|
|
26101
|
+
super();
|
|
26102
|
+
this.appName = opts.mqttInfo.app_name ?? "anker_power";
|
|
26103
|
+
this.userId = opts.userId ?? opts.mqttInfo.user_id;
|
|
26104
|
+
this.armIntervalMs = opts.armIntervalMs ?? 25e3;
|
|
26105
|
+
this.siteId = opts.siteId;
|
|
26106
|
+
this.logger = opts.logger;
|
|
26107
|
+
const uid = this.userId ?? "anonymous";
|
|
26108
|
+
this.appClientId = opts.appClientId ?? buildAppShapedClientId({
|
|
26109
|
+
appName: this.appName,
|
|
26110
|
+
uid,
|
|
26111
|
+
mqttUuid: opts.mqttUuid ?? mqttUuidFrom(`anker-solix-mqtt:${uid}`)
|
|
26112
|
+
});
|
|
26113
|
+
this.transport = new SecureMqtt({
|
|
26114
|
+
credentials: opts.mqttInfo,
|
|
26115
|
+
clientId: opts.clientId ?? opts.mqttInfo.thing_name,
|
|
26116
|
+
reconnectPeriod: 5e3,
|
|
26117
|
+
logger: opts.logger
|
|
26118
|
+
});
|
|
26119
|
+
this.transport.on("error", (e) => this.emit("error", e));
|
|
26120
|
+
this.transport.on("message", (msg) => this.onMessage(msg));
|
|
26121
|
+
}
|
|
26122
|
+
/**
|
|
26123
|
+
* Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
|
|
26124
|
+
* start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
|
|
26125
|
+
*
|
|
26126
|
+
* Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
|
|
26127
|
+
* channels — never the `…/req` channels, which are the app→device request side this arms on, and would
|
|
26128
|
+
* echo its own publishes back.
|
|
26129
|
+
*
|
|
26130
|
+
* Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
|
|
26131
|
+
* than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
|
|
26132
|
+
* success: the call would resolve and arm on every interval while no reading ever arrives.
|
|
26133
|
+
*
|
|
26134
|
+
* The re-arm timer is unreffed, so a caller that watches and returns can still exit.
|
|
26135
|
+
*/
|
|
26136
|
+
async watch(device) {
|
|
26137
|
+
await this.transport.connect();
|
|
26138
|
+
const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
|
|
26139
|
+
const granted = await this.transport.subscribe([
|
|
26140
|
+
topics.paramInfo,
|
|
26141
|
+
topics.cmdRes,
|
|
26142
|
+
...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
|
|
26143
|
+
]);
|
|
26144
|
+
if (!granted.includes(topics.paramInfo)) {
|
|
26145
|
+
const scope = this.appName;
|
|
26146
|
+
throw new Error(`watch ${device.device_sn}: telemetry topic "${topics.paramInfo}" denied on credential scope "${scope}" \u2014 the subscription would arm but never deliver a reading`);
|
|
26147
|
+
}
|
|
26148
|
+
this.watched.set(device.device_sn, device);
|
|
26149
|
+
if (this.armIntervalMs > 0) {
|
|
26150
|
+
await this.armAll();
|
|
26151
|
+
if (!this.armTimer) {
|
|
26152
|
+
this.armTimer = setInterval(() => void this.armAll(), this.armIntervalMs);
|
|
26153
|
+
this.armTimer.unref?.();
|
|
26154
|
+
}
|
|
26155
|
+
}
|
|
26156
|
+
}
|
|
26157
|
+
/** Tear down the connection and stop the re-arm timer. */
|
|
26158
|
+
async close() {
|
|
26159
|
+
if (this.armTimer) {
|
|
26160
|
+
clearInterval(this.armTimer);
|
|
26161
|
+
this.armTimer = void 0;
|
|
26162
|
+
}
|
|
26163
|
+
this.watched.clear();
|
|
26164
|
+
await this.transport.disconnect();
|
|
26165
|
+
}
|
|
26166
|
+
/**
|
|
26167
|
+
* Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
|
|
26168
|
+
* a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
|
|
26169
|
+
* heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
|
|
26170
|
+
* (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
|
|
26171
|
+
* not thrown, so one bad device doesn't stop the rest or kill the timer.
|
|
26172
|
+
*/
|
|
26173
|
+
async armAll() {
|
|
26174
|
+
for (const device of this.watched.values()) {
|
|
26175
|
+
try {
|
|
26176
|
+
await this.arm(device);
|
|
26177
|
+
} catch (e) {
|
|
26178
|
+
this.emit("error", e);
|
|
26179
|
+
}
|
|
26180
|
+
}
|
|
26181
|
+
if (this.userId) {
|
|
26182
|
+
try {
|
|
26183
|
+
await this.transport.publish(solixUserTopics(this.appName, this.userId).powerSite, this.heartbeatEnvelope(), {
|
|
26184
|
+
qos: 1
|
|
26185
|
+
});
|
|
26186
|
+
} catch (e) {
|
|
26187
|
+
this.emit("error", e);
|
|
26188
|
+
}
|
|
26189
|
+
}
|
|
26190
|
+
}
|
|
26191
|
+
/** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
|
|
26192
|
+
async arm(device) {
|
|
26193
|
+
const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
|
|
26194
|
+
for (const variant of ["info", "realtime"]) {
|
|
26195
|
+
const body = this.commandEnvelope(device, buildFf09Request(variant), variant === "info" ? { encoding_type: 2 } : {});
|
|
26196
|
+
await this.transport.publish(topic, body, { qos: 1 });
|
|
26197
|
+
}
|
|
26198
|
+
this.logger?.debug?.(`[solix] armed ${device.device_sn} (param_info reporting requested)`);
|
|
26199
|
+
}
|
|
26200
|
+
/** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
|
|
26201
|
+
makeHead(cmd, extra) {
|
|
26202
|
+
return {
|
|
26203
|
+
version: "1.0.0.1",
|
|
26204
|
+
client_id: this.appClientId,
|
|
26205
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
26206
|
+
cmd_status: 2,
|
|
26207
|
+
sign_code: 1,
|
|
26208
|
+
cmd,
|
|
26209
|
+
...extra
|
|
26210
|
+
};
|
|
26211
|
+
}
|
|
26212
|
+
/**
|
|
26213
|
+
* Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
|
|
26214
|
+
* `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
|
|
26215
|
+
* from a real value, so sending `""` would claim an account this client does not have.
|
|
26216
|
+
*/
|
|
26217
|
+
commandEnvelope(device, frame, extra) {
|
|
26218
|
+
this.seq += 1;
|
|
26219
|
+
return JSON.stringify({
|
|
26220
|
+
head: this.makeHead(17, {
|
|
26221
|
+
sess_id: genId(),
|
|
26222
|
+
msg_seq: this.seq,
|
|
26223
|
+
seed: genId(),
|
|
26224
|
+
device_pn: device.product_code,
|
|
26225
|
+
device_sn: device.device_sn
|
|
26226
|
+
}),
|
|
26227
|
+
payload: JSON.stringify({
|
|
26228
|
+
device_sn: device.device_sn,
|
|
26229
|
+
...this.userId ? { account_id: this.userId } : {},
|
|
26230
|
+
data: frame.toString("base64"),
|
|
26231
|
+
...extra
|
|
26232
|
+
})
|
|
26233
|
+
});
|
|
26234
|
+
}
|
|
26235
|
+
/**
|
|
26236
|
+
* The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
|
|
26237
|
+
* `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
|
|
26238
|
+
*/
|
|
26239
|
+
heartbeatEnvelope() {
|
|
26240
|
+
return JSON.stringify({
|
|
26241
|
+
head: this.makeHead(10, { sess_id: "1", msg_seq: 1, seed: "1" }),
|
|
26242
|
+
payload: JSON.stringify({ user_id: this.userId ?? "", ...this.siteId ? { site_id: this.siteId } : {} })
|
|
26243
|
+
});
|
|
26244
|
+
}
|
|
26245
|
+
/**
|
|
26246
|
+
* Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
|
|
26247
|
+
* product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
|
|
26248
|
+
* own `a2` field wins for the serial when it carries one.
|
|
26249
|
+
*/
|
|
26250
|
+
onMessage(msg) {
|
|
26251
|
+
const topic = msg.topic ?? "";
|
|
26252
|
+
const buf = extractFf09Payload(msg.raw);
|
|
26253
|
+
if (!buf)
|
|
26254
|
+
return;
|
|
26255
|
+
const frame = decodeSolixParamFrame(buf);
|
|
26256
|
+
if (!frame)
|
|
26257
|
+
return;
|
|
26258
|
+
const parts = topic.split("/");
|
|
26259
|
+
const reading = {
|
|
26260
|
+
deviceSn: frame.deviceSn ?? parts[3] ?? "",
|
|
26261
|
+
productCode: parts[2] ?? "",
|
|
26262
|
+
topic,
|
|
26263
|
+
frame,
|
|
26264
|
+
values: solixReadings(frame)
|
|
26265
|
+
};
|
|
26266
|
+
this.emit("reading", reading);
|
|
26267
|
+
}
|
|
26268
|
+
};
|
|
26269
|
+
function extractFf09Payload(raw) {
|
|
26270
|
+
if (Buffer.isBuffer(raw))
|
|
26271
|
+
return raw;
|
|
26272
|
+
if (!raw || typeof raw !== "object")
|
|
26273
|
+
return null;
|
|
26274
|
+
const env = raw;
|
|
26275
|
+
let payload = env.payload;
|
|
26276
|
+
if (typeof payload === "string") {
|
|
26277
|
+
try {
|
|
26278
|
+
payload = JSON.parse(payload);
|
|
26279
|
+
} catch {
|
|
26280
|
+
return null;
|
|
26281
|
+
}
|
|
26282
|
+
}
|
|
26283
|
+
const p = payload;
|
|
26284
|
+
const data = p?.data ?? p?.trans ?? env.data;
|
|
26285
|
+
if (typeof data !== "string")
|
|
26286
|
+
return null;
|
|
26287
|
+
const buf = Buffer.from(data, "base64");
|
|
26288
|
+
return buf.length ? buf : null;
|
|
26289
|
+
}
|
|
26290
|
+
function buildFf09Request(variant, atUnixSec) {
|
|
26291
|
+
const ts = Buffer.alloc(4);
|
|
26292
|
+
ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
|
|
26293
|
+
const body = variant === "info" ? Buffer.concat([Buffer.from([3, 0, 15, 0, 64, 161, 1, 34, 254, 4]), ts]) : Buffer.concat([
|
|
26294
|
+
Buffer.from([
|
|
26295
|
+
3,
|
|
26296
|
+
0,
|
|
26297
|
+
15,
|
|
26298
|
+
0,
|
|
26299
|
+
87,
|
|
26300
|
+
161,
|
|
26301
|
+
1,
|
|
26302
|
+
34,
|
|
26303
|
+
162,
|
|
26304
|
+
2,
|
|
26305
|
+
1,
|
|
26306
|
+
1,
|
|
26307
|
+
163,
|
|
26308
|
+
3,
|
|
26309
|
+
2,
|
|
26310
|
+
44,
|
|
26311
|
+
1,
|
|
26312
|
+
254,
|
|
26313
|
+
5,
|
|
26314
|
+
3
|
|
26315
|
+
]),
|
|
26316
|
+
ts
|
|
26317
|
+
]);
|
|
26318
|
+
const frame = Buffer.alloc(body.length + 5);
|
|
26319
|
+
frame[0] = 255;
|
|
26320
|
+
frame[1] = 9;
|
|
26321
|
+
frame.writeUInt16LE(frame.length, 2);
|
|
26322
|
+
body.copy(frame, 4);
|
|
26323
|
+
let xor = 0;
|
|
26324
|
+
for (let i = 0; i < frame.length - 1; i++)
|
|
26325
|
+
xor ^= frame[i];
|
|
26326
|
+
frame[frame.length - 1] = xor;
|
|
26327
|
+
return frame;
|
|
26328
|
+
}
|
|
26329
|
+
|
|
25220
26330
|
// dist/transport/tuya/index.js
|
|
25221
26331
|
var tuya_exports = {};
|
|
25222
26332
|
__export(tuya_exports, {
|
|
@@ -25257,6 +26367,7 @@ export {
|
|
|
25257
26367
|
AUDIO_MEMBERS,
|
|
25258
26368
|
AccessUnitAssembler,
|
|
25259
26369
|
AiDetectType,
|
|
26370
|
+
AlarmDelayMode,
|
|
25260
26371
|
ArmingMode,
|
|
25261
26372
|
BATTERY_MEMBERS,
|
|
25262
26373
|
BIZ_CHANNEL,
|
|
@@ -25277,6 +26388,7 @@ export {
|
|
|
25277
26388
|
CusPushEvent,
|
|
25278
26389
|
CusPushMode,
|
|
25279
26390
|
DEFAULT_KEEPALIVE_MS,
|
|
26391
|
+
DISPLAY_MEMBERS,
|
|
25280
26392
|
DOCK_ACTIVITIES,
|
|
25281
26393
|
DOCK_KINDS,
|
|
25282
26394
|
DOORBELL_MEMBERS,
|
|
@@ -25330,6 +26442,7 @@ export {
|
|
|
25330
26442
|
P256,
|
|
25331
26443
|
P2PSession,
|
|
25332
26444
|
P2P_ENVELOPE,
|
|
26445
|
+
P2P_STATION_WAITS,
|
|
25333
26446
|
PRINTER_CATEGORY_RE,
|
|
25334
26447
|
PTZ_MEMBERS,
|
|
25335
26448
|
PowerSource,
|
|
@@ -25349,6 +26462,8 @@ export {
|
|
|
25349
26462
|
SIREN_MEMBERS,
|
|
25350
26463
|
SMART_LIGHT_MEMBERS,
|
|
25351
26464
|
SMOKE_MEMBERS,
|
|
26465
|
+
SOLIX_ENERGY_METER_MEMBERS,
|
|
26466
|
+
SOLIX_LOCAL_KEY_HEX,
|
|
25352
26467
|
STATE_EVENT_FIELDS,
|
|
25353
26468
|
STATION_CHANNEL3 as STATION_CHANNEL,
|
|
25354
26469
|
STATION_CHUNK_BYTES,
|
|
@@ -25361,8 +26476,13 @@ export {
|
|
|
25361
26476
|
SirenAlarmDuration,
|
|
25362
26477
|
SirenVolume,
|
|
25363
26478
|
SmartDropPushEvent,
|
|
26479
|
+
SolixClient,
|
|
26480
|
+
SolixDevice,
|
|
26481
|
+
SolixMqtt,
|
|
25364
26482
|
StateConvergenceError,
|
|
25365
26483
|
StationBusyError,
|
|
26484
|
+
StationKeyUnavailableError,
|
|
26485
|
+
StationUnreachableError,
|
|
25366
26486
|
StoredSnapshotUnavailableError,
|
|
25367
26487
|
StreamingQuality,
|
|
25368
26488
|
SuctionLevel,
|
|
@@ -25393,6 +26513,7 @@ export {
|
|
|
25393
26513
|
buildDeviceNameBody,
|
|
25394
26514
|
buildDirectBinaryBody,
|
|
25395
26515
|
buildEventIndex,
|
|
26516
|
+
buildModelIndex,
|
|
25396
26517
|
buildRealtimeInit,
|
|
25397
26518
|
captureSnapshotFromShared,
|
|
25398
26519
|
cellAtPoint,
|
|
@@ -25430,6 +26551,7 @@ export {
|
|
|
25430
26551
|
detectCapabilities,
|
|
25431
26552
|
detectionName,
|
|
25432
26553
|
discoverReachableInstance,
|
|
26554
|
+
discoverSolixDevices,
|
|
25433
26555
|
encodeAiDetectType,
|
|
25434
26556
|
encodeVarint,
|
|
25435
26557
|
encryptBody,
|
|
@@ -25443,7 +26565,6 @@ export {
|
|
|
25443
26565
|
freshestLanIp,
|
|
25444
26566
|
genId,
|
|
25445
26567
|
generateFid,
|
|
25446
|
-
generateMqttUuid,
|
|
25447
26568
|
getCapabilityModule,
|
|
25448
26569
|
getIdSuffix,
|
|
25449
26570
|
getImageBaseCode,
|
|
@@ -25456,6 +26577,7 @@ export {
|
|
|
25456
26577
|
inferName,
|
|
25457
26578
|
inspectParams,
|
|
25458
26579
|
isKnownValueKind,
|
|
26580
|
+
isNotAuthorized,
|
|
25459
26581
|
isPrivateIpv4,
|
|
25460
26582
|
isSessionValid,
|
|
25461
26583
|
isV1Image,
|
|
@@ -25467,10 +26589,12 @@ export {
|
|
|
25467
26589
|
lz4BlockDecompress,
|
|
25468
26590
|
mapCellValue,
|
|
25469
26591
|
mapCellValueAt,
|
|
26592
|
+
md5Hex,
|
|
25470
26593
|
mergeCandidateIps,
|
|
25471
26594
|
mergeProperties,
|
|
25472
26595
|
mqttAppName,
|
|
25473
26596
|
mqttScopeFor,
|
|
26597
|
+
mqttUuidFrom,
|
|
25474
26598
|
namespaceForCodec,
|
|
25475
26599
|
needsRealtimeInit,
|
|
25476
26600
|
noopLogger,
|
|
@@ -25510,9 +26634,12 @@ export {
|
|
|
25510
26634
|
secureTopic,
|
|
25511
26635
|
signKey,
|
|
25512
26636
|
signRequest,
|
|
26637
|
+
solixDeviceTopics,
|
|
26638
|
+
solixUserTopics,
|
|
25513
26639
|
structuralEqual,
|
|
25514
26640
|
subscribeTopics,
|
|
25515
26641
|
suctionLevelName,
|
|
26642
|
+
tokenNotExpired,
|
|
25516
26643
|
tuya_exports as tuya,
|
|
25517
26644
|
u16be,
|
|
25518
26645
|
u16le,
|