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