Metadata-Version: 2.4
Name: jbl-ma-control
Version: 1.2.0
Summary: Python client library for the JBL MA-series (MA510/MA710/MA7100HP/MA9100HP) IP control protocol
Author: Hamish Fagg
License: MIT
Project-URL: Homepage, https://github.com/hamishfagg/jbl-ma-control
Project-URL: Documentation, https://github.com/hamishfagg/jbl-ma-control#readme
Project-URL: Bug Tracker, https://github.com/hamishfagg/jbl-ma-control/issues
Keywords: jbl,avr,ip-control,home-automation,harman,receiver
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Classifier: Topic :: Multimedia :: Sound/Audio
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
Dynamic: license-file

# jbl-ma-control

A pure-Python client library for the **JBL MA-series** AV receivers
(MA510, MA710, MA7100HP, MA9100HP) over their IP control protocol.

It implements **every command and option** in Harman's
*IP control interface and commands for JBL MA510/MA710/MA7100HP/MA9100HP*
(document v1.7): power, input selection, volume, mute, surround decoding,
equalization, room EQ, Dolby/DTS options, streaming status, IR passthrough,
reboot, factory reset and more.

- No third-party runtime dependencies.
- Fully type-hinted (ships `py.typed`).
- Thorough error handling with a typed exception hierarchy.
- Framing layer is transport-independent and unit-tested; the client is
  tested end-to-end against an in-process mock AVR.

## Installation

```bash
pip install jbl-ma-control
```

## Quick start

```python
from jbl_ma import JBLClient, InputSource, SurroundMode

# The AVR always listens on TCP port 50000.
with JBLClient("192.168.1.50") as avr:
    model = avr.initialize()          # identify ourselves; returns the Model
    print("Connected to", model.name)

    avr.power_on()
    avr.set_input_source(InputSource.HDMI1)
    avr.set_volume(35)                # 0..99
    avr.set_surround_mode(SurroundMode.ALL_STEREO)
    avr.set_bass(4)                   # dB, -12..12
    avr.unmute()
```

### Async

For asyncio applications (including Home Assistant) use `AsyncJBLClient`, which
exposes the same method names as coroutines:

```python
import asyncio
from jbl_ma import AsyncJBLClient, InputSource

async def main():
    async with AsyncJBLClient("192.168.1.50") as avr:
        await avr.initialize()
        await avr.power_on()
        await avr.set_input_source(InputSource.HDMI1)
        await avr.set_volume(35)

asyncio.run(main())
```

Concurrent coroutines sharing one `AsyncJBLClient` are safe — transactions are
serialized internally so request/response pairs never interleave.

#### Push updates (no polling)

The AVR sends unsolicited frames whenever its state changes from the front
panel or IR remote. `AsyncJBLClient` runs a background reader while connected,
so an `on_event` callback receives those changes as they happen — you don't
have to poll:

```python
def on_change(frame):
    print("state changed:", hex(frame.command), frame.data)

async with AsyncJBLClient("192.168.1.50", on_event=on_change) as avr:
    await avr.initialize()
    await asyncio.sleep(3600)   # on_change fires whenever the AVR changes

```

`on_event` may be a plain function or a coroutine function. Note the protocol
has no request/response correlation id, so a state-change frame that arrives in
the exact window while a command's response is awaited may be consumed as that
response; this is a protocol limitation, not a library one.

### Manual connection management

The client is a context manager; you can also manage the connection manually:

```python
avr = JBLClient("192.168.1.50", connect=False)
avr.connect()
try:
    print(avr.get_volume())
finally:
    avr.close()
```

## Protocol notes

Every transmission uses the frame format from the PDF:

```
Controller -> AVR : <0x23><CmdID><DataLen><Data...><0x0D>
AVR -> Controller : <0x02 0x23><CmdID><RspCode><DataLen><Data...><0x0D>
```

- After connecting you should call `initialize()` so the AVR registers this
  controller. It returns the connected `Model`.
- Call `heartbeat()` periodically to keep the AVR out of its auto-standby
  timer; `initialize()` works as a heartbeat too.
- `reboot()` is acknowledged and then the unit restarts, dropping the
  connection. On the MA510 (verified on hardware) it comes back up in
  **standby** rather than powered on — call `power_on()` afterwards if you need
  it on, and allow time for it to reboot before reconnecting.
- The AVR also emits **unsolicited** frames when its state changes from the
  front panel or IR remote. Pass an `on_event` callback to receive them:

  ```python
  def on_change(frame):
      print("state change:", frame.command, frame.data)

  avr = JBLClient("192.168.1.50", on_event=on_change)
  ```

## API overview

| Area | Methods |
| --- | --- |
| System | `initialize()`, `heartbeat()`, `reboot()`, `factory_reset()`, `get_software_version()` |
| Power | `get_power()`, `set_power()`, `power_on()`, `standby()` |
| Display | `get_display_dim()`, `set_display_dim()` |
| Source | `get_input_source()`, `set_input_source()` |
| Volume | `get_volume()`, `set_volume()`, `get_mute()`, `set_mute()`, `mute()`, `unmute()` |
| Sound | `get_surround_mode()`, `set_surround_mode()`, `get_dolby_audio_mode()`, `set_dolby_audio_mode()`, `get_dialog_enhanced()`, `set_dialog_enhanced()`, `get_drc()`, `set_drc()` |
| EQ | `get_treble()`, `set_treble()`, `get_bass()`, `set_bass()`, `get_room_eq()`, `set_room_eq()` |
| Party mode | `get_party_mode()`, `set_party_mode()`, `get_party_volume()`, `set_party_volume()` |
| Streaming | `get_streaming_state()` |
| IR remote | `send_ir()` |

All enums (`InputSource`, `SurroundMode`, `DisplayDim`, `RoomEQ`,
`DolbyAudioMode`, `PowerState`, `StreamingServer`, `PlayState`,
`SoftwareModule`, `Model`, `IRCommand`) and value ranges are exported from the
top-level `jbl_ma` package.

> **Model availability:** some options only exist on certain models (for
> example HDMI 5/6, Phono, party mode, DTS Neural:X and Dirac Live). The
> library exposes every documented value; sending one an attached model does
> not support results in the AVR replying with `ParameterNotRecognizedError`
> or `CommandInvalidError`.

## Error handling

All errors derive from `jbl_ma.JBLError`:

```python
from jbl_ma import JBLClient, JBLCommandError, CommandInvalidError, RoomEQ

with JBLClient("192.168.1.50") as avr:
    try:
        avr.set_room_eq(RoomEQ.DIRAC_LIVE)
    except CommandInvalidError:
        print("No room-correction filter has been uploaded yet.")
    except JBLCommandError as exc:
        print("AVR rejected the command, code", hex(exc.code))
```

| Exception | Meaning |
| --- | --- |
| `JBLConnectionError` | Socket refused / reset / closed |
| `JBLTimeoutError` | No complete response within the timeout |
| `JBLProtocolError` | A malformed frame was sent or received |
| `CommandNotRecognizedError` | Response code `0xC1` |
| `ParameterNotRecognizedError` | Response code `0xC2` |
| `CommandInvalidError` | Response code `0xC3` (command invalid at this time) |
| `InvalidDataLengthError` | Response code `0xC4` |

## Low-level framing

The `jbl_ma.protocol` module can encode and decode frames without any I/O,
which is useful for testing or building your own transport:

```python
from jbl_ma import encode_command, decode_responses, Command

frame = encode_command(Command.MASTER_VOLUME, b"\x28")   # set volume to 40
responses, leftover = decode_responses(b"\x02\x23\x06\x00\x01\x28\x0d")
```

## Development

```bash
pip install -e ".[dev]"
pytest
```

## License

MIT
