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.
Files changed (32) hide show
  1. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/PKG-INFO +1 -1
  2. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/pyproject.toml +1 -1
  3. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/__init__.py +13 -1
  4. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/const.py +36 -0
  5. python_mister_fpga-0.1.3/src/mister_fpga/ra.py +82 -0
  6. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/ssh.py +20 -10
  7. python_mister_fpga-0.1.3/tests/test_ra.py +163 -0
  8. python_mister_fpga-0.1.3/tests/test_ssh.py +82 -0
  9. python_mister_fpga-0.1.2/tests/test_ssh.py +0 -25
  10. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.github/workflows/docs.yml +0 -0
  11. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.github/workflows/release.yml +0 -0
  12. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.github/workflows/tests.yml +0 -0
  13. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/.gitignore +0 -0
  14. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/CHANGELOG.md +0 -0
  15. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/LICENSE +0 -0
  16. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/README.md +0 -0
  17. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/assets/dark_logo.png +0 -0
  18. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/assets/icon.png +0 -0
  19. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/assets/logo.png +0 -0
  20. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/api.md +0 -0
  21. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/assets/dark_logo.png +0 -0
  22. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/assets/icon.png +0 -0
  23. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/assets/logo.png +0 -0
  24. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/changelog.md +0 -0
  25. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/index.md +0 -0
  26. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/docs/usage.md +0 -0
  27. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/mkdocs.yml +0 -0
  28. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/client.py +0 -0
  29. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/py.typed +0 -0
  30. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/src/mister_fpga/websocket.py +0 -0
  31. {python_mister_fpga-0.1.2 → python_mister_fpga-0.1.3}/tests/test_client.py +0 -0
  32. {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.2
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.2"
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.2"
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 async_probe(self) -> dict:
104
- """Run the SSH probe command and return parsed telemetry.
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
- Telemetry dict as returned by :func:`parse_ssh_probe`, or ``{}``
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._ensure()
112
- result = await self._conn.run(SSH_PROBE_CMD, check=False, timeout=10)
113
- return parse_ssh_probe(result.stdout or "")
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