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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * High-level controller shared by the CLI, the HTTP API and the MCP server.
3
+ *
4
+ * - connects lazily, serialises operations and reconnects once if the link dropped
5
+ * - disconnects after `idleTimeout` seconds without commands, so the phone app can use the mesh again
6
+ * (0 = keep the link open)
7
+ * - accepts light names from the config file ("living"), "all", or a raw vaddr ("00000000")
8
+ */
9
+ import { InputError, LoginError } from './errors.js';
10
+ import { log } from './log.js';
11
+ import * as P from './protocol.js';
12
+ import { OdelicMesh } from './client.js';
13
+ import { Config } from './config.js';
14
+
15
+ /** Integer from a number or numeric string (like Python's int()); throws InputError otherwise. */
16
+ export function toInt(v, what = 'value') {
17
+ const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v;
18
+ if (typeof n !== 'number' || !Number.isFinite(n)) throw new InputError(`${what} must be a number (got ${JSON.stringify(v)})`);
19
+ return Math.trunc(n);
20
+ }
21
+
22
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
23
+ const cmpBuf = (a, b) => Buffer.compare(a.vaddr, b.vaddr);
24
+
25
+ export class Controller {
26
+ /**
27
+ * @param {string|null} [appId] default: $ODELIC_ID / config file
28
+ * @param {{node?: string|null, config?: Config, idleTimeout?: number|string|null, mesh?: OdelicMesh}} [opts]
29
+ * idleTimeout: seconds before an idle link is released; 0 = stay connected;
30
+ * null = $ODELIC_IDLE_TIMEOUT / config file / 60 s.
31
+ */
32
+ constructor(appId = null, { node = null, config = null, idleTimeout = null, mesh = null } = {}) {
33
+ this.config = config ?? Config.load();
34
+ const aid = this.config.appId(appId);
35
+ if (!aid && !mesh) throw new InputError('no app ID: pass --id, set ODELIC_ID, or run `odelic setup --id <ID>`');
36
+ this.mesh = mesh ?? new OdelicMesh(aid, { device: node ?? this.config.node });
37
+ this.idleTimeout = this.config.idle(idleTimeout);
38
+ this._queue = Promise.resolve();
39
+ this._idle = null;
40
+ }
41
+
42
+ // ------------------------------------------------------------ connection handling
43
+ /** Run `fn` after every previously queued operation (a simple mutex). */
44
+ _locked(fn) {
45
+ const run = this._queue.then(fn);
46
+ this._queue = run.catch(() => {});
47
+ return run;
48
+ }
49
+
50
+ _run(fn) {
51
+ return this._locked(async () => {
52
+ this._cancelIdle();
53
+ try {
54
+ for (let attempt = 0; ; attempt++) {
55
+ if (!this.mesh.isConnected) await this.mesh.connect();
56
+ try {
57
+ return await fn(this.mesh);
58
+ } catch (e) {
59
+ // retry once when the BLE link dropped under us; input / login errors are final
60
+ if (attempt || this.mesh.isConnected || e instanceof InputError || e instanceof LoginError) throw e;
61
+ log.info(`link lost (${e.message}), reconnecting`);
62
+ await this.mesh.disconnect();
63
+ }
64
+ }
65
+ } finally {
66
+ this._armIdle();
67
+ }
68
+ });
69
+ }
70
+
71
+ _cancelIdle() {
72
+ if (this._idle) clearTimeout(this._idle);
73
+ this._idle = null;
74
+ }
75
+
76
+ _armIdle() {
77
+ if (!this.idleTimeout || !this.mesh.isConnected) return;
78
+ this._idle = setTimeout(() => {
79
+ this._locked(async () => {
80
+ this._idle = null;
81
+ if (this.mesh.isConnected) {
82
+ log.info('idle, disconnecting');
83
+ await this.mesh.disconnect();
84
+ }
85
+ }).catch((e) => log.warning(`idle disconnect failed: ${e.message}`));
86
+ }, this.idleTimeout * 1000);
87
+ this._idle.unref?.();
88
+ }
89
+
90
+ /** Connect now (otherwise the first command connects). */
91
+ async connect() {
92
+ await this._run(async () => {});
93
+ }
94
+
95
+ async close() {
96
+ this._cancelIdle();
97
+ await this._locked(() => this.mesh.disconnect());
98
+ }
99
+
100
+ get connected() {
101
+ return this.mesh.isConnected;
102
+ }
103
+
104
+ get keepConnected() {
105
+ return !this.idleTimeout;
106
+ }
107
+
108
+ info() {
109
+ return { connected: this.connected, node: this.mesh.nodeName, mode: this.keepConnected ? 'keep' : 'idle', idle_timeout: this.idleTimeout };
110
+ }
111
+
112
+ // ------------------------------------------------------------ helpers
113
+ _label(v) {
114
+ return v.equals(P.BROADCAST) ? 'all' : (this.config.nameOf(v) ?? v.toString('hex'));
115
+ }
116
+
117
+ _done(action, v, extra = {}) {
118
+ return { ok: true, action, target: this._label(v), ...extra };
119
+ }
120
+
121
+ // ------------------------------------------------------------ actions
122
+ async on(target = 'all') {
123
+ const v = this.config.resolve(target);
124
+ await this._run((m) => m.on(v));
125
+ return this._done('on', v);
126
+ }
127
+
128
+ async off(target = 'all') {
129
+ const v = this.config.resolve(target);
130
+ await this._run((m) => m.off(v));
131
+ return this._done('off', v);
132
+ }
133
+
134
+ /** color 0 (warm) .. 100 (cool), brightness 1..100 % */
135
+ async setLight(target = 'all', color = 50, brightness = 100) {
136
+ const v = this.config.resolve(target);
137
+ color = clamp(toInt(color, 'color'), 0, 100);
138
+ brightness = clamp(toInt(brightness, 'brightness'), 1, 100);
139
+ await this._run((m) => m.setLight({ color, brightness, target: v }));
140
+ return this._done('light', v, { color, brightness });
141
+ }
142
+
143
+ async nightLight(target = 'all', level = 0) {
144
+ const v = this.config.resolve(target);
145
+ level = clamp(toInt(level, 'level'), 0, 2);
146
+ await this._run((m) => m.nightLight(level, v));
147
+ return this._done('night', v, { level });
148
+ }
149
+
150
+ /** action: "on", "off" or "light" (uses color / brightness) */
151
+ async group(groupId, action = 'on', color = 50, brightness = 100) {
152
+ const id = toInt(groupId, 'group id');
153
+ if (action === 'light') {
154
+ const c = clamp(toInt(color, 'color'), 0, 100);
155
+ const b = clamp(toInt(brightness, 'brightness'), 1, 100);
156
+ await this._run((m) => m.setGroup(id, { color: c, brightness: b }));
157
+ } else if (action === 'on' || action === 'off') {
158
+ await this._run((m) => m.setGroup(id, { on: action === 'on' }));
159
+ } else {
160
+ throw new InputError('action must be on, off or light');
161
+ }
162
+ return { ok: true, action: `group_${action}`, group: id };
163
+ }
164
+
165
+ async scene(index, save = false) {
166
+ const i = toInt(index, 'scene index');
167
+ await this._run((m) => (save ? m.saveScene(i) : m.playScene(i)));
168
+ return { ok: true, action: save ? 'scene_save' : 'scene_play', index: i };
169
+ }
170
+
171
+ async changeColor(target) {
172
+ const v = this.config.resolve(target);
173
+ await this._run((m) => m.changeColor(v));
174
+ return this._done('change_color', v);
175
+ }
176
+
177
+ /** Lights answering a mesh ping, plus named lights that did not answer (online: false). */
178
+ async lights(timeout = 3) {
179
+ const devs = await this._run((m) => m.devices(timeout));
180
+ const out = [];
181
+ const seen = new Set();
182
+ for (const d of [...devs].sort(cmpBuf)) {
183
+ const hex = d.vaddr.toString('hex');
184
+ seen.add(hex);
185
+ out.push({ name: this.config.nameOf(d.vaddr), vaddr: hex, online: true, mac: d.mac, product: d.product, version: d.version });
186
+ }
187
+ for (const [name, v] of Object.entries(this.config.lights)) {
188
+ if (!seen.has(v)) out.push({ name, vaddr: v, online: false });
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** State of one light, or of every responding light for "all". */
194
+ async status(target = 'all') {
195
+ const v = this.config.resolve(target);
196
+ if (v.equals(P.BROADCAST)) {
197
+ const devs = await this._run((m) => m.devices());
198
+ const out = [];
199
+ for (const d of [...devs].sort(cmpBuf)) out.push(await this.status(d.vaddr));
200
+ return out;
201
+ }
202
+ const st = await this._run((m) => m.status(v));
203
+ if (st === null) return { name: this.config.nameOf(v), vaddr: v.toString('hex'), online: false };
204
+ return { name: this.config.nameOf(v), online: true, ...st.toJSON() };
205
+ }
206
+
207
+ // ------------------------------------------------------------ names (no BLE needed)
208
+ nameLight(name, target) {
209
+ this.config.setName(name, target);
210
+ this.config.save();
211
+ return { ok: true, name, vaddr: this.config.lights[name] };
212
+ }
213
+
214
+ forgetName(name) {
215
+ this.config.removeName(name);
216
+ this.config.save();
217
+ return { ok: true, removed: name };
218
+ }
219
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Pairlink connected-mesh crypto.
3
+ *
4
+ * Reverse-engineered from libnative-lib.so in the ODELIC app (jp.co.odelic.smt.remote10):
5
+ *
6
+ * - key material = HomeID (4B, little endian) and password (4 ASCII bytes) interleaved
7
+ * -> h0 p0 h1 p1 h2 p2 h3 p3
8
+ * - login key = material + "LOGINKEY" (AES-128)
9
+ * - event key = material + "EVENTKEY" (AES-128)
10
+ * - login : node sends 02 20 <AES_LK(homeid | session4 | pad)>,
11
+ * we answer 01 16 03 <AES_LK(homeid | pwd | session4 | pad)>
12
+ * - data packets : 03 <dst4> <ch> <payload> -> 06 <dst4> <ch> XOR4(AES_EK(pkcs7(payload)), session4)
13
+ */
14
+ import { createCipheriv, createDecipheriv } from 'node:crypto';
15
+
16
+ import { InputError, LoginError } from './errors.js';
17
+
18
+ /** AES-128-ECB without padding; `data` must be a multiple of 16 bytes. */
19
+ export function aes128(key, data, { decrypt = false } = {}) {
20
+ const c = (decrypt ? createDecipheriv : createCipheriv)('aes-128-ecb', key, null);
21
+ c.setAutoPadding(false);
22
+ return Buffer.concat([c.update(data), c.final()]);
23
+ }
24
+
25
+ export function pkcs7Pad(data) {
26
+ const n = 16 - (data.length % 16);
27
+ return Buffer.concat([data, Buffer.alloc(n, n)]);
28
+ }
29
+
30
+ export function pkcs7Unpad(data) {
31
+ const n = data.length ? data[data.length - 1] : 0;
32
+ if (n < 1 || n > 16 || n > data.length || !data.subarray(-n).every((b) => b === n)) {
33
+ throw new InputError('bad padding');
34
+ }
35
+ return data.subarray(0, data.length - n);
36
+ }
37
+
38
+ /** Keys for one mesh (HomeID + password) and the per-connection session value. */
39
+ export class MeshCrypto {
40
+ /**
41
+ * @param {number} homeid
42
+ * @param {string} password 4 ASCII characters
43
+ */
44
+ constructor(homeid, password) {
45
+ if (!Number.isInteger(homeid) || homeid < 0 || homeid > 0xffffffff) {
46
+ throw new InputError(`HomeID must be a 32-bit unsigned integer (got ${homeid})`);
47
+ }
48
+ if (typeof password !== 'string' || !/^[\x00-\x7f]{4}$/.test(password)) {
49
+ throw new InputError('password must be 4 ASCII characters');
50
+ }
51
+ this.homeid = Buffer.alloc(4);
52
+ this.homeid.writeUInt32LE(homeid);
53
+ this.password = Buffer.from(password, 'ascii');
54
+ const mat = Buffer.alloc(8);
55
+ for (let i = 0; i < 4; i++) {
56
+ mat[2 * i] = this.homeid[i];
57
+ mat[2 * i + 1] = this.password[i];
58
+ }
59
+ this._loginKey = Buffer.concat([mat, Buffer.from('LOGINKEY')]);
60
+ this._eventKey = Buffer.concat([mat, Buffer.from('EVENTKEY')]);
61
+ /** @type {Buffer|null} */
62
+ this.session = null;
63
+ }
64
+
65
+ /** `challenge` = the 16 encrypted bytes following `02 20`. Returns the login packet. */
66
+ answerChallenge(challenge) {
67
+ const plain = aes128(this._loginKey, Buffer.from(challenge).subarray(0, 16), { decrypt: true });
68
+ if (!plain.subarray(0, 4).equals(this.homeid)) {
69
+ throw new LoginError(`node belongs to another HomeID (${plain.readUInt32LE(0)})`);
70
+ }
71
+ this.session = Buffer.from(plain.subarray(4, 8));
72
+ const body = aes128(this._loginKey, pkcs7Pad(Buffer.concat([this.homeid, this.password, this.session])));
73
+ return Buffer.concat([Buffer.from([0x01, 0x16, 0x03]), body]);
74
+ }
75
+
76
+ _xor(data) {
77
+ const k = this.session;
78
+ return Buffer.from(data.map((b, i) => b ^ k[i % 4]));
79
+ }
80
+
81
+ /** `03 <dst4> <ch> <payload>` -> `06 <dst4> <ch> <cipher>` */
82
+ encrypt(plain) {
83
+ plain = Buffer.from(plain);
84
+ if (this.session === null) throw new LoginError('not logged in');
85
+ if (plain.length < 7 || plain[0] !== 0x03) throw new InputError('expected a data-event packet (type 3)');
86
+ const cipher = aes128(this._eventKey, pkcs7Pad(plain.subarray(6)));
87
+ return Buffer.concat([Buffer.from([0x06]), plain.subarray(1, 6), this._xor(cipher)]);
88
+ }
89
+
90
+ /** `06 <src/dst4> <ch> <cipher>` -> `03 <...> <payload>`; other packet types pass through. */
91
+ decrypt(packet) {
92
+ packet = Buffer.from(packet);
93
+ if (!packet.length || packet[0] !== 0x06 || this.session === null) return packet;
94
+ let body = this._xor(packet.subarray(6));
95
+ body = body.subarray(0, body.length - (body.length % 16));
96
+ const plain = pkcs7Unpad(aes128(this._eventKey, body, { decrypt: true }));
97
+ return Buffer.concat([Buffer.from([0x03]), packet.subarray(1, 6), plain]);
98
+ }
99
+ }
package/src/errors.js ADDED
@@ -0,0 +1,17 @@
1
+ /** Error types. `InputError` = bad user input (HTTP 400); the others come from the mesh / BLE side. */
2
+
3
+ export class OdelicError extends Error {
4
+ constructor(message, options) {
5
+ super(message, options);
6
+ this.name = this.constructor.name;
7
+ }
8
+ }
9
+
10
+ /** Invalid argument: unknown light name, malformed vaddr / ID, out-of-range number, ... */
11
+ export class InputError extends OdelicError {}
12
+
13
+ /** The node rejected the login (wrong HomeID / password, or another controller is connected). */
14
+ export class LoginError extends OdelicError {}
15
+
16
+ /** No mesh node found, or the BLE link could not be established. */
17
+ export class ConnectionError extends OdelicError {}
package/src/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /** Unofficial Node.js library for ODELIC Bluetooth lights (Pairlink connected-mesh). */
2
+ export { OdelicMesh, findNodes } from './client.js';
3
+ export { Config, configPath, parseIdle } from './config.js';
4
+ export { Controller } from './controller.js';
5
+ export { MeshCrypto } from './crypto.js';
6
+ export { ConnectionError, InputError, LoginError, OdelicError } from './errors.js';
7
+ export { setLogLevel } from './log.js';
8
+ export { BROADCAST, Device, LightState, parseAppId } from './protocol.js';
9
+ export * as protocol from './protocol.js';
10
+ export { serve } from './server.js';
11
+ export { VERSION } from './version.js';
package/src/log.js ADDED
@@ -0,0 +1,20 @@
1
+ /** Minimal leveled logger writing to stderr (stdout stays clean for the CLI output and MCP stdio). */
2
+
3
+ const LEVELS = { debug: 10, info: 20, warning: 30, error: 40, silent: 100 };
4
+ let current = LEVELS[String(process.env.ODELIC_LOG || '').toLowerCase()] ?? LEVELS.warning;
5
+
6
+ export function setLogLevel(level) {
7
+ if (!(level in LEVELS)) throw new RangeError(`unknown log level ${level}`);
8
+ current = LEVELS[level];
9
+ }
10
+
11
+ function emit(level, args) {
12
+ if (LEVELS[level] >= current) console.error('odelic:', ...args);
13
+ }
14
+
15
+ export const log = {
16
+ debug: (...a) => emit('debug', a),
17
+ info: (...a) => emit('info', a),
18
+ warning: (...a) => emit('warning', a),
19
+ error: (...a) => emit('error', a),
20
+ };
package/src/mcp.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * MCP server exposing ODELIC lights to AI assistants (stdio).
3
+ *
4
+ * npm install odelic @modelcontextprotocol/sdk zod
5
+ * odelic-mcp # reads the ID from ODELIC_ID or the config file (`odelic setup --id ...`)
6
+ *
7
+ * The BLE link is opened on the first command. By default it is released after 60 s without
8
+ * commands so the official phone app can still connect; set ODELIC_IDLE_TIMEOUT (seconds, 0 = stay
9
+ * connected) or `odelic setup --keep-connected` to change that.
10
+ */
11
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import { z } from 'zod';
14
+
15
+ import { Controller } from './controller.js';
16
+ import { VERSION } from './version.js';
17
+
18
+ export const INSTRUCTIONS = `Controls ODELIC Bluetooth ceiling/room lights in the user's home.
19
+ Targets: a light name (see list_lights), or "all" for every light.
20
+ Colour is 0 = warm/電球色 (orange) ... 100 = cool/昼光色 (bluish white); brightness is 1..100 %.
21
+ Commands are fire-and-forget; use light_status to confirm the result if needed.`;
22
+
23
+ const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false };
24
+ const ACTION = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false };
25
+ const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, openWorldHint: false };
26
+
27
+ const target = z.string().default('all').describe('light name, vaddr, or "all"');
28
+ const color = z.number().int().min(0).max(100).default(50).describe('0 = warm/電球色 .. 50 = neutral .. 100 = cool/昼光色');
29
+ const brightness = z.number().int().min(1).max(100).default(100).describe('1..100 (%)');
30
+
31
+ const result = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] });
32
+
33
+ /**
34
+ * Build the MCP server.
35
+ * @param {() => Controller} getController called lazily on the first tool call
36
+ */
37
+ export function createMcpServer(getController = lazyController()) {
38
+ const server = new McpServer({ name: 'odelic', version: VERSION }, { instructions: INSTRUCTIONS });
39
+ const ctl = () => getController();
40
+ const tool = (name, description, annotations, inputSchema, fn) =>
41
+ server.registerTool(name, { description, annotations, inputSchema }, async (args) => {
42
+ try {
43
+ return result(await fn(args));
44
+ } catch (e) {
45
+ return { ...result({ ok: false, error: `${e.name}: ${e.message}` }), isError: true };
46
+ }
47
+ });
48
+
49
+ tool('list_lights', 'List the lights in the mesh (name, vaddr, online, product). Takes a few seconds.',
50
+ READ_ONLY, {}, () => ctl().lights());
51
+ tool('light_status', 'Current state of a light: on/off, colour (0 warm..100 cool) and brightness (%).',
52
+ READ_ONLY, { target }, async (a) => {
53
+ const res = await ctl().status(a.target);
54
+ return Array.isArray(res) ? { lights: res } : res;
55
+ });
56
+ tool('turn_on', 'Turn a light (or "all") on with its previous colour and brightness.',
57
+ ACTION, { target }, (a) => ctl().on(a.target));
58
+ tool('turn_off', 'Turn a light (or "all") off.', ACTION, { target }, (a) => ctl().off(a.target));
59
+ tool('set_light', 'Turn on with a given colour and brightness.',
60
+ ACTION, { target, color, brightness }, (a) => ctl().setLight(a.target, a.color, a.brightness));
61
+ tool('night_light', 'Very dim warm night light. level 0..2.',
62
+ ACTION, { target, level: z.number().int().min(0).max(2).default(0) }, (a) => ctl().nightLight(a.target, a.level));
63
+ tool('control_group', 'Control a group configured in the ODELIC app. action: "on", "off" or "light" (uses color/brightness).',
64
+ ACTION, { group_id: z.number().int().min(0).max(255), action: z.enum(['on', 'off', 'light']).default('on'), color, brightness },
65
+ (a) => ctl().group(a.group_id, a.action, a.color, a.brightness));
66
+ tool('play_scene', 'Recall a scene saved in the lights (index as in the app).',
67
+ ACTION, { index: z.number().int().min(0).max(255) }, (a) => ctl().scene(a.index, false));
68
+ tool('save_scene', 'Store the current state of all lights as scene `index` (overwrites that scene).',
69
+ DESTRUCTIVE, { index: z.number().int().min(0).max(255) }, (a) => ctl().scene(a.index, true));
70
+ tool('name_light', 'Give a light (vaddr from list_lights) a friendly name such as "living" or "リビング".',
71
+ { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, { name: z.string().min(1), vaddr: z.string() },
72
+ (a) => ctl().nameLight(a.name, a.vaddr));
73
+ tool('forget_light_name', 'Remove a light name from the config.',
74
+ DESTRUCTIVE, { name: z.string() }, (a) => ctl().forgetName(a.name));
75
+ return server;
76
+ }
77
+
78
+ function lazyController() {
79
+ let c = null;
80
+ // idle timeout from $ODELIC_IDLE_TIMEOUT / config (0 = stay connected)
81
+ return () => (c ??= new Controller());
82
+ }
83
+
84
+ /** Run the MCP server on stdio (logs go to stderr). */
85
+ export async function main() {
86
+ const getController = lazyController();
87
+ const server = createMcpServer(getController);
88
+ await server.connect(new StdioServerTransport());
89
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Packet builders/parsers for ODELIC lights on the Pairlink mesh.
3
+ *
4
+ * Mirrors jp.co.odelic.smt.remote03.act.module.ControllerAct in "central" (Tiger) mode:
5
+ * 03 <dst vaddr 4B> <channel> <own vaddr 4B> <msg id> <args...>
6
+ */
7
+ import { InputError } from './errors.js';
8
+
9
+ export const BROADCAST = Buffer.from([0xff, 0xff, 0xff, 0xff]);
10
+
11
+ // channels
12
+ export const CH_TO_LIGHT = 0x20;
13
+ export const CH_TO_LIGHT_BROADCAST = 0x2a;
14
+ export const CH_PING = 0xfe;
15
+ export const CH_PING_RESPONSE = 0xff;
16
+
17
+ // message ids (MeshProfile.DATAEVENT_MSGID_*)
18
+ export const MSG_LIGHT = 0xc0; // SM_BRIGHT_LIGHT
19
+ export const MSG_LIGHT_GROUP = 0xc1; // SM_BRIGHT_LIGHT_GROUP
20
+ export const MSG_CHANGE_COLOR = 0xe2; // SM_CHANGE_COLOR (cycle colour preset)
21
+ export const MSG_SCENE_SAVE = 0x40; // SM_SCENE_IN
22
+ export const MSG_SCENE_PLAY = 0x42; // SM_SCENE_PLAY
23
+ export const MSG_STATUS = 0x70; // SM_STATUS
24
+ export const MSG_STATUS_RESPONSES = [0x71, 0x35]; // STATUS_MAIN / STATE_RESPONSE_FD
25
+
26
+ // special (x, y) pairs
27
+ export const XY_ON = [55, 55];
28
+ export const XY_OFF = [50, 50];
29
+
30
+ export const X_MAX = 20; // colour: 0 = warm (電球色) ... 20 = cool (昼光色)
31
+ export const Y_MAX = 19; // brightness: 0 = brightest ... 19 = dimmest
32
+
33
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
34
+
35
+ /** 0 (warmest) .. 100 (coolest) -> x 0..20 */
36
+ export function colorToX(color) {
37
+ return clamp(Math.round((color * X_MAX) / 100), 0, X_MAX);
38
+ }
39
+
40
+ /** 1..100 % -> y 19..0 (same rule as the app: (100 - b) / 5, clamped) */
41
+ export function brightnessToY(brightness) {
42
+ return clamp(Math.floor((100 - brightness) / 5), 0, Y_MAX);
43
+ }
44
+
45
+ /** 4-byte vaddr from a Buffer/Uint8Array, a number (little endian) or a hex string ("00000000", "00:00:00:00"). */
46
+ export function vaddr(v) {
47
+ let b;
48
+ if (v instanceof Uint8Array) {
49
+ b = Buffer.from(v);
50
+ } else if (typeof v === 'number') {
51
+ if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) throw new InputError(`vaddr out of range: ${v}`);
52
+ b = Buffer.alloc(4);
53
+ b.writeUInt32LE(v);
54
+ } else {
55
+ const s = String(v).replace(/:/g, '');
56
+ if (!/^[0-9a-fA-F]*$/.test(s) || s.length % 2) throw new InputError(`invalid vaddr: ${v}`);
57
+ b = Buffer.from(s, 'hex');
58
+ }
59
+ if (b.length !== 4) throw new InputError('vaddr must be 4 bytes');
60
+ return b;
61
+ }
62
+
63
+ /** Concatenate Buffers and byte arrays; like Python's bytes(), array values must be integers 0..255. */
64
+ function bytes(...parts) {
65
+ return Buffer.concat(parts.map((p) => {
66
+ if (Buffer.isBuffer(p)) return p;
67
+ for (const v of p) {
68
+ if (!Number.isInteger(v) || v < 0 || v > 255) throw new InputError(`byte value out of range: ${v}`);
69
+ }
70
+ return Buffer.from(p);
71
+ }));
72
+ }
73
+
74
+ export function dataEvent(dst, channel, payload) {
75
+ return bytes([0x03], dst, [channel], payload);
76
+ }
77
+
78
+ /** ControllerAct.setlight: on/off/colour/brightness for one light or all (broadcast). */
79
+ export function light(own, dst, x, y, index = 0) {
80
+ const ch = dst.equals(BROADCAST) ? CH_TO_LIGHT_BROADCAST : CH_TO_LIGHT;
81
+ return dataEvent(dst, ch, bytes(own, [MSG_LIGHT, index, x, y, 0, 0, 0, 0]));
82
+ }
83
+
84
+ /** ControllerAct.sendgroup_new */
85
+ export function groupLight(own, groupId, x, y) {
86
+ return dataEvent(BROADCAST, CH_TO_LIGHT, bytes(own, [MSG_LIGHT_GROUP, x, y, 0, 0, 0, 0, 0, 0, groupId]));
87
+ }
88
+
89
+ export function changeColor(own, dst) {
90
+ return dataEvent(dst, CH_TO_LIGHT, bytes(own, [MSG_CHANGE_COLOR]));
91
+ }
92
+
93
+ export function scene(own, index, save, dst = BROADCAST) {
94
+ const msg = save ? MSG_SCENE_SAVE : MSG_SCENE_PLAY;
95
+ return dataEvent(dst, CH_TO_LIGHT, bytes(own, [msg, 0, 0, 0, 0, 0, 0, index]));
96
+ }
97
+
98
+ export function statusRequest(own, dst) {
99
+ return dataEvent(dst, CH_TO_LIGHT, bytes(own, [MSG_STATUS]));
100
+ }
101
+
102
+ export function pingAll(own) {
103
+ return dataEvent(BROADCAST, CH_PING, own);
104
+ }
105
+
106
+ export class Device {
107
+ constructor({ vaddr, mac, product, version }) {
108
+ this.vaddr = vaddr;
109
+ this.mac = mac;
110
+ this.product = product;
111
+ this.version = version;
112
+ }
113
+
114
+ toString() {
115
+ return `vaddr=${this.vaddr.toString('hex')} mac=${this.mac} product=${this.product} ver=${this.version}`;
116
+ }
117
+ }
118
+
119
+ /** MeshCommon.GetPingResponse; `p` is a decrypted type-3 packet with channel 0xFF. */
120
+ export function parsePingResponse(p) {
121
+ if (p.length < 20 || p[0] !== 0x03 || p[5] !== CH_PING_RESPONSE) return null;
122
+ const mac = [...p.subarray(6, 12)].reverse().map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(':');
123
+ const product = p[16] | (p[17] << 8);
124
+ return new Device({ vaddr: Buffer.from(p.subarray(12, 16)), mac, product, version: `${product}.${p[18]}.${p[19]}` });
125
+ }
126
+
127
+ export class LightState {
128
+ constructor({ vaddr, on, x, y }) {
129
+ this.vaddr = vaddr;
130
+ this.on = on;
131
+ this.x = x;
132
+ this.y = y;
133
+ }
134
+
135
+ /** 0 = warm .. 100 = cool, null if unknown (light reports a plain "on"). */
136
+ get color() {
137
+ return !this.on || this.x > X_MAX ? null : this.x * 5;
138
+ }
139
+
140
+ /** 1..100 %, 0 when off, null if unknown. */
141
+ get brightness() {
142
+ if (!this.on) return 0;
143
+ return this.y > Y_MAX ? null : 100 - this.y * 5;
144
+ }
145
+
146
+ toJSON() {
147
+ return { vaddr: this.vaddr.toString('hex'), on: this.on, color: this.color, brightness: this.brightness, x: this.x, y: this.y };
148
+ }
149
+ }
150
+
151
+ /** Status reply (MeshBlePresenter.syncState): `03 <dst4> <ch> <src4> 71 <x> <y> ...` */
152
+ export function parseStatus(p) {
153
+ if (p.length < 13 || p[0] !== 0x03 || !MSG_STATUS_RESPONSES.includes(p[10])) return null;
154
+ const x = p[11];
155
+ const y = p[12];
156
+ return new LightState({ vaddr: Buffer.from(p.subarray(6, 10)), on: !(x === XY_OFF[0] && y === XY_OFF[1]), x, y });
157
+ }
158
+
159
+ /** The app shows "ID:<HomeID><4-char password>", e.g. "ID:12345678" -> { homeid: 1234, password: "5678" }. */
160
+ export function parseAppId(appId) {
161
+ let s = String(appId).trim();
162
+ if (s.toUpperCase().startsWith('ID:')) s = s.slice(3);
163
+ if (s.length < 5) throw new InputError(`ID looks too short: ${JSON.stringify(String(appId))}`);
164
+ const head = s.slice(0, -4);
165
+ if (!/^\d+$/.test(head)) throw new InputError(`ID must start with the numeric HomeID: ${JSON.stringify(String(appId))}`);
166
+ return { homeid: Number(head), password: s.slice(-4) };
167
+ }
168
+
169
+ /**
170
+ * HomeID advertised by a mesh node (manufacturer id 0xFFFF, c0/c1 ff 01|02 <homeid LE>).
171
+ * `manufacturerData` is the raw AD field as noble reports it, i.e. starting with the 2-byte company id.
172
+ */
173
+ export function advHomeid(manufacturerData) {
174
+ const m = manufacturerData;
175
+ if (!m || m.length < 9 || m[0] !== 0xff || m[1] !== 0xff) return null;
176
+ const v = m.subarray(2);
177
+ if ((v[0] === 0xc0 || v[0] === 0xc1) && v[1] === 0xff && (v[2] === 1 || v[2] === 2)) return v.readUInt32LE(3);
178
+ return null;
179
+ }