aiolibresync 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiolibresync-0.3.0/.gitignore +23 -0
- aiolibresync-0.3.0/CHANGELOG.md +10 -0
- aiolibresync-0.3.0/LICENSE +21 -0
- aiolibresync-0.3.0/PKG-INFO +141 -0
- aiolibresync-0.3.0/README.md +114 -0
- aiolibresync-0.3.0/docs/commands.md +284 -0
- aiolibresync-0.3.0/docs/devices.md +169 -0
- aiolibresync-0.3.0/docs/protocol-media.md +400 -0
- aiolibresync-0.3.0/docs/protocol-system-control.md +369 -0
- aiolibresync-0.3.0/pyproject.toml +67 -0
- aiolibresync-0.3.0/src/aiolibresync/__init__.py +86 -0
- aiolibresync-0.3.0/src/aiolibresync/client.py +655 -0
- aiolibresync-0.3.0/src/aiolibresync/connection.py +209 -0
- aiolibresync-0.3.0/src/aiolibresync/const.py +185 -0
- aiolibresync-0.3.0/src/aiolibresync/discovery.py +439 -0
- aiolibresync-0.3.0/src/aiolibresync/exceptions.py +22 -0
- aiolibresync-0.3.0/src/aiolibresync/frames.py +204 -0
- aiolibresync-0.3.0/src/aiolibresync/media.py +239 -0
- aiolibresync-0.3.0/src/aiolibresync/py.typed +0 -0
- aiolibresync-0.3.0/src/aiolibresync/state.py +108 -0
- aiolibresync-0.3.0/src/aiolibresync/systemcontrol.py +212 -0
- aiolibresync-0.3.0/tests/conftest.py +49 -0
- aiolibresync-0.3.0/tests/fakes.py +524 -0
- aiolibresync-0.3.0/tests/fixtures/2026-01-18-port-50006-app-session.c +60 -0
- aiolibresync-0.3.0/tests/fixtures/2026-01-18-port-7777-app-session.c +289 -0
- aiolibresync-0.3.0/tests/fixtures/2026-08-15-app-dsp-50006.log +34 -0
- aiolibresync-0.3.0/tests/fixtures/2026-08-15-app-dsp-7777.log +49 -0
- aiolibresync-0.3.0/tests/fixtures/2026-08-15-app-dsp2-50006.log +47 -0
- aiolibresync-0.3.0/tests/fixtures/2026-08-15-app-dsp2-7777.log +62 -0
- aiolibresync-0.3.0/tests/fixtures/2026-08-15-remote-labelled-50006.log +43 -0
- aiolibresync-0.3.0/tests/fixtures/2026-08-15-remote-labelled-7777.log +328 -0
- aiolibresync-0.3.0/tests/fixtures/README.md +22 -0
- aiolibresync-0.3.0/tests/fixtures/upnp-device-description.xml +53 -0
- aiolibresync-0.3.0/tests/test_client.py +1126 -0
- aiolibresync-0.3.0/tests/test_connection.py +245 -0
- aiolibresync-0.3.0/tests/test_discovery.py +326 -0
- aiolibresync-0.3.0/tests/test_frames.py +273 -0
- aiolibresync-0.3.0/tests/test_media.py +193 -0
- aiolibresync-0.3.0/tests/test_state.py +79 -0
- aiolibresync-0.3.0/tests/test_systemcontrol.py +181 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Environments
|
|
2
|
+
.venv/
|
|
3
|
+
venv/
|
|
4
|
+
|
|
5
|
+
# Python bytecode and packaging
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
|
|
12
|
+
# Tool caches and coverage
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.mypy_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.coverage
|
|
17
|
+
.coverage.*
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# Editors and OS
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
.DS_Store
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-09-26
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `LibreSyncClient`: both ports, push-driven state, reconnection, confirmation of every command, a slow poll for power and room correction, and `diagnostics()`.
|
|
8
|
+
- `DeviceState.playback`: the play state to believe, from `audio_state` (whether sound is coming out) where it is known.
|
|
9
|
+
- `async_discover()` and `async_probe()`: SSDP discovery, device description, and a control-port check that sends nothing.
|
|
10
|
+
- `DeviceState.eq_preset` can be `0` while the vendor app's EQ editor is open.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alessandro Zarrilli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aiolibresync
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Async client for Libre Wireless LibreSync audio hubs, such as the Platin Stereo Hub
|
|
5
|
+
Project-URL: Homepage, https://github.com/drsound/aiolibresync
|
|
6
|
+
Project-URL: Documentation, https://github.com/drsound/aiolibresync/tree/main/docs
|
|
7
|
+
Project-URL: Issues, https://github.com/drsound/aiolibresync/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/drsound/aiolibresync/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Alessandro Zarrilli
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: asyncio,home assistant,libre wireless,libresync,platin,wisa
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Home Automation
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff<0.17,>=0.16.3; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# aiolibresync
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
- Pure Python, no runtime dependencies, fully typed.
|
|
35
|
+
- Push-driven: the hub announces volume, source, transport and metadata as they change, and a slow poll covers the two properties nothing announces.
|
|
36
|
+
- Discovery over SSDP, plus a probe for an address you already know.
|
|
37
|
+
- Written against the Home Assistant integration quality scale, but it does not depend on Home Assistant.
|
|
38
|
+
|
|
39
|
+
## Status
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install aiolibresync
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Python 3.12 or later.
|
|
50
|
+
|
|
51
|
+
## Finding a hub
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from aiolibresync import async_discover, async_probe
|
|
55
|
+
|
|
56
|
+
for device in await async_discover():
|
|
57
|
+
print(device.host, device.udn, device.name)
|
|
58
|
+
|
|
59
|
+
device = await async_probe("192.168.1.50") # an address you already have
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`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).
|
|
63
|
+
|
|
64
|
+
## Controlling it
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import asyncio
|
|
68
|
+
from aiolibresync import DeviceState, LibreSyncClient
|
|
69
|
+
|
|
70
|
+
async def main() -> None:
|
|
71
|
+
client = LibreSyncClient("192.168.1.50")
|
|
72
|
+
ready = asyncio.Event()
|
|
73
|
+
|
|
74
|
+
def on_state(state: DeviceState) -> None:
|
|
75
|
+
print(state)
|
|
76
|
+
if state.available:
|
|
77
|
+
ready.set()
|
|
78
|
+
|
|
79
|
+
client.subscribe(on_state)
|
|
80
|
+
await client.async_connect()
|
|
81
|
+
await ready.wait()
|
|
82
|
+
await client.async_set_volume(30)
|
|
83
|
+
await asyncio.sleep(60)
|
|
84
|
+
await client.async_disconnect()
|
|
85
|
+
|
|
86
|
+
asyncio.run(main())
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`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.
|
|
90
|
+
|
|
91
|
+
| Method | What it does |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `async_set_power(on)` | reads the power state first and toggles only if needed, because the device has no discrete on or off |
|
|
94
|
+
| `async_select_source(index)` | selects a source by the device's own index, from `state.sources` |
|
|
95
|
+
| `async_set_volume(level)` | 0–100 |
|
|
96
|
+
| `async_media_play()`, `_pause()`, `_stop()`, `_next_track()`, `_previous_track()` | transport, for the streaming renderer |
|
|
97
|
+
| `async_set_room_correction(enabled)`, `async_set_manual_eq(enabled)` | the two DSP switches |
|
|
98
|
+
| `async_select_eq_preset(preset)` | 1–3, the presets built in the vendor's app |
|
|
99
|
+
| `async_refresh()` | re-reads everything |
|
|
100
|
+
| `subscribe(callback)` | called with a new `DeviceState` on every change; returns an unsubscribe function |
|
|
101
|
+
| `diagnostics()` | a redacted snapshot with frame counters and any unrecognised frames |
|
|
102
|
+
|
|
103
|
+
Every command waits for the device to confirm the new state and raises `ConfirmationTimeout` if it does not.
|
|
104
|
+
|
|
105
|
+
## Things that will surprise you
|
|
106
|
+
|
|
107
|
+
**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.
|
|
108
|
+
|
|
109
|
+
**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).
|
|
110
|
+
|
|
111
|
+
**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.
|
|
112
|
+
|
|
113
|
+
**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).
|
|
114
|
+
|
|
115
|
+
**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.
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
| | |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| [docs/devices.md](docs/devices.md) | what the device is, which brands share it, how a hub is found and identified |
|
|
122
|
+
| [docs/protocol-system-control.md](docs/protocol-system-control.md) | port 50006: framing, events, power, what must be polled |
|
|
123
|
+
| [docs/protocol-media.md](docs/protocol-media.md) | port 7777: the LUCI media session, message boxes, metadata |
|
|
124
|
+
| [docs/commands.md](docs/commands.md) | every known frame on both ports, with how firmly each is known |
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python -m venv .venv
|
|
132
|
+
.venv/bin/pip install -e ".[dev]"
|
|
133
|
+
.venv/bin/pytest
|
|
134
|
+
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
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.
|
|
138
|
+
|
|
139
|
+
## Legal
|
|
140
|
+
|
|
141
|
+
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.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# aiolibresync
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- Pure Python, no runtime dependencies, fully typed.
|
|
8
|
+
- Push-driven: the hub announces volume, source, transport and metadata as they change, and a slow poll covers the two properties nothing announces.
|
|
9
|
+
- Discovery over SSDP, plus a probe for an address you already know.
|
|
10
|
+
- Written against the Home Assistant integration quality scale, but it does not depend on Home Assistant.
|
|
11
|
+
|
|
12
|
+
## Status
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install aiolibresync
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.12 or later.
|
|
23
|
+
|
|
24
|
+
## Finding a hub
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from aiolibresync import async_discover, async_probe
|
|
28
|
+
|
|
29
|
+
for device in await async_discover():
|
|
30
|
+
print(device.host, device.udn, device.name)
|
|
31
|
+
|
|
32
|
+
device = await async_probe("192.168.1.50") # an address you already have
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`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).
|
|
36
|
+
|
|
37
|
+
## Controlling it
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import asyncio
|
|
41
|
+
from aiolibresync import DeviceState, LibreSyncClient
|
|
42
|
+
|
|
43
|
+
async def main() -> None:
|
|
44
|
+
client = LibreSyncClient("192.168.1.50")
|
|
45
|
+
ready = asyncio.Event()
|
|
46
|
+
|
|
47
|
+
def on_state(state: DeviceState) -> None:
|
|
48
|
+
print(state)
|
|
49
|
+
if state.available:
|
|
50
|
+
ready.set()
|
|
51
|
+
|
|
52
|
+
client.subscribe(on_state)
|
|
53
|
+
await client.async_connect()
|
|
54
|
+
await ready.wait()
|
|
55
|
+
await client.async_set_volume(30)
|
|
56
|
+
await asyncio.sleep(60)
|
|
57
|
+
await client.async_disconnect()
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`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.
|
|
63
|
+
|
|
64
|
+
| Method | What it does |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `async_set_power(on)` | reads the power state first and toggles only if needed, because the device has no discrete on or off |
|
|
67
|
+
| `async_select_source(index)` | selects a source by the device's own index, from `state.sources` |
|
|
68
|
+
| `async_set_volume(level)` | 0–100 |
|
|
69
|
+
| `async_media_play()`, `_pause()`, `_stop()`, `_next_track()`, `_previous_track()` | transport, for the streaming renderer |
|
|
70
|
+
| `async_set_room_correction(enabled)`, `async_set_manual_eq(enabled)` | the two DSP switches |
|
|
71
|
+
| `async_select_eq_preset(preset)` | 1–3, the presets built in the vendor's app |
|
|
72
|
+
| `async_refresh()` | re-reads everything |
|
|
73
|
+
| `subscribe(callback)` | called with a new `DeviceState` on every change; returns an unsubscribe function |
|
|
74
|
+
| `diagnostics()` | a redacted snapshot with frame counters and any unrecognised frames |
|
|
75
|
+
|
|
76
|
+
Every command waits for the device to confirm the new state and raises `ConfirmationTimeout` if it does not.
|
|
77
|
+
|
|
78
|
+
## Things that will surprise you
|
|
79
|
+
|
|
80
|
+
**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.
|
|
81
|
+
|
|
82
|
+
**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).
|
|
83
|
+
|
|
84
|
+
**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.
|
|
85
|
+
|
|
86
|
+
**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).
|
|
87
|
+
|
|
88
|
+
**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.
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
| | |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| [docs/devices.md](docs/devices.md) | what the device is, which brands share it, how a hub is found and identified |
|
|
95
|
+
| [docs/protocol-system-control.md](docs/protocol-system-control.md) | port 50006: framing, events, power, what must be polled |
|
|
96
|
+
| [docs/protocol-media.md](docs/protocol-media.md) | port 7777: the LUCI media session, message boxes, metadata |
|
|
97
|
+
| [docs/commands.md](docs/commands.md) | every known frame on both ports, with how firmly each is known |
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python -m venv .venv
|
|
105
|
+
.venv/bin/pip install -e ".[dev]"
|
|
106
|
+
.venv/bin/pytest
|
|
107
|
+
.venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
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.
|
|
111
|
+
|
|
112
|
+
## Legal
|
|
113
|
+
|
|
114
|
+
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.
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Command and event reference
|
|
2
|
+
|
|
3
|
+
Every frame a client can send to a LibreSync hub, and every frame it can expect back, on both control ports: System Control on TCP 50006 and the media session on TCP 7777. This is the byte-level lookup table. The reasoning behind each rule lives in the protocol references: [protocol-system-control.md](protocol-system-control.md) for port 50006 and [protocol-media.md](protocol-media.md) for port 7777.
|
|
4
|
+
|
|
5
|
+
Everything here was established on one Platin Stereo Hub running firmware 1.52. Other firmware versions, and other brands on the same platform, may differ.
|
|
6
|
+
|
|
7
|
+
Byte sequences are hexadecimal. Offsets count from 0 at the first byte of the frame, including the length header.
|
|
8
|
+
|
|
9
|
+
## Evidence levels
|
|
10
|
+
|
|
11
|
+
Every row says where its confidence comes from.
|
|
12
|
+
|
|
13
|
+
- `tested` — sent to the device and confirmed by an effect of a different nature: a resulting event, or something audible.
|
|
14
|
+
- `capture` — seen on the wire, sent by the vendor's app or pushed by the device, but not confirmed by a deliberate test.
|
|
15
|
+
- `derived` — built from the frame rules of this reference and never sent to a device.
|
|
16
|
+
- `binary` — read out of the firmware itself (a string, a symbol or an instruction sequence). A name that suggests a meaning is not a confirmed meaning.
|
|
17
|
+
- `claimed` — asserted by an unverified source. No row currently carries this level.
|
|
18
|
+
|
|
19
|
+
A command is confirmed by its effect, not by reading back the value that was written. The mute section below shows why.
|
|
20
|
+
|
|
21
|
+
## Port 50006 — System Control
|
|
22
|
+
|
|
23
|
+
### Frame format
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
[length:2 BE][checksum][class][opcode][data...]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- `length` counts every byte after itself, so a frame is `length + 2` bytes long.
|
|
30
|
+
- `checksum = sum(bytes[3:]) & 0xFF` on client frames. The firmware does not verify it, so a wrong value is accepted, but generate it anyway: it is what the vendor's app does.
|
|
31
|
+
- Device-to-client frames carry `0x00` in byte 2. Byte 2 is not a direction marker: the firmware also accepts a client frame carrying `0x00`, so tell requests from reports by class, opcode and data length.
|
|
32
|
+
|
|
33
|
+
Two properties of the port shape every client:
|
|
34
|
+
|
|
35
|
+
- **It is a shared broadcast bus.** Every device-to-client frame goes to every connected client, not only the one that asked, and other clients' requests are visible too. A passive listener sees state changes without polling.
|
|
36
|
+
- **Frame on the length header.** Scanning the stream for byte patterns confuses one event for another; reading the 2-byte length and splitting on it does not.
|
|
37
|
+
|
|
38
|
+
The port is a transparent tunnel to a host microcontroller whose firmware is not on the network module, so `binary` evidence cannot exist for any row in this section. See [protocol-system-control.md](protocol-system-control.md).
|
|
39
|
+
|
|
40
|
+
### Power
|
|
41
|
+
|
|
42
|
+
| Action | Frame | Evidence |
|
|
43
|
+
| --- | --- | --- |
|
|
44
|
+
| Get power state | `00 03 0f 02 0d` | tested |
|
|
45
|
+
| Toggle power | `00 04 11 02 0f 00` | tested |
|
|
46
|
+
| Toggle power (the vendor app's "on" form) | `00 04 12 02 0f 01` | tested |
|
|
47
|
+
|
|
48
|
+
> **Power is a toggle.** The last two rows are the same command. The vendor's app sends `0f 00` for off and `0f 01` for on, with a correct checksum for each, but the firmware ignores the data byte: `0f 01` sent to a powered-on hub turns it off. There is no discrete power opcode. Query the state first, then toggle only if it differs; the vendor's own app does not, and races. See [protocol-system-control.md](protocol-system-control.md).
|
|
49
|
+
|
|
50
|
+
The toggle answers with **two** identical `02 0e` frames, broadcast to every connected client. Deduplicate them.
|
|
51
|
+
|
|
52
|
+
### Source selection
|
|
53
|
+
|
|
54
|
+
Frame: `00 04 <05+ix> 02 03 <ix>`, where `ix` is the source index from the device's own source list.
|
|
55
|
+
|
|
56
|
+
| Input | Index | Frame | Evidence |
|
|
57
|
+
| --- | --- | --- | --- |
|
|
58
|
+
| Streaming / Wi-Fi | 0 | `00 04 05 02 03 00` | tested |
|
|
59
|
+
| USB | 1 | `00 04 06 02 03 01` | derived |
|
|
60
|
+
| COAX | 2 | `00 04 07 02 03 02` | derived |
|
|
61
|
+
| OPT3 | 3 | `00 04 08 02 03 03` | derived |
|
|
62
|
+
| OPT2 | 4 | `00 04 09 02 03 04` | derived |
|
|
63
|
+
| OPT1 | 5 | `00 04 0a 02 03 05` | derived |
|
|
64
|
+
| HDMI ARC | 6 | `00 04 0b 02 03 06` | tested |
|
|
65
|
+
| AUX | 7 | `00 04 0c 02 03 07` | derived |
|
|
66
|
+
| Line In | 8 | `00 04 0d 02 03 08` | tested |
|
|
67
|
+
| Bluetooth | 9 | `00 04 0e 02 03 09` | derived |
|
|
68
|
+
|
|
69
|
+
| Action | Frame | Evidence |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| Get source list and current source | `00 03 03 02 01` | tested |
|
|
72
|
+
|
|
73
|
+
Take the index-to-name mapping from the device's JSON source list (the `02 02` report), not from this table. The names above are the ones this hub reports, and a rebranded device can expose a different input set.
|
|
74
|
+
|
|
75
|
+
Frames with a wrong checksum byte are accepted: `0x0a`, `0xff` and `0x00` in byte 2 all select the source. Send the correct checksum regardless.
|
|
76
|
+
|
|
77
|
+
Switching **away from Streaming ends the Chromecast session**, and switching back does not resume it. A client that offers source selection should expect the media session to end, not merely pause.
|
|
78
|
+
|
|
79
|
+
### Volume, mute and source in one read
|
|
80
|
+
|
|
81
|
+
| Action | Frame | Response | Evidence |
|
|
82
|
+
| --- | --- | --- | --- |
|
|
83
|
+
| Get source, volume, mute and audio activity | `00 03 22 02 20` | `00 07 00 02 21 <src> <vol> <mute> <audio>` | tested |
|
|
84
|
+
|
|
85
|
+
The hub answers in about 84 ms with the same `02 21` report it pushes on change. It is the only way to read those four fields on connect, because `02 21` is otherwise emitted on change only. Read it once on connect; there is nothing to gain from polling it.
|
|
86
|
+
|
|
87
|
+
The volume and transport *commands* live on port 7777. Mute has no command on either port.
|
|
88
|
+
|
|
89
|
+
### DSP
|
|
90
|
+
|
|
91
|
+
| Action | Frame | Evidence |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| Manual EQ on | `00 04 08 03 04 01` | tested |
|
|
94
|
+
| Manual EQ off (flat) | `00 04 07 03 04 00` | tested |
|
|
95
|
+
| Get manual EQ state | `00 03 08 03 05` | tested |
|
|
96
|
+
| Room correction on | `00 04 09 07 01 01` | tested |
|
|
97
|
+
| Room correction off | `00 04 08 07 01 00` | tested |
|
|
98
|
+
| Get room correction state | `00 03 09 07 02` | tested |
|
|
99
|
+
|
|
100
|
+
`03 04` is the same switch the vendor's app shows as the manual EQ master switch.
|
|
101
|
+
|
|
102
|
+
Room correction is a single frame. The set alone takes effect; the query that the vendor's app sends after it is an ordinary read, not a second half of the command. The hub takes between 0.21 s and 0.76 s to apply a change (four measured writes), so a read-back immediately after the set can still return the old value. Re-read until the value changes or a timeout expires.
|
|
103
|
+
|
|
104
|
+
### EQ presets
|
|
105
|
+
|
|
106
|
+
| Action | Frame | Evidence |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| Select EQ preset 1 | `00 07 0f 08 02 01 01 01 02` | tested |
|
|
109
|
+
| Select EQ preset 2 | `00 07 10 08 02 01 02 01 02` | tested |
|
|
110
|
+
| Select EQ preset 3 | `00 07 11 08 02 01 03 01 02` | tested |
|
|
111
|
+
| Get active preset | `00 04 0d 08 05 00` | capture |
|
|
112
|
+
|
|
113
|
+
The data field is `01 <n> 01 <action>`. The preset id sits at offset 6 and is the only byte that varies between the three selects.
|
|
114
|
+
|
|
115
|
+
> **Never send `08 02` with a last byte other than `02`.** The last byte is an action, not a constant: `02` selects, while `00` and `01` store the editor's working curve into preset `<n>` and **overwrite** whatever filters the owner built there. A client that means to select must send `02` and nothing else. The library's `select_eq_preset` range-checks the id to 1–3 and appends a fixed `01 02`, so it cannot store by accident.
|
|
116
|
+
|
|
117
|
+
#### Preset 0 and the editor frames
|
|
118
|
+
|
|
119
|
+
The hub has a fourth preset slot, **preset 0**, which is the vendor app's EQ editor working slot. It holds the last curve edited. The app selects it with the ordinary select command when its editor opens, and from then on the hub reports preset 0 until another preset is selected. A client must accept 0 as a legitimate active preset, not treat it as an error.
|
|
120
|
+
|
|
121
|
+
The editor uses these frames. They are listed so a client can recognise them on the shared bus, not so it can send them.
|
|
122
|
+
|
|
123
|
+
| Frame | Meaning | Evidence |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `00 07 0e 08 02 01 00 01 02` | select preset 0, the editor's working slot | capture |
|
|
126
|
+
| `00 23 <cs> 08 01 …`, 35 bytes | write one filter into the working slot. Not decoded | capture |
|
|
127
|
+
| `00 07 <0c+n> 08 02 01 <n> 01 00`, then `00 07 <0d+n> 08 02 01 <n> 01 01` | store the working curve into preset `<n>`, **overwriting** it. Each draws an `08 05` report whose last byte echoes `00` or `01` | capture |
|
|
128
|
+
|
|
129
|
+
#### Filter definitions
|
|
130
|
+
|
|
131
|
+
Selecting a preset is supported; defining one is not, and users build presets in the vendor's app. Two queries return the underlying data, and neither response is decoded.
|
|
132
|
+
|
|
133
|
+
| Query | Response | Contents |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `00 03 10 07 09` | `00 8f 00 07 0a …`, 143 bytes | the room calibration curve |
|
|
136
|
+
| `00 05 <0c+n> 08 04 00 <n>` | `00 0f 00 08 04 02 <i> …`, 17 bytes, one frame per filter | filter parameters for preset `<n>` |
|
|
137
|
+
|
|
138
|
+
The filter read answers with **one 17-byte frame per filter**, with the filter index `<i>` in the second data byte (`02 00 …`, `02 01 …`). A preset with no filters gets no answer at all, so silence means an empty preset, not a failure. The blocks differ per preset, which is where the filter definitions live.
|
|
139
|
+
|
|
140
|
+
### Connect-time configuration queries
|
|
141
|
+
|
|
142
|
+
The vendor's app sends these on every connect. They are read-only: sending them changes nothing, and each answer goes to every connected client.
|
|
143
|
+
|
|
144
|
+
| Frame | Response | Evidence |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `00 03 4c 02 4a` | `00 04 00 02 4b 00` | tested |
|
|
147
|
+
| `00 03 4f 02 4d` | `00 04 00 02 4e 00` | tested |
|
|
148
|
+
| `00 03 8d 02 8b` | `00 04 00 02 8b 3c` | tested |
|
|
149
|
+
|
|
150
|
+
All three answers are invariant across power state and source. They are configuration, not state, and what they mean cannot be determined from the network side. **Do not poll them**; read them once on connect if at all.
|
|
151
|
+
|
|
152
|
+
> **The get/report/set pattern is not a rule.** In class `02` the opcodes usually come in triplets, a get at `N`, a report at `N+1` and a set at `N+2` (`01/02/03`, `0d/0e/0f`, `20/21`). `02 8b` breaks it by answering with its own opcode. Do not send the inferred set forms `02 22`, `02 4c` or `02 4f`: they would write properties nobody has identified.
|
|
153
|
+
|
|
154
|
+
### Events (device to client)
|
|
155
|
+
|
|
156
|
+
Byte 2 is always `0x00` in this direction, and every frame goes to every connected client.
|
|
157
|
+
|
|
158
|
+
| Event | Frame | Meaning | Evidence |
|
|
159
|
+
| --- | --- | --- | --- |
|
|
160
|
+
| Power state | `00 04 00 02 0e 01` / `… 00` | on / off. Sent **twice** when changed over the network | tested |
|
|
161
|
+
| Source list | `01 4a 00 02 02` + JSON | the full source list, `sts:1` marks the current one. Pushed on every source change | tested |
|
|
162
|
+
| Volume / mute / source | `00 07 00 02 21 <src> <vol> <mute> <audio>`, 9 bytes | all four in one frame, **on change only** | tested |
|
|
163
|
+
| Room correction | `00 04 00 07 03 01` / `… 00` | on / off. **Never pushed**: it only appears as the answer to a `07 02` query | tested |
|
|
164
|
+
| Manual EQ | `00 04 00 03 05 01` / `… 00` | on / off, about 200 ms after the set | tested |
|
|
165
|
+
| EQ preset | `00 09 00 08 05 02 04 <n> 01 01 <a>`, 11 bytes | active preset, id at offset 7 (0 is the editor slot). The last byte `<a>` is `01` after a select, and echoes the action byte (`00` or `01`) after a store | tested |
|
|
166
|
+
| unknown `0x4b` | `00 04 00 02 4b 00` | | capture |
|
|
167
|
+
| unknown `0x4e` | `00 04 00 02 4e 00` | | capture |
|
|
168
|
+
| unknown `0x8b` | `00 04 00 02 8b 3c` | `0x3c` = 60 | capture |
|
|
169
|
+
|
|
170
|
+
Notes on the `02 21` report:
|
|
171
|
+
|
|
172
|
+
- `<src>` at offset 5 is a source index in a **second numbering** that disagrees with the JSON `ix` on the wireless inputs. Take the current source from the `02 02` list instead.
|
|
173
|
+
- `<vol>` is at offset 6 and `<mute>` at offset 7. The mute byte is the dependable way to read mute state, since the port 7777 mute query can be refused.
|
|
174
|
+
- `<audio>` at offset 8 is **audio activity on the selected input**, in the play-state values `00`, `01`, `02`, `05`. It is neither a signal flag nor the streaming play state, and on a physical input it is the only field on either port that knows whether sound is coming out.
|
|
175
|
+
|
|
176
|
+
Field-by-field detail is in [protocol-system-control.md](protocol-system-control.md).
|
|
177
|
+
|
|
178
|
+
**Room correction is the one property with no event at all**, whoever changes it. A client must poll `00 03 09 07 02`. The vendor's app re-reads after each of its own sets and shows a stale value if something else changes the setting while its screen is open.
|
|
179
|
+
|
|
180
|
+
Power changes are pushed on firmware 1.52, including a press on the RF remote. Whether every power path is pushed on every firmware is not established, so a periodic `02 0d` query is a sensible backstop.
|
|
181
|
+
|
|
182
|
+
### Observed, unidentified
|
|
183
|
+
|
|
184
|
+
| Frame | Notes | Evidence |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| `00 07 00 08 06 02 00 01 00` | class `08` (presets), opcode `06`. Seen twice, changed no observed state, meaning unknown | capture |
|
|
187
|
+
|
|
188
|
+
## Port 7777 — media session
|
|
189
|
+
|
|
190
|
+
### Frame format
|
|
191
|
+
|
|
192
|
+
Both directions share one 10-byte header followed by the payload:
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
<RemoteID:2> <msg_type> <cmd:2> <status> <CRC:2> <len:2> <payload>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Every 16-bit field is **little-endian from the client** and **big-endian from the device**. For the commands below a request is therefore `00 00 <msg_type> <cmd_lo> <cmd_hi> 00 00 00 <len_lo> <len_hi> <payload>`.
|
|
199
|
+
|
|
200
|
+
- `msg_type`: `01` query, `02` command or pushed event.
|
|
201
|
+
- `cmd` is a **16-bit LUCI message-box id**, not a port-local opcode. The same box is read one way and written the other.
|
|
202
|
+
- `status`: `00` unsolicited, `01` reply, `02` refused, `03` session not registered. Dispatch on the message box, not on this byte.
|
|
203
|
+
- **Leave the CRC at `00 00` on requests.** The device accepts zero, which is what its own app sends, and also accepts a wrong non-zero value. On device frames the field is CRC-16/XMODEM and can be verified.
|
|
204
|
+
|
|
205
|
+
The full layout is in [protocol-media.md](protocol-media.md).
|
|
206
|
+
|
|
207
|
+
### Commands and queries
|
|
208
|
+
|
|
209
|
+
| Action | msg_type | cmd | Payload | Evidence |
|
|
210
|
+
| --- | --- | --- | --- | --- |
|
|
211
|
+
| Register session | `02` | `0003` | client IP as ASCII, e.g. `192.168.1.20` | tested |
|
|
212
|
+
| Read model | `02` | `00d0` | `READ_Model` | capture |
|
|
213
|
+
| Set volume | `02` | `0040` | `"0"`–`"100"` | tested |
|
|
214
|
+
| Play / Pause / Stop / Next / Prev | `02` | `0028` | `PLAY` `PAUSE` `STOP` `NEXT` `PREV` | tested |
|
|
215
|
+
| Get now-playing | `01` | `002a` | *(empty)* | capture |
|
|
216
|
+
| Get volume | `01` | `0040` | *(empty)* | tested |
|
|
217
|
+
| Get mute | `01` | `003f` | *(empty)*, answered `MUTE` / `UNMUTE` or refused | tested |
|
|
218
|
+
| ~~Set mute~~ | `02` | `003f` | `MUTE` / `UNMUTE`, **accepted and ineffective** | tested |
|
|
219
|
+
|
|
220
|
+
**Register the session before anything else.** An unregistered connection is answered `cmd 0000 status 03` and the request is dropped, which looks like a malformed frame and is not. The vendor's app waits about 100 ms after registering before it sends anything.
|
|
221
|
+
|
|
222
|
+
What the transport commands produce:
|
|
223
|
+
|
|
224
|
+
- `PAUSE`: the `0028` ack (`status 01`), then `0033 "2"`.
|
|
225
|
+
- `PLAY`: the same ack, then `0033 "0"`.
|
|
226
|
+
- `NEXT` and `PREV`: byte-identical sequences, the `0028` ack, `0033 "1"`, `0033 "5"`, a position reset and `01ee`. The two cannot be told apart on the wire. The RF remote produces the same sequence.
|
|
227
|
+
- `STOP`: the position field goes to `-1000`, which means "no position" and makes the field **signed**, and the speakers report `SPEAKER_INACTIVE`. See [protocol-media.md](protocol-media.md).
|
|
228
|
+
- Set volume: the `0040` event arrives **twice**, as it does when the volume changes from the remote.
|
|
229
|
+
|
|
230
|
+
The mute query answered `MUTE` / `UNMUTE` in one session and was refused with `status 02` in later sessions on the same firmware, with the hub both off and playing. Read mute from the port 50006 `02 21` report instead, fetched on demand with `02 20`.
|
|
231
|
+
|
|
232
|
+
### There is no mute command
|
|
233
|
+
|
|
234
|
+
Neither the RF remote nor the vendor's app sends a mute command over the network: the remote's mute button puts nothing on the wire, and the app has no mute control. Writing `02 003f "MUTE"` is **accepted**, without the refusal other boxes give, and a re-read then returns `MUTE`. **The audio does not change**, confirmed by ear and by the playback position, which kept tracking wall-clock time to within 800 ms.
|
|
235
|
+
|
|
236
|
+
Two rules follow:
|
|
237
|
+
|
|
238
|
+
- **Mute is read-only.** Box `0x3f` is a mirror the host microcontroller updates; writing it desynchronises the mirror and reaches nothing. Volume behaves differently because its box is backed by a stored setting with a handler that propagates it. "Same box, read one way and written the other" is a property of some boxes, not of the bus.
|
|
239
|
+
- **Writing a box is not commanding the device.** Reading back the value just written confirms nothing. A command is confirmed by an effect of a different nature: an event on the other port, or something audible.
|
|
240
|
+
|
|
241
|
+
### Message boxes named from the firmware
|
|
242
|
+
|
|
243
|
+
| cmd | Meaning | Evidence |
|
|
244
|
+
| --- | --- | --- |
|
|
245
|
+
| `0005` | firmware version | binary |
|
|
246
|
+
| `0040` | volume, backed by the stored `current_volume` setting | binary |
|
|
247
|
+
| `005a` | model/brand string, `LibreX` on this build | binary |
|
|
248
|
+
| `006f` | tunnel init; the payload is the TCP port as ASCII | binary |
|
|
249
|
+
| `0070` | tunnel data. **This is port 50006** | binary + capture |
|
|
250
|
+
|
|
251
|
+
> **Do not sweep this port, and never address factory reset.** Because `cmd` names a message box directly, an unknown value reaches whatever the internal bus attaches to that box, and somewhere in that space are factory reset and firmware upgrade. Port 50006 cannot reach those; port 7777 can. A client should address only a closed set of known boxes, never one taken from user input. Boxes 107 (`0x6b`) and 219 (`0xdb`) refuse requests with `status 02` on this model.
|
|
252
|
+
|
|
253
|
+
### Pushed events (device to client)
|
|
254
|
+
|
|
255
|
+
| cmd | Payload | Meaning | Evidence |
|
|
256
|
+
| --- | --- | --- | --- |
|
|
257
|
+
| `0031` | milliseconds, ASCII | playback position, once per second while playing. Signed: `-1000` means no position | capture |
|
|
258
|
+
| `0033` | `0` `1` `2` `5` | play state: playing / stopped or idle / paused / loading | capture |
|
|
259
|
+
| `003f` | `MUTE` / `UNMUTE` | mute changed | capture |
|
|
260
|
+
| `0040` | `0`–`100` | volume changed, sent **twice** | capture |
|
|
261
|
+
| `002a`, `002d` | PlayView JSON | now-playing metadata, sent as an identical pair | capture |
|
|
262
|
+
| `0046` | `SPEAKER_ACTIVE,<n>` / `SPEAKER_INACTIVE,<n>` | WiSA speakers active or released | capture |
|
|
263
|
+
| `00e6` | e.g. `48000` | sample rate of the new track | capture |
|
|
264
|
+
| `01ee` | *(empty)* | part of the track-change sequence. A two-byte id: read `01 ee` at offsets 3..4, not `status 01` / `cmd ee` | capture |
|
|
265
|
+
| `000a`, `0032` | e.g. `24` / `25` | internal source id | capture |
|
|
266
|
+
| `0070` | binary | tunnel: carries client-to-device port 50006 frames. The same box is also backed by UART and BLE | capture + binary |
|
|
267
|
+
|
|
268
|
+
The tunnel carries port 50006 **commands** only, not the device's replies or events, so port 7777 alone is not enough. A client needs both sockets.
|
|
269
|
+
|
|
270
|
+
### Multiroom
|
|
271
|
+
|
|
272
|
+
`SETMASTER` `SETSLAVE` `SETFREE` `JOINALL` `JOINTO` `JOINNEXT` `JOINNEXTLEFT` `JOINNEXTRIGHT` `DROPME` `DROPALL` exist as command names in the firmware (`binary` only). No wire format and no port is known for them.
|
|
273
|
+
|
|
274
|
+
## Not this protocol: Primare's ASCII API
|
|
275
|
+
|
|
276
|
+
Primare publishes a TCP/IP and RS232 API for its Prisma range, built on the same Libre LS9 module, on **the same port 50006**. It is not this protocol. Primare's API is line-oriented ASCII:
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
!1pow.? -> get power !1vol.25 -> set volume 25
|
|
280
|
+
!1pow.1 -> power on !1mut.t -> toggle mute
|
|
281
|
+
!1inp.6 -> select input 6 terminated by <CR><LF>
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Same port number, same module, a completely different wire format: Primare layers its own integrator-facing API over the LibreSync stack. The document is useful as a map of which properties the platform models (power, input, volume, mute, DSP and surround mode), not as a decoder for the binary protocol. In particular, Primare's discrete power on/off does not mean the binary protocol has one: `02 0f` toggles and ignores its data byte.
|