@camstack/addon-pipeline 1.2.191 → 1.2.193

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 +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +7 -7
  4. package/dist/detection-pipeline/index.mjs +5 -5
  5. package/dist/{dist-PQArEi7A.js → dist-HG5geL9t.js} +135 -1
  6. package/dist/{dist-Cjazw3T-.mjs → dist-mYDK58Jp.mjs} +124 -2
  7. package/dist/{lazy-sharp-DfnnPeG5.js → lazy-sharp-DmAwZ0s9.js} +1 -1
  8. package/dist/{local-frame-registry-CrLITJ5O.mjs → local-frame-registry-av9ObDG0.mjs} +1 -1
  9. package/dist/{local-frame-registry-DNVTAOrZ.js → local-frame-registry-x3m6SNqf.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-wFmd_eR_.js → node--my8JTOW.js} +1 -1
  13. package/dist/{node-KFUZT2L-.mjs → node-xRsaPwBa.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +255 -221
  15. package/dist/pipeline-runner/index.mjs +253 -219
  16. package/dist/{process-memory-BgX9_XQ5.mjs → process-memory-BDHR3lxe.mjs} +1 -1
  17. package/dist/{process-memory-B4o15Ps9.js → process-memory-CoCSce63.js} +1 -1
  18. package/dist/recorder/index.js +319 -8
  19. package/dist/recorder/index.mjs +319 -8
  20. package/dist/{segment-demux-js-C36b7S82.js → segment-demux-js-CBynn9Gh.js} +1 -1
  21. package/dist/{segment-demux-js-Cgz-NlSg.mjs → segment-demux-js-CXvgKfsn.mjs} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +45 -10
  23. package/dist/session-decode/decode-worker-child.mjs +44 -9
  24. package/dist/stream-broker/_stub.js +1 -1
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-YWGmRusr.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CUjbLOm9.mjs} +3 -3
  26. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D8AK-_F6.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B_NVJXAy.mjs} +1 -1
  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-CuZBoqi0.mjs → hostInit-DnBCyuSA.mjs} +3 -3
  30. package/dist/stream-broker/index.js +93 -43
  31. package/dist/stream-broker/index.mjs +93 -43
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-n4hf2NsC.mjs → worker-protocol-C0R1DDC0.mjs} +1 -1
  34. package/dist/{worker-protocol-BulRQMSK.js → worker-protocol-DCsi4YS1.js} +1 -1
  35. package/package.json +1 -1
@@ -2,8 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-PQArEi7A.js");
6
- const require_node = require("../node-wFmd_eR_.js");
5
+ const require_dist = require("../dist-HG5geL9t.js");
6
+ const require_node = require("../node--my8JTOW.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
8
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
9
9
  const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
@@ -12523,6 +12523,7 @@ function triggerKey(source) {
12523
12523
  case "audio": return "audio";
12524
12524
  case "object": return `object:${source.macroClass}`;
12525
12525
  case "sensor": return `sensor:${source.sourceDeviceId}`;
12526
+ case "signal": return "signal";
12526
12527
  }
12527
12528
  }
12528
12529
  /**
@@ -12553,6 +12554,7 @@ function bandTriggerSources(band) {
12553
12554
  kind: "sensor",
12554
12555
  sourceDeviceId
12555
12556
  });
12557
+ if (t.deviceSignal === true) sources.push({ kind: "signal" });
12556
12558
  return sources;
12557
12559
  }
12558
12560
  /**
@@ -12607,7 +12609,69 @@ function withWindows(state, source, windows) {
12607
12609
  const key = triggerKey(source);
12608
12610
  if (windows.length === 0) next.delete(key);
12609
12611
  else next.set(key, windows);
12610
- return { windows: next };
12612
+ return withHeld({ windows: next }, state.held);
12613
+ }
12614
+ /** The same state with `held` replaced — absent when there is nothing held, so
12615
+ * a state that holds nothing is shaped exactly like one built before holds
12616
+ * existed. */
12617
+ function withHeld(state, held) {
12618
+ if (held === void 0 || held.size === 0) {
12619
+ const { held: _dropped, ...rest } = state;
12620
+ return rest;
12621
+ }
12622
+ return {
12623
+ ...state,
12624
+ held
12625
+ };
12626
+ }
12627
+ /** When the source rose, or null when it is not held. */
12628
+ function heldSinceMs(state, source) {
12629
+ return state.held?.get(triggerKey(source)) ?? null;
12630
+ }
12631
+ /**
12632
+ * Raise a LEVEL source: from `atMs` on, the live gate demands and the keep
12633
+ * gate retains, until {@link releaseTrigger}. Idempotent — a second rise of a
12634
+ * source already held keeps the ORIGINAL rise, because the level never fell in
12635
+ * between and the footage since then belongs to the same episode. A re-emitted
12636
+ * `active: true` (a slice rewritten for another field, a reconcile read) must
12637
+ * not shorten the keep span.
12638
+ */
12639
+ function holdTrigger(state, source, atMs) {
12640
+ const key = triggerKey(source);
12641
+ if (state.held?.has(key)) return state;
12642
+ const held = new Map(state.held);
12643
+ held.set(key, atMs);
12644
+ return withHeld(state, held);
12645
+ }
12646
+ /**
12647
+ * Lower a LEVEL source: the hold becomes the window `[since, atMs]`, folded
12648
+ * through the SAME merge every other source uses — so a rise landing inside
12649
+ * the post-buffer of the previous fall extends that episode rather than
12650
+ * opening a second one, exactly as consecutive motion triggers do. From here
12651
+ * on the band's `postBufferSec` runs and the schedule arms the close.
12652
+ *
12653
+ * A release of a source that is not held returns the SAME object: a falling
12654
+ * edge the recorder never saw rise (a runner respawn mid-job, a reconcile
12655
+ * read) has nothing to close, and the caller can skip a write.
12656
+ */
12657
+ function releaseTrigger(state, source, atMs, mergeGapMs) {
12658
+ const key = triggerKey(source);
12659
+ const since = state.held?.get(key);
12660
+ if (since === void 0) return state;
12661
+ const held = new Map(state.held);
12662
+ held.delete(key);
12663
+ const span = {
12664
+ firstMs: since,
12665
+ lastMs: Math.max(since, atMs)
12666
+ };
12667
+ const windows = triggerWindowsFor(state, source);
12668
+ const newest = windows[windows.length - 1];
12669
+ const merged = newest !== void 0 && span.firstMs - newest.lastMs <= mergeGapMs ? [...windows.slice(0, -1), {
12670
+ firstMs: Math.min(newest.firstMs, span.firstMs),
12671
+ lastMs: Math.max(newest.lastMs, span.lastMs)
12672
+ }] : [...windows, span];
12673
+ const evicted = Math.max(0, merged.length - 32);
12674
+ return withWindows(withHeld(state, held), source, evicted > 0 ? merged.slice(evicted) : merged);
12611
12675
  }
12612
12676
  /**
12613
12677
  * Fold one qualifying trigger into the history, immutably. It EXTENDS the newest
@@ -12649,7 +12713,7 @@ function pruneTriggerHistory(state, postMs, nowMs) {
12649
12713
  if (live.length !== windows.length) changed = true;
12650
12714
  if (live.length > 0) next.set(key, live);
12651
12715
  }
12652
- return changed ? { windows: next } : state;
12716
+ return changed ? withHeld({ windows: next }, state.held) : state;
12653
12717
  }
12654
12718
  /** Most recent trigger for a source, or null when none is retained. */
12655
12719
  function latestTriggerMs(state, source) {
@@ -12664,6 +12728,7 @@ function latestTriggerMs(state, source) {
12664
12728
  function eventsBandDemanded(band, triggers, atMs) {
12665
12729
  const { postMs } = resolveBandBufferMs(band);
12666
12730
  return bandTriggerSources(band).some((source) => {
12731
+ if (heldSinceMs(triggers, source) !== null) return true;
12667
12732
  const lastMs = latestTriggerMs(triggers, source);
12668
12733
  return lastMs !== null && atMs - lastMs <= postMs;
12669
12734
  });
@@ -12737,7 +12802,11 @@ function segmentRetainedForEvents(segStartMs, segEndMs, band, triggers, attach)
12737
12802
  if (segStartMs <= window.lastMs + postMs) return true;
12738
12803
  return attach.attachedAtMs <= window.lastMs + postMs && segStartMs <= deadlineMs;
12739
12804
  };
12740
- return bandTriggerSources(band).some((source) => triggerWindowsFor(triggers, source).some(overlaps));
12805
+ return bandTriggerSources(band).some((source) => {
12806
+ const since = heldSinceMs(triggers, source);
12807
+ if (since !== null && segEndMs >= since - preMs) return true;
12808
+ return triggerWindowsFor(triggers, source).some(overlaps);
12809
+ });
12741
12810
  }
12742
12811
  /**
12743
12812
  * By how many ms a discarded segment missed being kept — its start past the
@@ -12759,7 +12828,10 @@ function segmentMissedByMs(segStartMs, band, triggers, attach) {
12759
12828
  const windowEndMs = window.lastMs + postMs;
12760
12829
  return attach.attachedAtMs <= windowEndMs ? Math.max(windowEndMs, deadlineMs) : windowEndMs;
12761
12830
  };
12762
- const considered = bandTriggerSources(band).flatMap((source) => [...triggerWindowsFor(triggers, source)]);
12831
+ const sources = bandTriggerSources(band);
12832
+ const holds = sources.map((source) => heldSinceMs(triggers, source)).filter((s) => s !== null);
12833
+ if (holds.length > 0) return Math.min(-1, segStartMs - Math.min(...holds));
12834
+ const considered = sources.flatMap((source) => [...triggerWindowsFor(triggers, source)]);
12763
12835
  if (considered.length === 0) return null;
12764
12836
  return segStartMs - Math.max(...considered.map(latestKeptStart));
12765
12837
  }
@@ -12943,7 +13015,9 @@ function nextBandEdgeMs(config, nowMs) {
12943
13015
  function eventsWindowCloseMs(band, triggers, nowMs) {
12944
13016
  if (band.mode !== "events") return null;
12945
13017
  const { postMs } = resolveBandBufferMs(band);
12946
- const open = bandTriggerSources(band).map((source) => latestTriggerMs(triggers, source)).filter((ms) => ms !== null).map((ms) => ms + postMs).filter((ms) => ms >= nowMs);
13018
+ const sources = bandTriggerSources(band);
13019
+ if (sources.some((source) => heldSinceMs(triggers, source) !== null)) return null;
13020
+ const open = sources.map((source) => latestTriggerMs(triggers, source)).filter((ms) => ms !== null).map((ms) => ms + postMs).filter((ms) => ms >= nowMs);
12947
13021
  if (open.length === 0) return null;
12948
13022
  return Math.max(...open) + 1;
12949
13023
  }
@@ -13951,6 +14025,25 @@ var PLACEMENT_TICK_MS = 3e4;
13951
14025
  */
13952
14026
  var RECONCILE_TICK_MS = 12e4;
13953
14027
  /**
14028
+ * How long a `recording-signal` hold may stand WITHOUT a read confirming it
14029
+ * before the reconcile releases it anyway.
14030
+ *
14031
+ * The hold is opened by an event (D8: telemetry, may be dropped) and closed by
14032
+ * an event; the reconcile beat re-reads the level through RPC every
14033
+ * {@link RECONCILE_TICK_MS} and that read is what normally keeps or ends it. This
14034
+ * bound is for the case where the READ itself keeps failing — the device-state
14035
+ * door unreachable for hours — so that one lost falling edge can never pin a
14036
+ * camera for ever (D11). It counts from the last confirmation (the rise event,
14037
+ * or the last read that said `active`), never from the rise alone: a job that
14038
+ * genuinely lasts longer than this, with the level re-read every two minutes,
14039
+ * is never cut.
14040
+ *
14041
+ * Four hours: the longest whole-house clean-and-mop observed is under three,
14042
+ * and a device-state door down for four hours is an outage that has already
14043
+ * cost more than one recording.
14044
+ */
14045
+ var SIGNAL_HOLD_MAX_MS = 4 * 36e5;
14046
+ /**
13954
14047
  * Bound on the ONE store read at the head of every placement and reconcile pass
13955
14048
  * (`enabledDeviceIds` → `readDeviceConfigs`).
13956
14049
  *
@@ -14121,6 +14214,18 @@ var RecordingController = class {
14121
14214
  * {@link TriggerState}. */
14122
14215
  triggers = /* @__PURE__ */ new Map();
14123
14216
  /**
14217
+ * Per device, when the `recording-signal` hold was last CONFIRMED — by the
14218
+ * rise event or by a reconcile read that said `active`. The clock
14219
+ * {@link SIGNAL_HOLD_MAX_MS} counts from. Deleted with the hold.
14220
+ */
14221
+ signalConfirmedAt = /* @__PURE__ */ new Map();
14222
+ /**
14223
+ * Per device, how many CONSECUTIVE reconcile reads have said `active: false`
14224
+ * while a hold stood. Reset by any read that says `active` and by the rise
14225
+ * event. Reaches {@link SIGNAL_RELEASE_READS} → the reconcile releases.
14226
+ */
14227
+ signalContradictions = /* @__PURE__ */ new Map();
14228
+ /**
14124
14229
  * Last sign of life per `deviceId:profile` — seeded at attach (so the grace
14125
14230
  * window starts at the pin, not at the first segment) and stamped on every
14126
14231
  * finalize. Read by {@link checkIdleWriters}. Stamped BEFORE the keep/discard
@@ -14392,7 +14497,8 @@ var RecordingController = class {
14392
14497
  async reconcileTriggerHistory(deviceId) {
14393
14498
  const loadMotion = this.deps.loadMotionEvents;
14394
14499
  const loadObject = this.deps.loadObjectEvents;
14395
- if (loadMotion === void 0 && loadObject === void 0 || this.stopped) return;
14500
+ const loadSignal = this.deps.loadDeviceSignal;
14501
+ if (loadMotion === void 0 && loadObject === void 0 && loadSignal === void 0 || this.stopped) return;
14396
14502
  try {
14397
14503
  const config = await this.deps.loadConfig(deviceId);
14398
14504
  if (config.enabled !== true) return;
@@ -14410,6 +14516,10 @@ var RecordingController = class {
14410
14516
  };
14411
14517
  for (const source of bandTriggerSources(band)) {
14412
14518
  if (this.stopped) return;
14519
+ if (source.kind === "signal") {
14520
+ if (loadSignal !== void 0) await this.reconcileSignalLevel(deviceId, source, postMs);
14521
+ continue;
14522
+ }
14413
14523
  const rows = await durableRowsFor(source);
14414
14524
  if (rows === null || rows.length === 0) continue;
14415
14525
  if (this.stopped) return;
@@ -14442,6 +14552,164 @@ var RecordingController = class {
14442
14552
  }
14443
14553
  }
14444
14554
  /**
14555
+ * Compare the `recording-signal` HOLD with the slice the device publishes
14556
+ * now, and correct the hold the way D49 allows: a read that raises the level
14557
+ * is applied at once (footage is what is at stake); a read that lowers it is
14558
+ * applied only when {@link SIGNAL_RELEASE_READS} consecutive reads agree; a
14559
+ * read that fails changes nothing — except that a hold nobody has confirmed
14560
+ * for {@link SIGNAL_HOLD_MAX_MS} is released anyway, and says so.
14561
+ *
14562
+ * Never throws: called from the head of the reconcile beat.
14563
+ */
14564
+ async reconcileSignalLevel(deviceId, source, postMs) {
14565
+ const load = this.deps.loadDeviceSignal;
14566
+ if (load === void 0) return;
14567
+ const nowMs = this.now();
14568
+ const since = heldSinceMs(this.triggersFor(deviceId), source);
14569
+ let level;
14570
+ try {
14571
+ level = await load(deviceId);
14572
+ } catch (err) {
14573
+ this.deps.logger.warn("recorder: device signal read failed — the hold stands", {
14574
+ tags: { deviceId },
14575
+ meta: {
14576
+ held: since !== null,
14577
+ error: require_dist.errMsg(err)
14578
+ }
14579
+ });
14580
+ this.releaseIfHeldPastBound(deviceId, source, since, postMs, nowMs);
14581
+ return;
14582
+ }
14583
+ if (this.stopped) return;
14584
+ if (level?.active === true) {
14585
+ this.signalConfirmedAt.set(deviceId, nowMs);
14586
+ this.signalContradictions.delete(deviceId);
14587
+ if (since !== null) return;
14588
+ const rise = level.lastChangedAt > 0 ? Math.min(level.lastChangedAt, nowMs) : nowMs;
14589
+ this.triggers.set(deviceId, holdTrigger(this.triggersFor(deviceId), source, rise));
14590
+ this.deps.logger.info("recorder: device signal recovered from the slice — the bus had not delivered the rise", {
14591
+ tags: { deviceId },
14592
+ meta: {
14593
+ since: rise,
14594
+ lastChangedAt: level.lastChangedAt
14595
+ }
14596
+ });
14597
+ return;
14598
+ }
14599
+ if (since === null) return;
14600
+ const contradictions = (this.signalContradictions.get(deviceId) ?? 0) + 1;
14601
+ if (contradictions < 2) {
14602
+ this.signalContradictions.set(deviceId, contradictions);
14603
+ this.deps.logger.debug("recorder: device signal read contradicts the hold — waiting for a second read to agree", {
14604
+ tags: { deviceId },
14605
+ meta: {
14606
+ since,
14607
+ reads: contradictions,
14608
+ absent: level === null
14609
+ }
14610
+ });
14611
+ return;
14612
+ }
14613
+ this.applySignalRelease(deviceId, source, nowMs, postMs);
14614
+ this.deps.logger.info("recorder: device signal released by reconcile — two reads agreed the level is down", {
14615
+ tags: { deviceId },
14616
+ meta: {
14617
+ since,
14618
+ heldMs: nowMs - since,
14619
+ absent: level === null
14620
+ }
14621
+ });
14622
+ }
14623
+ /** The bound on a hold the reads could not confirm. Warns, because it DROPS
14624
+ * the rest of an episode the operator may still want. */
14625
+ releaseIfHeldPastBound(deviceId, source, since, postMs, nowMs) {
14626
+ if (since === null) return;
14627
+ const confirmedAt = this.signalConfirmedAt.get(deviceId) ?? since;
14628
+ if (nowMs - confirmedAt <= 144e5) return;
14629
+ this.applySignalRelease(deviceId, source, nowMs, postMs);
14630
+ this.deps.logger.warn("recorder: device signal held past its bound with no read confirming it — released, the episode may be cut", {
14631
+ tags: { deviceId },
14632
+ meta: {
14633
+ since,
14634
+ confirmedAt,
14635
+ heldMs: nowMs - since,
14636
+ boundMs: SIGNAL_HOLD_MAX_MS
14637
+ }
14638
+ });
14639
+ }
14640
+ /** Lower the hold into a window and forget the per-device signal clocks. */
14641
+ applySignalRelease(deviceId, source, atMs, postMs) {
14642
+ const pruned = pruneTriggerHistory(this.triggersFor(deviceId), postMs, atMs);
14643
+ this.triggers.set(deviceId, releaseTrigger(pruned, source, atMs, postMs));
14644
+ this.signalConfirmedAt.delete(deviceId);
14645
+ this.signalContradictions.delete(deviceId);
14646
+ }
14647
+ /**
14648
+ * Feed the camera's own `recording-signal` LEVEL — the counterpart of
14649
+ * `onTrigger` for `RecordingTriggers.deviceSignal`, fed from the
14650
+ * `DeviceStateChanged` subscription in `recorder/addon/index.ts`.
14651
+ *
14652
+ * `active: true` HOLDS the source open (idempotent: a re-emitted slice never
14653
+ * moves the rise); `active: false` releases it into a window ending at `atMs`,
14654
+ * after which the band's `postBufferSec` runs and `evaluateDevice` arms the
14655
+ * close exactly as it does for a motion episode. Both converge at once: the
14656
+ * rise is what wakes a battery camera and pins its source (the robot's
14657
+ * monitor opens through `battery.wakeForStream` on the attach), the fall is
14658
+ * what lets the source be released and the monitor close.
14659
+ *
14660
+ * No config read on this path (D49): the hold is recorded unconditionally —
14661
+ * it costs one map entry — and `evaluateDevice`, which reads the last-known
14662
+ * config, decides whether any band listens. A camera whose bands do not name
14663
+ * the signal simply never demands.
14664
+ */
14665
+ async onSignalLevel(deviceId, source, active, atMs) {
14666
+ if (this.stopped) return;
14667
+ const before = this.triggersFor(deviceId);
14668
+ if (active) {
14669
+ const held = holdTrigger(before, source, atMs);
14670
+ this.signalConfirmedAt.set(deviceId, this.now());
14671
+ this.signalContradictions.delete(deviceId);
14672
+ if (held !== before) {
14673
+ this.triggers.set(deviceId, held);
14674
+ this.deps.logger.info("recorder: device signal raised — recording window held open", {
14675
+ tags: { deviceId },
14676
+ meta: {
14677
+ source: triggerKey(source),
14678
+ atMs
14679
+ }
14680
+ });
14681
+ }
14682
+ } else {
14683
+ const postMs = await this.postBufferMsFor(deviceId, atMs);
14684
+ if (this.stopped) return;
14685
+ const since = heldSinceMs(this.triggersFor(deviceId), source);
14686
+ if (since === null) return;
14687
+ this.applySignalRelease(deviceId, source, atMs, postMs);
14688
+ this.deps.logger.info("recorder: device signal fell — post-buffer running, then the window closes", {
14689
+ tags: { deviceId },
14690
+ meta: {
14691
+ source: triggerKey(source),
14692
+ since,
14693
+ atMs,
14694
+ heldMs: atMs - since,
14695
+ postMs
14696
+ }
14697
+ });
14698
+ }
14699
+ try {
14700
+ await this.evaluateDevice(deviceId);
14701
+ } catch (err) {
14702
+ this.deps.logger.warn("recorder: device signal attach deferred — stream not available yet (retries on next broker ready/reconcile)", {
14703
+ tags: { deviceId },
14704
+ meta: {
14705
+ source: triggerKey(source),
14706
+ active,
14707
+ error: require_dist.errMsg(err)
14708
+ }
14709
+ });
14710
+ }
14711
+ }
14712
+ /**
14445
14713
  * Re-evaluate one device after a config change (called from `setDeviceConfig`).
14446
14714
  * Failures propagate so the caller can surface them, but the persisted config
14447
14715
  * has already been written by the time this runs.
@@ -16544,6 +16812,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
16544
16812
  */
16545
16813
  sensorTriggerUnsub = null;
16546
16814
  /**
16815
+ * Unsubscribe handle for the `DeviceStateChanged` subscription filtered on
16816
+ * the camera's own `recording-signal` slice — the feed behind
16817
+ * `RecordingTriggers.deviceSignal`. A LEVEL, so it has no edge memory of its
16818
+ * own: the controller holds and releases (`onSignalLevel`).
16819
+ */
16820
+ signalTriggerUnsub = null;
16821
+ /**
16547
16822
  * Edge memory + reverse index (source device → cameras) for the sensor
16548
16823
  * trigger. Constructed once per addon instance: `startedAtMs` is the floor a
16549
16824
  * first-sighting rise must beat, and a fresh instance per boot is exactly
@@ -16795,6 +17070,26 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
16795
17070
  limit: TRIGGER_RECONCILE_ROW_LIMIT
16796
17071
  })).filter((event) => (event.source ?? "pipeline") === "pipeline").map((event) => event.timestamp);
16797
17072
  },
17073
+ /**
17074
+ * The DURABLE half of the DEVICE-SIGNAL source: the camera's own
17075
+ * `recording-signal` slice, read back through the kernel's state door.
17076
+ * `null` when the device has never written it; a slice that does not
17077
+ * parse is a REJECTION, not a level — the reconcile must not read
17078
+ * garbage as "down" and release on it.
17079
+ */
17080
+ loadDeviceSignal: async (deviceId) => {
17081
+ const slice = await this.ctx.api.deviceState.getCapSlice.query({
17082
+ deviceId,
17083
+ capName: require_dist.recordingSignalCapability.name
17084
+ });
17085
+ if (slice === null) return null;
17086
+ const parsed = require_dist.RecordingSignalStatusSchema.safeParse(slice);
17087
+ if (!parsed.success) throw new Error(`recording-signal slice for device ${deviceId} does not parse`);
17088
+ return {
17089
+ active: parsed.data.active,
17090
+ lastChangedAt: parsed.data.lastChangedAt
17091
+ };
17092
+ },
16798
17093
  locations: () => this.resolvedLocations,
16799
17094
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
16800
17095
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
@@ -17130,6 +17425,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17130
17425
  this.detectionTriggerUnsub = null;
17131
17426
  this.sensorTriggerUnsub?.();
17132
17427
  this.sensorTriggerUnsub = null;
17428
+ this.signalTriggerUnsub?.();
17429
+ this.signalTriggerUnsub = null;
17133
17430
  await this.controller?.stop();
17134
17431
  this.controller = null;
17135
17432
  this.segmentStore = null;
@@ -17302,6 +17599,20 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17302
17599
  });
17303
17600
  });
17304
17601
  }
17602
+ if (this.signalTriggerUnsub === null) this.signalTriggerUnsub = this.ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (event) => {
17603
+ const parsed = parseSensorStateChanged(event.data);
17604
+ if (parsed === null || parsed.capName !== require_dist.recordingSignalCapability.name) return;
17605
+ const level = require_dist.RecordingSignalStatusSchema.safeParse(parsed.slice);
17606
+ if (!level.success) {
17607
+ this.ctx.logger.warn("recorder: device signal slice does not parse — ignored", {
17608
+ tags: { deviceId: parsed.deviceId },
17609
+ meta: { slice: parsed.slice }
17610
+ });
17611
+ return;
17612
+ }
17613
+ const atMs = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
17614
+ this.controller?.onSignalLevel(parsed.deviceId, { kind: "signal" }, level.data.active, atMs);
17615
+ });
17305
17616
  try {
17306
17617
  await this.segmentHours?.load();
17307
17618
  this.segmentHours?.repairAggregates();