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/src/client.js DELETED
@@ -1,362 +0,0 @@
1
- /** BLE client for ODELIC lights (@stoprocent/noble). */
2
- import { setTimeout as sleep } from 'node:timers/promises';
3
-
4
- import { ConnectionError, InputError, LoginError } from './errors.js';
5
- import { log } from './log.js';
6
- import * as P from './protocol.js';
7
- import { MeshCrypto } from './crypto.js';
8
-
9
- export const UUID_SERVICE = '9e5d1e475c1343a0863582adffc0386f';
10
- export const UUID_WRITE = '9e5d1e475c1343a0863582adffc1386f';
11
- export const UUID_NOTIFY = '9e5d1e475c1343a0863582adffc2386f';
12
-
13
- let noblePromise = null;
14
-
15
- /** The noble instance, loaded on first use so that importing odelic never touches Bluetooth. */
16
- export function getNoble() {
17
- noblePromise ??= import('@stoprocent/noble').then((m) => m.default ?? m);
18
- return noblePromise;
19
- }
20
-
21
- async function poweredOn(noble, timeout = 10) {
22
- try {
23
- await noble.waitForPoweredOnAsync(timeout * 1000);
24
- } catch (e) {
25
- throw new ConnectionError(`Bluetooth is not available (state: ${noble.state}); ` +
26
- 'turn Bluetooth on and allow this terminal to use it', { cause: e });
27
- }
28
- }
29
-
30
- function withTimeout(promise, seconds, message) {
31
- let timer;
32
- const t = new Promise((_, reject) => {
33
- timer = setTimeout(() => reject(new ConnectionError(message)), seconds * 1000);
34
- });
35
- return Promise.race([promise, t]).finally(() => clearTimeout(timer));
36
- }
37
-
38
- const norm = (s) => String(s ?? '').toLowerCase().replace(/[-:]/g, '');
39
-
40
- /**
41
- * Scan for mesh nodes.
42
- * @param {{homeid?: number|null, timeout?: number}} [opts] timeout in seconds
43
- * @returns {Promise<Array<{peripheral: any, name: string|null, address: string, homeid: number, rssi: number}>>}
44
- * sorted by signal strength (strongest first)
45
- */
46
- export async function findNodes({ homeid = null, timeout = 8 } = {}) {
47
- const noble = await getNoble();
48
- await poweredOn(noble);
49
- const found = new Map();
50
- const onDiscover = (p) => {
51
- const hid = P.advHomeid(p.advertisement?.manufacturerData);
52
- if (hid === null || (homeid !== null && hid !== homeid)) return;
53
- found.set(p.id, {
54
- peripheral: p,
55
- name: p.advertisement?.localName || null,
56
- address: p.address && p.address !== 'unknown' ? p.address : p.id,
57
- homeid: hid,
58
- rssi: p.rssi,
59
- });
60
- };
61
- noble.on('discover', onDiscover);
62
- try {
63
- await noble.startScanningAsync([], true);
64
- await sleep(timeout * 1000);
65
- } finally {
66
- noble.removeListener('discover', onDiscover);
67
- await noble.stopScanningAsync().catch(() => {});
68
- }
69
- return [...found.values()].sort((a, b) => b.rssi - a.rssi);
70
- }
71
-
72
- /** Tiny async FIFO used while logging in. */
73
- class Inbox {
74
- constructor() {
75
- this.items = [];
76
- this.waiters = [];
77
- }
78
-
79
- push(v) {
80
- const w = this.waiters.shift();
81
- if (w) w(v);
82
- else this.items.push(v);
83
- }
84
-
85
- next() {
86
- if (this.items.length) return Promise.resolve(this.items.shift());
87
- return new Promise((resolve) => this.waiters.push(resolve));
88
- }
89
- }
90
-
91
- /**
92
- * One logged-in connection to an ODELIC mesh.
93
- *
94
- * const mesh = new OdelicMesh('12345678');
95
- * await mesh.connect();
96
- * await mesh.setLight({ color: 0, brightness: 100 }); // warm, full
97
- * await mesh.off();
98
- * await mesh.disconnect();
99
- */
100
- export class OdelicMesh {
101
- /**
102
- * @param {string|null} appId ID shown in the app, e.g. "12345678" (or pass homeid + password)
103
- * @param {{homeid?: number, password?: string, device?: any, scanTimeout?: number}} [opts]
104
- * device: noble Peripheral, or a node name / address / id; default = strongest node of the mesh
105
- */
106
- constructor(appId = null, { homeid, password, device = null, scanTimeout = 8 } = {}) {
107
- if (appId !== null && appId !== undefined) ({ homeid, password } = P.parseAppId(appId));
108
- if (homeid === undefined || homeid === null || password === undefined || password === null) {
109
- throw new InputError("give appId (e.g. '12345678') or homeid + password");
110
- }
111
- this.homeid = homeid;
112
- this.crypto = new MeshCrypto(homeid, password);
113
- this.device = device;
114
- this.scanTimeout = scanTimeout;
115
- /** our vaddr in the mesh (assigned at login) */
116
- this.own = null;
117
- this.nodeName = null;
118
- this._peripheral = null;
119
- this._write = null;
120
- this._notify = null;
121
- this._inbox = null;
122
- this._seg = [];
123
- this._segSeq = 0;
124
- this._listeners = new Set();
125
- this._onNotify = this._onNotify.bind(this);
126
- this._onDisconnect = this._onDisconnect.bind(this);
127
- }
128
-
129
- // ------------------------------------------------------------ connection
130
- async _resolveDevice() {
131
- if (this.device !== null && typeof this.device !== 'string') return this.device;
132
- let nodes = await findNodes({ homeid: this.homeid, timeout: this.scanTimeout });
133
- if (typeof this.device === 'string') {
134
- const want = norm(this.device);
135
- nodes = nodes.filter((n) => [n.name, n.address, n.peripheral.id].some((v) => v && norm(v) === want));
136
- }
137
- if (!nodes.length) throw new ConnectionError(`no mesh node with HomeID ${this.homeid} found`);
138
- log.info(`using node ${nodes[0].name} (${nodes[0].address}) rssi ${nodes[0].rssi}`);
139
- return nodes[0].peripheral;
140
- }
141
-
142
- /** Scan (unless a peripheral was given), connect and log in. `timeout` in seconds. */
143
- async connect(timeout = 10) {
144
- const p = await this._resolveDevice();
145
- await poweredOn(await getNoble());
146
- this.nodeName = p.advertisement?.localName || p.id;
147
- this.own = null;
148
- this._inbox = new Inbox();
149
- this._peripheral = p;
150
- p.on('disconnect', this._onDisconnect);
151
- try {
152
- await withTimeout(p.connectAsync(), timeout, `could not connect to ${this.nodeName}`);
153
- const { characteristics } = await withTimeout(
154
- p.discoverSomeServicesAndCharacteristicsAsync([UUID_SERVICE], [UUID_WRITE, UUID_NOTIFY]),
155
- timeout, 'GATT discovery timed out');
156
- this._write = characteristics.find((c) => norm(c.uuid) === UUID_WRITE);
157
- this._notify = characteristics.find((c) => norm(c.uuid) === UUID_NOTIFY);
158
- if (!this._write || !this._notify) throw new ConnectionError(`${this.nodeName} is not an ODELIC mesh node`);
159
- this._notify.on('data', this._onNotify);
160
- await this._notify.subscribeAsync();
161
- await this._send(Buffer.from([0x01, 0x20])); // ask for the login challenge
162
- await withTimeout(this._login(), timeout, 'login timed out');
163
- } catch (e) {
164
- await this.disconnect();
165
- throw e;
166
- } finally {
167
- this._inbox = null;
168
- }
169
- }
170
-
171
- get isConnected() {
172
- return this._peripheral !== null && this._peripheral.state === 'connected' && this.own !== null;
173
- }
174
-
175
- _onDisconnect() {
176
- log.info('node disconnected');
177
- this.own = null;
178
- }
179
-
180
- async disconnect() {
181
- const p = this._peripheral;
182
- if (!p) return;
183
- this._peripheral = null;
184
- this.own = null;
185
- p.removeListener('disconnect', this._onDisconnect);
186
- if (this._notify) this._notify.removeListener('data', this._onNotify);
187
- this._write = this._notify = null;
188
- try {
189
- await withTimeout(p.disconnectAsync(), 5, 'disconnect timed out');
190
- } catch (e) {
191
- log.debug(`disconnect: ${e.message}`);
192
- }
193
- }
194
-
195
- async _login() {
196
- const inbox = this._inbox;
197
- for (;;) {
198
- const p = await inbox.next();
199
- if (p[0] === 0x02 && p[1] === 0x20 && p.length >= 18) {
200
- await this._send(this.crypto.answerChallenge(p.subarray(2, 18)));
201
- } else if (p[0] === 0x02 && p[1] === 0x16) {
202
- if (p.length >= 3 && p[2] === 0x08) {
203
- throw new LoginError('node is already in use by another controller ' +
204
- '(close the ODELIC app / turn off phone Bluetooth and retry)');
205
- }
206
- if (p.length < 3 || p[2] !== 0) throw new LoginError(`login rejected (status ${p.subarray(2).toString('hex')})`);
207
- } else if (p[0] === 0x01 && p[1] === 0x0a && p.length >= 6) {
208
- this.own = Buffer.from(p.subarray(2, 6));
209
- }
210
- if (this.own !== null) return;
211
- }
212
- }
213
-
214
- // ------------------------------------------------------------ transport
215
- async _send(data) {
216
- if (!this._write) throw new ConnectionError('not connected');
217
- log.debug(`-> ${data.toString('hex')}`);
218
- await this._write.writeAsync(data, true); // write without response
219
- }
220
-
221
- _onNotify(data) {
222
- data = Buffer.from(data);
223
- log.debug(`<- ${data.toString('hex')}`);
224
- if (data.length >= 3 && data[0] === 4 && data[1] === 4) { // segmented PDU
225
- const seq = data[2] & 0x0f;
226
- const total = data[2] >> 4;
227
- if (seq !== this._segSeq + 1) {
228
- this._seg = [];
229
- this._segSeq = 0;
230
- return;
231
- }
232
- this._seg.push(data.subarray(3));
233
- this._segSeq = seq;
234
- if (seq !== total) return;
235
- data = Buffer.concat(this._seg);
236
- this._seg = [];
237
- this._segSeq = 0;
238
- }
239
- try {
240
- data = this.crypto.decrypt(data);
241
- } catch {
242
- log.debug(`undecryptable packet ${data.toString('hex')}`);
243
- return;
244
- }
245
- this._inbox?.push(data);
246
- for (const cb of [...this._listeners]) {
247
- try {
248
- cb(data);
249
- } catch (e) {
250
- log.error('listener failed:', e);
251
- }
252
- }
253
- }
254
-
255
- /** Encrypt and send a type-3 data-event packet (see protocol.js). */
256
- async send(packet) {
257
- if (this.own === null) throw new LoginError('not connected');
258
- const enc = this.crypto.encrypt(packet);
259
- const mtu = Math.max(20, (this._peripheral?.mtu ?? 23) - 3);
260
- if (enc.length <= mtu) return this._send(enc);
261
- const chunk = mtu - 3;
262
- const total = Math.ceil(enc.length / chunk);
263
- for (let i = 0; i < total; i++) {
264
- await this._send(Buffer.concat([Buffer.from([0x01, 0x21, (total << 4) | (i + 1)]),
265
- enc.subarray(i * chunk, (i + 1) * chunk)]));
266
- await sleep(15);
267
- }
268
- }
269
-
270
- /** `callback(packet: Buffer)` for every received (decrypted) packet. Returns an unsubscribe function. */
271
- addListener(callback) {
272
- this._listeners.add(callback);
273
- return () => this._listeners.delete(callback);
274
- }
275
-
276
- removeListener(callback) {
277
- this._listeners.delete(callback);
278
- }
279
-
280
- // ------------------------------------------------------------ actions
281
- /** Turn on (restores the previous colour/brightness). */
282
- async on(target = P.BROADCAST) {
283
- await this.send(P.light(this.own, P.vaddr(target), ...P.XY_ON));
284
- }
285
-
286
- async off(target = P.BROADCAST) {
287
- await this.send(P.light(this.own, P.vaddr(target), ...P.XY_OFF));
288
- }
289
-
290
- /**
291
- * color: 0 = warm (電球色) .. 100 = cool (昼光色); brightness: 1..100 %.
292
- * Or pass raw x (0..20) / y (0..19, 0 = brightest).
293
- */
294
- async setLight({ color = null, brightness = 100, target = P.BROADCAST, x = null, y = null } = {}) {
295
- x ??= P.colorToX(color ?? 50);
296
- y ??= P.brightnessToY(brightness);
297
- await this.send(P.light(this.own, P.vaddr(target), x, y));
298
- }
299
-
300
- /** Dim warm night light, level 0..2 (what the app does for plain lights). */
301
- async nightLight(level = 0, target = P.BROADCAST) {
302
- await this.send(P.light(this.own, P.vaddr(target), 0, 17 + Math.max(0, Math.min(2, level))));
303
- }
304
-
305
- /** Same as setLight but for a group id (as configured in the app). on: true/false for on/off. */
306
- async setGroup(groupId, { color = null, brightness = 100, x = null, y = null, on = null } = {}) {
307
- if (on !== null) [x, y] = on ? P.XY_ON : P.XY_OFF;
308
- x ??= P.colorToX(color ?? 50);
309
- y ??= P.brightnessToY(brightness);
310
- await this.send(P.groupLight(this.own, groupId, x, y));
311
- }
312
-
313
- /** Cycle the colour preset of one light (light_change_color). */
314
- async changeColor(target) {
315
- await this.send(P.changeColor(this.own, P.vaddr(target)));
316
- }
317
-
318
- async saveScene(index) {
319
- await this.send(P.scene(this.own, index, true));
320
- }
321
-
322
- async playScene(index) {
323
- await this.send(P.scene(this.own, index, false));
324
- }
325
-
326
- /** Ping the whole mesh and return the responding lights (`timeout` in seconds). */
327
- async devices(timeout = 3) {
328
- const found = new Map();
329
- const off = this.addListener((p) => {
330
- const d = P.parsePingResponse(p);
331
- if (d) found.set(d.vaddr.toString('hex'), d);
332
- });
333
- try {
334
- await this.send(P.pingAll(this.own));
335
- await sleep(timeout * 1000);
336
- } finally {
337
- off();
338
- }
339
- return [...found.values()];
340
- }
341
-
342
- /** Ask one light (vaddr) for its state. Resolves to a LightState, or null on timeout. */
343
- async status(target, timeout = 3) {
344
- const dst = P.vaddr(target);
345
- let off;
346
- let timer;
347
- const reply = new Promise((resolve) => {
348
- off = this.addListener((p) => {
349
- const st = P.parseStatus(p);
350
- if (st && st.vaddr.equals(dst)) resolve(st);
351
- });
352
- timer = setTimeout(() => resolve(null), timeout * 1000);
353
- });
354
- try {
355
- await this.send(P.statusRequest(this.own, dst));
356
- return await reply;
357
- } finally {
358
- clearTimeout(timer);
359
- off();
360
- }
361
- }
362
- }
package/src/controller.js DELETED
@@ -1,219 +0,0 @@
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.js';
10
- import { log } from './log.js';
11
- import * as P from './protocol.js';
12
- import { OdelicMesh } from './client.js';
13
- import { Config } from './config.js';
14
-
15
- /** Integer from a number or numeric string (like Python's int()); throws InputError otherwise. */
16
- export function toInt(v, what = 'value') {
17
- const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
18
- if (typeof n !== 'number' || !Number.isFinite(n)) throw new InputError(`${what} must be a number (got ${JSON.stringify(v)})`);
19
- return Math.trunc(n);
20
- }
21
-
22
- const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
23
- const cmpBuf = (a, b) => Buffer.compare(a.vaddr, b.vaddr);
24
-
25
- export class Controller {
26
- /**
27
- * @param {string|null} [appId] default: $ODELIC_ID / config file
28
- * @param {{node?: string|null, config?: Config, idleTimeout?: number|string|null, mesh?: OdelicMesh}} [opts]
29
- * idleTimeout: seconds before an idle link is released; 0 = stay connected;
30
- * null = $ODELIC_IDLE_TIMEOUT / config file / 60 s.
31
- */
32
- constructor(appId = null, { node = null, config = null, idleTimeout = null, mesh = null } = {}) {
33
- this.config = config ?? Config.load();
34
- const aid = this.config.appId(appId);
35
- if (!aid && !mesh) throw new InputError('no app ID: pass --id, set ODELIC_ID, or run `odelic setup --id <ID>`');
36
- this.mesh = mesh ?? new OdelicMesh(aid, { device: node ?? this.config.node });
37
- this.idleTimeout = this.config.idle(idleTimeout);
38
- this._queue = Promise.resolve();
39
- this._idle = null;
40
- }
41
-
42
- // ------------------------------------------------------------ connection handling
43
- /** Run `fn` after every previously queued operation (a simple mutex). */
44
- _locked(fn) {
45
- const run = this._queue.then(fn);
46
- this._queue = run.catch(() => {});
47
- return run;
48
- }
49
-
50
- _run(fn) {
51
- return this._locked(async () => {
52
- this._cancelIdle();
53
- try {
54
- for (let attempt = 0; ; attempt++) {
55
- if (!this.mesh.isConnected) await this.mesh.connect();
56
- try {
57
- return await fn(this.mesh);
58
- } catch (e) {
59
- // retry once when the BLE link dropped under us; input / login errors are final
60
- if (attempt || this.mesh.isConnected || e instanceof InputError || e instanceof LoginError) throw e;
61
- log.info(`link lost (${e.message}), reconnecting`);
62
- await this.mesh.disconnect();
63
- }
64
- }
65
- } finally {
66
- this._armIdle();
67
- }
68
- });
69
- }
70
-
71
- _cancelIdle() {
72
- if (this._idle) clearTimeout(this._idle);
73
- this._idle = null;
74
- }
75
-
76
- _armIdle() {
77
- if (!this.idleTimeout || !this.mesh.isConnected) return;
78
- this._idle = setTimeout(() => {
79
- this._locked(async () => {
80
- this._idle = null;
81
- if (this.mesh.isConnected) {
82
- log.info('idle, disconnecting');
83
- await this.mesh.disconnect();
84
- }
85
- }).catch((e) => log.warning(`idle disconnect failed: ${e.message}`));
86
- }, this.idleTimeout * 1000);
87
- this._idle.unref?.();
88
- }
89
-
90
- /** Connect now (otherwise the first command connects). */
91
- async connect() {
92
- await this._run(async () => {});
93
- }
94
-
95
- async close() {
96
- this._cancelIdle();
97
- await this._locked(() => this.mesh.disconnect());
98
- }
99
-
100
- get connected() {
101
- return this.mesh.isConnected;
102
- }
103
-
104
- get keepConnected() {
105
- return !this.idleTimeout;
106
- }
107
-
108
- info() {
109
- return { connected: this.connected, node: this.mesh.nodeName, mode: this.keepConnected ? 'keep' : 'idle', idle_timeout: this.idleTimeout };
110
- }
111
-
112
- // ------------------------------------------------------------ helpers
113
- _label(v) {
114
- return v.equals(P.BROADCAST) ? 'all' : (this.config.nameOf(v) ?? v.toString('hex'));
115
- }
116
-
117
- _done(action, v, extra = {}) {
118
- return { ok: true, action, target: this._label(v), ...extra };
119
- }
120
-
121
- // ------------------------------------------------------------ actions
122
- async on(target = 'all') {
123
- const v = this.config.resolve(target);
124
- await this._run((m) => m.on(v));
125
- return this._done('on', v);
126
- }
127
-
128
- async off(target = 'all') {
129
- const v = this.config.resolve(target);
130
- await this._run((m) => m.off(v));
131
- return this._done('off', v);
132
- }
133
-
134
- /** color 0 (warm) .. 100 (cool), brightness 1..100 % */
135
- async setLight(target = 'all', color = 50, brightness = 100) {
136
- const v = this.config.resolve(target);
137
- color = clamp(toInt(color, 'color'), 0, 100);
138
- brightness = clamp(toInt(brightness, 'brightness'), 1, 100);
139
- await this._run((m) => m.setLight({ color, brightness, target: v }));
140
- return this._done('light', v, { color, brightness });
141
- }
142
-
143
- async nightLight(target = 'all', level = 0) {
144
- const v = this.config.resolve(target);
145
- level = clamp(toInt(level, 'level'), 0, 2);
146
- await this._run((m) => m.nightLight(level, v));
147
- return this._done('night', v, { level });
148
- }
149
-
150
- /** action: "on", "off" or "light" (uses color / brightness) */
151
- async group(groupId, action = 'on', color = 50, brightness = 100) {
152
- const id = toInt(groupId, 'group id');
153
- if (action === 'light') {
154
- const c = clamp(toInt(color, 'color'), 0, 100);
155
- const b = clamp(toInt(brightness, 'brightness'), 1, 100);
156
- await this._run((m) => m.setGroup(id, { color: c, brightness: b }));
157
- } else if (action === 'on' || action === 'off') {
158
- await this._run((m) => m.setGroup(id, { on: action === 'on' }));
159
- } else {
160
- throw new InputError('action must be on, off or light');
161
- }
162
- return { ok: true, action: `group_${action}`, group: id };
163
- }
164
-
165
- async scene(index, save = false) {
166
- const i = toInt(index, 'scene index');
167
- await this._run((m) => (save ? m.saveScene(i) : m.playScene(i)));
168
- return { ok: true, action: save ? 'scene_save' : 'scene_play', index: i };
169
- }
170
-
171
- async changeColor(target) {
172
- const v = this.config.resolve(target);
173
- await this._run((m) => m.changeColor(v));
174
- return this._done('change_color', v);
175
- }
176
-
177
- /** Lights answering a mesh ping, plus named lights that did not answer (online: false). */
178
- async lights(timeout = 3) {
179
- const devs = await this._run((m) => m.devices(timeout));
180
- const out = [];
181
- const seen = new Set();
182
- for (const d of [...devs].sort(cmpBuf)) {
183
- const hex = d.vaddr.toString('hex');
184
- seen.add(hex);
185
- out.push({ name: this.config.nameOf(d.vaddr), vaddr: hex, online: true, mac: d.mac, product: d.product, version: d.version });
186
- }
187
- for (const [name, v] of Object.entries(this.config.lights)) {
188
- if (!seen.has(v)) out.push({ name, vaddr: v, online: false });
189
- }
190
- return out;
191
- }
192
-
193
- /** State of one light, or of every responding light for "all". */
194
- async status(target = 'all') {
195
- const v = this.config.resolve(target);
196
- if (v.equals(P.BROADCAST)) {
197
- const devs = await this._run((m) => m.devices());
198
- const out = [];
199
- for (const d of [...devs].sort(cmpBuf)) out.push(await this.status(d.vaddr));
200
- return out;
201
- }
202
- const st = await this._run((m) => m.status(v));
203
- if (st === null) return { name: this.config.nameOf(v), vaddr: v.toString('hex'), online: false };
204
- return { name: this.config.nameOf(v), online: true, ...st.toJSON() };
205
- }
206
-
207
- // ------------------------------------------------------------ names (no BLE needed)
208
- nameLight(name, target) {
209
- this.config.setName(name, target);
210
- this.config.save();
211
- return { ok: true, name, vaddr: this.config.lights[name] };
212
- }
213
-
214
- forgetName(name) {
215
- this.config.removeName(name);
216
- this.config.save();
217
- return { ok: true, removed: name };
218
- }
219
- }
package/src/log.js DELETED
@@ -1,20 +0,0 @@
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 };
4
- let current = LEVELS[String(process.env.ODELIC_LOG || '').toLowerCase()] ?? LEVELS.warning;
5
-
6
- export function setLogLevel(level) {
7
- if (!(level in LEVELS)) throw new RangeError(`unknown log level ${level}`);
8
- current = LEVELS[level];
9
- }
10
-
11
- function emit(level, args) {
12
- if (LEVELS[level] >= current) console.error('odelic:', ...args);
13
- }
14
-
15
- export const log = {
16
- debug: (...a) => emit('debug', a),
17
- info: (...a) => emit('info', a),
18
- warning: (...a) => emit('warning', a),
19
- error: (...a) => emit('error', a),
20
- };
package/src/version.js DELETED
@@ -1 +0,0 @@
1
- export const VERSION = '0.2.0';