@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.
package/dist/backend/cli.js
CHANGED
|
@@ -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(
|
|
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");
|