Q-SYS QRWC — Remote WebSocket Control
Q-SYS Remote WebSocket Control (QRWC) is a beta NPM library that enables Node.js applications and web browsers to communicate with Q-SYS design controls over a WebSocket connection. It targets developers integrating custom software with Q-SYS systems and requires Q-SYS Designer version 10.0 or higher. Unlike the older QRC TCP protocol — which uses raw TCP on port 1710 and targets Crestron/AMX control systems — QRWC uses a standard WebSocket connection and is natively accessible from JavaScript environments without raw socket management. The library wraps the WebSocket in a clean component/control object model with typed event emitters.
Beta notice: QRWC is functional but not feature-complete as of Q-SYS 10.2, and the API is subject to change between releases. QSC assumes no responsibility for issues arising from its use. For production-critical integrations, QRC remains the stable alternative.
Requirements
| Requirement | Detail |
|---|---|
| Q-SYS Designer | Version 10.0 or newer |
| Runtime | Node.js or modern browser |
| Package | @q-sys/qrwc (NPM) |
| Core Manager | WebSocket service enabled under Network → Services |
| Design setting | Components must be marked as scriptable (Code Name and Script Access: External or All) |
npm install @q-sys/qrwc
Code Name and Script Access is a per-design setting in Q-SYS Designer (Design Properties). It must be set to External or All to expose components to QRWC. The default None blocks all external access. Only components marked as scriptable appear in qrwc.components — if a component is missing after connect, check this setting first.
QRWC is not supported in Emulation Mode. The Core must have a design loaded and actively in Run mode.
WebSocket Connection
QRWC connects via a standard WebSocket. The URL path is:
ws://[CoreIP]/qrc-public-api/v0
You create the WebSocket yourself and pass it to the library:
import { Qrwc } from '@q-sys/qrwc'
import WebSocket from 'ws' // Node.js; in browsers use native WebSocket
const socket = new WebSocket('ws://192.168.1.100/qrc-public-api/v0')
Port: QRWC uses port 443 (standard HTTPS/WSS). This is firewall-friendly compared to QRC's non-standard port 1710 — port 443 is open in virtually all enterprise networks.
Keepalive: The Core closes idle QRWC connections after 60 seconds of inactivity. The @q-sys/qrwc library handles keepalive automatically. Raw WebSocket implementations (without the library) must send at least one message every 60 seconds or the Core will close the socket.
Initializing QRWC
Qrwc.createQrwc() is async — it returns a Promise that resolves once the library has connected to the Core and discovered all scriptable components:
// TypeScript — generic parameter tells the compiler which components and controls exist
const qrwc = await Qrwc.createQrwc<{
Gain: 'gain' | 'mute'
Text_Box: 'text.1'
}>({
socket,
pollingInterval: 350, // optional — default 350 ms (~3×/sec); minimum 34 ms
})
// JavaScript — same thing without types
const qrwc = await Qrwc.createQrwc({ socket })
Start Options
| Option | Type | Default | Description |
|---|---|---|---|
socket | IWebSocket | Required | Open WebSocket instance connected to the Core |
pollingInterval | number (ms) | 350 | Polling interval for control changes; minimum 34 ms |
componentFilter | (componentState) => boolean | All scriptable | Filter callback — limit which design components are connected |
timeout | number (ms) | 5000 | Timeout for WebSocket message responses |
logger | Partial<ILogger> | None | Logger object; supports pino, console, or any compatible shape |
componentFilter is important in large designs. Connecting to every scriptable component in a complex design generates unnecessary polling traffic. Filter to only what the integration needs:
const qrwc = await Qrwc.createQrwc({
socket,
componentFilter: (componentState) => componentState.Name.startsWith('Room'),
logger: console
})
Accessing Components and Controls
After createQrwc() resolves, all discovered components are available via qrwc.components — a dictionary keyed by component Code Name:
// Access a component
const gainComponent = qrwc.components.Gain
// Access a control (standard name)
const gainControl = qrwc.components.Gain.controls.gain
// Access a control with a complex name (dot notation won't work)
const textControl = qrwc.components.Text_Box.controls['text.1']
// Components not in the TypeScript generic use optional chaining
const gain1 = qrwc.components.Gain_1?.controls.gain // Control | undefined
Control State Properties
Each control has a state object (read-only, replaced with a new reference on every update):
| Property | Type | Description |
|---|---|---|
Name | string | Control name |
Component | string | Parent component name |
Value | string | number | undefined | Numeric or string value |
String | string | undefined | Human-readable string representation |
Position | number | undefined | Normalized position 0.0–1.0 |
Bool | boolean | undefined | true if Position ≥ 0.5; only valid for Boolean-type controls |
Type | string | undefined | Control type (e.g., "Text", "Boolean", "Float") |
Additional properties (Direction, Choices, Color, Invisible, Disabled, Legend, etc.) are present in state even if not in the TypeScript type definition.
Listening for Control Updates
QRWC fires update events at three levels: global, component, and control. The control level is recommended for most integrations:
// Control level (recommended — most specific)
gainControl.on('update', (state) => {
console.log('Gain:', state.Value, state.String)
updateFaderUI(state.Value)
})
// Component level — all controls on a component
qrwc.components.Gain.on('update', (control, state) => {
console.log(`Gain.${control.name} changed:`, state.Value)
})
// Global level — all controls across all components
qrwc.on('update', (component, control, state) => {
console.log(`${component.name}.${control.name}:`, state.Value)
})
Updating Controls
Use control.update() — it returns a Promise that resolves to the new IControlState:
// By Value (number or string)
await gainControl.update(20) // { Value: 20 }
await gainControl.update({ Value: 20 }) // equivalent
// By Position (normalized 0.0–1.0)
await gainControl.update({ Position: 0.5 })
// By String
await textControl.update('Hello world')
await textControl.update({ String: 'Hello world' })
// By Bool (coerced to 1 or 0)
await muteControl.update(true) // { Value: 1 }
await muteControl.update({ Bool: true }) // equivalent
await muteControl.update(false) // { Value: 0 }
// Read back the new state
const newState = await gainControl.update(15)
console.log(newState.Value) // 15
console.log(newState.String) // e.g. "15.0 dB"
// Pass a full IControlState from one control to another
gain0.on('update', (state) => {
gain1.update(state) // mirror gain0 → gain1
})
Engine Status
On startup, QRWC requests and stores the Core's engine status. Access it synchronously after createQrwc() resolves:
const status = qrwc.engineStatus
/*
{
Platform: 'Core 8 Flex',
State: 'Active', // 'Active' | 'Standby' | 'Emulator'
DesignName: 'Room A',
DesignCode: 'JFtMjsiUg05G',
IsRedundant: false,
IsEmulator: false,
Status: { Code: 0, String: 'OK' }
}
*/
Redundant Core Handling
For systems with primary/backup redundant Cores:
- Open and maintain WebSocket connections to both Cores simultaneously.
- Check
engineStatus.Stateon each connection —"Active"vs"Standby". - Send all
control.update()calls to the Active Core only. Commands to the Standby are silently ignored. - Verify
DesignCodematches on both Cores — a mismatch means they are not running the same design. - Handle the failover window. Commands in-flight during failover can be missed. For critical state, poll and verify after the new Active Core is confirmed.
The Core sends updated engine status automatically when status changes — listen at the connection level (raw WebSocket onmessage) or check qrwc.engineStatus after reconnect.
Disconnection and Reconnection
QRWC fires a disconnected event when the WebSocket closes and automatically cleans up all listeners, intervals, and internal state. You must create a new WebSocket and a new Qrwc instance to reconnect — do not attempt to reuse a closed instance:
qrwc.on('disconnected', (reason) => {
console.log('Disconnected:', reason)
// reconnect strategy
setTimeout(async () => {
const newSocket = new WebSocket('ws://192.168.1.100/qrc-public-api/v0')
const newQrwc = await Qrwc.createQrwc({ socket: newSocket, pollingInterval: 350 })
// re-attach all event listeners to newQrwc
attachListeners(newQrwc)
}, 5000)
})
When finished normally, close QRWC explicitly to clean up:
qrwc.close()
Logger Integration
QRWC accepts an optional logger for debug output. Tested with pino and console:
// Using console (logs everything — no level threshold)
const qrwc = await Qrwc.createQrwc({ socket, logger: console })
// Selective console levels (omit trace to suppress verbose output)
const qrwc = await Qrwc.createQrwc({
socket,
logger: {
error: console.error,
warn: console.warn,
info: console.info,
debug: console.debug
// trace omitted — suppresses verbose polling logs
}
})
// Using pino (structured JSON logs)
import { pino } from 'pino'
import pretty from 'pino-pretty'
const qrwc = await Qrwc.createQrwc({
socket,
logger: pino({ level: 'info' }, pretty({ colorize: true }))
})
Official Examples
QSC maintains reference implementations in the public GitHub repository qsys-sd/qrwc:
- qrwc-node-example — Node.js TypeScript application demonstrating connection, component discovery, control subscription, and update patterns. The starting point for any server-side integration (REST bridge, booking system connector, building management adapter).
- qrwc-react-example — React browser application demonstrating the full QRWC lifecycle in a UI context: WebSocket creation,
Qrwc.createQrwc(), binding controlupdateevents to React state, and callingcontrol.update()from UI interactions.
These examples are pinned to a specific commit and reflect a tested, working API version. Clone the repo and run the examples against a local Core or emulator (note: QRWC does not work in Emulator Mode — a physical Core with a design in Run mode is required) before starting a custom integration.
Polling Model vs QRC Push Model
QRWC uses a polling model: the library polls the Core for control values at pollingInterval (default 350 ms). When a polled value differs from the last known state, an update event fires. This means:
- Control change latency is up to one polling interval (≤350 ms at default)
- Reduce
pollingIntervalto 100–150 ms for time-sensitive feedback (mute indicators, call status) - Minimum polling interval is 34 ms (~30×/sec) — below this the library rejects the value
- Higher polling rates generate more network traffic; use
componentFilterto limit scope when polling fast
QRC's change group model pushes updates immediately on change, with polling as a fallback. For extremely latency-sensitive integrations, QRC may be preferable. For most Node.js and browser use cases, QRWC's 350 ms default is imperceptible.
QRWC vs QRC — Which to Use
| Factor | QRC (TCP port 1710) | QRWC (WebSocket port 443) |
|---|---|---|
| Status | Stable, production | Beta — API subject to change |
| Browser access | No | Yes |
| Crestron / AMX | Yes (SIMPL, NetLinx) | No |
| Node.js / cloud | Awkward (raw TCP + null framing) | Yes — purpose-built |
| Firewall | Port 1710 often needs allowlisting | Port 443 almost always open |
| Redundant Core | Manual implementation | Built-in via engineStatus |
| Change model | Push (change groups, ~immediate) | Poll (default 350 ms) |
| Emulator support | Yes | No |
| Q-SYS version | All | 10.0+ |
| Reconnect on disconnect | Manual | Manual (new instance required) |
Common Pitfalls
-
WebSocket service not enabled in Core Manager. QRWC connections fail silently if the WebSocket service is not explicitly enabled under Network → Services in Core Manager. The Core serves HTTPS on port 443 but will not accept WebSocket upgrade requests until this is turned on. This is the most common setup failure — check it first before any other troubleshooting.
-
Components not marked as scriptable.
qrwc.componentswill be empty or missing expected components if Code Name and Script Access is left at the defaultNonein Design Properties. Set it toExternalorAll, re-push the design to the Core, and reconnect. Only components explicitly marked scriptable are discoverable by QRWC. -
Reusing a closed Qrwc instance after disconnection. The library fully cleans up on disconnect — all internal state, listeners, and polling intervals are torn down. Calling methods on a disconnected instance throws errors. Always create a fresh WebSocket and call
Qrwc.createQrwc()again to reconnect. Do not attempt to reuse the old instance. -
Not re-attaching event listeners after reconnect. Because a new
Qrwcinstance is required on reconnect, allcontrol.on('update', ...)handlers from the previous instance are gone. Structure your code so that listener attachment is a function that can be called on any newQrwcinstance, not inline setup that runs once at startup. -
60-second timeout in raw WebSocket usage. Applications using
@q-sys/qrwcare protected automatically. Custom raw WebSocket clients that go silent for 60 seconds are disconnected by the Core without warning. Implement a keepalive — send any valid message (e.g., aStatusGetJSON-RPC call) every 30 seconds. -
API changes between Q-SYS releases. QRWC is explicitly beta. Pin
@q-sys/qrwcto a tested version inpackage.jsonand test against new Core firmware before upgrading production systems. Do not auto-update the package or Core firmware simultaneously — change one variable at a time.