christie-mseries
Python client for Christie M Series projectors' Ethernet serial API on TCP port 3002. Standard library only, no third-party dependencies.
Named for the protocol, not one model: client.py's wire framing/parsing
comes straight from Christie's own M Series Serial API Commands document and
should hold across the line. Only ChristieM4K25 in projector.py is
actually verified, against the TruLife+ platform in the M 4K25 RGB
specifically — its control set (which codes exist, test pattern numbering,
brightness floor) is not assumed to carry over to other M Series models
without checking docs/menu-map.json/raw_query() against the real unit
first. See "The control model, and how to find a control" below for why.
Contents
- Firmware this was built for
- Install
- Usage
- CLI
- The control model, and how to find a control
- Command coverage
- Inputs:
SIN - Laser power:
LAS+POWR - How replies work, and why it bites
- Status groups
- Polling:
snapshot() - Testing
- Consumers
- Layout
- Credits
- License
Firmware this was built for
Christie M 4K25 RGB, main control board software 1.3.x — developed and verified against a physical unit running 1.3.9, re-verified against the same unit updated to 1.3.10 (no behavior changes to anything below; see "1.3.10" note further down), with these component versions:
| Component | Version |
|---|---|
| Main Control Board SW | ChristieM 1.3.10 |
| Main Control Board HW | CAVE.7.1 |
| Formatter R/G/B HW | CFB098HU.3.4 |
| Photon SW | 1.6.1-1(Boot)/1.8.1-7(Main) |
| Power Supply HW | PSB.2.0 |
| Housekeeping Board HW | HKBG.3.2 |
| Keypad Display HW | IKB.6.0 |
| Variable Option Module HW | VOMHBI.3.0 |
| BIOS SW | 00.33 |
| SSPWBD SW / HW | 1.6.741270 / WBD.2.3 |
This matters more than it usually would. Which codes exist at all is a
property of the firmware, not of the model — this unit's TruLife+ platform
implements roughly a third of the codes in the M Series serial API document
this client was first written from, and docs/menu-map.json is a snapshot of
this firmware's menu tree. After a software upgrade, regenerate that map and
re-run the tests before trusting any code that is not exercised by them:
python3 examples/dump_menu.py <host> <user> <password> --json > docs/menu-map.json
git diff docs/menu-map.json # controls added, removed or re-ranged
1.3.10: re-ran the full functional check (power/shutter/brightness/
LiteLOC/test pattern/lens positions/input/hours, plus a fresh menu dump) —
everything already wrapped here behaves identically. Five new codes appeared
under EDC+DPCV/EDC+DPVO/EDC+HDCV/EDC+HDVO/MMC+EDID (custom EDID
management for the HDMI/DisplayPort inputs, under Configuration > Input
Settings), all reported enabled: false with no options beyond "Default
EDID" — nothing configurable yet, so nothing new to wrap.
Read the running versions at any time with:
python3 -c "from christie_mseries import ChristieM4K25
with ChristieM4K25('<host>') as p:
for k, v in p.get_status('VERS').items(): print(f'{k}: {v}')"
Install
pip install christie-mseries
For local development:
git clone https://github.com/imsatasia/py-christie-mseries.git
cd py-christie-mseries
uv sync --group dev # or: task install
Usage
from christie_mseries import ChristieM4K25
with ChristieM4K25("192.0.2.50") as projector:
projector.get_power_state() # <PowerState.ON: 1>
projector.get_power_state_text() # 'On'
projector.get_input() # (1, 'One-Port HDMI0')
projector.get_brightness() # 70.0 (percent of laser power)
projector.get_hours() # '4:05 (h:m)'
projector.get_temperatures() # {'Air Intake Temperature (Temp 2)': '34 °C', ...}
projector.power_on(wait=True) # blocks until the ack comes back
projector.snapshot() # Snapshot(power='On', power_code=1, ...) -- one poll, every field a caller like Home Assistant wants
Anything without a named method is reachable with raw_query() /
raw_set(), which take a code and an optional subcode:
projector.raw_query("GAM") # (GAM?)
projector.raw_query("LAS", "WHTX") # (LAS+WHTX?)
CLI
Installed as the christie-mseries console script:
christie-mseries 192.0.2.50 status # power, input, hours, alarms
christie-mseries 192.0.2.50 json # machine-readable, one poll (snapshot())
christie-mseries 192.0.2.50 brightness # read laser power %
christie-mseries 192.0.2.50 brightness 70 # set it (30-100)
christie-mseries 192.0.2.50 input 3 # by SIN index...
christie-mseries 192.0.2.50 input "HDMI 2.1 Port 3" # ...or by label
christie-mseries 192.0.2.50 shutter open
christie-mseries 192.0.2.50 test-pattern "Color Bars"
christie-mseries 192.0.2.50 group TEMP # one status group in full
christie-mseries 192.0.2.50 probe # which documented codes exist here
The control model, and how to find a control
This unit runs Christie's TruLife+ platform, and the M Series serial API
doc (020-100224-11) that this library was first written from describes a
different, older one. Of the doc's 175 codes, 36 answer here; assuming the
rest exist produces methods that fail at runtime with ERR00101 "Control Not Found".
More importantly, the doc's model — a flat space of three-letter codes — is
simply not how this platform is shaped. Its real control surface is
overwhelmingly subcoded: of 221 controls (as of firmware 1.3.10), 19 are
bare codes and 202 are CODE+SUB across 33 families.
That has a sharp consequence for discovery. The serial API cannot list
anything. It only confirms a code you already guessed, and a code that
requires a subcode answers Control Not Found when queried bare — LAS,
WRP and NET all do. So sweeping the three-letter space cannot find
subcoded controls even in principle: a sweep of all 17,576 combinations
returned 56 codes here and still missed laser power entirely.
The projector's built-in web UI has the interface that does enumerate: a
JSON-RPC endpoint at http://<host>/cgi-bin/c4jweb/, where menu:get
returns each menu node with every child's code, label, type, range, soft
range and option list. examples/dump_menu.py walks the whole tree:
python3 examples/dump_menu.py <host> <user> <password> --json > docs/menu-map.json
docs/menu-map.json is the checked-in result and the reference to read
first for any "can it do X?" question: 236 entries covering those 221
controls, each with its menu path. Regenerate it after a firmware update.
Note that session:connect returns a session URL that every later call must
be posted to, or the reply is invalid Session ID.
The vendor's own M Series serial API document (020-100224-11) is
deliberately not included in this repo — it's Christie's copyrighted
material, and reproducing it wholesale isn't appropriate to publish here.
Everything this README says about it (which codes it gets right or wrong,
what it doesn't cover) comes from our own testing against the real unit,
cross-checked with docs/menu-map.json.
Command coverage
Codes with a named method:
| Code | Meaning | Method |
|---|---|---|
PWR |
Power | power_on(), power_off(), get_power_state(), get_power_state_text() |
SHU |
Shutter | open_shutter(), close_shutter(), is_shutter_open() |
LAS+POWR |
Brightness (laser power) | get_brightness(), set_brightness() |
LAS+STAT |
LiteLOC | get_liteloc(), set_liteloc() |
SIN |
Select Input | select_input() (index or label), get_input() |
CHA |
Channel | select_channel(), get_channel(), copy_channel(), delete_channel() |
SST |
Projector Status | get_status(), get_hours(), get_model(), get_serial_number(), get_temperatures() |
FCS |
Focus | get_focus(), set_focus() |
ZOM |
Zoom | get_zoom(), set_zoom() |
LHO |
Lens Horizontal | get_lens_horizontal(), set_lens_horizontal() |
LVO |
Lens Vertical | get_lens_vertical(), set_lens_vertical() |
LCB |
Lens Calibration | calibrate_lens(), home_lens() (write-only) |
FRZ |
Image Freeze | is_frozen(), set_frozen() |
ITP |
Test Pattern | get_test_pattern(), set_test_pattern() |
WRP+SLCT |
Geometry Correction | get_geometry_warp(), set_geometry_warp(), reset_keystone() |
ASU |
Auto Setup | auto_setup() (write-only) |
TDM TDD TDN TDO TDT DRK |
3D | get_3d_mode(), get_3d_emitter_delay(), is_3d_input_inverted(), get_3d_sync_output(), is_3d_test_pattern_enabled(), get_3d_dark_interval() (+ setters) |
NET |
Network Setup | get_network() |
ADR |
Address | get_address(), set_address() |
PNG |
Ping | ping() |
| — | Everything worth polling in one shot | snapshot() |
Present but not wrapped — use raw_query() / raw_set(): APW (auto power
on), BGC (gamma curve), CLE (color enable), CSP (color space), DTL
(detail), EME (error messages), FMD (film mode detect), FRD (frame
delay), GAM (gamma), MSP (menu location), NTR (network routing), OSD
(on-screen display), RAL (remote access level), SOR (screen
orientation), SZP (size preset), UID (user ID), plus the ~202 subcoded
controls in docs/menu-map.json.
What this platform does not have
- None of the doc's laser/illumination codes.
LPI,LPM,LPPare all absent, as areLSR,LSP,LPW,LIP,ILP,LSIandILI. Laser power is controllable, just under a different name — see below. - No ILS / lens memory.
ILSandILVare absent, so lens positions are not stored per channel. Channels (CHA) still store source routing and image settings, but recalling one will not move the lens. (Thechristie-m4k25-homeassistantandchristie-m4k25-control4integrations each synthesize their own lens presets on top ofget_focus()/set_focus()etc., since the projector has none of its own.) - No iris control (
IRS,DIM,DIS,MIP). - No
PJHfor runtime hours — useget_hours(), which pulls "Projector Hours" out of theSYSTstatus group. - No
KEYremote-button emulator and noMNU, so the on-screen menu can't be driven over this API. - No
+MAINsubcodes. The doc'sSIN+MAIN,CHA+MAIN,TDM+MAIN,TDD+MAINandDRK+MAINdo not exist; use the plain codes.
These absences are structural, not a side effect of the projector being idle: probing in standby and again with the light source running returns an identical list, 35 codes either way.
Inputs: SIN
The projector reports its inputs by its own names (get_input() returns
(3, "One-Port VOM-HDMI")), which don't say which connector on the back they
are. INPUTS maps each SIN index to the physical port, and select_input()
takes either the index or the label, the same way set_test_pattern() takes
a number or a name:
| Index | Projector's name | Label |
|---|---|---|
| 1 | One-Port HDMI0 | HDMI 2.0 Port 1 |
| 2 | One-Port HDMI1 | HDMI 2.0 Port 2 |
| 3 | One-Port VOM-HDMI | HDMI 2.1 Port 3 |
| 4 | One-Port VOM-DP0 | DisplayPort 1.4 Port 3 |
| 5 | One-Port VOM-DP1 | DisplayPort 1.4 Port 4 |
| 6 | One-Port DP0 | DisplayPort 1.2 Port 1 |
| 7 | One-Port DP1 | DisplayPort 1.2 Port 2 |
| 8-11 | One-Port SDI0-SDI3 | SDI 1-SDI 4 |
projector.select_input("HDMI 2.1 Port 3") # same as select_input(3)
projector.select_input(3)
An unknown label raises ValueError before anything is sent. Every index
1-11 was switched to and read back on hardware; the labels come from
the port names in the projector's Input Settings menu (EDC+HDCV, HDVO,
DPCV, DPVO in docs/menu-map.json). Index 3 = HDMI 2.1 Port 3 is
confirmed; the pairing of indexes 4-7 to individual DisplayPort ports is
inferred from the order the projector lists them, not checked against a
cable - correct INPUTS if a port turns out to be the other of its pair.
The VOM-* inputs belong to the Variable Option Module, so a unit without
one won't have them.
SIN is refused with Disabled Control while the projector is in standby:
the input can be read then, but only changed once it is on. snapshot()
carries the table for pollers (see below), and its input_label falls back to
the projector's own name for an index that isn't in INPUTS.
Laser power: LAS+POWR
Labelled Brightness in the projector's menu, under Configuration > Light
& Output Settings. Stored in tenths of a percent, so it reads 700 at 70%:
projector.get_brightness() # 70.0
projector.set_brightness(85) # percent, not tenths
Its hard range is 0–1000, and the projector publishes a soft minimum of 200, below which it will not run the lasers at all.
set_brightness() enforces a stricter floor of 30%, because Christie's
own release notes for both 1.3.9 and 1.3.10 carry this as an open known
issue: "LiteLOC performance is compromised when running at low brightness
levels (30% or less) ... laser devices may shut down and colors may drop
out." LiteLOC is enabled on this unit, so 20–30% is a band the hardware
accepts but the vendor advises against, and it is rejected rather than
offered. The menu node
also carries a softvalue: the brightness actually being applied, which
drops below the set value when the projector thermally limits itself. That
is what the web UI's "Brightness Reduced / System Adjusted" labels report.
If this floor is ever revisited, change BRIGHTNESS_MIN_PERCENT here and
update it everywhere else it's duplicated — the Home Assistant integration's
number entity and the Control4 driver's SetBrightness command each carry
their own copy, and all three are expected to agree.
LAS+STAT is LiteLOC, reporting 3 for enabled and 1 for disabled. Note
the projector currently marks it enabled: false — greyed out in its own
UI. Per-laser setpoints LAS+REDP/GRNP/BLUP exist under Admin >
Diagnostics; those are service-level colour calibration, not a brightness
control, and are best left alone.
Laser telemetry is separate and read-only: get_status("LGHT") returns
105 items including per-bank temperatures, driver amps/volts, and "Laser On
Hours".
How replies work, and why it bites
Three things about this protocol are easy to get wrong, and all three produced real bugs here:
- Most replies pair a number with a description —
(PWR!000 "Standby Mode"), not(PWR!000)— soint()on the reply data fails. Useget_power_state()for the number andget_power_state_text()for the projector's own wording, which is more trustworthy than the doc's value tables (the doc saysTDM1 is "Native 3D"; this unit says "Auto Detect"). - A successful SET is answered with silence, a rejected one isn't. An
unread error reply doesn't vanish — it's returned as the answer to the
next query, shifting every later reading by one.
set()therefore waits briefly for an error and raises it. - Three different failures look similar.
ERR00101 "Control Not Found"means the code doesn't exist on this platform (or needs a subcode);ERR00105 "Disabled Control"means it exists but isn't available right now — image controls likeITPandFRZare rejected in standby and accepted once the projector is on; and"This control can not be read"marks write-only codes (ASU,LCB).
Also worth knowing for polling: for a few seconds right after PWR 1 the
projector accepts a TCP connection but answers nothing, so a read can time
out mid-transition. Treat a timeout as "unknown", not as "off" — the
sequence observed here was silence, then 11 "Warming Up", then 1 "On"
about 16 seconds after the command.
Status groups
get_status(group) returns {label: value} for one group, reading the
projector's multi-message reply until it goes quiet:
| Group | Items | Contents |
|---|---|---|
CONF |
4 | model, serial number, output resolution, build date |
COOL |
12 | fans and blowers |
LGHT |
105 | laser/light-source state, temperatures, firmware versions |
SIGN |
22 | input signal properties |
SYST |
30 | hours, pitch/roll, lens calibration, board health, voltages |
TEMP |
13 | temperature sensors |
VERS |
13 | software versions |
ALRM |
0 | active alarms; empty is the healthy case, returned as {} |
The doc's HLTH and LAMP groups don't exist here, and LGHT exists but
isn't in the doc.
Polling: snapshot()
snap = projector.snapshot()
snap.power_code, snap.power # 1, "On"
snap.is_on, snap.in_transition
snap.input, snap.input_name
snap.input_label, snap.inputs, snap.input_map # "HDMI 2.1 Port 3", [labels...], {label: index}
snap.brightness, snap.liteloc
snap.test_pattern, snap.test_patterns, snap.test_pattern_map
snap.focus, snap.zoom, snap.lens_horizontal, snap.lens_vertical
snap.hours, snap.model, snap.serial, snap.alarms
snap.intake_temp # None if unreadable
One connection's worth of reads, bundled into a single Snapshot dataclass.
This is the single source of truth both the CLI's json command and (via
christie-m4k25-homeassistant) Home Assistant's coordinator poll from — add
a new pollable field here, not in either caller.
snapshot() raises like any other method here if a read fails or the
connection drops mid-poll. A caller that wants "unreachable" reported as
data instead of an exception should catch around it, the same way cli.py's
as_json() does:
try:
with ChristieM4K25(host, timeout=8) as projector:
snap = projector.snapshot()
except Exception as exc:
... # report as unreachable rather than propagating
Testing
task test # or: uv run pytest -v
29 protocol tests (tests/test_protocol.py) plus a domain-layer suite
(tests/test_projector.py) covering ChristieM4K25's methods including
snapshot(). There is no simulator, so both run against fake sockets;
behaviour on real hardware is checked with christie-mseries probe and
christie-mseries status. The reply strings in the tests are real captures,
including the cases that previously broke: replies pairing a number with a
description (000 "Standby Mode"), multi-field replies (NET), escaped
parens ("3:14 \(h:m\)"), and non-ASCII status text ("32 °C").
Consumers
This package is a standalone client; it contains no Home Assistant or Control4 integration code of its own.
christie-m4k25-homeassistant— a native Home Assistant custom integration built on this package.christie-m4k25-control4— a Control4 DriverWorks driver; a separate Lua reimplementation of the same wire protocol (Control4 drivers can't call out to a Python process), not a consumer of this package at runtime, but developed against the same hardware and kept in sync with the protocol notes above.
Layout
| Path | Purpose |
|---|---|
christie_mseries/client.py |
Message framing, parsing, and the raw request/set API |
christie_mseries/projector.py |
High-level ChristieM4K25 methods and Snapshot |
christie_mseries/cli.py |
The christie-mseries console script, including the json poll Home Assistant runs |
examples/dump_menu.py |
Regenerates docs/menu-map.json from the web RPC |
docs/menu-map.json |
221 labelled controls — the reference to read first |
Credits
This library was originally written against Christie's own M Series Serial API Commands technical reference:
Technical Reference 020-100224-11 — M Series Serial API Commands, © 2016 Christie Digital Systems USA Inc. All rights reserved. PDF, hosted by Christie Digital
That document isn't reproduced in this repository — it's Christie's
copyrighted material, and redistributing a vendor's proprietary manual
wholesale isn't appropriate for a public repo. It's linked here instead,
from Christie's own site, for anyone who wants the original. As documented
throughout this README, it also turned out to only partially describe this
platform (TruLife+, not the M Series document's original target) — the
document this library actually relies on for day-to-day accuracy is
docs/menu-map.json, generated directly from the projector's own web UI.
License
MIT — see LICENSE. Applies to the code in this repository only, not to Christie's serial API document credited above.
Metadata
Release files for christie-mseries 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| christie_mseries-1.1.0.tar.gz | 40.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| christie_mseries-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 70.1 kB
Release files / christie_mseries-1.1.0.tar.gz
| Download URL | christie_mseries-1.1.0.tar.gz |
|---|---|
| Size | 40.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ec8e6fac53ba68d4de0a8aa354f6c628047d09bb7f4c91b59825ef730adab5ea
|
|
BLAKE2b-256 checksum How to use checksums |
855b80b113075ca71d9c004831c4fdf2b75233a5d662fe3e8e5a3b28140cc870
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency logRelease files / christie_mseries-1.1.0-py3-none-any.whl
| Download URL | christie_mseries-1.1.0-py3-none-any.whl |
|---|---|
| Size | 29.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2e8c756cc70c39d10fcc2752e4aeaaff4f78a2dc116d8fcee2ad659e693c72ee
|
|
BLAKE2b-256 checksum How to use checksums |
9a11517ac8feae045707588d3213bb79fcec8aeffeea5357af6bbbf0958a79f3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.
Transparency log