@riddix/hamh 2.1.0-alpha.755 → 2.1.0-alpha.757
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
|
@@ -124071,6 +124071,14 @@ function computeControllerWarnings(controllers, exposed) {
|
|
|
124071
124071
|
}
|
|
124072
124072
|
return warnings;
|
|
124073
124073
|
}
|
|
124074
|
+
function controllerWarningsForFabrics(fabrics, exposed) {
|
|
124075
|
+
const controllers = [
|
|
124076
|
+
...new Set(fabrics.map((f) => classifyController(f.rootVendorId)).filter((c) => c !== void 0))
|
|
124077
|
+
];
|
|
124078
|
+
if (controllers.length === 0)
|
|
124079
|
+
return [];
|
|
124080
|
+
return computeControllerWarnings(controllers, exposed);
|
|
124081
|
+
}
|
|
124074
124082
|
var controllerByVendorId, deviceTypeIdSupport, controllerLabels;
|
|
124075
124083
|
var init_controller_compat = __esm({
|
|
124076
124084
|
"../common/dist/controller-compat.js"() {
|
|
@@ -124622,7 +124630,8 @@ function buildEntityDiagnostics(exposed, failed, warnings) {
|
|
|
124622
124630
|
const result = failed.map((f) => ({
|
|
124623
124631
|
entityId: f.entityId,
|
|
124624
124632
|
status: "failed",
|
|
124625
|
-
reason: f.reason
|
|
124633
|
+
reason: f.reason,
|
|
124634
|
+
failedAt: f.failedAt
|
|
124626
124635
|
}));
|
|
124627
124636
|
const failedIds = new Set(failed.map((f) => f.entityId));
|
|
124628
124637
|
const issuesByEntity = /* @__PURE__ */ new Map();
|
|
@@ -127121,12 +127130,7 @@ function healthApi(bridgeService, haClient, version2, startTime) {
|
|
|
127121
127130
|
const fabrics = data.commissioning?.fabrics ?? [];
|
|
127122
127131
|
const sessionInfo = b.getSessionInfo();
|
|
127123
127132
|
const exposed = b.getExposedDeviceTypes();
|
|
127124
|
-
const
|
|
127125
|
-
...new Set(
|
|
127126
|
-
fabrics.map((f) => classifyController(f.rootVendorId)).filter((c) => c !== void 0)
|
|
127127
|
-
)
|
|
127128
|
-
];
|
|
127129
|
-
const controllerWarnings = controllers.length > 0 ? computeControllerWarnings(controllers, exposed) : [];
|
|
127133
|
+
const controllerWarnings = data.controllerWarnings ?? [];
|
|
127130
127134
|
const entityDiagnostics = buildEntityDiagnostics(
|
|
127131
127135
|
exposed,
|
|
127132
127136
|
data.failedEntities ?? [],
|
|
@@ -127166,7 +127170,8 @@ function healthApi(bridgeService, haClient, version2, startTime) {
|
|
|
127166
127170
|
recovery: {
|
|
127167
127171
|
enabled: bridgeService.autoRecoveryEnabled,
|
|
127168
127172
|
lastRecoveryAttempt: bridgeService.lastRecoveryAttempt?.toISOString(),
|
|
127169
|
-
recoveryCount: bridgeService.recoveryCount
|
|
127173
|
+
recoveryCount: bridgeService.recoveryCount,
|
|
127174
|
+
history: bridgeService.recoveryHistory
|
|
127170
127175
|
}
|
|
127171
127176
|
};
|
|
127172
127177
|
res.json(detailed);
|
|
@@ -128353,15 +128358,13 @@ function metricsApi(bridgeService, haClient, haRegistry, startTime) {
|
|
|
128353
128358
|
router.get("/", (_, res) => {
|
|
128354
128359
|
const memoryUsage = process.memoryUsage();
|
|
128355
128360
|
const bridges = bridgeService.bridges;
|
|
128356
|
-
const
|
|
128357
|
-
const
|
|
128358
|
-
const
|
|
128359
|
-
const
|
|
128360
|
-
|
|
128361
|
-
|
|
128362
|
-
|
|
128363
|
-
const totalFabrics = bridges.reduce(
|
|
128364
|
-
(sum, b) => sum + (b.data.commissioning?.fabrics?.length ?? 0),
|
|
128361
|
+
const datas = bridges.map((b) => b.data);
|
|
128362
|
+
const running = datas.filter((d) => d.status === "running").length;
|
|
128363
|
+
const stopped = datas.filter((d) => d.status === "stopped").length;
|
|
128364
|
+
const failed = datas.filter((d) => d.status === "failed").length;
|
|
128365
|
+
const totalDevices = datas.reduce((sum, d) => sum + d.deviceCount, 0);
|
|
128366
|
+
const totalFabrics = datas.reduce(
|
|
128367
|
+
(sum, d) => sum + (d.commissioning?.fabrics?.length ?? 0),
|
|
128365
128368
|
0
|
|
128366
128369
|
);
|
|
128367
128370
|
const metrics = {
|
|
@@ -128393,15 +128396,13 @@ function metricsApi(bridgeService, haClient, haRegistry, startTime) {
|
|
|
128393
128396
|
const memoryUsage = process.memoryUsage();
|
|
128394
128397
|
const bridges = bridgeService.bridges;
|
|
128395
128398
|
const uptime2 = Math.floor((Date.now() - startTime) / 1e3);
|
|
128396
|
-
const
|
|
128397
|
-
const
|
|
128398
|
-
const
|
|
128399
|
-
const
|
|
128400
|
-
|
|
128401
|
-
|
|
128402
|
-
|
|
128403
|
-
const totalFabrics = bridges.reduce(
|
|
128404
|
-
(sum, b) => sum + (b.data.commissioning?.fabrics?.length ?? 0),
|
|
128399
|
+
const datas = bridges.map((b) => b.data);
|
|
128400
|
+
const running = datas.filter((d) => d.status === "running").length;
|
|
128401
|
+
const stopped = datas.filter((d) => d.status === "stopped").length;
|
|
128402
|
+
const failed = datas.filter((d) => d.status === "failed").length;
|
|
128403
|
+
const totalDevices = datas.reduce((sum, d) => sum + d.deviceCount, 0);
|
|
128404
|
+
const totalFabrics = datas.reduce(
|
|
128405
|
+
(sum, d) => sum + (d.commissioning?.fabrics?.length ?? 0),
|
|
128405
128406
|
0
|
|
128406
128407
|
);
|
|
128407
128408
|
const lines = [
|
|
@@ -128459,8 +128460,9 @@ function metricsApi(bridgeService, haClient, haRegistry, startTime) {
|
|
|
128459
128460
|
""
|
|
128460
128461
|
];
|
|
128461
128462
|
for (const bridge of bridges) {
|
|
128462
|
-
const
|
|
128463
|
-
const
|
|
128463
|
+
const data = bridge.data;
|
|
128464
|
+
const status3 = data.status === "running" ? 1 : 0;
|
|
128465
|
+
const safeName = data.name.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
128464
128466
|
lines.push(
|
|
128465
128467
|
`# HELP hamh_bridge_status Bridge status (1=running, 0=not running)`,
|
|
128466
128468
|
`# TYPE hamh_bridge_status gauge`,
|
|
@@ -128468,7 +128470,7 @@ function metricsApi(bridgeService, haClient, haRegistry, startTime) {
|
|
|
128468
128470
|
"",
|
|
128469
128471
|
`# HELP hamh_bridge_devices Number of devices on bridge`,
|
|
128470
128472
|
`# TYPE hamh_bridge_devices gauge`,
|
|
128471
|
-
`hamh_bridge_devices{bridge_id="${bridge.id}",bridge_name="${safeName}"} ${
|
|
128473
|
+
`hamh_bridge_devices{bridge_id="${bridge.id}",bridge_name="${safeName}"} ${data.deviceCount}`,
|
|
128472
128474
|
""
|
|
128473
128475
|
);
|
|
128474
128476
|
}
|
|
@@ -129336,7 +129338,177 @@ function buildPath(...paths) {
|
|
|
129336
129338
|
|
|
129337
129339
|
// src/api/settings-api.ts
|
|
129338
129340
|
import { Router as Router2 } from "express";
|
|
129339
|
-
|
|
129341
|
+
|
|
129342
|
+
// src/services/storage/app-settings-storage.ts
|
|
129343
|
+
init_service();
|
|
129344
|
+
import { randomBytes as randomBytes2, scryptSync, timingSafeEqual } from "node:crypto";
|
|
129345
|
+
var SCRYPT_KEYLEN = 64;
|
|
129346
|
+
var SCRYPT_COST_N = 16384;
|
|
129347
|
+
var SCRYPT_BLOCK_R = 8;
|
|
129348
|
+
var SCRYPT_PARALLEL_P = 1;
|
|
129349
|
+
var SALT_BYTES = 16;
|
|
129350
|
+
var DEFAULT_BACKUP_SETTINGS = {
|
|
129351
|
+
autoBackup: true,
|
|
129352
|
+
backupRetentionCount: 5
|
|
129353
|
+
};
|
|
129354
|
+
var MIN_RECOVERY_INTERVAL_MS = 1e4;
|
|
129355
|
+
var MAX_RECOVERY_INTERVAL_MS = 36e5;
|
|
129356
|
+
var DEFAULT_RECOVERY_SETTINGS = {
|
|
129357
|
+
autoRecoveryEnabled: true,
|
|
129358
|
+
recoveryIntervalMs: 6e4
|
|
129359
|
+
};
|
|
129360
|
+
function hashPassword(plain2) {
|
|
129361
|
+
const salt = randomBytes2(SALT_BYTES);
|
|
129362
|
+
const derived = scryptSync(plain2, salt, SCRYPT_KEYLEN, {
|
|
129363
|
+
N: SCRYPT_COST_N,
|
|
129364
|
+
r: SCRYPT_BLOCK_R,
|
|
129365
|
+
p: SCRYPT_PARALLEL_P
|
|
129366
|
+
});
|
|
129367
|
+
return {
|
|
129368
|
+
passwordHash: derived.toString("hex"),
|
|
129369
|
+
passwordSalt: salt.toString("hex")
|
|
129370
|
+
};
|
|
129371
|
+
}
|
|
129372
|
+
function verifyPasswordHash(plain2, passwordHash, passwordSalt) {
|
|
129373
|
+
let expected;
|
|
129374
|
+
let salt;
|
|
129375
|
+
try {
|
|
129376
|
+
expected = Buffer.from(passwordHash, "hex");
|
|
129377
|
+
salt = Buffer.from(passwordSalt, "hex");
|
|
129378
|
+
} catch {
|
|
129379
|
+
return false;
|
|
129380
|
+
}
|
|
129381
|
+
if (expected.length !== SCRYPT_KEYLEN || salt.length === 0) {
|
|
129382
|
+
return false;
|
|
129383
|
+
}
|
|
129384
|
+
const derived = scryptSync(plain2, salt, SCRYPT_KEYLEN, {
|
|
129385
|
+
N: SCRYPT_COST_N,
|
|
129386
|
+
r: SCRYPT_BLOCK_R,
|
|
129387
|
+
p: SCRYPT_PARALLEL_P
|
|
129388
|
+
});
|
|
129389
|
+
return timingSafeEqual(derived, expected);
|
|
129390
|
+
}
|
|
129391
|
+
function constantTimeStringEquals(a, b) {
|
|
129392
|
+
const bufA = Buffer.from(a);
|
|
129393
|
+
const bufB = Buffer.from(b);
|
|
129394
|
+
if (bufA.length !== bufB.length) {
|
|
129395
|
+
timingSafeEqual(bufA, bufA);
|
|
129396
|
+
return false;
|
|
129397
|
+
}
|
|
129398
|
+
return timingSafeEqual(bufA, bufB);
|
|
129399
|
+
}
|
|
129400
|
+
var AppSettingsStorage = class extends Service {
|
|
129401
|
+
constructor(appStorage) {
|
|
129402
|
+
super("AppSettingsStorage");
|
|
129403
|
+
this.appStorage = appStorage;
|
|
129404
|
+
}
|
|
129405
|
+
appStorage;
|
|
129406
|
+
storage;
|
|
129407
|
+
settings = {};
|
|
129408
|
+
// Caches the last plaintext password that was successfully verified
|
|
129409
|
+
// against the stored hash, keyed by username. This lets repeated basic-auth
|
|
129410
|
+
// calls avoid re-running scrypt on every HTTP request without persisting
|
|
129411
|
+
// the plaintext to disk.
|
|
129412
|
+
verifiedPassword = null;
|
|
129413
|
+
async initialize() {
|
|
129414
|
+
this.storage = this.appStorage.createContext("settings");
|
|
129415
|
+
const stored = await this.storage.get(
|
|
129416
|
+
"data",
|
|
129417
|
+
{}
|
|
129418
|
+
);
|
|
129419
|
+
this.settings = stored ?? {};
|
|
129420
|
+
}
|
|
129421
|
+
get auth() {
|
|
129422
|
+
if (!this.settings.auth) {
|
|
129423
|
+
return void 0;
|
|
129424
|
+
}
|
|
129425
|
+
return { username: this.settings.auth.username };
|
|
129426
|
+
}
|
|
129427
|
+
async setAuth(credentials) {
|
|
129428
|
+
if (!credentials) {
|
|
129429
|
+
this.settings.auth = void 0;
|
|
129430
|
+
this.verifiedPassword = null;
|
|
129431
|
+
} else {
|
|
129432
|
+
const { passwordHash, passwordSalt } = hashPassword(credentials.password);
|
|
129433
|
+
this.settings.auth = {
|
|
129434
|
+
username: credentials.username,
|
|
129435
|
+
passwordHash,
|
|
129436
|
+
passwordSalt
|
|
129437
|
+
};
|
|
129438
|
+
this.verifiedPassword = {
|
|
129439
|
+
username: credentials.username,
|
|
129440
|
+
password: credentials.password
|
|
129441
|
+
};
|
|
129442
|
+
}
|
|
129443
|
+
await this.persist();
|
|
129444
|
+
}
|
|
129445
|
+
verifyAuth(username, password) {
|
|
129446
|
+
const stored = this.settings.auth;
|
|
129447
|
+
if (!stored) {
|
|
129448
|
+
return false;
|
|
129449
|
+
}
|
|
129450
|
+
if (!constantTimeStringEquals(username, stored.username)) {
|
|
129451
|
+
return false;
|
|
129452
|
+
}
|
|
129453
|
+
if (this.verifiedPassword && this.verifiedPassword.username === stored.username && constantTimeStringEquals(password, this.verifiedPassword.password)) {
|
|
129454
|
+
return true;
|
|
129455
|
+
}
|
|
129456
|
+
if (stored.password !== void 0) {
|
|
129457
|
+
const matches = constantTimeStringEquals(password, stored.password);
|
|
129458
|
+
if (matches) {
|
|
129459
|
+
this.verifiedPassword = { username: stored.username, password };
|
|
129460
|
+
void this.migrateLegacyPlaintext(password);
|
|
129461
|
+
}
|
|
129462
|
+
return matches;
|
|
129463
|
+
}
|
|
129464
|
+
if (stored.passwordHash && stored.passwordSalt) {
|
|
129465
|
+
if (verifyPasswordHash(password, stored.passwordHash, stored.passwordSalt)) {
|
|
129466
|
+
this.verifiedPassword = { username: stored.username, password };
|
|
129467
|
+
return true;
|
|
129468
|
+
}
|
|
129469
|
+
}
|
|
129470
|
+
return false;
|
|
129471
|
+
}
|
|
129472
|
+
async migrateLegacyPlaintext(plain2) {
|
|
129473
|
+
const current = this.settings.auth;
|
|
129474
|
+
if (!current || current.password === void 0) {
|
|
129475
|
+
return;
|
|
129476
|
+
}
|
|
129477
|
+
const { passwordHash, passwordSalt } = hashPassword(plain2);
|
|
129478
|
+
this.settings.auth = {
|
|
129479
|
+
username: current.username,
|
|
129480
|
+
passwordHash,
|
|
129481
|
+
passwordSalt
|
|
129482
|
+
};
|
|
129483
|
+
try {
|
|
129484
|
+
await this.persist();
|
|
129485
|
+
} catch {
|
|
129486
|
+
}
|
|
129487
|
+
}
|
|
129488
|
+
get backupSettings() {
|
|
129489
|
+
return { ...DEFAULT_BACKUP_SETTINGS, ...this.settings.backup };
|
|
129490
|
+
}
|
|
129491
|
+
async setBackupSettings(settings) {
|
|
129492
|
+
this.settings.backup = { ...this.settings.backup, ...settings };
|
|
129493
|
+
await this.persist();
|
|
129494
|
+
}
|
|
129495
|
+
get recoverySettings() {
|
|
129496
|
+
return { ...DEFAULT_RECOVERY_SETTINGS, ...this.settings.recovery };
|
|
129497
|
+
}
|
|
129498
|
+
async setRecoverySettings(settings) {
|
|
129499
|
+
this.settings.recovery = { ...this.settings.recovery, ...settings };
|
|
129500
|
+
await this.persist();
|
|
129501
|
+
}
|
|
129502
|
+
async persist() {
|
|
129503
|
+
await this.storage.set(
|
|
129504
|
+
"data",
|
|
129505
|
+
this.settings
|
|
129506
|
+
);
|
|
129507
|
+
}
|
|
129508
|
+
};
|
|
129509
|
+
|
|
129510
|
+
// src/api/settings-api.ts
|
|
129511
|
+
function settingsApi(settingsStorage, bridgeService, envAuth) {
|
|
129340
129512
|
const router = Router2();
|
|
129341
129513
|
router.get("/auth", (_req, res) => {
|
|
129342
129514
|
if (envAuth) {
|
|
@@ -129393,6 +129565,31 @@ function settingsApi(settingsStorage, envAuth) {
|
|
|
129393
129565
|
res.json({ enabled: false, source: "none" });
|
|
129394
129566
|
}
|
|
129395
129567
|
);
|
|
129568
|
+
router.get("/recovery", (_req, res) => {
|
|
129569
|
+
res.json(settingsStorage.recoverySettings);
|
|
129570
|
+
});
|
|
129571
|
+
router.put(
|
|
129572
|
+
"/recovery",
|
|
129573
|
+
async (req, res) => {
|
|
129574
|
+
const next = { ...settingsStorage.recoverySettings };
|
|
129575
|
+
const { autoRecoveryEnabled, recoveryIntervalMs } = req.body;
|
|
129576
|
+
if (typeof autoRecoveryEnabled === "boolean") {
|
|
129577
|
+
next.autoRecoveryEnabled = autoRecoveryEnabled;
|
|
129578
|
+
}
|
|
129579
|
+
if (typeof recoveryIntervalMs === "number") {
|
|
129580
|
+
if (recoveryIntervalMs < MIN_RECOVERY_INTERVAL_MS || recoveryIntervalMs > MAX_RECOVERY_INTERVAL_MS) {
|
|
129581
|
+
res.status(400).json({
|
|
129582
|
+
error: `recoveryIntervalMs must be between ${MIN_RECOVERY_INTERVAL_MS} and ${MAX_RECOVERY_INTERVAL_MS}`
|
|
129583
|
+
});
|
|
129584
|
+
return;
|
|
129585
|
+
}
|
|
129586
|
+
next.recoveryIntervalMs = recoveryIntervalMs;
|
|
129587
|
+
}
|
|
129588
|
+
await settingsStorage.setRecoverySettings(next);
|
|
129589
|
+
bridgeService.applyRecoverySettings(next);
|
|
129590
|
+
res.json(next);
|
|
129591
|
+
}
|
|
129592
|
+
);
|
|
129396
129593
|
return router;
|
|
129397
129594
|
}
|
|
129398
129595
|
|
|
@@ -129824,7 +130021,10 @@ var WebApi = class extends Service {
|
|
|
129824
130021
|
).use("/bridges", bridgeExportApi(this.bridgeStorage)).use("/bridge-icons", bridgeIconApi(this.props.storageLocation)).use(
|
|
129825
130022
|
"/device-images",
|
|
129826
130023
|
deviceImageApi(this.props.storageLocation, this.haRegistry)
|
|
129827
|
-
).use("/entity-mappings", entityMappingApi(this.mappingStorage)).use("/mapping-profiles", mappingProfileApi(this.mappingStorage)).use("/lock-credentials", lockCredentialApi(this.lockCredentialStorage)).use(
|
|
130024
|
+
).use("/entity-mappings", entityMappingApi(this.mappingStorage)).use("/mapping-profiles", mappingProfileApi(this.mappingStorage)).use("/lock-credentials", lockCredentialApi(this.lockCredentialStorage)).use(
|
|
130025
|
+
"/settings",
|
|
130026
|
+
settingsApi(this.settingsStorage, this.bridgeService, this.props.auth)
|
|
130027
|
+
).use(
|
|
129828
130028
|
"/backup",
|
|
129829
130029
|
backupApi(
|
|
129830
130030
|
this.bridgeStorage,
|
|
@@ -130611,13 +130811,23 @@ var BridgeFactory = class extends Service {
|
|
|
130611
130811
|
init_esm();
|
|
130612
130812
|
init_service();
|
|
130613
130813
|
import crypto3 from "node:crypto";
|
|
130614
|
-
|
|
130814
|
+
function clampRecoveryInterval(ms) {
|
|
130815
|
+
if (!Number.isFinite(ms)) return 6e4;
|
|
130816
|
+
return Math.min(
|
|
130817
|
+
Math.max(ms, MIN_RECOVERY_INTERVAL_MS),
|
|
130818
|
+
MAX_RECOVERY_INTERVAL_MS
|
|
130819
|
+
);
|
|
130820
|
+
}
|
|
130821
|
+
var BridgeService = class _BridgeService extends Service {
|
|
130615
130822
|
constructor(bridgeStorage, bridgeFactory, props) {
|
|
130616
130823
|
super("BridgeService");
|
|
130617
130824
|
this.bridgeStorage = bridgeStorage;
|
|
130618
130825
|
this.bridgeFactory = bridgeFactory;
|
|
130619
130826
|
this.props = props;
|
|
130620
130827
|
this.autoRecoveryEnabled = props.autoRecovery ?? true;
|
|
130828
|
+
this.recoveryIntervalMs = clampRecoveryInterval(
|
|
130829
|
+
props.recoveryIntervalMs ?? 6e4
|
|
130830
|
+
);
|
|
130621
130831
|
}
|
|
130622
130832
|
bridgeStorage;
|
|
130623
130833
|
bridgeFactory;
|
|
@@ -130631,6 +130841,12 @@ var BridgeService = class extends Service {
|
|
|
130631
130841
|
// Set by the caller (e.g. start-handler) to broadcast updates via WebSocket.
|
|
130632
130842
|
onBridgeChanged;
|
|
130633
130843
|
recoveryInterval;
|
|
130844
|
+
recoveryIntervalMs = 6e4;
|
|
130845
|
+
recoveryInProgress = false;
|
|
130846
|
+
_recoveryHistory = [];
|
|
130847
|
+
lastReactiveRecovery = 0;
|
|
130848
|
+
static MAX_RECOVERY_HISTORY = 20;
|
|
130849
|
+
static REACTIVE_DEBOUNCE_MS = 5e3;
|
|
130634
130850
|
async initialize() {
|
|
130635
130851
|
for (const data of this.bridgeStorage.bridges) {
|
|
130636
130852
|
const normalized = this.normalizeBridgeData(data);
|
|
@@ -130653,24 +130869,80 @@ var BridgeService = class extends Service {
|
|
|
130653
130869
|
};
|
|
130654
130870
|
}
|
|
130655
130871
|
startAutoRecovery() {
|
|
130656
|
-
const intervalMs = this.props.recoveryIntervalMs ?? 6e4;
|
|
130657
130872
|
this.recoveryInterval = setInterval(() => {
|
|
130658
130873
|
this.attemptRecovery();
|
|
130659
|
-
},
|
|
130874
|
+
}, this.recoveryIntervalMs);
|
|
130875
|
+
}
|
|
130876
|
+
stopAutoRecovery() {
|
|
130877
|
+
if (this.recoveryInterval) {
|
|
130878
|
+
clearInterval(this.recoveryInterval);
|
|
130879
|
+
this.recoveryInterval = void 0;
|
|
130880
|
+
}
|
|
130881
|
+
}
|
|
130882
|
+
get recoverySettings() {
|
|
130883
|
+
return {
|
|
130884
|
+
autoRecoveryEnabled: this.autoRecoveryEnabled,
|
|
130885
|
+
recoveryIntervalMs: this.recoveryIntervalMs
|
|
130886
|
+
};
|
|
130887
|
+
}
|
|
130888
|
+
get recoveryHistory() {
|
|
130889
|
+
return [...this._recoveryHistory];
|
|
130890
|
+
}
|
|
130891
|
+
// Apply persisted recovery settings (at startup) or a change from the UI.
|
|
130892
|
+
applyRecoverySettings(settings) {
|
|
130893
|
+
this.autoRecoveryEnabled = settings.autoRecoveryEnabled;
|
|
130894
|
+
this.recoveryIntervalMs = clampRecoveryInterval(
|
|
130895
|
+
settings.recoveryIntervalMs
|
|
130896
|
+
);
|
|
130897
|
+
this.stopAutoRecovery();
|
|
130898
|
+
if (this.autoRecoveryEnabled) {
|
|
130899
|
+
this.startAutoRecovery();
|
|
130900
|
+
}
|
|
130901
|
+
}
|
|
130902
|
+
// Run a recovery pass right away, e.g. just after Home Assistant reconnects,
|
|
130903
|
+
// instead of waiting for the next interval. Debounced so a burst of reconnect
|
|
130904
|
+
// events only triggers one pass. Still only touches failed bridges.
|
|
130905
|
+
recoverFailedBridgesNow() {
|
|
130906
|
+
if (!this.autoRecoveryEnabled) return;
|
|
130907
|
+
const now = Date.now();
|
|
130908
|
+
if (now - this.lastReactiveRecovery < _BridgeService.REACTIVE_DEBOUNCE_MS) {
|
|
130909
|
+
return;
|
|
130910
|
+
}
|
|
130911
|
+
this.lastReactiveRecovery = now;
|
|
130912
|
+
void this.attemptRecovery();
|
|
130660
130913
|
}
|
|
130661
130914
|
async attemptRecovery() {
|
|
130915
|
+
if (this.recoveryInProgress) return;
|
|
130662
130916
|
const failedBridges = this.bridges.filter(
|
|
130663
130917
|
(b) => b.data.status === "failed"
|
|
130664
130918
|
);
|
|
130665
130919
|
if (failedBridges.length === 0) return;
|
|
130920
|
+
this.recoveryInProgress = true;
|
|
130666
130921
|
this.lastRecoveryAttempt = /* @__PURE__ */ new Date();
|
|
130667
|
-
|
|
130668
|
-
|
|
130669
|
-
|
|
130670
|
-
|
|
130671
|
-
|
|
130672
|
-
|
|
130922
|
+
try {
|
|
130923
|
+
for (const bridge of failedBridges) {
|
|
130924
|
+
try {
|
|
130925
|
+
await bridge.start();
|
|
130926
|
+
this.recoveryCount++;
|
|
130927
|
+
this.recordRecovery(bridge, "success");
|
|
130928
|
+
} catch (e) {
|
|
130929
|
+
this.log.warn(`Recovery attempt failed for bridge ${bridge.id}:`, e);
|
|
130930
|
+
this.recordRecovery(bridge, "failed");
|
|
130931
|
+
}
|
|
130673
130932
|
}
|
|
130933
|
+
} finally {
|
|
130934
|
+
this.recoveryInProgress = false;
|
|
130935
|
+
}
|
|
130936
|
+
}
|
|
130937
|
+
recordRecovery(bridge, outcome) {
|
|
130938
|
+
this._recoveryHistory.push({
|
|
130939
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
130940
|
+
bridgeId: bridge.id,
|
|
130941
|
+
bridgeName: bridge.data.name,
|
|
130942
|
+
outcome
|
|
130943
|
+
});
|
|
130944
|
+
if (this._recoveryHistory.length > _BridgeService.MAX_RECOVERY_HISTORY) {
|
|
130945
|
+
this._recoveryHistory.shift();
|
|
130674
130946
|
}
|
|
130675
130947
|
}
|
|
130676
130948
|
async restartBridge(bridgeId) {
|
|
@@ -130681,9 +130953,7 @@ var BridgeService = class extends Service {
|
|
|
130681
130953
|
return true;
|
|
130682
130954
|
}
|
|
130683
130955
|
async dispose() {
|
|
130684
|
-
|
|
130685
|
-
clearInterval(this.recoveryInterval);
|
|
130686
|
-
}
|
|
130956
|
+
this.stopAutoRecovery();
|
|
130687
130957
|
await Promise.all(this.bridges.map((bridge) => bridge.dispose()));
|
|
130688
130958
|
}
|
|
130689
130959
|
getNextAvailablePort(startPort = 5540) {
|
|
@@ -131259,161 +131529,6 @@ function mockDeviceId(entityId) {
|
|
|
131259
131529
|
return `e__${hash2}`;
|
|
131260
131530
|
}
|
|
131261
131531
|
|
|
131262
|
-
// src/services/storage/app-settings-storage.ts
|
|
131263
|
-
init_service();
|
|
131264
|
-
import { randomBytes as randomBytes2, scryptSync, timingSafeEqual } from "node:crypto";
|
|
131265
|
-
var SCRYPT_KEYLEN = 64;
|
|
131266
|
-
var SCRYPT_COST_N = 16384;
|
|
131267
|
-
var SCRYPT_BLOCK_R = 8;
|
|
131268
|
-
var SCRYPT_PARALLEL_P = 1;
|
|
131269
|
-
var SALT_BYTES = 16;
|
|
131270
|
-
var DEFAULT_BACKUP_SETTINGS = {
|
|
131271
|
-
autoBackup: true,
|
|
131272
|
-
backupRetentionCount: 5
|
|
131273
|
-
};
|
|
131274
|
-
function hashPassword(plain2) {
|
|
131275
|
-
const salt = randomBytes2(SALT_BYTES);
|
|
131276
|
-
const derived = scryptSync(plain2, salt, SCRYPT_KEYLEN, {
|
|
131277
|
-
N: SCRYPT_COST_N,
|
|
131278
|
-
r: SCRYPT_BLOCK_R,
|
|
131279
|
-
p: SCRYPT_PARALLEL_P
|
|
131280
|
-
});
|
|
131281
|
-
return {
|
|
131282
|
-
passwordHash: derived.toString("hex"),
|
|
131283
|
-
passwordSalt: salt.toString("hex")
|
|
131284
|
-
};
|
|
131285
|
-
}
|
|
131286
|
-
function verifyPasswordHash(plain2, passwordHash, passwordSalt) {
|
|
131287
|
-
let expected;
|
|
131288
|
-
let salt;
|
|
131289
|
-
try {
|
|
131290
|
-
expected = Buffer.from(passwordHash, "hex");
|
|
131291
|
-
salt = Buffer.from(passwordSalt, "hex");
|
|
131292
|
-
} catch {
|
|
131293
|
-
return false;
|
|
131294
|
-
}
|
|
131295
|
-
if (expected.length !== SCRYPT_KEYLEN || salt.length === 0) {
|
|
131296
|
-
return false;
|
|
131297
|
-
}
|
|
131298
|
-
const derived = scryptSync(plain2, salt, SCRYPT_KEYLEN, {
|
|
131299
|
-
N: SCRYPT_COST_N,
|
|
131300
|
-
r: SCRYPT_BLOCK_R,
|
|
131301
|
-
p: SCRYPT_PARALLEL_P
|
|
131302
|
-
});
|
|
131303
|
-
return timingSafeEqual(derived, expected);
|
|
131304
|
-
}
|
|
131305
|
-
function constantTimeStringEquals(a, b) {
|
|
131306
|
-
const bufA = Buffer.from(a);
|
|
131307
|
-
const bufB = Buffer.from(b);
|
|
131308
|
-
if (bufA.length !== bufB.length) {
|
|
131309
|
-
timingSafeEqual(bufA, bufA);
|
|
131310
|
-
return false;
|
|
131311
|
-
}
|
|
131312
|
-
return timingSafeEqual(bufA, bufB);
|
|
131313
|
-
}
|
|
131314
|
-
var AppSettingsStorage = class extends Service {
|
|
131315
|
-
constructor(appStorage) {
|
|
131316
|
-
super("AppSettingsStorage");
|
|
131317
|
-
this.appStorage = appStorage;
|
|
131318
|
-
}
|
|
131319
|
-
appStorage;
|
|
131320
|
-
storage;
|
|
131321
|
-
settings = {};
|
|
131322
|
-
// Caches the last plaintext password that was successfully verified
|
|
131323
|
-
// against the stored hash, keyed by username. This lets repeated basic-auth
|
|
131324
|
-
// calls avoid re-running scrypt on every HTTP request without persisting
|
|
131325
|
-
// the plaintext to disk.
|
|
131326
|
-
verifiedPassword = null;
|
|
131327
|
-
async initialize() {
|
|
131328
|
-
this.storage = this.appStorage.createContext("settings");
|
|
131329
|
-
const stored = await this.storage.get(
|
|
131330
|
-
"data",
|
|
131331
|
-
{}
|
|
131332
|
-
);
|
|
131333
|
-
this.settings = stored ?? {};
|
|
131334
|
-
}
|
|
131335
|
-
get auth() {
|
|
131336
|
-
if (!this.settings.auth) {
|
|
131337
|
-
return void 0;
|
|
131338
|
-
}
|
|
131339
|
-
return { username: this.settings.auth.username };
|
|
131340
|
-
}
|
|
131341
|
-
async setAuth(credentials) {
|
|
131342
|
-
if (!credentials) {
|
|
131343
|
-
this.settings.auth = void 0;
|
|
131344
|
-
this.verifiedPassword = null;
|
|
131345
|
-
} else {
|
|
131346
|
-
const { passwordHash, passwordSalt } = hashPassword(credentials.password);
|
|
131347
|
-
this.settings.auth = {
|
|
131348
|
-
username: credentials.username,
|
|
131349
|
-
passwordHash,
|
|
131350
|
-
passwordSalt
|
|
131351
|
-
};
|
|
131352
|
-
this.verifiedPassword = {
|
|
131353
|
-
username: credentials.username,
|
|
131354
|
-
password: credentials.password
|
|
131355
|
-
};
|
|
131356
|
-
}
|
|
131357
|
-
await this.persist();
|
|
131358
|
-
}
|
|
131359
|
-
verifyAuth(username, password) {
|
|
131360
|
-
const stored = this.settings.auth;
|
|
131361
|
-
if (!stored) {
|
|
131362
|
-
return false;
|
|
131363
|
-
}
|
|
131364
|
-
if (!constantTimeStringEquals(username, stored.username)) {
|
|
131365
|
-
return false;
|
|
131366
|
-
}
|
|
131367
|
-
if (this.verifiedPassword && this.verifiedPassword.username === stored.username && constantTimeStringEquals(password, this.verifiedPassword.password)) {
|
|
131368
|
-
return true;
|
|
131369
|
-
}
|
|
131370
|
-
if (stored.password !== void 0) {
|
|
131371
|
-
const matches = constantTimeStringEquals(password, stored.password);
|
|
131372
|
-
if (matches) {
|
|
131373
|
-
this.verifiedPassword = { username: stored.username, password };
|
|
131374
|
-
void this.migrateLegacyPlaintext(password);
|
|
131375
|
-
}
|
|
131376
|
-
return matches;
|
|
131377
|
-
}
|
|
131378
|
-
if (stored.passwordHash && stored.passwordSalt) {
|
|
131379
|
-
if (verifyPasswordHash(password, stored.passwordHash, stored.passwordSalt)) {
|
|
131380
|
-
this.verifiedPassword = { username: stored.username, password };
|
|
131381
|
-
return true;
|
|
131382
|
-
}
|
|
131383
|
-
}
|
|
131384
|
-
return false;
|
|
131385
|
-
}
|
|
131386
|
-
async migrateLegacyPlaintext(plain2) {
|
|
131387
|
-
const current = this.settings.auth;
|
|
131388
|
-
if (!current || current.password === void 0) {
|
|
131389
|
-
return;
|
|
131390
|
-
}
|
|
131391
|
-
const { passwordHash, passwordSalt } = hashPassword(plain2);
|
|
131392
|
-
this.settings.auth = {
|
|
131393
|
-
username: current.username,
|
|
131394
|
-
passwordHash,
|
|
131395
|
-
passwordSalt
|
|
131396
|
-
};
|
|
131397
|
-
try {
|
|
131398
|
-
await this.persist();
|
|
131399
|
-
} catch {
|
|
131400
|
-
}
|
|
131401
|
-
}
|
|
131402
|
-
get backupSettings() {
|
|
131403
|
-
return { ...DEFAULT_BACKUP_SETTINGS, ...this.settings.backup };
|
|
131404
|
-
}
|
|
131405
|
-
async setBackupSettings(settings) {
|
|
131406
|
-
this.settings.backup = { ...this.settings.backup, ...settings };
|
|
131407
|
-
await this.persist();
|
|
131408
|
-
}
|
|
131409
|
-
async persist() {
|
|
131410
|
-
await this.storage.set(
|
|
131411
|
-
"data",
|
|
131412
|
-
this.settings
|
|
131413
|
-
);
|
|
131414
|
-
}
|
|
131415
|
-
};
|
|
131416
|
-
|
|
131417
131532
|
// src/services/storage/app-storage.ts
|
|
131418
131533
|
init_service();
|
|
131419
131534
|
var AppStorage = class extends Service {
|
|
@@ -147233,6 +147348,7 @@ init_esm7();
|
|
|
147233
147348
|
import crypto4 from "node:crypto";
|
|
147234
147349
|
|
|
147235
147350
|
// src/services/bridges/bridge-data-provider.ts
|
|
147351
|
+
init_dist();
|
|
147236
147352
|
init_service();
|
|
147237
147353
|
import { values as values2 } from "lodash-es";
|
|
147238
147354
|
var BridgeDataProvider = class extends Service {
|
|
@@ -147289,8 +147405,13 @@ var BridgeDataProvider = class extends Service {
|
|
|
147289
147405
|
/**
|
|
147290
147406
|
* @deprecated
|
|
147291
147407
|
*/
|
|
147292
|
-
withMetadata(status3, serverNode, deviceCount, failedEntities = []) {
|
|
147408
|
+
withMetadata(status3, serverNode, deviceCount, failedEntities = [], exposedDeviceTypes = []) {
|
|
147293
147409
|
const commissioning = serverNode.state.commissioning;
|
|
147410
|
+
const fabrics = commissioning ? values2(commissioning.fabrics) : [];
|
|
147411
|
+
const controllerWarnings = controllerWarningsForFabrics(
|
|
147412
|
+
fabrics,
|
|
147413
|
+
exposedDeviceTypes
|
|
147414
|
+
);
|
|
147294
147415
|
return {
|
|
147295
147416
|
id: this.id,
|
|
147296
147417
|
name: this.name,
|
|
@@ -147311,7 +147432,7 @@ var BridgeDataProvider = class extends Service {
|
|
|
147311
147432
|
discriminator: commissioning.discriminator,
|
|
147312
147433
|
manualPairingCode: commissioning.pairingCodes.manualPairingCode,
|
|
147313
147434
|
qrPairingCode: commissioning.pairingCodes.qrPairingCode,
|
|
147314
|
-
fabrics:
|
|
147435
|
+
fabrics: fabrics.map((fabric) => ({
|
|
147315
147436
|
fabricIndex: fabric.fabricIndex,
|
|
147316
147437
|
fabricId: Number(fabric.fabricId),
|
|
147317
147438
|
nodeId: Number(fabric.nodeId),
|
|
@@ -147321,7 +147442,8 @@ var BridgeDataProvider = class extends Service {
|
|
|
147321
147442
|
}))
|
|
147322
147443
|
} : void 0,
|
|
147323
147444
|
deviceCount,
|
|
147324
|
-
failedEntities: failedEntities.length > 0 ? failedEntities : void 0
|
|
147445
|
+
failedEntities: failedEntities.length > 0 ? failedEntities : void 0,
|
|
147446
|
+
controllerWarnings: controllerWarnings.length > 0 ? controllerWarnings : void 0
|
|
147325
147447
|
};
|
|
147326
147448
|
}
|
|
147327
147449
|
};
|
|
@@ -148500,7 +148622,8 @@ var Bridge = class {
|
|
|
148500
148622
|
this.status,
|
|
148501
148623
|
this.server,
|
|
148502
148624
|
this.aggregator.parts.size,
|
|
148503
|
-
this.endpointManager.failedEntities
|
|
148625
|
+
this.endpointManager.failedEntities,
|
|
148626
|
+
this.getExposedDeviceTypes()
|
|
148504
148627
|
);
|
|
148505
148628
|
}
|
|
148506
148629
|
getSessionInfo() {
|
|
@@ -162992,7 +163115,11 @@ var EntityIsolationServiceImpl = class {
|
|
|
162992
163115
|
return true;
|
|
162993
163116
|
}
|
|
162994
163117
|
const reason = `${classification}. Entity isolated to protect bridge stability.`;
|
|
162995
|
-
this.isolatedEntities.set(key, {
|
|
163118
|
+
this.isolatedEntities.set(key, {
|
|
163119
|
+
entityId: entityName,
|
|
163120
|
+
reason,
|
|
163121
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
163122
|
+
});
|
|
162996
163123
|
logger231.warn(
|
|
162997
163124
|
`Isolating entity "${entityName}" from bridge ${bridgeId} due to: ${reason}`
|
|
162998
163125
|
);
|
|
@@ -163080,6 +163207,13 @@ var BridgeEndpointManager = class extends Service {
|
|
|
163080
163207
|
const isolated = EntityIsolationService.getIsolatedEntities(this.bridgeId);
|
|
163081
163208
|
return [...this._failedEntities, ...isolated];
|
|
163082
163209
|
}
|
|
163210
|
+
addFailedEntity(entityId, reason) {
|
|
163211
|
+
this._failedEntities.push({
|
|
163212
|
+
entityId,
|
|
163213
|
+
reason,
|
|
163214
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
163215
|
+
});
|
|
163216
|
+
}
|
|
163083
163217
|
wirePluginCallbacks() {
|
|
163084
163218
|
if (!this.pluginManager) return;
|
|
163085
163219
|
this.pluginManager.onDeviceRegistered = async (pluginName, device) => {
|
|
@@ -163490,10 +163624,10 @@ var BridgeEndpointManager = class extends Service {
|
|
|
163490
163624
|
}
|
|
163491
163625
|
if (memoryLimitReached) {
|
|
163492
163626
|
if (!existingEndpoints.some((e) => e.entityId === entityId)) {
|
|
163493
|
-
this.
|
|
163627
|
+
this.addFailedEntity(
|
|
163494
163628
|
entityId,
|
|
163495
|
-
|
|
163496
|
-
|
|
163629
|
+
"Skipped due to memory pressure, reduce entities or increase heap size"
|
|
163630
|
+
);
|
|
163497
163631
|
}
|
|
163498
163632
|
continue;
|
|
163499
163633
|
}
|
|
@@ -163511,7 +163645,7 @@ var BridgeEndpointManager = class extends Service {
|
|
|
163511
163645
|
if (entityId.length > MAX_ENTITY_ID_LENGTH) {
|
|
163512
163646
|
const reason = `Entity ID too long (${entityId.length} chars, max ${MAX_ENTITY_ID_LENGTH}). This would cause filesystem errors.`;
|
|
163513
163647
|
this.log.warn(`Skipping entity: ${entityId}. Reason: ${reason}`);
|
|
163514
|
-
this.
|
|
163648
|
+
this.addFailedEntity(entityId, reason);
|
|
163515
163649
|
continue;
|
|
163516
163650
|
}
|
|
163517
163651
|
let endpoint = existingEndpoints.find((e) => e.entityId === entityId);
|
|
@@ -163527,7 +163661,7 @@ var BridgeEndpointManager = class extends Service {
|
|
|
163527
163661
|
} catch (e) {
|
|
163528
163662
|
const reason = this.extractErrorReason(e);
|
|
163529
163663
|
this.log.warn(`Failed to create device ${entityId}: ${reason}`);
|
|
163530
|
-
this.
|
|
163664
|
+
this.addFailedEntity(entityId, reason);
|
|
163531
163665
|
continue;
|
|
163532
163666
|
}
|
|
163533
163667
|
if (endpoint) {
|
|
@@ -163543,10 +163677,7 @@ var BridgeEndpointManager = class extends Service {
|
|
|
163543
163677
|
`Failed to add endpoint for ${entityId}: ${errorMessage}`
|
|
163544
163678
|
);
|
|
163545
163679
|
this.logDetailedError(entityId, e);
|
|
163546
|
-
this.
|
|
163547
|
-
entityId,
|
|
163548
|
-
reason: this.extractErrorReason(e)
|
|
163549
|
-
});
|
|
163680
|
+
this.addFailedEntity(entityId, this.extractErrorReason(e));
|
|
163550
163681
|
}
|
|
163551
163682
|
}
|
|
163552
163683
|
}
|
|
@@ -164427,7 +164558,8 @@ var ServerModeBridge = class {
|
|
|
164427
164558
|
this.status,
|
|
164428
164559
|
this.server,
|
|
164429
164560
|
this.endpointManager.devices.length,
|
|
164430
|
-
this.endpointManager.failedEntities
|
|
164561
|
+
this.endpointManager.failedEntities,
|
|
164562
|
+
this.getExposedDeviceTypes()
|
|
164431
164563
|
);
|
|
164432
164564
|
}
|
|
164433
164565
|
/**
|
|
@@ -165991,6 +166123,14 @@ async function startHandler(startOptions, webUiDist) {
|
|
|
165991
166123
|
bridgeService.onBridgeChanged = (bridgeId) => {
|
|
165992
166124
|
webApi.websocket.broadcastBridgeUpdate(bridgeId);
|
|
165993
166125
|
};
|
|
166126
|
+
const [haClient, settingsStorage] = await Promise.all([
|
|
166127
|
+
appEnvironment.load(HomeAssistantClient),
|
|
166128
|
+
appEnvironment.load(AppSettingsStorage)
|
|
166129
|
+
]);
|
|
166130
|
+
bridgeService.applyRecoverySettings(settingsStorage.recoverySettings);
|
|
166131
|
+
haClient.connection.addEventListener("ready", () => {
|
|
166132
|
+
bridgeService.recoverFailedBridgesNow();
|
|
166133
|
+
});
|
|
165994
166134
|
let shuttingDown = false;
|
|
165995
166135
|
const gracefulShutdown = async (signal) => {
|
|
165996
166136
|
if (shuttingDown) return;
|