mcp-server-sofabaton 0.2.0__py3-none-any.whl
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.
- mcp_server_sofabaton-0.2.0.dist-info/METADATA +453 -0
- mcp_server_sofabaton-0.2.0.dist-info/RECORD +27 -0
- mcp_server_sofabaton-0.2.0.dist-info/WHEEL +5 -0
- mcp_server_sofabaton-0.2.0.dist-info/entry_points.txt +2 -0
- mcp_server_sofabaton-0.2.0.dist-info/licenses/LICENSE +21 -0
- mcp_server_sofabaton-0.2.0.dist-info/top_level.txt +1 -0
- sofabaton_mcp/__init__.py +5 -0
- sofabaton_mcp/api.py +235 -0
- sofabaton_mcp/assumptions.py +217 -0
- sofabaton_mcp/cli.py +326 -0
- sofabaton_mcp/client.py +790 -0
- sofabaton_mcp/config.py +233 -0
- sofabaton_mcp/doctor.py +349 -0
- sofabaton_mcp/limits.py +56 -0
- sofabaton_mcp/logsafe.py +83 -0
- sofabaton_mcp/mqtt.py +356 -0
- sofabaton_mcp/protocol.py +152 -0
- sofabaton_mcp/ratelimit.py +81 -0
- sofabaton_mcp/remote.py +364 -0
- sofabaton_mcp/server.py +232 -0
- sofabaton_mcp/sim/__init__.py +0 -0
- sofabaton_mcp/sim/broker.py +223 -0
- sofabaton_mcp/sim/fake_server.py +257 -0
- sofabaton_mcp/sim/fake_x2.py +213 -0
- sofabaton_mcp/sim/fixtures/x2_living_room.json +16 -0
- sofabaton_mcp/sim/hub_state.py +146 -0
- sofabaton_mcp/tasks.py +40 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-server-sofabaton
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP server for Sofabaton universal remote hubs: an X2 over its own MQTT link, or any model via sofabaton-x-server
|
|
5
|
+
Author: Nicholas E. Andrade
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SDNick484/mcp-server-sofabaton
|
|
8
|
+
Project-URL: Issues, https://github.com/SDNick484/mcp-server-sofabaton/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/SDNick484/mcp-server-sofabaton/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: mcp,mcp-server,sofabaton,universal-remote,home-automation,mqtt
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp<3,>=2.3
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: pyjwt[crypto]>=2.10
|
|
26
|
+
Requires-Dist: uvicorn>=0.31
|
|
27
|
+
Requires-Dist: typing_extensions>=4.7
|
|
28
|
+
Provides-Extra: mqtt
|
|
29
|
+
Requires-Dist: aiomqtt<3,>=2.3; extra == "mqtt"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: aiomqtt<3,>=2.3; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
33
|
+
Requires-Dist: anyio>=4; extra == "dev"
|
|
34
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
35
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
36
|
+
Requires-Dist: sofabaton-x<0.3,>=0.2.3; extra == "dev"
|
|
37
|
+
Requires-Dist: httpx2; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# mcp-server-sofabaton
|
|
41
|
+
|
|
42
|
+
[](https://github.com/SDNick484/mcp-server-sofabaton/actions/workflows/ci.yml)
|
|
43
|
+
|
|
44
|
+
An [MCP](https://modelcontextprotocol.io) server for **Sofabaton X1, X1S and X2** universal
|
|
45
|
+
remote hubs. It lets an MCP client such as Claude start and stop activities, press remote
|
|
46
|
+
buttons, send macros, favorites and device commands, find the remote, and read the presses of
|
|
47
|
+
buttons you've bound to it.
|
|
48
|
+
|
|
49
|
+
All three models work through [sofabaton-x-server](https://pypi.org/project/sofabaton-x-server/).
|
|
50
|
+
An **X2** can also be reached over **MQTT**, either on its own or alongside the server. That adds
|
|
51
|
+
live activity state, and keeps the hub controllable while the Sofabaton app is open. Every
|
|
52
|
+
response tells the model which model it's talking to and what that hub can and can't do.
|
|
53
|
+
|
|
54
|
+
> **Status: alpha, looking for testers.** Tested on one X2 over MQTT (see [Tested on](#tested-on)).
|
|
55
|
+
> The sofabaton-x-server path, and so every X1 and X1S, has run only against the simulator. If you
|
|
56
|
+
> have a Sofabaton hub, a report of what does and doesn't work on yours is the most useful thing
|
|
57
|
+
> you can send: [open a hardware report](https://github.com/SDNick484/mcp-server-sofabaton/issues/new?template=hardware_report.yml).
|
|
58
|
+
|
|
59
|
+
Not affiliated with Sofabaton.
|
|
60
|
+
|
|
61
|
+
## Tested on
|
|
62
|
+
|
|
63
|
+
| | |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| Hub | One Sofabaton **X2**, over **MQTT only** (no sofabaton-x-server). Firmware unknown: MQTT doesn't report it |
|
|
66
|
+
| Broker | Home Assistant's Mosquitto add-on |
|
|
67
|
+
| Server | Alpine Linux in a Proxmox LXC, Python 3.14, as the OpenRC service in `deploy/alpine/` |
|
|
68
|
+
| Client | Claude, over Streamable HTTP through a Cloudflare Tunnel with Cloudflare Access |
|
|
69
|
+
| Setup | 5 activities, 6 devices, 1 macro, 7 favorites (on the TV and on a Home Assistant Wifi Device) |
|
|
70
|
+
|
|
71
|
+
**Seen working on that X2 (2026-10-16):** listing activities, devices, buttons, macros and
|
|
72
|
+
favorites; starting an activity and powering it off, with the hub's confirmation; hard buttons
|
|
73
|
+
(`VOL_UP`); a macro; favorites on two different devices; holding presses until the power macro
|
|
74
|
+
has settled (about 5 s here, inside the 6 s window); and presses of a button bound to a Wifi
|
|
75
|
+
Device arriving on `<MAC>/up`.
|
|
76
|
+
|
|
77
|
+
**Seen not working, and handled:** the X2 never answers a request for a device's own command list
|
|
78
|
+
over MQTT, so device commands over MQTT are refused up front with what would enable them
|
|
79
|
+
(sofabaton-x-server). And a burst of a dozen or so unanswered requests left that X2 still
|
|
80
|
+
publishing state but answering nothing until it was power-cycled; nothing in the server sends
|
|
81
|
+
requests like that.
|
|
82
|
+
|
|
83
|
+
**Not tested on hardware yet:** the X1 and X1S, sofabaton-x-server with any hub, an X2 on both
|
|
84
|
+
paths at once, more than one hub, brokers other than Mosquitto, and other firmware. Each detail
|
|
85
|
+
the code relies on is a named assumption with its source and status in
|
|
86
|
+
[PROTOCOL.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/PROTOCOL.md); [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/HARDWARE_VALIDATION.md) is the
|
|
87
|
+
checklist for confirming them on your hub.
|
|
88
|
+
|
|
89
|
+
## What works with which hub
|
|
90
|
+
|
|
91
|
+
| | X1 / X1S | X2 + server | X2 + server + MQTT | X2, MQTT only |
|
|
92
|
+
| ------------------------------------------------ | ---------------- | --------------- | --------------------------- | ---------------- |
|
|
93
|
+
| Activities, buttons, macros, favorites | yes | yes | yes | yes |
|
|
94
|
+
| A device's own commands | yes | yes | yes (not while the app is open) | no: the X2 doesn't list them over MQTT |
|
|
95
|
+
| Find the remote; model and firmware | yes | yes | yes | no |
|
|
96
|
+
| Presses of buttons bound to a Wifi Device | yes (HTTP) | yes | yes | yes (MQTT) |
|
|
97
|
+
| Live activity state (pushed by the hub) | no | no | yes | yes |
|
|
98
|
+
| Control while the Sofabaton app is open | no | no | yes: falls back to MQTT | yes |
|
|
99
|
+
| Needs | server | server | server + broker | broker + the MAC |
|
|
100
|
+
|
|
101
|
+
**MQTT is X2-only.** The X1 and X1S have no MQTT; on those models, the server is the only way in
|
|
102
|
+
and everything in the first three rows works through it. `get_status` reports this as
|
|
103
|
+
`model`, `capabilities` and `limitations`, so the model can plan around it. For example, on an
|
|
104
|
+
X1S it says *"This is an X1S: live activity state, control while the Sofabaton app is open,
|
|
105
|
+
and MQTT presses need an X2."*
|
|
106
|
+
|
|
107
|
+
## How it reaches the hub
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
Claude ──► mcp-server-sofabaton ──┬──► sofabaton-x-server ──► hub (X1, X1S, X2)
|
|
111
|
+
(picks a path per call) └──► MQTT broker ◄────────► hub (X2 only)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **Through sofabaton-x-server (any model).** Sofabaton publishes no API. The
|
|
115
|
+
[sofabaton-x](https://pypi.org/project/sofabaton-x/) library reverse-engineers it by
|
|
116
|
+
sitting between the hub and the official app as a proxy, so one process owns the hub at a
|
|
117
|
+
time. sofabaton-x-server is that process, with a REST API. Going through it means many
|
|
118
|
+
clients can share the hub. It also means a model can only reach the control routes: editing,
|
|
119
|
+
erasing and restoring the hub need a token this project never holds.
|
|
120
|
+
- **Over MQTT (X2).** The X2 keeps its own connection to the broker you set in the Sofabaton
|
|
121
|
+
app (Me → Connect to Home Assistant). It answers list requests and takes commands on topics
|
|
122
|
+
under its MAC, and it announces every activity change. This doesn't go through the proxy, so
|
|
123
|
+
it keeps working while the app holds it.
|
|
124
|
+
|
|
125
|
+
With both configured, each call uses the server when it's in control mode and MQTT otherwise.
|
|
126
|
+
Names are resolved on whichever path is used, so ids never cross from one to the other.
|
|
127
|
+
|
|
128
|
+
## Install
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
python -m venv ~/.venvs/sofabaton && . ~/.venvs/sofabaton/bin/activate
|
|
132
|
+
pip install "mcp-server-sofabaton[mqtt]" # or without [mqtt] for server-only (X1/X1S, or an X2 without MQTT)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
From source instead (for the newest code, or to run the tests):
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
git clone https://github.com/SDNick484/mcp-server-sofabaton.git && cd mcp-server-sofabaton
|
|
139
|
+
python -m venv .venv && . .venv/bin/activate
|
|
140
|
+
pip install -e ".[mqtt,dev]" && pytest
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Requires Python 3.11+. The `[mqtt]` extra adds one dependency, `aiomqtt` (which brings
|
|
144
|
+
`paho-mqtt`).
|
|
145
|
+
|
|
146
|
+
**Claim sofabaton-x-server.** Open its control panel and set up an admin account (Server
|
|
147
|
+
settings > Access). Until you do, it lets anyone on the LAN edit and erase hubs. `doctor` warns
|
|
148
|
+
while it's unclaimed.
|
|
149
|
+
|
|
150
|
+
## Configuration
|
|
151
|
+
|
|
152
|
+
Environment variables (an MCP client may not pass your shell's environment, so set them in the
|
|
153
|
+
client's config):
|
|
154
|
+
|
|
155
|
+
| Setting | Default | What it does |
|
|
156
|
+
| ------------------------------- | ----------------------- | ------------------------------------------------------------------------------- |
|
|
157
|
+
| `SOFABATON_URL` | `http://localhost:8480` | sofabaton-x-server's address. `none` for MQTT only |
|
|
158
|
+
| `SOFABATON_HUB` | *(the only hub)* | Hub id or name, when the server manages more than one |
|
|
159
|
+
| `SOFABATON_MQTT_URL` | *(off)* | `mqtt://[user:pass@]host[:1883]` or `mqtts://...` (X2 only) |
|
|
160
|
+
| `SOFABATON_MQTT_USERNAME` | | If not in the URL |
|
|
161
|
+
| `SOFABATON_MQTT_PASSWORD` | | If not in the URL |
|
|
162
|
+
| `SOFABATON_MQTT_PASSWORD_FILE` | | Read the password from a file (wins over the others) |
|
|
163
|
+
| `SOFABATON_MQTT_MAC` | *(from the server)* | The X2's MAC. Needed for MQTT only; with a server it's learned |
|
|
164
|
+
| `SOFABATON_MQTT_SETTLE_S` | `6` | After an activity change over MQTT, hold presses off this long |
|
|
165
|
+
| `SOFABATON_DRY_RUN` | off | Read the hub, send nothing that changes anything (`serve --dry-run` too) |
|
|
166
|
+
| `SOFABATON_LOG_UNREDACTED` | off | Don't mask IPs and MACs in logs (`--no-redact`); passwords and other secrets stay masked |
|
|
167
|
+
|
|
168
|
+
There is deliberately no sofabaton-x-server token setting. Bad values are reported by name at
|
|
169
|
+
startup and by `doctor`, rather than failing later.
|
|
170
|
+
|
|
171
|
+
Typical setups:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
# X1 / X1S, or an X2 without MQTT
|
|
175
|
+
SOFABATON_URL=http://192.168.1.70:8480
|
|
176
|
+
|
|
177
|
+
# X2, both paths (recommended)
|
|
178
|
+
SOFABATON_URL=http://192.168.1.70:8480
|
|
179
|
+
SOFABATON_MQTT_URL=mqtt://sofabaton@192.168.1.10:1883
|
|
180
|
+
SOFABATON_MQTT_PASSWORD_FILE=/etc/mcp/sofabaton-mqtt.pw
|
|
181
|
+
|
|
182
|
+
# X2, MQTT only (no sofabaton-x-server)
|
|
183
|
+
SOFABATON_URL=none
|
|
184
|
+
SOFABATON_MQTT_URL=mqtt://sofabaton:secret@192.168.1.10:1883
|
|
185
|
+
SOFABATON_MQTT_MAC=02:AB:34:CD:56:EF
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## First contact
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
mcp-server-sofabaton doctor # each layer, with a hint for whatever fails
|
|
192
|
+
mcp-server-sofabaton check # the model, the path, capabilities, and the names the model will use
|
|
193
|
+
mcp-server-sofabaton call start_activity activity="Watch Shield" # one tool, as the model calls it
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`doctor` checks the server (reachable, version, claimed, hub session, mode, identity,
|
|
197
|
+
catalog) and MQTT (login, the X2 answering on its topics). If the X2 doesn't answer on the
|
|
198
|
+
uppercase-MAC topics, it tries lowercase and tells you. `doctor --listen 30` prints everything
|
|
199
|
+
the X2 publishes while you press keys on the remote. `doctor --dump DIR` saves a redacted
|
|
200
|
+
capture for `tests/fixtures/recorded/`.
|
|
201
|
+
|
|
202
|
+
## Use it with an MCP client
|
|
203
|
+
|
|
204
|
+
Local (stdio): Claude Code and Claude Desktop launch it themselves.
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"mcpServers": {
|
|
209
|
+
"sofabaton": {
|
|
210
|
+
"command": "/path/to/mcp-server-sofabaton/.venv/bin/mcp-server-sofabaton",
|
|
211
|
+
"env": {
|
|
212
|
+
"SOFABATON_URL": "http://192.168.1.70:8480",
|
|
213
|
+
"SOFABATON_MQTT_URL": "mqtt://sofabaton:secret@192.168.1.10:1883"
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
For Claude Code: `claude mcp add sofabaton -e SOFABATON_URL=http://192.168.1.70:8480 -- /path/to/.venv/bin/mcp-server-sofabaton`.
|
|
221
|
+
|
|
222
|
+
> [!WARNING]
|
|
223
|
+
> **This server turns your devices on and off. Don't put it on a network you don't trust without
|
|
224
|
+
> authentication in front of it.** Without Cloudflare Access configured, the HTTP server serves
|
|
225
|
+
> only requests that start on this machine: it won't listen on anything but `127.0.0.1`, won't
|
|
226
|
+
> start with a `--public-host`, and answers 403 to any request a tunnel or proxy forwarded
|
|
227
|
+
> (`Cf-Connecting-IP`, `X-Forwarded-For`, ...). `--insecure-no-auth` turns those checks off for a
|
|
228
|
+
> trusted-LAN test. With a tunnel, set `CF_ACCESS_TEAM_DOMAIN` and `CF_ACCESS_AUD` so the server
|
|
229
|
+
> checks Access's signed assertion on every request itself.
|
|
230
|
+
|
|
231
|
+
As a service (Streamable HTTP), to share one instance between Claude Code, Claude Desktop and
|
|
232
|
+
the mobile app through a Cloudflare Tunnel with Cloudflare Access in front:
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com CF_ACCESS_AUD=<aud tag> \
|
|
236
|
+
mcp-server-sofabaton serve --http --public-host mcp.example.com # 127.0.0.1:8714/sofabaton/mcp
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Every request must carry a valid Access JWT (`Cf-Access-Jwt-Assertion`). The checks and the
|
|
240
|
+
flags are documented in `src/sofabaton_mcp/remote.py`. The Host-header (DNS-rebinding) checks stay on
|
|
241
|
+
even with `--bind 0.0.0.0`: for LAN clients, also pass `--public-host <this machine's LAN IP>`.
|
|
242
|
+
|
|
243
|
+
### As an OpenRC service on Alpine (e.g. a Proxmox LXC)
|
|
244
|
+
|
|
245
|
+
`deploy/alpine/` installs it the same way as mcp-server-webos and -harmony, so all three can share
|
|
246
|
+
an LXC: a venv in `/opt/mcp-server-sofabaton` (with the `[mqtt]` extra), a `mcp-sofabaton` system
|
|
247
|
+
user, every setting in `/etc/mcp-server-sofabaton/env`, and `supervise-daemon` restarting it if
|
|
248
|
+
it exits. Keep the broker password out of the env file: put it in
|
|
249
|
+
`/etc/mcp-server-sofabaton/mqtt-password` (`0640 root:mcp-sofabaton`) and set
|
|
250
|
+
`SOFABATON_MQTT_PASSWORD_FILE` to that path.
|
|
251
|
+
|
|
252
|
+
```sh
|
|
253
|
+
git clone https://github.com/SDNick484/mcp-server-sofabaton.git && cd mcp-server-sofabaton
|
|
254
|
+
sh deploy/alpine/install.sh # as root; re-run to upgrade
|
|
255
|
+
vim /etc/mcp-server-sofabaton/env # SOFABATON_URL and/or SOFABATON_MQTT_*, Access
|
|
256
|
+
su -s /bin/sh mcp-sofabaton -c '/opt/mcp-server-sofabaton/run.sh doctor'
|
|
257
|
+
rc-update add mcp-server-sofabaton default && rc-service mcp-server-sofabaton start
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Because every setting is an environment variable, `run.sh` takes a subcommand and runs it with
|
|
261
|
+
exactly the service's environment: `run.sh doctor`, `run.sh check`, `run.sh call get_status`.
|
|
262
|
+
Logs are in `/var/log/mcp-server-sofabaton/server.log`. CI runs `deploy/alpine/smoke-test.sh` in a
|
|
263
|
+
stock `python:3.12-alpine` container: install, the test suite on musl, `run.sh doctor`, the
|
|
264
|
+
service under OpenRC, and an MCP call over HTTP reaching a simulated X2 on both paths.
|
|
265
|
+
|
|
266
|
+
## Tools
|
|
267
|
+
|
|
268
|
+
| Tool | What it does |
|
|
269
|
+
| -------------------- | ----------------------------------------------------------------------------------------------- |
|
|
270
|
+
| `get_status` | Start here: model, running activity, `via` (server or MQTT), `capabilities`, `limitations`, `transition` |
|
|
271
|
+
| `list_activities` | Activities, marking the running one |
|
|
272
|
+
| `list_devices` | Devices (brand and class over the server; names only over MQTT) |
|
|
273
|
+
| `list_commands` | Named commands for an activity (macros, favorites) or a device (default: the running activity) |
|
|
274
|
+
| `start_activity` | Start an activity and wait for the hub to confirm; `unchanged` if it's already running |
|
|
275
|
+
| `power_off` | Power off the running activity and confirm |
|
|
276
|
+
| `press_button` | A remote hard button (`VOL_UP`, `PAUSE`, `OK`, ...) 1-10 times, through the running activity |
|
|
277
|
+
| `send_command` | A macro, favorite or device command by name, 1-10 times |
|
|
278
|
+
| `find_remote` | Make the remote beep (needs the server) |
|
|
279
|
+
| `get_recent_presses` | Presses of remote buttons bound to a Wifi Device: the remote as an input to the agent |
|
|
280
|
+
|
|
281
|
+
Actions return `{outcome: done | unchanged | dry_run, detail, via, sent}`. `sent` lists the
|
|
282
|
+
exact requests or publishes, with the MAC shown as `<MAC>`. When something can't work on this
|
|
283
|
+
hub, the error says why and what would enable it.
|
|
284
|
+
|
|
285
|
+
### A session against the simulator
|
|
286
|
+
|
|
287
|
+
An X2 with both paths. Someone opens the Sofabaton app partway through, and the server
|
|
288
|
+
switches to MQTT. (Output from `simulate`, trimmed.)
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
> get_status
|
|
292
|
+
{"model": "X2", "via": "server", "running_activity": null, "transition": null,
|
|
293
|
+
"capabilities": ["catalog", "activities", "buttons", "commands", "presses", "find_remote",
|
|
294
|
+
"hub_info", "live_activity_state", "control_while_app_open"],
|
|
295
|
+
"limitations": [], ...}
|
|
296
|
+
|
|
297
|
+
> start_activity {"activity": "Watch Shield"}
|
|
298
|
+
{"outcome": "done", "detail": "Start Watch Shield", "via": "server",
|
|
299
|
+
"sent": ["POST /api/v1/hubs/a1b2c3/activities/101/start"]}
|
|
300
|
+
|
|
301
|
+
> press_button {"button": "VOL_UP", "repeat": 3}
|
|
302
|
+
{"outcome": "done", "detail": "Press VOL_UP x3 on activity Watch Shield", "via": "server", ...}
|
|
303
|
+
|
|
304
|
+
# (someone opens the Sofabaton app: sofabaton-x-server goes to observe mode)
|
|
305
|
+
|
|
306
|
+
> start_activity {"activity": "Listen to Music"}
|
|
307
|
+
{"outcome": "done", "via": "mqtt",
|
|
308
|
+
"detail": "Start Listen to Music. The X2 announced it early; its power macro may run for a
|
|
309
|
+
few more seconds (presses wait 6s).",
|
|
310
|
+
"sent": ["publish activity/<MAC>/activity_control_down {\"data\": {\"activity_id\": 102, \"state\": \"on\"}}"]}
|
|
311
|
+
|
|
312
|
+
> send_command {"command": "Input HDMI 2", "target": "Onkyo Receiver"}
|
|
313
|
+
ERROR: The X2 just changed activity and its power macro may still be running; a press now
|
|
314
|
+
could interrupt it. Try again in 5s (SOFABATON_MQTT_SETTLE_S sets this window).
|
|
315
|
+
|
|
316
|
+
> get_status
|
|
317
|
+
{"model": "X2", "via": "mqtt", "running_activity": "Listen to Music",
|
|
318
|
+
"transition": "power macro may still be running (presses wait 5s more)",
|
|
319
|
+
"capabilities": ["catalog", "activities", "buttons", "commands", "presses", "hub_info",
|
|
320
|
+
"live_activity_state", "control_while_app_open"],
|
|
321
|
+
"limitations": ["The Sofabaton app holds sofabaton-x-server's proxy (observe mode): commands
|
|
322
|
+
go over MQTT meanwhile, and find_remote waits until the app is closed."], ...}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The same calls on an X1S go through the server, and `limitations` says the MQTT features need
|
|
326
|
+
an X2.
|
|
327
|
+
|
|
328
|
+
## The remote as an input
|
|
329
|
+
|
|
330
|
+
A "Wifi Device" on the hub is a virtual device whose commands do nothing but report the press.
|
|
331
|
+
On the X2 it can report to `<MAC>/up` on your broker, and on any model it can report over HTTP
|
|
332
|
+
to sofabaton-x-server. Bind one to a key (say, a long press of a color button) and
|
|
333
|
+
`get_recent_presses` sees it. A remote button can then mean "ask the assistant" or "run my
|
|
334
|
+
bedtime routine" to an agent that watches for it. Wifi Devices are set up in the Sofabaton app
|
|
335
|
+
or the server's control panel: that's editing the hub, which this server doesn't do.
|
|
336
|
+
|
|
337
|
+
## Safety design
|
|
338
|
+
|
|
339
|
+
- **No token, so no edits.** sofabaton-x-server lets anyone read and control, and requires a
|
|
340
|
+
token for editing, deleting, erasing and restoring. This project never holds one.
|
|
341
|
+
- **A local route allow-list too.** The REST client can only make GETs and the four control
|
|
342
|
+
POSTs (`send`, activity `start`/`stop`, `find-remote`). Anything else raises before a request
|
|
343
|
+
leaves the process. `test_openapi_contract.py` checks that the allow-list matches exactly
|
|
344
|
+
those routes in the server's published API.
|
|
345
|
+
- **MQTT topics are fixed.** Only the topics in `protocol.py` are ever published to, with
|
|
346
|
+
payloads built from names resolved against the hub's own lists.
|
|
347
|
+
- **Names, not ids.** The model never supplies an id. Buttons are an enum. `POWER_ON` and
|
|
348
|
+
`POWER_OFF` are left out, so starting and stopping always go through the tools that confirm.
|
|
349
|
+
- **Confirmation, not trust.** `accepted: true` means the hub took the frame. Starts and stops
|
|
350
|
+
wait for the hub to report the change.
|
|
351
|
+
- **Don't interrupt a macro.** Over MQTT the X2 announces a change at the *start* of its power
|
|
352
|
+
macro and never says when it's done, so presses wait out a settle window.
|
|
353
|
+
- **Rate limits and caps.** Per call: at most 10 repeats, 100-2000 ms apart. Across calls (a
|
|
354
|
+
model in a loop): 20 presses in a burst, then 4 a second; 4 activity changes in a burst, then
|
|
355
|
+
one per 15 s. A call over the limit is refused whole, never half-sent.
|
|
356
|
+
- **Dry run.** `serve --dry-run` (or `call --dry-run`) reads the hub and reports what it would
|
|
357
|
+
send.
|
|
358
|
+
- **Logs are redacted**: IP addresses and MACs (unless `--no-redact`), and always secrets (passwords
|
|
359
|
+
in URLs, JWTs, bearer tokens, keys). They go to stderr; stdout is the
|
|
360
|
+
MCP transport.
|
|
361
|
+
- Every tool has a title and explicit MCP annotations. Nothing is destructive.
|
|
362
|
+
|
|
363
|
+
## Troubleshooting
|
|
364
|
+
|
|
365
|
+
| Message | Meaning |
|
|
366
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
367
|
+
| `Can't reach sofabaton-x-server at ...` | It isn't running, or `SOFABATON_URL` is wrong for the environment the client launched us in |
|
|
368
|
+
| `... close the app on every phone and tablet` | The app holds the proxy (observe mode). On an X2, MQTT would keep it controllable |
|
|
369
|
+
| `has no session with the hub right now` | The hub is off the network, or its IP changed. Check the server's control panel |
|
|
370
|
+
| `didn't report it finished within 30s` | A long power macro, or a device didn't respond. Check `get_status` before retrying |
|
|
371
|
+
| `The X2 didn't answer the ... request over MQTT` | The X2 isn't linked to this broker, or the MAC is wrong. Run `doctor` |
|
|
372
|
+
| `the MQTT broker at ... rejected the login` | Wrong user or password in `SOFABATON_MQTT_URL` |
|
|
373
|
+
| `power macro may still be running` | Expected for a few seconds after a change over MQTT |
|
|
374
|
+
| `Rate limit: refusing to ...` | Many presses in a short time. Wait and retry if it was deliberate |
|
|
375
|
+
|
|
376
|
+
## Development
|
|
377
|
+
|
|
378
|
+
```sh
|
|
379
|
+
pip install -e ".[dev]"
|
|
380
|
+
pytest -q # everything, no hardware: fakes for the server, the broker and the X2
|
|
381
|
+
ruff check . && ruff format --check . && mypy
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The simulator runs the same fakes as real services, so you can point a client (or `doctor`)
|
|
385
|
+
at them:
|
|
386
|
+
|
|
387
|
+
```sh
|
|
388
|
+
mcp-server-sofabaton simulate # X2: fake server + broker + X2; type `help` at sim>
|
|
389
|
+
mcp-server-sofabaton simulate --model X1S # server only
|
|
390
|
+
mcp-server-sofabaton simulate --no-server # X2 over MQTT only
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
It prints the `export` lines for another terminal. At its `sim>` prompt you act as the
|
|
394
|
+
physical remote (`off`, `start <activity>`, `key <device> <key>`) or change the scene
|
|
395
|
+
(`app on` puts the server in observe mode).
|
|
396
|
+
|
|
397
|
+
The MQTT tests use an in-package broker by default. To run them against mosquitto, as CI does:
|
|
398
|
+
|
|
399
|
+
```sh
|
|
400
|
+
docker compose -f dev/compose.yaml up -d
|
|
401
|
+
MQTT_TEST_BROKER=mqtt://127.0.0.1:1883 pytest -q tests/test_mqtt.py tests/test_tools.py tests/test_tooling.py -rs
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
What the tests cover:
|
|
405
|
+
|
|
406
|
+
| File | Covers |
|
|
407
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
408
|
+
| `test_openapi_contract.py` | Our routes, types and the fake's payloads against the OpenAPI document recorded from sofabaton-x-server 0.2.4 |
|
|
409
|
+
| `test_client.py` | The server path for every model, capabilities, confirmation |
|
|
410
|
+
| `test_mqtt.py` | The X2 over MQTT, alone and with the server, against a real broker over sockets |
|
|
411
|
+
| `test_tools.py` | The MCP contract the model sees (schemas, annotations, structured results) |
|
|
412
|
+
| `test_safety.py` | Dry run, rate limits, caps, config validation, redaction |
|
|
413
|
+
| `test_tooling.py` | `doctor`, `check`, `call` and `simulate`, including as real processes |
|
|
414
|
+
| `test_recorded.py` | Replays captures from a real hub (`doctor --dump`) through the real parsers |
|
|
415
|
+
| `test_assumptions.py` | Every assumption appears here and in HARDWARE_VALIDATION.md |
|
|
416
|
+
|
|
417
|
+
## Verification status
|
|
418
|
+
|
|
419
|
+
These are the protocol assumptions the code depends on. `hardware-verified` means seen on a real
|
|
420
|
+
hub; the rest are verified against the simulator only (details and sources in [PROTOCOL.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/PROTOCOL.md)):
|
|
421
|
+
|
|
422
|
+
| Assumption | Confidence | Status |
|
|
423
|
+
| ---------------------- | ---------- | -------------- |
|
|
424
|
+
| `S-REST-API` | high | simulator-only |
|
|
425
|
+
| `S-REST-MODES` | medium | simulator-only |
|
|
426
|
+
| `S-REST-START` | low | simulator-only |
|
|
427
|
+
| `S-X1-NO-MQTT` | high | simulator-only |
|
|
428
|
+
| `S-MQTT-UP` | high | hardware-verified |
|
|
429
|
+
| `S-MQTT-STATE` | high | hardware-verified |
|
|
430
|
+
| `S-MQTT-MAC-CASE` | medium | hardware-verified |
|
|
431
|
+
| `S-MQTT-CONTROL` | medium | hardware-verified |
|
|
432
|
+
| `S-MQTT-KEYS` | medium | hardware-verified |
|
|
433
|
+
| `S-MQTT-MACRO` | medium | hardware-verified |
|
|
434
|
+
| `S-MQTT-FAVORITE` | low | hardware-verified |
|
|
435
|
+
| `S-MQTT-DEVICE` | high | simulator-only |
|
|
436
|
+
| `S-MQTT-LISTS` | medium | hardware-verified |
|
|
437
|
+
| `S-MQTT-DEVICE-KEYS` | high | hardware-verified |
|
|
438
|
+
| `S-MQTT-IDS` | low | simulator-only |
|
|
439
|
+
| `S-MQTT-SERIAL` | medium | simulator-only |
|
|
440
|
+
| `S-MQTT-REPLY-TIMEOUT` | low | hardware-verified |
|
|
441
|
+
| `S-MQTT-SETTLE` | low | hardware-verified |
|
|
442
|
+
|
|
443
|
+
`test_assumptions.py` fails if this table and `assumptions.py` disagree.
|
|
444
|
+
|
|
445
|
+
## Roadmap
|
|
446
|
+
|
|
447
|
+
- Validate on hardware ([HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/HARDWARE_VALIDATION.md))
|
|
448
|
+
- Push instead of poll: surface activity changes and presses as MCP resource updates
|
|
449
|
+
- Publish to PyPI and the MCP registry
|
|
450
|
+
|
|
451
|
+
## License
|
|
452
|
+
|
|
453
|
+
MIT. See [LICENSE](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
mcp_server_sofabaton-0.2.0.dist-info/licenses/LICENSE,sha256=UaFe7iKAtdvNfrhH4lJ-v0InFSbjZGMZ4dLXeVf7o8g,1076
|
|
2
|
+
sofabaton_mcp/__init__.py,sha256=_IPko7pPOMUMNfbhS85pbu8SphUVcwpTo3iIiY0xzTk,322
|
|
3
|
+
sofabaton_mcp/api.py,sha256=Luher23kSKnYvhtbYknrbRuQ7QhooDrRccUcFeLsxEs,8283
|
|
4
|
+
sofabaton_mcp/assumptions.py,sha256=8bIPJWfDtrro1jXklg4w6NqYRCvP1tnK-33VpnKRkZE,10682
|
|
5
|
+
sofabaton_mcp/cli.py,sha256=cN1iMEObU5qMZ6tH2iJAXGNHunElSMqoWVUCDYhjUD0,15118
|
|
6
|
+
sofabaton_mcp/client.py,sha256=MorFFJSiXlTayWRdFzgiKzwLvPACduOwi6vgfDC6xMI,36763
|
|
7
|
+
sofabaton_mcp/config.py,sha256=bV-HKR9qoJ_HE40oo3PX8HIH3_T-I0ZxK_4d45BMYwg,7448
|
|
8
|
+
sofabaton_mcp/doctor.py,sha256=EQKa6It7Gk9mzmpNGw4DuFgMJ1gjRifkODYKMuMj5PU,14260
|
|
9
|
+
sofabaton_mcp/limits.py,sha256=m5mifBWeACapiNGf-czDlRLl5xENmFb4KJvPEypfNfk,2003
|
|
10
|
+
sofabaton_mcp/logsafe.py,sha256=4Fu1xvgQNjGPmUnxvl-pbPcAJmbVN-oCvmC8LOWyHoQ,3664
|
|
11
|
+
sofabaton_mcp/mqtt.py,sha256=i70I0GFy1aNQvNZmIA6JE30PxcBFKciVlzZKpEPrw1A,15481
|
|
12
|
+
sofabaton_mcp/protocol.py,sha256=9RaV0g2tLfHk0q7cbylbDx8z15RO_EJclg3sXuv1kQg,6497
|
|
13
|
+
sofabaton_mcp/ratelimit.py,sha256=71p4qF2UQCI_yU0tzcMdCpbXv_Em7aq8sMmc4b_tyt4,3121
|
|
14
|
+
sofabaton_mcp/remote.py,sha256=F9mC_HXxDAJknz2WtGQyOjZp3psZXEiSlFcPjAIe7Ic,16420
|
|
15
|
+
sofabaton_mcp/server.py,sha256=guqgylGmAKfKvq2YmR9q-6joS-sTOImwM1_C-M4KkRY,9772
|
|
16
|
+
sofabaton_mcp/tasks.py,sha256=ITGFDbl2wIfUwqht_FJMWQavqjDWUiKwuqYDEaYs1ss,1716
|
|
17
|
+
sofabaton_mcp/sim/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
sofabaton_mcp/sim/broker.py,sha256=MfBgwrgE3X70crvzn0mroosJPY-wZme9zSwsPGyZjlU,8721
|
|
19
|
+
sofabaton_mcp/sim/fake_server.py,sha256=moenZNIJLspxLtCDPavYXgtdazz_Xx6I6sMUk8jka70,12071
|
|
20
|
+
sofabaton_mcp/sim/fake_x2.py,sha256=K1NasgfED6TwHCwXOEhzLDIhDzBoRFeerH7w1WLc4FA,9846
|
|
21
|
+
sofabaton_mcp/sim/hub_state.py,sha256=nP-wk40BpphSJPNqAULEpZJr1pk10k9txuXFz_SaOvs,5611
|
|
22
|
+
sofabaton_mcp/sim/fixtures/x2_living_room.json,sha256=n5uMfypT0ws07V-zB1YSKjMpEaJfytt4vODaX0AxYag,1079
|
|
23
|
+
mcp_server_sofabaton-0.2.0.dist-info/METADATA,sha256=UxITPC580fTC4j6g4SuXGnnx_m4QX1T4k2EL3SHNxIE,27067
|
|
24
|
+
mcp_server_sofabaton-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
25
|
+
mcp_server_sofabaton-0.2.0.dist-info/entry_points.txt,sha256=2QtEzxj3iTjL7u7qxWvIN9HKgS5A4wcbWrmrJ3UtbZY,64
|
|
26
|
+
mcp_server_sofabaton-0.2.0.dist-info/top_level.txt,sha256=5uyCsoA7YtitVoA7chzw6YpRvdKp94Kxo0PlqWfNvOs,14
|
|
27
|
+
mcp_server_sofabaton-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicholas E. Andrade
|
|
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 @@
|
|
|
1
|
+
sofabaton_mcp
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""MCP server for Sofabaton X1/X1S/X2 hubs: over an X2's own MQTT link, or through sofabaton-x-server."""
|
|
2
|
+
|
|
3
|
+
# The one place the version lives: pyproject.toml reads it from here. Bump it for every release, since pip
|
|
4
|
+
# keeps an installed copy whose version matches, even when the code under it changed.
|
|
5
|
+
__version__ = "0.2.0"
|