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/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
|
-
#
|
|
1
|
+
# odelic.js
|
|
2
2
|
|
|
3
|
-
|
|
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))
|
package/bin/odelic.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "odelic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|