odelic 0.0.0-stage → 0.2.1
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 +189 -2
- package/bin/odelic-mcp.js +5 -0
- package/bin/odelic.js +6 -0
- package/dist/cli.d.ts +58 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +279 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +125 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +354 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +139 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +95 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +210 -0
- package/dist/controller.js.map +1 -0
- package/dist/crypto.d.ts +25 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +94 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +21 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp.d.ts +23 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +74 -0
- package/dist/mcp.js.map +1 -0
- package/dist/protocol.d.ts +90 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +178 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +187 -0
- package/dist/server.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +80 -4
- package/src/cli.ts +267 -0
- package/src/client.ts +414 -0
- package/src/config.ts +142 -0
- package/src/controller.ts +267 -0
- package/src/crypto.ts +100 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +17 -0
- package/src/log.ts +24 -0
- package/src/mcp.ts +100 -0
- package/src/protocol.ts +205 -0
- package/src/server.ts +195 -0
- package/src/version.ts +1 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
import { InputError } from './errors.js';
|
|
19
|
+
import { log } from './log.js';
|
|
20
|
+
import { toInt } from './controller.js';
|
|
21
|
+
export const LOOPBACK = ['127.0.0.1', 'localhost', '::1'];
|
|
22
|
+
const MAX_BODY = 65536;
|
|
23
|
+
export class HttpError extends Error {
|
|
24
|
+
status;
|
|
25
|
+
constructor(status, message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'HttpError';
|
|
28
|
+
this.status = status;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function need(method, expected) {
|
|
32
|
+
if (method !== expected)
|
|
33
|
+
throw new HttpError(405, `use ${expected}`);
|
|
34
|
+
}
|
|
35
|
+
const num = (v, def) => (v ?? def);
|
|
36
|
+
/** Route one request to the controller. Returns the JSON-able result. */
|
|
37
|
+
export async function dispatch(ctl, method, path, params) {
|
|
38
|
+
let parts;
|
|
39
|
+
try {
|
|
40
|
+
parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map(decodeURIComponent);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new HttpError(400, 'bad URL encoding');
|
|
44
|
+
}
|
|
45
|
+
if (parts.length === 1 && parts[0] === 'health')
|
|
46
|
+
return { ok: true, ...ctl.info() };
|
|
47
|
+
if (!parts.length)
|
|
48
|
+
throw new HttpError(404, 'not found');
|
|
49
|
+
const [head] = parts;
|
|
50
|
+
if (head === 'lights') {
|
|
51
|
+
if (parts.length === 1) {
|
|
52
|
+
need(method, 'GET');
|
|
53
|
+
return ctl.lights();
|
|
54
|
+
}
|
|
55
|
+
const target = parts[1];
|
|
56
|
+
if (parts.length === 2) {
|
|
57
|
+
need(method, 'GET');
|
|
58
|
+
return ctl.status(target);
|
|
59
|
+
}
|
|
60
|
+
need(method, 'POST');
|
|
61
|
+
const act = parts[2];
|
|
62
|
+
if (act === 'on')
|
|
63
|
+
return ctl.on(target);
|
|
64
|
+
if (act === 'off')
|
|
65
|
+
return ctl.off(target);
|
|
66
|
+
if (act === 'light')
|
|
67
|
+
return ctl.setLight(target, num(params.color, 50), num(params.brightness, 100));
|
|
68
|
+
if (act === 'night')
|
|
69
|
+
return ctl.nightLight(target, num(params.level, 0));
|
|
70
|
+
}
|
|
71
|
+
else if (head === 'groups' && parts.length === 3) {
|
|
72
|
+
need(method, 'POST');
|
|
73
|
+
return ctl.group(toInt(parts[1], 'group id'), parts[2], num(params.color, 50), num(params.brightness, 100));
|
|
74
|
+
}
|
|
75
|
+
else if (head === 'scenes' && parts.length === 3 && (parts[2] === 'play' || parts[2] === 'save')) {
|
|
76
|
+
need(method, 'POST');
|
|
77
|
+
return ctl.scene(toInt(parts[1], 'scene index'), parts[2] === 'save');
|
|
78
|
+
}
|
|
79
|
+
throw new HttpError(404, 'not found');
|
|
80
|
+
}
|
|
81
|
+
function readBody(req) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const declared = Number(req.headers['content-length'] || 0);
|
|
84
|
+
if (declared > MAX_BODY) {
|
|
85
|
+
reject(new HttpError(413, 'body too large'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const chunks = [];
|
|
89
|
+
let size = 0;
|
|
90
|
+
req.on('data', (c) => {
|
|
91
|
+
size += c.length;
|
|
92
|
+
if (size > MAX_BODY) {
|
|
93
|
+
reject(new HttpError(413, 'body too large'));
|
|
94
|
+
req.destroy();
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
chunks.push(c);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
101
|
+
req.on('error', reject);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const digest = (s) => createHash('sha256').update(String(s)).digest();
|
|
105
|
+
/** A `(req, res)` handler for node:http. */
|
|
106
|
+
export function makeHandler(ctl, token = null) {
|
|
107
|
+
return async (req, res) => {
|
|
108
|
+
let status = 200;
|
|
109
|
+
let payload;
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
112
|
+
const params = Object.fromEntries(url.searchParams);
|
|
113
|
+
const body = await readBody(req);
|
|
114
|
+
if (body.length) {
|
|
115
|
+
let data;
|
|
116
|
+
try {
|
|
117
|
+
data = JSON.parse(body.toString('utf8'));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new HttpError(400, 'body must be JSON');
|
|
121
|
+
}
|
|
122
|
+
if (data && typeof data === 'object' && !Array.isArray(data))
|
|
123
|
+
Object.assign(params, data);
|
|
124
|
+
}
|
|
125
|
+
if (token) {
|
|
126
|
+
const auth = req.headers.authorization ?? '';
|
|
127
|
+
const given = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7) : (params.token ?? '');
|
|
128
|
+
if (!timingSafeEqual(digest(given), digest(token)))
|
|
129
|
+
throw new HttpError(401, 'bad or missing token');
|
|
130
|
+
}
|
|
131
|
+
delete params.token;
|
|
132
|
+
payload = await dispatch(ctl, (req.method ?? 'GET').toUpperCase(), url.pathname, params);
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
const err = e;
|
|
136
|
+
if (err instanceof HttpError) {
|
|
137
|
+
status = err.status;
|
|
138
|
+
}
|
|
139
|
+
else if (err instanceof InputError) {
|
|
140
|
+
status = 400;
|
|
141
|
+
}
|
|
142
|
+
else { // BLE / login failures
|
|
143
|
+
status = 502;
|
|
144
|
+
log.error('request failed:', err);
|
|
145
|
+
}
|
|
146
|
+
payload = { ok: false, error: err instanceof HttpError || err instanceof InputError ? err.message : `${err.name}: ${err.message}` };
|
|
147
|
+
}
|
|
148
|
+
const data = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
|
|
149
|
+
if (res.headersSent || res.destroyed)
|
|
150
|
+
return;
|
|
151
|
+
res.writeHead(status, {
|
|
152
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
153
|
+
'Content-Length': data.length,
|
|
154
|
+
Connection: 'close',
|
|
155
|
+
});
|
|
156
|
+
res.end(data);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** Start the HTTP API. Resolves to the listening server; closing the server also closes the controller. */
|
|
160
|
+
export async function serve(ctl, { host = '127.0.0.1', port = 8765, token = null } = {}) {
|
|
161
|
+
if (!LOOPBACK.includes(host) && !token) {
|
|
162
|
+
throw new InputError(`refusing to listen on ${host} without a token (use --token or ODELIC_HTTP_TOKEN)`);
|
|
163
|
+
}
|
|
164
|
+
const server = http.createServer(makeHandler(ctl, token));
|
|
165
|
+
server.headersTimeout = 10_000;
|
|
166
|
+
server.requestTimeout = 10_000 + 60_000; // BLE operations (scan + login) can take a while
|
|
167
|
+
await new Promise((resolve, reject) => {
|
|
168
|
+
server.once('error', reject);
|
|
169
|
+
server.listen(port, host, () => {
|
|
170
|
+
server.removeListener('error', reject);
|
|
171
|
+
resolve();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
server.on('close', () => {
|
|
175
|
+
ctl.close().catch(() => { });
|
|
176
|
+
});
|
|
177
|
+
const addr = server.address();
|
|
178
|
+
log.warning(`HTTP API on http://${host}:${addr.port} (${ctl.keepConnected
|
|
179
|
+
? 'keeping the BLE link open' : `idle disconnect after ${ctl.idleTimeout}s`})`);
|
|
180
|
+
if (ctl.keepConnected) {
|
|
181
|
+
ctl.connect()
|
|
182
|
+
.then(() => log.warning(`connected via ${ctl.mesh.nodeName}`))
|
|
183
|
+
.catch((e) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
|
|
184
|
+
}
|
|
185
|
+
return server;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,82 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "odelic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Unofficial TypeScript/Node.js library for ODELIC Bluetooth lights (Pairlink connected-mesh)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "rbstxt",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/rbstxt/odelic.js.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/rbstxt/odelic.js#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rbstxt/odelic.js/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"odelic",
|
|
18
|
+
"bluetooth",
|
|
19
|
+
"ble",
|
|
20
|
+
"mesh",
|
|
21
|
+
"lighting",
|
|
22
|
+
"smart-home",
|
|
23
|
+
"mcp",
|
|
24
|
+
"typescript"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./mcp": {
|
|
36
|
+
"types": "./dist/mcp.d.ts",
|
|
37
|
+
"default": "./dist/mcp.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"bin": {
|
|
42
|
+
"odelic": "bin/odelic.js",
|
|
43
|
+
"odelic-mcp": "bin/odelic-mcp.js"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"bin",
|
|
47
|
+
"dist",
|
|
48
|
+
"src",
|
|
49
|
+
"LICENSE",
|
|
50
|
+
"README.md"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
|
|
54
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
55
|
+
"test": "node --test \"test/*.test.ts\"",
|
|
56
|
+
"prepack": "npm run build"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@stoprocent/noble": "^2.8.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.32.0",
|
|
63
|
+
"zod": "^3.25.0 || ^4.0.0"
|
|
64
|
+
},
|
|
65
|
+
"peerDependenciesMeta": {
|
|
66
|
+
"@modelcontextprotocol/sdk": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"zod": {
|
|
70
|
+
"optional": true
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
75
|
+
"@types/node": "^24.19.2",
|
|
76
|
+
"typescript": "^7.0.2",
|
|
77
|
+
"zod": "^4.1.12"
|
|
78
|
+
},
|
|
79
|
+
"publishConfig": {
|
|
80
|
+
"access": "public"
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** odelic command line. `odelic setup --id <APP_ID>` once, then e.g. `odelic off`, `odelic light living --color 0`. */
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
|
|
5
|
+
import { InputError, OdelicError } from './errors.ts';
|
|
6
|
+
import { log, setLogLevel } from './log.ts';
|
|
7
|
+
import { findNodes } from './client.ts';
|
|
8
|
+
import { Config, configPath, parseIdle } from './config.ts';
|
|
9
|
+
import { Controller } from './controller.ts';
|
|
10
|
+
import { parseAppId } from './protocol.ts';
|
|
11
|
+
import { VERSION } from './version.ts';
|
|
12
|
+
|
|
13
|
+
const HELP = `usage: odelic [--id ID] [--node NODE] [-v] <command> [...]
|
|
14
|
+
|
|
15
|
+
Control ODELIC Bluetooth lights.
|
|
16
|
+
|
|
17
|
+
global options:
|
|
18
|
+
--id ID ID shown in the app (ID:xxxx); default: $ODELIC_ID or the config file
|
|
19
|
+
--node NODE BLE name/address of the node to connect through
|
|
20
|
+
-v, --verbose show BLE packets
|
|
21
|
+
-h, --help show this help
|
|
22
|
+
--version show the version
|
|
23
|
+
|
|
24
|
+
commands:
|
|
25
|
+
setup [--id ID] [--node NODE] [--idle SECONDS | --keep-connected]
|
|
26
|
+
save the app ID (and optionally a node / connection mode) to the config file
|
|
27
|
+
scan list nearby mesh nodes (no login)
|
|
28
|
+
lights list lights in the mesh (alias: devices)
|
|
29
|
+
on [TARGET] turn on (previous colour/brightness)
|
|
30
|
+
off [TARGET] turn off
|
|
31
|
+
light [TARGET] [--color 0..100] [--brightness 1..100]
|
|
32
|
+
turn on with colour (0 = warm 電球色 .. 100 = cool 昼光色) and brightness
|
|
33
|
+
night [TARGET] [--level 0..2]
|
|
34
|
+
night light
|
|
35
|
+
status [TARGET] show on/off, colour and brightness
|
|
36
|
+
group ID on|off|light [--color N] [--brightness N]
|
|
37
|
+
control a group (id as configured in the app)
|
|
38
|
+
scene save|play INDEX
|
|
39
|
+
save or play a scene
|
|
40
|
+
name add NAME VADDR | name rm NAME | name list
|
|
41
|
+
manage light names
|
|
42
|
+
serve [--host H] [--port P] [--token T] [--idle SECONDS | --keep-connected]
|
|
43
|
+
run the HTTP API (token default: $ODELIC_HTTP_TOKEN)
|
|
44
|
+
mcp run the MCP server on stdio (needs @modelcontextprotocol/sdk and zod)
|
|
45
|
+
|
|
46
|
+
TARGET is a light name, a vaddr or "all" (default).`;
|
|
47
|
+
|
|
48
|
+
const OPTIONS = {
|
|
49
|
+
id: { type: 'string' },
|
|
50
|
+
node: { type: 'string' },
|
|
51
|
+
verbose: { type: 'boolean', short: 'v' },
|
|
52
|
+
help: { type: 'boolean', short: 'h' },
|
|
53
|
+
version: { type: 'boolean' },
|
|
54
|
+
idle: { type: 'string' },
|
|
55
|
+
'keep-connected': { type: 'boolean' },
|
|
56
|
+
color: { type: 'string' },
|
|
57
|
+
brightness: { type: 'string' },
|
|
58
|
+
level: { type: 'string' },
|
|
59
|
+
host: { type: 'string' },
|
|
60
|
+
port: { type: 'string' },
|
|
61
|
+
token: { type: 'string' },
|
|
62
|
+
} as const;
|
|
63
|
+
|
|
64
|
+
type OptionName = keyof typeof OPTIONS;
|
|
65
|
+
export type CliOptions = {
|
|
66
|
+
[K in OptionName]?: (typeof OPTIONS)[K]['type'] extends 'boolean' ? boolean : string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Which options each command accepts (besides the global ones). */
|
|
70
|
+
const COMMAND_OPTIONS: Record<string, readonly OptionName[]> = {
|
|
71
|
+
setup: ['idle', 'keep-connected'],
|
|
72
|
+
scan: [],
|
|
73
|
+
lights: [],
|
|
74
|
+
devices: [],
|
|
75
|
+
on: [],
|
|
76
|
+
off: [],
|
|
77
|
+
light: ['color', 'brightness'],
|
|
78
|
+
night: ['level'],
|
|
79
|
+
status: [],
|
|
80
|
+
group: ['color', 'brightness'],
|
|
81
|
+
scene: [],
|
|
82
|
+
name: [],
|
|
83
|
+
serve: ['host', 'port', 'token', 'idle', 'keep-connected'],
|
|
84
|
+
mcp: [],
|
|
85
|
+
};
|
|
86
|
+
const GLOBAL: readonly OptionName[] = ['id', 'node', 'verbose', 'help', 'version'];
|
|
87
|
+
const MAX_ARGS: Record<string, number> = { on: 1, off: 1, light: 1, night: 1, status: 1, group: 2, scene: 2, name: 3 };
|
|
88
|
+
|
|
89
|
+
function intArg(v: string | undefined, name: string, { min = -Infinity, max = Infinity } = {}): number | undefined {
|
|
90
|
+
if (v === undefined) return undefined;
|
|
91
|
+
if (!/^-?\d+$/.test(v)) throw new InputError(`--${name}: invalid int value: '${v}'`);
|
|
92
|
+
const n = Number(v);
|
|
93
|
+
if (n < min || n > max) throw new InputError(`--${name}: must be between ${min} and ${max}`);
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function choice<T extends string>(v: string | undefined, name: string, choices: readonly T[]): T {
|
|
98
|
+
if (!choices.includes(v as T)) {
|
|
99
|
+
throw new InputError(`${name}: expected one of ${choices.join(', ')} (got ${v === undefined ? 'nothing' : `'${v}'`})`);
|
|
100
|
+
}
|
|
101
|
+
return v as T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ParsedCli {
|
|
105
|
+
cmd: string;
|
|
106
|
+
args: string[];
|
|
107
|
+
opts: CliOptions;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Parse argv; throws InputError on bad usage. */
|
|
111
|
+
export function parseCli(argv: string[]): ParsedCli {
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new InputError((e as Error).message);
|
|
117
|
+
}
|
|
118
|
+
const o = parsed.values as CliOptions;
|
|
119
|
+
if (o.help || o.version) return { cmd: o.help ? 'help' : 'version', args: [], opts: o };
|
|
120
|
+
const [cmd, ...args] = parsed.positionals;
|
|
121
|
+
if (!cmd) throw new InputError('missing command (see odelic --help)');
|
|
122
|
+
if (!Object.hasOwn(COMMAND_OPTIONS, cmd)) throw new InputError(`unknown command '${cmd}' (see odelic --help)`);
|
|
123
|
+
for (const k of Object.keys(o) as OptionName[]) {
|
|
124
|
+
if (!GLOBAL.includes(k) && !COMMAND_OPTIONS[cmd].includes(k)) throw new InputError(`${cmd}: unrecognized option --${k}`);
|
|
125
|
+
}
|
|
126
|
+
if (o.idle !== undefined && o['keep-connected']) throw new InputError('--idle and --keep-connected are mutually exclusive');
|
|
127
|
+
if (o.idle !== undefined) parseIdle(o.idle); // validate early
|
|
128
|
+
const maxArgs = MAX_ARGS[cmd] ?? 0;
|
|
129
|
+
if (args.length > maxArgs) throw new InputError(`${cmd}: unexpected argument '${args[maxArgs]}'`);
|
|
130
|
+
return { cmd, args, opts: o };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function idleValue(o: CliOptions): number | null {
|
|
134
|
+
return o['keep-connected'] ? 0 : parseIdle(o.idle);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function show(obj: unknown): void {
|
|
138
|
+
console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runBle(cmd: string, args: string[], o: CliOptions, cfg: Config): Promise<void> {
|
|
142
|
+
if (cmd === 'scan') {
|
|
143
|
+
const aid = cfg.appId(o.id);
|
|
144
|
+
const homeid = aid ? parseAppId(aid).homeid : null;
|
|
145
|
+
for (const n of await findNodes({ homeid })) {
|
|
146
|
+
console.log(`${String(n.name).padEnd(20)} ${n.address} HomeID=${n.homeid} rssi=${n.rssi}`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (cmd === 'serve') {
|
|
152
|
+
const { serve } = await import('./server.ts');
|
|
153
|
+
const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: idleValue(o) });
|
|
154
|
+
const server = await serve(ctl, {
|
|
155
|
+
host: o.host ?? '127.0.0.1',
|
|
156
|
+
port: intArg(o.port, 'port', { min: 0, max: 65535 }) ?? 8765,
|
|
157
|
+
token: o.token ?? process.env.ODELIC_HTTP_TOKEN ?? null,
|
|
158
|
+
});
|
|
159
|
+
await new Promise<void>((resolve) => {
|
|
160
|
+
const stop = () => server.close(() => resolve());
|
|
161
|
+
process.once('SIGINT', stop);
|
|
162
|
+
process.once('SIGTERM', stop);
|
|
163
|
+
});
|
|
164
|
+
await ctl.close();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const target = args[0] ?? 'all';
|
|
169
|
+
const color = () => intArg(o.color, 'color') ?? 50;
|
|
170
|
+
const brightness = () => intArg(o.brightness, 'brightness') ?? 100;
|
|
171
|
+
const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: 0 });
|
|
172
|
+
try {
|
|
173
|
+
if (cmd === 'lights' || cmd === 'devices') {
|
|
174
|
+
for (const d of await ctl.lights()) {
|
|
175
|
+
if (d.online) console.log(`${(d.name ?? '-').padEnd(12)} vaddr=${d.vaddr} mac=${d.mac} product=${d.product} ver=${d.version}`);
|
|
176
|
+
else console.log(`${String(d.name).padEnd(12)} vaddr=${d.vaddr} (offline)`);
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (cmd === 'on') show(await ctl.on(target));
|
|
181
|
+
else if (cmd === 'off') show(await ctl.off(target));
|
|
182
|
+
else if (cmd === 'light') show(await ctl.setLight(target, color(), brightness()));
|
|
183
|
+
else if (cmd === 'night') show(await ctl.nightLight(target, intArg(o.level, 'level', { min: 0, max: 2 }) ?? 0));
|
|
184
|
+
else if (cmd === 'status') show(await ctl.status(target));
|
|
185
|
+
else if (cmd === 'group') {
|
|
186
|
+
const id = intArg(args[0], 'group_id');
|
|
187
|
+
if (id === undefined) throw new InputError('group: missing group id');
|
|
188
|
+
show(await ctl.group(id, choice(args[1], 'group action', ['on', 'off', 'light'] as const), color(), brightness()));
|
|
189
|
+
} else if (cmd === 'scene') {
|
|
190
|
+
const action = choice(args[0], 'scene action', ['save', 'play'] as const);
|
|
191
|
+
const index = intArg(args[1], 'index');
|
|
192
|
+
if (index === undefined) throw new InputError('scene: missing index');
|
|
193
|
+
show(await ctl.scene(index, action === 'save'));
|
|
194
|
+
}
|
|
195
|
+
await sleep(500); // let write-without-response packets drain before disconnecting
|
|
196
|
+
} finally {
|
|
197
|
+
await ctl.close();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** CLI entry point. Resolves to the process exit code, or null for long-running commands (`mcp`). */
|
|
202
|
+
export async function main(argv: string[] = process.argv.slice(2)): Promise<number | null> {
|
|
203
|
+
let parsed: ParsedCli;
|
|
204
|
+
try {
|
|
205
|
+
parsed = parseCli(argv);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
console.error(`odelic: error: ${(e as Error).message}`);
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
const { cmd, args, opts: o } = parsed;
|
|
211
|
+
if (cmd === 'help') {
|
|
212
|
+
console.log(HELP);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
if (cmd === 'version') {
|
|
216
|
+
console.log(VERSION);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
if (o.verbose) setLogLevel('debug');
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const cfg = Config.load();
|
|
223
|
+
if (cmd === 'setup') {
|
|
224
|
+
if (o.id) {
|
|
225
|
+
parseAppId(o.id);
|
|
226
|
+
cfg.id = o.id;
|
|
227
|
+
}
|
|
228
|
+
if (o.node) cfg.node = o.node;
|
|
229
|
+
const idle = idleValue(o);
|
|
230
|
+
if (idle !== null) cfg.idleTimeout = idle;
|
|
231
|
+
cfg.save();
|
|
232
|
+
const mode = cfg.idle();
|
|
233
|
+
console.log(`saved to ${configPath()} (id: ${cfg.id ? 'set' : 'not set'}, connection: ${
|
|
234
|
+
mode ? `disconnect after ${mode}s idle` : 'keep connected'})`);
|
|
235
|
+
} else if (cmd === 'name') {
|
|
236
|
+
const sub = choice(args[0], 'name command', ['add', 'rm', 'list'] as const);
|
|
237
|
+
if (sub === 'add') {
|
|
238
|
+
if (args.length !== 3) throw new InputError('usage: odelic name add NAME VADDR');
|
|
239
|
+
cfg.setName(args[1], args[2]);
|
|
240
|
+
cfg.save();
|
|
241
|
+
} else if (sub === 'rm') {
|
|
242
|
+
if (args.length !== 2) throw new InputError('usage: odelic name rm NAME');
|
|
243
|
+
cfg.removeName(args[1]);
|
|
244
|
+
cfg.save();
|
|
245
|
+
}
|
|
246
|
+
for (const [name, v] of Object.entries(cfg.lights)) console.log(`${name.padEnd(12)} ${v}`);
|
|
247
|
+
} else if (cmd === 'mcp') {
|
|
248
|
+
let mcp: typeof import('./mcp.ts');
|
|
249
|
+
try {
|
|
250
|
+
mcp = await import('./mcp.ts');
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if ((e as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw e;
|
|
253
|
+
throw new OdelicError(`${(e as Error).message} (install with: npm install @modelcontextprotocol/sdk zod)`);
|
|
254
|
+
}
|
|
255
|
+
await mcp.main();
|
|
256
|
+
return null; // keep running until stdin closes
|
|
257
|
+
} else {
|
|
258
|
+
await runBle(cmd, args, o, cfg);
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
} catch (e) {
|
|
262
|
+
const err = e as Error;
|
|
263
|
+
if (o.verbose || !(err instanceof OdelicError)) log.debug(err.stack);
|
|
264
|
+
console.error(`error: ${err.message}`);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
}
|