odelic 0.2.0 → 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 (68) hide show
  1. package/README.md +93 -91
  2. package/bin/odelic-mcp.js +1 -1
  3. package/bin/odelic.js +1 -1
  4. package/dist/cli.d.ts +58 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +279 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/client.d.ts +125 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +354 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/config.d.ts +34 -0
  13. package/dist/config.d.ts.map +1 -0
  14. package/dist/config.js +139 -0
  15. package/dist/config.js.map +1 -0
  16. package/dist/controller.d.ts +95 -0
  17. package/dist/controller.d.ts.map +1 -0
  18. package/dist/controller.js +210 -0
  19. package/dist/controller.js.map +1 -0
  20. package/dist/crypto.d.ts +25 -0
  21. package/dist/crypto.d.ts.map +1 -0
  22. package/dist/crypto.js +94 -0
  23. package/dist/crypto.js.map +1 -0
  24. package/dist/errors.d.ts +14 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +17 -0
  27. package/dist/errors.js.map +1 -0
  28. package/dist/index.d.ts +18 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/{src → dist}/index.js +1 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/log.d.ts +18 -0
  33. package/dist/log.d.ts.map +1 -0
  34. package/dist/log.js +21 -0
  35. package/dist/log.js.map +1 -0
  36. package/dist/mcp.d.ts +23 -0
  37. package/dist/mcp.d.ts.map +1 -0
  38. package/dist/mcp.js +74 -0
  39. package/dist/mcp.js.map +1 -0
  40. package/dist/protocol.d.ts +90 -0
  41. package/dist/protocol.d.ts.map +1 -0
  42. package/dist/protocol.js +178 -0
  43. package/dist/protocol.js.map +1 -0
  44. package/dist/server.d.ts +24 -0
  45. package/dist/server.d.ts.map +1 -0
  46. package/dist/server.js +187 -0
  47. package/dist/server.js.map +1 -0
  48. package/dist/version.d.ts +2 -0
  49. package/dist/version.d.ts.map +1 -0
  50. package/dist/version.js +2 -0
  51. package/dist/version.js.map +1 -0
  52. package/package.json +19 -7
  53. package/src/{cli.js → cli.ts} +64 -50
  54. package/src/client.ts +414 -0
  55. package/src/{config.js → config.ts} +37 -23
  56. package/src/controller.ts +267 -0
  57. package/src/{crypto.js → crypto.ts} +29 -28
  58. package/src/{errors.js → errors.ts} +2 -2
  59. package/src/index.ts +17 -0
  60. package/src/log.ts +24 -0
  61. package/src/{mcp.js → mcp.ts} +31 -20
  62. package/src/{protocol.js → protocol.ts} +56 -30
  63. package/src/{server.js → server.ts} +57 -38
  64. package/src/version.ts +1 -0
  65. package/src/client.js +0 -362
  66. package/src/controller.js +0 -219
  67. package/src/log.js +0 -20
  68. package/src/version.js +0 -1
package/dist/server.js ADDED
@@ -0,0 +1,187 @@
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 { InputError } from './errors.js';
19
+ import { log } from './log.js';
20
+ import { toInt } from './controller.js';
21
+ export const LOOPBACK = ['127.0.0.1', 'localhost', '::1'];
22
+ const MAX_BODY = 65536;
23
+ export class HttpError extends Error {
24
+ status;
25
+ constructor(status, message) {
26
+ super(message);
27
+ this.name = 'HttpError';
28
+ this.status = status;
29
+ }
30
+ }
31
+ function need(method, expected) {
32
+ if (method !== expected)
33
+ throw new HttpError(405, `use ${expected}`);
34
+ }
35
+ const num = (v, def) => (v ?? def);
36
+ /** Route one request to the controller. Returns the JSON-able result. */
37
+ export async function dispatch(ctl, method, path, params) {
38
+ let parts;
39
+ try {
40
+ parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map(decodeURIComponent);
41
+ }
42
+ catch {
43
+ throw new HttpError(400, 'bad URL encoding');
44
+ }
45
+ if (parts.length === 1 && parts[0] === 'health')
46
+ return { ok: true, ...ctl.info() };
47
+ if (!parts.length)
48
+ throw new HttpError(404, 'not found');
49
+ const [head] = parts;
50
+ if (head === 'lights') {
51
+ if (parts.length === 1) {
52
+ need(method, 'GET');
53
+ return ctl.lights();
54
+ }
55
+ const target = parts[1];
56
+ if (parts.length === 2) {
57
+ need(method, 'GET');
58
+ return ctl.status(target);
59
+ }
60
+ need(method, 'POST');
61
+ const act = parts[2];
62
+ if (act === 'on')
63
+ return ctl.on(target);
64
+ if (act === 'off')
65
+ return ctl.off(target);
66
+ if (act === 'light')
67
+ return ctl.setLight(target, num(params.color, 50), num(params.brightness, 100));
68
+ if (act === 'night')
69
+ return ctl.nightLight(target, num(params.level, 0));
70
+ }
71
+ else if (head === 'groups' && parts.length === 3) {
72
+ need(method, 'POST');
73
+ return ctl.group(toInt(parts[1], 'group id'), parts[2], num(params.color, 50), num(params.brightness, 100));
74
+ }
75
+ else if (head === 'scenes' && parts.length === 3 && (parts[2] === 'play' || parts[2] === 'save')) {
76
+ need(method, 'POST');
77
+ return ctl.scene(toInt(parts[1], 'scene index'), parts[2] === 'save');
78
+ }
79
+ throw new HttpError(404, 'not found');
80
+ }
81
+ function readBody(req) {
82
+ return new Promise((resolve, reject) => {
83
+ const declared = Number(req.headers['content-length'] || 0);
84
+ if (declared > MAX_BODY) {
85
+ reject(new HttpError(413, 'body too large'));
86
+ return;
87
+ }
88
+ const chunks = [];
89
+ let size = 0;
90
+ req.on('data', (c) => {
91
+ size += c.length;
92
+ if (size > MAX_BODY) {
93
+ reject(new HttpError(413, 'body too large'));
94
+ req.destroy();
95
+ }
96
+ else {
97
+ chunks.push(c);
98
+ }
99
+ });
100
+ req.on('end', () => resolve(Buffer.concat(chunks)));
101
+ req.on('error', reject);
102
+ });
103
+ }
104
+ const digest = (s) => createHash('sha256').update(String(s)).digest();
105
+ /** A `(req, res)` handler for node:http. */
106
+ export function makeHandler(ctl, token = null) {
107
+ return async (req, res) => {
108
+ let status = 200;
109
+ let payload;
110
+ try {
111
+ const url = new URL(req.url ?? '/', 'http://localhost');
112
+ const params = Object.fromEntries(url.searchParams);
113
+ const body = await readBody(req);
114
+ if (body.length) {
115
+ let data;
116
+ try {
117
+ data = JSON.parse(body.toString('utf8'));
118
+ }
119
+ catch {
120
+ throw new HttpError(400, 'body must be JSON');
121
+ }
122
+ if (data && typeof data === 'object' && !Array.isArray(data))
123
+ Object.assign(params, data);
124
+ }
125
+ if (token) {
126
+ const auth = req.headers.authorization ?? '';
127
+ const given = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7) : (params.token ?? '');
128
+ if (!timingSafeEqual(digest(given), digest(token)))
129
+ throw new HttpError(401, 'bad or missing token');
130
+ }
131
+ delete params.token;
132
+ payload = await dispatch(ctl, (req.method ?? 'GET').toUpperCase(), url.pathname, params);
133
+ }
134
+ catch (e) {
135
+ const err = e;
136
+ if (err instanceof HttpError) {
137
+ status = err.status;
138
+ }
139
+ else if (err instanceof InputError) {
140
+ status = 400;
141
+ }
142
+ else { // BLE / login failures
143
+ status = 502;
144
+ log.error('request failed:', err);
145
+ }
146
+ payload = { ok: false, error: err instanceof HttpError || err instanceof InputError ? err.message : `${err.name}: ${err.message}` };
147
+ }
148
+ const data = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
149
+ if (res.headersSent || res.destroyed)
150
+ return;
151
+ res.writeHead(status, {
152
+ 'Content-Type': 'application/json; charset=utf-8',
153
+ 'Content-Length': data.length,
154
+ Connection: 'close',
155
+ });
156
+ res.end(data);
157
+ };
158
+ }
159
+ /** Start the HTTP API. Resolves to the listening server; closing the server also closes the controller. */
160
+ export async function serve(ctl, { host = '127.0.0.1', port = 8765, token = null } = {}) {
161
+ if (!LOOPBACK.includes(host) && !token) {
162
+ throw new InputError(`refusing to listen on ${host} without a token (use --token or ODELIC_HTTP_TOKEN)`);
163
+ }
164
+ const server = http.createServer(makeHandler(ctl, token));
165
+ server.headersTimeout = 10_000;
166
+ server.requestTimeout = 10_000 + 60_000; // BLE operations (scan + login) can take a while
167
+ await new Promise((resolve, reject) => {
168
+ server.once('error', reject);
169
+ server.listen(port, host, () => {
170
+ server.removeListener('error', reject);
171
+ resolve();
172
+ });
173
+ });
174
+ server.on('close', () => {
175
+ ctl.close().catch(() => { });
176
+ });
177
+ const addr = server.address();
178
+ log.warning(`HTTP API on http://${host}:${addr.port} (${ctl.keepConnected
179
+ ? 'keeping the BLE link open' : `idle disconnect after ${ctl.idleTimeout}s`})`);
180
+ if (ctl.keepConnected) {
181
+ ctl.connect()
182
+ .then(() => log.warning(`connected via ${ctl.mesh.nodeName}`))
183
+ .catch((e) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
184
+ }
185
+ return server;
186
+ }
187
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export declare const VERSION = "0.2.1";
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAC"}
@@ -0,0 +1,2 @@
1
+ export const VERSION = '0.2.1';
2
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "odelic",
3
- "version": "0.2.0",
4
- "description": "Unofficial Node.js library for ODELIC Bluetooth lights (Pairlink connected-mesh)",
3
+ "version": "0.2.1",
4
+ "description": "Unofficial TypeScript/Node.js library for ODELIC Bluetooth lights (Pairlink connected-mesh)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "rbstxt",
@@ -20,14 +20,22 @@
20
20
  "mesh",
21
21
  "lighting",
22
22
  "smart-home",
23
- "mcp"
23
+ "mcp",
24
+ "typescript"
24
25
  ],
25
26
  "engines": {
26
- "node": ">=20"
27
+ "node": ">=22"
27
28
  },
29
+ "types": "./dist/index.d.ts",
28
30
  "exports": {
29
- ".": "./src/index.js",
30
- "./mcp": "./src/mcp.js",
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./mcp": {
36
+ "types": "./dist/mcp.d.ts",
37
+ "default": "./dist/mcp.js"
38
+ },
31
39
  "./package.json": "./package.json"
32
40
  },
33
41
  "bin": {
@@ -36,12 +44,16 @@
36
44
  },
37
45
  "files": [
38
46
  "bin",
47
+ "dist",
39
48
  "src",
40
49
  "LICENSE",
41
50
  "README.md"
42
51
  ],
43
52
  "scripts": {
44
- "test": "node --test"
53
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
54
+ "typecheck": "tsc -p tsconfig.test.json",
55
+ "test": "node --test \"test/*.test.ts\"",
56
+ "prepack": "npm run build"
45
57
  },
46
58
  "dependencies": {
47
59
  "@stoprocent/noble": "^2.8.0"
@@ -2,13 +2,13 @@
2
2
  import { setTimeout as sleep } from 'node:timers/promises';
3
3
  import { parseArgs } from 'node:util';
4
4
 
5
- import { InputError, OdelicError } from './errors.js';
6
- import { log, setLogLevel } from './log.js';
7
- import { findNodes } from './client.js';
8
- import { Config, configPath, parseIdle } from './config.js';
9
- import { Controller } from './controller.js';
10
- import { parseAppId } from './protocol.js';
11
- import { VERSION } from './version.js';
5
+ import { InputError, OdelicError } from './errors.ts';
6
+ import { log, setLogLevel } from './log.ts';
7
+ import { findNodes } from './client.ts';
8
+ import { Config, configPath, parseIdle } from './config.ts';
9
+ import { Controller } from './controller.ts';
10
+ import { parseAppId } from './protocol.ts';
11
+ import { VERSION } from './version.ts';
12
12
 
13
13
  const HELP = `usage: odelic [--id ID] [--node NODE] [-v] <command> [...]
14
14
 
@@ -59,10 +59,15 @@ const OPTIONS = {
59
59
  host: { type: 'string' },
60
60
  port: { type: 'string' },
61
61
  token: { type: 'string' },
62
+ } as const;
63
+
64
+ type OptionName = keyof typeof OPTIONS;
65
+ export type CliOptions = {
66
+ [K in OptionName]?: (typeof OPTIONS)[K]['type'] extends 'boolean' ? boolean : string;
62
67
  };
63
68
 
64
69
  /** Which options each command accepts (besides the global ones). */
65
- const COMMAND_OPTIONS = {
70
+ const COMMAND_OPTIONS: Record<string, readonly OptionName[]> = {
66
71
  setup: ['idle', 'keep-connected'],
67
72
  scan: [],
68
73
  lights: [],
@@ -78,53 +83,62 @@ const COMMAND_OPTIONS = {
78
83
  serve: ['host', 'port', 'token', 'idle', 'keep-connected'],
79
84
  mcp: [],
80
85
  };
81
- const GLOBAL = ['id', 'node', 'verbose', 'help', 'version'];
86
+ const GLOBAL: readonly OptionName[] = ['id', 'node', 'verbose', 'help', 'version'];
87
+ const MAX_ARGS: Record<string, number> = { on: 1, off: 1, light: 1, night: 1, status: 1, group: 2, scene: 2, name: 3 };
82
88
 
83
- function intArg(v, name, { min = -Infinity, max = Infinity, def } = {}) {
84
- if (v === undefined) return def;
89
+ function intArg(v: string | undefined, name: string, { min = -Infinity, max = Infinity } = {}): number | undefined {
90
+ if (v === undefined) return undefined;
85
91
  if (!/^-?\d+$/.test(v)) throw new InputError(`--${name}: invalid int value: '${v}'`);
86
92
  const n = Number(v);
87
93
  if (n < min || n > max) throw new InputError(`--${name}: must be between ${min} and ${max}`);
88
94
  return n;
89
95
  }
90
96
 
91
- function choice(v, name, choices) {
92
- if (!choices.includes(v)) throw new InputError(`${name}: expected one of ${choices.join(', ')} (got ${v === undefined ? 'nothing' : `'${v}'`})`);
93
- return v;
97
+ function choice<T extends string>(v: string | undefined, name: string, choices: readonly T[]): T {
98
+ if (!choices.includes(v as T)) {
99
+ throw new InputError(`${name}: expected one of ${choices.join(', ')} (got ${v === undefined ? 'nothing' : `'${v}'`})`);
100
+ }
101
+ return v as T;
102
+ }
103
+
104
+ export interface ParsedCli {
105
+ cmd: string;
106
+ args: string[];
107
+ opts: CliOptions;
94
108
  }
95
109
 
96
- /** Parse argv into `{cmd, args, opts}`; throws InputError on bad usage. */
97
- export function parseCli(argv) {
110
+ /** Parse argv; throws InputError on bad usage. */
111
+ export function parseCli(argv: string[]): ParsedCli {
98
112
  let parsed;
99
113
  try {
100
114
  parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
101
115
  } catch (e) {
102
- throw new InputError(e.message);
116
+ throw new InputError((e as Error).message);
103
117
  }
104
- const { values: o, positionals } = parsed;
118
+ const o = parsed.values as CliOptions;
105
119
  if (o.help || o.version) return { cmd: o.help ? 'help' : 'version', args: [], opts: o };
106
- const [cmd, ...args] = positionals;
120
+ const [cmd, ...args] = parsed.positionals;
107
121
  if (!cmd) throw new InputError('missing command (see odelic --help)');
108
- if (!(cmd in COMMAND_OPTIONS)) throw new InputError(`unknown command '${cmd}' (see odelic --help)`);
109
- for (const k of Object.keys(o)) {
122
+ if (!Object.hasOwn(COMMAND_OPTIONS, cmd)) throw new InputError(`unknown command '${cmd}' (see odelic --help)`);
123
+ for (const k of Object.keys(o) as OptionName[]) {
110
124
  if (!GLOBAL.includes(k) && !COMMAND_OPTIONS[cmd].includes(k)) throw new InputError(`${cmd}: unrecognized option --${k}`);
111
125
  }
112
126
  if (o.idle !== undefined && o['keep-connected']) throw new InputError('--idle and --keep-connected are mutually exclusive');
113
127
  if (o.idle !== undefined) parseIdle(o.idle); // validate early
114
- const maxArgs = { on: 1, off: 1, light: 1, night: 1, status: 1, group: 2, scene: 2, name: 3 }[cmd] ?? 0;
128
+ const maxArgs = MAX_ARGS[cmd] ?? 0;
115
129
  if (args.length > maxArgs) throw new InputError(`${cmd}: unexpected argument '${args[maxArgs]}'`);
116
130
  return { cmd, args, opts: o };
117
131
  }
118
132
 
119
- function idleValue(o) {
133
+ function idleValue(o: CliOptions): number | null {
120
134
  return o['keep-connected'] ? 0 : parseIdle(o.idle);
121
135
  }
122
136
 
123
- function show(obj) {
137
+ function show(obj: unknown): void {
124
138
  console.log(typeof obj === 'string' ? obj : JSON.stringify(obj, null, 2));
125
139
  }
126
140
 
127
- async function runBle(cmd, args, o, cfg) {
141
+ async function runBle(cmd: string, args: string[], o: CliOptions, cfg: Config): Promise<void> {
128
142
  if (cmd === 'scan') {
129
143
  const aid = cfg.appId(o.id);
130
144
  const homeid = aid ? parseAppId(aid).homeid : null;
@@ -135,15 +149,15 @@ async function runBle(cmd, args, o, cfg) {
135
149
  }
136
150
 
137
151
  if (cmd === 'serve') {
138
- const { serve } = await import('./server.js');
152
+ const { serve } = await import('./server.ts');
139
153
  const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: idleValue(o) });
140
154
  const server = await serve(ctl, {
141
155
  host: o.host ?? '127.0.0.1',
142
- port: intArg(o.port, 'port', { min: 0, max: 65535, def: 8765 }),
156
+ port: intArg(o.port, 'port', { min: 0, max: 65535 }) ?? 8765,
143
157
  token: o.token ?? process.env.ODELIC_HTTP_TOKEN ?? null,
144
158
  });
145
- await new Promise((resolve) => {
146
- const stop = () => server.close(resolve);
159
+ await new Promise<void>((resolve) => {
160
+ const stop = () => server.close(() => resolve());
147
161
  process.once('SIGINT', stop);
148
162
  process.once('SIGTERM', stop);
149
163
  });
@@ -152,28 +166,28 @@ async function runBle(cmd, args, o, cfg) {
152
166
  }
153
167
 
154
168
  const target = args[0] ?? 'all';
169
+ const color = () => intArg(o.color, 'color') ?? 50;
170
+ const brightness = () => intArg(o.brightness, 'brightness') ?? 100;
155
171
  const ctl = new Controller(o.id, { node: o.node, config: cfg, idleTimeout: 0 });
156
172
  try {
157
173
  if (cmd === 'lights' || cmd === 'devices') {
158
174
  for (const d of await ctl.lights()) {
159
175
  if (d.online) console.log(`${(d.name ?? '-').padEnd(12)} vaddr=${d.vaddr} mac=${d.mac} product=${d.product} ver=${d.version}`);
160
- else console.log(`${d.name.padEnd(12)} vaddr=${d.vaddr} (offline)`);
176
+ else console.log(`${String(d.name).padEnd(12)} vaddr=${d.vaddr} (offline)`);
161
177
  }
162
178
  return;
163
179
  }
164
180
  if (cmd === 'on') show(await ctl.on(target));
165
181
  else if (cmd === 'off') show(await ctl.off(target));
166
- else if (cmd === 'light') {
167
- show(await ctl.setLight(target, intArg(o.color, 'color', { def: 50 }), intArg(o.brightness, 'brightness', { def: 100 })));
168
- } else if (cmd === 'night') show(await ctl.nightLight(target, intArg(o.level, 'level', { min: 0, max: 2, def: 0 })));
182
+ else if (cmd === 'light') show(await ctl.setLight(target, color(), brightness()));
183
+ else if (cmd === 'night') show(await ctl.nightLight(target, intArg(o.level, 'level', { min: 0, max: 2 }) ?? 0));
169
184
  else if (cmd === 'status') show(await ctl.status(target));
170
185
  else if (cmd === 'group') {
171
186
  const id = intArg(args[0], 'group_id');
172
187
  if (id === undefined) throw new InputError('group: missing group id');
173
- show(await ctl.group(id, choice(args[1], 'group action', ['on', 'off', 'light']),
174
- intArg(o.color, 'color', { def: 50 }), intArg(o.brightness, 'brightness', { def: 100 })));
188
+ show(await ctl.group(id, choice(args[1], 'group action', ['on', 'off', 'light'] as const), color(), brightness()));
175
189
  } else if (cmd === 'scene') {
176
- const action = choice(args[0], 'scene action', ['save', 'play']);
190
+ const action = choice(args[0], 'scene action', ['save', 'play'] as const);
177
191
  const index = intArg(args[1], 'index');
178
192
  if (index === undefined) throw new InputError('scene: missing index');
179
193
  show(await ctl.scene(index, action === 'save'));
@@ -184,17 +198,16 @@ async function runBle(cmd, args, o, cfg) {
184
198
  }
185
199
  }
186
200
 
187
- /** CLI entry point. Resolves to the process exit code. */
188
- export async function main(argv = process.argv.slice(2)) {
189
- let cmd;
190
- let args;
191
- let o;
201
+ /** CLI entry point. Resolves to the process exit code, or null for long-running commands (`mcp`). */
202
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<number | null> {
203
+ let parsed: ParsedCli;
192
204
  try {
193
- ({ cmd, args, opts: o } = parseCli(argv));
205
+ parsed = parseCli(argv);
194
206
  } catch (e) {
195
- console.error(`odelic: error: ${e.message}`);
207
+ console.error(`odelic: error: ${(e as Error).message}`);
196
208
  return 2;
197
209
  }
210
+ const { cmd, args, opts: o } = parsed;
198
211
  if (cmd === 'help') {
199
212
  console.log(HELP);
200
213
  return 0;
@@ -220,7 +233,7 @@ export async function main(argv = process.argv.slice(2)) {
220
233
  console.log(`saved to ${configPath()} (id: ${cfg.id ? 'set' : 'not set'}, connection: ${
221
234
  mode ? `disconnect after ${mode}s idle` : 'keep connected'})`);
222
235
  } else if (cmd === 'name') {
223
- const sub = choice(args[0], 'name command', ['add', 'rm', 'list']);
236
+ const sub = choice(args[0], 'name command', ['add', 'rm', 'list'] as const);
224
237
  if (sub === 'add') {
225
238
  if (args.length !== 3) throw new InputError('usage: odelic name add NAME VADDR');
226
239
  cfg.setName(args[1], args[2]);
@@ -232,12 +245,12 @@ export async function main(argv = process.argv.slice(2)) {
232
245
  }
233
246
  for (const [name, v] of Object.entries(cfg.lights)) console.log(`${name.padEnd(12)} ${v}`);
234
247
  } else if (cmd === 'mcp') {
235
- let mcp;
248
+ let mcp: typeof import('./mcp.ts');
236
249
  try {
237
- mcp = await import('./mcp.js');
250
+ mcp = await import('./mcp.ts');
238
251
  } catch (e) {
239
- if (e.code !== 'ERR_MODULE_NOT_FOUND') throw e;
240
- throw new OdelicError(`${e.message} (install with: npm install @modelcontextprotocol/sdk zod)`);
252
+ if ((e as NodeJS.ErrnoException).code !== 'ERR_MODULE_NOT_FOUND') throw e;
253
+ throw new OdelicError(`${(e as Error).message} (install with: npm install @modelcontextprotocol/sdk zod)`);
241
254
  }
242
255
  await mcp.main();
243
256
  return null; // keep running until stdin closes
@@ -246,8 +259,9 @@ export async function main(argv = process.argv.slice(2)) {
246
259
  }
247
260
  return 0;
248
261
  } catch (e) {
249
- if (o.verbose || !(e instanceof OdelicError)) log.debug(e.stack);
250
- console.error(`error: ${e.message}`);
262
+ const err = e as Error;
263
+ if (o.verbose || !(err instanceof OdelicError)) log.debug(err.stack);
264
+ console.error(`error: ${err.message}`);
251
265
  return 1;
252
266
  }
253
267
  }