tirtc-device-builder 0.5.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 (32) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -36
  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-audio-contract.example.json +66 -0
  10. package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +95 -0
  11. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +12 -7
  12. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +6 -0
  13. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +80 -0
  14. package/skills/tirtc-esp32-builder/assets/report-template.md +16 -1
  15. package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +10 -0
  16. package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
  17. package/skills/tirtc-esp32-builder/references/capability-rules.md +3 -3
  18. package/skills/tirtc-esp32-builder/references/environment.md +8 -0
  19. package/skills/tirtc-esp32-builder/references/hardware-ir.md +8 -2
  20. package/skills/tirtc-esp32-builder/references/reporting.md +4 -2
  21. package/skills/tirtc-esp32-builder/references/runtime-contract.md +34 -0
  22. package/skills/tirtc-esp32-builder/references/video-contract.md +31 -0
  23. package/skills/tirtc-esp32-builder/references/workflow.md +4 -2
  24. package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
  25. package/skills/tirtc-esp32-builder/scripts/doctor.py +7 -1
  26. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +399 -6
  27. package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
  28. package/skills/tirtc-esp32-builder/scripts/install_runtime_gate.py +66 -0
  29. package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
  30. package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
  31. package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +314 -0
  32. package/skills/tirtc-esp32-builder/scripts/video_contract.py +407 -0
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env python3
2
+ """Verify a project-local TiRTC video pipeline contract."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ VIDEO_MEDIA = {
16
+ "mjpeg": "TIRTC_VIDEO_JPEG",
17
+ "h264": "TIRTC_VIDEO_H264",
18
+ "h265": "TIRTC_VIDEO_H265",
19
+ }
20
+ VIDEO_BOUNDARY = {
21
+ "mjpeg": "complete_jpeg",
22
+ "h264": "annex_b_access_unit",
23
+ "h265": "annex_b_access_unit",
24
+ }
25
+
26
+
27
+ def sha256_file(path: Path) -> str:
28
+ digest = hashlib.sha256()
29
+ with path.open("rb") as stream:
30
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
31
+ digest.update(chunk)
32
+ return digest.hexdigest()
33
+
34
+
35
+ def project_file(project: Path, value: Any, label: str) -> Path:
36
+ if not isinstance(value, str) or not value.strip():
37
+ raise ValueError(f"{label} must be a non-empty project-relative path")
38
+ relative = Path(value)
39
+ if relative.is_absolute():
40
+ raise ValueError(f"{label} must be project-relative, got {value!r}")
41
+ resolved = (project / relative).resolve()
42
+ if project != resolved and project not in resolved.parents:
43
+ raise ValueError(f"{label} escapes project root: {value!r}")
44
+ return resolved
45
+
46
+
47
+ def mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]:
48
+ if not isinstance(value, dict):
49
+ errors.append(f"{label} must be an object")
50
+ return {}
51
+ return value
52
+
53
+
54
+ def positive_int(value: Any, label: str, errors: list[str]) -> int | None:
55
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
56
+ errors.append(f"{label} must be a positive integer")
57
+ return None
58
+ return value
59
+
60
+
61
+ def lock_versions(lock_text: str) -> dict[str, str]:
62
+ versions: dict[str, str] = {}
63
+ current: str | None = None
64
+ for line in lock_text.splitlines():
65
+ match = re.match(r"^ ([^ ].*):$", line)
66
+ if match:
67
+ current = match.group(1)
68
+ continue
69
+ if current is not None:
70
+ version = re.match(r"^ version:\s*['\"]?([^'\"\s]+)", line)
71
+ if version:
72
+ versions[current] = version.group(1)
73
+ current = None
74
+ return versions
75
+
76
+
77
+ def check_dependencies(
78
+ project: Path,
79
+ contract: dict[str, Any],
80
+ errors: list[str],
81
+ inputs: dict[str, str],
82
+ ) -> None:
83
+ dependencies = contract.get("dependencies")
84
+ if not isinstance(dependencies, dict) or not dependencies:
85
+ errors.append("dependencies must map component names to exact locked versions")
86
+ return
87
+ lock = project / "dependencies.lock"
88
+ if not lock.is_file():
89
+ errors.append("dependencies.lock is missing")
90
+ return
91
+ text = lock.read_text(encoding="utf-8", errors="replace")
92
+ inputs["dependencies.lock"] = sha256_file(lock)
93
+ locked = lock_versions(text)
94
+ for name, expected in dependencies.items():
95
+ if not isinstance(name, str) or not isinstance(expected, str) or not expected:
96
+ errors.append("dependencies entries must be non-empty string pairs")
97
+ elif locked.get(name) != expected:
98
+ errors.append(
99
+ f"locked dependency mismatch for {name}: "
100
+ f"expected {expected}, got {locked.get(name, 'missing')}"
101
+ )
102
+
103
+
104
+ def check_platform_contract(
105
+ project: Path,
106
+ contract: dict[str, Any],
107
+ errors: list[str],
108
+ inputs: dict[str, str],
109
+ ) -> None:
110
+ try:
111
+ path = project_file(
112
+ project, contract.get("platform_contract"), "platform_contract"
113
+ )
114
+ except ValueError as exc:
115
+ errors.append(str(exc))
116
+ return
117
+ if not path.is_file():
118
+ errors.append(f"platform media contract does not exist: {path}")
119
+ return
120
+ try:
121
+ platform = json.loads(path.read_text(encoding="utf-8"))
122
+ except (OSError, json.JSONDecodeError) as exc:
123
+ errors.append(f"invalid platform media contract: {exc}")
124
+ return
125
+ inputs[str(path.relative_to(project))] = sha256_file(path)
126
+ if not isinstance(platform, dict) or platform.get("schema_version") != 1:
127
+ errors.append("platform media contract schema_version must be 1")
128
+ return
129
+ h5 = platform.get("h5")
130
+ video = h5.get("video_up") if isinstance(h5, dict) else None
131
+ if not isinstance(video, dict) or video.get("stream_id") != 11:
132
+ errors.append("platform media contract must define H5 video_up stream 11")
133
+ return
134
+ profiles = video.get("supported_profiles")
135
+ if not isinstance(profiles, list):
136
+ errors.append("platform H5 video supported_profiles must be an array")
137
+ return
138
+ supported: dict[str, dict[str, Any]] = {}
139
+ for index, item in enumerate(profiles):
140
+ if not isinstance(item, dict):
141
+ errors.append(
142
+ f"platform supported_profiles[{index}] must be an object"
143
+ )
144
+ continue
145
+ codec = item.get("codec")
146
+ if codec not in VIDEO_MEDIA:
147
+ errors.append(
148
+ f"platform supported_profiles[{index}].codec is unsupported"
149
+ )
150
+ continue
151
+ if codec in supported:
152
+ errors.append(f"platform video profile {codec} is duplicated")
153
+ continue
154
+ if item.get("media") != VIDEO_MEDIA[codec]:
155
+ errors.append(
156
+ f"platform video profile {codec} media must be {VIDEO_MEDIA[codec]}"
157
+ )
158
+ if item.get("send_boundary") != VIDEO_BOUNDARY[codec]:
159
+ errors.append(
160
+ f"platform video profile {codec} boundary must be "
161
+ f"{VIDEO_BOUNDARY[codec]}"
162
+ )
163
+ supported[codec] = item
164
+ camera = contract.get("camera")
165
+ selected = camera.get("codec") if isinstance(camera, dict) else None
166
+ if selected in VIDEO_MEDIA and selected not in supported:
167
+ errors.append(
168
+ f"board-selected codec {selected} is not supported by platform contract"
169
+ )
170
+
171
+
172
+ def check_scheduler(
173
+ project: Path,
174
+ contract: dict[str, Any],
175
+ errors: list[str],
176
+ inputs: dict[str, str],
177
+ ) -> None:
178
+ scheduler = mapping(contract.get("scheduler"), "scheduler", errors)
179
+ wifi_core = scheduler.get("wifi_core")
180
+ camera_core = scheduler.get("camera_core")
181
+ for value, label in ((wifi_core, "wifi_core"), (camera_core, "camera_core")):
182
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
183
+ errors.append(f"scheduler.{label} must be an integer >= 0")
184
+ if isinstance(wifi_core, int) and isinstance(camera_core, int) and wifi_core == camera_core:
185
+ errors.append("camera event processing must not share the configured Wi-Fi core")
186
+ config_files = scheduler.get("config_files")
187
+ if not isinstance(config_files, list) or not config_files:
188
+ errors.append("scheduler.config_files must be a non-empty array")
189
+ return
190
+ for index, value in enumerate(config_files):
191
+ try:
192
+ config = project_file(project, value, f"scheduler.config_files[{index}]")
193
+ except ValueError as exc:
194
+ errors.append(str(exc))
195
+ continue
196
+ if not config.is_file():
197
+ errors.append(f"scheduler config does not exist: {config}")
198
+ continue
199
+ text = config.read_text(encoding="utf-8", errors="replace")
200
+ inputs[str(config.relative_to(project))] = sha256_file(config)
201
+ if isinstance(camera_core, int) and f"CONFIG_CAMERA_CORE{camera_core}=y" not in text:
202
+ errors.append(f"{config.relative_to(project)} does not select camera core {camera_core}")
203
+ if isinstance(wifi_core, int) and f"CONFIG_CAMERA_CORE{wifi_core}=y" in text:
204
+ errors.append(f"{config.relative_to(project)} pins camera processing to Wi-Fi core {wifi_core}")
205
+ wifi_config_files = scheduler.get("wifi_config_files")
206
+ if not isinstance(wifi_config_files, list) or not wifi_config_files:
207
+ errors.append("scheduler.wifi_config_files must be a non-empty array")
208
+ return
209
+ for index, value in enumerate(wifi_config_files):
210
+ try:
211
+ config = project_file(
212
+ project, value, f"scheduler.wifi_config_files[{index}]"
213
+ )
214
+ except ValueError as exc:
215
+ errors.append(str(exc))
216
+ continue
217
+ if not config.is_file():
218
+ errors.append(f"Wi-Fi scheduler config does not exist: {config}")
219
+ continue
220
+ text = config.read_text(encoding="utf-8", errors="replace")
221
+ inputs[str(config.relative_to(project))] = sha256_file(config)
222
+ if (
223
+ isinstance(wifi_core, int)
224
+ and f"CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_{wifi_core}=y" not in text
225
+ ):
226
+ errors.append(
227
+ f"{config.relative_to(project)} does not pin Wi-Fi task to core {wifi_core}"
228
+ )
229
+
230
+
231
+ def check_pipeline(contract: dict[str, Any], errors: list[str]) -> None:
232
+ camera = mapping(contract.get("camera"), "camera", errors)
233
+ codec = camera.get("codec")
234
+ if codec not in VIDEO_MEDIA:
235
+ errors.append("camera.codec must be one of h264, h265, mjpeg")
236
+ else:
237
+ boundary_field = (
238
+ "complete_jpeg_per_send"
239
+ if codec == "mjpeg"
240
+ else "complete_access_unit_per_send"
241
+ )
242
+ if camera.get(boundary_field) is not True:
243
+ errors.append(f"camera.{boundary_field} must be true")
244
+ expected_media = VIDEO_MEDIA[codec]
245
+ if camera.get("media") != expected_media:
246
+ errors.append(f"camera.media must be {expected_media} for {codec}")
247
+ if camera.get("stream_id") != 11:
248
+ errors.append("camera.stream_id must be 11 for the H5 video contract")
249
+ frame_buffers = positive_int(camera.get("frame_buffers"), "camera.frame_buffers", errors)
250
+ if frame_buffers is not None and frame_buffers < 2:
251
+ errors.append("camera.frame_buffers must be at least 2 for the selected realtime pipeline")
252
+ accepted_pids = camera.get("accepted_sensor_pids")
253
+ if (
254
+ not isinstance(accepted_pids, list)
255
+ or not accepted_pids
256
+ or any(
257
+ isinstance(pid, bool)
258
+ or not isinstance(pid, int)
259
+ or pid < 0
260
+ or pid > 0xFFFF
261
+ for pid in accepted_pids
262
+ )
263
+ or len(set(accepted_pids)) != len(accepted_pids)
264
+ ):
265
+ errors.append(
266
+ "camera.accepted_sensor_pids must be a non-empty unique uint16 array"
267
+ )
268
+ if camera.get("unknown_sensor_policy") != "reject":
269
+ errors.append("camera.unknown_sensor_policy must be reject")
270
+
271
+ memory = mapping(contract.get("memory"), "memory", errors)
272
+ send_buffer = positive_int(
273
+ memory.get("max_send_buffer_bytes"), "memory.max_send_buffer_bytes", errors
274
+ )
275
+ frame_field = (
276
+ "max_complete_jpeg_bytes"
277
+ if codec == "mjpeg"
278
+ else "max_complete_access_unit_bytes"
279
+ )
280
+ complete_frame = positive_int(
281
+ memory.get(frame_field), f"memory.{frame_field}", errors
282
+ )
283
+ backpressure = positive_int(
284
+ memory.get("video_backpressure_bytes"), "memory.video_backpressure_bytes", errors
285
+ )
286
+ if None not in (send_buffer, complete_frame, backpressure):
287
+ if complete_frame > backpressure:
288
+ errors.append(
289
+ "one complete encoded frame/access unit must fit below the "
290
+ "video backpressure threshold"
291
+ )
292
+ if backpressure >= send_buffer:
293
+ errors.append("video backpressure threshold must be lower than max send buffer")
294
+
295
+
296
+ def check_assertions(
297
+ project: Path,
298
+ contract: dict[str, Any],
299
+ errors: list[str],
300
+ inputs: dict[str, str],
301
+ ) -> None:
302
+ assertions = contract.get("implementation_assertions")
303
+ if not isinstance(assertions, list) or not assertions:
304
+ errors.append("implementation_assertions must be a non-empty array")
305
+ return
306
+ for index, item in enumerate(assertions):
307
+ label = f"implementation_assertions[{index}]"
308
+ assertion = mapping(item, label, errors)
309
+ try:
310
+ source = project_file(project, assertion.get("file"), f"{label}.file")
311
+ except ValueError as exc:
312
+ errors.append(str(exc))
313
+ continue
314
+ if not source.is_file():
315
+ errors.append(f"{label}.file does not exist: {source}")
316
+ continue
317
+ text = source.read_text(encoding="utf-8", errors="replace")
318
+ compact = re.sub(r"\s+", "", text)
319
+ inputs[str(source.relative_to(project))] = sha256_file(source)
320
+ for field, haystack, should_exist in (
321
+ ("contains", text, True),
322
+ ("contains_compact", compact, True),
323
+ ("absent", text, False),
324
+ ("absent_compact", compact, False),
325
+ ):
326
+ needles = assertion.get(field, [])
327
+ if not isinstance(needles, list) or any(
328
+ not isinstance(needle, str) or not needle for needle in needles
329
+ ):
330
+ errors.append(f"{label}.{field} must be an array of non-empty strings")
331
+ continue
332
+ for needle in needles:
333
+ found = needle in haystack
334
+ if found != should_exist:
335
+ verb = "missing" if should_exist else "contains forbidden"
336
+ errors.append(f"{label} {verb} {field} token: {needle}")
337
+
338
+
339
+ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
340
+ project = project_path.expanduser().resolve()
341
+ contract_file = contract_path.expanduser().resolve()
342
+ if not project.is_dir():
343
+ return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
344
+ if not contract_file.is_file():
345
+ return {"ok": False, "errors": [f"video contract does not exist: {contract_file}"]}
346
+ if project != contract_file and project not in contract_file.parents:
347
+ return {"ok": False, "errors": ["video contract must be inside the project"]}
348
+ try:
349
+ contract = json.loads(contract_file.read_text(encoding="utf-8"))
350
+ except (OSError, json.JSONDecodeError) as exc:
351
+ return {"ok": False, "errors": [f"invalid video contract: {exc}"]}
352
+ if not isinstance(contract, dict):
353
+ return {"ok": False, "errors": ["video contract root must be an object"]}
354
+ errors: list[str] = []
355
+ inputs = {str(contract_file.relative_to(project)): sha256_file(contract_file)}
356
+ if contract.get("schema_version") != 1:
357
+ errors.append("schema_version must be 1")
358
+ evidence = contract.get("evidence")
359
+ if not isinstance(evidence, list) or len(evidence) < 2 or any(
360
+ not isinstance(item, str) or not item for item in evidence
361
+ ):
362
+ errors.append("evidence must contain at least two non-empty source IDs")
363
+ check_dependencies(project, contract, errors, inputs)
364
+ check_platform_contract(project, contract, errors, inputs)
365
+ check_scheduler(project, contract, errors, inputs)
366
+ check_pipeline(contract, errors)
367
+ check_assertions(project, contract, errors, inputs)
368
+ camera = contract.get("camera", {})
369
+ memory = contract.get("memory", {})
370
+ return {
371
+ "ok": not errors,
372
+ "summary": (
373
+ f"codec={camera.get('codec')} stream={camera.get('stream_id')} "
374
+ f"buffers={camera.get('frame_buffers')} "
375
+ f"send_buffer={memory.get('max_send_buffer_bytes')}"
376
+ ),
377
+ "inputs": inputs,
378
+ "errors": errors,
379
+ }
380
+
381
+
382
+ def main() -> int:
383
+ parser = argparse.ArgumentParser(
384
+ description="Verify locked camera dependencies, scheduler isolation, JPEG framing, memory, and adapter assertions."
385
+ )
386
+ parser.add_argument("contract", type=Path)
387
+ parser.add_argument("--project", type=Path, required=True)
388
+ parser.add_argument("--json", action="store_true")
389
+ parser.add_argument("--evidence-out", type=Path)
390
+ args = parser.parse_args()
391
+ result = verify_contract(args.contract, args.project)
392
+ if args.evidence_out is not None:
393
+ output = args.evidence_out.expanduser().resolve()
394
+ output.parent.mkdir(parents=True, exist_ok=True)
395
+ output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
396
+ if args.json:
397
+ print(json.dumps(result, ensure_ascii=False, indent=2))
398
+ elif result["ok"]:
399
+ print(f"PASS: video semantic contract: {result['summary']}")
400
+ else:
401
+ for error in result["errors"]:
402
+ print(f"FAIL: {error}", file=sys.stderr)
403
+ return 0 if result["ok"] else 3
404
+
405
+
406
+ if __name__ == "__main__":
407
+ raise SystemExit(main())