smart-home-engine 1.53.3 → 2.0.0
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/web/assets/{index-BsO7OQRT.css → index-Bvl2uECO.css} +1 -1
- package/dist/web/assets/index-DLP7FxoS.js +305 -0
- package/dist/web/assets/{tsMode-CWVhEJ2t.js → tsMode-BJVHqGQB.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/src/config.js +21 -1
- package/src/index.js +107 -9
- package/src/lib/crash-record.js +103 -0
- package/src/lib/secrets.js +28 -1
- package/src/matter/bindings.js +291 -0
- package/src/matter/bridge-templates.js +261 -0
- package/src/matter/controller.js +1391 -14
- package/src/matter/dcl.js +252 -0
- package/src/matter/device-health.js +337 -0
- package/src/matter/groups.js +192 -0
- package/src/matter/mqtt-bridge.js +513 -0
- package/src/matter/thread-datasets.js +285 -0
- package/src/matter/thread-health.js +278 -0
- package/src/matter/thread-mesh.js +496 -0
- package/src/matter/thread.js +267 -0
- package/src/web/ai-tools.js +369 -0
- package/src/web/matter-api.js +560 -0
- package/src/web/prompts/tools.md +2 -1
- package/src/web/server.js +4 -1
- package/dist/web/assets/index-D2gZU7ce.js +0 -298
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Device information from the CSA Distributed Compliance Ledger (roadmap M25): vendor names for
|
|
5
|
+
* vendor IDs and product details (name, label, part number, product/manual/support URLs) for
|
|
6
|
+
* vendor/product IDs — what a device reports about itself is often a placeholder.
|
|
7
|
+
*
|
|
8
|
+
* Built on matter.js: `DclVendorInfoService` keeps the vendor list in the Matter storage (seeded
|
|
9
|
+
* from the `@matter/dcl-data` snapshot so names are there without network, refreshed from the
|
|
10
|
+
* ledger daily) and fetches product models on demand. she caches the product models itself
|
|
11
|
+
* (`dcl-products.json` next to the Matter storage, 30 days) so the device page works offline.
|
|
12
|
+
*
|
|
13
|
+
* `offline: true` (config `matterDclOffline`) never touches the network: vendors come from the
|
|
14
|
+
* snapshot, products from the cache only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const PRODUCT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
21
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
22
|
+
|
|
23
|
+
let _log = null;
|
|
24
|
+
let _service = null;
|
|
25
|
+
let _offline = false;
|
|
26
|
+
let _seedVendors = new Map();
|
|
27
|
+
let _products = new Map(); // "vid:pid" → { info: object|null, fetchedAt: number }
|
|
28
|
+
let _productsFile = null;
|
|
29
|
+
let _inflight = new Map();
|
|
30
|
+
|
|
31
|
+
function _vendorFromSeed(e) {
|
|
32
|
+
return {
|
|
33
|
+
vendorId: Number(e.vendorId),
|
|
34
|
+
vendorName: e.vendorName ?? null,
|
|
35
|
+
companyLegalName: e.companyLegalName ?? null,
|
|
36
|
+
companyPreferredName: e.companyPreferredName ?? null,
|
|
37
|
+
vendorLandingPageUrl: e.vendorLandingPageUrl ?? e.vendorLandingPageURL ?? null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The vendor snapshot from `@matter/dcl-data` (an ESM package — `require()` loads it on Node 20.19,
|
|
43
|
+
* 22.12 and later; older Nodes get no snapshot and a warning): a SeedSource, or null.
|
|
44
|
+
*/
|
|
45
|
+
function _loadSeed() {
|
|
46
|
+
try {
|
|
47
|
+
const mod = require('@matter/dcl-data/node');
|
|
48
|
+
return mod.vendors();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
_log?.warn(`matter: DCL vendor snapshot not available: ${String(err.message).split('\n')[0]}`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _loadProducts() {
|
|
56
|
+
_products = new Map();
|
|
57
|
+
if (!_productsFile) return;
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(fs.readFileSync(_productsFile, 'utf8'));
|
|
60
|
+
for (const [k, v] of Object.entries(raw)) if (v && typeof v === 'object') _products.set(k, v);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err.code !== 'ENOENT') _log?.warn(`matter: cannot read ${_productsFile}: ${err.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _saveProducts() {
|
|
67
|
+
if (!_productsFile) return;
|
|
68
|
+
try {
|
|
69
|
+
const tmp = _productsFile + '.tmp';
|
|
70
|
+
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(_products), null, 1));
|
|
71
|
+
fs.renameSync(tmp, _productsFile);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
_log?.warn(`matter: cannot write ${_productsFile}: ${err.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Start the DCL layer. Never throws — without the ledger the device page shows what the device
|
|
79
|
+
* reports.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ env: object, storageDir: string, log: object, offline?: boolean }} opts
|
|
82
|
+
* env: the matter.js Environment of the controller node (its StorageService holds the vendor list)
|
|
83
|
+
*/
|
|
84
|
+
async function start({ env, storageDir, log, offline = false }) {
|
|
85
|
+
_log = log;
|
|
86
|
+
_offline = !!offline;
|
|
87
|
+
_productsFile = storageDir ? path.join(storageDir, 'dcl-products.json') : null;
|
|
88
|
+
_loadProducts();
|
|
89
|
+
const seed = _loadSeed();
|
|
90
|
+
if (seed) {
|
|
91
|
+
try {
|
|
92
|
+
for await (const e of seed.entries) _seedVendors.set(Number(e.vendorId), _vendorFromSeed(e));
|
|
93
|
+
_log?.info(`matter: DCL vendor snapshot loaded (${_seedVendors.size} vendors, built ${seed.builtAt})`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
_log?.warn(`matter: reading the DCL vendor snapshot failed: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (_offline) {
|
|
99
|
+
_log?.info('matter: DCL access is off (matterDclOffline) — vendor names from the snapshot, products from the cache');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!env) return;
|
|
103
|
+
try {
|
|
104
|
+
const { DclVendorInfoService } = require('@matter/protocol');
|
|
105
|
+
const { Days } = require('@matter/general');
|
|
106
|
+
const service = new DclVendorInfoService(env, {
|
|
107
|
+
updateInterval: Days.one,
|
|
108
|
+
...(seed ? { seed: { vendors: seed } } : {}),
|
|
109
|
+
});
|
|
110
|
+
// construction fetches from the ledger — do not hold the controller start for it
|
|
111
|
+
service.construction.then(
|
|
112
|
+
() => {
|
|
113
|
+
_service = service;
|
|
114
|
+
_log?.info(`matter: DCL vendor information ready (${service.vendors.size} vendors)`);
|
|
115
|
+
},
|
|
116
|
+
(err) => _log?.warn(`matter: DCL vendor information service failed: ${err.message}`),
|
|
117
|
+
);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
_log?.warn(`matter: DCL vendor information service not started: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function close() {
|
|
124
|
+
const s = _service;
|
|
125
|
+
_service = null;
|
|
126
|
+
_inflight = new Map();
|
|
127
|
+
if (s) {
|
|
128
|
+
try {
|
|
129
|
+
await s.close();
|
|
130
|
+
} catch {
|
|
131
|
+
/* closing */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Vendor details for a vendor ID from the live service, else the snapshot; null when unknown. */
|
|
137
|
+
function vendor(vendorId) {
|
|
138
|
+
const id = Number(vendorId);
|
|
139
|
+
if (!Number.isFinite(id)) return null;
|
|
140
|
+
if (_service) {
|
|
141
|
+
try {
|
|
142
|
+
const v = _service.infoFor(id);
|
|
143
|
+
if (v) return _vendorFromSeed(v);
|
|
144
|
+
} catch {
|
|
145
|
+
/* not constructed yet */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return _seedVendors.get(id) ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The name to show for a vendor ID: the preferred company name, the vendor name, or null. */
|
|
152
|
+
function vendorName(vendorId) {
|
|
153
|
+
const v = vendor(vendorId);
|
|
154
|
+
return v ? v.companyPreferredName || v.vendorName || v.companyLegalName || null : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _withTimeout(promise, ms) {
|
|
158
|
+
let t;
|
|
159
|
+
return Promise.race([promise, new Promise((_r, reject) => (t = setTimeout(() => reject(new Error(`DCL request timed out after ${ms} ms`)), ms)))]).finally(() =>
|
|
160
|
+
clearTimeout(t),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Product details for vendor/product ID: from the cache (30 days), else from the ledger (and
|
|
166
|
+
* cached, also a "not in the ledger" answer). Offline: the cache only. Returns null when unknown.
|
|
167
|
+
*/
|
|
168
|
+
async function product(vendorId, productId) {
|
|
169
|
+
const vid = Number(vendorId);
|
|
170
|
+
const pid = Number(productId);
|
|
171
|
+
if (!Number.isFinite(vid) || !Number.isFinite(pid)) return null;
|
|
172
|
+
const key = `${vid}:${pid}`;
|
|
173
|
+
const cached = _products.get(key);
|
|
174
|
+
const fresh = cached && Date.now() - (cached.fetchedAt ?? 0) < PRODUCT_TTL_MS;
|
|
175
|
+
if (fresh || _offline || !_service) return cached?.info ?? null;
|
|
176
|
+
if (_inflight.has(key)) return _inflight.get(key);
|
|
177
|
+
const p = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
const info = await _withTimeout(_service.productInfoFor(vid, pid), FETCH_TIMEOUT_MS);
|
|
180
|
+
const entry = {
|
|
181
|
+
info: info
|
|
182
|
+
? {
|
|
183
|
+
productName: info.productName ?? null,
|
|
184
|
+
productLabel: info.productLabel ?? null,
|
|
185
|
+
partNumber: info.partNumber ?? null,
|
|
186
|
+
deviceTypeId: info.deviceTypeID === undefined ? null : Number(info.deviceTypeID),
|
|
187
|
+
productUrl: info.productUrl ?? null,
|
|
188
|
+
userManualUrl: info.userManualUrl ?? null,
|
|
189
|
+
supportUrl: info.supportUrl ?? null,
|
|
190
|
+
commissioningFallbackUrl: info.commissioningFallbackUrl ?? null,
|
|
191
|
+
}
|
|
192
|
+
: null,
|
|
193
|
+
fetchedAt: Date.now(),
|
|
194
|
+
};
|
|
195
|
+
_products.set(key, entry);
|
|
196
|
+
_saveProducts();
|
|
197
|
+
return entry.info;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
_log?.debug(`matter: DCL product lookup ${key} failed: ${err.message}`);
|
|
200
|
+
return cached?.info ?? null;
|
|
201
|
+
} finally {
|
|
202
|
+
_inflight.delete(key);
|
|
203
|
+
}
|
|
204
|
+
})();
|
|
205
|
+
_inflight.set(key, p);
|
|
206
|
+
return p;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Everything the device page shows under "Product (CSA ledger)" for a node's basic information.
|
|
211
|
+
* @param {{ vendorId?: number, productId?: number }} basic basicInformation of the root endpoint
|
|
212
|
+
*/
|
|
213
|
+
async function deviceInfo(basic) {
|
|
214
|
+
const vid = basic?.vendorId;
|
|
215
|
+
const pid = basic?.productId;
|
|
216
|
+
const v = vendor(vid);
|
|
217
|
+
const p = await product(vid, pid);
|
|
218
|
+
const cached = Number.isFinite(Number(vid)) && Number.isFinite(Number(pid)) ? _products.get(`${Number(vid)}:${Number(pid)}`) : undefined;
|
|
219
|
+
return {
|
|
220
|
+
vendorId: vid === undefined ? null : Number(vid),
|
|
221
|
+
productId: pid === undefined ? null : Number(pid),
|
|
222
|
+
vendor: v,
|
|
223
|
+
vendorName: vendorName(vid),
|
|
224
|
+
product: p,
|
|
225
|
+
productStatus: p ? 'found' : cached ? 'not in the ledger' : _offline ? 'offline — not cached' : _service ? 'not in the ledger' : 'ledger not reachable yet',
|
|
226
|
+
fetchedAt: cached?.fetchedAt ?? null,
|
|
227
|
+
offline: _offline,
|
|
228
|
+
live: !!_service,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Test hook. */
|
|
233
|
+
function _reset() {
|
|
234
|
+
_service = null;
|
|
235
|
+
_offline = false;
|
|
236
|
+
_seedVendors = new Map();
|
|
237
|
+
_products = new Map();
|
|
238
|
+
_productsFile = null;
|
|
239
|
+
_inflight = new Map();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Test hook: inject a fake service / seed. */
|
|
243
|
+
function _inject({ service, seedVendors, productsFile } = {}) {
|
|
244
|
+
if (service !== undefined) _service = service;
|
|
245
|
+
if (seedVendors) _seedVendors = new Map(seedVendors.map((v) => [Number(v.vendorId), _vendorFromSeed(v)]));
|
|
246
|
+
if (productsFile !== undefined) {
|
|
247
|
+
_productsFile = productsFile;
|
|
248
|
+
_loadProducts();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { start, close, vendor, vendorName, product, deviceInfo, _reset, _inject, PRODUCT_TTL_MS };
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matter device health (roadmap M21): per node the battery (Power Source cluster: percent, charge
|
|
5
|
+
* level, charge state), when it was last heard from, its online/offline history and its Thread
|
|
6
|
+
* role changes — kept in `device-health.json` in the Matter storage across restarts; alerts for a
|
|
7
|
+
* low battery and for a long offline period, logged at warn, published retained on
|
|
8
|
+
* `<name>/status/matter/health` as `{ val: 'ok' | 'warning', ts, lc, alerts }`, shown on the device page.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const HISTORY_MAX = 50;
|
|
15
|
+
const OFFLINE_ALERT_MS = 60 * 60 * 1000;
|
|
16
|
+
const LOW_BATTERY_PERCENT = 20;
|
|
17
|
+
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
let _log = null;
|
|
20
|
+
let _broadcast = null;
|
|
21
|
+
let _publish = null;
|
|
22
|
+
let _topic = null;
|
|
23
|
+
let _controller = null;
|
|
24
|
+
let _file = null;
|
|
25
|
+
let _timer = null;
|
|
26
|
+
let _listening = false;
|
|
27
|
+
let _lastVal = null;
|
|
28
|
+
let _lc = null;
|
|
29
|
+
/** nodeId → { lastSeen, online, since, offlineHistory: [{ online, ts }], battery, roles: [{ role, ts }] } */
|
|
30
|
+
let _nodes = new Map();
|
|
31
|
+
const _active = new Map();
|
|
32
|
+
|
|
33
|
+
function _ctrl() {
|
|
34
|
+
return _controller ?? require('./controller');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _load() {
|
|
38
|
+
_nodes = new Map();
|
|
39
|
+
if (!_file) return;
|
|
40
|
+
try {
|
|
41
|
+
const raw = JSON.parse(fs.readFileSync(_file, 'utf8'));
|
|
42
|
+
for (const [k, v] of Object.entries(raw.nodes ?? {})) if (v && typeof v === 'object') _nodes.set(k, v);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code !== 'ENOENT') _log?.warn(`matter health: cannot read ${_file}: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let _saveTimer = null;
|
|
49
|
+
function _save() {
|
|
50
|
+
if (!_file || _saveTimer) return;
|
|
51
|
+
_saveTimer = setTimeout(() => {
|
|
52
|
+
_saveTimer = null;
|
|
53
|
+
try {
|
|
54
|
+
fs.writeFileSync(_file + '.tmp', JSON.stringify({ v: 1, nodes: Object.fromEntries(_nodes) }));
|
|
55
|
+
fs.renameSync(_file + '.tmp', _file);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
_log?.warn(`matter health: cannot write ${_file}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
}, 2000);
|
|
60
|
+
if (typeof _saveTimer.unref === 'function') _saveTimer.unref();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _entry(nodeId) {
|
|
64
|
+
let e = _nodes.get(String(nodeId));
|
|
65
|
+
if (!e) {
|
|
66
|
+
e = { lastSeen: null, online: null, since: null, offlineHistory: [], battery: null, roles: [] };
|
|
67
|
+
_nodes.set(String(nodeId), e);
|
|
68
|
+
}
|
|
69
|
+
return e;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const CHARGE_LEVELS = ['ok', 'warning', 'critical'];
|
|
73
|
+
const CHARGE_STATES = ['unknown', 'charging', 'full', 'not charging'];
|
|
74
|
+
|
|
75
|
+
/** Fold a Power Source attribute into the battery record (pure on the record). */
|
|
76
|
+
function applyBatteryAttr(battery, attrName, value) {
|
|
77
|
+
const b = battery ?? { percent: null, level: null, state: null, voltageMv: null, ts: null };
|
|
78
|
+
switch (attrName) {
|
|
79
|
+
case 'batPercentRemaining':
|
|
80
|
+
b.percent = value === null || value === undefined ? null : Math.round(Number(value) / 2); // half-percent units
|
|
81
|
+
break;
|
|
82
|
+
case 'batChargeLevel':
|
|
83
|
+
b.level = value === null || value === undefined ? null : (CHARGE_LEVELS[Number(value)] ?? String(value));
|
|
84
|
+
break;
|
|
85
|
+
case 'batChargeState':
|
|
86
|
+
b.state = value === null || value === undefined ? null : (CHARGE_STATES[Number(value)] ?? String(value));
|
|
87
|
+
break;
|
|
88
|
+
case 'batVoltage':
|
|
89
|
+
b.voltageMv = value === null || value === undefined ? null : Number(value);
|
|
90
|
+
break;
|
|
91
|
+
default:
|
|
92
|
+
return battery;
|
|
93
|
+
}
|
|
94
|
+
b.ts = Date.now();
|
|
95
|
+
return b;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _seen(nodeId, ts = Date.now()) {
|
|
99
|
+
const e = _entry(nodeId);
|
|
100
|
+
e.lastSeen = ts;
|
|
101
|
+
_save();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _onAttr(ev) {
|
|
105
|
+
_seen(ev.nodeId, ev.ts);
|
|
106
|
+
if (ev.clusterName === 'powerSource') {
|
|
107
|
+
const e = _entry(ev.nodeId);
|
|
108
|
+
const next = applyBatteryAttr(e.battery, ev.attrName, ev.value);
|
|
109
|
+
if (next !== e.battery) {
|
|
110
|
+
e.battery = next;
|
|
111
|
+
_save();
|
|
112
|
+
}
|
|
113
|
+
} else if (ev.clusterName === 'threadNetworkDiagnostics' && ev.attrName === 'routingRole') {
|
|
114
|
+
const e = _entry(ev.nodeId);
|
|
115
|
+
const last = e.roles[e.roles.length - 1];
|
|
116
|
+
if (!last || last.role !== Number(ev.value)) {
|
|
117
|
+
e.roles.push({ role: Number(ev.value), ts: ev.ts ?? Date.now() });
|
|
118
|
+
if (e.roles.length > HISTORY_MAX) e.roles.splice(0, e.roles.length - HISTORY_MAX);
|
|
119
|
+
_save();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _onEvent(ev) {
|
|
125
|
+
_seen(ev.nodeId, ev.ts);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _onStatus({ nodeId, online }) {
|
|
129
|
+
const e = _entry(nodeId);
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (e.online !== online) {
|
|
132
|
+
e.online = online;
|
|
133
|
+
e.since = now;
|
|
134
|
+
e.offlineHistory.push({ online, ts: now });
|
|
135
|
+
if (e.offlineHistory.length > HISTORY_MAX) e.offlineHistory.splice(0, e.offlineHistory.length - HISTORY_MAX);
|
|
136
|
+
if (online) e.lastSeen = now;
|
|
137
|
+
_save();
|
|
138
|
+
_broadcast?.({ type: 'matter:health', nodeId });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Read what the cached state already knows (battery, role) for every paired node. */
|
|
143
|
+
function _scan() {
|
|
144
|
+
const ctrl = _ctrl();
|
|
145
|
+
for (const dev of ctrl.listPaired()) {
|
|
146
|
+
const e = _entry(dev.nodeId);
|
|
147
|
+
if (e.online === null) {
|
|
148
|
+
e.online = dev.online;
|
|
149
|
+
e.since = Date.now();
|
|
150
|
+
}
|
|
151
|
+
let endpoints;
|
|
152
|
+
try {
|
|
153
|
+
endpoints = ctrl.getEndpoints(dev.nodeId);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
for (const ep of endpoints) {
|
|
158
|
+
const ps = ep.clusters.find((c) => c.name === 'powerSource');
|
|
159
|
+
// the cached state fills in what the file does not know; a report updates it anyway
|
|
160
|
+
if (ps && !e.battery)
|
|
161
|
+
for (const k of ['batPercentRemaining', 'batChargeLevel', 'batChargeState', 'batVoltage'])
|
|
162
|
+
if (ps.attrs[k] !== undefined) e.battery = applyBatteryAttr(e.battery, k, ps.attrs[k]);
|
|
163
|
+
const tnd = ep.endpointId === 0 ? ep.clusters.find((c) => c.name === 'threadNetworkDiagnostics') : null;
|
|
164
|
+
if (tnd && tnd.attrs.routingRole !== undefined && tnd.attrs.routingRole !== null && !e.roles.length)
|
|
165
|
+
e.roles.push({ role: Number(tnd.attrs.routingRole), ts: Date.now() });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
_save();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The view of one node. */
|
|
172
|
+
function nodeHealth(nodeId, { now = Date.now() } = {}) {
|
|
173
|
+
const e = _nodes.get(String(nodeId)) ?? null;
|
|
174
|
+
if (!e) return null;
|
|
175
|
+
const offlinePeriods = [];
|
|
176
|
+
let start = null;
|
|
177
|
+
for (const h of e.offlineHistory) {
|
|
178
|
+
if (!h.online) start = h.ts;
|
|
179
|
+
else if (start !== null) {
|
|
180
|
+
offlinePeriods.push({ from: start, to: h.ts, ms: h.ts - start });
|
|
181
|
+
start = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const week = now - 7 * 24 * 60 * 60 * 1000;
|
|
185
|
+
const offlineWeek = offlinePeriods.filter((p) => p.to >= week);
|
|
186
|
+
return {
|
|
187
|
+
nodeId: String(nodeId),
|
|
188
|
+
lastSeen: e.lastSeen,
|
|
189
|
+
online: e.online,
|
|
190
|
+
since: e.since,
|
|
191
|
+
battery: e.battery,
|
|
192
|
+
offlineNow: e.online === false ? now - (e.since ?? now) : 0,
|
|
193
|
+
offlineCount7d: offlineWeek.length + (e.online === false && start !== null && start >= week ? 1 : 0),
|
|
194
|
+
offlineMs7d: offlineWeek.reduce((a, p) => a + p.ms, 0) + (e.online === false && start !== null ? now - Math.max(start, week) : 0),
|
|
195
|
+
lastOffline: offlinePeriods.length ? offlinePeriods[offlinePeriods.length - 1] : null,
|
|
196
|
+
roles: e.roles.slice(-10),
|
|
197
|
+
alerts: [..._active.values()].filter((a) => a.nodeId === String(nodeId)),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Every paired node's health, with the alerts. */
|
|
202
|
+
function listHealth({ now = Date.now() } = {}) {
|
|
203
|
+
const nodes = [];
|
|
204
|
+
for (const dev of _ctrl().listPaired())
|
|
205
|
+
nodes.push({ ...(nodeHealth(dev.nodeId, { now }) ?? { nodeId: dev.nodeId, lastSeen: null, online: dev.online, battery: null, alerts: [] }), name: dev.name });
|
|
206
|
+
return {
|
|
207
|
+
val: _active.size ? 'warning' : 'ok',
|
|
208
|
+
nodes,
|
|
209
|
+
alerts: [..._active.values()],
|
|
210
|
+
topic: _topic,
|
|
211
|
+
thresholds: { offlineAlertMs: OFFLINE_ALERT_MS, lowBatteryPercent: LOW_BATTERY_PERCENT },
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Evaluate the alerts: low battery, long offline. Returns raised and cleared. */
|
|
216
|
+
function check({ now = Date.now() } = {}) {
|
|
217
|
+
const raised = [];
|
|
218
|
+
const cleared = [];
|
|
219
|
+
const wanted = new Map();
|
|
220
|
+
for (const dev of _ctrl().listPaired()) {
|
|
221
|
+
const e = _nodes.get(dev.nodeId);
|
|
222
|
+
if (!e) continue;
|
|
223
|
+
const name = dev.name ?? `node ${dev.nodeId}`;
|
|
224
|
+
if (e.battery && (e.battery.level === 'warning' || e.battery.level === 'critical' || (e.battery.percent !== null && e.battery.percent <= LOW_BATTERY_PERCENT))) {
|
|
225
|
+
wanted.set(`battery:${dev.nodeId}`, {
|
|
226
|
+
key: `battery:${dev.nodeId}`,
|
|
227
|
+
kind: 'low_battery',
|
|
228
|
+
nodeId: dev.nodeId,
|
|
229
|
+
name,
|
|
230
|
+
message: `${name}: battery ${e.battery.percent !== null ? e.battery.percent + ' %' : e.battery.level}`,
|
|
231
|
+
since: now,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (e.online === false && e.since && now - e.since > OFFLINE_ALERT_MS) {
|
|
235
|
+
wanted.set(`offline:${dev.nodeId}`, {
|
|
236
|
+
key: `offline:${dev.nodeId}`,
|
|
237
|
+
kind: 'offline',
|
|
238
|
+
nodeId: dev.nodeId,
|
|
239
|
+
name,
|
|
240
|
+
message: `${name}: offline for ${Math.round((now - e.since) / 3600000)} h`,
|
|
241
|
+
since: e.since,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const [key, a] of wanted) {
|
|
246
|
+
if (_active.has(key)) continue;
|
|
247
|
+
_active.set(key, a);
|
|
248
|
+
raised.push(a);
|
|
249
|
+
}
|
|
250
|
+
for (const [key, a] of _active) {
|
|
251
|
+
if (wanted.has(key)) continue;
|
|
252
|
+
_active.delete(key);
|
|
253
|
+
cleared.push(a);
|
|
254
|
+
}
|
|
255
|
+
for (const a of raised) _log?.warn(`matter health: ${a.message}`);
|
|
256
|
+
for (const a of cleared) _log?.info(`matter health: cleared — ${a.message}`);
|
|
257
|
+
const val = _active.size ? 'warning' : 'ok';
|
|
258
|
+
if (raised.length || cleared.length || _lastVal === null) {
|
|
259
|
+
if (_lc === null || val !== _lastVal) _lc = now;
|
|
260
|
+
_lastVal = val;
|
|
261
|
+
if (_publish && _topic) {
|
|
262
|
+
try {
|
|
263
|
+
_publish(
|
|
264
|
+
_topic,
|
|
265
|
+
JSON.stringify({
|
|
266
|
+
val,
|
|
267
|
+
ts: now,
|
|
268
|
+
lc: _lc,
|
|
269
|
+
alerts: [..._active.values()].map((a) => ({ kind: a.kind, nodeId: a.nodeId, name: a.name, message: a.message, since: a.since })),
|
|
270
|
+
}),
|
|
271
|
+
{ retain: true },
|
|
272
|
+
);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
_log?.debug(`matter health: publish failed: ${err.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
_broadcast?.({ type: 'matter:health' });
|
|
278
|
+
}
|
|
279
|
+
return { raised, cleared, val };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function start({ publish, name, log, broadcast, controller, storageDir } = {}) {
|
|
283
|
+
_publish = publish ?? _publish;
|
|
284
|
+
_topic = name ? `${name}/status/matter/health` : _topic;
|
|
285
|
+
_log = log ?? _log;
|
|
286
|
+
_broadcast = broadcast ?? _broadcast;
|
|
287
|
+
_controller = controller ?? null;
|
|
288
|
+
_file = storageDir ? path.join(storageDir, 'device-health.json') : _file;
|
|
289
|
+
_load();
|
|
290
|
+
if (!_listening) {
|
|
291
|
+
const ev = _ctrl().events;
|
|
292
|
+
if (ev) {
|
|
293
|
+
ev.on('attr', _onAttr);
|
|
294
|
+
ev.on('event', _onEvent);
|
|
295
|
+
ev.on('status', _onStatus);
|
|
296
|
+
_listening = true;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
_scan();
|
|
301
|
+
} catch (err) {
|
|
302
|
+
_log?.debug(`matter health: initial scan: ${err.message}`);
|
|
303
|
+
}
|
|
304
|
+
if (_timer) clearInterval(_timer);
|
|
305
|
+
_timer = setInterval(() => {
|
|
306
|
+
try {
|
|
307
|
+
check();
|
|
308
|
+
} catch (err) {
|
|
309
|
+
_log?.debug(`matter health: ${err.message}`);
|
|
310
|
+
}
|
|
311
|
+
}, CHECK_INTERVAL_MS);
|
|
312
|
+
if (typeof _timer.unref === 'function') _timer.unref();
|
|
313
|
+
setTimeout(() => {
|
|
314
|
+
try {
|
|
315
|
+
check();
|
|
316
|
+
} catch {
|
|
317
|
+
/* later */
|
|
318
|
+
}
|
|
319
|
+
}, 30 * 1000).unref?.();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function stop() {
|
|
323
|
+
if (_timer) clearInterval(_timer);
|
|
324
|
+
_timer = null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Test hook. */
|
|
328
|
+
function _reset() {
|
|
329
|
+
_nodes = new Map();
|
|
330
|
+
_active.clear();
|
|
331
|
+
_lastVal = null;
|
|
332
|
+
_lc = null;
|
|
333
|
+
_listening = false;
|
|
334
|
+
_file = null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
module.exports = { start, stop, check, listHealth, nodeHealth, applyBatteryAttr, _onAttr, _onEvent, _onStatus, _reset, OFFLINE_ALERT_MS, LOW_BATTERY_PERCENT };
|