tirtc-device-builder 0.7.2 → 0.8.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 +23 -0
- package/README.md +44 -14
- package/bin/tirtc-device-builder.js +33 -1
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +139 -39
- package/skills/tirtc-esp32-builder/USAGE.md +3 -2
- package/skills/tirtc-esp32-builder/VERSION +1 -1
- package/skills/tirtc-esp32-builder/agents/openai.yaml +1 -1
- package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +4 -0
- package/skills/tirtc-esp32-builder/assets/board-identity.example.json +16 -0
- package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +3 -3
- package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +9 -1
- package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +5 -5
- package/skills/tirtc-esp32-builder/assets/report-template.md +6 -1
- package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +13 -0
- package/skills/tirtc-esp32-builder/knowledge/board-registry.json +4 -0
- package/skills/tirtc-esp32-builder/references/audio-contract.md +33 -3
- package/skills/tirtc-esp32-builder/references/board-knowledge.md +72 -0
- package/skills/tirtc-esp32-builder/references/capability-rules.md +11 -1
- package/skills/tirtc-esp32-builder/references/environment.md +20 -0
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +17 -3
- package/skills/tirtc-esp32-builder/references/porting-risks.md +25 -0
- package/skills/tirtc-esp32-builder/references/reporting.md +1 -1
- package/skills/tirtc-esp32-builder/references/runtime-contract.md +12 -2
- package/skills/tirtc-esp32-builder/references/tirtc-platform.md +92 -0
- package/skills/tirtc-esp32-builder/references/workflow.md +31 -11
- package/skills/tirtc-esp32-builder/scripts/audio_contract.py +105 -3
- package/skills/tirtc-esp32-builder/scripts/board_registry.py +677 -0
- package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +147 -8
- package/skills/tirtc-esp32-builder/scripts/project_portability.py +178 -3
- package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +122 -1
|
@@ -22,8 +22,18 @@ FEATURES = {
|
|
|
22
22
|
"h5_live_video",
|
|
23
23
|
"h5_talkback",
|
|
24
24
|
"ai_talk",
|
|
25
|
+
"device_call",
|
|
26
|
+
"wechat_voip",
|
|
25
27
|
}
|
|
26
|
-
AUDIO_FEATURES = {
|
|
28
|
+
AUDIO_FEATURES = {
|
|
29
|
+
"h5_live_audio",
|
|
30
|
+
"h5_talkback",
|
|
31
|
+
"ai_talk",
|
|
32
|
+
"device_call",
|
|
33
|
+
"wechat_voip",
|
|
34
|
+
}
|
|
35
|
+
AEC_REQUIRED_FEATURES = {"ai_talk", "device_call", "wechat_voip"}
|
|
36
|
+
BUSINESS_FEATURES = {"device_call", "wechat_voip"}
|
|
27
37
|
VIDEO_FEATURES = {"h5_live_video"}
|
|
28
38
|
VERIFICATION_LEVELS = {
|
|
29
39
|
"extracted": 1,
|
|
@@ -63,6 +73,8 @@ FEATURE_HIL_LEVEL = {
|
|
|
63
73
|
"h5_live_video": "L5",
|
|
64
74
|
"h5_talkback": "L5",
|
|
65
75
|
"ai_talk": "L6",
|
|
76
|
+
"device_call": "L6",
|
|
77
|
+
"wechat_voip": "L6",
|
|
66
78
|
}
|
|
67
79
|
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
68
80
|
SOURCE_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.-]*):")
|
|
@@ -345,6 +357,28 @@ def validate_hardware_resources(
|
|
|
345
357
|
errors,
|
|
346
358
|
)
|
|
347
359
|
|
|
360
|
+
duplex = mapping(
|
|
361
|
+
resources.get("duplex_audio", {}),
|
|
362
|
+
"hardware_resources.duplex_audio",
|
|
363
|
+
errors,
|
|
364
|
+
)
|
|
365
|
+
for field in (
|
|
366
|
+
"simultaneous_capture_playback",
|
|
367
|
+
"playback_reference_available",
|
|
368
|
+
"aec_implementation_available",
|
|
369
|
+
):
|
|
370
|
+
nullable_bool(
|
|
371
|
+
duplex.get(field),
|
|
372
|
+
f"hardware_resources.duplex_audio.{field}",
|
|
373
|
+
errors,
|
|
374
|
+
)
|
|
375
|
+
if "verification" in duplex:
|
|
376
|
+
validate_verification(
|
|
377
|
+
duplex.get("verification"),
|
|
378
|
+
"hardware_resources.duplex_audio.verification",
|
|
379
|
+
errors,
|
|
380
|
+
)
|
|
381
|
+
|
|
348
382
|
realtime = mapping(
|
|
349
383
|
resources.get("camera_realtime"),
|
|
350
384
|
"hardware_resources.camera_realtime",
|
|
@@ -826,6 +860,27 @@ def audio_mapping_requirement(
|
|
|
826
860
|
)
|
|
827
861
|
|
|
828
862
|
|
|
863
|
+
def aec_capability_requirement(
|
|
864
|
+
resources: dict[str, Any], minimum_verification: str = "corroborated"
|
|
865
|
+
) -> Requirement:
|
|
866
|
+
duplex = resources.get("duplex_audio", {})
|
|
867
|
+
for field, label in (
|
|
868
|
+
("simultaneous_capture_playback", "simultaneous capture and playback"),
|
|
869
|
+
("playback_reference_available", "physical playback-reference capture path"),
|
|
870
|
+
("aec_implementation_available", "AEC processor implementation"),
|
|
871
|
+
):
|
|
872
|
+
requirement = verified_bool_requirement(
|
|
873
|
+
duplex, field, label, minimum_verification
|
|
874
|
+
)
|
|
875
|
+
if requirement[0] != "SATISFIED":
|
|
876
|
+
return requirement
|
|
877
|
+
return (
|
|
878
|
+
"SATISFIED",
|
|
879
|
+
"full-duplex audio, playback reference, and AEC implementation are resolved",
|
|
880
|
+
VERIFICATION_LEVELS.get(duplex.get("verification"), 0),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
829
884
|
def camera_realtime_requirement(
|
|
830
885
|
resources: dict[str, Any], minimum_verification: str = "corroborated"
|
|
831
886
|
) -> Requirement:
|
|
@@ -979,20 +1034,22 @@ def project_requirements(data: dict[str, Any]) -> list[Requirement]:
|
|
|
979
1034
|
)
|
|
980
1035
|
|
|
981
1036
|
target = data["soc"]["target"].strip().lower()
|
|
982
|
-
if target
|
|
1037
|
+
if target not in {"esp32s3", "esp32p4"}:
|
|
983
1038
|
requirements.append(
|
|
984
1039
|
(
|
|
985
1040
|
"BLOCKED",
|
|
986
|
-
f"
|
|
1041
|
+
f"TiRTC ESP32 workflow supports esp32s3 or esp32p4, not {target}",
|
|
987
1042
|
0,
|
|
988
1043
|
)
|
|
989
1044
|
)
|
|
990
1045
|
else:
|
|
991
|
-
requirements.append(
|
|
1046
|
+
requirements.append(
|
|
1047
|
+
("SATISFIED", f"{target} is a supported TiRTC target", 2)
|
|
1048
|
+
)
|
|
992
1049
|
|
|
993
1050
|
platform = data["toolchain"]["tirtc"]["platform"].strip().lower()
|
|
994
|
-
expected_platform = "espressif-
|
|
995
|
-
if target
|
|
1051
|
+
expected_platform = f"espressif-{target}"
|
|
1052
|
+
if target in {"esp32s3", "esp32p4"} and platform != expected_platform:
|
|
996
1053
|
requirements.append(
|
|
997
1054
|
(
|
|
998
1055
|
"BLOCKED",
|
|
@@ -1113,6 +1170,8 @@ def assess_ir(
|
|
|
1113
1170
|
artifact_sha256: str | None = None,
|
|
1114
1171
|
phase: str | None = None,
|
|
1115
1172
|
audio_gate: Requirement | None = None,
|
|
1173
|
+
aec_gate: Requirement | None = None,
|
|
1174
|
+
business_gate: Requirement | None = None,
|
|
1116
1175
|
video_gate: Requirement | None = None,
|
|
1117
1176
|
runtime_gate: Requirement | None = None,
|
|
1118
1177
|
artifact_gate: Requirement | None = None,
|
|
@@ -1189,6 +1248,14 @@ def assess_ir(
|
|
|
1189
1248
|
audio_output, "audio_output", minimum_verification
|
|
1190
1249
|
),
|
|
1191
1250
|
]
|
|
1251
|
+
if feature in AEC_REQUIRED_FEATURES:
|
|
1252
|
+
requirements.append(
|
|
1253
|
+
(
|
|
1254
|
+
"NEEDS_CONFIRMATION",
|
|
1255
|
+
"AEC-required sessions need Hardware IR v2 duplex_audio evidence",
|
|
1256
|
+
0,
|
|
1257
|
+
)
|
|
1258
|
+
)
|
|
1192
1259
|
result[feature] = combine_requirements(
|
|
1193
1260
|
requirements,
|
|
1194
1261
|
success_status=success_status,
|
|
@@ -1223,6 +1290,10 @@ def assess_ir(
|
|
|
1223
1290
|
audio_mapping_requirement(resources, minimum_verification),
|
|
1224
1291
|
memory_requirement(resources, minimum_verification),
|
|
1225
1292
|
]
|
|
1293
|
+
if feature in AEC_REQUIRED_FEATURES:
|
|
1294
|
+
requirements.append(
|
|
1295
|
+
aec_capability_requirement(resources, minimum_verification)
|
|
1296
|
+
)
|
|
1226
1297
|
if feature in AUDIO_FEATURES and selected_phase in {"build", "hil"}:
|
|
1227
1298
|
requirements.append(
|
|
1228
1299
|
audio_gate
|
|
@@ -1232,6 +1303,15 @@ def assess_ir(
|
|
|
1232
1303
|
0,
|
|
1233
1304
|
)
|
|
1234
1305
|
)
|
|
1306
|
+
if feature in AEC_REQUIRED_FEATURES and selected_phase in {"build", "hil"}:
|
|
1307
|
+
requirements.append(
|
|
1308
|
+
aec_gate
|
|
1309
|
+
or (
|
|
1310
|
+
"BLOCKED",
|
|
1311
|
+
"AEC semantic gate did not prove simultaneous full-duplex audio and enabled echo cancellation",
|
|
1312
|
+
0,
|
|
1313
|
+
)
|
|
1314
|
+
)
|
|
1235
1315
|
if feature in VIDEO_FEATURES and selected_phase in {"build", "hil"}:
|
|
1236
1316
|
requirements.append(
|
|
1237
1317
|
video_gate
|
|
@@ -1250,6 +1330,15 @@ def assess_ir(
|
|
|
1250
1330
|
0,
|
|
1251
1331
|
)
|
|
1252
1332
|
)
|
|
1333
|
+
if feature in BUSINESS_FEATURES and selected_phase in {"build", "hil"}:
|
|
1334
|
+
requirements.append(
|
|
1335
|
+
business_gate
|
|
1336
|
+
or (
|
|
1337
|
+
"BLOCKED",
|
|
1338
|
+
"business runtime gate did not prove the requested call/VoIP protocol and session arbiter",
|
|
1339
|
+
0,
|
|
1340
|
+
)
|
|
1341
|
+
)
|
|
1253
1342
|
result[feature] = combine_requirements(
|
|
1254
1343
|
requirements, success_status=success_status
|
|
1255
1344
|
)
|
|
@@ -1359,6 +1448,7 @@ def command_assess(args: argparse.Namespace) -> int:
|
|
|
1359
1448
|
)
|
|
1360
1449
|
project = (args.project or args.path.parent).expanduser().resolve()
|
|
1361
1450
|
audio_gate: Requirement | None = None
|
|
1451
|
+
aec_gate: Requirement | None = None
|
|
1362
1452
|
audio_gate_result: dict[str, Any] | None = None
|
|
1363
1453
|
if (
|
|
1364
1454
|
data.get("schema_version") == 2
|
|
@@ -1394,6 +1484,24 @@ def command_assess(args: argparse.Namespace) -> int:
|
|
|
1394
1484
|
+ str(audio_gate_result.get("summary", "verified")),
|
|
1395
1485
|
VERIFICATION_LEVELS["build_verified"],
|
|
1396
1486
|
)
|
|
1487
|
+
if AEC_REQUIRED_FEATURES.intersection(
|
|
1488
|
+
data["features"]["requested"]
|
|
1489
|
+
):
|
|
1490
|
+
if (
|
|
1491
|
+
audio_gate_result.get("simultaneous_capture_playback") is True
|
|
1492
|
+
and audio_gate_result.get("echo_cancellation_enabled") is True
|
|
1493
|
+
):
|
|
1494
|
+
aec_gate = (
|
|
1495
|
+
"SATISFIED",
|
|
1496
|
+
"AEC gate proved simultaneous capture/playback with echo cancellation enabled",
|
|
1497
|
+
VERIFICATION_LEVELS["build_verified"],
|
|
1498
|
+
)
|
|
1499
|
+
else:
|
|
1500
|
+
aec_gate = (
|
|
1501
|
+
"BLOCKED",
|
|
1502
|
+
"AI/call/VoIP requires simultaneous capture/playback and echo_cancellation.enabled=true",
|
|
1503
|
+
0,
|
|
1504
|
+
)
|
|
1397
1505
|
else:
|
|
1398
1506
|
audio_gate = (
|
|
1399
1507
|
"BLOCKED",
|
|
@@ -1445,6 +1553,7 @@ def command_assess(args: argparse.Namespace) -> int:
|
|
|
1445
1553
|
0,
|
|
1446
1554
|
)
|
|
1447
1555
|
runtime_gate: Requirement | None = None
|
|
1556
|
+
business_gate: Requirement | None = None
|
|
1448
1557
|
runtime_gate_result: dict[str, Any] | None = None
|
|
1449
1558
|
if data.get("schema_version") == 2 and selected_phase in {"build", "hil"}:
|
|
1450
1559
|
relative_contract = data["hardware_resources"].get(
|
|
@@ -1469,13 +1578,41 @@ def command_assess(args: argparse.Namespace) -> int:
|
|
|
1469
1578
|
}
|
|
1470
1579
|
else:
|
|
1471
1580
|
runtime_gate_result = verify_runtime_contract(contract, project)
|
|
1472
|
-
if runtime_gate_result["ok"]:
|
|
1581
|
+
if runtime_gate_result.get("base_ok", runtime_gate_result["ok"]):
|
|
1582
|
+
requested_business = (
|
|
1583
|
+
set(data["features"]["requested"]) & BUSINESS_FEATURES
|
|
1584
|
+
)
|
|
1585
|
+
verified_business = set(
|
|
1586
|
+
runtime_gate_result.get("business_features", [])
|
|
1587
|
+
)
|
|
1588
|
+
missing_business = requested_business - verified_business
|
|
1473
1589
|
runtime_gate = (
|
|
1474
1590
|
"SATISFIED",
|
|
1475
|
-
"TiRTC runtime semantic gate passed: "
|
|
1591
|
+
"TiRTC base runtime semantic gate passed: "
|
|
1476
1592
|
+ str(runtime_gate_result.get("summary", "verified")),
|
|
1477
1593
|
VERIFICATION_LEVELS["build_verified"],
|
|
1478
1594
|
)
|
|
1595
|
+
if requested_business and (
|
|
1596
|
+
runtime_gate_result.get("business_ok") is not True
|
|
1597
|
+
or missing_business
|
|
1598
|
+
):
|
|
1599
|
+
details = list(runtime_gate_result.get("business_errors", []))
|
|
1600
|
+
if missing_business:
|
|
1601
|
+
details.append(
|
|
1602
|
+
"missing features: " + ", ".join(sorted(missing_business))
|
|
1603
|
+
)
|
|
1604
|
+
business_gate = (
|
|
1605
|
+
"BLOCKED",
|
|
1606
|
+
"TiRTC business runtime gate failed: " + "; ".join(details),
|
|
1607
|
+
0,
|
|
1608
|
+
)
|
|
1609
|
+
elif requested_business:
|
|
1610
|
+
business_gate = (
|
|
1611
|
+
"SATISFIED",
|
|
1612
|
+
"TiRTC business runtime gate verified: "
|
|
1613
|
+
+ ", ".join(sorted(requested_business)),
|
|
1614
|
+
VERIFICATION_LEVELS["build_verified"],
|
|
1615
|
+
)
|
|
1479
1616
|
else:
|
|
1480
1617
|
runtime_gate = (
|
|
1481
1618
|
"BLOCKED",
|
|
@@ -1494,6 +1631,8 @@ def command_assess(args: argparse.Namespace) -> int:
|
|
|
1494
1631
|
artifact_sha256=args.artifact_sha256,
|
|
1495
1632
|
phase=selected_phase,
|
|
1496
1633
|
audio_gate=audio_gate,
|
|
1634
|
+
aec_gate=aec_gate,
|
|
1635
|
+
business_gate=business_gate,
|
|
1497
1636
|
video_gate=video_gate,
|
|
1498
1637
|
runtime_gate=runtime_gate,
|
|
1499
1638
|
artifact_gate=artifact_gate,
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
|
+
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
@@ -13,8 +14,30 @@ from typing import Any
|
|
|
13
14
|
|
|
14
15
|
ABSOLUTE_PATH = re.compile(r"(?:^|[\s\"'])(?:/home/|/root/|[A-Za-z]:[\\/])")
|
|
15
16
|
BUILD_INPUT_NAMES = {"CMakeLists.txt", "idf_component.yml", "idf_component.yaml"}
|
|
16
|
-
BUILD_INPUT_SUFFIXES = {
|
|
17
|
+
BUILD_INPUT_SUFFIXES = {
|
|
18
|
+
".c",
|
|
19
|
+
".cmake",
|
|
20
|
+
".csv",
|
|
21
|
+
".defaults",
|
|
22
|
+
".env",
|
|
23
|
+
".h",
|
|
24
|
+
".json",
|
|
25
|
+
".lock",
|
|
26
|
+
".py",
|
|
27
|
+
".sh",
|
|
28
|
+
".txt",
|
|
29
|
+
".yaml",
|
|
30
|
+
".yml",
|
|
31
|
+
}
|
|
17
32
|
SKIP_PARTS = {"build", "managed_components", ".git"}
|
|
33
|
+
REQUIRED_PROJECT_INPUTS = {
|
|
34
|
+
"dependencies.lock",
|
|
35
|
+
"hardware-ir.json",
|
|
36
|
+
"sdkconfig.defaults",
|
|
37
|
+
}
|
|
38
|
+
PARTITION_FILE_RE = re.compile(
|
|
39
|
+
r'^CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="([^"]+)"$', re.MULTILINE
|
|
40
|
+
)
|
|
18
41
|
|
|
19
42
|
|
|
20
43
|
def source_files(project: Path):
|
|
@@ -26,13 +49,156 @@ def source_files(project: Path):
|
|
|
26
49
|
yield path
|
|
27
50
|
|
|
28
51
|
|
|
52
|
+
def project_file(
|
|
53
|
+
project: Path, value: Any, label: str, errors: list[str]
|
|
54
|
+
) -> Path | None:
|
|
55
|
+
if not isinstance(value, str) or not value.strip():
|
|
56
|
+
errors.append(f"{label} must be a non-empty project-relative path")
|
|
57
|
+
return None
|
|
58
|
+
relative = Path(value)
|
|
59
|
+
if relative.is_absolute():
|
|
60
|
+
errors.append(f"{label} must be project-relative, got {value!r}")
|
|
61
|
+
return None
|
|
62
|
+
resolved = (project / relative).resolve()
|
|
63
|
+
if project != resolved and project not in resolved.parents:
|
|
64
|
+
errors.append(f"{label} escapes project root: {value!r}")
|
|
65
|
+
return None
|
|
66
|
+
return resolved
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_hardware_ir(project: Path, errors: list[str]) -> dict[str, Any] | None:
|
|
70
|
+
path = project / "hardware-ir.json"
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
76
|
+
errors.append(f"hardware-ir.json is invalid: {exc}")
|
|
77
|
+
return None
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
errors.append("hardware-ir.json root must be an object")
|
|
80
|
+
return None
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def required_project_files(
|
|
85
|
+
project: Path, export: bool, errors: list[str]
|
|
86
|
+
) -> set[Path]:
|
|
87
|
+
required = {(project / relative).resolve() for relative in REQUIRED_PROJECT_INPUTS}
|
|
88
|
+
ir = load_hardware_ir(project, errors)
|
|
89
|
+
if ir is not None:
|
|
90
|
+
schema_version = ir.get("schema_version")
|
|
91
|
+
requested = ir.get("features", {}).get("requested", [])
|
|
92
|
+
if schema_version == 2:
|
|
93
|
+
resources = ir.get("hardware_resources")
|
|
94
|
+
if not isinstance(resources, dict):
|
|
95
|
+
errors.append("hardware-ir.json hardware_resources must be an object")
|
|
96
|
+
else:
|
|
97
|
+
contract_fields = ["runtime_semantic_contract"]
|
|
98
|
+
if {"h5_live_audio", "h5_talkback", "ai_talk"}.intersection(
|
|
99
|
+
requested
|
|
100
|
+
):
|
|
101
|
+
contract_fields.append("audio_semantic_contract")
|
|
102
|
+
if "h5_live_video" in requested:
|
|
103
|
+
contract_fields.append("video_semantic_contract")
|
|
104
|
+
for field in contract_fields:
|
|
105
|
+
path = project_file(
|
|
106
|
+
project,
|
|
107
|
+
resources.get(field),
|
|
108
|
+
f"hardware_resources.{field}",
|
|
109
|
+
errors,
|
|
110
|
+
)
|
|
111
|
+
if path is not None:
|
|
112
|
+
required.add(path)
|
|
113
|
+
elif (
|
|
114
|
+
schema_version == 1
|
|
115
|
+
and "h5_live_video" in requested
|
|
116
|
+
and ir.get("camera", {}).get("h264", {}).get("available") is not True
|
|
117
|
+
):
|
|
118
|
+
errors.append(
|
|
119
|
+
"schema v1 can only export the legacy H.264 video contract; "
|
|
120
|
+
"migrate Hardware IR to schema v2 for MJPEG or H.265"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if export:
|
|
124
|
+
artifacts = ir.get("build_evidence", {}).get("artifacts")
|
|
125
|
+
if not isinstance(artifacts, list) or not artifacts:
|
|
126
|
+
errors.append(
|
|
127
|
+
"hardware-ir.json build_evidence.artifacts must retain at least "
|
|
128
|
+
"one verified deliverable for export"
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
for index, record in enumerate(artifacts):
|
|
132
|
+
if not isinstance(record, dict):
|
|
133
|
+
errors.append(
|
|
134
|
+
f"build_evidence.artifacts[{index}] must be an object"
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
path = project_file(
|
|
138
|
+
project,
|
|
139
|
+
record.get("path"),
|
|
140
|
+
f"build_evidence.artifacts[{index}].path",
|
|
141
|
+
errors,
|
|
142
|
+
)
|
|
143
|
+
if path is not None:
|
|
144
|
+
required.add(path)
|
|
145
|
+
|
|
146
|
+
for config_name in ("sdkconfig.defaults", "sdkconfig"):
|
|
147
|
+
config = project / config_name
|
|
148
|
+
if not config.is_file():
|
|
149
|
+
continue
|
|
150
|
+
text = config.read_text(encoding="utf-8", errors="replace")
|
|
151
|
+
match = PARTITION_FILE_RE.search(text)
|
|
152
|
+
if match:
|
|
153
|
+
path = project_file(
|
|
154
|
+
project,
|
|
155
|
+
match.group(1),
|
|
156
|
+
f"{config_name} custom partition table",
|
|
157
|
+
errors,
|
|
158
|
+
)
|
|
159
|
+
if path is not None:
|
|
160
|
+
required.add(path)
|
|
161
|
+
return required
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def git_root(project: Path) -> Path | None:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["git", "-C", str(project), "rev-parse", "--show-toplevel"],
|
|
167
|
+
stdout=subprocess.PIPE,
|
|
168
|
+
stderr=subprocess.DEVNULL,
|
|
169
|
+
text=True,
|
|
170
|
+
check=False,
|
|
171
|
+
)
|
|
172
|
+
if result.returncode != 0:
|
|
173
|
+
return None
|
|
174
|
+
return Path(result.stdout.strip()).resolve()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def git_ignored(root: Path, path: Path) -> bool:
|
|
178
|
+
try:
|
|
179
|
+
relative = path.relative_to(root)
|
|
180
|
+
except ValueError:
|
|
181
|
+
return False
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
["git", "-C", str(root), "check-ignore", "--quiet", "--", relative.as_posix()],
|
|
184
|
+
stdout=subprocess.DEVNULL,
|
|
185
|
+
stderr=subprocess.DEVNULL,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
return result.returncode == 0
|
|
189
|
+
|
|
190
|
+
|
|
29
191
|
def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
|
|
30
192
|
project = project_path.expanduser().resolve()
|
|
31
193
|
errors: list[str] = []
|
|
32
194
|
if not (project / "CMakeLists.txt").is_file():
|
|
33
195
|
return {"ok": False, "errors": ["CMakeLists.txt is missing"]}
|
|
34
|
-
|
|
35
|
-
|
|
196
|
+
required = required_project_files(project, export, errors)
|
|
197
|
+
for path in sorted(required):
|
|
198
|
+
if not path.is_file():
|
|
199
|
+
errors.append(
|
|
200
|
+
f"required portable input is missing: {path.relative_to(project)}"
|
|
201
|
+
)
|
|
36
202
|
sdk_required = (
|
|
37
203
|
project / "third_party" / "tirtc" / "include" / "tirtc" / "tiRTC.h",
|
|
38
204
|
project / "third_party" / "tirtc" / "lib" / "libTiRTC.a",
|
|
@@ -43,6 +209,15 @@ def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
|
|
|
43
209
|
errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
|
|
44
210
|
if export and (project / "build").exists():
|
|
45
211
|
errors.append("export source contains a machine-bound build/ directory")
|
|
212
|
+
if export:
|
|
213
|
+
root = git_root(project)
|
|
214
|
+
if root is not None:
|
|
215
|
+
for path in sorted(required):
|
|
216
|
+
if path.is_file() and git_ignored(root, path):
|
|
217
|
+
errors.append(
|
|
218
|
+
"required portable input is ignored by Git: "
|
|
219
|
+
f"{path.relative_to(project)}"
|
|
220
|
+
)
|
|
46
221
|
|
|
47
222
|
for path in source_files(project):
|
|
48
223
|
relative = path.relative_to(project)
|
|
@@ -37,6 +37,30 @@ VIDEO_PROFILES = {
|
|
|
37
37
|
"h264": ("TIRTC_VIDEO_H264", "annex_b_access_unit"),
|
|
38
38
|
"h265": ("TIRTC_VIDEO_H265", "annex_b_access_unit"),
|
|
39
39
|
}
|
|
40
|
+
BUSINESS_FEATURES = {"device_call", "wechat_voip"}
|
|
41
|
+
BUSINESS_PROTOCOL_TOKENS = {
|
|
42
|
+
"device_call": (
|
|
43
|
+
"/v1/call/request",
|
|
44
|
+
"/v1/call/device/info",
|
|
45
|
+
"/v1/call/reject",
|
|
46
|
+
"/v1/call/cancel",
|
|
47
|
+
"/v1/call/hangup",
|
|
48
|
+
"/v1/call/room",
|
|
49
|
+
"call_incoming",
|
|
50
|
+
"room_cancel",
|
|
51
|
+
"call_reject",
|
|
52
|
+
),
|
|
53
|
+
"wechat_voip": (
|
|
54
|
+
"/v1/voip/device/profile",
|
|
55
|
+
"/v1/voip/device/contacts",
|
|
56
|
+
"/v1/voip/device/call",
|
|
57
|
+
"call_incoming",
|
|
58
|
+
"callers_update",
|
|
59
|
+
"wx_call_id",
|
|
60
|
+
"wxcall",
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
GIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
40
64
|
|
|
41
65
|
|
|
42
66
|
def sha256_file(path: Path) -> str:
|
|
@@ -150,6 +174,89 @@ def check_platform_contract(platform: Any, errors: list[str]) -> None:
|
|
|
150
174
|
)
|
|
151
175
|
|
|
152
176
|
|
|
177
|
+
def check_business_contract(
|
|
178
|
+
contract: dict[str, Any],
|
|
179
|
+
project: Path,
|
|
180
|
+
errors: list[str],
|
|
181
|
+
inputs: dict[str, str],
|
|
182
|
+
) -> set[str]:
|
|
183
|
+
business = contract.get("business")
|
|
184
|
+
if business is None:
|
|
185
|
+
return set()
|
|
186
|
+
if not isinstance(business, dict):
|
|
187
|
+
errors.append("business must be an object")
|
|
188
|
+
return set()
|
|
189
|
+
features = business.get("features", [])
|
|
190
|
+
if not isinstance(features, list) or any(
|
|
191
|
+
not isinstance(feature, str) for feature in features
|
|
192
|
+
):
|
|
193
|
+
errors.append("business.features must be an array of feature names")
|
|
194
|
+
return set()
|
|
195
|
+
selected = set(features)
|
|
196
|
+
unknown = selected - BUSINESS_FEATURES
|
|
197
|
+
if unknown:
|
|
198
|
+
errors.append(
|
|
199
|
+
"business.features contains unsupported features: "
|
|
200
|
+
+ ", ".join(sorted(unknown))
|
|
201
|
+
)
|
|
202
|
+
if not selected:
|
|
203
|
+
return set()
|
|
204
|
+
|
|
205
|
+
revision = business.get("protocol_revision")
|
|
206
|
+
if not isinstance(revision, str) or not GIT_SHA_RE.fullmatch(revision):
|
|
207
|
+
errors.append(
|
|
208
|
+
"business.protocol_revision must pin the 40-character tirtc-server-example commit"
|
|
209
|
+
)
|
|
210
|
+
arbiter = business.get("session_arbiter")
|
|
211
|
+
if not isinstance(arbiter, dict):
|
|
212
|
+
errors.append("business.session_arbiter must be an object")
|
|
213
|
+
else:
|
|
214
|
+
for field in (
|
|
215
|
+
"single_foreground_owner",
|
|
216
|
+
"generation_guard",
|
|
217
|
+
"monotonic_deadlines",
|
|
218
|
+
"deferred_lifecycle",
|
|
219
|
+
"restore_h5_after_call",
|
|
220
|
+
):
|
|
221
|
+
if arbiter.get(field) is not True:
|
|
222
|
+
errors.append(f"business.session_arbiter.{field} must be true")
|
|
223
|
+
if arbiter.get("pending_capacity") != 1:
|
|
224
|
+
errors.append("business.session_arbiter.pending_capacity must be 1")
|
|
225
|
+
|
|
226
|
+
assertions = business.get("implementation_assertions")
|
|
227
|
+
combined = ""
|
|
228
|
+
if not isinstance(assertions, list) or not assertions:
|
|
229
|
+
errors.append("business.implementation_assertions must be a non-empty array")
|
|
230
|
+
else:
|
|
231
|
+
for index, assertion in enumerate(assertions):
|
|
232
|
+
label = f"business.implementation_assertions[{index}]"
|
|
233
|
+
if not isinstance(assertion, dict):
|
|
234
|
+
errors.append(f"{label} must be an object")
|
|
235
|
+
continue
|
|
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
|
+
combined += "\n" + text
|
|
246
|
+
inputs[str(source.relative_to(project))] = sha256_file(source)
|
|
247
|
+
for token in assertion.get("contains", []):
|
|
248
|
+
if not isinstance(token, str) or not token:
|
|
249
|
+
errors.append(f"{label}.contains must contain non-empty strings")
|
|
250
|
+
elif token not in text:
|
|
251
|
+
errors.append(f"{label} is missing token: {token}")
|
|
252
|
+
|
|
253
|
+
for feature in sorted(selected & BUSINESS_FEATURES):
|
|
254
|
+
for token in BUSINESS_PROTOCOL_TOKENS[feature]:
|
|
255
|
+
if token not in combined:
|
|
256
|
+
errors.append(f"business {feature} is missing protocol token: {token}")
|
|
257
|
+
return selected & BUSINESS_FEATURES
|
|
258
|
+
|
|
259
|
+
|
|
153
260
|
def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
154
261
|
project = project_path.expanduser().resolve()
|
|
155
262
|
contract_file = contract_path.expanduser().resolve()
|
|
@@ -275,9 +382,23 @@ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
|
275
382
|
):
|
|
276
383
|
errors.append("AI media must start only from the validated command handler")
|
|
277
384
|
|
|
385
|
+
base_errors = list(errors)
|
|
386
|
+
business_errors: list[str] = []
|
|
387
|
+
business_features = check_business_contract(
|
|
388
|
+
contract, project, business_errors, inputs
|
|
389
|
+
)
|
|
390
|
+
errors.extend(business_errors)
|
|
391
|
+
|
|
278
392
|
return {
|
|
279
393
|
"ok": not errors,
|
|
280
|
-
"
|
|
394
|
+
"base_ok": not base_errors,
|
|
395
|
+
"business_ok": not business_errors,
|
|
396
|
+
"summary": (
|
|
397
|
+
"endpoint + callbacks + H5/AI stream and negotiation contract; "
|
|
398
|
+
f"business={','.join(sorted(business_features)) or 'none'}"
|
|
399
|
+
),
|
|
400
|
+
"business_features": sorted(business_features),
|
|
401
|
+
"business_errors": business_errors,
|
|
281
402
|
"inputs": inputs,
|
|
282
403
|
"errors": errors,
|
|
283
404
|
}
|