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.
- package/AGENT_GUIDE.md +298 -0
- package/LICENSE +21 -0
- package/PROTOCOL.md +650 -0
- package/README.md +81 -0
- package/dist/config.js +71 -0
- package/dist/connection.js +728 -0
- package/dist/index.js +285 -0
- package/mcpb/manifest.json +76 -0
- package/package.json +55 -0
|
@@ -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
|
+
}
|