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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Capability rules
|
|
2
2
|
|
|
3
|
-
Run `hardware_ir.py assess --strict` before generation. Hardware IR v2 validates the selected product contract rather than assuming one video codec or provisioning method. Schema v1 remains readable for existing H.264 projects.
|
|
3
|
+
Run `hardware_ir.py assess --phase intake --strict` before generation. Hardware IR v2 validates the selected product contract rather than assuming one video codec or provisioning method. Schema v1 remains readable for existing H.264 projects.
|
|
4
4
|
|
|
5
5
|
## Current starter contract
|
|
6
6
|
|
|
@@ -11,7 +11,9 @@ Run `hardware_ir.py assess --strict` before generation. Hardware IR v2 validates
|
|
|
11
11
|
| `h5_talkback` | G.711 A-law, 8 kHz downlink decode and speaker path for stream 14; audio controller/GPIO ownership and memory budget resolved |
|
|
12
12
|
| `ai_talk` | A-law 8 kHz microphone and speaker paths for AI stream 1, started only after `start_session`; audio ownership/channel mapping and memory budget resolved |
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Hardware identity, wiring, the selected media contract, and the adapter/resource design must be at least `corroborated` to become `READY_TO_PORT`. At intake, `available=true` means a pinned source path can implement the selected profile; it does not claim that the final adapter or physical media path has run. Implementation composition, final ELF policy, runtime memory margin, browser media, and stability belong to the build or HIL phases.
|
|
15
|
+
|
|
16
|
+
A design fact that is still unknown is `NEEDS_CONFIRMATION`; a confirmed missing or incompatible resource is `BLOCKED`. Facts that can be resolved safely through source inspection, adapter implementation, compilation, or post-link inspection should be resolved in that layer rather than converted into a request for HIL evidence.
|
|
15
17
|
|
|
16
18
|
## Selected video profiles
|
|
17
19
|
|
|
@@ -25,14 +27,16 @@ Hardware IR v2 stores one or more `camera.video_profiles` and exactly one select
|
|
|
25
27
|
|
|
26
28
|
Available but unselected profiles do not satisfy or block the selected contract. Stream IDs and codec support must come from the applicable ThingConnect/H5 contract, not this table alone.
|
|
27
29
|
|
|
28
|
-
##
|
|
30
|
+
## Phased project gates
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
The intake phase requires:
|
|
31
33
|
|
|
32
|
-
- one evidenced I2C driver
|
|
34
|
+
- one selected and evidenced I2C driver-family plan when I2C is used;
|
|
33
35
|
- a selected Wi-Fi credential method that is available, reprovisionable, and keeps credentials outside source control;
|
|
34
36
|
- one evidenced binding method—verification code, factory-bound identity, development credentials, or documented custom flow—plus stored-binding behavior and reset control;
|
|
35
|
-
- feature-specific I2S/GPIO ownership, channel/TDM mapping, realtime camera policy, and startup/media memory budget.
|
|
37
|
+
- feature-specific I2S/GPIO ownership plans, channel/TDM mapping, realtime camera policy, and a static startup/media memory budget.
|
|
38
|
+
|
|
39
|
+
At intake, `corroborated` on these fields means the design is resolved from authoritative sources and is safe to implement. After compilation, promote a field to `build_verified` only when the generated source, component lock, semantic gate, compile result, or post-link gate establishes it. Build assessment reruns the applicable project-relative [audio contract](audio-contract.md) and [video contract](video-contract.md); self-declared `resolved=true`, `pipeline_safe=true`, or memory-budget booleans cannot replace them. The build phase requires an exact artifact SHA-256 and returns `BUILD_VERIFIED` only when every requested feature passes. Runtime measurements never need to be invented to pass intake or build.
|
|
36
40
|
|
|
37
41
|
SoftAP is one Wi-Fi option, not a universal requirement. BLE, SmartConfig, factory NVS, development configuration, or a documented custom method can satisfy intake when the selected path is evidenced. Committed plaintext credentials are always `BLOCKED`.
|
|
38
42
|
|
|
@@ -43,7 +47,7 @@ SoftAP is one Wi-Fi option, not a universal requirement. BLE, SmartConfig, facto
|
|
|
43
47
|
- Start AI media only after the successful `start_session` response; stop and flush media before disconnecting.
|
|
44
48
|
- Copy SDK callback payloads into bounded queues before returning. Perform decoding, playback, HTTP, and lifecycle changes outside callbacks.
|
|
45
49
|
- Use monotonic timestamps and session generation to reject stale frames and delayed callbacks.
|
|
46
|
-
- Record runtime evidence with the exact BIN/ELF SHA-256. Use `assess --artifact-sha256 <sha>`; documentation verification alone never becomes v2 `HIL_VERIFIED`.
|
|
50
|
+
- Record runtime evidence with the exact BIN/ELF SHA-256. Use `assess --phase hil --artifact-sha256 <sha>`; documentation verification alone never becomes v2 `HIL_VERIFIED`.
|
|
47
51
|
|
|
48
52
|
## Typical blocked cases
|
|
49
53
|
|
|
@@ -45,6 +45,14 @@ The default managed root is `<setup-root>/kits/esp32s3/<kit-version>`. The publi
|
|
|
45
45
|
|
|
46
46
|
SDK resolution is independent after generation: an explicit `--sdk-dir` wins, followed by `<project>/third_party/tirtc`, then the SDK packaged in the resolved Device Kit or legacy workspace. The generated project remains diagnosable after it is moved away from the Kit.
|
|
47
47
|
|
|
48
|
+
Before copying a generated project to another machine, run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 <skill-dir>/scripts/project_portability.py <generated-project> --export
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Copy source inputs only. Never export `build/`: CMake caches absolute source, toolchain, and Python paths from the originating machine. `managed_components/` may be regenerated from the committed `dependencies.lock`; the bundled `third_party/tirtc` SDK and its build contract must remain in the source package. CMake must invoke shell gates through `bash <script>` so the build does not depend on archive- or filesystem-specific executable bits.
|
|
55
|
+
|
|
48
56
|
## Required checks
|
|
49
57
|
|
|
50
58
|
- `python3`, `git`, `idf.py`, and the target compiler are available in the active shell;
|
|
@@ -27,6 +27,8 @@ Verification levels are ordered:
|
|
|
27
27
|
4. `hardware_verified`: the local peripheral works on the exact board revision.
|
|
28
28
|
5. `hil_verified`: retained for legacy facts; schema v2 feature HIL additionally requires matching artifact evidence.
|
|
29
29
|
|
|
30
|
+
Use the same fact across phases without overstating it. `corroborated` means authoritative sources establish an implementable design; `build_verified` means the generated source, locked dependencies, compile, or post-link gates establish that implementation; runtime behavior requires matching artifact evidence. For example, a corroborated single-I2C-family field records a dependency plan, while its build-verified form records the final ELF audit.
|
|
31
|
+
|
|
30
32
|
## Schema v2 contracts
|
|
31
33
|
|
|
32
34
|
The IR contains:
|
|
@@ -35,10 +37,13 @@ The IR contains:
|
|
|
35
37
|
- camera identity evidence plus `video_profiles[]` and `selected_video_profile`;
|
|
36
38
|
- audio input/output paths;
|
|
37
39
|
- `hardware_resources` for I2C, I2S/GPIO ownership, audio channel mapping, camera realtime policy, and memory budget;
|
|
40
|
+
- project-relative `hardware_resources.audio_semantic_contract` for every project requesting audio;
|
|
41
|
+
- project-relative `hardware_resources.video_semantic_contract` for every project requesting video;
|
|
38
42
|
- `onboarding.wifi_credentials` with selectable SoftAP/BLE/SmartConfig/factory/development/custom methods;
|
|
39
43
|
- selectable ThingConnect binding methods plus stored-binding states and reset control;
|
|
40
44
|
- requested features;
|
|
41
45
|
- optional `runtime_evidence[]`, each bound to a full firmware SHA-256.
|
|
46
|
+
- `build_evidence.artifacts[]` containing each accepted BIN/ELF path, byte size, and full SHA-256.
|
|
42
47
|
|
|
43
48
|
The selected Wi-Fi method must be available and corroborated, credentials must remain outside tracked source, and reprovisioning must be defined. A board without SoftAP is valid when another selected method meets those conditions.
|
|
44
49
|
|
|
@@ -46,13 +51,25 @@ The selected video profile controls assessment. An unselected H.264 fallback can
|
|
|
46
51
|
|
|
47
52
|
## Artifact-bound HIL
|
|
48
53
|
|
|
49
|
-
Run:
|
|
54
|
+
Run the phase gates in order:
|
|
50
55
|
|
|
51
56
|
```bash
|
|
52
57
|
python3 <skill-dir>/scripts/hardware_ir.py assess hardware-ir.json \
|
|
58
|
+
--phase intake --strict
|
|
59
|
+
python3 <skill-dir>/scripts/hardware_ir.py assess hardware-ir.json \
|
|
60
|
+
--phase build --project <generated-project> \
|
|
53
61
|
--artifact-sha256 <64-character-sha256> --strict
|
|
54
62
|
```
|
|
55
63
|
|
|
64
|
+
The intake phase returns `READY_TO_PORT`; the build phase returns `BUILD_VERIFIED`. Build assessment only accepts a hash already present in `build_evidence.artifacts[]` and reruns the project-local media contracts through `--project`. Missing serial or browser access does not block either phase.
|
|
65
|
+
|
|
66
|
+
Run:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
python3 <skill-dir>/scripts/hardware_ir.py assess hardware-ir.json \
|
|
70
|
+
--phase hil --artifact-sha256 <64-character-sha256> --strict
|
|
71
|
+
```
|
|
72
|
+
|
|
56
73
|
H5 features require matching L5 evidence; AI requires matching L6 evidence. Evidence from an older firmware remains provenance but does not verify the current artifact.
|
|
57
74
|
|
|
58
75
|
## Intake quality
|
|
@@ -36,7 +36,7 @@ For every method, keep device credentials outside source control, handle an alre
|
|
|
36
36
|
|
|
37
37
|
Platform service discovery and the TiRTC SDK service endpoint are different settings. Record both. HTTPS requires valid time, DNS, certificate validation, TLS client/TLS 1.2, and enough contiguous internal memory.
|
|
38
38
|
|
|
39
|
-
Before
|
|
39
|
+
Define a conservative static budget before implementation using the locked SDK contract, framebuffer geometry, DMA/queue bounds, task stacks, and an internal-memory reserve. Before claiming runtime margin or tuning TiRTC buffers, measure internal free/largest blocks, PSRAM, frame size distribution, queue watermarks, and send/drop rates on the exact artifact. A larger queue can prevent drops, exhaust startup memory, or add buffer latency. If an authorized HTTP baseline exists, stage transport changes separately from media changes and retain the HTTPS requirements as a pending acceptance item.
|
|
40
40
|
|
|
41
41
|
## Network and media evidence
|
|
42
42
|
|
|
@@ -8,7 +8,7 @@ Use [the report template](../assets/report-template.md) and preserve separate `P
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| L-1 Environment | ESP-IDF version, target compiler, TiRTC SDK, build contract, and requested serial access pass doctor checks |
|
|
10
10
|
| L0 Generate | Project and Hardware IR exist; no existing output was overwritten |
|
|
11
|
-
| L1 Build | Hardware IR valid, SDK contract
|
|
11
|
+
| L1 Build | Hardware IR valid, SDK contract and requested-feature semantic gates pass, `idf.py build` succeeds, artifacts recorded |
|
|
12
12
|
| L2 Boot | Exact chip/port resolved, flash succeeds, firmware boots without panic |
|
|
13
13
|
| L3 Online | Wi-Fi provisioning, binding, MQTT, and TiRTC reach ready state |
|
|
14
14
|
| L4 Media | Camera/microphone/speaker local paths work and counters/measurements are captured |
|
|
@@ -16,11 +16,15 @@ Use [the report template](../assets/report-template.md) and preserve separate `P
|
|
|
16
16
|
| L6 AI | Token, WHIP, `start_session`, bidirectional audio, stop, and H5 recovery work |
|
|
17
17
|
| L7 Stability | Requested weak-network, repeated-session, resource, and soak criteria pass |
|
|
18
18
|
|
|
19
|
+
Run and record the intake assessment before L0, the build assessment with `--project` and the exact artifact SHA-256 at L1, and the HIL assessment only when matching runtime evidence exists. The L1 hash must already appear in `build_evidence.artifacts[]`; a syntactically valid unrecorded hash is a failure. Missing serial or browser access is a `SKIP` for the affected L2-L7 levels, not an L0/L1 failure.
|
|
20
|
+
|
|
21
|
+
Keep `COMPILE_PASS` separate from `BUILD_VERIFIED`. When the compiler succeeds but a requested audio or video semantic gate fails or is missing, record the compiler result and report the feature and project as blocked. H5 image display is L5 evidence, never an inference from L1.
|
|
22
|
+
|
|
19
23
|
## Evidence
|
|
20
24
|
|
|
21
25
|
Record exact board revision, selected media, Wi-Fi, and binding profiles, toolchain and SDK versions, source/adapter revisions, commands, return codes, firmware size and SHA-256, serial port/chip, sanitized log paths, browser or platform observations, and every unavailable dependency.
|
|
22
26
|
|
|
23
|
-
Runtime evidence belongs to one artifact. Label superseded artifacts and keep their observations as history; do not promote them to the current BIN/ELF. For HIL assessment, add the full SHA-256 to `runtime_evidence` and run `hardware_ir.py assess --artifact-sha256 <sha> --strict`.
|
|
27
|
+
Runtime evidence belongs to one artifact. Label superseded artifacts and keep their observations as history; do not promote them to the current BIN/ELF. For HIL assessment, add the full SHA-256 to `runtime_evidence` and run `hardware_ir.py assess --phase hil --artifact-sha256 <sha> --strict`.
|
|
24
28
|
|
|
25
29
|
For L3-L7 media runs, capture the signals that distinguish software regressions from environment changes: onboarding/binding state, BSSID/channel/RSSI, reconnect or roaming events, audio/video send/receive/drop/error counters, queue watermarks, camera overflow count, internal heap/largest block, PSRAM, and measured latency when available. Never record credential values.
|
|
26
30
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Video semantic contract
|
|
2
|
+
|
|
3
|
+
Use this branch whenever a requested feature sends camera video. Create `board-video-contract.json` inside the generated project from the exact board evidence, selected codec/profile, locked components, Device Kit contract, and adapter design.
|
|
4
|
+
|
|
5
|
+
The project-local contract must establish:
|
|
6
|
+
|
|
7
|
+
- the exact camera component and ESP-IDF versions from `dependencies.lock`;
|
|
8
|
+
- camera event processing isolation from the configured Wi-Fi core, with both task-core selections verified from resolved configuration rather than assumed defaults;
|
|
9
|
+
- selected frame-buffer count and memory location;
|
|
10
|
+
- `camera.codec` plus the matching SDK media symbol, and one complete MJPEG JPEG, H.264 access unit, or H.265 access unit per SDK send according to the selected profile;
|
|
11
|
+
- stream ID, SDK media symbol, refresh semantics, and send return handling;
|
|
12
|
+
- maximum encoded frame enforced before send, TiRTC max send buffer, and a lower backpressure threshold;
|
|
13
|
+
- exact accepted sensor identities and rejection of every uncorroborated PID;
|
|
14
|
+
- implementation assertions against `sdkconfig.defaults`, resolved `sdkconfig`, adapter source, TiRTC wrapper, and product composition root.
|
|
15
|
+
|
|
16
|
+
For MJPEG use `complete_jpeg_per_send` and `max_complete_jpeg_bytes`; for H.264/H.265 use `complete_access_unit_per_send` and `max_complete_access_unit_bytes`. The media symbol must match the codec. For the LCKFB V1.0.1 MJPEG profile, a complete JPEG must fit at or below the backpressure threshold, which must remain below the TiRTC max send buffer. Camera event work and Wi-Fi must use different cores. These are project contract values, not board-agnostic Skill defaults.
|
|
17
|
+
|
|
18
|
+
After `idf.py reconfigure`, run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python3 <skill-dir>/scripts/video_contract.py \
|
|
22
|
+
<project>/board-video-contract.json \
|
|
23
|
+
--project <project> \
|
|
24
|
+
--evidence-out <project>/build/video-contract-evidence.json
|
|
25
|
+
python3 <skill-dir>/scripts/install_video_gate.py <project>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The installer copies the verifier into `tools/` and makes every `idf.py build` depend on it. Set `hardware_resources.video_semantic_contract` to the project-relative contract path and pass `--project` to build assessment.
|
|
29
|
+
|
|
30
|
+
Compilation without this gate is `COMPILE_PASS / VIDEO_CAPABILITY_BLOCKED`. H5 image display remains L5 and requires runtime evidence bound to the exact artifact.
|
|
@@ -23,8 +23,8 @@ Use this branch when the user supplies a board model, vendor URL, schematic, BOM
|
|
|
23
23
|
3. Prefer official schematic/BOM and BSP facts. For a PDF schematic, inspect page labels and net names; prefer an exported netlist, pin CSV, or vendor board definition when available.
|
|
24
24
|
4. Cross-check critical pins, clocks, power enables, reset lines, sensor/codec variants, ESP-IDF version, resource ownership, and onboarding behavior across at least two independent artifacts when possible.
|
|
25
25
|
5. Create the Hardware IR v2. Use `null` for unknown facts and retain contradictory values as an explicit issue instead of selecting one silently. Store concrete board values in the IR/adapter rather than Skill files.
|
|
26
|
-
6. Validate and
|
|
27
|
-
7. When
|
|
26
|
+
6. Validate and run the intake assessment. Classify unresolved facts by their next evidence source: source, implementation, build, HIL, or user input. Ask only for `user_blocked` facts that prevent a safe design. SoftAP is optional when another evidenced Wi-Fi credential method is available, keeps credentials outside source, and defines reprovisioning.
|
|
27
|
+
7. When hardware identity, wiring, product contracts, and an evidenced resource plan reach `READY_TO_PORT`, generate the starter and implement the board adapter. Generate a compile-safe adapter by default when remaining uncertainty is implementation-, build-, or HIL-resolvable. Stop at the IR/report only when missing user evidence or an incompatible dependency makes a safe implementation impossible.
|
|
28
28
|
|
|
29
29
|
The branch is complete when every supplied artifact maps to an IR fact, provenance entry, contradiction, or declared irrelevant item.
|
|
30
30
|
|
|
@@ -68,4 +68,12 @@ The stable modules own stream IDs, negotiated/contracted formats, TiRTC callback
|
|
|
68
68
|
|
|
69
69
|
Use a bounded loop per layer: diagnose one failing invariant, make the smallest correction, and rerun that layer before moving forward. Change one high-risk variable per HIL comparison. Turn reusable invariants into tests or post-link gates. Stop and report when the remaining failure requires unavailable hardware, credentials, a new SDK binary, a public protocol change, or a user choice.
|
|
70
70
|
|
|
71
|
+
Run the assessor once per layer:
|
|
72
|
+
|
|
73
|
+
- `--phase intake`: corroborated design evidence; success is `READY_TO_PORT`.
|
|
74
|
+
- `--phase build --project <project> --artifact-sha256 <sha>`: compile/semantic/post-link evidence; success is `BUILD_VERIFIED`.
|
|
75
|
+
- `--phase hil --artifact-sha256 <sha>`: matching L5/L6 runtime evidence; success is `HIL_VERIFIED`.
|
|
76
|
+
|
|
77
|
+
No serial authorization is required for L0/L1. When serial, browser, account, service, or network access is unavailable, complete the safe build work and report the affected L2-L7 levels as `SKIP`.
|
|
78
|
+
|
|
71
79
|
Do not use successful compilation as evidence for camera frames, speaker output, Web rendering, AI audio, or long-run stability. Bind every runtime conclusion to the tested firmware SHA-256.
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify an ESP32 board audio contract against locked source and adapter files."""
|
|
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
|
+
PAIR_PATTERN = re.compile(r"\{\s*(\d+)\s*,\s*(\d+)\s*,")
|
|
16
|
+
AUDIO_MODES = {"standard", "tdm", "dsp", "pcm"}
|
|
17
|
+
HANDOFF_POLICIES = {"none", "release_before_claim", "delete_recreate"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256_file(path: Path) -> str:
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
with path.open("rb") as stream:
|
|
23
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
24
|
+
digest.update(chunk)
|
|
25
|
+
return digest.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_file(project: Path, value: Any, label: str) -> Path:
|
|
29
|
+
if not isinstance(value, str) or not value.strip():
|
|
30
|
+
raise ValueError(f"{label} must be a non-empty project-relative path")
|
|
31
|
+
relative = Path(value)
|
|
32
|
+
if relative.is_absolute():
|
|
33
|
+
raise ValueError(f"{label} must be project-relative, got {value!r}")
|
|
34
|
+
resolved = (project / relative).resolve()
|
|
35
|
+
if project != resolved and project not in resolved.parents:
|
|
36
|
+
raise ValueError(f"{label} escapes project root: {value!r}")
|
|
37
|
+
return resolved
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_mapping(value: Any, label: str, errors: list[str]) -> dict[str, Any]:
|
|
41
|
+
if not isinstance(value, dict):
|
|
42
|
+
errors.append(f"{label} must be an object")
|
|
43
|
+
return {}
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_positive_int(value: Any, label: str, errors: list[str]) -> int | None:
|
|
48
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
49
|
+
errors.append(f"{label} must be a positive integer")
|
|
50
|
+
return None
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_nonempty_string(value: Any, label: str, errors: list[str]) -> str | None:
|
|
55
|
+
if not isinstance(value, str) or not value.strip():
|
|
56
|
+
errors.append(f"{label} must be a non-empty string")
|
|
57
|
+
return None
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_clock(
|
|
62
|
+
project: Path,
|
|
63
|
+
contract: dict[str, Any],
|
|
64
|
+
errors: list[str],
|
|
65
|
+
inputs: dict[str, str],
|
|
66
|
+
) -> None:
|
|
67
|
+
clock = require_mapping(contract.get("clock"), "clock", errors)
|
|
68
|
+
sample_rate = require_positive_int(
|
|
69
|
+
clock.get("sample_rate_hz"), "clock.sample_rate_hz", errors
|
|
70
|
+
)
|
|
71
|
+
ratio = require_positive_int(clock.get("mclk_ratio"), "clock.mclk_ratio", errors)
|
|
72
|
+
mclk = require_positive_int(clock.get("mclk_hz"), "clock.mclk_hz", errors)
|
|
73
|
+
if None not in (sample_rate, ratio, mclk) and sample_rate * ratio != mclk:
|
|
74
|
+
errors.append(
|
|
75
|
+
"clock tuple is inconsistent: "
|
|
76
|
+
f"{sample_rate}Hz x {ratio} != {mclk}Hz"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
tables = clock.get("codec_tables")
|
|
80
|
+
if not isinstance(tables, list) or not tables:
|
|
81
|
+
errors.append("clock.codec_tables must list every clocked codec driver table")
|
|
82
|
+
return
|
|
83
|
+
if sample_rate is None or mclk is None:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
for index, item in enumerate(tables):
|
|
87
|
+
label = f"clock.codec_tables[{index}]"
|
|
88
|
+
table = require_mapping(item, label, errors)
|
|
89
|
+
codec = require_nonempty_string(table.get("codec"), f"{label}.codec", errors)
|
|
90
|
+
try:
|
|
91
|
+
source = project_file(project, table.get("source"), f"{label}.source")
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
errors.append(str(exc))
|
|
94
|
+
continue
|
|
95
|
+
if not source.is_file():
|
|
96
|
+
errors.append(f"{label}.source does not exist: {source}")
|
|
97
|
+
continue
|
|
98
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
99
|
+
pairs = {(int(first), int(second)) for first, second in PAIR_PATTERN.findall(text)}
|
|
100
|
+
inputs[str(source.relative_to(project))] = sha256_file(source)
|
|
101
|
+
if not pairs:
|
|
102
|
+
errors.append(f"{label} contains no C initializer clock pairs")
|
|
103
|
+
elif (mclk, sample_rate) not in pairs:
|
|
104
|
+
errors.append(
|
|
105
|
+
f"{codec or label} rejects selected clock tuple: "
|
|
106
|
+
f"sample_rate={sample_rate}Hz mclk={mclk}Hz"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check_endpoint(
|
|
111
|
+
value: Any,
|
|
112
|
+
label: str,
|
|
113
|
+
errors: list[str],
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
endpoint = require_mapping(value, label, errors)
|
|
116
|
+
controller = endpoint.get("controller")
|
|
117
|
+
if isinstance(controller, bool) or not isinstance(controller, int) or controller < 0:
|
|
118
|
+
errors.append(f"{label}.controller must be an integer >= 0")
|
|
119
|
+
mode = endpoint.get("mode")
|
|
120
|
+
if mode not in AUDIO_MODES:
|
|
121
|
+
errors.append(f"{label}.mode must be one of {', '.join(sorted(AUDIO_MODES))}")
|
|
122
|
+
role = endpoint.get("role")
|
|
123
|
+
if role not in {"master", "slave"}:
|
|
124
|
+
errors.append(f"{label}.role must be master or slave")
|
|
125
|
+
return endpoint
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_topology(contract: dict[str, Any], errors: list[str]) -> None:
|
|
129
|
+
capture = check_endpoint(contract.get("capture"), "capture", errors)
|
|
130
|
+
playback = check_endpoint(contract.get("playback"), "playback", errors)
|
|
131
|
+
|
|
132
|
+
capture_mode = capture.get("mode")
|
|
133
|
+
tdm_enabled = capture.get("tdm_enabled")
|
|
134
|
+
if not isinstance(tdm_enabled, bool):
|
|
135
|
+
errors.append("capture.tdm_enabled must be true or false")
|
|
136
|
+
elif (capture_mode == "tdm") != tdm_enabled:
|
|
137
|
+
errors.append("capture.mode and capture.tdm_enabled disagree")
|
|
138
|
+
|
|
139
|
+
if capture_mode == "tdm":
|
|
140
|
+
slot_count = require_positive_int(
|
|
141
|
+
capture.get("slot_count"), "capture.slot_count", errors
|
|
142
|
+
)
|
|
143
|
+
slot_order = capture.get("slot_order")
|
|
144
|
+
slot_signals = capture.get("slot_signals")
|
|
145
|
+
selected_slot = capture.get("selected_slot")
|
|
146
|
+
selected_signal = capture.get("selected_signal")
|
|
147
|
+
if not isinstance(slot_order, list) or not slot_order:
|
|
148
|
+
errors.append("capture.slot_order must be a non-empty array in TDM mode")
|
|
149
|
+
if not isinstance(slot_signals, list) or not slot_signals:
|
|
150
|
+
errors.append("capture.slot_signals must be a non-empty array in TDM mode")
|
|
151
|
+
if slot_count is not None:
|
|
152
|
+
if isinstance(slot_order, list) and len(slot_order) != slot_count:
|
|
153
|
+
errors.append("capture.slot_order length must equal capture.slot_count")
|
|
154
|
+
if isinstance(slot_signals, list) and len(slot_signals) != slot_count:
|
|
155
|
+
errors.append("capture.slot_signals length must equal capture.slot_count")
|
|
156
|
+
if (
|
|
157
|
+
isinstance(selected_slot, bool)
|
|
158
|
+
or not isinstance(selected_slot, int)
|
|
159
|
+
or selected_slot < 0
|
|
160
|
+
or slot_count is None
|
|
161
|
+
or selected_slot >= slot_count
|
|
162
|
+
):
|
|
163
|
+
errors.append("capture.selected_slot must select an available TDM slot")
|
|
164
|
+
elif isinstance(slot_signals, list) and selected_slot < len(slot_signals):
|
|
165
|
+
if selected_signal != slot_signals[selected_slot]:
|
|
166
|
+
errors.append(
|
|
167
|
+
"capture.selected_signal does not match capture.slot_signals at "
|
|
168
|
+
"capture.selected_slot"
|
|
169
|
+
)
|
|
170
|
+
require_nonempty_string(
|
|
171
|
+
capture.get("mapping_evidence"), "capture.mapping_evidence", errors
|
|
172
|
+
)
|
|
173
|
+
else:
|
|
174
|
+
require_nonempty_string(
|
|
175
|
+
capture.get("mapping_evidence"), "capture.mapping_evidence", errors
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
shared = require_mapping(contract.get("shared_clock"), "shared_clock", errors)
|
|
179
|
+
gpios = shared.get("gpios")
|
|
180
|
+
if not isinstance(gpios, list) or not gpios or any(
|
|
181
|
+
isinstance(gpio, bool) or not isinstance(gpio, int) or gpio < 0 for gpio in gpios
|
|
182
|
+
):
|
|
183
|
+
errors.append("shared_clock.gpios must be a non-empty array of GPIO numbers")
|
|
184
|
+
simultaneous = shared.get("directions_simultaneous")
|
|
185
|
+
if not isinstance(simultaneous, bool):
|
|
186
|
+
errors.append("shared_clock.directions_simultaneous must be true or false")
|
|
187
|
+
handoff = shared.get("handoff")
|
|
188
|
+
if handoff not in HANDOFF_POLICIES:
|
|
189
|
+
errors.append(
|
|
190
|
+
"shared_clock.handoff must be one of "
|
|
191
|
+
+ ", ".join(sorted(HANDOFF_POLICIES))
|
|
192
|
+
)
|
|
193
|
+
if simultaneous is False and handoff == "none":
|
|
194
|
+
errors.append("half-duplex shared clocks require an explicit handoff")
|
|
195
|
+
if simultaneous is True and capture.get("mode") != playback.get("mode"):
|
|
196
|
+
errors.append("simultaneous directions cannot use different I2S modes")
|
|
197
|
+
if (
|
|
198
|
+
capture.get("controller") == playback.get("controller")
|
|
199
|
+
and capture.get("mode") != playback.get("mode")
|
|
200
|
+
and handoff != "delete_recreate"
|
|
201
|
+
):
|
|
202
|
+
errors.append(
|
|
203
|
+
"one I2S controller cannot retain different capture/playback modes; "
|
|
204
|
+
"use distinct controllers or delete_recreate handoff"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def check_assertions(
|
|
209
|
+
project: Path,
|
|
210
|
+
contract: dict[str, Any],
|
|
211
|
+
errors: list[str],
|
|
212
|
+
inputs: dict[str, str],
|
|
213
|
+
) -> None:
|
|
214
|
+
assertions = contract.get("implementation_assertions")
|
|
215
|
+
if not isinstance(assertions, list) or not assertions:
|
|
216
|
+
errors.append("implementation_assertions must be a non-empty array")
|
|
217
|
+
return
|
|
218
|
+
for index, item in enumerate(assertions):
|
|
219
|
+
label = f"implementation_assertions[{index}]"
|
|
220
|
+
assertion = require_mapping(item, label, errors)
|
|
221
|
+
try:
|
|
222
|
+
source = project_file(project, assertion.get("file"), f"{label}.file")
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
errors.append(str(exc))
|
|
225
|
+
continue
|
|
226
|
+
if not source.is_file():
|
|
227
|
+
errors.append(f"{label}.file does not exist: {source}")
|
|
228
|
+
continue
|
|
229
|
+
text = source.read_text(encoding="utf-8", errors="replace")
|
|
230
|
+
compact = re.sub(r"\s+", "", text)
|
|
231
|
+
inputs[str(source.relative_to(project))] = sha256_file(source)
|
|
232
|
+
for field, haystack, present in (
|
|
233
|
+
("contains", text, True),
|
|
234
|
+
("contains_compact", compact, True),
|
|
235
|
+
("absent", text, False),
|
|
236
|
+
("absent_compact", compact, False),
|
|
237
|
+
):
|
|
238
|
+
needles = assertion.get(field, [])
|
|
239
|
+
if not isinstance(needles, list) or any(
|
|
240
|
+
not isinstance(needle, str) or not needle for needle in needles
|
|
241
|
+
):
|
|
242
|
+
errors.append(f"{label}.{field} must be an array of non-empty strings")
|
|
243
|
+
continue
|
|
244
|
+
for needle in needles:
|
|
245
|
+
found = needle in haystack
|
|
246
|
+
if found != present:
|
|
247
|
+
verb = "missing" if present else "contains forbidden"
|
|
248
|
+
errors.append(f"{label} {verb} {field} token: {needle}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
252
|
+
project = project_path.expanduser().resolve()
|
|
253
|
+
contract_file = contract_path.expanduser().resolve()
|
|
254
|
+
errors: list[str] = []
|
|
255
|
+
inputs: dict[str, str] = {}
|
|
256
|
+
if not project.is_dir():
|
|
257
|
+
return {"ok": False, "errors": [f"project directory does not exist: {project}"]}
|
|
258
|
+
if not contract_file.is_file():
|
|
259
|
+
return {"ok": False, "errors": [f"audio contract does not exist: {contract_file}"]}
|
|
260
|
+
if project != contract_file and project not in contract_file.parents:
|
|
261
|
+
return {"ok": False, "errors": ["audio contract must be inside the project"]}
|
|
262
|
+
try:
|
|
263
|
+
contract = json.loads(contract_file.read_text(encoding="utf-8"))
|
|
264
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
265
|
+
return {"ok": False, "errors": [f"invalid audio contract: {exc}"]}
|
|
266
|
+
if not isinstance(contract, dict):
|
|
267
|
+
return {"ok": False, "errors": ["audio contract root must be an object"]}
|
|
268
|
+
if contract.get("schema_version") != 1:
|
|
269
|
+
errors.append("schema_version must be 1")
|
|
270
|
+
evidence = contract.get("evidence")
|
|
271
|
+
if not isinstance(evidence, list) or len(evidence) < 2 or any(
|
|
272
|
+
not isinstance(item, str) or not item for item in evidence
|
|
273
|
+
):
|
|
274
|
+
errors.append("evidence must contain at least two non-empty source IDs")
|
|
275
|
+
inputs[str(contract_file.relative_to(project))] = sha256_file(contract_file)
|
|
276
|
+
check_clock(project, contract, errors, inputs)
|
|
277
|
+
check_topology(contract, errors)
|
|
278
|
+
check_assertions(project, contract, errors, inputs)
|
|
279
|
+
clock = contract.get("clock", {})
|
|
280
|
+
return {
|
|
281
|
+
"ok": not errors,
|
|
282
|
+
"summary": (
|
|
283
|
+
f"sample_rate={clock.get('sample_rate_hz')}Hz "
|
|
284
|
+
f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')}"
|
|
285
|
+
),
|
|
286
|
+
"inputs": inputs,
|
|
287
|
+
"errors": errors,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_args() -> argparse.Namespace:
|
|
292
|
+
parser = argparse.ArgumentParser(
|
|
293
|
+
description="Verify codec clocks, I2S topology, channel mapping, and adapter assertions."
|
|
294
|
+
)
|
|
295
|
+
parser.add_argument("contract", type=Path)
|
|
296
|
+
parser.add_argument("--project", type=Path, required=True)
|
|
297
|
+
parser.add_argument("--json", action="store_true")
|
|
298
|
+
parser.add_argument("--evidence-out", type=Path)
|
|
299
|
+
return parser.parse_args()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main() -> int:
|
|
303
|
+
args = parse_args()
|
|
304
|
+
result = verify_contract(args.contract, args.project)
|
|
305
|
+
if args.evidence_out is not None:
|
|
306
|
+
output = args.evidence_out.expanduser().resolve()
|
|
307
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
+
output.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
309
|
+
if args.json:
|
|
310
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
311
|
+
elif result["ok"]:
|
|
312
|
+
print(f"PASS: audio semantic contract: {result['summary']}")
|
|
313
|
+
else:
|
|
314
|
+
for error in result["errors"]:
|
|
315
|
+
print(f"FAIL: {error}", file=sys.stderr)
|
|
316
|
+
return 0 if result["ok"] else 3
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|