@riddix/hamh 2.1.0-alpha.835 → 2.1.0-alpha.837

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
  };
@@ -135034,6 +135040,298 @@ function resolveLabelValue(value, labels) {
135034
135040
  return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
135035
135041
  }
135036
135042
 
135043
+ // src/matter/endpoints/entity-endpoint.ts
135044
+ init_esm7();
135045
+ var EntityEndpoint = class extends Endpoint {
135046
+ constructor(type, entityId, customName, mappedEntityIds, endpointId) {
135047
+ super(type, { id: endpointId ?? createEndpointId(entityId, customName) });
135048
+ this.entityId = entityId;
135049
+ this.mappedEntityIds = mappedEntityIds ?? [];
135050
+ }
135051
+ entityId;
135052
+ mappedEntityIds;
135053
+ lastMappedStates = {};
135054
+ hasMappedEntityChanged(states) {
135055
+ let changed = false;
135056
+ for (const mappedId of this.mappedEntityIds) {
135057
+ const mappedState = states[mappedId];
135058
+ if (!mappedState) continue;
135059
+ const fp = mappedState.state;
135060
+ if (fp !== this.lastMappedStates[mappedId]) {
135061
+ this.lastMappedStates[mappedId] = fp;
135062
+ changed = true;
135063
+ }
135064
+ }
135065
+ return changed;
135066
+ }
135067
+ };
135068
+ function createEndpointId(entityId, customName) {
135069
+ const baseName = customName || entityId;
135070
+ return baseName.replace(/\./g, "_").replace(/\s+/g, "_");
135071
+ }
135072
+ function getMappedEntityIds(mapping) {
135073
+ if (!mapping) return [];
135074
+ const ids = [];
135075
+ if (mapping.batteryEntity && !mapping.disableBatteryMapping) {
135076
+ ids.push(mapping.batteryEntity);
135077
+ }
135078
+ if (mapping.faultEntity) ids.push(mapping.faultEntity);
135079
+ if (mapping.chargingStateEntity) ids.push(mapping.chargingStateEntity);
135080
+ if (mapping.temperatureEntity) ids.push(mapping.temperatureEntity);
135081
+ if (mapping.humidityEntity) ids.push(mapping.humidityEntity);
135082
+ if (mapping.pressureEntity) ids.push(mapping.pressureEntity);
135083
+ if (mapping.cleaningModeEntity) ids.push(mapping.cleaningModeEntity);
135084
+ if (mapping.suctionLevelEntity) ids.push(mapping.suctionLevelEntity);
135085
+ if (mapping.mopIntensityEntity) ids.push(mapping.mopIntensityEntity);
135086
+ if (mapping.filterLifeEntity) ids.push(mapping.filterLifeEntity);
135087
+ if (mapping.powerEntity) ids.push(mapping.powerEntity);
135088
+ if (mapping.energyEntity) ids.push(mapping.energyEntity);
135089
+ if (mapping.voltageEntity) ids.push(mapping.voltageEntity);
135090
+ if (mapping.currentEntity) ids.push(mapping.currentEntity);
135091
+ if (mapping.batteryPowerEntity) ids.push(mapping.batteryPowerEntity);
135092
+ if (mapping.batteryEnergyEntity) ids.push(mapping.batteryEnergyEntity);
135093
+ if (mapping.currentRoomEntity) ids.push(mapping.currentRoomEntity);
135094
+ if (mapping.cleanedAreaEntity) ids.push(mapping.cleanedAreaEntity);
135095
+ if (mapping.composedEntities) {
135096
+ for (const sub of mapping.composedEntities) {
135097
+ if (sub.entityId) ids.push(sub.entityId);
135098
+ }
135099
+ }
135100
+ return ids;
135101
+ }
135102
+
135103
+ // src/services/bridges/identity-resolver.ts
135104
+ var SEP = "\0";
135105
+ function identityKey(entity) {
135106
+ const uniqueId = entity.registry?.unique_id;
135107
+ const platform = entity.registry?.platform;
135108
+ if (!uniqueId || !platform) {
135109
+ return null;
135110
+ }
135111
+ const domain = entity.entity_id.split(".")[0];
135112
+ return platform + SEP + domain + SEP + uniqueId;
135113
+ }
135114
+ var IdentityResolver = class {
135115
+ constructor(identityStorage, mappingStorage) {
135116
+ this.identityStorage = identityStorage;
135117
+ this.mappingStorage = mappingStorage;
135118
+ }
135119
+ identityStorage;
135120
+ mappingStorage;
135121
+ async resolveIdentity(bridgeId, entity, mapping, opts) {
135122
+ const entityId = entity.entity_id;
135123
+ const key = identityKey(entity);
135124
+ if (key == null) {
135125
+ return {
135126
+ endpointId: createEndpointId(entityId, mapping?.customName),
135127
+ anchorEntityId: entityId,
135128
+ protected: false
135129
+ };
135130
+ }
135131
+ const existing = this.identityStorage.getIdentity(bridgeId, key);
135132
+ if (existing) {
135133
+ const renamedFrom = existing.lastEntityId !== entityId ? existing.lastEntityId ?? existing.anchorEntityId : void 0;
135134
+ if (opts.stableIdentity) {
135135
+ if (renamedFrom) {
135136
+ this.identityStorage.setIdentity(bridgeId, key, {
135137
+ ...existing,
135138
+ lastEntityId: entityId
135139
+ });
135140
+ await this.rekeyMapping(bridgeId, renamedFrom, entityId);
135141
+ }
135142
+ return {
135143
+ endpointId: existing.endpointId,
135144
+ anchorEntityId: existing.anchorEntityId,
135145
+ protected: true,
135146
+ renamedFrom
135147
+ };
135148
+ }
135149
+ const liveEndpointId = createEndpointId(entityId, mapping?.customName);
135150
+ if (existing.endpointId !== liveEndpointId || existing.anchorEntityId !== entityId || existing.lastEntityId !== entityId) {
135151
+ this.identityStorage.setIdentity(bridgeId, key, {
135152
+ ...existing,
135153
+ endpointId: liveEndpointId,
135154
+ anchorEntityId: entityId,
135155
+ lastEntityId: entityId
135156
+ });
135157
+ }
135158
+ return {
135159
+ endpointId: liveEndpointId,
135160
+ anchorEntityId: entityId,
135161
+ protected: false,
135162
+ renamedFrom
135163
+ };
135164
+ }
135165
+ const desired = createEndpointId(entityId, mapping?.customName);
135166
+ let endpointId = desired;
135167
+ if (opts.stableIdentity) {
135168
+ let n = 2;
135169
+ while (opts.isEndpointIdTaken(endpointId, key)) {
135170
+ endpointId = `${desired}_${n++}`;
135171
+ }
135172
+ }
135173
+ const record = {
135174
+ endpointId,
135175
+ anchorEntityId: entityId,
135176
+ lastEntityId: entityId,
135177
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
135178
+ };
135179
+ this.identityStorage.setIdentity(bridgeId, key, record);
135180
+ if (opts.stableIdentity) {
135181
+ return {
135182
+ endpointId: record.endpointId,
135183
+ anchorEntityId: record.anchorEntityId,
135184
+ protected: true
135185
+ };
135186
+ }
135187
+ return {
135188
+ endpointId: desired,
135189
+ anchorEntityId: entityId,
135190
+ protected: false
135191
+ };
135192
+ }
135193
+ // Carry a mapping from the old entity_id to the new one on rename. anchorEntityId
135194
+ // never changes, only the mapping key. Skips when no old mapping exists or a new
135195
+ // one already does, so a manual mapping under the new id is never clobbered.
135196
+ async rekeyMapping(bridgeId, oldEntityId, newEntityId) {
135197
+ if (oldEntityId === newEntityId) {
135198
+ return;
135199
+ }
135200
+ const old = this.mappingStorage.getMapping(bridgeId, oldEntityId);
135201
+ if (!old) {
135202
+ return;
135203
+ }
135204
+ if (this.mappingStorage.getMapping(bridgeId, newEntityId)) {
135205
+ return;
135206
+ }
135207
+ await this.mappingStorage.setMapping({
135208
+ ...old,
135209
+ bridgeId,
135210
+ entityId: newEntityId
135211
+ });
135212
+ await this.mappingStorage.deleteMapping(bridgeId, oldEntityId);
135213
+ }
135214
+ };
135215
+
135216
+ // src/services/storage/orphan-cleanup.ts
135217
+ var ORPHAN_TOMBSTONE_MS = 7 * 24 * 60 * 60 * 1e3;
135218
+ function buildPresentIdentityKeys(entities) {
135219
+ const keys3 = /* @__PURE__ */ new Set();
135220
+ for (const entity of Object.values(entities ?? {})) {
135221
+ if (!entity?.entity_id) continue;
135222
+ const key = identityKey({ entity_id: entity.entity_id, registry: entity });
135223
+ if (key != null) keys3.add(key);
135224
+ }
135225
+ return keys3;
135226
+ }
135227
+ function buildPresentEntityIds(entities) {
135228
+ const ids = /* @__PURE__ */ new Set();
135229
+ for (const entity of Object.values(entities ?? {})) {
135230
+ if (entity?.entity_id) ids.add(entity.entity_id);
135231
+ }
135232
+ return ids;
135233
+ }
135234
+ function stampIdentityPresence(store, bridgeId, presentKeys, now = Date.now()) {
135235
+ const nowIso = new Date(now).toISOString();
135236
+ for (const key of [...store.getBridgeIdentities(bridgeId).keys()]) {
135237
+ if (presentKeys.has(key)) {
135238
+ store.clearIdentityMissing(bridgeId, key);
135239
+ } else {
135240
+ store.markIdentityMissing(bridgeId, key, nowIso);
135241
+ }
135242
+ }
135243
+ }
135244
+ function stampMappingPresence(store, bridgeId, presentEntityIds, now = Date.now()) {
135245
+ const nowIso = new Date(now).toISOString();
135246
+ const entityIds = store.getMappingsForBridge(bridgeId).map((m) => m.entityId);
135247
+ for (const entityId of entityIds) {
135248
+ if (presentEntityIds.has(entityId)) {
135249
+ store.clearMappingMissing(bridgeId, entityId);
135250
+ } else {
135251
+ store.markMappingMissing(bridgeId, entityId, nowIso);
135252
+ }
135253
+ }
135254
+ }
135255
+ function computeOrphanCandidates(input) {
135256
+ const now = input.now ?? Date.now();
135257
+ const candidates = [];
135258
+ for (const [key, record] of input.identities) {
135259
+ if (!record.missingSince) continue;
135260
+ const missingFor = now - Date.parse(record.missingSince);
135261
+ if (!(missingFor >= ORPHAN_TOMBSTONE_MS)) continue;
135262
+ if (input.presentKeys.has(key)) continue;
135263
+ const lastEntityId = record.lastEntityId ?? record.anchorEntityId;
135264
+ candidates.push({
135265
+ identityKey: key,
135266
+ lastEntityId,
135267
+ missingSince: record.missingSince,
135268
+ // Only a true orphan mapping, not one a live entity holds under a reused
135269
+ // entity_id (a different identity key). Guards the sweep from clobbering
135270
+ // that live entity's custom mapping.
135271
+ hasMapping: input.hasMapping(lastEntityId) && !input.presentEntityIds.has(lastEntityId),
135272
+ kind: "identity"
135273
+ });
135274
+ }
135275
+ if (input.mappings) {
135276
+ const linkedByIdentity = /* @__PURE__ */ new Set();
135277
+ for (const candidate of candidates) {
135278
+ if (candidate.hasMapping) linkedByIdentity.add(candidate.lastEntityId);
135279
+ }
135280
+ for (const mapping of input.mappings) {
135281
+ if (!mapping.missingSince) continue;
135282
+ const missingFor = now - Date.parse(mapping.missingSince);
135283
+ if (!(missingFor >= ORPHAN_TOMBSTONE_MS)) continue;
135284
+ if (input.presentEntityIds.has(mapping.entityId)) continue;
135285
+ if (linkedByIdentity.has(mapping.entityId)) continue;
135286
+ candidates.push({
135287
+ identityKey: mapping.entityId,
135288
+ lastEntityId: mapping.entityId,
135289
+ missingSince: mapping.missingSince,
135290
+ hasMapping: true,
135291
+ kind: "mapping"
135292
+ });
135293
+ }
135294
+ }
135295
+ return candidates;
135296
+ }
135297
+ async function executeOrphanCleanup(input) {
135298
+ const candidates = computeOrphanCandidates({
135299
+ bridgeId: input.bridgeId,
135300
+ identities: input.identities,
135301
+ presentKeys: input.presentKeys,
135302
+ presentEntityIds: input.presentEntityIds,
135303
+ hasMapping: (entityId) => input.getMapping(input.bridgeId, entityId) != null,
135304
+ mappings: input.mappings,
135305
+ now: input.now
135306
+ });
135307
+ const byKey = new Map(candidates.map((c) => [c.identityKey, c]));
135308
+ const results = [];
135309
+ for (const key of input.requestedKeys) {
135310
+ const candidate = byKey.get(key);
135311
+ if (!candidate) {
135312
+ results.push({
135313
+ identityKey: key,
135314
+ deleted: false,
135315
+ reason: "not a current orphan candidate"
135316
+ });
135317
+ continue;
135318
+ }
135319
+ if (candidate.kind === "mapping") {
135320
+ if (!input.presentEntityIds.has(candidate.lastEntityId)) {
135321
+ await input.deleteMapping(input.bridgeId, candidate.lastEntityId);
135322
+ }
135323
+ results.push({ identityKey: key, deleted: true });
135324
+ continue;
135325
+ }
135326
+ if (candidate.hasMapping && !input.presentEntityIds.has(candidate.lastEntityId)) {
135327
+ await input.deleteMapping(input.bridgeId, candidate.lastEntityId);
135328
+ }
135329
+ await input.deleteIdentity(input.bridgeId, key);
135330
+ results.push({ identityKey: key, deleted: true });
135331
+ }
135332
+ return results;
135333
+ }
135334
+
135037
135335
  // src/utils/json/endpoint-to-json.ts
135038
135336
  function safeClone(value, depth = 0) {
135039
135337
  if (depth > 20 || value === null || value === void 0) return value;
@@ -135079,7 +135377,7 @@ function endpointToJson(endpoint, parentId) {
135079
135377
 
135080
135378
  // src/api/matter-api.ts
135081
135379
  var ajv = new Ajv();
135082
- function matterApi(bridgeService, haRegistry, identityStorage) {
135380
+ function matterApi(bridgeService, haRegistry, identityStorage, mappingStorage) {
135083
135381
  const router = express11.Router();
135084
135382
  router.get("/", (_, res) => {
135085
135383
  res.status(200).json({});
@@ -135182,6 +135480,64 @@ function matterApi(bridgeService, haRegistry, identityStorage) {
135182
135480
  });
135183
135481
  }
135184
135482
  });
135483
+ router.get("/bridges/:bridgeId/orphans", async (req, res) => {
135484
+ const bridgeId = req.params.bridgeId;
135485
+ const bridge = bridgeService.bridges.find((b) => b.id === bridgeId);
135486
+ if (!bridge) {
135487
+ res.status(404).send("Not Found");
135488
+ return;
135489
+ }
135490
+ if (!haRegistry || !identityStorage) {
135491
+ res.status(503).json({ error: "Orphan cleanup is not available" });
135492
+ return;
135493
+ }
135494
+ const presentKeys = buildPresentIdentityKeys(haRegistry.entities);
135495
+ const presentEntityIds = buildPresentEntityIds(haRegistry.entities);
135496
+ const candidates = computeOrphanCandidates({
135497
+ bridgeId,
135498
+ identities: identityStorage.getBridgeIdentities(bridgeId),
135499
+ presentKeys,
135500
+ presentEntityIds,
135501
+ hasMapping: (entityId) => mappingStorage?.getMapping(bridgeId, entityId) != null,
135502
+ mappings: mappingStorage?.getMappingsForBridge(bridgeId)
135503
+ });
135504
+ res.status(200).json({ candidates });
135505
+ });
135506
+ router.post(
135507
+ "/bridges/:bridgeId/actions/cleanup-orphans",
135508
+ async (req, res) => {
135509
+ const bridgeId = req.params.bridgeId;
135510
+ const bridge = bridgeService.bridges.find((b) => b.id === bridgeId);
135511
+ if (!bridge) {
135512
+ res.status(404).send("Not Found");
135513
+ return;
135514
+ }
135515
+ if (!haRegistry || !identityStorage || !mappingStorage) {
135516
+ res.status(503).json({ error: "Orphan cleanup is not available" });
135517
+ return;
135518
+ }
135519
+ const body = req.body;
135520
+ const requestedKeys = Array.isArray(body?.identityKeys) ? body.identityKeys.filter((k) => typeof k === "string") : [];
135521
+ try {
135522
+ const results = await executeOrphanCleanup({
135523
+ bridgeId,
135524
+ requestedKeys,
135525
+ identities: identityStorage.getBridgeIdentities(bridgeId),
135526
+ presentKeys: buildPresentIdentityKeys(haRegistry.entities),
135527
+ presentEntityIds: buildPresentEntityIds(haRegistry.entities),
135528
+ getMapping: (b, e) => mappingStorage.getMapping(b, e),
135529
+ deleteMapping: (b, e) => mappingStorage.deleteMapping(b, e),
135530
+ deleteIdentity: (b, k) => identityStorage.deleteIdentity(b, k),
135531
+ mappings: mappingStorage.getMappingsForBridge(bridgeId)
135532
+ });
135533
+ res.status(200).json({ results });
135534
+ } catch (e) {
135535
+ res.status(500).json({
135536
+ error: e instanceof Error ? e.message : "Unknown error"
135537
+ });
135538
+ }
135539
+ }
135540
+ );
135185
135541
  router.get("/bridges/:bridgeId/devices", async (req, res) => {
135186
135542
  const bridgeId = req.params.bridgeId;
135187
135543
  const bridge = bridgeService.bridges.find((b) => b.id === bridgeId);
@@ -137201,7 +137557,12 @@ var WebApi = class extends Service {
137201
137557
  const api = express17.Router();
137202
137558
  api.use(express17.json()).use(nocache()).use(
137203
137559
  "/matter",
137204
- matterApi(this.bridgeService, this.haRegistry, this.identityStorage)
137560
+ matterApi(
137561
+ this.bridgeService,
137562
+ this.haRegistry,
137563
+ this.identityStorage,
137564
+ this.mappingStorage
137565
+ )
137205
137566
  ).use(
137206
137567
  "/health",
137207
137568
  healthApi(
@@ -139022,11 +139383,37 @@ var EntityIdentityStorage = class extends Service {
139022
139383
  this.identities.delete(bridgeId);
139023
139384
  await this.flush();
139024
139385
  }
139386
+ // Drop a single identity record. Used by the manual orphan cleanup after the
139387
+ // 7-day tombstone; flush immediately since it is a user-initiated deletion.
139388
+ async deleteIdentity(bridgeId, key) {
139389
+ const bridgeMap = this.identities.get(bridgeId);
139390
+ if (bridgeMap?.delete(key)) {
139391
+ await this.flush();
139392
+ }
139393
+ }
139394
+ // Stamp the tombstone the first time a record's entity is absent from HA. Keeps
139395
+ // the first-seen time on later passes and no-ops once stamped, so a steady
139396
+ // absence does not churn the debounced persist.
139397
+ markIdentityMissing(bridgeId, key, nowIso) {
139398
+ const record = this.identities.get(bridgeId)?.get(key);
139399
+ if (!record || record.missingSince != null) return;
139400
+ record.missingSince = nowIso;
139401
+ this.schedulePersist();
139402
+ }
139403
+ // Clear the tombstone when the entity is present again. No-ops when there is
139404
+ // nothing to clear, so an all-present bridge writes nothing on refresh.
139405
+ clearIdentityMissing(bridgeId, key) {
139406
+ const record = this.identities.get(bridgeId)?.get(key);
139407
+ if (!record || record.missingSince == null) return;
139408
+ delete record.missingSince;
139409
+ this.schedulePersist();
139410
+ }
139025
139411
  };
139026
139412
 
139027
139413
  // src/services/storage/entity-mapping-storage.ts
139028
139414
  init_service();
139029
139415
  var CURRENT_VERSION2 = 1;
139416
+ var PERSIST_DEBOUNCE_MS2 = 500;
139030
139417
  var EntityMappingStorage = class extends Service {
139031
139418
  constructor(appStorage) {
139032
139419
  super("EntityMappingStorage");
@@ -139035,10 +139422,14 @@ var EntityMappingStorage = class extends Service {
139035
139422
  appStorage;
139036
139423
  storage;
139037
139424
  mappings = /* @__PURE__ */ new Map();
139425
+ persistTimer = null;
139038
139426
  async initialize() {
139039
139427
  this.storage = this.appStorage.createContext("entity-mappings");
139040
139428
  await this.load();
139041
139429
  }
139430
+ async dispose() {
139431
+ await this.flush();
139432
+ }
139042
139433
  async load() {
139043
139434
  const stored = await this.storage.get("data", {
139044
139435
  version: CURRENT_VERSION2,
@@ -139073,6 +139464,10 @@ var EntityMappingStorage = class extends Service {
139073
139464
  }
139074
139465
  }
139075
139466
  async persist() {
139467
+ if (this.persistTimer) {
139468
+ clearTimeout(this.persistTimer);
139469
+ this.persistTimer = null;
139470
+ }
139076
139471
  const data = {
139077
139472
  version: CURRENT_VERSION2,
139078
139473
  mappings: {}
@@ -139082,6 +139477,17 @@ var EntityMappingStorage = class extends Service {
139082
139477
  }
139083
139478
  await this.storage.set("data", data);
139084
139479
  }
139480
+ schedulePersist() {
139481
+ if (this.persistTimer) return;
139482
+ this.persistTimer = setTimeout(() => {
139483
+ this.persistTimer = null;
139484
+ void this.persist();
139485
+ }, PERSIST_DEBOUNCE_MS2);
139486
+ }
139487
+ // Flush any pending debounced write, used on dispose and by tests.
139488
+ async flush() {
139489
+ await this.persist();
139490
+ }
139085
139491
  getMappingsForBridge(bridgeId) {
139086
139492
  const bridgeMap = this.mappings.get(bridgeId);
139087
139493
  return bridgeMap ? Array.from(bridgeMap.values()) : [];
@@ -139175,6 +139581,23 @@ var EntityMappingStorage = class extends Service {
139175
139581
  this.mappings.delete(bridgeId);
139176
139582
  await this.persist();
139177
139583
  }
139584
+ // Stamp the tombstone the first time a mapping's entity is absent from HA.
139585
+ // Keeps the first-seen time on later passes and no-ops once stamped, so a
139586
+ // steady absence does not churn the debounced persist.
139587
+ markMappingMissing(bridgeId, entityId, nowIso) {
139588
+ const record = this.mappings.get(bridgeId)?.get(entityId);
139589
+ if (!record || record.missingSince != null) return;
139590
+ record.missingSince = nowIso;
139591
+ this.schedulePersist();
139592
+ }
139593
+ // Clear the tombstone when the entity is present again. No-ops when there is
139594
+ // nothing to clear, so an all-present bridge writes nothing on refresh.
139595
+ clearMappingMissing(bridgeId, entityId) {
139596
+ const record = this.mappings.get(bridgeId)?.get(entityId);
139597
+ if (!record || record.missingSince == null) return;
139598
+ delete record.missingSince;
139599
+ this.schedulePersist();
139600
+ }
139178
139601
  };
139179
139602
  function sanitizeVendorId(value) {
139180
139603
  if (value === void 0 || value === null || value === "") {
@@ -156805,7 +157228,23 @@ function seedExistingSessionStarts(startedAt, sessions, now = Date.now()) {
156805
157228
  }
156806
157229
  }
156807
157230
 
157231
+ // src/services/bridges/wedge-watchdog.ts
157232
+ var WEDGE_WARMUP_MS = 15 * 60 * 1e3;
157233
+ var WEDGE_IM_SILENCE_MS = 45 * 60 * 1e3;
157234
+ var WEDGE_MIN_ROTATE_INTERVAL_MS = 60 * 60 * 1e3;
157235
+ function decideWedgeRotation(input) {
157236
+ const {
157237
+ subscriptionCount,
157238
+ sessionAgeMs,
157239
+ lastImRequestMsAgo,
157240
+ lastRotatedMsAgo
157241
+ } = input;
157242
+ const imSilenceMs = lastImRequestMsAgo == null ? sessionAgeMs : lastImRequestMsAgo;
157243
+ return subscriptionCount > 0 && sessionAgeMs > WEDGE_WARMUP_MS && imSilenceMs > WEDGE_IM_SILENCE_MS && (lastRotatedMsAgo == null || lastRotatedMsAgo > WEDGE_MIN_ROTATE_INTERVAL_MS);
157244
+ }
157245
+
156808
157246
  // src/services/bridges/bridge.ts
157247
+ var imWrapMarker = /* @__PURE__ */ Symbol("hamh.wedgeImWrap");
156809
157248
  var AUTO_FORCE_SYNC_INTERVAL_MS = 9e4;
156810
157249
  var SHUTDOWN_SESSION_CLOSE_TIMEOUT_MS = 2500;
156811
157250
  var MDNS_ADDRESS_CHECK_INTERVAL_MS = 6e4;
@@ -156853,6 +157292,12 @@ var Bridge = class {
156853
157292
  sessionStartedAt = /* @__PURE__ */ new Map();
156854
157293
  rotationTimer = null;
156855
157294
  maxSessionAgeMs = 0;
157295
+ // Wedge watchdog: last inbound Interaction Model request time per session and
157296
+ // last time the watchdog rotated it, both keyed by the long-lived session
157297
+ // object so they clear when the session goes away.
157298
+ lastImRequestAt = /* @__PURE__ */ new WeakMap();
157299
+ wedgeLastRotatedAt = /* @__PURE__ */ new WeakMap();
157300
+ wedgeWatchdogTimer = null;
156856
157301
  // Watches the advertised interface addresses so a dynamic ISP IPv6 prefix
156857
157302
  // change forces a fresh operational announcement (#415).
156858
157303
  mdnsAddressTimer = null;
@@ -156912,6 +157357,8 @@ var Bridge = class {
156912
157357
  const nowMs = Date.now();
156913
157358
  const lastActiveMsAgo = typeof s.activeTimestamp === "number" && s.activeTimestamp > 0 ? nowMs - s.activeTimestamp : null;
156914
157359
  const lastAnyActivityMsAgo = typeof s.timestamp === "number" ? nowMs - s.timestamp : null;
157360
+ const lastImAt = this.lastImRequestAt.get(s);
157361
+ const lastImRequestMsAgo = lastImAt != null ? nowMs - lastImAt : null;
156915
157362
  const startedAt = this.sessionStartedAt.get(s.id);
156916
157363
  return {
156917
157364
  id: s.id,
@@ -156920,6 +157367,7 @@ var Bridge = class {
156920
157367
  subscriptionCount: subCount,
156921
157368
  lastActiveMsAgo,
156922
157369
  lastAnyActivityMsAgo,
157370
+ lastImRequestMsAgo,
156923
157371
  isPeerActive: Boolean(s.isPeerActive),
156924
157372
  ageMsFromOpen: startedAt != null ? nowMs - startedAt : null
156925
157373
  };
@@ -157086,6 +157534,7 @@ var Bridge = class {
157086
157534
  this.wireSessionDiagnostics();
157087
157535
  this.wireFabricWarnings();
157088
157536
  this.startSessionRotation();
157537
+ this.startWedgeWatchdog();
157089
157538
  this.startMdnsAddressWatch();
157090
157539
  logMemoryUsage(this.log, "bridge running");
157091
157540
  diagnosticEventBus.emit("bridge_started", `Bridge started`, {
@@ -157285,6 +157734,29 @@ ${e?.toString()}`);
157285
157734
  };
157286
157735
  sessionManager.sessions.added.on(this.sessionAddedHandler);
157287
157736
  sessionManager.sessions.deleted.on(this.sessionDeletedHandler);
157737
+ this.wireImRequestTracking();
157738
+ } catch {
157739
+ }
157740
+ }
157741
+ // Stamp the time of every inbound Interaction Model request per session by
157742
+ // wrapping InteractionServer.onNewExchange. The wedge watchdog reads these to
157743
+ // tell a live-but-consuming controller from one that only keeps acking.
157744
+ wireImRequestTracking() {
157745
+ try {
157746
+ const is = this.server.env.get(InteractionServer);
157747
+ const marked = is;
157748
+ if (marked[imWrapMarker]) {
157749
+ return;
157750
+ }
157751
+ const original = is.onNewExchange.bind(is);
157752
+ is.onNewExchange = (exchange, message) => {
157753
+ const session = exchange.session;
157754
+ if (session) {
157755
+ this.lastImRequestAt.set(session, Date.now());
157756
+ }
157757
+ return original(exchange, message);
157758
+ };
157759
+ marked[imWrapMarker] = true;
157288
157760
  } catch {
157289
157761
  }
157290
157762
  }
@@ -157447,6 +157919,7 @@ ${e?.toString()}`);
157447
157919
  }
157448
157920
  this.staleSessionTimers.clear();
157449
157921
  this.stopSessionRotation();
157922
+ this.stopWedgeWatchdog();
157450
157923
  this.stopMdnsAddressWatch();
157451
157924
  this.sessionStartedAt.clear();
157452
157925
  }
@@ -157512,6 +157985,72 @@ ${e?.toString()}`);
157512
157985
  this.rotationTimer = null;
157513
157986
  }
157514
157987
  }
157988
+ // Opt-in wedge watchdog: reuse the rotation check cadence (5min) to look for
157989
+ // the one session wedged on "Updating" and rotate just that one.
157990
+ startWedgeWatchdog() {
157991
+ this.stopWedgeWatchdog();
157992
+ if (!this.dataProvider.featureFlags?.wedgeWatchdog) {
157993
+ return;
157994
+ }
157995
+ this.wedgeWatchdogTimer = setInterval(
157996
+ () => this.runWedgeWatchdogCheck(),
157997
+ ROTATION_CHECK_INTERVAL_MS
157998
+ );
157999
+ this.log.info(
158000
+ `Wedge watchdog: checking every ${ROTATION_CHECK_INTERVAL_MS / 6e4}min`
158001
+ );
158002
+ }
158003
+ stopWedgeWatchdog() {
158004
+ if (this.wedgeWatchdogTimer) {
158005
+ clearInterval(this.wedgeWatchdogTimer);
158006
+ this.wedgeWatchdogTimer = null;
158007
+ }
158008
+ }
158009
+ // Rotate exactly the sessions the pure rule flags as wedged. Closing is the
158010
+ // same graceful-then-force path age rotation uses, so a false positive just
158011
+ // re-CASEs the controller.
158012
+ runWedgeWatchdogCheck() {
158013
+ try {
158014
+ const sessionManager = this.server.env.get(SessionManager);
158015
+ const now = Date.now();
158016
+ const closes = [];
158017
+ for (const s of [...sessionManager.sessions]) {
158018
+ if (s.isClosing) continue;
158019
+ const startedAt = this.sessionStartedAt.get(s.id);
158020
+ const sessionAgeMs = startedAt != null ? now - startedAt : 0;
158021
+ const lastImAt = this.lastImRequestAt.get(s);
158022
+ const lastImRequestMsAgo = lastImAt != null ? now - lastImAt : null;
158023
+ const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
158024
+ const lastRotatedMsAgo = lastRotatedAt != null ? now - lastRotatedAt : null;
158025
+ if (!decideWedgeRotation({
158026
+ subscriptionCount: s.subscriptions.size,
158027
+ sessionAgeMs,
158028
+ lastImRequestMsAgo,
158029
+ lastRotatedMsAgo
158030
+ })) {
158031
+ continue;
158032
+ }
158033
+ const silenceMin = Math.round(
158034
+ (lastImRequestMsAgo ?? sessionAgeMs) / 6e4
158035
+ );
158036
+ this.log.info(
158037
+ `Wedge watchdog: rotating session ${s.id}, no inbound interaction for ${silenceMin}min`
158038
+ );
158039
+ this.wedgeLastRotatedAt.set(s, now);
158040
+ closes.push(
158041
+ s.initiateClose().catch(() => {
158042
+ return s.initiateForceClose({
158043
+ cause: new Error("wedge watchdog, forcing")
158044
+ });
158045
+ })
158046
+ );
158047
+ }
158048
+ if (closes.length > 0) {
158049
+ Promise.allSettled(closes).then(() => this.triggerMdnsReAnnounce());
158050
+ }
158051
+ } catch {
158052
+ }
158053
+ }
157515
158054
  // Poll the interface addresses mDNS advertises and re-announce when they
157516
158055
  // change. matter.js caches the operational records at first announcement, so
157517
158056
  // a dynamic ISP IPv6 prefix change keeps advertising the dead global address
@@ -157643,6 +158182,7 @@ ${e?.toString()}`);
157643
158182
  if (this.status.code === BridgeStatus.Running) {
157644
158183
  this.startAutoForceSyncIfEnabled();
157645
158184
  this.startSessionRotation();
158185
+ this.startWedgeWatchdog();
157646
158186
  }
157647
158187
  } catch (e) {
157648
158188
  const reason = "Failed to update bridge due to error:";
@@ -157765,66 +158305,6 @@ var AggregatorEndpoint2 = class extends Endpoint {
157765
158305
  }
157766
158306
  };
157767
158307
 
157768
- // src/matter/endpoints/entity-endpoint.ts
157769
- init_esm7();
157770
- var EntityEndpoint = class extends Endpoint {
157771
- constructor(type, entityId, customName, mappedEntityIds, endpointId) {
157772
- super(type, { id: endpointId ?? createEndpointId(entityId, customName) });
157773
- this.entityId = entityId;
157774
- this.mappedEntityIds = mappedEntityIds ?? [];
157775
- }
157776
- entityId;
157777
- mappedEntityIds;
157778
- lastMappedStates = {};
157779
- hasMappedEntityChanged(states) {
157780
- let changed = false;
157781
- for (const mappedId of this.mappedEntityIds) {
157782
- const mappedState = states[mappedId];
157783
- if (!mappedState) continue;
157784
- const fp = mappedState.state;
157785
- if (fp !== this.lastMappedStates[mappedId]) {
157786
- this.lastMappedStates[mappedId] = fp;
157787
- changed = true;
157788
- }
157789
- }
157790
- return changed;
157791
- }
157792
- };
157793
- function createEndpointId(entityId, customName) {
157794
- const baseName = customName || entityId;
157795
- return baseName.replace(/\./g, "_").replace(/\s+/g, "_");
157796
- }
157797
- function getMappedEntityIds(mapping) {
157798
- if (!mapping) return [];
157799
- const ids = [];
157800
- if (mapping.batteryEntity && !mapping.disableBatteryMapping) {
157801
- ids.push(mapping.batteryEntity);
157802
- }
157803
- if (mapping.faultEntity) ids.push(mapping.faultEntity);
157804
- if (mapping.chargingStateEntity) ids.push(mapping.chargingStateEntity);
157805
- if (mapping.temperatureEntity) ids.push(mapping.temperatureEntity);
157806
- if (mapping.humidityEntity) ids.push(mapping.humidityEntity);
157807
- if (mapping.pressureEntity) ids.push(mapping.pressureEntity);
157808
- if (mapping.cleaningModeEntity) ids.push(mapping.cleaningModeEntity);
157809
- if (mapping.suctionLevelEntity) ids.push(mapping.suctionLevelEntity);
157810
- if (mapping.mopIntensityEntity) ids.push(mapping.mopIntensityEntity);
157811
- if (mapping.filterLifeEntity) ids.push(mapping.filterLifeEntity);
157812
- if (mapping.powerEntity) ids.push(mapping.powerEntity);
157813
- if (mapping.energyEntity) ids.push(mapping.energyEntity);
157814
- if (mapping.voltageEntity) ids.push(mapping.voltageEntity);
157815
- if (mapping.currentEntity) ids.push(mapping.currentEntity);
157816
- if (mapping.batteryPowerEntity) ids.push(mapping.batteryPowerEntity);
157817
- if (mapping.batteryEnergyEntity) ids.push(mapping.batteryEnergyEntity);
157818
- if (mapping.currentRoomEntity) ids.push(mapping.currentRoomEntity);
157819
- if (mapping.cleanedAreaEntity) ids.push(mapping.cleanedAreaEntity);
157820
- if (mapping.composedEntities) {
157821
- for (const sub of mapping.composedEntities) {
157822
- if (sub.entityId) ids.push(sub.entityId);
157823
- }
157824
- }
157825
- return ids;
157826
- }
157827
-
157828
158308
  // src/matter/endpoints/legacy/legacy-endpoint.ts
157829
158309
  init_dist();
157830
158310
  init_esm();
@@ -172802,119 +173282,6 @@ var EntityIsolationServiceImpl = class {
172802
173282
  };
172803
173283
  var EntityIsolationService = new EntityIsolationServiceImpl();
172804
173284
 
172805
- // src/services/bridges/identity-resolver.ts
172806
- var SEP = "\0";
172807
- function identityKey(entity) {
172808
- const uniqueId = entity.registry?.unique_id;
172809
- const platform = entity.registry?.platform;
172810
- if (!uniqueId || !platform) {
172811
- return null;
172812
- }
172813
- const domain = entity.entity_id.split(".")[0];
172814
- return platform + SEP + domain + SEP + uniqueId;
172815
- }
172816
- var IdentityResolver = class {
172817
- constructor(identityStorage, mappingStorage) {
172818
- this.identityStorage = identityStorage;
172819
- this.mappingStorage = mappingStorage;
172820
- }
172821
- identityStorage;
172822
- mappingStorage;
172823
- async resolveIdentity(bridgeId, entity, mapping, opts) {
172824
- const entityId = entity.entity_id;
172825
- const key = identityKey(entity);
172826
- if (key == null) {
172827
- return {
172828
- endpointId: createEndpointId(entityId, mapping?.customName),
172829
- anchorEntityId: entityId,
172830
- protected: false
172831
- };
172832
- }
172833
- const existing = this.identityStorage.getIdentity(bridgeId, key);
172834
- if (existing) {
172835
- const renamedFrom = existing.lastEntityId !== entityId ? existing.lastEntityId ?? existing.anchorEntityId : void 0;
172836
- if (opts.stableIdentity) {
172837
- if (renamedFrom) {
172838
- this.identityStorage.setIdentity(bridgeId, key, {
172839
- ...existing,
172840
- lastEntityId: entityId
172841
- });
172842
- await this.rekeyMapping(bridgeId, renamedFrom, entityId);
172843
- }
172844
- return {
172845
- endpointId: existing.endpointId,
172846
- anchorEntityId: existing.anchorEntityId,
172847
- protected: true,
172848
- renamedFrom
172849
- };
172850
- }
172851
- const liveEndpointId = createEndpointId(entityId, mapping?.customName);
172852
- if (existing.endpointId !== liveEndpointId || existing.anchorEntityId !== entityId || existing.lastEntityId !== entityId) {
172853
- this.identityStorage.setIdentity(bridgeId, key, {
172854
- ...existing,
172855
- endpointId: liveEndpointId,
172856
- anchorEntityId: entityId,
172857
- lastEntityId: entityId
172858
- });
172859
- }
172860
- return {
172861
- endpointId: liveEndpointId,
172862
- anchorEntityId: entityId,
172863
- protected: false,
172864
- renamedFrom
172865
- };
172866
- }
172867
- const desired = createEndpointId(entityId, mapping?.customName);
172868
- let endpointId = desired;
172869
- if (opts.stableIdentity) {
172870
- let n = 2;
172871
- while (opts.isEndpointIdTaken(endpointId, key)) {
172872
- endpointId = `${desired}_${n++}`;
172873
- }
172874
- }
172875
- const record = {
172876
- endpointId,
172877
- anchorEntityId: entityId,
172878
- lastEntityId: entityId,
172879
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
172880
- };
172881
- this.identityStorage.setIdentity(bridgeId, key, record);
172882
- if (opts.stableIdentity) {
172883
- return {
172884
- endpointId: record.endpointId,
172885
- anchorEntityId: record.anchorEntityId,
172886
- protected: true
172887
- };
172888
- }
172889
- return {
172890
- endpointId: desired,
172891
- anchorEntityId: entityId,
172892
- protected: false
172893
- };
172894
- }
172895
- // Carry a mapping from the old entity_id to the new one on rename. anchorEntityId
172896
- // never changes, only the mapping key. Skips when no old mapping exists or a new
172897
- // one already does, so a manual mapping under the new id is never clobbered.
172898
- async rekeyMapping(bridgeId, oldEntityId, newEntityId) {
172899
- if (oldEntityId === newEntityId) {
172900
- return;
172901
- }
172902
- const old = this.mappingStorage.getMapping(bridgeId, oldEntityId);
172903
- if (!old) {
172904
- return;
172905
- }
172906
- if (this.mappingStorage.getMapping(bridgeId, newEntityId)) {
172907
- return;
172908
- }
172909
- await this.mappingStorage.setMapping({
172910
- ...old,
172911
- bridgeId,
172912
- entityId: newEntityId
172913
- });
172914
- await this.mappingStorage.deleteMapping(bridgeId, oldEntityId);
172915
- }
172916
- };
172917
-
172918
173285
  // src/services/bridges/bridge-endpoint-manager.ts
172919
173286
  var MAX_ENTITY_ID_LENGTH = 150;
172920
173287
  var ENDPOINT_REMOVAL_GRACE_MS = 6e4;
@@ -172924,6 +173291,7 @@ var BridgeEndpointManager = class extends Service {
172924
173291
  this.client = client;
172925
173292
  this.registry = registry2;
172926
173293
  this.mappingStorage = mappingStorage;
173294
+ this.identityStorage = identityStorage;
172927
173295
  this.bridgeId = bridgeId;
172928
173296
  this.log = log;
172929
173297
  this.pluginManager = pluginManager;
@@ -172945,6 +173313,7 @@ var BridgeEndpointManager = class extends Service {
172945
173313
  client;
172946
173314
  registry;
172947
173315
  mappingStorage;
173316
+ identityStorage;
172948
173317
  bridgeId;
172949
173318
  log;
172950
173319
  pluginManager;
@@ -173342,6 +173711,17 @@ var BridgeEndpointManager = class extends Service {
173342
173711
  this.mappingFingerprints.set(entityId, fp);
173343
173712
  }
173344
173713
  }
173714
+ const fullEntities = this.registry.fullEntities;
173715
+ stampIdentityPresence(
173716
+ this.identityStorage,
173717
+ this.bridgeId,
173718
+ buildPresentIdentityKeys(fullEntities)
173719
+ );
173720
+ stampMappingPresence(
173721
+ this.mappingStorage,
173722
+ this.bridgeId,
173723
+ buildPresentEntityIds(fullEntities)
173724
+ );
173345
173725
  const existingEndpoints = [];
173346
173726
  const now = Date.now();
173347
173727
  for (const endpoint of endpoints) {
@@ -173661,6 +174041,11 @@ var BridgeRegistry = class _BridgeRegistry {
173661
174041
  initialState(entityId) {
173662
174042
  return this._states[entityId];
173663
174043
  }
174044
+ // The complete HA entity set (unfiltered). Used by orphan tombstone stamping
174045
+ // so a filter change or a scope narrowing never looks like a removal.
174046
+ get fullEntities() {
174047
+ return this.registry.entities;
174048
+ }
173664
174049
  // composed sub-entities may sit outside the bridge filter (#408), so these
173665
174050
  // fall back to the full HA registry. keep them separate from the strict
173666
174051
  // accessors above, every other caller must stay filtered.
@@ -174378,6 +174763,7 @@ init_dist();
174378
174763
  init_esm7();
174379
174764
  import * as os10 from "node:os";
174380
174765
  init_diagnostic_event_bus();
174766
+ var imWrapMarker2 = /* @__PURE__ */ Symbol("hamh.wedgeImWrap");
174381
174767
  var AUTO_FORCE_SYNC_INTERVAL_MS2 = 9e4;
174382
174768
  var SHUTDOWN_SESSION_CLOSE_TIMEOUT_MS2 = 2500;
174383
174769
  var MDNS_ADDRESS_CHECK_INTERVAL_MS2 = 6e4;
@@ -174410,6 +174796,12 @@ var ServerModeBridge = class {
174410
174796
  sessionStartedAt = /* @__PURE__ */ new Map();
174411
174797
  rotationTimer = null;
174412
174798
  maxSessionAgeMs = 0;
174799
+ // Wedge watchdog: last inbound Interaction Model request time per session and
174800
+ // last time the watchdog rotated it, both keyed by the long-lived session
174801
+ // object so they clear when the session goes away.
174802
+ lastImRequestAt = /* @__PURE__ */ new WeakMap();
174803
+ wedgeLastRotatedAt = /* @__PURE__ */ new WeakMap();
174804
+ wedgeWatchdogTimer = null;
174413
174805
  // Watches the advertised interface addresses so a dynamic ISP IPv6 prefix
174414
174806
  // change forces a fresh operational announcement (#415).
174415
174807
  mdnsAddressTimer = null;
@@ -174486,6 +174878,8 @@ var ServerModeBridge = class {
174486
174878
  const nowMs = Date.now();
174487
174879
  const lastActiveMsAgo = typeof s.activeTimestamp === "number" && s.activeTimestamp > 0 ? nowMs - s.activeTimestamp : null;
174488
174880
  const lastAnyActivityMsAgo = typeof s.timestamp === "number" ? nowMs - s.timestamp : null;
174881
+ const lastImAt = this.lastImRequestAt.get(s);
174882
+ const lastImRequestMsAgo = lastImAt != null ? nowMs - lastImAt : null;
174489
174883
  const startedAt = this.sessionStartedAt.get(s.id);
174490
174884
  return {
174491
174885
  id: s.id,
@@ -174494,6 +174888,7 @@ var ServerModeBridge = class {
174494
174888
  subscriptionCount: subCount,
174495
174889
  lastActiveMsAgo,
174496
174890
  lastAnyActivityMsAgo,
174891
+ lastImRequestMsAgo,
174497
174892
  isPeerActive: Boolean(s.isPeerActive),
174498
174893
  ageMsFromOpen: startedAt != null ? nowMs - startedAt : null
174499
174894
  };
@@ -174557,6 +174952,7 @@ var ServerModeBridge = class {
174557
174952
  this.wireSessionDiagnostics();
174558
174953
  this.wireFabricWarnings();
174559
174954
  this.startSessionRotation();
174955
+ this.startWedgeWatchdog();
174560
174956
  this.startMdnsAddressWatch();
174561
174957
  this.scheduleWarmStart();
174562
174958
  logMemoryUsage(this.log, "server mode bridge running");
@@ -174574,6 +174970,7 @@ ${e?.toString()}`);
174574
174970
  }
174575
174971
  async stop(code = BridgeStatus.Stopped, reason = "Manually stopped") {
174576
174972
  this.stopSessionRotation();
174973
+ this.stopWedgeWatchdog();
174577
174974
  this.stopMdnsAddressWatch();
174578
174975
  this.unwireSessionDiagnostics();
174579
174976
  this.unwireFabricWarnings();
@@ -174606,6 +175003,7 @@ ${e?.toString()}`);
174606
175003
  if (this.status.code === BridgeStatus.Running) {
174607
175004
  this.startAutoForceSyncIfEnabled();
174608
175005
  this.startSessionRotation();
175006
+ this.startWedgeWatchdog();
174609
175007
  }
174610
175008
  } catch (e) {
174611
175009
  const reason = "Failed to update server mode bridge due to error:";
@@ -174790,6 +175188,29 @@ ${e?.toString()}`);
174790
175188
  sessionManager.sessions.added.on(this.sessionAddedHandler);
174791
175189
  sessionManager.sessions.deleted.on(this.sessionDeletedHandler);
174792
175190
  seedExistingSessionStarts(this.sessionStartedAt, sessionManager.sessions);
175191
+ this.wireImRequestTracking();
175192
+ } catch {
175193
+ }
175194
+ }
175195
+ // Stamp the time of every inbound Interaction Model request per session by
175196
+ // wrapping InteractionServer.onNewExchange. The wedge watchdog reads these to
175197
+ // tell a live-but-consuming controller from one that only keeps acking.
175198
+ wireImRequestTracking() {
175199
+ try {
175200
+ const is = this.server.env.get(InteractionServer);
175201
+ const marked = is;
175202
+ if (marked[imWrapMarker2]) {
175203
+ return;
175204
+ }
175205
+ const original = is.onNewExchange.bind(is);
175206
+ is.onNewExchange = (exchange, message) => {
175207
+ const session = exchange.session;
175208
+ if (session) {
175209
+ this.lastImRequestAt.set(session, Date.now());
175210
+ }
175211
+ return original(exchange, message);
175212
+ };
175213
+ marked[imWrapMarker2] = true;
174793
175214
  } catch {
174794
175215
  }
174795
175216
  }
@@ -175007,6 +175428,72 @@ ${e?.toString()}`);
175007
175428
  this.rotationTimer = null;
175008
175429
  }
175009
175430
  }
175431
+ // Opt-in wedge watchdog: reuse the rotation check cadence (5min) to look for
175432
+ // the one session wedged on "Updating" and rotate just that one.
175433
+ startWedgeWatchdog() {
175434
+ this.stopWedgeWatchdog();
175435
+ if (!this.dataProvider.featureFlags?.wedgeWatchdog) {
175436
+ return;
175437
+ }
175438
+ this.wedgeWatchdogTimer = setInterval(
175439
+ () => this.runWedgeWatchdogCheck(),
175440
+ ROTATION_CHECK_INTERVAL_MS
175441
+ );
175442
+ this.log.info(
175443
+ `Wedge watchdog: checking every ${ROTATION_CHECK_INTERVAL_MS / 6e4}min`
175444
+ );
175445
+ }
175446
+ stopWedgeWatchdog() {
175447
+ if (this.wedgeWatchdogTimer) {
175448
+ clearInterval(this.wedgeWatchdogTimer);
175449
+ this.wedgeWatchdogTimer = null;
175450
+ }
175451
+ }
175452
+ // Rotate exactly the sessions the pure rule flags as wedged. Closing is the
175453
+ // same graceful-then-force path age rotation uses, so a false positive just
175454
+ // re-CASEs the controller.
175455
+ runWedgeWatchdogCheck() {
175456
+ try {
175457
+ const sessionManager = this.server.env.get(SessionManager);
175458
+ const now = Date.now();
175459
+ const closes = [];
175460
+ for (const s of [...sessionManager.sessions]) {
175461
+ if (s.isClosing) continue;
175462
+ const startedAt = this.sessionStartedAt.get(s.id);
175463
+ const sessionAgeMs = startedAt != null ? now - startedAt : 0;
175464
+ const lastImAt = this.lastImRequestAt.get(s);
175465
+ const lastImRequestMsAgo = lastImAt != null ? now - lastImAt : null;
175466
+ const lastRotatedAt = this.wedgeLastRotatedAt.get(s);
175467
+ const lastRotatedMsAgo = lastRotatedAt != null ? now - lastRotatedAt : null;
175468
+ if (!decideWedgeRotation({
175469
+ subscriptionCount: s.subscriptions.size,
175470
+ sessionAgeMs,
175471
+ lastImRequestMsAgo,
175472
+ lastRotatedMsAgo
175473
+ })) {
175474
+ continue;
175475
+ }
175476
+ const silenceMin = Math.round(
175477
+ (lastImRequestMsAgo ?? sessionAgeMs) / 6e4
175478
+ );
175479
+ this.log.info(
175480
+ `Wedge watchdog: rotating session ${s.id}, no inbound interaction for ${silenceMin}min`
175481
+ );
175482
+ this.wedgeLastRotatedAt.set(s, now);
175483
+ closes.push(
175484
+ s.initiateClose().catch(() => {
175485
+ return s.initiateForceClose({
175486
+ cause: new Error("wedge watchdog, forcing")
175487
+ });
175488
+ })
175489
+ );
175490
+ }
175491
+ if (closes.length > 0) {
175492
+ Promise.allSettled(closes).then(() => this.triggerMdnsReAnnounce());
175493
+ }
175494
+ } catch {
175495
+ }
175496
+ }
175010
175497
  // Poll the interface addresses mDNS advertises and re-announce when they
175011
175498
  // change. matter.js caches the operational records at first announcement, so
175012
175499
  // a dynamic ISP IPv6 prefix change keeps advertising the dead global address
@@ -175580,6 +176067,7 @@ var ServerModeEndpointManager = class extends Service {
175580
176067
  this.client = client;
175581
176068
  this.registry = registry2;
175582
176069
  this.mappingStorage = mappingStorage;
176070
+ this.identityStorage = identityStorage;
175583
176071
  this.dataProvider = dataProvider;
175584
176072
  this.log = log;
175585
176073
  this.identityResolver = new IdentityResolver(
@@ -175591,6 +176079,7 @@ var ServerModeEndpointManager = class extends Service {
175591
176079
  client;
175592
176080
  registry;
175593
176081
  mappingStorage;
176082
+ identityStorage;
175594
176083
  dataProvider;
175595
176084
  log;
175596
176085
  entityIds = [];
@@ -175690,6 +176179,17 @@ var ServerModeEndpointManager = class extends Service {
175690
176179
  this.registry.refresh();
175691
176180
  this._failedEntities = [];
175692
176181
  this.entityIds = this.registry.entityIds;
176182
+ const fullEntities = this.registry.fullEntities;
176183
+ stampIdentityPresence(
176184
+ this.identityStorage,
176185
+ this.dataProvider.id,
176186
+ buildPresentIdentityKeys(fullEntities)
176187
+ );
176188
+ stampMappingPresence(
176189
+ this.mappingStorage,
176190
+ this.dataProvider.id,
176191
+ buildPresentEntityIds(fullEntities)
176192
+ );
175693
176193
  try {
175694
176194
  if (this.entityIds.length === 0) {
175695
176195
  this.log.warn("Server mode bridge has no entities configured");