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.
Files changed (29) hide show
  1. python_mister_fpga-0.1.2/.github/workflows/docs.yml +33 -0
  2. python_mister_fpga-0.1.2/.github/workflows/release.yml +26 -0
  3. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/.gitignore +1 -0
  4. python_mister_fpga-0.1.2/CHANGELOG.md +10 -0
  5. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/PKG-INFO +10 -1
  6. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/README.md +5 -0
  7. python_mister_fpga-0.1.2/docs/api.md +15 -0
  8. python_mister_fpga-0.1.2/docs/assets/dark_logo.png +0 -0
  9. python_mister_fpga-0.1.2/docs/assets/icon.png +0 -0
  10. python_mister_fpga-0.1.2/docs/assets/logo.png +0 -0
  11. python_mister_fpga-0.1.2/docs/changelog.md +13 -0
  12. python_mister_fpga-0.1.2/docs/index.md +37 -0
  13. python_mister_fpga-0.1.2/docs/usage.md +92 -0
  14. python_mister_fpga-0.1.2/mkdocs.yml +49 -0
  15. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/pyproject.toml +3 -1
  16. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/__init__.py +13 -2
  17. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/client.py +115 -4
  18. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/ssh.py +38 -3
  19. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/websocket.py +54 -6
  20. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/tests/test_client.py +7 -0
  21. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/.github/workflows/tests.yml +0 -0
  22. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/LICENSE +0 -0
  23. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/assets/dark_logo.png +0 -0
  24. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/assets/icon.png +0 -0
  25. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/assets/logo.png +0 -0
  26. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/const.py +0 -0
  27. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/src/mister_fpga/py.typed +0 -0
  28. {python_mister_fpga-0.1.0 → python_mister_fpga-0.1.2}/tests/test_ssh.py +0 -0
  29. {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
@@ -8,3 +8,4 @@ build/
8
8
  .ruff_cache/
9
9
  .coverage
10
10
  .DS_Store
11
+ site/
@@ -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.0
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
  [![PyPI version](https://img.shields.io/pypi/v/python-mister-fpga)](https://pypi.org/project/python-mister-fpga/)
39
43
  [![Python versions](https://img.shields.io/pypi/pyversions/python-mister-fpga)](https://pypi.org/project/python-mister-fpga/)
40
44
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+ [![Docs](https://img.shields.io/badge/docs-mkdocs--material-blue)](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
  [![PyPI version](https://img.shields.io/pypi/v/python-mister-fpga)](https://pypi.org/project/python-mister-fpga/)
12
12
  [![Python versions](https://img.shields.io/pypi/pyversions/python-mister-fpga)](https://pypi.org/project/python-mister-fpga/)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
14
+ [![Docs](https://img.shields.io/badge/docs-mkdocs--material-blue)](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
@@ -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.0"
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 DEFAULT_PORT, INI_VIDEO_KEYS, KEYBOARD_NAMES, WS_PATH
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.0"
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
- """Thin async wrapper around the mrext Remote REST API."""
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 only if this client created it."""
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. Tolerant of blanks."""
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
- """Maintains a persistent asyncssh connection and runs the probe."""
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; returns {} on any failure (SSH is best-effort)."""
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
- """Pure reducer: apply one WS text frame to (status, menu_path, index_state)."""
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
- """Connects to the mrext Remote WebSocket and invokes a callback per text frame."""
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(self, on_message) -> None:
73
- """Run the reconnect loop, calling on_message(text) for each TEXT frame.
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
- on_message may be sync or async. Runs until stop() or cancellation.
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