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.
Files changed (39) hide show
  1. python_mister_fpga-0.3.0/CHANGELOG.md +39 -0
  2. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/PKG-INFO +30 -2
  3. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/README.md +28 -0
  4. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/api.md +20 -0
  5. python_mister_fpga-0.3.0/docs/changelog.md +37 -0
  6. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/usage.md +29 -0
  7. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/pyproject.toml +1 -1
  8. python_mister_fpga-0.3.0/src/mister_fpga/__init__.py +97 -0
  9. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/client.py +174 -20
  10. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/const.py +47 -2
  11. python_mister_fpga-0.3.0/src/mister_fpga/gamepad.py +127 -0
  12. python_mister_fpga-0.3.0/src/mister_fpga/ra.py +171 -0
  13. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/ssh.py +95 -5
  14. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/websocket.py +95 -13
  15. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_client.py +85 -6
  16. python_mister_fpga-0.3.0/tests/test_gamepad_ralog.py +44 -0
  17. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_ra.py +1 -1
  18. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_ra_web.py +1 -1
  19. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_ssh.py +41 -0
  20. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/tests/test_websocket.py +33 -0
  21. python_mister_fpga-0.2.0/CHANGELOG.md +0 -15
  22. python_mister_fpga-0.2.0/docs/changelog.md +0 -13
  23. python_mister_fpga-0.2.0/src/mister_fpga/__init__.py +0 -52
  24. python_mister_fpga-0.2.0/src/mister_fpga/ra.py +0 -82
  25. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.github/workflows/docs.yml +0 -0
  26. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.github/workflows/release.yml +0 -0
  27. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.github/workflows/tests.yml +0 -0
  28. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/.gitignore +0 -0
  29. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/LICENSE +0 -0
  30. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/assets/dark_logo.png +0 -0
  31. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/assets/icon.png +0 -0
  32. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/assets/logo.png +0 -0
  33. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/assets/dark_logo.png +0 -0
  34. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/assets/icon.png +0 -0
  35. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/assets/logo.png +0 -0
  36. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/docs/index.md +0 -0
  37. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/mkdocs.yml +0 -0
  38. {python_mister_fpga-0.2.0 → python_mister_fpga-0.3.0}/src/mister_fpga/py.typed +0 -0
  39. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-mister-fpga
3
- Version: 0.2.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.2.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: Full path of the running game ROM, or ``None``.
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
- dns: DNS server address reported by the device.
75
- disk_total: Total SD-card space in bytes (first disk).
76
- disk_used: Used SD-card space in bytes (first disk).
77
- disk_free: Free SD-card space in bytes (first disk).
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 = disks[0] if disks else {}
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(self, query: str, system: str = "all") -> dict:
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 slug, or ``"all"`` (default).
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
- A dict with a ``results`` key containing matching game entries.
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", PATH_GAMES_SEARCH, payload={"data": query, "system": system}
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
- return await self._request("GET", PATH_SCREENSHOTS) or []
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 core/menu background display mode (0 = off, 1 = wallpaper, …)."""
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 using a short-link token (mrext ``/l/<data>`` endpoint)."""
379
- await self._request("GET", f"{PATH_LAUNCH_TOKEN}/{data}")
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
- """Send a raw HID keyboard scan-code integer."""
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)