@camstack/addon-post-analysis 1.2.24 → 1.2.26

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-MQmqxd7v.js");
5
+ const require_dist = require("../dist-CP-FTAl5.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -1542,6 +1542,33 @@ function pointInPolygon$1(point, polygon) {
1542
1542
  }
1543
1543
  return inside;
1544
1544
  }
1545
+ /** Parametric line-segment intersection. Returns intersection point or null. */
1546
+ function lineIntersection(a, b) {
1547
+ const dx1 = a.p2.x - a.p1.x;
1548
+ const dy1 = a.p2.y - a.p1.y;
1549
+ const dx2 = b.p2.x - b.p1.x;
1550
+ const dy2 = b.p2.y - b.p1.y;
1551
+ const denom = dx1 * dy2 - dy1 * dx2;
1552
+ if (Math.abs(denom) < 1e-10) return null;
1553
+ const t = ((b.p1.x - a.p1.x) * dy2 - (b.p1.y - a.p1.y) * dx2) / denom;
1554
+ const u = ((b.p1.x - a.p1.x) * dy1 - (b.p1.y - a.p1.y) * dx1) / denom;
1555
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
1556
+ return {
1557
+ x: a.p1.x + t * dx1,
1558
+ y: a.p1.y + t * dy1
1559
+ };
1560
+ }
1561
+ /** Detect tripwire crossing and direction via cross product. */
1562
+ function tripwireCrossing(prev, curr, wire) {
1563
+ if (!lineIntersection({
1564
+ p1: prev,
1565
+ p2: curr
1566
+ }, wire)) return null;
1567
+ return {
1568
+ crossed: true,
1569
+ direction: (curr.x - prev.x) * (wire.p2.y - wire.p1.y) - (curr.y - prev.y) * (wire.p2.x - wire.p1.x) > 0 ? "right" : "left"
1570
+ };
1571
+ }
1545
1572
  /** Centroid of a bounding box { x, y, w, h } */
1546
1573
  function bboxCentroid(bbox) {
1547
1574
  return {
@@ -1556,6 +1583,95 @@ function normalizeToPixel(p, width, height) {
1556
1583
  y: p.y * height
1557
1584
  };
1558
1585
  }
1586
+ var NONE = [];
1587
+ /**
1588
+ * Which side of the directed line the point is on.
1589
+ *
1590
+ * Sign of the 2D cross product of the line's own vector with (point − p1):
1591
+ * positive is the right-hand side of p1 → p2. Exactly zero means ON the line,
1592
+ * which is deliberately its own value: it is neither side, so it can neither
1593
+ * confirm nor cancel a crossing.
1594
+ */
1595
+ function sideOf(point, wire) {
1596
+ const cross = (wire.p2.x - wire.p1.x) * (point.y - wire.p1.y) - (wire.p2.y - wire.p1.y) * (point.x - wire.p1.x);
1597
+ if (cross > 0) return 1;
1598
+ if (cross < 0) return -1;
1599
+ return 0;
1600
+ }
1601
+ var TripwireTracker = class {
1602
+ config;
1603
+ /** `trackId` → `wireId` → state. Keyed by track so `forget` is one delete. */
1604
+ tracks = /* @__PURE__ */ new Map();
1605
+ constructor(config = {}) {
1606
+ this.config = {
1607
+ confirmFrames: 2,
1608
+ ...config
1609
+ };
1610
+ }
1611
+ /**
1612
+ * Feed one frame's centroid for one track against one wire.
1613
+ *
1614
+ * Returns the crossings CONFIRMED on this frame — usually none. Coordinates
1615
+ * are whatever space the caller uses consistently; the zone geometry is
1616
+ * normalised (0–1), so the frame processor passes normalised centroids.
1617
+ */
1618
+ observe(trackId, wireId, wire, centroid) {
1619
+ const wires = this.tracks.get(trackId) ?? /* @__PURE__ */ new Map();
1620
+ this.tracks.set(trackId, wires);
1621
+ const side = sideOf(centroid, wire);
1622
+ const state = wires.get(wireId);
1623
+ if (state === void 0) {
1624
+ wires.set(wireId, {
1625
+ last: centroid,
1626
+ confirmedSide: side,
1627
+ pendingSide: side,
1628
+ pendingFrames: 0,
1629
+ sawIntersection: false,
1630
+ pendingDirection: "in"
1631
+ });
1632
+ return NONE;
1633
+ }
1634
+ const crossedNow = tripwireCrossing(state.last, centroid, wire) !== null;
1635
+ state.last = centroid;
1636
+ if (side === 0) return NONE;
1637
+ if (side === state.confirmedSide) {
1638
+ state.pendingSide = side;
1639
+ state.pendingFrames = 0;
1640
+ state.sawIntersection = false;
1641
+ return NONE;
1642
+ }
1643
+ if (side !== state.pendingSide) {
1644
+ state.pendingSide = side;
1645
+ state.pendingFrames = 1;
1646
+ state.sawIntersection = crossedNow;
1647
+ state.pendingDirection = side > 0 ? "in" : "out";
1648
+ } else {
1649
+ state.pendingFrames += 1;
1650
+ state.sawIntersection = state.sawIntersection || crossedNow;
1651
+ }
1652
+ if (state.pendingFrames < this.config.confirmFrames) return NONE;
1653
+ if (!state.sawIntersection) {
1654
+ state.confirmedSide = side;
1655
+ state.pendingFrames = 0;
1656
+ return NONE;
1657
+ }
1658
+ state.confirmedSide = side;
1659
+ state.pendingFrames = 0;
1660
+ state.sawIntersection = false;
1661
+ return [{
1662
+ wireId,
1663
+ direction: state.pendingDirection
1664
+ }];
1665
+ }
1666
+ /** Drop a track. Called when the subject leaves, so state cannot accumulate. */
1667
+ forget(trackId) {
1668
+ this.tracks.delete(trackId);
1669
+ }
1670
+ /** Number of tracks held — a leak check for the caller's tests. */
1671
+ size() {
1672
+ return this.tracks.size;
1673
+ }
1674
+ };
1559
1675
  //#endregion
1560
1676
  //#region src/pipeline-analytics/pipeline/tracker/sort-tracker.ts
1561
1677
  var DEFAULT_TRACKER_CONFIG = {
@@ -2244,10 +2360,11 @@ var DetectionEventEmitter = class {
2244
2360
  const eventType = ZONE_TYPE_TO_EVENT[ze.type];
2245
2361
  if (!eventType) continue;
2246
2362
  if (!this.config.enabledTypes.includes(eventType)) continue;
2247
- const track = trackMap.get(ze.trackId);
2363
+ const present = trackMap.get(ze.trackId);
2364
+ const track = present ?? this.lastKnownTracks.get(ze.trackId);
2248
2365
  if (!track || track.trackAge < this.config.minTrackAge) continue;
2249
2366
  const state = stateMap.get(ze.trackId);
2250
- this.valuedTracks.add(ze.trackId);
2367
+ if (present) this.valuedTracks.add(ze.trackId);
2251
2368
  events.push({
2252
2369
  id: `evt-${++eventIdCounter}`,
2253
2370
  type: eventType,
@@ -2594,6 +2711,19 @@ var FrameProcessor = class {
2594
2711
  /** Zone crossings per track — the producer of `zone.enter` / `zone.exit`. */
2595
2712
  zoneTransitions = new ZoneTransitionTracker();
2596
2713
  /**
2714
+ * Tripwire crossings. Separate from `zoneTransitions` because a tripwire is
2715
+ * a DIRECTED line, not an area: membership is meaningless and the question is
2716
+ * which way the subject went over it.
2717
+ */
2718
+ tripwires = new TripwireTracker();
2719
+ /**
2720
+ * Last detection seen per track. An edge exit fires for a track that is
2721
+ * ABSENT from the frame, so there is no current detection to attach — this is
2722
+ * the one the subject had when it was last seen. Cleared with the rest of the
2723
+ * per-track caches when the track leaves.
2724
+ */
2725
+ lastDetectionByTrack = /* @__PURE__ */ new Map();
2726
+ /**
2597
2727
  * Last zone membership seen per track, so an event for a track that is GONE
2598
2728
  * this frame still carries the zones it was in. See the use site.
2599
2729
  */
@@ -2789,6 +2919,7 @@ var FrameProcessor = class {
2789
2919
  }
2790
2920
  this.lastZoneOverlaps = overlapsByTrack;
2791
2921
  this.lastZoneRejections = rejectedByTrack;
2922
+ for (const td of trackedDetections) this.lastDetectionByTrack.set(td.trackId, td);
2792
2923
  for (const [trackId, zoneIds] of zonesByTrack) this.lastZonesByTrack.set(trackId, zoneIds);
2793
2924
  for (const state of objectStates) {
2794
2925
  if (zonesByTrack.has(state.trackId)) continue;
@@ -2807,16 +2938,58 @@ var FrameProcessor = class {
2807
2938
  timestamp
2808
2939
  });
2809
2940
  };
2941
+ for (const state of objectStates) {
2942
+ if (state.state !== "leaving") continue;
2943
+ const stillInside = this.zoneTransitions.forget(state.trackId);
2944
+ this.tripwires.forget(state.trackId);
2945
+ if (stillInside.length === 0) continue;
2946
+ const last = this.lastDetectionByTrack.get(state.trackId);
2947
+ if (last === void 0) continue;
2948
+ for (const zoneId of stillInside) pushZoneEvent("zone-exit", zoneId, last);
2949
+ }
2810
2950
  for (const td of trackedDetections) {
2811
2951
  const crossings = this.zoneTransitions.observe(td.trackId, zonesByTrack.get(td.trackId) ?? []);
2812
2952
  for (const zoneId of crossings.entered) pushZoneEvent("zone-enter", zoneId, td);
2813
2953
  for (const zoneId of crossings.exited) pushZoneEvent("zone-exit", zoneId, td);
2814
2954
  }
2955
+ const wires = this.zones.filter((z) => z.kind === "tripwire" && z.polygon.length >= 2);
2956
+ if (wires.length > 0) for (const td of trackedDetections) {
2957
+ const centroid = bboxCentroid({
2958
+ x: td.bbox.x / frameWidth,
2959
+ y: td.bbox.y / frameHeight,
2960
+ w: td.bbox.w / frameWidth,
2961
+ h: td.bbox.h / frameHeight
2962
+ });
2963
+ for (const zone of wires) {
2964
+ const a = zone.polygon[0];
2965
+ const b = zone.polygon[1];
2966
+ if (a === void 0 || b === void 0) continue;
2967
+ const wire = {
2968
+ p1: {
2969
+ x: a.x,
2970
+ y: a.y
2971
+ },
2972
+ p2: {
2973
+ x: b.x,
2974
+ y: b.y
2975
+ }
2976
+ };
2977
+ for (const cross of this.tripwires.observe(td.trackId, zone.id, wire, centroid)) zoneEvents.push({
2978
+ type: "tripwire-cross",
2979
+ zoneId: zone.id,
2980
+ zoneName: zone.name,
2981
+ trackId: td.trackId,
2982
+ detection: td,
2983
+ direction: cross.direction,
2984
+ timestamp
2985
+ });
2986
+ }
2987
+ }
2815
2988
  const rawEvents = this.eventEmitter.emit(trackedDetections, objectStates, zoneEvents, [], String(this.deviceId));
2816
2989
  for (const state of objectStates) {
2817
2990
  if (state.state !== "leaving") continue;
2818
- this.zoneTransitions.forget(state.trackId);
2819
2991
  this.lastZonesByTrack.delete(state.trackId);
2992
+ this.lastDetectionByTrack.delete(state.trackId);
2820
2993
  }
2821
2994
  const tracked = trackedDetections.map((td) => {
2822
2995
  const state = mapObjectStateToTrackState(objectStates.find((o) => o.trackId === td.trackId)?.state);
@@ -2856,9 +3029,9 @@ var FrameProcessor = class {
2856
3029
  };
2857
3030
  });
2858
3031
  const crossingOf = (e) => {
2859
- const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : void 0;
2860
- if (direction === void 0) return void 0;
2861
3032
  const ze = e.zoneEvents[0];
3033
+ const direction = e.type === "zone.enter" ? "enter" : e.type === "zone.exit" ? "exit" : e.type === "tripwire.cross" ? ze?.direction === "out" ? "exit" : "enter" : void 0;
3034
+ if (direction === void 0) return void 0;
2862
3035
  if (ze === void 0) return void 0;
2863
3036
  return {
2864
3037
  direction,
@@ -4585,17 +4758,7 @@ function referencesAudioClass(classes) {
4585
4758
  if (classes === void 0) return false;
4586
4759
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
4587
4760
  }
4588
- /**
4589
- * Evaluate one rule against one subject. The rule's `delivery` must match
4590
- * the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
4591
- * `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
4592
- * at the SUBJECT timestamp (the persist moment), so boot-time reconcile of
4593
- * missed records applies the schedule the record lived under.
4594
- *
4595
- * Throttle/cooldown is deliberately NOT part of this function — cooldown
4596
- * is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
4597
- */
4598
- function evaluateRule(rule, subject) {
4761
+ function evaluateRule(rule, subject, deviceState) {
4599
4762
  if (subject.kind === "occupancy-event") {
4600
4763
  if (rule.delivery !== "device-event") return fail("delivery");
4601
4764
  const occ = rule.conditions.occupancy;
@@ -4610,6 +4773,11 @@ function evaluateRule(rule, subject) {
4610
4773
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4611
4774
  }
4612
4775
  const c = rule.conditions;
4776
+ if (c.deviceState !== void 0) {
4777
+ const current = deviceState?.(c.deviceState.deviceId);
4778
+ if (current === void 0) return fail("deviceState");
4779
+ if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
4780
+ }
4613
4781
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
4614
4782
  if (c.source !== void 0 && c.source !== "any") {
4615
4783
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -4950,6 +5118,29 @@ var NcDispatcher = class {
4950
5118
  } });
4951
5119
  return { ok: true };
4952
5120
  }
5121
+ if (this.deps.snoozeGate !== void 0) {
5122
+ const snoozeId = await this.deps.snoozeGate({
5123
+ ruleId: entry.ruleId,
5124
+ ruleName: entry.payload.ruleName,
5125
+ targetId: target.id,
5126
+ deviceId: entry.deviceId,
5127
+ ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
5128
+ at: this.now()
5129
+ });
5130
+ if (snoozeId !== null) {
5131
+ this.deps.logger.info("notification suppressed by snooze", {
5132
+ tags: { deviceId: entry.deviceId },
5133
+ meta: {
5134
+ snoozeId,
5135
+ ruleId: entry.ruleId,
5136
+ targetId: target.id,
5137
+ target: target.name,
5138
+ eventId: entry.recordId
5139
+ }
5140
+ });
5141
+ return { ok: true };
5142
+ }
5143
+ }
4953
5144
  const notification = await this.buildNotification(entry);
4954
5145
  try {
4955
5146
  const result = await this.deps.send({
@@ -4982,6 +5173,81 @@ var NcDispatcher = class {
4982
5173
  };
4983
5174
  }
4984
5175
  }
5176
+ /**
5177
+ * Deliver ONE end-of-window snooze digest to one target.
5178
+ *
5179
+ * Best-effort by design, and the return value says which it was rather than
5180
+ * throwing: the caller marks the window handled either way, because a digest
5181
+ * retried every drain tick for hours is worse than a digest missed once — and
5182
+ * a partially-delivered one must never be re-sent to the targets that already
5183
+ * have it. A failure is logged with the window id, so a missing summary is a
5184
+ * findable line rather than a silence inside a silence.
5185
+ *
5186
+ * The digest carries no media and no `tag`: it is not an event, and tagging
5187
+ * it with a rule id would let a phone collapse it into the very
5188
+ * notifications it is reporting on.
5189
+ */
5190
+ async deliverDigest(input) {
5191
+ let target;
5192
+ try {
5193
+ target = await this.resolveTarget(input.targetId);
5194
+ } catch (err) {
5195
+ this.deps.logger.warn("snooze digest: target catalog unreachable", { meta: {
5196
+ snoozeId: input.snoozeId,
5197
+ targetId: input.targetId,
5198
+ error: String(err)
5199
+ } });
5200
+ return false;
5201
+ }
5202
+ if (target === null || !target.enabled) {
5203
+ this.deps.logger.warn("snooze digest: target gone or disabled", { meta: {
5204
+ snoozeId: input.snoozeId,
5205
+ targetId: input.targetId
5206
+ } });
5207
+ return false;
5208
+ }
5209
+ try {
5210
+ const result = await this.deps.send({
5211
+ addonId: target.addonId,
5212
+ targetId: target.id,
5213
+ notification: {
5214
+ title: input.title,
5215
+ body: input.body,
5216
+ priority: 2
5217
+ }
5218
+ });
5219
+ if (!result.success) {
5220
+ this.deps.logger.warn("snooze digest send failed", { meta: {
5221
+ snoozeId: input.snoozeId,
5222
+ targetId: target.id,
5223
+ error: result.error
5224
+ } });
5225
+ return false;
5226
+ }
5227
+ this.deps.logger.info("snooze digest delivered", { meta: {
5228
+ snoozeId: input.snoozeId,
5229
+ target: target.name,
5230
+ hidden: input.total
5231
+ } });
5232
+ return true;
5233
+ } catch (err) {
5234
+ this.deps.logger.warn("snooze digest send threw", { meta: {
5235
+ snoozeId: input.snoozeId,
5236
+ targetId: target.id,
5237
+ error: String(err)
5238
+ } });
5239
+ return false;
5240
+ }
5241
+ }
5242
+ /** Device display names for a set of ids, for the digest's lines. */
5243
+ async resolveDeviceNames(deviceIds) {
5244
+ const out = /* @__PURE__ */ new Map();
5245
+ for (const id of new Set(deviceIds)) {
5246
+ const name = await this.deps.getDeviceName(id).catch(() => null);
5247
+ if (name !== null) out.set(id, name);
5248
+ }
5249
+ return out;
5250
+ }
4985
5251
  async resolveTarget(targetId) {
4986
5252
  const cached = this.cachedTarget(targetId);
4987
5253
  if (cached !== null) return cached;
@@ -5100,7 +5366,8 @@ var NcDispatcher = class {
5100
5366
  format: want.format,
5101
5367
  ...entry.payload.mediaClipPreRollSec !== void 0 ? { preRollSec: entry.payload.mediaClipPreRollSec } : {},
5102
5368
  ...entry.payload.mediaClipPostRollSec !== void 0 ? { postRollSec: entry.payload.mediaClipPostRollSec } : {},
5103
- ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {}
5369
+ ...entry.payload.mediaProfile !== void 0 ? { profile: entry.payload.mediaProfile } : {},
5370
+ ...entry.payload.mediaClipSpeed !== void 0 ? { speed: entry.payload.mediaClipSpeed } : {}
5104
5371
  });
5105
5372
  if (rendered !== null && rendered.byteLength > 0) {
5106
5373
  const bytes = new Uint8Array(rendered.byteLength);
@@ -6340,6 +6607,521 @@ var NcRuleStore = class {
6340
6607
  });
6341
6608
  }
6342
6609
  };
6610
+ var NcDeviceStateCache = class {
6611
+ deps;
6612
+ states = /* @__PURE__ */ new Map();
6613
+ lastOkAt = 0;
6614
+ ids = [];
6615
+ now;
6616
+ constructor(deps) {
6617
+ this.deps = deps;
6618
+ this.now = deps.now ?? (() => Date.now());
6619
+ }
6620
+ /**
6621
+ * The device ids worth reading — recomputed from the rule set, so a cluster
6622
+ * with no gated rule does no work at all.
6623
+ */
6624
+ setWatched(ids) {
6625
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
6626
+ }
6627
+ watched() {
6628
+ return this.ids;
6629
+ }
6630
+ /** Hot-path read. No I/O, no await — this is called per event. */
6631
+ get(deviceId) {
6632
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
6633
+ return this.states.get(deviceId);
6634
+ }
6635
+ /**
6636
+ * Re-read every watched state. Never throws: a gate that could fail the
6637
+ * evaluation tick would take the notification pipeline with it.
6638
+ */
6639
+ async refresh() {
6640
+ if (this.ids.length === 0) {
6641
+ this.states = /* @__PURE__ */ new Map();
6642
+ return;
6643
+ }
6644
+ try {
6645
+ const next = await this.deps.readStates(this.ids);
6646
+ this.states = new Map(next);
6647
+ this.lastOkAt = this.now();
6648
+ for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
6649
+ } catch (err) {
6650
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
6651
+ watched: this.ids.length,
6652
+ error: String(err)
6653
+ } });
6654
+ }
6655
+ }
6656
+ };
6657
+ //#endregion
6658
+ //#region src/notification-center/snooze-digest.ts
6659
+ /** `14:03` in the node's local zone — a digest is read by a person, at home. */
6660
+ function clock(ms) {
6661
+ const d = new Date(ms);
6662
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
6663
+ }
6664
+ function plural(n, one, many) {
6665
+ return n === 1 ? `1 ${one}` : `${n} ${many}`;
6666
+ }
6667
+ /**
6668
+ * What the window covered, in the words the operator chose it with — the rule's
6669
+ * NAME, the camera's name. "a rule was snoozed" tells the reader nothing they
6670
+ * could act on.
6671
+ */
6672
+ function scopeLabel(snooze, rows, deviceNames) {
6673
+ switch (snooze.scope) {
6674
+ case "all": return "Everything";
6675
+ case "device": {
6676
+ const id = snooze.deviceId;
6677
+ if (id === void 0) return "A camera";
6678
+ return deviceNames.get(id) ?? `Camera ${id}`;
6679
+ }
6680
+ case "rule": {
6681
+ const named = rows.find((r) => r.ruleId === snooze.ruleId);
6682
+ return named !== void 0 ? `“${named.ruleName}”` : "A rule";
6683
+ }
6684
+ }
6685
+ }
6686
+ /**
6687
+ * One digest for ONE target — the counters passed in are already the rows
6688
+ * belonging to it, so the message says what THAT destination missed rather
6689
+ * than what the household did.
6690
+ *
6691
+ * Returns null when the window hid nothing: "snooze ended, 0 notifications" is
6692
+ * a notification the operator did not need, and sending it for every quiet
6693
+ * window is how a summary becomes the noise it was meant to suppress.
6694
+ */
6695
+ function buildSnoozeDigest(snooze, rows, deviceNames) {
6696
+ const total = rows.reduce((sum, r) => sum + r.count, 0);
6697
+ if (total === 0) return null;
6698
+ const from = clock(Math.min(...rows.map((r) => r.firstAt)));
6699
+ const to = clock(snooze.expiresAt);
6700
+ const title = `Snooze ended — ${plural(total, "notification", "notifications")} hidden`;
6701
+ const lines = rows.toSorted((a, b) => b.count - a.count || a.ruleName.localeCompare(b.ruleName)).map((r) => {
6702
+ const where = deviceNames.get(r.deviceId) ?? `camera ${r.deviceId}`;
6703
+ return `• ${r.ruleName} on ${where} — ${r.count}`;
6704
+ });
6705
+ return {
6706
+ title,
6707
+ body: [`${scopeLabel(snooze, rows, deviceNames)} was snoozed${snooze.global ? " for everyone" : ""}, ${from}–${to}.`, ...lines].join("\n"),
6708
+ total
6709
+ };
6710
+ }
6711
+ /** The distinct targets a window owes a digest to. */
6712
+ function digestTargets(rows) {
6713
+ return [...new Set(rows.map((r) => r.targetId))];
6714
+ }
6715
+ //#endregion
6716
+ //#region src/notification-center/snooze-policy.ts
6717
+ /** How broad a scope is, for choosing between overlapping snoozes. */
6718
+ var BREADTH = {
6719
+ rule: 1,
6720
+ device: 2,
6721
+ all: 3
6722
+ };
6723
+ function covers(entry, q) {
6724
+ if (q.nowMs < entry.startedAt) return false;
6725
+ if (q.nowMs >= entry.expiresAt) return false;
6726
+ if (!entry.global && entry.userId !== q.userId) return false;
6727
+ switch (entry.scope) {
6728
+ case "all": return true;
6729
+ case "rule": return entry.ruleId !== void 0 && entry.ruleId === q.ruleId;
6730
+ case "device": return entry.deviceId !== void 0 && entry.deviceId === q.deviceId;
6731
+ }
6732
+ }
6733
+ /**
6734
+ * The snooze that silences this notification, or null.
6735
+ *
6736
+ * The BROADEST match wins so one window produces one digest: an "all" snooze
6737
+ * overlapping a rule snooze should not leave the recipient two summaries for
6738
+ * the same silence.
6739
+ */
6740
+ function activeSnoozeFor(entries, q) {
6741
+ let best = null;
6742
+ for (const e of entries) {
6743
+ if (!covers(e, q)) continue;
6744
+ if (best === null || BREADTH[e.scope] > BREADTH[best.scope]) best = e;
6745
+ }
6746
+ return best;
6747
+ }
6748
+ /**
6749
+ * May this caller silence EVERYONE, not just themselves?
6750
+ *
6751
+ * Global is the only part of a snooze that can affect a person who did not ask
6752
+ * for it, so it is never reachable by accident:
6753
+ *
6754
+ * - **rule scope** — the rule itself must opt in (`snoozeAllowGlobal`, default
6755
+ * off). That is the gardener case: this rule is known to be noisy for
6756
+ * everybody in a way that is legitimate to silence for everybody.
6757
+ * - **device or whole-feed scope** — there is no rule to carry the opt-in, and
6758
+ * "silence this camera for the household" is an administrative act. Admin
6759
+ * only.
6760
+ *
6761
+ * A personal snooze needs no permission at all: it silences only its author.
6762
+ */
6763
+ function canSetGlobal(req) {
6764
+ if (req.isAdmin) return true;
6765
+ if (req.scope !== "rule") return false;
6766
+ return req.ruleAllowsGlobal === true;
6767
+ }
6768
+ /** Entries whose window has closed and whose digest has not been sent yet. */
6769
+ function dueSummaries(entries, nowMs, alreadySent) {
6770
+ return entries.filter((e) => e.summary && nowMs >= e.expiresAt && !alreadySent.has(e.id));
6771
+ }
6772
+ //#endregion
6773
+ //#region src/notification-center/snooze-store.ts
6774
+ /**
6775
+ * NcSnoozeStore — the durable half of snooze.
6776
+ *
6777
+ * Two collections, and the split matters:
6778
+ *
6779
+ * - **windows** (`notification-center:snoozes`) — small, mirrored into RAM,
6780
+ * read on the dispatcher's hot path with zero I/O. Refreshed on the drain
6781
+ * tick, so a window opened on the hub takes effect on the evaluating node
6782
+ * within one tick (D8: reconcile, never trust an event to arrive).
6783
+ * - **suppression counters** (`notification-center:snooze-suppressed`) — one
6784
+ * row per (window, target, rule, camera), holding a COUNT and the first/last
6785
+ * instant. Not one row per hidden notification: a snooze exists because
6786
+ * something is noisy, so the row-per-event version of this table is unbounded
6787
+ * exactly when it is used most. The digest reads "42 events, 14:03–17:58",
6788
+ * which is the aggregate anyway.
6789
+ *
6790
+ * Expiry is never a job here either. Nothing in this file ends a window; the
6791
+ * clock does, and {@link NcSnoozeStore.prune} only reclaims rows long after the
6792
+ * fact. A prune that never runs delays a digest and wastes a few KB — it can
6793
+ * never leave the operator silenced.
6794
+ */
6795
+ var NC_SNOOZES_COLLECTION = "notification-center:snoozes";
6796
+ var NC_SNOOZE_SUPPRESSED_COLLECTION = "notification-center:snooze-suppressed";
6797
+ var NC_SNOOZES_COLUMNS = [
6798
+ {
6799
+ name: "id",
6800
+ type: "TEXT",
6801
+ primaryKey: true,
6802
+ notNull: true
6803
+ },
6804
+ {
6805
+ name: "userId",
6806
+ type: "TEXT",
6807
+ notNull: true
6808
+ },
6809
+ {
6810
+ name: "expiresAt",
6811
+ type: "INTEGER",
6812
+ notNull: true
6813
+ },
6814
+ (
6815
+ /** The FULL window (Zod-validated on read); the scalars are projections. */
6816
+ {
6817
+ name: "snooze",
6818
+ type: "JSON",
6819
+ notNull: true
6820
+ })
6821
+ ];
6822
+ var NC_SNOOZES_INDEXES = [{
6823
+ name: "idx_nc_snoozes_expires",
6824
+ columns: ["expiresAt"]
6825
+ }];
6826
+ var NC_SNOOZE_SUPPRESSED_COLUMNS = [
6827
+ {
6828
+ name: "id",
6829
+ type: "TEXT",
6830
+ primaryKey: true,
6831
+ notNull: true
6832
+ },
6833
+ {
6834
+ name: "snoozeId",
6835
+ type: "TEXT",
6836
+ notNull: true
6837
+ },
6838
+ {
6839
+ name: "targetId",
6840
+ type: "TEXT",
6841
+ notNull: true
6842
+ },
6843
+ {
6844
+ name: "ruleId",
6845
+ type: "TEXT",
6846
+ notNull: true
6847
+ },
6848
+ {
6849
+ name: "ruleName",
6850
+ type: "TEXT",
6851
+ notNull: true
6852
+ },
6853
+ {
6854
+ name: "deviceId",
6855
+ type: "INTEGER",
6856
+ notNull: true
6857
+ },
6858
+ {
6859
+ name: "count",
6860
+ type: "INTEGER",
6861
+ notNull: true
6862
+ },
6863
+ {
6864
+ name: "firstAt",
6865
+ type: "INTEGER",
6866
+ notNull: true
6867
+ },
6868
+ {
6869
+ name: "lastAt",
6870
+ type: "INTEGER",
6871
+ notNull: true
6872
+ }
6873
+ ];
6874
+ var NC_SNOOZE_SUPPRESSED_INDEXES = [{
6875
+ name: "idx_nc_snooze_suppressed_snooze",
6876
+ columns: ["snoozeId"]
6877
+ }];
6878
+ /** How long a closed window's rows survive, for a retried digest and for
6879
+ * "what did I miss" to still answer after a restart. */
6880
+ var PRUNE_HORIZON_MS = 6 * 36e5;
6881
+ /** Counter key — one row per (window, target, rule, camera). */
6882
+ function counterKey(s) {
6883
+ return `${s.snoozeId}|${s.targetId}|${s.ruleId}|${s.deviceId}`;
6884
+ }
6885
+ var NcSnoozeStore = class {
6886
+ byId = /* @__PURE__ */ new Map();
6887
+ store;
6888
+ logger;
6889
+ now;
6890
+ newId;
6891
+ constructor(deps) {
6892
+ this.store = deps.store;
6893
+ this.logger = deps.logger;
6894
+ this.now = deps.now ?? (() => Date.now());
6895
+ this.newId = deps.newId ?? (() => (0, node_crypto.randomUUID)());
6896
+ }
6897
+ static async declare(store) {
6898
+ await store.declareCollection.mutate({
6899
+ collection: NC_SNOOZES_COLLECTION,
6900
+ columns: [...NC_SNOOZES_COLUMNS],
6901
+ indexes: [...NC_SNOOZES_INDEXES]
6902
+ });
6903
+ await store.declareCollection.mutate({
6904
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
6905
+ columns: [...NC_SNOOZE_SUPPRESSED_COLUMNS],
6906
+ indexes: [...NC_SNOOZE_SUPPRESSED_INDEXES]
6907
+ });
6908
+ }
6909
+ /**
6910
+ * (Re)hydrate every window. Called at boot and on the drain tick — the table
6911
+ * holds active windows and recently closed ones, so it stays small enough to
6912
+ * read whole. A row whose JSON no longer validates is SKIPPED: a malformed
6913
+ * window must never be able to silence anything.
6914
+ */
6915
+ async load() {
6916
+ try {
6917
+ const rows = await this.store.query.query({
6918
+ collection: NC_SNOOZES_COLLECTION,
6919
+ filter: { limit: 1e3 }
6920
+ });
6921
+ this.byId.clear();
6922
+ let skipped = 0;
6923
+ for (const row of rows) {
6924
+ const parsed = require_dist.NcSnoozeSchema.safeParse(row.data["snooze"]);
6925
+ if (!parsed.success) {
6926
+ skipped += 1;
6927
+ continue;
6928
+ }
6929
+ this.byId.set(parsed.data.id, parsed.data);
6930
+ }
6931
+ if (skipped > 0) this.logger.warn("snooze rows skipped as malformed — they silence NOTHING", { meta: { skipped } });
6932
+ } catch (err) {
6933
+ this.logger.warn("snooze load failed — keeping the windows already in memory", { meta: { error: String(err) } });
6934
+ }
6935
+ }
6936
+ /** Every known window, newest first. */
6937
+ list() {
6938
+ return [...this.byId.values()].toSorted((a, b) => b.startedAt - a.startedAt);
6939
+ }
6940
+ /** The windows a caller may SEE: their own, plus the global ones that
6941
+ * silence them. Never another person's private silence. */
6942
+ listVisibleTo(userId, isAdmin) {
6943
+ if (isAdmin) return this.list();
6944
+ return this.list().filter((s) => s.userId === userId || s.global);
6945
+ }
6946
+ get(snoozeId) {
6947
+ return this.byId.get(snoozeId) ?? null;
6948
+ }
6949
+ /** Hot-path read — the window silencing this delivery, or null. */
6950
+ activeFor(query) {
6951
+ return activeSnoozeFor([...this.byId.values()], query);
6952
+ }
6953
+ /**
6954
+ * Open a window. Permission for `global` is the CALLER's problem (see
6955
+ * `canSetGlobal`) — this store persists what it is given, exactly like the
6956
+ * rule store leaves ownership to the bridge action.
6957
+ */
6958
+ async create(input) {
6959
+ const startedAt = this.now();
6960
+ const snooze = require_dist.NcSnoozeSchema.parse({
6961
+ id: this.newId(),
6962
+ userId: input.userId,
6963
+ scope: input.scope,
6964
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
6965
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
6966
+ startedAt,
6967
+ expiresAt: startedAt + input.durationMinutes * 6e4,
6968
+ global: input.global,
6969
+ summary: input.summary
6970
+ });
6971
+ await this.persist(snooze);
6972
+ this.byId.set(snooze.id, snooze);
6973
+ return snooze;
6974
+ }
6975
+ /**
6976
+ * End a window NOW by moving its expiry to this instant — never by deleting
6977
+ * it. The digest of what it hid is still owed to whoever opened it, and a
6978
+ * deleted row owes nothing.
6979
+ */
6980
+ async cancel(snoozeId) {
6981
+ const existing = this.byId.get(snoozeId);
6982
+ if (existing === void 0) return null;
6983
+ const now = this.now();
6984
+ if (existing.expiresAt <= now) return existing;
6985
+ const ended = {
6986
+ ...existing,
6987
+ expiresAt: now
6988
+ };
6989
+ await this.persist(ended);
6990
+ this.byId.set(ended.id, ended);
6991
+ return ended;
6992
+ }
6993
+ /**
6994
+ * Record that a window hid one notification. Read-modify-write on the
6995
+ * counter row; best-effort, because losing a line of a SUMMARY must never
6996
+ * cost the suppression decision that produced it.
6997
+ */
6998
+ async recordSuppressed(s) {
6999
+ const key = counterKey(s);
7000
+ try {
7001
+ const existing = await this.readCounter(key);
7002
+ const next = {
7003
+ snoozeId: s.snoozeId,
7004
+ targetId: s.targetId,
7005
+ ruleId: s.ruleId,
7006
+ ruleName: s.ruleName,
7007
+ deviceId: s.deviceId,
7008
+ count: (existing?.count ?? 0) + 1,
7009
+ firstAt: existing?.firstAt ?? s.at,
7010
+ lastAt: s.at
7011
+ };
7012
+ await this.store.set.mutate({
7013
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7014
+ key,
7015
+ value: { ...next }
7016
+ });
7017
+ } catch (err) {
7018
+ this.logger.warn("snooze suppression counter write failed (digest will under-report)", {
7019
+ tags: { deviceId: s.deviceId },
7020
+ meta: {
7021
+ snoozeId: s.snoozeId,
7022
+ ruleId: s.ruleId,
7023
+ error: String(err)
7024
+ }
7025
+ });
7026
+ }
7027
+ }
7028
+ /** Windows that have closed and still owe a digest. */
7029
+ dueDigests(nowMs) {
7030
+ const sent = new Set([...this.byId.values()].filter((s) => s.digestSentAt !== void 0).map((s) => s.id));
7031
+ return dueSummaries([...this.byId.values()], nowMs, sent);
7032
+ }
7033
+ /** What one window hid, grouped per target. */
7034
+ async suppressedFor(snoozeId) {
7035
+ try {
7036
+ const rows = await this.store.query.query({
7037
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7038
+ filter: {
7039
+ where: { snoozeId },
7040
+ limit: 1e3
7041
+ }
7042
+ });
7043
+ const out = [];
7044
+ for (const row of rows) {
7045
+ const parsed = require_dist.NcSnoozeSuppressedSchema.safeParse(row.data);
7046
+ if (parsed.success) out.push(parsed.data);
7047
+ }
7048
+ return out;
7049
+ } catch (err) {
7050
+ this.logger.warn("snooze suppression read failed", { meta: {
7051
+ snoozeId,
7052
+ error: String(err)
7053
+ } });
7054
+ return [];
7055
+ }
7056
+ }
7057
+ /**
7058
+ * Mark a digest as handled.
7059
+ *
7060
+ * Called after the ATTEMPT, not after a success: a digest that cannot be
7061
+ * delivered is not worth retrying every two seconds for six hours, and a
7062
+ * partially-sent one must not be re-sent to the targets that already got it.
7063
+ * The failure is logged with the window id — a missing digest is then a
7064
+ * findable line, not a silence inside a silence.
7065
+ */
7066
+ async markDigestSent(snoozeId) {
7067
+ const existing = this.byId.get(snoozeId);
7068
+ if (existing === void 0) return;
7069
+ const marked = {
7070
+ ...existing,
7071
+ digestSentAt: this.now()
7072
+ };
7073
+ await this.persist(marked);
7074
+ this.byId.set(marked.id, marked);
7075
+ }
7076
+ /**
7077
+ * Drop windows that closed more than {@link PRUNE_HORIZON_MS} ago, and their
7078
+ * counters. Only ever touches CLOSED windows — an active one cannot be
7079
+ * pruned into oblivion by a clock argument.
7080
+ */
7081
+ async prune(nowMs) {
7082
+ const horizon = nowMs - PRUNE_HORIZON_MS;
7083
+ const stale = [...this.byId.values()].filter((s) => s.expiresAt < horizon);
7084
+ let dropped = 0;
7085
+ for (const s of stale) try {
7086
+ await this.store.deleteWhere.mutate({
7087
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7088
+ filter: { where: { snoozeId: s.id } }
7089
+ });
7090
+ await this.store.delete.mutate({
7091
+ collection: NC_SNOOZES_COLLECTION,
7092
+ key: s.id
7093
+ });
7094
+ this.byId.delete(s.id);
7095
+ dropped += 1;
7096
+ } catch (err) {
7097
+ this.logger.warn("snooze prune failed", { meta: {
7098
+ snoozeId: s.id,
7099
+ error: String(err)
7100
+ } });
7101
+ }
7102
+ return dropped;
7103
+ }
7104
+ /** `get` returns the stored VALUE, not a `{ id, data }` row. */
7105
+ async readCounter(key) {
7106
+ const value = await this.store.get.query({
7107
+ collection: NC_SNOOZE_SUPPRESSED_COLLECTION,
7108
+ key
7109
+ });
7110
+ const parsed = require_dist.NcSnoozeSuppressedSchema.safeParse(value);
7111
+ return parsed.success ? parsed.data : null;
7112
+ }
7113
+ async persist(snooze) {
7114
+ await this.store.set.mutate({
7115
+ collection: NC_SNOOZES_COLLECTION,
7116
+ key: snooze.id,
7117
+ value: {
7118
+ userId: snooze.userId,
7119
+ expiresAt: snooze.expiresAt,
7120
+ snooze
7121
+ }
7122
+ });
7123
+ }
7124
+ };
6343
7125
  //#endregion
6344
7126
  //#region src/notification-center/timelapse/timelapse-store.ts
6345
7127
  var NC_TIMELAPSE_RULES_COLLECTION = "notification-center:timelapse-rules";
@@ -6702,6 +7484,12 @@ var NotificationCenter = class NotificationCenter {
6702
7484
  * snapshots; watched keys are recomputed from the enabled occupancy rules. */
6703
7485
  occupancyWatcher = new OccupancyWatcher();
6704
7486
  occupancyStore;
7487
+ /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7488
+ * reads it with no I/O, the drain tick refreshes it. */
7489
+ snoozes;
7490
+ /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
7491
+ * read with no I/O on the persist hook. */
7492
+ deviceStates;
6705
7493
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
6706
7494
  * the watcher is idle (zero per-frame cost) otherwise. */
6707
7495
  occupancyEnabled = false;
@@ -6734,10 +7522,22 @@ var NotificationCenter = class NotificationCenter {
6734
7522
  store: deps.store,
6735
7523
  logger: this.logger.child("occupancy")
6736
7524
  });
7525
+ this.deviceStates = new NcDeviceStateCache({
7526
+ logger: this.logger.child("device-state"),
7527
+ readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
7528
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7529
+ });
7530
+ this.snoozes = new NcSnoozeStore({
7531
+ store: deps.store,
7532
+ logger: this.logger.child("snooze"),
7533
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7534
+ });
6737
7535
  this.dispatcher = new NcDispatcher({
6738
7536
  ...deps.dispatcher,
6739
7537
  logger: this.logger.child("dispatch"),
6740
- isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId)
7538
+ ...deps.now !== void 0 ? { now: deps.now } : {},
7539
+ isRuleTargetDisabled: (ruleId, targetId) => this.rules.isRuleTargetDisabled(ruleId, targetId),
7540
+ snoozeGate: (input) => this.snoozeGate(input)
6741
7541
  });
6742
7542
  this.outbox = new NcOutbox({
6743
7543
  store: deps.store,
@@ -6767,6 +7567,7 @@ var NotificationCenter = class NotificationCenter {
6767
7567
  await NcRuleStore.declare(store);
6768
7568
  await NcOutbox.declare(store);
6769
7569
  await OccupancyStore.declare(store);
7570
+ await NcSnoozeStore.declare(store);
6770
7571
  await TimelapseStore.declare(store);
6771
7572
  }
6772
7573
  /**
@@ -6777,8 +7578,10 @@ var NotificationCenter = class NotificationCenter {
6777
7578
  */
6778
7579
  async start(opts) {
6779
7580
  await this.rules.load();
7581
+ await this.snoozes.load();
6780
7582
  this.refreshOccupancyWatch();
6781
7583
  if (!opts.evaluation) return;
7584
+ await this.deviceStates.refresh();
6782
7585
  this.evaluationActive = true;
6783
7586
  await this.outbox.load();
6784
7587
  await this.seedCooldowns();
@@ -7005,6 +7808,12 @@ var NotificationCenter = class NotificationCenter {
7005
7808
  this.logger.debug("getHistory: skipping malformed outbox row", { meta: { id: entry.id } });
7006
7809
  }
7007
7810
  return { entries: mapped };
7811
+ },
7812
+ listSnoozes: async ({ caller }) => ({ snoozes: [...this.snoozes.listVisibleTo(caller.userId, caller.isAdmin)] }),
7813
+ createSnooze: async ({ snooze, caller }) => ({ snooze: await this.createSnooze(snooze, caller) }),
7814
+ cancelSnooze: async ({ snoozeId, caller }) => {
7815
+ await this.cancelSnooze(snoozeId, caller);
7816
+ return { success: true };
7008
7817
  }
7009
7818
  };
7010
7819
  }
@@ -7044,7 +7853,7 @@ var NotificationCenter = class NotificationCenter {
7044
7853
  let anyMatched = false;
7045
7854
  const rejections = [];
7046
7855
  for (const rule of candidates) {
7047
- const evaluation = evaluateRule(rule, subject);
7856
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
7048
7857
  if (!evaluation.matched) {
7049
7858
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
7050
7859
  this.logger.debug("rule did not match", {
@@ -7135,6 +7944,7 @@ var NotificationCenter = class NotificationCenter {
7135
7944
  ...rule.media.gif === true ? { mediaGif: true } : {},
7136
7945
  ...rule.media.clip === true ? { mediaClip: true } : {},
7137
7946
  ...rule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: rule.media.clipPreRollSec } : {},
7947
+ ...rule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: rule.media.clipSpeed } : {},
7138
7948
  ...rule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: rule.media.clipPostRollSec } : {},
7139
7949
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
7140
7950
  ...target.params !== void 0 ? { params: target.params } : {},
@@ -7244,6 +8054,13 @@ var NotificationCenter = class NotificationCenter {
7244
8054
  }
7245
8055
  this.occupancyWatcher.setWatchedKeys(specs);
7246
8056
  this.occupancyEnabled = specs.length > 0;
8057
+ const gated = [];
8058
+ for (const rule of this.rules.list()) {
8059
+ if (!rule.enabled) continue;
8060
+ const ds = rule.conditions.deviceState;
8061
+ if (ds !== void 0) gated.push(ds.deviceId);
8062
+ }
8063
+ this.deviceStates.setWatched(gated);
7247
8064
  }
7248
8065
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
7249
8066
  * hydrate the watcher from the store, then prune orphaned durable rows to the
@@ -7260,6 +8077,7 @@ var NotificationCenter = class NotificationCenter {
7260
8077
  await this.rules.load();
7261
8078
  this.refreshOccupancyWatch();
7262
8079
  if (!this.evaluationActive) return;
8080
+ await this.deviceStates.refresh();
7263
8081
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
7264
8082
  }
7265
8083
  /** The full-key set the watcher currently tracks (post prune) — the bound for
@@ -7327,9 +8145,127 @@ var NotificationCenter = class NotificationCenter {
7327
8145
  await this.outbox.setWatermark(now);
7328
8146
  }
7329
8147
  async drainTick() {
8148
+ await this.snoozes.load();
7330
8149
  await this.outbox.drainOnce();
8150
+ await this.deliverDueDigests();
7331
8151
  this.drainTicks += 1;
7332
- if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) await this.outbox.setWatermark(this.now());
8152
+ if (this.drainTicks % WATERMARK_EVERY_TICKS === 0) {
8153
+ await this.outbox.setWatermark(this.now());
8154
+ await this.snoozes.prune(this.now());
8155
+ }
8156
+ }
8157
+ /**
8158
+ * The dispatcher's gate: is this delivery silenced, and if so, count it.
8159
+ *
8160
+ * Returns the window's id (the dispatcher logs it) or null. Recording is
8161
+ * awaited rather than fired off so a suppression cannot be lost to a shutdown
8162
+ * between the decision and the row — an under-reported digest is the one
8163
+ * failure of this feature the operator cannot detect.
8164
+ */
8165
+ async snoozeGate(input) {
8166
+ const active = this.snoozes.activeFor({
8167
+ nowMs: input.at,
8168
+ ...input.ownerUserId !== void 0 ? { userId: input.ownerUserId } : {},
8169
+ ruleId: input.ruleId,
8170
+ deviceId: input.deviceId
8171
+ });
8172
+ if (active === null) return null;
8173
+ if (active.summary) await this.snoozes.recordSuppressed({
8174
+ snoozeId: active.id,
8175
+ targetId: input.targetId,
8176
+ ruleId: input.ruleId,
8177
+ ruleName: input.ruleName,
8178
+ deviceId: input.deviceId,
8179
+ at: input.at
8180
+ });
8181
+ return active.id;
8182
+ }
8183
+ /**
8184
+ * Send the summary owed by every window that has closed since the last tick.
8185
+ *
8186
+ * The digest goes exactly where the silenced notifications would have gone —
8187
+ * one per target that was actually suppressed — so it needs no routing of its
8188
+ * own and can never reach someone the snooze never touched.
8189
+ */
8190
+ async deliverDueDigests() {
8191
+ const due = this.snoozes.dueDigests(this.now());
8192
+ for (const snooze of due) try {
8193
+ const rows = await this.snoozes.suppressedFor(snooze.id);
8194
+ const names = await this.dispatcher.resolveDeviceNames(rows.map((r) => r.deviceId));
8195
+ for (const targetId of digestTargets(rows)) {
8196
+ const digest = buildSnoozeDigest(snooze, rows.filter((r) => r.targetId === targetId), names);
8197
+ if (digest === null) continue;
8198
+ await this.dispatcher.deliverDigest({
8199
+ snoozeId: snooze.id,
8200
+ targetId,
8201
+ title: digest.title,
8202
+ body: digest.body,
8203
+ total: digest.total
8204
+ });
8205
+ }
8206
+ await this.snoozes.markDigestSent(snooze.id);
8207
+ } catch (err) {
8208
+ this.logger.warn("snooze digest tick failed", { meta: {
8209
+ snoozeId: snooze.id,
8210
+ error: String(err)
8211
+ } });
8212
+ }
8213
+ }
8214
+ /** The durable snooze store — the cap provider and the `nc.*` bridge serve
8215
+ * CRUD from it, the same write-through cache the gate reads. */
8216
+ get snoozeStore() {
8217
+ return this.snoozes;
8218
+ }
8219
+ /**
8220
+ * Open a window on behalf of a caller, with the permission check that makes
8221
+ * `global` safe. Lives here rather than in the store because it needs the
8222
+ * RULE (its `snoozeAllowGlobal` opt-in), and a permission decision that can
8223
+ * silence other people belongs where both halves are visible.
8224
+ */
8225
+ async createSnooze(input, caller) {
8226
+ if (input.scope === "rule" && input.ruleId === void 0) throw new Error("a rule snooze needs a ruleId");
8227
+ if (input.scope === "device" && input.deviceId === void 0) throw new Error("a camera snooze needs a deviceId");
8228
+ const wantsGlobal = input.global === true;
8229
+ if (wantsGlobal) {
8230
+ const rule = input.ruleId !== void 0 ? this.rules.get(input.ruleId) : null;
8231
+ if (!canSetGlobal({
8232
+ scope: input.scope,
8233
+ ...rule !== null ? { ruleAllowsGlobal: rule.snoozeAllowGlobal === true } : {},
8234
+ isAdmin: caller.isAdmin
8235
+ })) throw new Error("forbidden: this snooze may not be made global");
8236
+ }
8237
+ const snooze = await this.snoozes.create({
8238
+ userId: caller.userId,
8239
+ scope: input.scope,
8240
+ ...input.ruleId !== void 0 ? { ruleId: input.ruleId } : {},
8241
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
8242
+ durationMinutes: input.durationMinutes,
8243
+ global: wantsGlobal,
8244
+ summary: input.summary !== false
8245
+ });
8246
+ this.logger.info("snooze opened", {
8247
+ ...snooze.deviceId !== void 0 ? { tags: { deviceId: snooze.deviceId } } : {},
8248
+ meta: {
8249
+ snoozeId: snooze.id,
8250
+ scope: snooze.scope,
8251
+ by: caller.userId,
8252
+ global: snooze.global,
8253
+ minutes: input.durationMinutes,
8254
+ ...snooze.ruleId !== void 0 ? { ruleId: snooze.ruleId } : {}
8255
+ }
8256
+ });
8257
+ return snooze;
8258
+ }
8259
+ /** End a window early. A user may end their own; an admin may end any. */
8260
+ async cancelSnooze(snoozeId, caller) {
8261
+ const existing = this.snoozes.get(snoozeId);
8262
+ if (existing === null) throw new Error(`snooze not found: ${snoozeId}`);
8263
+ if (!caller.isAdmin && existing.userId !== caller.userId) throw new Error(`forbidden: snooze not owned: ${snoozeId}`);
8264
+ await this.snoozes.cancel(snoozeId);
8265
+ this.logger.info("snooze cancelled", { meta: {
8266
+ snoozeId,
8267
+ by: caller.userId
8268
+ } });
7333
8269
  }
7334
8270
  /** The "at least one addressee" invariant lives here, not in Zod: the
7335
8271
  * schema allows empty `targets` (a users-only rule) and a cross-field
@@ -7369,7 +8305,7 @@ var NotificationCenter = class NotificationCenter {
7369
8305
  const results = [];
7370
8306
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
7371
8307
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
7372
- const evaluation = evaluateRule(rule, subject);
8308
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
7373
8309
  results.push({
7374
8310
  recordId: subject.recordId,
7375
8311
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -7446,6 +8382,21 @@ var ncActions = require_dist.defineCustomActions({
7446
8382
  }), require_dist.object({ success: require_dist.literal(true) }), {
7447
8383
  kind: "mutation",
7448
8384
  caller: "required"
8385
+ }),
8386
+ /**
8387
+ * Snooze, from the phone — the surface that matters most for this feature,
8388
+ * because "silence this until morning" is a thing you press in bed, not at a
8389
+ * desk. Scoped to the caller: their own windows plus the global ones that
8390
+ * silence them.
8391
+ */
8392
+ "nc.listSnoozes": require_dist.customAction(require_dist.object({}), require_dist.object({ snoozes: require_dist.array(require_dist.NcSnoozeSchema) }), { caller: "required" }),
8393
+ "nc.createSnooze": require_dist.customAction(require_dist.object({ snooze: require_dist.NcSnoozeInputSchema }), require_dist.object({ snooze: require_dist.NcSnoozeSchema }), {
8394
+ kind: "mutation",
8395
+ caller: "required"
8396
+ }),
8397
+ "nc.cancelSnooze": require_dist.customAction(require_dist.object({ snoozeId: require_dist.string() }), require_dist.object({ success: require_dist.literal(true) }), {
8398
+ kind: "mutation",
8399
+ caller: "required"
7449
8400
  })
7450
8401
  });
7451
8402
  /** Fail-closed caller resolution — an absent forwarded caller is NEVER admin. */
@@ -7538,6 +8489,28 @@ function makeNcActionHandlers(deps) {
7538
8489
  } });
7539
8490
  return { success: true };
7540
8491
  },
8492
+ "nc.listSnoozes": async (_input, caller) => {
8493
+ const c = requireCaller(caller);
8494
+ return { snoozes: [...deps.snooze.list(c.userId, c.isAdmin)] };
8495
+ },
8496
+ "nc.createSnooze": async (input, caller) => {
8497
+ const c = requireCaller(caller);
8498
+ const parsed = require_dist.NcSnoozeInputSchema.parse(input.snooze);
8499
+ if (parsed.ruleId !== void 0) assertRuleVisible(parsed.ruleId, c);
8500
+ const snooze = await deps.snooze.create(parsed, c);
8501
+ deps.logger.info("nc snooze opened", { meta: {
8502
+ snoozeId: snooze.id,
8503
+ scope: snooze.scope,
8504
+ by: c.userId,
8505
+ global: snooze.global
8506
+ } });
8507
+ return { snooze };
8508
+ },
8509
+ "nc.cancelSnooze": async (input, caller) => {
8510
+ const c = requireCaller(caller);
8511
+ await deps.snooze.cancel(input.snoozeId, c);
8512
+ return { success: true };
8513
+ },
7541
8514
  "nc.setRuleTargetEnabled": async (input, caller) => {
7542
8515
  const c = requireCaller(caller);
7543
8516
  assertRuleVisible(input.ruleId, c);
@@ -11866,6 +12839,37 @@ var OpsLogStore = class {
11866
12839
  }
11867
12840
  return out;
11868
12841
  }
12842
+ /**
12843
+ * Delete audit entries older than `cutoffMs`. Returns how many went.
12844
+ *
12845
+ * The audit log is the one collection that records everyone else's
12846
+ * retention and had none of its own — it grows monotonically for the life of
12847
+ * the node (11,519 rows on the operator's hub before this existed).
12848
+ *
12849
+ * ONE statement, not a select-then-delete drain: `deleteWhere` is exactly
12850
+ * the bulk path that removed the N+1 loops from every other retention path.
12851
+ * `whereBetween` on `at` is inclusive, so `[0, cutoffMs - 1]` selects
12852
+ * strictly-older rows — the same boundary `EventStore.pruneBefore` uses.
12853
+ *
12854
+ * BEST-EFFORT, like `append`: a failed audit trim must never fail the
12855
+ * retention sweep that called it. It warns rather than throwing, because a
12856
+ * trim that silently does nothing is how a table gets to 11,519 rows.
12857
+ */
12858
+ async pruneBefore(cutoffMs) {
12859
+ try {
12860
+ const { deleted } = await this.store.deleteWhere.mutate({
12861
+ collection: OPS_LOG_COLLECTION,
12862
+ filter: { whereBetween: { at: [0, cutoffMs - 1] } }
12863
+ });
12864
+ return deleted;
12865
+ } catch (err) {
12866
+ this.logger.warn("ops-log: prune failed — the audit log will keep growing", { meta: {
12867
+ cutoffMs,
12868
+ error: err instanceof Error ? err.message : String(err)
12869
+ } });
12870
+ return 0;
12871
+ }
12872
+ }
11869
12873
  };
11870
12874
  /** SQLite stores nullable columns as `null`; the row schema uses `.nullable()`
11871
12875
  * for deviceId/detail (accepts null) but drop any stray null on required
@@ -20341,6 +21345,12 @@ var NcArtifactSecretSchema = require_dist.string();
20341
21345
  var EmbeddingEnabledSchema = require_dist.boolean();
20342
21346
  var SILENCE_FLOOR_DBFS = -55;
20343
21347
  var RETENTION_SWEEP_INTERVAL_MS = 5 * 6e4;
21348
+ /**
21349
+ * How long the events ops-log is kept. 30 days: long enough to answer "what
21350
+ * deleted my footage last month", short enough that the audit never becomes
21351
+ * the largest thing the node stores.
21352
+ */
21353
+ var OPS_LOG_RETENTION_MS = 720 * 60 * 6e4;
20344
21354
  /** Throttle for the "overlay synthesis failed" warn — one line / minute / device. */
20345
21355
  var OVERLAY_SYNTHESIS_WARN_THROTTLE_MS = 6e4;
20346
21356
  var AUDIO_EVENT_HEARTBEAT_MS = 5e3;
@@ -20854,6 +21864,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20854
21864
  return makeNcActionHandlers({
20855
21865
  ruleStore: center.ruleStore,
20856
21866
  logger: this.ctx.logger.child("nc-actions"),
21867
+ snooze: {
21868
+ list: (userId, isAdmin) => center.snoozeStore.listVisibleTo(userId, isAdmin),
21869
+ create: (input, caller) => center.createSnooze(input, caller),
21870
+ cancel: (snoozeId, caller) => center.cancelSnooze(snoozeId, caller)
21871
+ },
20857
21872
  listCallerTargetIds: async (userId) => {
20858
21873
  return (await api.notificationOutput.listTargets.query({})).filter((t) => t.config["ownerUserId"] === userId).map((t) => t.id);
20859
21874
  }
@@ -20885,7 +21900,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
20885
21900
  let storage = this.ctx.kernel.storage;
20886
21901
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
20887
21902
  if (mediaRoot) {
20888
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DrRsUzaw.js"));
21903
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BQUEteve.js"));
20889
21904
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
20890
21905
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
20891
21906
  }
@@ -21242,6 +22257,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21242
22257
  addonId: device.addonId
21243
22258
  };
21244
22259
  },
22260
+ readDeviceStates: async (ids) => {
22261
+ const out = /* @__PURE__ */ new Map();
22262
+ for (const id of ids) try {
22263
+ const state = require_dist.readDeviceStateFrom(await api.deviceManager.loadRuntimeState.query({ deviceId: id }));
22264
+ if (state !== void 0) out.set(id, state);
22265
+ } catch (err) {
22266
+ this.ctx.logger.debug("device-state read failed", {
22267
+ tags: { deviceId: id },
22268
+ meta: { error: String(err) }
22269
+ });
22270
+ }
22271
+ return out;
22272
+ },
21245
22273
  dispatcher: {
21246
22274
  getZoneNames: async (deviceId) => {
21247
22275
  return (await api.zones.listZones.query({ deviceId })).map((z) => ({
@@ -21266,6 +22294,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21266
22294
  postRollSec: req.postRollSec ?? NC_FOOTAGE_POST_ROLL_SEC,
21267
22295
  maxWidth: NC_FOOTAGE_MAX_WIDTH,
21268
22296
  fps: NC_FOOTAGE_FPS,
22297
+ ...req.speed !== void 0 ? { speed: req.speed } : {},
21269
22298
  ...req.profile === "high" || req.profile === "mid" || req.profile === "low" ? { profile: req.profile } : {}
21270
22299
  });
21271
22300
  const buf = Buffer.from(res.base64, "base64");
@@ -21423,6 +22452,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21423
22452
  this.sweepRetention();
21424
22453
  this.runTrackRetentionSweep();
21425
22454
  this.stationaryRegistry?.sweep(Date.now());
22455
+ this.eventsOpsLog?.pruneBefore(Date.now() - OPS_LOG_RETENTION_MS);
21426
22456
  }, RETENTION_SWEEP_INTERVAL_MS);
21427
22457
  }
21428
22458
  /** Cap provider registrations returned from `onInitialize` — the widgets