odelic 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -2
- package/bin/odelic-mcp.js +5 -0
- package/bin/odelic.js +6 -0
- 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 +125 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +354 -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 +210 -0
- package/dist/controller.js.map +1 -0
- package/dist/crypto.d.ts +25 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +94 -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/dist/index.js +12 -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 +80 -4
- package/src/cli.ts +267 -0
- package/src/client.ts +414 -0
- package/src/config.ts +142 -0
- package/src/controller.ts +267 -0
- package/src/crypto.ts +100 -0
- package/src/errors.ts +17 -0
- package/src/index.ts +17 -0
- package/src/log.ts +24 -0
- package/src/mcp.ts +100 -0
- package/src/protocol.ts +205 -0
- package/src/server.ts +195 -0
- package/src/version.ts +1 -0
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
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
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.ts';
|
|
17
|
+
import * as P from './protocol.ts';
|
|
18
|
+
|
|
19
|
+
export const ALL_NAMES: readonly string[] = ['all', '*', '全部', 'すべて'];
|
|
20
|
+
export const DEFAULT_IDLE_TIMEOUT = 60;
|
|
21
|
+
const KEEP_WORDS = ['0', 'never', 'none', 'off', 'keep', 'forever'];
|
|
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
|
+
|
|
31
|
+
/** Seconds before an idle connection is closed; 0 (or "never"/"keep") = stay connected; null = not set. */
|
|
32
|
+
export function parseIdle(value: unknown): number | null {
|
|
33
|
+
if (value === null || value === undefined || value === '') return null;
|
|
34
|
+
if (typeof value === 'string' && KEEP_WORDS.includes(value.trim().toLowerCase())) return 0;
|
|
35
|
+
const v = typeof value === 'string' ? Number(value.trim()) : Number(value);
|
|
36
|
+
if (typeof value === 'boolean' || !Number.isFinite(v)) throw new InputError(`invalid idle timeout: ${String(value)}`);
|
|
37
|
+
if (v < 0) throw new InputError('idle timeout must be >= 0');
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function configPath(): string {
|
|
42
|
+
if (process.env.ODELIC_CONFIG) return expandHome(process.env.ODELIC_CONFIG);
|
|
43
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
44
|
+
return path.join(base, 'odelic', 'config.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function expandHome(p: string): string {
|
|
48
|
+
return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class Config {
|
|
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) {
|
|
60
|
+
data ??= {};
|
|
61
|
+
this.path = file ?? configPath();
|
|
62
|
+
this.id = data.id ?? null;
|
|
63
|
+
this.node = data.node ?? null;
|
|
64
|
+
this.lights = Object.create(null) as Record<string, string>;
|
|
65
|
+
for (const [k, v] of Object.entries(data.lights ?? {})) this.lights[String(k)] = String(v).toLowerCase();
|
|
66
|
+
this.idleTimeout = parseIdle(data.idle_timeout);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
static load(file: string | null = null): Config {
|
|
70
|
+
const p = file ?? configPath();
|
|
71
|
+
let data: ConfigData = {};
|
|
72
|
+
try {
|
|
73
|
+
data = JSON.parse(fs.readFileSync(p, 'utf8')) as ConfigData;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
|
76
|
+
}
|
|
77
|
+
return new Config(data, p);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
save(): void {
|
|
81
|
+
fs.mkdirSync(path.dirname(this.path), { recursive: true });
|
|
82
|
+
const tmp = this.path.replace(/\.[^./]*$/, '') + '.tmp';
|
|
83
|
+
const data: ConfigData = { id: this.id, node: this.node, idle_timeout: this.idleTimeout, lights: this.lights };
|
|
84
|
+
// the ID is the mesh password: keep the file private
|
|
85
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
86
|
+
try {
|
|
87
|
+
fs.chmodSync(tmp, 0o600);
|
|
88
|
+
} catch {
|
|
89
|
+
// not supported on this filesystem
|
|
90
|
+
}
|
|
91
|
+
fs.renameSync(tmp, this.path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Effective app ID: override > $ODELIC_ID > config. */
|
|
95
|
+
appId(override: string | null = null): string | null {
|
|
96
|
+
return override || process.env.ODELIC_ID || this.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Effective idle timeout: override > $ODELIC_IDLE_TIMEOUT > config > 60 s. 0 = stay connected. */
|
|
100
|
+
idle(override: number | string | null = null): number {
|
|
101
|
+
for (const v of [override, process.env.ODELIC_IDLE_TIMEOUT, this.idleTimeout]) {
|
|
102
|
+
const parsed = parseIdle(v);
|
|
103
|
+
if (parsed !== null) return parsed;
|
|
104
|
+
}
|
|
105
|
+
return DEFAULT_IDLE_TIMEOUT;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ------------------------------------------------------------ names
|
|
109
|
+
nameOf(vaddr: Uint8Array): string | null {
|
|
110
|
+
const hex = Buffer.from(vaddr).toString('hex');
|
|
111
|
+
for (const [name, v] of Object.entries(this.lights)) if (v === hex) return name;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** light name / "all" / vaddr hex / bytes -> 4-byte vaddr */
|
|
116
|
+
resolve(target: P.VaddrLike | null | undefined): Buffer {
|
|
117
|
+
if (target === null || target === undefined) return P.BROADCAST;
|
|
118
|
+
if (target instanceof Uint8Array) return P.vaddr(target);
|
|
119
|
+
const t = String(target).trim();
|
|
120
|
+
if (t === '' || ALL_NAMES.includes(t.toLowerCase())) return P.BROADCAST;
|
|
121
|
+
for (const [name, v] of Object.entries(this.lights)) {
|
|
122
|
+
if (name.toLowerCase() === t.toLowerCase()) return Buffer.from(v, 'hex');
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
return P.vaddr(t);
|
|
126
|
+
} catch {
|
|
127
|
+
const known = Object.keys(this.lights).join(', ') || '(none)';
|
|
128
|
+
throw new InputError(`unknown light ${JSON.stringify(String(target))} (known names: ${known})`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setName(name: string, target: P.VaddrLike): void {
|
|
133
|
+
const v = this.resolve(target);
|
|
134
|
+
if (v.equals(P.BROADCAST)) throw new InputError('cannot name the broadcast address');
|
|
135
|
+
this.lights[name] = v.toString('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
removeName(name: string): void {
|
|
139
|
+
if (!Object.hasOwn(this.lights, name)) throw new InputError(`no light named ${JSON.stringify(name)}`);
|
|
140
|
+
delete this.lights[name];
|
|
141
|
+
}
|
|
142
|
+
}
|