@mega-yfue/eufy-sdk 0.2.0-beta.1 → 0.2.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/eufy-mega.d.ts +16 -11
- package/dist/core/contracts.d.ts +57 -27
- 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 +1446 -192
- 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/solix-client.d.ts +158 -0
- package/dist/transport/http/solix-constants.d.ts +29 -0
- package/dist/transport/mqtt/index.d.ts +2 -0
- package/dist/transport/mqtt/secure-mqtt.d.ts +14 -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 +143 -15
- package/dist/transport/p2p/index.d.ts +1 -0
- package/dist/transport/p2p/live-stream.d.ts +5 -4
- 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/dist/transport/p2p/session-manager.d.ts +57 -23
- package/dist/transport/p2p/shared-live-source.d.ts +10 -1
- 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
|
|
@@ -1810,6 +1817,14 @@ function parseSecureTopic(topic) {
|
|
|
1810
1817
|
return void 0;
|
|
1811
1818
|
return { root, category, model, sn, tail: parts.slice(4).join("/") };
|
|
1812
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
|
+
}
|
|
1813
1828
|
|
|
1814
1829
|
// dist/transport/mqtt/secure-mqtt.js
|
|
1815
1830
|
var SUBACK_FAILURE = 128;
|
|
@@ -1903,7 +1918,7 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1903
1918
|
* four topics for `eufy_life`).
|
|
1904
1919
|
*
|
|
1905
1920
|
* The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
|
|
1906
|
-
*
|
|
1921
|
+
* SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
|
|
1907
1922
|
* whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
|
|
1908
1923
|
* topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
|
|
1909
1924
|
* line that grants its state channel but refuses (say) the OTA leg still works.
|
|
@@ -1912,9 +1927,8 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1912
1927
|
if (!this.client)
|
|
1913
1928
|
throw new Error("SecureMqtt not connected");
|
|
1914
1929
|
const topics = [...subscribeTopics(device)];
|
|
1915
|
-
const
|
|
1930
|
+
const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
|
|
1916
1931
|
const scope = this.o.credentials.app_name ?? "default";
|
|
1917
|
-
const denied = grants.filter((g) => g.qos === SUBACK_FAILURE).map((g) => g.topic);
|
|
1918
1932
|
if (denied.length === topics.length) {
|
|
1919
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(", ")})`);
|
|
1920
1934
|
}
|
|
@@ -1922,6 +1936,29 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
1922
1936
|
this.emit("error", new Error(`subscribe ${device.sn}: "${topic}" denied on credential scope "${scope}"`));
|
|
1923
1937
|
}
|
|
1924
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
|
+
}
|
|
1925
1962
|
/**
|
|
1926
1963
|
* Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
|
|
1927
1964
|
* is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the
|
|
@@ -2556,14 +2593,26 @@ var CameraDisabledError = class extends Error {
|
|
|
2556
2593
|
this.name = "CameraDisabledError";
|
|
2557
2594
|
}
|
|
2558
2595
|
};
|
|
2559
|
-
var
|
|
2560
|
-
|
|
2561
|
-
/** Always true: the
|
|
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. */
|
|
2562
2610
|
retryable = true;
|
|
2563
|
-
constructor(
|
|
2564
|
-
super(`
|
|
2565
|
-
this.
|
|
2566
|
-
this.
|
|
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";
|
|
2567
2616
|
}
|
|
2568
2617
|
};
|
|
2569
2618
|
var LiveStreamStartError = class extends Error {
|
|
@@ -6890,6 +6939,26 @@ var KEYPAD = {
|
|
|
6890
6939
|
// dist/model/capabilities/arming.js
|
|
6891
6940
|
var STATION_CHANNEL2 = 255;
|
|
6892
6941
|
var ArmingMode = {
|
|
6942
|
+
/** Armed — full protection, nobody home (wire value 0). */
|
|
6943
|
+
away: "away",
|
|
6944
|
+
/** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
|
|
6945
|
+
home: "home",
|
|
6946
|
+
/** Scheduled — the station follows the timetable configured in the app (wire value 2). */
|
|
6947
|
+
schedule: "schedule",
|
|
6948
|
+
/** Custom 1 — a user-defined posture configured in the app (wire value 3). */
|
|
6949
|
+
custom1: "custom1",
|
|
6950
|
+
/** Custom 2 — a user-defined posture configured in the app (wire value 4). */
|
|
6951
|
+
custom2: "custom2",
|
|
6952
|
+
/** Custom 3 — a user-defined posture configured in the app (wire value 5). */
|
|
6953
|
+
custom3: "custom3",
|
|
6954
|
+
/** Off — the station's alarm system is switched off entirely (wire value 6). */
|
|
6955
|
+
off: "off",
|
|
6956
|
+
/** Geofenced — the station follows the app's location-based rules (wire value 47). */
|
|
6957
|
+
geo: "geo",
|
|
6958
|
+
/** Disarmed — no alarms; sensors still report state (wire value 63). */
|
|
6959
|
+
disarmed: "disarmed"
|
|
6960
|
+
};
|
|
6961
|
+
var AlarmDelayMode = {
|
|
6893
6962
|
/** Armed — full protection, nobody home (wire value 0). */
|
|
6894
6963
|
away: "away",
|
|
6895
6964
|
/** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
|
|
@@ -6905,10 +6974,11 @@ var ARMING_CMD = {
|
|
|
6905
6974
|
* mValue3:0, `payload:{mode_type:<int>, user_name:<string>}`.
|
|
6906
6975
|
*
|
|
6907
6976
|
* ⚠️ Only 3 of the 9 modes were exercised in that capture — `mode_type` 0 (away), 63 (disarmed), 1
|
|
6908
|
-
* (home), all confirmed byte-exact
|
|
6909
|
-
*
|
|
6910
|
-
*
|
|
6911
|
-
*
|
|
6977
|
+
* (home), all confirmed byte-exact. Re-confirmed live 2026-08-05: each reported its own MODE_SWITCH push
|
|
6978
|
+
* within ~5s of the write. The remaining six are live confirmations rather than captures — `custom1` 3
|
|
6979
|
+
* first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
|
|
6980
|
+
* frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
|
|
6981
|
+
* the per-value evidence and the dates.
|
|
6912
6982
|
*/
|
|
6913
6983
|
SET_ARMING: 1224,
|
|
6914
6984
|
/**
|
|
@@ -6942,21 +7012,14 @@ var ARMING_MODE_WIRE = {
|
|
|
6942
7012
|
away: 0,
|
|
6943
7013
|
home: 1,
|
|
6944
7014
|
schedule: 2,
|
|
6945
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6946
7015
|
custom1: 3,
|
|
6947
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6948
7016
|
custom2: 4,
|
|
6949
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6950
7017
|
custom3: 5,
|
|
6951
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6952
7018
|
off: 6,
|
|
6953
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6954
7019
|
geo: 47,
|
|
6955
|
-
// ⚠️ reportable, NOT settable — see the doc comment above
|
|
6956
7020
|
disarmed: 63
|
|
6957
7021
|
};
|
|
6958
7022
|
var ARMING_MODE_LABELS = enumLabels(ARMING_MODE_WIRE);
|
|
6959
|
-
var SETTABLE_MODES = Object.values(ArmingMode).map((m) => ARMING_MODE_WIRE[m]);
|
|
6960
7023
|
function armingModeOf(v) {
|
|
6961
7024
|
const name = String(v);
|
|
6962
7025
|
if (name in ArmingMode)
|
|
@@ -6965,11 +7028,10 @@ function armingModeOf(v) {
|
|
|
6965
7028
|
return Object.values(ArmingMode).find((m) => ARMING_MODE_WIRE[m] === wire);
|
|
6966
7029
|
}
|
|
6967
7030
|
function armingCommand(mode, ctx) {
|
|
6968
|
-
const modeType = ARMING_MODE_WIRE[mode];
|
|
6969
7031
|
if (!ctx.accountName) {
|
|
6970
7032
|
throw new Error(`arming: missing account identity (user_name) [${describeDevice(ctx)}]`);
|
|
6971
7033
|
}
|
|
6972
|
-
return setPayload(ARMING_CMD.SET_ARMING, { mode_type:
|
|
7034
|
+
return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0);
|
|
6973
7035
|
}
|
|
6974
7036
|
function alarmDelayCommand(mode, config, ctx) {
|
|
6975
7037
|
const data = {
|
|
@@ -6986,11 +7048,10 @@ function alarmDelayCommand(mode, config, ctx) {
|
|
|
6986
7048
|
}
|
|
6987
7049
|
var ARMING_MEMBERS = {
|
|
6988
7050
|
/**
|
|
6989
|
-
*
|
|
6990
|
-
*
|
|
6991
|
-
*
|
|
6992
|
-
*
|
|
6993
|
-
* declaration.
|
|
7051
|
+
* Read and write are the same nine modes, so `enumValues` is the whole domain: `writeDomain` falls back
|
|
7052
|
+
* to it, and the derived setter, the refusal message and the offered argument all read from that one
|
|
7053
|
+
* declaration. A member states an `args` entry only where the two sides DIFFER. See
|
|
7054
|
+
* {@link ARMING_MODE_WIRE} for the per-value evidence.
|
|
6994
7055
|
*
|
|
6995
7056
|
* `armingCommand` may also throw synchronously (missing account identity) and `bindMembers` turns that
|
|
6996
7057
|
* into a rejection, so the builder stays plain.
|
|
@@ -7009,8 +7070,7 @@ var ARMING_MEMBERS = {
|
|
|
7009
7070
|
kind: "enum",
|
|
7010
7071
|
enumValues: ARMING_MODE_LABELS,
|
|
7011
7072
|
provenance: "verified",
|
|
7012
|
-
|
|
7013
|
-
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.",
|
|
7073
|
+
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.",
|
|
7014
7074
|
observation: {
|
|
7015
7075
|
event: "armingModeChanged",
|
|
7016
7076
|
reflects: (value) => ({ param: ARMING_CMD.SET_ARMING, expected: ARMING_MODE_WIRE[armingModeOf(value)] }),
|
|
@@ -7031,9 +7091,10 @@ var ARMING_MEMBERS = {
|
|
|
7031
7091
|
* this mode — there is no known GET to fetch it automatically, and a wrong guess here can silently
|
|
7032
7092
|
* misconfigure which sensors arm/trigger for real.
|
|
7033
7093
|
*
|
|
7034
|
-
* Takes {@link
|
|
7035
|
-
* captured
|
|
7036
|
-
* `
|
|
7094
|
+
* Takes {@link AlarmDelayMode}, not {@link ArmingMode}: a delay is configurable only for a mode whose
|
|
7095
|
+
* integer is captured on THIS command, and `custom1` is confirmed on cmd 1224 only. The frame carries
|
|
7096
|
+
* that integer in `mode_id` with no runtime validation and no readback, so a mode outside this union
|
|
7097
|
+
* would be the same unverified guess `setMode` refuses.
|
|
7037
7098
|
*/
|
|
7038
7099
|
setAlarmDelayConfig: method(({ ctx, sink }) => (mode, config) => {
|
|
7039
7100
|
try {
|
|
@@ -7704,6 +7765,89 @@ var ModeCtrlMethod = {
|
|
|
7704
7765
|
STOP_SMART_FOLLOW: 18,
|
|
7705
7766
|
START_GLOBAL_CRUISE: 20
|
|
7706
7767
|
};
|
|
7768
|
+
var ModeCtrlParamMethod = {
|
|
7769
|
+
/** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
|
|
7770
|
+
SELECT_ROOMS: { method: 1, param: 4 },
|
|
7771
|
+
/** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
|
|
7772
|
+
SELECT_ZONES: { method: 2, param: 5 },
|
|
7773
|
+
/** `START_GOTO_CLEAN` — drive to a point and clean around it. */
|
|
7774
|
+
GOTO: { method: 4, param: 7 },
|
|
7775
|
+
/** `START_SCENE_CLEAN` — run a saved scene by its id. */
|
|
7776
|
+
SCENE: { method: 24, param: 14 }
|
|
7777
|
+
};
|
|
7778
|
+
var SELECT_ROOMS_FIELD = {
|
|
7779
|
+
/** `rooms` — repeated, one entry per room. */
|
|
7780
|
+
ROOMS: 1,
|
|
7781
|
+
/** `clean_times` — how many passes to make. */
|
|
7782
|
+
CLEAN_TIMES: 2,
|
|
7783
|
+
/** `map_id` — WHICH saved map the room ids belong to. */
|
|
7784
|
+
MAP_ID: 3,
|
|
7785
|
+
/** `id` within a `Room`. */
|
|
7786
|
+
ROOM_ID: 1,
|
|
7787
|
+
/** `order` within a `Room` — the sequence to visit them in. */
|
|
7788
|
+
ROOM_ORDER: 2
|
|
7789
|
+
};
|
|
7790
|
+
var SELECT_ZONES_FIELD = {
|
|
7791
|
+
/** `zones` — repeated, one entry per rectangle. */
|
|
7792
|
+
ZONES: 1,
|
|
7793
|
+
/** `map_id` — which saved map the coordinates belong to. */
|
|
7794
|
+
MAP_ID: 2,
|
|
7795
|
+
/** `quadrangle` within a `Zone` — its four corners. */
|
|
7796
|
+
QUADRANGLE: 1,
|
|
7797
|
+
/** `clean_times` within a `Zone`. */
|
|
7798
|
+
ZONE_CLEAN_TIMES: 2,
|
|
7799
|
+
/** `x` within a `Point`, SIGNED centimetres. */
|
|
7800
|
+
POINT_X: 1,
|
|
7801
|
+
/** `y` within a `Point`, SIGNED centimetres. */
|
|
7802
|
+
POINT_Y: 2
|
|
7803
|
+
};
|
|
7804
|
+
var SCENE_CLEAN_ID = 1;
|
|
7805
|
+
function encodeModeCtrlParam(method2, paramField, build) {
|
|
7806
|
+
return rawDp((w) => {
|
|
7807
|
+
if (method2 !== 0)
|
|
7808
|
+
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
7809
|
+
w.int(MODE_CTRL_FIELD.SEQ, nextModeCtrlSeq());
|
|
7810
|
+
w.sub(paramField, build);
|
|
7811
|
+
});
|
|
7812
|
+
}
|
|
7813
|
+
function encodeSelectRoomsClean(mapId, rooms, cleanTimes = 1) {
|
|
7814
|
+
const { method: method2, param } = ModeCtrlParamMethod.SELECT_ROOMS;
|
|
7815
|
+
return encodeModeCtrlParam(method2, param, (p) => {
|
|
7816
|
+
for (const room of rooms) {
|
|
7817
|
+
p.sub(SELECT_ROOMS_FIELD.ROOMS, (r) => {
|
|
7818
|
+
r.int(SELECT_ROOMS_FIELD.ROOM_ID, room.id);
|
|
7819
|
+
if (room.order !== void 0)
|
|
7820
|
+
r.int(SELECT_ROOMS_FIELD.ROOM_ORDER, room.order);
|
|
7821
|
+
});
|
|
7822
|
+
}
|
|
7823
|
+
p.int(SELECT_ROOMS_FIELD.CLEAN_TIMES, cleanTimes);
|
|
7824
|
+
p.int(SELECT_ROOMS_FIELD.MAP_ID, mapId);
|
|
7825
|
+
});
|
|
7826
|
+
}
|
|
7827
|
+
function encodeSelectZonesClean(mapId, zones) {
|
|
7828
|
+
const { method: method2, param } = ModeCtrlParamMethod.SELECT_ZONES;
|
|
7829
|
+
return encodeModeCtrlParam(method2, param, (p) => {
|
|
7830
|
+
for (const zone of zones) {
|
|
7831
|
+
p.sub(SELECT_ZONES_FIELD.ZONES, (z) => {
|
|
7832
|
+
z.sub(SELECT_ZONES_FIELD.QUADRANGLE, (q) => {
|
|
7833
|
+
for (const [i, corner] of zone.corners.entries()) {
|
|
7834
|
+
q.sub(i + 1, (pt) => {
|
|
7835
|
+
pt.sint(SELECT_ZONES_FIELD.POINT_X, corner.x);
|
|
7836
|
+
pt.sint(SELECT_ZONES_FIELD.POINT_Y, corner.y);
|
|
7837
|
+
});
|
|
7838
|
+
}
|
|
7839
|
+
});
|
|
7840
|
+
if (zone.cleanTimes !== void 0)
|
|
7841
|
+
z.int(SELECT_ZONES_FIELD.ZONE_CLEAN_TIMES, zone.cleanTimes);
|
|
7842
|
+
});
|
|
7843
|
+
}
|
|
7844
|
+
p.int(SELECT_ZONES_FIELD.MAP_ID, mapId);
|
|
7845
|
+
});
|
|
7846
|
+
}
|
|
7847
|
+
function encodeSceneClean(sceneId) {
|
|
7848
|
+
const { method: method2, param } = ModeCtrlParamMethod.SCENE;
|
|
7849
|
+
return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
|
|
7850
|
+
}
|
|
7707
7851
|
var modeCtrlSeq = 111;
|
|
7708
7852
|
function nextModeCtrlSeq() {
|
|
7709
7853
|
return ++modeCtrlSeq;
|
|
@@ -9539,7 +9683,41 @@ var VACUUM_CLEAN_MEMBERS = {
|
|
|
9539
9683
|
/** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
|
|
9540
9684
|
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),
|
|
9541
9685
|
/** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
|
|
9542
|
-
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)
|
|
9686
|
+
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),
|
|
9687
|
+
/**
|
|
9688
|
+
* Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).
|
|
9689
|
+
*
|
|
9690
|
+
* The id is the device's own, as {@link VACUUM_CLEAN_MEMBERS.scenes} reports it — `VacuumScene.id`
|
|
9691
|
+
* off the `SceneResponse` on DP 180. A scene the device reports invalid stays reportable and running
|
|
9692
|
+
* it is still a well-formed request; `VacuumScene.invalidReason` says why the device will refuse.
|
|
9693
|
+
*
|
|
9694
|
+
* Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 24 has been
|
|
9695
|
+
* WATCHED: run on a T2351, it started the named scene.
|
|
9696
|
+
*/
|
|
9697
|
+
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),
|
|
9698
|
+
/**
|
|
9699
|
+
* Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
|
|
9700
|
+
*
|
|
9701
|
+
* `mapId` has no default and that is deliberate: room ids are per map, so assuming the map a
|
|
9702
|
+
* single-floor home would have sends a two-floor home's ids against the wrong floor. `SceneInfo.mapid`
|
|
9703
|
+
* on DP 180 and a scheduled rooms-clean's `map_id` are the two real map ids the device reports.
|
|
9704
|
+
*
|
|
9705
|
+
* `cleanTimes` is how many passes to make over the set; rooms with no `order` are visited in the
|
|
9706
|
+
* order given.
|
|
9707
|
+
*
|
|
9708
|
+
* Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 1 has been
|
|
9709
|
+
* WATCHED: run on a T2351, it cleaned the rooms named.
|
|
9710
|
+
*/
|
|
9711
|
+
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),
|
|
9712
|
+
/**
|
|
9713
|
+
* Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).
|
|
9714
|
+
*
|
|
9715
|
+
* Corners are SIGNED centimetres in the map's own frame, whose origin sits wherever the robot first
|
|
9716
|
+
* mapped from — negative coordinates are ordinary and are ZigZag-encoded, not written as plain
|
|
9717
|
+
* varints. Same `mapId` reasoning as {@link VACUUM_CLEAN_MEMBERS.cleanRooms}, and the same evidence:
|
|
9718
|
+
* method 2 was run on a T2351 and cleaned the rectangles given.
|
|
9719
|
+
*/
|
|
9720
|
+
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)
|
|
9543
9721
|
};
|
|
9544
9722
|
var VACUUM_CLEAN = {
|
|
9545
9723
|
capability: "vacuum_clean",
|
|
@@ -9715,6 +9893,70 @@ function toNumberArray(arr) {
|
|
|
9715
9893
|
return result;
|
|
9716
9894
|
}
|
|
9717
9895
|
|
|
9896
|
+
// dist/model/capabilities/display.js
|
|
9897
|
+
var DISPLAY_PARAM = { BATTERY: 8001, SOFTWARE_VERSION: 8003 };
|
|
9898
|
+
var DISPLAY_MEMBERS = {
|
|
9899
|
+
/**
|
|
9900
|
+
* Battery level, 0-100.
|
|
9901
|
+
*
|
|
9902
|
+
* **On this capability rather than on `battery`, and that is the line partition doing its job.** The
|
|
9903
|
+
* security-line `battery` capability reads param 1101 and a Smart Display's charge is 8001 in its own
|
|
9904
|
+
* id space — two wires that happen to mean the same thing. One capability reading both would be a claim
|
|
9905
|
+
* that the two ecosystems share a param space, so a consumer reads a display's charge through
|
|
9906
|
+
* `dev.display()` and a camera's through `dev.battery()`.
|
|
9907
|
+
*
|
|
9908
|
+
* `verified` rather than `mega`: the id is in the device's cloud record, but the NAME came from the
|
|
9909
|
+
* maintainer's own knowledge of the hardware rather than from the cloud data-point list, and `"100"`
|
|
9910
|
+
* fits brightness, volume or charge equally.
|
|
9911
|
+
*
|
|
9912
|
+
* The scale is `percent` on the reading itself, not on convention alone: a full charge reads `255` on a
|
|
9913
|
+
* 0-255 scale and `1000` on a 0-1000 one, so `"100"` on a charged unit is positive evidence for 0-100
|
|
9914
|
+
* rather than merely consistent with it. What nobody has done is watch it MOVE, which is why a value
|
|
9915
|
+
* frozen at 100 would not yet be distinguishable from a healthy one.
|
|
9916
|
+
*/
|
|
9917
|
+
battery: {
|
|
9918
|
+
param: DISPLAY_PARAM.BATTERY,
|
|
9919
|
+
type: "number",
|
|
9920
|
+
unit: "%",
|
|
9921
|
+
kind: "percent",
|
|
9922
|
+
provenance: "verified",
|
|
9923
|
+
description: "Battery level, 0-100 (param 8001)."
|
|
9924
|
+
},
|
|
9925
|
+
/**
|
|
9926
|
+
* Version-shaped, and that shape is the whole of the evidence — hence `guessed`, and hence no typed
|
|
9927
|
+
* getter: a caller reading this off a bound object cannot see the label.
|
|
9928
|
+
*
|
|
9929
|
+
* `unexposed` rather than absent, so the schema still carries its type and that label and
|
|
9930
|
+
* `getProperty("softwareVersion")` still answers. `dev.info()?.firmwareVersion` is the field to trust
|
|
9931
|
+
* where the cloud record carries one; on this display it does not, which is the only reason 8003 is
|
|
9932
|
+
* named at all.
|
|
9933
|
+
*/
|
|
9934
|
+
softwareVersion: {
|
|
9935
|
+
param: DISPLAY_PARAM.SOFTWARE_VERSION,
|
|
9936
|
+
type: "string",
|
|
9937
|
+
kind: "text",
|
|
9938
|
+
provenance: "guessed",
|
|
9939
|
+
unexposed: true,
|
|
9940
|
+
description: "Version-shaped string (param 8003), meaning unconfirmed \u2014 prefer `info.firmwareVersion`."
|
|
9941
|
+
}
|
|
9942
|
+
};
|
|
9943
|
+
var DISPLAY = {
|
|
9944
|
+
capability: "display",
|
|
9945
|
+
line: "display",
|
|
9946
|
+
description: "Smart Display battery level (param 8001, display namespace).",
|
|
9947
|
+
members: DISPLAY_MEMBERS,
|
|
9948
|
+
properties: propertiesOf(DISPLAY_MEMBERS),
|
|
9949
|
+
/**
|
|
9950
|
+
* Claimed by CODEC, not by an evidence param.
|
|
9951
|
+
*
|
|
9952
|
+
* 8001 is in the device's cloud record, so the ordinary evidence gate would install the getter anyway
|
|
9953
|
+
* — but the capability should attach to a Smart Display that has reported nothing yet too, because a
|
|
9954
|
+
* device on this line has no other capability to carry it. The line partition keeps this off
|
|
9955
|
+
* everything else: `display` is the only codec in the `display` line.
|
|
9956
|
+
*/
|
|
9957
|
+
detection: { codecs: ["display"] }
|
|
9958
|
+
};
|
|
9959
|
+
|
|
9718
9960
|
// dist/model/capabilities/locate.js
|
|
9719
9961
|
var LOCATE_DP = 160;
|
|
9720
9962
|
var LEGACY_LOCATE_DP = TUYA_VACUUM_DP.LOOK_FOR_SWEEPER;
|
|
@@ -9843,6 +10085,7 @@ var MODULES = [
|
|
|
9843
10085
|
VACUUM_DOCK,
|
|
9844
10086
|
SUCTION,
|
|
9845
10087
|
LOCATE,
|
|
10088
|
+
DISPLAY,
|
|
9846
10089
|
INFO
|
|
9847
10090
|
];
|
|
9848
10091
|
var BY_CAP = new Map(MODULES.map((m) => [m.capability, m]));
|
|
@@ -9887,11 +10130,11 @@ var CODEC_LINE = {
|
|
|
9887
10130
|
mower: "clean",
|
|
9888
10131
|
light: "life",
|
|
9889
10132
|
printer: "print",
|
|
9890
|
-
display: "
|
|
10133
|
+
display: "display"
|
|
9891
10134
|
};
|
|
9892
10135
|
function lineAllows(module, codec) {
|
|
9893
10136
|
const line = module.line ?? "security";
|
|
9894
|
-
return line === "any" || line === CODEC_LINE[codec];
|
|
10137
|
+
return line === "any" || codec !== void 0 && line === CODEC_LINE[codec];
|
|
9895
10138
|
}
|
|
9896
10139
|
function detectCapabilities(rec, codec) {
|
|
9897
10140
|
const found = /* @__PURE__ */ new Set();
|
|
@@ -9921,10 +10164,10 @@ function detectCapabilities(rec, codec) {
|
|
|
9921
10164
|
if (!matched && d.modelHints && haystack.length > 0) {
|
|
9922
10165
|
matched = d.modelHints.some((re) => re.test(haystack));
|
|
9923
10166
|
}
|
|
9924
|
-
if (!matched && d.codecs) {
|
|
10167
|
+
if (!matched && d.codecs && codec !== void 0) {
|
|
9925
10168
|
matched = d.codecs.includes(codec);
|
|
9926
10169
|
}
|
|
9927
|
-
if (!matched && d.detect) {
|
|
10170
|
+
if (!matched && d.detect && codec !== void 0) {
|
|
9928
10171
|
try {
|
|
9929
10172
|
matched = d.detect(rec, codec) === true;
|
|
9930
10173
|
} catch {
|
|
@@ -11987,6 +12230,28 @@ var CLEAN_PARAMS = {
|
|
|
11987
12230
|
provenance: "mega"
|
|
11988
12231
|
}
|
|
11989
12232
|
};
|
|
12233
|
+
var DISPLAY_PARAMS = {
|
|
12234
|
+
8001: {
|
|
12235
|
+
name: "battery",
|
|
12236
|
+
type: "number",
|
|
12237
|
+
provenance: "verified"
|
|
12238
|
+
},
|
|
12239
|
+
8003: {
|
|
12240
|
+
name: "softwareVersion",
|
|
12241
|
+
type: "string",
|
|
12242
|
+
provenance: "guessed"
|
|
12243
|
+
},
|
|
12244
|
+
8005: {
|
|
12245
|
+
name: "modelName",
|
|
12246
|
+
type: "string",
|
|
12247
|
+
provenance: "mega"
|
|
12248
|
+
},
|
|
12249
|
+
8006: {
|
|
12250
|
+
name: "modelCode",
|
|
12251
|
+
type: "string",
|
|
12252
|
+
provenance: "mega"
|
|
12253
|
+
}
|
|
12254
|
+
};
|
|
11990
12255
|
|
|
11991
12256
|
// dist/model/life-params.js
|
|
11992
12257
|
var LIFE_PARAMS = {
|
|
@@ -12014,7 +12279,10 @@ var TABLES = {
|
|
|
12014
12279
|
// 3D-printer (ankermake) id space — empty until a live capture confirms the param↔semantic map
|
|
12015
12280
|
// (printer-support plan Stage 3). Present so the printer codec resolves to its OWN namespace rather
|
|
12016
12281
|
// than falling through to `security` and decoding another line's dictionary.
|
|
12017
|
-
print: {}
|
|
12282
|
+
print: {},
|
|
12283
|
+
// Smart Display (T87Ax) — ids 8001-8006, four of them named. Same reason as `print`: its own
|
|
12284
|
+
// dictionary rather than a corner of another line's.
|
|
12285
|
+
display: DISPLAY_PARAMS
|
|
12018
12286
|
};
|
|
12019
12287
|
function paramDef(ns, paramType) {
|
|
12020
12288
|
return TABLES[ns][paramType];
|
|
@@ -12029,7 +12297,7 @@ var NAMESPACE_BY_CODEC = {
|
|
|
12029
12297
|
mower: "clean",
|
|
12030
12298
|
light: "life",
|
|
12031
12299
|
printer: "print",
|
|
12032
|
-
display: "
|
|
12300
|
+
display: "display"
|
|
12033
12301
|
};
|
|
12034
12302
|
function namespaceForCodec(codec) {
|
|
12035
12303
|
return NAMESPACE_BY_CODEC[codec];
|
|
@@ -13092,6 +13360,174 @@ function inspectParams(rec, sn) {
|
|
|
13092
13360
|
};
|
|
13093
13361
|
}
|
|
13094
13362
|
|
|
13363
|
+
// dist/model/capabilities/solix.js
|
|
13364
|
+
var SOLIX_ENERGY_METER_MEMBERS = {
|
|
13365
|
+
/**
|
|
13366
|
+
* Line-1 voltage (V), ff09 tag `0xAC` — the ONE confirmed meter binding, matched against a live
|
|
13367
|
+
* single-phase frame (a nominal mains voltage). Read-only; the evidence gate installs its getter only
|
|
13368
|
+
* once a frame carrying `0xAC` has landed, so it is absent (not a fabricated `0`) until then.
|
|
13369
|
+
*/
|
|
13370
|
+
meterVoltageL1: {
|
|
13371
|
+
param: 172,
|
|
13372
|
+
type: "number",
|
|
13373
|
+
kind: "scalar",
|
|
13374
|
+
unit: "V",
|
|
13375
|
+
provenance: "verified",
|
|
13376
|
+
description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
|
|
13377
|
+
}
|
|
13378
|
+
};
|
|
13379
|
+
var CATEGORY_CAPABILITIES = {
|
|
13380
|
+
"Portable Power Station": ["battery", "acOutput", "solarInput"],
|
|
13381
|
+
"Plug-in Home Battery": ["battery", "solarInput", "acOutput", "energyMeter"],
|
|
13382
|
+
"Powered Cooler": ["battery", "cooler"],
|
|
13383
|
+
"Power Bank": ["battery"],
|
|
13384
|
+
"Smart EV Charger": ["evCharger"],
|
|
13385
|
+
Charger: ["charger"],
|
|
13386
|
+
Accessory: []
|
|
13387
|
+
};
|
|
13388
|
+
var SOLIX_METER_MODELS = ["AE1X0"];
|
|
13389
|
+
var SOLARBANK_MODELS = ["A1790", "A17C"];
|
|
13390
|
+
function detectSolixCapabilities(rec, category) {
|
|
13391
|
+
const caps = /* @__PURE__ */ new Set(["identity"]);
|
|
13392
|
+
if (rec.device_sw_version)
|
|
13393
|
+
caps.add("firmware");
|
|
13394
|
+
if (rec.wifi_online !== void 0 || rec.rssi != null || rec.wifi_name)
|
|
13395
|
+
caps.add("connectivity");
|
|
13396
|
+
for (const c of category && CATEGORY_CAPABILITIES[category] || [])
|
|
13397
|
+
caps.add(c);
|
|
13398
|
+
if (SOLIX_METER_MODELS.some((m) => rec.product_code?.startsWith(m)))
|
|
13399
|
+
caps.add("energyMeter");
|
|
13400
|
+
if (SOLARBANK_MODELS.some((m) => rec.product_code?.startsWith(m))) {
|
|
13401
|
+
caps.add("battery");
|
|
13402
|
+
caps.add("solarInput");
|
|
13403
|
+
}
|
|
13404
|
+
return caps;
|
|
13405
|
+
}
|
|
13406
|
+
|
|
13407
|
+
// dist/model/solix-catalog.js
|
|
13408
|
+
function buildModelIndex(categories) {
|
|
13409
|
+
const index = /* @__PURE__ */ new Map();
|
|
13410
|
+
for (const category of categories) {
|
|
13411
|
+
for (const product of category.products ?? []) {
|
|
13412
|
+
const entry = { name: product.name, category: category.name };
|
|
13413
|
+
if (product.product_code)
|
|
13414
|
+
index.set(product.product_code, entry);
|
|
13415
|
+
for (const variant of product.p_codes ?? []) {
|
|
13416
|
+
const code = typeof variant === "string" ? variant : variant?.product_code;
|
|
13417
|
+
if (code)
|
|
13418
|
+
index.set(code, entry);
|
|
13419
|
+
}
|
|
13420
|
+
}
|
|
13421
|
+
}
|
|
13422
|
+
return index;
|
|
13423
|
+
}
|
|
13424
|
+
|
|
13425
|
+
// dist/model/solix-device.js
|
|
13426
|
+
var READ_ONLY_SINK = { dispatch: async () => {
|
|
13427
|
+
} };
|
|
13428
|
+
var SolixDevice = class {
|
|
13429
|
+
serial;
|
|
13430
|
+
productCode;
|
|
13431
|
+
record;
|
|
13432
|
+
caps;
|
|
13433
|
+
identity_;
|
|
13434
|
+
values = {};
|
|
13435
|
+
constructor(record, opts = {}) {
|
|
13436
|
+
this.record = record;
|
|
13437
|
+
this.serial = record.device_sn;
|
|
13438
|
+
this.productCode = record.product_code;
|
|
13439
|
+
const label = opts.catalog ? buildModelIndex(opts.catalog).get(record.product_code) : void 0;
|
|
13440
|
+
this.identity_ = {
|
|
13441
|
+
serial: record.device_sn,
|
|
13442
|
+
productCode: record.product_code,
|
|
13443
|
+
name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
|
|
13444
|
+
category: label?.category
|
|
13445
|
+
};
|
|
13446
|
+
this.caps = detectSolixCapabilities(record, this.identity_.category);
|
|
13447
|
+
}
|
|
13448
|
+
/** All capabilities this device carries. */
|
|
13449
|
+
get capabilities() {
|
|
13450
|
+
return [...this.caps];
|
|
13451
|
+
}
|
|
13452
|
+
/** Whether the device carries a capability — the only correct way to branch on behaviour. */
|
|
13453
|
+
has(capability) {
|
|
13454
|
+
return this.caps.has(capability);
|
|
13455
|
+
}
|
|
13456
|
+
/**
|
|
13457
|
+
* Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
|
|
13458
|
+
* WHOLE reading, not just its values, and drops one addressed to a different device: the documented
|
|
13459
|
+
* wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
|
|
13460
|
+
* watched meter on the account — so without this filter two meters would cross-feed each other's floats.
|
|
13461
|
+
* A reading with no `deviceSn` (a hand-built one) is accepted as-is.
|
|
13462
|
+
*/
|
|
13463
|
+
applyReading(reading) {
|
|
13464
|
+
if (reading.deviceSn && reading.deviceSn !== this.serial)
|
|
13465
|
+
return;
|
|
13466
|
+
this.values = { ...this.values, ...reading.values };
|
|
13467
|
+
}
|
|
13468
|
+
/** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
|
|
13469
|
+
telemetry() {
|
|
13470
|
+
return { ...this.values };
|
|
13471
|
+
}
|
|
13472
|
+
identity() {
|
|
13473
|
+
return { ...this.identity_ };
|
|
13474
|
+
}
|
|
13475
|
+
firmware() {
|
|
13476
|
+
return this.record.device_sw_version ? { version: this.record.device_sw_version } : void 0;
|
|
13477
|
+
}
|
|
13478
|
+
connectivity() {
|
|
13479
|
+
if (!this.has("connectivity"))
|
|
13480
|
+
return void 0;
|
|
13481
|
+
const rssi = this.record.rssi != null ? Number(this.record.rssi) : void 0;
|
|
13482
|
+
return {
|
|
13483
|
+
online: !!this.record.wifi_online,
|
|
13484
|
+
rssi: Number.isFinite(rssi) ? rssi : void 0,
|
|
13485
|
+
ssid: this.record.wifi_name
|
|
13486
|
+
};
|
|
13487
|
+
}
|
|
13488
|
+
/**
|
|
13489
|
+
* The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
|
|
13490
|
+
* installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
|
|
13491
|
+
* held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
|
|
13492
|
+
* time of this call, so re-call it to pick up a tag first seen since.
|
|
13493
|
+
*/
|
|
13494
|
+
energyMeter() {
|
|
13495
|
+
if (!this.has("energyMeter"))
|
|
13496
|
+
return void 0;
|
|
13497
|
+
return bindMembers(SOLIX_ENERGY_METER_MEMBERS, this.meterDeps());
|
|
13498
|
+
}
|
|
13499
|
+
/**
|
|
13500
|
+
* The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
|
|
13501
|
+
* evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
|
|
13502
|
+
* codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
|
|
13503
|
+
* Solix telemetry is read-only, no member here dispatches a command.
|
|
13504
|
+
*/
|
|
13505
|
+
meterDeps() {
|
|
13506
|
+
const read = (name) => {
|
|
13507
|
+
const v = this.values[name];
|
|
13508
|
+
return v === void 0 ? void 0 : { name, paramType: 0, value: v, ts: Date.now() };
|
|
13509
|
+
};
|
|
13510
|
+
return { ctx: { channel: 0, paramIds: this.seenTags() }, sink: READ_ONLY_SINK, read };
|
|
13511
|
+
}
|
|
13512
|
+
/** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
|
|
13513
|
+
seenTags() {
|
|
13514
|
+
const tags = /* @__PURE__ */ new Set();
|
|
13515
|
+
for (const key of Object.keys(this.values)) {
|
|
13516
|
+
const m = /^channel_([0-9a-f]+)$/.exec(key);
|
|
13517
|
+
if (m)
|
|
13518
|
+
tags.add(parseInt(m[1], 16));
|
|
13519
|
+
}
|
|
13520
|
+
return tags;
|
|
13521
|
+
}
|
|
13522
|
+
};
|
|
13523
|
+
async function discoverSolixDevices(client, opts = {}) {
|
|
13524
|
+
const [records, catalog] = await Promise.all([
|
|
13525
|
+
client.getDevices(),
|
|
13526
|
+
opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => [])
|
|
13527
|
+
]);
|
|
13528
|
+
return records.map((r) => new SolixDevice(r, { catalog }));
|
|
13529
|
+
}
|
|
13530
|
+
|
|
13095
13531
|
// dist/client/map-channels.js
|
|
13096
13532
|
var reader = (kind, decode) => (raw, codec) => {
|
|
13097
13533
|
const value = decode(raw, codec);
|
|
@@ -14190,19 +14626,27 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14190
14626
|
* cameras from that second group streamed normally at level-1 — including one of the same firmware as an
|
|
14191
14627
|
* own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
|
|
14192
14628
|
* separates nothing on this path, and a start failure on such a session is not evidence about it.
|
|
14629
|
+
*
|
|
14630
|
+
* Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
|
|
14631
|
+
* `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
|
|
14632
|
+
* cipher and the cause are known, and re-stating it here would double every settled negotiation.
|
|
14193
14633
|
*/
|
|
14194
14634
|
async awaitLevel2Key(graceMs, graceFrom = "call") {
|
|
14195
|
-
if (this.closed)
|
|
14635
|
+
if (this.closed) {
|
|
14636
|
+
this.trace({ phase: "level2-unavailable", reason: "session-closed" });
|
|
14196
14637
|
return false;
|
|
14638
|
+
}
|
|
14197
14639
|
if (this.level2Key)
|
|
14198
14640
|
return true;
|
|
14199
|
-
if (!this.level2Pending)
|
|
14641
|
+
if (!this.level2Pending) {
|
|
14642
|
+
this.trace({ phase: "level2-unavailable", reason: "not-negotiating" });
|
|
14200
14643
|
return false;
|
|
14644
|
+
}
|
|
14201
14645
|
const since = graceFrom === "call" ? Date.now() : this.connectedAtMs ?? Date.now();
|
|
14202
14646
|
const remaining = graceMs - (Date.now() - since);
|
|
14203
14647
|
if (remaining <= 0) {
|
|
14204
14648
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} no level-2 key and its ${graceMs}ms grace has elapsed`);
|
|
14205
|
-
this.trace({ phase: "level2-
|
|
14649
|
+
this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: graceMs });
|
|
14206
14650
|
return false;
|
|
14207
14651
|
}
|
|
14208
14652
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
|
|
@@ -14228,10 +14672,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14228
14672
|
});
|
|
14229
14673
|
if (outcome === "timeout") {
|
|
14230
14674
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 key did not arrive within its grace`);
|
|
14675
|
+
this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: remaining });
|
|
14231
14676
|
} else if (outcome === "terminal") {
|
|
14232
14677
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 negotiation concluded without a key`);
|
|
14233
14678
|
} else if (outcome === "closed") {
|
|
14234
14679
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} session closed before the level-2 key arrived`);
|
|
14680
|
+
this.trace({ phase: "level2-unavailable", reason: "session-closed" });
|
|
14235
14681
|
}
|
|
14236
14682
|
return outcome === "key";
|
|
14237
14683
|
}
|
|
@@ -14282,6 +14728,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14282
14728
|
this.level2Negotiating = true;
|
|
14283
14729
|
const generation = this.connectionGeneration;
|
|
14284
14730
|
const cipherId = gatewayInfoCipherId(gwPayload);
|
|
14731
|
+
this.trace({ phase: "level2-negotiating", cipherId });
|
|
14285
14732
|
void (async () => {
|
|
14286
14733
|
try {
|
|
14287
14734
|
const eccPrivHex = await this.cfg.resolveCipherKey?.(cipherId);
|
|
@@ -14289,11 +14736,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14289
14736
|
return;
|
|
14290
14737
|
if (!eccPrivHex) {
|
|
14291
14738
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} no ECC key for cipher_id ${cipherId}`);
|
|
14739
|
+
this.trace({ phase: "level2-unavailable", reason: "no-cipher-key", cipherId });
|
|
14292
14740
|
this.settleLevel2();
|
|
14293
14741
|
return;
|
|
14294
14742
|
}
|
|
14295
14743
|
const key = deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivHex);
|
|
14296
14744
|
if (!key) {
|
|
14745
|
+
this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
|
|
14297
14746
|
this.settleLevel2();
|
|
14298
14747
|
this.emit("error", new Error(`level-2 key derivation failed (cipher_id ${cipherId})`));
|
|
14299
14748
|
return;
|
|
@@ -14305,6 +14754,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
14305
14754
|
} catch (e) {
|
|
14306
14755
|
if (this.closed || generation !== this.connectionGeneration)
|
|
14307
14756
|
return;
|
|
14757
|
+
this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
|
|
14308
14758
|
this.settleLevel2();
|
|
14309
14759
|
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
14310
14760
|
}
|
|
@@ -15655,19 +16105,23 @@ function parseFf09SettingsResponse(plain) {
|
|
|
15655
16105
|
if (plain.length < 1)
|
|
15656
16106
|
throw new Error("ff09: settings response too short (missing status byte)");
|
|
15657
16107
|
const status = plain[0];
|
|
16108
|
+
const fields = walkFf09Tlv(plain, 1, plain.length);
|
|
16109
|
+
return { status, fields };
|
|
16110
|
+
}
|
|
16111
|
+
function walkFf09Tlv(buf, start, end) {
|
|
15658
16112
|
const fields = /* @__PURE__ */ new Map();
|
|
15659
|
-
let i =
|
|
15660
|
-
while (i + 2 <=
|
|
15661
|
-
const
|
|
15662
|
-
if (
|
|
16113
|
+
let i = start;
|
|
16114
|
+
while (i + 2 <= end) {
|
|
16115
|
+
const tag2 = buf[i];
|
|
16116
|
+
if (tag2 === 0)
|
|
15663
16117
|
break;
|
|
15664
|
-
const len =
|
|
15665
|
-
if (i + 2 + len >
|
|
16118
|
+
const len = buf[i + 1];
|
|
16119
|
+
if (i + 2 + len > end)
|
|
15666
16120
|
break;
|
|
15667
|
-
fields.set(
|
|
16121
|
+
fields.set(tag2, buf.subarray(i + 2, i + 2 + len));
|
|
15668
16122
|
i += 2 + len;
|
|
15669
16123
|
}
|
|
15670
|
-
return
|
|
16124
|
+
return fields;
|
|
15671
16125
|
}
|
|
15672
16126
|
function readFf09U16LE(field, name) {
|
|
15673
16127
|
if (!field || field.length < 2)
|
|
@@ -16321,8 +16775,9 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
16321
16775
|
* Stop re-issuing the media start once this camera's own media has arrived, on an attached camera.
|
|
16322
16776
|
*
|
|
16323
16777
|
* The nudge differs by topology and only one branch is a ping: an own-session camera sends a small
|
|
16324
|
-
* keepalive, while an attached camera has no such state and re-sends the FULL media start.
|
|
16325
|
-
* serves one camera at a time that restart re-asserts this channel against whatever else is warm
|
|
16778
|
+
* keepalive, while an attached camera has no such state and re-sends the FULL media start. Over one session,
|
|
16779
|
+
* which serves one camera at a time, that restart re-asserts this channel against whatever else is warm on
|
|
16780
|
+
* it, so two
|
|
16326
16781
|
* attached streams restart every interval and contend for the station continuously — measured on a real base
|
|
16327
16782
|
* as a full start every 3 s from each.
|
|
16328
16783
|
*
|
|
@@ -16370,10 +16825,12 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
16370
16825
|
if (!this.listening || this.kaTimer)
|
|
16371
16826
|
return;
|
|
16372
16827
|
if (this.opts.reassertWanted?.() === false) {
|
|
16828
|
+
this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "declined" });
|
|
16373
16829
|
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`);
|
|
16374
16830
|
this.armStallWatch();
|
|
16375
16831
|
return;
|
|
16376
16832
|
}
|
|
16833
|
+
this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "reasserted" });
|
|
16377
16834
|
this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms \u2014 re-asserting this camera's channel`);
|
|
16378
16835
|
this.sendStart();
|
|
16379
16836
|
this.kaTimer = setInterval(() => this.sendStart(), keepAliveMs);
|
|
@@ -16487,7 +16944,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
16487
16944
|
*
|
|
16488
16945
|
* The match is UNCONDITIONAL, however long a station serves another camera instead of this one.
|
|
16489
16946
|
*
|
|
16490
|
-
*
|
|
16947
|
+
* One session serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
|
|
16491
16948
|
* it switches, so "no media of my own yet, plenty for someone else" is what an ordinary handover looks like
|
|
16492
16949
|
* and does not distinguish a station that tags differently from one that is simply busy.
|
|
16493
16950
|
*
|
|
@@ -16594,10 +17051,12 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
16594
17051
|
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
16595
17052
|
const collectMs = opts.collectMs ?? 1500;
|
|
16596
17053
|
const skip = opts.skipKeyframes ?? 1;
|
|
17054
|
+
opts.signal?.throwIfAborted();
|
|
16597
17055
|
const consumer = source.attach();
|
|
16598
17056
|
const primed = consumer.primed;
|
|
17057
|
+
let burst;
|
|
16599
17058
|
try {
|
|
16600
|
-
|
|
17059
|
+
burst = await new Promise((resolve, reject) => {
|
|
16601
17060
|
const bufs = [];
|
|
16602
17061
|
let sets = source.parameterSets;
|
|
16603
17062
|
let codec = "h264";
|
|
@@ -16632,6 +17091,10 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
16632
17091
|
cleanup();
|
|
16633
17092
|
reject(new LiveSnapshotUnavailableError("source-failed", `source ended before a keyframe (state: ${source.state})`));
|
|
16634
17093
|
};
|
|
17094
|
+
const onAbandoned = () => {
|
|
17095
|
+
cleanup();
|
|
17096
|
+
reject(opts.signal?.reason);
|
|
17097
|
+
};
|
|
16635
17098
|
const cleanup = () => {
|
|
16636
17099
|
clearTimeout(timer);
|
|
16637
17100
|
if (settle)
|
|
@@ -16639,19 +17102,21 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
16639
17102
|
consumer.off("video", onVideo);
|
|
16640
17103
|
consumer.off("error", onError);
|
|
16641
17104
|
consumer.off("stop", onStop);
|
|
17105
|
+
opts.signal?.removeEventListener("abort", onAbandoned);
|
|
16642
17106
|
};
|
|
16643
17107
|
consumer.on("video", onVideo);
|
|
16644
17108
|
consumer.on("error", onError);
|
|
16645
17109
|
consumer.on("stop", onStop);
|
|
16646
|
-
|
|
16647
|
-
return await annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
|
|
16648
|
-
logger: opts.logger ?? noopLogger,
|
|
16649
|
-
level: opts.ffmpegLevel,
|
|
16650
|
-
executable: opts.ffmpegPath
|
|
17110
|
+
opts.signal?.addEventListener("abort", onAbandoned, { once: true });
|
|
16651
17111
|
});
|
|
16652
17112
|
} finally {
|
|
16653
17113
|
consumer.detach();
|
|
16654
17114
|
}
|
|
17115
|
+
return annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
|
|
17116
|
+
logger: opts.logger ?? noopLogger,
|
|
17117
|
+
level: opts.ffmpegLevel,
|
|
17118
|
+
executable: opts.ffmpegPath
|
|
17119
|
+
});
|
|
16655
17120
|
}
|
|
16656
17121
|
async function recordClip(session, seconds, opts = {}) {
|
|
16657
17122
|
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
@@ -17529,6 +17994,8 @@ var SharedLiveSource = class {
|
|
|
17529
17994
|
this.configuredFrom = void 0;
|
|
17530
17995
|
this.ring = [];
|
|
17531
17996
|
this._state = state;
|
|
17997
|
+
if (state === "stopped")
|
|
17998
|
+
this.opts.onStopped?.();
|
|
17532
17999
|
if (startFailed && report)
|
|
17533
18000
|
this.opts.onStartFailed?.();
|
|
17534
18001
|
}
|
|
@@ -17611,14 +18078,14 @@ var SessionManager = class {
|
|
|
17611
18078
|
this.logger = opts.logger ?? noopLogger;
|
|
17612
18079
|
}
|
|
17613
18080
|
/** The live session for a station, or `undefined` if not open. */
|
|
17614
|
-
get(
|
|
17615
|
-
return this.entries.get(
|
|
18081
|
+
get(key) {
|
|
18082
|
+
return this.entries.get(key)?.session;
|
|
17616
18083
|
}
|
|
17617
|
-
/**
|
|
18084
|
+
/** Keys of the open sessions. */
|
|
17618
18085
|
keys() {
|
|
17619
18086
|
return [...this.entries].filter(([, e]) => e.session).map(([sn]) => sn);
|
|
17620
18087
|
}
|
|
17621
|
-
/** A plain `Map<
|
|
18088
|
+
/** A plain `Map<key, P2PSession>` snapshot of the open sessions (for `getSessions()` / tests). */
|
|
17622
18089
|
liveSessions() {
|
|
17623
18090
|
const m = /* @__PURE__ */ new Map();
|
|
17624
18091
|
for (const [sn, e] of this.entries)
|
|
@@ -17626,56 +18093,77 @@ var SessionManager = class {
|
|
|
17626
18093
|
m.set(sn, e.session);
|
|
17627
18094
|
return m;
|
|
17628
18095
|
}
|
|
17629
|
-
/**
|
|
17630
|
-
|
|
17631
|
-
|
|
18096
|
+
/**
|
|
18097
|
+
* Get or create the lifecycle entry under `key`, recording which station it connects to. An existing
|
|
18098
|
+
* entry keeps the station it was opened with — the key owns one connection for its lifetime, and a
|
|
18099
|
+
* later caller passing a different station would otherwise re-point a live entry's power tier.
|
|
18100
|
+
*/
|
|
18101
|
+
entry(key, station) {
|
|
18102
|
+
let e = this.entries.get(key);
|
|
17632
18103
|
if (!e) {
|
|
17633
|
-
e = { retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
|
|
17634
|
-
this.entries.set(
|
|
18104
|
+
e = { station, retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
|
|
18105
|
+
this.entries.set(key, e);
|
|
17635
18106
|
}
|
|
17636
18107
|
return e;
|
|
17637
18108
|
}
|
|
17638
|
-
/**
|
|
17639
|
-
|
|
17640
|
-
|
|
18109
|
+
/**
|
|
18110
|
+
* Register an already-built session for test seeding or an externally assembled connection.
|
|
18111
|
+
*
|
|
18112
|
+
* `station` defaults to the key, which is safe HERE and nowhere else in this class: the paths that open a
|
|
18113
|
+
* session under a key that is not a station serial all go through {@link acquire}, which requires it. A
|
|
18114
|
+
* caller seeding one under such a key must pass it.
|
|
18115
|
+
*/
|
|
18116
|
+
register(key, session, station = key) {
|
|
18117
|
+
this.entry(key, station).session = session;
|
|
17641
18118
|
}
|
|
17642
18119
|
/**
|
|
17643
|
-
* Ensure a session to `
|
|
18120
|
+
* Ensure a session to `key` is open, building it via `factory` if cold. Concurrent calls for the
|
|
17644
18121
|
* same cold station share ONE connect (the `connecting` promise); `factory` builds + wires + awaits
|
|
17645
18122
|
* `connect()` and resolves the connected session.
|
|
17646
18123
|
*/
|
|
17647
|
-
async acquire(
|
|
17648
|
-
const e = this.entry(
|
|
18124
|
+
async acquire(key, factory, station) {
|
|
18125
|
+
const e = this.entry(key, station);
|
|
17649
18126
|
if (e.connecting) {
|
|
17650
|
-
this.logger.debug(`[session ${
|
|
18127
|
+
this.logger.debug(`[session ${key}] connecting \u2014 joining in-flight open`);
|
|
17651
18128
|
return e.connecting;
|
|
17652
18129
|
}
|
|
17653
18130
|
if (e.session)
|
|
17654
18131
|
return e.session;
|
|
17655
|
-
this.logger.debug(`[session ${
|
|
18132
|
+
this.logger.debug(`[session ${key}] connecting now (on demand)`);
|
|
17656
18133
|
const generation = this.generation;
|
|
17657
18134
|
const p = factory((session) => e.session = session);
|
|
17658
18135
|
e.connecting = p;
|
|
17659
18136
|
try {
|
|
17660
18137
|
const session = await p;
|
|
17661
|
-
if (generation !== this.generation || this.entries.get(
|
|
18138
|
+
if (generation !== this.generation || this.entries.get(key) !== e) {
|
|
17662
18139
|
await session.close();
|
|
17663
|
-
throw new SessionSupersededError(`P2P session start superseded for
|
|
18140
|
+
throw new SessionSupersededError(`P2P session start superseded for ${key}`);
|
|
17664
18141
|
}
|
|
17665
18142
|
e.session ??= session;
|
|
17666
|
-
this.logger.debug(`[session ${
|
|
18143
|
+
this.logger.debug(`[session ${key}] connected`);
|
|
17667
18144
|
return e.session;
|
|
17668
18145
|
} finally {
|
|
17669
18146
|
if (e.connecting === p)
|
|
17670
18147
|
e.connecting = void 0;
|
|
17671
18148
|
}
|
|
17672
18149
|
}
|
|
17673
|
-
/**
|
|
17674
|
-
|
|
17675
|
-
|
|
18150
|
+
/**
|
|
18151
|
+
* Add a reason to stay connected; cancels a pending idle-close.
|
|
18152
|
+
*
|
|
18153
|
+
* Refused when nothing is open under `key`, for the reason {@link release} gives in the other direction: a
|
|
18154
|
+
* retain names a session that was acquired, and one that names nothing would file an entry with no
|
|
18155
|
+
* connection behind it. {@link hold} is the path that legitimately creates one, and it opens the entry
|
|
18156
|
+
* itself before retaining it.
|
|
18157
|
+
*/
|
|
18158
|
+
retain(key) {
|
|
18159
|
+
const e = this.entries.get(key);
|
|
18160
|
+
if (!e) {
|
|
18161
|
+
this.logger.warn(`[session ${key}] retain on a session that is not open \u2014 ignored`);
|
|
18162
|
+
return;
|
|
18163
|
+
}
|
|
17676
18164
|
e.retained++;
|
|
17677
18165
|
if (e.idle.pending)
|
|
17678
|
-
this.logger.debug(`[session ${
|
|
18166
|
+
this.logger.debug(`[session ${key}] in use again \u2014 idle-detach cancelled`);
|
|
17679
18167
|
e.idle.cancel();
|
|
17680
18168
|
}
|
|
17681
18169
|
/**
|
|
@@ -17686,44 +18174,50 @@ var SessionManager = class {
|
|
|
17686
18174
|
* from scratch or complete a deferred reset a real viewer has not yet earned. Clamping to zero did
|
|
17687
18175
|
* both silently.
|
|
17688
18176
|
*/
|
|
17689
|
-
release(
|
|
17690
|
-
const e = this.entries.get(
|
|
18177
|
+
release(key) {
|
|
18178
|
+
const e = this.entries.get(key);
|
|
17691
18179
|
if (!e)
|
|
17692
18180
|
return;
|
|
17693
18181
|
if (e.retained === 0) {
|
|
17694
|
-
this.logger.warn(`[session ${
|
|
18182
|
+
this.logger.warn(`[session ${key}] release with nothing retained \u2014 ignored`);
|
|
17695
18183
|
return;
|
|
17696
18184
|
}
|
|
17697
18185
|
e.retained -= 1;
|
|
17698
18186
|
if (e.reset && e.retained <= e.holdTimers.size) {
|
|
17699
|
-
void this.autoClose(
|
|
18187
|
+
void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] deferred reset failed`, error));
|
|
17700
18188
|
return;
|
|
17701
18189
|
}
|
|
17702
18190
|
if (e.retained === 0)
|
|
17703
|
-
this.armIdle(
|
|
18191
|
+
this.armIdle(key, e);
|
|
17704
18192
|
}
|
|
17705
18193
|
/**
|
|
17706
18194
|
* Hold a session warm for `commandKeepAliveMs` after a control command, then release. A burst of
|
|
17707
18195
|
* commands each re-holds before the previous release fires, so the session never idles mid-burst.
|
|
17708
18196
|
*/
|
|
17709
|
-
bumpCommand(
|
|
17710
|
-
this.hold(
|
|
18197
|
+
bumpCommand(key, station) {
|
|
18198
|
+
this.hold(key, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS, station);
|
|
17711
18199
|
}
|
|
17712
18200
|
/**
|
|
17713
|
-
* Retain a
|
|
18201
|
+
* Retain a session and release it again after `ms` — the primitive behind command-keepalive and event
|
|
17714
18202
|
* pre-warm, and the only way to hold one open without an attachment to release it.
|
|
17715
18203
|
*
|
|
18204
|
+
* `station` is required rather than defaulted from the key, because this is the one path that can
|
|
18205
|
+
* CREATE an entry: a pre-warm takes its hold before the open. An entry filed under a media key with
|
|
18206
|
+
* that key as its own station would be asked for the power tier of a serial that does not exist, be
|
|
18207
|
+
* answered `wired`, and never idle-detach — which on a battery station is the drain this class exists
|
|
18208
|
+
* to prevent, and is invisible until the battery is flat.
|
|
18209
|
+
*
|
|
17716
18210
|
* The timer is owned by the entry, so {@link discard} cancels it. That ownership is the point: keyed
|
|
17717
18211
|
* only by serial, an expiring hold would otherwise outlive the entry it was taken on and release a
|
|
17718
18212
|
* retain counted by the SUCCESSOR entry — dropping a live viewer's count and arming an idle-detach
|
|
17719
18213
|
* underneath it.
|
|
17720
18214
|
*/
|
|
17721
|
-
hold(
|
|
17722
|
-
const entry = this.entry(
|
|
17723
|
-
this.retain(
|
|
18215
|
+
hold(key, ms, station) {
|
|
18216
|
+
const entry = this.entry(key, station);
|
|
18217
|
+
this.retain(key);
|
|
17724
18218
|
const timer = setTimeout(() => {
|
|
17725
18219
|
entry.holdTimers.delete(timer);
|
|
17726
|
-
this.release(
|
|
18220
|
+
this.release(key);
|
|
17727
18221
|
}, ms);
|
|
17728
18222
|
timer.unref?.();
|
|
17729
18223
|
entry.holdTimers.add(timer);
|
|
@@ -17732,29 +18226,29 @@ var SessionManager = class {
|
|
|
17732
18226
|
* Arm the idle-close timer for a station whose retain count just reached zero. A wired station with
|
|
17733
18227
|
* an infinite window is left persistent (no timer). Any subsequent {@link retain} cancels it.
|
|
17734
18228
|
*/
|
|
17735
|
-
armIdle(
|
|
18229
|
+
armIdle(key, e) {
|
|
17736
18230
|
e.idle.cancel();
|
|
17737
|
-
if ((this.opts.poweredFor?.(
|
|
17738
|
-
this.logger.debug(`[session ${
|
|
18231
|
+
if ((this.opts.poweredFor?.(e.station) ?? "wired") !== "battery") {
|
|
18232
|
+
this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 staying persistent (wired)`);
|
|
17739
18233
|
return;
|
|
17740
18234
|
}
|
|
17741
18235
|
const idleMs = this.opts.batteryIdleMs ?? BATTERY_IDLE_MS;
|
|
17742
|
-
this.logger.debug(`[session ${
|
|
17743
|
-
e.idle.arm(idleMs, () => this.onIdle(
|
|
18236
|
+
this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
|
|
18237
|
+
e.idle.arm(idleMs, () => this.onIdle(key));
|
|
17744
18238
|
}
|
|
17745
18239
|
/**
|
|
17746
18240
|
* Close a station's session once its idle window elapses with nothing retained, letting the device sleep.
|
|
17747
18241
|
* Re-checks the count first (activity between the timer firing and now re-arms instead). Dropping the
|
|
17748
18242
|
* entry here and the session's own `close` → {@link remove} are both idempotent.
|
|
17749
18243
|
*/
|
|
17750
|
-
onIdle(
|
|
17751
|
-
const e = this.entries.get(
|
|
18244
|
+
onIdle(key) {
|
|
18245
|
+
const e = this.entries.get(key);
|
|
17752
18246
|
if (!e)
|
|
17753
18247
|
return;
|
|
17754
18248
|
if (e.retained > 0)
|
|
17755
18249
|
return;
|
|
17756
|
-
this.logger.debug(`[session ${
|
|
17757
|
-
void this.autoClose(
|
|
18250
|
+
this.logger.debug(`[session ${key}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
|
|
18251
|
+
void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] idle detach failed`, error));
|
|
17758
18252
|
}
|
|
17759
18253
|
/**
|
|
17760
18254
|
* Close a station the manager itself decided to close, and announce it.
|
|
@@ -17772,23 +18266,23 @@ var SessionManager = class {
|
|
|
17772
18266
|
* An entry that never carried a session is still torn down, but silently: a pre-warm whose open failed
|
|
17773
18267
|
* leaves one behind, and announcing it would report a station closed that was never reported open.
|
|
17774
18268
|
*/
|
|
17775
|
-
async autoClose(
|
|
17776
|
-
const entry = this.discard(
|
|
18269
|
+
async autoClose(key) {
|
|
18270
|
+
const entry = this.discard(key);
|
|
17777
18271
|
if (!entry)
|
|
17778
18272
|
return;
|
|
17779
18273
|
if (entry.session)
|
|
17780
|
-
this.opts.onAutoClose?.(
|
|
18274
|
+
this.opts.onAutoClose?.(key);
|
|
17781
18275
|
await this.closeEntry(entry);
|
|
17782
18276
|
}
|
|
17783
18277
|
/** Drop a station's entry + timer (called from the session's `close` handler). Idempotent. */
|
|
17784
|
-
remove(
|
|
17785
|
-
const entry = this.discard(
|
|
18278
|
+
remove(key) {
|
|
18279
|
+
const entry = this.discard(key);
|
|
17786
18280
|
if (entry)
|
|
17787
18281
|
this.settleReset(entry);
|
|
17788
18282
|
}
|
|
17789
18283
|
/** Close one station now and discard its lifecycle entry. */
|
|
17790
|
-
async close(
|
|
17791
|
-
const entry = this.discard(
|
|
18284
|
+
async close(key) {
|
|
18285
|
+
const entry = this.discard(key);
|
|
17792
18286
|
if (entry)
|
|
17793
18287
|
await this.closeEntry(entry);
|
|
17794
18288
|
}
|
|
@@ -17802,12 +18296,12 @@ var SessionManager = class {
|
|
|
17802
18296
|
* Both branches close through {@link autoClose}: the caller asked for a recycle, not for the station's
|
|
17803
18297
|
* live sources to be dropped, so it does not clean up after one — exactly like the idle path.
|
|
17804
18298
|
*/
|
|
17805
|
-
resetWhenUnused(
|
|
17806
|
-
const entry = this.entries.get(
|
|
18299
|
+
resetWhenUnused(key) {
|
|
18300
|
+
const entry = this.entries.get(key);
|
|
17807
18301
|
if (!entry)
|
|
17808
18302
|
return Promise.resolve();
|
|
17809
18303
|
if (entry.retained <= entry.holdTimers.size)
|
|
17810
|
-
return this.autoClose(
|
|
18304
|
+
return this.autoClose(key);
|
|
17811
18305
|
entry.reset ??= Promise.withResolvers();
|
|
17812
18306
|
return entry.reset.promise;
|
|
17813
18307
|
}
|
|
@@ -17839,22 +18333,22 @@ var SessionManager = class {
|
|
|
17839
18333
|
* discarded entry owns no live timer, which is what stops a deferred release from landing on whatever
|
|
17840
18334
|
* entry next occupies this serial.
|
|
17841
18335
|
*/
|
|
17842
|
-
discard(
|
|
17843
|
-
const entry = this.entries.get(
|
|
18336
|
+
discard(key) {
|
|
18337
|
+
const entry = this.entries.get(key);
|
|
17844
18338
|
if (!entry)
|
|
17845
18339
|
return void 0;
|
|
17846
18340
|
entry.idle.cancel();
|
|
17847
18341
|
for (const timer of entry.holdTimers)
|
|
17848
18342
|
clearTimeout(timer);
|
|
17849
18343
|
entry.holdTimers.clear();
|
|
17850
|
-
this.entries.delete(
|
|
18344
|
+
this.entries.delete(key);
|
|
17851
18345
|
return entry;
|
|
17852
18346
|
}
|
|
17853
18347
|
/** Close every session and clear all timers. */
|
|
17854
18348
|
async closeAll() {
|
|
17855
18349
|
this.generation++;
|
|
17856
|
-
const entries = [...this.entries.keys()].flatMap((
|
|
17857
|
-
const entry = this.discard(
|
|
18350
|
+
const entries = [...this.entries.keys()].flatMap((key) => {
|
|
18351
|
+
const entry = this.discard(key);
|
|
17858
18352
|
return entry ? [entry] : [];
|
|
17859
18353
|
});
|
|
17860
18354
|
const results = await Promise.allSettled(entries.map((entry) => this.closeEntry(entry)));
|
|
@@ -18858,6 +19352,11 @@ function abortable(work, signal) {
|
|
|
18858
19352
|
}
|
|
18859
19353
|
var LEVEL2_GRACE_MS = 25e3;
|
|
18860
19354
|
var LEVEL2_SETTLE_MS = 8e3;
|
|
19355
|
+
var P2P_STATION_WAITS = {
|
|
19356
|
+
connect: CONNECT_WAIT_MS,
|
|
19357
|
+
level2Grace: LEVEL2_GRACE_MS,
|
|
19358
|
+
level2Settle: LEVEL2_SETTLE_MS
|
|
19359
|
+
};
|
|
18861
19360
|
var RTSP_URL_READ_TIMEOUT_MS = 12e3;
|
|
18862
19361
|
var SHARED_LIVE_OPT_KEYS = [
|
|
18863
19362
|
"eccPrivateKey",
|
|
@@ -18875,7 +19374,7 @@ function sameLiveOpt(a, b) {
|
|
|
18875
19374
|
}
|
|
18876
19375
|
var P2PCommandRouter = class _P2PCommandRouter {
|
|
18877
19376
|
deps;
|
|
18878
|
-
/**
|
|
19377
|
+
/** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
|
|
18879
19378
|
manager;
|
|
18880
19379
|
/** Error objects already forwarded while a station startup awaits the same session signal. */
|
|
18881
19380
|
reportedErrors = /* @__PURE__ */ new WeakSet();
|
|
@@ -18883,6 +19382,21 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18883
19382
|
liveSources = /* @__PURE__ */ new Map();
|
|
18884
19383
|
/** The options each live source was built from, so a later caller's conflicting ones can be reported. */
|
|
18885
19384
|
liveSourceOpts = /* @__PURE__ */ new Map();
|
|
19385
|
+
/**
|
|
19386
|
+
* The session each live source pulls over — the station's own serial, or the source's own media
|
|
19387
|
+
* session key.
|
|
19388
|
+
*
|
|
19389
|
+
* Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
|
|
19390
|
+
* a reservation rather than a record. Two cameras started together (the four-tile case this feature
|
|
19391
|
+
* exists for) would otherwise both find the station's session free: a consumer attaches only after
|
|
19392
|
+
* `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
|
|
19393
|
+
* would take the shared connection and contend on it.
|
|
19394
|
+
*
|
|
19395
|
+
* A station entry is kept as well as a media one, because "is the station's own session already
|
|
19396
|
+
* claimed" is the question being asked, and an entry that is present but not yet in
|
|
19397
|
+
* {@link liveSources} is a claim in flight.
|
|
19398
|
+
*/
|
|
19399
|
+
liveSessionKeys = /* @__PURE__ */ new Map();
|
|
18886
19400
|
/**
|
|
18887
19401
|
* The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
|
|
18888
19402
|
* time and the session carries one audio sequence, so this path is exclusive where a live pull is
|
|
@@ -18896,7 +19410,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18896
19410
|
this.manager = new SessionManager({
|
|
18897
19411
|
poweredFor: deps.poweredFor,
|
|
18898
19412
|
logger: deps.logger,
|
|
18899
|
-
onAutoClose: (
|
|
19413
|
+
onAutoClose: (key) => _P2PCommandRouter.isMediaSessionKey(key) ? this.tearDownMediaSession(key) : this.tearDownStation(key),
|
|
18900
19414
|
...deps.sessionIdle
|
|
18901
19415
|
});
|
|
18902
19416
|
}
|
|
@@ -18909,6 +19423,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18909
19423
|
}
|
|
18910
19424
|
return normalized;
|
|
18911
19425
|
}
|
|
19426
|
+
/**
|
|
19427
|
+
* Emit a live trace under a station session's handle, for work this router does ON that session before
|
|
19428
|
+
* the session itself records anything — reaching the station, and resolving what a device is on it. Same
|
|
19429
|
+
* handle as everything the session goes on to trace, which is what groups one attempt.
|
|
19430
|
+
*/
|
|
19431
|
+
traceOnStation(session, trace) {
|
|
19432
|
+
traceLiveStart(this.deps.logger ?? noopLogger, trace, session.traceId);
|
|
19433
|
+
}
|
|
18912
19434
|
/**
|
|
18913
19435
|
* Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
|
|
18914
19436
|
* usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
|
|
@@ -18920,7 +19442,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18920
19442
|
static claimsDevice(dev) {
|
|
18921
19443
|
return typeof dev.p2pDid === "string" && dev.p2pDid.length > 0;
|
|
18922
19444
|
}
|
|
18923
|
-
/**
|
|
19445
|
+
/**
|
|
19446
|
+
* The open P2P sessions by key — a station's own under its serial, a camera's media session under
|
|
19447
|
+
* `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
|
|
19448
|
+
*/
|
|
18924
19449
|
getSessions() {
|
|
18925
19450
|
return this.manager.liveSessions();
|
|
18926
19451
|
}
|
|
@@ -18932,16 +19457,16 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18932
19457
|
*
|
|
18933
19458
|
* One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
|
|
18934
19459
|
* own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
|
|
18935
|
-
* A second hold after the open would buy nothing: {@link
|
|
19460
|
+
* A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
|
|
18936
19461
|
* and the lookups are away, not once the peer has answered, so both would expire together.
|
|
18937
19462
|
*
|
|
18938
19463
|
* Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
|
|
18939
19464
|
* session was deliberately closed underneath a speculative open, which is not a fault to report.
|
|
18940
19465
|
*/
|
|
18941
19466
|
async prewarm(parentSn, ms = PREWARM_MS) {
|
|
18942
|
-
this.manager.hold(parentSn, ms);
|
|
19467
|
+
this.manager.hold(parentSn, ms, parentSn);
|
|
18943
19468
|
try {
|
|
18944
|
-
await this.
|
|
19469
|
+
await this.openSession(parentSn, parentSn);
|
|
18945
19470
|
} catch (e) {
|
|
18946
19471
|
if (e instanceof SessionSupersededError)
|
|
18947
19472
|
return;
|
|
@@ -18958,6 +19483,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18958
19483
|
src.dispose();
|
|
18959
19484
|
this.liveSources.clear();
|
|
18960
19485
|
this.liveSourceOpts.clear();
|
|
19486
|
+
this.liveSessionKeys.clear();
|
|
18961
19487
|
await this.manager.closeAll();
|
|
18962
19488
|
}
|
|
18963
19489
|
/** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
|
|
@@ -18999,8 +19525,55 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
18999
19525
|
* when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
|
|
19000
19526
|
* on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
|
|
19001
19527
|
*/
|
|
19002
|
-
|
|
19003
|
-
|
|
19528
|
+
/**
|
|
19529
|
+
* The key a camera's own media session is filed under, distinct from every station serial because a
|
|
19530
|
+
* serial contains no `#`.
|
|
19531
|
+
*/
|
|
19532
|
+
static mediaSessionKey(parentSn, channel) {
|
|
19533
|
+
return `${parentSn}#live:${channel}`;
|
|
19534
|
+
}
|
|
19535
|
+
/** Whether `key` names a media session rather than a station's own. */
|
|
19536
|
+
static isMediaSessionKey(key) {
|
|
19537
|
+
return key.includes("#live:");
|
|
19538
|
+
}
|
|
19539
|
+
/**
|
|
19540
|
+
* Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
|
|
19541
|
+
*
|
|
19542
|
+
* One session serves one camera: a station fans its cameras over a session and answers the most recent
|
|
19543
|
+
* start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
|
|
19544
|
+
* how a station serves another camera.
|
|
19545
|
+
*
|
|
19546
|
+
* The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
|
|
19547
|
+
* captured opening one PPCS session per camera, with three cameras' video arriving in the same second
|
|
19548
|
+
* at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
|
|
19549
|
+
* Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
|
|
19550
|
+
* full frame rate at once over a session each, where the same pair down one session could only take
|
|
19551
|
+
* turns.
|
|
19552
|
+
*
|
|
19553
|
+
* It carries media alone. The station announces its state to every client that connects, so a session
|
|
19554
|
+
* wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
|
|
19555
|
+
* unannounced, and the station's own session stays the single source of connection state, control
|
|
19556
|
+
* notifications and frames.
|
|
19557
|
+
*
|
|
19558
|
+
* The level-2 key is waited for here on the same terms the station's own session gets, because a
|
|
19559
|
+
* connection is not usable for this without one: an attached camera's media start has no level-1 form,
|
|
19560
|
+
* so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
|
|
19561
|
+
* shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
|
|
19562
|
+
* carry the start is not a connected session, so this refuses rather than returning one.
|
|
19563
|
+
*/
|
|
19564
|
+
async openMediaSession(parentSn, channel, signal) {
|
|
19565
|
+
const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
|
|
19566
|
+
let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
|
|
19567
|
+
if (!ready && session.repromptLevel2Key()) {
|
|
19568
|
+
ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
|
|
19569
|
+
}
|
|
19570
|
+
if (!ready)
|
|
19571
|
+
throw new StationKeyUnavailableError(parentSn);
|
|
19572
|
+
return session;
|
|
19573
|
+
}
|
|
19574
|
+
/** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
|
|
19575
|
+
async openSession(key, parentSn) {
|
|
19576
|
+
return this.manager.acquire(key, async (register) => {
|
|
19004
19577
|
const stationDev = this.recordFor(parentSn);
|
|
19005
19578
|
if (!stationDev)
|
|
19006
19579
|
throw new Error(`station ${parentSn} is not in the device list`);
|
|
@@ -19015,11 +19588,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19015
19588
|
this.deps.onError(e instanceof Error ? e : new Error(String(e)));
|
|
19016
19589
|
}
|
|
19017
19590
|
const localAddress = this.deps.localAddresses?.[parentSn] ?? freshestLanIp(raw);
|
|
19018
|
-
const session = this.makeSession(parentSn, did, raw, dskKey, localAddress);
|
|
19591
|
+
const session = this.makeSession(parentSn, did, raw, dskKey, localAddress, key, key === parentSn);
|
|
19019
19592
|
register(session);
|
|
19020
19593
|
await session.connect();
|
|
19021
19594
|
return session;
|
|
19022
|
-
});
|
|
19595
|
+
}, parentSn);
|
|
19023
19596
|
}
|
|
19024
19597
|
/**
|
|
19025
19598
|
* Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
|
|
@@ -19032,8 +19605,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19032
19605
|
*
|
|
19033
19606
|
* The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
|
|
19034
19607
|
* source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
|
|
19608
|
+
*
|
|
19609
|
+
* A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
|
|
19610
|
+
* level-2 readiness and inbound frames all reach the owner through the station's own session, and a
|
|
19611
|
+
* station announces those to every client that connects — so fanning a second connection's copies out
|
|
19612
|
+
* under the same station serial would report each one twice, and a close would tear down the station
|
|
19613
|
+
* while its own session is still serving.
|
|
19035
19614
|
*/
|
|
19036
|
-
makeSession(stationSn, did, raw, dskKey, localAddress) {
|
|
19615
|
+
makeSession(stationSn, did, raw, dskKey, localAddress, key, announces) {
|
|
19037
19616
|
const conn = raw?.p2p_conn ?? raw?.app_conn;
|
|
19038
19617
|
const adminUserId = raw?.member?.admin_user_id || this.deps.mega.auth?.userId || "";
|
|
19039
19618
|
const session = new P2PSession({
|
|
@@ -19049,7 +19628,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19049
19628
|
let ecc;
|
|
19050
19629
|
try {
|
|
19051
19630
|
const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
|
|
19052
|
-
ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key
|
|
19631
|
+
ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
|
|
19632
|
+
if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
|
|
19633
|
+
ecc = ciphers[0].ecc_private_key;
|
|
19634
|
+
this.traceOnStation(session, {
|
|
19635
|
+
phase: "cipher-fallback",
|
|
19636
|
+
cipherId,
|
|
19637
|
+
answeredCipherId: Number(ciphers[0].cipher_id)
|
|
19638
|
+
});
|
|
19639
|
+
}
|
|
19053
19640
|
} catch (e) {
|
|
19054
19641
|
this.deps.onError(e instanceof Error ? e : new Error(String(e)));
|
|
19055
19642
|
}
|
|
@@ -19059,6 +19646,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19059
19646
|
},
|
|
19060
19647
|
logger: this.deps.logger ?? noopLogger
|
|
19061
19648
|
});
|
|
19649
|
+
session.on("error", (e) => this.reportError(e));
|
|
19650
|
+
if (!announces) {
|
|
19651
|
+
session.on("close", () => {
|
|
19652
|
+
if (this.manager.get(key) === session)
|
|
19653
|
+
this.tearDownMediaSession(key);
|
|
19654
|
+
});
|
|
19655
|
+
return session;
|
|
19656
|
+
}
|
|
19062
19657
|
session.on("connect", () => {
|
|
19063
19658
|
if (this.manager.get(stationSn) === session)
|
|
19064
19659
|
this.deps.onConnect(stationSn);
|
|
@@ -19068,7 +19663,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19068
19663
|
return;
|
|
19069
19664
|
this.tearDownStation(stationSn);
|
|
19070
19665
|
});
|
|
19071
|
-
session.on("error", (e) => this.reportError(e));
|
|
19072
19666
|
session.on("level2Ready", ({ cipherId }) => this.deps.onLevel2Ready(stationSn, cipherId));
|
|
19073
19667
|
session.on("data", (f) => this.deps.onFrame(stationSn, f));
|
|
19074
19668
|
return session;
|
|
@@ -19079,7 +19673,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19079
19673
|
*/
|
|
19080
19674
|
async ensureStation(parentSn, signal) {
|
|
19081
19675
|
try {
|
|
19082
|
-
const session = await this.
|
|
19676
|
+
const session = await this.openSession(parentSn, parentSn);
|
|
19083
19677
|
if (session.isConnected)
|
|
19084
19678
|
return;
|
|
19085
19679
|
await new Promise((resolve, reject) => {
|
|
@@ -19127,7 +19721,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19127
19721
|
const dev = this.recordFor(sn);
|
|
19128
19722
|
if (!dev)
|
|
19129
19723
|
throw new Error(`device ${sn} not found`);
|
|
19130
|
-
await this.
|
|
19724
|
+
await this.openSession(this.stationKeyFor(dev), this.stationKeyFor(dev));
|
|
19131
19725
|
return dev;
|
|
19132
19726
|
}
|
|
19133
19727
|
/**
|
|
@@ -19203,19 +19797,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19203
19797
|
return {
|
|
19204
19798
|
snapshotLive: async (opts) => {
|
|
19205
19799
|
const source = await this.sharedLiveSourceFor(sn, opts ?? {});
|
|
19206
|
-
return
|
|
19800
|
+
return captureSnapshotFromShared(source, {
|
|
19207
19801
|
...opts,
|
|
19208
19802
|
logger: this.deps.logger ?? noopLogger,
|
|
19209
19803
|
ffmpegLevel: this.deps.ffmpegLogLevel,
|
|
19210
19804
|
ffmpegPath: this.deps.ffmpegPath
|
|
19211
|
-
})
|
|
19805
|
+
});
|
|
19212
19806
|
},
|
|
19213
19807
|
live: async (opts) => {
|
|
19214
|
-
const source = await this.sharedLiveSourceFor(sn, opts);
|
|
19808
|
+
const source = await this.sharedLiveSourceFor(sn, opts, true);
|
|
19215
19809
|
return this.attachUnlessAborted(source, opts?.signal);
|
|
19216
19810
|
},
|
|
19217
19811
|
openReadable: async (opts) => {
|
|
19218
|
-
const source = await this.sharedLiveSourceFor(sn, opts ?? {});
|
|
19812
|
+
const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
|
|
19219
19813
|
return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
|
|
19220
19814
|
},
|
|
19221
19815
|
recordFragments: (opts) => this.recordFragments(sn, opts),
|
|
@@ -19356,10 +19950,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19356
19950
|
* {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
|
|
19357
19951
|
* reuse branch, so a reuse frees the station as a cold start does.
|
|
19358
19952
|
*
|
|
19953
|
+
* `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
|
|
19954
|
+
* connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
|
|
19955
|
+
* frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
|
|
19956
|
+
* so a still asked for while a sibling is being watched contends as it always did, and the caller's
|
|
19957
|
+
* retained image answers it. A live view still outranks a tile; what changed is that two live views no
|
|
19958
|
+
* longer have to outrank each other.
|
|
19959
|
+
*
|
|
19359
19960
|
* The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
|
|
19360
19961
|
* place.
|
|
19361
19962
|
*/
|
|
19362
|
-
async sharedLiveSourceFor(sn, opts = {}) {
|
|
19963
|
+
async sharedLiveSourceFor(sn, opts = {}, mayOpenOwnSession = false) {
|
|
19363
19964
|
const { session, parentSn, channel, accountId, homeBaseAttached } = await this.resolveSession(sn, {
|
|
19364
19965
|
waitLevel2: "soft",
|
|
19365
19966
|
requireLevel2ForAttached: true,
|
|
@@ -19372,14 +19973,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19372
19973
|
source = void 0;
|
|
19373
19974
|
}
|
|
19374
19975
|
this.releaseLingeringSiblings(parentSn, channel);
|
|
19375
|
-
if (homeBaseAttached) {
|
|
19376
|
-
const serving = this.occupiedSiblingChannel(parentSn, key);
|
|
19377
|
-
if (serving !== void 0)
|
|
19378
|
-
throw new StationBusyError(serving);
|
|
19379
|
-
}
|
|
19380
19976
|
if (!source) {
|
|
19381
19977
|
const logger = this.deps.logger ?? noopLogger;
|
|
19382
|
-
const
|
|
19978
|
+
const sessionKey = mayOpenOwnSession && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
|
|
19979
|
+
this.liveSessionKeys.set(key, sessionKey);
|
|
19980
|
+
let held;
|
|
19981
|
+
try {
|
|
19982
|
+
held = {
|
|
19983
|
+
session: sessionKey === parentSn ? session : await this.openMediaSession(parentSn, channel, opts.signal)
|
|
19984
|
+
};
|
|
19985
|
+
} catch (error) {
|
|
19986
|
+
this.liveSessionKeys.delete(key);
|
|
19987
|
+
throw error;
|
|
19988
|
+
}
|
|
19383
19989
|
source = new SharedLiveSource({
|
|
19384
19990
|
makeStream: (ctx) => new LiveStream(held.session, {
|
|
19385
19991
|
channel,
|
|
@@ -19397,8 +20003,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19397
20003
|
budgetGraceMs: opts.budgetGraceMs,
|
|
19398
20004
|
logger,
|
|
19399
20005
|
label: key,
|
|
19400
|
-
onActive: () => this.manager.retain(
|
|
19401
|
-
onIdle: () => this.manager.release(
|
|
20006
|
+
onActive: () => this.manager.retain(sessionKey),
|
|
20007
|
+
onIdle: () => this.manager.release(sessionKey),
|
|
20008
|
+
onStopped: () => this.closeMediaSession(key),
|
|
19402
20009
|
onStartFailed: () => this.onLiveStartFailed(sn, key),
|
|
19403
20010
|
onSessionUnreachable: () => this.replaceUnreachableSession(sn, key, held)
|
|
19404
20011
|
});
|
|
@@ -19413,14 +20020,25 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19413
20020
|
* Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
|
|
19414
20021
|
*
|
|
19415
20022
|
* A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
|
|
19416
|
-
* re-sending the full media start every keepalive tick. Two channels doing that at once
|
|
19417
|
-
* serves one camera at a time leaves the new stream receiving nothing but the old camera's frames
|
|
19418
|
-
* long as the linger lasts.
|
|
20023
|
+
* re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
|
|
20024
|
+
* which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
|
|
20025
|
+
* for as long as the linger lasts.
|
|
19419
20026
|
*
|
|
19420
20027
|
* Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
|
|
19421
20028
|
* re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
|
|
19422
20029
|
* looking at competing with one somebody just asked for.
|
|
19423
20030
|
*
|
|
20031
|
+
* An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
|
|
20032
|
+
* has sent no media start and is competing for nothing. Without that, two cameras opened at the same
|
|
20033
|
+
* moment destroy each other — the second finds the first's source built but not yet attached to, reads
|
|
20034
|
+
* zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
|
|
20035
|
+
* this whole path exists for.
|
|
20036
|
+
*
|
|
20037
|
+
* A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
|
|
20038
|
+
* contention this releases is contention over one session, and that sibling is not on this one. Dropping
|
|
20039
|
+
* it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
|
|
20040
|
+
* competition that is not happening.
|
|
20041
|
+
*
|
|
19424
20042
|
* A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
|
|
19425
20043
|
* pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
|
|
19426
20044
|
* retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
|
|
@@ -19434,28 +20052,49 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19434
20052
|
for (const [key, source] of [...this.liveSources]) {
|
|
19435
20053
|
if (!key.startsWith(`${parentSn}:`) || key === own || source.consumerCount > 0)
|
|
19436
20054
|
continue;
|
|
19437
|
-
(
|
|
20055
|
+
if (source.state === "idle")
|
|
20056
|
+
continue;
|
|
20057
|
+
if (this.liveSessionKeys.get(key) !== parentSn)
|
|
20058
|
+
continue;
|
|
20059
|
+
(this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one session serves one camera at a time`);
|
|
19438
20060
|
this.dropLiveSource(key);
|
|
19439
20061
|
}
|
|
19440
20062
|
}
|
|
19441
20063
|
/**
|
|
19442
|
-
*
|
|
20064
|
+
* Whether another camera has already claimed this station's OWN session, ignoring `key`.
|
|
20065
|
+
*
|
|
20066
|
+
* The question a newcomer has to answer is not whether the station is busy — it can serve one camera
|
|
20067
|
+
* per connection — but whether the connection it would otherwise share is taken. A camera on a media
|
|
20068
|
+
* session of its own does not hold this one, so a station whose first camera has since stopped hands
|
|
20069
|
+
* its own session to the next arrival rather than opening a socket beside an idle one.
|
|
20070
|
+
*
|
|
20071
|
+
* A claim counts while its source is still `idle`, source or no source. That is a start that has been
|
|
20072
|
+
* handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
|
|
20073
|
+
* at the same moment can see each other: consumers attach after this method has already run for both.
|
|
20074
|
+
*
|
|
20075
|
+
* The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
|
|
20076
|
+
* session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
|
|
20077
|
+
* socket against destroying a start someone is waiting on, which is not a close trade.
|
|
19443
20078
|
*
|
|
19444
20079
|
* A stopped source is skipped even when consumers are still attached to it. A failed start fails its
|
|
19445
20080
|
* consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
|
|
19446
|
-
* and counting that as a viewer would
|
|
19447
|
-
* restarted. Only a source that can still deliver holds a place.
|
|
20081
|
+
* and counting that as a viewer would put every later camera on a connection of its own until the
|
|
20082
|
+
* client restarted. Only a source that can still deliver holds a place.
|
|
19448
20083
|
*/
|
|
19449
|
-
|
|
19450
|
-
for (const [siblingKey,
|
|
19451
|
-
if (siblingKey === key ||
|
|
20084
|
+
stationSessionInUse(parentSn, key) {
|
|
20085
|
+
for (const [siblingKey, sessionKey] of this.liveSessionKeys) {
|
|
20086
|
+
if (siblingKey === key || sessionKey !== parentSn)
|
|
20087
|
+
continue;
|
|
20088
|
+
const sibling = this.liveSources.get(siblingKey);
|
|
20089
|
+
if (!sibling)
|
|
20090
|
+
return true;
|
|
20091
|
+
if (sibling.state === "stopped")
|
|
19452
20092
|
continue;
|
|
19453
|
-
if (sibling.state
|
|
20093
|
+
if (sibling.state !== "idle" && sibling.consumerCount === 0)
|
|
19454
20094
|
continue;
|
|
19455
|
-
|
|
19456
|
-
return Number.isFinite(channel) ? channel : void 0;
|
|
20095
|
+
return true;
|
|
19457
20096
|
}
|
|
19458
|
-
return
|
|
20097
|
+
return false;
|
|
19459
20098
|
}
|
|
19460
20099
|
/**
|
|
19461
20100
|
* Whether the stream on `key` should re-assert its channel to hold the station.
|
|
@@ -19466,8 +20105,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19466
20105
|
* - Nothing attached: no. There is nobody to take the station for.
|
|
19467
20106
|
* - A live viewer attached: yes. That is the picture someone is looking at.
|
|
19468
20107
|
* - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
|
|
19469
|
-
* tile that is off screen while the live view is on it, and
|
|
19470
|
-
* cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
|
|
20108
|
+
* tile that is off screen while the live view is on it, and one session serving one camera at a time
|
|
20109
|
+
* cannot satisfy both — and a still does not open a connection of its own. Measured: a still on a sibling halved a live view's frame rate for as long as
|
|
19471
20110
|
* it took, and its own capture then took fifteen seconds because it was contending.
|
|
19472
20111
|
*
|
|
19473
20112
|
* A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
|
|
@@ -19489,7 +20128,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19489
20128
|
}
|
|
19490
20129
|
return consumer;
|
|
19491
20130
|
}
|
|
19492
|
-
/**
|
|
20131
|
+
/**
|
|
20132
|
+
* Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
|
|
20133
|
+
* on a session goes with it, and a media session opened for this source alone is closed: nothing else
|
|
20134
|
+
* can reach that connection, so leaving it open would hold a socket and a station keepalive for a
|
|
20135
|
+
* camera no longer being pulled. A claim on the STATION's own session is released without closing
|
|
20136
|
+
* anything — that connection carries the station's control traffic and outlives any one camera.
|
|
20137
|
+
*/
|
|
19493
20138
|
dropLiveSource(key) {
|
|
19494
20139
|
const source = this.liveSources.get(key);
|
|
19495
20140
|
if (!source)
|
|
@@ -19497,6 +20142,34 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19497
20142
|
source.dispose();
|
|
19498
20143
|
this.liveSources.delete(key);
|
|
19499
20144
|
this.liveSourceOpts.delete(key);
|
|
20145
|
+
this.closeMediaSession(key);
|
|
20146
|
+
}
|
|
20147
|
+
/** Close and forget the media session `key`'s live source owned, if it owned one. */
|
|
20148
|
+
closeMediaSession(key) {
|
|
20149
|
+
const sessionKey = this.liveSessionKeys.get(key);
|
|
20150
|
+
if (sessionKey === void 0)
|
|
20151
|
+
return;
|
|
20152
|
+
this.liveSessionKeys.delete(key);
|
|
20153
|
+
if (!_P2PCommandRouter.isMediaSessionKey(sessionKey))
|
|
20154
|
+
return;
|
|
20155
|
+
void this.manager.close(sessionKey).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
|
|
20156
|
+
}
|
|
20157
|
+
/**
|
|
20158
|
+
* Drop the live source a media session was carrying, after that session closed on its own.
|
|
20159
|
+
*
|
|
20160
|
+
* The source holds the closed connection and never re-resolves it, so it can only answer its retained
|
|
20161
|
+
* keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
|
|
20162
|
+
* a fresh source — which, finding the station busy again, opens a fresh media session for it.
|
|
20163
|
+
*/
|
|
20164
|
+
tearDownMediaSession(sessionKey) {
|
|
20165
|
+
this.manager.remove(sessionKey);
|
|
20166
|
+
for (const [key, owned] of this.liveSessionKeys) {
|
|
20167
|
+
if (owned === sessionKey) {
|
|
20168
|
+
this.liveSessionKeys.delete(key);
|
|
20169
|
+
this.dropLiveSource(key);
|
|
20170
|
+
return;
|
|
20171
|
+
}
|
|
20172
|
+
}
|
|
19500
20173
|
}
|
|
19501
20174
|
/**
|
|
19502
20175
|
* Drop everything that was riding a station's session, and report the station closed.
|
|
@@ -19680,9 +20353,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19680
20353
|
s.sendStringPayloadCommand(P2P_ENVELOPE.CONTROL_PAYLOAD, json, ch);
|
|
19681
20354
|
return Promise.resolve();
|
|
19682
20355
|
},
|
|
19683
|
-
l2: async ({ session: s, channel: ch }) => {
|
|
20356
|
+
l2: async ({ session: s, channel: ch, parentSn }) => {
|
|
19684
20357
|
if (!await s.awaitLevel2Key(LEVEL2_GRACE_MS, "call")) {
|
|
19685
|
-
throw new
|
|
20358
|
+
throw new StationKeyUnavailableError(parentSn);
|
|
19686
20359
|
}
|
|
19687
20360
|
s.sendRawLevel2(json, ch, P2P_ENVELOPE.CONTROL_PAYLOAD);
|
|
19688
20361
|
}
|
|
@@ -19840,24 +20513,38 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19840
20513
|
const parentSn = homeBaseAttached ? raw.parent_sn : dev.stationSn ?? sn;
|
|
19841
20514
|
const session = this.manager.get(parentSn) ?? this.manager.get(sn) ?? (dev.stationSn ? this.manager.get(dev.stationSn) : void 0);
|
|
19842
20515
|
if (!session) {
|
|
19843
|
-
|
|
20516
|
+
const stations = this.manager.keys().filter((k) => !_P2PCommandRouter.isMediaSessionKey(k));
|
|
20517
|
+
throw new Error(`no P2P session for ${sn} (known: ${stations.join(", ") || "none"})`);
|
|
19844
20518
|
}
|
|
19845
20519
|
if (session.pathAnswering === false && !rebuilt) {
|
|
19846
20520
|
(this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
|
|
19847
20521
|
await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
|
|
19848
20522
|
return await this.resolveSession(sn, opts, true);
|
|
19849
20523
|
}
|
|
19850
|
-
this.manager.bumpCommand(parentSn);
|
|
20524
|
+
this.manager.bumpCommand(parentSn, parentSn);
|
|
19851
20525
|
const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
|
|
19852
|
-
const
|
|
20526
|
+
const stationAdminId = raw.member?.admin_user_id;
|
|
20527
|
+
const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
|
|
19853
20528
|
const t0 = Date.now();
|
|
19854
|
-
|
|
19855
|
-
|
|
19856
|
-
|
|
20529
|
+
let waitedMs = 0;
|
|
20530
|
+
if (!session.isConnected) {
|
|
20531
|
+
this.traceOnStation(session, { phase: "session-connect-wait", waitMs: CONNECT_WAIT_MS });
|
|
20532
|
+
while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
|
|
20533
|
+
opts.signal?.throwIfAborted();
|
|
20534
|
+
await sleep2(200);
|
|
20535
|
+
}
|
|
20536
|
+
waitedMs = Date.now() - t0;
|
|
20537
|
+
this.traceOnStation(session, session.isConnected ? { phase: "session-connected", waitedMs } : { phase: "session-unreachable", waitedMs });
|
|
19857
20538
|
}
|
|
19858
20539
|
opts.signal?.throwIfAborted();
|
|
19859
20540
|
if (!session.isConnected)
|
|
19860
|
-
throw new
|
|
20541
|
+
throw new StationUnreachableError(parentSn, waitedMs);
|
|
20542
|
+
this.traceOnStation(session, {
|
|
20543
|
+
phase: "station-resolved",
|
|
20544
|
+
topology: homeBaseAttached ? "attached" : "own",
|
|
20545
|
+
channel,
|
|
20546
|
+
stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other"
|
|
20547
|
+
});
|
|
19861
20548
|
if (opts.waitLevel2) {
|
|
19862
20549
|
if (opts.waitLevel2 === "settle") {
|
|
19863
20550
|
await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
|
|
@@ -19871,7 +20558,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
19871
20558
|
ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), opts.signal);
|
|
19872
20559
|
}
|
|
19873
20560
|
if (!ready)
|
|
19874
|
-
throw new
|
|
20561
|
+
throw new StationKeyUnavailableError(parentSn);
|
|
19875
20562
|
}
|
|
19876
20563
|
return { session, parentSn, channel, accountId, homeBaseAttached };
|
|
19877
20564
|
}
|
|
@@ -21358,8 +22045,8 @@ function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
|
|
|
21358
22045
|
},
|
|
21359
22046
|
format: "jwk"
|
|
21360
22047
|
});
|
|
21361
|
-
const
|
|
21362
|
-
return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(
|
|
22048
|
+
const md5Hex2 = createHash6("md5").update(password).digest("hex");
|
|
22049
|
+
return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex2)).toString("hex");
|
|
21363
22050
|
}
|
|
21364
22051
|
var TuyaClient = class {
|
|
21365
22052
|
signer;
|
|
@@ -23986,8 +24673,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
23986
24673
|
* @example
|
|
23987
24674
|
* ```ts
|
|
23988
24675
|
* const res = await eufy.login();
|
|
23989
|
-
* if (res.status === "captcha") await eufy.solveCaptcha(await
|
|
23990
|
-
* else if (res.status === "2fa") await eufy.submitVerifyCode(await
|
|
24676
|
+
* if (res.status === "captcha") await eufy.solveCaptcha(await promptUser(res.image));
|
|
24677
|
+
* else if (res.status === "2fa") await eufy.submitVerifyCode(await promptUser());
|
|
23991
24678
|
* ```
|
|
23992
24679
|
*/
|
|
23993
24680
|
async login(opts = {}) {
|
|
@@ -24141,9 +24828,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24141
24828
|
/**
|
|
24142
24829
|
* Combine explicit P2P media with the optional passive push-thumbnail provider.
|
|
24143
24830
|
*
|
|
24144
|
-
* The retained still also becomes the answer for a live still that could not be captured.
|
|
24145
|
-
* serves one camera at a time and a live view outranks a tile
|
|
24146
|
-
* being watched is refused at the transport. Answering the retained bytes answers the read rather than
|
|
24831
|
+
* The retained still also becomes the answer for a live still that could not be captured. One session
|
|
24832
|
+
* serves one camera at a time and a live view outranks a tile — a still does not open a connection of its
|
|
24833
|
+
* own — so a still asked for while a sibling is being watched is refused at the transport. Answering the retained bytes answers the read rather than
|
|
24147
24834
|
* failing it, marked {@link MediaProvider.snapshotLive} `retained` so the caller knows they are not
|
|
24148
24835
|
* current. With nothing retained the refusal stands.
|
|
24149
24836
|
*/
|
|
@@ -24312,7 +24999,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24312
24999
|
* @example
|
|
24313
25000
|
* ```ts
|
|
24314
25001
|
* const dev = await eufy.getDevice(sn);
|
|
24315
|
-
* if (dev.has("camera")) await dev.camera()?.snapshotStored();
|
|
25002
|
+
* if (dev.has("camera")) await dev.camera?.()?.snapshotStored?.();
|
|
24316
25003
|
* console.log(dev.getProperty("battery"));
|
|
24317
25004
|
* ```
|
|
24318
25005
|
*/
|
|
@@ -24896,10 +25583,15 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
24896
25583
|
await sink.dispatch(cmd);
|
|
24897
25584
|
}
|
|
24898
25585
|
/**
|
|
24899
|
-
*
|
|
25586
|
+
* The open P2P sessions, by key. P2P is auto-managed: wired stations are warmed at login, battery
|
|
24900
25587
|
* stations open on demand (command / stream, or an opted-in event pre-warm) and idle-detach — so this
|
|
24901
|
-
* map grows and shrinks over time.
|
|
24902
|
-
*
|
|
25588
|
+
* map grows and shrinks over time.
|
|
25589
|
+
*
|
|
25590
|
+
* A station's own session is keyed by its serial, and `p2pConnect(stationSn)` / `p2pClose(stationSn)`
|
|
25591
|
+
* track those. A station serving more than one camera at once also holds a session per extra camera,
|
|
25592
|
+
* keyed `<stationSn>#live:<channel>` — these carry media alone and raise no connection events, because
|
|
25593
|
+
* a station announces its state to every client that connects and reporting each copy would double
|
|
25594
|
+
* every event the station's own session already delivers.
|
|
24903
25595
|
*/
|
|
24904
25596
|
getP2pSessions() {
|
|
24905
25597
|
return this.p2p.getSessions();
|
|
@@ -25272,6 +25964,553 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25272
25964
|
}
|
|
25273
25965
|
};
|
|
25274
25966
|
|
|
25967
|
+
// dist/transport/http/solix-constants.js
|
|
25968
|
+
var SOLIX_APP_NAME = "anker_power";
|
|
25969
|
+
var SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
|
|
25970
|
+
var SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
|
|
25971
|
+
var SOLIX_ENDPOINTS = {
|
|
25972
|
+
estimateDomain: "/passport/estimate_domain",
|
|
25973
|
+
keyExchange: "/openapi/oauth/key/exchange",
|
|
25974
|
+
login: "/passport/login",
|
|
25975
|
+
/** Bound devices for the account (flat list). */
|
|
25976
|
+
getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices",
|
|
25977
|
+
/** Sites (systems) the account owns; devices are grouped under a site. */
|
|
25978
|
+
getSiteList: "/power_service/v1/site/get_site_list",
|
|
25979
|
+
/** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
|
|
25980
|
+
getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
|
|
25981
|
+
/** GET: the pairable-product catalog (categories → products), for labelling model codes. */
|
|
25982
|
+
productCategories: "/power_service/v1/product_categories"
|
|
25983
|
+
};
|
|
25984
|
+
|
|
25985
|
+
// dist/transport/http/solix-client.js
|
|
25986
|
+
function solixSessionFresh(s) {
|
|
25987
|
+
return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
|
|
25988
|
+
}
|
|
25989
|
+
var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
25990
|
+
var SolixClient = class {
|
|
25991
|
+
email;
|
|
25992
|
+
password;
|
|
25993
|
+
country;
|
|
25994
|
+
appVersion;
|
|
25995
|
+
doFetch;
|
|
25996
|
+
store;
|
|
25997
|
+
openudid;
|
|
25998
|
+
apiHost;
|
|
25999
|
+
session_;
|
|
26000
|
+
/** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
|
|
26001
|
+
pending2fa;
|
|
26002
|
+
/**
|
|
26003
|
+
* Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
|
|
26004
|
+
* not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
|
|
26005
|
+
* handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
|
|
26006
|
+
* override that also skips domain-estimate, and every read goes through `this.apiHost`.
|
|
26007
|
+
*/
|
|
26008
|
+
constructor(opts) {
|
|
26009
|
+
this.email = opts.email;
|
|
26010
|
+
this.password = opts.password;
|
|
26011
|
+
this.country = (opts.countryCode ?? "US").toUpperCase();
|
|
26012
|
+
this.appVersion = opts.appVersion ?? "3.23.0";
|
|
26013
|
+
this.doFetch = opts.fetchImpl ?? fetch;
|
|
26014
|
+
this.apiHost = opts.apiHost ?? SOLIX_DEFAULT_API_HOST;
|
|
26015
|
+
this.store = opts.store;
|
|
26016
|
+
const saved = this.store?.load();
|
|
26017
|
+
this.openudid = opts.openudid ?? saved?.openudid ?? uuidFromHex(md5Hex(`anker-solix:${opts.email}`));
|
|
26018
|
+
if (saved?.session && solixSessionFresh(saved.session)) {
|
|
26019
|
+
this.session_ = saved.session;
|
|
26020
|
+
this.apiHost = opts.apiHost ?? saved.session.apiHost;
|
|
26021
|
+
}
|
|
26022
|
+
}
|
|
26023
|
+
/** Persist the current device id (+ session, if any) when a store is configured. */
|
|
26024
|
+
persist() {
|
|
26025
|
+
this.store?.save({ openudid: this.openudid, session: this.session_ });
|
|
26026
|
+
}
|
|
26027
|
+
/** The authenticated session, once {@link login} has resolved to `ok`. */
|
|
26028
|
+
get session() {
|
|
26029
|
+
return this.session_;
|
|
26030
|
+
}
|
|
26031
|
+
/**
|
|
26032
|
+
* Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
|
|
26033
|
+
* must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
|
|
26034
|
+
* (`401 token error`) — so those use {@link baseHeaders} directly.
|
|
26035
|
+
*/
|
|
26036
|
+
authHeaders(extra = {}) {
|
|
26037
|
+
return this.baseHeaders({ openudid: this.openudid, "x-terminal-id": this.openudid, ...extra });
|
|
26038
|
+
}
|
|
26039
|
+
/** Base headers common to every Solix request. */
|
|
26040
|
+
baseHeaders(extra = {}) {
|
|
26041
|
+
return {
|
|
26042
|
+
"content-type": "application/json",
|
|
26043
|
+
"app-name": SOLIX_APP_NAME,
|
|
26044
|
+
"model-type": "PHONE",
|
|
26045
|
+
"os-type": "android",
|
|
26046
|
+
"os-version": "36",
|
|
26047
|
+
"app-version": this.appVersion,
|
|
26048
|
+
country: this.country,
|
|
26049
|
+
timezone: "GMT+00:00",
|
|
26050
|
+
language: "en",
|
|
26051
|
+
"user-agent": "ktor-client",
|
|
26052
|
+
accept: "application/json",
|
|
26053
|
+
...extra
|
|
26054
|
+
};
|
|
26055
|
+
}
|
|
26056
|
+
/** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
|
|
26057
|
+
async send(method2, host, path, headers, body) {
|
|
26058
|
+
const res = await this.doFetch(`https://${host}${path}`, {
|
|
26059
|
+
method: method2,
|
|
26060
|
+
headers,
|
|
26061
|
+
body,
|
|
26062
|
+
signal: AbortSignal.timeout(2e4)
|
|
26063
|
+
});
|
|
26064
|
+
const text2 = await res.text();
|
|
26065
|
+
try {
|
|
26066
|
+
return JSON.parse(text2);
|
|
26067
|
+
} catch {
|
|
26068
|
+
throw new Error(`Solix ${path} \u2192 HTTP ${res.status}, non-JSON: ${text2.slice(0, 120)}`);
|
|
26069
|
+
}
|
|
26070
|
+
}
|
|
26071
|
+
/** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
|
|
26072
|
+
post(host, path, body, headers) {
|
|
26073
|
+
return this.send("POST", host, path, headers, body);
|
|
26074
|
+
}
|
|
26075
|
+
/** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
|
|
26076
|
+
async estimateHost() {
|
|
26077
|
+
try {
|
|
26078
|
+
const env = await this.post(SOLIX_ESTIMATE_HOST, SOLIX_ENDPOINTS.estimateDomain, JSON.stringify({ ab: this.country, mode: 1 }), this.baseHeaders());
|
|
26079
|
+
const domain = env.data?.domain;
|
|
26080
|
+
if (domain)
|
|
26081
|
+
this.apiHost = domain;
|
|
26082
|
+
} catch {
|
|
26083
|
+
}
|
|
26084
|
+
}
|
|
26085
|
+
/** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
|
|
26086
|
+
async keyExchange() {
|
|
26087
|
+
const prep = prepareKeyExchange(SOLIX_LOCAL_KEY_HEX);
|
|
26088
|
+
const env = await this.post(this.apiHost, SOLIX_ENDPOINTS.keyExchange, JSON.stringify({ client_public_key: prep.encryptedClientPublicKey }), this.authHeaders(prep.headers));
|
|
26089
|
+
const spk = env.data?.server_public_key;
|
|
26090
|
+
if (env.code !== 0 || !spk)
|
|
26091
|
+
throw new Error(`Solix key/exchange failed (${env.code}): ${env.msg}`);
|
|
26092
|
+
return finishKeyExchange(prep, spk);
|
|
26093
|
+
}
|
|
26094
|
+
/** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
|
|
26095
|
+
async postLogin(kx, verifyCode, limitedToken) {
|
|
26096
|
+
const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
|
|
26097
|
+
const bodyObj = {
|
|
26098
|
+
email: this.email,
|
|
26099
|
+
password: encryptedPassword,
|
|
26100
|
+
ab: this.country,
|
|
26101
|
+
client_secret_info: { public_key: clientPublicKeyHex },
|
|
26102
|
+
answer: "",
|
|
26103
|
+
captcha_id: "",
|
|
26104
|
+
verify_code: verifyCode ?? "",
|
|
26105
|
+
login_id: ""
|
|
26106
|
+
};
|
|
26107
|
+
const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
|
|
26108
|
+
const ts = nowSec();
|
|
26109
|
+
const once = genId();
|
|
26110
|
+
return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
|
|
26111
|
+
"x-encryption-info": "algo_ecdh",
|
|
26112
|
+
"x-key-ident": kx.keyIdent,
|
|
26113
|
+
"x-request-ts": ts,
|
|
26114
|
+
"x-request-once": once,
|
|
26115
|
+
"x-signature": signRequest(kx.shareKey, ts, once, encBody),
|
|
26116
|
+
...limitedToken ? { "x-auth-token": limitedToken } : {}
|
|
26117
|
+
}));
|
|
26118
|
+
}
|
|
26119
|
+
/**
|
|
26120
|
+
* Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
|
|
26121
|
+
* `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
|
|
26122
|
+
* has been satisfied.
|
|
26123
|
+
*/
|
|
26124
|
+
classifyLogin(data, isVerify) {
|
|
26125
|
+
const userId = data.ap_cloud_user_id ?? data.user_id;
|
|
26126
|
+
const authToken = data.auth_token;
|
|
26127
|
+
if (!userId || !authToken)
|
|
26128
|
+
throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
|
|
26129
|
+
const faInfo = data.fa_info ?? {};
|
|
26130
|
+
if (!isVerify && faInfo.info) {
|
|
26131
|
+
this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
|
|
26132
|
+
return { status: "2fa", method: "code sent by the passport" };
|
|
26133
|
+
}
|
|
26134
|
+
this.pending2fa = void 0;
|
|
26135
|
+
this.session_ = {
|
|
26136
|
+
authToken,
|
|
26137
|
+
userId,
|
|
26138
|
+
gtoken: gtoken(userId),
|
|
26139
|
+
apiHost: this.apiHost,
|
|
26140
|
+
tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
|
|
26141
|
+
};
|
|
26142
|
+
this.persist();
|
|
26143
|
+
return { status: "ok", session: this.session_ };
|
|
26144
|
+
}
|
|
26145
|
+
/** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
|
|
26146
|
+
decryptLogin(env, kx) {
|
|
26147
|
+
if (typeof env.data !== "string")
|
|
26148
|
+
throw new Error(`Solix login (${env.code}): ${env.msg}`);
|
|
26149
|
+
return JSON.parse(decryptBody(env.data, kx.shareKey).toString("utf-8"));
|
|
26150
|
+
}
|
|
26151
|
+
/**
|
|
26152
|
+
* Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
|
|
26153
|
+
* when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
|
|
26154
|
+
* (adopted from a store) is answered without a handshake.
|
|
26155
|
+
*/
|
|
26156
|
+
async login() {
|
|
26157
|
+
if (solixSessionFresh(this.session_)) {
|
|
26158
|
+
return { status: "ok", session: this.session_ };
|
|
26159
|
+
}
|
|
26160
|
+
await this.estimateHost();
|
|
26161
|
+
const kx = await this.keyExchange();
|
|
26162
|
+
const env = await this.postLogin(kx);
|
|
26163
|
+
return this.classifyLogin(this.decryptLogin(env, kx), false);
|
|
26164
|
+
}
|
|
26165
|
+
/** Complete a `2fa` login with the code the passport sent. */
|
|
26166
|
+
async submitVerifyCode(code) {
|
|
26167
|
+
if (!this.pending2fa)
|
|
26168
|
+
throw new Error("no 2FA login is pending");
|
|
26169
|
+
const kx = await this.keyExchange();
|
|
26170
|
+
const env = await this.postLogin(kx, code, this.pending2fa.limitedToken);
|
|
26171
|
+
return this.classifyLogin(this.decryptLogin(env, kx), true);
|
|
26172
|
+
}
|
|
26173
|
+
/**
|
|
26174
|
+
* One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
|
|
26175
|
+
* the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
|
|
26176
|
+
*/
|
|
26177
|
+
async authed(method2, path, body) {
|
|
26178
|
+
if (!this.session_)
|
|
26179
|
+
throw new Error("not authenticated \u2014 call login() first");
|
|
26180
|
+
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);
|
|
26181
|
+
if (env.code !== 0)
|
|
26182
|
+
throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
|
|
26183
|
+
return env.data ?? null;
|
|
26184
|
+
}
|
|
26185
|
+
/**
|
|
26186
|
+
* The account's bound Solix devices (flat list; may be empty when devices live under sites). The
|
|
26187
|
+
* gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
|
|
26188
|
+
* beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
|
|
26189
|
+
*/
|
|
26190
|
+
async getDevices() {
|
|
26191
|
+
const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
|
|
26192
|
+
return Array.isArray(data) ? data : data?.data ?? [];
|
|
26193
|
+
}
|
|
26194
|
+
/** The account's sites (systems); devices are typically grouped under a site. */
|
|
26195
|
+
async getSites() {
|
|
26196
|
+
const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
|
|
26197
|
+
return data?.site_list ?? [];
|
|
26198
|
+
}
|
|
26199
|
+
/** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
|
|
26200
|
+
async getUserMqttInfo() {
|
|
26201
|
+
return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
|
|
26202
|
+
}
|
|
26203
|
+
/**
|
|
26204
|
+
* The pairable-product catalog (categories → products). This is Anker's product registry, not the
|
|
26205
|
+
* account's devices — fetch it to label a discovered device's model code with a marketing name and
|
|
26206
|
+
* category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
|
|
26207
|
+
* baked-in table.
|
|
26208
|
+
*/
|
|
26209
|
+
async getProductCatalog() {
|
|
26210
|
+
return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
|
|
26211
|
+
}
|
|
26212
|
+
};
|
|
26213
|
+
|
|
26214
|
+
// dist/transport/mqtt/solix-mqtt.js
|
|
26215
|
+
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
26216
|
+
var SOLIX_METER_FIELD_NAMES = {
|
|
26217
|
+
172: "meterVoltageL1"
|
|
26218
|
+
};
|
|
26219
|
+
function readSolixChannel(value) {
|
|
26220
|
+
if (!value || value.length < 1)
|
|
26221
|
+
return void 0;
|
|
26222
|
+
const raw = value.subarray(1);
|
|
26223
|
+
const ch = { type: value[0], raw };
|
|
26224
|
+
if (raw.length === 4) {
|
|
26225
|
+
ch.float = raw.readFloatLE(0);
|
|
26226
|
+
ch.uint = raw.readUInt32LE(0);
|
|
26227
|
+
}
|
|
26228
|
+
return ch;
|
|
26229
|
+
}
|
|
26230
|
+
function decodeSolixParamFrame(buf) {
|
|
26231
|
+
if (buf.length < 10 || buf[0] !== 255 || buf[1] !== 9)
|
|
26232
|
+
return null;
|
|
26233
|
+
const declaredLen = buf.readUInt16LE(2);
|
|
26234
|
+
if (declaredLen < 5 || declaredLen > buf.length)
|
|
26235
|
+
return null;
|
|
26236
|
+
let xor = 0;
|
|
26237
|
+
for (let i = 0; i < declaredLen; i++)
|
|
26238
|
+
xor ^= buf[i];
|
|
26239
|
+
if (xor !== 0)
|
|
26240
|
+
return null;
|
|
26241
|
+
const end = declaredLen - 1;
|
|
26242
|
+
const start = buf.indexOf(161, 4);
|
|
26243
|
+
if (start < 0 || start >= end)
|
|
26244
|
+
return { fields: /* @__PURE__ */ new Map() };
|
|
26245
|
+
const fields = walkFf09Tlv(buf, start, end);
|
|
26246
|
+
let deviceSn;
|
|
26247
|
+
const a2 = fields.get(162);
|
|
26248
|
+
if (a2 && a2.length > 1)
|
|
26249
|
+
deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
|
|
26250
|
+
return { deviceSn, fields };
|
|
26251
|
+
}
|
|
26252
|
+
function solixReadings(frame) {
|
|
26253
|
+
const out = {};
|
|
26254
|
+
for (const [tag2, value] of frame.fields) {
|
|
26255
|
+
if (tag2 < 166)
|
|
26256
|
+
continue;
|
|
26257
|
+
const ch = readSolixChannel(value);
|
|
26258
|
+
if (ch?.type !== 5 || ch.float === void 0)
|
|
26259
|
+
continue;
|
|
26260
|
+
out[`channel_${tag2.toString(16)}`] = ch.float;
|
|
26261
|
+
const name = SOLIX_METER_FIELD_NAMES[tag2];
|
|
26262
|
+
if (name)
|
|
26263
|
+
out[name] = ch.float;
|
|
26264
|
+
}
|
|
26265
|
+
return out;
|
|
26266
|
+
}
|
|
26267
|
+
var SolixMqtt = class extends EventEmitter10 {
|
|
26268
|
+
transport;
|
|
26269
|
+
appName;
|
|
26270
|
+
userId;
|
|
26271
|
+
appClientId;
|
|
26272
|
+
armIntervalMs;
|
|
26273
|
+
logger;
|
|
26274
|
+
siteId;
|
|
26275
|
+
watched = /* @__PURE__ */ new Map();
|
|
26276
|
+
seq = 0;
|
|
26277
|
+
armTimer;
|
|
26278
|
+
/**
|
|
26279
|
+
* Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
|
|
26280
|
+
* (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
|
|
26281
|
+
* restart presents itself to the broker as a new client, so it defaults deterministically from the user
|
|
26282
|
+
* id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
|
|
26283
|
+
*/
|
|
26284
|
+
constructor(opts) {
|
|
26285
|
+
super();
|
|
26286
|
+
this.appName = opts.mqttInfo.app_name ?? "anker_power";
|
|
26287
|
+
this.userId = opts.userId ?? opts.mqttInfo.user_id;
|
|
26288
|
+
this.armIntervalMs = opts.armIntervalMs ?? 25e3;
|
|
26289
|
+
this.siteId = opts.siteId;
|
|
26290
|
+
this.logger = opts.logger;
|
|
26291
|
+
const uid = this.userId ?? "anonymous";
|
|
26292
|
+
this.appClientId = opts.appClientId ?? buildAppShapedClientId({
|
|
26293
|
+
appName: this.appName,
|
|
26294
|
+
uid,
|
|
26295
|
+
mqttUuid: opts.mqttUuid ?? mqttUuidFrom(`anker-solix-mqtt:${uid}`)
|
|
26296
|
+
});
|
|
26297
|
+
this.transport = new SecureMqtt({
|
|
26298
|
+
credentials: opts.mqttInfo,
|
|
26299
|
+
clientId: opts.clientId ?? opts.mqttInfo.thing_name,
|
|
26300
|
+
reconnectPeriod: 5e3,
|
|
26301
|
+
logger: opts.logger
|
|
26302
|
+
});
|
|
26303
|
+
this.transport.on("error", (e) => this.emit("error", e));
|
|
26304
|
+
this.transport.on("message", (msg) => this.onMessage(msg));
|
|
26305
|
+
}
|
|
26306
|
+
/**
|
|
26307
|
+
* Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
|
|
26308
|
+
* start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
|
|
26309
|
+
*
|
|
26310
|
+
* Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
|
|
26311
|
+
* channels — never the `…/req` channels, which are the app→device request side this arms on, and would
|
|
26312
|
+
* echo its own publishes back.
|
|
26313
|
+
*
|
|
26314
|
+
* Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
|
|
26315
|
+
* than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
|
|
26316
|
+
* success: the call would resolve and arm on every interval while no reading ever arrives.
|
|
26317
|
+
*
|
|
26318
|
+
* The re-arm timer is unreffed, so a caller that watches and returns can still exit.
|
|
26319
|
+
*/
|
|
26320
|
+
async watch(device) {
|
|
26321
|
+
await this.transport.connect();
|
|
26322
|
+
const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
|
|
26323
|
+
const granted = await this.transport.subscribe([
|
|
26324
|
+
topics.paramInfo,
|
|
26325
|
+
topics.cmdRes,
|
|
26326
|
+
...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
|
|
26327
|
+
]);
|
|
26328
|
+
if (!granted.includes(topics.paramInfo)) {
|
|
26329
|
+
const scope = this.appName;
|
|
26330
|
+
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`);
|
|
26331
|
+
}
|
|
26332
|
+
this.watched.set(device.device_sn, device);
|
|
26333
|
+
if (this.armIntervalMs > 0) {
|
|
26334
|
+
await this.armAll();
|
|
26335
|
+
if (!this.armTimer) {
|
|
26336
|
+
this.armTimer = setInterval(() => void this.armAll(), this.armIntervalMs);
|
|
26337
|
+
this.armTimer.unref?.();
|
|
26338
|
+
}
|
|
26339
|
+
}
|
|
26340
|
+
}
|
|
26341
|
+
/** Tear down the connection and stop the re-arm timer. */
|
|
26342
|
+
async close() {
|
|
26343
|
+
if (this.armTimer) {
|
|
26344
|
+
clearInterval(this.armTimer);
|
|
26345
|
+
this.armTimer = void 0;
|
|
26346
|
+
}
|
|
26347
|
+
this.watched.clear();
|
|
26348
|
+
await this.transport.disconnect();
|
|
26349
|
+
}
|
|
26350
|
+
/**
|
|
26351
|
+
* Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
|
|
26352
|
+
* a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
|
|
26353
|
+
* heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
|
|
26354
|
+
* (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
|
|
26355
|
+
* not thrown, so one bad device doesn't stop the rest or kill the timer.
|
|
26356
|
+
*/
|
|
26357
|
+
async armAll() {
|
|
26358
|
+
for (const device of this.watched.values()) {
|
|
26359
|
+
try {
|
|
26360
|
+
await this.arm(device);
|
|
26361
|
+
} catch (e) {
|
|
26362
|
+
this.emit("error", e);
|
|
26363
|
+
}
|
|
26364
|
+
}
|
|
26365
|
+
if (this.userId) {
|
|
26366
|
+
try {
|
|
26367
|
+
await this.transport.publish(solixUserTopics(this.appName, this.userId).powerSite, this.heartbeatEnvelope(), {
|
|
26368
|
+
qos: 1
|
|
26369
|
+
});
|
|
26370
|
+
} catch (e) {
|
|
26371
|
+
this.emit("error", e);
|
|
26372
|
+
}
|
|
26373
|
+
}
|
|
26374
|
+
}
|
|
26375
|
+
/** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
|
|
26376
|
+
async arm(device) {
|
|
26377
|
+
const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
|
|
26378
|
+
for (const variant of ["info", "realtime"]) {
|
|
26379
|
+
const body = this.commandEnvelope(device, buildFf09Request(variant), variant === "info" ? { encoding_type: 2 } : {});
|
|
26380
|
+
await this.transport.publish(topic, body, { qos: 1 });
|
|
26381
|
+
}
|
|
26382
|
+
this.logger?.debug?.(`[solix] armed ${device.device_sn} (param_info reporting requested)`);
|
|
26383
|
+
}
|
|
26384
|
+
/** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
|
|
26385
|
+
makeHead(cmd, extra) {
|
|
26386
|
+
return {
|
|
26387
|
+
version: "1.0.0.1",
|
|
26388
|
+
client_id: this.appClientId,
|
|
26389
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
26390
|
+
cmd_status: 2,
|
|
26391
|
+
sign_code: 1,
|
|
26392
|
+
cmd,
|
|
26393
|
+
...extra
|
|
26394
|
+
};
|
|
26395
|
+
}
|
|
26396
|
+
/**
|
|
26397
|
+
* Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
|
|
26398
|
+
* `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
|
|
26399
|
+
* from a real value, so sending `""` would claim an account this client does not have.
|
|
26400
|
+
*/
|
|
26401
|
+
commandEnvelope(device, frame, extra) {
|
|
26402
|
+
this.seq += 1;
|
|
26403
|
+
return JSON.stringify({
|
|
26404
|
+
head: this.makeHead(17, {
|
|
26405
|
+
sess_id: genId(),
|
|
26406
|
+
msg_seq: this.seq,
|
|
26407
|
+
seed: genId(),
|
|
26408
|
+
device_pn: device.product_code,
|
|
26409
|
+
device_sn: device.device_sn
|
|
26410
|
+
}),
|
|
26411
|
+
payload: JSON.stringify({
|
|
26412
|
+
device_sn: device.device_sn,
|
|
26413
|
+
...this.userId ? { account_id: this.userId } : {},
|
|
26414
|
+
data: frame.toString("base64"),
|
|
26415
|
+
...extra
|
|
26416
|
+
})
|
|
26417
|
+
});
|
|
26418
|
+
}
|
|
26419
|
+
/**
|
|
26420
|
+
* The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
|
|
26421
|
+
* `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
|
|
26422
|
+
*/
|
|
26423
|
+
heartbeatEnvelope() {
|
|
26424
|
+
return JSON.stringify({
|
|
26425
|
+
head: this.makeHead(10, { sess_id: "1", msg_seq: 1, seed: "1" }),
|
|
26426
|
+
payload: JSON.stringify({ user_id: this.userId ?? "", ...this.siteId ? { site_id: this.siteId } : {} })
|
|
26427
|
+
});
|
|
26428
|
+
}
|
|
26429
|
+
/**
|
|
26430
|
+
* Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
|
|
26431
|
+
* product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
|
|
26432
|
+
* own `a2` field wins for the serial when it carries one.
|
|
26433
|
+
*/
|
|
26434
|
+
onMessage(msg) {
|
|
26435
|
+
const topic = msg.topic ?? "";
|
|
26436
|
+
const buf = extractFf09Payload(msg.raw);
|
|
26437
|
+
if (!buf)
|
|
26438
|
+
return;
|
|
26439
|
+
const frame = decodeSolixParamFrame(buf);
|
|
26440
|
+
if (!frame)
|
|
26441
|
+
return;
|
|
26442
|
+
const parts = topic.split("/");
|
|
26443
|
+
const reading = {
|
|
26444
|
+
deviceSn: frame.deviceSn ?? parts[3] ?? "",
|
|
26445
|
+
productCode: parts[2] ?? "",
|
|
26446
|
+
topic,
|
|
26447
|
+
frame,
|
|
26448
|
+
values: solixReadings(frame)
|
|
26449
|
+
};
|
|
26450
|
+
this.emit("reading", reading);
|
|
26451
|
+
}
|
|
26452
|
+
};
|
|
26453
|
+
function extractFf09Payload(raw) {
|
|
26454
|
+
if (Buffer.isBuffer(raw))
|
|
26455
|
+
return raw;
|
|
26456
|
+
if (!raw || typeof raw !== "object")
|
|
26457
|
+
return null;
|
|
26458
|
+
const env = raw;
|
|
26459
|
+
let payload = env.payload;
|
|
26460
|
+
if (typeof payload === "string") {
|
|
26461
|
+
try {
|
|
26462
|
+
payload = JSON.parse(payload);
|
|
26463
|
+
} catch {
|
|
26464
|
+
return null;
|
|
26465
|
+
}
|
|
26466
|
+
}
|
|
26467
|
+
const p = payload;
|
|
26468
|
+
const data = p?.data ?? p?.trans ?? env.data;
|
|
26469
|
+
if (typeof data !== "string")
|
|
26470
|
+
return null;
|
|
26471
|
+
const buf = Buffer.from(data, "base64");
|
|
26472
|
+
return buf.length ? buf : null;
|
|
26473
|
+
}
|
|
26474
|
+
function buildFf09Request(variant, atUnixSec) {
|
|
26475
|
+
const ts = Buffer.alloc(4);
|
|
26476
|
+
ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
|
|
26477
|
+
const body = variant === "info" ? Buffer.concat([Buffer.from([3, 0, 15, 0, 64, 161, 1, 34, 254, 4]), ts]) : Buffer.concat([
|
|
26478
|
+
Buffer.from([
|
|
26479
|
+
3,
|
|
26480
|
+
0,
|
|
26481
|
+
15,
|
|
26482
|
+
0,
|
|
26483
|
+
87,
|
|
26484
|
+
161,
|
|
26485
|
+
1,
|
|
26486
|
+
34,
|
|
26487
|
+
162,
|
|
26488
|
+
2,
|
|
26489
|
+
1,
|
|
26490
|
+
1,
|
|
26491
|
+
163,
|
|
26492
|
+
3,
|
|
26493
|
+
2,
|
|
26494
|
+
44,
|
|
26495
|
+
1,
|
|
26496
|
+
254,
|
|
26497
|
+
5,
|
|
26498
|
+
3
|
|
26499
|
+
]),
|
|
26500
|
+
ts
|
|
26501
|
+
]);
|
|
26502
|
+
const frame = Buffer.alloc(body.length + 5);
|
|
26503
|
+
frame[0] = 255;
|
|
26504
|
+
frame[1] = 9;
|
|
26505
|
+
frame.writeUInt16LE(frame.length, 2);
|
|
26506
|
+
body.copy(frame, 4);
|
|
26507
|
+
let xor = 0;
|
|
26508
|
+
for (let i = 0; i < frame.length - 1; i++)
|
|
26509
|
+
xor ^= frame[i];
|
|
26510
|
+
frame[frame.length - 1] = xor;
|
|
26511
|
+
return frame;
|
|
26512
|
+
}
|
|
26513
|
+
|
|
25275
26514
|
// dist/transport/tuya/index.js
|
|
25276
26515
|
var tuya_exports = {};
|
|
25277
26516
|
__export(tuya_exports, {
|
|
@@ -25312,6 +26551,7 @@ export {
|
|
|
25312
26551
|
AUDIO_MEMBERS,
|
|
25313
26552
|
AccessUnitAssembler,
|
|
25314
26553
|
AiDetectType,
|
|
26554
|
+
AlarmDelayMode,
|
|
25315
26555
|
ArmingMode,
|
|
25316
26556
|
BATTERY_MEMBERS,
|
|
25317
26557
|
BIZ_CHANNEL,
|
|
@@ -25332,6 +26572,7 @@ export {
|
|
|
25332
26572
|
CusPushEvent,
|
|
25333
26573
|
CusPushMode,
|
|
25334
26574
|
DEFAULT_KEEPALIVE_MS,
|
|
26575
|
+
DISPLAY_MEMBERS,
|
|
25335
26576
|
DOCK_ACTIVITIES,
|
|
25336
26577
|
DOCK_KINDS,
|
|
25337
26578
|
DOORBELL_MEMBERS,
|
|
@@ -25385,6 +26626,7 @@ export {
|
|
|
25385
26626
|
P256,
|
|
25386
26627
|
P2PSession,
|
|
25387
26628
|
P2P_ENVELOPE,
|
|
26629
|
+
P2P_STATION_WAITS,
|
|
25388
26630
|
PRINTER_CATEGORY_RE,
|
|
25389
26631
|
PTZ_MEMBERS,
|
|
25390
26632
|
PowerSource,
|
|
@@ -25404,6 +26646,8 @@ export {
|
|
|
25404
26646
|
SIREN_MEMBERS,
|
|
25405
26647
|
SMART_LIGHT_MEMBERS,
|
|
25406
26648
|
SMOKE_MEMBERS,
|
|
26649
|
+
SOLIX_ENERGY_METER_MEMBERS,
|
|
26650
|
+
SOLIX_LOCAL_KEY_HEX,
|
|
25407
26651
|
STATE_EVENT_FIELDS,
|
|
25408
26652
|
STATION_CHANNEL3 as STATION_CHANNEL,
|
|
25409
26653
|
STATION_CHUNK_BYTES,
|
|
@@ -25416,8 +26660,12 @@ export {
|
|
|
25416
26660
|
SirenAlarmDuration,
|
|
25417
26661
|
SirenVolume,
|
|
25418
26662
|
SmartDropPushEvent,
|
|
26663
|
+
SolixClient,
|
|
26664
|
+
SolixDevice,
|
|
26665
|
+
SolixMqtt,
|
|
25419
26666
|
StateConvergenceError,
|
|
25420
|
-
|
|
26667
|
+
StationKeyUnavailableError,
|
|
26668
|
+
StationUnreachableError,
|
|
25421
26669
|
StoredSnapshotUnavailableError,
|
|
25422
26670
|
StreamingQuality,
|
|
25423
26671
|
SuctionLevel,
|
|
@@ -25448,6 +26696,7 @@ export {
|
|
|
25448
26696
|
buildDeviceNameBody,
|
|
25449
26697
|
buildDirectBinaryBody,
|
|
25450
26698
|
buildEventIndex,
|
|
26699
|
+
buildModelIndex,
|
|
25451
26700
|
buildRealtimeInit,
|
|
25452
26701
|
captureSnapshotFromShared,
|
|
25453
26702
|
cellAtPoint,
|
|
@@ -25485,6 +26734,7 @@ export {
|
|
|
25485
26734
|
detectCapabilities,
|
|
25486
26735
|
detectionName,
|
|
25487
26736
|
discoverReachableInstance,
|
|
26737
|
+
discoverSolixDevices,
|
|
25488
26738
|
encodeAiDetectType,
|
|
25489
26739
|
encodeVarint,
|
|
25490
26740
|
encryptBody,
|
|
@@ -25522,6 +26772,7 @@ export {
|
|
|
25522
26772
|
lz4BlockDecompress,
|
|
25523
26773
|
mapCellValue,
|
|
25524
26774
|
mapCellValueAt,
|
|
26775
|
+
md5Hex,
|
|
25525
26776
|
mergeCandidateIps,
|
|
25526
26777
|
mergeProperties,
|
|
25527
26778
|
mqttAppName,
|
|
@@ -25566,9 +26817,12 @@ export {
|
|
|
25566
26817
|
secureTopic,
|
|
25567
26818
|
signKey,
|
|
25568
26819
|
signRequest,
|
|
26820
|
+
solixDeviceTopics,
|
|
26821
|
+
solixUserTopics,
|
|
25569
26822
|
structuralEqual,
|
|
25570
26823
|
subscribeTopics,
|
|
25571
26824
|
suctionLevelName,
|
|
26825
|
+
tokenNotExpired,
|
|
25572
26826
|
tuya_exports as tuya,
|
|
25573
26827
|
u16be,
|
|
25574
26828
|
u16le,
|