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