python-mister-fpga 0.1.2__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/PKG-INFO +1 -1
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/pyproject.toml +1 -1
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/__init__.py +13 -1
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/const.py +36 -0
- python_mister_fpga-0.1.3/src/mister_fpga/ra.py +82 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/ssh.py +20 -10
- python_mister_fpga-0.1.3/tests/test_ra.py +163 -0
- python_mister_fpga-0.1.3/tests/test_ssh.py +82 -0
- python_mister_fpga-0.1.2/tests/test_ssh.py +0 -25
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.github/workflows/docs.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.github/workflows/release.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.github/workflows/tests.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.gitignore +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/CHANGELOG.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/LICENSE +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/README.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/assets/icon.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/assets/logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/api.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/assets/dark_logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/assets/icon.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/assets/logo.png +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/changelog.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/index.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/usage.md +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/mkdocs.yml +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/client.py +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/py.typed +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/websocket.py +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/tests/test_client.py +0 -0
- {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/tests/test_websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mister-fpga
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry
|
|
5
5
|
Project-URL: Homepage, https://github.com/hudsonbrendon/python-mister-fpga
|
|
6
6
|
Project-URL: Documentation, https://hudsonbrendon.github.io/python-mister-fpga/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mister-fpga"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -6,9 +6,14 @@ from .const import (
|
|
|
6
6
|
DEFAULT_SSH_USERNAME,
|
|
7
7
|
INI_VIDEO_KEYS,
|
|
8
8
|
KEYBOARD_NAMES,
|
|
9
|
+
RA_STATUS_CMD,
|
|
10
|
+
RA_SUPPORTED_SYSTEMS,
|
|
11
|
+
RA_USERNAME_PLACEHOLDER,
|
|
9
12
|
SSH_PROBE_CMD,
|
|
10
13
|
WS_PATH,
|
|
14
|
+
MisterRAStatus,
|
|
11
15
|
)
|
|
16
|
+
from .ra import MisterRA, MisterRAError, parse_ra_status
|
|
12
17
|
from .ssh import MisterSSH, parse_ssh_probe
|
|
13
18
|
from .websocket import MisterWebSocketClient, apply_ws_message
|
|
14
19
|
|
|
@@ -16,16 +21,23 @@ __all__ = [
|
|
|
16
21
|
"MisterClient",
|
|
17
22
|
"MisterStatus",
|
|
18
23
|
"MisterConnectionError",
|
|
24
|
+
"MisterRA",
|
|
25
|
+
"MisterRAError",
|
|
26
|
+
"MisterRAStatus",
|
|
19
27
|
"MisterWebSocketClient",
|
|
20
28
|
"apply_ws_message",
|
|
21
29
|
"MisterSSH",
|
|
30
|
+
"parse_ra_status",
|
|
22
31
|
"parse_ssh_probe",
|
|
23
32
|
"KEYBOARD_NAMES",
|
|
24
33
|
"INI_VIDEO_KEYS",
|
|
34
|
+
"RA_STATUS_CMD",
|
|
35
|
+
"RA_SUPPORTED_SYSTEMS",
|
|
36
|
+
"RA_USERNAME_PLACEHOLDER",
|
|
25
37
|
"WS_PATH",
|
|
26
38
|
"DEFAULT_PORT",
|
|
27
39
|
"DEFAULT_SSH_PORT",
|
|
28
40
|
"DEFAULT_SSH_USERNAME",
|
|
29
41
|
"SSH_PROBE_CMD",
|
|
30
42
|
]
|
|
31
|
-
__version__ = "0.1.
|
|
43
|
+
__version__ = "0.1.3"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Protocol constants for the MiSTer FPGA mrext Remote API."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
4
6
|
DEFAULT_PORT = 8182
|
|
5
7
|
HTTP_TIMEOUT = 10
|
|
6
8
|
|
|
@@ -60,3 +62,37 @@ KEYBOARD_NAMES = (
|
|
|
60
62
|
"reset", "screenshot", "raw_screenshot", "console", "exit_console",
|
|
61
63
|
"computer_osd", "change_background", "pair_bluetooth", "toggle_core_dates",
|
|
62
64
|
)
|
|
65
|
+
|
|
66
|
+
# RetroAchievements (local, via SSH helper scripts) ------------------------
|
|
67
|
+
|
|
68
|
+
# mrext system keys for cores with odelot RA-modified builds.
|
|
69
|
+
RA_SUPPORTED_SYSTEMS = (
|
|
70
|
+
"NES", "FDS", "SNES", "MegaDrive", "SMS", "Gameboy", "N64", "PSX",
|
|
71
|
+
"GBA", "MegaCD", "NeoGeo", "TurboGrafx16", "Atari2600", "Atari7800", "S32X",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Placeholder written by the RA installer before the user fills credentials.
|
|
75
|
+
RA_USERNAME_PLACEHOLDER = "YOUR_RA_USERNAME"
|
|
76
|
+
|
|
77
|
+
# One-shot SSH command emitting parseable RA state. Combines an install probe,
|
|
78
|
+
# the username from the cfg, and ra_status.sh output.
|
|
79
|
+
RA_STATUS_CMD = (
|
|
80
|
+
"if [ -d /media/fat/_RA_Cores ]; then echo installed=1; "
|
|
81
|
+
"else echo installed=0; fi; "
|
|
82
|
+
"echo \"username=$(sed -n 's/^username=//p' "
|
|
83
|
+
"/media/fat/retroachievements.cfg 2>/dev/null | head -1)\"; "
|
|
84
|
+
"bash /media/fat/Scripts/.ra/ra_status.sh 2>/dev/null"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class MisterRAStatus:
|
|
90
|
+
"""Parsed RetroAchievements local state."""
|
|
91
|
+
|
|
92
|
+
installed: bool = False
|
|
93
|
+
cores_on: bool = False
|
|
94
|
+
binary_ra: bool = False
|
|
95
|
+
hardcore: bool = False
|
|
96
|
+
username: str | None = None
|
|
97
|
+
cores_active: int = 0
|
|
98
|
+
cores_total: int = 0
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""RetroAchievements local control over SSH (best-effort)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .const import RA_STATUS_CMD, RA_USERNAME_PLACEHOLDER, MisterRAStatus
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Matches a per-core table row: "NES RA -> ..." or "PSX STOCK ...".
|
|
12
|
+
_CORE_ROW = re.compile(r"^\S+\s+(RA|STOCK)\b")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_ra_status(raw: str) -> MisterRAStatus:
|
|
16
|
+
"""Parse RA_STATUS_CMD output into a MisterRAStatus.
|
|
17
|
+
|
|
18
|
+
Tolerant of format drift: matches stable prefixes, ignores unknown lines,
|
|
19
|
+
and never raises.
|
|
20
|
+
"""
|
|
21
|
+
status = MisterRAStatus()
|
|
22
|
+
total = 0
|
|
23
|
+
active = 0
|
|
24
|
+
for line in raw.splitlines():
|
|
25
|
+
stripped = line.strip()
|
|
26
|
+
if stripped.startswith("installed="):
|
|
27
|
+
status.installed = stripped.split("=", 1)[1].strip() == "1"
|
|
28
|
+
elif stripped.startswith("username="):
|
|
29
|
+
value = stripped.split("=", 1)[1].strip()
|
|
30
|
+
status.username = (
|
|
31
|
+
value if value and value != RA_USERNAME_PLACEHOLDER else None
|
|
32
|
+
)
|
|
33
|
+
elif stripped.startswith("Mode flag:"):
|
|
34
|
+
status.cores_on = "ON" in stripped.split(":", 1)[1]
|
|
35
|
+
elif stripped.startswith("Hardcore"):
|
|
36
|
+
status.hardcore = "ON" in stripped.split(":", 1)[1]
|
|
37
|
+
elif stripped.startswith("/media/fat/MiSTer"):
|
|
38
|
+
status.binary_ra = "RA" in stripped.split(":", 1)[1]
|
|
39
|
+
else:
|
|
40
|
+
match = _CORE_ROW.match(line)
|
|
41
|
+
if match:
|
|
42
|
+
total += 1
|
|
43
|
+
if match.group(1) == "RA":
|
|
44
|
+
active += 1
|
|
45
|
+
status.cores_total = total
|
|
46
|
+
status.cores_active = active
|
|
47
|
+
return status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MisterRAError(Exception):
|
|
51
|
+
"""A RetroAchievements control command failed."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MisterRA:
|
|
55
|
+
"""RetroAchievements local control sharing a MisterSSH connection."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, ssh) -> None:
|
|
58
|
+
self._ssh = ssh
|
|
59
|
+
|
|
60
|
+
async def async_status(self) -> MisterRAStatus | None:
|
|
61
|
+
"""Probe RA state. Returns None only on SSH failure (best-effort)."""
|
|
62
|
+
try:
|
|
63
|
+
_rc, out = await self._ssh.async_run(RA_STATUS_CMD, timeout=15)
|
|
64
|
+
except Exception as err: # noqa: BLE001 - best-effort; never break the integration
|
|
65
|
+
_LOGGER.debug("RA status probe failed: %s", err)
|
|
66
|
+
return None
|
|
67
|
+
return parse_ra_status(out)
|
|
68
|
+
|
|
69
|
+
async def async_cores_on(self) -> None:
|
|
70
|
+
await self._run_checked("bash /media/fat/Scripts/.ra/ra_on.sh")
|
|
71
|
+
|
|
72
|
+
async def async_cores_off(self) -> None:
|
|
73
|
+
await self._run_checked("bash /media/fat/Scripts/.ra/ra_off.sh")
|
|
74
|
+
|
|
75
|
+
async def async_set_hardcore(self, enabled: bool) -> None:
|
|
76
|
+
arg = "on" if enabled else "off"
|
|
77
|
+
await self._run_checked(f"bash /media/fat/Scripts/.ra/ra_hardcore.sh {arg}")
|
|
78
|
+
|
|
79
|
+
async def _run_checked(self, command: str) -> None:
|
|
80
|
+
rc, out = await self._ssh.async_run(command, timeout=30)
|
|
81
|
+
if rc != 0:
|
|
82
|
+
raise MisterRAError(f"command failed (rc={rc}): {command}\n{out}")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Optional SSH telemetry client for the MiSTer FPGA."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import asyncio
|
|
4
5
|
import logging
|
|
5
6
|
|
|
6
7
|
from .const import SSH_PROBE_CMD
|
|
@@ -86,6 +87,7 @@ class MisterSSH:
|
|
|
86
87
|
self.username = username
|
|
87
88
|
self.password = password
|
|
88
89
|
self._conn = None
|
|
90
|
+
self._lock = asyncio.Lock()
|
|
89
91
|
|
|
90
92
|
async def _ensure(self) -> None:
|
|
91
93
|
if self._conn is not None:
|
|
@@ -100,20 +102,28 @@ class MisterSSH:
|
|
|
100
102
|
known_hosts=None,
|
|
101
103
|
)
|
|
102
104
|
|
|
103
|
-
async def
|
|
104
|
-
"""Run
|
|
105
|
+
async def async_run(self, command: str, timeout: int = 15) -> tuple[int, str]:
|
|
106
|
+
"""Run a command over the shared SSH connection.
|
|
105
107
|
|
|
106
|
-
Returns
|
|
107
|
-
|
|
108
|
-
if the SSH connection or command fails.
|
|
108
|
+
Returns ``(exit_status, stdout)``. Raises on connection/transport
|
|
109
|
+
failure (callers that need best-effort behaviour must catch).
|
|
109
110
|
"""
|
|
111
|
+
async with self._lock:
|
|
112
|
+
try:
|
|
113
|
+
await self._ensure()
|
|
114
|
+
result = await self._conn.run(command, check=False, timeout=timeout)
|
|
115
|
+
except Exception:
|
|
116
|
+
self._conn = None
|
|
117
|
+
raise
|
|
118
|
+
return result.exit_status or 0, result.stdout or ""
|
|
119
|
+
|
|
120
|
+
async def async_probe(self) -> dict:
|
|
121
|
+
"""Run the SSH probe command and return parsed telemetry."""
|
|
110
122
|
try:
|
|
111
|
-
await self.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
except Exception as err: # noqa: BLE001 - asyncssh raises many types; SSH is best-effort and must never break the HTTP integration
|
|
123
|
+
_rc, out = await self.async_run(SSH_PROBE_CMD, timeout=10)
|
|
124
|
+
return parse_ssh_probe(out)
|
|
125
|
+
except Exception as err: # noqa: BLE001 - SSH is best-effort; never break the HTTP integration
|
|
115
126
|
_LOGGER.debug("MiSTer SSH probe failed: %s", err)
|
|
116
|
-
self._conn = None
|
|
117
127
|
return {}
|
|
118
128
|
|
|
119
129
|
async def async_close(self) -> None:
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Tests for the RetroAchievements local module."""
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from mister_fpga.const import RA_SUPPORTED_SYSTEMS, MisterRAStatus
|
|
5
|
+
from mister_fpga.ra import MisterRA, MisterRAError, parse_ra_status
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_ra_status_defaults():
|
|
9
|
+
status = MisterRAStatus()
|
|
10
|
+
assert status.installed is False
|
|
11
|
+
assert status.cores_on is False
|
|
12
|
+
assert status.binary_ra is False
|
|
13
|
+
assert status.hardcore is False
|
|
14
|
+
assert status.username is None
|
|
15
|
+
assert status.cores_active == 0
|
|
16
|
+
assert status.cores_total == 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_supported_systems_contains_core_keys():
|
|
20
|
+
assert "NES" in RA_SUPPORTED_SYSTEMS
|
|
21
|
+
assert "SNES" in RA_SUPPORTED_SYSTEMS
|
|
22
|
+
assert "PSX" in RA_SUPPORTED_SYSTEMS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_STATUS_ON = """installed=1
|
|
26
|
+
username=hudsonbrendon
|
|
27
|
+
ra_status v0.4.0
|
|
28
|
+
Mode flag: ON
|
|
29
|
+
Hardcore : OFF
|
|
30
|
+
/media/fat/MiSTer : RA (odelot)
|
|
31
|
+
|
|
32
|
+
CORE STATE DETAILS
|
|
33
|
+
---- ----- -------
|
|
34
|
+
NES RA -> NES.rbf (1 symlink(s), 1 stashed)
|
|
35
|
+
SNES RA -> SNES.rbf (1 symlink(s), 1 stashed)
|
|
36
|
+
PSX STOCK PSX_20260411.rbf
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_STATUS_OFF = """installed=1
|
|
40
|
+
username=YOUR_RA_USERNAME
|
|
41
|
+
Mode flag: never toggled
|
|
42
|
+
Hardcore : OFF
|
|
43
|
+
/media/fat/MiSTer : STOCK
|
|
44
|
+
NES STOCK NES_20260603.rbf
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
_NOT_INSTALLED = "installed=0\nusername=\n"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_parse_status_on():
|
|
51
|
+
s = parse_ra_status(_STATUS_ON)
|
|
52
|
+
assert s.installed is True
|
|
53
|
+
assert s.cores_on is True
|
|
54
|
+
assert s.binary_ra is True
|
|
55
|
+
assert s.hardcore is False
|
|
56
|
+
assert s.username == "hudsonbrendon"
|
|
57
|
+
assert s.cores_total == 3
|
|
58
|
+
assert s.cores_active == 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_parse_status_off_treats_placeholder_username_as_none():
|
|
62
|
+
s = parse_ra_status(_STATUS_OFF)
|
|
63
|
+
assert s.installed is True
|
|
64
|
+
assert s.cores_on is False
|
|
65
|
+
assert s.binary_ra is False
|
|
66
|
+
assert s.username is None
|
|
67
|
+
assert s.cores_total == 1
|
|
68
|
+
assert s.cores_active == 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_parse_not_installed():
|
|
72
|
+
s = parse_ra_status(_NOT_INSTALLED)
|
|
73
|
+
assert s.installed is False
|
|
74
|
+
assert s.username is None
|
|
75
|
+
assert s.cores_total == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_parse_hardcore_on_with_trailing_note():
|
|
79
|
+
s = parse_ra_status(
|
|
80
|
+
"installed=1\nMode flag: ON\n"
|
|
81
|
+
"Hardcore : ON (NES/FDS only enforced upstream)\n"
|
|
82
|
+
"/media/fat/MiSTer : RA (odelot)\n"
|
|
83
|
+
)
|
|
84
|
+
assert s.hardcore is True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_parse_malformed_does_not_crash():
|
|
88
|
+
s = parse_ra_status("garbage\n\nnonsense line")
|
|
89
|
+
assert s.installed is False
|
|
90
|
+
assert s.cores_total == 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _FakeSSH:
|
|
94
|
+
def __init__(self, rc=0, out=""):
|
|
95
|
+
self._rc = rc
|
|
96
|
+
self._out = out
|
|
97
|
+
self.commands = []
|
|
98
|
+
|
|
99
|
+
async def async_run(self, command, timeout=15):
|
|
100
|
+
self.commands.append(command)
|
|
101
|
+
return self._rc, self._out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class _RaisingSSH:
|
|
105
|
+
async def async_run(self, command, timeout=15):
|
|
106
|
+
raise OSError("ssh down")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.asyncio
|
|
110
|
+
async def test_async_status_parses():
|
|
111
|
+
ssh = _FakeSSH(out=_STATUS_ON)
|
|
112
|
+
ra = MisterRA(ssh)
|
|
113
|
+
status = await ra.async_status()
|
|
114
|
+
assert status is not None
|
|
115
|
+
assert status.cores_on is True
|
|
116
|
+
assert status.username == "hudsonbrendon"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_async_status_none_on_ssh_failure():
|
|
121
|
+
ra = MisterRA(_RaisingSSH())
|
|
122
|
+
assert await ra.async_status() is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_cores_on_off_commands():
|
|
127
|
+
ssh = _FakeSSH()
|
|
128
|
+
ra = MisterRA(ssh)
|
|
129
|
+
await ra.async_cores_on()
|
|
130
|
+
await ra.async_cores_off()
|
|
131
|
+
assert ssh.commands == [
|
|
132
|
+
"bash /media/fat/Scripts/.ra/ra_on.sh",
|
|
133
|
+
"bash /media/fat/Scripts/.ra/ra_off.sh",
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.asyncio
|
|
138
|
+
async def test_set_hardcore_arg():
|
|
139
|
+
ssh = _FakeSSH()
|
|
140
|
+
ra = MisterRA(ssh)
|
|
141
|
+
await ra.async_set_hardcore(True)
|
|
142
|
+
await ra.async_set_hardcore(False)
|
|
143
|
+
assert ssh.commands == [
|
|
144
|
+
"bash /media/fat/Scripts/.ra/ra_hardcore.sh on",
|
|
145
|
+
"bash /media/fat/Scripts/.ra/ra_hardcore.sh off",
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_control_raises_on_nonzero():
|
|
151
|
+
ra = MisterRA(_FakeSSH(rc=1, out="fail"))
|
|
152
|
+
with pytest.raises(MisterRAError):
|
|
153
|
+
await ra.async_cores_on()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_public_exports():
|
|
157
|
+
import mister_fpga
|
|
158
|
+
|
|
159
|
+
assert hasattr(mister_fpga, "MisterRA")
|
|
160
|
+
assert hasattr(mister_fpga, "MisterRAStatus")
|
|
161
|
+
assert hasattr(mister_fpga, "MisterRAError")
|
|
162
|
+
assert hasattr(mister_fpga, "RA_SUPPORTED_SYSTEMS")
|
|
163
|
+
assert mister_fpga.__version__ == "0.1.3"
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Tests for the optional SSH telemetry parsing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from mister_fpga.ssh import MisterSSH, parse_ssh_probe
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_parse_ssh_probe_full():
|
|
10
|
+
raw = (
|
|
11
|
+
"SNES\n|||\n123456.78 100.0\n|||\n0.50 0.40 0.30 1/98 1234"
|
|
12
|
+
"\n|||\n1048576\n524288\n|||\n1742860800"
|
|
13
|
+
)
|
|
14
|
+
data = parse_ssh_probe(raw)
|
|
15
|
+
assert data["active_core"] == "SNES"
|
|
16
|
+
assert data["uptime_seconds"] == 123456
|
|
17
|
+
assert data["cpu_load_1m"] == 0.50
|
|
18
|
+
assert data["memory_used_percent"] == 50.0
|
|
19
|
+
assert data["firmware_timestamp"] == 1742860800
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_parse_ssh_probe_partial_is_tolerant():
|
|
23
|
+
raw = "MENU\n|||\n\n|||\n\n|||\n\n|||\n"
|
|
24
|
+
data = parse_ssh_probe(raw)
|
|
25
|
+
assert data["active_core"] == "MENU"
|
|
26
|
+
assert data["uptime_seconds"] is None
|
|
27
|
+
assert data["memory_used_percent"] is None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _FakeResult:
|
|
31
|
+
def __init__(self, stdout="", exit_status=0):
|
|
32
|
+
self.stdout = stdout
|
|
33
|
+
self.exit_status = exit_status
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _FakeConn:
|
|
37
|
+
def __init__(self, result):
|
|
38
|
+
self._result = result
|
|
39
|
+
self.commands = []
|
|
40
|
+
|
|
41
|
+
async def run(self, command, check=False, timeout=10):
|
|
42
|
+
self.commands.append(command)
|
|
43
|
+
return self._result
|
|
44
|
+
|
|
45
|
+
def close(self):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_async_run_returns_rc_and_stdout(monkeypatch):
|
|
51
|
+
ssh = MisterSSH("h", 22, "root", "1")
|
|
52
|
+
ssh._conn = _FakeConn(_FakeResult(stdout="hello", exit_status=0))
|
|
53
|
+
rc, out = await ssh.async_run("echo hello")
|
|
54
|
+
assert rc == 0
|
|
55
|
+
assert out == "hello"
|
|
56
|
+
assert ssh._conn.commands == ["echo hello"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_async_run_propagates_nonzero(monkeypatch):
|
|
61
|
+
ssh = MisterSSH("h", 22, "root", "1")
|
|
62
|
+
ssh._conn = _FakeConn(_FakeResult(stdout="boom", exit_status=3))
|
|
63
|
+
rc, out = await ssh.async_run("false")
|
|
64
|
+
assert rc == 3
|
|
65
|
+
assert out == "boom"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_async_run_raises_and_drops_conn_on_error():
|
|
70
|
+
ssh = MisterSSH("h", 22, "root", "1")
|
|
71
|
+
|
|
72
|
+
class _BoomConn:
|
|
73
|
+
async def run(self, *a, **k):
|
|
74
|
+
raise OSError("down")
|
|
75
|
+
|
|
76
|
+
def close(self):
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
ssh._conn = _BoomConn()
|
|
80
|
+
with pytest.raises(OSError):
|
|
81
|
+
await ssh.async_run("x")
|
|
82
|
+
assert ssh._conn is None
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
"""Tests for the optional SSH telemetry parsing."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from mister_fpga.ssh import parse_ssh_probe
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def test_parse_ssh_probe_full():
|
|
8
|
-
raw = (
|
|
9
|
-
"SNES\n|||\n123456.78 100.0\n|||\n0.50 0.40 0.30 1/98 1234"
|
|
10
|
-
"\n|||\n1048576\n524288\n|||\n1742860800"
|
|
11
|
-
)
|
|
12
|
-
data = parse_ssh_probe(raw)
|
|
13
|
-
assert data["active_core"] == "SNES"
|
|
14
|
-
assert data["uptime_seconds"] == 123456
|
|
15
|
-
assert data["cpu_load_1m"] == 0.50
|
|
16
|
-
assert data["memory_used_percent"] == 50.0
|
|
17
|
-
assert data["firmware_timestamp"] == 1742860800
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_parse_ssh_probe_partial_is_tolerant():
|
|
21
|
-
raw = "MENU\n|||\n\n|||\n\n|||\n\n|||\n"
|
|
22
|
-
data = parse_ssh_probe(raw)
|
|
23
|
-
assert data["active_core"] == "MENU"
|
|
24
|
-
assert data["uptime_seconds"] is None
|
|
25
|
-
assert data["memory_used_percent"] is None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|