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