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
@@ -0,0 +1,267 @@
1
+ /**
2
+ * High-level controller shared by the CLI, the HTTP API and the MCP server.
3
+ *
4
+ * - connects lazily, serialises operations and reconnects once if the link dropped
5
+ * - disconnects after `idleTimeout` seconds without commands, so the phone app can use the mesh again
6
+ * (0 = keep the link open)
7
+ * - accepts light names from the config file ("living"), "all", or a raw vaddr ("00000000")
8
+ */
9
+ import { InputError, LoginError } from './errors.ts';
10
+ import { log } from './log.ts';
11
+ import * as P from './protocol.ts';
12
+ import { OdelicMesh } from './client.ts';
13
+ import { Config } from './config.ts';
14
+
15
+ /** Where a command goes: a light name from the config, "all", or a vaddr. */
16
+ export type Target = P.VaddrLike;
17
+
18
+ export interface ActionResult {
19
+ ok: true;
20
+ action: string;
21
+ target?: string;
22
+ color?: number;
23
+ brightness?: number;
24
+ level?: number;
25
+ group?: number;
26
+ index?: number;
27
+ }
28
+
29
+ export interface LightInfo {
30
+ name: string | null;
31
+ vaddr: string;
32
+ online: boolean;
33
+ mac?: string;
34
+ product?: number;
35
+ version?: string;
36
+ }
37
+
38
+ export type LightStatus =
39
+ | ({ name: string | null; online: true } & P.LightStateJSON)
40
+ | { name: string | null; vaddr: string; online: false };
41
+
42
+ export interface ControllerInfo {
43
+ connected: boolean;
44
+ node: string | null;
45
+ mode: 'keep' | 'idle';
46
+ idle_timeout: number;
47
+ }
48
+
49
+ export interface ControllerOptions {
50
+ /** BLE name / address of the node to connect through (default: config, else strongest node) */
51
+ node?: string | null;
52
+ config?: Config | null;
53
+ /** seconds before an idle link is released; 0 = stay connected; null = $ODELIC_IDLE_TIMEOUT / config / 60 s */
54
+ idleTimeout?: number | string | null;
55
+ /** use this mesh connection instead of creating one (mainly for tests) */
56
+ mesh?: OdelicMesh | null;
57
+ }
58
+
59
+ /** Integer from a number or numeric string (like Python's int()); throws InputError otherwise. */
60
+ export function toInt(v: unknown, what = 'value'): number {
61
+ const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
62
+ if (typeof n !== 'number' || !Number.isFinite(n)) throw new InputError(`${what} must be a number (got ${JSON.stringify(v)})`);
63
+ return Math.trunc(n);
64
+ }
65
+
66
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
67
+ const byVaddr = (a: P.Device, b: P.Device) => Buffer.compare(a.vaddr, b.vaddr);
68
+
69
+ export class Controller {
70
+ readonly config: Config;
71
+ readonly mesh: OdelicMesh;
72
+ /** seconds; 0 = keep the link open */
73
+ readonly idleTimeout: number;
74
+ private queue: Promise<unknown> = Promise.resolve();
75
+ private idle: NodeJS.Timeout | null = null;
76
+
77
+ /** @param appId default: $ODELIC_ID / config file */
78
+ constructor(appId: string | null = null, { node = null, config = null, idleTimeout = null, mesh = null }: ControllerOptions = {}) {
79
+ this.config = config ?? Config.load();
80
+ const aid = this.config.appId(appId);
81
+ if (!aid && !mesh) throw new InputError('no app ID: pass --id, set ODELIC_ID, or run `odelic setup --id <ID>`');
82
+ this.mesh = mesh ?? new OdelicMesh(aid, { device: node ?? this.config.node });
83
+ this.idleTimeout = this.config.idle(idleTimeout);
84
+ }
85
+
86
+ // ------------------------------------------------------------ connection handling
87
+ /** Run `fn` after every previously queued operation (a simple mutex). */
88
+ private locked<T>(fn: () => Promise<T>): Promise<T> {
89
+ const run = this.queue.then(fn);
90
+ this.queue = run.catch(() => {});
91
+ return run;
92
+ }
93
+
94
+ private run<T>(fn: (mesh: OdelicMesh) => Promise<T>): Promise<T> {
95
+ return this.locked(async () => {
96
+ this.cancelIdle();
97
+ try {
98
+ for (let attempt = 0; ; attempt++) {
99
+ if (!this.mesh.isConnected) await this.mesh.connect();
100
+ try {
101
+ return await fn(this.mesh);
102
+ } catch (e) {
103
+ // retry once when the BLE link dropped under us; input / login errors are final
104
+ if (attempt || this.mesh.isConnected || e instanceof InputError || e instanceof LoginError) throw e;
105
+ log.info(`link lost (${(e as Error).message}), reconnecting`);
106
+ await this.mesh.disconnect();
107
+ }
108
+ }
109
+ } finally {
110
+ this.armIdle();
111
+ }
112
+ });
113
+ }
114
+
115
+ private cancelIdle(): void {
116
+ if (this.idle) clearTimeout(this.idle);
117
+ this.idle = null;
118
+ }
119
+
120
+ private armIdle(): void {
121
+ if (!this.idleTimeout || !this.mesh.isConnected) return;
122
+ this.idle = setTimeout(() => {
123
+ this.locked(async () => {
124
+ this.idle = null;
125
+ if (this.mesh.isConnected) {
126
+ log.info('idle, disconnecting');
127
+ await this.mesh.disconnect();
128
+ }
129
+ }).catch((e: Error) => log.warning(`idle disconnect failed: ${e.message}`));
130
+ }, this.idleTimeout * 1000);
131
+ this.idle.unref();
132
+ }
133
+
134
+ /** Connect now (otherwise the first command connects). */
135
+ async connect(): Promise<void> {
136
+ await this.run(async () => {});
137
+ }
138
+
139
+ async close(): Promise<void> {
140
+ this.cancelIdle();
141
+ await this.locked(() => this.mesh.disconnect());
142
+ }
143
+
144
+ get connected(): boolean {
145
+ return this.mesh.isConnected;
146
+ }
147
+
148
+ get keepConnected(): boolean {
149
+ return !this.idleTimeout;
150
+ }
151
+
152
+ info(): ControllerInfo {
153
+ return { connected: this.connected, node: this.mesh.nodeName, mode: this.keepConnected ? 'keep' : 'idle', idle_timeout: this.idleTimeout };
154
+ }
155
+
156
+ // ------------------------------------------------------------ helpers
157
+ private label(v: Buffer): string {
158
+ return v.equals(P.BROADCAST) ? 'all' : (this.config.nameOf(v) ?? v.toString('hex'));
159
+ }
160
+
161
+ private done(action: string, v: Buffer, extra: Partial<ActionResult> = {}): ActionResult {
162
+ return { ok: true, action, target: this.label(v), ...extra };
163
+ }
164
+
165
+ // ------------------------------------------------------------ actions
166
+ async on(target: Target = 'all'): Promise<ActionResult> {
167
+ const v = this.config.resolve(target);
168
+ await this.run((m) => m.on(v));
169
+ return this.done('on', v);
170
+ }
171
+
172
+ async off(target: Target = 'all'): Promise<ActionResult> {
173
+ const v = this.config.resolve(target);
174
+ await this.run((m) => m.off(v));
175
+ return this.done('off', v);
176
+ }
177
+
178
+ /** color 0 (warm) .. 100 (cool), brightness 1..100 % */
179
+ async setLight(target: Target = 'all', color: number | string = 50, brightness: number | string = 100): Promise<ActionResult> {
180
+ const v = this.config.resolve(target);
181
+ const c = clamp(toInt(color, 'color'), 0, 100);
182
+ const b = clamp(toInt(brightness, 'brightness'), 1, 100);
183
+ await this.run((m) => m.setLight({ color: c, brightness: b, target: v }));
184
+ return this.done('light', v, { color: c, brightness: b });
185
+ }
186
+
187
+ async nightLight(target: Target = 'all', level: number | string = 0): Promise<ActionResult> {
188
+ const v = this.config.resolve(target);
189
+ const l = clamp(toInt(level, 'level'), 0, 2);
190
+ await this.run((m) => m.nightLight(l, v));
191
+ return this.done('night', v, { level: l });
192
+ }
193
+
194
+ /** action: "on", "off" or "light" (uses color / brightness) */
195
+ async group(groupId: number | string, action = 'on', color: number | string = 50, brightness: number | string = 100): Promise<ActionResult> {
196
+ const id = toInt(groupId, 'group id');
197
+ if (action === 'light') {
198
+ const c = clamp(toInt(color, 'color'), 0, 100);
199
+ const b = clamp(toInt(brightness, 'brightness'), 1, 100);
200
+ await this.run((m) => m.setGroup(id, { color: c, brightness: b }));
201
+ } else if (action === 'on' || action === 'off') {
202
+ await this.run((m) => m.setGroup(id, { on: action === 'on' }));
203
+ } else {
204
+ throw new InputError('action must be on, off or light');
205
+ }
206
+ return { ok: true, action: `group_${action}`, group: id };
207
+ }
208
+
209
+ async scene(index: number | string, save = false): Promise<ActionResult> {
210
+ const i = toInt(index, 'scene index');
211
+ await this.run((m) => (save ? m.saveScene(i) : m.playScene(i)));
212
+ return { ok: true, action: save ? 'scene_save' : 'scene_play', index: i };
213
+ }
214
+
215
+ async changeColor(target: Target): Promise<ActionResult> {
216
+ const v = this.config.resolve(target);
217
+ await this.run((m) => m.changeColor(v));
218
+ return this.done('change_color', v);
219
+ }
220
+
221
+ /** Lights answering a mesh ping, plus named lights that did not answer (online: false). */
222
+ async lights(timeout = 3): Promise<LightInfo[]> {
223
+ const devs = await this.run((m) => m.devices(timeout));
224
+ const out: LightInfo[] = [];
225
+ const seen = new Set<string>();
226
+ for (const d of [...devs].sort(byVaddr)) {
227
+ const hex = d.vaddr.toString('hex');
228
+ seen.add(hex);
229
+ out.push({ name: this.config.nameOf(d.vaddr), vaddr: hex, online: true, mac: d.mac, product: d.product, version: d.version });
230
+ }
231
+ for (const [name, v] of Object.entries(this.config.lights)) {
232
+ if (!seen.has(v)) out.push({ name, vaddr: v, online: false });
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** State of one light, or of every responding light for "all". */
238
+ async status(target: Target = 'all'): Promise<LightStatus | LightStatus[]> {
239
+ const v = this.config.resolve(target);
240
+ if (v.equals(P.BROADCAST)) {
241
+ const devs = await this.run((m) => m.devices());
242
+ const out: LightStatus[] = [];
243
+ for (const d of [...devs].sort(byVaddr)) out.push(await this.statusOf(d.vaddr));
244
+ return out;
245
+ }
246
+ return this.statusOf(v);
247
+ }
248
+
249
+ private async statusOf(v: Buffer): Promise<LightStatus> {
250
+ const st = await this.run((m) => m.status(v));
251
+ if (st === null) return { name: this.config.nameOf(v), vaddr: v.toString('hex'), online: false };
252
+ return { name: this.config.nameOf(v), online: true, ...st.toJSON() };
253
+ }
254
+
255
+ // ------------------------------------------------------------ names (no BLE needed)
256
+ nameLight(name: string, target: Target): { ok: true; name: string; vaddr: string } {
257
+ this.config.setName(name, target);
258
+ this.config.save();
259
+ return { ok: true, name, vaddr: this.config.lights[name] };
260
+ }
261
+
262
+ forgetName(name: string): { ok: true; removed: string } {
263
+ this.config.removeName(name);
264
+ this.config.save();
265
+ return { ok: true, removed: name };
266
+ }
267
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pairlink connected-mesh crypto.
3
+ *
4
+ * Reverse-engineered from libnative-lib.so in the ODELIC app (jp.co.odelic.smt.remote10):
5
+ *
6
+ * - key material = HomeID (4B, little endian) and password (4 ASCII bytes) interleaved
7
+ * -> h0 p0 h1 p1 h2 p2 h3 p3
8
+ * - login key = material + "LOGINKEY" (AES-128)
9
+ * - event key = material + "EVENTKEY" (AES-128)
10
+ * - login : node sends 02 20 <AES_LK(homeid | session4 | pad)>,
11
+ * we answer 01 16 03 <AES_LK(homeid | pwd | session4 | pad)>
12
+ * - data packets : 03 <dst4> <ch> <payload> -> 06 <dst4> <ch> XOR4(AES_EK(pkcs7(payload)), session4)
13
+ */
14
+ import { createCipheriv, createDecipheriv } from 'node:crypto';
15
+
16
+ import { InputError, LoginError } from './errors.ts';
17
+
18
+ /** AES-128-ECB without padding; `data` must be a multiple of 16 bytes. */
19
+ export function aes128(key: Uint8Array, data: Uint8Array, { decrypt = false }: { decrypt?: boolean } = {}): Buffer {
20
+ const c = decrypt ? createDecipheriv('aes-128-ecb', key, null) : createCipheriv('aes-128-ecb', key, null);
21
+ c.setAutoPadding(false);
22
+ return Buffer.concat([c.update(data), c.final()]);
23
+ }
24
+
25
+ export function pkcs7Pad(data: Uint8Array): Buffer {
26
+ const n = 16 - (data.length % 16);
27
+ return Buffer.concat([data, Buffer.alloc(n, n)]);
28
+ }
29
+
30
+ export function pkcs7Unpad(data: Buffer): Buffer {
31
+ const n = data.length ? data[data.length - 1] : 0;
32
+ if (n < 1 || n > 16 || n > data.length || !data.subarray(-n).every((b) => b === n)) {
33
+ throw new InputError('bad padding');
34
+ }
35
+ return data.subarray(0, data.length - n);
36
+ }
37
+
38
+ /** Keys for one mesh (HomeID + password) and the per-connection session value. */
39
+ export class MeshCrypto {
40
+ readonly homeid: Buffer;
41
+ readonly password: Buffer;
42
+ /** Per-connection value from the login challenge; null until logged in. */
43
+ session: Buffer | null = null;
44
+ private readonly loginKey: Buffer;
45
+ private readonly eventKey: Buffer;
46
+
47
+ /** @param password 4 ASCII characters */
48
+ constructor(homeid: number, password: string) {
49
+ if (!Number.isInteger(homeid) || homeid < 0 || homeid > 0xffffffff) {
50
+ throw new InputError(`HomeID must be a 32-bit unsigned integer (got ${homeid})`);
51
+ }
52
+ if (typeof password !== 'string' || !/^[\x00-\x7f]{4}$/.test(password)) {
53
+ throw new InputError('password must be 4 ASCII characters');
54
+ }
55
+ this.homeid = Buffer.alloc(4);
56
+ this.homeid.writeUInt32LE(homeid);
57
+ this.password = Buffer.from(password, 'ascii');
58
+ const mat = Buffer.alloc(8);
59
+ for (let i = 0; i < 4; i++) {
60
+ mat[2 * i] = this.homeid[i];
61
+ mat[2 * i + 1] = this.password[i];
62
+ }
63
+ this.loginKey = Buffer.concat([mat, Buffer.from('LOGINKEY')]);
64
+ this.eventKey = Buffer.concat([mat, Buffer.from('EVENTKEY')]);
65
+ }
66
+
67
+ /** `challenge` = the 16 encrypted bytes following `02 20`. Returns the login packet. */
68
+ answerChallenge(challenge: Uint8Array): Buffer {
69
+ const plain = aes128(this.loginKey, Buffer.from(challenge).subarray(0, 16), { decrypt: true });
70
+ if (!plain.subarray(0, 4).equals(this.homeid)) {
71
+ throw new LoginError(`node belongs to another HomeID (${plain.readUInt32LE(0)})`);
72
+ }
73
+ this.session = Buffer.from(plain.subarray(4, 8));
74
+ const body = aes128(this.loginKey, pkcs7Pad(Buffer.concat([this.homeid, this.password, this.session])));
75
+ return Buffer.concat([Buffer.from([0x01, 0x16, 0x03]), body]);
76
+ }
77
+
78
+ private xor(data: Buffer, key: Buffer): Buffer {
79
+ return Buffer.from(data.map((b, i) => b ^ key[i % 4]));
80
+ }
81
+
82
+ /** `03 <dst4> <ch> <payload>` -> `06 <dst4> <ch> <cipher>` */
83
+ encrypt(packet: Uint8Array): Buffer {
84
+ const plain = Buffer.from(packet);
85
+ if (this.session === null) throw new LoginError('not logged in');
86
+ if (plain.length < 7 || plain[0] !== 0x03) throw new InputError('expected a data-event packet (type 3)');
87
+ const cipher = aes128(this.eventKey, pkcs7Pad(plain.subarray(6)));
88
+ return Buffer.concat([Buffer.from([0x06]), plain.subarray(1, 6), this.xor(cipher, this.session)]);
89
+ }
90
+
91
+ /** `06 <src/dst4> <ch> <cipher>` -> `03 <...> <payload>`; other packet types pass through. */
92
+ decrypt(data: Uint8Array): Buffer {
93
+ const packet = Buffer.from(data);
94
+ if (!packet.length || packet[0] !== 0x06 || this.session === null) return packet;
95
+ let body = this.xor(packet.subarray(6), this.session);
96
+ body = body.subarray(0, body.length - (body.length % 16));
97
+ const plain = pkcs7Unpad(aes128(this.eventKey, body, { decrypt: true }));
98
+ return Buffer.concat([Buffer.from([0x03]), packet.subarray(1, 6), plain]);
99
+ }
100
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Error types. `InputError` = bad user input (HTTP 400); the others come from the mesh / BLE side. */
2
+
3
+ export class OdelicError extends Error {
4
+ constructor(message?: string, options?: ErrorOptions) {
5
+ super(message, options);
6
+ this.name = new.target.name;
7
+ }
8
+ }
9
+
10
+ /** Invalid argument: unknown light name, malformed vaddr / ID, out-of-range number, ... */
11
+ export class InputError extends OdelicError {}
12
+
13
+ /** The node rejected the login (wrong HomeID / password, or another controller is connected). */
14
+ export class LoginError extends OdelicError {}
15
+
16
+ /** No mesh node found, or the BLE link could not be established. */
17
+ export class ConnectionError extends OdelicError {}
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Unofficial Node.js library for ODELIC Bluetooth lights (Pairlink connected-mesh). */
2
+ export { OdelicMesh, findNodes } from './client.ts';
3
+ export type { MeshNode, OdelicMeshOptions, PacketListener, SetGroupOptions, SetLightOptions } from './client.ts';
4
+ export { Config, configPath, parseIdle } from './config.ts';
5
+ export type { ConfigData } from './config.ts';
6
+ export { Controller } from './controller.ts';
7
+ export type { ActionResult, ControllerInfo, ControllerOptions, LightInfo, LightStatus, Target } from './controller.ts';
8
+ export { MeshCrypto } from './crypto.ts';
9
+ export { ConnectionError, InputError, LoginError, OdelicError } from './errors.ts';
10
+ export { setLogLevel } from './log.ts';
11
+ export type { LogLevel } from './log.ts';
12
+ export { BROADCAST, Device, LightState, parseAppId } from './protocol.ts';
13
+ export type { LightStateJSON, VaddrLike } from './protocol.ts';
14
+ export * as protocol from './protocol.ts';
15
+ export { serve } from './server.ts';
16
+ export type { ServeOptions } from './server.ts';
17
+ export { VERSION } from './version.ts';
package/src/log.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** Minimal leveled logger writing to stderr (stdout stays clean for the CLI output and MCP stdio). */
2
+
3
+ const LEVELS = { debug: 10, info: 20, warning: 30, error: 40, silent: 100 } as const;
4
+ export type LogLevel = keyof typeof LEVELS;
5
+
6
+ const isLevel = (v: string): v is LogLevel => Object.hasOwn(LEVELS, v);
7
+ const fromEnv = String(process.env.ODELIC_LOG ?? '').toLowerCase();
8
+ let current: number = isLevel(fromEnv) ? LEVELS[fromEnv] : LEVELS.warning;
9
+
10
+ export function setLogLevel(level: LogLevel): void {
11
+ if (!isLevel(level)) throw new RangeError(`unknown log level ${level}`);
12
+ current = LEVELS[level];
13
+ }
14
+
15
+ function emit(level: LogLevel, args: unknown[]): void {
16
+ if (LEVELS[level] >= current) console.error('odelic:', ...args);
17
+ }
18
+
19
+ export const log = {
20
+ debug: (...a: unknown[]) => emit('debug', a),
21
+ info: (...a: unknown[]) => emit('info', a),
22
+ warning: (...a: unknown[]) => emit('warning', a),
23
+ error: (...a: unknown[]) => emit('error', a),
24
+ };
package/src/mcp.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * MCP server exposing ODELIC lights to AI assistants (stdio).
3
+ *
4
+ * npm install odelic @modelcontextprotocol/sdk zod
5
+ * odelic-mcp # reads the ID from ODELIC_ID or the config file (`odelic setup --id ...`)
6
+ *
7
+ * The BLE link is opened on the first command. By default it is released after 60 s without
8
+ * commands so the official phone app can still connect; set ODELIC_IDLE_TIMEOUT (seconds, 0 = stay
9
+ * connected) or `odelic setup --keep-connected` to change that.
10
+ */
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
14
+ import { z } from 'zod';
15
+
16
+ import { Controller } from './controller.ts';
17
+ import { VERSION } from './version.ts';
18
+
19
+ export const INSTRUCTIONS = `Controls ODELIC Bluetooth ceiling/room lights in the user's home.
20
+ Targets: a light name (see list_lights), or "all" for every light.
21
+ Colour is 0 = warm/電球色 (orange) ... 100 = cool/昼光色 (bluish white); brightness is 1..100 %.
22
+ Commands are fire-and-forget; use light_status to confirm the result if needed.`;
23
+
24
+ /** The part of Controller the MCP tools use. */
25
+ export type McpController = Pick<Controller,
26
+ 'lights' | 'status' | 'on' | 'off' | 'setLight' | 'nightLight' | 'group' | 'scene' | 'nameLight' | 'forgetName'>;
27
+
28
+ const READ_ONLY: ToolAnnotations = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
29
+ const ACTION: ToolAnnotations = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
30
+ const DESTRUCTIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
31
+
32
+ const target = z.string().default('all').describe('light name, vaddr, or "all"');
33
+ const color = z.number().int().min(0).max(100).default(50).describe('0 = warm/電球色 .. 50 = neutral .. 100 = cool/昼光色');
34
+ const brightness = z.number().int().min(1).max(100).default(100).describe('1..100 (%)');
35
+ const byte = z.number().int().min(0).max(255);
36
+
37
+ const result = (value: unknown): CallToolResult => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
38
+
39
+ /**
40
+ * Build the MCP server.
41
+ * @param getController called lazily on the first tool call
42
+ */
43
+ export function createMcpServer(getController: () => McpController = lazyController()): McpServer {
44
+ const server = new McpServer({ name: 'odelic', version: VERSION }, { instructions: INSTRUCTIONS });
45
+ const ctl = () => getController();
46
+
47
+ function tool<S extends z.ZodRawShape>(name: string, description: string, annotations: ToolAnnotations, inputSchema: S,
48
+ fn: (args: z.infer<z.ZodObject<S>>) => unknown): void {
49
+ const handler = async (args: z.infer<z.ZodObject<S>>): Promise<CallToolResult> => {
50
+ try {
51
+ return result(await fn(args));
52
+ } catch (e) {
53
+ const err = e as Error;
54
+ return { ...result({ ok: false, error: `${err.name}: ${err.message}` }), isError: true };
55
+ }
56
+ };
57
+ // the SDK's registerTool generics do not narrow well through this helper
58
+ (server.registerTool as (n: string, c: object, h: typeof handler) => unknown)(name, { description, annotations, inputSchema }, handler);
59
+ }
60
+
61
+ tool('list_lights', 'List the lights in the mesh (name, vaddr, online, product). Takes a few seconds.',
62
+ READ_ONLY, {}, () => ctl().lights());
63
+ tool('light_status', 'Current state of a light: on/off, colour (0 warm..100 cool) and brightness (%).',
64
+ READ_ONLY, { target }, async (a) => {
65
+ const res = await ctl().status(a.target);
66
+ return Array.isArray(res) ? { lights: res } : res;
67
+ });
68
+ tool('turn_on', 'Turn a light (or "all") on with its previous colour and brightness.',
69
+ ACTION, { target }, (a) => ctl().on(a.target));
70
+ tool('turn_off', 'Turn a light (or "all") off.', ACTION, { target }, (a) => ctl().off(a.target));
71
+ tool('set_light', 'Turn on with a given colour and brightness.',
72
+ ACTION, { target, color, brightness }, (a) => ctl().setLight(a.target, a.color, a.brightness));
73
+ tool('night_light', 'Very dim warm night light. level 0..2.',
74
+ ACTION, { target, level: z.number().int().min(0).max(2).default(0) }, (a) => ctl().nightLight(a.target, a.level));
75
+ tool('control_group', 'Control a group configured in the ODELIC app. action: "on", "off" or "light" (uses color/brightness).',
76
+ ACTION, { group_id: byte, action: z.enum(['on', 'off', 'light']).default('on'), color, brightness },
77
+ (a) => ctl().group(a.group_id, a.action, a.color, a.brightness));
78
+ tool('play_scene', 'Recall a scene saved in the lights (index as in the app).',
79
+ ACTION, { index: byte }, (a) => ctl().scene(a.index, false));
80
+ tool('save_scene', 'Store the current state of all lights as scene `index` (overwrites that scene).',
81
+ DESTRUCTIVE, { index: byte }, (a) => ctl().scene(a.index, true));
82
+ tool('name_light', 'Give a light (vaddr from list_lights) a friendly name such as "living" or "リビング".',
83
+ { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, { name: z.string().min(1), vaddr: z.string() },
84
+ (a) => ctl().nameLight(a.name, a.vaddr));
85
+ tool('forget_light_name', 'Remove a light name from the config.',
86
+ DESTRUCTIVE, { name: z.string() }, (a) => ctl().forgetName(a.name));
87
+ return server;
88
+ }
89
+
90
+ function lazyController(): () => Controller {
91
+ let c: Controller | null = null;
92
+ // idle timeout from $ODELIC_IDLE_TIMEOUT / config (0 = stay connected)
93
+ return () => (c ??= new Controller());
94
+ }
95
+
96
+ /** Run the MCP server on stdio (logs go to stderr). */
97
+ export async function main(): Promise<void> {
98
+ const server = createMcpServer(lazyController());
99
+ await server.connect(new StdioServerTransport());
100
+ }