Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

qsys-pjlink

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.

Why the protocol is split from the platform

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 stock lua.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.

Repository layout

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

Installing in Q-SYS Designer

  1. Add a Control Script component to your design.
  2. Open its Scripting tab. In the Modules panel, add src/pjlink.lua and src/md5.lua as modules named pjlink and md5 respectively (Q-SYS Designer's scripting engine supports require()-able modules attached to a Control Script this way).
  3. Paste the contents of src/pjlink_qsys.lua into the component's main Code editor.
  4. 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.

Properties

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.

Controls

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.

Running the tests

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.

Running the mock projector

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.

Status

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-SYS TcpSocket, Controls, Timer and Properties APIs (event handler names, Socket:Read/Write signatures, 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 Crypto MD5 helper at all; if not (or if its API differs from what pjlink_qsys.lua probes for), the bundled md5.lua fallback is used, which is what the tests actually cover.

License

MIT - see LICENSE. Copyright (c) 2026 Eugene.

About

PJLink Class 1 projector driver for Q-SYS — platform-independent protocol core in Lua, 133 tests, runs without hardware

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages