platformio.mcp 0.1.0__py3-none-any.whl
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.
- platformio_mcp/__init__.py +3 -0
- platformio_mcp/cli.py +132 -0
- platformio_mcp/core.py +113 -0
- platformio_mcp/monitor.py +211 -0
- platformio_mcp/parsers.py +186 -0
- platformio_mcp/pio.py +201 -0
- platformio_mcp/server.py +41 -0
- platformio_mcp/toolchain.py +326 -0
- platformio_mcp/tools/__init__.py +8 -0
- platformio_mcp/tools/analysis.py +283 -0
- platformio_mcp/tools/build.py +130 -0
- platformio_mcp/tools/devices.py +147 -0
- platformio_mcp/tools/packages.py +103 -0
- platformio_mcp/tools/project.py +194 -0
- platformio_mcp/tools/quality.py +134 -0
- platformio_mcp/tools/system.py +47 -0
- platformio_mcp-0.1.0.dist-info/METADATA +213 -0
- platformio_mcp-0.1.0.dist-info/RECORD +20 -0
- platformio_mcp-0.1.0.dist-info/WHEEL +4 -0
- platformio_mcp-0.1.0.dist-info/entry_points.txt +4 -0
platformio_mcp/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""`platformio.mcp` command line: serve (default), install, doctor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
|
|
16
|
+
CLIENTS = ("claude-code", "claude-desktop", "cursor", "codex", "windsurf", "print")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def server_command(with_platformio: bool, policy: str | None) -> dict:
|
|
20
|
+
pkg = 'platformio.mcp[platformio]' if with_platformio else "platformio.mcp"
|
|
21
|
+
entry = {"command": "uvx", "args": [pkg]}
|
|
22
|
+
if policy:
|
|
23
|
+
entry["env"] = {"PLATFORMIO_MCP_POLICY": policy}
|
|
24
|
+
return entry
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _merge_json(path: Path, key_path: list[str], entry: dict) -> None:
|
|
28
|
+
data = {}
|
|
29
|
+
if path.exists():
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
|
32
|
+
except json.JSONDecodeError as exc:
|
|
33
|
+
sys.exit(f"{path} is not valid JSON ({exc}); fix it or add the entry by hand:\n{json.dumps(entry, indent=2)}")
|
|
34
|
+
node = data
|
|
35
|
+
for key in key_path[:-1]:
|
|
36
|
+
node = node.setdefault(key, {})
|
|
37
|
+
node[key_path[-1]] = entry
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def claude_desktop_config() -> Path:
|
|
43
|
+
system = platform.system()
|
|
44
|
+
if system == "Darwin":
|
|
45
|
+
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
46
|
+
if system == "Windows":
|
|
47
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / "Claude" / "claude_desktop_config.json"
|
|
48
|
+
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def install(client: str, with_platformio: bool, policy: str | None, scope: str) -> None:
|
|
52
|
+
entry = server_command(with_platformio, policy)
|
|
53
|
+
if client == "print":
|
|
54
|
+
print(json.dumps({"mcpServers": {"platformio": entry}}, indent=2))
|
|
55
|
+
return
|
|
56
|
+
if client == "claude-code":
|
|
57
|
+
claude = shutil.which("claude")
|
|
58
|
+
if not claude:
|
|
59
|
+
sys.exit("`claude` CLI not found on PATH. Install Claude Code, or run: uvx platformio.mcp install --print")
|
|
60
|
+
cmd = [claude, "mcp", "add", "--scope", scope, "--transport", "stdio"]
|
|
61
|
+
for k, v in entry.get("env", {}).items():
|
|
62
|
+
cmd += ["--env", f"{k}={v}"]
|
|
63
|
+
cmd += ["platformio", "--", entry["command"], *entry["args"]]
|
|
64
|
+
print("$ " + " ".join(cmd))
|
|
65
|
+
subprocess.run(cmd, check=False)
|
|
66
|
+
return
|
|
67
|
+
if client == "claude-desktop":
|
|
68
|
+
path = claude_desktop_config()
|
|
69
|
+
_merge_json(path, ["mcpServers", "platformio"], entry)
|
|
70
|
+
print(f"Added 'platformio' to {path}. Restart Claude Desktop.")
|
|
71
|
+
return
|
|
72
|
+
if client == "cursor":
|
|
73
|
+
path = Path.home() / ".cursor" / "mcp.json" if scope == "user" else Path.cwd() / ".cursor" / "mcp.json"
|
|
74
|
+
_merge_json(path, ["mcpServers", "platformio"], entry)
|
|
75
|
+
print(f"Added 'platformio' to {path}. Reload Cursor's MCP settings.")
|
|
76
|
+
return
|
|
77
|
+
if client == "windsurf":
|
|
78
|
+
path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
|
|
79
|
+
_merge_json(path, ["mcpServers", "platformio"], entry)
|
|
80
|
+
print(f"Added 'platformio' to {path}. Restart Windsurf.")
|
|
81
|
+
return
|
|
82
|
+
if client == "codex":
|
|
83
|
+
path = Path.home() / ".codex" / "config.toml"
|
|
84
|
+
block = "\n[mcp_servers.platformio]\ncommand = \"uvx\"\nargs = [" + ", ".join(json.dumps(a) for a in entry["args"]) + "]\n"
|
|
85
|
+
if entry.get("env"):
|
|
86
|
+
block += "[mcp_servers.platformio.env]\n" + "".join(f'{k} = "{v}"\n' for k, v in entry["env"].items())
|
|
87
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
88
|
+
if "[mcp_servers.platformio]" in existing:
|
|
89
|
+
print(f"{path} already has [mcp_servers.platformio]; edit it by hand if needed:\n{block}")
|
|
90
|
+
return
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
path.write_text(existing.rstrip("\n") + "\n" + block, encoding="utf-8")
|
|
93
|
+
print(f"Appended [mcp_servers.platformio] to {path}. Restart Codex.")
|
|
94
|
+
return
|
|
95
|
+
sys.exit(f"unknown client {client}; choose from {', '.join(CLIENTS)}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def doctor() -> None:
|
|
99
|
+
from .tools.system import pio_system_info
|
|
100
|
+
|
|
101
|
+
print(json.dumps(pio_system_info(), indent=2))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: list[str] | None = None) -> None:
|
|
105
|
+
parser = argparse.ArgumentParser(prog="platformio.mcp", description="MCP server for PlatformIO. With no subcommand it serves over stdio.")
|
|
106
|
+
parser.add_argument("--version", action="version", version=f"platformio.mcp {__version__}")
|
|
107
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
108
|
+
sub.add_parser("serve", help="run the MCP server on stdio (default)")
|
|
109
|
+
p_install = sub.add_parser("install", help="register this server with an MCP client")
|
|
110
|
+
p_install.add_argument("--client", choices=CLIENTS, help="which client to configure")
|
|
111
|
+
for c in CLIENTS:
|
|
112
|
+
p_install.add_argument(f"--{c}", dest="client", action="store_const", const=c, help=f"shortcut for --client {c}")
|
|
113
|
+
p_install.add_argument("--with-platformio", action="store_true", help="bundle PlatformIO Core via the [platformio] extra so nothing else needs installing")
|
|
114
|
+
p_install.add_argument("--policy", choices=("full", "build_only", "read_only"), help="set PLATFORMIO_MCP_POLICY for the server")
|
|
115
|
+
p_install.add_argument("--scope", choices=("user", "project"), default="user", help="where the client stores the config (claude-code, cursor)")
|
|
116
|
+
sub.add_parser("doctor", help="print pio_system_info as JSON")
|
|
117
|
+
|
|
118
|
+
args = parser.parse_args(argv)
|
|
119
|
+
if args.cmd == "install":
|
|
120
|
+
if not args.client:
|
|
121
|
+
p_install.error("choose a client, e.g. --claude-code, --cursor, --claude-desktop, --codex, --windsurf, or --print")
|
|
122
|
+
install(args.client, args.with_platformio, args.policy, args.scope)
|
|
123
|
+
elif args.cmd == "doctor":
|
|
124
|
+
doctor()
|
|
125
|
+
else:
|
|
126
|
+
from .server import serve
|
|
127
|
+
|
|
128
|
+
serve()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
main()
|
platformio_mcp/core.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared state and helpers used by every tool module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .monitor import MonitorManager
|
|
13
|
+
from .pio import PioNotFound, PioResult, resolve_project_dir, run_pio
|
|
14
|
+
|
|
15
|
+
POLICIES = ("full", "build_only", "read_only")
|
|
16
|
+
|
|
17
|
+
monitors = MonitorManager()
|
|
18
|
+
|
|
19
|
+
ToolFn = Callable[..., dict[str, Any]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PolicyError(PermissionError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def policy() -> str:
|
|
27
|
+
value = os.environ.get("PLATFORMIO_MCP_POLICY", "full").strip().lower()
|
|
28
|
+
return value if value in POLICIES else "full"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_policy(action: str) -> None:
|
|
32
|
+
"""action is 'build' (compiles, installs packages, edits project) or 'flash' (touches hardware state)."""
|
|
33
|
+
current = policy()
|
|
34
|
+
if current == "read_only" and action in ("build", "flash"):
|
|
35
|
+
raise PolicyError(f"PLATFORMIO_MCP_POLICY=read_only blocks '{action}' actions. Only listing and inspection tools are allowed.")
|
|
36
|
+
if current == "build_only" and action == "flash":
|
|
37
|
+
raise PolicyError("PLATFORMIO_MCP_POLICY=build_only blocks flashing, erasing, and writing to devices. Builds and tests without upload still work.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def guard(fn: ToolFn) -> ToolFn:
|
|
41
|
+
"""Turn expected failures into a structured {ok: false} result instead of a protocol error."""
|
|
42
|
+
|
|
43
|
+
@functools.wraps(fn)
|
|
44
|
+
def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
45
|
+
try:
|
|
46
|
+
return fn(*args, **kwargs)
|
|
47
|
+
except PioNotFound as exc:
|
|
48
|
+
return {"ok": False, "error": "pio_not_found", "summary": str(exc)}
|
|
49
|
+
except PolicyError as exc:
|
|
50
|
+
return {"ok": False, "error": "policy_denied", "summary": str(exc), "policy": policy()}
|
|
51
|
+
except FileNotFoundError as exc:
|
|
52
|
+
return {"ok": False, "error": "not_found", "summary": str(exc)}
|
|
53
|
+
except re.error as exc:
|
|
54
|
+
return {"ok": False, "error": "bad_regex", "summary": f"invalid regular expression '{exc.pattern}': {exc.msg} (position {exc.pos})."}
|
|
55
|
+
except (KeyError, ValueError, RuntimeError, OSError) as exc:
|
|
56
|
+
return {"ok": False, "error": type(exc).__name__, "summary": str(exc)}
|
|
57
|
+
except Exception as exc: # last resort: a structured failure beats a bare "Error executing tool"
|
|
58
|
+
return {"ok": False, "error": type(exc).__name__, "summary": f"{type(exc).__name__}: {exc}"}
|
|
59
|
+
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def run_json(args: list[str], cwd: str | None = None, timeout: float | None = None, tool: str = "pio") -> tuple[PioResult, Any]:
|
|
64
|
+
"""Run a pio command that supports --json-output and parse it."""
|
|
65
|
+
res = run_pio(args, cwd=cwd, timeout=timeout, tool=tool)
|
|
66
|
+
if not res.ok:
|
|
67
|
+
raise RuntimeError(f"`pio {' '.join(args)}` failed (exit {res.returncode}). Output:\n{res.output[-2000:]}")
|
|
68
|
+
text = res.output.strip()
|
|
69
|
+
# pio sometimes prints warnings before the JSON payload; find the first bracket/brace.
|
|
70
|
+
start = min([i for i in (text.find("["), text.find("{")) if i >= 0], default=-1)
|
|
71
|
+
if start < 0:
|
|
72
|
+
raise RuntimeError(f"`pio {' '.join(args)}` returned no JSON. Output:\n{text[-1000:]}")
|
|
73
|
+
try:
|
|
74
|
+
return res, json.loads(text[start:])
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise RuntimeError(f"could not parse JSON from `pio {' '.join(args)}`: {exc}. See {res.log_path}") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def project_config(project_dir: str | None) -> dict[str, dict[str, Any]]:
|
|
80
|
+
"""`pio project config --json-output` as {section: {key: value}}."""
|
|
81
|
+
path = resolve_project_dir(project_dir)
|
|
82
|
+
_, data = run_json(["project", "config", "--json-output"], cwd=str(path), tool="project-config")
|
|
83
|
+
out: dict[str, dict[str, Any]] = {}
|
|
84
|
+
for section, items in data:
|
|
85
|
+
out[section] = {k: v for k, v in items}
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def env_names(config: dict[str, dict[str, Any]]) -> list[str]:
|
|
90
|
+
return [s[4:] for s in config if s.startswith("env:")]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def default_envs(config: dict[str, dict[str, Any]]) -> list[str]:
|
|
94
|
+
raw = config.get("platformio", {}).get("default_envs")
|
|
95
|
+
if not raw:
|
|
96
|
+
return env_names(config)
|
|
97
|
+
if isinstance(raw, str):
|
|
98
|
+
return [e.strip() for e in raw.replace("\n", ",").split(",") if e.strip()]
|
|
99
|
+
return list(raw)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def env_setting(config: dict[str, dict[str, Any]], env: str, key: str, default: Any = None) -> Any:
|
|
103
|
+
"""Look a key up in [env:x], following one level of `extends`, then [env]."""
|
|
104
|
+
section = config.get(f"env:{env}", {})
|
|
105
|
+
if key in section:
|
|
106
|
+
return section[key]
|
|
107
|
+
bases = section.get("extends") or []
|
|
108
|
+
if isinstance(bases, str):
|
|
109
|
+
bases = [b.strip() for b in bases.replace("\n", ",").split(",") if b.strip()]
|
|
110
|
+
for base in bases:
|
|
111
|
+
if key in config.get(base, {}):
|
|
112
|
+
return config[base][key]
|
|
113
|
+
return config.get("env", {}).get(key, default)
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Background serial monitor sessions with a bounded ring buffer and cursor-based reads.
|
|
2
|
+
|
|
3
|
+
`pio device monitor` refuses to run without an interactive terminal, so sessions talk to the
|
|
4
|
+
port directly with pyserial. Lines are numbered from 0; a cursor is "the next line index I have
|
|
5
|
+
not seen", so `read(cursor=0)` returns everything still in the buffer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections import deque
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ByteSource(Protocol):
|
|
20
|
+
"""What a session needs from a port: pyserial's Serial satisfies it, so do test fakes."""
|
|
21
|
+
|
|
22
|
+
def read(self, size: int = 1) -> bytes: ...
|
|
23
|
+
def write(self, data: bytes) -> int | None: ...
|
|
24
|
+
def close(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Session:
|
|
29
|
+
id: str
|
|
30
|
+
port: str
|
|
31
|
+
baud: int
|
|
32
|
+
source: ByteSource
|
|
33
|
+
max_lines: int = 5000
|
|
34
|
+
lines: deque[str] = field(init=False)
|
|
35
|
+
first_index: int = 0 # absolute index of lines[0]
|
|
36
|
+
next_index: int = 0 # absolute index the next appended line will get
|
|
37
|
+
partial: str = ""
|
|
38
|
+
error: str | None = None
|
|
39
|
+
closed: bool = False
|
|
40
|
+
started_at: float = field(default_factory=time.time)
|
|
41
|
+
bytes_received: int = 0
|
|
42
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
43
|
+
changed: threading.Condition = field(init=False)
|
|
44
|
+
thread: threading.Thread | None = None
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
self.lines = deque(maxlen=self.max_lines)
|
|
48
|
+
self.changed = threading.Condition(self.lock)
|
|
49
|
+
|
|
50
|
+
def _append(self, line: str) -> None:
|
|
51
|
+
# Must hold self.lock.
|
|
52
|
+
if len(self.lines) == self.lines.maxlen:
|
|
53
|
+
self.first_index += 1
|
|
54
|
+
self.lines.append(line)
|
|
55
|
+
self.next_index += 1
|
|
56
|
+
|
|
57
|
+
def feed(self, data: bytes) -> None:
|
|
58
|
+
text = data.decode("utf-8", errors="replace")
|
|
59
|
+
with self.changed:
|
|
60
|
+
self.bytes_received += len(data)
|
|
61
|
+
buf = self.partial + text
|
|
62
|
+
parts = buf.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
63
|
+
self.partial = parts.pop()
|
|
64
|
+
for p in parts:
|
|
65
|
+
self._append(p)
|
|
66
|
+
self.changed.notify_all()
|
|
67
|
+
|
|
68
|
+
def _reader(self) -> None:
|
|
69
|
+
try:
|
|
70
|
+
while not self.closed:
|
|
71
|
+
data = self.source.read(4096)
|
|
72
|
+
if data:
|
|
73
|
+
self.feed(data)
|
|
74
|
+
else:
|
|
75
|
+
time.sleep(0.01)
|
|
76
|
+
except Exception as exc: # port unplugged, permission lost, fake exhausted
|
|
77
|
+
with self.changed:
|
|
78
|
+
self.error = f"{type(exc).__name__}: {exc}"
|
|
79
|
+
self.closed = True
|
|
80
|
+
self.changed.notify_all()
|
|
81
|
+
|
|
82
|
+
def start(self) -> None:
|
|
83
|
+
self.thread = threading.Thread(target=self._reader, name=f"pio-monitor-{self.id}", daemon=True)
|
|
84
|
+
self.thread.start()
|
|
85
|
+
|
|
86
|
+
def read(self, cursor: int = 0, max_lines: int = 200, wait_for: str | None = None, timeout_s: float = 0) -> dict:
|
|
87
|
+
pattern = re.compile(wait_for) if wait_for else None
|
|
88
|
+
deadline = time.monotonic() + max(timeout_s, 0)
|
|
89
|
+
with self.changed:
|
|
90
|
+
while True:
|
|
91
|
+
start = max(cursor, self.first_index)
|
|
92
|
+
available = self.next_index - start
|
|
93
|
+
new = list(self.lines)[start - self.first_index : start - self.first_index + max(available, 0)] if available > 0 else []
|
|
94
|
+
matched = None
|
|
95
|
+
if pattern:
|
|
96
|
+
for i, line in enumerate(new):
|
|
97
|
+
if pattern.search(line):
|
|
98
|
+
matched = start + i
|
|
99
|
+
new = new[: i + 1]
|
|
100
|
+
break
|
|
101
|
+
if pattern and matched is None and not self.closed:
|
|
102
|
+
remaining = deadline - time.monotonic()
|
|
103
|
+
if remaining > 0:
|
|
104
|
+
self.changed.wait(timeout=remaining)
|
|
105
|
+
continue
|
|
106
|
+
if not pattern and not new and timeout_s > 0 and not self.closed:
|
|
107
|
+
remaining = deadline - time.monotonic()
|
|
108
|
+
if remaining > 0:
|
|
109
|
+
self.changed.wait(timeout=remaining)
|
|
110
|
+
continue
|
|
111
|
+
truncated = len(new) > max_lines
|
|
112
|
+
new = new[:max_lines]
|
|
113
|
+
next_cursor = start + len(new)
|
|
114
|
+
return {
|
|
115
|
+
"session_id": self.id,
|
|
116
|
+
"lines": new,
|
|
117
|
+
"cursor": next_cursor,
|
|
118
|
+
"dropped_before_cursor": max(0, self.first_index - cursor) if cursor < self.first_index else 0,
|
|
119
|
+
"more_available": truncated or next_cursor < self.next_index,
|
|
120
|
+
"matched": matched is not None,
|
|
121
|
+
"matched_line": matched,
|
|
122
|
+
"partial_line": self.partial,
|
|
123
|
+
"closed": self.closed,
|
|
124
|
+
"error": self.error,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def write(self, text: str, newline: bool = True) -> int:
|
|
128
|
+
if self.closed:
|
|
129
|
+
raise RuntimeError(f"session {self.id} is closed")
|
|
130
|
+
data = (text + ("\n" if newline else "")).encode("utf-8")
|
|
131
|
+
self.source.write(data)
|
|
132
|
+
return len(data)
|
|
133
|
+
|
|
134
|
+
def stop(self) -> None:
|
|
135
|
+
with self.changed:
|
|
136
|
+
self.closed = True
|
|
137
|
+
self.changed.notify_all()
|
|
138
|
+
try:
|
|
139
|
+
self.source.close()
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
if self.thread and self.thread is not threading.current_thread():
|
|
143
|
+
self.thread.join(timeout=2)
|
|
144
|
+
|
|
145
|
+
def info(self) -> dict:
|
|
146
|
+
with self.lock:
|
|
147
|
+
return {
|
|
148
|
+
"session_id": self.id,
|
|
149
|
+
"port": self.port,
|
|
150
|
+
"baud": self.baud,
|
|
151
|
+
"lines_buffered": len(self.lines),
|
|
152
|
+
"next_cursor": self.next_index,
|
|
153
|
+
"bytes_received": self.bytes_received,
|
|
154
|
+
"uptime_s": round(time.time() - self.started_at, 1),
|
|
155
|
+
"closed": self.closed,
|
|
156
|
+
"error": self.error,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def open_serial(port: str, baud: int) -> ByteSource:
|
|
161
|
+
import serial # pyserial
|
|
162
|
+
|
|
163
|
+
return serial.Serial(port, baudrate=baud, timeout=0.2)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class MonitorManager:
|
|
167
|
+
def __init__(self, opener=open_serial, max_lines: int = 5000) -> None:
|
|
168
|
+
self._sessions: dict[str, Session] = {}
|
|
169
|
+
self._lock = threading.Lock()
|
|
170
|
+
self._opener = opener
|
|
171
|
+
self._max_lines = max_lines
|
|
172
|
+
|
|
173
|
+
def start(self, port: str, baud: int) -> Session:
|
|
174
|
+
with self._lock:
|
|
175
|
+
for s in self._sessions.values():
|
|
176
|
+
if s.port == port and not s.closed:
|
|
177
|
+
raise RuntimeError(f"port {port} is already open in session {s.id}; stop it first")
|
|
178
|
+
source = self._opener(port, baud)
|
|
179
|
+
session = Session(id=uuid.uuid4().hex[:8], port=port, baud=baud, source=source, max_lines=self._max_lines)
|
|
180
|
+
self._sessions[session.id] = session
|
|
181
|
+
session.start()
|
|
182
|
+
return session
|
|
183
|
+
|
|
184
|
+
def get(self, session_id: str) -> Session:
|
|
185
|
+
s = self._sessions.get(session_id)
|
|
186
|
+
if s is None:
|
|
187
|
+
raise KeyError(f"unknown session {session_id}; call pio_monitor_list")
|
|
188
|
+
return s
|
|
189
|
+
|
|
190
|
+
def stop(self, session_id: str) -> dict:
|
|
191
|
+
s = self.get(session_id)
|
|
192
|
+
s.stop()
|
|
193
|
+
with self._lock:
|
|
194
|
+
self._sessions.pop(session_id, None)
|
|
195
|
+
return s.info()
|
|
196
|
+
|
|
197
|
+
def stop_all(self) -> None:
|
|
198
|
+
for sid in list(self._sessions):
|
|
199
|
+
try:
|
|
200
|
+
self.stop(sid)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
def list(self) -> list[dict]:
|
|
205
|
+
return [s.info() for s in self._sessions.values()]
|
|
206
|
+
|
|
207
|
+
def session_on_port(self, port: str) -> Session | None:
|
|
208
|
+
for s in self._sessions.values():
|
|
209
|
+
if s.port == port and not s.closed:
|
|
210
|
+
return s
|
|
211
|
+
return None
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Parsers for the parts of PlatformIO output that have no --json-output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
|
|
8
|
+
DIAG_RE = re.compile(
|
|
9
|
+
r"^(?P<file>[^\s:][^:\n]*?):(?P<line>\d+):(?:(?P<col>\d+):)?\s*(?P<kind>fatal error|error|warning|note):\s*(?P<msg>.*)$",
|
|
10
|
+
re.MULTILINE,
|
|
11
|
+
)
|
|
12
|
+
LINKER_RE = re.compile(r"^.*?(undefined reference to .*|symbol\(s\) not found.*|multiple definition of .*|region .* overflowed by .*)$", re.MULTILINE)
|
|
13
|
+
SCONS_ERR_RE = re.compile(r"^\*\*\* \[(?P<target>[^\]]+)\] (?P<msg>.*)$", re.MULTILINE)
|
|
14
|
+
MEM_RE = re.compile(r"^(?P<kind>RAM|Flash):\s+\[[=\s]*\]\s+(?P<pct>[\d.]+)%\s+\(used (?P<used>\d+) bytes from (?P<total>\d+) bytes\)", re.MULTILINE)
|
|
15
|
+
RESULT_RE = re.compile(r"^=+ \[(?P<status>SUCCESS|FAILED|ERROR)\] Took (?P<secs>[\d.]+) seconds =+$", re.MULTILINE)
|
|
16
|
+
ENV_RE = re.compile(r"^Processing (?P<env>\S+) \(", re.MULTILINE)
|
|
17
|
+
PKG_SEARCH_HEAD_RE = re.compile(r"^Found (?P<total>\d+) packages \(page (?P<page>\d+) of (?P<pages>\d+)\)", re.MULTILINE)
|
|
18
|
+
PKG_LINE_RE = re.compile(r"^(?P<indent>[\s│├└─]*)(?P<kind>Platform|Tool|Library)?\s*(?P<spec>[\w.@:/ -]+?) @ (?P<version>[^\s(]+)(?: \((?P<req>[^)]*)\))?\s*$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Diagnostic:
|
|
23
|
+
kind: str
|
|
24
|
+
file: str
|
|
25
|
+
line: int
|
|
26
|
+
column: int | None
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
return asdict(self)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_diagnostics(text: str) -> list[Diagnostic]:
|
|
34
|
+
"""Extract compiler errors/warnings/notes, linker errors, and SCons failure lines."""
|
|
35
|
+
diags: list[Diagnostic] = []
|
|
36
|
+
seen: set[tuple] = set()
|
|
37
|
+
for m in DIAG_RE.finditer(text):
|
|
38
|
+
key = (m["file"], m["line"], m["col"], m["kind"], m["msg"])
|
|
39
|
+
if key in seen:
|
|
40
|
+
continue
|
|
41
|
+
seen.add(key)
|
|
42
|
+
diags.append(Diagnostic(kind=m["kind"].replace("fatal ", ""), file=m["file"], line=int(m["line"]), column=int(m["col"]) if m["col"] else None, message=m["msg"].strip()))
|
|
43
|
+
for m in LINKER_RE.finditer(text):
|
|
44
|
+
msg = m.group(1).strip()
|
|
45
|
+
key = ("linker", msg)
|
|
46
|
+
if key in seen:
|
|
47
|
+
continue
|
|
48
|
+
seen.add(key)
|
|
49
|
+
diags.append(Diagnostic(kind="error", file="<linker>", line=0, column=None, message=msg))
|
|
50
|
+
for m in SCONS_ERR_RE.finditer(text):
|
|
51
|
+
key = ("scons", m["target"], m["msg"])
|
|
52
|
+
if key in seen:
|
|
53
|
+
continue
|
|
54
|
+
seen.add(key)
|
|
55
|
+
diags.append(Diagnostic(kind="error", file=m["target"], line=0, column=None, message=f"build step failed: {m['msg']}"))
|
|
56
|
+
return diags
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_memory(text: str) -> dict[str, dict]:
|
|
60
|
+
"""Return {'ram': {...}, 'flash': {...}} from the size report lines, if present."""
|
|
61
|
+
out: dict[str, dict] = {}
|
|
62
|
+
for m in MEM_RE.finditer(text):
|
|
63
|
+
out[m["kind"].lower()] = {"percent": float(m["pct"]), "used_bytes": int(m["used"]), "total_bytes": int(m["total"])}
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_build_result(text: str) -> dict:
|
|
68
|
+
"""Overall status, duration, and environments seen in a `pio run` log."""
|
|
69
|
+
statuses = [(m["status"], float(m["secs"])) for m in RESULT_RE.finditer(text)]
|
|
70
|
+
envs = ENV_RE.findall(text)
|
|
71
|
+
status = "unknown"
|
|
72
|
+
if statuses:
|
|
73
|
+
status = "failed" if any(s != "SUCCESS" for s, _ in statuses) else "success"
|
|
74
|
+
return {"status": status, "environments": envs, "took_s": round(sum(t for _, t in statuses), 2) if statuses else None}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_pkg_search(text: str) -> dict:
|
|
78
|
+
"""Parse the human-readable `pio pkg search` output into a list of packages."""
|
|
79
|
+
head = PKG_SEARCH_HEAD_RE.search(text)
|
|
80
|
+
packages: list[dict] = []
|
|
81
|
+
block: list[str] = []
|
|
82
|
+
|
|
83
|
+
def flush() -> None:
|
|
84
|
+
if len(block) >= 2:
|
|
85
|
+
spec = block[0].strip()
|
|
86
|
+
meta = [p.strip() for p in block[1].split("•")]
|
|
87
|
+
packages.append(
|
|
88
|
+
{
|
|
89
|
+
"spec": spec,
|
|
90
|
+
"owner": spec.split("/")[0] if "/" in spec else None,
|
|
91
|
+
"name": spec.split("/")[-1],
|
|
92
|
+
"type": meta[0].lower() if meta else None,
|
|
93
|
+
"version": meta[1] if len(meta) > 1 else None,
|
|
94
|
+
"published": meta[2].replace("Published on ", "") if len(meta) > 2 else None,
|
|
95
|
+
"description": " ".join(l.strip() for l in block[2:]).strip() or None,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
block.clear()
|
|
99
|
+
|
|
100
|
+
for line in text.split("\n"):
|
|
101
|
+
if PKG_SEARCH_HEAD_RE.match(line):
|
|
102
|
+
continue
|
|
103
|
+
if not line.strip():
|
|
104
|
+
flush()
|
|
105
|
+
continue
|
|
106
|
+
block.append(line)
|
|
107
|
+
flush()
|
|
108
|
+
return {
|
|
109
|
+
"total": int(head["total"]) if head else len(packages),
|
|
110
|
+
"page": int(head["page"]) if head else 1,
|
|
111
|
+
"pages": int(head["pages"]) if head else 1,
|
|
112
|
+
"packages": packages,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse_pkg_list(text: str) -> list[dict]:
|
|
117
|
+
"""Parse `pio pkg list` / `pio pkg outdated` tree output into flat rows."""
|
|
118
|
+
rows: list[dict] = []
|
|
119
|
+
env = None
|
|
120
|
+
for line in text.split("\n"):
|
|
121
|
+
m = re.match(r"^Resolving (?P<env>\S+) dependencies", line)
|
|
122
|
+
if m:
|
|
123
|
+
env = m["env"]
|
|
124
|
+
continue
|
|
125
|
+
m = PKG_LINE_RE.match(line)
|
|
126
|
+
if not m or not m["spec"].strip():
|
|
127
|
+
continue
|
|
128
|
+
rows.append({"env": env, "type": (m["kind"] or "Library").lower(), "spec": m["spec"].strip(), "version": m["version"], "required": (m["req"] or "").removeprefix("required: ").strip() or None})
|
|
129
|
+
return rows
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_targets(text: str) -> list[dict]:
|
|
133
|
+
"""Parse the table printed by `pio run --list-targets`."""
|
|
134
|
+
rows: list[dict] = []
|
|
135
|
+
for line in text.split("\n"):
|
|
136
|
+
if not line.strip() or line.startswith("Environment") or set(line.strip()) <= {"-", " "}:
|
|
137
|
+
continue
|
|
138
|
+
parts = re.split(r"\s{2,}", line.strip())
|
|
139
|
+
if len(parts) < 3:
|
|
140
|
+
continue
|
|
141
|
+
rows.append({"env": parts[0], "group": parts[1], "name": parts[2], "title": parts[3] if len(parts) > 3 else None, "description": parts[4] if len(parts) > 4 else None})
|
|
142
|
+
return rows
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def summarize_test_report(report: dict) -> dict:
|
|
146
|
+
"""Reduce the `pio test --json-output-path` report to what an agent needs."""
|
|
147
|
+
suites = []
|
|
148
|
+
for s in report.get("test_suites", []):
|
|
149
|
+
cases = [
|
|
150
|
+
{
|
|
151
|
+
"name": c.get("name"),
|
|
152
|
+
"status": c.get("status"),
|
|
153
|
+
"message": c.get("message") or c.get("exception"),
|
|
154
|
+
"file": (c.get("source") or {}).get("file"),
|
|
155
|
+
"line": (c.get("source") or {}).get("line"),
|
|
156
|
+
}
|
|
157
|
+
for c in s.get("test_cases", [])
|
|
158
|
+
]
|
|
159
|
+
suites.append({"env": s.get("env_name"), "test": s.get("test_name"), "status": s.get("status"), "duration_s": round(s.get("duration", 0), 2), "cases": cases})
|
|
160
|
+
return {
|
|
161
|
+
"total": report.get("testcase_nums", 0),
|
|
162
|
+
"failed": report.get("failure_nums", 0),
|
|
163
|
+
"errored": report.get("error_nums", 0),
|
|
164
|
+
"skipped": report.get("skipped_nums", 0),
|
|
165
|
+
"duration_s": round(report.get("duration", 0), 2),
|
|
166
|
+
"suites": suites,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def summarize_check_report(report: list[dict], project_dir: str | None = None) -> dict:
|
|
171
|
+
"""Group `pio check --json-output` defects by file and severity."""
|
|
172
|
+
by_severity = {"high": 0, "medium": 0, "low": 0}
|
|
173
|
+
defects = []
|
|
174
|
+
tools = []
|
|
175
|
+
for entry in report:
|
|
176
|
+
tools.append({"env": entry.get("env"), "tool": entry.get("tool"), "succeeded": entry.get("succeeded"), "duration_s": round(entry.get("duration", 0), 2)})
|
|
177
|
+
for d in entry.get("defects", []):
|
|
178
|
+
sev = d.get("severity", "low")
|
|
179
|
+
by_severity[sev] = by_severity.get(sev, 0) + 1
|
|
180
|
+
f = d.get("file") or ""
|
|
181
|
+
if project_dir and f.startswith(project_dir):
|
|
182
|
+
f = f[len(project_dir) :].lstrip("/\\")
|
|
183
|
+
defects.append({"severity": sev, "category": d.get("category"), "id": d.get("id"), "message": d.get("message"), "file": f, "line": d.get("line"), "column": d.get("column"), "cwe": d.get("cwe"), "tool": entry.get("tool"), "env": entry.get("env")})
|
|
184
|
+
order = {"high": 0, "medium": 1, "low": 2}
|
|
185
|
+
defects.sort(key=lambda d: (order.get(d["severity"], 3), d["file"], d["line"] or 0))
|
|
186
|
+
return {"defect_count": len(defects), "by_severity": by_severity, "tools": tools, "defects": defects}
|