@camstack/addon-post-analysis 1.2.280 → 1.2.282

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.
@@ -1,4 +1,4 @@
1
- import { $ as RECORDING_SOURCE_CAMSTACK_ADDON, $t as CamProfileSchema, At as isScheduleActive, B as NcRuleUpdateInputSchema, Bt as readDeviceStateFrom, C as MAX_BIRTH_DECISION_RECORDS, Ct as faceGalleryCapability, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dt as isEventOwnerType, E as MediaFileKindEnum, Et as isDetectionMacroClass, F as NcConditionDescriptorSchema, Ft as occupancyScope, G as NcSystemEventKindSchema, Gt as storageOccupancyCapability, H as NcSnoozeInputSchema, Ht as resolveLocationMode, I as NcRuleInputSchema, J as NcTemplatePreviewSchema, Jt as templateVarsFor, K as NcTaxonomySchema, Kt as subKindsOf, L as NcRulePatchSchema, Lt as pickClusterStepModels, M as NC_TEMPLATE_VARS, Mt as kebabToCamel, N as NcAlarmConfigSchema, Nt as mayWriteToLocation, O as NC_CONDITION_CATALOG, Ot as isKnownTemplateVar, P as NcAlarmSettingsPatchSchema, Pt as notificationRulesCapability, Q as RECORDING_EXPORT_MAX_READ_BYTES, Qt as BaseAddon, R as NcRuleSchema, Rt as pipelineAnalyticsCapability, S as MAX_ARCHIVED_DEBUG_NOTES, St as evictionPolicyOfLocation, T as MOTION_CLOSE_AFTER_MS, U as NcSnoozeSchema, V as NcScheduleSchema, Vt as readTimelapseGeneratedAt, W as NcSnoozeSuppressedSchema, Wt as sceneMonitorCapability, X as OpsLogEntrySchema, Xt as zoneAnalyticsCapability, Y as NcTemplateVarDescriptorSchema, Yt as vectorDimFromBase64, Zt as errMsg$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as string, _t as cosineSimilarity$1, a as BirthDecisionRecordSchema, an as isDeviceScopedCap, at as SceneMonitorSchema, b as LabelAttributionSchema, bt as encodeVectorBase64, c as CoverStateSchema, cn as _enum, ct as TimelapseRulePatchSchema, dn as discriminatedUnion, dt as addonWidgetsSourceCapability, en as DeviceType, et as RetrainStatusSchema, f as DETECTION_MACRO_CLASSES, fn as literal, ft as alarmPanelCapability, g as EVENT_OWNER_TYPES, gn as record, gt as buildEventKindDescriptor, h as EVENT_KIND_BY_CAP, hn as partialRecord, ht as audioModeOf, i as BaseDevice, in as hydrateSchema, it as SCENE_DIVERGED, j as NC_TAXONOMY, jt as isSourceCap, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isMediaOwnerType, l as DEFAULT_EVENT_COLOR, ln as array, lt as TimelapseRuleSchema, m as DeclaredDevices, mn as object, mt as audioMetricsCapability, n as AlarmArmModeSchema, nn as customAction, nt as SCENE_DEFAULT_ANCHOR_THRESHOLD, o as CLUSTER_MODEL_SCOPED_STEPS, on as nodePin, ot as TIMELAPSE_DENSE_FLOOR_SEC, pn as number, pt as assertTimelapseCadences, q as NcTemplatePreviewInputSchema, qt as systemEventFilterApplies, r as ArchivedDebugNoteSchema, rn as defineCustomActions, rt as SCENE_DEFAULT_UNCOVERED_POLICY, s as COCO_TO_MACRO, sn as sleep$1, st as TimelapseRuleInputSchema, t as AUDIO_MACRO_LABELS, tn as createEvent, un as boolean, ut as TrackSourceSchema, v as FULL_IMAGE_BBOX, vn as unknown, vt as deriveRecordingMode, wt as failureContributionCapability, x as LockStateSchema, xt as evaluateSensorEdge, y as FailureCounters, yn as EventCategory, z as NcRuleTargetSchema, zt as plateGalleryCapability } from "../dist-Z6AO1MsY.mjs";
1
+ import { $t as systemEventFamilyProblems, A as NC_SENSOR_TRANSITION_KINDS, At as isEventOwnerType, B as NcRuleSchema, C as MAX_BIRTH_DECISION_RECORDS, Cn as partialRecord, Ct as encodeVectorBase64, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dn as EventCategory, Dt as failureContributionCapability, E as MediaFileKindEnum, En as unknown, Et as faceGalleryCapability, F as NcAlarmSettingsPatchSchema, Ft as isSystemDelivery, G as NcSnoozeSchema, Gt as readTimelapseGeneratedAt, H as NcRuleUpdateInputSchema, Ht as pipelineAnalyticsCapability, I as NcConditionDescriptorSchema, It as kebabToCamel, J as NcTaxonomySchema, Jt as ruleBelongsToAlarm$1, K as NcSnoozeSuppressedSchema, Kt as resolveLocationMode, L as NcConditionsSchema, Lt as mayWriteToLocation, M as NC_TAXONOMY, Mt as isMediaOwnerType, N as NC_TEMPLATE_VARS, Nt as isScheduleActive, O as NC_CONDITION_CATALOG, P as NcAlarmConfigSchema, Pt as isSourceCap, Q as OpsLogEntrySchema, Qt as subKindsOf, R as NcRuleInputSchema, Rt as notificationRulesCapability, S as MAX_ARCHIVED_DEBUG_NOTES, Sn as object, T as MOTION_CLOSE_AFTER_MS, Tn as string, Tt as evictionPolicyOfLocation, U as NcScheduleSchema, Ut as plateGalleryCapability, V as NcRuleTargetSchema, Vt as pickClusterStepModels, W as NcSnoozeInputSchema, Wt as readDeviceStateFrom, X as NcTemplatePreviewSchema, Xt as sensorRaisedOf, Y as NcTemplatePreviewInputSchema, Yt as sceneMonitorCapability, Z as NcTemplateVarDescriptorSchema, Zt as storageOccupancyCapability, _ as FIRST_LEVEL_MACRO_CLASSES, _n as array, _t as audioModeOf, a as BirthDecisionRecordSchema, an as canonicalHash, at as SCENE_DEFAULT_UNCOVERED_POLICY, b as LabelAttributionSchema, bn as literal, bt as deriveRecordingMode, c as CoverStateSchema, cn as DeviceType, ct as TIMELAPSE_DENSE_FLOOR_SEC, dn as defineCustomActions, dt as TimelapseRuleSchema, en as systemEventFilterApplies, et as RECORDING_EXPORT_MAX_READ_BYTES, f as DETECTION_MACRO_CLASSES, fn as hydrateSchema, ft as TrackSourceSchema, g as EVENT_OWNER_TYPES, gn as _enum, gt as audioMetricsCapability, h as EVENT_KIND_BY_CAP, hn as sleep$1, ht as assertTimelapseCadences, i as BaseDevice, in as errMsg$1, it as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as isKnownTemplateVar, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isDetectionMacroClass, l as DEFAULT_EVENT_COLOR, ln as createEvent, lt as TimelapseRuleInputSchema, m as DeclaredDevices, mn as nodePin, mt as alarmPanelCapability, n as AlarmArmModeSchema, nn as vectorDimFromBase64, nt as RetrainStatusSchema, o as CLUSTER_MODEL_SCOPED_STEPS, on as BaseAddon, ot as SCENE_DIVERGED, pn as isDeviceScopedCap, pt as addonWidgetsSourceCapability, q as NcSystemEventKindSchema, r as ArchivedDebugNoteSchema, rn as zoneAnalyticsCapability, s as COCO_TO_MACRO, sn as CamProfileSchema, st as SceneMonitorSchema, t as AUDIO_MACRO_LABELS, tn as templateVarsFor, tt as RECORDING_SOURCE_CAMSTACK_ADDON, un as customAction, ut as TimelapseRulePatchSchema, v as FULL_IMAGE_BBOX, vn as boolean, vt as buildEventKindDescriptor, wn as record, wt as evaluateSensorEdge, x as LockStateSchema, xn as number, xt as describeSystemEventFamilyProblem, y as FailureCounters, yn as discriminatedUnion, yt as cosineSimilarity$1, z as NcRulePatchSchema, zt as occupancyScope } from "../dist-C3zTKShG.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -281,6 +281,7 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
281
281
  const ids = /* @__PURE__ */ new Set();
282
282
  let ruleCount = 0;
283
283
  let allDevices = false;
284
+ let except = null;
284
285
  for (const rule of rules) {
285
286
  if (!rule.enabled) continue;
286
287
  const gate = rule.conditions.deviceState;
@@ -288,21 +289,35 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
288
289
  if (!gate.states.includes(wanted)) continue;
289
290
  ruleCount += 1;
290
291
  const scope = rule.conditions.devices;
292
+ const excluded = rule.conditions.devicesExclude ?? [];
291
293
  if (scope === void 0 || scope.length === 0) {
292
294
  allDevices = true;
295
+ except = narrowExcept(except, excluded);
293
296
  continue;
294
297
  }
295
- for (const id of scope) ids.add(id);
298
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
296
299
  }
297
300
  const deviceIds = [...ids].toSorted((a, b) => a - b);
301
+ const allDevicesExcept = except === null || except === "none" ? [] : [...except].filter((id) => !ids.has(id)).toSorted((a, b) => a - b);
298
302
  return {
299
303
  mode,
300
304
  ruleCount,
301
305
  allDevices,
306
+ allDevicesExcept,
302
307
  deviceIds,
303
308
  skippedDevices: skippedFor(deviceIds, skipProbe)
304
309
  };
305
310
  }
311
+ /**
312
+ * Fold one UNSCOPED covering rule into the except-set: with no exclusion it
313
+ * covers everything (`'none'`, final); otherwise only what every unscoped rule
314
+ * excludes stays uncovered — the intersection.
315
+ */
316
+ function narrowExcept(current, excluded) {
317
+ if (current === "none" || excluded.length === 0) return "none";
318
+ if (current === null) return new Set(excluded);
319
+ return new Set(excluded.filter((id) => current.has(id)));
320
+ }
306
321
  function skippedFor(deviceIds, skipProbe) {
307
322
  if (skipProbe === void 0) return [];
308
323
  const out = [];
@@ -376,6 +391,19 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
376
391
  textCount: names.length
377
392
  };
378
393
  }
394
+ if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
395
+ const names = coverage.allDevicesExcept.map(deviceName);
396
+ return {
397
+ bodyKey: "alarm.arm.body.allDevicesExcept",
398
+ textParams: {
399
+ rules,
400
+ mode,
401
+ count: `${names.length}`,
402
+ names: names.join(", ")
403
+ },
404
+ textCount: names.length
405
+ };
406
+ }
379
407
  if (coverage.allDevices) return {
380
408
  bodyKey: "alarm.arm.body.allDevices",
381
409
  textParams: {
@@ -594,7 +622,8 @@ function alarmCoveredDevices(rules, panelDeviceId, mode) {
594
622
  if (!gate.states.includes(wanted)) continue;
595
623
  const scope = rule.conditions.devices;
596
624
  if (scope === void 0 || scope.length === 0) continue;
597
- for (const id of scope) ids.add(id);
625
+ const excluded = rule.conditions.devicesExclude ?? [];
626
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
598
627
  }
599
628
  return [...ids].toSorted((a, b) => a - b);
600
629
  }
@@ -7788,10 +7817,10 @@ function replaceConditionReferences(monitor, condition, reference) {
7788
7817
  //#endregion
7789
7818
  //#region src/notification-center/snooze-exempt.ts
7790
7819
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
7791
- var ALARM_PANEL_CAP$2 = "alarm-panel";
7820
+ var ALARM_PANEL_CAP$1 = "alarm-panel";
7792
7821
  var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
7793
7822
  function actuatesAlarmPanel(sequences) {
7794
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
7823
+ for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
7795
7824
  return false;
7796
7825
  }
7797
7826
  /**
@@ -9556,6 +9585,7 @@ function isLinkedSetSubject(subject) {
9556
9585
  function subjectFromSensorEvent(ev, markerTrackId, linked) {
9557
9586
  const eventType = readSensorEventType(ev.value);
9558
9587
  const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
9588
+ const sensorRaised = sensorRaisedOf(ev.kind, ev.value);
9559
9589
  return {
9560
9590
  kind: "device-event",
9561
9591
  recordId: String(ev.id),
@@ -9569,6 +9599,7 @@ function subjectFromSensorEvent(ev, markerTrackId, linked) {
9569
9599
  sensorKind: ev.kind,
9570
9600
  ...eventType !== void 0 ? { eventType } : {},
9571
9601
  ...sensorEdge !== void 0 ? { sensorEdge } : {},
9602
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
9572
9603
  ...linked !== void 0 ? {
9573
9604
  linkedSensorId: ev.sourceDeviceId,
9574
9605
  linkedCameraIds: [...linked.linkedCameraIds],
@@ -9796,6 +9827,7 @@ function presentConditionIds(c) {
9796
9827
  const ids = [];
9797
9828
  if (c.systemEvent !== void 0) ids.push("systemEvent");
9798
9829
  if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
9830
+ if (c.devicesExclude !== void 0 && c.devicesExclude.length > 0) ids.push("devicesExclude");
9799
9831
  if (c.source !== void 0 && c.source !== "any") ids.push("source");
9800
9832
  if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
9801
9833
  if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
@@ -9812,6 +9844,7 @@ function presentConditionIds(c) {
9812
9844
  if (c.plates !== void 0) ids.push("plates");
9813
9845
  if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
9814
9846
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
9847
+ if (c.sensorTransition !== void 0) ids.push("sensorTransition");
9815
9848
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
9816
9849
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
9817
9850
  if (c.occupancy !== void 0) ids.push("occupancy");
@@ -9906,12 +9939,23 @@ function zonesVisitedBy(subject) {
9906
9939
  * before the subject exists (the linked-camera capture gate). Coarse in one
9907
9940
  * direction only: `true` may still fail later; `false` never would have fired.
9908
9941
  */
9909
- function deviceEventRuleMayFireOnKind(rule, sensorKind) {
9942
+ function deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised) {
9910
9943
  const c = rule.conditions;
9911
9944
  if (c.occupancy !== void 0) return false;
9945
+ if (sensorTransitionRefuses(c.sensorTransition, sensorRaised)) return false;
9912
9946
  if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
9913
9947
  return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
9914
9948
  }
9949
+ /**
9950
+ * Does a rule's `sensorTransition` refuse this edge? Only a KNOWN edge can be
9951
+ * refused: `sensorRaised === undefined` (a kind with no raise, an unreadable
9952
+ * slice) always passes — a flood rule never goes silent on a bad read (D640).
9953
+ * The ONE predicate {@link evaluateRule} and the capture gate both ask.
9954
+ */
9955
+ function sensorTransitionRefuses(transition, sensorRaised) {
9956
+ if (transition === void 0 || sensorRaised === void 0) return false;
9957
+ return sensorRaised !== (transition === "raised");
9958
+ }
9915
9959
  function toLowerSet(values) {
9916
9960
  return new Set(values.map((v) => v.trim().toLowerCase()));
9917
9961
  }
@@ -9948,9 +9992,6 @@ function matchesIdentityScope(subject, scope) {
9948
9992
  function ruleAwaitsRecognition(rule) {
9949
9993
  return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
9950
9994
  }
9951
- var ARMED_WORD_PREFIX = "armed_";
9952
- var ALARM_PANEL_CAP$1 = "alarm-panel";
9953
- var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9954
9995
  /**
9955
9996
  * A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
9956
9997
  * `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
@@ -9968,8 +10009,10 @@ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9968
10009
  * catalog on save, so a new key would be dropped by the first edit.
9969
10010
  */
9970
10011
  function ruleBelongsToAlarm(rule) {
9971
- if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
9972
- return (rule.actions?.onTrigger ?? []).some((sequence) => sequence.enabled && sequence.actions.some((step) => step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1 && step.method === ALARM_PANEL_TRIGGER_METHOD));
10012
+ return ruleBelongsToAlarm$1({
10013
+ conditions: rule.conditions,
10014
+ ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
10015
+ });
9973
10016
  }
9974
10017
  /**
9975
10018
  * The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
@@ -10145,6 +10188,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10145
10188
  if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
10146
10189
  }
10147
10190
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
10191
+ if (c.devicesExclude !== void 0 && deviceScopeExcludes(c.devicesExclude, subject)) return fail("devicesExclude");
10148
10192
  if (c.source !== void 0 && c.source !== "any") {
10149
10193
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
10150
10194
  }
@@ -10221,6 +10265,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10221
10265
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
10222
10266
  if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
10223
10267
  }
10268
+ if (sensorTransitionRefuses(c.sensorTransition, subject.sensorRaised)) return fail("sensorTransition");
10224
10269
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
10225
10270
  if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
10226
10271
  }
@@ -10326,6 +10371,27 @@ function matchesDeviceScope(scope, subject) {
10326
10371
  if (scope.includes(subject.deviceId)) return true;
10327
10372
  return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
10328
10373
  }
10374
+ /**
10375
+ * Does a rule's `devicesExclude` (D641) exclude this subject?
10376
+ *
10377
+ * The exact complement of {@link matchesDeviceScope}, row by row. A per-camera
10378
+ * row is excluded by its camera OR by the sensor that fired, the two ids an
10379
+ * include matches on. A linked set stands for the rows it replaced (D630 §6):
10380
+ * it is excluded when its SENSOR is, or when EVERY camera that took a row is —
10381
+ * one excluded row camera leaves the others firing, as their own rows would
10382
+ * have. A camera that only LINKS the sensor excludes nothing, for the reason it
10383
+ * includes nothing: ADR 0632 widens the media, never which rules fire.
10384
+ */
10385
+ function deviceScopeExcludes(excluded, subject) {
10386
+ if (excluded.length === 0) return false;
10387
+ if (subject.linkedSensorId !== void 0) {
10388
+ if (excluded.includes(subject.linkedSensorId)) return true;
10389
+ const rows = subject.rowCameraIds ?? [];
10390
+ return rows.length > 0 && rows.every((cameraId) => excluded.includes(cameraId));
10391
+ }
10392
+ if (excluded.includes(subject.deviceId)) return true;
10393
+ return subject.sourceDeviceId !== void 0 && excluded.includes(subject.sourceDeviceId);
10394
+ }
10329
10395
  function cooldownKey(rule, subject) {
10330
10396
  const first = subject.classNames[0];
10331
10397
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -12453,6 +12519,7 @@ var en_default = {
12453
12519
  "snooze.scope.anyRule": "A rule",
12454
12520
  "snooze.scope.rule": "“{{rule}}”",
12455
12521
  "alarm.arm.body.allDevices": "Active: all devices",
12522
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
12456
12523
  "alarm.arm.body.noDevices": "Active: no device restriction",
12457
12524
  "alarm.arm.body.devices": "Active: {{names}}",
12458
12525
  "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
@@ -12741,6 +12808,7 @@ var it_default = {
12741
12808
  "snooze.scope.anyRule": "Una regola",
12742
12809
  "snooze.scope.rule": "“{{rule}}”",
12743
12810
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
12811
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
12744
12812
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
12745
12813
  "alarm.arm.body.devices": "Attivi: {{names}}",
12746
12814
  "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
@@ -13143,6 +13211,245 @@ function pickTrackNarrationFrames(rows, max) {
13143
13211
  return picked;
13144
13212
  }
13145
13213
  //#endregion
13214
+ //#region src/notification-center/rule-family-guard.ts
13215
+ /**
13216
+ * The hub's half of D637: a NEW or UPDATED system-event rule answers ONE family
13217
+ * (device events, system, alarm) and carries only that family's filters.
13218
+ *
13219
+ * THE authority for the invariant. The cap-provider route
13220
+ * (`notificationRules.createRule/updateRule`, index.ts) and the rule bridge
13221
+ * (`nc.createRule/updateRule`, nc-actions.ts) both call it through
13222
+ * `guardRuleFamily` (create) and `guardPatchFamily` (update), so the two save
13223
+ * paths cannot drift. This is the same
13224
+ * arrangement as `assertRuleHasAddressee`.
13225
+ *
13226
+ * Not in Zod: a cross-field refine on `NcConditionsSchema` would also reject the
13227
+ * STORED mixed rules at load (NcRuleSchema.parse in the store), and those must
13228
+ * keep firing until the operator recreates them. The engine is untouched, and
13229
+ * so is the device-prune runner: removing a deleted device id from a stored
13230
+ * rule is not authoring.
13231
+ */
13232
+ var NcRuleFamilyRefusal = class extends Error {
13233
+ problems;
13234
+ /** The rule that was judged: the input on a create, the MERGED rule on an update. */
13235
+ rule;
13236
+ constructor(problems, rule) {
13237
+ super(`system-event rule refused: ${problems.map(describeSystemEventFamilyProblem).join("; ")}`);
13238
+ this.name = "NcRuleFamilyRefusal";
13239
+ this.problems = problems;
13240
+ this.rule = rule;
13241
+ }
13242
+ };
13243
+ function assertRuleFamily(rule) {
13244
+ if (!isSystemDelivery(rule.delivery)) return;
13245
+ const problems = systemEventFamilyProblems(rule.conditions.systemEvent);
13246
+ if (problems.length > 0) throw new NcRuleFamilyRefusal(problems, rule);
13247
+ }
13248
+ var byValue = (a, b) => a < b ? -1 : a > b ? 1 : 0;
13249
+ var byNumber = (a, b) => a - b;
13250
+ /**
13251
+ * A system-event condition with every list sorted. The engine reads `kinds` and
13252
+ * the id-list filters as SETS (membership only), so an editor that re-sorts
13253
+ * them — the admin's `toggleSystemEventKind` puts kinds in option order — has
13254
+ * not changed the rule. Copies; the input is never touched.
13255
+ */
13256
+ function sortedSystemEvent(condition) {
13257
+ const { kinds, deviceIds, excludeDeviceIds, deviceTypes, nodeIds, packageNames } = condition;
13258
+ return {
13259
+ ...condition,
13260
+ kinds: kinds.toSorted(byValue),
13261
+ ...deviceIds !== void 0 ? { deviceIds: deviceIds.toSorted(byNumber) } : {},
13262
+ ...excludeDeviceIds !== void 0 ? { excludeDeviceIds: excludeDeviceIds.toSorted(byNumber) } : {},
13263
+ ...deviceTypes !== void 0 ? { deviceTypes: deviceTypes.toSorted(byValue) } : {},
13264
+ ...nodeIds !== void 0 ? { nodeIds: nodeIds.toSorted(byValue) } : {},
13265
+ ...packageNames !== void 0 ? { packageNames: packageNames.toSorted(byValue) } : {}
13266
+ };
13267
+ }
13268
+ /** The canonical form two conditions are compared in. */
13269
+ function conditionsFingerprint(conditions) {
13270
+ const { systemEvent } = conditions;
13271
+ return canonicalHash(systemEvent === void 0 ? conditions : {
13272
+ ...conditions,
13273
+ systemEvent: sortedSystemEvent(systemEvent)
13274
+ });
13275
+ }
13276
+ /**
13277
+ * Do two conditions say the same thing? Both go through `NcConditionsSchema`
13278
+ * (the normalisation the store applies on every write) and are compared
13279
+ * canonically: key order is irrelevant, an `undefined` key equals an absent
13280
+ * one, and the system-event `kinds` and id-list filters are compared as SETS.
13281
+ * Every other array keeps its order. A side that does not parse is treated as
13282
+ * a change, so it is judged rather than waved through.
13283
+ */
13284
+ function sameConditions(a, b) {
13285
+ const left = NcConditionsSchema.safeParse(a);
13286
+ const right = NcConditionsSchema.safeParse(b);
13287
+ if (!left.success || !right.success) return false;
13288
+ return conditionsFingerprint(left.data) === conditionsFingerprint(right.data);
13289
+ }
13290
+ /**
13291
+ * The rule a patch WOULD produce, as far as the family is concerned — merged
13292
+ * the way the store merges it: `delivery` and `conditions` replace whole.
13293
+ *
13294
+ * `null` when the patch CHANGES neither. Both editors send the whole rule on
13295
+ * every save, so "the patch carries `conditions`" is not "the patch changes
13296
+ * them": an unchanged full-form save of a stored mixed rule (a rename, a
13297
+ * retarget) must pass, and only a save that alters the delivery or the
13298
+ * conditions is judged.
13299
+ */
13300
+ function mergedFamilySubject(existing, patch) {
13301
+ const deliveryChanged = patch.delivery !== void 0 && patch.delivery !== existing.delivery;
13302
+ const conditionsChanged = patch.conditions !== void 0 && !sameConditions(patch.conditions, existing.conditions);
13303
+ if (!deliveryChanged && !conditionsChanged) return null;
13304
+ return {
13305
+ delivery: patch.delivery ?? existing.delivery,
13306
+ conditions: patch.conditions ?? existing.conditions
13307
+ };
13308
+ }
13309
+ /**
13310
+ * On update, the MERGED rule is judged. A patch that changes neither `delivery`
13311
+ * nor `conditions` (absent, or equal to what is stored) leaves the kinds as
13312
+ * they are, so a stored mixed rule can still be switched off, renamed or
13313
+ * retargeted before the operator recreates it.
13314
+ */
13315
+ function assertPatchKeepsFamily(existing, patch) {
13316
+ if (existing === null) return;
13317
+ const merged = mergedFamilySubject(existing, patch);
13318
+ if (merged !== null) assertRuleFamily(merged);
13319
+ }
13320
+ /**
13321
+ * Run a family check and LOG a refusal: who tried to save, the rule that was
13322
+ * judged (its delivery and kinds) and every problem in words (the families,
13323
+ * the offending kind or filter). Then rethrow. A refused save is dropped work,
13324
+ * and dropped work is never silent. Any other error passes through unlogged.
13325
+ */
13326
+ function logRefusal$2(check, ctx) {
13327
+ try {
13328
+ check();
13329
+ } catch (err) {
13330
+ if (err instanceof NcRuleFamilyRefusal) ctx.logger.warn("nc rule refused: outside one family", { meta: {
13331
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13332
+ by: ctx.by,
13333
+ delivery: err.rule.delivery,
13334
+ kinds: err.rule.conditions.systemEvent?.kinds ?? [],
13335
+ problems: err.problems.map(describeSystemEventFamilyProblem)
13336
+ } });
13337
+ throw err;
13338
+ }
13339
+ }
13340
+ /** {@link assertRuleFamily}, logged. The create path of both routes. */
13341
+ function guardRuleFamily(rule, ctx) {
13342
+ logRefusal$2(() => assertRuleFamily(rule), ctx);
13343
+ }
13344
+ /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13345
+ function guardPatchFamily(existing, patch, ctx) {
13346
+ logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
13347
+ }
13348
+ //#endregion
13349
+ //#region src/notification-center/device-scope-guard.ts
13350
+ /**
13351
+ * The deliveries whose evaluation reads the device scope — the `devices`
13352
+ * descriptor's `appliesTo`, restated as the one delivery that lacks it.
13353
+ */
13354
+ var NO_DEVICE_SCOPE_DELIVERY = "system-event";
13355
+ var NcDevicesExcludeRefusal = class extends Error {
13356
+ delivery;
13357
+ devicesExclude;
13358
+ constructor(delivery, devicesExclude) {
13359
+ super(`rule refused: devicesExclude does not apply to a '${delivery}' rule — a system rule excludes devices with systemEvent.excludeDeviceIds`);
13360
+ this.name = "NcDevicesExcludeRefusal";
13361
+ this.delivery = delivery;
13362
+ this.devicesExclude = devicesExclude;
13363
+ }
13364
+ };
13365
+ function assertDevicesExcludeDelivery(rule) {
13366
+ const excluded = rule.conditions.devicesExclude;
13367
+ if (excluded === void 0 || rule.delivery !== NO_DEVICE_SCOPE_DELIVERY) return;
13368
+ throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
13369
+ }
13370
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13371
+ function logRefusal$1(check, ctx) {
13372
+ try {
13373
+ check();
13374
+ } catch (err) {
13375
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$1(ctx.logger, err, ctx);
13376
+ throw err;
13377
+ }
13378
+ }
13379
+ function warnRefusal$1(logger, err, ctx) {
13380
+ logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
13381
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13382
+ by: ctx.by,
13383
+ delivery: err.delivery,
13384
+ devicesExclude: [...err.devicesExclude]
13385
+ } });
13386
+ }
13387
+ /** The create path of both routes. */
13388
+ function guardRuleDevicesExclude(rule, ctx) {
13389
+ logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
13390
+ }
13391
+ /**
13392
+ * The update path of both routes: the MERGED rule is judged, and only when the
13393
+ * patch changes `delivery` or `conditions`.
13394
+ */
13395
+ function guardPatchDevicesExclude(existing, patch, ctx) {
13396
+ if (existing === null) return;
13397
+ const merged = mergedFamilySubject(existing, patch);
13398
+ if (merged === null) return;
13399
+ logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
13400
+ }
13401
+ //#endregion
13402
+ //#region src/notification-center/sensor-transition-guard.ts
13403
+ /** The one delivery whose subject carries a sensor edge. */
13404
+ var SENSOR_TRANSITION_DELIVERY = "device-event";
13405
+ var NcSensorTransitionRefusal = class extends Error {
13406
+ delivery;
13407
+ sensorTransition;
13408
+ constructor(delivery, sensorTransition) {
13409
+ super(`rule refused: sensorTransition '${sensorTransition}' applies only to a device-event rule, not '${delivery}'`);
13410
+ this.name = "NcSensorTransitionRefusal";
13411
+ this.delivery = delivery;
13412
+ this.sensorTransition = sensorTransition;
13413
+ }
13414
+ };
13415
+ function assertSensorTransitionDelivery(rule) {
13416
+ const transition = rule.conditions.sensorTransition;
13417
+ if (transition === void 0 || rule.delivery === SENSOR_TRANSITION_DELIVERY) return;
13418
+ throw new NcSensorTransitionRefusal(rule.delivery, transition);
13419
+ }
13420
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13421
+ function logRefusal(check, ctx) {
13422
+ try {
13423
+ check();
13424
+ } catch (err) {
13425
+ if (err instanceof NcSensorTransitionRefusal) warnRefusal(ctx.logger, err, ctx);
13426
+ throw err;
13427
+ }
13428
+ }
13429
+ function warnRefusal(logger, err, ctx) {
13430
+ logger.warn("nc rule refused: sensorTransition off a device-event rule", { meta: {
13431
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13432
+ by: ctx.by,
13433
+ delivery: err.delivery,
13434
+ sensorTransition: err.sensorTransition
13435
+ } });
13436
+ }
13437
+ /** The create path of both routes. */
13438
+ function guardRuleSensorTransition(rule, ctx) {
13439
+ logRefusal(() => assertSensorTransitionDelivery(rule), ctx);
13440
+ }
13441
+ /**
13442
+ * The update path of both routes: the MERGED rule is judged, and only when the
13443
+ * patch changes `delivery` or `conditions` — an unchanged full-form save
13444
+ * (a rename, a retarget) is not re-judged.
13445
+ */
13446
+ function guardPatchSensorTransition(existing, patch, ctx) {
13447
+ if (existing === null) return;
13448
+ const merged = mergedFamilySubject(existing, patch);
13449
+ if (merged === null) return;
13450
+ logRefusal(() => assertSensorTransitionDelivery(merged), ctx);
13451
+ }
13452
+ //#endregion
13146
13453
  //#region src/notification-center/occupancy-watcher.ts
13147
13454
  /** Sentinel key segments for the "no zone" (whole-frame) and "no class" scopes. */
13148
13455
  var FRAME_SCOPE = "@frame";
@@ -13911,6 +14218,19 @@ function labelFor(conditions) {
13911
14218
  return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
13912
14219
  }
13913
14220
  /**
14221
+ * The edge a planned sensor event carries (D640): RAISED by default — the
14222
+ * alert, which is what an operator pressing Test on a flood rule expects to
14223
+ * see — and cleared only for a rule that asks for the clear. Only for a kind
14224
+ * that HAS a raise: a doorbell press carries none in production, and a test
14225
+ * subject claiming one would be a subject no real record could be.
14226
+ */
14227
+ function sensorEdgeFor(trigger, c) {
14228
+ const kind = c.sensorKinds?.[0];
14229
+ if (trigger !== "device-event" || kind === void 0) return {};
14230
+ if (!NC_SENSOR_TRANSITION_KINDS.includes(kind.trim().toLowerCase())) return {};
14231
+ return { sensorRaised: c.sensorTransition !== "cleared" };
14232
+ }
14233
+ /**
13914
14234
  * Plan the injection that fires this rule.
13915
14235
  *
13916
14236
  * Every condition the engine reads off the SUBJECT is satisfied here; the two
@@ -13932,6 +14252,10 @@ function planRuleTest(input) {
13932
14252
  note: "This rule names no camera and none was supplied, so the event is attributed to no device and carries no picture. Scope the rule to a camera, or pick one, to see the notification it would really send."
13933
14253
  });
13934
14254
  const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
14255
+ if (trigger !== "system-event" && (c.devicesExclude ?? []).includes(deviceId)) notes.push({
14256
+ conditionId: "devicesExclude",
14257
+ note: "The camera this test uses is one the rule excludes, so the evaluation will refuse the event — exactly as it refuses every real one there. Pick another camera to see the notification."
14258
+ });
13935
14259
  const scheduledAt = scheduleActiveAt(rule.schedule, now);
13936
14260
  if (scheduledAt === null) notes.push({
13937
14261
  conditionId: "schedule",
@@ -13981,6 +14305,7 @@ function planRuleTest(input) {
13981
14305
  } } : {},
13982
14306
  ...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
13983
14307
  ...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
14308
+ ...sensorEdgeFor(trigger, c),
13984
14309
  ...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
13985
14310
  ...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
13986
14311
  ...bbox !== void 0 ? { bbox } : {},
@@ -14499,6 +14824,10 @@ var NcTestEventInputSchema = object({
14499
14824
  /** DEVICE-EVENT: the raw device event-type token, when the source is an
14500
14825
  * event-emitter slice. */
14501
14826
  eventType: string().optional(),
14827
+ /** DEVICE-EVENT: which edge a boolean sensor moved on — true raised, false
14828
+ * cleared (D640). Absent = the slice could not say, which production
14829
+ * answers for a doorbell press or an unreadable read. */
14830
+ sensorRaised: boolean().optional(),
14502
14831
  /** PACKAGE-EVENT: which lifecycle phase. */
14503
14832
  packagePhase: _enum(["delivered", "picked-up"]).optional(),
14504
14833
  /** Detection provenance. Absent = `pipeline` (what a record with no stamped
@@ -14717,6 +15046,7 @@ function buildSyntheticEvent(input, recordId, now) {
14717
15046
  ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
14718
15047
  ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
14719
15048
  ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
15049
+ ...input.sensorRaised !== void 0 ? { sensorRaised: input.sensorRaised } : {},
14720
15050
  ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
14721
15051
  ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
14722
15052
  ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
@@ -15843,6 +16173,18 @@ function makeNcActionHandlers(deps) {
15843
16173
  const parsed = NcRuleInputSchema.parse(input.rule);
15844
16174
  assertAdminOnlyFields(parsed, [], c);
15845
16175
  assertRuleHasAddressee(parsed.targets, parsed.targetUsers);
16176
+ guardRuleFamily(parsed, {
16177
+ logger: deps.logger,
16178
+ by: c.userId
16179
+ });
16180
+ guardRuleSensorTransition(parsed, {
16181
+ logger: deps.logger,
16182
+ by: c.userId
16183
+ });
16184
+ guardRuleDevicesExclude(parsed, {
16185
+ logger: deps.logger,
16186
+ by: c.userId
16187
+ });
15846
16188
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
15847
16189
  const rule = await deps.ruleStore.create({
15848
16190
  ...parsed,
@@ -15861,6 +16203,21 @@ function makeNcActionHandlers(deps) {
15861
16203
  const clear = input.clear ?? [];
15862
16204
  assertAdminOnlyFields(patch, clear, c, existing);
15863
16205
  assertPatchKeepsAddressee(existing, patch, clear);
16206
+ guardPatchFamily(existing, patch, {
16207
+ logger: deps.logger,
16208
+ by: c.userId,
16209
+ ruleId: input.ruleId
16210
+ });
16211
+ guardPatchSensorTransition(existing, patch, {
16212
+ logger: deps.logger,
16213
+ by: c.userId,
16214
+ ruleId: input.ruleId
16215
+ });
16216
+ guardPatchDevicesExclude(existing, patch, {
16217
+ logger: deps.logger,
16218
+ by: c.userId,
16219
+ ruleId: input.ruleId
16220
+ });
15864
16221
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
15865
16222
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
15866
16223
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -19004,6 +19361,19 @@ function pruneRuleConditions(conditions, isDead, notes) {
19004
19361
  notes.removed("conditions.devices", deviceSubjects(verdict.dead));
19005
19362
  }
19006
19363
  }
19364
+ const excluded = conditions.devicesExclude;
19365
+ if (excluded !== void 0) {
19366
+ const verdict = judgeList(excluded, isDead);
19367
+ if (!verdict.untouched) {
19368
+ const { devicesExclude: _dead, ...rest } = next;
19369
+ next = verdict.last ? rest : {
19370
+ ...rest,
19371
+ devicesExclude: [...verdict.next]
19372
+ };
19373
+ changed = true;
19374
+ notes.removed("conditions.devicesExclude", deviceSubjects(verdict.dead));
19375
+ }
19376
+ }
19007
19377
  const systemEvent = conditions.systemEvent;
19008
19378
  if (systemEvent?.deviceIds !== void 0) {
19009
19379
  const verdict = judgeList(systemEvent.deviceIds, isDead);
@@ -19184,6 +19554,7 @@ function referencedDeviceIds(snapshot) {
19184
19554
  for (const rule of snapshot.rules) {
19185
19555
  const c = rule.conditions;
19186
19556
  add(c.devices);
19557
+ add(c.devicesExclude);
19187
19558
  add(c.systemEvent?.deviceIds);
19188
19559
  if (c.deviceState !== void 0) ids.add(c.deviceState.deviceId);
19189
19560
  if (c.scene?.deviceId !== void 0) ids.add(c.scene.deviceId);
@@ -22037,6 +22408,50 @@ var NcOutbox = class {
22037
22408
  return [];
22038
22409
  }
22039
22410
  }
22411
+ /**
22412
+ * The rows `getRuleLastRuns` reduces — ONE statement: fire time inside
22413
+ * `[sinceMs, ∞)`, newest-first on `idx_nc_outbox_created`, projected to the
22414
+ * four columns the reduction reads (never the `payload` JSON), at most
22415
+ * `limit` rows. `sinceMs: null` = no lower bound (the operator keeps the
22416
+ * outbox forever); `limit` still bounds the read.
22417
+ *
22418
+ * Unlike {@link queryHistory} this THROWS on a store error: its caller
22419
+ * answers "which rules did not run", and an empty page from a failed read
22420
+ * would answer "none of them did" (D393).
22421
+ */
22422
+ async queryLastRunRows(sinceMs, limit) {
22423
+ const rows = await this.store.query.query({
22424
+ collection: NC_OUTBOX_COLLECTION,
22425
+ filter: {
22426
+ ...sinceMs !== null ? { whereBetween: { createdAt: [sinceMs, Number.MAX_SAFE_INTEGER] } } : {},
22427
+ orderBy: {
22428
+ field: "createdAt",
22429
+ direction: "desc"
22430
+ },
22431
+ limit
22432
+ },
22433
+ columns: [...NC_LAST_RUN_COLUMNS]
22434
+ });
22435
+ const out = [];
22436
+ let malformed = 0;
22437
+ let oldestReadAt = null;
22438
+ for (const row of rows) {
22439
+ const at = Number(row.data["createdAt"]);
22440
+ if (Number.isFinite(at) && (oldestReadAt === null || at < oldestReadAt)) oldestReadAt = at;
22441
+ const parsed = lastRunRow(row.data);
22442
+ if (parsed === null) malformed += 1;
22443
+ else out.push(parsed);
22444
+ }
22445
+ if (malformed > 0) this.logger.warn("last-run read skipped malformed outbox rows", { meta: {
22446
+ malformed,
22447
+ read: rows.length
22448
+ } });
22449
+ return {
22450
+ rows: out,
22451
+ truncated: rows.length >= limit,
22452
+ oldestReadAt
22453
+ };
22454
+ }
22040
22455
  async getWatermark() {
22041
22456
  try {
22042
22457
  const row = await this.store.get.query({
@@ -22324,6 +22739,27 @@ function rowToEntry$1(id, data) {
22324
22739
  payload
22325
22740
  };
22326
22741
  }
22742
+ /** The columns {@link NcOutbox.queryLastRunRows} projects. */
22743
+ var NC_LAST_RUN_COLUMNS = [
22744
+ "ruleId",
22745
+ "createdAt",
22746
+ "status",
22747
+ "deviceId"
22748
+ ];
22749
+ /** A projected row back to a {@link NcLastRunRow} — a guard, never a cast. */
22750
+ function lastRunRow(data) {
22751
+ const ruleId = data["ruleId"];
22752
+ const createdAt = Number(data["createdAt"]);
22753
+ const deviceId = Number(data["deviceId"]);
22754
+ const status = data["status"];
22755
+ if (typeof ruleId !== "string" || !Number.isFinite(createdAt) || !Number.isFinite(deviceId) || status !== "pending" && status !== "sent" && status !== "dead") return null;
22756
+ return {
22757
+ ruleId,
22758
+ createdAt,
22759
+ status,
22760
+ deviceId
22761
+ };
22762
+ }
22327
22763
  //#endregion
22328
22764
  //#region src/notification-center/rule-actions.ts
22329
22765
  var NcRuleActionRunner = class {
@@ -23015,6 +23451,58 @@ var NcRuleAiStore = class {
23015
23451
  }
23016
23452
  };
23017
23453
  //#endregion
23454
+ //#region src/notification-center/rule-last-runs.ts
23455
+ /**
23456
+ * Row bound of the one read. Projected rows are four scalars (~100 B), so the
23457
+ * bound costs ~2 MB at worst. The live outbox volume was NOT measured when
23458
+ * this was chosen. It exists so a runaway rule cannot turn an admin page load
23459
+ * into a table walk — and when it bites, `horizonMs` says so.
23460
+ */
23461
+ var NC_RULE_LAST_RUNS_SCAN_LIMIT = 2e4;
23462
+ /** Least successful first: a fire that reached some targets and not others
23463
+ * did not "deliver". */
23464
+ var STATUS_SEVERITY = {
23465
+ sent: 0,
23466
+ pending: 1,
23467
+ dead: 2
23468
+ };
23469
+ function worse(a, b) {
23470
+ return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a;
23471
+ }
23472
+ /**
23473
+ * Reduce newest-first rows to one run per rule, in one pass.
23474
+ *
23475
+ * Rows sharing the newest `createdAt` of a rule are one fire to several
23476
+ * targets; they merge into the least successful status. `read.rows` MUST be
23477
+ * ordered newest-first — that is what the outbox read asks the store for.
23478
+ */
23479
+ function summariseRuleLastRuns(read, scan) {
23480
+ const byRule = /* @__PURE__ */ new Map();
23481
+ for (const row of read.rows) {
23482
+ const seen = byRule.get(row.ruleId);
23483
+ if (seen === void 0) byRule.set(row.ruleId, {
23484
+ ruleId: row.ruleId,
23485
+ lastAt: row.createdAt,
23486
+ status: row.status,
23487
+ deviceId: row.deviceId
23488
+ });
23489
+ else if (seen.lastAt === row.createdAt && worse(seen.status, row.status) !== seen.status) byRule.set(row.ruleId, {
23490
+ ...seen,
23491
+ status: row.status,
23492
+ deviceId: row.deviceId
23493
+ });
23494
+ }
23495
+ return {
23496
+ runs: [...byRule.values()],
23497
+ horizonMs: horizonOf(read, scan)
23498
+ };
23499
+ }
23500
+ /** How far back the answer really looked — see the module doc. */
23501
+ function horizonOf(read, scan) {
23502
+ if (read.truncated && read.oldestReadAt !== null) return Math.max(0, scan.now - read.oldestReadAt);
23503
+ return scan.windowMs;
23504
+ }
23505
+ //#endregion
23018
23506
  //#region src/notification-center/durable-reread.ts
23019
23507
  function isRecord$3(value) {
23020
23508
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -27979,9 +28467,14 @@ function outboxEntryToHistory(texts, entry) {
27979
28467
  function renderAudioSpec(spec) {
27980
28468
  return spec.mode === "label" ? `label:${spec.labels.join("+")}` : `level:${String(spec.dbThreshold)}dBFS|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
27981
28469
  }
27982
- /** The camera scope in one token — `@all` is a real answer, not a missing one. */
27983
- function renderAudioScope(devices) {
27984
- return devices.length === 0 ? "@all" : devices.join("+");
28470
+ /**
28471
+ * The camera scope in one token — `@all` is a real answer, not a missing one,
28472
+ * and `@all-except:<ids>` is a different one (D641): an excluding rule logged
28473
+ * as `@all` would claim the camera it never watches.
28474
+ */
28475
+ function renderAudioScope(devices, excluded) {
28476
+ if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
28477
+ return devices.filter((id) => !excluded.includes(id)).join("+");
27985
28478
  }
27986
28479
  var NotificationCenter = class NotificationCenter {
27987
28480
  logger;
@@ -29723,7 +30216,7 @@ var NotificationCenter = class NotificationCenter {
29723
30216
  * scope the evaluation will match on (`matchesDeviceScope`).
29724
30217
  */
29725
30218
  beginLinkedStills(request) {
29726
- const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
30219
+ const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
29727
30220
  const shelf = this.linkedStills;
29728
30221
  if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
29729
30222
  if (this.deviceMutes.isMuted(sensorId)) return void 0;
@@ -29733,7 +30226,13 @@ var NotificationCenter = class NotificationCenter {
29733
30226
  const involved = [sensorId, ...rowShown];
29734
30227
  if (!this.rules.listEnabled("device-event").some((rule) => {
29735
30228
  if (rule.media.attach === "none") return false;
29736
- if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
30229
+ if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
30230
+ const excluded = rule.conditions.devicesExclude;
30231
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, {
30232
+ deviceId: sensorId,
30233
+ linkedSensorId: sensorId,
30234
+ rowCameraIds: rowShown
30235
+ })) return false;
29737
30236
  const devices = rule.conditions.devices;
29738
30237
  return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
29739
30238
  })) return void 0;
@@ -29855,6 +30354,8 @@ var NotificationCenter = class NotificationCenter {
29855
30354
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
29856
30355
  const devices = rule.conditions.devices;
29857
30356
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
30357
+ const excluded = rule.conditions.devicesExclude;
30358
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId })) continue;
29858
30359
  return shelf.capture(deviceId, atMs, "audio");
29859
30360
  }
29860
30361
  }
@@ -29957,6 +30458,8 @@ var NotificationCenter = class NotificationCenter {
29957
30458
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
29958
30459
  const devices = rule.conditions.devices;
29959
30460
  if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
30461
+ const excluded = rule.conditions.devicesExclude;
30462
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId: edge.deviceId })) continue;
29960
30463
  return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
29961
30464
  }
29962
30465
  }
@@ -30088,6 +30591,18 @@ var NotificationCenter = class NotificationCenter {
30088
30591
  getRule: async ({ ruleId }) => ({ rule: this.rules.get(ruleId) }),
30089
30592
  createRule: async ({ rule, caller }) => {
30090
30593
  assertRuleHasAddressee(rule.targets, rule.targetUsers);
30594
+ guardRuleFamily(rule, {
30595
+ logger: this.logger,
30596
+ by: caller.userId
30597
+ });
30598
+ guardRuleSensorTransition(rule, {
30599
+ logger: this.logger,
30600
+ by: caller.userId
30601
+ });
30602
+ guardRuleDevicesExclude(rule, {
30603
+ logger: this.logger,
30604
+ by: caller.userId
30605
+ });
30091
30606
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
30092
30607
  const created = await this.rules.create(rule, caller.userId);
30093
30608
  this.logger.info("notification rule created", { meta: {
@@ -30099,7 +30614,23 @@ var NotificationCenter = class NotificationCenter {
30099
30614
  },
30100
30615
  updateRule: async ({ ruleId, patch, clear, caller }) => {
30101
30616
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
30102
- assertPatchKeepsAddressee(this.rules.get(ruleId), patch, clear ?? []);
30617
+ const existing = this.rules.get(ruleId);
30618
+ assertPatchKeepsAddressee(existing, patch, clear ?? []);
30619
+ guardPatchFamily(existing, patch, {
30620
+ logger: this.logger,
30621
+ by: caller.userId,
30622
+ ruleId
30623
+ });
30624
+ guardPatchSensorTransition(existing, patch, {
30625
+ logger: this.logger,
30626
+ by: caller.userId,
30627
+ ruleId
30628
+ });
30629
+ guardPatchDevicesExclude(existing, patch, {
30630
+ logger: this.logger,
30631
+ by: caller.userId,
30632
+ ruleId
30633
+ });
30103
30634
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
30104
30635
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
30105
30636
  this.logger.info("notification rule updated", { meta: {
@@ -30141,6 +30672,14 @@ var NotificationCenter = class NotificationCenter {
30141
30672
  }
30142
30673
  return { entries: mapped };
30143
30674
  },
30675
+ getRuleLastRuns: async () => {
30676
+ const now = this.now();
30677
+ const windowMs = (await this.deps.pruneWindows()).outboxMs;
30678
+ return summariseRuleLastRuns(await this.outbox.queryLastRunRows(windowMs !== null ? now - windowMs : null, NC_RULE_LAST_RUNS_SCAN_LIMIT), {
30679
+ now,
30680
+ windowMs
30681
+ });
30682
+ },
30144
30683
  resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
30145
30684
  listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
30146
30685
  setDeviceMuted: async ({ deviceId, muted }) => {
@@ -30472,6 +31011,7 @@ var NotificationCenter = class NotificationCenter {
30472
31011
  coverage: this.coverageFor(panel).map((c) => ({
30473
31012
  ...c,
30474
31013
  deviceIds: [...c.deviceIds],
31014
+ allDevicesExcept: [...c.allDevicesExcept],
30475
31015
  skippedDevices: c.skippedDevices.map((s) => ({ ...s }))
30476
31016
  }))
30477
31017
  };
@@ -30981,6 +31521,9 @@ var NotificationCenter = class NotificationCenter {
30981
31521
  failed: evaluation.failedCondition,
30982
31522
  classes: subject.classNames,
30983
31523
  ...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
31524
+ ...subject.sensorRaised !== void 0 ? { sensorRaised: subject.sensorRaised } : {},
31525
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
31526
+ ...subject.linkedSensorId !== void 0 ? { linkedSensorId: subject.linkedSensorId } : {},
30984
31527
  eventId: subject.recordId,
30985
31528
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
30986
31529
  }
@@ -31090,7 +31633,11 @@ var NotificationCenter = class NotificationCenter {
31090
31633
  eventId: subject.recordId,
31091
31634
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31092
31635
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
31093
- ...subject.zones.length > 0 ? { zones: subject.zones } : {}
31636
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {},
31637
+ ...rule.conditions.sensorTransition !== void 0 ? {
31638
+ sensorTransition: rule.conditions.sensorTransition,
31639
+ sensorRaised: subject.sensorRaised ?? null
31640
+ } : {}
31094
31641
  }
31095
31642
  });
31096
31643
  const unresolvable = unresolvableButtons(rule.actions);
@@ -31697,7 +32244,8 @@ var NotificationCenter = class NotificationCenter {
31697
32244
  ruleId: rule.id,
31698
32245
  ruleName: rule.name,
31699
32246
  spec,
31700
- devices: rule.conditions.devices ?? []
32247
+ devices: rule.conditions.devices ?? [],
32248
+ excluded: rule.conditions.devicesExclude ?? []
31701
32249
  });
31702
32250
  }
31703
32251
  this.audioWatcher.setWatchedSpecs(audioSpecs);
@@ -31789,10 +32337,12 @@ var NotificationCenter = class NotificationCenter {
31789
32337
  * for at all"; the per-camera line answers "why is 617 not notifying" — and
31790
32338
  * that question is ALWAYS asked per camera, so the line carries
31791
32339
  * `tags.deviceId` and a rule scoped to no camera is reported as watching
31792
- * every one rather than silently omitted.
32340
+ * every one rather than silently omitted — every one EXCEPT its
32341
+ * `devicesExclude` cameras (D641), each of which gets its own NOT-watching
32342
+ * line on that camera.
31793
32343
  */
31794
32344
  reportAudioWatch(entries) {
31795
- const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices)}`).toSorted().join(",");
32345
+ const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices, e.excluded)}`).toSorted().join(",");
31796
32346
  if (rendered === this.lastAudioWatchReport) return;
31797
32347
  this.lastAudioWatchReport = rendered;
31798
32348
  this.logger.info("audio watched specs", { meta: {
@@ -31806,14 +32356,28 @@ var NotificationCenter = class NotificationCenter {
31806
32356
  mode: entry.spec.mode,
31807
32357
  spec: renderAudioSpec(entry.spec)
31808
32358
  };
32359
+ for (const deviceId of entry.excluded) this.logger.info("audio rule NOT watching camera", {
32360
+ tags: { deviceId },
32361
+ meta: {
32362
+ ...meta,
32363
+ reason: "devicesExclude"
32364
+ }
32365
+ });
31809
32366
  if (entry.devices.length === 0) {
31810
- this.logger.info("audio rule watching every camera", { meta });
32367
+ if (entry.excluded.length === 0) this.logger.info("audio rule watching every camera", { meta });
32368
+ else this.logger.info("audio rule watching every camera except the excluded", { meta: {
32369
+ ...meta,
32370
+ excluded: [...entry.excluded]
32371
+ } });
31811
32372
  continue;
31812
32373
  }
31813
- for (const deviceId of entry.devices) this.logger.info("audio rule watching camera", {
31814
- tags: { deviceId },
31815
- meta
31816
- });
32374
+ for (const deviceId of entry.devices) {
32375
+ if (entry.excluded.includes(deviceId)) continue;
32376
+ this.logger.info("audio rule watching camera", {
32377
+ tags: { deviceId },
32378
+ meta
32379
+ });
32380
+ }
31817
32381
  }
31818
32382
  }
31819
32383
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
@@ -89201,10 +89765,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
89201
89765
  startLinkedStills(data, timestamp, cameras) {
89202
89766
  const sensorKind = EVENT_KIND_BY_CAP[data.capName]?.kind;
89203
89767
  if (sensorKind === void 0) return void 0;
89768
+ const sensorRaised = sensorRaisedOf(sensorKind, sensorSliceRecord(data.slice));
89204
89769
  try {
89205
89770
  return this.notificationCenter?.beginLinkedStills({
89206
89771
  sensorId: data.deviceId,
89207
89772
  sensorKind,
89773
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
89208
89774
  cameraIds: cameras.mediaCameraIds,
89209
89775
  rowCameraIds: cameras.rowCameraIds,
89210
89776
  atMs: timestamp