siliconrig 0.2.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.
- siliconrig-0.2.0/.github/workflows/publish.yml +48 -0
- siliconrig-0.2.0/.gitignore +9 -0
- siliconrig-0.2.0/LICENSE +21 -0
- siliconrig-0.2.0/PKG-INFO +105 -0
- siliconrig-0.2.0/README.md +78 -0
- siliconrig-0.2.0/pyproject.toml +51 -0
- siliconrig-0.2.0/src/siliconrig/__init__.py +21 -0
- siliconrig-0.2.0/src/siliconrig/board.py +118 -0
- siliconrig-0.2.0/src/siliconrig/client.py +108 -0
- siliconrig-0.2.0/src/siliconrig/exceptions.py +21 -0
- siliconrig-0.2.0/src/siliconrig/plugin.py +63 -0
- siliconrig-0.2.0/src/siliconrig/serial.py +141 -0
- siliconrig-0.2.0/src/siliconrig/session.py +123 -0
- siliconrig-0.2.0/tests/conftest.py +55 -0
- siliconrig-0.2.0/tests/test_board.py +62 -0
- siliconrig-0.2.0/tests/test_client.py +62 -0
- siliconrig-0.2.0/tests/test_serial.py +77 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
with:
|
|
17
|
+
fetch-depth: 0
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: pip install -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: pytest tests/ -v
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
needs: test
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment: pypi
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
with:
|
|
36
|
+
fetch-depth: 0
|
|
37
|
+
|
|
38
|
+
- uses: actions/setup-python@v5
|
|
39
|
+
with:
|
|
40
|
+
python-version: "3.12"
|
|
41
|
+
|
|
42
|
+
- name: Build package
|
|
43
|
+
run: |
|
|
44
|
+
pip install build
|
|
45
|
+
python -m build
|
|
46
|
+
|
|
47
|
+
- name: Publish to PyPI
|
|
48
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
siliconrig-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RAWS Consulting
|
|
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,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: siliconrig
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK and pytest fixtures for siliconrig hardware-in-the-loop testing
|
|
5
|
+
Project-URL: Homepage, https://siliconrig.dev
|
|
6
|
+
Project-URL: Documentation, https://siliconrig.dev/docs/guides/python-sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/siliconrig/srig-python
|
|
8
|
+
Author: RAWS Consulting
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: HIL,MCU,embedded,hardware-in-the-loop,pytest,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Software Development :: Testing
|
|
18
|
+
Classifier: Topic :: System :: Hardware
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: httpx<1,>=0.27
|
|
21
|
+
Requires-Dist: websockets<15,>=13.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# siliconrig
|
|
29
|
+
|
|
30
|
+
Python SDK for [siliconrig](https://siliconrig.dev) — remote access to MCU development boards.
|
|
31
|
+
|
|
32
|
+
Use it in scripts, automation, or as a pytest plugin for hardware-in-the-loop testing.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install siliconrig
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from siliconrig import Client
|
|
44
|
+
|
|
45
|
+
client = Client()
|
|
46
|
+
|
|
47
|
+
with client.session(board="esp32s3") as session:
|
|
48
|
+
session.flash("firmware.bin")
|
|
49
|
+
session.serial.expect("Ready", timeout=10)
|
|
50
|
+
session.serial.send("status\n")
|
|
51
|
+
print(session.serial.read_until("OK", timeout=5))
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or use the `Board` shorthand:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from siliconrig import Board
|
|
58
|
+
|
|
59
|
+
with Board("esp32-s3", firmware="build/app.bin") as board:
|
|
60
|
+
board.expect("System ready", timeout=5)
|
|
61
|
+
board.send("gpio set 4 1\n")
|
|
62
|
+
board.expect("GPIO4=HIGH", timeout=2)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## pytest plugin
|
|
66
|
+
|
|
67
|
+
The package includes a pytest plugin that registers automatically. Use it with custom fixtures:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import pytest
|
|
71
|
+
from siliconrig import Board
|
|
72
|
+
|
|
73
|
+
@pytest.fixture
|
|
74
|
+
def board():
|
|
75
|
+
with Board("esp32-s3", firmware="build/app.bin") as b:
|
|
76
|
+
yield b
|
|
77
|
+
|
|
78
|
+
def test_boot_ok(board):
|
|
79
|
+
assert board.expect("System ready", timeout=5)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or use the built-in `siliconrig_board` fixture via CLI options:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pytest --siliconrig-board esp32s3 --siliconrig-firmware build/app.bin tests/hil/
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Authentication
|
|
89
|
+
|
|
90
|
+
Set your API key via environment variable:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
export SRIG_API_KEY=key_...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or pass it directly:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
client = Client(api_key="key_...")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Documentation
|
|
103
|
+
|
|
104
|
+
- [Python SDK guide](https://siliconrig.dev/docs/guides/python-sdk)
|
|
105
|
+
- [CI/CD integration](https://siliconrig.dev/docs/guides/cicd)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# siliconrig
|
|
2
|
+
|
|
3
|
+
Python SDK for [siliconrig](https://siliconrig.dev) — remote access to MCU development boards.
|
|
4
|
+
|
|
5
|
+
Use it in scripts, automation, or as a pytest plugin for hardware-in-the-loop testing.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install siliconrig
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from siliconrig import Client
|
|
17
|
+
|
|
18
|
+
client = Client()
|
|
19
|
+
|
|
20
|
+
with client.session(board="esp32s3") as session:
|
|
21
|
+
session.flash("firmware.bin")
|
|
22
|
+
session.serial.expect("Ready", timeout=10)
|
|
23
|
+
session.serial.send("status\n")
|
|
24
|
+
print(session.serial.read_until("OK", timeout=5))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or use the `Board` shorthand:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from siliconrig import Board
|
|
31
|
+
|
|
32
|
+
with Board("esp32-s3", firmware="build/app.bin") as board:
|
|
33
|
+
board.expect("System ready", timeout=5)
|
|
34
|
+
board.send("gpio set 4 1\n")
|
|
35
|
+
board.expect("GPIO4=HIGH", timeout=2)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## pytest plugin
|
|
39
|
+
|
|
40
|
+
The package includes a pytest plugin that registers automatically. Use it with custom fixtures:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import pytest
|
|
44
|
+
from siliconrig import Board
|
|
45
|
+
|
|
46
|
+
@pytest.fixture
|
|
47
|
+
def board():
|
|
48
|
+
with Board("esp32-s3", firmware="build/app.bin") as b:
|
|
49
|
+
yield b
|
|
50
|
+
|
|
51
|
+
def test_boot_ok(board):
|
|
52
|
+
assert board.expect("System ready", timeout=5)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or use the built-in `siliconrig_board` fixture via CLI options:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pytest --siliconrig-board esp32s3 --siliconrig-firmware build/app.bin tests/hil/
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Authentication
|
|
62
|
+
|
|
63
|
+
Set your API key via environment variable:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
export SRIG_API_KEY=key_...
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or pass it directly:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
client = Client(api_key="key_...")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Documentation
|
|
76
|
+
|
|
77
|
+
- [Python SDK guide](https://siliconrig.dev/docs/guides/python-sdk)
|
|
78
|
+
- [CI/CD integration](https://siliconrig.dev/docs/guides/cicd)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "siliconrig"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Python SDK and pytest fixtures for siliconrig hardware-in-the-loop testing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "RAWS Consulting" }]
|
|
13
|
+
keywords = ["embedded", "testing", "hardware-in-the-loop", "HIL", "pytest", "MCU"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Testing",
|
|
21
|
+
"Topic :: System :: Hardware",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"websockets>=13.0,<15",
|
|
25
|
+
"httpx>=0.27,<1",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"pytest-asyncio>=0.24",
|
|
32
|
+
"ruff>=0.8",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://siliconrig.dev"
|
|
37
|
+
Documentation = "https://siliconrig.dev/docs/guides/python-sdk"
|
|
38
|
+
Repository = "https://github.com/siliconrig/srig-python"
|
|
39
|
+
|
|
40
|
+
[project.entry-points.pytest11]
|
|
41
|
+
siliconrig = "siliconrig.plugin"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.version]
|
|
44
|
+
source = "vcs"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/siliconrig"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
line-length = 99
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""siliconrig — Python SDK for hardware-in-the-loop testing."""
|
|
2
|
+
|
|
3
|
+
from siliconrig.client import Client
|
|
4
|
+
from siliconrig.board import Board
|
|
5
|
+
from siliconrig.exceptions import (
|
|
6
|
+
SiliconrigError,
|
|
7
|
+
AuthError,
|
|
8
|
+
SessionError,
|
|
9
|
+
FlashError,
|
|
10
|
+
SerialTimeout,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Client",
|
|
15
|
+
"Board",
|
|
16
|
+
"SiliconrigError",
|
|
17
|
+
"AuthError",
|
|
18
|
+
"SessionError",
|
|
19
|
+
"FlashError",
|
|
20
|
+
"SerialTimeout",
|
|
21
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""High-level Board convenience wrapper."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
from siliconrig.client import Client
|
|
8
|
+
from siliconrig.session import Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
"""Convenience wrapper that creates a client, session, and flashes firmware.
|
|
13
|
+
|
|
14
|
+
Designed for concise pytest fixtures::
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def board():
|
|
18
|
+
with Board("esp32-s3", firmware="build/app.bin") as b:
|
|
19
|
+
yield b
|
|
20
|
+
|
|
21
|
+
def test_boot(board):
|
|
22
|
+
assert board.expect("Ready", timeout=5)
|
|
23
|
+
|
|
24
|
+
Serial methods (``send``, ``read``, ``read_until``, ``expect``, ``flush``)
|
|
25
|
+
are available directly on the Board instance for convenience.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
board_type: str,
|
|
31
|
+
firmware: str | Path | None = None,
|
|
32
|
+
api_key: str | None = None,
|
|
33
|
+
base_url: str | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._board_type = board_type
|
|
36
|
+
self._firmware = firmware
|
|
37
|
+
self._api_key = api_key
|
|
38
|
+
self._base_url = base_url
|
|
39
|
+
self._client: Client | None = None
|
|
40
|
+
self._session: Session | None = None
|
|
41
|
+
self._ctx: Any = None
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> Self:
|
|
44
|
+
self._client = Client(api_key=self._api_key, base_url=self._base_url)
|
|
45
|
+
self._ctx = self._client.session(board=self._board_type)
|
|
46
|
+
self._session = self._ctx.__enter__()
|
|
47
|
+
|
|
48
|
+
if self._firmware:
|
|
49
|
+
self._session.flash(self._firmware)
|
|
50
|
+
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(
|
|
54
|
+
self,
|
|
55
|
+
exc_type: type[BaseException] | None,
|
|
56
|
+
exc_val: BaseException | None,
|
|
57
|
+
exc_tb: TracebackType | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
if self._ctx:
|
|
60
|
+
self._ctx.__exit__(exc_type, exc_val, exc_tb)
|
|
61
|
+
if self._client:
|
|
62
|
+
self._client.close()
|
|
63
|
+
|
|
64
|
+
# -- proxied serial methods -----------------------------------------------
|
|
65
|
+
|
|
66
|
+
def send(self, data: str) -> None:
|
|
67
|
+
"""Send data to the board's UART."""
|
|
68
|
+
assert self._session is not None
|
|
69
|
+
self._session.serial.send(data)
|
|
70
|
+
|
|
71
|
+
def read(self, n: int = 4096, timeout: float = 5.0) -> str:
|
|
72
|
+
"""Read up to *n* characters."""
|
|
73
|
+
assert self._session is not None
|
|
74
|
+
return self._session.serial.read(n, timeout=timeout)
|
|
75
|
+
|
|
76
|
+
def read_until(self, pattern: str, timeout: float = 10.0) -> str:
|
|
77
|
+
"""Read until *pattern* appears."""
|
|
78
|
+
assert self._session is not None
|
|
79
|
+
return self._session.serial.read_until(pattern, timeout=timeout)
|
|
80
|
+
|
|
81
|
+
def expect(self, pattern: str, timeout: float = 10.0) -> str:
|
|
82
|
+
"""Assert that *pattern* appears within *timeout* seconds."""
|
|
83
|
+
assert self._session is not None
|
|
84
|
+
return self._session.serial.expect(pattern, timeout=timeout)
|
|
85
|
+
|
|
86
|
+
def flush(self) -> None:
|
|
87
|
+
"""Clear the serial receive buffer."""
|
|
88
|
+
assert self._session is not None
|
|
89
|
+
self._session.serial.flush()
|
|
90
|
+
|
|
91
|
+
# -- proxied session methods ----------------------------------------------
|
|
92
|
+
|
|
93
|
+
def flash(self, firmware: str | Path, timeout: float = 120.0) -> None:
|
|
94
|
+
"""Flash firmware to the board."""
|
|
95
|
+
assert self._session is not None
|
|
96
|
+
self._session.flash(firmware, timeout=timeout)
|
|
97
|
+
|
|
98
|
+
def reset(self) -> None:
|
|
99
|
+
"""Power-cycle the board."""
|
|
100
|
+
assert self._session is not None
|
|
101
|
+
self._session.reset()
|
|
102
|
+
|
|
103
|
+
def info(self) -> dict[str, Any]:
|
|
104
|
+
"""Get session details."""
|
|
105
|
+
assert self._session is not None
|
|
106
|
+
return self._session.info()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def serial(self):
|
|
110
|
+
"""Access the underlying Serial instance."""
|
|
111
|
+
assert self._session is not None
|
|
112
|
+
return self._session.serial
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def session(self) -> Session:
|
|
116
|
+
"""Access the underlying Session instance."""
|
|
117
|
+
assert self._session is not None
|
|
118
|
+
return self._session
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""siliconrig API client."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from siliconrig.exceptions import AuthError, SessionError
|
|
11
|
+
from siliconrig.session import Session
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.srig.io"
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Client:
|
|
18
|
+
"""Client for the siliconrig REST API.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
api_key: API key (``key_...``). Falls back to
|
|
22
|
+
the ``SRIG_API_KEY`` environment variable.
|
|
23
|
+
base_url: Coordinator base URL. Falls back to ``SRIG_BASE_URL``
|
|
24
|
+
or ``https://api.srig.io``.
|
|
25
|
+
timeout: Default HTTP timeout in seconds.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: str | None = None,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.api_key = api_key or os.environ.get("SRIG_API_KEY")
|
|
35
|
+
if not self.api_key:
|
|
36
|
+
raise AuthError(
|
|
37
|
+
"No API key provided. Pass api_key= or set SRIG_API_KEY."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.base_url = (
|
|
41
|
+
base_url or os.environ.get("SRIG_BASE_URL") or DEFAULT_BASE_URL
|
|
42
|
+
).rstrip("/")
|
|
43
|
+
|
|
44
|
+
self._http = httpx.Client(
|
|
45
|
+
base_url=self.base_url,
|
|
46
|
+
headers={"X-API-Key": self.api_key},
|
|
47
|
+
timeout=timeout,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# -- public helpers -------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def boards(self) -> list[dict[str, Any]]:
|
|
53
|
+
"""List available board types with real-time availability."""
|
|
54
|
+
resp = self._http.get("/v1/boards")
|
|
55
|
+
_check(resp)
|
|
56
|
+
return resp.json()
|
|
57
|
+
|
|
58
|
+
@contextmanager
|
|
59
|
+
def session(
|
|
60
|
+
self,
|
|
61
|
+
board: str,
|
|
62
|
+
base_image_id: str | None = None,
|
|
63
|
+
) -> Generator[Session, None, None]:
|
|
64
|
+
"""Create a hardware session and yield it as a context manager.
|
|
65
|
+
|
|
66
|
+
The session is automatically ended when the block exits.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
board: Board type identifier (e.g. ``"esp32s3"``).
|
|
70
|
+
base_image_id: Optional base image to pre-flash.
|
|
71
|
+
"""
|
|
72
|
+
body: dict[str, Any] = {"board_type": board}
|
|
73
|
+
if base_image_id:
|
|
74
|
+
body["base_image_id"] = base_image_id
|
|
75
|
+
|
|
76
|
+
resp = self._http.post("/v1/sessions", json=body)
|
|
77
|
+
_check(resp)
|
|
78
|
+
data = resp.json()
|
|
79
|
+
session_id: str = data["id"]
|
|
80
|
+
|
|
81
|
+
session = Session(
|
|
82
|
+
session_id=session_id,
|
|
83
|
+
data=data,
|
|
84
|
+
http=self._http,
|
|
85
|
+
base_url=self.base_url,
|
|
86
|
+
api_key=self.api_key,
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
yield session
|
|
90
|
+
finally:
|
|
91
|
+
session.close()
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
"""Close the underlying HTTP client."""
|
|
95
|
+
self._http.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check(resp: httpx.Response) -> None:
|
|
99
|
+
"""Raise a typed exception for non-2xx responses."""
|
|
100
|
+
if resp.is_success:
|
|
101
|
+
return
|
|
102
|
+
try:
|
|
103
|
+
detail = resp.json().get("error", resp.text)
|
|
104
|
+
except Exception:
|
|
105
|
+
detail = resp.text
|
|
106
|
+
if resp.status_code in (401, 403):
|
|
107
|
+
raise AuthError(detail)
|
|
108
|
+
raise SessionError(f"[{resp.status_code}] {detail}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""siliconrig exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SiliconrigError(Exception):
|
|
5
|
+
"""Base exception for all siliconrig errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AuthError(SiliconrigError):
|
|
9
|
+
"""Authentication or authorization failure."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionError(SiliconrigError):
|
|
13
|
+
"""Session lifecycle error (create, end, not found)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FlashError(SiliconrigError):
|
|
17
|
+
"""Firmware flashing failed."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SerialTimeout(SiliconrigError):
|
|
21
|
+
"""Serial read/expect timed out."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""pytest plugin — auto-registered via the ``pytest11`` entry point.
|
|
2
|
+
|
|
3
|
+
Provides the ``siliconrig_board`` fixture and CLI options.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from siliconrig.board import Board
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
12
|
+
group = parser.getgroup("siliconrig", "siliconrig hardware-in-the-loop testing")
|
|
13
|
+
group.addoption(
|
|
14
|
+
"--siliconrig-board",
|
|
15
|
+
dest="siliconrig_board",
|
|
16
|
+
default=None,
|
|
17
|
+
help="Board type to use for siliconrig sessions (e.g. esp32s3).",
|
|
18
|
+
)
|
|
19
|
+
group.addoption(
|
|
20
|
+
"--siliconrig-firmware",
|
|
21
|
+
dest="siliconrig_firmware",
|
|
22
|
+
default=None,
|
|
23
|
+
help="Path to firmware binary to flash before tests.",
|
|
24
|
+
)
|
|
25
|
+
group.addoption(
|
|
26
|
+
"--siliconrig-api-key",
|
|
27
|
+
dest="siliconrig_api_key",
|
|
28
|
+
default=None,
|
|
29
|
+
help="API key (overrides SRIG_API_KEY env var).",
|
|
30
|
+
)
|
|
31
|
+
group.addoption(
|
|
32
|
+
"--siliconrig-base-url",
|
|
33
|
+
dest="siliconrig_base_url",
|
|
34
|
+
default=None,
|
|
35
|
+
help="Coordinator base URL (overrides SRIG_BASE_URL env var).",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture(scope="session")
|
|
40
|
+
def siliconrig_board(request: pytest.FixtureRequest):
|
|
41
|
+
"""Session-scoped fixture that provides a flashed, ready-to-use board.
|
|
42
|
+
|
|
43
|
+
Configure via CLI options or environment variables::
|
|
44
|
+
|
|
45
|
+
pytest --siliconrig-board esp32s3 --siliconrig-firmware build/app.bin
|
|
46
|
+
|
|
47
|
+
Or use the ``Board`` class directly in your own fixtures for more control.
|
|
48
|
+
"""
|
|
49
|
+
board_type = request.config.getoption("siliconrig_board")
|
|
50
|
+
if board_type is None:
|
|
51
|
+
pytest.skip("No --siliconrig-board specified; skipping HIL tests")
|
|
52
|
+
|
|
53
|
+
firmware = request.config.getoption("siliconrig_firmware")
|
|
54
|
+
api_key = request.config.getoption("siliconrig_api_key")
|
|
55
|
+
base_url = request.config.getoption("siliconrig_base_url")
|
|
56
|
+
|
|
57
|
+
with Board(
|
|
58
|
+
board_type,
|
|
59
|
+
firmware=firmware,
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
base_url=base_url,
|
|
62
|
+
) as board:
|
|
63
|
+
yield board
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Serial interface over WebSocket."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from collections import deque
|
|
9
|
+
|
|
10
|
+
import websockets.sync.client as ws_sync
|
|
11
|
+
|
|
12
|
+
from siliconrig.exceptions import SerialTimeout
|
|
13
|
+
|
|
14
|
+
_WS_CLOSE_TIMEOUT = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Serial:
|
|
18
|
+
"""WebSocket-backed serial console for a siliconrig session.
|
|
19
|
+
|
|
20
|
+
Connects to the coordinator's serial proxy and exposes a synchronous
|
|
21
|
+
send/read/expect API suitable for pytest tests.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, ws_url: str, api_key: str) -> None:
|
|
25
|
+
self._buf: deque[str] = deque()
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._closed = False
|
|
28
|
+
self._error: Exception | None = None
|
|
29
|
+
|
|
30
|
+
headers = {"X-API-Key": api_key}
|
|
31
|
+
self._ws = ws_sync.connect(ws_url, additional_headers=headers)
|
|
32
|
+
|
|
33
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
34
|
+
self._reader.start()
|
|
35
|
+
|
|
36
|
+
# -- write ----------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def send(self, data: str) -> None:
|
|
39
|
+
"""Send a string to the board's UART."""
|
|
40
|
+
msg = json.dumps({"type": "serial_data", "data": data})
|
|
41
|
+
self._ws.send(msg)
|
|
42
|
+
|
|
43
|
+
# -- read -----------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def read(self, n: int = 4096, timeout: float = 5.0) -> str:
|
|
46
|
+
"""Read up to *n* characters from the receive buffer.
|
|
47
|
+
|
|
48
|
+
Blocks until at least one character is available or *timeout* expires.
|
|
49
|
+
"""
|
|
50
|
+
deadline = time.monotonic() + timeout
|
|
51
|
+
while time.monotonic() < deadline:
|
|
52
|
+
with self._lock:
|
|
53
|
+
if self._buf:
|
|
54
|
+
text = "".join(self._buf)
|
|
55
|
+
self._buf.clear()
|
|
56
|
+
return text[:n]
|
|
57
|
+
time.sleep(0.05)
|
|
58
|
+
raise SerialTimeout(f"No data received within {timeout}s")
|
|
59
|
+
|
|
60
|
+
def read_until(self, pattern: str, timeout: float = 10.0) -> str:
|
|
61
|
+
"""Read until *pattern* appears in the accumulated output.
|
|
62
|
+
|
|
63
|
+
Returns everything up to and including the matched text.
|
|
64
|
+
Data after the match is preserved in the buffer for subsequent reads.
|
|
65
|
+
"""
|
|
66
|
+
collected: list[str] = []
|
|
67
|
+
deadline = time.monotonic() + timeout
|
|
68
|
+
regex = re.compile(re.escape(pattern))
|
|
69
|
+
|
|
70
|
+
while time.monotonic() < deadline:
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._buf:
|
|
73
|
+
collected.append("".join(self._buf))
|
|
74
|
+
self._buf.clear()
|
|
75
|
+
|
|
76
|
+
full = "".join(collected)
|
|
77
|
+
m = regex.search(full)
|
|
78
|
+
if m:
|
|
79
|
+
# Put unmatched remainder back into the buffer.
|
|
80
|
+
remainder = full[m.end():]
|
|
81
|
+
if remainder:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._buf.appendleft(remainder)
|
|
84
|
+
return full[: m.end()]
|
|
85
|
+
time.sleep(0.05)
|
|
86
|
+
|
|
87
|
+
full = "".join(collected)
|
|
88
|
+
extra = ""
|
|
89
|
+
if self._error is not None:
|
|
90
|
+
extra = f" (reader thread died: {self._error})"
|
|
91
|
+
raise SerialTimeout(
|
|
92
|
+
f"Pattern {pattern!r} not found within {timeout}s.{extra} "
|
|
93
|
+
f"Received so far: {full[-200:]!r}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def expect(self, pattern: str, timeout: float = 10.0) -> str:
|
|
97
|
+
"""Assert that *pattern* appears within *timeout* seconds.
|
|
98
|
+
|
|
99
|
+
Returns the matched output (same as ``read_until``).
|
|
100
|
+
Raises ``SerialTimeout`` on failure.
|
|
101
|
+
"""
|
|
102
|
+
return self.read_until(pattern, timeout=timeout)
|
|
103
|
+
|
|
104
|
+
def flush(self) -> None:
|
|
105
|
+
"""Discard all buffered receive data."""
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._buf.clear()
|
|
108
|
+
|
|
109
|
+
# -- lifecycle ------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""Disconnect the WebSocket."""
|
|
113
|
+
self._closed = True
|
|
114
|
+
try:
|
|
115
|
+
self._ws.close(timeout=_WS_CLOSE_TIMEOUT)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
# -- internal -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def _read_loop(self) -> None:
|
|
122
|
+
"""Background thread: read WebSocket frames into the buffer."""
|
|
123
|
+
try:
|
|
124
|
+
for raw in self._ws:
|
|
125
|
+
if self._closed:
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
msg = json.loads(raw)
|
|
129
|
+
except (json.JSONDecodeError, TypeError):
|
|
130
|
+
continue
|
|
131
|
+
if msg.get("type") == "serial_data":
|
|
132
|
+
raw_data = msg.get("data", "")
|
|
133
|
+
try:
|
|
134
|
+
text = base64.b64decode(raw_data).decode("utf-8", errors="replace")
|
|
135
|
+
except Exception:
|
|
136
|
+
text = raw_data
|
|
137
|
+
with self._lock:
|
|
138
|
+
self._buf.append(text)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
if not self._closed:
|
|
141
|
+
self._error = exc
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Hardware session wrapper."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from siliconrig.exceptions import FlashError, SessionError
|
|
10
|
+
from siliconrig.serial import Serial
|
|
11
|
+
|
|
12
|
+
_FLASH_POLL_INTERVAL = 0.1
|
|
13
|
+
_FLASH_DEFAULT_TIMEOUT = 120.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Session:
|
|
17
|
+
"""A live session on a remote board.
|
|
18
|
+
|
|
19
|
+
Created via :meth:`Client.session` — not instantiated directly.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
session_id: str,
|
|
25
|
+
data: dict[str, Any],
|
|
26
|
+
http: httpx.Client,
|
|
27
|
+
base_url: str,
|
|
28
|
+
api_key: str,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.id = session_id
|
|
31
|
+
self._data = data
|
|
32
|
+
self._http = http
|
|
33
|
+
self._api_key = api_key
|
|
34
|
+
self._closed = False
|
|
35
|
+
|
|
36
|
+
ws_scheme = "wss" if base_url.startswith("https") else "ws"
|
|
37
|
+
ws_base = base_url.replace("https://", "").replace("http://", "")
|
|
38
|
+
ws_url = f"{ws_scheme}://{ws_base}/v1/sessions/{session_id}/serial"
|
|
39
|
+
|
|
40
|
+
self.serial = Serial(ws_url, api_key)
|
|
41
|
+
|
|
42
|
+
# -- firmware -------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def flash(
|
|
45
|
+
self,
|
|
46
|
+
firmware: str | Path,
|
|
47
|
+
timeout: float = _FLASH_DEFAULT_TIMEOUT,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Upload and flash a firmware binary.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
firmware: Path to the ``.bin`` file (max 4 MB).
|
|
53
|
+
timeout: Seconds to wait for flashing to complete.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
FlashError: If flashing fails or times out.
|
|
57
|
+
FileNotFoundError: If the firmware file doesn't exist.
|
|
58
|
+
"""
|
|
59
|
+
path = Path(firmware)
|
|
60
|
+
if not path.exists():
|
|
61
|
+
raise FileNotFoundError(f"Firmware not found: {path}")
|
|
62
|
+
|
|
63
|
+
with open(path, "rb") as f:
|
|
64
|
+
resp = self._http.post(
|
|
65
|
+
f"/v1/sessions/{self.id}/flash",
|
|
66
|
+
files={"firmware": (path.name, f, "application/octet-stream")},
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if not resp.is_success:
|
|
71
|
+
try:
|
|
72
|
+
detail = resp.json().get("error", resp.text)
|
|
73
|
+
except Exception:
|
|
74
|
+
detail = resp.text
|
|
75
|
+
raise FlashError(f"Flash upload failed: {detail}")
|
|
76
|
+
|
|
77
|
+
self._wait_flash(timeout)
|
|
78
|
+
|
|
79
|
+
# -- power ----------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def reset(self) -> None:
|
|
82
|
+
"""Power-cycle the board via USB hub port control."""
|
|
83
|
+
resp = self._http.post(f"/v1/sessions/{self.id}/power-cycle")
|
|
84
|
+
if not resp.is_success:
|
|
85
|
+
raise SessionError(f"Power cycle failed: {resp.text}")
|
|
86
|
+
|
|
87
|
+
# -- info -----------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def info(self) -> dict[str, Any]:
|
|
90
|
+
"""Fetch current session details from the coordinator."""
|
|
91
|
+
resp = self._http.get(f"/v1/sessions/{self.id}")
|
|
92
|
+
if not resp.is_success:
|
|
93
|
+
raise SessionError(f"Failed to get session info: {resp.text}")
|
|
94
|
+
self._data = resp.json()
|
|
95
|
+
return self._data
|
|
96
|
+
|
|
97
|
+
# -- lifecycle ------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
"""End the session and disconnect serial."""
|
|
101
|
+
if self._closed:
|
|
102
|
+
return
|
|
103
|
+
self._closed = True
|
|
104
|
+
self.serial.close()
|
|
105
|
+
try:
|
|
106
|
+
self._http.delete(f"/v1/sessions/{self.id}")
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# -- internal -------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def _wait_flash(self, timeout: float) -> None:
|
|
113
|
+
"""Poll session info until the board is done flashing."""
|
|
114
|
+
deadline = time.monotonic() + timeout
|
|
115
|
+
while time.monotonic() < deadline:
|
|
116
|
+
data = self.info()
|
|
117
|
+
state = data.get("state", "")
|
|
118
|
+
if state in ("idle", "active"):
|
|
119
|
+
return
|
|
120
|
+
if state in ("error", "ended"):
|
|
121
|
+
raise FlashError(f"Flash failed: {data.get('end_reason', 'unknown')}")
|
|
122
|
+
time.sleep(_FLASH_POLL_INTERVAL)
|
|
123
|
+
raise FlashError(f"Flash did not complete within {timeout}s")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared test fixtures."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeWebSocket:
|
|
11
|
+
"""Simulates a WebSocket connection for serial testing."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._inbox: list[str] = []
|
|
15
|
+
self._sent: list[str] = []
|
|
16
|
+
self._closed = False
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def inject(self, msg_type: str, data: str) -> None:
|
|
20
|
+
with self._lock:
|
|
21
|
+
self._inbox.append(json.dumps({"type": msg_type, "data": data}))
|
|
22
|
+
|
|
23
|
+
def send(self, data: str) -> None:
|
|
24
|
+
self._sent.append(data)
|
|
25
|
+
|
|
26
|
+
def close(self, timeout: float = 3) -> None:
|
|
27
|
+
self._closed = True
|
|
28
|
+
|
|
29
|
+
def __iter__(self):
|
|
30
|
+
while not self._closed:
|
|
31
|
+
with self._lock:
|
|
32
|
+
if self._inbox:
|
|
33
|
+
yield self._inbox.pop(0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def fake_ws():
|
|
38
|
+
return FakeWebSocket()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def mock_http():
|
|
43
|
+
"""A mocked httpx.Client that returns canned responses."""
|
|
44
|
+
client = MagicMock()
|
|
45
|
+
|
|
46
|
+
def make_response(status_code=200, json_data=None):
|
|
47
|
+
resp = MagicMock()
|
|
48
|
+
resp.status_code = status_code
|
|
49
|
+
resp.is_success = 200 <= status_code < 300
|
|
50
|
+
resp.json.return_value = json_data or {}
|
|
51
|
+
resp.text = json.dumps(json_data or {})
|
|
52
|
+
return resp
|
|
53
|
+
|
|
54
|
+
client._make_response = make_response
|
|
55
|
+
return client
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tests for siliconrig.Board convenience wrapper."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from siliconrig.board import Board
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestBoardContextManager:
|
|
11
|
+
@patch("siliconrig.board.Client")
|
|
12
|
+
def test_creates_session_and_flashes(self, MockClient):
|
|
13
|
+
mock_client = MockClient.return_value
|
|
14
|
+
mock_session = MagicMock()
|
|
15
|
+
mock_ctx = MagicMock()
|
|
16
|
+
mock_ctx.__enter__ = MagicMock(return_value=mock_session)
|
|
17
|
+
mock_ctx.__exit__ = MagicMock(return_value=False)
|
|
18
|
+
mock_client.session.return_value = mock_ctx
|
|
19
|
+
|
|
20
|
+
with Board("esp32s3", firmware="app.bin", api_key="sk_test") as b:
|
|
21
|
+
assert b.session is mock_session
|
|
22
|
+
mock_session.flash.assert_called_once_with("app.bin")
|
|
23
|
+
|
|
24
|
+
mock_client.close.assert_called_once()
|
|
25
|
+
|
|
26
|
+
@patch("siliconrig.board.Client")
|
|
27
|
+
def test_no_firmware_skips_flash(self, MockClient):
|
|
28
|
+
mock_client = MockClient.return_value
|
|
29
|
+
mock_session = MagicMock()
|
|
30
|
+
mock_ctx = MagicMock()
|
|
31
|
+
mock_ctx.__enter__ = MagicMock(return_value=mock_session)
|
|
32
|
+
mock_ctx.__exit__ = MagicMock(return_value=False)
|
|
33
|
+
mock_client.session.return_value = mock_ctx
|
|
34
|
+
|
|
35
|
+
with Board("esp32s3", api_key="sk_test") as b:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
mock_session.flash.assert_not_called()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestBoardProxies:
|
|
42
|
+
@patch("siliconrig.board.Client")
|
|
43
|
+
def test_send_proxies_to_serial(self, MockClient):
|
|
44
|
+
mock_client = MockClient.return_value
|
|
45
|
+
mock_session = MagicMock()
|
|
46
|
+
mock_ctx = MagicMock()
|
|
47
|
+
mock_ctx.__enter__ = MagicMock(return_value=mock_session)
|
|
48
|
+
mock_ctx.__exit__ = MagicMock(return_value=False)
|
|
49
|
+
mock_client.session.return_value = mock_ctx
|
|
50
|
+
|
|
51
|
+
with Board("esp32s3", api_key="sk_test") as b:
|
|
52
|
+
b.send("test\n")
|
|
53
|
+
mock_session.serial.send.assert_called_with("test\n")
|
|
54
|
+
|
|
55
|
+
b.expect("OK")
|
|
56
|
+
mock_session.serial.expect.assert_called_with("OK", timeout=10.0)
|
|
57
|
+
|
|
58
|
+
b.flush()
|
|
59
|
+
mock_session.serial.flush.assert_called_once()
|
|
60
|
+
|
|
61
|
+
b.reset()
|
|
62
|
+
mock_session.reset.assert_called_once()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Tests for siliconrig.Client."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from siliconrig.client import Client, _check
|
|
9
|
+
from siliconrig.exceptions import AuthError, SessionError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestClientInit:
|
|
13
|
+
def test_requires_api_key(self):
|
|
14
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
15
|
+
os.environ.pop("SRIG_API_KEY", None)
|
|
16
|
+
with pytest.raises(AuthError, match="No API key"):
|
|
17
|
+
Client()
|
|
18
|
+
|
|
19
|
+
def test_reads_env_var(self):
|
|
20
|
+
with patch.dict(os.environ, {"SRIG_API_KEY": "sk_test_123"}):
|
|
21
|
+
c = Client()
|
|
22
|
+
assert c.api_key == "sk_test_123"
|
|
23
|
+
c.close()
|
|
24
|
+
|
|
25
|
+
def test_explicit_key_overrides_env(self):
|
|
26
|
+
with patch.dict(os.environ, {"SRIG_API_KEY": "sk_env"}):
|
|
27
|
+
c = Client(api_key="sk_explicit")
|
|
28
|
+
assert c.api_key == "sk_explicit"
|
|
29
|
+
c.close()
|
|
30
|
+
|
|
31
|
+
def test_custom_base_url(self):
|
|
32
|
+
c = Client(api_key="sk_test", base_url="http://localhost:8080")
|
|
33
|
+
assert c.base_url == "http://localhost:8080"
|
|
34
|
+
c.close()
|
|
35
|
+
|
|
36
|
+
def test_strips_trailing_slash(self):
|
|
37
|
+
c = Client(api_key="sk_test", base_url="http://localhost:8080/")
|
|
38
|
+
assert c.base_url == "http://localhost:8080"
|
|
39
|
+
c.close()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestCheck:
|
|
43
|
+
def test_success_passes(self):
|
|
44
|
+
resp = MagicMock()
|
|
45
|
+
resp.is_success = True
|
|
46
|
+
_check(resp) # should not raise
|
|
47
|
+
|
|
48
|
+
def test_401_raises_auth_error(self):
|
|
49
|
+
resp = MagicMock()
|
|
50
|
+
resp.is_success = False
|
|
51
|
+
resp.status_code = 401
|
|
52
|
+
resp.json.return_value = {"error": "invalid key"}
|
|
53
|
+
with pytest.raises(AuthError, match="invalid key"):
|
|
54
|
+
_check(resp)
|
|
55
|
+
|
|
56
|
+
def test_500_raises_session_error(self):
|
|
57
|
+
resp = MagicMock()
|
|
58
|
+
resp.is_success = False
|
|
59
|
+
resp.status_code = 500
|
|
60
|
+
resp.json.return_value = {"error": "internal"}
|
|
61
|
+
with pytest.raises(SessionError, match="internal"):
|
|
62
|
+
_check(resp)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Tests for siliconrig.Serial."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from siliconrig.exceptions import SerialTimeout
|
|
11
|
+
from siliconrig.serial import Serial
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def serial_conn(fake_ws):
|
|
16
|
+
with patch("siliconrig.serial.ws_sync.connect", return_value=fake_ws):
|
|
17
|
+
s = Serial("ws://localhost/serial", "sk_test")
|
|
18
|
+
yield s, fake_ws
|
|
19
|
+
s.close()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestSend:
|
|
23
|
+
def test_sends_json_frame(self, serial_conn):
|
|
24
|
+
serial, ws = serial_conn
|
|
25
|
+
serial.send("hello\n")
|
|
26
|
+
sent = json.loads(ws._sent[-1])
|
|
27
|
+
assert sent == {"type": "serial_data", "data": "hello\n"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestRead:
|
|
31
|
+
def test_reads_buffered_data(self, serial_conn):
|
|
32
|
+
serial, ws = serial_conn
|
|
33
|
+
ws.inject("serial_data", "boot ok\n")
|
|
34
|
+
time.sleep(0.15)
|
|
35
|
+
result = serial.read(timeout=1)
|
|
36
|
+
assert "boot ok" in result
|
|
37
|
+
|
|
38
|
+
def test_timeout_raises(self, serial_conn):
|
|
39
|
+
serial, _ = serial_conn
|
|
40
|
+
with pytest.raises(SerialTimeout):
|
|
41
|
+
serial.read(timeout=0.2)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestReadUntil:
|
|
45
|
+
def test_finds_pattern(self, serial_conn):
|
|
46
|
+
serial, ws = serial_conn
|
|
47
|
+
ws.inject("serial_data", "loading... ")
|
|
48
|
+
ws.inject("serial_data", "Ready\n")
|
|
49
|
+
time.sleep(0.15)
|
|
50
|
+
result = serial.read_until("Ready", timeout=2)
|
|
51
|
+
assert "Ready" in result
|
|
52
|
+
|
|
53
|
+
def test_timeout_shows_received(self, serial_conn):
|
|
54
|
+
serial, ws = serial_conn
|
|
55
|
+
ws.inject("serial_data", "partial")
|
|
56
|
+
time.sleep(0.15)
|
|
57
|
+
with pytest.raises(SerialTimeout, match="partial"):
|
|
58
|
+
serial.read_until("NEVER", timeout=0.3)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestExpect:
|
|
62
|
+
def test_expect_is_read_until(self, serial_conn):
|
|
63
|
+
serial, ws = serial_conn
|
|
64
|
+
ws.inject("serial_data", "System ready\n")
|
|
65
|
+
time.sleep(0.15)
|
|
66
|
+
result = serial.expect("ready", timeout=2)
|
|
67
|
+
assert "ready" in result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestFlush:
|
|
71
|
+
def test_clears_buffer(self, serial_conn):
|
|
72
|
+
serial, ws = serial_conn
|
|
73
|
+
ws.inject("serial_data", "noise")
|
|
74
|
+
time.sleep(0.15)
|
|
75
|
+
serial.flush()
|
|
76
|
+
with pytest.raises(SerialTimeout):
|
|
77
|
+
serial.read(timeout=0.2)
|