odelic 0.0.0-stage → 0.2.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 +187 -2
- package/bin/odelic-mcp.js +5 -0
- package/bin/odelic.js +6 -0
- package/package.json +68 -4
- package/src/cli.js +253 -0
- package/src/client.js +362 -0
- package/src/config.js +128 -0
- package/src/controller.js +219 -0
- package/src/crypto.js +99 -0
- package/src/errors.js +17 -0
- package/src/index.js +11 -0
- package/src/log.js +20 -0
- package/src/mcp.js +89 -0
- package/src/protocol.js +179 -0
- package/src/server.js +176 -0
- package/src/version.js +1 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny dependency-free HTTP/JSON API on top of Controller.
|
|
3
|
+
*
|
|
4
|
+
* GET /health
|
|
5
|
+
* GET /lights list lights (ping)
|
|
6
|
+
* GET /lights/<target> state ("all" = every light)
|
|
7
|
+
* POST /lights/<target>/on | off
|
|
8
|
+
* POST /lights/<target>/light {"color": 0..100, "brightness": 1..100}
|
|
9
|
+
* POST /lights/<target>/night {"level": 0..2}
|
|
10
|
+
* POST /groups/<id>/on | off | light
|
|
11
|
+
* POST /scenes/<index>/play | save
|
|
12
|
+
*
|
|
13
|
+
* Parameters may be sent as a JSON body or as query parameters. <target> is a light name, "all" or a vaddr.
|
|
14
|
+
* When a token is configured every request needs `Authorization: Bearer <token>` (or ?token=...).
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import http from 'node:http';
|
|
18
|
+
|
|
19
|
+
import { InputError } from './errors.js';
|
|
20
|
+
import { log } from './log.js';
|
|
21
|
+
import { toInt } from './controller.js';
|
|
22
|
+
|
|
23
|
+
export const LOOPBACK = ['127.0.0.1', 'localhost', '::1'];
|
|
24
|
+
const MAX_BODY = 65536;
|
|
25
|
+
|
|
26
|
+
export class HttpError extends Error {
|
|
27
|
+
constructor(status, message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'HttpError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function need(method, expected) {
|
|
35
|
+
if (method !== expected) throw new HttpError(405, `use ${expected}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Route one request to the controller. Returns the JSON-able result. */
|
|
39
|
+
export async function dispatch(ctl, method, path, params) {
|
|
40
|
+
let parts;
|
|
41
|
+
try {
|
|
42
|
+
parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map(decodeURIComponent);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new HttpError(400, 'bad URL encoding');
|
|
45
|
+
}
|
|
46
|
+
if (parts.length === 1 && parts[0] === 'health') return { ok: true, ...ctl.info() };
|
|
47
|
+
if (!parts.length) throw new HttpError(404, 'not found');
|
|
48
|
+
const [head] = parts;
|
|
49
|
+
if (head === 'lights') {
|
|
50
|
+
if (parts.length === 1) {
|
|
51
|
+
need(method, 'GET');
|
|
52
|
+
return ctl.lights();
|
|
53
|
+
}
|
|
54
|
+
const target = parts[1];
|
|
55
|
+
if (parts.length === 2) {
|
|
56
|
+
need(method, 'GET');
|
|
57
|
+
return ctl.status(target);
|
|
58
|
+
}
|
|
59
|
+
need(method, 'POST');
|
|
60
|
+
const act = parts[2];
|
|
61
|
+
if (act === 'on') return ctl.on(target);
|
|
62
|
+
if (act === 'off') return ctl.off(target);
|
|
63
|
+
if (act === 'light') return ctl.setLight(target, params.color ?? 50, params.brightness ?? 100);
|
|
64
|
+
if (act === 'night') return ctl.nightLight(target, params.level ?? 0);
|
|
65
|
+
} else if (head === 'groups' && parts.length === 3) {
|
|
66
|
+
need(method, 'POST');
|
|
67
|
+
return ctl.group(toInt(parts[1], 'group id'), parts[2], params.color ?? 50, params.brightness ?? 100);
|
|
68
|
+
} else if (head === 'scenes' && parts.length === 3 && (parts[2] === 'play' || parts[2] === 'save')) {
|
|
69
|
+
need(method, 'POST');
|
|
70
|
+
return ctl.scene(toInt(parts[1], 'scene index'), parts[2] === 'save');
|
|
71
|
+
}
|
|
72
|
+
throw new HttpError(404, 'not found');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readBody(req) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const declared = Number(req.headers['content-length'] || 0);
|
|
78
|
+
if (declared > MAX_BODY) {
|
|
79
|
+
reject(new HttpError(413, 'body too large'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let size = 0;
|
|
84
|
+
req.on('data', (c) => {
|
|
85
|
+
size += c.length;
|
|
86
|
+
if (size > MAX_BODY) {
|
|
87
|
+
reject(new HttpError(413, 'body too large'));
|
|
88
|
+
req.destroy();
|
|
89
|
+
} else {
|
|
90
|
+
chunks.push(c);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const digest = (s) => createHash('sha256').update(String(s)).digest();
|
|
99
|
+
|
|
100
|
+
/** A `(req, res)` handler for node:http. */
|
|
101
|
+
export function makeHandler(ctl, token = null) {
|
|
102
|
+
return async (req, res) => {
|
|
103
|
+
let status = 200;
|
|
104
|
+
let payload;
|
|
105
|
+
try {
|
|
106
|
+
const url = new URL(req.url, 'http://localhost');
|
|
107
|
+
const params = Object.fromEntries(url.searchParams);
|
|
108
|
+
const body = await readBody(req);
|
|
109
|
+
if (body.length) {
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(body.toString('utf8'));
|
|
113
|
+
} catch {
|
|
114
|
+
throw new HttpError(400, 'body must be JSON');
|
|
115
|
+
}
|
|
116
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) Object.assign(params, data);
|
|
117
|
+
}
|
|
118
|
+
if (token) {
|
|
119
|
+
const auth = req.headers.authorization || '';
|
|
120
|
+
const given = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7) : (params.token ?? '');
|
|
121
|
+
if (!timingSafeEqual(digest(given), digest(token))) throw new HttpError(401, 'bad or missing token');
|
|
122
|
+
}
|
|
123
|
+
delete params.token;
|
|
124
|
+
payload = await dispatch(ctl, req.method.toUpperCase(), url.pathname, params);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
if (e instanceof HttpError) {
|
|
127
|
+
status = e.status;
|
|
128
|
+
} else if (e instanceof InputError) {
|
|
129
|
+
status = 400;
|
|
130
|
+
} else { // BLE / login failures
|
|
131
|
+
status = 502;
|
|
132
|
+
log.error('request failed:', e);
|
|
133
|
+
}
|
|
134
|
+
payload = { ok: false, error: e instanceof HttpError || e instanceof InputError ? e.message : `${e.name}: ${e.message}` };
|
|
135
|
+
}
|
|
136
|
+
const data = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
|
|
137
|
+
if (res.headersSent || res.destroyed) return;
|
|
138
|
+
res.writeHead(status, {
|
|
139
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
140
|
+
'Content-Length': data.length,
|
|
141
|
+
Connection: 'close',
|
|
142
|
+
});
|
|
143
|
+
res.end(data);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Start the HTTP API. Resolves to the listening http.Server; closing the server also closes the controller.
|
|
149
|
+
* @param {import('./controller.js').Controller} ctl
|
|
150
|
+
* @param {{host?: string, port?: number, token?: string|null}} [opts]
|
|
151
|
+
*/
|
|
152
|
+
export async function serve(ctl, { host = '127.0.0.1', port = 8765, token = null } = {}) {
|
|
153
|
+
if (!LOOPBACK.includes(host) && !token) {
|
|
154
|
+
throw new InputError(`refusing to listen on ${host} without a token (use --token or ODELIC_HTTP_TOKEN)`);
|
|
155
|
+
}
|
|
156
|
+
const server = http.createServer(makeHandler(ctl, token));
|
|
157
|
+
server.headersTimeout = 10_000;
|
|
158
|
+
server.requestTimeout = 10_000 + 60_000; // BLE operations (scan + login) can take a while
|
|
159
|
+
await new Promise((resolve, reject) => {
|
|
160
|
+
server.once('error', reject);
|
|
161
|
+
server.listen(port, host, () => {
|
|
162
|
+
server.removeListener('error', reject);
|
|
163
|
+
resolve();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
server.on('close', () => ctl.close().catch(() => {}));
|
|
167
|
+
const addr = server.address();
|
|
168
|
+
log.warning(`HTTP API on http://${host}:${addr.port} (${ctl.keepConnected
|
|
169
|
+
? 'keeping the BLE link open' : `idle disconnect after ${ctl.idleTimeout}s`})`);
|
|
170
|
+
if (ctl.keepConnected) {
|
|
171
|
+
ctl.connect()
|
|
172
|
+
.then(() => log.warning(`connected via ${ctl.mesh.nodeName}`))
|
|
173
|
+
.catch((e) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
|
|
174
|
+
}
|
|
175
|
+
return server;
|
|
176
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = '0.2.0';
|