@camstack/addon-post-analysis 1.2.338 → 1.2.340

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-BcSLJ8aL.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-BFG-v_BV.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");
@@ -1408,6 +1408,12 @@ async function readAlarmOpenings(ids, loadRuntime, logger) {
1408
1408
  function isPhase(value) {
1409
1409
  return value === "open" || value === "closed";
1410
1410
  }
1411
+ /**
1412
+ * A change time that cannot be a move: `0` is a placeholder and `1` is the
1413
+ * "known value, unknown change time" convention providers stamp on a replay or
1414
+ * a recovery (`HM_SEEDED_CHANGED_AT`, `HA_SEEDED_CHANGED_AT`).
1415
+ */
1416
+ var NO_CHANGE_TIME = 1;
1411
1417
  var NcAlarmExclusions = class {
1412
1418
  entries = [];
1413
1419
  /** Replace the whole set — one arm, one set of exclusions. */
@@ -1416,7 +1422,9 @@ var NcAlarmExclusions = class {
1416
1422
  deviceId,
1417
1423
  phase: "open",
1418
1424
  at,
1419
- announced: false
1425
+ announced: false,
1426
+ moved: false,
1427
+ lastMoveAt: 0
1420
1428
  }));
1421
1429
  }
1422
1430
  /** Every excluded sensor, in the order it was excluded. */
@@ -1440,24 +1448,53 @@ var NcAlarmExclusions = class {
1440
1448
  * An unreadable state changes nothing (D49): a failed read must never lift an
1441
1449
  * exclusion the operator asked for, and must never swallow the close that
1442
1450
  * would have.
1451
+ *
1452
+ * `changedAt` is the reading's OWN change time, when the caller has one (a
1453
+ * sensor event's slice; the reconcile tick has none). It decides only whether
1454
+ * an open in phase `open` really MOVED ({@link takeMoved}) — never a lift.
1443
1455
  */
1444
- observe(deviceId, reading, _at) {
1456
+ observe(deviceId, reading, _at, changedAt) {
1445
1457
  if (reading === void 0) return "unchanged";
1446
1458
  const current = this.entries.find((e) => e.deviceId === deviceId);
1447
1459
  if (current === void 0) return "unchanged";
1448
1460
  if (reading === "secure") {
1449
- if (current.phase === "closed") return "unchanged";
1450
- this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
1451
- ...e,
1452
- phase: "closed"
1453
- } : e);
1454
- return "closed";
1461
+ if (current.phase === "closed") return this.replace({
1462
+ ...current,
1463
+ moved: false
1464
+ }, "unchanged");
1465
+ return this.replace({
1466
+ ...current,
1467
+ phase: "closed",
1468
+ moved: false
1469
+ }, "closed");
1470
+ }
1471
+ if (current.phase === "open") {
1472
+ const fresh = changedAt !== void 0 && Number.isFinite(changedAt) && changedAt > Math.max(current.at, current.lastMoveAt, NO_CHANGE_TIME);
1473
+ const redelivered = current.moved && changedAt === current.lastMoveAt;
1474
+ return this.replace({
1475
+ ...current,
1476
+ moved: fresh || redelivered,
1477
+ lastMoveAt: fresh ? changedAt : current.lastMoveAt
1478
+ }, "unchanged");
1455
1479
  }
1456
- if (current.phase === "open") return "unchanged";
1457
1480
  this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
1458
1481
  return "rearmed";
1459
1482
  }
1460
1483
  /**
1484
+ * Did the last reading of this sensor really move it (D735)? Consumed: the
1485
+ * press it caused asks once, and a later press of the same reading is a
1486
+ * duplicate. False for a sensor that is not excluded.
1487
+ */
1488
+ takeMoved(deviceId) {
1489
+ const current = this.entries.find((e) => e.deviceId === deviceId);
1490
+ if (current === void 0 || !current.moved) return false;
1491
+ this.replace({
1492
+ ...current,
1493
+ moved: false
1494
+ }, "unchanged");
1495
+ return true;
1496
+ }
1497
+ /**
1461
1498
  * Mark this sensor's ignored trigger as announced — true only the FIRST time
1462
1499
  * in this arm (the caller then announces and persists), false for a repeat
1463
1500
  * or for a sensor that is not excluded.
@@ -1480,6 +1517,11 @@ var NcAlarmExclusions = class {
1480
1517
  this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
1481
1518
  return true;
1482
1519
  }
1520
+ /** Swap one entry for its next version — the set is replaced, never mutated. */
1521
+ replace(next, change) {
1522
+ this.entries = this.entries.map((e) => e.deviceId === next.deviceId ? next : e);
1523
+ return change;
1524
+ }
1483
1525
  /** The arm this belongs to is over. */
1484
1526
  clear() {
1485
1527
  this.entries = [];
@@ -1511,7 +1553,9 @@ var NcAlarmExclusions = class {
1511
1553
  deviceId: item,
1512
1554
  phase: "open",
1513
1555
  at: 0,
1514
- announced: false
1556
+ announced: false,
1557
+ moved: false,
1558
+ lastMoveAt: 0
1515
1559
  });
1516
1560
  continue;
1517
1561
  }
@@ -1525,7 +1569,9 @@ var NcAlarmExclusions = class {
1525
1569
  deviceId,
1526
1570
  phase,
1527
1571
  at,
1528
- announced: rec["announced"] === true
1572
+ announced: rec["announced"] === true,
1573
+ moved: false,
1574
+ lastMoveAt: 0
1529
1575
  });
1530
1576
  }
1531
1577
  this.entries = out;
@@ -1616,7 +1662,8 @@ function modeDelaysFromPersisted(raw) {
1616
1662
  }
1617
1663
  if (item.notifyDuringEntryDelay !== void 0) kept.notifyDuringEntryDelay = item.notifyDuringEntryDelay;
1618
1664
  if (item.notifySensorRewatched !== void 0) kept.notifySensorRewatched = item.notifySensorRewatched;
1619
- if (kept.notifyDuringEntryDelay !== void 0 || kept.notifySensorRewatched !== void 0 || kept.exitDelaySec !== void 0 || kept.entryDelaySec !== void 0 || kept.triggeredDurationSec !== void 0 || kept.postTriggeredAction !== void 0 || kept.schedule !== void 0) entries.push(kept);
1665
+ if (item.notifySensorIgnored !== void 0) kept.notifySensorIgnored = item.notifySensorIgnored;
1666
+ if (kept.notifyDuringEntryDelay !== void 0 || kept.notifySensorRewatched !== void 0 || kept.notifySensorIgnored !== void 0 || kept.exitDelaySec !== void 0 || kept.entryDelaySec !== void 0 || kept.triggeredDurationSec !== void 0 || kept.postTriggeredAction !== void 0 || kept.schedule !== void 0) entries.push(kept);
1620
1667
  }
1621
1668
  return {
1622
1669
  entries,
@@ -1827,6 +1874,10 @@ function alarmPushDecision(phase, heard) {
1827
1874
  switch (phase.kind) {
1828
1875
  case "none":
1829
1876
  case "triggers-now": return { verdict: { kind: "plain" } };
1877
+ case "excluded": return { verdict: {
1878
+ kind: "suppress",
1879
+ reason: "alarm-sensor-excluded"
1880
+ } };
1830
1881
  case "triggered": return { verdict: { kind: "intrusion" } };
1831
1882
  case "starts-entry-delay": return heard.pending ? { verdict: {
1832
1883
  kind: "suppress",
@@ -1869,17 +1920,6 @@ function intrusionDeviceOf(ref) {
1869
1920
  //#endregion
1870
1921
  //#region src/notification-center/alarm/alarm-transitions.ts
1871
1922
  /**
1872
- * How long a note handed to {@link NcAlarmAttribution.note} stays valid.
1873
- *
1874
- * The note is placed IMMEDIATELY before the `alarm-panel.trigger` call it
1875
- * describes, in the same process, so the real gap is milliseconds. The bound
1876
- * exists only so a sequence that failed before reaching its trigger step — or a
1877
- * `wait` that outlived the incident — cannot leave an attribution lying around
1878
- * for an unrelated manual trigger to pick up half an hour later.
1879
- * Mis-attributing an alarm would put somebody else's photograph on it.
1880
- */
1881
- var TRIGGER_ATTRIBUTION_TTL_MS = 3e4;
1882
- /**
1883
1923
  * How many followers one incident keeps (D705). The mosaic tiles at most
1884
1924
  * `NC_GROUPED_MOSAIC_MAX_TILES` (6) and the sentence counts every member, so this
1885
1925
  * bounds RAM and the frozen row, not the picture: a camera that keeps matching for
@@ -1895,88 +1935,41 @@ function subjectKeyOf(ref) {
1895
1935
  * WHO the panel's current countdown belongs to.
1896
1936
  *
1897
1937
  * Small and separate because its whole content is a LIFETIME, and a lifetime
1898
- * is what goes wrong: the note and the trigger are milliseconds apart, but the
1899
- * trigger and the siren are up to `entryDelaySec` apart, and in between the
1900
- * operator may disarm, re-arm, or press trigger by hand. Each of those must
1901
- * drop the attribution, or tonight's alarm arrives carrying last night's
1902
- * photograph.
1938
+ * is what goes wrong: the trigger and the siren are up to `entryDelaySec`
1939
+ * apart, and in between the operator may disarm, re-arm, or press trigger by
1940
+ * hand. Each of those must drop the attribution, or tonight's alarm arrives
1941
+ * carrying last night's photograph.
1942
+ *
1943
+ * It holds NO pending press. A rule press carries its source IN the call
1944
+ * (`NcAlarmPanelDevice.triggerFromRule`), synchronously, so there is nothing
1945
+ * to hand over and nothing to overwrite. The single "next trigger is this
1946
+ * rule's" slot this class used to hold was written before an awaited cap call
1947
+ * and read when the call landed; two matches 1 ms apart overwrote it, one press
1948
+ * landed with no source, skipped the exclusion check and sounded the siren off
1949
+ * an excluded contact (2026-10-07 02:32Z, D735).
1903
1950
  */
1904
1951
  var NcAlarmAttribution = class {
1905
- noted = null;
1906
1952
  claimed = null;
1907
1953
  /** The incident's other subjects (D705) — replaced, never mutated. */
1908
1954
  followed = [];
1909
- /** "The next trigger is this rule's." Consumed by the trigger that follows:
1910
- * {@link claim} when it is accepted, {@link follow} when an entry delay
1911
- * already running refused it (D705), {@link dropPending} when an excluded
1912
- * sensor made it. */
1913
- note(source, now) {
1914
- this.noted = {
1915
- source,
1916
- at: now
1917
- };
1918
- }
1919
- /**
1920
- * The note that has not been claimed yet — WITHOUT consuming it.
1921
- *
1922
- * The panel has to know which SENSOR is about to set the alarm off before it
1923
- * decides whether to accept the trigger at all (an excluded contact does
1924
- * not), and asking must not spend the note: a trigger that is accepted still
1925
- * needs it to build the combined notification. Expired reads as absent, on
1926
- * the same bound {@link claim} uses.
1927
- */
1928
- pending(now) {
1929
- const noted = this.noted;
1930
- if (noted === null) return null;
1931
- if (now - noted.at > TRIGGER_ATTRIBUTION_TTL_MS) return null;
1932
- return noted.source;
1933
- }
1934
- /** Throw the un-claimed note away — the trigger it described was REFUSED, and
1935
- * a note left behind would be inherited by the next real one. */
1936
- dropPending() {
1937
- this.noted = null;
1938
- }
1939
1955
  /**
1940
- * Called by an ACCEPTED trigger, and by every accepted trigger — including
1941
- * one with no note, so a manual trigger clears whatever a rule left behind
1942
- * rather than inheriting it.
1956
+ * Called by EVERY accepted trigger with the source that pressed it — `null`
1957
+ * for a manual or external press, so it clears whatever a rule left behind
1958
+ * rather than inheriting it. A new countdown is a new incident: nothing the
1959
+ * last one collected rides it.
1943
1960
  */
1944
- claim(now) {
1945
- const noted = this.noted;
1946
- this.noted = null;
1961
+ claim(source) {
1962
+ this.claimed = source;
1947
1963
  this.followed = [];
1948
- if (noted === null) {
1949
- this.claimed = null;
1950
- return {
1951
- source: null,
1952
- expired: null
1953
- };
1954
- }
1955
- if (now - noted.at > TRIGGER_ATTRIBUTION_TTL_MS) {
1956
- this.claimed = null;
1957
- return {
1958
- source: null,
1959
- expired: noted.source
1960
- };
1961
- }
1962
- this.claimed = noted.source;
1963
- return {
1964
- source: noted.source,
1965
- expired: null
1966
- };
1967
1964
  }
1968
1965
  /**
1969
- * A trigger the panel REFUSED because its entry delay is already running (D705):
1970
- * the note it left is another subject of the SAME incident. Consumed either way —
1971
- * a note left behind would be claimed by the next, unrelated incident.
1966
+ * A rule press the panel REFUSED because its entry delay is already running
1967
+ * (D705): its source is another subject of the SAME incident.
1972
1968
  *
1973
1969
  * Only an incident a rule started collects followers: the combined row is the
1974
1970
  * source rule's, and promoting a follower to source would change the sentence.
1975
1971
  */
1976
- follow(now) {
1977
- const source = this.pending(now);
1978
- this.noted = null;
1979
- if (source === null) return { kind: "none" };
1972
+ follow(source) {
1980
1973
  const claimed = this.claimed;
1981
1974
  if (claimed === null) return {
1982
1975
  kind: "no-incident",
@@ -2230,7 +2223,8 @@ var ncAlarmPanelSchema = require_clip_model_registry.object({
2230
2223
  schedule: require_clip_model_registry.unknown().optional(),
2231
2224
  /** D706 — the mode's notification switches; absent = off. */
2232
2225
  notifyDuringEntryDelay: require_clip_model_registry.boolean().optional(),
2233
- notifySensorRewatched: require_clip_model_registry.boolean().optional()
2226
+ notifySensorRewatched: require_clip_model_registry.boolean().optional(),
2227
+ notifySensorIgnored: require_clip_model_registry.boolean().optional()
2234
2228
  })).default([]),
2235
2229
  /**
2236
2230
  * Active alarm configuration profile id. Empty / absent on a blob older than
@@ -2306,6 +2300,7 @@ function settingsBlobFromPersisted(raw) {
2306
2300
  if (rec["schedule"] !== void 0) entry.schedule = rec["schedule"];
2307
2301
  if (typeof rec["notifyDuringEntryDelay"] === "boolean") entry.notifyDuringEntryDelay = rec["notifyDuringEntryDelay"];
2308
2302
  if (typeof rec["notifySensorRewatched"] === "boolean") entry.notifySensorRewatched = rec["notifySensorRewatched"];
2303
+ if (typeof rec["notifySensorIgnored"] === "boolean") entry.notifySensorIgnored = rec["notifySensorIgnored"];
2309
2304
  modeDelaysLoose.push(entry);
2310
2305
  }
2311
2306
  const truncatedTriggered = Math.max(0, Math.min(3600, Math.trunc(triggered)));
@@ -2882,21 +2877,26 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2882
2877
  this.onPublish = hook;
2883
2878
  }
2884
2879
  /**
2885
- * "The next trigger is this rule's, about this subject."
2880
+ * A RULE pressed the panel, and says which match it was — in the call itself.
2886
2881
  *
2887
2882
  * The join between a rule's notification and the alarm's. `alarm-panel.trigger`
2888
2883
  * takes nothing but a device id — by design, since a Home Assistant panel
2889
- * provides the same cap — so the attribution cannot ride the cap call. It is
2890
- * handed over in-process instead, immediately before the actuation, and the
2891
- * panel stamps it onto the `alarm-triggered` event it eventually emits.
2892
- *
2893
- * The FIRST accepted trigger claims the note and owns the countdown; a rule
2884
+ * provides the same cap — so a rule press on THIS panel never goes through the
2885
+ * cap: the notification centre calls this method, in-process and
2886
+ * synchronously, with the match that pressed it. The source is never parked
2887
+ * anywhere for a later call to pick up: a single "next trigger is this rule's"
2888
+ * slot, written before an awaited cap call and read when it landed, was
2889
+ * overwritten by a second match 1 ms later, and the press that found it empty
2890
+ * skipped the exclusion check and sounded the siren off an excluded contact
2891
+ * (2026-10-07 02:32Z, D735).
2892
+ *
2893
+ * The FIRST accepted press claims the source and owns the countdown; a rule
2894
2894
  * pressing again while the entry delay runs is refused as a trigger and its
2895
- * note becomes a FOLLOWER of the same incident (D705) — the triggered
2895
+ * source becomes a FOLLOWER of the same incident (D705) — the triggered
2896
2896
  * notification tiles the source and every follower as one mosaic.
2897
2897
  */
2898
- noteRuleTrigger(source, now = Date.now()) {
2899
- this.attribution.note(source, now);
2898
+ triggerFromRule(source, now = Date.now()) {
2899
+ return this.press(source, now);
2900
2900
  }
2901
2901
  /**
2902
2902
  * Where the alarm stands for ONE rule match, read BEFORE that match presses the panel
@@ -2905,14 +2905,19 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2905
2905
  * sensor is ignored, a running countdown or a triggered alarm refuses the press, an armed
2906
2906
  * panel starts the countdown (or fires at once when the mode's entry delay is 0).
2907
2907
  *
2908
- * Two refinements make the answer exact rather than a guess:
2909
- * - the subject that STARTED the countdown is still its starter on its later matches (a
2910
- * person who stays in view is not "another intrusion during the delay");
2911
- * - a note left by a press that has not landed yet (same evaluation, two rules) already
2912
- * owns the countdown, so a second subject in that window is a follower.
2908
+ * The subject that STARTED the countdown is still its starter on its later matches (a
2909
+ * person who stays in view is not "another intrusion during the delay"). A rule press
2910
+ * lands synchronously ({@link triggerFromRule}), so the machine this reads already holds
2911
+ * every earlier press of the same evaluation — there is no press "in flight" to guess at.
2912
+ *
2913
+ * A match of a sensor the current arm EXCLUDES is `excluded`: its press will be ignored,
2914
+ * and the rule's own push is not sent either (D735) — the panel's bypass logic speaks for it.
2913
2915
  */
2914
2916
  classifyRuleMatch(ref, now = Date.now()) {
2915
- if (this.exclusions.blocks(intrusionDeviceOf(ref))) return { kind: "none" };
2917
+ if (this.exclusions.blocks(intrusionDeviceOf(ref))) return {
2918
+ kind: "excluded",
2919
+ sensorDeviceId: intrusionDeviceOf(ref)
2920
+ };
2916
2921
  const mode = armModeOf(this.machine.target, NC_ALARM_MODES);
2917
2922
  if (mode === null) return { kind: "none" };
2918
2923
  const state = stateAt(this.machine, now);
@@ -2934,17 +2939,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
2934
2939
  };
2935
2940
  }
2936
2941
  if (!isArmed(this.machine, now)) return { kind: "none" };
2937
- const immediate = this.delaysFor(mode).entryDelaySec === 0;
2938
- const inFlight = this.attribution.pending(now);
2939
- if (inFlight !== null && subjectKeyOf(inFlight) !== key) return immediate ? {
2940
- kind: "triggered",
2941
- mode
2942
- } : {
2943
- kind: "entry-delay",
2944
- mode,
2945
- notify: this.notifyPolicy(mode).notifyDuringEntryDelay
2946
- };
2947
- return immediate ? {
2942
+ return this.delaysFor(mode).entryDelaySec === 0 ? {
2948
2943
  kind: "triggers-now",
2949
2944
  mode
2950
2945
  } : {
@@ -3125,7 +3120,7 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3125
3120
  * sensor is already lifted when the rule it fires reaches the panel.
3126
3121
  */
3127
3122
  observeOpening(deviceId, reading, now = Date.now(), options = {}) {
3128
- const change = this.exclusions.observe(deviceId, reading, now);
3123
+ const change = this.exclusions.observe(deviceId, reading, now, options.changedAt);
3129
3124
  if (change === "unchanged") return "unchanged";
3130
3125
  this.persist();
3131
3126
  if (change === "closed") return "closed";
@@ -3175,13 +3170,12 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3175
3170
  event: "lifted"
3176
3171
  }, now);
3177
3172
  }
3178
- /** Did THIS sensor start the running entry delay (landed, or a press about to land)? */
3173
+ /** Did THIS sensor start the running entry delay? A rule press lands synchronously, so
3174
+ * a press of the evaluation that lifted it has already landed when this is asked. */
3179
3175
  startsEntryDelay(deviceId, now) {
3180
- const state = stateAt(this.machine, now);
3181
- const sensorOf = (ref) => ref !== null ? intrusionDeviceOf(ref) : void 0;
3182
- if (state === "pending") return sensorOf(this.attribution.current()) === deviceId;
3183
- if (!isArmed(this.machine, now) || state === "triggered") return false;
3184
- return sensorOf(this.attribution.pending(now)) === deviceId;
3176
+ if (stateAt(this.machine, now) !== "pending") return false;
3177
+ const starter = this.attribution.current();
3178
+ return starter !== null && intrusionDeviceOf(starter) === deviceId;
3185
3179
  }
3186
3180
  /**
3187
3181
  * A DELETED device leaves the current arm's exclusions: it can never report
@@ -3407,64 +3401,78 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3407
3401
  },
3408
3402
  disarm: async () => this.disarm(),
3409
3403
  trigger: async () => {
3410
- const now = Date.now();
3411
- const pending = this.attribution.pending(now);
3412
- const source = pending !== null ? intrusionDeviceOf(pending) : void 0;
3413
- if (source !== void 0 && this.exclusions.blocks(source)) {
3414
- this.attribution.dropPending();
3415
- this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
3416
- tags: { deviceId: source },
3417
- meta: {
3418
- panelDeviceId: this.id,
3419
- ruleId: pending?.ruleId,
3420
- rule: pending?.ruleName,
3421
- state: stateAt(this.machine, now)
3422
- }
3423
- });
3424
- this.announceIgnoredTrigger(source, now);
3425
- return;
3404
+ this.press(null, Date.now());
3405
+ }
3406
+ });
3407
+ }
3408
+ /**
3409
+ * THE ENTRY DELAY LIVES OR DIES HERE. The one body every press runs — a rule's
3410
+ * ({@link triggerFromRule}, with its source) and a person's (the cap, `null`).
3411
+ *
3412
+ * It used to set `fired: true` outright, calling itself "a panic button", while
3413
+ * the entry-delay transition sat in a method NO caller ever reached — so the
3414
+ * panel shipped with `pending` unreachable. The doc comment describing the right
3415
+ * design is precisely what made it look implemented.
3416
+ *
3417
+ * Synchronous, end to end: the source is a parameter, never a slot, so two
3418
+ * presses cannot exchange their sources however close together they land.
3419
+ */
3420
+ press(source, now) {
3421
+ const sensor = source !== null ? intrusionDeviceOf(source) : void 0;
3422
+ if (sensor !== void 0 && this.exclusions.blocks(sensor)) {
3423
+ const moved = this.exclusions.takeMoved(sensor);
3424
+ this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
3425
+ tags: { deviceId: sensor },
3426
+ meta: {
3427
+ panelDeviceId: this.id,
3428
+ ruleId: source?.ruleId,
3429
+ rule: source?.ruleName,
3430
+ state: stateAt(this.machine, now),
3431
+ moved
3426
3432
  }
3427
- const armed = armModeOf(this.machine.target, NC_ALARM_MODES);
3428
- const resolved = this.delaysFor(armed);
3429
- const next = trigger(this.machine, resolved, now);
3430
- if (next === this.machine) {
3431
- const state = stateAt(this.machine, now);
3432
- this.ctx.logger.info(ignoredTriggerMessage(state), {
3433
- tags: { deviceId: this.id },
3434
- meta: { state }
3435
- });
3436
- if (state === "pending") {
3437
- this.reportFollow(this.attribution.follow(now));
3438
- return;
3439
- }
3440
- this.attribution.dropPending();
3441
- return;
3433
+ });
3434
+ this.announceIgnoredTrigger(sensor, now, moved);
3435
+ return "ignored-excluded";
3436
+ }
3437
+ const armed = armModeOf(this.machine.target, NC_ALARM_MODES);
3438
+ const resolved = this.delaysFor(armed);
3439
+ const next = trigger(this.machine, resolved, now);
3440
+ if (next === this.machine) {
3441
+ const state = stateAt(this.machine, now);
3442
+ this.ctx.logger.info(ignoredTriggerMessage(state), {
3443
+ tags: { deviceId: this.id },
3444
+ meta: {
3445
+ state,
3446
+ ...source !== null ? {
3447
+ ruleId: source.ruleId,
3448
+ rule: source.ruleName
3449
+ } : {}
3442
3450
  }
3443
- const claim = this.attribution.claim(now);
3444
- if (claim.expired !== null) this.ctx.logger.info("alarm trigger attribution expired — plain alarm notification", {
3445
- tags: { deviceId: this.id },
3446
- meta: {
3447
- ruleId: claim.expired.ruleId,
3448
- rule: claim.expired.ruleName
3449
- }
3450
- });
3451
- this.machine = next;
3452
- this.ctx.logger.info("alarm delays applied", {
3453
- tags: { deviceId: this.id },
3454
- meta: {
3455
- mode: armed,
3456
- phase: "trigger",
3457
- entryDelaySec: resolved.entryDelaySec,
3458
- triggeredDurationSec: resolved.triggeredDurationSec,
3459
- source: {
3460
- entry: resolved.source.entryDelaySec,
3461
- siren: resolved.source.triggeredDurationSec
3462
- }
3463
- }
3464
- });
3465
- this.publish(now);
3451
+ });
3452
+ if (state === "pending" && source !== null) this.reportFollow(this.attribution.follow(source));
3453
+ return "refused";
3454
+ }
3455
+ this.attribution.claim(source);
3456
+ this.machine = next;
3457
+ this.ctx.logger.info("alarm delays applied", {
3458
+ tags: { deviceId: this.id },
3459
+ meta: {
3460
+ mode: armed,
3461
+ phase: "trigger",
3462
+ entryDelaySec: resolved.entryDelaySec,
3463
+ triggeredDurationSec: resolved.triggeredDurationSec,
3464
+ source: {
3465
+ entry: resolved.source.entryDelaySec,
3466
+ siren: resolved.source.triggeredDurationSec
3467
+ },
3468
+ ...source !== null ? {
3469
+ ruleId: source.ruleId,
3470
+ rule: source.ruleName
3471
+ } : { by: "manual" }
3466
3472
  }
3467
3473
  });
3474
+ this.publish(now);
3475
+ return "accepted";
3468
3476
  }
3469
3477
  status(now = Date.now()) {
3470
3478
  return {
@@ -3545,7 +3553,6 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3545
3553
  * stated: the drop is work the operator would otherwise never see go missing.
3546
3554
  */
3547
3555
  reportFollow(outcome) {
3548
- if (outcome.kind === "none") return;
3549
3556
  const source = outcome.source;
3550
3557
  const meta = {
3551
3558
  panelDeviceId: this.id,
@@ -3574,13 +3581,15 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3574
3581
  });
3575
3582
  }
3576
3583
  /**
3577
- * A dropped trigger is TOLD, not only logged — an intrusion the alarm
3578
- * deliberately did not act on is what the operator most needs to hear — but
3579
- * ONCE per sensor per arm (the mark rides the persisted exclusion), and only
3580
- * while the panel is armed: during the exit delay `trigger()` would have
3581
- * dropped it anyway, so the exclusion is not what ignored it.
3584
+ * "Ignored" is TOLD only when the excluded sensor really MOVED while armed and the
3585
+ * mode's `notifySensorIgnored` switch is on (D735, default off) — and then ONCE per
3586
+ * sensor per arm (the mark rides the persisted exclusion), and only while the panel is
3587
+ * armed: during the exit delay `trigger()` would have dropped it anyway, so the
3588
+ * exclusion is not what ignored it. A contact that was open all along and was merely
3589
+ * re-published (a provider reconnect, a Zigbee2MQTT restart) moved nothing, and four
3590
+ * such pushes reached the operator in 60 ms on 2026-10-07. Every refusal is a line.
3582
3591
  */
3583
- announceIgnoredTrigger(deviceId, now) {
3592
+ announceIgnoredTrigger(deviceId, now, moved) {
3584
3593
  const state = stateAt(this.machine, now);
3585
3594
  if (!isArmed(this.machine, now)) {
3586
3595
  this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
@@ -3602,6 +3611,29 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3602
3611
  });
3603
3612
  return;
3604
3613
  }
3614
+ if (!moved) {
3615
+ this.ctx.logger.debug("alarm bypass not announced — the sensor did not move since the arm", {
3616
+ tags: { deviceId },
3617
+ meta: {
3618
+ panelDeviceId: this.id,
3619
+ state,
3620
+ reason: "not-moved"
3621
+ }
3622
+ });
3623
+ return;
3624
+ }
3625
+ const mode = armModeOf(this.machine.target, NC_ALARM_MODES);
3626
+ if (mode === null || !this.notifyPolicy(mode).notifySensorIgnored) {
3627
+ this.ctx.logger.info("alarm \"ignored\" not notified — the mode does not notify it", {
3628
+ tags: { deviceId },
3629
+ meta: {
3630
+ panelDeviceId: this.id,
3631
+ mode,
3632
+ reason: "ignored-notify-off"
3633
+ }
3634
+ });
3635
+ return;
3636
+ }
3605
3637
  if (!this.exclusions.markAnnounced(deviceId)) {
3606
3638
  this.ctx.logger.debug("alarm bypass already announced in this arm — repeat not sent", {
3607
3639
  tags: { deviceId },
@@ -3759,7 +3791,7 @@ function ncAlarmPanelPort(panel) {
3759
3791
  deleteProfile: (id) => panel.deleteProfile(id),
3760
3792
  activateProfile: (id) => panel.activateProfile(id),
3761
3793
  availableModes: () => NC_ALARM_MODES,
3762
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
3794
+ triggerFromRule: (source) => panel.triggerFromRule(source),
3763
3795
  classifyRuleMatch: (source) => panel.classifyRuleMatch(source),
3764
3796
  announceLifted: (deviceId, now) => panel.announceLifted(deviceId, now),
3765
3797
  excludedDevices: () => panel.excludedDevices(),
@@ -4666,22 +4698,26 @@ var HUB_API_PORT = 4443;
4666
4698
  * preferred LAN interface priority `0`, so a public candidate has to sit below
4667
4699
  * zero to outrank it.
4668
4700
  */
4669
- var PRIORITY_MARKED = -400;
4701
+ var PRIORITY_PUBLIC_ENDPOINT = -400;
4670
4702
  var PRIORITY_CONNECTED = -200;
4671
4703
  var PRIORITY_EXTERNAL_LIST = -100;
4672
4704
  /**
4673
- * Build the ranked candidate list, in the operator's own order: the address
4674
- * marked on the Network page, then the connected external ingress (tunnels),
4675
- * then the LAN addresses. Every source is best-effort — one that throws
4676
- * contributes nothing rather than costing the whole list.
4705
+ * Build the ranked candidate list for every link a notification carries
4706
+ * (buttons, attachments / media, admin links): the hub's PUBLIC ENDPOINT
4707
+ * (D734), then — only reached when that is unknown — the connected external
4708
+ * ingress (tunnels), then the LAN addresses. Every source is best-effort — one
4709
+ * that throws contributes nothing rather than costing the whole list.
4710
+ *
4711
+ * The notification-taps address is deliberately NOT an input: it decides only
4712
+ * where a tap opens, and its default (the app link) is no hub address.
4677
4713
  */
4678
4714
  async function collectArtifactEndpoints(sources) {
4679
4715
  const out = [];
4680
- const marked = sources.markedBaseUrl;
4681
- if (marked !== void 0 && marked !== "" && !require_clip_model_registry.isAppLinkBase(marked)) out.push({
4682
- baseUrl: marked,
4716
+ const publicEndpoint = sources.publicEndpoint;
4717
+ if (publicEndpoint !== void 0 && publicEndpoint !== "" && !require_clip_model_registry.isAppLinkBase(publicEndpoint)) out.push({
4718
+ baseUrl: publicEndpoint,
4683
4719
  kind: "public",
4684
- priority: PRIORITY_MARKED
4720
+ priority: PRIORITY_PUBLIC_ENDPOINT
4685
4721
  });
4686
4722
  try {
4687
4723
  const connected = await sources.getConnected();
@@ -4916,6 +4952,49 @@ var NcArtifactPlane = class {
4916
4952
  }
4917
4953
  };
4918
4954
  //#endregion
4955
+ //#region src/notification-center/link-endpoints.ts
4956
+ async function publicEndpointAddress(api, logger) {
4957
+ try {
4958
+ const { roles } = await api.localNetwork.getAddresses.query({});
4959
+ return roles.publicEndpoint.resolved ?? void 0;
4960
+ } catch (err) {
4961
+ logger.warn("nc links: public endpoint unreadable — using the ingress ladder", { meta: { error: err instanceof Error ? err.message : String(err) } });
4962
+ return;
4963
+ }
4964
+ }
4965
+ /** The ranked base-URL candidates for a notification's non-tap links. */
4966
+ async function notificationLinkEndpoints(api, logger) {
4967
+ return collectArtifactEndpoints({
4968
+ publicEndpoint: await publicEndpointAddress(api, logger),
4969
+ getConnected: async () => {
4970
+ const status = await api.networkAccess.getStatus.query();
4971
+ logger.debug("artifact base-url: connected ingress", { meta: {
4972
+ connected: status.connected,
4973
+ url: status.endpoint?.url ?? null,
4974
+ protocol: status.endpoint?.protocol ?? null
4975
+ } });
4976
+ return status.connected && status.endpoint !== null ? {
4977
+ url: status.endpoint.url,
4978
+ protocol: status.endpoint.protocol
4979
+ } : null;
4980
+ },
4981
+ listExternal: async () => {
4982
+ return (await api.networkAccess.listEndpoints.query()).map((e) => ({
4983
+ url: e.url,
4984
+ protocol: e.protocol
4985
+ }));
4986
+ },
4987
+ listLan: async (port) => {
4988
+ return (await api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.flatMap((e) => e.kind === "app-link" || e.kind === "custom" ? [] : [{
4989
+ baseUrl: e.baseUrl,
4990
+ kind: e.kind,
4991
+ priority: e.priority
4992
+ }]);
4993
+ },
4994
+ logger
4995
+ });
4996
+ }
4997
+ //#endregion
4919
4998
  //#region src/notification-center/artifact-read-plane.ts
4920
4999
  /**
4921
5000
  * Parse a single-range `Range` header against a known length.
@@ -5372,158 +5451,6 @@ function mimeFromExtension(file) {
5372
5451
  return "application/octet-stream";
5373
5452
  }
5374
5453
  //#endregion
5375
- //#region src/notification-center/system-event-tap-url.ts
5376
- /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
5377
- var ADMIN_ADDONS_PATH = "/system/addons";
5378
- var ADMIN_CLUSTER_PATH = "/system/cluster";
5379
- var ADMIN_DEVICES_PATH = "/devices";
5380
- /**
5381
- * The query parameter naming a node.
5382
- *
5383
- * One spelling for both admin pages, mirrored by `NODE_PARAM` in
5384
- * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
5385
- * the two packages by `scripts/check-notification-tap-targets.ts`.
5386
- */
5387
- var NODE_PARAM = "node";
5388
- /** The device page's top-level tab parameter, read by `DeviceDetail`. */
5389
- var TAB_PARAM = "tab";
5390
- /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
5391
- var STREAM_BROKER_TAB = "stream-broker";
5392
- /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
5393
- * string map with no device-model dependency; the guard pins it. */
5394
- var CAMERA_DEVICE_TYPE = "camera";
5395
- /** Kinds whose action is "go and apply an ADDON update". */
5396
- var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
5397
- /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
5398
- var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
5399
- /**
5400
- * Kinds whose action is "go and look at this device's stream".
5401
- *
5402
- * BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
5403
- * because "did it really come back, and what did it do while it was gone" is
5404
- * an action and the page that answers it is the one the down-notification
5405
- * already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
5406
- * Videocamera sala feste è online" with `tapAction.value: null` — the same
5407
- * camera's `device-offline` an hour earlier opened the Stream Broker tab, and
5408
- * the recovery opened nothing, purely because `device-online` had no entry
5409
- * here.
5410
- *
5411
- * The legacy `camera-*` pair is here for the same reason both are still in the
5412
- * enum: outbox and history rows enqueued before the rename carry them, and an
5413
- * old row is exactly the one whose action has to keep working.
5414
- *
5415
- * Kinds NOT here are kinds whose DOWN direction has no action either
5416
- * (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
5417
- * recovery does not earn a destination its failure was never given.
5418
- */
5419
- var DEVICE_STREAM_KINDS = new Set([
5420
- "device-offline",
5421
- "device-online",
5422
- "stream-offline",
5423
- "stream-online",
5424
- "camera-offline",
5425
- "camera-online"
5426
- ]);
5427
- /**
5428
- * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
5429
- *
5430
- * A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
5431
- * too — that name is precisely the claim it made, and it is why the kind was
5432
- * split into `device-*` + a `deviceTypes` condition.
5433
- */
5434
- var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
5435
- "stream-offline",
5436
- "stream-online",
5437
- "camera-offline",
5438
- "camera-online"
5439
- ]);
5440
- /**
5441
- * Where a per-section STATE SUMMARY row lands (D708). The row spans nodes, so
5442
- * no `?node=`: the unscoped page is the honest one. A section absent here taps
5443
- * through its kind, like any other row — a device section carries the system
5444
- * placeholder device and gets no link, and the crash-looped addons have none
5445
- * (their instant kind has none either).
5446
- *
5447
- * `nodes` is the one destination no kind gives: a node going offline has no
5448
- * instant action, but "which nodes are in trouble" is answered by the
5449
- * Cluster page, and the summary row is about all of them at once.
5450
- */
5451
- var SUMMARY_SECTION_PATHS = {
5452
- nodes: ADMIN_CLUSTER_PATH,
5453
- "addon-updates": ADMIN_ADDONS_PATH,
5454
- updates: ADMIN_CLUSTER_PATH
5455
- };
5456
- function withParam(path, key, value) {
5457
- if (value === void 0 || value.length === 0) return path;
5458
- return `${path}?${key}=${encodeURIComponent(value)}`;
5459
- }
5460
- /**
5461
- * The admin path (with its query) a system event taps through to, or `null`
5462
- * when the kind has no action.
5463
- *
5464
- * Exported for the cross-package guard and for tests that want the path
5465
- * without a base; the dispatcher goes through {@link systemEventTapUrl}.
5466
- */
5467
- function systemEventTapPath(subject) {
5468
- const sectionPath = subject.summarySection !== void 0 ? SUMMARY_SECTION_PATHS[subject.summarySection] : void 0;
5469
- if (sectionPath !== void 0) return sectionPath;
5470
- if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
5471
- if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
5472
- if (DEVICE_STREAM_KINDS.has(subject.kind)) {
5473
- const deviceId = subject.deviceId;
5474
- if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
5475
- const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
5476
- return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
5477
- }
5478
- return null;
5479
- }
5480
- /** Full URL, or `null` when the kind has no action or no base is reachable. */
5481
- function systemEventTapUrl(subject, baseUrl) {
5482
- if (baseUrl === null || baseUrl.length === 0) return null;
5483
- const path = systemEventTapPath(subject);
5484
- if (path === null) return null;
5485
- return `${baseUrl.replace(/\/+$/, "")}${path}`;
5486
- }
5487
- //#endregion
5488
- //#region src/notification-center/default-tap-url.ts
5489
- /**
5490
- * The app-link choice from the flagged notification address — present when
5491
- * the effective address is the app link. Reads the answer through
5492
- * `viewerLinkBaseOf`, the same reading the Network page's preview uses.
5493
- */
5494
- function ncAppLinkChoiceOf(endpoint) {
5495
- const base = require_clip_model_registry.viewerLinkBaseOf(endpoint);
5496
- return base.form === "app" ? { hubBaseUrl: base.hubBaseUrl } : null;
5497
- }
5498
- /**
5499
- * The tap URL, or null. In the app form a camera link is minted even with no
5500
- * `hub=` — the central page reaches the hub through the app's (or the
5501
- * browser's) own stored hub (central-link design §5). The hub form and every
5502
- * admin destination still need a base.
5503
- */
5504
- function defaultTapUrl(input) {
5505
- const baseUrl = input.baseUrl === null || input.baseUrl.length === 0 ? null : input.baseUrl;
5506
- if (input.systemEvent !== void 0) return baseUrl === null ? null : systemEventTapUrl(input.systemEvent, baseUrl);
5507
- const destination = {
5508
- kind: "camera",
5509
- deviceId: input.deviceId,
5510
- atMs: input.timestampMs
5511
- };
5512
- return input.appLink !== void 0 ? require_clip_model_registry.buildViewerLink({
5513
- base: {
5514
- form: "app",
5515
- hubBaseUrl: input.appLink.hubBaseUrl
5516
- },
5517
- destination
5518
- }) : require_clip_model_registry.buildViewerLink({
5519
- base: {
5520
- form: "hub",
5521
- hubBaseUrl: baseUrl
5522
- },
5523
- destination
5524
- });
5525
- }
5526
- //#endregion
5527
5454
  //#region src/notification-center/prune-windows.ts
5528
5455
  /**
5529
5456
  * The operator-configurable prune windows, in days. One module, because the
@@ -14941,7 +14868,8 @@ var NcGroupMemberFateSchema = require_clip_model_registry.union([require_clip_mo
14941
14868
  "already-existed",
14942
14869
  "close-failed",
14943
14870
  "alarm-entry-delay-started",
14944
- "alarm-entry-delay"
14871
+ "alarm-entry-delay",
14872
+ "alarm-sensor-excluded"
14945
14873
  ]), require_clip_model_registry.templateLiteral(["failed-condition:", require_clip_model_registry.string()])]);
14946
14874
  /** Read cap, so a client cannot ask for the whole ring in one call. */
14947
14875
  var MAX_QUERY_LIMIT = 200;
@@ -23060,6 +22988,143 @@ function withSummaryBody(rule, section) {
23060
22988
  };
23061
22989
  }
23062
22990
  //#endregion
22991
+ //#region src/notification-center/system-event-tap-url.ts
22992
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
22993
+ var ADMIN_ADDONS_PATH = "/system/addons";
22994
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
22995
+ var ADMIN_DEVICES_PATH = "/devices";
22996
+ /**
22997
+ * The query parameter naming a node.
22998
+ *
22999
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
23000
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
23001
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
23002
+ */
23003
+ var NODE_PARAM = "node";
23004
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
23005
+ var TAB_PARAM = "tab";
23006
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
23007
+ var STREAM_BROKER_TAB = "stream-broker";
23008
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
23009
+ * string map with no device-model dependency; the guard pins it. */
23010
+ var CAMERA_DEVICE_TYPE = "camera";
23011
+ /** Kinds whose action is "go and apply an ADDON update". */
23012
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
23013
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
23014
+ var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
23015
+ /**
23016
+ * Kinds whose action is "go and look at this device's stream".
23017
+ *
23018
+ * BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
23019
+ * because "did it really come back, and what did it do while it was gone" is
23020
+ * an action and the page that answers it is the one the down-notification
23021
+ * already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
23022
+ * Videocamera sala feste è online" with `tapAction.value: null` — the same
23023
+ * camera's `device-offline` an hour earlier opened the Stream Broker tab, and
23024
+ * the recovery opened nothing, purely because `device-online` had no entry
23025
+ * here.
23026
+ *
23027
+ * The legacy `camera-*` pair is here for the same reason both are still in the
23028
+ * enum: outbox and history rows enqueued before the rename carry them, and an
23029
+ * old row is exactly the one whose action has to keep working.
23030
+ *
23031
+ * Kinds NOT here are kinds whose DOWN direction has no action either
23032
+ * (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
23033
+ * recovery does not earn a destination its failure was never given.
23034
+ */
23035
+ var DEVICE_STREAM_KINDS = new Set([
23036
+ "device-offline",
23037
+ "device-online",
23038
+ "stream-offline",
23039
+ "stream-online",
23040
+ "camera-offline",
23041
+ "camera-online"
23042
+ ]);
23043
+ /**
23044
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
23045
+ *
23046
+ * A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
23047
+ * too — that name is precisely the claim it made, and it is why the kind was
23048
+ * split into `device-*` + a `deviceTypes` condition.
23049
+ */
23050
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
23051
+ "stream-offline",
23052
+ "stream-online",
23053
+ "camera-offline",
23054
+ "camera-online"
23055
+ ]);
23056
+ /**
23057
+ * Where a per-section STATE SUMMARY row lands (D708). The row spans nodes, so
23058
+ * no `?node=`: the unscoped page is the honest one. A section absent here taps
23059
+ * through its kind, like any other row — a device section carries the system
23060
+ * placeholder device and gets no link, and the crash-looped addons have none
23061
+ * (their instant kind has none either).
23062
+ *
23063
+ * `nodes` is the one destination no kind gives: a node going offline has no
23064
+ * instant action, but "which nodes are in trouble" is answered by the
23065
+ * Cluster page, and the summary row is about all of them at once.
23066
+ */
23067
+ var SUMMARY_SECTION_PATHS = {
23068
+ nodes: ADMIN_CLUSTER_PATH,
23069
+ "addon-updates": ADMIN_ADDONS_PATH,
23070
+ updates: ADMIN_CLUSTER_PATH
23071
+ };
23072
+ function withParam(path, key, value) {
23073
+ if (value === void 0 || value.length === 0) return path;
23074
+ return `${path}?${key}=${encodeURIComponent(value)}`;
23075
+ }
23076
+ /**
23077
+ * The admin path (with its query) a system event taps through to, or `null`
23078
+ * when the kind has no action.
23079
+ *
23080
+ * Exported for the cross-package guard and for tests that want the path
23081
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
23082
+ */
23083
+ function systemEventTapPath(subject) {
23084
+ const sectionPath = subject.summarySection !== void 0 ? SUMMARY_SECTION_PATHS[subject.summarySection] : void 0;
23085
+ if (sectionPath !== void 0) return sectionPath;
23086
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
23087
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
23088
+ if (DEVICE_STREAM_KINDS.has(subject.kind)) {
23089
+ const deviceId = subject.deviceId;
23090
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
23091
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
23092
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
23093
+ }
23094
+ return null;
23095
+ }
23096
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
23097
+ function systemEventTapUrl(subject, baseUrl) {
23098
+ if (baseUrl === null || baseUrl.length === 0) return null;
23099
+ const path = systemEventTapPath(subject);
23100
+ if (path === null) return null;
23101
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
23102
+ }
23103
+ //#endregion
23104
+ //#region src/notification-center/default-tap-url.ts
23105
+ /**
23106
+ * The tap URL, or null. In the app form a camera link is minted even with no
23107
+ * `hub=` — the central page reaches the hub through the app's (or the
23108
+ * browser's) own stored hub (central-link design §5). The hub form and every
23109
+ * admin destination still need a base.
23110
+ */
23111
+ function defaultTapUrl(input) {
23112
+ const publicBaseUrl = input.publicBaseUrl === null || input.publicBaseUrl.length === 0 ? null : input.publicBaseUrl;
23113
+ if (input.systemEvent !== void 0) return publicBaseUrl === null ? null : systemEventTapUrl(input.systemEvent, publicBaseUrl);
23114
+ const destination = {
23115
+ kind: "camera",
23116
+ deviceId: input.deviceId,
23117
+ atMs: input.timestampMs
23118
+ };
23119
+ return require_clip_model_registry.buildViewerLink({
23120
+ base: input.tap ?? {
23121
+ form: "hub",
23122
+ hubBaseUrl: publicBaseUrl
23123
+ },
23124
+ destination
23125
+ });
23126
+ }
23127
+ //#endregion
23063
23128
  //#region src/notification-center/footage-source.ts
23064
23129
  /**
23065
23130
  * Decide where this window must be cut from. Pure and total.
@@ -25557,20 +25622,19 @@ var NcDispatcher = class {
25557
25622
  * repo keeps paying for.
25558
25623
  */
25559
25624
  /**
25560
- * The app-link half of a viewer tap (D709), as a spreadable fragment. A
25561
- * failing read falls back to the hub form — the tap every notification had
25562
- * before D709, which still opens — and is logged, so a tap that lands on the
25563
- * hub instead of the app has a line saying why.
25625
+ * The `notificationTaps` base of a camera tap (D734). A failing read is
25626
+ * `null` — the tap then opens the hub viewer on the public endpoint — and is
25627
+ * logged, so a tap that lands somewhere other than the flagged address has a
25628
+ * line saying why.
25564
25629
  */
25565
- async resolveViewerAppLink() {
25566
- const read = this.deps.viewerAppLink;
25567
- if (read === void 0) return {};
25630
+ async resolveTapBase() {
25631
+ const read = this.deps.tapBase;
25632
+ if (read === void 0) return null;
25568
25633
  try {
25569
- const choice = await read();
25570
- return choice === null ? {} : { appLink: choice };
25634
+ return await read();
25571
25635
  } catch (err) {
25572
- this.deps.logger.warn("notification link address unreadable — tap uses the hub address", { meta: { error: err instanceof Error ? err.message : String(err) } });
25573
- return {};
25636
+ this.deps.logger.warn("notification tap address unreadable — tap uses the public endpoint", { meta: { error: err instanceof Error ? err.message : String(err) } });
25637
+ return null;
25574
25638
  }
25575
25639
  }
25576
25640
  async mintActions(entry, target) {
@@ -25664,11 +25728,12 @@ var NcDispatcher = class {
25664
25728
  const resolvedMedia = await this.withArtifactUrls(entry, resolved.attachments);
25665
25729
  const attachments = [...resolvedMedia.attachments, ...frozenArtifactAttachments(entry)];
25666
25730
  const params = pickParams(entry.payload.params);
25667
- const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
25731
+ const publicUrlBase = this.deps.publicUrlBase;
25732
+ const tapUrl = params.clickUrl === void 0 && publicUrlBase !== void 0 ? defaultTapUrl({
25668
25733
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
25669
25734
  timestampMs: subject.timestamp,
25670
- baseUrl: await this.deps.viewerUrlBase().catch(() => null),
25671
- ...systemEvent === void 0 ? await this.resolveViewerAppLink() : {},
25735
+ publicBaseUrl: await publicUrlBase().catch(() => null),
25736
+ tap: systemEvent === void 0 ? await this.resolveTapBase() : null,
25672
25737
  ...systemEvent !== void 0 ? { systemEvent: {
25673
25738
  kind: systemEvent.kind,
25674
25739
  ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
@@ -39831,7 +39896,8 @@ var NotificationCenter = class {
39831
39896
  ruleAiStats: () => ruleAiAnalyst.stats()
39832
39897
  } : {},
39833
39898
  releaseCooldown: (input) => this.releaseCooldown(input),
39834
- ...deps.dispatcher.viewerUrlBase !== void 0 ? { viewerUrlBase: deps.dispatcher.viewerUrlBase } : {}
39899
+ ...deps.dispatcher.publicUrlBase !== void 0 ? { publicUrlBase: deps.dispatcher.publicUrlBase } : {},
39900
+ ...deps.dispatcher.tapBase !== void 0 ? { tapBase: deps.dispatcher.tapBase } : {}
39835
39901
  });
39836
39902
  this.outbox = new NcOutbox({
39837
39903
  store: deps.store,
@@ -40829,7 +40895,11 @@ var NotificationCenter = class {
40829
40895
  if (panel === null) return "unchanged";
40830
40896
  const opening = alarmOpeningFromSensorEvent(event.kind, event.value);
40831
40897
  if (opening.kind !== "reading") return "unchanged";
40832
- return panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp, { deferLiftAnnouncement: true });
40898
+ const changedAt = event.value?.["lastChangedAt"];
40899
+ return panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp, {
40900
+ deferLiftAnnouncement: true,
40901
+ ...typeof changedAt === "number" ? { changedAt } : {}
40902
+ });
40833
40903
  }
40834
40904
  /**
40835
40905
  * "Watched again", once the event that lifted the sensor has been EVALUATED (D706).
@@ -42316,23 +42386,50 @@ var NotificationCenter = class {
42316
42386
  });
42317
42387
  }
42318
42388
  /**
42319
- * Arm the attribution for a `alarm-panel.trigger` this rule is about to make,
42320
- * then delegate.
42389
+ * The one actuation seam of this centre — a rule's sequence step, a granted button.
42390
+ *
42391
+ * A press of THIS node's panel `trigger` never reaches the actuator: with a match
42392
+ * (`origin`) it is {@link NcAlarmPanelPort.triggerFromRule}, synchronously, the source
42393
+ * in the call. The join is HERE rather than in the runner: the runner actuates devices
42394
+ * generically and must not learn what an alarm is, while this class holds the panel.
42321
42395
  *
42322
- * The join point, and it is deliberately HERE rather than in the runner: the
42323
- * runner actuates devices generically and must not learn what an alarm is,
42324
- * while this class already holds the panel. The note is placed immediately
42325
- * before the call it describes, so the window it is valid for is the call
42326
- * itself.
42396
+ * Without a match, the press has no sensor to check against the arm's exclusions, so
42397
+ * while the arm EXCLUDES anything it is REFUSED (fail-closed, D735): an anonymous press
42398
+ * from this seam is exactly what sounded the siren off an excluded contact on
42399
+ * 2026-10-07. A person's manual trigger (the panel's cap, from the UI) does not come
42400
+ * through here and is unaffected.
42327
42401
  */
42328
42402
  async actuateWithAttribution(input) {
42403
+ const panel = this.alarmPanel;
42404
+ if (panel !== null && input.deviceId === panel.deviceId && input.cap === ALARM_PANEL_CAP && input.method === "trigger" && this.pressLocalPanel(panel, input.origin)) return;
42329
42405
  const actuate = this.deps.actuate;
42330
42406
  if (actuate === void 0) throw new Error("no actuator is wired on this node");
42331
- const panel = this.alarmPanel;
42332
- if (panel !== null && input.origin !== void 0 && input.deviceId === panel.deviceId && input.cap === ALARM_PANEL_CAP && input.method === "trigger") panel.noteRuleTrigger(input.origin);
42333
42407
  await actuate(input);
42334
42408
  }
42335
42409
  /**
42410
+ * A press of this node's panel from the actuation seam; true when it was handled here.
42411
+ * With a source: the panel's rule press. Without one: refused while the arm excludes any
42412
+ * sensor — an ERROR line and a throw, so the sequence step reports failed instead of
42413
+ * looking done. Without one and nothing excluded: false, and the press takes the cap
42414
+ * like any manual press (there is no sensor whose exclusion it could skip).
42415
+ */
42416
+ pressLocalPanel(panel, origin) {
42417
+ if (origin !== void 0) {
42418
+ panel.triggerFromRule(origin);
42419
+ return true;
42420
+ }
42421
+ const excluded = panel.excludedDevices();
42422
+ if (excluded.length === 0) return false;
42423
+ this.logger.error("alarm trigger REFUSED — a rule-action press carried no source while the arm excludes sensors", {
42424
+ tags: { deviceId: panel.deviceId },
42425
+ meta: {
42426
+ excluded: excluded.join(","),
42427
+ state: panel.currentState()
42428
+ }
42429
+ });
42430
+ throw new Error("alarm trigger refused: no source, and the current arm excludes sensors");
42431
+ }
42432
+ /**
42336
42433
  * Device names from the in-process directory — no I/O, so the arm sentence
42337
42434
  * can be composed on the publish path.
42338
42435
  *
@@ -43220,13 +43317,9 @@ var NotificationCenter = class {
43220
43317
  }
43221
43318
  const origin = fireRefFor(rule, subject, kind);
43222
43319
  const phase = panel.classifyRuleMatch(origin);
43223
- this.actuateWithAttribution({
43224
- deviceId: panel.deviceId,
43225
- cap: ALARM_PANEL_CAP,
43226
- method: "trigger",
43227
- args: {},
43228
- origin
43229
- }).catch((err) => {
43320
+ try {
43321
+ panel.triggerFromRule(origin);
43322
+ } catch (err) {
43230
43323
  this.logger.warn("alarm panel auto-trigger failed", {
43231
43324
  tags: { deviceId: subject.deviceId },
43232
43325
  meta: {
@@ -43236,7 +43329,7 @@ var NotificationCenter = class {
43236
43329
  error: err instanceof Error ? err.message : String(err)
43237
43330
  }
43238
43331
  });
43239
- });
43332
+ }
43240
43333
  return phase;
43241
43334
  }
43242
43335
  /**
@@ -101598,9 +101691,15 @@ async function projectSensorMarkers(deps, data, timestamp) {
101598
101691
  *
101599
101692
  * - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
101600
101693
  * (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
101601
- * provider drops the `unavailable` push and writes the recovery, whose
101602
- * `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
101603
- * `last_changed`. The mirror diffs, and emits.
101694
+ * provider dropped the `unavailable` push and wrote the recovery, whose
101695
+ * `entryOpen` was still `false` and whose `lastChangedAt` was HA's fresh
101696
+ * `last_changed`. The mirror diffed, and emitted. Since D735 the provider
101697
+ * keeps the change time of a value that did not change
101698
+ * (`addon-provider-homeassistant` `ha-change-stamp.ts`), because this gate
101699
+ * cannot fix the ACTIVE half: with its memory empty (a hub restart), a
101700
+ * recovery `on` with a fresh stamp is indistinguishable here from an
101701
+ * opening — that is how four excluded contacts pressed the armed panel on
101702
+ * 2026-10-07 02:32Z.
101604
101703
  * - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
101605
101704
  * `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
101606
101705
  * real state lands seconds later with a `lastChangedAt` hours old. Two
@@ -106690,51 +106789,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
106690
106789
  };
106691
106790
  }
106692
106791
  }
106693
- /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
106694
- async markedNotificationEndpoint() {
106695
- try {
106696
- return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
106697
- } catch {
106698
- return;
106699
- }
106700
- }
106701
106792
  /**
106702
- * The ranked base-URL candidates a notification LINK may use.
106793
+ * The ranked base-URL candidates a notification LINK may use — every link
106794
+ * except the tap: the hub's public endpoint (D734, `link-endpoints.ts`).
106703
106795
  *
106704
- * Shared by the artifact plane (media) and the action plane (buttons) — one
106705
- * resolver, because a second copy would drift and the two would start
106706
- * minting links on different hosts for the same notification.
106796
+ * Shared by the artifact plane (media), the action plane (buttons) and the
106797
+ * admin page a system event taps into — one resolver, because a second copy
106798
+ * would drift and the planes would start minting links on different hosts
106799
+ * for the same notification.
106707
106800
  */
106708
106801
  async ncEndpoints() {
106709
- return collectArtifactEndpoints({
106710
- markedBaseUrl: await this.markedNotificationEndpoint(),
106711
- getConnected: async () => {
106712
- const status = await this.ctx.api.networkAccess.getStatus.query();
106713
- this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
106714
- connected: status.connected,
106715
- url: status.endpoint?.url ?? null,
106716
- protocol: status.endpoint?.protocol ?? null
106717
- } });
106718
- return status.connected && status.endpoint !== null ? {
106719
- url: status.endpoint.url,
106720
- protocol: status.endpoint.protocol
106721
- } : null;
106722
- },
106723
- listExternal: async () => {
106724
- return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
106725
- url: e.url,
106726
- protocol: e.protocol
106727
- }));
106728
- },
106729
- listLan: async (port) => {
106730
- return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.flatMap((e) => e.kind === "app-link" || e.kind === "custom" ? [] : [{
106731
- baseUrl: e.baseUrl,
106732
- kind: e.kind,
106733
- priority: e.priority
106734
- }]);
106735
- },
106736
- logger: this.ctx.logger
106737
- });
106802
+ return notificationLinkEndpoints(this.ctx.api, this.ctx.logger);
106738
106803
  }
106739
106804
  /**
106740
106805
  * Serve the NC ACTION plane: the public route a notification button lands on.
@@ -109320,8 +109385,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
109320
109385
  resolveFfmpeg: () => this.ctx.deps.ensureFfmpeg()
109321
109386
  }), req),
109322
109387
  publishArtifact: async (bytes, mime, deviceId) => await this.ncArtifactPlane?.publish(bytes, mime, deviceId) ?? null,
109323
- viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
109324
- viewerAppLink: async () => ncAppLinkChoiceOf(await this.ctx.api.localNetwork.getNotificationEndpoint.query()),
109388
+ publicUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
109389
+ tapBase: async () => require_clip_model_registry.viewerLinkBaseOf(await this.ctx.api.localNetwork.getNotificationEndpoint.query()),
109325
109390
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
109326
109391
  url: null,
109327
109392
  artifactId: null