@camstack/addon-post-analysis 1.2.25 → 1.2.27

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-Bi1fiPc0.js");
5
+ const require_dist = require("../dist-Be0WkqVJ.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -347,6 +347,323 @@ function mimeFromExtension(file) {
347
347
  return "application/octet-stream";
348
348
  }
349
349
  //#endregion
350
+ //#region src/notification-center/alarm/alarm-state-machine.ts
351
+ var DISARMED = {
352
+ target: "disarmed",
353
+ fired: false,
354
+ changedAt: 0
355
+ };
356
+ function armedState(mode) {
357
+ return `armed_${mode}`;
358
+ }
359
+ /**
360
+ * The state to report at `now`.
361
+ *
362
+ * A comparison, not a job: nothing has to run for the exit delay to end. A
363
+ * caller that stops ticking stops NOTIFYING about the change, but the panel's
364
+ * answer is still correct the moment anyone asks.
365
+ */
366
+ function stateAt(m, now) {
367
+ if (m.fired) return "triggered";
368
+ if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
369
+ if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
370
+ return m.target;
371
+ }
372
+ /** Whether the panel is armed enough for a trigger to matter. */
373
+ function isArmed(m, now) {
374
+ const s = stateAt(m, now);
375
+ return s.startsWith("armed_") || s === "pending" || s === "triggered";
376
+ }
377
+ /**
378
+ * Arm into `mode`. An exit delay of 0 arms immediately — some modes (`home`)
379
+ * legitimately have none, because nobody is leaving.
380
+ */
381
+ function arm(mode, delays, now) {
382
+ const target = armedState(mode);
383
+ if (delays.exitDelaySec <= 0) return {
384
+ target,
385
+ fired: false,
386
+ changedAt: now
387
+ };
388
+ return {
389
+ target,
390
+ armedAt: now + delays.exitDelaySec * 1e3,
391
+ fired: false,
392
+ changedAt: now
393
+ };
394
+ }
395
+ /**
396
+ * Disarm, from ANY state — including mid-arming and mid-pending.
397
+ *
398
+ * Cancelling a pending entry is the single most-used transition in a real
399
+ * alarm (walking in and typing the code), and it must CANCEL rather than
400
+ * queue: a disarm that let the pending timer still fire would set the alarm off
401
+ * behind the operator.
402
+ */
403
+ function disarm(now) {
404
+ return {
405
+ ...DISARMED,
406
+ changedAt: now
407
+ };
408
+ }
409
+ /**
410
+ * Something matched while armed.
411
+ *
412
+ * Starts the entry delay, or fires at once when there is none. A trigger while
413
+ * DISARMED is ignored — the panel is the gate, and a rule that fires while
414
+ * disarmed is a notification, not a break-in.
415
+ *
416
+ * A second trigger during `pending` does NOT restart the countdown: an
417
+ * intruder walking past two sensors would otherwise postpone the alarm.
418
+ */
419
+ function trigger(m, delays, now) {
420
+ if (!isArmed(m, now)) return m;
421
+ if (m.fired || m.triggerAt !== void 0) return m;
422
+ if (delays.entryDelaySec <= 0) return {
423
+ ...m,
424
+ fired: true,
425
+ changedAt: now
426
+ };
427
+ return {
428
+ ...m,
429
+ triggerAt: now + delays.entryDelaySec * 1e3,
430
+ changedAt: now
431
+ };
432
+ }
433
+ /**
434
+ * Settle the machine so a stored value never depends on when it is read.
435
+ *
436
+ * `stateAt` already answers correctly from an unsettled value; this is what a
437
+ * caller persists after a tick, so `fired` becomes true in the record rather
438
+ * than only in the reading of it.
439
+ */
440
+ function settle(m, now) {
441
+ if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
442
+ target: m.target,
443
+ fired: true,
444
+ changedAt: m.triggerAt
445
+ };
446
+ if (m.armedAt !== void 0 && now >= m.armedAt) return {
447
+ target: m.target,
448
+ fired: m.fired,
449
+ changedAt: m.armedAt
450
+ };
451
+ return m;
452
+ }
453
+ //#endregion
454
+ //#region src/notification-center/alarm/alarm-panel-device.ts
455
+ /**
456
+ * The Notification Center's own alarm panel.
457
+ *
458
+ * A REAL device, not a special case: it carries `alarm-panel` natively, so the
459
+ * `deviceState` rule gate reads it through `loadRuntimeState` exactly like a
460
+ * switch or a contact, and both exporters see it through their ordinary
461
+ * device mapping. Nothing had to learn about alarms.
462
+ *
463
+ * It needed no new addon and no base-class change either — the wiring already
464
+ * existed and was simply never used this way: `ctx.kernel.devices.create()` is
465
+ * on EVERY addon's kernel services, not only a `BaseDeviceProvider`'s, and
466
+ * `integrations.create` gives it a fixed home to hang from.
467
+ *
468
+ * The lifecycle lives in {@link ../alarm-state-machine.js} as pure functions.
469
+ * This class is the thin part: persist the machine, publish the derived state,
470
+ * and re-publish when a delay elapses.
471
+ */
472
+ /** Every arm mode a camstack-owned panel offers. */
473
+ var MODES = [
474
+ "home",
475
+ "away",
476
+ "night"
477
+ ];
478
+ /**
479
+ * Defaults an operator can change per panel. `home` has no exit delay on
480
+ * purpose — nobody is leaving — but the shared value is applied to every mode
481
+ * until per-mode delays are asked for.
482
+ */
483
+ var DEFAULT_DELAYS = {
484
+ exitDelaySec: 30,
485
+ entryDelaySec: 20
486
+ };
487
+ /**
488
+ * How often the panel re-publishes while a delay is running.
489
+ *
490
+ * The state is computed from an INSTANT, so this tick only decides how quickly
491
+ * the change is ANNOUNCED — a missed tick can never leave the panel stuck in
492
+ * `arming`. One second is what makes a countdown look like a countdown.
493
+ */
494
+ var TICK_MS = 1e3;
495
+ /**
496
+ * The panel's persisted config: the delays an operator tunes, and the machine
497
+ * itself. The machine is persisted because the alternative is an alarm that
498
+ * disarms itself on every deploy.
499
+ */
500
+ var ncAlarmPanelSchema = require_dist.object({
501
+ exitDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
502
+ entryDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
503
+ machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional()
504
+ });
505
+ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
506
+ features = [];
507
+ machine = DISARMED;
508
+ delays = DEFAULT_DELAYS;
509
+ ticker = null;
510
+ constructor(ctx) {
511
+ super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
512
+ this.delays = {
513
+ exitDelaySec: this.config.get("exitDelaySec"),
514
+ entryDelaySec: this.config.get("entryDelaySec")
515
+ };
516
+ this.restore();
517
+ this.registerAlarmCap();
518
+ this.publish();
519
+ }
520
+ /** Fire a trigger at the panel. Called by the notification center when a rule
521
+ * that arms the alarm matches. Ignored unless the panel is armed. */
522
+ onRuleTriggered(now = Date.now()) {
523
+ const next = trigger(this.machine, this.delays, now);
524
+ if (next === this.machine) return;
525
+ this.machine = next;
526
+ this.publish(now);
527
+ }
528
+ /** Current lifecycle word — what the `deviceState` gate compares against. */
529
+ currentState(now = Date.now()) {
530
+ return stateAt(this.machine, now);
531
+ }
532
+ /** Stop the countdown when the device goes away. The state itself is
533
+ * persisted, so nothing is lost — only the announcements stop. */
534
+ stopCountdown() {
535
+ this.stopTicking();
536
+ }
537
+ registerAlarmCap() {
538
+ this.ctx.registerNativeCap(require_dist.alarmPanelCapability, {
539
+ getStatus: async () => this.status(),
540
+ arm: async ({ mode }) => {
541
+ this.machine = arm(mode, this.delays, Date.now());
542
+ this.publish();
543
+ },
544
+ disarm: async () => {
545
+ this.machine = disarm(Date.now());
546
+ this.publish();
547
+ },
548
+ trigger: async () => {
549
+ this.machine = {
550
+ ...this.machine,
551
+ fired: true,
552
+ changedAt: Date.now()
553
+ };
554
+ this.publish();
555
+ }
556
+ });
557
+ }
558
+ status(now = Date.now()) {
559
+ return {
560
+ state: stateAt(this.machine, now),
561
+ availableModes: [...MODES],
562
+ requiresCode: false,
563
+ lastChangedAt: this.machine.changedAt
564
+ };
565
+ }
566
+ /**
567
+ * Write the slice and keep ticking while a delay is running.
568
+ *
569
+ * `settle` is applied first so the PERSISTED machine does not depend on when
570
+ * it is next read — a hub restarted mid-`pending` comes back already fired,
571
+ * rather than fired only once somebody looks.
572
+ */
573
+ publish(now = Date.now()) {
574
+ this.machine = settle(this.machine, now);
575
+ this.runtimeState.setCapState("alarm-panel", this.status(now));
576
+ this.persist();
577
+ if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0) this.startTicking();
578
+ else this.stopTicking();
579
+ }
580
+ startTicking() {
581
+ if (this.ticker !== null) return;
582
+ this.ticker = setInterval(() => this.publish(), TICK_MS);
583
+ this.ticker.unref?.();
584
+ }
585
+ stopTicking() {
586
+ if (this.ticker === null) return;
587
+ clearInterval(this.ticker);
588
+ this.ticker = null;
589
+ }
590
+ /** The machine rides the device's own config blob — it is small, and it must
591
+ * survive a restart or the alarm disarms itself on every deploy. */
592
+ persist() {
593
+ this.config.set("machine", { ...this.machine });
594
+ }
595
+ restore() {
596
+ const raw = this.config.get("machine");
597
+ if (raw === null || typeof raw !== "object") return;
598
+ const m = raw;
599
+ if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
600
+ this.machine = {
601
+ target: m.target,
602
+ ...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
603
+ ...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
604
+ fired: m.fired === true,
605
+ changedAt: m.changedAt
606
+ };
607
+ }
608
+ };
609
+ //#endregion
610
+ //#region src/notification-center/alarm/ensure-alarm-panel.ts
611
+ /**
612
+ * Bring the Notification Center's alarm panel into existence, once.
613
+ *
614
+ * Two idempotent steps — a fixed integration to hang from, and one device under
615
+ * it. Both are `get-or-create`, because this runs on every boot and an operator
616
+ * must never end up with a second panel after a restart.
617
+ *
618
+ * The integration is FIXED: it exists because the addon says so, not because an
619
+ * operator added it, and there is no add/delete flow for it. That is the whole
620
+ * meaning of "a system integration" here — the same `integrations` row every
621
+ * provider uses, created by its owner instead of by a form.
622
+ */
623
+ /** Stable id of the one panel. Never derived from a name — a rename must not
624
+ * mint a second device. */
625
+ var NC_ALARM_STABLE_ID = "nc-alarm-panel";
626
+ var NC_ALARM_DEVICE_NAME = "Alarm";
627
+ /**
628
+ * Idempotent. Returns the ids either way so the caller can hold onto the panel.
629
+ *
630
+ * Failures are the CALLER's to handle: a hub whose alarm could not be created
631
+ * should say so loudly and keep notifying, not fail to boot — but that decision
632
+ * does not belong in here.
633
+ */
634
+ async function ensureAlarmPanel(deps) {
635
+ const integration = await deps.getIntegration(deps.addonId) ?? await deps.createIntegration({
636
+ addonId: deps.addonId,
637
+ name: deps.integrationName
638
+ });
639
+ const existingDevice = await deps.findDeviceByStableId(NC_ALARM_STABLE_ID);
640
+ if (existingDevice !== null) return {
641
+ integrationId: integration.id,
642
+ deviceId: existingDevice.id,
643
+ created: false
644
+ };
645
+ const device = await deps.createDevice({
646
+ stableId: NC_ALARM_STABLE_ID,
647
+ integrationId: integration.id,
648
+ name: NC_ALARM_DEVICE_NAME
649
+ });
650
+ deps.logger.info("alarm panel created", {
651
+ tags: { deviceId: device.id },
652
+ meta: {
653
+ integrationId: integration.id,
654
+ stableId: NC_ALARM_STABLE_ID
655
+ }
656
+ });
657
+ return {
658
+ integrationId: integration.id,
659
+ deviceId: device.id,
660
+ created: true
661
+ };
662
+ }
663
+ /** The device class + type the caller passes to `kernel.devices.create`. */
664
+ var NC_ALARM_DEVICE_CLASS = NcAlarmPanelDevice;
665
+ var NC_ALARM_DEVICE_TYPE = require_dist.DeviceType.AlarmPanel;
666
+ //#endregion
350
667
  //#region src/pipeline-analytics/videoclips-provider.ts
351
668
  var SOURCE = "analytics";
352
669
  function clipIdFor(eventId, startMs, endMs) {
@@ -4758,17 +5075,7 @@ function referencesAudioClass(classes) {
4758
5075
  if (classes === void 0) return false;
4759
5076
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
4760
5077
  }
4761
- /**
4762
- * Evaluate one rule against one subject. The rule's `delivery` must match
4763
- * the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
4764
- * `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
4765
- * at the SUBJECT timestamp (the persist moment), so boot-time reconcile of
4766
- * missed records applies the schedule the record lived under.
4767
- *
4768
- * Throttle/cooldown is deliberately NOT part of this function — cooldown
4769
- * is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
4770
- */
4771
- function evaluateRule(rule, subject) {
5078
+ function evaluateRule(rule, subject, deviceState) {
4772
5079
  if (subject.kind === "occupancy-event") {
4773
5080
  if (rule.delivery !== "device-event") return fail("delivery");
4774
5081
  const occ = rule.conditions.occupancy;
@@ -4783,6 +5090,11 @@ function evaluateRule(rule, subject) {
4783
5090
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4784
5091
  }
4785
5092
  const c = rule.conditions;
5093
+ if (c.deviceState !== void 0) {
5094
+ const current = deviceState?.(c.deviceState.deviceId);
5095
+ if (current === void 0) return fail("deviceState");
5096
+ if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
5097
+ }
4786
5098
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
4787
5099
  if (c.source !== void 0 && c.source !== "any") {
4788
5100
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -6411,6 +6723,117 @@ function rowToEntry$1(id, data) {
6411
6723
  };
6412
6724
  }
6413
6725
  //#endregion
6726
+ //#region src/notification-center/rule-actions.ts
6727
+ var NcRuleActionRunner = class {
6728
+ deps;
6729
+ /** Last run per `${ruleId}:${sequenceName}` — the throttle's memory. */
6730
+ lastRunAt = /* @__PURE__ */ new Map();
6731
+ now;
6732
+ sleep;
6733
+ constructor(deps) {
6734
+ this.deps = deps;
6735
+ this.now = deps.now ?? (() => Date.now());
6736
+ this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
6737
+ }
6738
+ /**
6739
+ * Run every enabled sequence, in order.
6740
+ *
6741
+ * Never throws: an actuation that fails must not cost the NOTIFICATION about
6742
+ * the very same event. The operator finds out from the log and from the
6743
+ * device not having moved — not from silence on both.
6744
+ */
6745
+ async run(input) {
6746
+ const out = [];
6747
+ for (const sequence of input.sequences) out.push(await this.runOne(input, sequence));
6748
+ return out;
6749
+ }
6750
+ async runOne(input, sequence) {
6751
+ if (!sequence.enabled) return {
6752
+ name: sequence.name,
6753
+ ran: false,
6754
+ stepsRun: 0,
6755
+ skippedReason: "disabled"
6756
+ };
6757
+ if (sequence.actions.length === 0) return {
6758
+ name: sequence.name,
6759
+ ran: false,
6760
+ stepsRun: 0,
6761
+ skippedReason: "empty"
6762
+ };
6763
+ const key = `${input.ruleId}:${sequence.name}`;
6764
+ const now = this.now();
6765
+ const minDelayMs = (sequence.minDelaySec ?? 0) * 1e3;
6766
+ const last = this.lastRunAt.get(key);
6767
+ if (minDelayMs > 0 && last !== void 0 && now - last < minDelayMs) {
6768
+ this.deps.logger.debug("rule action sequence throttled", {
6769
+ tags: { deviceId: input.deviceId },
6770
+ meta: {
6771
+ ruleId: input.ruleId,
6772
+ sequence: sequence.name,
6773
+ sinceLastMs: now - last
6774
+ }
6775
+ });
6776
+ return {
6777
+ name: sequence.name,
6778
+ ran: false,
6779
+ stepsRun: 0,
6780
+ skippedReason: "throttled"
6781
+ };
6782
+ }
6783
+ this.lastRunAt.set(key, now);
6784
+ let stepsRun = 0;
6785
+ for (const [index, action] of sequence.actions.entries()) try {
6786
+ await this.step(action);
6787
+ stepsRun += 1;
6788
+ } catch (err) {
6789
+ this.deps.logger.warn("rule action failed — stopping the sequence", {
6790
+ tags: { deviceId: input.deviceId },
6791
+ meta: {
6792
+ ruleId: input.ruleId,
6793
+ rule: input.ruleName,
6794
+ sequence: sequence.name,
6795
+ step: index,
6796
+ action: action.kind === "cap" ? `${action.cap}.${action.method}` : "wait",
6797
+ error: String(err)
6798
+ }
6799
+ });
6800
+ return {
6801
+ name: sequence.name,
6802
+ ran: true,
6803
+ stepsRun,
6804
+ failedAt: index
6805
+ };
6806
+ }
6807
+ this.deps.logger.info("rule actions ran", {
6808
+ tags: { deviceId: input.deviceId },
6809
+ meta: {
6810
+ ruleId: input.ruleId,
6811
+ rule: input.ruleName,
6812
+ sequence: sequence.name,
6813
+ steps: stepsRun
6814
+ }
6815
+ });
6816
+ return {
6817
+ name: sequence.name,
6818
+ ran: true,
6819
+ stepsRun
6820
+ };
6821
+ }
6822
+ async step(action) {
6823
+ if (action.kind === "wait") {
6824
+ await this.sleep(action.seconds * 1e3);
6825
+ return;
6826
+ }
6827
+ if (!this.deps.isDeviceScoped(action.cap)) throw new Error(`rule action refused: "${action.cap}" is not a device capability — only devices can be actuated`);
6828
+ await this.deps.actuate({
6829
+ deviceId: action.deviceId,
6830
+ cap: action.cap,
6831
+ method: action.method,
6832
+ args: action.args ?? {}
6833
+ });
6834
+ }
6835
+ };
6836
+ //#endregion
6414
6837
  //#region src/notification-center/rule-store.ts
6415
6838
  var NC_RULES_COLLECTION = "notification-center:rules";
6416
6839
  var NC_RULES_COLUMNS = [
@@ -6612,6 +7035,53 @@ var NcRuleStore = class {
6612
7035
  });
6613
7036
  }
6614
7037
  };
7038
+ var NcDeviceStateCache = class {
7039
+ deps;
7040
+ states = /* @__PURE__ */ new Map();
7041
+ lastOkAt = 0;
7042
+ ids = [];
7043
+ now;
7044
+ constructor(deps) {
7045
+ this.deps = deps;
7046
+ this.now = deps.now ?? (() => Date.now());
7047
+ }
7048
+ /**
7049
+ * The device ids worth reading — recomputed from the rule set, so a cluster
7050
+ * with no gated rule does no work at all.
7051
+ */
7052
+ setWatched(ids) {
7053
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
7054
+ }
7055
+ watched() {
7056
+ return this.ids;
7057
+ }
7058
+ /** Hot-path read. No I/O, no await — this is called per event. */
7059
+ get(deviceId) {
7060
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
7061
+ return this.states.get(deviceId);
7062
+ }
7063
+ /**
7064
+ * Re-read every watched state. Never throws: a gate that could fail the
7065
+ * evaluation tick would take the notification pipeline with it.
7066
+ */
7067
+ async refresh() {
7068
+ if (this.ids.length === 0) {
7069
+ this.states = /* @__PURE__ */ new Map();
7070
+ return;
7071
+ }
7072
+ try {
7073
+ const next = await this.deps.readStates(this.ids);
7074
+ this.states = new Map(next);
7075
+ this.lastOkAt = this.now();
7076
+ for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
7077
+ } catch (err) {
7078
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
7079
+ watched: this.ids.length,
7080
+ error: String(err)
7081
+ } });
7082
+ }
7083
+ }
7084
+ };
6615
7085
  //#endregion
6616
7086
  //#region src/notification-center/snooze-digest.ts
6617
7087
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -7445,6 +7915,11 @@ var NotificationCenter = class NotificationCenter {
7445
7915
  /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7446
7916
  * reads it with no I/O, the drain tick refreshes it. */
7447
7917
  snoozes;
7918
+ /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
7919
+ * read with no I/O on the persist hook. */
7920
+ deviceStates;
7921
+ /** Runs a matched rule's action sequences. Null when no actuator is wired. */
7922
+ actionRunner;
7448
7923
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7449
7924
  * the watcher is idle (zero per-frame cost) otherwise. */
7450
7925
  occupancyEnabled = false;
@@ -7477,6 +7952,17 @@ var NotificationCenter = class NotificationCenter {
7477
7952
  store: deps.store,
7478
7953
  logger: this.logger.child("occupancy")
7479
7954
  });
7955
+ this.actionRunner = deps.actuate !== void 0 && deps.isCapDeviceScoped !== void 0 ? new NcRuleActionRunner({
7956
+ logger: this.logger.child("actions"),
7957
+ actuate: deps.actuate,
7958
+ isDeviceScoped: deps.isCapDeviceScoped,
7959
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7960
+ }) : null;
7961
+ this.deviceStates = new NcDeviceStateCache({
7962
+ logger: this.logger.child("device-state"),
7963
+ readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
7964
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7965
+ });
7480
7966
  this.snoozes = new NcSnoozeStore({
7481
7967
  store: deps.store,
7482
7968
  logger: this.logger.child("snooze"),
@@ -7531,6 +8017,7 @@ var NotificationCenter = class NotificationCenter {
7531
8017
  await this.snoozes.load();
7532
8018
  this.refreshOccupancyWatch();
7533
8019
  if (!opts.evaluation) return;
8020
+ await this.deviceStates.refresh();
7534
8021
  this.evaluationActive = true;
7535
8022
  await this.outbox.load();
7536
8023
  await this.seedCooldowns();
@@ -7802,7 +8289,7 @@ var NotificationCenter = class NotificationCenter {
7802
8289
  let anyMatched = false;
7803
8290
  const rejections = [];
7804
8291
  for (const rule of candidates) {
7805
- const evaluation = evaluateRule(rule, subject);
8292
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
7806
8293
  if (!evaluation.matched) {
7807
8294
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
7808
8295
  this.logger.debug("rule did not match", {
@@ -7846,6 +8333,13 @@ var NotificationCenter = class NotificationCenter {
7846
8333
  ...subject.zones.length > 0 ? { zones: subject.zones } : {}
7847
8334
  }
7848
8335
  });
8336
+ const sequences = rule.actions?.onTrigger;
8337
+ if (sequences !== void 0 && sequences.length > 0 && this.actionRunner !== null) this.actionRunner.run({
8338
+ ruleId: rule.id,
8339
+ ruleName: rule.name,
8340
+ deviceId: subject.deviceId,
8341
+ sequences
8342
+ });
7849
8343
  const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
7850
8344
  if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
7851
8345
  anyMatched = true;
@@ -8003,6 +8497,13 @@ var NotificationCenter = class NotificationCenter {
8003
8497
  }
8004
8498
  this.occupancyWatcher.setWatchedKeys(specs);
8005
8499
  this.occupancyEnabled = specs.length > 0;
8500
+ const gated = [];
8501
+ for (const rule of this.rules.list()) {
8502
+ if (!rule.enabled) continue;
8503
+ const ds = rule.conditions.deviceState;
8504
+ if (ds !== void 0) gated.push(ds.deviceId);
8505
+ }
8506
+ this.deviceStates.setWatched(gated);
8006
8507
  }
8007
8508
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
8008
8509
  * hydrate the watcher from the store, then prune orphaned durable rows to the
@@ -8019,6 +8520,7 @@ var NotificationCenter = class NotificationCenter {
8019
8520
  await this.rules.load();
8020
8521
  this.refreshOccupancyWatch();
8021
8522
  if (!this.evaluationActive) return;
8523
+ await this.deviceStates.refresh();
8022
8524
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
8023
8525
  }
8024
8526
  /** The full-key set the watcher currently tracks (post prune) — the bound for
@@ -8243,10 +8745,16 @@ var NotificationCenter = class NotificationCenter {
8243
8745
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
8244
8746
  for (const track of tracks) subjects.push(subjectFromTrack(track));
8245
8747
  }
8748
+ let lookup = (id) => this.deviceStates.get(id);
8749
+ const gated = rule.conditions.deviceState;
8750
+ if (gated !== void 0 && this.deps.readDeviceStates !== void 0) try {
8751
+ const fresh = await this.deps.readDeviceStates([gated.deviceId]);
8752
+ lookup = (id) => fresh.get(id) ?? this.deviceStates.get(id);
8753
+ } catch {}
8246
8754
  const results = [];
8247
8755
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
8248
8756
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
8249
- const evaluation = evaluateRule(rule, subject);
8757
+ const evaluation = evaluateRule(rule, subject, lookup);
8250
8758
  results.push({
8251
8759
  recordId: subject.recordId,
8252
8760
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -21204,6 +21712,11 @@ async function resolveDefaultEventMedia(deps) {
21204
21712
  }
21205
21713
  //#endregion
21206
21714
  //#region src/pipeline-analytics/index.ts
21715
+ /** This addon's manifest id — the owner of the fixed Notification Center
21716
+ * integration and of the alarm device under it. */
21717
+ var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
21718
+ /** Operator-visible name of the fixed integration. */
21719
+ var NC_INTEGRATION_NAME = "Notification Center";
21207
21720
  /**
21208
21721
  * Pipeline Analytics addon — subscribes to `PipelineInferenceResult` +
21209
21722
  * `PipelineAudioInferenceResult` on the bus, refines raw detections
@@ -21730,6 +22243,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21730
22243
  const api = this.ctx.api;
21731
22244
  if (!api) throw new Error("pipeline-analytics requires ctx.api (device-manager + settings-store)");
21732
22245
  await this.declareCollections(api);
22246
+ await this.ensureAlarmPanel(api);
21733
22247
  const logger = this.ctx.logger;
21734
22248
  const storage = await this.resolveMediaStorage(logger);
21735
22249
  const stores = await this.buildStores(api, logger, storage);
@@ -21817,6 +22331,40 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21817
22331
  }
21818
22332
  /** Declare typed collections up-front so the first insert doesn't race with
21819
22333
  * a CREATE TABLE. Idempotent. */
22334
+ /**
22335
+ * Bring the Notification Center's alarm panel into existence, once.
22336
+ *
22337
+ * Both steps are get-or-create, because this runs on every boot and an
22338
+ * operator must never end up with a second panel after a restart. The
22339
+ * integration is FIXED: it exists because this addon says so, not because
22340
+ * anybody added it, and there is no add/delete flow for it.
22341
+ */
22342
+ async ensureAlarmPanel(api) {
22343
+ const devices = this.ctx.kernel.devices;
22344
+ try {
22345
+ const result = await ensureAlarmPanel({
22346
+ logger: this.ctx.logger.child("alarm"),
22347
+ addonId: PIPELINE_ANALYTICS_ADDON_ID,
22348
+ integrationName: NC_INTEGRATION_NAME,
22349
+ getIntegration: async (addonId) => api.integrations.getByAddonId.query({ addonId }),
22350
+ createIntegration: async (input) => api.integrations.create.mutate(input),
22351
+ findDeviceByStableId: async (stableId) => {
22352
+ const found = (await api.deviceManager.listAll.query({ addonId: PIPELINE_ANALYTICS_ADDON_ID })).find((d) => d.stableId === stableId);
22353
+ return found === void 0 ? null : { id: found.id };
22354
+ },
22355
+ createDevice: async ({ stableId, integrationId, name }) => {
22356
+ return { id: (await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
22357
+ type: NC_ALARM_DEVICE_TYPE,
22358
+ name,
22359
+ integrationId
22360
+ })).id };
22361
+ }
22362
+ });
22363
+ if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
22364
+ } catch (err) {
22365
+ this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
22366
+ }
22367
+ }
21820
22368
  async declareCollections(api) {
21821
22369
  await TrackStore.declare(api.settingsStore);
21822
22370
  await MediaStore.declare(api.settingsStore);
@@ -21841,7 +22389,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21841
22389
  let storage = this.ctx.kernel.storage;
21842
22390
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
21843
22391
  if (mediaRoot) {
21844
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DFKBSXUx.js"));
22392
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-C5glqf4X.js"));
21845
22393
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
21846
22394
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
21847
22395
  }
@@ -22198,6 +22746,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22198
22746
  addonId: device.addonId
22199
22747
  };
22200
22748
  },
22749
+ actuate: async ({ deviceId, cap, method, args }) => {
22750
+ const router = api[require_dist.kebabToCamel(cap)];
22751
+ if (router === null || typeof router !== "object") throw new Error(`rule action: no router for capability "${cap}"`);
22752
+ const proc = router[method];
22753
+ const call = proc !== null && typeof proc === "object" ? proc : null;
22754
+ const fn = call?.mutate ?? call?.query;
22755
+ if (typeof fn !== "function") throw new Error(`rule action: "${cap}.${method}" is not a callable cap method`);
22756
+ await fn({
22757
+ deviceId,
22758
+ ...args
22759
+ });
22760
+ },
22761
+ isCapDeviceScoped: require_dist.isDeviceScopedCap,
22762
+ readDeviceStates: async (ids) => {
22763
+ const out = /* @__PURE__ */ new Map();
22764
+ for (const id of ids) try {
22765
+ const state = require_dist.readDeviceStateFrom(await api.deviceManager.loadRuntimeState.query({ deviceId: id }));
22766
+ if (state !== void 0) out.set(id, state);
22767
+ } catch (err) {
22768
+ this.ctx.logger.debug("device-state read failed", {
22769
+ tags: { deviceId: id },
22770
+ meta: { error: String(err) }
22771
+ });
22772
+ }
22773
+ return out;
22774
+ },
22201
22775
  dispatcher: {
22202
22776
  getZoneNames: async (deviceId) => {
22203
22777
  return (await api.zones.listZones.query({ deviceId })).map((z) => ({