@mega-yfue/eufy-sdk 0.6.0-beta.17 → 0.6.0-beta.19

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.
@@ -248,6 +248,12 @@ export declare class DeviceRegistry {
248
248
  * for an expired token is the same lie {@link getDevices} stopped telling, one level down.
249
249
  */
250
250
  refreshedList(sn: string): Promise<EufyDevice | undefined>;
251
+ /**
252
+ * Record that `sn` was heard on a realtime wire at `atMs`, advancing its {@link EufyDevice.lastSeenMs}.
253
+ *
254
+ * Only ever moves the time forward, and survives a {@link getDevices} rebuild.
255
+ */
256
+ markSeen(sn: string, atMs: number): void;
251
257
  /**
252
258
  * Record state a device reported over its realtime wire, merging into whatever it last reported.
253
259
  *
@@ -129,6 +129,12 @@ export declare class EufyMega extends EventEmitter {
129
129
  private readonly pollTimer;
130
130
  /** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
131
131
  private readonly pushRetryTimer;
132
+ /**
133
+ * Per wired station, the pending reopen of its session and the pause before the next one; see
134
+ * {@link reopenWiredStation}. Dropped for a station when its session connects, and wholly by
135
+ * {@link disconnect}.
136
+ */
137
+ private readonly stationReopens;
132
138
  constructor(opts: EufyMegaOptions);
133
139
  /**
134
140
  * Builds the inbound listener that converts ThingClips DP maps to realtime capability state.
@@ -313,7 +319,16 @@ export declare class EufyMega extends EventEmitter {
313
319
  * manager's own log, since by then nothing is waiting to receive it.
314
320
  */
315
321
  private recycleStandaloneSession;
316
- /** Reopen a wired station that {@link recycleStandaloneSession} closed, while the generation that warmed it is current. */
322
+ /**
323
+ * Reopen a wired station's session while the generation that warmed it is current: after
324
+ * {@link recycleStandaloneSession} closed it, after it closed on its own, or after its warm-up failed.
325
+ *
326
+ * A wired station's session is the persistent one realtime start opened, so a station left without one
327
+ * receives nothing until a command dials it again. The first reopen runs at once; one that fails is
328
+ * retried after {@link STATION_REOPEN_MIN_MS}, doubling up to {@link STATION_REOPEN_MAX_MS}, until the
329
+ * session connects or the generation ends. One reopen is pending per station at a time, and each failure
330
+ * is reported once, by {@link P2PCommandRouter.ensureStation}.
331
+ */
317
332
  private reopenWiredStation;
318
333
  /**
319
334
  * Whether the DECODED property now reads what the write asked for.
@@ -339,6 +354,8 @@ export declare class EufyMega extends EventEmitter {
339
354
  * fans same-kind sensors out by channel, so the resolved serial is folded in; a payload's own
340
355
  * fields still win on conflict.
341
356
  *
357
+ * A station's camera-info table lands per channel, each channel's params on the device attached there.
358
+ *
342
359
  * Params the station volunteered land as device state first. They arrive in the cloud record's own
343
360
  * `param_type → value` shape, so no capability has to claim an id it does not own, and the reads
344
361
  * they back stop waiting for the next cloud poll. A capability decode follows for the frames that
@@ -754,7 +771,8 @@ export declare class EufyMega extends EventEmitter {
754
771
  */
755
772
  keepStationWarm(stationSn: string): () => void;
756
773
  /** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
757
- * stations stay closed until an on-demand open. Best-effort per station. */
774
+ * stations stay closed until an on-demand open. Best-effort per station: one that fails is handed to
775
+ * {@link reopenWiredStation}. */
758
776
  private warmWiredP2P;
759
777
  /**
760
778
  * A station's power tier for the P2P lifecycle: a HomeBase/station is `"wired"` (persistent); a
@@ -202,9 +202,11 @@ export interface EufyMegaOptions extends MegaClientConfig {
202
202
  * that choice on every host. The SDK reports when the device last spoke; the caller decides what that
203
203
  * means — the same split as the snapshot cache TTL and the live power budget.
204
204
  *
205
- * P2P session state is **not** a liveness signal and is not carried here: sessions are opened only
206
- * when something needs one and closed when idle, so "no session" is the resting state of a healthy
207
- * device. Transport visibility lives on `getP2pSessions()` and the `p2pConnect`/`p2pClose` events,
205
+ * The absence of a P2P session is **not** a liveness signal and is not carried here: sessions are opened
206
+ * only when something needs one and closed when idle, so "no session" is the resting state of a healthy
207
+ * device. A station answering an open session is the opposite fact, positive evidence, and does advance
208
+ * the station's own {@link DeviceState.lastSeenMs} — never that of a device it covers, whose liveness its answer does
209
+ * not show. Transport visibility lives on `getP2pSessions()` and the `p2pConnect`/`p2pClose` events,
208
210
  * station-scoped like the session itself.
209
211
  *
210
212
  * The cloud record carries no connectivity field either: it has no `status` / `device_online`, and the
@@ -215,8 +217,9 @@ export interface DeviceState {
215
217
  /** The parent station whose P2P session covers this device (itself, when standalone). */
216
218
  stationSn: string;
217
219
  /**
218
- * When the device last reported to the cloud, in ms. Bounded by
219
- * the cloud's own slow refresh — minutes, not seconds — so it answers "is this device alive at all",
220
+ * When the device was last heard from, in ms: its last report to the cloud, or a later one over a
221
+ * realtime wire, a station's heartbeat answer on its open P2P session included. The cloud half is bounded
222
+ * by the cloud's own slow refresh — minutes, not seconds — so it answers "is this device alive at all",
220
223
  * not "what is it doing right now".
221
224
  */
222
225
  lastSeenMs?: number;
@@ -332,7 +335,7 @@ export type EufyMegaEventMap = {
332
335
  map: VacuumMapSnapshot;
333
336
  }];
334
337
  /**
335
- * A device reported to the cloud since the last poll — its {@link DeviceState.lastSeenMs} advanced.
338
+ * A device was heard from since the last poll — its {@link DeviceState.lastSeenMs} advanced.
336
339
  * Carries {@link DeviceState}; the host applies its own staleness threshold.
337
340
  *
338
341
  * Transport/session lifecycle is NOT this event: that's `p2pConnect`/`p2pClose`, station-scoped where
@@ -80,8 +80,10 @@ export interface EufyDevice {
80
80
  */
81
81
  paramUpdatedAt?: Record<number, number>;
82
82
  /**
83
- * The most recent {@link paramUpdatedAt} across all of this device's params, in **milliseconds**
84
- * (comparable to `Date.now()` directly). `undefined` when the record carried no timestamp at all.
83
+ * When the device was last heard from, in **milliseconds** (comparable to `Date.now()` directly): the
84
+ * most recent {@link paramUpdatedAt} across all of its params, or a later report over a realtime wire,
85
+ * including a station answering its own P2P session's heartbeat. Never moves backwards. `undefined` when
86
+ * neither has been seen.
85
87
  *
86
88
  * This is an observation — "the device last reported at T" — deliberately **not** a reachability
87
89
  * verdict. How long a silence means "unreachable" depends on the device (a mains camera heartbeats
package/dist/index.js CHANGED
@@ -2981,16 +2981,13 @@ var BIZ_PROTOCOL = {
2981
2981
  var BIZ_CHANNEL = {
2982
2982
  /** Scene (room-group) data. Hard-coded in the app, not part of `ChanIds`. */
2983
2983
  SCENES: 0,
2984
- MAP_INFO: 1,
2985
- PATH: 2,
2986
- ROOM_OUTLINE: 3,
2987
- ROOM_PARAMS: 4,
2988
- RESTRICTED_ZONE: 5,
2989
- DYNAMIC_DATA: 6,
2990
- TEMPORARY_DATA: 7,
2991
- OBSTACLE_INFO: 8,
2992
- MAP_DATA: 9,
2993
- CRUISE_DATA: 10
2984
+ MAP_DATA: 2,
2985
+ ROOM_OUTLINE: 4,
2986
+ ROOM_PARAMS: 5,
2987
+ RESTRICTED_ZONE: 6,
2988
+ DYNAMIC_DATA: 7,
2989
+ MAP_DESCRIPTION: 60,
2990
+ MAP_BACKUP: 68
2994
2991
  };
2995
2992
  var BY_ID = new Map(Object.entries(BIZ_CHANNEL).map(([name, id]) => [id, name]));
2996
2993
  function bizChannelName(id) {
@@ -6828,17 +6825,15 @@ var LIGHT_MEMBERS = {
6828
6825
  writeAs: "setEnabled"
6829
6826
  }
6830
6827
  };
6828
+ var NO_SPOTLIGHT_MODELS = /* @__PURE__ */ new Set(["T8171", "T8416"]);
6831
6829
  var LIGHT = {
6832
6830
  capability: "light",
6833
6831
  description: "Floodlight / spotlight manual switch and brightness.",
6834
6832
  members: LIGHT_MEMBERS,
6835
6833
  properties: propertiesOf(LIGHT_MEMBERS),
6836
- // Three T8171 units on firmware 1.2.1.6 report 1400/1401/1403 without a spotlight, so those
6837
- // param ids are not sufficient evidence for that model. The camera status LED (1045) is not light.
6838
- // Other models keep the existing param and name evidence.
6839
6834
  detection: {
6840
6835
  modelHints: [/floodlight|wall.?light|spot.?light|search.?light/i],
6841
- detect: (record) => record.model !== "T8171" && [1400, 1401, 1403, 6080, 6082, 1413].some((param) => Object.hasOwn(record.params ?? {}, param))
6836
+ detect: (record) => !NO_SPOTLIGHT_MODELS.has(record.model ?? "") && [1400, 1401, 1403, 6080, 6082, 1413].some((param) => Object.hasOwn(record.params ?? {}, param))
6842
6837
  },
6843
6838
  /**
6844
6839
  * Only what the member table cannot express: `on`/`off` as no-argument verbs over the same wire as the
@@ -8858,6 +8853,19 @@ function encodeSceneClean(sceneId) {
8858
8853
  const { method: method2, param } = ModeCtrlParamMethod.SCENE;
8859
8854
  return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
8860
8855
  }
8856
+ var MAP_WATCH_FIELD = {
8857
+ /** The time the write is sent, in microseconds since the epoch. */
8858
+ TIME_US: 1,
8859
+ /** `1` on the write that asks for the map. */
8860
+ WATCHING: 2
8861
+ };
8862
+ function encodeMapWatch(nowMs, watching) {
8863
+ return rawDp((w) => {
8864
+ w.int(MAP_WATCH_FIELD.TIME_US, Math.floor(nowMs) * 1e3);
8865
+ if (watching)
8866
+ w.int(MAP_WATCH_FIELD.WATCHING, 1);
8867
+ });
8868
+ }
8861
8869
  function encodeAutoClean() {
8862
8870
  const { method: method2, param } = ModeCtrlParamMethod.AUTO;
8863
8871
  return encodeModeCtrlParam(method2, param, (p) => p.int(AUTO_CLEAN_TIMES, 1));
@@ -10727,6 +10735,20 @@ var VACUUM_CLEAN_MEMBERS = {
10727
10735
  * WATCHED: run on a T2351, it started the named scene.
10728
10736
  */
10729
10737
  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),
10738
+ /**
10739
+ * Ask the robot to push its whole map — plane, room outline, room list and restricted zones — on the
10740
+ * map stream, as the vendor app does when its map view opens: two DP 153 writes, the time and then the
10741
+ * time with field 2 set (see {@link encodeMapWatch}).
10742
+ *
10743
+ * A docked robot sends no map of its own accord, so this is how its room ids and names become
10744
+ * readable without a clean. The map arrives on the stream like any other, not as this call's result.
10745
+ *
10746
+ * WATCHED: sent to a docked robot, it pushed its whole map within a second.
10747
+ */
10748
+ requestMap: method(({ sink }) => async () => {
10749
+ await sink.dispatch(aiotDp(VACUUM_DP.WORK_STATUS, encodeMapWatch(Date.now(), false)));
10750
+ await sink.dispatch(aiotDp(VACUUM_DP.WORK_STATUS, encodeMapWatch(Date.now(), true)));
10751
+ }, "Ask the robot to push its whole map on the map stream (two map-watch writes over DP 153).", isAiotVacuum),
10730
10752
  /**
10731
10753
  * State the cleaning settings a run uses — `CleanParamRequest.clean_param` over DP 154.
10732
10754
  *
@@ -15184,7 +15206,9 @@ var READER_BY_CHANNEL = {
15184
15206
  ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
15185
15207
  ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
15186
15208
  RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
15187
- DYNAMIC_DATA: reader("pose", decodeVacuumPose)
15209
+ DYNAMIC_DATA: reader("pose", decodeVacuumPose),
15210
+ MAP_DESCRIPTION: reader("description", decodeVacuumMapDescription),
15211
+ MAP_BACKUP: reader("backup", decodeVacuumMapBackup)
15188
15212
  };
15189
15213
  var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
15190
15214
  var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
@@ -16326,6 +16350,19 @@ function paramReport(payload) {
16326
16350
  }
16327
16351
  return Object.keys(out).length ? out : void 0;
16328
16352
  }
16353
+ function channelReport(payload) {
16354
+ const list = payload?.params;
16355
+ if (!Array.isArray(list))
16356
+ return void 0;
16357
+ const out = {};
16358
+ for (const entry of list) {
16359
+ const p = entry;
16360
+ if (typeof p?.dev_type !== "number" || typeof p.param_type !== "number" || p.param_value == null)
16361
+ continue;
16362
+ (out[p.dev_type] ??= {})[p.param_type] = String(p.param_value);
16363
+ }
16364
+ return Object.keys(out).length ? out : void 0;
16365
+ }
16329
16366
  var traceSequence = 0;
16330
16367
  function firstTableRows(text2) {
16331
16368
  const decoded = Buffer.from(text2, "latin1").toString("utf8");
@@ -16407,7 +16444,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16407
16444
  audioStalled = false;
16408
16445
  audioRetransmitTimer;
16409
16446
  lastPongData;
16410
- /** When the selected peer last answered outbound P2P traffic on this connection. */
16447
+ /**
16448
+ * When the selected peer last answered outbound P2P traffic on this connection.
16449
+ *
16450
+ * Each answer also emits `peerAnswer` with this time: a PONG or an ACK from the connected address is the
16451
+ * station replying on this connection now, which a heartbeat draws every {@link HEARTBEAT_MS}.
16452
+ */
16411
16453
  lastPeerAt;
16412
16454
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
16413
16455
  pathStaleTraced = false;
@@ -17046,6 +17088,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17046
17088
  if (fromConnectedPeer && (hasHeader(msg, ResponseMessageType.PONG) || hasHeader(msg, ResponseMessageType.ACK))) {
17047
17089
  this.lastPeerAt = Date.now();
17048
17090
  this.pathStaleTraced = false;
17091
+ this.emit("peerAnswer", this.lastPeerAt);
17049
17092
  }
17050
17093
  if (!hasHeader(msg, ResponseMessageType.DATA)) {
17051
17094
  this.logger.debug(`[p2p] ${this.cfg.stationSn} <<< ${rinfo.address}:${rinfo.port} header=${msg.subarray(0, 2).toString("hex")} len=${msg.length}`);
@@ -18377,7 +18420,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18377
18420
  frame.json = JSON.parse(text2);
18378
18421
  } catch {
18379
18422
  }
18380
- const reported = header.commandId === CMD_NOTIFY_PAYLOAD ? paramReport(frame.json?.payload) : header.commandId === CMD_CAMERA_INFO ? paramReport(frame.json) : void 0;
18423
+ const stationTable = header.commandId === CMD_CAMERA_INFO && header.channel === STATION_CHANNEL3;
18424
+ const byChannel = stationTable ? channelReport(frame.json) : void 0;
18425
+ const reported = header.commandId === CMD_NOTIFY_PAYLOAD ? paramReport(frame.json?.payload) : header.commandId === CMD_CAMERA_INFO && !stationTable ? paramReport(frame.json) : void 0;
18426
+ if (byChannel)
18427
+ frame.channelParams = byChannel;
18381
18428
  if (reported)
18382
18429
  frame.params = reported;
18383
18430
  }
@@ -19496,6 +19543,7 @@ var LiveStream = class extends EventEmitter3 {
19496
19543
  }
19497
19544
  return void 0;
19498
19545
  }
19546
+ /** Emit one accepted media frame; an audio frame with no bytes past its header emits nothing. */
19499
19547
  onFrame(f) {
19500
19548
  const accepted = this.acceptsMedia(f);
19501
19549
  if (f.commandId === CMD_VIDEO_FRAME2 && !this.tracedFirstVideoCommand) {
@@ -19627,11 +19675,11 @@ var LiveStream = class extends EventEmitter3 {
19627
19675
  * The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
19628
19676
  * opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
19629
19677
  * before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
19630
- * its header.
19678
+ * its header, empty for a frame that carries only its header.
19631
19679
  */
19632
19680
  audioPayload(frame) {
19633
19681
  if (!this.decoder || !isSealedAudio(frame)) {
19634
- return frame.length > AUDIO_HEADER_LEN ? frame.subarray(AUDIO_HEADER_LEN) : frame;
19682
+ return frame.subarray(AUDIO_HEADER_LEN);
19635
19683
  }
19636
19684
  const mediaKey = this.decoder.currentMediaKey;
19637
19685
  return mediaKey ? openSealedAudio(frame, mediaKey) : void 0;
@@ -22629,6 +22677,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
22629
22677
  this.tearDownStation(stationSn);
22630
22678
  });
22631
22679
  session.on("level2Ready", ({ cipherId }) => this.deps.onLevel2Ready(stationSn, cipherId));
22680
+ session.on("peerAnswer", (atMs) => {
22681
+ if (this.manager.get(stationSn) === session)
22682
+ this.deps.onPeerAnswer?.(stationSn, atMs);
22683
+ });
22632
22684
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
22633
22685
  return session;
22634
22686
  }
@@ -28844,12 +28896,15 @@ function mergeParams(raw, params, paramUpdatedAt) {
28844
28896
  paramUpdatedAt[p.param_type] = ts;
28845
28897
  }
28846
28898
  }
28899
+ function newest(a, b) {
28900
+ return a === void 0 ? b : b === void 0 ? a : Math.max(a, b);
28901
+ }
28847
28902
  function lastSeenMsOf(paramUpdatedAt) {
28848
- let newest = 0;
28903
+ let newest2 = 0;
28849
28904
  for (const ts of Object.values(paramUpdatedAt))
28850
- if (ts > newest)
28851
- newest = ts;
28852
- return newest > 0 ? newest * 1e3 : void 0;
28905
+ if (ts > newest2)
28906
+ newest2 = ts;
28907
+ return newest2 > 0 ? newest2 * 1e3 : void 0;
28853
28908
  }
28854
28909
  var CLASS_BY_CODEC = {
28855
28910
  station: "homebase",
@@ -28971,6 +29026,7 @@ var DeviceRegistry = class {
28971
29026
  this.onError(e);
28972
29027
  }
28973
29028
  const seen = /* @__PURE__ */ new Map();
29029
+ const prior = new Map(this.devices.map((d) => [d.sn, d.lastSeenMs]));
28974
29030
  for (const body of bodies) {
28975
29031
  let res;
28976
29032
  try {
@@ -29005,7 +29061,7 @@ var DeviceRegistry = class {
29005
29061
  p2pDid: raw.p2p_did,
29006
29062
  params,
29007
29063
  paramUpdatedAt,
29008
- lastSeenMs: lastSeenMsOf(paramUpdatedAt),
29064
+ lastSeenMs: newest(lastSeenMsOf(paramUpdatedAt), prior.get(raw.device_sn)),
29009
29065
  raw,
29010
29066
  deviceClass,
29011
29067
  ...c
@@ -29241,6 +29297,16 @@ var DeviceRegistry = class {
29241
29297
  return void 0;
29242
29298
  }
29243
29299
  }
29300
+ /**
29301
+ * Record that `sn` was heard on a realtime wire at `atMs`, advancing its {@link EufyDevice.lastSeenMs}.
29302
+ *
29303
+ * Only ever moves the time forward, and survives a {@link getDevices} rebuild.
29304
+ */
29305
+ markSeen(sn, atMs) {
29306
+ const dev = this.devices.find((d) => d.sn === sn);
29307
+ if (dev)
29308
+ dev.lastSeenMs = newest(dev.lastSeenMs, atMs);
29309
+ }
29244
29310
  /**
29245
29311
  * Record state a device reported over its realtime wire, merging into whatever it last reported.
29246
29312
  *
@@ -29255,9 +29321,7 @@ var DeviceRegistry = class {
29255
29321
  if (!Object.keys(params).length)
29256
29322
  return;
29257
29323
  this.dpParams.set(sn, { ...this.dpParams.get(sn), ...params });
29258
- const dev = this.devices.find((d) => d.sn === sn);
29259
- if (dev)
29260
- dev.lastSeenMs = Date.now();
29324
+ this.markSeen(sn, Date.now());
29261
29325
  this.deviceCapsCache.delete(sn);
29262
29326
  const waiters = this.stateWaiters.get(sn);
29263
29327
  if (waiters) {
@@ -29466,6 +29530,8 @@ var DEFAULT_PREWARM_TIERS = ["wired", "battery"];
29466
29530
  var SESSION_RECYCLE_WAIT_MS = 5e3;
29467
29531
  var PUSH_RETRY_MIN_MS = 3e4;
29468
29532
  var PUSH_RETRY_MAX_MS = 6e5;
29533
+ var STATION_REOPEN_MIN_MS = 5e3;
29534
+ var STATION_REOPEN_MAX_MS = 3e5;
29469
29535
  var RealtimeStartupSupersededError = class extends Error {
29470
29536
  };
29471
29537
  var RefreshWindowClosedError = class extends Error {
@@ -29572,6 +29638,12 @@ var EufyMega = class extends EventEmitter14 {
29572
29638
  pollTimer = new Timer();
29573
29639
  /** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
29574
29640
  pushRetryTimer = new Timer();
29641
+ /**
29642
+ * Per wired station, the pending reopen of its session and the pause before the next one; see
29643
+ * {@link reopenWiredStation}. Dropped for a station when its session connects, and wholly by
29644
+ * {@link disconnect}.
29645
+ */
29646
+ stationReopens = /* @__PURE__ */ new Map();
29575
29647
  constructor(opts) {
29576
29648
  super();
29577
29649
  this.opts = opts;
@@ -29605,10 +29677,18 @@ var EufyMega = class extends EventEmitter14 {
29605
29677
  ensureDevices: async () => {
29606
29678
  await this.registry.getDevices();
29607
29679
  },
29608
- onConnect: (sn) => this.emit("p2pConnect", sn),
29609
- onClose: (sn) => this.emit("p2pClose", sn),
29680
+ onConnect: (sn) => {
29681
+ this.stationReopens.get(sn)?.timer.cancel();
29682
+ this.stationReopens.delete(sn);
29683
+ this.emit("p2pConnect", sn);
29684
+ },
29685
+ onClose: (sn) => {
29686
+ this.emit("p2pClose", sn);
29687
+ this.reopenWiredStation(sn);
29688
+ },
29610
29689
  onError: (e) => this.reportError(e),
29611
29690
  onLevel2Ready: (sn, cipherId) => this.emit("p2pLevel2Ready", { stationSn: sn, cipherId }),
29691
+ onPeerAnswer: (sn, atMs) => this.registry.markSeen(sn, atMs),
29612
29692
  onFrame: (stationSn, f) => this.onP2PFrame(stationSn, f)
29613
29693
  });
29614
29694
  this.rtc = new RtcCommandRouter({
@@ -30081,7 +30161,16 @@ var EufyMega = class extends EventEmitter14 {
30081
30161
  releaseWrite.then(() => this.opts.logger?.debug(`[session ${sn}] recycle still waiting on an attached viewer \u2014 releasing the write that asked for it`))
30082
30162
  ]);
30083
30163
  }
30084
- /** Reopen a wired station that {@link recycleStandaloneSession} closed, while the generation that warmed it is current. */
30164
+ /**
30165
+ * Reopen a wired station's session while the generation that warmed it is current: after
30166
+ * {@link recycleStandaloneSession} closed it, after it closed on its own, or after its warm-up failed.
30167
+ *
30168
+ * A wired station's session is the persistent one realtime start opened, so a station left without one
30169
+ * receives nothing until a command dials it again. The first reopen runs at once; one that fails is
30170
+ * retried after {@link STATION_REOPEN_MIN_MS}, doubling up to {@link STATION_REOPEN_MAX_MS}, until the
30171
+ * session connects or the generation ends. One reopen is pending per station at a time, and each failure
30172
+ * is reported once, by {@link P2PCommandRouter.ensureStation}.
30173
+ */
30085
30174
  reopenWiredStation(sn) {
30086
30175
  const generation = this.realtimeGeneration;
30087
30176
  if (generation?.epoch !== this.realtimeEpoch)
@@ -30090,9 +30179,18 @@ var EufyMega = class extends EventEmitter14 {
30090
30179
  return;
30091
30180
  if (this.stationPower(sn) !== "wired")
30092
30181
  return;
30093
- void this.p2p.ensureStation(sn, generation.abort.signal).catch((error) => {
30094
- if (!generation.abort.signal.aborted)
30095
- this.reportError(error);
30182
+ const reopen = this.stationReopens.get(sn) ?? { timer: new Timer(), delayMs: 0 };
30183
+ this.stationReopens.set(sn, reopen);
30184
+ if (reopen.timer.pending)
30185
+ return;
30186
+ reopen.timer.arm(reopen.delayMs, () => {
30187
+ if (generation.epoch !== this.realtimeEpoch)
30188
+ return;
30189
+ reopen.delayMs = Math.min(Math.max(reopen.delayMs * 2, STATION_REOPEN_MIN_MS), STATION_REOPEN_MAX_MS);
30190
+ void this.p2p.ensureStation(sn, generation.abort.signal).catch(() => {
30191
+ if (!generation.abort.signal.aborted && this.stationReopens.get(sn) === reopen)
30192
+ this.reopenWiredStation(sn);
30193
+ });
30096
30194
  });
30097
30195
  }
30098
30196
  /**
@@ -30135,6 +30233,8 @@ var EufyMega = class extends EventEmitter14 {
30135
30233
  * fans same-kind sensors out by channel, so the resolved serial is folded in; a payload's own
30136
30234
  * fields still win on conflict.
30137
30235
  *
30236
+ * A station's camera-info table lands per channel, each channel's params on the device attached there.
30237
+ *
30138
30238
  * Params the station volunteered land as device state first. They arrive in the cloud record's own
30139
30239
  * `param_type → value` shape, so no capability has to claim an id it does not own, and the reads
30140
30240
  * they back stop waiting for the next cloud poll. A capability decode follows for the frames that
@@ -30153,6 +30253,8 @@ var EufyMega = class extends EventEmitter14 {
30153
30253
  };
30154
30254
  if (f.params)
30155
30255
  this.applyRealtimeState(deviceSn, f.params);
30256
+ for (const [channel, params] of Object.entries(f.channelParams ?? {}))
30257
+ this.applyRealtimeState(this.registry.serialForFrame(stationSn, Number(channel)), params);
30156
30258
  this.applyRealtimeReport(deviceSn, decodeState(signal, caps));
30157
30259
  for (const ev of decodeEvent(signal, caps))
30158
30260
  this.emitSemantic(ev.event, deviceSn ? { deviceSn, ...ev.payload } : ev.payload, {
@@ -31035,7 +31137,8 @@ var EufyMega = class extends EventEmitter14 {
31035
31137
  return this.rtc.keepWarm(route);
31036
31138
  }
31037
31139
  /** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
31038
- * stations stay closed until an on-demand open. Best-effort per station. */
31140
+ * stations stay closed until an on-demand open. Best-effort per station: one that fails is handed to
31141
+ * {@link reopenWiredStation}. */
31039
31142
  async warmWiredP2P(readiness = createPlaneReadiness(), signal) {
31040
31143
  const wired = /* @__PURE__ */ new Set();
31041
31144
  for (const d of this.registry.p2pDevices()) {
@@ -31053,6 +31156,7 @@ var EufyMega = class extends EventEmitter14 {
31053
31156
  return;
31054
31157
  readiness.failed++;
31055
31158
  readiness.pending--;
31159
+ this.reopenWiredStation(sn);
31056
31160
  })));
31057
31161
  return readiness;
31058
31162
  }
@@ -31727,6 +31831,9 @@ var EufyMega = class extends EventEmitter14 {
31727
31831
  async teardownRealtime() {
31728
31832
  this.pollTimer.cancel();
31729
31833
  this.pushRetryTimer.cancel();
31834
+ for (const { timer } of this.stationReopens.values())
31835
+ timer.cancel();
31836
+ this.stationReopens.clear();
31730
31837
  this.lastStateAnnounced.clear();
31731
31838
  await this.closeMqttTransports();
31732
31839
  this.rtc.close();