odelic 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -91
- package/bin/odelic-mcp.js +1 -1
- package/bin/odelic.js +1 -1
- package/dist/cli.d.ts +58 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +279 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +140 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +448 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +139 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +95 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +239 -0
- package/dist/controller.js.map +1 -0
- package/dist/crypto.d.ts +34 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +111 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +17 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/{src → dist}/index.js +1 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +21 -0
- package/dist/log.js.map +1 -0
- package/dist/mcp.d.ts +23 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +74 -0
- package/dist/mcp.js.map +1 -0
- package/dist/protocol.d.ts +90 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +178 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +187 -0
- package/dist/server.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +19 -7
- package/src/{cli.js → cli.ts} +64 -50
- package/src/client.ts +497 -0
- package/src/{config.js → config.ts} +37 -23
- package/src/controller.ts +294 -0
- package/src/{crypto.js → crypto.ts} +46 -28
- package/src/{errors.js → errors.ts} +2 -2
- package/src/index.ts +17 -0
- package/src/log.ts +24 -0
- package/src/{mcp.js → mcp.ts} +31 -20
- package/src/{protocol.js → protocol.ts} +56 -30
- package/src/{server.js → server.ts} +57 -38
- package/src/version.ts +1 -0
- package/src/client.js +0 -362
- package/src/controller.js +0 -219
- package/src/log.js +0 -20
- package/src/version.js +0 -1
package/src/client.ts
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
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 loggingIn = false;
|
|
165
|
+
private seg: Buffer[] = [];
|
|
166
|
+
private segSeq = 0;
|
|
167
|
+
private readonly listeners = new Set<PacketListener>();
|
|
168
|
+
/** ms timestamp of the last packet we could decrypt (session health signal). */
|
|
169
|
+
lastRx = 0;
|
|
170
|
+
/** True when the session may be out of sync with the node. */
|
|
171
|
+
suspect = false;
|
|
172
|
+
|
|
173
|
+
/** @param appId ID shown in the app, e.g. "12345678" (or pass `homeid` + `password`) */
|
|
174
|
+
constructor(appId: string | null = null, { homeid, password, device = null, scanTimeout = 8 }: OdelicMeshOptions = {}) {
|
|
175
|
+
if (appId !== null && appId !== undefined) ({ homeid, password } = P.parseAppId(appId));
|
|
176
|
+
if (homeid === undefined || password === undefined) {
|
|
177
|
+
throw new InputError("give appId (e.g. '12345678') or homeid + password");
|
|
178
|
+
}
|
|
179
|
+
this.homeid = homeid;
|
|
180
|
+
this.crypto = new MeshCrypto(homeid, password);
|
|
181
|
+
this.device = device;
|
|
182
|
+
this.scanTimeout = scanTimeout;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ------------------------------------------------------------ connection
|
|
186
|
+
private async resolveDevice(): Promise<Peripheral> {
|
|
187
|
+
if (this.device !== null && typeof this.device !== 'string') return this.device as Peripheral;
|
|
188
|
+
let nodes = await findNodes({ homeid: this.homeid, timeout: this.scanTimeout });
|
|
189
|
+
if (typeof this.device === 'string') {
|
|
190
|
+
const want = norm(this.device);
|
|
191
|
+
nodes = nodes.filter((n) => [n.name, n.address, n.peripheral.id].some((v) => v && norm(v) === want));
|
|
192
|
+
}
|
|
193
|
+
if (!nodes.length) throw new ConnectionError(`no mesh node with HomeID ${this.homeid} found`);
|
|
194
|
+
log.info(`using node ${nodes[0].name} (${nodes[0].address}) rssi ${nodes[0].rssi}`);
|
|
195
|
+
return nodes[0].peripheral as Peripheral;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Scan (unless a peripheral was given), connect and log in. `timeout` in seconds. */
|
|
199
|
+
async connect(timeout = 10): Promise<void> {
|
|
200
|
+
const p = await this.resolveDevice();
|
|
201
|
+
await poweredOn(await getNoble());
|
|
202
|
+
this.nodeName = p.advertisement?.localName || p.id;
|
|
203
|
+
this.own = null;
|
|
204
|
+
this.inbox = new Inbox();
|
|
205
|
+
this.loggingIn = true;
|
|
206
|
+
this.peripheral = p;
|
|
207
|
+
p.on('disconnect', this.onDisconnect);
|
|
208
|
+
try {
|
|
209
|
+
await withTimeout(p.connectAsync(), timeout, `could not connect to ${this.nodeName}`);
|
|
210
|
+
const { characteristics } = await withTimeout(
|
|
211
|
+
p.discoverSomeServicesAndCharacteristicsAsync([UUID_SERVICE], [UUID_WRITE, UUID_NOTIFY]),
|
|
212
|
+
timeout, 'GATT discovery timed out');
|
|
213
|
+
this.writeChar = characteristics.find((c) => norm(c.uuid) === UUID_WRITE) ?? null;
|
|
214
|
+
this.notifyChar = characteristics.find((c) => norm(c.uuid) === UUID_NOTIFY) ?? null;
|
|
215
|
+
if (!this.writeChar || !this.notifyChar) throw new ConnectionError(`${this.nodeName} is not an ODELIC mesh node`);
|
|
216
|
+
this.notifyChar.on('data', this.onNotify);
|
|
217
|
+
await this.notifyChar.subscribeAsync();
|
|
218
|
+
await this.write(Buffer.from([0x01, 0x20])); // ask for the login challenge
|
|
219
|
+
await withTimeout(this.login(this.inbox), timeout, 'login timed out');
|
|
220
|
+
} catch (e) {
|
|
221
|
+
await this.disconnect();
|
|
222
|
+
throw e;
|
|
223
|
+
} finally {
|
|
224
|
+
this.loggingIn = false;
|
|
225
|
+
this.inbox = null;
|
|
226
|
+
}
|
|
227
|
+
this.lastRx = Date.now();
|
|
228
|
+
this.suspect = false;
|
|
229
|
+
const vaddrHex = (this.own as Buffer | null)?.toString('hex') ?? '?';
|
|
230
|
+
log.info(`logged in via ${this.nodeName} (own vaddr ${vaddrHex})`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Re-run the login handshake on the existing link to pick up the node's current session.
|
|
235
|
+
*
|
|
236
|
+
* The node answers 08 ("already logged in") but still switches to the new challenge's
|
|
237
|
+
* session, which we answer too, so commands are understood again afterwards.
|
|
238
|
+
* A status of 00 means the node forgot us (reboot); wait for the new vaddr then.
|
|
239
|
+
*/
|
|
240
|
+
async resync(timeout = 8): Promise<void> {
|
|
241
|
+
if (!this.peripheral || this.peripheral.state !== 'connected') throw new LoginError('not connected');
|
|
242
|
+
const oldOwn = this.own ? Buffer.from(this.own) : null;
|
|
243
|
+
const inbox = new Inbox<Buffer>();
|
|
244
|
+
this.own = null;
|
|
245
|
+
this.inbox = inbox;
|
|
246
|
+
this.loggingIn = true;
|
|
247
|
+
try {
|
|
248
|
+
await this.write(Buffer.from([0x01, 0x20]));
|
|
249
|
+
await withTimeout(this.login(inbox, { resync: true, oldOwn }), timeout, 'resync timed out');
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (this.own === null && oldOwn) this.own = oldOwn;
|
|
252
|
+
throw e;
|
|
253
|
+
} finally {
|
|
254
|
+
this.loggingIn = false;
|
|
255
|
+
if (this.inbox === inbox) this.inbox = null;
|
|
256
|
+
}
|
|
257
|
+
this.suspect = false;
|
|
258
|
+
const vaddrHex = (this.own as Buffer | null)?.toString('hex') ?? '?';
|
|
259
|
+
log.warning(`session resynchronised with ${this.nodeName} (vaddr ${vaddrHex})`);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Make sure the link can talk to the node; resync/reconnect when it looks stale. */
|
|
263
|
+
async ensureLive(timeout = 8): Promise<void> {
|
|
264
|
+
if (!this.peripheral || this.peripheral.state !== 'connected' || this.own === null) {
|
|
265
|
+
await this.disconnect();
|
|
266
|
+
await this.connect();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (this.suspect) {
|
|
270
|
+
try {
|
|
271
|
+
await this.resync(timeout);
|
|
272
|
+
} catch {
|
|
273
|
+
await this.disconnect();
|
|
274
|
+
await this.connect();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get isConnected(): boolean {
|
|
280
|
+
return this.peripheral !== null && this.peripheral.state === 'connected' && this.own !== null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private readonly onDisconnect = (): void => {
|
|
284
|
+
log.warning(`node ${this.nodeName} disconnected`);
|
|
285
|
+
this.own = null;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
async disconnect(): Promise<void> {
|
|
289
|
+
const p = this.peripheral;
|
|
290
|
+
if (!p) return;
|
|
291
|
+
this.peripheral = null;
|
|
292
|
+
this.own = null;
|
|
293
|
+
p.removeListener('disconnect', this.onDisconnect);
|
|
294
|
+
this.notifyChar?.removeListener('data', this.onNotify);
|
|
295
|
+
this.writeChar = this.notifyChar = null;
|
|
296
|
+
try {
|
|
297
|
+
await withTimeout(p.disconnectAsync(), 5, 'disconnect timed out');
|
|
298
|
+
} catch (e) {
|
|
299
|
+
log.debug(`disconnect: ${(e as Error).message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async login(inbox: Inbox<Buffer>, { resync = false, oldOwn = null }: { resync?: boolean; oldOwn?: Buffer | null } = {}): Promise<void> {
|
|
304
|
+
let answered = false;
|
|
305
|
+
for (;;) {
|
|
306
|
+
const p = await inbox.next();
|
|
307
|
+
if (p[0] === 0x02 && p[1] === 0x20 && p.length >= 18) {
|
|
308
|
+
await this.write(this.crypto.answerChallenge(p.subarray(2, 18)));
|
|
309
|
+
answered = true;
|
|
310
|
+
} else if (p[0] === 0x02 && p[1] === 0x16 && answered) {
|
|
311
|
+
const status = p.length >= 3 ? p[2] : undefined;
|
|
312
|
+
if (resync && status === 0x08) {
|
|
313
|
+
if (this.own === null && oldOwn) this.own = oldOwn; // 08 keeps our login; no new vaddr is sent
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (status === 0x08) {
|
|
317
|
+
throw new LoginError('node is already in use by another controller ' +
|
|
318
|
+
'(close the ODELIC app / turn off phone Bluetooth and retry)');
|
|
319
|
+
}
|
|
320
|
+
if (status !== 0) throw new LoginError(`login rejected (status ${p.subarray(2).toString('hex')})`);
|
|
321
|
+
if (resync) {
|
|
322
|
+
if (this.own !== null && oldOwn && !this.own.equals(oldOwn)) return; // 00 + fresh vaddr via onNotify
|
|
323
|
+
continue; // 00: wait for the new 01 0a vaddr assignment
|
|
324
|
+
}
|
|
325
|
+
} else if (p[0] === 0x01 && p[1] === 0x0a && p.length >= 6) {
|
|
326
|
+
this.own = Buffer.from(p.subarray(2, 6));
|
|
327
|
+
}
|
|
328
|
+
if (!resync && this.own !== null) return;
|
|
329
|
+
if (resync && this.own !== null && oldOwn && !this.own.equals(oldOwn)) return;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ------------------------------------------------------------ transport
|
|
334
|
+
private async write(data: Buffer): Promise<void> {
|
|
335
|
+
if (!this.writeChar) throw new ConnectionError('not connected');
|
|
336
|
+
log.debug(`-> ${data.toString('hex')}`);
|
|
337
|
+
await this.writeChar.writeAsync(data, true); // write without response
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private readonly onNotify = (raw: Buffer): void => {
|
|
341
|
+
let data: Buffer = Buffer.from(raw);
|
|
342
|
+
log.debug(`<- ${data.toString('hex')}`);
|
|
343
|
+
if (data.length >= 3 && data[0] === 4 && data[1] === 4) { // segmented PDU
|
|
344
|
+
const seq = data[2] & 0x0f;
|
|
345
|
+
const total = data[2] >> 4;
|
|
346
|
+
if (seq !== this.segSeq + 1) {
|
|
347
|
+
this.seg = [];
|
|
348
|
+
this.segSeq = 0;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.seg.push(data.subarray(3));
|
|
352
|
+
this.segSeq = seq;
|
|
353
|
+
if (seq !== total) return;
|
|
354
|
+
data = Buffer.concat(this.seg);
|
|
355
|
+
this.seg = [];
|
|
356
|
+
this.segSeq = 0;
|
|
357
|
+
}
|
|
358
|
+
let plain: Buffer;
|
|
359
|
+
try {
|
|
360
|
+
plain = this.crypto.decrypt(data);
|
|
361
|
+
} catch {
|
|
362
|
+
log.debug(`undecryptable packet ${data.toString('hex')}`);
|
|
363
|
+
if (this.own !== null && !this.loggingIn) this.suspect = true; // node encrypts with a session we do not have
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (plain !== data && plain[0] === 0x03) this.lastRx = Date.now();
|
|
367
|
+
data = plain;
|
|
368
|
+
if (data[0] === 0x01 && data[1] === 0x0a && data.length >= 6) this.own = Buffer.from(data.subarray(2, 6));
|
|
369
|
+
if (this.loggingIn) {
|
|
370
|
+
this.inbox?.push(data);
|
|
371
|
+
} else if (data[0] === 0x02 && data[1] === 0x20 && data.length >= 18 && this.own !== null) {
|
|
372
|
+
// someone else (another process on this Mac shares the link) started a login:
|
|
373
|
+
// the node now uses that challenge's session, so follow it
|
|
374
|
+
if (this.crypto.adoptChallenge(data.subarray(2, 18))) {
|
|
375
|
+
this.suspect = false;
|
|
376
|
+
log.warning('another controller started a login on this link; following its session');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (const cb of [...this.listeners]) {
|
|
380
|
+
try {
|
|
381
|
+
cb(data);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
log.error('listener failed:', e);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
private requireOwn(): Buffer {
|
|
389
|
+
if (this.own === null) throw new LoginError('not connected');
|
|
390
|
+
return this.own;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Encrypt and send a type-3 data-event packet (see protocol.ts). */
|
|
394
|
+
async send(packet: Uint8Array): Promise<void> {
|
|
395
|
+
this.requireOwn();
|
|
396
|
+
const enc = this.crypto.encrypt(packet);
|
|
397
|
+
const mtu = Math.max(20, (this.peripheral?.mtu ?? 23) - 3);
|
|
398
|
+
if (enc.length <= mtu) return this.write(enc);
|
|
399
|
+
const chunk = mtu - 3;
|
|
400
|
+
const total = Math.ceil(enc.length / chunk);
|
|
401
|
+
for (let i = 0; i < total; i++) {
|
|
402
|
+
await this.write(Buffer.concat([Buffer.from([0x01, 0x21, (total << 4) | (i + 1)]),
|
|
403
|
+
enc.subarray(i * chunk, (i + 1) * chunk)]));
|
|
404
|
+
await sleep(15);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Receive every (decrypted) packet. Returns an unsubscribe function. */
|
|
409
|
+
addListener(callback: PacketListener): () => void {
|
|
410
|
+
this.listeners.add(callback);
|
|
411
|
+
return () => this.listeners.delete(callback);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
removeListener(callback: PacketListener): void {
|
|
415
|
+
this.listeners.delete(callback);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ------------------------------------------------------------ actions
|
|
419
|
+
/** Turn on (restores the previous colour/brightness). */
|
|
420
|
+
async on(target: P.VaddrLike = P.BROADCAST): Promise<void> {
|
|
421
|
+
await this.send(P.light(this.requireOwn(), P.vaddr(target), ...P.XY_ON));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async off(target: P.VaddrLike = P.BROADCAST): Promise<void> {
|
|
425
|
+
await this.send(P.light(this.requireOwn(), P.vaddr(target), ...P.XY_OFF));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Turn on with a colour and brightness (or raw x / y). */
|
|
429
|
+
async setLight({ color = null, brightness = 100, target = P.BROADCAST, x = null, y = null }: SetLightOptions = {}): Promise<void> {
|
|
430
|
+
x ??= P.colorToX(color ?? 50);
|
|
431
|
+
y ??= P.brightnessToY(brightness);
|
|
432
|
+
await this.send(P.light(this.requireOwn(), P.vaddr(target), x, y));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Dim warm night light, level 0..2 (what the app does for plain lights). */
|
|
436
|
+
async nightLight(level = 0, target: P.VaddrLike = P.BROADCAST): Promise<void> {
|
|
437
|
+
await this.send(P.light(this.requireOwn(), P.vaddr(target), 0, 17 + Math.max(0, Math.min(2, level))));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Same as setLight but for a group id (as configured in the app). */
|
|
441
|
+
async setGroup(groupId: number, { color = null, brightness = 100, x = null, y = null, on = null }: SetGroupOptions = {}): Promise<void> {
|
|
442
|
+
if (on !== null) [x, y] = on ? P.XY_ON : P.XY_OFF;
|
|
443
|
+
x ??= P.colorToX(color ?? 50);
|
|
444
|
+
y ??= P.brightnessToY(brightness);
|
|
445
|
+
await this.send(P.groupLight(this.requireOwn(), groupId, x, y));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Cycle the colour preset of one light (light_change_color). */
|
|
449
|
+
async changeColor(target: P.VaddrLike): Promise<void> {
|
|
450
|
+
await this.send(P.changeColor(this.requireOwn(), P.vaddr(target)));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async saveScene(index: number): Promise<void> {
|
|
454
|
+
await this.send(P.scene(this.requireOwn(), index, true));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async playScene(index: number): Promise<void> {
|
|
458
|
+
await this.send(P.scene(this.requireOwn(), index, false));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Ping the whole mesh and return the responding lights (`timeout` in seconds). */
|
|
462
|
+
async devices(timeout = 3): Promise<P.Device[]> {
|
|
463
|
+
const found = new Map<string, P.Device>();
|
|
464
|
+
const off = this.addListener((p) => {
|
|
465
|
+
const d = P.parsePingResponse(p);
|
|
466
|
+
if (d) found.set(d.vaddr.toString('hex'), d);
|
|
467
|
+
});
|
|
468
|
+
try {
|
|
469
|
+
await this.send(P.pingAll(this.requireOwn()));
|
|
470
|
+
await sleep(timeout * 1000);
|
|
471
|
+
} finally {
|
|
472
|
+
off();
|
|
473
|
+
}
|
|
474
|
+
return [...found.values()];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Ask one light for its state. Resolves to null on timeout (`timeout` in seconds). */
|
|
478
|
+
async status(target: P.VaddrLike, timeout = 3): Promise<P.LightState | null> {
|
|
479
|
+
const dst = P.vaddr(target);
|
|
480
|
+
let off = () => {};
|
|
481
|
+
let timer: NodeJS.Timeout | undefined;
|
|
482
|
+
const reply = new Promise<P.LightState | null>((resolve) => {
|
|
483
|
+
off = this.addListener((p) => {
|
|
484
|
+
const st = P.parseStatus(p);
|
|
485
|
+
if (st && st.vaddr.equals(dst)) resolve(st);
|
|
486
|
+
});
|
|
487
|
+
timer = setTimeout(() => resolve(null), timeout * 1000);
|
|
488
|
+
});
|
|
489
|
+
try {
|
|
490
|
+
await this.send(P.statusRequest(this.requireOwn(), dst));
|
|
491
|
+
return await reply;
|
|
492
|
+
} finally {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
off();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
@@ -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.
|
|
17
|
-
import * as P from './protocol.
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
}
|