# denon-rs232

Async Python library to control Denon AV receivers over RS232 serial, built on [serialx](https://github.com/puddly/serialx).

## Installation

```bash
pip install denon-rs232
```

Requires Python 3.12+.

## Quick start

```python
import asyncio
from denon_rs232 import DenonReceiver, InputSource

async def main():
    receiver = DenonReceiver("/dev/ttyUSB0")
    await receiver.connect()
    await receiver.query_state()

    # State is fully populated after query_state()
    print(f"Power: {receiver.state.power}")
    print(f"Volume: {receiver.state.volume} dB")
    print(f"Input: {receiver.state.input_source}")

    # Control the receiver
    await receiver.main.set_volume(-30.0)
    await receiver.main.select_input_source(InputSource.DVD)

    await receiver.disconnect()

asyncio.run(main())
```

## CLI

A built-in CLI lets you quickly test your serial connection:

```bash
# Query and print receiver status
python -m denon_rs232 /dev/ttyUSB0

# Also probe which input sources the receiver accepts
python -m denon_rs232 /dev/ttyUSB0 --probe

# Use legacy zone 3 prefix for AVR-3803/3805
python -m denon_rs232 /dev/ttyUSB0 --zone3-prefix Z1
```

## Features

### Full state after query

`connect()` only opens and verifies the serial connection. Call `query_state()` when you want the current receiver state populated into the `state` property. After that, state is kept up to date via events from the receiver.

How much a receiver in standby answers differs per model, so `query_state()` stops querying at the first unanswered query instead of waiting out the rest. The skipped queries are repeated automatically once the receiver reports that it left standby.

Control lives on shared player objects:

```python
receiver.main
receiver.zone_2
receiver.zone_3
```

```python
receiver = DenonReceiver("/dev/ttyUSB0")
await receiver.connect()
await receiver.query_state()

state = receiver.state
state.power          # PowerState.ON / PowerState.STANDBY
state.main_zone_power      # True / False
state.volume         # float in dB (0.0 = reference, -80.0 = min, +18.0 = max)
state.mute           # True / False
state.input_source   # InputSource enum
state.surround_mode  # str (e.g. "STEREO", "DOLBY DIGITAL", "DTS SURROUND")
state.digital_input  # DigitalInputMode enum
state.video_select   # InputSource or None
state.rec_select     # InputSource or None
```

### Event subscription

Subscribe to state changes to react in real-time. Callbacks receive a `ReceiverState` snapshot on updates, or `None` when the connection is lost.

```python
def on_state_change(state):
    if state is None:
        print("Disconnected!")
        return
    print(f"Volume: {state.volume} dB, Source: {state.input_source}")

unsub = receiver.subscribe(on_state_change)
# Later:
unsub()  # stop receiving events
```

### Receiver power

```python
await receiver.power_on()
await receiver.power_standby()
power = await receiver.query_power()  # bool
```

### Main zone

```python
await receiver.main.power_on()
await receiver.main.power_standby()
on = await receiver.main.query_power()  # bool
```

### Master volume

Volume is represented in dB: 0.0 dB is the reference level, -80.0 is minimum, +18.0 is maximum. Half-dB steps are supported.

```python
await receiver.main.set_volume(-25.0)     # set to -25 dB
await receiver.main.set_volume(-25.5)     # half-dB step
await receiver.main.volume_up()
await receiver.main.volume_down()
db = await receiver.main.query_volume()   # float
```

### Channel volumes

Individual channel levels, relative to the master volume. 0.0 dB is neutral, range is -12.0 to +12.0 dB. Available channels depend on the speaker configuration: FL, FR, C, SW, SL, SR, SBL, SBR, SB.

```python
await receiver.main.set_channel_volume("FL", 2.0)   # front left +2 dB
await receiver.main.set_channel_volume("SW", -3.5)  # subwoofer -3.5 dB
await receiver.main.channel_volume_up("C")
await receiver.main.channel_volume_down("FR")

# All channel volumes are in state after connect:
state.channel_volumes  # {"FL": 0.0, "FR": 0.0, "C": -1.0, ...}
```

### Mute

```python
await receiver.main.mute_on()
await receiver.main.mute_off()
muted = await receiver.main.query_mute()  # bool
```

### Input source

```python
from denon_rs232 import InputSource

await receiver.main.select_input_source(InputSource.BD)
source = await receiver.main.query_input_source()  # InputSource enum
```

Available sources depend on the model. See [Input sources](#input-sources) below.

### Surround mode

Surround mode is kept as a plain string because receivers return many combined mode names (e.g. `"DOLBY D+PL2X C"`, `"DTS HD MSTR"`).

```python
await receiver.main.set_surround_mode("STEREO")
await receiver.main.set_surround_mode("DOLBY DIGITAL")
await receiver.main.set_surround_mode("DTS SURROUND")
await receiver.main.set_surround_mode("DIRECT")
await receiver.main.set_surround_mode("PURE DIRECT")
await receiver.main.set_surround_mode("MCH STEREO")
mode = await receiver.main.query_surround_mode()  # str
```

### Digital input mode

```python
from denon_rs232 import DigitalInputMode

await receiver.main.set_digital_input(DigitalInputMode.AUTO)
await receiver.main.set_digital_input(DigitalInputMode.HDMI)
await receiver.main.set_digital_input(DigitalInputMode.DIGITAL)
await receiver.main.set_digital_input(DigitalInputMode.ANALOG)
mode = await receiver.main.query_digital_input()  # DigitalInputMode enum or None ("NO")
```

Legacy models also support `PCM`, `DTS`, `RF`, `EXT_IN_1`, `EXT_IN_2`.

### Video / recording source select

Override the video or recording source independently from the main input source:

```python
await receiver.main.set_video_select(InputSource.DVD)
await receiver.main.cancel_video_select()  # return to following input
source = await receiver.main.query_video_select()

await receiver.main.set_rec_select(InputSource.CD)
await receiver.main.cancel_rec_select()
source = await receiver.main.query_rec_select()
```

### Parameter settings

```python
from denon_rs232 import SurroundBack, ModeSetting, RoomEQ

# Tone defeat
await receiver.main.tone_defeat_on()
await receiver.main.tone_defeat_off()

# Surround back speakers
await receiver.main.set_surround_back(SurroundBack.PL2X_CINEMA)
await receiver.main.set_surround_back(SurroundBack.OFF)

# Cinema EQ
await receiver.main.cinema_eq_on()
await receiver.main.cinema_eq_off()

# Decoder mode
await receiver.main.set_mode_setting(ModeSetting.CINEMA)
await receiver.main.set_mode_setting(ModeSetting.MUSIC)

# Room EQ (pre-Audyssey models)
await receiver.main.set_room_eq(RoomEQ.FLAT)
```

All parameter settings are available in `state` after connect:

```python
state.tone_defeat     # bool
state.surround_back   # SurroundBack enum
state.cinema_eq       # bool
state.mode_setting    # ModeSetting enum
state.room_eq         # RoomEQ enum (event-only, not in PS? response)
```

### Tuner

```python
from denon_rs232 import TunerBand, TunerMode

await receiver.main.set_tuner_band(TunerBand.FM)
await receiver.main.set_tuner_mode(TunerMode.AUTO)
await receiver.main.set_tuner_frequency("105000")  # FM 105.0 MHz
await receiver.main.set_tuner_preset("A1")
await receiver.main.tuner_frequency_up()
await receiver.main.tuner_frequency_down()
await receiver.main.tuner_preset_up()
await receiver.main.tuner_preset_down()

freq = await receiver.main.query_tuner_frequency()  # str
preset = await receiver.main.query_tuner_preset()   # str
```

Tuner band and mode are available via events (`state.tuner_band`, `state.tuner_mode`).

### Multi-zone

Zone 2 and Zone 3 can be controlled independently. Zone state (power, source, volume) is populated by `query_state()` and updated via events.

```python
# Zone 2
await receiver.zone_2.power_on()
await receiver.zone_2.power_standby()
await receiver.zone_2.select_input_source(InputSource.TUNER)
await receiver.zone_2.set_volume(-30.0)
await receiver.zone_2.volume_up()
await receiver.zone_2.volume_down()

# Zone 3
await receiver.zone_3.power_on()
await receiver.zone_3.power_standby()
await receiver.zone_3.select_input_source(InputSource.CD)
await receiver.zone_3.set_volume(-35.0)
await receiver.zone_3.volume_up()
await receiver.zone_3.volume_down()
```

Zone state in `state`:

```python
state.zone_2.power   # bool
state.zone_2.input_source  # InputSource
state.zone_2.volume  # float in dB
state.zone_3.power   # bool
state.zone_3.input_source  # InputSource
state.zone_3.volume  # float in dB
```

**Zone 3 prefix**: Legacy models (AVR-3803, AVR-3805) use the `Z1` command prefix for Zone 3. Modern models use `Z3`. The default is `Z3`; pass `zone3_prefix="Z1"` for legacy models:

```python
receiver = DenonReceiver("/dev/ttyUSB0", zone3_prefix="Z1")
```

**Zone half dB volume steps**: some models (AVR-3805) only support integer values in the Zone 2/3 volume settings. Half dB zone volume steps are enabled by default, use `zone_half_db_volume_step=False` for affected models:

```python
receiver = DenonReceiver("/dev/ttyUSB0", zone_half_db_volume_step=False)
```

### Source probing

Discover which input sources the receiver actually supports by trying each one:

```python
sources = await receiver.probe_sources()
# frozenset({InputSource.CD, InputSource.DVD, InputSource.TUNER, ...})
```

This briefly switches through all input sources and restores the original when done. Nothing should be playing during probing.

### Receiver models

Pre-defined model capabilities are available in `denon_rs232.models`:

```python
from denon_rs232.models import AVR_3805, AVR_X4000, ALL_MODELS

# Check if a source is supported by a specific model
InputSource.BD in AVR_X4000.input_sources       # True
InputSource.BD in AVR_3805.input_sources         # False

# Get the zone 3 prefix for a model
AVR_3805.zone3_prefix   # "Z1"
AVR_X4000.zone3_prefix  # "Z3"

# Iterate all models
for model in ALL_MODELS:
    print(f"{model.name}: {len(model.input_sources)} sources")
```

Available models:

| Constant | Models | Era | Zone 3 | Digital |
|----------|--------|-----|--------|---------|
| `AVR_3803` | AVR-3803 / AVC-3570 / AVR-2803 | ~2003 | Z1 | Gen 1 (PCM/DTS/RF) |
| `AVR_3805` | AVR-3805 / AVC-3890 | ~2004 | Z1 | Gen 1 (PCM/DTS) |
| `AVR_987` | AVR-987 | ~2005 | Z3 | Gen 1 |
| `AVR_2308CI` | AVR-2308CI / AVC-2308 | ~2007 | -- | Gen 1 |
| `AVR_2808CI` | AVR-2808CI / AVC-2808 / AVR-988 | ~2007 | Z3 | Gen 1 |
| `AVR_4308CI` | AVR-4308CI | ~2008 | Z3 | Gen 1 |
| `AVR_3310CI` | AVR-3310CI / AVR-990 / AVC-3310 | ~2009 | Z3 | Gen 2 (HDMI/DIGITAL) |
| `AVR_X1000` | AVR-X1000 / AVR-E300 | ~2013 | -- | Gen 3 (HDMI/DIGITAL) |
| `AVR_X4000` | AVR-X4000 | ~2013 | Z3 | Gen 3 |
| `AVR_X4200W` | AVR-X4200W / X3200W / X2200W / X1200W | ~2015 | Z3 | Gen 3 |

### Connection handling

The library handles connection errors gracefully:

- If the receiver doesn't respond during `connect()`, a `ConnectionError` is raised.
- If the serial connection is lost (cable unplugged, device error), subscribers receive `None` and `connected` becomes `False`.
- Write errors during commands propagate the exception and tear down the connection.

```python
try:
    await receiver.connect()
except ConnectionError:
    print("Receiver not responding")
```

## Input sources

Available input sources vary by model era:

| Source | Protocol value | Era |
|--------|---------------|-----|
| `PHONO` | PHONO | Legacy |
| `CD` | CD | Legacy |
| `TUNER` | TUNER | Legacy |
| `DVD` | DVD | Legacy |
| `VDP` | VDP | Legacy |
| `TV` | TV | Legacy |
| `DBS_SAT` | DBS/SAT | Legacy |
| `VCR_1` | VCR-1 | Legacy |
| `VCR_2` | VCR-2 | Legacy |
| `VCR_3` | VCR-3 | Legacy |
| `V_AUX` | V.AUX | Legacy |
| `CDR_TAPE1` | CDR/TAPE1 | Legacy |
| `MD_TAPE2` | MD/TAPE2 | Legacy |
| `HDP` | HDP | Transition |
| `DVR` | DVR | Transition |
| `TV_CBL` | TV/CBL | Transition |
| `SAT` | SAT | Transition |
| `NET_USB` | NET/USB | Transition |
| `DOCK` | DOCK | Transition |
| `IPOD` | IPOD | Transition |
| `BD` | BD | Modern |
| `SAT_CBL` | SAT/CBL | Modern |
| `MPLAY` | MPLAY | Modern |
| `GAME` | GAME | Modern |
| `AUX1` | AUX1 | Modern |
| `AUX2` | AUX2 | Modern |
| `NET` | NET | Modern |
| `BT` | BT | Modern |
| `USB_IPOD` | USB/IPOD | Modern |
| `PANDORA` | PANDORA | Streaming |
| `SIRIUSXM` | SIRIUSXM | Streaming |
| `SPOTIFY` | SPOTIFY | Streaming |
| `FLICKR` | FLICKR | Streaming |
| `IRADIO` | IRADIO | Streaming |
| `SERVER` | SERVER | Streaming |
| `FAVORITES` | FAVORITES | Streaming |
| `LASTFM` | LASTFM | Streaming |
| `XM` | XM | Radio |
| `SIRIUS` | SIRIUS | Radio |
| `HDRADIO` | HDRADIO | Radio |
| `DAB` | DAB | Radio |

Not all sources exist on every receiver. Use `probe_sources()` or a `ReceiverModel` definition to determine which sources your receiver supports.

## Serial connection

The library uses [serialx](https://github.com/puddly/serialx) for async serial communication. All Denon RS232 receivers use 9600 baud, 8 data bits, no parity, 1 stop bit.

Most receivers have a DB-9 connector. The AVR-3803 / AVC-3570 uses a 3.5mm stereo mini plug (Tip=RXD, Ring=TXD, Sleeve=GND).

## Development

```bash
# Install dev dependencies
uv sync

# Run tests
uv run pytest

# Run tests with verbose output
uv run pytest -v
```

## License

MIT
