Skip to main content

Async library to control Anthem MRX 1120 / 720 / 520 / AVM 60 receivers over RS232

Project description

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*, split Z1ARC* 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 during connect(), ConnectionError is raised.
  • If the serial connection drops, subscribers receive None and connected becomes False.
  • 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 raise CommandError from 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 raise Gen1CommandError from 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 (09, plus dj on D2/D2v for second-bank inputs). The Source enum lists them.
  • Identify: ? returns (model,version,build) — populated into state.model / state.version / state.build_date during connect().

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

Development

uv sync
uv run pytest

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

anthem_rs232-0.2.1.tar.gz (39.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

anthem_rs232-0.2.1-py3-none-any.whl (48.6 kB view details)

Uploaded Python 3

File details

Details for the file anthem_rs232-0.2.1.tar.gz.

File metadata

  • Download URL: anthem_rs232-0.2.1.tar.gz
  • Upload date:
  • Size: 39.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for anthem_rs232-0.2.1.tar.gz
Algorithm Hash digest
SHA256 96d1a4810231a5e68110747deea91fdccba9765a89df3046c22d73d5550436c2
MD5 61de669139d69750a3a1dbfa7dfb8783
BLAKE2b-256 9f5d0166fbf8fbd7cba3e160c063161a188eb103d010e92f98760070a02ca84c

See more details on using hashes here.

Provenance

The following attestation bundles were made for anthem_rs232-0.2.1.tar.gz:

Publisher: publish.yml on bbangert/anthem-rs232

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file anthem_rs232-0.2.1-py3-none-any.whl.

File metadata

  • Download URL: anthem_rs232-0.2.1-py3-none-any.whl
  • Upload date:
  • Size: 48.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for anthem_rs232-0.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 8e609f76641a801378fd5e544e6732574fe14991ceec4b17b488e12a1f855fde
MD5 1803f4d0b8c7fdb5e7a28c92700faf34
BLAKE2b-256 9121a6c182e3a68279eb313da2cb338a7cb3aa6886e0bc1c30df450e79a5a82b

See more details on using hashes here.

Provenance

The following attestation bundles were made for anthem_rs232-0.2.1-py3-none-any.whl:

Publisher: publish.yml on bbangert/anthem-rs232

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page