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.
- christie_mseries-1.0.0/LICENSE +21 -0
- christie_mseries-1.0.0/PKG-INFO +471 -0
- christie_mseries-1.0.0/README.md +454 -0
- christie_mseries-1.0.0/christie_mseries/__init__.py +36 -0
- christie_mseries-1.0.0/christie_mseries/cli.py +410 -0
- christie_mseries-1.0.0/christie_mseries/client.py +292 -0
- christie_mseries-1.0.0/christie_mseries/exceptions.py +11 -0
- christie_mseries-1.0.0/christie_mseries/projector.py +664 -0
- christie_mseries-1.0.0/christie_mseries.egg-info/PKG-INFO +471 -0
- christie_mseries-1.0.0/christie_mseries.egg-info/SOURCES.txt +15 -0
- christie_mseries-1.0.0/christie_mseries.egg-info/dependency_links.txt +1 -0
- christie_mseries-1.0.0/christie_mseries.egg-info/entry_points.txt +2 -0
- christie_mseries-1.0.0/christie_mseries.egg-info/top_level.txt +1 -0
- christie_mseries-1.0.0/pyproject.toml +51 -0
- christie_mseries-1.0.0/setup.cfg +4 -0
- christie_mseries-1.0.0/tests/test_projector.py +353 -0
- christie_mseries-1.0.0/tests/test_protocol.py +239 -0
|
@@ -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.
|