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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +189 -2
  3. package/bin/odelic-mcp.js +5 -0
  4. package/bin/odelic.js +6 -0
  5. package/dist/cli.d.ts +58 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +279 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/client.d.ts +125 -0
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +354 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/config.d.ts +34 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +139 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/controller.d.ts +95 -0
  18. package/dist/controller.d.ts.map +1 -0
  19. package/dist/controller.js +210 -0
  20. package/dist/controller.js.map +1 -0
  21. package/dist/crypto.d.ts +25 -0
  22. package/dist/crypto.d.ts.map +1 -0
  23. package/dist/crypto.js +94 -0
  24. package/dist/crypto.js.map +1 -0
  25. package/dist/errors.d.ts +14 -0
  26. package/dist/errors.d.ts.map +1 -0
  27. package/dist/errors.js +17 -0
  28. package/dist/errors.js.map +1 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +12 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/log.d.ts +18 -0
  34. package/dist/log.d.ts.map +1 -0
  35. package/dist/log.js +21 -0
  36. package/dist/log.js.map +1 -0
  37. package/dist/mcp.d.ts +23 -0
  38. package/dist/mcp.d.ts.map +1 -0
  39. package/dist/mcp.js +74 -0
  40. package/dist/mcp.js.map +1 -0
  41. package/dist/protocol.d.ts +90 -0
  42. package/dist/protocol.d.ts.map +1 -0
  43. package/dist/protocol.js +178 -0
  44. package/dist/protocol.js.map +1 -0
  45. package/dist/server.d.ts +24 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +187 -0
  48. package/dist/server.js.map +1 -0
  49. package/dist/version.d.ts +2 -0
  50. package/dist/version.d.ts.map +1 -0
  51. package/dist/version.js +2 -0
  52. package/dist/version.js.map +1 -0
  53. package/package.json +80 -4
  54. package/src/cli.ts +267 -0
  55. package/src/client.ts +414 -0
  56. package/src/config.ts +142 -0
  57. package/src/controller.ts +267 -0
  58. package/src/crypto.ts +100 -0
  59. package/src/errors.ts +17 -0
  60. package/src/index.ts +17 -0
  61. package/src/log.ts +24 -0
  62. package/src/mcp.ts +100 -0
  63. package/src/protocol.ts +205 -0
  64. package/src/server.ts +195 -0
  65. package/src/version.ts +1 -0
@@ -0,0 +1,205 @@
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.ts';
8
+
9
+ /** A light address: 4 bytes, a number (little endian) or a hex string ("00000000", "00:00:00:00"). */
10
+ export type VaddrLike = Uint8Array | number | string;
11
+
12
+ export const BROADCAST: Buffer = Buffer.from([0xff, 0xff, 0xff, 0xff]);
13
+
14
+ // channels
15
+ export const CH_TO_LIGHT = 0x20;
16
+ export const CH_TO_LIGHT_BROADCAST = 0x2a;
17
+ export const CH_PING = 0xfe;
18
+ export const CH_PING_RESPONSE = 0xff;
19
+
20
+ // message ids (MeshProfile.DATAEVENT_MSGID_*)
21
+ export const MSG_LIGHT = 0xc0; // SM_BRIGHT_LIGHT
22
+ export const MSG_LIGHT_GROUP = 0xc1; // SM_BRIGHT_LIGHT_GROUP
23
+ export const MSG_CHANGE_COLOR = 0xe2; // SM_CHANGE_COLOR (cycle colour preset)
24
+ export const MSG_SCENE_SAVE = 0x40; // SM_SCENE_IN
25
+ export const MSG_SCENE_PLAY = 0x42; // SM_SCENE_PLAY
26
+ export const MSG_STATUS = 0x70; // SM_STATUS
27
+ export const MSG_STATUS_RESPONSES: readonly number[] = [0x71, 0x35]; // STATUS_MAIN / STATE_RESPONSE_FD
28
+
29
+ // special (x, y) pairs
30
+ export const XY_ON = [55, 55] as const;
31
+ export const XY_OFF = [50, 50] as const;
32
+
33
+ export const X_MAX = 20; // colour: 0 = warm (電球色) ... 20 = cool (昼光色)
34
+ export const Y_MAX = 19; // brightness: 0 = brightest ... 19 = dimmest
35
+
36
+ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
37
+
38
+ /** 0 (warmest) .. 100 (coolest) -> x 0..20 */
39
+ export function colorToX(color: number): number {
40
+ return clamp(Math.round((color * X_MAX) / 100), 0, X_MAX);
41
+ }
42
+
43
+ /** 1..100 % -> y 19..0 (same rule as the app: (100 - b) / 5, clamped) */
44
+ export function brightnessToY(brightness: number): number {
45
+ return clamp(Math.floor((100 - brightness) / 5), 0, Y_MAX);
46
+ }
47
+
48
+ /** 4-byte vaddr from a Buffer/Uint8Array, a number (little endian) or a hex string. */
49
+ export function vaddr(v: VaddrLike): Buffer {
50
+ let b: Buffer;
51
+ if (v instanceof Uint8Array) {
52
+ b = Buffer.from(v);
53
+ } else if (typeof v === 'number') {
54
+ if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) throw new InputError(`vaddr out of range: ${v}`);
55
+ b = Buffer.alloc(4);
56
+ b.writeUInt32LE(v);
57
+ } else {
58
+ const s = String(v).replace(/:/g, '');
59
+ if (!/^[0-9a-fA-F]*$/.test(s) || s.length % 2) throw new InputError(`invalid vaddr: ${v}`);
60
+ b = Buffer.from(s, 'hex');
61
+ }
62
+ if (b.length !== 4) throw new InputError('vaddr must be 4 bytes');
63
+ return b;
64
+ }
65
+
66
+ /** Concatenate Buffers and byte arrays; like Python's bytes(), array values must be integers 0..255. */
67
+ function bytes(...parts: (Uint8Array | readonly number[])[]): Buffer {
68
+ return Buffer.concat(parts.map((p) => {
69
+ if (p instanceof Uint8Array) return p;
70
+ for (const v of p) {
71
+ if (!Number.isInteger(v) || v < 0 || v > 255) throw new InputError(`byte value out of range: ${v}`);
72
+ }
73
+ return Buffer.from(p);
74
+ }));
75
+ }
76
+
77
+ export function dataEvent(dst: Uint8Array, channel: number, payload: Uint8Array): Buffer {
78
+ return bytes([0x03], dst, [channel], payload);
79
+ }
80
+
81
+ /** ControllerAct.setlight: on/off/colour/brightness for one light or all (broadcast). */
82
+ export function light(own: Uint8Array, dst: Buffer, x: number, y: number, index = 0): Buffer {
83
+ const ch = dst.equals(BROADCAST) ? CH_TO_LIGHT_BROADCAST : CH_TO_LIGHT;
84
+ return dataEvent(dst, ch, bytes(own, [MSG_LIGHT, index, x, y, 0, 0, 0, 0]));
85
+ }
86
+
87
+ /** ControllerAct.sendgroup_new */
88
+ export function groupLight(own: Uint8Array, groupId: number, x: number, y: number): Buffer {
89
+ return dataEvent(BROADCAST, CH_TO_LIGHT, bytes(own, [MSG_LIGHT_GROUP, x, y, 0, 0, 0, 0, 0, 0, groupId]));
90
+ }
91
+
92
+ export function changeColor(own: Uint8Array, dst: Uint8Array): Buffer {
93
+ return dataEvent(dst, CH_TO_LIGHT, bytes(own, [MSG_CHANGE_COLOR]));
94
+ }
95
+
96
+ export function scene(own: Uint8Array, index: number, save: boolean, dst: Uint8Array = BROADCAST): Buffer {
97
+ const msg = save ? MSG_SCENE_SAVE : MSG_SCENE_PLAY;
98
+ return dataEvent(dst, CH_TO_LIGHT, bytes(own, [msg, 0, 0, 0, 0, 0, 0, index]));
99
+ }
100
+
101
+ export function statusRequest(own: Uint8Array, dst: Uint8Array): Buffer {
102
+ return dataEvent(dst, CH_TO_LIGHT, bytes(own, [MSG_STATUS]));
103
+ }
104
+
105
+ export function pingAll(own: Uint8Array): Buffer {
106
+ return dataEvent(BROADCAST, CH_PING, own);
107
+ }
108
+
109
+ /** A light that answered a mesh ping. */
110
+ export class Device {
111
+ readonly vaddr: Buffer;
112
+ readonly mac: string;
113
+ readonly product: number;
114
+ readonly version: string;
115
+
116
+ constructor({ vaddr, mac, product, version }: { vaddr: Buffer; mac: string; product: number; version: string }) {
117
+ this.vaddr = vaddr;
118
+ this.mac = mac;
119
+ this.product = product;
120
+ this.version = version;
121
+ }
122
+
123
+ toString(): string {
124
+ return `vaddr=${this.vaddr.toString('hex')} mac=${this.mac} product=${this.product} ver=${this.version}`;
125
+ }
126
+ }
127
+
128
+ /** MeshCommon.GetPingResponse; `p` is a decrypted type-3 packet with channel 0xFF. */
129
+ export function parsePingResponse(p: Buffer): Device | null {
130
+ if (p.length < 20 || p[0] !== 0x03 || p[5] !== CH_PING_RESPONSE) return null;
131
+ const mac = [...p.subarray(6, 12)].reverse().map((b) => b.toString(16).toUpperCase().padStart(2, '0')).join(':');
132
+ const product = p[16] | (p[17] << 8);
133
+ return new Device({ vaddr: Buffer.from(p.subarray(12, 16)), mac, product, version: `${product}.${p[18]}.${p[19]}` });
134
+ }
135
+
136
+ export interface LightStateJSON {
137
+ vaddr: string;
138
+ on: boolean;
139
+ /** 0 = warm .. 100 = cool, null if unknown */
140
+ color: number | null;
141
+ /** 1..100 %, 0 when off, null if unknown */
142
+ brightness: number | null;
143
+ x: number;
144
+ y: number;
145
+ }
146
+
147
+ /** State reported by one light. */
148
+ export class LightState {
149
+ readonly vaddr: Buffer;
150
+ readonly on: boolean;
151
+ readonly x: number;
152
+ readonly y: number;
153
+
154
+ constructor({ vaddr, on, x, y }: { vaddr: Buffer; on: boolean; x: number; y: number }) {
155
+ this.vaddr = vaddr;
156
+ this.on = on;
157
+ this.x = x;
158
+ this.y = y;
159
+ }
160
+
161
+ /** 0 = warm .. 100 = cool, null if unknown (light reports a plain "on"). */
162
+ get color(): number | null {
163
+ return !this.on || this.x > X_MAX ? null : this.x * 5;
164
+ }
165
+
166
+ /** 1..100 %, 0 when off, null if unknown. */
167
+ get brightness(): number | null {
168
+ if (!this.on) return 0;
169
+ return this.y > Y_MAX ? null : 100 - this.y * 5;
170
+ }
171
+
172
+ toJSON(): LightStateJSON {
173
+ return { vaddr: this.vaddr.toString('hex'), on: this.on, color: this.color, brightness: this.brightness, x: this.x, y: this.y };
174
+ }
175
+ }
176
+
177
+ /** Status reply (MeshBlePresenter.syncState): `03 <dst4> <ch> <src4> 71 <x> <y> ...` */
178
+ export function parseStatus(p: Buffer): LightState | null {
179
+ if (p.length < 13 || p[0] !== 0x03 || !MSG_STATUS_RESPONSES.includes(p[10])) return null;
180
+ const x = p[11];
181
+ const y = p[12];
182
+ return new LightState({ vaddr: Buffer.from(p.subarray(6, 10)), on: !(x === XY_OFF[0] && y === XY_OFF[1]), x, y });
183
+ }
184
+
185
+ /** The app shows "ID:<HomeID><4-char password>", e.g. "ID:12345678" -> { homeid: 1234, password: "5678" }. */
186
+ export function parseAppId(appId: string): { homeid: number; password: string } {
187
+ let s = String(appId).trim();
188
+ if (s.toUpperCase().startsWith('ID:')) s = s.slice(3);
189
+ if (s.length < 5) throw new InputError(`ID looks too short: ${JSON.stringify(String(appId))}`);
190
+ const head = s.slice(0, -4);
191
+ if (!/^\d+$/.test(head)) throw new InputError(`ID must start with the numeric HomeID: ${JSON.stringify(String(appId))}`);
192
+ return { homeid: Number(head), password: s.slice(-4) };
193
+ }
194
+
195
+ /**
196
+ * HomeID advertised by a mesh node (manufacturer id 0xFFFF, c0/c1 ff 01|02 <homeid LE>).
197
+ * `manufacturerData` is the raw AD field as noble reports it, i.e. starting with the 2-byte company id.
198
+ */
199
+ export function advHomeid(manufacturerData: Buffer | null | undefined): number | null {
200
+ const m = manufacturerData;
201
+ if (!m || m.length < 9 || m[0] !== 0xff || m[1] !== 0xff) return null;
202
+ const v = m.subarray(2);
203
+ if ((v[0] === 0xc0 || v[0] === 0xc1) && v[1] === 0xff && (v[2] === 1 || v[2] === 2)) return v.readUInt32LE(3);
204
+ return null;
205
+ }
package/src/server.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tiny dependency-free HTTP/JSON API on top of Controller.
3
+ *
4
+ * GET /health
5
+ * GET /lights list lights (ping)
6
+ * GET /lights/<target> state ("all" = every light)
7
+ * POST /lights/<target>/on | off
8
+ * POST /lights/<target>/light {"color": 0..100, "brightness": 1..100}
9
+ * POST /lights/<target>/night {"level": 0..2}
10
+ * POST /groups/<id>/on | off | light
11
+ * POST /scenes/<index>/play | save
12
+ *
13
+ * Parameters may be sent as a JSON body or as query parameters. <target> is a light name, "all" or a vaddr.
14
+ * When a token is configured every request needs `Authorization: Bearer <token>` (or ?token=...).
15
+ */
16
+ import { createHash, timingSafeEqual } from 'node:crypto';
17
+ import http from 'node:http';
18
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
19
+ import type { AddressInfo } from 'node:net';
20
+
21
+ import { InputError } from './errors.ts';
22
+ import { log } from './log.ts';
23
+ import { toInt } from './controller.ts';
24
+ import type { Controller } from './controller.ts';
25
+
26
+ export const LOOPBACK: readonly string[] = ['127.0.0.1', 'localhost', '::1'];
27
+ const MAX_BODY = 65536;
28
+
29
+ /** The part of Controller the HTTP API uses. */
30
+ export type HttpController = Pick<Controller,
31
+ 'info' | 'lights' | 'status' | 'on' | 'off' | 'setLight' | 'nightLight' | 'group' | 'scene'>;
32
+
33
+ type Params = Record<string, unknown>;
34
+
35
+ export class HttpError extends Error {
36
+ readonly status: number;
37
+
38
+ constructor(status: number, message: string) {
39
+ super(message);
40
+ this.name = 'HttpError';
41
+ this.status = status;
42
+ }
43
+ }
44
+
45
+ function need(method: string, expected: string): void {
46
+ if (method !== expected) throw new HttpError(405, `use ${expected}`);
47
+ }
48
+
49
+ const num = (v: unknown, def: number) => (v ?? def) as number | string;
50
+
51
+ /** Route one request to the controller. Returns the JSON-able result. */
52
+ export async function dispatch(ctl: HttpController, method: string, path: string, params: Params): Promise<unknown> {
53
+ let parts: string[];
54
+ try {
55
+ parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map(decodeURIComponent);
56
+ } catch {
57
+ throw new HttpError(400, 'bad URL encoding');
58
+ }
59
+ if (parts.length === 1 && parts[0] === 'health') return { ok: true, ...ctl.info() };
60
+ if (!parts.length) throw new HttpError(404, 'not found');
61
+ const [head] = parts;
62
+ if (head === 'lights') {
63
+ if (parts.length === 1) {
64
+ need(method, 'GET');
65
+ return ctl.lights();
66
+ }
67
+ const target = parts[1];
68
+ if (parts.length === 2) {
69
+ need(method, 'GET');
70
+ return ctl.status(target);
71
+ }
72
+ need(method, 'POST');
73
+ const act = parts[2];
74
+ if (act === 'on') return ctl.on(target);
75
+ if (act === 'off') return ctl.off(target);
76
+ if (act === 'light') return ctl.setLight(target, num(params.color, 50), num(params.brightness, 100));
77
+ if (act === 'night') return ctl.nightLight(target, num(params.level, 0));
78
+ } else if (head === 'groups' && parts.length === 3) {
79
+ need(method, 'POST');
80
+ return ctl.group(toInt(parts[1], 'group id'), parts[2], num(params.color, 50), num(params.brightness, 100));
81
+ } else if (head === 'scenes' && parts.length === 3 && (parts[2] === 'play' || parts[2] === 'save')) {
82
+ need(method, 'POST');
83
+ return ctl.scene(toInt(parts[1], 'scene index'), parts[2] === 'save');
84
+ }
85
+ throw new HttpError(404, 'not found');
86
+ }
87
+
88
+ function readBody(req: IncomingMessage): Promise<Buffer> {
89
+ return new Promise((resolve, reject) => {
90
+ const declared = Number(req.headers['content-length'] || 0);
91
+ if (declared > MAX_BODY) {
92
+ reject(new HttpError(413, 'body too large'));
93
+ return;
94
+ }
95
+ const chunks: Buffer[] = [];
96
+ let size = 0;
97
+ req.on('data', (c: Buffer) => {
98
+ size += c.length;
99
+ if (size > MAX_BODY) {
100
+ reject(new HttpError(413, 'body too large'));
101
+ req.destroy();
102
+ } else {
103
+ chunks.push(c);
104
+ }
105
+ });
106
+ req.on('end', () => resolve(Buffer.concat(chunks)));
107
+ req.on('error', reject);
108
+ });
109
+ }
110
+
111
+ const digest = (s: unknown) => createHash('sha256').update(String(s)).digest();
112
+
113
+ /** A `(req, res)` handler for node:http. */
114
+ export function makeHandler(ctl: HttpController, token: string | null = null) {
115
+ return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
116
+ let status = 200;
117
+ let payload: unknown;
118
+ try {
119
+ const url = new URL(req.url ?? '/', 'http://localhost');
120
+ const params: Params = Object.fromEntries(url.searchParams);
121
+ const body = await readBody(req);
122
+ if (body.length) {
123
+ let data: unknown;
124
+ try {
125
+ data = JSON.parse(body.toString('utf8'));
126
+ } catch {
127
+ throw new HttpError(400, 'body must be JSON');
128
+ }
129
+ if (data && typeof data === 'object' && !Array.isArray(data)) Object.assign(params, data);
130
+ }
131
+ if (token) {
132
+ const auth = req.headers.authorization ?? '';
133
+ const given = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7) : (params.token ?? '');
134
+ if (!timingSafeEqual(digest(given), digest(token))) throw new HttpError(401, 'bad or missing token');
135
+ }
136
+ delete params.token;
137
+ payload = await dispatch(ctl, (req.method ?? 'GET').toUpperCase(), url.pathname, params);
138
+ } catch (e) {
139
+ const err = e as Error;
140
+ if (err instanceof HttpError) {
141
+ status = err.status;
142
+ } else if (err instanceof InputError) {
143
+ status = 400;
144
+ } else { // BLE / login failures
145
+ status = 502;
146
+ log.error('request failed:', err);
147
+ }
148
+ payload = { ok: false, error: err instanceof HttpError || err instanceof InputError ? err.message : `${err.name}: ${err.message}` };
149
+ }
150
+ const data = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
151
+ if (res.headersSent || res.destroyed) return;
152
+ res.writeHead(status, {
153
+ 'Content-Type': 'application/json; charset=utf-8',
154
+ 'Content-Length': data.length,
155
+ Connection: 'close',
156
+ });
157
+ res.end(data);
158
+ };
159
+ }
160
+
161
+ export interface ServeOptions {
162
+ host?: string;
163
+ port?: number;
164
+ /** bearer token; required when listening on a non-loopback address */
165
+ token?: string | null;
166
+ }
167
+
168
+ /** Start the HTTP API. Resolves to the listening server; closing the server also closes the controller. */
169
+ export async function serve(ctl: Controller, { host = '127.0.0.1', port = 8765, token = null }: ServeOptions = {}): Promise<Server> {
170
+ if (!LOOPBACK.includes(host) && !token) {
171
+ throw new InputError(`refusing to listen on ${host} without a token (use --token or ODELIC_HTTP_TOKEN)`);
172
+ }
173
+ const server = http.createServer(makeHandler(ctl, token));
174
+ server.headersTimeout = 10_000;
175
+ server.requestTimeout = 10_000 + 60_000; // BLE operations (scan + login) can take a while
176
+ await new Promise<void>((resolve, reject) => {
177
+ server.once('error', reject);
178
+ server.listen(port, host, () => {
179
+ server.removeListener('error', reject);
180
+ resolve();
181
+ });
182
+ });
183
+ server.on('close', () => {
184
+ ctl.close().catch(() => {});
185
+ });
186
+ const addr = server.address() as AddressInfo;
187
+ log.warning(`HTTP API on http://${host}:${addr.port} (${ctl.keepConnected
188
+ ? 'keeping the BLE link open' : `idle disconnect after ${ctl.idleTimeout}s`})`);
189
+ if (ctl.keepConnected) {
190
+ ctl.connect()
191
+ .then(() => log.warning(`connected via ${ctl.mesh.nodeName}`))
192
+ .catch((e: Error) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
193
+ }
194
+ return server;
195
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = '0.2.1';