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.
- package/README.md +93 -91
- package/bin/odelic-mcp.js +1 -1
- package/bin/odelic.js +1 -1
- package/dist/cli.d.ts +58 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +279 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +448 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +139 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +95 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +239 -0
- package/dist/controller.js.map +1 -0
- package/dist/crypto.d.ts +34 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +111 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/{src → dist}/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +21 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp.d.ts +23 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +74 -0
- package/dist/mcp.js.map +1 -0
- package/dist/protocol.d.ts +90 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +178 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +187 -0
- package/dist/server.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +19 -7
- package/src/{cli.js → cli.ts} +64 -50
- package/src/client.ts +497 -0
- package/src/{config.js → config.ts} +37 -23
- package/src/controller.ts +294 -0
- package/src/{crypto.js → crypto.ts} +46 -28
- package/src/{errors.js → errors.ts} +2 -2
- package/src/index.ts +17 -0
- package/src/log.ts +24 -0
- package/src/{mcp.js → mcp.ts} +31 -20
- package/src/{protocol.js → protocol.ts} +56 -30
- package/src/{server.js → server.ts} +57 -38
- package/src/version.ts +1 -0
- package/src/client.js +0 -362
- package/src/controller.js +0 -219
- package/src/log.js +0 -20
- package/src/version.js +0 -1
|
@@ -0,0 +1,294 @@
|
|
|
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) {
|
|
100
|
+
log.info(`connecting via ${this.mesh.device ?? 'strongest node'}`);
|
|
101
|
+
await this.mesh.connect();
|
|
102
|
+
} else if (this.mesh.suspect) {
|
|
103
|
+
log.info('session looks stale, resynchronising');
|
|
104
|
+
try {
|
|
105
|
+
await this.mesh.resync();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
log.warning(`resync failed (${(e as Error).message}), reconnecting`);
|
|
108
|
+
await this.mesh.disconnect();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let res: T;
|
|
113
|
+
try {
|
|
114
|
+
res = await fn(this.mesh);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
// input / login errors are final; anything else may be a dropped link
|
|
117
|
+
if (attempt || e instanceof InputError || e instanceof LoginError) throw e;
|
|
118
|
+
log.warning(`request failed (${(e as Error).message}), reconnecting`);
|
|
119
|
+
await this.mesh.disconnect();
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
// status() resolves to null on timeout: the session may have
|
|
123
|
+
// switched under us (another controller logged in), so make sure
|
|
124
|
+
// the link is live and retry once
|
|
125
|
+
if (res === null && !attempt) {
|
|
126
|
+
log.info('no reply, ensuring the session is live and retrying');
|
|
127
|
+
try {
|
|
128
|
+
await this.mesh.ensureLive();
|
|
129
|
+
} catch (e) {
|
|
130
|
+
log.warning(`ensureLive failed (${(e as Error).message})`);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
return res;
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
this.armIdle();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private cancelIdle(): void {
|
|
143
|
+
if (this.idle) clearTimeout(this.idle);
|
|
144
|
+
this.idle = null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private armIdle(): void {
|
|
148
|
+
if (!this.idleTimeout || !this.mesh.isConnected) return;
|
|
149
|
+
this.idle = setTimeout(() => {
|
|
150
|
+
this.locked(async () => {
|
|
151
|
+
this.idle = null;
|
|
152
|
+
if (this.mesh.isConnected) {
|
|
153
|
+
log.info('idle, disconnecting');
|
|
154
|
+
await this.mesh.disconnect();
|
|
155
|
+
}
|
|
156
|
+
}).catch((e: Error) => log.warning(`idle disconnect failed: ${e.message}`));
|
|
157
|
+
}, this.idleTimeout * 1000);
|
|
158
|
+
this.idle.unref();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Connect now (otherwise the first command connects). */
|
|
162
|
+
async connect(): Promise<void> {
|
|
163
|
+
await this.run(async () => {});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async close(): Promise<void> {
|
|
167
|
+
this.cancelIdle();
|
|
168
|
+
await this.locked(() => this.mesh.disconnect());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get connected(): boolean {
|
|
172
|
+
return this.mesh.isConnected;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get keepConnected(): boolean {
|
|
176
|
+
return !this.idleTimeout;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
info(): ControllerInfo {
|
|
180
|
+
return { connected: this.connected, node: this.mesh.nodeName, mode: this.keepConnected ? 'keep' : 'idle', idle_timeout: this.idleTimeout };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ------------------------------------------------------------ helpers
|
|
184
|
+
private label(v: Buffer): string {
|
|
185
|
+
return v.equals(P.BROADCAST) ? 'all' : (this.config.nameOf(v) ?? v.toString('hex'));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private done(action: string, v: Buffer, extra: Partial<ActionResult> = {}): ActionResult {
|
|
189
|
+
return { ok: true, action, target: this.label(v), ...extra };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ------------------------------------------------------------ actions
|
|
193
|
+
async on(target: Target = 'all'): Promise<ActionResult> {
|
|
194
|
+
const v = this.config.resolve(target);
|
|
195
|
+
await this.run((m) => m.on(v));
|
|
196
|
+
return this.done('on', v);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async off(target: Target = 'all'): Promise<ActionResult> {
|
|
200
|
+
const v = this.config.resolve(target);
|
|
201
|
+
await this.run((m) => m.off(v));
|
|
202
|
+
return this.done('off', v);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** color 0 (warm) .. 100 (cool), brightness 1..100 % */
|
|
206
|
+
async setLight(target: Target = 'all', color: number | string = 50, brightness: number | string = 100): Promise<ActionResult> {
|
|
207
|
+
const v = this.config.resolve(target);
|
|
208
|
+
const c = clamp(toInt(color, 'color'), 0, 100);
|
|
209
|
+
const b = clamp(toInt(brightness, 'brightness'), 1, 100);
|
|
210
|
+
await this.run((m) => m.setLight({ color: c, brightness: b, target: v }));
|
|
211
|
+
return this.done('light', v, { color: c, brightness: b });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async nightLight(target: Target = 'all', level: number | string = 0): Promise<ActionResult> {
|
|
215
|
+
const v = this.config.resolve(target);
|
|
216
|
+
const l = clamp(toInt(level, 'level'), 0, 2);
|
|
217
|
+
await this.run((m) => m.nightLight(l, v));
|
|
218
|
+
return this.done('night', v, { level: l });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** action: "on", "off" or "light" (uses color / brightness) */
|
|
222
|
+
async group(groupId: number | string, action = 'on', color: number | string = 50, brightness: number | string = 100): Promise<ActionResult> {
|
|
223
|
+
const id = toInt(groupId, 'group id');
|
|
224
|
+
if (action === 'light') {
|
|
225
|
+
const c = clamp(toInt(color, 'color'), 0, 100);
|
|
226
|
+
const b = clamp(toInt(brightness, 'brightness'), 1, 100);
|
|
227
|
+
await this.run((m) => m.setGroup(id, { color: c, brightness: b }));
|
|
228
|
+
} else if (action === 'on' || action === 'off') {
|
|
229
|
+
await this.run((m) => m.setGroup(id, { on: action === 'on' }));
|
|
230
|
+
} else {
|
|
231
|
+
throw new InputError('action must be on, off or light');
|
|
232
|
+
}
|
|
233
|
+
return { ok: true, action: `group_${action}`, group: id };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async scene(index: number | string, save = false): Promise<ActionResult> {
|
|
237
|
+
const i = toInt(index, 'scene index');
|
|
238
|
+
await this.run((m) => (save ? m.saveScene(i) : m.playScene(i)));
|
|
239
|
+
return { ok: true, action: save ? 'scene_save' : 'scene_play', index: i };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async changeColor(target: Target): Promise<ActionResult> {
|
|
243
|
+
const v = this.config.resolve(target);
|
|
244
|
+
await this.run((m) => m.changeColor(v));
|
|
245
|
+
return this.done('change_color', v);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Lights answering a mesh ping, plus named lights that did not answer (online: false). */
|
|
249
|
+
async lights(timeout = 3): Promise<LightInfo[]> {
|
|
250
|
+
const devs = await this.run((m) => m.devices(timeout));
|
|
251
|
+
const out: LightInfo[] = [];
|
|
252
|
+
const seen = new Set<string>();
|
|
253
|
+
for (const d of [...devs].sort(byVaddr)) {
|
|
254
|
+
const hex = d.vaddr.toString('hex');
|
|
255
|
+
seen.add(hex);
|
|
256
|
+
out.push({ name: this.config.nameOf(d.vaddr), vaddr: hex, online: true, mac: d.mac, product: d.product, version: d.version });
|
|
257
|
+
}
|
|
258
|
+
for (const [name, v] of Object.entries(this.config.lights)) {
|
|
259
|
+
if (!seen.has(v)) out.push({ name, vaddr: v, online: false });
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** State of one light, or of every responding light for "all". */
|
|
265
|
+
async status(target: Target = 'all'): Promise<LightStatus | LightStatus[]> {
|
|
266
|
+
const v = this.config.resolve(target);
|
|
267
|
+
if (v.equals(P.BROADCAST)) {
|
|
268
|
+
const devs = await this.run((m) => m.devices());
|
|
269
|
+
const out: LightStatus[] = [];
|
|
270
|
+
for (const d of [...devs].sort(byVaddr)) out.push(await this.statusOf(d.vaddr));
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
return this.statusOf(v);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async statusOf(v: Buffer): Promise<LightStatus> {
|
|
277
|
+
const st = await this.run((m) => m.status(v));
|
|
278
|
+
if (st === null) return { name: this.config.nameOf(v), vaddr: v.toString('hex'), online: false };
|
|
279
|
+
return { name: this.config.nameOf(v), online: true, ...st.toJSON() };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ------------------------------------------------------------ names (no BLE needed)
|
|
283
|
+
nameLight(name: string, target: Target): { ok: true; name: string; vaddr: string } {
|
|
284
|
+
this.config.setName(name, target);
|
|
285
|
+
this.config.save();
|
|
286
|
+
return { ok: true, name, vaddr: this.config.lights[name] };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
forgetName(name: string): { ok: true; removed: string } {
|
|
290
|
+
this.config.removeName(name);
|
|
291
|
+
this.config.save();
|
|
292
|
+
return { ok: true, removed: name };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -13,21 +13,21 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
|
15
15
|
|
|
16
|
-
import { InputError, LoginError } from './errors.
|
|
16
|
+
import { InputError, LoginError } from './errors.ts';
|
|
17
17
|
|
|
18
18
|
/** AES-128-ECB without padding; `data` must be a multiple of 16 bytes. */
|
|
19
|
-
export function aes128(key, data, { decrypt = false } = {}) {
|
|
20
|
-
const c =
|
|
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
21
|
c.setAutoPadding(false);
|
|
22
22
|
return Buffer.concat([c.update(data), c.final()]);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export function pkcs7Pad(data) {
|
|
25
|
+
export function pkcs7Pad(data: Uint8Array): Buffer {
|
|
26
26
|
const n = 16 - (data.length % 16);
|
|
27
27
|
return Buffer.concat([data, Buffer.alloc(n, n)]);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export function pkcs7Unpad(data) {
|
|
30
|
+
export function pkcs7Unpad(data: Buffer): Buffer {
|
|
31
31
|
const n = data.length ? data[data.length - 1] : 0;
|
|
32
32
|
if (n < 1 || n > 16 || n > data.length || !data.subarray(-n).every((b) => b === n)) {
|
|
33
33
|
throw new InputError('bad padding');
|
|
@@ -37,11 +37,15 @@ export function pkcs7Unpad(data) {
|
|
|
37
37
|
|
|
38
38
|
/** Keys for one mesh (HomeID + password) and the per-connection session value. */
|
|
39
39
|
export class MeshCrypto {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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) {
|
|
45
49
|
if (!Number.isInteger(homeid) || homeid < 0 || homeid > 0xffffffff) {
|
|
46
50
|
throw new InputError(`HomeID must be a 32-bit unsigned integer (got ${homeid})`);
|
|
47
51
|
}
|
|
@@ -56,44 +60,58 @@ export class MeshCrypto {
|
|
|
56
60
|
mat[2 * i] = this.homeid[i];
|
|
57
61
|
mat[2 * i + 1] = this.password[i];
|
|
58
62
|
}
|
|
59
|
-
this.
|
|
60
|
-
this.
|
|
61
|
-
/** @type {Buffer|null} */
|
|
62
|
-
this.session = null;
|
|
63
|
+
this.loginKey = Buffer.concat([mat, Buffer.from('LOGINKEY')]);
|
|
64
|
+
this.eventKey = Buffer.concat([mat, Buffer.from('EVENTKEY')]);
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/** `challenge` = the 16 encrypted bytes following `02 20`. Returns the login packet. */
|
|
66
|
-
answerChallenge(challenge) {
|
|
67
|
-
const plain = aes128(this.
|
|
68
|
+
answerChallenge(challenge: Uint8Array): Buffer {
|
|
69
|
+
const plain = aes128(this.loginKey, Buffer.from(challenge).subarray(0, 16), { decrypt: true });
|
|
68
70
|
if (!plain.subarray(0, 4).equals(this.homeid)) {
|
|
69
71
|
throw new LoginError(`node belongs to another HomeID (${plain.readUInt32LE(0)})`);
|
|
70
72
|
}
|
|
71
73
|
this.session = Buffer.from(plain.subarray(4, 8));
|
|
72
|
-
const body = aes128(this.
|
|
74
|
+
const body = aes128(this.loginKey, pkcs7Pad(Buffer.concat([this.homeid, this.password, this.session])));
|
|
73
75
|
return Buffer.concat([Buffer.from([0x01, 0x16, 0x03]), body]);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Follow a login challenge we did not ask for.
|
|
80
|
+
*
|
|
81
|
+
* The node switches to the session value of every new challenge, even when that login is
|
|
82
|
+
* later refused with status 08. On macOS all processes share one BLE link per peripheral,
|
|
83
|
+
* so another program logging in through the same link silently invalidates our session
|
|
84
|
+
* unless we switch too. Returns true if the session changed.
|
|
85
|
+
*/
|
|
86
|
+
adoptChallenge(challenge: Uint8Array): boolean {
|
|
87
|
+
const plain = aes128(this.loginKey, Buffer.from(challenge).subarray(0, 16), { decrypt: true });
|
|
88
|
+
if (!plain.subarray(0, 4).equals(this.homeid)) return false;
|
|
89
|
+
const next = Buffer.from(plain.subarray(4, 8));
|
|
90
|
+
const changed = this.session === null || !next.equals(this.session);
|
|
91
|
+
this.session = next;
|
|
92
|
+
return changed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private xor(data: Buffer, key: Buffer): Buffer {
|
|
96
|
+
return Buffer.from(data.map((b, i) => b ^ key[i % 4]));
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
/** `03 <dst4> <ch> <payload>` -> `06 <dst4> <ch> <cipher>` */
|
|
82
|
-
encrypt(
|
|
83
|
-
plain = Buffer.from(
|
|
100
|
+
encrypt(packet: Uint8Array): Buffer {
|
|
101
|
+
const plain = Buffer.from(packet);
|
|
84
102
|
if (this.session === null) throw new LoginError('not logged in');
|
|
85
103
|
if (plain.length < 7 || plain[0] !== 0x03) throw new InputError('expected a data-event packet (type 3)');
|
|
86
|
-
const cipher = aes128(this.
|
|
87
|
-
return Buffer.concat([Buffer.from([0x06]), plain.subarray(1, 6), this.
|
|
104
|
+
const cipher = aes128(this.eventKey, pkcs7Pad(plain.subarray(6)));
|
|
105
|
+
return Buffer.concat([Buffer.from([0x06]), plain.subarray(1, 6), this.xor(cipher, this.session)]);
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
/** `06 <src/dst4> <ch> <cipher>` -> `03 <...> <payload>`; other packet types pass through. */
|
|
91
|
-
decrypt(
|
|
92
|
-
packet = Buffer.from(
|
|
109
|
+
decrypt(data: Uint8Array): Buffer {
|
|
110
|
+
const packet = Buffer.from(data);
|
|
93
111
|
if (!packet.length || packet[0] !== 0x06 || this.session === null) return packet;
|
|
94
|
-
let body = this.
|
|
112
|
+
let body = this.xor(packet.subarray(6), this.session);
|
|
95
113
|
body = body.subarray(0, body.length - (body.length % 16));
|
|
96
|
-
const plain = pkcs7Unpad(aes128(this.
|
|
114
|
+
const plain = pkcs7Unpad(aes128(this.eventKey, body, { decrypt: true }));
|
|
97
115
|
return Buffer.concat([Buffer.from([0x03]), packet.subarray(1, 6), plain]);
|
|
98
116
|
}
|
|
99
117
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/** Error types. `InputError` = bad user input (HTTP 400); the others come from the mesh / BLE side. */
|
|
2
2
|
|
|
3
3
|
export class OdelicError extends Error {
|
|
4
|
-
constructor(message, options) {
|
|
4
|
+
constructor(message?: string, options?: ErrorOptions) {
|
|
5
5
|
super(message, options);
|
|
6
|
-
this.name =
|
|
6
|
+
this.name = new.target.name;
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
|
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.js → mcp.ts}
RENAMED
|
@@ -10,41 +10,53 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
12
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
13
|
+
import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
|
|
13
14
|
import { z } from 'zod';
|
|
14
15
|
|
|
15
|
-
import { Controller } from './controller.
|
|
16
|
-
import { VERSION } from './version.
|
|
16
|
+
import { Controller } from './controller.ts';
|
|
17
|
+
import { VERSION } from './version.ts';
|
|
17
18
|
|
|
18
19
|
export const INSTRUCTIONS = `Controls ODELIC Bluetooth ceiling/room lights in the user's home.
|
|
19
20
|
Targets: a light name (see list_lights), or "all" for every light.
|
|
20
21
|
Colour is 0 = warm/電球色 (orange) ... 100 = cool/昼光色 (bluish white); brightness is 1..100 %.
|
|
21
22
|
Commands are fire-and-forget; use light_status to confirm the result if needed.`;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 };
|
|
26
31
|
|
|
27
32
|
const target = z.string().default('all').describe('light name, vaddr, or "all"');
|
|
28
33
|
const color = z.number().int().min(0).max(100).default(50).describe('0 = warm/電球色 .. 50 = neutral .. 100 = cool/昼光色');
|
|
29
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);
|
|
30
36
|
|
|
31
|
-
const result = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
|
|
37
|
+
const result = (value: unknown): CallToolResult => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
|
|
32
38
|
|
|
33
39
|
/**
|
|
34
40
|
* Build the MCP server.
|
|
35
|
-
* @param
|
|
41
|
+
* @param getController called lazily on the first tool call
|
|
36
42
|
*/
|
|
37
|
-
export function createMcpServer(getController = lazyController()) {
|
|
43
|
+
export function createMcpServer(getController: () => McpController = lazyController()): McpServer {
|
|
38
44
|
const server = new McpServer({ name: 'odelic', version: VERSION }, { instructions: INSTRUCTIONS });
|
|
39
45
|
const ctl = () => getController();
|
|
40
|
-
|
|
41
|
-
|
|
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> => {
|
|
42
50
|
try {
|
|
43
51
|
return result(await fn(args));
|
|
44
52
|
} catch (e) {
|
|
45
|
-
|
|
53
|
+
const err = e as Error;
|
|
54
|
+
return { ...result({ ok: false, error: `${err.name}: ${err.message}` }), isError: true };
|
|
46
55
|
}
|
|
47
|
-
}
|
|
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
|
+
}
|
|
48
60
|
|
|
49
61
|
tool('list_lights', 'List the lights in the mesh (name, vaddr, online, product). Takes a few seconds.',
|
|
50
62
|
READ_ONLY, {}, () => ctl().lights());
|
|
@@ -61,12 +73,12 @@ export function createMcpServer(getController = lazyController()) {
|
|
|
61
73
|
tool('night_light', 'Very dim warm night light. level 0..2.',
|
|
62
74
|
ACTION, { target, level: z.number().int().min(0).max(2).default(0) }, (a) => ctl().nightLight(a.target, a.level));
|
|
63
75
|
tool('control_group', 'Control a group configured in the ODELIC app. action: "on", "off" or "light" (uses color/brightness).',
|
|
64
|
-
ACTION, { group_id:
|
|
76
|
+
ACTION, { group_id: byte, action: z.enum(['on', 'off', 'light']).default('on'), color, brightness },
|
|
65
77
|
(a) => ctl().group(a.group_id, a.action, a.color, a.brightness));
|
|
66
78
|
tool('play_scene', 'Recall a scene saved in the lights (index as in the app).',
|
|
67
|
-
ACTION, { index:
|
|
79
|
+
ACTION, { index: byte }, (a) => ctl().scene(a.index, false));
|
|
68
80
|
tool('save_scene', 'Store the current state of all lights as scene `index` (overwrites that scene).',
|
|
69
|
-
DESTRUCTIVE, { index:
|
|
81
|
+
DESTRUCTIVE, { index: byte }, (a) => ctl().scene(a.index, true));
|
|
70
82
|
tool('name_light', 'Give a light (vaddr from list_lights) a friendly name such as "living" or "リビング".',
|
|
71
83
|
{ readOnlyHint: false, destructiveHint: false, openWorldHint: false }, { name: z.string().min(1), vaddr: z.string() },
|
|
72
84
|
(a) => ctl().nameLight(a.name, a.vaddr));
|
|
@@ -75,15 +87,14 @@ export function createMcpServer(getController = lazyController()) {
|
|
|
75
87
|
return server;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
function lazyController() {
|
|
79
|
-
let c = null;
|
|
90
|
+
function lazyController(): () => Controller {
|
|
91
|
+
let c: Controller | null = null;
|
|
80
92
|
// idle timeout from $ODELIC_IDLE_TIMEOUT / config (0 = stay connected)
|
|
81
93
|
return () => (c ??= new Controller());
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
/** Run the MCP server on stdio (logs go to stderr). */
|
|
85
|
-
export async function main() {
|
|
86
|
-
const
|
|
87
|
-
const server = createMcpServer(getController);
|
|
97
|
+
export async function main(): Promise<void> {
|
|
98
|
+
const server = createMcpServer(lazyController());
|
|
88
99
|
await server.connect(new StdioServerTransport());
|
|
89
100
|
}
|