embeddedci-mcp 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.
@@ -0,0 +1,13 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+
7
+ # Build artifacts
8
+ build/
9
+ dist/
10
+
11
+ # Virtualenvs
12
+ .venv/
13
+ venv/
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: embeddedci-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing the EmbeddedCI BenchPod SDK as tools for AI agents
5
+ Project-URL: Homepage, https://embeddedci.com
6
+ Project-URL: Repository, https://github.com/embeddedci-com/embeddedci-python
7
+ Project-URL: Issues, https://github.com/embeddedci-com/embeddedci-python/issues
8
+ Author: EmbeddedCI
9
+ License: Apache-2.0
10
+ Keywords: agent,benchpod,embedded,hardware-in-the-loop,mcp,swd
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Testing
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: embeddedci>=0.1
22
+ Requires-Dist: mcp[cli]>=1.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # embeddedci-mcp
28
+
29
+ An [MCP](https://modelcontextprotocol.io) server that exposes the
30
+ [`embeddedci`](https://github.com/embeddedci-com/embeddedci-python/tree/main/packages/embeddedci)
31
+ BenchPod SDK as tools, so an AI agent can drive a real hardware-in-the-loop bench:
32
+ power a target board, flash it over SWD, capture its UART, and emulate/decode an
33
+ I2C sensor.
34
+
35
+ It's a thin consumer of the SDK — every tool maps directly to a
36
+ `embeddedci.benchpod.BenchPod` method.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install embeddedci-mcp # pulls embeddedci from PyPI
42
+ # or run without installing:
43
+ uvx embeddedci-mcp --help
44
+ ```
45
+
46
+ For local development from this repo, see the
47
+ [workspace README](https://github.com/embeddedci-com/embeddedci-python#development).
48
+
49
+ ## Run
50
+
51
+ ```bash
52
+ # stdio (launched by an MCP client as a subprocess — the usual case):
53
+ embeddedci-mcp --transport stdio
54
+
55
+ # streamable HTTP (for a remote bench):
56
+ embeddedci-mcp --transport http --host 0.0.0.0 --port 8000
57
+
58
+ # preset a default connection so the `connect` tool needs no argument:
59
+ embeddedci-mcp --connection /dev/tty.usbserial-0001
60
+ embeddedci-mcp --connection 192.168.1.213 # wifi/TCP, default port 8080
61
+ ```
62
+
63
+ The connection can also come from the `BENCHPOD_CONNECTION` environment variable.
64
+
65
+ ## Client configuration
66
+
67
+ ### Claude Desktop / Cursor (`mcp.json` / `claude_desktop_config.json`)
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "benchpod": {
73
+ "command": "uvx",
74
+ "args": ["embeddedci-mcp"],
75
+ "env": { "BENCHPOD_CONNECTION": "192.168.1.213" }
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ Use `"command": "embeddedci-mcp"` instead if it's installed on `PATH`.
82
+
83
+ ### Claude Code
84
+
85
+ ```bash
86
+ claude mcp add benchpod -- uvx embeddedci-mcp
87
+ ```
88
+
89
+ ## Tools
90
+
91
+ | Group | Tools |
92
+ | --- | --- |
93
+ | Lifecycle / status | `connect`, `disconnect`, `ping`, `status` |
94
+ | Power | `power_on`, `power_off`, `target_power`, `target_status` |
95
+ | Flash | `flash` |
96
+ | UART | `capture_uart`, `power_cycle_and_capture` |
97
+ | I2C sensor | `enable_i2c_sensor`, `set_i2c_sensor`, `disable_i2c_sensor`, `i2c_sensor_status`, `i2c_sensor_regs`, `i2c_sensor_la_decoded`, `i2c_read_register` |
98
+ | Pull-ups | `enable_pullup`, `disable_pullup`, `pullup_status` |
99
+ | Low-level | `command`, `gpio_set`, `capture_adc`, `signal_generate`, `measure` |
100
+
101
+ Device/firmware failures come back as `{"ok": false, "error": ..., "error_type": ...}`
102
+ rather than raising, so the agent can reason about them.
103
+
104
+ ### Resources
105
+
106
+ - `benchpod://wiring` — the default LA channel → DUT signal pin map and eFuse table.
107
+ - `benchpod://help` — the canonical HIL workflow order.
108
+
109
+ ## Example agent flow
110
+
111
+ 1. `connect("192.168.1.213")`
112
+ 2. `flash(swclk=11, swdio=12, nreset=3, target="target/stm32f4x.cfg", file="app.elf", target_power=1)`
113
+ 3. `enable_pullup([1, 2])` then `enable_i2c_sensor(sda=2, scl=1, temperature_c=22.5, pressure_pa=101000)`
114
+ 4. `power_cycle_and_capture(rx=5, tx=4, delay=1.5, duration=6.0, until_regex="APP_OK")`
115
+ 5. `i2c_sensor_status()` / `i2c_read_register(address=0x76, register=0xD0)` to confirm the DUT probed the sensor.
116
+
117
+ ## Publishing to the MCP Registry
118
+
119
+ `server.json` is starter metadata for the
120
+ [Official MCP Registry](https://registry.modelcontextprotocol.io) (currently in
121
+ preview). Ship the package to PyPI first (that's what makes `uvx embeddedci-mcp`
122
+ work), then publish the registry entry once the name is stable:
123
+
124
+ ```bash
125
+ mcp-publisher publish # GitHub-authenticated; reads server.json
126
+ ```
@@ -0,0 +1,100 @@
1
+ # embeddedci-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that exposes the
4
+ [`embeddedci`](https://github.com/embeddedci-com/embeddedci-python/tree/main/packages/embeddedci)
5
+ BenchPod SDK as tools, so an AI agent can drive a real hardware-in-the-loop bench:
6
+ power a target board, flash it over SWD, capture its UART, and emulate/decode an
7
+ I2C sensor.
8
+
9
+ It's a thin consumer of the SDK — every tool maps directly to a
10
+ `embeddedci.benchpod.BenchPod` method.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install embeddedci-mcp # pulls embeddedci from PyPI
16
+ # or run without installing:
17
+ uvx embeddedci-mcp --help
18
+ ```
19
+
20
+ For local development from this repo, see the
21
+ [workspace README](https://github.com/embeddedci-com/embeddedci-python#development).
22
+
23
+ ## Run
24
+
25
+ ```bash
26
+ # stdio (launched by an MCP client as a subprocess — the usual case):
27
+ embeddedci-mcp --transport stdio
28
+
29
+ # streamable HTTP (for a remote bench):
30
+ embeddedci-mcp --transport http --host 0.0.0.0 --port 8000
31
+
32
+ # preset a default connection so the `connect` tool needs no argument:
33
+ embeddedci-mcp --connection /dev/tty.usbserial-0001
34
+ embeddedci-mcp --connection 192.168.1.213 # wifi/TCP, default port 8080
35
+ ```
36
+
37
+ The connection can also come from the `BENCHPOD_CONNECTION` environment variable.
38
+
39
+ ## Client configuration
40
+
41
+ ### Claude Desktop / Cursor (`mcp.json` / `claude_desktop_config.json`)
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "benchpod": {
47
+ "command": "uvx",
48
+ "args": ["embeddedci-mcp"],
49
+ "env": { "BENCHPOD_CONNECTION": "192.168.1.213" }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Use `"command": "embeddedci-mcp"` instead if it's installed on `PATH`.
56
+
57
+ ### Claude Code
58
+
59
+ ```bash
60
+ claude mcp add benchpod -- uvx embeddedci-mcp
61
+ ```
62
+
63
+ ## Tools
64
+
65
+ | Group | Tools |
66
+ | --- | --- |
67
+ | Lifecycle / status | `connect`, `disconnect`, `ping`, `status` |
68
+ | Power | `power_on`, `power_off`, `target_power`, `target_status` |
69
+ | Flash | `flash` |
70
+ | UART | `capture_uart`, `power_cycle_and_capture` |
71
+ | I2C sensor | `enable_i2c_sensor`, `set_i2c_sensor`, `disable_i2c_sensor`, `i2c_sensor_status`, `i2c_sensor_regs`, `i2c_sensor_la_decoded`, `i2c_read_register` |
72
+ | Pull-ups | `enable_pullup`, `disable_pullup`, `pullup_status` |
73
+ | Low-level | `command`, `gpio_set`, `capture_adc`, `signal_generate`, `measure` |
74
+
75
+ Device/firmware failures come back as `{"ok": false, "error": ..., "error_type": ...}`
76
+ rather than raising, so the agent can reason about them.
77
+
78
+ ### Resources
79
+
80
+ - `benchpod://wiring` — the default LA channel → DUT signal pin map and eFuse table.
81
+ - `benchpod://help` — the canonical HIL workflow order.
82
+
83
+ ## Example agent flow
84
+
85
+ 1. `connect("192.168.1.213")`
86
+ 2. `flash(swclk=11, swdio=12, nreset=3, target="target/stm32f4x.cfg", file="app.elf", target_power=1)`
87
+ 3. `enable_pullup([1, 2])` then `enable_i2c_sensor(sda=2, scl=1, temperature_c=22.5, pressure_pa=101000)`
88
+ 4. `power_cycle_and_capture(rx=5, tx=4, delay=1.5, duration=6.0, until_regex="APP_OK")`
89
+ 5. `i2c_sensor_status()` / `i2c_read_register(address=0x76, register=0xD0)` to confirm the DUT probed the sensor.
90
+
91
+ ## Publishing to the MCP Registry
92
+
93
+ `server.json` is starter metadata for the
94
+ [Official MCP Registry](https://registry.modelcontextprotocol.io) (currently in
95
+ preview). Ship the package to PyPI first (that's what makes `uvx embeddedci-mcp`
96
+ work), then publish the registry entry once the name is stable:
97
+
98
+ ```bash
99
+ mcp-publisher publish # GitHub-authenticated; reads server.json
100
+ ```
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "embeddedci-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing the EmbeddedCI BenchPod SDK as tools for AI agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "EmbeddedCI" }]
13
+ keywords = ["embedded", "hardware-in-the-loop", "mcp", "benchpod", "swd", "agent"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Testing",
24
+ ]
25
+ dependencies = [
26
+ "embeddedci>=0.1",
27
+ "mcp[cli]>=1.9",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7.0"]
32
+
33
+ [project.scripts]
34
+ embeddedci-mcp = "embeddedci_mcp.__main__:main"
35
+
36
+ [project.urls]
37
+ Homepage = "https://embeddedci.com"
38
+ Repository = "https://github.com/embeddedci-com/embeddedci-python"
39
+ Issues = "https://github.com/embeddedci-com/embeddedci-python/issues"
40
+
41
+ # Inside the workspace, resolve embeddedci from the sibling source tree (see the
42
+ # repo-root pyproject.toml [tool.uv.workspace]). On PyPI this is ignored and the
43
+ # published embeddedci is used.
44
+ [tool.uv.sources]
45
+ embeddedci = { workspace = true }
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/embeddedci_mcp"]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
@@ -0,0 +1,38 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
+ "name": "io.github.embeddedci-com/embeddedci-mcp",
4
+ "description": "Drive an EmbeddedCI BenchPod hardware-in-the-loop tester: power, flash over SWD, capture UART, and emulate/decode an I2C sensor on a real target board.",
5
+ "version": "0.1.0",
6
+ "repository": {
7
+ "url": "https://github.com/embeddedci-com/embeddedci-python",
8
+ "source": "github",
9
+ "subfolder": "packages/embeddedci-mcp"
10
+ },
11
+ "packages": [
12
+ {
13
+ "registry_type": "pypi",
14
+ "registry_base_url": "https://pypi.org",
15
+ "identifier": "embeddedci-mcp",
16
+ "version": "0.1.0",
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
20
+ "runtime_arguments": [
21
+ {
22
+ "type": "named",
23
+ "name": "--connection",
24
+ "description": "Default BenchPod connection: host[:port], a serial device path, or 'serial'.",
25
+ "is_required": false,
26
+ "format": "string"
27
+ }
28
+ ],
29
+ "environment_variables": [
30
+ {
31
+ "name": "BENCHPOD_CONNECTION",
32
+ "description": "Fallback BenchPod connection when --connection / the connect tool is not given one.",
33
+ "is_required": false
34
+ }
35
+ ]
36
+ }
37
+ ]
38
+ }
@@ -0,0 +1,15 @@
1
+ """embeddedci-mcp — an MCP server exposing the EmbeddedCI BenchPod SDK as tools."""
2
+
3
+ from .server import mcp
4
+ from .session import SESSION, Session
5
+
6
+ __all__ = ["mcp", "SESSION", "Session", "main"]
7
+
8
+ __version__ = "0.1.0"
9
+
10
+
11
+ def main(argv=None) -> None:
12
+ """Console-script entry point (see ``__main__.main``)."""
13
+ from .__main__ import main as _main
14
+
15
+ _main(argv)
@@ -0,0 +1,57 @@
1
+ """Command-line entry point for the BenchPod MCP server.
2
+
3
+ Run over stdio (the default; launched as a subprocess by an MCP client such as
4
+ Claude Desktop / Cursor / Claude Code) or as an HTTP server for a remote bench::
5
+
6
+ embeddedci-mcp --transport stdio
7
+ embeddedci-mcp --transport http --host 0.0.0.0 --port 8000 --connection serial
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from typing import Optional, Sequence
14
+
15
+ from .server import mcp
16
+ from .session import SESSION
17
+
18
+
19
+ def main(argv: Optional[Sequence[str]] = None) -> None:
20
+ parser = argparse.ArgumentParser(
21
+ prog="embeddedci-mcp",
22
+ description="MCP server exposing the EmbeddedCI BenchPod SDK as agent tools.",
23
+ )
24
+ parser.add_argument(
25
+ "--transport", choices=["stdio", "http"], default="stdio",
26
+ help="MCP transport: 'stdio' (default, local subprocess) or 'http' "
27
+ "(streamable HTTP for a remote bench).",
28
+ )
29
+ parser.add_argument(
30
+ "--host", default="127.0.0.1",
31
+ help="Bind host for --transport http (default: 127.0.0.1).",
32
+ )
33
+ parser.add_argument(
34
+ "--port", type=int, default=8000,
35
+ help="Bind port for --transport http (default: 8000).",
36
+ )
37
+ parser.add_argument(
38
+ "--connection", default=None,
39
+ help="Default BenchPod connection used by the `connect` tool when called "
40
+ "with no argument: host[:port], a serial device path, or 'serial'. "
41
+ "Falls back to the BENCHPOD_CONNECTION environment variable.",
42
+ )
43
+ args = parser.parse_args(argv)
44
+
45
+ if args.connection:
46
+ SESSION.default_connection = args.connection
47
+
48
+ if args.transport == "http":
49
+ mcp.settings.host = args.host
50
+ mcp.settings.port = args.port
51
+ mcp.run(transport="streamable-http")
52
+ else:
53
+ mcp.run(transport="stdio")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
@@ -0,0 +1,506 @@
1
+ """The MCP server: every BenchPod capability mapped to an MCP tool.
2
+
3
+ Each tool is a thin adapter — coerce JSON-friendly arguments, call the matching
4
+ :class:`~embeddedci.benchpod.BenchPod` method, and return a JSON-serializable
5
+ result. Device/firmware failures (the :class:`BenchPodError` family) are turned
6
+ into structured ``{"ok": false, "error": ..., "error_type": ...}`` results so an
7
+ agent can reason about them instead of seeing a raw traceback.
8
+
9
+ No protocol, flash, or decode logic lives here; it all comes from the
10
+ ``embeddedci`` SDK. This module only exposes it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import functools
16
+ import re
17
+ from typing import Any, Dict, List, Optional, Union
18
+
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ from embeddedci.benchpod import i2c
22
+ from embeddedci.benchpod.errors import BenchPodError
23
+
24
+ from .session import SESSION
25
+
26
+ mcp = FastMCP("embeddedci-benchpod")
27
+
28
+
29
+ # -- helpers ----------------------------------------------------------------
30
+
31
+ def _safe(fn):
32
+ """Wrap a tool so SDK errors become structured results, not tracebacks.
33
+
34
+ ``functools.wraps`` preserves the wrapped function's signature and
35
+ annotations, so FastMCP still derives the correct input schema.
36
+ """
37
+
38
+ @functools.wraps(fn)
39
+ def wrapper(*args, **kwargs):
40
+ try:
41
+ return fn(*args, **kwargs)
42
+ except BenchPodError as exc:
43
+ return {"ok": False, "error": str(exc), "error_type": type(exc).__name__}
44
+
45
+ return wrapper
46
+
47
+
48
+ def _tail(text: str, limit: int = 4000) -> str:
49
+ """Keep only the last ``limit`` chars (flash logs can be huge)."""
50
+ if not text:
51
+ return text
52
+ return text if len(text) <= limit else "…(truncated)…\n" + text[-limit:]
53
+
54
+
55
+ def _flash_result(result) -> dict:
56
+ return {
57
+ "ok": result.ok,
58
+ "returncode": result.returncode,
59
+ "target_unreachable": result.target_unreachable,
60
+ "stalled": result.stalled,
61
+ "stdout_tail": _tail(result.stdout),
62
+ "stderr_tail": _tail(result.stderr),
63
+ }
64
+
65
+
66
+ def _summarize_samples(data, head: int = 32) -> dict:
67
+ data = list(data)
68
+ out: dict = {"count": len(data), "head": data[:head], "truncated": len(data) > head}
69
+ if data:
70
+ out["min"] = min(data)
71
+ out["max"] = max(data)
72
+ return out
73
+
74
+
75
+ def _compile_until(until_regex: Optional[str]):
76
+ return re.compile(until_regex) if until_regex else None
77
+
78
+
79
+ # -- lifecycle / status -----------------------------------------------------
80
+
81
+ @mcp.tool()
82
+ @_safe
83
+ def connect(connection: Optional[str] = None) -> dict:
84
+ """Open a BenchPod connection and return its status.
85
+
86
+ ``connection`` is a host[:port] (wifi/TCP, default port 8080), a serial
87
+ device path (e.g. ``/dev/tty.usbserial-0001``), or ``"serial"`` to
88
+ auto-detect. When omitted, the server's ``--connection`` default or the
89
+ ``BENCHPOD_CONNECTION`` environment variable is used. Re-connecting closes
90
+ any previous connection first.
91
+ """
92
+ from embeddedci.benchpod.connection import resolve_connection
93
+
94
+ spec = resolve_connection(connection or SESSION.default_connection)
95
+ pod = SESSION.connect(connection)
96
+ info: dict = {
97
+ "connected": True,
98
+ "kind": spec.kind,
99
+ "target": spec.addr or spec.device or "(auto-detect)",
100
+ }
101
+ try:
102
+ info["status"] = pod.status()
103
+ except BenchPodError as exc: # connected, but status round-trip failed
104
+ info["status_error"] = str(exc)
105
+ return info
106
+
107
+
108
+ @mcp.tool()
109
+ @_safe
110
+ def disconnect() -> dict:
111
+ """Close the current BenchPod connection (safe if none is open)."""
112
+ SESSION.disconnect()
113
+ return {"connected": False}
114
+
115
+
116
+ @mcp.tool()
117
+ @_safe
118
+ def ping() -> dict:
119
+ """Confirm the pod is reachable."""
120
+ return {"ping": SESSION.require().ping()}
121
+
122
+
123
+ @mcp.tool()
124
+ @_safe
125
+ def status() -> Any:
126
+ """Return firmware/connection status (dict over TCP, text over serial)."""
127
+ return SESSION.require().status()
128
+
129
+
130
+ # -- power ------------------------------------------------------------------
131
+
132
+ @mcp.tool()
133
+ @_safe
134
+ def power_on(efuse: int = 1, delay: Optional[float] = None) -> dict:
135
+ """Power the target on via an eFuse (1 = internal 5V, 2 = external).
136
+
137
+ ``delay`` (seconds) schedules the power-on pod-side and returns immediately —
138
+ use it to power-on *during* a UART capture so the boot banner lands in-window.
139
+ """
140
+ SESSION.require().power_on(efuse, delay=delay)
141
+ return {"ok": True, "efuse": efuse, "on": True, "delay": delay}
142
+
143
+
144
+ @mcp.tool()
145
+ @_safe
146
+ def power_off(efuse: int = 1, delay: Optional[float] = None) -> dict:
147
+ """Power the target off via an eFuse (1 = internal 5V, 2 = external)."""
148
+ SESSION.require().power_off(efuse, delay=delay)
149
+ return {"ok": True, "efuse": efuse, "on": False, "delay": delay}
150
+
151
+
152
+ @mcp.tool()
153
+ @_safe
154
+ def target_power(efuse: int, on: bool, delay: Optional[float] = None) -> dict:
155
+ """Enable or disable a target-power eFuse explicitly."""
156
+ SESSION.require().target_power(efuse, on=on, delay=delay)
157
+ return {"ok": True, "efuse": efuse, "on": on, "delay": delay}
158
+
159
+
160
+ @mcp.tool()
161
+ @_safe
162
+ def target_status() -> Any:
163
+ """Read the eFuse enabled/fault/valid state (RP2350B)."""
164
+ return SESSION.require().command({"cmd": "target_status"})
165
+
166
+
167
+ # -- flash ------------------------------------------------------------------
168
+
169
+ @mcp.tool()
170
+ @_safe
171
+ def flash(
172
+ swclk: int,
173
+ swdio: int,
174
+ nreset: Optional[int] = None,
175
+ target: str = "",
176
+ file: str = "",
177
+ load_address: str = "",
178
+ target_power: Optional[int] = None,
179
+ verify: bool = True,
180
+ reset: bool = True,
181
+ connect_under_reset: Optional[bool] = None,
182
+ clear_reset_events: bool = True,
183
+ extra_configs: Optional[List[str]] = None,
184
+ extra_args: Optional[List[str]] = None,
185
+ timeout: float = 300.0,
186
+ connect_attempts: int = 5,
187
+ ) -> dict:
188
+ """Flash an SWD target via the OpenOCD remote_bitbang bridge.
189
+
190
+ ``swclk``/``swdio``/``nreset`` are LA channels (1-12). ``target`` is an
191
+ OpenOCD target cfg (e.g. ``target/stm32f4x.cfg``); ``file`` is the firmware
192
+ image. ``target_power`` (1/2) powers the target before flashing. Returns a
193
+ structured result with ``ok`` plus ``stdout_tail``/``stderr_tail`` — inspect
194
+ ``target_unreachable``/``stalled`` to diagnose failures. Over a serial
195
+ bit-bang link, set ``verify=false`` (the long verify phase is flaky there).
196
+ """
197
+ result = SESSION.require().flash(
198
+ swclk=swclk, swdio=swdio, nreset=nreset,
199
+ target=target, file=file, load_address=load_address,
200
+ target_power=target_power, verify=verify, reset=reset,
201
+ connect_under_reset=connect_under_reset,
202
+ clear_reset_events=clear_reset_events,
203
+ extra_configs=tuple(extra_configs or ()),
204
+ extra_args=tuple(extra_args or ()),
205
+ timeout=timeout, connect_attempts=connect_attempts,
206
+ check=False,
207
+ )
208
+ return _flash_result(result)
209
+
210
+
211
+ # -- UART capture -----------------------------------------------------------
212
+
213
+ @mcp.tool()
214
+ @_safe
215
+ def capture_uart(
216
+ rx: int,
217
+ tx: int,
218
+ duration: float,
219
+ baud: int = 115200,
220
+ until_regex: Optional[str] = None,
221
+ ) -> dict:
222
+ """Capture the DUT's UART output for ``duration`` seconds.
223
+
224
+ ``rx`` is the LA channel the pod samples (wire the DUT's TX here); ``tx`` is
225
+ driven (the DUT's RX). ``until_regex`` (a Python regex) stops the capture
226
+ early on first match. Returns ``{text, lines, matched}``.
227
+ """
228
+ cap = SESSION.require().capture_uart(
229
+ rx=rx, tx=tx, baud=baud, duration=duration,
230
+ until=_compile_until(until_regex),
231
+ )
232
+ return {"text": cap.text, "lines": cap.lines, "matched": cap.matched}
233
+
234
+
235
+ @mcp.tool()
236
+ @_safe
237
+ def power_cycle_and_capture(
238
+ rx: int,
239
+ tx: int,
240
+ efuse: int = 1,
241
+ delay: float = 1.0,
242
+ duration: float = 4.0,
243
+ baud: int = 115200,
244
+ until_regex: Optional[str] = None,
245
+ off_settle: float = 0.3,
246
+ ) -> dict:
247
+ """Power-cycle the target while capturing its boot output.
248
+
249
+ Powers ``efuse`` off, schedules a power-on ``delay`` seconds out (pod-side
250
+ timer), then captures UART for ``duration`` seconds so the boot banner lands
251
+ inside the window. ``duration`` should comfortably exceed ``delay``.
252
+ """
253
+ cap = SESSION.require().power_cycle_and_capture(
254
+ rx=rx, tx=tx, efuse=efuse, delay=delay, duration=duration, baud=baud,
255
+ until=_compile_until(until_regex), off_settle=off_settle,
256
+ )
257
+ return {"text": cap.text, "lines": cap.lines, "matched": cap.matched}
258
+
259
+
260
+ # -- emulated I2C sensor ----------------------------------------------------
261
+
262
+ @mcp.tool()
263
+ @_safe
264
+ def enable_i2c_sensor(
265
+ sda: int,
266
+ scl: int,
267
+ sensor: str = "bmp280",
268
+ address: int = 0x76,
269
+ temperature_c: Optional[float] = None,
270
+ pressure_pa: Optional[float] = None,
271
+ ) -> dict:
272
+ """Make the pod emulate an I2C sensor (e.g. BMP280) on ``sda``/``scl``.
273
+
274
+ The pod becomes an I2C slave the DUT's master can read. Enable pull-ups on
275
+ the SDA/SCL LA channels first (``enable_pullup``) so the open-drain bus idles
276
+ high. Optionally seed ``temperature_c``/``pressure_pa``.
277
+ """
278
+ return SESSION.require().enable_i2c_sensor(
279
+ sensor, sda=sda, scl=scl, address=address,
280
+ temperature_c=temperature_c, pressure_pa=pressure_pa,
281
+ )
282
+
283
+
284
+ @mcp.tool()
285
+ @_safe
286
+ def set_i2c_sensor(temperature_c: Optional[float] = None,
287
+ pressure_pa: Optional[float] = None) -> dict:
288
+ """Update the emulated sensor's reported values (at least one required)."""
289
+ return SESSION.require().set_i2c_sensor(
290
+ temperature_c=temperature_c, pressure_pa=pressure_pa
291
+ )
292
+
293
+
294
+ @mcp.tool()
295
+ @_safe
296
+ def disable_i2c_sensor() -> dict:
297
+ """Disarm the emulated sensor (safe if none is active)."""
298
+ SESSION.require().disable_i2c_sensor()
299
+ return {"ok": True}
300
+
301
+
302
+ @mcp.tool()
303
+ @_safe
304
+ def i2c_sensor_status() -> dict:
305
+ """Return sensor + I2C-bus activity counters (transactions, writes, …)."""
306
+ return SESSION.require().i2c_sensor_status()
307
+
308
+
309
+ @mcp.tool()
310
+ @_safe
311
+ def i2c_sensor_regs(start: int = 0, length: int = 256) -> dict:
312
+ """Read the emulated sensor's register image."""
313
+ regs = SESSION.require().i2c_sensor_regs(start, length)
314
+ return {"start": start, "length": len(regs), "bytes": regs}
315
+
316
+
317
+ @mcp.tool()
318
+ @_safe
319
+ def i2c_sensor_la_decoded(samples: int = 1024,
320
+ sample_rate_mhz: Optional[float] = None) -> dict:
321
+ """Capture the I2C bus and decode it into a human-readable trace.
322
+
323
+ Returns a one-line-per-transaction ``trace`` (e.g.
324
+ ``S 0x76W+ 0xD0+ Sr 0x76R+ 0x58- P``), the transaction count, and the set of
325
+ addresses seen — not the raw sample array.
326
+ """
327
+ txns = SESSION.require().i2c_sensor_la_decoded(samples, sample_rate_mhz)
328
+ addrs = sorted({m.address for t in txns for m in t.messages if m.address is not None})
329
+ return {
330
+ "trace": i2c.format_transactions(txns),
331
+ "transactions": len(txns),
332
+ "addresses": [f"0x{a:02X}" for a in addrs],
333
+ }
334
+
335
+
336
+ @mcp.tool()
337
+ @_safe
338
+ def i2c_read_register(
339
+ address: int,
340
+ register: int,
341
+ samples: int = 4096,
342
+ sample_rate_mhz: float = 0.5,
343
+ ) -> dict:
344
+ """Capture the I2C bus and return the bytes the DUT read from ``register``.
345
+
346
+ Decodes the live waveform and looks for a register-pointer write to
347
+ ``address`` followed by a read. Returns ``{value, addressed, trace}``;
348
+ ``value`` is ``null`` if that read pattern wasn't captured in the window.
349
+ """
350
+ txns = SESSION.require().i2c_sensor_la_decoded(samples, sample_rate_mhz)
351
+ return {
352
+ "value": i2c.read_register(txns, address, register),
353
+ "addressed": i2c.addressed(txns, address),
354
+ "trace": i2c.format_transactions(txns),
355
+ }
356
+
357
+
358
+ # -- LA pin pull-ups (LA1-8) ------------------------------------------------
359
+
360
+ @mcp.tool()
361
+ @_safe
362
+ def enable_pullup(las: List[int]) -> dict:
363
+ """Enable the fixed pull-up on one or more LA channels (LA1-8 only)."""
364
+ pod = SESSION.require()
365
+ pod.enable_pullup(*las)
366
+ return pod.pullup_status()
367
+
368
+
369
+ @mcp.tool()
370
+ @_safe
371
+ def disable_pullup(las: List[int]) -> dict:
372
+ """Disable the pull-up on one or more LA channels (LA1-8 only)."""
373
+ pod = SESSION.require()
374
+ pod.disable_pullup(*las)
375
+ return pod.pullup_status()
376
+
377
+
378
+ @mcp.tool()
379
+ @_safe
380
+ def pullup_status() -> dict:
381
+ """Return ``{"la_pullup_mask": <bitmask>}`` (bit la-1 set = pull-up on)."""
382
+ return SESSION.require().pullup_status()
383
+
384
+
385
+ # -- low-level pass-throughs (TCP / serial-json) ----------------------------
386
+
387
+ @mcp.tool()
388
+ @_safe
389
+ def command(request: Dict[str, Any]) -> Any:
390
+ """Send a raw JSON command to the pod (escape hatch).
391
+
392
+ ``request`` must include a ``cmd`` key, e.g. ``{"cmd": "status"}``. Use this
393
+ for firmware commands not covered by a dedicated tool.
394
+ """
395
+ return SESSION.require().command(request)
396
+
397
+
398
+ @mcp.tool()
399
+ @_safe
400
+ def gpio_set(la: int, state: Union[int, str]) -> Any:
401
+ """Drive an LA channel: ``state`` 1 = high, 0 = low, ``"z"`` = high-Z."""
402
+ return SESSION.require().gpio_set(la, state)
403
+
404
+
405
+ @mcp.tool()
406
+ @_safe
407
+ def capture_adc(samples: int = 256, sample_rate_mhz: Optional[float] = None) -> dict:
408
+ """Capture ADC samples; returns count/min/max and the first 32 values."""
409
+ data = SESSION.require().capture(samples, sample_rate_mhz=sample_rate_mhz)
410
+ return _summarize_samples(data)
411
+
412
+
413
+ @mcp.tool()
414
+ @_safe
415
+ def signal_generate(
416
+ waveform: str,
417
+ freq: float,
418
+ amplitude: float,
419
+ offset: float = 0.0,
420
+ duration_ms: Optional[int] = None,
421
+ sample_rate_mhz: Optional[float] = None,
422
+ ) -> Any:
423
+ """Generate a DAC waveform (sine/square/sawtooth/…) on the analog output."""
424
+ req: dict = {"cmd": "generate", "waveform": waveform,
425
+ "freq": freq, "amplitude": amplitude, "offset": offset}
426
+ if duration_ms is not None:
427
+ req["duration_ms"] = duration_ms
428
+ if sample_rate_mhz is not None:
429
+ req["sample_rate_mhz"] = sample_rate_mhz
430
+ return SESSION.require().command(req)
431
+
432
+
433
+ @mcp.tool()
434
+ @_safe
435
+ def measure(
436
+ waveform: str,
437
+ freq: float,
438
+ amplitude: float,
439
+ offset: float = 0.0,
440
+ samples: int = 256,
441
+ sample_rate_mhz: Optional[float] = None,
442
+ ) -> dict:
443
+ """Drive the DAC and capture the ADC loopback; returns a sample summary."""
444
+ pod = SESSION.require()
445
+ fn = getattr(pod.transport, "samples", None)
446
+ if fn is None:
447
+ raise BenchPodError("measure is only available on the TCP transport")
448
+ req: dict = {"cmd": "measure", "waveform": waveform, "freq": freq,
449
+ "amplitude": amplitude, "offset": offset, "samples": samples}
450
+ if sample_rate_mhz is not None:
451
+ req["sample_rate_mhz"] = sample_rate_mhz
452
+ return _summarize_samples(fn(req))
453
+
454
+
455
+ # -- resources (read-only context for the agent) ----------------------------
456
+
457
+ _WIRING = """\
458
+ BenchPod LA channel wiring (defaults from the BMP280 HIL example).
459
+ The pod's logic-analyzer channels (LA1-12) are wired to the DUT:
460
+
461
+ DUT signal Pod LA channel eFuse / notes
462
+ --------------------------------- -------------- -----------------------
463
+ SWCLK (SWD clock) LA11
464
+ SWDIO (SWD data) LA12
465
+ NRST (optional reset) LA3
466
+ UART: DUT TX -> pod samples LA5 (capture_uart rx)
467
+ UART: DUT RX <- pod drives LA4 (capture_uart tx)
468
+ I2C SDA LA2 4.7k pull-up (LA1/2)
469
+ I2C SCL LA1 4.7k pull-up (LA1/2)
470
+ Target 5V power eFuse 1 1=internal 5V, 2=external
471
+
472
+ Pull-ups exist only on LA1-8 (LA1/2=4.7k, LA3/4=2.2k, LA5-8=10k); enable them on
473
+ the I2C SDA/SCL channels so the open-drain bus idles high before arming the
474
+ emulated sensor. These are bench defaults — confirm against the actual wiring.
475
+ """
476
+
477
+ _HELP = """\
478
+ This server drives an EmbeddedCI BenchPod (hardware-in-the-loop tester) so you
479
+ can power, flash, and probe a real target board.
480
+
481
+ Typical workflow:
482
+ 1. connect(connection) — open the pod (host[:port], /dev/tty*, or 'serial')
483
+ 2. flash(swclk, swdio, nreset, — program the DUT over SWD
484
+ target, file, ...) (over serial bit-bang use verify=false)
485
+ 3. enable_pullup([sda, scl]) — for I2C work, idle the bus high
486
+ enable_i2c_sensor(sda, scl) — have the pod emulate a sensor
487
+ 4. power_cycle_and_capture(rx, tx,— reboot the DUT and capture its boot log
488
+ delay, duration, until_regex)
489
+ 5. i2c_sensor_status() / — confirm the DUT actually probed the bus
490
+ i2c_read_register(addr, reg)
491
+
492
+ Errors come back as {"ok": false, "error": ..., "error_type": ...} rather than
493
+ raising. See the benchpod://wiring resource for the LA channel pin map.
494
+ """
495
+
496
+
497
+ @mcp.resource("benchpod://wiring")
498
+ def wiring() -> str:
499
+ """Default LA channel → DUT signal pin map and eFuse table."""
500
+ return _WIRING
501
+
502
+
503
+ @mcp.resource("benchpod://help")
504
+ def help_() -> str:
505
+ """How to drive a HIL run with these tools (canonical workflow order)."""
506
+ return _HELP
@@ -0,0 +1,66 @@
1
+ """A single, process-wide BenchPod connection shared across MCP tool calls.
2
+
3
+ The MCP server process is long-lived, but each tool invocation is independent.
4
+ We hold the connected :class:`~embeddedci.benchpod.BenchPod` in one module-level
5
+ :class:`Session` so an agent can ``connect`` once and then ``flash``/``capture``/…
6
+ against the same device. Tools call :meth:`Session.require` to get the live pod
7
+ (or a clear "connect first" error).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ from embeddedci.benchpod import BenchPod
15
+ from embeddedci.benchpod.errors import BenchPodError
16
+
17
+
18
+ class NotConnectedError(BenchPodError):
19
+ """Raised when a tool needs the device but ``connect`` was never called."""
20
+
21
+
22
+ class Session:
23
+ """Holds at most one open BenchPod connection."""
24
+
25
+ def __init__(self) -> None:
26
+ self._pod: Optional[BenchPod] = None
27
+ #: Default connection used by ``connect`` when called with no argument
28
+ #: (set from the ``--connection`` CLI flag). ``None`` falls back to the
29
+ #: ``BENCHPOD_CONNECTION`` environment variable.
30
+ self.default_connection: Optional[str] = None
31
+ self.timeout: float = 30.0
32
+
33
+ @property
34
+ def connected(self) -> bool:
35
+ return self._pod is not None
36
+
37
+ def connect(self, connection: Optional[str] = None,
38
+ *, timeout: Optional[float] = None) -> BenchPod:
39
+ """Open (or re-open) the device, closing any prior connection first."""
40
+ self.disconnect()
41
+ conn = connection or self.default_connection
42
+ self._pod = BenchPod(
43
+ conn, timeout=self.timeout if timeout is None else timeout
44
+ )
45
+ return self._pod
46
+
47
+ def require(self) -> BenchPod:
48
+ """Return the live pod, or raise if not connected."""
49
+ if self._pod is None:
50
+ raise NotConnectedError(
51
+ "not connected to a BenchPod — call the `connect` tool first"
52
+ )
53
+ return self._pod
54
+
55
+ def disconnect(self) -> None:
56
+ """Close the device if one is open (idempotent)."""
57
+ if self._pod is not None:
58
+ try:
59
+ self._pod.close()
60
+ except Exception:
61
+ pass
62
+ self._pod = None
63
+
64
+
65
+ #: The one shared session for this server process.
66
+ SESSION = Session()
@@ -0,0 +1,137 @@
1
+ """Test fixtures: a fake Transport injected into a real BenchPod.
2
+
3
+ The MCP tools call ``embeddedci.benchpod.BenchPod`` methods; we exercise them
4
+ with no hardware by giving BenchPod a :class:`FakeTransport` (which implements
5
+ the Transport ABC plus the JSON ``command``/``samples`` extras) and a
6
+ :class:`FakeRawLink` for UART/SWD byte streams.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ import pytest
14
+
15
+ from embeddedci.benchpod import BenchPod
16
+ from embeddedci.benchpod.transport.base import Transport
17
+
18
+ from embeddedci_mcp.session import SESSION
19
+
20
+
21
+ class FakeRawLink:
22
+ """A bounded byte stream: yields ``data`` once, then EOF (``b""``)."""
23
+
24
+ def __init__(self, data: bytes = b"") -> None:
25
+ self._buf = bytearray(data)
26
+ self.written = bytearray()
27
+ self.closed = False
28
+
29
+ def read(self, n: int) -> bytes:
30
+ if self.closed or not self._buf:
31
+ return b""
32
+ chunk = bytes(self._buf[:n])
33
+ del self._buf[:n]
34
+ return chunk
35
+
36
+ def write(self, data: bytes) -> int:
37
+ self.written += data
38
+ return len(data)
39
+
40
+ def close(self) -> None:
41
+ self.closed = True
42
+
43
+
44
+ class FakeTransport(Transport):
45
+ """In-memory pod that answers the high-level + JSON-command surface."""
46
+
47
+ def __init__(self) -> None:
48
+ self.power: dict[int, bool] = {}
49
+ self.calls: list[tuple] = []
50
+ self.uart_data = b"boot\r\nAPP_OK\r\n"
51
+ self._sensor: dict[str, Any] = {"active": False}
52
+ self._regs = list(range(256))
53
+
54
+ # -- Transport ABC --
55
+ def status(self) -> Any:
56
+ return {"version": "fake-1.0",
57
+ "caps": ["signal", "gpio", "power", "swd", "i2c_sensor", "uart"]}
58
+
59
+ def ping(self) -> Any:
60
+ return "pong"
61
+
62
+ def target_power(self, efuse: int, on: bool, delay_ms: int = 0) -> None:
63
+ self.power[efuse] = on
64
+ self.calls.append(("target_power", efuse, on, delay_ms))
65
+
66
+ def swd_start(self, swclk: int, swdio: int, nreset: Optional[int]):
67
+ return FakeRawLink()
68
+
69
+ def uart_proxy_start(self, rx: int, tx: int, baud: int):
70
+ return FakeRawLink(self.uart_data)
71
+
72
+ def close(self) -> None:
73
+ self.calls.append(("close",))
74
+
75
+ # -- JSON command extras (TCP / serial-json) --
76
+ def command(self, req: dict) -> Any:
77
+ cmd = req.get("cmd")
78
+ self.calls.append(("command", cmd))
79
+ if cmd in ("status", "ping"):
80
+ return self.status() if cmd == "status" else "pong"
81
+ if cmd == "sensor_start":
82
+ self._sensor = {"active": True, "type": req["type"], "addr": req["addr"],
83
+ "transactions": 0}
84
+ return {"type": req["type"], "addr": req["addr"]}
85
+ if cmd == "sensor_set":
86
+ return {"type": "bmp280"}
87
+ if cmd == "sensor_stop":
88
+ self._sensor = {"active": False}
89
+ return None
90
+ if cmd == "sensor_status":
91
+ return {"active": self._sensor.get("active", False), "transactions": 3}
92
+ if cmd == "pullup":
93
+ return {"la": req["la"],
94
+ "pullup": 1 if req.get("state") == "on" else 0, "ohms": "4.7k"}
95
+ if cmd == "pullup_status":
96
+ return {"la_pullup_mask": 3}
97
+ if cmd == "target_status":
98
+ return {"efuse1": {"enabled": 1, "fault": 0, "valid": 1}}
99
+ if cmd == "gpio_set":
100
+ return {"la": req["la"], "state": req["state"]}
101
+ if cmd == "generate":
102
+ return None
103
+ return None
104
+
105
+ def samples(self, req: dict) -> list:
106
+ cmd = req.get("cmd")
107
+ self.calls.append(("samples", cmd))
108
+ if cmd == "sensor_regs":
109
+ return self._regs[: req.get("len", 256)]
110
+ if cmd == "sensor_la":
111
+ return []
112
+ if cmd == "measure":
113
+ return [10, 20, 30, 40]
114
+ if cmd == "capture":
115
+ return list(range(req.get("samples", 4)))
116
+ return []
117
+
118
+
119
+ @pytest.fixture(autouse=True)
120
+ def _reset_session():
121
+ """Each test starts with a clean, disconnected session."""
122
+ SESSION.disconnect()
123
+ SESSION.default_connection = None
124
+ yield
125
+ SESSION.disconnect()
126
+
127
+
128
+ @pytest.fixture
129
+ def fake_transport() -> FakeTransport:
130
+ return FakeTransport()
131
+
132
+
133
+ @pytest.fixture
134
+ def connected(fake_transport: FakeTransport) -> FakeTransport:
135
+ """A session connected to a BenchPod backed by the fake transport."""
136
+ SESSION._pod = BenchPod(transport=fake_transport)
137
+ return fake_transport
@@ -0,0 +1,192 @@
1
+ """Exercise the MCP tools without hardware (fake transport injected)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from embeddedci.benchpod import BenchPod
8
+ from embeddedci.benchpod.connection import ConnSpec
9
+ from embeddedci.benchpod.flash import FlashResult
10
+
11
+ import embeddedci.benchpod.connection as conn_mod
12
+ import embeddedci_mcp.session as session_mod
13
+ from embeddedci_mcp import server
14
+ from embeddedci_mcp.session import SESSION
15
+
16
+
17
+ # -- not connected ----------------------------------------------------------
18
+
19
+ def test_tool_before_connect_returns_structured_error():
20
+ result = server.ping()
21
+ assert result["ok"] is False
22
+ assert result["error_type"] == "NotConnectedError"
23
+ assert "connect" in result["error"].lower()
24
+
25
+
26
+ # -- connect ----------------------------------------------------------------
27
+
28
+ def test_connect_reports_status(monkeypatch, fake_transport):
29
+ monkeypatch.setattr(
30
+ session_mod, "BenchPod",
31
+ lambda conn, timeout=30.0: BenchPod(transport=fake_transport),
32
+ )
33
+ monkeypatch.setattr(
34
+ conn_mod, "resolve_connection",
35
+ lambda c: ConnSpec(kind="tcp", addr="1.2.3.4:8080"),
36
+ )
37
+ result = server.connect("1.2.3.4")
38
+ assert result["connected"] is True
39
+ assert result["kind"] == "tcp"
40
+ assert result["target"] == "1.2.3.4:8080"
41
+ assert result["status"]["version"] == "fake-1.0"
42
+ assert SESSION.connected
43
+
44
+
45
+ def test_disconnect(connected):
46
+ assert SESSION.connected
47
+ assert server.disconnect() == {"connected": False}
48
+ assert not SESSION.connected
49
+ # idempotent: disconnecting again is safe
50
+ assert server.disconnect() == {"connected": False}
51
+
52
+
53
+ # -- status / ping ----------------------------------------------------------
54
+
55
+ def test_ping(connected):
56
+ assert server.ping() == {"ping": "pong"}
57
+
58
+
59
+ def test_status(connected):
60
+ assert server.status()["version"] == "fake-1.0"
61
+
62
+
63
+ # -- power ------------------------------------------------------------------
64
+
65
+ def test_power_on_off(connected):
66
+ on = server.power_on(efuse=1)
67
+ assert on["ok"] is True and on["on"] is True
68
+ assert connected.power[1] is True
69
+
70
+ off = server.power_off(efuse=1)
71
+ assert off["on"] is False
72
+ assert connected.power[1] is False
73
+
74
+
75
+ def test_target_power_explicit(connected):
76
+ assert server.target_power(efuse=2, on=True, delay=0.5)["efuse"] == 2
77
+ assert connected.power[2] is True
78
+
79
+
80
+ def test_target_status(connected):
81
+ assert server.target_status()["efuse1"]["enabled"] == 1
82
+
83
+
84
+ # -- flash ------------------------------------------------------------------
85
+
86
+ def test_flash_serializes_result(connected, monkeypatch):
87
+ monkeypatch.setattr(
88
+ SESSION._pod, "flash",
89
+ lambda **kw: FlashResult(ok=True, returncode=0,
90
+ stdout="Programming Finished", stderr=""),
91
+ )
92
+ result = server.flash(swclk=11, swdio=12, nreset=3,
93
+ target="target/stm32f4x.cfg", file="fw.elf")
94
+ assert result["ok"] is True
95
+ assert result["returncode"] == 0
96
+ assert "Programming Finished" in result["stdout_tail"]
97
+ assert result["target_unreachable"] is False
98
+
99
+
100
+ # -- UART -------------------------------------------------------------------
101
+
102
+ def test_capture_uart_matches(connected):
103
+ result = server.capture_uart(rx=5, tx=4, duration=1.0, until_regex="APP_OK")
104
+ assert result["matched"] is True
105
+ assert "APP_OK" in result["text"]
106
+ assert "APP_OK" in result["lines"]
107
+
108
+
109
+ def test_power_cycle_and_capture(connected):
110
+ result = server.power_cycle_and_capture(
111
+ rx=5, tx=4, efuse=1, delay=0.0, duration=1.0, until_regex="APP_OK",
112
+ off_settle=0.0,
113
+ )
114
+ assert result["matched"] is True
115
+ # power was cycled off then scheduled back on
116
+ assert ("target_power", 1, False, 0) in connected.calls
117
+
118
+
119
+ # -- I2C sensor -------------------------------------------------------------
120
+
121
+ def test_i2c_sensor_lifecycle(connected):
122
+ started = server.enable_i2c_sensor(sda=2, scl=1, temperature_c=22.5,
123
+ pressure_pa=101000)
124
+ assert started["type"] == "bmp280"
125
+ status = server.i2c_sensor_status()
126
+ assert status["active"] is True
127
+ assert status["transactions"] == 3
128
+ assert server.disable_i2c_sensor() == {"ok": True}
129
+
130
+
131
+ def test_i2c_sensor_regs_summary(connected):
132
+ regs = server.i2c_sensor_regs(start=0, length=8)
133
+ assert regs["length"] == 8
134
+ assert regs["bytes"] == list(range(8))
135
+
136
+
137
+ # -- pull-ups ---------------------------------------------------------------
138
+
139
+ def test_pullups(connected):
140
+ assert server.enable_pullup([1, 2])["la_pullup_mask"] == 3
141
+ assert server.disable_pullup([1, 2])["la_pullup_mask"] == 3
142
+ assert server.pullup_status()["la_pullup_mask"] == 3
143
+
144
+
145
+ # -- low-level --------------------------------------------------------------
146
+
147
+ def test_command_escape_hatch(connected):
148
+ assert server.command({"cmd": "status"})["version"] == "fake-1.0"
149
+
150
+
151
+ def test_capture_adc_summary(connected):
152
+ summary = server.capture_adc(samples=4)
153
+ assert summary["count"] == 4
154
+ assert summary["head"] == [0, 1, 2, 3]
155
+ assert summary["min"] == 0 and summary["max"] == 3
156
+
157
+
158
+ def test_measure_summary(connected):
159
+ summary = server.measure(waveform="sine", freq=1000, amplitude=1.0, samples=4)
160
+ assert summary["count"] == 4
161
+ assert summary["max"] == 40
162
+
163
+
164
+ # -- tool / resource registration ------------------------------------------
165
+
166
+ def test_all_tools_registered():
167
+ tools = asyncio.run(server.mcp.list_tools())
168
+ names = {t.name for t in tools}
169
+ expected = {
170
+ "connect", "disconnect", "ping", "status",
171
+ "power_on", "power_off", "target_power", "target_status",
172
+ "flash", "capture_uart", "power_cycle_and_capture",
173
+ "enable_i2c_sensor", "set_i2c_sensor", "disable_i2c_sensor",
174
+ "i2c_sensor_status", "i2c_sensor_regs", "i2c_sensor_la_decoded",
175
+ "i2c_read_register",
176
+ "enable_pullup", "disable_pullup", "pullup_status",
177
+ "command", "gpio_set", "capture_adc", "signal_generate", "measure",
178
+ }
179
+ assert expected <= names
180
+
181
+ # the flash tool's schema exposes its key parameters
182
+ flash = next(t for t in tools if t.name == "flash")
183
+ props = flash.inputSchema["properties"]
184
+ for param in ("swclk", "swdio", "nreset", "target", "file", "verify"):
185
+ assert param in props
186
+
187
+
188
+ def test_resources_registered():
189
+ resources = asyncio.run(server.mcp.list_resources())
190
+ uris = {str(r.uri) for r in resources}
191
+ assert "benchpod://wiring" in uris
192
+ assert "benchpod://help" in uris