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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rbstxt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,188 @@
1
- # Temporary Holding Version
1
+ # odelic.js
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An **unofficial** Node.js library for controlling ODELIC Bluetooth lights from a PC. This is the JavaScript port of the Python package [odelic.py](https://github.com/rbstxt/odelic.py), with the same features.
4
+
5
+ It was built by analyzing the communication protocol of the official Android app (`jp.co.odelic.smt.remote10` v1.10.39). It targets devices that use the Pairlink connected-mesh protocol. It does not use the cloud; it controls the lights directly and locally over BLE.
6
+
7
+ - The only BLE dependency is [@stoprocent/noble](https://github.com/stoprocent/noble). Encryption (AES-128) uses Node's built-in `crypto`.
8
+ - Tested on macOS. It should also work in any environment noble supports (Linux / Windows).
9
+ - The config file (`~/.config/odelic/config.json`) has the same format as the Python version and can be shared with it.
10
+ - This tool is not endorsed by the manufacturer. Use it at your own risk.
11
+
12
+ ## Setup
13
+
14
+ Requires Node.js 20 or later.
15
+
16
+ ```sh
17
+ npm install odelic
18
+ # To use the MCP server, also install these
19
+ npm install @modelcontextprotocol/sdk zod
20
+ ```
21
+
22
+ For development (from this repository):
23
+
24
+ ```sh
25
+ npm install
26
+ npm test
27
+ ```
28
+
29
+ ### Required information: the app ID
30
+
31
+ The app's menu screen shows something like `ID:12345678`. It is **the HomeID followed by the password (the last 4 characters)**. In this example the HomeID is 1234 and the password is `5678`.
32
+
33
+ ```sh
34
+ odelic setup --id 12345678 # save it to the config file (permissions 600)
35
+ # or specify it each time with --id / the ODELIC_ID environment variable
36
+ ```
37
+
38
+ ## CLI
39
+
40
+ ```sh
41
+ odelic scan # find nearby mesh nodes (no login)
42
+ odelic lights # list the lights in the mesh (vaddr / MAC / product code)
43
+ odelic on # turn all on (restores the previous color and brightness)
44
+ odelic off # turn all off
45
+ odelic light --color 0 --brightness 100 # warm (電球色) at full brightness
46
+ odelic light --color 100 --brightness 30 # cool (昼光色) at 30%
47
+ odelic night --level 0 # night light (0-2)
48
+ odelic status # on/off, color and brightness of every light
49
+ odelic name add リビング 00000000 # give a light a name (vaddr from `lights`)
50
+ odelic off リビング # control a single light by name or vaddr
51
+ odelic group 1 on # per group (the group ID is set in the app)
52
+ odelic scene save 1 / odelic scene play 1 # save and recall scenes
53
+ odelic -v ... # show sent and received packets
54
+ ```
55
+
56
+ ### Auto-disconnect or stay connected
57
+
58
+ A mesh node **can connect to only one controller at a time**. To keep the phone app usable, the HTTP API and MCP server drop the BLE connection after 60 seconds of inactivity by default. If you don't use the phone app, you can keep the connection open.
59
+
60
+ ```sh
61
+ odelic setup --keep-connected # always stay connected
62
+ odelic setup --idle 300 # disconnect after 300 idle seconds
63
+ ```
64
+
65
+ Priority: command option (`--idle` / `--keep-connected`) → environment variable `ODELIC_IDLE_TIMEOUT` (`0` keeps the connection) → config file → default 60 seconds.
66
+
67
+ ## Using it as a library
68
+
69
+ ```js
70
+ import { OdelicMesh } from 'odelic';
71
+
72
+ const mesh = new OdelicMesh(process.env.ODELIC_ID);
73
+ await mesh.connect();
74
+ try {
75
+ await mesh.setLight({ color: 0, brightness: 100 }); // 0 = warm .. 100 = cool
76
+ for (const d of await mesh.devices()) console.log(String(d));
77
+ console.log((await mesh.status('00000000'))?.toJSON());
78
+ await mesh.off();
79
+ } finally {
80
+ await mesh.disconnect();
81
+ }
82
+ ```
83
+
84
+ | Method | Description |
85
+ |---|---|
86
+ | `on(target)` / `off(target)` | Turn on / off. Omit `target` (vaddr) to target every light |
87
+ | `setLight({color, brightness, target, x, y})` | Set color and brightness. Raw values x (0–20) / y (0–19, 0 is brightest) also work |
88
+ | `nightLight(level, target)` | Night light (the 3 dimmest warm levels) |
89
+ | `setGroup(groupId, {color, brightness, on})` | Per-group control |
90
+ | `changeColor(target)` | Cycle color presets (supported models only) |
91
+ | `saveScene(i)` / `playScene(i)` | Save and recall scenes |
92
+ | `devices()` | List the lights in the mesh via ping |
93
+ | `status(target)` | Query state (`LightState`: `on` / `color` / `brightness`) |
94
+ | `send(packet)` | Encrypt and send any type-3 packet (see `protocol`) |
95
+ | `addListener(cb)` | Receive every incoming packet, decrypted |
96
+
97
+ `Controller` is a higher-level class that handles name resolution, auto connect/reconnect, and idle disconnect (used internally by the CLI, HTTP, and MCP).
98
+
99
+ ```js
100
+ import { Controller } from 'odelic';
101
+
102
+ const ctl = new Controller(); // ID from ODELIC_ID / config file
103
+ await ctl.setLight('リビング', 0, 100); // target, color, brightness
104
+ console.log(await ctl.status('all'));
105
+ await ctl.close();
106
+ ```
107
+
108
+ Timeouts and the idle duration are all given in **seconds**, matching the Python version and the config file.
109
+
110
+ ## HTTP API
111
+
112
+ ```sh
113
+ odelic serve # http://127.0.0.1:8765
114
+ odelic serve --keep-connected # connect at startup and keep the connection
115
+ odelic serve --host 0.0.0.0 --token s3cret # a token is required to listen beyond localhost
116
+ ```
117
+
118
+ | Method | Path | Description |
119
+ |---|---|---|
120
+ | GET | `/health` | Connection status |
121
+ | GET | `/lights` | List lights |
122
+ | GET | `/lights/<target>` | State (`all` = every light) |
123
+ | POST | `/lights/<target>/on` · `/off` | Turn on / off |
124
+ | POST | `/lights/<target>/light` | `{"color": 0..100, "brightness": 1..100}` |
125
+ | POST | `/lights/<target>/night` | `{"level": 0..2}` |
126
+ | POST | `/groups/<id>/on` · `/off` · `/light` | Group control |
127
+ | POST | `/scenes/<index>/play` · `/save` | Scenes |
128
+
129
+ Parameters can be passed as a JSON body or as query parameters. When a token is set, use `Authorization: Bearer <token>` (or `?token=`).
130
+
131
+ ```sh
132
+ curl -X POST localhost:8765/lights/リビング/light -d '{"color": 0, "brightness": 80}'
133
+ ```
134
+
135
+ ## MCP server (for AI assistants)
136
+
137
+ This lets Claude and other AI tools control the lights. It requires `@modelcontextprotocol/sdk` and `zod`.
138
+
139
+ ```sh
140
+ claude mcp add odelic -e ODELIC_ID=12345678 -- npx -y -p odelic -p @modelcontextprotocol/sdk -p zod odelic-mcp
141
+ ```
142
+
143
+ Configuration file example (Claude Desktop, etc.):
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "odelic": {
149
+ "command": "npx",
150
+ "args": ["-y", "-p", "odelic", "-p", "@modelcontextprotocol/sdk", "-p", "zod", "odelic-mcp"],
151
+ "env": { "ODELIC_ID": "12345678" }
152
+ }
153
+ }
154
+ }
155
+ ```
156
+
157
+ To run it from a local checkout, use `"command": "node", "args": ["/path/to/odelic-js/bin/odelic-mcp.js"]`.
158
+
159
+ Tools: `list_lights` `light_status` `turn_on` `turn_off` `set_light` `night_light` `control_group` `play_scene` `save_scene` `name_light` `forget_light_name`
160
+
161
+ ## Notes
162
+
163
+ - If the phone app is connected, you'll get `node is already in use by another controller`. Close the app or turn off Bluetooth on the phone and try again.
164
+ - On macOS, the first run asks for Bluetooth permission for the terminal (or whatever app runs it).
165
+ - **Registering new lights** is not supported. The app registers lights by having the phone broadcast a custom-format signal, and macOS's standard Bluetooth stack cannot broadcast that format. Register lights with the app.
166
+
167
+ ## Protocol overview
168
+
169
+ | Item | Details |
170
+ |---|---|
171
+ | GATT service | `9e5d1e47-5c13-43a0-8635-82adffc0386f` (write `…ffc1…` / notify `…ffc2…`) |
172
+ | Key | An AES-128 key made of the 8 bytes formed by interleaving the HomeID (4B, little endian) and the password (4B) one byte at a time, followed by `"LOGINKEY"` or `"EVENTKEY"` |
173
+ | Login | Send `01 20` → node returns `02 20 + AES_L(homeid, session4)` → reply with `01 16 03 + AES_L(homeid, pwd, session4)` → `02 16 00` means success (`08` means another device is connected) → `01 0a <own vaddr>` assigns your address |
174
+ | Data | Plaintext `03 <dst4> <ch> <payload>` becomes `06 <dst4> <ch> XOR(AES_E(pkcs7(payload)), session4)` |
175
+ | Light command | `<own vaddr> C0 <index> <x> <y> 00 00 00 00`, ch=0x20 (0x2A when broadcasting to all). x=55,y=55 turns on, 50,50 turns off |
176
+
177
+ The tests use test vectors generated by running the original app's native library in an emulator (the same ones as the Python version; see `tools/native_oracle.py` in odelic.py).
178
+
179
+ ## Releasing
180
+
181
+ Publishing a GitHub release runs `.github/workflows/publish.yml`, which publishes to npm via npm Trusted Publishing (OIDC) — no token needed.
182
+
183
+ 1. Bump the version in `package.json` and `src/version.js`, then push
184
+ 2. Create a GitHub release with the tag `v<version>` (e.g. `v0.2.1`)
185
+
186
+ ## License
187
+
188
+ MIT License ([LICENSE](LICENSE))
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ const code = await main(['mcp']);
5
+ if (code !== null) process.exit(code);
package/bin/odelic.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ const code = await main();
5
+ // noble keeps native handles open after use, so exit explicitly (null = long-running, e.g. `odelic mcp`)
6
+ if (code !== null) process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,70 @@
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.0",
4
+ "description": "Unofficial 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
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "exports": {
29
+ ".": "./src/index.js",
30
+ "./mcp": "./src/mcp.js",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "bin": {
34
+ "odelic": "bin/odelic.js",
35
+ "odelic-mcp": "bin/odelic-mcp.js"
36
+ },
37
+ "files": [
38
+ "bin",
39
+ "src",
40
+ "LICENSE",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "test": "node --test"
45
+ },
46
+ "dependencies": {
47
+ "@stoprocent/noble": "^2.8.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@modelcontextprotocol/sdk": "^1.32.0",
51
+ "zod": "^3.25.0 || ^4.0.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@modelcontextprotocol/sdk": {
55
+ "optional": true
56
+ },
57
+ "zod": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@modelcontextprotocol/sdk": "^1.32.2",
63
+ "@types/node": "^24.19.2",
64
+ "typescript": "^7.0.2",
65
+ "zod": "^4.1.12"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }
package/src/cli.js ADDED
@@ -0,0 +1,253 @@
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.js';
6
+ import { log, setLogLevel } from './log.js';
7
+ import { findNodes } from './client.js';
8
+ import { Config, configPath, parseIdle } from './config.js';
9
+ import { Controller } from './controller.js';
10
+ import { parseAppId } from './protocol.js';
11
+ import { VERSION } from './version.js';
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
+ };
63
+
64
+ /** Which options each command accepts (besides the global ones). */
65
+ const COMMAND_OPTIONS = {
66
+ setup: ['idle', 'keep-connected'],
67
+ scan: [],
68
+ lights: [],
69
+ devices: [],
70
+ on: [],
71
+ off: [],
72
+ light: ['color', 'brightness'],
73
+ night: ['level'],
74
+ status: [],
75
+ group: ['color', 'brightness'],
76
+ scene: [],
77
+ name: [],
78
+ serve: ['host', 'port', 'token', 'idle', 'keep-connected'],
79
+ mcp: [],
80
+ };
81
+ const GLOBAL = ['id', 'node', 'verbose', 'help', 'version'];
82
+
83
+ function intArg(v, name, { min = -Infinity, max = Infinity, def } = {}) {
84
+ if (v === undefined) return def;
85
+ if (!/^-?\d+$/.test(v)) throw new InputError(`--${name}: invalid int value: '${v}'`);
86
+ const n = Number(v);
87
+ if (n < min || n > max) throw new InputError(`--${name}: must be between ${min} and ${max}`);
88
+ return n;
89
+ }
90
+
91
+ function choice(v, name, choices) {
92
+ if (!choices.includes(v)) throw new InputError(`${name}: expected one of ${choices.join(', ')} (got ${v === undefined ? 'nothing' : `'${v}'`})`);
93
+ return v;
94
+ }
95
+
96
+ /** Parse argv into `{cmd, args, opts}`; throws InputError on bad usage. */
97
+ export function parseCli(argv) {
98
+ let parsed;
99
+ try {
100
+ parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
101
+ } catch (e) {
102
+ throw new InputError(e.message);
103
+ }
104
+ const { values: o, positionals } = parsed;
105
+ if (o.help || o.version) return { cmd: o.help ? 'help' : 'version', args: [], opts: o };
106
+ const [cmd, ...args] = positionals;
107
+ if (!cmd) throw new InputError('missing command (see odelic --help)');
108
+ if (!(cmd in COMMAND_OPTIONS)) throw new InputError(`unknown command '${cmd}' (see odelic --help)`);
109
+ for (const k of Object.keys(o)) {
110
+ if (!GLOBAL.includes(k) && !COMMAND_OPTIONS[cmd].includes(k)) throw new InputError(`${cmd}: unrecognized option --${k}`);
111
+ }
112
+ if (o.idle !== undefined && o['keep-connected']) throw new InputError('--idle and --keep-connected are mutually exclusive');
113
+ if (o.idle !== undefined) parseIdle(o.idle); // validate early
114
+ const maxArgs = { on: 1, off: 1, light: 1, night: 1, status: 1, group: 2, scene: 2, name: 3 }[cmd] ?? 0;
115
+ if (args.length > maxArgs) throw new InputError(`${cmd}: unexpected argument '${args[maxArgs]}'`);
116
+ return { cmd, args, opts: o };
117
+ }
118
+
119
+ function idleValue(o) {
120
+ return o['keep-connected'] ? 0 : parseIdle(o.idle);
121
+ }
122
+
123
+ function show(obj) {
124
+ console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
125
+ }
126
+
127
+ async function runBle(cmd, args, o, cfg) {
128
+ if (cmd === 'scan') {
129
+ const aid = cfg.appId(o.id);
130
+ const homeid = aid ? parseAppId(aid).homeid : null;
131
+ for (const n of await findNodes({ homeid })) {
132
+ console.log(`${String(n.name).padEnd(20)} ${n.address} HomeID=${n.homeid} rssi=${n.rssi}`);
133
+ }
134
+ return;
135
+ }
136
+
137
+ if (cmd === 'serve') {
138
+ const { serve } = await import('./server.js');
139
+ const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: idleValue(o) });
140
+ const server = await serve(ctl, {
141
+ host: o.host ?? '127.0.0.1',
142
+ port: intArg(o.port, 'port', { min: 0, max: 65535, def: 8765 }),
143
+ token: o.token ?? process.env.ODELIC_HTTP_TOKEN ?? null,
144
+ });
145
+ await new Promise((resolve) => {
146
+ const stop = () => server.close(resolve);
147
+ process.once('SIGINT', stop);
148
+ process.once('SIGTERM', stop);
149
+ });
150
+ await ctl.close();
151
+ return;
152
+ }
153
+
154
+ const target = args[0] ?? 'all';
155
+ const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: 0 });
156
+ try {
157
+ if (cmd === 'lights' || cmd === 'devices') {
158
+ for (const d of await ctl.lights()) {
159
+ if (d.online) console.log(`${(d.name ?? '-').padEnd(12)} vaddr=${d.vaddr} mac=${d.mac} product=${d.product} ver=${d.version}`);
160
+ else console.log(`${d.name.padEnd(12)} vaddr=${d.vaddr} (offline)`);
161
+ }
162
+ return;
163
+ }
164
+ if (cmd === 'on') show(await ctl.on(target));
165
+ else if (cmd === 'off') show(await ctl.off(target));
166
+ else if (cmd === 'light') {
167
+ show(await ctl.setLight(target, intArg(o.color, 'color', { def: 50 }), intArg(o.brightness, 'brightness', { def: 100 })));
168
+ } else if (cmd === 'night') show(await ctl.nightLight(target, intArg(o.level, 'level', { min: 0, max: 2, def: 0 })));
169
+ else if (cmd === 'status') show(await ctl.status(target));
170
+ else if (cmd === 'group') {
171
+ const id = intArg(args[0], 'group_id');
172
+ if (id === undefined) throw new InputError('group: missing group id');
173
+ show(await ctl.group(id, choice(args[1], 'group action', ['on', 'off', 'light']),
174
+ intArg(o.color, 'color', { def: 50 }), intArg(o.brightness, 'brightness', { def: 100 })));
175
+ } else if (cmd === 'scene') {
176
+ const action = choice(args[0], 'scene action', ['save', 'play']);
177
+ const index = intArg(args[1], 'index');
178
+ if (index === undefined) throw new InputError('scene: missing index');
179
+ show(await ctl.scene(index, action === 'save'));
180
+ }
181
+ await sleep(500); // let write-without-response packets drain before disconnecting
182
+ } finally {
183
+ await ctl.close();
184
+ }
185
+ }
186
+
187
+ /** CLI entry point. Resolves to the process exit code. */
188
+ export async function main(argv = process.argv.slice(2)) {
189
+ let cmd;
190
+ let args;
191
+ let o;
192
+ try {
193
+ ({ cmd, args, opts: o } = parseCli(argv));
194
+ } catch (e) {
195
+ console.error(`odelic: error: ${e.message}`);
196
+ return 2;
197
+ }
198
+ if (cmd === 'help') {
199
+ console.log(HELP);
200
+ return 0;
201
+ }
202
+ if (cmd === 'version') {
203
+ console.log(VERSION);
204
+ return 0;
205
+ }
206
+ if (o.verbose) setLogLevel('debug');
207
+
208
+ try {
209
+ const cfg = Config.load();
210
+ if (cmd === 'setup') {
211
+ if (o.id) {
212
+ parseAppId(o.id);
213
+ cfg.id = o.id;
214
+ }
215
+ if (o.node) cfg.node = o.node;
216
+ const idle = idleValue(o);
217
+ if (idle !== null) cfg.idleTimeout = idle;
218
+ cfg.save();
219
+ const mode = cfg.idle();
220
+ console.log(`saved to ${configPath()} (id: ${cfg.id ? 'set' : 'not set'}, connection: ${
221
+ mode ? `disconnect after ${mode}s idle` : 'keep connected'})`);
222
+ } else if (cmd === 'name') {
223
+ const sub = choice(args[0], 'name command', ['add', 'rm', 'list']);
224
+ if (sub === 'add') {
225
+ if (args.length !== 3) throw new InputError('usage: odelic name add NAME VADDR');
226
+ cfg.setName(args[1], args[2]);
227
+ cfg.save();
228
+ } else if (sub === 'rm') {
229
+ if (args.length !== 2) throw new InputError('usage: odelic name rm NAME');
230
+ cfg.removeName(args[1]);
231
+ cfg.save();
232
+ }
233
+ for (const [name, v] of Object.entries(cfg.lights)) console.log(`${name.padEnd(12)} ${v}`);
234
+ } else if (cmd === 'mcp') {
235
+ let mcp;
236
+ try {
237
+ mcp = await import('./mcp.js');
238
+ } catch (e) {
239
+ if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
240
+ throw new OdelicError(`${e.message} (install with: npm install @modelcontextprotocol/sdk zod)`);
241
+ }
242
+ await mcp.main();
243
+ return null; // keep running until stdin closes
244
+ } else {
245
+ await runBle(cmd, args, o, cfg);
246
+ }
247
+ return 0;
248
+ } catch (e) {
249
+ if (o.verbose || !(e instanceof OdelicError)) log.debug(e.stack);
250
+ console.error(`error: ${e.message}`);
251
+ return 1;
252
+ }
253
+ }