python-mister-fpga 0.1.2__tar.gz → 0.2.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.1.2 → python_mister_fpga-0.2.0}/CHANGELOG.md +5 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/PKG-INFO +73 -1
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/README.md +71 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/api.md +10 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/pyproject.toml +9 -2
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/src/mister_fpga/__init__.py +22 -1
- python_mister_fpga-0.2.0/src/mister_fpga/const.py +145 -0
- python_mister_fpga-0.2.0/src/mister_fpga/ra.py +82 -0
- python_mister_fpga-0.2.0/src/mister_fpga/ra_web.py +215 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/src/mister_fpga/ssh.py +20 -10
- python_mister_fpga-0.2.0/tests/test_ra.py +163 -0
- python_mister_fpga-0.2.0/tests/test_ra_web.py +304 -0
- python_mister_fpga-0.2.0/tests/test_ssh.py +82 -0
- python_mister_fpga-0.1.2/src/mister_fpga/const.py +0 -62
- python_mister_fpga-0.1.2/tests/test_ssh.py +0 -25
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/.github/workflows/docs.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/.github/workflows/release.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/.gitignore +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/LICENSE +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/assets/icon.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/assets/logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/assets/icon.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/assets/logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/changelog.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/index.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/docs/usage.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/mkdocs.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/src/mister_fpga/client.py +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/src/mister_fpga/py.typed +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/src/mister_fpga/websocket.py +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/tests/test_client.py +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.2.0}/tests/test_websocket.py +0 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
- 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.
|
|
5
|
+
- New dataclasses `MisterRAWebStats`, `RAGameProgress`, `RAAchievement` for aggregated cloud-stats snapshots.
|
|
6
|
+
- New exception `MisterRAWebError` raised on network, HTTP, or API-level failures (including bad credentials).
|
|
7
|
+
|
|
3
8
|
## 0.1.2
|
|
4
9
|
- Add online documentation (MkDocs Material) at https://hudsonbrendon.github.io/python-mister-fpga/ and enrich public-API docstrings.
|
|
5
10
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mister-fpga
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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,74 @@ async def main():
|
|
|
107
108
|
asyncio.run(main())
|
|
108
109
|
```
|
|
109
110
|
|
|
111
|
+
## RetroAchievements cloud stats
|
|
112
|
+
|
|
113
|
+
`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).
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import asyncio
|
|
117
|
+
from mister_fpga import MisterRAWeb
|
|
118
|
+
|
|
119
|
+
async def main():
|
|
120
|
+
web = MisterRAWeb("myuser", "my_api_key")
|
|
121
|
+
stats = await web.async_fetch_stats()
|
|
122
|
+
print(stats.hardcore_points, stats.rank, stats.current_game)
|
|
123
|
+
await web.async_close()
|
|
124
|
+
|
|
125
|
+
asyncio.run(main())
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `MisterRAWeb(username, api_key, *, session=None, timeout=15)`
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
| Method | Description |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `await async_fetch_stats() -> MisterRAWebStats` | Fire all three API calls concurrently and return an aggregated stats snapshot. |
|
|
135
|
+
| `await async_validate()` | Lightweight credential check — raises `MisterRAWebError` if the username or API key is wrong. |
|
|
136
|
+
| `await async_get_badge_image(url) -> bytes` | Fetch a badge or icon image by absolute URL (e.g. `stats.last_achievement.badge_url`). |
|
|
137
|
+
| `await async_close()` | Close the session if this client created it. |
|
|
138
|
+
|
|
139
|
+
### Dataclasses
|
|
140
|
+
|
|
141
|
+
**`MisterRAWebStats`** — aggregated snapshot returned by `async_fetch_stats()`:
|
|
142
|
+
|
|
143
|
+
| Field | Type | Description |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `hardcore_points` | `int` | Total hardcore score. |
|
|
146
|
+
| `softcore_points` | `int` | Total softcore score. |
|
|
147
|
+
| `rank` | `int \| None` | Global hardcore rank (None if unranked). |
|
|
148
|
+
| `total_ranked` | `int \| None` | Total number of ranked players. |
|
|
149
|
+
| `current_game` | `RAGameProgress \| None` | Most recently played game (first entry in the recent-games list). |
|
|
150
|
+
| `recent_games` | `list[RAGameProgress]` | Up to 10 recently played games, most-recent first. |
|
|
151
|
+
| `last_achievement` | `RAAchievement \| None` | Most recently unlocked achievement (within the last 7 days). |
|
|
152
|
+
|
|
153
|
+
**`RAGameProgress`** — per-game achievement progress:
|
|
154
|
+
|
|
155
|
+
| Field | Type | Description |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `game_id` | `int` | RA game ID. |
|
|
158
|
+
| `title` | `str` | Game title. |
|
|
159
|
+
| `console` | `str` | Console name (e.g. `"SNES"`). |
|
|
160
|
+
| `num_achieved` | `int` | Achievements unlocked by the player. |
|
|
161
|
+
| `num_possible` | `int` | Total achievements in the set. |
|
|
162
|
+
| `percent` | `float` | Completion percentage (0–100). |
|
|
163
|
+
| `last_played` | `str \| None` | ISO-ish timestamp of last session. |
|
|
164
|
+
| `icon_url` | `str \| None` | Absolute URL of the game icon. |
|
|
165
|
+
|
|
166
|
+
**`RAAchievement`** — a single unlocked achievement:
|
|
167
|
+
|
|
168
|
+
| Field | Type | Description |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `title` | `str` | Achievement title. |
|
|
171
|
+
| `description` | `str` | Achievement description. |
|
|
172
|
+
| `points` | `int` | Point value. |
|
|
173
|
+
| `game_title` | `str` | Title of the game it belongs to. |
|
|
174
|
+
| `date` | `str \| None` | Unlock timestamp. |
|
|
175
|
+
| `badge_url` | `str \| None` | Absolute URL of the badge image. |
|
|
176
|
+
|
|
177
|
+
**`MisterRAWebError`** — raised by `MisterRAWeb` on network failures, HTTP errors, or API-level errors (including bad credentials).
|
|
178
|
+
|
|
110
179
|
## API
|
|
111
180
|
|
|
112
181
|
- **`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 +185,9 @@ asyncio.run(main())
|
|
|
116
185
|
- **`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
186
|
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
118
187
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
188
|
+
- **`MisterRAWeb(username, api_key, *, session=None, timeout=15)`** — async RA Web API client; `await web.async_fetch_stats()` returns `MisterRAWebStats`.
|
|
189
|
+
- **`MisterRAWebStats`**, **`RAGameProgress`**, **`RAAchievement`** — cloud-stats dataclasses (see section above).
|
|
190
|
+
- **`MisterRAWebError`** — raised on RA Web API failures.
|
|
119
191
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
120
192
|
|
|
121
193
|
## Documentation
|
|
@@ -76,6 +76,74 @@ async def main():
|
|
|
76
76
|
asyncio.run(main())
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
## RetroAchievements cloud stats
|
|
80
|
+
|
|
81
|
+
`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).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import asyncio
|
|
85
|
+
from mister_fpga import MisterRAWeb
|
|
86
|
+
|
|
87
|
+
async def main():
|
|
88
|
+
web = MisterRAWeb("myuser", "my_api_key")
|
|
89
|
+
stats = await web.async_fetch_stats()
|
|
90
|
+
print(stats.hardcore_points, stats.rank, stats.current_game)
|
|
91
|
+
await web.async_close()
|
|
92
|
+
|
|
93
|
+
asyncio.run(main())
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `MisterRAWeb(username, api_key, *, session=None, timeout=15)`
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
| Method | Description |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `await async_fetch_stats() -> MisterRAWebStats` | Fire all three API calls concurrently and return an aggregated stats snapshot. |
|
|
103
|
+
| `await async_validate()` | Lightweight credential check — raises `MisterRAWebError` if the username or API key is wrong. |
|
|
104
|
+
| `await async_get_badge_image(url) -> bytes` | Fetch a badge or icon image by absolute URL (e.g. `stats.last_achievement.badge_url`). |
|
|
105
|
+
| `await async_close()` | Close the session if this client created it. |
|
|
106
|
+
|
|
107
|
+
### Dataclasses
|
|
108
|
+
|
|
109
|
+
**`MisterRAWebStats`** — aggregated snapshot returned by `async_fetch_stats()`:
|
|
110
|
+
|
|
111
|
+
| Field | Type | Description |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `hardcore_points` | `int` | Total hardcore score. |
|
|
114
|
+
| `softcore_points` | `int` | Total softcore score. |
|
|
115
|
+
| `rank` | `int \| None` | Global hardcore rank (None if unranked). |
|
|
116
|
+
| `total_ranked` | `int \| None` | Total number of ranked players. |
|
|
117
|
+
| `current_game` | `RAGameProgress \| None` | Most recently played game (first entry in the recent-games list). |
|
|
118
|
+
| `recent_games` | `list[RAGameProgress]` | Up to 10 recently played games, most-recent first. |
|
|
119
|
+
| `last_achievement` | `RAAchievement \| None` | Most recently unlocked achievement (within the last 7 days). |
|
|
120
|
+
|
|
121
|
+
**`RAGameProgress`** — per-game achievement progress:
|
|
122
|
+
|
|
123
|
+
| Field | Type | Description |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `game_id` | `int` | RA game ID. |
|
|
126
|
+
| `title` | `str` | Game title. |
|
|
127
|
+
| `console` | `str` | Console name (e.g. `"SNES"`). |
|
|
128
|
+
| `num_achieved` | `int` | Achievements unlocked by the player. |
|
|
129
|
+
| `num_possible` | `int` | Total achievements in the set. |
|
|
130
|
+
| `percent` | `float` | Completion percentage (0–100). |
|
|
131
|
+
| `last_played` | `str \| None` | ISO-ish timestamp of last session. |
|
|
132
|
+
| `icon_url` | `str \| None` | Absolute URL of the game icon. |
|
|
133
|
+
|
|
134
|
+
**`RAAchievement`** — a single unlocked achievement:
|
|
135
|
+
|
|
136
|
+
| Field | Type | Description |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `title` | `str` | Achievement title. |
|
|
139
|
+
| `description` | `str` | Achievement description. |
|
|
140
|
+
| `points` | `int` | Point value. |
|
|
141
|
+
| `game_title` | `str` | Title of the game it belongs to. |
|
|
142
|
+
| `date` | `str \| None` | Unlock timestamp. |
|
|
143
|
+
| `badge_url` | `str \| None` | Absolute URL of the badge image. |
|
|
144
|
+
|
|
145
|
+
**`MisterRAWebError`** — raised by `MisterRAWeb` on network failures, HTTP errors, or API-level errors (including bad credentials).
|
|
146
|
+
|
|
79
147
|
## API
|
|
80
148
|
|
|
81
149
|
- **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
|
|
@@ -85,6 +153,9 @@ asyncio.run(main())
|
|
|
85
153
|
- **`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)`.
|
|
86
154
|
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
87
155
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
156
|
+
- **`MisterRAWeb(username, api_key, *, session=None, timeout=15)`** — async RA Web API client; `await web.async_fetch_stats()` returns `MisterRAWebStats`.
|
|
157
|
+
- **`MisterRAWebStats`**, **`RAGameProgress`**, **`RAAchievement`** — cloud-stats dataclasses (see section above).
|
|
158
|
+
- **`MisterRAWebError`** — raised on RA Web API failures.
|
|
88
159
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
89
160
|
|
|
90
161
|
## Documentation
|
|
@@ -13,3 +13,13 @@
|
|
|
13
13
|
::: mister_fpga.parse_ssh_probe
|
|
14
14
|
|
|
15
15
|
::: mister_fpga.MisterConnectionError
|
|
16
|
+
|
|
17
|
+
::: mister_fpga.MisterRAWeb
|
|
18
|
+
|
|
19
|
+
::: mister_fpga.MisterRAWebStats
|
|
20
|
+
|
|
21
|
+
::: mister_fpga.RAGameProgress
|
|
22
|
+
|
|
23
|
+
::: mister_fpga.RAAchievement
|
|
24
|
+
|
|
25
|
+
::: mister_fpga.MisterRAWebError
|
|
@@ -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.2.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]
|
|
@@ -6,9 +6,18 @@ from .const import (
|
|
|
6
6
|
DEFAULT_SSH_USERNAME,
|
|
7
7
|
INI_VIDEO_KEYS,
|
|
8
8
|
KEYBOARD_NAMES,
|
|
9
|
+
RA_STATUS_CMD,
|
|
10
|
+
RA_SUPPORTED_SYSTEMS,
|
|
11
|
+
RA_USERNAME_PLACEHOLDER,
|
|
9
12
|
SSH_PROBE_CMD,
|
|
10
13
|
WS_PATH,
|
|
14
|
+
MisterRAStatus,
|
|
15
|
+
MisterRAWebStats,
|
|
16
|
+
RAAchievement,
|
|
17
|
+
RAGameProgress,
|
|
11
18
|
)
|
|
19
|
+
from .ra import MisterRA, MisterRAError, parse_ra_status
|
|
20
|
+
from .ra_web import MisterRAWeb, MisterRAWebError
|
|
12
21
|
from .ssh import MisterSSH, parse_ssh_probe
|
|
13
22
|
from .websocket import MisterWebSocketClient, apply_ws_message
|
|
14
23
|
|
|
@@ -16,16 +25,28 @@ __all__ = [
|
|
|
16
25
|
"MisterClient",
|
|
17
26
|
"MisterStatus",
|
|
18
27
|
"MisterConnectionError",
|
|
28
|
+
"MisterRA",
|
|
29
|
+
"MisterRAError",
|
|
30
|
+
"MisterRAStatus",
|
|
31
|
+
"MisterRAWeb",
|
|
32
|
+
"MisterRAWebError",
|
|
33
|
+
"MisterRAWebStats",
|
|
19
34
|
"MisterWebSocketClient",
|
|
20
35
|
"apply_ws_message",
|
|
21
36
|
"MisterSSH",
|
|
37
|
+
"parse_ra_status",
|
|
22
38
|
"parse_ssh_probe",
|
|
39
|
+
"RAAchievement",
|
|
40
|
+
"RAGameProgress",
|
|
23
41
|
"KEYBOARD_NAMES",
|
|
24
42
|
"INI_VIDEO_KEYS",
|
|
43
|
+
"RA_STATUS_CMD",
|
|
44
|
+
"RA_SUPPORTED_SYSTEMS",
|
|
45
|
+
"RA_USERNAME_PLACEHOLDER",
|
|
25
46
|
"WS_PATH",
|
|
26
47
|
"DEFAULT_PORT",
|
|
27
48
|
"DEFAULT_SSH_PORT",
|
|
28
49
|
"DEFAULT_SSH_USERNAME",
|
|
29
50
|
"SSH_PROBE_CMD",
|
|
30
51
|
]
|
|
31
|
-
__version__ = "0.
|
|
52
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Protocol constants for the MiSTer FPGA mrext Remote API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
DEFAULT_PORT = 8182
|
|
7
|
+
HTTP_TIMEOUT = 10
|
|
8
|
+
|
|
9
|
+
# mrext Remote API paths (relative to base_url = http://host:port/api)
|
|
10
|
+
PATH_PLAYING = "/games/playing"
|
|
11
|
+
PATH_SYSINFO = "/sysinfo"
|
|
12
|
+
PATH_SYSTEMS = "/systems"
|
|
13
|
+
PATH_GAMES_LAUNCH = "/games/launch"
|
|
14
|
+
PATH_GAMES_SEARCH = "/games/search"
|
|
15
|
+
PATH_GAMES_INDEX = "/games/index"
|
|
16
|
+
PATH_LAUNCH_MENU = "/launch/menu"
|
|
17
|
+
PATH_KEYBOARD = "/controls/keyboard"
|
|
18
|
+
PATH_REBOOT = "/settings/system/reboot"
|
|
19
|
+
PATH_RESTART_REMOTE = "/settings/remote/restart"
|
|
20
|
+
PATH_SCREENSHOTS = "/screenshots"
|
|
21
|
+
PATH_MUSIC_STATUS = "/music/status"
|
|
22
|
+
PATH_MUSIC_PLAY = "/music/play"
|
|
23
|
+
PATH_MUSIC_STOP = "/music/stop"
|
|
24
|
+
PATH_MUSIC_NEXT = "/music/next"
|
|
25
|
+
PATH_MUSIC_PLAYLIST = "/music/playlist"
|
|
26
|
+
PATH_MUSIC_PLAYBACK = "/music/playback"
|
|
27
|
+
PATH_WALLPAPERS = "/wallpapers"
|
|
28
|
+
PATH_INIS = "/settings/inis"
|
|
29
|
+
PATH_CORE_MENU = "/settings/core/menu"
|
|
30
|
+
PATH_SCRIPTS_LIST = "/scripts/list"
|
|
31
|
+
PATH_SCRIPTS_LAUNCH = "/scripts/launch"
|
|
32
|
+
PATH_SCRIPTS_CONSOLE = "/scripts/console"
|
|
33
|
+
PATH_SCRIPTS_KILL = "/scripts/kill"
|
|
34
|
+
PATH_PEERS = "/settings/remote/peers"
|
|
35
|
+
PATH_LAUNCH = "/launch"
|
|
36
|
+
PATH_LAUNCH_TOKEN = "/l"
|
|
37
|
+
PATH_LAUNCH_NEW = "/launch/new"
|
|
38
|
+
PATH_KEYBOARD_RAW = "/controls/keyboard-raw"
|
|
39
|
+
|
|
40
|
+
# WebSocket endpoint (mounted under /api on Remote v0.4)
|
|
41
|
+
WS_PATH = "/api/ws"
|
|
42
|
+
|
|
43
|
+
# MiSTer.ini keys exposed as Number entities (value range 0-100)
|
|
44
|
+
INI_VIDEO_KEYS = ("video_brightness", "video_contrast", "video_saturation")
|
|
45
|
+
|
|
46
|
+
# SSH defaults and probe command
|
|
47
|
+
DEFAULT_SSH_PORT = 22
|
|
48
|
+
DEFAULT_SSH_USERNAME = "root"
|
|
49
|
+
|
|
50
|
+
SSH_PROBE_CMD = (
|
|
51
|
+
"cat /tmp/CORENAME 2>/dev/null; echo '|||'; "
|
|
52
|
+
"cat /proc/uptime 2>/dev/null; echo '|||'; "
|
|
53
|
+
"cat /proc/loadavg 2>/dev/null; echo '|||'; "
|
|
54
|
+
"awk '/MemTotal|MemAvailable/{print $2}' /proc/meminfo 2>/dev/null; echo '|||'; "
|
|
55
|
+
"stat -c %Y /media/fat/MiSTer 2>/dev/null"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Keyboard control names accepted by POST /controls/keyboard/{name}
|
|
59
|
+
KEYBOARD_NAMES = (
|
|
60
|
+
"up", "down", "left", "right", "confirm", "back", "cancel", "menu",
|
|
61
|
+
"osd", "core_select", "user", "volume_up", "volume_down", "volume_mute",
|
|
62
|
+
"reset", "screenshot", "raw_screenshot", "console", "exit_console",
|
|
63
|
+
"computer_osd", "change_background", "pair_bluetooth", "toggle_core_dates",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# RetroAchievements (local, via SSH helper scripts) ------------------------
|
|
67
|
+
|
|
68
|
+
# mrext system keys for cores with odelot RA-modified builds.
|
|
69
|
+
RA_SUPPORTED_SYSTEMS = (
|
|
70
|
+
"NES", "FDS", "SNES", "MegaDrive", "SMS", "Gameboy", "N64", "PSX",
|
|
71
|
+
"GBA", "MegaCD", "NeoGeo", "TurboGrafx16", "Atari2600", "Atari7800", "S32X",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Placeholder written by the RA installer before the user fills credentials.
|
|
75
|
+
RA_USERNAME_PLACEHOLDER = "YOUR_RA_USERNAME"
|
|
76
|
+
|
|
77
|
+
# One-shot SSH command emitting parseable RA state. Combines an install probe,
|
|
78
|
+
# the username from the cfg, and ra_status.sh output.
|
|
79
|
+
RA_STATUS_CMD = (
|
|
80
|
+
"if [ -d /media/fat/_RA_Cores ]; then echo installed=1; "
|
|
81
|
+
"else echo installed=0; fi; "
|
|
82
|
+
"echo \"username=$(sed -n 's/^username=//p' "
|
|
83
|
+
"/media/fat/retroachievements.cfg 2>/dev/null | head -1)\"; "
|
|
84
|
+
"bash /media/fat/Scripts/.ra/ra_status.sh 2>/dev/null"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class MisterRAStatus:
|
|
90
|
+
"""Parsed RetroAchievements local state."""
|
|
91
|
+
|
|
92
|
+
installed: bool = False
|
|
93
|
+
cores_on: bool = False
|
|
94
|
+
binary_ra: bool = False
|
|
95
|
+
hardcore: bool = False
|
|
96
|
+
username: str | None = None
|
|
97
|
+
cores_active: int = 0
|
|
98
|
+
cores_total: int = 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# RetroAchievements Web API (cloud player stats) ------------------------------
|
|
102
|
+
|
|
103
|
+
RA_WEB_API_BASE = "https://retroachievements.org/API/"
|
|
104
|
+
RA_WEB_IMAGE_BASE = "https://media.retroachievements.org"
|
|
105
|
+
RA_WEB_DEFAULT_RECENT_COUNT = 10
|
|
106
|
+
RA_WEB_DEFAULT_ACHIEVEMENT_MINUTES = 10080 # 7 days, in minutes
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RAGameProgress:
|
|
111
|
+
"""Per-game achievement progress from the RA Web API."""
|
|
112
|
+
|
|
113
|
+
game_id: int
|
|
114
|
+
title: str
|
|
115
|
+
console: str
|
|
116
|
+
num_achieved: int = 0
|
|
117
|
+
num_possible: int = 0
|
|
118
|
+
percent: float = 0.0
|
|
119
|
+
last_played: str | None = None
|
|
120
|
+
icon_url: str | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class RAAchievement:
|
|
125
|
+
"""A single unlocked achievement from the RA Web API."""
|
|
126
|
+
|
|
127
|
+
title: str
|
|
128
|
+
description: str
|
|
129
|
+
points: int
|
|
130
|
+
game_title: str
|
|
131
|
+
date: str | None = None
|
|
132
|
+
badge_url: str | None = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class MisterRAWebStats:
|
|
137
|
+
"""Aggregated RetroAchievements cloud player stats."""
|
|
138
|
+
|
|
139
|
+
hardcore_points: int = 0
|
|
140
|
+
softcore_points: int = 0
|
|
141
|
+
rank: int | None = None
|
|
142
|
+
total_ranked: int | None = None
|
|
143
|
+
current_game: RAGameProgress | None = None
|
|
144
|
+
recent_games: list[RAGameProgress] = field(default_factory=list)
|
|
145
|
+
last_achievement: RAAchievement | None = None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""RetroAchievements local control over SSH (best-effort)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .const import RA_STATUS_CMD, RA_USERNAME_PLACEHOLDER, MisterRAStatus
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Matches a per-core table row: "NES RA -> ..." or "PSX STOCK ...".
|
|
12
|
+
_CORE_ROW = re.compile(r"^\S+\s+(RA|STOCK)\b")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_ra_status(raw: str) -> MisterRAStatus:
|
|
16
|
+
"""Parse RA_STATUS_CMD output into a MisterRAStatus.
|
|
17
|
+
|
|
18
|
+
Tolerant of format drift: matches stable prefixes, ignores unknown lines,
|
|
19
|
+
and never raises.
|
|
20
|
+
"""
|
|
21
|
+
status = MisterRAStatus()
|
|
22
|
+
total = 0
|
|
23
|
+
active = 0
|
|
24
|
+
for line in raw.splitlines():
|
|
25
|
+
stripped = line.strip()
|
|
26
|
+
if stripped.startswith("installed="):
|
|
27
|
+
status.installed = stripped.split("=", 1)[1].strip() == "1"
|
|
28
|
+
elif stripped.startswith("username="):
|
|
29
|
+
value = stripped.split("=", 1)[1].strip()
|
|
30
|
+
status.username = (
|
|
31
|
+
value if value and value != RA_USERNAME_PLACEHOLDER else None
|
|
32
|
+
)
|
|
33
|
+
elif stripped.startswith("Mode flag:"):
|
|
34
|
+
status.cores_on = "ON" in stripped.split(":", 1)[1]
|
|
35
|
+
elif stripped.startswith("Hardcore"):
|
|
36
|
+
status.hardcore = "ON" in stripped.split(":", 1)[1]
|
|
37
|
+
elif stripped.startswith("/media/fat/MiSTer"):
|
|
38
|
+
status.binary_ra = "RA" in stripped.split(":", 1)[1]
|
|
39
|
+
else:
|
|
40
|
+
match = _CORE_ROW.match(line)
|
|
41
|
+
if match:
|
|
42
|
+
total += 1
|
|
43
|
+
if match.group(1) == "RA":
|
|
44
|
+
active += 1
|
|
45
|
+
status.cores_total = total
|
|
46
|
+
status.cores_active = active
|
|
47
|
+
return status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MisterRAError(Exception):
|
|
51
|
+
"""A RetroAchievements control command failed."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MisterRA:
|
|
55
|
+
"""RetroAchievements local control sharing a MisterSSH connection."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, ssh) -> None:
|
|
58
|
+
self._ssh = ssh
|
|
59
|
+
|
|
60
|
+
async def async_status(self) -> MisterRAStatus | None:
|
|
61
|
+
"""Probe RA state. Returns None only on SSH failure (best-effort)."""
|
|
62
|
+
try:
|
|
63
|
+
_rc, out = await self._ssh.async_run(RA_STATUS_CMD, timeout=15)
|
|
64
|
+
except Exception as err: # noqa: BLE001 - best-effort; never break the integration
|
|
65
|
+
_LOGGER.debug("RA status probe failed: %s", err)
|
|
66
|
+
return None
|
|
67
|
+
return parse_ra_status(out)
|
|
68
|
+
|
|
69
|
+
async def async_cores_on(self) -> None:
|
|
70
|
+
await self._run_checked("bash /media/fat/Scripts/.ra/ra_on.sh")
|
|
71
|
+
|
|
72
|
+
async def async_cores_off(self) -> None:
|
|
73
|
+
await self._run_checked("bash /media/fat/Scripts/.ra/ra_off.sh")
|
|
74
|
+
|
|
75
|
+
async def async_set_hardcore(self, enabled: bool) -> None:
|
|
76
|
+
arg = "on" if enabled else "off"
|
|
77
|
+
await self._run_checked(f"bash /media/fat/Scripts/.ra/ra_hardcore.sh {arg}")
|
|
78
|
+
|
|
79
|
+
async def _run_checked(self, command: str) -> None:
|
|
80
|
+
rc, out = await self._ssh.async_run(command, timeout=30)
|
|
81
|
+
if rc != 0:
|
|
82
|
+
raise MisterRAError(f"command failed (rc={rc}): {command}\n{out}")
|