A PJLink Class 1 driver for Q-SYS: a Control Script that connects to a network projector or flat panel over PJLink (TCP, port 4352) and exposes power, input, AV mute, lamp hours and error status as Q-SYS Controls.
src/pjlink.lua is the entire PJLink Class 1 protocol - command framing,
parameter validation, response parsing, value decoding, stream buffering -
written with zero dependency on Q-SYS. It doesn't touch TcpSocket,
Controls, Timer, Properties or even print; it only works with plain
Lua strings and tables. src/pjlink_qsys.lua is a thin adapter that wires
that logic to a real socket and a real UI.
The reason isn't academic. A protocol implementation that can only be exercised by opening a real TCP connection to a real projector is untestable and stuck to one platform. Splitting it out means:
- Testable in isolation.
spec/runs the whole protocol test suite with a stocklua.exe- no Q-SYS runtime, no hardware, no mocking the platform API. See "Running the tests" below. - Portable. The same core could be dropped into any Lua host with a few lines of adapter code, instead of rewriting the protocol.
The same split is used in the companion repository crestron-pjlink,
which implements this identical protocol in C# for Crestron SIMPL#Pro:
a dependency-free netstandard2.0 protocol core, a platform adapter, and
its own test suite. Together they show one protocol implemented twice on
two different control systems from the same design, rather than a single
platform-specific script.
src/
pjlink.lua Protocol core - no Q-SYS dependency, unit tested directly
md5.lua Pure Lua 5.4 MD5, used for PJLink authentication
pjlink_qsys.lua Q-SYS Control Script: TcpSocket + Controls + Timer + Properties
spec/
run_all.lua Test runner - run this with lua.exe
support.lua Minimal assertion/reporting helpers (no busted, no luarocks)
md5_spec.lua MD5 against the RFC 1321 test vectors
pjlink_spec.lua Command building, handshake/response parsing, decoders, buffering
tools/
mock-projector.js Node PJLink emulator, for end-to-end testing without hardware
- Add a Control Script component to your design.
- Open its Scripting tab. In the Modules panel, add
src/pjlink.luaandsrc/md5.luaas modules namedpjlinkandmd5respectively (Q-SYS Designer's scripting engine supportsrequire()-able modules attached to a Control Script this way). - Paste the contents of
src/pjlink_qsys.luainto the component's main Code editor. - Set the Properties described below, then save. The script connects on load and reconnects automatically if the link drops.
If your version of Q-SYS Designer doesn't support attaching separate module
files to a Control Script, you can't just paste pjlink.lua and md5.lua
above pjlink_qsys.lua and delete the require(...) calls: both modules
end in a top-level return, and Lua only allows return as the very last
statement of a chunk, so anything pasted below it is a syntax error. Wrap
each module's body in an immediately-invoked function instead, and make two
small edits to pjlink_qsys.lua so it uses those locals instead of
require():
local md5 = (function()
-- paste the entire contents of src/md5.lua here, unchanged
end)()
local pjlink = (function()
-- paste the entire contents of src/pjlink.lua here, unchanged
end)()
-- paste the entire contents of src/pjlink_qsys.lua here, with two edits:
-- 1. delete the line `local pjlink = require("pjlink")` - the `pjlink`
-- local defined above already covers it, by lexical scope;
-- 2. inside resolve_md5(), change `require("md5").hex` to `md5.hex`,
-- referring to the `md5` local defined above.This has been verified to loadfile() cleanly as a single chunk. Only the
split-file, require()-based install path in step 2 above is what this
repository's automated test suite (spec/) actually exercises - compiling
is not the same as testing.
| Property | Type | Default | Notes |
|---|---|---|---|
| IP Address | string | 192.168.1.100 |
Projector's network address |
| Port | integer | 4352 |
PJLink standard port |
| Password | string | (empty) | Leave empty if the projector has no PJLink authentication configured |
| Poll Interval (s) | integer | 20 |
How often POWR/AVMT/INPT/ERST/LAMP are re-queried once connected. Capped at 25 s - see the note below the table |
Properties are read once at script load; change one and save the design to apply it.
The Poll Interval ceiling of 25 s (rather than an arbitrary large number) is a protocol nuance, not an oversight: PJLink Class 1 permits a projector to close an idle TCP session after roughly 30 s without traffic, and some units enforce this. Keeping the poll interval comfortably under that threshold means the routine poll doubles as a keepalive, so the driver never needs a separate keepalive command.
| Control | Type | Direction | Purpose |
|---|---|---|---|
| PowerOn | Button (momentary) | input | Sends POWR 1 |
| PowerOff | Button (momentary) | input | Sends POWR 0 |
| PowerState | Text | output | Decoded power state: standby / on / cooling / warming |
| Input | Text | input/output | Set: writes a 2-character PJLink input code (e.g. 21). Feedback: current input |
| AvMuteVideo | Button (toggle) | input/output | Video mute on/off, feedback from AVMT polling |
| AvMuteAudio | Button (toggle) | input/output | Audio mute on/off, feedback from AVMT polling |
| LampHours | Text | output | Hours and on/off state for every lamp reported by the projector |
| ErrorStatus | Text | output | Decoded ERST: fan/lamp/temperature/cover/filter/other |
| ConnectionStatus | Text | output | Disconnected / Connecting / Connected / Reconnecting / Auth failed (a PJLINK ERRA reply to a command, i.e. a wrong Password) |
| ConnectionIndicator | Indicator (LED) | output | Lit when ConnectionStatus is Connected |
| ProjectorName | Text | output | From NAME |
| Manufacturer | Text | output | From INF1 |
| Model | Text | output | From INF2 |
The Control Script also implements a reconnect backoff (1/2/5/10/30/60 s), a
10-second handshake timeout, a 5-second response timeout, and a strict
one-command-in-flight queue - with the response matched against the command
actually in flight, so a late reply after a timeout can't be mistaken for
the answer to whatever was sent next - because PJLink requires the client
to wait for a reply before sending the next command. See the comments in
src/pjlink_qsys.lua.
Known limitation of that matching. The command name is the only correlator PJLink offers: the wire format has no sequence number or tag, so two consecutive requests for the same command are indistinguishable. If a request times out and the next request happens to be that same command, a late reply to the abandoned one is accepted as the reply to the new one. The value is still of the right type and at most one poll interval stale, so this is a benign stale read rather than a desynchronized session - eliminating it entirely would mean dropping the TCP connection on every response timeout to force a resync, which costs more than it buys here. This is a protocol constraint, not something a different implementation would avoid.
Requires only a Lua 5.4 interpreter, run from the repository root:
lua spec/run_all.lua
This covers every MD5 RFC 1321 test vector (all seven, including the two
longest - 62 and 80 bytes - plus additional 55/56/57/64/119/120-byte inputs
straddling the padding boundary and the multi-block path), command building
(valid and invalid parameters) for every PJLink Class 1 command, handshake
parsing (both authenticated and unauthenticated, plus PJLINK ERRA),
response parsing (including all four ERRn codes and the bare OK
set-command acknowledgement), every value decoder (including the LAMP
decoder rejecting non-integer hours), and the incoming-stream line buffer
under byte-at-a-time, multi-line-per-chunk, split-mid-line, CRLF and
over-length-remainder delivery.
tools/mock-projector.js is a dependency-free Node emulator that listens
on the PJLink port, tracks power/input/mute state, simulates warm-up and
cool-down with timers, and serves LAMP/ERST/INST/NAME/INF1/INF2
like a real unit.
node tools/mock-projector.js # no authentication
node tools/mock-projector.js --password secret # authentication required
node tools/mock-projector.js --port 14352 # non-default port
node tools/mock-projector.js --help
Point the Q-SYS Control Script's IP Address/Port Properties at the machine
running the emulator (127.0.0.1 and the port above, if running locally)
to exercise the full connect/handshake/authenticate/poll/control flow
without a physical projector.
The protocol is implemented against the PJLink Class 1 command set
(JBMIA PJLink specification), and is covered by the automated test suite
(spec/) plus manual end-to-end verification against a live
tools/mock-projector.js process: real response lines captured from the
mock were fed into the actual src/pjlink.lua parser/decoders (not
hand-typed fixtures), covering NAME/INF1/INF2/INFO/CLSS/INST,
POWR query and set (including the bare OK acknowledgement), AVMT
query, INPT, ERST, LAMP, and ERR1/ERR2/ERR3 (an undefined
command, an out-of-range AVMT parameter, and POWR 1 refused while
cooling down). Authenticated mode was checked both ways: src/pjlink.lua's
build_auth_prefix() and src/md5.lua were used to compute a real auth
digest that the mock accepted, and a deliberately wrong password produced
exactly PJLINK ERRA. The warm-up/cool-down state machine was also driven
end to end (POWR 1 -> warming -> on, POWR 0 -> cooling ->
standby). ERR4 is covered by the automated suite (spec/) only - the
mock projector has no code path that ever emits it.
It has not been tested against a physical projector or display. The first things to verify on real hardware:
- Whether the target projector's PJLink handshake, authentication and response formatting match the spec byte-for-byte (some manufacturers are known to deviate slightly from the standard).
- Whether
src/pjlink_qsys.lua's use of the Q-SYSTcpSocket,Controls,TimerandPropertiesAPIs (event handler names,Socket:Read/Writesignatures,BufferLength, control type/indicator declarations) matches the exact behavior of the Q-SYS Designer version in use - these were written to the documented/typical Q-SYS scripting API shape but not run inside an actual Q-SYS Core or Designer emulator. - Whether Q-SYS Designer exposes a built-in
CryptoMD5 helper at all; if not (or if its API differs from whatpjlink_qsys.luaprobes for), the bundledmd5.luafallback is used, which is what the tests actually cover.
MIT - see LICENSE. Copyright (c) 2026 Eugene.