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
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env python3
2
+ """Verify the generated project's ThingConnect/TiRTC runtime protocol 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
+ CALLBACKS = (
16
+ "on_event",
17
+ "on_conn_accepted",
18
+ "on_conn_error",
19
+ "on_disconnected",
20
+ "on_ai_connect",
21
+ "on_audio",
22
+ "on_video",
23
+ "on_command",
24
+ "on_request_key_frame",
25
+ "on_subscribe_audio",
26
+ "on_subscribe_video",
27
+ "on_unsubscribe_audio",
28
+ "on_unsubscribe_video",
29
+ )
30
+ FORBIDDEN_CALLBACK_CALLS = (
31
+ "TiRtcDisconnect(",
32
+ "TiRtcStop(",
33
+ "TiRtcUninit(",
34
+ )
35
+ VIDEO_PROFILES = {
36
+ "mjpeg": ("TIRTC_VIDEO_JPEG", "complete_jpeg"),
37
+ "h264": ("TIRTC_VIDEO_H264", "annex_b_access_unit"),
38
+ "h265": ("TIRTC_VIDEO_H265", "annex_b_access_unit"),
39
+ }
40
+
41
+
42
+ def sha256_file(path: Path) -> str:
43
+ digest = hashlib.sha256()
44
+ with path.open("rb") as stream:
45
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
46
+ digest.update(chunk)
47
+ return digest.hexdigest()
48
+
49
+
50
+ def project_file(project: Path, value: Any, label: str) -> Path:
51
+ if not isinstance(value, str) or not value.strip():
52
+ raise ValueError(f"{label} must be a non-empty project-relative path")
53
+ relative = Path(value)
54
+ if relative.is_absolute():
55
+ raise ValueError(f"{label} must be project-relative, got {value!r}")
56
+ resolved = (project / relative).resolve()
57
+ if project != resolved and project not in resolved.parents:
58
+ raise ValueError(f"{label} escapes project root: {value!r}")
59
+ return resolved
60
+
61
+
62
+ def function_body(text: str, name: str) -> str | None:
63
+ pattern = re.compile(rf"\b{re.escape(name)}\s*\([^;]*?\)\s*\{{", re.DOTALL)
64
+ match = pattern.search(text)
65
+ if match is None:
66
+ return None
67
+ brace = text.find("{", match.start())
68
+ depth = 0
69
+ for index in range(brace, len(text)):
70
+ if text[index] == "{":
71
+ depth += 1
72
+ elif text[index] == "}":
73
+ depth -= 1
74
+ if depth == 0:
75
+ return text[brace : index + 1]
76
+ return None
77
+
78
+
79
+ def require_tokens(
80
+ text: str, label: str, tokens: tuple[str, ...], errors: list[str]
81
+ ) -> None:
82
+ compact = re.sub(r"\s+", "", text)
83
+ for token in tokens:
84
+ if re.sub(r"\s+", "", token) not in compact:
85
+ errors.append(f"{label} is missing protocol token: {token}")
86
+
87
+
88
+ def check_platform_contract(platform: Any, errors: list[str]) -> None:
89
+ if not isinstance(platform, dict) or platform.get("schema_version") != 1:
90
+ errors.append("platform media contract schema_version must be 1")
91
+ return
92
+ expected_audio = {
93
+ ("h5", "audio_up"): (10, "alaw", 8000, 1),
94
+ ("h5", "audio_down"): (14, "alaw", 8000, 1),
95
+ ("ai", "audio_up"): (1, "alaw", 8000, 1),
96
+ ("ai", "audio_down"): (1, "alaw", 8000, 1),
97
+ }
98
+ for (section, direction), expected in expected_audio.items():
99
+ parent = platform.get(section)
100
+ actual = parent.get(direction) if isinstance(parent, dict) else None
101
+ if not isinstance(actual, dict):
102
+ errors.append(f"platform contract is missing {section}.{direction}")
103
+ continue
104
+ values = (
105
+ actual.get("stream_id"),
106
+ actual.get("codec"),
107
+ actual.get("sample_rate_hz"),
108
+ actual.get("channels"),
109
+ )
110
+ if values != expected or actual.get("media") != "TIRTC_AUDIO_ALAW" or actual.get(
111
+ "flags"
112
+ ) != "TIRTC_AUDIOSAMPLE_8K16B1C":
113
+ errors.append(
114
+ f"platform {section}.{direction} must be stream/codec/rate/channels "
115
+ f"{expected} with TiRTC A-law 8k flags"
116
+ )
117
+ h5 = platform.get("h5")
118
+ video = h5.get("video_up") if isinstance(h5, dict) else None
119
+ profiles = video.get("supported_profiles") if isinstance(video, dict) else None
120
+ if not isinstance(video, dict) or video.get("stream_id") != 11 or not isinstance(
121
+ profiles, list
122
+ ):
123
+ errors.append("platform contract must define H5 video stream 11 profiles")
124
+ else:
125
+ actual_profiles = {
126
+ item.get("codec"): (item.get("media"), item.get("send_boundary"))
127
+ for item in profiles
128
+ if isinstance(item, dict) and isinstance(item.get("codec"), str)
129
+ }
130
+ if actual_profiles != VIDEO_PROFILES:
131
+ errors.append(
132
+ "platform H5 video profiles must declare MJPEG, H.264 and H.265 "
133
+ "with their exact TiRTC media and send boundaries"
134
+ )
135
+ ai = platform.get("ai")
136
+ response = ai.get("start_session_response") if isinstance(ai, dict) else None
137
+ required = response.get("required_fields") if isinstance(response, dict) else None
138
+ if (
139
+ not isinstance(required, list)
140
+ or not {
141
+ "id",
142
+ "result.session_id",
143
+ "result.input_audio",
144
+ "result.output_audio",
145
+ }.issubset(set(required))
146
+ or response.get("response_formats_authoritative") is not True
147
+ ):
148
+ errors.append(
149
+ "platform AI contract must make session_id and response audio formats authoritative"
150
+ )
151
+
152
+
153
+ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
154
+ project = project_path.expanduser().resolve()
155
+ contract_file = contract_path.expanduser().resolve()
156
+ errors: list[str] = []
157
+ inputs: dict[str, str] = {}
158
+ if not project.is_dir():
159
+ return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
160
+ if not contract_file.is_file():
161
+ return {"ok": False, "errors": [f"runtime contract does not exist: {contract_file}"]}
162
+ if project != contract_file and project not in contract_file.parents:
163
+ return {"ok": False, "errors": ["runtime contract must be inside the project"]}
164
+ try:
165
+ contract = json.loads(contract_file.read_text(encoding="utf-8"))
166
+ except (OSError, json.JSONDecodeError) as exc:
167
+ return {"ok": False, "errors": [f"invalid runtime contract: {exc}"]}
168
+ if not isinstance(contract, dict) or contract.get("schema_version") != 1:
169
+ return {"ok": False, "errors": ["runtime contract schema_version must be 1"]}
170
+ inputs[str(contract_file.relative_to(project))] = sha256_file(contract_file)
171
+
172
+ files = contract.get("files")
173
+ if not isinstance(files, dict):
174
+ return {"ok": False, "errors": ["runtime contract files must be an object"]}
175
+ contents: dict[str, str] = {}
176
+ for name in ("platform_client", "app_main", "starter_tirtc", "starter_runtime"):
177
+ try:
178
+ path = project_file(project, files.get(name), f"files.{name}")
179
+ except ValueError as exc:
180
+ errors.append(str(exc))
181
+ continue
182
+ if not path.is_file():
183
+ errors.append(f"files.{name} does not exist: {path}")
184
+ continue
185
+ contents[name] = path.read_text(encoding="utf-8", errors="replace")
186
+ inputs[str(path.relative_to(project))] = sha256_file(path)
187
+
188
+ try:
189
+ platform_path = project_file(
190
+ project, contract.get("platform_contract"), "platform_contract"
191
+ )
192
+ except ValueError as exc:
193
+ errors.append(str(exc))
194
+ platform_path = None
195
+ if platform_path is not None:
196
+ if not platform_path.is_file():
197
+ errors.append(f"platform media contract does not exist: {platform_path}")
198
+ else:
199
+ inputs[str(platform_path.relative_to(project))] = sha256_file(platform_path)
200
+ try:
201
+ check_platform_contract(
202
+ json.loads(platform_path.read_text(encoding="utf-8")), errors
203
+ )
204
+ except json.JSONDecodeError as exc:
205
+ errors.append(f"invalid platform media contract: {exc}")
206
+
207
+ platform_source = contents.get("platform_client", "")
208
+ app_main = contents.get("app_main", "")
209
+ tirtc = contents.get("starter_tirtc", "")
210
+ runtime = contents.get("starter_runtime", "")
211
+ require_tokens(
212
+ platform_source,
213
+ "platform_client",
214
+ ('"tirtc-srv"', "platform_client_tirtc_endpoint", "s_services.tirtc"),
215
+ errors,
216
+ )
217
+ require_tokens(
218
+ app_main,
219
+ "app_main",
220
+ ("platform_client_tirtc_endpoint()", ".service_endpoint = tirtc_endpoint"),
221
+ errors,
222
+ )
223
+ require_tokens(
224
+ tirtc,
225
+ "starter_tirtc",
226
+ (
227
+ "TIRTC_OPT_SERVICE_ENDPOINT",
228
+ "config->service_endpoint",
229
+ ".stream_id = mode == STARTER_TIRTC_H5 ? H5_AUDIO_STREAM : AI_AUDIO_STREAM",
230
+ ".media = TIRTC_AUDIO_ALAW",
231
+ ".flags = TIRTC_AUDIOSAMPLE_8K16B1C",
232
+ "frame->media != TIRTC_AUDIO_ALAW",
233
+ "frame->flags != TIRTC_AUDIOSAMPLE_8K16B1C",
234
+ "TIRTC_VIDEO_JPEG",
235
+ "TIRTC_VIDEO_H264",
236
+ "TIRTC_VIDEO_H265",
237
+ "defer_disconnect(connection)",
238
+ ),
239
+ errors,
240
+ )
241
+ for callback in CALLBACKS:
242
+ body = function_body(tirtc, callback)
243
+ if body is None:
244
+ errors.append(f"starter_tirtc callback {callback} is missing")
245
+ continue
246
+ for forbidden in FORBIDDEN_CALLBACK_CALLS:
247
+ if forbidden in body:
248
+ errors.append(
249
+ f"starter_tirtc callback {callback} calls forbidden lifecycle API {forbidden[:-1]}"
250
+ )
251
+ worker = function_body(tirtc, "deferred_disconnect_task")
252
+ if worker is None or "TiRtcDisconnect(" not in worker:
253
+ errors.append("deferred disconnect worker must own TiRtcDisconnect")
254
+
255
+ require_tokens(
256
+ runtime,
257
+ "starter_runtime",
258
+ (
259
+ '"input_audio"',
260
+ '"output_audio"',
261
+ '"codec", "alaw"',
262
+ '"sample_rate", 8000',
263
+ '"channels", 1',
264
+ '"session_id"',
265
+ "ai_audio_format_is_alaw_8k_mono(input_audio)",
266
+ "ai_audio_format_is_alaw_8k_mono(output_audio)",
267
+ "has_result && !accepted",
268
+ 'strcmp(method->valuestring, "end_session")',
269
+ ),
270
+ errors,
271
+ )
272
+ ai_handler = function_body(runtime, "handle_ai_command")
273
+ if ai_handler is None or "starter_media_start(STARTER_TIRTC_AI" not in re.sub(
274
+ r"\s+", "", ai_handler
275
+ ):
276
+ errors.append("AI media must start only from the validated command handler")
277
+
278
+ return {
279
+ "ok": not errors,
280
+ "summary": "endpoint + callbacks + H5/AI stream and negotiation contract",
281
+ "inputs": inputs,
282
+ "errors": errors,
283
+ }
284
+
285
+
286
+ def main() -> int:
287
+ parser = argparse.ArgumentParser(
288
+ description="Verify TiRTC endpoint, callbacks, stream metadata, and AI negotiation."
289
+ )
290
+ parser.add_argument("contract", type=Path)
291
+ parser.add_argument("--project", type=Path, required=True)
292
+ parser.add_argument("--json", action="store_true")
293
+ parser.add_argument("--evidence-out", type=Path)
294
+ args = parser.parse_args()
295
+ result = verify_contract(args.contract, args.project)
296
+ if args.evidence_out is not None:
297
+ output = args.evidence_out.expanduser().resolve()
298
+ output.parent.mkdir(parents=True, exist_ok=True)
299
+ output.write_text(
300
+ json.dumps(result, ensure_ascii=False, indent=2) + "\n",
301
+ encoding="utf-8",
302
+ )
303
+ if args.json:
304
+ print(json.dumps(result, ensure_ascii=False, indent=2))
305
+ elif result["ok"]:
306
+ print(f"PASS: TiRTC runtime contract: {result['summary']}")
307
+ else:
308
+ for error in result["errors"]:
309
+ print(f"FAIL: {error}", file=sys.stderr)
310
+ return 0 if result["ok"] else 3
311
+
312
+
313
+ if __name__ == "__main__":
314
+ raise SystemExit(main())
@@ -17,6 +17,11 @@ VIDEO_MEDIA = {
17
17
  "h264": "TIRTC_VIDEO_H264",
18
18
  "h265": "TIRTC_VIDEO_H265",
19
19
  }
20
+ VIDEO_BOUNDARY = {
21
+ "mjpeg": "complete_jpeg",
22
+ "h264": "annex_b_access_unit",
23
+ "h265": "annex_b_access_unit",
24
+ }
20
25
 
21
26
 
22
27
  def sha256_file(path: Path) -> str:
@@ -96,6 +101,74 @@ def check_dependencies(
96
101
  )
97
102
 
98
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
+
99
172
  def check_scheduler(
100
173
  project: Path,
101
174
  contract: dict[str, Any],
@@ -288,6 +361,7 @@ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
288
361
  ):
289
362
  errors.append("evidence must contain at least two non-empty source IDs")
290
363
  check_dependencies(project, contract, errors, inputs)
364
+ check_platform_contract(project, contract, errors, inputs)
291
365
  check_scheduler(project, contract, errors, inputs)
292
366
  check_pipeline(contract, errors)
293
367
  check_assertions(project, contract, errors, inputs)