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.
@@ -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
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ *.egg
7
+ .*
8
+ !.gitignore
9
+ !.github/
@@ -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)