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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-Dt8a9eeo.js";import{t as I}from"./index-t_EkMUG4.js";/*!-----------------------------------------------------------------------------
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
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-t_EkMUG4.js"></script>
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-C3njyEHY.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.20.8",
3
+ "version": "1.21.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
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
- /** Wrap a user callback dispatch so the heartbeat can identify the active script. */
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
- fn();
203
- // Record end-time synchronously — no setImmediate, which would race with the
204
- // heartbeat timer when blocking occurs inside a poll-phase (I/O) callback.
205
- _blockingScriptTs = Date.now();
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 };
@@ -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 cancel = changeEvent.on((value, oldValue) => {
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
- } catch {
763
- // ignore
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, callback);
59
+ return controller.subscribeAttribute(trackingKey, nodeId, endpointId, clusterName, attrName, wrapped);
47
60
  } catch (err) {
48
61
  scriptDomain.emit('error', err);
49
62
  }
@@ -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 };