tirtc-device-builder 0.6.0 → 0.7.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.
Files changed (26) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +21 -31
  4. package/bin/esp32-kit-metadata.js +7 -6
  5. package/bin/install-esp32-kit.js +2 -0
  6. package/package.json +1 -1
  7. package/skills/tirtc-esp32-builder/SKILL.md +4 -4
  8. package/skills/tirtc-esp32-builder/USAGE.md +1 -1
  9. package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +2 -1
  10. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +7 -5
  11. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +1 -0
  12. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +67 -38
  13. package/skills/tirtc-esp32-builder/assets/report-template.md +5 -1
  14. package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +10 -0
  15. package/skills/tirtc-esp32-builder/references/capability-rules.md +3 -3
  16. package/skills/tirtc-esp32-builder/references/hardware-ir.md +3 -1
  17. package/skills/tirtc-esp32-builder/references/reporting.md +2 -2
  18. package/skills/tirtc-esp32-builder/references/runtime-contract.md +34 -0
  19. package/skills/tirtc-esp32-builder/references/video-contract.md +4 -3
  20. package/skills/tirtc-esp32-builder/references/workflow.md +3 -1
  21. package/skills/tirtc-esp32-builder/scripts/doctor.py +7 -1
  22. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +177 -3
  23. package/skills/tirtc-esp32-builder/scripts/install_runtime_gate.py +66 -0
  24. package/skills/tirtc-esp32-builder/scripts/project_portability.py +2 -2
  25. package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +314 -0
  26. package/skills/tirtc-esp32-builder/scripts/video_contract.py +74 -0
@@ -62,12 +62,14 @@ The adapter owns:
62
62
  - DMA buffers, hardware clocks, power, reset, GPIO, refresh/key-frame requests, and realtime task allocation;
63
63
  - bounded stop, resource release, and generation-aware flushing.
64
64
 
65
- The stable modules own stream IDs, negotiated/contracted formats, TiRTC callback copying, connection handles, session generation, and H5/AI sequencing.
65
+ The stable modules own the discovered TiRTC service endpoint, stream IDs, negotiated/contracted formats, TiRTC callback copying, connection handles, session generation, and H5/AI sequencing. SDK lifecycle changes such as disconnect run in a worker/state-machine context, never directly inside an SDK callback.
66
66
 
67
67
  ## Verification loop
68
68
 
69
69
  Use a bounded loop per layer: diagnose one failing invariant, make the smallest correction, and rerun that layer before moving forward. Change one high-risk variable per HIL comparison. Turn reusable invariants into tests or post-link gates. Stop and report when the remaining failure requires unavailable hardware, credentials, a new SDK binary, a public protocol change, or a user choice.
70
70
 
71
+ For every generated H5/AI project, validate `tirtc-runtime-contract.json` and run `install_runtime_gate.py <project>` before the ordinary build. Audio and video projects additionally install their media gates. A build that bypasses any applicable gate is not `BUILD_VERIFIED`.
72
+
71
73
  Run the assessor once per layer:
72
74
 
73
75
  - `--phase intake`: corroborated design evidence; success is `READY_TO_PORT`.
@@ -124,8 +124,14 @@ def compare_contract(
124
124
  actual = config.get(key)
125
125
  if expected is None:
126
126
  mismatches.append(f"SDK contract does not declare {key}")
127
+ elif actual is None and expected.lower() == "off":
128
+ # ESP-IDF omits some disabled child booleans from the final sdkconfig
129
+ # when their parent dependency is off. An absent boolean therefore has
130
+ # the same effective value as "# ... is not set" for this fixed set of
131
+ # SDK ABI options. Expected-on and scalar values remain strict.
132
+ continue
127
133
  elif actual is None:
128
- mismatches.append(f"project does not explicitly configure {key}={expected}")
134
+ mismatches.append(f"project does not configure required {key}={expected}")
129
135
  elif actual.lower() != expected.lower():
130
136
  mismatches.append(f"{key}: expected {expected}, got {actual}")
131
137
  return mismatches
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import hashlib
7
8
  import json
8
9
  import re
9
10
  import shutil
@@ -12,6 +13,7 @@ from pathlib import Path
12
13
  from typing import Any
13
14
 
14
15
  from audio_contract import verify_contract as verify_audio_contract
16
+ from runtime_contract import verify_contract as verify_runtime_contract
15
17
  from video_contract import verify_contract as verify_video_contract
16
18
 
17
19
 
@@ -63,6 +65,16 @@ FEATURE_HIL_LEVEL = {
63
65
  "ai_talk": "L6",
64
66
  }
65
67
  SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
68
+ SOURCE_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):")
69
+ ALLOWED_SOURCE_SCHEMES = {
70
+ "http",
71
+ "https",
72
+ "device-kit",
73
+ "managed",
74
+ "official",
75
+ "user-input",
76
+ "user-supplied",
77
+ }
66
78
  Requirement = tuple[str, str, int]
67
79
 
68
80
 
@@ -78,6 +90,62 @@ def load_ir(path: Path) -> dict[str, Any]:
78
90
  return data
79
91
 
80
92
 
93
+ def sha256_file(path: Path) -> str:
94
+ digest = hashlib.sha256()
95
+ with path.open("rb") as stream:
96
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
97
+ digest.update(chunk)
98
+ return digest.hexdigest()
99
+
100
+
101
+ def validate_source_locations(data: dict[str, Any], ir_directory: Path) -> list[str]:
102
+ """Resolve local evidence and reject invented or machine-bound source locators."""
103
+ errors: list[str] = []
104
+ root = ir_directory.expanduser().resolve()
105
+ sources = data.get("sources")
106
+ if not isinstance(sources, list):
107
+ return errors
108
+ for index, item in enumerate(sources):
109
+ if not isinstance(item, dict):
110
+ continue
111
+ prefix = f"sources[{index}]"
112
+ location = item.get("location")
113
+ if not isinstance(location, str) or not location.strip():
114
+ continue
115
+ if ";" in location:
116
+ errors.append(
117
+ f"{prefix}.location must identify exactly one source, not a semicolon list"
118
+ )
119
+ continue
120
+ scheme_match = SOURCE_SCHEME_RE.match(location)
121
+ if scheme_match is not None:
122
+ scheme = scheme_match.group(1).lower()
123
+ if scheme not in ALLOWED_SOURCE_SCHEMES:
124
+ errors.append(
125
+ f"{prefix}.location uses unsupported source scheme {scheme!r}"
126
+ )
127
+ continue
128
+ relative = Path(location)
129
+ if relative.is_absolute():
130
+ errors.append(
131
+ f"{prefix}.location must be IR-relative or a supported source URI"
132
+ )
133
+ continue
134
+ resolved = (root / relative).resolve()
135
+ if not resolved.exists():
136
+ errors.append(
137
+ f"{prefix}.location does not resolve from the IR directory: {location}"
138
+ )
139
+ continue
140
+ expected_sha = item.get("sha256")
141
+ if expected_sha is not None:
142
+ if not resolved.is_file():
143
+ errors.append(f"{prefix}.sha256 can only describe a regular file")
144
+ elif sha256_file(resolved).lower() != str(expected_sha).lower():
145
+ errors.append(f"{prefix}.sha256 does not match {location}")
146
+ return errors
147
+
148
+
81
149
  def mapping(value: Any, path: str, errors: list[str]) -> dict[str, Any]:
82
150
  if not isinstance(value, dict):
83
151
  errors.append(f"{path} must be an object")
@@ -245,6 +313,16 @@ def validate_hardware_resources(
245
313
  errors.append(
246
314
  "hardware_resources.video_semantic_contract must be project-relative"
247
315
  )
316
+ runtime_contract = resources.get("runtime_semantic_contract")
317
+ nullable_string(
318
+ runtime_contract,
319
+ "hardware_resources.runtime_semantic_contract",
320
+ errors,
321
+ )
322
+ if isinstance(runtime_contract, str) and Path(runtime_contract).is_absolute():
323
+ errors.append(
324
+ "hardware_resources.runtime_semantic_contract must be project-relative"
325
+ )
248
326
 
249
327
  mapping_section = mapping(
250
328
  resources.get("audio_channel_mapping"),
@@ -462,6 +540,11 @@ def validate_ir(data: dict[str, Any]) -> list[str]:
462
540
  source = mapping(item, prefix, errors)
463
541
  for key in ("id", "kind", "location"):
464
542
  nonempty_string(source.get(key), f"{prefix}.{key}", errors)
543
+ source_sha = source.get("sha256")
544
+ if source_sha is not None and (
545
+ not isinstance(source_sha, str) or not SHA256_RE.fullmatch(source_sha)
546
+ ):
547
+ errors.append(f"{prefix}.sha256 must be a 64-character SHA-256")
465
548
  source_id = source.get("id")
466
549
  if isinstance(source_id, str) and source_id:
467
550
  if source_id in source_ids:
@@ -993,12 +1076,46 @@ def artifact_requirement(
993
1076
  )
994
1077
 
995
1078
 
1079
+ def artifact_file_requirement(
1080
+ data: dict[str, Any], artifact_sha256: str | None, project: Path
1081
+ ) -> Requirement:
1082
+ recorded = artifact_requirement(data, artifact_sha256)
1083
+ if recorded[0] != "SATISFIED" or artifact_sha256 is None:
1084
+ return recorded
1085
+ record = matching_build_artifact(data, artifact_sha256)
1086
+ if record is None:
1087
+ return "BLOCKED", "matching build artifact record disappeared", 0
1088
+ value = record.get("path")
1089
+ if not isinstance(value, str) or not value:
1090
+ return "BLOCKED", "matching build artifact path is missing", 0
1091
+ relative = Path(value)
1092
+ if relative.is_absolute():
1093
+ return "BLOCKED", "matching build artifact path is not project-relative", 0
1094
+ root = project.expanduser().resolve()
1095
+ path = (root / relative).resolve()
1096
+ if root != path and root not in path.parents:
1097
+ return "BLOCKED", "matching build artifact path escapes project root", 0
1098
+ if not path.is_file():
1099
+ return "BLOCKED", f"recorded build artifact does not exist: {value}", 0
1100
+ if path.stat().st_size != record.get("size_bytes"):
1101
+ return "BLOCKED", f"recorded build artifact size is stale for {value}", 0
1102
+ if sha256_file(path).lower() != artifact_sha256.lower():
1103
+ return "BLOCKED", f"recorded build artifact SHA-256 is stale for {value}", 0
1104
+ return (
1105
+ "SATISFIED",
1106
+ f"artifact file {value} matches size and SHA-256",
1107
+ VERIFICATION_LEVELS["build_verified"],
1108
+ )
1109
+
1110
+
996
1111
  def assess_ir(
997
1112
  data: dict[str, Any],
998
1113
  artifact_sha256: str | None = None,
999
1114
  phase: str | None = None,
1000
1115
  audio_gate: Requirement | None = None,
1001
1116
  video_gate: Requirement | None = None,
1117
+ runtime_gate: Requirement | None = None,
1118
+ artifact_gate: Requirement | None = None,
1002
1119
  ) -> dict[str, Any]:
1003
1120
  selected_phase = phase or ("hil" if artifact_sha256 else "intake")
1004
1121
  if selected_phase not in ASSESSMENT_PHASES:
@@ -1043,7 +1160,7 @@ def assess_ir(
1043
1160
  resources = {}
1044
1161
 
1045
1162
  if selected_phase in {"build", "hil"}:
1046
- project.append(artifact_requirement(data, artifact_sha256))
1163
+ project.append(artifact_gate or artifact_requirement(data, artifact_sha256))
1047
1164
 
1048
1165
  for feature in requested:
1049
1166
  if schema_version == 1:
@@ -1124,6 +1241,15 @@ def assess_ir(
1124
1241
  0,
1125
1242
  )
1126
1243
  )
1244
+ if selected_phase in {"build", "hil"}:
1245
+ requirements.append(
1246
+ runtime_gate
1247
+ or (
1248
+ "NEEDS_CONFIRMATION",
1249
+ "TiRTC runtime semantic gate was not executed for this project",
1250
+ 0,
1251
+ )
1252
+ )
1127
1253
  result[feature] = combine_requirements(
1128
1254
  requirements, success_status=success_status
1129
1255
  )
@@ -1203,7 +1329,7 @@ def command_validate(args: argparse.Namespace) -> int:
1203
1329
  except ValueError as exc:
1204
1330
  print(str(exc), file=sys.stderr)
1205
1331
  return 2
1206
- errors = validate_ir(data)
1332
+ errors = validate_ir(data) + validate_source_locations(data, args.path.parent)
1207
1333
  if errors:
1208
1334
  for error in errors:
1209
1335
  print(f"error: {error}", file=sys.stderr)
@@ -1218,7 +1344,7 @@ def command_assess(args: argparse.Namespace) -> int:
1218
1344
  except ValueError as exc:
1219
1345
  print(str(exc), file=sys.stderr)
1220
1346
  return 2
1221
- errors = validate_ir(data)
1347
+ errors = validate_ir(data) + validate_source_locations(data, args.path.parent)
1222
1348
  if errors:
1223
1349
  for error in errors:
1224
1350
  print(f"error: {error}", file=sys.stderr)
@@ -1318,6 +1444,50 @@ def command_assess(args: argparse.Namespace) -> int:
1318
1444
  + "; ".join(video_gate_result.get("errors", [])),
1319
1445
  0,
1320
1446
  )
1447
+ runtime_gate: Requirement | None = None
1448
+ runtime_gate_result: dict[str, Any] | None = None
1449
+ if data.get("schema_version") == 2 and selected_phase in {"build", "hil"}:
1450
+ relative_contract = data["hardware_resources"].get(
1451
+ "runtime_semantic_contract"
1452
+ )
1453
+ if not isinstance(relative_contract, str) or not relative_contract:
1454
+ runtime_gate = (
1455
+ "NEEDS_CONFIRMATION",
1456
+ "hardware_resources.runtime_semantic_contract is missing",
1457
+ 0,
1458
+ )
1459
+ runtime_gate_result = {
1460
+ "ok": False,
1461
+ "errors": ["TiRTC runtime semantic contract is missing"],
1462
+ }
1463
+ else:
1464
+ contract = (project / relative_contract).resolve()
1465
+ if project != contract and project not in contract.parents:
1466
+ runtime_gate_result = {
1467
+ "ok": False,
1468
+ "errors": ["TiRTC runtime semantic contract escapes project root"],
1469
+ }
1470
+ else:
1471
+ runtime_gate_result = verify_runtime_contract(contract, project)
1472
+ if runtime_gate_result["ok"]:
1473
+ runtime_gate = (
1474
+ "SATISFIED",
1475
+ "TiRTC runtime semantic gate passed: "
1476
+ + str(runtime_gate_result.get("summary", "verified")),
1477
+ VERIFICATION_LEVELS["build_verified"],
1478
+ )
1479
+ else:
1480
+ runtime_gate = (
1481
+ "BLOCKED",
1482
+ "TiRTC runtime semantic gate failed: "
1483
+ + "; ".join(runtime_gate_result.get("errors", [])),
1484
+ 0,
1485
+ )
1486
+ artifact_gate = (
1487
+ artifact_file_requirement(data, args.artifact_sha256, project)
1488
+ if selected_phase in {"build", "hil"}
1489
+ else None
1490
+ )
1321
1491
  try:
1322
1492
  assessment = assess_ir(
1323
1493
  data,
@@ -1325,6 +1495,8 @@ def command_assess(args: argparse.Namespace) -> int:
1325
1495
  phase=selected_phase,
1326
1496
  audio_gate=audio_gate,
1327
1497
  video_gate=video_gate,
1498
+ runtime_gate=runtime_gate,
1499
+ artifact_gate=artifact_gate,
1328
1500
  )
1329
1501
  except ValueError as exc:
1330
1502
  print(f"error: {exc}", file=sys.stderr)
@@ -1333,6 +1505,8 @@ def command_assess(args: argparse.Namespace) -> int:
1333
1505
  assessment["audio_semantic_gate"] = audio_gate_result
1334
1506
  if video_gate_result is not None:
1335
1507
  assessment["video_semantic_gate"] = video_gate_result
1508
+ if runtime_gate_result is not None:
1509
+ assessment["runtime_semantic_gate"] = runtime_gate_result
1336
1510
  print(json.dumps(assessment, ensure_ascii=False, indent=2))
1337
1511
  if args.strict:
1338
1512
  statuses = {item["status"] for item in assessment["features"].values()}
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Install the portable TiRTC runtime protocol gate into an ESP-IDF project."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ MARKER = "# tirtc-runtime-semantic-gate-v1"
13
+ BLOCK = f"""
14
+
15
+ {MARKER}
16
+ add_custom_target(tirtc_runtime_semantic_gate ALL
17
+ COMMAND "${{PYTHON}}"
18
+ "${{CMAKE_CURRENT_LIST_DIR}}/tools/verify_runtime_contract.py"
19
+ "${{CMAKE_CURRENT_LIST_DIR}}/tirtc-runtime-contract.json"
20
+ --project "${{CMAKE_CURRENT_LIST_DIR}}"
21
+ --evidence-out "${{CMAKE_BINARY_DIR}}/runtime-contract-evidence.json"
22
+ COMMENT "Validating TiRTC endpoint, callbacks, streams and AI media negotiation"
23
+ VERBATIM
24
+ )
25
+ add_dependencies(${{CMAKE_PROJECT_NAME}}.elf tirtc_runtime_semantic_gate)
26
+ """
27
+
28
+
29
+ def install(project: Path) -> None:
30
+ project = project.expanduser().resolve()
31
+ cmake = project / "CMakeLists.txt"
32
+ contract = project / "tirtc-runtime-contract.json"
33
+ if not cmake.is_file():
34
+ raise ValueError(f"ESP-IDF project CMakeLists.txt not found: {cmake}")
35
+ if not contract.is_file():
36
+ raise ValueError(
37
+ f"runtime contract not found: {contract}; create and verify it before installing the gate"
38
+ )
39
+ tools = project / "tools"
40
+ tools.mkdir(parents=True, exist_ok=True)
41
+ destination = tools / "verify_runtime_contract.py"
42
+ source = Path(__file__).resolve().with_name("runtime_contract.py")
43
+ if destination.exists() and destination.read_bytes() != source.read_bytes():
44
+ raise ValueError(f"refusing to overwrite a different gate: {destination}")
45
+ if not destination.exists():
46
+ shutil.copyfile(source, destination)
47
+ text = cmake.read_text(encoding="utf-8")
48
+ if MARKER not in text:
49
+ cmake.write_text(text.rstrip() + BLOCK + "\n", encoding="utf-8")
50
+
51
+
52
+ def main() -> int:
53
+ parser = argparse.ArgumentParser()
54
+ parser.add_argument("project", type=Path)
55
+ args = parser.parse_args()
56
+ try:
57
+ install(args.project)
58
+ except ValueError as exc:
59
+ print(f"error: {exc}", file=sys.stderr)
60
+ return 2
61
+ print(f"installed TiRTC runtime semantic gate: {args.project.resolve()}")
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ raise SystemExit(main())
@@ -41,8 +41,8 @@ def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
41
41
  missing_sdk = [str(path.relative_to(project)) for path in sdk_required if not path.is_file()]
42
42
  if missing_sdk:
43
43
  errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
44
- if export and (project / "build" / "CMakeCache.txt").exists():
45
- errors.append("export source contains build/CMakeCache.txt from another machine")
44
+ if export and (project / "build").exists():
45
+ errors.append("export source contains a machine-bound build/ directory")
46
46
 
47
47
  for path in source_files(project):
48
48
  relative = path.relative_to(project)