@mega-yfue/eufy-sdk 0.2.0-beta.2 → 0.2.0-beta.4

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.
@@ -74,6 +74,61 @@ export declare class CameraDisabledError extends Error {
74
74
  cause?: unknown;
75
75
  });
76
76
  }
77
+ /**
78
+ * Work on a station was refused: the station did not provide the session key that work requires.
79
+ *
80
+ * A station reached over its HomeBase encrypts what it is sent under a key negotiated once per connection, and
81
+ * a media start for an attached camera has no unencrypted form at all — so without that key there is nothing
82
+ * to send, however reachable the station is. Naming this apart from a source that failed is what separates an
83
+ * account whose cipher material could not be resolved from a camera that is off, a station that is busy, or a
84
+ * stream that produced nothing: they share no next step.
85
+ *
86
+ * The `level2-unavailable` trace states WHY the key is not coming. This states only that it is not, because
87
+ * that is what the refusal itself knows.
88
+ *
89
+ * `stationSn` is the station that owed the key, which is the parent for an attached camera and therefore not
90
+ * the serial the refused call was made about: several cameras refused at once are one station's outcome, and
91
+ * nothing else in the refusal says so.
92
+ */
93
+ export declare class StationKeyUnavailableError extends Error {
94
+ /** The station whose session key did not arrive. */
95
+ readonly stationSn: string;
96
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
97
+ readonly retryable = true;
98
+ constructor(
99
+ /** The station whose session key did not arrive. */
100
+ stationSn: string, options?: {
101
+ cause?: unknown;
102
+ });
103
+ }
104
+ /**
105
+ * Work on a station was refused: its session did not connect within the wait it was given.
106
+ *
107
+ * A station is reached over its own session, and nothing addressed to it — a media start, a property read, a
108
+ * still — can be attempted before that session is up. Naming this apart from every other failure is what tells
109
+ * a station that could not be reached at all from one that answered and then refused, or one that served media
110
+ * a caller could not use: those call for opposite next steps, and a caller cannot infer which it had from a
111
+ * message.
112
+ *
113
+ * `waitedMs` is how long was actually waited, which a caller compares against its own deadline to know whether
114
+ * this SDK concluded or its own bound expired first. `stationSn` is the station that could not be reached —
115
+ * the parent for an attached camera, so it is not derivable from the serial the call was made about.
116
+ */
117
+ export declare class StationUnreachableError extends Error {
118
+ /** The station whose session did not connect. */
119
+ readonly stationSn: string;
120
+ /** How long the session was waited on before this was raised. */
121
+ readonly waitedMs: number;
122
+ /** Always true: a station unreachable now may answer on a later attempt. */
123
+ readonly retryable = true;
124
+ constructor(
125
+ /** The station whose session did not connect. */
126
+ stationSn: string,
127
+ /** How long the session was waited on before this was raised. */
128
+ waitedMs: number, options?: {
129
+ cause?: unknown;
130
+ });
131
+ }
77
132
  /**
78
133
  * A live stream was refused: the station is already serving another of its cameras to a viewer.
79
134
  *
package/dist/index.js CHANGED
@@ -2556,6 +2556,28 @@ var CameraDisabledError = class extends Error {
2556
2556
  this.name = "CameraDisabledError";
2557
2557
  }
2558
2558
  };
2559
+ var StationKeyUnavailableError = class extends Error {
2560
+ stationSn;
2561
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
2562
+ retryable = true;
2563
+ constructor(stationSn, options) {
2564
+ super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
2565
+ this.stationSn = stationSn;
2566
+ this.name = "StationKeyUnavailableError";
2567
+ }
2568
+ };
2569
+ var StationUnreachableError = class extends Error {
2570
+ stationSn;
2571
+ waitedMs;
2572
+ /** Always true: a station unreachable now may answer on a later attempt. */
2573
+ retryable = true;
2574
+ constructor(stationSn, waitedMs, options) {
2575
+ super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
2576
+ this.stationSn = stationSn;
2577
+ this.waitedMs = waitedMs;
2578
+ this.name = "StationUnreachableError";
2579
+ }
2580
+ };
2559
2581
  var StationBusyError = class extends Error {
2560
2582
  servingChannel;
2561
2583
  /** Always true: the station is busy now, and stops being busy when the other stream is released. */
@@ -14201,19 +14223,27 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14201
14223
  * cameras from that second group streamed normally at level-1 — including one of the same firmware as an
14202
14224
  * own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
14203
14225
  * separates nothing on this path, and a start failure on such a session is not evidence about it.
14226
+ *
14227
+ * Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
14228
+ * `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
14229
+ * cipher and the cause are known, and re-stating it here would double every settled negotiation.
14204
14230
  */
14205
14231
  async awaitLevel2Key(graceMs, graceFrom = "call") {
14206
- if (this.closed)
14232
+ if (this.closed) {
14233
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14207
14234
  return false;
14235
+ }
14208
14236
  if (this.level2Key)
14209
14237
  return true;
14210
- if (!this.level2Pending)
14238
+ if (!this.level2Pending) {
14239
+ this.trace({ phase: "level2-unavailable", reason: "not-negotiating" });
14211
14240
  return false;
14241
+ }
14212
14242
  const since = graceFrom === "call" ? Date.now() : this.connectedAtMs ?? Date.now();
14213
14243
  const remaining = graceMs - (Date.now() - since);
14214
14244
  if (remaining <= 0) {
14215
14245
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no level-2 key and its ${graceMs}ms grace has elapsed`);
14216
- this.trace({ phase: "level2-absent", waitedMs: graceMs });
14246
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: graceMs });
14217
14247
  return false;
14218
14248
  }
14219
14249
  this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
@@ -14239,10 +14269,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14239
14269
  });
14240
14270
  if (outcome === "timeout") {
14241
14271
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 key did not arrive within its grace`);
14272
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: remaining });
14242
14273
  } else if (outcome === "terminal") {
14243
14274
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 negotiation concluded without a key`);
14244
14275
  } else if (outcome === "closed") {
14245
14276
  this.logger.debug(`[p2p] ${this.cfg.stationSn} session closed before the level-2 key arrived`);
14277
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14246
14278
  }
14247
14279
  return outcome === "key";
14248
14280
  }
@@ -14293,6 +14325,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14293
14325
  this.level2Negotiating = true;
14294
14326
  const generation = this.connectionGeneration;
14295
14327
  const cipherId = gatewayInfoCipherId(gwPayload);
14328
+ this.trace({ phase: "level2-negotiating", cipherId });
14296
14329
  void (async () => {
14297
14330
  try {
14298
14331
  const eccPrivHex = await this.cfg.resolveCipherKey?.(cipherId);
@@ -14300,11 +14333,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14300
14333
  return;
14301
14334
  if (!eccPrivHex) {
14302
14335
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no ECC key for cipher_id ${cipherId}`);
14336
+ this.trace({ phase: "level2-unavailable", reason: "no-cipher-key", cipherId });
14303
14337
  this.settleLevel2();
14304
14338
  return;
14305
14339
  }
14306
14340
  const key = deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivHex);
14307
14341
  if (!key) {
14342
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14308
14343
  this.settleLevel2();
14309
14344
  this.emit("error", new Error(`level-2 key derivation failed (cipher_id ${cipherId})`));
14310
14345
  return;
@@ -14316,6 +14351,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14316
14351
  } catch (e) {
14317
14352
  if (this.closed || generation !== this.connectionGeneration)
14318
14353
  return;
14354
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14319
14355
  this.settleLevel2();
14320
14356
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
14321
14357
  }
@@ -16381,10 +16417,12 @@ var LiveStream = class extends EventEmitter3 {
16381
16417
  if (!this.listening || this.kaTimer)
16382
16418
  return;
16383
16419
  if (this.opts.reassertWanted?.() === false) {
16420
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "declined" });
16384
16421
  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`);
16385
16422
  this.armStallWatch();
16386
16423
  return;
16387
16424
  }
16425
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "reasserted" });
16388
16426
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms \u2014 re-asserting this camera's channel`);
16389
16427
  this.sendStart();
16390
16428
  this.kaTimer = setInterval(() => this.sendStart(), keepAliveMs);
@@ -16605,10 +16643,12 @@ async function captureSnapshotFromShared(source, opts = {}) {
16605
16643
  const timeoutMs = opts.timeoutMs ?? 2e4;
16606
16644
  const collectMs = opts.collectMs ?? 1500;
16607
16645
  const skip = opts.skipKeyframes ?? 1;
16646
+ opts.signal?.throwIfAborted();
16608
16647
  const consumer = source.attach();
16609
16648
  const primed = consumer.primed;
16649
+ let burst;
16610
16650
  try {
16611
- const burst = await new Promise((resolve, reject) => {
16651
+ burst = await new Promise((resolve, reject) => {
16612
16652
  const bufs = [];
16613
16653
  let sets = source.parameterSets;
16614
16654
  let codec = "h264";
@@ -16643,6 +16683,10 @@ async function captureSnapshotFromShared(source, opts = {}) {
16643
16683
  cleanup();
16644
16684
  reject(new LiveSnapshotUnavailableError("source-failed", `source ended before a keyframe (state: ${source.state})`));
16645
16685
  };
16686
+ const onAbandoned = () => {
16687
+ cleanup();
16688
+ reject(opts.signal?.reason);
16689
+ };
16646
16690
  const cleanup = () => {
16647
16691
  clearTimeout(timer);
16648
16692
  if (settle)
@@ -16650,19 +16694,21 @@ async function captureSnapshotFromShared(source, opts = {}) {
16650
16694
  consumer.off("video", onVideo);
16651
16695
  consumer.off("error", onError);
16652
16696
  consumer.off("stop", onStop);
16697
+ opts.signal?.removeEventListener("abort", onAbandoned);
16653
16698
  };
16654
16699
  consumer.on("video", onVideo);
16655
16700
  consumer.on("error", onError);
16656
16701
  consumer.on("stop", onStop);
16657
- });
16658
- return await annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
16659
- logger: opts.logger ?? noopLogger,
16660
- level: opts.ffmpegLevel,
16661
- executable: opts.ffmpegPath
16702
+ opts.signal?.addEventListener("abort", onAbandoned, { once: true });
16662
16703
  });
16663
16704
  } finally {
16664
16705
  consumer.detach();
16665
16706
  }
16707
+ return annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
16708
+ logger: opts.logger ?? noopLogger,
16709
+ level: opts.ffmpegLevel,
16710
+ executable: opts.ffmpegPath
16711
+ });
16666
16712
  }
16667
16713
  async function recordClip(session, seconds, opts = {}) {
16668
16714
  const timeoutMs = opts.timeoutMs ?? 2e4;
@@ -18869,6 +18915,11 @@ function abortable(work, signal) {
18869
18915
  }
18870
18916
  var LEVEL2_GRACE_MS = 25e3;
18871
18917
  var LEVEL2_SETTLE_MS = 8e3;
18918
+ var P2P_STATION_WAITS = {
18919
+ connect: CONNECT_WAIT_MS,
18920
+ level2Grace: LEVEL2_GRACE_MS,
18921
+ level2Settle: LEVEL2_SETTLE_MS
18922
+ };
18872
18923
  var RTSP_URL_READ_TIMEOUT_MS = 12e3;
18873
18924
  var SHARED_LIVE_OPT_KEYS = [
18874
18925
  "eccPrivateKey",
@@ -18920,6 +18971,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
18920
18971
  }
18921
18972
  return normalized;
18922
18973
  }
18974
+ /**
18975
+ * Emit a live trace under a station session's handle, for work this router does ON that session before
18976
+ * the session itself records anything — reaching the station, and resolving what a device is on it. Same
18977
+ * handle as everything the session goes on to trace, which is what groups one attempt.
18978
+ */
18979
+ traceOnStation(session, trace) {
18980
+ traceLiveStart(this.deps.logger ?? noopLogger, trace, session.traceId);
18981
+ }
18923
18982
  /**
18924
18983
  * Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
18925
18984
  * usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
@@ -19060,7 +19119,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
19060
19119
  let ecc;
19061
19120
  try {
19062
19121
  const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
19063
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key ?? ciphers[0]?.ecc_private_key;
19122
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
19123
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
19124
+ ecc = ciphers[0].ecc_private_key;
19125
+ this.traceOnStation(session, {
19126
+ phase: "cipher-fallback",
19127
+ cipherId,
19128
+ answeredCipherId: Number(ciphers[0].cipher_id)
19129
+ });
19130
+ }
19064
19131
  } catch (e) {
19065
19132
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19066
19133
  }
@@ -19214,12 +19281,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
19214
19281
  return {
19215
19282
  snapshotLive: async (opts) => {
19216
19283
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
19217
- return abortable(captureSnapshotFromShared(source, {
19284
+ return captureSnapshotFromShared(source, {
19218
19285
  ...opts,
19219
19286
  logger: this.deps.logger ?? noopLogger,
19220
19287
  ffmpegLevel: this.deps.ffmpegLogLevel,
19221
19288
  ffmpegPath: this.deps.ffmpegPath
19222
- }), opts?.signal);
19289
+ });
19223
19290
  },
19224
19291
  live: async (opts) => {
19225
19292
  const source = await this.sharedLiveSourceFor(sn, opts);
@@ -19691,9 +19758,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19691
19758
  s.sendStringPayloadCommand(P2P_ENVELOPE.CONTROL_PAYLOAD, json, ch);
19692
19759
  return Promise.resolve();
19693
19760
  },
19694
- l2: async ({ session: s, channel: ch }) => {
19761
+ l2: async ({ session: s, channel: ch, parentSn }) => {
19695
19762
  if (!await s.awaitLevel2Key(LEVEL2_GRACE_MS, "call")) {
19696
- throw new Error(`level-2 key not ready for ${sn} \u2014 cannot query`);
19763
+ throw new StationKeyUnavailableError(parentSn);
19697
19764
  }
19698
19765
  s.sendRawLevel2(json, ch, P2P_ENVELOPE.CONTROL_PAYLOAD);
19699
19766
  }
@@ -19860,15 +19927,28 @@ var P2PCommandRouter = class _P2PCommandRouter {
19860
19927
  }
19861
19928
  this.manager.bumpCommand(parentSn);
19862
19929
  const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
19863
- const accountId = raw.member?.admin_user_id ?? this.deps.mega.auth?.userId ?? "";
19930
+ const stationAdminId = raw.member?.admin_user_id;
19931
+ const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
19864
19932
  const t0 = Date.now();
19865
- while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
19866
- opts.signal?.throwIfAborted();
19867
- await sleep2(200);
19933
+ let waitedMs = 0;
19934
+ if (!session.isConnected) {
19935
+ this.traceOnStation(session, { phase: "session-connect-wait", waitMs: CONNECT_WAIT_MS });
19936
+ while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
19937
+ opts.signal?.throwIfAborted();
19938
+ await sleep2(200);
19939
+ }
19940
+ waitedMs = Date.now() - t0;
19941
+ this.traceOnStation(session, session.isConnected ? { phase: "session-connected", waitedMs } : { phase: "session-unreachable", waitedMs });
19868
19942
  }
19869
19943
  opts.signal?.throwIfAborted();
19870
19944
  if (!session.isConnected)
19871
- throw new Error(`P2P session for ${parentSn} did not connect`);
19945
+ throw new StationUnreachableError(parentSn, waitedMs);
19946
+ this.traceOnStation(session, {
19947
+ phase: "station-resolved",
19948
+ topology: homeBaseAttached ? "attached" : "own",
19949
+ channel,
19950
+ stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other"
19951
+ });
19872
19952
  if (opts.waitLevel2) {
19873
19953
  if (opts.waitLevel2 === "settle") {
19874
19954
  await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
@@ -19882,7 +19962,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19882
19962
  ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), opts.signal);
19883
19963
  }
19884
19964
  if (!ready)
19885
- throw new Error(`level-2 key not ready for ${parentSn}`);
19965
+ throw new StationKeyUnavailableError(parentSn);
19886
19966
  }
19887
19967
  return { session, parentSn, channel, accountId, homeBaseAttached };
19888
19968
  }
@@ -25397,6 +25477,7 @@ export {
25397
25477
  P256,
25398
25478
  P2PSession,
25399
25479
  P2P_ENVELOPE,
25480
+ P2P_STATION_WAITS,
25400
25481
  PRINTER_CATEGORY_RE,
25401
25482
  PTZ_MEMBERS,
25402
25483
  PowerSource,
@@ -25430,6 +25511,8 @@ export {
25430
25511
  SmartDropPushEvent,
25431
25512
  StateConvergenceError,
25432
25513
  StationBusyError,
25514
+ StationKeyUnavailableError,
25515
+ StationUnreachableError,
25433
25516
  StoredSnapshotUnavailableError,
25434
25517
  StreamingQuality,
25435
25518
  SuctionLevel,