@camstack/addon-post-analysis 1.2.338 → 1.2.339

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.
@@ -1,4 +1,4 @@
1
- import { $ as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, $n as encodeVectorBase64, $r as systemEventDeliveryProblems, $t as RECORDING_SOURCE_CAMSTACK_ADDON, A as EMBEDDING_MODEL_FAILED, Ai as literal, An as applyModeDelaysEdits, Ar as plateGalleryCapability, At as NcLegacyRuleActionSequenceSchema, B as LabelAttributionSchema, Bi as union, Bn as coreBlockIdFromAddonId, Br as rootSupplyServes, Bt as NcSnoozeInputSchema, C as CoverStateSchema, Cn as TrackSideSchema, Cr as linkedStepOf, Ct as NcAlarmConfigSchema, D as DETECTION_PIPELINE_CAP_NAME, Di as array, Dn as alarmDeviceSecureOnArm, Dr as occupancyScope, Dt as NcAlarmSettingsPatchSchema, E as DETECTION_MACRO_CLASSES, Ei as _enum, En as addonWidgetsSourceCapability, Er as notificationRulesCapability, Et as NcAlarmSensorsSchema, F as EVENT_OWNER_TYPES, Fi as record, Fn as audioModeOf, Fr as resolveClipSearchMinScore, Ft as NcRuleSchema, G as MAX_BIRTH_DECISION_RECORDS, Gn as deriveRecordingMode, Gr as ruleKindOf, Gt as NcTaxonomySchema, H as ListTrackRelationsResultSchema, Hi as EventCategory, Hn as createAlarmProfile, Hr as ruleBelongsToAlarm$1, Ht as NcSnoozeSuppressedSchema, I as EXPORT_DENSE_MAX_RANGES, Ii as strictObject, In as buildEventKindDescriptor, Ir as resolveLocationMode, It as NcRuleTargetSchema, J as MAX_TRACK_RELATIONS, Jr as sensorRaisedOf, Jt as NcTemplateVarDescriptorSchema, K as MAX_BIRTH_LATENCY_PROXY_MS, Kn as describeSystemEventDeliveryProblem, Kr as sceneMonitorCapability, Kt as NcTemplatePreviewInputSchema, L as FIRST_LEVEL_MACRO_CLASSES, Li as string, Ln as buildViewerLink, Lr as resolveModeDelays, Lt as NcRuleUpdateInputSchema, M as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Mi as number, Mn as applySecureOnArmEdit, Mr as readMotionTriggeredBy, Mt as NcRuleActionsSchema, N as EVENT_DENSITY_BATCH_MAX, Ni as object, Nn as assertTimelapseCadences, Nr as readTimelapseGeneratedAt, Nt as NcRuleInputSchema, O as DeclaredDevices, Oi as boolean, On as alarmPanelCapability, Ot as NcConditionDescriptorSchema, P as EVENT_KIND_BY_CAP, Pi as partialRecord, Pn as audioMetricsCapability, Pr as renameAlarmProfile, Pt as NcRulePatchSchema, Q as NC_ALARM_DEFAULT_PROFILE_ID, Qr as syncActiveAlarmProfileSettings, Qt as RECORDING_EXPORT_MAX_READ_BYTES, R as FULL_IMAGE_BBOX, Ri as templateLiteral, Rn as cloneAlarmSettings, Rr as resolveModeNotifyPolicy, Rt as NcScheduleSchema, S as ClearTrackRelationResultSchema, Si as nodePin, Sn as TrackRelationSchema, Sr as kebabToCamel, St as NC_TEMPLATE_VARS, T as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Ti as ZodObject, Tn as activateAlarmProfile, Tr as memoizePinnedFfmpeg, Tt as NcAlarmSensorsInputSchema, U as LockStateSchema, Un as deleteAlarmProfile, Ur as ruleDeviceCompatCapQueries, Ut as NcStateSummarySectionIdSchema, V as ListTrackRelationsInputSchema, Vi as unknown, Vn as cosineSimilarity$1, Vr as ruleAutoTriggersAlarm, Vt as NcSnoozeSchema, W as MAX_ARCHIVED_DEBUG_NOTES, Wn as derivePostTriggeredAction, Wr as ruleGroupingApplies, Wt as NcSystemEventKindSchema, X as MediaFileKindEnum, Xn as embeddingRefusalOf, Xr as storageOccupancyCapability, Xt as OpsLogEntrySchema, Y as MOTION_CLOSE_AFTER_MS, Yr as servedTemplateVars, Yt as OPS_LOG_DEFAULT_LIMIT, Z as MotionTriggeredBySchema, Zr as subKindsOf, _ as BIRTH_DECISION_DEFAULT_LIMIT, _i as createEvent, _n as TimelapseRuleInputSchema, _r as isRootSuppliableStepId, _t as NC_SENSOR_TRANSITION_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as templateVarsFor, an as SCENE_DEFAULT_CHECK_INTERVAL_SEC, ar as gifDeriveSpeed, at as NC_AUDIO_CONFIRM_HITS_DEFAULT, b as COCO_TO_MACRO, bi as hydrateSchema, bn as TrackPuritySchema, br as isSystemEventSummaryRule, bt as NC_TANK_SYSTEM_EVENT_KINDS, c as resolveClusterModelPin, ci as zoneAnalyticsCapability, cn as SCENE_DEFAULT_UNCOVERED_POLICY, cr as isAppLinkBase, ct as NC_CLIP_PRE_ROLL_DEFAULT_SEC, d as APPEARANCE_BLOCKS, di as evaluateSensorEdge, dn as SceneMonitorSchema, dr as isDetectionMacroClass, dt as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, ei as systemEventFamilyProblems, en as RebuildObjectEmbeddingsRefusalSchema, er as ensureAlarmProfiles, et as NC_ALARM_DELAY_FIELDS, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fi as isSourceCap, fn as SetTrackPurityInputSchema, fr as isEventKindLabelKey, ft as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, g as ArchivedDebugNoteSchema, gi as CamProfileSchema, gn as TRACK_RELATIONS_DEFAULT_LIMIT, gr as isRetiredClearKey, gt as NC_GROUPING_MIN_SEC, h as AlarmArmModeSchema, hi as BaseAddon, hn as TIMELAPSE_DENSE_FLOOR_SEC, hr as isMediaOwnerType, ht as NC_GROUPING_MAX_SEC, ii as templateCarriesSummary, in as SCENE_DEFAULT_ANCHOR_THRESHOLD, ir as failureContributionCapability, it as NC_ALARM_TRIGGERED_MAX_SEC, j as EMBEDDING_REQUIREMENTS_FAILED, ji as looseObject, jn as applyNonBlockingEdit, jr as readDeviceStateFrom, jt as NcRuleActionSchema, k as DetectionSourceSchema, ki as discriminatedUnion, kn as applyBlockingEdit, kr as pipelineAnalyticsCapability, kt as NcConditionsSchema, l as ALL_CAPABILITY_DEFINITIONS, li as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, ln as SCENE_DIVERGED, lr as isAudioClassName, lt as NC_CLIP_RETENTION_MAX_SEC, m as AUDIO_MACRO_LABELS, mi as errMsg, mn as SetTrackRelationInputSchema, mr as isKnownTemplateVar, mt as NC_GROUPING_DEFAULT_SEC, ni as systemEventSummaryScheduleOf, nn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, nr as evictionPolicyOfLocation, nt as NC_ALARM_NOTIFY_FIELDS, o as FACE_EMBEDDING_STEP_ID, oi as vectorDimFromBase64, on as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, or as gifFromMp4Args, ot as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pi as DeviceType, pn as SetTrackPurityResultSchema, pr as isEventOwnerType, pt as NC_DEFAULT_SNOOZE_MINUTES, q as MAX_TRACK_PURITIES, qn as describeSystemEventFamilyProblem, qr as secureActuationFor, qt as NcTemplatePreviewSchema, ri as systemEventSummarySplitOf, rn as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, rr as faceGalleryCapability, rt as NC_ALARM_SYSTEM_EVENT_KINDS, s as readClusterModelPinStrict, si as viewerLinkBaseOf, sn as SCENE_DEFAULT_QUIET_SECONDS, sr as isAlarmControllableCap, st as NC_CLIP_POST_ROLL_DEFAULT_SEC, t as BUILTIN_CLIP_MODELS, ti as systemEventFilterApplies, tn as RetrainStatusSchema, tr as eventKindLabel, tt as NC_ALARM_DELAY_MAX_SEC, u as APPEARANCE_BIN_COUNT, ui as canonicalHash, un as SCENE_RESET_RECAPTURES, ur as isDefaultAlarmProfileId, ut as NC_CONDITION_CATALOG, v as BaseDevice, vi as customAction, vn as TimelapseRulePatchSchema, vr as isScheduleActive, vt as NC_SNOOZE_MAX_MINUTES, w as DEFAULT_EVENT_COLOR, wi as sleep$1, wn as TrackSourceSchema, wr as mayWriteToLocation, wt as NcAlarmPostTriggeredActionSchema, x as ClearTrackRelationInputSchema, xi as isDeviceScopedCap, xn as TrackRelationKindSchema, xr as isVectorIndexDimConflict, xt as NC_TAXONOMY, y as BirthDecisionRecordSchema, yi as defineCustomActions, yn as TimelapseRuleSchema, yr as isSystemDelivery, yt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, z as FailureCounters, zi as tuple, zn as consumableLowPercentOf, zt as NcSharedSequenceSchema } from "../clip-model-registry-BLUVLdlY.mjs";
1
+ import { $ as NC_ALARM_DEFAULT_TRIGGERED_DURATION_SEC, $n as encodeVectorBase64, $r as systemEventDeliveryProblems, $t as RECORDING_SOURCE_CAMSTACK_ADDON, A as EMBEDDING_MODEL_FAILED, Ai as literal, An as applyModeDelaysEdits, Ar as plateGalleryCapability, At as NcLegacyRuleActionSequenceSchema, B as LabelAttributionSchema, Bi as union, Bn as coreBlockIdFromAddonId, Br as rootSupplyServes, Bt as NcSnoozeInputSchema, C as CoverStateSchema, Cn as TrackSideSchema, Cr as linkedStepOf, Ct as NcAlarmConfigSchema, D as DETECTION_PIPELINE_CAP_NAME, Di as array, Dn as alarmDeviceSecureOnArm, Dr as occupancyScope, Dt as NcAlarmSettingsPatchSchema, E as DETECTION_MACRO_CLASSES, Ei as _enum, En as addonWidgetsSourceCapability, Er as notificationRulesCapability, Et as NcAlarmSensorsSchema, F as EVENT_OWNER_TYPES, Fi as record, Fn as audioModeOf, Fr as resolveClipSearchMinScore, Ft as NcRuleSchema, G as MAX_BIRTH_DECISION_RECORDS, Gn as deriveRecordingMode, Gr as ruleKindOf, Gt as NcTaxonomySchema, H as ListTrackRelationsResultSchema, Hi as EventCategory, Hn as createAlarmProfile, Hr as ruleBelongsToAlarm$1, Ht as NcSnoozeSuppressedSchema, I as EXPORT_DENSE_MAX_RANGES, Ii as strictObject, In as buildEventKindDescriptor, Ir as resolveLocationMode, It as NcRuleTargetSchema, J as MAX_TRACK_RELATIONS, Jr as sensorRaisedOf, Jt as NcTemplateVarDescriptorSchema, K as MAX_BIRTH_LATENCY_PROXY_MS, Kn as describeSystemEventDeliveryProblem, Kr as sceneMonitorCapability, Kt as NcTemplatePreviewInputSchema, L as FIRST_LEVEL_MACRO_CLASSES, Li as string, Ln as buildViewerLink, Lr as resolveModeDelays, Lt as NcRuleUpdateInputSchema, M as EMBEDDING_REQUIREMENTS_UNAVAILABLE, Mi as number, Mn as applySecureOnArmEdit, Mr as readMotionTriggeredBy, Mt as NcRuleActionsSchema, N as EVENT_DENSITY_BATCH_MAX, Ni as object, Nn as assertTimelapseCadences, Nr as readTimelapseGeneratedAt, Nt as NcRuleInputSchema, O as DeclaredDevices, Oi as boolean, On as alarmPanelCapability, Ot as NcConditionDescriptorSchema, P as EVENT_KIND_BY_CAP, Pi as partialRecord, Pn as audioMetricsCapability, Pr as renameAlarmProfile, Pt as NcRulePatchSchema, Q as NC_ALARM_DEFAULT_PROFILE_ID, Qr as syncActiveAlarmProfileSettings, Qt as RECORDING_EXPORT_MAX_READ_BYTES, R as FULL_IMAGE_BBOX, Ri as templateLiteral, Rn as cloneAlarmSettings, Rr as resolveModeNotifyPolicy, Rt as NcScheduleSchema, S as ClearTrackRelationResultSchema, Si as nodePin, Sn as TrackRelationSchema, Sr as kebabToCamel, St as NC_TEMPLATE_VARS, T as DEFAULT_TIMELAPSE_PREVIEW_TEXT, Ti as ZodObject, Tn as activateAlarmProfile, Tr as memoizePinnedFfmpeg, Tt as NcAlarmSensorsInputSchema, U as LockStateSchema, Un as deleteAlarmProfile, Ur as ruleDeviceCompatCapQueries, Ut as NcStateSummarySectionIdSchema, V as ListTrackRelationsInputSchema, Vi as unknown, Vn as cosineSimilarity$1, Vr as ruleAutoTriggersAlarm, Vt as NcSnoozeSchema, W as MAX_ARCHIVED_DEBUG_NOTES, Wn as derivePostTriggeredAction, Wr as ruleGroupingApplies, Wt as NcSystemEventKindSchema, X as MediaFileKindEnum, Xn as embeddingRefusalOf, Xr as storageOccupancyCapability, Xt as OpsLogEntrySchema, Y as MOTION_CLOSE_AFTER_MS, Yr as servedTemplateVars, Yt as OPS_LOG_DEFAULT_LIMIT, Z as MotionTriggeredBySchema, Zr as subKindsOf, _ as BIRTH_DECISION_DEFAULT_LIMIT, _i as createEvent, _n as TimelapseRuleInputSchema, _r as isRootSuppliableStepId, _t as NC_SENSOR_TRANSITION_KINDS, a as CLIP_EMBEDDING_STEP_ID, ai as templateVarsFor, an as SCENE_DEFAULT_CHECK_INTERVAL_SEC, ar as gifDeriveSpeed, at as NC_AUDIO_CONFIRM_HITS_DEFAULT, b as COCO_TO_MACRO, bi as hydrateSchema, bn as TrackPuritySchema, br as isSystemEventSummaryRule, bt as NC_TANK_SYSTEM_EVENT_KINDS, c as resolveClusterModelPin, ci as zoneAnalyticsCapability, cn as SCENE_DEFAULT_UNCOVERED_POLICY, cr as isAppLinkBase, ct as NC_CLIP_PRE_ROLL_DEFAULT_SEC, d as APPEARANCE_BLOCKS, di as evaluateSensorEdge, dn as SceneMonitorSchema, dr as isDetectionMacroClass, dt as NC_CONSUMABLE_LOW_PERCENT_DEFAULT, ei as systemEventFamilyProblems, en as RebuildObjectEmbeddingsRefusalSchema, er as ensureAlarmProfiles, et as NC_ALARM_DELAY_FIELDS, f as APPEARANCE_RESOLVES_AMBIGUITY_DEFAULT, fi as isSourceCap, fn as SetTrackPurityInputSchema, fr as isEventKindLabelKey, ft as NC_CONSUMABLE_SYSTEM_EVENT_KINDS, g as ArchivedDebugNoteSchema, gi as CamProfileSchema, gn as TRACK_RELATIONS_DEFAULT_LIMIT, gr as isRetiredClearKey, gt as NC_GROUPING_MIN_SEC, h as AlarmArmModeSchema, hi as BaseAddon, hn as TIMELAPSE_DENSE_FLOOR_SEC, hr as isMediaOwnerType, ht as NC_GROUPING_MAX_SEC, ii as templateCarriesSummary, in as SCENE_DEFAULT_ANCHOR_THRESHOLD, ir as failureContributionCapability, it as NC_ALARM_TRIGGERED_MAX_SEC, j as EMBEDDING_REQUIREMENTS_FAILED, ji as looseObject, jn as applyNonBlockingEdit, jr as readDeviceStateFrom, jt as NcRuleActionSchema, k as DetectionSourceSchema, ki as discriminatedUnion, kn as applyBlockingEdit, kr as pipelineAnalyticsCapability, kt as NcConditionsSchema, l as ALL_CAPABILITY_DEFINITIONS, li as DEFAULT_FIRST_SIGHTING_FRESHNESS_MS, ln as SCENE_DIVERGED, lr as isAudioClassName, lt as NC_CLIP_RETENTION_MAX_SEC, m as AUDIO_MACRO_LABELS, mi as errMsg, mn as SetTrackRelationInputSchema, mr as isKnownTemplateVar, mt as NC_GROUPING_DEFAULT_SEC, ni as systemEventSummaryScheduleOf, nn as SCENE_CONFIRM_DEFAULT_MAX_IMAGE_PX, nr as evictionPolicyOfLocation, nt as NC_ALARM_NOTIFY_FIELDS, o as FACE_EMBEDDING_STEP_ID, oi as vectorDimFromBase64, on as SCENE_DEFAULT_OBSERVATION_SPACING_SEC, or as gifFromMp4Args, ot as NC_AUDIO_CONFIRM_WINDOW_SEC_DEFAULT, p as ARCHIVED_DEBUG_NOTES_DEFAULT_LIMIT, pi as DeviceType, pn as SetTrackPurityResultSchema, pr as isEventOwnerType, pt as NC_DEFAULT_SNOOZE_MINUTES, q as MAX_TRACK_PURITIES, qn as describeSystemEventFamilyProblem, qr as secureActuationFor, qt as NcTemplatePreviewSchema, ri as systemEventSummarySplitOf, rn as SCENE_CONFIRM_DEFAULT_TIMEOUT_MS, rr as faceGalleryCapability, rt as NC_ALARM_SYSTEM_EVENT_KINDS, s as readClusterModelPinStrict, si as viewerLinkBaseOf, sn as SCENE_DEFAULT_QUIET_SECONDS, sr as isAlarmControllableCap, st as NC_CLIP_POST_ROLL_DEFAULT_SEC, t as BUILTIN_CLIP_MODELS, ti as systemEventFilterApplies, tn as RetrainStatusSchema, tr as eventKindLabel, tt as NC_ALARM_DELAY_MAX_SEC, u as APPEARANCE_BIN_COUNT, ui as canonicalHash, un as SCENE_RESET_RECAPTURES, ur as isDefaultAlarmProfileId, ut as NC_CONDITION_CATALOG, v as BaseDevice, vi as customAction, vn as TimelapseRulePatchSchema, vr as isScheduleActive, vt as NC_SNOOZE_MAX_MINUTES, w as DEFAULT_EVENT_COLOR, wi as sleep$1, wn as TrackSourceSchema, wr as mayWriteToLocation, wt as NcAlarmPostTriggeredActionSchema, x as ClearTrackRelationInputSchema, xi as isDeviceScopedCap, xn as TrackRelationKindSchema, xr as isVectorIndexDimConflict, xt as NC_TAXONOMY, y as BirthDecisionRecordSchema, yi as defineCustomActions, yn as TimelapseRuleSchema, yr as isSystemDelivery, yt as NC_SYSTEM_EVENT_KIND_GROUP_SPECS, z as FailureCounters, zi as tuple, zn as consumableLowPercentOf, zt as NcSharedSequenceSchema } from "../clip-model-registry-BKVUtnRA.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs$1 from "node:fs";
4
4
  import { createWriteStream, promises } from "node:fs";
@@ -1401,6 +1401,12 @@ async function readAlarmOpenings(ids, loadRuntime, logger) {
1401
1401
  function isPhase(value) {
1402
1402
  return value === "open" || value === "closed";
1403
1403
  }
1404
+ /**
1405
+ * A change time that cannot be a move: `0` is a placeholder and `1` is the
1406
+ * "known value, unknown change time" convention providers stamp on a replay or
1407
+ * a recovery (`HM_SEEDED_CHANGED_AT`, `HA_SEEDED_CHANGED_AT`).
1408
+ */
1409
+ var NO_CHANGE_TIME = 1;
1404
1410
  var NcAlarmExclusions = class {
1405
1411
  entries = [];
1406
1412
  /** Replace the whole set — one arm, one set of exclusions. */
@@ -1409,7 +1415,9 @@ var NcAlarmExclusions = class {
1409
1415
  deviceId,
1410
1416
  phase: "open",
1411
1417
  at,
1412
- announced: false
1418
+ announced: false,
1419
+ moved: false,
1420
+ lastMoveAt: 0
1413
1421
  }));
1414
1422
  }
1415
1423
  /** Every excluded sensor, in the order it was excluded. */
@@ -1433,24 +1441,53 @@ var NcAlarmExclusions = class {
1433
1441
  * An unreadable state changes nothing (D49): a failed read must never lift an
1434
1442
  * exclusion the operator asked for, and must never swallow the close that
1435
1443
  * would have.
1444
+ *
1445
+ * `changedAt` is the reading's OWN change time, when the caller has one (a
1446
+ * sensor event's slice; the reconcile tick has none). It decides only whether
1447
+ * an open in phase `open` really MOVED ({@link takeMoved}) — never a lift.
1436
1448
  */
1437
- observe(deviceId, reading, _at) {
1449
+ observe(deviceId, reading, _at, changedAt) {
1438
1450
  if (reading === void 0) return "unchanged";
1439
1451
  const current = this.entries.find((e) => e.deviceId === deviceId);
1440
1452
  if (current === void 0) return "unchanged";
1441
1453
  if (reading === "secure") {
1442
- if (current.phase === "closed") return "unchanged";
1443
- this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
1444
- ...e,
1445
- phase: "closed"
1446
- } : e);
1447
- return "closed";
1454
+ if (current.phase === "closed") return this.replace({
1455
+ ...current,
1456
+ moved: false
1457
+ }, "unchanged");
1458
+ return this.replace({
1459
+ ...current,
1460
+ phase: "closed",
1461
+ moved: false
1462
+ }, "closed");
1463
+ }
1464
+ if (current.phase === "open") {
1465
+ const fresh = changedAt !== void 0 && Number.isFinite(changedAt) && changedAt > Math.max(current.at, current.lastMoveAt, NO_CHANGE_TIME);
1466
+ const redelivered = current.moved && changedAt === current.lastMoveAt;
1467
+ return this.replace({
1468
+ ...current,
1469
+ moved: fresh || redelivered,
1470
+ lastMoveAt: fresh ? changedAt : current.lastMoveAt
1471
+ }, "unchanged");
1448
1472
  }
1449
- if (current.phase === "open") return "unchanged";
1450
1473
  this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
1451
1474
  return "rearmed";
1452
1475
  }
1453
1476
  /**
1477
+ * Did the last reading of this sensor really move it (D735)? Consumed: the
1478
+ * press it caused asks once, and a later press of the same reading is a
1479
+ * duplicate. False for a sensor that is not excluded.
1480
+ */
1481
+ takeMoved(deviceId) {
1482
+ const current = this.entries.find((e) => e.deviceId === deviceId);
1483
+ if (current === void 0 || !current.moved) return false;
1484
+ this.replace({
1485
+ ...current,
1486
+ moved: false
1487
+ }, "unchanged");
1488
+ return true;
1489
+ }
1490
+ /**
1454
1491
  * Mark this sensor's ignored trigger as announced — true only the FIRST time
1455
1492
  * in this arm (the caller then announces and persists), false for a repeat
1456
1493
  * or for a sensor that is not excluded.
@@ -1473,6 +1510,11 @@ var NcAlarmExclusions = class {
1473
1510
  this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
1474
1511
  return true;
1475
1512
  }
1513
+ /** Swap one entry for its next version — the set is replaced, never mutated. */
1514
+ replace(next, change) {
1515
+ this.entries = this.entries.map((e) => e.deviceId === next.deviceId ? next : e);
1516
+ return change;
1517
+ }
1476
1518
  /** The arm this belongs to is over. */
1477
1519
  clear() {
1478
1520
  this.entries = [];
@@ -1504,7 +1546,9 @@ var NcAlarmExclusions = class {
1504
1546
  deviceId: item,
1505
1547
  phase: "open",
1506
1548
  at: 0,
1507
- announced: false
1549
+ announced: false,
1550
+ moved: false,
1551
+ lastMoveAt: 0
1508
1552
  });
1509
1553
  continue;
1510
1554
  }
@@ -1518,7 +1562,9 @@ var NcAlarmExclusions = class {
1518
1562
  deviceId,
1519
1563
  phase,
1520
1564
  at,
1521
- announced: rec["announced"] === true
1565
+ announced: rec["announced"] === true,
1566
+ moved: false,
1567
+ lastMoveAt: 0
1522
1568
  });
1523
1569
  }
1524
1570
  this.entries = out;
@@ -1609,7 +1655,8 @@ function modeDelaysFromPersisted(raw) {
1609
1655
  }
1610
1656
  if (item.notifyDuringEntryDelay !== void 0) kept.notifyDuringEntryDelay = item.notifyDuringEntryDelay;
1611
1657
  if (item.notifySensorRewatched !== void 0) kept.notifySensorRewatched = item.notifySensorRewatched;
1612
- 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);
1658
+ if (item.notifySensorIgnored !== void 0) kept.notifySensorIgnored = item.notifySensorIgnored;
1659
+ 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);
1613
1660
  }
1614
1661
  return {
1615
1662
  entries,
@@ -1820,6 +1867,10 @@ function alarmPushDecision(phase, heard) {
1820
1867
  switch (phase.kind) {
1821
1868
  case "none":
1822
1869
  case "triggers-now": return { verdict: { kind: "plain" } };
1870
+ case "excluded": return { verdict: {
1871
+ kind: "suppress",
1872
+ reason: "alarm-sensor-excluded"
1873
+ } };
1823
1874
  case "triggered": return { verdict: { kind: "intrusion" } };
1824
1875
  case "starts-entry-delay": return heard.pending ? { verdict: {
1825
1876
  kind: "suppress",
@@ -1862,17 +1913,6 @@ function intrusionDeviceOf(ref) {
1862
1913
  //#endregion
1863
1914
  //#region src/notification-center/alarm/alarm-transitions.ts
1864
1915
  /**
1865
- * How long a note handed to {@link NcAlarmAttribution.note} stays valid.
1866
- *
1867
- * The note is placed IMMEDIATELY before the `alarm-panel.trigger` call it
1868
- * describes, in the same process, so the real gap is milliseconds. The bound
1869
- * exists only so a sequence that failed before reaching its trigger step — or a
1870
- * `wait` that outlived the incident — cannot leave an attribution lying around
1871
- * for an unrelated manual trigger to pick up half an hour later.
1872
- * Mis-attributing an alarm would put somebody else's photograph on it.
1873
- */
1874
- var TRIGGER_ATTRIBUTION_TTL_MS = 3e4;
1875
- /**
1876
1916
  * How many followers one incident keeps (D705). The mosaic tiles at most
1877
1917
  * `NC_GROUPED_MOSAIC_MAX_TILES` (6) and the sentence counts every member, so this
1878
1918
  * bounds RAM and the frozen row, not the picture: a camera that keeps matching for
@@ -1888,88 +1928,41 @@ function subjectKeyOf(ref) {
1888
1928
  * WHO the panel's current countdown belongs to.
1889
1929
  *
1890
1930
  * Small and separate because its whole content is a LIFETIME, and a lifetime
1891
- * is what goes wrong: the note and the trigger are milliseconds apart, but the
1892
- * trigger and the siren are up to `entryDelaySec` apart, and in between the
1893
- * operator may disarm, re-arm, or press trigger by hand. Each of those must
1894
- * drop the attribution, or tonight's alarm arrives carrying last night's
1895
- * photograph.
1931
+ * is what goes wrong: the trigger and the siren are up to `entryDelaySec`
1932
+ * apart, and in between the operator may disarm, re-arm, or press trigger by
1933
+ * hand. Each of those must drop the attribution, or tonight's alarm arrives
1934
+ * carrying last night's photograph.
1935
+ *
1936
+ * It holds NO pending press. A rule press carries its source IN the call
1937
+ * (`NcAlarmPanelDevice.triggerFromRule`), synchronously, so there is nothing
1938
+ * to hand over and nothing to overwrite. The single "next trigger is this
1939
+ * rule's" slot this class used to hold was written before an awaited cap call
1940
+ * and read when the call landed; two matches 1 ms apart overwrote it, one press
1941
+ * landed with no source, skipped the exclusion check and sounded the siren off
1942
+ * an excluded contact (2026-10-07 02:32Z, D735).
1896
1943
  */
1897
1944
  var NcAlarmAttribution = class {
1898
- noted = null;
1899
1945
  claimed = null;
1900
1946
  /** The incident's other subjects (D705) — replaced, never mutated. */
1901
1947
  followed = [];
1902
- /** "The next trigger is this rule's." Consumed by the trigger that follows:
1903
- * {@link claim} when it is accepted, {@link follow} when an entry delay
1904
- * already running refused it (D705), {@link dropPending} when an excluded
1905
- * sensor made it. */
1906
- note(source, now) {
1907
- this.noted = {
1908
- source,
1909
- at: now
1910
- };
1911
- }
1912
- /**
1913
- * The note that has not been claimed yet — WITHOUT consuming it.
1914
- *
1915
- * The panel has to know which SENSOR is about to set the alarm off before it
1916
- * decides whether to accept the trigger at all (an excluded contact does
1917
- * not), and asking must not spend the note: a trigger that is accepted still
1918
- * needs it to build the combined notification. Expired reads as absent, on
1919
- * the same bound {@link claim} uses.
1920
- */
1921
- pending(now) {
1922
- const noted = this.noted;
1923
- if (noted === null) return null;
1924
- if (now - noted.at > TRIGGER_ATTRIBUTION_TTL_MS) return null;
1925
- return noted.source;
1926
- }
1927
- /** Throw the un-claimed note away — the trigger it described was REFUSED, and
1928
- * a note left behind would be inherited by the next real one. */
1929
- dropPending() {
1930
- this.noted = null;
1931
- }
1932
1948
  /**
1933
- * Called by an ACCEPTED trigger, and by every accepted trigger — including
1934
- * one with no note, so a manual trigger clears whatever a rule left behind
1935
- * rather than inheriting it.
1949
+ * Called by EVERY accepted trigger with the source that pressed it — `null`
1950
+ * for a manual or external press, so it clears whatever a rule left behind
1951
+ * rather than inheriting it. A new countdown is a new incident: nothing the
1952
+ * last one collected rides it.
1936
1953
  */
1937
- claim(now) {
1938
- const noted = this.noted;
1939
- this.noted = null;
1954
+ claim(source) {
1955
+ this.claimed = source;
1940
1956
  this.followed = [];
1941
- if (noted === null) {
1942
- this.claimed = null;
1943
- return {
1944
- source: null,
1945
- expired: null
1946
- };
1947
- }
1948
- if (now - noted.at > TRIGGER_ATTRIBUTION_TTL_MS) {
1949
- this.claimed = null;
1950
- return {
1951
- source: null,
1952
- expired: noted.source
1953
- };
1954
- }
1955
- this.claimed = noted.source;
1956
- return {
1957
- source: noted.source,
1958
- expired: null
1959
- };
1960
1957
  }
1961
1958
  /**
1962
- * A trigger the panel REFUSED because its entry delay is already running (D705):
1963
- * the note it left is another subject of the SAME incident. Consumed either way —
1964
- * a note left behind would be claimed by the next, unrelated incident.
1959
+ * A rule press the panel REFUSED because its entry delay is already running
1960
+ * (D705): its source is another subject of the SAME incident.
1965
1961
  *
1966
1962
  * Only an incident a rule started collects followers: the combined row is the
1967
1963
  * source rule's, and promoting a follower to source would change the sentence.
1968
1964
  */
1969
- follow(now) {
1970
- const source = this.pending(now);
1971
- this.noted = null;
1972
- if (source === null) return { kind: "none" };
1965
+ follow(source) {
1973
1966
  const claimed = this.claimed;
1974
1967
  if (claimed === null) return {
1975
1968
  kind: "no-incident",
@@ -2223,7 +2216,8 @@ var ncAlarmPanelSchema = object({
2223
2216
  schedule: unknown().optional(),
2224
2217
  /** D706 — the mode's notification switches; absent = off. */
2225
2218
  notifyDuringEntryDelay: boolean().optional(),
2226
- notifySensorRewatched: boolean().optional()
2219
+ notifySensorRewatched: boolean().optional(),
2220
+ notifySensorIgnored: boolean().optional()
2227
2221
  })).default([]),
2228
2222
  /**
2229
2223
  * Active alarm configuration profile id. Empty / absent on a blob older than
@@ -2299,6 +2293,7 @@ function settingsBlobFromPersisted(raw) {
2299
2293
  if (rec["schedule"] !== void 0) entry.schedule = rec["schedule"];
2300
2294
  if (typeof rec["notifyDuringEntryDelay"] === "boolean") entry.notifyDuringEntryDelay = rec["notifyDuringEntryDelay"];
2301
2295
  if (typeof rec["notifySensorRewatched"] === "boolean") entry.notifySensorRewatched = rec["notifySensorRewatched"];
2296
+ if (typeof rec["notifySensorIgnored"] === "boolean") entry.notifySensorIgnored = rec["notifySensorIgnored"];
2302
2297
  modeDelaysLoose.push(entry);
2303
2298
  }
2304
2299
  const truncatedTriggered = Math.max(0, Math.min(3600, Math.trunc(triggered)));
@@ -2875,21 +2870,26 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2875
2870
  this.onPublish = hook;
2876
2871
  }
2877
2872
  /**
2878
- * "The next trigger is this rule's, about this subject."
2873
+ * A RULE pressed the panel, and says which match it was — in the call itself.
2879
2874
  *
2880
2875
  * The join between a rule's notification and the alarm's. `alarm-panel.trigger`
2881
2876
  * takes nothing but a device id — by design, since a Home Assistant panel
2882
- * provides the same cap — so the attribution cannot ride the cap call. It is
2883
- * handed over in-process instead, immediately before the actuation, and the
2884
- * panel stamps it onto the `alarm-triggered` event it eventually emits.
2885
- *
2886
- * The FIRST accepted trigger claims the note and owns the countdown; a rule
2877
+ * provides the same cap — so a rule press on THIS panel never goes through the
2878
+ * cap: the notification centre calls this method, in-process and
2879
+ * synchronously, with the match that pressed it. The source is never parked
2880
+ * anywhere for a later call to pick up: a single "next trigger is this rule's"
2881
+ * slot, written before an awaited cap call and read when it landed, was
2882
+ * overwritten by a second match 1 ms later, and the press that found it empty
2883
+ * skipped the exclusion check and sounded the siren off an excluded contact
2884
+ * (2026-10-07 02:32Z, D735).
2885
+ *
2886
+ * The FIRST accepted press claims the source and owns the countdown; a rule
2887
2887
  * pressing again while the entry delay runs is refused as a trigger and its
2888
- * note becomes a FOLLOWER of the same incident (D705) — the triggered
2888
+ * source becomes a FOLLOWER of the same incident (D705) — the triggered
2889
2889
  * notification tiles the source and every follower as one mosaic.
2890
2890
  */
2891
- noteRuleTrigger(source, now = Date.now()) {
2892
- this.attribution.note(source, now);
2891
+ triggerFromRule(source, now = Date.now()) {
2892
+ return this.press(source, now);
2893
2893
  }
2894
2894
  /**
2895
2895
  * Where the alarm stands for ONE rule match, read BEFORE that match presses the panel
@@ -2898,14 +2898,19 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2898
2898
  * sensor is ignored, a running countdown or a triggered alarm refuses the press, an armed
2899
2899
  * panel starts the countdown (or fires at once when the mode's entry delay is 0).
2900
2900
  *
2901
- * Two refinements make the answer exact rather than a guess:
2902
- * - the subject that STARTED the countdown is still its starter on its later matches (a
2903
- * person who stays in view is not "another intrusion during the delay");
2904
- * - a note left by a press that has not landed yet (same evaluation, two rules) already
2905
- * owns the countdown, so a second subject in that window is a follower.
2901
+ * The subject that STARTED the countdown is still its starter on its later matches (a
2902
+ * person who stays in view is not "another intrusion during the delay"). A rule press
2903
+ * lands synchronously ({@link triggerFromRule}), so the machine this reads already holds
2904
+ * every earlier press of the same evaluation — there is no press "in flight" to guess at.
2905
+ *
2906
+ * A match of a sensor the current arm EXCLUDES is `excluded`: its press will be ignored,
2907
+ * and the rule's own push is not sent either (D735) — the panel's bypass logic speaks for it.
2906
2908
  */
2907
2909
  classifyRuleMatch(ref, now = Date.now()) {
2908
- if (this.exclusions.blocks(intrusionDeviceOf(ref))) return { kind: "none" };
2910
+ if (this.exclusions.blocks(intrusionDeviceOf(ref))) return {
2911
+ kind: "excluded",
2912
+ sensorDeviceId: intrusionDeviceOf(ref)
2913
+ };
2909
2914
  const mode = armModeOf(this.machine.target, NC_ALARM_MODES);
2910
2915
  if (mode === null) return { kind: "none" };
2911
2916
  const state = stateAt(this.machine, now);
@@ -2927,17 +2932,7 @@ var NcAlarmPanelDevice = class extends BaseDevice {
2927
2932
  };
2928
2933
  }
2929
2934
  if (!isArmed(this.machine, now)) return { kind: "none" };
2930
- const immediate = this.delaysFor(mode).entryDelaySec === 0;
2931
- const inFlight = this.attribution.pending(now);
2932
- if (inFlight !== null && subjectKeyOf(inFlight) !== key) return immediate ? {
2933
- kind: "triggered",
2934
- mode
2935
- } : {
2936
- kind: "entry-delay",
2937
- mode,
2938
- notify: this.notifyPolicy(mode).notifyDuringEntryDelay
2939
- };
2940
- return immediate ? {
2935
+ return this.delaysFor(mode).entryDelaySec === 0 ? {
2941
2936
  kind: "triggers-now",
2942
2937
  mode
2943
2938
  } : {
@@ -3118,7 +3113,7 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3118
3113
  * sensor is already lifted when the rule it fires reaches the panel.
3119
3114
  */
3120
3115
  observeOpening(deviceId, reading, now = Date.now(), options = {}) {
3121
- const change = this.exclusions.observe(deviceId, reading, now);
3116
+ const change = this.exclusions.observe(deviceId, reading, now, options.changedAt);
3122
3117
  if (change === "unchanged") return "unchanged";
3123
3118
  this.persist();
3124
3119
  if (change === "closed") return "closed";
@@ -3168,13 +3163,12 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3168
3163
  event: "lifted"
3169
3164
  }, now);
3170
3165
  }
3171
- /** Did THIS sensor start the running entry delay (landed, or a press about to land)? */
3166
+ /** Did THIS sensor start the running entry delay? A rule press lands synchronously, so
3167
+ * a press of the evaluation that lifted it has already landed when this is asked. */
3172
3168
  startsEntryDelay(deviceId, now) {
3173
- const state = stateAt(this.machine, now);
3174
- const sensorOf = (ref) => ref !== null ? intrusionDeviceOf(ref) : void 0;
3175
- if (state === "pending") return sensorOf(this.attribution.current()) === deviceId;
3176
- if (!isArmed(this.machine, now) || state === "triggered") return false;
3177
- return sensorOf(this.attribution.pending(now)) === deviceId;
3169
+ if (stateAt(this.machine, now) !== "pending") return false;
3170
+ const starter = this.attribution.current();
3171
+ return starter !== null && intrusionDeviceOf(starter) === deviceId;
3178
3172
  }
3179
3173
  /**
3180
3174
  * A DELETED device leaves the current arm's exclusions: it can never report
@@ -3400,64 +3394,78 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3400
3394
  },
3401
3395
  disarm: async () => this.disarm(),
3402
3396
  trigger: async () => {
3403
- const now = Date.now();
3404
- const pending = this.attribution.pending(now);
3405
- const source = pending !== null ? intrusionDeviceOf(pending) : void 0;
3406
- if (source !== void 0 && this.exclusions.blocks(source)) {
3407
- this.attribution.dropPending();
3408
- this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
3409
- tags: { deviceId: source },
3410
- meta: {
3411
- panelDeviceId: this.id,
3412
- ruleId: pending?.ruleId,
3413
- rule: pending?.ruleName,
3414
- state: stateAt(this.machine, now)
3415
- }
3416
- });
3417
- this.announceIgnoredTrigger(source, now);
3418
- return;
3397
+ this.press(null, Date.now());
3398
+ }
3399
+ });
3400
+ }
3401
+ /**
3402
+ * THE ENTRY DELAY LIVES OR DIES HERE. The one body every press runs — a rule's
3403
+ * ({@link triggerFromRule}, with its source) and a person's (the cap, `null`).
3404
+ *
3405
+ * It used to set `fired: true` outright, calling itself "a panic button", while
3406
+ * the entry-delay transition sat in a method NO caller ever reached — so the
3407
+ * panel shipped with `pending` unreachable. The doc comment describing the right
3408
+ * design is precisely what made it look implemented.
3409
+ *
3410
+ * Synchronous, end to end: the source is a parameter, never a slot, so two
3411
+ * presses cannot exchange their sources however close together they land.
3412
+ */
3413
+ press(source, now) {
3414
+ const sensor = source !== null ? intrusionDeviceOf(source) : void 0;
3415
+ if (sensor !== void 0 && this.exclusions.blocks(sensor)) {
3416
+ const moved = this.exclusions.takeMoved(sensor);
3417
+ this.ctx.logger.info("alarm trigger ignored — the sensor was open when the alarm was armed", {
3418
+ tags: { deviceId: sensor },
3419
+ meta: {
3420
+ panelDeviceId: this.id,
3421
+ ruleId: source?.ruleId,
3422
+ rule: source?.ruleName,
3423
+ state: stateAt(this.machine, now),
3424
+ moved
3419
3425
  }
3420
- const armed = armModeOf(this.machine.target, NC_ALARM_MODES);
3421
- const resolved = this.delaysFor(armed);
3422
- const next = trigger(this.machine, resolved, now);
3423
- if (next === this.machine) {
3424
- const state = stateAt(this.machine, now);
3425
- this.ctx.logger.info(ignoredTriggerMessage(state), {
3426
- tags: { deviceId: this.id },
3427
- meta: { state }
3428
- });
3429
- if (state === "pending") {
3430
- this.reportFollow(this.attribution.follow(now));
3431
- return;
3432
- }
3433
- this.attribution.dropPending();
3434
- return;
3426
+ });
3427
+ this.announceIgnoredTrigger(sensor, now, moved);
3428
+ return "ignored-excluded";
3429
+ }
3430
+ const armed = armModeOf(this.machine.target, NC_ALARM_MODES);
3431
+ const resolved = this.delaysFor(armed);
3432
+ const next = trigger(this.machine, resolved, now);
3433
+ if (next === this.machine) {
3434
+ const state = stateAt(this.machine, now);
3435
+ this.ctx.logger.info(ignoredTriggerMessage(state), {
3436
+ tags: { deviceId: this.id },
3437
+ meta: {
3438
+ state,
3439
+ ...source !== null ? {
3440
+ ruleId: source.ruleId,
3441
+ rule: source.ruleName
3442
+ } : {}
3435
3443
  }
3436
- const claim = this.attribution.claim(now);
3437
- if (claim.expired !== null) this.ctx.logger.info("alarm trigger attribution expired — plain alarm notification", {
3438
- tags: { deviceId: this.id },
3439
- meta: {
3440
- ruleId: claim.expired.ruleId,
3441
- rule: claim.expired.ruleName
3442
- }
3443
- });
3444
- this.machine = next;
3445
- this.ctx.logger.info("alarm delays applied", {
3446
- tags: { deviceId: this.id },
3447
- meta: {
3448
- mode: armed,
3449
- phase: "trigger",
3450
- entryDelaySec: resolved.entryDelaySec,
3451
- triggeredDurationSec: resolved.triggeredDurationSec,
3452
- source: {
3453
- entry: resolved.source.entryDelaySec,
3454
- siren: resolved.source.triggeredDurationSec
3455
- }
3456
- }
3457
- });
3458
- this.publish(now);
3444
+ });
3445
+ if (state === "pending" && source !== null) this.reportFollow(this.attribution.follow(source));
3446
+ return "refused";
3447
+ }
3448
+ this.attribution.claim(source);
3449
+ this.machine = next;
3450
+ this.ctx.logger.info("alarm delays applied", {
3451
+ tags: { deviceId: this.id },
3452
+ meta: {
3453
+ mode: armed,
3454
+ phase: "trigger",
3455
+ entryDelaySec: resolved.entryDelaySec,
3456
+ triggeredDurationSec: resolved.triggeredDurationSec,
3457
+ source: {
3458
+ entry: resolved.source.entryDelaySec,
3459
+ siren: resolved.source.triggeredDurationSec
3460
+ },
3461
+ ...source !== null ? {
3462
+ ruleId: source.ruleId,
3463
+ rule: source.ruleName
3464
+ } : { by: "manual" }
3459
3465
  }
3460
3466
  });
3467
+ this.publish(now);
3468
+ return "accepted";
3461
3469
  }
3462
3470
  status(now = Date.now()) {
3463
3471
  return {
@@ -3538,7 +3546,6 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3538
3546
  * stated: the drop is work the operator would otherwise never see go missing.
3539
3547
  */
3540
3548
  reportFollow(outcome) {
3541
- if (outcome.kind === "none") return;
3542
3549
  const source = outcome.source;
3543
3550
  const meta = {
3544
3551
  panelDeviceId: this.id,
@@ -3567,13 +3574,15 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3567
3574
  });
3568
3575
  }
3569
3576
  /**
3570
- * A dropped trigger is TOLD, not only logged — an intrusion the alarm
3571
- * deliberately did not act on is what the operator most needs to hear — but
3572
- * ONCE per sensor per arm (the mark rides the persisted exclusion), and only
3573
- * while the panel is armed: during the exit delay `trigger()` would have
3574
- * dropped it anyway, so the exclusion is not what ignored it.
3577
+ * "Ignored" is TOLD only when the excluded sensor really MOVED while armed and the
3578
+ * mode's `notifySensorIgnored` switch is on (D735, default off) — and then ONCE per
3579
+ * sensor per arm (the mark rides the persisted exclusion), and only while the panel is
3580
+ * armed: during the exit delay `trigger()` would have dropped it anyway, so the
3581
+ * exclusion is not what ignored it. A contact that was open all along and was merely
3582
+ * re-published (a provider reconnect, a Zigbee2MQTT restart) moved nothing, and four
3583
+ * such pushes reached the operator in 60 ms on 2026-10-07. Every refusal is a line.
3575
3584
  */
3576
- announceIgnoredTrigger(deviceId, now) {
3585
+ announceIgnoredTrigger(deviceId, now, moved) {
3577
3586
  const state = stateAt(this.machine, now);
3578
3587
  if (!isArmed(this.machine, now)) {
3579
3588
  this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
@@ -3595,6 +3604,29 @@ var NcAlarmPanelDevice = class extends BaseDevice {
3595
3604
  });
3596
3605
  return;
3597
3606
  }
3607
+ if (!moved) {
3608
+ this.ctx.logger.debug("alarm bypass not announced — the sensor did not move since the arm", {
3609
+ tags: { deviceId },
3610
+ meta: {
3611
+ panelDeviceId: this.id,
3612
+ state,
3613
+ reason: "not-moved"
3614
+ }
3615
+ });
3616
+ return;
3617
+ }
3618
+ const mode = armModeOf(this.machine.target, NC_ALARM_MODES);
3619
+ if (mode === null || !this.notifyPolicy(mode).notifySensorIgnored) {
3620
+ this.ctx.logger.info("alarm \"ignored\" not notified — the mode does not notify it", {
3621
+ tags: { deviceId },
3622
+ meta: {
3623
+ panelDeviceId: this.id,
3624
+ mode,
3625
+ reason: "ignored-notify-off"
3626
+ }
3627
+ });
3628
+ return;
3629
+ }
3598
3630
  if (!this.exclusions.markAnnounced(deviceId)) {
3599
3631
  this.ctx.logger.debug("alarm bypass already announced in this arm — repeat not sent", {
3600
3632
  tags: { deviceId },
@@ -3752,7 +3784,7 @@ function ncAlarmPanelPort(panel) {
3752
3784
  deleteProfile: (id) => panel.deleteProfile(id),
3753
3785
  activateProfile: (id) => panel.activateProfile(id),
3754
3786
  availableModes: () => NC_ALARM_MODES,
3755
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
3787
+ triggerFromRule: (source) => panel.triggerFromRule(source),
3756
3788
  classifyRuleMatch: (source) => panel.classifyRuleMatch(source),
3757
3789
  announceLifted: (deviceId, now) => panel.announceLifted(deviceId, now),
3758
3790
  excludedDevices: () => panel.excludedDevices(),
@@ -4658,22 +4690,26 @@ var HUB_API_PORT = 4443;
4658
4690
  * preferred LAN interface priority `0`, so a public candidate has to sit below
4659
4691
  * zero to outrank it.
4660
4692
  */
4661
- var PRIORITY_MARKED = -400;
4693
+ var PRIORITY_PUBLIC_ENDPOINT = -400;
4662
4694
  var PRIORITY_CONNECTED = -200;
4663
4695
  var PRIORITY_EXTERNAL_LIST = -100;
4664
4696
  /**
4665
- * Build the ranked candidate list, in the operator's own order: the address
4666
- * marked on the Network page, then the connected external ingress (tunnels),
4667
- * then the LAN addresses. Every source is best-effort — one that throws
4668
- * contributes nothing rather than costing the whole list.
4697
+ * Build the ranked candidate list for every link a notification carries
4698
+ * (buttons, attachments / media, admin links): the hub's PUBLIC ENDPOINT
4699
+ * (D734), then — only reached when that is unknown — the connected external
4700
+ * ingress (tunnels), then the LAN addresses. Every source is best-effort — one
4701
+ * that throws contributes nothing rather than costing the whole list.
4702
+ *
4703
+ * The notification-taps address is deliberately NOT an input: it decides only
4704
+ * where a tap opens, and its default (the app link) is no hub address.
4669
4705
  */
4670
4706
  async function collectArtifactEndpoints(sources) {
4671
4707
  const out = [];
4672
- const marked = sources.markedBaseUrl;
4673
- if (marked !== void 0 && marked !== "" && !isAppLinkBase(marked)) out.push({
4674
- baseUrl: marked,
4708
+ const publicEndpoint = sources.publicEndpoint;
4709
+ if (publicEndpoint !== void 0 && publicEndpoint !== "" && !isAppLinkBase(publicEndpoint)) out.push({
4710
+ baseUrl: publicEndpoint,
4675
4711
  kind: "public",
4676
- priority: PRIORITY_MARKED
4712
+ priority: PRIORITY_PUBLIC_ENDPOINT
4677
4713
  });
4678
4714
  try {
4679
4715
  const connected = await sources.getConnected();
@@ -4908,6 +4944,49 @@ var NcArtifactPlane = class {
4908
4944
  }
4909
4945
  };
4910
4946
  //#endregion
4947
+ //#region src/notification-center/link-endpoints.ts
4948
+ async function publicEndpointAddress(api, logger) {
4949
+ try {
4950
+ const { roles } = await api.localNetwork.getAddresses.query({});
4951
+ return roles.publicEndpoint.resolved ?? void 0;
4952
+ } catch (err) {
4953
+ logger.warn("nc links: public endpoint unreadable — using the ingress ladder", { meta: { error: err instanceof Error ? err.message : String(err) } });
4954
+ return;
4955
+ }
4956
+ }
4957
+ /** The ranked base-URL candidates for a notification's non-tap links. */
4958
+ async function notificationLinkEndpoints(api, logger) {
4959
+ return collectArtifactEndpoints({
4960
+ publicEndpoint: await publicEndpointAddress(api, logger),
4961
+ getConnected: async () => {
4962
+ const status = await api.networkAccess.getStatus.query();
4963
+ logger.debug("artifact base-url: connected ingress", { meta: {
4964
+ connected: status.connected,
4965
+ url: status.endpoint?.url ?? null,
4966
+ protocol: status.endpoint?.protocol ?? null
4967
+ } });
4968
+ return status.connected && status.endpoint !== null ? {
4969
+ url: status.endpoint.url,
4970
+ protocol: status.endpoint.protocol
4971
+ } : null;
4972
+ },
4973
+ listExternal: async () => {
4974
+ return (await api.networkAccess.listEndpoints.query()).map((e) => ({
4975
+ url: e.url,
4976
+ protocol: e.protocol
4977
+ }));
4978
+ },
4979
+ listLan: async (port) => {
4980
+ return (await api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.flatMap((e) => e.kind === "app-link" || e.kind === "custom" ? [] : [{
4981
+ baseUrl: e.baseUrl,
4982
+ kind: e.kind,
4983
+ priority: e.priority
4984
+ }]);
4985
+ },
4986
+ logger
4987
+ });
4988
+ }
4989
+ //#endregion
4911
4990
  //#region src/notification-center/artifact-read-plane.ts
4912
4991
  /**
4913
4992
  * Parse a single-range `Range` header against a known length.
@@ -5364,158 +5443,6 @@ function mimeFromExtension(file) {
5364
5443
  return "application/octet-stream";
5365
5444
  }
5366
5445
  //#endregion
5367
- //#region src/notification-center/system-event-tap-url.ts
5368
- /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
5369
- var ADMIN_ADDONS_PATH = "/system/addons";
5370
- var ADMIN_CLUSTER_PATH = "/system/cluster";
5371
- var ADMIN_DEVICES_PATH = "/devices";
5372
- /**
5373
- * The query parameter naming a node.
5374
- *
5375
- * One spelling for both admin pages, mirrored by `NODE_PARAM` in
5376
- * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
5377
- * the two packages by `scripts/check-notification-tap-targets.ts`.
5378
- */
5379
- var NODE_PARAM = "node";
5380
- /** The device page's top-level tab parameter, read by `DeviceDetail`. */
5381
- var TAB_PARAM = "tab";
5382
- /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
5383
- var STREAM_BROKER_TAB = "stream-broker";
5384
- /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
5385
- * string map with no device-model dependency; the guard pins it. */
5386
- var CAMERA_DEVICE_TYPE = "camera";
5387
- /** Kinds whose action is "go and apply an ADDON update". */
5388
- var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
5389
- /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
5390
- var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
5391
- /**
5392
- * Kinds whose action is "go and look at this device's stream".
5393
- *
5394
- * BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
5395
- * because "did it really come back, and what did it do while it was gone" is
5396
- * an action and the page that answers it is the one the down-notification
5397
- * already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
5398
- * Videocamera sala feste è online" with `tapAction.value: null` — the same
5399
- * camera's `device-offline` an hour earlier opened the Stream Broker tab, and
5400
- * the recovery opened nothing, purely because `device-online` had no entry
5401
- * here.
5402
- *
5403
- * The legacy `camera-*` pair is here for the same reason both are still in the
5404
- * enum: outbox and history rows enqueued before the rename carry them, and an
5405
- * old row is exactly the one whose action has to keep working.
5406
- *
5407
- * Kinds NOT here are kinds whose DOWN direction has no action either
5408
- * (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
5409
- * recovery does not earn a destination its failure was never given.
5410
- */
5411
- var DEVICE_STREAM_KINDS = new Set([
5412
- "device-offline",
5413
- "device-online",
5414
- "stream-offline",
5415
- "stream-online",
5416
- "camera-offline",
5417
- "camera-online"
5418
- ]);
5419
- /**
5420
- * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
5421
- *
5422
- * A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
5423
- * too — that name is precisely the claim it made, and it is why the kind was
5424
- * split into `device-*` + a `deviceTypes` condition.
5425
- */
5426
- var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
5427
- "stream-offline",
5428
- "stream-online",
5429
- "camera-offline",
5430
- "camera-online"
5431
- ]);
5432
- /**
5433
- * Where a per-section STATE SUMMARY row lands (D708). The row spans nodes, so
5434
- * no `?node=`: the unscoped page is the honest one. A section absent here taps
5435
- * through its kind, like any other row — a device section carries the system
5436
- * placeholder device and gets no link, and the crash-looped addons have none
5437
- * (their instant kind has none either).
5438
- *
5439
- * `nodes` is the one destination no kind gives: a node going offline has no
5440
- * instant action, but "which nodes are in trouble" is answered by the
5441
- * Cluster page, and the summary row is about all of them at once.
5442
- */
5443
- var SUMMARY_SECTION_PATHS = {
5444
- nodes: ADMIN_CLUSTER_PATH,
5445
- "addon-updates": ADMIN_ADDONS_PATH,
5446
- updates: ADMIN_CLUSTER_PATH
5447
- };
5448
- function withParam(path, key, value) {
5449
- if (value === void 0 || value.length === 0) return path;
5450
- return `${path}?${key}=${encodeURIComponent(value)}`;
5451
- }
5452
- /**
5453
- * The admin path (with its query) a system event taps through to, or `null`
5454
- * when the kind has no action.
5455
- *
5456
- * Exported for the cross-package guard and for tests that want the path
5457
- * without a base; the dispatcher goes through {@link systemEventTapUrl}.
5458
- */
5459
- function systemEventTapPath(subject) {
5460
- const sectionPath = subject.summarySection !== void 0 ? SUMMARY_SECTION_PATHS[subject.summarySection] : void 0;
5461
- if (sectionPath !== void 0) return sectionPath;
5462
- if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
5463
- if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
5464
- if (DEVICE_STREAM_KINDS.has(subject.kind)) {
5465
- const deviceId = subject.deviceId;
5466
- if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
5467
- const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
5468
- return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
5469
- }
5470
- return null;
5471
- }
5472
- /** Full URL, or `null` when the kind has no action or no base is reachable. */
5473
- function systemEventTapUrl(subject, baseUrl) {
5474
- if (baseUrl === null || baseUrl.length === 0) return null;
5475
- const path = systemEventTapPath(subject);
5476
- if (path === null) return null;
5477
- return `${baseUrl.replace(/\/+$/, "")}${path}`;
5478
- }
5479
- //#endregion
5480
- //#region src/notification-center/default-tap-url.ts
5481
- /**
5482
- * The app-link choice from the flagged notification address — present when
5483
- * the effective address is the app link. Reads the answer through
5484
- * `viewerLinkBaseOf`, the same reading the Network page's preview uses.
5485
- */
5486
- function ncAppLinkChoiceOf(endpoint) {
5487
- const base = viewerLinkBaseOf(endpoint);
5488
- return base.form === "app" ? { hubBaseUrl: base.hubBaseUrl } : null;
5489
- }
5490
- /**
5491
- * The tap URL, or null. In the app form a camera link is minted even with no
5492
- * `hub=` — the central page reaches the hub through the app's (or the
5493
- * browser's) own stored hub (central-link design §5). The hub form and every
5494
- * admin destination still need a base.
5495
- */
5496
- function defaultTapUrl(input) {
5497
- const baseUrl = input.baseUrl === null || input.baseUrl.length === 0 ? null : input.baseUrl;
5498
- if (input.systemEvent !== void 0) return baseUrl === null ? null : systemEventTapUrl(input.systemEvent, baseUrl);
5499
- const destination = {
5500
- kind: "camera",
5501
- deviceId: input.deviceId,
5502
- atMs: input.timestampMs
5503
- };
5504
- return input.appLink !== void 0 ? buildViewerLink({
5505
- base: {
5506
- form: "app",
5507
- hubBaseUrl: input.appLink.hubBaseUrl
5508
- },
5509
- destination
5510
- }) : buildViewerLink({
5511
- base: {
5512
- form: "hub",
5513
- hubBaseUrl: baseUrl
5514
- },
5515
- destination
5516
- });
5517
- }
5518
- //#endregion
5519
5446
  //#region src/notification-center/prune-windows.ts
5520
5447
  /**
5521
5448
  * The operator-configurable prune windows, in days. One module, because the
@@ -14933,7 +14860,8 @@ var NcGroupMemberFateSchema = union([_enum([
14933
14860
  "already-existed",
14934
14861
  "close-failed",
14935
14862
  "alarm-entry-delay-started",
14936
- "alarm-entry-delay"
14863
+ "alarm-entry-delay",
14864
+ "alarm-sensor-excluded"
14937
14865
  ]), templateLiteral(["failed-condition:", string()])]);
14938
14866
  /** Read cap, so a client cannot ask for the whole ring in one call. */
14939
14867
  var MAX_QUERY_LIMIT = 200;
@@ -23037,6 +22965,143 @@ function withSummaryBody(rule, section) {
23037
22965
  };
23038
22966
  }
23039
22967
  //#endregion
22968
+ //#region src/notification-center/system-event-tap-url.ts
22969
+ /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
22970
+ var ADMIN_ADDONS_PATH = "/system/addons";
22971
+ var ADMIN_CLUSTER_PATH = "/system/cluster";
22972
+ var ADMIN_DEVICES_PATH = "/devices";
22973
+ /**
22974
+ * The query parameter naming a node.
22975
+ *
22976
+ * One spelling for both admin pages, mirrored by `NODE_PARAM` in
22977
+ * `packages/addon-admin-ui/src/lib/node-url-selection.ts` and pinned across
22978
+ * the two packages by `scripts/check-notification-tap-targets.ts`.
22979
+ */
22980
+ var NODE_PARAM = "node";
22981
+ /** The device page's top-level tab parameter, read by `DeviceDetail`. */
22982
+ var TAB_PARAM = "tab";
22983
+ /** The Stream Broker tab id — a `WELL_KNOWN_TABS` member, hoisted `top-tab`. */
22984
+ var STREAM_BROKER_TAB = "stream-broker";
22985
+ /** `DeviceType.Camera`. Spelled here rather than imported so this stays a pure
22986
+ * string map with no device-model dependency; the guard pins it. */
22987
+ var CAMERA_DEVICE_TYPE = "camera";
22988
+ /** Kinds whose action is "go and apply an ADDON update". */
22989
+ var ADDON_UPDATE_KINDS = new Set(["addon-update-available"]);
22990
+ /** Kinds whose action is "go and apply a NODE's own (`@camstack/server`) update". */
22991
+ var NODE_UPDATE_KINDS = new Set(["server-update-available", "wrapper-update-available"]);
22992
+ /**
22993
+ * Kinds whose action is "go and look at this device's stream".
22994
+ *
22995
+ * BOTH DIRECTIONS. A recovery lands on the same page as the failure it undoes,
22996
+ * because "did it really come back, and what did it do while it was gone" is
22997
+ * an action and the page that answers it is the one the down-notification
22998
+ * already opened. On 2026-08-30 the operator's phone showed "Il dispositivo
22999
+ * Videocamera sala feste è online" with `tapAction.value: null` — the same
23000
+ * camera's `device-offline` an hour earlier opened the Stream Broker tab, and
23001
+ * the recovery opened nothing, purely because `device-online` had no entry
23002
+ * here.
23003
+ *
23004
+ * The legacy `camera-*` pair is here for the same reason both are still in the
23005
+ * enum: outbox and history rows enqueued before the rename carry them, and an
23006
+ * old row is exactly the one whose action has to keep working.
23007
+ *
23008
+ * Kinds NOT here are kinds whose DOWN direction has no action either
23009
+ * (`node-online`/`node-offline`, `device-enabled`/`device-disabled`) — a
23010
+ * recovery does not earn a destination its failure was never given.
23011
+ */
23012
+ var DEVICE_STREAM_KINDS = new Set([
23013
+ "device-offline",
23014
+ "device-online",
23015
+ "stream-offline",
23016
+ "stream-online",
23017
+ "camera-offline",
23018
+ "camera-online"
23019
+ ]);
23020
+ /**
23021
+ * Kinds whose subject is a camera BY CONSTRUCTION, whatever the row says.
23022
+ *
23023
+ * A stream belongs to a camera and nothing else. The legacy `camera-*` pair is
23024
+ * too — that name is precisely the claim it made, and it is why the kind was
23025
+ * split into `device-*` + a `deviceTypes` condition.
23026
+ */
23027
+ var CAMERA_BY_CONSTRUCTION_KINDS = new Set([
23028
+ "stream-offline",
23029
+ "stream-online",
23030
+ "camera-offline",
23031
+ "camera-online"
23032
+ ]);
23033
+ /**
23034
+ * Where a per-section STATE SUMMARY row lands (D708). The row spans nodes, so
23035
+ * no `?node=`: the unscoped page is the honest one. A section absent here taps
23036
+ * through its kind, like any other row — a device section carries the system
23037
+ * placeholder device and gets no link, and the crash-looped addons have none
23038
+ * (their instant kind has none either).
23039
+ *
23040
+ * `nodes` is the one destination no kind gives: a node going offline has no
23041
+ * instant action, but "which nodes are in trouble" is answered by the
23042
+ * Cluster page, and the summary row is about all of them at once.
23043
+ */
23044
+ var SUMMARY_SECTION_PATHS = {
23045
+ nodes: ADMIN_CLUSTER_PATH,
23046
+ "addon-updates": ADMIN_ADDONS_PATH,
23047
+ updates: ADMIN_CLUSTER_PATH
23048
+ };
23049
+ function withParam(path, key, value) {
23050
+ if (value === void 0 || value.length === 0) return path;
23051
+ return `${path}?${key}=${encodeURIComponent(value)}`;
23052
+ }
23053
+ /**
23054
+ * The admin path (with its query) a system event taps through to, or `null`
23055
+ * when the kind has no action.
23056
+ *
23057
+ * Exported for the cross-package guard and for tests that want the path
23058
+ * without a base; the dispatcher goes through {@link systemEventTapUrl}.
23059
+ */
23060
+ function systemEventTapPath(subject) {
23061
+ const sectionPath = subject.summarySection !== void 0 ? SUMMARY_SECTION_PATHS[subject.summarySection] : void 0;
23062
+ if (sectionPath !== void 0) return sectionPath;
23063
+ if (ADDON_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_ADDONS_PATH, NODE_PARAM, subject.nodeId);
23064
+ if (NODE_UPDATE_KINDS.has(subject.kind)) return withParam(ADMIN_CLUSTER_PATH, NODE_PARAM, subject.nodeId);
23065
+ if (DEVICE_STREAM_KINDS.has(subject.kind)) {
23066
+ const deviceId = subject.deviceId;
23067
+ if (deviceId === void 0 || !Number.isInteger(deviceId) || deviceId <= 0) return null;
23068
+ const devicePath = `${ADMIN_DEVICES_PATH}/${String(deviceId)}`;
23069
+ return CAMERA_BY_CONSTRUCTION_KINDS.has(subject.kind) || subject.deviceType === CAMERA_DEVICE_TYPE ? withParam(devicePath, TAB_PARAM, STREAM_BROKER_TAB) : devicePath;
23070
+ }
23071
+ return null;
23072
+ }
23073
+ /** Full URL, or `null` when the kind has no action or no base is reachable. */
23074
+ function systemEventTapUrl(subject, baseUrl) {
23075
+ if (baseUrl === null || baseUrl.length === 0) return null;
23076
+ const path = systemEventTapPath(subject);
23077
+ if (path === null) return null;
23078
+ return `${baseUrl.replace(/\/+$/, "")}${path}`;
23079
+ }
23080
+ //#endregion
23081
+ //#region src/notification-center/default-tap-url.ts
23082
+ /**
23083
+ * The tap URL, or null. In the app form a camera link is minted even with no
23084
+ * `hub=` — the central page reaches the hub through the app's (or the
23085
+ * browser's) own stored hub (central-link design §5). The hub form and every
23086
+ * admin destination still need a base.
23087
+ */
23088
+ function defaultTapUrl(input) {
23089
+ const publicBaseUrl = input.publicBaseUrl === null || input.publicBaseUrl.length === 0 ? null : input.publicBaseUrl;
23090
+ if (input.systemEvent !== void 0) return publicBaseUrl === null ? null : systemEventTapUrl(input.systemEvent, publicBaseUrl);
23091
+ const destination = {
23092
+ kind: "camera",
23093
+ deviceId: input.deviceId,
23094
+ atMs: input.timestampMs
23095
+ };
23096
+ return buildViewerLink({
23097
+ base: input.tap ?? {
23098
+ form: "hub",
23099
+ hubBaseUrl: publicBaseUrl
23100
+ },
23101
+ destination
23102
+ });
23103
+ }
23104
+ //#endregion
23040
23105
  //#region src/notification-center/footage-source.ts
23041
23106
  /**
23042
23107
  * Decide where this window must be cut from. Pure and total.
@@ -25534,20 +25599,19 @@ var NcDispatcher = class {
25534
25599
  * repo keeps paying for.
25535
25600
  */
25536
25601
  /**
25537
- * The app-link half of a viewer tap (D709), as a spreadable fragment. A
25538
- * failing read falls back to the hub form — the tap every notification had
25539
- * before D709, which still opens — and is logged, so a tap that lands on the
25540
- * hub instead of the app has a line saying why.
25602
+ * The `notificationTaps` base of a camera tap (D734). A failing read is
25603
+ * `null` — the tap then opens the hub viewer on the public endpoint — and is
25604
+ * logged, so a tap that lands somewhere other than the flagged address has a
25605
+ * line saying why.
25541
25606
  */
25542
- async resolveViewerAppLink() {
25543
- const read = this.deps.viewerAppLink;
25544
- if (read === void 0) return {};
25607
+ async resolveTapBase() {
25608
+ const read = this.deps.tapBase;
25609
+ if (read === void 0) return null;
25545
25610
  try {
25546
- const choice = await read();
25547
- return choice === null ? {} : { appLink: choice };
25611
+ return await read();
25548
25612
  } catch (err) {
25549
- this.deps.logger.warn("notification link address unreadable — tap uses the hub address", { meta: { error: err instanceof Error ? err.message : String(err) } });
25550
- return {};
25613
+ this.deps.logger.warn("notification tap address unreadable — tap uses the public endpoint", { meta: { error: err instanceof Error ? err.message : String(err) } });
25614
+ return null;
25551
25615
  }
25552
25616
  }
25553
25617
  async mintActions(entry, target) {
@@ -25641,11 +25705,12 @@ var NcDispatcher = class {
25641
25705
  const resolvedMedia = await this.withArtifactUrls(entry, resolved.attachments);
25642
25706
  const attachments = [...resolvedMedia.attachments, ...frozenArtifactAttachments(entry)];
25643
25707
  const params = pickParams(entry.payload.params);
25644
- const tapUrl = params.clickUrl === void 0 && this.deps.viewerUrlBase !== void 0 ? defaultTapUrl({
25708
+ const publicUrlBase = this.deps.publicUrlBase;
25709
+ const tapUrl = params.clickUrl === void 0 && publicUrlBase !== void 0 ? defaultTapUrl({
25645
25710
  deviceId: systemEvent !== void 0 ? 0 : subject.deviceId,
25646
25711
  timestampMs: subject.timestamp,
25647
- baseUrl: await this.deps.viewerUrlBase().catch(() => null),
25648
- ...systemEvent === void 0 ? await this.resolveViewerAppLink() : {},
25712
+ publicBaseUrl: await publicUrlBase().catch(() => null),
25713
+ tap: systemEvent === void 0 ? await this.resolveTapBase() : null,
25649
25714
  ...systemEvent !== void 0 ? { systemEvent: {
25650
25715
  kind: systemEvent.kind,
25651
25716
  ...systemEvent.deviceId !== void 0 ? { deviceId: systemEvent.deviceId } : {},
@@ -39808,7 +39873,8 @@ var NotificationCenter = class {
39808
39873
  ruleAiStats: () => ruleAiAnalyst.stats()
39809
39874
  } : {},
39810
39875
  releaseCooldown: (input) => this.releaseCooldown(input),
39811
- ...deps.dispatcher.viewerUrlBase !== void 0 ? { viewerUrlBase: deps.dispatcher.viewerUrlBase } : {}
39876
+ ...deps.dispatcher.publicUrlBase !== void 0 ? { publicUrlBase: deps.dispatcher.publicUrlBase } : {},
39877
+ ...deps.dispatcher.tapBase !== void 0 ? { tapBase: deps.dispatcher.tapBase } : {}
39812
39878
  });
39813
39879
  this.outbox = new NcOutbox({
39814
39880
  store: deps.store,
@@ -40806,7 +40872,11 @@ var NotificationCenter = class {
40806
40872
  if (panel === null) return "unchanged";
40807
40873
  const opening = alarmOpeningFromSensorEvent(event.kind, event.value);
40808
40874
  if (opening.kind !== "reading") return "unchanged";
40809
- return panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp, { deferLiftAnnouncement: true });
40875
+ const changedAt = event.value?.["lastChangedAt"];
40876
+ return panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp, {
40877
+ deferLiftAnnouncement: true,
40878
+ ...typeof changedAt === "number" ? { changedAt } : {}
40879
+ });
40810
40880
  }
40811
40881
  /**
40812
40882
  * "Watched again", once the event that lifted the sensor has been EVALUATED (D706).
@@ -42293,23 +42363,50 @@ var NotificationCenter = class {
42293
42363
  });
42294
42364
  }
42295
42365
  /**
42296
- * Arm the attribution for a `alarm-panel.trigger` this rule is about to make,
42297
- * then delegate.
42366
+ * The one actuation seam of this centre — a rule's sequence step, a granted button.
42367
+ *
42368
+ * A press of THIS node's panel `trigger` never reaches the actuator: with a match
42369
+ * (`origin`) it is {@link NcAlarmPanelPort.triggerFromRule}, synchronously, the source
42370
+ * in the call. The join is HERE rather than in the runner: the runner actuates devices
42371
+ * generically and must not learn what an alarm is, while this class holds the panel.
42298
42372
  *
42299
- * The join point, and it is deliberately HERE rather than in the runner: the
42300
- * runner actuates devices generically and must not learn what an alarm is,
42301
- * while this class already holds the panel. The note is placed immediately
42302
- * before the call it describes, so the window it is valid for is the call
42303
- * itself.
42373
+ * Without a match, the press has no sensor to check against the arm's exclusions, so
42374
+ * while the arm EXCLUDES anything it is REFUSED (fail-closed, D735): an anonymous press
42375
+ * from this seam is exactly what sounded the siren off an excluded contact on
42376
+ * 2026-10-07. A person's manual trigger (the panel's cap, from the UI) does not come
42377
+ * through here and is unaffected.
42304
42378
  */
42305
42379
  async actuateWithAttribution(input) {
42380
+ const panel = this.alarmPanel;
42381
+ if (panel !== null && input.deviceId === panel.deviceId && input.cap === ALARM_PANEL_CAP && input.method === "trigger" && this.pressLocalPanel(panel, input.origin)) return;
42306
42382
  const actuate = this.deps.actuate;
42307
42383
  if (actuate === void 0) throw new Error("no actuator is wired on this node");
42308
- const panel = this.alarmPanel;
42309
- if (panel !== null && input.origin !== void 0 && input.deviceId === panel.deviceId && input.cap === ALARM_PANEL_CAP && input.method === "trigger") panel.noteRuleTrigger(input.origin);
42310
42384
  await actuate(input);
42311
42385
  }
42312
42386
  /**
42387
+ * A press of this node's panel from the actuation seam; true when it was handled here.
42388
+ * With a source: the panel's rule press. Without one: refused while the arm excludes any
42389
+ * sensor — an ERROR line and a throw, so the sequence step reports failed instead of
42390
+ * looking done. Without one and nothing excluded: false, and the press takes the cap
42391
+ * like any manual press (there is no sensor whose exclusion it could skip).
42392
+ */
42393
+ pressLocalPanel(panel, origin) {
42394
+ if (origin !== void 0) {
42395
+ panel.triggerFromRule(origin);
42396
+ return true;
42397
+ }
42398
+ const excluded = panel.excludedDevices();
42399
+ if (excluded.length === 0) return false;
42400
+ this.logger.error("alarm trigger REFUSED — a rule-action press carried no source while the arm excludes sensors", {
42401
+ tags: { deviceId: panel.deviceId },
42402
+ meta: {
42403
+ excluded: excluded.join(","),
42404
+ state: panel.currentState()
42405
+ }
42406
+ });
42407
+ throw new Error("alarm trigger refused: no source, and the current arm excludes sensors");
42408
+ }
42409
+ /**
42313
42410
  * Device names from the in-process directory — no I/O, so the arm sentence
42314
42411
  * can be composed on the publish path.
42315
42412
  *
@@ -43197,13 +43294,9 @@ var NotificationCenter = class {
43197
43294
  }
43198
43295
  const origin = fireRefFor(rule, subject, kind);
43199
43296
  const phase = panel.classifyRuleMatch(origin);
43200
- this.actuateWithAttribution({
43201
- deviceId: panel.deviceId,
43202
- cap: ALARM_PANEL_CAP,
43203
- method: "trigger",
43204
- args: {},
43205
- origin
43206
- }).catch((err) => {
43297
+ try {
43298
+ panel.triggerFromRule(origin);
43299
+ } catch (err) {
43207
43300
  this.logger.warn("alarm panel auto-trigger failed", {
43208
43301
  tags: { deviceId: subject.deviceId },
43209
43302
  meta: {
@@ -43213,7 +43306,7 @@ var NotificationCenter = class {
43213
43306
  error: err instanceof Error ? err.message : String(err)
43214
43307
  }
43215
43308
  });
43216
- });
43309
+ }
43217
43310
  return phase;
43218
43311
  }
43219
43312
  /**
@@ -101503,9 +101596,15 @@ async function projectSensorMarkers(deps, data, timestamp) {
101503
101596
  *
101504
101597
  * - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
101505
101598
  * (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
101506
- * provider drops the `unavailable` push and writes the recovery, whose
101507
- * `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
101508
- * `last_changed`. The mirror diffs, and emits.
101599
+ * provider dropped the `unavailable` push and wrote the recovery, whose
101600
+ * `entryOpen` was still `false` and whose `lastChangedAt` was HA's fresh
101601
+ * `last_changed`. The mirror diffed, and emitted. Since D735 the provider
101602
+ * keeps the change time of a value that did not change
101603
+ * (`addon-provider-homeassistant` `ha-change-stamp.ts`), because this gate
101604
+ * cannot fix the ACTIVE half: with its memory empty (a hub restart), a
101605
+ * recovery `on` with a fresh stamp is indistinguishable here from an
101606
+ * opening — that is how four excluded contacts pressed the armed panel on
101607
+ * 2026-10-07 02:32Z.
101509
101608
  * - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
101510
101609
  * `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
101511
101610
  * real state lands seconds later with a `lastChangedAt` hours old. Two
@@ -106595,51 +106694,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
106595
106694
  };
106596
106695
  }
106597
106696
  }
106598
- /** The operator's marked notification endpoint, or null (AUTO / unavailable). */
106599
- async markedNotificationEndpoint() {
106600
- try {
106601
- return (await this.ctx.api.localNetwork.getNotificationEndpoint.query()).baseUrl ?? void 0;
106602
- } catch {
106603
- return;
106604
- }
106605
- }
106606
106697
  /**
106607
- * The ranked base-URL candidates a notification LINK may use.
106698
+ * The ranked base-URL candidates a notification LINK may use — every link
106699
+ * except the tap: the hub's public endpoint (D734, `link-endpoints.ts`).
106608
106700
  *
106609
- * Shared by the artifact plane (media) and the action plane (buttons) — one
106610
- * resolver, because a second copy would drift and the two would start
106611
- * minting links on different hosts for the same notification.
106701
+ * Shared by the artifact plane (media), the action plane (buttons) and the
106702
+ * admin page a system event taps into — one resolver, because a second copy
106703
+ * would drift and the planes would start minting links on different hosts
106704
+ * for the same notification.
106612
106705
  */
106613
106706
  async ncEndpoints() {
106614
- return collectArtifactEndpoints({
106615
- markedBaseUrl: await this.markedNotificationEndpoint(),
106616
- getConnected: async () => {
106617
- const status = await this.ctx.api.networkAccess.getStatus.query();
106618
- this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
106619
- connected: status.connected,
106620
- url: status.endpoint?.url ?? null,
106621
- protocol: status.endpoint?.protocol ?? null
106622
- } });
106623
- return status.connected && status.endpoint !== null ? {
106624
- url: status.endpoint.url,
106625
- protocol: status.endpoint.protocol
106626
- } : null;
106627
- },
106628
- listExternal: async () => {
106629
- return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
106630
- url: e.url,
106631
- protocol: e.protocol
106632
- }));
106633
- },
106634
- listLan: async (port) => {
106635
- return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.flatMap((e) => e.kind === "app-link" || e.kind === "custom" ? [] : [{
106636
- baseUrl: e.baseUrl,
106637
- kind: e.kind,
106638
- priority: e.priority
106639
- }]);
106640
- },
106641
- logger: this.ctx.logger
106642
- });
106707
+ return notificationLinkEndpoints(this.ctx.api, this.ctx.logger);
106643
106708
  }
106644
106709
  /**
106645
106710
  * Serve the NC ACTION plane: the public route a notification button lands on.
@@ -109225,8 +109290,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
109225
109290
  resolveFfmpeg: () => this.ctx.deps.ensureFfmpeg()
109226
109291
  }), req),
109227
109292
  publishArtifact: async (bytes, mime, deviceId) => await this.ncArtifactPlane?.publish(bytes, mime, deviceId) ?? null,
109228
- viewerUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
109229
- viewerAppLink: async () => ncAppLinkChoiceOf(await this.ctx.api.localNetwork.getNotificationEndpoint.query()),
109293
+ publicUrlBase: async () => pickArtifactBaseUrl(await this.ncEndpoints()),
109294
+ tapBase: async () => viewerLinkBaseOf(await this.ctx.api.localNetwork.getNotificationEndpoint.query()),
109230
109295
  indexArtifact: async (bytes, mime, meta) => await this.ncArtifactPlane?.publishIndexed(bytes, mime, meta) ?? {
109231
109296
  url: null,
109232
109297
  artifactId: null