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.3.0.tar.gz (41.4 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.3.0-py3-none-any.whl (50.2 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: anthem_rs232-0.3.0.tar.gz
  • Upload date:
  • Size: 41.4 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.3.0.tar.gz
Algorithm Hash digest
SHA256 4fdc5bba20e8da43b8500890525784d9de1ccf3ad9c7663c20eb7a660a0536f5
MD5 a0d93d14c22ccc9ac74be64b3de7204b
BLAKE2b-256 0446d18e791dae5c63b5de0ee907cf6a5b17873006e8502f5e4953240444df8b

See more details on using hashes here.

Provenance

The following attestation bundles were made for anthem_rs232-0.3.0.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.3.0-py3-none-any.whl.

File metadata

  • Download URL: anthem_rs232-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 50.2 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.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b6a093adb2bd9c3ceb0f4ca931958beebd158f0434772dec41d1b83c7b26d193
MD5 f8dab8e9043a247de75e0fd9e464cbb7
BLAKE2b-256 9e1690d918850abdde1093a12e0b8973fc7ab6cda6b229733689c9bb2750039e

See more details on using hashes here.

Provenance

The following attestation bundles were made for anthem_rs232-0.3.0-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