Unofficial protocol library and man-in-the-middle proxy for Sofabaton X1 / X1S / X2 universal remote hubs
Project description
sofabaton-x
Unofficial Python library for Sofabaton X1 / X1S / X2 universal remote hubs: a reverse-engineered protocol implementation and a man-in-the-middle proxy that sits between the hub and the official mobile app.
This is the protocol engine extracted from the Home Assistant Sofabaton X1S integration; the integration is its reference consumer.
Disclaimer: this project is not affiliated with or endorsed by Sofabaton. The protocol was reverse-engineered from network captures; behavior may break with future hub firmware.
What it does
- Proxy a physical hub: the library advertises itself via mDNS exactly like a real hub, the official app connects to it, and every frame is relayed, decoded and observable. The hub keeps working with the app while your application gets full visibility and control.
- Catalogs: read activities, devices, buttons, commands, macros and favorites from the hub's wire protocol.
- Control: send button/command presses, switch activities, trigger find-my-remote.
- Provisioning (protocol side): create/update/delete devices and activities, including virtual WiFi/IP devices.
- Backup / restore: export and restore hub configuration.
- Events: subscribe to hub/app connection state, activity changes, OTA progress and catalog updates.
Deliberately out of scope: executing the HTTP callbacks that virtual WiFi/IP devices define (e.g. a Roku-style ECP listener). The library carries the protocol artifacts for those features so applications can build them on top — the Home Assistant integration does exactly that.
Install
pip install sofabaton-x
Python 3.11+. The only dependency is python-zeroconf (mDNS advertising and hub discovery).
Quickstart
Find a hub, then proxy it. Blocking work runs in the event loop's executor and callbacks (plain functions or coroutines) are delivered on the loop, so application code never touches the engine threads:
import asyncio
from sofabaton import AsyncXProxy, async_discover_hubs
async def main():
hubs = await async_discover_hubs(timeout=5.0) # physical hubs; proxies filtered
hub = hubs[0]
proxy = AsyncXProxy(
hub_ip=hub.host, # the physical hub's IP
mdns_instance=hub.name,
mdns_txt=hub.txt, # carries HVER -> X1/X1S/X2 classification
hub_version=hub.hub_version, # optional; the connect banner confirms it
)
proxy.on_activity_change(lambda new, old, name: print(f"activity -> {name}"))
async with proxy:
await proxy.wait_until_controllable() # own the hub (see below)
activities = await proxy.activities() # {id: {name, active, ...}}
for dev_id in await proxy.devices():
for cmd in await proxy.commands(dev_id): # [{command_id, label}]
await proxy.send(dev_id, cmd["command_id"]) # fire a command
await proxy.start_activity(next(iter(activities)))
asyncio.run(main())
Ports
The proxy has two network faces. Apart from hub_ip, every port defaults
to the right value — you usually only touch hub_listen_port to avoid a
local collision:
| Argument | Default | Side | What it is |
|---|---|---|---|
hub_ip |
— | hub | the physical hub's IPv4 address |
hub_port |
8102 | hub | UDP port on the hub we send CALL_ME to (protocol-fixed) |
hub_listen_port |
8200 | hub | TCP port on this host the hub connects back to |
app_discovery_port |
8102 | app | UDP port on this host the app finds + calls us on (keep 8102 for iOS) |
The hub model (X1/X1S/X2) is confirmed from the connect banner, so
hub_version is only a pre-connect hint. See
docs/networking.md
for the complete port map and firewall guidance.
Everything is keyed on (entity_id, command_id) — you browse to get
those ids, then send(entity_id, command_id). The reads return them
directly (cached if available, else fetched):
| read | returns |
|---|---|
activities() / devices() |
{id: {name, ...}} |
commands(device_id) |
[{command_id, label}] |
macros(activity_id) |
[{command_id, label}] |
favorites(activity_id) |
[{device_id, command_id, label}] |
buttons(entity_id) |
[{button_code, name, device_id, command_id}] |
current_activity() |
{activity_id, name} or None when idle |
current_activity() is the exception to the table above — it reads the
hub's live running-activity state (no fetch) and works in observe mode
too; subscribe to changes with on_activity_change(cb).
Control: send(entity_id, command_id) (alias press),
start_activity(act), stop_activity(act), find_remote().
Two modes
The proxy sits transparently between the hub and the official app, which gives it two distinct modes:
- Observe — the app is connected through the proxy. You watch
activity changes (
current_activity()/on_activity_change), connects and OTA events in real time, but the app owns the hub, so you can't issue commands. Gate onawait proxy.wait_connected(). - Control — no app attached; the proxy owns the hub, so reads fetch
fresh and commands/backup work. Gate on
await proxy.wait_until_controllable().
start() only spawns the transport; the connect handshake happens
afterwards, so await the matching readiness primitive before reading or
acting (otherwise a read raises with the reason — hub not connected, or
an app holds it).
A CLI ships as a console script:
sofabaton discover # scan the LAN for hubs
sofabaton run --hub-ip 192.168.1.50 # proxy + interactive shell
x> status
x> activities
x> send 101 POWER_ON
Runnable examples — discovery, watching a live session (observe mode),
taking control of a hub, reading per-entity detail
(commands/macros/favorites), schema-versioned backup/restore, and
building an HTTP callback listener on top of the library — live in
sofabaton-x/examples/.
Protocol & networking docs
This library is a reverse-engineered implementation; the wire protocol and network topology are documented in the repository:
- Protocol reference —
docs/protocol/: connection flow, frame format, opcodes, data structures, hub versions and more. - Networking guide —
docs/networking.md: the full port map, the two proxy faces, firewall rules and VLAN caveats.
Stability
Names importable from the package root — from sofabaton import ...,
the set listed in sofabaton.__all__ — are the supported API and follow
semver. Everything else (sofabaton.opcode_handlers, frame parsing,
wire schemas, the proxy_* mixin modules) is internal and may change
between minor releases. The library raises stdlib exceptions
(ValueError for malformed/unclassifiable input, RuntimeError /
TimeoutError for transport and ack failures); there are no custom
exception types. Until 1.0, pin a minor version.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sofabaton_x-0.1.0rc1.tar.gz.
File metadata
- Download URL: sofabaton_x-0.1.0rc1.tar.gz
- Upload date:
- Size: 203.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5279e371d927523a57137ad2d895379894e3bdaf6b239ceda5e2ac13c06910ff
|
|
| MD5 |
350bb4548fdba6d0802c4941396b69e5
|
|
| BLAKE2b-256 |
b1fcf9da7c71beb5c5d4f4d636fc91483d9e32fd14615c9c2b4c86b040d9c2d5
|
Provenance
The following attestation bundles were made for sofabaton_x-0.1.0rc1.tar.gz:
Publisher:
sofabaton-x-release.yml on m3tac0de/home-assistant-sofabaton-x1s
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sofabaton_x-0.1.0rc1.tar.gz -
Subject digest:
5279e371d927523a57137ad2d895379894e3bdaf6b239ceda5e2ac13c06910ff - Sigstore transparency entry: 1830851882
- Sigstore integration time:
-
Permalink:
m3tac0de/home-assistant-sofabaton-x1s@0847c5d953b0717bb891ea8a13ede060faf354fc -
Branch / Tag:
refs/tags/sofabaton-x-v0.1.0rc1 - Owner: https://github.com/m3tac0de
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
sofabaton-x-release.yml@0847c5d953b0717bb891ea8a13ede060faf354fc -
Trigger Event:
push
-
Statement type:
File details
Details for the file sofabaton_x-0.1.0rc1-py3-none-any.whl.
File metadata
- Download URL: sofabaton_x-0.1.0rc1-py3-none-any.whl
- Upload date:
- Size: 220.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
643bda5bb571bae2ba9c590a1f3a40c9f3eaf62975f4ef9b90e6045def91011f
|
|
| MD5 |
9c484250fc0428d754eac009ca449b00
|
|
| BLAKE2b-256 |
5265181fc0e65ce9f21f5da4e986ae94b98838da8d5ce8d6b73aec73a241248f
|
Provenance
The following attestation bundles were made for sofabaton_x-0.1.0rc1-py3-none-any.whl:
Publisher:
sofabaton-x-release.yml on m3tac0de/home-assistant-sofabaton-x1s
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sofabaton_x-0.1.0rc1-py3-none-any.whl -
Subject digest:
643bda5bb571bae2ba9c590a1f3a40c9f3eaf62975f4ef9b90e6045def91011f - Sigstore transparency entry: 1830851962
- Sigstore integration time:
-
Permalink:
m3tac0de/home-assistant-sofabaton-x1s@0847c5d953b0717bb891ea8a13ede060faf354fc -
Branch / Tag:
refs/tags/sofabaton-x-v0.1.0rc1 - Owner: https://github.com/m3tac0de
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
sofabaton-x-release.yml@0847c5d953b0717bb891ea8a13ede060faf354fc -
Trigger Event:
push
-
Statement type: