@camstack/addon-post-analysis 1.2.25 → 1.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8414,6 +8414,60 @@ function buildNcTaxonomy() {
8414
8414
  /** The frozen NC taxonomy, derived once from the taxonomy dictionary. */
8415
8415
  var NC_TAXONOMY = Object.freeze(buildNcTaxonomy());
8416
8416
  /**
8417
+ * Most specific first. Extending this list is how a new device kind becomes
8418
+ * gateable; nothing else needs to change.
8419
+ */
8420
+ var DEVICE_STATE_READERS = [
8421
+ {
8422
+ cap: "alarm-panel",
8423
+ field: "state"
8424
+ },
8425
+ {
8426
+ cap: "cover",
8427
+ field: "state"
8428
+ },
8429
+ {
8430
+ cap: "presence",
8431
+ field: "state"
8432
+ },
8433
+ {
8434
+ cap: "lock",
8435
+ field: "locked",
8436
+ booleanWords: ["locked", "unlocked"]
8437
+ },
8438
+ {
8439
+ cap: "contact",
8440
+ field: "entryOpen",
8441
+ booleanWords: ["open", "closed"]
8442
+ },
8443
+ {
8444
+ cap: "switch",
8445
+ field: "on",
8446
+ booleanWords: ["on", "off"]
8447
+ },
8448
+ {
8449
+ cap: "binary",
8450
+ field: "on",
8451
+ booleanWords: ["on", "off"]
8452
+ }
8453
+ ];
8454
+ /**
8455
+ * Collapse a device's full runtime state to the one string a rule compares
8456
+ * against, or `undefined` when nothing in the table applies.
8457
+ *
8458
+ * `undefined` is the safe answer everywhere: the gate treats it as "does not
8459
+ * match", so a device whose kind we cannot read simply never arms a rule.
8460
+ */
8461
+ function readDeviceStateFrom(runtimeState) {
8462
+ for (const reader of DEVICE_STATE_READERS) {
8463
+ const slice = runtimeState[reader.cap];
8464
+ if (slice === null || typeof slice !== "object") continue;
8465
+ const value = slice[reader.field];
8466
+ if (typeof value === "string" && value.length > 0) return value;
8467
+ if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
8468
+ }
8469
+ }
8470
+ /**
8417
8471
  * Error types for the safe expression engine. Two distinct classes so callers
8418
8472
  * can tell a compile-time (grammar) failure from a runtime (evaluation)
8419
8473
  * failure — both are non-fatal to the host: read paths degrade to "skip link".
@@ -9236,11 +9290,13 @@ var NcZoneConditionSchema = object({
9236
9290
  /** Quantifier over `ids` — at least one / every one visited. */
9237
9291
  match: _enum(["any", "all"]).default("any")
9238
9292
  });
9239
- /**
9240
- * The P1 condition set — a flat AND of groups; absent group = pass;
9241
- * membership lists are OR within the list (spec §2.3).
9242
- */
9243
9293
  var NcConditionsSchema = object({
9294
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9295
+ deviceState: object({
9296
+ deviceId: number().int(),
9297
+ /** Any of these matches. */
9298
+ states: array(string().min(1)).min(1)
9299
+ }).optional(),
9244
9300
  /** Device scope — absent = all devices. */
9245
9301
  devices: array(number()).optional(),
9246
9302
  /** Detector class names (any overlap with the record's class set). */
@@ -9605,7 +9661,8 @@ var NcConditionDescriptorSchema = object({
9605
9661
  "packagePhase",
9606
9662
  "crossingSelect",
9607
9663
  "polygonDraw",
9608
- "occupancy"
9664
+ "occupancy",
9665
+ "deviceState"
9609
9666
  ]),
9610
9667
  operator: _enum([
9611
9668
  "in",
@@ -9864,6 +9921,21 @@ var NC_CONDITION_CATALOG = [
9864
9921
  phase: "P1",
9865
9922
  description: "pipeline / onboard / sensor; a record with no stamped source counts as pipeline."
9866
9923
  },
9924
+ {
9925
+ id: "deviceState",
9926
+ group: "scope",
9927
+ label: "Device state",
9928
+ valueType: "deviceState",
9929
+ operator: "in",
9930
+ appliesTo: [
9931
+ "immediate",
9932
+ "track-end",
9933
+ "device-event",
9934
+ "package-event"
9935
+ ],
9936
+ phase: "P2",
9937
+ description: "Only fire while another device is in one of the chosen states — the alarm armed, a switch on, a contact closed. A state that cannot be read does NOT fire."
9938
+ },
9867
9939
  {
9868
9940
  id: "sensorKinds",
9869
9941
  group: "device",
@@ -28021,6 +28093,12 @@ Object.defineProperty(exports, "plateGalleryCapability", {
28021
28093
  return plateGalleryCapability;
28022
28094
  }
28023
28095
  });
28096
+ Object.defineProperty(exports, "readDeviceStateFrom", {
28097
+ enumerable: true,
28098
+ get: function() {
28099
+ return readDeviceStateFrom;
28100
+ }
28101
+ });
28024
28102
  Object.defineProperty(exports, "string", {
28025
28103
  enumerable: true,
28026
28104
  get: function() {
@@ -8392,6 +8392,60 @@ function buildNcTaxonomy() {
8392
8392
  /** The frozen NC taxonomy, derived once from the taxonomy dictionary. */
8393
8393
  var NC_TAXONOMY = Object.freeze(buildNcTaxonomy());
8394
8394
  /**
8395
+ * Most specific first. Extending this list is how a new device kind becomes
8396
+ * gateable; nothing else needs to change.
8397
+ */
8398
+ var DEVICE_STATE_READERS = [
8399
+ {
8400
+ cap: "alarm-panel",
8401
+ field: "state"
8402
+ },
8403
+ {
8404
+ cap: "cover",
8405
+ field: "state"
8406
+ },
8407
+ {
8408
+ cap: "presence",
8409
+ field: "state"
8410
+ },
8411
+ {
8412
+ cap: "lock",
8413
+ field: "locked",
8414
+ booleanWords: ["locked", "unlocked"]
8415
+ },
8416
+ {
8417
+ cap: "contact",
8418
+ field: "entryOpen",
8419
+ booleanWords: ["open", "closed"]
8420
+ },
8421
+ {
8422
+ cap: "switch",
8423
+ field: "on",
8424
+ booleanWords: ["on", "off"]
8425
+ },
8426
+ {
8427
+ cap: "binary",
8428
+ field: "on",
8429
+ booleanWords: ["on", "off"]
8430
+ }
8431
+ ];
8432
+ /**
8433
+ * Collapse a device's full runtime state to the one string a rule compares
8434
+ * against, or `undefined` when nothing in the table applies.
8435
+ *
8436
+ * `undefined` is the safe answer everywhere: the gate treats it as "does not
8437
+ * match", so a device whose kind we cannot read simply never arms a rule.
8438
+ */
8439
+ function readDeviceStateFrom(runtimeState) {
8440
+ for (const reader of DEVICE_STATE_READERS) {
8441
+ const slice = runtimeState[reader.cap];
8442
+ if (slice === null || typeof slice !== "object") continue;
8443
+ const value = slice[reader.field];
8444
+ if (typeof value === "string" && value.length > 0) return value;
8445
+ if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
8446
+ }
8447
+ }
8448
+ /**
8395
8449
  * Error types for the safe expression engine. Two distinct classes so callers
8396
8450
  * can tell a compile-time (grammar) failure from a runtime (evaluation)
8397
8451
  * failure — both are non-fatal to the host: read paths degrade to "skip link".
@@ -9214,11 +9268,13 @@ var NcZoneConditionSchema = object({
9214
9268
  /** Quantifier over `ids` — at least one / every one visited. */
9215
9269
  match: _enum(["any", "all"]).default("any")
9216
9270
  });
9217
- /**
9218
- * The P1 condition set — a flat AND of groups; absent group = pass;
9219
- * membership lists are OR within the list (spec §2.3).
9220
- */
9221
9271
  var NcConditionsSchema = object({
9272
+ /** Gate on ANOTHER device's current state (the alarm armed, a switch on). */
9273
+ deviceState: object({
9274
+ deviceId: number().int(),
9275
+ /** Any of these matches. */
9276
+ states: array(string().min(1)).min(1)
9277
+ }).optional(),
9222
9278
  /** Device scope — absent = all devices. */
9223
9279
  devices: array(number()).optional(),
9224
9280
  /** Detector class names (any overlap with the record's class set). */
@@ -9583,7 +9639,8 @@ var NcConditionDescriptorSchema = object({
9583
9639
  "packagePhase",
9584
9640
  "crossingSelect",
9585
9641
  "polygonDraw",
9586
- "occupancy"
9642
+ "occupancy",
9643
+ "deviceState"
9587
9644
  ]),
9588
9645
  operator: _enum([
9589
9646
  "in",
@@ -9842,6 +9899,21 @@ var NC_CONDITION_CATALOG = [
9842
9899
  phase: "P1",
9843
9900
  description: "pipeline / onboard / sensor; a record with no stamped source counts as pipeline."
9844
9901
  },
9902
+ {
9903
+ id: "deviceState",
9904
+ group: "scope",
9905
+ label: "Device state",
9906
+ valueType: "deviceState",
9907
+ operator: "in",
9908
+ appliesTo: [
9909
+ "immediate",
9910
+ "track-end",
9911
+ "device-event",
9912
+ "package-event"
9913
+ ],
9914
+ phase: "P2",
9915
+ description: "Only fire while another device is in one of the chosen states — the alarm armed, a switch on, a contact closed. A state that cannot be read does NOT fire."
9916
+ },
9845
9917
  {
9846
9918
  id: "sensorKinds",
9847
9919
  group: "device",
@@ -27741,4 +27813,4 @@ Object.freeze({
27741
27813
  "smtp-provider": "email"
27742
27814
  });
27743
27815
  //#endregion
27744
- export { subKindsOf as A, array as B, defineCustomActions as C, notificationRulesCapability as D, hfModelUrl as E, DeviceType as F, string as G, literal as H, createEvent as I, EventCategory as K, hydrateSchema as L, zoneAnalyticsCapability as M, errMsg as N, pipelineAnalyticsCapability as O, BaseAddon as P, nodePin as R, customAction as S, faceGalleryCapability as T, number as U, boolean as V, object as W, TimelapseRuleSchema as _, NC_CONDITION_CATALOG as a, buildEventKindDescriptor as b, NcRuleInputSchema as c, NcSnoozeInputSchema as d, NcSnoozeSchema as f, TimelapseRuleInputSchema as g, OpsLogEntrySchema as h, MACRO_LABELS as i, videoclipsCapability as j, plateGalleryCapability as k, NcRulePatchSchema as l, NcTaxonomySchema as m, EVENT_KIND_BY_CAP as n, NC_TAXONOMY as o, NcSnoozeSuppressedSchema as p, EVENT_PAD_MS as r, NcConditionDescriptorSchema as s, DEFAULT_EVENT_COLOR as t, NcRuleSchema as u, addonWidgetsSourceCapability as v, embeddingEncoderCapability as w, cosineSimilarity as x, audioMetricsCapability as y, _enum as z };
27816
+ export { readDeviceStateFrom as A, _enum as B, defineCustomActions as C, notificationRulesCapability as D, hfModelUrl as E, BaseAddon as F, object as G, boolean as H, DeviceType as I, string as K, createEvent as L, videoclipsCapability as M, zoneAnalyticsCapability as N, pipelineAnalyticsCapability as O, errMsg as P, hydrateSchema as R, customAction as S, faceGalleryCapability as T, literal as U, array as V, number as W, TimelapseRuleSchema as _, NC_CONDITION_CATALOG as a, buildEventKindDescriptor as b, NcRuleInputSchema as c, NcSnoozeInputSchema as d, NcSnoozeSchema as f, TimelapseRuleInputSchema as g, OpsLogEntrySchema as h, MACRO_LABELS as i, subKindsOf as j, plateGalleryCapability as k, NcRulePatchSchema as l, NcTaxonomySchema as m, EVENT_KIND_BY_CAP as n, NC_TAXONOMY as o, NcSnoozeSuppressedSchema as p, EventCategory as q, EVENT_PAD_MS as r, NcConditionDescriptorSchema as s, DEFAULT_EVENT_COLOR as t, NcRuleSchema as u, addonWidgetsSourceCapability as v, embeddingEncoderCapability as w, cosineSimilarity as x, audioMetricsCapability as y, nodePin as z };
@@ -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-CP-FTAl5.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_fs$1 = require_dist.__toESM(node_fs, 1);
8
8
  node_fs = require_dist.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { E as hfModelUrl, P as BaseAddon, w as embeddingEncoderCapability } from "../dist-DsCSHZ9N.mjs";
1
+ import { E as hfModelUrl, F as BaseAddon, w as embeddingEncoderCapability } from "../dist-O227Je-Q.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import "node:fs";
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-Bi1fiPc0.js");
1
+ const require_dist = require("./dist-CP-FTAl5.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs, 1);
4
4
  let node_path = require("node:path");
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.25",
21
+ version: "1.2.26",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_analytics_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.25",
39
+ version: "1.2.26",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
@@ -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-CP-FTAl5.js");
6
6
  let node_fs = require("node:fs");
7
7
  let node_path = require("node:path");
8
8
  node_path = require_dist.__toESM(node_path);
@@ -4758,17 +4758,7 @@ function referencesAudioClass(classes) {
4758
4758
  if (classes === void 0) return false;
4759
4759
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
4760
4760
  }
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) {
4761
+ function evaluateRule(rule, subject, deviceState) {
4772
4762
  if (subject.kind === "occupancy-event") {
4773
4763
  if (rule.delivery !== "device-event") return fail("delivery");
4774
4764
  const occ = rule.conditions.occupancy;
@@ -4783,6 +4773,11 @@ function evaluateRule(rule, subject) {
4783
4773
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4784
4774
  }
4785
4775
  const c = rule.conditions;
4776
+ if (c.deviceState !== void 0) {
4777
+ const current = deviceState?.(c.deviceState.deviceId);
4778
+ if (current === void 0) return fail("deviceState");
4779
+ if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
4780
+ }
4786
4781
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
4787
4782
  if (c.source !== void 0 && c.source !== "any") {
4788
4783
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -6612,6 +6607,53 @@ var NcRuleStore = class {
6612
6607
  });
6613
6608
  }
6614
6609
  };
6610
+ var NcDeviceStateCache = class {
6611
+ deps;
6612
+ states = /* @__PURE__ */ new Map();
6613
+ lastOkAt = 0;
6614
+ ids = [];
6615
+ now;
6616
+ constructor(deps) {
6617
+ this.deps = deps;
6618
+ this.now = deps.now ?? (() => Date.now());
6619
+ }
6620
+ /**
6621
+ * The device ids worth reading — recomputed from the rule set, so a cluster
6622
+ * with no gated rule does no work at all.
6623
+ */
6624
+ setWatched(ids) {
6625
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
6626
+ }
6627
+ watched() {
6628
+ return this.ids;
6629
+ }
6630
+ /** Hot-path read. No I/O, no await — this is called per event. */
6631
+ get(deviceId) {
6632
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
6633
+ return this.states.get(deviceId);
6634
+ }
6635
+ /**
6636
+ * Re-read every watched state. Never throws: a gate that could fail the
6637
+ * evaluation tick would take the notification pipeline with it.
6638
+ */
6639
+ async refresh() {
6640
+ if (this.ids.length === 0) {
6641
+ this.states = /* @__PURE__ */ new Map();
6642
+ return;
6643
+ }
6644
+ try {
6645
+ const next = await this.deps.readStates(this.ids);
6646
+ this.states = new Map(next);
6647
+ this.lastOkAt = this.now();
6648
+ for (const id of this.ids) if (!next.has(id)) this.deps.logger.info("device-state gate: no state for a watched device", { tags: { deviceId: id } });
6649
+ } catch (err) {
6650
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
6651
+ watched: this.ids.length,
6652
+ error: String(err)
6653
+ } });
6654
+ }
6655
+ }
6656
+ };
6615
6657
  //#endregion
6616
6658
  //#region src/notification-center/snooze-digest.ts
6617
6659
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -7445,6 +7487,9 @@ var NotificationCenter = class NotificationCenter {
7445
7487
  /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7446
7488
  * reads it with no I/O, the drain tick refreshes it. */
7447
7489
  snoozes;
7490
+ /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
7491
+ * read with no I/O on the persist hook. */
7492
+ deviceStates;
7448
7493
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7449
7494
  * the watcher is idle (zero per-frame cost) otherwise. */
7450
7495
  occupancyEnabled = false;
@@ -7477,6 +7522,11 @@ var NotificationCenter = class NotificationCenter {
7477
7522
  store: deps.store,
7478
7523
  logger: this.logger.child("occupancy")
7479
7524
  });
7525
+ this.deviceStates = new NcDeviceStateCache({
7526
+ logger: this.logger.child("device-state"),
7527
+ readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
7528
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7529
+ });
7480
7530
  this.snoozes = new NcSnoozeStore({
7481
7531
  store: deps.store,
7482
7532
  logger: this.logger.child("snooze"),
@@ -7531,6 +7581,7 @@ var NotificationCenter = class NotificationCenter {
7531
7581
  await this.snoozes.load();
7532
7582
  this.refreshOccupancyWatch();
7533
7583
  if (!opts.evaluation) return;
7584
+ await this.deviceStates.refresh();
7534
7585
  this.evaluationActive = true;
7535
7586
  await this.outbox.load();
7536
7587
  await this.seedCooldowns();
@@ -7802,7 +7853,7 @@ var NotificationCenter = class NotificationCenter {
7802
7853
  let anyMatched = false;
7803
7854
  const rejections = [];
7804
7855
  for (const rule of candidates) {
7805
- const evaluation = evaluateRule(rule, subject);
7856
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
7806
7857
  if (!evaluation.matched) {
7807
7858
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
7808
7859
  this.logger.debug("rule did not match", {
@@ -8003,6 +8054,13 @@ var NotificationCenter = class NotificationCenter {
8003
8054
  }
8004
8055
  this.occupancyWatcher.setWatchedKeys(specs);
8005
8056
  this.occupancyEnabled = specs.length > 0;
8057
+ const gated = [];
8058
+ for (const rule of this.rules.list()) {
8059
+ if (!rule.enabled) continue;
8060
+ const ds = rule.conditions.deviceState;
8061
+ if (ds !== void 0) gated.push(ds.deviceId);
8062
+ }
8063
+ this.deviceStates.setWatched(gated);
8006
8064
  }
8007
8065
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
8008
8066
  * hydrate the watcher from the store, then prune orphaned durable rows to the
@@ -8019,6 +8077,7 @@ var NotificationCenter = class NotificationCenter {
8019
8077
  await this.rules.load();
8020
8078
  this.refreshOccupancyWatch();
8021
8079
  if (!this.evaluationActive) return;
8080
+ await this.deviceStates.refresh();
8022
8081
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
8023
8082
  }
8024
8083
  /** The full-key set the watcher currently tracks (post prune) — the bound for
@@ -8246,7 +8305,7 @@ var NotificationCenter = class NotificationCenter {
8246
8305
  const results = [];
8247
8306
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
8248
8307
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
8249
- const evaluation = evaluateRule(rule, subject);
8308
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
8250
8309
  results.push({
8251
8310
  recordId: subject.recordId,
8252
8311
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -21841,7 +21900,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
21841
21900
  let storage = this.ctx.kernel.storage;
21842
21901
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
21843
21902
  if (mediaRoot) {
21844
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-DFKBSXUx.js"));
21903
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BQUEteve.js"));
21845
21904
  storage = new FilesystemStorageProvider(mediaRoot, { eventMedia: mediaRoot });
21846
21905
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
21847
21906
  }
@@ -22198,6 +22257,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
22198
22257
  addonId: device.addonId
22199
22258
  };
22200
22259
  },
22260
+ readDeviceStates: async (ids) => {
22261
+ const out = /* @__PURE__ */ new Map();
22262
+ for (const id of ids) try {
22263
+ const state = require_dist.readDeviceStateFrom(await api.deviceManager.loadRuntimeState.query({ deviceId: id }));
22264
+ if (state !== void 0) out.set(id, state);
22265
+ } catch (err) {
22266
+ this.ctx.logger.debug("device-state read failed", {
22267
+ tags: { deviceId: id },
22268
+ meta: { error: String(err) }
22269
+ });
22270
+ }
22271
+ return out;
22272
+ },
22201
22273
  dispatcher: {
22202
22274
  getZoneNames: async (deviceId) => {
22203
22275
  return (await api.zones.listZones.query({ deviceId })).map((z) => ({
@@ -1,4 +1,4 @@
1
- import { A as subKindsOf, B as array, C as defineCustomActions, D as notificationRulesCapability, F as DeviceType, G as string, H as literal, I as createEvent, K as EventCategory, L as hydrateSchema, M as zoneAnalyticsCapability, N as errMsg, O as pipelineAnalyticsCapability, P as BaseAddon, R as nodePin, S as customAction, T as faceGalleryCapability, U as number, V as boolean, W as object, _ as TimelapseRuleSchema, a as NC_CONDITION_CATALOG, b as buildEventKindDescriptor, c as NcRuleInputSchema, d as NcSnoozeInputSchema, f as NcSnoozeSchema, g as TimelapseRuleInputSchema, h as OpsLogEntrySchema, i as MACRO_LABELS, j as videoclipsCapability, k as plateGalleryCapability, l as NcRulePatchSchema, m as NcTaxonomySchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as NcSnoozeSuppressedSchema, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as addonWidgetsSourceCapability, x as cosineSimilarity, y as audioMetricsCapability, z as _enum } from "../dist-DsCSHZ9N.mjs";
1
+ import { A as readDeviceStateFrom, B as _enum, C as defineCustomActions, D as notificationRulesCapability, F as BaseAddon, G as object, H as boolean, I as DeviceType, K as string, L as createEvent, M as videoclipsCapability, N as zoneAnalyticsCapability, O as pipelineAnalyticsCapability, P as errMsg, R as hydrateSchema, S as customAction, T as faceGalleryCapability, U as literal, V as array, W as number, _ as TimelapseRuleSchema, a as NC_CONDITION_CATALOG, b as buildEventKindDescriptor, c as NcRuleInputSchema, d as NcSnoozeInputSchema, f as NcSnoozeSchema, g as TimelapseRuleInputSchema, h as OpsLogEntrySchema, i as MACRO_LABELS, j as subKindsOf, k as plateGalleryCapability, l as NcRulePatchSchema, m as NcTaxonomySchema, n as EVENT_KIND_BY_CAP, o as NC_TAXONOMY, p as NcSnoozeSuppressedSchema, q as EventCategory, r as EVENT_PAD_MS, s as NcConditionDescriptorSchema, t as DEFAULT_EVENT_COLOR, u as NcRuleSchema, v as addonWidgetsSourceCapability, x as cosineSimilarity, y as audioMetricsCapability, z as nodePin } from "../dist-O227Je-Q.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -4752,17 +4752,7 @@ function referencesAudioClass(classes) {
4752
4752
  if (classes === void 0) return false;
4753
4753
  return classes.some((c) => c.trim().toLowerCase().startsWith("audio-"));
4754
4754
  }
4755
- /**
4756
- * Evaluate one rule against one subject. The rule's `delivery` must match
4757
- * the subject kind (`immediate` ↔ `object-event`, `track-end` ↔
4758
- * `track-end`) — a mismatch fails with `'delivery'`. Schedule is evaluated
4759
- * at the SUBJECT timestamp (the persist moment), so boot-time reconcile of
4760
- * missed records applies the schedule the record lived under.
4761
- *
4762
- * Throttle/cooldown is deliberately NOT part of this function — cooldown
4763
- * is stateful; see {@link cooldownKey} / {@link isCoolingDown}.
4764
- */
4765
- function evaluateRule(rule, subject) {
4755
+ function evaluateRule(rule, subject, deviceState) {
4766
4756
  if (subject.kind === "occupancy-event") {
4767
4757
  if (rule.delivery !== "device-event") return fail("delivery");
4768
4758
  const occ = rule.conditions.occupancy;
@@ -4777,6 +4767,11 @@ function evaluateRule(rule, subject) {
4777
4767
  if (rule.conditions.occupancy !== void 0) return fail("occupancy");
4778
4768
  }
4779
4769
  const c = rule.conditions;
4770
+ if (c.deviceState !== void 0) {
4771
+ const current = deviceState?.(c.deviceState.deviceId);
4772
+ if (current === void 0) return fail("deviceState");
4773
+ if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
4774
+ }
4780
4775
  if (c.devices !== void 0 && c.devices.length > 0 && !c.devices.includes(subject.deviceId)) return fail("devices");
4781
4776
  if (c.source !== void 0 && c.source !== "any") {
4782
4777
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -6606,6 +6601,53 @@ var NcRuleStore = class {
6606
6601
  });
6607
6602
  }
6608
6603
  };
6604
+ var NcDeviceStateCache = class {
6605
+ deps;
6606
+ states = /* @__PURE__ */ new Map();
6607
+ lastOkAt = 0;
6608
+ ids = [];
6609
+ now;
6610
+ constructor(deps) {
6611
+ this.deps = deps;
6612
+ this.now = deps.now ?? (() => Date.now());
6613
+ }
6614
+ /**
6615
+ * The device ids worth reading — recomputed from the rule set, so a cluster
6616
+ * with no gated rule does no work at all.
6617
+ */
6618
+ setWatched(ids) {
6619
+ this.ids = [...new Set(ids)].sort((a, b) => a - b);
6620
+ }
6621
+ watched() {
6622
+ return this.ids;
6623
+ }
6624
+ /** Hot-path read. No I/O, no await — this is called per event. */
6625
+ get(deviceId) {
6626
+ if (this.now() - this.lastOkAt > 3e5) return void 0;
6627
+ return this.states.get(deviceId);
6628
+ }
6629
+ /**
6630
+ * Re-read every watched state. Never throws: a gate that could fail the
6631
+ * evaluation tick would take the notification pipeline with it.
6632
+ */
6633
+ async refresh() {
6634
+ if (this.ids.length === 0) {
6635
+ this.states = /* @__PURE__ */ new Map();
6636
+ return;
6637
+ }
6638
+ try {
6639
+ const next = await this.deps.readStates(this.ids);
6640
+ this.states = new Map(next);
6641
+ this.lastOkAt = this.now();
6642
+ 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 } });
6643
+ } catch (err) {
6644
+ this.deps.logger.warn("device-state refresh failed — keeping the previous states until they age out", { meta: {
6645
+ watched: this.ids.length,
6646
+ error: String(err)
6647
+ } });
6648
+ }
6649
+ }
6650
+ };
6609
6651
  //#endregion
6610
6652
  //#region src/notification-center/snooze-digest.ts
6611
6653
  /** `14:03` in the node's local zone — a digest is read by a person, at home. */
@@ -7439,6 +7481,9 @@ var NotificationCenter = class NotificationCenter {
7439
7481
  /** Snooze windows + what they hid. Mirrored into RAM; the dispatcher's gate
7440
7482
  * reads it with no I/O, the drain tick refreshes it. */
7441
7483
  snoozes;
7484
+ /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
7485
+ * read with no I/O on the persist hook. */
7486
+ deviceStates;
7442
7487
  /** True when ≥1 enabled `device-event` rule declares an occupancy condition —
7443
7488
  * the watcher is idle (zero per-frame cost) otherwise. */
7444
7489
  occupancyEnabled = false;
@@ -7471,6 +7516,11 @@ var NotificationCenter = class NotificationCenter {
7471
7516
  store: deps.store,
7472
7517
  logger: this.logger.child("occupancy")
7473
7518
  });
7519
+ this.deviceStates = new NcDeviceStateCache({
7520
+ logger: this.logger.child("device-state"),
7521
+ readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
7522
+ ...deps.now !== void 0 ? { now: deps.now } : {}
7523
+ });
7474
7524
  this.snoozes = new NcSnoozeStore({
7475
7525
  store: deps.store,
7476
7526
  logger: this.logger.child("snooze"),
@@ -7525,6 +7575,7 @@ var NotificationCenter = class NotificationCenter {
7525
7575
  await this.snoozes.load();
7526
7576
  this.refreshOccupancyWatch();
7527
7577
  if (!opts.evaluation) return;
7578
+ await this.deviceStates.refresh();
7528
7579
  this.evaluationActive = true;
7529
7580
  await this.outbox.load();
7530
7581
  await this.seedCooldowns();
@@ -7796,7 +7847,7 @@ var NotificationCenter = class NotificationCenter {
7796
7847
  let anyMatched = false;
7797
7848
  const rejections = [];
7798
7849
  for (const rule of candidates) {
7799
- const evaluation = evaluateRule(rule, subject);
7850
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
7800
7851
  if (!evaluation.matched) {
7801
7852
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
7802
7853
  this.logger.debug("rule did not match", {
@@ -7997,6 +8048,13 @@ var NotificationCenter = class NotificationCenter {
7997
8048
  }
7998
8049
  this.occupancyWatcher.setWatchedKeys(specs);
7999
8050
  this.occupancyEnabled = specs.length > 0;
8051
+ const gated = [];
8052
+ for (const rule of this.rules.list()) {
8053
+ if (!rule.enabled) continue;
8054
+ const ds = rule.conditions.deviceState;
8055
+ if (ds !== void 0) gated.push(ds.deviceId);
8056
+ }
8057
+ this.deviceStates.setWatched(gated);
8000
8058
  }
8001
8059
  /** Boot reseed of confirmed occupancy edge-state (durability, constraint 4) —
8002
8060
  * hydrate the watcher from the store, then prune orphaned durable rows to the
@@ -8013,6 +8071,7 @@ var NotificationCenter = class NotificationCenter {
8013
8071
  await this.rules.load();
8014
8072
  this.refreshOccupancyWatch();
8015
8073
  if (!this.evaluationActive) return;
8074
+ await this.deviceStates.refresh();
8016
8075
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
8017
8076
  }
8018
8077
  /** The full-key set the watcher currently tracks (post prune) — the bound for
@@ -8240,7 +8299,7 @@ var NotificationCenter = class NotificationCenter {
8240
8299
  const results = [];
8241
8300
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
8242
8301
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
8243
- const evaluation = evaluateRule(rule, subject);
8302
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id));
8244
8303
  results.push({
8245
8304
  recordId: subject.recordId,
8246
8305
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -22192,6 +22251,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
22192
22251
  addonId: device.addonId
22193
22252
  };
22194
22253
  },
22254
+ readDeviceStates: async (ids) => {
22255
+ const out = /* @__PURE__ */ new Map();
22256
+ for (const id of ids) try {
22257
+ const state = readDeviceStateFrom(await api.deviceManager.loadRuntimeState.query({ deviceId: id }));
22258
+ if (state !== void 0) out.set(id, state);
22259
+ } catch (err) {
22260
+ this.ctx.logger.debug("device-state read failed", {
22261
+ tags: { deviceId: id },
22262
+ meta: { error: String(err) }
22263
+ });
22264
+ }
22265
+ return out;
22266
+ },
22195
22267
  dispatcher: {
22196
22268
  getZoneNames: async (deviceId) => {
22197
22269
  return (await api.zones.listZones.query({ deviceId })).map((z) => ({
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-QDqT3XHa.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-aG07ehD0.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.25",
3
+ "version": "1.2.26",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",