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.
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +36 -36
- package/bin/esp32-kit-metadata.js +7 -6
- package/bin/install-esp32-kit.js +2 -0
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +4 -4
- package/skills/tirtc-esp32-builder/USAGE.md +1 -1
- package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +66 -0
- package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +95 -0
- package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +12 -7
- package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +6 -0
- package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +80 -0
- package/skills/tirtc-esp32-builder/assets/report-template.md +16 -1
- package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +10 -0
- package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
- package/skills/tirtc-esp32-builder/references/capability-rules.md +3 -3
- package/skills/tirtc-esp32-builder/references/environment.md +8 -0
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +8 -2
- package/skills/tirtc-esp32-builder/references/reporting.md +4 -2
- package/skills/tirtc-esp32-builder/references/runtime-contract.md +34 -0
- package/skills/tirtc-esp32-builder/references/video-contract.md +31 -0
- package/skills/tirtc-esp32-builder/references/workflow.md +4 -2
- package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
- package/skills/tirtc-esp32-builder/scripts/doctor.py +7 -1
- package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +399 -6
- package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
- package/skills/tirtc-esp32-builder/scripts/install_runtime_gate.py +66 -0
- package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
- package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
- package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +314 -0
- package/skills/tirtc-esp32-builder/scripts/video_contract.py +407 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install the portable TiRTC video semantic 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-video-semantic-gate-v1"
|
|
13
|
+
BLOCK = f"""
|
|
14
|
+
|
|
15
|
+
{MARKER}
|
|
16
|
+
add_custom_target(tirtc_video_semantic_gate ALL
|
|
17
|
+
COMMAND "${{PYTHON}}"
|
|
18
|
+
"${{CMAKE_CURRENT_LIST_DIR}}/tools/verify_video_contract.py"
|
|
19
|
+
"${{CMAKE_CURRENT_LIST_DIR}}/board-video-contract.json"
|
|
20
|
+
--project "${{CMAKE_CURRENT_LIST_DIR}}"
|
|
21
|
+
--evidence-out "${{CMAKE_BINARY_DIR}}/video-contract-evidence.json"
|
|
22
|
+
COMMENT "Validating camera lock, CPU isolation, complete JPEG framing, backpressure and memory"
|
|
23
|
+
VERBATIM
|
|
24
|
+
)
|
|
25
|
+
add_dependencies(${{CMAKE_PROJECT_NAME}}.elf tirtc_video_semantic_gate)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def install(project: Path) -> None:
|
|
30
|
+
project = project.expanduser().resolve()
|
|
31
|
+
cmake = project / "CMakeLists.txt"
|
|
32
|
+
contract = project / "board-video-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"video 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_video_contract.py"
|
|
42
|
+
source = Path(__file__).resolve().with_name("video_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 video semantic gate: {args.project.resolve()}")
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check that a generated TiRTC ESP32 source package can move to another machine."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ABSOLUTE_PATH = re.compile(r"(?:^|[\s\"'])(?:/home/|/root/|[A-Za-z]:[\\/])")
|
|
15
|
+
BUILD_INPUT_NAMES = {"CMakeLists.txt", "idf_component.yml", "idf_component.yaml"}
|
|
16
|
+
BUILD_INPUT_SUFFIXES = {".c", ".h", ".cmake", ".py", ".sh"}
|
|
17
|
+
SKIP_PARTS = {"build", "managed_components", ".git"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def source_files(project: Path):
|
|
21
|
+
for path in project.rglob("*"):
|
|
22
|
+
relative = path.relative_to(project)
|
|
23
|
+
if not path.is_file() or SKIP_PARTS.intersection(relative.parts):
|
|
24
|
+
continue
|
|
25
|
+
if path.name in BUILD_INPUT_NAMES or path.suffix.lower() in BUILD_INPUT_SUFFIXES:
|
|
26
|
+
yield path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
|
|
30
|
+
project = project_path.expanduser().resolve()
|
|
31
|
+
errors: list[str] = []
|
|
32
|
+
if not (project / "CMakeLists.txt").is_file():
|
|
33
|
+
return {"ok": False, "errors": ["CMakeLists.txt is missing"]}
|
|
34
|
+
if not (project / "dependencies.lock").is_file():
|
|
35
|
+
errors.append("dependencies.lock is missing")
|
|
36
|
+
sdk_required = (
|
|
37
|
+
project / "third_party" / "tirtc" / "include" / "tirtc" / "tiRTC.h",
|
|
38
|
+
project / "third_party" / "tirtc" / "lib" / "libTiRTC.a",
|
|
39
|
+
project / "third_party" / "tirtc" / "manifest" / "build-contract.env",
|
|
40
|
+
)
|
|
41
|
+
missing_sdk = [str(path.relative_to(project)) for path in sdk_required if not path.is_file()]
|
|
42
|
+
if missing_sdk:
|
|
43
|
+
errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
|
|
44
|
+
if export and (project / "build").exists():
|
|
45
|
+
errors.append("export source contains a machine-bound build/ directory")
|
|
46
|
+
|
|
47
|
+
for path in source_files(project):
|
|
48
|
+
relative = path.relative_to(project)
|
|
49
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
50
|
+
if ABSOLUTE_PATH.search(text):
|
|
51
|
+
errors.append(f"build input contains a machine-specific absolute path: {relative}")
|
|
52
|
+
if path.name == "CMakeLists.txt" or path.suffix.lower() == ".cmake":
|
|
53
|
+
for number, line in enumerate(text.splitlines(), 1):
|
|
54
|
+
stripped = line.strip()
|
|
55
|
+
if stripped.startswith("COMMAND ") and ".sh" in stripped:
|
|
56
|
+
command = stripped[len("COMMAND ") :].lstrip('"')
|
|
57
|
+
if not command.startswith("bash ") and not command.startswith("${BASH"):
|
|
58
|
+
errors.append(
|
|
59
|
+
f"shell gate relies on executable permission: {relative}:{number}"
|
|
60
|
+
)
|
|
61
|
+
return {"ok": not errors, "errors": errors}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> int:
|
|
65
|
+
parser = argparse.ArgumentParser()
|
|
66
|
+
parser.add_argument("project", type=Path)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--export",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="also reject a carried build/CMakeCache.txt",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("--json", action="store_true")
|
|
73
|
+
args = parser.parse_args()
|
|
74
|
+
result = check_project(args.project, export=args.export)
|
|
75
|
+
if args.json:
|
|
76
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
77
|
+
elif result["ok"]:
|
|
78
|
+
print("PASS: project source is relocatable")
|
|
79
|
+
else:
|
|
80
|
+
for error in result["errors"]:
|
|
81
|
+
print(f"FAIL: {error}", file=sys.stderr)
|
|
82
|
+
return 0 if result["ok"] else 3
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
|
@@ -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())
|