python-mister-fpga 0.2.0__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.2.0 → python_mister_fpga-0.3.0}/PKG-INFO +30 -2
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/README.md +28 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/api.md +20 -0
- python_mister_fpga-0.3.0/docs/changelog.md +37 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/usage.md +29 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/pyproject.toml +1 -1
- python_mister_fpga-0.3.0/src/mister_fpga/__init__.py +97 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/client.py +174 -20
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/const.py +47 -2
- 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.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/ssh.py +95 -5
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/websocket.py +95 -13
- {python_mister_fpga-0.2.0 → 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.2.0 → python_mister_fpga-0.3.0}/tests/test_ra.py +1 -1
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_ra_web.py +1 -1
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_ssh.py +41 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_websocket.py +33 -0
- python_mister_fpga-0.2.0/CHANGELOG.md +0 -15
- python_mister_fpga-0.2.0/docs/changelog.md +0 -13
- python_mister_fpga-0.2.0/src/mister_fpga/__init__.py +0 -52
- python_mister_fpga-0.2.0/src/mister_fpga/ra.py +0 -82
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.github/workflows/docs.yml +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.github/workflows/release.yml +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.gitignore +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/LICENSE +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/assets/icon.png +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/assets/logo.png +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/assets/icon.png +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/assets/logo.png +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/index.md +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/mkdocs.yml +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/py.typed +0 -0
- {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/ra_web.py +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/
|
|
@@ -108,6 +108,34 @@ async def main():
|
|
|
108
108
|
asyncio.run(main())
|
|
109
109
|
```
|
|
110
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
|
+
|
|
111
139
|
## RetroAchievements cloud stats
|
|
112
140
|
|
|
113
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).
|
|
@@ -76,6 +76,34 @@ async def main():
|
|
|
76
76
|
asyncio.run(main())
|
|
77
77
|
```
|
|
78
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
|
+
|
|
79
107
|
## RetroAchievements cloud stats
|
|
80
108
|
|
|
81
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).
|
|
@@ -14,6 +14,26 @@
|
|
|
14
14
|
|
|
15
15
|
::: mister_fpga.MisterConnectionError
|
|
16
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
|
+
|
|
17
37
|
::: mister_fpga.MisterRAWeb
|
|
18
38
|
|
|
19
39
|
::: mister_fpga.MisterRAWebStats
|
|
@@ -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"
|
|
@@ -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"
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
"""Async REST client for the MiSTer FPGA mrext Remote API."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import base64
|
|
4
5
|
import json
|
|
5
6
|
import logging
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from typing import Any
|
|
9
|
+
from urllib.parse import quote
|
|
8
10
|
|
|
9
11
|
import aiohttp
|
|
10
12
|
|
|
11
13
|
from .const import (
|
|
14
|
+
BACKGROUND_MODES,
|
|
12
15
|
PATH_CORE_MENU,
|
|
13
16
|
PATH_GAMES_INDEX,
|
|
14
17
|
PATH_GAMES_LAUNCH,
|
|
15
18
|
PATH_GAMES_SEARCH,
|
|
19
|
+
PATH_GAMES_VIEW,
|
|
16
20
|
PATH_INIS,
|
|
17
21
|
PATH_KEYBOARD,
|
|
18
22
|
PATH_KEYBOARD_RAW,
|
|
@@ -20,6 +24,7 @@ from .const import (
|
|
|
20
24
|
PATH_LAUNCH_MENU,
|
|
21
25
|
PATH_LAUNCH_NEW,
|
|
22
26
|
PATH_LAUNCH_TOKEN,
|
|
27
|
+
PATH_MENU_VIEW,
|
|
23
28
|
PATH_MUSIC_NEXT,
|
|
24
29
|
PATH_MUSIC_PLAY,
|
|
25
30
|
PATH_MUSIC_PLAYBACK,
|
|
@@ -28,13 +33,20 @@ from .const import (
|
|
|
28
33
|
PATH_MUSIC_STOP,
|
|
29
34
|
PATH_PEERS,
|
|
30
35
|
PATH_PLAYING,
|
|
36
|
+
PATH_PLAYLOG_GAMES,
|
|
37
|
+
PATH_PLAYLOG_SESSIONS,
|
|
38
|
+
PATH_PLAYLOG_STATUS,
|
|
39
|
+
PATH_PLAYLOG_SUMMARY,
|
|
31
40
|
PATH_REBOOT,
|
|
41
|
+
PATH_REMOTE_LOG,
|
|
32
42
|
PATH_RESTART_REMOTE,
|
|
43
|
+
PATH_SCREEN,
|
|
33
44
|
PATH_SCREENSHOTS,
|
|
34
45
|
PATH_SCRIPTS_CONSOLE,
|
|
35
46
|
PATH_SCRIPTS_KILL,
|
|
36
47
|
PATH_SCRIPTS_LAUNCH,
|
|
37
48
|
PATH_SCRIPTS_LIST,
|
|
49
|
+
PATH_SEARCH_SYSTEMS,
|
|
38
50
|
PATH_SYSINFO,
|
|
39
51
|
PATH_SYSTEMS,
|
|
40
52
|
PATH_WALLPAPERS,
|
|
@@ -51,6 +63,30 @@ class MisterConnectionError(Exception):
|
|
|
51
63
|
"""
|
|
52
64
|
|
|
53
65
|
|
|
66
|
+
class MisterUnsupportedError(MisterConnectionError):
|
|
67
|
+
"""The endpoint does not exist on this Remote version.
|
|
68
|
+
|
|
69
|
+
mrext Remote answers unknown ``/api`` routes (or a wrong HTTP method) with
|
|
70
|
+
its web UI, ``200 text/html``, instead of a 404.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _seg(value: str) -> str:
|
|
75
|
+
"""Quote one URL path segment (names may contain ``#``, ``?``, spaces)."""
|
|
76
|
+
return quote(str(value), safe="")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _safe_relative(path: str) -> str:
|
|
80
|
+
"""Reject paths that climb out of the SD card.
|
|
81
|
+
|
|
82
|
+
Remote 0.4 joins relative paths onto ``/media/fat`` without containment,
|
|
83
|
+
so ``../..`` reaches the whole filesystem.
|
|
84
|
+
"""
|
|
85
|
+
if ".." in path.replace("\\", "/").split("/"):
|
|
86
|
+
raise ValueError(f"path may not contain '..': {path!r}")
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
54
90
|
@dataclass
|
|
55
91
|
class MisterStatus:
|
|
56
92
|
"""Snapshot of the MiSTer device state at a point in time.
|
|
@@ -64,17 +100,26 @@ class MisterStatus:
|
|
|
64
100
|
menu is active.
|
|
65
101
|
system: System slug used by mrext (e.g. ``"snes"``).
|
|
66
102
|
system_name: Human-readable system name (e.g. ``"Super Nintendo"``).
|
|
67
|
-
game:
|
|
103
|
+
game: Running game as ``"<systemId>/<filename>"`` (or the bare core
|
|
104
|
+
name for arcade), or ``None``. This is *not* a filesystem path;
|
|
105
|
+
do not pass it to :meth:`MisterClient.async_launch_game`.
|
|
68
106
|
game_name: Basename of the game without extension, or ``None``.
|
|
69
107
|
hostname: MiSTer hostname as reported by the Remote API.
|
|
70
108
|
version: mrext Remote version string.
|
|
71
109
|
ip: Primary IP address of the MiSTer device.
|
|
72
110
|
ips: All IP addresses reported by the device.
|
|
73
111
|
updated: ISO-8601 timestamp of the last sysinfo update.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
112
|
+
updated: Time of the last MiSTer update, not of this snapshot.
|
|
113
|
+
dns: ``<hostname>.local`` mDNS name (only when mDNS is enabled) — not a
|
|
114
|
+
DNS server.
|
|
115
|
+
disk_total: Total SD-card space in bytes.
|
|
116
|
+
disk_used: Used SD-card space in bytes.
|
|
117
|
+
disk_free: Free SD-card space in bytes.
|
|
118
|
+
disks: Every disk reported by sysinfo (SD card, USB drives, ...).
|
|
119
|
+
core_session_time: Seconds the current core has been running
|
|
120
|
+
(Remote >= 0.5, else ``None``).
|
|
121
|
+
game_session_time: Seconds the current game has been running
|
|
122
|
+
(Remote >= 0.5, else ``None``).
|
|
78
123
|
"""
|
|
79
124
|
|
|
80
125
|
online: bool = False
|
|
@@ -92,6 +137,9 @@ class MisterStatus:
|
|
|
92
137
|
disk_total: int | None = None
|
|
93
138
|
disk_used: int | None = None
|
|
94
139
|
disk_free: int | None = None
|
|
140
|
+
disks: list[dict] = field(default_factory=list)
|
|
141
|
+
core_session_time: int | None = None
|
|
142
|
+
game_session_time: int | None = None
|
|
95
143
|
|
|
96
144
|
@property
|
|
97
145
|
def is_running_game(self) -> bool:
|
|
@@ -164,6 +212,10 @@ class MisterClient:
|
|
|
164
212
|
resp.raise_for_status()
|
|
165
213
|
if not parse_json:
|
|
166
214
|
return await resp.read()
|
|
215
|
+
if resp.content_type == "text/html":
|
|
216
|
+
raise MisterUnsupportedError(
|
|
217
|
+
f"{method} {url} is not an API endpoint on this Remote"
|
|
218
|
+
)
|
|
167
219
|
text = await resp.text()
|
|
168
220
|
if not text.strip():
|
|
169
221
|
return None
|
|
@@ -190,7 +242,10 @@ class MisterClient:
|
|
|
190
242
|
playing = await self._request("GET", PATH_PLAYING) or {}
|
|
191
243
|
ips = sysinfo.get("ips") or []
|
|
192
244
|
disks = sysinfo.get("disks") or []
|
|
193
|
-
disk =
|
|
245
|
+
disk = next(
|
|
246
|
+
(d for d in disks if d.get("path") == "/media/fat"),
|
|
247
|
+
disks[0] if disks else {},
|
|
248
|
+
)
|
|
194
249
|
return MisterStatus(
|
|
195
250
|
online=True,
|
|
196
251
|
core=playing.get("core") or None,
|
|
@@ -207,6 +262,9 @@ class MisterClient:
|
|
|
207
262
|
disk_total=disk.get("total"),
|
|
208
263
|
disk_used=disk.get("used"),
|
|
209
264
|
disk_free=disk.get("free"),
|
|
265
|
+
disks=disks,
|
|
266
|
+
core_session_time=playing.get("coreSessionTime"),
|
|
267
|
+
game_session_time=playing.get("gameSessionTime"),
|
|
210
268
|
)
|
|
211
269
|
|
|
212
270
|
async def async_get_systems(self) -> list[dict]:
|
|
@@ -225,19 +283,45 @@ class MisterClient:
|
|
|
225
283
|
"""Return to the MiSTer main menu."""
|
|
226
284
|
await self._request("POST", PATH_LAUNCH_MENU)
|
|
227
285
|
|
|
228
|
-
async def async_search_games(
|
|
286
|
+
async def async_search_games(
|
|
287
|
+
self, query: str, system: str = "all", page: int | None = None
|
|
288
|
+
) -> dict:
|
|
229
289
|
"""Search the game index.
|
|
230
290
|
|
|
231
291
|
Args:
|
|
232
292
|
query: Free-text search string.
|
|
233
|
-
system: Limit results to a system
|
|
293
|
+
system: Limit results to a system id, or ``"all"`` (default).
|
|
294
|
+
page: 1-based result page (Remote >= 0.5 paginates; 0.4 ignores it
|
|
295
|
+
and caps results at 500).
|
|
234
296
|
|
|
235
297
|
Returns:
|
|
236
|
-
|
|
298
|
+
``{"data": [{"system": {...}, "name", "path"}], "total",
|
|
299
|
+
"pageSize", "page"}``.
|
|
300
|
+
"""
|
|
301
|
+
payload: dict[str, Any] = {"query": query, "system": system}
|
|
302
|
+
if page is not None:
|
|
303
|
+
payload["page"] = page
|
|
304
|
+
return await self._request("POST", PATH_GAMES_SEARCH, payload=payload) or {}
|
|
305
|
+
|
|
306
|
+
async def async_get_search_systems(self) -> list[dict]:
|
|
307
|
+
"""Return the systems present in the game index (``[{id, name}]``)."""
|
|
308
|
+
data = await self._request("GET", PATH_SEARCH_SYSTEMS) or {}
|
|
309
|
+
return data.get("systems", []) if isinstance(data, dict) else []
|
|
310
|
+
|
|
311
|
+
async def async_browse_games(self, path: str | None = None) -> dict:
|
|
312
|
+
"""List a games folder (``{"up", "items"}``); root when *path* is None."""
|
|
313
|
+
payload = {"path": _safe_relative(path)} if path else {}
|
|
314
|
+
return await self._request("POST", PATH_GAMES_VIEW, payload=payload) or {}
|
|
315
|
+
|
|
316
|
+
async def async_browse_menu(self, path: str = "") -> dict:
|
|
317
|
+
"""List a folder of the MiSTer menu tree (``{"up", "items": [...]}``).
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ValueError: If *path* contains ``..`` (Remote 0.4 does not contain it).
|
|
237
321
|
"""
|
|
238
322
|
return (
|
|
239
323
|
await self._request(
|
|
240
|
-
"POST",
|
|
324
|
+
"POST", PATH_MENU_VIEW, payload={"path": _safe_relative(path)}
|
|
241
325
|
)
|
|
242
326
|
or {}
|
|
243
327
|
)
|
|
@@ -264,7 +348,8 @@ class MisterClient:
|
|
|
264
348
|
|
|
265
349
|
async def async_get_screenshots(self) -> list[dict]:
|
|
266
350
|
"""Return metadata for all saved screenshots."""
|
|
267
|
-
|
|
351
|
+
data = await self._request("GET", PATH_SCREENSHOTS)
|
|
352
|
+
return data if isinstance(data, list) else []
|
|
268
353
|
|
|
269
354
|
async def async_get_screenshot_image(self, core: str, filename: str) -> bytes:
|
|
270
355
|
"""Download a screenshot image as raw bytes.
|
|
@@ -280,7 +365,13 @@ class MisterClient:
|
|
|
280
365
|
MisterConnectionError: If the image cannot be retrieved.
|
|
281
366
|
"""
|
|
282
367
|
return await self._request(
|
|
283
|
-
"GET", f"{PATH_SCREENSHOTS}/{core}/{filename}", parse_json=False
|
|
368
|
+
"GET", f"{PATH_SCREENSHOTS}/{_seg(core)}/{_seg(filename)}", parse_json=False
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
async def async_delete_screenshot(self, core: str, filename: str) -> None:
|
|
372
|
+
"""Delete one saved screenshot."""
|
|
373
|
+
await self._request(
|
|
374
|
+
"DELETE", f"{PATH_SCREENSHOTS}/{_seg(core)}/{_seg(filename)}"
|
|
284
375
|
)
|
|
285
376
|
|
|
286
377
|
async def async_get_music_status(self) -> dict:
|
|
@@ -306,7 +397,7 @@ class MisterClient:
|
|
|
306
397
|
|
|
307
398
|
async def async_set_wallpaper(self, filename: str) -> None:
|
|
308
399
|
"""Set the active wallpaper by filename."""
|
|
309
|
-
await self._request("POST", f"{PATH_WALLPAPERS}/{filename}")
|
|
400
|
+
await self._request("POST", f"{PATH_WALLPAPERS}/{_seg(filename)}")
|
|
310
401
|
|
|
311
402
|
async def async_clear_wallpaper(self) -> None:
|
|
312
403
|
"""Remove the active wallpaper (revert to default)."""
|
|
@@ -330,7 +421,18 @@ class MisterClient:
|
|
|
330
421
|
await self._request("PUT", f"{PATH_INIS}/{ini_id}", payload=values)
|
|
331
422
|
|
|
332
423
|
async def async_set_background_mode(self, mode: int) -> None:
|
|
333
|
-
"""Set the
|
|
424
|
+
"""Set the menu background mode.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
mode: One of :data:`~mister_fpga.const.BACKGROUND_MODES` (0 none,
|
|
428
|
+
2 wallpaper, 4/6 horizontal bars, 8/10 vertical bars,
|
|
429
|
+
12 spectrum, 14 black).
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
ValueError: For any other value (the server rejects odd modes).
|
|
433
|
+
"""
|
|
434
|
+
if mode not in BACKGROUND_MODES:
|
|
435
|
+
raise ValueError(f"invalid background mode {mode}")
|
|
334
436
|
await self._request("PUT", PATH_CORE_MENU, payload={"mode": mode})
|
|
335
437
|
|
|
336
438
|
# --- Music (extended) ---
|
|
@@ -340,11 +442,11 @@ class MisterClient:
|
|
|
340
442
|
|
|
341
443
|
async def async_set_music_playlist(self, name: str) -> None:
|
|
342
444
|
"""Activate the music playlist identified by *name*."""
|
|
343
|
-
await self._request("POST", f"{PATH_MUSIC_PLAYLIST}/{name}")
|
|
445
|
+
await self._request("POST", f"{PATH_MUSIC_PLAYLIST}/{_seg(name)}")
|
|
344
446
|
|
|
345
447
|
async def async_set_music_playback(self, mode: str) -> None:
|
|
346
448
|
"""Set playback mode (e.g. ``"random"``, ``"loop"``)."""
|
|
347
|
-
await self._request("POST", f"{PATH_MUSIC_PLAYBACK}/{mode}")
|
|
449
|
+
await self._request("POST", f"{PATH_MUSIC_PLAYBACK}/{_seg(mode)}")
|
|
348
450
|
|
|
349
451
|
# --- Scripts ---
|
|
350
452
|
async def async_get_scripts(self) -> dict:
|
|
@@ -353,7 +455,7 @@ class MisterClient:
|
|
|
353
455
|
|
|
354
456
|
async def async_launch_script(self, filename: str) -> None:
|
|
355
457
|
"""Launch a user script by its filename."""
|
|
356
|
-
await self._request("POST", f"{PATH_SCRIPTS_LAUNCH}/{filename}")
|
|
458
|
+
await self._request("POST", f"{PATH_SCRIPTS_LAUNCH}/{_seg(filename)}")
|
|
357
459
|
|
|
358
460
|
async def async_open_console(self) -> None:
|
|
359
461
|
"""Open the MiSTer console (script terminal)."""
|
|
@@ -375,8 +477,13 @@ class MisterClient:
|
|
|
375
477
|
await self._request("POST", PATH_LAUNCH, payload={"path": path})
|
|
376
478
|
|
|
377
479
|
async def async_launch_token(self, data: str) -> None:
|
|
378
|
-
"""Launch
|
|
379
|
-
|
|
480
|
+
"""Launch a token: a path, or a ``**command:`` such as ``**random:snes``.
|
|
481
|
+
|
|
482
|
+
The text is base64url-encoded (no padding) as the ``/l/<data>``
|
|
483
|
+
endpoint requires; pass it in plain form.
|
|
484
|
+
"""
|
|
485
|
+
encoded = base64.urlsafe_b64encode(data.encode()).decode().rstrip("=")
|
|
486
|
+
await self._request("GET", f"{PATH_LAUNCH_TOKEN}/{encoded}")
|
|
380
487
|
|
|
381
488
|
async def async_create_shortcut(
|
|
382
489
|
self, game_path: str, folder: str, name: str
|
|
@@ -399,5 +506,52 @@ class MisterClient:
|
|
|
399
506
|
|
|
400
507
|
# --- Raw keyboard ---
|
|
401
508
|
async def async_send_keyboard_raw(self, code: int) -> None:
|
|
402
|
-
"""
|
|
509
|
+
"""Tap a Linux evdev ``KEY_*`` code (e.g. 28 = Enter).
|
|
510
|
+
|
|
511
|
+
A negative code sends Shift + ``abs(code)``.
|
|
512
|
+
"""
|
|
403
513
|
await self._request("POST", f"{PATH_KEYBOARD_RAW}/{code}")
|
|
514
|
+
|
|
515
|
+
# --- Remote >= 0.5 ---
|
|
516
|
+
async def async_get_screen(self) -> dict:
|
|
517
|
+
"""Return the current framebuffer resolution ``{width, height}``."""
|
|
518
|
+
return await self._request("GET", PATH_SCREEN) or {}
|
|
519
|
+
|
|
520
|
+
async def async_get_playlog_status(self) -> dict:
|
|
521
|
+
"""PlayLog state: ``{installed, running, database, recents, startup}``."""
|
|
522
|
+
return await self._request("GET", PATH_PLAYLOG_STATUS) or {}
|
|
523
|
+
|
|
524
|
+
async def async_get_playlog_summary(
|
|
525
|
+
self, *, top: int | None = None, days: int | None = None
|
|
526
|
+
) -> dict:
|
|
527
|
+
"""Play-time summary: totals (seconds), streaks, top games/systems, per-day."""
|
|
528
|
+
params = "&".join(
|
|
529
|
+
f"{k}={v}" for k, v in (("top", top), ("days", days)) if v is not None
|
|
530
|
+
)
|
|
531
|
+
path = PATH_PLAYLOG_SUMMARY + (f"?{params}" if params else "")
|
|
532
|
+
return await self._request("GET", path) or {}
|
|
533
|
+
|
|
534
|
+
async def async_get_playlog_games(
|
|
535
|
+
self, *, sort: str | None = None, limit: int | None = None, offset: int = 0
|
|
536
|
+
) -> dict:
|
|
537
|
+
"""Per-game play stats ``{"games": [...], "total"}``."""
|
|
538
|
+
params = [f"offset={offset}"]
|
|
539
|
+
if sort:
|
|
540
|
+
params.append(f"sort={_seg(sort)}")
|
|
541
|
+
if limit:
|
|
542
|
+
params.append(f"limit={limit}")
|
|
543
|
+
return (
|
|
544
|
+
await self._request("GET", f"{PATH_PLAYLOG_GAMES}?{'&'.join(params)}")
|
|
545
|
+
or {}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
async def async_get_playlog_sessions(
|
|
549
|
+
self, *, limit: int | None = None, offset: int = 0
|
|
550
|
+
) -> dict:
|
|
551
|
+
"""Recent play sessions ``{"sessions": [...], "total"}``."""
|
|
552
|
+
params = f"offset={offset}" + (f"&limit={limit}" if limit else "")
|
|
553
|
+
return await self._request("GET", f"{PATH_PLAYLOG_SESSIONS}?{params}") or {}
|
|
554
|
+
|
|
555
|
+
async def async_get_remote_log(self) -> bytes:
|
|
556
|
+
"""Download the Remote service log."""
|
|
557
|
+
return await self._request("GET", PATH_REMOTE_LOG, parse_json=False)
|