tirtc-device-builder 0.7.1 → 0.7.3
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 +16 -0
- package/README.md +2 -2
- package/bin/setup-esp32.js +149 -23
- package/package.json +1 -1
- package/skills/tirtc-esp32-builder/SKILL.md +4 -4
- package/skills/tirtc-esp32-builder/VERSION +1 -0
- package/skills/tirtc-esp32-builder/assets/board-audio-contract.example.json +1 -0
- package/skills/tirtc-esp32-builder/assets/lckfb-szpi-esp32s3-portable-prompt.md +4 -4
- package/skills/tirtc-esp32-builder/references/audio-contract.md +25 -3
- package/skills/tirtc-esp32-builder/references/environment.md +14 -2
- package/skills/tirtc-esp32-builder/references/hardware-ir.md +5 -0
- package/skills/tirtc-esp32-builder/references/porting-risks.md +25 -0
- package/skills/tirtc-esp32-builder/scripts/audio_contract.py +103 -3
- package/skills/tirtc-esp32-builder/scripts/doctor.py +82 -14
- package/skills/tirtc-esp32-builder/scripts/project_portability.py +178 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
This project follows Semantic Versioning.
|
|
4
4
|
|
|
5
|
+
## 0.7.3
|
|
6
|
+
|
|
7
|
+
- Make source export reject missing or Git-ignored Hardware IR, requested-feature
|
|
8
|
+
contracts, dependency locks, custom partition tables, and retained build
|
|
9
|
+
artifacts.
|
|
10
|
+
- Support frame-compatible paired standard/TDM full-duplex audio and validate
|
|
11
|
+
hardware-reference AEC microphone/reference slot mapping.
|
|
12
|
+
- Document ESP32-S3 FPU task affinity, watchdog fairness, software-encoder task
|
|
13
|
+
isolation, and PSRAM DMA staging as distinct bring-up risks.
|
|
14
|
+
|
|
15
|
+
## 0.7.2
|
|
16
|
+
|
|
17
|
+
- Require the selected managed ESP32 Device Kit manifest to match the pinned Kit version; ignore stale managed references instead of reporting an older structurally complete Kit as ready.
|
|
18
|
+
- Make Doctor validate an exact `--expected-kit` version and persist the version actually read from the selected Kit rather than the desired version.
|
|
19
|
+
- Ship a verifiable Skill `VERSION` marker, include it in setup readiness and package tests, and remove the inapplicable Plugin-version prerequisite from the portable clean-room prompt.
|
|
20
|
+
|
|
5
21
|
## 0.7.1
|
|
6
22
|
|
|
7
23
|
- Split clean-room bootstrap from Skill execution: install or replace the pinned Skill before Codex starts, restart the session, then run read-only version and Doctor checks before generation.
|
package/README.md
CHANGED
|
@@ -881,8 +881,8 @@ metadata 中的版本、标签、上游 commit 和期望 SHA-256 必须与本地
|
|
|
881
881
|
|
|
882
882
|
```bash
|
|
883
883
|
npm test
|
|
884
|
-
git tag -a v0.7.
|
|
885
|
-
git push origin v0.7.
|
|
884
|
+
git tag -a v0.7.3 -m "v0.7.3"
|
|
885
|
+
git push origin v0.7.3
|
|
886
886
|
```
|
|
887
887
|
|
|
888
888
|
不要重复发布已经存在的 npm 版本。版本变化同步更新 `package.json`、`.codex-plugin/plugin.json` 和发布说明。
|
package/bin/setup-esp32.js
CHANGED
|
@@ -33,6 +33,8 @@ const REQUIRED_SDK_FILES = [
|
|
|
33
33
|
join("lib", "libTiRTC.a"),
|
|
34
34
|
join("manifest", "build-contract.env"),
|
|
35
35
|
];
|
|
36
|
+
const DEVICE_KIT_MANIFEST = "manifest.json";
|
|
37
|
+
const SKILL_VERSION_FILE = "VERSION";
|
|
36
38
|
|
|
37
39
|
function setupRootFrom(environment) {
|
|
38
40
|
return resolve(
|
|
@@ -199,14 +201,71 @@ function discoverThingConnectRoot(start) {
|
|
|
199
201
|
}
|
|
200
202
|
}
|
|
201
203
|
|
|
202
|
-
function
|
|
203
|
-
if (!
|
|
204
|
-
return
|
|
204
|
+
function readTrimmedFile(path) {
|
|
205
|
+
if (!existsSync(path)) {
|
|
206
|
+
return null;
|
|
205
207
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
try {
|
|
209
|
+
const value = readFileSync(path, "utf8").trim();
|
|
210
|
+
return value || null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function inspectDeviceKit(root) {
|
|
217
|
+
const manifestPath = root ? join(root, DEVICE_KIT_MANIFEST) : null;
|
|
218
|
+
let manifestPresent = false;
|
|
219
|
+
let version = null;
|
|
220
|
+
let manifestError = null;
|
|
221
|
+
if (manifestPath && existsSync(manifestPath)) {
|
|
222
|
+
manifestPresent = true;
|
|
223
|
+
try {
|
|
224
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
225
|
+
if (
|
|
226
|
+
manifest &&
|
|
227
|
+
typeof manifest === "object" &&
|
|
228
|
+
typeof manifest.kit_version === "string" &&
|
|
229
|
+
manifest.kit_version.trim()
|
|
230
|
+
) {
|
|
231
|
+
version = manifest.kit_version.trim();
|
|
232
|
+
} else {
|
|
233
|
+
manifestError = "manifest.json does not declare kit_version";
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
manifestError = `invalid manifest.json: ${
|
|
237
|
+
error instanceof Error ? error.message : String(error)
|
|
238
|
+
}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const generatorReady = Boolean(
|
|
243
|
+
root && existsSync(join(root, GENERATOR_PATH)),
|
|
209
244
|
);
|
|
245
|
+
const sdk = root ? join(root, SDK_PATH) : null;
|
|
246
|
+
const sdkReady = Boolean(
|
|
247
|
+
sdk &&
|
|
248
|
+
REQUIRED_SDK_FILES.every((relative) => existsSync(join(sdk, relative))),
|
|
249
|
+
);
|
|
250
|
+
const structureReady = generatorReady && sdkReady;
|
|
251
|
+
const versionCompatible =
|
|
252
|
+
!manifestPresent ||
|
|
253
|
+
(manifestError === null && version === ESP32_KIT.version);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
expectedVersion: ESP32_KIT.version,
|
|
257
|
+
manifestError,
|
|
258
|
+
manifestPath,
|
|
259
|
+
manifestPresent,
|
|
260
|
+
ready: structureReady && versionCompatible,
|
|
261
|
+
structureReady,
|
|
262
|
+
version,
|
|
263
|
+
versionCompatible,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function readSkillVersion(skillRoot) {
|
|
268
|
+
return readTrimmedFile(join(skillRoot, SKILL_VERSION_FILE));
|
|
210
269
|
}
|
|
211
270
|
|
|
212
271
|
function readConfig(path) {
|
|
@@ -358,7 +417,7 @@ function writeManagedEnvironment(context, packageVersion) {
|
|
|
358
417
|
idf_version: PINNED_IDF_VERSION,
|
|
359
418
|
idf_dir: context.idfDir,
|
|
360
419
|
idf_tools_path: context.idfToolsPath,
|
|
361
|
-
device_kit_version:
|
|
420
|
+
device_kit_version: context.deviceKit.version,
|
|
362
421
|
device_kit_root: context.thingConnectRoot,
|
|
363
422
|
thing_connect_root: context.thingConnectRoot,
|
|
364
423
|
skills_dir: context.skillsDir,
|
|
@@ -418,11 +477,31 @@ function setupContext(options, runtime) {
|
|
|
418
477
|
requestedThingConnect = managedThingConnect;
|
|
419
478
|
}
|
|
420
479
|
}
|
|
421
|
-
|
|
480
|
+
let normalizedThingConnect = normalizeThingConnectRoot(
|
|
422
481
|
requestedThingConnect,
|
|
423
482
|
);
|
|
424
|
-
|
|
483
|
+
let thingConnectRoot =
|
|
425
484
|
normalizedThingConnect || resolve(requestedThingConnect);
|
|
485
|
+
let deviceKit = inspectDeviceKit(thingConnectRoot);
|
|
486
|
+
let thingConnectFallback = null;
|
|
487
|
+
const versionedKitMismatch =
|
|
488
|
+
deviceKit.manifestPresent && !deviceKit.versionCompatible;
|
|
489
|
+
const staleManagedReference =
|
|
490
|
+
(thingConnectSource === "TIRTC_THING_CONNECT_ROOT" ||
|
|
491
|
+
thingConnectSource === "managed config") &&
|
|
492
|
+
!deviceKit.structureReady;
|
|
493
|
+
if (
|
|
494
|
+
thingConnectSource !== "explicit --thing-connect-root" &&
|
|
495
|
+
(versionedKitMismatch || staleManagedReference)
|
|
496
|
+
) {
|
|
497
|
+
thingConnectFallback = `${thingConnectSource}: ${thingConnectRoot}`;
|
|
498
|
+
requestedThingConnect = managedThingConnect;
|
|
499
|
+
thingConnectSource = "managed Device Kit";
|
|
500
|
+
normalizedThingConnect = normalizeThingConnectRoot(requestedThingConnect);
|
|
501
|
+
thingConnectRoot =
|
|
502
|
+
normalizedThingConnect || resolve(requestedThingConnect);
|
|
503
|
+
deviceKit = inspectDeviceKit(thingConnectRoot);
|
|
504
|
+
}
|
|
426
505
|
|
|
427
506
|
const active = currentIdf(environment);
|
|
428
507
|
let idfDir;
|
|
@@ -459,6 +538,8 @@ function setupContext(options, runtime) {
|
|
|
459
538
|
? { ...active, directoryReady: directoryIdf.ready }
|
|
460
539
|
: { ...directoryIdf, root: idfDir, directoryReady: directoryIdf.ready };
|
|
461
540
|
|
|
541
|
+
const skillTarget = join(options.skillsDir, runtime.platform.skill);
|
|
542
|
+
const skillVersion = readSkillVersion(skillTarget);
|
|
462
543
|
return {
|
|
463
544
|
activeIdf: active,
|
|
464
545
|
configPath,
|
|
@@ -468,9 +549,17 @@ function setupContext(options, runtime) {
|
|
|
468
549
|
idfToolsPath,
|
|
469
550
|
managedThingConnect,
|
|
470
551
|
rootDir: options.rootDir,
|
|
471
|
-
|
|
552
|
+
deviceKit,
|
|
553
|
+
skillReady:
|
|
554
|
+
existsSync(join(skillTarget, "SKILL.md")) &&
|
|
555
|
+
skillVersion === runtime.packageVersion,
|
|
556
|
+
skillTarget,
|
|
557
|
+
skillVersion,
|
|
472
558
|
skillsDir: options.skillsDir,
|
|
473
|
-
|
|
559
|
+
thingConnectFallback,
|
|
560
|
+
thingConnectManaged:
|
|
561
|
+
resolve(thingConnectRoot) === resolve(managedThingConnect),
|
|
562
|
+
thingConnectReady: deviceKit.ready,
|
|
474
563
|
thingConnectRoot,
|
|
475
564
|
thingConnectSource,
|
|
476
565
|
};
|
|
@@ -479,15 +568,29 @@ function setupContext(options, runtime) {
|
|
|
479
568
|
function printState(context, runtime) {
|
|
480
569
|
printCheck("INFO", "setup root", context.rootDir);
|
|
481
570
|
printCheck(
|
|
482
|
-
|
|
571
|
+
context.skillReady ? "PASS" : "MISS",
|
|
483
572
|
"Codex Skill",
|
|
484
|
-
context.skillTarget
|
|
573
|
+
`${context.skillTarget} (version ${
|
|
574
|
+
context.skillVersion || "missing"
|
|
575
|
+
}; expected ${runtime.packageVersion})`,
|
|
485
576
|
);
|
|
577
|
+
const kitVersion = context.deviceKit.manifestPresent
|
|
578
|
+
? context.deviceKit.version || "invalid manifest"
|
|
579
|
+
: "legacy workspace (unversioned)";
|
|
486
580
|
printCheck(
|
|
487
581
|
context.thingConnectReady ? "PASS" : "MISS",
|
|
488
582
|
"ESP32 Device Kit",
|
|
489
|
-
`${context.thingConnectRoot} (${context.thingConnectSource}
|
|
583
|
+
`${context.thingConnectRoot} (${context.thingConnectSource}; version ${
|
|
584
|
+
kitVersion
|
|
585
|
+
}; expected ${ESP32_KIT.version})`,
|
|
490
586
|
);
|
|
587
|
+
if (context.thingConnectFallback) {
|
|
588
|
+
printCheck(
|
|
589
|
+
"INFO",
|
|
590
|
+
"ignored stale Kit reference",
|
|
591
|
+
context.thingConnectFallback,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
491
594
|
printCheck(
|
|
492
595
|
context.idf.ready ? "PASS" : "MISS",
|
|
493
596
|
"ESP-IDF",
|
|
@@ -534,10 +637,19 @@ function printSystemDependencyHelp(missing) {
|
|
|
534
637
|
|
|
535
638
|
function installSkill(options, context, runtime) {
|
|
536
639
|
const present = existsSync(join(context.skillTarget, "SKILL.md"));
|
|
537
|
-
if (present && !options.forceSkill) {
|
|
538
|
-
console.log(
|
|
640
|
+
if (present && context.skillReady && !options.forceSkill) {
|
|
641
|
+
console.log(
|
|
642
|
+
`SKIP Codex Skill ${runtime.packageVersion} already exists: ${context.skillTarget}`,
|
|
643
|
+
);
|
|
539
644
|
return;
|
|
540
645
|
}
|
|
646
|
+
if (present && !options.forceSkill) {
|
|
647
|
+
throw new Error(
|
|
648
|
+
`Codex Skill at ${context.skillTarget} has version ${
|
|
649
|
+
context.skillVersion || "missing"
|
|
650
|
+
}, expected ${runtime.packageVersion}; rerun with --force-skill to replace it`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
541
653
|
const args = [
|
|
542
654
|
runtime.cliPath,
|
|
543
655
|
"install",
|
|
@@ -560,9 +672,14 @@ function installDeviceKit(options, context, runtime) {
|
|
|
560
672
|
);
|
|
561
673
|
return context.thingConnectRoot;
|
|
562
674
|
}
|
|
563
|
-
if (context.
|
|
675
|
+
if (!context.thingConnectManaged) {
|
|
676
|
+
const reason =
|
|
677
|
+
context.deviceKit.manifestError ||
|
|
678
|
+
(context.deviceKit.manifestPresent
|
|
679
|
+
? `version ${context.deviceKit.version || "missing"}; expected ${ESP32_KIT.version}`
|
|
680
|
+
: "missing generator or TiRTC SDK");
|
|
564
681
|
throw new Error(
|
|
565
|
-
`${context.thingConnectSource}
|
|
682
|
+
`${context.thingConnectSource} is not a compatible ESP32 Device Kit (${reason}): ${context.thingConnectRoot}`,
|
|
566
683
|
);
|
|
567
684
|
}
|
|
568
685
|
if (existsSync(context.managedThingConnect)) {
|
|
@@ -577,9 +694,10 @@ function installDeviceKit(options, context, runtime) {
|
|
|
577
694
|
}
|
|
578
695
|
runOrThrow(process.execPath, args, { environment: runtime.environment });
|
|
579
696
|
const root = normalizeThingConnectRoot(context.managedThingConnect);
|
|
580
|
-
|
|
697
|
+
const installedKit = inspectDeviceKit(root);
|
|
698
|
+
if (!installedKit.ready || installedKit.version !== ESP32_KIT.version) {
|
|
581
699
|
throw new Error(
|
|
582
|
-
`installed ESP32 Device Kit is
|
|
700
|
+
`installed ESP32 Device Kit is incomplete or has the wrong version: ${context.managedThingConnect}`,
|
|
583
701
|
);
|
|
584
702
|
}
|
|
585
703
|
return root;
|
|
@@ -658,6 +776,9 @@ function runDoctor(context, runtime) {
|
|
|
658
776
|
context.thingConnectRoot,
|
|
659
777
|
"--require-workspace",
|
|
660
778
|
];
|
|
779
|
+
if (context.deviceKit.manifestPresent) {
|
|
780
|
+
args.push("--expected-kit", ESP32_KIT.version);
|
|
781
|
+
}
|
|
661
782
|
let result;
|
|
662
783
|
if (context.idfDir && existsSync(join(context.idfDir, "export.sh"))) {
|
|
663
784
|
result = activatedResult(
|
|
@@ -691,7 +812,7 @@ function runDoctor(context, runtime) {
|
|
|
691
812
|
|
|
692
813
|
function isReady(context) {
|
|
693
814
|
return (
|
|
694
|
-
|
|
815
|
+
context.skillReady &&
|
|
695
816
|
context.thingConnectReady &&
|
|
696
817
|
context.idf.ready
|
|
697
818
|
);
|
|
@@ -729,7 +850,7 @@ export function runEsp32Setup(args, input) {
|
|
|
729
850
|
if (!isReady(context)) {
|
|
730
851
|
console.log("OVERALL: NEEDS_SETUP");
|
|
731
852
|
console.log(
|
|
732
|
-
|
|
853
|
+
`NEXT: npx tirtc-device-builder@${runtime.packageVersion} setup esp32 --install`,
|
|
733
854
|
);
|
|
734
855
|
return 1;
|
|
735
856
|
}
|
|
@@ -756,7 +877,12 @@ export function runEsp32Setup(args, input) {
|
|
|
756
877
|
try {
|
|
757
878
|
installSkill(options, context, runtime);
|
|
758
879
|
const thingConnectRoot = installDeviceKit(options, context, runtime);
|
|
759
|
-
context = {
|
|
880
|
+
context = {
|
|
881
|
+
...context,
|
|
882
|
+
deviceKit: inspectDeviceKit(thingConnectRoot),
|
|
883
|
+
thingConnectRoot,
|
|
884
|
+
thingConnectReady: true,
|
|
885
|
+
};
|
|
760
886
|
installIdf(context, runtime);
|
|
761
887
|
context = setupContext(options, {
|
|
762
888
|
...runtime,
|
package/package.json
CHANGED
|
@@ -9,9 +9,9 @@ Turn board evidence into an evidence-backed ESP-IDF project. Treat the Hardware
|
|
|
9
9
|
|
|
10
10
|
## Start
|
|
11
11
|
|
|
12
|
-
1. Read [environment.md](references/environment.md). For a first-time setup or a request to check/install prerequisites, prefer `npx tirtc-device-builder@latest setup esp32`. Run its `--install` mode only when the user explicitly authorizes installation at the displayed destinations. The installer may create user-space files but never grants permission for `sudo` or persistent shell-profile edits.
|
|
13
|
-
2. Locate the versioned ESP32 Device Kit root containing `device-sim/` using the explicit input, `TIRTC_THING_CONNECT_ROOT`, the managed setup configuration, or workspace discovery.
|
|
14
|
-
3. Run the Doctor through the managed environment helper when one exists; otherwise run `python3 <skill-dir>/scripts/doctor.py --expected-idf 5.5 --target esp32s3`. Add `--require-workspace`
|
|
12
|
+
1. Read this Skill's `VERSION` file and [environment.md](references/environment.md). Record the `VERSION` value as the installed Skill version; never infer it from the prompt or an npm command run elsewhere. For a first-time setup or a request to check/install prerequisites, prefer `npx tirtc-device-builder@latest setup esp32`. Run its `--install` mode only when the user explicitly authorizes installation at the displayed destinations. The installer may create user-space files but never grants permission for `sudo` or persistent shell-profile edits.
|
|
13
|
+
2. Locate the versioned ESP32 Device Kit root containing `device-sim/` using the explicit input, `TIRTC_THING_CONNECT_ROOT`, the managed setup configuration, or workspace discovery. For a managed Kit, read `manifest.json`, require its exact expected `kit_version`, and treat its manifest and packaged protocol documents as generation facts. An older but structurally complete Kit is not compatible. If the user explicitly supplies an unversioned full ThingConnect source workspace, treat it as legacy input and also read its applicable `AGENTS.md`.
|
|
14
|
+
3. Run the Doctor through the managed environment helper when one exists; otherwise run `python3 <skill-dir>/scripts/doctor.py --expected-idf 5.5 --target esp32s3`. Add `--expected-kit <kit-version> --require-workspace` for managed-Kit generation; a self-contained generated project can instead resolve its bundled SDK through `--project`. Resolve every required failure before claiming build readiness.
|
|
15
15
|
4. Read [workflow.md](references/workflow.md). Select the registered-board, new-board intake, or existing-project branch. The branch is selected when every supplied artifact has been accounted for and the exact board revision is known or explicitly unresolved.
|
|
16
16
|
5. Read [hardware-ir.md](references/hardware-ir.md) when a Hardware IR must be created or updated. New intake uses schema v2; schema v1 remains readable for existing H.264 projects. Record a source and verification level for every hardware fact that affects a requested feature.
|
|
17
17
|
6. Run `python3 <skill-dir>/scripts/hardware_ir.py validate <hardware-ir.json>` and then `assess --phase intake --strict`. `READY_TO_PORT` means the evidence is sufficient to design the adapter without guessing wiring or changing an unapproved public contract; it does not require a final ELF or runtime measurements.
|
|
@@ -40,4 +40,4 @@ For HIL assessment, run `hardware_ir.py assess --phase hil --artifact-sha256 <sh
|
|
|
40
40
|
|
|
41
41
|
## Finish
|
|
42
42
|
|
|
43
|
-
After the final assessment, remove the machine-bound `build/` tree without rebuilding, then run `project_portability.py <project> --export` against the source plus the project-relative `artifacts/` copies. Return the generated project path, Hardware IR, capability assessment, build artifacts, flash record when applicable, and `TIRTC_PORTING_REPORT.md`. The task is complete only when every requested feature is either verified at the requested level or named as a blocker with the smallest next action that can resolve it.
|
|
43
|
+
After the final assessment, remove the machine-bound `build/` tree without rebuilding, then run `project_portability.py <project> --export` against the source plus the project-relative `artifacts/` copies. Resolve every missing or Git-ignored required input reported by that check; local intermediate artifacts may remain ignored, but retained build evidence must survive export. Return the generated project path, Hardware IR, capability assessment, build artifacts, flash record when applicable, and `TIRTC_PORTING_REPORT.md`. The task is complete only when every requested feature is either verified at the requested level or named as a blocker with the smallest next action that can resolve it.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.7.3
|
|
@@ -7,7 +7,7 @@ $tirtc-esp32-builder
|
|
|
7
7
|
前置条件(必须由开发者在启动本次 Codex 会话前完成,不属于本提示词内的操作):
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx --yes tirtc-device-builder@0.7.
|
|
10
|
+
npx --yes tirtc-device-builder@0.7.3 setup esp32 --install --force-skill
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
该命令只允许在当前用户目录安装固定版本的 Skill、managed ESP32 Device Kit、ESP-IDF 和工具链;禁止 sudo、系统级包变更和修改 shell profile。安装完成后,开发者必须关闭原 Codex 会话,再从本 clean-room 工作区启动一个新会话,然后粘贴本提示词。
|
|
@@ -15,11 +15,11 @@ npx --yes tirtc-device-builder@0.7.1 setup esp32 --install --force-skill
|
|
|
15
15
|
本轮第一步先只读运行:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npx --yes tirtc-device-builder@0.7.
|
|
19
|
-
npx --yes tirtc-device-builder@0.7.
|
|
18
|
+
npx --yes tirtc-device-builder@0.7.3 --version
|
|
19
|
+
npx --yes tirtc-device-builder@0.7.3 setup esp32
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
必须根据命令的实际输出和本机文件确认:npm 包为 0.7.3、已安装 Skill 的 `VERSION` 为 0.7.3、所选 Device Kit 的 `manifest.json` 中 `kit_version` 为 1.1.1,并且 Doctor 对 `--expected-kit 1.1.1` 输出 `OVERALL: PASS`。Plugin manifest 不属于这种 npm 安装方式的运行时前置条件,不得把不可访问的 Plugin 版本当作阻塞项。如果版本不一致、Skill 是在当前会话启动后才安装,或环境检查未通过,停止并报告前置条件不成立;不要在当前会话中替换 Skill 后继续生成工程。
|
|
23
23
|
|
|
24
24
|
工作区与 clean-room 边界:
|
|
25
25
|
- 将启动 Codex 时的当前目录定义为 `WORKSPACE_ROOT`。
|
|
@@ -10,12 +10,32 @@ The contract must describe:
|
|
|
10
10
|
|
|
11
11
|
- the PCM sample rate, MCLK ratio, resulting MCLK, and every clocked codec driver table;
|
|
12
12
|
- capture/playback controller, role, and standard/TDM/DSP/PCM mode;
|
|
13
|
+
- per-direction slot count and slot bit width when simultaneous directions use
|
|
14
|
+
different framing modes;
|
|
13
15
|
- TDM enable, slot count/order, physical signal at each slot, selected slot, and mapping evidence when TDM is used;
|
|
14
16
|
- shared clock GPIOs, whether directions are simultaneous, and the release/recreate handoff;
|
|
17
|
+
- paired-channel ownership and equal BCLKs per frame when one controller runs
|
|
18
|
+
simultaneous standard TX and TDM RX;
|
|
19
|
+
- when AEC is selected, the hardware sample rate plus distinct microphone and
|
|
20
|
+
playback-reference slots/signals;
|
|
15
21
|
- source assertions tying the normalized contract to the actual adapter implementation.
|
|
16
22
|
|
|
17
23
|
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
24
|
|
|
25
|
+
Simultaneous directions do not have to use the same ESP-IDF mode name. A valid
|
|
26
|
+
mixed-mode topology must set `shared_clock.paired_channels=true`, use the same
|
|
27
|
+
controller, and provide `slot_count` plus `slot_bit_width` for both directions;
|
|
28
|
+
their products must match. For example, four 16-bit TDM RX slots and two 32-bit
|
|
29
|
+
standard TX slots both consume 64 BCLKs per frame. This exception does not allow
|
|
30
|
+
two independent masters to drive shared clock GPIOs.
|
|
31
|
+
|
|
32
|
+
For hardware-reference AEC, add `echo_cancellation` with `enabled`,
|
|
33
|
+
`sample_rate_hz`, `microphone_slot`/`microphone_signal`, and
|
|
34
|
+
`reference_slot`/`reference_signal`. The gate verifies that AEC runs while both
|
|
35
|
+
directions are active, uses the hardware clock rate, and maps two distinct TDM
|
|
36
|
+
signals. Implementation assertions must still bind the contract to the paired
|
|
37
|
+
channel setup, AEC library configuration, and exact slot extraction code.
|
|
38
|
+
|
|
19
39
|
## Mandatory gate
|
|
20
40
|
|
|
21
41
|
Run the gate before claiming an audio-capable build:
|
|
@@ -44,8 +64,10 @@ Audio reaches `BUILD_VERIFIED` only when all of these are true:
|
|
|
44
64
|
1. the contract has at least two authoritative source IDs;
|
|
45
65
|
2. its arithmetic and codec table lookups pass;
|
|
46
66
|
3. its topology, TDM mapping, and handoff are internally consistent;
|
|
47
|
-
4.
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
4. simultaneous mixed framing has equal BCLKs per frame and any AEC reference is
|
|
68
|
+
mapped to a distinct evidenced slot;
|
|
69
|
+
5. its adapter assertions pass;
|
|
70
|
+
6. the gate is part of the ordinary build;
|
|
71
|
+
7. the exact BIN/ELF is hashed after that build.
|
|
50
72
|
|
|
51
73
|
Compilation without this gate is `COMPILE_PASS`, not audio `BUILD_VERIFIED`.
|
|
@@ -16,7 +16,7 @@ The automatic branch never runs `sudo` or modifies a persistent shell profile. W
|
|
|
16
16
|
When `<setup-root>/env.sh` exists, use it only as an activation prefix for the current command:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
bash -lc '. "<setup-root>/env.sh" && python3 "<skill-dir>/scripts/doctor.py" --expected-idf 5.5 --target esp32s3 --require-workspace'
|
|
19
|
+
bash -lc '. "<setup-root>/env.sh" && python3 "<skill-dir>/scripts/doctor.py" --expected-idf 5.5 --expected-kit 1.1.1 --target esp32s3 --require-workspace'
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
The helper contains paths, not device or network credentials. Read `<setup-root>/config.json` when exact managed paths are needed; the environment helper does not authorize unrelated downloads, shell-profile changes, flashing, or credential writes.
|
|
@@ -26,6 +26,7 @@ Run the doctor before generation, build, flash, or monitor:
|
|
|
26
26
|
```bash
|
|
27
27
|
python3 <skill-dir>/scripts/doctor.py \
|
|
28
28
|
--expected-idf 5.5 \
|
|
29
|
+
--expected-kit 1.1.1 \
|
|
29
30
|
--target esp32s3 \
|
|
30
31
|
--require-workspace
|
|
31
32
|
```
|
|
@@ -34,7 +35,11 @@ Add `--project <generated-project>` after generation so the doctor can compare `
|
|
|
34
35
|
|
|
35
36
|
## ESP32 Device Kit
|
|
36
37
|
|
|
37
|
-
The automatic setup downloads a versioned minimal Kit instead of cloning the ThingConnect server repository.
|
|
38
|
+
The automatic setup downloads a versioned minimal Kit instead of cloning the ThingConnect server repository. A managed Kit is ready only when its generator and SDK files exist and `manifest.json` declares the exact pinned `kit_version`. A stale environment or managed configuration that points at an older Kit is ignored in favor of the current versioned managed path. The setup configuration records the version read from that manifest; it never substitutes the desired version for the actual one.
|
|
39
|
+
|
|
40
|
+
The installed Skill has its own `<skill-dir>/VERSION` marker. Setup requires it to equal the npm package version and reports both values. Replacing a missing or mismatched marker requires the explicit `--install --force-skill` flow and a new Codex session.
|
|
41
|
+
|
|
42
|
+
For an explicitly selected legacy workspace, omit `--expected-kit`; otherwise resolve the generation root in this order:
|
|
38
43
|
|
|
39
44
|
1. an explicit `--thing-connect-root <path>`;
|
|
40
45
|
2. `TIRTC_THING_CONNECT_ROOT`;
|
|
@@ -53,6 +58,13 @@ python3 <skill-dir>/scripts/project_portability.py <generated-project> --export
|
|
|
53
58
|
|
|
54
59
|
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
60
|
|
|
61
|
+
The export check also requires Hardware IR, every requested-feature semantic
|
|
62
|
+
contract, `sdkconfig.defaults`, a referenced custom partition table, and each
|
|
63
|
+
artifact retained in `build_evidence.artifacts[]`. Inside a Git worktree these
|
|
64
|
+
inputs must not be untracked files hidden by `.gitignore`. Ignore local build
|
|
65
|
+
trees and intermediate firmware snapshots, then explicitly include the exact
|
|
66
|
+
validated release bundle used by retained evidence.
|
|
67
|
+
|
|
56
68
|
## Required checks
|
|
57
69
|
|
|
58
70
|
- `python3`, `git`, `idf.py`, and the target compiler are available in the active shell;
|
|
@@ -10,6 +10,11 @@ python3 <skill-dir>/scripts/hardware_ir.py init <output>/hardware-ir.json
|
|
|
10
10
|
|
|
11
11
|
The initializer creates schema v2. The validator still accepts schema v1 for existing H.264-only projects; update new or materially changed boards to v2.
|
|
12
12
|
|
|
13
|
+
Schema v1 cannot represent an MJPEG/H.265 selection, semantic-contract paths,
|
|
14
|
+
or artifact-bound v2 HIL. If an existing v1 project requests H5 video without
|
|
15
|
+
an available H.264 path, migrate it to v2 rather than interpreting the legacy
|
|
16
|
+
H.264 assessment failure as a board or platform codec failure.
|
|
17
|
+
|
|
13
18
|
## Evidence rules
|
|
14
19
|
|
|
15
20
|
- Give every source a stable `id`, `kind`, `location`, and revision when available.
|
|
@@ -13,6 +13,17 @@ Record the selected video profile, audio formats and stream IDs, duplex/AEC poli
|
|
|
13
13
|
- I2S/audio: record controller, GPIO, master/slave mode, clocks, DMA, channel/TDM slot to physical-signal mapping, and shared-signal handoff. A codec name or I2C address does not prove the audio path.
|
|
14
14
|
- Realtime camera path: record DMA/event queues, task core/priority, and competing Wi-Fi work. Treat queue overflows as scheduling or throughput evidence, then change one variable per HIL comparison.
|
|
15
15
|
|
|
16
|
+
Distinguish the camera driver's event task from an adapter-owned software
|
|
17
|
+
JPEG/H.26x task. Core isolation of the driver does not prove that a CPU-bound
|
|
18
|
+
encoder is isolated. On ESP32-S3, code using floating point can cause an
|
|
19
|
+
unpinned task to become pinned to the first core where it uses the FPU; AEC and
|
|
20
|
+
other DSP tasks therefore need intentional affinity when they would otherwise
|
|
21
|
+
land on the Wi-Fi core. A DMA-fed loop or software encoder that can remain
|
|
22
|
+
continuously runnable must block on a queue/semaphore or yield at a bounded
|
|
23
|
+
frame boundary. Keep the idle-task watchdog enabled, and expose maximum
|
|
24
|
+
processing time plus deadline misses instead of hiding starvation by widening
|
|
25
|
+
or disabling the watchdog.
|
|
26
|
+
|
|
16
27
|
Turn every discovered invariant that can regress into a focused test or post-link gate. Generate board-specific values with the project; keep the Skill generic.
|
|
17
28
|
|
|
18
29
|
## Wi-Fi credentials and device binding
|
|
@@ -38,6 +49,13 @@ Platform service discovery and the TiRTC SDK service endpoint are different sett
|
|
|
38
49
|
|
|
39
50
|
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
51
|
|
|
52
|
+
Treat PSRAM framebuffer placement, direct peripheral DMA into PSRAM, and an
|
|
53
|
+
internal-DMA staging buffer as three separate facts. PSRAM capacity does not
|
|
54
|
+
prove DMA compatibility or frame integrity for a sensor/pixel format. Promote
|
|
55
|
+
direct PSRAM DMA only after artifact-bound HIL checks image boundaries and line
|
|
56
|
+
integrity; otherwise retain the evidenced staging path and budget its internal
|
|
57
|
+
DMA reserve explicitly.
|
|
58
|
+
|
|
41
59
|
## Network and media evidence
|
|
42
60
|
|
|
43
61
|
Disable Wi-Fi power saving for the realtime baseline unless the product contract says otherwise. Record BSSID, channel, RSSI, reconnect/roaming events, media counters, queue watermarks, camera overflows, internal heap, and largest block. Establish a strong, stable AP baseline before controlled weak-network testing.
|
|
@@ -47,3 +65,10 @@ Confirm SDK send return semantics and callback payload lifetimes from the select
|
|
|
47
65
|
## Artifact discipline
|
|
48
66
|
|
|
49
67
|
Record BIN/ELF SHA-256 for every build used in HIL. Runtime evidence applies only to the exact artifact. Diagnose one failing invariant, make the smallest correction, rerun that layer, and preserve the comparison in the report.
|
|
68
|
+
|
|
69
|
+
Local intermediate snapshots may be ignored, but the Hardware IR, semantic
|
|
70
|
+
contracts, `dependencies.lock`, custom partition input, and the artifact named
|
|
71
|
+
by retained build evidence must survive the chosen export mechanism. Broad
|
|
72
|
+
`*.json`, `*.csv`, or `artifacts/` ignore rules are invalid when they hide an
|
|
73
|
+
untracked required input; `project_portability.py --export` checks this when the
|
|
74
|
+
project is inside a Git worktree.
|
|
@@ -192,18 +192,114 @@ def check_topology(contract: dict[str, Any], errors: list[str]) -> None:
|
|
|
192
192
|
)
|
|
193
193
|
if simultaneous is False and handoff == "none":
|
|
194
194
|
errors.append("half-duplex shared clocks require an explicit handoff")
|
|
195
|
+
if simultaneous is True and handoff != "none":
|
|
196
|
+
errors.append("simultaneous directions must not use a clock handoff")
|
|
197
|
+
paired_channels = shared.get("paired_channels", False)
|
|
198
|
+
if not isinstance(paired_channels, bool):
|
|
199
|
+
errors.append("shared_clock.paired_channels must be true or false")
|
|
200
|
+
paired_channels = False
|
|
201
|
+
|
|
202
|
+
mixed_paired_ok = False
|
|
195
203
|
if simultaneous is True and capture.get("mode") != playback.get("mode"):
|
|
196
|
-
|
|
204
|
+
if paired_channels is not True:
|
|
205
|
+
errors.append(
|
|
206
|
+
"simultaneous mixed I2S modes require shared_clock.paired_channels=true"
|
|
207
|
+
)
|
|
208
|
+
if capture.get("controller") != playback.get("controller"):
|
|
209
|
+
errors.append(
|
|
210
|
+
"simultaneous mixed I2S modes require paired directions on one controller"
|
|
211
|
+
)
|
|
212
|
+
frame_bits: dict[str, int] = {}
|
|
213
|
+
for endpoint, label in ((capture, "capture"), (playback, "playback")):
|
|
214
|
+
slots = require_positive_int(
|
|
215
|
+
endpoint.get("slot_count"), f"{label}.slot_count", errors
|
|
216
|
+
)
|
|
217
|
+
width = require_positive_int(
|
|
218
|
+
endpoint.get("slot_bit_width"),
|
|
219
|
+
f"{label}.slot_bit_width",
|
|
220
|
+
errors,
|
|
221
|
+
)
|
|
222
|
+
if slots is not None and width is not None:
|
|
223
|
+
frame_bits[label] = slots * width
|
|
224
|
+
if len(frame_bits) == 2 and frame_bits["capture"] != frame_bits["playback"]:
|
|
225
|
+
errors.append(
|
|
226
|
+
"simultaneous mixed I2S modes require equal BCLKs per frame: "
|
|
227
|
+
f"capture={frame_bits['capture']} playback={frame_bits['playback']}"
|
|
228
|
+
)
|
|
229
|
+
mixed_paired_ok = (
|
|
230
|
+
paired_channels is True
|
|
231
|
+
and capture.get("controller") == playback.get("controller")
|
|
232
|
+
and len(frame_bits) == 2
|
|
233
|
+
and frame_bits["capture"] == frame_bits["playback"]
|
|
234
|
+
)
|
|
197
235
|
if (
|
|
198
236
|
capture.get("controller") == playback.get("controller")
|
|
199
237
|
and capture.get("mode") != playback.get("mode")
|
|
238
|
+
and not mixed_paired_ok
|
|
200
239
|
and handoff != "delete_recreate"
|
|
201
240
|
):
|
|
202
241
|
errors.append(
|
|
203
242
|
"one I2S controller cannot retain different capture/playback modes; "
|
|
204
|
-
"use distinct controllers or
|
|
243
|
+
"use paired frame-compatible directions, distinct controllers, or "
|
|
244
|
+
"delete_recreate handoff"
|
|
205
245
|
)
|
|
206
246
|
|
|
247
|
+
aec_value = contract.get("echo_cancellation")
|
|
248
|
+
if aec_value is None:
|
|
249
|
+
return
|
|
250
|
+
aec = require_mapping(aec_value, "echo_cancellation", errors)
|
|
251
|
+
enabled = aec.get("enabled")
|
|
252
|
+
if not isinstance(enabled, bool):
|
|
253
|
+
errors.append("echo_cancellation.enabled must be true or false")
|
|
254
|
+
return
|
|
255
|
+
if not enabled:
|
|
256
|
+
return
|
|
257
|
+
if simultaneous is not True:
|
|
258
|
+
errors.append("echo cancellation requires simultaneous capture and playback")
|
|
259
|
+
if capture_mode != "tdm":
|
|
260
|
+
errors.append("echo cancellation reference mapping requires TDM capture")
|
|
261
|
+
sample_rate = require_positive_int(
|
|
262
|
+
aec.get("sample_rate_hz"), "echo_cancellation.sample_rate_hz", errors
|
|
263
|
+
)
|
|
264
|
+
clock = contract.get("clock", {})
|
|
265
|
+
if sample_rate is not None and sample_rate != clock.get("sample_rate_hz"):
|
|
266
|
+
errors.append("echo cancellation sample rate must match the hardware clock")
|
|
267
|
+
|
|
268
|
+
microphone_slot = aec.get("microphone_slot")
|
|
269
|
+
reference_slot = aec.get("reference_slot")
|
|
270
|
+
microphone_signal = require_nonempty_string(
|
|
271
|
+
aec.get("microphone_signal"), "echo_cancellation.microphone_signal", errors
|
|
272
|
+
)
|
|
273
|
+
reference_signal = require_nonempty_string(
|
|
274
|
+
aec.get("reference_signal"), "echo_cancellation.reference_signal", errors
|
|
275
|
+
)
|
|
276
|
+
slot_signals = capture.get("slot_signals")
|
|
277
|
+
slot_count = capture.get("slot_count")
|
|
278
|
+
for slot, signal, label in (
|
|
279
|
+
(microphone_slot, microphone_signal, "microphone"),
|
|
280
|
+
(reference_slot, reference_signal, "reference"),
|
|
281
|
+
):
|
|
282
|
+
if (
|
|
283
|
+
isinstance(slot, bool)
|
|
284
|
+
or not isinstance(slot, int)
|
|
285
|
+
or slot < 0
|
|
286
|
+
or not isinstance(slot_count, int)
|
|
287
|
+
or slot >= slot_count
|
|
288
|
+
):
|
|
289
|
+
errors.append(
|
|
290
|
+
f"echo_cancellation.{label}_slot must select an available TDM slot"
|
|
291
|
+
)
|
|
292
|
+
elif isinstance(slot_signals, list) and slot < len(slot_signals):
|
|
293
|
+
if signal != slot_signals[slot]:
|
|
294
|
+
errors.append(
|
|
295
|
+
f"echo_cancellation.{label}_signal does not match "
|
|
296
|
+
f"capture.slot_signals at slot {slot}"
|
|
297
|
+
)
|
|
298
|
+
if microphone_slot == reference_slot:
|
|
299
|
+
errors.append("echo cancellation microphone and reference slots must differ")
|
|
300
|
+
if microphone_signal is not None and microphone_signal == reference_signal:
|
|
301
|
+
errors.append("echo cancellation microphone and reference signals must differ")
|
|
302
|
+
|
|
207
303
|
|
|
208
304
|
def check_assertions(
|
|
209
305
|
project: Path,
|
|
@@ -277,11 +373,15 @@ def verify_contract(contract_path: Path, project_path: Path) -> dict[str, Any]:
|
|
|
277
373
|
check_topology(contract, errors)
|
|
278
374
|
check_assertions(project, contract, errors, inputs)
|
|
279
375
|
clock = contract.get("clock", {})
|
|
376
|
+
shared = contract.get("shared_clock", {})
|
|
377
|
+
aec = contract.get("echo_cancellation", {})
|
|
280
378
|
return {
|
|
281
379
|
"ok": not errors,
|
|
282
380
|
"summary": (
|
|
283
381
|
f"sample_rate={clock.get('sample_rate_hz')}Hz "
|
|
284
|
-
f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')}"
|
|
382
|
+
f"mclk={clock.get('mclk_hz')}Hz ratio={clock.get('mclk_ratio')} "
|
|
383
|
+
f"simultaneous={shared.get('directions_simultaneous')} "
|
|
384
|
+
f"aec={aec.get('enabled', False)}"
|
|
285
385
|
),
|
|
286
386
|
"inputs": inputs,
|
|
287
387
|
"errors": errors,
|
|
@@ -27,6 +27,7 @@ GENERATOR_RELATIVE_PATH = Path("device-sim/scripts/create_esp32_project.py")
|
|
|
27
27
|
DEFAULT_SDK_RELATIVE_PATH = Path(
|
|
28
28
|
"device-sim/sdk/espressif-esp32s3/2.3.0"
|
|
29
29
|
)
|
|
30
|
+
DEVICE_KIT_MANIFEST = "manifest.json"
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def check(name: str, status: str, detail: str, required: bool = True) -> dict[str, Any]:
|
|
@@ -166,6 +167,43 @@ def normalize_thing_connect_root(candidate: Path) -> Path | None:
|
|
|
166
167
|
return None
|
|
167
168
|
|
|
168
169
|
|
|
170
|
+
def inspect_device_kit(root: Path | None) -> dict[str, str | bool | None]:
|
|
171
|
+
"""Read the managed Kit identity without rejecting legacy workspaces."""
|
|
172
|
+
if root is None:
|
|
173
|
+
return {
|
|
174
|
+
"manifest_present": False,
|
|
175
|
+
"version": None,
|
|
176
|
+
"error": "Device Kit root is unresolved",
|
|
177
|
+
}
|
|
178
|
+
manifest_path = root / DEVICE_KIT_MANIFEST
|
|
179
|
+
if not manifest_path.is_file():
|
|
180
|
+
return {
|
|
181
|
+
"manifest_present": False,
|
|
182
|
+
"version": None,
|
|
183
|
+
"error": None,
|
|
184
|
+
}
|
|
185
|
+
try:
|
|
186
|
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
187
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
188
|
+
return {
|
|
189
|
+
"manifest_present": True,
|
|
190
|
+
"version": None,
|
|
191
|
+
"error": f"invalid {manifest_path}: {exc}",
|
|
192
|
+
}
|
|
193
|
+
version = payload.get("kit_version") if isinstance(payload, dict) else None
|
|
194
|
+
if not isinstance(version, str) or not version.strip():
|
|
195
|
+
return {
|
|
196
|
+
"manifest_present": True,
|
|
197
|
+
"version": None,
|
|
198
|
+
"error": f"{manifest_path} does not declare kit_version",
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
"manifest_present": True,
|
|
202
|
+
"version": version.strip(),
|
|
203
|
+
"error": None,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
169
207
|
def find_thing_connect_root(
|
|
170
208
|
explicit: Path | None,
|
|
171
209
|
project: Path | None,
|
|
@@ -273,27 +311,51 @@ def diagnose(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
273
311
|
args.thing_connect_root,
|
|
274
312
|
args.project,
|
|
275
313
|
)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
314
|
+
kit_identity = inspect_device_kit(thing_connect_root)
|
|
315
|
+
kit_error = kit_identity["error"]
|
|
316
|
+
actual_kit = kit_identity["version"]
|
|
317
|
+
if thing_connect_root is None:
|
|
318
|
+
workspace_status = "FAIL" if args.require_workspace or args.expected_kit else "WARN"
|
|
319
|
+
workspace_detail = (
|
|
320
|
+
f"not found; pass --thing-connect-root or set {THING_CONNECT_ENV}"
|
|
321
|
+
)
|
|
322
|
+
elif args.expected_kit and not kit_identity["manifest_present"]:
|
|
323
|
+
workspace_status = "FAIL"
|
|
324
|
+
workspace_detail = (
|
|
325
|
+
f"{thing_connect_root} ({thing_connect_source}) is an unversioned legacy "
|
|
326
|
+
f"workspace; expected managed Device Kit {args.expected_kit}"
|
|
327
|
+
)
|
|
328
|
+
elif kit_error:
|
|
329
|
+
workspace_status = "FAIL"
|
|
330
|
+
workspace_detail = str(kit_error)
|
|
331
|
+
elif args.expected_kit and actual_kit != args.expected_kit:
|
|
332
|
+
workspace_status = "FAIL"
|
|
333
|
+
workspace_detail = (
|
|
334
|
+
f"version {actual_kit or 'missing'} at {thing_connect_root} "
|
|
335
|
+
f"({thing_connect_source}); expected {args.expected_kit}"
|
|
336
|
+
)
|
|
337
|
+
else:
|
|
338
|
+
workspace_status = "PASS"
|
|
339
|
+
version_detail = (
|
|
340
|
+
f"version {actual_kit}"
|
|
341
|
+
if actual_kit
|
|
342
|
+
else "unversioned legacy workspace"
|
|
343
|
+
)
|
|
344
|
+
workspace_detail = (
|
|
345
|
+
f"{version_detail} at {thing_connect_root} ({thing_connect_source})"
|
|
346
|
+
)
|
|
279
347
|
checks.append(
|
|
280
348
|
check(
|
|
281
349
|
"ESP32 Device Kit",
|
|
282
350
|
workspace_status,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if thing_connect_root
|
|
286
|
-
else (
|
|
287
|
-
f"not found; pass --thing-connect-root or set {THING_CONNECT_ENV}"
|
|
288
|
-
)
|
|
289
|
-
),
|
|
290
|
-
required=args.require_workspace,
|
|
351
|
+
workspace_detail,
|
|
352
|
+
required=args.require_workspace or bool(args.expected_kit),
|
|
291
353
|
)
|
|
292
354
|
)
|
|
293
|
-
if args.require_workspace and
|
|
355
|
+
if (args.require_workspace or args.expected_kit) and workspace_status == "FAIL":
|
|
294
356
|
next_actions.append(
|
|
295
|
-
"Run setup esp32 --install
|
|
296
|
-
"--thing-connect-root."
|
|
357
|
+
"Run setup esp32 --install to select the pinned managed Device Kit, or "
|
|
358
|
+
"pass a matching Kit path with --thing-connect-root."
|
|
297
359
|
)
|
|
298
360
|
|
|
299
361
|
sdk_dir, sdk_source = resolve_sdk_dir(
|
|
@@ -390,6 +452,8 @@ def diagnose(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
390
452
|
return {
|
|
391
453
|
"overall": overall,
|
|
392
454
|
"expected_idf": args.expected_idf,
|
|
455
|
+
"expected_kit": args.expected_kit,
|
|
456
|
+
"device_kit_version": actual_kit,
|
|
393
457
|
"target": args.target,
|
|
394
458
|
"thing_connect_root": (
|
|
395
459
|
str(thing_connect_root) if thing_connect_root is not None else None
|
|
@@ -414,6 +478,10 @@ def parse_args() -> argparse.Namespace:
|
|
|
414
478
|
description="Check ESP-IDF, target tools, TiRTC SDK, project contract, and serial access."
|
|
415
479
|
)
|
|
416
480
|
parser.add_argument("--expected-idf", default="5.5")
|
|
481
|
+
parser.add_argument(
|
|
482
|
+
"--expected-kit",
|
|
483
|
+
help="require an exact managed ESP32 Device Kit manifest version",
|
|
484
|
+
)
|
|
417
485
|
parser.add_argument("--target", default="esp32s3")
|
|
418
486
|
parser.add_argument("--idf-py", type=Path)
|
|
419
487
|
parser.add_argument("--thing-connect-root", type=Path)
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
|
+
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
@@ -13,8 +14,30 @@ from typing import Any
|
|
|
13
14
|
|
|
14
15
|
ABSOLUTE_PATH = re.compile(r"(?:^|[\s\"'])(?:/home/|/root/|[A-Za-z]:[\\/])")
|
|
15
16
|
BUILD_INPUT_NAMES = {"CMakeLists.txt", "idf_component.yml", "idf_component.yaml"}
|
|
16
|
-
BUILD_INPUT_SUFFIXES = {
|
|
17
|
+
BUILD_INPUT_SUFFIXES = {
|
|
18
|
+
".c",
|
|
19
|
+
".cmake",
|
|
20
|
+
".csv",
|
|
21
|
+
".defaults",
|
|
22
|
+
".env",
|
|
23
|
+
".h",
|
|
24
|
+
".json",
|
|
25
|
+
".lock",
|
|
26
|
+
".py",
|
|
27
|
+
".sh",
|
|
28
|
+
".txt",
|
|
29
|
+
".yaml",
|
|
30
|
+
".yml",
|
|
31
|
+
}
|
|
17
32
|
SKIP_PARTS = {"build", "managed_components", ".git"}
|
|
33
|
+
REQUIRED_PROJECT_INPUTS = {
|
|
34
|
+
"dependencies.lock",
|
|
35
|
+
"hardware-ir.json",
|
|
36
|
+
"sdkconfig.defaults",
|
|
37
|
+
}
|
|
38
|
+
PARTITION_FILE_RE = re.compile(
|
|
39
|
+
r'^CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="([^"]+)"$', re.MULTILINE
|
|
40
|
+
)
|
|
18
41
|
|
|
19
42
|
|
|
20
43
|
def source_files(project: Path):
|
|
@@ -26,13 +49,156 @@ def source_files(project: Path):
|
|
|
26
49
|
yield path
|
|
27
50
|
|
|
28
51
|
|
|
52
|
+
def project_file(
|
|
53
|
+
project: Path, value: Any, label: str, errors: list[str]
|
|
54
|
+
) -> Path | None:
|
|
55
|
+
if not isinstance(value, str) or not value.strip():
|
|
56
|
+
errors.append(f"{label} must be a non-empty project-relative path")
|
|
57
|
+
return None
|
|
58
|
+
relative = Path(value)
|
|
59
|
+
if relative.is_absolute():
|
|
60
|
+
errors.append(f"{label} must be project-relative, got {value!r}")
|
|
61
|
+
return None
|
|
62
|
+
resolved = (project / relative).resolve()
|
|
63
|
+
if project != resolved and project not in resolved.parents:
|
|
64
|
+
errors.append(f"{label} escapes project root: {value!r}")
|
|
65
|
+
return None
|
|
66
|
+
return resolved
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_hardware_ir(project: Path, errors: list[str]) -> dict[str, Any] | None:
|
|
70
|
+
path = project / "hardware-ir.json"
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
76
|
+
errors.append(f"hardware-ir.json is invalid: {exc}")
|
|
77
|
+
return None
|
|
78
|
+
if not isinstance(data, dict):
|
|
79
|
+
errors.append("hardware-ir.json root must be an object")
|
|
80
|
+
return None
|
|
81
|
+
return data
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def required_project_files(
|
|
85
|
+
project: Path, export: bool, errors: list[str]
|
|
86
|
+
) -> set[Path]:
|
|
87
|
+
required = {(project / relative).resolve() for relative in REQUIRED_PROJECT_INPUTS}
|
|
88
|
+
ir = load_hardware_ir(project, errors)
|
|
89
|
+
if ir is not None:
|
|
90
|
+
schema_version = ir.get("schema_version")
|
|
91
|
+
requested = ir.get("features", {}).get("requested", [])
|
|
92
|
+
if schema_version == 2:
|
|
93
|
+
resources = ir.get("hardware_resources")
|
|
94
|
+
if not isinstance(resources, dict):
|
|
95
|
+
errors.append("hardware-ir.json hardware_resources must be an object")
|
|
96
|
+
else:
|
|
97
|
+
contract_fields = ["runtime_semantic_contract"]
|
|
98
|
+
if {"h5_live_audio", "h5_talkback", "ai_talk"}.intersection(
|
|
99
|
+
requested
|
|
100
|
+
):
|
|
101
|
+
contract_fields.append("audio_semantic_contract")
|
|
102
|
+
if "h5_live_video" in requested:
|
|
103
|
+
contract_fields.append("video_semantic_contract")
|
|
104
|
+
for field in contract_fields:
|
|
105
|
+
path = project_file(
|
|
106
|
+
project,
|
|
107
|
+
resources.get(field),
|
|
108
|
+
f"hardware_resources.{field}",
|
|
109
|
+
errors,
|
|
110
|
+
)
|
|
111
|
+
if path is not None:
|
|
112
|
+
required.add(path)
|
|
113
|
+
elif (
|
|
114
|
+
schema_version == 1
|
|
115
|
+
and "h5_live_video" in requested
|
|
116
|
+
and ir.get("camera", {}).get("h264", {}).get("available") is not True
|
|
117
|
+
):
|
|
118
|
+
errors.append(
|
|
119
|
+
"schema v1 can only export the legacy H.264 video contract; "
|
|
120
|
+
"migrate Hardware IR to schema v2 for MJPEG or H.265"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if export:
|
|
124
|
+
artifacts = ir.get("build_evidence", {}).get("artifacts")
|
|
125
|
+
if not isinstance(artifacts, list) or not artifacts:
|
|
126
|
+
errors.append(
|
|
127
|
+
"hardware-ir.json build_evidence.artifacts must retain at least "
|
|
128
|
+
"one verified deliverable for export"
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
for index, record in enumerate(artifacts):
|
|
132
|
+
if not isinstance(record, dict):
|
|
133
|
+
errors.append(
|
|
134
|
+
f"build_evidence.artifacts[{index}] must be an object"
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
path = project_file(
|
|
138
|
+
project,
|
|
139
|
+
record.get("path"),
|
|
140
|
+
f"build_evidence.artifacts[{index}].path",
|
|
141
|
+
errors,
|
|
142
|
+
)
|
|
143
|
+
if path is not None:
|
|
144
|
+
required.add(path)
|
|
145
|
+
|
|
146
|
+
for config_name in ("sdkconfig.defaults", "sdkconfig"):
|
|
147
|
+
config = project / config_name
|
|
148
|
+
if not config.is_file():
|
|
149
|
+
continue
|
|
150
|
+
text = config.read_text(encoding="utf-8", errors="replace")
|
|
151
|
+
match = PARTITION_FILE_RE.search(text)
|
|
152
|
+
if match:
|
|
153
|
+
path = project_file(
|
|
154
|
+
project,
|
|
155
|
+
match.group(1),
|
|
156
|
+
f"{config_name} custom partition table",
|
|
157
|
+
errors,
|
|
158
|
+
)
|
|
159
|
+
if path is not None:
|
|
160
|
+
required.add(path)
|
|
161
|
+
return required
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def git_root(project: Path) -> Path | None:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
["git", "-C", str(project), "rev-parse", "--show-toplevel"],
|
|
167
|
+
stdout=subprocess.PIPE,
|
|
168
|
+
stderr=subprocess.DEVNULL,
|
|
169
|
+
text=True,
|
|
170
|
+
check=False,
|
|
171
|
+
)
|
|
172
|
+
if result.returncode != 0:
|
|
173
|
+
return None
|
|
174
|
+
return Path(result.stdout.strip()).resolve()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def git_ignored(root: Path, path: Path) -> bool:
|
|
178
|
+
try:
|
|
179
|
+
relative = path.relative_to(root)
|
|
180
|
+
except ValueError:
|
|
181
|
+
return False
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
["git", "-C", str(root), "check-ignore", "--quiet", "--", relative.as_posix()],
|
|
184
|
+
stdout=subprocess.DEVNULL,
|
|
185
|
+
stderr=subprocess.DEVNULL,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
return result.returncode == 0
|
|
189
|
+
|
|
190
|
+
|
|
29
191
|
def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
|
|
30
192
|
project = project_path.expanduser().resolve()
|
|
31
193
|
errors: list[str] = []
|
|
32
194
|
if not (project / "CMakeLists.txt").is_file():
|
|
33
195
|
return {"ok": False, "errors": ["CMakeLists.txt is missing"]}
|
|
34
|
-
|
|
35
|
-
|
|
196
|
+
required = required_project_files(project, export, errors)
|
|
197
|
+
for path in sorted(required):
|
|
198
|
+
if not path.is_file():
|
|
199
|
+
errors.append(
|
|
200
|
+
f"required portable input is missing: {path.relative_to(project)}"
|
|
201
|
+
)
|
|
36
202
|
sdk_required = (
|
|
37
203
|
project / "third_party" / "tirtc" / "include" / "tirtc" / "tiRTC.h",
|
|
38
204
|
project / "third_party" / "tirtc" / "lib" / "libTiRTC.a",
|
|
@@ -43,6 +209,15 @@ def check_project(project_path: Path, export: bool = False) -> dict[str, Any]:
|
|
|
43
209
|
errors.append("bundled TiRTC SDK is incomplete: " + ", ".join(missing_sdk))
|
|
44
210
|
if export and (project / "build").exists():
|
|
45
211
|
errors.append("export source contains a machine-bound build/ directory")
|
|
212
|
+
if export:
|
|
213
|
+
root = git_root(project)
|
|
214
|
+
if root is not None:
|
|
215
|
+
for path in sorted(required):
|
|
216
|
+
if path.is_file() and git_ignored(root, path):
|
|
217
|
+
errors.append(
|
|
218
|
+
"required portable input is ignored by Git: "
|
|
219
|
+
f"{path.relative_to(project)}"
|
|
220
|
+
)
|
|
46
221
|
|
|
47
222
|
for path in source_files(project):
|
|
48
223
|
relative = path.relative_to(project)
|