@camstack/addon-provider-dreame 0.2.92 → 0.2.94

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.
Files changed (3) hide show
  1. package/dist/addon.js +174 -11
  2. package/dist/addon.mjs +174 -11
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -72605,7 +72605,17 @@ var EventDensityBucketSchema = object({
72605
72605
  bucketStart: number(),
72606
72606
  motion: number().int(),
72607
72607
  object: number().int(),
72608
- audio: number().int()
72608
+ audio: number().int(),
72609
+ /**
72610
+ * Energy-weighted mean dBFS of the audio events in this bucket — the LEVEL,
72611
+ * as opposed to `audio`, which is their COUNT (D431).
72612
+ *
72613
+ * ABSENT when the bucket holds no audio event. Absence means "nothing was
72614
+ * measured", which is not the same claim as silence, and a lane that cannot
72615
+ * tell them apart paints a quiet hour at full scale — which is exactly what
72616
+ * the count-on-a-level-meter did.
72617
+ */
72618
+ audioDbfs: number().optional()
72609
72619
  });
72610
72620
  /**
72611
72621
  * One camera's row in a `getEventDensityBatch` answer.
@@ -82793,7 +82803,23 @@ var RecordingRangeSchema = object({
82793
82803
  });
82794
82804
  var RecordingAvailabilitySchema = object({
82795
82805
  deviceId: number(),
82796
- ranges: array(RecordingRangeSchema)
82806
+ ranges: array(RecordingRangeSchema),
82807
+ /**
82808
+ * Every profile this camera has footage in — not only the one `ranges`
82809
+ * describes (D433).
82810
+ *
82811
+ * `ranges` answers for ONE profile by design: the timeline is a single bar,
82812
+ * and enumerating all of them triples the directory reads for a bar that
82813
+ * would look identical. But the answer used to say nothing about that, so a
82814
+ * caller asking "what can I export?" read the single preferred profile as
82815
+ * the only one that exists — on this deployment every camera records `high`
82816
+ * AND `low`, the low tree holding 14 GB for camera 615 alone, and the export
82817
+ * UI could not see it.
82818
+ *
82819
+ * Cheap: it is the same shallow directory read the profile CHOICE already
82820
+ * makes. Ask for a specific profile's ranges with the `profile` input.
82821
+ */
82822
+ profilesWithFootage: array(string())
82797
82823
  });
82798
82824
  var RecordingDaysSchema = object({
82799
82825
  deviceId: number(),
@@ -82819,7 +82845,11 @@ var RecordingDaysSchema = object({
82819
82845
  var RecordingAvailabilityForDeviceSchema = object({
82820
82846
  deviceId: number(),
82821
82847
  read: _enum(["read", "unreadable"]),
82822
- ranges: array(RecordingRangeSchema).readonly()
82848
+ ranges: array(RecordingRangeSchema).readonly(),
82849
+ /** Same field, same meaning, as the singular answer (D433). A row that
82850
+ * dropped it would tell a grid caller the archive holds one profile.
82851
+ * Empty on an `'unreadable'` row: nobody looked. */
82852
+ profilesWithFootage: array(string()).readonly()
82823
82853
  });
82824
82854
  /**
82825
82855
  * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
@@ -83109,7 +83139,14 @@ var ReadWindowBytesResultSchema = discriminatedUnion("kind", [object({
83109
83139
  method(object({
83110
83140
  deviceId: number(),
83111
83141
  fromMs: number(),
83112
- toMs: number()
83142
+ toMs: number(),
83143
+ /**
83144
+ * Answer for THIS profile instead of the preferred one (D433). Absent
83145
+ * keeps the timeline's behaviour — one bar, one profile, one set of
83146
+ * reads. `profilesWithFootage` on the answer says what may be asked
83147
+ * for.
83148
+ */
83149
+ profile: string().optional()
83113
83150
  }), RecordingAvailabilitySchema, {
83114
83151
  kind: "query",
83115
83152
  auth: "protected"
@@ -98475,7 +98512,7 @@ function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_E
98475
98512
  function resolveNavigationFeatures(overrides) {
98476
98513
  return {
98477
98514
  ...DREAME_NAVIGATION_DEFAULT_FEATURES,
98478
- ...overrides?.features ?? {}
98515
+ ...overrides?.features
98479
98516
  };
98480
98517
  }
98481
98518
  /** The disabled entry ids in force for ONE device: the addon set plus its own. */
@@ -98585,7 +98622,7 @@ var DreameNetworkLinkMonitor = class {
98585
98622
  meta: {
98586
98623
  reason,
98587
98624
  rawBars: bars,
98588
- ...next ?? {}
98625
+ ...next
98589
98626
  }
98590
98627
  });
98591
98628
  this.reported = true;
@@ -144141,9 +144178,28 @@ var { mulaw, alaw } = resolveAlawmulaw();
144141
144178
  * name, and WE own the TCP socket — when the broker's reader detaches we know
144142
144179
  * the viewer is gone and release the monitor at once, instead of polling the
144143
144180
  * broker for consumers or waiting on a duration cap.
144181
+ *
144182
+ * And we own the FEED: the relay dies in three ways nodedreame reports and one
144183
+ * it cannot — the socket errors (`error`; with no listener an EventEmitter
144184
+ * throws, and on 2026-09-09 a `read ETIMEDOUT` took the whole provider down),
144185
+ * the relay closes and the stream stops itself (`stopped`), or the socket goes
144186
+ * silent for minutes before the kernel gives up (nothing at all: 16 min of a
144187
+ * broker dialling a port that accepted and sent nothing). The server reports
144188
+ * every one of them ONCE through `onFeedLost`, and never its own close, so the
144189
+ * device can put the lazy sentinel back and let the next dial be a wake (D424).
144144
144190
  */
144145
144191
  var VIDEO_PT = 96;
144146
144192
  var AUDIO_PT = 97;
144193
+ /**
144194
+ * How long the feed may go without a video access unit before the relay is
144195
+ * declared dead. The broker gives a source 8 s (`RTP_ACTIVITY_TIMEOUT_MS`);
144196
+ * this sits just past it so a stall is judged by the side that can act on it.
144197
+ */
144198
+ var FEED_STALL_MS = 1e4;
144199
+ var FEED_STALL_SWEEP_MS = 1e3;
144200
+ function describeLoss(loss) {
144201
+ return loss.error === null ? loss.reason : `${loss.reason}: ${loss.error.message}`;
144202
+ }
144147
144203
  function deferred() {
144148
144204
  let resolve = () => void 0;
144149
144205
  return {
@@ -144176,11 +144232,37 @@ function consoleInto(logger) {
144176
144232
  * server calls `onConsumerDisconnect` when the last reader detaches.
144177
144233
  */
144178
144234
  async function startRfc4571Server(params) {
144179
- const { stream, logger, onConsumerDisconnect, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3 } = params;
144235
+ const { stream, logger, onConsumerDisconnect, onFeedLost, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3, stallTimeoutMs = FEED_STALL_MS } = params;
144180
144236
  const videoRef = { current: null };
144181
144237
  const audioRef = { current: null };
144182
144238
  const firstKeyframe = deferred();
144183
144239
  const firstAudio = deferred();
144240
+ const firstLoss = deferred();
144241
+ let pendingLoss = null;
144242
+ let toreDown = false;
144243
+ let audioFrames = 0;
144244
+ let videoAccessUnits = 0;
144245
+ let lastAudioAt = 0;
144246
+ let audioStallReported = false;
144247
+ let serving = false;
144248
+ let lastVideoAt = 0;
144249
+ const reportLoss = (loss) => {
144250
+ if (toreDown || pendingLoss !== null) return;
144251
+ pendingLoss = loss;
144252
+ firstLoss.resolve(loss);
144253
+ if (serving) onFeedLost(loss);
144254
+ };
144255
+ stream.on("error", (err) => reportLoss({
144256
+ reason: "error",
144257
+ error: err
144258
+ }));
144259
+ stream.on("stopped", () => reportLoss({
144260
+ reason: "stopped",
144261
+ error: null
144262
+ }));
144263
+ stream.on("videoAccessUnit", () => {
144264
+ lastVideoAt = Date.now();
144265
+ });
144184
144266
  stream.on("videoAccessUnit", (e) => {
144185
144267
  if (videoRef.current !== null) return;
144186
144268
  const p = extractH264ParamSetsFromAccessUnit(e.data);
@@ -144206,15 +144288,24 @@ async function startRfc4571Server(params) {
144206
144288
  }
144207
144289
  });
144208
144290
  await stream.start();
144291
+ const lostBeforeServing = firstLoss.promise.then((loss) => {
144292
+ throw new Error(`relay lost before the first keyframe (${describeLoss(loss)})`);
144293
+ });
144209
144294
  try {
144210
- await withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe");
144295
+ await Promise.race([withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe"), lostBeforeServing]);
144211
144296
  } catch (err) {
144212
144297
  await stream.stop().catch(() => void 0);
144213
144298
  throw err;
144214
144299
  }
144300
+ const graceStartedAt = Date.now();
144215
144301
  await Promise.race([firstAudio.promise, new Promise((resolve) => {
144216
144302
  setTimeout(resolve, audioGraceMs).unref?.();
144217
144303
  })]);
144304
+ const waitedMs = Date.now() - graceStartedAt;
144305
+ if (pendingLoss !== null) {
144306
+ await stream.stop().catch(() => void 0);
144307
+ throw new Error(`relay lost before the server was up (${describeLoss(pendingLoss)})`);
144308
+ }
144218
144309
  const paramSets = videoRef.current;
144219
144310
  if (paramSets === null) throw new Error("no parameter sets after the first keyframe");
144220
144311
  const audioFacts = audioRef.current;
@@ -144230,11 +144321,40 @@ async function startRfc4571Server(params) {
144230
144321
  configHex: audioFacts.configHex
144231
144322
  } : void 0);
144232
144323
  const muxer = new Rfc4571Muxer(consoleInto(logger), VIDEO_PT, audioFacts !== null ? AUDIO_PT : void 0);
144233
- stream.on("videoAccessUnit", (e) => muxer.sendVideoAccessUnit("H264", e.data, e.isKeyframe, e.microseconds));
144234
- if (audioFacts !== null) stream.on("audioFrame", (adts) => muxer.sendAudioAdtsFrame(adts));
144324
+ stream.on("videoAccessUnit", (e) => {
144325
+ videoAccessUnits += 1;
144326
+ muxer.sendVideoAccessUnit("H264", e.data, e.isKeyframe, e.microseconds);
144327
+ });
144328
+ if (audioFacts !== null) {
144329
+ logger.info("rfc4571: serving audio", { meta: {
144330
+ codec: "aac",
144331
+ sampleRate: audioFacts.sampleRate,
144332
+ channels: audioFacts.channels,
144333
+ waitedMs
144334
+ } });
144335
+ lastAudioAt = Date.now();
144336
+ stream.on("audioFrame", (adts) => {
144337
+ audioFrames += 1;
144338
+ lastAudioAt = Date.now();
144339
+ muxer.sendAudioAdtsFrame(adts);
144340
+ });
144341
+ } else {
144342
+ logger.info("rfc4571: no audio within the grace — serving video-only", { meta: {
144343
+ waitedMs,
144344
+ audioGraceMs
144345
+ } });
144346
+ let reportedLate = false;
144347
+ stream.on("audioFrame", () => {
144348
+ if (reportedLate) return;
144349
+ reportedLate = true;
144350
+ logger.warn("rfc4571: audio arrived after the grace — this session stays video-only", { meta: {
144351
+ lateByMs: Date.now() - graceStartedAt - waitedMs,
144352
+ audioGraceMs
144353
+ } });
144354
+ });
144355
+ }
144235
144356
  let liveClients = 0;
144236
144357
  let idleTimer = null;
144237
- let toreDown = false;
144238
144358
  const server = node_net.default.createServer((socket) => {
144239
144359
  liveClients += 1;
144240
144360
  if (idleTimer !== null) clearTimeout(idleTimer);
@@ -144261,6 +144381,24 @@ async function startRfc4571Server(params) {
144261
144381
  });
144262
144382
  const addr = server.address();
144263
144383
  if (addr === null || typeof addr === "string") throw new Error("failed to bind the RFC4571 server");
144384
+ const stallSweep = setInterval(() => {
144385
+ if (toreDown) return;
144386
+ if (audioFacts !== null && !audioStallReported && Date.now() - lastAudioAt >= stallTimeoutMs) {
144387
+ audioStallReported = true;
144388
+ logger.warn("rfc4571: audio stopped while the video kept flowing", { meta: {
144389
+ audioFrames,
144390
+ videoAccessUnits,
144391
+ silentForMs: Date.now() - lastAudioAt
144392
+ } });
144393
+ }
144394
+ if (Date.now() - lastVideoAt < stallTimeoutMs) return;
144395
+ reportLoss({
144396
+ reason: "stalled",
144397
+ error: null
144398
+ });
144399
+ }, Math.max(5, Math.min(FEED_STALL_SWEEP_MS, Math.floor(stallTimeoutMs / 2))));
144400
+ stallSweep.unref?.();
144401
+ serving = true;
144264
144402
  return {
144265
144403
  host: bindHost,
144266
144404
  port: addr.port,
@@ -144272,6 +144410,12 @@ async function startRfc4571Server(params) {
144272
144410
  } : null,
144273
144411
  close: async () => {
144274
144412
  toreDown = true;
144413
+ logger.info("rfc4571: session ended", { meta: {
144414
+ videoAccessUnits,
144415
+ audioFrames,
144416
+ declaredAudio: audioFacts !== null
144417
+ } });
144418
+ clearInterval(stallSweep);
144275
144419
  if (idleTimer !== null) clearTimeout(idleTimer);
144276
144420
  try {
144277
144421
  muxer.close();
@@ -144551,6 +144695,9 @@ var DreameCameraDevice = class extends DreameChildDevice {
144551
144695
  keyframeTimeoutMs: WAKE_TIMEOUT_MS,
144552
144696
  onConsumerDisconnect: () => {
144553
144697
  this.closeStream("idle");
144698
+ },
144699
+ onFeedLost: (loss) => {
144700
+ this.onFeedLost(loss);
144554
144701
  }
144555
144702
  });
144556
144703
  this.live = {
@@ -144590,6 +144737,22 @@ var DreameCameraDevice = class extends DreameChildDevice {
144590
144737
  });
144591
144738
  return this.opening;
144592
144739
  }
144740
+ /**
144741
+ * The feed died under an open monitor. The session is closed — the lazy
144742
+ * sentinel goes back on the broker — so the broker's next dial asks for a
144743
+ * materialize, which is the wake. Logged: this branch drops a live stream.
144744
+ */
144745
+ onFeedLost(loss) {
144746
+ this.ctx.logger.warn("robot camera: relay feed lost — closing the monitor; the next dial re-opens it", {
144747
+ tags: { deviceId: this.id },
144748
+ meta: {
144749
+ reason: loss.reason,
144750
+ error: loss.error === null ? null : errMsg(loss.error),
144751
+ openMs: Date.now() - this.sessionOpenedAt
144752
+ }
144753
+ });
144754
+ this.closeStream("feed-lost");
144755
+ }
144593
144756
  armMaxDuration() {
144594
144757
  if (this.maxTimer !== null) clearTimeout(this.maxTimer);
144595
144758
  this.maxTimer = setTimeout(() => {
package/dist/addon.mjs CHANGED
@@ -72602,7 +72602,17 @@ var EventDensityBucketSchema = object({
72602
72602
  bucketStart: number(),
72603
72603
  motion: number().int(),
72604
72604
  object: number().int(),
72605
- audio: number().int()
72605
+ audio: number().int(),
72606
+ /**
72607
+ * Energy-weighted mean dBFS of the audio events in this bucket — the LEVEL,
72608
+ * as opposed to `audio`, which is their COUNT (D431).
72609
+ *
72610
+ * ABSENT when the bucket holds no audio event. Absence means "nothing was
72611
+ * measured", which is not the same claim as silence, and a lane that cannot
72612
+ * tell them apart paints a quiet hour at full scale — which is exactly what
72613
+ * the count-on-a-level-meter did.
72614
+ */
72615
+ audioDbfs: number().optional()
72606
72616
  });
72607
72617
  /**
72608
72618
  * One camera's row in a `getEventDensityBatch` answer.
@@ -82790,7 +82800,23 @@ var RecordingRangeSchema = object({
82790
82800
  });
82791
82801
  var RecordingAvailabilitySchema = object({
82792
82802
  deviceId: number(),
82793
- ranges: array(RecordingRangeSchema)
82803
+ ranges: array(RecordingRangeSchema),
82804
+ /**
82805
+ * Every profile this camera has footage in — not only the one `ranges`
82806
+ * describes (D433).
82807
+ *
82808
+ * `ranges` answers for ONE profile by design: the timeline is a single bar,
82809
+ * and enumerating all of them triples the directory reads for a bar that
82810
+ * would look identical. But the answer used to say nothing about that, so a
82811
+ * caller asking "what can I export?" read the single preferred profile as
82812
+ * the only one that exists — on this deployment every camera records `high`
82813
+ * AND `low`, the low tree holding 14 GB for camera 615 alone, and the export
82814
+ * UI could not see it.
82815
+ *
82816
+ * Cheap: it is the same shallow directory read the profile CHOICE already
82817
+ * makes. Ask for a specific profile's ranges with the `profile` input.
82818
+ */
82819
+ profilesWithFootage: array(string())
82794
82820
  });
82795
82821
  var RecordingDaysSchema = object({
82796
82822
  deviceId: number(),
@@ -82816,7 +82842,11 @@ var RecordingDaysSchema = object({
82816
82842
  var RecordingAvailabilityForDeviceSchema = object({
82817
82843
  deviceId: number(),
82818
82844
  read: _enum(["read", "unreadable"]),
82819
- ranges: array(RecordingRangeSchema).readonly()
82845
+ ranges: array(RecordingRangeSchema).readonly(),
82846
+ /** Same field, same meaning, as the singular answer (D433). A row that
82847
+ * dropped it would tell a grid caller the archive holds one profile.
82848
+ * Empty on an `'unreadable'` row: nobody looked. */
82849
+ profilesWithFootage: array(string()).readonly()
82820
82850
  });
82821
82851
  /**
82822
82852
  * One camera's row in a `getDaysWithRecordingsBatch` answer. Same rule as
@@ -83106,7 +83136,14 @@ var ReadWindowBytesResultSchema = discriminatedUnion("kind", [object({
83106
83136
  method(object({
83107
83137
  deviceId: number(),
83108
83138
  fromMs: number(),
83109
- toMs: number()
83139
+ toMs: number(),
83140
+ /**
83141
+ * Answer for THIS profile instead of the preferred one (D433). Absent
83142
+ * keeps the timeline's behaviour — one bar, one profile, one set of
83143
+ * reads. `profilesWithFootage` on the answer says what may be asked
83144
+ * for.
83145
+ */
83146
+ profile: string().optional()
83110
83147
  }), RecordingAvailabilitySchema, {
83111
83148
  kind: "query",
83112
83149
  auth: "protected"
@@ -98472,7 +98509,7 @@ function dreameNavigationCatalog(disabledEntryIds = DREAME_NAVIGATION_DISABLED_E
98472
98509
  function resolveNavigationFeatures(overrides) {
98473
98510
  return {
98474
98511
  ...DREAME_NAVIGATION_DEFAULT_FEATURES,
98475
- ...overrides?.features ?? {}
98512
+ ...overrides?.features
98476
98513
  };
98477
98514
  }
98478
98515
  /** The disabled entry ids in force for ONE device: the addon set plus its own. */
@@ -98582,7 +98619,7 @@ var DreameNetworkLinkMonitor = class {
98582
98619
  meta: {
98583
98620
  reason,
98584
98621
  rawBars: bars,
98585
- ...next ?? {}
98622
+ ...next
98586
98623
  }
98587
98624
  });
98588
98625
  this.reported = true;
@@ -144138,9 +144175,28 @@ var { mulaw, alaw } = resolveAlawmulaw();
144138
144175
  * name, and WE own the TCP socket — when the broker's reader detaches we know
144139
144176
  * the viewer is gone and release the monitor at once, instead of polling the
144140
144177
  * broker for consumers or waiting on a duration cap.
144178
+ *
144179
+ * And we own the FEED: the relay dies in three ways nodedreame reports and one
144180
+ * it cannot — the socket errors (`error`; with no listener an EventEmitter
144181
+ * throws, and on 2026-09-09 a `read ETIMEDOUT` took the whole provider down),
144182
+ * the relay closes and the stream stops itself (`stopped`), or the socket goes
144183
+ * silent for minutes before the kernel gives up (nothing at all: 16 min of a
144184
+ * broker dialling a port that accepted and sent nothing). The server reports
144185
+ * every one of them ONCE through `onFeedLost`, and never its own close, so the
144186
+ * device can put the lazy sentinel back and let the next dial be a wake (D424).
144141
144187
  */
144142
144188
  var VIDEO_PT = 96;
144143
144189
  var AUDIO_PT = 97;
144190
+ /**
144191
+ * How long the feed may go without a video access unit before the relay is
144192
+ * declared dead. The broker gives a source 8 s (`RTP_ACTIVITY_TIMEOUT_MS`);
144193
+ * this sits just past it so a stall is judged by the side that can act on it.
144194
+ */
144195
+ var FEED_STALL_MS = 1e4;
144196
+ var FEED_STALL_SWEEP_MS = 1e3;
144197
+ function describeLoss(loss) {
144198
+ return loss.error === null ? loss.reason : `${loss.reason}: ${loss.error.message}`;
144199
+ }
144144
144200
  function deferred() {
144145
144201
  let resolve = () => void 0;
144146
144202
  return {
@@ -144173,11 +144229,37 @@ function consoleInto(logger) {
144173
144229
  * server calls `onConsumerDisconnect` when the last reader detaches.
144174
144230
  */
144175
144231
  async function startRfc4571Server(params) {
144176
- const { stream, logger, onConsumerDisconnect, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3 } = params;
144232
+ const { stream, logger, onConsumerDisconnect, onFeedLost, bindHost = "0.0.0.0", keyframeTimeoutMs = 2e4, audioGraceMs = 1500, idleGraceMs = 4e3, stallTimeoutMs = FEED_STALL_MS } = params;
144177
144233
  const videoRef = { current: null };
144178
144234
  const audioRef = { current: null };
144179
144235
  const firstKeyframe = deferred();
144180
144236
  const firstAudio = deferred();
144237
+ const firstLoss = deferred();
144238
+ let pendingLoss = null;
144239
+ let toreDown = false;
144240
+ let audioFrames = 0;
144241
+ let videoAccessUnits = 0;
144242
+ let lastAudioAt = 0;
144243
+ let audioStallReported = false;
144244
+ let serving = false;
144245
+ let lastVideoAt = 0;
144246
+ const reportLoss = (loss) => {
144247
+ if (toreDown || pendingLoss !== null) return;
144248
+ pendingLoss = loss;
144249
+ firstLoss.resolve(loss);
144250
+ if (serving) onFeedLost(loss);
144251
+ };
144252
+ stream.on("error", (err) => reportLoss({
144253
+ reason: "error",
144254
+ error: err
144255
+ }));
144256
+ stream.on("stopped", () => reportLoss({
144257
+ reason: "stopped",
144258
+ error: null
144259
+ }));
144260
+ stream.on("videoAccessUnit", () => {
144261
+ lastVideoAt = Date.now();
144262
+ });
144181
144263
  stream.on("videoAccessUnit", (e) => {
144182
144264
  if (videoRef.current !== null) return;
144183
144265
  const p = extractH264ParamSetsFromAccessUnit(e.data);
@@ -144203,15 +144285,24 @@ async function startRfc4571Server(params) {
144203
144285
  }
144204
144286
  });
144205
144287
  await stream.start();
144288
+ const lostBeforeServing = firstLoss.promise.then((loss) => {
144289
+ throw new Error(`relay lost before the first keyframe (${describeLoss(loss)})`);
144290
+ });
144206
144291
  try {
144207
- await withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe");
144292
+ await Promise.race([withTimeout(firstKeyframe.promise, keyframeTimeoutMs, "timed out waiting for the first keyframe"), lostBeforeServing]);
144208
144293
  } catch (err) {
144209
144294
  await stream.stop().catch(() => void 0);
144210
144295
  throw err;
144211
144296
  }
144297
+ const graceStartedAt = Date.now();
144212
144298
  await Promise.race([firstAudio.promise, new Promise((resolve) => {
144213
144299
  setTimeout(resolve, audioGraceMs).unref?.();
144214
144300
  })]);
144301
+ const waitedMs = Date.now() - graceStartedAt;
144302
+ if (pendingLoss !== null) {
144303
+ await stream.stop().catch(() => void 0);
144304
+ throw new Error(`relay lost before the server was up (${describeLoss(pendingLoss)})`);
144305
+ }
144215
144306
  const paramSets = videoRef.current;
144216
144307
  if (paramSets === null) throw new Error("no parameter sets after the first keyframe");
144217
144308
  const audioFacts = audioRef.current;
@@ -144227,11 +144318,40 @@ async function startRfc4571Server(params) {
144227
144318
  configHex: audioFacts.configHex
144228
144319
  } : void 0);
144229
144320
  const muxer = new Rfc4571Muxer(consoleInto(logger), VIDEO_PT, audioFacts !== null ? AUDIO_PT : void 0);
144230
- stream.on("videoAccessUnit", (e) => muxer.sendVideoAccessUnit("H264", e.data, e.isKeyframe, e.microseconds));
144231
- if (audioFacts !== null) stream.on("audioFrame", (adts) => muxer.sendAudioAdtsFrame(adts));
144321
+ stream.on("videoAccessUnit", (e) => {
144322
+ videoAccessUnits += 1;
144323
+ muxer.sendVideoAccessUnit("H264", e.data, e.isKeyframe, e.microseconds);
144324
+ });
144325
+ if (audioFacts !== null) {
144326
+ logger.info("rfc4571: serving audio", { meta: {
144327
+ codec: "aac",
144328
+ sampleRate: audioFacts.sampleRate,
144329
+ channels: audioFacts.channels,
144330
+ waitedMs
144331
+ } });
144332
+ lastAudioAt = Date.now();
144333
+ stream.on("audioFrame", (adts) => {
144334
+ audioFrames += 1;
144335
+ lastAudioAt = Date.now();
144336
+ muxer.sendAudioAdtsFrame(adts);
144337
+ });
144338
+ } else {
144339
+ logger.info("rfc4571: no audio within the grace — serving video-only", { meta: {
144340
+ waitedMs,
144341
+ audioGraceMs
144342
+ } });
144343
+ let reportedLate = false;
144344
+ stream.on("audioFrame", () => {
144345
+ if (reportedLate) return;
144346
+ reportedLate = true;
144347
+ logger.warn("rfc4571: audio arrived after the grace — this session stays video-only", { meta: {
144348
+ lateByMs: Date.now() - graceStartedAt - waitedMs,
144349
+ audioGraceMs
144350
+ } });
144351
+ });
144352
+ }
144232
144353
  let liveClients = 0;
144233
144354
  let idleTimer = null;
144234
- let toreDown = false;
144235
144355
  const server = net.createServer((socket) => {
144236
144356
  liveClients += 1;
144237
144357
  if (idleTimer !== null) clearTimeout(idleTimer);
@@ -144258,6 +144378,24 @@ async function startRfc4571Server(params) {
144258
144378
  });
144259
144379
  const addr = server.address();
144260
144380
  if (addr === null || typeof addr === "string") throw new Error("failed to bind the RFC4571 server");
144381
+ const stallSweep = setInterval(() => {
144382
+ if (toreDown) return;
144383
+ if (audioFacts !== null && !audioStallReported && Date.now() - lastAudioAt >= stallTimeoutMs) {
144384
+ audioStallReported = true;
144385
+ logger.warn("rfc4571: audio stopped while the video kept flowing", { meta: {
144386
+ audioFrames,
144387
+ videoAccessUnits,
144388
+ silentForMs: Date.now() - lastAudioAt
144389
+ } });
144390
+ }
144391
+ if (Date.now() - lastVideoAt < stallTimeoutMs) return;
144392
+ reportLoss({
144393
+ reason: "stalled",
144394
+ error: null
144395
+ });
144396
+ }, Math.max(5, Math.min(FEED_STALL_SWEEP_MS, Math.floor(stallTimeoutMs / 2))));
144397
+ stallSweep.unref?.();
144398
+ serving = true;
144261
144399
  return {
144262
144400
  host: bindHost,
144263
144401
  port: addr.port,
@@ -144269,6 +144407,12 @@ async function startRfc4571Server(params) {
144269
144407
  } : null,
144270
144408
  close: async () => {
144271
144409
  toreDown = true;
144410
+ logger.info("rfc4571: session ended", { meta: {
144411
+ videoAccessUnits,
144412
+ audioFrames,
144413
+ declaredAudio: audioFacts !== null
144414
+ } });
144415
+ clearInterval(stallSweep);
144272
144416
  if (idleTimer !== null) clearTimeout(idleTimer);
144273
144417
  try {
144274
144418
  muxer.close();
@@ -144548,6 +144692,9 @@ var DreameCameraDevice = class extends DreameChildDevice {
144548
144692
  keyframeTimeoutMs: WAKE_TIMEOUT_MS,
144549
144693
  onConsumerDisconnect: () => {
144550
144694
  this.closeStream("idle");
144695
+ },
144696
+ onFeedLost: (loss) => {
144697
+ this.onFeedLost(loss);
144551
144698
  }
144552
144699
  });
144553
144700
  this.live = {
@@ -144587,6 +144734,22 @@ var DreameCameraDevice = class extends DreameChildDevice {
144587
144734
  });
144588
144735
  return this.opening;
144589
144736
  }
144737
+ /**
144738
+ * The feed died under an open monitor. The session is closed — the lazy
144739
+ * sentinel goes back on the broker — so the broker's next dial asks for a
144740
+ * materialize, which is the wake. Logged: this branch drops a live stream.
144741
+ */
144742
+ onFeedLost(loss) {
144743
+ this.ctx.logger.warn("robot camera: relay feed lost — closing the monitor; the next dial re-opens it", {
144744
+ tags: { deviceId: this.id },
144745
+ meta: {
144746
+ reason: loss.reason,
144747
+ error: loss.error === null ? null : errMsg(loss.error),
144748
+ openMs: Date.now() - this.sessionOpenedAt
144749
+ }
144750
+ });
144751
+ this.closeStream("feed-lost");
144752
+ }
144590
144753
  armMaxDuration() {
144591
144754
  if (this.maxTimer !== null) clearTimeout(this.maxTimer);
144592
144755
  this.maxTimer = setTimeout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-dreame",
3
- "version": "0.2.92",
3
+ "version": "0.2.94",
4
4
  "description": "Dreame robot-vacuum / lawn-mower device-provider addon for CamStack — wraps the @apocaliss92/nodedreame Dreamehome cloud client",
5
5
  "keywords": [
6
6
  "camstack",