smart-home-engine 1.20.8 → 1.21.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-AcVonavW.js +235 -0
- package/dist/web/assets/{index-C3njyEHY.css → index-BAau5K4k.css} +1 -1
- package/dist/web/assets/{tsMode-4yeSlBtg.js → tsMode-BsFm8afo.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/index.js +24 -7
- package/src/lib/ha-discovery.js +349 -0
- package/src/matter/controller.js +14 -4
- package/src/sandbox/matter-sandbox.js +14 -1
- package/src/web/mqtt-api.js +33 -0
- package/dist/web/assets/index-t_EkMUG4.js +0 -234
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-
|
|
1
|
+
import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-AcVonavW.js";/*!-----------------------------------------------------------------------------
|
|
2
2
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
3
|
* Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
|
|
4
4
|
* Released under the MIT license
|
package/dist/web/index.html
CHANGED
|
@@ -172,10 +172,10 @@
|
|
|
172
172
|
}
|
|
173
173
|
})();
|
|
174
174
|
</script>
|
|
175
|
-
<script type="module" crossorigin src="/assets/index-
|
|
175
|
+
<script type="module" crossorigin src="/assets/index-AcVonavW.js"></script>
|
|
176
176
|
<link rel="modulepreload" crossorigin href="/assets/monaco-langs-Dt8a9eeo.js">
|
|
177
177
|
<link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
|
|
178
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
178
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BAau5K4k.css">
|
|
179
179
|
</head>
|
|
180
180
|
<body>
|
|
181
181
|
<div id="app"></div>
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -196,13 +196,30 @@ const _global = {};
|
|
|
196
196
|
let _blockingScript = null;
|
|
197
197
|
let _blockingScriptTs = 0; // timestamp when the last _dispatch() call finished
|
|
198
198
|
|
|
199
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* Wrap a user callback dispatch so the heartbeat can identify the active
|
|
201
|
+
* script — and so async callback errors are caught close to their source with
|
|
202
|
+
* script attribution (S1): when the callback returns a Promise, a rejection is
|
|
203
|
+
* logged with the script's label. The global unhandledRejection handler stays
|
|
204
|
+
* as a last resort but cannot name the script; label-prefixed errors also show
|
|
205
|
+
* up in the script's log panel in the Scripts tab.
|
|
206
|
+
*/
|
|
200
207
|
function _dispatch(label, fn) {
|
|
201
208
|
_blockingScript = label;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
209
|
+
let result;
|
|
210
|
+
try {
|
|
211
|
+
result = fn();
|
|
212
|
+
} finally {
|
|
213
|
+
// Record end-time synchronously — no setImmediate, which would race with the
|
|
214
|
+
// heartbeat timer when blocking occurs inside a poll-phase (I/O) callback.
|
|
215
|
+
_blockingScriptTs = Date.now();
|
|
216
|
+
}
|
|
217
|
+
if (result && typeof result.then === 'function' && typeof result.catch === 'function') {
|
|
218
|
+
result.catch((err) => {
|
|
219
|
+
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
220
|
+
log.error(makeLabel(label) + ' async callback error: ' + msg);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
206
223
|
}
|
|
207
224
|
|
|
208
225
|
// Sun scheduling
|
|
@@ -867,11 +884,11 @@ function runScript(script, name, _origin) {
|
|
|
867
884
|
subscriptions.push({ topic, options, callback: typeof callback === 'function' && scriptDomain.bind(callback), _script: name });
|
|
868
885
|
|
|
869
886
|
if (options.retain && store.has('mqtt::' + topic) && typeof callback === 'function') {
|
|
870
|
-
callback(topic, store.get('mqtt::' + topic), store.getObject('mqtt::' + topic));
|
|
887
|
+
_dispatch(name, () => callback(topic, store.get('mqtt::' + topic), store.getObject('mqtt::' + topic)));
|
|
871
888
|
} else if (options.retain && (/\/\+\//.test(topic) || /\+$/.test(topic) || /\+/.test(topic) || topic.endsWith('#')) && typeof callback === 'function') {
|
|
872
889
|
for (const [t, obj] of store.mqttEntries()) {
|
|
873
890
|
if (mqttWildcards(t, topic)) {
|
|
874
|
-
callback(t, obj.val, obj);
|
|
891
|
+
_dispatch(name, () => callback(t, obj.val, obj));
|
|
875
892
|
}
|
|
876
893
|
}
|
|
877
894
|
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Home Assistant MQTT discovery analysis (roadmap M10).
|
|
5
|
+
*
|
|
6
|
+
* Pure functions over the daemon's state store: parse every retained
|
|
7
|
+
* `<prefix>/<component>/[<node_id>/]<object_id>/config` announcement, group
|
|
8
|
+
* the entities by the device they belong to and derive, per device,
|
|
9
|
+
*
|
|
10
|
+
* - the config topics that announce it,
|
|
11
|
+
* - the state / command / availability topics the announcements reference,
|
|
12
|
+
* - the retained topics that can safely be wiped along with the device
|
|
13
|
+
* ("state topics": referenced topics plus everything below the device's
|
|
14
|
+
* own topic prefix(es), never anything shared with another device),
|
|
15
|
+
* - an *orphaned* flag (none of the referenced topics exist any more) and a
|
|
16
|
+
* *duplicate* flag (another device with the same name exists).
|
|
17
|
+
*
|
|
18
|
+
* Both the classic per-entity format and the device-level format (HA 2024.4+,
|
|
19
|
+
* `<prefix>/device/<id>/config` with a `cmps` map) are supported, including
|
|
20
|
+
* the abbreviated key names and the `~` base-topic shorthand.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const DEFAULT_PREFIX = 'homeassistant';
|
|
24
|
+
|
|
25
|
+
/** Abbreviation → full key (subset of HA's ABBREVIATIONS relevant here). */
|
|
26
|
+
const ABBR = {
|
|
27
|
+
dev: 'device',
|
|
28
|
+
ids: 'identifiers',
|
|
29
|
+
mf: 'manufacturer',
|
|
30
|
+
mdl: 'model',
|
|
31
|
+
sw: 'sw_version',
|
|
32
|
+
hw: 'hw_version',
|
|
33
|
+
cns: 'connections',
|
|
34
|
+
cmps: 'components',
|
|
35
|
+
uniq_id: 'unique_id',
|
|
36
|
+
p: 'platform',
|
|
37
|
+
stat_t: 'state_topic',
|
|
38
|
+
cmd_t: 'command_topic',
|
|
39
|
+
avty: 'availability',
|
|
40
|
+
avty_t: 'availability_topic',
|
|
41
|
+
json_attr_t: 'json_attributes_topic',
|
|
42
|
+
t: 'topic',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const TOPIC_KEY_RE = /(^|_)(topic|t)$/;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalise abbreviated keys (recursively) so the rest of the module only
|
|
49
|
+
* deals with long names.
|
|
50
|
+
* @param {*} v
|
|
51
|
+
* @returns {*}
|
|
52
|
+
*/
|
|
53
|
+
function expandKeys(v) {
|
|
54
|
+
if (Array.isArray(v)) return v.map(expandKeys);
|
|
55
|
+
if (!v || typeof v !== 'object') return v;
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [k, val] of Object.entries(v)) {
|
|
58
|
+
out[ABBR[k] || k] = expandKeys(val);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply the `~` base-topic shorthand to a topic value.
|
|
65
|
+
* @param {string} t
|
|
66
|
+
* @param {string} base
|
|
67
|
+
*/
|
|
68
|
+
function applyBase(t, base) {
|
|
69
|
+
if (!base || typeof t !== 'string') return t;
|
|
70
|
+
if (t.startsWith('~')) return base + t.slice(1);
|
|
71
|
+
if (t.endsWith('~')) return t.slice(0, -1) + base;
|
|
72
|
+
return t;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Collect every topic string referenced by a (key-expanded) entity config,
|
|
77
|
+
* split into *state* topics (`state_topic`, `command_topic`, `*_topic`, …)
|
|
78
|
+
* and *availability* topics (`availability_topic`, `availability[].topic`).
|
|
79
|
+
* Availability topics are frequently shared infrastructure (a bridge's
|
|
80
|
+
* `…/bridge/state`), so they are kept apart: they neither derive a device's
|
|
81
|
+
* topic prefix nor decide whether a device is still alive.
|
|
82
|
+
* @param {object} cfg
|
|
83
|
+
* @param {string} base
|
|
84
|
+
* @returns {{state: string[], availability: string[]}}
|
|
85
|
+
*/
|
|
86
|
+
function collectTopics(cfg, base) {
|
|
87
|
+
const state = new Set();
|
|
88
|
+
const availability = new Set();
|
|
89
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
90
|
+
if (k === 'device' || k === 'components') continue;
|
|
91
|
+
if (k === 'availability_topic') {
|
|
92
|
+
if (typeof v === 'string' && v) availability.add(applyBase(v, base));
|
|
93
|
+
} else if (k === 'availability') {
|
|
94
|
+
const list = Array.isArray(v) ? v : [v];
|
|
95
|
+
for (const a of list) {
|
|
96
|
+
if (a && typeof a === 'object' && typeof a.topic === 'string' && a.topic) availability.add(applyBase(a.topic, base));
|
|
97
|
+
else if (typeof a === 'string' && a) availability.add(applyBase(a, base));
|
|
98
|
+
}
|
|
99
|
+
} else if (TOPIC_KEY_RE.test(k) && typeof v === 'string' && v) {
|
|
100
|
+
state.add(applyBase(v, base));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { state: [...state], availability: [...availability] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Merge two collectTopics() results. */
|
|
107
|
+
function mergeTopics(a, b) {
|
|
108
|
+
return {
|
|
109
|
+
state: [...new Set([...a.state, ...b.state])],
|
|
110
|
+
availability: [...new Set([...a.availability, ...b.availability])],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Turn HA's `identifiers` (string | (string|string[])[]) into a stable key.
|
|
116
|
+
* @param {*} ids
|
|
117
|
+
* @returns {string|null}
|
|
118
|
+
*/
|
|
119
|
+
function identifierKey(ids) {
|
|
120
|
+
if (typeof ids === 'string') return ids || null;
|
|
121
|
+
if (Array.isArray(ids) && ids.length) {
|
|
122
|
+
const first = ids[0];
|
|
123
|
+
if (Array.isArray(first)) return first.map(String).join(':');
|
|
124
|
+
if (first !== null && first !== undefined && first !== '') return String(first);
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse a discovery config topic.
|
|
131
|
+
* @param {string} topic
|
|
132
|
+
* @param {string} prefix
|
|
133
|
+
* @returns {{component:string,nodeId:string|null,objectId:string}|null}
|
|
134
|
+
*/
|
|
135
|
+
function parseConfigTopic(topic, prefix) {
|
|
136
|
+
if (!topic.startsWith(prefix + '/')) return null;
|
|
137
|
+
const parts = topic.slice(prefix.length + 1).split('/');
|
|
138
|
+
if (parts[parts.length - 1] !== 'config') return null;
|
|
139
|
+
if (parts.length === 3) return { component: parts[0], nodeId: null, objectId: parts[1] };
|
|
140
|
+
if (parts.length === 4) return { component: parts[0], nodeId: parts[1], objectId: parts[2] };
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Longest path prefix of a topic (all segments but the last).
|
|
146
|
+
* @param {string} t
|
|
147
|
+
*/
|
|
148
|
+
function dirname(t) {
|
|
149
|
+
const i = t.lastIndexOf('/');
|
|
150
|
+
return i > 0 ? t.slice(0, i) : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function segCount(t) {
|
|
154
|
+
return t ? t.split('/').length : 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Analyse the retained state for HA discovery announcements.
|
|
159
|
+
*
|
|
160
|
+
* @param {Iterable<[string, {val:*, ts?:number}]>} entries [topic, stateObj] pairs (store.mqttEntries())
|
|
161
|
+
* @param {{prefix?: string}} [opts]
|
|
162
|
+
* @returns {{prefix:string, devices:object[], entityCount:number}}
|
|
163
|
+
*/
|
|
164
|
+
function analyzeDiscovery(entries, opts = {}) {
|
|
165
|
+
const prefix = (opts.prefix || DEFAULT_PREFIX).replace(/\/+$/, '');
|
|
166
|
+
const all = new Map(); // topic → {val, ts}
|
|
167
|
+
for (const [topic, obj] of entries) all.set(topic, obj);
|
|
168
|
+
|
|
169
|
+
/** @type {Map<string, object>} deviceKey → device */
|
|
170
|
+
const devices = new Map();
|
|
171
|
+
let entityCount = 0;
|
|
172
|
+
|
|
173
|
+
const getDevice = (key, info, fallbackName) => {
|
|
174
|
+
let d = devices.get(key);
|
|
175
|
+
if (!d) {
|
|
176
|
+
d = {
|
|
177
|
+
id: key,
|
|
178
|
+
name: null,
|
|
179
|
+
manufacturer: null,
|
|
180
|
+
model: null,
|
|
181
|
+
identifiers: [],
|
|
182
|
+
entities: [],
|
|
183
|
+
configTopics: [],
|
|
184
|
+
refTopics: new Set(),
|
|
185
|
+
_stateRefs: new Set(),
|
|
186
|
+
_candidatePrefixes: new Set(),
|
|
187
|
+
};
|
|
188
|
+
devices.set(key, d);
|
|
189
|
+
}
|
|
190
|
+
if (info) {
|
|
191
|
+
if (!d.name && typeof info.name === 'string') d.name = info.name;
|
|
192
|
+
if (!d.manufacturer && typeof info.manufacturer === 'string') d.manufacturer = info.manufacturer;
|
|
193
|
+
if (!d.model && typeof info.model === 'string') d.model = info.model;
|
|
194
|
+
if (!d.identifiers.length && info.identifiers !== undefined) {
|
|
195
|
+
d.identifiers = (Array.isArray(info.identifiers) ? info.identifiers : [info.identifiers]).map((x) => (Array.isArray(x) ? x.join(':') : String(x)));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!d.name && fallbackName) d.name = fallbackName;
|
|
199
|
+
return d;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const addEntity = (d, entity, refs) => {
|
|
203
|
+
entity.topics = [...new Set([...refs.state, ...refs.availability])].sort();
|
|
204
|
+
d.entities.push(entity);
|
|
205
|
+
entityCount++;
|
|
206
|
+
for (const t of entity.topics) {
|
|
207
|
+
if (t.startsWith(prefix + '/')) continue; // never treat discovery topics as state
|
|
208
|
+
d.refTopics.add(t);
|
|
209
|
+
}
|
|
210
|
+
for (const t of refs.state) {
|
|
211
|
+
if (t.startsWith(prefix + '/')) continue;
|
|
212
|
+
d._stateRefs.add(t);
|
|
213
|
+
const dir = dirname(t);
|
|
214
|
+
if (segCount(dir) >= 2) d._candidatePrefixes.add(dir);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
for (const [topic, obj] of all) {
|
|
219
|
+
const parsed = parseConfigTopic(topic, prefix);
|
|
220
|
+
if (!parsed) continue;
|
|
221
|
+
const raw = obj && obj.val;
|
|
222
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
223
|
+
const cfg = expandKeys(raw);
|
|
224
|
+
const base = typeof cfg['~'] === 'string' ? cfg['~'] : '';
|
|
225
|
+
const devInfo = cfg.device && typeof cfg.device === 'object' ? cfg.device : null;
|
|
226
|
+
const ts = obj.ts || 0;
|
|
227
|
+
|
|
228
|
+
if (parsed.component === 'device' && cfg.components && typeof cfg.components === 'object') {
|
|
229
|
+
// Device-level discovery: one payload announces all components
|
|
230
|
+
const key = identifierKey(devInfo && devInfo.identifiers) || (devInfo && devInfo.name) || 'device:' + parsed.objectId;
|
|
231
|
+
const d = getDevice(key, devInfo, parsed.objectId);
|
|
232
|
+
d.configTopics.push(topic);
|
|
233
|
+
const shared = collectTopics(cfg, base);
|
|
234
|
+
for (const [cid, comp] of Object.entries(cfg.components)) {
|
|
235
|
+
if (!comp || typeof comp !== 'object') continue;
|
|
236
|
+
addEntity(
|
|
237
|
+
d,
|
|
238
|
+
{
|
|
239
|
+
component: typeof comp.platform === 'string' ? comp.platform : 'unknown',
|
|
240
|
+
objectId: cid,
|
|
241
|
+
name: typeof comp.name === 'string' ? comp.name : cid,
|
|
242
|
+
uniqueId: typeof comp.unique_id === 'string' ? comp.unique_id : null,
|
|
243
|
+
configTopic: topic,
|
|
244
|
+
ts,
|
|
245
|
+
},
|
|
246
|
+
mergeTopics(shared, collectTopics(comp, base)),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const key =
|
|
253
|
+
identifierKey(devInfo && devInfo.identifiers) ||
|
|
254
|
+
(devInfo && typeof devInfo.name === 'string' && devInfo.name) ||
|
|
255
|
+
(parsed.nodeId ? 'node:' + parsed.nodeId : 'entity:' + parsed.component + '/' + parsed.objectId);
|
|
256
|
+
const d = getDevice(key, devInfo, parsed.nodeId || parsed.objectId);
|
|
257
|
+
d.configTopics.push(topic);
|
|
258
|
+
addEntity(
|
|
259
|
+
d,
|
|
260
|
+
{
|
|
261
|
+
component: parsed.component,
|
|
262
|
+
objectId: parsed.objectId,
|
|
263
|
+
name: typeof cfg.name === 'string' ? cfg.name : parsed.objectId,
|
|
264
|
+
uniqueId: typeof cfg.unique_id === 'string' ? cfg.unique_id : null,
|
|
265
|
+
configTopic: topic,
|
|
266
|
+
ts,
|
|
267
|
+
},
|
|
268
|
+
collectTopics(cfg, base),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Topics / prefixes referenced by more than one device are shared
|
|
273
|
+
// infrastructure (e.g. `zigbee2mqtt/bridge/state`) and must never be wiped.
|
|
274
|
+
const refOwners = new Map();
|
|
275
|
+
const prefixOwners = new Map();
|
|
276
|
+
for (const d of devices.values()) {
|
|
277
|
+
for (const t of d._stateRefs) refOwners.set(t, (refOwners.get(t) || 0) + 1);
|
|
278
|
+
for (const p of d._candidatePrefixes) prefixOwners.set(p, (prefixOwners.get(p) || 0) + 1);
|
|
279
|
+
}
|
|
280
|
+
const isSharedPrefix = (p) => {
|
|
281
|
+
// shared if the prefix itself, or any ancestor of it, is claimed by another device
|
|
282
|
+
for (const [q, n] of prefixOwners) {
|
|
283
|
+
if (n > 1 && (q === p || p.startsWith(q + '/'))) return true;
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const nameCount = new Map();
|
|
289
|
+
for (const d of devices.values()) {
|
|
290
|
+
if (d.name) {
|
|
291
|
+
const k = d.name.trim().toLowerCase();
|
|
292
|
+
nameCount.set(k, (nameCount.get(k) || 0) + 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const allTopics = [...all.keys()];
|
|
297
|
+
const result = [];
|
|
298
|
+
for (const d of devices.values()) {
|
|
299
|
+
// Only state/command topics that no other device references count as
|
|
300
|
+
// "owned"; they drive both the liveness check and the wipe list.
|
|
301
|
+
const ownRefs = [...d._stateRefs].filter((t) => refOwners.get(t) === 1);
|
|
302
|
+
const prefixes = [...d._candidatePrefixes].filter((p) => !isSharedPrefix(p));
|
|
303
|
+
// Drop prefixes nested inside another kept prefix of the same device
|
|
304
|
+
const statePrefixes = prefixes.filter((p) => !prefixes.some((q) => q !== p && p.startsWith(q + '/'))).sort();
|
|
305
|
+
|
|
306
|
+
const stateSet = new Set();
|
|
307
|
+
for (const t of ownRefs) if (all.has(t)) stateSet.add(t);
|
|
308
|
+
for (const p of statePrefixes) {
|
|
309
|
+
for (const t of allTopics) {
|
|
310
|
+
if ((t === p || t.startsWith(p + '/')) && !t.startsWith(prefix + '/')) stateSet.add(t);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const t of d.configTopics) stateSet.delete(t);
|
|
314
|
+
const stateTopics = [...stateSet].sort();
|
|
315
|
+
|
|
316
|
+
let lastSeen = null;
|
|
317
|
+
let alive = 0;
|
|
318
|
+
for (const t of ownRefs) {
|
|
319
|
+
const o = all.get(t);
|
|
320
|
+
if (o) {
|
|
321
|
+
alive++;
|
|
322
|
+
if (o.ts && (lastSeen === null || o.ts > lastSeen)) lastSeen = o.ts;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const configTs = Math.max(0, ...d.entities.map((e) => e.ts || 0));
|
|
326
|
+
|
|
327
|
+
result.push({
|
|
328
|
+
id: d.id,
|
|
329
|
+
name: d.name,
|
|
330
|
+
manufacturer: d.manufacturer,
|
|
331
|
+
model: d.model,
|
|
332
|
+
identifiers: d.identifiers,
|
|
333
|
+
entities: d.entities.sort((a, b) => a.configTopic.localeCompare(b.configTopic)),
|
|
334
|
+
configTopics: [...new Set(d.configTopics)].sort(),
|
|
335
|
+
refTopics: [...d.refTopics].sort(),
|
|
336
|
+
statePrefixes,
|
|
337
|
+
stateTopics,
|
|
338
|
+
orphaned: ownRefs.length > 0 && alive === 0,
|
|
339
|
+
duplicate: Boolean(d.name && nameCount.get(d.name.trim().toLowerCase()) > 1),
|
|
340
|
+
lastSeen,
|
|
341
|
+
configTs: configTs || null,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
result.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
|
346
|
+
return { prefix, devices: result, entityCount };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = { analyzeDiscovery, DEFAULT_PREFIX, parseConfigTopic, expandKeys, collectTopics, identifierKey };
|
package/src/matter/controller.js
CHANGED
|
@@ -722,13 +722,18 @@ function subscribeAttribute(scriptFile, nodeId, endpointId, clusterName, attrNam
|
|
|
722
722
|
if (!changeEvent) throw new Error(`Attribute "${attrName}" on cluster "${clusterName}" has no change event`);
|
|
723
723
|
|
|
724
724
|
const listenerId = _nextListenerId++;
|
|
725
|
-
const
|
|
725
|
+
const observer = (value, oldValue) => {
|
|
726
726
|
try {
|
|
727
727
|
callback(value, oldValue);
|
|
728
728
|
} catch (err) {
|
|
729
729
|
_log?.error(`matter subscriber error in ${scriptFile}:`, err.message);
|
|
730
730
|
}
|
|
731
|
-
}
|
|
731
|
+
};
|
|
732
|
+
// Observable.on() returns void — detaching needs off() with the same
|
|
733
|
+
// observer reference. (A cancel based on on()'s return value silently did
|
|
734
|
+
// nothing, leaking one listener per hot-reload.)
|
|
735
|
+
changeEvent.on(observer);
|
|
736
|
+
const cancel = () => changeEvent.off(observer);
|
|
732
737
|
|
|
733
738
|
if (!_listeners.has(scriptFile)) _listeners.set(scriptFile, new Map());
|
|
734
739
|
_listeners.get(scriptFile).set(listenerId, { cancel });
|
|
@@ -756,13 +761,18 @@ function unsubscribe(scriptFile, listenerId) {
|
|
|
756
761
|
function cleanup(scriptFile) {
|
|
757
762
|
const scriptListeners = _listeners.get(scriptFile);
|
|
758
763
|
if (!scriptListeners) return;
|
|
764
|
+
let cancelled = 0;
|
|
759
765
|
for (const { cancel } of scriptListeners.values()) {
|
|
760
766
|
try {
|
|
761
767
|
cancel();
|
|
762
|
-
|
|
763
|
-
|
|
768
|
+
cancelled++;
|
|
769
|
+
} catch (err) {
|
|
770
|
+
// Don't swallow silently — a broken cancel means a leaked listener
|
|
771
|
+
// (this hid the on()-returns-void bug above for a long time).
|
|
772
|
+
_log?.warn(`matter: failed to cancel a subscription of ${scriptFile}: ${err.message}`);
|
|
764
773
|
}
|
|
765
774
|
}
|
|
775
|
+
if (cancelled > 0) _log?.debug(`matter: cancelled ${cancelled} subscription(s) of ${scriptFile}`);
|
|
766
776
|
_listeners.delete(scriptFile);
|
|
767
777
|
}
|
|
768
778
|
|
|
@@ -42,8 +42,21 @@ module.exports = function (she, { scriptDomain, scriptName, scriptFile }) {
|
|
|
42
42
|
* @returns {number} listenerId
|
|
43
43
|
*/
|
|
44
44
|
sub(nodeId, endpointId, clusterName, attrName, callback) {
|
|
45
|
+
// Wrap so callback errors (sync and async) are logged with the
|
|
46
|
+
// script's label — she.error carries the label, which also routes
|
|
47
|
+
// the line into the script's log panel in the Scripts tab (S1).
|
|
48
|
+
const wrapped = (value, oldValue) => {
|
|
49
|
+
try {
|
|
50
|
+
const result = callback(value, oldValue);
|
|
51
|
+
if (result && typeof result.then === 'function' && typeof result.catch === 'function') {
|
|
52
|
+
result.catch((err) => she.error('async callback error:', err instanceof Error ? (err.stack ?? err.message) : String(err)));
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
she.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
56
|
+
}
|
|
57
|
+
};
|
|
45
58
|
try {
|
|
46
|
-
return controller.subscribeAttribute(trackingKey, nodeId, endpointId, clusterName, attrName,
|
|
59
|
+
return controller.subscribeAttribute(trackingKey, nodeId, endpointId, clusterName, attrName, wrapped);
|
|
47
60
|
} catch (err) {
|
|
48
61
|
scriptDomain.emit('error', err);
|
|
49
62
|
}
|
package/src/web/mqtt-api.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* Routes:
|
|
7
7
|
* GET /she/mqtt/state → snapshot of all retained MQTT topic values
|
|
8
8
|
* POST /she/mqtt/publish → publish a message to a topic
|
|
9
|
+
* GET /she/mqtt/ha-discovery?prefix= → Home Assistant discovery devices (grouped announcements)
|
|
10
|
+
* DELETE /she/mqtt/ha-discovery { topics } → clear retained messages on the given topics
|
|
9
11
|
*
|
|
10
12
|
* Call init(store, getMqttClient) once after the state store is created.
|
|
11
13
|
* The getMqttClient callback is a zero-arg function returning the live mqtt client
|
|
@@ -18,6 +20,7 @@
|
|
|
18
20
|
|
|
19
21
|
const express = require('express');
|
|
20
22
|
const { broadcast, broadcastBrokerLog } = require('./log-ws');
|
|
23
|
+
const { analyzeDiscovery, DEFAULT_PREFIX } = require('../lib/ha-discovery');
|
|
21
24
|
|
|
22
25
|
const BROKER_LOG_TOPICS = new Set(['D', 'I', 'N', 'W', 'E'].map((l) => `$SYS/broker/log/${l}`));
|
|
23
26
|
|
|
@@ -75,4 +78,34 @@ router.post('/publish', (req, res) => {
|
|
|
75
78
|
});
|
|
76
79
|
});
|
|
77
80
|
|
|
81
|
+
// GET /she/mqtt/ha-discovery?prefix=homeassistant — devices announced via HA MQTT discovery
|
|
82
|
+
router.get('/ha-discovery', (req, res) => {
|
|
83
|
+
if (!_store) return res.json({ prefix: DEFAULT_PREFIX, devices: [], entityCount: 0 });
|
|
84
|
+
const prefix = typeof req.query.prefix === 'string' && req.query.prefix.trim() ? req.query.prefix.trim() : DEFAULT_PREFIX;
|
|
85
|
+
if (/[+#]/.test(prefix)) return res.status(400).json({ error: 'prefix must not contain wildcards' });
|
|
86
|
+
res.json(analyzeDiscovery(_store.mqttEntries(), { prefix }));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// DELETE /she/mqtt/ha-discovery — { topics: string[] } → clear the retained message on each topic
|
|
90
|
+
router.delete('/ha-discovery', async (req, res) => {
|
|
91
|
+
const mqtt = _getMqtt();
|
|
92
|
+
if (!mqtt) return res.status(503).json({ error: 'MQTT not connected' });
|
|
93
|
+
const topics = req.body && req.body.topics;
|
|
94
|
+
if (!Array.isArray(topics) || topics.length === 0) return res.status(400).json({ error: 'topics[] required' });
|
|
95
|
+
const bad = topics.find((t) => typeof t !== 'string' || !t || /[+#]/.test(t));
|
|
96
|
+
if (bad !== undefined) return res.status(400).json({ error: 'invalid topic: ' + String(bad) });
|
|
97
|
+
|
|
98
|
+
const unique = [...new Set(topics)];
|
|
99
|
+
const errors = [];
|
|
100
|
+
for (const t of unique) {
|
|
101
|
+
await new Promise((resolve) => {
|
|
102
|
+
mqtt.publish(t, '', { retain: true, qos: 0 }, (err) => {
|
|
103
|
+
if (err) errors.push({ topic: t, error: err.message });
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
res.json({ ok: errors.length === 0, cleared: unique.length - errors.length, errors });
|
|
109
|
+
});
|
|
110
|
+
|
|
78
111
|
module.exports = { router, init };
|