python-mister-fpga 0.1.3__tar.gz → 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.
- python_mister_fpga-0.3.0/CHANGELOG.md +39 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/PKG-INFO +102 -2
- python_mister_fpga-0.3.0/README.md +199 -0
- python_mister_fpga-0.3.0/docs/api.md +45 -0
- python_mister_fpga-0.3.0/docs/changelog.md +37 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/docs/usage.md +29 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/pyproject.toml +9 -2
- python_mister_fpga-0.3.0/src/mister_fpga/__init__.py +97 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/src/mister_fpga/client.py +174 -20
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/src/mister_fpga/const.py +95 -3
- python_mister_fpga-0.3.0/src/mister_fpga/gamepad.py +127 -0
- python_mister_fpga-0.3.0/src/mister_fpga/ra.py +171 -0
- python_mister_fpga-0.3.0/src/mister_fpga/ra_web.py +215 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/src/mister_fpga/ssh.py +95 -5
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/src/mister_fpga/websocket.py +95 -13
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/tests/test_client.py +85 -6
- python_mister_fpga-0.3.0/tests/test_gamepad_ralog.py +44 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/tests/test_ra.py +1 -1
- python_mister_fpga-0.3.0/tests/test_ra_web.py +304 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/tests/test_ssh.py +41 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/tests/test_websocket.py +33 -0
- python_mister_fpga-0.1.3/CHANGELOG.md +0 -10
- python_mister_fpga-0.1.3/README.md +0 -100
- python_mister_fpga-0.1.3/docs/api.md +0 -15
- python_mister_fpga-0.1.3/docs/changelog.md +0 -13
- python_mister_fpga-0.1.3/src/mister_fpga/__init__.py +0 -43
- python_mister_fpga-0.1.3/src/mister_fpga/ra.py +0 -82
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/.github/workflows/docs.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/.github/workflows/release.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/.gitignore +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/LICENSE +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/assets/icon.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/assets/logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/docs/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/docs/assets/icon.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/docs/assets/logo.png +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/docs/index.md +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/mkdocs.yml +0 -0
- {python_mister_fpga-0.1.3 → python_mister_fpga-0.3.0}/src/mister_fpga/py.typed +0 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Findings from reverse-engineering mrext Remote 0.4/0.5 and odelot's Main_MiSTer on a real device.
|
|
6
|
+
|
|
7
|
+
**Fixes**
|
|
8
|
+
- `async_search_games` sent `data`; the server reads `query`, so every search returned the whole index. Adds `page` (Remote 0.5 paginates). Response is `{data, total, pageSize, page}`.
|
|
9
|
+
- `async_set_background_mode` used `/settings/core/menu` (answered by the web UI); the route is `/settings/cores/menu`, and only modes 0, 2, 4 … 14 are valid (`BACKGROUND_MODES`). Invalid modes now raise `ValueError`.
|
|
10
|
+
- `async_launch_token` now base64url-encodes the token as `/l/` requires; pass plain text such as `**random:snes`.
|
|
11
|
+
- Unknown routes (Remote answers them with `200 text/html`) raise `MisterUnsupportedError` instead of "invalid JSON".
|
|
12
|
+
- `async_get_screenshots` returns `[]` when the server sends `null`.
|
|
13
|
+
- Path segments (screenshots, wallpapers, scripts, playlists) are URL-quoted.
|
|
14
|
+
- `MisterStatus` disk fields come from the `/media/fat` entry, not just the first disk.
|
|
15
|
+
- WebSocket reducer: `menuNavigation:FULL:CURRENT` is split and joined into one path; arcade `gameRunning:<core>` keeps the name; a new `coreRunning` clears the stale system/game.
|
|
16
|
+
- Docstrings: `game` is `<system>/<file>`, not a path; `dns` is the mDNS name; raw keyboard codes are Linux evdev codes.
|
|
17
|
+
|
|
18
|
+
**New**
|
|
19
|
+
- Main health over SSH: `parse_ssh_probe` adds `cmd_fifo_pending` (bytes Main has not read from `/dev/MiSTer_cmd` — non-zero on two probes means Main is hung), `main_open_fds`, `volume`/`muted`, `game_id`, `sam_active`, `sam_game`.
|
|
20
|
+
- `MisterSSH.async_send_command` writes the command FIFO non-blocking and raises `MisterMainUnresponsiveError` instead of hanging; `async_set_volume`, `async_set_mute`.
|
|
21
|
+
- `MisterGamepad`: virtual controller/keyboard through the MiSTer Companion Remote daemon (port 9191).
|
|
22
|
+
- `parse_ra_log` / `MisterRA.async_read_log` / `MisterRA.async_set_debug`: RetroAchievements login, game identified, achievement, leaderboard, game-completed events from odelot's debug log.
|
|
23
|
+
- `parse_index_status` / `MisterIndexStatus` (index progress), `split_menu_navigation`, `MisterWebSocketClient.async_send` (e.g. `kbdRawDown:`/`kbdRawUp:`).
|
|
24
|
+
- REST: `async_delete_screenshot`, `async_get_search_systems`, `async_browse_games`, `async_browse_menu` (rejects `..`), `async_get_remote_log`, and for Remote 0.5 `async_get_screen`, `async_get_playlog_status/summary/games/sessions`; `MisterStatus.core_session_time`/`game_session_time`, `disks`.
|
|
25
|
+
- `MDNS_SERVICE_TYPE` for zeroconf discovery.
|
|
26
|
+
|
|
27
|
+
## 0.2.0
|
|
28
|
+
- Add `MisterRAWeb` — async client for the [RetroAchievements.org Web API](https://api.docs.retroachievements.org/) to pull cloud player stats (points, rank, recently-played games, latest unlocked achievement) without SSH.
|
|
29
|
+
- New dataclasses `MisterRAWebStats`, `RAGameProgress`, `RAAchievement` for aggregated cloud-stats snapshots.
|
|
30
|
+
- New exception `MisterRAWebError` raised on network, HTTP, or API-level failures (including bad credentials).
|
|
31
|
+
|
|
32
|
+
## 0.1.2
|
|
33
|
+
- Add online documentation (MkDocs Material) at https://hudsonbrendon.github.io/python-mister-fpga/ and enrich public-API docstrings.
|
|
34
|
+
|
|
35
|
+
## 0.1.1
|
|
36
|
+
- Export `DEFAULT_SSH_PORT`, `DEFAULT_SSH_USERNAME` and `SSH_PROBE_CMD` from the top-level package so consumers can reuse them without duplicating.
|
|
37
|
+
|
|
38
|
+
## 0.1.0
|
|
39
|
+
- Initial release: async REST client (`MisterClient`/`MisterStatus`/`MisterConnectionError`), WebSocket reducer + client (`apply_ws_message`/`MisterWebSocketClient`), and SSH telemetry (`parse_ssh_probe`/`MisterSSH`) for the MiSTer FPGA mrext Remote API.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-mister-fpga
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry
|
|
5
5
|
Project-URL: Homepage, https://github.com/hudsonbrendon/python-mister-fpga
|
|
6
6
|
Project-URL: Documentation, https://hudsonbrendon.github.io/python-mister-fpga/
|
|
@@ -23,6 +23,7 @@ Provides-Extra: docs
|
|
|
23
23
|
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
24
24
|
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
|
|
25
25
|
Provides-Extra: test
|
|
26
|
+
Requires-Dist: aiohttp<3.10; extra == 'test'
|
|
26
27
|
Requires-Dist: aioresponses>=0.7; extra == 'test'
|
|
27
28
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
28
29
|
Requires-Dist: pytest>=8; extra == 'test'
|
|
@@ -107,6 +108,102 @@ async def main():
|
|
|
107
108
|
asyncio.run(main())
|
|
108
109
|
```
|
|
109
110
|
|
|
111
|
+
### Main health, volume and Super Attract Mode (SSH)
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
data = await ssh.async_probe()
|
|
115
|
+
data["cmd_fifo_pending"] # >0 on two probes in a row: Main is hung
|
|
116
|
+
data["volume"], data["muted"], data["game_id"], data["sam_game"]
|
|
117
|
+
await ssh.async_set_volume(5) # 0-7, never blocks on a hung Main
|
|
118
|
+
await ssh.async_send_command("load_core /media/fat/menu.rbf")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Virtual gamepad (MiSTer Companion Remote daemon, port 9191)
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from mister_fpga import MisterGamepad
|
|
125
|
+
|
|
126
|
+
pad = MisterGamepad("192.168.1.50")
|
|
127
|
+
await pad.async_press("start") # a real controller press: works in cores, menu, SAM
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### RetroAchievements events (odelot Main with `debug=1`)
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from mister_fpga import MisterRA
|
|
134
|
+
|
|
135
|
+
ra = MisterRA(ssh)
|
|
136
|
+
offset, events = await ra.async_read_log(0) # login / game_identified / achievement / ...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## RetroAchievements cloud stats
|
|
140
|
+
|
|
141
|
+
`MisterRAWeb` queries the public [RetroAchievements.org Web API](https://api.docs.retroachievements.org/) to pull a player's points, rank, recently-played games, and latest unlocked achievement — no SSH required. You need a **Web API key** from [retroachievements.org/settings](https://retroachievements.org/settings).
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import asyncio
|
|
145
|
+
from mister_fpga import MisterRAWeb
|
|
146
|
+
|
|
147
|
+
async def main():
|
|
148
|
+
web = MisterRAWeb("myuser", "my_api_key")
|
|
149
|
+
stats = await web.async_fetch_stats()
|
|
150
|
+
print(stats.hardcore_points, stats.rank, stats.current_game)
|
|
151
|
+
await web.async_close()
|
|
152
|
+
|
|
153
|
+
asyncio.run(main())
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### `MisterRAWeb(username, api_key, *, session=None, timeout=15)`
|
|
157
|
+
|
|
158
|
+
Async HTTPS client for the RA Web API. When `session` is `None` the client creates its own `aiohttp.ClientSession`; call `await web.async_close()` to release it. Pass a shared session to manage its lifetime yourself.
|
|
159
|
+
|
|
160
|
+
| Method | Description |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `await async_fetch_stats() -> MisterRAWebStats` | Fire all three API calls concurrently and return an aggregated stats snapshot. |
|
|
163
|
+
| `await async_validate()` | Lightweight credential check — raises `MisterRAWebError` if the username or API key is wrong. |
|
|
164
|
+
| `await async_get_badge_image(url) -> bytes` | Fetch a badge or icon image by absolute URL (e.g. `stats.last_achievement.badge_url`). |
|
|
165
|
+
| `await async_close()` | Close the session if this client created it. |
|
|
166
|
+
|
|
167
|
+
### Dataclasses
|
|
168
|
+
|
|
169
|
+
**`MisterRAWebStats`** — aggregated snapshot returned by `async_fetch_stats()`:
|
|
170
|
+
|
|
171
|
+
| Field | Type | Description |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `hardcore_points` | `int` | Total hardcore score. |
|
|
174
|
+
| `softcore_points` | `int` | Total softcore score. |
|
|
175
|
+
| `rank` | `int \| None` | Global hardcore rank (None if unranked). |
|
|
176
|
+
| `total_ranked` | `int \| None` | Total number of ranked players. |
|
|
177
|
+
| `current_game` | `RAGameProgress \| None` | Most recently played game (first entry in the recent-games list). |
|
|
178
|
+
| `recent_games` | `list[RAGameProgress]` | Up to 10 recently played games, most-recent first. |
|
|
179
|
+
| `last_achievement` | `RAAchievement \| None` | Most recently unlocked achievement (within the last 7 days). |
|
|
180
|
+
|
|
181
|
+
**`RAGameProgress`** — per-game achievement progress:
|
|
182
|
+
|
|
183
|
+
| Field | Type | Description |
|
|
184
|
+
|---|---|---|
|
|
185
|
+
| `game_id` | `int` | RA game ID. |
|
|
186
|
+
| `title` | `str` | Game title. |
|
|
187
|
+
| `console` | `str` | Console name (e.g. `"SNES"`). |
|
|
188
|
+
| `num_achieved` | `int` | Achievements unlocked by the player. |
|
|
189
|
+
| `num_possible` | `int` | Total achievements in the set. |
|
|
190
|
+
| `percent` | `float` | Completion percentage (0–100). |
|
|
191
|
+
| `last_played` | `str \| None` | ISO-ish timestamp of last session. |
|
|
192
|
+
| `icon_url` | `str \| None` | Absolute URL of the game icon. |
|
|
193
|
+
|
|
194
|
+
**`RAAchievement`** — a single unlocked achievement:
|
|
195
|
+
|
|
196
|
+
| Field | Type | Description |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `title` | `str` | Achievement title. |
|
|
199
|
+
| `description` | `str` | Achievement description. |
|
|
200
|
+
| `points` | `int` | Point value. |
|
|
201
|
+
| `game_title` | `str` | Title of the game it belongs to. |
|
|
202
|
+
| `date` | `str \| None` | Unlock timestamp. |
|
|
203
|
+
| `badge_url` | `str \| None` | Absolute URL of the badge image. |
|
|
204
|
+
|
|
205
|
+
**`MisterRAWebError`** — raised by `MisterRAWeb` on network failures, HTTP errors, or API-level errors (including bad credentials).
|
|
206
|
+
|
|
110
207
|
## API
|
|
111
208
|
|
|
112
209
|
- **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
|
|
@@ -116,6 +213,9 @@ asyncio.run(main())
|
|
|
116
213
|
- **`apply_ws_message(message, status, menu_path, index_state)`** — pure reducer; apply a single WS text frame and return updated `(status, menu_path, index_state)`.
|
|
117
214
|
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
118
215
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
216
|
+
- **`MisterRAWeb(username, api_key, *, session=None, timeout=15)`** — async RA Web API client; `await web.async_fetch_stats()` returns `MisterRAWebStats`.
|
|
217
|
+
- **`MisterRAWebStats`**, **`RAGameProgress`**, **`RAAchievement`** — cloud-stats dataclasses (see section above).
|
|
218
|
+
- **`MisterRAWebError`** — raised on RA Web API failures.
|
|
119
219
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
120
220
|
|
|
121
221
|
## Documentation
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/dark_logo.png" width="420">
|
|
4
|
+
<img src="assets/logo.png" alt="python-mister-fpga" width="420">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
# python-mister-fpga
|
|
9
|
+
|
|
10
|
+
[](https://github.com/hudsonbrendon/python-mister-fpga/actions/workflows/tests.yml)
|
|
11
|
+
[](https://pypi.org/project/python-mister-fpga/)
|
|
12
|
+
[](https://pypi.org/project/python-mister-fpga/)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
[](https://hudsonbrendon.github.io/python-mister-fpga/)
|
|
15
|
+
|
|
16
|
+
Async Python client for the [mrext Remote API](https://github.com/wizzomafizzo/mrext) (REST + WebSocket) and optional SSH telemetry for the MiSTer FPGA. Zero Home Assistant dependency — use it in any Python project.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install python-mister-fpga
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### REST client
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
from mister_fpga import MisterClient
|
|
31
|
+
|
|
32
|
+
async def main():
|
|
33
|
+
client = MisterClient("192.168.1.50")
|
|
34
|
+
status = await client.async_get_status()
|
|
35
|
+
print(status.core, status.game)
|
|
36
|
+
await client.async_launch_game("/media/fat/games/SNES/Chrono.sfc")
|
|
37
|
+
await client.async_close()
|
|
38
|
+
|
|
39
|
+
asyncio.run(main())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### WebSocket (real-time updates)
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
from mister_fpga import MisterWebSocketClient, MisterStatus, apply_ws_message
|
|
47
|
+
|
|
48
|
+
state = MisterStatus(online=True)
|
|
49
|
+
menu_path = None
|
|
50
|
+
index_state = (False, False)
|
|
51
|
+
|
|
52
|
+
def on_message(text: str) -> None:
|
|
53
|
+
global state, menu_path, index_state
|
|
54
|
+
state, menu_path, index_state = apply_ws_message(text, state, menu_path, index_state)
|
|
55
|
+
print(state.core, state.game)
|
|
56
|
+
|
|
57
|
+
async def main():
|
|
58
|
+
ws = MisterWebSocketClient("192.168.1.50")
|
|
59
|
+
await ws.listen(on_message)
|
|
60
|
+
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### SSH telemetry
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import asyncio
|
|
68
|
+
from mister_fpga import MisterSSH
|
|
69
|
+
|
|
70
|
+
async def main():
|
|
71
|
+
ssh = MisterSSH("192.168.1.50", 22, "root", "1")
|
|
72
|
+
data = await ssh.async_probe()
|
|
73
|
+
print(data)
|
|
74
|
+
await ssh.async_close()
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Main health, volume and Super Attract Mode (SSH)
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
data = await ssh.async_probe()
|
|
83
|
+
data["cmd_fifo_pending"] # >0 on two probes in a row: Main is hung
|
|
84
|
+
data["volume"], data["muted"], data["game_id"], data["sam_game"]
|
|
85
|
+
await ssh.async_set_volume(5) # 0-7, never blocks on a hung Main
|
|
86
|
+
await ssh.async_send_command("load_core /media/fat/menu.rbf")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Virtual gamepad (MiSTer Companion Remote daemon, port 9191)
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from mister_fpga import MisterGamepad
|
|
93
|
+
|
|
94
|
+
pad = MisterGamepad("192.168.1.50")
|
|
95
|
+
await pad.async_press("start") # a real controller press: works in cores, menu, SAM
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### RetroAchievements events (odelot Main with `debug=1`)
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from mister_fpga import MisterRA
|
|
102
|
+
|
|
103
|
+
ra = MisterRA(ssh)
|
|
104
|
+
offset, events = await ra.async_read_log(0) # login / game_identified / achievement / ...
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## RetroAchievements cloud stats
|
|
108
|
+
|
|
109
|
+
`MisterRAWeb` queries the public [RetroAchievements.org Web API](https://api.docs.retroachievements.org/) to pull a player's points, rank, recently-played games, and latest unlocked achievement — no SSH required. You need a **Web API key** from [retroachievements.org/settings](https://retroachievements.org/settings).
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
import asyncio
|
|
113
|
+
from mister_fpga import MisterRAWeb
|
|
114
|
+
|
|
115
|
+
async def main():
|
|
116
|
+
web = MisterRAWeb("myuser", "my_api_key")
|
|
117
|
+
stats = await web.async_fetch_stats()
|
|
118
|
+
print(stats.hardcore_points, stats.rank, stats.current_game)
|
|
119
|
+
await web.async_close()
|
|
120
|
+
|
|
121
|
+
asyncio.run(main())
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### `MisterRAWeb(username, api_key, *, session=None, timeout=15)`
|
|
125
|
+
|
|
126
|
+
Async HTTPS client for the RA Web API. When `session` is `None` the client creates its own `aiohttp.ClientSession`; call `await web.async_close()` to release it. Pass a shared session to manage its lifetime yourself.
|
|
127
|
+
|
|
128
|
+
| Method | Description |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `await async_fetch_stats() -> MisterRAWebStats` | Fire all three API calls concurrently and return an aggregated stats snapshot. |
|
|
131
|
+
| `await async_validate()` | Lightweight credential check — raises `MisterRAWebError` if the username or API key is wrong. |
|
|
132
|
+
| `await async_get_badge_image(url) -> bytes` | Fetch a badge or icon image by absolute URL (e.g. `stats.last_achievement.badge_url`). |
|
|
133
|
+
| `await async_close()` | Close the session if this client created it. |
|
|
134
|
+
|
|
135
|
+
### Dataclasses
|
|
136
|
+
|
|
137
|
+
**`MisterRAWebStats`** — aggregated snapshot returned by `async_fetch_stats()`:
|
|
138
|
+
|
|
139
|
+
| Field | Type | Description |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| `hardcore_points` | `int` | Total hardcore score. |
|
|
142
|
+
| `softcore_points` | `int` | Total softcore score. |
|
|
143
|
+
| `rank` | `int \| None` | Global hardcore rank (None if unranked). |
|
|
144
|
+
| `total_ranked` | `int \| None` | Total number of ranked players. |
|
|
145
|
+
| `current_game` | `RAGameProgress \| None` | Most recently played game (first entry in the recent-games list). |
|
|
146
|
+
| `recent_games` | `list[RAGameProgress]` | Up to 10 recently played games, most-recent first. |
|
|
147
|
+
| `last_achievement` | `RAAchievement \| None` | Most recently unlocked achievement (within the last 7 days). |
|
|
148
|
+
|
|
149
|
+
**`RAGameProgress`** — per-game achievement progress:
|
|
150
|
+
|
|
151
|
+
| Field | Type | Description |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `game_id` | `int` | RA game ID. |
|
|
154
|
+
| `title` | `str` | Game title. |
|
|
155
|
+
| `console` | `str` | Console name (e.g. `"SNES"`). |
|
|
156
|
+
| `num_achieved` | `int` | Achievements unlocked by the player. |
|
|
157
|
+
| `num_possible` | `int` | Total achievements in the set. |
|
|
158
|
+
| `percent` | `float` | Completion percentage (0–100). |
|
|
159
|
+
| `last_played` | `str \| None` | ISO-ish timestamp of last session. |
|
|
160
|
+
| `icon_url` | `str \| None` | Absolute URL of the game icon. |
|
|
161
|
+
|
|
162
|
+
**`RAAchievement`** — a single unlocked achievement:
|
|
163
|
+
|
|
164
|
+
| Field | Type | Description |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `title` | `str` | Achievement title. |
|
|
167
|
+
| `description` | `str` | Achievement description. |
|
|
168
|
+
| `points` | `int` | Point value. |
|
|
169
|
+
| `game_title` | `str` | Title of the game it belongs to. |
|
|
170
|
+
| `date` | `str \| None` | Unlock timestamp. |
|
|
171
|
+
| `badge_url` | `str \| None` | Absolute URL of the badge image. |
|
|
172
|
+
|
|
173
|
+
**`MisterRAWebError`** — raised by `MisterRAWeb` on network failures, HTTP errors, or API-level errors (including bad credentials).
|
|
174
|
+
|
|
175
|
+
## API
|
|
176
|
+
|
|
177
|
+
- **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
|
|
178
|
+
- **`MisterStatus`** — dataclass snapshot: `online`, `core`, `system`, `game`, `hostname`, `version`, `ip`, `ips`, `dns`, `disk_total/used/free`. Property `is_running_game`.
|
|
179
|
+
- **`MisterConnectionError`** — raised on network/HTTP errors.
|
|
180
|
+
- **`MisterWebSocketClient(host, port=8182, *, session=None, reconnect_delay=5)`** — reconnecting WS loop; `await ws.listen(callback)`, call `ws.stop()` to exit.
|
|
181
|
+
- **`apply_ws_message(message, status, menu_path, index_state)`** — pure reducer; apply a single WS text frame and return updated `(status, menu_path, index_state)`.
|
|
182
|
+
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
183
|
+
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
184
|
+
- **`MisterRAWeb(username, api_key, *, session=None, timeout=15)`** — async RA Web API client; `await web.async_fetch_stats()` returns `MisterRAWebStats`.
|
|
185
|
+
- **`MisterRAWebStats`**, **`RAGameProgress`**, **`RAAchievement`** — cloud-stats dataclasses (see section above).
|
|
186
|
+
- **`MisterRAWebError`** — raised on RA Web API failures.
|
|
187
|
+
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
188
|
+
|
|
189
|
+
## Documentation
|
|
190
|
+
|
|
191
|
+
Full documentation is available at **https://hudsonbrendon.github.io/python-mister-fpga/** — usage guide, runnable examples, and auto-generated API reference.
|
|
192
|
+
|
|
193
|
+
## Credits
|
|
194
|
+
|
|
195
|
+
REST/WebSocket API by [wizzomafizzo/mrext](https://github.com/wizzomafizzo/mrext). MiSTer-kun logo by the MiSTer-devel project. Author [@hudsonbrendon](https://github.com/hudsonbrendon).
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
::: mister_fpga.MisterClient
|
|
4
|
+
|
|
5
|
+
::: mister_fpga.MisterStatus
|
|
6
|
+
|
|
7
|
+
::: mister_fpga.MisterWebSocketClient
|
|
8
|
+
|
|
9
|
+
::: mister_fpga.apply_ws_message
|
|
10
|
+
|
|
11
|
+
::: mister_fpga.MisterSSH
|
|
12
|
+
|
|
13
|
+
::: mister_fpga.parse_ssh_probe
|
|
14
|
+
|
|
15
|
+
::: mister_fpga.MisterConnectionError
|
|
16
|
+
|
|
17
|
+
::: mister_fpga.MisterUnsupportedError
|
|
18
|
+
|
|
19
|
+
::: mister_fpga.MisterMainUnresponsiveError
|
|
20
|
+
|
|
21
|
+
::: mister_fpga.MisterGamepad
|
|
22
|
+
|
|
23
|
+
::: mister_fpga.MisterGamepadError
|
|
24
|
+
|
|
25
|
+
::: mister_fpga.MisterIndexStatus
|
|
26
|
+
|
|
27
|
+
::: mister_fpga.parse_index_status
|
|
28
|
+
|
|
29
|
+
::: mister_fpga.split_menu_navigation
|
|
30
|
+
|
|
31
|
+
::: mister_fpga.parse_volume
|
|
32
|
+
|
|
33
|
+
::: mister_fpga.parse_ra_log
|
|
34
|
+
|
|
35
|
+
::: mister_fpga.MisterRA
|
|
36
|
+
|
|
37
|
+
::: mister_fpga.MisterRAWeb
|
|
38
|
+
|
|
39
|
+
::: mister_fpga.MisterRAWebStats
|
|
40
|
+
|
|
41
|
+
::: mister_fpga.RAGameProgress
|
|
42
|
+
|
|
43
|
+
::: mister_fpga.RAAchievement
|
|
44
|
+
|
|
45
|
+
::: mister_fpga.MisterRAWebError
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
Findings from reverse-engineering mrext Remote 0.4/0.5 and odelot's Main_MiSTer on a real device.
|
|
6
|
+
|
|
7
|
+
**Fixes**
|
|
8
|
+
- `async_search_games` sent `data`; the server reads `query`, so every search returned the whole index. Adds `page` (Remote 0.5 paginates). Response is `{data, total, pageSize, page}`.
|
|
9
|
+
- `async_set_background_mode` used `/settings/core/menu` (answered by the web UI); the route is `/settings/cores/menu`, and only modes 0, 2, 4 … 14 are valid (`BACKGROUND_MODES`). Invalid modes now raise `ValueError`.
|
|
10
|
+
- `async_launch_token` now base64url-encodes the token as `/l/` requires; pass plain text such as `**random:snes`.
|
|
11
|
+
- Unknown routes (Remote answers them with `200 text/html`) raise `MisterUnsupportedError` instead of "invalid JSON".
|
|
12
|
+
- `async_get_screenshots` returns `[]` when the server sends `null`.
|
|
13
|
+
- Path segments (screenshots, wallpapers, scripts, playlists) are URL-quoted.
|
|
14
|
+
- `MisterStatus` disk fields come from the `/media/fat` entry, not just the first disk.
|
|
15
|
+
- WebSocket reducer: `menuNavigation:FULL:CURRENT` is split and joined into one path; arcade `gameRunning:<core>` keeps the name; a new `coreRunning` clears the stale system/game.
|
|
16
|
+
- Docstrings: `game` is `<system>/<file>`, not a path; `dns` is the mDNS name; raw keyboard codes are Linux evdev codes.
|
|
17
|
+
|
|
18
|
+
**New**
|
|
19
|
+
- Main health over SSH: `parse_ssh_probe` adds `cmd_fifo_pending` (bytes Main has not read from `/dev/MiSTer_cmd` — non-zero on two probes means Main is hung), `main_open_fds`, `volume`/`muted`, `game_id`, `sam_active`, `sam_game`.
|
|
20
|
+
- `MisterSSH.async_send_command` writes the command FIFO non-blocking and raises `MisterMainUnresponsiveError` instead of hanging; `async_set_volume`, `async_set_mute`.
|
|
21
|
+
- `MisterGamepad`: virtual controller/keyboard through the MiSTer Companion Remote daemon (port 9191).
|
|
22
|
+
- `parse_ra_log` / `MisterRA.async_read_log` / `MisterRA.async_set_debug`: RetroAchievements login, game identified, achievement, leaderboard, game-completed events from odelot's debug log.
|
|
23
|
+
- `parse_index_status` / `MisterIndexStatus` (index progress), `split_menu_navigation`, `MisterWebSocketClient.async_send` (e.g. `kbdRawDown:`/`kbdRawUp:`).
|
|
24
|
+
- REST: `async_delete_screenshot`, `async_get_search_systems`, `async_browse_games`, `async_browse_menu` (rejects `..`), `async_get_remote_log`, and for Remote 0.5 `async_get_screen`, `async_get_playlog_status/summary/games/sessions`; `MisterStatus.core_session_time`/`game_session_time`, `disks`.
|
|
25
|
+
- `MDNS_SERVICE_TYPE` for zeroconf discovery.
|
|
26
|
+
|
|
27
|
+
## 0.1.2
|
|
28
|
+
|
|
29
|
+
- Add online documentation (MkDocs Material) at <https://hudsonbrendon.github.io/python-mister-fpga/> and enrich public-API docstrings.
|
|
30
|
+
|
|
31
|
+
## 0.1.1
|
|
32
|
+
|
|
33
|
+
- Export `DEFAULT_SSH_PORT`, `DEFAULT_SSH_USERNAME` and `SSH_PROBE_CMD` from the top-level package so consumers can reuse them without duplicating.
|
|
34
|
+
|
|
35
|
+
## 0.1.0
|
|
36
|
+
|
|
37
|
+
- Initial release: async REST client (`MisterClient`/`MisterStatus`/`MisterConnectionError`), WebSocket reducer + client (`apply_ws_message`/`MisterWebSocketClient`), and SSH telemetry (`parse_ssh_probe`/`MisterSSH`) for the MiSTer FPGA mrext Remote API.
|
|
@@ -90,3 +90,32 @@ async def main():
|
|
|
90
90
|
|
|
91
91
|
asyncio.run(main())
|
|
92
92
|
```
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
## Main health, volume and Super Attract Mode (SSH)
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
data = await ssh.async_probe()
|
|
99
|
+
data["cmd_fifo_pending"] # >0 on two probes in a row: Main is hung
|
|
100
|
+
data["volume"], data["muted"], data["game_id"], data["sam_game"]
|
|
101
|
+
await ssh.async_set_volume(5) # 0-7, never blocks on a hung Main
|
|
102
|
+
await ssh.async_send_command("load_core /media/fat/menu.rbf")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Virtual gamepad (MiSTer Companion Remote daemon, port 9191)
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from mister_fpga import MisterGamepad
|
|
109
|
+
|
|
110
|
+
pad = MisterGamepad("192.168.1.50")
|
|
111
|
+
await pad.async_press("start") # a real controller press: works in cores, menu, SAM
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## RetroAchievements events (odelot Main with `debug=1`)
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from mister_fpga import MisterRA
|
|
118
|
+
|
|
119
|
+
ra = MisterRA(ssh)
|
|
120
|
+
offset, events = await ra.async_read_log(0) # login / game_identified / achievement / ...
|
|
121
|
+
```
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mister-fpga"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -28,7 +28,14 @@ Documentation = "https://hudsonbrendon.github.io/python-mister-fpga/"
|
|
|
28
28
|
Issues = "https://github.com/hudsonbrendon/python-mister-fpga/issues"
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
31
|
-
test = [
|
|
31
|
+
test = [
|
|
32
|
+
"pytest>=8",
|
|
33
|
+
"pytest-asyncio>=0.23",
|
|
34
|
+
"aioresponses>=0.7",
|
|
35
|
+
# aiohttp<3.10: aioresponses 0.7.8 uses the removed loop= param in StreamReader
|
|
36
|
+
"aiohttp<3.10",
|
|
37
|
+
"ruff",
|
|
38
|
+
]
|
|
32
39
|
docs = ["mkdocs-material>=9.5", "mkdocstrings[python]>=0.25"]
|
|
33
40
|
|
|
34
41
|
[tool.hatch.build.targets.wheel]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Async Python client for the MiSTer FPGA mrext Remote API."""
|
|
2
|
+
from .client import (
|
|
3
|
+
MisterClient,
|
|
4
|
+
MisterConnectionError,
|
|
5
|
+
MisterStatus,
|
|
6
|
+
MisterUnsupportedError,
|
|
7
|
+
)
|
|
8
|
+
from .const import (
|
|
9
|
+
BACKGROUND_MODES,
|
|
10
|
+
CMD_FIFO,
|
|
11
|
+
DEFAULT_PORT,
|
|
12
|
+
DEFAULT_SSH_PORT,
|
|
13
|
+
DEFAULT_SSH_USERNAME,
|
|
14
|
+
INI_VIDEO_KEYS,
|
|
15
|
+
KEYBOARD_NAMES,
|
|
16
|
+
MDNS_SERVICE_TYPE,
|
|
17
|
+
RA_STATUS_CMD,
|
|
18
|
+
RA_SUPPORTED_SYSTEMS,
|
|
19
|
+
RA_USERNAME_PLACEHOLDER,
|
|
20
|
+
SSH_PROBE_CMD,
|
|
21
|
+
WS_PATH,
|
|
22
|
+
MisterRAStatus,
|
|
23
|
+
MisterRAWebStats,
|
|
24
|
+
RAAchievement,
|
|
25
|
+
RAGameProgress,
|
|
26
|
+
)
|
|
27
|
+
from .gamepad import (
|
|
28
|
+
DEFAULT_GAMEPAD_PATH,
|
|
29
|
+
DEFAULT_GAMEPAD_PORT,
|
|
30
|
+
GAMEPAD_ACTIONS,
|
|
31
|
+
GAMEPAD_BUTTONS,
|
|
32
|
+
MisterGamepad,
|
|
33
|
+
MisterGamepadError,
|
|
34
|
+
)
|
|
35
|
+
from .ra import MisterRA, MisterRAError, parse_ra_log, parse_ra_status
|
|
36
|
+
from .ra_web import MisterRAWeb, MisterRAWebError
|
|
37
|
+
from .ssh import (
|
|
38
|
+
MisterMainUnresponsiveError,
|
|
39
|
+
MisterSSH,
|
|
40
|
+
parse_game_id,
|
|
41
|
+
parse_ssh_probe,
|
|
42
|
+
parse_volume,
|
|
43
|
+
)
|
|
44
|
+
from .websocket import (
|
|
45
|
+
MisterIndexStatus,
|
|
46
|
+
MisterWebSocketClient,
|
|
47
|
+
apply_ws_message,
|
|
48
|
+
parse_index_status,
|
|
49
|
+
split_menu_navigation,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"BACKGROUND_MODES",
|
|
54
|
+
"CMD_FIFO",
|
|
55
|
+
"DEFAULT_GAMEPAD_PATH",
|
|
56
|
+
"DEFAULT_GAMEPAD_PORT",
|
|
57
|
+
"GAMEPAD_ACTIONS",
|
|
58
|
+
"GAMEPAD_BUTTONS",
|
|
59
|
+
"MDNS_SERVICE_TYPE",
|
|
60
|
+
"MisterGamepad",
|
|
61
|
+
"MisterGamepadError",
|
|
62
|
+
"MisterIndexStatus",
|
|
63
|
+
"MisterMainUnresponsiveError",
|
|
64
|
+
"MisterUnsupportedError",
|
|
65
|
+
"parse_game_id",
|
|
66
|
+
"parse_index_status",
|
|
67
|
+
"parse_ra_log",
|
|
68
|
+
"parse_volume",
|
|
69
|
+
"split_menu_navigation",
|
|
70
|
+
"MisterClient",
|
|
71
|
+
"MisterStatus",
|
|
72
|
+
"MisterConnectionError",
|
|
73
|
+
"MisterRA",
|
|
74
|
+
"MisterRAError",
|
|
75
|
+
"MisterRAStatus",
|
|
76
|
+
"MisterRAWeb",
|
|
77
|
+
"MisterRAWebError",
|
|
78
|
+
"MisterRAWebStats",
|
|
79
|
+
"MisterWebSocketClient",
|
|
80
|
+
"apply_ws_message",
|
|
81
|
+
"MisterSSH",
|
|
82
|
+
"parse_ra_status",
|
|
83
|
+
"parse_ssh_probe",
|
|
84
|
+
"RAAchievement",
|
|
85
|
+
"RAGameProgress",
|
|
86
|
+
"KEYBOARD_NAMES",
|
|
87
|
+
"INI_VIDEO_KEYS",
|
|
88
|
+
"RA_STATUS_CMD",
|
|
89
|
+
"RA_SUPPORTED_SYSTEMS",
|
|
90
|
+
"RA_USERNAME_PLACEHOLDER",
|
|
91
|
+
"WS_PATH",
|
|
92
|
+
"DEFAULT_PORT",
|
|
93
|
+
"DEFAULT_SSH_PORT",
|
|
94
|
+
"DEFAULT_SSH_USERNAME",
|
|
95
|
+
"SSH_PROBE_CMD",
|
|
96
|
+
]
|
|
97
|
+
__version__ = "0.3.0"
|