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 +21 -0
- package/README.md +51 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/qrc.d.ts +159 -0
- package/dist/qrc.js +392 -0
- package/package.json +37 -0
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
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
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
|
+
}
|