odelic 0.2.0 → 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 (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 +125 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +354 -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 +210 -0
  19. package/dist/controller.js.map +1 -0
  20. package/dist/crypto.d.ts +25 -0
  21. package/dist/crypto.d.ts.map +1 -0
  22. package/dist/crypto.js +94 -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 +414 -0
  55. package/src/{config.js → config.ts} +37 -23
  56. package/src/controller.ts +267 -0
  57. package/src/{crypto.js → crypto.ts} +29 -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.ts ADDED
@@ -0,0 +1,414 @@
1
+ /** BLE client for ODELIC lights (@stoprocent/noble). */
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import type { Characteristic, Noble, Peripheral } from '@stoprocent/noble';
4
+
5
+ import { ConnectionError, InputError, LoginError } from './errors.ts';
6
+ import { log } from './log.ts';
7
+ import * as P from './protocol.ts';
8
+ import { MeshCrypto } from './crypto.ts';
9
+
10
+ export const UUID_SERVICE = '9e5d1e475c1343a0863582adffc0386f';
11
+ export const UUID_WRITE = '9e5d1e475c1343a0863582adffc1386f';
12
+ export const UUID_NOTIFY = '9e5d1e475c1343a0863582adffc2386f';
13
+
14
+ /** Called with every received (decrypted) packet. */
15
+ export type PacketListener = (packet: Buffer) => void;
16
+
17
+ /**
18
+ * A noble `Peripheral`, typed structurally so that odelic's public types do not pull in noble's
19
+ * declarations (which fail to type-check without `skipLibCheck`).
20
+ */
21
+ export interface BlePeripheral {
22
+ readonly id: string;
23
+ readonly address: string;
24
+ readonly rssi: number;
25
+ readonly advertisement: { localName?: string; manufacturerData?: Buffer };
26
+ }
27
+
28
+ /** A mesh node found by {@link findNodes}. */
29
+ export interface MeshNode {
30
+ peripheral: BlePeripheral;
31
+ name: string | null;
32
+ address: string;
33
+ homeid: number;
34
+ rssi: number;
35
+ }
36
+
37
+ let noblePromise: Promise<Noble> | null = null;
38
+
39
+ /** The noble instance, loaded on first use so that importing odelic never touches Bluetooth. */
40
+ function getNoble(): Promise<Noble> {
41
+ noblePromise ??= import('@stoprocent/noble').then((m) => m.default);
42
+ return noblePromise;
43
+ }
44
+
45
+ async function poweredOn(noble: Noble, timeout = 10): Promise<void> {
46
+ try {
47
+ await noble.waitForPoweredOnAsync(timeout * 1000);
48
+ } catch (e) {
49
+ throw new ConnectionError(`Bluetooth is not available (state: ${noble.state}); ` +
50
+ 'turn Bluetooth on and allow this terminal to use it', { cause: e });
51
+ }
52
+ }
53
+
54
+ function withTimeout<T>(promise: Promise<T>, seconds: number, message: string): Promise<T> {
55
+ let timer: NodeJS.Timeout | undefined;
56
+ const t = new Promise<never>((_, reject) => {
57
+ timer = setTimeout(() => reject(new ConnectionError(message)), seconds * 1000);
58
+ });
59
+ return Promise.race([promise, t]).finally(() => clearTimeout(timer));
60
+ }
61
+
62
+ const norm = (s: unknown) => String(s ?? '').toLowerCase().replace(/[-:]/g, '');
63
+
64
+ /**
65
+ * Scan for mesh nodes, sorted by signal strength (strongest first).
66
+ * @param opts.homeid only nodes of this mesh
67
+ * @param opts.timeout scan duration in seconds
68
+ */
69
+ export async function findNodes({ homeid = null, timeout = 8 }: { homeid?: number | null; timeout?: number } = {}): Promise<MeshNode[]> {
70
+ const noble = await getNoble();
71
+ await poweredOn(noble);
72
+ const found = new Map<string, MeshNode>();
73
+ const onDiscover = (p: Peripheral) => {
74
+ const hid = P.advHomeid(p.advertisement?.manufacturerData);
75
+ if (hid === null || (homeid !== null && hid !== homeid)) return;
76
+ found.set(p.id, {
77
+ peripheral: p,
78
+ name: p.advertisement?.localName || null,
79
+ address: p.address && p.address !== 'unknown' ? p.address : p.id,
80
+ homeid: hid,
81
+ rssi: p.rssi,
82
+ });
83
+ };
84
+ noble.on('discover', onDiscover);
85
+ try {
86
+ await noble.startScanningAsync([], true);
87
+ await sleep(timeout * 1000);
88
+ } finally {
89
+ noble.removeListener('discover', onDiscover);
90
+ await noble.stopScanningAsync().catch(() => {});
91
+ }
92
+ return [...found.values()].sort((a, b) => b.rssi - a.rssi);
93
+ }
94
+
95
+ /** Tiny async FIFO used while logging in. */
96
+ class Inbox<T> {
97
+ private items: T[] = [];
98
+ private waiters: ((v: T) => void)[] = [];
99
+
100
+ push(v: T): void {
101
+ const w = this.waiters.shift();
102
+ if (w) w(v);
103
+ else this.items.push(v);
104
+ }
105
+
106
+ next(): Promise<T> {
107
+ if (this.items.length) return Promise.resolve(this.items.shift() as T);
108
+ return new Promise((resolve) => this.waiters.push(resolve));
109
+ }
110
+ }
111
+
112
+ export interface OdelicMeshOptions {
113
+ /** HomeID (instead of `appId`) */
114
+ homeid?: number;
115
+ /** 4-character password (instead of `appId`) */
116
+ password?: string;
117
+ /** noble Peripheral, or a node name / address / id; default = strongest node of the mesh */
118
+ device?: BlePeripheral | string | null;
119
+ /** scan duration in seconds when looking for a node */
120
+ scanTimeout?: number;
121
+ }
122
+
123
+ export interface SetLightOptions {
124
+ /** 0 = warm (電球色) .. 100 = cool (昼光色); default 50 */
125
+ color?: number | null;
126
+ /** 1..100 % (default 100) */
127
+ brightness?: number;
128
+ /** default: every light */
129
+ target?: P.VaddrLike;
130
+ /** raw colour 0..20 (overrides `color`) */
131
+ x?: number | null;
132
+ /** raw brightness 0..19, 0 = brightest (overrides `brightness`) */
133
+ y?: number | null;
134
+ }
135
+
136
+ export interface SetGroupOptions extends Omit<SetLightOptions, 'target'> {
137
+ /** true / false = plain on / off (ignores colour and brightness) */
138
+ on?: boolean | null;
139
+ }
140
+
141
+ /**
142
+ * One logged-in connection to an ODELIC mesh.
143
+ *
144
+ * ```ts
145
+ * const mesh = new OdelicMesh('12345678');
146
+ * await mesh.connect();
147
+ * await mesh.setLight({ color: 0, brightness: 100 }); // warm, full
148
+ * await mesh.off();
149
+ * await mesh.disconnect();
150
+ * ```
151
+ */
152
+ export class OdelicMesh {
153
+ readonly homeid: number;
154
+ readonly crypto: MeshCrypto;
155
+ device: BlePeripheral | string | null;
156
+ scanTimeout: number;
157
+ /** our vaddr in the mesh (assigned at login) */
158
+ own: Buffer | null = null;
159
+ nodeName: string | null = null;
160
+ private peripheral: Peripheral | null = null;
161
+ private writeChar: Characteristic | null = null;
162
+ private notifyChar: Characteristic | null = null;
163
+ private inbox: Inbox<Buffer> | null = null;
164
+ private seg: Buffer[] = [];
165
+ private segSeq = 0;
166
+ private readonly listeners = new Set<PacketListener>();
167
+
168
+ /** @param appId ID shown in the app, e.g. "12345678" (or pass `homeid` + `password`) */
169
+ constructor(appId: string | null = null, { homeid, password, device = null, scanTimeout = 8 }: OdelicMeshOptions = {}) {
170
+ if (appId !== null && appId !== undefined) ({ homeid, password } = P.parseAppId(appId));
171
+ if (homeid === undefined || password === undefined) {
172
+ throw new InputError("give appId (e.g. '12345678') or homeid + password");
173
+ }
174
+ this.homeid = homeid;
175
+ this.crypto = new MeshCrypto(homeid, password);
176
+ this.device = device;
177
+ this.scanTimeout = scanTimeout;
178
+ }
179
+
180
+ // ------------------------------------------------------------ connection
181
+ private async resolveDevice(): Promise<Peripheral> {
182
+ if (this.device !== null && typeof this.device !== 'string') return this.device as Peripheral;
183
+ let nodes = await findNodes({ homeid: this.homeid, timeout: this.scanTimeout });
184
+ if (typeof this.device === 'string') {
185
+ const want = norm(this.device);
186
+ nodes = nodes.filter((n) => [n.name, n.address, n.peripheral.id].some((v) => v && norm(v) === want));
187
+ }
188
+ if (!nodes.length) throw new ConnectionError(`no mesh node with HomeID ${this.homeid} found`);
189
+ log.info(`using node ${nodes[0].name} (${nodes[0].address}) rssi ${nodes[0].rssi}`);
190
+ return nodes[0].peripheral as Peripheral;
191
+ }
192
+
193
+ /** Scan (unless a peripheral was given), connect and log in. `timeout` in seconds. */
194
+ async connect(timeout = 10): Promise<void> {
195
+ const p = await this.resolveDevice();
196
+ await poweredOn(await getNoble());
197
+ this.nodeName = p.advertisement?.localName || p.id;
198
+ this.own = null;
199
+ this.inbox = new Inbox();
200
+ this.peripheral = p;
201
+ p.on('disconnect', this.onDisconnect);
202
+ try {
203
+ await withTimeout(p.connectAsync(), timeout, `could not connect to ${this.nodeName}`);
204
+ const { characteristics } = await withTimeout(
205
+ p.discoverSomeServicesAndCharacteristicsAsync([UUID_SERVICE], [UUID_WRITE, UUID_NOTIFY]),
206
+ timeout, 'GATT discovery timed out');
207
+ this.writeChar = characteristics.find((c) => norm(c.uuid) === UUID_WRITE) ?? null;
208
+ this.notifyChar = characteristics.find((c) => norm(c.uuid) === UUID_NOTIFY) ?? null;
209
+ if (!this.writeChar || !this.notifyChar) throw new ConnectionError(`${this.nodeName} is not an ODELIC mesh node`);
210
+ this.notifyChar.on('data', this.onNotify);
211
+ await this.notifyChar.subscribeAsync();
212
+ await this.write(Buffer.from([0x01, 0x20])); // ask for the login challenge
213
+ await withTimeout(this.login(this.inbox), timeout, 'login timed out');
214
+ } catch (e) {
215
+ await this.disconnect();
216
+ throw e;
217
+ } finally {
218
+ this.inbox = null;
219
+ }
220
+ }
221
+
222
+ get isConnected(): boolean {
223
+ return this.peripheral !== null && this.peripheral.state === 'connected' && this.own !== null;
224
+ }
225
+
226
+ private readonly onDisconnect = (): void => {
227
+ log.info('node disconnected');
228
+ this.own = null;
229
+ };
230
+
231
+ async disconnect(): Promise<void> {
232
+ const p = this.peripheral;
233
+ if (!p) return;
234
+ this.peripheral = null;
235
+ this.own = null;
236
+ p.removeListener('disconnect', this.onDisconnect);
237
+ this.notifyChar?.removeListener('data', this.onNotify);
238
+ this.writeChar = this.notifyChar = null;
239
+ try {
240
+ await withTimeout(p.disconnectAsync(), 5, 'disconnect timed out');
241
+ } catch (e) {
242
+ log.debug(`disconnect: ${(e as Error).message}`);
243
+ }
244
+ }
245
+
246
+ private async login(inbox: Inbox<Buffer>): Promise<void> {
247
+ for (;;) {
248
+ const p = await inbox.next();
249
+ if (p[0] === 0x02 && p[1] === 0x20 && p.length >= 18) {
250
+ await this.write(this.crypto.answerChallenge(p.subarray(2, 18)));
251
+ } else if (p[0] === 0x02 && p[1] === 0x16) {
252
+ if (p.length >= 3 && p[2] === 0x08) {
253
+ throw new LoginError('node is already in use by another controller ' +
254
+ '(close the ODELIC app / turn off phone Bluetooth and retry)');
255
+ }
256
+ if (p.length < 3 || p[2] !== 0) throw new LoginError(`login rejected (status ${p.subarray(2).toString('hex')})`);
257
+ } else if (p[0] === 0x01 && p[1] === 0x0a && p.length >= 6) {
258
+ this.own = Buffer.from(p.subarray(2, 6));
259
+ }
260
+ if (this.own !== null) return;
261
+ }
262
+ }
263
+
264
+ // ------------------------------------------------------------ transport
265
+ private async write(data: Buffer): Promise<void> {
266
+ if (!this.writeChar) throw new ConnectionError('not connected');
267
+ log.debug(`-> ${data.toString('hex')}`);
268
+ await this.writeChar.writeAsync(data, true); // write without response
269
+ }
270
+
271
+ private readonly onNotify = (raw: Buffer): void => {
272
+ let data: Buffer = Buffer.from(raw);
273
+ log.debug(`<- ${data.toString('hex')}`);
274
+ if (data.length >= 3 && data[0] === 4 && data[1] === 4) { // segmented PDU
275
+ const seq = data[2] & 0x0f;
276
+ const total = data[2] >> 4;
277
+ if (seq !== this.segSeq + 1) {
278
+ this.seg = [];
279
+ this.segSeq = 0;
280
+ return;
281
+ }
282
+ this.seg.push(data.subarray(3));
283
+ this.segSeq = seq;
284
+ if (seq !== total) return;
285
+ data = Buffer.concat(this.seg);
286
+ this.seg = [];
287
+ this.segSeq = 0;
288
+ }
289
+ try {
290
+ data = this.crypto.decrypt(data);
291
+ } catch {
292
+ log.debug(`undecryptable packet ${data.toString('hex')}`);
293
+ return;
294
+ }
295
+ this.inbox?.push(data);
296
+ for (const cb of [...this.listeners]) {
297
+ try {
298
+ cb(data);
299
+ } catch (e) {
300
+ log.error('listener failed:', e);
301
+ }
302
+ }
303
+ };
304
+
305
+ private requireOwn(): Buffer {
306
+ if (this.own === null) throw new LoginError('not connected');
307
+ return this.own;
308
+ }
309
+
310
+ /** Encrypt and send a type-3 data-event packet (see protocol.ts). */
311
+ async send(packet: Uint8Array): Promise<void> {
312
+ this.requireOwn();
313
+ const enc = this.crypto.encrypt(packet);
314
+ const mtu = Math.max(20, (this.peripheral?.mtu ?? 23) - 3);
315
+ if (enc.length <= mtu) return this.write(enc);
316
+ const chunk = mtu - 3;
317
+ const total = Math.ceil(enc.length / chunk);
318
+ for (let i = 0; i < total; i++) {
319
+ await this.write(Buffer.concat([Buffer.from([0x01, 0x21, (total << 4) | (i + 1)]),
320
+ enc.subarray(i * chunk, (i + 1) * chunk)]));
321
+ await sleep(15);
322
+ }
323
+ }
324
+
325
+ /** Receive every (decrypted) packet. Returns an unsubscribe function. */
326
+ addListener(callback: PacketListener): () => void {
327
+ this.listeners.add(callback);
328
+ return () => this.listeners.delete(callback);
329
+ }
330
+
331
+ removeListener(callback: PacketListener): void {
332
+ this.listeners.delete(callback);
333
+ }
334
+
335
+ // ------------------------------------------------------------ actions
336
+ /** Turn on (restores the previous colour/brightness). */
337
+ async on(target: P.VaddrLike = P.BROADCAST): Promise<void> {
338
+ await this.send(P.light(this.requireOwn(), P.vaddr(target), ...P.XY_ON));
339
+ }
340
+
341
+ async off(target: P.VaddrLike = P.BROADCAST): Promise<void> {
342
+ await this.send(P.light(this.requireOwn(), P.vaddr(target), ...P.XY_OFF));
343
+ }
344
+
345
+ /** Turn on with a colour and brightness (or raw x / y). */
346
+ async setLight({ color = null, brightness = 100, target = P.BROADCAST, x = null, y = null }: SetLightOptions = {}): Promise<void> {
347
+ x ??= P.colorToX(color ?? 50);
348
+ y ??= P.brightnessToY(brightness);
349
+ await this.send(P.light(this.requireOwn(), P.vaddr(target), x, y));
350
+ }
351
+
352
+ /** Dim warm night light, level 0..2 (what the app does for plain lights). */
353
+ async nightLight(level = 0, target: P.VaddrLike = P.BROADCAST): Promise<void> {
354
+ await this.send(P.light(this.requireOwn(), P.vaddr(target), 0, 17 + Math.max(0, Math.min(2, level))));
355
+ }
356
+
357
+ /** Same as setLight but for a group id (as configured in the app). */
358
+ async setGroup(groupId: number, { color = null, brightness = 100, x = null, y = null, on = null }: SetGroupOptions = {}): Promise<void> {
359
+ if (on !== null) [x, y] = on ? P.XY_ON : P.XY_OFF;
360
+ x ??= P.colorToX(color ?? 50);
361
+ y ??= P.brightnessToY(brightness);
362
+ await this.send(P.groupLight(this.requireOwn(), groupId, x, y));
363
+ }
364
+
365
+ /** Cycle the colour preset of one light (light_change_color). */
366
+ async changeColor(target: P.VaddrLike): Promise<void> {
367
+ await this.send(P.changeColor(this.requireOwn(), P.vaddr(target)));
368
+ }
369
+
370
+ async saveScene(index: number): Promise<void> {
371
+ await this.send(P.scene(this.requireOwn(), index, true));
372
+ }
373
+
374
+ async playScene(index: number): Promise<void> {
375
+ await this.send(P.scene(this.requireOwn(), index, false));
376
+ }
377
+
378
+ /** Ping the whole mesh and return the responding lights (`timeout` in seconds). */
379
+ async devices(timeout = 3): Promise<P.Device[]> {
380
+ const found = new Map<string, P.Device>();
381
+ const off = this.addListener((p) => {
382
+ const d = P.parsePingResponse(p);
383
+ if (d) found.set(d.vaddr.toString('hex'), d);
384
+ });
385
+ try {
386
+ await this.send(P.pingAll(this.requireOwn()));
387
+ await sleep(timeout * 1000);
388
+ } finally {
389
+ off();
390
+ }
391
+ return [...found.values()];
392
+ }
393
+
394
+ /** Ask one light for its state. Resolves to null on timeout (`timeout` in seconds). */
395
+ async status(target: P.VaddrLike, timeout = 3): Promise<P.LightState | null> {
396
+ const dst = P.vaddr(target);
397
+ let off = () => {};
398
+ let timer: NodeJS.Timeout | undefined;
399
+ const reply = new Promise<P.LightState | null>((resolve) => {
400
+ off = this.addListener((p) => {
401
+ const st = P.parseStatus(p);
402
+ if (st && st.vaddr.equals(dst)) resolve(st);
403
+ });
404
+ timer = setTimeout(() => resolve(null), timeout * 1000);
405
+ });
406
+ try {
407
+ await this.send(P.statusRequest(this.requireOwn(), dst));
408
+ return await reply;
409
+ } finally {
410
+ clearTimeout(timer);
411
+ off();
412
+ }
413
+ }
414
+ }
@@ -13,60 +13,74 @@ import fs from 'node:fs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
 
16
- import { InputError } from './errors.js';
17
- import * as P from './protocol.js';
16
+ import { InputError } from './errors.ts';
17
+ import * as P from './protocol.ts';
18
18
 
19
- export const ALL_NAMES = ['all', '*', '全部', 'すべて'];
19
+ export const ALL_NAMES: readonly string[] = ['all', '*', '全部', 'すべて'];
20
20
  export const DEFAULT_IDLE_TIMEOUT = 60;
21
21
  const KEEP_WORDS = ['0', 'never', 'none', 'off', 'keep', 'forever'];
22
22
 
23
+ /** The JSON stored on disk (snake_case, shared with the Python package). */
24
+ export interface ConfigData {
25
+ id?: string | null;
26
+ node?: string | null;
27
+ idle_timeout?: number | string | null;
28
+ lights?: Record<string, string>;
29
+ }
30
+
23
31
  /** Seconds before an idle connection is closed; 0 (or "never"/"keep") = stay connected; null = not set. */
24
- export function parseIdle(value) {
32
+ export function parseIdle(value: unknown): number | null {
25
33
  if (value === null || value === undefined || value === '') return null;
26
34
  if (typeof value === 'string' && KEEP_WORDS.includes(value.trim().toLowerCase())) return 0;
27
35
  const v = typeof value === 'string' ? Number(value.trim()) : Number(value);
28
- if (typeof value === 'boolean' || !Number.isFinite(v)) throw new InputError(`invalid idle timeout: ${value}`);
36
+ if (typeof value === 'boolean' || !Number.isFinite(v)) throw new InputError(`invalid idle timeout: ${String(value)}`);
29
37
  if (v < 0) throw new InputError('idle timeout must be >= 0');
30
38
  return v;
31
39
  }
32
40
 
33
- export function configPath() {
41
+ export function configPath(): string {
34
42
  if (process.env.ODELIC_CONFIG) return expandHome(process.env.ODELIC_CONFIG);
35
43
  const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
36
44
  return path.join(base, 'odelic', 'config.json');
37
45
  }
38
46
 
39
- function expandHome(p) {
47
+ function expandHome(p: string): string {
40
48
  return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p;
41
49
  }
42
50
 
43
51
  export class Config {
44
- constructor(data = {}, file = null) {
52
+ path: string;
53
+ id: string | null;
54
+ node: string | null;
55
+ idleTimeout: number | null;
56
+ /** name -> vaddr hex (null prototype: names like "constructor" are plain keys) */
57
+ lights: Record<string, string>;
58
+
59
+ constructor(data: ConfigData | null = {}, file: string | null = null) {
45
60
  data ??= {};
46
61
  this.path = file ?? configPath();
47
62
  this.id = data.id ?? null;
48
63
  this.node = data.node ?? null;
49
- /** @type {Record<string, string>} name -> vaddr hex */
50
- this.lights = Object.create(null); // null prototype: names like "constructor" are plain keys
64
+ this.lights = Object.create(null) as Record<string, string>;
51
65
  for (const [k, v] of Object.entries(data.lights ?? {})) this.lights[String(k)] = String(v).toLowerCase();
52
66
  this.idleTimeout = parseIdle(data.idle_timeout);
53
67
  }
54
68
 
55
- static load(file = null) {
69
+ static load(file: string | null = null): Config {
56
70
  const p = file ?? configPath();
57
- let data = {};
71
+ let data: ConfigData = {};
58
72
  try {
59
- data = JSON.parse(fs.readFileSync(p, 'utf8'));
73
+ data = JSON.parse(fs.readFileSync(p, 'utf8')) as ConfigData;
60
74
  } catch (e) {
61
- if (e.code !== 'ENOENT') throw e;
75
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
62
76
  }
63
77
  return new Config(data, p);
64
78
  }
65
79
 
66
- save() {
80
+ save(): void {
67
81
  fs.mkdirSync(path.dirname(this.path), { recursive: true });
68
82
  const tmp = this.path.replace(/\.[^./]*$/, '') + '.tmp';
69
- const data = { id: this.id, node: this.node, idle_timeout: this.idleTimeout, lights: this.lights };
83
+ const data: ConfigData = { id: this.id, node: this.node, idle_timeout: this.idleTimeout, lights: this.lights };
70
84
  // the ID is the mesh password: keep the file private
71
85
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
72
86
  try {
@@ -78,12 +92,12 @@ export class Config {
78
92
  }
79
93
 
80
94
  /** Effective app ID: override > $ODELIC_ID > config. */
81
- appId(override = null) {
95
+ appId(override: string | null = null): string | null {
82
96
  return override || process.env.ODELIC_ID || this.id;
83
97
  }
84
98
 
85
99
  /** Effective idle timeout: override > $ODELIC_IDLE_TIMEOUT > config > 60 s. 0 = stay connected. */
86
- idle(override = null) {
100
+ idle(override: number | string | null = null): number {
87
101
  for (const v of [override, process.env.ODELIC_IDLE_TIMEOUT, this.idleTimeout]) {
88
102
  const parsed = parseIdle(v);
89
103
  if (parsed !== null) return parsed;
@@ -92,14 +106,14 @@ export class Config {
92
106
  }
93
107
 
94
108
  // ------------------------------------------------------------ names
95
- nameOf(vaddr) {
109
+ nameOf(vaddr: Uint8Array): string | null {
96
110
  const hex = Buffer.from(vaddr).toString('hex');
97
111
  for (const [name, v] of Object.entries(this.lights)) if (v === hex) return name;
98
112
  return null;
99
113
  }
100
114
 
101
- /** light name / "all" / vaddr hex / Buffer -> 4-byte vaddr */
102
- resolve(target) {
115
+ /** light name / "all" / vaddr hex / bytes -> 4-byte vaddr */
116
+ resolve(target: P.VaddrLike | null | undefined): Buffer {
103
117
  if (target === null || target === undefined) return P.BROADCAST;
104
118
  if (target instanceof Uint8Array) return P.vaddr(target);
105
119
  const t = String(target).trim();
@@ -115,13 +129,13 @@ export class Config {
115
129
  }
116
130
  }
117
131
 
118
- setName(name, target) {
132
+ setName(name: string, target: P.VaddrLike): void {
119
133
  const v = this.resolve(target);
120
134
  if (v.equals(P.BROADCAST)) throw new InputError('cannot name the broadcast address');
121
135
  this.lights[name] = v.toString('hex');
122
136
  }
123
137
 
124
- removeName(name) {
138
+ removeName(name: string): void {
125
139
  if (!Object.hasOwn(this.lights, name)) throw new InputError(`no light named ${JSON.stringify(name)}`);
126
140
  delete this.lights[name];
127
141
  }