python-mister-fpga 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,20 @@
1
+ """Async Python client for the MiSTer FPGA mrext Remote API."""
2
+ from .client import MisterClient, MisterConnectionError, MisterStatus
3
+ from .const import DEFAULT_PORT, INI_VIDEO_KEYS, KEYBOARD_NAMES, WS_PATH
4
+ from .ssh import MisterSSH, parse_ssh_probe
5
+ from .websocket import MisterWebSocketClient, apply_ws_message
6
+
7
+ __all__ = [
8
+ "MisterClient",
9
+ "MisterStatus",
10
+ "MisterConnectionError",
11
+ "MisterWebSocketClient",
12
+ "apply_ws_message",
13
+ "MisterSSH",
14
+ "parse_ssh_probe",
15
+ "KEYBOARD_NAMES",
16
+ "INI_VIDEO_KEYS",
17
+ "WS_PATH",
18
+ "DEFAULT_PORT",
19
+ ]
20
+ __version__ = "0.1.0"
mister_fpga/client.py ADDED
@@ -0,0 +1,292 @@
1
+ """Async REST client for the MiSTer FPGA mrext Remote API."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ import aiohttp
10
+
11
+ from .const import (
12
+ PATH_CORE_MENU,
13
+ PATH_GAMES_INDEX,
14
+ PATH_GAMES_LAUNCH,
15
+ PATH_GAMES_SEARCH,
16
+ PATH_INIS,
17
+ PATH_KEYBOARD,
18
+ PATH_KEYBOARD_RAW,
19
+ PATH_LAUNCH,
20
+ PATH_LAUNCH_MENU,
21
+ PATH_LAUNCH_NEW,
22
+ PATH_LAUNCH_TOKEN,
23
+ PATH_MUSIC_NEXT,
24
+ PATH_MUSIC_PLAY,
25
+ PATH_MUSIC_PLAYBACK,
26
+ PATH_MUSIC_PLAYLIST,
27
+ PATH_MUSIC_STATUS,
28
+ PATH_MUSIC_STOP,
29
+ PATH_PEERS,
30
+ PATH_PLAYING,
31
+ PATH_REBOOT,
32
+ PATH_RESTART_REMOTE,
33
+ PATH_SCREENSHOTS,
34
+ PATH_SCRIPTS_CONSOLE,
35
+ PATH_SCRIPTS_KILL,
36
+ PATH_SCRIPTS_LAUNCH,
37
+ PATH_SCRIPTS_LIST,
38
+ PATH_SYSINFO,
39
+ PATH_SYSTEMS,
40
+ PATH_WALLPAPERS,
41
+ )
42
+
43
+ _LOGGER = logging.getLogger(__name__)
44
+
45
+
46
+ class MisterConnectionError(Exception):
47
+ """Raised when the MiSTer Remote API is unreachable or returns an error."""
48
+
49
+
50
+ @dataclass
51
+ class MisterStatus:
52
+ """Snapshot of the MiSTer device state."""
53
+
54
+ online: bool = False
55
+ core: str | None = None
56
+ system: str | None = None
57
+ system_name: str | None = None
58
+ game: str | None = None
59
+ game_name: str | None = None
60
+ hostname: str | None = None
61
+ version: str | None = None
62
+ ip: str | None = None
63
+ ips: list[str] = field(default_factory=list)
64
+ updated: str | None = None
65
+ dns: str | None = None
66
+ disk_total: int | None = None
67
+ disk_used: int | None = None
68
+ disk_free: int | None = None
69
+
70
+ @property
71
+ def is_running_game(self) -> bool:
72
+ """True when a real core/game (not the menu) is running."""
73
+ if not self.online:
74
+ return False
75
+ core = (self.core or "").strip().lower()
76
+ return bool(core) and core not in ("menu", "none")
77
+
78
+
79
+ class MisterClient:
80
+ """Thin async wrapper around the mrext Remote REST API."""
81
+
82
+ def __init__(
83
+ self,
84
+ host: str,
85
+ port: int = 8182,
86
+ *,
87
+ session: aiohttp.ClientSession | None = None,
88
+ timeout: int = 10,
89
+ ) -> None:
90
+ self.host = host
91
+ self.port = port
92
+ self.base_url = f"http://{host}:{port}/api"
93
+ self._timeout = timeout
94
+ self._session = session
95
+ self._owns_session = session is None
96
+
97
+ async def _get_session(self) -> aiohttp.ClientSession:
98
+ if self._session is None:
99
+ self._session = aiohttp.ClientSession()
100
+ return self._session
101
+
102
+ async def async_close(self) -> None:
103
+ """Close the session only if this client created it."""
104
+ if self._owns_session and self._session is not None:
105
+ await self._session.close()
106
+ self._session = None
107
+
108
+ async def _request(
109
+ self,
110
+ method: str,
111
+ path: str,
112
+ *,
113
+ payload: dict | None = None,
114
+ parse_json: bool = True,
115
+ ) -> Any:
116
+ session = await self._get_session()
117
+ url = f"{self.base_url}{path}"
118
+ try:
119
+ async with session.request(
120
+ method,
121
+ url,
122
+ json=payload,
123
+ timeout=aiohttp.ClientTimeout(total=self._timeout),
124
+ ) as resp:
125
+ resp.raise_for_status()
126
+ if not parse_json:
127
+ return await resp.read()
128
+ text = await resp.text()
129
+ if not text.strip():
130
+ return None
131
+ try:
132
+ return json.loads(text)
133
+ except json.JSONDecodeError as err:
134
+ raise MisterConnectionError(
135
+ f"{method} {url} returned invalid JSON: {err}"
136
+ ) from err
137
+ except (TimeoutError, aiohttp.ClientError) as err:
138
+ _LOGGER.debug("Request %s %s failed: %s", method, url, err)
139
+ raise MisterConnectionError(f"{method} {url} failed: {err}") from err
140
+
141
+ async def async_get_status(self) -> MisterStatus:
142
+ sysinfo = await self._request("GET", PATH_SYSINFO) or {}
143
+ playing = await self._request("GET", PATH_PLAYING) or {}
144
+ ips = sysinfo.get("ips") or []
145
+ disks = sysinfo.get("disks") or []
146
+ disk = disks[0] if disks else {}
147
+ return MisterStatus(
148
+ online=True,
149
+ core=playing.get("core") or None,
150
+ system=playing.get("system") or None,
151
+ system_name=playing.get("systemName") or None,
152
+ game=playing.get("game") or None,
153
+ game_name=playing.get("gameName") or None,
154
+ hostname=sysinfo.get("hostname"),
155
+ version=sysinfo.get("version"),
156
+ ips=ips,
157
+ ip=ips[0] if ips else None,
158
+ updated=sysinfo.get("updated"),
159
+ dns=sysinfo.get("dns"),
160
+ disk_total=disk.get("total"),
161
+ disk_used=disk.get("used"),
162
+ disk_free=disk.get("free"),
163
+ )
164
+
165
+ async def async_get_systems(self) -> list[dict]:
166
+ return await self._request("GET", PATH_SYSTEMS) or []
167
+
168
+ async def async_launch_system(self, system_id: str) -> None:
169
+ await self._request("POST", f"{PATH_SYSTEMS}/{system_id}")
170
+
171
+ async def async_launch_game(self, path: str) -> None:
172
+ await self._request("POST", PATH_GAMES_LAUNCH, payload={"path": path})
173
+
174
+ async def async_launch_menu(self) -> None:
175
+ await self._request("POST", PATH_LAUNCH_MENU)
176
+
177
+ async def async_search_games(self, query: str, system: str = "all") -> dict:
178
+ return (
179
+ await self._request(
180
+ "POST", PATH_GAMES_SEARCH, payload={"data": query, "system": system}
181
+ )
182
+ or {}
183
+ )
184
+
185
+ async def async_index_games(self) -> None:
186
+ await self._request("POST", PATH_GAMES_INDEX)
187
+
188
+ async def async_send_keyboard(self, name: str) -> None:
189
+ await self._request("POST", f"{PATH_KEYBOARD}/{name}")
190
+
191
+ async def async_reboot(self) -> None:
192
+ await self._request("POST", PATH_REBOOT)
193
+
194
+ async def async_restart_remote(self) -> None:
195
+ await self._request("POST", PATH_RESTART_REMOTE)
196
+
197
+ async def async_take_screenshot(self) -> None:
198
+ await self._request("POST", PATH_SCREENSHOTS)
199
+
200
+ async def async_get_screenshots(self) -> list[dict]:
201
+ return await self._request("GET", PATH_SCREENSHOTS) or []
202
+
203
+ async def async_get_screenshot_image(self, core: str, filename: str) -> bytes:
204
+ return await self._request(
205
+ "GET", f"{PATH_SCREENSHOTS}/{core}/{filename}", parse_json=False
206
+ )
207
+
208
+ async def async_get_music_status(self) -> dict:
209
+ return await self._request("GET", PATH_MUSIC_STATUS) or {}
210
+
211
+ async def async_music_play(self) -> None:
212
+ await self._request("POST", PATH_MUSIC_PLAY)
213
+
214
+ async def async_music_stop(self) -> None:
215
+ await self._request("POST", PATH_MUSIC_STOP)
216
+
217
+ async def async_music_next(self) -> None:
218
+ await self._request("POST", PATH_MUSIC_NEXT)
219
+
220
+ # --- Wallpapers ---
221
+ async def async_get_wallpapers(self) -> dict:
222
+ return await self._request("GET", PATH_WALLPAPERS) or {}
223
+
224
+ async def async_set_wallpaper(self, filename: str) -> None:
225
+ await self._request("POST", f"{PATH_WALLPAPERS}/{filename}")
226
+
227
+ async def async_clear_wallpaper(self) -> None:
228
+ await self._request("DELETE", PATH_WALLPAPERS)
229
+
230
+ # --- INI files ---
231
+ async def async_get_inis(self) -> dict:
232
+ return await self._request("GET", PATH_INIS) or {}
233
+
234
+ async def async_get_ini_values(self, ini_id: int) -> dict:
235
+ return await self._request("GET", f"{PATH_INIS}/{ini_id}") or {}
236
+
237
+ async def async_set_active_ini(self, ini_id: int) -> None:
238
+ await self._request("PUT", PATH_INIS, payload={"ini": ini_id})
239
+
240
+ async def async_set_ini_values(self, ini_id: int, values: dict) -> None:
241
+ await self._request("PUT", f"{PATH_INIS}/{ini_id}", payload=values)
242
+
243
+ async def async_set_background_mode(self, mode: int) -> None:
244
+ await self._request("PUT", PATH_CORE_MENU, payload={"mode": mode})
245
+
246
+ # --- Music (extended) ---
247
+ async def async_get_music_playlists(self) -> list[str]:
248
+ return await self._request("GET", PATH_MUSIC_PLAYLIST) or []
249
+
250
+ async def async_set_music_playlist(self, name: str) -> None:
251
+ await self._request("POST", f"{PATH_MUSIC_PLAYLIST}/{name}")
252
+
253
+ async def async_set_music_playback(self, mode: str) -> None:
254
+ await self._request("POST", f"{PATH_MUSIC_PLAYBACK}/{mode}")
255
+
256
+ # --- Scripts ---
257
+ async def async_get_scripts(self) -> dict:
258
+ return await self._request("GET", PATH_SCRIPTS_LIST) or {}
259
+
260
+ async def async_launch_script(self, filename: str) -> None:
261
+ await self._request("POST", f"{PATH_SCRIPTS_LAUNCH}/{filename}")
262
+
263
+ async def async_open_console(self) -> None:
264
+ await self._request("POST", PATH_SCRIPTS_CONSOLE)
265
+
266
+ async def async_kill_script(self) -> None:
267
+ await self._request("POST", PATH_SCRIPTS_KILL)
268
+
269
+ # --- Peers ---
270
+ async def async_get_peers(self) -> list[dict]:
271
+ data = await self._request("GET", PATH_PEERS) or {}
272
+ return data.get("peers", [])
273
+
274
+ # --- Launchers ---
275
+ async def async_launch_path(self, path: str) -> None:
276
+ await self._request("POST", PATH_LAUNCH, payload={"path": path})
277
+
278
+ async def async_launch_token(self, data: str) -> None:
279
+ await self._request("GET", f"{PATH_LAUNCH_TOKEN}/{data}")
280
+
281
+ async def async_create_shortcut(
282
+ self, game_path: str, folder: str, name: str
283
+ ) -> dict:
284
+ return await self._request(
285
+ "POST",
286
+ PATH_LAUNCH_NEW,
287
+ payload={"gamePath": game_path, "folder": folder, "name": name},
288
+ ) or {}
289
+
290
+ # --- Raw keyboard ---
291
+ async def async_send_keyboard_raw(self, code: int) -> None:
292
+ await self._request("POST", f"{PATH_KEYBOARD_RAW}/{code}")
mister_fpga/const.py ADDED
@@ -0,0 +1,62 @@
1
+ """Protocol constants for the MiSTer FPGA mrext Remote API."""
2
+ from __future__ import annotations
3
+
4
+ DEFAULT_PORT = 8182
5
+ HTTP_TIMEOUT = 10
6
+
7
+ # mrext Remote API paths (relative to base_url = http://host:port/api)
8
+ PATH_PLAYING = "/games/playing"
9
+ PATH_SYSINFO = "/sysinfo"
10
+ PATH_SYSTEMS = "/systems"
11
+ PATH_GAMES_LAUNCH = "/games/launch"
12
+ PATH_GAMES_SEARCH = "/games/search"
13
+ PATH_GAMES_INDEX = "/games/index"
14
+ PATH_LAUNCH_MENU = "/launch/menu"
15
+ PATH_KEYBOARD = "/controls/keyboard"
16
+ PATH_REBOOT = "/settings/system/reboot"
17
+ PATH_RESTART_REMOTE = "/settings/remote/restart"
18
+ PATH_SCREENSHOTS = "/screenshots"
19
+ PATH_MUSIC_STATUS = "/music/status"
20
+ PATH_MUSIC_PLAY = "/music/play"
21
+ PATH_MUSIC_STOP = "/music/stop"
22
+ PATH_MUSIC_NEXT = "/music/next"
23
+ PATH_MUSIC_PLAYLIST = "/music/playlist"
24
+ PATH_MUSIC_PLAYBACK = "/music/playback"
25
+ PATH_WALLPAPERS = "/wallpapers"
26
+ PATH_INIS = "/settings/inis"
27
+ PATH_CORE_MENU = "/settings/core/menu"
28
+ PATH_SCRIPTS_LIST = "/scripts/list"
29
+ PATH_SCRIPTS_LAUNCH = "/scripts/launch"
30
+ PATH_SCRIPTS_CONSOLE = "/scripts/console"
31
+ PATH_SCRIPTS_KILL = "/scripts/kill"
32
+ PATH_PEERS = "/settings/remote/peers"
33
+ PATH_LAUNCH = "/launch"
34
+ PATH_LAUNCH_TOKEN = "/l"
35
+ PATH_LAUNCH_NEW = "/launch/new"
36
+ PATH_KEYBOARD_RAW = "/controls/keyboard-raw"
37
+
38
+ # WebSocket endpoint (mounted under /api on Remote v0.4)
39
+ WS_PATH = "/api/ws"
40
+
41
+ # MiSTer.ini keys exposed as Number entities (value range 0-100)
42
+ INI_VIDEO_KEYS = ("video_brightness", "video_contrast", "video_saturation")
43
+
44
+ # SSH defaults and probe command
45
+ DEFAULT_SSH_PORT = 22
46
+ DEFAULT_SSH_USERNAME = "root"
47
+
48
+ SSH_PROBE_CMD = (
49
+ "cat /tmp/CORENAME 2>/dev/null; echo '|||'; "
50
+ "cat /proc/uptime 2>/dev/null; echo '|||'; "
51
+ "cat /proc/loadavg 2>/dev/null; echo '|||'; "
52
+ "awk '/MemTotal|MemAvailable/{print $2}' /proc/meminfo 2>/dev/null; echo '|||'; "
53
+ "stat -c %Y /media/fat/MiSTer 2>/dev/null"
54
+ )
55
+
56
+ # Keyboard control names accepted by POST /controls/keyboard/{name}
57
+ KEYBOARD_NAMES = (
58
+ "up", "down", "left", "right", "confirm", "back", "cancel", "menu",
59
+ "osd", "core_select", "user", "volume_up", "volume_down", "volume_mute",
60
+ "reset", "screenshot", "raw_screenshot", "console", "exit_console",
61
+ "computer_osd", "change_background", "pair_bluetooth", "toggle_core_dates",
62
+ )
mister_fpga/py.typed ADDED
File without changes
mister_fpga/ssh.py ADDED
@@ -0,0 +1,88 @@
1
+ """Optional SSH telemetry client for the MiSTer FPGA."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+
6
+ from .const import SSH_PROBE_CMD
7
+
8
+ _LOGGER = logging.getLogger(__name__)
9
+
10
+ _SEP = "|||"
11
+
12
+
13
+ def parse_ssh_probe(raw: str) -> dict:
14
+ """Parse the batched SSH probe output into a telemetry dict. Tolerant of blanks."""
15
+ parts = [p.strip() for p in raw.split(_SEP)]
16
+ while len(parts) < 5:
17
+ parts.append("")
18
+ core, uptime_s, load_s, mem_s, fw_s = parts[:5]
19
+
20
+ def _int(value: str) -> int | None:
21
+ try:
22
+ return int(float(value))
23
+ except (TypeError, ValueError):
24
+ return None
25
+
26
+ uptime = _int(uptime_s.split()[0]) if uptime_s else None
27
+ load_1m = None
28
+ if load_s:
29
+ try:
30
+ load_1m = float(load_s.split()[0])
31
+ except (ValueError, IndexError):
32
+ load_1m = None
33
+ mem_used_pct = None
34
+ mem_lines = [m for m in mem_s.splitlines() if m]
35
+ if len(mem_lines) >= 2:
36
+ total = _int(mem_lines[0])
37
+ avail = _int(mem_lines[1])
38
+ if total:
39
+ mem_used_pct = round((total - avail) / total * 100, 1)
40
+ fw_ts = _int(fw_s) if fw_s else None
41
+
42
+ return {
43
+ "active_core": core or None,
44
+ "uptime_seconds": uptime,
45
+ "cpu_load_1m": load_1m,
46
+ "memory_used_percent": mem_used_pct,
47
+ "firmware_timestamp": fw_ts,
48
+ }
49
+
50
+
51
+ class MisterSSH:
52
+ """Maintains a persistent asyncssh connection and runs the probe."""
53
+
54
+ def __init__(self, host: str, port: int, username: str, password: str) -> None:
55
+ self.host = host
56
+ self.port = port
57
+ self.username = username
58
+ self.password = password
59
+ self._conn = None
60
+
61
+ async def _ensure(self) -> None:
62
+ if self._conn is not None:
63
+ return
64
+ import asyncssh
65
+
66
+ self._conn = await asyncssh.connect(
67
+ self.host,
68
+ port=self.port,
69
+ username=self.username,
70
+ password=self.password,
71
+ known_hosts=None,
72
+ )
73
+
74
+ async def async_probe(self) -> dict:
75
+ """Run the probe; returns {} on any failure (SSH is best-effort)."""
76
+ try:
77
+ await self._ensure()
78
+ result = await self._conn.run(SSH_PROBE_CMD, check=False, timeout=10)
79
+ return parse_ssh_probe(result.stdout or "")
80
+ except Exception as err: # noqa: BLE001 - asyncssh raises many types; SSH is best-effort and must never break the HTTP integration
81
+ _LOGGER.debug("MiSTer SSH probe failed: %s", err)
82
+ self._conn = None
83
+ return {}
84
+
85
+ async def async_close(self) -> None:
86
+ if self._conn is not None:
87
+ self._conn.close()
88
+ self._conn = None
@@ -0,0 +1,103 @@
1
+ """WebSocket client for real-time MiSTer Remote updates."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from dataclasses import replace
7
+
8
+ import aiohttp
9
+
10
+ from .client import MisterStatus
11
+ from .const import WS_PATH
12
+
13
+ _LOGGER = logging.getLogger(__name__)
14
+
15
+
16
+ def apply_ws_message(
17
+ message: str,
18
+ status: MisterStatus,
19
+ menu_path: str | None,
20
+ index_state: tuple[bool, bool],
21
+ ) -> tuple[MisterStatus, str | None, tuple[bool, bool]]:
22
+ """Pure reducer: apply one WS text frame to (status, menu_path, index_state)."""
23
+ prefix, _, rest = message.partition(":")
24
+ if prefix == "coreRunning":
25
+ core = rest.strip() or None
26
+ if core is None:
27
+ return (
28
+ replace(status, core=None, game=None, game_name=None),
29
+ menu_path,
30
+ index_state,
31
+ )
32
+ return replace(status, core=core), menu_path, index_state
33
+ if prefix == "gameRunning":
34
+ rest = rest.strip()
35
+ if not rest:
36
+ return replace(status, game=None, game_name=None), menu_path, index_state
37
+ _, _, name = rest.partition("/")
38
+ game_name = name.rsplit(".", 1)[0] if name else None
39
+ return replace(status, game=rest, game_name=game_name), menu_path, index_state
40
+ if prefix == "menuNavigation":
41
+ return status, rest.strip() or None, index_state
42
+ if prefix == "indexStatus":
43
+ parts = rest.split(",")
44
+ exists = len(parts) > 0 and parts[0] == "y"
45
+ in_progress = len(parts) > 1 and parts[1] == "y"
46
+ return status, menu_path, (exists, in_progress)
47
+ return status, menu_path, index_state
48
+
49
+
50
+ class MisterWebSocketClient:
51
+ """Connects to the mrext Remote WebSocket and invokes a callback per text frame."""
52
+
53
+ def __init__(
54
+ self,
55
+ host: str,
56
+ port: int = 8182,
57
+ *,
58
+ session=None,
59
+ reconnect_delay: int = 5,
60
+ ) -> None:
61
+ self.host = host
62
+ self.port = port
63
+ self._session = session
64
+ self._owns_session = session is None
65
+ self._reconnect_delay = reconnect_delay
66
+ self._stop = False
67
+
68
+ @property
69
+ def url(self) -> str:
70
+ return f"ws://{self.host}:{self.port}{WS_PATH}"
71
+
72
+ async def listen(self, on_message) -> None:
73
+ """Run the reconnect loop, calling on_message(text) for each TEXT frame.
74
+
75
+ on_message may be sync or async. Runs until stop() or cancellation.
76
+ """
77
+ owns = self._session is None
78
+ session = self._session or aiohttp.ClientSession()
79
+ try:
80
+ while not self._stop:
81
+ try:
82
+ async with session.ws_connect(self.url, heartbeat=30) as ws:
83
+ async for msg in ws:
84
+ if msg.type == aiohttp.WSMsgType.TEXT:
85
+ result = on_message(msg.data)
86
+ if hasattr(result, "__await__"):
87
+ await result
88
+ elif msg.type in (
89
+ aiohttp.WSMsgType.CLOSED,
90
+ aiohttp.WSMsgType.ERROR,
91
+ ):
92
+ break
93
+ except (aiohttp.ClientError, TimeoutError):
94
+ pass
95
+ if self._stop:
96
+ break
97
+ await asyncio.sleep(self._reconnect_delay)
98
+ finally:
99
+ if owns:
100
+ await session.close()
101
+
102
+ def stop(self) -> None:
103
+ self._stop = True
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-mister-fpga
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry
5
+ Project-URL: Homepage, https://github.com/hudsonbrendon/python-mister-fpga
6
+ Project-URL: Issues, https://github.com/hudsonbrendon/python-mister-fpga/issues
7
+ Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: aiohttp,async,fpga,mister,mrext,retro
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: aiohttp>=3.9
20
+ Requires-Dist: asyncssh>=2.21
21
+ Provides-Extra: test
22
+ Requires-Dist: aioresponses>=0.7; extra == 'test'
23
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
24
+ Requires-Dist: pytest>=8; extra == 'test'
25
+ Requires-Dist: ruff; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ <p align="center">
29
+ <picture>
30
+ <source media="(prefers-color-scheme: dark)" srcset="assets/dark_logo.png" width="420">
31
+ <img src="assets/logo.png" alt="python-mister-fpga" width="420">
32
+ </picture>
33
+ </p>
34
+
35
+ # python-mister-fpga
36
+
37
+ [![Tests](https://github.com/hudsonbrendon/python-mister-fpga/actions/workflows/tests.yml/badge.svg)](https://github.com/hudsonbrendon/python-mister-fpga/actions/workflows/tests.yml)
38
+ [![PyPI version](https://img.shields.io/pypi/v/python-mister-fpga)](https://pypi.org/project/python-mister-fpga/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/python-mister-fpga)](https://pypi.org/project/python-mister-fpga/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
41
+
42
+ 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
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install python-mister-fpga
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### REST client
53
+
54
+ ```python
55
+ import asyncio
56
+ from mister_fpga import MisterClient
57
+
58
+ async def main():
59
+ client = MisterClient("192.168.1.50")
60
+ status = await client.async_get_status()
61
+ print(status.core, status.game)
62
+ await client.async_launch_game("/media/fat/games/SNES/Chrono.sfc")
63
+ await client.async_close()
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ### WebSocket (real-time updates)
69
+
70
+ ```python
71
+ import asyncio
72
+ from mister_fpga import MisterWebSocketClient, MisterStatus, apply_ws_message
73
+
74
+ state = MisterStatus(online=True)
75
+ menu_path = None
76
+ index_state = (False, False)
77
+
78
+ def on_message(text: str) -> None:
79
+ global state, menu_path, index_state
80
+ state, menu_path, index_state = apply_ws_message(text, state, menu_path, index_state)
81
+ print(state.core, state.game)
82
+
83
+ async def main():
84
+ ws = MisterWebSocketClient("192.168.1.50")
85
+ await ws.listen(on_message)
86
+
87
+ asyncio.run(main())
88
+ ```
89
+
90
+ ### SSH telemetry
91
+
92
+ ```python
93
+ import asyncio
94
+ from mister_fpga import MisterSSH
95
+
96
+ async def main():
97
+ ssh = MisterSSH("192.168.1.50", 22, "root", "1")
98
+ data = await ssh.async_probe()
99
+ print(data)
100
+ await ssh.async_close()
101
+
102
+ asyncio.run(main())
103
+ ```
104
+
105
+ ## API
106
+
107
+ - **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
108
+ - **`MisterStatus`** — dataclass snapshot: `online`, `core`, `system`, `game`, `hostname`, `version`, `ip`, `ips`, `dns`, `disk_total/used/free`. Property `is_running_game`.
109
+ - **`MisterConnectionError`** — raised on network/HTTP errors.
110
+ - **`MisterWebSocketClient(host, port=8182, *, session=None, reconnect_delay=5)`** — reconnecting WS loop; `await ws.listen(callback)`, call `ws.stop()` to exit.
111
+ - **`apply_ws_message(message, status, menu_path, index_state)`** — pure reducer; apply a single WS text frame and return updated `(status, menu_path, index_state)`.
112
+ - **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
113
+ - **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
114
+ - **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
115
+
116
+ ## Credits
117
+
118
+ 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).
119
+
120
+ ## License
121
+
122
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ mister_fpga/__init__.py,sha256=J7zvYdc0fwYxXaJYdmemVcJL1c0zJKSC9Lmkg9k_qa8,592
2
+ mister_fpga/client.py,sha256=4OHlmnEvh-opy4vTFdWYPmggIOeDhzbqS1CdGUC7Uyk,9828
3
+ mister_fpga/const.py,sha256=Lb0CC5eK4r9dJH4RQtjEmSb6VgTE-TaEqycCuAud6VE,2223
4
+ mister_fpga/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mister_fpga/ssh.py,sha256=sP9OLmn7v7VZHLIG2YVZdgjBMKEXAT-JWymu7Elf-OA,2700
6
+ mister_fpga/websocket.py,sha256=1pWvaxLOVeQsYcW40tviowkXupX3pahNW2E8YoJDiY8,3525
7
+ python_mister_fpga-0.1.0.dist-info/METADATA,sha256=N7Byl3ApzL1y1up8_qkh6u5CG3jXaZSAxMy8ufEZWok,4631
8
+ python_mister_fpga-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ python_mister_fpga-0.1.0.dist-info/licenses/LICENSE,sha256=4Zgd3fbLJqWSkwKA-hh1rNN6DC9tNQ3rWLX93fingOw,1071
10
+ python_mister_fpga-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hudson Brendon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.