@riddix/hamh 2.1.0-alpha.868 → 2.1.0-alpha.870

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.
@@ -133079,7 +133079,8 @@ var init_home_assistant_actions = __esm({
133079
133079
  }
133080
133080
  call(action, entityId) {
133081
133081
  const target = action.target === false ? entityId : action.target ?? entityId;
133082
- const key = `${target}-${action.action}`;
133082
+ const intent = Object.keys(action.data ?? {}).length ? "adjust" : "command";
133083
+ const key = `${target}-${action.action}-${intent}`;
133083
133084
  this.debounceContext.get(key, 100)({ ...action, entityId });
133084
133085
  }
133085
133086
  async callAction(domain, action, data, target, returnResponse) {
@@ -160300,7 +160301,7 @@ var PowerSourceServerBase = class extends FeaturedBase3 {
160300
160301
  }
160301
160302
  let batChargeState = PowerSource3.BatChargeState.Unknown;
160302
160303
  if (isCharging2 === true) {
160303
- batChargeState = batteryPercent != null && batteryPercent >= 100 ? PowerSource3.BatChargeState.IsAtFullCharge : PowerSource3.BatChargeState.IsCharging;
160304
+ batChargeState = batteryPercent == null ? PowerSource3.BatChargeState.Unknown : batteryPercent >= 100 ? PowerSource3.BatChargeState.IsAtFullCharge : PowerSource3.BatChargeState.IsCharging;
160304
160305
  } else if (isCharging2 === false) {
160305
160306
  batChargeState = PowerSource3.BatChargeState.IsNotCharging;
160306
160307
  }
@@ -173213,10 +173214,12 @@ function isCharging(entity) {
173213
173214
  if (attrs.is_charging === false || attrs.charging === false) return false;
173214
173215
  const level = batteryFromAttributes(entity.attributes);
173215
173216
  if (level != null && level >= 100) return false;
173217
+ const status3 = typeof attrs.status === "string" ? attrs.status.toLowerCase().replace(/[-_]/g, " ") : "";
173218
+ if (status3.includes("discharg") || status3.includes("not charg") || status3.includes("full") || status3.includes("complete")) {
173219
+ return false;
173220
+ }
173216
173221
  if (attrs.battery_icon?.includes("charging")) return true;
173217
- if (typeof attrs.status === "string" && attrs.status.toLowerCase().includes("charg"))
173218
- return true;
173219
- return false;
173222
+ return status3.includes("charg");
173220
173223
  }
173221
173224
  function isDockedCharging(entity, batteryPercent) {
173222
173225
  const attrs = entity.attributes;
@@ -175132,6 +175135,9 @@ var LegacyEndpoint = class _LegacyEndpoint extends EntityEndpoint {
175132
175135
  )) {
175133
175136
  return;
175134
175137
  }
175138
+ if (errorMessage.includes("is not present on this endpoint")) {
175139
+ return;
175140
+ }
175135
175141
  throw error;
175136
175142
  }
175137
175143
  }
@@ -177322,1827 +177328,1964 @@ var subscribeEntities = (conn, onChange, entityIds) => {
177322
177328
  });
177323
177329
  };
177324
177330
 
177325
- // src/services/bridges/entity-isolation-service.ts
177331
+ // src/services/bridges/bridge-registry.ts
177332
+ init_dist();
177326
177333
  init_esm();
177327
- init_diagnostic_event_bus();
177328
- var logger252 = Logger.get("EntityIsolation");
177329
- var EntityIsolationServiceImpl = class {
177330
- isolatedEntities = /* @__PURE__ */ new Map();
177331
- isolationCallbacks = /* @__PURE__ */ new Map();
177332
- /**
177333
- * Register a callback to be called when an entity needs to be isolated.
177334
- * The callback should remove the entity from the bridge's aggregator.
177335
- */
177336
- registerIsolationCallback(bridgeId, callback) {
177337
- this.isolationCallbacks.set(bridgeId, callback);
177334
+ init_send_ha_message();
177335
+ import { callService as callService2 } from "home-assistant-js-websocket";
177336
+ import { keys as keys2, pickBy, values as values3 } from "lodash-es";
177337
+ function fingerprintBattery(fingerprint) {
177338
+ try {
177339
+ const parsed = JSON.parse(fingerprint);
177340
+ return Array.isArray(parsed) && typeof parsed[1] === "string" ? parsed[1] : null;
177341
+ } catch {
177342
+ return null;
177338
177343
  }
177339
- unregisterIsolationCallback(bridgeId) {
177340
- this.isolationCallbacks.delete(bridgeId);
177344
+ }
177345
+ var BridgeRegistry = class _BridgeRegistry {
177346
+ constructor(registry3, dataProvider, client) {
177347
+ this.registry = registry3;
177348
+ this.dataProvider = dataProvider;
177349
+ this.client = client;
177350
+ this.refresh();
177351
+ }
177352
+ registry;
177353
+ dataProvider;
177354
+ client;
177355
+ get entityIds() {
177356
+ return keys2(this._entities);
177357
+ }
177358
+ _devices = {};
177359
+ _entities = {};
177360
+ _states = {};
177361
+ // Track battery entities that have been auto-assigned to other devices
177362
+ _usedBatteryEntities = /* @__PURE__ */ new Set();
177363
+ // Cache for battery entity lookups (deviceId -> entityId or null)
177364
+ _batteryEntityCache = /* @__PURE__ */ new Map();
177365
+ // Cache for problem entity lookups (deviceId -> entityId or null) (#408)
177366
+ _problemEntityCache = /* @__PURE__ */ new Map();
177367
+ // Track humidity entities that have been auto-assigned to temperature sensors
177368
+ _usedHumidityEntities = /* @__PURE__ */ new Set();
177369
+ // Track pressure entities that have been auto-assigned to temperature sensors
177370
+ _usedPressureEntities = /* @__PURE__ */ new Set();
177371
+ // Track power entities that have been auto-assigned to switch/plug entities
177372
+ _usedPowerEntities = /* @__PURE__ */ new Set();
177373
+ // Track energy entities that have been auto-assigned to switch/plug entities
177374
+ _usedEnergyEntities = /* @__PURE__ */ new Set();
177375
+ // Track entities consumed by composed devices (e.g., sensors/climate grouped under air purifier)
177376
+ _usedComposedSubEntities = /* @__PURE__ */ new Set();
177377
+ deviceOf(entityId) {
177378
+ const entity = this._entities[entityId];
177379
+ return this._devices[entity.device_id];
177380
+ }
177381
+ entity(entityId) {
177382
+ return this._entities[entityId];
177383
+ }
177384
+ initialState(entityId) {
177385
+ return this._states[entityId];
177386
+ }
177387
+ // The complete HA entity set (unfiltered). Used by orphan tombstone stamping
177388
+ // so a filter change or a scope narrowing never looks like a removal.
177389
+ get fullEntities() {
177390
+ return this.registry.entities;
177391
+ }
177392
+ // Successful-reload counter, see HomeAssistantRegistry (#438).
177393
+ get snapshotGeneration() {
177394
+ return this.registry.snapshotGeneration;
177395
+ }
177396
+ // composed sub-entities may sit outside the bridge filter (#408), so these
177397
+ // fall back to the full HA registry. keep them separate from the strict
177398
+ // accessors above, every other caller must stay filtered.
177399
+ initialStateIncludingUnfiltered(entityId) {
177400
+ return this._states[entityId] ?? this.registry.states[entityId];
177401
+ }
177402
+ entityIncludingUnfiltered(entityId) {
177403
+ return this._entities[entityId] ?? this.registry.entities[entityId];
177404
+ }
177405
+ deviceOfIncludingUnfiltered(entityId) {
177406
+ const entity = this.entityIncludingUnfiltered(entityId);
177407
+ if (!entity) return void 0;
177408
+ return this._devices[entity.device_id] ?? this.registry.devices[entity.device_id];
177341
177409
  }
177342
177410
  /**
177343
- * Parse the endpoint path from a Matter.js error message and extract the entity name.
177344
- * Example path: "ed5b4f8d042e4599b833f21da4ededba.aggregator.Küchenlicht.onOff.on"
177345
- * Returns: { bridgeId: "ed5b4f8d...", entityName: "Küchenlicht" }
177411
+ * The battery sensor the auto-mapping would resolve for this entity right
177412
+ * now, or "" when auto-mapping does not apply. Part of the endpoint mapping
177413
+ * fingerprint: a sensor that was unavailable at endpoint creation used to be
177414
+ * negative-cached forever, the endpoint never rebuilt and the vacuum lost
177415
+ * its battery until a full restart (#450).
177346
177416
  */
177347
- parseEndpointPath(errorMessage) {
177348
- const match = errorMessage.match(/([a-f0-9]{32})\.aggregator\.([^.\s>]+)/i);
177349
- if (match) {
177350
- return {
177351
- bridgeId: match[1],
177352
- entityName: match[2]
177353
- };
177354
- }
177355
- return null;
177356
- }
177357
- classifyError(msg) {
177358
- if (msg.includes("Invalid intervalMs")) {
177359
- return "Subscription timing error (Invalid intervalMs)";
177360
- }
177361
- if (msg.includes("Behaviors have errors")) {
177362
- return "Behavior initialization failure";
177363
- }
177364
- if (msg.includes("TransactionDestroyedError")) {
177365
- return "Transaction destroyed during operation";
177417
+ batteryFingerprintFor(entityId, mapping) {
177418
+ if (mapping?.batteryEntity || mapping?.disableBatteryMapping) return "";
177419
+ if (entityId.startsWith("sensor.") || entityId.startsWith("binary_sensor.")) {
177420
+ return "";
177366
177421
  }
177367
- if (msg.includes("DestroyedDependencyError")) {
177368
- return "Dependency destroyed during operation";
177422
+ if (!this.isAutoBatteryMappingEnabled() && !entityId.startsWith("vacuum.")) {
177423
+ return "";
177369
177424
  }
177370
- if (msg.includes("UninitializedDependencyError")) {
177371
- return "Uninitialized dependency access";
177425
+ const entity = this.entity(entityId);
177426
+ if (!entity?.device_id) return "";
177427
+ const resolved = this.findBatteryEntityForDevice(entity.device_id);
177428
+ return resolved && resolved !== entityId ? resolved : "";
177429
+ }
177430
+ /** Drop one cached battery answer so the next lookup resolves fresh (#450). */
177431
+ forgetBatteryCacheForDevice(deviceId) {
177432
+ this._batteryEntityCache.delete(deviceId);
177433
+ }
177434
+ /**
177435
+ * Find a battery sensor entity that belongs to the same HA device.
177436
+ * Returns the entity_id of the battery sensor, or undefined if none found.
177437
+ */
177438
+ findBatteryEntityForDevice(deviceId) {
177439
+ if (this._batteryEntityCache.has(deviceId)) {
177440
+ const cached = this._batteryEntityCache.get(deviceId);
177441
+ return cached === null ? void 0 : cached;
177372
177442
  }
177373
- if (msg.includes("Endpoint storage inaccessible")) {
177374
- return "Endpoint storage inaccessible";
177443
+ const entities = values3(this.registry.entities);
177444
+ const sameDevice = entities.filter((e) => e.device_id === deviceId);
177445
+ for (const entity of sameDevice) {
177446
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177447
+ const state = this.registry.states[entity.entity_id];
177448
+ if (!state) {
177449
+ continue;
177450
+ }
177451
+ const attrs = state.attributes;
177452
+ if (attrs.device_class === SensorDeviceClass.battery && resolveBatteryPercent(state.state) != null) {
177453
+ this._batteryEntityCache.set(deviceId, entity.entity_id);
177454
+ return entity.entity_id;
177455
+ }
177375
177456
  }
177376
- if (msg.includes("Error initializing part")) {
177377
- return "Endpoint construction failure";
177457
+ for (const entity of sameDevice) {
177458
+ if (!entity.entity_id.startsWith("binary_sensor.")) continue;
177459
+ const state = this.registry.states[entity.entity_id];
177460
+ if (!state) continue;
177461
+ const attrs = state.attributes;
177462
+ if (attrs.device_class === "battery" && resolveBatteryPercent(state.state) != null) {
177463
+ this._batteryEntityCache.set(deviceId, entity.entity_id);
177464
+ return entity.entity_id;
177465
+ }
177378
177466
  }
177379
- if (msg.includes("aggregator.")) {
177380
- return "Runtime error in endpoint";
177467
+ for (const entity of sameDevice) {
177468
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177469
+ const state = this.registry.states[entity.entity_id];
177470
+ if (!state) continue;
177471
+ const attrs = state.attributes;
177472
+ const looksLikeBattery = attrs.unit_of_measurement === "%" || entity.entity_id.toLowerCase().includes("batt");
177473
+ if ((attrs.device_class === "enum" || attrs.device_class == null && looksLikeBattery) && resolveBatteryPercent(state.state) != null) {
177474
+ this._batteryEntityCache.set(deviceId, entity.entity_id);
177475
+ return entity.entity_id;
177476
+ }
177381
177477
  }
177382
- return null;
177478
+ this._batteryEntityCache.set(deviceId, null);
177479
+ return void 0;
177383
177480
  }
177384
177481
  /**
177385
- * Attempt to isolate an entity based on an error.
177386
- * Returns true if the entity was successfully identified and isolation was triggered.
177482
+ * Mark a battery entity as used (auto-assigned to another device).
177387
177483
  */
177388
- async isolateFromError(error) {
177389
- const msg = error instanceof Error ? error.message : String(error);
177390
- const classification = this.classifyError(msg);
177391
- if (!classification) {
177392
- return false;
177393
- }
177394
- const parsed = this.parseEndpointPath(msg);
177395
- if (!parsed) {
177396
- logger252.warn("Could not parse entity from error:", msg);
177397
- return false;
177398
- }
177399
- const { bridgeId, entityName } = parsed;
177400
- const callback = this.isolationCallbacks.get(bridgeId);
177401
- if (!callback) {
177402
- logger252.warn(
177403
- `No isolation callback registered for bridge ${bridgeId}, entity: ${entityName}`
177404
- );
177405
- return false;
177406
- }
177407
- const key = `${bridgeId}:${entityName}`;
177408
- if (this.isolatedEntities.has(key)) {
177409
- return true;
177484
+ markBatteryEntityUsed(entityId) {
177485
+ this._usedBatteryEntities.add(entityId);
177486
+ }
177487
+ /**
177488
+ * Check if a battery entity has been auto-assigned to another device.
177489
+ */
177490
+ isBatteryEntityUsed(entityId) {
177491
+ return this._usedBatteryEntities.has(entityId);
177492
+ }
177493
+ /**
177494
+ * Find a problem/safety binary sensor on the same HA device, so a smoke/CO
177495
+ * alarm can drive hardwareFaultAlert from it. Prefers device_class=problem
177496
+ * over safety. Returns the entity_id, or undefined if none found (#408).
177497
+ */
177498
+ findProblemEntityForDevice(deviceId) {
177499
+ if (this._problemEntityCache.has(deviceId)) {
177500
+ const cached = this._problemEntityCache.get(deviceId);
177501
+ return cached === null ? void 0 : cached;
177410
177502
  }
177411
- const reason = `${classification}. Entity isolated to protect bridge stability.`;
177412
- this.isolatedEntities.set(key, {
177413
- entityId: entityName,
177414
- reason,
177415
- failedAt: (/* @__PURE__ */ new Date()).toISOString()
177416
- });
177417
- logger252.warn(
177418
- `Isolating entity "${entityName}" from bridge ${bridgeId} due to: ${reason}`
177419
- );
177420
- diagnosticEventBus.emit("entity_error", `Entity isolated: ${entityName}`, {
177421
- bridgeId,
177422
- entityId: entityName,
177423
- details: { reason: classification }
177424
- });
177425
- try {
177426
- await callback(entityName);
177427
- return true;
177428
- } catch (e) {
177429
- logger252.error(`Failed to isolate entity ${entityName}:`, e);
177430
- return false;
177503
+ const entities = values3(this.registry.entities);
177504
+ const sameDevice = entities.filter((e) => e.device_id === deviceId);
177505
+ let safety;
177506
+ for (const entity of sameDevice) {
177507
+ if (!entity.entity_id.startsWith("binary_sensor.")) continue;
177508
+ const state = this.registry.states[entity.entity_id];
177509
+ if (!state) continue;
177510
+ const attrs = state.attributes;
177511
+ if (attrs.device_class === "problem") {
177512
+ this._problemEntityCache.set(deviceId, entity.entity_id);
177513
+ return entity.entity_id;
177514
+ }
177515
+ if (attrs.device_class === "safety" && !safety) {
177516
+ safety = entity.entity_id;
177517
+ }
177431
177518
  }
177519
+ this._problemEntityCache.set(deviceId, safety ?? null);
177520
+ return safety;
177432
177521
  }
177433
177522
  /**
177434
- * Get all isolated entities for a specific bridge.
177523
+ * Check if auto battery mapping is enabled for this bridge.
177435
177524
  */
177436
- getIsolatedEntities(bridgeId) {
177437
- const result = [];
177438
- for (const [key, entity] of this.isolatedEntities) {
177439
- if (key.startsWith(`${bridgeId}:`)) {
177440
- result.push(entity);
177525
+ isAutoBatteryMappingEnabled() {
177526
+ return this.dataProvider.featureFlags?.autoBatteryMapping === true || this.dataProvider.featureFlags?.autoComposedDevices === true;
177527
+ }
177528
+ /**
177529
+ * Check if auto composed devices mode is enabled.
177530
+ * When enabled, temperature sensors with auto-mapped humidity/pressure/battery
177531
+ * build real Matter Composed Devices (BridgedNodeEndpoint with sub-endpoints)
177532
+ * rather than stacking extra clusters onto a flat TemperatureSensor.
177533
+ * Apple Home, Google Home, and Alexa render each sub-endpoint using its
177534
+ * own device type.
177535
+ */
177536
+ isAutoComposedDevicesEnabled() {
177537
+ return this.dataProvider.featureFlags?.autoComposedDevices === true;
177538
+ }
177539
+ /**
177540
+ * Check if auto humidity mapping is enabled for this bridge.
177541
+ * Default: true (enabled by default).
177542
+ * When enabled, humidity sensors on the same device as a temperature sensor
177543
+ * are combined into a single TemperatureHumiditySensor endpoint.
177544
+ * Note: Apple Home does not display humidity on TemperatureSensorDevice
177545
+ * endpoints, so users on Apple Home should explicitly disable this.
177546
+ * See: https://github.com/RiDDiX/home-assistant-matter-hub/issues/133
177547
+ */
177548
+ isAutoHumidityMappingEnabled() {
177549
+ return this.dataProvider.featureFlags?.autoHumidityMapping !== false || this.dataProvider.featureFlags?.autoComposedDevices === true;
177550
+ }
177551
+ /**
177552
+ * Find a humidity sensor entity that belongs to the same HA device.
177553
+ * Returns the entity_id of the humidity sensor, or undefined if none found.
177554
+ */
177555
+ findHumidityEntityForDevice(deviceId) {
177556
+ const entities = values3(this.registry.entities);
177557
+ for (const entity of entities) {
177558
+ if (entity.device_id !== deviceId) continue;
177559
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177560
+ const state = this.registry.states[entity.entity_id];
177561
+ if (!state) continue;
177562
+ const attrs = state.attributes;
177563
+ if (attrs.device_class === SensorDeviceClass.humidity) {
177564
+ return entity.entity_id;
177441
177565
  }
177442
177566
  }
177443
- return result;
177567
+ return void 0;
177444
177568
  }
177445
177569
  /**
177446
- * Clear isolated entities for a bridge (e.g., on restart).
177570
+ * Find a temperature sensor entity that belongs to the same HA device.
177571
+ * Returns the entity_id of the temperature sensor, or undefined if none found.
177447
177572
  */
177448
- clearIsolatedEntities(bridgeId) {
177449
- for (const key of this.isolatedEntities.keys()) {
177450
- if (key.startsWith(`${bridgeId}:`)) {
177451
- this.isolatedEntities.delete(key);
177573
+ findTemperatureEntityForDevice(deviceId) {
177574
+ const entities = values3(this.registry.entities);
177575
+ for (const entity of entities) {
177576
+ if (entity.device_id !== deviceId) continue;
177577
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177578
+ const state = this.registry.states[entity.entity_id];
177579
+ if (!state) continue;
177580
+ const attrs = state.attributes;
177581
+ if (attrs.device_class === SensorDeviceClass.temperature) {
177582
+ return entity.entity_id;
177452
177583
  }
177453
177584
  }
177585
+ return void 0;
177454
177586
  }
177455
- };
177456
- var EntityIsolationService = new EntityIsolationServiceImpl();
177457
-
177458
- // src/services/bridges/bridge-endpoint-manager.ts
177459
- var MAX_ENTITY_ID_LENGTH = 150;
177460
- var ENDPOINT_REMOVAL_GRACE_MS = 3e5;
177461
- function isEntityPart(p) {
177462
- return typeof p.updateStates === "function";
177463
- }
177464
- function hasEntityIdentity(p) {
177465
- return typeof p.entityId === "string";
177466
- }
177467
- var BridgeEndpointManager = class extends Service {
177468
- constructor(client, registry3, mappingStorage, identityStorage, bridgeId, log, pluginManager, pluginRegistry, pluginInstaller) {
177469
- super("BridgeEndpointManager");
177470
- this.client = client;
177471
- this.registry = registry3;
177472
- this.mappingStorage = mappingStorage;
177473
- this.identityStorage = identityStorage;
177474
- this.bridgeId = bridgeId;
177475
- this.log = log;
177476
- this.pluginManager = pluginManager;
177477
- this.pluginRegistry = pluginRegistry;
177478
- this.pluginInstaller = pluginInstaller;
177479
- this.root = new AggregatorEndpoint2("aggregator");
177480
- this.identityResolver = new IdentityResolver(
177481
- identityStorage,
177482
- mappingStorage
177483
- );
177484
- EntityIsolationService.registerIsolationCallback(
177485
- bridgeId,
177486
- this.isolateEntity.bind(this)
177487
- );
177488
- if (this.pluginManager) {
177489
- this.wirePluginCallbacks();
177587
+ /**
177588
+ * Find a climate entity that belongs to the same HA device.
177589
+ * Returns the entity_id of the climate entity, or undefined if none found.
177590
+ */
177591
+ findClimateEntityForDevice(deviceId) {
177592
+ const entities = values3(this.registry.entities);
177593
+ for (const entity of entities) {
177594
+ if (entity.device_id !== deviceId) continue;
177595
+ if (!entity.entity_id.startsWith("climate.")) continue;
177596
+ const state = this.registry.states[entity.entity_id];
177597
+ if (state) return entity.entity_id;
177490
177598
  }
177599
+ return void 0;
177491
177600
  }
177492
- client;
177493
- registry;
177494
- mappingStorage;
177495
- identityStorage;
177496
- bridgeId;
177497
- log;
177498
- pluginManager;
177499
- pluginRegistry;
177500
- pluginInstaller;
177501
- root;
177502
- entityIds = [];
177503
- unsubscribe;
177504
- observingRequested = false;
177505
- _failedEntities = [];
177506
- mappingFingerprints = /* @__PURE__ */ new Map();
177507
- // entityId -> first absence stamp (grace window)
177508
- pendingRemovals = /* @__PURE__ */ new Map();
177509
- removalRecheckTimer = null;
177510
- // Bumped on every stop, so a refresh that was already running cannot arm a
177511
- // timer on a bridge that has since stopped (#438).
177512
- lifecycle = 0;
177513
- pluginEndpoints = /* @__PURE__ */ new Map();
177514
- pluginStateUpdating = /* @__PURE__ */ new Set();
177515
- pluginListeners = /* @__PURE__ */ new Map();
177516
- get failedEntities() {
177517
- const isolated = EntityIsolationService.getIsolatedEntities(this.bridgeId);
177518
- return [...this._failedEntities, ...isolated];
177601
+ /**
177602
+ * Mark an entity as consumed by a composed device.
177603
+ */
177604
+ markComposedSubEntityUsed(entityId) {
177605
+ this._usedComposedSubEntities.add(entityId);
177519
177606
  }
177520
- addFailedEntity(entityId, reason) {
177521
- this._failedEntities.push({
177522
- entityId,
177523
- reason,
177524
- failedAt: (/* @__PURE__ */ new Date()).toISOString()
177525
- });
177607
+ /**
177608
+ * Check if an entity has been consumed by a composed device.
177609
+ */
177610
+ isComposedSubEntityUsed(entityId) {
177611
+ return this._usedComposedSubEntities.has(entityId);
177526
177612
  }
177527
- identityResolver;
177528
- wirePluginCallbacks() {
177529
- if (!this.pluginManager) return;
177530
- this.pluginManager.onDeviceRegistered = async (pluginName, device) => {
177531
- let endpoint;
177532
- if (device.endpointType) {
177533
- try {
177534
- validateEndpointType(
177535
- device.endpointType,
177536
- `plugin:${pluginName}:${device.id}`
177537
- );
177538
- } catch (e) {
177539
- this.log.warn(
177540
- `Plugin "${pluginName}": invalid endpointType for device "${device.id}":`,
177541
- e
177542
- );
177543
- return;
177544
- }
177545
- const supplied = device.endpointType;
177546
- const initialState = {};
177547
- for (const cluster2 of device.clusters) {
177548
- if (cluster2.clusterId === "pluginDevice" && supplied.behaviors?.pluginDevice == null && supplied.behaviors?.bridgedDeviceBasicInformation == null) {
177549
- this.log.warn(
177550
- `Plugin "${pluginName}": device "${device.id}" declares a "pluginDevice" cluster without owning that behavior, ignoring it`
177551
- );
177552
- continue;
177613
+ /**
177614
+ * Mark a humidity entity as used (auto-assigned to a temperature sensor).
177615
+ */
177616
+ markHumidityEntityUsed(entityId) {
177617
+ this._usedHumidityEntities.add(entityId);
177618
+ }
177619
+ /**
177620
+ * Check if a humidity entity has been auto-assigned to a temperature sensor.
177621
+ */
177622
+ isHumidityEntityUsed(entityId) {
177623
+ return this._usedHumidityEntities.has(entityId);
177624
+ }
177625
+ /**
177626
+ * Check if auto pressure mapping is enabled for this bridge.
177627
+ * Default: true (enabled by default).
177628
+ * When enabled, pressure sensors on the same device as a temperature sensor
177629
+ * are combined into a single endpoint with PressureMeasurement cluster.
177630
+ */
177631
+ isAutoPressureMappingEnabled() {
177632
+ return this.dataProvider.featureFlags?.autoPressureMapping !== false || this.dataProvider.featureFlags?.autoComposedDevices === true;
177633
+ }
177634
+ /**
177635
+ * Check if the vacuum OnOff cluster feature flag is enabled.
177636
+ * Defaults to OFF. OnOff is NOT part of the RoboticVacuumCleaner (0x74) device
177637
+ * type spec. Adding it makes the device non-conformant and causes Amazon Alexa
177638
+ * to reject it entirely (#185, #183). Only enable if a specific controller needs it.
177639
+ */
177640
+ isVacuumOnOffEnabled() {
177641
+ return this.dataProvider.featureFlags?.vacuumOnOff === true;
177642
+ }
177643
+ // Consume frozen device identities (#404). Seeding always runs; only
177644
+ // consumption of the stored endpoint id/anchor is gated on this flag.
177645
+ isStableIdentityEnabled() {
177646
+ return this.dataProvider.featureFlags?.stableIdentity === true;
177647
+ }
177648
+ /**
177649
+ * Check if the vacuum OnOff cluster should be included for server-mode vacuums.
177650
+ * Defaults to OFF. OnOff is NOT part of the RoboticVacuumCleaner (0x74) device
177651
+ * type spec. Adding it makes the device non-conformant and causes Amazon Alexa
177652
+ * to reject it entirely (#185, #183). Apple Home may also render the vacuum
177653
+ * incorrectly (shows "Updating" or switch UI). Only enable via feature flag
177654
+ * if a specific controller requires it.
177655
+ */
177656
+ isServerModeVacuumOnOffEnabled() {
177657
+ return this.dataProvider.featureFlags?.vacuumOnOff === true;
177658
+ }
177659
+ /**
177660
+ * Auto-detect vacuum-related select entities on the same HA device.
177661
+ * HA integrations (Dreame, Roborock, Ecovacs, Valetudo, etc.) expose vacuum
177662
+ * features as select entities with well-known suffixes. This finds them
177663
+ * automatically so users don't need to configure each entity manually.
177664
+ */
177665
+ findVacuumSelectEntities(deviceId) {
177666
+ const entities = values3(this.registry.entities);
177667
+ const sameDevice = entities.filter(
177668
+ (e) => e.device_id === deviceId && e.entity_id.startsWith("select.")
177669
+ );
177670
+ let cleaningModeEntity;
177671
+ let suctionLevelEntity;
177672
+ let mopIntensityEntity;
177673
+ for (const entity of sameDevice) {
177674
+ const state = this.registry.states[entity.entity_id];
177675
+ if (!state) continue;
177676
+ const id = entity.entity_id.toLowerCase();
177677
+ if (!cleaningModeEntity) {
177678
+ if (id.includes("cleaning_mode")) {
177679
+ cleaningModeEntity = entity.entity_id;
177680
+ } else if (id.endsWith("_mode")) {
177681
+ const options = state.attributes?.options;
177682
+ if (options?.some(
177683
+ (o) => /^(vacuum|mop|sweep|sweep_mop|sweep_before_mopping|sweep_then_mop|vacuum_and_mop|vacuum_then_mop|mopping|sweeping|sweeping_and_mopping|mopping_after_sweeping)$/i.test(
177684
+ o.replace(/\s+/g, "_")
177685
+ )
177686
+ )) {
177687
+ cleaningModeEntity = entity.entity_id;
177553
177688
  }
177554
- initialState[cluster2.clusterId] = cluster2.attributes;
177555
177689
  }
177556
- const hasOwnIdentity = supplied.behaviors?.bridgedDeviceBasicInformation != null;
177557
- const ownsPluginDevice = supplied.behaviors?.pluginDevice != null;
177558
- const mutable = typeof supplied.with === "function" && typeof supplied.set === "function";
177559
- if (!mutable && Object.keys(initialState).length > 0) {
177560
- this.log.warn(
177561
- `Plugin "${pluginName}": endpointType for device "${device.id}" cannot take its cluster config, skipping it`
177562
- );
177563
- return;
177564
- }
177565
- if (!hasOwnIdentity && (!mutable || ownsPluginDevice)) {
177566
- this.log.warn(
177567
- `Plugin "${pluginName}": device "${device.id}" mounts without BridgedDeviceBasicInformation, controllers may not show it`
177568
- );
177569
- }
177570
- let base = device.endpointType;
177571
- if (mutable && !hasOwnIdentity && !ownsPluginDevice) {
177572
- base = base.with(PluginBasicInformationServer, PluginDeviceBehavior);
177573
- initialState.pluginDevice = { device, pluginName };
177574
- }
177575
- endpoint = new Endpoint(
177576
- Object.keys(initialState).length > 0 ? base.set(initialState) : base,
177577
- { id: `plugin_${device.id}` }
177578
- );
177579
- } else {
177580
- const type = createPluginEndpointType(device.deviceType ?? "");
177581
- if (!type) {
177582
- this.log.warn(
177583
- `Plugin "${pluginName}": unsupported device type "${device.deviceType}" for device "${device.id}"`
177584
- );
177585
- return;
177586
- }
177587
- const initialState = {
177588
- pluginDevice: { device, pluginName }
177589
- };
177590
- for (const cluster2 of device.clusters) {
177591
- initialState[cluster2.clusterId] = cluster2.attributes;
177592
- }
177593
- endpoint = new Endpoint(type.set(initialState), {
177594
- id: `plugin_${device.id}`
177595
- });
177596
177690
  }
177597
- try {
177598
- await this.root.add(endpoint);
177599
- this.pluginEndpoints.set(device.id, endpoint);
177600
- this.wirePluginEndpointEvents(device, endpoint);
177601
- this.log.info(
177602
- `Plugin "${pluginName}": added device "${device.name}" (${device.deviceType})`
177603
- );
177604
- } catch (e) {
177605
- this.log.warn(
177606
- `Plugin "${pluginName}": failed to add device "${device.id}":`,
177607
- e
177608
- );
177691
+ if (!suctionLevelEntity && (id.includes("suction_level") || id.endsWith("_fan"))) {
177692
+ suctionLevelEntity = entity.entity_id;
177609
177693
  }
177610
- };
177611
- this.pluginManager.onDeviceUnregistered = async (pluginName, deviceId, options) => {
177612
- const listeners = this.pluginListeners.get(deviceId);
177613
- if (listeners) {
177614
- for (const { observable, listener } of listeners) {
177615
- try {
177616
- observable.off(listener);
177617
- } catch {
177618
- }
177619
- }
177620
- this.pluginListeners.delete(deviceId);
177694
+ if (!mopIntensityEntity && (id.includes("mop_intensity") || id.includes("mop_pad_humidity") || id.includes("water_volume") || id.includes("water_amount") || id.endsWith("_water"))) {
177695
+ mopIntensityEntity = entity.entity_id;
177621
177696
  }
177622
- const endpoint = this.pluginEndpoints.get(deviceId);
177623
- if (endpoint) {
177624
- try {
177625
- if (options?.keepIdentity) {
177626
- await endpoint.close();
177627
- } else {
177628
- await endpoint.delete();
177629
- }
177630
- } catch (e) {
177631
- this.log.warn(
177632
- `Plugin "${pluginName}": failed to remove device "${deviceId}":`,
177633
- e
177634
- );
177635
- }
177636
- this.pluginEndpoints.delete(deviceId);
177697
+ }
177698
+ let currentRoomEntity;
177699
+ const sameDeviceSensors = entities.filter(
177700
+ (e) => e.device_id === deviceId && e.entity_id.startsWith("sensor.")
177701
+ );
177702
+ for (const entity of sameDeviceSensors) {
177703
+ if (entity.entity_id.toLowerCase().endsWith("_current_room")) {
177704
+ currentRoomEntity = entity.entity_id;
177705
+ break;
177637
177706
  }
177707
+ }
177708
+ return {
177709
+ cleaningModeEntity,
177710
+ suctionLevelEntity,
177711
+ mopIntensityEntity,
177712
+ currentRoomEntity
177638
177713
  };
177639
- this.pluginManager.onDeviceStateUpdated = (pluginName, deviceId, clusterId3, attributes9) => {
177640
- const endpoint = this.pluginEndpoints.get(deviceId);
177641
- if (!endpoint) return;
177642
- const behaviorType = endpoint.type.behaviors[clusterId3];
177643
- if (!behaviorType) {
177644
- this.log.debug(
177645
- `Plugin "${pluginName}": cluster "${clusterId3}" not found on device "${deviceId}"`
177714
+ }
177715
+ static valetudoLogger = Logger.get("ValetudoRooms");
177716
+ /**
177717
+ * Find Valetudo map segments from the sensor.*_map_segments entity on the
177718
+ * same HA device. Valetudo exposes room/segment data via MQTT as a sensor
177719
+ * with numeric segment IDs in its attributes.
177720
+ *
177721
+ * Attribute format:
177722
+ * - Unnamed segments: { "1": 1, "2": 2, "4": 4 }
177723
+ * - Named segments: { "1": "Kitchen", "2": "Living Room" }
177724
+ */
177725
+ findValetudoMapSegments(deviceId) {
177726
+ const entities = values3(this.registry.entities);
177727
+ const mapSensor = entities.find(
177728
+ (e) => e.device_id === deviceId && e.entity_id.startsWith("sensor.") && e.entity_id.endsWith("_map_segments")
177729
+ );
177730
+ if (!mapSensor) return [];
177731
+ const state = this.registry.states[mapSensor.entity_id];
177732
+ if (!state) return [];
177733
+ const attrs = state.attributes;
177734
+ const rooms = [];
177735
+ for (const [key, value] of Object.entries(attrs)) {
177736
+ if (!/^\d+$/.test(key)) continue;
177737
+ const segmentId = Number.parseInt(key, 10);
177738
+ const name = typeof value === "string" ? value : `Segment ${key}`;
177739
+ rooms.push({ id: segmentId, name });
177740
+ }
177741
+ if (rooms.length > 0) {
177742
+ _BridgeRegistry.valetudoLogger.info(
177743
+ `Found ${rooms.length} Valetudo segments via ${mapSensor.entity_id}`
177744
+ );
177745
+ }
177746
+ return rooms;
177747
+ }
177748
+ static roborockLogger = Logger.get("RoborockRooms");
177749
+ /**
177750
+ * Resolve rooms for a Roborock vacuum by calling roborock.get_maps.
177751
+ * Returns parsed VacuumRoom[] with segment IDs, or empty array if
177752
+ * the service is unavailable or the vacuum is not Roborock.
177753
+ */
177754
+ async resolveRoborockRooms(entityId) {
177755
+ if (!this.client) return [];
177756
+ try {
177757
+ const raw = await callService2(
177758
+ this.client.connection,
177759
+ "roborock",
177760
+ "get_maps",
177761
+ void 0,
177762
+ { entity_id: entityId },
177763
+ true
177764
+ );
177765
+ const wrapper = raw;
177766
+ const responseData = wrapper?.response ?? wrapper;
177767
+ const entityData = responseData?.[entityId];
177768
+ if (!entityData?.maps) {
177769
+ _BridgeRegistry.roborockLogger.debug(
177770
+ `${entityId}: roborock.get_maps returned no maps (keys: ${Object.keys(responseData ?? {}).join(", ")})`
177646
177771
  );
177647
- return;
177772
+ return [];
177648
177773
  }
177649
- this.pluginStateUpdating.add(deviceId);
177650
- endpoint.setStateOf(behaviorType, attributes9).catch((e) => {
177651
- this.log.warn(
177652
- `Plugin "${pluginName}": failed to update "${clusterId3}" on "${deviceId}":`,
177653
- e
177654
- );
177655
- }).finally(() => {
177656
- this.pluginStateUpdating.delete(deviceId);
177657
- });
177658
- };
177659
- }
177660
- wirePluginEndpointEvents(device, endpoint) {
177661
- if (!device.onAttributeWrite) return;
177662
- const allEvents = endpoint.events;
177663
- const listeners = [];
177664
- for (const behaviorId of Object.keys(endpoint.type.behaviors)) {
177665
- if (behaviorId === "pluginDevice") continue;
177666
- const behaviorEvents = allEvents[behaviorId];
177667
- if (!behaviorEvents || typeof behaviorEvents !== "object") continue;
177668
- const eventNames = /* @__PURE__ */ new Set();
177669
- for (let proto = behaviorEvents; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
177670
- for (const name of Object.getOwnPropertyNames(proto)) {
177671
- if (name.endsWith("$Changed")) eventNames.add(name);
177774
+ const rooms = [];
177775
+ for (const map of entityData.maps) {
177776
+ if (!map.rooms) continue;
177777
+ for (const [segmentId, roomName] of Object.entries(map.rooms)) {
177778
+ const id = /^\d+$/.test(segmentId) ? Number.parseInt(segmentId, 10) : segmentId;
177779
+ rooms.push({ id, name: roomName });
177672
177780
  }
177673
177781
  }
177674
- for (const eventName of eventNames) {
177675
- const observable = behaviorEvents[eventName];
177676
- if (!observable || typeof observable.on !== "function") continue;
177677
- const attrName = eventName.slice(0, -"$Changed".length);
177678
- const listener = (newValue) => {
177679
- if (this.pluginStateUpdating.has(device.id)) return;
177680
- device.onAttributeWrite?.(behaviorId, attrName, newValue).catch((e) => {
177681
- this.log.debug(
177682
- `Plugin device "${device.id}": onAttributeWrite error for ${behaviorId}.${attrName}:`,
177683
- e
177684
- );
177685
- });
177686
- };
177687
- observable.on(listener);
177688
- listeners.push({ observable, listener });
177782
+ if (rooms.length > 0) {
177783
+ _BridgeRegistry.roborockLogger.info(
177784
+ `${entityId}: Resolved ${rooms.length} rooms via roborock.get_maps`
177785
+ );
177689
177786
  }
177787
+ return rooms;
177788
+ } catch (error) {
177789
+ const msg = error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
177790
+ _BridgeRegistry.roborockLogger.warn(
177791
+ `${entityId}: roborock.get_maps failed: ${msg}`
177792
+ );
177793
+ return [];
177690
177794
  }
177691
- if (listeners.length > 0) {
177692
- this.pluginListeners.set(device.id, listeners);
177693
- }
177694
- }
177695
- async startPlugins() {
177696
- if (!this.pluginManager) return;
177697
- await this.registerBuiltInPlugins();
177698
- await this.loadRegisteredPlugins();
177699
- await this.pluginManager.startAll();
177700
- await this.pluginManager.configureAll();
177701
177795
  }
177702
- // Built-in plugins ship inside the backend bundle, so they share the same
177703
- // matter.js instance and can hand over live EndpointTypes.
177704
- async registerBuiltInPlugins() {
177705
- if (!this.pluginManager) return;
177706
- for (const Plugin of BUILTIN_PLUGINS) {
177707
- const plugin = new Plugin();
177708
- try {
177709
- await this.pluginManager.registerBuiltIn(plugin);
177710
- } catch (e) {
177711
- this.log.warn(
177712
- `Failed to register built-in plugin "${plugin.name}":`,
177713
- e
177796
+ static cleanAreaLogger = Logger.get("CleanAreaRooms");
177797
+ /**
177798
+ * Resolve HA areas mapped to vacuum segments via HA 2026.3 CLEAN_AREA.
177799
+ * Fetches the full entity registry entry (including options.vacuum.area_mapping)
177800
+ * and resolves HA area names from the area registry.
177801
+ * Returns CleanAreaRoom[] sorted alphabetically, or empty array if
177802
+ * CLEAN_AREA is not supported or no area_mapping is configured.
177803
+ */
177804
+ async resolveCleanAreaRooms(entityId, supportedFeatures) {
177805
+ if (!this.client) return [];
177806
+ if (!(supportedFeatures & VacuumDeviceFeature.CLEAN_AREA)) return [];
177807
+ try {
177808
+ const entry = await sendHaMessage(this.client.connection, {
177809
+ type: "config/entity_registry/get",
177810
+ entity_id: entityId
177811
+ });
177812
+ const vacuumOptions = entry?.options?.vacuum;
177813
+ const areaMapping = vacuumOptions?.area_mapping;
177814
+ if (!areaMapping || Object.keys(areaMapping).length === 0) {
177815
+ _BridgeRegistry.cleanAreaLogger.debug(
177816
+ `${entityId}: CLEAN_AREA supported but no area_mapping configured`
177714
177817
  );
177818
+ return [];
177715
177819
  }
177716
- }
177717
- }
177718
- async loadRegisteredPlugins() {
177719
- if (!this.pluginManager || !this.pluginRegistry || !this.pluginInstaller)
177720
- return;
177721
- const registered = this.pluginRegistry.getAll();
177722
- for (const entry of registered) {
177723
- if (!entry.autoLoad) continue;
177724
- const packagePath = this.pluginInstaller.getPluginPath(entry.packageName);
177820
+ let validSegmentIds;
177725
177821
  try {
177726
- await this.pluginManager.loadExternal(packagePath, entry.config);
177727
- this.log.info(
177728
- `Loaded external plugin: ${entry.packageName} from ${packagePath}`
177822
+ const segmentsResponse = await sendHaMessage(this.client.connection, {
177823
+ type: "vacuum/get_segments",
177824
+ entity_id: entityId
177825
+ });
177826
+ if (Array.isArray(segmentsResponse)) {
177827
+ validSegmentIds = new Set(segmentsResponse.map((s) => s.id));
177828
+ _BridgeRegistry.cleanAreaLogger.debug(
177829
+ `${entityId}: Current vacuum segments: ${[...validSegmentIds].join(", ")}`
177830
+ );
177831
+ }
177832
+ } catch {
177833
+ _BridgeRegistry.cleanAreaLogger.debug(
177834
+ `${entityId}: vacuum/get_segments not available, skipping stale entry detection`
177729
177835
  );
177730
- } catch (e) {
177731
- this.log.warn(
177732
- `Failed to load external plugin "${entry.packageName}":`,
177733
- e
177836
+ }
177837
+ const rooms = [];
177838
+ for (const haAreaId of Object.keys(areaMapping)) {
177839
+ const segments = areaMapping[haAreaId];
177840
+ if (!segments || segments.length === 0) {
177841
+ _BridgeRegistry.cleanAreaLogger.debug(
177842
+ `${entityId}: Skipping HA area ${haAreaId}, no segments mapped`
177843
+ );
177844
+ continue;
177845
+ }
177846
+ if (validSegmentIds && !segments.some((sid) => validSegmentIds.has(sid))) {
177847
+ const areaName2 = this.registry.areas.get(haAreaId) ?? haAreaId;
177848
+ _BridgeRegistry.cleanAreaLogger.info(
177849
+ `${entityId}: Skipping stale HA area "${areaName2}" (${haAreaId}), segments [${segments.join(", ")}] no longer exist on vacuum`
177850
+ );
177851
+ continue;
177852
+ }
177853
+ const areaName = this.registry.areas.get(haAreaId) ?? haAreaId;
177854
+ rooms.push({
177855
+ areaId: hashAreaId(haAreaId),
177856
+ haAreaId,
177857
+ name: areaName
177858
+ });
177859
+ }
177860
+ rooms.sort((a, b) => a.name.localeCompare(b.name));
177861
+ if (rooms.length > 0) {
177862
+ _BridgeRegistry.cleanAreaLogger.info(
177863
+ `${entityId}: Resolved ${rooms.length} HA areas via CLEAN_AREA mapping`
177734
177864
  );
177735
177865
  }
177866
+ return rooms;
177867
+ } catch (error) {
177868
+ const msg = error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
177869
+ _BridgeRegistry.cleanAreaLogger.warn(
177870
+ `${entityId}: Failed to resolve CLEAN_AREA mapping: ${msg}`
177871
+ );
177872
+ return [];
177736
177873
  }
177737
177874
  }
177738
- async stopPlugins() {
177739
- if (!this.pluginManager) return;
177740
- await this.pluginManager.shutdownAll("Bridge stopping");
177741
- for (const [id, endpoint] of this.pluginEndpoints) {
177742
- try {
177743
- await endpoint.close();
177744
- } catch (e) {
177745
- this.log.warn(`Failed to close plugin endpoint ${id}:`, e);
177875
+ /**
177876
+ * Find a pressure sensor entity that belongs to the same HA device.
177877
+ * Returns the entity_id of the pressure sensor, or undefined if none found.
177878
+ */
177879
+ findPressureEntityForDevice(deviceId) {
177880
+ const entities = values3(this.registry.entities);
177881
+ for (const entity of entities) {
177882
+ if (entity.device_id !== deviceId) continue;
177883
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177884
+ const state = this.registry.states[entity.entity_id];
177885
+ if (!state) continue;
177886
+ const attrs = state.attributes;
177887
+ if (attrs.device_class === SensorDeviceClass.pressure || attrs.device_class === SensorDeviceClass.atmospheric_pressure) {
177888
+ return entity.entity_id;
177746
177889
  }
177747
177890
  }
177748
- this.pluginEndpoints.clear();
177749
- }
177750
- getPluginInfo() {
177751
- if (!this.pluginManager) {
177752
- return { metadata: [], devices: [], circuitBreakers: {} };
177753
- }
177754
- const cbStates = this.pluginManager.getCircuitBreakerStates();
177755
- const circuitBreakers = {};
177756
- for (const [name, state] of cbStates) {
177757
- circuitBreakers[name] = state;
177758
- }
177759
- return {
177760
- metadata: this.pluginManager.getMetadata(),
177761
- devices: this.pluginManager.getAllDevices(),
177762
- circuitBreakers
177763
- };
177764
- }
177765
- async enablePlugin(pluginName) {
177766
- return await this.pluginManager?.enablePlugin(pluginName);
177767
- }
177768
- async disablePlugin(pluginName) {
177769
- return await this.pluginManager?.disablePlugin(pluginName);
177770
- }
177771
- resetPlugin(pluginName) {
177772
- this.pluginManager?.resetPlugin(pluginName);
177891
+ return void 0;
177773
177892
  }
177774
- getPluginConfigSchema(pluginName) {
177775
- return this.pluginManager?.getConfigSchema(pluginName);
177893
+ /**
177894
+ * Mark a pressure entity as used (auto-assigned to a temperature sensor).
177895
+ */
177896
+ markPressureEntityUsed(entityId) {
177897
+ this._usedPressureEntities.add(entityId);
177776
177898
  }
177777
- async updatePluginConfig(pluginName, config8) {
177778
- return await this.pluginManager?.updateConfig(pluginName, config8) ?? false;
177899
+ /**
177900
+ * Check if a pressure entity has been auto-assigned to a temperature sensor.
177901
+ */
177902
+ isPressureEntityUsed(entityId) {
177903
+ return this._usedPressureEntities.has(entityId);
177779
177904
  }
177780
177905
  /**
177781
- * Isolate an entity by removing it from the aggregator.
177782
- * Called by EntityIsolationService when a runtime error is detected.
177906
+ * Find a power sensor entity (device_class: power) on the same HA device.
177783
177907
  */
177784
- async isolateEntity(entityName) {
177785
- const endpoints = [...this.root.parts].filter(hasEntityIdentity);
177786
- const endpoint = endpoints.find(
177787
- (e) => !(e instanceof VacuumAreaSwitchEndpoint) && (e.id === entityName || e.entityId === entityName)
177788
- ) ?? endpoints.find((e) => e.id === entityName);
177789
- if (endpoint) {
177790
- this.log.warn(
177791
- `Isolating entity ${endpoint.entityId} due to runtime error`
177792
- );
177793
- try {
177794
- await endpoint.close();
177795
- } catch (e) {
177796
- this.log.error(`Failed to close isolated endpoint:`, e);
177797
- }
177798
- this.pendingRemovals.delete(endpoint.entityId);
177799
- this.mappingFingerprints.delete(endpoint.entityId);
177800
- if (!(endpoint instanceof VacuumAreaSwitchEndpoint)) {
177801
- for (const sw of endpoints) {
177802
- if (!(sw instanceof VacuumAreaSwitchEndpoint)) continue;
177803
- if (sw.vacuumEndpointId !== endpoint.id) continue;
177804
- try {
177805
- await sw.close();
177806
- } catch (e) {
177807
- this.log.warn(`Failed to remove area switch ${sw.id}:`, e);
177808
- }
177809
- }
177908
+ findPowerEntityForDevice(deviceId) {
177909
+ const entities = values3(this.registry.entities);
177910
+ for (const entity of entities) {
177911
+ if (entity.device_id !== deviceId) continue;
177912
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177913
+ const state = this.registry.states[entity.entity_id];
177914
+ if (!state) continue;
177915
+ const attrs = state.attributes;
177916
+ if (attrs.device_class === SensorDeviceClass.power) {
177917
+ return entity.entity_id;
177810
177918
  }
177811
177919
  }
177920
+ return void 0;
177812
177921
  }
177813
- // refreshDevices only runs on registry-fingerprint changes, which may not
177814
- // recur, so drive any held removals to completion ourselves once the grace
177815
- // window has passed.
177816
- scheduleRemovalRecheck() {
177817
- if (this.removalRecheckTimer) {
177818
- clearTimeout(this.removalRecheckTimer);
177819
- this.removalRecheckTimer = null;
177922
+ /**
177923
+ * Find an energy sensor entity (device_class: energy) on the same HA device.
177924
+ */
177925
+ findEnergyEntityForDevice(deviceId) {
177926
+ const entities = values3(this.registry.entities);
177927
+ for (const entity of entities) {
177928
+ if (entity.device_id !== deviceId) continue;
177929
+ if (!entity.entity_id.startsWith("sensor.")) continue;
177930
+ const state = this.registry.states[entity.entity_id];
177931
+ if (!state) continue;
177932
+ const attrs = state.attributes;
177933
+ if (attrs.device_class === SensorDeviceClass.energy) {
177934
+ return entity.entity_id;
177935
+ }
177820
177936
  }
177821
- if (this.pendingRemovals.size === 0) return;
177822
- const lifecycle = this.lifecycle;
177823
- this.removalRecheckTimer = setTimeout(() => {
177824
- this.removalRecheckTimer = null;
177825
- this.refreshDevices().catch((e) => {
177826
- this.log.warn("Endpoint removal recheck failed:", e);
177827
- if (lifecycle === this.lifecycle) this.scheduleRemovalRecheck();
177828
- });
177829
- }, ENDPOINT_REMOVAL_GRACE_MS + 5e3);
177937
+ return void 0;
177830
177938
  }
177831
- getPluginDomainMappings() {
177832
- if (!this.pluginManager) return void 0;
177833
- const mappings = this.pluginManager.getDomainMappings();
177834
- if (mappings.size === 0) return void 0;
177835
- const result = /* @__PURE__ */ new Map();
177836
- for (const [domain, mapping] of mappings) {
177837
- result.set(domain, mapping.matterDeviceType);
177838
- }
177839
- return result;
177939
+ markPowerEntityUsed(entityId) {
177940
+ this._usedPowerEntities.add(entityId);
177840
177941
  }
177841
- getEntityMapping(entityId) {
177842
- return this.mappingStorage.getMapping(this.bridgeId, entityId);
177942
+ isPowerEntityUsed(entityId) {
177943
+ return this._usedPowerEntities.has(entityId);
177843
177944
  }
177844
- computeMappingFingerprint(mapping) {
177845
- if (!mapping) return "";
177846
- return JSON.stringify(mapping);
177945
+ markEnergyEntityUsed(entityId) {
177946
+ this._usedEnergyEntities.add(entityId);
177847
177947
  }
177848
- async dispose() {
177849
- this.stopObserving();
177850
- if (this.removalRecheckTimer) {
177851
- clearTimeout(this.removalRecheckTimer);
177852
- this.removalRecheckTimer = null;
177853
- }
177854
- this.pendingRemovals.clear();
177855
- EntityIsolationService.unregisterIsolationCallback(this.bridgeId);
177856
- EntityIsolationService.clearIsolatedEntities(this.bridgeId);
177857
- const endpoints = this.root.parts.map((p) => p);
177858
- for (const endpoint of endpoints) {
177859
- try {
177860
- await endpoint.close();
177861
- } catch (e) {
177862
- this.log.warn(`Failed to close endpoint during dispose:`, e);
177863
- }
177864
- }
177948
+ isEnergyEntityUsed(entityId) {
177949
+ return this._usedEnergyEntities.has(entityId);
177865
177950
  }
177866
- async startObserving() {
177867
- this.clearSubscription();
177868
- this.observingRequested = true;
177869
- if (!this.entityIds.length) {
177870
- return;
177951
+ mergeExternalStates(states) {
177952
+ const registryStates = this.registry.states;
177953
+ for (const entityId of Object.keys(states)) {
177954
+ registryStates[entityId] = states[entityId];
177871
177955
  }
177872
- const subscriptionIds = this.collectSubscriptionEntityIds();
177873
- this.unsubscribe = subscribeEntities(
177874
- this.client.connection,
177875
- (e, changed) => this.updateStates(e, changed),
177876
- subscriptionIds
177877
- );
177878
177956
  }
177879
- collectSubscriptionEntityIds() {
177880
- const ids = new Set(this.entityIds);
177881
- const endpoints = this.root.parts.map((p) => p);
177882
- for (const endpoint of endpoints) {
177883
- const mappedIds = endpoint.mappedEntityIds;
177884
- if (mappedIds) {
177885
- for (const mappedId of mappedIds) {
177886
- ids.add(mappedId);
177887
- }
177888
- }
177957
+ /**
177958
+ * Get the area name for an entity, resolving from HA area registry.
177959
+ * Priority: entity area_id > device area_id > undefined
177960
+ */
177961
+ getAreaName(entityId) {
177962
+ const entity = this._entities[entityId];
177963
+ if (!entity) return void 0;
177964
+ const entityAreaId = entity.area_id;
177965
+ if (entityAreaId) {
177966
+ const name = this.registry.areas.get(entityAreaId);
177967
+ if (name) return name;
177889
177968
  }
177890
- return [...ids];
177891
- }
177892
- clearSubscription() {
177893
- this.unsubscribe?.();
177894
- this.unsubscribe = void 0;
177895
- }
177896
- stopObserving() {
177897
- this.observingRequested = false;
177898
- this.lifecycle++;
177899
- this.clearSubscription();
177900
- if (this.removalRecheckTimer) {
177901
- clearTimeout(this.removalRecheckTimer);
177902
- this.removalRecheckTimer = null;
177969
+ const device = this._devices[entity.device_id];
177970
+ const deviceAreaId = device?.area_id;
177971
+ if (deviceAreaId) {
177972
+ const name = this.registry.areas.get(deviceAreaId);
177973
+ if (name) return name;
177903
177974
  }
177975
+ return void 0;
177904
177976
  }
177905
- async refreshDevices() {
177906
- this.registry.refresh();
177907
- const lifecycle = this.lifecycle;
177908
- const endpoints = [...this.root.parts].filter(hasEntityIdentity).filter((p) => !(p instanceof VacuumAreaSwitchEndpoint));
177909
- const fullEntities = this.registry.fullEntities;
177910
- if (!this.client.haRunning || Object.keys(fullEntities).length === 0) {
177911
- this.pendingRemovals.clear();
177912
- this.log.warn(
177913
- `HA not running or registry empty, deferring reconcile of ${endpoints.length} endpoints`
177914
- );
177915
- return;
177916
- }
177917
- this._failedEntities = [];
177918
- this.entityIds = this.registry.entityIds;
177919
- if (this.registry.isAutoComposedDevicesEnabled()) {
177920
- for (const eid of this.entityIds) {
177921
- const m = this.getEntityMapping(eid);
177922
- if (m?.composedEntities) {
177923
- for (const sub of m.composedEntities) {
177924
- if (sub.entityId) {
177925
- this.registry.markComposedSubEntityUsed(sub.entityId);
177926
- }
177927
- }
177928
- }
177929
- if (!eid.startsWith("fan.")) continue;
177930
- const matterType = m?.matterDeviceType ?? "fan";
177931
- if (matterType !== "air_purifier") continue;
177932
- const ent = this.registry.entity(eid);
177933
- const tempId = m?.temperatureEntity || (ent?.device_id ? this.registry.findTemperatureEntityForDevice(ent.device_id) : void 0);
177934
- const humId = m?.humidityEntity || (ent?.device_id ? this.registry.findHumidityEntityForDevice(ent.device_id) : void 0);
177935
- if (tempId) this.registry.markComposedSubEntityUsed(tempId);
177936
- if (humId) this.registry.markComposedSubEntityUsed(humId);
177977
+ refresh() {
177978
+ this._usedBatteryEntities.clear();
177979
+ this._usedHumidityEntities.clear();
177980
+ this._usedPressureEntities.clear();
177981
+ this._usedPowerEntities.clear();
177982
+ this._usedEnergyEntities.clear();
177983
+ this._usedComposedSubEntities.clear();
177984
+ this._batteryEntityCache.clear();
177985
+ this._problemEntityCache.clear();
177986
+ this._entities = pickBy(this.registry.entities, (entity) => {
177987
+ const device = this.registry.devices[entity.device_id];
177988
+ const filter = this.dataProvider.filter;
177989
+ const featureFlags = this.dataProvider.featureFlags ?? {};
177990
+ if (entity.disabled_by != null) {
177991
+ return false;
177937
177992
  }
177938
- }
177939
- const stableIdentity = this.registry.isStableIdentityEnabled();
177940
- const resolvedByEntity = /* @__PURE__ */ new Map();
177941
- const endpointIdToEntity = /* @__PURE__ */ new Map();
177942
- const claimedEndpointIds = /* @__PURE__ */ new Map();
177943
- for (const entityId of this.entityIds) {
177944
- const entityInfo = {
177945
- entity_id: entityId,
177946
- registry: this.registry.entity(entityId)
177947
- };
177948
- const resolved = await this.identityResolver.resolveIdentity(
177949
- this.bridgeId,
177950
- entityInfo,
177951
- this.getEntityMapping(entityId),
177952
- {
177953
- stableIdentity,
177954
- isEndpointIdTaken: (id, key) => claimedEndpointIds.has(id) && claimedEndpointIds.get(id) !== key
177955
- }
177956
- );
177957
- resolvedByEntity.set(entityId, resolved);
177958
- claimedEndpointIds.set(
177959
- resolved.endpointId,
177960
- identityKey(entityInfo) ?? `\0e:${entityId}`
177961
- );
177962
- endpointIdToEntity.set(resolved.endpointId, entityId);
177963
- if (resolved.renamedFrom && this.mappingFingerprints.has(resolved.renamedFrom)) {
177964
- const fp = this.mappingFingerprints.get(resolved.renamedFrom);
177965
- this.mappingFingerprints.delete(resolved.renamedFrom);
177966
- this.mappingFingerprints.set(entityId, fp);
177993
+ const isHidden = entity.hidden_by != null;
177994
+ if (isHidden && !featureFlags.includeHiddenEntities) {
177995
+ return false;
177967
177996
  }
177968
- }
177969
- stampIdentityPresence(
177970
- this.identityStorage,
177971
- this.bridgeId,
177972
- buildPresentIdentityKeys(fullEntities)
177997
+ const state = this.registry.states[entity.entity_id];
177998
+ return this.matchesFilter(filter, entity, device, state);
177999
+ });
178000
+ this._states = pickBy(
178001
+ this.registry.states,
178002
+ (e) => !!this._entities[e.entity_id]
177973
178003
  );
177974
- stampMappingPresence(
177975
- this.mappingStorage,
177976
- this.bridgeId,
177977
- buildPresentEntityIds(fullEntities)
178004
+ this._devices = pickBy(
178005
+ this.registry.devices,
178006
+ (d) => values3(this._entities).map((e) => e.device_id).some((id) => d.id === id)
177978
178007
  );
177979
- for (const part of [...this.root.parts]) {
177980
- if (!(part instanceof VacuumAreaSwitchEndpoint)) continue;
177981
- const claimant = endpointIdToEntity.get(part.id);
177982
- if (claimant == null) continue;
177983
- this.log.info(
177984
- `Area switch ${part.id} collides with entity ${claimant}, removing the switch`
177985
- );
177986
- try {
177987
- await part.delete();
177988
- } catch (e) {
177989
- this.log.warn(`Failed to remove colliding area switch ${part.id}:`, e);
177990
- }
177991
- }
177992
- const existingEndpoints = [];
177993
- const now = Date.now();
177994
- for (const endpoint of endpoints) {
177995
- const present = this.entityIds.includes(endpoint.entityId);
177996
- const claimant = endpointIdToEntity.get(endpoint.id);
177997
- if (!present && claimant != null && claimant !== endpoint.entityId) {
177998
- this.log.info(
177999
- `Entity renamed ${endpoint.entityId} -> ${claimant}, keeping endpoint ${endpoint.id}`
178000
- );
178001
- try {
178002
- await endpoint.close();
178003
- } catch (e) {
178004
- this.log.warn(
178005
- `Failed to close renamed endpoint ${endpoint.entityId}:`,
178006
- e
178008
+ this.preCalculateAutoAssignments();
178009
+ }
178010
+ /**
178011
+ * Pre-calculate which entities will be auto-assigned to other devices.
178012
+ * This must run BEFORE endpoint creation to ensure correct "used" marking
178013
+ * regardless of the order entities are processed.
178014
+ */
178015
+ preCalculateAutoAssignments() {
178016
+ const entities = values3(this._entities);
178017
+ for (const entity of entities) {
178018
+ if (!entity.device_id) continue;
178019
+ if (!entity.entity_id.startsWith("sensor.")) continue;
178020
+ const state = this._states[entity.entity_id];
178021
+ if (!state) continue;
178022
+ const attrs = state.attributes;
178023
+ if (attrs.device_class === SensorDeviceClass.temperature) {
178024
+ if (this.isAutoHumidityMappingEnabled()) {
178025
+ const humidityEntityId = this.findHumidityEntityForDevice(
178026
+ entity.device_id
178007
178027
  );
178028
+ if (humidityEntityId && humidityEntityId !== entity.entity_id) {
178029
+ this._usedHumidityEntities.add(humidityEntityId);
178030
+ }
178031
+ }
178032
+ if (this.isAutoPressureMappingEnabled()) {
178033
+ const pressureEntityId = this.findPressureEntityForDevice(
178034
+ entity.device_id
178035
+ );
178036
+ if (pressureEntityId && pressureEntityId !== entity.entity_id) {
178037
+ this._usedPressureEntities.add(pressureEntityId);
178038
+ }
178008
178039
  }
178009
- this.pendingRemovals.delete(endpoint.entityId);
178010
- this.mappingFingerprints.delete(endpoint.entityId);
178011
- continue;
178012
178040
  }
178013
- if (present) {
178014
- this.pendingRemovals.delete(endpoint.entityId);
178041
+ }
178042
+ for (const entity of entities) {
178043
+ if (!entity.device_id) continue;
178044
+ const domain = entity.entity_id.split(".")[0];
178045
+ if (domain !== "switch" && domain !== "light") continue;
178046
+ const powerEntityId = this.findPowerEntityForDevice(entity.device_id);
178047
+ if (powerEntityId && powerEntityId !== entity.entity_id) {
178048
+ if (!this._usedPowerEntities.has(powerEntityId)) {
178049
+ this._usedPowerEntities.add(powerEntityId);
178050
+ }
178015
178051
  }
178016
- if (!present) {
178017
- const entry = this.pendingRemovals.get(endpoint.entityId);
178018
- if (entry == null) {
178019
- this.pendingRemovals.set(endpoint.entityId, {
178020
- since: now,
178021
- generation: this.registry.snapshotGeneration
178022
- });
178023
- existingEndpoints.push(endpoint);
178024
- continue;
178052
+ const energyEntityId = this.findEnergyEntityForDevice(entity.device_id);
178053
+ if (energyEntityId && energyEntityId !== entity.entity_id) {
178054
+ if (!this._usedEnergyEntities.has(energyEntityId)) {
178055
+ this._usedEnergyEntities.add(energyEntityId);
178025
178056
  }
178026
- if (now - entry.since < ENDPOINT_REMOVAL_GRACE_MS || now - this.client.runningSince < ENDPOINT_REMOVAL_GRACE_MS || this.registry.snapshotGeneration <= entry.generation) {
178027
- existingEndpoints.push(endpoint);
178028
- continue;
178057
+ }
178058
+ }
178059
+ if (this.isAutoBatteryMappingEnabled()) {
178060
+ for (const entity of entities) {
178061
+ if (!entity.device_id) continue;
178062
+ if (this._usedHumidityEntities.has(entity.entity_id)) continue;
178063
+ if (entity.entity_id.startsWith("sensor.")) {
178064
+ const state = this._states[entity.entity_id];
178065
+ if (state) {
178066
+ const attrs = state.attributes;
178067
+ if (attrs.device_class === SensorDeviceClass.battery) continue;
178068
+ }
178029
178069
  }
178030
- try {
178031
- this.log.info(
178032
- `Removing endpoint ${endpoint.entityId} (ep ${endpoint.number}) after the grace window, controllers will see a new number if it returns`
178033
- );
178034
- await endpoint.delete();
178035
- } catch (e) {
178036
- this.log.warn(`Failed to delete endpoint ${endpoint.entityId}:`, e);
178070
+ if (entity.entity_id.startsWith("binary_sensor.")) {
178071
+ const state = this._states[entity.entity_id];
178072
+ if (state) {
178073
+ const attrs = state.attributes;
178074
+ if (attrs.device_class === "battery") continue;
178075
+ }
178037
178076
  }
178038
- this.mappingFingerprints.delete(endpoint.entityId);
178039
- this.pendingRemovals.delete(endpoint.entityId);
178040
- } else if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(endpoint.entityId)) {
178041
- this.log.info(
178042
- `Removing standalone endpoint ${endpoint.entityId}, consumed by composed device`
178077
+ const batteryEntityId = this.findBatteryEntityForDevice(
178078
+ entity.device_id
178043
178079
  );
178044
- try {
178045
- await endpoint.close();
178046
- } catch (e) {
178047
- this.log.warn(
178048
- `Failed to remove composed sub-entity endpoint ${endpoint.entityId}:`,
178049
- e
178050
- );
178051
- }
178052
- this.mappingFingerprints.delete(endpoint.entityId);
178053
- } else {
178054
- const currentMapping = this.getEntityMapping(endpoint.entityId);
178055
- const currentFp = this.computeMappingFingerprint(currentMapping);
178056
- const storedFp = this.mappingFingerprints.get(endpoint.entityId) ?? "";
178057
- if (currentFp !== storedFp) {
178058
- this.log.info(
178059
- `Mapping changed for ${endpoint.entityId}, recreating endpoint`
178060
- );
178061
- const resolvedId = resolvedByEntity.get(endpoint.entityId)?.endpointId ?? createEndpointId(endpoint.entityId, currentMapping?.customName);
178062
- const sameId = resolvedId === endpoint.id;
178063
- try {
178064
- if (sameId) {
178065
- await endpoint.close();
178066
- } else {
178067
- await endpoint.delete();
178068
- }
178069
- } catch (e) {
178070
- this.log.warn(
178071
- `Failed to recreate endpoint ${endpoint.entityId} for mapping change:`,
178072
- e
178073
- );
178080
+ if (batteryEntityId && batteryEntityId !== entity.entity_id) {
178081
+ if (!this._usedBatteryEntities.has(batteryEntityId)) {
178082
+ this._usedBatteryEntities.add(batteryEntityId);
178074
178083
  }
178075
- this.mappingFingerprints.delete(endpoint.entityId);
178076
- } else {
178077
- existingEndpoints.push(endpoint);
178078
178084
  }
178079
178085
  }
178080
178086
  }
178081
- if (lifecycle === this.lifecycle) {
178082
- this.scheduleRemovalRecheck();
178083
- }
178084
- let memoryLimitReached = false;
178085
- for (const entityId of this.entityIds) {
178086
- if (!memoryLimitReached && isHeapUnderPressure()) {
178087
- memoryLimitReached = true;
178088
- this.log.error(
178089
- "Memory pressure detected, skipping remaining entities to prevent OOM crash. Reduce the number of entities in this bridge or increase the Node.js heap size (NODE_OPTIONS=--max-old-space-size=1024)."
178090
- );
178091
- }
178092
- if (memoryLimitReached) {
178093
- if (!existingEndpoints.some((e) => e.entityId === entityId)) {
178094
- this.addFailedEntity(
178095
- entityId,
178096
- "Skipped due to memory pressure, reduce entities or increase heap size"
178097
- );
178098
- }
178099
- continue;
178100
- }
178101
- const mapping = this.getEntityMapping(entityId);
178102
- if (mapping?.disabled) {
178103
- this.log.debug(`Skipping disabled entity: ${entityId}`);
178104
- continue;
178087
+ }
178088
+ /**
178089
+ * The first already-matched entity the given matcher tests true for.
178090
+ * Server mode pins the primary entity to the first include matcher with
178091
+ * this, independent of HA registry order (#301).
178092
+ */
178093
+ firstEntityMatching(matcher) {
178094
+ const labels = this.registry.labels;
178095
+ for (const entity of values3(this._entities)) {
178096
+ const device = this.registry.devices[entity.device_id];
178097
+ const state = this.registry.states[entity.entity_id];
178098
+ if (testMatchers([matcher], device, entity, "any", state, labels)) {
178099
+ return entity.entity_id;
178105
178100
  }
178106
- if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(entityId)) {
178107
- this.log.debug(
178108
- `Skipping ${entityId}, already part of a composed device`
178109
- );
178110
- continue;
178101
+ }
178102
+ return void 0;
178103
+ }
178104
+ matchesFilter(filter, entity, device, entityState) {
178105
+ const labels = this.registry.labels;
178106
+ if (filter.include.length > 0 && !testMatchers(
178107
+ filter.include,
178108
+ device,
178109
+ entity,
178110
+ filter.includeMode,
178111
+ entityState,
178112
+ labels
178113
+ )) {
178114
+ return false;
178115
+ }
178116
+ if (filter.exclude.length > 0 && testMatchers(filter.exclude, device, entity, "any", entityState, labels)) {
178117
+ return false;
178118
+ }
178119
+ return true;
178120
+ }
178121
+ };
178122
+ function hashAreaId(areaId) {
178123
+ let hash2 = 0;
178124
+ for (let i = 0; i < areaId.length; i++) {
178125
+ const char = areaId.charCodeAt(i);
178126
+ hash2 = (hash2 << 5) - hash2 + char;
178127
+ hash2 |= 0;
178128
+ }
178129
+ return Math.abs(hash2);
178130
+ }
178131
+
178132
+ // src/services/bridges/entity-isolation-service.ts
178133
+ init_esm();
178134
+ init_diagnostic_event_bus();
178135
+ var logger252 = Logger.get("EntityIsolation");
178136
+ var EntityIsolationServiceImpl = class {
178137
+ isolatedEntities = /* @__PURE__ */ new Map();
178138
+ isolationCallbacks = /* @__PURE__ */ new Map();
178139
+ /**
178140
+ * Register a callback to be called when an entity needs to be isolated.
178141
+ * The callback should remove the entity from the bridge's aggregator.
178142
+ */
178143
+ registerIsolationCallback(bridgeId, callback) {
178144
+ this.isolationCallbacks.set(bridgeId, callback);
178145
+ }
178146
+ unregisterIsolationCallback(bridgeId) {
178147
+ this.isolationCallbacks.delete(bridgeId);
178148
+ }
178149
+ /**
178150
+ * Parse the endpoint path from a Matter.js error message and extract the entity name.
178151
+ * Example path: "ed5b4f8d042e4599b833f21da4ededba.aggregator.Küchenlicht.onOff.on"
178152
+ * Returns: { bridgeId: "ed5b4f8d...", entityName: "Küchenlicht" }
178153
+ */
178154
+ parseEndpointPath(errorMessage) {
178155
+ const match = errorMessage.match(/([a-f0-9]{32})\.aggregator\.([^.\s>]+)/i);
178156
+ if (match) {
178157
+ return {
178158
+ bridgeId: match[1],
178159
+ entityName: match[2]
178160
+ };
178161
+ }
178162
+ return null;
178163
+ }
178164
+ classifyError(msg) {
178165
+ if (msg.includes("Invalid intervalMs")) {
178166
+ return "Subscription timing error (Invalid intervalMs)";
178167
+ }
178168
+ if (msg.includes("Behaviors have errors")) {
178169
+ return "Behavior initialization failure";
178170
+ }
178171
+ if (msg.includes("TransactionDestroyedError")) {
178172
+ return "Transaction destroyed during operation";
178173
+ }
178174
+ if (msg.includes("DestroyedDependencyError")) {
178175
+ return "Dependency destroyed during operation";
178176
+ }
178177
+ if (msg.includes("UninitializedDependencyError")) {
178178
+ return "Uninitialized dependency access";
178179
+ }
178180
+ if (msg.includes("Endpoint storage inaccessible")) {
178181
+ return "Endpoint storage inaccessible";
178182
+ }
178183
+ if (msg.includes("Error initializing part")) {
178184
+ return "Endpoint construction failure";
178185
+ }
178186
+ if (msg.includes("aggregator.")) {
178187
+ return "Runtime error in endpoint";
178188
+ }
178189
+ return null;
178190
+ }
178191
+ /**
178192
+ * Attempt to isolate an entity based on an error.
178193
+ * Returns true if the entity was successfully identified and isolation was triggered.
178194
+ */
178195
+ async isolateFromError(error) {
178196
+ const msg = error instanceof Error ? error.message : String(error);
178197
+ const classification = this.classifyError(msg);
178198
+ if (!classification) {
178199
+ return false;
178200
+ }
178201
+ const parsed = this.parseEndpointPath(msg);
178202
+ if (!parsed) {
178203
+ logger252.warn("Could not parse entity from error:", msg);
178204
+ return false;
178205
+ }
178206
+ const { bridgeId, entityName } = parsed;
178207
+ const callback = this.isolationCallbacks.get(bridgeId);
178208
+ if (!callback) {
178209
+ logger252.warn(
178210
+ `No isolation callback registered for bridge ${bridgeId}, entity: ${entityName}`
178211
+ );
178212
+ return false;
178213
+ }
178214
+ const key = `${bridgeId}:${entityName}`;
178215
+ if (this.isolatedEntities.has(key)) {
178216
+ return true;
178217
+ }
178218
+ const reason = `${classification}. Entity isolated to protect bridge stability.`;
178219
+ this.isolatedEntities.set(key, {
178220
+ entityId: entityName,
178221
+ reason,
178222
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
178223
+ });
178224
+ logger252.warn(
178225
+ `Isolating entity "${entityName}" from bridge ${bridgeId} due to: ${reason}`
178226
+ );
178227
+ diagnosticEventBus.emit("entity_error", `Entity isolated: ${entityName}`, {
178228
+ bridgeId,
178229
+ entityId: entityName,
178230
+ details: { reason: classification }
178231
+ });
178232
+ try {
178233
+ await callback(entityName);
178234
+ return true;
178235
+ } catch (e) {
178236
+ logger252.error(`Failed to isolate entity ${entityName}:`, e);
178237
+ return false;
178238
+ }
178239
+ }
178240
+ /**
178241
+ * Get all isolated entities for a specific bridge.
178242
+ */
178243
+ getIsolatedEntities(bridgeId) {
178244
+ const result = [];
178245
+ for (const [key, entity] of this.isolatedEntities) {
178246
+ if (key.startsWith(`${bridgeId}:`)) {
178247
+ result.push(entity);
178111
178248
  }
178112
- if (entityId.length > MAX_ENTITY_ID_LENGTH) {
178113
- const reason = `Entity ID too long (${entityId.length} chars, max ${MAX_ENTITY_ID_LENGTH}). This would cause filesystem errors.`;
178114
- this.log.warn(`Skipping entity: ${entityId}. Reason: ${reason}`);
178115
- this.addFailedEntity(entityId, reason);
178116
- continue;
178249
+ }
178250
+ return result;
178251
+ }
178252
+ /**
178253
+ * Clear isolated entities for a bridge (e.g., on restart).
178254
+ */
178255
+ clearIsolatedEntities(bridgeId) {
178256
+ for (const key of this.isolatedEntities.keys()) {
178257
+ if (key.startsWith(`${bridgeId}:`)) {
178258
+ this.isolatedEntities.delete(key);
178117
178259
  }
178118
- let endpoint = existingEndpoints.find((e) => e.entityId === entityId);
178119
- if (!endpoint) {
178260
+ }
178261
+ }
178262
+ };
178263
+ var EntityIsolationService = new EntityIsolationServiceImpl();
178264
+
178265
+ // src/services/bridges/bridge-endpoint-manager.ts
178266
+ var MAX_ENTITY_ID_LENGTH = 150;
178267
+ var ENDPOINT_REMOVAL_GRACE_MS = 3e5;
178268
+ function isEntityPart(p) {
178269
+ return typeof p.updateStates === "function";
178270
+ }
178271
+ function hasEntityIdentity(p) {
178272
+ return typeof p.entityId === "string";
178273
+ }
178274
+ var BridgeEndpointManager = class extends Service {
178275
+ constructor(client, registry3, mappingStorage, identityStorage, bridgeId, log, pluginManager, pluginRegistry, pluginInstaller) {
178276
+ super("BridgeEndpointManager");
178277
+ this.client = client;
178278
+ this.registry = registry3;
178279
+ this.mappingStorage = mappingStorage;
178280
+ this.identityStorage = identityStorage;
178281
+ this.bridgeId = bridgeId;
178282
+ this.log = log;
178283
+ this.pluginManager = pluginManager;
178284
+ this.pluginRegistry = pluginRegistry;
178285
+ this.pluginInstaller = pluginInstaller;
178286
+ this.root = new AggregatorEndpoint2("aggregator");
178287
+ this.identityResolver = new IdentityResolver(
178288
+ identityStorage,
178289
+ mappingStorage
178290
+ );
178291
+ EntityIsolationService.registerIsolationCallback(
178292
+ bridgeId,
178293
+ this.isolateEntity.bind(this)
178294
+ );
178295
+ if (this.pluginManager) {
178296
+ this.wirePluginCallbacks();
178297
+ }
178298
+ }
178299
+ client;
178300
+ registry;
178301
+ mappingStorage;
178302
+ identityStorage;
178303
+ bridgeId;
178304
+ log;
178305
+ pluginManager;
178306
+ pluginRegistry;
178307
+ pluginInstaller;
178308
+ root;
178309
+ entityIds = [];
178310
+ unsubscribe;
178311
+ observingRequested = false;
178312
+ _failedEntities = [];
178313
+ mappingFingerprints = /* @__PURE__ */ new Map();
178314
+ // entityId -> first absence stamp (grace window)
178315
+ pendingRemovals = /* @__PURE__ */ new Map();
178316
+ removalRecheckTimer = null;
178317
+ // Bumped on every stop, so a refresh that was already running cannot arm a
178318
+ // timer on a bridge that has since stopped (#438).
178319
+ lifecycle = 0;
178320
+ pluginEndpoints = /* @__PURE__ */ new Map();
178321
+ pluginStateUpdating = /* @__PURE__ */ new Set();
178322
+ pluginListeners = /* @__PURE__ */ new Map();
178323
+ get failedEntities() {
178324
+ const isolated = EntityIsolationService.getIsolatedEntities(this.bridgeId);
178325
+ return [...this._failedEntities, ...isolated];
178326
+ }
178327
+ addFailedEntity(entityId, reason) {
178328
+ this._failedEntities.push({
178329
+ entityId,
178330
+ reason,
178331
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
178332
+ });
178333
+ }
178334
+ identityResolver;
178335
+ wirePluginCallbacks() {
178336
+ if (!this.pluginManager) return;
178337
+ this.pluginManager.onDeviceRegistered = async (pluginName, device) => {
178338
+ let endpoint;
178339
+ if (device.endpointType) {
178120
178340
  try {
178121
- const domainMappings = this.getPluginDomainMappings();
178122
- const resolved = resolvedByEntity.get(entityId);
178123
- endpoint = await LegacyEndpoint.create(
178124
- this.registry,
178125
- entityId,
178126
- mapping,
178127
- domainMappings,
178128
- false,
178129
- resolved?.endpointId,
178130
- resolved?.anchorEntityId
178341
+ validateEndpointType(
178342
+ device.endpointType,
178343
+ `plugin:${pluginName}:${device.id}`
178131
178344
  );
178132
178345
  } catch (e) {
178133
- const reason = this.extractErrorReason(e);
178134
- this.log.warn(`Failed to create device ${entityId}: ${reason}`);
178135
- this.addFailedEntity(entityId, reason);
178136
- continue;
178346
+ this.log.warn(
178347
+ `Plugin "${pluginName}": invalid endpointType for device "${device.id}":`,
178348
+ e
178349
+ );
178350
+ return;
178137
178351
  }
178138
- if (endpoint) {
178139
- try {
178140
- await this.root.add(endpoint);
178141
- this.mappingFingerprints.set(
178142
- entityId,
178143
- this.computeMappingFingerprint(mapping)
178144
- );
178145
- } catch (e) {
178146
- const errorMessage = e instanceof Error ? e.message : String(e);
178352
+ const supplied = device.endpointType;
178353
+ const initialState = {};
178354
+ for (const cluster2 of device.clusters) {
178355
+ if (cluster2.clusterId === "pluginDevice" && supplied.behaviors?.pluginDevice == null && supplied.behaviors?.bridgedDeviceBasicInformation == null) {
178147
178356
  this.log.warn(
178148
- `Failed to add endpoint for ${entityId}: ${errorMessage}`
178357
+ `Plugin "${pluginName}": device "${device.id}" declares a "pluginDevice" cluster without owning that behavior, ignoring it`
178149
178358
  );
178150
- this.logDetailedError(entityId, e);
178151
- this.addFailedEntity(entityId, this.extractErrorReason(e));
178359
+ continue;
178152
178360
  }
178361
+ initialState[cluster2.clusterId] = cluster2.attributes;
178153
178362
  }
178154
- }
178155
- }
178156
- await this.reconcileAreaSwitches();
178157
- if (this.observingRequested) {
178158
- this.startObserving();
178159
- }
178160
- }
178161
- // Opt-in per-area room switches (#355). One momentary OnOffPlugInUnit sibling
178162
- // per configured service area, mounted alongside its vacuum with a stable
178163
- // derived id. Areas and mapping come from the parent's vacuumEffective, the
178164
- // exact config its ServiceArea cluster was built from, never from raw storage
178165
- // (raw can be a different id space: injected Valetudo/Roborock rooms,
178166
- // auto-resolved CLEAN_AREA). The vacuum's own endpoint is never touched here.
178167
- // Switches are kept while their parent endpoint survives with the flag on
178168
- // (the parent's removal grace is mirrored for free), rebuilt via close() when
178169
- // the parent was recreated for a mapping change so numbers survive, and
178170
- // deleted when the flag goes off, the area is gone, or the parent is gone.
178171
- async reconcileAreaSwitches() {
178172
- const parts = this.root.parts.map((p) => p);
178173
- const switches = parts.filter(
178174
- (p) => p instanceof VacuumAreaSwitchEndpoint
178175
- );
178176
- const vacuumById = /* @__PURE__ */ new Map();
178177
- for (const part of parts) {
178178
- if (part instanceof VacuumAreaSwitchEndpoint) continue;
178179
- vacuumById.set(part.id, part);
178180
- }
178181
- const isolated = new Set(
178182
- EntityIsolationService.getIsolatedEntities(this.bridgeId).map(
178183
- (f) => f.entityId
178184
- )
178185
- );
178186
- const parentIsolated = (parent) => isolated.has(parent.id) || parent.entityId != null && isolated.has(parent.entityId);
178187
- const kept = /* @__PURE__ */ new Set();
178188
- for (const sw of switches) {
178189
- const parent = vacuumById.get(sw.vacuumEndpointId);
178190
- const mapping = parent ? this.getEntityMapping(parent.entityId) : void 0;
178191
- const effective = parent instanceof LegacyEndpoint ? parent.vacuumEffective : void 0;
178192
- let keep = false;
178193
- let rebuild = false;
178194
- if (parent && !parentIsolated(parent) && mapping?.vacuumRoomSwitches && effective) {
178195
- const areas = getVacuumServiceAreas(
178196
- effective.state.attributes,
178197
- effective.mapping
178198
- );
178199
- if (areas.some((a) => a.areaId === sw.areaId)) {
178200
- if (sw.parentEffective === effective) {
178201
- keep = true;
178202
- } else {
178203
- rebuild = true;
178204
- }
178363
+ const hasOwnIdentity = supplied.behaviors?.bridgedDeviceBasicInformation != null;
178364
+ const ownsPluginDevice = supplied.behaviors?.pluginDevice != null;
178365
+ const mutable = typeof supplied.with === "function" && typeof supplied.set === "function";
178366
+ if (!mutable && Object.keys(initialState).length > 0) {
178367
+ this.log.warn(
178368
+ `Plugin "${pluginName}": endpointType for device "${device.id}" cannot take its cluster config, skipping it`
178369
+ );
178370
+ return;
178205
178371
  }
178206
- }
178207
- if (keep) {
178208
- kept.add(sw.id);
178209
- continue;
178210
- }
178211
- try {
178212
- if (rebuild) {
178213
- await sw.close();
178214
- } else {
178215
- await sw.delete();
178372
+ if (!hasOwnIdentity && (!mutable || ownsPluginDevice)) {
178373
+ this.log.warn(
178374
+ `Plugin "${pluginName}": device "${device.id}" mounts without BridgedDeviceBasicInformation, controllers may not show it`
178375
+ );
178216
178376
  }
178217
- } catch (e) {
178218
- this.log.warn(`Failed to remove area switch ${sw.id}:`, e);
178219
- }
178220
- }
178221
- for (const part of vacuumById.values()) {
178222
- const entityId = part.entityId;
178223
- if (!entityId?.startsWith("vacuum.")) continue;
178224
- if (parentIsolated(part)) continue;
178225
- const mapping = this.getEntityMapping(entityId);
178226
- if (!mapping?.vacuumRoomSwitches) continue;
178227
- const effective = part instanceof LegacyEndpoint ? part.vacuumEffective : void 0;
178228
- if (!effective) continue;
178229
- const areas = getVacuumServiceAreas(
178230
- effective.state.attributes,
178231
- effective.mapping
178232
- );
178233
- const entity = {
178234
- entity_id: entityId,
178235
- state: effective.state,
178236
- registry: this.registry.entity(entityId),
178237
- deviceRegistry: this.registry.deviceOf(entityId)
178238
- };
178239
- for (const area of areas) {
178240
- const switchId = `${part.id}_roomsw_${area.areaId}`;
178241
- if (kept.has(switchId)) continue;
178242
- const holder = vacuumById.get(switchId);
178243
- if (holder) {
178377
+ let base = device.endpointType;
178378
+ if (mutable && !hasOwnIdentity && !ownsPluginDevice) {
178379
+ base = base.with(PluginBasicInformationServer, PluginDeviceBehavior);
178380
+ initialState.pluginDevice = { device, pluginName };
178381
+ }
178382
+ endpoint = new Endpoint(
178383
+ Object.keys(initialState).length > 0 ? base.set(initialState) : base,
178384
+ { id: `plugin_${device.id}` }
178385
+ );
178386
+ } else {
178387
+ const type = createPluginEndpointType(device.deviceType ?? "");
178388
+ if (!type) {
178244
178389
  this.log.warn(
178245
- `Skipping area switch ${switchId} for ${entityId}: id taken by entity ${holder.entityId}`
178390
+ `Plugin "${pluginName}": unsupported device type "${device.deviceType}" for device "${device.id}"`
178246
178391
  );
178247
- continue;
178392
+ return;
178393
+ }
178394
+ const initialState = {
178395
+ pluginDevice: { device, pluginName }
178396
+ };
178397
+ for (const cluster2 of device.clusters) {
178398
+ initialState[cluster2.clusterId] = cluster2.attributes;
178399
+ }
178400
+ endpoint = new Endpoint(type.set(initialState), {
178401
+ id: `plugin_${device.id}`
178402
+ });
178403
+ }
178404
+ try {
178405
+ await this.root.add(endpoint);
178406
+ this.pluginEndpoints.set(device.id, endpoint);
178407
+ this.wirePluginEndpointEvents(device, endpoint);
178408
+ this.log.info(
178409
+ `Plugin "${pluginName}": added device "${device.name}" (${device.deviceType})`
178410
+ );
178411
+ } catch (e) {
178412
+ this.log.warn(
178413
+ `Plugin "${pluginName}": failed to add device "${device.id}":`,
178414
+ e
178415
+ );
178416
+ }
178417
+ };
178418
+ this.pluginManager.onDeviceUnregistered = async (pluginName, deviceId, options) => {
178419
+ const listeners = this.pluginListeners.get(deviceId);
178420
+ if (listeners) {
178421
+ for (const { observable, listener } of listeners) {
178422
+ try {
178423
+ observable.off(listener);
178424
+ } catch {
178425
+ }
178248
178426
  }
178427
+ this.pluginListeners.delete(deviceId);
178428
+ }
178429
+ const endpoint = this.pluginEndpoints.get(deviceId);
178430
+ if (endpoint) {
178249
178431
  try {
178250
- const endpoint = VacuumAreaSwitchEndpoint.create({
178251
- vacuumEndpointId: part.id,
178252
- entity,
178253
- mapping: effective.mapping,
178254
- area,
178255
- parentEffective: effective
178256
- });
178257
- await this.root.add(endpoint);
178432
+ if (options?.keepIdentity) {
178433
+ await endpoint.close();
178434
+ } else {
178435
+ await endpoint.delete();
178436
+ }
178258
178437
  } catch (e) {
178259
178438
  this.log.warn(
178260
- `Failed to add area switch ${switchId} for ${entityId}:`,
178439
+ `Plugin "${pluginName}": failed to remove device "${deviceId}":`,
178261
178440
  e
178262
178441
  );
178263
178442
  }
178443
+ this.pluginEndpoints.delete(deviceId);
178264
178444
  }
178265
- }
178266
- }
178267
- updateInFlight;
178268
- pendingStates;
178269
- pendingChanged;
178270
- mergeChanged(a, b) {
178271
- if (a === null || b === null) return null;
178272
- const merged = new Set(a);
178273
- for (const id of b) merged.add(id);
178274
- return merged;
178275
- }
178276
- async updateStates(states, changed = null) {
178277
- if (this.updateInFlight) {
178278
- this.pendingStates = states;
178279
- this.pendingChanged = this.pendingChanged === void 0 ? changed : this.mergeChanged(this.pendingChanged, changed);
178280
- return this.updateInFlight;
178281
- }
178282
- this.updateInFlight = this.runUpdateStates(states, changed).finally(() => {
178283
- this.updateInFlight = void 0;
178284
- const queued = this.pendingStates;
178285
- const queuedChanged = this.pendingChanged;
178286
- this.pendingStates = void 0;
178287
- this.pendingChanged = void 0;
178288
- if (queued) {
178289
- this.updateStates(
178290
- queued,
178291
- queuedChanged === void 0 ? null : queuedChanged
178292
- ).catch((e) => this.log.warn("Queued state update failed:", e));
178445
+ };
178446
+ this.pluginManager.onDeviceStateUpdated = (pluginName, deviceId, clusterId3, attributes9) => {
178447
+ const endpoint = this.pluginEndpoints.get(deviceId);
178448
+ if (!endpoint) return;
178449
+ const behaviorType = endpoint.type.behaviors[clusterId3];
178450
+ if (!behaviorType) {
178451
+ this.log.debug(
178452
+ `Plugin "${pluginName}": cluster "${clusterId3}" not found on device "${deviceId}"`
178453
+ );
178454
+ return;
178293
178455
  }
178294
- });
178295
- return this.updateInFlight;
178456
+ this.pluginStateUpdating.add(deviceId);
178457
+ endpoint.setStateOf(behaviorType, attributes9).catch((e) => {
178458
+ this.log.warn(
178459
+ `Plugin "${pluginName}": failed to update "${clusterId3}" on "${deviceId}":`,
178460
+ e
178461
+ );
178462
+ }).finally(() => {
178463
+ this.pluginStateUpdating.delete(deviceId);
178464
+ });
178465
+ };
178296
178466
  }
178297
- async runUpdateStates(states, changed) {
178298
- const startMs = performance.now();
178299
- this.registry.mergeExternalStates(states);
178300
- const allEndpoints = [...this.root.parts].filter(isEntityPart);
178301
- const endpoints = changed === null ? allEndpoints : allEndpoints.filter(
178302
- (e) => changed.has(e.entityId) || (e.mappedEntityIds ?? []).some((id) => changed.has(id))
178303
- );
178304
- if (endpoints.length === 0) return;
178305
- const results = await Promise.allSettled(
178306
- endpoints.map((endpoint) => endpoint.updateStates(states))
178307
- );
178308
- let failedCount = 0;
178309
- for (const result of results) {
178310
- if (result.status === "rejected") {
178311
- failedCount++;
178312
- this.log.warn("State update failed for endpoint:", result.reason);
178467
+ wirePluginEndpointEvents(device, endpoint) {
178468
+ if (!device.onAttributeWrite) return;
178469
+ const allEvents = endpoint.events;
178470
+ const listeners = [];
178471
+ for (const behaviorId of Object.keys(endpoint.type.behaviors)) {
178472
+ if (behaviorId === "pluginDevice") continue;
178473
+ const behaviorEvents = allEvents[behaviorId];
178474
+ if (!behaviorEvents || typeof behaviorEvents !== "object") continue;
178475
+ const eventNames = /* @__PURE__ */ new Set();
178476
+ for (let proto = behaviorEvents; proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
178477
+ for (const name of Object.getOwnPropertyNames(proto)) {
178478
+ if (name.endsWith("$Changed")) eventNames.add(name);
178479
+ }
178313
178480
  }
178314
- }
178315
- const latencyMs = Math.round((performance.now() - startMs) * 100) / 100;
178316
- if (latencyMs > 200 || failedCount > 0) {
178317
- const msg = `State update: ${endpoints.length} endpoints in ${latencyMs}ms` + (failedCount > 0 ? ` (${failedCount} failed)` : "");
178318
- if (latencyMs > 200) {
178319
- this.log.warn(`Slow ${msg}`);
178481
+ for (const eventName of eventNames) {
178482
+ const observable = behaviorEvents[eventName];
178483
+ if (!observable || typeof observable.on !== "function") continue;
178484
+ const attrName = eventName.slice(0, -"$Changed".length);
178485
+ const listener = (newValue) => {
178486
+ if (this.pluginStateUpdating.has(device.id)) return;
178487
+ device.onAttributeWrite?.(behaviorId, attrName, newValue).catch((e) => {
178488
+ this.log.debug(
178489
+ `Plugin device "${device.id}": onAttributeWrite error for ${behaviorId}.${attrName}:`,
178490
+ e
178491
+ );
178492
+ });
178493
+ };
178494
+ observable.on(listener);
178495
+ listeners.push({ observable, listener });
178320
178496
  }
178321
- diagnosticEventBus.emit("state_update", msg, {
178322
- bridgeId: this.bridgeId,
178323
- details: {
178324
- endpointCount: endpoints.length,
178325
- failedCount,
178326
- latencyMs
178327
- }
178328
- });
178497
+ }
178498
+ if (listeners.length > 0) {
178499
+ this.pluginListeners.set(device.id, listeners);
178329
178500
  }
178330
178501
  }
178331
- /**
178332
- * Log detailed behavior error information for debugging "Behaviors have errors".
178333
- * Matter.js EndpointBehaviorsError extends AggregateError, the `errors` array
178334
- * contains individual behavior crash errors (one per failed behavior).
178335
- */
178336
- logDetailedError(entityId, error) {
178337
- if (!(error instanceof Error)) return;
178338
- const errorsArray = error.errors;
178339
- if (Array.isArray(errorsArray) && errorsArray.length > 0) {
178340
- for (let i = 0; i < errorsArray.length; i++) {
178341
- const subError = errorsArray[i];
178342
- const subMsg = subError instanceof Error ? subError.message : String(subError);
178502
+ async startPlugins() {
178503
+ if (!this.pluginManager) return;
178504
+ await this.registerBuiltInPlugins();
178505
+ await this.loadRegisteredPlugins();
178506
+ await this.pluginManager.startAll();
178507
+ await this.pluginManager.configureAll();
178508
+ }
178509
+ // Built-in plugins ship inside the backend bundle, so they share the same
178510
+ // matter.js instance and can hand over live EndpointTypes.
178511
+ async registerBuiltInPlugins() {
178512
+ if (!this.pluginManager) return;
178513
+ for (const Plugin of BUILTIN_PLUGINS) {
178514
+ const plugin = new Plugin();
178515
+ try {
178516
+ await this.pluginManager.registerBuiltIn(plugin);
178517
+ } catch (e) {
178343
178518
  this.log.warn(
178344
- `[${entityId}] Behavior error [${i + 1}/${errorsArray.length}]: ${subMsg}`
178519
+ `Failed to register built-in plugin "${plugin.name}":`,
178520
+ e
178345
178521
  );
178346
- let cause = subError instanceof Error ? subError.cause : void 0;
178347
- while (cause instanceof Error) {
178348
- this.log.warn(`[${entityId}] Caused by: ${cause.message}`);
178349
- cause = cause.cause;
178350
- }
178351
- if (subError instanceof Error && subError.stack) {
178352
- this.log.debug(`[${entityId}] Sub-error stack: ${subError.stack}`);
178353
- }
178354
- }
178355
- } else {
178356
- let current = error.cause;
178357
- while (current instanceof Error) {
178358
- this.log.warn(`[${entityId}] Caused by: ${current.message}`);
178359
- current = current.cause;
178360
178522
  }
178361
178523
  }
178362
- if (error.stack) {
178363
- this.log.debug(`[${entityId}] Full stack: ${error.stack}`);
178364
- }
178365
178524
  }
178366
- extractErrorReason(error) {
178367
- if (error instanceof Error) {
178368
- const cause = error.cause;
178369
- if (cause?.message) {
178370
- return `${error.message}: ${cause.message}`;
178525
+ async loadRegisteredPlugins() {
178526
+ if (!this.pluginManager || !this.pluginRegistry || !this.pluginInstaller)
178527
+ return;
178528
+ const registered = this.pluginRegistry.getAll();
178529
+ for (const entry of registered) {
178530
+ if (!entry.autoLoad) continue;
178531
+ const packagePath = this.pluginInstaller.getPluginPath(entry.packageName);
178532
+ try {
178533
+ await this.pluginManager.loadExternal(packagePath, entry.config);
178534
+ this.log.info(
178535
+ `Loaded external plugin: ${entry.packageName} from ${packagePath}`
178536
+ );
178537
+ } catch (e) {
178538
+ this.log.warn(
178539
+ `Failed to load external plugin "${entry.packageName}":`,
178540
+ e
178541
+ );
178371
178542
  }
178372
- return error.message;
178373
178543
  }
178374
- return String(error);
178375
- }
178376
- };
178377
-
178378
- // src/services/bridges/bridge-registry.ts
178379
- init_dist();
178380
- init_esm();
178381
- init_send_ha_message();
178382
- import { callService as callService2 } from "home-assistant-js-websocket";
178383
- import { keys as keys2, pickBy, values as values3 } from "lodash-es";
178384
- var BridgeRegistry = class _BridgeRegistry {
178385
- constructor(registry3, dataProvider, client) {
178386
- this.registry = registry3;
178387
- this.dataProvider = dataProvider;
178388
- this.client = client;
178389
- this.refresh();
178390
- }
178391
- registry;
178392
- dataProvider;
178393
- client;
178394
- get entityIds() {
178395
- return keys2(this._entities);
178396
- }
178397
- _devices = {};
178398
- _entities = {};
178399
- _states = {};
178400
- // Track battery entities that have been auto-assigned to other devices
178401
- _usedBatteryEntities = /* @__PURE__ */ new Set();
178402
- // Cache for battery entity lookups (deviceId -> entityId or null)
178403
- _batteryEntityCache = /* @__PURE__ */ new Map();
178404
- // Cache for problem entity lookups (deviceId -> entityId or null) (#408)
178405
- _problemEntityCache = /* @__PURE__ */ new Map();
178406
- // Track humidity entities that have been auto-assigned to temperature sensors
178407
- _usedHumidityEntities = /* @__PURE__ */ new Set();
178408
- // Track pressure entities that have been auto-assigned to temperature sensors
178409
- _usedPressureEntities = /* @__PURE__ */ new Set();
178410
- // Track power entities that have been auto-assigned to switch/plug entities
178411
- _usedPowerEntities = /* @__PURE__ */ new Set();
178412
- // Track energy entities that have been auto-assigned to switch/plug entities
178413
- _usedEnergyEntities = /* @__PURE__ */ new Set();
178414
- // Track entities consumed by composed devices (e.g., sensors/climate grouped under air purifier)
178415
- _usedComposedSubEntities = /* @__PURE__ */ new Set();
178416
- deviceOf(entityId) {
178417
- const entity = this._entities[entityId];
178418
- return this._devices[entity.device_id];
178419
178544
  }
178420
- entity(entityId) {
178421
- return this._entities[entityId];
178422
- }
178423
- initialState(entityId) {
178424
- return this._states[entityId];
178425
- }
178426
- // The complete HA entity set (unfiltered). Used by orphan tombstone stamping
178427
- // so a filter change or a scope narrowing never looks like a removal.
178428
- get fullEntities() {
178429
- return this.registry.entities;
178430
- }
178431
- // Successful-reload counter, see HomeAssistantRegistry (#438).
178432
- get snapshotGeneration() {
178433
- return this.registry.snapshotGeneration;
178434
- }
178435
- // composed sub-entities may sit outside the bridge filter (#408), so these
178436
- // fall back to the full HA registry. keep them separate from the strict
178437
- // accessors above, every other caller must stay filtered.
178438
- initialStateIncludingUnfiltered(entityId) {
178439
- return this._states[entityId] ?? this.registry.states[entityId];
178440
- }
178441
- entityIncludingUnfiltered(entityId) {
178442
- return this._entities[entityId] ?? this.registry.entities[entityId];
178443
- }
178444
- deviceOfIncludingUnfiltered(entityId) {
178445
- const entity = this.entityIncludingUnfiltered(entityId);
178446
- if (!entity) return void 0;
178447
- return this._devices[entity.device_id] ?? this.registry.devices[entity.device_id];
178448
- }
178449
- /**
178450
- * Find a battery sensor entity that belongs to the same HA device.
178451
- * Returns the entity_id of the battery sensor, or undefined if none found.
178452
- */
178453
- findBatteryEntityForDevice(deviceId) {
178454
- if (this._batteryEntityCache.has(deviceId)) {
178455
- const cached = this._batteryEntityCache.get(deviceId);
178456
- return cached === null ? void 0 : cached;
178457
- }
178458
- const entities = values3(this.registry.entities);
178459
- const sameDevice = entities.filter((e) => e.device_id === deviceId);
178460
- for (const entity of sameDevice) {
178461
- if (!entity.entity_id.startsWith("sensor.")) continue;
178462
- const state = this.registry.states[entity.entity_id];
178463
- if (!state) {
178464
- continue;
178465
- }
178466
- const attrs = state.attributes;
178467
- if (attrs.device_class === SensorDeviceClass.battery && resolveBatteryPercent(state.state) != null) {
178468
- this._batteryEntityCache.set(deviceId, entity.entity_id);
178469
- return entity.entity_id;
178545
+ async stopPlugins() {
178546
+ if (!this.pluginManager) return;
178547
+ await this.pluginManager.shutdownAll("Bridge stopping");
178548
+ for (const [id, endpoint] of this.pluginEndpoints) {
178549
+ try {
178550
+ await endpoint.close();
178551
+ } catch (e) {
178552
+ this.log.warn(`Failed to close plugin endpoint ${id}:`, e);
178470
178553
  }
178471
178554
  }
178472
- for (const entity of sameDevice) {
178473
- if (!entity.entity_id.startsWith("binary_sensor.")) continue;
178474
- const state = this.registry.states[entity.entity_id];
178475
- if (!state) continue;
178476
- const attrs = state.attributes;
178477
- if (attrs.device_class === "battery" && resolveBatteryPercent(state.state) != null) {
178478
- this._batteryEntityCache.set(deviceId, entity.entity_id);
178479
- return entity.entity_id;
178480
- }
178555
+ this.pluginEndpoints.clear();
178556
+ }
178557
+ getPluginInfo() {
178558
+ if (!this.pluginManager) {
178559
+ return { metadata: [], devices: [], circuitBreakers: {} };
178481
178560
  }
178482
- for (const entity of sameDevice) {
178483
- if (!entity.entity_id.startsWith("sensor.")) continue;
178484
- const state = this.registry.states[entity.entity_id];
178485
- if (!state) continue;
178486
- const attrs = state.attributes;
178487
- const looksLikeBattery = attrs.unit_of_measurement === "%" || entity.entity_id.toLowerCase().includes("batt");
178488
- if ((attrs.device_class === "enum" || attrs.device_class == null && looksLikeBattery) && resolveBatteryPercent(state.state) != null) {
178489
- this._batteryEntityCache.set(deviceId, entity.entity_id);
178490
- return entity.entity_id;
178491
- }
178561
+ const cbStates = this.pluginManager.getCircuitBreakerStates();
178562
+ const circuitBreakers = {};
178563
+ for (const [name, state] of cbStates) {
178564
+ circuitBreakers[name] = state;
178492
178565
  }
178493
- this._batteryEntityCache.set(deviceId, null);
178494
- return void 0;
178495
- }
178496
- /**
178497
- * Mark a battery entity as used (auto-assigned to another device).
178498
- */
178499
- markBatteryEntityUsed(entityId) {
178500
- this._usedBatteryEntities.add(entityId);
178566
+ return {
178567
+ metadata: this.pluginManager.getMetadata(),
178568
+ devices: this.pluginManager.getAllDevices(),
178569
+ circuitBreakers
178570
+ };
178501
178571
  }
178502
- /**
178503
- * Check if a battery entity has been auto-assigned to another device.
178504
- */
178505
- isBatteryEntityUsed(entityId) {
178506
- return this._usedBatteryEntities.has(entityId);
178572
+ async enablePlugin(pluginName) {
178573
+ return await this.pluginManager?.enablePlugin(pluginName);
178507
178574
  }
178508
- /**
178509
- * Find a problem/safety binary sensor on the same HA device, so a smoke/CO
178510
- * alarm can drive hardwareFaultAlert from it. Prefers device_class=problem
178511
- * over safety. Returns the entity_id, or undefined if none found (#408).
178512
- */
178513
- findProblemEntityForDevice(deviceId) {
178514
- if (this._problemEntityCache.has(deviceId)) {
178515
- const cached = this._problemEntityCache.get(deviceId);
178516
- return cached === null ? void 0 : cached;
178517
- }
178518
- const entities = values3(this.registry.entities);
178519
- const sameDevice = entities.filter((e) => e.device_id === deviceId);
178520
- let safety;
178521
- for (const entity of sameDevice) {
178522
- if (!entity.entity_id.startsWith("binary_sensor.")) continue;
178523
- const state = this.registry.states[entity.entity_id];
178524
- if (!state) continue;
178525
- const attrs = state.attributes;
178526
- if (attrs.device_class === "problem") {
178527
- this._problemEntityCache.set(deviceId, entity.entity_id);
178528
- return entity.entity_id;
178529
- }
178530
- if (attrs.device_class === "safety" && !safety) {
178531
- safety = entity.entity_id;
178532
- }
178533
- }
178534
- this._problemEntityCache.set(deviceId, safety ?? null);
178535
- return safety;
178575
+ async disablePlugin(pluginName) {
178576
+ return await this.pluginManager?.disablePlugin(pluginName);
178536
178577
  }
178537
- /**
178538
- * Check if auto battery mapping is enabled for this bridge.
178539
- */
178540
- isAutoBatteryMappingEnabled() {
178541
- return this.dataProvider.featureFlags?.autoBatteryMapping === true || this.dataProvider.featureFlags?.autoComposedDevices === true;
178578
+ resetPlugin(pluginName) {
178579
+ this.pluginManager?.resetPlugin(pluginName);
178542
178580
  }
178543
- /**
178544
- * Check if auto composed devices mode is enabled.
178545
- * When enabled, temperature sensors with auto-mapped humidity/pressure/battery
178546
- * build real Matter Composed Devices (BridgedNodeEndpoint with sub-endpoints)
178547
- * rather than stacking extra clusters onto a flat TemperatureSensor.
178548
- * Apple Home, Google Home, and Alexa render each sub-endpoint using its
178549
- * own device type.
178550
- */
178551
- isAutoComposedDevicesEnabled() {
178552
- return this.dataProvider.featureFlags?.autoComposedDevices === true;
178581
+ getPluginConfigSchema(pluginName) {
178582
+ return this.pluginManager?.getConfigSchema(pluginName);
178553
178583
  }
178554
- /**
178555
- * Check if auto humidity mapping is enabled for this bridge.
178556
- * Default: true (enabled by default).
178557
- * When enabled, humidity sensors on the same device as a temperature sensor
178558
- * are combined into a single TemperatureHumiditySensor endpoint.
178559
- * Note: Apple Home does not display humidity on TemperatureSensorDevice
178560
- * endpoints, so users on Apple Home should explicitly disable this.
178561
- * See: https://github.com/RiDDiX/home-assistant-matter-hub/issues/133
178562
- */
178563
- isAutoHumidityMappingEnabled() {
178564
- return this.dataProvider.featureFlags?.autoHumidityMapping !== false || this.dataProvider.featureFlags?.autoComposedDevices === true;
178584
+ async updatePluginConfig(pluginName, config8) {
178585
+ return await this.pluginManager?.updateConfig(pluginName, config8) ?? false;
178565
178586
  }
178566
178587
  /**
178567
- * Find a humidity sensor entity that belongs to the same HA device.
178568
- * Returns the entity_id of the humidity sensor, or undefined if none found.
178588
+ * Isolate an entity by removing it from the aggregator.
178589
+ * Called by EntityIsolationService when a runtime error is detected.
178569
178590
  */
178570
- findHumidityEntityForDevice(deviceId) {
178571
- const entities = values3(this.registry.entities);
178572
- for (const entity of entities) {
178573
- if (entity.device_id !== deviceId) continue;
178574
- if (!entity.entity_id.startsWith("sensor.")) continue;
178575
- const state = this.registry.states[entity.entity_id];
178576
- if (!state) continue;
178577
- const attrs = state.attributes;
178578
- if (attrs.device_class === SensorDeviceClass.humidity) {
178579
- return entity.entity_id;
178591
+ async isolateEntity(entityName) {
178592
+ const endpoints = [...this.root.parts].filter(hasEntityIdentity);
178593
+ const endpoint = endpoints.find(
178594
+ (e) => !(e instanceof VacuumAreaSwitchEndpoint) && (e.id === entityName || e.entityId === entityName)
178595
+ ) ?? endpoints.find((e) => e.id === entityName);
178596
+ if (endpoint) {
178597
+ this.log.warn(
178598
+ `Isolating entity ${endpoint.entityId} due to runtime error`
178599
+ );
178600
+ try {
178601
+ await endpoint.close();
178602
+ } catch (e) {
178603
+ this.log.error(`Failed to close isolated endpoint:`, e);
178604
+ }
178605
+ this.pendingRemovals.delete(endpoint.entityId);
178606
+ this.mappingFingerprints.delete(endpoint.entityId);
178607
+ if (!(endpoint instanceof VacuumAreaSwitchEndpoint)) {
178608
+ for (const sw of endpoints) {
178609
+ if (!(sw instanceof VacuumAreaSwitchEndpoint)) continue;
178610
+ if (sw.vacuumEndpointId !== endpoint.id) continue;
178611
+ try {
178612
+ await sw.close();
178613
+ } catch (e) {
178614
+ this.log.warn(`Failed to remove area switch ${sw.id}:`, e);
178615
+ }
178616
+ }
178580
178617
  }
178581
178618
  }
178582
- return void 0;
178583
178619
  }
178584
- /**
178585
- * Find a temperature sensor entity that belongs to the same HA device.
178586
- * Returns the entity_id of the temperature sensor, or undefined if none found.
178587
- */
178588
- findTemperatureEntityForDevice(deviceId) {
178589
- const entities = values3(this.registry.entities);
178590
- for (const entity of entities) {
178591
- if (entity.device_id !== deviceId) continue;
178592
- if (!entity.entity_id.startsWith("sensor.")) continue;
178593
- const state = this.registry.states[entity.entity_id];
178594
- if (!state) continue;
178595
- const attrs = state.attributes;
178596
- if (attrs.device_class === SensorDeviceClass.temperature) {
178597
- return entity.entity_id;
178598
- }
178620
+ // refreshDevices only runs on registry-fingerprint changes, which may not
178621
+ // recur, so drive any held removals to completion ourselves once the grace
178622
+ // window has passed.
178623
+ scheduleRemovalRecheck() {
178624
+ if (this.removalRecheckTimer) {
178625
+ clearTimeout(this.removalRecheckTimer);
178626
+ this.removalRecheckTimer = null;
178627
+ }
178628
+ if (this.pendingRemovals.size === 0) return;
178629
+ const lifecycle = this.lifecycle;
178630
+ this.removalRecheckTimer = setTimeout(() => {
178631
+ this.removalRecheckTimer = null;
178632
+ this.refreshDevices().catch((e) => {
178633
+ this.log.warn("Endpoint removal recheck failed:", e);
178634
+ if (lifecycle === this.lifecycle) this.scheduleRemovalRecheck();
178635
+ });
178636
+ }, ENDPOINT_REMOVAL_GRACE_MS + 5e3);
178637
+ }
178638
+ getPluginDomainMappings() {
178639
+ if (!this.pluginManager) return void 0;
178640
+ const mappings = this.pluginManager.getDomainMappings();
178641
+ if (mappings.size === 0) return void 0;
178642
+ const result = /* @__PURE__ */ new Map();
178643
+ for (const [domain, mapping] of mappings) {
178644
+ result.set(domain, mapping.matterDeviceType);
178599
178645
  }
178600
- return void 0;
178646
+ return result;
178601
178647
  }
178602
- /**
178603
- * Find a climate entity that belongs to the same HA device.
178604
- * Returns the entity_id of the climate entity, or undefined if none found.
178605
- */
178606
- findClimateEntityForDevice(deviceId) {
178607
- const entities = values3(this.registry.entities);
178608
- for (const entity of entities) {
178609
- if (entity.device_id !== deviceId) continue;
178610
- if (!entity.entity_id.startsWith("climate.")) continue;
178611
- const state = this.registry.states[entity.entity_id];
178612
- if (state) return entity.entity_id;
178648
+ getEntityMapping(entityId) {
178649
+ return this.mappingStorage.getMapping(this.bridgeId, entityId);
178650
+ }
178651
+ // #450: an endpoint built while its battery sensor was unavailable stays
178652
+ // battery-less, because registry ticks only refresh on structural changes.
178653
+ // When a same-device sensor state arrives, re-resolve and rebuild.
178654
+ batteryRetryScheduled = false;
178655
+ batteryRetryTimer = null;
178656
+ // deviceId -> primary entityId of endpoints that auto-map but carry no
178657
+ // battery, bounds the per-state-batch check to a map hit
178658
+ batteryRetryCandidates = /* @__PURE__ */ new Map();
178659
+ // Only endpoints the auto-mapping applies to belong here: a manual or
178660
+ // disabled mapping, or a sensor endpoint sharing the device, must not
178661
+ // claim the slot (last writer would win) and stall the recovery.
178662
+ batteryRetryEligible(entityId) {
178663
+ const mapping = this.getEntityMapping(entityId);
178664
+ if (mapping?.batteryEntity || mapping?.disableBatteryMapping) return false;
178665
+ if (entityId.startsWith("sensor.") || entityId.startsWith("binary_sensor.")) {
178666
+ return false;
178613
178667
  }
178614
- return void 0;
178668
+ return entityId.startsWith("vacuum.") || !!this.registry.isAutoBatteryMappingEnabled?.();
178615
178669
  }
178616
- /**
178617
- * Mark an entity as consumed by a composed device.
178618
- */
178619
- markComposedSubEntityUsed(entityId) {
178620
- this._usedComposedSubEntities.add(entityId);
178670
+ rebuildBatteryRetryCandidates() {
178671
+ this.batteryRetryCandidates.clear();
178672
+ for (const part of this.root.parts) {
178673
+ if (!hasEntityIdentity(part)) continue;
178674
+ const fingerprint = this.mappingFingerprints.get(part.entityId);
178675
+ if (fingerprint === void 0) continue;
178676
+ if (fingerprintBattery(fingerprint) != null) continue;
178677
+ if (!this.batteryRetryEligible(part.entityId)) continue;
178678
+ const deviceId = this.registry.entity(part.entityId)?.device_id;
178679
+ if (deviceId) this.batteryRetryCandidates.set(deviceId, part.entityId);
178680
+ }
178621
178681
  }
178622
- /**
178623
- * Check if an entity has been consumed by a composed device.
178624
- */
178625
- isComposedSubEntityUsed(entityId) {
178626
- return this._usedComposedSubEntities.has(entityId);
178682
+ maybeRetryBatteryMapping(states, changed) {
178683
+ if (!this.observingRequested || this.batteryRetryScheduled || this.batteryRetryCandidates.size === 0) {
178684
+ return;
178685
+ }
178686
+ for (const id of changed ?? Object.keys(states)) {
178687
+ if (!id.startsWith("sensor.") && !id.startsWith("binary_sensor."))
178688
+ continue;
178689
+ const deviceId = this.registry.fullEntities[id]?.device_id;
178690
+ if (!deviceId) continue;
178691
+ const entityId = this.batteryRetryCandidates.get(deviceId);
178692
+ if (!entityId) continue;
178693
+ this.registry.forgetBatteryCacheForDevice(deviceId);
178694
+ const resolved = this.registry.batteryFingerprintFor(
178695
+ entityId,
178696
+ this.getEntityMapping(entityId)
178697
+ );
178698
+ if (!resolved) continue;
178699
+ this.batteryRetryScheduled = true;
178700
+ this.log.info(
178701
+ `Battery sensor ${resolved} appeared for ${entityId}, rebuilding`
178702
+ );
178703
+ this.batteryRetryTimer = setTimeout(() => {
178704
+ this.batteryRetryTimer = null;
178705
+ this.refreshDevices().catch((e) => this.log.warn("Battery retry refresh failed:", e)).finally(() => {
178706
+ this.batteryRetryScheduled = false;
178707
+ });
178708
+ }, 0);
178709
+ return;
178710
+ }
178627
178711
  }
178628
- /**
178629
- * Mark a humidity entity as used (auto-assigned to a temperature sensor).
178630
- */
178631
- markHumidityEntityUsed(entityId) {
178632
- this._usedHumidityEntities.add(entityId);
178712
+ computeMappingFingerprint(mapping, entityId) {
178713
+ const battery = entityId ? this.registry.batteryFingerprintFor(entityId, mapping) : "";
178714
+ return JSON.stringify([mapping ?? null, battery || null]);
178715
+ }
178716
+ // Live fingerprint for reconcile compares: when the resolver finds nothing
178717
+ // right now but the stored fingerprint maps a sensor that still exists on
178718
+ // the SAME device, keep it. An unavailable snapshot (HA restart) must not
178719
+ // strip the mapping and rebuild the endpoint battery-less (#450).
178720
+ compareFingerprint(mapping, entityId, storedFingerprint) {
178721
+ const fingerprint = this.computeMappingFingerprint(mapping, entityId);
178722
+ if (fingerprintBattery(fingerprint) != null || !storedFingerprint)
178723
+ return fingerprint;
178724
+ if (!this.batteryRetryEligible(entityId)) return fingerprint;
178725
+ const battery = fingerprintBattery(storedFingerprint);
178726
+ if (!battery) return fingerprint;
178727
+ const deviceId = this.registry.entity(entityId)?.device_id;
178728
+ const stillSameDevice = !!deviceId && this.registry.fullEntities[battery]?.device_id === deviceId;
178729
+ return stillSameDevice ? JSON.stringify([mapping ?? null, battery]) : fingerprint;
178730
+ }
178731
+ // The stored fingerprint must reflect what this endpoint actually maps: a
178732
+ // battery resolved while the endpoint was built without one (sensor outage
178733
+ // during a forced rebuild) would otherwise never trigger the catch-up (#450).
178734
+ fingerprintAsBuilt(mapping, entityId, endpoint) {
178735
+ const fingerprint = this.computeMappingFingerprint(mapping, entityId);
178736
+ const battery = fingerprintBattery(fingerprint);
178737
+ if (battery == null) return fingerprint;
178738
+ return (endpoint.mappedEntityIds ?? []).includes(battery) ? fingerprint : JSON.stringify([mapping ?? null, null]);
178633
178739
  }
178634
- /**
178635
- * Check if a humidity entity has been auto-assigned to a temperature sensor.
178636
- */
178637
- isHumidityEntityUsed(entityId) {
178638
- return this._usedHumidityEntities.has(entityId);
178740
+ async dispose() {
178741
+ this.stopObserving();
178742
+ if (this.removalRecheckTimer) {
178743
+ clearTimeout(this.removalRecheckTimer);
178744
+ this.removalRecheckTimer = null;
178745
+ }
178746
+ this.pendingRemovals.clear();
178747
+ EntityIsolationService.unregisterIsolationCallback(this.bridgeId);
178748
+ EntityIsolationService.clearIsolatedEntities(this.bridgeId);
178749
+ const endpoints = this.root.parts.map((p) => p);
178750
+ for (const endpoint of endpoints) {
178751
+ try {
178752
+ await endpoint.close();
178753
+ } catch (e) {
178754
+ this.log.warn(`Failed to close endpoint during dispose:`, e);
178755
+ }
178756
+ }
178639
178757
  }
178640
- /**
178641
- * Check if auto pressure mapping is enabled for this bridge.
178642
- * Default: true (enabled by default).
178643
- * When enabled, pressure sensors on the same device as a temperature sensor
178644
- * are combined into a single endpoint with PressureMeasurement cluster.
178645
- */
178646
- isAutoPressureMappingEnabled() {
178647
- return this.dataProvider.featureFlags?.autoPressureMapping !== false || this.dataProvider.featureFlags?.autoComposedDevices === true;
178758
+ async startObserving() {
178759
+ this.clearSubscription();
178760
+ this.observingRequested = true;
178761
+ if (!this.entityIds.length) {
178762
+ return;
178763
+ }
178764
+ const subscriptionIds = this.collectSubscriptionEntityIds();
178765
+ this.unsubscribe = subscribeEntities(
178766
+ this.client.connection,
178767
+ (e, changed) => this.updateStates(e, changed),
178768
+ subscriptionIds
178769
+ );
178648
178770
  }
178649
- /**
178650
- * Check if the vacuum OnOff cluster feature flag is enabled.
178651
- * Defaults to OFF. OnOff is NOT part of the RoboticVacuumCleaner (0x74) device
178652
- * type spec. Adding it makes the device non-conformant and causes Amazon Alexa
178653
- * to reject it entirely (#185, #183). Only enable if a specific controller needs it.
178654
- */
178655
- isVacuumOnOffEnabled() {
178656
- return this.dataProvider.featureFlags?.vacuumOnOff === true;
178771
+ collectSubscriptionEntityIds() {
178772
+ const ids = new Set(this.entityIds);
178773
+ const endpoints = this.root.parts.map((p) => p);
178774
+ for (const endpoint of endpoints) {
178775
+ const mappedIds = endpoint.mappedEntityIds;
178776
+ if (mappedIds) {
178777
+ for (const mappedId of mappedIds) {
178778
+ ids.add(mappedId);
178779
+ }
178780
+ }
178781
+ }
178782
+ if (this.batteryRetryCandidates.size > 0) {
178783
+ for (const entity of Object.values(this.registry.fullEntities)) {
178784
+ if (!entity.device_id) continue;
178785
+ if (!this.batteryRetryCandidates.has(entity.device_id)) continue;
178786
+ if (entity.entity_id.startsWith("sensor.") || entity.entity_id.startsWith("binary_sensor.")) {
178787
+ ids.add(entity.entity_id);
178788
+ }
178789
+ }
178790
+ }
178791
+ return [...ids];
178657
178792
  }
178658
- // Consume frozen device identities (#404). Seeding always runs; only
178659
- // consumption of the stored endpoint id/anchor is gated on this flag.
178660
- isStableIdentityEnabled() {
178661
- return this.dataProvider.featureFlags?.stableIdentity === true;
178793
+ clearSubscription() {
178794
+ this.unsubscribe?.();
178795
+ this.unsubscribe = void 0;
178662
178796
  }
178663
- /**
178664
- * Check if the vacuum OnOff cluster should be included for server-mode vacuums.
178665
- * Defaults to OFF. OnOff is NOT part of the RoboticVacuumCleaner (0x74) device
178666
- * type spec. Adding it makes the device non-conformant and causes Amazon Alexa
178667
- * to reject it entirely (#185, #183). Apple Home may also render the vacuum
178668
- * incorrectly (shows "Updating" or switch UI). Only enable via feature flag
178669
- * if a specific controller requires it.
178670
- */
178671
- isServerModeVacuumOnOffEnabled() {
178672
- return this.dataProvider.featureFlags?.vacuumOnOff === true;
178797
+ stopObserving() {
178798
+ this.observingRequested = false;
178799
+ this.lifecycle++;
178800
+ this.clearSubscription();
178801
+ if (this.removalRecheckTimer) {
178802
+ clearTimeout(this.removalRecheckTimer);
178803
+ this.removalRecheckTimer = null;
178804
+ }
178805
+ if (this.batteryRetryTimer) {
178806
+ clearTimeout(this.batteryRetryTimer);
178807
+ this.batteryRetryTimer = null;
178808
+ }
178809
+ this.batteryRetryScheduled = false;
178673
178810
  }
178674
- /**
178675
- * Auto-detect vacuum-related select entities on the same HA device.
178676
- * HA integrations (Dreame, Roborock, Ecovacs, Valetudo, etc.) expose vacuum
178677
- * features as select entities with well-known suffixes. This finds them
178678
- * automatically so users don't need to configure each entity manually.
178679
- */
178680
- findVacuumSelectEntities(deviceId) {
178681
- const entities = values3(this.registry.entities);
178682
- const sameDevice = entities.filter(
178683
- (e) => e.device_id === deviceId && e.entity_id.startsWith("select.")
178684
- );
178685
- let cleaningModeEntity;
178686
- let suctionLevelEntity;
178687
- let mopIntensityEntity;
178688
- for (const entity of sameDevice) {
178689
- const state = this.registry.states[entity.entity_id];
178690
- if (!state) continue;
178691
- const id = entity.entity_id.toLowerCase();
178692
- if (!cleaningModeEntity) {
178693
- if (id.includes("cleaning_mode")) {
178694
- cleaningModeEntity = entity.entity_id;
178695
- } else if (id.endsWith("_mode")) {
178696
- const options = state.attributes?.options;
178697
- if (options?.some(
178698
- (o) => /^(vacuum|mop|sweep|sweep_mop|sweep_before_mopping|sweep_then_mop|vacuum_and_mop|vacuum_then_mop|mopping|sweeping|sweeping_and_mopping|mopping_after_sweeping)$/i.test(
178699
- o.replace(/\s+/g, "_")
178700
- )
178701
- )) {
178702
- cleaningModeEntity = entity.entity_id;
178811
+ async refreshDevices() {
178812
+ this.registry.refresh();
178813
+ const lifecycle = this.lifecycle;
178814
+ const endpoints = [...this.root.parts].filter(hasEntityIdentity).filter((p) => !(p instanceof VacuumAreaSwitchEndpoint));
178815
+ const fullEntities = this.registry.fullEntities;
178816
+ if (!this.client.haRunning || Object.keys(fullEntities).length === 0) {
178817
+ this.pendingRemovals.clear();
178818
+ this.log.warn(
178819
+ `HA not running or registry empty, deferring reconcile of ${endpoints.length} endpoints`
178820
+ );
178821
+ return;
178822
+ }
178823
+ this._failedEntities = [];
178824
+ this.entityIds = this.registry.entityIds;
178825
+ if (this.registry.isAutoComposedDevicesEnabled()) {
178826
+ for (const eid of this.entityIds) {
178827
+ const m = this.getEntityMapping(eid);
178828
+ if (m?.composedEntities) {
178829
+ for (const sub of m.composedEntities) {
178830
+ if (sub.entityId) {
178831
+ this.registry.markComposedSubEntityUsed(sub.entityId);
178832
+ }
178703
178833
  }
178704
178834
  }
178835
+ if (!eid.startsWith("fan.")) continue;
178836
+ const matterType = m?.matterDeviceType ?? "fan";
178837
+ if (matterType !== "air_purifier") continue;
178838
+ const ent = this.registry.entity(eid);
178839
+ const tempId = m?.temperatureEntity || (ent?.device_id ? this.registry.findTemperatureEntityForDevice(ent.device_id) : void 0);
178840
+ const humId = m?.humidityEntity || (ent?.device_id ? this.registry.findHumidityEntityForDevice(ent.device_id) : void 0);
178841
+ if (tempId) this.registry.markComposedSubEntityUsed(tempId);
178842
+ if (humId) this.registry.markComposedSubEntityUsed(humId);
178705
178843
  }
178706
- if (!suctionLevelEntity && (id.includes("suction_level") || id.endsWith("_fan"))) {
178707
- suctionLevelEntity = entity.entity_id;
178708
- }
178709
- if (!mopIntensityEntity && (id.includes("mop_intensity") || id.includes("mop_pad_humidity") || id.includes("water_volume") || id.includes("water_amount") || id.endsWith("_water"))) {
178710
- mopIntensityEntity = entity.entity_id;
178844
+ }
178845
+ const stableIdentity = this.registry.isStableIdentityEnabled();
178846
+ const resolvedByEntity = /* @__PURE__ */ new Map();
178847
+ const endpointIdToEntity = /* @__PURE__ */ new Map();
178848
+ const claimedEndpointIds = /* @__PURE__ */ new Map();
178849
+ for (const entityId of this.entityIds) {
178850
+ const entityInfo = {
178851
+ entity_id: entityId,
178852
+ registry: this.registry.entity(entityId)
178853
+ };
178854
+ const resolved = await this.identityResolver.resolveIdentity(
178855
+ this.bridgeId,
178856
+ entityInfo,
178857
+ this.getEntityMapping(entityId),
178858
+ {
178859
+ stableIdentity,
178860
+ isEndpointIdTaken: (id, key) => claimedEndpointIds.has(id) && claimedEndpointIds.get(id) !== key
178861
+ }
178862
+ );
178863
+ resolvedByEntity.set(entityId, resolved);
178864
+ claimedEndpointIds.set(
178865
+ resolved.endpointId,
178866
+ identityKey(entityInfo) ?? `\0e:${entityId}`
178867
+ );
178868
+ endpointIdToEntity.set(resolved.endpointId, entityId);
178869
+ if (resolved.renamedFrom && this.mappingFingerprints.has(resolved.renamedFrom)) {
178870
+ const fp = this.mappingFingerprints.get(resolved.renamedFrom);
178871
+ this.mappingFingerprints.delete(resolved.renamedFrom);
178872
+ this.mappingFingerprints.set(entityId, fp);
178711
178873
  }
178712
178874
  }
178713
- let currentRoomEntity;
178714
- const sameDeviceSensors = entities.filter(
178715
- (e) => e.device_id === deviceId && e.entity_id.startsWith("sensor.")
178875
+ stampIdentityPresence(
178876
+ this.identityStorage,
178877
+ this.bridgeId,
178878
+ buildPresentIdentityKeys(fullEntities)
178716
178879
  );
178717
- for (const entity of sameDeviceSensors) {
178718
- if (entity.entity_id.toLowerCase().endsWith("_current_room")) {
178719
- currentRoomEntity = entity.entity_id;
178720
- break;
178880
+ stampMappingPresence(
178881
+ this.mappingStorage,
178882
+ this.bridgeId,
178883
+ buildPresentEntityIds(fullEntities)
178884
+ );
178885
+ for (const part of [...this.root.parts]) {
178886
+ if (!(part instanceof VacuumAreaSwitchEndpoint)) continue;
178887
+ const claimant = endpointIdToEntity.get(part.id);
178888
+ if (claimant == null) continue;
178889
+ this.log.info(
178890
+ `Area switch ${part.id} collides with entity ${claimant}, removing the switch`
178891
+ );
178892
+ try {
178893
+ await part.delete();
178894
+ } catch (e) {
178895
+ this.log.warn(`Failed to remove colliding area switch ${part.id}:`, e);
178721
178896
  }
178722
178897
  }
178723
- return {
178724
- cleaningModeEntity,
178725
- suctionLevelEntity,
178726
- mopIntensityEntity,
178727
- currentRoomEntity
178728
- };
178729
- }
178730
- static valetudoLogger = Logger.get("ValetudoRooms");
178731
- /**
178732
- * Find Valetudo map segments from the sensor.*_map_segments entity on the
178733
- * same HA device. Valetudo exposes room/segment data via MQTT as a sensor
178734
- * with numeric segment IDs in its attributes.
178735
- *
178736
- * Attribute format:
178737
- * - Unnamed segments: { "1": 1, "2": 2, "4": 4 }
178738
- * - Named segments: { "1": "Kitchen", "2": "Living Room" }
178739
- */
178740
- findValetudoMapSegments(deviceId) {
178741
- const entities = values3(this.registry.entities);
178742
- const mapSensor = entities.find(
178743
- (e) => e.device_id === deviceId && e.entity_id.startsWith("sensor.") && e.entity_id.endsWith("_map_segments")
178744
- );
178745
- if (!mapSensor) return [];
178746
- const state = this.registry.states[mapSensor.entity_id];
178747
- if (!state) return [];
178748
- const attrs = state.attributes;
178749
- const rooms = [];
178750
- for (const [key, value] of Object.entries(attrs)) {
178751
- if (!/^\d+$/.test(key)) continue;
178752
- const segmentId = Number.parseInt(key, 10);
178753
- const name = typeof value === "string" ? value : `Segment ${key}`;
178754
- rooms.push({ id: segmentId, name });
178898
+ const existingEndpoints = [];
178899
+ const now = Date.now();
178900
+ for (const endpoint of endpoints) {
178901
+ const present = this.entityIds.includes(endpoint.entityId);
178902
+ const claimant = endpointIdToEntity.get(endpoint.id);
178903
+ if (!present && claimant != null && claimant !== endpoint.entityId) {
178904
+ this.log.info(
178905
+ `Entity renamed ${endpoint.entityId} -> ${claimant}, keeping endpoint ${endpoint.id}`
178906
+ );
178907
+ try {
178908
+ await endpoint.close();
178909
+ } catch (e) {
178910
+ this.log.warn(
178911
+ `Failed to close renamed endpoint ${endpoint.entityId}:`,
178912
+ e
178913
+ );
178914
+ }
178915
+ this.pendingRemovals.delete(endpoint.entityId);
178916
+ this.mappingFingerprints.delete(endpoint.entityId);
178917
+ continue;
178918
+ }
178919
+ if (present) {
178920
+ this.pendingRemovals.delete(endpoint.entityId);
178921
+ }
178922
+ if (!present) {
178923
+ const entry = this.pendingRemovals.get(endpoint.entityId);
178924
+ if (entry == null) {
178925
+ this.pendingRemovals.set(endpoint.entityId, {
178926
+ since: now,
178927
+ generation: this.registry.snapshotGeneration
178928
+ });
178929
+ existingEndpoints.push(endpoint);
178930
+ continue;
178931
+ }
178932
+ if (now - entry.since < ENDPOINT_REMOVAL_GRACE_MS || now - this.client.runningSince < ENDPOINT_REMOVAL_GRACE_MS || this.registry.snapshotGeneration <= entry.generation) {
178933
+ existingEndpoints.push(endpoint);
178934
+ continue;
178935
+ }
178936
+ try {
178937
+ this.log.info(
178938
+ `Removing endpoint ${endpoint.entityId} (ep ${endpoint.number}) after the grace window, controllers will see a new number if it returns`
178939
+ );
178940
+ await endpoint.delete();
178941
+ } catch (e) {
178942
+ this.log.warn(`Failed to delete endpoint ${endpoint.entityId}:`, e);
178943
+ }
178944
+ this.mappingFingerprints.delete(endpoint.entityId);
178945
+ this.pendingRemovals.delete(endpoint.entityId);
178946
+ } else if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(endpoint.entityId)) {
178947
+ this.log.info(
178948
+ `Removing standalone endpoint ${endpoint.entityId}, consumed by composed device`
178949
+ );
178950
+ try {
178951
+ await endpoint.close();
178952
+ } catch (e) {
178953
+ this.log.warn(
178954
+ `Failed to remove composed sub-entity endpoint ${endpoint.entityId}:`,
178955
+ e
178956
+ );
178957
+ }
178958
+ this.mappingFingerprints.delete(endpoint.entityId);
178959
+ } else {
178960
+ const currentMapping = this.getEntityMapping(endpoint.entityId);
178961
+ const storedFp = this.mappingFingerprints.get(endpoint.entityId) ?? "";
178962
+ const currentFp = this.compareFingerprint(
178963
+ currentMapping,
178964
+ endpoint.entityId,
178965
+ storedFp
178966
+ );
178967
+ if (currentFp !== storedFp) {
178968
+ this.log.info(
178969
+ `Mapping changed for ${endpoint.entityId}, recreating endpoint`
178970
+ );
178971
+ const resolvedId = resolvedByEntity.get(endpoint.entityId)?.endpointId ?? createEndpointId(endpoint.entityId, currentMapping?.customName);
178972
+ const sameId = resolvedId === endpoint.id;
178973
+ try {
178974
+ if (sameId) {
178975
+ await endpoint.close();
178976
+ } else {
178977
+ await endpoint.delete();
178978
+ }
178979
+ } catch (e) {
178980
+ this.log.warn(
178981
+ `Failed to recreate endpoint ${endpoint.entityId} for mapping change:`,
178982
+ e
178983
+ );
178984
+ }
178985
+ this.mappingFingerprints.delete(endpoint.entityId);
178986
+ } else {
178987
+ existingEndpoints.push(endpoint);
178988
+ }
178989
+ }
178755
178990
  }
178756
- if (rooms.length > 0) {
178757
- _BridgeRegistry.valetudoLogger.info(
178758
- `Found ${rooms.length} Valetudo segments via ${mapSensor.entity_id}`
178759
- );
178991
+ if (lifecycle === this.lifecycle) {
178992
+ this.scheduleRemovalRecheck();
178760
178993
  }
178761
- return rooms;
178762
- }
178763
- static roborockLogger = Logger.get("RoborockRooms");
178764
- /**
178765
- * Resolve rooms for a Roborock vacuum by calling roborock.get_maps.
178766
- * Returns parsed VacuumRoom[] with segment IDs, or empty array if
178767
- * the service is unavailable or the vacuum is not Roborock.
178768
- */
178769
- async resolveRoborockRooms(entityId) {
178770
- if (!this.client) return [];
178771
- try {
178772
- const raw = await callService2(
178773
- this.client.connection,
178774
- "roborock",
178775
- "get_maps",
178776
- void 0,
178777
- { entity_id: entityId },
178778
- true
178779
- );
178780
- const wrapper = raw;
178781
- const responseData = wrapper?.response ?? wrapper;
178782
- const entityData = responseData?.[entityId];
178783
- if (!entityData?.maps) {
178784
- _BridgeRegistry.roborockLogger.debug(
178785
- `${entityId}: roborock.get_maps returned no maps (keys: ${Object.keys(responseData ?? {}).join(", ")})`
178994
+ let memoryLimitReached = false;
178995
+ for (const entityId of this.entityIds) {
178996
+ if (!memoryLimitReached && isHeapUnderPressure()) {
178997
+ memoryLimitReached = true;
178998
+ this.log.error(
178999
+ "Memory pressure detected, skipping remaining entities to prevent OOM crash. Reduce the number of entities in this bridge or increase the Node.js heap size (NODE_OPTIONS=--max-old-space-size=1024)."
178786
179000
  );
178787
- return [];
178788
179001
  }
178789
- const rooms = [];
178790
- for (const map of entityData.maps) {
178791
- if (!map.rooms) continue;
178792
- for (const [segmentId, roomName] of Object.entries(map.rooms)) {
178793
- const id = /^\d+$/.test(segmentId) ? Number.parseInt(segmentId, 10) : segmentId;
178794
- rooms.push({ id, name: roomName });
179002
+ if (memoryLimitReached) {
179003
+ if (!existingEndpoints.some((e) => e.entityId === entityId)) {
179004
+ this.addFailedEntity(
179005
+ entityId,
179006
+ "Skipped due to memory pressure, reduce entities or increase heap size"
179007
+ );
178795
179008
  }
179009
+ continue;
178796
179010
  }
178797
- if (rooms.length > 0) {
178798
- _BridgeRegistry.roborockLogger.info(
178799
- `${entityId}: Resolved ${rooms.length} rooms via roborock.get_maps`
179011
+ const mapping = this.getEntityMapping(entityId);
179012
+ if (mapping?.disabled) {
179013
+ this.log.debug(`Skipping disabled entity: ${entityId}`);
179014
+ continue;
179015
+ }
179016
+ if (this.registry.isAutoComposedDevicesEnabled() && this.registry.isComposedSubEntityUsed(entityId)) {
179017
+ this.log.debug(
179018
+ `Skipping ${entityId}, already part of a composed device`
178800
179019
  );
179020
+ continue;
178801
179021
  }
178802
- return rooms;
178803
- } catch (error) {
178804
- const msg = error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
178805
- _BridgeRegistry.roborockLogger.warn(
178806
- `${entityId}: roborock.get_maps failed: ${msg}`
178807
- );
178808
- return [];
179022
+ if (entityId.length > MAX_ENTITY_ID_LENGTH) {
179023
+ const reason = `Entity ID too long (${entityId.length} chars, max ${MAX_ENTITY_ID_LENGTH}). This would cause filesystem errors.`;
179024
+ this.log.warn(`Skipping entity: ${entityId}. Reason: ${reason}`);
179025
+ this.addFailedEntity(entityId, reason);
179026
+ continue;
179027
+ }
179028
+ let endpoint = existingEndpoints.find((e) => e.entityId === entityId);
179029
+ if (!endpoint) {
179030
+ try {
179031
+ const domainMappings = this.getPluginDomainMappings();
179032
+ const resolved = resolvedByEntity.get(entityId);
179033
+ endpoint = await LegacyEndpoint.create(
179034
+ this.registry,
179035
+ entityId,
179036
+ mapping,
179037
+ domainMappings,
179038
+ false,
179039
+ resolved?.endpointId,
179040
+ resolved?.anchorEntityId
179041
+ );
179042
+ } catch (e) {
179043
+ const reason = this.extractErrorReason(e);
179044
+ this.log.warn(`Failed to create device ${entityId}: ${reason}`);
179045
+ this.addFailedEntity(entityId, reason);
179046
+ continue;
179047
+ }
179048
+ if (endpoint) {
179049
+ try {
179050
+ await this.root.add(endpoint);
179051
+ this.mappingFingerprints.set(
179052
+ entityId,
179053
+ this.fingerprintAsBuilt(mapping, entityId, endpoint)
179054
+ );
179055
+ } catch (e) {
179056
+ const errorMessage = e instanceof Error ? e.message : String(e);
179057
+ this.log.warn(
179058
+ `Failed to add endpoint for ${entityId}: ${errorMessage}`
179059
+ );
179060
+ this.logDetailedError(entityId, e);
179061
+ this.addFailedEntity(entityId, this.extractErrorReason(e));
179062
+ }
179063
+ }
179064
+ }
179065
+ }
179066
+ await this.reconcileAreaSwitches();
179067
+ this.rebuildBatteryRetryCandidates();
179068
+ if (this.observingRequested) {
179069
+ this.startObserving();
178809
179070
  }
178810
179071
  }
178811
- static cleanAreaLogger = Logger.get("CleanAreaRooms");
178812
- /**
178813
- * Resolve HA areas mapped to vacuum segments via HA 2026.3 CLEAN_AREA.
178814
- * Fetches the full entity registry entry (including options.vacuum.area_mapping)
178815
- * and resolves HA area names from the area registry.
178816
- * Returns CleanAreaRoom[] sorted alphabetically, or empty array if
178817
- * CLEAN_AREA is not supported or no area_mapping is configured.
178818
- */
178819
- async resolveCleanAreaRooms(entityId, supportedFeatures) {
178820
- if (!this.client) return [];
178821
- if (!(supportedFeatures & VacuumDeviceFeature.CLEAN_AREA)) return [];
178822
- try {
178823
- const entry = await sendHaMessage(this.client.connection, {
178824
- type: "config/entity_registry/get",
178825
- entity_id: entityId
178826
- });
178827
- const vacuumOptions = entry?.options?.vacuum;
178828
- const areaMapping = vacuumOptions?.area_mapping;
178829
- if (!areaMapping || Object.keys(areaMapping).length === 0) {
178830
- _BridgeRegistry.cleanAreaLogger.debug(
178831
- `${entityId}: CLEAN_AREA supported but no area_mapping configured`
179072
+ // Opt-in per-area room switches (#355). One momentary OnOffPlugInUnit sibling
179073
+ // per configured service area, mounted alongside its vacuum with a stable
179074
+ // derived id. Areas and mapping come from the parent's vacuumEffective, the
179075
+ // exact config its ServiceArea cluster was built from, never from raw storage
179076
+ // (raw can be a different id space: injected Valetudo/Roborock rooms,
179077
+ // auto-resolved CLEAN_AREA). The vacuum's own endpoint is never touched here.
179078
+ // Switches are kept while their parent endpoint survives with the flag on
179079
+ // (the parent's removal grace is mirrored for free), rebuilt via close() when
179080
+ // the parent was recreated for a mapping change so numbers survive, and
179081
+ // deleted when the flag goes off, the area is gone, or the parent is gone.
179082
+ async reconcileAreaSwitches() {
179083
+ const parts = this.root.parts.map((p) => p);
179084
+ const switches = parts.filter(
179085
+ (p) => p instanceof VacuumAreaSwitchEndpoint
179086
+ );
179087
+ const vacuumById = /* @__PURE__ */ new Map();
179088
+ for (const part of parts) {
179089
+ if (part instanceof VacuumAreaSwitchEndpoint) continue;
179090
+ vacuumById.set(part.id, part);
179091
+ }
179092
+ const isolated = new Set(
179093
+ EntityIsolationService.getIsolatedEntities(this.bridgeId).map(
179094
+ (f) => f.entityId
179095
+ )
179096
+ );
179097
+ const parentIsolated = (parent) => isolated.has(parent.id) || parent.entityId != null && isolated.has(parent.entityId);
179098
+ const kept = /* @__PURE__ */ new Set();
179099
+ for (const sw of switches) {
179100
+ const parent = vacuumById.get(sw.vacuumEndpointId);
179101
+ const mapping = parent ? this.getEntityMapping(parent.entityId) : void 0;
179102
+ const effective = parent instanceof LegacyEndpoint ? parent.vacuumEffective : void 0;
179103
+ let keep = false;
179104
+ let rebuild = false;
179105
+ if (parent && !parentIsolated(parent) && mapping?.vacuumRoomSwitches && effective) {
179106
+ const areas = getVacuumServiceAreas(
179107
+ effective.state.attributes,
179108
+ effective.mapping
178832
179109
  );
178833
- return [];
179110
+ if (areas.some((a) => a.areaId === sw.areaId)) {
179111
+ if (sw.parentEffective === effective) {
179112
+ keep = true;
179113
+ } else {
179114
+ rebuild = true;
179115
+ }
179116
+ }
179117
+ }
179118
+ if (keep) {
179119
+ kept.add(sw.id);
179120
+ continue;
178834
179121
  }
178835
- let validSegmentIds;
178836
179122
  try {
178837
- const segmentsResponse = await sendHaMessage(this.client.connection, {
178838
- type: "vacuum/get_segments",
178839
- entity_id: entityId
178840
- });
178841
- if (Array.isArray(segmentsResponse)) {
178842
- validSegmentIds = new Set(segmentsResponse.map((s) => s.id));
178843
- _BridgeRegistry.cleanAreaLogger.debug(
178844
- `${entityId}: Current vacuum segments: ${[...validSegmentIds].join(", ")}`
178845
- );
179123
+ if (rebuild) {
179124
+ await sw.close();
179125
+ } else {
179126
+ await sw.delete();
178846
179127
  }
178847
- } catch {
178848
- _BridgeRegistry.cleanAreaLogger.debug(
178849
- `${entityId}: vacuum/get_segments not available, skipping stale entry detection`
178850
- );
179128
+ } catch (e) {
179129
+ this.log.warn(`Failed to remove area switch ${sw.id}:`, e);
178851
179130
  }
178852
- const rooms = [];
178853
- for (const haAreaId of Object.keys(areaMapping)) {
178854
- const segments = areaMapping[haAreaId];
178855
- if (!segments || segments.length === 0) {
178856
- _BridgeRegistry.cleanAreaLogger.debug(
178857
- `${entityId}: Skipping HA area ${haAreaId}, no segments mapped`
179131
+ }
179132
+ for (const part of vacuumById.values()) {
179133
+ const entityId = part.entityId;
179134
+ if (!entityId?.startsWith("vacuum.")) continue;
179135
+ if (parentIsolated(part)) continue;
179136
+ const mapping = this.getEntityMapping(entityId);
179137
+ if (!mapping?.vacuumRoomSwitches) continue;
179138
+ const effective = part instanceof LegacyEndpoint ? part.vacuumEffective : void 0;
179139
+ if (!effective) continue;
179140
+ const areas = getVacuumServiceAreas(
179141
+ effective.state.attributes,
179142
+ effective.mapping
179143
+ );
179144
+ const entity = {
179145
+ entity_id: entityId,
179146
+ state: effective.state,
179147
+ registry: this.registry.entity(entityId),
179148
+ deviceRegistry: this.registry.deviceOf(entityId)
179149
+ };
179150
+ for (const area of areas) {
179151
+ const switchId = `${part.id}_roomsw_${area.areaId}`;
179152
+ if (kept.has(switchId)) continue;
179153
+ const holder = vacuumById.get(switchId);
179154
+ if (holder) {
179155
+ this.log.warn(
179156
+ `Skipping area switch ${switchId} for ${entityId}: id taken by entity ${holder.entityId}`
178858
179157
  );
178859
179158
  continue;
178860
179159
  }
178861
- if (validSegmentIds && !segments.some((sid) => validSegmentIds.has(sid))) {
178862
- const areaName2 = this.registry.areas.get(haAreaId) ?? haAreaId;
178863
- _BridgeRegistry.cleanAreaLogger.info(
178864
- `${entityId}: Skipping stale HA area "${areaName2}" (${haAreaId}), segments [${segments.join(", ")}] no longer exist on vacuum`
179160
+ try {
179161
+ const endpoint = VacuumAreaSwitchEndpoint.create({
179162
+ vacuumEndpointId: part.id,
179163
+ entity,
179164
+ mapping: effective.mapping,
179165
+ area,
179166
+ parentEffective: effective
179167
+ });
179168
+ await this.root.add(endpoint);
179169
+ } catch (e) {
179170
+ this.log.warn(
179171
+ `Failed to add area switch ${switchId} for ${entityId}:`,
179172
+ e
178865
179173
  );
178866
- continue;
178867
179174
  }
178868
- const areaName = this.registry.areas.get(haAreaId) ?? haAreaId;
178869
- rooms.push({
178870
- areaId: hashAreaId(haAreaId),
178871
- haAreaId,
178872
- name: areaName
178873
- });
178874
- }
178875
- rooms.sort((a, b) => a.name.localeCompare(b.name));
178876
- if (rooms.length > 0) {
178877
- _BridgeRegistry.cleanAreaLogger.info(
178878
- `${entityId}: Resolved ${rooms.length} HA areas via CLEAN_AREA mapping`
178879
- );
178880
- }
178881
- return rooms;
178882
- } catch (error) {
178883
- const msg = error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
178884
- _BridgeRegistry.cleanAreaLogger.warn(
178885
- `${entityId}: Failed to resolve CLEAN_AREA mapping: ${msg}`
178886
- );
178887
- return [];
178888
- }
178889
- }
178890
- /**
178891
- * Find a pressure sensor entity that belongs to the same HA device.
178892
- * Returns the entity_id of the pressure sensor, or undefined if none found.
178893
- */
178894
- findPressureEntityForDevice(deviceId) {
178895
- const entities = values3(this.registry.entities);
178896
- for (const entity of entities) {
178897
- if (entity.device_id !== deviceId) continue;
178898
- if (!entity.entity_id.startsWith("sensor.")) continue;
178899
- const state = this.registry.states[entity.entity_id];
178900
- if (!state) continue;
178901
- const attrs = state.attributes;
178902
- if (attrs.device_class === SensorDeviceClass.pressure || attrs.device_class === SensorDeviceClass.atmospheric_pressure) {
178903
- return entity.entity_id;
178904
- }
178905
- }
178906
- return void 0;
178907
- }
178908
- /**
178909
- * Mark a pressure entity as used (auto-assigned to a temperature sensor).
178910
- */
178911
- markPressureEntityUsed(entityId) {
178912
- this._usedPressureEntities.add(entityId);
178913
- }
178914
- /**
178915
- * Check if a pressure entity has been auto-assigned to a temperature sensor.
178916
- */
178917
- isPressureEntityUsed(entityId) {
178918
- return this._usedPressureEntities.has(entityId);
178919
- }
178920
- /**
178921
- * Find a power sensor entity (device_class: power) on the same HA device.
178922
- */
178923
- findPowerEntityForDevice(deviceId) {
178924
- const entities = values3(this.registry.entities);
178925
- for (const entity of entities) {
178926
- if (entity.device_id !== deviceId) continue;
178927
- if (!entity.entity_id.startsWith("sensor.")) continue;
178928
- const state = this.registry.states[entity.entity_id];
178929
- if (!state) continue;
178930
- const attrs = state.attributes;
178931
- if (attrs.device_class === SensorDeviceClass.power) {
178932
- return entity.entity_id;
178933
- }
178934
- }
178935
- return void 0;
178936
- }
178937
- /**
178938
- * Find an energy sensor entity (device_class: energy) on the same HA device.
178939
- */
178940
- findEnergyEntityForDevice(deviceId) {
178941
- const entities = values3(this.registry.entities);
178942
- for (const entity of entities) {
178943
- if (entity.device_id !== deviceId) continue;
178944
- if (!entity.entity_id.startsWith("sensor.")) continue;
178945
- const state = this.registry.states[entity.entity_id];
178946
- if (!state) continue;
178947
- const attrs = state.attributes;
178948
- if (attrs.device_class === SensorDeviceClass.energy) {
178949
- return entity.entity_id;
178950
179175
  }
178951
179176
  }
178952
- return void 0;
178953
- }
178954
- markPowerEntityUsed(entityId) {
178955
- this._usedPowerEntities.add(entityId);
178956
- }
178957
- isPowerEntityUsed(entityId) {
178958
- return this._usedPowerEntities.has(entityId);
178959
- }
178960
- markEnergyEntityUsed(entityId) {
178961
- this._usedEnergyEntities.add(entityId);
178962
- }
178963
- isEnergyEntityUsed(entityId) {
178964
- return this._usedEnergyEntities.has(entityId);
178965
179177
  }
178966
- mergeExternalStates(states) {
178967
- const registryStates = this.registry.states;
178968
- for (const entityId of Object.keys(states)) {
178969
- registryStates[entityId] = states[entityId];
178970
- }
179178
+ updateInFlight;
179179
+ pendingStates;
179180
+ pendingChanged;
179181
+ mergeChanged(a, b) {
179182
+ if (a === null || b === null) return null;
179183
+ const merged = new Set(a);
179184
+ for (const id of b) merged.add(id);
179185
+ return merged;
178971
179186
  }
178972
- /**
178973
- * Get the area name for an entity, resolving from HA area registry.
178974
- * Priority: entity area_id > device area_id > undefined
178975
- */
178976
- getAreaName(entityId) {
178977
- const entity = this._entities[entityId];
178978
- if (!entity) return void 0;
178979
- const entityAreaId = entity.area_id;
178980
- if (entityAreaId) {
178981
- const name = this.registry.areas.get(entityAreaId);
178982
- if (name) return name;
178983
- }
178984
- const device = this._devices[entity.device_id];
178985
- const deviceAreaId = device?.area_id;
178986
- if (deviceAreaId) {
178987
- const name = this.registry.areas.get(deviceAreaId);
178988
- if (name) return name;
179187
+ async updateStates(states, changed = null) {
179188
+ if (this.updateInFlight) {
179189
+ this.pendingStates = states;
179190
+ this.pendingChanged = this.pendingChanged === void 0 ? changed : this.mergeChanged(this.pendingChanged, changed);
179191
+ return this.updateInFlight;
178989
179192
  }
178990
- return void 0;
178991
- }
178992
- refresh() {
178993
- this._usedBatteryEntities.clear();
178994
- this._usedHumidityEntities.clear();
178995
- this._usedPressureEntities.clear();
178996
- this._usedPowerEntities.clear();
178997
- this._usedEnergyEntities.clear();
178998
- this._usedComposedSubEntities.clear();
178999
- this._batteryEntityCache.clear();
179000
- this._problemEntityCache.clear();
179001
- this._entities = pickBy(this.registry.entities, (entity) => {
179002
- const device = this.registry.devices[entity.device_id];
179003
- const filter = this.dataProvider.filter;
179004
- const featureFlags = this.dataProvider.featureFlags ?? {};
179005
- if (entity.disabled_by != null) {
179006
- return false;
179007
- }
179008
- const isHidden = entity.hidden_by != null;
179009
- if (isHidden && !featureFlags.includeHiddenEntities) {
179010
- return false;
179193
+ this.updateInFlight = this.runUpdateStates(states, changed).finally(() => {
179194
+ this.updateInFlight = void 0;
179195
+ const queued = this.pendingStates;
179196
+ const queuedChanged = this.pendingChanged;
179197
+ this.pendingStates = void 0;
179198
+ this.pendingChanged = void 0;
179199
+ if (queued) {
179200
+ this.updateStates(
179201
+ queued,
179202
+ queuedChanged === void 0 ? null : queuedChanged
179203
+ ).catch((e) => this.log.warn("Queued state update failed:", e));
179011
179204
  }
179012
- const state = this.registry.states[entity.entity_id];
179013
- return this.matchesFilter(filter, entity, device, state);
179014
179205
  });
179015
- this._states = pickBy(
179016
- this.registry.states,
179017
- (e) => !!this._entities[e.entity_id]
179206
+ return this.updateInFlight;
179207
+ }
179208
+ async runUpdateStates(states, changed) {
179209
+ const startMs = performance.now();
179210
+ this.registry.mergeExternalStates(states);
179211
+ this.maybeRetryBatteryMapping(states, changed);
179212
+ const allEndpoints = [...this.root.parts].filter(isEntityPart);
179213
+ const endpoints = changed === null ? allEndpoints : allEndpoints.filter(
179214
+ (e) => changed.has(e.entityId) || (e.mappedEntityIds ?? []).some((id) => changed.has(id))
179018
179215
  );
179019
- this._devices = pickBy(
179020
- this.registry.devices,
179021
- (d) => values3(this._entities).map((e) => e.device_id).some((id) => d.id === id)
179216
+ if (endpoints.length === 0) return;
179217
+ const results = await Promise.allSettled(
179218
+ endpoints.map((endpoint) => endpoint.updateStates(states))
179022
179219
  );
179023
- this.preCalculateAutoAssignments();
179024
- }
179025
- /**
179026
- * Pre-calculate which entities will be auto-assigned to other devices.
179027
- * This must run BEFORE endpoint creation to ensure correct "used" marking
179028
- * regardless of the order entities are processed.
179029
- */
179030
- preCalculateAutoAssignments() {
179031
- const entities = values3(this._entities);
179032
- for (const entity of entities) {
179033
- if (!entity.device_id) continue;
179034
- if (!entity.entity_id.startsWith("sensor.")) continue;
179035
- const state = this._states[entity.entity_id];
179036
- if (!state) continue;
179037
- const attrs = state.attributes;
179038
- if (attrs.device_class === SensorDeviceClass.temperature) {
179039
- if (this.isAutoHumidityMappingEnabled()) {
179040
- const humidityEntityId = this.findHumidityEntityForDevice(
179041
- entity.device_id
179042
- );
179043
- if (humidityEntityId && humidityEntityId !== entity.entity_id) {
179044
- this._usedHumidityEntities.add(humidityEntityId);
179045
- }
179046
- }
179047
- if (this.isAutoPressureMappingEnabled()) {
179048
- const pressureEntityId = this.findPressureEntityForDevice(
179049
- entity.device_id
179050
- );
179051
- if (pressureEntityId && pressureEntityId !== entity.entity_id) {
179052
- this._usedPressureEntities.add(pressureEntityId);
179053
- }
179054
- }
179220
+ let failedCount = 0;
179221
+ for (const result of results) {
179222
+ if (result.status === "rejected") {
179223
+ failedCount++;
179224
+ this.log.warn("State update failed for endpoint:", result.reason);
179055
179225
  }
179056
179226
  }
179057
- for (const entity of entities) {
179058
- if (!entity.device_id) continue;
179059
- const domain = entity.entity_id.split(".")[0];
179060
- if (domain !== "switch" && domain !== "light") continue;
179061
- const powerEntityId = this.findPowerEntityForDevice(entity.device_id);
179062
- if (powerEntityId && powerEntityId !== entity.entity_id) {
179063
- if (!this._usedPowerEntities.has(powerEntityId)) {
179064
- this._usedPowerEntities.add(powerEntityId);
179065
- }
179066
- }
179067
- const energyEntityId = this.findEnergyEntityForDevice(entity.device_id);
179068
- if (energyEntityId && energyEntityId !== entity.entity_id) {
179069
- if (!this._usedEnergyEntities.has(energyEntityId)) {
179070
- this._usedEnergyEntities.add(energyEntityId);
179071
- }
179227
+ const latencyMs = Math.round((performance.now() - startMs) * 100) / 100;
179228
+ if (latencyMs > 200 || failedCount > 0) {
179229
+ const msg = `State update: ${endpoints.length} endpoints in ${latencyMs}ms` + (failedCount > 0 ? ` (${failedCount} failed)` : "");
179230
+ if (latencyMs > 200) {
179231
+ this.log.warn(`Slow ${msg}`);
179072
179232
  }
179073
- }
179074
- if (this.isAutoBatteryMappingEnabled()) {
179075
- for (const entity of entities) {
179076
- if (!entity.device_id) continue;
179077
- if (this._usedHumidityEntities.has(entity.entity_id)) continue;
179078
- if (entity.entity_id.startsWith("sensor.")) {
179079
- const state = this._states[entity.entity_id];
179080
- if (state) {
179081
- const attrs = state.attributes;
179082
- if (attrs.device_class === SensorDeviceClass.battery) continue;
179083
- }
179084
- }
179085
- if (entity.entity_id.startsWith("binary_sensor.")) {
179086
- const state = this._states[entity.entity_id];
179087
- if (state) {
179088
- const attrs = state.attributes;
179089
- if (attrs.device_class === "battery") continue;
179090
- }
179091
- }
179092
- const batteryEntityId = this.findBatteryEntityForDevice(
179093
- entity.device_id
179094
- );
179095
- if (batteryEntityId && batteryEntityId !== entity.entity_id) {
179096
- if (!this._usedBatteryEntities.has(batteryEntityId)) {
179097
- this._usedBatteryEntities.add(batteryEntityId);
179098
- }
179233
+ diagnosticEventBus.emit("state_update", msg, {
179234
+ bridgeId: this.bridgeId,
179235
+ details: {
179236
+ endpointCount: endpoints.length,
179237
+ failedCount,
179238
+ latencyMs
179099
179239
  }
179100
- }
179240
+ });
179101
179241
  }
179102
179242
  }
179103
179243
  /**
179104
- * The first already-matched entity the given matcher tests true for.
179105
- * Server mode pins the primary entity to the first include matcher with
179106
- * this, independent of HA registry order (#301).
179244
+ * Log detailed behavior error information for debugging "Behaviors have errors".
179245
+ * Matter.js EndpointBehaviorsError extends AggregateError, the `errors` array
179246
+ * contains individual behavior crash errors (one per failed behavior).
179107
179247
  */
179108
- firstEntityMatching(matcher) {
179109
- const labels = this.registry.labels;
179110
- for (const entity of values3(this._entities)) {
179111
- const device = this.registry.devices[entity.device_id];
179112
- const state = this.registry.states[entity.entity_id];
179113
- if (testMatchers([matcher], device, entity, "any", state, labels)) {
179114
- return entity.entity_id;
179248
+ logDetailedError(entityId, error) {
179249
+ if (!(error instanceof Error)) return;
179250
+ const errorsArray = error.errors;
179251
+ if (Array.isArray(errorsArray) && errorsArray.length > 0) {
179252
+ for (let i = 0; i < errorsArray.length; i++) {
179253
+ const subError = errorsArray[i];
179254
+ const subMsg = subError instanceof Error ? subError.message : String(subError);
179255
+ this.log.warn(
179256
+ `[${entityId}] Behavior error [${i + 1}/${errorsArray.length}]: ${subMsg}`
179257
+ );
179258
+ let cause = subError instanceof Error ? subError.cause : void 0;
179259
+ while (cause instanceof Error) {
179260
+ this.log.warn(`[${entityId}] Caused by: ${cause.message}`);
179261
+ cause = cause.cause;
179262
+ }
179263
+ if (subError instanceof Error && subError.stack) {
179264
+ this.log.debug(`[${entityId}] Sub-error stack: ${subError.stack}`);
179265
+ }
179266
+ }
179267
+ } else {
179268
+ let current = error.cause;
179269
+ while (current instanceof Error) {
179270
+ this.log.warn(`[${entityId}] Caused by: ${current.message}`);
179271
+ current = current.cause;
179115
179272
  }
179116
179273
  }
179117
- return void 0;
179118
- }
179119
- matchesFilter(filter, entity, device, entityState) {
179120
- const labels = this.registry.labels;
179121
- if (filter.include.length > 0 && !testMatchers(
179122
- filter.include,
179123
- device,
179124
- entity,
179125
- filter.includeMode,
179126
- entityState,
179127
- labels
179128
- )) {
179129
- return false;
179274
+ if (error.stack) {
179275
+ this.log.debug(`[${entityId}] Full stack: ${error.stack}`);
179130
179276
  }
179131
- if (filter.exclude.length > 0 && testMatchers(filter.exclude, device, entity, "any", entityState, labels)) {
179132
- return false;
179277
+ }
179278
+ extractErrorReason(error) {
179279
+ if (error instanceof Error) {
179280
+ const cause = error.cause;
179281
+ if (cause?.message) {
179282
+ return `${error.message}: ${cause.message}`;
179283
+ }
179284
+ return error.message;
179133
179285
  }
179134
- return true;
179286
+ return String(error);
179135
179287
  }
179136
179288
  };
179137
- function hashAreaId(areaId) {
179138
- let hash2 = 0;
179139
- for (let i = 0; i < areaId.length; i++) {
179140
- const char = areaId.charCodeAt(i);
179141
- hash2 = (hash2 << 5) - hash2 + char;
179142
- hash2 |= 0;
179143
- }
179144
- return Math.abs(hash2);
179145
- }
179146
179289
 
179147
179290
  // src/services/bridges/server-mode-bridge.ts
179148
179291
  init_dist();
@@ -180645,9 +180788,82 @@ var ServerModeEndpointManager = class extends Service {
180645
180788
  getEntityMapping(entityId) {
180646
180789
  return this.mappingStorage.getMapping(this.dataProvider.id, entityId);
180647
180790
  }
180648
- computeMappingFingerprint(mapping) {
180649
- if (!mapping) return "";
180650
- return JSON.stringify(mapping);
180791
+ // #450: an endpoint built while its battery sensor was unavailable stays
180792
+ // battery-less, because registry ticks only refresh on structural changes.
180793
+ // When a same-device sensor state arrives, re-resolve and rebuild.
180794
+ batteryRetryScheduled = false;
180795
+ batteryRetryTimer = null;
180796
+ // deviceId -> primary entityId of endpoints that auto-map but carry no
180797
+ // battery, bounds the per-state-batch check to a map hit
180798
+ batteryRetryCandidates = /* @__PURE__ */ new Map();
180799
+ // Only endpoints the auto-mapping applies to belong here: a manual or
180800
+ // disabled mapping, or a sensor endpoint sharing the device, must not
180801
+ // claim the slot (last writer would win) and stall the recovery.
180802
+ batteryRetryEligible(entityId) {
180803
+ const mapping = this.getEntityMapping(entityId);
180804
+ if (mapping?.batteryEntity || mapping?.disableBatteryMapping) return false;
180805
+ if (entityId.startsWith("sensor.") || entityId.startsWith("binary_sensor.")) {
180806
+ return false;
180807
+ }
180808
+ return entityId.startsWith("vacuum.") || !!this.registry.isAutoBatteryMappingEnabled?.();
180809
+ }
180810
+ rebuildBatteryRetryCandidates() {
180811
+ this.batteryRetryCandidates.clear();
180812
+ for (const [entityId, entry] of this.endpoints) {
180813
+ if (fingerprintBattery(entry.fingerprint) != null) continue;
180814
+ if (!this.batteryRetryEligible(entityId)) continue;
180815
+ const deviceId = this.registry.entity(entityId)?.device_id;
180816
+ if (deviceId) this.batteryRetryCandidates.set(deviceId, entityId);
180817
+ }
180818
+ }
180819
+ maybeRetryBatteryMapping(states) {
180820
+ if (!this.observingRequested || this.batteryRetryScheduled || this.batteryRetryCandidates.size === 0) {
180821
+ return;
180822
+ }
180823
+ for (const id of Object.keys(states)) {
180824
+ if (!id.startsWith("sensor.") && !id.startsWith("binary_sensor."))
180825
+ continue;
180826
+ const deviceId = this.registry.fullEntities[id]?.device_id;
180827
+ if (!deviceId) continue;
180828
+ const entityId = this.batteryRetryCandidates.get(deviceId);
180829
+ if (!entityId) continue;
180830
+ this.registry.forgetBatteryCacheForDevice(deviceId);
180831
+ const resolved = this.registry.batteryFingerprintFor(
180832
+ entityId,
180833
+ this.getEntityMapping(entityId)
180834
+ );
180835
+ if (!resolved) continue;
180836
+ this.batteryRetryScheduled = true;
180837
+ this.log.info(
180838
+ `Battery sensor ${resolved} appeared for ${entityId}, rebuilding`
180839
+ );
180840
+ this.batteryRetryTimer = setTimeout(() => {
180841
+ this.batteryRetryTimer = null;
180842
+ this.refreshDevices().catch((e) => this.log.warn("Battery retry refresh failed:", e)).finally(() => {
180843
+ this.batteryRetryScheduled = false;
180844
+ });
180845
+ }, 0);
180846
+ return;
180847
+ }
180848
+ }
180849
+ computeMappingFingerprint(mapping, entityId) {
180850
+ const battery = entityId ? this.registry.batteryFingerprintFor(entityId, mapping) : "";
180851
+ return JSON.stringify([mapping ?? null, battery || null]);
180852
+ }
180853
+ // Live fingerprint for reconcile compares: when the resolver finds nothing
180854
+ // right now but the stored fingerprint maps a sensor that still exists on
180855
+ // the SAME device, keep it. An unavailable snapshot (HA restart) must not
180856
+ // strip the mapping and rebuild the endpoint battery-less (#450).
180857
+ compareFingerprint(mapping, entityId, storedFingerprint) {
180858
+ const fingerprint = this.computeMappingFingerprint(mapping, entityId);
180859
+ if (fingerprintBattery(fingerprint) != null || !storedFingerprint)
180860
+ return fingerprint;
180861
+ if (!this.batteryRetryEligible(entityId)) return fingerprint;
180862
+ const battery = fingerprintBattery(storedFingerprint);
180863
+ if (!battery) return fingerprint;
180864
+ const deviceId = this.registry.entity(entityId)?.device_id;
180865
+ const stillSameDevice = !!deviceId && this.registry.fullEntities[battery]?.device_id === deviceId;
180866
+ return stillSameDevice ? JSON.stringify([mapping ?? null, battery]) : fingerprint;
180651
180867
  }
180652
180868
  async dispose() {
180653
180869
  this.stopObserving();
@@ -180688,6 +180904,15 @@ var ServerModeEndpointManager = class extends Service {
180688
180904
  ids.add(mappedId);
180689
180905
  }
180690
180906
  }
180907
+ if (this.batteryRetryCandidates.size > 0) {
180908
+ for (const entity of Object.values(this.registry.fullEntities)) {
180909
+ if (!entity.device_id) continue;
180910
+ if (!this.batteryRetryCandidates.has(entity.device_id)) continue;
180911
+ if (entity.entity_id.startsWith("sensor.") || entity.entity_id.startsWith("binary_sensor.")) {
180912
+ ids.add(entity.entity_id);
180913
+ }
180914
+ }
180915
+ }
180691
180916
  return [...ids];
180692
180917
  }
180693
180918
  clearSubscription() {
@@ -180702,6 +180927,11 @@ var ServerModeEndpointManager = class extends Service {
180702
180927
  clearTimeout(this.removalRecheckTimer);
180703
180928
  this.removalRecheckTimer = null;
180704
180929
  }
180930
+ if (this.batteryRetryTimer) {
180931
+ clearTimeout(this.batteryRetryTimer);
180932
+ this.batteryRetryTimer = null;
180933
+ }
180934
+ this.batteryRetryScheduled = false;
180705
180935
  }
180706
180936
  /** Primary first (the entity the first include matcher tests true for). */
180707
180937
  orderEntityIds(ids) {
@@ -180896,9 +181126,9 @@ var ServerModeEndpointManager = class extends Service {
180896
181126
  });
180897
181127
  continue;
180898
181128
  }
180899
- const fingerprint = this.computeMappingFingerprint(mapping);
181129
+ const fingerprint = this.computeMappingFingerprint(mapping, entityId);
180900
181130
  const existing = this.endpoints.get(entityId);
180901
- if (existing && existing.fingerprint === fingerprint) {
181131
+ if (existing && existing.fingerprint === this.compareFingerprint(mapping, entityId, existing.fingerprint)) {
180902
181132
  this.log.debug(`Device endpoint already exists for ${entityId}`);
180903
181133
  continue;
180904
181134
  }
@@ -180967,7 +181197,9 @@ var ServerModeEndpointManager = class extends Service {
180967
181197
  continue;
180968
181198
  }
180969
181199
  await this.serverNode.addDevice(endpoint);
180970
- this.endpoints.set(entityId, { endpoint, fingerprint });
181200
+ const builtBattery = fingerprintBattery(fingerprint);
181201
+ const asBuilt = builtBattery != null && !endpoint.mappedEntityIds.includes(builtBattery) ? JSON.stringify([mapping ?? null, null]) : fingerprint;
181202
+ this.endpoints.set(entityId, { endpoint, fingerprint: asBuilt });
180971
181203
  for (const [id, owner] of this.parkedEndpointIds) {
180972
181204
  if (id === endpointId || owner === entityId) {
180973
181205
  this.parkedEndpointIds.delete(id);
@@ -180995,6 +181227,7 @@ var ServerModeEndpointManager = class extends Service {
180995
181227
  if (lifecycle === this.lifecycle) {
180996
181228
  this.scheduleRemovalRecheck();
180997
181229
  }
181230
+ this.rebuildBatteryRetryCandidates();
180998
181231
  if (this.observingRequested) {
180999
181232
  this.startObserving();
181000
181233
  }
@@ -181002,6 +181235,7 @@ var ServerModeEndpointManager = class extends Service {
181002
181235
  }
181003
181236
  async updateStates(states) {
181004
181237
  this.registry.mergeExternalStates(states);
181238
+ this.maybeRetryBatteryMapping(states);
181005
181239
  for (const [entityId, entry] of this.endpoints) {
181006
181240
  try {
181007
181241
  await entry.endpoint.updateStates(states);