@camstack/addon-post-analysis 1.2.327 → 1.2.331

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_clip_model_registry = require("../clip-model-registry-CDG2krro.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-DQmmIYXS.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -659,7 +659,8 @@ var NcAlarmArmOutcomeSchema = require_clip_model_registry.discriminatedUnion("ou
659
659
  require_clip_model_registry.object({
660
660
  outcome: require_clip_model_registry.literal("rejected"),
661
661
  reason: NcAlarmArmAnywayRejectionSchema
662
- }).readonly()
662
+ }).readonly(),
663
+ require_clip_model_registry.object({ outcome: require_clip_model_registry.literal("outside-schedule") }).readonly()
663
664
  ]);
664
665
  /**
665
666
  * The devices a mode covers, ascending and de-duplicated.
@@ -1518,7 +1519,25 @@ function modeDelaysFromPersisted(raw) {
1518
1519
  }
1519
1520
  kept[field] = value;
1520
1521
  }
1521
- if (kept.exitDelaySec !== void 0 || kept.entryDelaySec !== void 0 || kept.triggeredDurationSec !== void 0) entries.push(kept);
1522
+ if (item.postTriggeredAction !== void 0) {
1523
+ const action = require_clip_model_registry.NcAlarmPostTriggeredActionSchema.safeParse(item.postTriggeredAction);
1524
+ if (action.success) kept.postTriggeredAction = action.data;
1525
+ else dropped.push({
1526
+ mode: item.mode,
1527
+ why: "out-of-bounds",
1528
+ field: "triggeredDurationSec"
1529
+ });
1530
+ }
1531
+ if (item.schedule !== void 0) {
1532
+ const schedule = require_clip_model_registry.NcScheduleSchema.safeParse(item.schedule);
1533
+ if (schedule.success) kept.schedule = schedule.data;
1534
+ else dropped.push({
1535
+ mode: item.mode,
1536
+ why: "out-of-bounds",
1537
+ field: "schedule"
1538
+ });
1539
+ }
1540
+ if (kept.exitDelaySec !== void 0 || kept.entryDelaySec !== void 0 || kept.triggeredDurationSec !== void 0 || kept.postTriggeredAction !== void 0 || kept.schedule !== void 0) entries.push(kept);
1522
1541
  }
1523
1542
  return {
1524
1543
  entries,
@@ -1534,16 +1553,15 @@ function modeDelaysFromPersisted(raw) {
1534
1553
  * (`resolveModeDelays`): `home` has no exit delay on purpose — nobody is
1535
1554
  * leaving — and says so with its own entry, not by this shared value.
1536
1555
  *
1537
- * `triggeredDurationSec: 0` = "sound until somebody disarms it". That is what
1538
- * the panel did before the field existed, so an install that never opens the
1539
- * tab keeps behaving exactly as it did — a default that silently re-armed
1540
- * every existing alarm after N seconds would be a behaviour change nobody
1541
- * asked for.
1556
+ * Default post-triggered action is `rearmAfter` 10 s — every mode inherits
1557
+ * that unless the operator overrides. Older blobs that still store the stock
1558
+ * `0` + absent/`doNothing` seed are migrated once at panel boot (D701).
1542
1559
  */
1543
1560
  var DEFAULT_DELAYS = {
1544
1561
  exitDelaySec: 30,
1545
1562
  entryDelaySec: 20,
1546
- triggeredDurationSec: 0
1563
+ triggeredDurationSec: 10,
1564
+ postTriggeredAction: "rearmAfter"
1547
1565
  };
1548
1566
  var DISARMED = {
1549
1567
  target: "disarmed",
@@ -1561,7 +1579,7 @@ function armedState(mode) {
1561
1579
  * answer is still correct the moment anyone asks.
1562
1580
  */
1563
1581
  function stateAt(m, now) {
1564
- if (m.clearAt !== void 0 && now >= m.clearAt) return m.target;
1582
+ if (m.clearAt !== void 0 && now >= m.clearAt) return m.clearToDisarmed === true ? "disarmed" : m.target;
1565
1583
  if (m.fired) return "triggered";
1566
1584
  if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
1567
1585
  if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
@@ -1657,10 +1675,18 @@ function trigger(m, delays, now) {
1657
1675
  changedAt: now
1658
1676
  };
1659
1677
  }
1660
- /** `clearAt` iff the operator asked for a bounded siren — see {@link AlarmDelays}. */
1678
+ /**
1679
+ * `clearAt` (+ optional disarm) when the operator asked for a bounded
1680
+ * post-triggered action — see {@link AlarmDelays}.
1681
+ */
1661
1682
  function autoClear(delays, firesAt) {
1662
- if (delays.triggeredDurationSec <= 0) return {};
1663
- return { clearAt: firesAt + delays.triggeredDurationSec * 1e3 };
1683
+ if (delays.postTriggeredAction === "doNothing") return {};
1684
+ const clearAt = firesAt + Math.max(0, delays.triggeredDurationSec) * 1e3;
1685
+ if (delays.postTriggeredAction === "disableAfter") return {
1686
+ clearAt,
1687
+ clearToDisarmed: true
1688
+ };
1689
+ return { clearAt };
1664
1690
  }
1665
1691
  /**
1666
1692
  * Settle the machine so a stored value never depends on when it is read.
@@ -1670,16 +1696,23 @@ function autoClear(delays, firesAt) {
1670
1696
  * than only in the reading of it.
1671
1697
  */
1672
1698
  function settle(m, now) {
1673
- if (m.clearAt !== void 0 && now >= m.clearAt) return {
1674
- target: m.target,
1675
- fired: false,
1676
- changedAt: m.clearAt
1677
- };
1699
+ if (m.clearAt !== void 0 && now >= m.clearAt) {
1700
+ if (m.clearToDisarmed === true) return {
1701
+ ...DISARMED,
1702
+ changedAt: m.clearAt
1703
+ };
1704
+ return {
1705
+ target: m.target,
1706
+ fired: false,
1707
+ changedAt: m.clearAt
1708
+ };
1709
+ }
1678
1710
  if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
1679
1711
  target: m.target,
1680
1712
  fired: true,
1681
1713
  changedAt: m.triggerAt,
1682
- ...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {}
1714
+ ...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {},
1715
+ ...m.clearToDisarmed === true ? { clearToDisarmed: true } : {}
1683
1716
  };
1684
1717
  if (m.armedAt !== void 0 && now >= m.armedAt) return {
1685
1718
  target: m.target,
@@ -1826,7 +1859,8 @@ function alarmTransition(input) {
1826
1859
  kind: "alarm-disarmed",
1827
1860
  state: next,
1828
1861
  previous,
1829
- at
1862
+ at,
1863
+ ...input.autoCleared === true ? { autoCleared: true } : {}
1830
1864
  };
1831
1865
  return null;
1832
1866
  }
@@ -1917,6 +1951,17 @@ var ncAlarmPanelSchema = require_clip_model_registry.object({
1917
1951
  entryDelaySec: require_clip_model_registry.number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
1918
1952
  triggeredDurationSec: require_clip_model_registry.number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
1919
1953
  /**
1954
+ * What happens when the triggered window ends. Absent on older blobs →
1955
+ * derived from {@link triggeredDurationSec} at read time (never defaulted
1956
+ * here: a Zod default would force `doNothing` onto installs that already
1957
+ * had a positive `triggeredDurationSec` and silently stop re-arming).
1958
+ */
1959
+ postTriggeredAction: require_clip_model_registry._enum([
1960
+ "rearmAfter",
1961
+ "disableAfter",
1962
+ "doNothing"
1963
+ ]).optional(),
1964
+ /**
1920
1965
  * VESTIGIAL (D630): nothing reads it to decide anything any more — every
1921
1966
  * transition is announced to `announceTargets`. Kept so a persisted blob
1922
1967
  * and the settings cap still round-trip it, and read once at boot to tell
@@ -1975,7 +2020,14 @@ var ncAlarmPanelSchema = require_clip_model_registry.object({
1975
2020
  mode: require_clip_model_registry.string(),
1976
2021
  exitDelaySec: require_clip_model_registry.number().optional(),
1977
2022
  entryDelaySec: require_clip_model_registry.number().optional(),
1978
- triggeredDurationSec: require_clip_model_registry.number().optional()
2023
+ triggeredDurationSec: require_clip_model_registry.number().optional(),
2024
+ postTriggeredAction: require_clip_model_registry._enum([
2025
+ "rearmAfter",
2026
+ "disableAfter",
2027
+ "doNothing"
2028
+ ]).optional(),
2029
+ /** Loose — re-validated through {@link modeDelaysFromPersisted}. */
2030
+ schedule: require_clip_model_registry.unknown().optional()
1979
2031
  })).default([]),
1980
2032
  /**
1981
2033
  * Active alarm configuration profile id. Empty / absent on a blob older than
@@ -2047,12 +2099,18 @@ function settingsBlobFromPersisted(raw) {
2047
2099
  if (typeof rec["exitDelaySec"] === "number") entry.exitDelaySec = rec["exitDelaySec"];
2048
2100
  if (typeof rec["entryDelaySec"] === "number") entry.entryDelaySec = rec["entryDelaySec"];
2049
2101
  if (typeof rec["triggeredDurationSec"] === "number") entry.triggeredDurationSec = rec["triggeredDurationSec"];
2102
+ if (typeof rec["postTriggeredAction"] === "string") entry.postTriggeredAction = rec["postTriggeredAction"];
2103
+ if (rec["schedule"] !== void 0) entry.schedule = rec["schedule"];
2050
2104
  modeDelaysLoose.push(entry);
2051
2105
  }
2106
+ const truncatedTriggered = Math.max(0, Math.min(3600, Math.trunc(triggered)));
2107
+ const rawAction = raw["postTriggeredAction"];
2108
+ const postTriggeredAction = rawAction === "rearmAfter" || rawAction === "disableAfter" || rawAction === "doNothing" ? rawAction : void 0;
2052
2109
  return {
2053
2110
  exitDelaySec: Math.max(0, Math.min(600, Math.trunc(exit))),
2054
2111
  entryDelaySec: Math.max(0, Math.min(600, Math.trunc(entry))),
2055
- triggeredDurationSec: Math.max(0, Math.min(3600, Math.trunc(triggered))),
2112
+ triggeredDurationSec: truncatedTriggered,
2113
+ ...postTriggeredAction !== void 0 ? { postTriggeredAction } : {},
2056
2114
  announceTargets,
2057
2115
  nonBlocking: lists("nonBlocking"),
2058
2116
  blocking: lists("blocking"),
@@ -2065,6 +2123,7 @@ function settingsToPersisted(settings) {
2065
2123
  exitDelaySec: settings.exitDelaySec,
2066
2124
  entryDelaySec: settings.entryDelaySec,
2067
2125
  triggeredDurationSec: settings.triggeredDurationSec,
2126
+ ...settings.postTriggeredAction !== void 0 ? { postTriggeredAction: settings.postTriggeredAction } : {},
2068
2127
  announceTargets: [...settings.announceTargets],
2069
2128
  nonBlocking: settings.nonBlocking.map((l) => ({
2070
2129
  mode: l.mode,
@@ -2149,11 +2208,13 @@ function machineFromPersisted(raw) {
2149
2208
  const armedAt = optionalInstant(rec["armedAt"]);
2150
2209
  const triggerAt = optionalInstant(rec["triggerAt"]);
2151
2210
  const clearAt = optionalInstant(rec["clearAt"]);
2211
+ const clearToDisarmed = rec["clearToDisarmed"] === true;
2152
2212
  return {
2153
2213
  target: rec["target"],
2154
2214
  ...armedAt !== void 0 ? { armedAt } : {},
2155
2215
  ...triggerAt !== void 0 ? { triggerAt } : {},
2156
2216
  ...clearAt !== void 0 ? { clearAt } : {},
2217
+ ...clearToDisarmed ? { clearToDisarmed: true } : {},
2157
2218
  fired: rec["fired"] === true,
2158
2219
  changedAt: rec["changedAt"]
2159
2220
  };
@@ -2193,6 +2254,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2193
2254
  super(ctx, ncAlarmPanelSchema, { type: require_clip_model_registry.DeviceType.AlarmPanel });
2194
2255
  this.logDroppedModeDelays();
2195
2256
  this.restore();
2257
+ this.migrateStockAfterTriggerDefaults();
2196
2258
  this.registerAlarmCap();
2197
2259
  this.publish();
2198
2260
  if (this.config.get("announceArm") === false && this.config.get("announceTargets").length > 0) this.ctx.logger.info("alarm announcements now cover every transition (D630) — the announceArm switch is gone", { tags: { deviceId: this.id } });
@@ -2204,10 +2266,36 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2204
2266
  this.config.set("announceTargets", []);
2205
2267
  }
2206
2268
  }
2269
+ /**
2270
+ * One-shot: the pre-D701 stock seed was `triggeredDurationSec: 0` + absent /
2271
+ * `doNothing` (stay triggered forever). That left After trigger looking dead
2272
+ * in the UI. Flip only that seed to {@link DEFAULT_DELAYS} — never a custom
2273
+ * duration or an explicit `disableAfter`.
2274
+ */
2275
+ migrateStockAfterTriggerDefaults() {
2276
+ const duration = this.config.get("triggeredDurationSec");
2277
+ const action = this.config.get("postTriggeredAction");
2278
+ if (!(duration === 0 && (action === void 0 || action === "doNothing"))) return;
2279
+ this.config.set("triggeredDurationSec", DEFAULT_DELAYS.triggeredDurationSec);
2280
+ this.config.set("postTriggeredAction", DEFAULT_DELAYS.postTriggeredAction);
2281
+ this.ctx.logger.info("alarm after-trigger defaults migrated to rearmAfter 10s", {
2282
+ tags: { deviceId: this.id },
2283
+ meta: {
2284
+ fromDurationSec: duration,
2285
+ fromAction: action ?? "absent",
2286
+ toDurationSec: DEFAULT_DELAYS.triggeredDurationSec,
2287
+ toAction: DEFAULT_DELAYS.postTriggeredAction
2288
+ }
2289
+ });
2290
+ }
2207
2291
  /** The operator-visible configuration, as the cap serves it. */
2208
2292
  settings() {
2293
+ const storedAction = this.config.get("postTriggeredAction");
2209
2294
  return {
2210
- ...this.readDelays(),
2295
+ exitDelaySec: this.config.get("exitDelaySec"),
2296
+ entryDelaySec: this.config.get("entryDelaySec"),
2297
+ triggeredDurationSec: this.config.get("triggeredDurationSec"),
2298
+ ...storedAction !== void 0 ? { postTriggeredAction: storedAction } : {},
2211
2299
  announceArm: this.config.get("announceArm"),
2212
2300
  announceTargets: [...this.config.get("announceTargets")],
2213
2301
  nonBlocking: modeDeviceListFromPersisted(this.config.get("nonBlocking")),
@@ -2356,6 +2444,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2356
2444
  if (patch.exitDelaySec !== void 0) await this.config.set("exitDelaySec", patch.exitDelaySec);
2357
2445
  if (patch.entryDelaySec !== void 0) await this.config.set("entryDelaySec", patch.entryDelaySec);
2358
2446
  if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
2447
+ if (patch.postTriggeredAction !== void 0) await this.config.set("postTriggeredAction", patch.postTriggeredAction);
2359
2448
  if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
2360
2449
  if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
2361
2450
  if (membership !== void 0 && membershipChanged !== null) this.ctx.logger.info("alarm non-blocking list changed", {
@@ -2531,6 +2620,29 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2531
2620
  }
2532
2621
  });
2533
2622
  }
2623
+ if (edit.postTriggeredAction !== void 0) {
2624
+ const from = was?.postTriggeredAction ?? null;
2625
+ const to = now?.postTriggeredAction ?? null;
2626
+ if (from !== to) this.ctx.logger.info("alarm mode delays changed", {
2627
+ tags: { deviceId: this.id },
2628
+ meta: {
2629
+ mode: edit.mode,
2630
+ field: "postTriggeredAction",
2631
+ from,
2632
+ to
2633
+ }
2634
+ });
2635
+ }
2636
+ if (edit.schedule !== void 0) {
2637
+ if ((was?.schedule === void 0 ? null : "set") !== (now?.schedule === void 0 ? null : "set") || edit.schedule !== null) this.ctx.logger.info("alarm mode activation schedule changed", {
2638
+ tags: { deviceId: this.id },
2639
+ meta: {
2640
+ mode: edit.mode,
2641
+ cleared: edit.schedule === null,
2642
+ windows: now?.schedule?.windows.length ?? 0
2643
+ }
2644
+ });
2645
+ }
2534
2646
  }
2535
2647
  }
2536
2648
  /** One INFO line per persisted per-mode delay entry the guard refused to trust. */
@@ -2589,6 +2701,13 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2589
2701
  */
2590
2702
  async armWith(mode) {
2591
2703
  const now = Date.now();
2704
+ if (!this.modeScheduleAllows(mode, now)) {
2705
+ this.ctx.logger.info("alarm arm refused — outside activation schedule", {
2706
+ tags: { deviceId: this.id },
2707
+ meta: { mode }
2708
+ });
2709
+ return { outcome: "outside-schedule" };
2710
+ }
2592
2711
  const read = await this.applyArmGuard(mode);
2593
2712
  if (read.kind === "unguarded") return this.settleArm(mode, null, now);
2594
2713
  if (read.kind === "check-failed") {
@@ -2602,6 +2721,11 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2602
2721
  why: this.exclusionWhyFor(mode, device)
2603
2722
  })), now);
2604
2723
  }
2724
+ /** Absent schedule = always allowed (`isScheduleActive`). */
2725
+ modeScheduleAllows(mode, now) {
2726
+ const schedule = modeDelaysFromPersisted(this.config.get("modeDelays")).entries.find((e) => e.mode === mode)?.schedule;
2727
+ return require_clip_model_registry.isScheduleActive(schedule, now);
2728
+ }
2605
2729
  /**
2606
2730
  * The Arm anyway on ONE refusal's notification — the only path that arms
2607
2731
  * around a device that blocks.
@@ -2627,6 +2751,17 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2627
2751
  const reason = refusalId === this.spentRefusalId ? "used" : "unknown-refusal";
2628
2752
  return this.rejectArmAnyway(refusalId, reason);
2629
2753
  }
2754
+ if (!this.modeScheduleAllows(held.mode, now)) {
2755
+ this.refusal = null;
2756
+ this.ctx.logger.info("alarm arm-anyway refused — outside activation schedule", {
2757
+ tags: { deviceId: this.id },
2758
+ meta: {
2759
+ mode: held.mode,
2760
+ refusalId
2761
+ }
2762
+ });
2763
+ return { outcome: "outside-schedule" };
2764
+ }
2630
2765
  if (this.machine.changedAt !== held.panelChangedAt) {
2631
2766
  this.refusal = null;
2632
2767
  return this.rejectArmAnyway(refusalId, "stale");
@@ -3022,11 +3157,12 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3022
3157
  * rather than fired only once somebody looks.
3023
3158
  */
3024
3159
  publish(now = Date.now()) {
3160
+ const autoCleared = this.machine.clearAt !== void 0 && now >= this.machine.clearAt && this.machine.clearToDisarmed === true;
3025
3161
  this.machine = settle(this.machine, now);
3026
3162
  const state = stateAt(this.machine, now);
3027
3163
  this.runtimeState.setCapState("alarm-panel", this.status(now));
3028
3164
  this.persist();
3029
- this.report(state, now);
3165
+ this.report(state, now, autoCleared);
3030
3166
  if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0 || this.machine.clearAt !== void 0) this.startTicking();
3031
3167
  else this.stopTicking();
3032
3168
  }
@@ -3038,7 +3174,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3038
3174
  * panel's own state update with it would make a delivery failure look like
3039
3175
  * an alarm failure.
3040
3176
  */
3041
- report(state, now) {
3177
+ report(state, now, autoCleared = false) {
3042
3178
  const previous = this.lastPublished;
3043
3179
  this.lastPublished = state;
3044
3180
  const hook = this.onPublish;
@@ -3053,7 +3189,8 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3053
3189
  at: now,
3054
3190
  ...source !== null ? { source } : {},
3055
3191
  ...arming !== void 0 ? { arming } : {},
3056
- ...pending !== void 0 ? { pending } : {}
3192
+ ...pending !== void 0 ? { pending } : {},
3193
+ ...autoCleared ? { autoCleared: true } : {}
3057
3194
  });
3058
3195
  if (transition?.kind === "alarm-triggered") this.attribution.clear();
3059
3196
  try {
@@ -3139,8 +3276,12 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3139
3276
  }
3140
3277
  /** The global durations plus the per-mode overrides, read from the persisted config NOW. */
3141
3278
  delaySettings() {
3279
+ const storedAction = this.config.get("postTriggeredAction");
3142
3280
  return {
3143
- ...this.readDelays(),
3281
+ exitDelaySec: this.config.get("exitDelaySec"),
3282
+ entryDelaySec: this.config.get("entryDelaySec"),
3283
+ triggeredDurationSec: this.config.get("triggeredDurationSec"),
3284
+ ...storedAction !== void 0 ? { postTriggeredAction: storedAction } : {},
3144
3285
  modeDelays: modeDelaysFromPersisted(this.config.get("modeDelays")).entries
3145
3286
  };
3146
3287
  }
@@ -3150,24 +3291,32 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3150
3291
  */
3151
3292
  delaysFor(mode) {
3152
3293
  const settings = this.delaySettings();
3153
- if (mode === null) return {
3154
- exitDelaySec: settings.exitDelaySec,
3155
- entryDelaySec: settings.entryDelaySec,
3156
- triggeredDurationSec: settings.triggeredDurationSec,
3157
- source: {
3158
- exitDelaySec: "default",
3159
- entryDelaySec: "default",
3160
- triggeredDurationSec: "default"
3161
- }
3162
- };
3294
+ if (mode === null) {
3295
+ const delays = this.readDelays();
3296
+ return {
3297
+ exitDelaySec: delays.exitDelaySec,
3298
+ entryDelaySec: delays.entryDelaySec,
3299
+ triggeredDurationSec: delays.triggeredDurationSec,
3300
+ postTriggeredAction: delays.postTriggeredAction,
3301
+ source: {
3302
+ exitDelaySec: "default",
3303
+ entryDelaySec: "default",
3304
+ triggeredDurationSec: "default",
3305
+ postTriggeredAction: "default"
3306
+ }
3307
+ };
3308
+ }
3163
3309
  return require_clip_model_registry.resolveModeDelays(settings, mode);
3164
3310
  }
3165
- /** The three global durations, read from the persisted config. */
3311
+ /** The global durations + post-triggered action, read from the persisted config. */
3166
3312
  readDelays() {
3313
+ const triggeredDurationSec = this.config.get("triggeredDurationSec");
3314
+ const stored = this.config.get("postTriggeredAction");
3167
3315
  return {
3168
3316
  exitDelaySec: this.config.get("exitDelaySec"),
3169
3317
  entryDelaySec: this.config.get("entryDelaySec"),
3170
- triggeredDurationSec: this.config.get("triggeredDurationSec")
3318
+ triggeredDurationSec,
3319
+ postTriggeredAction: require_clip_model_registry.derivePostTriggeredAction(stored, triggeredDurationSec)
3171
3320
  };
3172
3321
  }
3173
3322
  startTicking() {
@@ -5148,7 +5297,7 @@ function ncClassNoun(texts, className, count) {
5148
5297
  count,
5149
5298
  vars: { name: className }
5150
5299
  });
5151
- if (require_clip_model_registry.isEventKindLabelKey(normalised)) return require_clip_model_registry.eventKindLabel(normalised, texts.language);
5300
+ if (require_clip_model_registry.isEventKindLabelKey(normalised)) return require_clip_model_registry.eventKindLabel(normalised, texts.language).toLocaleLowerCase(texts.language);
5152
5301
  return texts.text({
5153
5302
  key: "class.other",
5154
5303
  count,
@@ -5156,13 +5305,24 @@ function ncClassNoun(texts, className, count) {
5156
5305
  });
5157
5306
  }
5158
5307
  /**
5159
- * The parenthetical half of a labelled subject: a recognised person or plate
5160
- * is printed verbatim; a classification (species, COCO sub, …) goes through
5161
- * the shared event-kind map so "animal (cat)" becomes "animale (Gatto)".
5308
+ * The ONE capitalisation the notification body gets: its first letter, after
5309
+ * every catalog fragment and placeholder has already been rendered lowercase.
5310
+ * Title, fragments and mid-sentence summaries are never passed through here.
5162
5311
  */
5163
- function ncSubjectLabelText(texts, label, labelKind) {
5164
- if (labelKind === "identity" || labelKind === "plate") return label;
5165
- return require_clip_model_registry.eventKindLabel(label, texts.language);
5312
+ function ncSentenceCaseBody(body) {
5313
+ if (body.length === 0) return body;
5314
+ const first = body[0];
5315
+ const upper = first.toLocaleUpperCase();
5316
+ if (upper === first) return body;
5317
+ return `${upper}${body.slice(1)}`;
5318
+ }
5319
+ /**
5320
+ * True when `label` is a taxonomy / classification id (species, COCO sub, …)
5321
+ * rather than a person name or plate. Those REPLACE the coarse class in the
5322
+ * subject — "dog" not "animal (dog)", "cane" not "animale (cane)".
5323
+ */
5324
+ function isClassificationLabel(label) {
5325
+ return require_clip_model_registry.isEventKindLabelKey(label.trim().toLowerCase());
5166
5326
  }
5167
5327
  /** `person (John)` / `persona (John)` — the thing the notification is about. */
5168
5328
  function ncSubjectText(texts, input) {
@@ -5171,11 +5331,15 @@ function ncSubjectText(texts, input) {
5171
5331
  key: "detection.subject",
5172
5332
  vars: { classNoun }
5173
5333
  });
5334
+ if (input.labelKind !== "identity" && input.labelKind !== "plate" && isClassificationLabel(input.label)) return texts.text({
5335
+ key: "detection.subject",
5336
+ vars: { classNoun: ncClassNoun(texts, input.label, 1) }
5337
+ });
5174
5338
  return texts.text({
5175
5339
  key: subjectLabelKey(input.labelKind),
5176
5340
  vars: {
5177
5341
  classNoun,
5178
- label: ncSubjectLabelText(texts, input.label, input.labelKind)
5342
+ label: input.label
5179
5343
  }
5180
5344
  });
5181
5345
  }
@@ -5765,7 +5929,7 @@ function isAdoptable(record, nowMs) {
5765
5929
  * what `localDayMinute` does for the same input — one behaviour for a bad
5766
5930
  * zone, not two.
5767
5931
  */
5768
- function clockOf$6(atMs, timeZone) {
5932
+ function clockOf$7(atMs, timeZone) {
5769
5933
  if (timeZone !== void 0) try {
5770
5934
  return new Intl.DateTimeFormat("en-GB", {
5771
5935
  timeZone,
@@ -5901,7 +6065,7 @@ var TimelapseRenderer = class {
5901
6065
  includeAudio: false,
5902
6066
  maxLifeMs: await this.ports.exportLifeMs(),
5903
6067
  deleteAfterDownload: false,
5904
- title: `${request.ruleName} ${clockOf$6(window.startMs, request.timezone)}→${clockOf$6(window.endMs, request.timezone)}`,
6068
+ title: `${request.ruleName} ${clockOf$7(window.startMs, request.timezone)}→${clockOf$7(window.endMs, request.timezone)}`,
5905
6069
  timelapse: {
5906
6070
  everyMs,
5907
6071
  outputFps: request.framerate,
@@ -6232,7 +6396,7 @@ var TimelapseRenderer = class {
6232
6396
  ...meta,
6233
6397
  unservedWindowCount: verdict.unservedWindows.length,
6234
6398
  unservedWindows: verdict.unservedWindows.slice(0, 12).map((w) => ({
6235
- at: clockOf$6(request.window.startMs + w.fromSec * 1e3, request.timezone),
6399
+ at: clockOf$7(request.window.startMs + w.fromSec * 1e3, request.timezone),
6236
6400
  fromSec: Math.round(w.fromSec),
6237
6401
  toSec: Math.round(w.toSec),
6238
6402
  expectedDenseFrames: w.expectedDenseFrames,
@@ -8813,6 +8977,7 @@ function buildActionButtons(input) {
8813
8977
  sequenceId: button.sequenceId,
8814
8978
  actionId: button.id
8815
8979
  }),
8980
+ mode: "background",
8816
8981
  ...button.icon !== void 0 ? { icon: button.icon } : {},
8817
8982
  ...button.destructive !== void 0 ? { destructive: button.destructive } : {}
8818
8983
  });
@@ -8928,10 +9093,9 @@ var MAX_ARM_BUTTONS = 3;
8928
9093
  * (`ALARM_DISARM_TTL_MS`). Both exist: the hint is for the person, the TTL is
8929
9094
  * for the forwarded screenshot.
8930
9095
  *
8931
- * Arm buttons are `background` — arming from the lock screen should not open a
8932
- * browser. The disarm is `navigate`: it removes a protection, and the operator
8933
- * doing it deserves to SEE that it happened rather than trust a POST nobody
8934
- * confirmed.
9096
+ * Every alarm button is `background` — Zentik (and ntfy) fire a POST and leave
9097
+ * the lock screen alone. Opening a browser to arm or disarm costs more
9098
+ * attention than the notification did.
8935
9099
  *
8936
9100
  * `panel.availableModes` ARE the enabled modes: CamStack has no per-mode
8937
9101
  * disable setting, so the Arm buttons offer exactly what the panel declares.
@@ -8965,14 +9129,14 @@ function buildAlarmButtons(input) {
8965
9129
  }),
8966
9130
  icon: "disarm",
8967
9131
  destructive: true,
8968
- mode: "navigate"
9132
+ mode: "background"
8969
9133
  }];
8970
9134
  }
8971
9135
  /**
8972
9136
  * The one Arm anyway a refusal notification carries — or none, when the row
8973
- * names no refusal. `navigate` and destructive, like the disarm: it arms
8974
- * AROUND an open door, and the person doing it should see it happen. Its TTL
8975
- * is the refusal's own (`ARM_ANYWAY_TTL_MS`, priced by the minter).
9137
+ * names no refusal. Destructive + background: it arms around an open door via
9138
+ * POST (Zentik BACKGROUND_CALL), same as disarm. Its TTL is the refusal's own
9139
+ * (`ARM_ANYWAY_TTL_MS`, priced by the minter).
8976
9140
  */
8977
9141
  function armAnywayButton(refusalId, input) {
8978
9142
  if (refusalId === void 0) return [];
@@ -8987,7 +9151,7 @@ function armAnywayButton(refusalId, input) {
8987
9151
  }),
8988
9152
  icon: "arm",
8989
9153
  destructive: true,
8990
- mode: "navigate"
9154
+ mode: "background"
8991
9155
  }];
8992
9156
  }
8993
9157
  //#endregion
@@ -12909,6 +13073,10 @@ function alarmTextKeys(transition, body) {
12909
13073
  titleKey,
12910
13074
  bodyKey: `system.${kind}.body.${transition.sensor.event}`
12911
13075
  };
13076
+ if (kind === "alarm-disarmed" && transition.autoCleared === true) return {
13077
+ titleKey,
13078
+ bodyKey: `system.${kind}.body.autoCleared`
13079
+ };
12912
13080
  if (kind === "alarm-disarmed" && (transition.previous === "triggered" || transition.previous === "pending")) return {
12913
13081
  titleKey,
12914
13082
  bodyKey: `system.${kind}.body.defused`
@@ -14628,13 +14796,14 @@ var en_default = {
14628
14796
  "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
14629
14797
  "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
14630
14798
  "system.alarm-arming.body": "Arming",
14631
- "system.alarm-armed.title.home": "Armed: home",
14632
- "system.alarm-armed.title.away": "Armed: away",
14633
- "system.alarm-armed.title.night": "Armed: night",
14799
+ "system.alarm-armed.title.home": "Alarm armed: home",
14800
+ "system.alarm-armed.title.away": "Alarm armed: away",
14801
+ "system.alarm-armed.title.night": "Alarm armed: night",
14634
14802
  "system.alarm-armed.body": "Alarm armed",
14635
14803
  "system.alarm-disarmed.title": "Disarmed",
14636
14804
  "system.alarm-disarmed.body": "Alarm disarmed",
14637
14805
  "system.alarm-disarmed.body.defused": "Alarm disarmed during an alarm",
14806
+ "system.alarm-disarmed.body.autoCleared": "Alarm disarmed automatically after the siren",
14638
14807
  "system.alarm-triggered.title": "ALARM",
14639
14808
  "system.alarm-triggered.body": "Alarm triggered",
14640
14809
  "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
@@ -14956,13 +15125,14 @@ var it_default = {
14956
15125
  "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
14957
15126
  "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
14958
15127
  "system.alarm-arming.body": "Inserimento in corso",
14959
- "system.alarm-armed.title.home": "Inserito: Casa",
14960
- "system.alarm-armed.title.away": "Inserito: Fuori",
14961
- "system.alarm-armed.title.night": "Inserito: Notte",
15128
+ "system.alarm-armed.title.home": "Allarme inserito: Casa",
15129
+ "system.alarm-armed.title.away": "Allarme inserito: Fuori",
15130
+ "system.alarm-armed.title.night": "Allarme inserito: Notte",
14962
15131
  "system.alarm-armed.body": "Allarme inserito",
14963
15132
  "system.alarm-disarmed.title": "Disinserito",
14964
15133
  "system.alarm-disarmed.body": "Allarme disinserito",
14965
15134
  "system.alarm-disarmed.body.defused": "Allarme disinserito durante un allarme",
15135
+ "system.alarm-disarmed.body.autoCleared": "Allarme disinserito automaticamente dopo la sirena",
14966
15136
  "system.alarm-triggered.title": "ALLARME",
14967
15137
  "system.alarm-triggered.body": "Allarme scattato",
14968
15138
  "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
@@ -23965,7 +24135,7 @@ var NcDispatcher = class {
23965
24135
  ...systemCount,
23966
24136
  ...systemEvent?.body !== void 0 ? { frozen: systemEvent.body } : {}
23967
24137
  });
23968
- return composeRuleAiBody({
24138
+ return ncSentenceCaseBody(composeRuleAiBody({
23969
24139
  aiText,
23970
24140
  body: entry.payload.synthetic === true ? this.renderText({
23971
24141
  key: "test.prefix",
@@ -23973,7 +24143,7 @@ var NcDispatcher = class {
23973
24143
  frozen: rendered
23974
24144
  }) : rendered,
23975
24145
  templateNamesAi
23976
- });
24146
+ }));
23977
24147
  };
23978
24148
  const body = renderBody("");
23979
24149
  const resolved = await this.resolveAttachments(entry);
@@ -25153,15 +25323,18 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
25153
25323
  const systemEvent = subject.systemEvent;
25154
25324
  const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
25155
25325
  const group = readOutboxGroup(entry.payload.group);
25326
+ const locale = texts.language;
25327
+ const cameraName = deviceName.toLocaleLowerCase(locale);
25328
+ const zonesLower = zoneLabels.map((z) => z.toLocaleLowerCase(locale));
25156
25329
  return {
25157
- camera: deviceName,
25330
+ camera: cameraName,
25158
25331
  class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
25159
25332
  classLabel: (() => {
25160
25333
  const wire = detection?.className ?? (systemEvent !== void 0 ? "" : subject.className);
25161
- return wire.length === 0 ? "" : require_clip_model_registry.eventKindLabel(wire, texts.language);
25334
+ return wire.length === 0 ? "" : require_clip_model_registry.eventKindLabel(wire, locale).toLocaleLowerCase(locale);
25162
25335
  })(),
25163
25336
  label: detection?.label ?? subject.label ?? "",
25164
- zones: zoneLabels.join(", "),
25337
+ zones: zonesLower.join(", "),
25165
25338
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
25166
25339
  className: detection.className,
25167
25340
  ...detection.label !== void 0 ? { label: detection.label } : {}
@@ -25171,9 +25344,9 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
25171
25344
  ...subject.labelKind !== void 0 ? { labelKind: subject.labelKind } : {}
25172
25345
  }),
25173
25346
  detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
25174
- inZones: ncInZonesText(texts, zoneLabels),
25175
- scope: occupancy !== void 0 ? occupancy.zone ?? deviceName : "",
25176
- zone: occupancy?.zone ?? zoneLabels[0] ?? "",
25347
+ inZones: ncInZonesText(texts, zonesLower),
25348
+ scope: occupancy !== void 0 ? occupancy.zone?.toLocaleLowerCase(locale) ?? cameraName : "",
25349
+ zone: (occupancy?.zone ?? zoneLabels[0] ?? "").toLocaleLowerCase(locale),
25177
25350
  confidence: percentOf(detection?.confidence ?? subject.confidence),
25178
25351
  time: ncClockText(texts.language, subject.timestamp, timeZone),
25179
25352
  sounds: subject.audio?.labels.join(", ") ?? "",
@@ -29671,7 +29844,7 @@ function buildRuleAiSystemPrompt(shape, language, maxWords, grouped = false) {
29671
29844
  ].join("");
29672
29845
  }
29673
29846
  /** `HH:MM` in the host timezone. */
29674
- function clockOf$5(atMs) {
29847
+ function clockOf$6(atMs) {
29675
29848
  const d = new Date(atMs);
29676
29849
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
29677
29850
  }
@@ -29709,7 +29882,7 @@ function buildRuleAiPrompt(input) {
29709
29882
  if (shape === "detection-track") {
29710
29883
  lines.push(`Camera: ${camera}.${located} Subject: ${subject}. The stills, in order:`);
29711
29884
  for (const [i, frame] of input.frames.entries()) {
29712
- const when = frame.atMs !== void 0 ? ` · ${clockOf$5(frame.atMs)}` : "";
29885
+ const when = frame.atMs !== void 0 ? ` · ${clockOf$6(frame.atMs)}` : "";
29713
29886
  lines.push(`${String(i + 1)}.${when}`);
29714
29887
  }
29715
29888
  return lines.join("\n");
@@ -31480,7 +31653,7 @@ var DEFAULT_PROMPT_EN = "What happened during this period? Summarise it for a ho
31480
31653
  */
31481
31654
  var SummaryAnswerSchema = require_clip_model_registry.object({ summary: require_clip_model_registry.string() });
31482
31655
  /** `HH:MM` in the host timezone — the two numbers a frame label needs. */
31483
- function clockOf$4(atMs) {
31656
+ function clockOf$5(atMs) {
31484
31657
  const d = new Date(atMs);
31485
31658
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
31486
31659
  }
@@ -31503,7 +31676,7 @@ function buildSummaryAiPrompt(input, frames) {
31503
31676
  const roster = frames.map((f, i) => {
31504
31677
  const location = plainLabel(f.location ?? "");
31505
31678
  const where = location.length > 0 ? ` (${location})` : "";
31506
- return `${String(i + 1)}. ${plainLabel(f.deviceName)}${where} · ${clockOf$4(f.atMs)} · ${plainVocabulary(f.className) || "subject"}`;
31679
+ return `${String(i + 1)}. ${plainLabel(f.deviceName)}${where} · ${clockOf$5(f.atMs)} · ${plainVocabulary(f.className) || "subject"}`;
31507
31680
  }).join("\n");
31508
31681
  const described = /* @__PURE__ */ new Map();
31509
31682
  for (const f of frames) {
@@ -32106,7 +32279,7 @@ var NO_DETECTIONS$1 = {
32106
32279
  * has to check is a key somebody can get wrong. */
32107
32280
  var BODY_SEPARATOR$1 = " · ";
32108
32281
  /** `HH:MM` in the host timezone. */
32109
- function clockOf$3(atMs) {
32282
+ function clockOf$4(atMs) {
32110
32283
  const d = new Date(atMs);
32111
32284
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
32112
32285
  }
@@ -32120,8 +32293,8 @@ function clockOf$3(atMs) {
32120
32293
  function summaryTemplateVars(input) {
32121
32294
  return {
32122
32295
  rule: input.ruleName,
32123
- from: clockOf$3(input.window.startMs),
32124
- to: clockOf$3(input.window.endMs),
32296
+ from: clockOf$4(input.window.startMs),
32297
+ to: clockOf$4(input.window.endMs),
32125
32298
  time: ncClockText(input.texts.language, input.generatedAt),
32126
32299
  events: String(input.tileCount),
32127
32300
  cameras: String(input.cameraCount),
@@ -32216,11 +32389,11 @@ function summaryRecordId(ruleId, windowEndMs) {
32216
32389
  /** The caption burned onto the mosaic's title band, with the same `{{var}}`
32217
32390
  * vocabulary the title and body use. `''` is a real answer ("no caption"). */
32218
32391
  function summaryCaptionText(input) {
32219
- if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$3(input.window.startMs)}→${clockOf$3(input.window.endMs)}`;
32392
+ if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$4(input.window.startMs)}→${clockOf$4(input.window.endMs)}`;
32220
32393
  return renderTemplate(input.captionText, {
32221
32394
  rule: input.ruleName,
32222
- from: clockOf$3(input.window.startMs),
32223
- to: clockOf$3(input.window.endMs)
32395
+ from: clockOf$4(input.window.startMs),
32396
+ to: clockOf$4(input.window.endMs)
32224
32397
  }) ?? "";
32225
32398
  }
32226
32399
  /**
@@ -33152,7 +33325,7 @@ var NcSummaryProducer = class {
33152
33325
  purpose: "summary",
33153
33326
  tiles: tiles.map(({ candidate, jpeg }) => ({
33154
33327
  jpeg,
33155
- ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
33328
+ ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$3(candidate.firstSeen)} · ${candidate.className}` } : {},
33156
33329
  deviceId: candidate.deviceId,
33157
33330
  sourceId: candidate.trackId
33158
33331
  })),
@@ -33375,7 +33548,7 @@ var NcSummaryProducer = class {
33375
33548
  }
33376
33549
  };
33377
33550
  /** `HH:MM` in the host timezone — the two numbers a tile label needs. */
33378
- function clockOf$2(atMs) {
33551
+ function clockOf$3(atMs) {
33379
33552
  const d = new Date(atMs);
33380
33553
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
33381
33554
  }
@@ -33875,6 +34048,99 @@ var NcCalendarEventsStore = class {
33875
34048
  }
33876
34049
  };
33877
34050
  //#endregion
34051
+ //#region src/notification-center/calendar-events/calendar-events-body.ts
34052
+ /** Visual weight on the tomorrow header — Telegram/Zentik plain text. */
34053
+ var CALENDAR_EVENTS_TOMORROW_MARK = "★";
34054
+ /** Separates the tomorrow block from the rest of the horizon. */
34055
+ var CALENDAR_EVENTS_DAY_DELIMITER = "────────";
34056
+ var MAX_EVENT_LINES = 40;
34057
+ function pad2(n) {
34058
+ return String(n).padStart(2, "0");
34059
+ }
34060
+ /** Local calendar day key `YYYY-MM-DD`. */
34061
+ function localDayKey(atMs) {
34062
+ const d = new Date(atMs);
34063
+ return `${String(d.getFullYear())}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
34064
+ }
34065
+ function startOfLocalDay(atMs) {
34066
+ const d = new Date(atMs);
34067
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
34068
+ }
34069
+ function addLocalDays(atMs, days) {
34070
+ const d = new Date(atMs);
34071
+ d.setDate(d.getDate() + days);
34072
+ return d.getTime();
34073
+ }
34074
+ function dayHeadingFor(dayStartMs, todayKey, tomorrowKey) {
34075
+ const key = localDayKey(dayStartMs);
34076
+ const label = new Date(dayStartMs).toLocaleDateString(void 0, {
34077
+ weekday: "short",
34078
+ month: "short",
34079
+ day: "numeric"
34080
+ });
34081
+ if (key === todayKey) return `Today — ${label}`;
34082
+ if (key === tomorrowKey) return `${CALENDAR_EVENTS_TOMORROW_MARK} Tomorrow — ${label}`;
34083
+ return label;
34084
+ }
34085
+ /** `HH:MM` local, or `YYYY-MM-DD HH:MM` when the end falls on another day. */
34086
+ function clockOf$2(atMs) {
34087
+ const d = new Date(atMs);
34088
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
34089
+ }
34090
+ function formatEventLine(event) {
34091
+ const when = event.allDay === true ? "All day" : `${clockOf$2(event.startMs)}–${clockOf$2(event.endMs)}`;
34092
+ const where = event.location !== void 0 && event.location.length > 0 ? ` @ ${event.location}` : "";
34093
+ return `• ${when} ${event.title}${where}`;
34094
+ }
34095
+ function groupByLocalDay(events) {
34096
+ const map = /* @__PURE__ */ new Map();
34097
+ for (const ev of events) {
34098
+ const dayKey = localDayKey(ev.startMs);
34099
+ const existing = map.get(dayKey);
34100
+ if (existing !== void 0) {
34101
+ existing.events.push(ev);
34102
+ continue;
34103
+ }
34104
+ map.set(dayKey, {
34105
+ dayKey,
34106
+ dayStartMs: startOfLocalDay(ev.startMs),
34107
+ events: [ev]
34108
+ });
34109
+ }
34110
+ return [...map.values()].toSorted((a, b) => a.dayStartMs - b.dayStartMs);
34111
+ }
34112
+ /**
34113
+ * Build the digest body.
34114
+ *
34115
+ * @param nowMs — fire time; tomorrow is the local calendar day after this.
34116
+ */
34117
+ function formatCalendarEventsBody(events, nowMs, horizonDays) {
34118
+ if (events.length === 0) return `No events in the next ${String(horizonDays)} day(s).`;
34119
+ const todayKey = localDayKey(nowMs);
34120
+ const tomorrowKey = localDayKey(addLocalDays(startOfLocalDay(nowMs), 1));
34121
+ const buckets = groupByLocalDay(events);
34122
+ const today = buckets.filter((b) => b.dayKey === todayKey);
34123
+ const tomorrow = buckets.filter((b) => b.dayKey === tomorrowKey);
34124
+ const later = buckets.filter((b) => b.dayKey !== todayKey && b.dayKey !== tomorrowKey);
34125
+ const lead = [...today, ...tomorrow];
34126
+ const lines = [];
34127
+ let emitted = 0;
34128
+ const pushBucket = (bucket) => {
34129
+ if (emitted >= MAX_EVENT_LINES) return;
34130
+ lines.push(dayHeadingFor(bucket.dayStartMs, todayKey, tomorrowKey));
34131
+ for (const ev of bucket.events) {
34132
+ if (emitted >= MAX_EVENT_LINES) break;
34133
+ lines.push(formatEventLine(ev));
34134
+ emitted += 1;
34135
+ }
34136
+ };
34137
+ for (const bucket of lead) pushBucket(bucket);
34138
+ if (lead.length > 0 && later.length > 0) lines.push(CALENDAR_EVENTS_DAY_DELIMITER);
34139
+ for (const bucket of later) pushBucket(bucket);
34140
+ if (events.length > MAX_EVENT_LINES) lines.push(`…and ${String(events.length - MAX_EVENT_LINES)} more`);
34141
+ return lines.join("\n");
34142
+ }
34143
+ //#endregion
33878
34144
  //#region src/notification-center/calendar-events/calendar-events-delivery.ts
33879
34145
  function calendarEventsRecordId(ruleId, windowEndMs) {
33880
34146
  return `calendar-events:${ruleId}:${windowEndMs}`;
@@ -33884,16 +34150,8 @@ function clockOf$1(atMs) {
33884
34150
  const d = new Date(atMs);
33885
34151
  return `${`${String(d.getFullYear())}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`} ${`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`}`;
33886
34152
  }
33887
- function formatEventLine(event) {
33888
- const when = event.allDay ? clockOf$1(event.startMs).slice(0, 10) : `${clockOf$1(event.startMs)}–${clockOf$1(event.endMs)}`;
33889
- const where = event.location !== void 0 && event.location.length > 0 ? ` @ ${event.location}` : "";
33890
- return `• ${when} ${event.title}${where}`;
33891
- }
33892
34153
  function derivedBody$1(input) {
33893
- if (input.events.length === 0) return `No events in the next ${String(input.horizonDays)} day(s).`;
33894
- const lines = input.events.slice(0, 40).map(formatEventLine);
33895
- const extra = input.events.length > 40 ? `\n…and ${String(input.events.length - 40)} more` : "";
33896
- return lines.join("\n") + extra;
34154
+ return formatCalendarEventsBody(input.events, input.eventWindow.startMs, input.horizonDays);
33897
34155
  }
33898
34156
  function templateVars(input) {
33899
34157
  return {
@@ -39339,19 +39597,21 @@ var NotificationCenter = class {
39339
39597
  }
39340
39598
  /**
39341
39599
  * Start photographing every camera that links `sensorId`, and return the
39342
- * owners the sensor-level evaluation names — or `undefined` when there is no
39343
- * set (≤1 linked camera: today's marker is the picture), nothing to show
39344
- * (every linked camera muted), the event will be dropped as muted (the
39345
- * SENSOR is muted, or there were row cameras and every one is muted — see
39346
- * the evaluation's mute gate), or no enabled device-event rule that could
39347
- * fire on this sensor KIND wants a picture (the gate is the RULE's media
39348
- * policy, scope and kind conditions, read from the in-memory mirror — D49,
39349
- * as `audioStillOwner`; an occupancy rule, which rides the same delivery,
39350
- * never fires on a sensor). Coarse on purpose, in one direction only: a
39351
- * capture for a rule whose other conditions then fail is one wasted
39352
- * snapshot, a missing capture is a notification without its picture. The
39353
- * kind check is what keeps a chatty multi-linked motion sensor from
39354
- * composing a mosaic on every edge for a doorbell rule.
39600
+ * owners the sensor-level evaluation names — or `undefined` when there is
39601
+ * nothing to photograph (`cameraIds` empty), nothing to show (every linked
39602
+ * camera muted), the event will be dropped as muted (the SENSOR is muted,
39603
+ * or there were row cameras and every one is muted — see the evaluation's
39604
+ * mute gate), or no enabled device-event rule that could fire on this
39605
+ * sensor KIND wants a picture (the gate is the RULE's media policy, scope
39606
+ * and kind conditions, read from the in-memory mirror — D49, as
39607
+ * `audioStillOwner`; an occupancy rule, which rides the same delivery,
39608
+ * never fires on a sensor). One linked camera is enough: link alone owns
39609
+ * notification media; tracked rows are a separate timeline question. Coarse
39610
+ * on purpose, in one direction only: a capture for a rule whose other
39611
+ * conditions then fail is one wasted snapshot, a missing capture is a
39612
+ * notification without its picture. The kind check is what keeps a chatty
39613
+ * multi-linked motion sensor from composing a mosaic on every edge for a
39614
+ * doorbell rule.
39355
39615
  *
39356
39616
  * A MUTED camera (D62) is never dialled: its tile is left out, said on that
39357
39617
  * camera (once per window), and the primary is the first linked camera that
@@ -39364,7 +39624,7 @@ var NotificationCenter = class {
39364
39624
  beginLinkedStills(request) {
39365
39625
  const { sensorId, sensorKind, cameraIds, atMs, rowCameraIds, sensorRaised } = request;
39366
39626
  const shelf = this.linkedStills;
39367
- if (shelf === null || !this.evaluationActive || cameraIds.length < 2) return void 0;
39627
+ if (shelf === null || !this.evaluationActive || cameraIds.length === 0) return void 0;
39368
39628
  if (this.deviceMutes.isMuted(sensorId)) return void 0;
39369
39629
  const rowShown = this.unmutedCameras(rowCameraIds);
39370
39630
  if (rowCameraIds.length > 0 && rowShown.length === 0) return void 0;
@@ -41721,14 +41981,19 @@ var NotificationCenter = class {
41721
41981
  }
41722
41982
  sequenceActionsOf = (sequenceId) => this.sequences.get(sequenceId)?.actions;
41723
41983
  /**
41724
- * Run this rule's onTrigger / onTriggerOff bindings for this match.
41984
+ * Run this rule's onTrigger / onTriggerOff bindings for this match, and —
41985
+ * when the rule is gated on `armed_*` — auto-trigger the CamStack
41986
+ * alarm-panel (never sirens).
41725
41987
  *
41726
41988
  * Called BEFORE the cooldown gate — see the call site for why. Everything
41727
41989
  * here is fire-and-forget and nothing may throw into the evaluation.
41728
41990
  */
41729
41991
  runRuleActions(rule, subject, kind, controls) {
41730
- if (this.actionRunner === null || controls.runActions === false) return;
41731
- const bindings = this.isTriggerOffSubject(subject) ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41992
+ if (controls.runActions === false) return;
41993
+ const triggerOff = this.isTriggerOffSubject(subject);
41994
+ if (!triggerOff) this.autoTriggerAlarmPanel(rule, subject, kind);
41995
+ if (this.actionRunner === null) return;
41996
+ const bindings = triggerOff ? rule.actions?.onTriggerOff : rule.actions?.onTrigger;
41732
41997
  if (bindings === void 0 || bindings.length === 0) return;
41733
41998
  this.actionRunner.runBindings({
41734
41999
  ruleId: rule.id,
@@ -41740,6 +42005,41 @@ var NotificationCenter = class {
41740
42005
  });
41741
42006
  }
41742
42007
  /**
42008
+ * A rule gated on `armed_*` fires the CamStack panel on match — no
42009
+ * `onTrigger` sequence required. Sirens are never actuated here.
42010
+ */
42011
+ autoTriggerAlarmPanel(rule, subject, kind) {
42012
+ if (!require_clip_model_registry.ruleAutoTriggersAlarm(rule)) return;
42013
+ const panel = this.alarmPanel;
42014
+ if (panel === null) {
42015
+ this.logger.warn("alarm rule matched but no alarm panel is wired — panel not triggered", {
42016
+ tags: { deviceId: subject.deviceId },
42017
+ meta: {
42018
+ ruleId: rule.id,
42019
+ rule: rule.name
42020
+ }
42021
+ });
42022
+ return;
42023
+ }
42024
+ this.actuateWithAttribution({
42025
+ deviceId: panel.deviceId,
42026
+ cap: ALARM_PANEL_CAP,
42027
+ method: "trigger",
42028
+ args: {},
42029
+ origin: fireRefFor(rule, subject, kind)
42030
+ }).catch((err) => {
42031
+ this.logger.warn("alarm panel auto-trigger failed", {
42032
+ tags: { deviceId: subject.deviceId },
42033
+ meta: {
42034
+ ruleId: rule.id,
42035
+ rule: rule.name,
42036
+ panelId: panel.deviceId,
42037
+ error: err instanceof Error ? err.message : String(err)
42038
+ }
42039
+ });
42040
+ });
42041
+ }
42042
+ /**
41743
42043
  * The muted device that drops this subject, or `undefined` when none does.
41744
42044
  * A per-camera subject: its camera. A linked set: the sensor; else, when
41745
42045
  * the change took rows and every row camera is muted, the sensor too — the
@@ -104137,8 +104437,22 @@ var NO_PUBLICATION = {
104137
104437
  url: null,
104138
104438
  artifactId: null
104139
104439
  };
104140
- function isCapRecord(value) {
104141
- return typeof value === "object" && value !== null;
104440
+ /**
104441
+ * A tRPC-shaped router or procedure reached by DYNAMIC name off `ctx.api`.
104442
+ *
104443
+ * A rule names its cap and method as strings, so the generated client cannot be
104444
+ * indexed statically. The walk down `api → router → procedure → mutate|query`
104445
+ * is therefore guarded hop by hop rather than asserted: every hop that is not
104446
+ * what it must be raises the named error the rule author needs to see.
104447
+ *
104448
+ * Forked-addon `ctx.api` namespaces are tRPC v11 Proxies over a noop function
104449
+ * (`typeof === 'function'`). Treating only plain objects as routers made every
104450
+ * sequence step fail with `no router for capability "alarm-panel"` while the
104451
+ * same method answered fine over HTTP — the panel never left `armed_*`, so
104452
+ * HomeKit stayed quiet.
104453
+ */
104454
+ function isCapNode(value) {
104455
+ return typeof value === "object" && value !== null || typeof value === "function";
104142
104456
  }
104143
104457
  function isCapCall(value) {
104144
104458
  return typeof value === "function";
@@ -107257,10 +107571,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
107257
107571
  },
107258
107572
  actuate: async ({ deviceId, cap, method, args }) => {
107259
107573
  const router = Reflect.get(api, require_clip_model_registry.kebabToCamel(cap));
107260
- if (!isCapRecord(router)) throw new Error(`rule action: no router for capability "${cap}"`);
107261
- const proc = router[method];
107262
- const fn = isCapRecord(proc) ? proc["mutate"] ?? proc["query"] : void 0;
107263
- if (!isCapCall(fn)) throw new Error(`rule action: "${cap}.${method}" is not a callable cap method`);
107574
+ if (!isCapNode(router)) throw new Error(`rule action: no router for capability "${cap}"`);
107575
+ const proc = Reflect.get(router, method);
107576
+ const mutate = isCapNode(proc) ? Reflect.get(proc, "mutate") : void 0;
107577
+ const query = isCapNode(proc) ? Reflect.get(proc, "query") : void 0;
107578
+ const fn = isCapCall(mutate) ? mutate : isCapCall(query) ? query : void 0;
107579
+ if (fn === void 0) throw new Error(`rule action: "${cap}.${method}" is not a callable cap method`);
107264
107580
  await fn({
107265
107581
  deviceId,
107266
107582
  ...args
@@ -113513,7 +113829,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
113513
113829
  });
113514
113830
  return NO_SENSOR_NOTIFICATION_CAMERAS;
113515
113831
  });
113516
- const linkedSet = cameras.mediaCameraIds.length > 1;
113832
+ const linkedSet = cameras.mediaCameraIds.length > 1 || cameras.mediaCameraIds.length === 1 && cameras.rowCameraIds.length === 0;
113517
113833
  const linkedStills = linkedSet ? this.startLinkedStills(data, timestamp, cameras) : void 0;
113518
113834
  const persisted = [];
113519
113835
  const unowned = [];