anthem-rs232
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.
Modeled on denon-rs232; Gen 1 command set ported from rsnodgrass/python-anthemav-serial.
Supported hardware
| Model(s) | Series | Constants | RS232 |
|---|---|---|---|
| Statement D1 | d1 | STATEMENT_D1 |
Gen 1 |
| Statement D2, D2v | d2 / d2v | STATEMENT_D2, STATEMENT_D2V |
Gen 1 |
| AVM 20, AVM 30, AVM 40 | avm20–avm40 | AVM_20, AVM_30, AVM_40 |
Gen 1 |
| AVM 50, AVM 50v | avm50 | AVM_50, AVM_50V |
Gen 1 |
| MRX 300, MRX 500, MRX 700 | mrx | MRX_300, MRX_500, MRX_700 |
Gen 1 |
| MRX 310, MRX 510, MRX 710 | mrx1 | MRX_310, MRX_510, MRX_710 |
Gen 2 |
| MRX 520, MRX 720, MRX 1120 | mrx2 | MRX_520, MRX_720, MRX_1120 |
Gen 2 |
| AVM 60 | avm60 | AVM_60 |
Gen 2 |
Gen 1 constants live in anthem_rs232.gen1; Gen 2 in anthem_rs232.models.
The two generations use different wire formats and have separate receiver classes:
# Gen 2 (semicolon-terminated, 115200 baud)
from anthem_rs232 import AnthemReceiver
from anthem_rs232.models import MRX_1120
gen2 = AnthemReceiver("/dev/ttyUSB0", model=MRX_1120)
# Gen 1 (LF-terminated, 9600 / 19200 baud per model)
from anthem_rs232 import Gen1Receiver
from anthem_rs232.gen1 import STATEMENT_D2
gen1 = Gen1Receiver("/dev/ttyUSB0", model=STATEMENT_D2)
Not supported
- STR amplifiers — Gen 2 protocol family but not validated against this library.
- Gen 4 — MRX 540 / 740 / 1140 + AVM 70 / 90. Substantially different protocol (new
GC*,NM*,SS*, splitZ1ARC*sub-commands; tuner removed). - MRX SLM (2024+) — IP-only.
Installation
pip install anthem-rs232
Requires Python 3.12+.
Quick start
import asyncio
from anthem_rs232 import AnthemReceiver, AudioListeningMode
async def main():
receiver = AnthemReceiver("/dev/ttyUSB0")
await receiver.connect()
await receiver.query_state()
print(f"Model: {receiver.state.model}")
print(f"Power: {receiver.state.power}")
print(f"Volume: {receiver.state.main_zone.volume} dB")
print(f"Input: {receiver.state.main_zone.input_index}")
await receiver.main.set_volume(-30.0)
await receiver.main.select_input(2)
await receiver.main.set_audio_listening_mode(AudioListeningMode.DOLBY_SURROUND)
await receiver.disconnect()
asyncio.run(main())
CLI
# Detect which Anthem model + protocol generation is on the wire (auto-tries
# Gen 2 @ 115200, Gen 1 @ 9600, Gen 1 @ 19200)
python -m anthem_rs232.probe /dev/ttyUSB0
# Query and print receiver status (Gen 2)
python -m anthem_rs232 /dev/ttyUSB0
# Probe configured inputs (uses ICN? + ISN?/ILN?)
python -m anthem_rs232 /dev/ttyUSB0 --probe
Detecting the model
If you don't know which receiver is on a serial port (or which generation), use probe():
from anthem_rs232 import probe
result = await probe("/dev/ttyUSB0")
# result.generation -> 1 or 2
# result.model_name -> "MRX 1120"
# result.baud_rate -> 115200
# result.model -> the matching model constant (MRX_1120) or None
if result is None:
print("No Anthem responded")
else:
print(f"{result.model_name} (Gen {result.generation}, {result.baud_rate} baud)")
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://).
Features
Full state after query
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.
receiver = AnthemReceiver("/dev/ttyUSB0")
await receiver.connect()
await receiver.query_state()
state = receiver.state
state.model # "MRX 1120"
state.software_version # "0.2.3"
state.power # True if any zone is on
state.front_panel_brightness # FrontPanelBrightness.MEDIUM
state.inputs # {1: InputConfig(...), ...}
mz = state.main_zone
mz.power # True / False
mz.input_index # 1
mz.volume # -35.0
mz.mute # True / False
mz.arc_enabled # True / False
mz.balance # 0-100, 50 = center
mz.audio_listening_mode # AudioListeningMode enum
mz.audio_input_format # AudioInputFormat enum
mz.video_input_resolution # VideoInputResolution enum
Event subscription
Subscribe to state changes. Callbacks receive a ReceiverState snapshot on updates, or None when the connection drops.
def on_change(state):
if state is None:
print("Disconnected!")
return
print(f"Volume: {state.main_zone.volume} dB")
unsub = receiver.subscribe(on_change)
unsub() # stop receiving
Power
await receiver.power_on_all() # Z0POW1 -- power all zones on
await receiver.power_off_all() # Z0POW0 -- system standby
await receiver.main.power_on() # Z1POW1
await receiver.main.power_off() # Z1POW0
on = await receiver.main.query_power()
Volume
Volume is in dB and rounded to the nearest valid value by the receiver.
await receiver.main.set_volume(-30.0) # Z1VOL-30
await receiver.main.set_volume(5.0) # Z1VOL+05
await receiver.main.volume_up() # Z1VUP01 (1 dB step)
await receiver.main.volume_up(step=5) # Z1VUP05
await receiver.main.volume_down(step=2)
db = await receiver.main.query_volume()
Mute
await receiver.main.mute_on()
await receiver.main.mute_off()
await receiver.main.mute_toggle()
Inputs
Anthem inputs are configurable and identified by 1-based index. Names come from ISN? (short) and ILN? (long) and are populated during query_state().
await receiver.main.select_input(2)
idx = await receiver.main.query_input()
inputs = receiver.state.inputs
print(inputs[1].short_name) # "CBL"
print(inputs[1].long_name) # "Cable Box"
Audio listening mode
from anthem_rs232 import AudioListeningMode
await receiver.main.set_audio_listening_mode(AudioListeningMode.DOLBY_SURROUND)
await receiver.main.audio_listening_mode_next()
mode = await receiver.main.query_audio_listening_mode()
Anthem Room Correction
await receiver.main.arc_on()
await receiver.main.arc_off()
on = await receiver.main.query_arc()
Per-input processing
Lip sync delay, Dolby Volume, and the Dolby Volume Leveler are stored per
input on the receiver. input_index=0 (the default) targets the currently
selected input.
await receiver.set_lip_sync(50) # SLIP00050 (0-150 ms, 5 ms steps)
await receiver.set_dolby_volume(True, input_index=2) # SDVS021
await receiver.set_dolby_volume_leveler(5) # SDVL005 (0 = off, 1-9)
ms = await receiver.query_lip_sync()
on = await receiver.query_dolby_volume()
level = await receiver.query_dolby_volume_leveler()
# Populated per input from query replies and auto-reports:
receiver.state.inputs[2].lip_sync_ms
receiver.state.inputs[2].dolby_volume
receiver.state.inputs[2].dolby_volume_leveler
Balance, channel levels, tone
These are runtime adjustments meant to compensate for source material — for system setup, use the receiver's setup menu and ARC.
from anthem_rs232 import Channel
await receiver.main.set_balance(50) # 0=full left, 100=full right
await receiver.main.set_channel_level(Channel.SUBS, -3.0)
await receiver.main.set_channel_level(Channel.HEIGHTS_1, 2.0)
await receiver.main.channel_level_up(Channel.CENTER)
await receiver.main.set_bass(-2.0)
await receiver.main.set_treble(3.0)
Tuner (FM)
await receiver.main.set_fm_frequency(105.50) # T1FMS105.50
await receiver.main.tune_up()
await receiver.main.seek_up()
await receiver.main.preset_up()
await receiver.main.select_preset(7)
await receiver.main.assign_preset(7)
await receiver.main.remove_preset(7)
freq = await receiver.main.query_fm_frequency()
Zone 2
await receiver.zone_2.power_on()
await receiver.zone_2.select_input(2)
await receiver.zone_2.set_volume(-40.0)
await receiver.zone_2.mute_on()
Setup / display
from anthem_rs232 import FrontPanelBrightness
await receiver.set_front_panel_brightness(FrontPanelBrightness.HIGH)
await receiver.set_standby_ip_control(True) # required for IP power-on
await receiver.set_speaker_profile(2)
await receiver.main.display_message(0, "Hello")
await receiver.main.open_setup_menu()
Triggers
await receiver.set_trigger_control(1, rs232_controlled=True)
await receiver.set_trigger(1, on=True)
Connection handling
- If
IDM?is not answered duringconnect(),ConnectionErroris raised. - If the serial connection drops, subscribers receive
NoneandconnectedbecomesFalse. - 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. - Receiver errors come back as
!E/R/I/Z<original>;and raiseCommandErrorfrom the matching pending query. Fire-and-forget commands log the error.
try:
await receiver.connect()
except ConnectionError:
print("Receiver not responding")
Gen 1 (Statement / older AVM / older MRX)
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).
import asyncio
from anthem_rs232 import Gen1Receiver
from anthem_rs232.gen1 import (
STATEMENT_D2,
Source,
DecoderMode,
EffectMode,
SleepTimer,
DolbyDynamicRange,
TunerMode,
)
async def main():
rx = Gen1Receiver("/dev/ttyUSB0", model=STATEMENT_D2)
await rx.connect() # opens serial, sends ?, enables SST1 auto-reports
await rx.query_state() # P1?, P2?, P3?, H?, TT?
state = rx.state
print(state.model, state.version) # "Statement D2" "3.10"
print(state.main_zone.volume) # e.g. -30.5
# Main zone (P1)
await rx.main.power_on() # P1P1
await rx.main.set_volume(-30.5) # P1VM-30.5 (0.5 dB step)
await rx.main.mute_toggle() # P1MT
await rx.main.select_source(Source.DVD_1.value) # P1S5
await rx.main.set_decoder_mode("5", DecoderMode.PRO_LOGIC)
await rx.main.set_effect_mode("5", EffectMode.HALL)
await rx.main.set_dolby_dynamic_range(DolbyDynamicRange.LATE_NIGHT)
await rx.main.set_sleep_timer(SleepTimer.SIXTY_MIN)
await rx.main.display_message(1, "Hello")
# Channel trims and balance (Main only)
await rx.main.set_center_trim(1.5)
await rx.main.set_sub_trim(-2.0)
await rx.main.set_front_balance(2.0)
await rx.main.set_master_bass(-3.0)
# Zone 2 (P2) -- separate volume scale (1.25 dB step)
await rx.zone_2.power_on()
await rx.zone_2.set_volume(-50.0) # P2V-50.0 (no "M" after V)
await rx.zone_2.set_balance(2.5)
await rx.zone_2.set_treble(4.0)
# Rec / Zone 3 (P3) -- source-only
await rx.rec.select_source(Source.TUNER.value)
# Headphone (H) -- dedicated output
await rx.headphone.set_volume(-20.0)
await rx.headphone.mute_toggle()
# Tuner (T)
await rx.tuner.set_fm_frequency(101.5) # TFT101.5
await rx.tuner.set_am_frequency(540) # TAT0540
await rx.tuner.fm_preset(1, 2) # TFP12
band, freq = await rx.tuner.query_frequency()
# 12 V triggers
await rx.set_trigger(1, on=True) # t1T1
# System
await rx.power_on_all() # P1P1;P2P1;P3P1
await rx.lock_front_panel() # FPL1
await rx.rename_source("5", "Apple") # SN5Apple
await rx.save_user_settings() # SfSU
await rx.disconnect()
asyncio.run(main())
Gen 1 notes
- Wire format: LF-terminated ASCII, default 9600 baud (D2/D2v: 19200). Multiple commands can be chained on one line with
;. - 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. - Errors are verbose ASCII:
Invalid Command,Parameter Out-of-range,Main Off,Zone2 Off,Unit Off,Already in use. They raiseGen1CommandErrorfrom the matching pending query. - 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. - Source codes: single ASCII characters (
0–9, plusd–jon D2/D2v for second-bank inputs). TheSourceenum lists them. - Identify:
?returns(model,version,build)— populated intostate.model/state.version/state.build_dateduringconnect().
Serial connection
Gen 2 (MRX 310+, AVM 60): 115200 baud, 8N1, no flow control, ;-terminated.
Gen 1 (Statement, older AVM/MRX): 9600 or 19200 baud (per model), 8N1, no flow control, LF-terminated.
Both use a straight-wired DB-9 cable (Pin 2 = Tx, 3 = Rx, 5 = GND).
Protocol references
- Gen 2: Anthem MRX 1120 / 720 / 520 / AVM 60 IP / RS-232 serial commands (Google Sheets)
- Gen 1: AVM-2 serial spec (21 November 2000) — bundled in the rsnodgrass/python-anthemav-serial repo
Development
uv sync
uv run pytest
License
MIT
Release files for anthem-rs232 0.3.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 | |
|---|---|---|---|
| anthem_rs232-0.3.0.tar.gz | 41.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| anthem_rs232-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 91.6 kB
Release files / anthem_rs232-0.3.0.tar.gz
| Download URL | anthem_rs232-0.3.0.tar.gz |
|---|---|
| Size | 41.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
4fdc5bba20e8da43b8500890525784d9de1ccf3ad9c7663c20eb7a660a0536f5
|
|
BLAKE2b-256 checksum How to use checksums |
0446d18e791dae5c63b5de0ee907cf6a5b17873006e8502f5e4953240444df8b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.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 Jul 28, 2026.
Transparency logRelease files / anthem_rs232-0.3.0-py3-none-any.whl
| Download URL | anthem_rs232-0.3.0-py3-none-any.whl |
|---|---|
| Size | 50.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b6a093adb2bd9c3ceb0f4ca931958beebd158f0434772dec41d1b83c7b26d193
|
|
BLAKE2b-256 checksum How to use checksums |
9e1690d918850abdde1093a12e0b8973fc7ab6cda6b229733689c9bb2750039e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.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 Jul 28, 2026.
Transparency log