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