@camstack/addon-pipeline 1.2.374 → 1.2.601

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +3 -3
  2. package/dist/audio-analyzer/index.mjs +3 -3
  3. package/dist/{custom-model-catalog-1LDuDw8S.mjs → custom-model-catalog-CK9GK3N5.mjs} +2 -2
  4. package/dist/{custom-model-catalog-W0DHDhfo.js → custom-model-catalog-DgyafyuW.js} +2 -2
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-CmdLCb_F.mjs → dist-BaHsDOWa.mjs} +1124 -29
  8. package/dist/{dist-CBEUVjTS.js → dist-BqI10eLV.js} +1129 -28
  9. package/dist/{model-catalogs-BnCdB2A3.mjs → model-catalogs-DM5sVj1T.mjs} +1 -1
  10. package/dist/{model-catalogs-D5YksA5A.js → model-catalogs-DhGVt_sH.js} +1 -1
  11. package/dist/motion-wasm/index.js +1 -1
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/{node-C3r8kiOo.mjs → node-B0a0dXhg.mjs} +38 -3
  14. package/dist/{node-DMgi6pND.js → node-CgwqjGz0.js} +42 -1
  15. package/dist/pipeline-runner/index.js +4 -4
  16. package/dist/pipeline-runner/index.mjs +4 -4
  17. package/dist/{process-memory-GFR7fxpJ.js → process-memory-C0zbWvwD.js} +1 -1
  18. package/dist/{process-memory-X0ws1SGJ.mjs → process-memory-sfdcLVkW.mjs} +1 -1
  19. package/dist/recorder/index.js +357 -516
  20. package/dist/recorder/index.mjs +357 -516
  21. package/dist/{segment-demux-js-CSQVhbFI.js → segment-demux-js-B9lL2VCC.js} +1 -1
  22. package/dist/{segment-demux-js-IKEO1XBz.mjs → segment-demux-js-BB4CUAB5.mjs} +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPFTlVoW.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbN9DjoK.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Boq8JqyD.mjs +26 -0
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-B25q1OKG.mjs +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-BOp1_Hhk.mjs → hostInit-BSYHAfPt.mjs} +3 -3
  30. package/dist/stream-broker/index.js +556 -32
  31. package/dist/stream-broker/index.mjs +556 -32
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/package.json +1 -1
  34. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B7wsxLrK.mjs +0 -26
  35. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BohOb2Cl.mjs +0 -26
@@ -1,9 +1,9 @@
1
1
  import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
2
- import { An as errMsg, At as gifFromMp4Args, Bt as memoizePinnedFfmpeg, D as G711_SCALE_CORRECTION_DB, Dn as isSoftwareDecode, Dt as ffmpegRate, En as invocationFromEncodeProfile, Ft as loadContributionCapability, Gn as isSecretMarker, I as RATE_CONTROL_RELAXED, It as logChannelsCapability, J as addonWidgetsSourceCapability, Jn as maskUrlCredentials, Kn as makeProfileBrokerId, L as RATE_CONTROL_TIGHT, Mn as BaseAddon, Nn as CAM_PROFILE_ORDER, On as DeviceFeature, Pn as CamProfileSchema, Qt as rankStreams, Rn as createEvent, Vn as expandAudioChunkToF32le, W as RingBuffer, Wn as isEvent, Xn as parseProfileBrokerId, Yn as nodePin, Z as armRemovalReconcile, an as resolveEgressDecodeHwAccel, ar as literal, bn as webrtcSessionCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, dr as union, dt as declareLogChannel, er as _enum, fr as EventCategory, gn as streamBrokerCapability, it as cameraStreamsCapability, jn as BOOT_RECOVERY_BACKOFF_MS, kn as DeviceType, kt as gifDeriveSpeed, lr as record, lt as createHwAccelCache, nr as boolean, o as BatteryStatusSchema, or as number, ot as clipExportFailureOf, pt as deriveBatteryPresence, qn as makeSourceBrokerId, rr as discriminatedUnion, s as CLIP_BROKER_PACED_RATES, sr as object, tr as array, tt as batteryCapability, ur as string, ut as createLogChannelsProvider, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, wn as buildFfmpegArgs, xn as AUDIO_PRESETS, yt as egressTranscodeSharingKey } from "../dist-CmdLCb_F.mjs";
3
- import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C3r8kiOo.mjs";
2
+ import { An as errMsg, At as gifFromMp4Args, Bt as memoizePinnedFfmpeg, D as G711_SCALE_CORRECTION_DB, Dn as isSoftwareDecode, Dt as ffmpegRate, En as invocationFromEncodeProfile, Ft as loadContributionCapability, Gn as isEvent, Hn as expandAudioChunkToF32le, I as RATE_CONTROL_RELAXED, It as logChannelsCapability, J as addonWidgetsSourceCapability, Jn as makeSourceBrokerId, Kn as isSecretMarker, L as RATE_CONTROL_TIGHT, Mn as BaseAddon, Nn as CAM_PROFILE_ORDER, On as DeviceFeature, Pn as CamProfileSchema, Qt as rankStreams, Rn as createEvent, W as RingBuffer, Xn as nodePin, Yn as maskUrlCredentials, Z as armRemovalReconcile, Zn as parseProfileBrokerId, an as resolveEgressDecodeHwAccel, bn as webrtcSessionCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, cr as object, dr as string, dt as declareLogChannel, fr as union, gn as streamBrokerCapability, ir as discriminatedUnion, it as cameraStreamsCapability, jn as BOOT_RECOVERY_BACKOFF_MS, kn as DeviceType, kt as gifDeriveSpeed, lt as createHwAccelCache, nr as array, o as BatteryStatusSchema, or as literal, ot as clipExportFailureOf, pr as EventCategory, pt as deriveBatteryPresence, qn as makeProfileBrokerId, rr as boolean, s as CLIP_BROKER_PACED_RATES, sr as number, tr as _enum, tt as batteryCapability, ur as record, ut as createLogChannelsProvider, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, wn as buildFfmpegArgs, xn as AUDIO_PRESETS, yt as egressTranscodeSharingKey } from "../dist-BaHsDOWa.mjs";
3
+ import { a as defaultFfmpegSpawn, i as NO_COST_CLAIM, n as Fmp4FragmentChild, r as Fmp4FragmentPlane, s as probeFfmpegBinary, t as ChildCostRegistry } from "../node-B0a0dXhg.mjs";
4
4
  import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
5
5
  import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
6
- import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-IKEO1XBz.mjs";
6
+ import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-BB4CUAB5.mjs";
7
7
  import { createRequire } from "node:module";
8
8
  import * as os$1 from "node:os";
9
9
  import { networkInterfaces, tmpdir } from "node:os";
@@ -854,6 +854,131 @@ function healthEventIsOperatorFacing(camStreamId) {
854
854
  return !PRIVATE_STREAM_PREFIXES.some((prefix) => camStreamId.startsWith(prefix));
855
855
  }
856
856
  //#endregion
857
+ //#region src/stream-broker/stream-broker/stream-health-policy.ts
858
+ /**
859
+ * WHAT a stale stream means for a battery camera (D883).
860
+ *
861
+ * A mains camera whose stream goes quiet for the stale window has lost its
862
+ * stream: that is an outage. A battery camera whose stream goes quiet has,
863
+ * ordinarily, gone to sleep — the designed steady state, not a fault. The
864
+ * broker used to tell the two apart by nothing, so every sleep produced a
865
+ * `stream.offline` per profile and every wake a `stream.online`, and the
866
+ * operator's system-event rule pushed each one.
867
+ *
868
+ * The verdict is made HERE, at the emitter, from the battery's own sleep
869
+ * mirror — never by a filter downstream:
870
+ *
871
+ * - asleep and nobody asked for the stream — NOT an outage (`asleep`);
872
+ * - asleep, a stream was DEMANDED and none arrived — `wake-timeout`;
873
+ * - reported awake and the stream is gone — `connection-error`;
874
+ * - silent past the battery refresh window — `battery-unreachable`.
875
+ *
876
+ * A sleep verdict needs a sleep state that was actually READ. A mirror that
877
+ * was never fed (a restart, a dropped event) is not "asleep": the caller passes
878
+ * `battery: null` and the stream is judged exactly as a mains one (D49 —
879
+ * unknown excludes nothing, and a silent suppression on an unread mirror hid
880
+ * a camera that slept and then died).
881
+ */
882
+ var OUTAGE_REASONS = [
883
+ "stale-timeout",
884
+ "wake-timeout",
885
+ "connection-error",
886
+ "battery-unreachable"
887
+ ];
888
+ /**
889
+ * How long a demand may wait for a sleeping camera's stream before it counts
890
+ * as a failed wake: the firmware wake bound plus the broker settle window of
891
+ * wake-on-play (8 s + 20 s), with margin for the sweep cadence.
892
+ */
893
+ var WAKE_DEMAND_BOUND_MS = 45e3;
894
+ function isStreamOutageReason(reason) {
895
+ return reason !== void 0 && OUTAGE_REASONS.includes(reason);
896
+ }
897
+ function judgeStaleStream(input) {
898
+ if (input.battery === null) return {
899
+ kind: "outage",
900
+ reason: "stale-timeout"
901
+ };
902
+ const presence = deriveBatteryPresence({
903
+ status: input.battery.facts,
904
+ nowMs: input.nowMs
905
+ });
906
+ if (presence === "unreachable") return {
907
+ kind: "outage",
908
+ reason: "battery-unreachable"
909
+ };
910
+ if (presence === "awake") return {
911
+ kind: "outage",
912
+ reason: "connection-error"
913
+ };
914
+ if (input.demanded) return {
915
+ kind: "outage",
916
+ reason: "wake-timeout"
917
+ };
918
+ return {
919
+ kind: "suppressed",
920
+ presence
921
+ };
922
+ }
923
+ //#endregion
924
+ //#region src/stream-broker/stream-broker/device-stream-outages.ts
925
+ var DeviceStreamOutages = class {
926
+ byDevice = /* @__PURE__ */ new Map();
927
+ get(deviceId) {
928
+ return this.byDevice.get(deviceId);
929
+ }
930
+ /** Open the device's outage, or add this stream to the one already open. */
931
+ open(deviceId, camStreamId, reason, now) {
932
+ const existing = this.byDevice.get(deviceId);
933
+ if (existing) {
934
+ existing.camStreamIds.add(camStreamId);
935
+ existing.wakeOnly = false;
936
+ existing.lastSignalAt = now;
937
+ return existing;
938
+ }
939
+ const created = {
940
+ reason,
941
+ since: now,
942
+ camStreamIds: new Set([camStreamId]),
943
+ announced: false,
944
+ wakeOnly: false,
945
+ lastSignalAt: now
946
+ };
947
+ this.byDevice.set(deviceId, created);
948
+ return created;
949
+ }
950
+ /**
951
+ * A failed wake with no broker to carry it. Joins the device's open outage
952
+ * (one outage per device) or opens a wake-only one over its REAL streams.
953
+ */
954
+ openWake(deviceId, camStreamIds, reason, now) {
955
+ const existing = this.byDevice.get(deviceId);
956
+ if (existing) {
957
+ existing.lastSignalAt = now;
958
+ return existing;
959
+ }
960
+ const created = {
961
+ reason,
962
+ since: now,
963
+ camStreamIds: new Set(camStreamIds),
964
+ announced: false,
965
+ wakeOnly: true,
966
+ lastSignalAt: now
967
+ };
968
+ this.byDevice.set(deviceId, created);
969
+ return created;
970
+ }
971
+ entries() {
972
+ return this.byDevice.entries();
973
+ }
974
+ close(deviceId) {
975
+ this.byDevice.delete(deviceId);
976
+ }
977
+ clear() {
978
+ this.byDevice.clear();
979
+ }
980
+ };
981
+ //#endregion
857
982
  //#region src/stream-broker/stream-broker/sentry-eligibility.ts
858
983
  function sentryEligible(input) {
859
984
  if (input.batteryOperated === null || input.declaresSignals === null) return {
@@ -1737,7 +1862,7 @@ var FfmpegPushReader = class {
1737
1862
  this.ffmpegPath = options.ffmpegPath;
1738
1863
  this.args = options.args;
1739
1864
  this.logger = options.logger;
1740
- this.spawnFn = options.spawnFn ?? spawn;
1865
+ this.spawnFn = options.spawnFn ?? defaultFfmpegSpawn;
1741
1866
  this.callbacks = callbacks;
1742
1867
  this.logLabel = logLabel;
1743
1868
  this.claimCost = options.claimCost;
@@ -7646,7 +7771,7 @@ var EgressTranscodeManager = class {
7646
7771
  const egress = new TranscodeEgress({
7647
7772
  logger: this.opts.logger.child("egress"),
7648
7773
  ffmpegBinaryPath,
7649
- spawnFn: this.opts.spawnFn ?? spawn,
7774
+ spawnFn: this.opts.spawnFn ?? defaultFfmpegSpawn,
7650
7775
  acquireChannel: () => channel,
7651
7776
  ...spec.onDied ? { onChildExit: spec.onDied } : {},
7652
7777
  claimCost: (pid) => this.opts.costRegistry?.claim({
@@ -7681,7 +7806,7 @@ var EgressTranscodeManager = class {
7681
7806
  const child = new Fmp4FragmentChild({
7682
7807
  logger: this.opts.logger.child("egress-fmp4"),
7683
7808
  ffmpegBinaryPath,
7684
- spawnFn: this.opts.spawnFn ?? spawn,
7809
+ spawnFn: this.opts.spawnFn ?? defaultFfmpegSpawn,
7685
7810
  ...this.opts.fragmentFirstUnitTimeoutMs === void 0 ? {} : { firstUnitTimeoutMs: this.opts.fragmentFirstUnitTimeoutMs },
7686
7811
  ...spec.onDied ? { onChildExit: spec.onDied } : {}
7687
7812
  }, {
@@ -14551,6 +14676,15 @@ var StreamBroker = class StreamBroker {
14551
14676
  return false;
14552
14677
  }
14553
14678
  /**
14679
+ * Does a consumer want this stream right now (a viewer, a recording, a
14680
+ * detection decode, a camera-reported wake hold)? Sentry sessions and idle
14681
+ * prestream do not count. The stream-health sweep reads it to tell a battery
14682
+ * camera that slept unasked from one that failed to wake for somebody (D883).
14683
+ */
14684
+ hasLiveDemand() {
14685
+ return this.hasLiveIntent();
14686
+ }
14687
+ /**
14554
14688
  * True when at least one consumer needs DECODED audio. The audio-specific
14555
14689
  * subset of {@link hasDemand} — deliberately excludes video-only
14556
14690
  * consumers so a video-only viewer never spins up the AAC/Opus decode
@@ -16780,7 +16914,7 @@ var TranscodePipelineManager = class {
16780
16914
  this.onFfmpegMissing = opts.onFfmpegMissing ?? null;
16781
16915
  this.probeFfmpeg = opts.probeFfmpeg ?? probeFfmpegBinary;
16782
16916
  this.resolveStreamHwAccel = opts.resolveStreamHwAccel ?? null;
16783
- this.spawnFn = opts.spawnFn ?? spawn;
16917
+ this.spawnFn = opts.spawnFn ?? defaultFfmpegSpawn;
16784
16918
  this.acquireChannel = opts.acquireChannel ?? null;
16785
16919
  this.sentryOffered = opts.sentryOffered ?? null;
16786
16920
  }
@@ -17450,6 +17584,10 @@ function resolveBrokerTier(brokerId, localLookup, remoteBrokerTiers) {
17450
17584
  */
17451
17585
  var STREAM_STALE_TIMEOUT_MS = 12e4;
17452
17586
  var STREAM_HEALTH_POLL_MS = 15e3;
17587
+ /** How often an unanswered / stale battery probe is asked again. */
17588
+ var BATTERY_PROBE_RETRY_MS = 6e4;
17589
+ /** A wake-only outage with no further failed wake for this long is over: the demand is gone. */
17590
+ var WAKE_ONLY_OUTAGE_TTL_MS = 5 * 6e4;
17453
17591
  /** Compose a SOURCE broker id from a `(deviceId, camStreamId)` tuple. Thin
17454
17592
  * wrapper over the canonical `makeSourceBrokerId` — single source of truth. */
17455
17593
  function brokerIdFor(deviceId, camStreamId) {
@@ -17642,6 +17780,25 @@ var StreamBrokerManager = class StreamBrokerManager {
17642
17780
  * guess which one it was looking at.
17643
17781
  */
17644
17782
  streamOfflineReasonByBroker = /* @__PURE__ */ new Map();
17783
+ /**
17784
+ * Brokers whose stream went quiet because their BATTERY camera is asleep.
17785
+ * Not an outage and never announced (D883); remembered so the wake that ends
17786
+ * it is recognised as a plain wake instead of a `recovered`.
17787
+ */
17788
+ sleepSuppressedBrokers = /* @__PURE__ */ new Set();
17789
+ /**
17790
+ * What the sweep knows of a device's battery: is it one, and what did its
17791
+ * `battery` slice last say. Fed by `BatteryOnStatusChanged` (droppable, empty
17792
+ * after a restart) AND read from the device registry / slice by the sweep
17793
+ * itself — the same two reads wake-on-play makes — so a restart or a lost
17794
+ * event never leaves the verdict standing on an unread mirror (D49).
17795
+ */
17796
+ batteryProbe = /* @__PURE__ */ new Map();
17797
+ batteryProbeInFlight = /* @__PURE__ */ new Set();
17798
+ /** When a consumer's demand on a quiet battery stream was first seen (the wake bound starts there). */
17799
+ demandSince = /* @__PURE__ */ new Map();
17800
+ /** The stream outage a DEVICE is in (one entry per device, however many profiles). */
17801
+ deviceOutages = new DeviceStreamOutages();
17645
17802
  streamHealthTimer;
17646
17803
  /**
17647
17804
  * Routes an audio-chunk `subscriptionId` (Phase 5 / D9) back to its owning
@@ -18582,6 +18739,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18582
18739
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
18583
18740
  if (deviceId === null) return;
18584
18741
  const sleeping = data.status?.sleeping === true;
18742
+ this.recordBatterySleep(deviceId, data.status);
18585
18743
  this.applyPlaceholderReasonToDevice(deviceId, sleeping ? "sleeping" : "reconnecting");
18586
18744
  });
18587
18745
  bus.subscribe({ category: EventCategory.BatteryOnWakeStarted }, (event) => {
@@ -18745,18 +18903,7 @@ var StreamBrokerManager = class StreamBrokerManager {
18745
18903
  await this.batteryWakeOnPlay.run(deviceId, {
18746
18904
  logger: this.logger,
18747
18905
  isBatteryDevice: (id) => this.isBatteryOperated(id),
18748
- readBatteryFacts: async (id) => {
18749
- const slice = await api.deviceState.getCapSlice.query({
18750
- deviceId: id,
18751
- capName: batteryCapability.name
18752
- }).catch(() => null);
18753
- const parsed = BatteryStatusSchema.safeParse(slice);
18754
- if (!parsed.success) return null;
18755
- return {
18756
- sleeping: parsed.data.sleeping,
18757
- lastContactAt: parsed.data.lastContactAt
18758
- };
18759
- },
18906
+ readBatteryFacts: (id) => this.readBatterySleepFacts(id),
18760
18907
  wakeForStream: async (id, timeoutMs) => api.battery.wakeForStream.mutate({
18761
18908
  deviceId: id,
18762
18909
  timeoutMs
@@ -18764,6 +18911,9 @@ var StreamBrokerManager = class StreamBrokerManager {
18764
18911
  reconcileCatalog: (id) => this.reconcileDeviceCatalog(id),
18765
18912
  ensureBroker: () => this.ensureBrokerForWebrtcSession(deviceId, brokerId, camStreamId),
18766
18913
  hasBroker: demandHasBroker
18914
+ }).catch((err) => {
18915
+ if (err instanceof BatteryStreamUnavailableError) this.announceBatteryWakeFailure(deviceId, camStreamId, err.presence === "unreachable" ? "battery-unreachable" : "wake-timeout");
18916
+ throw err;
18767
18917
  });
18768
18918
  }
18769
18919
  /** Any broker (local or remote-adapter) registered for this device? The
@@ -20387,6 +20537,10 @@ var StreamBrokerManager = class StreamBrokerManager {
20387
20537
  this.brokers.clear();
20388
20538
  this.streamHealthByBroker.clear();
20389
20539
  this.streamOfflineReasonByBroker.clear();
20540
+ this.sleepSuppressedBrokers.clear();
20541
+ this.batteryProbe.clear();
20542
+ this.demandSince.clear();
20543
+ this.deviceOutages.clear();
20390
20544
  this.audioSubscriptionBroker.clear();
20391
20545
  await this.rtspServer.stop();
20392
20546
  }
@@ -20409,6 +20563,8 @@ var StreamBrokerManager = class StreamBrokerManager {
20409
20563
  evaluateStreamHealth() {
20410
20564
  if (!this.eventBus) return;
20411
20565
  const now = Date.now();
20566
+ const outageOpenedOn = /* @__PURE__ */ new Map();
20567
+ const cameBackOn = /* @__PURE__ */ new Map();
20412
20568
  for (const [brokerId, broker] of this.brokers) {
20413
20569
  const parsed = this.parseBrokerId(brokerId);
20414
20570
  if (!parsed) continue;
@@ -20418,30 +20574,386 @@ var StreamBrokerManager = class StreamBrokerManager {
20418
20574
  const wasHealthy = this.streamHealthByBroker.get(brokerId);
20419
20575
  const isHealthy = lastPacketAt > 0 && now - lastPacketAt <= STREAM_STALE_TIMEOUT_MS;
20420
20576
  if (!isHealthy && broker.getStats().status === "streaming") broker.redial("stream-health sweep: phantom streaming, no packets within stale window");
20421
- if (wasHealthy === isHealthy) continue;
20577
+ const context = {
20578
+ deviceId,
20579
+ camStreamId,
20580
+ brokerId,
20581
+ sourceType: broker.getActiveSourceType(),
20582
+ lastPacketAt
20583
+ };
20584
+ if (wasHealthy === isHealthy) {
20585
+ if (!isHealthy) this.rejudgeQuietBroker(brokerId, broker, context, now, outageOpenedOn);
20586
+ continue;
20587
+ }
20422
20588
  if (wasHealthy === void 0 && !isHealthy && !broker.suspended) {
20423
20589
  this.streamHealthByBroker.set(brokerId, false);
20424
20590
  continue;
20425
20591
  }
20592
+ let verdict = null;
20593
+ if (!isHealthy && !broker.suspended) {
20594
+ verdict = this.judgeQuietStream(brokerId, deviceId, broker, now);
20595
+ if (verdict === "defer") continue;
20596
+ }
20426
20597
  this.streamHealthByBroker.set(brokerId, isHealthy);
20427
- const reason = isHealthy ? wasHealthy === void 0 || !this.streamOfflineReasonByBroker.has(brokerId) ? "first-packet" : this.streamOfflineReasonByBroker.get(brokerId) === "suspended" ? "resumed" : "recovered" : broker.suspended ? "suspended" : "stale-timeout";
20428
- if (isHealthy) this.streamOfflineReasonByBroker.delete(brokerId);
20429
- else this.streamOfflineReasonByBroker.set(brokerId, reason);
20430
- this.emitStreamHealth(isHealthy, {
20598
+ const wasSleepSuppressed = this.sleepSuppressedBrokers.delete(brokerId);
20599
+ const previousReason = this.streamOfflineReasonByBroker.get(brokerId);
20600
+ if (verdict !== null) {
20601
+ if (verdict.kind === "suppressed") {
20602
+ this.markSleepSuppressed(brokerId, context, verdict.presence, lastPacketAt);
20603
+ continue;
20604
+ }
20605
+ this.openStreamOutage(brokerId, context, verdict.reason, now, outageOpenedOn);
20606
+ continue;
20607
+ }
20608
+ if (!isHealthy) {
20609
+ this.demandSince.delete(brokerId);
20610
+ this.streamOfflineReasonByBroker.set(brokerId, "suspended");
20611
+ this.emitStreamHealth(false, {
20612
+ ...context,
20613
+ reason: "suspended"
20614
+ });
20615
+ continue;
20616
+ }
20617
+ this.streamOfflineReasonByBroker.delete(brokerId);
20618
+ this.demandSince.delete(brokerId);
20619
+ if (wasSleepSuppressed) this.logger.info("battery camera woke and delivers a stream — plain wake, no event", {
20620
+ tags: { deviceId },
20621
+ meta: { camStreamId }
20622
+ });
20623
+ if (this.deviceOutages.get(deviceId) !== void 0) {
20624
+ if (!cameBackOn.has(deviceId)) cameBackOn.set(deviceId, context);
20625
+ continue;
20626
+ }
20627
+ if (wasSleepSuppressed) {
20628
+ this.writeCameraStreamsSlice(deviceId, this.snapshotProfileSlots(deviceId));
20629
+ continue;
20630
+ }
20631
+ const reason = isStreamOutageReason(previousReason) ? "recovered" : previousReason === "suspended" ? "resumed" : "first-packet";
20632
+ this.emitStreamHealth(true, {
20633
+ ...context,
20634
+ reason
20635
+ });
20636
+ }
20637
+ for (const [deviceId, context] of outageOpenedOn) this.announceDeviceOutage(deviceId, context);
20638
+ for (const [deviceId, context] of cameBackOn) this.settleDeviceOutage(deviceId, context);
20639
+ this.expireWakeOnlyOutages(now);
20640
+ }
20641
+ /** Record one stream as part of its device's outage (announced after the pass). */
20642
+ openStreamOutage(brokerId, context, reason, now, outageOpenedOn) {
20643
+ this.sleepSuppressedBrokers.delete(brokerId);
20644
+ this.streamOfflineReasonByBroker.set(brokerId, reason);
20645
+ this.deviceOutages.open(context.deviceId, context.camStreamId, reason, now);
20646
+ if (!outageOpenedOn.has(context.deviceId)) outageOpenedOn.set(context.deviceId, context);
20647
+ }
20648
+ markSleepSuppressed(brokerId, context, presence, lastPacketAt) {
20649
+ this.sleepSuppressedBrokers.add(brokerId);
20650
+ this.streamOfflineReasonByBroker.delete(brokerId);
20651
+ this.logger.info("stream quiet on a sleeping battery camera — not an outage", {
20652
+ tags: { deviceId: context.deviceId },
20653
+ meta: {
20654
+ camStreamId: context.camStreamId,
20655
+ presence,
20656
+ lastPacketAt
20657
+ }
20658
+ });
20659
+ this.writeCameraStreamsSlice(context.deviceId, this.snapshotProfileSlots(context.deviceId));
20660
+ }
20661
+ /**
20662
+ * The verdict on a quiet, non-idle stream. `'defer'` = the device's battery
20663
+ * class is still being asked of the registry; the edge is kept and judged
20664
+ * again. A class that could not be answered, or a slice never read, judges as
20665
+ * a mains camera — never as a sleeper (D49).
20666
+ */
20667
+ judgeQuietStream(brokerId, deviceId, broker, now) {
20668
+ const probe = this.ensureBatteryProbe(deviceId, now);
20669
+ if (probe.state === "pending") return "defer";
20670
+ return judgeStaleStream({
20671
+ battery: probe.isBattery === true && probe.facts !== null ? { facts: probe.facts } : null,
20672
+ demanded: this.demandHeldPastBound(brokerId, broker, now),
20673
+ nowMs: now
20674
+ });
20675
+ }
20676
+ demandHeldPastBound(brokerId, broker, now) {
20677
+ if (!broker.hasLiveDemand()) {
20678
+ this.demandSince.delete(brokerId);
20679
+ return false;
20680
+ }
20681
+ const since = this.demandSince.get(brokerId) ?? now;
20682
+ this.demandSince.set(brokerId, since);
20683
+ return now - since >= WAKE_DEMAND_BOUND_MS;
20684
+ }
20685
+ /**
20686
+ * A broker that stays quiet, pass after pass. Three things can be true of it:
20687
+ * it was suppressed as a sleeper (the camera may have died, or a wake may
20688
+ * have been demanded since); it went idle while holding an outage open; or it
20689
+ * resumed from an idle suspend on a battery camera that never answered.
20690
+ */
20691
+ rejudgeQuietBroker(brokerId, broker, context, now, outageOpenedOn) {
20692
+ const { deviceId } = context;
20693
+ const reason = this.streamOfflineReasonByBroker.get(brokerId);
20694
+ if (isStreamOutageReason(reason) && broker.suspended) {
20695
+ this.dropSuspendedOutage(brokerId, context);
20696
+ return;
20697
+ }
20698
+ const suppressed = this.sleepSuppressedBrokers.has(brokerId);
20699
+ const resumedQuiet = reason === "suspended" && !broker.suspended;
20700
+ if (!suppressed && !resumedQuiet) return;
20701
+ if (broker.suspended) {
20702
+ this.demandSince.delete(brokerId);
20703
+ return;
20704
+ }
20705
+ this.refreshBatteryFactsIfDue(deviceId, now);
20706
+ const probe = this.batteryProbe.get(deviceId);
20707
+ if (probe?.state !== "done" || probe.isBattery !== true || probe.facts === null) return;
20708
+ const verdict = judgeStaleStream({
20709
+ battery: { facts: probe.facts },
20710
+ demanded: this.demandHeldPastBound(brokerId, broker, now),
20711
+ nowMs: now
20712
+ });
20713
+ if (verdict.kind === "suppressed") return;
20714
+ this.logger.warn("quiet battery stream is now an outage", {
20715
+ tags: { deviceId },
20716
+ meta: {
20717
+ camStreamId: context.camStreamId,
20718
+ reason: verdict.reason
20719
+ }
20720
+ });
20721
+ this.openStreamOutage(brokerId, context, verdict.reason, now, outageOpenedOn);
20722
+ }
20723
+ /**
20724
+ * An outage stream whose last consumer left (the broker went idle). Nobody is
20725
+ * waiting for it any more, so the outage ends there — closed cleanly, with
20726
+ * the one online that lets every consumer clear its row (reason `suspended`,
20727
+ * which the notification gate discards).
20728
+ */
20729
+ dropSuspendedOutage(brokerId, context) {
20730
+ const { deviceId, camStreamId } = context;
20731
+ this.demandSince.delete(brokerId);
20732
+ this.streamOfflineReasonByBroker.set(brokerId, "suspended");
20733
+ const outage = this.deviceOutages.get(deviceId);
20734
+ this.logger.info("stream outage dropped — the broker went idle, nobody is waiting", {
20735
+ tags: { deviceId },
20736
+ meta: { camStreamId }
20737
+ });
20738
+ if (!outage) return;
20739
+ outage.camStreamIds.delete(camStreamId);
20740
+ if (outage.camStreamIds.size > 0 || outage.wakeOnly) return;
20741
+ this.closeDeviceOutage(deviceId, outage, "suspended", context);
20742
+ }
20743
+ /** Close a device outage and, when it was announced, say so once (any online closes the rows). */
20744
+ closeDeviceOutage(deviceId, outage, reason, context) {
20745
+ this.deviceOutages.close(deviceId);
20746
+ if (!outage.announced) return;
20747
+ this.emitDeviceStreamHealth(true, deviceId, reason, /* @__PURE__ */ new Set(), context);
20748
+ }
20749
+ /** Wake-only outages end when the failed wakes stop (demand released). */
20750
+ expireWakeOnlyOutages(now) {
20751
+ for (const [deviceId, outage] of [...this.deviceOutages.entries()]) {
20752
+ if (!outage.wakeOnly || now - outage.lastSignalAt < WAKE_ONLY_OUTAGE_TTL_MS) continue;
20753
+ this.logger.info("wake failure outage ended — no further demand", {
20754
+ tags: { deviceId },
20755
+ meta: { reason: outage.reason }
20756
+ });
20757
+ this.closeDeviceOutage(deviceId, outage, "suspended", {
20431
20758
  deviceId,
20759
+ brokerId: String(deviceId),
20760
+ sourceType: "battery-wake",
20761
+ lastPacketAt: 0
20762
+ });
20763
+ }
20764
+ }
20765
+ /** The device's battery probe; starts the read when there is none (or a retry is due). */
20766
+ ensureBatteryProbe(deviceId, now) {
20767
+ const existing = this.batteryProbe.get(deviceId);
20768
+ if (existing === void 0) {
20769
+ if (!this.api) {
20770
+ const none = {
20771
+ state: "done",
20772
+ isBattery: null,
20773
+ facts: null,
20774
+ readAt: now
20775
+ };
20776
+ this.batteryProbe.set(deviceId, none);
20777
+ return none;
20778
+ }
20779
+ const pending = {
20780
+ state: "pending",
20781
+ isBattery: null,
20782
+ facts: null,
20783
+ readAt: now
20784
+ };
20785
+ this.batteryProbe.set(deviceId, pending);
20786
+ this.probeBattery(deviceId);
20787
+ return pending;
20788
+ }
20789
+ if (existing.state === "done" && existing.isBattery === null && this.api && now - existing.readAt >= BATTERY_PROBE_RETRY_MS && !this.batteryProbeInFlight.has(deviceId)) this.probeBattery(deviceId);
20790
+ return existing;
20791
+ }
20792
+ async probeBattery(deviceId) {
20793
+ this.batteryProbeInFlight.add(deviceId);
20794
+ try {
20795
+ const isBattery = await this.isBatteryOperated(deviceId);
20796
+ const facts = isBattery === true ? await this.readBatterySleepFacts(deviceId) : null;
20797
+ const current = this.batteryProbe.get(deviceId);
20798
+ this.batteryProbe.set(deviceId, {
20799
+ state: "done",
20800
+ isBattery: current?.isBattery === true ? true : isBattery,
20801
+ facts: current?.facts ?? facts,
20802
+ readAt: Date.now()
20803
+ });
20804
+ } finally {
20805
+ this.batteryProbeInFlight.delete(deviceId);
20806
+ }
20807
+ }
20808
+ refreshBatteryFactsIfDue(deviceId, now) {
20809
+ const probe = this.batteryProbe.get(deviceId);
20810
+ if (probe?.state !== "done" || probe.isBattery !== true) return;
20811
+ if (now - probe.readAt < BATTERY_PROBE_RETRY_MS || this.batteryProbeInFlight.has(deviceId)) return;
20812
+ this.batteryProbeInFlight.add(deviceId);
20813
+ this.readBatterySleepFacts(deviceId).then((facts) => {
20814
+ const current = this.batteryProbe.get(deviceId);
20815
+ this.batteryProbe.set(deviceId, {
20816
+ state: "done",
20817
+ isBattery: true,
20818
+ facts: facts ?? current?.facts ?? null,
20819
+ readAt: Date.now()
20820
+ });
20821
+ }).finally(() => this.batteryProbeInFlight.delete(deviceId));
20822
+ }
20823
+ /** The device's `battery` slice as sleep facts, or `null` when it cannot be read. */
20824
+ async readBatterySleepFacts(deviceId) {
20825
+ const api = this.api;
20826
+ if (!api) return null;
20827
+ const slice = await api.deviceState.getCapSlice.query({
20828
+ deviceId,
20829
+ capName: batteryCapability.name
20830
+ }).catch(() => null);
20831
+ const parsed = BatteryStatusSchema.safeParse(slice);
20832
+ if (!parsed.success) return null;
20833
+ return {
20834
+ sleeping: parsed.data.sleeping,
20835
+ lastContactAt: parsed.data.lastContactAt
20836
+ };
20837
+ }
20838
+ recordBatterySleep(deviceId, status) {
20839
+ const previous = this.batteryProbe.get(deviceId);
20840
+ const facts = status && typeof status.sleeping === "boolean" ? {
20841
+ sleeping: status.sleeping,
20842
+ ...typeof status.lastContactAt === "number" ? { lastContactAt: status.lastContactAt } : {}
20843
+ } : null;
20844
+ this.batteryProbe.set(deviceId, {
20845
+ state: "done",
20846
+ isBattery: true,
20847
+ facts: facts ?? previous?.facts ?? null,
20848
+ readAt: Date.now()
20849
+ });
20850
+ }
20851
+ /**
20852
+ * A released broker can no longer carry an outage. When it was the last
20853
+ * stream holding an ANNOUNCED outage open, the outage is closed with an
20854
+ * online (`broker-destroyed`, which the notification gate discards but every
20855
+ * ledger and alert card clears on) — otherwise the row stays open for ever.
20856
+ */
20857
+ forgetReleasedStreamOutage(context) {
20858
+ const { deviceId, camStreamId } = context;
20859
+ const outage = this.deviceOutages.get(deviceId);
20860
+ if (!outage || !outage.camStreamIds.delete(camStreamId)) return;
20861
+ if (outage.camStreamIds.size > 0) return;
20862
+ this.logger.info("stream outage closed — its last stream was released", {
20863
+ tags: { deviceId },
20864
+ meta: {
20865
+ reason: outage.reason,
20432
20866
  camStreamId,
20433
- profile: findProfileForStream(this.assignments.get(deviceId)?.map ?? {}, camStreamId),
20434
- brokerId,
20435
- sourceType: broker.getActiveSourceType(),
20436
- lastPacketAt,
20437
- reason
20867
+ announced: outage.announced
20868
+ }
20869
+ });
20870
+ this.closeDeviceOutage(deviceId, outage, "broker-destroyed", context);
20871
+ }
20872
+ /** The ONE `stream.offline` of a device outage, naming every stream it covers. */
20873
+ announceDeviceOutage(deviceId, context) {
20874
+ const outage = this.deviceOutages.get(deviceId);
20875
+ if (!outage) return;
20876
+ if (outage.announced) {
20877
+ this.logger.debug("stream outage joined an already announced one — no second event", {
20878
+ tags: { deviceId },
20879
+ meta: {
20880
+ camStreamIds: [...outage.camStreamIds],
20881
+ reason: outage.reason
20882
+ }
20438
20883
  });
20884
+ return;
20885
+ }
20886
+ outage.announced = true;
20887
+ this.logger.warn("stream outage", {
20888
+ tags: { deviceId },
20889
+ meta: {
20890
+ reason: outage.reason,
20891
+ camStreamIds: [...outage.camStreamIds]
20892
+ }
20893
+ });
20894
+ this.emitDeviceStreamHealth(false, deviceId, outage.reason, outage.camStreamIds, context);
20895
+ }
20896
+ /**
20897
+ * A broker of a device in outage is delivering again. The device is back when
20898
+ * no stream of it is still down for an outage reason; then, and only then,
20899
+ * the single `stream.online` (`recovered`) goes out.
20900
+ */
20901
+ settleDeviceOutage(deviceId, context) {
20902
+ const outage = this.deviceOutages.get(deviceId);
20903
+ if (!outage) return;
20904
+ for (const camStreamId of [...outage.camStreamIds]) {
20905
+ const brokerId = brokerIdFor(deviceId, camStreamId);
20906
+ if (!isStreamOutageReason(this.streamOfflineReasonByBroker.get(brokerId))) outage.camStreamIds.delete(camStreamId);
20439
20907
  }
20908
+ if (outage.camStreamIds.size > 0) return;
20909
+ this.deviceOutages.close(deviceId);
20910
+ if (!outage.announced) return;
20911
+ this.logger.info("stream outage over — device delivers again", {
20912
+ tags: { deviceId },
20913
+ meta: {
20914
+ reason: outage.reason,
20915
+ outageMs: Date.now() - outage.since
20916
+ }
20917
+ });
20918
+ this.emitDeviceStreamHealth(true, deviceId, "recovered", new Set(context.camStreamId !== void 0 ? [context.camStreamId] : []), context);
20919
+ }
20920
+ /**
20921
+ * A wake somebody ASKED for did not produce a stream (the viewer's play
20922
+ * button, found no broker and a wake that settled nothing). This is the one
20923
+ * failure with no broker to carry it, so it opens the device outage itself.
20924
+ */
20925
+ announceBatteryWakeFailure(deviceId, camStreamId, reason) {
20926
+ if (!this.eventBus) return;
20927
+ const published = [...this.cameraStreams.get(deviceId)?.keys() ?? []];
20928
+ const streamIds = camStreamId !== void 0 && published.includes(camStreamId) ? [camStreamId] : published;
20929
+ this.deviceOutages.openWake(deviceId, streamIds, reason, Date.now());
20930
+ this.announceDeviceOutage(deviceId, {
20931
+ deviceId,
20932
+ ...streamIds[0] !== void 0 ? { camStreamId: streamIds[0] } : {},
20933
+ brokerId: streamIds[0] !== void 0 ? brokerIdFor(deviceId, streamIds[0]) : String(deviceId),
20934
+ sourceType: "battery-wake",
20935
+ lastPacketAt: 0
20936
+ });
20937
+ }
20938
+ emitDeviceStreamHealth(online, deviceId, reason, camStreamIds, context) {
20939
+ const ids = [...camStreamIds];
20940
+ const first = ids[0] ?? context.camStreamId;
20941
+ const assignments = this.assignments.get(deviceId)?.map ?? {};
20942
+ this.emitStreamHealth(online, {
20943
+ ...context,
20944
+ deviceId,
20945
+ ...first !== void 0 ? { camStreamId: first } : {},
20946
+ brokerId: first !== void 0 ? brokerIdFor(deviceId, first) : context.brokerId,
20947
+ profile: first !== void 0 ? findProfileForStream(assignments, first) : null,
20948
+ reason,
20949
+ camStreamIds: ids
20950
+ });
20440
20951
  }
20441
20952
  emitStreamHealth(online, payload) {
20442
20953
  const bus = this.eventBus;
20443
20954
  if (!bus) return;
20444
20955
  const category = online ? "stream.online" : "stream.offline";
20956
+ const profile = payload.profile !== void 0 ? payload.profile : payload.camStreamId !== void 0 ? findProfileForStream(this.assignments.get(payload.deviceId)?.map ?? {}, payload.camStreamId) : null;
20445
20957
  bus.emit({
20446
20958
  id: `${category}-${payload.brokerId}-${Date.now()}`,
20447
20959
  timestamp: /* @__PURE__ */ new Date(),
@@ -20450,7 +20962,10 @@ var StreamBrokerManager = class StreamBrokerManager {
20450
20962
  type: "stream-broker",
20451
20963
  id: payload.brokerId
20452
20964
  },
20453
- data: payload
20965
+ data: {
20966
+ ...payload,
20967
+ profile
20968
+ }
20454
20969
  });
20455
20970
  this.writeCameraStreamsSlice(payload.deviceId, this.snapshotProfileSlots(payload.deviceId));
20456
20971
  }
@@ -22266,6 +22781,14 @@ var StreamBrokerManager = class StreamBrokerManager {
22266
22781
  });
22267
22782
  this.streamHealthByBroker.delete(brokerId);
22268
22783
  this.streamOfflineReasonByBroker.delete(brokerId);
22784
+ this.sleepSuppressedBrokers.delete(brokerId);
22785
+ this.forgetReleasedStreamOutage({
22786
+ deviceId,
22787
+ camStreamId,
22788
+ brokerId,
22789
+ sourceType: broker.getActiveSourceType(),
22790
+ lastPacketAt: broker.getLastPacketAt()
22791
+ });
22269
22792
  await broker.stop();
22270
22793
  this.brokers.delete(brokerId);
22271
22794
  this.sentryFrames.delete(brokerId);
@@ -33773,9 +34296,10 @@ var ClipStreamFeeder = class {
33773
34296
  asked: "high",
33774
34297
  served: answer.served
33775
34298
  });
33776
- if (this.deps.handle.profile !== void 0 && this.deps.handle.profile !== "high" && answer.served === "high") this.deps.onLog?.("clip stream: only the main copy exists — serving it for a cheaper ask", {
34299
+ const askedProfile = this.deps.handle.profile;
34300
+ if (askedProfile !== void 0 && askedProfile !== null && askedProfile !== "high" && answer.served === "high") this.deps.onLog?.("clip stream: only the main copy exists — serving it for a cheaper ask", {
33777
34301
  deviceId: this.deps.handle.deviceId,
33778
- asked: this.deps.handle.profile,
34302
+ asked: askedProfile,
33779
34303
  served: answer.served
33780
34304
  });
33781
34305
  if (this.audioAsk !== "none" && answer.servedAudio !== this.audioAsk) {