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.
Files changed (32) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/README.md +36 -36
  4. package/bin/esp32-kit-metadata.js +7 -6
  5. package/bin/install-esp32-kit.js +2 -0
  6. package/package.json +1 -1
  7. package/skills/tirtc-esp32-builder/SKILL.md +4 -4
  8. package/skills/tirtc-esp32-builder/USAGE.md +1 -1
  9. package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +66 -0
  10. package/skills/tirtc-esp32-builder/assets/board-video-contract.example.json +95 -0
  11. package/skills/tirtc-esp32-builder/assets/developer-intake-prompt.md +12 -7
  12. package/skills/tirtc-esp32-builder/assets/hardware-ir-v2.example.json +6 -0
  13. package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +80 -0
  14. package/skills/tirtc-esp32-builder/assets/report-template.md +16 -1
  15. package/skills/tirtc-esp32-builder/assets/tirtc-runtime-contract.example.json +10 -0
  16. package/skills/tirtc-esp32-builder/references/audio-contract.md +51 -0
  17. package/skills/tirtc-esp32-builder/references/capability-rules.md +3 -3
  18. package/skills/tirtc-esp32-builder/references/environment.md +8 -0
  19. package/skills/tirtc-esp32-builder/references/hardware-ir.md +8 -2
  20. package/skills/tirtc-esp32-builder/references/reporting.md +4 -2
  21. package/skills/tirtc-esp32-builder/references/runtime-contract.md +34 -0
  22. package/skills/tirtc-esp32-builder/references/video-contract.md +31 -0
  23. package/skills/tirtc-esp32-builder/references/workflow.md +4 -2
  24. package/skills/tirtc-esp32-builder/scripts/audio_contract.py +320 -0
  25. package/skills/tirtc-esp32-builder/scripts/doctor.py +7 -1
  26. package/skills/tirtc-esp32-builder/scripts/hardware_ir.py +399 -6
  27. package/skills/tirtc-esp32-builder/scripts/install_audio_gate.py +66 -0
  28. package/skills/tirtc-esp32-builder/scripts/install_runtime_gate.py +66 -0
  29. package/skills/tirtc-esp32-builder/scripts/install_video_gate.py +66 -0
  30. package/skills/tirtc-esp32-builder/scripts/project_portability.py +86 -0
  31. package/skills/tirtc-esp32-builder/scripts/runtime_contract.py +314 -0
  32. package/skills/tirtc-esp32-builder/scripts/video_contract.py +407 -0
@@ -28,6 +28,21 @@
28
28
  | H5 talkback | | |
29
29
  | AI intercom | | |
30
30
 
31
+ ## Semantic build gates
32
+
33
+ - Platform/Web video profiles: `{{PLATFORM_VIDEO_PROFILES}}`
34
+ - Board-selected video profile: `{{BOARD_VIDEO_PROFILE}}`
35
+ - Runtime contract path/SHA-256: `{{RUNTIME_CONTRACT}}`
36
+ - Endpoint/callback/downlink/AI-session result: `{{RUNTIME_GATE}}`
37
+ - Audio contract path/SHA-256: `{{AUDIO_CONTRACT}}`
38
+ - Codec clock-table result: `{{AUDIO_CLOCK_GATE}}`
39
+ - I2S mode/controller/slot/handoff result: `{{AUDIO_TOPOLOGY_GATE}}`
40
+ - Video contract path/SHA-256: `{{VIDEO_CONTRACT}}`
41
+ - Camera lock/PID/CPU/frame/backpressure result: `{{VIDEO_GATE}}`
42
+ - Final ELF I2C driver-family result: `{{I2C_ELF_GATE}}`
43
+ - Build artifact present in Hardware IR evidence: `{{BUILD_ARTIFACT_BINDING}}`
44
+ - Compiler result versus requested-feature result: `{{COMPILE_VS_CAPABILITY}}`
45
+
31
46
  ## Acceptance
32
47
 
33
48
  | Level | PASS/FAIL/SKIP | Command and evidence |
@@ -45,7 +60,7 @@
45
60
  ## Firmware and flash record
46
61
 
47
62
  - Serial port/chip: `{{SERIAL_TARGET}}`
48
- - Firmware artifacts: `{{FIRMWARE_ARTIFACTS}}`
63
+ - Project-relative firmware artifacts: `{{FIRMWARE_ARTIFACTS}}`
49
64
  - Firmware SHA-256: `{{FIRMWARE_SHA256}}`
50
65
  - Flash command/result: `{{FLASH_RESULT}}`
51
66
 
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema_version": 1,
3
+ "platform_contract": "platform-media-contract.json",
4
+ "files": {
5
+ "platform_client": "components/platform_client/src/platform_client.c",
6
+ "app_main": "main/app_main.c",
7
+ "starter_tirtc": "components/starter_tirtc/src/starter_tirtc.c",
8
+ "starter_runtime": "components/starter_runtime/src/starter_runtime.c"
9
+ }
10
+ }
@@ -0,0 +1,51 @@
1
+ # Audio semantic contract
2
+
3
+ Read this reference whenever a requested feature uses microphone capture or speaker playback. Its purpose is to turn codec clocks, I2S modes, channel mapping, and shared-signal ownership into one deterministic build gate.
4
+
5
+ ## Project contract
6
+
7
+ Create `board-audio-contract.json` in the generated project. Start from `assets/board-audio-contract.example.json`, but replace every board value and evidence ID from the exact schematic, official BSP/example, datasheet, and locked component source. Keep every path project-relative.
8
+
9
+ The contract must describe:
10
+
11
+ - the PCM sample rate, MCLK ratio, resulting MCLK, and every clocked codec driver table;
12
+ - capture/playback controller, role, and standard/TDM/DSP/PCM mode;
13
+ - TDM enable, slot count/order, physical signal at each slot, selected slot, and mapping evidence when TDM is used;
14
+ - shared clock GPIOs, whether directions are simultaneous, and the release/recreate handoff;
15
+ - source assertions tying the normalized contract to the actual adapter implementation.
16
+
17
+ A generic header comment such as “typically 256” is not coefficient evidence. After `idf.py reconfigure` resolves managed components, the selected `(MCLK, sample rate)` pair must exist in every locked codec table named by the contract.
18
+
19
+ ## Mandatory gate
20
+
21
+ Run the gate before claiming an audio-capable build:
22
+
23
+ ```bash
24
+ python3 <skill-dir>/scripts/audio_contract.py \
25
+ <project>/board-audio-contract.json \
26
+ --project <project> \
27
+ --evidence-out <project>/build/audio-contract-evidence.json
28
+ ```
29
+
30
+ Then install it into every subsequent `idf.py build`:
31
+
32
+ ```bash
33
+ python3 <skill-dir>/scripts/install_audio_gate.py <project>
34
+ ```
35
+
36
+ The installer copies the verifier into the project and adds an `ALL` CMake dependency for the ELF. A moved project therefore retains its semantic gate without requiring the Skill installation path.
37
+
38
+ Set `hardware_resources.audio_semantic_contract` to `board-audio-contract.json`. Build assessment must pass `--project <project>`; the assessor reruns the gate and blocks every requested audio feature when the contract is missing or fails.
39
+
40
+ ## Completion criterion
41
+
42
+ Audio reaches `BUILD_VERIFIED` only when all of these are true:
43
+
44
+ 1. the contract has at least two authoritative source IDs;
45
+ 2. its arithmetic and codec table lookups pass;
46
+ 3. its topology, TDM mapping, and handoff are internally consistent;
47
+ 4. its adapter assertions pass;
48
+ 5. the gate is part of the ordinary build;
49
+ 6. the exact BIN/ELF is hashed after that build.
50
+
51
+ Compilation without this gate is `COMPILE_PASS`, not audio `BUILD_VERIFIED`.
@@ -17,7 +17,7 @@ A design fact that is still unknown is `NEEDS_CONFIRMATION`; a confirmed missing
17
17
 
18
18
  ## Selected video profiles
19
19
 
20
- Hardware IR v2 stores one or more `camera.video_profiles` and exactly one selected profile for H5 video:
20
+ The current platform/Web contract supports all three profiles below on video stream 11. Hardware IR v2 separately stores the profiles the board can produce and exactly one selected profile for H5 video:
21
21
 
22
22
  | Codec | Required output contract |
23
23
  |---|---|
@@ -25,7 +25,7 @@ Hardware IR v2 stores one or more `camera.video_profiles` and exactly one select
25
25
  | `h264` | `h264_annex_b_access_units`: Annex-B access units with SPS/PPS and IDR request behavior |
26
26
  | `h265` | `h265_annex_b_access_units`: Annex-B access units with parameter-set and refresh behavior defined by the coordinated H5 contract |
27
27
 
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.
28
+ Available but unselected board profiles do not satisfy or block the selected contract. A board may select MJPEG even though the platform also accepts H.264 and H.265. Stream IDs and codec support must be verified against the project-local platform contract rather than inferred from board hardware.
29
29
 
30
30
  ## Phased project gates
31
31
 
@@ -36,7 +36,7 @@ The intake phase requires:
36
36
  - one evidenced binding method—verification code, factory-bound identity, development credentials, or documented custom flow—plus stored-binding behavior and reset control;
37
37
  - feature-specific I2S/GPIO ownership plans, channel/TDM mapping, realtime camera policy, and a static startup/media memory budget.
38
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, compile result, or post-link gate establishes it. The build phase requires an exact artifact SHA-256 and returns `BUILD_VERIFIED`. Runtime measurements never need to be invented to pass intake or build.
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), [video contract](video-contract.md), and mandatory [runtime contract](runtime-contract.md); self-declared `resolved=true`, `pipeline_safe=true`, or memory-budget booleans cannot replace them. The build phase requires an exact on-disk 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.
40
40
 
41
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`.
42
42
 
@@ -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;
@@ -13,6 +13,7 @@ The initializer creates schema v2. The validator still accepts schema v1 for exi
13
13
  ## Evidence rules
14
14
 
15
15
  - Give every source a stable `id`, `kind`, `location`, and revision when available.
16
+ - Use one resolvable location per source. Accept an IR-relative local path or an explicit `https:`, `http:`, `device-kit:`, `managed:`, `official:`, `user-input:`, or `user-supplied:` locator. Reject absolute machine paths, concatenated multi-source strings, invented schemes, missing local paths, and declared SHA-256 values that do not match the referenced local file.
16
17
  - Reference source IDs from board facts, selected profiles, onboarding methods, resources, and runtime evidence.
17
18
  - Use `null` for unknown facts. Retain contradictory values as explicit issues instead of choosing silently.
18
19
  - Hardware revision `unspecified` is valid during intake but blocks reusable-board readiness.
@@ -37,10 +38,14 @@ The IR contains:
37
38
  - camera identity evidence plus `video_profiles[]` and `selected_video_profile`;
38
39
  - audio input/output paths;
39
40
  - `hardware_resources` for I2C, I2S/GPIO ownership, audio channel mapping, camera realtime policy, and memory budget;
41
+ - project-relative `hardware_resources.audio_semantic_contract` for every project requesting audio;
42
+ - project-relative `hardware_resources.video_semantic_contract` for every project requesting video;
43
+ - project-relative `hardware_resources.runtime_semantic_contract` for every generated H5/AI project;
40
44
  - `onboarding.wifi_credentials` with selectable SoftAP/BLE/SmartConfig/factory/development/custom methods;
41
45
  - selectable ThingConnect binding methods plus stored-binding states and reset control;
42
46
  - requested features;
43
47
  - optional `runtime_evidence[]`, each bound to a full firmware SHA-256.
48
+ - `build_evidence.artifacts[]` containing each accepted BIN/ELF path, byte size, and full SHA-256.
44
49
 
45
50
  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.
46
51
 
@@ -54,10 +59,11 @@ Run the phase gates in order:
54
59
  python3 <skill-dir>/scripts/hardware_ir.py assess hardware-ir.json \
55
60
  --phase intake --strict
56
61
  python3 <skill-dir>/scripts/hardware_ir.py assess hardware-ir.json \
57
- --phase build --artifact-sha256 <64-character-sha256> --strict
62
+ --phase build --project <generated-project> \
63
+ --artifact-sha256 <64-character-sha256> --strict
58
64
  ```
59
65
 
60
- The intake phase returns `READY_TO_PORT`; the build phase returns `BUILD_VERIFIED`. Missing serial or browser access does not block either phase.
66
+ 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[]`, reopens that project-relative artifact to verify its byte size and SHA-256, and reruns the project-local audio, video, and runtime contracts through `--project`. Missing serial or browser access does not block either phase.
61
67
 
62
68
  Run:
63
69
 
@@ -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 checked, `idf.py build` succeeds, artifacts recorded |
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,7 +16,9 @@ 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 the exact artifact SHA-256 at L1, and the HIL assessment only when matching runtime evidence exists. Missing serial or browser access is a `SKIP` for the affected L2-L7 levels, not an L0/L1 failure.
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. Before assessment, copy final deliverable BIN/ELF files to project-relative `artifacts/` paths and record their actual byte size and SHA-256 in `build_evidence.artifacts[]`; the assessor reopens the file and rejects stale metadata. 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 required runtime, audio, or video semantic gate fails or is missing, record the compiler result and report the feature and project as blocked. After build assessment, remove `build/` without rebuilding and run `project_portability.py --export`; the source deliverable may retain verified `artifacts/` copies but not a machine-bound build tree. H5 image display is L5 evidence, never an inference from L1.
20
22
 
21
23
  ## Evidence
22
24
 
@@ -0,0 +1,34 @@
1
+ # TiRTC runtime protocol contract
2
+
3
+ Use this gate for every generated H5/AI project. It verifies protocol behavior that is
4
+ neither a board clock fact nor a camera fact: service discovery wiring, SDK callback
5
+ lifecycle, stream/media metadata, and AI session negotiation.
6
+
7
+ Copy `assets/tirtc-runtime-contract.example.json` to
8
+ `<project>/tirtc-runtime-contract.json`. Keep the file paths project-relative. The
9
+ Device Kit generator supplies `platform-media-contract.json`; do not recreate it from
10
+ the prompt or replace it with board capability claims.
11
+
12
+ Run and install the gate before the final build:
13
+
14
+ ```bash
15
+ python3 <skill-dir>/scripts/runtime_contract.py \
16
+ <project>/tirtc-runtime-contract.json \
17
+ --project <project> \
18
+ --evidence-out <project>/build/runtime-contract-evidence.json
19
+ python3 <skill-dir>/scripts/install_runtime_gate.py <project>
20
+ ```
21
+
22
+ The gate requires all of the following:
23
+
24
+ - discovered `tirtc-srv` is passed to `TIRTC_OPT_SERVICE_ENDPOINT`;
25
+ - SDK callbacks copy/queue work and never call Disconnect, Stop, or Uninit;
26
+ - H5 streams 10/11/14 and AI stream 1 use the platform media contract;
27
+ - downlink accepts only G.711 A-law, 8 kHz, mono metadata before decoding;
28
+ - platform video capability includes MJPEG, H.264, and H.265 while the board contract
29
+ selects exactly one;
30
+ - AI media starts only after a matching response provides a non-empty session ID and
31
+ authoritative input/output audio formats matching the implemented codec;
32
+ - remote `end_session` converges through the runtime control task.
33
+
34
+ Compilation without this gate is not an H5/AI `BUILD_VERIFIED` result.
@@ -0,0 +1,31 @@
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. Set its project-relative `platform_contract` to the generated `platform-media-contract.json`.
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
+ - the platform/Web contract exposes stream 11 profiles for MJPEG, H.264, and H.265, with the media symbol and framing boundary for each;
11
+ - the board contract selects exactly one codec it can actually produce: `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 that profile;
12
+ - stream ID, SDK media symbol, refresh semantics, and send return handling;
13
+ - maximum encoded frame enforced before send, TiRTC max send buffer, and a lower backpressure threshold;
14
+ - exact accepted sensor identities and rejection of every uncorroborated PID;
15
+ - implementation assertions against `sdkconfig.defaults`, resolved `sdkconfig`, adapter source, TiRTC wrapper, and product composition root.
16
+
17
+ 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. The LCKFB V1.0.1 board selects MJPEG because its evidenced camera path produces JPEG; this board limitation must not be generalized into a platform limitation. 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.
18
+
19
+ After `idf.py reconfigure`, run:
20
+
21
+ ```bash
22
+ python3 <skill-dir>/scripts/video_contract.py \
23
+ <project>/board-video-contract.json \
24
+ --project <project> \
25
+ --evidence-out <project>/build/video-contract-evidence.json
26
+ python3 <skill-dir>/scripts/install_video_gate.py <project>
27
+ ```
28
+
29
+ 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.
30
+
31
+ Compilation without this gate is `COMPILE_PASS / VIDEO_CAPABILITY_BLOCKED`. H5 image display remains L5 and requires runtime evidence bound to the exact artifact.
@@ -62,16 +62,18 @@ The adapter owns:
62
62
  - DMA buffers, hardware clocks, power, reset, GPIO, refresh/key-frame requests, and realtime task allocation;
63
63
  - bounded stop, resource release, and generation-aware flushing.
64
64
 
65
- The stable modules own stream IDs, negotiated/contracted formats, TiRTC callback copying, connection handles, session generation, and H5/AI sequencing.
65
+ The stable modules own the discovered TiRTC service endpoint, stream IDs, negotiated/contracted formats, TiRTC callback copying, connection handles, session generation, and H5/AI sequencing. SDK lifecycle changes such as disconnect run in a worker/state-machine context, never directly inside an SDK callback.
66
66
 
67
67
  ## Verification loop
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
+ For every generated H5/AI project, validate `tirtc-runtime-contract.json` and run `install_runtime_gate.py <project>` before the ordinary build. Audio and video projects additionally install their media gates. A build that bypasses any applicable gate is not `BUILD_VERIFIED`.
72
+
71
73
  Run the assessor once per layer:
72
74
 
73
75
  - `--phase intake`: corroborated design evidence; success is `READY_TO_PORT`.
74
- - `--phase build --artifact-sha256 <sha>`: compile/post-link evidence; success is `BUILD_VERIFIED`.
76
+ - `--phase build --project <project> --artifact-sha256 <sha>`: compile/semantic/post-link evidence; success is `BUILD_VERIFIED`.
75
77
  - `--phase hil --artifact-sha256 <sha>`: matching L5/L6 runtime evidence; success is `HIL_VERIFIED`.
76
78
 
77
79
  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`.
@@ -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())
@@ -124,8 +124,14 @@ def compare_contract(
124
124
  actual = config.get(key)
125
125
  if expected is None:
126
126
  mismatches.append(f"SDK contract does not declare {key}")
127
+ elif actual is None and expected.lower() == "off":
128
+ # ESP-IDF omits some disabled child booleans from the final sdkconfig
129
+ # when their parent dependency is off. An absent boolean therefore has
130
+ # the same effective value as "# ... is not set" for this fixed set of
131
+ # SDK ABI options. Expected-on and scalar values remain strict.
132
+ continue
127
133
  elif actual is None:
128
- mismatches.append(f"project does not explicitly configure {key}={expected}")
134
+ mismatches.append(f"project does not configure required {key}={expected}")
129
135
  elif actual.lower() != expected.lower():
130
136
  mismatches.append(f"{key}: expected {expected}, got {actual}")
131
137
  return mismatches