Education

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 Controls table for accessing Named Controls anywhere in the design
  • The Component API for reading/writing any component in the design by name
  • Network APIs: TcpSocket, HttpClient, UdpSocket
  • Timer, System, Snapshot, Design objects
  • 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 windowprint() 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 in pcall to 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, using os.time() in a tight loop, or performing lengthy computation inside an EventHandler stalls all other event processing. Use Timer.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 Disconnected handler simply stops working after the first disconnect — no error is surfaced to the operator. Every TCP socket must have a Disconnected handler 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 all json.decode calls in pcall() 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 the bit library: bit.band(), bit.bor()), and string.format has some differences from later versions. Test against the emulator before assuming a Lua pattern works.

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