tirtc-device-builder 0.7.2 → 0.8.0
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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/README.md +44 -14
- package/bin/tirtc-device-builder.js +33 -1
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +139 -39
- package/skills/tirtc-esp32-builder/USAGE.md +3 -2
- package/skills/tirtc-esp32-builder/VERSION +1 -1
- package/skills/tirtc-esp32-builder/agents/openai.yaml +1 -1
- package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +4 -0
- package/skills/tirtc-esp32-builder/assets/board-identity.example.json +16 -0
- package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +3 -3
- package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +9 -1
- package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +5 -5
- package/skills/tirtc-esp32-builder/assets/report-template.md +6 -1
- package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +13 -0
- package/skills/tirtc-esp32-builder/knowledge/board-registry.json +4 -0
- package/skills/tirtc-esp32-builder/references/audio-contract.md +33 -3
- package/skills/tirtc-esp32-builder/references/board-knowledge.md +72 -0
- package/skills/tirtc-esp32-builder/references/capability-rules.md +11 -1
- package/skills/tirtc-esp32-builder/references/environment.md +20 -0
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +17 -3
- package/skills/tirtc-esp32-builder/references/porting-risks.md +25 -0
- package/skills/tirtc-esp32-builder/references/reporting.md +1 -1
- package/skills/tirtc-esp32-builder/references/runtime-contract.md +12 -2
- package/skills/tirtc-esp32-builder/references/tirtc-platform.md +92 -0
- package/skills/tirtc-esp32-builder/references/workflow.md +31 -11
- package/skills/tirtc-esp32-builder/scripts/audio_contract.py +105 -3
- package/skills/tirtc-esp32-builder/scripts/board_registry.py +677 -0
- package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +147 -8
- package/skills/tirtc-esp32-builder/scripts/project_portability.py +178 -3
- package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +122 -1
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate and query reusable ESP32 board knowledge without guessing identity."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from hardware_ir import load_ir, validate_ir, validate_source_locations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SCHEMA_VERSION = 1
|
|
18
|
+
PACKAGE_STATUSES = {"knowledge_only", "adapter_verified", "hil_verified"}
|
|
19
|
+
LESSON_SCOPES = {"generic", "component", "board"}
|
|
20
|
+
LESSON_VERIFICATION = {
|
|
21
|
+
"reported",
|
|
22
|
+
"corroborated",
|
|
23
|
+
"build_verified",
|
|
24
|
+
"hardware_verified",
|
|
25
|
+
"hil_verified",
|
|
26
|
+
}
|
|
27
|
+
ACTION_KINDS = {"prefer", "require", "warn", "block_until_hil"}
|
|
28
|
+
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_json(path: Path, label: str) -> dict[str, Any]:
|
|
32
|
+
try:
|
|
33
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except FileNotFoundError as exc:
|
|
35
|
+
raise ValueError(f"{label} does not exist: {path}") from exc
|
|
36
|
+
except json.JSONDecodeError as exc:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"invalid {label} JSON at line {exc.lineno}: {exc.msg}"
|
|
39
|
+
) from exc
|
|
40
|
+
if not isinstance(value, dict):
|
|
41
|
+
raise ValueError(f"{label} root must be an object")
|
|
42
|
+
return value
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalized(value: Any) -> str:
|
|
46
|
+
return str(value).strip().casefold() if value is not None else ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def nonempty(value: Any, path: str, errors: list[str]) -> bool:
|
|
50
|
+
if not isinstance(value, str) or not value.strip():
|
|
51
|
+
errors.append(f"{path} must be a non-empty string")
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def nullable_nonempty(value: Any, path: str, errors: list[str]) -> None:
|
|
57
|
+
if value is not None:
|
|
58
|
+
nonempty(value, path, errors)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def nullable_positive_int(value: Any, path: str, errors: list[str]) -> None:
|
|
62
|
+
if value is not None and (
|
|
63
|
+
isinstance(value, bool) or not isinstance(value, int) or value <= 0
|
|
64
|
+
):
|
|
65
|
+
errors.append(f"{path} must be a positive integer or null")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def string_array(value: Any, path: str, errors: list[str]) -> list[str]:
|
|
69
|
+
if not isinstance(value, list):
|
|
70
|
+
errors.append(f"{path} must be an array")
|
|
71
|
+
return []
|
|
72
|
+
result: list[str] = []
|
|
73
|
+
for index, item in enumerate(value):
|
|
74
|
+
if nonempty(item, f"{path}[{index}]", errors):
|
|
75
|
+
result.append(item)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_component(item: Any, path: str, errors: list[str]) -> None:
|
|
80
|
+
if not isinstance(item, dict):
|
|
81
|
+
errors.append(f"{path} must be an object")
|
|
82
|
+
return
|
|
83
|
+
nonempty(item.get("kind"), f"{path}.kind", errors)
|
|
84
|
+
nonempty(item.get("model"), f"{path}.model", errors)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_probe(item: Any, path: str, errors: list[str]) -> None:
|
|
88
|
+
if not isinstance(item, dict):
|
|
89
|
+
errors.append(f"{path} must be an object")
|
|
90
|
+
return
|
|
91
|
+
nonempty(item.get("kind"), f"{path}.kind", errors)
|
|
92
|
+
nonempty(item.get("value"), f"{path}.value", errors)
|
|
93
|
+
required = item.get("required_for_exact")
|
|
94
|
+
if not isinstance(required, bool):
|
|
95
|
+
errors.append(f"{path}.required_for_exact must be true or false")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_identity(identity: Any, path: str, errors: list[str]) -> None:
|
|
99
|
+
if not isinstance(identity, dict):
|
|
100
|
+
errors.append(f"{path} must be an object")
|
|
101
|
+
return
|
|
102
|
+
for field in ("vendor", "model", "hardware_revision", "target"):
|
|
103
|
+
nonempty(identity.get(field), f"{path}.{field}", errors)
|
|
104
|
+
aliases = string_array(identity.get("aliases", []), f"{path}.aliases", errors)
|
|
105
|
+
names = [identity.get("model"), *aliases]
|
|
106
|
+
normalized_names = [normalized(value) for value in names if normalized(value)]
|
|
107
|
+
if len(normalized_names) != len(set(normalized_names)):
|
|
108
|
+
errors.append(f"{path}.model and aliases must be unique")
|
|
109
|
+
nullable_nonempty(identity.get("module"), f"{path}.module", errors)
|
|
110
|
+
nullable_positive_int(identity.get("flash_mb"), f"{path}.flash_mb", errors)
|
|
111
|
+
nullable_positive_int(identity.get("psram_mb"), f"{path}.psram_mb", errors)
|
|
112
|
+
components = identity.get("components", [])
|
|
113
|
+
if not isinstance(components, list):
|
|
114
|
+
errors.append(f"{path}.components must be an array")
|
|
115
|
+
else:
|
|
116
|
+
for index, item in enumerate(components):
|
|
117
|
+
validate_component(item, f"{path}.components[{index}]", errors)
|
|
118
|
+
probes = identity.get("probes", [])
|
|
119
|
+
if not isinstance(probes, list):
|
|
120
|
+
errors.append(f"{path}.probes must be an array")
|
|
121
|
+
else:
|
|
122
|
+
for index, item in enumerate(probes):
|
|
123
|
+
validate_probe(item, f"{path}.probes[{index}]", errors)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def safe_registry_path(registry_path: Path, value: Any, path: str, errors: list[str]) -> Path | None:
|
|
127
|
+
if not isinstance(value, str) or not value.strip():
|
|
128
|
+
errors.append(f"{path} must be a non-empty registry-relative path")
|
|
129
|
+
return None
|
|
130
|
+
relative = Path(value)
|
|
131
|
+
if relative.is_absolute():
|
|
132
|
+
errors.append(f"{path} must be registry-relative")
|
|
133
|
+
return None
|
|
134
|
+
root = registry_path.parent.resolve()
|
|
135
|
+
resolved = (root / relative).resolve()
|
|
136
|
+
if resolved != root and root not in resolved.parents:
|
|
137
|
+
errors.append(f"{path} escapes the registry root")
|
|
138
|
+
return None
|
|
139
|
+
return resolved
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def validate_lesson(
|
|
143
|
+
lesson: Any,
|
|
144
|
+
path: str,
|
|
145
|
+
package_id: str,
|
|
146
|
+
board_ids: set[str],
|
|
147
|
+
registry_path: Path,
|
|
148
|
+
errors: list[str],
|
|
149
|
+
) -> None:
|
|
150
|
+
if not isinstance(lesson, dict):
|
|
151
|
+
errors.append(f"{path} must be an object")
|
|
152
|
+
return
|
|
153
|
+
nonempty(lesson.get("id"), f"{path}.id", errors)
|
|
154
|
+
scope = lesson.get("scope")
|
|
155
|
+
if scope not in LESSON_SCOPES:
|
|
156
|
+
errors.append(f"{path}.scope must be one of {', '.join(sorted(LESSON_SCOPES))}")
|
|
157
|
+
nonempty(lesson.get("subject"), f"{path}.subject", errors)
|
|
158
|
+
nonempty(lesson.get("guidance"), f"{path}.guidance", errors)
|
|
159
|
+
action = lesson.get("action")
|
|
160
|
+
if action not in ACTION_KINDS:
|
|
161
|
+
errors.append(f"{path}.action must be one of {', '.join(sorted(ACTION_KINDS))}")
|
|
162
|
+
verification = lesson.get("verification")
|
|
163
|
+
if verification not in LESSON_VERIFICATION:
|
|
164
|
+
errors.append(
|
|
165
|
+
f"{path}.verification must be one of "
|
|
166
|
+
+ ", ".join(sorted(LESSON_VERIFICATION))
|
|
167
|
+
)
|
|
168
|
+
applies_to = lesson.get("applies_to")
|
|
169
|
+
if not isinstance(applies_to, dict):
|
|
170
|
+
errors.append(f"{path}.applies_to must be an object")
|
|
171
|
+
applies_to = {}
|
|
172
|
+
if scope == "board" and applies_to.get("package_id") != package_id:
|
|
173
|
+
errors.append(f"{path} board lesson must target its containing package")
|
|
174
|
+
if scope == "component":
|
|
175
|
+
nonempty(applies_to.get("kind"), f"{path}.applies_to.kind", errors)
|
|
176
|
+
nonempty(applies_to.get("model"), f"{path}.applies_to.model", errors)
|
|
177
|
+
|
|
178
|
+
evidence = lesson.get("evidence")
|
|
179
|
+
if not isinstance(evidence, list) or not evidence:
|
|
180
|
+
errors.append(f"{path}.evidence must be a non-empty array")
|
|
181
|
+
evidence = []
|
|
182
|
+
evidence_boards: set[str] = set()
|
|
183
|
+
for index, item in enumerate(evidence):
|
|
184
|
+
prefix = f"{path}.evidence[{index}]"
|
|
185
|
+
if not isinstance(item, dict):
|
|
186
|
+
errors.append(f"{prefix} must be an object")
|
|
187
|
+
continue
|
|
188
|
+
board_id = item.get("board_package_id")
|
|
189
|
+
if nonempty(board_id, f"{prefix}.board_package_id", errors):
|
|
190
|
+
evidence_boards.add(board_id)
|
|
191
|
+
if board_id not in board_ids:
|
|
192
|
+
errors.append(f"{prefix}.board_package_id is not registered")
|
|
193
|
+
nullable_nonempty(item.get("source_ref"), f"{prefix}.source_ref", errors)
|
|
194
|
+
sha = item.get("artifact_sha256")
|
|
195
|
+
if sha is not None and (not isinstance(sha, str) or SHA256_RE.fullmatch(sha) is None):
|
|
196
|
+
errors.append(f"{prefix}.artifact_sha256 must be a full SHA-256")
|
|
197
|
+
|
|
198
|
+
if verification in {"hardware_verified", "hil_verified"} and not any(
|
|
199
|
+
isinstance(item, dict) and item.get("artifact_sha256")
|
|
200
|
+
for item in evidence
|
|
201
|
+
):
|
|
202
|
+
errors.append(f"{path} hardware/HIL lesson requires artifact SHA-256 evidence")
|
|
203
|
+
if scope == "generic":
|
|
204
|
+
if len(evidence_boards) < 2:
|
|
205
|
+
errors.append(f"{path} generic lesson requires two independent board packages")
|
|
206
|
+
regression_test = safe_registry_path(
|
|
207
|
+
registry_path,
|
|
208
|
+
lesson.get("regression_test"),
|
|
209
|
+
f"{path}.regression_test",
|
|
210
|
+
errors,
|
|
211
|
+
)
|
|
212
|
+
if regression_test is not None and not regression_test.is_file():
|
|
213
|
+
errors.append(f"{path}.regression_test does not exist")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate_registry(data: dict[str, Any], registry_path: Path) -> list[str]:
|
|
217
|
+
errors: list[str] = []
|
|
218
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
219
|
+
errors.append(f"schema_version must be {SCHEMA_VERSION}")
|
|
220
|
+
boards = data.get("boards")
|
|
221
|
+
if not isinstance(boards, list):
|
|
222
|
+
errors.append("boards must be an array")
|
|
223
|
+
return errors
|
|
224
|
+
package_ids: set[str] = set()
|
|
225
|
+
for index, item in enumerate(boards):
|
|
226
|
+
prefix = f"boards[{index}]"
|
|
227
|
+
if not isinstance(item, dict):
|
|
228
|
+
errors.append(f"{prefix} must be an object")
|
|
229
|
+
continue
|
|
230
|
+
package_id = item.get("package_id")
|
|
231
|
+
if nonempty(package_id, f"{prefix}.package_id", errors):
|
|
232
|
+
if package_id in package_ids:
|
|
233
|
+
errors.append(f"duplicate package_id {package_id!r}")
|
|
234
|
+
package_ids.add(package_id)
|
|
235
|
+
|
|
236
|
+
lesson_ids: set[str] = set()
|
|
237
|
+
for index, board in enumerate(boards):
|
|
238
|
+
if not isinstance(board, dict):
|
|
239
|
+
continue
|
|
240
|
+
prefix = f"boards[{index}]"
|
|
241
|
+
package_id = str(board.get("package_id", ""))
|
|
242
|
+
status = board.get("status")
|
|
243
|
+
if status not in PACKAGE_STATUSES:
|
|
244
|
+
errors.append(
|
|
245
|
+
f"{prefix}.status must be one of {', '.join(sorted(PACKAGE_STATUSES))}"
|
|
246
|
+
)
|
|
247
|
+
validate_identity(board.get("identity"), f"{prefix}.identity", errors)
|
|
248
|
+
compatibility = board.get("compatibility")
|
|
249
|
+
if not isinstance(compatibility, dict):
|
|
250
|
+
errors.append(f"{prefix}.compatibility must be an object")
|
|
251
|
+
else:
|
|
252
|
+
nonempty(compatibility.get("idf"), f"{prefix}.compatibility.idf", errors)
|
|
253
|
+
nonempty(
|
|
254
|
+
compatibility.get("tirtc_sdk"),
|
|
255
|
+
f"{prefix}.compatibility.tirtc_sdk",
|
|
256
|
+
errors,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
artifacts = board.get("artifacts", {})
|
|
260
|
+
if not isinstance(artifacts, dict):
|
|
261
|
+
errors.append(f"{prefix}.artifacts must be an object")
|
|
262
|
+
artifacts = {}
|
|
263
|
+
if status in {"adapter_verified", "hil_verified"}:
|
|
264
|
+
for field in ("hardware_ir", "adapter", "config_overlay"):
|
|
265
|
+
resolved = safe_registry_path(
|
|
266
|
+
registry_path,
|
|
267
|
+
artifacts.get(field),
|
|
268
|
+
f"{prefix}.artifacts.{field}",
|
|
269
|
+
errors,
|
|
270
|
+
)
|
|
271
|
+
if resolved is not None and not resolved.exists():
|
|
272
|
+
errors.append(f"{prefix}.artifacts.{field} does not exist")
|
|
273
|
+
contracts = artifacts.get("contracts")
|
|
274
|
+
if not isinstance(contracts, list) or not contracts:
|
|
275
|
+
errors.append(f"{prefix}.artifacts.contracts must be a non-empty array")
|
|
276
|
+
else:
|
|
277
|
+
for contract_index, value in enumerate(contracts):
|
|
278
|
+
resolved = safe_registry_path(
|
|
279
|
+
registry_path,
|
|
280
|
+
value,
|
|
281
|
+
f"{prefix}.artifacts.contracts[{contract_index}]",
|
|
282
|
+
errors,
|
|
283
|
+
)
|
|
284
|
+
if resolved is not None and not resolved.is_file():
|
|
285
|
+
errors.append(
|
|
286
|
+
f"{prefix}.artifacts.contracts[{contract_index}] does not exist"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
lessons = board.get("lessons", [])
|
|
290
|
+
if not isinstance(lessons, list):
|
|
291
|
+
errors.append(f"{prefix}.lessons must be an array")
|
|
292
|
+
continue
|
|
293
|
+
for lesson_index, lesson in enumerate(lessons):
|
|
294
|
+
lesson_path = f"{prefix}.lessons[{lesson_index}]"
|
|
295
|
+
validate_lesson(
|
|
296
|
+
lesson,
|
|
297
|
+
lesson_path,
|
|
298
|
+
package_id,
|
|
299
|
+
package_ids,
|
|
300
|
+
registry_path,
|
|
301
|
+
errors,
|
|
302
|
+
)
|
|
303
|
+
if isinstance(lesson, dict) and isinstance(lesson.get("id"), str):
|
|
304
|
+
lesson_id = lesson["id"]
|
|
305
|
+
if lesson_id in lesson_ids:
|
|
306
|
+
errors.append(f"duplicate lesson id {lesson_id!r}")
|
|
307
|
+
lesson_ids.add(lesson_id)
|
|
308
|
+
return errors
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def validate_observed_identity(data: dict[str, Any]) -> list[str]:
|
|
312
|
+
errors: list[str] = []
|
|
313
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
314
|
+
errors.append(f"schema_version must be {SCHEMA_VERSION}")
|
|
315
|
+
declared = data.get("declared")
|
|
316
|
+
observed = data.get("observed")
|
|
317
|
+
if not isinstance(declared, dict):
|
|
318
|
+
errors.append("declared must be an object")
|
|
319
|
+
declared = {}
|
|
320
|
+
if not isinstance(observed, dict):
|
|
321
|
+
errors.append("observed must be an object")
|
|
322
|
+
observed = {}
|
|
323
|
+
for field in ("vendor", "model", "hardware_revision"):
|
|
324
|
+
nullable_nonempty(declared.get(field), f"declared.{field}", errors)
|
|
325
|
+
for field in ("target", "module"):
|
|
326
|
+
nullable_nonempty(observed.get(field), f"observed.{field}", errors)
|
|
327
|
+
nullable_positive_int(observed.get("flash_mb"), "observed.flash_mb", errors)
|
|
328
|
+
nullable_positive_int(observed.get("psram_mb"), "observed.psram_mb", errors)
|
|
329
|
+
for field, validator in (("components", validate_component), ("probes", validate_probe)):
|
|
330
|
+
values = observed.get(field, [])
|
|
331
|
+
if not isinstance(values, list):
|
|
332
|
+
errors.append(f"observed.{field} must be an array")
|
|
333
|
+
continue
|
|
334
|
+
for index, item in enumerate(values):
|
|
335
|
+
validator(item, f"observed.{field}[{index}]", errors)
|
|
336
|
+
if not any(normalized(declared.get(field)) for field in ("vendor", "model", "hardware_revision")) and not any(
|
|
337
|
+
observed.get(field) for field in ("target", "module", "components", "probes")
|
|
338
|
+
):
|
|
339
|
+
errors.append("identity must contain declared board data or observed probes")
|
|
340
|
+
return errors
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def pair_set(values: Any) -> set[tuple[str, str]]:
|
|
344
|
+
if not isinstance(values, list):
|
|
345
|
+
return set()
|
|
346
|
+
return {
|
|
347
|
+
(normalized(item.get("kind")), normalized(item.get("model")))
|
|
348
|
+
for item in values
|
|
349
|
+
if isinstance(item, dict) and normalized(item.get("kind")) and normalized(item.get("model"))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def probe_set(values: Any) -> set[tuple[str, str]]:
|
|
354
|
+
if not isinstance(values, list):
|
|
355
|
+
return set()
|
|
356
|
+
return {
|
|
357
|
+
(normalized(item.get("kind")), normalized(item.get("value")))
|
|
358
|
+
for item in values
|
|
359
|
+
if isinstance(item, dict) and normalized(item.get("kind")) and normalized(item.get("value"))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def match_board(board: dict[str, Any], query: dict[str, Any]) -> dict[str, Any] | None:
|
|
364
|
+
identity = board["identity"]
|
|
365
|
+
declared = query.get("declared", {})
|
|
366
|
+
observed = query.get("observed", {})
|
|
367
|
+
conflicts: list[str] = []
|
|
368
|
+
missing: list[str] = []
|
|
369
|
+
reasons: list[str] = []
|
|
370
|
+
|
|
371
|
+
package_names = {
|
|
372
|
+
normalized(identity.get("model")),
|
|
373
|
+
*(normalized(value) for value in identity.get("aliases", [])),
|
|
374
|
+
}
|
|
375
|
+
declared_model = normalized(declared.get("model"))
|
|
376
|
+
model_match = bool(declared_model and declared_model in package_names)
|
|
377
|
+
vendor_match = normalized(declared.get("vendor")) == normalized(identity.get("vendor")) if declared.get("vendor") else False
|
|
378
|
+
revision_match = normalized(declared.get("hardware_revision")) == normalized(identity.get("hardware_revision")) if declared.get("hardware_revision") else False
|
|
379
|
+
|
|
380
|
+
for field in ("target", "module", "flash_mb", "psram_mb"):
|
|
381
|
+
expected = identity.get(field)
|
|
382
|
+
actual = observed.get(field)
|
|
383
|
+
if expected is not None and actual is not None:
|
|
384
|
+
if normalized(expected) != normalized(actual):
|
|
385
|
+
conflicts.append(f"{field} differs: expected {expected}, observed {actual}")
|
|
386
|
+
else:
|
|
387
|
+
reasons.append(f"{field} matched")
|
|
388
|
+
|
|
389
|
+
expected_probes = probe_set(identity.get("probes", []))
|
|
390
|
+
actual_probes = probe_set(observed.get("probes", []))
|
|
391
|
+
observed_by_kind: dict[str, set[str]] = {}
|
|
392
|
+
for kind, value in actual_probes:
|
|
393
|
+
observed_by_kind.setdefault(kind, set()).add(value)
|
|
394
|
+
for probe in identity.get("probes", []):
|
|
395
|
+
if not isinstance(probe, dict) or not probe.get("required_for_exact"):
|
|
396
|
+
continue
|
|
397
|
+
key = (normalized(probe.get("kind")), normalized(probe.get("value")))
|
|
398
|
+
if key in actual_probes:
|
|
399
|
+
reasons.append(f"required probe {probe.get('kind')}={probe.get('value')} matched")
|
|
400
|
+
elif key[0] in observed_by_kind:
|
|
401
|
+
conflicts.append(
|
|
402
|
+
f"required probe {probe.get('kind')} differs from {probe.get('value')}"
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
missing.append(f"required probe {probe.get('kind')}={probe.get('value')}")
|
|
406
|
+
|
|
407
|
+
expected_components = pair_set(identity.get("components", []))
|
|
408
|
+
actual_components = pair_set(observed.get("components", []))
|
|
409
|
+
shared_components = sorted(expected_components.intersection(actual_components))
|
|
410
|
+
|
|
411
|
+
if conflicts:
|
|
412
|
+
classification = "component" if shared_components else "none"
|
|
413
|
+
elif model_match and vendor_match and revision_match and not missing:
|
|
414
|
+
classification = "exact"
|
|
415
|
+
elif model_match or (model_match and vendor_match):
|
|
416
|
+
classification = "probable"
|
|
417
|
+
if not revision_match:
|
|
418
|
+
missing.append("exact hardware revision")
|
|
419
|
+
elif shared_components or expected_probes.intersection(actual_probes):
|
|
420
|
+
classification = "component"
|
|
421
|
+
else:
|
|
422
|
+
classification = "none"
|
|
423
|
+
|
|
424
|
+
if classification == "none":
|
|
425
|
+
return None
|
|
426
|
+
status = board["status"]
|
|
427
|
+
if classification == "exact" and status in {"adapter_verified", "hil_verified"}:
|
|
428
|
+
reuse = "registered_board"
|
|
429
|
+
elif classification == "exact":
|
|
430
|
+
reuse = "knowledge_only"
|
|
431
|
+
elif classification == "probable":
|
|
432
|
+
reuse = "candidate_only"
|
|
433
|
+
else:
|
|
434
|
+
reuse = "component_lessons_only"
|
|
435
|
+
rank = {"exact": 3, "probable": 2, "component": 1}[classification]
|
|
436
|
+
applicable_lessons = []
|
|
437
|
+
for lesson in board.get("lessons", []):
|
|
438
|
+
scope = lesson.get("scope")
|
|
439
|
+
if classification == "exact" or scope == "generic" or (
|
|
440
|
+
scope == "component" and shared_components
|
|
441
|
+
):
|
|
442
|
+
applicable_lessons.append(lesson)
|
|
443
|
+
return {
|
|
444
|
+
"package_id": board["package_id"],
|
|
445
|
+
"status": status,
|
|
446
|
+
"classification": classification,
|
|
447
|
+
"reuse": reuse,
|
|
448
|
+
"rank": rank,
|
|
449
|
+
"reasons": reasons,
|
|
450
|
+
"missing": sorted(set(missing)),
|
|
451
|
+
"conflicts": conflicts,
|
|
452
|
+
"shared_components": [
|
|
453
|
+
{"kind": kind, "model": model} for kind, model in shared_components
|
|
454
|
+
],
|
|
455
|
+
"applicable_lessons": applicable_lessons,
|
|
456
|
+
"artifacts": board.get("artifacts", {}) if reuse == "registered_board" else {},
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def match_registry(registry: dict[str, Any], query: dict[str, Any]) -> dict[str, Any]:
|
|
461
|
+
matches = [
|
|
462
|
+
match
|
|
463
|
+
for board in registry.get("boards", [])
|
|
464
|
+
if (match := match_board(board, query)) is not None
|
|
465
|
+
]
|
|
466
|
+
matches.sort(key=lambda item: (-item["rank"], item["package_id"]))
|
|
467
|
+
return {
|
|
468
|
+
"schema_version": SCHEMA_VERSION,
|
|
469
|
+
"result": matches[0]["classification"] if matches else "none",
|
|
470
|
+
"safe_registered_reuse": bool(
|
|
471
|
+
matches and matches[0]["reuse"] == "registered_board"
|
|
472
|
+
),
|
|
473
|
+
"matches": matches,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def components_from_ir(data: dict[str, Any]) -> list[dict[str, str]]:
|
|
478
|
+
components: list[dict[str, str]] = []
|
|
479
|
+
camera = data.get("camera", {})
|
|
480
|
+
if camera.get("present") is True and isinstance(camera.get("sensor"), str):
|
|
481
|
+
components.append({"kind": "camera", "model": camera["sensor"]})
|
|
482
|
+
for section, kind in (("audio_input", "audio_input"), ("audio_output", "audio_output")):
|
|
483
|
+
value = data.get(section, {})
|
|
484
|
+
for codec in value.get("codecs", []) if isinstance(value, dict) else []:
|
|
485
|
+
if isinstance(codec, dict) and isinstance(codec.get("name"), str):
|
|
486
|
+
components.append({"kind": kind, "model": codec["name"]})
|
|
487
|
+
unique = {(normalized(item["kind"]), normalized(item["model"])): item for item in components}
|
|
488
|
+
return [unique[key] for key in sorted(unique)]
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def candidate_from_ir(data: dict[str, Any]) -> dict[str, Any]:
|
|
492
|
+
board = data["board"]
|
|
493
|
+
soc = data["soc"]
|
|
494
|
+
toolchain = data["toolchain"]
|
|
495
|
+
tirtc = toolchain.get("tirtc", {})
|
|
496
|
+
resources = data.get("hardware_resources", {})
|
|
497
|
+
contracts = [
|
|
498
|
+
resources.get(field)
|
|
499
|
+
for field in (
|
|
500
|
+
"audio_semantic_contract",
|
|
501
|
+
"video_semantic_contract",
|
|
502
|
+
"runtime_semantic_contract",
|
|
503
|
+
)
|
|
504
|
+
if isinstance(resources.get(field), str) and resources.get(field)
|
|
505
|
+
]
|
|
506
|
+
return {
|
|
507
|
+
"schema_version": SCHEMA_VERSION,
|
|
508
|
+
"promotion_status": "candidate",
|
|
509
|
+
"package_id": board["id"],
|
|
510
|
+
"identity": {
|
|
511
|
+
"vendor": board["vendor"],
|
|
512
|
+
"model": board["model"],
|
|
513
|
+
"aliases": [],
|
|
514
|
+
"hardware_revision": board["hardware_revision"],
|
|
515
|
+
"target": soc["target"],
|
|
516
|
+
"module": soc.get("module"),
|
|
517
|
+
"flash_mb": soc.get("flash_mb"),
|
|
518
|
+
"psram_mb": soc.get("psram_mb"),
|
|
519
|
+
"components": components_from_ir(data),
|
|
520
|
+
"probes": [],
|
|
521
|
+
},
|
|
522
|
+
"compatibility": {
|
|
523
|
+
"idf": toolchain["framework_version"],
|
|
524
|
+
"tirtc_sdk": tirtc.get("version", "unknown"),
|
|
525
|
+
},
|
|
526
|
+
"proposed_artifacts": {
|
|
527
|
+
"hardware_ir": "hardware-ir.json",
|
|
528
|
+
"adapter": None,
|
|
529
|
+
"config_overlay": "sdkconfig.defaults",
|
|
530
|
+
"contracts": contracts,
|
|
531
|
+
},
|
|
532
|
+
"lessons": [],
|
|
533
|
+
"promotion_requirements": [
|
|
534
|
+
"resolve exact PCB revision and every required runtime probe",
|
|
535
|
+
"copy project-relative Hardware IR, adapter, config overlay and contracts into the registry package",
|
|
536
|
+
"bind hardware/HIL lessons to an exact artifact SHA-256",
|
|
537
|
+
"add a focused regression test before promoting a generic invariant",
|
|
538
|
+
"review and commit the candidate; never mutate an installed Skill automatically",
|
|
539
|
+
],
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def default_registry() -> Path:
|
|
544
|
+
return Path(__file__).resolve().parent.parent / "knowledge" / "board-registry.json"
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def load_valid_registry(path: Path) -> dict[str, Any]:
|
|
548
|
+
registry = load_json(path, "board registry")
|
|
549
|
+
errors = validate_registry(registry, path)
|
|
550
|
+
if errors:
|
|
551
|
+
raise ValueError("\n".join(f"error: {item}" for item in errors))
|
|
552
|
+
return registry
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def command_validate(args: argparse.Namespace) -> int:
|
|
556
|
+
try:
|
|
557
|
+
registry = load_json(args.registry, "board registry")
|
|
558
|
+
except ValueError as exc:
|
|
559
|
+
print(str(exc), file=sys.stderr)
|
|
560
|
+
return 2
|
|
561
|
+
errors = validate_registry(registry, args.registry)
|
|
562
|
+
if errors:
|
|
563
|
+
for item in errors:
|
|
564
|
+
print(f"error: {item}", file=sys.stderr)
|
|
565
|
+
return 2
|
|
566
|
+
print(f"valid board registry: {args.registry} ({len(registry['boards'])} packages)")
|
|
567
|
+
return 0
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def command_list(args: argparse.Namespace) -> int:
|
|
571
|
+
try:
|
|
572
|
+
registry = load_valid_registry(args.registry)
|
|
573
|
+
except ValueError as exc:
|
|
574
|
+
print(str(exc), file=sys.stderr)
|
|
575
|
+
return 2
|
|
576
|
+
if not registry["boards"]:
|
|
577
|
+
print("No curated board packages are registered.")
|
|
578
|
+
return 0
|
|
579
|
+
for board in registry["boards"]:
|
|
580
|
+
identity = board["identity"]
|
|
581
|
+
print(
|
|
582
|
+
f"{board['package_id']}\t{board['status']}\t"
|
|
583
|
+
f"{identity['vendor']} {identity['model']} {identity['hardware_revision']}"
|
|
584
|
+
)
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def command_match(args: argparse.Namespace) -> int:
|
|
589
|
+
try:
|
|
590
|
+
registry = load_valid_registry(args.registry)
|
|
591
|
+
query = load_json(args.identity, "board identity")
|
|
592
|
+
except ValueError as exc:
|
|
593
|
+
print(str(exc), file=sys.stderr)
|
|
594
|
+
return 2
|
|
595
|
+
errors = validate_observed_identity(query)
|
|
596
|
+
if errors:
|
|
597
|
+
for item in errors:
|
|
598
|
+
print(f"error: {item}", file=sys.stderr)
|
|
599
|
+
return 2
|
|
600
|
+
print(json.dumps(match_registry(registry, query), indent=2, ensure_ascii=False))
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def command_init_identity(args: argparse.Namespace) -> int:
|
|
605
|
+
output = args.output.resolve()
|
|
606
|
+
if output.exists():
|
|
607
|
+
print(f"refusing to overwrite existing file: {output}", file=sys.stderr)
|
|
608
|
+
return 2
|
|
609
|
+
example = Path(__file__).resolve().parent.parent / "assets" / "board-identity.example.json"
|
|
610
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
611
|
+
shutil.copyfile(example, output)
|
|
612
|
+
print(f"created board identity: {output}")
|
|
613
|
+
return 0
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def command_candidate(args: argparse.Namespace) -> int:
|
|
617
|
+
output = args.output.resolve()
|
|
618
|
+
if output.exists():
|
|
619
|
+
print(f"refusing to overwrite existing file: {output}", file=sys.stderr)
|
|
620
|
+
return 2
|
|
621
|
+
try:
|
|
622
|
+
data = load_ir(args.hardware_ir)
|
|
623
|
+
except ValueError as exc:
|
|
624
|
+
print(str(exc), file=sys.stderr)
|
|
625
|
+
return 2
|
|
626
|
+
errors = validate_ir(data) + validate_source_locations(data, args.hardware_ir.parent)
|
|
627
|
+
if errors:
|
|
628
|
+
for item in errors:
|
|
629
|
+
print(f"error: {item}", file=sys.stderr)
|
|
630
|
+
return 2
|
|
631
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
632
|
+
output.write_text(
|
|
633
|
+
json.dumps(candidate_from_ir(data), indent=2, ensure_ascii=False) + "\n",
|
|
634
|
+
encoding="utf-8",
|
|
635
|
+
)
|
|
636
|
+
print(f"created board knowledge candidate: {output}")
|
|
637
|
+
return 0
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def parse_args() -> argparse.Namespace:
|
|
641
|
+
parser = argparse.ArgumentParser(
|
|
642
|
+
description="Validate, match, and create candidates for ESP32 board knowledge."
|
|
643
|
+
)
|
|
644
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
645
|
+
|
|
646
|
+
for name in ("validate", "list"):
|
|
647
|
+
child = subparsers.add_parser(name)
|
|
648
|
+
child.add_argument("--registry", type=Path, default=default_registry())
|
|
649
|
+
|
|
650
|
+
match_parser = subparsers.add_parser("match")
|
|
651
|
+
match_parser.add_argument("--registry", type=Path, default=default_registry())
|
|
652
|
+
match_parser.add_argument("--identity", type=Path, required=True)
|
|
653
|
+
|
|
654
|
+
identity_parser = subparsers.add_parser("init-identity")
|
|
655
|
+
identity_parser.add_argument("--output", type=Path, required=True)
|
|
656
|
+
|
|
657
|
+
candidate_parser = subparsers.add_parser("candidate")
|
|
658
|
+
candidate_parser.add_argument("--hardware-ir", type=Path, required=True)
|
|
659
|
+
candidate_parser.add_argument("--output", type=Path, required=True)
|
|
660
|
+
return parser.parse_args()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def main() -> int:
|
|
664
|
+
args = parse_args()
|
|
665
|
+
if args.command == "validate":
|
|
666
|
+
return command_validate(args)
|
|
667
|
+
if args.command == "list":
|
|
668
|
+
return command_list(args)
|
|
669
|
+
if args.command == "match":
|
|
670
|
+
return command_match(args)
|
|
671
|
+
if args.command == "init-identity":
|
|
672
|
+
return command_init_identity(args)
|
|
673
|
+
return command_candidate(args)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
if __name__ == "__main__":
|
|
677
|
+
raise SystemExit(main())
|