@riddix/hamh 2.1.0-alpha.712 → 2.1.0-alpha.713

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.
@@ -124211,8 +124211,7 @@ var init_bridge_config_schema = __esm({
124211
124211
  sessionMaxAgeHours: {
124212
124212
  title: "Session Rotation Max Age (hours)",
124213
124213
  type: "number",
124214
- description: "Server Mode only. Rotate matter sessions older than this many hours so iPhone clients re-subscribe and Apple Home unsticks 'Updating' tiles. Set 0 to disable rotation. Range 0-168, default 4. (#287)",
124215
- default: 4,
124214
+ description: "Rotate matter sessions older than this many hours so controllers re-establish and re-subscribe. Server Mode rotates every 4h by default; standard bridges only rotate when you set a value here. Set 0 to disable. Range 0 to 168. (#287)",
124216
124215
  minimum: 0,
124217
124216
  maximum: 168
124218
124217
  },
@@ -147398,6 +147397,30 @@ function ensureCommissioningConfig(server) {
147398
147397
 
147399
147398
  // src/services/bridges/bridge.ts
147400
147399
  init_diagnostic_event_bus();
147400
+
147401
+ // src/services/bridges/session-rotation.ts
147402
+ var DEFAULT_SESSION_MAX_AGE_HOURS = 4;
147403
+ var SESSION_MAX_AGE_HOURS_RANGE = { min: 1, max: 168 };
147404
+ var ROTATION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
147405
+ function parseSessionMaxAgeHours(raw) {
147406
+ if (raw == null || raw === "") return DEFAULT_SESSION_MAX_AGE_HOURS;
147407
+ const n = Number.parseInt(raw, 10);
147408
+ if (Number.isNaN(n) || n < 0) return null;
147409
+ if (n === 0) return 0;
147410
+ const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
147411
+ if (n < min) return min;
147412
+ if (n > max) return max;
147413
+ return n;
147414
+ }
147415
+ function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
147416
+ for (const session of sessions) {
147417
+ if (!startedAt.has(session.id)) {
147418
+ startedAt.set(session.id, now);
147419
+ }
147420
+ }
147421
+ }
147422
+
147423
+ // src/services/bridges/bridge.ts
147401
147424
  var AUTO_FORCE_SYNC_INTERVAL_MS = 9e4;
147402
147425
  var DEAD_SESSION_TIMEOUT_MS = 6e4;
147403
147426
  var Bridge = class {
@@ -147436,6 +147459,11 @@ var Bridge = class {
147436
147459
  autoForceSyncTimer = null;
147437
147460
  deadSessionTimer = null;
147438
147461
  staleSessionTimers = /* @__PURE__ */ new Map();
147462
+ // Age-based session rotation (#287): track when each session opened so an
147463
+ // aged controller session can be rotated, forcing it to re-subscribe.
147464
+ sessionStartedAt = /* @__PURE__ */ new Map();
147465
+ rotationTimer = null;
147466
+ maxSessionAgeMs = 0;
147439
147467
  // Serialize concurrent lifecycle calls so auto-recovery and a manual
147440
147468
  // restartBridge can't race past each other's Starting/Stopping states.
147441
147469
  startInFlight;
@@ -147588,6 +147616,7 @@ var Bridge = class {
147588
147616
  });
147589
147617
  }
147590
147618
  this.wireSessionDiagnostics();
147619
+ this.startSessionRotation();
147591
147620
  logMemoryUsage(this.log, "bridge running");
147592
147621
  diagnosticEventBus.emit("bridge_started", `Bridge started`, {
147593
147622
  bridgeId: this.id,
@@ -147645,6 +147674,7 @@ ${e?.toString()}`);
147645
147674
  wireSessionDiagnostics() {
147646
147675
  try {
147647
147676
  const sessionManager = this.server.env.get(SessionManager);
147677
+ seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
147648
147678
  this.sessionDiagHandler = (session) => {
147649
147679
  const sessions = [...sessionManager.sessions];
147650
147680
  let totalSubs = 0;
@@ -147703,6 +147733,7 @@ ${e?.toString()}`);
147703
147733
  };
147704
147734
  sessionManager.subscriptionsChanged.on(this.sessionDiagHandler);
147705
147735
  this.sessionAddedHandler = (newSession) => {
147736
+ this.sessionStartedAt.set(newSession.id, Date.now());
147706
147737
  this.log.info(
147707
147738
  `Session opened: id=${newSession.id} peer=${newSession.peerNodeId}`
147708
147739
  );
@@ -147728,6 +147759,7 @@ ${e?.toString()}`);
147728
147759
  }
147729
147760
  };
147730
147761
  this.sessionDeletedHandler = (session) => {
147762
+ this.sessionStartedAt.delete(session.id);
147731
147763
  const sessions = [...sessionManager.sessions];
147732
147764
  this.log.warn(
147733
147765
  `Session closed: id=${session.id} peer=${session.peerNodeId} | remaining sessions=${sessions.length}`
@@ -147835,6 +147867,8 @@ ${e?.toString()}`);
147835
147867
  clearTimeout(timer);
147836
147868
  }
147837
147869
  this.staleSessionTimers.clear();
147870
+ this.stopSessionRotation();
147871
+ this.sessionStartedAt.clear();
147838
147872
  }
147839
147873
  stopAutoForceSync() {
147840
147874
  if (this.autoForceSyncTimer) {
@@ -147842,12 +147876,100 @@ ${e?.toString()}`);
147842
147876
  this.autoForceSyncTimer = null;
147843
147877
  }
147844
147878
  }
147879
+ // Start periodic age-based session rotation (#287). Aging out a controller's
147880
+ // session forces it to re-establish and re-subscribe, recovering a wedged
147881
+ // Alexa subscription that would otherwise stay stuck until a restart.
147882
+ startSessionRotation() {
147883
+ this.stopSessionRotation();
147884
+ const hours = this.readSessionMaxAgeHours();
147885
+ if (hours === 0) {
147886
+ this.log.info(
147887
+ "Session rotation disabled (HAMH_MATTER_SESSION_MAX_AGE_HOURS=0)"
147888
+ );
147889
+ return;
147890
+ }
147891
+ this.maxSessionAgeMs = hours * 60 * 60 * 1e3;
147892
+ this.rotationTimer = setInterval(
147893
+ () => this.rotateAgedSessions(),
147894
+ ROTATION_CHECK_INTERVAL_MS
147895
+ );
147896
+ this.log.info(
147897
+ `Session rotation: max age ${hours}h, check every ${ROTATION_CHECK_INTERVAL_MS / 6e4}min`
147898
+ );
147899
+ }
147900
+ stopSessionRotation() {
147901
+ if (this.rotationTimer) {
147902
+ clearInterval(this.rotationTimer);
147903
+ this.rotationTimer = null;
147904
+ }
147905
+ }
147906
+ // Resolve the rotation max age. Bridge config wins, then the env var. An
147907
+ // aggregator bridge holds many devices on one controller session, so
147908
+ // rotating it re-subscribes them all at once. Rotation is therefore opt-in
147909
+ // here: it stays disabled unless set via config or the env var.
147910
+ readSessionMaxAgeHours() {
147911
+ const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
147912
+ const fromConfig = this.dataProvider.sessionMaxAgeHours;
147913
+ if (fromConfig != null && Number.isFinite(fromConfig) && fromConfig >= 0) {
147914
+ if (fromConfig === 0) return 0;
147915
+ if (fromConfig < min) return min;
147916
+ if (fromConfig > max) return max;
147917
+ return fromConfig;
147918
+ }
147919
+ const raw = process.env.HAMH_MATTER_SESSION_MAX_AGE_HOURS;
147920
+ if (raw == null || raw === "") {
147921
+ return 0;
147922
+ }
147923
+ const parsed = parseSessionMaxAgeHours(raw);
147924
+ if (parsed == null) {
147925
+ this.log.warn(
147926
+ `Invalid HAMH_MATTER_SESSION_MAX_AGE_HOURS=${raw}, disabling session rotation`
147927
+ );
147928
+ return 0;
147929
+ }
147930
+ return parsed;
147931
+ }
147932
+ // Gracefully close sessions older than maxSessionAgeMs that still hold
147933
+ // subscriptions, so the controller re-establishes CASE and re-subscribes.
147934
+ // 0-sub sessions are handled by the dead/stale-session path.
147935
+ rotateAgedSessions() {
147936
+ if (this.maxSessionAgeMs === 0) return;
147937
+ try {
147938
+ const sessionManager = this.server.env.get(SessionManager);
147939
+ const now = Date.now();
147940
+ const closes = [];
147941
+ for (const s of [...sessionManager.sessions]) {
147942
+ const startedAt = this.sessionStartedAt.get(s.id);
147943
+ if (startedAt == null) continue;
147944
+ const ageMs = now - startedAt;
147945
+ if (ageMs < this.maxSessionAgeMs || s.isClosing || s.subscriptions.size === 0) {
147946
+ continue;
147947
+ }
147948
+ const ageMin = Math.round(ageMs / 6e4);
147949
+ this.log.info(
147950
+ `Rotating session ${s.id} (peer ${s.peerNodeId}, age ${ageMin}min, subs ${s.subscriptions.size})`
147951
+ );
147952
+ closes.push(
147953
+ s.initiateClose().catch(() => {
147954
+ return s.initiateForceClose({
147955
+ cause: new Error("session rotation, forcing")
147956
+ });
147957
+ })
147958
+ );
147959
+ }
147960
+ if (closes.length > 0) {
147961
+ Promise.allSettled(closes).then(() => this.triggerMdnsReAnnounce());
147962
+ }
147963
+ } catch {
147964
+ }
147965
+ }
147845
147966
  async update(update) {
147846
147967
  try {
147847
147968
  this.dataProvider.update(update);
147848
147969
  await this.refreshDevices();
147849
147970
  if (this.status.code === BridgeStatus.Running) {
147850
147971
  this.startAutoForceSyncIfEnabled();
147972
+ this.startSessionRotation();
147851
147973
  }
147852
147974
  } catch (e) {
147853
147975
  const reason = "Failed to update bridge due to error:";
@@ -160910,6 +161032,7 @@ var EntityIsolationService = new EntityIsolationServiceImpl();
160910
161032
 
160911
161033
  // src/services/bridges/bridge-endpoint-manager.ts
160912
161034
  var MAX_ENTITY_ID_LENGTH = 150;
161035
+ var ENDPOINT_REMOVAL_GRACE_MS = 6e4;
160913
161036
  var BridgeEndpointManager = class extends Service {
160914
161037
  constructor(client, registry2, mappingStorage, bridgeId, log, pluginManager, pluginRegistry, pluginInstaller) {
160915
161038
  super("BridgeEndpointManager");
@@ -160943,6 +161066,9 @@ var BridgeEndpointManager = class extends Service {
160943
161066
  unsubscribe;
160944
161067
  _failedEntities = [];
160945
161068
  mappingFingerprints = /* @__PURE__ */ new Map();
161069
+ // entityId -> first time it went missing from the registry (grace window)
161070
+ pendingRemovals = /* @__PURE__ */ new Map();
161071
+ removalRecheckTimer = null;
160946
161072
  pluginEndpoints = /* @__PURE__ */ new Map();
160947
161073
  pluginStateUpdating = /* @__PURE__ */ new Set();
160948
161074
  pluginListeners = /* @__PURE__ */ new Map();
@@ -161145,8 +161271,26 @@ var BridgeEndpointManager = class extends Service {
161145
161271
  } catch (e) {
161146
161272
  this.log.error(`Failed to delete isolated endpoint:`, e);
161147
161273
  }
161274
+ this.pendingRemovals.delete(endpoint.entityId);
161275
+ this.mappingFingerprints.delete(endpoint.entityId);
161148
161276
  }
161149
161277
  }
161278
+ // refreshDevices only runs on registry-fingerprint changes, which may not
161279
+ // recur, so drive any held removals to completion ourselves once the grace
161280
+ // window has passed.
161281
+ scheduleRemovalRecheck() {
161282
+ if (this.removalRecheckTimer) {
161283
+ clearTimeout(this.removalRecheckTimer);
161284
+ this.removalRecheckTimer = null;
161285
+ }
161286
+ if (this.pendingRemovals.size === 0) return;
161287
+ this.removalRecheckTimer = setTimeout(() => {
161288
+ this.removalRecheckTimer = null;
161289
+ this.refreshDevices().catch(
161290
+ (e) => this.log.warn("Endpoint removal recheck failed:", e)
161291
+ );
161292
+ }, ENDPOINT_REMOVAL_GRACE_MS + 5e3);
161293
+ }
161150
161294
  getPluginDomainMappings() {
161151
161295
  if (!this.pluginManager) return void 0;
161152
161296
  const mappings = this.pluginManager.getDomainMappings();
@@ -161166,6 +161310,11 @@ var BridgeEndpointManager = class extends Service {
161166
161310
  }
161167
161311
  async dispose() {
161168
161312
  this.stopObserving();
161313
+ if (this.removalRecheckTimer) {
161314
+ clearTimeout(this.removalRecheckTimer);
161315
+ this.removalRecheckTimer = null;
161316
+ }
161317
+ this.pendingRemovals.clear();
161169
161318
  EntityIsolationService.unregisterIsolationCallback(this.bridgeId);
161170
161319
  EntityIsolationService.clearIsolatedEntities(this.bridgeId);
161171
161320
  const endpoints = this.root.parts.map((p) => p);
@@ -161232,14 +161381,30 @@ var BridgeEndpointManager = class extends Service {
161232
161381
  }
161233
161382
  }
161234
161383
  const existingEndpoints = [];
161384
+ const now = Date.now();
161235
161385
  for (const endpoint of endpoints) {
161236
- if (!this.entityIds.includes(endpoint.entityId)) {
161386
+ const present = this.entityIds.includes(endpoint.entityId);
161387
+ if (present) {
161388
+ this.pendingRemovals.delete(endpoint.entityId);
161389
+ }
161390
+ if (!present) {
161391
+ const since = this.pendingRemovals.get(endpoint.entityId);
161392
+ if (since == null) {
161393
+ this.pendingRemovals.set(endpoint.entityId, now);
161394
+ existingEndpoints.push(endpoint);
161395
+ continue;
161396
+ }
161397
+ if (now - since < ENDPOINT_REMOVAL_GRACE_MS) {
161398
+ existingEndpoints.push(endpoint);
161399
+ continue;
161400
+ }
161237
161401
  try {
161238
161402
  await endpoint.delete();
161239
161403
  } catch (e) {
161240
161404
  this.log.warn(`Failed to delete endpoint ${endpoint.entityId}:`, e);
161241
161405
  }
161242
161406
  this.mappingFingerprints.delete(endpoint.entityId);
161407
+ this.pendingRemovals.delete(endpoint.entityId);
161243
161408
  } else if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(endpoint.entityId)) {
161244
161409
  this.log.info(
161245
161410
  `Deleting standalone endpoint ${endpoint.entityId}, consumed by composed device`
@@ -161275,6 +161440,7 @@ var BridgeEndpointManager = class extends Service {
161275
161440
  }
161276
161441
  }
161277
161442
  }
161443
+ this.scheduleRemovalRecheck();
161278
161444
  let memoryLimitReached = false;
161279
161445
  for (const entityId of this.entityIds) {
161280
161446
  if (!memoryLimitReached && isHeapUnderPressure()) {
@@ -162159,26 +162325,6 @@ init_dist();
162159
162325
  init_diagnostic_event_bus();
162160
162326
  var AUTO_FORCE_SYNC_INTERVAL_MS2 = 9e4;
162161
162327
  var DEAD_SESSION_TIMEOUT_MS2 = 6e4;
162162
- var DEFAULT_SESSION_MAX_AGE_HOURS = 4;
162163
- var SESSION_MAX_AGE_HOURS_RANGE = { min: 1, max: 168 };
162164
- var ROTATION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
162165
- function parseSessionMaxAgeHours(raw) {
162166
- if (raw == null || raw === "") return DEFAULT_SESSION_MAX_AGE_HOURS;
162167
- const n = Number.parseInt(raw, 10);
162168
- if (Number.isNaN(n) || n < 0) return null;
162169
- if (n === 0) return 0;
162170
- const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
162171
- if (n < min) return min;
162172
- if (n > max) return max;
162173
- return n;
162174
- }
162175
- function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
162176
- for (const session of sessions) {
162177
- if (!startedAt.has(session.id)) {
162178
- startedAt.set(session.id, now);
162179
- }
162180
- }
162181
- }
162182
162328
  function makeWarmStartState(state, now = (/* @__PURE__ */ new Date()).toISOString()) {
162183
162329
  return { ...state, last_updated: now };
162184
162330
  }