@riddix/hamh 2.1.0-alpha.854 → 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.
package/dist/backend/cli.js
CHANGED
|
@@ -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();
|
|
@@ -157745,9 +157745,39 @@ function decideWedgeRotation(input) {
|
|
|
157745
157745
|
const imSilenceMs = lastImRequestMsAgo == null ? sessionAgeMs : lastImRequestMsAgo;
|
|
157746
157746
|
return subscriptionCount > 0 && sessionAgeMs > WEDGE_WARMUP_MS && imSilenceMs > WEDGE_IM_SILENCE_MS && (lastRotatedMsAgo == null || lastRotatedMsAgo > WEDGE_MIN_ROTATE_INTERVAL_MS);
|
|
157747
157747
|
}
|
|
157748
|
+
var WEDGE_V2_COMMAND_SILENCE_MS = 45 * 60 * 1e3;
|
|
157749
|
+
var WEDGE_V2_WINDOW_MS = 30 * 60 * 1e3;
|
|
157750
|
+
var WEDGE_V2_MIN_SUBSCRIBES = 3;
|
|
157751
|
+
var WEDGE_V2_MIN_GIVE_UPS = 2;
|
|
157752
|
+
var WEDGE_RING_SIZE = 10;
|
|
157753
|
+
var WEDGE_GIVE_UP_QUIET_MS = 5e3;
|
|
157754
|
+
function inWindow(timesMs, nowMs) {
|
|
157755
|
+
return timesMs.filter((t) => nowMs - t <= WEDGE_V2_WINDOW_MS);
|
|
157756
|
+
}
|
|
157757
|
+
function countRecent(timesMs, nowMs) {
|
|
157758
|
+
return inWindow(timesMs, nowMs).length;
|
|
157759
|
+
}
|
|
157760
|
+
function decideWedgeRotationV2(input) {
|
|
157761
|
+
const silenceMs = input.commandSilenceMs ?? input.sessionAgeMs;
|
|
157762
|
+
if (input.subscriptionCount <= 0) return false;
|
|
157763
|
+
if (input.sessionAgeMs <= WEDGE_WARMUP_MS) return false;
|
|
157764
|
+
if (silenceMs <= WEDGE_V2_COMMAND_SILENCE_MS) return false;
|
|
157765
|
+
const subscribes = inWindow(input.subscribeTimesMs, input.nowMs);
|
|
157766
|
+
const giveUps = inWindow(input.giveUpTimesMs, input.nowMs);
|
|
157767
|
+
if (subscribes.length < WEDGE_V2_MIN_SUBSCRIBES) return false;
|
|
157768
|
+
if (giveUps.length < WEDGE_V2_MIN_GIVE_UPS) return false;
|
|
157769
|
+
if (Math.max(...giveUps) <= Math.min(...subscribes)) return false;
|
|
157770
|
+
return input.lastRotatedMsAgo == null || input.lastRotatedMsAgo > WEDGE_MIN_ROTATE_INTERVAL_MS;
|
|
157771
|
+
}
|
|
157748
157772
|
|
|
157749
157773
|
// src/services/bridges/bridge.ts
|
|
157750
157774
|
var imWrapMarker = /* @__PURE__ */ Symbol("hamh.wedgeImWrap");
|
|
157775
|
+
var commandMessageTypes = [
|
|
157776
|
+
MessageType.ReadRequest,
|
|
157777
|
+
MessageType.WriteRequest,
|
|
157778
|
+
MessageType.InvokeRequest,
|
|
157779
|
+
MessageType.TimedRequest
|
|
157780
|
+
];
|
|
157751
157781
|
var AUTO_FORCE_SYNC_INTERVAL_MS = 9e4;
|
|
157752
157782
|
var SHUTDOWN_SESSION_CLOSE_TIMEOUT_MS = 2500;
|
|
157753
157783
|
var MDNS_ADDRESS_CHECK_INTERVAL_MS = 6e4;
|
|
@@ -157801,6 +157831,13 @@ var Bridge = class {
|
|
|
157801
157831
|
lastImRequestAt = /* @__PURE__ */ new WeakMap();
|
|
157802
157832
|
wedgeLastRotatedAt = /* @__PURE__ */ new WeakMap();
|
|
157803
157833
|
wedgeWatchdogTimer = null;
|
|
157834
|
+
// V2 shadow signals (#365): last command-class request per session, plus
|
|
157835
|
+
// rings of subscribe and delivery give-up times (newest WEDGE_RING_SIZE).
|
|
157836
|
+
lastCommandImAt = /* @__PURE__ */ new WeakMap();
|
|
157837
|
+
subscribeTimesMs = /* @__PURE__ */ new WeakMap();
|
|
157838
|
+
giveUpTimesMs = /* @__PURE__ */ new WeakMap();
|
|
157839
|
+
// Sessions whose subscriptions.deleted we already hooked for give-ups.
|
|
157840
|
+
wedgeHookedSessions = /* @__PURE__ */ new WeakSet();
|
|
157804
157841
|
// Watches the advertised interface addresses so a dynamic ISP IPv6 prefix
|
|
157805
157842
|
// change forces a fresh operational announcement (#415).
|
|
157806
157843
|
mdnsAddressTimer = null;
|
|
@@ -157864,6 +157901,20 @@ var Bridge = class {
|
|
|
157864
157901
|
const lastImAt = this.lastImRequestAt.get(s);
|
|
157865
157902
|
const lastImRequestMsAgo = lastImAt != null ? nowMs - lastImAt : null;
|
|
157866
157903
|
const startedAt = this.sessionStartedAt.get(s.id);
|
|
157904
|
+
const lastCmdAt = this.lastCommandImAt.get(s);
|
|
157905
|
+
const lastCommandImRequestMsAgo = lastCmdAt != null ? nowMs - lastCmdAt : null;
|
|
157906
|
+
const subscribeTimes = this.subscribeTimesMs.get(s) ?? [];
|
|
157907
|
+
const giveUpTimes = this.giveUpTimesMs.get(s) ?? [];
|
|
157908
|
+
const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
|
|
157909
|
+
const wedgeV2WouldRotate = decideWedgeRotationV2({
|
|
157910
|
+
subscriptionCount: subCount,
|
|
157911
|
+
sessionAgeMs: startedAt != null ? nowMs - startedAt : 0,
|
|
157912
|
+
commandSilenceMs: lastCommandImRequestMsAgo,
|
|
157913
|
+
subscribeTimesMs: subscribeTimes,
|
|
157914
|
+
giveUpTimesMs: giveUpTimes,
|
|
157915
|
+
nowMs,
|
|
157916
|
+
lastRotatedMsAgo: lastRotatedAt != null ? nowMs - lastRotatedAt : null
|
|
157917
|
+
});
|
|
157867
157918
|
return {
|
|
157868
157919
|
id: s.id,
|
|
157869
157920
|
peerNodeId: String(s.peerNodeId),
|
|
@@ -157873,6 +157924,10 @@ var Bridge = class {
|
|
|
157873
157924
|
lastActiveMsAgo,
|
|
157874
157925
|
lastAnyActivityMsAgo,
|
|
157875
157926
|
lastImRequestMsAgo,
|
|
157927
|
+
lastCommandImRequestMsAgo,
|
|
157928
|
+
subscribesLast30Min: countRecent(subscribeTimes, nowMs),
|
|
157929
|
+
giveUpsLast30Min: countRecent(giveUpTimes, nowMs),
|
|
157930
|
+
wedgeV2WouldRotate,
|
|
157876
157931
|
isPeerActive: Boolean(s.isPeerActive),
|
|
157877
157932
|
ageMsFromOpen: startedAt != null ? nowMs - startedAt : null
|
|
157878
157933
|
};
|
|
@@ -158102,6 +158157,15 @@ ${e?.toString()}`);
|
|
|
158102
158157
|
try {
|
|
158103
158158
|
const sessionManager = this.server.env.get(SessionManager);
|
|
158104
158159
|
seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
|
|
158160
|
+
const hookGiveUpsWithPeers = (sess) => this.hookSubscriptionGiveUps(
|
|
158161
|
+
sess,
|
|
158162
|
+
() => [...sessionManager.sessions].filter(
|
|
158163
|
+
(s) => s.peerNodeId === sess.peerNodeId && s.fabric?.fabricIndex === sess.fabric?.fabricIndex
|
|
158164
|
+
)
|
|
158165
|
+
);
|
|
158166
|
+
for (const sess of sessionManager.sessions) {
|
|
158167
|
+
hookGiveUpsWithPeers(sess);
|
|
158168
|
+
}
|
|
158105
158169
|
this.sessionDiagHandler = (session) => {
|
|
158106
158170
|
const sessions = [...sessionManager.sessions];
|
|
158107
158171
|
let totalSubs = 0;
|
|
@@ -158164,6 +158228,7 @@ ${e?.toString()}`);
|
|
|
158164
158228
|
sessionManager.subscriptionsChanged.on(this.sessionDiagHandler);
|
|
158165
158229
|
this.sessionAddedHandler = (newSession) => {
|
|
158166
158230
|
this.sessionStartedAt.set(newSession.id, Date.now());
|
|
158231
|
+
hookGiveUpsWithPeers(newSession);
|
|
158167
158232
|
this.log.info(
|
|
158168
158233
|
`Session opened: id=${newSession.id} peer=${newSession.peerNodeId}`
|
|
158169
158234
|
);
|
|
@@ -158257,7 +158322,14 @@ ${e?.toString()}`);
|
|
|
158257
158322
|
is.onNewExchange = (exchange, message) => {
|
|
158258
158323
|
const session = exchange.session;
|
|
158259
158324
|
if (session) {
|
|
158260
|
-
|
|
158325
|
+
const now = Date.now();
|
|
158326
|
+
this.lastImRequestAt.set(session, now);
|
|
158327
|
+
const type = message?.payloadHeader?.messageType;
|
|
158328
|
+
if (type === MessageType.SubscribeRequest) {
|
|
158329
|
+
this.pushWedgeRing(this.subscribeTimesMs, session, now);
|
|
158330
|
+
} else if (type != null && commandMessageTypes.includes(type)) {
|
|
158331
|
+
this.lastCommandImAt.set(session, now);
|
|
158332
|
+
}
|
|
158261
158333
|
}
|
|
158262
158334
|
return original(exchange, message);
|
|
158263
158335
|
};
|
|
@@ -158265,6 +158337,52 @@ ${e?.toString()}`);
|
|
|
158265
158337
|
} catch {
|
|
158266
158338
|
}
|
|
158267
158339
|
}
|
|
158340
|
+
// Keep only the newest WEDGE_RING_SIZE timestamps per session.
|
|
158341
|
+
pushWedgeRing(ring, session, now) {
|
|
158342
|
+
const times = ring.get(session) ?? [];
|
|
158343
|
+
times.push(now);
|
|
158344
|
+
if (times.length > WEDGE_RING_SIZE) {
|
|
158345
|
+
times.splice(0, times.length - WEDGE_RING_SIZE);
|
|
158346
|
+
}
|
|
158347
|
+
ring.set(session, times);
|
|
158348
|
+
}
|
|
158349
|
+
// Watch this session's subscriptions for server-side delivery give-ups
|
|
158350
|
+
// (#365 v2 shadow). isTerminated true fires both on a peer cancel and on
|
|
158351
|
+
// the 3-strikes delivery give-up; only the give-up arrives without a
|
|
158352
|
+
// coincident inbound IM request, so that is the discriminator.
|
|
158353
|
+
hookSubscriptionGiveUps(session, peerSessions) {
|
|
158354
|
+
const key = session;
|
|
158355
|
+
if (this.wedgeHookedSessions.has(key)) {
|
|
158356
|
+
return;
|
|
158357
|
+
}
|
|
158358
|
+
const deleted = session.subscriptions?.deleted;
|
|
158359
|
+
if (typeof deleted?.on !== "function") {
|
|
158360
|
+
return;
|
|
158361
|
+
}
|
|
158362
|
+
this.wedgeHookedSessions.add(key);
|
|
158363
|
+
deleted.on((sub) => {
|
|
158364
|
+
if (sub?.isTerminated !== true) {
|
|
158365
|
+
return;
|
|
158366
|
+
}
|
|
158367
|
+
const now = Date.now();
|
|
158368
|
+
const fresh = (s) => {
|
|
158369
|
+
const at = this.lastImRequestAt.get(s);
|
|
158370
|
+
return at != null && now - at <= WEDGE_GIVE_UP_QUIET_MS;
|
|
158371
|
+
};
|
|
158372
|
+
if (fresh(key)) {
|
|
158373
|
+
return;
|
|
158374
|
+
}
|
|
158375
|
+
for (const s of peerSessions?.() ?? []) {
|
|
158376
|
+
if (s !== key && fresh(s)) {
|
|
158377
|
+
return;
|
|
158378
|
+
}
|
|
158379
|
+
}
|
|
158380
|
+
this.pushWedgeRing(this.giveUpTimesMs, key, now);
|
|
158381
|
+
this.log.debug(
|
|
158382
|
+
`wedge v2 give-up recorded sub=${sub?.subscriptionId}`
|
|
158383
|
+
);
|
|
158384
|
+
});
|
|
158385
|
+
}
|
|
158268
158386
|
closeStaleSession(sessionId) {
|
|
158269
158387
|
try {
|
|
158270
158388
|
const sessionManager = this.server.env.get(SessionManager);
|
|
@@ -158527,14 +158645,38 @@ ${e?.toString()}`);
|
|
|
158527
158645
|
const lastImRequestMsAgo = lastImAt != null ? now - lastImAt : null;
|
|
158528
158646
|
const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
|
|
158529
158647
|
const lastRotatedMsAgo = lastRotatedAt != null ? now - lastRotatedAt : null;
|
|
158530
|
-
|
|
158648
|
+
const v1 = decideWedgeRotation({
|
|
158531
158649
|
subscriptionCount: s.subscriptions.size,
|
|
158532
158650
|
sessionAgeMs,
|
|
158533
158651
|
lastImRequestMsAgo,
|
|
158534
158652
|
lastRotatedMsAgo
|
|
158535
|
-
})
|
|
158653
|
+
});
|
|
158654
|
+
const lastCmdAt = this.lastCommandImAt.get(s);
|
|
158655
|
+
const commandSilenceMs = lastCmdAt != null ? now - lastCmdAt : null;
|
|
158656
|
+
const subscribeTimes = this.subscribeTimesMs.get(s) ?? [];
|
|
158657
|
+
const giveUpTimes = this.giveUpTimesMs.get(s) ?? [];
|
|
158658
|
+
const v2 = decideWedgeRotationV2({
|
|
158659
|
+
subscriptionCount: s.subscriptions.size,
|
|
158660
|
+
sessionAgeMs,
|
|
158661
|
+
commandSilenceMs,
|
|
158662
|
+
subscribeTimesMs: subscribeTimes,
|
|
158663
|
+
giveUpTimesMs: giveUpTimes,
|
|
158664
|
+
nowMs: now,
|
|
158665
|
+
lastRotatedMsAgo
|
|
158666
|
+
});
|
|
158667
|
+
const cmdSilenceMin = Math.round(
|
|
158668
|
+
(commandSilenceMs ?? sessionAgeMs) / 6e4
|
|
158669
|
+
);
|
|
158670
|
+
const v2Stats = `cmdSilenceMin=${cmdSilenceMin} subscribes30m=${countRecent(subscribeTimes, now)} giveUps30m=${countRecent(giveUpTimes, now)}`;
|
|
158671
|
+
if (v2 && !v1) {
|
|
158672
|
+
this.log.info(`wedge v2 would rotate session ${s.id}: ${v2Stats}`);
|
|
158673
|
+
}
|
|
158674
|
+
if (!v1) {
|
|
158536
158675
|
continue;
|
|
158537
158676
|
}
|
|
158677
|
+
this.log.info(
|
|
158678
|
+
`wedge v2 session ${s.id}: v1 rotated, v2 agree=${v2} ${v2Stats}`
|
|
158679
|
+
);
|
|
158538
158680
|
const silenceMin = Math.round(
|
|
158539
158681
|
(lastImRequestMsAgo ?? sessionAgeMs) / 6e4
|
|
158540
158682
|
);
|
|
@@ -175223,9 +175365,943 @@ var CameraPlugin = class {
|
|
|
175223
175365
|
}
|
|
175224
175366
|
};
|
|
175225
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
|
+
|
|
175226
176301
|
// src/plugins/builtin/index.ts
|
|
175227
176302
|
var BUILTIN_PLUGINS = [
|
|
175228
|
-
CameraPlugin
|
|
176303
|
+
CameraPlugin,
|
|
176304
|
+
SecurityPlugin
|
|
175229
176305
|
];
|
|
175230
176306
|
|
|
175231
176307
|
// src/services/bridges/bridge-endpoint-manager.ts
|
|
@@ -175654,8 +176730,13 @@ var BridgeEndpointManager = class extends Service {
|
|
|
175654
176730
|
if (behaviorId === "pluginDevice") continue;
|
|
175655
176731
|
const behaviorEvents = allEvents[behaviorId];
|
|
175656
176732
|
if (!behaviorEvents || typeof behaviorEvents !== "object") continue;
|
|
175657
|
-
|
|
175658
|
-
|
|
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) {
|
|
175659
176740
|
const observable = behaviorEvents[eventName];
|
|
175660
176741
|
if (!observable || typeof observable.on !== "function") continue;
|
|
175661
176742
|
const attrName = eventName.slice(0, -"$Changed".length);
|
|
@@ -176207,7 +177288,7 @@ var BridgeEndpointManager = class extends Service {
|
|
|
176207
177288
|
init_dist();
|
|
176208
177289
|
init_esm();
|
|
176209
177290
|
init_send_ha_message();
|
|
176210
|
-
import { callService as
|
|
177291
|
+
import { callService as callService3 } from "home-assistant-js-websocket";
|
|
176211
177292
|
import { keys as keys2, pickBy, values as values3 } from "lodash-es";
|
|
176212
177293
|
var BridgeRegistry = class _BridgeRegistry {
|
|
176213
177294
|
constructor(registry3, dataProvider, client) {
|
|
@@ -176593,7 +177674,7 @@ var BridgeRegistry = class _BridgeRegistry {
|
|
|
176593
177674
|
async resolveRoborockRooms(entityId) {
|
|
176594
177675
|
if (!this.client) return [];
|
|
176595
177676
|
try {
|
|
176596
|
-
const raw = await
|
|
177677
|
+
const raw = await callService3(
|
|
176597
177678
|
this.client.connection,
|
|
176598
177679
|
"roborock",
|
|
176599
177680
|
"get_maps",
|
|
@@ -176974,6 +178055,12 @@ init_esm7();
|
|
|
176974
178055
|
import * as os10 from "node:os";
|
|
176975
178056
|
init_diagnostic_event_bus();
|
|
176976
178057
|
var imWrapMarker2 = /* @__PURE__ */ Symbol("hamh.wedgeImWrap");
|
|
178058
|
+
var commandMessageTypes2 = [
|
|
178059
|
+
MessageType.ReadRequest,
|
|
178060
|
+
MessageType.WriteRequest,
|
|
178061
|
+
MessageType.InvokeRequest,
|
|
178062
|
+
MessageType.TimedRequest
|
|
178063
|
+
];
|
|
176977
178064
|
var AUTO_FORCE_SYNC_INTERVAL_MS2 = 9e4;
|
|
176978
178065
|
var SHUTDOWN_SESSION_CLOSE_TIMEOUT_MS2 = 2500;
|
|
176979
178066
|
var MDNS_ADDRESS_CHECK_INTERVAL_MS2 = 6e4;
|
|
@@ -177012,6 +178099,13 @@ var ServerModeBridge = class {
|
|
|
177012
178099
|
lastImRequestAt = /* @__PURE__ */ new WeakMap();
|
|
177013
178100
|
wedgeLastRotatedAt = /* @__PURE__ */ new WeakMap();
|
|
177014
178101
|
wedgeWatchdogTimer = null;
|
|
178102
|
+
// V2 shadow signals (#365): last command-class request per session, plus
|
|
178103
|
+
// rings of subscribe and delivery give-up times (newest WEDGE_RING_SIZE).
|
|
178104
|
+
lastCommandImAt = /* @__PURE__ */ new WeakMap();
|
|
178105
|
+
subscribeTimesMs = /* @__PURE__ */ new WeakMap();
|
|
178106
|
+
giveUpTimesMs = /* @__PURE__ */ new WeakMap();
|
|
178107
|
+
// Sessions whose subscriptions.deleted we already hooked for give-ups.
|
|
178108
|
+
wedgeHookedSessions = /* @__PURE__ */ new WeakSet();
|
|
177015
178109
|
// Watches the advertised interface addresses so a dynamic ISP IPv6 prefix
|
|
177016
178110
|
// change forces a fresh operational announcement (#415).
|
|
177017
178111
|
mdnsAddressTimer = null;
|
|
@@ -177092,6 +178186,20 @@ var ServerModeBridge = class {
|
|
|
177092
178186
|
const lastImAt = this.lastImRequestAt.get(s);
|
|
177093
178187
|
const lastImRequestMsAgo = lastImAt != null ? nowMs - lastImAt : null;
|
|
177094
178188
|
const startedAt = this.sessionStartedAt.get(s.id);
|
|
178189
|
+
const lastCmdAt = this.lastCommandImAt.get(s);
|
|
178190
|
+
const lastCommandImRequestMsAgo = lastCmdAt != null ? nowMs - lastCmdAt : null;
|
|
178191
|
+
const subscribeTimes = this.subscribeTimesMs.get(s) ?? [];
|
|
178192
|
+
const giveUpTimes = this.giveUpTimesMs.get(s) ?? [];
|
|
178193
|
+
const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
|
|
178194
|
+
const wedgeV2WouldRotate = decideWedgeRotationV2({
|
|
178195
|
+
subscriptionCount: subCount,
|
|
178196
|
+
sessionAgeMs: startedAt != null ? nowMs - startedAt : 0,
|
|
178197
|
+
commandSilenceMs: lastCommandImRequestMsAgo,
|
|
178198
|
+
subscribeTimesMs: subscribeTimes,
|
|
178199
|
+
giveUpTimesMs: giveUpTimes,
|
|
178200
|
+
nowMs,
|
|
178201
|
+
lastRotatedMsAgo: lastRotatedAt != null ? nowMs - lastRotatedAt : null
|
|
178202
|
+
});
|
|
177095
178203
|
return {
|
|
177096
178204
|
id: s.id,
|
|
177097
178205
|
peerNodeId: String(s.peerNodeId),
|
|
@@ -177101,6 +178209,10 @@ var ServerModeBridge = class {
|
|
|
177101
178209
|
lastActiveMsAgo,
|
|
177102
178210
|
lastAnyActivityMsAgo,
|
|
177103
178211
|
lastImRequestMsAgo,
|
|
178212
|
+
lastCommandImRequestMsAgo,
|
|
178213
|
+
subscribesLast30Min: countRecent(subscribeTimes, nowMs),
|
|
178214
|
+
giveUpsLast30Min: countRecent(giveUpTimes, nowMs),
|
|
178215
|
+
wedgeV2WouldRotate,
|
|
177104
178216
|
isPeerActive: Boolean(s.isPeerActive),
|
|
177105
178217
|
ageMsFromOpen: startedAt != null ? nowMs - startedAt : null
|
|
177106
178218
|
};
|
|
@@ -177324,6 +178436,7 @@ ${e?.toString()}`);
|
|
|
177324
178436
|
sessionManager.subscriptionsChanged.on(this.sessionDiagHandler);
|
|
177325
178437
|
this.sessionAddedHandler = (newSession) => {
|
|
177326
178438
|
this.sessionStartedAt.set(newSession.id, Date.now());
|
|
178439
|
+
hookGiveUpsWithPeers(newSession);
|
|
177327
178440
|
this.log.info(
|
|
177328
178441
|
`Session opened: id=${newSession.id} peer=${newSession.peerNodeId}`
|
|
177329
178442
|
);
|
|
@@ -177400,6 +178513,15 @@ ${e?.toString()}`);
|
|
|
177400
178513
|
sessionManager.sessions.added.on(this.sessionAddedHandler);
|
|
177401
178514
|
sessionManager.sessions.deleted.on(this.sessionDeletedHandler);
|
|
177402
178515
|
seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
|
|
178516
|
+
const hookGiveUpsWithPeers = (sess) => this.hookSubscriptionGiveUps(
|
|
178517
|
+
sess,
|
|
178518
|
+
() => [...sessionManager.sessions].filter(
|
|
178519
|
+
(s) => s.peerNodeId === sess.peerNodeId && s.fabric?.fabricIndex === sess.fabric?.fabricIndex
|
|
178520
|
+
)
|
|
178521
|
+
);
|
|
178522
|
+
for (const sess of sessionManager.sessions) {
|
|
178523
|
+
hookGiveUpsWithPeers(sess);
|
|
178524
|
+
}
|
|
177403
178525
|
this.wireImRequestTracking();
|
|
177404
178526
|
} catch {
|
|
177405
178527
|
}
|
|
@@ -177418,7 +178540,14 @@ ${e?.toString()}`);
|
|
|
177418
178540
|
is.onNewExchange = (exchange, message) => {
|
|
177419
178541
|
const session = exchange.session;
|
|
177420
178542
|
if (session) {
|
|
177421
|
-
|
|
178543
|
+
const now = Date.now();
|
|
178544
|
+
this.lastImRequestAt.set(session, now);
|
|
178545
|
+
const type = message?.payloadHeader?.messageType;
|
|
178546
|
+
if (type === MessageType.SubscribeRequest) {
|
|
178547
|
+
this.pushWedgeRing(this.subscribeTimesMs, session, now);
|
|
178548
|
+
} else if (type != null && commandMessageTypes2.includes(type)) {
|
|
178549
|
+
this.lastCommandImAt.set(session, now);
|
|
178550
|
+
}
|
|
177422
178551
|
}
|
|
177423
178552
|
return original(exchange, message);
|
|
177424
178553
|
};
|
|
@@ -177426,6 +178555,52 @@ ${e?.toString()}`);
|
|
|
177426
178555
|
} catch {
|
|
177427
178556
|
}
|
|
177428
178557
|
}
|
|
178558
|
+
// Keep only the newest WEDGE_RING_SIZE timestamps per session.
|
|
178559
|
+
pushWedgeRing(ring, session, now) {
|
|
178560
|
+
const times = ring.get(session) ?? [];
|
|
178561
|
+
times.push(now);
|
|
178562
|
+
if (times.length > WEDGE_RING_SIZE) {
|
|
178563
|
+
times.splice(0, times.length - WEDGE_RING_SIZE);
|
|
178564
|
+
}
|
|
178565
|
+
ring.set(session, times);
|
|
178566
|
+
}
|
|
178567
|
+
// Watch this session's subscriptions for server-side delivery give-ups
|
|
178568
|
+
// (#365 v2 shadow). isTerminated true fires both on a peer cancel and on
|
|
178569
|
+
// the 3-strikes delivery give-up; only the give-up arrives without a
|
|
178570
|
+
// coincident inbound IM request, so that is the discriminator.
|
|
178571
|
+
hookSubscriptionGiveUps(session, peerSessions) {
|
|
178572
|
+
const key = session;
|
|
178573
|
+
if (this.wedgeHookedSessions.has(key)) {
|
|
178574
|
+
return;
|
|
178575
|
+
}
|
|
178576
|
+
const deleted = session.subscriptions?.deleted;
|
|
178577
|
+
if (typeof deleted?.on !== "function") {
|
|
178578
|
+
return;
|
|
178579
|
+
}
|
|
178580
|
+
this.wedgeHookedSessions.add(key);
|
|
178581
|
+
deleted.on((sub) => {
|
|
178582
|
+
if (sub?.isTerminated !== true) {
|
|
178583
|
+
return;
|
|
178584
|
+
}
|
|
178585
|
+
const now = Date.now();
|
|
178586
|
+
const fresh = (s) => {
|
|
178587
|
+
const at = this.lastImRequestAt.get(s);
|
|
178588
|
+
return at != null && now - at <= WEDGE_GIVE_UP_QUIET_MS;
|
|
178589
|
+
};
|
|
178590
|
+
if (fresh(key)) {
|
|
178591
|
+
return;
|
|
178592
|
+
}
|
|
178593
|
+
for (const s of peerSessions?.() ?? []) {
|
|
178594
|
+
if (s !== key && fresh(s)) {
|
|
178595
|
+
return;
|
|
178596
|
+
}
|
|
178597
|
+
}
|
|
178598
|
+
this.pushWedgeRing(this.giveUpTimesMs, key, now);
|
|
178599
|
+
this.log.debug(
|
|
178600
|
+
`wedge v2 give-up recorded sub=${sub?.subscriptionId}`
|
|
178601
|
+
);
|
|
178602
|
+
});
|
|
178603
|
+
}
|
|
177429
178604
|
closeStaleSession(sessionId) {
|
|
177430
178605
|
try {
|
|
177431
178606
|
const sessionManager = this.server.env.get(SessionManager);
|
|
@@ -177677,14 +178852,38 @@ ${e?.toString()}`);
|
|
|
177677
178852
|
const lastImRequestMsAgo = lastImAt != null ? now - lastImAt : null;
|
|
177678
178853
|
const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
|
|
177679
178854
|
const lastRotatedMsAgo = lastRotatedAt != null ? now - lastRotatedAt : null;
|
|
177680
|
-
|
|
178855
|
+
const v1 = decideWedgeRotation({
|
|
177681
178856
|
subscriptionCount: s.subscriptions.size,
|
|
177682
178857
|
sessionAgeMs,
|
|
177683
178858
|
lastImRequestMsAgo,
|
|
177684
178859
|
lastRotatedMsAgo
|
|
177685
|
-
})
|
|
178860
|
+
});
|
|
178861
|
+
const lastCmdAt = this.lastCommandImAt.get(s);
|
|
178862
|
+
const commandSilenceMs = lastCmdAt != null ? now - lastCmdAt : null;
|
|
178863
|
+
const subscribeTimes = this.subscribeTimesMs.get(s) ?? [];
|
|
178864
|
+
const giveUpTimes = this.giveUpTimesMs.get(s) ?? [];
|
|
178865
|
+
const v2 = decideWedgeRotationV2({
|
|
178866
|
+
subscriptionCount: s.subscriptions.size,
|
|
178867
|
+
sessionAgeMs,
|
|
178868
|
+
commandSilenceMs,
|
|
178869
|
+
subscribeTimesMs: subscribeTimes,
|
|
178870
|
+
giveUpTimesMs: giveUpTimes,
|
|
178871
|
+
nowMs: now,
|
|
178872
|
+
lastRotatedMsAgo
|
|
178873
|
+
});
|
|
178874
|
+
const cmdSilenceMin = Math.round(
|
|
178875
|
+
(commandSilenceMs ?? sessionAgeMs) / 6e4
|
|
178876
|
+
);
|
|
178877
|
+
const v2Stats = `cmdSilenceMin=${cmdSilenceMin} subscribes30m=${countRecent(subscribeTimes, now)} giveUps30m=${countRecent(giveUpTimes, now)}`;
|
|
178878
|
+
if (v2 && !v1) {
|
|
178879
|
+
this.log.info(`wedge v2 would rotate session ${s.id}: ${v2Stats}`);
|
|
178880
|
+
}
|
|
178881
|
+
if (!v1) {
|
|
177686
178882
|
continue;
|
|
177687
178883
|
}
|
|
178884
|
+
this.log.info(
|
|
178885
|
+
`wedge v2 session ${s.id}: v1 rotated, v2 agree=${v2} ${v2Stats}`
|
|
178886
|
+
);
|
|
177688
178887
|
const silenceMin = Math.round(
|
|
177689
178888
|
(lastImRequestMsAgo ?? sessionAgeMs) / 6e4
|
|
177690
178889
|
);
|