tirtc-device-builder 0.2.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.
@@ -0,0 +1,49 @@
1
+ # Hardware IR
2
+
3
+ Hardware IR is a generated, reviewable description of one exact board revision. It is the only input consumed by deterministic capability checks. Start from [the example](../assets/hardware-ir.example.json) with:
4
+
5
+ ```bash
6
+ python3 <skill-dir>/scripts/hardware_ir.py init <output>/hardware-ir.json
7
+ ```
8
+
9
+ ## Evidence rules
10
+
11
+ - Give every source a stable `id`, `kind`, `location`, and revision when available.
12
+ - Reference those IDs from `soc`, `toolchain`, `camera`, `audio_input`, and `audio_output`.
13
+ - Use `null` for unknown presence, pins, formats, or encoder properties. Empty strings are invalid facts.
14
+ - Hardware revision `unspecified` is acceptable during intake but blocks registration as a reusable supported board.
15
+ - Keep desired features under `features.requested`; do not encode wishes as hardware facts.
16
+ - Record the strongest evidenced verification level, not the intended future state.
17
+
18
+ Verification levels are ordered:
19
+
20
+ 1. `extracted`: obtained from one source.
21
+ 2. `corroborated`: confirmed by another authoritative artifact, such as schematic plus BSP.
22
+ 3. `build_verified`: the matching implementation builds with the locked toolchain.
23
+ 4. `hardware_verified`: the peripheral works on the exact physical revision.
24
+ 5. `hil_verified`: the requested H5/AI path passes end to end.
25
+
26
+ ## Minimum facts
27
+
28
+ The IR contains:
29
+
30
+ - exact board identity and revision;
31
+ - SoC target, module, Flash, and PSRAM;
32
+ - ESP-IDF and TiRTC SDK platform/version/build contract plus their verification level;
33
+ - camera presence, sensor/interface, and H.264 output/key-frame properties;
34
+ - audio input and output presence, interface, codecs, sample rates, and verification;
35
+ - requested ThingConnect feature IDs.
36
+
37
+ Pin and driver details may live in a board adapter manifest referenced from the IR once the adapter exists. Until then, missing pins remain capability issues even if the high-level media path appears possible.
38
+
39
+ ## Intake quality
40
+
41
+ Preferred input order:
42
+
43
+ 1. exact schematic/netlist and BOM for the physical revision;
44
+ 2. official BSP pinned to a commit or release;
45
+ 3. sensor, codec, amplifier, and module datasheets;
46
+ 4. a minimal project that has been built for the board;
47
+ 5. product pages, README files, photographs, and community material.
48
+
49
+ A schematic establishes electrical connectivity, not driver maturity, encoding throughput, acoustic behavior, or end-to-end TiRTC compatibility. Preserve those as separate verification facts.
@@ -0,0 +1,23 @@
1
+ # Reporting and acceptance
2
+
3
+ Use [the report template](../assets/report-template.md) and preserve separate `PASS`, `FAIL`, and `SKIP` results.
4
+
5
+ ## Acceptance levels
6
+
7
+ | Level | Completion evidence |
8
+ |---|---|
9
+ | L-1 Environment | ESP-IDF version, target compiler, TiRTC SDK, build contract, and requested serial access pass doctor checks |
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 |
12
+ | L2 Boot | Exact chip/port resolved, flash succeeds, firmware boots without panic |
13
+ | L3 Online | Wi-Fi provisioning, binding, MQTT, and TiRTC reach ready state |
14
+ | L4 Media | Camera/microphone/speaker local paths work and counters/measurements are captured |
15
+ | L5 H5 | Browser receives the declared video/audio and talkback reaches the device |
16
+ | L6 AI | Token, WHIP, `start_session`, bidirectional audio, stop, and H5 recovery work |
17
+ | L7 Stability | Requested weak-network, repeated-session, resource, and soak criteria pass |
18
+
19
+ ## Evidence
20
+
21
+ Record exact board revision, 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
+
23
+ Reports describe observed current behavior. A `SKIP` caused by missing hardware, account, service, browser, or external network does not become a pass. If the user's requested completion level includes a skipped critical case, the final outcome remains incomplete.
@@ -0,0 +1,70 @@
1
+ # Workflow
2
+
3
+ ## Select one branch
4
+
5
+ ### Registered board
6
+
7
+ Use this branch when a board and exact hardware revision already have a Hardware IR plus a matching board media adapter.
8
+
9
+ 1. Validate and assess the saved Hardware IR against the requested features.
10
+ 2. Confirm that its BSP, ESP-IDF, TiRTC SDK, and adapter revisions are still resolvable.
11
+ 3. Generate a new starter project without overwriting an existing path.
12
+ 4. Install the matching board adapter and configuration overlay.
13
+ 5. Build, optionally flash, execute the requested acceptance levels, and issue a fresh report.
14
+
15
+ The branch is complete when the new run has its own build and verification evidence; an older board report is provenance, not proof of the new artifact.
16
+
17
+ ### New-board intake
18
+
19
+ Use this branch when the user supplies a board model, vendor URL, schematic, BOM, pin map, BSP, datasheets, photographs, or peripheral example projects without a verified adapter.
20
+
21
+ 1. Resolve the full model, module, PCB marking, and hardware revision. Treat different revisions as different boards.
22
+ 2. 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.
23
+ 3. Cross-check critical pins, clocks, power enables, reset lines, sensor/codec variants, and ESP-IDF version across at least two independent artifacts when possible.
24
+ 4. Create the Hardware IR. Use `null` for unknown facts and retain contradictory values as an explicit issue instead of selecting one silently.
25
+ 5. Validate and assess requested features. Ask only for unresolved facts that block the next safe step.
26
+ 6. When all requirements reach `READY_TO_PORT`, generate the starter and implement the board adapter. When requirements remain blocked, generate only the IR, capability report, and an optional compile-safe skeleton if requested.
27
+
28
+ The branch is complete when every supplied artifact maps to an IR fact, provenance entry, contradiction, or declared irrelevant item.
29
+
30
+ ### Existing project
31
+
32
+ Use this branch when the user supplies an ESP-IDF/BSP project instead of board documents.
33
+
34
+ 1. Inspect its target, `sdkconfig`, partitions, component manifests/CMake, pin definitions, sensor and codec initialization, and working peripheral examples.
35
+ 2. Build the existing minimal peripheral examples when the environment permits; code that compiles for a named target is stronger evidence than prose but does not prove hardware behavior.
36
+ 3. Create the Hardware IR from the project and any companion hardware documents.
37
+ 4. Preserve reusable vendor drivers behind the board adapter. Keep product UI and board code out of ThingConnect session/TiRTC modules.
38
+
39
+ The branch is complete when the reused and replaced parts are explicit and the original project remains intact unless the user requested in-place work.
40
+
41
+ ## Repository document routing
42
+
43
+ Read only the documents for the active branch, but read each selected document completely.
44
+
45
+ - ESP32 starter or adapter work: `device-sim/device-sim-esp32/README.md`, `device-sim/ESP32_STARTER.md`, the selected SDK package README, and the generated template README.
46
+ - H5 live view or talkback: `device-h5-live.md`.
47
+ - AI intercom: `device-ai.md`.
48
+ - H5/AI switching, delayed callbacks, ownership, or timeouts: `device-session-model.md` and `device-session-arbiter.md`.
49
+ - Onboarding, binding, MQTT, token, or identity: `device-integration.md`.
50
+ - Public HTTP field or error changes: `api-reference.md` and `error-response-policy.md`.
51
+
52
+ ## Generation and board seam
53
+
54
+ The runtime-facing `starter_media` interface stays stable. A reusable board integration should implement an internal `BoardMediaAdapterV1`-style adapter owned by `starter_media` rather than editing H5, AI, `starter_runtime`, or `starter_tirtc` for each board.
55
+
56
+ The adapter owns:
57
+
58
+ - camera capture and H.264 encoding tasks;
59
+ - microphone capture and audio encoding;
60
+ - downlink audio decode, buffering, codec, amplifier, and I2S playback;
61
+ - DMA buffers, hardware clocks, power, reset, GPIO, and key-frame requests;
62
+ - bounded stop, resource release, and generation-aware flushing.
63
+
64
+ The stable modules own stream IDs, negotiated/contracted formats, TiRTC callback copying, connection handles, session generation, and H5/AI sequencing.
65
+
66
+ ## Verification loop
67
+
68
+ Use a bounded loop per layer: diagnose one failing invariant, make the smallest correction, and rerun that layer before moving forward. Stop and report when the remaining failure requires unavailable hardware, credentials, a new SDK binary, a public protocol change, or a user choice.
69
+
70
+ Do not use successful compilation as evidence for camera frames, speaker output, Web rendering, AI audio, or long-run stability.
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env python3
2
+ """Diagnose an ESP-IDF and TiRTC ESP32 build environment."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import glob
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ VERSION_PATTERN = re.compile(r"(?:ESP-IDF\s+)?v?(\d+)\.(\d+)(?:\.(\d+))?", re.I)
19
+ CONTRACT_KEYS = {
20
+ "CONFIG_FREERTOS_HZ",
21
+ "CONFIG_FREERTOS_USE_TRACE_FACILITY",
22
+ "CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS",
23
+ "CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS",
24
+ }
25
+ THING_CONNECT_ENV = "TIRTC_THING_CONNECT_ROOT"
26
+ GENERATOR_RELATIVE_PATH = Path("device-sim/scripts/create_esp32_project.py")
27
+ DEFAULT_SDK_RELATIVE_PATH = Path(
28
+ "device-sim/sdk/espressif-esp32s3/2.3.0"
29
+ )
30
+
31
+
32
+ def check(name: str, status: str, detail: str, required: bool = True) -> dict[str, Any]:
33
+ return {
34
+ "name": name,
35
+ "status": status,
36
+ "required": required,
37
+ "detail": detail,
38
+ }
39
+
40
+
41
+ def parse_version(text: str) -> tuple[int, ...] | None:
42
+ match = VERSION_PATTERN.search(text)
43
+ if match is None:
44
+ return None
45
+ parts = [int(match.group(1)), int(match.group(2))]
46
+ if match.group(3) is not None:
47
+ parts.append(int(match.group(3)))
48
+ return tuple(parts)
49
+
50
+
51
+ def version_matches(actual: tuple[int, ...] | None, expected: str) -> bool:
52
+ if actual is None:
53
+ return False
54
+ expected_match = re.fullmatch(r"v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.x)?", expected)
55
+ if expected_match is None:
56
+ raise ValueError(f"invalid expected ESP-IDF version: {expected}")
57
+ expected_parts = tuple(
58
+ int(part) for part in expected_match.groups() if part is not None
59
+ )
60
+ return actual[: len(expected_parts)] == expected_parts
61
+
62
+
63
+ def run_version(command: list[str]) -> tuple[int, str]:
64
+ try:
65
+ completed = subprocess.run(
66
+ command,
67
+ check=False,
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=15,
71
+ )
72
+ except (OSError, subprocess.TimeoutExpired) as exc:
73
+ return 1, str(exc)
74
+ output = "\n".join(part for part in (completed.stdout, completed.stderr) if part)
75
+ return completed.returncode, output.strip()
76
+
77
+
78
+ def find_idf(explicit: Path | None) -> tuple[Path | None, str]:
79
+ if explicit is not None:
80
+ return explicit.resolve(), "explicit --idf-py"
81
+ on_path = shutil.which("idf.py")
82
+ if on_path:
83
+ return Path(on_path).resolve(), "PATH"
84
+ idf_path = os.environ.get("IDF_PATH")
85
+ if idf_path:
86
+ candidate = Path(idf_path).expanduser() / "tools" / "idf.py"
87
+ if candidate.is_file():
88
+ return candidate.resolve(), "IDF_PATH"
89
+ return None, "not found"
90
+
91
+
92
+ def parse_env_file(path: Path) -> dict[str, str]:
93
+ values: dict[str, str] = {}
94
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
95
+ line = raw_line.strip()
96
+ if not line or line.startswith("#") or "=" not in line:
97
+ continue
98
+ key, value = line.split("=", 1)
99
+ values[key.strip()] = value.strip().strip('"')
100
+ return values
101
+
102
+
103
+ def parse_kconfig(path: Path) -> dict[str, str]:
104
+ values: dict[str, str] = {}
105
+ unset_pattern = re.compile(r"#\s+(CONFIG_[A-Z0-9_]+)\s+is not set")
106
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
107
+ line = raw_line.strip()
108
+ unset = unset_pattern.fullmatch(line)
109
+ if unset:
110
+ values[unset.group(1)] = "off"
111
+ elif line.startswith("CONFIG_") and "=" in line:
112
+ key, value = line.split("=", 1)
113
+ normalized = value.strip().strip('"')
114
+ values[key] = "on" if normalized == "y" else normalized
115
+ return values
116
+
117
+
118
+ def compare_contract(
119
+ contract: dict[str, str], config: dict[str, str]
120
+ ) -> list[str]:
121
+ mismatches: list[str] = []
122
+ for key in sorted(CONTRACT_KEYS):
123
+ expected = contract.get(key)
124
+ actual = config.get(key)
125
+ if expected is None:
126
+ mismatches.append(f"SDK contract does not declare {key}")
127
+ elif actual is None:
128
+ mismatches.append(f"project does not explicitly configure {key}={expected}")
129
+ elif actual.lower() != expected.lower():
130
+ mismatches.append(f"{key}: expected {expected}, got {actual}")
131
+ return mismatches
132
+
133
+
134
+ def compiler_name(target: str) -> str:
135
+ names = {
136
+ "esp32s3": "xtensa-esp32s3-elf-gcc",
137
+ "esp32": "xtensa-esp32-elf-gcc",
138
+ "esp32c3": "riscv32-esp-elf-gcc",
139
+ "esp32c6": "riscv32-esp-elf-gcc",
140
+ "esp32p4": "riscv32-esp-elf-gcc",
141
+ }
142
+ return names.get(target, "")
143
+
144
+
145
+ def discover_serial_ports() -> list[str]:
146
+ if os.name == "nt":
147
+ return []
148
+ ports: list[str] = []
149
+ for pattern in ("/dev/ttyACM*", "/dev/ttyUSB*", "/dev/cu.usb*"):
150
+ ports.extend(glob.glob(pattern))
151
+ return sorted(set(ports))
152
+
153
+
154
+ def normalize_thing_connect_root(candidate: Path) -> Path | None:
155
+ """Accept either the ThingConnect directory or its parent repository."""
156
+ resolved = candidate.expanduser().resolve()
157
+ for root in (resolved, resolved / "thing-connect"):
158
+ if (root / GENERATOR_RELATIVE_PATH).is_file():
159
+ return root
160
+ return None
161
+
162
+
163
+ def find_thing_connect_root(
164
+ explicit: Path | None,
165
+ project: Path | None,
166
+ *,
167
+ cwd: Path | None = None,
168
+ ) -> tuple[Path | None, str]:
169
+ if explicit is not None:
170
+ return normalize_thing_connect_root(explicit), "explicit --thing-connect-root"
171
+
172
+ from_environment = os.environ.get(THING_CONNECT_ENV)
173
+ if from_environment:
174
+ return (
175
+ normalize_thing_connect_root(Path(from_environment)),
176
+ THING_CONNECT_ENV,
177
+ )
178
+
179
+ search_starts = [path for path in (project, cwd or Path.cwd()) if path is not None]
180
+ seen: set[Path] = set()
181
+ for start in search_starts:
182
+ resolved = start.expanduser().resolve()
183
+ if resolved.is_file():
184
+ resolved = resolved.parent
185
+ for candidate in (resolved, *resolved.parents):
186
+ if candidate in seen:
187
+ continue
188
+ seen.add(candidate)
189
+ root = normalize_thing_connect_root(candidate)
190
+ if root is not None:
191
+ return root, f"discovered from {start}"
192
+ return None, "not found"
193
+
194
+
195
+ def resolve_sdk_dir(
196
+ explicit: Path | None,
197
+ project: Path | None,
198
+ thing_connect_root: Path | None,
199
+ ) -> tuple[Path | None, str]:
200
+ if explicit is not None:
201
+ return explicit.expanduser().resolve(), "explicit --sdk-dir"
202
+ if project is not None:
203
+ bundled = project.expanduser().resolve() / "third_party" / "tirtc"
204
+ if bundled.is_dir():
205
+ return bundled, "generated project"
206
+ if thing_connect_root is not None:
207
+ return thing_connect_root / DEFAULT_SDK_RELATIVE_PATH, "ThingConnect workspace"
208
+ return None, "not found"
209
+
210
+
211
+ def diagnose(args: argparse.Namespace) -> dict[str, Any]:
212
+ checks: list[dict[str, Any]] = []
213
+ next_actions: list[str] = []
214
+
215
+ checks.append(check("python3", "PASS", sys.version.split()[0]))
216
+ for tool, required in (("git", True), ("cmake", False), ("ninja", False)):
217
+ location = shutil.which(tool)
218
+ status = "PASS" if location else ("FAIL" if required else "WARN")
219
+ checks.append(check(tool, status, location or "not found", required))
220
+
221
+ idf_path, idf_source = find_idf(args.idf_py)
222
+ if idf_path is None or not idf_path.is_file():
223
+ checks.append(check("idf.py", "FAIL", "not found in PATH or IDF_PATH"))
224
+ next_actions.append(
225
+ f"Install or activate an official ESP-IDF {args.expected_idf}.x environment, then rerun doctor."
226
+ )
227
+ else:
228
+ command = [str(idf_path), "--version"]
229
+ if idf_path.suffix == ".py" and not os.access(idf_path, os.X_OK):
230
+ command.insert(0, sys.executable)
231
+ returncode, output = run_version(command)
232
+ actual = parse_version(output)
233
+ if returncode != 0:
234
+ status = "FAIL"
235
+ detail = f"{idf_path} failed: {output or f'exit {returncode}'}"
236
+ elif not version_matches(actual, args.expected_idf):
237
+ status = "FAIL"
238
+ detail = f"{output}; expected {args.expected_idf}.x"
239
+ else:
240
+ status = "PASS"
241
+ detail = f"{output} ({idf_source}: {idf_path})"
242
+ checks.append(check("idf.py", status, detail))
243
+ if idf_source == "IDF_PATH" and shutil.which("idf.py") is None:
244
+ checks.append(
245
+ check(
246
+ "idf activation",
247
+ "FAIL",
248
+ "IDF_PATH contains idf.py but the current shell is not exported",
249
+ )
250
+ )
251
+ next_actions.append("Activate the existing IDF_PATH installation; do not install a duplicate copy.")
252
+
253
+ compiler = compiler_name(args.target)
254
+ if compiler:
255
+ compiler_path = shutil.which(compiler)
256
+ checks.append(
257
+ check(
258
+ "target compiler",
259
+ "PASS" if compiler_path else "FAIL",
260
+ compiler_path or f"{compiler} not found; ESP-IDF environment may be inactive",
261
+ )
262
+ )
263
+ else:
264
+ checks.append(check("target compiler", "FAIL", f"unsupported target {args.target}"))
265
+
266
+ thing_connect_root, thing_connect_source = find_thing_connect_root(
267
+ args.thing_connect_root,
268
+ args.project,
269
+ )
270
+ workspace_status = "PASS" if thing_connect_root else (
271
+ "FAIL" if args.require_workspace else "WARN"
272
+ )
273
+ checks.append(
274
+ check(
275
+ "ThingConnect workspace",
276
+ workspace_status,
277
+ (
278
+ f"{thing_connect_root} ({thing_connect_source})"
279
+ if thing_connect_root
280
+ else (
281
+ f"not found; pass --thing-connect-root or set {THING_CONNECT_ENV}"
282
+ )
283
+ ),
284
+ required=args.require_workspace,
285
+ )
286
+ )
287
+ if args.require_workspace and thing_connect_root is None:
288
+ next_actions.append(
289
+ "Clone the public ThingConnect repository or pass its absolute path with "
290
+ "--thing-connect-root."
291
+ )
292
+
293
+ sdk_dir, sdk_source = resolve_sdk_dir(
294
+ args.sdk_dir,
295
+ args.project,
296
+ thing_connect_root,
297
+ )
298
+ if sdk_dir is None:
299
+ sdk_files: list[Path] = []
300
+ missing_sdk = [
301
+ "SDK location unresolved; pass --sdk-dir, --project, or --thing-connect-root"
302
+ ]
303
+ sdk_detail = missing_sdk[0]
304
+ else:
305
+ sdk_files = [
306
+ sdk_dir / "include" / "tirtc" / "tiRTC.h",
307
+ sdk_dir / "lib" / "libTiRTC.a",
308
+ sdk_dir / "manifest" / "build-contract.env",
309
+ ]
310
+ missing_sdk = [str(path) for path in sdk_files if not path.is_file()]
311
+ sdk_detail = (
312
+ "missing: " + ", ".join(missing_sdk)
313
+ if missing_sdk
314
+ else f"{sdk_dir} ({sdk_source})"
315
+ )
316
+ checks.append(
317
+ check(
318
+ "TiRTC SDK",
319
+ "FAIL" if missing_sdk else "PASS",
320
+ sdk_detail,
321
+ )
322
+ )
323
+
324
+ if args.project is not None:
325
+ project = args.project.resolve()
326
+ config_path = project / "sdkconfig"
327
+ if not config_path.is_file():
328
+ config_path = project / "sdkconfig.defaults"
329
+ contract_path = (
330
+ sdk_dir / "manifest" / "build-contract.env"
331
+ if sdk_dir is not None
332
+ else None
333
+ )
334
+ if not config_path.is_file():
335
+ checks.append(
336
+ check(
337
+ "TiRTC build contract",
338
+ "FAIL",
339
+ f"no sdkconfig or sdkconfig.defaults in {project}",
340
+ )
341
+ )
342
+ elif contract_path is None or not contract_path.is_file():
343
+ detail = (
344
+ "TiRTC SDK location is unresolved"
345
+ if contract_path is None
346
+ else f"missing {contract_path}"
347
+ )
348
+ checks.append(check("TiRTC build contract", "FAIL", detail))
349
+ else:
350
+ mismatches = compare_contract(
351
+ parse_env_file(contract_path), parse_kconfig(config_path)
352
+ )
353
+ checks.append(
354
+ check(
355
+ "TiRTC build contract",
356
+ "FAIL" if mismatches else "PASS",
357
+ "; ".join(mismatches) if mismatches else f"matches {config_path}",
358
+ )
359
+ )
360
+
361
+ ports = discover_serial_ports()
362
+ if args.serial_port:
363
+ serial = Path(args.serial_port)
364
+ if serial.exists() and os.access(serial, os.R_OK | os.W_OK):
365
+ serial_status, serial_detail = "PASS", f"{serial} is readable and writable"
366
+ elif serial.exists():
367
+ serial_status, serial_detail = "FAIL", f"{serial} lacks read/write permission"
368
+ else:
369
+ serial_status, serial_detail = "FAIL", f"{serial} does not exist"
370
+ checks.append(check("serial port", serial_status, serial_detail))
371
+ else:
372
+ checks.append(
373
+ check(
374
+ "serial discovery",
375
+ "PASS" if ports else "WARN",
376
+ ", ".join(ports) if ports else "no serial device detected",
377
+ required=False,
378
+ )
379
+ )
380
+
381
+ overall = "FAIL" if any(
382
+ item["required"] and item["status"] == "FAIL" for item in checks
383
+ ) else "PASS"
384
+ return {
385
+ "overall": overall,
386
+ "expected_idf": args.expected_idf,
387
+ "target": args.target,
388
+ "thing_connect_root": (
389
+ str(thing_connect_root) if thing_connect_root is not None else None
390
+ ),
391
+ "sdk_dir": str(sdk_dir) if sdk_dir is not None else None,
392
+ "checks": checks,
393
+ "next_actions": next_actions,
394
+ }
395
+
396
+
397
+ def print_human(result: dict[str, Any]) -> None:
398
+ for item in result["checks"]:
399
+ suffix = " [required]" if item["required"] else ""
400
+ print(f"{item['status']:4} {item['name']}{suffix}: {item['detail']}")
401
+ print(f"OVERALL: {result['overall']}")
402
+ for action in result["next_actions"]:
403
+ print(f"NEXT: {action}")
404
+
405
+
406
+ def parse_args() -> argparse.Namespace:
407
+ parser = argparse.ArgumentParser(
408
+ description="Check ESP-IDF, target tools, TiRTC SDK, project contract, and serial access."
409
+ )
410
+ parser.add_argument("--expected-idf", default="5.5")
411
+ parser.add_argument("--target", default="esp32s3")
412
+ parser.add_argument("--idf-py", type=Path)
413
+ parser.add_argument("--thing-connect-root", type=Path)
414
+ parser.add_argument(
415
+ "--require-workspace",
416
+ action="store_true",
417
+ help="fail when a ThingConnect workspace with the ESP32 generator is unavailable",
418
+ )
419
+ parser.add_argument("--sdk-dir", type=Path)
420
+ parser.add_argument("--project", type=Path)
421
+ parser.add_argument("--serial-port")
422
+ parser.add_argument("--json", action="store_true")
423
+ return parser.parse_args()
424
+
425
+
426
+ def main() -> int:
427
+ args = parse_args()
428
+ try:
429
+ result = diagnose(args)
430
+ except ValueError as exc:
431
+ print(str(exc), file=sys.stderr)
432
+ return 2
433
+ if args.json:
434
+ print(json.dumps(result, ensure_ascii=False, indent=2))
435
+ else:
436
+ print_human(result)
437
+ return 0 if result["overall"] == "PASS" else 2
438
+
439
+
440
+ if __name__ == "__main__":
441
+ raise SystemExit(main())