@riddix/hamh 2.1.0-alpha.711 → 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
  },
@@ -145361,6 +145360,15 @@ var ServerModeServerNode = class extends ServerNode {
145361
145360
  );
145362
145361
  }
145363
145362
  }
145363
+ // align the pairing device-type hint with the real device (default is vacuum)
145364
+ async updateAdvertisedDeviceType(deviceType) {
145365
+ try {
145366
+ await this.set({ productDescription: { deviceType } });
145367
+ } catch (e) {
145368
+ const msg = e instanceof Error ? e.message : String(e);
145369
+ logger180.warn(`Failed to set server-mode device type: ${msg}`);
145370
+ }
145371
+ }
145364
145372
  async factoryReset() {
145365
145373
  await this.cancel();
145366
145374
  await this.erase();
@@ -147389,6 +147397,30 @@ function ensureCommissioningConfig(server) {
147389
147397
 
147390
147398
  // src/services/bridges/bridge.ts
147391
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
147392
147424
  var AUTO_FORCE_SYNC_INTERVAL_MS = 9e4;
147393
147425
  var DEAD_SESSION_TIMEOUT_MS = 6e4;
147394
147426
  var Bridge = class {
@@ -147427,6 +147459,11 @@ var Bridge = class {
147427
147459
  autoForceSyncTimer = null;
147428
147460
  deadSessionTimer = null;
147429
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;
147430
147467
  // Serialize concurrent lifecycle calls so auto-recovery and a manual
147431
147468
  // restartBridge can't race past each other's Starting/Stopping states.
147432
147469
  startInFlight;
@@ -147579,6 +147616,7 @@ var Bridge = class {
147579
147616
  });
147580
147617
  }
147581
147618
  this.wireSessionDiagnostics();
147619
+ this.startSessionRotation();
147582
147620
  logMemoryUsage(this.log, "bridge running");
147583
147621
  diagnosticEventBus.emit("bridge_started", `Bridge started`, {
147584
147622
  bridgeId: this.id,
@@ -147636,6 +147674,7 @@ ${e?.toString()}`);
147636
147674
  wireSessionDiagnostics() {
147637
147675
  try {
147638
147676
  const sessionManager = this.server.env.get(SessionManager);
147677
+ seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
147639
147678
  this.sessionDiagHandler = (session) => {
147640
147679
  const sessions = [...sessionManager.sessions];
147641
147680
  let totalSubs = 0;
@@ -147694,6 +147733,7 @@ ${e?.toString()}`);
147694
147733
  };
147695
147734
  sessionManager.subscriptionsChanged.on(this.sessionDiagHandler);
147696
147735
  this.sessionAddedHandler = (newSession) => {
147736
+ this.sessionStartedAt.set(newSession.id, Date.now());
147697
147737
  this.log.info(
147698
147738
  `Session opened: id=${newSession.id} peer=${newSession.peerNodeId}`
147699
147739
  );
@@ -147719,6 +147759,7 @@ ${e?.toString()}`);
147719
147759
  }
147720
147760
  };
147721
147761
  this.sessionDeletedHandler = (session) => {
147762
+ this.sessionStartedAt.delete(session.id);
147722
147763
  const sessions = [...sessionManager.sessions];
147723
147764
  this.log.warn(
147724
147765
  `Session closed: id=${session.id} peer=${session.peerNodeId} | remaining sessions=${sessions.length}`
@@ -147826,6 +147867,8 @@ ${e?.toString()}`);
147826
147867
  clearTimeout(timer);
147827
147868
  }
147828
147869
  this.staleSessionTimers.clear();
147870
+ this.stopSessionRotation();
147871
+ this.sessionStartedAt.clear();
147829
147872
  }
147830
147873
  stopAutoForceSync() {
147831
147874
  if (this.autoForceSyncTimer) {
@@ -147833,12 +147876,100 @@ ${e?.toString()}`);
147833
147876
  this.autoForceSyncTimer = null;
147834
147877
  }
147835
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
+ }
147836
147966
  async update(update) {
147837
147967
  try {
147838
147968
  this.dataProvider.update(update);
147839
147969
  await this.refreshDevices();
147840
147970
  if (this.status.code === BridgeStatus.Running) {
147841
147971
  this.startAutoForceSyncIfEnabled();
147972
+ this.startSessionRotation();
147842
147973
  }
147843
147974
  } catch (e) {
147844
147975
  const reason = "Failed to update bridge due to error:";
@@ -160218,10 +160349,29 @@ var UserComposedEndpoint = class _UserComposedEndpoint extends Endpoint {
160218
160349
  }
160219
160350
  };
160220
160351
 
160352
+ // src/matter/endpoints/standalone-endpoint-type.ts
160353
+ init_esm7();
160354
+ var BRIDGED_INFO_ID = "bridgedDeviceBasicInformation";
160355
+ function asStandaloneEndpointType(type) {
160356
+ const behaviors = type.behaviors;
160357
+ if (!(BRIDGED_INFO_ID in behaviors)) {
160358
+ return type;
160359
+ }
160360
+ const kept = Object.entries(behaviors).filter(([id]) => id !== BRIDGED_INFO_ID).map(([, behavior]) => behavior);
160361
+ return MutableEndpoint({
160362
+ name: type.name,
160363
+ deviceType: type.deviceType,
160364
+ deviceRevision: type.deviceRevision,
160365
+ deviceClass: type.deviceClass,
160366
+ requirements: type.requirements,
160367
+ behaviors: SupportedBehaviors(...kept)
160368
+ });
160369
+ }
160370
+
160221
160371
  // src/matter/endpoints/legacy/legacy-endpoint.ts
160222
160372
  var logger225 = Logger.get("LegacyEndpoint");
160223
160373
  var LegacyEndpoint = class _LegacyEndpoint extends EntityEndpoint {
160224
- static async create(registry2, entityId, mapping, pluginDomainMappings) {
160374
+ static async create(registry2, entityId, mapping, pluginDomainMappings, standalone = false) {
160225
160375
  const deviceRegistry = registry2.deviceOf(entityId);
160226
160376
  let state = registry2.initialState(entityId);
160227
160377
  const entity = registry2.entity(entityId);
@@ -160547,7 +160697,7 @@ var LegacyEndpoint = class _LegacyEndpoint extends EntityEndpoint {
160547
160697
  }
160548
160698
  }
160549
160699
  const areaName = registry2.getAreaName(entityId);
160550
- const type = createLegacyEndpointType(payload, effectiveMapping, areaName, {
160700
+ let type = createLegacyEndpointType(payload, effectiveMapping, areaName, {
160551
160701
  vacuumOnOff: registry2.isVacuumOnOffEnabled(),
160552
160702
  cleaningModeOptions,
160553
160703
  pluginDomainMappings
@@ -160555,6 +160705,9 @@ var LegacyEndpoint = class _LegacyEndpoint extends EntityEndpoint {
160555
160705
  if (!type) {
160556
160706
  return;
160557
160707
  }
160708
+ if (standalone) {
160709
+ type = asStandaloneEndpointType(type);
160710
+ }
160558
160711
  const customName = effectiveMapping?.customName;
160559
160712
  const mappedIds = getMappedEntityIds(effectiveMapping);
160560
160713
  return new _LegacyEndpoint(type, entityId, customName, mappedIds);
@@ -160879,6 +161032,7 @@ var EntityIsolationService = new EntityIsolationServiceImpl();
160879
161032
 
160880
161033
  // src/services/bridges/bridge-endpoint-manager.ts
160881
161034
  var MAX_ENTITY_ID_LENGTH = 150;
161035
+ var ENDPOINT_REMOVAL_GRACE_MS = 6e4;
160882
161036
  var BridgeEndpointManager = class extends Service {
160883
161037
  constructor(client, registry2, mappingStorage, bridgeId, log, pluginManager, pluginRegistry, pluginInstaller) {
160884
161038
  super("BridgeEndpointManager");
@@ -160912,6 +161066,9 @@ var BridgeEndpointManager = class extends Service {
160912
161066
  unsubscribe;
160913
161067
  _failedEntities = [];
160914
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;
160915
161072
  pluginEndpoints = /* @__PURE__ */ new Map();
160916
161073
  pluginStateUpdating = /* @__PURE__ */ new Set();
160917
161074
  pluginListeners = /* @__PURE__ */ new Map();
@@ -161114,7 +161271,25 @@ var BridgeEndpointManager = class extends Service {
161114
161271
  } catch (e) {
161115
161272
  this.log.error(`Failed to delete isolated endpoint:`, e);
161116
161273
  }
161274
+ this.pendingRemovals.delete(endpoint.entityId);
161275
+ this.mappingFingerprints.delete(endpoint.entityId);
161276
+ }
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;
161117
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);
161118
161293
  }
161119
161294
  getPluginDomainMappings() {
161120
161295
  if (!this.pluginManager) return void 0;
@@ -161135,6 +161310,11 @@ var BridgeEndpointManager = class extends Service {
161135
161310
  }
161136
161311
  async dispose() {
161137
161312
  this.stopObserving();
161313
+ if (this.removalRecheckTimer) {
161314
+ clearTimeout(this.removalRecheckTimer);
161315
+ this.removalRecheckTimer = null;
161316
+ }
161317
+ this.pendingRemovals.clear();
161138
161318
  EntityIsolationService.unregisterIsolationCallback(this.bridgeId);
161139
161319
  EntityIsolationService.clearIsolatedEntities(this.bridgeId);
161140
161320
  const endpoints = this.root.parts.map((p) => p);
@@ -161201,14 +161381,30 @@ var BridgeEndpointManager = class extends Service {
161201
161381
  }
161202
161382
  }
161203
161383
  const existingEndpoints = [];
161384
+ const now = Date.now();
161204
161385
  for (const endpoint of endpoints) {
161205
- 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
+ }
161206
161401
  try {
161207
161402
  await endpoint.delete();
161208
161403
  } catch (e) {
161209
161404
  this.log.warn(`Failed to delete endpoint ${endpoint.entityId}:`, e);
161210
161405
  }
161211
161406
  this.mappingFingerprints.delete(endpoint.entityId);
161407
+ this.pendingRemovals.delete(endpoint.entityId);
161212
161408
  } else if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(endpoint.entityId)) {
161213
161409
  this.log.info(
161214
161410
  `Deleting standalone endpoint ${endpoint.entityId}, consumed by composed device`
@@ -161244,6 +161440,7 @@ var BridgeEndpointManager = class extends Service {
161244
161440
  }
161245
161441
  }
161246
161442
  }
161443
+ this.scheduleRemovalRecheck();
161247
161444
  let memoryLimitReached = false;
161248
161445
  for (const entityId of this.entityIds) {
161249
161446
  if (!memoryLimitReached && isHeapUnderPressure()) {
@@ -162128,26 +162325,6 @@ init_dist();
162128
162325
  init_diagnostic_event_bus();
162129
162326
  var AUTO_FORCE_SYNC_INTERVAL_MS2 = 9e4;
162130
162327
  var DEAD_SESSION_TIMEOUT_MS2 = 6e4;
162131
- var DEFAULT_SESSION_MAX_AGE_HOURS = 4;
162132
- var SESSION_MAX_AGE_HOURS_RANGE = { min: 1, max: 168 };
162133
- var ROTATION_CHECK_INTERVAL_MS = 5 * 60 * 1e3;
162134
- function parseSessionMaxAgeHours(raw) {
162135
- if (raw == null || raw === "") return DEFAULT_SESSION_MAX_AGE_HOURS;
162136
- const n = Number.parseInt(raw, 10);
162137
- if (Number.isNaN(n) || n < 0) return null;
162138
- if (n === 0) return 0;
162139
- const { min, max } = SESSION_MAX_AGE_HOURS_RANGE;
162140
- if (n < min) return min;
162141
- if (n > max) return max;
162142
- return n;
162143
- }
162144
- function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
162145
- for (const session of sessions) {
162146
- if (!startedAt.has(session.id)) {
162147
- startedAt.set(session.id, now);
162148
- }
162149
- }
162150
- }
162151
162328
  function makeWarmStartState(state, now = (/* @__PURE__ */ new Date()).toISOString()) {
162152
162329
  return { ...state, last_updated: now };
162153
162330
  }
@@ -163243,7 +163420,9 @@ var ServerModeEndpointManager = class extends Service {
163243
163420
  const endpoint = await LegacyEndpoint.create(
163244
163421
  this.registry,
163245
163422
  entityId,
163246
- mapping
163423
+ mapping,
163424
+ void 0,
163425
+ true
163247
163426
  );
163248
163427
  if (!endpoint) {
163249
163428
  this._failedEntities.push({
@@ -163286,6 +163465,10 @@ var ServerModeEndpointManager = class extends Service {
163286
163465
  mapping,
163287
163466
  friendlyName
163288
163467
  );
163468
+ const deviceType = this.deviceEndpoint?.type?.deviceType;
163469
+ if (deviceType != null) {
163470
+ await this.serverNode.updateAdvertisedDeviceType(deviceType);
163471
+ }
163289
163472
  }
163290
163473
  /**
163291
163474
  * Creates a Server Mode Vacuum endpoint without BridgedDeviceBasicInformation.