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.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. 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
+ ]