odelic 0.2.0 → 0.2.2

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 (68) hide show
  1. package/README.md +93 -91
  2. package/bin/odelic-mcp.js +1 -1
  3. package/bin/odelic.js +1 -1
  4. package/dist/cli.d.ts +58 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +279 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/client.d.ts +140 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +448 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/config.d.ts +34 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +139 -0
  15. package/dist/config.js.map +1 -0
  16. package/dist/controller.d.ts +95 -0
  17. package/dist/controller.d.ts.map +1 -0
  18. package/dist/controller.js +239 -0
  19. package/dist/controller.js.map +1 -0
  20. package/dist/crypto.d.ts +34 -0
  21. package/dist/crypto.d.ts.map +1 -0
  22. package/dist/crypto.js +111 -0
  23. package/dist/crypto.js.map +1 -0
  24. package/dist/errors.d.ts +14 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +17 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/index.d.ts +18 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/{src → dist}/index.js +1 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/log.d.ts +18 -0
  33. package/dist/log.d.ts.map +1 -0
  34. package/dist/log.js +21 -0
  35. package/dist/log.js.map +1 -0
  36. package/dist/mcp.d.ts +23 -0
  37. package/dist/mcp.d.ts.map +1 -0
  38. package/dist/mcp.js +74 -0
  39. package/dist/mcp.js.map +1 -0
  40. package/dist/protocol.d.ts +90 -0
  41. package/dist/protocol.d.ts.map +1 -0
  42. package/dist/protocol.js +178 -0
  43. package/dist/protocol.js.map +1 -0
  44. package/dist/server.d.ts +24 -0
  45. package/dist/server.d.ts.map +1 -0
  46. package/dist/server.js +187 -0
  47. package/dist/server.js.map +1 -0
  48. package/dist/version.d.ts +2 -0
  49. package/dist/version.d.ts.map +1 -0
  50. package/dist/version.js +2 -0
  51. package/dist/version.js.map +1 -0
  52. package/package.json +19 -7
  53. package/src/{cli.js → cli.ts} +64 -50
  54. package/src/client.ts +497 -0
  55. package/src/{config.js → config.ts} +37 -23
  56. package/src/controller.ts +294 -0
  57. package/src/{crypto.js → crypto.ts} +46 -28
  58. package/src/{errors.js → errors.ts} +2 -2
  59. package/src/index.ts +17 -0
  60. package/src/log.ts +24 -0
  61. package/src/{mcp.js → mcp.ts} +31 -20
  62. package/src/{protocol.js → protocol.ts} +56 -30
  63. package/src/{server.js → server.ts} +57 -38
  64. package/src/version.ts +1 -0
  65. package/src/client.js +0 -362
  66. package/src/controller.js +0 -219
  67. package/src/log.js +0 -20
  68. package/src/version.js +0 -1
package/README.md CHANGED
@@ -1,78 +1,80 @@
1
1
  # odelic.js
2
2
 
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.
3
+ ODELIC(オーデリック)の Bluetooth 照明を PC から操作するための**非公式** TypeScript / Node.js ライブラリです。Python 版 [odelic.py](https://github.com/rbstxt/odelic.py) を TypeScript に移植したもので、機能は同じです。型定義も同梱しています。
4
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.
5
+ 公式 Android アプリ(`jp.co.odelic.smt.remote10` v1.10.39)の通信方式を解析して作りました。対象はアプリが使っている Pairlink connected-mesh 系の機器です。クラウドは使わず、BLE でローカルに直接操作します。
6
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.
7
+ - BLE 用の依存ライブラリは [@stoprocent/noble](https://github.com/stoprocent/noble) だけです。暗号処理(AES-128)は Node 標準の `crypto` を使っています。
8
+ - macOS で動作を確認しています。noble が対応する環境(Linux / Windows)でも動く想定です。
9
+ - 設定ファイル(`~/.config/odelic/config.json`)は Python 版と同じ形式で、共有できます。
10
+ - メーカー非公認のツールです。利用は自己責任でお願いします。
11
11
 
12
- ## Setup
12
+ ## セットアップ
13
13
 
14
- Requires Node.js 20 or later.
14
+ Node.js 22 以上が必要です。
15
15
 
16
16
  ```sh
17
17
  npm install odelic
18
- # To use the MCP server, also install these
18
+ # MCP サーバーを使う場合は、これらも入れてください
19
19
  npm install @modelcontextprotocol/sdk zod
20
20
  ```
21
21
 
22
- For development (from this repository):
22
+ 開発用(このリポジトリから):
23
23
 
24
24
  ```sh
25
25
  npm install
26
- npm test
26
+ npm run typecheck # src/ と test/ の型チェック
27
+ npm test # .ts のテストをそのまま実行(Node 標準の型除去機能を使用)
28
+ npm run build # src/ を dist/ にコンパイル(JS と .d.ts)。bin/ の CLI は dist/ を使います
27
29
  ```
28
30
 
29
- ### Required information: the app ID
31
+ ### 必要な情報:アプリの ID
30
32
 
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`.
33
+ アプリのメニュー画面に `ID:12345678` のような表示があります。これは **HomeID とパスワード(末尾4文字)をつなげたもの**です。この例では HomeID が 1234、パスワードが `5678` になります。
32
34
 
33
35
  ```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
+ odelic setup --id 12345678 # 設定ファイルに保存(権限 600)
37
+ # または毎回 --id / 環境変数 ODELIC_ID で指定
36
38
  ```
37
39
 
38
40
  ## CLI
39
41
 
40
42
  ```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
43
+ odelic scan # 近くのメッシュノードを探す(ログインしない)
44
+ odelic lights # メッシュ内の照明一覧(vaddr / MAC / 製品コード)
45
+ odelic on # 全点灯(前回の色・明るさに戻る)
46
+ odelic off # 全消灯
47
+ odelic light --color 0 --brightness 100 # 暖色(電球色)で最大の明るさ
48
+ odelic light --color 100 --brightness 30 # 寒色(昼光色)で 30%
49
+ odelic night --level 0 # 常夜灯(0〜2)
50
+ odelic status # 全照明の点灯状態・色・明るさ
51
+ odelic name add リビング 00000000 # 照明に名前を付ける(vaddr は lights で確認)
52
+ odelic off リビング # 名前か vaddr で1台だけ操作
53
+ odelic group 1 on # グループ単位(グループ ID はアプリでの設定)
54
+ odelic scene save 1 / odelic scene play 1 # シーンの保存・呼び出し
55
+ odelic -v ... # 送受信パケットを表示する
54
56
  ```
55
57
 
56
- ### Auto-disconnect or stay connected
58
+ ### 自動切断か常駐か
57
59
 
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.
60
+ メッシュノードは**同時に 1 台のコントローラとしか接続できません**。スマホのアプリも使えるように、HTTP API と MCP サーバーは既定で 60 秒操作がなければ BLE 接続を切ります。スマホのアプリを使わないなら、つなぎっぱなしにできます。
59
61
 
60
62
  ```sh
61
- odelic setup --keep-connected # always stay connected
62
- odelic setup --idle 300 # disconnect after 300 idle seconds
63
+ odelic setup --keep-connected # 常に接続したままにする
64
+ odelic setup --idle 300 # 300 秒操作がなければ切断
63
65
  ```
64
66
 
65
- Priority: command option (`--idle` / `--keep-connected`) → environment variable `ODELIC_IDLE_TIMEOUT` (`0` keeps the connection) → config file → default 60 seconds.
67
+ 優先順位:コマンドのオプション(`--idle` / `--keep-connected`)→ 環境変数 `ODELIC_IDLE_TIMEOUT`(`0` で常駐)→ 設定ファイル → 既定値 60 秒
66
68
 
67
- ## Using it as a library
69
+ ## ライブラリとして使う
68
70
 
69
- ```js
71
+ ```ts
70
72
  import { OdelicMesh } from 'odelic';
71
73
 
72
- const mesh = new OdelicMesh(process.env.ODELIC_ID);
74
+ const mesh = new OdelicMesh(process.env.ODELIC_ID ?? null);
73
75
  await mesh.connect();
74
76
  try {
75
- await mesh.setLight({ color: 0, brightness: 100 }); // 0 = warm .. 100 = cool
77
+ await mesh.setLight({ color: 0, brightness: 100 }); // 0 = 暖色 .. 100 = 寒色
76
78
  for (const d of await mesh.devices()) console.log(String(d));
77
79
  console.log((await mesh.status('00000000'))?.toJSON());
78
80
  await mesh.off();
@@ -81,66 +83,66 @@ try {
81
83
  }
82
84
  ```
83
85
 
84
- | Method | Description |
86
+ | メソッド | 内容 |
85
87
  |---|---|
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'));
88
+ | `on(target)` / `off(target)` | 点灯・消灯。`target`(vaddr)を省略すると全照明が対象 |
89
+ | `setLight({color, brightness, target, x, y})` | 色と明るさの指定。生の値 x (0–20) / y (0–19, 0 が最も明るい) でも指定可 |
90
+ | `nightLight(level, target)` | 常夜灯(暖色の最も暗い 3 段階) |
91
+ | `setGroup(groupId, {color, brightness, on})` | グループ単位の操作 |
92
+ | `changeColor(target)` | 色プリセットの切り替え(対応機種のみ) |
93
+ | `saveScene(i)` / `playScene(i)` | シーンの保存・呼び出し |
94
+ | `devices()` | ping でメッシュ内の照明を列挙 |
95
+ | `status(target)` | 状態の問い合わせ(`LightState`: `on` / `color` / `brightness`) |
96
+ | `send(packet)` | 任意の type-3 パケットを暗号化して送信(`protocol` を参照) |
97
+ | `addListener(cb)` | 受信した全パケットを復号済みで受け取る |
98
+
99
+ 名前の解決・自動接続と再接続・アイドル切断までまとめた上位クラス `Controller` もあります(CLI・HTTP・MCP の内部で使用)。
100
+
101
+ ```ts
102
+ import { Controller, type LightStatus } from 'odelic';
103
+
104
+ const ctl = new Controller(); // ID は ODELIC_ID / 設定ファイルから
105
+ await ctl.setLight('リビング', 0, 100); // 対象, 色, 明るさ
106
+ const states: LightStatus | LightStatus[] = await ctl.status('all');
105
107
  await ctl.close();
106
108
  ```
107
109
 
108
- Timeouts and the idle duration are all given in **seconds**, matching the Python version and the config file.
110
+ タイムアウトやアイドル時間の単位は、Python 版や設定ファイルに合わせてすべて**秒**です。
109
111
 
110
112
  ## HTTP API
111
113
 
112
114
  ```sh
113
115
  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
+ odelic serve --keep-connected # 起動時に接続し、つなぎっぱなしにする
117
+ odelic serve --host 0.0.0.0 --token s3cret # localhost 以外で待ち受けるにはトークンが必須
116
118
  ```
117
119
 
118
- | Method | Path | Description |
120
+ | メソッド | パス | 内容 |
119
121
  |---|---|---|
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 |
122
+ | GET | `/health` | 接続状態 |
123
+ | GET | `/lights` | 照明一覧 |
124
+ | GET | `/lights/<target>` | 状態(`all` で全照明) |
125
+ | POST | `/lights/<target>/on` · `/off` | 点灯・消灯 |
124
126
  | POST | `/lights/<target>/light` | `{"color": 0..100, "brightness": 1..100}` |
125
127
  | POST | `/lights/<target>/night` | `{"level": 0..2}` |
126
- | POST | `/groups/<id>/on` · `/off` · `/light` | Group control |
127
- | POST | `/scenes/<index>/play` · `/save` | Scenes |
128
+ | POST | `/groups/<id>/on` · `/off` · `/light` | グループ操作 |
129
+ | POST | `/scenes/<index>/play` · `/save` | シーン |
128
130
 
129
- Parameters can be passed as a JSON body or as query parameters. When a token is set, use `Authorization: Bearer <token>` (or `?token=`).
131
+ パラメータは JSON ボディでもクエリパラメータでも渡せます。トークンを設定した場合は `Authorization: Bearer <token>`(または `?token=`)が必要です。
130
132
 
131
133
  ```sh
132
134
  curl -X POST localhost:8765/lights/リビング/light -d '{"color": 0, "brightness": 80}'
133
135
  ```
134
136
 
135
- ## MCP server (for AI assistants)
137
+ ## MCP サーバー(AI アシスタント向け)
136
138
 
137
- This lets Claude and other AI tools control the lights. It requires `@modelcontextprotocol/sdk` and `zod`.
139
+ Claude などの AI から照明を操作できます。`@modelcontextprotocol/sdk` と `zod` が必要です。
138
140
 
139
141
  ```sh
140
142
  claude mcp add odelic -e ODELIC_ID=12345678 -- npx -y -p odelic -p @modelcontextprotocol/sdk -p zod odelic-mcp
141
143
  ```
142
144
 
143
- Configuration file example (Claude Desktop, etc.):
145
+ 設定ファイルに書く場合(Claude Desktop など):
144
146
 
145
147
  ```json
146
148
  {
@@ -154,35 +156,35 @@ Configuration file example (Claude Desktop, etc.):
154
156
  }
155
157
  ```
156
158
 
157
- To run it from a local checkout, use `"command": "node", "args": ["/path/to/odelic-js/bin/odelic-mcp.js"]`.
159
+ このリポジトリから直接動かす場合は、一度 `npm run build` してから `"command": "node", "args": ["/path/to/odelic-js/bin/odelic-mcp.js"]` を指定してください。
158
160
 
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`
161
+ ツール一覧:`list_lights` `light_status` `turn_on` `turn_off` `set_light` `night_light` `control_group` `play_scene` `save_scene` `name_light` `forget_light_name`
160
162
 
161
- ## Notes
163
+ ## 注意
162
164
 
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.
165
+ - スマホのアプリが接続中だと `node is already in use by another controller` になります。アプリを閉じるか、スマホの Bluetooth を切ってから試してください。
166
+ - macOS では初回実行時に、ターミナル(または実行元のアプリ)への Bluetooth 許可を求められます。
167
+ - **新しい照明の登録**には未対応です。アプリはスマホから独自形式の電波を発信して登録していますが、macOS の標準 Bluetooth 機能ではその形式を発信できません。登録はアプリで行ってください。
166
168
 
167
- ## Protocol overview
169
+ ## プロトコル概要
168
170
 
169
- | Item | Details |
171
+ | 項目 | 内容 |
170
172
  |---|---|
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 |
173
+ | GATT サービス | `9e5d1e47-5c13-43a0-8635-82adffc0386f`(書き込み用 `…ffc1…` / 通知用 `…ffc2…`) |
174
+ | 鍵 | HomeID(4B, little endian)とパスワード(4B)を 1 バイトずつ交互に並べた 8B に、`"LOGINKEY"` または `"EVENTKEY"` を連結した AES-128 鍵 |
175
+ | ログイン | `01 20` を送る → ノードが `02 20 + AES_L(homeid, session4)` を返す → `01 16 03 + AES_L(homeid, pwd, session4)` を返す → `02 16 00` が来れば成功(`08` は他の端末が接続中) → `01 0a <own vaddr>` で自分のアドレスが割り当てられる |
176
+ | データ | 平文 `03 <dst4> <ch> <payload>` を `06 <dst4> <ch> XOR(AES_E(pkcs7(payload)), session4)` に変換 |
177
+ | 照明命令 | `<own vaddr> C0 <index> <x> <y> 00 00 00 00`、ch=0x20(全体宛は 0x2A)。x=55,y=55 で点灯、50,50 で消灯 |
176
178
 
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).
179
+ テストでは、元アプリのネイティブライブラリをエミュレータで動かして作ったテストベクタと照合しています(Python 版と同じもの。odelic.py の `tools/native_oracle.py` を参照)。
178
180
 
179
- ## Releasing
181
+ ## リリース
180
182
 
181
- Publishing a GitHub release runs `.github/workflows/publish.yml`, which publishes to npm via npm Trusted Publishing (OIDC) — no token needed.
183
+ GitHub でリリースを作ると、`.github/workflows/publish.yml` が npm へ公開します。npm の Trusted Publishing(OIDC)を使うので、トークンは不要です。
182
184
 
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
+ 1. `package.json` と `src/version.ts` のバージョンを上げてプッシュ
186
+ 2. GitHub で `v<バージョン>` タグのリリースを作成(例: `v0.2.1`)
185
187
 
186
- ## License
188
+ ## ライセンス
187
189
 
188
- MIT License ([LICENSE](LICENSE))
190
+ MIT License([LICENSE](LICENSE))
package/bin/odelic-mcp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../src/cli.js';
2
+ import { main } from '../dist/cli.js';
3
3
 
4
4
  const code = await main(['mcp']);
5
5
  if (code !== null) process.exit(code);
package/bin/odelic.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { main } from '../src/cli.js';
2
+ import { main } from '../dist/cli.js';
3
3
 
4
4
  const code = await main();
5
5
  // noble keeps native handles open after use, so exit explicitly (null = long-running, e.g. `odelic mcp`)
package/dist/cli.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ declare const OPTIONS: {
2
+ readonly id: {
3
+ readonly type: 'string';
4
+ };
5
+ readonly node: {
6
+ readonly type: 'string';
7
+ };
8
+ readonly verbose: {
9
+ readonly type: 'boolean';
10
+ readonly short: 'v';
11
+ };
12
+ readonly help: {
13
+ readonly type: 'boolean';
14
+ readonly short: 'h';
15
+ };
16
+ readonly version: {
17
+ readonly type: 'boolean';
18
+ };
19
+ readonly idle: {
20
+ readonly type: 'string';
21
+ };
22
+ readonly 'keep-connected': {
23
+ readonly type: 'boolean';
24
+ };
25
+ readonly color: {
26
+ readonly type: 'string';
27
+ };
28
+ readonly brightness: {
29
+ readonly type: 'string';
30
+ };
31
+ readonly level: {
32
+ readonly type: 'string';
33
+ };
34
+ readonly host: {
35
+ readonly type: 'string';
36
+ };
37
+ readonly port: {
38
+ readonly type: 'string';
39
+ };
40
+ readonly token: {
41
+ readonly type: 'string';
42
+ };
43
+ };
44
+ type OptionName = keyof typeof OPTIONS;
45
+ export type CliOptions = {
46
+ [K in OptionName]?: (typeof OPTIONS)[K]['type'] extends 'boolean' ? boolean : string;
47
+ };
48
+ export interface ParsedCli {
49
+ cmd: string;
50
+ args: string[];
51
+ opts: CliOptions;
52
+ }
53
+ /** Parse argv; throws InputError on bad usage. */
54
+ export declare function parseCli(argv: string[]): ParsedCli;
55
+ /** CLI entry point. Resolves to the process exit code, or null for long-running commands (`mcp`). */
56
+ export declare function main(argv?: string[]): Promise<number | null>;
57
+ export {};
58
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AA+CA,QAAA,MAAM,OAAO;aACX,EAAE;iBAAI,IAAI,EAAE,QAAQ;;aACpB,IAAI;iBAAI,IAAI,EAAE,QAAQ;;aACtB,OAAO;iBAAI,IAAI,EAAE,SAAS;iBAAE,KAAK,EAAE,GAAG;;aACtC,IAAI;iBAAI,IAAI,EAAE,SAAS;iBAAE,KAAK,EAAE,GAAG;;aACnC,OAAO;iBAAI,IAAI,EAAE,SAAS;;aAC1B,IAAI;iBAAI,IAAI,EAAE,QAAQ;;aACtB,gBAAgB;iBAAI,IAAI,EAAE,SAAS;;aACnC,KAAK;iBAAI,IAAI,EAAE,QAAQ;;aACvB,UAAU;iBAAI,IAAI,EAAE,QAAQ;;aAC5B,KAAK;iBAAI,IAAI,EAAE,QAAQ;;aACvB,IAAI;iBAAI,IAAI,EAAE,QAAQ;;aACtB,IAAI;iBAAI,IAAI,EAAE,QAAQ;;aACtB,KAAK;iBAAI,IAAI,EAAE,QAAQ;;CACf,CAAC;AAEX,KAAK,UAAU,GAAG,MAAM,OAAO,OAAO,CAAC;AACvC,MAAM,MAAM,UAAU,GAAG;KACtB,CAAC,IAAI,UAAU,CAAC,CAAC,EAAE,CAAC,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,SAAS,GAAG,OAAO,GAAG,MAAM;CACrF,CAAC;AAqCF,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,IAAI,EAAE,UAAU,CAAC;CAClB;AAED,kDAAkD;AAClD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,SAAS,CAoBlD;AAsED,qGAAqG;AACrG,wBAAsB,IAAI,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiEzF"}
package/dist/cli.js ADDED
@@ -0,0 +1,279 @@
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
+ import { InputError, OdelicError } from './errors.js';
5
+ import { log, setLogLevel } from './log.js';
6
+ import { findNodes } from './client.js';
7
+ import { Config, configPath, parseIdle } from './config.js';
8
+ import { Controller } from './controller.js';
9
+ import { parseAppId } from './protocol.js';
10
+ import { VERSION } from './version.js';
11
+ const HELP = `usage: odelic [--id ID] [--node NODE] [-v] <command> [...]
12
+
13
+ Control ODELIC Bluetooth lights.
14
+
15
+ global options:
16
+ --id ID ID shown in the app (ID:xxxx); default: $ODELIC_ID or the config file
17
+ --node NODE BLE name/address of the node to connect through
18
+ -v, --verbose show BLE packets
19
+ -h, --help show this help
20
+ --version show the version
21
+
22
+ commands:
23
+ setup [--id ID] [--node NODE] [--idle SECONDS | --keep-connected]
24
+ save the app ID (and optionally a node / connection mode) to the config file
25
+ scan list nearby mesh nodes (no login)
26
+ lights list lights in the mesh (alias: devices)
27
+ on [TARGET] turn on (previous colour/brightness)
28
+ off [TARGET] turn off
29
+ light [TARGET] [--color 0..100] [--brightness 1..100]
30
+ turn on with colour (0 = warm 電球色 .. 100 = cool 昼光色) and brightness
31
+ night [TARGET] [--level 0..2]
32
+ night light
33
+ status [TARGET] show on/off, colour and brightness
34
+ group ID on|off|light [--color N] [--brightness N]
35
+ control a group (id as configured in the app)
36
+ scene save|play INDEX
37
+ save or play a scene
38
+ name add NAME VADDR | name rm NAME | name list
39
+ manage light names
40
+ serve [--host H] [--port P] [--token T] [--idle SECONDS | --keep-connected]
41
+ run the HTTP API (token default: $ODELIC_HTTP_TOKEN)
42
+ mcp run the MCP server on stdio (needs @modelcontextprotocol/sdk and zod)
43
+
44
+ TARGET is a light name, a vaddr or "all" (default).`;
45
+ const OPTIONS = {
46
+ id: { type: 'string' },
47
+ node: { type: 'string' },
48
+ verbose: { type: 'boolean', short: 'v' },
49
+ help: { type: 'boolean', short: 'h' },
50
+ version: { type: 'boolean' },
51
+ idle: { type: 'string' },
52
+ 'keep-connected': { type: 'boolean' },
53
+ color: { type: 'string' },
54
+ brightness: { type: 'string' },
55
+ level: { type: 'string' },
56
+ host: { type: 'string' },
57
+ port: { type: 'string' },
58
+ token: { type: 'string' },
59
+ };
60
+ /** Which options each command accepts (besides the global ones). */
61
+ const COMMAND_OPTIONS = {
62
+ setup: ['idle', 'keep-connected'],
63
+ scan: [],
64
+ lights: [],
65
+ devices: [],
66
+ on: [],
67
+ off: [],
68
+ light: ['color', 'brightness'],
69
+ night: ['level'],
70
+ status: [],
71
+ group: ['color', 'brightness'],
72
+ scene: [],
73
+ name: [],
74
+ serve: ['host', 'port', 'token', 'idle', 'keep-connected'],
75
+ mcp: [],
76
+ };
77
+ const GLOBAL = ['id', 'node', 'verbose', 'help', 'version'];
78
+ const MAX_ARGS = { on: 1, off: 1, light: 1, night: 1, status: 1, group: 2, scene: 2, name: 3 };
79
+ function intArg(v, name, { min = -Infinity, max = Infinity } = {}) {
80
+ if (v === undefined)
81
+ return undefined;
82
+ if (!/^-?\d+$/.test(v))
83
+ throw new InputError(`--${name}: invalid int value: '${v}'`);
84
+ const n = Number(v);
85
+ if (n < min || n > max)
86
+ throw new InputError(`--${name}: must be between ${min} and ${max}`);
87
+ return n;
88
+ }
89
+ function choice(v, name, choices) {
90
+ if (!choices.includes(v)) {
91
+ throw new InputError(`${name}: expected one of ${choices.join(', ')} (got ${v === undefined ? 'nothing' : `'${v}'`})`);
92
+ }
93
+ return v;
94
+ }
95
+ /** Parse argv; throws InputError on bad usage. */
96
+ export function parseCli(argv) {
97
+ let parsed;
98
+ try {
99
+ parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
100
+ }
101
+ catch (e) {
102
+ throw new InputError(e.message);
103
+ }
104
+ const o = parsed.values;
105
+ if (o.help || o.version)
106
+ return { cmd: o.help ? 'help' : 'version', args: [], opts: o };
107
+ const [cmd, ...args] = parsed.positionals;
108
+ if (!cmd)
109
+ throw new InputError('missing command (see odelic --help)');
110
+ if (!Object.hasOwn(COMMAND_OPTIONS, cmd))
111
+ throw new InputError(`unknown command '${cmd}' (see odelic --help)`);
112
+ for (const k of Object.keys(o)) {
113
+ if (!GLOBAL.includes(k) && !COMMAND_OPTIONS[cmd].includes(k))
114
+ throw new InputError(`${cmd}: unrecognized option --${k}`);
115
+ }
116
+ if (o.idle !== undefined && o['keep-connected'])
117
+ throw new InputError('--idle and --keep-connected are mutually exclusive');
118
+ if (o.idle !== undefined)
119
+ parseIdle(o.idle); // validate early
120
+ const maxArgs = MAX_ARGS[cmd] ?? 0;
121
+ if (args.length > maxArgs)
122
+ throw new InputError(`${cmd}: unexpected argument '${args[maxArgs]}'`);
123
+ return { cmd, args, opts: o };
124
+ }
125
+ function idleValue(o) {
126
+ return o['keep-connected'] ? 0 : parseIdle(o.idle);
127
+ }
128
+ function show(obj) {
129
+ console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
130
+ }
131
+ async function runBle(cmd, args, o, cfg) {
132
+ if (cmd === 'scan') {
133
+ const aid = cfg.appId(o.id);
134
+ const homeid = aid ? parseAppId(aid).homeid : null;
135
+ for (const n of await findNodes({ homeid })) {
136
+ console.log(`${String(n.name).padEnd(20)} ${n.address} HomeID=${n.homeid} rssi=${n.rssi}`);
137
+ }
138
+ return;
139
+ }
140
+ if (cmd === 'serve') {
141
+ const { serve } = await import('./server.js');
142
+ const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: idleValue(o) });
143
+ const server = await serve(ctl, {
144
+ host: o.host ?? '127.0.0.1',
145
+ port: intArg(o.port, 'port', { min: 0, max: 65535 }) ?? 8765,
146
+ token: o.token ?? process.env.ODELIC_HTTP_TOKEN ?? null,
147
+ });
148
+ await new Promise((resolve) => {
149
+ const stop = () => server.close(() => resolve());
150
+ process.once('SIGINT', stop);
151
+ process.once('SIGTERM', stop);
152
+ });
153
+ await ctl.close();
154
+ return;
155
+ }
156
+ const target = args[0] ?? 'all';
157
+ const color = () => intArg(o.color, 'color') ?? 50;
158
+ const brightness = () => intArg(o.brightness, 'brightness') ?? 100;
159
+ const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: 0 });
160
+ try {
161
+ if (cmd === 'lights' || cmd === 'devices') {
162
+ for (const d of await ctl.lights()) {
163
+ if (d.online)
164
+ console.log(`${(d.name ?? '-').padEnd(12)} vaddr=${d.vaddr} mac=${d.mac} product=${d.product} ver=${d.version}`);
165
+ else
166
+ console.log(`${String(d.name).padEnd(12)} vaddr=${d.vaddr} (offline)`);
167
+ }
168
+ return;
169
+ }
170
+ if (cmd === 'on')
171
+ show(await ctl.on(target));
172
+ else if (cmd === 'off')
173
+ show(await ctl.off(target));
174
+ else if (cmd === 'light')
175
+ show(await ctl.setLight(target, color(), brightness()));
176
+ else if (cmd === 'night')
177
+ show(await ctl.nightLight(target, intArg(o.level, 'level', { min: 0, max: 2 }) ?? 0));
178
+ else if (cmd === 'status')
179
+ show(await ctl.status(target));
180
+ else if (cmd === 'group') {
181
+ const id = intArg(args[0], 'group_id');
182
+ if (id === undefined)
183
+ throw new InputError('group: missing group id');
184
+ show(await ctl.group(id, choice(args[1], 'group action', ['on', 'off', 'light']), color(), brightness()));
185
+ }
186
+ else if (cmd === 'scene') {
187
+ const action = choice(args[0], 'scene action', ['save', 'play']);
188
+ const index = intArg(args[1], 'index');
189
+ if (index === undefined)
190
+ throw new InputError('scene: missing index');
191
+ show(await ctl.scene(index, action === 'save'));
192
+ }
193
+ await sleep(500); // let write-without-response packets drain before disconnecting
194
+ }
195
+ finally {
196
+ await ctl.close();
197
+ }
198
+ }
199
+ /** CLI entry point. Resolves to the process exit code, or null for long-running commands (`mcp`). */
200
+ export async function main(argv = process.argv.slice(2)) {
201
+ let parsed;
202
+ try {
203
+ parsed = parseCli(argv);
204
+ }
205
+ catch (e) {
206
+ console.error(`odelic: error: ${e.message}`);
207
+ return 2;
208
+ }
209
+ const { cmd, args, opts: o } = parsed;
210
+ if (cmd === 'help') {
211
+ console.log(HELP);
212
+ return 0;
213
+ }
214
+ if (cmd === 'version') {
215
+ console.log(VERSION);
216
+ return 0;
217
+ }
218
+ if (o.verbose)
219
+ setLogLevel('debug');
220
+ try {
221
+ const cfg = Config.load();
222
+ if (cmd === 'setup') {
223
+ if (o.id) {
224
+ parseAppId(o.id);
225
+ cfg.id = o.id;
226
+ }
227
+ if (o.node)
228
+ cfg.node = o.node;
229
+ const idle = idleValue(o);
230
+ if (idle !== null)
231
+ cfg.idleTimeout = idle;
232
+ cfg.save();
233
+ const mode = cfg.idle();
234
+ console.log(`saved to ${configPath()} (id: ${cfg.id ? 'set' : 'not set'}, connection: ${mode ? `disconnect after ${mode}s idle` : 'keep connected'})`);
235
+ }
236
+ else if (cmd === 'name') {
237
+ const sub = choice(args[0], 'name command', ['add', 'rm', 'list']);
238
+ if (sub === 'add') {
239
+ if (args.length !== 3)
240
+ throw new InputError('usage: odelic name add NAME VADDR');
241
+ cfg.setName(args[1], args[2]);
242
+ cfg.save();
243
+ }
244
+ else if (sub === 'rm') {
245
+ if (args.length !== 2)
246
+ throw new InputError('usage: odelic name rm NAME');
247
+ cfg.removeName(args[1]);
248
+ cfg.save();
249
+ }
250
+ for (const [name, v] of Object.entries(cfg.lights))
251
+ console.log(`${name.padEnd(12)} ${v}`);
252
+ }
253
+ else if (cmd === 'mcp') {
254
+ let mcp;
255
+ try {
256
+ mcp = await import('./mcp.js');
257
+ }
258
+ catch (e) {
259
+ if (e.code !== 'ERR_MODULE_NOT_FOUND')
260
+ throw e;
261
+ throw new OdelicError(`${e.message} (install with: npm install @modelcontextprotocol/sdk zod)`);
262
+ }
263
+ await mcp.main();
264
+ return null; // keep running until stdin closes
265
+ }
266
+ else {
267
+ await runBle(cmd, args, o, cfg);
268
+ }
269
+ return 0;
270
+ }
271
+ catch (e) {
272
+ const err = e;
273
+ if (o.verbose || !(err instanceof OdelicError))
274
+ log.debug(err.stack);
275
+ console.error(`error: ${err.message}`);
276
+ return 1;
277
+ }
278
+ }
279
+ //# sourceMappingURL=cli.js.map