qsys-qrc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Robert Owens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # qsys-qrc
2
+
3
+ TypeScript client for QSC's **Q-SYS Remote Control (QRC)** protocol — the
4
+ null-terminated JSON-RPC-over-TCP interface every Q-SYS Core (and Q-SYS
5
+ Designer in Emulate mode) serves on port 1710.
6
+
7
+ Handles the wire framing, request/response correlation, change groups,
8
+ keepalive, and transparent auto-reconnect, and ships shared types for
9
+ controls, components, and engine status. It is the core that the
10
+ [`qsys`](https://www.npmjs.com/package/qsys) CLI and the
11
+ [`q-sys-mcp`](https://www.npmjs.com/package/q-sys-mcp) server build on.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ npm install qsys-qrc
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { QrcClient } from 'qsys-qrc';
23
+
24
+ const qrc = new QrcClient({ host: '192.168.1.10' });
25
+ await qrc.connect();
26
+
27
+ const status = await qrc.statusGet(); // typed helpers…
28
+ await qrc.setControl('MainGain', -6, 2); // value -6, 2 s ramp
29
+ const [gain] = await qrc.getControl(['MainGain']);
30
+
31
+ await qrc.send('Component.GetComponents'); // …or raw QRC methods
32
+
33
+ qrc.close();
34
+ ```
35
+
36
+ Typed helpers cover the whole protocol surface: status, components,
37
+ named controls, change groups (poll-based watch), and snapshots.
38
+
39
+ The client keeps the socket alive (`NoOp` keepalive) and reconnects
40
+ transparently if the Core drops the connection; in-flight requests are
41
+ rejected, subsequent ones reuse the new socket.
42
+
43
+ ## Disclaimer
44
+
45
+ This is an independent open-source project, **not affiliated with, endorsed
46
+ by, or supported by QSC, LLC**. "Q-SYS" is a trademark of QSC. The client
47
+ speaks the publicly documented QRC protocol and contains no QSC code.
48
+
49
+ ## License
50
+
51
+ MIT
@@ -0,0 +1,2 @@
1
+ /** qsys-qrc — the QRC client, wire framing, and shared types. */
2
+ export * from './qrc.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ /** qsys-qrc — the QRC client, wire framing, and shared types. */
2
+ export * from './qrc.js';
package/dist/qrc.d.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { EventEmitter } from 'node:events';
2
+ export interface QrcClientOptions {
3
+ host: string;
4
+ port?: number;
5
+ /** Keepalive interval in ms. QRC closes idle sockets after 60s; default 30s. */
6
+ keepAliveMs?: number;
7
+ /** Per-request timeout in ms (default 10s). */
8
+ requestTimeoutMs?: number;
9
+ /**
10
+ * Auto-reconnect on an unexpected socket drop (Core restart, leaving Emulate
11
+ * mode, a network blip), replaying logon + change-group registrations so
12
+ * polling resumes seamlessly. Default true. An explicit close() disables it.
13
+ */
14
+ reconnect?: boolean;
15
+ /** Initial reconnect backoff in ms (default 500). */
16
+ reconnectInitialMs?: number;
17
+ /** Max reconnect backoff in ms (default 10_000). */
18
+ reconnectMaxMs?: number;
19
+ /** Consecutive background reconnect attempts before giving up until the next request (default 8). */
20
+ reconnectMaxAttempts?: number;
21
+ }
22
+ export declare class QrcError extends Error {
23
+ code?: number;
24
+ data?: unknown;
25
+ constructor(err: unknown);
26
+ }
27
+ /**
28
+ * Q-SYS Remote Control (QRC) client.
29
+ * Speaks JSON-RPC 2.0 over a raw TCP socket (default port 1710), framed with
30
+ * null terminators — the wire format QSC documents and that the Designer
31
+ * Emulate-mode soft-core serves on localhost.
32
+ *
33
+ * Events: 'engineStatus' (params), 'notification' (full message), 'error', 'close',
34
+ * plus reconnect lifecycle: 'reconnecting' (attempt #), 'reconnected',
35
+ * 'reconnectError' ({attempt, error}), 'reconnectFailed'.
36
+ */
37
+ export declare class QrcClient extends EventEmitter {
38
+ private readonly host;
39
+ private readonly port;
40
+ private readonly keepAliveMs;
41
+ private readonly requestTimeoutMs;
42
+ private readonly reconnectEnabled;
43
+ private readonly reconnectInitialMs;
44
+ private readonly reconnectMaxMs;
45
+ private readonly reconnectMaxAttempts;
46
+ private socket;
47
+ private buf;
48
+ private nextId;
49
+ private readonly pending;
50
+ private keepAliveTimer;
51
+ private connected;
52
+ private explicitlyClosed;
53
+ private reconnectPromise;
54
+ private logonCreds;
55
+ private readonly changeGroups;
56
+ constructor(opts: QrcClientOptions);
57
+ isConnected(): boolean;
58
+ connect(): Promise<void>;
59
+ /** Open a fresh socket and wire its handlers. Rejects if the TCP connect fails. */
60
+ private openSocket;
61
+ private onData;
62
+ private dispatch;
63
+ /**
64
+ * Send a JSON-RPC request and await the correlated response. If the socket is
65
+ * down (or drops mid-request) and auto-reconnect is enabled, this waits for a
66
+ * reconnect and retries once — connection drops are transparent to callers.
67
+ */
68
+ send(method: string, params?: unknown): Promise<unknown>;
69
+ /** One request attempt on the current socket — no reconnect handling. */
70
+ private sendOnce;
71
+ /** A connection drop is retryable; a timeout (socket still up) is not. */
72
+ private shouldRetryAfterDrop;
73
+ /** Fire-and-forget notification (no id, no response expected). */
74
+ notify(method: string, params?: unknown): void;
75
+ private startKeepAlive;
76
+ private stopKeepAlive;
77
+ private onClose;
78
+ /** Resolve once connected, driving (or joining) a reconnect attempt as needed. */
79
+ private ensureReconnected;
80
+ /**
81
+ * Re-dial with exponential backoff, then replay logon + change-group
82
+ * registrations so polling resumes. Never rejects: callers read isConnected()
83
+ * (or a subsequent sendOnce) for the outcome. Gives up after maxAttempts, but
84
+ * the next request re-triggers a fresh attempt.
85
+ */
86
+ private runReconnect;
87
+ /** Re-establish session state on a fresh socket. Throws if any step fails. */
88
+ private replayState;
89
+ /** Tear down the current socket without triggering reconnect (used between attempts). */
90
+ private teardownSocket;
91
+ private sleep;
92
+ close(): void;
93
+ /** Emit 'error' if anyone is listening; otherwise log to stderr instead of throwing. */
94
+ private emitError;
95
+ logon(user: string, password: string): Promise<unknown>;
96
+ statusGet(): Promise<EngineStatus>;
97
+ getComponents(): Promise<QrcComponent[]>;
98
+ getComponentControls(name: string): Promise<{
99
+ Name: string;
100
+ Controls: QrcControl[];
101
+ }>;
102
+ getComponent(name: string, controls: string[]): Promise<{
103
+ Name: string;
104
+ Controls: QrcControl[];
105
+ }>;
106
+ setComponent(name: string, controls: Array<{
107
+ Name: string;
108
+ Value: ControlValue;
109
+ Ramp?: number;
110
+ }>): Promise<unknown>;
111
+ getControl(names: string[]): Promise<QrcControl[]>;
112
+ setControl(name: string, value: ControlValue, ramp?: number): Promise<unknown>;
113
+ changeGroupAddControl(id: string, controls: string[]): Promise<unknown>;
114
+ changeGroupAddComponentControl(id: string, component: string, controls: string[]): Promise<unknown>;
115
+ changeGroupPoll(id: string): Promise<{
116
+ Id: string;
117
+ Changes: QrcControl[];
118
+ }>;
119
+ changeGroupRemove(id: string, controls: string[]): Promise<unknown>;
120
+ changeGroupClear(id: string): Promise<unknown>;
121
+ changeGroupInvalidate(id: string): Promise<unknown>;
122
+ changeGroupDestroy(id: string): Promise<unknown>;
123
+ /**
124
+ * Recall a saved snapshot. Note: QRC's `Bank` param is the snapshot *number*
125
+ * within a named bank — `bank` here is the bank name, `number` the slot.
126
+ */
127
+ snapshotLoad(bank: string, number: number, ramp?: number): Promise<unknown>;
128
+ snapshotSave(bank: string, number: number): Promise<unknown>;
129
+ private groupState;
130
+ }
131
+ export type ControlValue = number | string | boolean;
132
+ export interface EngineStatus {
133
+ Platform: string;
134
+ State: string;
135
+ DesignName: string;
136
+ DesignCode: string;
137
+ IsRedundant: boolean;
138
+ IsEmulator: boolean;
139
+ Status: {
140
+ Code: number;
141
+ String: string;
142
+ };
143
+ }
144
+ export interface QrcComponent {
145
+ ID?: string;
146
+ Name: string;
147
+ Type: string;
148
+ Properties?: Array<{
149
+ Name: string;
150
+ Value: string;
151
+ PrettyName?: string;
152
+ }>;
153
+ }
154
+ export interface QrcControl {
155
+ Name: string;
156
+ Value: ControlValue;
157
+ String?: string;
158
+ Position?: number;
159
+ }
package/dist/qrc.js ADDED
@@ -0,0 +1,392 @@
1
+ import net from 'node:net';
2
+ import { EventEmitter } from 'node:events';
3
+ export class QrcError extends Error {
4
+ code;
5
+ data;
6
+ constructor(err) {
7
+ const o = (err && typeof err === 'object') ? err : null;
8
+ super(o ? String(o.message ?? JSON.stringify(err)) : String(err));
9
+ this.name = 'QrcError';
10
+ if (o) {
11
+ this.code = typeof o.code === 'number' ? o.code : undefined;
12
+ this.data = o.data;
13
+ }
14
+ }
15
+ }
16
+ /**
17
+ * Q-SYS Remote Control (QRC) client.
18
+ * Speaks JSON-RPC 2.0 over a raw TCP socket (default port 1710), framed with
19
+ * null terminators — the wire format QSC documents and that the Designer
20
+ * Emulate-mode soft-core serves on localhost.
21
+ *
22
+ * Events: 'engineStatus' (params), 'notification' (full message), 'error', 'close',
23
+ * plus reconnect lifecycle: 'reconnecting' (attempt #), 'reconnected',
24
+ * 'reconnectError' ({attempt, error}), 'reconnectFailed'.
25
+ */
26
+ export class QrcClient extends EventEmitter {
27
+ host;
28
+ port;
29
+ keepAliveMs;
30
+ requestTimeoutMs;
31
+ reconnectEnabled;
32
+ reconnectInitialMs;
33
+ reconnectMaxMs;
34
+ reconnectMaxAttempts;
35
+ socket = null;
36
+ buf = '';
37
+ nextId = 1;
38
+ pending = new Map();
39
+ keepAliveTimer = null;
40
+ connected = false;
41
+ explicitlyClosed = false;
42
+ reconnectPromise = null;
43
+ logonCreds = null;
44
+ changeGroups = new Map();
45
+ constructor(opts) {
46
+ super();
47
+ this.host = opts.host;
48
+ this.port = opts.port ?? 1710;
49
+ this.keepAliveMs = opts.keepAliveMs ?? 30_000;
50
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? 10_000;
51
+ this.reconnectEnabled = opts.reconnect ?? true;
52
+ this.reconnectInitialMs = opts.reconnectInitialMs ?? 500;
53
+ this.reconnectMaxMs = opts.reconnectMaxMs ?? 10_000;
54
+ this.reconnectMaxAttempts = opts.reconnectMaxAttempts ?? 8;
55
+ }
56
+ isConnected() {
57
+ return this.connected;
58
+ }
59
+ async connect() {
60
+ this.explicitlyClosed = false;
61
+ await this.openSocket();
62
+ }
63
+ /** Open a fresh socket and wire its handlers. Rejects if the TCP connect fails. */
64
+ openSocket() {
65
+ return new Promise((resolve, reject) => {
66
+ const sock = net.createConnection({ host: this.host, port: this.port });
67
+ sock.setEncoding('utf8');
68
+ const onConnectError = (e) => reject(e);
69
+ sock.once('error', onConnectError);
70
+ sock.once('connect', () => {
71
+ sock.removeListener('error', onConnectError);
72
+ this.socket = sock;
73
+ this.connected = true;
74
+ this.buf = '';
75
+ sock.on('data', (chunk) => this.onData(typeof chunk === 'string' ? chunk : chunk.toString('utf8')));
76
+ sock.on('error', (e) => this.emitError(e));
77
+ sock.on('close', () => this.onClose());
78
+ this.startKeepAlive();
79
+ resolve();
80
+ });
81
+ });
82
+ }
83
+ onData(chunk) {
84
+ this.buf += chunk;
85
+ let idx;
86
+ while ((idx = this.buf.indexOf('\0')) !== -1) {
87
+ const raw = this.buf.slice(0, idx);
88
+ this.buf = this.buf.slice(idx + 1);
89
+ if (!raw.trim())
90
+ continue;
91
+ let msg;
92
+ try {
93
+ msg = JSON.parse(raw);
94
+ }
95
+ catch {
96
+ this.emitError(new Error(`QRC parse error: ${raw.slice(0, 200)}`));
97
+ continue;
98
+ }
99
+ this.dispatch(msg);
100
+ }
101
+ }
102
+ dispatch(msg) {
103
+ const id = msg.id;
104
+ if (typeof id === 'number' && this.pending.has(id)) {
105
+ const p = this.pending.get(id);
106
+ this.pending.delete(id);
107
+ clearTimeout(p.timer);
108
+ if (msg.error)
109
+ p.reject(new QrcError(msg.error));
110
+ else
111
+ p.resolve(msg.result);
112
+ return;
113
+ }
114
+ // Unsolicited notification (EngineStatus on connect, change-group autopolls, etc.)
115
+ if (typeof msg.method === 'string') {
116
+ if (msg.method === 'EngineStatus')
117
+ this.emit('engineStatus', msg.params);
118
+ this.emit('notification', msg);
119
+ }
120
+ }
121
+ /**
122
+ * Send a JSON-RPC request and await the correlated response. If the socket is
123
+ * down (or drops mid-request) and auto-reconnect is enabled, this waits for a
124
+ * reconnect and retries once — connection drops are transparent to callers.
125
+ */
126
+ async send(method, params) {
127
+ if (!this.connected)
128
+ await this.ensureReconnected();
129
+ try {
130
+ return await this.sendOnce(method, params);
131
+ }
132
+ catch (err) {
133
+ if (this.shouldRetryAfterDrop(err)) {
134
+ await this.ensureReconnected();
135
+ if (this.connected)
136
+ return await this.sendOnce(method, params);
137
+ }
138
+ throw err;
139
+ }
140
+ }
141
+ /** One request attempt on the current socket — no reconnect handling. */
142
+ sendOnce(method, params) {
143
+ if (!this.socket || !this.connected) {
144
+ return Promise.reject(new Error('QRC not connected — call connect() first'));
145
+ }
146
+ const id = this.nextId++;
147
+ const frame = JSON.stringify({ jsonrpc: '2.0', method, params: params ?? null, id }) + '\0';
148
+ return new Promise((resolve, reject) => {
149
+ const timer = setTimeout(() => {
150
+ this.pending.delete(id);
151
+ reject(new Error(`QRC request timed out: ${method} (id ${id})`));
152
+ }, this.requestTimeoutMs);
153
+ this.pending.set(id, { resolve, reject, timer });
154
+ this.socket.write(frame);
155
+ });
156
+ }
157
+ /** A connection drop is retryable; a timeout (socket still up) is not. */
158
+ shouldRetryAfterDrop(err) {
159
+ if (this.explicitlyClosed || !this.reconnectEnabled)
160
+ return false;
161
+ const m = err?.message ?? '';
162
+ return m.includes('QRC connection closed') || m.includes('QRC not connected');
163
+ }
164
+ /** Fire-and-forget notification (no id, no response expected). */
165
+ notify(method, params) {
166
+ if (!this.socket || !this.connected)
167
+ return;
168
+ this.socket.write(JSON.stringify({ jsonrpc: '2.0', method, params: params ?? null }) + '\0');
169
+ }
170
+ startKeepAlive() {
171
+ this.stopKeepAlive();
172
+ this.keepAliveTimer = setInterval(() => this.notify('NoOp', {}), this.keepAliveMs);
173
+ this.keepAliveTimer.unref?.();
174
+ }
175
+ stopKeepAlive() {
176
+ if (this.keepAliveTimer) {
177
+ clearInterval(this.keepAliveTimer);
178
+ this.keepAliveTimer = null;
179
+ }
180
+ }
181
+ onClose() {
182
+ this.connected = false;
183
+ this.stopKeepAlive();
184
+ for (const p of this.pending.values()) {
185
+ clearTimeout(p.timer);
186
+ p.reject(new Error('QRC connection closed'));
187
+ }
188
+ this.pending.clear();
189
+ this.emit('close');
190
+ // Kick a background reconnect on an unexpected drop. The !reconnectPromise
191
+ // guard means a drop during an in-flight reconnect attempt won't start a
192
+ // second loop (and our own teardownSocket() won't either).
193
+ if (this.reconnectEnabled && !this.explicitlyClosed && !this.reconnectPromise) {
194
+ void this.ensureReconnected();
195
+ }
196
+ }
197
+ /** Resolve once connected, driving (or joining) a reconnect attempt as needed. */
198
+ ensureReconnected() {
199
+ if (this.connected)
200
+ return Promise.resolve();
201
+ if (this.explicitlyClosed || !this.reconnectEnabled)
202
+ return Promise.resolve();
203
+ if (!this.reconnectPromise) {
204
+ this.reconnectPromise = this.runReconnect().finally(() => {
205
+ this.reconnectPromise = null;
206
+ });
207
+ }
208
+ return this.reconnectPromise;
209
+ }
210
+ /**
211
+ * Re-dial with exponential backoff, then replay logon + change-group
212
+ * registrations so polling resumes. Never rejects: callers read isConnected()
213
+ * (or a subsequent sendOnce) for the outcome. Gives up after maxAttempts, but
214
+ * the next request re-triggers a fresh attempt.
215
+ */
216
+ async runReconnect() {
217
+ let delay = this.reconnectInitialMs;
218
+ for (let attempt = 1; attempt <= this.reconnectMaxAttempts; attempt++) {
219
+ if (this.explicitlyClosed)
220
+ return;
221
+ this.emit('reconnecting', attempt);
222
+ try {
223
+ await this.openSocket();
224
+ await this.replayState();
225
+ this.emit('reconnected');
226
+ return;
227
+ }
228
+ catch (err) {
229
+ this.teardownSocket();
230
+ this.emit('reconnectError', { attempt, error: err });
231
+ if (attempt >= this.reconnectMaxAttempts)
232
+ break;
233
+ await this.sleep(delay);
234
+ delay = Math.min(delay * 2, this.reconnectMaxMs);
235
+ }
236
+ }
237
+ this.emit('reconnectFailed');
238
+ }
239
+ /** Re-establish session state on a fresh socket. Throws if any step fails. */
240
+ async replayState() {
241
+ if (this.logonCreds) {
242
+ await this.sendOnce('Logon', { User: this.logonCreds.user, Password: this.logonCreds.password });
243
+ }
244
+ for (const [id, g] of this.changeGroups) {
245
+ if (g.controls.size > 0) {
246
+ await this.sendOnce('ChangeGroup.AddControl', { Id: id, Controls: [...g.controls] });
247
+ }
248
+ for (const [component, controls] of g.components) {
249
+ if (controls.size > 0) {
250
+ await this.sendOnce('ChangeGroup.AddComponentControl', {
251
+ Id: id,
252
+ Component: { Name: component, Controls: [...controls].map((n) => ({ Name: n })) },
253
+ });
254
+ }
255
+ }
256
+ }
257
+ }
258
+ /** Tear down the current socket without triggering reconnect (used between attempts). */
259
+ teardownSocket() {
260
+ this.stopKeepAlive();
261
+ if (this.socket) {
262
+ this.socket.removeAllListeners();
263
+ this.socket.destroy();
264
+ this.socket = null;
265
+ }
266
+ this.connected = false;
267
+ }
268
+ sleep(ms) {
269
+ return new Promise((resolve) => {
270
+ const t = setTimeout(resolve, ms);
271
+ t.unref?.();
272
+ });
273
+ }
274
+ close() {
275
+ this.explicitlyClosed = true;
276
+ this.stopKeepAlive();
277
+ if (this.socket) {
278
+ this.socket.end();
279
+ this.socket = null;
280
+ }
281
+ this.connected = false;
282
+ this.changeGroups.clear();
283
+ this.logonCreds = null;
284
+ }
285
+ /** Emit 'error' if anyone is listening; otherwise log to stderr instead of throwing. */
286
+ emitError(err) {
287
+ if (this.listenerCount('error') > 0)
288
+ this.emit('error', err);
289
+ else
290
+ console.error('[qrc]', err.message);
291
+ }
292
+ // ---- QRC method wrappers ----
293
+ async logon(user, password) {
294
+ const result = await this.send('Logon', { User: user, Password: password });
295
+ this.logonCreds = { user, password }; // remembered so reconnect can re-auth
296
+ return result;
297
+ }
298
+ statusGet() {
299
+ return this.send('StatusGet', 0);
300
+ }
301
+ getComponents() {
302
+ return this.send('Component.GetComponents', null);
303
+ }
304
+ getComponentControls(name) {
305
+ return this.send('Component.GetControls', { Name: name });
306
+ }
307
+ getComponent(name, controls) {
308
+ return this.send('Component.Get', {
309
+ Name: name,
310
+ Controls: controls.map((n) => ({ Name: n })),
311
+ });
312
+ }
313
+ setComponent(name, controls) {
314
+ return this.send('Component.Set', { Name: name, Controls: controls });
315
+ }
316
+ getControl(names) {
317
+ return this.send('Control.Get', names);
318
+ }
319
+ setControl(name, value, ramp) {
320
+ const params = { Name: name, Value: value };
321
+ if (ramp != null)
322
+ params.Ramp = ramp;
323
+ return this.send('Control.Set', params);
324
+ }
325
+ async changeGroupAddControl(id, controls) {
326
+ const result = await this.send('ChangeGroup.AddControl', { Id: id, Controls: controls });
327
+ const g = this.groupState(id);
328
+ for (const c of controls)
329
+ g.controls.add(c);
330
+ return result;
331
+ }
332
+ async changeGroupAddComponentControl(id, component, controls) {
333
+ const result = await this.send('ChangeGroup.AddComponentControl', {
334
+ Id: id,
335
+ Component: { Name: component, Controls: controls.map((n) => ({ Name: n })) },
336
+ });
337
+ const set = this.groupState(id).components.get(component) ?? new Set();
338
+ for (const c of controls)
339
+ set.add(c);
340
+ this.groupState(id).components.set(component, set);
341
+ return result;
342
+ }
343
+ changeGroupPoll(id) {
344
+ return this.send('ChangeGroup.Poll', { Id: id });
345
+ }
346
+ async changeGroupRemove(id, controls) {
347
+ const result = await this.send('ChangeGroup.Remove', { Id: id, Controls: controls });
348
+ const g = this.changeGroups.get(id);
349
+ if (g)
350
+ for (const c of controls)
351
+ g.controls.delete(c); // drop from replay state too
352
+ return result;
353
+ }
354
+ async changeGroupClear(id) {
355
+ const result = await this.send('ChangeGroup.Clear', { Id: id });
356
+ const g = this.changeGroups.get(id);
357
+ if (g) {
358
+ g.controls.clear();
359
+ g.components.clear();
360
+ }
361
+ return result;
362
+ }
363
+ changeGroupInvalidate(id) {
364
+ return this.send('ChangeGroup.Invalidate', { Id: id });
365
+ }
366
+ async changeGroupDestroy(id) {
367
+ const result = await this.send('ChangeGroup.Destroy', { Id: id });
368
+ this.changeGroups.delete(id); // stop replaying a group the caller tore down
369
+ return result;
370
+ }
371
+ /**
372
+ * Recall a saved snapshot. Note: QRC's `Bank` param is the snapshot *number*
373
+ * within a named bank — `bank` here is the bank name, `number` the slot.
374
+ */
375
+ snapshotLoad(bank, number, ramp) {
376
+ const params = { Name: bank, Bank: number };
377
+ if (ramp != null)
378
+ params.Ramp = ramp;
379
+ return this.send('Snapshot.Load', params);
380
+ }
381
+ snapshotSave(bank, number) {
382
+ return this.send('Snapshot.Save', { Name: bank, Bank: number });
383
+ }
384
+ groupState(id) {
385
+ let g = this.changeGroups.get(id);
386
+ if (!g) {
387
+ g = { controls: new Set(), components: new Map() };
388
+ this.changeGroups.set(id, g);
389
+ }
390
+ return g;
391
+ }
392
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "qsys-qrc",
3
+ "version": "0.1.0",
4
+ "description": "Q-SYS Remote Control (QRC) client, null-terminated JSON-RPC wire framing, and shared types — the core the qsys CLI and q-sys-mcp server both build on.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "prepare": "npm run build"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/reowens/qsys-tools.git",
26
+ "directory": "packages/qsys-qrc"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/reowens/qsys-tools/issues"
30
+ },
31
+ "homepage": "https://github.com/reowens/qsys-tools/tree/main/packages/qsys-qrc#readme",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "license": "MIT",
36
+ "keywords": ["q-sys", "qsys", "qsc", "qrc", "av"]
37
+ }