odelic 0.0.0-stage → 0.2.0

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/src/client.js ADDED
@@ -0,0 +1,362 @@
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/config.js ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * User configuration: app ID, preferred node, connection mode and light names.
3
+ *
4
+ * Stored as JSON at $ODELIC_CONFIG, or $XDG_CONFIG_HOME/odelic/config.json (default ~/.config/odelic/config.json).
5
+ * The format is shared with the Python package (odelic.py), so both can use the same file:
6
+ *
7
+ * {"id": "12345678", "node": null, "idle_timeout": 60, "lights": {"living": "00000000"}}
8
+ *
9
+ * idle_timeout: seconds of inactivity before the BLE link is released (so the phone app can connect);
10
+ * 0 keeps the link open permanently.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ import { InputError } from './errors.js';
17
+ import * as P from './protocol.js';
18
+
19
+ export const ALL_NAMES = ['all', '*', '全部', 'すべて'];
20
+ export const DEFAULT_IDLE_TIMEOUT = 60;
21
+ const KEEP_WORDS = ['0', 'never', 'none', 'off', 'keep', 'forever'];
22
+
23
+ /** Seconds before an idle connection is closed; 0 (or "never"/"keep") = stay connected; null = not set. */
24
+ export function parseIdle(value) {
25
+ if (value === null || value === undefined || value === '') return null;
26
+ if (typeof value === 'string' && KEEP_WORDS.includes(value.trim().toLowerCase())) return 0;
27
+ 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}`);
29
+ if (v < 0) throw new InputError('idle timeout must be >= 0');
30
+ return v;
31
+ }
32
+
33
+ export function configPath() {
34
+ if (process.env.ODELIC_CONFIG) return expandHome(process.env.ODELIC_CONFIG);
35
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
36
+ return path.join(base, 'odelic', 'config.json');
37
+ }
38
+
39
+ function expandHome(p) {
40
+ return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p;
41
+ }
42
+
43
+ export class Config {
44
+ constructor(data = {}, file = null) {
45
+ data ??= {};
46
+ this.path = file ?? configPath();
47
+ this.id = data.id ?? null;
48
+ 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
51
+ for (const [k, v] of Object.entries(data.lights ?? {})) this.lights[String(k)] = String(v).toLowerCase();
52
+ this.idleTimeout = parseIdle(data.idle_timeout);
53
+ }
54
+
55
+ static load(file = null) {
56
+ const p = file ?? configPath();
57
+ let data = {};
58
+ try {
59
+ data = JSON.parse(fs.readFileSync(p, 'utf8'));
60
+ } catch (e) {
61
+ if (e.code !== 'ENOENT') throw e;
62
+ }
63
+ return new Config(data, p);
64
+ }
65
+
66
+ save() {
67
+ fs.mkdirSync(path.dirname(this.path), { recursive: true });
68
+ const tmp = this.path.replace(/\.[^./]*$/, '') + '.tmp';
69
+ const data = { id: this.id, node: this.node, idle_timeout: this.idleTimeout, lights: this.lights };
70
+ // the ID is the mesh password: keep the file private
71
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
72
+ try {
73
+ fs.chmodSync(tmp, 0o600);
74
+ } catch {
75
+ // not supported on this filesystem
76
+ }
77
+ fs.renameSync(tmp, this.path);
78
+ }
79
+
80
+ /** Effective app ID: override > $ODELIC_ID > config. */
81
+ appId(override = null) {
82
+ return override || process.env.ODELIC_ID || this.id;
83
+ }
84
+
85
+ /** Effective idle timeout: override > $ODELIC_IDLE_TIMEOUT > config > 60 s. 0 = stay connected. */
86
+ idle(override = null) {
87
+ for (const v of [override, process.env.ODELIC_IDLE_TIMEOUT, this.idleTimeout]) {
88
+ const parsed = parseIdle(v);
89
+ if (parsed !== null) return parsed;
90
+ }
91
+ return DEFAULT_IDLE_TIMEOUT;
92
+ }
93
+
94
+ // ------------------------------------------------------------ names
95
+ nameOf(vaddr) {
96
+ const hex = Buffer.from(vaddr).toString('hex');
97
+ for (const [name, v] of Object.entries(this.lights)) if (v === hex) return name;
98
+ return null;
99
+ }
100
+
101
+ /** light name / "all" / vaddr hex / Buffer -> 4-byte vaddr */
102
+ resolve(target) {
103
+ if (target === null || target === undefined) return P.BROADCAST;
104
+ if (target instanceof Uint8Array) return P.vaddr(target);
105
+ const t = String(target).trim();
106
+ if (t === '' || ALL_NAMES.includes(t.toLowerCase())) return P.BROADCAST;
107
+ for (const [name, v] of Object.entries(this.lights)) {
108
+ if (name.toLowerCase() === t.toLowerCase()) return Buffer.from(v, 'hex');
109
+ }
110
+ try {
111
+ return P.vaddr(t);
112
+ } catch {
113
+ const known = Object.keys(this.lights).join(', ') || '(none)';
114
+ throw new InputError(`unknown light ${JSON.stringify(String(target))} (known names: ${known})`);
115
+ }
116
+ }
117
+
118
+ setName(name, target) {
119
+ const v = this.resolve(target);
120
+ if (v.equals(P.BROADCAST)) throw new InputError('cannot name the broadcast address');
121
+ this.lights[name] = v.toString('hex');
122
+ }
123
+
124
+ removeName(name) {
125
+ if (!Object.hasOwn(this.lights, name)) throw new InputError(`no light named ${JSON.stringify(name)}`);
126
+ delete this.lights[name];
127
+ }
128
+ }