feat: voice bridge overhaul, scenario manager, agent docs, CI validation
- Rewrote mascarade_voice_bridge.py (+441 lines, OPUS support, hint routing) - Added scenario_manager (h/cpp) for runtime puzzle orchestration - Enhanced zacus.sh dev tooling (+370 lines) - Updated agent docs (AGENTS, TODO, firmware, tests) - Removed obsolete async_tcp_fork submodule - Added voice-bridge requirements - Fixed validate.yml CI workflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
name: Validate Zacus content
|
||||
name: Validate Zacus refactor
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,19 +11,42 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install dependencies
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install validator dependencies
|
||||
run: python -m pip install --upgrade pip -r tools/requirements/validators.txt
|
||||
- name: Validate runtime bundle coherence
|
||||
|
||||
- name: Install docs dependencies
|
||||
run: python -m pip install -r tools/requirements/docs.txt
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend-scratch-v2/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm --prefix frontend-scratch-v2 ci
|
||||
|
||||
- name: Run canonical content checks
|
||||
run: bash tools/test/run_content_checks.sh
|
||||
|
||||
- name: Validate runtime bundle
|
||||
run: python tools/scenario/validate_runtime_bundle.py
|
||||
- name: Validate scenario
|
||||
run: python tools/scenario/validate_scenario.py game/scenarios/zacus_v2.yaml
|
||||
- name: Export scenario markdown briefs
|
||||
run: python tools/scenario/export_md.py game/scenarios/zacus_v2.yaml
|
||||
- name: Validate audio manifest
|
||||
run: python tools/audio/validate_manifest.py audio/manifests/zacus_v2_audio.yaml
|
||||
- name: Validate printables manifest
|
||||
run: python tools/printables/validate_manifest.py printables/manifests/zacus_v2_printables.yaml
|
||||
|
||||
- name: Lint studio frontend
|
||||
run: npm --prefix frontend-scratch-v2 run lint
|
||||
|
||||
- name: Test studio frontend
|
||||
run: npm --prefix frontend-scratch-v2 run test
|
||||
|
||||
- name: Build studio frontend
|
||||
run: npm --prefix frontend-scratch-v2 run build
|
||||
|
||||
- name: Build docs
|
||||
run: python -m mkdocs build --strict
|
||||
|
||||
@@ -81,8 +81,8 @@ Logs/artifacts:
|
||||
- Toute modification de structure doit être répercutée dans la documentation et l'onboarding.
|
||||
- Les grosses reviews sont autorisees (rapport detaille + recommandations).
|
||||
- Les gates build/smoke sont recommandees, mais non obligatoires sauf demande explicite.
|
||||
- Consult et mettez à jour `docs/AGENT_TODO.md` avant toute opération importante : c’est le tracker canonique des tâches d’agent, mentionnez-y les étapes réalisées, les artefacts produits et toute impasse matérielle pour guider les agents suivants.
|
||||
- Les logs et artefacts (`logs/`, `artifacts/`, etc.) ne doivent pas être committés ; notez leur existence (chemins, horodatages) dans `docs/AGENT_TODO.md` ou dans le rapport final au lieu de les versionner.
|
||||
- Consult et mettez à jour `hardware/firmware/docs/AGENT_TODO.md` avant toute opération importante : c’est le tracker canonique des tâches d’agent, mentionnez-y les étapes réalisées, les artefacts produits et toute impasse matérielle pour guider les agents suivants.
|
||||
- Les logs et artefacts (`logs/`, `artifacts/`, etc.) ne doivent pas être committés ; notez leur existence (chemins, horodatages) dans `hardware/firmware/docs/AGENT_TODO.md` ou dans le rapport final au lieu de les versionner.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboarding / briefing files (`.github/agents/`, `docs/_generated/`).
|
||||
|
||||
## Doit
|
||||
- Avant toute mise à jour, lire `docs/AGENT_TODO.md` pour ne pas dupliquer les efforts de coordination.
|
||||
- Avant toute mise à jour, lire `hardware/firmware/docs/AGENT_TODO.md` pour ne pas dupliquer les efforts de coordination.
|
||||
- Refléter tout changement d’organisation ou de procédure dans `docs/TEST_SCRIPT_COORDINATOR.md`, `docs/TEST_COHERENCE_AUDIT_RUNBOOK.md`, et les AGENTS concernés.
|
||||
- Maintenir `docs/_generated/COCKPIT_COMMANDS.md` en cohérence avec `tools/dev/cockpit_commands.yaml` via `python3 tools/dev/gen_cockpit_docs.py`.
|
||||
- Documenter les nouveaux artefacts (gates/tests) dans la section adéquate du runbook et mentionner les chemins dans le reporting template.
|
||||
@@ -12,7 +12,7 @@ Docs agents govern `docs/`, `README.md`, `esp32_audio/README.md`, and any onboar
|
||||
|
||||
## Artefacts
|
||||
- Les docs n’intègrent pas d’artefacts binaires ; citez plutôt les dossiers `artifacts/` lorsque vous décrivez un protocole ou un gate réussi.
|
||||
- Pour les instructions “TODO”, mettez à jour `docs/AGENT_TODO.md` (bloc 4/5) en parallèle des docs.
|
||||
- Pour les instructions “TODO”, mettez à jour `hardware/firmware/docs/AGENT_TODO.md` (bloc 4/5) en parallèle des docs.
|
||||
|
||||
## Bonnes pratiques
|
||||
- Chaque mise à jour de runbook ou guide doit inclure un rappel sur la nécessité de respecter `docs/AGENT_TODO.md` comme tracker unique.
|
||||
- Chaque mise à jour de runbook ou guide doit inclure un rappel sur la nécessité de respecter `hardware/firmware/docs/AGENT_TODO.md` comme tracker unique.
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
Firmware agents cover `esp32_audio/`, `ui/esp8266_oled/`, `ui/rp2040_tft/`, and shared `protocol/` code that drives the MCU binaries.
|
||||
|
||||
## Doit
|
||||
- Lire puis mettre à jour `docs/AGENT_TODO.md` avant d’agir (le tracker unique garde la trace de l’état de chaque gate).
|
||||
- Lire puis mettre à jour `hardware/firmware/docs/AGENT_TODO.md` avant d’agir (le tracker unique garde la trace de l’état de chaque gate).
|
||||
- Respecter le contrat global (`AGENTS.md`) et les règles d’outillage de `tools/dev/AGENTS.md` (gates/scripts centralisés, artefacts hors git).
|
||||
- S’assurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `docs/AGENT_TODO.md`.
|
||||
- Pour les modifications de structure, ajouter un commentaire dans `docs/AGENT_TODO.md` + mentionner le commit dans `docs/TEST_SCRIPT_COORDINATOR.md`.
|
||||
- Toujours signaler l’état des UI Link / LittleFS / I2S dans `docs/AGENT_TODO.md:6-14` lorsqu’on manipule ces composants.
|
||||
- S’assurer que tout build se passe via `platformio.ini`/`build_all.sh` ou `Makefile fast-*`, et documenter les artefacts dans `hardware/firmware/docs/AGENT_TODO.md`.
|
||||
- Pour les modifications de structure, ajouter un commentaire dans `hardware/firmware/docs/AGENT_TODO.md` + mentionner le commit dans `docs/TEST_SCRIPT_COORDINATOR.md`.
|
||||
- Toujours signaler l’état des UI Link / LittleFS / I2S dans `hardware/firmware/docs/AGENT_TODO.md:6-14` lorsqu’on manipule ces composants.
|
||||
|
||||
## Reporting
|
||||
- Chaque run doit produire des artefacts `artifacts/<phase>/<timestamp>` ; les chemins doivent figurer dans la case correspondante du TODO.
|
||||
- Données hardware (logs, ports) restent hors git : référencer les fichiers dans `docs/AGENT_TODO.md` ou le rapport final.
|
||||
- Données hardware (logs, ports) restent hors git : référencer les fichiers dans `hardware/firmware/docs/AGENT_TODO.md` ou le rapport final.
|
||||
|
||||
## References
|
||||
- Branche principale : `docs/SPRINT_RECOMMENDATIONS.md`, `README.md`, `protocol/ui_link_v2.md`
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
Tests agents focus on files under `tools/dev/`, `tools/test/`, `esp32_audio/tests/`, and the artifacts/log directories (`artifacts/`, `logs/`).
|
||||
|
||||
## Doit
|
||||
- Toujours lire/mettre à jour `docs/AGENT_TODO.md` pour annoncer les gates en cours ou bloqués (ex. smoke, stress, audit).
|
||||
- Toujours lire/mettre à jour `hardware/firmware/docs/AGENT_TODO.md` pour annoncer les gates en cours ou bloqués (ex. smoke, stress, audit).
|
||||
- Exécuter les calibrations/gates via les scripts officiels (`tools/dev/run_matrix_and_smoke.sh`, `tools/dev/run_smoke_tests.sh`, `tools/dev/run_stress_tests.py`, `tools/test/audit_coherence.py`), puis consigner les artefacts sous `artifacts/`.
|
||||
- Tenir `docs/TEST_SCRIPT_COORDINATOR.md` à jour avec les résultats et les chemins d’évidence (section “Exécutions récentes” + reporting template).
|
||||
- Valider les commandes cockpit via `tools/dev/cockpit_commands.yaml` et `docs/_generated/COCKPIT_COMMANDS.md` (regénération avec `python3 tools/dev/gen_cockpit_docs.py` si besoin).
|
||||
- Mentionner les échecs réseau (HTTP/WebSocket/WiFi) dans `docs/AGENT_TODO.md` et `docs/TEST_SCRIPT_COORDINATOR.md` pour la coordination.
|
||||
- Mentionner les échecs réseau (HTTP/WebSocket/WiFi) dans `hardware/firmware/docs/AGENT_TODO.md` et `docs/TEST_SCRIPT_COORDINATOR.md` pour la coordination.
|
||||
|
||||
## Artefacts
|
||||
- Les logs exigent `meta.json`, `git.txt`, `commands.txt`, `summary.md` (au minimum) ; vérifiez les répertoires demandés dans `docs/TEST_SCRIPT_COORDINATOR.md`.
|
||||
- Pas de commit d’artefacts ; les chemins doivent être mentionnés dans le TODO.
|
||||
|
||||
## Bonnes pratiques
|
||||
- En cas de panne persistante (UI link, stress panic, scénario manquant), capturez les traces dans `artifacts/rc_live/<timestamp>` et notez la prochaine action dans `docs/AGENT_TODO.md`.
|
||||
- En cas de panne persistante (UI link, stress panic, scénario manquant), capturez les traces dans `artifacts/rc_live/<timestamp>` et notez la prochaine action dans `hardware/firmware/docs/AGENT_TODO.md`.
|
||||
|
||||
Submodule hardware/firmware/async_tcp_fork deleted from 63d953d72c
@@ -1,3 +1,294 @@
|
||||
## [2026-03-16] Graph-first cleanup slice
|
||||
|
||||
- Objective:
|
||||
- keep enchaîning the Runtime 3 migration with a surgical cleanup pass,
|
||||
- remove the canonical `steps_reference_order` bridge from the scenario YAML,
|
||||
- align the studio and compiler helpers on graph-first `firmware.steps`.
|
||||
|
||||
- Changes applied:
|
||||
- Canonical scenario cleanup:
|
||||
- removed `firmware.steps_reference_order` from `game/scenarios/zacus_v2.yaml`.
|
||||
- Studio/runtime alignment:
|
||||
- `frontend-scratch-v2/src/lib/scenario.ts` now treats `firmware.steps` as canonical and only falls back to `firmware.steps_reference_order`,
|
||||
not a top-level `steps_reference_order`.
|
||||
- `frontend-scratch-v2/src/lib/runtime3.ts` now resolves the Runtime 3 entry step from nested firmware data only.
|
||||
- `frontend-scratch-v2/src/types.ts` moves the compatibility bridge under `firmware.steps_reference_order`.
|
||||
- `frontend-scratch-v2/tests/scenario-runtime3.test.ts` now asserts the builder no longer emits a top-level
|
||||
`steps_reference_order`.
|
||||
- Runtime 3 compiler compatibility:
|
||||
- `tools/scenario/runtime3_common.py` now prefers `firmware.steps_reference_order` over a legacy top-level fallback
|
||||
when it needs an ordered import path.
|
||||
- Spec/todo refresh:
|
||||
- updated active plan/todo/spec files to record that the canonical scenario is now graph-only.
|
||||
|
||||
- Validation:
|
||||
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run test` -> PASS
|
||||
|
||||
- Evidence artifacts:
|
||||
- Safety checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260316-073226_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260316-073226_status.txt`
|
||||
|
||||
- Remaining next steps:
|
||||
- remove the last compatibility bridge from non-canonical import/export paths,
|
||||
- wire native Runtime 3 deploy actions once firmware accepts native IR payloads,
|
||||
- run the full build gate matrix after this cleanup slice.
|
||||
|
||||
## [2026-03-16] Frontend regression gate wiring
|
||||
|
||||
- Objective:
|
||||
- harden the studio migration with a first-class frontend test gate,
|
||||
- keep graph-first behavior protected in CI and local shells,
|
||||
- align the local AI gateway prompt with the new canonical YAML shape.
|
||||
|
||||
- Changes applied:
|
||||
- Added frontend regression tests in:
|
||||
- `frontend-scratch-v2/tests/scenario-runtime3.test.ts`
|
||||
- Added frontend test tooling:
|
||||
- `frontend-scratch-v2/package.json` -> `npm test`
|
||||
- `Makefile` -> `frontend-test`
|
||||
- `tools/dev/zacus.sh` -> `frontend-test`
|
||||
- `.github/workflows/validate.yml` -> frontend test step
|
||||
- The new tests cover:
|
||||
- graph-first YAML validation without `steps_reference_order`,
|
||||
- YAML parsing from `firmware.steps`,
|
||||
- Runtime 3 entry-step compilation from `firmware.initial_step`,
|
||||
- builder output compatibility.
|
||||
- Updated `tools/dev/local_studio_ai_gateway.py` so the generation prompt now asks for canonical Zacus V2 YAML with `firmware.initial_step` and `firmware.steps`.
|
||||
|
||||
- Validation:
|
||||
- `npm --prefix frontend-scratch-v2 install` -> PASS
|
||||
- Note: npm reported `3 vulnerabilities` (`2 moderate`, `1 high`) but install completed.
|
||||
- `npm --prefix frontend-scratch-v2 run test` -> PASS
|
||||
- `bash tools/dev/zacus.sh frontend-test` -> PASS
|
||||
- `make frontend-test` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run build` -> PASS
|
||||
- Warning only: Vite bundle size advisory still present.
|
||||
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
|
||||
- `python3 -m mkdocs build --strict` -> PASS
|
||||
|
||||
- Evidence artifacts:
|
||||
- Safety checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260316-065010_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260316-065010_status.txt`
|
||||
|
||||
- Remaining next steps:
|
||||
- decide whether to address the npm audit findings in a dedicated dependency hygiene pass,
|
||||
- remove the remaining `steps_reference_order` bridge from consumers,
|
||||
- wire native Runtime 3 deploy actions into the studio once firmware accepts them.
|
||||
|
||||
## [2026-03-16] Studio graph adoption + runbook cleanup
|
||||
|
||||
- Objective:
|
||||
- continue the migration after the canonical graph landing,
|
||||
- make the studio consume graph-first scenario data without a hard dependency on `steps_reference_order`,
|
||||
- clean more active runbook/agent references still pointing at the old tracker path.
|
||||
|
||||
- Changes applied:
|
||||
- Frontend graph adoption:
|
||||
- `frontend-scratch-v2/src/lib/scenario.ts` now validates scenario order from `firmware.steps` first,
|
||||
while still tolerating `steps_reference_order` as a compatibility bridge.
|
||||
- `frontend-scratch-v2/src/lib/runtime3.ts` now resolves `entry_step_id` from `firmware.initial_step`
|
||||
or the first firmware step before falling back to older fields.
|
||||
- `frontend-scratch-v2/src/types.ts` makes `steps_reference_order` optional.
|
||||
- Studio UX:
|
||||
- `frontend-scratch-v2/src/components/Dashboard.tsx` now exposes a Runtime 3 adapter card
|
||||
showing contract, load status, scenario id, entry step, migration mode, counts, schema, and bundle path.
|
||||
- Active cleanup:
|
||||
- updated more active agent/runbook files that still referenced `docs/AGENT_TODO.md`:
|
||||
- `.github/agents/ALIGNMENT_COMPLETE.md`
|
||||
- `.github/agents/PHASE_LAUNCH_PLAN.md`
|
||||
- `hardware/firmware/AGENTS_DOCS.md`
|
||||
- `hardware/firmware/AGENTS_FIRMWARE.md`
|
||||
- `hardware/firmware/AGENTS_TESTS.md`
|
||||
- `hardware/firmware/docs/QUICKSTART.md`
|
||||
- `hardware/ui_freenove_allinone/RC_FINAL_BOARD.md`
|
||||
|
||||
- Validation:
|
||||
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run build` -> PASS
|
||||
- Warning only: Vite bundle size advisory still present.
|
||||
- `python3 -m mkdocs build --strict` -> PASS
|
||||
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
|
||||
- RAM: `84.3%` (`276264 / 327680`)
|
||||
- Flash: `39.7%` (`2496997 / 6291456`)
|
||||
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
|
||||
|
||||
- Evidence artifacts:
|
||||
- Safety checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260316-063851_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260316-063851_status.txt`
|
||||
|
||||
- Remaining next steps:
|
||||
- remove `steps_reference_order` once the remaining consumers are migrated,
|
||||
- wire Runtime 3 deploy/refresh actions in the studio when the firmware adapter accepts native IR payloads,
|
||||
- continue the cleanup pass on non-critical historical docs only after isolating it from the dirty worktree.
|
||||
|
||||
## [2026-03-16] Runtime 3 graph migration + active cleanup pass
|
||||
|
||||
- Objective:
|
||||
- continue the refactor waves with the next real runtime slice,
|
||||
- migrate the canonical scenario from ordered steps to explicit `firmware.steps`,
|
||||
- clean active root-level legacy references without triggering a destructive cleanup pass.
|
||||
|
||||
- Changes applied:
|
||||
- Migrated `game/scenarios/zacus_v2.yaml` to an explicit firmware graph:
|
||||
- added `firmware.initial_step`,
|
||||
- added `firmware.steps`,
|
||||
- preserved `firmware.steps_reference_order` temporarily as a compatibility bridge.
|
||||
- Canonical transitions are now explicit in YAML:
|
||||
- `STEP_LA_DETECTOR` uses `unlock`,
|
||||
- `STEP_RTC_ESP_ETAPE1` and `STEP_RTC_ESP_ETAPE2` use `espnow`,
|
||||
- `STEP_WIN_ETAPE1` uses `audio_done`,
|
||||
- `STEP_QR_DETECTOR` uses `unlock`.
|
||||
- Tightened validators and exports:
|
||||
- `tools/scenario/validate_scenario.py` now validates `firmware.steps`,
|
||||
- `tools/scenario/export_md.py` now prefers `firmware.steps`,
|
||||
- `tools/scenario/verify_runtime3_pivots.py` now requires `migration_mode=firmware_import`.
|
||||
- Runtime 3 tests now assert the canonical scenario compiles in `firmware_import` mode and preserves unlock semantics on the key detectors.
|
||||
- CI cleanup:
|
||||
- `.github/workflows/validate.yml` now installs docs dependencies and runs `mkdocs build --strict`.
|
||||
- Active legacy cleanup:
|
||||
- updated root agent/spec references still pointing to `zacus_v1` or stale `docs/AGENT_TODO.md` paths in active coordination files.
|
||||
|
||||
- Validation:
|
||||
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
|
||||
- Runtime 3 compile result now shows `migration_mode=firmware_import`
|
||||
- `python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run build` -> PASS
|
||||
- Warning only: Vite bundle size advisory still present.
|
||||
- `python3 -m mkdocs build --strict` -> PASS
|
||||
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
|
||||
- RAM: `84.3%` (`276264 / 327680`)
|
||||
- Flash: `39.7%` (`2496997 / 6291456`)
|
||||
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
|
||||
|
||||
- Evidence artifacts:
|
||||
- Safety checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260316-063356_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260316-063356_status.txt`
|
||||
- Firmware Runtime 3 artifact:
|
||||
- `hardware/firmware/data/story/runtime3/DEFAULT.json`
|
||||
|
||||
- Remaining next steps:
|
||||
- remove the transitional `steps_reference_order` bridge after consumers fully adopt `firmware.steps`,
|
||||
- surface Runtime 3 metadata as a first-class panel in the studio,
|
||||
- validate the Runtime 3 adapter route on real hardware with flash/smoke evidence.
|
||||
|
||||
## [2026-03-16] Runtime 3 adapter follow-through + validation closure
|
||||
|
||||
- Objective:
|
||||
- continue the Zacus refactor waves after the baseline slice,
|
||||
- close the Runtime 3 adapter TODOs that were still open,
|
||||
- keep the hardware evidence tracker aligned with the new canonical validation flow.
|
||||
|
||||
- Changes applied:
|
||||
- Added Runtime 3 regression tests in `tests/runtime3/test_runtime3_routes.py`.
|
||||
- Covers the canonical route:
|
||||
- `STEP_U_SON_PROTO`
|
||||
- `STEP_LA_DETECTOR`
|
||||
- `STEP_RTC_ESP_ETAPE1`
|
||||
- `STEP_WIN_ETAPE1`
|
||||
- `STEP_WARNING`
|
||||
- `STEP_LEFOU_DETECTOR`
|
||||
- `STEP_RTC_ESP_ETAPE2`
|
||||
- `STEP_QR_DETECTOR`
|
||||
- `STEP_FINAL_WIN`
|
||||
- Covers explicit graph-priority behavior for `firmware.steps` fixtures.
|
||||
- Added `runtime3-test` to:
|
||||
- `Makefile`
|
||||
- `tools/test/run_content_checks.sh`
|
||||
- `tools/dev/zacus.sh`
|
||||
- Exported the firmware Runtime 3 bundle to:
|
||||
- `hardware/firmware/data/story/runtime3/DEFAULT.json`
|
||||
- Firmware adapter path now exposes:
|
||||
- `GET /api/runtime3/status`
|
||||
- `GET /api/runtime3/document`
|
||||
- `/api/status` now reports `story.runtime_contract` and `runtime3` metadata.
|
||||
- Updated coordination docs/todos/plans to reflect:
|
||||
- Runtime 3 adapter discovery is landed,
|
||||
- canonical validation now includes Runtime 3 regression tests,
|
||||
- remaining next slice is the full `firmware.steps` graph migration.
|
||||
- Corrected top-level agent/doc references that still pointed to `docs/AGENT_TODO.md` or `zacus_v1` in the active coordination path.
|
||||
|
||||
- Validation:
|
||||
- `python3 tools/scenario/verify_runtime3_pivots.py --help` -> PASS
|
||||
- `python3 tools/scenario/export_runtime3_firmware_bundle.py --help` -> PASS
|
||||
- `bash tools/dev/zacus.sh --help` -> PASS
|
||||
- `bash tools/test/run_content_checks.sh --help` -> PASS
|
||||
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
|
||||
- `python3 tools/scenario/export_runtime3_firmware_bundle.py game/scenarios/zacus_v2.yaml` -> PASS
|
||||
- `bash tools/dev/zacus.sh runtime3-verify` -> PASS
|
||||
- `bash tools/dev/zacus.sh runtime3-firmware-bundle` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run build` -> PASS
|
||||
- Warning only: Vite bundle still exceeds the 500 kB advisory threshold.
|
||||
- `python3 -m mkdocs build --strict` -> PASS
|
||||
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
|
||||
- RAM: `84.3%` (`276264 / 327680`)
|
||||
- Flash: `39.7%` (`2496997 / 6291456`)
|
||||
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
|
||||
|
||||
- Evidence artifacts:
|
||||
- Safety checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260316-054341_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260316-054341_status.txt`
|
||||
- Firmware Runtime 3 artifact:
|
||||
- `hardware/firmware/data/story/runtime3/DEFAULT.json`
|
||||
|
||||
- Remaining next steps:
|
||||
- migrate `game/scenarios/zacus_v2.yaml` from `firmware.steps_reference_order` to explicit `firmware.steps`,
|
||||
- render Runtime 3 adapter metadata as a first-class panel in the studio,
|
||||
- flash and smoke the Runtime 3 adapter route on real hardware.
|
||||
|
||||
## [2026-03-16] Zacus refactor baseline + build recovery
|
||||
|
||||
- Objective:
|
||||
- land the first Runtime 3/tooling/docs/studio slice of the Zacus refactor,
|
||||
- recover the broken React/Blockly build,
|
||||
- restore Freenove build margin after the DRAM overflow.
|
||||
|
||||
- Changes applied:
|
||||
- Added refactor coordination canon under `memory/`, `plans/`, and `todos/`.
|
||||
- Added Runtime 3 tooling:
|
||||
- `tools/scenario/runtime3_common.py`
|
||||
- `tools/scenario/compile_runtime3.py`
|
||||
- `tools/scenario/simulate_runtime3.py`
|
||||
- Updated `frontend-scratch-v2` for:
|
||||
- Blockly transition authoring,
|
||||
- YAML + Runtime 3 IR previews,
|
||||
- TypeScript-safe API/runtime typing.
|
||||
- Updated root docs/specs and MkDocs architecture maps.
|
||||
- Updated `tools/test/run_content_checks.sh`, `tools/dev/zacus.sh`, `Makefile`, and CI validation flow.
|
||||
- Reduced `UI_LV_MEM_SIZE_KB` from `128` to `120` in `hardware/firmware/platformio.ini`.
|
||||
|
||||
- Validation:
|
||||
- `bash tools/test/run_content_checks.sh --install-missing` -> PASS
|
||||
- `bash tools/dev/zacus.sh runtime3-compile` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run lint` -> PASS
|
||||
- `npm --prefix frontend-scratch-v2 run build` -> PASS
|
||||
- `python3 -m mkdocs build --strict` -> PASS
|
||||
- `pio run -d hardware/firmware -e freenove_esp32s3` -> PASS
|
||||
- RAM: `84.3%` (`276120 / 327680`)
|
||||
- Flash: `39.6%` (`2491997 / 6291456`)
|
||||
- `pio run -d hardware/firmware -e esp8266_oled` -> PASS
|
||||
|
||||
- Evidence artifacts:
|
||||
- Safety checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260316-051424_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260316-051424_status.txt`
|
||||
- Runtime 3 artifact:
|
||||
- `artifacts/runtime3/20260316-043303/`
|
||||
|
||||
- Remaining next steps:
|
||||
- load Runtime 3 artifacts from firmware instead of relying on migration-only generation,
|
||||
- add fixture-based tests for `LA 440`, `LEFOU`, and `QR WIN`,
|
||||
- continue quarantining legacy frontend/spec references before deletion.
|
||||
|
||||
## [2026-03-01] RESOLVED: A252 Audio Optimizations (3 Critical Fixes)
|
||||
|
||||
**Status: ✅ COMPLETE & DEPLOYED**
|
||||
|
||||
@@ -215,4 +215,4 @@ SYS_RTOS_STATUS
|
||||
- Mapping I2S out Freenove: `BCK=42`, `WS=41`, `DOUT=1` (`FREENOVE_I2S_BCK`, `FREENOVE_I2S_WS`, `FREENOVE_I2S_DOUT`).
|
||||
- Mapping micro I2S in: `SCK=3`, `WS=14`, `DIN=46` (`FREENOVE_I2S_IN_SCK`, `FREENOVE_I2S_IN_WS`, `FREENOVE_I2S_IN_DIN`).
|
||||
- Tester avec `MIC_TUNER_STATUS ON 200` et `HW_STATUS_JSON` pour vérifier fréquence/confiance/niveau.
|
||||
- En cas d’échec, consigner l’evidence (artefact + log + commande) dans `docs/AGENT_TODO.md`.
|
||||
- En cas d’échec, consigner l’evidence (artefact + log + commande) dans `hardware/firmware/docs/AGENT_TODO.md`.
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
- Display: `320x480`
|
||||
- Tick: `33 ms` (`~30 FPS` target)
|
||||
- Color mode default: `8bpp` (`LV_COLOR_DEPTH=8`)
|
||||
- `LV_MEM_SIZE`: `UI_LV_MEM_SIZE_KB` (default `160 KB`)
|
||||
- `LV_MEM_SIZE`: `UI_LV_MEM_SIZE_KB` (current target `120 KB`)
|
||||
|
||||
## Buffers
|
||||
|
||||
### Draw buffers
|
||||
Default (`UI_DRAW_BUF_LINES=40`):
|
||||
- Per buffer size = `320 * 40 * sizeof(lv_color_t)`
|
||||
- In 8bpp mode: `~12.5 KB` per buffer
|
||||
- Double-buffer total: `~25 KB`
|
||||
Default (`UI_DRAW_BUF_LINES=16`):
|
||||
- Per buffer size = `320 * 16 * sizeof(lv_color_t)`
|
||||
- In 8bpp mode: `~5 KB` per buffer
|
||||
- Double-buffer total: `~10 KB`
|
||||
|
||||
### Transfer buffer
|
||||
- `UI_DMA_TRANS_BUF_LINES=40`
|
||||
- `UI_DMA_TRANS_BUF_LINES=16`
|
||||
- Buffer type: RGB565 transfer (`uint16_t`)
|
||||
- Size = `320 * 40 * 2` = `~25 KB`
|
||||
- Size = `320 * 16 * 2` = `~10 KB`
|
||||
|
||||
### Full-frame bench (optional)
|
||||
- 8bpp full frame: `320 * 480 * 1` = `~150 KB`
|
||||
@@ -38,4 +38,3 @@ Use serial commands:
|
||||
2. Increase `UI_DRAW_BUF_LINES` only when internal free DMA heap is stable.
|
||||
3. Increase `UI_LV_MEM_SIZE_KB` only if runtime LVGL monitor shows low free headroom.
|
||||
4. Use PSRAM line buffers only when required, with `dma_trans_buf_` still in internal DMA SRAM.
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ bool sameText(const char* lhs, const char* rhs) {
|
||||
|
||||
} // namespace
|
||||
|
||||
int8_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId) {
|
||||
int16_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId) {
|
||||
if (scenario.steps == nullptr || scenario.stepCount == 0U || stepId == nullptr || stepId[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
for (uint8_t i = 0U; i < scenario.stepCount; ++i) {
|
||||
if (sameText(scenario.steps[i].id, stepId)) {
|
||||
return static_cast<int8_t>(i);
|
||||
return static_cast<int16_t>(i);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
|
||||
@@ -95,5 +95,5 @@ struct StorySnapshot {
|
||||
uint8_t queuedEvents;
|
||||
};
|
||||
|
||||
int8_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId);
|
||||
int16_t storyFindStepIndex(const ScenarioDef& scenario, const char* stepId);
|
||||
bool storyValidateScenarioDef(const ScenarioDef& scenario, StoryValidationError* outError);
|
||||
|
||||
@@ -133,7 +133,7 @@ build_flags =
|
||||
-DUI_FX_SPRITE_H=120
|
||||
-DUI_FX_TARGET_FPS=18
|
||||
-DFREENOVE_ENABLE_TASK_TOPOLOGY=0
|
||||
-DUI_LV_MEM_SIZE_KB=128
|
||||
-DUI_LV_MEM_SIZE_KB=120
|
||||
-DUI_FONT_PIXEL_ENABLE=1
|
||||
-DUI_FONT_TITLE_XL_ENABLE=1
|
||||
-DUI_ENABLE_MEM_MONITOR=0
|
||||
|
||||
@@ -61,7 +61,7 @@ Recommandation : ajouter des commentaires dans ui_freenove_config.h et tester
|
||||
3. **Utiliser le fallback I2S** : remplacer tout appel à `dacWrite` par une routine I2S (`i2sWriteSample`).
|
||||
4. **Initialiser I2S** : suivre l’exemple Espressif pour la configuration du canal, du clock, des slots et des GPIO.
|
||||
5. **Tester l’audio** : utiliser un signal simple (ex : sinusoïde) pour valider la sortie.
|
||||
6. **Documenter la traçabilité** : reporter les étapes, patchs et artefacts dans [docs/AGENT_TODO.md](docs/AGENT_TODO.md).
|
||||
6. **Documenter la traçabilité** : reporter les étapes, patchs et artefacts dans `hardware/firmware/docs/AGENT_TODO.md`.
|
||||
|
||||
### Liens techniques
|
||||
|
||||
@@ -109,7 +109,7 @@ Recommandation : ajouter des commentaires dans ui_freenove_config.h et tester
|
||||
# UI link : retry et diagnostic
|
||||
|
||||
- Le check UI link (connexion UI ESP32/ESP8266) intègre désormais une logique de retry automatique (3 essais par défaut, paramétrable via `ZACUS_UI_LINK_RETRIES`).
|
||||
- En cas d’échec, le script logue chaque tentative, synchronise l’évidence dans `docs/AGENT_TODO.md`, et produit un rapport détaillé dans les artefacts.
|
||||
- En cas d’échec, le script logue chaque tentative, synchronise l’évidence dans `hardware/firmware/docs/AGENT_TODO.md`, et produit un rapport détaillé dans les artefacts.
|
||||
- Toute évolution ou échec du protocole UI link est traçable dans les logs et la documentation.
|
||||
|
||||
# cockpit.sh : automatisation smoke test
|
||||
@@ -119,7 +119,7 @@ Recommandation : ajouter des commentaires dans ui_freenove_config.h et tester
|
||||
|
||||
# Synchronisation evidence/logs
|
||||
|
||||
- Chaque run (succès ou échec) synchronise l’évidence (artefacts, logs, verdict) dans `docs/AGENT_TODO.md`.
|
||||
- Chaque run (succès ou échec) synchronise l’évidence (artefacts, logs, verdict) dans `hardware/firmware/docs/AGENT_TODO.md`.
|
||||
- Les artefacts et logs sont produits dans `hardware/firmware/artifacts/` et `hardware/firmware/logs/`.
|
||||
- La traçabilité est assurée pour chaque run, avec horodatage, chemin, et verdict.
|
||||
|
||||
|
||||
@@ -16,6 +16,23 @@ struct ScenarioSnapshot {
|
||||
bool mp3_gate_open = false;
|
||||
};
|
||||
|
||||
struct Runtime3ArtifactInfo {
|
||||
bool discovered = false;
|
||||
bool loaded = false;
|
||||
size_t size_bytes = 0U;
|
||||
uint32_t scenario_version = 0U;
|
||||
uint16_t step_count = 0U;
|
||||
uint16_t transition_count = 0U;
|
||||
String path;
|
||||
String schema_version;
|
||||
String scenario_id;
|
||||
String entry_step_id;
|
||||
String source_kind;
|
||||
String generated_by;
|
||||
String migration_mode;
|
||||
String error;
|
||||
};
|
||||
|
||||
class ScenarioManager {
|
||||
public:
|
||||
static const char* readScenarioField(JsonVariantConst root,
|
||||
@@ -39,6 +56,7 @@ class ScenarioManager {
|
||||
bool gotoScene(const char* scene_id, uint32_t now_ms, const char* source);
|
||||
|
||||
ScenarioSnapshot snapshot() const;
|
||||
const Runtime3ArtifactInfo& runtime3Artifact() const;
|
||||
bool consumeSceneChanged();
|
||||
bool consumeAudioRequest(String* out_audio_pack_id);
|
||||
uint32_t transitionEventMask() const;
|
||||
@@ -59,6 +77,8 @@ class ScenarioManager {
|
||||
static constexpr uint8_t kMaxStepResourceOverrides = 24U;
|
||||
|
||||
void clearStepResourceOverrides();
|
||||
void clearRuntime3ArtifactInfo();
|
||||
void loadRuntime3ArtifactInfo(const char* scenario_file_path, const char* scenario_id);
|
||||
void loadStepResourceOverrides(const char* scenario_file_path);
|
||||
const StepResourceOverride* findStepResourceOverride(const char* step_id) const;
|
||||
void applyStepResourceOverride(const StepDef* step,
|
||||
@@ -93,4 +113,5 @@ class ScenarioManager {
|
||||
String initial_step_override_;
|
||||
StepResourceOverride step_resource_overrides_[kMaxStepResourceOverrides];
|
||||
uint8_t step_resource_override_count_ = 0U;
|
||||
Runtime3ArtifactInfo runtime3_artifact_;
|
||||
};
|
||||
|
||||
@@ -5180,12 +5180,31 @@ bool webDispatchAction(const String& action_raw) {
|
||||
return dispatchControlAction(action_raw, millis(), nullptr);
|
||||
}
|
||||
|
||||
void webFillRuntime3Status(JsonObject runtime3) {
|
||||
const Runtime3ArtifactInfo& artifact = g_scenario.runtime3Artifact();
|
||||
runtime3["discovered"] = artifact.discovered;
|
||||
runtime3["loaded"] = artifact.loaded;
|
||||
runtime3["path"] = artifact.path;
|
||||
runtime3["schema_version"] = artifact.schema_version;
|
||||
runtime3["scenario_id"] = artifact.scenario_id;
|
||||
runtime3["scenario_version"] = artifact.scenario_version;
|
||||
runtime3["entry_step_id"] = artifact.entry_step_id;
|
||||
runtime3["source_kind"] = artifact.source_kind;
|
||||
runtime3["generated_by"] = artifact.generated_by;
|
||||
runtime3["migration_mode"] = artifact.migration_mode;
|
||||
runtime3["step_count"] = artifact.step_count;
|
||||
runtime3["transition_count"] = artifact.transition_count;
|
||||
runtime3["size_bytes"] = artifact.size_bytes;
|
||||
runtime3["error"] = artifact.error;
|
||||
}
|
||||
|
||||
void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
|
||||
if (out_document == nullptr) {
|
||||
return;
|
||||
}
|
||||
const NetworkManager::Snapshot net = g_network.snapshot();
|
||||
const ScenarioSnapshot scenario = g_scenario.snapshot();
|
||||
const Runtime3ArtifactInfo& runtime3_artifact = g_scenario.runtime3Artifact();
|
||||
|
||||
out_document->clear();
|
||||
JsonObject network = (*out_document)["network"].to<JsonObject>();
|
||||
@@ -5214,6 +5233,7 @@ void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
|
||||
story["step"] = stepIdFromSnapshot(scenario);
|
||||
story["screen"] = (scenario.screen_scene_id != nullptr) ? scenario.screen_scene_id : "";
|
||||
story["audio_pack"] = (scenario.audio_pack_id != nullptr) ? scenario.audio_pack_id : "";
|
||||
story["runtime_contract"] = runtime3_artifact.loaded ? "runtime3+story_v2_adapter" : "story_v2";
|
||||
|
||||
JsonObject audio = (*out_document)["audio"].to<JsonObject>();
|
||||
audio["playing"] = g_audio.isPlaying();
|
||||
@@ -5234,6 +5254,9 @@ void webBuildStatusDocument(StaticJsonDocument<4096>* out_document) {
|
||||
JsonObject media = (*out_document)["media"].to<JsonObject>();
|
||||
webFillMediaStatus(media, millis());
|
||||
|
||||
JsonObject runtime3 = (*out_document)["runtime3"].to<JsonObject>();
|
||||
webFillRuntime3Status(runtime3);
|
||||
|
||||
const runtime::resource::ResourceCoordinatorSnapshot resource_snapshot = g_resource_coordinator.snapshot();
|
||||
const UiMemorySnapshot ui_snapshot = g_ui.memorySnapshot();
|
||||
JsonObject resource = (*out_document)["resource"].to<JsonObject>();
|
||||
@@ -5261,6 +5284,27 @@ void webSendStatus() {
|
||||
webSendJsonDocument(document);
|
||||
}
|
||||
|
||||
void webSendRuntime3Status() {
|
||||
StaticJsonDocument<1024> document;
|
||||
webFillRuntime3Status(document.to<JsonObject>());
|
||||
webSendJsonDocument(document);
|
||||
}
|
||||
|
||||
void webSendRuntime3Document() {
|
||||
const Runtime3ArtifactInfo& artifact = g_scenario.runtime3Artifact();
|
||||
if (!artifact.loaded || artifact.path.isEmpty()) {
|
||||
g_web_server.send(404, "application/json", "{\"ok\":false,\"error\":\"runtime3_not_loaded\"}");
|
||||
return;
|
||||
}
|
||||
File file = LittleFS.open(artifact.path.c_str(), "r");
|
||||
if (!file) {
|
||||
g_web_server.send(500, "application/json", "{\"ok\":false,\"error\":\"runtime3_open_failed\"}");
|
||||
return;
|
||||
}
|
||||
g_web_server.streamFile(file, "application/json");
|
||||
file.close();
|
||||
}
|
||||
|
||||
void webSendStatusSse() {
|
||||
StaticJsonDocument<4096> document;
|
||||
webBuildStatusDocument(&document);
|
||||
@@ -5307,6 +5351,14 @@ void setupWebUiImpl() {
|
||||
webSendStatus();
|
||||
});
|
||||
|
||||
webOnApi("/api/runtime3/status", HTTP_GET, []() {
|
||||
webSendRuntime3Status();
|
||||
});
|
||||
|
||||
webOnApi("/api/runtime3/document", HTTP_GET, []() {
|
||||
webSendRuntime3Document();
|
||||
});
|
||||
|
||||
webOnApi("/api/stream", HTTP_GET, []() {
|
||||
webSendStatusSse();
|
||||
});
|
||||
|
||||
@@ -89,6 +89,48 @@ bool loadScenarioIdFromFile(const char* scenario_file_path, String* out_scenario
|
||||
return true;
|
||||
}
|
||||
|
||||
bool loadRuntime3PathFromFile(const char* scenario_file_path, String* out_runtime3_path) {
|
||||
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0' || out_runtime3_path == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (!LittleFS.exists(scenario_file_path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(scenario_file_path, "r");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
StaticJsonDocument<96> filter;
|
||||
filter["runtime3_file"] = true;
|
||||
filter["runtime3_path"] = true;
|
||||
filter["story"]["runtime3_file"] = true;
|
||||
filter["story"]["runtime3_path"] = true;
|
||||
|
||||
StaticJsonDocument<512> document;
|
||||
const DeserializationError error =
|
||||
deserializeJson(document, file, DeserializationOption::Filter(filter));
|
||||
file.close();
|
||||
if (error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* const runtime3_keys[] = {"runtime3_file", "runtime3_path"};
|
||||
const char* runtime3_path = ScenarioManager::readScenarioField(
|
||||
document.as<JsonVariantConst>(), runtime3_keys, sizeof(runtime3_keys) / sizeof(runtime3_keys[0]));
|
||||
if (runtime3_path == nullptr) {
|
||||
runtime3_path = ScenarioManager::readScenarioField(
|
||||
document["story"].as<JsonVariantConst>(), runtime3_keys, sizeof(runtime3_keys) / sizeof(runtime3_keys[0]));
|
||||
}
|
||||
if (runtime3_path == nullptr || runtime3_path[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
*out_runtime3_path = runtime3_path;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* ScenarioManager::readScenarioField(JsonVariantConst root,
|
||||
@@ -123,6 +165,7 @@ bool ScenarioManager::begin(const char* scenario_file_path) {
|
||||
debug_transition_bypass_enabled_ = false;
|
||||
initial_step_override_.remove(0);
|
||||
clearStepResourceOverrides();
|
||||
clearRuntime3ArtifactInfo();
|
||||
String selected_scenario_id;
|
||||
if (loadScenarioIdFromFile(scenario_file_path, &selected_scenario_id)) {
|
||||
scenario_ = storyScenarioV2ById(selected_scenario_id.c_str());
|
||||
@@ -157,6 +200,7 @@ bool ScenarioManager::begin(const char* scenario_file_path) {
|
||||
}
|
||||
|
||||
loadStepResourceOverrides(scenario_file_path);
|
||||
loadRuntime3ArtifactInfo(scenario_file_path, scenario_->id);
|
||||
reset();
|
||||
return true;
|
||||
}
|
||||
@@ -166,6 +210,7 @@ bool ScenarioManager::beginById(const char* scenario_id) {
|
||||
debug_transition_bypass_enabled_ = false;
|
||||
initial_step_override_.remove(0);
|
||||
clearStepResourceOverrides();
|
||||
clearRuntime3ArtifactInfo();
|
||||
|
||||
if (scenario_id != nullptr && scenario_id[0] != '\0') {
|
||||
scenario_ = storyScenarioV2ById(scenario_id);
|
||||
@@ -183,6 +228,7 @@ bool ScenarioManager::beginById(const char* scenario_id) {
|
||||
} else {
|
||||
Serial.printf("[SCENARIO] warning: validation failed for %s\n", scenario_->id);
|
||||
}
|
||||
loadRuntime3ArtifactInfo(nullptr, scenario_->id);
|
||||
reset();
|
||||
return true;
|
||||
}
|
||||
@@ -378,6 +424,10 @@ ScenarioSnapshot ScenarioManager::snapshot() const {
|
||||
return out;
|
||||
}
|
||||
|
||||
const Runtime3ArtifactInfo& ScenarioManager::runtime3Artifact() const {
|
||||
return runtime3_artifact_;
|
||||
}
|
||||
|
||||
bool ScenarioManager::consumeSceneChanged() {
|
||||
const bool changed = scene_changed_;
|
||||
scene_changed_ = false;
|
||||
@@ -662,6 +712,94 @@ void ScenarioManager::clearStepResourceOverrides() {
|
||||
step_resource_override_count_ = 0U;
|
||||
}
|
||||
|
||||
void ScenarioManager::clearRuntime3ArtifactInfo() {
|
||||
runtime3_artifact_ = Runtime3ArtifactInfo{};
|
||||
}
|
||||
|
||||
void ScenarioManager::loadRuntime3ArtifactInfo(const char* scenario_file_path, const char* scenario_id) {
|
||||
clearRuntime3ArtifactInfo();
|
||||
|
||||
String runtime3_path;
|
||||
if (!loadRuntime3PathFromFile(scenario_file_path, &runtime3_path)) {
|
||||
if (scenario_id == nullptr || scenario_id[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
runtime3_path = "/story/runtime3/";
|
||||
runtime3_path += scenario_id;
|
||||
runtime3_path += ".json";
|
||||
}
|
||||
|
||||
runtime3_artifact_.discovered = true;
|
||||
runtime3_artifact_.path = runtime3_path;
|
||||
if (!LittleFS.exists(runtime3_path.c_str())) {
|
||||
runtime3_artifact_.error = "missing";
|
||||
Serial.printf("[RUNTIME3] artifact missing path=%s\n", runtime3_path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
File file = LittleFS.open(runtime3_path.c_str(), "r");
|
||||
if (!file) {
|
||||
runtime3_artifact_.error = "open_failed";
|
||||
Serial.printf("[RUNTIME3] open failed path=%s\n", runtime3_path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t file_size = static_cast<size_t>(file.size());
|
||||
runtime3_artifact_.size_bytes = file_size;
|
||||
if (file_size == 0U || file_size > 32768U) {
|
||||
runtime3_artifact_.error = "size_out_of_range";
|
||||
file.close();
|
||||
Serial.printf("[RUNTIME3] unexpected artifact size path=%s bytes=%u\n",
|
||||
runtime3_path.c_str(),
|
||||
static_cast<unsigned int>(file_size));
|
||||
return;
|
||||
}
|
||||
|
||||
DynamicJsonDocument document(file_size + 1024U);
|
||||
const DeserializationError error = deserializeJson(document, file);
|
||||
file.close();
|
||||
if (error) {
|
||||
runtime3_artifact_.error = error.c_str();
|
||||
Serial.printf("[RUNTIME3] parse failed path=%s error=%s\n",
|
||||
runtime3_path.c_str(),
|
||||
runtime3_artifact_.error.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
JsonObjectConst scenario = document["scenario"].as<JsonObjectConst>();
|
||||
JsonArrayConst steps = document["steps"].as<JsonArrayConst>();
|
||||
JsonObjectConst metadata = document["metadata"].as<JsonObjectConst>();
|
||||
if (scenario.isNull() || steps.isNull()) {
|
||||
runtime3_artifact_.error = "invalid_document";
|
||||
Serial.printf("[RUNTIME3] invalid artifact path=%s\n", runtime3_path.c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
runtime3_artifact_.schema_version = stringOrNull(document["schema_version"]);
|
||||
runtime3_artifact_.scenario_id = stringOrNull(scenario["id"]);
|
||||
runtime3_artifact_.entry_step_id = stringOrNull(scenario["entry_step_id"]);
|
||||
runtime3_artifact_.source_kind = stringOrNull(scenario["source_kind"]);
|
||||
runtime3_artifact_.generated_by = stringOrNull(metadata["generated_by"]);
|
||||
runtime3_artifact_.migration_mode = stringOrNull(metadata["migration_mode"]);
|
||||
runtime3_artifact_.scenario_version = scenario["version"].is<uint32_t>() ? scenario["version"].as<uint32_t>() : 0U;
|
||||
runtime3_artifact_.step_count = static_cast<uint16_t>(steps.size());
|
||||
runtime3_artifact_.transition_count = 0U;
|
||||
for (JsonVariantConst step_variant : steps) {
|
||||
JsonArrayConst transitions = step_variant["transitions"].as<JsonArrayConst>();
|
||||
if (!transitions.isNull()) {
|
||||
runtime3_artifact_.transition_count =
|
||||
static_cast<uint16_t>(runtime3_artifact_.transition_count + transitions.size());
|
||||
}
|
||||
}
|
||||
runtime3_artifact_.loaded = true;
|
||||
runtime3_artifact_.error.remove(0);
|
||||
Serial.printf("[RUNTIME3] loaded path=%s steps=%u transitions=%u entry=%s\n",
|
||||
runtime3_path.c_str(),
|
||||
static_cast<unsigned int>(runtime3_artifact_.step_count),
|
||||
static_cast<unsigned int>(runtime3_artifact_.transition_count),
|
||||
runtime3_artifact_.entry_step_id.c_str());
|
||||
}
|
||||
|
||||
void ScenarioManager::loadStepResourceOverrides(const char* scenario_file_path) {
|
||||
clearStepResourceOverrides();
|
||||
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0') {
|
||||
|
||||
@@ -388,15 +388,16 @@ def build_story_prompt(blueprint: dict[str, Any]) -> str:
|
||||
normalized_json = json.dumps(normalized, ensure_ascii=False, indent=2)
|
||||
|
||||
return (
|
||||
"Génère un YAML de scenario Zacus compatible Story V2 (format frontend).\n"
|
||||
"Le YAML doit contenir id, version, title, steps, app_bindings, initial_step.\n"
|
||||
"Génère un YAML de scénario Zacus V2 canonique, compatible studio + Runtime 3.\n"
|
||||
"Le YAML doit contenir au minimum id, version, title, players, duration, canon, steps_narrative, firmware.initial_step et firmware.steps.\n"
|
||||
"Conserver le flow par défaut décrit ci-dessous (noms d'événements inchangés).\n"
|
||||
"Si certains champs manquent, utilise les valeurs ci-dessous.\n\n"
|
||||
f"{normalized_json}\n\n"
|
||||
"Règles techniques:\n"
|
||||
"- transitions: trigger, event_type, event_name, target_step_id, after_ms, priority.\n"
|
||||
"- event_name respecte exactement: BTN,NEXT,AUDIO_DONE,ACK_*,UNLOCK,FORCE_*,ETAPE2_DUE,QR_TIMEOUT,WIN_DUE.\n"
|
||||
"- target_step_id accepte aussi SCENE_MEDIA_MANAGER.\n"
|
||||
"- firmware.steps[*] contient: step_id, screen_scene_id, audio_pack_id, actions, apps, transitions.\n"
|
||||
"- transitions: event_type, event_name, target_step_id, after_ms, priority.\n"
|
||||
"- event_name respecte exactement: BTN_NEXT, AUDIO_DONE, ACK_*, UNLOCK_*, QR_TIMEOUT.\n"
|
||||
"- garder un ordre cohérent entre steps_narrative et firmware.steps.\n"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -14,18 +14,31 @@ Protocol (XiaoZhi-inspired):
|
||||
3. Device sends text_query or binary OPUS audio
|
||||
4. Server: ASR -> LLM -> TTS -> stream audio back
|
||||
|
||||
Requires: fastapi, uvicorn, httpx, websockets
|
||||
Install: pip install fastapi uvicorn httpx websockets
|
||||
Listen protocol (OPUS audio path):
|
||||
- Client -> {"type":"listen","state":"detect","text":"Hi ESP"} (wake word)
|
||||
- Server -> {"type":"listen_ack","state":"ready"} (ready to receive)
|
||||
- Client -> binary OPUS frames (20ms each, 16kHz mono)
|
||||
- Client -> {"type":"listen","state":"stop"} (end of speech)
|
||||
- Server -> {"type":"stt","text":"transcribed text"} (STT result)
|
||||
- Server -> {"type":"tts","state":"start","text":"response"}
|
||||
- Server -> binary OPUS frames (TTS audio)
|
||||
- Server -> {"type":"tts","state":"stop"}
|
||||
|
||||
Requires: fastapi, uvicorn, httpx, websockets, opuslib, webrtcvad
|
||||
Install: pip install fastapi uvicorn httpx websockets opuslib webrtcvad
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
@@ -34,10 +47,26 @@ try:
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from starlette.websockets import WebSocketState
|
||||
except ImportError:
|
||||
print("Missing dependencies. Install with:")
|
||||
print("Missing core dependencies. Install with:")
|
||||
print(" pip install fastapi uvicorn httpx websockets")
|
||||
sys.exit(1)
|
||||
|
||||
# OPUS codec (optional — needed for audio path)
|
||||
try:
|
||||
import opuslib
|
||||
import opuslib.api.encoder
|
||||
import opuslib.api.decoder
|
||||
HAS_OPUS = True
|
||||
except ImportError:
|
||||
HAS_OPUS = False
|
||||
|
||||
# VAD (optional — needed for audio path)
|
||||
try:
|
||||
import webrtcvad
|
||||
HAS_VAD = True
|
||||
except ImportError:
|
||||
HAS_VAD = False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -46,9 +75,20 @@ TTS_URL = os.environ.get("ZACUS_TTS_URL", "http://192.168.0.120:8001/v1/audio/sp
|
||||
TTS_VOICE = os.environ.get("ZACUS_TTS_VOICE", "alloy")
|
||||
LLM_URL = os.environ.get("ZACUS_LLM_URL", "http://localhost:8100/v1/chat/completions")
|
||||
LLM_MODEL = os.environ.get("ZACUS_LLM_MODEL", "default")
|
||||
STT_URL = os.environ.get("ZACUS_STT_URL", "http://192.168.0.120:8901/v1/audio/transcriptions")
|
||||
AUTH_TOKEN = os.environ.get("ZACUS_VOICE_TOKEN", "")
|
||||
LOG_LEVEL = os.environ.get("ZACUS_VOICE_LOG", "INFO")
|
||||
|
||||
# Audio constants
|
||||
OPUS_SAMPLE_RATE = 16000 # 16kHz mono
|
||||
OPUS_CHANNELS = 1
|
||||
OPUS_FRAME_MS = 20 # 20ms frames from ESP32
|
||||
OPUS_FRAME_SAMPLES = OPUS_SAMPLE_RATE * OPUS_FRAME_MS // 1000 # 320 samples
|
||||
OPUS_APPLICATION = "voip" # opuslib application type
|
||||
TTS_OPUS_BITRATE = 24000 # 24kbps for TTS back to ESP32
|
||||
VAD_AGGRESSIVENESS = 2 # webrtcvad 0-3 (2 = balanced)
|
||||
VAD_SILENCE_FRAMES = 30 # ~600ms of silence to trigger end-of-speech
|
||||
|
||||
PROFESSOR_ZACUS_PROMPT = (
|
||||
"Tu es le Professeur Zacus, un scientifique excentrique et bienveillant. "
|
||||
"Tu parles en francais. Tu donnes des indices cryptiques pour aider les "
|
||||
@@ -68,6 +108,224 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger("voice_bridge")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audio Session Manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AudioSessionManager:
|
||||
"""Manages per-device audio sessions for OPUS decode, VAD, and STT.
|
||||
|
||||
Lifecycle:
|
||||
1. start() — called on listen/detect, creates decoder, resets buffers
|
||||
2. feed_opus(frame) — decode OPUS frame, run VAD, buffer PCM
|
||||
3. stop() -> str — finalize, send accumulated PCM to Whisper STT
|
||||
"""
|
||||
|
||||
def __init__(self, device_id: str):
|
||||
self.device_id = device_id
|
||||
self._pcm_buffer = bytearray()
|
||||
self._active = False
|
||||
self._decoder = None
|
||||
self._vad = None
|
||||
self._silence_count = 0
|
||||
self._speech_detected = False
|
||||
|
||||
def start(self):
|
||||
"""Start a new recording session."""
|
||||
self._pcm_buffer = bytearray()
|
||||
self._active = True
|
||||
self._silence_count = 0
|
||||
self._speech_detected = False
|
||||
|
||||
if HAS_OPUS:
|
||||
self._decoder = opuslib.Decoder(OPUS_SAMPLE_RATE, OPUS_CHANNELS)
|
||||
else:
|
||||
self._decoder = None
|
||||
logger.warning("[%s] opuslib not available — raw PCM passthrough", self.device_id)
|
||||
|
||||
if HAS_VAD:
|
||||
self._vad = webrtcvad.Vad(VAD_AGGRESSIVENESS)
|
||||
else:
|
||||
self._vad = None
|
||||
|
||||
logger.info("[%s] Audio session started (opus=%s, vad=%s)",
|
||||
self.device_id, HAS_OPUS, HAS_VAD)
|
||||
|
||||
def feed_opus(self, frame: bytes) -> bool:
|
||||
"""Decode one OPUS frame and buffer the PCM.
|
||||
|
||||
Returns True if VAD detected end-of-speech (enough trailing silence).
|
||||
"""
|
||||
if not self._active:
|
||||
return False
|
||||
|
||||
# Decode OPUS -> 16-bit PCM
|
||||
if self._decoder and HAS_OPUS:
|
||||
try:
|
||||
pcm = self._decoder.decode(frame, OPUS_FRAME_SAMPLES)
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] OPUS decode error: %s", self.device_id, exc)
|
||||
return False
|
||||
else:
|
||||
# Fallback: treat as raw 16-bit PCM
|
||||
pcm = frame
|
||||
|
||||
self._pcm_buffer.extend(pcm)
|
||||
|
||||
# VAD check
|
||||
if self._vad:
|
||||
try:
|
||||
# webrtcvad needs exactly 10/20/30ms of 16-bit PCM at supported rates
|
||||
is_speech = self._vad.is_speech(pcm, OPUS_SAMPLE_RATE)
|
||||
except Exception:
|
||||
is_speech = True # assume speech on VAD error
|
||||
|
||||
if is_speech:
|
||||
self._speech_detected = True
|
||||
self._silence_count = 0
|
||||
else:
|
||||
self._silence_count += 1
|
||||
if self._speech_detected and self._silence_count >= VAD_SILENCE_FRAMES:
|
||||
logger.info("[%s] VAD: end-of-speech detected", self.device_id)
|
||||
return True
|
||||
else:
|
||||
# No VAD: simple energy-based detection
|
||||
self._speech_detected = True
|
||||
if len(pcm) >= 2:
|
||||
energy = _rms_energy(pcm)
|
||||
if energy < 200: # silence threshold
|
||||
self._silence_count += 1
|
||||
if self._silence_count >= VAD_SILENCE_FRAMES:
|
||||
logger.info("[%s] Energy VAD: end-of-speech", self.device_id)
|
||||
return True
|
||||
else:
|
||||
self._silence_count = 0
|
||||
|
||||
return False
|
||||
|
||||
async def stop_and_transcribe(self) -> str:
|
||||
"""Stop session and transcribe accumulated PCM via Whisper."""
|
||||
self._active = False
|
||||
pcm_bytes = bytes(self._pcm_buffer)
|
||||
|
||||
if not pcm_bytes:
|
||||
logger.warning("[%s] Empty audio buffer, nothing to transcribe", self.device_id)
|
||||
return ""
|
||||
|
||||
duration_s = len(pcm_bytes) / (OPUS_SAMPLE_RATE * 2) # 16-bit = 2 bytes/sample
|
||||
logger.info("[%s] Transcribing %.1fs of audio (%d bytes PCM)",
|
||||
self.device_id, duration_s, len(pcm_bytes))
|
||||
|
||||
# Wrap PCM in WAV container for Whisper
|
||||
wav_bytes = _pcm_to_wav(pcm_bytes, OPUS_SAMPLE_RATE, OPUS_CHANNELS)
|
||||
|
||||
# POST to Whisper STT endpoint (OpenAI-compatible)
|
||||
text = await _transcribe_audio(wav_bytes, self.device_id)
|
||||
return text
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._active
|
||||
|
||||
|
||||
def _rms_energy(pcm: bytes) -> float:
|
||||
"""Compute RMS energy of 16-bit PCM samples."""
|
||||
n_samples = len(pcm) // 2
|
||||
if n_samples == 0:
|
||||
return 0.0
|
||||
samples = struct.unpack(f"<{n_samples}h", pcm[:n_samples * 2])
|
||||
return (sum(s * s for s in samples) / n_samples) ** 0.5
|
||||
|
||||
|
||||
def _pcm_to_wav(pcm: bytes, sample_rate: int, channels: int) -> bytes:
|
||||
"""Wrap raw 16-bit PCM in a WAV container."""
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(channels)
|
||||
wf.setsampwidth(2) # 16-bit
|
||||
wf.setframerate(sample_rate)
|
||||
wf.writeframes(pcm)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _wav_to_pcm(wav_data: bytes) -> tuple[bytes, int, int]:
|
||||
"""Extract raw PCM, sample_rate, channels from WAV bytes."""
|
||||
buf = io.BytesIO(wav_data)
|
||||
with wave.open(buf, "rb") as wf:
|
||||
pcm = wf.readframes(wf.getnframes())
|
||||
return pcm, wf.getframerate(), wf.getnchannels()
|
||||
|
||||
|
||||
def _encode_pcm_to_opus_frames(pcm: bytes, sample_rate: int, channels: int) -> list[bytes]:
|
||||
"""Encode raw PCM to a list of OPUS frames suitable for streaming.
|
||||
|
||||
Resamples to 16kHz mono if needed (simple nearest-neighbor).
|
||||
"""
|
||||
if not HAS_OPUS:
|
||||
logger.warning("opuslib not available — cannot encode OPUS")
|
||||
return []
|
||||
|
||||
# Resample to target rate if needed (simple decimation)
|
||||
if sample_rate != OPUS_SAMPLE_RATE or channels != OPUS_CHANNELS:
|
||||
pcm = _resample_pcm(pcm, sample_rate, channels, OPUS_SAMPLE_RATE, OPUS_CHANNELS)
|
||||
|
||||
encoder = opuslib.Encoder(OPUS_SAMPLE_RATE, OPUS_CHANNELS, OPUS_APPLICATION)
|
||||
|
||||
frame_size_bytes = OPUS_FRAME_SAMPLES * 2 # 16-bit mono
|
||||
frames = []
|
||||
offset = 0
|
||||
while offset + frame_size_bytes <= len(pcm):
|
||||
chunk = pcm[offset:offset + frame_size_bytes]
|
||||
try:
|
||||
opus_frame = encoder.encode(chunk, OPUS_FRAME_SAMPLES)
|
||||
frames.append(opus_frame)
|
||||
except Exception as exc:
|
||||
logger.debug("OPUS encode error at offset %d: %s", offset, exc)
|
||||
offset += frame_size_bytes
|
||||
|
||||
logger.debug("Encoded %d OPUS frames from %d bytes PCM", len(frames), len(pcm))
|
||||
return frames
|
||||
|
||||
|
||||
def _resample_pcm(pcm: bytes, src_rate: int, src_ch: int,
|
||||
dst_rate: int, dst_ch: int) -> bytes:
|
||||
"""Simple nearest-neighbor resample. Good enough for voice."""
|
||||
src_sample_size = 2 * src_ch # 16-bit per channel
|
||||
n_src_samples = len(pcm) // src_sample_size
|
||||
ratio = dst_rate / src_rate
|
||||
n_dst_samples = int(n_src_samples * ratio)
|
||||
|
||||
result = bytearray()
|
||||
for i in range(n_dst_samples):
|
||||
src_idx = int(i / ratio)
|
||||
if src_idx >= n_src_samples:
|
||||
src_idx = n_src_samples - 1
|
||||
offset = src_idx * src_sample_size
|
||||
if dst_ch == 1 and src_ch == 2:
|
||||
# Stereo to mono: average
|
||||
l = struct.unpack_from("<h", pcm, offset)[0]
|
||||
r = struct.unpack_from("<h", pcm, offset + 2)[0]
|
||||
result.extend(struct.pack("<h", (l + r) // 2))
|
||||
elif dst_ch == 1 and src_ch == 1:
|
||||
result.extend(pcm[offset:offset + 2])
|
||||
else:
|
||||
# Just take first channel
|
||||
result.extend(pcm[offset:offset + 2])
|
||||
|
||||
return bytes(result)
|
||||
|
||||
|
||||
# Per-device session registry
|
||||
_audio_sessions: dict[str, AudioSessionManager] = {}
|
||||
|
||||
|
||||
def _get_session(device_id: str) -> AudioSessionManager:
|
||||
"""Get or create an AudioSessionManager for a device."""
|
||||
if device_id not in _audio_sessions:
|
||||
_audio_sessions[device_id] = AudioSessionManager(device_id)
|
||||
return _audio_sessions[device_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -78,7 +336,15 @@ app = FastAPI(title="Zacus Voice Bridge", version="1.0.0")
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check."""
|
||||
return {"status": "ok", "service": "voice_bridge", "tts_url": TTS_URL}
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "voice_bridge",
|
||||
"tts_url": TTS_URL,
|
||||
"stt_url": STT_URL,
|
||||
"opus": HAS_OPUS,
|
||||
"vad": HAS_VAD,
|
||||
"active_sessions": len(_audio_sessions),
|
||||
}
|
||||
|
||||
|
||||
@app.websocket("/voice/ws")
|
||||
@@ -103,10 +369,16 @@ async def voice_websocket(websocket: WebSocket, token: str = ""):
|
||||
device_id = raw.get("device_id", "unknown")
|
||||
logger.info("Handshake from device: %s", device_id)
|
||||
|
||||
capabilities = ["tts", "llm", "text_query"]
|
||||
if HAS_OPUS:
|
||||
capabilities.append("opus_audio")
|
||||
if HAS_VAD:
|
||||
capabilities.append("vad")
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "hello_ack",
|
||||
"version": 1,
|
||||
"capabilities": ["tts", "llm", "text_query"],
|
||||
"version": 2,
|
||||
"capabilities": capabilities,
|
||||
})
|
||||
|
||||
# --- Main loop ---
|
||||
@@ -142,10 +414,40 @@ async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
|
||||
if msg_type == "listen":
|
||||
state = msg.get("state", "")
|
||||
if state == "detect":
|
||||
logger.info("[%s] Wake word detected: %s", device_id, msg.get("text", ""))
|
||||
wake_text = msg.get("text", "")
|
||||
logger.info("[%s] Wake word detected: %s", device_id, wake_text)
|
||||
# Start audio session for this device
|
||||
session = _get_session(device_id)
|
||||
session.start()
|
||||
await ws.send_json({"type": "listen_ack", "state": "ready"})
|
||||
|
||||
elif state == "stop":
|
||||
logger.info("[%s] Stopped listening", device_id)
|
||||
logger.info("[%s] Listen stop received — transcribing", device_id)
|
||||
session = _get_session(device_id)
|
||||
t0 = time.monotonic()
|
||||
transcription = await session.stop_and_transcribe()
|
||||
t_stt = time.monotonic() - t0
|
||||
|
||||
if transcription:
|
||||
logger.info("[%s] STT (%.1fs): %s", device_id, t_stt, transcription[:80])
|
||||
await ws.send_json({"type": "stt", "text": transcription})
|
||||
|
||||
# Feed transcription into the LLM pipeline
|
||||
# Detect hint routing: [HINT:puzzle_id:level] prefix
|
||||
hint_match = re.match(r'^\[HINT:(\w+):(\d)\]\s*(.*)', transcription)
|
||||
if hint_match:
|
||||
puzzle_id = hint_match.group(1)
|
||||
hint_level = int(hint_match.group(2))
|
||||
question = hint_match.group(3) or "Give me a hint"
|
||||
response = await _query_hints(puzzle_id, question, hint_level, device_id)
|
||||
else:
|
||||
response = await _query_llm(transcription)
|
||||
|
||||
# TTS response — encode to OPUS if available
|
||||
await _send_tts_response(ws, response, device_id)
|
||||
else:
|
||||
logger.warning("[%s] STT returned empty text", device_id)
|
||||
await ws.send_json({"type": "stt", "text": ""})
|
||||
|
||||
elif msg_type == "text_query":
|
||||
query = msg.get("text", "").strip()
|
||||
@@ -166,21 +468,9 @@ async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
|
||||
response = await _query_hints(puzzle_id, question, hint_level, device_id)
|
||||
else:
|
||||
response = await _query_llm(query)
|
||||
t_llm = time.monotonic() - t0
|
||||
|
||||
# TTS
|
||||
await ws.send_json({"type": "tts", "state": "start", "text": response})
|
||||
audio = await _text_to_speech(response)
|
||||
t_total = time.monotonic() - t0
|
||||
|
||||
if audio:
|
||||
await ws.send_bytes(audio)
|
||||
logger.info("[%s] Response sent: LLM=%.1fs, total=%.1fs, audio=%dKB",
|
||||
device_id, t_llm, t_total, len(audio) // 1024)
|
||||
else:
|
||||
logger.warning("[%s] TTS failed, text-only response", device_id)
|
||||
|
||||
await ws.send_json({"type": "tts", "state": "stop"})
|
||||
# TTS response (OPUS-encoded if available)
|
||||
await _send_tts_response(ws, response, device_id)
|
||||
|
||||
elif msg_type == "abort":
|
||||
logger.info("[%s] Playback aborted", device_id)
|
||||
@@ -190,17 +480,37 @@ async def _handle_message(ws: WebSocket, msg: dict, device_id: str):
|
||||
|
||||
|
||||
async def _handle_audio(ws: WebSocket, frame: bytes, device_id: str):
|
||||
"""Handle binary audio frame.
|
||||
"""Handle binary OPUS audio frame from ESP32.
|
||||
|
||||
MVP: Log frame size. Full implementation will:
|
||||
1. Decode OPUS
|
||||
2. Buffer until VAD end-of-speech
|
||||
3. Run ASR (faster-whisper)
|
||||
4. Query LLM
|
||||
5. TTS + stream back
|
||||
Decodes OPUS, buffers PCM, runs VAD. On end-of-speech detection,
|
||||
automatically triggers transcription and LLM response.
|
||||
"""
|
||||
logger.debug("[%s] Audio frame: %d bytes", device_id, len(frame))
|
||||
# TODO: OPUS decode + ASR pipeline
|
||||
session = _get_session(device_id)
|
||||
|
||||
if not session.active:
|
||||
logger.debug("[%s] Audio frame received but no active session (%d bytes)",
|
||||
device_id, len(frame))
|
||||
return
|
||||
|
||||
end_of_speech = session.feed_opus(frame)
|
||||
|
||||
if end_of_speech:
|
||||
# VAD triggered end-of-speech — transcribe automatically
|
||||
logger.info("[%s] VAD end-of-speech — auto-transcribing", device_id)
|
||||
t0 = time.monotonic()
|
||||
transcription = await session.stop_and_transcribe()
|
||||
t_stt = time.monotonic() - t0
|
||||
|
||||
if transcription:
|
||||
logger.info("[%s] Auto-STT (%.1fs): %s", device_id, t_stt, transcription[:80])
|
||||
await ws.send_json({"type": "stt", "text": transcription})
|
||||
|
||||
# Feed into LLM pipeline
|
||||
response = await _query_llm(transcription)
|
||||
await _send_tts_response(ws, response, device_id)
|
||||
else:
|
||||
logger.warning("[%s] Auto-STT returned empty", device_id)
|
||||
await ws.send_json({"type": "stt", "text": ""})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -259,7 +569,7 @@ async def _query_llm(text: str) -> str:
|
||||
|
||||
|
||||
async def _text_to_speech(text: str) -> Optional[bytes]:
|
||||
"""Convert text to speech via Piper/OpenedAI-Speech API."""
|
||||
"""Convert text to speech via Piper/OpenedAI-Speech API. Returns WAV bytes."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.post(
|
||||
@@ -280,6 +590,64 @@ async def _text_to_speech(text: str) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
async def _transcribe_audio(wav_bytes: bytes, device_id: str) -> str:
|
||||
"""Send WAV audio to Whisper STT (OpenAI-compatible) and return text."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(
|
||||
STT_URL,
|
||||
files={"file": ("audio.wav", wav_bytes, "audio/wav")},
|
||||
data={"model": "whisper-1", "language": "fr"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("text", "").strip()
|
||||
logger.error("[%s] STT HTTP %d: %s", device_id, resp.status_code, resp.text[:200])
|
||||
except Exception as exc:
|
||||
logger.error("[%s] STT error: %s", device_id, exc)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
async def _send_tts_response(ws: WebSocket, text: str, device_id: str):
|
||||
"""Get TTS audio, optionally encode to OPUS, and stream to client.
|
||||
|
||||
Protocol:
|
||||
1. {"type":"tts","state":"start","text":"..."}
|
||||
2. Binary OPUS frames (or single WAV blob if OPUS unavailable)
|
||||
3. {"type":"tts","state":"stop"}
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
await ws.send_json({"type": "tts", "state": "start", "text": text})
|
||||
|
||||
wav_audio = await _text_to_speech(text)
|
||||
t_tts = time.monotonic() - t0
|
||||
|
||||
if wav_audio:
|
||||
if HAS_OPUS:
|
||||
# Encode WAV -> OPUS frames and stream
|
||||
try:
|
||||
pcm, rate, channels = _wav_to_pcm(wav_audio)
|
||||
opus_frames = _encode_pcm_to_opus_frames(pcm, rate, channels)
|
||||
for frame in opus_frames:
|
||||
await ws.send_bytes(frame)
|
||||
logger.info("[%s] TTS OPUS sent: %.1fs, %d frames",
|
||||
device_id, t_tts, len(opus_frames))
|
||||
except Exception as exc:
|
||||
logger.error("[%s] OPUS encode failed, falling back to WAV: %s",
|
||||
device_id, exc)
|
||||
await ws.send_bytes(wav_audio)
|
||||
else:
|
||||
# No OPUS encoder — send raw WAV blob
|
||||
await ws.send_bytes(wav_audio)
|
||||
logger.info("[%s] TTS WAV sent: %.1fs, %dKB",
|
||||
device_id, t_tts, len(wav_audio) // 1024)
|
||||
else:
|
||||
logger.warning("[%s] TTS failed, text-only response", device_id)
|
||||
|
||||
await ws.send_json({"type": "tts", "state": "stop"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -290,17 +658,22 @@ def main():
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
|
||||
parser.add_argument("--tts-url", default=None, help="TTS API URL")
|
||||
parser.add_argument("--llm-url", default=None, help="LLM API URL")
|
||||
parser.add_argument("--stt-url", default=None, help="Whisper STT API URL")
|
||||
args = parser.parse_args()
|
||||
|
||||
global TTS_URL, LLM_URL
|
||||
global TTS_URL, LLM_URL, STT_URL
|
||||
if args.tts_url:
|
||||
TTS_URL = args.tts_url
|
||||
if args.llm_url:
|
||||
LLM_URL = args.llm_url
|
||||
if args.stt_url:
|
||||
STT_URL = args.stt_url
|
||||
|
||||
logger.info("Starting Voice Bridge on %s:%d", args.host, args.port)
|
||||
logger.info(" TTS: %s", TTS_URL)
|
||||
logger.info(" LLM: %s", LLM_URL)
|
||||
logger.info(" STT: %s", STT_URL)
|
||||
logger.info(" OPUS: %s | VAD: %s", HAS_OPUS, HAS_VAD)
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Voice Bridge dependencies
|
||||
# Core (required)
|
||||
fastapi>=0.100
|
||||
uvicorn>=0.23
|
||||
httpx>=0.24
|
||||
websockets>=11.0
|
||||
|
||||
# OPUS audio codec (required for audio path)
|
||||
opuslib>=3.0.1
|
||||
|
||||
# Voice Activity Detection (required for audio path)
|
||||
webrtcvad>=2.0.10
|
||||
+356
-14
@@ -12,6 +12,9 @@ ARTIFACT_ROOT="$REPO_ROOT/artifacts"
|
||||
PORTS_ARTIFACT_ROOT="$ARTIFACT_ROOT/ports"
|
||||
LATEST_PORTS_JSON="$PORTS_ARTIFACT_ROOT/latest_ports_resolve.json"
|
||||
CODEX_ARTIFACT_ROOT="$ARTIFACT_ROOT/codex"
|
||||
RUNTIME3_ARTIFACT_ROOT="$ARTIFACT_ROOT/runtime3"
|
||||
FRONTEND_ROOT="$REPO_ROOT/frontend-scratch-v2"
|
||||
DEFAULT_SCENARIO="$REPO_ROOT/game/scenarios/zacus_v2.yaml"
|
||||
DEFAULT_PORTS_FIXTURE="$REPO_ROOT/tools/test/fixtures/ports_list_macos.txt"
|
||||
LAST_RESOLVED_JSON=""
|
||||
|
||||
@@ -33,6 +36,18 @@ usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: zacus.sh <command> [args]
|
||||
Commands:
|
||||
content-checks Run canonical validators, Runtime 3 compile/sim, and export
|
||||
runtime3-compile Compile a scenario YAML to Runtime 3 IR JSON
|
||||
runtime3-simulate Simulate the Runtime 3 route from a scenario YAML
|
||||
runtime3-verify Verify Runtime 3 pivot coverage on the canonical scenario
|
||||
runtime3-test Run Runtime 3 unit tests
|
||||
runtime3-firmware-bundle Export Runtime 3 JSON into the firmware LittleFS tree
|
||||
frontend-lint Run the React/Blockly studio lint gate
|
||||
frontend-test Run the React/Blockly studio test gate
|
||||
frontend-build Run the React/Blockly studio build gate
|
||||
docs-build Build the MkDocs site in strict mode
|
||||
artifacts-summary Show recent Zacus artifact directories
|
||||
artifacts-prune Delete old Zacus artifact directories (explicit --yes required)
|
||||
rc Run the RC live gate (always strict)
|
||||
smoke Run the serial smoke helper (pass --role ... args)
|
||||
ports Resolve ports once and show summary
|
||||
@@ -43,6 +58,10 @@ Commands:
|
||||
USAGE
|
||||
}
|
||||
|
||||
have_gum() {
|
||||
[[ -t 0 && -t 1 ]] && command -v gum >/dev/null 2>&1
|
||||
}
|
||||
|
||||
print_ports_summary() {
|
||||
local json_file="$1"
|
||||
if [[ ! -f "$json_file" ]]; then
|
||||
@@ -157,6 +176,239 @@ cmd_ports() {
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_content_checks() {
|
||||
info "running content checks"
|
||||
(cd "$REPO_ROOT" && bash tools/test/run_content_checks.sh "$@")
|
||||
}
|
||||
|
||||
cmd_runtime3_compile() {
|
||||
local scenario="$DEFAULT_SCENARIO"
|
||||
local out_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scenario)
|
||||
shift
|
||||
scenario="${1:-}"
|
||||
;;
|
||||
-o|--out)
|
||||
shift
|
||||
out_file="${1:-}"
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<'HELP'
|
||||
Usage: zacus.sh runtime3-compile [--scenario <yaml>] [-o <json>]
|
||||
Compiles the canonical YAML into Zacus Runtime 3 IR JSON.
|
||||
HELP
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "unexpected option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
mkdir -p "$RUNTIME3_ARTIFACT_ROOT"
|
||||
local ts
|
||||
ts=$(date -u +%Y%m%d-%H%M%S)
|
||||
local art_dir="$RUNTIME3_ARTIFACT_ROOT/$ts"
|
||||
mkdir -p "$art_dir"
|
||||
if [[ -z "$out_file" ]]; then
|
||||
out_file="$art_dir/runtime3.json"
|
||||
fi
|
||||
printf 'command=runtime3-compile\nscenario=%s\nout=%s\n' "$scenario" "$out_file" > "$art_dir/meta.txt"
|
||||
printf 'python3 tools/scenario/compile_runtime3.py %q -o %q\n' "$scenario" "$out_file" > "$art_dir/commands.txt"
|
||||
info "compiling Runtime 3 (out=$out_file)"
|
||||
(cd "$REPO_ROOT" && python3 tools/scenario/compile_runtime3.py "$scenario" -o "$out_file")
|
||||
cat > "$art_dir/summary.md" <<EOF
|
||||
# Runtime 3 Compile
|
||||
|
||||
- Scenario: \`$scenario\`
|
||||
- Output: \`$out_file\`
|
||||
- Status: success
|
||||
EOF
|
||||
printf '[runtime3] artifact directory: %s\n' "$art_dir"
|
||||
}
|
||||
|
||||
cmd_runtime3_simulate() {
|
||||
local scenario="$DEFAULT_SCENARIO"
|
||||
local max_steps="16"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scenario)
|
||||
shift
|
||||
scenario="${1:-}"
|
||||
;;
|
||||
--max-steps)
|
||||
shift
|
||||
max_steps="${1:-}"
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<'HELP'
|
||||
Usage: zacus.sh runtime3-simulate [--scenario <yaml>] [--max-steps <n>]
|
||||
Simulates the Runtime 3 route from the given scenario YAML.
|
||||
HELP
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "unexpected option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
mkdir -p "$RUNTIME3_ARTIFACT_ROOT"
|
||||
local ts
|
||||
ts=$(date -u +%Y%m%d-%H%M%S)
|
||||
local art_dir="$RUNTIME3_ARTIFACT_ROOT/$ts"
|
||||
mkdir -p "$art_dir"
|
||||
printf 'command=runtime3-simulate\nscenario=%s\nmax_steps=%s\n' "$scenario" "$max_steps" > "$art_dir/meta.txt"
|
||||
printf 'python3 tools/scenario/simulate_runtime3.py %q --max-steps %q\n' "$scenario" "$max_steps" > "$art_dir/commands.txt"
|
||||
info "simulating Runtime 3 (scenario=$scenario max_steps=$max_steps)"
|
||||
(cd "$REPO_ROOT" && python3 tools/scenario/simulate_runtime3.py "$scenario" --max-steps "$max_steps") | tee "$art_dir/simulate.log"
|
||||
cat > "$art_dir/summary.md" <<EOF
|
||||
# Runtime 3 Simulation
|
||||
|
||||
- Scenario: \`$scenario\`
|
||||
- Max steps: \`$max_steps\`
|
||||
- Status: success
|
||||
EOF
|
||||
printf '[runtime3] artifact directory: %s\n' "$art_dir"
|
||||
}
|
||||
|
||||
cmd_runtime3_verify() {
|
||||
local scenario="$DEFAULT_SCENARIO"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scenario)
|
||||
shift
|
||||
scenario="${1:-}"
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<'HELP'
|
||||
Usage: zacus.sh runtime3-verify [--scenario <yaml>]
|
||||
Checks Runtime 3 pivots and route order on the given scenario YAML.
|
||||
HELP
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "unexpected option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
info "verifying Runtime 3 pivots (scenario=$scenario)"
|
||||
(cd "$REPO_ROOT" && python3 tools/scenario/verify_runtime3_pivots.py "$scenario")
|
||||
}
|
||||
|
||||
cmd_runtime3_test() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--help|-h)
|
||||
cat <<'HELP'
|
||||
Usage: zacus.sh runtime3-test
|
||||
Runs the Runtime 3 unit test suite.
|
||||
HELP
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "unexpected option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
info "running Runtime 3 unit tests"
|
||||
(cd "$REPO_ROOT" && python3 -m unittest discover -s tests/runtime3 -p 'test_*.py')
|
||||
}
|
||||
|
||||
cmd_runtime3_firmware_bundle() {
|
||||
local scenario="$DEFAULT_SCENARIO"
|
||||
local out_file="$REPO_ROOT/hardware/firmware/data/story/runtime3/DEFAULT.json"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--scenario)
|
||||
shift
|
||||
scenario="${1:-}"
|
||||
;;
|
||||
-o|--out)
|
||||
shift
|
||||
out_file="${1:-}"
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<'HELP'
|
||||
Usage: zacus.sh runtime3-firmware-bundle [--scenario <yaml>] [-o <json>]
|
||||
Exports Runtime 3 JSON into the firmware LittleFS tree.
|
||||
HELP
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "unexpected option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
info "exporting Runtime 3 firmware bundle (out=$out_file)"
|
||||
(cd "$REPO_ROOT" && python3 tools/scenario/export_runtime3_firmware_bundle.py "$scenario" -o "$out_file")
|
||||
}
|
||||
|
||||
cmd_frontend_lint() {
|
||||
info "running frontend lint"
|
||||
(cd "$FRONTEND_ROOT" && npm run lint)
|
||||
}
|
||||
|
||||
cmd_frontend_test() {
|
||||
info "running frontend tests"
|
||||
(cd "$FRONTEND_ROOT" && npm run test)
|
||||
}
|
||||
|
||||
cmd_frontend_build() {
|
||||
info "running frontend build"
|
||||
(cd "$FRONTEND_ROOT" && npm run build)
|
||||
}
|
||||
|
||||
cmd_docs_build() {
|
||||
info "building MkDocs site"
|
||||
(cd "$REPO_ROOT" && python3 -m mkdocs build --strict)
|
||||
}
|
||||
|
||||
cmd_artifacts_summary() {
|
||||
mkdir -p "$ARTIFACT_ROOT"
|
||||
info "recent artifact directories"
|
||||
find "$ARTIFACT_ROOT" -mindepth 1 -maxdepth 2 -type d | sort | tail -n 20 | while IFS= read -r path; do
|
||||
du -sh "$path"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_artifacts_prune() {
|
||||
local keep_days="7"
|
||||
local confirmed="0"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--keep-days)
|
||||
shift
|
||||
keep_days="${1:-}"
|
||||
;;
|
||||
--yes)
|
||||
confirmed="1"
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<'HELP'
|
||||
Usage: zacus.sh artifacts-prune [--keep-days <n>] --yes
|
||||
Deletes artifact directories older than the retention window under artifacts/.
|
||||
HELP
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
die "unexpected option: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [[ "$confirmed" != "1" ]]; then
|
||||
die "artifacts-prune is destructive; re-run with --yes"
|
||||
fi
|
||||
info "pruning artifacts older than $keep_days days"
|
||||
find "$ARTIFACT_ROOT" -mindepth 2 -maxdepth 2 -type d -mtime +"$keep_days" -print -exec rm -rf {} +
|
||||
}
|
||||
|
||||
cmd_rc() {
|
||||
info "running RC live"
|
||||
(cd "$FW_ROOT" && "$RC_RUNNER")
|
||||
@@ -266,6 +518,10 @@ menu_select_prompt() {
|
||||
if [[ ${#choices[@]} -eq 0 ]]; then
|
||||
die "no prompt files found under $PROMPT_DIR"
|
||||
fi
|
||||
if have_gum; then
|
||||
gum choose --header "Select Codex prompt" "${choices[@]}"
|
||||
return 0
|
||||
fi
|
||||
printf 'Available prompts:\n'
|
||||
local idx=1
|
||||
for prompt in "${choices[@]}"; do
|
||||
@@ -334,31 +590,81 @@ cmd_menu() {
|
||||
while true; do
|
||||
printf '\n=== Zacus cockpit ===\n'
|
||||
show_ports_context || true
|
||||
printf '[menu] options:\n'
|
||||
printf ' 1) RC live\n'
|
||||
printf ' 2) Serial smoke\n'
|
||||
printf ' 3) Codex (run prompt)\n'
|
||||
printf ' 4) Ports: resolve now\n'
|
||||
printf ' 5) ZeroClaw preflight\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -rp 'Choice: ' choice
|
||||
if have_gum; then
|
||||
choice="$(gum choose \
|
||||
"RC live" \
|
||||
"Serial smoke" \
|
||||
"Content checks" \
|
||||
"Runtime 3 compile" \
|
||||
"Runtime 3 simulate" \
|
||||
"Runtime 3 verify" \
|
||||
"Runtime 3 tests" \
|
||||
"Runtime 3 firmware bundle" \
|
||||
"Frontend tests" \
|
||||
"Frontend build" \
|
||||
"Codex (run prompt)" \
|
||||
"Ports: resolve now" \
|
||||
"ZeroClaw preflight" \
|
||||
"Exit")"
|
||||
else
|
||||
printf '[menu] options:\n'
|
||||
printf ' 1) RC live\n'
|
||||
printf ' 2) Serial smoke\n'
|
||||
printf ' 3) Content checks\n'
|
||||
printf ' 4) Runtime 3 compile\n'
|
||||
printf ' 5) Runtime 3 simulate\n'
|
||||
printf ' 6) Runtime 3 verify\n'
|
||||
printf ' 7) Runtime 3 tests\n'
|
||||
printf ' 8) Runtime 3 firmware bundle\n'
|
||||
printf ' 9) Frontend tests\n'
|
||||
printf ' 10) Frontend build\n'
|
||||
printf ' 11) Codex (run prompt)\n'
|
||||
printf ' 12) Ports: resolve now\n'
|
||||
printf ' 13) ZeroClaw preflight\n'
|
||||
printf ' 0) Exit\n'
|
||||
read -rp 'Choice: ' choice
|
||||
fi
|
||||
case "$choice" in
|
||||
1)
|
||||
1|"RC live")
|
||||
cmd_rc
|
||||
;;
|
||||
2)
|
||||
2|"Serial smoke")
|
||||
cmd_smoke
|
||||
;;
|
||||
3)
|
||||
3|"Content checks")
|
||||
cmd_content_checks
|
||||
;;
|
||||
4|"Runtime 3 compile")
|
||||
cmd_runtime3_compile
|
||||
;;
|
||||
5|"Runtime 3 simulate")
|
||||
cmd_runtime3_simulate
|
||||
;;
|
||||
6|"Runtime 3 verify")
|
||||
cmd_runtime3_verify
|
||||
;;
|
||||
7|"Runtime 3 tests")
|
||||
cmd_runtime3_test
|
||||
;;
|
||||
8|"Runtime 3 firmware bundle")
|
||||
cmd_runtime3_firmware_bundle
|
||||
;;
|
||||
9|"Frontend tests")
|
||||
cmd_frontend_test
|
||||
;;
|
||||
10|"Frontend build")
|
||||
cmd_frontend_build
|
||||
;;
|
||||
11|"Codex (run prompt)")
|
||||
menu_run_codex
|
||||
;;
|
||||
4)
|
||||
12|"Ports: resolve now")
|
||||
cmd_ports
|
||||
;;
|
||||
5)
|
||||
13|"ZeroClaw preflight")
|
||||
cmd_zeroclaw_preflight --require-port
|
||||
;;
|
||||
0)
|
||||
0|"Exit")
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
@@ -371,6 +677,42 @@ cmd_menu() {
|
||||
command="${1:-help}"
|
||||
shift || true
|
||||
case "$command" in
|
||||
content-checks)
|
||||
cmd_content_checks "$@"
|
||||
;;
|
||||
runtime3-compile)
|
||||
cmd_runtime3_compile "$@"
|
||||
;;
|
||||
runtime3-simulate)
|
||||
cmd_runtime3_simulate "$@"
|
||||
;;
|
||||
runtime3-verify)
|
||||
cmd_runtime3_verify "$@"
|
||||
;;
|
||||
runtime3-test)
|
||||
cmd_runtime3_test "$@"
|
||||
;;
|
||||
runtime3-firmware-bundle)
|
||||
cmd_runtime3_firmware_bundle "$@"
|
||||
;;
|
||||
frontend-lint)
|
||||
cmd_frontend_lint "$@"
|
||||
;;
|
||||
frontend-test)
|
||||
cmd_frontend_test "$@"
|
||||
;;
|
||||
frontend-build)
|
||||
cmd_frontend_build "$@"
|
||||
;;
|
||||
docs-build)
|
||||
cmd_docs_build "$@"
|
||||
;;
|
||||
artifacts-summary)
|
||||
cmd_artifacts_summary "$@"
|
||||
;;
|
||||
artifacts-prune)
|
||||
cmd_artifacts_prune "$@"
|
||||
;;
|
||||
rc)
|
||||
cmd_rc
|
||||
;;
|
||||
|
||||
@@ -102,7 +102,13 @@ def to_export_model(scenario: dict) -> dict:
|
||||
|
||||
firmware_steps = []
|
||||
firmware = scenario.get("firmware")
|
||||
if isinstance(firmware, dict) and isinstance(firmware.get("steps_reference_order"), list):
|
||||
if isinstance(firmware, dict) and isinstance(firmware.get("steps"), list):
|
||||
firmware_steps = [
|
||||
str(step.get("step_id", ""))
|
||||
for step in firmware["steps"]
|
||||
if isinstance(step, dict) and step.get("step_id")
|
||||
]
|
||||
elif isinstance(firmware, dict) and isinstance(firmware.get("steps_reference_order"), list):
|
||||
firmware_steps = [str(step) for step in firmware["steps_reference_order"] if step]
|
||||
|
||||
proof = []
|
||||
|
||||
Reference in New Issue
Block a user