@camstack/system 1.2.263 → 1.2.264

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 (65) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/camera-grid/addon.d.ts +20 -0
  12. package/dist/builtins/camera-grid/grid-activity-mirror.d.ts +32 -0
  13. package/dist/builtins/camera-grid/grid-activity.d.ts +50 -0
  14. package/dist/builtins/camera-grid/grid-camera-device.d.ts +11 -1
  15. package/dist/builtins/camera-grid/grid-canvas.d.ts +83 -0
  16. package/dist/builtins/camera-grid/grid-compositor.d.ts +36 -0
  17. package/dist/builtins/camera-grid/grid-detection-mapping.d.ts +31 -0
  18. package/dist/builtins/camera-grid/grid-tile-feed.d.ts +38 -0
  19. package/dist/builtins/camera-grid/grid-tile-plan.d.ts +32 -0
  20. package/dist/builtins/camera-grid/index.js +416 -7
  21. package/dist/builtins/camera-grid/index.mjs +416 -7
  22. package/dist/builtins/camera-grid/silence-analysis.d.ts +7 -0
  23. package/dist/builtins/console-logging/index.js +1 -1
  24. package/dist/builtins/console-logging/index.mjs +1 -1
  25. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  26. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  27. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  29. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  30. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  31. package/dist/builtins/hub-forwarder/index.js +1 -1
  32. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  33. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  34. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  35. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  36. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  37. package/dist/builtins/local-network/local-network.addon.js +1 -1
  38. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  39. package/dist/builtins/loki-logging/index.js +1 -1
  40. package/dist/builtins/loki-logging/index.mjs +1 -1
  41. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  42. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  43. package/dist/builtins/platform-probe/index.js +1 -1
  44. package/dist/builtins/platform-probe/index.mjs +1 -1
  45. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  46. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  47. package/dist/builtins/snapshot/index.js +1 -1
  48. package/dist/builtins/snapshot/index.mjs +1 -1
  49. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  50. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  51. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  52. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/system-config/system-config.addon.js +1 -1
  56. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{dist-6hiXbrNY.js → dist-BhYweLVr.js} +74 -15
  60. package/dist/{dist-CQ7ItBIG.mjs → dist-CO2O_ddo.mjs} +61 -8
  61. package/dist/index.js +1 -1
  62. package/dist/index.mjs +1 -1
  63. package/dist/{retired-settings-keys-CCXmaFTK.mjs → retired-settings-keys-BnnHWyfj.mjs} +1 -1
  64. package/dist/{retired-settings-keys-AmmGrCp1.js → retired-settings-keys-CKLI8VmE.js} +1 -1
  65. package/package.json +4 -1
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-6hiXbrNY.js");
6
+ const require_dist = require("../../dist-BhYweLVr.js");
7
7
  let zod = require("zod");
8
8
  let _camstack_types_node = require("@camstack/types/node");
9
9
  let node_path = require("node:path");
@@ -197,6 +197,10 @@ var GridCameraDevice = class extends require_dist.BaseDevice {
197
197
  getSnapshot: async ({ deviceId }) => activeRuntime().getSnapshot(deviceId),
198
198
  invalidateCache: async () => {}
199
199
  });
200
+ this.ctx.registerNativeCap(require_dist.motionCapability, {
201
+ getStatus: async ({ deviceId }) => activeRuntime().motionStatus(deviceId),
202
+ isDetected: async ({ deviceId }) => activeRuntime().motionStatus(deviceId).detected
203
+ });
200
204
  this.markOnline(true);
201
205
  }
202
206
  catalog() {
@@ -400,6 +404,330 @@ function normalizeGridRows(raw, mintId) {
400
404
  };
401
405
  }
402
406
  //#endregion
407
+ //#region src/builtins/camera-grid/grid-activity-mirror.ts
408
+ /**
409
+ * What a grid says about ITSELF, kept from what its sources said.
410
+ *
411
+ * ## Why the grid answers at all
412
+ *
413
+ * A grid is a camera, and the viewer already knows how to light a camera: the
414
+ * motion ring keys off `motion.on-motion-changed` filtered by `deviceId`, the
415
+ * audio one off `pipeline.audio-inference-result`. So the grid does not need a
416
+ * new channel, a new cap or a line of app code — it needs to answer those
417
+ * questions about its own device id. The operator set both rules: motion is on
418
+ * if AT LEAST ONE source has it, and the audio level is the LOUDEST of them.
419
+ *
420
+ * It is derived from the SOURCES' events and never from the composed picture.
421
+ * Analysing the composite would be a second motion authority for cameras that
422
+ * already have one, and a decode this addon does not pay today.
423
+ *
424
+ * ## The falling edge is the whole difficulty
425
+ *
426
+ * Bus events are telemetry and may be dropped (D8/D11). A lost `detected:
427
+ * false` would leave a grid lit for ever, and the grid has no phase machine to
428
+ * produce a falling edge of its own — a real camera's comes from the pipeline
429
+ * runner, which a grid deliberately does not run.
430
+ *
431
+ * So a source's motion is remembered WITH ITS TIME and expires on its own. That
432
+ * is D49's shape: in-memory state refreshed off the event path, never a gate
433
+ * that re-reads something fallible in order to decide. A dropped clear costs a
434
+ * ring that stays lit until the expiry instead of for ever, and a subject that
435
+ * is genuinely still there keeps renewing it.
436
+ *
437
+ * The expiry is `MOTION_CLOSE_AFTER_MS`, imported and not re-spelled: it is the
438
+ * system's one answer to "how long after the last push is a camera still
439
+ * moving", and a second number here would be a second authority disagreeing
440
+ * with the first on exactly the cameras that make up the grid.
441
+ */
442
+ /**
443
+ * How long an audio reading counts as current.
444
+ *
445
+ * The analyzer emits one per window per camera, so a source that has gone quiet
446
+ * simply stops arriving — there is no "silence" event to wait for. Without a
447
+ * window the loudest reading of the day would be the grid's level for ever.
448
+ * Chosen to span several analysis windows so an ordinary gap does not blink the
449
+ * level away, and to be far shorter than a person notices a stale ring.
450
+ */
451
+ var AUDIO_FRESH_MS = 5e3;
452
+ var GridActivityMirror = class {
453
+ motion = /* @__PURE__ */ new Map();
454
+ audio = /* @__PURE__ */ new Map();
455
+ lastDetectedAt = null;
456
+ /** One source's motion push. `detected: false` is as important as `true`. */
457
+ noteMotion(sourceDeviceId, detected, atMs) {
458
+ this.motion.set(sourceDeviceId, {
459
+ detected,
460
+ atMs
461
+ });
462
+ if (detected) this.lastDetectedAt = Math.max(this.lastDetectedAt ?? 0, atMs);
463
+ }
464
+ /** One source's audio window. `dbfs` is the level, on the analyzer's scale. */
465
+ noteAudio(sourceDeviceId, dbfs, atMs) {
466
+ this.audio.set(sourceDeviceId, {
467
+ dbfs,
468
+ atMs
469
+ });
470
+ }
471
+ /**
472
+ * Drop every source this grid no longer composes.
473
+ *
474
+ * A cell removed while its camera was moving would otherwise hold the grid
475
+ * lit until the expiry, for a picture the source is not even in.
476
+ */
477
+ keepOnly(sourceDeviceIds) {
478
+ const keep = new Set(sourceDeviceIds);
479
+ const motionIds = Array.from(this.motion.keys());
480
+ const audioIds = Array.from(this.audio.keys());
481
+ for (const id of motionIds) if (!keep.has(id)) this.motion.delete(id);
482
+ for (const id of audioIds) if (!keep.has(id)) this.audio.delete(id);
483
+ }
484
+ /** At least one source moving, as of `nowMs`. */
485
+ motionAt(nowMs) {
486
+ let detected = false;
487
+ for (const state of this.motion.values()) {
488
+ if (!state.detected) continue;
489
+ if (nowMs - state.atMs > 15e3) continue;
490
+ detected = true;
491
+ break;
492
+ }
493
+ return {
494
+ detected,
495
+ lastDetectedAt: this.lastDetectedAt
496
+ };
497
+ }
498
+ /**
499
+ * The loudest CURRENT reading, or `null` when nothing fresh has been heard.
500
+ *
501
+ * Null and not `-Infinity`: "nobody has reported a level" is a different
502
+ * answer from "it is silent", and a number here would have the viewer draw a
503
+ * level for a grid whose sources are not being analysed at all.
504
+ */
505
+ loudestAt(nowMs) {
506
+ let loudest = null;
507
+ for (const state of this.audio.values()) {
508
+ if (nowMs - state.atMs > AUDIO_FRESH_MS) continue;
509
+ if (loudest === null || state.dbfs > loudest) loudest = state.dbfs;
510
+ }
511
+ return loudest;
512
+ }
513
+ };
514
+ //#endregion
515
+ //#region src/builtins/camera-grid/grid-detection-mapping.ts
516
+ /**
517
+ * Where this detection lands on the canvas — once per cell that shows it.
518
+ *
519
+ * Empty is a real answer: the subject is in a part of the source this grid does
520
+ * not display, or the frame arrived with no size to measure against. Neither is
521
+ * a reason to guess.
522
+ */
523
+ function mapDetectionToCanvas(input) {
524
+ const { width: frameW, height: frameH } = input.sourceFrame;
525
+ if (frameW <= 0 || frameH <= 0) return [];
526
+ const left = input.bbox.x / frameW;
527
+ const top = input.bbox.y / frameH;
528
+ const right = (input.bbox.x + input.bbox.width) / frameW;
529
+ const bottom = (input.bbox.y + input.bbox.height) / frameH;
530
+ const out = [];
531
+ for (const { source, cell } of input.cells) {
532
+ if (source.width <= 0 || source.height <= 0) continue;
533
+ const l = Math.max(left, source.x);
534
+ const t = Math.max(top, source.y);
535
+ const r = Math.min(right, source.x + source.width);
536
+ const b = Math.min(bottom, source.y + source.height);
537
+ if (r <= l || b <= t) continue;
538
+ const cx = (l - source.x) / source.width;
539
+ const cy = (t - source.y) / source.height;
540
+ const cw = (r - l) / source.width;
541
+ const ch = (b - t) / source.height;
542
+ out.push({
543
+ x: (cell.x + cx * cell.width) * input.canvas.width,
544
+ y: (cell.y + cy * cell.height) * input.canvas.height,
545
+ width: cw * cell.width * input.canvas.width,
546
+ height: ch * cell.height * input.canvas.height
547
+ });
548
+ }
549
+ return out;
550
+ }
551
+ //#endregion
552
+ //#region src/builtins/camera-grid/grid-activity.ts
553
+ /**
554
+ * The grid's own motion / audio / detection, forwarded from its sources.
555
+ *
556
+ * ## Why the grid speaks a camera's language instead of a new one
557
+ *
558
+ * The operator asked for the grid's tile to light up in the viewer exactly as a
559
+ * camera's card does, with NO app change. The app already knows how: it filters
560
+ * `motion.on-motion-changed` by `deviceId` for the motion ring,
561
+ * `pipeline.audio-inference-result` for the audio one, and `detection.result`
562
+ * for the detection one. So the grid answers those three questions about its
563
+ * own device id, in the same shapes. Nothing new is invented, and nothing in
564
+ * the viewer is touched.
565
+ *
566
+ * The rules are the operator's: motion if AT LEAST ONE source has it, and the
567
+ * LOUDEST source's level for audio.
568
+ *
569
+ * ## What speaking a camera's language costs, and why it is safe here
570
+ *
571
+ * Those categories have other listeners, and emitting on them makes the grid a
572
+ * motion source for the whole system. Checked, one by one, before shipping:
573
+ * the recorder's event capture, the HomeKit and Alexa exports and the Home
574
+ * Assistant export are all per-device and do nothing until an operator enables
575
+ * them for this camera — and if an operator DOES export a grid to HomeKit, a
576
+ * grid reporting motion is correct, not a surprise.
577
+ *
578
+ * The one listener with no gate of its own was `pipeline-analytics`, which
579
+ * would have opened a motion EPISODE per grid, duplicating the rows its sources
580
+ * already write. Its gate turned out to be the `pipeline-analytics` WRAPPER
581
+ * BINDING, so the answer is the one the operator gave: a derived camera is
582
+ * created with the analytics wrappers OFF (`grid-analytics-defaults.ts`).
583
+ * That is a binding, not a new flag, so an operator who wants a grid analysed
584
+ * can simply turn it back on.
585
+ *
586
+ * ## Detection carries boxes whether or not anyone wanted them
587
+ *
588
+ * `detection.result` is the same event the viewer draws BOXES from, so a grid
589
+ * that reports detections reports boxes. Sending the source's own pixel box
590
+ * would draw it against the composed canvas at confidently wrong coordinates,
591
+ * so every box is carried through the cell geometry first
592
+ * (`grid-detection-mapping.ts`) and lands where the subject actually is.
593
+ */
594
+ /**
595
+ * One mirror per grid, and the emission that follows an update.
596
+ *
597
+ * Deliberately NOT one mirror per source: the question is always "is THIS grid
598
+ * lit", and a source shared by two grids answers it twice, differently, because
599
+ * the other cells differ.
600
+ */
601
+ var GridActivityForwarder = class {
602
+ deps;
603
+ mirrors = /* @__PURE__ */ new Map();
604
+ constructor(deps) {
605
+ this.deps = deps;
606
+ }
607
+ /** A source camera's motion push. */
608
+ onSourceMotion(sourceDeviceId, detected, atMs) {
609
+ for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
610
+ const mirror = this.mirrorFor(grid);
611
+ const before = mirror.motionAt(atMs).detected;
612
+ mirror.noteMotion(sourceDeviceId, detected, atMs);
613
+ const after = mirror.motionAt(atMs);
614
+ if (after.detected === before) continue;
615
+ this.emitMotion(grid.deviceId, after, atMs);
616
+ }
617
+ }
618
+ /** A source camera's audio window. */
619
+ onSourceAudio(sourceDeviceId, dbfs, atMs) {
620
+ for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
621
+ const mirror = this.mirrorFor(grid);
622
+ mirror.noteAudio(sourceDeviceId, dbfs, atMs);
623
+ const loudest = mirror.loudestAt(atMs);
624
+ if (loudest === null) continue;
625
+ this.emitAudio(grid.deviceId, loudest, atMs);
626
+ }
627
+ }
628
+ /** A source camera's detection frame, with its boxes in SOURCE pixels. */
629
+ onSourceDetections(frame) {
630
+ for (const grid of this.deps.gridsForSource(frame.deviceId)) {
631
+ const cells = grid.cellsForSource(frame.deviceId);
632
+ if (cells.length === 0) continue;
633
+ const detections = [];
634
+ for (const detection of frame.detections) for (const bbox of mapDetectionToCanvas({
635
+ bbox: detection.bbox,
636
+ sourceFrame: {
637
+ width: frame.width,
638
+ height: frame.height
639
+ },
640
+ canvas: grid.canvas,
641
+ cells
642
+ })) {
643
+ const { refinedBbox: _refined, mask: _mask, ...rest } = detection;
644
+ detections.push({
645
+ ...rest,
646
+ bbox
647
+ });
648
+ }
649
+ if (detections.length === 0) continue;
650
+ this.emitDetections(grid, frame, detections);
651
+ }
652
+ }
653
+ /** The grid's own motion answer, for `motion.getStatus`. */
654
+ motionStatusFor(deviceId, nowMs) {
655
+ return this.mirrors.get(deviceId)?.motionAt(nowMs) ?? {
656
+ detected: false,
657
+ lastDetectedAt: null
658
+ };
659
+ }
660
+ /** Drop the mirror of a grid that no longer exists. */
661
+ forget(deviceId) {
662
+ this.mirrors.delete(deviceId);
663
+ }
664
+ mirrorFor(grid) {
665
+ let mirror = this.mirrors.get(grid.deviceId);
666
+ if (mirror === void 0) {
667
+ mirror = new GridActivityMirror();
668
+ this.mirrors.set(grid.deviceId, mirror);
669
+ }
670
+ mirror.keepOnly(grid.sourceDeviceIds);
671
+ return mirror;
672
+ }
673
+ source(deviceId) {
674
+ return {
675
+ type: "device",
676
+ id: deviceId,
677
+ addonId: this.deps.addonId,
678
+ deviceId
679
+ };
680
+ }
681
+ emitMotion(deviceId, state, atMs) {
682
+ const payload = {
683
+ deviceId,
684
+ detected: state.detected,
685
+ timestamp: atMs,
686
+ source: "onboard"
687
+ };
688
+ this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.MotionOnMotionChanged, this.source(deviceId), payload));
689
+ this.deps.logger.debug("camera grid: motion forwarded", {
690
+ tags: { deviceId },
691
+ meta: { detected: state.detected }
692
+ });
693
+ }
694
+ emitAudio(deviceId, dbfs, atMs) {
695
+ const payload = {
696
+ deviceId,
697
+ frame: {
698
+ kind: "audio-window",
699
+ windowId: `grid-${String(deviceId)}-${String(atMs)}`,
700
+ deviceId,
701
+ timestamp: atMs,
702
+ startMs: atMs,
703
+ endMs: atMs,
704
+ level: {
705
+ rms: 0,
706
+ dbfs
707
+ },
708
+ detections: []
709
+ },
710
+ nodeId: "hub"
711
+ };
712
+ this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.PipelineAudioInferenceResult, this.source(deviceId), payload));
713
+ }
714
+ emitDetections(grid, sourceFrame, detections) {
715
+ const payload = {
716
+ frame: {
717
+ kind: "frame",
718
+ frameId: `grid-${String(grid.deviceId)}-${sourceFrame.frameId}`,
719
+ deviceId: grid.deviceId,
720
+ timestamp: sourceFrame.timestamp,
721
+ width: grid.canvas.width,
722
+ height: grid.canvas.height,
723
+ detections
724
+ },
725
+ analysisResults: []
726
+ };
727
+ this.deps.eventBus.emit(require_dist.createEvent(require_dist.EventCategory.DetectionResult, this.source(grid.deviceId), payload));
728
+ }
729
+ };
730
+ //#endregion
403
731
  //#region src/builtins/camera-grid/grid-wire-format.ts
404
732
  /** What the relay answers with, and what the sampler must demux. */
405
733
  var GRID_WIRE_CONTENT_TYPE = "video/x-flv";
@@ -1402,11 +1730,14 @@ var GridStreamSession = class {
1402
1730
  * for a grid must win, and a reconcile that re-asserted every pass would silently
1403
1731
  * overrule them once a minute.
1404
1732
  */
1405
- var GRID_SILENCED_CAP_NAMES = [
1406
- "motion-detection",
1407
- require_dist.DETECTION_PIPELINE_CAP_NAME,
1408
- require_dist.AUDIO_ANALYSIS_CAP_NAME
1409
- ];
1733
+ /**
1734
+ * The list lives in `@camstack/types` now, and this is an alias rather than a
1735
+ * second copy. There WERE two — this one and the Terminal addon's — and they
1736
+ * had drifted: neither named `pipeline-analytics`, the one cap with no gate of
1737
+ * its own, which is what a grid forwarding its sources' motion would have used
1738
+ * to write a duplicate motion episode per event.
1739
+ */
1740
+ var GRID_SILENCED_CAP_NAMES = require_dist.DERIVED_CAMERA_SILENCED_CAP_NAMES;
1410
1741
  /**
1411
1742
  * Resolve the addon currently providing `capName` for this device, from the
1412
1743
  * device's OWN bindings. `listBindableCapsForDeviceType` answers for the device
@@ -1783,6 +2114,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
1783
2114
  * — that only changes when an operator changes it.
1784
2115
  */
1785
2116
  planByInstanceId = /* @__PURE__ */ new Map();
2117
+ activity = null;
1786
2118
  snapshots = null;
1787
2119
  frameSampler = null;
1788
2120
  ffmpegBinaryPath = "ffmpeg";
@@ -1811,11 +2143,29 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
1811
2143
  sampleFrame: async (session) => this.sampleFrame(session),
1812
2144
  store: new GridLastFrameFileStore({ dataDir: this.ctx.dataDir })
1813
2145
  });
2146
+ this.activity = new GridActivityForwarder({
2147
+ eventBus: this.ctx.eventBus,
2148
+ logger: this.ctx.logger.child("activity"),
2149
+ addonId: this.ctx.id,
2150
+ gridsForSource: (sourceDeviceId) => this.gridsForSource(sourceDeviceId),
2151
+ now: () => Date.now()
2152
+ });
2153
+ this.subscribeSourceActivity();
1814
2154
  installGridCameraRuntime({
1815
2155
  descriptorsFor: (instanceId) => this.descriptorsFor(instanceId),
1816
2156
  getLayout: (deviceId) => this.gridViewFor(deviceId),
1817
2157
  saveLayout: async (patch) => this.saveGridLayout(patch),
1818
- getSnapshot: async (deviceId) => this.getSnapshot(deviceId)
2158
+ getSnapshot: async (deviceId) => this.getSnapshot(deviceId),
2159
+ motionStatus: (deviceId) => {
2160
+ const state = this.activity?.motionStatusFor(deviceId, Date.now()) ?? {
2161
+ detected: false,
2162
+ lastDetectedAt: null
2163
+ };
2164
+ return {
2165
+ ...state,
2166
+ autoClearAfterMs: state.detected ? require_dist.MOTION_CLOSE_AFTER_MS : null
2167
+ };
2168
+ }
1819
2169
  });
1820
2170
  await this.reconcile().catch((error) => {
1821
2171
  this.ctx.logger.warn("camera grid: initial reconciliation failed — will retry", { meta: { error: require_dist.errMsg(error) } });
@@ -1952,6 +2302,64 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
1952
2302
  }
1953
2303
  }
1954
2304
  /**
2305
+ * Listen to the SOURCES, so the grid can say the same things about itself.
2306
+ *
2307
+ * Filtered by source id inside the handler rather than by subscribing per
2308
+ * camera: the bus filter is per category, the set of sources changes whenever
2309
+ * an operator drags a cell, and a per-camera subscription would have to be
2310
+ * torn down and rebuilt on every layout save.
2311
+ *
2312
+ * Every handler is TOTAL — a malformed payload is ignored, never thrown out
2313
+ * of. These are telemetry events on a shared bus (D8/D11), and a throw here
2314
+ * would take the grid's forwarding down for every grid because one camera
2315
+ * emitted something unexpected.
2316
+ */
2317
+ subscribeSourceActivity() {
2318
+ this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
2319
+ const data = event.data;
2320
+ if (typeof data.deviceId !== "number" || typeof data.detected !== "boolean") return;
2321
+ const at = typeof data.timestamp === "number" ? data.timestamp : Date.now();
2322
+ this.activity?.onSourceMotion(data.deviceId, data.detected, at);
2323
+ });
2324
+ this.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
2325
+ const data = event.data;
2326
+ const dbfs = data.frame?.level?.dbfs;
2327
+ if (typeof data.deviceId !== "number" || typeof dbfs !== "number") return;
2328
+ this.activity?.onSourceAudio(data.deviceId, dbfs, Date.now());
2329
+ });
2330
+ this.subscribe({ category: require_dist.EventCategory.DetectionResult }, (event) => {
2331
+ const frame = event.data.frame;
2332
+ if (frame === void 0 || frame.kind !== "frame") return;
2333
+ this.activity?.onSourceDetections(frame);
2334
+ });
2335
+ }
2336
+ /**
2337
+ * Which grids show this camera. Empty for the overwhelming majority of
2338
+ * events, which is why it is the FIRST thing every handler asks.
2339
+ */
2340
+ gridsForSource(sourceDeviceId) {
2341
+ const targets = [];
2342
+ for (const [instanceId, deviceId] of this.deviceIdByInstanceId) {
2343
+ const instance = this.instanceById(instanceId);
2344
+ if (!instance || !instance.enabled) continue;
2345
+ if (sourceDeviceId === deviceId) continue;
2346
+ if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
2347
+ targets.push({
2348
+ deviceId,
2349
+ canvas: {
2350
+ width: instance.width,
2351
+ height: instance.height
2352
+ },
2353
+ cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
2354
+ source: cell.source,
2355
+ cell: cell.cell
2356
+ })),
2357
+ sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))]
2358
+ });
2359
+ }
2360
+ return targets;
2361
+ }
2362
+ /**
1955
2363
  * Which stream each grid reads each of its sources at, asked of the BROKER.
1956
2364
  *
1957
2365
  * `listAllProfileSlots` is the one authority on "does 615 have a low
@@ -2135,6 +2543,7 @@ var CameraGridAddon = class extends require_dist.BaseAddon {
2135
2543
  }
2136
2544
  for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
2137
2545
  if (live.has(instanceId)) continue;
2546
+ this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
2138
2547
  this.deviceIdByInstanceId.delete(instanceId);
2139
2548
  this.planByInstanceId.delete(instanceId);
2140
2549
  }