smart-home-engine 1.53.4 → 2.0.1
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-Bu5o4KXc.css} +1 -1
- package/dist/web/assets/index-CqYTIaIG.js +306 -0
- package/dist/web/assets/{tsMode-Ci4l56iF.js → tsMode-20i8zomj.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 +535 -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,513 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Matter ↔ MQTT bridge (roadmap M19): every commissioned node's application attributes and
|
|
5
|
+
* events published to MQTT, commands and writes accepted on `set`, reads on `get` — by default
|
|
6
|
+
* under the mqtt-smarthome convention (`matter/status/…` as `{ val, ts, lc }`, `matter/set/…`,
|
|
7
|
+
* `matter/connected`). Topics and payload format are templates (D-26), the device names are stable
|
|
8
|
+
* MQTT names assigned when a node first appears (D-21), each node can be switched off (D-24).
|
|
9
|
+
*
|
|
10
|
+
* Fed by the controller's event stream; publishes through she's own MQTT connection. Settings in
|
|
11
|
+
* `<matter storage>/mqtt-bridge.json`. Off until switched on in the Matter page's MQTT tab.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const t = require('./bridge-templates');
|
|
17
|
+
|
|
18
|
+
let _publish = null;
|
|
19
|
+
let _isConnected = () => false;
|
|
20
|
+
let _store = null;
|
|
21
|
+
let _log = null;
|
|
22
|
+
let _broadcast = null;
|
|
23
|
+
let _controller = null;
|
|
24
|
+
let _file = null;
|
|
25
|
+
let _settings = defaults();
|
|
26
|
+
let _started = false;
|
|
27
|
+
let _listening = false;
|
|
28
|
+
/** topic → { json, ts, lc } of the attribute topics published this run (for `lc`) */
|
|
29
|
+
let _last = new Map();
|
|
30
|
+
/** the retained topics published this run */
|
|
31
|
+
let _published = new Set();
|
|
32
|
+
let _lastWarn = new Map();
|
|
33
|
+
|
|
34
|
+
function defaults() {
|
|
35
|
+
return { enabled: false, format: 'basic', templates: { ...t.DEFAULT_TEMPLATES }, devices: {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _load() {
|
|
39
|
+
_settings = defaults();
|
|
40
|
+
if (!_file) return;
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(fs.readFileSync(_file, 'utf8'));
|
|
43
|
+
if (raw && typeof raw === 'object') {
|
|
44
|
+
_settings.enabled = raw.enabled === true;
|
|
45
|
+
_settings.format = t.FORMATS.includes(raw.format) ? raw.format : 'basic';
|
|
46
|
+
_settings.templates = { ...t.DEFAULT_TEMPLATES, ...(raw.templates && typeof raw.templates === 'object' ? raw.templates : {}) };
|
|
47
|
+
_settings.devices = raw.devices && typeof raw.devices === 'object' ? raw.devices : {};
|
|
48
|
+
}
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code !== 'ENOENT') _log?.warn(`matter bridge: cannot read ${_file}: ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _save() {
|
|
55
|
+
if (!_file) return;
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(_file + '.tmp', JSON.stringify(_settings, null, 1));
|
|
58
|
+
fs.renameSync(_file + '.tmp', _file);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
_log?.warn(`matter bridge: cannot write ${_file}: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _ctrl() {
|
|
65
|
+
return _controller ?? require('./controller');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _warnOnce(key, msg) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (now - (_lastWarn.get(key) ?? 0) < 60000) return;
|
|
71
|
+
_lastWarn.set(key, now);
|
|
72
|
+
_log?.warn(msg);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {{ publish: Function, isConnected: Function, store: object, log: object, storageDir: string, broadcast?: Function, controller?: object }} opts
|
|
77
|
+
*/
|
|
78
|
+
function init({ publish, isConnected, store, log, storageDir, broadcast, controller }) {
|
|
79
|
+
_publish = publish;
|
|
80
|
+
_isConnected = isConnected ?? (() => true);
|
|
81
|
+
_store = store;
|
|
82
|
+
_log = log;
|
|
83
|
+
_broadcast = broadcast ?? null;
|
|
84
|
+
_controller = controller ?? null;
|
|
85
|
+
_file = storageDir ? path.join(storageDir, 'mqtt-bridge.json') : null;
|
|
86
|
+
_started = false;
|
|
87
|
+
_last = new Map();
|
|
88
|
+
_published = new Set();
|
|
89
|
+
_load();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Names ─────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
function _deviceEntry(nodeId) {
|
|
95
|
+
return _settings.devices[String(nodeId)];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every paired node has an MQTT name (D-21): from its name when it first appears, kept. */
|
|
99
|
+
function _ensureNames() {
|
|
100
|
+
let changed = false;
|
|
101
|
+
const taken = new Set(Object.values(_settings.devices).map((d) => d.name));
|
|
102
|
+
for (const dev of _ctrl().listPaired()) {
|
|
103
|
+
if (_deviceEntry(dev.nodeId)) continue;
|
|
104
|
+
const name = t.uniqueName(t.mqttName(dev.name ?? `node_${dev.nodeId}`), taken);
|
|
105
|
+
taken.add(name);
|
|
106
|
+
_settings.devices[dev.nodeId] = { name, publish: true, deviceName: dev.name ?? null };
|
|
107
|
+
changed = true;
|
|
108
|
+
_log?.info(`matter bridge: node ${dev.nodeId} is "${name}" on MQTT`);
|
|
109
|
+
}
|
|
110
|
+
if (changed) _save();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _nameOf(nodeId) {
|
|
114
|
+
return _deviceEntry(nodeId)?.name ?? `node_${nodeId}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _nodeIdForDevice(device) {
|
|
118
|
+
for (const [nodeId, d] of Object.entries(_settings.devices)) if (d.name === device) return nodeId;
|
|
119
|
+
if (/^\d+$/.test(String(device)) && _settings.devices[String(device)]) return String(device);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _publishesNode(nodeId) {
|
|
124
|
+
const d = _deviceEntry(nodeId);
|
|
125
|
+
return !d || d.publish !== false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Publishing ────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function _out(topic, payload, retain) {
|
|
131
|
+
if (!_publish || !_isConnected()) return false;
|
|
132
|
+
try {
|
|
133
|
+
_publish(topic, payload, { retain });
|
|
134
|
+
if (retain) {
|
|
135
|
+
if (payload === '') _published.delete(topic);
|
|
136
|
+
else _published.add(topic);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
_warnOnce('publish', `matter bridge: publish failed: ${err.message}`);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _vars(nodeId, endpointId, clusterName, name) {
|
|
146
|
+
return {
|
|
147
|
+
device: _nameOf(nodeId),
|
|
148
|
+
nodeId: String(nodeId),
|
|
149
|
+
endpoint: String(endpointId),
|
|
150
|
+
cluster: t.snake(clusterName),
|
|
151
|
+
attribute: t.snake(name),
|
|
152
|
+
event: t.snake(name),
|
|
153
|
+
attributeOrCommand: t.snake(name),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _allowed(endpointId, clusterName, attrName) {
|
|
158
|
+
if (Number(endpointId) === 0) return false;
|
|
159
|
+
if (t.DENY_CLUSTERS.has(clusterName)) return false;
|
|
160
|
+
if (attrName !== undefined && t.DENY_ATTRIBUTES.has(attrName)) return false;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _publishAttr(nodeId, endpointId, clusterName, attrName, value, ts = Date.now()) {
|
|
165
|
+
if (!_settings.enabled || !_settings.templates.attribute) return;
|
|
166
|
+
if (!_publishesNode(nodeId) || !_allowed(endpointId, clusterName, attrName) || value === undefined) return;
|
|
167
|
+
const vars = _vars(nodeId, endpointId, clusterName, attrName);
|
|
168
|
+
const topic = t.fill(_settings.templates.attribute, vars);
|
|
169
|
+
const json = JSON.stringify(value);
|
|
170
|
+
const prev = _last.get(topic);
|
|
171
|
+
const lc = prev && prev.json === json ? prev.lc : ts;
|
|
172
|
+
_last.set(topic, { json, ts, lc });
|
|
173
|
+
_out(
|
|
174
|
+
topic,
|
|
175
|
+
t.formatPayload(_settings.format, value, {
|
|
176
|
+
ts,
|
|
177
|
+
lc,
|
|
178
|
+
matter: { nodeId: String(nodeId), device: vars.device, endpoint: Number(endpointId), cluster: clusterName, attribute: attrName },
|
|
179
|
+
}),
|
|
180
|
+
true,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _publishEvent(nodeId, endpointId, clusterName, eventName, value, ts = Date.now()) {
|
|
185
|
+
if (!_settings.enabled || !_settings.templates.event) return;
|
|
186
|
+
if (!_publishesNode(nodeId) || !_allowed(endpointId, clusterName)) return;
|
|
187
|
+
const vars = _vars(nodeId, endpointId, clusterName, eventName);
|
|
188
|
+
_out(
|
|
189
|
+
t.fill(_settings.templates.event, vars),
|
|
190
|
+
t.formatPayload(_settings.format, value, {
|
|
191
|
+
ts,
|
|
192
|
+
lc: ts,
|
|
193
|
+
matter: { nodeId: String(nodeId), device: vars.device, endpoint: Number(endpointId), cluster: clusterName, event: eventName },
|
|
194
|
+
}),
|
|
195
|
+
false,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _publishOnline(nodeId, online, ts = Date.now()) {
|
|
200
|
+
if (!_settings.enabled || !_settings.templates.online || !_publishesNode(nodeId)) return;
|
|
201
|
+
const vars = _vars(nodeId, 0, '', '');
|
|
202
|
+
const topic = t.fill(_settings.templates.online, vars);
|
|
203
|
+
const json = JSON.stringify(!!online);
|
|
204
|
+
const prev = _last.get(topic);
|
|
205
|
+
const lc = prev && prev.json === json ? prev.lc : ts;
|
|
206
|
+
_last.set(topic, { json, ts, lc });
|
|
207
|
+
_out(topic, t.formatPayload(_settings.format, !!online, { ts, lc, matter: { nodeId: String(nodeId), device: vars.device } }), true);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _publishConnected(value) {
|
|
211
|
+
if (!_settings.templates.connected) return;
|
|
212
|
+
_out(_settings.templates.connected, String(value), true);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Publish the cached state of a node (every allowed attribute) and its online flag. */
|
|
216
|
+
function _publishNode(dev) {
|
|
217
|
+
if (!_settings.enabled || !_publishesNode(dev.nodeId)) return;
|
|
218
|
+
_publishOnline(dev.nodeId, dev.online);
|
|
219
|
+
if (!dev.online) return;
|
|
220
|
+
let endpoints;
|
|
221
|
+
try {
|
|
222
|
+
endpoints = _ctrl().getEndpoints(dev.nodeId);
|
|
223
|
+
} catch {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const ts = Date.now();
|
|
227
|
+
for (const ep of endpoints) {
|
|
228
|
+
for (const cluster of ep.clusters) {
|
|
229
|
+
if (!_allowed(ep.endpointId, cluster.name)) continue;
|
|
230
|
+
for (const [attr, value] of Object.entries(cluster.attrs ?? {})) _publishAttr(dev.nodeId, ep.endpointId, cluster.name, attr, value, ts);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _publishAll() {
|
|
236
|
+
if (!_settings.enabled) return;
|
|
237
|
+
_ensureNames();
|
|
238
|
+
for (const dev of _ctrl().listPaired()) _publishNode(dev);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── Start-up cleanup (SPEC: read back the retained topics, drop the stale ones) ──
|
|
242
|
+
|
|
243
|
+
/** The retained topics of ours in the state store that match the (given) templates, with the device each names. */
|
|
244
|
+
function _retainedOfOurs(templates) {
|
|
245
|
+
const out = [];
|
|
246
|
+
if (!_store || typeof _store.mqttEntries !== 'function') return out;
|
|
247
|
+
for (const key of ['attribute', 'online']) {
|
|
248
|
+
const tpl = templates[key];
|
|
249
|
+
if (!tpl) continue;
|
|
250
|
+
for (const [topic] of _store.mqttEntries()) {
|
|
251
|
+
const vars = t.match(tpl, topic);
|
|
252
|
+
if (vars) out.push({ topic, key, device: vars.device ?? vars.nodeId ?? null });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Clear retained topics: all of ours, or those of devices that are gone or switched off. */
|
|
259
|
+
function _cleanup({ all = false, templates = _settings.templates, device = null } = {}) {
|
|
260
|
+
let n = 0;
|
|
261
|
+
const live = new Set(
|
|
262
|
+
Object.entries(_settings.devices)
|
|
263
|
+
.filter(([, d]) => d.publish !== false)
|
|
264
|
+
.map(([, d]) => d.name),
|
|
265
|
+
);
|
|
266
|
+
const paired = new Set(
|
|
267
|
+
_ctrl()
|
|
268
|
+
.listPaired()
|
|
269
|
+
.map((d) => d.nodeId),
|
|
270
|
+
);
|
|
271
|
+
for (const [nodeId, d] of Object.entries(_settings.devices)) if (!paired.has(nodeId)) live.delete(d.name);
|
|
272
|
+
for (const r of _retainedOfOurs(templates)) {
|
|
273
|
+
const stale = all || (device !== null && r.device === device) || (r.device !== null && !live.has(r.device) && !paired.has(r.device));
|
|
274
|
+
if (!stale) continue;
|
|
275
|
+
if (_out(r.topic, '', true)) n++;
|
|
276
|
+
_last.delete(r.topic);
|
|
277
|
+
}
|
|
278
|
+
if (n) _log?.info(`matter bridge: cleared ${n} retained topic(s)`);
|
|
279
|
+
return n;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Inbound ───────────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* An MQTT message from she's connection. Returns true when the bridge handled it.
|
|
286
|
+
* @param {string} topic
|
|
287
|
+
* @param {Buffer|string} payload
|
|
288
|
+
*/
|
|
289
|
+
function onMessage(topic, payload) {
|
|
290
|
+
if (!_settings.enabled) return false;
|
|
291
|
+
const set = _settings.templates.set ? t.match(_settings.templates.set, topic) : null;
|
|
292
|
+
if (set) {
|
|
293
|
+
_handleSet(set, payload).catch((err) => _warnOnce(`set:${topic}`, `matter bridge: ${topic}: ${err.message}`));
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
const get = _settings.templates.get ? t.match(_settings.templates.get, topic) : null;
|
|
297
|
+
if (get) {
|
|
298
|
+
_handleGet(get).catch((err) => _warnOnce(`get:${topic}`, `matter bridge: ${topic}: ${err.message}`));
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _resolveTarget(vars) {
|
|
305
|
+
const nodeId = _nodeIdForDevice(vars.device ?? vars.nodeId);
|
|
306
|
+
if (!nodeId) throw new Error(`unknown device "${vars.device ?? vars.nodeId}"`);
|
|
307
|
+
const endpointId = Number(vars.endpoint);
|
|
308
|
+
if (!Number.isInteger(endpointId)) throw new Error(`endpoint "${vars.endpoint}" is not a number`);
|
|
309
|
+
const clusterName = t.camel(vars.cluster ?? '');
|
|
310
|
+
if (!clusterName) throw new Error('no cluster in the topic');
|
|
311
|
+
return { nodeId, endpointId, clusterName };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function _handleSet(vars, payload) {
|
|
315
|
+
const { nodeId, endpointId, clusterName } = _resolveTarget(vars);
|
|
316
|
+
const name = t.camel(vars.attributeOrCommand ?? '');
|
|
317
|
+
if (!name) throw new Error('no attribute or command in the topic');
|
|
318
|
+
const val = t.parseInbound(payload);
|
|
319
|
+
const clusters = require('@matter/main/clusters');
|
|
320
|
+
const def = clusters[clusterName.charAt(0).toUpperCase() + clusterName.slice(1)]?.Complete;
|
|
321
|
+
if (!def) throw new Error(`unknown cluster "${clusterName}"`);
|
|
322
|
+
const action = t.resolveSet(clusterName, name, val, def);
|
|
323
|
+
const ctrl = _ctrl();
|
|
324
|
+
if (action.kind === 'command') {
|
|
325
|
+
_log?.debug(`matter bridge: ${_nameOf(nodeId)}/${endpointId} ${clusterName}.${action.command}(${JSON.stringify(action.args ?? {})})`);
|
|
326
|
+
await ctrl.sendCommand(nodeId, endpointId, clusterName, action.command, action.args ?? {});
|
|
327
|
+
} else {
|
|
328
|
+
_log?.debug(`matter bridge: ${_nameOf(nodeId)}/${endpointId} write ${clusterName}.${action.attribute} = ${JSON.stringify(action.value)}`);
|
|
329
|
+
await ctrl.setAttribute(nodeId, endpointId, clusterName, action.attribute, action.value);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function _handleGet(vars) {
|
|
334
|
+
const { nodeId, endpointId, clusterName } = _resolveTarget(vars);
|
|
335
|
+
const attr = t.camel(vars.attribute ?? '');
|
|
336
|
+
if (!attr) throw new Error('no attribute in the topic');
|
|
337
|
+
const value = await _ctrl().getAttribute(nodeId, endpointId, clusterName, attr);
|
|
338
|
+
_publishAttr(nodeId, endpointId, clusterName, attr, _ctrl()._attrValue ? _ctrl()._attrValue(clusterName, attr, value) : value);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
function _onAttr(e) {
|
|
344
|
+
_publishAttr(e.nodeId, e.endpointId, e.clusterName, e.attrName, e.value, e.ts);
|
|
345
|
+
}
|
|
346
|
+
function _onEvent(e) {
|
|
347
|
+
_publishEvent(e.nodeId, e.endpointId, e.clusterName, e.eventName, e.value, e.ts);
|
|
348
|
+
}
|
|
349
|
+
function _onStatus(e) {
|
|
350
|
+
if (!_settings.enabled) return;
|
|
351
|
+
const dev = _ctrl()
|
|
352
|
+
.listPaired()
|
|
353
|
+
.find((d) => d.nodeId === e.nodeId);
|
|
354
|
+
if (e.online && dev) _publishNode({ ...dev, online: true });
|
|
355
|
+
else _publishOnline(e.nodeId, e.online);
|
|
356
|
+
}
|
|
357
|
+
function _onDeviceList() {
|
|
358
|
+
if (!_settings.enabled) return;
|
|
359
|
+
_ensureNames();
|
|
360
|
+
_cleanup();
|
|
361
|
+
_publishAll();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function _listen() {
|
|
365
|
+
if (_listening) return;
|
|
366
|
+
const ev = _ctrl().events;
|
|
367
|
+
if (!ev) return;
|
|
368
|
+
ev.on('attr', _onAttr);
|
|
369
|
+
ev.on('event', _onEvent);
|
|
370
|
+
ev.on('status', _onStatus);
|
|
371
|
+
ev.on('deviceList', _onDeviceList);
|
|
372
|
+
_listening = true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Start the bridge: names, connected, the stale-topic cleanup, the current state. Call once MQTT's retained state is in. */
|
|
376
|
+
function start() {
|
|
377
|
+
_started = true;
|
|
378
|
+
_listen();
|
|
379
|
+
if (!_settings.enabled) return;
|
|
380
|
+
_ensureNames();
|
|
381
|
+
_publishConnected(2);
|
|
382
|
+
_cleanup();
|
|
383
|
+
_publishAll();
|
|
384
|
+
_log?.info(`matter bridge: on — ${_published.size} topic(s) published under the ${_settings.format} format`);
|
|
385
|
+
_broadcast?.({ type: 'matter:bridge' });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** On shutdown: connected 0 (she's last will covers only her own connected topic). */
|
|
389
|
+
function stop() {
|
|
390
|
+
if (_settings.enabled) _publishConnected(0);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── Settings ──────────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
function getSettings() {
|
|
396
|
+
const paired = _ctrl().listPaired();
|
|
397
|
+
const known = new Set(paired.map((d) => d.nodeId));
|
|
398
|
+
const devices = paired.map((d) => {
|
|
399
|
+
const e = _deviceEntry(d.nodeId);
|
|
400
|
+
return {
|
|
401
|
+
nodeId: d.nodeId,
|
|
402
|
+
deviceName: d.name,
|
|
403
|
+
online: d.online,
|
|
404
|
+
name: e?.name ?? t.mqttName(d.name ?? `node_${d.nodeId}`),
|
|
405
|
+
publish: e ? e.publish !== false : true,
|
|
406
|
+
assigned: !!e,
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
for (const [nodeId, e] of Object.entries(_settings.devices))
|
|
410
|
+
if (!known.has(nodeId)) devices.push({ nodeId, deviceName: e.deviceName ?? null, online: false, name: e.name, publish: e.publish !== false, assigned: true, gone: true });
|
|
411
|
+
return {
|
|
412
|
+
enabled: _settings.enabled,
|
|
413
|
+
format: _settings.format,
|
|
414
|
+
templates: { ..._settings.templates },
|
|
415
|
+
defaults: { ...t.DEFAULT_TEMPLATES },
|
|
416
|
+
placeholders: [...t.PLACEHOLDERS],
|
|
417
|
+
formats: [...t.FORMATS],
|
|
418
|
+
devices,
|
|
419
|
+
status: { started: _started, connected: _isConnected(), published: _published.size },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Change bridge on/off, the format or the templates. A changed template or format clears the
|
|
425
|
+
* old retained topics and republishes under the new ones.
|
|
426
|
+
*/
|
|
427
|
+
function updateSettings({ enabled, format, templates } = {}) {
|
|
428
|
+
const next = { ..._settings, templates: { ..._settings.templates } };
|
|
429
|
+
if (enabled !== undefined) next.enabled = enabled === true;
|
|
430
|
+
if (format !== undefined) {
|
|
431
|
+
if (!t.FORMATS.includes(format)) throw new Error(`format must be one of ${t.FORMATS.join(', ')}`);
|
|
432
|
+
next.format = format;
|
|
433
|
+
}
|
|
434
|
+
if (templates !== undefined) {
|
|
435
|
+
if (!templates || typeof templates !== 'object') throw new Error('templates must be an object');
|
|
436
|
+
for (const [k, v] of Object.entries(templates)) {
|
|
437
|
+
if (!(k in t.DEFAULT_TEMPLATES)) throw new Error(`unknown template "${k}"`);
|
|
438
|
+
next.templates[k] = typeof v === 'string' ? v.trim() : v;
|
|
439
|
+
}
|
|
440
|
+
const problems = t.validate(next.templates);
|
|
441
|
+
if (problems.length) throw new Error(problems.join('; '));
|
|
442
|
+
}
|
|
443
|
+
const wasEnabled = _settings.enabled;
|
|
444
|
+
const topicsChanged = JSON.stringify(next.templates) !== JSON.stringify(_settings.templates) || next.format !== _settings.format;
|
|
445
|
+
const old = _settings;
|
|
446
|
+
if (wasEnabled && (topicsChanged || !next.enabled)) {
|
|
447
|
+
// under the old templates: everything of ours goes
|
|
448
|
+
_cleanup({ all: true, templates: old.templates });
|
|
449
|
+
if (old.templates.connected && old.templates.connected !== next.templates.connected) _out(old.templates.connected, '', true);
|
|
450
|
+
if (!next.enabled && old.templates.connected) _out(old.templates.connected, '0', true);
|
|
451
|
+
_last = new Map();
|
|
452
|
+
}
|
|
453
|
+
_settings = next;
|
|
454
|
+
_save();
|
|
455
|
+
if (_started && next.enabled && (!wasEnabled || topicsChanged)) {
|
|
456
|
+
_ensureNames();
|
|
457
|
+
_publishConnected(2);
|
|
458
|
+
_cleanup();
|
|
459
|
+
_publishAll();
|
|
460
|
+
_log?.info(`matter bridge: ${wasEnabled ? 'republished' : 'switched on'} — ${_published.size} topic(s)`);
|
|
461
|
+
} else if (wasEnabled && !next.enabled) {
|
|
462
|
+
_log?.info('matter bridge: switched off');
|
|
463
|
+
}
|
|
464
|
+
_broadcast?.({ type: 'matter:bridge' });
|
|
465
|
+
return getSettings();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Rename a node on MQTT or switch its publishing; a rename moves its topics and clears the old ones. */
|
|
469
|
+
function setDevice(nodeId, { name, publish } = {}) {
|
|
470
|
+
nodeId = String(nodeId);
|
|
471
|
+
_ensureNames();
|
|
472
|
+
const entry = _deviceEntry(nodeId);
|
|
473
|
+
if (!entry) throw new Error(`Matter node not found: ${nodeId}`);
|
|
474
|
+
const old = { ...entry };
|
|
475
|
+
if (name !== undefined) {
|
|
476
|
+
const clean = t.mqttName(name);
|
|
477
|
+
if (!clean || clean !== String(name).trim()) throw new Error(`"${name}" is not a valid MQTT name — use lower-case letters, digits and underscores (like "${clean}")`);
|
|
478
|
+
const taken = Object.entries(_settings.devices)
|
|
479
|
+
.filter(([id]) => id !== nodeId)
|
|
480
|
+
.map(([, d]) => d.name);
|
|
481
|
+
if (taken.includes(clean)) throw new Error(`"${clean}" is already used by another device`);
|
|
482
|
+
entry.name = clean;
|
|
483
|
+
}
|
|
484
|
+
if (publish !== undefined) entry.publish = publish !== false;
|
|
485
|
+
const changed = old.name !== entry.name || old.publish !== entry.publish;
|
|
486
|
+
_save();
|
|
487
|
+
if (changed && _settings.enabled && _started) {
|
|
488
|
+
if (old.name !== entry.name || entry.publish === false) {
|
|
489
|
+
// the old topics of this device go (its name identifies them in the store)
|
|
490
|
+
_cleanup({ device: old.name });
|
|
491
|
+
}
|
|
492
|
+
if (entry.publish !== false) {
|
|
493
|
+
const dev = _ctrl()
|
|
494
|
+
.listPaired()
|
|
495
|
+
.find((d) => d.nodeId === nodeId);
|
|
496
|
+
if (dev) _publishNode(dev);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
_broadcast?.({ type: 'matter:bridge' });
|
|
500
|
+
return getSettings();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Test hook. */
|
|
504
|
+
function _reset() {
|
|
505
|
+
_settings = defaults();
|
|
506
|
+
_started = false;
|
|
507
|
+
_listening = false;
|
|
508
|
+
_last = new Map();
|
|
509
|
+
_published = new Set();
|
|
510
|
+
_lastWarn = new Map();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
module.exports = { init, start, stop, onMessage, getSettings, updateSettings, setDevice, _reset, _publishAttr, _publishEvent, _cleanup };
|