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

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