christie-mseries 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 imsatasia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,471 @@
1
+ Metadata-Version: 2.4
2
+ Name: christie-mseries
3
+ Version: 1.0.0
4
+ Summary: Python client for Christie M Series projectors' serial API over TCP
5
+ Author-email: imsatasia <imsatasia@yahoo.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/imsatasia/py-christie-mseries
8
+ Project-URL: Issues, https://github.com/imsatasia/py-christie-mseries/issues
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Home Automation
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # christie-mseries
19
+
20
+ Python client for Christie **M Series** projectors' Ethernet serial API on
21
+ **TCP port 3002**. Standard library only, no third-party dependencies.
22
+
23
+ Named for the protocol, not one model: `client.py`'s wire framing/parsing
24
+ comes straight from Christie's own M Series Serial API Commands document and
25
+ should hold across the line. Only `ChristieM4K25` in `projector.py` is
26
+ actually verified, against the TruLife+ platform in the M 4K25 RGB
27
+ specifically — its control set (which codes exist, test pattern numbering,
28
+ brightness floor) is *not* assumed to carry over to other M Series models
29
+ without checking `docs/menu-map.json`/`raw_query()` against the real unit
30
+ first. See "The control model, and how to find a control" below for why.
31
+
32
+ ## Contents
33
+
34
+ - [Firmware this was built for](#firmware-this-was-built-for)
35
+ - [Install](#install)
36
+ - [Usage](#usage)
37
+ - [CLI](#cli)
38
+ - [The control model, and how to find a control](#the-control-model-and-how-to-find-a-control)
39
+ - [Command coverage](#command-coverage)
40
+ - [Inputs: `SIN`](#inputs-sin)
41
+ - [Laser power: `LAS+POWR`](#laser-power-laspowr)
42
+ - [How replies work, and why it bites](#how-replies-work-and-why-it-bites)
43
+ - [Status groups](#status-groups)
44
+ - [Polling: `snapshot()`](#polling-snapshot)
45
+ - [Testing](#testing)
46
+ - [Consumers](#consumers)
47
+ - [Layout](#layout)
48
+ - [Credits](#credits)
49
+ - [License](#license)
50
+
51
+ ## Firmware this was built for
52
+
53
+ **Christie M 4K25 RGB, main control board software 1.3.x** — developed and
54
+ verified against a physical unit running **1.3.9**, re-verified against the
55
+ same unit updated to **1.3.10** (no behavior changes to anything below; see
56
+ "1.3.10" note further down), with these component versions:
57
+
58
+ | Component | Version |
59
+ | --- | --- |
60
+ | Main Control Board SW | `ChristieM 1.3.10` |
61
+ | Main Control Board HW | `CAVE.7.1` |
62
+ | Formatter R/G/B HW | `CFB098HU.3.4` |
63
+ | Photon SW | `1.6.1-1(Boot)/1.8.1-7(Main)` |
64
+ | Power Supply HW | `PSB.2.0` |
65
+ | Housekeeping Board HW | `HKBG.3.2` |
66
+ | Keypad Display HW | `IKB.6.0` |
67
+ | Variable Option Module HW | `VOMHBI.3.0` |
68
+ | BIOS SW | `00.33` |
69
+ | SSPWBD SW / HW | `1.6.741270` / `WBD.2.3` |
70
+
71
+ This matters more than it usually would. Which codes exist at all is a
72
+ property of the firmware, not of the model — this unit's TruLife+ platform
73
+ implements roughly a third of the codes in the M Series serial API document
74
+ this client was first written from, and `docs/menu-map.json` is a snapshot of
75
+ *this* firmware's menu tree. After a software upgrade, regenerate that map and
76
+ re-run the tests before trusting any code that is not exercised by them:
77
+
78
+ ```bash
79
+ python3 examples/dump_menu.py <host> <user> <password> --json > docs/menu-map.json
80
+ git diff docs/menu-map.json # controls added, removed or re-ranged
81
+ ```
82
+
83
+ **1.3.10**: re-ran the full functional check (power/shutter/brightness/
84
+ LiteLOC/test pattern/lens positions/input/hours, plus a fresh menu dump) —
85
+ everything already wrapped here behaves identically. Five new codes appeared
86
+ under `EDC+DPCV`/`EDC+DPVO`/`EDC+HDCV`/`EDC+HDVO`/`MMC+EDID` (custom EDID
87
+ management for the HDMI/DisplayPort inputs, under Configuration > Input
88
+ Settings), all reported `enabled: false` with no options beyond "Default
89
+ EDID" — nothing configurable yet, so nothing new to wrap.
90
+
91
+ Read the running versions at any time with:
92
+
93
+ ```bash
94
+ python3 -c "from christie_mseries import ChristieM4K25
95
+ with ChristieM4K25('<host>') as p:
96
+ for k, v in p.get_status('VERS').items(): print(f'{k}: {v}')"
97
+ ```
98
+
99
+ ## Install
100
+
101
+ ```bash
102
+ pip install christie-mseries
103
+ ```
104
+
105
+ For local development:
106
+
107
+ ```bash
108
+ git clone https://github.com/imsatasia/py-christie-mseries.git
109
+ cd py-christie-mseries
110
+ uv sync --group dev # or: task install
111
+ ```
112
+
113
+ ## Usage
114
+
115
+ ```python
116
+ from christie_mseries import ChristieM4K25
117
+
118
+ with ChristieM4K25("192.0.2.50") as projector:
119
+ projector.get_power_state() # <PowerState.ON: 1>
120
+ projector.get_power_state_text() # 'On'
121
+ projector.get_input() # (1, 'One-Port HDMI0')
122
+ projector.get_brightness() # 70.0 (percent of laser power)
123
+ projector.get_hours() # '4:05 (h:m)'
124
+ projector.get_temperatures() # {'Air Intake Temperature (Temp 2)': '34 °C', ...}
125
+ projector.power_on(wait=True) # blocks until the ack comes back
126
+ projector.snapshot() # Snapshot(power='On', power_code=1, ...) -- one poll, every field a caller like Home Assistant wants
127
+ ```
128
+
129
+ Anything without a named method is reachable with `raw_query()` /
130
+ `raw_set()`, which take a code and an optional subcode:
131
+
132
+ ```python
133
+ projector.raw_query("GAM") # (GAM?)
134
+ projector.raw_query("LAS", "WHTX") # (LAS+WHTX?)
135
+ ```
136
+
137
+ ## CLI
138
+
139
+ Installed as the `christie-mseries` console script:
140
+
141
+ ```bash
142
+ christie-mseries 192.0.2.50 status # power, input, hours, alarms
143
+ christie-mseries 192.0.2.50 json # machine-readable, one poll (snapshot())
144
+ christie-mseries 192.0.2.50 brightness # read laser power %
145
+ christie-mseries 192.0.2.50 brightness 70 # set it (30-100)
146
+ christie-mseries 192.0.2.50 input 3 # by SIN index...
147
+ christie-mseries 192.0.2.50 input "HDMI 2.1 Port 3" # ...or by label
148
+ christie-mseries 192.0.2.50 shutter open
149
+ christie-mseries 192.0.2.50 test-pattern "Color Bars"
150
+ christie-mseries 192.0.2.50 group TEMP # one status group in full
151
+ christie-mseries 192.0.2.50 probe # which documented codes exist here
152
+ ```
153
+
154
+ ## The control model, and how to find a control
155
+
156
+ This unit runs Christie's **TruLife+** platform, and the M Series serial API
157
+ doc (`020-100224-11`) that this library was first written from describes a
158
+ different, older one. Of the doc's 175 codes, 36 answer here; assuming the
159
+ rest exist produces methods that fail at runtime with `ERR00101 "Control Not
160
+ Found"`.
161
+
162
+ More importantly, the doc's model — a flat space of three-letter codes — is
163
+ simply not how this platform is shaped. Its real control surface is
164
+ overwhelmingly **subcoded**: of 221 controls (as of firmware 1.3.10), 19 are
165
+ bare codes and 202 are `CODE+SUB` across 33 families.
166
+
167
+ That has a sharp consequence for discovery. **The serial API cannot list
168
+ anything.** It only confirms a code you already guessed, and a code that
169
+ requires a subcode answers `Control Not Found` when queried bare — `LAS`,
170
+ `WRP` and `NET` all do. So sweeping the three-letter space cannot find
171
+ subcoded controls even in principle: a sweep of all 17,576 combinations
172
+ returned 56 codes here and still missed laser power entirely.
173
+
174
+ The projector's built-in web UI has the interface that *does* enumerate: a
175
+ JSON-RPC endpoint at `http://<host>/cgi-bin/c4jweb/`, where `menu:get`
176
+ returns each menu node with every child's code, label, type, range, soft
177
+ range and option list. `examples/dump_menu.py` walks the whole tree:
178
+
179
+ ```bash
180
+ python3 examples/dump_menu.py <host> <user> <password> --json > docs/menu-map.json
181
+ ```
182
+
183
+ **`docs/menu-map.json` is the checked-in result and the reference to read
184
+ first** for any "can it do X?" question: 236 entries covering those 221
185
+ controls, each with its menu path. Regenerate it after a firmware update.
186
+ Note that `session:connect` returns a session URL that every later call must
187
+ be posted to, or the reply is `invalid Session ID`.
188
+
189
+ The vendor's own M Series serial API document (020-100224-11) is
190
+ deliberately **not** included in this repo — it's Christie's copyrighted
191
+ material, and reproducing it wholesale isn't appropriate to publish here.
192
+ Everything this README says about it (which codes it gets right or wrong,
193
+ what it doesn't cover) comes from our own testing against the real unit,
194
+ cross-checked with `docs/menu-map.json`.
195
+
196
+ ## Command coverage
197
+
198
+ Codes with a named method:
199
+
200
+ | Code | Meaning | Method |
201
+ |------|---------|--------|
202
+ | `PWR` | Power | `power_on()`, `power_off()`, `get_power_state()`, `get_power_state_text()` |
203
+ | `SHU` | Shutter | `open_shutter()`, `close_shutter()`, `is_shutter_open()` |
204
+ | `LAS+POWR` | Brightness (laser power) | `get_brightness()`, `set_brightness()` |
205
+ | `LAS+STAT` | LiteLOC | `get_liteloc()`, `set_liteloc()` |
206
+ | `SIN` | Select Input | `select_input()` (index or label), `get_input()` |
207
+ | `CHA` | Channel | `select_channel()`, `get_channel()`, `copy_channel()`, `delete_channel()` |
208
+ | `SST` | Projector Status | `get_status()`, `get_hours()`, `get_model()`, `get_serial_number()`, `get_temperatures()` |
209
+ | `FCS` | Focus | `get_focus()`, `set_focus()` |
210
+ | `ZOM` | Zoom | `get_zoom()`, `set_zoom()` |
211
+ | `LHO` | Lens Horizontal | `get_lens_horizontal()`, `set_lens_horizontal()` |
212
+ | `LVO` | Lens Vertical | `get_lens_vertical()`, `set_lens_vertical()` |
213
+ | `LCB` | Lens Calibration | `calibrate_lens()`, `home_lens()` (write-only) |
214
+ | `FRZ` | Image Freeze | `is_frozen()`, `set_frozen()` |
215
+ | `ITP` | Test Pattern | `get_test_pattern()`, `set_test_pattern()` |
216
+ | `WRP+SLCT` | Geometry Correction | `get_geometry_warp()`, `set_geometry_warp()`, `reset_keystone()` |
217
+ | `ASU` | Auto Setup | `auto_setup()` (write-only) |
218
+ | `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) |
219
+ | `NET` | Network Setup | `get_network()` |
220
+ | `ADR` | Address | `get_address()`, `set_address()` |
221
+ | `PNG` | Ping | `ping()` |
222
+ | — | Everything worth polling in one shot | `snapshot()` |
223
+
224
+ Present but not wrapped — use `raw_query()` / `raw_set()`: `APW` (auto power
225
+ on), `BGC` (gamma curve), `CLE` (color enable), `CSP` (color space), `DTL`
226
+ (detail), `EME` (error messages), `FMD` (film mode detect), `FRD` (frame
227
+ delay), `GAM` (gamma), `MSP` (menu location), `NTR` (network routing), `OSD`
228
+ (on-screen display), `RAL` (remote access level), `SOR` (screen
229
+ orientation), `SZP` (size preset), `UID` (user ID), plus the ~202 subcoded
230
+ controls in `docs/menu-map.json`.
231
+
232
+ ### What this platform does not have
233
+
234
+ - **None of the doc's laser/illumination codes.** `LPI`, `LPM`, `LPP` are
235
+ all absent, as are `LSR`, `LSP`, `LPW`, `LIP`, `ILP`, `LSI` and `ILI`.
236
+ Laser power is controllable, just under a different name — see below.
237
+ - **No ILS / lens memory.** `ILS` and `ILV` are absent, so lens positions
238
+ are not stored per channel. Channels (`CHA`) still store source routing
239
+ and image settings, but recalling one will not move the lens. (The
240
+ `christie-m4k25-homeassistant` and `christie-m4k25-control4` integrations
241
+ each synthesize their own lens presets on top of `get_focus()`/`set_focus()`
242
+ etc., since the projector has none of its own.)
243
+ - **No iris control** (`IRS`, `DIM`, `DIS`, `MIP`).
244
+ - **No `PJH`** for runtime hours — use `get_hours()`, which pulls "Projector
245
+ Hours" out of the `SYST` status group.
246
+ - **No `KEY` remote-button emulator** and no `MNU`, so the on-screen menu
247
+ can't be driven over this API.
248
+ - **No `+MAIN` subcodes.** The doc's `SIN+MAIN`, `CHA+MAIN`, `TDM+MAIN`,
249
+ `TDD+MAIN` and `DRK+MAIN` do not exist; use the plain codes.
250
+
251
+ These absences are structural, not a side effect of the projector being
252
+ idle: probing in standby and again with the light source running returns an
253
+ identical list, 35 codes either way.
254
+
255
+ ## Inputs: `SIN`
256
+
257
+ The projector reports its inputs by its own names (`get_input()` returns
258
+ `(3, "One-Port VOM-HDMI")`), which don't say which connector on the back they
259
+ are. `INPUTS` maps each `SIN` index to the physical port, and `select_input()`
260
+ takes either the index or the label, the same way `set_test_pattern()` takes
261
+ a number or a name:
262
+
263
+ | Index | Projector's name | Label |
264
+ |---|---|---|
265
+ | 1 | One-Port HDMI0 | HDMI 2.0 Port 1 |
266
+ | 2 | One-Port HDMI1 | HDMI 2.0 Port 2 |
267
+ | 3 | One-Port VOM-HDMI | HDMI 2.1 Port 3 |
268
+ | 4 | One-Port VOM-DP0 | DisplayPort 1.4 Port 3 |
269
+ | 5 | One-Port VOM-DP1 | DisplayPort 1.4 Port 4 |
270
+ | 6 | One-Port DP0 | DisplayPort 1.2 Port 1 |
271
+ | 7 | One-Port DP1 | DisplayPort 1.2 Port 2 |
272
+ | 8-11 | One-Port SDI0-SDI3 | SDI 1-SDI 4 |
273
+
274
+ ```python
275
+ projector.select_input("HDMI 2.1 Port 3") # same as select_input(3)
276
+ projector.select_input(3)
277
+ ```
278
+
279
+ An unknown label raises `ValueError` before anything is sent. Every index
280
+ 1-11 was switched to and read back on hardware; the labels come from
281
+ the port names in the projector's Input Settings menu (`EDC+HDCV`, `HDVO`,
282
+ `DPCV`, `DPVO` in `docs/menu-map.json`). **Index 3 = HDMI 2.1 Port 3 is
283
+ confirmed; the pairing of indexes 4-7 to individual DisplayPort ports is
284
+ inferred from the order the projector lists them, not checked against a
285
+ cable** - correct `INPUTS` if a port turns out to be the other of its pair.
286
+ The `VOM-*` inputs belong to the Variable Option Module, so a unit without
287
+ one won't have them.
288
+
289
+ `SIN` is refused with `Disabled Control` while the projector is in standby:
290
+ the input can be read then, but only changed once it is on. `snapshot()`
291
+ carries the table for pollers (see below), and its `input_label` falls back to
292
+ the projector's own name for an index that isn't in `INPUTS`.
293
+
294
+ ## Laser power: `LAS+POWR`
295
+
296
+ Labelled **Brightness** in the projector's menu, under Configuration > Light
297
+ & Output Settings. Stored in tenths of a percent, so it reads `700` at 70%:
298
+
299
+ ```python
300
+ projector.get_brightness() # 70.0
301
+ projector.set_brightness(85) # percent, not tenths
302
+ ```
303
+
304
+ Its hard range is 0–1000, and the projector publishes a **soft minimum of
305
+ 200**, below which it will not run the lasers at all.
306
+
307
+ `set_brightness()` enforces a stricter floor of **30%**, because Christie's
308
+ own release notes for both 1.3.9 and 1.3.10 carry this as an open known
309
+ issue: *"LiteLOC performance is compromised when running at low brightness
310
+ levels (30% or less) ... laser devices may shut down and colors may drop
311
+ out."* LiteLOC is enabled on this unit, so 20–30% is a band the hardware
312
+ accepts but the vendor advises against, and it is rejected rather than
313
+ offered. The menu node
314
+ also carries a `softvalue`: the brightness actually being applied, which
315
+ drops below the set value when the projector thermally limits itself. That
316
+ is what the web UI's "Brightness Reduced / System Adjusted" labels report.
317
+
318
+ If this floor is ever revisited, change `BRIGHTNESS_MIN_PERCENT` here and
319
+ update it everywhere else it's duplicated — the Home Assistant integration's
320
+ `number` entity and the Control4 driver's `SetBrightness` command each carry
321
+ their own copy, and all three are expected to agree.
322
+
323
+ `LAS+STAT` is LiteLOC, reporting `3` for enabled and `1` for disabled. Note
324
+ the projector currently marks it `enabled: false` — greyed out in its own
325
+ UI. Per-laser setpoints `LAS+REDP`/`GRNP`/`BLUP` exist under Admin >
326
+ Diagnostics; those are service-level colour calibration, not a brightness
327
+ control, and are best left alone.
328
+
329
+ Laser *telemetry* is separate and read-only: `get_status("LGHT")` returns
330
+ 105 items including per-bank temperatures, driver amps/volts, and "Laser On
331
+ Hours".
332
+
333
+ ## How replies work, and why it bites
334
+
335
+ Three things about this protocol are easy to get wrong, and all three
336
+ produced real bugs here:
337
+
338
+ - **Most replies pair a number with a description** — `(PWR!000 "Standby
339
+ Mode")`, not `(PWR!000)` — so `int()` on the reply data fails. Use
340
+ `get_power_state()` for the number and `get_power_state_text()` for the
341
+ projector's own wording, which is more trustworthy than the doc's value
342
+ tables (the doc says `TDM` 1 is "Native 3D"; this unit says "Auto Detect").
343
+ - **A successful SET is answered with silence, a rejected one isn't.** An
344
+ unread error reply doesn't vanish — it's returned as the answer to the
345
+ next query, shifting every later reading by one. `set()` therefore waits
346
+ briefly for an error and raises it.
347
+ - **Three different failures look similar.** `ERR00101 "Control Not Found"`
348
+ means the code doesn't exist on this platform (or needs a subcode);
349
+ `ERR00105 "Disabled Control"` means it exists but isn't available right
350
+ now — image controls like `ITP` and `FRZ` are rejected in standby and
351
+ accepted once the projector is on; and `"This control can not be read"`
352
+ marks write-only codes (`ASU`, `LCB`).
353
+
354
+ Also worth knowing for polling: for a few seconds right after `PWR 1` the
355
+ projector accepts a TCP connection but answers nothing, so a read can time
356
+ out mid-transition. Treat a timeout as "unknown", not as "off" — the
357
+ sequence observed here was silence, then `11 "Warming Up"`, then `1 "On"`
358
+ about 16 seconds after the command.
359
+
360
+ ## Status groups
361
+
362
+ `get_status(group)` returns `{label: value}` for one group, reading the
363
+ projector's multi-message reply until it goes quiet:
364
+
365
+ | Group | Items | Contents |
366
+ |-------|-------|----------|
367
+ | `CONF` | 4 | model, serial number, output resolution, build date |
368
+ | `COOL` | 12 | fans and blowers |
369
+ | `LGHT` | 105 | laser/light-source state, temperatures, firmware versions |
370
+ | `SIGN` | 22 | input signal properties |
371
+ | `SYST` | 30 | hours, pitch/roll, lens calibration, board health, voltages |
372
+ | `TEMP` | 13 | temperature sensors |
373
+ | `VERS` | 13 | software versions |
374
+ | `ALRM` | 0 | active alarms; empty is the healthy case, returned as `{}` |
375
+
376
+ The doc's `HLTH` and `LAMP` groups don't exist here, and `LGHT` exists but
377
+ isn't in the doc.
378
+
379
+ ## Polling: `snapshot()`
380
+
381
+ ```python
382
+ snap = projector.snapshot()
383
+ snap.power_code, snap.power # 1, "On"
384
+ snap.is_on, snap.in_transition
385
+ snap.input, snap.input_name
386
+ snap.input_label, snap.inputs, snap.input_map # "HDMI 2.1 Port 3", [labels...], {label: index}
387
+ snap.brightness, snap.liteloc
388
+ snap.test_pattern, snap.test_patterns, snap.test_pattern_map
389
+ snap.focus, snap.zoom, snap.lens_horizontal, snap.lens_vertical
390
+ snap.hours, snap.model, snap.serial, snap.alarms
391
+ snap.intake_temp # None if unreadable
392
+ ```
393
+
394
+ One connection's worth of reads, bundled into a single `Snapshot` dataclass.
395
+ This is the single source of truth both the CLI's `json` command and (via
396
+ `christie-m4k25-homeassistant`) Home Assistant's coordinator poll from — add
397
+ a new pollable field here, not in either caller.
398
+
399
+ `snapshot()` raises like any other method here if a read fails or the
400
+ connection drops mid-poll. A caller that wants "unreachable" reported as
401
+ data instead of an exception should catch around it, the same way `cli.py`'s
402
+ `as_json()` does:
403
+
404
+ ```python
405
+ try:
406
+ with ChristieM4K25(host, timeout=8) as projector:
407
+ snap = projector.snapshot()
408
+ except Exception as exc:
409
+ ... # report as unreachable rather than propagating
410
+ ```
411
+
412
+ ## Testing
413
+
414
+ ```bash
415
+ task test # or: uv run pytest -v
416
+ ```
417
+
418
+ 29 protocol tests (`tests/test_protocol.py`) plus a domain-layer suite
419
+ (`tests/test_projector.py`) covering `ChristieM4K25`'s methods including
420
+ `snapshot()`. There is no simulator, so both run against fake sockets;
421
+ behaviour on real hardware is checked with `christie-mseries probe` and
422
+ `christie-mseries status`. The reply strings in the tests are real captures,
423
+ including the cases that previously broke: replies pairing a number with a
424
+ description (`000 "Standby Mode"`), multi-field replies (`NET`), escaped
425
+ parens (`"3:14 \(h:m\)"`), and non-ASCII status text (`"32 °C"`).
426
+
427
+ ## Consumers
428
+
429
+ This package is a standalone client; it contains no Home Assistant or
430
+ Control4 integration code of its own.
431
+
432
+ - [`christie-m4k25-homeassistant`](https://github.com/imsatasia/christie-m4k25-homeassistant) —
433
+ a native Home Assistant custom integration built on this package.
434
+ - [`christie-m4k25-control4`](https://github.com/imsatasia/christie-m4k25-control4) —
435
+ a Control4 DriverWorks driver; a separate Lua reimplementation of the same
436
+ wire protocol (Control4 drivers can't call out to a Python process), not a
437
+ consumer of this package at runtime, but developed against the same
438
+ hardware and kept in sync with the protocol notes above.
439
+
440
+ ## Layout
441
+
442
+ | Path | Purpose |
443
+ |------|---------|
444
+ | `christie_mseries/client.py` | Message framing, parsing, and the raw request/set API |
445
+ | `christie_mseries/projector.py` | High-level `ChristieM4K25` methods and `Snapshot` |
446
+ | `christie_mseries/cli.py` | The `christie-mseries` console script, including the `json` poll Home Assistant runs |
447
+ | `examples/dump_menu.py` | Regenerates `docs/menu-map.json` from the web RPC |
448
+ | `docs/menu-map.json` | **221 labelled controls** — the reference to read first |
449
+
450
+ ## Credits
451
+
452
+ This library was originally written against Christie's own **M Series
453
+ Serial API Commands** technical reference:
454
+
455
+ > Technical Reference 020-100224-11 — *M Series Serial API Commands*,
456
+ > © 2016 Christie Digital Systems USA Inc. All rights reserved.
457
+ > [PDF, hosted by Christie Digital](https://www.christiedigital.com/globalassets/resources/public/020-100224-11-christie-lit-tech-ref-m-series-serial-commands.pdf)
458
+
459
+ That document isn't reproduced in this repository — it's Christie's
460
+ copyrighted material, and redistributing a vendor's proprietary manual
461
+ wholesale isn't appropriate for a public repo. It's linked here instead,
462
+ from Christie's own site, for anyone who wants the original. As documented
463
+ throughout this README, it also turned out to only partially describe this
464
+ platform (TruLife+, not the M Series document's original target) — the
465
+ document this library actually relies on for day-to-day accuracy is
466
+ `docs/menu-map.json`, generated directly from the projector's own web UI.
467
+
468
+ ## License
469
+
470
+ MIT — see [LICENSE](LICENSE). Applies to the code in this repository only,
471
+ not to Christie's serial API document credited above.