anthem-rs232 0.2.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,430 @@
1
+ Metadata-Version: 2.4
2
+ Name: anthem-rs232
3
+ Version: 0.2.0
4
+ Summary: Async library to control Anthem MRX 1120 / 720 / 520 / AVM 60 receivers over RS232
5
+ Author: Ben Hass
6
+ Author-email: Ben Hass <ben@groovie.org>
7
+ License-Expression: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Home Automation
14
+ Classifier: Topic :: System :: Hardware
15
+ Classifier: Framework :: AsyncIO
16
+ Requires-Dist: serialx[esphome]>=1.8.2
17
+ Requires-Dist: serial-toolkit>=0.2.0
18
+ Requires-Python: >=3.14
19
+ Project-URL: Repository, https://github.com/bbangert/anthem-rs232
20
+ Description-Content-Type: text/markdown
21
+
22
+ # anthem-rs232
23
+
24
+ Async Python library for the Anthem RS-232 protocol family — covers the **Gen 2** receivers (Statement-replacement MRX 310 through MRX 1120, AVM 60) and the older **Gen 1** receivers (Statement D1/D2/D2v, AVM 20–50, MRX 300/500/700). Built on [serialx](https://github.com/puddly/serialx).
25
+
26
+ Modeled on [denon-rs232](https://github.com/home-assistant-libs/denon-rs232); Gen 1 command set ported from [rsnodgrass/python-anthemav-serial](https://github.com/rsnodgrass/python-anthemav-serial).
27
+
28
+ ## Supported hardware
29
+
30
+ | Model(s) | Series | Constants | RS232 |
31
+ |----------------------------|-------------|------------------------------------|-------|
32
+ | Statement D1 | d1 | `STATEMENT_D1` | Gen 1 |
33
+ | Statement D2, D2v | d2 / d2v | `STATEMENT_D2`, `STATEMENT_D2V` | Gen 1 |
34
+ | AVM 20, AVM 30, AVM 40 | avm20–avm40 | `AVM_20`, `AVM_30`, `AVM_40` | Gen 1 |
35
+ | AVM 50, AVM 50v | avm50 | `AVM_50`, `AVM_50V` | Gen 1 |
36
+ | MRX 300, MRX 500, MRX 700 | mrx | `MRX_300`, `MRX_500`, `MRX_700` | Gen 1 |
37
+ | MRX 310, MRX 510, MRX 710 | mrx1 | `MRX_310`, `MRX_510`, `MRX_710` | Gen 2 |
38
+ | MRX 520, MRX 720, MRX 1120 | mrx2 | `MRX_520`, `MRX_720`, `MRX_1120` | Gen 2 |
39
+ | AVM 60 | avm60 | `AVM_60` | Gen 2 |
40
+
41
+ Gen 1 constants live in `anthem_rs232.gen1`; Gen 2 in `anthem_rs232.models`.
42
+
43
+ The two generations use different wire formats and have separate receiver classes:
44
+
45
+ ```python
46
+ # Gen 2 (semicolon-terminated, 115200 baud)
47
+ from anthem_rs232 import AnthemReceiver
48
+ from anthem_rs232.models import MRX_1120
49
+ gen2 = AnthemReceiver("/dev/ttyUSB0", model=MRX_1120)
50
+
51
+ # Gen 1 (LF-terminated, 9600 / 19200 baud per model)
52
+ from anthem_rs232 import Gen1Receiver
53
+ from anthem_rs232.gen1 import STATEMENT_D2
54
+ gen1 = Gen1Receiver("/dev/ttyUSB0", model=STATEMENT_D2)
55
+ ```
56
+
57
+ ### Not supported
58
+
59
+ - **STR amplifiers** — Gen 2 protocol family but not validated against this library.
60
+ - **Gen 4** — MRX 540 / 740 / 1140 + AVM 70 / 90. Substantially different protocol (new `GC*`, `NM*`, `SS*`, split `Z1ARC*` sub-commands; tuner removed).
61
+ - **MRX SLM** (2024+) — IP-only.
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ pip install anthem-rs232
67
+ ```
68
+
69
+ Requires Python 3.12+.
70
+
71
+ ## Quick start
72
+
73
+ ```python
74
+ import asyncio
75
+ from anthem_rs232 import AnthemReceiver, AudioListeningMode
76
+
77
+ async def main():
78
+ receiver = AnthemReceiver("/dev/ttyUSB0")
79
+ await receiver.connect()
80
+ await receiver.query_state()
81
+
82
+ print(f"Model: {receiver.state.model}")
83
+ print(f"Power: {receiver.state.power}")
84
+ print(f"Volume: {receiver.state.main_zone.volume} dB")
85
+ print(f"Input: {receiver.state.main_zone.input_index}")
86
+
87
+ await receiver.main.set_volume(-30.0)
88
+ await receiver.main.select_input(2)
89
+ await receiver.main.set_audio_listening_mode(AudioListeningMode.DOLBY_SURROUND)
90
+
91
+ await receiver.disconnect()
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ ## CLI
97
+
98
+ ```bash
99
+ # Detect which Anthem model + protocol generation is on the wire (auto-tries
100
+ # Gen 2 @ 115200, Gen 1 @ 9600, Gen 1 @ 19200)
101
+ python -m anthem_rs232.probe /dev/ttyUSB0
102
+
103
+ # Query and print receiver status (Gen 2)
104
+ python -m anthem_rs232 /dev/ttyUSB0
105
+
106
+ # Probe configured inputs (uses ICN? + ISN?/ILN?)
107
+ python -m anthem_rs232 /dev/ttyUSB0 --probe
108
+ ```
109
+
110
+ ## Detecting the model
111
+
112
+ If you don't know which receiver is on a serial port (or which generation), use `probe()`:
113
+
114
+ ```python
115
+ from anthem_rs232 import probe
116
+
117
+ result = await probe("/dev/ttyUSB0")
118
+ # result.generation -> 1 or 2
119
+ # result.model_name -> "MRX 1120"
120
+ # result.baud_rate -> 115200
121
+ # result.model -> the matching model constant (MRX_1120) or None
122
+
123
+ if result is None:
124
+ print("No Anthem responded")
125
+ else:
126
+ print(f"{result.model_name} (Gen {result.generation}, {result.baud_rate} baud)")
127
+ ```
128
+
129
+ The probe walks Gen 2 (115200, `IDM?` then `Z1POW?` fallback) → Gen 1 @ 9600 (`?\n`) → Gen 1 @ 19200, and returns the first match. Works against the same port strings the receivers accept (local devices, `socket://`, `esphome://`).
130
+
131
+ ## Features
132
+
133
+ ### Full state after query
134
+
135
+ `connect()` opens the serial port, verifies the link with `IDM?`, and enables auto-reports with `ECH1`. Call `query_state()` to populate the current receiver state into the `state` property — after that, state stays current via auto-report events from the receiver.
136
+
137
+ ```python
138
+ receiver = AnthemReceiver("/dev/ttyUSB0")
139
+ await receiver.connect()
140
+ await receiver.query_state()
141
+
142
+ state = receiver.state
143
+ state.model # "MRX 1120"
144
+ state.software_version # "0.2.3"
145
+ state.power # True if any zone is on
146
+ state.front_panel_brightness # FrontPanelBrightness.MEDIUM
147
+ state.inputs # {1: InputConfig(...), ...}
148
+
149
+ mz = state.main_zone
150
+ mz.power # True / False
151
+ mz.input_index # 1
152
+ mz.volume # -35.0
153
+ mz.mute # True / False
154
+ mz.arc_enabled # True / False
155
+ mz.balance # 0-100, 50 = center
156
+ mz.audio_listening_mode # AudioListeningMode enum
157
+ mz.audio_input_format # AudioInputFormat enum
158
+ mz.video_input_resolution # VideoInputResolution enum
159
+ ```
160
+
161
+ ### Event subscription
162
+
163
+ Subscribe to state changes. Callbacks receive a `ReceiverState` snapshot on updates, or `None` when the connection drops.
164
+
165
+ ```python
166
+ def on_change(state):
167
+ if state is None:
168
+ print("Disconnected!")
169
+ return
170
+ print(f"Volume: {state.main_zone.volume} dB")
171
+
172
+ unsub = receiver.subscribe(on_change)
173
+ unsub() # stop receiving
174
+ ```
175
+
176
+ ### Power
177
+
178
+ ```python
179
+ await receiver.power_on_all() # Z0POW1 -- power all zones on
180
+ await receiver.power_off_all() # Z0POW0 -- system standby
181
+ await receiver.main.power_on() # Z1POW1
182
+ await receiver.main.power_off() # Z1POW0
183
+ on = await receiver.main.query_power()
184
+ ```
185
+
186
+ ### Volume
187
+
188
+ Volume is in dB and rounded to the nearest valid value by the receiver.
189
+
190
+ ```python
191
+ await receiver.main.set_volume(-30.0) # Z1VOL-30
192
+ await receiver.main.set_volume(5.0) # Z1VOL+05
193
+ await receiver.main.volume_up() # Z1VUP01 (1 dB step)
194
+ await receiver.main.volume_up(step=5) # Z1VUP05
195
+ await receiver.main.volume_down(step=2)
196
+ db = await receiver.main.query_volume()
197
+ ```
198
+
199
+ ### Mute
200
+
201
+ ```python
202
+ await receiver.main.mute_on()
203
+ await receiver.main.mute_off()
204
+ await receiver.main.mute_toggle()
205
+ ```
206
+
207
+ ### Inputs
208
+
209
+ Anthem inputs are configurable and identified by 1-based index. Names come from `ISN?` (short) and `ILN?` (long) and are populated during `query_state()`.
210
+
211
+ ```python
212
+ await receiver.main.select_input(2)
213
+ idx = await receiver.main.query_input()
214
+
215
+ inputs = receiver.state.inputs
216
+ print(inputs[1].short_name) # "CBL"
217
+ print(inputs[1].long_name) # "Cable Box"
218
+ ```
219
+
220
+ ### Audio listening mode
221
+
222
+ ```python
223
+ from anthem_rs232 import AudioListeningMode
224
+
225
+ await receiver.main.set_audio_listening_mode(AudioListeningMode.DOLBY_SURROUND)
226
+ await receiver.main.audio_listening_mode_next()
227
+ mode = await receiver.main.query_audio_listening_mode()
228
+ ```
229
+
230
+ ### Anthem Room Correction
231
+
232
+ ```python
233
+ await receiver.main.arc_on()
234
+ await receiver.main.arc_off()
235
+ on = await receiver.main.query_arc()
236
+ ```
237
+
238
+ ### Per-input processing
239
+
240
+ Lip sync delay, Dolby Volume, and the Dolby Volume Leveler are stored per
241
+ input on the receiver. ``input_index=0`` (the default) targets the currently
242
+ selected input.
243
+
244
+ ```python
245
+ await receiver.set_lip_sync(50) # SLIP00050 (0-150 ms, 5 ms steps)
246
+ await receiver.set_dolby_volume(True, input_index=2) # SDVS021
247
+ await receiver.set_dolby_volume_leveler(5) # SDVL005 (0 = off, 1-9)
248
+
249
+ ms = await receiver.query_lip_sync()
250
+ on = await receiver.query_dolby_volume()
251
+ level = await receiver.query_dolby_volume_leveler()
252
+
253
+ # Populated per input from query replies and auto-reports:
254
+ receiver.state.inputs[2].lip_sync_ms
255
+ receiver.state.inputs[2].dolby_volume
256
+ receiver.state.inputs[2].dolby_volume_leveler
257
+ ```
258
+
259
+ ### Balance, channel levels, tone
260
+
261
+ These are runtime adjustments meant to compensate for source material — for system setup, use the receiver's setup menu and ARC.
262
+
263
+ ```python
264
+ from anthem_rs232 import Channel
265
+
266
+ await receiver.main.set_balance(50) # 0=full left, 100=full right
267
+ await receiver.main.set_channel_level(Channel.SUBS, -3.0)
268
+ await receiver.main.set_channel_level(Channel.HEIGHTS_1, 2.0)
269
+ await receiver.main.channel_level_up(Channel.CENTER)
270
+
271
+ await receiver.main.set_bass(-2.0)
272
+ await receiver.main.set_treble(3.0)
273
+ ```
274
+
275
+ ### Tuner (FM)
276
+
277
+ ```python
278
+ await receiver.main.set_fm_frequency(105.50) # T1FMS105.50
279
+ await receiver.main.tune_up()
280
+ await receiver.main.seek_up()
281
+ await receiver.main.preset_up()
282
+ await receiver.main.select_preset(7)
283
+ await receiver.main.assign_preset(7)
284
+ await receiver.main.remove_preset(7)
285
+ freq = await receiver.main.query_fm_frequency()
286
+ ```
287
+
288
+ ### Zone 2
289
+
290
+ ```python
291
+ await receiver.zone_2.power_on()
292
+ await receiver.zone_2.select_input(2)
293
+ await receiver.zone_2.set_volume(-40.0)
294
+ await receiver.zone_2.mute_on()
295
+ ```
296
+
297
+ ### Setup / display
298
+
299
+ ```python
300
+ from anthem_rs232 import FrontPanelBrightness
301
+
302
+ await receiver.set_front_panel_brightness(FrontPanelBrightness.HIGH)
303
+ await receiver.set_standby_ip_control(True) # required for IP power-on
304
+ await receiver.set_speaker_profile(2)
305
+ await receiver.main.display_message(0, "Hello")
306
+ await receiver.main.open_setup_menu()
307
+ ```
308
+
309
+ ### Triggers
310
+
311
+ ```python
312
+ await receiver.set_trigger_control(1, rs232_controlled=True)
313
+ await receiver.set_trigger(1, on=True)
314
+ ```
315
+
316
+ ### Connection handling
317
+
318
+ - If `IDM?` is not answered during `connect()`, `ConnectionError` is raised.
319
+ - If the serial connection drops, subscribers receive `None` and `connected` becomes `False`.
320
+ - An idle watchdog probes the link (`Z1POW?`, answered even in standby) after 60 s without RX and tears the connection down if repeated probes go unanswered — covering transports (e.g. serial-over-network proxies) that can die without delivering EOF. The probe retries up to 3 times because a unit in ECO standby consumes the first frame as its wake-up.
321
+ - Receiver errors come back as `!E/R/I/Z<original>;` and raise `CommandError` from the matching pending query. Fire-and-forget commands log the error.
322
+
323
+ ```python
324
+ try:
325
+ await receiver.connect()
326
+ except ConnectionError:
327
+ print("Receiver not responding")
328
+ ```
329
+
330
+ ## Gen 1 (Statement / older AVM / older MRX)
331
+
332
+ The Gen 1 receiver lives in the `gen1` subpackage and exposes a different API surface (because the protocol is different — different command shape, different volume scale, dedicated headphone output, decoder/effect modes, channel trims).
333
+
334
+ ```python
335
+ import asyncio
336
+ from anthem_rs232 import Gen1Receiver
337
+ from anthem_rs232.gen1 import (
338
+ STATEMENT_D2, Source, DecoderMode, EffectMode, SleepTimer,
339
+ DolbyDynamicRange, TunerMode,
340
+ )
341
+
342
+ async def main():
343
+ rx = Gen1Receiver("/dev/ttyUSB0", model=STATEMENT_D2)
344
+ await rx.connect() # opens serial, sends ?, enables SST1 auto-reports
345
+ await rx.query_state() # P1?, P2?, P3?, H?, TT?
346
+
347
+ state = rx.state
348
+ print(state.model, state.version) # "Statement D2" "3.10"
349
+ print(state.main_zone.volume) # e.g. -30.5
350
+
351
+ # Main zone (P1)
352
+ await rx.main.power_on() # P1P1
353
+ await rx.main.set_volume(-30.5) # P1VM-30.5 (0.5 dB step)
354
+ await rx.main.mute_toggle() # P1MT
355
+ await rx.main.select_source(Source.DVD_1.value) # P1S5
356
+ await rx.main.set_decoder_mode("5", DecoderMode.PRO_LOGIC)
357
+ await rx.main.set_effect_mode("5", EffectMode.HALL)
358
+ await rx.main.set_dolby_dynamic_range(DolbyDynamicRange.LATE_NIGHT)
359
+ await rx.main.set_sleep_timer(SleepTimer.SIXTY_MIN)
360
+ await rx.main.display_message(1, "Hello")
361
+
362
+ # Channel trims and balance (Main only)
363
+ await rx.main.set_center_trim(1.5)
364
+ await rx.main.set_sub_trim(-2.0)
365
+ await rx.main.set_front_balance(2.0)
366
+ await rx.main.set_master_bass(-3.0)
367
+
368
+ # Zone 2 (P2) -- separate volume scale (1.25 dB step)
369
+ await rx.zone_2.power_on()
370
+ await rx.zone_2.set_volume(-50.0) # P2V-50.0 (no "M" after V)
371
+ await rx.zone_2.set_balance(2.5)
372
+ await rx.zone_2.set_treble(4.0)
373
+
374
+ # Rec / Zone 3 (P3) -- source-only
375
+ await rx.rec.select_source(Source.TUNER.value)
376
+
377
+ # Headphone (H) -- dedicated output
378
+ await rx.headphone.set_volume(-20.0)
379
+ await rx.headphone.mute_toggle()
380
+
381
+ # Tuner (T)
382
+ await rx.tuner.set_fm_frequency(101.5) # TFT101.5
383
+ await rx.tuner.set_am_frequency(540) # TAT0540
384
+ await rx.tuner.fm_preset(1, 2) # TFP12
385
+ band, freq = await rx.tuner.query_frequency()
386
+
387
+ # 12 V triggers
388
+ await rx.set_trigger(1, on=True) # t1T1
389
+
390
+ # System
391
+ await rx.power_on_all() # P1P1;P2P1;P3P1
392
+ await rx.lock_front_panel() # FPL1
393
+ await rx.rename_source("5", "Apple") # SN5Apple
394
+ await rx.save_user_settings() # SfSU
395
+
396
+ await rx.disconnect()
397
+
398
+ asyncio.run(main())
399
+ ```
400
+
401
+ ### Gen 1 notes
402
+
403
+ - **Wire format**: LF-terminated ASCII, default 9600 baud (D2/D2v: 19200). Multiple commands can be chained on one line with `;`.
404
+ - **No reply on success**: the Gen 1 receiver answers queries only — set commands are silent unless they fail. Connect enables Tx Status (`SST1`) so external state changes (front panel, IR remote, knob, bitstream change) propagate as auto-report frames.
405
+ - **Errors are verbose ASCII**: `Invalid Command`, `Parameter Out-of-range`, `Main Off`, `Zone2 Off`, `Unit Off`, `Already in use`. They raise `Gen1CommandError` from the matching pending query.
406
+ - **Volume**: dB float with one decimal place. Main zone uses 0.5 dB step (`P1VM`); Zone 2 / Headphone use 1.25 dB step (`P2V` / `HV`). The library snaps to the nearest grid.
407
+ - **Source codes**: single ASCII characters (`0`–`9`, plus `d`–`j` on D2/D2v for second-bank inputs). The `Source` enum lists them.
408
+ - **Identify**: `?` returns `(model,version,build)` — populated into `state.model` / `state.version` / `state.build_date` during `connect()`.
409
+
410
+ ## Serial connection
411
+
412
+ **Gen 2** (MRX 310+, AVM 60): 115200 baud, 8N1, no flow control, `;`-terminated.
413
+ **Gen 1** (Statement, older AVM/MRX): 9600 or 19200 baud (per model), 8N1, no flow control, LF-terminated.
414
+ Both use a straight-wired DB-9 cable (Pin 2 = Tx, 3 = Rx, 5 = GND).
415
+
416
+ ## Protocol references
417
+
418
+ - Gen 2: [Anthem MRX 1120 / 720 / 520 / AVM 60 IP / RS-232 serial commands (Google Sheets)](https://docs.google.com/spreadsheets/d/1_lN8JWSIPRrWrqxuZ3lNGy5GQ3FjyaBi6kIeuk21GP4/edit?usp=sharing)
419
+ - Gen 1: AVM-2 serial spec (21 November 2000) — bundled in the [rsnodgrass/python-anthemav-serial repo](https://github.com/rsnodgrass/python-anthemav-serial/blob/main/docs/AVM-2-serial-programming.txt)
420
+
421
+ ## Development
422
+
423
+ ```bash
424
+ uv sync
425
+ uv run pytest
426
+ ```
427
+
428
+ ## License
429
+
430
+ MIT