mcp-server-batocera 0.1.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.
Files changed (44) hide show
  1. mcp_server_batocera-0.1.0/LICENSE +21 -0
  2. mcp_server_batocera-0.1.0/PKG-INFO +422 -0
  3. mcp_server_batocera-0.1.0/README.md +388 -0
  4. mcp_server_batocera-0.1.0/pyproject.toml +64 -0
  5. mcp_server_batocera-0.1.0/setup.cfg +4 -0
  6. mcp_server_batocera-0.1.0/src/batocera_mcp/__init__.py +5 -0
  7. mcp_server_batocera-0.1.0/src/batocera_mcp/assumptions.py +299 -0
  8. mcp_server_batocera-0.1.0/src/batocera_mcp/cli.py +176 -0
  9. mcp_server_batocera-0.1.0/src/batocera_mcp/client.py +325 -0
  10. mcp_server_batocera-0.1.0/src/batocera_mcp/config.py +158 -0
  11. mcp_server_batocera-0.1.0/src/batocera_mcp/doctor.py +190 -0
  12. mcp_server_batocera-0.1.0/src/batocera_mcp/logsafe.py +309 -0
  13. mcp_server_batocera-0.1.0/src/batocera_mcp/lookup.py +60 -0
  14. mcp_server_batocera-0.1.0/src/batocera_mcp/probe.py +133 -0
  15. mcp_server_batocera-0.1.0/src/batocera_mcp/ratelimit.py +226 -0
  16. mcp_server_batocera-0.1.0/src/batocera_mcp/registry.py +141 -0
  17. mcp_server_batocera-0.1.0/src/batocera_mcp/remote.py +516 -0
  18. mcp_server_batocera-0.1.0/src/batocera_mcp/retroarch.py +143 -0
  19. mcp_server_batocera-0.1.0/src/batocera_mcp/server.py +796 -0
  20. mcp_server_batocera-0.1.0/src/batocera_mcp/sim/__init__.py +1 -0
  21. mcp_server_batocera-0.1.0/src/batocera_mcp/sim/fake_cabinet.py +425 -0
  22. mcp_server_batocera-0.1.0/src/batocera_mcp/tasks.py +174 -0
  23. mcp_server_batocera-0.1.0/src/batocera_mcp/tools.py +29 -0
  24. mcp_server_batocera-0.1.0/src/mcp_server_batocera.egg-info/PKG-INFO +422 -0
  25. mcp_server_batocera-0.1.0/src/mcp_server_batocera.egg-info/SOURCES.txt +42 -0
  26. mcp_server_batocera-0.1.0/src/mcp_server_batocera.egg-info/dependency_links.txt +1 -0
  27. mcp_server_batocera-0.1.0/src/mcp_server_batocera.egg-info/entry_points.txt +2 -0
  28. mcp_server_batocera-0.1.0/src/mcp_server_batocera.egg-info/requires.txt +11 -0
  29. mcp_server_batocera-0.1.0/src/mcp_server_batocera.egg-info/top_level.txt +1 -0
  30. mcp_server_batocera-0.1.0/tests/test_assumptions.py +86 -0
  31. mcp_server_batocera-0.1.0/tests/test_cli.py +148 -0
  32. mcp_server_batocera-0.1.0/tests/test_http_tools.py +124 -0
  33. mcp_server_batocera-0.1.0/tests/test_logsafe.py +521 -0
  34. mcp_server_batocera-0.1.0/tests/test_lookup.py +41 -0
  35. mcp_server_batocera-0.1.0/tests/test_playtracker.py +561 -0
  36. mcp_server_batocera-0.1.0/tests/test_probe.py +68 -0
  37. mcp_server_batocera-0.1.0/tests/test_ratelimit.py +382 -0
  38. mcp_server_batocera-0.1.0/tests/test_registry.py +175 -0
  39. mcp_server_batocera-0.1.0/tests/test_remote.py +571 -0
  40. mcp_server_batocera-0.1.0/tests/test_stdio.py +25 -0
  41. mcp_server_batocera-0.1.0/tests/test_tasks.py +425 -0
  42. mcp_server_batocera-0.1.0/tests/test_tool_annotations.py +18 -0
  43. mcp_server_batocera-0.1.0/tests/test_tools.py +865 -0
  44. mcp_server_batocera-0.1.0/tests/test_units.py +200 -0
@@ -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,422 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-batocera
3
+ Version: 0.1.0
4
+ Summary: MCP server for Batocera retro-gaming cabinets, via the EmulationStation web API and RetroArch network commands
5
+ Author: Nicholas E. Andrade
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/SDNick484/mcp-server-batocera
8
+ Project-URL: Issues, https://github.com/SDNick484/mcp-server-batocera/issues
9
+ Project-URL: Changelog, https://github.com/SDNick484/mcp-server-batocera/blob/main/CHANGELOG.md
10
+ Keywords: mcp,mcp-server,batocera,emulationstation,retroarch,arcade,home-assistant
11
+ Classifier: Development Status :: 2 - Pre-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 :: Games/Entertainment :: Arcade
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: dev
29
+ Requires-Dist: pytest>=8; extra == "dev"
30
+ Requires-Dist: anyio>=4; extra == "dev"
31
+ Requires-Dist: ruff>=0.6; extra == "dev"
32
+ Requires-Dist: mypy>=1.11; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # mcp-server-batocera
36
+
37
+ An [MCP](https://modelcontextprotocol.io) server for a **[Batocera](https://batocera.org)** retro-gaming
38
+ cabinet. It lets an MCP client such as Claude browse the game library, see what's playing, launch and
39
+ quit games, put messages on screen, and, while a RetroArch game runs, pause, save or load state, take
40
+ screenshots and reset.
41
+
42
+ The repo also includes **playtracker**, a small hook script for the cabinet that publishes what's being
43
+ played to MQTT with Home Assistant discovery, plus what the cabinet is doing (screensaver, menu, playing,
44
+ off) for automations. It doesn't log screensaver activity.
45
+
46
+ > **Status: early, partly tested on hardware.** Built from Batocera's EmulationStation and RetroArch sources
47
+ > and tested against a wire-level simulator; playtracker is also tested against a real Mosquitto broker.
48
+ > On a real cabinet (Batocera 43), every MCP tool has been checked: `doctor`, the library, launching and
49
+ > quitting, messages, RetroArch and dry run, and playtracker with Home Assistant (sessions, the short-session
50
+ > filter, broker outages, and ignoring the screensaver). playtracker's cabinet events (Screensaver, Activity,
51
+ > session start, Cabinet) aren't checked on hardware yet. See [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-batocera/blob/main/HARDWARE_VALIDATION.md) and the
52
+ > [verification table](#verification-status).
53
+
54
+ This is not an official Batocera project.
55
+
56
+ ## How it works
57
+
58
+ ```
59
+ MCP client ──stdio──▶ mcp-server-batocera ──HTTP :1234──▶ EmulationStation web API ─┐
60
+ └──UDP :55355──▶ RetroArch (while a game runs) ├─ Batocera cabinet
61
+ Home Assistant ◀──MQTT :1883── playtracker.py ◀── EmulationStation event scripts ──────┘
62
+ ```
63
+
64
+ - **EmulationStation web API** (TCP 1234) runs inside the frontend, so it keeps answering during a game.
65
+ It covers the game library, the running game, launching, quitting and on-screen notifications.
66
+ - **RetroArch network commands** (UDP 55355) work only while a libretro core is running. They cover
67
+ pause, save/load state, screenshots, reset and in-game messages.
68
+ - **playtracker** runs on the cabinet on every EmulationStation event and publishes to Home Assistant's
69
+ Mosquitto broker. Nothing extra has to run on Batocera; it uses only Python's standard library.
70
+
71
+ ## Remote access: stdio, or HTTP behind Cloudflare Access
72
+
73
+ There are two ways to run it:
74
+
75
+ - **stdio**: Claude Code or Claude Desktop launches it on a machine on the cabinet's LAN.
76
+ - **`serve --http`**: Streamable HTTP on `127.0.0.1:8717/batocera/mcp`, as a long-lived service (see
77
+ [Service and Cloudflare](#service-and-cloudflare)). Published through a Cloudflare Tunnel with Cloudflare
78
+ Access in front, it's one URL you add as a connector in Claude Code, Claude Desktop and the mobile app.
79
+
80
+ The HTTP transport is `remote.py`, shared byte for byte with the AV siblings (Onkyo, Shield TV, Harmony,
81
+ Sofabaton, webOS) through [mcp-server-shared](https://github.com/SDNick484/mcp-server-shared). The cabinet's
82
+ own APIs have **no authentication**: EmulationStation's web API and RetroArch's network commands trust
83
+ anything that can reach them. So the server is the gate, and it doesn't rely on Cloudflare's edge alone:
84
+
85
+ - **With Access configured, every request needs a valid Access assertion**, checked here: signature
86
+ (Access's published keys), the application's audience tag, issuer, expiry, and `MCP_ALLOWED_EMAILS` if
87
+ set. Anything else gets a plain 403, so a request that reaches the port from the LAN, around the tunnel,
88
+ is refused too.
89
+ - **Without Access, it serves only the machine it runs on.** It won't listen on anything but loopback,
90
+ won't start with a `--public-host`, and answers 403 to anything a tunnel or proxy forwarded.
91
+ `--insecure-no-auth` turns that off for a short trusted-LAN test; don't run the service with it.
92
+ - **Never expose ports 1234 or 55355 themselves.** Only this server's port goes through the tunnel.
93
+
94
+ ## Cabinet setup
95
+
96
+ 1. **Turn on public web access.** By default the API only answers the cabinet itself. Go to *Main Menu >
97
+ System Settings > Developer > Enable public web access*, then reboot.
98
+ 2. **Optional, for the RetroArch tools:** SSH in (`root` / `linux`) and add this line to
99
+ `/userdata/system/batocera.conf`. It takes effect on the next game launch.
100
+ ```
101
+ global.retroarch.network_cmd_enable=true
102
+ ```
103
+ 3. Give the cabinet a fixed IP (a DHCP reservation) and use the IP rather than `batocera.local`.
104
+
105
+ Neither the API nor RetroArch's port has authentication; anything on the LAN can already use them. Keep
106
+ them off the internet.
107
+
108
+ ## Install the MCP server
109
+
110
+ ```sh
111
+ python -m venv ~/.venvs/batocera && . ~/.venvs/batocera/bin/activate
112
+ pip install mcp-server-batocera
113
+ BATOCERA_URL=192.168.1.20 mcp-server-batocera doctor
114
+ ```
115
+
116
+ From source instead (for the newest code, or to run the tests):
117
+
118
+ ```sh
119
+ git clone https://github.com/SDNick484/mcp-server-batocera.git && cd mcp-server-batocera
120
+ python -m venv .venv && . .venv/bin/activate
121
+ pip install -e ".[dev]" && pytest
122
+ ```
123
+
124
+ `doctor` checks each layer between this machine and the cabinet (TCP, the API, the library, the
125
+ running game, RetroArch), and when one fails, says what it means and which
126
+ [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-batocera/blob/main/HARDWARE_VALIDATION.md) step covers it. It changes nothing. It ends with the
127
+ [assumptions](#verification-status) that hardware contradicted (listed; the code no longer relies on them) and a
128
+ count of those not yet confirmed; `doctor --verbose` lists those too, and `doctor --json` always includes
129
+ both lists.
130
+
131
+ ```json
132
+ { "mcpServers": { "batocera": {
133
+ "command": "/path/to/mcp-server-batocera/.venv/bin/mcp-server-batocera",
134
+ "env": { "BATOCERA_URL": "192.168.1.20" } } } }
135
+ ```
136
+
137
+ For Claude Code: `claude mcp add batocera -e BATOCERA_URL=192.168.1.20 -- /path/to/.venv/bin/mcp-server-batocera`.
138
+
139
+ | Setting | Default | What it does |
140
+ | ------------------------- | -------------------------------------- | ---------------------------------------------- |
141
+ | `BATOCERA_URL` | `http://batocera.local:1234` | EmulationStation's API; a bare host gets `:1234` |
142
+ | `BATOCERA_RETROARCH_PORT` | `55355` | RetroArch's network command port |
143
+ | `BATOCERA_TIMEOUT` | `10` | Seconds to wait for the API (1-60) |
144
+ | `BATOCERA_CONFIG_DIR` | `$XDG_CONFIG_HOME/mcp-server-batocera` | Where an optional `config.json` lives (`url`, `retroarch_port`, `timeout_s`) |
145
+ | `BATOCERA_NAME` | the cabinet's address | How results name the cabinet |
146
+ | `BATOCERA_DRY_RUN` | off | Read the cabinet, change nothing (`serve --dry-run`) |
147
+ | `BATOCERA_LOG_UNREDACTED` | off | Show LAN addresses in logs (`--no-redact`); secrets stay hidden |
148
+ | `BATOCERA_DEBUG` | off | Debug logging (`--debug`) |
149
+
150
+ `config.json` takes `url`, `retroarch_port`, `timeout_s`, `name` and `dry_run`; environment variables win.
151
+
152
+ ### Trying it without the cabinet
153
+
154
+ ```sh
155
+ mcp-server-batocera simulate --write-config /tmp/sim # a fake cabinet: EmulationStation over HTTP, RetroArch over UDP
156
+ export BATOCERA_CONFIG_DIR=/tmp/sim # in another terminal
157
+ mcp-server-batocera doctor
158
+ mcp-server-batocera call find_games query=mario # one tool call, exactly as the model makes it
159
+ mcp-server-batocera call launch_game system=snes game_id=<id from the result above>
160
+ mcp-server-batocera call --dry-run quit_game # reports what it would send
161
+ ```
162
+
163
+ The simulator has a small library across a RetroArch system and a standalone one (GameCube), so both
164
+ paths are there to try. The tests use the same simulator.
165
+
166
+ ### On Alpine (e.g. a Proxmox LXC)
167
+
168
+ `deploy/alpine/` installs it on an always-on Alpine machine on the cabinet's LAN, in the sibling
169
+ servers' `deploy/alpine/` layout so they can share an LXC: a venv in `/opt/mcp-server-batocera`, a
170
+ `mcp-batocera` system user, every setting in `/etc/mcp-server-batocera/env`, an OpenRC service
171
+ (`/etc/init.d/mcp-server-batocera`), and `mcp-server-batocera` on `PATH`, which runs as that user with
172
+ those settings, for `doctor` and `call` from a shell.
173
+
174
+ ```sh
175
+ git clone https://github.com/SDNick484/mcp-server-batocera.git && cd mcp-server-batocera
176
+ sh deploy/alpine/install.sh # as root; re-run to upgrade
177
+ vim /etc/mcp-server-batocera/env # BATOCERA_URL=<the cabinet's IP>
178
+ mcp-server-batocera doctor
179
+ mcp-server-batocera call find_games query=mario
180
+ rc-update add mcp-server-batocera default && rc-service mcp-server-batocera start
181
+ ```
182
+
183
+ A variable set in your shell beats the env file, so one-off overrides work as usual
184
+ (`BATOCERA_DRY_RUN=1 mcp-server-batocera call ...`).
185
+
186
+ **Both arcade servers at once.** `deploy/alpine/setup-arcade.sh` installs this server and
187
+ [mcp-server-pixelcade](https://github.com/SDNick484/mcp-server-pixelcade) from GitHub into
188
+ `/srv/src/`, points both at the cabinet, runs both doctors, then enables both services at boot and
189
+ restarts them (so a re-run's new code is the code that runs). Run it as root on the Alpine machine, or
190
+ from elsewhere through the Proxmox host:
191
+
192
+ ```sh
193
+ ssh root@<proxmox host> "pct exec <container id> -- env ARCADE_HOST=<cabinet IP> sh -s" < deploy/alpine/setup-arcade.sh
194
+ ```
195
+
196
+ `ARCADE_REF` picks a branch, tag or commit (default `main`), and `ARCADE_REF_BATOCERA` or
197
+ `ARCADE_REF_PIXELCADE` one server's ref instead; re-running updates the code and keeps the settings. CI runs `deploy/alpine/smoke-test.sh` in a stock `python:3.12-alpine` container: install,
198
+ upgrade, the test suite on musl, `doctor` and `call` through the command on `PATH` against the
199
+ simulator, the OpenRC service (as its user, an MCP call over HTTP, Access settings from the env file),
200
+ and `setup-arcade.sh` itself.
201
+
202
+ ### Service and Cloudflare
203
+
204
+ The service runs `/opt/mcp-server-batocera/run.sh serve --http` under `supervise-daemon` (restarted if it
205
+ exits) as `mcp-batocera`, logging to `/var/log/mcp-server-batocera/server.log`. Its settings are the
206
+ `MCP_HTTP_*`, `MCP_PUBLIC_HOSTS` and `CF_ACCESS_*` lines in `/etc/mcp-server-batocera/env`:
207
+
208
+ | Flag | Variable | Default |
209
+ | --- | --- | --- |
210
+ | `--bind` | `MCP_HTTP_BIND` | `127.0.0.1` |
211
+ | `--port` | `MCP_HTTP_PORT` | `8717` (the siblings use 8711 to 8716, Pixelcade 8718) |
212
+ | `--path` | `MCP_HTTP_PATH` | `/batocera/mcp` |
213
+ | `--public-host` | `MCP_PUBLIC_HOSTS` | none: only loopback Host headers pass |
214
+ | | `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | none: serves only this machine |
215
+ | | `MCP_ALLOWED_EMAILS` | anyone Access lets in |
216
+
217
+ To use it as a connector, with cloudflared running on the same machine (keep the `127.0.0.1` bind):
218
+
219
+ 1. **Tunnel route:** a public hostname, say `mcp.example.com`, path `batocera/*`, to
220
+ `http://localhost:8717`. Several servers can share the hostname, one path each.
221
+ 2. **Access application** (self-hosted) covering that hostname and path, with a policy for who may use it
222
+ and Managed OAuth turned on, so Claude's connector can sign in through Access. Note its AUD tag.
223
+ 3. **The env file:** `MCP_PUBLIC_HOSTS=mcp.example.com`, `CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com`,
224
+ `CF_ACCESS_AUD=<the AUD tag>`, then `rc-service mcp-server-batocera restart`.
225
+ 4. **The connector:** `https://mcp.example.com/batocera/mcp`.
226
+
227
+ `GET /healthz` answers without auth, for monitoring. Details are in `src/batocera_mcp/remote.py`.
228
+
229
+ ## Tools
230
+
231
+ All tools are prefixed with `batocera_` to eliminate namespace collisions when multiple MCP servers run concurrently in an agent's context. CLI commands support shorthand (e.g., `mcp-server-batocera call find_games query=mario`).
232
+
233
+ | Tool | What it does |
234
+ | ---------------------------- | --------------------------------------------------------------------------------------------- |
235
+ | `batocera_get_status` | Reachable, EmulationStation version, the running game, busy (scraping/updating), RetroArch state |
236
+ | `batocera_list_systems` | Systems with games (not collections or the picture viewer): counts, played, favorites, most played |
237
+ | `batocera_find_games` | Search by name (punctuation-insensitive), filter by genre / players, list favorites / most played / most time / recent / random; pages with `offset` |
238
+ | `batocera_get_game` | Description, developer, publisher, rating, players, play count and time |
239
+ | `batocera_launch_game` | Start a game by system + id; refuses while another game runs; confirms it started |
240
+ | `batocera_quit_game` | Quit the running game (**destructive**: unsaved progress is lost) |
241
+ | `batocera_show_message` | Over the game (RetroArch), or a frontend notification or popup |
242
+ | `batocera_reload_games` | Rescan game lists after adding ROMs (rate-limited) |
243
+ | `batocera_pause_game` | Pause or resume (RetroArch) |
244
+ | `batocera_save_state` | Save to the current slot (RetroArch, rate-limited) |
245
+ | `batocera_load_state` | Load from the current slot (RetroArch, **destructive**, rate-limited) |
246
+ | `batocera_take_screenshot` | Save a screenshot on the cabinet (RetroArch) |
247
+ | `batocera_reset_game` | Reset the game (RetroArch, **destructive**, rate-limited) |
248
+ | `batocera_adjust_volume` | Volume up or down one step (0.5 dB), or toggle mute (RetroArch) |
249
+ | `batocera_change_save_slot` | Next or previous save-state slot, used by `save_state` / `load_state` (RetroArch) |
250
+
251
+ Play counts and play time come from EmulationStation itself (it records them in its game lists), so
252
+ "what have we played most?" works without Home Assistant.
253
+
254
+ Every tool that changes something returns the same structured result as the sibling servers:
255
+
256
+ ```json
257
+ { "cabinet": "Game Room", "outcome": "done", "detail": "Launched Galaga (mame).",
258
+ "sent": ["POST /launch /userdata/roms/mame/galaga.zip"], "warnings": [] }
259
+ ```
260
+
261
+ `outcome` is `done`, `unchanged` (already the case, so nothing was sent) or `dry_run`; `sent` is what
262
+ went to the cabinet, or in a dry run what would have. Failures are MCP error results with a message
263
+ saying what to fix.
264
+
265
+ ## Safety design
266
+
267
+ - **Games are an allow-list.** `launch_game` takes a system and EmulationStation's game id. The server
268
+ looks the id up in EmulationStation's own list and sends that entry's path; the model never supplies a
269
+ path.
270
+ - **Endpoints that change the system aren't reachable:** `/quit`, `/restart`, `/addgames`,
271
+ `/removegames`, metadata and media uploads, `/input` (simulated key presses) and `/storage/event`.
272
+ - **One game at a time, on purpose.** `launch_game` won't replace a running game; quitting is a separate
273
+ tool marked destructive, so MCP clients ask before ending someone's game.
274
+ - **Rate limits against runaway loops.** `launch_game` and `quit_game` share a budget of 3 (one more every
275
+ 5 s), and `reload_games` has its own of 2 (one more every 20 s). Past that, a call is refused with how long
276
+ to wait. Dry runs and calls that change nothing (already running, nothing to quit) don't count.
277
+ - **RetroArch gets constants only** (`PAUSE_TOGGLE`, `SAVE_STATE`, `LOAD_STATE`, `SCREENSHOT`, `RESET`),
278
+ plus `SHOW_MSG` with validated single-line text.
279
+ - Every tool has a title and MCP annotations. Errors say what to do ("turn on public web access",
280
+ "add `global.retroarch.network_cmd_enable=true`"), and logs go to stderr.
281
+ - **Over HTTP, the server is the gate** (the cabinet's APIs have none): Access assertions are checked on
282
+ every request, and without Access it serves only its own machine. See
283
+ [Remote access](#remote-access-stdio-or-http-behind-cloudflare-access).
284
+
285
+ ## Play tracking in Home Assistant (playtracker)
286
+
287
+ ### What gets logged, and what doesn't
288
+
289
+ Batocera's screensaver (dim, black, random video, slideshow, suspend) **never starts a game**. The
290
+ random-video and slideshow screensavers *select* games to show their videos, which fires EmulationStation's
291
+ `game-selected` event. (A Pixelcade marquee doesn't follow those: Pixelcade's own screensaver hook locks
292
+ it in attract mode until the screensaver ends.) playtracker only logs `game-start`/`game-end`, which fire
293
+ only when a game actually launches.
294
+
295
+ Two filters on top of that:
296
+ - **Screensaver launches.** With *Screensaver controls* on, pressing START on a screensaver video
297
+ launches that game. playtracker recognizes this case: the launched game is the one the screensaver was
298
+ showing, and the screensaver was active within the last 10 seconds. With `IGNORE_SCREENSAVER_LAUNCHES=yes`
299
+ (the default) it logs nothing for that session.
300
+ - **Short sessions.** Sessions under `MIN_SESSION_SECONDS` (default 60) aren't logged.
301
+
302
+ ### Install
303
+
304
+ 1. In Home Assistant, create a user for the cabinet (*Settings > People > Users*, e.g. `batocera`). The
305
+ Mosquitto add-on accepts Home Assistant users.
306
+ 2. Copy the files to the cabinet, from a clone of this repository (the PyPI package is the MCP server
307
+ only; playtracker runs on the cabinet and isn't part of it):
308
+ ```sh
309
+ scp playtracker/playtracker.py root@192.168.1.20:/userdata/system/configs/emulationstation/scripts/
310
+ scp playtracker/playtracker.conf.example root@192.168.1.20:/userdata/system/configs/playtracker.conf
311
+ ssh root@192.168.1.20 'chmod +x /userdata/system/configs/emulationstation/scripts/playtracker.py;
312
+ chmod 600 /userdata/system/configs/playtracker.conf'
313
+ ```
314
+ 3. Edit `playtracker.conf` (broker host, user, password, device name), then restart EmulationStation or
315
+ reboot. A device named after `DEVICE_NAME` (*Batocera* unless you change it) appears in Home
316
+ Assistant under MQTT.
317
+
318
+ Scripts directly in `.../emulationstation/scripts/` get every event, with the event name first. This
319
+ doesn't interfere with Pixelcade's per-event scripts in the subfolders. To debug, read
320
+ `/tmp/playtracker/playtracker.log` on the cabinet.
321
+
322
+ ### Entities
323
+
324
+ Home Assistant names the entities after the device, so with the default `DEVICE_NAME=Batocera` they
325
+ come out as below. Rename the device and they follow: `DEVICE_NAME=Game Room Arcade` gives
326
+ `sensor.game_room_arcade_now_playing`, and so on. Check the device page for the exact IDs.
327
+
328
+ | Entity | State | Attributes |
329
+ | --------------------------------------- | ---------------------- | ---------------------------------------------------- |
330
+ | `sensor.batocera_now_playing` | game name, or `Idle` | system, rom, started, source |
331
+ | `binary_sensor.batocera_playing` | on while a game runs | |
332
+ | `sensor.batocera_now_playing_system` | system name | |
333
+ | `sensor.batocera_last_session` | last game played | system, rom, started, ended, duration_s |
334
+ | `sensor.batocera_last_session_length` | minutes | |
335
+ | `event.batocera_game_session` | `session_start`, `session_end` | game, system, rom, source, started (+ ended, duration_s at the end) |
336
+ | `binary_sensor.batocera_screensaver` | on while the screensaver runs | type (dim, black, random video, slideshow, suspend) |
337
+ | `sensor.batocera_activity` | `menu`, `screensaver`, `playing`, `off` | |
338
+ | `event.batocera_cabinet` | `start`, `wake`, `sleep`, `quit`, `reboot`, `shutdown` | at |
339
+ | availability | `batocera/<id>/status` | online on start and wake, offline on quit, reboot, shutdown |
340
+
341
+ Each logged session is one `session_end` event, so it shows up in the logbook, history and automations.
342
+ `session_start` fires as a logged game starts (not for ignored screensaver launches).
343
+
344
+ **For automations** (lights when a game starts or the screensaver kicks in), use *Activity*. It describes
345
+ what's on screen rather than what's logged: a game launched from the screensaver reads `playing` even when
346
+ it isn't logged. It has no availability topic, so it shows `off` after a quit, reboot or shutdown instead of
347
+ *unavailable*. When START on a screensaver video launches a game, *Activity* can pass through `menu` for a
348
+ moment on its way to `playing` (EmulationStation's two events race), so give such triggers a `for:` of a
349
+ couple of seconds. EmulationStation's `sleep` and `wake` are its idle sleep during a dim or black screensaver,
350
+ not a system suspend, so the cabinet stays online through them, and a reboot or shutdown is one *Cabinet*
351
+ event, not a `quit` as well.
352
+
353
+ Daily play time:
354
+
355
+ ```yaml
356
+ sensor:
357
+ - platform: history_stats
358
+ name: Arcade play time today
359
+ entity_id: binary_sensor.batocera_playing
360
+ state: "on"
361
+ type: time
362
+ start: "{{ today_at() }}"
363
+ end: "{{ now() }}"
364
+ ```
365
+
366
+ If the broker is unreachable (Home Assistant restarting, say), messages are queued in `/tmp/playtracker`
367
+ and sent with the next event: the latest value of each entity, and logged sessions. Other events
368
+ (`session_start`, *Cabinet*) are dropped, because a late one would run its automation at the wrong time: a
369
+ shutdown event would arrive at the next boot. `/tmp` is a tmpfs, emptied by any restart. If the cabinet loses power, `status` stays
370
+ `online` until it next boots, because the script doesn't keep a connection open for an MQTT last will.
371
+
372
+ ## Verification status
373
+
374
+ Every Batocera behavior the code relies on is listed in `src/batocera_mcp/assumptions.py`, with its
375
+ source and how sure we are; the code cites each id where it depends on it, and
376
+ [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-batocera/blob/main/HARDWARE_VALIDATION.md) has the step that confirms it. *high* means read
377
+ directly in the code that implements it; *medium* means read in source but dependent on settings,
378
+ timing or versions; *low* means our own guess. Hardware results are from Batocera 43 (2026-10-21 to 2026-10-24); every assumption except `B-PT-LIFECYCLE` and `B-RA-AUDIO-SLOT` is confirmed.
379
+
380
+ | Assumption | Confidence | Status |
381
+ |---|---|---|
382
+ | `B-ES-PUBLIC-ACCESS` | high | hardware-verified |
383
+ | `B-ES-DURING-GAME` | medium | hardware-verified |
384
+ | `B-ES-RUNNINGGAME` | high | hardware-verified |
385
+ | `B-ES-LAUNCH` | high | hardware-verified |
386
+ | `B-ES-EMUKILL` | medium | hardware-verified |
387
+ | `B-ES-GAME-ID` | high | hardware-verified |
388
+ | `B-ES-METADATA` | high | hardware-verified |
389
+ | `B-ES-SYSTEMS` | high | hardware-verified |
390
+ | `B-ES-THREADS` | high | hardware-verified |
391
+ | `B-ES-MESSAGES` | medium | hardware-verified |
392
+ | `B-RA-ENABLE` | medium | hardware-verified |
393
+ | `B-RA-STATUS` | medium | hardware-verified |
394
+ | `B-RA-COMMANDS` | medium | hardware-verified |
395
+ | `B-RA-AUDIO-SLOT` | medium | simulator-only |
396
+ | `B-PT-SCRIPTS` | high | hardware-verified |
397
+ | `B-PT-GAME-ARGS` | high | hardware-verified |
398
+ | `B-PT-SCREENSAVER` | high | hardware-verified |
399
+ | `B-PT-SCREENSAVER-START` | medium | hardware-verified |
400
+ | `B-PT-TMP` | medium | hardware-verified |
401
+ | `B-PT-LIFECYCLE` | high | simulator-only |
402
+ | `B-PT-HA-MQTT` | high | hardware-verified |
403
+
404
+ ## Not yet included
405
+
406
+ - Fixed-command SSH tools (CPU temperature, disk space, restart EmulationStation). These need SSH
407
+ credentials, so they'd be opt-in.
408
+ - Adding ROMs (`/addgames`), or editing metadata and media.
409
+ - Choosing the save-state slot. RetroArch's network commands only step it up or down.
410
+
411
+ ## Development
412
+
413
+ ```sh
414
+ pip install -e ".[dev]"
415
+ pytest # tools via an in-process MCP client against the simulator, faults, CLI, playtracker, stdio, HTTP + Access
416
+ ruff check . && ruff format --check .
417
+ mypy # strict, src/ and playtracker/
418
+ ```
419
+
420
+ ## License
421
+
422
+ MIT. See [LICENSE](https://github.com/SDNick484/mcp-server-batocera/blob/main/LICENSE).