crestron-mcp 1.8.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.
@@ -0,0 +1,728 @@
1
+ /**
2
+ * TCP/TLS client for the Crestron processor, ported from the proven Python
3
+ * client (PythonMCPClient/crestron_mcp_server.py). Speaks the text protocol in
4
+ * PROTOCOL.md: newline-framed request/response, with mode-1 (password) and
5
+ * mode-2 (HMAC-SHA256 challenge-response over TLS) authentication.
6
+ *
7
+ * Auto-connects (and reconnects) on demand, so the MCP server can start before
8
+ * the processor is reachable and recover if the socket drops.
9
+ */
10
+ import * as net from "node:net";
11
+ import * as tls from "node:tls";
12
+ import * as crypto from "node:crypto";
13
+ /** Where customers buy a license, and where the client fetches free trials. Overridable via
14
+ * env for testing (point at a local mock or the Stripe test-mode portal). The MAC is appended
15
+ * as ?mac= so the storefront/trial is bound to this processor. */
16
+ const PORTAL_URL = process.env.CRESTRON_PORTAL_URL || "https://solutionav.com.au/crestron-mcp/";
17
+ const TRIAL_URL = process.env.CRESTRON_TRIAL_URL || "https://license.solutionav.com.au/trial";
18
+ /** Host of the trial/licensing server, surfaced in the trial result so the assistant can say
19
+ * where a trial came from. The fetch happens inside this client, below the tool boundary, so
20
+ * the model can't otherwise see that an online server was involved. */
21
+ const TRIAL_HOST = (() => {
22
+ try {
23
+ return new URL(TRIAL_URL).host;
24
+ }
25
+ catch {
26
+ return TRIAL_URL;
27
+ }
28
+ })();
29
+ /** After a control action, read the device's STATE back so the result carries the real outcome.
30
+ * The model reliably reads tool results but rarely queries on its own, so we push the feedback to
31
+ * it. CRESTRON_CONFIRM=0 disables the read-back; CRESTRON_SETTLE_MS is how long to let an
32
+ * immediate set's feedback join settle before reading (ramps/pulses/pending are tracked
33
+ * server-side and read instantly, so they don't wait). */
34
+ const CONFIRM = (process.env.CRESTRON_CONFIRM ?? "1") !== "0";
35
+ const SETTLE_MS = Math.max(0, Number(process.env.CRESTRON_SETTLE_MS ?? 350) || 0);
36
+ /** Render a millisecond duration as a short human phrase for the LLM ("2 days 4 hours"). */
37
+ function humanizeMs(ms) {
38
+ if (ms <= 0)
39
+ return "expired";
40
+ const d = Math.floor(ms / 86400000);
41
+ const h = Math.floor((ms % 86400000) / 3600000);
42
+ const m = Math.floor((ms % 3600000) / 60000);
43
+ const parts = [];
44
+ if (d)
45
+ parts.push(`${d} day${d === 1 ? "" : "s"}`);
46
+ if (h)
47
+ parts.push(`${h} hour${h === 1 ? "" : "s"}`);
48
+ if (!d && m)
49
+ parts.push(`${m} minute${m === 1 ? "" : "s"}`);
50
+ return parts.join(" ") || "under a minute";
51
+ }
52
+ /** Short duration phrase for in-flight actions: ms / seconds for sub-minute, else humanizeMs. */
53
+ function humanizeShort(ms) {
54
+ if (ms <= 0)
55
+ return "now";
56
+ if (ms < 1000)
57
+ return `${Math.round(ms)}ms`;
58
+ if (ms < 60000) {
59
+ const s = ms / 1000;
60
+ return `${s < 10 ? s.toFixed(1) : Math.round(s)}s`;
61
+ }
62
+ return humanizeMs(ms);
63
+ }
64
+ /** Loose value compare so an analog set ("50000") matches a numeric feedback (50000). */
65
+ function valuesMatch(a, b) {
66
+ const sa = String(a).trim();
67
+ const sb = String(b).trim();
68
+ if (sa === sb)
69
+ return true;
70
+ const na = Number(sa);
71
+ const nb = Number(sb);
72
+ if (!Number.isNaN(na) && !Number.isNaN(nb))
73
+ return na === nb;
74
+ return false;
75
+ }
76
+ /** One-line, LLM-facing summary of a STATE read taken just after an action: in-flight (fading /
77
+ * pulsing / scheduled) or settled (current value, flagged when it differs from what was set). */
78
+ function summarizeState(state, commanded) {
79
+ if (!state || typeof state !== "object")
80
+ return "state unavailable";
81
+ if (state.error)
82
+ return `could not read back state: ${String(state.error)}`;
83
+ let pendingPart = "";
84
+ const p = state.pending;
85
+ if (p && typeof p === "object") {
86
+ const inMs = typeof p.in_ms === "number" ? ` in ~${humanizeShort(p.in_ms)}` : "";
87
+ const action = String(p.action ?? "set").toLowerCase();
88
+ pendingPart = `; scheduled to ${action} ${p.value ?? ""}${inMs}`.replace(/\s+$/, "");
89
+ }
90
+ const value = state.value;
91
+ if (state.state === "ramping") {
92
+ const rem = typeof state.remaining_ms === "number" ? `, ~${humanizeShort(state.remaining_ms)} left` : "";
93
+ return `fading to ${state.target ?? value ?? "?"}${rem}${pendingPart}`;
94
+ }
95
+ if (state.state === "pulsing") {
96
+ const rem = typeof state.remaining_ms === "number" ? `, releases in ~${humanizeShort(state.remaining_ms)}` : "";
97
+ return `pulsing${rem}${pendingPart}`;
98
+ }
99
+ // idle (settled)
100
+ if (value === undefined || value === null) {
101
+ return `set${pendingPart || " (no feedback wired to confirm)"}`;
102
+ }
103
+ if (commanded !== undefined && !valuesMatch(value, commanded)) {
104
+ return `feedback reads ${String(value)} (set ${commanded})${pendingPart}`;
105
+ }
106
+ return `now ${String(value)}${pendingPart}`;
107
+ }
108
+ /** Serializes async sections (mirrors the Python asyncio.Lock usage). */
109
+ class Mutex {
110
+ tail = Promise.resolve();
111
+ run(fn) {
112
+ const result = this.tail.then(fn, fn);
113
+ this.tail = result.then(() => { }, () => { });
114
+ return result;
115
+ }
116
+ }
117
+ /** Python str.split(sep, maxsplit): at most maxsplit+1 parts, last keeps the rest
118
+ * (so a value containing ':' survives). */
119
+ function splitMax(s, sep, maxsplit) {
120
+ const parts = [];
121
+ let rest = s;
122
+ for (let i = 0; i < maxsplit; i++) {
123
+ const idx = rest.indexOf(sep);
124
+ if (idx < 0)
125
+ break;
126
+ parts.push(rest.slice(0, idx));
127
+ rest = rest.slice(idx + sep.length);
128
+ }
129
+ parts.push(rest);
130
+ return parts;
131
+ }
132
+ export class CrestronConnection {
133
+ host;
134
+ port;
135
+ authCredential; // mode 1: shared password
136
+ authKey; // mode 2: shared HMAC key (challenge-response)
137
+ tls; // a key implies TLS; can also be set on its own
138
+ processorId;
139
+ connected = false;
140
+ socket;
141
+ buffer = "";
142
+ readers = [];
143
+ lineBuf = [];
144
+ connectLock = new Mutex();
145
+ ioLock = new Mutex();
146
+ constructor(host, port = 50794, authCredential = "", authKey = "", useTls = false) {
147
+ this.host = host;
148
+ this.port = port;
149
+ this.authCredential = authCredential;
150
+ this.authKey = authKey;
151
+ this.tls = useTls || Boolean(authKey);
152
+ }
153
+ /** Human + LLM friendly, step-by-step guidance for an unlicensed processor. Licensing is a
154
+ * one-time, in-chat activation: the processor reports its activation code (MAC) in `detail`;
155
+ * the user gets a key for it and the LLM activates the box with activate_crestron_license. */
156
+ licenseHelp(detail) {
157
+ // Pull the MAC out of the box's message (e.g. "...(MAC 00107ff0ab17)") so the buy link is
158
+ // pre-bound to this processor; fall back to the bare portal URL if it's not present.
159
+ const macMatch = detail.match(/MAC\s+([0-9a-fA-F]{12})/);
160
+ const mac = macMatch ? macMatch[1].toLowerCase() : "";
161
+ const buyUrl = mac ? `${PORTAL_URL}?mac=${mac}` : PORTAL_URL;
162
+ return (`This Crestron processor (${this.host}) isn't licensed yet (${detail}).\n` +
163
+ `Two options, both doable right here in chat:\n` +
164
+ ` • Free trial: ask me to start one and I'll call start_crestron_trial. ` +
165
+ `Up to 3 one-week trials per processor; I'll tell you how many remain.\n` +
166
+ ` • Buy a perpetual license (AUD $249 inc GST). Purchase link, MAC pre-filled: ${buyUrl}\n` +
167
+ ` Get a key there, paste it back here, and I'll activate it with activate_crestron_license.\n` +
168
+ `Once licensed, the processor stays licensed for every client and across reboots. ` +
169
+ `Licensing only gates the natural-language layer; the AV system itself keeps running regardless.\n` +
170
+ `(When you relay this, offer BOTH options up front and give the user the full purchase link ` +
171
+ `above exactly as written, with the MAC, don't wait to be asked and don't shorten it to the domain.)`);
172
+ }
173
+ /** Turn an "ERROR:code:msg" response into a message string, expanding the license codes
174
+ * (1011 required / 1012 invalid) into the full step-by-step guidance. */
175
+ formatError(response) {
176
+ const parts = splitMax(response, ":", 2);
177
+ const code = parts[1];
178
+ const msg = parts.length > 2 ? parts[2] : "Unknown error";
179
+ if (code === "1011" || code === "1012")
180
+ return this.licenseHelp(msg);
181
+ return msg;
182
+ }
183
+ // --- transport ---------------------------------------------------------
184
+ openSocket() {
185
+ return new Promise((resolve, reject) => {
186
+ const sock = this.tls
187
+ ? tls.connect({
188
+ host: this.host,
189
+ port: this.port,
190
+ // Option A: trust the processor's built-in self-signed cert on a
191
+ // trusted LAN (no PKI to verify against). Mirror the Python
192
+ // CERT_NONE. Upgrade to verification/pinning if the net is untrusted.
193
+ // No servername/SNI: we don't verify the cert, and the host is an IP
194
+ // (setting SNI to an IP is invalid per RFC 6066).
195
+ rejectUnauthorized: false,
196
+ // Force TLS 1.2: Node's TLS 1.3 completes the handshake with the
197
+ // processor's Mono TLS stack but then never receives a reply (the
198
+ // box stalls). Pinning to 1.2 fixes it, and matches the gold-
199
+ // reference Samsung driver which also forces TLS 1.2. (Raw Python
200
+ // negotiates 1.3 fine; this is Node-specific.)
201
+ maxVersion: "TLSv1.2",
202
+ })
203
+ : net.connect({ host: this.host, port: this.port });
204
+ const readyEvent = this.tls ? "secureConnect" : "connect";
205
+ let settled = false;
206
+ const settle = (err) => {
207
+ if (settled)
208
+ return;
209
+ settled = true;
210
+ sock.removeListener(readyEvent, onReady);
211
+ sock.removeListener("error", onError);
212
+ if (err) {
213
+ try {
214
+ sock.destroy();
215
+ }
216
+ catch {
217
+ /* ignore */
218
+ }
219
+ reject(err);
220
+ }
221
+ else {
222
+ this.attach(sock);
223
+ resolve();
224
+ }
225
+ };
226
+ const onReady = () => settle();
227
+ const onError = (e) => settle(e);
228
+ sock.once(readyEvent, onReady);
229
+ sock.once("error", onError);
230
+ });
231
+ }
232
+ attach(sock) {
233
+ this.socket = sock;
234
+ this.buffer = "";
235
+ sock.on("data", (chunk) => this.onData(chunk.toString("utf8")));
236
+ sock.on("close", () => this.handleDrop());
237
+ sock.on("error", (e) => this.handleDrop(e));
238
+ }
239
+ onData(chunk) {
240
+ this.buffer += chunk;
241
+ let idx;
242
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
243
+ const line = this.buffer.slice(0, idx).trim(); // mirror Python .strip()
244
+ this.buffer = this.buffer.slice(idx + 1);
245
+ const waiter = this.readers.shift();
246
+ if (waiter)
247
+ waiter.resolve(line);
248
+ else
249
+ this.lineBuf.push(line);
250
+ }
251
+ }
252
+ handleDrop(err) {
253
+ this.connected = false;
254
+ this.socket = undefined;
255
+ this.buffer = "";
256
+ const e = new Error(err ? `Connection error: ${err.message}` : "Connection closed by Crestron");
257
+ for (const w of this.readers.splice(0))
258
+ w.reject(e);
259
+ this.lineBuf = [];
260
+ }
261
+ write(s) {
262
+ if (!this.socket)
263
+ throw new Error("Not connected");
264
+ this.socket.write(s);
265
+ }
266
+ readLine() {
267
+ const buffered = this.lineBuf.shift();
268
+ if (buffered !== undefined)
269
+ return Promise.resolve(buffered);
270
+ if (!this.socket || this.socket.destroyed) {
271
+ return Promise.reject(new Error("Connection closed by Crestron"));
272
+ }
273
+ return new Promise((resolve, reject) => {
274
+ this.readers.push({ resolve, reject });
275
+ });
276
+ }
277
+ async close() {
278
+ const sock = this.socket;
279
+ this.socket = undefined;
280
+ this.connected = false;
281
+ if (sock) {
282
+ try {
283
+ sock.destroy();
284
+ }
285
+ catch {
286
+ /* ignore */
287
+ }
288
+ }
289
+ const e = new Error("Connection closed");
290
+ for (const w of this.readers.splice(0))
291
+ w.reject(e);
292
+ this.lineBuf = [];
293
+ }
294
+ // --- handshake ---------------------------------------------------------
295
+ async ensureConnected() {
296
+ if (this.connected)
297
+ return;
298
+ await this.connectLock.run(async () => {
299
+ if (this.connected)
300
+ return;
301
+ await this.connect();
302
+ });
303
+ }
304
+ async connect() {
305
+ await this.openSocket();
306
+ this.write("HELLO\n");
307
+ const resp = await this.readLine();
308
+ if (!resp.startsWith("OK:MCP-CRESTRON")) {
309
+ await this.close();
310
+ throw new Error(`Unexpected HELLO response from ${this.host}:${this.port}: ${JSON.stringify(resp)}`);
311
+ }
312
+ const parts = resp.split(":");
313
+ this.processorId = parts.length > 3 ? parts[3] : undefined;
314
+ // Licensing is per-processor now (stored on the box, installed via activateLicense), so
315
+ // there's nothing to present on connect. Authenticate before marking connected. A key
316
+ // (mode 2, challenge-response) takes precedence over a password (mode 1).
317
+ if (this.authKey) {
318
+ await this.authenticateKey();
319
+ }
320
+ else if (this.authCredential) {
321
+ this.write(`AUTH:${this.authCredential}\n`);
322
+ const aresp = await this.readLine();
323
+ if (!aresp.startsWith("OK")) {
324
+ await this.close();
325
+ throw new Error(`Authentication rejected by ${this.host}:${this.port}: ${JSON.stringify(aresp)}`);
326
+ }
327
+ }
328
+ this.connected = true;
329
+ }
330
+ async authenticateKey() {
331
+ this.write("AUTH\n");
332
+ const cresp = await this.readLine();
333
+ if (!cresp.startsWith("CHALLENGE:")) {
334
+ await this.close();
335
+ throw new Error(`Expected CHALLENGE from ${this.host}:${this.port}, got ${JSON.stringify(cresp)}`);
336
+ }
337
+ const nonce = cresp.slice("CHALLENGE:".length);
338
+ const digest = crypto
339
+ .createHmac("sha256", Buffer.from(this.authKey, "utf8"))
340
+ .update(Buffer.from(nonce, "utf8"))
341
+ .digest("hex"); // lowercase hex, matches Python hexdigest()
342
+ this.write(`AUTH:${digest}\n`);
343
+ const aresp = await this.readLine();
344
+ if (!aresp.startsWith("OK")) {
345
+ await this.close();
346
+ throw new Error(`Key authentication rejected by ${this.host}:${this.port}: ${JSON.stringify(aresp)}`);
347
+ }
348
+ }
349
+ // --- request/response --------------------------------------------------
350
+ async sendCommand(command) {
351
+ await this.ensureConnected();
352
+ return this.ioLock.run(async () => {
353
+ try {
354
+ this.write(`${command}\n`);
355
+ return await this.readLine();
356
+ }
357
+ catch (e) {
358
+ await this.close();
359
+ throw new Error(`Communication error: ${e instanceof Error ? e.message : String(e)}`);
360
+ }
361
+ });
362
+ }
363
+ // --- protocol operations (mirror the Python client exactly) ------------
364
+ /**
365
+ * Live state of a device: current value plus whether it is idle / ramping (target +
366
+ * completes_at) / pulsing (releases_at), and any pending scheduled action. Backed by the
367
+ * STATE command; falls back to the flat QUERY on a processor too old to know STATE.
368
+ */
369
+ async queryDevice(deviceId) {
370
+ const response = await this.sendCommand(`STATE:${deviceId}`);
371
+ if (response.startsWith("DATA:"))
372
+ return JSON.parse(response.slice(5));
373
+ if (response.startsWith("ERROR")) {
374
+ const parts = splitMax(response, ":", 2);
375
+ if (parts[1] === "1000")
376
+ return this.queryDeviceFlat(deviceId); // older processor: no STATE
377
+ return { error: this.formatError(response) };
378
+ }
379
+ return { error: "Invalid response" };
380
+ }
381
+ /** Legacy flat read (OK:id:value), used as a fallback when STATE isn't supported. */
382
+ async queryDeviceFlat(deviceId) {
383
+ const response = await this.sendCommand(`QUERY:${deviceId}`);
384
+ if (response.startsWith("ERROR")) {
385
+ return { error: this.formatError(response) };
386
+ }
387
+ if (response.startsWith("OK")) {
388
+ const parts = splitMax(response, ":", 2);
389
+ return {
390
+ device_id: parts.length > 1 ? parts[1] : deviceId,
391
+ value: parts.length > 2 ? parts[2] : null,
392
+ state: "idle",
393
+ };
394
+ }
395
+ return { error: "Invalid response" };
396
+ }
397
+ /** The processor's current time, as epoch milliseconds + ISO 8601. */
398
+ async getTime() {
399
+ const response = await this.sendCommand("TIME");
400
+ if (response.startsWith("DATA:"))
401
+ return JSON.parse(response.slice(5));
402
+ if (response.startsWith("ERROR")) {
403
+ return { error: this.formatError(response) };
404
+ }
405
+ return { error: "Invalid response" };
406
+ }
407
+ /**
408
+ * Activate (license) this processor with a key the user obtained for it. The processor
409
+ * verifies the key against its own MAC and, on success, stores it - so the box stays
410
+ * licensed for every client and across reboots. One-time per processor. Whitespace in the
411
+ * pasted key is stripped so a messy paste still works.
412
+ */
413
+ async activateLicense(licenseKey) {
414
+ const clean = (licenseKey || "").replace(/\s+/g, "");
415
+ if (!clean)
416
+ return { success: false, error: "No license key provided." };
417
+ const response = await this.sendCommand(`ACTIVATE:${clean}`);
418
+ if (response.startsWith("OK")) {
419
+ const parts = splitMax(response, ":", 2); // OK:ACTIVATED:<customer>
420
+ return { success: true, activated: true, licensed_to: parts.length > 2 ? parts[2] : "" };
421
+ }
422
+ return { success: false, error: this.formatError(response) };
423
+ }
424
+ /**
425
+ * Report the processor's license/trial state: licensed (now), whether it's a time-limited
426
+ * trial, how long remains (ms + human phrase), the MAC, and a pre-bound buy URL. Lets the LLM
427
+ * detect an unlicensed box, surface trial time-remaining naturally, and offer trial/buy.
428
+ */
429
+ async licenseStatus() {
430
+ const response = await this.sendCommand("LICENSE_STATUS");
431
+ if (response.startsWith("DATA:")) {
432
+ const s = JSON.parse(response.slice(5));
433
+ if (typeof s.remaining_ms === "number")
434
+ s.remaining_human = humanizeMs(s.remaining_ms);
435
+ if (s.mac)
436
+ s.buy_url = `${PORTAL_URL}?mac=${s.mac}`;
437
+ return s;
438
+ }
439
+ if (response.startsWith("ERROR"))
440
+ return { error: this.formatError(response) };
441
+ return { error: "Invalid response" };
442
+ }
443
+ /**
444
+ * Start a free 7-day trial on this processor. Reads the box's MAC, fetches a signed trial
445
+ * license bound to it from the licensing server, and activates it on the box. The server caps
446
+ * trials per processor (default 3); when they're used up it returns a buy link instead. The
447
+ * box never needs internet; this client (which always has it) relays the key via ACTIVATE.
448
+ */
449
+ async startTrial() {
450
+ const status = await this.licenseStatus();
451
+ const mac = String(status.mac ?? "");
452
+ if (!mac)
453
+ return { success: false, error: "Could not read the processor's MAC to start a trial." };
454
+ if (status.licensed === true && status.time_limited !== true) {
455
+ return { success: false, error: "This processor already has a full (perpetual) license; no trial needed." };
456
+ }
457
+ let data;
458
+ try {
459
+ const res = await fetch(`${TRIAL_URL}?mac=${encodeURIComponent(mac)}`, { method: "GET" });
460
+ data = (await res.json().catch(() => ({})));
461
+ if (!res.ok || data.error) {
462
+ const buyUrl = data.buy_url || `${PORTAL_URL}?mac=${mac}`;
463
+ if (data.error === "trials_exhausted") {
464
+ return {
465
+ success: false,
466
+ trials_exhausted: true,
467
+ error: "All free trials for this processor have been used.",
468
+ buy_url: buyUrl,
469
+ mac,
470
+ price: "AUD $249 inc GST (perpetual)",
471
+ // Surface the buy link as a discrete field with an explicit directive: the model reads
472
+ // results fresh, so this is the reliable place to make it offer the link proactively.
473
+ next_step: "Give the user this exact buy_url (their processor MAC is already filled in); they buy " +
474
+ "a key, paste it back, and you activate it with activate_crestron_license. Offer the " +
475
+ "link now, don't wait to be asked.",
476
+ };
477
+ }
478
+ return { success: false, error: data.error || `Trial request failed (HTTP ${res.status}).` };
479
+ }
480
+ }
481
+ catch (e) {
482
+ return {
483
+ success: false,
484
+ error: `Could not reach the licensing server: ${e instanceof Error ? e.message : String(e)}`,
485
+ };
486
+ }
487
+ const blob = String(data.blob || "");
488
+ if (!blob)
489
+ return { success: false, error: "The licensing server returned no trial key." };
490
+ const act = await this.activateLicense(blob);
491
+ if (!act.success)
492
+ return { success: false, error: `Trial key rejected by the processor: ${act.error}` };
493
+ return {
494
+ success: true,
495
+ trial_started: true,
496
+ expires: data.expiry ?? null,
497
+ trials_remaining: data.trials_remaining ?? null,
498
+ trial_seq: data.trial_seq ?? null,
499
+ trial_max: data.trial_max ?? null,
500
+ licensed_to: act.licensed_to ?? "Trial",
501
+ // Provenance so the assistant answers "how/where did this happen" from data, not by
502
+ // guessing: the HTTPS fetch to the licensing server happens inside this client, which the
503
+ // model can't see, so name it explicitly here.
504
+ issued_by: TRIAL_HOST,
505
+ data_sent: "processor MAC only",
506
+ stored: "on the processor (persists across reboots)",
507
+ };
508
+ }
509
+ /** Read the device's STATE just after an action and summarize it for the LLM. settleMs lets an
510
+ * immediate set's feedback join settle first; ramps/pulses/pending are server-tracked so they
511
+ * pass 0. Never throws: a failed read-back is reported in the result, not fatal to the action. */
512
+ async confirm(deviceId, settleMs, commanded) {
513
+ try {
514
+ if (settleMs > 0)
515
+ await new Promise((r) => setTimeout(r, settleMs));
516
+ const state = await this.queryDevice(deviceId);
517
+ return { state, status: summarizeState(state, commanded) };
518
+ }
519
+ catch (e) {
520
+ const msg = e instanceof Error ? e.message : String(e);
521
+ return { state: { error: msg }, status: `could not read back state: ${msg}` };
522
+ }
523
+ }
524
+ async setDevice(deviceId, value, delayMs = 0) {
525
+ // delayMs > 0 -> SET_AFTER (the processor runs the set after the delay); else plain SET.
526
+ const command = delayMs > 0 ? `SET_AFTER:${deviceId}:${delayMs}:${value}` : `SET:${deviceId}:${value}`;
527
+ const response = await this.sendCommand(command);
528
+ if (response.startsWith("ERROR")) {
529
+ return { success: false, error: this.formatError(response) };
530
+ }
531
+ if (response.startsWith("OK")) {
532
+ const result = { success: true, device_id: deviceId, value };
533
+ if (delayMs > 0)
534
+ result.delay_ms = delayMs;
535
+ // Confirm: a delayed set is pending (server-tracked, read now); an immediate set needs a
536
+ // brief settle so the feedback join has moved before we read it back.
537
+ if (CONFIRM) {
538
+ const c = await this.confirm(deviceId, delayMs > 0 ? 0 : SETTLE_MS, delayMs > 0 ? undefined : value);
539
+ result.status = c.status;
540
+ result.confirmed = c.state;
541
+ }
542
+ return result;
543
+ }
544
+ return { success: false, error: "Invalid response" };
545
+ }
546
+ /**
547
+ * Apply a scene: set many devices in one tool call, each optionally fading (duration_ms,
548
+ * analog) and/or starting after a delay (delay_ms). Plain entries are sent together as one
549
+ * BATCH_SET; timed entries map to RAMP / SET_AFTER / delayed-RAMP. Returns per-device results.
550
+ * BATCH_SET (the plain entries) can't carry commas in a value; timed entries are sent
551
+ * individually so they can.
552
+ */
553
+ async setDevices(assignments) {
554
+ if (!assignments || assignments.length === 0)
555
+ return { success: false, error: "No devices given" };
556
+ const results = [];
557
+ const hasDur = (a) => typeof a.duration_ms === "number" && a.duration_ms > 0;
558
+ const hasDel = (a) => typeof a.delay_ms === "number" && a.delay_ms > 0;
559
+ const instant = assignments.filter((a) => !hasDur(a) && !hasDel(a));
560
+ const timed = assignments.filter((a) => hasDur(a) || hasDel(a));
561
+ if (instant.length > 0) {
562
+ const pairs = instant.map((a) => `${a.device_id}:${a.value}`).join(",");
563
+ const resp = await this.sendCommand(`BATCH_SET:${pairs}`);
564
+ if (resp.startsWith("DATA:")) {
565
+ for (const r of JSON.parse(resp.slice(5)))
566
+ results.push(r);
567
+ }
568
+ else {
569
+ const err = this.formatError(resp);
570
+ for (const a of instant)
571
+ results.push({ id: a.device_id, success: false, error: err });
572
+ }
573
+ }
574
+ for (const a of timed) {
575
+ const dur = hasDur(a) ? a.duration_ms : 0;
576
+ const del = hasDel(a) ? a.delay_ms : 0;
577
+ let command;
578
+ if (dur > 0 && del > 0)
579
+ command = `RAMP:${a.device_id}:${a.value}:${dur}:${del}`;
580
+ else if (dur > 0)
581
+ command = `RAMP:${a.device_id}:${a.value}:${dur}`;
582
+ else
583
+ command = `SET_AFTER:${a.device_id}:${del}:${a.value}`;
584
+ const resp = await this.sendCommand(command);
585
+ if (resp.startsWith("OK")) {
586
+ const r = { id: a.device_id, success: true, value: a.value };
587
+ if (dur > 0)
588
+ r.duration_ms = dur;
589
+ if (del > 0)
590
+ r.delay_ms = del;
591
+ results.push(r);
592
+ }
593
+ else {
594
+ results.push({ id: a.device_id, success: false, error: this.formatError(resp) });
595
+ }
596
+ }
597
+ // Confirm each device that took, folding its live state into that entry. Settle once up front
598
+ // if any instant entries were sent (their feedback joins lag); timed entries read instantly.
599
+ if (CONFIRM && results.length > 0) {
600
+ if (instant.length > 0 && SETTLE_MS > 0)
601
+ await new Promise((r) => setTimeout(r, SETTLE_MS));
602
+ for (const r of results) {
603
+ if (r.success === false)
604
+ continue;
605
+ const id = String(r.id ?? "");
606
+ if (!id)
607
+ continue;
608
+ const c = await this.confirm(id, 0);
609
+ r.status = c.status;
610
+ r.confirmed = c.state;
611
+ }
612
+ }
613
+ return { success: true, results };
614
+ }
615
+ /**
616
+ * Cancel activity on a device: stop a ramp (leaving the level where it is), release a
617
+ * pulse in progress to off, and clear any pending delayed action. Does not otherwise
618
+ * change the device's value (a device simply on/high from a SET stays so).
619
+ */
620
+ async cancelDevice(deviceId) {
621
+ const response = await this.sendCommand(`CANCEL:${deviceId}`);
622
+ if (response.startsWith("ERROR")) {
623
+ return { success: false, error: this.formatError(response) };
624
+ }
625
+ if (response.startsWith("OK")) {
626
+ return { success: true, device_id: deviceId, cancelled: true };
627
+ }
628
+ return { success: false, error: "Invalid response" };
629
+ }
630
+ /**
631
+ * Momentary pulse on a DIGITAL device: drive it high for pulseMs (after an optional
632
+ * pre-delay), then back low. Digital devices only; analog/serial are rejected.
633
+ */
634
+ async pulseDevice(deviceId, pulseMs, delayMs = 0) {
635
+ const command = delayMs > 0 ? `PULSE:${deviceId}:${pulseMs}:${delayMs}` : `PULSE:${deviceId}:${pulseMs}`;
636
+ const response = await this.sendCommand(command);
637
+ if (response.startsWith("ERROR")) {
638
+ return { success: false, error: this.formatError(response) };
639
+ }
640
+ if (response.startsWith("OK")) {
641
+ const result = { success: true, device_id: deviceId, pulse_ms: pulseMs };
642
+ if (delayMs > 0)
643
+ result.delay_ms = delayMs;
644
+ // Pulse (and any pre-delay) is tracked server-side, so STATE reports it instantly: no settle.
645
+ if (CONFIRM) {
646
+ const c = await this.confirm(deviceId, 0);
647
+ result.status = c.status;
648
+ result.confirmed = c.state;
649
+ }
650
+ return result;
651
+ }
652
+ return { success: false, error: "Invalid response" };
653
+ }
654
+ async rampDevice(deviceId, value, durationMs, delayMs = 0) {
655
+ // delayMs > 0 -> the processor starts the fade after the delay (scheduled); else immediate.
656
+ const command = delayMs > 0 ? `RAMP:${deviceId}:${value}:${durationMs}:${delayMs}` : `RAMP:${deviceId}:${value}:${durationMs}`;
657
+ const response = await this.sendCommand(command);
658
+ if (response.startsWith("ERROR")) {
659
+ return { success: false, error: this.formatError(response) };
660
+ }
661
+ if (response.startsWith("OK")) {
662
+ const result = { success: true, device_id: deviceId, value, duration_ms: durationMs };
663
+ if (delayMs > 0)
664
+ result.delay_ms = delayMs;
665
+ // The fade (or its scheduled start) is tracked server-side, so STATE shows "fading to X" /
666
+ // pending right away: no settle, and don't flag a mid-fade value as a mismatch.
667
+ if (CONFIRM) {
668
+ const c = await this.confirm(deviceId, 0);
669
+ result.status = c.status;
670
+ result.confirmed = c.state;
671
+ }
672
+ return result;
673
+ }
674
+ return { success: false, error: "Invalid response" };
675
+ }
676
+ /** Surface a protocol ERROR (e.g. 1009 authentication required, 1011/1012 license)
677
+ * instead of letting a non-DATA response masquerade as an empty result. License
678
+ * errors are expanded into step-by-step guidance via formatError. */
679
+ raiseIfError(response) {
680
+ if (response.startsWith("ERROR")) {
681
+ throw new Error(this.formatError(response));
682
+ }
683
+ }
684
+ async discoverSystem() {
685
+ const response = await this.sendCommand("DISCOVER");
686
+ if (response.startsWith("DATA:"))
687
+ return JSON.parse(response.slice(5));
688
+ this.raiseIfError(response);
689
+ return { error: "Failed to discover system" };
690
+ }
691
+ async listRooms() {
692
+ const response = await this.sendCommand("LIST_ROOMS");
693
+ if (response.startsWith("DATA:"))
694
+ return JSON.parse(response.slice(5));
695
+ this.raiseIfError(response);
696
+ return [];
697
+ }
698
+ async listDevices(room, category) {
699
+ // Always fetch the full list and filter client-side, case-insensitively, so
700
+ // room/category matching is robust regardless of the processor's filter
701
+ // quirks (its LIST_DEVICES:<room> matches the room NAME case-sensitively and
702
+ // not the room id, so an LLM passing the lowercase id gets nothing). Device
703
+ // counts are small, so fetching all and filtering here is cheap.
704
+ const response = await this.sendCommand("LIST_DEVICES");
705
+ if (!response.startsWith("DATA:")) {
706
+ this.raiseIfError(response);
707
+ return [];
708
+ }
709
+ const parsed = JSON.parse(response.slice(5));
710
+ if (!Array.isArray(parsed))
711
+ return parsed;
712
+ let devices = parsed;
713
+ if (room) {
714
+ const r = room.trim().toLowerCase();
715
+ // match the device's room name OR id (discovery exposes both)
716
+ devices = devices.filter((d) => {
717
+ const name = String(d?.room ?? "").toLowerCase();
718
+ const id = String(d?.room_id ?? "").toLowerCase();
719
+ return name === r || id === r;
720
+ });
721
+ }
722
+ if (category) {
723
+ const c = category.trim().toLowerCase();
724
+ devices = devices.filter((d) => String(d?.category ?? "").toLowerCase() === c);
725
+ }
726
+ return devices;
727
+ }
728
+ }