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 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
pip install aiolibresync
Python 3.12 or later.
Finding a hub
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.
Two stable identifiers are available. The factory serial (await async_read_serial(host), or state.serial on a connected client) does not depend on the UPnP daemon and is the one to prefer. The UDN (device.udn) is what discovery sees. Either can be None: a unit may lack a valid factory serial, and the UDN is served by a UPnP daemon that occasionally stops on its own until the hub is power-cycled. See docs/devices.md.
Controlling it
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.
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 | what the device is, which brands share it, how a hub is found and identified |
| docs/protocol-system-control.md | port 50006: framing, events, power, what must be polled |
| docs/protocol-media.md | port 7777: the LUCI media session, message boxes, metadata |
| 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
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), 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.
Release files for aiolibresync 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| aiolibresync-0.4.0.tar.gz | 108.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| aiolibresync-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 148.7 kB
Release files / aiolibresync-0.4.0.tar.gz
| Download URL | aiolibresync-0.4.0.tar.gz |
|---|---|
| Size | 108.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
af88806df31e48fbb9805ef254bba352c716d244df3c7576762addb8a87fc4f5
|
|
BLAKE2b-256 checksum How to use checksums |
bcc0c7656e654d2b2629ad98204bd8410e3e783d096d366d0770181d95910c46
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency logRelease files / aiolibresync-0.4.0-py3-none-any.whl
| Download URL | aiolibresync-0.4.0-py3-none-any.whl |
|---|---|
| Size | 40.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6f90d3923749df87ec680709247a0372d43fecc9b73d4a4867393f706fd02b31
|
|
BLAKE2b-256 checksum How to use checksums |
2d4a8559d5bd55ccef365f70a4899ade3487a891e466bb4874076c7de261b245
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency log