@mega-yfue/eufy-sdk 0.6.0-beta.34 → 0.6.0-beta.36

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.
@@ -156,7 +156,7 @@ export declare class DeviceChannelUnresolvedError extends Error {
156
156
  * - `invalid-recording` — the recording name is not one the camera's station could hold.
157
157
  * - `key-unavailable` — the station stores the recording sealed and the key it was sealed under could not be
158
158
  * obtained.
159
- * - `no-data` — the station sent nothing within the wait.
159
+ * - `no-data` — the station sent no recording: it answered that it holds none, or sent nothing within the wait.
160
160
  * - `incomplete` — the transfer ended before the station's finish frame.
161
161
  * - `undecodable` — data arrived but no video frame could be decoded from it.
162
162
  */
@@ -172,13 +172,15 @@ export declare class RecordingDownloadError extends Error {
172
172
  * A recording downloaded from the station, as elementary streams.
173
173
  *
174
174
  * `video` is Annex-B H.264 or H.265 in decode order, `audio` AAC-LC 16 kHz mono in ADTS framing (absent
175
- * when the recording carried none that could be decoded). `frames` counts the distinct video sequences
176
- * emitted. `missingFrames` counts gaps between received sequences and received sequences that yielded no
175
+ * when the recording carried none that could be decoded). `codec` is the codec of `video`, read off the parameter
176
+ * sets of its first keyframe that carries them, and is absent when no keyframe does. `frames` counts the distinct
177
+ * video sequences emitted. `missingFrames` counts gaps between received sequences and received sequences that yielded no
177
178
  * usable picture, including at either end. `durationMs` spans the first to the last emitted picture by the
178
179
  * camera's own timestamps; `fps` is derived from those stamps.
179
180
  */
180
181
  export interface RecordingDownload {
181
182
  video: Buffer;
183
+ codec?: VideoCodec;
182
184
  audio?: Buffer;
183
185
  frames: number;
184
186
  missingFrames: number;
@@ -493,7 +495,7 @@ export interface TuyaDpInbound {
493
495
  */
494
496
  onDps(sn: string, dps: Record<number, boolean | number | string>): void;
495
497
  }
496
- /** Elementary-stream video codec of a {@link LiveVideoFrame} — eufy cameras stream H.264 or H.265. */
498
+ /** Elementary-stream video codec — eufy cameras stream H.264 or H.265. */
497
499
  export type VideoCodec = "h264" | "h265" | "av1";
498
500
  /**
499
501
  * One decoded video access unit, as Annex-B (H.264 or H.265).
@@ -745,9 +747,12 @@ export interface LiveStreamConsumer extends LiveStreamHandle {
745
747
  export interface AbortableCall {
746
748
  signal?: AbortSignal;
747
749
  }
748
- /** Select an optical view; absence selects view 0. */
750
+ /** NVR output size requested when a live pull starts. */
751
+ export type MediaOutputResolution = "1080p";
752
+ /** Select an optical view and output size; absence selects view 0 at the route's default output. */
749
753
  export interface MediaSourceSelection {
750
754
  view?: number;
755
+ outputResolution?: MediaOutputResolution;
751
756
  }
752
757
  export interface SharedSourceHints {
753
758
  /**
@@ -774,6 +779,8 @@ export interface SharedSourceHints {
774
779
  * because an unbound model has no live client (hence `?.` at the call site).
775
780
  */
776
781
  export interface MediaProvider {
782
+ /** Output sizes supported by this bound media route. Absence means no size selection. */
783
+ outputResolutions?: readonly MediaOutputResolution[];
777
784
  /** Optical view identifiers supported by this bound media route. */
778
785
  views?: readonly number[];
779
786
  /**
package/dist/index.js CHANGED
@@ -3743,6 +3743,7 @@ var isNvrT8N00 = (ctx) => ctx.model === "T8N00";
3743
3743
  var NVR_MEDIA_CAMERA_MODELS = /* @__PURE__ */ new Set(["T8E00", "T8P00", "T8P10", "T8162", "T8410", "T8214"]);
3744
3744
  var isNvrMediaCamera = (ctx) => ctx.model !== void 0 && NVR_MEDIA_CAMERA_MODELS.has(ctx.model);
3745
3745
  var isNvrDualLensCamera = (ctx) => ctx.model === "T8E00";
3746
+ var isNvrFullHdCamera = (ctx) => ctx.model === "T8E00" || ctx.model === "T8P00";
3746
3747
  var isCallDoorbell = (ctx) => ctx.model === "T8223" || ctx.model === "T8224";
3747
3748
  var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
3748
3749
  var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
@@ -6084,10 +6085,12 @@ function refuseWhenDisabled(ctx, read) {
6084
6085
  if (CAMERA_MEMBERS.enabled.readReflectsWrite(ctx) && read("enabled")?.value === false)
6085
6086
  throw new CameraDisabledError(ctx.name ?? ctx.serial);
6086
6087
  }
6087
- function refuseUnsupportedView(media, opts) {
6088
+ function refuseUnsupportedSelection(media, opts) {
6088
6089
  const view = opts?.view ?? 0;
6089
6090
  if (!(media.views ?? [0]).includes(view))
6090
6091
  throw new RangeError(`camera: unsupported optical view ${view}`);
6092
+ if (opts?.outputResolution !== void 0 && !media.outputResolutions?.includes(opts.outputResolution))
6093
+ throw new RangeError(`camera: unsupported output resolution ${opts.outputResolution}`);
6091
6094
  }
6092
6095
  var CAMERA_MEMBERS = {
6093
6096
  /** Reads the latest cached report and exposes no setter. */
@@ -6376,6 +6379,7 @@ var CAMERA_MEMBERS = {
6376
6379
  write: (v, ctx) => statusLedCommand(asBool(v), ctx)
6377
6380
  },
6378
6381
  views: provided("media", (m) => m.views && (() => m.views), "Optical view identifiers supported by this camera's media route."),
6382
+ outputResolutions: provided("media", (m) => m.outputResolutions && (() => m.outputResolutions), "Output sizes supported by this camera's live media route; omission preserves its default output."),
6379
6383
  /**
6380
6384
  * Media is not a property: it returns DATA rather than moving state, its options are richer than a
6381
6385
  * value, and it exists only on a device bound to a provider. Each is declared once, with its
@@ -6396,12 +6400,12 @@ var CAMERA_MEMBERS = {
6396
6400
  snapshotStored: provided("media", (m, { ctx }) => isCameraCodec(ctx) && m.snapshotStored && (() => m.snapshotStored()), "Latest validated push thumbnail retained in memory.", ["snapshot"]),
6397
6401
  snapshotLive: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && (async (opts) => {
6398
6402
  refuseWhenDisabled(ctx, read);
6399
- refuseUnsupportedView(m, opts);
6403
+ refuseUnsupportedSelection(m, opts);
6400
6404
  return m.snapshotLive({ powered: poweredOf(ctx), ...opts });
6401
6405
  }), "Fresh still decoded from a short live burst."),
6402
6406
  live: provided("media", (m, { ctx, read }) => async (opts) => {
6403
6407
  refuseWhenDisabled(ctx, read);
6404
- refuseUnsupportedView(m, opts);
6408
+ refuseUnsupportedSelection(m, opts);
6405
6409
  return m.live({ powered: poweredOf(ctx), ...opts });
6406
6410
  }, "Open a managed live stream."),
6407
6411
  record: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && (async (...args) => {
@@ -6411,7 +6415,7 @@ var CAMERA_MEMBERS = {
6411
6415
  /** The T8530 serves its live feed as a Readable too. */
6412
6416
  openReadable: provided("media", (m, { ctx, read }) => (isCameraCodec(ctx) || isVideoSmartLock(ctx)) && m.openReadable && (async (opts) => {
6413
6417
  refuseWhenDisabled(ctx, read);
6414
- refuseUnsupportedView(m, opts);
6418
+ refuseUnsupportedSelection(m, opts);
6415
6419
  return m.openReadable({ powered: poweredOf(ctx), ...opts });
6416
6420
  }), "Open a node:stream Readable of the live feed."),
6417
6421
  recordFragments: provided("media", (m, { ctx, read }) => isCameraCodec(ctx) && m.recordFragments && ((opts) => {
@@ -6419,7 +6423,7 @@ var CAMERA_MEMBERS = {
6419
6423
  return m.recordFragments({ powered: poweredOf(ctx), ...opts });
6420
6424
  }), "Continuous fragmented-MP4 (CMAF) recording."),
6421
6425
  /** A T8530 is offered the download behind a HomeBase 3 alone. */
6422
- downloadRecording: provided("media", (m, { ctx }) => (isCameraCodec(ctx) || isVideoSmartLock(ctx) && ctx.stationSerial?.startsWith("T8030") === true) && m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
6426
+ downloadRecording: provided("media", (m, { ctx }) => (isCameraCodec(ctx) || isVideoSmartLock(ctx) && ctx.stationSerial?.startsWith("T8030") === true) && m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 or H.265 and AAC elementary streams."),
6423
6427
  /**
6424
6428
  * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
6425
6429
  * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
@@ -20058,7 +20062,16 @@ function homeBase3RecordingPath(channel, recording) {
20058
20062
  }
20059
20063
  var RECORDING_STATIONS = /* @__PURE__ */ new Map([
20060
20064
  ["T8010", { path: homeBase2RecordingPath, sealed: true, finishChannel: 0, chunkBytes: STATION_CHUNK_BYTES }],
20061
- ["T8030", { path: homeBase3RecordingPath, sealed: false, finishChannel: STATION_CHANNEL3, chunkBytes: 4e5 }]
20065
+ [
20066
+ "T8030",
20067
+ {
20068
+ path: homeBase3RecordingPath,
20069
+ sealed: false,
20070
+ finishChannel: STATION_CHANNEL3,
20071
+ chunkBytes: 4e5,
20072
+ missingCode: -111
20073
+ }
20074
+ ]
20062
20075
  ]);
20063
20076
  function receiveRecording(session, request) {
20064
20077
  let resolveFrames;
@@ -20102,6 +20115,14 @@ function receiveRecording(session, request) {
20102
20115
  collected = [];
20103
20116
  };
20104
20117
  const onData = (frame) => {
20118
+ if (frame.commandId === CommandType.CMD_DOWNLOAD_VIDEO) {
20119
+ if (frame.channel !== request.channel || frame.dataType !== 0 || received || draining)
20120
+ return;
20121
+ if (frame.data.length < 4 || frame.data.readInt32LE(0) !== request.missingCode)
20122
+ return;
20123
+ deliver(new RecordingDownloadError("no-data", `the station holds no recording at ${request.path}`));
20124
+ return stop();
20125
+ }
20105
20126
  if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
20106
20127
  if (frame.channel !== request.channel && frame.channel !== request.finishChannel)
20107
20128
  return;
@@ -20172,6 +20193,7 @@ function decodeRecording(frames, station, eccPrivateKeyHex) {
20172
20193
  const decoder = eccPrivateKey && new VideoFrameDecoder(eccPrivateKey);
20173
20194
  const assembler = new AccessUnitAssembler(void 0, station.chunkBytes);
20174
20195
  const video = [];
20196
+ let codec;
20175
20197
  const audio = [];
20176
20198
  const numbers = /* @__PURE__ */ new Set();
20177
20199
  const receivedNumbers = /* @__PURE__ */ new Set();
@@ -20186,6 +20208,8 @@ function decodeRecording(frames, station, eccPrivateKeyHex) {
20186
20208
  const units = assembler.push(raw, (payload) => decoder && header.keyframe ? decoder.decodeFrame(payload)?.h264 : clearVideoPayload(payload, header));
20187
20209
  for (const unit of units) {
20188
20210
  video.push(unit.data);
20211
+ if (unit.keyframe)
20212
+ codec ??= sniffAnnexbCodec(unit.data);
20189
20213
  numbers.add(header.sequence);
20190
20214
  const stamp = raw.readUIntLE(14, 6);
20191
20215
  firstStamp ??= stamp;
@@ -20214,6 +20238,7 @@ function decodeRecording(frames, station, eccPrivateKeyHex) {
20214
20238
  const durationMs = (lastStamp ?? 0) - (firstStamp ?? 0);
20215
20239
  return {
20216
20240
  video: Buffer.concat(video),
20241
+ ...codec ? { codec } : {},
20217
20242
  ...audio.length ? { audio: Buffer.concat(audio) } : {},
20218
20243
  frames: ordered.length,
20219
20244
  missingFrames,
@@ -23028,6 +23053,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23028
23053
  accountId,
23029
23054
  channel,
23030
23055
  finishChannel: station.finishChannel,
23056
+ missingCode: station.missingCode,
23031
23057
  timeoutMs: opts.timeoutMs,
23032
23058
  signal: opts.signal
23033
23059
  });
@@ -24478,6 +24504,7 @@ var RtcLiveStream = class extends EventEmitter7 {
24478
24504
  audio_chn: nvr ? this.opts.channel : -1,
24479
24505
  streamtype: 2,
24480
24506
  stitch_mode: 1,
24507
+ ...this.opts.outputResolution === "1080p" ? { stitch_resolution: { width: 1920, height: 1080 } } : {},
24481
24508
  chn_list: [{ index: 0, chn: this.opts.channel, sensor: this.opts.view ?? 0, isUps: 0, isClicked: true }],
24482
24509
  pip_cord: { x1: 0, y1: 0, x2: 0, y2: 0 },
24483
24510
  station_video_type: 6,
@@ -25518,13 +25545,14 @@ var RtcSession = class extends EventEmitter11 {
25518
25545
  closeAnnounced = false;
25519
25546
  sdpHandled = false;
25520
25547
  chain = Promise.resolve();
25548
+ stageReached = "signing";
25549
+ lastPeerState;
25521
25550
  constructor(opts) {
25522
25551
  super();
25523
25552
  this.opts = opts;
25524
25553
  this.logger = opts.logger ?? noopLogger;
25525
25554
  this.signaling = (opts.createSignaling ?? ((o) => new RtcSignalingClient(o)))(opts);
25526
25555
  this.peer = (opts.createPeer ?? ((o) => new RtcPeer(o)))({ logger: this.logger, ...opts.peer });
25527
- const mediaTrack = opts.peer?.mediaTrack === true;
25528
25556
  this.signaling.on("message", (inner) => {
25529
25557
  this.chain = this.chain.then(() => this.onSignaling(inner)).catch((e) => {
25530
25558
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
@@ -25538,6 +25566,7 @@ var RtcSession = class extends EventEmitter11 {
25538
25566
  if (this.connected)
25539
25567
  return;
25540
25568
  this.connected = true;
25569
+ this.stageReached = "connected";
25541
25570
  this.logger.debug(`[rtc] ${this.opts.stationSn} command channel open`);
25542
25571
  this.emit("connected");
25543
25572
  });
@@ -25549,9 +25578,14 @@ var RtcSession = class extends EventEmitter11 {
25549
25578
  this.announceClose();
25550
25579
  });
25551
25580
  this.peer.on("connectionState", (state) => {
25552
- if ((state === "failed" || state === "closed") && (this.connected || mediaTrack)) {
25581
+ this.lastPeerState = state;
25582
+ if (state !== "failed" && state !== "closed")
25583
+ return;
25584
+ if (this.connected || this.mediaTrack) {
25553
25585
  this.connected = false;
25554
25586
  this.announceClose();
25587
+ } else {
25588
+ this.shutdown(new Error(`RTC ${this.opts.stationSn} peer ${state} before the command channel opened`));
25555
25589
  }
25556
25590
  });
25557
25591
  this.peer.on("error", (e) => this.emit("error", e));
@@ -25564,9 +25598,20 @@ var RtcSession = class extends EventEmitter11 {
25564
25598
  this.emit("commandData", frame, linkType);
25565
25599
  });
25566
25600
  }
25601
+ get mediaTrack() {
25602
+ return this.opts.peer?.mediaTrack === true;
25603
+ }
25567
25604
  get isConnected() {
25568
25605
  return this.connected;
25569
25606
  }
25607
+ /** The last bring-up stage this session reached. */
25608
+ get stage() {
25609
+ return this.stageReached;
25610
+ }
25611
+ /** The peer connection's last reported state; `undefined` before it reports one. */
25612
+ get peerState() {
25613
+ return this.lastPeerState;
25614
+ }
25570
25615
  /**
25571
25616
  * Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
25572
25617
  * closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
@@ -25576,11 +25621,14 @@ var RtcSession = class extends EventEmitter11 {
25576
25621
  await this.signaling.fetchSign();
25577
25622
  if (this.closed)
25578
25623
  throw new Error("RTC session closed before the call was placed");
25624
+ this.stageReached = "connecting";
25579
25625
  await this.signaling.connect();
25626
+ this.stageReached = "authenticating";
25580
25627
  await this.waitForAuth();
25581
25628
  if (this.closed)
25582
25629
  throw new Error("RTC session closed before the call was placed");
25583
25630
  this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 ${this.opts.signalingMode ?? "scall"}`);
25631
+ this.stageReached = "calling";
25584
25632
  this.signaling.sendCall();
25585
25633
  }
25586
25634
  /** Send one portal packet; false when the command channel isn't open. */
@@ -25676,6 +25724,9 @@ var RtcSession = class extends EventEmitter11 {
25676
25724
  return;
25677
25725
  case "hangup":
25678
25726
  this.logger.debug(`[rtc] ${this.opts.stationSn} hub hung up`);
25727
+ if (!this.connected && this.opts.signalingMode !== "call") {
25728
+ this.shutdown(new Error(`RTC ${this.opts.stationSn} hub hung up before the command channel opened`));
25729
+ }
25679
25730
  return;
25680
25731
  default:
25681
25732
  return;
@@ -25688,6 +25739,7 @@ var RtcSession = class extends EventEmitter11 {
25688
25739
  const status = payload.status;
25689
25740
  if (status === 100 && payload.turn) {
25690
25741
  this.turn = payload.turn;
25742
+ this.stageReached = "relay-granted";
25691
25743
  try {
25692
25744
  await this.peer.init(payload.turn);
25693
25745
  } catch (e) {
@@ -25743,6 +25795,7 @@ var RtcSession = class extends EventEmitter11 {
25743
25795
  }
25744
25796
  const answer = await this.peer.handleRemoteOffer(offer);
25745
25797
  this.signaling.sendInfoSdp(this.opts.signalingMode === "call" ? answer : JSON.stringify(sdpToScallJson(answer)));
25798
+ this.stageReached = "answered";
25746
25799
  this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
25747
25800
  }
25748
25801
  };
@@ -25879,9 +25932,9 @@ var RtcCommandRouter = class {
25879
25932
  return this.callMediaProviderFor(route, route.stationSn, (session) => new RtcCallLiveStream(session, this.deps.logger), `record is not available for ${route.stationSn} over a portal call`, 45e3);
25880
25933
  }
25881
25934
  /** NVR media uses a dedicated call per camera, independent of station commands. */
25882
- nvrMediaProviderFor(route, views) {
25935
+ nvrMediaProviderFor(route, views, outputResolutions) {
25883
25936
  return {
25884
- ...this.callMediaProviderFor(route, route.cameraSn, (session, accountId, view) => new RtcLiveStream({
25937
+ ...this.callMediaProviderFor(route, route.cameraSn, (session, accountId, view, outputResolution) => new RtcLiveStream({
25885
25938
  session,
25886
25939
  seg: new SegmentCounter(),
25887
25940
  stationSn: route.stationSn,
@@ -25889,9 +25942,11 @@ var RtcCommandRouter = class {
25889
25942
  accountId,
25890
25943
  logger: this.deps.logger,
25891
25944
  nvr: true,
25892
- view
25945
+ view,
25946
+ outputResolution
25893
25947
  }), "record is not available over an NVR portal call"),
25894
- views
25948
+ views,
25949
+ outputResolutions
25895
25950
  };
25896
25951
  }
25897
25952
  /**
@@ -25905,7 +25960,8 @@ var RtcCommandRouter = class {
25905
25960
  const sourceFor = (opts = {}) => {
25906
25961
  opts.signal?.throwIfAborted();
25907
25962
  const view = opts.view ?? 0;
25908
- const sourceKey = view === 0 ? key : `${key}:view:${view}`;
25963
+ const viewKey = view === 0 ? key : `${key}:view:${view}`;
25964
+ const sourceKey = opts.outputResolution ? `${viewKey}:output:${opts.outputResolution}` : viewKey;
25909
25965
  let source = this.callSources.get(sourceKey);
25910
25966
  if (!source) {
25911
25967
  source = new SharedLiveSource({
@@ -25914,7 +25970,7 @@ var RtcCommandRouter = class {
25914
25970
  if (!identity)
25915
25971
  throw new Error(`rtc: not logged in, cannot call ${route.stationSn}`);
25916
25972
  const accountId = route.adminUserId || identity.userId;
25917
- return makeStream(this.newSession(route, accountId, identity, this.callShards.get(key)), accountId, view);
25973
+ return makeStream(this.newSession(route, accountId, identity, this.callShards.get(key)), accountId, view, opts.outputResolution);
25918
25974
  },
25919
25975
  label: sourceKey,
25920
25976
  logger: this.deps.logger,
@@ -26218,7 +26274,8 @@ var RtcCommandRouter = class {
26218
26274
  /**
26219
26275
  * One bounded bring-up: the session is up when its command channel opens, and it fails, with the
26220
26276
  * session closed and every listener gone, when `connect()` throws, the session closes first (rejecting
26221
- * with the failure that closed it, when it carries one), or {@link CONNECT_TIMEOUT_MS} passes. A single
26277
+ * with the failure that closed it, when it carries one), or {@link CONNECT_TIMEOUT_MS} passes, naming the
26278
+ * stage the session reached and its peer's last state. A single
26222
26279
  * promise, so an early failure cannot leave a second one rejecting unheard.
26223
26280
  */
26224
26281
  bringUp(sn, session) {
@@ -26244,7 +26301,7 @@ var RtcCommandRouter = class {
26244
26301
  };
26245
26302
  const onConnected = () => finish();
26246
26303
  const onClose = (err) => finish(err ?? new Error(`rtc: ${sn} session closed before the command channel opened`));
26247
- const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms`)), CONNECT_TIMEOUT_MS2);
26304
+ const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms (stage ${session.stage}, peer ${session.peerState ?? "not started"})`)), CONNECT_TIMEOUT_MS2);
26248
26305
  session.once("connected", onConnected);
26249
26306
  session.once("close", onClose);
26250
26307
  Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
@@ -30765,7 +30822,7 @@ var EufyMega = class _EufyMega extends EventEmitter14 {
30765
30822
  if (route.signalingMode === "call") {
30766
30823
  if (!camera || !isNvrMediaCamera(camera))
30767
30824
  return void 0;
30768
- return this.rtc.nvrMediaProviderFor({ ...route, cameraSn: sn, channel }, isNvrDualLensCamera(camera) ? [0, 1] : [0]);
30825
+ return this.rtc.nvrMediaProviderFor({ ...route, cameraSn: sn, channel }, isNvrDualLensCamera(camera) ? [0, 1] : [0], isNvrFullHdCamera(camera) ? ["1080p"] : void 0);
30769
30826
  }
30770
30827
  return this.rtc.mediaProviderFor({ ...route, cameraSn: sn, channel });
30771
30828
  }