Education

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

RequirementDetail
Q-SYS DesignerVersion 10.0 or newer
RuntimeNode.js or modern browser
Package@q-sys/qrwc (NPM)
Core ManagerWebSocket service enabled under Network → Services
Design settingComponents 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

OptionTypeDefaultDescription
socketIWebSocketRequiredOpen WebSocket instance connected to the Core
pollingIntervalnumber (ms)350Polling interval for control changes; minimum 34 ms
componentFilter(componentState) => booleanAll scriptableFilter callback — limit which design components are connected
timeoutnumber (ms)5000Timeout for WebSocket message responses
loggerPartial<ILogger>NoneLogger 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):

PropertyTypeDescription
NamestringControl name
ComponentstringParent component name
Valuestring | number | undefinedNumeric or string value
Stringstring | undefinedHuman-readable string representation
Positionnumber | undefinedNormalized position 0.0–1.0
Boolboolean | undefinedtrue if Position ≥ 0.5; only valid for Boolean-type controls
Typestring | undefinedControl 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:

  1. Open and maintain WebSocket connections to both Cores simultaneously.
  2. Check engineStatus.State on each connection — "Active" vs "Standby".
  3. Send all control.update() calls to the Active Core only. Commands to the Standby are silently ignored.
  4. Verify DesignCode matches on both Cores — a mismatch means they are not running the same design.
  5. 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 control update events to React state, and calling control.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 pollingInterval to 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 componentFilter to 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

FactorQRC (TCP port 1710)QRWC (WebSocket port 443)
StatusStable, productionBeta — API subject to change
Browser accessNoYes
Crestron / AMXYes (SIMPL, NetLinx)No
Node.js / cloudAwkward (raw TCP + null framing)Yes — purpose-built
FirewallPort 1710 often needs allowlistingPort 443 almost always open
Redundant CoreManual implementationBuilt-in via engineStatus
Change modelPush (change groups, ~immediate)Poll (default 350 ms)
Emulator supportYesNo
Q-SYS versionAll10.0+
Reconnect on disconnectManualManual (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.components will be empty or missing expected components if Code Name and Script Access is left at the default None in Design Properties. Set it to External or All, 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 Qrwc instance is required on reconnect, all control.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 new Qrwc instance, not inline setup that runs once at startup.

  • 60-second timeout in raw WebSocket usage. Applications using @q-sys/qrwc are 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., a StatusGet JSON-RPC call) every 30 seconds.

  • API changes between Q-SYS releases. QRWC is explicitly beta. Pin @q-sys/qrwc to a tested version in package.json and 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.

We use optional analytics cookies to understand site usage and improve the experience. You can accept or reject.