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