enginehall 2.0.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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- pcba/simulation.py +143 -0
pcba/mcp/protocol.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The MCP protocol both engine servers speak: newline-delimited JSON-RPC 2.0 over stdio.
|
|
2
|
+
|
|
3
|
+
No SDK, so the protocol is ours to get right; it lives here once instead of in each server.
|
|
4
|
+
|
|
5
|
+
Dual-era, as the 2026-07-28 revision allows:
|
|
6
|
+
|
|
7
|
+
- **Modern** clients (Claude Code since 2.1.29x) probe with `server/discover` and send the
|
|
8
|
+
protocol version and client capabilities in every request's `_meta`. They get stateless
|
|
9
|
+
answers carrying `resultType` and the server's identity in `_meta`.
|
|
10
|
+
- **Legacy** clients (`initialize` handshake, 2025-11-25 and earlier) are still most other
|
|
11
|
+
hosts. `initialize` negotiates one of the legacy versions for the life of the process.
|
|
12
|
+
|
|
13
|
+
A tool is `name -> (function, spec)`. The spec holds the MCP tool fields (`title`,
|
|
14
|
+
`description`, `inputSchema`, `annotations`); the server keeps a reference to the caller's
|
|
15
|
+
dict, so a registry patched at runtime is what gets served.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import traceback
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Callable, Mapping
|
|
25
|
+
|
|
26
|
+
MODERN_VERSIONS = ("2026-07-28",)
|
|
27
|
+
# Answered by `initialize`; the first one is offered when a client asks for a version we lack.
|
|
28
|
+
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
29
|
+
|
|
30
|
+
META_VERSION = "io.modelcontextprotocol/protocolVersion"
|
|
31
|
+
META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
|
|
32
|
+
|
|
33
|
+
PARSE_ERROR = -32700
|
|
34
|
+
INVALID_REQUEST = -32600
|
|
35
|
+
METHOD_NOT_FOUND = -32601
|
|
36
|
+
INVALID_PARAMS = -32602
|
|
37
|
+
INTERNAL_ERROR = -32603
|
|
38
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
39
|
+
|
|
40
|
+
# The tool list is fixed for the life of a server process.
|
|
41
|
+
LIST_TTL_MS = 3_600_000
|
|
42
|
+
|
|
43
|
+
Tools = Mapping[str, "tuple[Callable[..., Any], dict]"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def result(request_id: Any, payload: dict) -> dict:
|
|
47
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": payload}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def error(request_id: Any, code: int, message: str, data: Any = None) -> dict:
|
|
51
|
+
body: dict[str, Any] = {"code": code, "message": message}
|
|
52
|
+
if data is not None:
|
|
53
|
+
body["data"] = data
|
|
54
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": body}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_error_payload(payload: Any) -> bool:
|
|
58
|
+
"""A tool that answers `{"error": ...}` and nothing else has failed, whether it raised or
|
|
59
|
+
returned that payload: a client reading only `isError` must not take a miss for success."""
|
|
60
|
+
return isinstance(payload, dict) and set(payload) == {"error"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def tool_annotations(*, read_only: bool, destructive: bool = False, idempotent: bool = False) -> dict:
|
|
64
|
+
"""MCP behaviour hints for a local tool (never an open world).
|
|
65
|
+
|
|
66
|
+
Hints are advisory for clients; the server enforces its own rules regardless. The tool's
|
|
67
|
+
`title` is copied in when the list is served.
|
|
68
|
+
"""
|
|
69
|
+
if read_only:
|
|
70
|
+
return {"readOnlyHint": True, "openWorldHint": False}
|
|
71
|
+
return {
|
|
72
|
+
"readOnlyHint": False,
|
|
73
|
+
"destructiveHint": destructive,
|
|
74
|
+
"idempotentHint": idempotent,
|
|
75
|
+
"openWorldHint": False,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
READ_ONLY = tool_annotations(read_only=True)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _listed(name: str, spec: dict) -> dict:
|
|
83
|
+
tool = {"name": name, **spec}
|
|
84
|
+
if "annotations" in spec:
|
|
85
|
+
tool["annotations"] = {"title": spec["title"], **spec["annotations"]}
|
|
86
|
+
return tool
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Server:
|
|
91
|
+
name: str
|
|
92
|
+
title: str
|
|
93
|
+
version: str
|
|
94
|
+
description: str
|
|
95
|
+
instructions: str
|
|
96
|
+
tools: Tools
|
|
97
|
+
# Exceptions a tool raises for bad input or an unavailable resource. They become a tool
|
|
98
|
+
# result with `isError`, so the agent can correct itself; anything else is a bug and
|
|
99
|
+
# becomes a JSON-RPC internal error.
|
|
100
|
+
in_band: tuple[type[BaseException], ...] = (OSError, TypeError, ValueError)
|
|
101
|
+
describe_error: Callable[[BaseException], Any] = str
|
|
102
|
+
website_url: str = "https://enginehall.com"
|
|
103
|
+
# Optional server-specific semantics and attachments; the structured payload stays intact.
|
|
104
|
+
tool_is_error: Callable[[str, Any], bool] | None = None
|
|
105
|
+
tool_content: Callable[[str, Any], list[dict]] | None = None
|
|
106
|
+
|
|
107
|
+
# --- identity --------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def info(self) -> dict:
|
|
110
|
+
return {
|
|
111
|
+
"name": self.name,
|
|
112
|
+
"title": self.title,
|
|
113
|
+
"version": self.version,
|
|
114
|
+
"description": self.description,
|
|
115
|
+
"websiteUrl": self.website_url,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def _complete(self, request_id: Any, payload: dict, modern: bool) -> dict:
|
|
119
|
+
payload = {"resultType": "complete", **payload}
|
|
120
|
+
if modern:
|
|
121
|
+
payload["_meta"] = {META_SERVER_INFO: self.info()}
|
|
122
|
+
return result(request_id, payload)
|
|
123
|
+
|
|
124
|
+
# --- dispatch --------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def handle(self, message: Any) -> dict | None:
|
|
127
|
+
if not isinstance(message, dict):
|
|
128
|
+
return error(None, INVALID_REQUEST, "invalid request: expected a JSON object")
|
|
129
|
+
method = message.get("method")
|
|
130
|
+
request_id = message.get("id")
|
|
131
|
+
params = message.get("params")
|
|
132
|
+
if "id" not in message:
|
|
133
|
+
return None # A notification: never answered, whatever its method.
|
|
134
|
+
params = {} if params is None else params
|
|
135
|
+
if not isinstance(params, dict):
|
|
136
|
+
return error(request_id, INVALID_PARAMS, "invalid params: expected a JSON object")
|
|
137
|
+
|
|
138
|
+
meta = params.get("_meta")
|
|
139
|
+
version = meta.get(META_VERSION) if isinstance(meta, dict) else None
|
|
140
|
+
modern = version is not None
|
|
141
|
+
if modern and version not in MODERN_VERSIONS:
|
|
142
|
+
return error(
|
|
143
|
+
request_id,
|
|
144
|
+
UNSUPPORTED_PROTOCOL_VERSION,
|
|
145
|
+
"Unsupported protocol version",
|
|
146
|
+
{"supported": list(MODERN_VERSIONS), "requested": version},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if method == "server/discover":
|
|
150
|
+
return self._complete(request_id, {
|
|
151
|
+
"supportedVersions": list(MODERN_VERSIONS),
|
|
152
|
+
"capabilities": {"tools": {}},
|
|
153
|
+
"instructions": self.instructions,
|
|
154
|
+
"ttlMs": LIST_TTL_MS,
|
|
155
|
+
"cacheScope": "public",
|
|
156
|
+
}, modern=True)
|
|
157
|
+
|
|
158
|
+
if method == "initialize":
|
|
159
|
+
wanted = params.get("protocolVersion")
|
|
160
|
+
return result(request_id, {
|
|
161
|
+
"protocolVersion": wanted if wanted in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
|
|
162
|
+
"capabilities": {"tools": {}},
|
|
163
|
+
"serverInfo": self.info(),
|
|
164
|
+
"instructions": self.instructions,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
if method == "ping": # Legacy only; removed in 2026-07-28, harmless to keep.
|
|
168
|
+
return result(request_id, {})
|
|
169
|
+
|
|
170
|
+
if method == "tools/list":
|
|
171
|
+
return self._complete(request_id, {
|
|
172
|
+
"tools": [_listed(name, spec) for name, (_fn, spec) in self.tools.items()],
|
|
173
|
+
"ttlMs": LIST_TTL_MS,
|
|
174
|
+
"cacheScope": "public",
|
|
175
|
+
}, modern)
|
|
176
|
+
|
|
177
|
+
if method == "tools/call":
|
|
178
|
+
name = params.get("name")
|
|
179
|
+
if not isinstance(name, str) or name not in self.tools:
|
|
180
|
+
return error(request_id, INVALID_PARAMS, f"unknown tool: {name}")
|
|
181
|
+
arguments = params.get("arguments")
|
|
182
|
+
arguments = {} if arguments is None else arguments
|
|
183
|
+
if not isinstance(arguments, dict):
|
|
184
|
+
return error(request_id, INVALID_PARAMS, "tool arguments must be a JSON object")
|
|
185
|
+
function, _spec = self.tools[name]
|
|
186
|
+
try:
|
|
187
|
+
payload = function(**arguments)
|
|
188
|
+
is_error = (self.tool_is_error(name, payload) if self.tool_is_error else
|
|
189
|
+
is_error_payload(payload))
|
|
190
|
+
except self.in_band as exc:
|
|
191
|
+
payload, is_error = {"error": self.describe_error(exc)}, True
|
|
192
|
+
content = [{"type": "text", "text": json.dumps(payload, indent=2)}]
|
|
193
|
+
if self.tool_content:
|
|
194
|
+
content.extend(self.tool_content(name, payload))
|
|
195
|
+
return self._complete(request_id, {
|
|
196
|
+
"content": content,
|
|
197
|
+
"structuredContent": payload,
|
|
198
|
+
"isError": is_error,
|
|
199
|
+
}, modern)
|
|
200
|
+
|
|
201
|
+
return error(request_id, METHOD_NOT_FOUND, f"method not found: {method}")
|
|
202
|
+
|
|
203
|
+
def serve(self, stdin=None, stdout=None) -> None:
|
|
204
|
+
"""Run the stdio loop until EOF."""
|
|
205
|
+
stdin = stdin or sys.stdin
|
|
206
|
+
stdout = stdout or sys.stdout
|
|
207
|
+
for line in stdin:
|
|
208
|
+
line = line.strip()
|
|
209
|
+
if not line:
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
message = json.loads(line)
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
response = error(None, PARSE_ERROR, "parse error")
|
|
215
|
+
else:
|
|
216
|
+
try:
|
|
217
|
+
response = self.handle(message)
|
|
218
|
+
except Exception: # noqa: BLE001 - a crashed tool must not kill the server
|
|
219
|
+
traceback.print_exc(file=sys.stderr)
|
|
220
|
+
request_id = message.get("id") if isinstance(message, dict) else None
|
|
221
|
+
response = error(request_id, INTERNAL_ERROR, "internal error")
|
|
222
|
+
if response is not None:
|
|
223
|
+
stdout.write(json.dumps(response) + "\n")
|
|
224
|
+
stdout.flush()
|
pcba/mcp/server.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""MCP server — the same engine the CLI drives, exposed to agents.
|
|
2
|
+
|
|
3
|
+
Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio is small enough to implement
|
|
4
|
+
directly, and `pip install enginehall` then gives an agent integration with no extras and no
|
|
5
|
+
version-skew against an SDK. The protocol itself lives in `pcba.mcp.protocol`, shared with
|
|
6
|
+
`enginehall altium mcp`.
|
|
7
|
+
|
|
8
|
+
Six tools, not twenty. Each one has to earn its place by being something an agent genuinely
|
|
9
|
+
cannot do by reading files itself — which is exactly the tools that parse a binary container
|
|
10
|
+
or query ST's database. Tool surface is a maintenance liability, so it grows on demand.
|
|
11
|
+
|
|
12
|
+
Every tool returns structured JSON, not prose. The model's job is to explain findings, never
|
|
13
|
+
to detect them.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
from pcba import __version__, review
|
|
23
|
+
from pcba.changes import ChangeSetError
|
|
24
|
+
from pcba.mcp.protocol import READ_ONLY, Server
|
|
25
|
+
from pcba.parsers import altium
|
|
26
|
+
from pcba.parsers import ioc as ioc_parser
|
|
27
|
+
from pcba.parsers.altium.records import SchDocError
|
|
28
|
+
from pcba.parsers.ioc import IocError
|
|
29
|
+
|
|
30
|
+
# Findings are unbounded in principle; a runaway review must not blow up an agent's context.
|
|
31
|
+
MAX_FINDINGS = 100
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _project(
|
|
35
|
+
path: str,
|
|
36
|
+
part: str | None = None,
|
|
37
|
+
ioc: str | None = None,
|
|
38
|
+
target: str | None = None,
|
|
39
|
+
schematic_component: str | None = None,
|
|
40
|
+
schematic_dir: str | None = None,
|
|
41
|
+
kicad_project: str | None = None,
|
|
42
|
+
):
|
|
43
|
+
return review.load_project(
|
|
44
|
+
path, part=part, ioc=ioc, target=target, schematic_component=schematic_component,
|
|
45
|
+
schematic_dir=schematic_dir, kicad_project=kicad_project, tools=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- tools -----------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def tool_project_inspect(
|
|
53
|
+
path: str = ".",
|
|
54
|
+
part: str | None = None,
|
|
55
|
+
target: str | None = None,
|
|
56
|
+
schematic_component: str | None = None,
|
|
57
|
+
ioc: str | None = None,
|
|
58
|
+
schematic_dir: str | None = None,
|
|
59
|
+
kicad_project: str | None = None,
|
|
60
|
+
) -> dict:
|
|
61
|
+
found, project = _project(
|
|
62
|
+
path, part, ioc=ioc, target=target, schematic_component=schematic_component,
|
|
63
|
+
schematic_dir=schematic_dir, kicad_project=kicad_project,
|
|
64
|
+
)
|
|
65
|
+
return review.summary(found, project)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def tool_review_run(
|
|
69
|
+
path: str = ".",
|
|
70
|
+
part: str | None = None,
|
|
71
|
+
ioc: str | None = None,
|
|
72
|
+
baseline: str | None = None,
|
|
73
|
+
changed_only: str | None = None,
|
|
74
|
+
target: str | None = None,
|
|
75
|
+
schematic_component: str | None = None,
|
|
76
|
+
required_rules: list[str] | None = None,
|
|
77
|
+
schematic_dir: str | None = None,
|
|
78
|
+
kicad_project: str | None = None,
|
|
79
|
+
) -> dict:
|
|
80
|
+
result = review.run(
|
|
81
|
+
path,
|
|
82
|
+
part=part,
|
|
83
|
+
ioc=ioc,
|
|
84
|
+
schematic_dir=schematic_dir,
|
|
85
|
+
kicad_project=kicad_project,
|
|
86
|
+
baseline=baseline,
|
|
87
|
+
changed_only=changed_only,
|
|
88
|
+
target=target,
|
|
89
|
+
schematic_component=schematic_component,
|
|
90
|
+
required_rules=tuple(required_rules or ()),
|
|
91
|
+
)
|
|
92
|
+
payload = json.loads(result.render("json", findings=result.findings[:MAX_FINDINGS]))
|
|
93
|
+
if len(result.findings) > MAX_FINDINGS:
|
|
94
|
+
payload["truncated"] = {
|
|
95
|
+
"shown": MAX_FINDINGS,
|
|
96
|
+
"total": len(result.findings),
|
|
97
|
+
"note": "Use pcba_review_get_finding for specifics, or run `enginehall review` locally.",
|
|
98
|
+
}
|
|
99
|
+
return payload
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def tool_review_get_finding(
|
|
103
|
+
finding_id: str,
|
|
104
|
+
path: str = ".",
|
|
105
|
+
part: str | None = None,
|
|
106
|
+
ioc: str | None = None,
|
|
107
|
+
target: str | None = None,
|
|
108
|
+
schematic_component: str | None = None,
|
|
109
|
+
schematic_dir: str | None = None,
|
|
110
|
+
kicad_project: str | None = None,
|
|
111
|
+
) -> dict:
|
|
112
|
+
result = review.run(
|
|
113
|
+
path, part=part, ioc=ioc, target=target, schematic_component=schematic_component,
|
|
114
|
+
schematic_dir=schematic_dir, kicad_project=kicad_project,
|
|
115
|
+
)
|
|
116
|
+
for finding in result.findings:
|
|
117
|
+
if finding.id == finding_id:
|
|
118
|
+
return finding.to_dict()
|
|
119
|
+
return {"error": f"no finding with id {finding_id!r} in the current review"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def tool_altium_pinmap(
|
|
123
|
+
dir: str, part: str | None = None, schematic_component: str | None = None
|
|
124
|
+
) -> dict:
|
|
125
|
+
directory = Path(dir)
|
|
126
|
+
resolved = part or altium.detect_part(directory)
|
|
127
|
+
if not resolved:
|
|
128
|
+
return {"error": f"could not detect an MCU part under {directory}; pass `part`"}
|
|
129
|
+
return altium.to_dict(altium.extract(directory, resolved, component=schematic_component))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def tool_cubemx_inspect(ioc: str) -> dict:
|
|
133
|
+
values = ioc_parser.parse_ioc(Path(ioc))
|
|
134
|
+
return {
|
|
135
|
+
"part": ioc_parser.part_name(values),
|
|
136
|
+
"pins": ioc_parser.collect_pins(values),
|
|
137
|
+
"peripherals": ioc_parser.peripherals(values),
|
|
138
|
+
"clocks": ioc_parser.clocks(values),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def tool_cubemx_validate(ioc: str, manifest: str) -> dict:
|
|
143
|
+
values = ioc_parser.parse_ioc(Path(ioc))
|
|
144
|
+
errors = ioc_parser.validate(values, ioc_parser.load_manifest(Path(manifest)))
|
|
145
|
+
return {"passed": not errors, "errors": errors}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
TOOLS: dict[str, tuple[Callable, dict]] = {
|
|
149
|
+
"pcba_project_inspect": (
|
|
150
|
+
tool_project_inspect,
|
|
151
|
+
{
|
|
152
|
+
"title": "Inspect project",
|
|
153
|
+
"annotations": READ_ONLY,
|
|
154
|
+
"description": (
|
|
155
|
+
"Identify an embedded project's artifacts: schematic directory or KiCad project, "
|
|
156
|
+
"MCU configuration, firmware trees, MCU part. Also reports which review rules "
|
|
157
|
+
"can and cannot run. Call this first."
|
|
158
|
+
),
|
|
159
|
+
"inputSchema": {
|
|
160
|
+
"type": "object",
|
|
161
|
+
"properties": {
|
|
162
|
+
"path": {"type": "string", "description": "Project root (default: .)"},
|
|
163
|
+
"part": {"type": "string", "description": "MCU part; default auto-detect"},
|
|
164
|
+
"target": {"type": "string", "description": "Configured target name"},
|
|
165
|
+
"schematic_component": {"type": "string", "description": "MCU designator when several MCUs exist"},
|
|
166
|
+
"ioc": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "Path to a specific .ioc; relative to the project root",
|
|
169
|
+
},
|
|
170
|
+
"schematic_dir": {
|
|
171
|
+
"type": "string",
|
|
172
|
+
"description": "Directory of .SchDoc sheets; relative to the project root",
|
|
173
|
+
},
|
|
174
|
+
"kicad_project": {
|
|
175
|
+
"type": "string",
|
|
176
|
+
"description": "KiCad project (.kicad_pro or its folder); relative to the project root",
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
),
|
|
182
|
+
"pcba_review_run": (
|
|
183
|
+
tool_review_run,
|
|
184
|
+
{
|
|
185
|
+
"title": "Run review",
|
|
186
|
+
"annotations": READ_ONLY,
|
|
187
|
+
"description": (
|
|
188
|
+
"Run every rule and return findings where the schematic, the MCU "
|
|
189
|
+
"configuration and the firmware disagree, plus the KiCad schematic, component, "
|
|
190
|
+
"board and simulation checks on a KiCad project. Each finding carries evidence, "
|
|
191
|
+
"a location, a rule id and how it was detected. Check `coverage.rules` and "
|
|
192
|
+
"`coverage.skipped` — an empty findings list does not mean everything was checked."
|
|
193
|
+
),
|
|
194
|
+
"inputSchema": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"path": {"type": "string", "description": "Project root (default: .)"},
|
|
198
|
+
"part": {"type": "string", "description": "MCU part; default auto-detect"},
|
|
199
|
+
"ioc": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"description": "Path to a specific .ioc; relative to the project root",
|
|
202
|
+
},
|
|
203
|
+
"schematic_dir": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"description": "Directory of .SchDoc sheets; relative to the project root",
|
|
206
|
+
},
|
|
207
|
+
"kicad_project": {
|
|
208
|
+
"type": "string",
|
|
209
|
+
"description": "KiCad project (.kicad_pro or its folder); relative to the project root",
|
|
210
|
+
},
|
|
211
|
+
"baseline": {
|
|
212
|
+
"type": "string",
|
|
213
|
+
"description": "Versioned finding-suppression file",
|
|
214
|
+
},
|
|
215
|
+
"changed_only": {
|
|
216
|
+
"type": "string",
|
|
217
|
+
"description": "Git ref; return findings touching changed files only",
|
|
218
|
+
},
|
|
219
|
+
"target": {"type": "string", "description": "Configured target name"},
|
|
220
|
+
"schematic_component": {"type": "string", "description": "MCU designator when several MCUs exist"},
|
|
221
|
+
"required_rules": {"type": "array", "items": {"type": "string"}},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
),
|
|
226
|
+
"pcba_review_get_finding": (
|
|
227
|
+
tool_review_get_finding,
|
|
228
|
+
{
|
|
229
|
+
"title": "Get finding",
|
|
230
|
+
"annotations": READ_ONLY,
|
|
231
|
+
"description": "Full detail for one finding id from pcba_review_run.",
|
|
232
|
+
"inputSchema": {
|
|
233
|
+
"type": "object",
|
|
234
|
+
"properties": {
|
|
235
|
+
"finding_id": {"type": "string"},
|
|
236
|
+
"path": {"type": "string"},
|
|
237
|
+
"part": {"type": "string"},
|
|
238
|
+
"ioc": {"type": "string", "description": "Same explicit .ioc as the review"},
|
|
239
|
+
"schematic_dir": {"type": "string", "description": "Same schematic directory as the review"},
|
|
240
|
+
"kicad_project": {"type": "string", "description": "Same KiCad project as the review"},
|
|
241
|
+
"target": {"type": "string", "description": "Same target as the review"},
|
|
242
|
+
"schematic_component": {"type": "string", "description": "MCU designator used by the review"},
|
|
243
|
+
},
|
|
244
|
+
"required": ["finding_id"],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
),
|
|
248
|
+
"pcba_altium_pinmap": (
|
|
249
|
+
tool_altium_pinmap,
|
|
250
|
+
{
|
|
251
|
+
"title": "Altium MCU pin map",
|
|
252
|
+
"annotations": READ_ONLY,
|
|
253
|
+
"description": (
|
|
254
|
+
"Extract an MCU pin -> net map from binary Altium .SchDoc sheets, resolved "
|
|
255
|
+
"from wire geometry rather than net-label text. Pins are keyed by package pin "
|
|
256
|
+
"(pad), each with its port pin and name. Also reports mislabelled "
|
|
257
|
+
"nets and suspected open circuits. Use this instead of trying to read a "
|
|
258
|
+
".SchDoc directly — it is an OLE2 binary container."
|
|
259
|
+
),
|
|
260
|
+
"inputSchema": {
|
|
261
|
+
"type": "object",
|
|
262
|
+
"properties": {
|
|
263
|
+
"dir": {"type": "string", "description": "Directory of .SchDoc sheets"},
|
|
264
|
+
"part": {"type": "string", "description": "MCU part; default auto-detect"},
|
|
265
|
+
"schematic_component": {"type": "string", "description": "MCU designator when several MCUs exist"},
|
|
266
|
+
},
|
|
267
|
+
"required": ["dir"],
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
),
|
|
271
|
+
"pcba_cubemx_inspect": (
|
|
272
|
+
tool_cubemx_inspect,
|
|
273
|
+
{
|
|
274
|
+
"title": "Inspect CubeMX .ioc",
|
|
275
|
+
"annotations": READ_ONLY,
|
|
276
|
+
"description": (
|
|
277
|
+
"Read an STM32CubeMX .ioc: pins, signals, labels, enabled peripherals and "
|
|
278
|
+
"clock-tree values."
|
|
279
|
+
),
|
|
280
|
+
"inputSchema": {
|
|
281
|
+
"type": "object",
|
|
282
|
+
"properties": {"ioc": {"type": "string"}},
|
|
283
|
+
"required": ["ioc"],
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
),
|
|
287
|
+
"pcba_cubemx_validate": (
|
|
288
|
+
tool_cubemx_validate,
|
|
289
|
+
{
|
|
290
|
+
"title": "Validate CubeMX .ioc",
|
|
291
|
+
"annotations": READ_ONLY,
|
|
292
|
+
"description": (
|
|
293
|
+
"Check an .ioc against a manifest of values a regeneration must not change "
|
|
294
|
+
"(expected keys, peripherals, GPIO labels, CAN bit timing)."
|
|
295
|
+
),
|
|
296
|
+
"inputSchema": {
|
|
297
|
+
"type": "object",
|
|
298
|
+
"properties": {"ioc": {"type": "string"}, "manifest": {"type": "string"}},
|
|
299
|
+
"required": ["ioc", "manifest"],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# --- server ----------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
SERVER = Server(
|
|
309
|
+
name="enginehall",
|
|
310
|
+
title="EngineHall review",
|
|
311
|
+
version=__version__,
|
|
312
|
+
description=(
|
|
313
|
+
"Checks that an Altium schematic, an STM32CubeMX configuration and firmware agree. "
|
|
314
|
+
"Local and read-only."
|
|
315
|
+
),
|
|
316
|
+
instructions=(
|
|
317
|
+
"Start with pcba_project_inspect: it finds the schematic, the .ioc, the firmware roots "
|
|
318
|
+
"and the MCU, and says which rules can run. pcba_review_run returns deterministic "
|
|
319
|
+
"findings with evidence and a location; explain and prioritize them, never invent "
|
|
320
|
+
"findings of your own. An empty findings list means nothing only together with "
|
|
321
|
+
"coverage: read coverage.skipped and the uncompared signals before saying a project "
|
|
322
|
+
"is clean, and never call a review a sign-off. Read binary .SchDoc files through "
|
|
323
|
+
"pcba_altium_pinmap, not as text."
|
|
324
|
+
),
|
|
325
|
+
tools=TOOLS,
|
|
326
|
+
# Tool failures are reported in-band so the agent can react, rather than as a protocol error
|
|
327
|
+
# that just kills the call. Configuration and baseline errors are ValueErrors.
|
|
328
|
+
in_band=(IocError, SchDocError, ChangeSetError, OSError, TypeError, ValueError),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
handle = SERVER.handle
|
|
332
|
+
serve = SERVER.serve
|
pcba/model/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The normalized model and the finding schema."""
|
|
2
|
+
|
|
3
|
+
from pcba.model.finding import Detector, Domain, Evidence, Finding, Location, Severity
|
|
4
|
+
from pcba.model.pinmap import (
|
|
5
|
+
FirmwareIndex,
|
|
6
|
+
FirmwareReference,
|
|
7
|
+
McuConfig,
|
|
8
|
+
Pin,
|
|
9
|
+
PinAssignment,
|
|
10
|
+
Project,
|
|
11
|
+
Resolution,
|
|
12
|
+
SchematicPinMap,
|
|
13
|
+
is_port_pin,
|
|
14
|
+
is_power_pin,
|
|
15
|
+
natural_key,
|
|
16
|
+
port_sort_key,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Detector",
|
|
21
|
+
"Domain",
|
|
22
|
+
"Evidence",
|
|
23
|
+
"Finding",
|
|
24
|
+
"FirmwareIndex",
|
|
25
|
+
"FirmwareReference",
|
|
26
|
+
"Location",
|
|
27
|
+
"McuConfig",
|
|
28
|
+
"Pin",
|
|
29
|
+
"PinAssignment",
|
|
30
|
+
"Project",
|
|
31
|
+
"Resolution",
|
|
32
|
+
"SchematicPinMap",
|
|
33
|
+
"Severity",
|
|
34
|
+
"is_port_pin",
|
|
35
|
+
"is_power_pin",
|
|
36
|
+
"natural_key",
|
|
37
|
+
"port_sort_key",
|
|
38
|
+
]
|