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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -2
  3. package/bin/odelic-mcp.js +5 -0
  4. package/bin/odelic.js +6 -0
  5. package/dist/cli.d.ts +58 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +279 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/client.d.ts +125 -0
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +354 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/config.d.ts +34 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +139 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/controller.d.ts +95 -0
  18. package/dist/controller.d.ts.map +1 -0
  19. package/dist/controller.js +210 -0
  20. package/dist/controller.js.map +1 -0
  21. package/dist/crypto.d.ts +25 -0
  22. package/dist/crypto.d.ts.map +1 -0
  23. package/dist/crypto.js +94 -0
  24. package/dist/crypto.js.map +1 -0
  25. package/dist/errors.d.ts +14 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +17 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/log.d.ts +18 -0
  34. package/dist/log.d.ts.map +1 -0
  35. package/dist/log.js +21 -0
  36. package/dist/log.js.map +1 -0
  37. package/dist/mcp.d.ts +23 -0
  38. package/dist/mcp.d.ts.map +1 -0
  39. package/dist/mcp.js +74 -0
  40. package/dist/mcp.js.map +1 -0
  41. package/dist/protocol.d.ts +90 -0
  42. package/dist/protocol.d.ts.map +1 -0
  43. package/dist/protocol.js +178 -0
  44. package/dist/protocol.js.map +1 -0
  45. package/dist/server.d.ts +24 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +187 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/version.d.ts +2 -0
  50. package/dist/version.d.ts.map +1 -0
  51. package/dist/version.js +2 -0
  52. package/dist/version.js.map +1 -0
  53. package/package.json +80 -4
  54. package/src/cli.ts +267 -0
  55. package/src/client.ts +414 -0
  56. package/src/config.ts +142 -0
  57. package/src/controller.ts +267 -0
  58. package/src/crypto.ts +100 -0
  59. package/src/errors.ts +17 -0
  60. package/src/index.ts +17 -0
  61. package/src/log.ts +24 -0
  62. package/src/mcp.ts +100 -0
  63. package/src/protocol.ts +205 -0
  64. package/src/server.ts +195 -0
  65. 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,2 @@
1
+ export declare const VERSION = "0.2.1";
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
@@ -0,0 +1,2 @@
1
+ export const VERSION = '0.2.1';
2
+ //# sourceMappingURL=version.js.map
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }