Q-SYS Lua Scripting
Q-SYS Designer includes a full Lua 5.1 runtime embedded in the Core processor. Script components let integrators write event-driven logic, communicate with external systems over TCP/HTTP, parse JSON and XML responses, and perform calculations that the graphical component library cannot express. Unlike plugin-based integrations, Lua scripts run natively on the Core — no external server or PC required. Scripting is the tool of choice when a device has no published Q-SYS plugin, when custom business logic is needed, or when integrating with third-party APIs (room booking, building management, occupancy sensors).
See control-systems/qsc-qsys-overview for the Q-SYS platform overview and control-systems/qsc-qrc for controlling Q-SYS from external systems.
Script Components
A Script component is placed on the Q-SYS Designer canvas like any other component. It has configurable input/output pins (controls) that connect to the rest of the design. The Lua code runs inside the Script component and has access to:
- All controls wired to the component's pins
- The global Q-SYS
Controlstable for accessing Named Controls anywhere in the design - The
ComponentAPI for reading/writing any component in the design by name - Network APIs:
TcpSocket,HttpClient,UdpSocket Timer,System,Snapshot,Designobjects- Standard Lua libraries:
string,table,math,io(limited),json(QSC-provided)
Adding controls to a Script component:
In Designer, right-click the Script component → "Properties" → add input/output controls. Each control appears as a pin on the component and is accessible in Lua as Controls["ControlName"] or Controls.ControlName.
Running the script: Scripts start executing when the design loads on the Core. The top-level code runs once at startup; event handlers and timers continue running for the life of the design.
Q-SYS Lua API — Core Objects
Controls
The primary way to read and write values within the design:
-- Read a control value
local currentGain = Controls["MainGain"].Value -- numeric
local muteState = Controls["MicMute"].Boolean -- true/false
local labelText = Controls["StatusLabel"].String -- string
-- Write a control value
Controls["MainGain"].Value = -18.0
Controls["MicMute"].Boolean = true
Controls["StatusLabel"].String = "Meeting in progress"
-- Trigger a button (momentary pulse)
Controls["SceneRecall"].Trigger()
-- Ramp a gain over time (2 seconds)
Controls["MainGain"]:RampTo(-6.0, 2.0)
EventHandler — React to Control Changes
The most important pattern in Q-SYS Lua: register a function to fire whenever a control changes value.
Controls["MicMute"].EventHandler = function(ctrl)
if ctrl.Boolean then
Controls["MuteLED"].Boolean = true
Controls["StatusLabel"].String = "MUTED"
else
Controls["MuteLED"].Boolean = false
Controls["StatusLabel"].String = "Live"
end
end
EventHandlers fire on the Q-SYS event thread. Keep them short — do not perform blocking network calls inside an EventHandler. Spawn a coroutine or use a timer for anything time-consuming.
Component API — Access Any Component by Name
Access components in the design without wiring physical pins:
-- Get a component object
local myGain = Component.New("Main Zone Gain")
-- Read its controls
local db = myGain["gain"].Value
-- Set a control
myGain["gain"].Value = -12.0
myGain["mute"].Boolean = false
-- List all controls on a component (useful for debugging)
for name, ctrl in pairs(myGain) do
print(name, ctrl.Value)
end
Component names must match exactly what's in the Q-SYS design (case-sensitive).
Timers
Timers are essential for polling, keepalive, scheduled events, and debouncing:
-- One-shot timer (fires once after 5 seconds)
Timer.CallAfter(5.0, function()
Controls["StatusLabel"].String = "Timeout"
end)
-- Repeating timer
local pollTimer = Timer.New()
pollTimer.EventHandler = function()
-- Poll a device status every 30 seconds
checkDeviceStatus()
end
pollTimer:Start(30.0)
-- Stop a timer
pollTimer:Stop()
Timer.CallAfter is convenient for one-shot delays. Timer.New() with :Start(interval) is the pattern for repeating tasks.
TCP Client — Custom Device Integration
When a device has no Q-SYS plugin, write a TCP client in Lua:
local sock = TcpSocket.New()
local rxBuffer = ""
-- Connection event handlers
sock.Connected = function(s)
print("Connected to display")
s:Write("POWR0001\r") -- send power-on command
end
sock.Disconnected = function(s)
print("Display disconnected — reconnecting in 10s")
Timer.CallAfter(10.0, function() s:Connect("192.168.1.50", 4352) end)
end
sock.Data = function(s)
rxBuffer = rxBuffer .. s:Read(s.BufferLength)
-- Parse responses from rxBuffer
if rxBuffer:find("POWR=0001") then
Controls["DisplayPower"].Boolean = true
rxBuffer = ""
end
end
sock.Error = function(s, err)
print("Socket error:", err)
end
-- Connect
sock:Connect("192.168.1.50", 4352)
Reconnection logic is critical — devices reboot, networks hiccup. Always implement a reconnect timer in the Disconnected handler.
HTTP Client — REST API Integration
Q-SYS's HttpClient enables integration with room booking systems, building management APIs, and cloud services:
local http = HttpClient.New()
-- GET request (e.g., room booking status)
http:Download(
"https://booking.example.com/api/rooms/101/status",
{["Authorization"] = "Bearer " .. apiToken},
function(tbl, code, data, err)
if code == 200 then
local result = json.decode(data)
if result.occupied then
Controls["OccupiedLED"].Boolean = true
Controls["RoomStatus"].String = result.meeting_name
else
Controls["OccupiedLED"].Boolean = false
Controls["RoomStatus"].String = "Available"
end
else
print("Booking API error:", code, err)
end
end
)
For POST requests use http:Upload(). Both are asynchronous — the callback fires when the response arrives, keeping the script non-blocking.
JSON Handling
QSC provides a json library in the Q-SYS Lua environment:
-- Decode JSON string to Lua table
local data = json.decode('{"gain": -12.0, "mute": false, "label": "Main"}')
print(data.gain) -- -12.0
print(data.mute) -- false
-- Encode Lua table to JSON string
local payload = json.encode({
room = "Conference A",
occupied = true,
headcount = 6
})
-- Result: '{"room":"Conference A","occupied":true,"headcount":6}'
JSON decode errors raise a Lua error — wrap in pcall when parsing untrusted external data:
local ok, result = pcall(json.decode, rawString)
if not ok then
print("JSON parse error:", result)
end
Snapshot Control
Trigger design snapshots from Lua for scene recall:
-- Recall snapshot bank 1, slot 3
Snapshot.Load("MySnapshot", 3)
-- Save current state to snapshot bank 1, slot 3
Snapshot.Save("MySnapshot", 3)
Snapshots can be triggered by room booking status, time of day, or any control event — useful for "day mode / night mode" preset switching in houses of worship and auditoriums.
Common Patterns
Scheduled Daily Task
local function getDaySeconds()
local t = os.date("*t")
return t.hour * 3600 + t.min * 60 + t.sec
end
local scheduleTimer = Timer.New()
scheduleTimer.EventHandler = function()
local secs = getDaySeconds()
if secs == 6 * 3600 then -- 6:00 AM
Snapshot.Load("DayMode", 1)
elseif secs == 22 * 3600 then -- 10:00 PM
Snapshot.Load("NightMode", 1)
end
end
scheduleTimer:Start(1.0) -- check every second
Debounce (ignore rapid repeat triggers)
local debounceActive = false
Controls["MotionSensor"].EventHandler = function(ctrl)
if ctrl.Boolean and not debounceActive then
debounceActive = true
-- Trigger the room-occupied logic
Controls["RoomOccupied"].Boolean = true
Timer.CallAfter(30.0, function()
debounceActive = false
end)
end
end
Multi-Room Volume Tracking
local zones = {"Zone1Gain", "Zone2Gain", "Zone3Gain", "Zone4Gain"}
Controls["AllZonesGain"].EventHandler = function(ctrl)
local val = ctrl.Value
for _, name in ipairs(zones) do
Controls[name].Value = val
end
end
Debugging Tools
- Q-SYS Designer Debug window —
print()calls in Lua appear here in real time when connected to the Core or emulator. Use liberally during development. - Emulator — Run the full design including Lua scripts offline before touching hardware. HTTP and TCP sockets work in the emulator if the development PC has network access to the target servers.
pcall()for error isolation — Wrap risky code inpcallto catch errors without crashing the script:local ok, err = pcall(function() -- code that might fail end) if not ok then print("Error:", err) end- Status control patterns — Write debug state to a Named Control string:
Controls["DebugStatus"].String = "Step 3 reached". Visible in the UCI or in Designer without connecting to the debug console.
Common Pitfalls
-
Blocking calls inside EventHandlers. Q-SYS EventHandlers run on the main event thread. Calling
sock:Read()synchronously, usingos.time()in a tight loop, or performing lengthy computation inside an EventHandler stalls all other event processing. UseTimer.CallAfter()to defer work, and let HTTP/TCP callbacks handle async responses. -
Component name typos cause silent failures.
Component.New("Main Zone Gain ")(trailing space) returns a valid-looking object but accessing its controls returns nil values silently. Always copy component names directly from the Q-SYS Designer canvas using right-click → Copy Name rather than typing them manually. -
TCP reconnection not implemented. Devices reboot, switches fail, cables are unplugged. A Lua TCP script with no
Disconnectedhandler simply stops working after the first disconnect — no error is surfaced to the operator. Every TCP socket must have aDisconnectedhandler that schedules a reconnect attempt. Log the disconnect state to a Named Control status string so operators can see the fault. -
json.decode on malformed data crashes the script. External APIs occasionally return HTML error pages, partial JSON, or empty strings. Calling
json.decode()directly on these raises a Lua error that terminates the script. Wrap alljson.decodecalls inpcall()and handle the failure gracefully. -
Timer interval too short floods the Core. A timer firing every 10 ms (0.01 s) consuming significant computation — string parsing, table iteration, multiple control writes — can impact Core audio processing performance. Use the minimum practical poll interval: 100 ms for responsive UI feedback, 1–30 seconds for device polling, 1 second for scheduling checks.
-
Lua 5.1 limitations. Q-SYS uses Lua 5.1, not 5.3 or 5.4. Integer division uses
/(not//), bitwise operators are not available natively (use thebitlibrary:bit.band(),bit.bor()), andstring.formathas some differences from later versions. Test against the emulator before assuming a Lua pattern works.