@mega-yfue/eufy-sdk 0.5.0-beta.6 → 0.5.0-beta.8

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.
@@ -835,11 +835,14 @@ export declare class EufyMega extends EventEmitter {
835
835
  */
836
836
  setProperty(sn: string, name: string, value: boolean | number | string): Promise<void>;
837
837
  /**
838
- * Restart a HomeBase.
839
- *
840
- * **HomeBases only** — restart is a hub operation, so a non-HomeBase serial (a camera, an NVR)
841
- * throws rather than doing nothing. The hub drops its connection and returns after a minute or two,
842
- * so everything behind it is briefly offline. Verified on real hardware.
838
+ * Restart a station: a HomeBase, or a camera-family device that is its own station.
839
+ *
840
+ * The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
841
+ * P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
842
+ * A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
843
+ * throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
844
+ * and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
845
+ * Verified on real hardware against a HomeBase and a standalone indoor camera.
843
846
  */
844
847
  reboot(sn: string): Promise<void>;
845
848
  /**
package/dist/index.js CHANGED
@@ -16249,6 +16249,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16249
16249
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16250
16250
  return true;
16251
16251
  }
16252
+ /**
16253
+ * The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
16254
+ * level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
16255
+ */
16256
+ sendRawLevel1Bytes(payload, channel, outerCmd) {
16257
+ if (!this.connectAddress)
16258
+ return false;
16259
+ const data = Buffer.concat([
16260
+ buildCommandHeader(this.seqNumber, outerCmd),
16261
+ buildRawCommandPayload(encryptP2PData(paddingP2PData(payload), this.level1Key), channel, 1)
16262
+ ]);
16263
+ this.seqNumber = this.seqNumber + 1 & 65535;
16264
+ this.send(this.connectAddress, RequestMessageType.DATA, data);
16265
+ return true;
16266
+ }
16252
16267
  /**
16253
16268
  * Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
16254
16269
  * its runtime topology takes, as {@link startLiveMedia} selects its start:
@@ -16810,11 +16825,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16810
16825
  if (advance > SEQUENCE_LOOKBACK) {
16811
16826
  if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
16812
16827
  return;
16813
- if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
16828
+ if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
16814
16829
  this.trace({ phase: "sequence-restart", dataType });
16815
16830
  this.clearReorderTimer(dataType);
16816
16831
  this.reorderByType.delete(dataType);
16817
16832
  this.pendingByDataType.delete(dataType);
16833
+ if (dataType === P2PDataType.VIDEO)
16834
+ this.emit("videoGap");
16818
16835
  this.lastSeqByType.set(dataType, seqNo);
16819
16836
  this.reassemble(dataType, msg.subarray(8));
16820
16837
  return;
@@ -16880,9 +16897,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16880
16897
  */
16881
16898
  abandonHole(dataType) {
16882
16899
  const held = this.reorderByType.get(dataType).held;
16883
- if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
16900
+ if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
16884
16901
  this.trace({ phase: "datagram-gap", dataType });
16885
16902
  this.pendingByDataType.delete(dataType);
16903
+ if (dataType === P2PDataType.VIDEO)
16904
+ this.emit("videoGap");
16886
16905
  const last = this.lastSeqByType.get(dataType);
16887
16906
  const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
16888
16907
  this.lastSeqByType.set(dataType, earliest - 1 & 65535);
@@ -17519,7 +17538,8 @@ var AccessUnitAssembler = class {
17519
17538
  * `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
17520
17539
  * frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
17521
17540
  * of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
17522
- * carries).
17541
+ * carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
17542
+ * as a complete unit, even if its repeated header says keyframe.
17523
17543
  */
17524
17544
  push(payload, decode) {
17525
17545
  const header = parseVideoFrameHeader(payload);
@@ -17539,6 +17559,8 @@ var AccessUnitAssembler = class {
17539
17559
  return [unitOf(open2.header, Buffer.concat(open2.chunks))];
17540
17560
  }
17541
17561
  this.discard();
17562
+ if (!beginsAccessUnit(body))
17563
+ return [];
17542
17564
  if (full) {
17543
17565
  this.open = { header, chunks: [body], carried: body.length };
17544
17566
  return [];
@@ -17892,6 +17914,12 @@ var LiveStream = class extends EventEmitter3 {
17892
17914
  lastCodec = "h264";
17893
17915
  /** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
17894
17916
  units = new AccessUnitAssembler((drop) => this.reportDroppedUnit(drop));
17917
+ /** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
17918
+ awaitingKeyframeAfterGap = false;
17919
+ videoGapHandler = () => {
17920
+ this.units.discard();
17921
+ this.awaitingKeyframeAfterGap = true;
17922
+ };
17895
17923
  /** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
17896
17924
  mediaChannel;
17897
17925
  tracedFirstVideoCommand = false;
@@ -17934,6 +17962,7 @@ var LiveStream = class extends EventEmitter3 {
17934
17962
  return this;
17935
17963
  this.listening = true;
17936
17964
  this.session.on("data", this.handler);
17965
+ this.session.on("videoGap", this.videoGapHandler);
17937
17966
  this.session.on("liveStartUnacknowledged", this.unackedHandler);
17938
17967
  this.sendStart();
17939
17968
  const keepAliveMs = this.opts.keepAliveMs ?? DEFAULT_KEEPALIVE_MS;
@@ -18031,6 +18060,8 @@ var LiveStream = class extends EventEmitter3 {
18031
18060
  return;
18032
18061
  this.listening = false;
18033
18062
  this.session.off("data", this.handler);
18063
+ this.session.off("videoGap", this.videoGapHandler);
18064
+ this.awaitingKeyframeAfterGap = false;
18034
18065
  this.session.off("liveStartUnacknowledged", this.unackedHandler);
18035
18066
  if (this.kaTimer)
18036
18067
  clearInterval(this.kaTimer);
@@ -18066,6 +18097,11 @@ var LiveStream = class extends EventEmitter3 {
18066
18097
  try {
18067
18098
  if (f.commandId === CMD_VIDEO_FRAME2) {
18068
18099
  for (const unit of this.units.push(f.data, (payload) => this.annexbOf(payload, f.signCode))) {
18100
+ if (this.awaitingKeyframeAfterGap) {
18101
+ if (!unit.keyframe)
18102
+ continue;
18103
+ this.awaitingKeyframeAfterGap = false;
18104
+ }
18069
18105
  if (!this.tracedFirstVideoUnit) {
18070
18106
  this.tracedFirstVideoUnit = true;
18071
18107
  this.trace({ phase: "first-video-unit", keyframe: unit.keyframe });
@@ -18160,6 +18196,7 @@ var LiveStream = class extends EventEmitter3 {
18160
18196
  * otherwise silent in both directions: no frame reaches a consumer, and nothing states why.
18161
18197
  */
18162
18198
  reportDroppedUnit(drop) {
18199
+ this.awaitingKeyframeAfterGap = true;
18163
18200
  const message = `[live] dropped an incomplete access unit (${drop.carried} bytes in ${drop.chunks} frame(s), tail never arrived, ${drop.count} so far)`;
18164
18201
  if (drop.count === 1)
18165
18202
  this.logger.warn(message);
@@ -21175,14 +21212,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
21175
21212
  }
21176
21213
  }
21177
21214
  /**
21178
- * Restart a HomeBase. `RESTART_HUB` (1034) is a station-scalar on the broadcast channel 255: a
21179
- * level-2 frame whose body is `[u32 value][account_id padded]` — the same shape as the hub
21180
- * alarm-volume control. ✅ Wire-confirmed byte-exact from a capture of the app's own Restart
21181
- * (2026-08-03) and HW-tested: the captured frame carried value `0` and rebooted the hub. Replays
21182
- * like every other level-2 control, so a single dropped datagram doesn't lose it.
21215
+ * Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
21216
+ * broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
21217
+ * byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
21218
+ *
21219
+ * The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
21220
+ * level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
21221
+ * sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
21222
+ * control here is.
21183
21223
  */
21184
21224
  async rebootStation(sn) {
21185
- await this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8));
21225
+ await this.sendBySessionLevel(sn, {
21226
+ l2: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8), resolved),
21227
+ l1: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel1Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB), resolved)
21228
+ });
21186
21229
  }
21187
21230
  /**
21188
21231
  * A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
@@ -27288,16 +27331,20 @@ var EufyMega = class extends EventEmitter9 {
27288
27331
  await this.commandSinkFor(sn).dispatch(cmd);
27289
27332
  }
27290
27333
  /**
27291
- * Restart a HomeBase.
27334
+ * Restart a station: a HomeBase, or a camera-family device that is its own station.
27292
27335
  *
27293
- * **HomeBases only** — restart is a hub operation, so a non-HomeBase serial (a camera, an NVR)
27294
- * throws rather than doing nothing. The hub drops its connection and returns after a minute or two,
27295
- * so everything behind it is briefly offline. Verified on real hardware.
27336
+ * The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
27337
+ * P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
27338
+ * A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
27339
+ * throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
27340
+ * and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
27341
+ * Verified on real hardware against a HomeBase and a standalone indoor camera.
27296
27342
  */
27297
27343
  async reboot(sn) {
27298
27344
  const ctx = await this.commandContext(sn);
27299
- if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model })) {
27300
- throw new Error(`reboot: ${sn} is not a HomeBase (deviceType ${ctx.deviceType ?? "?"}, model ${ctx.model ?? "?"}) \u2014 restart is a hub-only operation`);
27345
+ const standaloneCamera = ctx.codec === "camera" && this.p2p.stationKeyOf(sn) === sn;
27346
+ if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model }) && !standaloneCamera) {
27347
+ throw new Error(`reboot: ${sn} is neither a HomeBase nor a standalone camera (deviceType ${ctx.deviceType ?? "?"}, model ${ctx.model ?? "?"}) \u2014 restart addresses the station that owns the P2P session`);
27301
27348
  }
27302
27349
  await this.p2p.rebootStation(sn);
27303
27350
  }