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/protocol.ts
ADDED
|
@@ -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';
|