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