Metadata-Version: 2.5
Name: aiolibresync
Version: 0.3.0
Summary: Async client for Libre Wireless LibreSync audio hubs, such as the Platin Stereo Hub
Project-URL: Homepage, https://github.com/drsound/aiolibresync
Project-URL: Documentation, https://github.com/drsound/aiolibresync/tree/main/docs
Project-URL: Issues, https://github.com/drsound/aiolibresync/issues
Project-URL: Changelog, https://github.com/drsound/aiolibresync/blob/main/CHANGELOG.md
Author: Alessandro Zarrilli
License-Expression: MIT
License-File: LICENSE
Keywords: asyncio,home assistant,libre wireless,libresync,platin,wisa
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Home Automation
Classifier: Typing :: Typed
Requires-Python: >=3.12
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff<0.17,>=0.16.3; extra == 'dev'
Description-Content-Type: text/markdown

# aiolibresync

An asyncio client for audio hubs built on the Libre Wireless **LibreSync** platform, such as the Platin Stereo Hub. It controls them over the local network, with no vendor app and no cloud.

The same hardware and protocol are sold under at least a dozen brands, including Platin, Buchardt, System Audio, Econik and Triangle. See [docs/devices.md](docs/devices.md) for the list and how confident each entry is.

- Pure Python, no runtime dependencies, fully typed.
- Push-driven: the hub announces volume, source, transport and metadata as they change, and a slow poll covers the two properties nothing announces.
- Discovery over SSDP, plus a probe for an address you already know.
- Written against the Home Assistant integration quality scale, but it does not depend on Home Assistant.

## Status

Alpha. Everything the library sends has been sent to a real hub and its effect observed, on a Platin Stereo Hub running firmware 1.52. Other brands and firmware versions have not been tested yet, and reports from owners are very welcome. `diagnostics()` produces a redacted dump designed for exactly that.

## Install

```bash
pip install aiolibresync
```

Python 3.12 or later.

## Finding a hub

```python
from aiolibresync import async_discover, async_probe

for device in await async_discover():
    print(device.host, device.udn, device.name)

device = await async_probe("192.168.1.50")  # an address you already have
```

`async_discover()` sends an SSDP search, fetches the device description from whatever answers, and confirms that the control port is open. The probe sends nothing to the control port: it only opens a TCP connection. `udn` is the stable identifier and survives reboots and address changes. It can be `None` on a hub that is fully controllable, because it is served by a UPnP daemon that occasionally stops on its own. A mains power cycle brings it back. See [docs/devices.md](docs/devices.md).

## Controlling it

```python
import asyncio
from aiolibresync import DeviceState, LibreSyncClient

async def main() -> None:
    client = LibreSyncClient("192.168.1.50")
    ready = asyncio.Event()

    def on_state(state: DeviceState) -> None:
        print(state)
        if state.available:
            ready.set()

    client.subscribe(on_state)
    await client.async_connect()
    await ready.wait()
    await client.async_set_volume(30)
    await asyncio.sleep(60)
    await client.async_disconnect()

asyncio.run(main())
```

`async_connect()` returns as soon as the two sockets have been *scheduled*, not once they are open, so a command issued on the next line raises `NotConnectedError`. `state.available` is the readiness signal: it becomes true when both ports are connected and false again on any disconnection. The client reconnects on its own, so treat `available` as a condition that can change at any time.

| Method | What it does |
| --- | --- |
| `async_set_power(on)` | reads the power state first and toggles only if needed, because the device has no discrete on or off |
| `async_select_source(index)` | selects a source by the device's own index, from `state.sources` |
| `async_set_volume(level)` | 0–100 |
| `async_media_play()`, `_pause()`, `_stop()`, `_next_track()`, `_previous_track()` | transport, for the streaming renderer |
| `async_set_room_correction(enabled)`, `async_set_manual_eq(enabled)` | the two DSP switches |
| `async_select_eq_preset(preset)` | 1–3, the presets built in the vendor's app |
| `async_refresh()` | re-reads everything |
| `subscribe(callback)` | called with a new `DeviceState` on every change; returns an unsubscribe function |
| `diagnostics()` | a redacted snapshot with frame counters and any unrecognised frames |

Every command waits for the device to confirm the new state and raises `ConfirmationTimeout` if it does not.

## Things that will surprise you

**Power is not power.** Switching the hub off is a *stop*: it ends the playback session. Switching it back on restores nothing and only allows playback again. When playback starts on a hub that is off, power-on arrives *last*, as a consequence of the session starting. The hub answers on both ports while "off". Keep `available` and `power` apart.

**There is no mute you can set.** The device accepts a mute write, reports the new value back, and leaves the audio alone. `state.muted` reflects mute set from the remote or the vendor's app, but the library offers no way to set it. See [docs/protocol-media.md](docs/protocol-media.md).

**Read `state.playback`, not `state.play_state`.** The hub reports playback twice. `play_state` is the streaming renderer's transport, and it says `PLAYING` on any physical input whether or not anything is connected. `audio_state` says whether sound is actually coming out. `playback` gives you the one to believe.

**Room correction is never announced.** It is polled, so a change made in the vendor's app shows up within one poll interval (`POLL_INTERVAL`, 30 s).

**Preset 0 is the vendor app's EQ editor.** While someone has the editor open, `state.eq_preset` is `0`. It is a legitimate state, not an error.

## Documentation

| | |
| --- | --- |
| [docs/devices.md](docs/devices.md) | what the device is, which brands share it, how a hub is found and identified |
| [docs/protocol-system-control.md](docs/protocol-system-control.md) | port 50006: framing, events, power, what must be polled |
| [docs/protocol-media.md](docs/protocol-media.md) | port 7777: the LUCI media session, message boxes, metadata |
| [docs/commands.md](docs/commands.md) | every known frame on both ports, with how firmly each is known |

Everything here was established by observing a real hub. Each frame in the reference is labelled with how it is known, from sent-and-observed down to inferred.

## Development

```bash
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy
```

The tests run the codec against real traffic captured from a hub, with personal data replaced (see [tests/fixtures/README.md](tests/fixtures/README.md)), and the client against a fake hub that speaks both ports.

## Legal

Independent reverse engineering, for interoperability. Not affiliated with Platin, Hansong, or Libre Wireless Technologies. LibreSync is a trademark of Libre Wireless Technologies, Inc. MIT licensed.
