@riddix/hamh 2.1.0-alpha.834 → 2.1.0-alpha.836

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.
@@ -132058,6 +132058,12 @@ var init_bridge_config_schema = __esm({
132058
132058
  description: "Anchor each device to its Home Assistant entity registry id instead of the entity id, so renaming an entity no longer re-adds it in your controller (Alexa, Google Home, Apple Home) and keeps groups and automations. Records are seeded from the start, so enabling this later is safe and never re-adds existing devices. Default off.",
132059
132059
  type: "boolean",
132060
132060
  default: false
132061
+ },
132062
+ wedgeWatchdog: {
132063
+ title: "Wedge Watchdog (Apple 'Updating' workaround)",
132064
+ description: "Rotate the one session that looks wedged, subscriptions still alive but no inbound request from the controller for about 45 minutes, earlier than the blind session rotation. Targets Apple Home tiles stuck on 'Updating' where the controller keeps acking but stops consuming data. A false positive only triggers a transparent reconnect, the same as normal rotation. Default off.",
132065
+ type: "boolean",
132066
+ default: false
132061
132067
  }
132062
132068
  }
132063
132069
  };
@@ -156805,7 +156811,23 @@ function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
156805
156811
  }
156806
156812
  }
156807
156813
 
156814
+ // src/services/bridges/wedge-watchdog.ts
156815
+ var WEDGE_WARMUP_MS = 15 * 60 * 1e3;
156816
+ var WEDGE_IM_SILENCE_MS = 45 * 60 * 1e3;
156817
+ var WEDGE_MIN_ROTATE_INTERVAL_MS = 60 * 60 * 1e3;
156818
+ function decideWedgeRotation(input) {
156819
+ const {
156820
+ subscriptionCount,
156821
+ sessionAgeMs,
156822
+ lastImRequestMsAgo,
156823
+ lastRotatedMsAgo
156824
+ } = input;
156825
+ const imSilenceMs = lastImRequestMsAgo == null ? sessionAgeMs : lastImRequestMsAgo;
156826
+ return subscriptionCount > 0 && sessionAgeMs > WEDGE_WARMUP_MS && imSilenceMs > WEDGE_IM_SILENCE_MS && (lastRotatedMsAgo == null || lastRotatedMsAgo > WEDGE_MIN_ROTATE_INTERVAL_MS);
156827
+ }
156828
+
156808
156829
  // src/services/bridges/bridge.ts
156830
+ var imWrapMarker = /* @__PURE__ */ Symbol("hamh.wedgeImWrap");
156809
156831
  var AUTO_FORCE_SYNC_INTERVAL_MS = 9e4;
156810
156832
  var SHUTDOWN_SESSION_CLOSE_TIMEOUT_MS = 2500;
156811
156833
  var MDNS_ADDRESS_CHECK_INTERVAL_MS = 6e4;
@@ -156853,6 +156875,12 @@ var Bridge = class {
156853
156875
  sessionStartedAt = /* @__PURE__ */ new Map();
156854
156876
  rotationTimer = null;
156855
156877
  maxSessionAgeMs = 0;
156878
+ // Wedge watchdog: last inbound Interaction Model request time per session and
156879
+ // last time the watchdog rotated it, both keyed by the long-lived session
156880
+ // object so they clear when the session goes away.
156881
+ lastImRequestAt = /* @__PURE__ */ new WeakMap();
156882
+ wedgeLastRotatedAt = /* @__PURE__ */ new WeakMap();
156883
+ wedgeWatchdogTimer = null;
156856
156884
  // Watches the advertised interface addresses so a dynamic ISP IPv6 prefix
156857
156885
  // change forces a fresh operational announcement (#415).
156858
156886
  mdnsAddressTimer = null;
@@ -156912,6 +156940,8 @@ var Bridge = class {
156912
156940
  const nowMs = Date.now();
156913
156941
  const lastActiveMsAgo = typeof s.activeTimestamp === "number" && s.activeTimestamp > 0 ? nowMs - s.activeTimestamp : null;
156914
156942
  const lastAnyActivityMsAgo = typeof s.timestamp === "number" ? nowMs - s.timestamp : null;
156943
+ const lastImAt = this.lastImRequestAt.get(s);
156944
+ const lastImRequestMsAgo = lastImAt != null ? nowMs - lastImAt : null;
156915
156945
  const startedAt = this.sessionStartedAt.get(s.id);
156916
156946
  return {
156917
156947
  id: s.id,
@@ -156920,6 +156950,7 @@ var Bridge = class {
156920
156950
  subscriptionCount: subCount,
156921
156951
  lastActiveMsAgo,
156922
156952
  lastAnyActivityMsAgo,
156953
+ lastImRequestMsAgo,
156923
156954
  isPeerActive: Boolean(s.isPeerActive),
156924
156955
  ageMsFromOpen: startedAt != null ? nowMs - startedAt : null
156925
156956
  };
@@ -157086,6 +157117,7 @@ var Bridge = class {
157086
157117
  this.wireSessionDiagnostics();
157087
157118
  this.wireFabricWarnings();
157088
157119
  this.startSessionRotation();
157120
+ this.startWedgeWatchdog();
157089
157121
  this.startMdnsAddressWatch();
157090
157122
  logMemoryUsage(this.log, "bridge running");
157091
157123
  diagnosticEventBus.emit("bridge_started", `Bridge started`, {
@@ -157285,6 +157317,29 @@ ${e?.toString()}`);
157285
157317
  };
157286
157318
  sessionManager.sessions.added.on(this.sessionAddedHandler);
157287
157319
  sessionManager.sessions.deleted.on(this.sessionDeletedHandler);
157320
+ this.wireImRequestTracking();
157321
+ } catch {
157322
+ }
157323
+ }
157324
+ // Stamp the time of every inbound Interaction Model request per session by
157325
+ // wrapping InteractionServer.onNewExchange. The wedge watchdog reads these to
157326
+ // tell a live-but-consuming controller from one that only keeps acking.
157327
+ wireImRequestTracking() {
157328
+ try {
157329
+ const is = this.server.env.get(InteractionServer);
157330
+ const marked = is;
157331
+ if (marked[imWrapMarker]) {
157332
+ return;
157333
+ }
157334
+ const original = is.onNewExchange.bind(is);
157335
+ is.onNewExchange = (exchange, message) => {
157336
+ const session = exchange.session;
157337
+ if (session) {
157338
+ this.lastImRequestAt.set(session, Date.now());
157339
+ }
157340
+ return original(exchange, message);
157341
+ };
157342
+ marked[imWrapMarker] = true;
157288
157343
  } catch {
157289
157344
  }
157290
157345
  }
@@ -157447,6 +157502,7 @@ ${e?.toString()}`);
157447
157502
  }
157448
157503
  this.staleSessionTimers.clear();
157449
157504
  this.stopSessionRotation();
157505
+ this.stopWedgeWatchdog();
157450
157506
  this.stopMdnsAddressWatch();
157451
157507
  this.sessionStartedAt.clear();
157452
157508
  }
@@ -157512,6 +157568,72 @@ ${e?.toString()}`);
157512
157568
  this.rotationTimer = null;
157513
157569
  }
157514
157570
  }
157571
+ // Opt-in wedge watchdog: reuse the rotation check cadence (5min) to look for
157572
+ // the one session wedged on "Updating" and rotate just that one.
157573
+ startWedgeWatchdog() {
157574
+ this.stopWedgeWatchdog();
157575
+ if (!this.dataProvider.featureFlags?.wedgeWatchdog) {
157576
+ return;
157577
+ }
157578
+ this.wedgeWatchdogTimer = setInterval(
157579
+ () => this.runWedgeWatchdogCheck(),
157580
+ ROTATION_CHECK_INTERVAL_MS
157581
+ );
157582
+ this.log.info(
157583
+ `Wedge watchdog: checking every ${ROTATION_CHECK_INTERVAL_MS / 6e4}min`
157584
+ );
157585
+ }
157586
+ stopWedgeWatchdog() {
157587
+ if (this.wedgeWatchdogTimer) {
157588
+ clearInterval(this.wedgeWatchdogTimer);
157589
+ this.wedgeWatchdogTimer = null;
157590
+ }
157591
+ }
157592
+ // Rotate exactly the sessions the pure rule flags as wedged. Closing is the
157593
+ // same graceful-then-force path age rotation uses, so a false positive just
157594
+ // re-CASEs the controller.
157595
+ runWedgeWatchdogCheck() {
157596
+ try {
157597
+ const sessionManager = this.server.env.get(SessionManager);
157598
+ const now = Date.now();
157599
+ const closes = [];
157600
+ for (const s of [...sessionManager.sessions]) {
157601
+ if (s.isClosing) continue;
157602
+ const startedAt = this.sessionStartedAt.get(s.id);
157603
+ const sessionAgeMs = startedAt != null ? now - startedAt : 0;
157604
+ const lastImAt = this.lastImRequestAt.get(s);
157605
+ const lastImRequestMsAgo = lastImAt != null ? now - lastImAt : null;
157606
+ const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
157607
+ const lastRotatedMsAgo = lastRotatedAt != null ? now - lastRotatedAt : null;
157608
+ if (!decideWedgeRotation({
157609
+ subscriptionCount: s.subscriptions.size,
157610
+ sessionAgeMs,
157611
+ lastImRequestMsAgo,
157612
+ lastRotatedMsAgo
157613
+ })) {
157614
+ continue;
157615
+ }
157616
+ const silenceMin = Math.round(
157617
+ (lastImRequestMsAgo ?? sessionAgeMs) / 6e4
157618
+ );
157619
+ this.log.info(
157620
+ `Wedge watchdog: rotating session ${s.id}, no inbound interaction for ${silenceMin}min`
157621
+ );
157622
+ this.wedgeLastRotatedAt.set(s, now);
157623
+ closes.push(
157624
+ s.initiateClose().catch(() => {
157625
+ return s.initiateForceClose({
157626
+ cause: new Error("wedge watchdog, forcing")
157627
+ });
157628
+ })
157629
+ );
157630
+ }
157631
+ if (closes.length > 0) {
157632
+ Promise.allSettled(closes).then(() => this.triggerMdnsReAnnounce());
157633
+ }
157634
+ } catch {
157635
+ }
157636
+ }
157515
157637
  // Poll the interface addresses mDNS advertises and re-announce when they
157516
157638
  // change. matter.js caches the operational records at first announcement, so
157517
157639
  // a dynamic ISP IPv6 prefix change keeps advertising the dead global address
@@ -157643,6 +157765,7 @@ ${e?.toString()}`);
157643
157765
  if (this.status.code === BridgeStatus.Running) {
157644
157766
  this.startAutoForceSyncIfEnabled();
157645
157767
  this.startSessionRotation();
157768
+ this.startWedgeWatchdog();
157646
157769
  }
157647
157770
  } catch (e) {
157648
157771
  const reason = "Failed to update bridge due to error:";
@@ -169579,20 +169702,22 @@ function resolveCleanAreaIds(selectedAreas, cleanAreaRooms) {
169579
169702
  }
169580
169703
  return haAreaIds;
169581
169704
  }
169705
+ var cleaningStates = [
169706
+ VacuumState.cleaning,
169707
+ VacuumState.segment_cleaning,
169708
+ VacuumState.zone_cleaning,
169709
+ VacuumState.spot_cleaning,
169710
+ VacuumState.mop_cleaning,
169711
+ VacuumState.paused
169712
+ ];
169713
+ function vacuumIsCleaning(state) {
169714
+ return state != null && cleaningStates.includes(state);
169715
+ }
169582
169716
  var vacuumRvcRunModeConfig = {
169583
169717
  getCurrentMode: (entity) => {
169584
- const state = entity.state;
169585
- const cleaningStates = [
169586
- VacuumState.cleaning,
169587
- VacuumState.segment_cleaning,
169588
- VacuumState.zone_cleaning,
169589
- VacuumState.spot_cleaning,
169590
- VacuumState.mop_cleaning,
169591
- VacuumState.paused
169592
- ];
169593
- const isCleaning = cleaningStates.includes(state);
169718
+ const isCleaning = vacuumIsCleaning(entity.state);
169594
169719
  logger235.debug(
169595
- `Vacuum state: "${state}", isCleaning: ${isCleaning}, currentMode: ${isCleaning ? "Cleaning" : "Idle"}`
169720
+ `Vacuum state: "${entity.state}", isCleaning: ${isCleaning}, currentMode: ${isCleaning ? "Cleaning" : "Idle"}`
169596
169721
  );
169597
169722
  return isCleaning ? 1 /* Cleaning */ : 0 /* Idle */;
169598
169723
  },
@@ -169926,11 +170051,12 @@ function createCleanAreaRvcRunModeServer(cleanAreaRooms) {
169926
170051
  currentMode: 0 /* Idle */
169927
170052
  });
169928
170053
  }
169929
- var VacuumRvcRunModeServer = RvcRunModeServer2(vacuumRvcRunModeConfig);
169930
170054
 
169931
170055
  // src/matter/endpoints/legacy/vacuum/behaviors/vacuum-on-off-server.ts
169932
170056
  var VacuumOnOffServer = OnOffServer2({
169933
- isOn: (_, agent) => agent.get(VacuumRvcRunModeServer).state.currentMode === 1 /* Cleaning */,
170057
+ // Derive from the entity directly. Reading the sibling rvcRunMode behavior
170058
+ // crashed with a class mismatch and would lag one tick behind anyway (#428).
170059
+ isOn: (entity) => vacuumIsCleaning(entity.state),
169934
170060
  turnOn: () => ({ action: "vacuum.start" }),
169935
170061
  turnOff: () => ({ action: "vacuum.return_to_base" })
169936
170062
  }).with();
@@ -170743,7 +170869,7 @@ function isDockedCharging(entity, batteryPercent) {
170743
170869
  }
170744
170870
  function mapVacuumOperationalState(entity, batteryPercent = batteryFromAttributes(entity.attributes), chargingState = null) {
170745
170871
  const state = entity.state;
170746
- const cleaningStates = [
170872
+ const cleaningStates2 = [
170747
170873
  VacuumState.cleaning,
170748
170874
  VacuumState.segment_cleaning,
170749
170875
  VacuumState.zone_cleaning,
@@ -170756,7 +170882,7 @@ function mapVacuumOperationalState(entity, batteryPercent = batteryFromAttribute
170756
170882
  operationalState = charging ? RvcOperationalState4.OperationalState.Charging : RvcOperationalState4.OperationalState.Docked;
170757
170883
  } else if (state === VacuumState.returning) {
170758
170884
  operationalState = RvcOperationalState4.OperationalState.SeekingCharger;
170759
- } else if (cleaningStates.includes(state)) {
170885
+ } else if (cleaningStates2.includes(state)) {
170760
170886
  operationalState = RvcOperationalState4.OperationalState.Running;
170761
170887
  } else if (state === VacuumState.paused) {
170762
170888
  operationalState = RvcOperationalState4.OperationalState.Paused;
@@ -174375,6 +174501,7 @@ init_dist();
174375
174501
  init_esm7();
174376
174502
  import * as os10 from "node:os";
174377
174503
  init_diagnostic_event_bus();
174504
+ var imWrapMarker2 = /* @__PURE__ */ Symbol("hamh.wedgeImWrap");
174378
174505
  var AUTO_FORCE_SYNC_INTERVAL_MS2 = 9e4;
174379
174506
  var SHUTDOWN_SESSION_CLOSE_TIMEOUT_MS2 = 2500;
174380
174507
  var MDNS_ADDRESS_CHECK_INTERVAL_MS2 = 6e4;
@@ -174407,6 +174534,12 @@ var ServerModeBridge = class {
174407
174534
  sessionStartedAt = /* @__PURE__ */ new Map();
174408
174535
  rotationTimer = null;
174409
174536
  maxSessionAgeMs = 0;
174537
+ // Wedge watchdog: last inbound Interaction Model request time per session and
174538
+ // last time the watchdog rotated it, both keyed by the long-lived session
174539
+ // object so they clear when the session goes away.
174540
+ lastImRequestAt = /* @__PURE__ */ new WeakMap();
174541
+ wedgeLastRotatedAt = /* @__PURE__ */ new WeakMap();
174542
+ wedgeWatchdogTimer = null;
174410
174543
  // Watches the advertised interface addresses so a dynamic ISP IPv6 prefix
174411
174544
  // change forces a fresh operational announcement (#415).
174412
174545
  mdnsAddressTimer = null;
@@ -174483,6 +174616,8 @@ var ServerModeBridge = class {
174483
174616
  const nowMs = Date.now();
174484
174617
  const lastActiveMsAgo = typeof s.activeTimestamp === "number" && s.activeTimestamp > 0 ? nowMs - s.activeTimestamp : null;
174485
174618
  const lastAnyActivityMsAgo = typeof s.timestamp === "number" ? nowMs - s.timestamp : null;
174619
+ const lastImAt = this.lastImRequestAt.get(s);
174620
+ const lastImRequestMsAgo = lastImAt != null ? nowMs - lastImAt : null;
174486
174621
  const startedAt = this.sessionStartedAt.get(s.id);
174487
174622
  return {
174488
174623
  id: s.id,
@@ -174491,6 +174626,7 @@ var ServerModeBridge = class {
174491
174626
  subscriptionCount: subCount,
174492
174627
  lastActiveMsAgo,
174493
174628
  lastAnyActivityMsAgo,
174629
+ lastImRequestMsAgo,
174494
174630
  isPeerActive: Boolean(s.isPeerActive),
174495
174631
  ageMsFromOpen: startedAt != null ? nowMs - startedAt : null
174496
174632
  };
@@ -174554,6 +174690,7 @@ var ServerModeBridge = class {
174554
174690
  this.wireSessionDiagnostics();
174555
174691
  this.wireFabricWarnings();
174556
174692
  this.startSessionRotation();
174693
+ this.startWedgeWatchdog();
174557
174694
  this.startMdnsAddressWatch();
174558
174695
  this.scheduleWarmStart();
174559
174696
  logMemoryUsage(this.log, "server mode bridge running");
@@ -174571,6 +174708,7 @@ ${e?.toString()}`);
174571
174708
  }
174572
174709
  async stop(code = BridgeStatus.Stopped, reason = "Manually stopped") {
174573
174710
  this.stopSessionRotation();
174711
+ this.stopWedgeWatchdog();
174574
174712
  this.stopMdnsAddressWatch();
174575
174713
  this.unwireSessionDiagnostics();
174576
174714
  this.unwireFabricWarnings();
@@ -174603,6 +174741,7 @@ ${e?.toString()}`);
174603
174741
  if (this.status.code === BridgeStatus.Running) {
174604
174742
  this.startAutoForceSyncIfEnabled();
174605
174743
  this.startSessionRotation();
174744
+ this.startWedgeWatchdog();
174606
174745
  }
174607
174746
  } catch (e) {
174608
174747
  const reason = "Failed to update server mode bridge due to error:";
@@ -174787,6 +174926,29 @@ ${e?.toString()}`);
174787
174926
  sessionManager.sessions.added.on(this.sessionAddedHandler);
174788
174927
  sessionManager.sessions.deleted.on(this.sessionDeletedHandler);
174789
174928
  seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
174929
+ this.wireImRequestTracking();
174930
+ } catch {
174931
+ }
174932
+ }
174933
+ // Stamp the time of every inbound Interaction Model request per session by
174934
+ // wrapping InteractionServer.onNewExchange. The wedge watchdog reads these to
174935
+ // tell a live-but-consuming controller from one that only keeps acking.
174936
+ wireImRequestTracking() {
174937
+ try {
174938
+ const is = this.server.env.get(InteractionServer);
174939
+ const marked = is;
174940
+ if (marked[imWrapMarker2]) {
174941
+ return;
174942
+ }
174943
+ const original = is.onNewExchange.bind(is);
174944
+ is.onNewExchange = (exchange, message) => {
174945
+ const session = exchange.session;
174946
+ if (session) {
174947
+ this.lastImRequestAt.set(session, Date.now());
174948
+ }
174949
+ return original(exchange, message);
174950
+ };
174951
+ marked[imWrapMarker2] = true;
174790
174952
  } catch {
174791
174953
  }
174792
174954
  }
@@ -175004,6 +175166,72 @@ ${e?.toString()}`);
175004
175166
  this.rotationTimer = null;
175005
175167
  }
175006
175168
  }
175169
+ // Opt-in wedge watchdog: reuse the rotation check cadence (5min) to look for
175170
+ // the one session wedged on "Updating" and rotate just that one.
175171
+ startWedgeWatchdog() {
175172
+ this.stopWedgeWatchdog();
175173
+ if (!this.dataProvider.featureFlags?.wedgeWatchdog) {
175174
+ return;
175175
+ }
175176
+ this.wedgeWatchdogTimer = setInterval(
175177
+ () => this.runWedgeWatchdogCheck(),
175178
+ ROTATION_CHECK_INTERVAL_MS
175179
+ );
175180
+ this.log.info(
175181
+ `Wedge watchdog: checking every ${ROTATION_CHECK_INTERVAL_MS / 6e4}min`
175182
+ );
175183
+ }
175184
+ stopWedgeWatchdog() {
175185
+ if (this.wedgeWatchdogTimer) {
175186
+ clearInterval(this.wedgeWatchdogTimer);
175187
+ this.wedgeWatchdogTimer = null;
175188
+ }
175189
+ }
175190
+ // Rotate exactly the sessions the pure rule flags as wedged. Closing is the
175191
+ // same graceful-then-force path age rotation uses, so a false positive just
175192
+ // re-CASEs the controller.
175193
+ runWedgeWatchdogCheck() {
175194
+ try {
175195
+ const sessionManager = this.server.env.get(SessionManager);
175196
+ const now = Date.now();
175197
+ const closes = [];
175198
+ for (const s of [...sessionManager.sessions]) {
175199
+ if (s.isClosing) continue;
175200
+ const startedAt = this.sessionStartedAt.get(s.id);
175201
+ const sessionAgeMs = startedAt != null ? now - startedAt : 0;
175202
+ const lastImAt = this.lastImRequestAt.get(s);
175203
+ const lastImRequestMsAgo = lastImAt != null ? now - lastImAt : null;
175204
+ const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
175205
+ const lastRotatedMsAgo = lastRotatedAt != null ? now - lastRotatedAt : null;
175206
+ if (!decideWedgeRotation({
175207
+ subscriptionCount: s.subscriptions.size,
175208
+ sessionAgeMs,
175209
+ lastImRequestMsAgo,
175210
+ lastRotatedMsAgo
175211
+ })) {
175212
+ continue;
175213
+ }
175214
+ const silenceMin = Math.round(
175215
+ (lastImRequestMsAgo ?? sessionAgeMs) / 6e4
175216
+ );
175217
+ this.log.info(
175218
+ `Wedge watchdog: rotating session ${s.id}, no inbound interaction for ${silenceMin}min`
175219
+ );
175220
+ this.wedgeLastRotatedAt.set(s, now);
175221
+ closes.push(
175222
+ s.initiateClose().catch(() => {
175223
+ return s.initiateForceClose({
175224
+ cause: new Error("wedge watchdog, forcing")
175225
+ });
175226
+ })
175227
+ );
175228
+ }
175229
+ if (closes.length > 0) {
175230
+ Promise.allSettled(closes).then(() => this.triggerMdnsReAnnounce());
175231
+ }
175232
+ } catch {
175233
+ }
175234
+ }
175007
175235
  // Poll the interface addresses mDNS advertises and re-announce when they
175008
175236
  // change. matter.js caches the operational records at first announcement, so
175009
175237
  // a dynamic ISP IPv6 prefix change keeps advertising the dead global address