@riddix/hamh 2.1.0-alpha.855 → 2.1.0-alpha.856

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.
@@ -136588,7 +136588,7 @@ import * as nodePath from "node:path";
136588
136588
  import express14 from "express";
136589
136589
 
136590
136590
  // src/plugins/builtin/names.ts
136591
- var BUILTIN_PLUGIN_NAMES = ["camera"];
136591
+ var BUILTIN_PLUGIN_NAMES = ["camera", "security"];
136592
136592
 
136593
136593
  // src/plugins/plugin-installer.ts
136594
136594
  init_esm();
@@ -175365,9 +175365,943 @@ var CameraPlugin = class {
175365
175365
  }
175366
175366
  };
175367
175367
 
175368
+ // src/plugins/builtin/security/security-plugin.ts
175369
+ init_esm();
175370
+ import {
175371
+ callService as callService2,
175372
+ createConnection as createConnection4,
175373
+ createLongLivedTokenAuth as createLongLivedTokenAuth3
175374
+ } from "home-assistant-js-websocket";
175375
+
175376
+ // src/plugins/builtin/security/security-state-machine.ts
175377
+ var ARM_MODES = [
175378
+ "home",
175379
+ "away",
175380
+ "night",
175381
+ "vacation"
175382
+ ];
175383
+ var defaultSecurityScheduler = {
175384
+ schedule(ms, fn) {
175385
+ const timer = setTimeout(fn, ms);
175386
+ timer.unref?.();
175387
+ return () => clearTimeout(timer);
175388
+ }
175389
+ };
175390
+ function parseEntityList(value) {
175391
+ if (typeof value !== "string") return [];
175392
+ return [
175393
+ ...new Set(
175394
+ value.split(",").map((s) => s.trim()).filter(Boolean)
175395
+ )
175396
+ ];
175397
+ }
175398
+ var SILENCEABLE_ALERT_DOMAINS = /* @__PURE__ */ new Set([
175399
+ "siren",
175400
+ "switch",
175401
+ "light"
175402
+ ]);
175403
+ var FIRE_ONLY_ALERT_DOMAINS = /* @__PURE__ */ new Set([
175404
+ "script",
175405
+ "scene"
175406
+ ]);
175407
+ function resolveSecurityLists(config8, warn) {
175408
+ const warned = /* @__PURE__ */ new Set();
175409
+ const alerts = (value) => parseEntityList(value).filter((id) => {
175410
+ const domain = id.split(".")[0];
175411
+ if (SILENCEABLE_ALERT_DOMAINS.has(domain) || FIRE_ONLY_ALERT_DOMAINS.has(domain)) {
175412
+ return true;
175413
+ }
175414
+ if (!warned.has(id)) {
175415
+ warned.add(id);
175416
+ warn?.(
175417
+ `alert entity ${id} dropped, only siren/switch/light and script/scene are supported`
175418
+ );
175419
+ }
175420
+ return false;
175421
+ });
175422
+ const awaySetters = parseEntityList(config8.awaySetters);
175423
+ const awayTriggers = parseEntityList(config8.awayTriggers);
175424
+ const awayAlerts = alerts(config8.awayAlerts);
175425
+ const vacationSetters = parseEntityList(config8.vacationSetters);
175426
+ const vacationTriggers = parseEntityList(config8.vacationTriggers);
175427
+ const vacationAlerts = alerts(config8.vacationAlerts);
175428
+ return {
175429
+ setters: {
175430
+ home: parseEntityList(config8.homeSetters),
175431
+ away: awaySetters,
175432
+ night: parseEntityList(config8.nightSetters),
175433
+ vacation: vacationSetters.length > 0 ? vacationSetters : awaySetters
175434
+ },
175435
+ offSetters: parseEntityList(config8.offSetters),
175436
+ triggers: {
175437
+ home: parseEntityList(config8.homeTriggers),
175438
+ away: awayTriggers,
175439
+ night: parseEntityList(config8.nightTriggers),
175440
+ vacation: vacationTriggers.length > 0 ? vacationTriggers : awayTriggers
175441
+ },
175442
+ triggers24h: parseEntityList(config8.triggers24h),
175443
+ alerts: {
175444
+ home: alerts(config8.homeAlerts),
175445
+ away: awayAlerts,
175446
+ night: alerts(config8.nightAlerts),
175447
+ vacation: vacationAlerts.length > 0 ? vacationAlerts : awayAlerts
175448
+ },
175449
+ alerts24h: alerts(config8.alerts24h),
175450
+ alwaysAlerts: alerts(config8.alwaysAlerts)
175451
+ };
175452
+ }
175453
+ function alertsForTier(lists, tier) {
175454
+ const base = tier === "24h" ? lists.alerts24h : lists.alerts[tier];
175455
+ return [.../* @__PURE__ */ new Set([...base, ...lists.alwaysAlerts])];
175456
+ }
175457
+ function watchedTriggerEntities(lists) {
175458
+ const all = new Set(lists.triggers24h);
175459
+ for (const mode of ARM_MODES) {
175460
+ for (const id of lists.triggers[mode]) all.add(id);
175461
+ }
175462
+ return all;
175463
+ }
175464
+ var PERIMETER_DEVICE_CLASSES = /* @__PURE__ */ new Set([
175465
+ "door",
175466
+ "window",
175467
+ "garage_door",
175468
+ "opening"
175469
+ ]);
175470
+ function isPerimeterTrigger(entityId, deviceClass) {
175471
+ return entityId.startsWith("binary_sensor.") && deviceClass != null && PERIMETER_DEVICE_CLASSES.has(deviceClass);
175472
+ }
175473
+ var SecurityStateMachine = class {
175474
+ constructor(config8, effects, scheduler = defaultSecurityScheduler) {
175475
+ this.config = config8;
175476
+ this.effects = effects;
175477
+ this.scheduler = scheduler;
175478
+ }
175479
+ config;
175480
+ effects;
175481
+ scheduler;
175482
+ mode = null;
175483
+ phase = "disarmed";
175484
+ // Where a triggered alarm returns to when the trigger time runs out.
175485
+ returnMode = null;
175486
+ // Tier of the current trip, only meaningful while triggered.
175487
+ trippedTier = null;
175488
+ // False while an exit delay is still running, so an auto-return after a
175489
+ // trip that cut the delay short can run the setters late.
175490
+ modeReachedRan = true;
175491
+ cancelTimer;
175492
+ get snapshot() {
175493
+ return { mode: this.mode, phase: this.phase };
175494
+ }
175495
+ // Live config swap: state survives, already running timers keep the delay
175496
+ // they started with.
175497
+ setConfig(config8) {
175498
+ this.config = config8;
175499
+ }
175500
+ // Applied once on start from the persisted snapshot. Interrupted delays
175501
+ // resolve to armed; triggered stays only when the trigger time is infinite.
175502
+ // A resolution that changed the phase is persisted right away so a second
175503
+ // restart replays no effects. An interrupted exit delay is the one case
175504
+ // where the setters never ran, so that restore dispatches modeReached.
175505
+ restore(snapshot) {
175506
+ if (!snapshot) return;
175507
+ const mode = ARM_MODES.includes(snapshot.mode) ? snapshot.mode : null;
175508
+ const reached = snapshot.modeReached ?? true;
175509
+ switch (snapshot.phase) {
175510
+ case "arming":
175511
+ case "pending":
175512
+ case "armed":
175513
+ if (mode) {
175514
+ this.mode = mode;
175515
+ this.phase = "armed";
175516
+ this.modeReachedRan = true;
175517
+ if (snapshot.phase !== "armed") this.persist();
175518
+ if (snapshot.phase === "arming") this.effects.modeReached(mode);
175519
+ }
175520
+ break;
175521
+ case "triggered":
175522
+ if (this.config.triggerTimeSeconds === 0) {
175523
+ this.mode = mode;
175524
+ this.returnMode = mode;
175525
+ this.phase = "triggered";
175526
+ this.modeReachedRan = reached;
175527
+ } else {
175528
+ if (mode) {
175529
+ this.mode = mode;
175530
+ this.phase = "armed";
175531
+ this.modeReachedRan = true;
175532
+ }
175533
+ this.persist();
175534
+ if (mode && !reached) this.effects.modeReached(mode);
175535
+ }
175536
+ break;
175537
+ default:
175538
+ break;
175539
+ }
175540
+ }
175541
+ // A mode switch changed on the Matter side. Exclusivity lives here: arming
175542
+ // one mode reports the other three off, turning the active one off disarms.
175543
+ handleModeSwitch(mode, on) {
175544
+ if (on) {
175545
+ if (this.mode === mode && this.phase !== "disarmed") {
175546
+ this.reportSwitches();
175547
+ return;
175548
+ }
175549
+ this.arm(mode);
175550
+ } else if (this.mode === mode && this.phase !== "disarmed" || this.mode === null && this.phase === "triggered") {
175551
+ this.disarm();
175552
+ } else {
175553
+ this.reportSwitches();
175554
+ }
175555
+ }
175556
+ arm(mode) {
175557
+ const wasTriggered = this.phase === "triggered";
175558
+ this.clearTimer();
175559
+ this.mode = mode;
175560
+ this.returnMode = null;
175561
+ const delayMs = this.config.exitDelaySeconds * 1e3;
175562
+ if (delayMs > 0) {
175563
+ this.phase = "arming";
175564
+ this.modeReachedRan = false;
175565
+ this.reportSwitches();
175566
+ if (wasTriggered) this.effects.alarmCleared();
175567
+ this.persist();
175568
+ this.cancelTimer = this.scheduler.schedule(
175569
+ delayMs,
175570
+ () => this.becomeArmed()
175571
+ );
175572
+ } else {
175573
+ this.phase = "armed";
175574
+ this.modeReachedRan = true;
175575
+ this.reportSwitches();
175576
+ if (wasTriggered) this.effects.alarmCleared();
175577
+ this.persist();
175578
+ this.effects.modeReached(mode);
175579
+ }
175580
+ }
175581
+ disarm() {
175582
+ if (this.phase === "disarmed") {
175583
+ this.reportSwitches();
175584
+ return;
175585
+ }
175586
+ const wasTriggered = this.phase === "triggered";
175587
+ this.clearTimer();
175588
+ this.mode = null;
175589
+ this.returnMode = null;
175590
+ this.phase = "disarmed";
175591
+ this.reportSwitches();
175592
+ if (wasTriggered) this.effects.alarmCleared();
175593
+ this.persist();
175594
+ this.effects.disarmed();
175595
+ }
175596
+ // A watched entity entered on/open. The 24h list trips in every state and
175597
+ // skips the entry delay; mode triggers only count while armed (the exit
175598
+ // delay exists so leaving does not trip the alarm).
175599
+ handleEntityOn(entityId, isPerimeter) {
175600
+ if (this.config.triggers24h.includes(entityId)) {
175601
+ if (this.phase !== "triggered" || this.trippedTier !== "24h") {
175602
+ this.trip("24h", entityId);
175603
+ }
175604
+ return;
175605
+ }
175606
+ const mode = this.mode;
175607
+ if (!mode) return;
175608
+ if (!this.config.triggers[mode].includes(entityId)) return;
175609
+ if (this.phase === "armed") {
175610
+ const entryMs = this.config.entryDelaySeconds * 1e3;
175611
+ if (isPerimeter && entryMs > 0) {
175612
+ this.phase = "pending";
175613
+ this.persist();
175614
+ this.cancelTimer = this.scheduler.schedule(
175615
+ entryMs,
175616
+ () => this.trip(mode, entityId)
175617
+ );
175618
+ } else {
175619
+ this.trip(mode, entityId);
175620
+ }
175621
+ } else if (this.phase === "pending" && !isPerimeter) {
175622
+ this.trip(mode, entityId);
175623
+ }
175624
+ }
175625
+ shutdown() {
175626
+ this.clearTimer();
175627
+ }
175628
+ becomeArmed() {
175629
+ this.cancelTimer = void 0;
175630
+ const mode = this.mode;
175631
+ if (!mode || this.phase !== "arming") return;
175632
+ this.phase = "armed";
175633
+ this.modeReachedRan = true;
175634
+ this.persist();
175635
+ this.effects.modeReached(mode);
175636
+ }
175637
+ trip(tier, entityId) {
175638
+ this.clearTimer();
175639
+ this.returnMode = this.mode;
175640
+ this.trippedTier = tier;
175641
+ this.phase = "triggered";
175642
+ this.effects.tripped(tier, entityId);
175643
+ this.persist();
175644
+ const holdMs = this.config.triggerTimeSeconds * 1e3;
175645
+ if (holdMs > 0) {
175646
+ this.cancelTimer = this.scheduler.schedule(
175647
+ holdMs,
175648
+ () => this.autoReturn()
175649
+ );
175650
+ }
175651
+ }
175652
+ autoReturn() {
175653
+ this.cancelTimer = void 0;
175654
+ this.mode = this.returnMode;
175655
+ this.returnMode = null;
175656
+ this.phase = this.mode ? "armed" : "disarmed";
175657
+ const lateSetters = this.mode != null && !this.modeReachedRan;
175658
+ this.modeReachedRan = true;
175659
+ this.effects.alarmCleared();
175660
+ this.persist();
175661
+ if (lateSetters && this.mode) this.effects.modeReached(this.mode);
175662
+ }
175663
+ reportSwitches() {
175664
+ const active = this.phase === "disarmed" ? null : this.mode;
175665
+ const states = {};
175666
+ for (const mode of ARM_MODES) states[mode] = mode === active;
175667
+ this.effects.switchStates(states);
175668
+ }
175669
+ persist() {
175670
+ this.effects.persist({
175671
+ ...this.snapshot,
175672
+ modeReached: this.modeReachedRan
175673
+ });
175674
+ }
175675
+ clearTimer() {
175676
+ this.cancelTimer?.();
175677
+ this.cancelTimer = void 0;
175678
+ }
175679
+ };
175680
+
175681
+ // src/plugins/builtin/security/security-plugin.ts
175682
+ var CONFIG_KEY2 = "config";
175683
+ var STATE_KEY = "state";
175684
+ var PENDING_SILENCE_KEY = "pendingSilence";
175685
+ var PENDING_SETTERS_KEY = "pendingSetters";
175686
+ var ALARM_DEVICE_ID = "alarm";
175687
+ var MODE_DEVICE_IDS = {
175688
+ home: "mode_home",
175689
+ away: "mode_away",
175690
+ night: "mode_night",
175691
+ vacation: "mode_vacation"
175692
+ };
175693
+ var MODE_NAMES = {
175694
+ home: "Home",
175695
+ away: "Away",
175696
+ night: "Night",
175697
+ vacation: "Vacation"
175698
+ };
175699
+ var RETRY_BASE_MS = 1e3;
175700
+ var RETRY_MAX_MS = 6e4;
175701
+ var CALL_DEADLINE_MS = 1e4;
175702
+ var SecurityPlugin = class {
175703
+ constructor(config8 = {}, deps = {}) {
175704
+ this.deps = deps;
175705
+ this.config = config8;
175706
+ }
175707
+ deps;
175708
+ name = "security";
175709
+ version = "0.1.0";
175710
+ log = Logger.get("SecurityPlugin");
175711
+ context;
175712
+ config;
175713
+ lists = resolveSecurityLists({});
175714
+ watched = /* @__PURE__ */ new Set();
175715
+ machine;
175716
+ activeAlerts = [];
175717
+ // Effect tasks run strictly FIFO: a disarm's silence can never overtake the
175718
+ // trip's turn_on loop it is chasing. Queued (not yet started) intent is
175719
+ // coalesced per entity so a hung call cannot pile up work behind it.
175720
+ tasks = [];
175721
+ draining = false;
175722
+ connection;
175723
+ unsubscribeEvents;
175724
+ retryTimer;
175725
+ backoffMs = RETRY_BASE_MS;
175726
+ // Bumped on every teardown; a dial resolving under a stale generation is
175727
+ // discarded instead of installing an old target.
175728
+ dialGeneration = 0;
175729
+ async onStart(context) {
175730
+ this.context = context;
175731
+ const stored = await context.storage.get(CONFIG_KEY2);
175732
+ this.config = { ...this.config, ...stored ?? {} };
175733
+ this.applyLists();
175734
+ this.machine = new SecurityStateMachine(
175735
+ this.machineConfig(),
175736
+ this.effects()
175737
+ );
175738
+ await this.registerDevices();
175739
+ const state = await context.storage.get(STATE_KEY);
175740
+ this.machine.restore(state);
175741
+ await context.storage.flush?.();
175742
+ if (state?.phase === "triggered") {
175743
+ const known = state.activeAlerts;
175744
+ if (this.machine.snapshot.phase === "triggered") {
175745
+ this.activeAlerts = known ?? this.allSilenceableAlerts();
175746
+ } else {
175747
+ const candidates = (known ?? this.allSilenceableAlerts()).filter(
175748
+ (id) => SILENCEABLE_ALERT_DOMAINS.has(id.split(".")[0])
175749
+ );
175750
+ this.enqueueSilence(candidates);
175751
+ }
175752
+ }
175753
+ this.pushDeviceStates();
175754
+ this.startConnection();
175755
+ }
175756
+ async onConfigChanged(config8) {
175757
+ this.config = config8;
175758
+ await this.context?.storage.set(CONFIG_KEY2, this.config);
175759
+ this.applyLists();
175760
+ this.machine?.setConfig(this.machineConfig());
175761
+ await this.stopConnection();
175762
+ this.startConnection();
175763
+ }
175764
+ async onShutdown() {
175765
+ this.machine?.shutdown();
175766
+ await this.stopConnection();
175767
+ for (const id of [...Object.values(MODE_DEVICE_IDS), ALARM_DEVICE_ID]) {
175768
+ await this.context?.unregisterDevice(id).catch(() => {
175769
+ });
175770
+ }
175771
+ }
175772
+ getCurrentConfig() {
175773
+ return { ...this.config };
175774
+ }
175775
+ getConfigSchema() {
175776
+ const entityList = (title, description) => ({
175777
+ type: "string",
175778
+ title,
175779
+ description,
175780
+ required: false
175781
+ });
175782
+ return {
175783
+ title: "Security",
175784
+ description: "A small alarm system: four exclusive mode switches plus an Alarm contact sensor. All entity fields are comma-separated lists.",
175785
+ properties: {
175786
+ exitDelaySeconds: {
175787
+ type: "number",
175788
+ title: "Exit delay (seconds)",
175789
+ description: "Delay before an armed mode takes effect. 0 disables.",
175790
+ default: 60,
175791
+ required: false
175792
+ },
175793
+ entryDelaySeconds: {
175794
+ type: "number",
175795
+ title: "Entry delay (seconds)",
175796
+ description: "Delay for door/window/garage door/opening sensors while armed. Other trigger classes trip instantly. 0 disables.",
175797
+ default: 60,
175798
+ required: false
175799
+ },
175800
+ triggerTimeSeconds: {
175801
+ type: "number",
175802
+ title: "Trigger time (seconds)",
175803
+ description: "How long the alarm stays triggered before returning to the state it was tripped from. 0 keeps it triggered until disarm.",
175804
+ default: 120,
175805
+ required: false
175806
+ },
175807
+ homeSetters: entityList(
175808
+ "Home setters",
175809
+ "Invoked when Home is armed, e.g. scene.arm_home,script.notify"
175810
+ ),
175811
+ homeTriggers: entityList(
175812
+ "Home triggers",
175813
+ "Sensors that trip the alarm while armed Home."
175814
+ ),
175815
+ homeAlerts: entityList(
175816
+ "Home alerts",
175817
+ "Turned on when the alarm trips while armed Home."
175818
+ ),
175819
+ awaySetters: entityList("Away setters", "Invoked when Away is armed."),
175820
+ awayTriggers: entityList(
175821
+ "Away triggers",
175822
+ "Sensors that trip the alarm while armed Away."
175823
+ ),
175824
+ awayAlerts: entityList(
175825
+ "Away alerts",
175826
+ "Turned on when the alarm trips while armed Away."
175827
+ ),
175828
+ nightSetters: entityList(
175829
+ "Night setters",
175830
+ "Invoked when Night is armed."
175831
+ ),
175832
+ nightTriggers: entityList(
175833
+ "Night triggers",
175834
+ "Sensors that trip the alarm while armed Night."
175835
+ ),
175836
+ nightAlerts: entityList(
175837
+ "Night alerts",
175838
+ "Turned on when the alarm trips while armed Night."
175839
+ ),
175840
+ vacationSetters: entityList(
175841
+ "Vacation setters",
175842
+ "Leave empty to use the Away setters."
175843
+ ),
175844
+ vacationTriggers: entityList(
175845
+ "Vacation triggers",
175846
+ "Leave empty to use the Away triggers."
175847
+ ),
175848
+ vacationAlerts: entityList(
175849
+ "Vacation alerts",
175850
+ "Leave empty to use the Away alerts."
175851
+ ),
175852
+ offSetters: entityList(
175853
+ "Off setters",
175854
+ "Invoked on disarm, e.g. scene.alarm_off"
175855
+ ),
175856
+ triggers24h: entityList(
175857
+ "24h triggers",
175858
+ "Trip the alarm in every state including disarmed, without entry delay. For smoke, gas, water leak and similar."
175859
+ ),
175860
+ alerts24h: entityList(
175861
+ "24h alerts",
175862
+ "Turned on when a 24h trigger trips the alarm."
175863
+ ),
175864
+ alwaysAlerts: entityList(
175865
+ "Always",
175866
+ "The master alert list: fired on every trip in addition to the tier's alerts."
175867
+ ),
175868
+ haUrl: {
175869
+ type: "string",
175870
+ title: "Home Assistant URL",
175871
+ description: "e.g. http://homeassistant.local:8123. Set together with the token to use a different Home Assistant; leave both empty to use the bridge's credentials. One without the other is ignored.",
175872
+ required: false
175873
+ },
175874
+ haToken: {
175875
+ type: "string",
175876
+ title: "Long-lived access token",
175877
+ description: "Token for the custom URL, only used together with it. Leave both empty to use the bridge's credentials.",
175878
+ required: false,
175879
+ secret: true
175880
+ }
175881
+ }
175882
+ };
175883
+ }
175884
+ // A watched entity changed state, from the websocket or from tests.
175885
+ handleTriggerEvent(entityId, state, deviceClass) {
175886
+ if (state !== "on" && state !== "open") return;
175887
+ if (!this.watched.has(entityId)) return;
175888
+ this.machine?.handleEntityOn(
175889
+ entityId,
175890
+ isPerimeterTrigger(entityId, deviceClass)
175891
+ );
175892
+ }
175893
+ applyLists() {
175894
+ this.lists = resolveSecurityLists(
175895
+ this.config,
175896
+ (message) => this.log.warn(message)
175897
+ );
175898
+ this.watched = watchedTriggerEntities(this.lists);
175899
+ }
175900
+ machineConfig() {
175901
+ const seconds = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
175902
+ return {
175903
+ exitDelaySeconds: seconds(this.config.exitDelaySeconds, 60),
175904
+ entryDelaySeconds: seconds(this.config.entryDelaySeconds, 60),
175905
+ triggerTimeSeconds: seconds(this.config.triggerTimeSeconds, 120),
175906
+ triggers: this.lists.triggers,
175907
+ triggers24h: this.lists.triggers24h
175908
+ };
175909
+ }
175910
+ effects() {
175911
+ return {
175912
+ switchStates: (states) => {
175913
+ for (const mode of ARM_MODES) {
175914
+ this.context?.updateDeviceState(MODE_DEVICE_IDS[mode], "onOff", {
175915
+ onOff: states[mode]
175916
+ });
175917
+ }
175918
+ },
175919
+ modeReached: (mode) => {
175920
+ this.enqueueSetters(mode);
175921
+ },
175922
+ disarmed: () => {
175923
+ this.enqueueSetters("off");
175924
+ },
175925
+ tripped: (tier, entityId) => {
175926
+ this.log.info(`alarm tripped by ${entityId} (${tier})`);
175927
+ this.context?.updateDeviceState(ALARM_DEVICE_ID, "booleanState", {
175928
+ stateValue: false
175929
+ });
175930
+ const alerts = alertsForTier(this.lists, tier);
175931
+ this.activeAlerts = [.../* @__PURE__ */ new Set([...this.activeAlerts, ...alerts])];
175932
+ this.enqueueAlerts(alerts);
175933
+ },
175934
+ alarmCleared: () => {
175935
+ this.context?.updateDeviceState(ALARM_DEVICE_ID, "booleanState", {
175936
+ stateValue: true
175937
+ });
175938
+ const toSilence = this.activeAlerts.filter(
175939
+ (id) => SILENCEABLE_ALERT_DOMAINS.has(id.split(".")[0])
175940
+ );
175941
+ this.activeAlerts = [];
175942
+ this.enqueueSilence(toSilence);
175943
+ },
175944
+ persist: (snapshot) => {
175945
+ const state = {
175946
+ ...snapshot,
175947
+ activeAlerts: [...this.activeAlerts]
175948
+ };
175949
+ void this.context?.storage.set(STATE_KEY, state).catch((e) => this.log.warn("failed to persist alarm state:", e));
175950
+ }
175951
+ };
175952
+ }
175953
+ setters(key) {
175954
+ return key === "off" ? this.lists.offSetters : this.lists.setters[key];
175955
+ }
175956
+ // Every alert a clear can turn off, across all tiers.
175957
+ allSilenceableAlerts() {
175958
+ const all = /* @__PURE__ */ new Set([
175959
+ ...ARM_MODES.flatMap((mode) => this.lists.alerts[mode]),
175960
+ ...this.lists.alerts24h,
175961
+ ...this.lists.alwaysAlerts
175962
+ ]);
175963
+ return [...all].filter(
175964
+ (id) => SILENCEABLE_ALERT_DOMAINS.has(id.split(".")[0])
175965
+ );
175966
+ }
175967
+ async registerDevices() {
175968
+ const context = this.context;
175969
+ if (!context) return;
175970
+ for (const mode of ARM_MODES) {
175971
+ await context.registerDevice({
175972
+ id: MODE_DEVICE_IDS[mode],
175973
+ name: MODE_NAMES[mode],
175974
+ deviceType: "on_off_plugin_unit",
175975
+ clusters: [{ clusterId: "onOff", attributes: { onOff: false } }],
175976
+ onAttributeWrite: async (clusterId3, attribute, value) => {
175977
+ if (clusterId3 !== "onOff" || attribute !== "onOff") return;
175978
+ this.machine?.handleModeSwitch(mode, value === true);
175979
+ }
175980
+ });
175981
+ }
175982
+ await context.registerDevice({
175983
+ id: ALARM_DEVICE_ID,
175984
+ name: "Alarm",
175985
+ deviceType: "contact_sensor",
175986
+ // stateValue true = closed = all quiet; false = open = tripped.
175987
+ clusters: [
175988
+ { clusterId: "booleanState", attributes: { stateValue: true } }
175989
+ ]
175990
+ });
175991
+ }
175992
+ // Reflect the restored snapshot onto the endpoints.
175993
+ pushDeviceStates() {
175994
+ const context = this.context;
175995
+ const machine = this.machine;
175996
+ if (!context || !machine) return;
175997
+ const snap = machine.snapshot;
175998
+ for (const mode of ARM_MODES) {
175999
+ context.updateDeviceState(MODE_DEVICE_IDS[mode], "onOff", {
176000
+ onOff: snap.mode === mode && snap.phase !== "disarmed"
176001
+ });
176002
+ }
176003
+ context.updateDeviceState(ALARM_DEVICE_ID, "booleanState", {
176004
+ stateValue: snap.phase !== "triggered"
176005
+ });
176006
+ }
176007
+ pushTask(task) {
176008
+ this.tasks.push(task);
176009
+ queueMicrotask(() => void this.drain());
176010
+ }
176011
+ enqueueSetters(mode) {
176012
+ this.tasks = this.tasks.filter((t) => t.kind !== "setters");
176013
+ this.pushTask({ kind: "setters", mode });
176014
+ }
176015
+ enqueueAlerts(entityIds) {
176016
+ this.dropQueuedIntent("silence", entityIds);
176017
+ const queued = this.tasks.find((t) => t.kind === "alerts");
176018
+ if (queued && queued.kind === "alerts") {
176019
+ queued.entities = [.../* @__PURE__ */ new Set([...queued.entities, ...entityIds])];
176020
+ return;
176021
+ }
176022
+ this.pushTask({ kind: "alerts", entities: [...entityIds] });
176023
+ }
176024
+ enqueueSilence(entityIds) {
176025
+ this.dropQueuedIntent("alerts", entityIds);
176026
+ const queued = this.tasks.find((t) => t.kind === "silence");
176027
+ if (queued && queued.kind === "silence") {
176028
+ queued.entities = [.../* @__PURE__ */ new Set([...queued.entities, ...entityIds])];
176029
+ return;
176030
+ }
176031
+ this.pushTask({ kind: "silence", entities: [...entityIds] });
176032
+ }
176033
+ enqueueReconnect() {
176034
+ if (this.tasks.some((t) => t.kind === "reconnect")) return;
176035
+ this.pushTask({ kind: "reconnect" });
176036
+ }
176037
+ // A newer turn_on/turn_off intent for an entity replaces the queued
176038
+ // opposite one. Only queued tasks are touched, an in-flight batch keeps
176039
+ // its order guarantee.
176040
+ dropQueuedIntent(kind, entityIds) {
176041
+ const drop = new Set(entityIds);
176042
+ this.tasks = this.tasks.flatMap((t) => {
176043
+ if (t.kind !== kind) return [t];
176044
+ const rest = t.entities.filter((id) => !drop.has(id));
176045
+ return rest.length > 0 ? [{ ...t, entities: rest }] : [];
176046
+ });
176047
+ }
176048
+ async drain() {
176049
+ if (this.draining) return;
176050
+ this.draining = true;
176051
+ try {
176052
+ for (; ; ) {
176053
+ const task = this.tasks.shift();
176054
+ if (!task) return;
176055
+ try {
176056
+ await this.runTask(task);
176057
+ } catch (e) {
176058
+ this.log.warn("effect batch failed:", e);
176059
+ }
176060
+ }
176061
+ } finally {
176062
+ this.draining = false;
176063
+ }
176064
+ }
176065
+ async runTask(task) {
176066
+ switch (task.kind) {
176067
+ case "setters":
176068
+ return this.runSetters(task.mode);
176069
+ case "alerts":
176070
+ return this.runAlerts(task.entities);
176071
+ case "silence":
176072
+ return this.runSilence(task.entities);
176073
+ case "reconnect":
176074
+ return this.flushAfterConnect();
176075
+ }
176076
+ }
176077
+ // Setter turn_on per domain: script.turn_on for script.*, scene.turn_on for
176078
+ // scene.*, else <domain>.turn_on. The intent goes to disk first and leaves
176079
+ // it only once every call resolved, so a crash, gap or failed call is
176080
+ // replayed on reconnect if the mode is still current.
176081
+ async runSetters(key) {
176082
+ const entities = this.setters(key);
176083
+ if (entities.length === 0) return;
176084
+ const storage2 = this.context?.storage;
176085
+ await storage2?.set(PENDING_SETTERS_KEY, { mode: key });
176086
+ await storage2?.flush?.();
176087
+ if (this.connection?.connected !== true) {
176088
+ this.log.debug(
176089
+ `no Home Assistant connection, ${key} setters wait for the reconnect`
176090
+ );
176091
+ return;
176092
+ }
176093
+ const ok2 = await this.callSequential(entities, "turn_on");
176094
+ if (ok2) await storage2?.delete(PENDING_SETTERS_KEY);
176095
+ }
176096
+ async runAlerts(entityIds) {
176097
+ if (entityIds.length === 0) return;
176098
+ await this.context?.storage.flush?.();
176099
+ const connection = this.connection;
176100
+ if (!connection || !connection.connected) {
176101
+ this.log.warn(
176102
+ `no Home Assistant connection, alerts skipped: ${entityIds.join(",")}`
176103
+ );
176104
+ return;
176105
+ }
176106
+ await this.callSequential(entityIds, "turn_on");
176107
+ }
176108
+ // Every due turn_off goes to storage first and leaves it only once HA
176109
+ // confirmed the call, so a crash or gap can never lose a silence.
176110
+ async runSilence(entityIds) {
176111
+ const storage2 = this.context?.storage;
176112
+ if (storage2 && entityIds.length > 0) {
176113
+ const pending = await storage2.get(PENDING_SILENCE_KEY) ?? [];
176114
+ await storage2.set(PENDING_SILENCE_KEY, [
176115
+ .../* @__PURE__ */ new Set([...pending, ...entityIds])
176116
+ ]);
176117
+ await storage2.flush?.();
176118
+ }
176119
+ await this.flushPendingSilence();
176120
+ }
176121
+ async flushPendingSilence() {
176122
+ const storage2 = this.context?.storage;
176123
+ if (!storage2) return;
176124
+ let pending = await storage2.get(PENDING_SILENCE_KEY) ?? [];
176125
+ if (pending.length === 0) return;
176126
+ const connection = this.connection;
176127
+ if (!connection || !connection.connected) {
176128
+ this.log.warn(
176129
+ `no Home Assistant connection, silence stays pending for ${pending.join(",")}`
176130
+ );
176131
+ return;
176132
+ }
176133
+ for (const entityId of [...pending]) {
176134
+ const domain = entityId.split(".")[0];
176135
+ try {
176136
+ await this.callWithDeadline(connection, domain, "turn_off", entityId);
176137
+ pending = pending.filter((id) => id !== entityId);
176138
+ await storage2.set(PENDING_SILENCE_KEY, pending);
176139
+ } catch (e) {
176140
+ this.log.warn(
176141
+ `${domain}.turn_off failed for ${entityId}, retried on reconnect:`,
176142
+ e
176143
+ );
176144
+ }
176145
+ }
176146
+ if (pending.length === 0) await storage2.delete(PENDING_SILENCE_KEY);
176147
+ }
176148
+ // Ran after every (re)connect: sirens first, then any setter batch a gap
176149
+ // swallowed, but only if the machine still stands where it did.
176150
+ async flushAfterConnect() {
176151
+ await this.flushPendingSilence();
176152
+ const storage2 = this.context?.storage;
176153
+ if (!storage2) return;
176154
+ const held = await storage2.get(
176155
+ PENDING_SETTERS_KEY
176156
+ );
176157
+ if (!held) return;
176158
+ const snap = this.machine?.snapshot;
176159
+ const stillCurrent = held.mode === "off" ? snap?.phase === "disarmed" : snap?.mode === held.mode && snap?.phase !== "disarmed";
176160
+ if (!stillCurrent) {
176161
+ await storage2.delete(PENDING_SETTERS_KEY);
176162
+ return;
176163
+ }
176164
+ const ok2 = await this.callSequential(this.setters(held.mode), "turn_on");
176165
+ if (ok2) await storage2.delete(PENDING_SETTERS_KEY);
176166
+ }
176167
+ async callSequential(entityIds, service) {
176168
+ const connection = this.connection;
176169
+ if (!connection || !connection.connected) return false;
176170
+ let allOk = true;
176171
+ for (const entityId of entityIds) {
176172
+ const domain = entityId.split(".")[0];
176173
+ try {
176174
+ await this.callWithDeadline(connection, domain, service, entityId);
176175
+ } catch (e) {
176176
+ allOk = false;
176177
+ this.log.warn(`${domain}.${service} failed for ${entityId}:`, e);
176178
+ }
176179
+ }
176180
+ return allOk;
176181
+ }
176182
+ async callWithDeadline(connection, domain, service, entityId) {
176183
+ let timer;
176184
+ try {
176185
+ await Promise.race([
176186
+ callService2(connection, domain, service, void 0, {
176187
+ entity_id: entityId
176188
+ }),
176189
+ new Promise((_, reject) => {
176190
+ timer = setTimeout(
176191
+ () => reject(
176192
+ new Error(
176193
+ `${domain}.${service} for ${entityId} timed out after ${CALL_DEADLINE_MS}ms`
176194
+ )
176195
+ ),
176196
+ CALL_DEADLINE_MS
176197
+ );
176198
+ timer.unref?.();
176199
+ })
176200
+ ]);
176201
+ } finally {
176202
+ if (timer) clearTimeout(timer);
176203
+ }
176204
+ }
176205
+ startConnection() {
176206
+ const hasCustomUrl = !!this.config.haUrl;
176207
+ const hasCustomToken = !!this.config.haToken;
176208
+ if (hasCustomUrl !== hasCustomToken) {
176209
+ this.log.warn(
176210
+ "haUrl and haToken must both be set for a custom Home Assistant, using the bridge credentials instead"
176211
+ );
176212
+ }
176213
+ const custom = hasCustomUrl && hasCustomToken;
176214
+ const haUrl = custom ? this.config.haUrl : this.context?.homeAssistant?.url;
176215
+ const haToken = custom ? this.config.haToken : this.context?.homeAssistant?.accessToken;
176216
+ if (!haUrl || !haToken) {
176217
+ this.log.info(
176218
+ "no Home Assistant connection, setters, alerts and triggers stay inactive"
176219
+ );
176220
+ return;
176221
+ }
176222
+ void this.connect(haUrl, haToken);
176223
+ }
176224
+ async connect(haUrl, haToken) {
176225
+ const generation = this.dialGeneration;
176226
+ let connection;
176227
+ try {
176228
+ connection = this.deps.connect ? await this.deps.connect(haUrl, haToken) : await createConnection4({
176229
+ auth: createLongLivedTokenAuth3(haUrl, haToken)
176230
+ });
176231
+ if (generation !== this.dialGeneration) {
176232
+ connection.close();
176233
+ return;
176234
+ }
176235
+ this.connection = connection;
176236
+ this.backoffMs = RETRY_BASE_MS;
176237
+ connection.addEventListener(
176238
+ "disconnected",
176239
+ () => this.log.info("Home Assistant connection lost, waiting for reconnect")
176240
+ );
176241
+ connection.addEventListener("ready", () => this.enqueueReconnect());
176242
+ const unsubscribe = await connection.subscribeEvents(
176243
+ (event3) => this.handleStateChanged(event3),
176244
+ "state_changed"
176245
+ );
176246
+ if (generation !== this.dialGeneration) {
176247
+ return;
176248
+ }
176249
+ this.unsubscribeEvents = unsubscribe;
176250
+ this.log.info(`watching ${this.watched.size} trigger entities`);
176251
+ this.enqueueReconnect();
176252
+ } catch (e) {
176253
+ connection?.close();
176254
+ if (this.connection === connection) this.connection = void 0;
176255
+ if (generation !== this.dialGeneration) return;
176256
+ this.log.warn(
176257
+ `Home Assistant connection failed, retrying in ${this.backoffMs}ms:`,
176258
+ e
176259
+ );
176260
+ const delay = this.backoffMs;
176261
+ this.backoffMs = Math.min(this.backoffMs * 2, RETRY_MAX_MS);
176262
+ this.retryTimer = setTimeout(() => {
176263
+ this.retryTimer = void 0;
176264
+ if (generation !== this.dialGeneration) return;
176265
+ void this.connect(haUrl, haToken);
176266
+ }, delay);
176267
+ this.retryTimer.unref?.();
176268
+ }
176269
+ }
176270
+ async stopConnection() {
176271
+ this.dialGeneration++;
176272
+ if (this.retryTimer) {
176273
+ clearTimeout(this.retryTimer);
176274
+ this.retryTimer = void 0;
176275
+ }
176276
+ const unsubscribe = this.unsubscribeEvents;
176277
+ this.unsubscribeEvents = void 0;
176278
+ if (unsubscribe) {
176279
+ try {
176280
+ await unsubscribe();
176281
+ } catch {
176282
+ }
176283
+ }
176284
+ this.connection?.close();
176285
+ this.connection = void 0;
176286
+ this.backoffMs = RETRY_BASE_MS;
176287
+ }
176288
+ handleStateChanged(event3) {
176289
+ const entityId = event3.data?.entity_id;
176290
+ const newState = event3.data?.new_state;
176291
+ if (!entityId || !newState?.state) return;
176292
+ if (event3.data?.old_state?.state === newState.state) return;
176293
+ this.handleTriggerEvent(
176294
+ entityId,
176295
+ newState.state,
176296
+ newState.attributes?.device_class
176297
+ );
176298
+ }
176299
+ };
176300
+
175368
176301
  // src/plugins/builtin/index.ts
175369
176302
  var BUILTIN_PLUGINS = [
175370
- CameraPlugin
176303
+ CameraPlugin,
176304
+ SecurityPlugin
175371
176305
  ];
175372
176306
 
175373
176307
  // src/services/bridges/bridge-endpoint-manager.ts
@@ -175796,8 +176730,13 @@ var BridgeEndpointManager = class extends Service {
175796
176730
  if (behaviorId === "pluginDevice") continue;
175797
176731
  const behaviorEvents = allEvents[behaviorId];
175798
176732
  if (!behaviorEvents || typeof behaviorEvents !== "object") continue;
175799
- for (const eventName of Object.keys(behaviorEvents)) {
175800
- if (!eventName.endsWith("$Changed")) continue;
176733
+ const eventNames = /* @__PURE__ */ new Set();
176734
+ for (let proto = behaviorEvents; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
176735
+ for (const name of Object.getOwnPropertyNames(proto)) {
176736
+ if (name.endsWith("$Changed")) eventNames.add(name);
176737
+ }
176738
+ }
176739
+ for (const eventName of eventNames) {
175801
176740
  const observable = behaviorEvents[eventName];
175802
176741
  if (!observable || typeof observable.on !== "function") continue;
175803
176742
  const attrName = eventName.slice(0, -"$Changed".length);
@@ -176349,7 +177288,7 @@ var BridgeEndpointManager = class extends Service {
176349
177288
  init_dist();
176350
177289
  init_esm();
176351
177290
  init_send_ha_message();
176352
- import { callService as callService2 } from "home-assistant-js-websocket";
177291
+ import { callService as callService3 } from "home-assistant-js-websocket";
176353
177292
  import { keys as keys2, pickBy, values as values3 } from "lodash-es";
176354
177293
  var BridgeRegistry = class _BridgeRegistry {
176355
177294
  constructor(registry3, dataProvider, client) {
@@ -176735,7 +177674,7 @@ var BridgeRegistry = class _BridgeRegistry {
176735
177674
  async resolveRoborockRooms(entityId) {
176736
177675
  if (!this.client) return [];
176737
177676
  try {
176738
- const raw = await callService2(
177677
+ const raw = await callService3(
176739
177678
  this.client.connection,
176740
177679
  "roborock",
176741
177680
  "get_maps",