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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
- package/dist/web/assets/index-BbwiXmS-.css +1 -0
- package/dist/web/assets/index-DD-XScWV.js +140 -0
- package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
- package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
- package/dist/web/assets/tsMode-DxTbjAE2.js +16 -0
- package/dist/web/index.html +164 -0
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
- package/package.json +85 -10
- package/src/config.js +56 -0
- package/src/elastic.js +19 -0
- package/src/index.js +1192 -0
- package/src/influx.js +25 -0
- package/src/lib/mqtt-wildcards.js +34 -0
- package/src/lib/parse-payload.js +29 -0
- package/src/lib/redis.js +74 -0
- package/src/lib/shedb-core.js +447 -0
- package/src/lib/shedb-worker.js +126 -0
- package/src/lib/state-store.js +97 -0
- package/src/lib/storage.js +74 -0
- package/src/matter/controller.js +307 -0
- package/src/sandbox/api.js +57 -0
- package/src/sandbox/elastic-sandbox.js +88 -0
- package/src/sandbox/influx-sandbox.js +107 -0
- package/src/sandbox/matter-sandbox.js +92 -0
- package/src/sandbox/shedb-sandbox.js +89 -0
- package/src/sandbox/stdlib.js +132 -0
- package/src/scripts/hello.js +3 -0
- package/src/web/ai-api.js +443 -0
- package/src/web/auth.js +186 -0
- package/src/web/config-api.js +34 -0
- package/src/web/deps-api.js +138 -0
- package/src/web/git-api.js +188 -0
- package/src/web/log-ws.js +78 -0
- package/src/web/matter-api.js +102 -0
- package/src/web/mqtt-api.js +65 -0
- package/src/web/scripts-api.js +192 -0
- package/src/web/server.js +139 -0
- package/src/web/shedb-api.js +140 -0
- package/src/web/shedb.js +168 -0
- 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 };
|