smart-home-engine 0.0.1 → 0.11.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
  4. package/dist/web/assets/index-BbwiXmS-.css +1 -0
  5. package/dist/web/assets/index-DD-XScWV.js +140 -0
  6. package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
  7. package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
  8. package/dist/web/assets/tsMode-DxTbjAE2.js +16 -0
  9. package/dist/web/index.html +164 -0
  10. package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
  11. package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
  12. package/package.json +85 -10
  13. package/src/config.js +56 -0
  14. package/src/elastic.js +19 -0
  15. package/src/index.js +1192 -0
  16. package/src/influx.js +25 -0
  17. package/src/lib/mqtt-wildcards.js +34 -0
  18. package/src/lib/parse-payload.js +29 -0
  19. package/src/lib/redis.js +74 -0
  20. package/src/lib/shedb-core.js +447 -0
  21. package/src/lib/shedb-worker.js +126 -0
  22. package/src/lib/state-store.js +97 -0
  23. package/src/lib/storage.js +74 -0
  24. package/src/matter/controller.js +307 -0
  25. package/src/sandbox/api.js +57 -0
  26. package/src/sandbox/elastic-sandbox.js +88 -0
  27. package/src/sandbox/influx-sandbox.js +107 -0
  28. package/src/sandbox/matter-sandbox.js +92 -0
  29. package/src/sandbox/shedb-sandbox.js +89 -0
  30. package/src/sandbox/stdlib.js +132 -0
  31. package/src/scripts/hello.js +3 -0
  32. package/src/web/ai-api.js +443 -0
  33. package/src/web/auth.js +186 -0
  34. package/src/web/config-api.js +34 -0
  35. package/src/web/deps-api.js +138 -0
  36. package/src/web/git-api.js +188 -0
  37. package/src/web/log-ws.js +78 -0
  38. package/src/web/matter-api.js +102 -0
  39. package/src/web/mqtt-api.js +65 -0
  40. package/src/web/scripts-api.js +192 -0
  41. package/src/web/server.js +139 -0
  42. package/src/web/shedb-api.js +140 -0
  43. package/src/web/shedb.js +168 -0
  44. package/index.js +0 -0
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { execFile } = require('child_process');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const https = require('https');
8
+ const { STORAGE_ROOT } = require('../lib/storage');
9
+
10
+ const router = express.Router();
11
+
12
+ /** Ensure ~/.she/package.json exists so npm commands work. */
13
+ function ensurePackageJson() {
14
+ const pkgPath = path.join(STORAGE_ROOT, 'package.json');
15
+ if (!fs.existsSync(pkgPath)) {
16
+ fs.writeFileSync(
17
+ pkgPath,
18
+ JSON.stringify(
19
+ {
20
+ name: 'she-user-scripts',
21
+ version: '1.0.0',
22
+ private: true,
23
+ description: 'User-installed npm packages for she scripts',
24
+ dependencies: {},
25
+ },
26
+ null,
27
+ 2,
28
+ ) + '\n',
29
+ 'utf8',
30
+ );
31
+ }
32
+ }
33
+
34
+ function readPackageJson() {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(path.join(STORAGE_ROOT, 'package.json'), 'utf8'));
37
+ } catch {
38
+ return { dependencies: {} };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Strict npm package-name validation.
44
+ * Allows scoped (@scope/name) and plain names; lowercase; no path traversal.
45
+ */
46
+ function isValidPkgName(name) {
47
+ return typeof name === 'string' && name.length > 0 && name.length <= 214 && /^(@[a-z0-9][a-z0-9_\-.]*\/)?[a-z0-9][a-z0-9_\-.]*$/.test(name);
48
+ }
49
+
50
+ /** Allow semver ranges, tags, and dist-tags (no shell-special chars). */
51
+ function isValidVersion(v) {
52
+ return typeof v === 'string' && v.length > 0 && v.length <= 50 && /^[a-z0-9_\-.*^~>=<|]+$/i.test(v);
53
+ }
54
+
55
+ // GET /she/deps — list installed packages from ~/.she/package.json
56
+ router.get('/', (req, res) => {
57
+ const pkg = readPackageJson();
58
+ const deps = pkg.dependencies || {};
59
+ res.json(Object.entries(deps).map(([name, version]) => ({ name, version })));
60
+ });
61
+
62
+ // GET /she/deps/search?q=term — search the npm registry
63
+ router.get('/search', (req, res) => {
64
+ const q = String(req.query.q ?? '').trim();
65
+ if (!q) return res.status(400).json({ error: 'Missing q parameter' });
66
+
67
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=20`;
68
+ const npmReq = https.get(url, { timeout: 10000 }, (npmRes) => {
69
+ let data = '';
70
+ npmRes.on('data', (chunk) => {
71
+ data += chunk;
72
+ });
73
+ npmRes.on('end', () => {
74
+ try {
75
+ const parsed = JSON.parse(data);
76
+ const results = (parsed.objects ?? []).map((obj) => ({
77
+ name: obj.package.name,
78
+ version: obj.package.version,
79
+ description: obj.package.description ?? '',
80
+ }));
81
+ res.json(results);
82
+ } catch {
83
+ if (!res.headersSent) res.status(502).json({ error: 'Failed to parse npm registry response' });
84
+ }
85
+ });
86
+ });
87
+ npmReq.on('error', (err) => {
88
+ if (!res.headersSent) res.status(502).json({ error: err.message });
89
+ });
90
+ npmReq.on('timeout', () => {
91
+ npmReq.destroy();
92
+ if (!res.headersSent) res.status(504).json({ error: 'npm registry timeout' });
93
+ });
94
+ });
95
+
96
+ // POST /she/deps/install — { name, version? }
97
+ router.post('/install', (req, res) => {
98
+ const name = String(req.body?.name ?? '').trim();
99
+ const version = req.body?.version ? String(req.body.version).trim() : null;
100
+
101
+ if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
102
+ if (version !== null && !isValidVersion(version)) return res.status(400).json({ error: 'Invalid version specifier' });
103
+
104
+ ensurePackageJson();
105
+ const spec = version ? `${name}@${version}` : name;
106
+ execFile('npm', ['install', '--save', spec], { cwd: STORAGE_ROOT, timeout: 120000 }, (err, stdout, stderr) => {
107
+ if (err) return res.status(500).json({ error: stderr || err.message, stdout });
108
+ res.json({ ok: true, stdout, stderr });
109
+ });
110
+ });
111
+
112
+ // POST /she/deps/remove — { name }
113
+ router.post('/remove', (req, res) => {
114
+ const name = String(req.body?.name ?? '').trim();
115
+
116
+ if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
117
+
118
+ ensurePackageJson();
119
+ execFile('npm', ['uninstall', '--save', name], { cwd: STORAGE_ROOT, timeout: 60000 }, (err, stdout, stderr) => {
120
+ if (err) return res.status(500).json({ error: stderr || err.message, stdout });
121
+ res.json({ ok: true, stdout, stderr });
122
+ });
123
+ });
124
+
125
+ // POST /she/deps/update — { name }
126
+ router.post('/update', (req, res) => {
127
+ const name = String(req.body?.name ?? '').trim();
128
+
129
+ if (!isValidPkgName(name)) return res.status(400).json({ error: 'Invalid package name' });
130
+
131
+ ensurePackageJson();
132
+ execFile('npm', ['install', '--save', `${name}@latest`], { cwd: STORAGE_ROOT, timeout: 120000 }, (err, stdout, stderr) => {
133
+ if (err) return res.status(500).json({ error: stderr || err.message, stdout });
134
+ res.json({ ok: true, stdout, stderr });
135
+ });
136
+ });
137
+
138
+ module.exports = { router };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const express = require('express');
4
+ const { execFile } = require('child_process');
5
+ const path = require('path');
6
+
7
+ const router = express.Router();
8
+
9
+ function getRoot(req) {
10
+ return req.app.locals.scriptDir || null;
11
+ }
12
+
13
+ /**
14
+ * Run a git command in the given cwd.
15
+ * Resolves with { stdout, stderr }; rejects with an error that carries .stderr and .stdout.
16
+ */
17
+ function git(args, cwd, timeout = 30000) {
18
+ return new Promise((resolve, reject) => {
19
+ execFile('git', args, { cwd, timeout }, (err, stdout, stderr) => {
20
+ if (err) {
21
+ const e = new Error(stderr.trim() || err.message);
22
+ e.stderr = stderr;
23
+ e.stdout = stdout;
24
+ reject(e);
25
+ } else {
26
+ resolve({ stdout, stderr });
27
+ }
28
+ });
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Find the git root for the given directory.
34
+ * Returns null when the directory is not inside a git repository.
35
+ */
36
+ async function getGitRoot(dir) {
37
+ try {
38
+ const { stdout } = await git(['rev-parse', '--show-toplevel'], dir);
39
+ return stdout.trim().replace(/\//g, path.sep);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolve a safe absolute path within the script root.
47
+ * Returns null when the path escapes the root (traversal attempt).
48
+ */
49
+ function safePath(root, relPath) {
50
+ const abs = path.resolve(root, relPath.replace(/^\/+/, ''));
51
+ if (!abs.startsWith(root + path.sep) && abs !== root) return null;
52
+ return abs;
53
+ }
54
+
55
+ /** Validate commit message: non-empty, no null bytes. */
56
+ function isValidMessage(msg) {
57
+ return typeof msg === 'string' && msg.trim().length > 0 && !msg.includes('\0');
58
+ }
59
+
60
+ // GET /she/git/status
61
+ // Returns branch name, changed files, and ahead/behind counts vs. upstream.
62
+ router.get('/status', async (req, res) => {
63
+ const scriptDir = getRoot(req);
64
+ if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
65
+
66
+ const gitRoot = await getGitRoot(scriptDir);
67
+ if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
68
+
69
+ try {
70
+ const [statusOut, branchOut] = await Promise.all([git(['status', '--porcelain', '-u'], gitRoot), git(['rev-parse', '--abbrev-ref', 'HEAD'], gitRoot)]);
71
+
72
+ const branch = branchOut.stdout.trim();
73
+ const changes = statusOut.stdout
74
+ .split('\n')
75
+ .filter(Boolean)
76
+ .map((line) => ({
77
+ status: line.slice(0, 2).trim(),
78
+ file: line.slice(3),
79
+ }));
80
+
81
+ let ahead = 0;
82
+ let behind = 0;
83
+ try {
84
+ const { stdout } = await git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], gitRoot);
85
+ const parts = stdout.trim().split(/\s+/);
86
+ ahead = parseInt(parts[0], 10) || 0;
87
+ behind = parseInt(parts[1], 10) || 0;
88
+ } catch {
89
+ // No upstream configured — leave as 0.
90
+ }
91
+
92
+ res.json({ branch, changes, ahead, behind });
93
+ } catch (e) {
94
+ res.status(500).json({ error: e.message });
95
+ }
96
+ });
97
+
98
+ // GET /she/git/remotes
99
+ // Returns the configured git remotes: [{ name, fetch, push }].
100
+ router.get('/remotes', async (req, res) => {
101
+ const scriptDir = getRoot(req);
102
+ if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
103
+
104
+ const gitRoot = await getGitRoot(scriptDir);
105
+ if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
106
+
107
+ try {
108
+ const { stdout } = await git(['remote', '-v'], gitRoot);
109
+ const remotes = {};
110
+ for (const line of stdout.split('\n').filter(Boolean)) {
111
+ const m = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
112
+ if (!m) continue;
113
+ const [, name, url, type] = m;
114
+ if (!remotes[name]) remotes[name] = { name, fetch: '', push: '' };
115
+ remotes[name][type] = url;
116
+ }
117
+ res.json(Object.values(remotes));
118
+ } catch (e) {
119
+ res.status(500).json({ error: e.message });
120
+ }
121
+ });
122
+
123
+ // POST /she/git/commit
124
+ // Body: { path?: string, files?: string[], message: string }
125
+ // Stages the given file(s) (or the whole scriptDir when neither is provided) and commits.
126
+ router.post('/commit', async (req, res) => {
127
+ const scriptDir = getRoot(req);
128
+ if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
129
+
130
+ const message = String(req.body?.message ?? '').trim();
131
+ if (!isValidMessage(message)) return res.status(400).json({ error: 'Invalid or empty commit message' });
132
+
133
+ // Accept a single path or an array of paths.
134
+ let relPaths;
135
+ if (req.body?.path) {
136
+ relPaths = [String(req.body.path)];
137
+ } else if (Array.isArray(req.body?.files) && req.body.files.length > 0) {
138
+ relPaths = req.body.files.map(String);
139
+ } else {
140
+ relPaths = [];
141
+ }
142
+
143
+ const gitRoot = await getGitRoot(scriptDir);
144
+ if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
145
+
146
+ try {
147
+ if (relPaths.length > 0) {
148
+ for (const rel of relPaths) {
149
+ const abs = safePath(scriptDir, rel);
150
+ if (!abs) return res.status(400).json({ error: `Invalid path: ${rel}` });
151
+ // Convert to forward slashes for git portability.
152
+ const relToRoot = path.relative(gitRoot, abs).replace(/\\/g, '/');
153
+ await git(['add', relToRoot], gitRoot);
154
+ }
155
+ } else {
156
+ // Stage the entire script directory.
157
+ const scriptDirRel = path.relative(gitRoot, scriptDir).replace(/\\/g, '/');
158
+ await git(['add', scriptDirRel + '/'], gitRoot);
159
+ }
160
+
161
+ await git(['commit', '-m', message], gitRoot);
162
+ res.json({ ok: true });
163
+ } catch (e) {
164
+ res.status(500).json({ error: e.message, stderr: e.stderr });
165
+ }
166
+ });
167
+
168
+ // POST /she/git/push
169
+ // Body: { remote?: string } — defaults to "origin".
170
+ router.post('/push', async (req, res) => {
171
+ const scriptDir = getRoot(req);
172
+ if (!scriptDir) return res.status(500).json({ error: 'scriptDir not configured' });
173
+
174
+ const remote = String(req.body?.remote ?? 'origin').trim();
175
+ if (!/^[a-zA-Z0-9_./-]+$/.test(remote)) return res.status(400).json({ error: 'Invalid remote name' });
176
+
177
+ const gitRoot = await getGitRoot(scriptDir);
178
+ if (!gitRoot) return res.status(404).json({ error: 'Not a git repository' });
179
+
180
+ try {
181
+ const { stdout, stderr } = await git(['push', remote], gitRoot, 60000);
182
+ res.json({ ok: true, stdout, stderr });
183
+ } catch (e) {
184
+ res.status(500).json({ error: e.message, stderr: e.stderr });
185
+ }
186
+ });
187
+
188
+ module.exports = { router };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const { WebSocketServer } = require('ws');
4
+
5
+ let _wss = null;
6
+ const _clients = new Set();
7
+
8
+ /**
9
+ * Attach a WebSocketServer to an existing http.Server.
10
+ * Clients connect to /she/ws. Once connected they receive:
11
+ * - { type: 'log', line: string } — every pino log line
12
+ * - { type: 'ping' } — keepalive every 30 s
13
+ *
14
+ * @param {import('http').Server} httpServer
15
+ * @param {(req: import('http').IncomingMessage) => boolean} [authCheck]
16
+ * Optional function that receives the upgrade request and returns true if
17
+ * the connection should be allowed. Defaults to always-allow.
18
+ */
19
+ function attachWss(httpServer, authCheck = () => true) {
20
+ _wss = new WebSocketServer({ server: httpServer, path: '/she/ws' });
21
+
22
+ _wss.on('connection', (ws, req) => {
23
+ if (!authCheck(req)) {
24
+ ws.close(1008, 'Unauthorized');
25
+ return;
26
+ }
27
+ _clients.add(ws);
28
+ ws.on('close', () => _clients.delete(ws));
29
+ ws.on('error', () => _clients.delete(ws));
30
+ });
31
+
32
+ // Keepalive ping every 30 s
33
+ const pingInterval = setInterval(() => {
34
+ const msg = JSON.stringify({ type: 'ping' });
35
+ for (const ws of _clients) {
36
+ if (ws.readyState === ws.OPEN) ws.send(msg);
37
+ }
38
+ }, 30_000);
39
+
40
+ _wss.on('close', () => clearInterval(pingInterval));
41
+ }
42
+
43
+ /**
44
+ * Broadcast an arbitrary JSON message to all connected WebSocket clients.
45
+ * @param {object} msg - must be JSON-serialisable
46
+ */
47
+ function broadcast(msg) {
48
+ if (_clients.size === 0) return;
49
+ const str = JSON.stringify(msg);
50
+ for (const ws of _clients) {
51
+ if (ws.readyState === ws.OPEN) ws.send(str);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Broadcast a structured log entry to all connected WebSocket clients.
57
+ * @param {{ level: string, msg: string, ts: number }} entry
58
+ */
59
+ function broadcastLog(entry) {
60
+ broadcast({ type: 'log', ...entry });
61
+ }
62
+
63
+ /**
64
+ * Close the WebSocket server.
65
+ * @returns {Promise<void>}
66
+ */
67
+ function closeWss() {
68
+ return new Promise((resolve) => {
69
+ if (_wss) {
70
+ _wss.close(resolve);
71
+ _wss = null;
72
+ } else {
73
+ resolve();
74
+ }
75
+ });
76
+ }
77
+
78
+ module.exports = { attachWss, broadcast, broadcastLog, closeWss };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Matter controller REST API — Express router mounted at /she/matter
5
+ *
6
+ * Routes:
7
+ * GET /she/matter/devices → list paired nodes
8
+ * POST /she/matter/commission → commission a device
9
+ * GET /she/matter/devices/:nodeId → node detail (endpoints + clusters)
10
+ * DELETE /she/matter/devices/:nodeId → unpair a device
11
+ * POST /she/matter/devices/:nodeId/command → invoke a cluster command
12
+ *
13
+ * All NodeIds are represented as decimal strings in JSON.
14
+ */
15
+
16
+ const express = require('express');
17
+
18
+ const router = express.Router();
19
+
20
+ function getController() {
21
+ return require('../matter/controller');
22
+ }
23
+
24
+ function notReady(res) {
25
+ return res.status(503).json({ error: 'Matter controller not started (--matter-storage not set)' });
26
+ }
27
+
28
+ function isReady() {
29
+ try {
30
+ const c = getController();
31
+ return typeof c.listPaired === 'function';
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ // GET /she/matter/devices — list paired nodes
38
+ router.get('/devices', (req, res) => {
39
+ if (!isReady()) return notReady(res);
40
+ try {
41
+ res.json(getController().listPaired());
42
+ } catch (err) {
43
+ res.status(500).json({ error: err.message });
44
+ }
45
+ });
46
+
47
+ // POST /she/matter/commission — { passcode, discriminator? } or { pairingCode }
48
+ router.post('/commission', async (req, res) => {
49
+ if (!isReady()) return notReady(res);
50
+ const body = req.body;
51
+ if (!body || (body.passcode === undefined && body.pairingCode === undefined)) {
52
+ return res.status(400).json({ error: 'body must contain passcode or pairingCode' });
53
+ }
54
+ try {
55
+ const nodeId = await getController().commission(body);
56
+ res.status(201).json({ nodeId });
57
+ } catch (err) {
58
+ res.status(500).json({ error: err.message });
59
+ }
60
+ });
61
+
62
+ // GET /she/matter/devices/:nodeId — node detail
63
+ router.get('/devices/:nodeId', (req, res) => {
64
+ if (!isReady()) return notReady(res);
65
+ try {
66
+ const endpoints = getController().getEndpoints(req.params.nodeId);
67
+ res.json({ nodeId: req.params.nodeId, endpoints });
68
+ } catch (err) {
69
+ if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
70
+ res.status(500).json({ error: err.message });
71
+ }
72
+ });
73
+
74
+ // DELETE /she/matter/devices/:nodeId — unpair
75
+ router.delete('/devices/:nodeId', async (req, res) => {
76
+ if (!isReady()) return notReady(res);
77
+ try {
78
+ await getController().unpair(req.params.nodeId);
79
+ res.json({ ok: true });
80
+ } catch (err) {
81
+ if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
82
+ res.status(500).json({ error: err.message });
83
+ }
84
+ });
85
+
86
+ // POST /she/matter/devices/:nodeId/command — { endpointId, clusterName, command, args? }
87
+ router.post('/devices/:nodeId/command', async (req, res) => {
88
+ if (!isReady()) return notReady(res);
89
+ const { endpointId, clusterName, command, args } = req.body || {};
90
+ if (endpointId === undefined || !clusterName || !command) {
91
+ return res.status(400).json({ error: 'body must contain endpointId, clusterName, command' });
92
+ }
93
+ try {
94
+ const result = await getController().sendCommand(req.params.nodeId, Number(endpointId), clusterName, command, args ?? {});
95
+ res.json({ ok: true, result: result ?? null });
96
+ } catch (err) {
97
+ if (err.message.includes('not found')) return res.status(404).json({ error: err.message });
98
+ res.status(500).json({ error: err.message });
99
+ }
100
+ });
101
+
102
+ module.exports = { router };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MQTT state REST API — Express router mounted at /she/mqtt
5
+ *
6
+ * Routes:
7
+ * GET /she/mqtt/state → snapshot of all retained MQTT topic values
8
+ * POST /she/mqtt/publish → publish a message to a topic
9
+ *
10
+ * Call init(store, getMqttClient) once after the state store is created.
11
+ * The getMqttClient callback is a zero-arg function returning the live mqtt client
12
+ * (or null when no broker is configured).
13
+ *
14
+ * State changes for mqtt:: keys are automatically broadcast to WebSocket clients
15
+ * as { type: 'mqtt', topic, val, ts }.
16
+ */
17
+
18
+ const express = require('express');
19
+ const { broadcast } = require('./log-ws');
20
+
21
+ const router = express.Router();
22
+
23
+ let _store = null;
24
+ let _getMqtt = () => null;
25
+
26
+ /**
27
+ * Initialise the MQTT API with the state store and an MQTT client getter.
28
+ * @param {import('../lib/state-store')} store
29
+ * @param {() => import('mqtt').MqttClient | null} getMqttClient
30
+ */
31
+ function init(store, getMqttClient) {
32
+ _store = store;
33
+ _getMqtt = getMqttClient;
34
+
35
+ // Forward every mqtt:: state change to connected WebSocket clients
36
+ store.on('change', (key, val, obj) => {
37
+ if (!key.startsWith('mqtt::')) return;
38
+ broadcast({ type: 'mqtt', topic: key.slice(6), val: obj.val, ts: obj.ts });
39
+ });
40
+ }
41
+
42
+ // GET /she/mqtt/state — sorted snapshot of all retained MQTT topic values
43
+ router.get('/state', (req, res) => {
44
+ if (!_store) return res.json([]);
45
+ const result = [];
46
+ for (const [topic, obj] of _store.mqttEntries()) {
47
+ result.push({ topic, val: obj.val, ts: obj.ts });
48
+ }
49
+ result.sort((a, b) => a.topic.localeCompare(b.topic));
50
+ res.json(result);
51
+ });
52
+
53
+ // POST /she/mqtt/publish — { topic, payload, retain?, qos? }
54
+ router.post('/publish', (req, res) => {
55
+ const mqtt = _getMqtt();
56
+ if (!mqtt) return res.status(503).json({ error: 'MQTT not connected' });
57
+ const { topic, payload, retain = false, qos = 0 } = req.body || {};
58
+ if (!topic) return res.status(400).json({ error: 'topic required' });
59
+ mqtt.publish(topic, String(payload ?? ''), { retain, qos }, (err) => {
60
+ if (err) return res.status(500).json({ error: err.message });
61
+ res.json({ ok: true });
62
+ });
63
+ });
64
+
65
+ module.exports = { router, init };