python-mister-fpga 0.1.1__tar.gz → 0.1.3__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.3/.github/workflows/docs.yml +33 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/.gitignore +1 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/CHANGELOG.md +3 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/PKG-INFO +10 -1
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/README.md +5 -0
- python_mister_fpga-0.1.3/docs/api.md +15 -0
- python_mister_fpga-0.1.3/docs/assets/dark_logo.png +0 -0
- python_mister_fpga-0.1.3/docs/assets/icon.png +0 -0
- python_mister_fpga-0.1.3/docs/assets/logo.png +0 -0
- python_mister_fpga-0.1.3/docs/changelog.md +13 -0
- python_mister_fpga-0.1.3/docs/index.md +37 -0
- python_mister_fpga-0.1.3/docs/usage.md +92 -0
- python_mister_fpga-0.1.3/mkdocs.yml +49 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/pyproject.toml +3 -1
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/src/mister_fpga/__init__.py +13 -1
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/src/mister_fpga/client.py +115 -4
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/src/mister_fpga/const.py +36 -0
- python_mister_fpga-0.1.3/src/mister_fpga/ra.py +82 -0
- python_mister_fpga-0.1.3/src/mister_fpga/ssh.py +133 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/src/mister_fpga/websocket.py +54 -6
- python_mister_fpga-0.1.3/tests/test_ra.py +163 -0
- python_mister_fpga-0.1.3/tests/test_ssh.py +82 -0
- python_mister_fpga-0.1.1/src/mister_fpga/ssh.py +0 -88
- python_mister_fpga-0.1.1/tests/test_ssh.py +0 -25
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/.github/workflows/release.yml +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/LICENSE +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/assets/icon.png +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/assets/logo.png +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/src/mister_fpga/py.typed +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/tests/test_client.py +0 -0
- {python_mister_fpga-0.1.1 → python_mister_fpga-0.1.3}/tests/test_websocket.py +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Docs
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
pages: write
|
|
8
|
+
id-token: write
|
|
9
|
+
concurrency:
|
|
10
|
+
group: pages
|
|
11
|
+
cancel-in-progress: true
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- run: pip install -e ".[docs]"
|
|
21
|
+
- run: mkdocs build --strict
|
|
22
|
+
- uses: actions/upload-pages-artifact@v3
|
|
23
|
+
with:
|
|
24
|
+
path: site
|
|
25
|
+
deploy:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment:
|
|
29
|
+
name: github-pages
|
|
30
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
31
|
+
steps:
|
|
32
|
+
- id: deployment
|
|
33
|
+
uses: actions/deploy-pages@v4
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.2
|
|
4
|
+
- Add online documentation (MkDocs Material) at https://hudsonbrendon.github.io/python-mister-fpga/ and enrich public-API docstrings.
|
|
5
|
+
|
|
3
6
|
## 0.1.1
|
|
4
7
|
- Export `DEFAULT_SSH_PORT`, `DEFAULT_SSH_USERNAME` and `SSH_PROBE_CMD` from the top-level package so consumers can reuse them without duplicating.
|
|
5
8
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mister-fpga
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
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
|
+
Project-URL: Documentation, https://hudsonbrendon.github.io/python-mister-fpga/
|
|
6
7
|
Project-URL: Issues, https://github.com/hudsonbrendon/python-mister-fpga/issues
|
|
7
8
|
Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
|
|
8
9
|
License: MIT
|
|
@@ -18,6 +19,9 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
18
19
|
Requires-Python: >=3.11
|
|
19
20
|
Requires-Dist: aiohttp>=3.9
|
|
20
21
|
Requires-Dist: asyncssh>=2.21
|
|
22
|
+
Provides-Extra: docs
|
|
23
|
+
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
|
|
24
|
+
Requires-Dist: mkdocstrings[python]>=0.25; extra == 'docs'
|
|
21
25
|
Provides-Extra: test
|
|
22
26
|
Requires-Dist: aioresponses>=0.7; extra == 'test'
|
|
23
27
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
@@ -38,6 +42,7 @@ Description-Content-Type: text/markdown
|
|
|
38
42
|
[](https://pypi.org/project/python-mister-fpga/)
|
|
39
43
|
[](https://pypi.org/project/python-mister-fpga/)
|
|
40
44
|
[](LICENSE)
|
|
45
|
+
[](https://hudsonbrendon.github.io/python-mister-fpga/)
|
|
41
46
|
|
|
42
47
|
Async Python client for the [mrext Remote API](https://github.com/wizzomafizzo/mrext) (REST + WebSocket) and optional SSH telemetry for the MiSTer FPGA. Zero Home Assistant dependency — use it in any Python project.
|
|
43
48
|
|
|
@@ -113,6 +118,10 @@ asyncio.run(main())
|
|
|
113
118
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
114
119
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
115
120
|
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
Full documentation is available at **https://hudsonbrendon.github.io/python-mister-fpga/** — usage guide, runnable examples, and auto-generated API reference.
|
|
124
|
+
|
|
116
125
|
## Credits
|
|
117
126
|
|
|
118
127
|
REST/WebSocket API by [wizzomafizzo/mrext](https://github.com/wizzomafizzo/mrext). MiSTer-kun logo by the MiSTer-devel project. Author [@hudsonbrendon](https://github.com/hudsonbrendon).
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
[](https://pypi.org/project/python-mister-fpga/)
|
|
12
12
|
[](https://pypi.org/project/python-mister-fpga/)
|
|
13
13
|
[](LICENSE)
|
|
14
|
+
[](https://hudsonbrendon.github.io/python-mister-fpga/)
|
|
14
15
|
|
|
15
16
|
Async Python client for the [mrext Remote API](https://github.com/wizzomafizzo/mrext) (REST + WebSocket) and optional SSH telemetry for the MiSTer FPGA. Zero Home Assistant dependency — use it in any Python project.
|
|
16
17
|
|
|
@@ -86,6 +87,10 @@ asyncio.run(main())
|
|
|
86
87
|
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
87
88
|
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
88
89
|
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
Full documentation is available at **https://hudsonbrendon.github.io/python-mister-fpga/** — usage guide, runnable examples, and auto-generated API reference.
|
|
93
|
+
|
|
89
94
|
## Credits
|
|
90
95
|
|
|
91
96
|
REST/WebSocket API by [wizzomafizzo/mrext](https://github.com/wizzomafizzo/mrext). MiSTer-kun logo by the MiSTer-devel project. Author [@hudsonbrendon](https://github.com/hudsonbrendon).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
::: mister_fpga.MisterClient
|
|
4
|
+
|
|
5
|
+
::: mister_fpga.MisterStatus
|
|
6
|
+
|
|
7
|
+
::: mister_fpga.MisterWebSocketClient
|
|
8
|
+
|
|
9
|
+
::: mister_fpga.apply_ws_message
|
|
10
|
+
|
|
11
|
+
::: mister_fpga.MisterSSH
|
|
12
|
+
|
|
13
|
+
::: mister_fpga.parse_ssh_probe
|
|
14
|
+
|
|
15
|
+
::: mister_fpga.MisterConnectionError
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- Add online documentation (MkDocs Material) at <https://hudsonbrendon.github.io/python-mister-fpga/> and enrich public-API docstrings.
|
|
6
|
+
|
|
7
|
+
## 0.1.1
|
|
8
|
+
|
|
9
|
+
- Export `DEFAULT_SSH_PORT`, `DEFAULT_SSH_USERNAME` and `SSH_PROBE_CMD` from the top-level package so consumers can reuse them without duplicating.
|
|
10
|
+
|
|
11
|
+
## 0.1.0
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<p align="center"><img src="assets/logo.png" width="380" alt="python-mister-fpga"></p>
|
|
2
|
+
|
|
3
|
+
# python-mister-fpga
|
|
4
|
+
|
|
5
|
+
**python-mister-fpga** is an async Python library for controlling and monitoring a
|
|
6
|
+
[MiSTer FPGA](https://github.com/MiSTer-devel/Main_MiSTer/wiki) device through the
|
|
7
|
+
[mrext Remote API](https://github.com/wizzomafizzo/mrext). It covers the full
|
|
8
|
+
REST surface (launch cores/games, screenshots, music, wallpapers, INI files,
|
|
9
|
+
scripts, and more), a reconnecting WebSocket client for real-time state updates,
|
|
10
|
+
and optional SSH telemetry — all with no Home Assistant dependency.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install python-mister-fpga
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quickstart
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import asyncio
|
|
22
|
+
from mister_fpga import MisterClient
|
|
23
|
+
|
|
24
|
+
async def main():
|
|
25
|
+
client = MisterClient("192.168.1.50")
|
|
26
|
+
status = await client.async_get_status()
|
|
27
|
+
print(status.core, status.game)
|
|
28
|
+
await client.async_close()
|
|
29
|
+
|
|
30
|
+
asyncio.run(main())
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Next steps
|
|
34
|
+
|
|
35
|
+
- [Usage guide](usage.md) — full runnable examples for REST, WebSocket, and SSH.
|
|
36
|
+
- [API reference](api.md) — auto-generated from docstrings.
|
|
37
|
+
- [Changelog](changelog.md) — version history.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Usage
|
|
2
|
+
|
|
3
|
+
## REST client
|
|
4
|
+
|
|
5
|
+
Use `MisterClient` to call the mrext Remote REST API. All methods are `async`;
|
|
6
|
+
call `async_close()` when done (or inject your own `aiohttp.ClientSession` and
|
|
7
|
+
manage its lifetime yourself).
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import asyncio
|
|
11
|
+
from mister_fpga import MisterClient
|
|
12
|
+
|
|
13
|
+
async def main():
|
|
14
|
+
client = MisterClient("192.168.1.50")
|
|
15
|
+
try:
|
|
16
|
+
# Fetch combined sysinfo + playing snapshot
|
|
17
|
+
status = await client.async_get_status()
|
|
18
|
+
print("Core :", status.core)
|
|
19
|
+
print("Game :", status.game_name)
|
|
20
|
+
print("Online:", status.online)
|
|
21
|
+
|
|
22
|
+
# Launch a game by its absolute SD-card path
|
|
23
|
+
await client.async_launch_game("/media/fat/games/SNES/Chrono Trigger.sfc")
|
|
24
|
+
|
|
25
|
+
# Search the game index
|
|
26
|
+
results = await client.async_search_games("Chrono", system="snes")
|
|
27
|
+
print(results)
|
|
28
|
+
|
|
29
|
+
# Return to the main menu
|
|
30
|
+
await client.async_launch_menu()
|
|
31
|
+
finally:
|
|
32
|
+
await client.async_close()
|
|
33
|
+
|
|
34
|
+
asyncio.run(main())
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## WebSocket (real-time updates)
|
|
38
|
+
|
|
39
|
+
`MisterWebSocketClient` connects to the mrext WebSocket endpoint and calls your
|
|
40
|
+
callback for every TEXT frame. Pair it with `apply_ws_message` — a pure
|
|
41
|
+
reducer — to keep a local `MisterStatus` in sync without polling.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from mister_fpga import MisterWebSocketClient, MisterStatus, apply_ws_message
|
|
46
|
+
|
|
47
|
+
state = MisterStatus(online=True)
|
|
48
|
+
menu_path = None
|
|
49
|
+
index_state = (False, False)
|
|
50
|
+
|
|
51
|
+
def on_message(text: str) -> None:
|
|
52
|
+
global state, menu_path, index_state
|
|
53
|
+
state, menu_path, index_state = apply_ws_message(
|
|
54
|
+
text, state, menu_path, index_state
|
|
55
|
+
)
|
|
56
|
+
print(f"core={state.core} game={state.game_name}")
|
|
57
|
+
|
|
58
|
+
async def main():
|
|
59
|
+
ws = MisterWebSocketClient("192.168.1.50")
|
|
60
|
+
# listen() blocks and reconnects automatically; cancel the task to stop.
|
|
61
|
+
await ws.listen(on_message)
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
To stop the loop from outside (e.g. on a signal), call `ws.stop()`.
|
|
67
|
+
|
|
68
|
+
## SSH telemetry
|
|
69
|
+
|
|
70
|
+
`MisterSSH` opens a persistent asyncssh connection and runs a batched probe
|
|
71
|
+
command that collects active core, uptime, CPU load, memory usage, and firmware
|
|
72
|
+
timestamp in a single round-trip. The probe is best-effort: it returns `{}`
|
|
73
|
+
instead of raising on any SSH failure.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
from mister_fpga import MisterSSH
|
|
78
|
+
|
|
79
|
+
async def main():
|
|
80
|
+
ssh = MisterSSH("192.168.1.50", port=22, username="root", password="1")
|
|
81
|
+
try:
|
|
82
|
+
data = await ssh.async_probe()
|
|
83
|
+
print("Active core :", data.get("active_core"))
|
|
84
|
+
print("Uptime (seconds) :", data.get("uptime_seconds"))
|
|
85
|
+
print("CPU load (1m) :", data.get("cpu_load_1m"))
|
|
86
|
+
print("Memory used % :", data.get("memory_used_percent"))
|
|
87
|
+
print("Firmware mtime :", data.get("firmware_timestamp"))
|
|
88
|
+
finally:
|
|
89
|
+
await ssh.async_close()
|
|
90
|
+
|
|
91
|
+
asyncio.run(main())
|
|
92
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
site_name: python-mister-fpga
|
|
2
|
+
site_description: Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry
|
|
3
|
+
site_url: https://hudsonbrendon.github.io/python-mister-fpga/
|
|
4
|
+
repo_url: https://github.com/hudsonbrendon/python-mister-fpga
|
|
5
|
+
repo_name: hudsonbrendon/python-mister-fpga
|
|
6
|
+
docs_dir: docs
|
|
7
|
+
theme:
|
|
8
|
+
name: material
|
|
9
|
+
logo: assets/icon.png
|
|
10
|
+
favicon: assets/icon.png
|
|
11
|
+
palette:
|
|
12
|
+
- media: "(prefers-color-scheme: light)"
|
|
13
|
+
scheme: default
|
|
14
|
+
toggle: {icon: material/weather-night, name: Switch to dark mode}
|
|
15
|
+
- media: "(prefers-color-scheme: dark)"
|
|
16
|
+
scheme: slate
|
|
17
|
+
toggle: {icon: material/weather-sunny, name: Switch to light mode}
|
|
18
|
+
features:
|
|
19
|
+
- navigation.sections
|
|
20
|
+
- navigation.top
|
|
21
|
+
- content.code.copy
|
|
22
|
+
- search.suggest
|
|
23
|
+
- search.highlight
|
|
24
|
+
plugins:
|
|
25
|
+
- search
|
|
26
|
+
- mkdocstrings:
|
|
27
|
+
handlers:
|
|
28
|
+
python:
|
|
29
|
+
paths: [src]
|
|
30
|
+
options:
|
|
31
|
+
docstring_style: google
|
|
32
|
+
show_source: true
|
|
33
|
+
show_root_heading: true
|
|
34
|
+
heading_level: 2
|
|
35
|
+
members_order: source
|
|
36
|
+
separate_signature: true
|
|
37
|
+
show_signature_annotations: true
|
|
38
|
+
markdown_extensions:
|
|
39
|
+
- admonition
|
|
40
|
+
- pymdownx.highlight
|
|
41
|
+
- pymdownx.superfences
|
|
42
|
+
- pymdownx.snippets
|
|
43
|
+
- toc:
|
|
44
|
+
permalink: true
|
|
45
|
+
nav:
|
|
46
|
+
- Home: index.md
|
|
47
|
+
- Usage: usage.md
|
|
48
|
+
- API reference: api.md
|
|
49
|
+
- Changelog: changelog.md
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mister-fpga"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
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"
|
|
@@ -24,10 +24,12 @@ dependencies = ["aiohttp>=3.9", "asyncssh>=2.21"]
|
|
|
24
24
|
|
|
25
25
|
[project.urls]
|
|
26
26
|
Homepage = "https://github.com/hudsonbrendon/python-mister-fpga"
|
|
27
|
+
Documentation = "https://hudsonbrendon.github.io/python-mister-fpga/"
|
|
27
28
|
Issues = "https://github.com/hudsonbrendon/python-mister-fpga/issues"
|
|
28
29
|
|
|
29
30
|
[project.optional-dependencies]
|
|
30
31
|
test = ["pytest>=8", "pytest-asyncio>=0.23", "aioresponses>=0.7", "ruff"]
|
|
32
|
+
docs = ["mkdocs-material>=9.5", "mkdocstrings[python]>=0.25"]
|
|
31
33
|
|
|
32
34
|
[tool.hatch.build.targets.wheel]
|
|
33
35
|
packages = ["src/mister_fpga"]
|
|
@@ -6,9 +6,14 @@ 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,
|
|
11
15
|
)
|
|
16
|
+
from .ra import MisterRA, MisterRAError, parse_ra_status
|
|
12
17
|
from .ssh import MisterSSH, parse_ssh_probe
|
|
13
18
|
from .websocket import MisterWebSocketClient, apply_ws_message
|
|
14
19
|
|
|
@@ -16,16 +21,23 @@ __all__ = [
|
|
|
16
21
|
"MisterClient",
|
|
17
22
|
"MisterStatus",
|
|
18
23
|
"MisterConnectionError",
|
|
24
|
+
"MisterRA",
|
|
25
|
+
"MisterRAError",
|
|
26
|
+
"MisterRAStatus",
|
|
19
27
|
"MisterWebSocketClient",
|
|
20
28
|
"apply_ws_message",
|
|
21
29
|
"MisterSSH",
|
|
30
|
+
"parse_ra_status",
|
|
22
31
|
"parse_ssh_probe",
|
|
23
32
|
"KEYBOARD_NAMES",
|
|
24
33
|
"INI_VIDEO_KEYS",
|
|
34
|
+
"RA_STATUS_CMD",
|
|
35
|
+
"RA_SUPPORTED_SYSTEMS",
|
|
36
|
+
"RA_USERNAME_PLACEHOLDER",
|
|
25
37
|
"WS_PATH",
|
|
26
38
|
"DEFAULT_PORT",
|
|
27
39
|
"DEFAULT_SSH_PORT",
|
|
28
40
|
"DEFAULT_SSH_USERNAME",
|
|
29
41
|
"SSH_PROBE_CMD",
|
|
30
42
|
]
|
|
31
|
-
__version__ = "0.1.
|
|
43
|
+
__version__ = "0.1.3"
|
|
@@ -44,12 +44,38 @@ _LOGGER = logging.getLogger(__name__)
|
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
class MisterConnectionError(Exception):
|
|
47
|
-
"""Raised when the MiSTer Remote API is unreachable or returns an error.
|
|
47
|
+
"""Raised when the MiSTer Remote API is unreachable or returns an error.
|
|
48
|
+
|
|
49
|
+
This exception wraps ``aiohttp.ClientError``, ``TimeoutError``, and
|
|
50
|
+
invalid-JSON responses so callers have a single exception type to catch.
|
|
51
|
+
"""
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
@dataclass
|
|
51
55
|
class MisterStatus:
|
|
52
|
-
"""Snapshot of the MiSTer device state.
|
|
56
|
+
"""Snapshot of the MiSTer device state at a point in time.
|
|
57
|
+
|
|
58
|
+
Returned by :meth:`MisterClient.async_get_status` and mutated in-place by
|
|
59
|
+
:func:`~mister_fpga.apply_ws_message`.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
online: ``True`` when the API responded successfully.
|
|
63
|
+
core: Short core identifier (e.g. ``"SNES"``), or ``None`` when the
|
|
64
|
+
menu is active.
|
|
65
|
+
system: System slug used by mrext (e.g. ``"snes"``).
|
|
66
|
+
system_name: Human-readable system name (e.g. ``"Super Nintendo"``).
|
|
67
|
+
game: Full path of the running game ROM, or ``None``.
|
|
68
|
+
game_name: Basename of the game without extension, or ``None``.
|
|
69
|
+
hostname: MiSTer hostname as reported by the Remote API.
|
|
70
|
+
version: mrext Remote version string.
|
|
71
|
+
ip: Primary IP address of the MiSTer device.
|
|
72
|
+
ips: All IP addresses reported by the device.
|
|
73
|
+
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).
|
|
78
|
+
"""
|
|
53
79
|
|
|
54
80
|
online: bool = False
|
|
55
81
|
core: str | None = None
|
|
@@ -77,7 +103,20 @@ class MisterStatus:
|
|
|
77
103
|
|
|
78
104
|
|
|
79
105
|
class MisterClient:
|
|
80
|
-
"""
|
|
106
|
+
"""Async REST client for the MiSTer FPGA mrext Remote API.
|
|
107
|
+
|
|
108
|
+
Wraps every endpoint exposed by the mrext Remote service under
|
|
109
|
+
``http://host:port/api``. All network I/O is non-blocking; methods raise
|
|
110
|
+
:class:`MisterConnectionError` on any network or HTTP error.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
host: IP address or hostname of the MiSTer device.
|
|
114
|
+
port: mrext Remote port (default ``8182``).
|
|
115
|
+
session: Optional shared ``aiohttp.ClientSession``. When ``None`` the
|
|
116
|
+
client creates and owns its own session; call
|
|
117
|
+
:meth:`async_close` to release it.
|
|
118
|
+
timeout: Per-request timeout in seconds (default ``10``).
|
|
119
|
+
"""
|
|
81
120
|
|
|
82
121
|
def __init__(
|
|
83
122
|
self,
|
|
@@ -100,7 +139,7 @@ class MisterClient:
|
|
|
100
139
|
return self._session
|
|
101
140
|
|
|
102
141
|
async def async_close(self) -> None:
|
|
103
|
-
"""Close the session
|
|
142
|
+
"""Close the underlying HTTP session if this client created it."""
|
|
104
143
|
if self._owns_session and self._session is not None:
|
|
105
144
|
await self._session.close()
|
|
106
145
|
self._session = None
|
|
@@ -139,6 +178,14 @@ class MisterClient:
|
|
|
139
178
|
raise MisterConnectionError(f"{method} {url} failed: {err}") from err
|
|
140
179
|
|
|
141
180
|
async def async_get_status(self) -> MisterStatus:
|
|
181
|
+
"""Fetch a combined sysinfo + playing snapshot.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A :class:`MisterStatus` with ``online=True`` on success.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
MisterConnectionError: If either underlying request fails.
|
|
188
|
+
"""
|
|
142
189
|
sysinfo = await self._request("GET", PATH_SYSINFO) or {}
|
|
143
190
|
playing = await self._request("GET", PATH_PLAYING) or {}
|
|
144
191
|
ips = sysinfo.get("ips") or []
|
|
@@ -163,18 +210,31 @@ class MisterClient:
|
|
|
163
210
|
)
|
|
164
211
|
|
|
165
212
|
async def async_get_systems(self) -> list[dict]:
|
|
213
|
+
"""Return the list of systems known to mrext."""
|
|
166
214
|
return await self._request("GET", PATH_SYSTEMS) or []
|
|
167
215
|
|
|
168
216
|
async def async_launch_system(self, system_id: str) -> None:
|
|
217
|
+
"""Launch the default game/core for *system_id*."""
|
|
169
218
|
await self._request("POST", f"{PATH_SYSTEMS}/{system_id}")
|
|
170
219
|
|
|
171
220
|
async def async_launch_game(self, path: str) -> None:
|
|
221
|
+
"""Launch a game by its absolute path on the MiSTer SD card."""
|
|
172
222
|
await self._request("POST", PATH_GAMES_LAUNCH, payload={"path": path})
|
|
173
223
|
|
|
174
224
|
async def async_launch_menu(self) -> None:
|
|
225
|
+
"""Return to the MiSTer main menu."""
|
|
175
226
|
await self._request("POST", PATH_LAUNCH_MENU)
|
|
176
227
|
|
|
177
228
|
async def async_search_games(self, query: str, system: str = "all") -> dict:
|
|
229
|
+
"""Search the game index.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
query: Free-text search string.
|
|
233
|
+
system: Limit results to a system slug, or ``"all"`` (default).
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
A dict with a ``results`` key containing matching game entries.
|
|
237
|
+
"""
|
|
178
238
|
return (
|
|
179
239
|
await self._request(
|
|
180
240
|
"POST", PATH_GAMES_SEARCH, payload={"data": query, "system": system}
|
|
@@ -183,104 +243,154 @@ class MisterClient:
|
|
|
183
243
|
)
|
|
184
244
|
|
|
185
245
|
async def async_index_games(self) -> None:
|
|
246
|
+
"""Trigger a background re-index of the game library."""
|
|
186
247
|
await self._request("POST", PATH_GAMES_INDEX)
|
|
187
248
|
|
|
188
249
|
async def async_send_keyboard(self, name: str) -> None:
|
|
250
|
+
"""Send a named virtual-keyboard event (see ``KEYBOARD_NAMES``)."""
|
|
189
251
|
await self._request("POST", f"{PATH_KEYBOARD}/{name}")
|
|
190
252
|
|
|
191
253
|
async def async_reboot(self) -> None:
|
|
254
|
+
"""Reboot the MiSTer device."""
|
|
192
255
|
await self._request("POST", PATH_REBOOT)
|
|
193
256
|
|
|
194
257
|
async def async_restart_remote(self) -> None:
|
|
258
|
+
"""Restart the mrext Remote service on the MiSTer."""
|
|
195
259
|
await self._request("POST", PATH_RESTART_REMOTE)
|
|
196
260
|
|
|
197
261
|
async def async_take_screenshot(self) -> None:
|
|
262
|
+
"""Capture a screenshot on the MiSTer and save it server-side."""
|
|
198
263
|
await self._request("POST", PATH_SCREENSHOTS)
|
|
199
264
|
|
|
200
265
|
async def async_get_screenshots(self) -> list[dict]:
|
|
266
|
+
"""Return metadata for all saved screenshots."""
|
|
201
267
|
return await self._request("GET", PATH_SCREENSHOTS) or []
|
|
202
268
|
|
|
203
269
|
async def async_get_screenshot_image(self, core: str, filename: str) -> bytes:
|
|
270
|
+
"""Download a screenshot image as raw bytes.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
core: Core name sub-directory (e.g. ``"SNES"``).
|
|
274
|
+
filename: Screenshot filename within that sub-directory.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
Raw image bytes (typically PNG).
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
MisterConnectionError: If the image cannot be retrieved.
|
|
281
|
+
"""
|
|
204
282
|
return await self._request(
|
|
205
283
|
"GET", f"{PATH_SCREENSHOTS}/{core}/{filename}", parse_json=False
|
|
206
284
|
)
|
|
207
285
|
|
|
208
286
|
async def async_get_music_status(self) -> dict:
|
|
287
|
+
"""Return the current music-player status dict."""
|
|
209
288
|
return await self._request("GET", PATH_MUSIC_STATUS) or {}
|
|
210
289
|
|
|
211
290
|
async def async_music_play(self) -> None:
|
|
291
|
+
"""Start or resume music playback."""
|
|
212
292
|
await self._request("POST", PATH_MUSIC_PLAY)
|
|
213
293
|
|
|
214
294
|
async def async_music_stop(self) -> None:
|
|
295
|
+
"""Stop music playback."""
|
|
215
296
|
await self._request("POST", PATH_MUSIC_STOP)
|
|
216
297
|
|
|
217
298
|
async def async_music_next(self) -> None:
|
|
299
|
+
"""Skip to the next track."""
|
|
218
300
|
await self._request("POST", PATH_MUSIC_NEXT)
|
|
219
301
|
|
|
220
302
|
# --- Wallpapers ---
|
|
221
303
|
async def async_get_wallpapers(self) -> dict:
|
|
304
|
+
"""Return available wallpapers and the currently active one."""
|
|
222
305
|
return await self._request("GET", PATH_WALLPAPERS) or {}
|
|
223
306
|
|
|
224
307
|
async def async_set_wallpaper(self, filename: str) -> None:
|
|
308
|
+
"""Set the active wallpaper by filename."""
|
|
225
309
|
await self._request("POST", f"{PATH_WALLPAPERS}/{filename}")
|
|
226
310
|
|
|
227
311
|
async def async_clear_wallpaper(self) -> None:
|
|
312
|
+
"""Remove the active wallpaper (revert to default)."""
|
|
228
313
|
await self._request("DELETE", PATH_WALLPAPERS)
|
|
229
314
|
|
|
230
315
|
# --- INI files ---
|
|
231
316
|
async def async_get_inis(self) -> dict:
|
|
317
|
+
"""Return a list of MiSTer.ini profiles and the active index."""
|
|
232
318
|
return await self._request("GET", PATH_INIS) or {}
|
|
233
319
|
|
|
234
320
|
async def async_get_ini_values(self, ini_id: int) -> dict:
|
|
321
|
+
"""Return all key/value pairs for INI profile *ini_id*."""
|
|
235
322
|
return await self._request("GET", f"{PATH_INIS}/{ini_id}") or {}
|
|
236
323
|
|
|
237
324
|
async def async_set_active_ini(self, ini_id: int) -> None:
|
|
325
|
+
"""Switch the active MiSTer.ini profile to *ini_id*."""
|
|
238
326
|
await self._request("PUT", PATH_INIS, payload={"ini": ini_id})
|
|
239
327
|
|
|
240
328
|
async def async_set_ini_values(self, ini_id: int, values: dict) -> None:
|
|
329
|
+
"""Update key/value pairs in INI profile *ini_id*."""
|
|
241
330
|
await self._request("PUT", f"{PATH_INIS}/{ini_id}", payload=values)
|
|
242
331
|
|
|
243
332
|
async def async_set_background_mode(self, mode: int) -> None:
|
|
333
|
+
"""Set the core/menu background display mode (0 = off, 1 = wallpaper, …)."""
|
|
244
334
|
await self._request("PUT", PATH_CORE_MENU, payload={"mode": mode})
|
|
245
335
|
|
|
246
336
|
# --- Music (extended) ---
|
|
247
337
|
async def async_get_music_playlists(self) -> list[str]:
|
|
338
|
+
"""Return the list of available music playlist names."""
|
|
248
339
|
return await self._request("GET", PATH_MUSIC_PLAYLIST) or []
|
|
249
340
|
|
|
250
341
|
async def async_set_music_playlist(self, name: str) -> None:
|
|
342
|
+
"""Activate the music playlist identified by *name*."""
|
|
251
343
|
await self._request("POST", f"{PATH_MUSIC_PLAYLIST}/{name}")
|
|
252
344
|
|
|
253
345
|
async def async_set_music_playback(self, mode: str) -> None:
|
|
346
|
+
"""Set playback mode (e.g. ``"random"``, ``"loop"``)."""
|
|
254
347
|
await self._request("POST", f"{PATH_MUSIC_PLAYBACK}/{mode}")
|
|
255
348
|
|
|
256
349
|
# --- Scripts ---
|
|
257
350
|
async def async_get_scripts(self) -> dict:
|
|
351
|
+
"""Return the list of user scripts available on the MiSTer."""
|
|
258
352
|
return await self._request("GET", PATH_SCRIPTS_LIST) or {}
|
|
259
353
|
|
|
260
354
|
async def async_launch_script(self, filename: str) -> None:
|
|
355
|
+
"""Launch a user script by its filename."""
|
|
261
356
|
await self._request("POST", f"{PATH_SCRIPTS_LAUNCH}/{filename}")
|
|
262
357
|
|
|
263
358
|
async def async_open_console(self) -> None:
|
|
359
|
+
"""Open the MiSTer console (script terminal)."""
|
|
264
360
|
await self._request("POST", PATH_SCRIPTS_CONSOLE)
|
|
265
361
|
|
|
266
362
|
async def async_kill_script(self) -> None:
|
|
363
|
+
"""Kill the currently running user script."""
|
|
267
364
|
await self._request("POST", PATH_SCRIPTS_KILL)
|
|
268
365
|
|
|
269
366
|
# --- Peers ---
|
|
270
367
|
async def async_get_peers(self) -> list[dict]:
|
|
368
|
+
"""Return the list of mrext Remote peers on the local network."""
|
|
271
369
|
data = await self._request("GET", PATH_PEERS) or {}
|
|
272
370
|
return data.get("peers", [])
|
|
273
371
|
|
|
274
372
|
# --- Launchers ---
|
|
275
373
|
async def async_launch_path(self, path: str) -> None:
|
|
374
|
+
"""Launch a file (ROM, core, script) by its absolute SD-card path."""
|
|
276
375
|
await self._request("POST", PATH_LAUNCH, payload={"path": path})
|
|
277
376
|
|
|
278
377
|
async def async_launch_token(self, data: str) -> None:
|
|
378
|
+
"""Launch using a short-link token (mrext ``/l/<data>`` endpoint)."""
|
|
279
379
|
await self._request("GET", f"{PATH_LAUNCH_TOKEN}/{data}")
|
|
280
380
|
|
|
281
381
|
async def async_create_shortcut(
|
|
282
382
|
self, game_path: str, folder: str, name: str
|
|
283
383
|
) -> dict:
|
|
384
|
+
"""Create a game shortcut in the Favourites/Recents folder.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
game_path: Absolute path to the game ROM on the SD card.
|
|
388
|
+
folder: Target folder name (e.g. ``"_Favorites"``).
|
|
389
|
+
name: Display name for the shortcut.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
A dict describing the created shortcut entry.
|
|
393
|
+
"""
|
|
284
394
|
return await self._request(
|
|
285
395
|
"POST",
|
|
286
396
|
PATH_LAUNCH_NEW,
|
|
@@ -289,4 +399,5 @@ class MisterClient:
|
|
|
289
399
|
|
|
290
400
|
# --- Raw keyboard ---
|
|
291
401
|
async def async_send_keyboard_raw(self, code: int) -> None:
|
|
402
|
+
"""Send a raw HID keyboard scan-code integer."""
|
|
292
403
|
await self._request("POST", f"{PATH_KEYBOARD_RAW}/{code}")
|