@camstack/addon-post-analysis 1.2.281 → 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.
@@ -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-BtqlSCQT.js");
5
+ const require_dist = require("../dist-D0zzdvSd.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -287,6 +287,7 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
287
287
  const ids = /* @__PURE__ */ new Set();
288
288
  let ruleCount = 0;
289
289
  let allDevices = false;
290
+ let except = null;
290
291
  for (const rule of rules) {
291
292
  if (!rule.enabled) continue;
292
293
  const gate = rule.conditions.deviceState;
@@ -294,21 +295,35 @@ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
294
295
  if (!gate.states.includes(wanted)) continue;
295
296
  ruleCount += 1;
296
297
  const scope = rule.conditions.devices;
298
+ const excluded = rule.conditions.devicesExclude ?? [];
297
299
  if (scope === void 0 || scope.length === 0) {
298
300
  allDevices = true;
301
+ except = narrowExcept(except, excluded);
299
302
  continue;
300
303
  }
301
- for (const id of scope) ids.add(id);
304
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
302
305
  }
303
306
  const deviceIds = [...ids].toSorted((a, b) => a - b);
307
+ const allDevicesExcept = except === null || except === "none" ? [] : [...except].filter((id) => !ids.has(id)).toSorted((a, b) => a - b);
304
308
  return {
305
309
  mode,
306
310
  ruleCount,
307
311
  allDevices,
312
+ allDevicesExcept,
308
313
  deviceIds,
309
314
  skippedDevices: skippedFor(deviceIds, skipProbe)
310
315
  };
311
316
  }
317
+ /**
318
+ * Fold one UNSCOPED covering rule into the except-set: with no exclusion it
319
+ * covers everything (`'none'`, final); otherwise only what every unscoped rule
320
+ * excludes stays uncovered — the intersection.
321
+ */
322
+ function narrowExcept(current, excluded) {
323
+ if (current === "none" || excluded.length === 0) return "none";
324
+ if (current === null) return new Set(excluded);
325
+ return new Set(excluded.filter((id) => current.has(id)));
326
+ }
312
327
  function skippedFor(deviceIds, skipProbe) {
313
328
  if (skipProbe === void 0) return [];
314
329
  const out = [];
@@ -382,6 +397,19 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
382
397
  textCount: names.length
383
398
  };
384
399
  }
400
+ if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
401
+ const names = coverage.allDevicesExcept.map(deviceName);
402
+ return {
403
+ bodyKey: "alarm.arm.body.allDevicesExcept",
404
+ textParams: {
405
+ rules,
406
+ mode,
407
+ count: `${names.length}`,
408
+ names: names.join(", ")
409
+ },
410
+ textCount: names.length
411
+ };
412
+ }
385
413
  if (coverage.allDevices) return {
386
414
  bodyKey: "alarm.arm.body.allDevices",
387
415
  textParams: {
@@ -600,7 +628,8 @@ function alarmCoveredDevices(rules, panelDeviceId, mode) {
600
628
  if (!gate.states.includes(wanted)) continue;
601
629
  const scope = rule.conditions.devices;
602
630
  if (scope === void 0 || scope.length === 0) continue;
603
- for (const id of scope) ids.add(id);
631
+ const excluded = rule.conditions.devicesExclude ?? [];
632
+ for (const id of scope) if (!excluded.includes(id)) ids.add(id);
604
633
  }
605
634
  return [...ids].toSorted((a, b) => a - b);
606
635
  }
@@ -7795,10 +7824,10 @@ function replaceConditionReferences(monitor, condition, reference) {
7795
7824
  //#endregion
7796
7825
  //#region src/notification-center/snooze-exempt.ts
7797
7826
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
7798
- var ALARM_PANEL_CAP$2 = "alarm-panel";
7827
+ var ALARM_PANEL_CAP$1 = "alarm-panel";
7799
7828
  var ALARM_KINDS = new Set(require_dist.NC_ALARM_SYSTEM_EVENT_KINDS);
7800
7829
  function actuatesAlarmPanel(sequences) {
7801
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
7830
+ for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
7802
7831
  return false;
7803
7832
  }
7804
7833
  /**
@@ -9563,6 +9592,7 @@ function isLinkedSetSubject(subject) {
9563
9592
  function subjectFromSensorEvent(ev, markerTrackId, linked) {
9564
9593
  const eventType = readSensorEventType(ev.value);
9565
9594
  const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
9595
+ const sensorRaised = require_dist.sensorRaisedOf(ev.kind, ev.value);
9566
9596
  return {
9567
9597
  kind: "device-event",
9568
9598
  recordId: String(ev.id),
@@ -9576,6 +9606,7 @@ function subjectFromSensorEvent(ev, markerTrackId, linked) {
9576
9606
  sensorKind: ev.kind,
9577
9607
  ...eventType !== void 0 ? { eventType } : {},
9578
9608
  ...sensorEdge !== void 0 ? { sensorEdge } : {},
9609
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
9579
9610
  ...linked !== void 0 ? {
9580
9611
  linkedSensorId: ev.sourceDeviceId,
9581
9612
  linkedCameraIds: [...linked.linkedCameraIds],
@@ -9803,6 +9834,7 @@ function presentConditionIds(c) {
9803
9834
  const ids = [];
9804
9835
  if (c.systemEvent !== void 0) ids.push("systemEvent");
9805
9836
  if (c.devices !== void 0 && c.devices.length > 0) ids.push("devices");
9837
+ if (c.devicesExclude !== void 0 && c.devicesExclude.length > 0) ids.push("devicesExclude");
9806
9838
  if (c.source !== void 0 && c.source !== "any") ids.push("source");
9807
9839
  if (c.classes !== void 0 && c.classes.length > 0) ids.push("classes");
9808
9840
  if (c.classesExclude !== void 0 && c.classesExclude.length > 0) ids.push("classesExclude");
@@ -9819,6 +9851,7 @@ function presentConditionIds(c) {
9819
9851
  if (c.plates !== void 0) ids.push("plates");
9820
9852
  if (c.sensorKinds !== void 0 && c.sensorKinds.length > 0) ids.push("sensorKinds");
9821
9853
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) ids.push("eventTypeTokens");
9854
+ if (c.sensorTransition !== void 0) ids.push("sensorTransition");
9822
9855
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
9823
9856
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
9824
9857
  if (c.occupancy !== void 0) ids.push("occupancy");
@@ -9913,12 +9946,23 @@ function zonesVisitedBy(subject) {
9913
9946
  * before the subject exists (the linked-camera capture gate). Coarse in one
9914
9947
  * direction only: `true` may still fail later; `false` never would have fired.
9915
9948
  */
9916
- function deviceEventRuleMayFireOnKind(rule, sensorKind) {
9949
+ function deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised) {
9917
9950
  const c = rule.conditions;
9918
9951
  if (c.occupancy !== void 0) return false;
9952
+ if (sensorTransitionRefuses(c.sensorTransition, sensorRaised)) return false;
9919
9953
  if (c.sensorKinds === void 0 || c.sensorKinds.length === 0) return true;
9920
9954
  return toLowerSet(c.sensorKinds).has(sensorKind.trim().toLowerCase());
9921
9955
  }
9956
+ /**
9957
+ * Does a rule's `sensorTransition` refuse this edge? Only a KNOWN edge can be
9958
+ * refused: `sensorRaised === undefined` (a kind with no raise, an unreadable
9959
+ * slice) always passes — a flood rule never goes silent on a bad read (D640).
9960
+ * The ONE predicate {@link evaluateRule} and the capture gate both ask.
9961
+ */
9962
+ function sensorTransitionRefuses(transition, sensorRaised) {
9963
+ if (transition === void 0 || sensorRaised === void 0) return false;
9964
+ return sensorRaised !== (transition === "raised");
9965
+ }
9922
9966
  function toLowerSet(values) {
9923
9967
  return new Set(values.map((v) => v.trim().toLowerCase()));
9924
9968
  }
@@ -9955,9 +9999,6 @@ function matchesIdentityScope(subject, scope) {
9955
9999
  function ruleAwaitsRecognition(rule) {
9956
10000
  return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
9957
10001
  }
9958
- var ARMED_WORD_PREFIX = "armed_";
9959
- var ALARM_PANEL_CAP$1 = "alarm-panel";
9960
- var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9961
10002
  /**
9962
10003
  * A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
9963
10004
  * `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
@@ -9975,8 +10016,10 @@ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9975
10016
  * catalog on save, so a new key would be dropped by the first edit.
9976
10017
  */
9977
10018
  function ruleBelongsToAlarm(rule) {
9978
- if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
9979
- 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));
10019
+ return require_dist.ruleBelongsToAlarm({
10020
+ conditions: rule.conditions,
10021
+ ...rule.actions?.onTrigger !== void 0 ? { onTrigger: rule.actions.onTrigger } : {}
10022
+ });
9980
10023
  }
9981
10024
  /**
9982
10025
  * The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
@@ -10152,6 +10195,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10152
10195
  if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
10153
10196
  }
10154
10197
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
10198
+ if (c.devicesExclude !== void 0 && deviceScopeExcludes(c.devicesExclude, subject)) return fail("devicesExclude");
10155
10199
  if (c.source !== void 0 && c.source !== "any") {
10156
10200
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
10157
10201
  }
@@ -10228,6 +10272,7 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
10228
10272
  if (c.eventTypeTokens !== void 0 && c.eventTypeTokens.length > 0) {
10229
10273
  if (subject.eventType === void 0 || !toLowerSet(c.eventTypeTokens).has(subject.eventType.trim().toLowerCase())) return fail("eventTypeTokens");
10230
10274
  }
10275
+ if (sensorTransitionRefuses(c.sensorTransition, subject.sensorRaised)) return fail("sensorTransition");
10231
10276
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") {
10232
10277
  if (subject.packagePhase !== c.packagePhase) return fail("packagePhase");
10233
10278
  }
@@ -10333,6 +10378,27 @@ function matchesDeviceScope(scope, subject) {
10333
10378
  if (scope.includes(subject.deviceId)) return true;
10334
10379
  return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
10335
10380
  }
10381
+ /**
10382
+ * Does a rule's `devicesExclude` (D641) exclude this subject?
10383
+ *
10384
+ * The exact complement of {@link matchesDeviceScope}, row by row. A per-camera
10385
+ * row is excluded by its camera OR by the sensor that fired, the two ids an
10386
+ * include matches on. A linked set stands for the rows it replaced (D630 §6):
10387
+ * it is excluded when its SENSOR is, or when EVERY camera that took a row is —
10388
+ * one excluded row camera leaves the others firing, as their own rows would
10389
+ * have. A camera that only LINKS the sensor excludes nothing, for the reason it
10390
+ * includes nothing: ADR 0632 widens the media, never which rules fire.
10391
+ */
10392
+ function deviceScopeExcludes(excluded, subject) {
10393
+ if (excluded.length === 0) return false;
10394
+ if (subject.linkedSensorId !== void 0) {
10395
+ if (excluded.includes(subject.linkedSensorId)) return true;
10396
+ const rows = subject.rowCameraIds ?? [];
10397
+ return rows.length > 0 && rows.every((cameraId) => excluded.includes(cameraId));
10398
+ }
10399
+ if (excluded.includes(subject.deviceId)) return true;
10400
+ return subject.sourceDeviceId !== void 0 && excluded.includes(subject.sourceDeviceId);
10401
+ }
10336
10402
  function cooldownKey(rule, subject) {
10337
10403
  const first = subject.classNames[0];
10338
10404
  const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
@@ -12460,6 +12526,7 @@ var en_default = {
12460
12526
  "snooze.scope.anyRule": "A rule",
12461
12527
  "snooze.scope.rule": "“{{rule}}”",
12462
12528
  "alarm.arm.body.allDevices": "Active: all devices",
12529
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except {{count}}: {{names}}",
12463
12530
  "alarm.arm.body.noDevices": "Active: no device restriction",
12464
12531
  "alarm.arm.body.devices": "Active: {{names}}",
12465
12532
  "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
@@ -12748,6 +12815,7 @@ var it_default = {
12748
12815
  "snooze.scope.anyRule": "Una regola",
12749
12816
  "snooze.scope.rule": "“{{rule}}”",
12750
12817
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
12818
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{count}}: {{names}}",
12751
12819
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
12752
12820
  "alarm.arm.body.devices": "Attivi: {{names}}",
12753
12821
  "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
@@ -13262,7 +13330,7 @@ function assertPatchKeepsFamily(existing, patch) {
13262
13330
  * the offending kind or filter). Then rethrow. A refused save is dropped work,
13263
13331
  * and dropped work is never silent. Any other error passes through unlogged.
13264
13332
  */
13265
- function logRefusal(check, ctx) {
13333
+ function logRefusal$2(check, ctx) {
13266
13334
  try {
13267
13335
  check();
13268
13336
  } catch (err) {
@@ -13278,11 +13346,115 @@ function logRefusal(check, ctx) {
13278
13346
  }
13279
13347
  /** {@link assertRuleFamily}, logged. The create path of both routes. */
13280
13348
  function guardRuleFamily(rule, ctx) {
13281
- logRefusal(() => assertRuleFamily(rule), ctx);
13349
+ logRefusal$2(() => assertRuleFamily(rule), ctx);
13282
13350
  }
13283
13351
  /** {@link assertPatchKeepsFamily}, logged. The update path of both routes. */
13284
13352
  function guardPatchFamily(existing, patch, ctx) {
13285
- logRefusal(() => assertPatchKeepsFamily(existing, patch), ctx);
13353
+ logRefusal$2(() => assertPatchKeepsFamily(existing, patch), ctx);
13354
+ }
13355
+ //#endregion
13356
+ //#region src/notification-center/device-scope-guard.ts
13357
+ /**
13358
+ * The deliveries whose evaluation reads the device scope — the `devices`
13359
+ * descriptor's `appliesTo`, restated as the one delivery that lacks it.
13360
+ */
13361
+ var NO_DEVICE_SCOPE_DELIVERY = "system-event";
13362
+ var NcDevicesExcludeRefusal = class extends Error {
13363
+ delivery;
13364
+ devicesExclude;
13365
+ constructor(delivery, devicesExclude) {
13366
+ super(`rule refused: devicesExclude does not apply to a '${delivery}' rule — a system rule excludes devices with systemEvent.excludeDeviceIds`);
13367
+ this.name = "NcDevicesExcludeRefusal";
13368
+ this.delivery = delivery;
13369
+ this.devicesExclude = devicesExclude;
13370
+ }
13371
+ };
13372
+ function assertDevicesExcludeDelivery(rule) {
13373
+ const excluded = rule.conditions.devicesExclude;
13374
+ if (excluded === void 0 || rule.delivery !== NO_DEVICE_SCOPE_DELIVERY) return;
13375
+ throw new NcDevicesExcludeRefusal(rule.delivery, excluded);
13376
+ }
13377
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13378
+ function logRefusal$1(check, ctx) {
13379
+ try {
13380
+ check();
13381
+ } catch (err) {
13382
+ if (err instanceof NcDevicesExcludeRefusal) warnRefusal$1(ctx.logger, err, ctx);
13383
+ throw err;
13384
+ }
13385
+ }
13386
+ function warnRefusal$1(logger, err, ctx) {
13387
+ logger.warn("nc rule refused: devicesExclude on a system rule", { meta: {
13388
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13389
+ by: ctx.by,
13390
+ delivery: err.delivery,
13391
+ devicesExclude: [...err.devicesExclude]
13392
+ } });
13393
+ }
13394
+ /** The create path of both routes. */
13395
+ function guardRuleDevicesExclude(rule, ctx) {
13396
+ logRefusal$1(() => assertDevicesExcludeDelivery(rule), ctx);
13397
+ }
13398
+ /**
13399
+ * The update path of both routes: the MERGED rule is judged, and only when the
13400
+ * patch changes `delivery` or `conditions`.
13401
+ */
13402
+ function guardPatchDevicesExclude(existing, patch, ctx) {
13403
+ if (existing === null) return;
13404
+ const merged = mergedFamilySubject(existing, patch);
13405
+ if (merged === null) return;
13406
+ logRefusal$1(() => assertDevicesExcludeDelivery(merged), ctx);
13407
+ }
13408
+ //#endregion
13409
+ //#region src/notification-center/sensor-transition-guard.ts
13410
+ /** The one delivery whose subject carries a sensor edge. */
13411
+ var SENSOR_TRANSITION_DELIVERY = "device-event";
13412
+ var NcSensorTransitionRefusal = class extends Error {
13413
+ delivery;
13414
+ sensorTransition;
13415
+ constructor(delivery, sensorTransition) {
13416
+ super(`rule refused: sensorTransition '${sensorTransition}' applies only to a device-event rule, not '${delivery}'`);
13417
+ this.name = "NcSensorTransitionRefusal";
13418
+ this.delivery = delivery;
13419
+ this.sensorTransition = sensorTransition;
13420
+ }
13421
+ };
13422
+ function assertSensorTransitionDelivery(rule) {
13423
+ const transition = rule.conditions.sensorTransition;
13424
+ if (transition === void 0 || rule.delivery === SENSOR_TRANSITION_DELIVERY) return;
13425
+ throw new NcSensorTransitionRefusal(rule.delivery, transition);
13426
+ }
13427
+ /** Run the check; a refusal is dropped work, so it is logged before it is rethrown. */
13428
+ function logRefusal(check, ctx) {
13429
+ try {
13430
+ check();
13431
+ } catch (err) {
13432
+ if (err instanceof NcSensorTransitionRefusal) warnRefusal(ctx.logger, err, ctx);
13433
+ throw err;
13434
+ }
13435
+ }
13436
+ function warnRefusal(logger, err, ctx) {
13437
+ logger.warn("nc rule refused: sensorTransition off a device-event rule", { meta: {
13438
+ ...ctx.ruleId !== void 0 ? { ruleId: ctx.ruleId } : {},
13439
+ by: ctx.by,
13440
+ delivery: err.delivery,
13441
+ sensorTransition: err.sensorTransition
13442
+ } });
13443
+ }
13444
+ /** The create path of both routes. */
13445
+ function guardRuleSensorTransition(rule, ctx) {
13446
+ logRefusal(() => assertSensorTransitionDelivery(rule), ctx);
13447
+ }
13448
+ /**
13449
+ * The update path of both routes: the MERGED rule is judged, and only when the
13450
+ * patch changes `delivery` or `conditions` — an unchanged full-form save
13451
+ * (a rename, a retarget) is not re-judged.
13452
+ */
13453
+ function guardPatchSensorTransition(existing, patch, ctx) {
13454
+ if (existing === null) return;
13455
+ const merged = mergedFamilySubject(existing, patch);
13456
+ if (merged === null) return;
13457
+ logRefusal(() => assertSensorTransitionDelivery(merged), ctx);
13286
13458
  }
13287
13459
  //#endregion
13288
13460
  //#region src/notification-center/occupancy-watcher.ts
@@ -14053,6 +14225,19 @@ function labelFor(conditions) {
14053
14225
  return conditions.identities?.[0] ?? conditions.labelEquals?.[0] ?? conditions.plates?.values?.[0] ?? void 0;
14054
14226
  }
14055
14227
  /**
14228
+ * The edge a planned sensor event carries (D640): RAISED by default — the
14229
+ * alert, which is what an operator pressing Test on a flood rule expects to
14230
+ * see — and cleared only for a rule that asks for the clear. Only for a kind
14231
+ * that HAS a raise: a doorbell press carries none in production, and a test
14232
+ * subject claiming one would be a subject no real record could be.
14233
+ */
14234
+ function sensorEdgeFor(trigger, c) {
14235
+ const kind = c.sensorKinds?.[0];
14236
+ if (trigger !== "device-event" || kind === void 0) return {};
14237
+ if (!require_dist.NC_SENSOR_TRANSITION_KINDS.includes(kind.trim().toLowerCase())) return {};
14238
+ return { sensorRaised: c.sensorTransition !== "cleared" };
14239
+ }
14240
+ /**
14056
14241
  * Plan the injection that fires this rule.
14057
14242
  *
14058
14243
  * Every condition the engine reads off the SUBJECT is satisfied here; the two
@@ -14074,6 +14259,10 @@ function planRuleTest(input) {
14074
14259
  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."
14075
14260
  });
14076
14261
  const deviceId = scopedDeviceId ?? NC_SYSTEM_DEVICE_ID;
14262
+ if (trigger !== "system-event" && (c.devicesExclude ?? []).includes(deviceId)) notes.push({
14263
+ conditionId: "devicesExclude",
14264
+ 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."
14265
+ });
14077
14266
  const scheduledAt = scheduleActiveAt(rule.schedule, now);
14078
14267
  if (scheduledAt === null) notes.push({
14079
14268
  conditionId: "schedule",
@@ -14123,6 +14312,7 @@ function planRuleTest(input) {
14123
14312
  } } : {},
14124
14313
  ...c.sensorKinds?.[0] !== void 0 ? { sensorKind: c.sensorKinds[0] } : {},
14125
14314
  ...c.eventTypeTokens?.[0] !== void 0 ? { eventType: c.eventTypeTokens[0] } : {},
14315
+ ...sensorEdgeFor(trigger, c),
14126
14316
  ...trigger === "package-event" ? { packagePhase: c.packagePhase === "picked-up" ? "picked-up" : "delivered" } : {},
14127
14317
  ...c.source !== void 0 && c.source !== "any" ? { source: c.source } : {},
14128
14318
  ...bbox !== void 0 ? { bbox } : {},
@@ -14656,6 +14846,10 @@ var NcTestEventInputSchema = require_dist.object({
14656
14846
  /** DEVICE-EVENT: the raw device event-type token, when the source is an
14657
14847
  * event-emitter slice. */
14658
14848
  eventType: require_dist.string().optional(),
14849
+ /** DEVICE-EVENT: which edge a boolean sensor moved on — true raised, false
14850
+ * cleared (D640). Absent = the slice could not say, which production
14851
+ * answers for a doorbell press or an unreadable read. */
14852
+ sensorRaised: require_dist.boolean().optional(),
14659
14853
  /** PACKAGE-EVENT: which lifecycle phase. */
14660
14854
  packagePhase: require_dist._enum(["delivered", "picked-up"]).optional(),
14661
14855
  /** Detection provenance. Absent = `pipeline` (what a record with no stamped
@@ -14874,6 +15068,7 @@ function buildSyntheticEvent(input, recordId, now) {
14874
15068
  ...input.labelConfidence !== void 0 ? { labelConfidence: input.labelConfidence } : {},
14875
15069
  ...input.sensorKind !== void 0 ? { sensorKind: input.sensorKind } : {},
14876
15070
  ...input.eventType !== void 0 ? { eventType: input.eventType } : {},
15071
+ ...input.sensorRaised !== void 0 ? { sensorRaised: input.sensorRaised } : {},
14877
15072
  ...input.packagePhase !== void 0 ? { packagePhase: input.packagePhase } : {},
14878
15073
  ...input.bbox !== void 0 ? { bbox: input.bbox } : {},
14879
15074
  ...input.crossing !== void 0 ? { crossing: input.crossing } : {},
@@ -16004,6 +16199,14 @@ function makeNcActionHandlers(deps) {
16004
16199
  logger: deps.logger,
16005
16200
  by: c.userId
16006
16201
  });
16202
+ guardRuleSensorTransition(parsed, {
16203
+ logger: deps.logger,
16204
+ by: c.userId
16205
+ });
16206
+ guardRuleDevicesExclude(parsed, {
16207
+ logger: deps.logger,
16208
+ by: c.userId
16209
+ });
16007
16210
  await assertTargetsOwned(parsed.targets.map((t) => t.targetId), c);
16008
16211
  const rule = await deps.ruleStore.create({
16009
16212
  ...parsed,
@@ -16027,6 +16230,16 @@ function makeNcActionHandlers(deps) {
16027
16230
  by: c.userId,
16028
16231
  ruleId: input.ruleId
16029
16232
  });
16233
+ guardPatchSensorTransition(existing, patch, {
16234
+ logger: deps.logger,
16235
+ by: c.userId,
16236
+ ruleId: input.ruleId
16237
+ });
16238
+ guardPatchDevicesExclude(existing, patch, {
16239
+ logger: deps.logger,
16240
+ by: c.userId,
16241
+ ruleId: input.ruleId
16242
+ });
16030
16243
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
16031
16244
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
16032
16245
  const rule = await deps.ruleStore.update(input.ruleId, safe, clear);
@@ -19170,6 +19383,19 @@ function pruneRuleConditions(conditions, isDead, notes) {
19170
19383
  notes.removed("conditions.devices", deviceSubjects(verdict.dead));
19171
19384
  }
19172
19385
  }
19386
+ const excluded = conditions.devicesExclude;
19387
+ if (excluded !== void 0) {
19388
+ const verdict = judgeList(excluded, isDead);
19389
+ if (!verdict.untouched) {
19390
+ const { devicesExclude: _dead, ...rest } = next;
19391
+ next = verdict.last ? rest : {
19392
+ ...rest,
19393
+ devicesExclude: [...verdict.next]
19394
+ };
19395
+ changed = true;
19396
+ notes.removed("conditions.devicesExclude", deviceSubjects(verdict.dead));
19397
+ }
19398
+ }
19173
19399
  const systemEvent = conditions.systemEvent;
19174
19400
  if (systemEvent?.deviceIds !== void 0) {
19175
19401
  const verdict = judgeList(systemEvent.deviceIds, isDead);
@@ -19350,6 +19576,7 @@ function referencedDeviceIds(snapshot) {
19350
19576
  for (const rule of snapshot.rules) {
19351
19577
  const c = rule.conditions;
19352
19578
  add(c.devices);
19579
+ add(c.devicesExclude);
19353
19580
  add(c.systemEvent?.deviceIds);
19354
19581
  if (c.deviceState !== void 0) ids.add(c.deviceState.deviceId);
19355
19582
  if (c.scene?.deviceId !== void 0) ids.add(c.scene.deviceId);
@@ -22203,6 +22430,50 @@ var NcOutbox = class {
22203
22430
  return [];
22204
22431
  }
22205
22432
  }
22433
+ /**
22434
+ * The rows `getRuleLastRuns` reduces — ONE statement: fire time inside
22435
+ * `[sinceMs, ∞)`, newest-first on `idx_nc_outbox_created`, projected to the
22436
+ * four columns the reduction reads (never the `payload` JSON), at most
22437
+ * `limit` rows. `sinceMs: null` = no lower bound (the operator keeps the
22438
+ * outbox forever); `limit` still bounds the read.
22439
+ *
22440
+ * Unlike {@link queryHistory} this THROWS on a store error: its caller
22441
+ * answers "which rules did not run", and an empty page from a failed read
22442
+ * would answer "none of them did" (D393).
22443
+ */
22444
+ async queryLastRunRows(sinceMs, limit) {
22445
+ const rows = await this.store.query.query({
22446
+ collection: NC_OUTBOX_COLLECTION,
22447
+ filter: {
22448
+ ...sinceMs !== null ? { whereBetween: { createdAt: [sinceMs, Number.MAX_SAFE_INTEGER] } } : {},
22449
+ orderBy: {
22450
+ field: "createdAt",
22451
+ direction: "desc"
22452
+ },
22453
+ limit
22454
+ },
22455
+ columns: [...NC_LAST_RUN_COLUMNS]
22456
+ });
22457
+ const out = [];
22458
+ let malformed = 0;
22459
+ let oldestReadAt = null;
22460
+ for (const row of rows) {
22461
+ const at = Number(row.data["createdAt"]);
22462
+ if (Number.isFinite(at) && (oldestReadAt === null || at < oldestReadAt)) oldestReadAt = at;
22463
+ const parsed = lastRunRow(row.data);
22464
+ if (parsed === null) malformed += 1;
22465
+ else out.push(parsed);
22466
+ }
22467
+ if (malformed > 0) this.logger.warn("last-run read skipped malformed outbox rows", { meta: {
22468
+ malformed,
22469
+ read: rows.length
22470
+ } });
22471
+ return {
22472
+ rows: out,
22473
+ truncated: rows.length >= limit,
22474
+ oldestReadAt
22475
+ };
22476
+ }
22206
22477
  async getWatermark() {
22207
22478
  try {
22208
22479
  const row = await this.store.get.query({
@@ -22490,6 +22761,27 @@ function rowToEntry$1(id, data) {
22490
22761
  payload
22491
22762
  };
22492
22763
  }
22764
+ /** The columns {@link NcOutbox.queryLastRunRows} projects. */
22765
+ var NC_LAST_RUN_COLUMNS = [
22766
+ "ruleId",
22767
+ "createdAt",
22768
+ "status",
22769
+ "deviceId"
22770
+ ];
22771
+ /** A projected row back to a {@link NcLastRunRow} — a guard, never a cast. */
22772
+ function lastRunRow(data) {
22773
+ const ruleId = data["ruleId"];
22774
+ const createdAt = Number(data["createdAt"]);
22775
+ const deviceId = Number(data["deviceId"]);
22776
+ const status = data["status"];
22777
+ if (typeof ruleId !== "string" || !Number.isFinite(createdAt) || !Number.isFinite(deviceId) || status !== "pending" && status !== "sent" && status !== "dead") return null;
22778
+ return {
22779
+ ruleId,
22780
+ createdAt,
22781
+ status,
22782
+ deviceId
22783
+ };
22784
+ }
22493
22785
  //#endregion
22494
22786
  //#region src/notification-center/rule-actions.ts
22495
22787
  var NcRuleActionRunner = class {
@@ -23181,6 +23473,58 @@ var NcRuleAiStore = class {
23181
23473
  }
23182
23474
  };
23183
23475
  //#endregion
23476
+ //#region src/notification-center/rule-last-runs.ts
23477
+ /**
23478
+ * Row bound of the one read. Projected rows are four scalars (~100 B), so the
23479
+ * bound costs ~2 MB at worst. The live outbox volume was NOT measured when
23480
+ * this was chosen. It exists so a runaway rule cannot turn an admin page load
23481
+ * into a table walk — and when it bites, `horizonMs` says so.
23482
+ */
23483
+ var NC_RULE_LAST_RUNS_SCAN_LIMIT = 2e4;
23484
+ /** Least successful first: a fire that reached some targets and not others
23485
+ * did not "deliver". */
23486
+ var STATUS_SEVERITY = {
23487
+ sent: 0,
23488
+ pending: 1,
23489
+ dead: 2
23490
+ };
23491
+ function worse(a, b) {
23492
+ return STATUS_SEVERITY[b] > STATUS_SEVERITY[a] ? b : a;
23493
+ }
23494
+ /**
23495
+ * Reduce newest-first rows to one run per rule, in one pass.
23496
+ *
23497
+ * Rows sharing the newest `createdAt` of a rule are one fire to several
23498
+ * targets; they merge into the least successful status. `read.rows` MUST be
23499
+ * ordered newest-first — that is what the outbox read asks the store for.
23500
+ */
23501
+ function summariseRuleLastRuns(read, scan) {
23502
+ const byRule = /* @__PURE__ */ new Map();
23503
+ for (const row of read.rows) {
23504
+ const seen = byRule.get(row.ruleId);
23505
+ if (seen === void 0) byRule.set(row.ruleId, {
23506
+ ruleId: row.ruleId,
23507
+ lastAt: row.createdAt,
23508
+ status: row.status,
23509
+ deviceId: row.deviceId
23510
+ });
23511
+ else if (seen.lastAt === row.createdAt && worse(seen.status, row.status) !== seen.status) byRule.set(row.ruleId, {
23512
+ ...seen,
23513
+ status: row.status,
23514
+ deviceId: row.deviceId
23515
+ });
23516
+ }
23517
+ return {
23518
+ runs: [...byRule.values()],
23519
+ horizonMs: horizonOf(read, scan)
23520
+ };
23521
+ }
23522
+ /** How far back the answer really looked — see the module doc. */
23523
+ function horizonOf(read, scan) {
23524
+ if (read.truncated && read.oldestReadAt !== null) return Math.max(0, scan.now - read.oldestReadAt);
23525
+ return scan.windowMs;
23526
+ }
23527
+ //#endregion
23184
23528
  //#region src/notification-center/durable-reread.ts
23185
23529
  function isRecord$3(value) {
23186
23530
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -28145,9 +28489,14 @@ function outboxEntryToHistory(texts, entry) {
28145
28489
  function renderAudioSpec(spec) {
28146
28490
  return spec.mode === "label" ? `label:${spec.labels.join("+")}` : `level:${String(spec.dbThreshold)}dBFS|h${String(spec.hitPercent)}|s${String(spec.samplingSeconds)}`;
28147
28491
  }
28148
- /** The camera scope in one token — `@all` is a real answer, not a missing one. */
28149
- function renderAudioScope(devices) {
28150
- return devices.length === 0 ? "@all" : devices.join("+");
28492
+ /**
28493
+ * The camera scope in one token — `@all` is a real answer, not a missing one,
28494
+ * and `@all-except:<ids>` is a different one (D641): an excluding rule logged
28495
+ * as `@all` would claim the camera it never watches.
28496
+ */
28497
+ function renderAudioScope(devices, excluded) {
28498
+ if (devices.length === 0) return excluded.length === 0 ? "@all" : `@all-except:${excluded.join("+")}`;
28499
+ return devices.filter((id) => !excluded.includes(id)).join("+");
28151
28500
  }
28152
28501
  var NotificationCenter = class NotificationCenter {
28153
28502
  logger;
@@ -29889,7 +30238,7 @@ var NotificationCenter = class NotificationCenter {
29889
30238
  * scope the evaluation will match on (`matchesDeviceScope`).
29890
30239
  */
29891
30240
  beginLinkedStills(request) {
29892
- const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds } = request;
30241
+ const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
29893
30242
  const shelf = this.linkedStills;
29894
30243
  if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
29895
30244
  if (this.deviceMutes.isMuted(sensorId)) return void 0;
@@ -29899,7 +30248,13 @@ var NotificationCenter = class NotificationCenter {
29899
30248
  const involved = [sensorId, ...rowShown];
29900
30249
  if (!this.rules.listEnabled("device-event").some((rule) => {
29901
30250
  if (rule.media.attach === "none") return false;
29902
- if (!deviceEventRuleMayFireOnKind(rule, sensorKind)) return false;
30251
+ if (!deviceEventRuleMayFireOnKind(rule, sensorKind, sensorRaised)) return false;
30252
+ const excluded = rule.conditions.devicesExclude;
30253
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, {
30254
+ deviceId: sensorId,
30255
+ linkedSensorId: sensorId,
30256
+ rowCameraIds: rowShown
30257
+ })) return false;
29903
30258
  const devices = rule.conditions.devices;
29904
30259
  return devices === void 0 || devices.length === 0 || devices.some((id) => involved.includes(id));
29905
30260
  })) return void 0;
@@ -30021,6 +30376,8 @@ var NotificationCenter = class NotificationCenter {
30021
30376
  if (!(mode === "condition" ? rule.conditions.audio !== void 0 : rule.conditions.audio === void 0 && referencesAudioClass(rule.conditions.classes))) continue;
30022
30377
  const devices = rule.conditions.devices;
30023
30378
  if (devices !== void 0 && devices.length > 0 && !devices.includes(deviceId)) continue;
30379
+ const excluded = rule.conditions.devicesExclude;
30380
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId })) continue;
30024
30381
  return shelf.capture(deviceId, atMs, "audio");
30025
30382
  }
30026
30383
  }
@@ -30123,6 +30480,8 @@ var NotificationCenter = class NotificationCenter {
30123
30480
  if (occ === void 0 || !matchesOccupancy(occ, subject)) continue;
30124
30481
  const devices = rule.conditions.devices;
30125
30482
  if (devices !== void 0 && devices.length > 0 && !devices.includes(edge.deviceId)) continue;
30483
+ const excluded = rule.conditions.devicesExclude;
30484
+ if (excluded !== void 0 && deviceScopeExcludes(excluded, { deviceId: edge.deviceId })) continue;
30126
30485
  return shelf.capture(edge.deviceId, edge.timestamp, "occupancy");
30127
30486
  }
30128
30487
  }
@@ -30258,6 +30617,14 @@ var NotificationCenter = class NotificationCenter {
30258
30617
  logger: this.logger,
30259
30618
  by: caller.userId
30260
30619
  });
30620
+ guardRuleSensorTransition(rule, {
30621
+ logger: this.logger,
30622
+ by: caller.userId
30623
+ });
30624
+ guardRuleDevicesExclude(rule, {
30625
+ logger: this.logger,
30626
+ by: caller.userId
30627
+ });
30261
30628
  await this.validateTargetRefs(rule.targets.map((t) => t.targetId));
30262
30629
  const created = await this.rules.create(rule, caller.userId);
30263
30630
  this.logger.info("notification rule created", { meta: {
@@ -30276,6 +30643,16 @@ var NotificationCenter = class NotificationCenter {
30276
30643
  by: caller.userId,
30277
30644
  ruleId
30278
30645
  });
30646
+ guardPatchSensorTransition(existing, patch, {
30647
+ logger: this.logger,
30648
+ by: caller.userId,
30649
+ ruleId
30650
+ });
30651
+ guardPatchDevicesExclude(existing, patch, {
30652
+ logger: this.logger,
30653
+ by: caller.userId,
30654
+ ruleId
30655
+ });
30279
30656
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
30280
30657
  const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
30281
30658
  this.logger.info("notification rule updated", { meta: {
@@ -30317,6 +30694,14 @@ var NotificationCenter = class NotificationCenter {
30317
30694
  }
30318
30695
  return { entries: mapped };
30319
30696
  },
30697
+ getRuleLastRuns: async () => {
30698
+ const now = this.now();
30699
+ const windowMs = (await this.deps.pruneWindows()).outboxMs;
30700
+ return summariseRuleLastRuns(await this.outbox.queryLastRunRows(windowMs !== null ? now - windowMs : null, NC_RULE_LAST_RUNS_SCAN_LIMIT), {
30701
+ now,
30702
+ windowMs
30703
+ });
30704
+ },
30320
30705
  resolveArtifactUrl: async ({ artifactId }) => ({ url: await this.deps.resolveArtifactUrl?.(artifactId) ?? null }),
30321
30706
  listDeviceMutes: async () => ({ mutedDeviceIds: this.deviceMutes.listMuted() }),
30322
30707
  setDeviceMuted: async ({ deviceId, muted }) => {
@@ -30648,6 +31033,7 @@ var NotificationCenter = class NotificationCenter {
30648
31033
  coverage: this.coverageFor(panel).map((c) => ({
30649
31034
  ...c,
30650
31035
  deviceIds: [...c.deviceIds],
31036
+ allDevicesExcept: [...c.allDevicesExcept],
30651
31037
  skippedDevices: c.skippedDevices.map((s) => ({ ...s }))
30652
31038
  }))
30653
31039
  };
@@ -31157,6 +31543,9 @@ var NotificationCenter = class NotificationCenter {
31157
31543
  failed: evaluation.failedCondition,
31158
31544
  classes: subject.classNames,
31159
31545
  ...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
31546
+ ...subject.sensorRaised !== void 0 ? { sensorRaised: subject.sensorRaised } : {},
31547
+ ...subject.sourceDeviceId !== void 0 ? { sourceDeviceId: subject.sourceDeviceId } : {},
31548
+ ...subject.linkedSensorId !== void 0 ? { linkedSensorId: subject.linkedSensorId } : {},
31160
31549
  eventId: subject.recordId,
31161
31550
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
31162
31551
  }
@@ -31266,7 +31655,11 @@ var NotificationCenter = class NotificationCenter {
31266
31655
  eventId: subject.recordId,
31267
31656
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
31268
31657
  ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {},
31269
- ...subject.zones.length > 0 ? { zones: subject.zones } : {}
31658
+ ...subject.zones.length > 0 ? { zones: subject.zones } : {},
31659
+ ...rule.conditions.sensorTransition !== void 0 ? {
31660
+ sensorTransition: rule.conditions.sensorTransition,
31661
+ sensorRaised: subject.sensorRaised ?? null
31662
+ } : {}
31270
31663
  }
31271
31664
  });
31272
31665
  const unresolvable = unresolvableButtons(rule.actions);
@@ -31873,7 +32266,8 @@ var NotificationCenter = class NotificationCenter {
31873
32266
  ruleId: rule.id,
31874
32267
  ruleName: rule.name,
31875
32268
  spec,
31876
- devices: rule.conditions.devices ?? []
32269
+ devices: rule.conditions.devices ?? [],
32270
+ excluded: rule.conditions.devicesExclude ?? []
31877
32271
  });
31878
32272
  }
31879
32273
  this.audioWatcher.setWatchedSpecs(audioSpecs);
@@ -31965,10 +32359,12 @@ var NotificationCenter = class NotificationCenter {
31965
32359
  * for at all"; the per-camera line answers "why is 617 not notifying" — and
31966
32360
  * that question is ALWAYS asked per camera, so the line carries
31967
32361
  * `tags.deviceId` and a rule scoped to no camera is reported as watching
31968
- * every one rather than silently omitted.
32362
+ * every one rather than silently omitted — every one EXCEPT its
32363
+ * `devicesExclude` cameras (D641), each of which gets its own NOT-watching
32364
+ * line on that camera.
31969
32365
  */
31970
32366
  reportAudioWatch(entries) {
31971
- const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices)}`).toSorted().join(",");
32367
+ const rendered = entries.map((e) => `${e.ruleId}=${renderAudioSpec(e.spec)}@${renderAudioScope(e.devices, e.excluded)}`).toSorted().join(",");
31972
32368
  if (rendered === this.lastAudioWatchReport) return;
31973
32369
  this.lastAudioWatchReport = rendered;
31974
32370
  this.logger.info("audio watched specs", { meta: {
@@ -31982,14 +32378,28 @@ var NotificationCenter = class NotificationCenter {
31982
32378
  mode: entry.spec.mode,
31983
32379
  spec: renderAudioSpec(entry.spec)
31984
32380
  };
32381
+ for (const deviceId of entry.excluded) this.logger.info("audio rule NOT watching camera", {
32382
+ tags: { deviceId },
32383
+ meta: {
32384
+ ...meta,
32385
+ reason: "devicesExclude"
32386
+ }
32387
+ });
31985
32388
  if (entry.devices.length === 0) {
31986
- this.logger.info("audio rule watching every camera", { meta });
32389
+ if (entry.excluded.length === 0) this.logger.info("audio rule watching every camera", { meta });
32390
+ else this.logger.info("audio rule watching every camera except the excluded", { meta: {
32391
+ ...meta,
32392
+ excluded: [...entry.excluded]
32393
+ } });
31987
32394
  continue;
31988
32395
  }
31989
- for (const deviceId of entry.devices) this.logger.info("audio rule watching camera", {
31990
- tags: { deviceId },
31991
- meta
31992
- });
32396
+ for (const deviceId of entry.devices) {
32397
+ if (entry.excluded.includes(deviceId)) continue;
32398
+ this.logger.info("audio rule watching camera", {
32399
+ tags: { deviceId },
32400
+ meta
32401
+ });
32402
+ }
31993
32403
  }
31994
32404
  }
31995
32405
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
@@ -89431,10 +89841,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
89431
89841
  startLinkedStills(data, timestamp, cameras) {
89432
89842
  const sensorKind = require_dist.EVENT_KIND_BY_CAP[data.capName]?.kind;
89433
89843
  if (sensorKind === void 0) return void 0;
89844
+ const sensorRaised = require_dist.sensorRaisedOf(sensorKind, sensorSliceRecord(data.slice));
89434
89845
  try {
89435
89846
  return this.notificationCenter?.beginLinkedStills({
89436
89847
  sensorId: data.deviceId,
89437
89848
  sensorKind,
89849
+ ...sensorRaised !== void 0 ? { sensorRaised } : {},
89438
89850
  cameraIds: cameras.mediaCameraIds,
89439
89851
  rowCameraIds: cameras.rowCameraIds,
89440
89852
  atMs: timestamp