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.
- mister_fpga/__init__.py +20 -0
- mister_fpga/client.py +292 -0
- mister_fpga/const.py +62 -0
- mister_fpga/py.typed +0 -0
- mister_fpga/ssh.py +88 -0
- mister_fpga/websocket.py +103 -0
- python_mister_fpga-0.1.0.dist-info/METADATA +122 -0
- python_mister_fpga-0.1.0.dist-info/RECORD +10 -0
- python_mister_fpga-0.1.0.dist-info/WHEEL +4 -0
- python_mister_fpga-0.1.0.dist-info/licenses/LICENSE +21 -0
mister_fpga/__init__.py
ADDED
|
@@ -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
|
mister_fpga/websocket.py
ADDED
|
@@ -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
|
+
[](https://github.com/hudsonbrendon/python-mister-fpga/actions/workflows/tests.yml)
|
|
38
|
+
[](https://pypi.org/project/python-mister-fpga/)
|
|
39
|
+
[](https://pypi.org/project/python-mister-fpga/)
|
|
40
|
+
[](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,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.
|