@mega-yfue/eufy-sdk 0.2.0-beta.27 → 0.2.0-beta.29

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.
@@ -1,13 +1,3 @@
1
- /**
2
- * The station a device's traffic belongs to, from its cloud record and its own serial.
3
- *
4
- * `parent_sn` carries the parent on a HomeBase-attached device. `station_sn` is frequently absent there —
5
- * empty on every attached sensor of a T8010 — and serves only as a fallback. An empty string states no
6
- * station.
7
- *
8
- * A device naming no parent answers its own serial, so every device has a station.
9
- */
10
- export declare function resolvedStationSn(raw: Record<string, unknown>, sn: string): string;
11
1
  /**
12
2
  * DeviceRegistry — the device list/record/capability-resolution collaborator behind {@link EufyMega}.
13
3
  *
@@ -85,6 +75,9 @@ export declare class DeviceRegistry {
85
75
  private devices;
86
76
  /** Per-(station, channel) capability cache for {@link capabilitiesForFrame}; `null` = negative hit. */
87
77
  private readonly frameCapsCache;
78
+ /** {@link stationChannels} of {@link channelMapFor}, the roster it was computed over. */
79
+ private channelMap;
80
+ private channelMapFor?;
88
81
  /**
89
82
  * Serials whose per-device param overlay has been refused. The call is owner-gated, so on a shared or
90
83
  * member account it fails for the whole life of the client — retrying it every refresh spends a request
@@ -291,33 +284,26 @@ export declare class DeviceRegistry {
291
284
  * hub would emit indistinguishable events. The facade enriches the payload with this.
292
285
  */
293
286
  serialForFrame(stationSn: string, channel: number): string | undefined;
294
- /**
295
- * The parent station a device's frames arrive under.
296
- *
297
- * `parent_sn` on the cloud record is the field that is actually populated for a HomeBase-attached
298
- * device — `stationSn` is frequently absent (observed empty on every attached sensor of a T8010),
299
- * so keying on it alone silently resolves an attached device to ITSELF and no frame ever matches.
300
- * Mirrors the router's own session-keying precedence, which is the source of truth for which
301
- * station a device's traffic belongs to. Answering the device's OWN serial is what "stands alone"
302
- * means, so this is also the topology signal `record()`/`capsOf` hand the resolver.
303
- */
304
- private stationOf;
305
287
  /**
306
288
  * The device a `(station, channel)` pair refers to — a station fans out to attached devices by
307
289
  * `device_channel`, while a standalone device is its own station at channel 0.
308
290
  *
309
- * A device claims a channel only when its record actually STATES one. Treating a missing
310
- * `device_channel` as 0 turns every such device into a rival claimant for channel 0, where a
311
- * station legitimately has an attached device already, and the winner is then decided by cloud list
312
- * order — so the same frame resolves to different devices across refreshes. The resolved serial now
313
- * decides where realtime state is written, not just which decoders may run, so an ambiguous answer
291
+ * A device claims a channel only when its record actually STATES one that no other device attached to
292
+ * the same station also states ({@link stationChannels}). Treating a missing `device_channel` as 0, or
293
+ * letting two claimants of one channel both hold it, leaves the winner to cloud list order — which a
294
+ * partial refresh reorders — so the same frame resolves to different devices across refreshes. The resolved
295
+ * serial decides where realtime state is written, not just which decoders may run, so an ambiguous answer
314
296
  * writes one device's params onto another.
315
297
  *
316
298
  * An attached device that names the channel wins over the station itself, which is what a station
317
- * fanning traffic out by channel means; the station answers for channel 0 only when nothing is
318
- * attached there, which is also the standalone case (a device is its own station).
299
+ * fanning traffic out by channel means. A channel two attached devices both state belongs to one of them,
300
+ * which cannot be told apart, so it answers nothing rather than the station. The station answers for
301
+ * channel 0 only when nothing attached states it, which is also the standalone case (a device is its own
302
+ * station).
319
303
  */
320
304
  private deviceForFrame;
305
+ /** {@link stationChannels} over the current roster, recomputed only when the roster itself is replaced. */
306
+ private stationChannelMap;
321
307
  /**
322
308
  * Resolve the capability set of the device a P2P frame belongs to — the `(station, channel)` pair
323
309
  * (a station fans out to attached devices by `device_channel`; a standalone device is its own
@@ -129,6 +129,27 @@ export declare class StationUnreachableError extends Error {
129
129
  cause?: unknown;
130
130
  });
131
131
  }
132
+ /**
133
+ * Work on a device was refused: its channel within its station cannot be established from the device records.
134
+ *
135
+ * A device attached to a HomeBase is addressed by a channel within that station: a media start and every
136
+ * per-channel command name it. When its record states no channel, or another device attached to the same station
137
+ * states the same one, any channel chosen would address whichever device actually holds it (streaming another
138
+ * camera's video under this serial), so nothing is sent. The `station-channel-unresolved` trace says which.
139
+ */
140
+ export declare class DeviceChannelUnresolvedError extends Error {
141
+ /** The device that could not be addressed. */
142
+ readonly sn: string;
143
+ /** The station it is attached to. */
144
+ readonly stationSn: string;
145
+ constructor(
146
+ /** The device that could not be addressed. */
147
+ sn: string,
148
+ /** The station it is attached to. */
149
+ stationSn: string, options?: {
150
+ cause?: unknown;
151
+ });
152
+ }
132
153
  /**
133
154
  * How a live stream ended before its first video keyframe: the warm-up deadline elapsed, the source
134
155
  * reported an error, or the source ended on its own.
@@ -98,6 +98,12 @@ export interface SolixSceneSolarbank {
98
98
  bat_temperature?: string | number;
99
99
  /** State of charge, % (string on the wire). Cross-checks the `ff09` `0xa3` SOC. */
100
100
  bat_soc?: string | number;
101
+ /**
102
+ * Number of ATTACHED expansion battery packs — the built-in battery is the host, not a pack. Read as
103
+ * `expansionPacks`. Observed live as `0` on a standalone AE103 main unit; a populated value (and the
104
+ * matching per-pack `bms_list` detail) has not yet been captured with a pack attached.
105
+ */
106
+ sub_package_num?: string | number;
101
107
  [k: string]: unknown;
102
108
  }
103
109
  /**
package/dist/index.js CHANGED
@@ -2898,6 +2898,16 @@ var StationUnreachableError = class extends Error {
2898
2898
  this.name = "StationUnreachableError";
2899
2899
  }
2900
2900
  };
2901
+ var DeviceChannelUnresolvedError = class extends Error {
2902
+ sn;
2903
+ stationSn;
2904
+ constructor(sn, stationSn, options) {
2905
+ super(`${sn} has no usable channel on station ${stationSn}, so nothing was sent to it`, options);
2906
+ this.sn = sn;
2907
+ this.stationSn = stationSn;
2908
+ this.name = "DeviceChannelUnresolvedError";
2909
+ }
2910
+ };
2901
2911
  var LiveStreamStartError = class extends Error {
2902
2912
  reason;
2903
2913
  stage;
@@ -14138,6 +14148,9 @@ function solarbankSceneReadings(scene) {
14138
14148
  const soc = sceneNum(sb.bat_soc);
14139
14149
  if (soc !== void 0)
14140
14150
  values.batterySoc = soc;
14151
+ const packs = sceneNum(sb.sub_package_num);
14152
+ if (packs !== void 0)
14153
+ values.expansionPacks = packs;
14141
14154
  if (Object.keys(values).length > 0)
14142
14155
  out.push({ deviceSn, values });
14143
14156
  }
@@ -20016,6 +20029,50 @@ var FragmentRecording = class extends EventEmitter6 {
20016
20029
  }
20017
20030
  };
20018
20031
 
20032
+ // dist/transport/p2p/station-channels.js
20033
+ function resolvedStationSn(raw, sn) {
20034
+ const parent = typeof raw.parent_sn === "string" && raw.parent_sn ? raw.parent_sn : void 0;
20035
+ if (parent && parent !== sn)
20036
+ return parent;
20037
+ const station = typeof raw.station_sn === "string" && raw.station_sn ? raw.station_sn : void 0;
20038
+ return station ?? sn;
20039
+ }
20040
+ function stationOf(dev) {
20041
+ const raw = dev.raw ?? {};
20042
+ const parent = typeof raw.parent_sn === "string" && raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : void 0;
20043
+ return parent ?? dev.stationSn ?? resolvedStationSn(raw, dev.sn);
20044
+ }
20045
+ function statedChannel(dev) {
20046
+ const v = dev.raw?.device_channel;
20047
+ return typeof v === "number" ? v : void 0;
20048
+ }
20049
+ function stationChannels(devices) {
20050
+ const out = /* @__PURE__ */ new Map();
20051
+ const claimants = /* @__PURE__ */ new Map();
20052
+ for (const d of devices) {
20053
+ const station = stationOf(d);
20054
+ const stated = statedChannel(d);
20055
+ if (d.sn === station || stated === void 0)
20056
+ continue;
20057
+ const counts = claimants.get(station) ?? /* @__PURE__ */ new Map();
20058
+ counts.set(stated, (counts.get(stated) ?? 0) + 1);
20059
+ claimants.set(station, counts);
20060
+ }
20061
+ for (const d of devices) {
20062
+ const station = stationOf(d);
20063
+ const stated = statedChannel(d);
20064
+ if (d.sn === station)
20065
+ out.set(d.sn, { channel: stated ?? 0 });
20066
+ else if (stated === void 0)
20067
+ out.set(d.sn, { issue: "missing" });
20068
+ else if ((claimants.get(station)?.get(stated) ?? 0) > 1)
20069
+ out.set(d.sn, { issue: "shared", claimed: stated });
20070
+ else
20071
+ out.set(d.sn, { channel: stated });
20072
+ }
20073
+ return out;
20074
+ }
20075
+
20019
20076
  // dist/transport/p2p/command-router.js
20020
20077
  var DIRECT_CMD_SENDS = 5;
20021
20078
  function abortable(work, signal) {
@@ -20169,11 +20226,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
20169
20226
  recordFor(sn) {
20170
20227
  return this.deps.listDevices().find((d) => d.sn === sn);
20171
20228
  }
20172
- /** The parent-station key a device's session lives under (its HomeBase, or itself if standalone). */
20173
- stationKeyFor(dev) {
20174
- const raw = dev.raw ?? {};
20175
- return raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : dev.stationSn ?? dev.sn;
20176
- }
20177
20229
  /**
20178
20230
  * The parent-station serial a device serial's session lives under — the single source of truth for
20179
20231
  * session keying, used by the facade (e.g. to pre-warm the right station for an event). Returns the
@@ -20181,14 +20233,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
20181
20233
  */
20182
20234
  stationKeyOf(sn) {
20183
20235
  const dev = this.recordFor(sn);
20184
- return dev ? this.stationKeyFor(dev) : sn;
20236
+ return dev ? stationOf(dev) : sn;
20185
20237
  }
20186
20238
  /** Reset only a standalone device's session; an attached device must not close its shared HomeBase. */
20187
20239
  async resetStandaloneSession(sn) {
20188
20240
  const device = this.recordFor(sn);
20189
20241
  if (!device)
20190
20242
  return;
20191
- const station = this.stationKeyFor(device);
20243
+ const station = stationOf(device);
20192
20244
  if (station === sn)
20193
20245
  await this.manager.resetWhenUnused(station);
20194
20246
  }
@@ -20400,7 +20452,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
20400
20452
  const dev = this.recordFor(sn);
20401
20453
  if (!dev)
20402
20454
  throw new Error(`device ${sn} not found`);
20403
- await this.openSession(this.stationKeyFor(dev), this.stationKeyFor(dev));
20455
+ await this.openSession(stationOf(dev), stationOf(dev));
20404
20456
  return dev;
20405
20457
  }
20406
20458
  /**
@@ -21188,8 +21240,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
21188
21240
  async resolveSession(sn, opts = {}, rebuilt = false) {
21189
21241
  const dev = await this.deviceFor(sn);
21190
21242
  const raw = dev.raw ?? {};
21191
- const homeBaseAttached = !!raw.parent_sn && raw.parent_sn !== sn;
21192
- const parentSn = homeBaseAttached ? raw.parent_sn : dev.stationSn ?? sn;
21243
+ const parentSn = stationOf(dev);
21244
+ const homeBaseAttached = parentSn !== sn;
21193
21245
  const session = this.manager.get(parentSn) ?? this.manager.get(sn) ?? (dev.stationSn ? this.manager.get(dev.stationSn) : void 0);
21194
21246
  if (!session) {
21195
21247
  const stations = this.manager.keys().filter((k) => !_P2PCommandRouter.isMediaSessionKey(k));
@@ -21200,8 +21252,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
21200
21252
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
21201
21253
  return await this.resolveSession(sn, opts, true);
21202
21254
  }
21255
+ const address = stationChannels(this.deps.listDevices()).get(sn);
21256
+ if (!("channel" in address)) {
21257
+ this.traceOnStation(session, { phase: "station-channel-unresolved", issue: address.issue });
21258
+ throw new DeviceChannelUnresolvedError(sn, parentSn);
21259
+ }
21203
21260
  this.manager.bumpCommand(parentSn, parentSn);
21204
- const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
21261
+ const { channel } = address;
21205
21262
  const stationAdminId = raw.member?.admin_user_id;
21206
21263
  const stationModel = this.recordFor(parentSn)?.model;
21207
21264
  const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
@@ -24149,13 +24206,6 @@ function mediaFailureTag(error) {
24149
24206
  }
24150
24207
 
24151
24208
  // dist/client/device-registry.js
24152
- function resolvedStationSn(raw, sn) {
24153
- const parent = typeof raw.parent_sn === "string" && raw.parent_sn ? raw.parent_sn : void 0;
24154
- if (parent && parent !== sn)
24155
- return parent;
24156
- const station = typeof raw.station_sn === "string" && raw.station_sn ? raw.station_sn : void 0;
24157
- return station ?? sn;
24158
- }
24159
24209
  function mergeParams(raw, params, paramUpdatedAt) {
24160
24210
  for (const p of raw ?? []) {
24161
24211
  if (!p || p.param_type == null || p.param_value == null)
@@ -24197,6 +24247,9 @@ var DeviceRegistry = class {
24197
24247
  devices = [];
24198
24248
  /** Per-(station, channel) capability cache for {@link capabilitiesForFrame}; `null` = negative hit. */
24199
24249
  frameCapsCache = /* @__PURE__ */ new Map();
24250
+ /** {@link stationChannels} of {@link channelMapFor}, the roster it was computed over. */
24251
+ channelMap = /* @__PURE__ */ new Map();
24252
+ channelMapFor;
24200
24253
  /**
24201
24254
  * Serials whose per-device param overlay has been refused. The call is owner-gated, so on a shared or
24202
24255
  * member account it fails for the whole life of the client — retrying it every refresh spends a request
@@ -24464,7 +24517,7 @@ var DeviceRegistry = class {
24464
24517
  }
24465
24518
  const raw = dev.raw;
24466
24519
  const deviceType = typeof raw?.["device_type"] === "number" ? raw["device_type"] : void 0;
24467
- const station = this.stationOf(dev);
24520
+ const station = stationOf(dev);
24468
24521
  return {
24469
24522
  deviceType,
24470
24523
  model: dev.model,
@@ -24612,43 +24665,47 @@ var DeviceRegistry = class {
24612
24665
  serialForFrame(stationSn, channel) {
24613
24666
  return this.deviceForFrame(stationSn, channel)?.sn;
24614
24667
  }
24615
- /**
24616
- * The parent station a device's frames arrive under.
24617
- *
24618
- * `parent_sn` on the cloud record is the field that is actually populated for a HomeBase-attached
24619
- * device — `stationSn` is frequently absent (observed empty on every attached sensor of a T8010),
24620
- * so keying on it alone silently resolves an attached device to ITSELF and no frame ever matches.
24621
- * Mirrors the router's own session-keying precedence, which is the source of truth for which
24622
- * station a device's traffic belongs to. Answering the device's OWN serial is what "stands alone"
24623
- * means, so this is also the topology signal `record()`/`capsOf` hand the resolver.
24624
- */
24625
- stationOf(dev) {
24626
- return dev.stationSn ?? resolvedStationSn(dev.raw ?? {}, dev.sn);
24627
- }
24628
24668
  /**
24629
24669
  * The device a `(station, channel)` pair refers to — a station fans out to attached devices by
24630
24670
  * `device_channel`, while a standalone device is its own station at channel 0.
24631
24671
  *
24632
- * A device claims a channel only when its record actually STATES one. Treating a missing
24633
- * `device_channel` as 0 turns every such device into a rival claimant for channel 0, where a
24634
- * station legitimately has an attached device already, and the winner is then decided by cloud list
24635
- * order — so the same frame resolves to different devices across refreshes. The resolved serial now
24636
- * decides where realtime state is written, not just which decoders may run, so an ambiguous answer
24672
+ * A device claims a channel only when its record actually STATES one that no other device attached to
24673
+ * the same station also states ({@link stationChannels}). Treating a missing `device_channel` as 0, or
24674
+ * letting two claimants of one channel both hold it, leaves the winner to cloud list order — which a
24675
+ * partial refresh reorders — so the same frame resolves to different devices across refreshes. The resolved
24676
+ * serial decides where realtime state is written, not just which decoders may run, so an ambiguous answer
24637
24677
  * writes one device's params onto another.
24638
24678
  *
24639
24679
  * An attached device that names the channel wins over the station itself, which is what a station
24640
- * fanning traffic out by channel means; the station answers for channel 0 only when nothing is
24641
- * attached there, which is also the standalone case (a device is its own station).
24680
+ * fanning traffic out by channel means. A channel two attached devices both state belongs to one of them,
24681
+ * which cannot be told apart, so it answers nothing rather than the station. The station answers for
24682
+ * channel 0 only when nothing attached states it, which is also the standalone case (a device is its own
24683
+ * station).
24642
24684
  */
24643
24685
  deviceForFrame(stationSn, channel) {
24644
- const attached = this.devices.find((d) => {
24645
- const stated = d.raw?.device_channel;
24646
- return typeof stated === "number" && stated === channel && d.sn !== stationSn && this.stationOf(d) === stationSn;
24647
- });
24648
- if (attached)
24649
- return attached;
24686
+ const channels = this.stationChannelMap();
24687
+ let claimed = false;
24688
+ for (const d of this.devices) {
24689
+ if (d.sn === stationSn || stationOf(d) !== stationSn)
24690
+ continue;
24691
+ const c = channels.get(d.sn);
24692
+ if (c && "channel" in c && c.channel === channel)
24693
+ return d;
24694
+ if (c && "claimed" in c && c.claimed === channel)
24695
+ claimed = true;
24696
+ }
24697
+ if (claimed)
24698
+ return void 0;
24650
24699
  return channel === 0 ? this.devices.find((d) => d.sn === stationSn) : void 0;
24651
24700
  }
24701
+ /** {@link stationChannels} over the current roster, recomputed only when the roster itself is replaced. */
24702
+ stationChannelMap() {
24703
+ if (this.channelMapFor !== this.devices) {
24704
+ this.channelMap = stationChannels(this.devices);
24705
+ this.channelMapFor = this.devices;
24706
+ }
24707
+ return this.channelMap;
24708
+ }
24652
24709
  /**
24653
24710
  * Resolve the capability set of the device a P2P frame belongs to — the `(station, channel)` pair
24654
24711
  * (a station fans out to attached devices by `device_channel`; a standalone device is its own
@@ -24697,7 +24754,7 @@ var DeviceRegistry = class {
24697
24754
  /** Resolve a record's capabilities the way `getDevice` does, so gating matches `device.has()`. */
24698
24755
  capsOf(dev) {
24699
24756
  const raw = dev.raw ?? {};
24700
- const station = this.stationOf(dev);
24757
+ const station = stationOf(dev);
24701
24758
  return new Set(resolveDevice({
24702
24759
  deviceType: raw.device_type,
24703
24760
  model: dev.model,
@@ -27144,8 +27201,9 @@ var SolixClient = class _SolixClient {
27144
27201
  * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
27145
27202
  * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
27146
27203
  * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
27147
- * app-synced source (and the only source for `chargeUpperLimit` / `backupReserve`). Verified live
27148
- * against a known AE103 setting (discharge 20 / charge 80).
27204
+ * app-synced source for `chargeUpperLimit`. `backupReserve` (with its enable switch) also has a second
27205
+ * source under the same name, the realtime MQTT `b5` frame; whether the two agree while the switch is
27206
+ * OFF is not yet verified. Verified live against a known AE103 setting (discharge 20 / charge 80).
27149
27207
  */
27150
27208
  async getSafetySocParams(siteId) {
27151
27209
  const p = await this.getSiteDeviceParam(siteId, _SolixClient.SOC_PARAM_TYPE);
@@ -27359,6 +27417,13 @@ function addSolarbankScalars(frame, out) {
27359
27417
  if (b5 && b5[0] === 4 && b5.length === 4) {
27360
27418
  out.dischargeLimit = b5[1];
27361
27419
  out.chargeLimit = b5[3];
27420
+ } else if (b5 && b5[0] === 4 && b5.length === 25) {
27421
+ out.backupReserve = b5[1];
27422
+ }
27423
+ const df = frame.fields.get(223);
27424
+ if (df && df[0] === 4 && df.length >= 7) {
27425
+ out.gridImportLimit = df.readUInt16LE(3);
27426
+ out.gridExportLimit = df.readUInt16LE(5);
27362
27427
  }
27363
27428
  }
27364
27429
  var SolixMqtt = class extends EventEmitter10 {
@@ -27758,6 +27823,7 @@ export {
27758
27823
  DOCK_KINDS,
27759
27824
  DOORBELL_MEMBERS,
27760
27825
  Device,
27826
+ DeviceChannelUnresolvedError,
27761
27827
  DoorbellPushEvent,
27762
27828
  DoorbellRingtone,
27763
27829
  EMPTY_CLEAN_RECORD_PAGE,