python-mister-fpga 0.1.0__tar.gz → 0.1.2__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/.github/workflows/docs.yml +33 -0
- python_mister_fpga-0.1.2/.github/workflows/release.yml +26 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/.gitignore +1 -0
- python_mister_fpga-0.1.2/CHANGELOG.md +10 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/PKG-INFO +10 -1
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/README.md +5 -0
- python_mister_fpga-0.1.2/docs/api.md +15 -0
- python_mister_fpga-0.1.2/docs/assets/dark_logo.png +0 -0
- python_mister_fpga-0.1.2/docs/assets/icon.png +0 -0
- python_mister_fpga-0.1.2/docs/assets/logo.png +0 -0
- python_mister_fpga-0.1.2/docs/changelog.md +13 -0
- python_mister_fpga-0.1.2/docs/index.md +37 -0
- python_mister_fpga-0.1.2/docs/usage.md +92 -0
- python_mister_fpga-0.1.2/mkdocs.yml +49 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/pyproject.toml +3 -1
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/__init__.py +13 -2
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/client.py +115 -4
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/ssh.py +38 -3
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/websocket.py +54 -6
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/tests/test_client.py +7 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/LICENSE +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/assets/icon.png +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/assets/logo.png +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/const.py +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/py.typed +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/tests/test_ssh.py +0 -0
- {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/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
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
pypi-publish:
|
|
12
|
+
name: Build and publish to PyPI
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write # required for PyPI Trusted Publishing (OIDC)
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
- name: Build distributions
|
|
22
|
+
run: |
|
|
23
|
+
python -m pip install --upgrade build
|
|
24
|
+
python -m build
|
|
25
|
+
- name: Publish to PyPI
|
|
26
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
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
|
+
|
|
6
|
+
## 0.1.1
|
|
7
|
+
- Export `DEFAULT_SSH_PORT`, `DEFAULT_SSH_USERNAME` and `SSH_PROBE_CMD` from the top-level package so consumers can reuse them without duplicating.
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
- 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,8 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mister-fpga
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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.2"
|
|
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"]
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"""Async Python client for the MiSTer FPGA mrext Remote API."""
|
|
2
2
|
from .client import MisterClient, MisterConnectionError, MisterStatus
|
|
3
|
-
from .const import
|
|
3
|
+
from .const import (
|
|
4
|
+
DEFAULT_PORT,
|
|
5
|
+
DEFAULT_SSH_PORT,
|
|
6
|
+
DEFAULT_SSH_USERNAME,
|
|
7
|
+
INI_VIDEO_KEYS,
|
|
8
|
+
KEYBOARD_NAMES,
|
|
9
|
+
SSH_PROBE_CMD,
|
|
10
|
+
WS_PATH,
|
|
11
|
+
)
|
|
4
12
|
from .ssh import MisterSSH, parse_ssh_probe
|
|
5
13
|
from .websocket import MisterWebSocketClient, apply_ws_message
|
|
6
14
|
|
|
@@ -16,5 +24,8 @@ __all__ = [
|
|
|
16
24
|
"INI_VIDEO_KEYS",
|
|
17
25
|
"WS_PATH",
|
|
18
26
|
"DEFAULT_PORT",
|
|
27
|
+
"DEFAULT_SSH_PORT",
|
|
28
|
+
"DEFAULT_SSH_USERNAME",
|
|
29
|
+
"SSH_PROBE_CMD",
|
|
19
30
|
]
|
|
20
|
-
__version__ = "0.1.
|
|
31
|
+
__version__ = "0.1.2"
|
|
@@ -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}")
|
|
@@ -11,7 +11,25 @@ _SEP = "|||"
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def parse_ssh_probe(raw: str) -> dict:
|
|
14
|
-
"""Parse the batched SSH probe output into a telemetry dict.
|
|
14
|
+
"""Parse the batched SSH probe output into a telemetry dict.
|
|
15
|
+
|
|
16
|
+
The probe command (``SSH_PROBE_CMD``) concatenates five fields separated by
|
|
17
|
+
``|||``: active core name, uptime, load average, memory info, and firmware
|
|
18
|
+
timestamp. Any missing or malformed field is silently coerced to ``None``.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
raw: Raw stdout string returned by running ``SSH_PROBE_CMD`` on the
|
|
22
|
+
MiSTer over SSH.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A dict with the following keys:
|
|
26
|
+
|
|
27
|
+
* ``active_core`` (``str | None``) — name from ``/tmp/CORENAME``.
|
|
28
|
+
* ``uptime_seconds`` (``int | None``) — seconds since last boot.
|
|
29
|
+
* ``cpu_load_1m`` (``float | None``) — 1-minute load average.
|
|
30
|
+
* ``memory_used_percent`` (``float | None``) — RAM usage 0–100.
|
|
31
|
+
* ``firmware_timestamp`` (``int | None``) — mtime of the MiSTer binary.
|
|
32
|
+
"""
|
|
15
33
|
parts = [p.strip() for p in raw.split(_SEP)]
|
|
16
34
|
while len(parts) < 5:
|
|
17
35
|
parts.append("")
|
|
@@ -49,7 +67,18 @@ def parse_ssh_probe(raw: str) -> dict:
|
|
|
49
67
|
|
|
50
68
|
|
|
51
69
|
class MisterSSH:
|
|
52
|
-
"""
|
|
70
|
+
"""Persistent asyncssh connection for MiSTer telemetry probes.
|
|
71
|
+
|
|
72
|
+
Maintains a single SSH connection that is re-established transparently on
|
|
73
|
+
failure. SSH telemetry is strictly best-effort: :meth:`async_probe`
|
|
74
|
+
always returns a dict (possibly empty) and never raises.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
host: IP address or hostname of the MiSTer device.
|
|
78
|
+
port: SSH port (default ``22``).
|
|
79
|
+
username: SSH username (default ``"root"``).
|
|
80
|
+
password: SSH password (often ``"1"`` on stock MiSTer).
|
|
81
|
+
"""
|
|
53
82
|
|
|
54
83
|
def __init__(self, host: str, port: int, username: str, password: str) -> None:
|
|
55
84
|
self.host = host
|
|
@@ -72,7 +101,12 @@ class MisterSSH:
|
|
|
72
101
|
)
|
|
73
102
|
|
|
74
103
|
async def async_probe(self) -> dict:
|
|
75
|
-
"""Run the probe
|
|
104
|
+
"""Run the SSH probe command and return parsed telemetry.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Telemetry dict as returned by :func:`parse_ssh_probe`, or ``{}``
|
|
108
|
+
if the SSH connection or command fails.
|
|
109
|
+
"""
|
|
76
110
|
try:
|
|
77
111
|
await self._ensure()
|
|
78
112
|
result = await self._conn.run(SSH_PROBE_CMD, check=False, timeout=10)
|
|
@@ -83,6 +117,7 @@ class MisterSSH:
|
|
|
83
117
|
return {}
|
|
84
118
|
|
|
85
119
|
async def async_close(self) -> None:
|
|
120
|
+
"""Close the underlying SSH connection if open."""
|
|
86
121
|
if self._conn is not None:
|
|
87
122
|
self._conn.close()
|
|
88
123
|
self._conn = None
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import asyncio
|
|
5
5
|
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
6
7
|
from dataclasses import replace
|
|
7
8
|
|
|
8
9
|
import aiohttp
|
|
@@ -19,7 +20,32 @@ def apply_ws_message(
|
|
|
19
20
|
menu_path: str | None,
|
|
20
21
|
index_state: tuple[bool, bool],
|
|
21
22
|
) -> tuple[MisterStatus, str | None, tuple[bool, bool]]:
|
|
22
|
-
"""
|
|
23
|
+
"""Apply one WebSocket text frame to the current state triple.
|
|
24
|
+
|
|
25
|
+
This is a *pure* reducer — it never mutates its arguments. Pass it every
|
|
26
|
+
raw text frame received from :class:`MisterWebSocketClient` to keep a
|
|
27
|
+
local :class:`~mister_fpga.MisterStatus` in sync without polling the REST
|
|
28
|
+
API.
|
|
29
|
+
|
|
30
|
+
Understood prefixes (``prefix:rest``):
|
|
31
|
+
|
|
32
|
+
* ``coreRunning`` — updates :attr:`~mister_fpga.MisterStatus.core`.
|
|
33
|
+
* ``gameRunning`` — updates :attr:`~mister_fpga.MisterStatus.game` and
|
|
34
|
+
:attr:`~mister_fpga.MisterStatus.game_name`.
|
|
35
|
+
* ``menuNavigation`` — updates *menu_path*.
|
|
36
|
+
* ``indexStatus`` — updates *index_state* ``(exists, in_progress)``.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
message: Raw text frame received from the WebSocket.
|
|
40
|
+
status: Current device-state snapshot.
|
|
41
|
+
menu_path: Current menu navigation path, or ``None``.
|
|
42
|
+
index_state: Tuple ``(index_exists, index_in_progress)``.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A new ``(status, menu_path, index_state)`` triple reflecting the
|
|
46
|
+
change encoded in *message*. Unrecognised prefixes are passed through
|
|
47
|
+
unchanged.
|
|
48
|
+
"""
|
|
23
49
|
prefix, _, rest = message.partition(":")
|
|
24
50
|
if prefix == "coreRunning":
|
|
25
51
|
core = rest.strip() or None
|
|
@@ -48,14 +74,27 @@ def apply_ws_message(
|
|
|
48
74
|
|
|
49
75
|
|
|
50
76
|
class MisterWebSocketClient:
|
|
51
|
-
"""
|
|
77
|
+
"""Reconnecting WebSocket client for real-time MiSTer Remote events.
|
|
78
|
+
|
|
79
|
+
Connects to the mrext Remote WebSocket endpoint and invokes a callback for
|
|
80
|
+
every TEXT frame received. If the connection drops the client waits
|
|
81
|
+
*reconnect_delay* seconds and reconnects automatically.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
host: IP address or hostname of the MiSTer device.
|
|
85
|
+
port: mrext Remote port (default ``8182``).
|
|
86
|
+
session: Optional shared ``aiohttp.ClientSession``. When ``None`` the
|
|
87
|
+
client creates and owns its own session.
|
|
88
|
+
reconnect_delay: Seconds to wait before reconnecting after a
|
|
89
|
+
connection loss (default ``5``).
|
|
90
|
+
"""
|
|
52
91
|
|
|
53
92
|
def __init__(
|
|
54
93
|
self,
|
|
55
94
|
host: str,
|
|
56
95
|
port: int = 8182,
|
|
57
96
|
*,
|
|
58
|
-
session=None,
|
|
97
|
+
session: aiohttp.ClientSession | None = None,
|
|
59
98
|
reconnect_delay: int = 5,
|
|
60
99
|
) -> None:
|
|
61
100
|
self.host = host
|
|
@@ -67,12 +106,20 @@ class MisterWebSocketClient:
|
|
|
67
106
|
|
|
68
107
|
@property
|
|
69
108
|
def url(self) -> str:
|
|
109
|
+
"""Full WebSocket URL derived from *host* and *port*."""
|
|
70
110
|
return f"ws://{self.host}:{self.port}{WS_PATH}"
|
|
71
111
|
|
|
72
|
-
async def listen(
|
|
73
|
-
|
|
112
|
+
async def listen(
|
|
113
|
+
self,
|
|
114
|
+
on_message: Callable[[str], None | Awaitable[None]],
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Run the reconnect loop, calling *on_message* for each TEXT frame.
|
|
117
|
+
|
|
118
|
+
Blocks until :meth:`stop` is called or the task is cancelled.
|
|
119
|
+
*on_message* may be a plain function or a coroutine function.
|
|
74
120
|
|
|
75
|
-
|
|
121
|
+
Args:
|
|
122
|
+
on_message: Callable ``(text: str) -> None | Awaitable``.
|
|
76
123
|
"""
|
|
77
124
|
owns = self._session is None
|
|
78
125
|
session = self._session or aiohttp.ClientSession()
|
|
@@ -100,4 +147,5 @@ class MisterWebSocketClient:
|
|
|
100
147
|
await session.close()
|
|
101
148
|
|
|
102
149
|
def stop(self) -> None:
|
|
150
|
+
"""Signal the :meth:`listen` loop to exit after the current iteration."""
|
|
103
151
|
self._stop = True
|
|
@@ -341,3 +341,10 @@ async def test_send_keyboard_raw(client):
|
|
|
341
341
|
with aioresponses() as m:
|
|
342
342
|
m.post(f"{BASE}/controls/keyboard-raw/16", status=200)
|
|
343
343
|
await client.async_send_keyboard_raw(16)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_ssh_defaults_exported():
|
|
347
|
+
from mister_fpga import DEFAULT_SSH_PORT, DEFAULT_SSH_USERNAME, SSH_PROBE_CMD
|
|
348
|
+
assert DEFAULT_SSH_PORT == 22
|
|
349
|
+
assert DEFAULT_SSH_USERNAME == "root"
|
|
350
|
+
assert "CORENAME" in SSH_PROBE_CMD
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|