When There Is No API Spec, Read the Wire
In live production and commercial AV environments, official control plugins don't always exist—and when vendor documentation does exist, it's frequently incomplete, hidden behind dealer portals, or out of sync with the firmware actually sitting in the rack.
Whenever I need to integrate a piece of production gear into Q-SYS—whether it's a broadcast video switcher, a digital audio console, an auditorium projector, or a studio tally light—my first step is almost always opening Wireshark. Packet captures don't care what a manual claims; they show you the exact bytes, headers, and timing the hardware actually responds to.
1. Recovering a 'Lost' Ross Carbonite Solo Without a Factory Reset
One of my favorite moments with Wireshark had nothing to do with writing code first—it was saving a live broadcast switcher from a factory reset. We lost network control of a Ross Carbonite Solo video switcher, and nobody had documented what static IP or subnet the frame had been assigned. The textbook troubleshooting step was a factory reset, which meant risking show memories, macros, and custom aux routing right when we needed the room operational.
Instead of resetting the switcher, I patched directly into the network segment, started a Wireshark capture, and filtered for ARP and RossTalk control traffic (`arp || tcp.port == 7788`). On a switched network, promiscuous mode alone won't show unicast conversations between other devices—that takes a mirrored switch port or a network tap—but ARP requests and announcements are broadcast to every port. Within seconds, ARP traffic from the Solo Panel and Solo Frame revealed the addresses they were actually using (shown here as the example addresses `192.0.2.34` and `192.0.2.35`).
ARP reveals addresses, not the subnet mask, so I put my interface on an unused address in the same range, verified I could reach the frame on TCP port 7788 (RossTalk), and wired up a custom Q-SYS Lua driver to switch crosspoints (`XPT`) across Program (`ME:1:PGM`) and all 16 Aux buses—restoring full room control without ever rebooting or wiping the frame.
-- Addresses discovered via Wireshark ARP capture (example values shown)
-- 192.0.2.34 = Ross Carbonite Solo Frame | 192.0.2.35 = Solo Panel
port = 7788 -- RossTalk TCP port
sock = TcpSocket.New()
sock.ReconnectTimeout = 5
Controls.Source.EventHandler = function()
local state = Controls.Source.String
local sendData
if state == "Black" then
sendData = "XPT " .. Controls.Destination.String .. ":BK"
else
for i = 1, #sources do
if state == "Input " .. i then
sendData = "XPT " .. Controls.Destination.String .. ":IN:" .. i
break
end
end
end
print('Sending Data --> "' .. sendData .. '"')
sock:Write(sendData .. "\r\n") -- RossTalk frames require CRLF termination
end2. Dissecting Raw Binary UDP Payloads (Studio-Tech Model 391)
Not every device speaks human-readable ASCII. When we needed Q-SYS to trigger Studio-Tech Model 391 on-air tally/alerting lights over the network, there was no public REST API—just a proprietary control application sending raw UDP datagrams to port 8700.
I captured the UDP stream in Wireshark while toggling the lamp on, off, and across RGB colors (red, green, blue), then compared the hex dumps byte-by-byte. Each packet was 30 bytes long, and the diff split it into a preamble (`FF FF 00 1E 07 E1 00 00`) whose third and fourth bytes match that length (`0x001E` = 30), the target unit's 6-byte MAC address, two zero bytes, the ASCII signature `Studio-T` (`53 74 75 64 69 6F 2D 54`), command bytes that stayed the same for ON and OFF (`5A 09 02 19`), and a two-byte tail that changed with state (`01 10` for ON, `00 C5` for OFF). The first tail byte reads as the on/off flag; the second is a suspected checksum. Since the driver only needs ON and OFF, it replays the two captured tails instead of computing one.
Before writing a single line of Lua, I replayed the captured hex payloads straight from the macOS terminal using Netcat (`echo -n -e ... | nc -u <ip> 8700`) to confirm the light responded, then ported the byte builder into Q-SYS `UdpSocket`. Because the payload embeds the target unit's MAC address, these bytes are a replay for that one light, not a generic command.
# Frame layout observed in Wireshark (UDP port 8700):
# FF FF 00 <len> 07 E1 00 00 <6-byte MAC> 00 00 "Studio-T" 5A 09 02 19 <state> <suspected checksum>
# The MAC and IP below are example values; a real replay uses the target unit's own.
# Replay ON packet (0x01 0x10) via UDP Netcat to verify before coding:
echo -n -e "\xFF\xFF\x00\x1E\x07\xE1\x00\x00\x00\x00\x5E\x00\x53\x01\x00\x00\x53\x74\x75\x64\x69\x6F\x2D\x54\x5A\x09\x02\x19\x01\x10" | nc -u 198.51.100.139 8700
# Replay OFF packet (0x00 0xC5):
echo -n -e "\xFF\xFF\x00\x1E\x07\xE1\x00\x00\x00\x00\x5E\x00\x53\x01\x00\x00\x53\x74\x75\x64\x69\x6F\x2D\x54\x5A\x09\x02\x19\x00\xC5" | nc -u 198.51.100.139 8700-- Replays the captured packets for one unit: its MAC (example value here)
-- is baked into the payload, so changing ip alone won't address another light.
function ledState(ip, bool)
local s ="\xFF\xFF\x00\x1E\x07\xE1\x00\x00\x00\x00\x5E\x00\x53\x01\x00\x00\x53\x74\x75\x64\x69\x6F\x2D\x54\x5A\x09\x02\x19"
if bool then
s = s .. "\x01\x10"
else
s = s .. "\x00\xC5"
end
s = s:gsub("\\x(%x%x)", function(x)
return string.char(tonumber(x, 16))
end)
local udp = UdpSocket.New()
udp:Open()
udp:Send(ip, 8700, s)
end3. Sniffing Undocumented Projector CGI Payloads
Projector web interfaces look simple in a browser, but automating them from Q-SYS `HttpClient` often fails with mysterious 400 or 403 responses until you inspect the actual HTTP conversation in Wireshark.
Capturing the browser traffic to our Sony Commons projectors showed two critical details the manual never mentioned: requests had to hit `/cgi-bin/web.cgi` with the `X-Requested-With: XMLHttpRequest` header present, and the power toggle was buried inside a numeric category/code RPC envelope (`category = "4054", code = "15"`). Doing the same packet inspection on our Christie HD14K-M projectors revealed how its `/control` endpoint issued session tokens (`type = "login"`) and reported lamp cooling states (`getcontrol` / `setvalue` on code `PWR`).
function getInfo()
return json.encode({
req = { category = "information", lang = "en", type = "get" }
})
end
function powerToggleOnOff()
-- Captured from web UI POST payload: category 4054 / code 15 triggers power toggle
return json.encode({
param = { command = { category = "4054", code = "15" } },
req = { category = "control", type = "send" }
})
end
function Upload(data)
HttpClient.Upload {
Url = URL.String .. "/cgi-bin/web.cgi",
Method = "POST",
Data = data,
Headers = {
["Content-Type"] = "application/json",
["X-Requested-With"] = "XMLHttpRequest"
},
EventHandler = done
}
end4. Following TCP Streams on a Yamaha TF Mixing Console
To let volunteers switch lobby and broadcast stereo feeds without touching the front-of-house console, I needed bidirectional control of a Yamaha TF mixer over TCP port 49280.
Using Wireshark's 'Follow TCP Stream' while muting channels and moving faders on the console surface exposed the exact line-delimited protocol: channels are 0-indexed on the wire (`InCh/Fader/On <ch-1> 0 <1|0>`), fader levels scale from `-32768` to `1000`, and rapid console movements coalesce multiple newline-separated status messages into a single TCP read buffer.
TCP delivers a byte stream, not discrete messages, so the reverse happens too: one message can arrive split across two reads. The parser keeps any unfinished tail in a buffer and completes it when the next read arrives, instead of trying to parse each read on its own.
-- Decode 0-indexed Yamaha wire message into 1-indexed Q-SYS channel
-- (-?%d+) accepts negative fader levels such as -32768
function decodeYamaha(str)
local path, channel, trash, value = str:match("MIXER:Current/(.+) (%d+) (%d+) (-?%d+)")
local number = tonumber(channel) or 0
return path, number + 1, trash, value
end
-- TCP reads can hold several messages or end mid-message, so keep the
-- unfinished tail until the rest of the line arrives
local rxBuffer = ""
function whatsOn(incomingData)
rxBuffer = rxBuffer .. incomingData
for line in rxBuffer:gmatch("([^\n]*)\n") do
local path, channel, _, value = decodeYamaha(line)
if path and path:match("InCh/Fader/On") ~= nil then
Controls.Outputs[channel].Value = tonumber(value) or 0
end
end
rxBuffer = rxBuffer:match("[^\n]*$")
end
function channelOnOff(channel, on)
local value = on and 1 or 0
return "set MIXER:Current/InCh/Fader/On " .. (channel - 1) .. " 0 " .. value .. "\n"
end5. Byte-Level Debugging: IEEE-754 OSC Floats & Serial Checksums
Wireshark is also the fastest way to find out why a device is silently ignoring your packets. While building an Open Sound Control (OSC) driver in Q-SYS Lua for an ENTTEC S-Play lighting controller, comparing `oscsend` packets against my Lua UDP socket in Wireshark immediately showed why my early packets were dropped: an OSC message is the address string, null-terminated and padded with null bytes (`\0`) to a 4-byte boundary, then a type-tag string padded the same way (`,f` for one float argument), then the argument itself as a big-endian IEEE-754 32-bit float.
Inspecting the raw bytes in Wireshark's hex pane made it straightforward to write a pure-Lua IEEE-754 float packer (`math.frexp` + bit shifts) and 4-byte alignment padding—just like verifying bitwise XOR Block Check Characters (`BCC`) when driving NEC MultiSync commercial displays over RS-232/TCP.
function toIEEE754(value)
if value == 0 then return string.char(0, 0, 0, 0) end
local sign = 0
if value < 0 then sign = 1; value = -value end
local mantissa, exponent = math.frexp(value)
exponent = exponent + 126
mantissa = (mantissa * 2 - 1) * math.pow(2, 23)
local b1 = (sign << 7) | (exponent >> 1)
local b2 = ((exponent & 1) << 7) | (math.floor(mantissa) >> 16)
local b3 = (math.floor(mantissa) >> 8) & 0xFF
local b4 = math.floor(mantissa) & 0xFF
return string.char(b1, b2, b3, b4)
end
-- OSC strings end with at least one null byte, padded to a 4-byte boundary
function oscString(s)
return s .. string.rep("\0", 4 - (#s % 4))
end
function intensity(level, playlistID)
local scope = playlistID and "/playlist" or "/master"
local pID = playlistID and "/" .. playlistID or ""
local osc_address = "/splay" .. scope .. "/intensity" .. pID
-- Address, type tags (",f" = one float32), then the big-endian argument
return oscString(osc_address) .. oscString(",f") .. toIEEE754(level)
endMy Repeatable Wireshark Workflow
- Capture where the traffic flows: Your own machine's sessions are always visible, and broadcasts like ARP reach every switch port. Unicast traffic between two other devices needs a mirrored switch port or a network tap.
- Isolate the segment: Filter by IP, MAC prefix, or port (`arp`, `udp.port == 8700`, `tcp.port == 49280`) so background broadcast chatter doesn't hide the control frame.
- Trigger one state change at a time: Capture ON, OFF, and a mid-range value separately so you can diff which bytes stay static (headers/MAC) and which bytes represent state and checksums.
- Replay from the terminal first: Use `nc -u` (UDP), `nc` (TCP), or `curl` to replay the captured payload and confirm the hardware reacts before writing any plugin code.
- Harden for Q-SYS runtime: Once the byte format works, wrap it in reconnect timers, newline/block framing, and state reconciliation so the UI stays in sync with the hardware.