smart-home-engine 1.53.4 → 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-Cp--IWao.css → index-Bvl2uECO.css} +1 -1
- package/dist/web/assets/index-DLP7FxoS.js +305 -0
- package/dist/web/assets/{tsMode-Ci4l56iF.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 +1299 -12
- 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-DPRtP55L.js +0 -298
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Thread dataset store (roadmap M17, D-13): the operational datasets — the credentials — of
|
|
5
|
+
* the Thread networks she knows, one per extended PAN ID, kept encrypted at rest with the secrets
|
|
6
|
+
* store's key in the Matter storage (`thread-datasets.enc`), never exposed to scripts, never
|
|
7
|
+
* logged. Shown redacted (no network key, no PSKc); the full TLV only through `reveal()`.
|
|
8
|
+
*
|
|
9
|
+
* The TLV is decoded here (a few lines — the format is type, length, value), so the store does not
|
|
10
|
+
* depend on the codec's shape in matter.js.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
let _log = null;
|
|
16
|
+
let _broadcast = null;
|
|
17
|
+
let _dir = null;
|
|
18
|
+
/** extPanIdHex → { tlv, source, added, note, meshcop } */
|
|
19
|
+
let _data = null;
|
|
20
|
+
let _error = null;
|
|
21
|
+
|
|
22
|
+
const SOURCES = ['pasted', 'qr', 'router', 'donor', 'sharing'];
|
|
23
|
+
|
|
24
|
+
function fileFor(dir) {
|
|
25
|
+
return path.join(dir, 'thread-datasets.enc');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function _load() {
|
|
29
|
+
const { readEncryptedFile } = require('../lib/secrets');
|
|
30
|
+
try {
|
|
31
|
+
const raw = readEncryptedFile(fileFor(_dir));
|
|
32
|
+
_data = raw && typeof raw === 'object' && raw.datasets && typeof raw.datasets === 'object' ? raw.datasets : {};
|
|
33
|
+
_error = null;
|
|
34
|
+
} catch (err) {
|
|
35
|
+
_data = null;
|
|
36
|
+
_error = err.message;
|
|
37
|
+
_log?.error(`matter thread: the dataset store cannot be read: ${err.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _save() {
|
|
42
|
+
const { writeEncryptedFile } = require('../lib/secrets');
|
|
43
|
+
writeEncryptedFile(fileFor(_dir), { v: 1, datasets: _data });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _require() {
|
|
47
|
+
if (!_data) throw new Error(`the Thread dataset store is not available: ${_error ?? 'not loaded'}`);
|
|
48
|
+
return _data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function init({ storageDir, log, broadcast } = {}) {
|
|
52
|
+
_log = log ?? _log;
|
|
53
|
+
_broadcast = broadcast ?? _broadcast;
|
|
54
|
+
_dir = storageDir;
|
|
55
|
+
_load();
|
|
56
|
+
const n = _data ? Object.keys(_data).length : 0;
|
|
57
|
+
if (n) _log?.info(`matter thread: ${n} Thread dataset(s) known`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _hex(bytes) {
|
|
61
|
+
if (!bytes) return null;
|
|
62
|
+
return Buffer.from(bytes).toString('hex').toUpperCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clean a pasted TLV: hex with or without `0x`, spaces, colons, dashes. */
|
|
66
|
+
function normaliseTlv(input) {
|
|
67
|
+
const s = String(input ?? '')
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/^0x/i, '')
|
|
70
|
+
.replace(/[\s:-]/g, '');
|
|
71
|
+
if (!/^[0-9a-fA-F]+$/.test(s) || s.length % 2 !== 0 || s.length < 8)
|
|
72
|
+
throw new Error("the dataset must be the hex TLV string (e.g. from `ot-ctl dataset active -x` or the border router's dataset QR)");
|
|
73
|
+
return s.toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Thread MeshCoP dataset TLV types (Thread specification, Table 8-2). */
|
|
77
|
+
const TLV_TYPES = {
|
|
78
|
+
0: 'channel',
|
|
79
|
+
1: 'panId',
|
|
80
|
+
2: 'extPanId',
|
|
81
|
+
3: 'networkName',
|
|
82
|
+
4: 'pskc',
|
|
83
|
+
5: 'networkKey',
|
|
84
|
+
7: 'meshLocalPrefix',
|
|
85
|
+
12: 'securityPolicy',
|
|
86
|
+
14: 'activeTimestamp',
|
|
87
|
+
51: 'pendingTimestamp',
|
|
88
|
+
52: 'delayTimer',
|
|
89
|
+
53: 'channelMask',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Decode a dataset TLV hex (type, length, value — one byte each for type and length, a length of
|
|
94
|
+
* 255 means an extended two-byte length). Small and self-contained so the store does not depend
|
|
95
|
+
* on the codec's shape in matter.js; throws on garbage.
|
|
96
|
+
* @returns {{ channel?: number, panId?: number, extPanId?: Buffer, networkName?: string, pskc?: Buffer, networkKey?: Buffer, meshLocalPrefix?: Buffer, securityPolicy?: { rotationTime: number, flags: number }, activeTimestamp?: Buffer, channelMask?: Buffer, unknownTlvs: { type: number, value: Buffer }[] }}
|
|
97
|
+
*/
|
|
98
|
+
function decode(tlvHex) {
|
|
99
|
+
const buf = Buffer.from(normaliseTlv(tlvHex), 'hex');
|
|
100
|
+
const ds = { unknownTlvs: [] };
|
|
101
|
+
let i = 0;
|
|
102
|
+
while (i < buf.length) {
|
|
103
|
+
const type = buf[i++];
|
|
104
|
+
if (i >= buf.length) throw new Error(`truncated TLV at offset ${i}`);
|
|
105
|
+
let len = buf[i++];
|
|
106
|
+
if (len === 0xff) {
|
|
107
|
+
if (i + 2 > buf.length) throw new Error(`truncated TLV length at offset ${i}`);
|
|
108
|
+
len = buf.readUInt16BE(i);
|
|
109
|
+
i += 2;
|
|
110
|
+
}
|
|
111
|
+
if (i + len > buf.length) throw new Error(`truncated TLV value at offset ${i} (type ${type}, length ${len})`);
|
|
112
|
+
const value = buf.subarray(i, i + len);
|
|
113
|
+
i += len;
|
|
114
|
+
switch (TLV_TYPES[type]) {
|
|
115
|
+
case 'channel':
|
|
116
|
+
ds.channel = len >= 3 ? value.readUInt16BE(1) : null;
|
|
117
|
+
break;
|
|
118
|
+
case 'panId':
|
|
119
|
+
ds.panId = len >= 2 ? value.readUInt16BE(0) : null;
|
|
120
|
+
break;
|
|
121
|
+
case 'extPanId':
|
|
122
|
+
ds.extPanId = Buffer.from(value);
|
|
123
|
+
break;
|
|
124
|
+
case 'networkName':
|
|
125
|
+
ds.networkName = value.toString('utf8');
|
|
126
|
+
break;
|
|
127
|
+
case 'pskc':
|
|
128
|
+
ds.pskc = Buffer.from(value);
|
|
129
|
+
break;
|
|
130
|
+
case 'networkKey':
|
|
131
|
+
ds.networkKey = Buffer.from(value);
|
|
132
|
+
break;
|
|
133
|
+
case 'meshLocalPrefix':
|
|
134
|
+
ds.meshLocalPrefix = Buffer.from(value);
|
|
135
|
+
break;
|
|
136
|
+
case 'securityPolicy':
|
|
137
|
+
ds.securityPolicy = len >= 3 ? { rotationTime: value.readUInt16BE(0), flags: value[2] } : null;
|
|
138
|
+
break;
|
|
139
|
+
case 'activeTimestamp':
|
|
140
|
+
ds.activeTimestamp = Buffer.from(value);
|
|
141
|
+
break;
|
|
142
|
+
case 'channelMask':
|
|
143
|
+
ds.channelMask = Buffer.from(value);
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
ds.unknownTlvs.push({ type, value: Buffer.from(value) });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!ds.extPanId || ds.extPanId.length !== 8) throw new Error('the dataset has no extended PAN ID — not a complete active dataset');
|
|
150
|
+
return ds;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The dataset without its secrets. */
|
|
154
|
+
function redact(ds) {
|
|
155
|
+
const { pskc: _p, networkKey: _k, ...rest } = ds;
|
|
156
|
+
return rest;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The redacted view of one entry: everything but the network key and the PSKc. */
|
|
160
|
+
function _view(extPanId, entry) {
|
|
161
|
+
let ds = null;
|
|
162
|
+
try {
|
|
163
|
+
ds = redact(decode(entry.tlv));
|
|
164
|
+
} catch {
|
|
165
|
+
/* an undecodable entry still lists */
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
extPanId,
|
|
169
|
+
networkName: ds?.networkName ?? null,
|
|
170
|
+
channel: ds?.channel ?? null,
|
|
171
|
+
panId: ds?.panId === undefined ? null : Number(ds.panId),
|
|
172
|
+
meshLocalPrefix: _hex(ds?.meshLocalPrefix),
|
|
173
|
+
activeTimestamp: _hex(ds?.activeTimestamp),
|
|
174
|
+
channelMask: _hex(ds?.channelMask),
|
|
175
|
+
securityPolicy: ds?.securityPolicy ?? null,
|
|
176
|
+
hasNetworkKey: !!decodeSafe(entry.tlv)?.networkKey,
|
|
177
|
+
hasPskc: !!decodeSafe(entry.tlv)?.pskc,
|
|
178
|
+
source: entry.source ?? null,
|
|
179
|
+
note: entry.note ?? null,
|
|
180
|
+
added: entry.added ?? null,
|
|
181
|
+
meshcop: entry.meshcop === true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function decodeSafe(tlv) {
|
|
186
|
+
try {
|
|
187
|
+
return decode(tlv);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The known datasets, redacted. */
|
|
194
|
+
function list() {
|
|
195
|
+
if (!_data) return { available: false, error: _error, datasets: [] };
|
|
196
|
+
return { available: true, error: null, datasets: Object.entries(_data).map(([k, v]) => _view(k, v)) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function has(extPanId) {
|
|
200
|
+
return !!(_data && extPanId && _data[String(extPanId).toUpperCase()]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Import a dataset from its TLV hex.
|
|
205
|
+
* @param {{ tlv: string, source?: string, note?: string }} opts
|
|
206
|
+
*/
|
|
207
|
+
function add({ tlv, source = 'pasted', note = '' } = {}) {
|
|
208
|
+
const data = _require();
|
|
209
|
+
const clean = normaliseTlv(tlv);
|
|
210
|
+
const ds = decode(clean);
|
|
211
|
+
const key = _hex(ds.extPanId);
|
|
212
|
+
const existed = !!data[key];
|
|
213
|
+
data[key] = {
|
|
214
|
+
tlv: clean,
|
|
215
|
+
source: SOURCES.includes(source) ? source : 'pasted',
|
|
216
|
+
note: String(note ?? '').slice(0, 200),
|
|
217
|
+
added: Date.now(),
|
|
218
|
+
meshcop: data[key]?.meshcop === true,
|
|
219
|
+
};
|
|
220
|
+
_save();
|
|
221
|
+
_log?.info(`matter thread: dataset for "${ds.networkName ?? key}" ${existed ? 'replaced' : 'added'} (${source})`);
|
|
222
|
+
_broadcast?.({ type: 'matter:thread' });
|
|
223
|
+
return _view(key, data[key]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function remove(extPanId) {
|
|
227
|
+
const data = _require();
|
|
228
|
+
const key = String(extPanId).toUpperCase();
|
|
229
|
+
if (!data[key]) throw new Error(`no dataset for ${key}`);
|
|
230
|
+
delete data[key];
|
|
231
|
+
_save();
|
|
232
|
+
_log?.info(`matter thread: dataset ${key} removed`);
|
|
233
|
+
_broadcast?.({ type: 'matter:thread' });
|
|
234
|
+
return { ok: true };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Switch the MeshCoP diagnostics through a border router on or off for a network (off by default). */
|
|
238
|
+
function setMeshcop(extPanId, enabled) {
|
|
239
|
+
const data = _require();
|
|
240
|
+
const key = String(extPanId).toUpperCase();
|
|
241
|
+
if (!data[key]) throw new Error(`no dataset for ${key}`);
|
|
242
|
+
data[key].meshcop = enabled === true;
|
|
243
|
+
_save();
|
|
244
|
+
_broadcast?.({ type: 'matter:thread' });
|
|
245
|
+
return _view(key, data[key]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The full TLV — behind the UI's explicit reveal with a confirmation. Never logged. */
|
|
249
|
+
function reveal(extPanId) {
|
|
250
|
+
const data = _require();
|
|
251
|
+
const key = String(extPanId).toUpperCase();
|
|
252
|
+
if (!data[key]) throw new Error(`no dataset for ${key}`);
|
|
253
|
+
return { extPanId: key, tlv: data[key].tlv };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The decoded dataset for internal use (MeshCoP, a border router push later); null when unknown. */
|
|
257
|
+
function dataset(extPanId) {
|
|
258
|
+
if (!_data) return null;
|
|
259
|
+
const e = _data[String(extPanId).toUpperCase()];
|
|
260
|
+
return e ? decodeSafe(e.tlv) : null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `ThreadNetworkCredentials` for `@matter/thread-br-client`'s MeshCoP path, when the dataset has a PSKc and MeshCoP is on. */
|
|
264
|
+
function credentialsFor(extPanId) {
|
|
265
|
+
const e = _data?.[String(extPanId).toUpperCase()];
|
|
266
|
+
if (!e || e.meshcop !== true) return null;
|
|
267
|
+
const ds = decodeSafe(e.tlv);
|
|
268
|
+
if (!ds?.pskc) return null;
|
|
269
|
+
return {
|
|
270
|
+
extPanId: ds.extPanId,
|
|
271
|
+
networkName: ds.networkName ?? '',
|
|
272
|
+
pskc: ds.pskc,
|
|
273
|
+
meshLocalPrefix: ds.meshLocalPrefix,
|
|
274
|
+
activeTimestamp: ds.activeTimestamp ? BigInt('0x' + _hex(ds.activeTimestamp)) : undefined,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Test hook. */
|
|
279
|
+
function _reset() {
|
|
280
|
+
_data = null;
|
|
281
|
+
_error = null;
|
|
282
|
+
_dir = null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { init, list, has, add, remove, setMeshcop, reveal, dataset, credentialsFor, normaliseTlv, decode, redact, fileFor, SOURCES, TLV_TYPES, _reset };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Thread health alerts (roadmap M26): evaluated from the border routers (M15) and the mesh (M16)
|
|
5
|
+
* once a minute and on changes — a leader change, a partitioned network (more than one partition
|
|
6
|
+
* ID announced for one extended PAN ID), a border router that stopped announcing itself, a router
|
|
7
|
+
* that dropped to a child or end device, a link whose quality fell from good to poor.
|
|
8
|
+
*
|
|
9
|
+
* Raised alerts are logged at warn (cleared ones at info), published retained on MQTT under
|
|
10
|
+
* `<name>/status/thread/health` as `{ val: 'ok' | 'warning', ts, lc, alerts }` so scripts and
|
|
11
|
+
* notifications can react, and shown on the Thread tab.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const CHECK_INTERVAL_MS = 60 * 1000;
|
|
15
|
+
const ROUTER_STALE_MS = 15 * 60 * 1000;
|
|
16
|
+
const HISTORY_MAX = 100;
|
|
17
|
+
|
|
18
|
+
let _log = null;
|
|
19
|
+
let _broadcast = null;
|
|
20
|
+
let _publish = null;
|
|
21
|
+
let _topic = null;
|
|
22
|
+
let _timer = null;
|
|
23
|
+
let _controller = null;
|
|
24
|
+
let _thread = null;
|
|
25
|
+
let _mesh = null;
|
|
26
|
+
let _lastPublished = null;
|
|
27
|
+
let _lc = null;
|
|
28
|
+
|
|
29
|
+
/** What the evaluator remembers between runs. */
|
|
30
|
+
function emptyState() {
|
|
31
|
+
return {
|
|
32
|
+
leaders: new Map(), // extPanId → leader ext address
|
|
33
|
+
partitions: new Map(), // extPanId → partition id set (as sorted string)
|
|
34
|
+
routers: new Map(), // ext address → { lastSeen, name, network }
|
|
35
|
+
roles: new Map(), // nodeId → roleCode
|
|
36
|
+
links: new Map(), // link key → lqi
|
|
37
|
+
active: new Map(), // alert key → alert
|
|
38
|
+
history: [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let _state = emptyState();
|
|
43
|
+
|
|
44
|
+
const ROUTER_ROLES = new Set([4, 5, 6]);
|
|
45
|
+
|
|
46
|
+
function _alertKey(kind, subject) {
|
|
47
|
+
return `${kind}:${subject}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Evaluate a snapshot against the remembered state. Pure: returns the new state, the alerts raised
|
|
52
|
+
* and cleared in this run. `snapshot` = { networks (M15 listNetworks().networks), mesh (M16 getMesh()), now }.
|
|
53
|
+
*/
|
|
54
|
+
function evaluate(state, { networks = [], mesh = { nodes: [], links: [] }, now = Date.now() }) {
|
|
55
|
+
const next = {
|
|
56
|
+
...state,
|
|
57
|
+
leaders: new Map(state.leaders),
|
|
58
|
+
partitions: new Map(state.partitions),
|
|
59
|
+
routers: new Map(state.routers),
|
|
60
|
+
roles: new Map(state.roles),
|
|
61
|
+
links: new Map(state.links),
|
|
62
|
+
active: new Map(state.active),
|
|
63
|
+
history: [...state.history],
|
|
64
|
+
};
|
|
65
|
+
const raised = [];
|
|
66
|
+
const cleared = [];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
|
|
69
|
+
const raise = (kind, subject, message, extra = {}) => {
|
|
70
|
+
const key = _alertKey(kind, subject);
|
|
71
|
+
seen.add(key);
|
|
72
|
+
if (next.active.has(key)) return;
|
|
73
|
+
const alert = { key, kind, subject, message, since: now, ...extra };
|
|
74
|
+
next.active.set(key, alert);
|
|
75
|
+
next.history.unshift({ ...alert, event: 'raised', at: now });
|
|
76
|
+
raised.push(alert);
|
|
77
|
+
};
|
|
78
|
+
const note = (kind, subject, message, extra = {}) => {
|
|
79
|
+
// a one-off event: it goes into the history and is raised, and clears on the next run
|
|
80
|
+
const key = _alertKey(kind, subject);
|
|
81
|
+
const alert = { key, kind, subject, message, since: now, transient: true, ...extra };
|
|
82
|
+
next.history.unshift({ ...alert, event: 'raised', at: now });
|
|
83
|
+
raised.push(alert);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// networks: leader, partitions, border routers
|
|
87
|
+
for (const net of networks) {
|
|
88
|
+
const name = net.networkName ?? net.extPanId;
|
|
89
|
+
const leaderBr = net.borderRouters.find((b) => b.state?.role === 'leader') ?? null;
|
|
90
|
+
const leaderExt = leaderBr?.extAddress ?? null;
|
|
91
|
+
const prevLeader = next.leaders.get(net.extPanId);
|
|
92
|
+
if (leaderExt && prevLeader && prevLeader !== leaderExt) {
|
|
93
|
+
const prevName = next.routers.get(prevLeader)?.name ?? prevLeader;
|
|
94
|
+
note('leader_changed', net.extPanId, `${name}: the leader changed from ${prevName} to ${leaderBr.vendorName ?? ''} ${leaderBr.modelName ?? ''}`.trim(), {
|
|
95
|
+
network: name,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (leaderExt) next.leaders.set(net.extPanId, leaderExt);
|
|
99
|
+
|
|
100
|
+
const parts = [...new Set(net.partitions ?? [])].sort().join(',');
|
|
101
|
+
if ((net.partitions ?? []).length > 1)
|
|
102
|
+
raise('partition', net.extPanId, `${name}: ${net.partitions.length} partition IDs announced (${parts}) — the network is split`, { network: name });
|
|
103
|
+
next.partitions.set(net.extPanId, parts);
|
|
104
|
+
|
|
105
|
+
for (const br of net.borderRouters) {
|
|
106
|
+
const label = `${br.vendorName ?? ''} ${br.modelName ?? ''}`.trim() || br.extAddress;
|
|
107
|
+
next.routers.set(br.extAddress, { lastSeen: br.lastSeen ?? now, name: label, network: name });
|
|
108
|
+
if (br.lastSeen && now - br.lastSeen > ROUTER_STALE_MS)
|
|
109
|
+
raise('border_router_offline', br.extAddress, `${name}: border router ${label} not seen for ${Math.round((now - br.lastSeen) / 60000)} min`, { network: name });
|
|
110
|
+
else {
|
|
111
|
+
// seen fresh: an offline alert for it (stale or vanished) is over
|
|
112
|
+
const key = _alertKey('border_router_offline', br.extAddress);
|
|
113
|
+
if (next.active.has(key)) {
|
|
114
|
+
cleared.push(next.active.get(key));
|
|
115
|
+
next.history.unshift({ ...next.active.get(key), event: 'cleared', at: now });
|
|
116
|
+
next.active.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// border routers that vanished from the registry
|
|
122
|
+
const present = new Set(networks.flatMap((n) => n.borderRouters.map((b) => b.extAddress)));
|
|
123
|
+
for (const [ext, r] of next.routers) {
|
|
124
|
+
if (present.has(ext)) continue;
|
|
125
|
+
raise('border_router_offline', ext, `${r.network}: border router ${r.name} disappeared from mDNS`, { network: r.network });
|
|
126
|
+
}
|
|
127
|
+
// the mesh: partitions among fabric nodes, roles, link quality
|
|
128
|
+
const byNet = new Map();
|
|
129
|
+
for (const n of mesh.nodes ?? []) {
|
|
130
|
+
if (n.kind !== 'thread') continue;
|
|
131
|
+
if (n.networkName && n.partitionId !== null && n.partitionId !== undefined) {
|
|
132
|
+
const s = byNet.get(n.networkName) ?? new Set();
|
|
133
|
+
s.add(String(n.partitionId));
|
|
134
|
+
byNet.set(n.networkName, s);
|
|
135
|
+
}
|
|
136
|
+
if (n.nodeId && n.roleCode !== null && n.roleCode !== undefined) {
|
|
137
|
+
const prev = next.roles.get(n.nodeId);
|
|
138
|
+
if (prev !== undefined && ROUTER_ROLES.has(prev) && !ROUTER_ROLES.has(n.roleCode) && n.online) {
|
|
139
|
+
raise('role_dropped', n.nodeId, `${n.name}: was a ${ROLE_NAMES[prev] ?? 'router'}, is now a ${n.role}`, { network: n.networkName ?? null });
|
|
140
|
+
} else if (ROUTER_ROLES.has(n.roleCode)) {
|
|
141
|
+
const key = _alertKey('role_dropped', n.nodeId);
|
|
142
|
+
if (next.active.has(key)) {
|
|
143
|
+
cleared.push(next.active.get(key));
|
|
144
|
+
next.history.unshift({ ...next.active.get(key), event: 'cleared', at: now });
|
|
145
|
+
next.active.delete(key);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
next.roles.set(n.nodeId, n.roleCode);
|
|
149
|
+
if (next.active.has(_alertKey('role_dropped', n.nodeId))) seen.add(_alertKey('role_dropped', n.nodeId));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const [name, parts] of byNet) {
|
|
153
|
+
if (parts.size > 1)
|
|
154
|
+
raise('partition', `nodes:${name}`, `${name}: devices report ${parts.size} partition IDs (${[...parts].sort().join(', ')}) — the mesh is split`, { network: name });
|
|
155
|
+
}
|
|
156
|
+
const nodeName = (id) => mesh.nodes?.find((x) => x.id === id)?.name ?? id;
|
|
157
|
+
for (const l of mesh.links ?? []) {
|
|
158
|
+
if (l.kind !== 'neighbour') continue;
|
|
159
|
+
const key = `${l.source}|${l.target}`;
|
|
160
|
+
const prev = next.links.get(key);
|
|
161
|
+
if (prev !== undefined && prev >= 3 && l.lqi <= 1) {
|
|
162
|
+
raise('link_degraded', key, `link ${nodeName(l.source)} ↔ ${nodeName(l.target)}: quality fell from good to poor (LQI ${l.lqi})`);
|
|
163
|
+
} else if (l.lqi >= 2 && next.active.has(_alertKey('link_degraded', key))) {
|
|
164
|
+
cleared.push(next.active.get(_alertKey('link_degraded', key)));
|
|
165
|
+
next.history.unshift({ ...next.active.get(_alertKey('link_degraded', key)), event: 'cleared', at: now });
|
|
166
|
+
next.active.delete(_alertKey('link_degraded', key));
|
|
167
|
+
}
|
|
168
|
+
if (next.active.has(_alertKey('link_degraded', key))) seen.add(_alertKey('link_degraded', key));
|
|
169
|
+
next.links.set(key, l.lqi);
|
|
170
|
+
}
|
|
171
|
+
// everything active that this run did not confirm is cleared (partitions healed, routers back)
|
|
172
|
+
for (const [key, alert] of next.active) {
|
|
173
|
+
if (seen.has(key) || alert.transient) continue;
|
|
174
|
+
if (key.startsWith('role_dropped:') || key.startsWith('link_degraded:')) continue; // cleared explicitly above
|
|
175
|
+
cleared.push(alert);
|
|
176
|
+
next.history.unshift({ ...alert, event: 'cleared', at: now });
|
|
177
|
+
next.active.delete(key);
|
|
178
|
+
}
|
|
179
|
+
for (const a of raised) if (a.transient) next.active.delete(a.key);
|
|
180
|
+
if (next.history.length > HISTORY_MAX) next.history.length = HISTORY_MAX;
|
|
181
|
+
return { state: next, raised, cleared };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const ROLE_NAMES = { 4: 'REED', 5: 'router', 6: 'leader' };
|
|
185
|
+
|
|
186
|
+
function status() {
|
|
187
|
+
const active = [..._state.active.values()];
|
|
188
|
+
return { val: active.length ? 'warning' : 'ok', active, history: _state.history, checkedAt: _lastPublished, topic: _topic };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _publishStatus(now) {
|
|
192
|
+
if (!_publish || !_topic) return;
|
|
193
|
+
const active = [..._state.active.values()];
|
|
194
|
+
const val = active.length ? 'warning' : 'ok';
|
|
195
|
+
if (_lc === null || val !== _lastVal) _lc = now;
|
|
196
|
+
_lastVal = val;
|
|
197
|
+
try {
|
|
198
|
+
_publish(
|
|
199
|
+
_topic,
|
|
200
|
+
JSON.stringify({
|
|
201
|
+
val,
|
|
202
|
+
ts: now,
|
|
203
|
+
lc: _lc,
|
|
204
|
+
alerts: active.map((a) => ({ kind: a.kind, subject: a.subject, message: a.message, since: a.since, network: a.network ?? null })),
|
|
205
|
+
}),
|
|
206
|
+
{ retain: true },
|
|
207
|
+
);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
_log?.debug(`matter thread health: publish failed: ${err.message}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
let _lastVal = null;
|
|
213
|
+
|
|
214
|
+
/** Run one evaluation: gather the snapshot, log and publish what changed. */
|
|
215
|
+
async function check({ now = Date.now() } = {}) {
|
|
216
|
+
let networks = [];
|
|
217
|
+
let mesh = { nodes: [], links: [] };
|
|
218
|
+
try {
|
|
219
|
+
networks = (_thread ?? require('./thread')).listNetworks().networks;
|
|
220
|
+
} catch {
|
|
221
|
+
/* discovery off */
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
mesh = await (_mesh ?? require('./thread-mesh')).getMesh({ withBindings: false });
|
|
225
|
+
} catch {
|
|
226
|
+
/* no controller */
|
|
227
|
+
}
|
|
228
|
+
const r = evaluate(_state, { networks, mesh, now });
|
|
229
|
+
_state = r.state;
|
|
230
|
+
for (const a of r.raised) _log?.warn(`matter thread: ${a.message}`);
|
|
231
|
+
for (const a of r.cleared) _log?.info(`matter thread: cleared — ${a.message}`);
|
|
232
|
+
_lastPublished = now;
|
|
233
|
+
if (r.raised.length || r.cleared.length || _lastVal === null) {
|
|
234
|
+
_publishStatus(now);
|
|
235
|
+
_broadcast?.({ type: 'matter:thread-health' });
|
|
236
|
+
}
|
|
237
|
+
return status();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @param {{ publish?: Function, name?: string, log?: object, broadcast?: Function, controller?: object, thread?: object, mesh?: object }} opts
|
|
242
|
+
*/
|
|
243
|
+
function start({ publish, name, log, broadcast, controller, thread, mesh } = {}) {
|
|
244
|
+
_publish = publish ?? _publish;
|
|
245
|
+
_topic = name ? `${name}/status/thread/health` : _topic;
|
|
246
|
+
_log = log ?? _log;
|
|
247
|
+
_broadcast = broadcast ?? _broadcast;
|
|
248
|
+
_controller = controller ?? null;
|
|
249
|
+
_thread = thread ?? null;
|
|
250
|
+
_mesh = mesh ?? null;
|
|
251
|
+
if (_timer) clearInterval(_timer);
|
|
252
|
+
_timer = setInterval(() => check().catch((err) => _log?.debug(`matter thread health: ${err.message}`)), CHECK_INTERVAL_MS);
|
|
253
|
+
if (typeof _timer.unref === 'function') _timer.unref();
|
|
254
|
+
// the first run a little after start, when discovery has had its minute
|
|
255
|
+
setTimeout(() => check().catch(() => {}), 90 * 1000).unref?.();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function stop() {
|
|
259
|
+
if (_timer) clearInterval(_timer);
|
|
260
|
+
_timer = null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Forget the history (the UI's clear button). */
|
|
264
|
+
function clearHistory() {
|
|
265
|
+
_state.history = [];
|
|
266
|
+
_broadcast?.({ type: 'matter:thread-health' });
|
|
267
|
+
return status();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Test hook. */
|
|
271
|
+
function _reset() {
|
|
272
|
+
_state = emptyState();
|
|
273
|
+
_lastPublished = null;
|
|
274
|
+
_lc = null;
|
|
275
|
+
_lastVal = null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = { evaluate, emptyState, check, status, start, stop, clearHistory, _reset, CHECK_INTERVAL_MS, ROUTER_STALE_MS };
|