python-mister-fpga 0.1.0__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.0/.github/workflows/tests.yml +30 -0
- python_mister_fpga-0.1.0/.gitignore +10 -0
- python_mister_fpga-0.1.0/LICENSE +21 -0
- python_mister_fpga-0.1.0/PKG-INFO +122 -0
- python_mister_fpga-0.1.0/README.md +95 -0
- python_mister_fpga-0.1.0/assets/dark_logo.png +0 -0
- python_mister_fpga-0.1.0/assets/icon.png +0 -0
- python_mister_fpga-0.1.0/assets/logo.png +0 -0
- python_mister_fpga-0.1.0/pyproject.toml +44 -0
- python_mister_fpga-0.1.0/src/mister_fpga/__init__.py +20 -0
- python_mister_fpga-0.1.0/src/mister_fpga/client.py +292 -0
- python_mister_fpga-0.1.0/src/mister_fpga/const.py +62 -0
- python_mister_fpga-0.1.0/src/mister_fpga/py.typed +0 -0
- python_mister_fpga-0.1.0/src/mister_fpga/ssh.py +88 -0
- python_mister_fpga-0.1.0/src/mister_fpga/websocket.py +103 -0
- python_mister_fpga-0.1.0/tests/test_client.py +343 -0
- python_mister_fpga-0.1.0/tests/test_ssh.py +25 -0
- python_mister_fpga-0.1.0/tests/test_websocket.py +80 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["main"]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: pip install -e ".[test]"
|
|
25
|
+
|
|
26
|
+
- name: Lint with ruff
|
|
27
|
+
run: ruff check .
|
|
28
|
+
|
|
29
|
+
- name: Run tests
|
|
30
|
+
run: pytest
|
|
@@ -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.
|
|
@@ -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,95 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/dark_logo.png" width="420">
|
|
4
|
+
<img src="assets/logo.png" alt="python-mister-fpga" width="420">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
# python-mister-fpga
|
|
9
|
+
|
|
10
|
+
[](https://github.com/hudsonbrendon/python-mister-fpga/actions/workflows/tests.yml)
|
|
11
|
+
[](https://pypi.org/project/python-mister-fpga/)
|
|
12
|
+
[](https://pypi.org/project/python-mister-fpga/)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
|
|
15
|
+
Async Python client for the [mrext Remote API](https://github.com/wizzomafizzo/mrext) (REST + WebSocket) and optional SSH telemetry for the MiSTer FPGA. Zero Home Assistant dependency — use it in any Python project.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install python-mister-fpga
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### REST client
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import asyncio
|
|
29
|
+
from mister_fpga import MisterClient
|
|
30
|
+
|
|
31
|
+
async def main():
|
|
32
|
+
client = MisterClient("192.168.1.50")
|
|
33
|
+
status = await client.async_get_status()
|
|
34
|
+
print(status.core, status.game)
|
|
35
|
+
await client.async_launch_game("/media/fat/games/SNES/Chrono.sfc")
|
|
36
|
+
await client.async_close()
|
|
37
|
+
|
|
38
|
+
asyncio.run(main())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### WebSocket (real-time updates)
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from mister_fpga import MisterWebSocketClient, MisterStatus, apply_ws_message
|
|
46
|
+
|
|
47
|
+
state = MisterStatus(online=True)
|
|
48
|
+
menu_path = None
|
|
49
|
+
index_state = (False, False)
|
|
50
|
+
|
|
51
|
+
def on_message(text: str) -> None:
|
|
52
|
+
global state, menu_path, index_state
|
|
53
|
+
state, menu_path, index_state = apply_ws_message(text, state, menu_path, index_state)
|
|
54
|
+
print(state.core, state.game)
|
|
55
|
+
|
|
56
|
+
async def main():
|
|
57
|
+
ws = MisterWebSocketClient("192.168.1.50")
|
|
58
|
+
await ws.listen(on_message)
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### SSH telemetry
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import asyncio
|
|
67
|
+
from mister_fpga import MisterSSH
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
ssh = MisterSSH("192.168.1.50", 22, "root", "1")
|
|
71
|
+
data = await ssh.async_probe()
|
|
72
|
+
print(data)
|
|
73
|
+
await ssh.async_close()
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## API
|
|
79
|
+
|
|
80
|
+
- **`MisterClient(host, port=8182, *, session=None, timeout=10)`** — async REST client; call `await client.async_close()` when done, or inject your own `aiohttp.ClientSession`.
|
|
81
|
+
- **`MisterStatus`** — dataclass snapshot: `online`, `core`, `system`, `game`, `hostname`, `version`, `ip`, `ips`, `dns`, `disk_total/used/free`. Property `is_running_game`.
|
|
82
|
+
- **`MisterConnectionError`** — raised on network/HTTP errors.
|
|
83
|
+
- **`MisterWebSocketClient(host, port=8182, *, session=None, reconnect_delay=5)`** — reconnecting WS loop; `await ws.listen(callback)`, call `ws.stop()` to exit.
|
|
84
|
+
- **`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)`.
|
|
85
|
+
- **`MisterSSH(host, port, username, password)`** — persistent asyncssh connection; `await ssh.async_probe()` returns telemetry dict.
|
|
86
|
+
- **`parse_ssh_probe(raw)`** — parse the raw batched SSH output into a telemetry dict.
|
|
87
|
+
- **`KEYBOARD_NAMES`**, **`INI_VIDEO_KEYS`**, **`WS_PATH`**, **`DEFAULT_PORT`** — protocol constants.
|
|
88
|
+
|
|
89
|
+
## Credits
|
|
90
|
+
|
|
91
|
+
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).
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT — see [LICENSE](LICENSE).
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-mister-fpga"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Async Python client for the MiSTer FPGA mrext Remote API (REST + WebSocket) and SSH telemetry"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Hudson Brendon", email = "contato.hudsonbrendon@gmail.com"}]
|
|
13
|
+
keywords = ["mister", "fpga", "mrext", "retro", "async", "aiohttp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Framework :: AsyncIO",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["aiohttp>=3.9", "asyncssh>=2.21"]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/hudsonbrendon/python-mister-fpga"
|
|
27
|
+
Issues = "https://github.com/hudsonbrendon/python-mister-fpga/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23", "aioresponses>=0.7", "ruff"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/mister_fpga"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
target-version = "py311"
|
|
37
|
+
line-length = 88
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
asyncio_mode = "auto"
|
|
44
|
+
addopts = "-q"
|
|
@@ -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"
|
|
@@ -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}")
|
|
@@ -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
|
+
)
|
|
File without changes
|
|
@@ -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,343 @@
|
|
|
1
|
+
"""Tests for the MiSTer FPGA API client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
import pytest
|
|
6
|
+
from aiohttp import ClientSession
|
|
7
|
+
from aioresponses import aioresponses
|
|
8
|
+
|
|
9
|
+
from mister_fpga.client import (
|
|
10
|
+
MisterClient,
|
|
11
|
+
MisterConnectionError,
|
|
12
|
+
MisterStatus,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
BASE = "http://192.168.31.77:8182/api"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
async def client():
|
|
20
|
+
"""Return a MisterClient backed by a ClientSession with ThreadedResolver.
|
|
21
|
+
|
|
22
|
+
Using aiohttp.ThreadedResolver instead of the default AsyncResolver (aiodns)
|
|
23
|
+
prevents pycares from spawning its ``_run_safe_shutdown_loop`` daemon thread.
|
|
24
|
+
"""
|
|
25
|
+
connector = aiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver())
|
|
26
|
+
session = ClientSession(connector=connector)
|
|
27
|
+
try:
|
|
28
|
+
yield MisterClient("192.168.31.77", 8182, session=session)
|
|
29
|
+
finally:
|
|
30
|
+
await session.close()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_base_url():
|
|
34
|
+
client = MisterClient("1.2.3.4", 8182)
|
|
35
|
+
assert client.base_url == "http://1.2.3.4:8182/api"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_timeout_param():
|
|
39
|
+
c = MisterClient("h", timeout=30)
|
|
40
|
+
assert c._timeout == 30
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_status_is_running_game():
|
|
44
|
+
assert MisterStatus(online=True, core="SNES").is_running_game is True
|
|
45
|
+
assert MisterStatus(online=True, core="MENU").is_running_game is False
|
|
46
|
+
assert MisterStatus(online=True, core="").is_running_game is False
|
|
47
|
+
assert MisterStatus(online=False, core="SNES").is_running_game is False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def test_get_status_merges_endpoints(client):
|
|
51
|
+
with aioresponses() as m:
|
|
52
|
+
m.get(
|
|
53
|
+
f"{BASE}/sysinfo",
|
|
54
|
+
payload={
|
|
55
|
+
"ips": ["192.168.31.77"],
|
|
56
|
+
"hostname": "MiSTer",
|
|
57
|
+
"version": "240101",
|
|
58
|
+
"updated": "2026-05-30",
|
|
59
|
+
"dns": "MiSTer.local",
|
|
60
|
+
"disks": [
|
|
61
|
+
{
|
|
62
|
+
"path": "/media/fat",
|
|
63
|
+
"total": 100,
|
|
64
|
+
"used": 75,
|
|
65
|
+
"free": 25,
|
|
66
|
+
"displayName": "SD card",
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
m.get(
|
|
72
|
+
f"{BASE}/games/playing",
|
|
73
|
+
payload={
|
|
74
|
+
"core": "SNES",
|
|
75
|
+
"system": "SNES",
|
|
76
|
+
"systemName": "Super Nintendo",
|
|
77
|
+
"game": "/games/SNES/Chrono.sfc",
|
|
78
|
+
"gameName": "Chrono Trigger",
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
status = await client.async_get_status()
|
|
82
|
+
assert status.online is True
|
|
83
|
+
assert status.core == "SNES"
|
|
84
|
+
assert status.system_name == "Super Nintendo"
|
|
85
|
+
assert status.game_name == "Chrono Trigger"
|
|
86
|
+
assert status.version == "240101"
|
|
87
|
+
assert status.hostname == "MiSTer"
|
|
88
|
+
assert status.ip == "192.168.31.77"
|
|
89
|
+
assert status.dns == "MiSTer.local"
|
|
90
|
+
assert status.disk_total == 100
|
|
91
|
+
assert status.disk_used == 75
|
|
92
|
+
assert status.disk_free == 25
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def test_get_status_raises_on_error(client):
|
|
96
|
+
with aioresponses() as m:
|
|
97
|
+
m.get(f"{BASE}/sysinfo", status=500)
|
|
98
|
+
with pytest.raises(MisterConnectionError):
|
|
99
|
+
await client.async_get_status()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def test_get_systems(client):
|
|
103
|
+
with aioresponses() as m:
|
|
104
|
+
m.get(
|
|
105
|
+
f"{BASE}/systems",
|
|
106
|
+
payload=[{"id": "SNES", "name": "Super Nintendo", "category": "Console"}],
|
|
107
|
+
)
|
|
108
|
+
systems = await client.async_get_systems()
|
|
109
|
+
assert systems[0]["id"] == "SNES"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def test_launch_game_posts_path(client):
|
|
113
|
+
with aioresponses() as m:
|
|
114
|
+
m.post(f"{BASE}/games/launch", status=200)
|
|
115
|
+
await client.async_launch_game("/games/SNES/Chrono.sfc")
|
|
116
|
+
import yarl
|
|
117
|
+
|
|
118
|
+
key = ("POST", yarl.URL(f"{BASE}/games/launch"))
|
|
119
|
+
assert m.requests[key][0].kwargs["json"] == {"path": "/games/SNES/Chrono.sfc"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def test_launch_system(client):
|
|
123
|
+
with aioresponses() as m:
|
|
124
|
+
m.post(f"{BASE}/systems/SNES", status=200)
|
|
125
|
+
await client.async_launch_system("SNES")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def test_send_keyboard(client):
|
|
129
|
+
with aioresponses() as m:
|
|
130
|
+
m.post(f"{BASE}/controls/keyboard/up", status=200)
|
|
131
|
+
await client.async_send_keyboard("up")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def test_reboot(client):
|
|
135
|
+
with aioresponses() as m:
|
|
136
|
+
m.post(f"{BASE}/settings/system/reboot", status=200)
|
|
137
|
+
await client.async_reboot()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def test_screenshot_image_returns_bytes(client):
|
|
141
|
+
with aioresponses() as m:
|
|
142
|
+
m.get(f"{BASE}/screenshots/SNES/shot.png", body=b"\x89PNG", status=200)
|
|
143
|
+
data = await client.async_get_screenshot_image("SNES", "shot.png")
|
|
144
|
+
assert data == b"\x89PNG"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def test_music_status(client):
|
|
148
|
+
with aioresponses() as m:
|
|
149
|
+
m.get(f"{BASE}/music/status", payload={"running": True, "playing": True})
|
|
150
|
+
status = await client.async_get_music_status()
|
|
151
|
+
assert status["playing"] is True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.parametrize(
|
|
155
|
+
("method_name", "path"),
|
|
156
|
+
[
|
|
157
|
+
("async_launch_menu", "/launch/menu"),
|
|
158
|
+
("async_index_games", "/games/index"),
|
|
159
|
+
("async_restart_remote", "/settings/remote/restart"),
|
|
160
|
+
("async_take_screenshot", "/screenshots"),
|
|
161
|
+
("async_music_play", "/music/play"),
|
|
162
|
+
("async_music_stop", "/music/stop"),
|
|
163
|
+
("async_music_next", "/music/next"),
|
|
164
|
+
],
|
|
165
|
+
)
|
|
166
|
+
async def test_simple_post_methods(client, method_name, path):
|
|
167
|
+
with aioresponses() as m:
|
|
168
|
+
m.post(f"{BASE}{path}", status=200)
|
|
169
|
+
await getattr(client, method_name)()
|
|
170
|
+
assert any(k[0] == "POST" for k in m.requests)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def test_search_games(client):
|
|
174
|
+
with aioresponses() as m:
|
|
175
|
+
m.post(f"{BASE}/games/search", payload={"data": [], "total": 0})
|
|
176
|
+
result = await client.async_search_games("chrono", "SNES")
|
|
177
|
+
assert result["total"] == 0
|
|
178
|
+
|
|
179
|
+
with aioresponses() as m:
|
|
180
|
+
m.post(f"{BASE}/games/search", payload={"data": [], "total": 0})
|
|
181
|
+
await client.async_search_games("chrono", "SNES")
|
|
182
|
+
import yarl
|
|
183
|
+
|
|
184
|
+
key = ("POST", yarl.URL(f"{BASE}/games/search"))
|
|
185
|
+
assert m.requests[key][0].kwargs["json"] == {"data": "chrono", "system": "SNES"}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def test_get_screenshots(client):
|
|
189
|
+
with aioresponses() as m:
|
|
190
|
+
m.get(
|
|
191
|
+
f"{BASE}/screenshots",
|
|
192
|
+
payload=[{"core": "SNES", "filename": "a.png", "modified": "2026-05-30"}],
|
|
193
|
+
)
|
|
194
|
+
screenshots = await client.async_get_screenshots()
|
|
195
|
+
assert isinstance(screenshots, list)
|
|
196
|
+
assert screenshots[0]["filename"] == "a.png"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_get_wallpapers(client):
|
|
200
|
+
with aioresponses() as m:
|
|
201
|
+
m.get(
|
|
202
|
+
f"{BASE}/wallpapers",
|
|
203
|
+
payload={
|
|
204
|
+
"active": "snatcher.png",
|
|
205
|
+
"backgroundMode": 2,
|
|
206
|
+
"wallpapers": [
|
|
207
|
+
{"name": "snatcher", "filename": "snatcher.png", "active": True}
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
wp = await client.async_get_wallpapers()
|
|
212
|
+
assert wp["active"] == "snatcher.png"
|
|
213
|
+
assert wp["backgroundMode"] == 2
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def test_set_and_clear_wallpaper(client):
|
|
217
|
+
with aioresponses() as m:
|
|
218
|
+
m.post(f"{BASE}/wallpapers/snatcher.png", status=200)
|
|
219
|
+
m.delete(f"{BASE}/wallpapers", status=200)
|
|
220
|
+
await client.async_set_wallpaper("snatcher.png")
|
|
221
|
+
await client.async_clear_wallpaper()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def test_inis_get_set_active(client):
|
|
225
|
+
import yarl
|
|
226
|
+
|
|
227
|
+
with aioresponses() as m:
|
|
228
|
+
m.get(
|
|
229
|
+
f"{BASE}/settings/inis",
|
|
230
|
+
payload={
|
|
231
|
+
"active": 0,
|
|
232
|
+
"inis": [
|
|
233
|
+
{
|
|
234
|
+
"id": 1,
|
|
235
|
+
"displayName": "Main",
|
|
236
|
+
"filename": "MiSTer.ini",
|
|
237
|
+
"path": "/x",
|
|
238
|
+
}
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
m.get(
|
|
243
|
+
f"{BASE}/settings/inis/1",
|
|
244
|
+
payload={"__hostname": "MiSTer", "video_brightness": "50"},
|
|
245
|
+
)
|
|
246
|
+
m.put(f"{BASE}/settings/inis", status=200)
|
|
247
|
+
m.put(f"{BASE}/settings/inis/1", status=200)
|
|
248
|
+
inis = await client.async_get_inis()
|
|
249
|
+
values = await client.async_get_ini_values(1)
|
|
250
|
+
await client.async_set_active_ini(1)
|
|
251
|
+
await client.async_set_ini_values(1, {"video_brightness": "60"})
|
|
252
|
+
put_inis_calls = m.requests[
|
|
253
|
+
("PUT", yarl.URL(f"{BASE}/settings/inis"))
|
|
254
|
+
]
|
|
255
|
+
put_inis_1_calls = m.requests[
|
|
256
|
+
("PUT", yarl.URL(f"{BASE}/settings/inis/1"))
|
|
257
|
+
]
|
|
258
|
+
assert inis["inis"][0]["displayName"] == "Main"
|
|
259
|
+
assert values["video_brightness"] == "50"
|
|
260
|
+
assert put_inis_calls[0].kwargs["json"] == {"ini": 1}
|
|
261
|
+
assert put_inis_1_calls[0].kwargs["json"] == {"video_brightness": "60"}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def test_set_background_mode(client):
|
|
265
|
+
import yarl
|
|
266
|
+
|
|
267
|
+
with aioresponses() as m:
|
|
268
|
+
m.put(f"{BASE}/settings/core/menu", status=200)
|
|
269
|
+
await client.async_set_background_mode(3)
|
|
270
|
+
put_calls = m.requests[("PUT", yarl.URL(f"{BASE}/settings/core/menu"))]
|
|
271
|
+
assert put_calls[0].kwargs["json"] == {"mode": 3}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def test_music_playlists_and_playback(client):
|
|
275
|
+
with aioresponses() as m:
|
|
276
|
+
m.get(f"{BASE}/music/playlist", payload=["none", "Vidya"])
|
|
277
|
+
m.post(f"{BASE}/music/playlist/Vidya", status=200)
|
|
278
|
+
m.post(f"{BASE}/music/playback/loop", status=200)
|
|
279
|
+
pls = await client.async_get_music_playlists()
|
|
280
|
+
await client.async_set_music_playlist("Vidya")
|
|
281
|
+
await client.async_set_music_playback("loop")
|
|
282
|
+
assert pls == ["none", "Vidya"]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def test_scripts_list_launch_kill_console(client):
|
|
286
|
+
with aioresponses() as m:
|
|
287
|
+
m.get(
|
|
288
|
+
f"{BASE}/scripts/list",
|
|
289
|
+
payload={
|
|
290
|
+
"canLaunch": True,
|
|
291
|
+
"scripts": [
|
|
292
|
+
{"name": "update_all", "filename": "update_all.sh", "path": "/x"}
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
m.post(f"{BASE}/scripts/launch/update_all.sh", status=200)
|
|
297
|
+
m.post(f"{BASE}/scripts/console", status=200)
|
|
298
|
+
m.post(f"{BASE}/scripts/kill", status=200)
|
|
299
|
+
scr = await client.async_get_scripts()
|
|
300
|
+
await client.async_launch_script("update_all.sh")
|
|
301
|
+
await client.async_open_console()
|
|
302
|
+
await client.async_kill_script()
|
|
303
|
+
assert scr["scripts"][0]["filename"] == "update_all.sh"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def test_peers(client):
|
|
307
|
+
with aioresponses() as m:
|
|
308
|
+
m.get(
|
|
309
|
+
f"{BASE}/settings/remote/peers",
|
|
310
|
+
payload={
|
|
311
|
+
"peers": [
|
|
312
|
+
{"hostname": "MiSTer.local", "version": "0.4", "ip": "1.2.3.4"}
|
|
313
|
+
]
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
peers = await client.async_get_peers()
|
|
317
|
+
assert peers[0]["ip"] == "1.2.3.4"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def test_generic_launch_and_token(client):
|
|
321
|
+
with aioresponses() as m:
|
|
322
|
+
m.post(f"{BASE}/launch", status=200)
|
|
323
|
+
m.get(f"{BASE}/l/bWVudS5yYmY=", status=200)
|
|
324
|
+
await client.async_launch_path("/media/fat/menu.rbf")
|
|
325
|
+
await client.async_launch_token("bWVudS5yYmY=")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def test_create_shortcut(client):
|
|
329
|
+
with aioresponses() as m:
|
|
330
|
+
m.post(
|
|
331
|
+
f"{BASE}/launch/new",
|
|
332
|
+
payload={"path": "/media/fat/_@Favorites/Crash.mgl"},
|
|
333
|
+
)
|
|
334
|
+
result = await client.async_create_shortcut(
|
|
335
|
+
"/g/Crash.chd", "_@Favorites", "Crash"
|
|
336
|
+
)
|
|
337
|
+
assert result["path"].endswith("Crash.mgl")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
async def test_send_keyboard_raw(client):
|
|
341
|
+
with aioresponses() as m:
|
|
342
|
+
m.post(f"{BASE}/controls/keyboard-raw/16", status=200)
|
|
343
|
+
await client.async_send_keyboard_raw(16)
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tests for the MiSTer FPGA WebSocket message parsing."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from mister_fpga.client import MisterStatus
|
|
5
|
+
from mister_fpga.websocket import MisterWebSocketClient, apply_ws_message
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_core_running_updates_status():
|
|
9
|
+
status = MisterStatus(online=True, core="MENU")
|
|
10
|
+
new, menu, idx = apply_ws_message("coreRunning:SNES", status, None, (False, False))
|
|
11
|
+
assert new.core == "SNES"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_core_running_blank_means_menu():
|
|
15
|
+
status = MisterStatus(online=True, core="SNES", game="x", game_name="X")
|
|
16
|
+
new, menu, idx = apply_ws_message("coreRunning:", status, None, (False, False))
|
|
17
|
+
assert new.core is None
|
|
18
|
+
assert new.game is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_game_running_updates_status():
|
|
22
|
+
status = MisterStatus(online=True, core="SNES")
|
|
23
|
+
new, menu, idx = apply_ws_message(
|
|
24
|
+
"gameRunning:SNES/Chrono.sfc", status, None, (False, False)
|
|
25
|
+
)
|
|
26
|
+
assert new.game == "SNES/Chrono.sfc"
|
|
27
|
+
assert new.game_name == "Chrono"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_menu_navigation_sets_path():
|
|
31
|
+
status = MisterStatus(online=True)
|
|
32
|
+
new, menu, idx = apply_ws_message(
|
|
33
|
+
"menuNavigation:_Console/SNES", status, None, (False, False)
|
|
34
|
+
)
|
|
35
|
+
assert menu == "_Console/SNES"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_index_status_parsed():
|
|
39
|
+
status = MisterStatus(online=True)
|
|
40
|
+
new, menu, (exists, in_progress) = apply_ws_message(
|
|
41
|
+
"indexStatus:y,n,0,0,", status, None, (False, False)
|
|
42
|
+
)
|
|
43
|
+
assert exists is True
|
|
44
|
+
assert in_progress is False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_unknown_message_is_noop():
|
|
48
|
+
status = MisterStatus(online=True, core="SNES")
|
|
49
|
+
new, menu, idx = apply_ws_message(
|
|
50
|
+
"somethingElse:42", status, "_Console", (True, False)
|
|
51
|
+
)
|
|
52
|
+
assert new.core == "SNES"
|
|
53
|
+
assert menu == "_Console"
|
|
54
|
+
assert idx == (True, False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_game_running_preserves_dots_and_spaces():
|
|
58
|
+
status = MisterStatus(online=True, core="PSX")
|
|
59
|
+
new, menu, idx = apply_ws_message(
|
|
60
|
+
"gameRunning:PSX/Crash Bandicoot (USA).chd", status, None, (False, False)
|
|
61
|
+
)
|
|
62
|
+
assert new.game == "PSX/Crash Bandicoot (USA).chd"
|
|
63
|
+
assert new.game_name == "Crash Bandicoot (USA)"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_websocket_client_url():
|
|
67
|
+
ws = MisterWebSocketClient("192.168.1.50")
|
|
68
|
+
assert ws.url == "ws://192.168.1.50:8182/api/ws"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_websocket_client_url_custom_port():
|
|
72
|
+
ws = MisterWebSocketClient("h", port=9000)
|
|
73
|
+
assert ws.url == "ws://h:9000/api/ws"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_websocket_client_stop_sets_flag():
|
|
77
|
+
ws = MisterWebSocketClient("h")
|
|
78
|
+
assert ws._stop is False
|
|
79
|
+
ws.stop()
|
|
80
|
+
assert ws._stop is True
|