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.
- embeddedci_mcp-0.1.0/.gitignore +13 -0
- embeddedci_mcp-0.1.0/PKG-INFO +126 -0
- embeddedci_mcp-0.1.0/README.md +100 -0
- embeddedci_mcp-0.1.0/pyproject.toml +51 -0
- embeddedci_mcp-0.1.0/server.json +38 -0
- embeddedci_mcp-0.1.0/src/embeddedci_mcp/__init__.py +15 -0
- embeddedci_mcp-0.1.0/src/embeddedci_mcp/__main__.py +57 -0
- embeddedci_mcp-0.1.0/src/embeddedci_mcp/server.py +506 -0
- embeddedci_mcp-0.1.0/src/embeddedci_mcp/session.py +66 -0
- embeddedci_mcp-0.1.0/tests/conftest.py +137 -0
- embeddedci_mcp-0.1.0/tests/test_tools.py +192 -0
|
@@ -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
|