commit db7083845b904d8e5a79cc0f47708feb2685e9d3 Author: Isaac Date: Sun Mar 1 20:08:00 2026 +0100 Initial commit: Freenove ESP32-S3 UI + story engine from le-mystere-professeur-zacus - Sources: ui_freenove_allinone/ (LovyanGFX + LVGL rendering) - Libraries: story engine, hardware managers, audio codec drivers - PlatformIO: freenove_esp32s3_full_with_ui environment - Status: Ready for optimization work Date: 2026-03-01 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..737863c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# PlatformIO +.pio/ +.platformio/ +build/ +.gcc-flags.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ + +# OS +.DS_Store +Thumbs.db + +# Logs & Artifacts +logs/ +artifacts/ +*.log +*.tmp + +# Firmware artifacts +upload_port +monitor_port +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..561de7c --- /dev/null +++ b/README.md @@ -0,0 +1,210 @@ +# Optimisation automatique hardware +Depuis février 2026, le cockpit détecte automatiquement le hardware connecté (port série, ID USB) et ne build/flash que l’environnement PlatformIO correspondant. + +Pour builder ou flasher : +- Menu cockpit : "Build all firmware" ou "Flash auto (hardware détecté)" +- Ligne de commande : ./tools/dev/cockpit.sh flash (auto) ou ./tools/dev/cockpit.sh build (auto) + +La détection est basée sur pyserial et l’ID USB (Freenove, ESP32, ESP8266, RP2040). Le log affiche le hardware détecté et l’environnement PlatformIO ciblé. + +Pour forcer un build/flash spécifique : ./tools/dev/cockpit.sh flash + +# Hardware Firmware + +> **[Mise à jour 2026]** +> +> **Tous les assets LittleFS (scénarios, écrans, scènes, audio, actions, etc.) sont désormais centralisés dans le dossier `data/` à la racine de `hardware/firmware/`.** +> +> Ce dossier unique sert de source pour le flash LittleFS sur ESP32, ESP8266 et RP2040. Les anciens dossiers `data/` dans les sous-projets doivent être migrés/supprimés (voir encart migration ci-dessous). + + +Workspace PlatformIO unique pour 3 firmwares: + +- `esp32_audio/`: firmware principal audio +- `ui/esp8266_oled/`: UI OLED legere +- `ui/rp2040_tft/`: UI TFT tactile (LVGL) +- `protocol/`: contrat UART partage (`ui_link_v2.md`, `ui_link_v2.h`) +### 🟦 Freenove Media Kit (RP2040) + +Un environnement PlatformIO dédié est disponible pour le boîtier Freenove Media Kit : + +- **Build** : `pio run -e ui_rp2040_freenove` +- **Flash** : `pio run -e ui_rp2040_freenove -t upload --upload-port ` +- **Monitor** : `pio device monitor -e ui_rp2040_freenove --port ` + +Le mapping hardware (pins, écran, boutons) est défini dans `ui/rp2040_tft/include/ui_freenove_config.h`. + +**Schéma de branchement** : voir `hardware/wiring/wiring.md` (section Freenove) + +**Remarque** : adaptez les defines dans `ui_freenove_config.h` selon votre version du Media Kit (écran, boutons, etc.). + + +## 📚 Documentation + +- **[État des lieux](docs/STATE_ANALYSIS.md)** - Analyse complète du firmware +- **[Recommandations Sprint](docs/SPRINT_RECOMMENDATIONS.md)** - Actions prioritaires +- **[Recovery WiFi/AP & Health](docs/WIFI_RECOVERY_AND_HEALTH.md)** - Procédure recovery AP, healthcheck, troubleshooting + +Pour débuter : [docs/QUICKSTART.md](docs/QUICKSTART.md) + + +## Structure des assets LittleFS (cross-plateforme) + +``` +hardware/firmware/data/ + story/ + scenarios/ + DEFAULT.json + apps/ + screens/ + audio/ + actions/ + audio/ + radio/ + net/ +``` + +**Scripts de génération et de flash : toujours pointer vers ce dossier.** + +--- + +## Build + +```sh +pio run +``` + +Build cible: + +```sh +pio run -e esp32dev +pio run -e esp32_release +pio run -e esp8266_oled +pio run -e ui_rp2040_ili9488 +pio run -e ui_rp2040_ili9486 +``` + +Script global: + +```sh +./build_all.sh +``` + +Bootstrap local tools once: + +```sh +./tools/dev/bootstrap_local.sh +``` + +## Story portable (generation + runtime) + +- Generation library: `lib/zacus_story_gen_ai/` (Yamale + Jinja2). +- Runtime library: `lib/zacus_story_portable/` (tinyfsm-style internals). +- Story serial protocol: JSON-lines V3 (`story.*`), see `docs/protocols/story_v3_serial.md`. +- Canonical migration doc: `docs/STORY_PORTABILITY_MIGRATION.md`. + +CLI: + +```sh +./tools/dev/story-gen validate +./tools/dev/story-gen generate-cpp +./tools/dev/story-gen generate-bundle +./tools/dev/story-gen all +``` + + +## Flash (cockpit) + +```sh +./tools/dev/cockpit.sh flash +``` + + +Options utiles: + +- `ZACUS_FLASH_ESP32_ENVS="esp32dev esp32_release"` +- `ZACUS_FLASH_RP2040_ENVS="ui_rp2040_ili9488 ui_rp2040_ili9486"` +- `ZACUS_PORT_ESP32=... ZACUS_PORT_ESP8266=... ZACUS_PORT_RP2040=...` + + +--- + +## Migration LittleFS (2026) + +- Déplacer tous les fichiers de scénario, écrans, scènes, audio, etc. dans `hardware/firmware/data/`. +- Adapter les scripts de génération et de flash pour pointer vers ce dossier. +- Supprimer les anciens dossiers `data/` dispersés dans les sous-projets (`ui/rp2040_tft/data/`, `esp32_audio/data/`, etc.). +- Mettre à jour tous les guides et onboarding pour refléter cette structure unique. + +--- + +```sh +make fast-esp32 ESP32_PORT= +make fast-ui-oled UI_OLED_PORT= +make fast-ui-tft UI_TFT_PORT= +``` + +Smoke série (manuel): + +```sh +python3 tools/dev/serial_smoke.py --role auto --baud 115200 --wait-port 3 --allow-no-hardware +``` + +MacOS CP2102 duplicates share VID/PID=10C4:EA60/0001; the LOCATION (20-6.1.1=ESP32, 20-6.1.2=ESP8266) drives the detector. `tools/dev/ports_map.json` now uses `location -> role` and `vidpid -> role` mappings. +USB console monitoring uses `115200`. ESP8266 internal UI link SoftwareSerial stays at `57600` (internal link only). + +## Serial smoke commands + +- baseline smoke (auto handles already connected boards): `python3 tools/dev/serial_smoke.py --role auto --baud 115200 --wait-port 3 --allow-no-hardware` +- run every detected role: `python3 tools/dev/serial_smoke.py --role all --baud 115200 --wait-port 3 --allow-no-hardware` +- force hardware detection: `ZACUS_REQUIRE_HW=1 python3 tools/dev/serial_smoke.py --role auto --baud 115200 --wait-port 180` +- skip PlatformIO builds and just run smoke (useful when downloads are impossible): `ZACUS_SKIP_PIO=1 ./tools/dev/run_matrix_and_smoke.sh` + +## Build + smoke combo + +```sh +./tools/dev/run_matrix_and_smoke.sh +# or from repo root: +./hw_now.sh +``` + +Cockpit shortcut: + +```sh +./tools/dev/cockpit.sh rc +``` + +`run_matrix_and_smoke.sh` ensures PlatformIO caches land under `$HOME/.platformio` (via `PLATFORMIO_CORE_DIR`) rather than inside the repo. +Before smoke it shows `⚠️ BRANCHE L’USB MAINTENANT ⚠️` three times, then waits for Enter while listing ports every 15s. +Each run writes deterministic artifacts under `artifacts/rc_live/_/` and logs under `logs/rc_live/_.log` (`summary.json`, `summary.md`, `ports_resolve.json`, `ui_link.log`, per-step logs). +The runner resolves macOS CP2102 by LOCATION (`20-6.1.1` ESP32, `20-6.1.2` ESP8266 USB), then enforces a dedicated `UI_LINK_STATUS connected=1` gate on ESP32. +When `ZACUS_ENV="freenove_esp32s3"` (single-board), ESP8266/UI-link/story-screen gates are marked `SKIP` with `not needed for combined board`. + +Environment overrides: + +- `ZACUS_REQUIRE_HW=1 ./tools/dev/run_matrix_and_smoke.sh` (fail when no hardware). +- `ZACUS_WAIT_PORT=3 ./tools/dev/run_matrix_and_smoke.sh` (override serial wait window for smoke). +- `ZACUS_NO_COUNTDOWN=1 ./tools/dev/run_matrix_and_smoke.sh` (skip the USB wait gate). +- `ZACUS_SKIP_SMOKE=1 ./tools/dev/run_matrix_and_smoke.sh` (build only, no serial smoke step). +- `ZACUS_ENV="esp32dev esp8266_oled" ./tools/dev/run_matrix_and_smoke.sh` (custom env subset). +- `ZACUS_ENV="freenove_esp32s3" ./tools/dev/run_matrix_and_smoke.sh` (single-board Freenove path). +- `ZACUS_FORCE_BUILD=1 ./tools/dev/run_matrix_and_smoke.sh` (force rebuild even when artifacts exist). + +By default the smoke step exits 0 when no serial hardware is present; use `ZACUS_REQUIRE_HW=1` to enforce detection. + +## Docs + +- Cablage ESP32/UI: `esp32_audio/WIRING.md` +- Cablage TFT: `ui/rp2040_tft/WIRING.md` +- Quickstart flash: `docs/QUICKSTART.md` +- RC board execution: `docs/RC_FINAL_BOARD.md` +- Protocole: `protocol/ui_link_v2.md` +- Cockpit command registry: `docs/_generated/COCKPIT_COMMANDS.md` + +## Codex prompts + +Prompt files live under `tools/dev/codex_prompts/*.prompt.md` and are designed to be consumed by the automation-friendly `codex exec` command. +Run `./tools/dev/codex_prompt_menu.sh` to see a numbered menu, pick a prompt, and send it to `codex exec --sandbox workspace-write --output-last-message artifacts/rc_live/_codex_last_message.md`. +You can also launch this helper directly from the firmware cockpit (`./tools/dev/cockpit.sh` option 6) so the existing workflow keeps a single entry point. + +Story authoring prompts live separately under `docs/protocols/story_specs/prompts/*.prompt.md`. They are not ops prompts, but can still be used with Codex tooling when needed. diff --git a/README_ESP32_ZACUS.md b/README_ESP32_ZACUS.md new file mode 100644 index 0000000..a023a01 --- /dev/null +++ b/README_ESP32_ZACUS.md @@ -0,0 +1,98 @@ +# ESP32_ZACUS – Freenove ESP32-S3 UI Firmware + +Branche de travail dédiée pour optimisations et refactoring de l'interface Freenove. + +**Status**: 2026-03-01 – Repo initialisé avec sources Freenove + story engine library. + +## Structure + +``` +ESP32_ZACUS/ +├── ui_freenove_allinone/ # Freenove UI app (UI rendering, scenes, story integration) +├── lib/ # Story engine + auxiliary libraries +├── platformio.ini # PlatformIO config (Freenove environment) +└── README.md # General Zacus firmware guide +``` + +## Workflow + +### Setup Local + +```bash +cd ESP32_ZACUS +python3 -m venv .venv +source .venv/bin/activate +pip install platformio esptool pyserial + +# List PlatformIO boards +pio boards | grep freenove +``` + +### Build & Upload Freenove + +```bash +pio run -e freenove_esp32s3_full_with_ui -t upload --upload-port /dev/cu.usbmodem5AB90753301 +``` + +### Serial Monitor (115200 baud) + +```bash +pio device monitor -p /dev/cu.usbmodem5AB90753301 -b 115200 +``` + +Or Python: + +```bash +python3 << 'PY' +import serial, time +port = '/dev/cu.usbmodem5AB90753301' +ser = serial.Serial(port, 115200, timeout=0.5) +time.sleep(0.2) +ser.write(b'STATUS\n') +time.sleep(0.3) +for _ in range(10): + line = ser.readline().decode('utf-8', 'ignore').strip() + if line: + print(line) +ser.close() +PY +``` + +## Future Work + +- **UI Optimization** (ui_freenove_allinone): + - Refactor LVGL rendering pipeline + - Scene/story integration improvements + - Touch input handling; audio trigger feedback + +- **Story Engine** (lib/story/): + - Performance audit + - Memory footprint reduction + - ESP-NOW sync protocol optimization + +- **Hardware Integration**: + - Audio codec I2S optimization + - PSRAM buffer management + - Camera/WiFi/Bluetooth coexistence + +## Git Push to GitHub + +Once repo created on GitHub: + +```bash +cd ESP32_ZACUS +git remote add origin https://github.com/YOUR_USER/ESP32_ZACUS.git +git branch -m master main +git push -u origin main +``` + +## References + +- [Freenove Board Docs](../hardware/docs/RC_FINAL_BOARD.md) +- [Story Engine Contract](../hardware/firmware/docs/ESP_NOW_API_CONTRACT_FREENOVE_V1.md) +- [Zacus Game Protocol](../hardware/firmware/docs/ARCHITECTURE_UML.md) + +--- + +**Last Updated**: 2026-03-01 +**Authored by**: Agent (Copilot) diff --git a/lib/zacus_story_gen_ai/README.md b/lib/zacus_story_gen_ai/README.md new file mode 100644 index 0000000..3495265 --- /dev/null +++ b/lib/zacus_story_gen_ai/README.md @@ -0,0 +1,15 @@ +# zacus_story_gen_ai + +Host-side Story generation library for firmware. + +## Commands + +- `story-gen validate` +- `story-gen generate-cpp` +- `story-gen generate-bundle` +- `story-gen all` + +Default inputs: + +- game scenarios: `game/scenarios/*.yaml` +- story specs: `hardware/firmware/docs/protocols/story_specs/scenarios/*.yaml` diff --git a/lib/zacus_story_gen_ai/pyproject.toml b/lib/zacus_story_gen_ai/pyproject.toml new file mode 100644 index 0000000..44c76c2 --- /dev/null +++ b/lib/zacus_story_gen_ai/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "zacus-story-gen-ai" +version = "0.1.0" +description = "Zacus Story generation library (Yamale + Jinja2)" +requires-python = ">=3.9" +dependencies = [ + "PyYAML>=6.0", + "yamale>=5.2.1", + "Jinja2>=3.1.0", +] + +[project.scripts] +story-gen = "zacus_story_gen_ai.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +zacus_story_gen_ai = ["templates/*.j2", "schemas/*.yamale"] diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/PKG-INFO b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/PKG-INFO new file mode 100644 index 0000000..f5d1477 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/PKG-INFO @@ -0,0 +1,8 @@ +Metadata-Version: 2.4 +Name: zacus-story-gen-ai +Version: 0.1.0 +Summary: Zacus Story generation library (Yamale + Jinja2) +Requires-Python: >=3.9 +Requires-Dist: PyYAML>=6.0 +Requires-Dist: yamale>=5.2.1 +Requires-Dist: Jinja2>=3.1.0 diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/SOURCES.txt b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/SOURCES.txt new file mode 100644 index 0000000..23e5020 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/SOURCES.txt @@ -0,0 +1,18 @@ +README.md +pyproject.toml +src/zacus_story_gen_ai/__init__.py +src/zacus_story_gen_ai/cli.py +src/zacus_story_gen_ai/generator.py +src/zacus_story_gen_ai.egg-info/PKG-INFO +src/zacus_story_gen_ai.egg-info/SOURCES.txt +src/zacus_story_gen_ai.egg-info/dependency_links.txt +src/zacus_story_gen_ai.egg-info/entry_points.txt +src/zacus_story_gen_ai.egg-info/requires.txt +src/zacus_story_gen_ai.egg-info/top_level.txt +src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale +src/zacus_story_gen_ai/schemas/story_spec_schema.yamale +src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 +src/zacus_story_gen_ai/templates/apps_gen.h.j2 +src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 +src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 +tests/test_generator.py \ No newline at end of file diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/dependency_links.txt b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/entry_points.txt b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/entry_points.txt new file mode 100644 index 0000000..48f4cdb --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +story-gen = zacus_story_gen_ai.cli:main diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/requires.txt b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/requires.txt new file mode 100644 index 0000000..c48bf81 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/requires.txt @@ -0,0 +1,3 @@ +PyYAML>=6.0 +yamale>=5.2.1 +Jinja2>=3.1.0 diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/top_level.txt b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/top_level.txt new file mode 100644 index 0000000..cf22d16 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai.egg-info/top_level.txt @@ -0,0 +1 @@ +zacus_story_gen_ai diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/__init__.py b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/__init__.py new file mode 100644 index 0000000..4b014fd --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/__init__.py @@ -0,0 +1,5 @@ +"""Story generation library (Yamale + Jinja2) for Zacus firmware.""" + +from .generator import StoryGenerationError + +__all__ = ["StoryGenerationError"] diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/cli.py b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/cli.py new file mode 100644 index 0000000..61c58fb --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/cli.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from .generator import ( + StoryGenerationError, + default_paths, + run_generate_bundle, + run_generate_cpp, + run_sync_screens, + run_validate, +) + + +def _path_or_none(value: str | None) -> Path | None: + if value is None or value == "": + return None + return Path(value) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Zacus story generator (Yamale + Jinja2)") + sub = parser.add_subparsers(dest="command", required=True) + + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--spec-dir", default="", help="Story spec directory") + common.add_argument("--game-dir", default="", help="Game scenario directory") + + validate_p = sub.add_parser("validate", parents=[common], help="Validate game + story specs") + + cpp_p = sub.add_parser("generate-cpp", parents=[common], help="Generate scenarios_gen/apps_gen C++") + cpp_p.add_argument("--out-dir", default="", help="Output directory for generated C++") + + generate_alias = sub.add_parser("generate", parents=[common], help="Alias of generate-cpp") + generate_alias.add_argument("--out-dir", default="", help="Output directory for generated C++") + + bundle_p = sub.add_parser("generate-bundle", parents=[common], help="Generate story bundle JSON+sha") + bundle_p.add_argument("--out-dir", default="", help="Bundle root directory") + bundle_p.add_argument("--archive", default="", help="Optional .tar.gz archive output") + + sync_screens_p = sub.add_parser( + "sync-screens", + parents=[common], + help="Synchronize data/story/screens and legacy_payloads/fs_excluded/screens from palette", + ) + sync_screens_p.add_argument( + "--check", + action="store_true", + help="Check drift only (no writes); non-zero exit on mismatch", + ) + + deploy_alias = sub.add_parser("deploy", parents=[common], help="Alias of generate-bundle") + deploy_alias.add_argument("--out-dir", default="", help="Bundle root directory") + deploy_alias.add_argument("--archive", default="", help="Optional .tar.gz archive output") + + all_p = sub.add_parser("all", parents=[common], help="Validate + generate-cpp + generate-bundle") + all_p.add_argument("--cpp-out-dir", default="", help="Output directory for generated C++") + all_p.add_argument("--bundle-out-dir", default="", help="Bundle root directory") + all_p.add_argument("--archive", default="", help="Optional .tar.gz archive output") + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + paths = default_paths() + spec_dir = _path_or_none(args.spec_dir) + game_dir = _path_or_none(args.game_dir) + + try: + if args.command == "validate": + result = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + print( + f"[story-gen] OK validate scenarios={result['scenario_count']} " + f"game_scenarios={result['game_scenario_count']}" + ) + return 0 + + if args.command in {"generate-cpp", "generate"}: + result = run_generate_cpp( + paths, + out_dir=_path_or_none(args.out_dir), + spec_dir=spec_dir, + game_dir=game_dir, + ) + print( + f"[story-gen] OK generate-cpp out={result['out_dir']} " + f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}" + ) + return 0 + + if args.command in {"generate-bundle", "deploy"}: + archive = _path_or_none(args.archive) + result = run_generate_bundle( + paths, + out_dir=_path_or_none(args.out_dir), + archive=archive, + spec_dir=spec_dir, + game_dir=game_dir, + ) + print( + f"[story-gen] OK generate-bundle out={result['out_dir']} " + f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}" + ) + if result["archive"] is not None: + print(f"[story-gen] archive={result['archive']}") + return 0 + + if args.command == "sync-screens": + result = run_sync_screens(paths, check_only=bool(args.check)) + mode = "check" if args.check else "write" + print( + f"[story-gen] OK sync-screens mode={mode} story={result['story_count']} " + f"story_written={result['story_written']} legacy_written={result['legacy_written']} " + f"palette={result['palette_path']} legacy_dir={result['legacy_dir']}" + ) + return 0 + + if args.command == "all": + validate = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + cpp = run_generate_cpp( + paths, + out_dir=_path_or_none(args.cpp_out_dir), + spec_dir=spec_dir, + game_dir=game_dir, + ) + bundle = run_generate_bundle( + paths, + out_dir=_path_or_none(args.bundle_out_dir), + archive=_path_or_none(args.archive), + spec_dir=spec_dir, + game_dir=game_dir, + ) + print( + "[story-gen] OK all " + f"validate={validate['scenario_count']} cpp={cpp['scenario_count']} " + f"bundle={bundle['scenario_count']} spec_hash={cpp['spec_hash']}" + ) + if bundle["archive"] is not None: + print(f"[story-gen] archive={bundle['archive']}") + return 0 + + parser.error(f"Unknown command: {args.command}") + return 2 + except StoryGenerationError as exc: + print(f"[story-gen] ERR {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/generator.py b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/generator.py new file mode 100644 index 0000000..72e26d2 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/generator.py @@ -0,0 +1,1954 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import tarfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + import yaml +except Exception: # pragma: no cover - dependency error surfaced at runtime + yaml = None + +try: + import yamale + from yamale.yamale_error import YamaleError +except Exception: # pragma: no cover - dependency error surfaced at runtime + yamale = None + YamaleError = Exception + +try: + from jinja2 import Environment, FileSystemLoader +except Exception: # pragma: no cover - dependency error surfaced at runtime + Environment = None + FileSystemLoader = None + + +ALLOWED_TRIGGER = {"on_event", "after_ms", "immediate"} +ALLOWED_EVENT = {"none", "unlock", "audio_done", "timer", "serial", "button", "espnow", "action"} +ALLOWED_APP = { + "LA_DETECTOR", + "AUDIO_PACK", + "SCREEN_SCENE", + "MP3_GATE", + "WIFI_STACK", + "ESPNOW_STACK", + "QR_UNLOCK_APP", +} + +EVENT_CPP = { + "none": "StoryEventType::kNone", + "unlock": "StoryEventType::kUnlock", + "audio_done": "StoryEventType::kAudioDone", + "timer": "StoryEventType::kTimer", + "serial": "StoryEventType::kSerial", + "button": "StoryEventType::kButton", + "espnow": "StoryEventType::kEspNow", + "action": "StoryEventType::kAction", +} +TRIGGER_CPP = { + "on_event": "StoryTransitionTrigger::kOnEvent", + "after_ms": "StoryTransitionTrigger::kAfterMs", + "immediate": "StoryTransitionTrigger::kImmediate", +} +APP_CPP = { + "LA_DETECTOR": "StoryAppType::kLaDetector", + "AUDIO_PACK": "StoryAppType::kAudioPack", + "SCREEN_SCENE": "StoryAppType::kScreenScene", + "MP3_GATE": "StoryAppType::kMp3Gate", + "WIFI_STACK": "StoryAppType::kWifiStack", + "ESPNOW_STACK": "StoryAppType::kEspNowStack", + "QR_UNLOCK_APP": "StoryAppType::kQrUnlockApp", +} + +ACTION_FILE_ALIASES = { + # Keep long action IDs in YAML/runtime while allowing short LittleFS filenames. + "ACTION_QR_CODE_SCANNER_START": "ACTION_QR_SCAN_START", + "ACTION_SET_BOOT_MEDIA_MANAGER": "ACTION_BOOT_MEDIA_MGR", +} + +DEFAULT_SCENE_PROFILE: dict[str, Any] = { + "title": "MISSION", + "subtitle": "", + "symbol": "RUN", + "effect": "pulse", + "effect_speed_ms": 680, + "theme": {"bg": "#07132A", "accent": "#2A76FF", "text": "#E8F1FF"}, + "transition": {"effect": "fade", "duration_ms": 240}, + "timeline": [ + { + "at_ms": 0, + "effect": "pulse", + "speed_ms": 680, + "theme": {"bg": "#07132A", "accent": "#2A76FF", "text": "#E8F1FF"}, + }, + { + "at_ms": 1400, + "effect": "blink", + "speed_ms": 360, + "theme": {"bg": "#0B1F3C", "accent": "#5A99FF", "text": "#F1F7FF"}, + }, + ], +} + +SCENE_PROFILES: dict[str, dict[str, Any]] = { + "SCENE_LOCKED": { + "title": "VERROUILLE", + "subtitle": "Attente de debloquage", + "symbol": "LOCK", + "effect": "pulse", + "effect_speed_ms": 680, + "theme": {"bg": "#08152D", "accent": "#3E8DFF", "text": "#F5F8FF"}, + "transition": {"effect": "fade", "duration_ms": 260}, + "timeline": [ + { + "at_ms": 0, + "effect": "pulse", + "speed_ms": 680, + "theme": {"bg": "#08152D", "accent": "#3E8DFF", "text": "#F5F8FF"}, + }, + { + "at_ms": 1400, + "effect": "blink", + "speed_ms": 420, + "theme": {"bg": "#0A1E3A", "accent": "#74B0FF", "text": "#F8FBFF"}, + }, + ], + }, + "SCENE_BROKEN": { + "title": "PROTO U-SON", + "subtitle": "Signal brouille", + "symbol": "ALERT", + "effect": "blink", + "effect_speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + "transition": {"effect": "glitch", "duration_ms": 160}, + "timeline": [ + { + "at_ms": 0, + "effect": "blink", + "speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + }, + { + "at_ms": 760, + "effect": "blink", + "speed_ms": 140, + "theme": {"bg": "#33070C", "accent": "#FF7A75", "text": "#FFF6F5"}, + }, + ], + }, + "SCENE_LA_DETECTOR": { + "title": "DETECTION", + "subtitle": "Balayage en cours", + "symbol": "SCAN", + "effect": "scan", + "effect_speed_ms": 960, + "theme": {"bg": "#041F1B", "accent": "#2CE5A6", "text": "#EFFFF8"}, + "transition": {"effect": "slide_up", "duration_ms": 220}, + "timeline": [ + { + "at_ms": 0, + "effect": "scan", + "speed_ms": 960, + "theme": {"bg": "#041F1B", "accent": "#2CE5A6", "text": "#EFFFF8"}, + }, + { + "at_ms": 1500, + "effect": "pulse", + "speed_ms": 620, + "theme": {"bg": "#062923", "accent": "#63F0C3", "text": "#F3FFFB"}, + }, + ], + }, + "SCENE_SEARCH": { + "title": "RECHERCHE", + "subtitle": "Analyse des indices", + "symbol": "SCAN", + "effect": "scan", + "effect_speed_ms": 920, + "theme": {"bg": "#05261F", "accent": "#35E7B0", "text": "#EFFFF8"}, + "transition": {"effect": "glitch", "duration_ms": 230}, + "timeline": [ + { + "at_ms": 0, + "effect": "scan", + "speed_ms": 920, + "theme": {"bg": "#05261F", "accent": "#35E7B0", "text": "#EFFFF8"}, + }, + { + "at_ms": 1600, + "effect": "wave", + "speed_ms": 520, + "theme": {"bg": "#07322A", "accent": "#67F0C4", "text": "#F2FFF9"}, + }, + { + "at_ms": 3000, + "effect": "scan", + "speed_ms": 820, + "theme": {"bg": "#05261F", "accent": "#35E7B0", "text": "#EFFFF8"}, + }, + ], + }, + "SCENE_CAMERA_SCAN": { + "title": "ZACUS QR VALIDATION", + "subtitle": "Scanne le QR final", + "symbol": "QR", + "effect": "none", + "effect_speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + "transition": {"effect": "fade", "duration_ms": 180}, + "timeline": [ + { + "at_ms": 0, + "effect": "none", + "speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + }, + ], + }, + "SCENE_MEDIA_MANAGER": { + "title": "MEDIA MANAGER", + "subtitle": "PHOTO / MP3 / STORY", + "symbol": "READY", + "effect": "radar", + "effect_speed_ms": 620, + "theme": {"bg": "#081A34", "accent": "#8BC4FF", "text": "#EAF6FF"}, + "transition": {"effect": "fade", "duration_ms": 220}, + "timeline": [ + { + "at_ms": 0, + "effect": "radar", + "speed_ms": 620, + "theme": {"bg": "#081A34", "accent": "#8BC4FF", "text": "#EAF6FF"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 520, + "theme": {"bg": "#0C2448", "accent": "#A6D4FF", "text": "#F5FAFF"}, + }, + ], + }, + "SCENE_PHOTO_MANAGER": { + "title": "PHOTO MANAGER", + "subtitle": "Capture JPEG", + "symbol": "SCAN", + "effect": "none", + "effect_speed_ms": 0, + "theme": {"bg": "#0B1A2E", "accent": "#86CCFF", "text": "#EEF6FF"}, + "transition": {"effect": "fade", "duration_ms": 200}, + "timeline": [ + { + "at_ms": 0, + "effect": "none", + "speed_ms": 0, + "theme": {"bg": "#0B1A2E", "accent": "#86CCFF", "text": "#EEF6FF"}, + }, + ], + }, + "SCENE_SIGNAL_SPIKE": { + "title": "PIC DE SIGNAL", + "subtitle": "Interference soudaine detectee", + "symbol": "ALERT", + "effect": "wave", + "effect_speed_ms": 260, + "theme": {"bg": "#24090C", "accent": "#FF6A52", "text": "#FFF2EB"}, + "transition": {"effect": "glitch", "duration_ms": 170}, + "timeline": [ + { + "at_ms": 0, + "effect": "wave", + "speed_ms": 260, + "theme": {"bg": "#24090C", "accent": "#FF6A52", "text": "#FFF2EB"}, + }, + { + "at_ms": 700, + "effect": "blink", + "speed_ms": 180, + "theme": {"bg": "#2F1014", "accent": "#FF8C73", "text": "#FFF8F5"}, + }, + { + "at_ms": 1400, + "effect": "wave", + "speed_ms": 320, + "theme": {"bg": "#24090C", "accent": "#FF6A52", "text": "#FFF2EB"}, + }, + ], + }, + "SCENE_MEDIA_ARCHIVE": { + "title": "ARCHIVES MEDIA", + "subtitle": "Photos et enregistrements sauvegardes", + "symbol": "READY", + "effect": "radar", + "effect_speed_ms": 760, + "theme": {"bg": "#0D1A34", "accent": "#7CB1FF", "text": "#EEF4FF"}, + "transition": {"effect": "fade", "duration_ms": 240}, + "timeline": [ + { + "at_ms": 0, + "effect": "radar", + "speed_ms": 760, + "theme": {"bg": "#0D1A34", "accent": "#7CB1FF", "text": "#EEF4FF"}, + }, + { + "at_ms": 1000, + "effect": "pulse", + "speed_ms": 620, + "theme": {"bg": "#132245", "accent": "#9CC7FF", "text": "#F7FAFF"}, + }, + { + "at_ms": 2000, + "effect": "radar", + "speed_ms": 760, + "theme": {"bg": "#0D1A34", "accent": "#7CB1FF", "text": "#EEF4FF"}, + }, + ], + }, + "SCENE_WIN": { + "title": "VICTOIRE", + "subtitle": "Etape validee", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + }, + { + "at_ms": 1000, + "effect": "blink", + "speed_ms": 240, + "theme": {"bg": "#341A4D", "accent": "#FFE083", "text": "#FFFDF3"}, + }, + ], + }, + "SCENE_WIN_ETAPE": { + "title": "VICTOIRE", + "subtitle": "Etape validee", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 420, + "theme": {"bg": "#231038", "accent": "#F4CB4A", "text": "#FFF8E2"}, + }, + { + "at_ms": 1000, + "effect": "blink", + "speed_ms": 240, + "theme": {"bg": "#341A4D", "accent": "#FFE083", "text": "#FFFDF3"}, + }, + ], + }, + "SCENE_REWARD": { + "title": "RECOMPENSE", + "subtitle": "Indice debloque", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 420, + "theme": {"bg": "#2A103E", "accent": "#F9D860", "text": "#FFF9E6"}, + "transition": {"effect": "zoom", "duration_ms": 300}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 420, + "theme": {"bg": "#2A103E", "accent": "#F9D860", "text": "#FFF9E6"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 280, + "theme": {"bg": "#3E1A52", "accent": "#FFD97D", "text": "#FFFDF2"}, + }, + ], + }, + "SCENE_READY": { + "title": "PRET", + "subtitle": "Scenario termine", + "symbol": "READY", + "effect": "wave", + "effect_speed_ms": 560, + "theme": {"bg": "#0F2A12", "accent": "#6CD96B", "text": "#EDFFED"}, + "transition": {"effect": "fade", "duration_ms": 220}, + "timeline": [ + { + "at_ms": 0, + "effect": "wave", + "speed_ms": 560, + "theme": {"bg": "#0F2A12", "accent": "#6CD96B", "text": "#EDFFED"}, + }, + { + "at_ms": 1500, + "effect": "radar", + "speed_ms": 740, + "theme": {"bg": "#133517", "accent": "#9EE49D", "text": "#F4FFF4"}, + }, + ], + }, + "SCENE_U_SON_PROTO": { + "title": "PROTO U-SON", + "subtitle": "Signal brouille", + "symbol": "ALERT", + "effect": "blink", + "effect_speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + "transition": {"effect": "glitch", "duration_ms": 160}, + "timeline": [ + { + "at_ms": 0, + "effect": "blink", + "speed_ms": 180, + "theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"}, + }, + { + "at_ms": 900, + "effect": "scan", + "speed_ms": 520, + "theme": {"bg": "#3A0A10", "accent": "#FF7873", "text": "#FFF7F7"}, + }, + ], + }, + "SCENE_WARNING": { + "title": "ALERTE", + "subtitle": "Signal anormal", + "symbol": "WARN", + "effect": "blink", + "effect_speed_ms": 240, + "theme": {"bg": "#261209", "accent": "#FF9A4A", "text": "#FFF2E6"}, + "transition": {"effect": "fade", "duration_ms": 200}, + "timeline": [ + { + "at_ms": 0, + "effect": "blink", + "speed_ms": 240, + "theme": {"bg": "#261209", "accent": "#FF9A4A", "text": "#FFF2E6"}, + }, + { + "at_ms": 1400, + "effect": "pulse", + "speed_ms": 520, + "theme": {"bg": "#31170C", "accent": "#FFC071", "text": "#FFF8EF"}, + }, + ], + }, + "SCENE_LEFOU_DETECTOR": { + "title": "DETECTEUR LEFOU", + "subtitle": "Analyse en cours", + "symbol": "AUDIO", + "effect": "wave", + "effect_speed_ms": 460, + "theme": {"bg": "#071B1A", "accent": "#46E6C8", "text": "#E9FFF9"}, + "transition": {"effect": "zoom", "duration_ms": 250}, + "timeline": [ + { + "at_ms": 0, + "effect": "wave", + "speed_ms": 460, + "theme": {"bg": "#071B1A", "accent": "#46E6C8", "text": "#E9FFF9"}, + }, + { + "at_ms": 1200, + "effect": "radar", + "speed_ms": 620, + "theme": {"bg": "#0A2523", "accent": "#7FF2DA", "text": "#F2FFFC"}, + }, + ], + }, + "SCENE_WIN_ETAPE1": { + "title": "WIN ETAPE 1", + "subtitle": "Validation distante", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 360, + "theme": {"bg": "#1E0F32", "accent": "#F5C64A", "text": "#FFF8E4"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 360, + "theme": {"bg": "#1E0F32", "accent": "#F5C64A", "text": "#FFF8E4"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 260, + "theme": {"bg": "#2A1645", "accent": "#FFD97A", "text": "#FFFDF3"}, + }, + ], + }, + "SCENE_WIN_ETAPE2": { + "title": "WIN ETAPE 2", + "subtitle": "ACK en attente", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 340, + "theme": {"bg": "#220F3A", "accent": "#FFCE62", "text": "#FFF8EA"}, + "transition": {"effect": "zoom", "duration_ms": 280}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 340, + "theme": {"bg": "#220F3A", "accent": "#FFCE62", "text": "#FFF8EA"}, + }, + { + "at_ms": 1200, + "effect": "pulse", + "speed_ms": 260, + "theme": {"bg": "#2E1850", "accent": "#FFE18E", "text": "#FFFDF5"}, + }, + ], + }, + "SCENE_QR_DETECTOR": { + "title": "ZACUS QR VALIDATION", + "subtitle": "Scan du QR final", + "symbol": "QR", + "effect": "none", + "effect_speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + "transition": {"effect": "fade", "duration_ms": 180}, + "timeline": [ + { + "at_ms": 0, + "effect": "none", + "speed_ms": 0, + "theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"}, + }, + { + "at_ms": 1600, + "effect": "pulse", + "speed_ms": 520, + "theme": {"bg": "#142A52", "accent": "#8EC1FF", "text": "#FCFEFF"}, + }, + ], + }, + "SCENE_FINAL_WIN": { + "title": "FINAL WIN", + "subtitle": "Mission accomplie", + "symbol": "WIN", + "effect": "celebrate", + "effect_speed_ms": 320, + "theme": {"bg": "#1C0C2E", "accent": "#FFCC5C", "text": "#FFF7E4"}, + "transition": {"effect": "fade", "duration_ms": 240}, + "timeline": [ + { + "at_ms": 0, + "effect": "celebrate", + "speed_ms": 320, + "theme": {"bg": "#1C0C2E", "accent": "#FFCC5C", "text": "#FFF7E4"}, + }, + { + "at_ms": 1400, + "effect": "blink", + "speed_ms": 220, + "theme": {"bg": "#2A1642", "accent": "#FFE18D", "text": "#FFFDF3"}, + }, + ], + }, +} + +SCENE_ALIASES: dict[str, str] = { + "SCENE_LA_DETECT": "SCENE_LA_DETECTOR", + "SCENE_U_SON": "SCENE_U_SON_PROTO", + "SCENE_LE_FOU_DETECTOR": "SCENE_LEFOU_DETECTOR", +} + +CANONICAL_SCREEN_SCENE_IDS: tuple[str, ...] = ( + "SCENE_LOCKED", + "SCENE_BROKEN", + "SCENE_U_SON_PROTO", + "SCENE_SEARCH", + "SCENE_LA_DETECTOR", + "SCENE_LEFOU_DETECTOR", + "SCENE_WARNING", + "SCENE_CAMERA_SCAN", + "SCENE_QR_DETECTOR", + "SCENE_SIGNAL_SPIKE", + "SCENE_REWARD", + "SCENE_WIN_ETAPE1", + "SCENE_WIN_ETAPE2", + "SCENE_FINAL_WIN", + "SCENE_MEDIA_ARCHIVE", + "SCENE_READY", + "SCENE_WIN", + "SCENE_WINNER", + "SCENE_FIREWORKS", + "SCENE_WIN_ETAPE", + "SCENE_MP3_PLAYER", + "SCENE_MEDIA_MANAGER", + "SCENE_PHOTO_MANAGER", +) + +LEGACY_SCREEN_ALIASES: dict[str, str] = { + "SCENE_LA_DETECT": "SCENE_LA_DETECTOR", + "SCENE_U_SON": "SCENE_U_SON_PROTO", + "SCENE_LE_FOU_DETECTOR": "SCENE_LEFOU_DETECTOR", + "SCENE_LOCK": "SCENE_LOCKED", + "LOCKED": "SCENE_LOCKED", + "LOCK": "SCENE_LOCKED", + "SCENE_AUDIO_PLAYER": "SCENE_MP3_PLAYER", + "SCENE_MP3": "SCENE_MP3_PLAYER", +} + +_ACTIVE_SCENE_PROFILES: dict[str, dict[str, Any]] | None = None + + +def _canonical_scene_id(scene_id: str) -> str: + return SCENE_ALIASES.get(scene_id, scene_id) + + +def _scene_slug(scene_id: str) -> str: + slug = scene_id + if slug.startswith("SCENE_"): + slug = slug[6:] + return slug.lower() + +DEFAULT_TEXT_OPTIONS: dict[str, Any] = { + "show_title": False, + "show_subtitle": True, + "show_symbol": True, + "title_case": "upper", + "subtitle_case": "raw", + "title_align": "top", + "subtitle_align": "bottom", +} + +DEFAULT_FRAMING_OPTIONS: dict[str, Any] = { + "preset": "center", + "x_offset": 0, + "y_offset": 0, + "scale_pct": 100, +} + +DEFAULT_SCROLL_OPTIONS: dict[str, Any] = { + "mode": "none", + "speed_ms": 4200, + "pause_ms": 900, + "loop": True, +} + +DEFAULT_DEMO_OPTIONS: dict[str, Any] = { + "mode": "standard", + "particle_count": 4, + "strobe_level": 65, +} + +SCREEN_EFFECT_CHOICES = {"none", "pulse", "scan", "radar", "wave", "blink", "celebrate"} +SCREEN_EFFECT_ALIASES = { + "steady": "none", + "glitch": "blink", + "reward": "celebrate", + "sonar": "radar", +} +TRANSITION_EFFECT_CHOICES = {"none", "fade", "slide_left", "slide_right", "slide_up", "slide_down", "zoom", "glitch"} +TRANSITION_EFFECT_ALIASES = { + "crossfade": "fade", + "left": "slide_left", + "right": "slide_right", + "up": "slide_up", + "down": "slide_down", + "zoom_in": "zoom", + "flash": "glitch", + "wipe": "slide_left", + "camera_flash": "glitch", +} +TEXT_CASE_CHOICES = {"raw", "upper", "lower"} +TEXT_ALIGN_CHOICES = {"top", "center", "bottom"} +FRAMING_PRESET_CHOICES = {"center", "focus_top", "focus_bottom", "split"} +SCROLL_MODE_CHOICES = {"none", "marquee"} +SCROLL_MODE_ALIASES = {"ticker": "marquee", "crawl": "marquee"} +DEMO_MODE_CHOICES = {"standard", "cinematic", "arcade"} + + +class StoryGenerationError(RuntimeError): + """Raised when validation or generation fails.""" + + +@dataclass +class StoryPaths: + fw_root: Path + repo_root: Path + game_scenarios_dir: Path + story_specs_dir: Path + story_data_dir: Path + generated_cpp_dir: Path + bundle_root: Path + + +@dataclass +class ValidationIssue: + file: str + field: str + reason: str + + def format(self) -> str: + return f"{self.file}: {self.field}: {self.reason}" + + +def _require_deps() -> None: + missing = [] + if yaml is None: + missing.append("PyYAML") + if yamale is None: + missing.append("yamale") + if Environment is None or FileSystemLoader is None: + missing.append("Jinja2") + if missing: + joined = ", ".join(missing) + raise StoryGenerationError( + f"Missing dependencies: {joined}. Install with: pip install pyyaml yamale Jinja2" + ) + + +def find_fw_root() -> Path: + start = Path(__file__).resolve() + for candidate in [start, *start.parents]: + if (candidate / "platformio.ini").exists(): + return candidate + raise StoryGenerationError("Cannot resolve firmware root (platformio.ini not found)") + + +def default_paths() -> StoryPaths: + fw_root = find_fw_root() + repo_root = fw_root.parents[1] + return StoryPaths( + fw_root=fw_root, + repo_root=repo_root, + game_scenarios_dir=repo_root / "game" / "scenarios", + story_specs_dir=fw_root / "docs" / "protocols" / "story_specs" / "scenarios", + story_data_dir=fw_root / "data" / "story", + generated_cpp_dir=fw_root / "hardware" / "libs" / "story" / "src" / "generated", + bundle_root=fw_root / "artifacts" / "story_fs" / "deploy", + ) + + +def _schema_path(name: str) -> Path: + return Path(__file__).resolve().parent / "schemas" / name + + +def _template_dir() -> Path: + return Path(__file__).resolve().parent / "templates" + + +def _list_yaml_files(path: Path) -> list[Path]: + if path.is_file() and path.suffix.lower() in {".yml", ".yaml"}: + return [path] + if not path.exists(): + return [] + return sorted([p for p in path.glob("*.y*ml") if p.is_file()]) + + +def _validate_yamale(schema_path: Path, files: list[Path]) -> None: + if not files: + raise StoryGenerationError(f"No YAML files found for schema: {schema_path.name}") + schema = yamale.make_schema(str(schema_path)) + errors: list[str] = [] + for file_path in files: + data = yamale.make_data(str(file_path)) + try: + yamale.validate(schema, data, strict=True) + except YamaleError as exc: + for result in exc.results: + for msg in result.errors: + errors.append(f"{file_path}: {msg}") + if errors: + raise StoryGenerationError("Yamale validation failed:\n" + "\n".join(errors)) + + +def _load_yaml(path: Path) -> dict[str, Any]: + obj = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(obj, dict): + raise StoryGenerationError(f"YAML root must be a mapping: {path}") + return obj + + +def _active_scene_profiles() -> dict[str, dict[str, Any]]: + return _ACTIVE_SCENE_PROFILES if _ACTIVE_SCENE_PROFILES is not None else SCENE_PROFILES + + +def _build_default_scene_profiles() -> dict[str, dict[str, Any]]: + profiles: dict[str, dict[str, Any]] = {} + for scene_id in CANONICAL_SCREEN_SCENE_IDS: + base = SCENE_PROFILES.get(scene_id) + if base is None: + fallback = copy.deepcopy(DEFAULT_SCENE_PROFILE) + fallback["title"] = scene_id.replace("SCENE_", "").replace("_", " ") + fallback["subtitle"] = "" + fallback["symbol"] = "RUN" + fallback["effect"] = "pulse" + base = fallback + profiles[scene_id] = copy.deepcopy(base) + return profiles + + +def _deep_merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = _deep_merge_dict(dict(base[key]), value) + else: + base[key] = copy.deepcopy(value) + return base + + +def _palette_path(paths: StoryPaths) -> Path: + return paths.story_data_dir / "palette" / "screens_palette_v3.yaml" + + +def _load_scene_profiles(paths: StoryPaths) -> dict[str, dict[str, Any]]: + profiles = _build_default_scene_profiles() + palette_path = _palette_path(paths) + if not palette_path.exists(): + return profiles + + payload = _load_yaml(palette_path) + scenes_payload = payload.get("scenes") + if not isinstance(scenes_payload, dict): + raise StoryGenerationError(f"Invalid palette file {palette_path}: missing mapping 'scenes'") + + missing: list[str] = [] + for scene_id in CANONICAL_SCREEN_SCENE_IDS: + override = scenes_payload.get(scene_id) + if not isinstance(override, dict): + missing.append(scene_id) + continue + profiles[scene_id] = _deep_merge_dict(copy.deepcopy(profiles[scene_id]), override) + + if missing: + raise StoryGenerationError( + f"Palette {palette_path} missing canonical scene profiles: {', '.join(sorted(missing))}" + ) + + for scene_id, override in scenes_payload.items(): + if not isinstance(scene_id, str) or not isinstance(override, dict): + continue + if scene_id in profiles: + continue + fallback = _build_default_scene_profiles().get(scene_id, copy.deepcopy(DEFAULT_SCENE_PROFILE)) + profiles[scene_id] = _deep_merge_dict(copy.deepcopy(fallback), override) + + return profiles + + +def _activate_scene_profiles(paths: StoryPaths) -> dict[str, dict[str, Any]]: + global _ACTIVE_SCENE_PROFILES + _ACTIVE_SCENE_PROFILES = _load_scene_profiles(paths) + return _ACTIVE_SCENE_PROFILES + + +def _normalize_scene_id(value: Any, issues: list[ValidationIssue], source: str) -> str: + scene_id = str(value).strip() if isinstance(value, str) else "" + if not scene_id: + return "" + scene_id = _canonical_scene_id(scene_id) + if scene_id not in _active_scene_profiles(): + issues.append(ValidationIssue(source, "screen_scene_id", f"unknown scene id '{scene_id}'")) + return "" + return scene_id + + +def _normalize_story_specs(files: list[Path]) -> list[dict[str, Any]]: + issues: list[ValidationIssue] = [] + scenarios: list[dict[str, Any]] = [] + ids: set[str] = set() + + for file_path in files: + raw = _load_yaml(file_path) + sid = str(raw.get("id", "")).strip() + if not sid: + issues.append(ValidationIssue(str(file_path), "id", "missing id")) + continue + if sid in ids: + issues.append(ValidationIssue(str(file_path), "id", f"duplicate id '{sid}'")) + continue + ids.add(sid) + + version = raw.get("version", 0) + if not isinstance(version, int): + issues.append(ValidationIssue(str(file_path), "version", "must be int")) + continue + + initial_step = str(raw.get("initial_step", "")).strip() + bindings_raw = raw.get("app_bindings", []) + steps_raw = raw.get("steps", []) + if not isinstance(bindings_raw, list) or not isinstance(steps_raw, list): + issues.append(ValidationIssue(str(file_path), "root", "app_bindings and steps must be lists")) + continue + + bindings: list[dict[str, Any]] = [] + binding_ids: set[str] = set() + for idx, binding in enumerate(bindings_raw): + if not isinstance(binding, dict): + issues.append(ValidationIssue(str(file_path), f"app_bindings[{idx}]", "must be mapping")) + continue + bid = str(binding.get("id", "")).strip() + app = str(binding.get("app", "")).strip() + if not bid: + issues.append(ValidationIssue(str(file_path), f"app_bindings[{idx}].id", "missing id")) + continue + if app not in ALLOWED_APP: + issues.append( + ValidationIssue(str(file_path), f"app_bindings[{idx}].app", f"invalid app '{app}'") + ) + continue + if bid in binding_ids: + issues.append(ValidationIssue(str(file_path), f"app_bindings[{idx}].id", f"duplicate '{bid}'")) + continue + binding_ids.add(bid) + + config = binding.get("config") if isinstance(binding.get("config"), dict) else None + if app == "LA_DETECTOR": + hold_ms = 3000 + unlock_event = "UNLOCK" + require_listening = True + if config is not None: + hold_raw = config.get("hold_ms", hold_ms) + if isinstance(hold_raw, int): + hold_ms = hold_raw + unlock_raw = config.get("unlock_event", unlock_event) + if isinstance(unlock_raw, str) and unlock_raw.strip(): + unlock_event = unlock_raw.strip() + listen_raw = config.get("require_listening", require_listening) + if isinstance(listen_raw, bool): + require_listening = listen_raw + config = { + "hold_ms": hold_ms, + "unlock_event": unlock_event, + "require_listening": require_listening, + } + else: + config = None + + bindings.append({"id": bid, "app": app, "config": config}) + + steps: list[dict[str, Any]] = [] + step_ids: set[str] = set() + for sidx, step in enumerate(steps_raw): + if not isinstance(step, dict): + issues.append(ValidationIssue(str(file_path), f"steps[{sidx}]", "must be mapping")) + continue + step_id = str(step.get("step_id", "")).strip() + if not step_id: + issues.append(ValidationIssue(str(file_path), f"steps[{sidx}].step_id", "missing step_id")) + continue + if step_id in step_ids: + issues.append(ValidationIssue(str(file_path), f"steps[{sidx}].step_id", f"duplicate '{step_id}'")) + continue + step_ids.add(step_id) + + apps = [str(v).strip() for v in (step.get("apps") or []) if str(v).strip()] + for app_id in apps: + if app_id not in binding_ids: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].apps", + f"unknown app binding '{app_id}'", + ) + ) + + transitions_norm: list[dict[str, Any]] = [] + for tidx, transition in enumerate(step.get("transitions") or []): + if not isinstance(transition, dict): + issues.append( + ValidationIssue(str(file_path), f"steps[{sidx}].transitions[{tidx}]", "must be mapping") + ) + continue + trigger = str(transition.get("trigger", "on_event")).strip().lower() + event_type = str(transition.get("event_type", "none")).strip().lower() + if event_type == "esp_now": + event_type = "espnow" + event_name = str(transition.get("event_name", "")).strip() + target_step_id = str(transition.get("target_step_id", "")).strip() + after_ms = transition.get("after_ms", 0) + priority = transition.get("priority", 0) + debug_only = bool(transition.get("debug_only", False)) + tr_id = str(transition.get("id", "")).strip() + if not tr_id: + tr_id = f"TR_{step_id}_{tidx + 1}" + if trigger not in ALLOWED_TRIGGER: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].trigger", + f"invalid trigger '{trigger}'", + ) + ) + if event_type not in ALLOWED_EVENT: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].event_type", + f"invalid event_type '{event_type}'", + ) + ) + if not isinstance(after_ms, int): + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].after_ms", + "must be int", + ) + ) + after_ms = 0 + if not isinstance(priority, int): + issues.append( + ValidationIssue( + str(file_path), + f"steps[{sidx}].transitions[{tidx}].priority", + "must be int", + ) + ) + priority = 0 + + transitions_norm.append( + { + "id": tr_id, + "trigger": trigger, + "event_type": event_type, + "event_name": event_name, + "target_step_id": target_step_id, + "after_ms": after_ms, + "priority": priority, + "debug_only": debug_only, + } + ) + + raw_scene_id = str(step.get("screen_scene_id", "")).strip() + normalized_scene_id = _normalize_scene_id(raw_scene_id, issues, str(file_path)) + actions = [str(v).strip() for v in (step.get("actions") or []) if str(v).strip()] + steps.append( + { + "step_id": step_id, + "screen_scene_id": normalized_scene_id, + "audio_pack_id": str(step.get("audio_pack_id", "")).strip(), + "actions": actions, + "apps": apps, + "mp3_gate_open": bool(step.get("mp3_gate_open", False)), + "transitions": transitions_norm, + } + ) + + if initial_step and initial_step not in step_ids: + issues.append(ValidationIssue(str(file_path), "initial_step", f"unknown step '{initial_step}'")) + + for step in steps: + for transition in step["transitions"]: + if transition["target_step_id"] and transition["target_step_id"] not in step_ids: + issues.append( + ValidationIssue( + str(file_path), + f"steps[{step['step_id']}].transitions[{transition['id']}].target_step_id", + f"unknown target '{transition['target_step_id']}'", + ) + ) + + scenarios.append( + { + "id": sid, + "version": version, + "estimated_duration_s": int(raw.get("estimated_duration_s", 0) or 0), + "debug_transition_bypass_enabled": bool(raw.get("debug_transition_bypass_enabled", False)), + "initial_step": initial_step, + "app_bindings": bindings, + "steps": steps, + "source": str(file_path), + } + ) + + if issues: + formatted = "\n".join(issue.format() for issue in issues) + raise StoryGenerationError("Story semantic validation failed:\n" + formatted) + + scenarios.sort(key=lambda item: item["id"]) + return scenarios + + +def _validate_game_scenarios(game_scenario_files: list[Path]) -> list[str]: + ids: list[str] = [] + for file_path in game_scenario_files: + doc = _load_yaml(file_path) + sid = str(doc.get("id", "")).strip() + if sid: + ids.append(sid) + return sorted(ids) + + +def _classify_game_yaml(file_path: Path) -> str: + doc = _load_yaml(file_path) + has_runtime = bool(doc.get("initial_step")) and isinstance(doc.get("steps"), list) + has_narrative = bool(doc.get("title")) and isinstance(doc.get("stations"), list) + has_template = isinstance(doc.get("prompt_input"), dict) + has_workbench = isinstance(doc.get("meta"), dict) and isinstance(doc.get("scenes"), list) + if has_workbench and not has_runtime and not has_narrative and not has_template: + return "workbench" + if has_workbench and (has_runtime or has_narrative or has_template): + raise StoryGenerationError( + f"Ambiguous game scenario type in {file_path}: workbench mixed with runtime/narrative/template keys detected" + ) + if has_template and not has_runtime and not has_narrative: + return "template" + if has_runtime and has_narrative: + raise StoryGenerationError(f"Ambiguous game scenario type in {file_path}: both runtime and narrative keys detected") + if has_runtime: + return "runtime" + if has_narrative: + return "narrative" + raise StoryGenerationError(f"Unknown game scenario format in {file_path}: cannot detect runtime/narrative/template") + + +def _canonical_yaml_payload(path: Path) -> str: + data = _load_yaml(path) + return json.dumps(data, sort_keys=True, separators=(",", ":")) + + +def _validate_runtime_mirror(spec_files: list[Path], runtime_game_files: list[Path]) -> None: + spec_by_id: dict[str, Path] = {} + for spec_file in spec_files: + scenario_id = str(_load_yaml(spec_file).get("id", "")).strip() + if scenario_id: + spec_by_id[scenario_id] = spec_file + + runtime_by_id: dict[str, Path] = {} + for runtime_file in runtime_game_files: + scenario_id = str(_load_yaml(runtime_file).get("id", "")).strip() + if scenario_id: + runtime_by_id[scenario_id] = runtime_file + + if "DEFAULT" not in spec_by_id or "DEFAULT" not in runtime_by_id: + raise StoryGenerationError("Strict mirror requires DEFAULT runtime YAML in both docs/protocols and game/scenarios") + + for scenario_id, runtime_file in runtime_by_id.items(): + spec_file = spec_by_id.get(scenario_id) + if spec_file is None: + continue + if _canonical_yaml_payload(runtime_file) != _canonical_yaml_payload(spec_file): + raise StoryGenerationError( + "Runtime YAML mirror mismatch for scenario " + f"{scenario_id}: {runtime_file} != {spec_file}" + ) + + +def _build_runtime_story_file_set(spec_files: list[Path], runtime_game_files: list[Path]) -> list[Path]: + selected: dict[str, Path] = {} + for spec_file in spec_files: + scenario_id = str(_load_yaml(spec_file).get("id", "")).strip() + if scenario_id: + selected[scenario_id] = spec_file + for runtime_file in runtime_game_files: + scenario_id = str(_load_yaml(runtime_file).get("id", "")).strip() + if scenario_id: + selected[scenario_id] = runtime_file + return [selected[key] for key in sorted(selected.keys())] + + +def _sha_hex(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _story_spec_hash(scenarios: list[dict[str, Any]]) -> str: + canonical = json.dumps(scenarios, sort_keys=True, separators=(",", ":")) + return _sha_hex(canonical.encode("utf-8"))[:12] + + +def _create_cpp_context(scenarios: list[dict[str, Any]], spec_hash: str) -> dict[str, Any]: + normalized: list[dict[str, Any]] = [] + for sidx, scenario in enumerate(scenarios): + step_entries: list[dict[str, Any]] = [] + for tidx, step in enumerate(scenario["steps"]): + prefix = f"kSc{sidx}St{tidx}" + transitions: list[dict[str, Any]] = [] + for transition in step["transitions"]: + transitions.append( + { + "id": transition["id"], + "trigger_cpp": TRIGGER_CPP.get(transition["trigger"], "StoryTransitionTrigger::kOnEvent"), + "event_cpp": EVENT_CPP.get(transition["event_type"], "StoryEventType::kNone"), + "event_name": transition["event_name"], + "after_ms": max(0, int(transition["after_ms"])), + "target_step_id": transition["target_step_id"], + "priority": max(0, int(transition["priority"])), + "debug_only": "true" if transition.get("debug_only", False) else "false", + } + ) + step_entries.append( + { + "prefix": prefix, + "id": step["step_id"], + "screen_scene_id": step["screen_scene_id"] or "nullptr", + "audio_pack_id": step["audio_pack_id"] or "nullptr", + "actions": step["actions"], + "apps": step["apps"], + "transitions": transitions, + "mp3_gate_open": "true" if step["mp3_gate_open"] else "false", + } + ) + normalized.append( + { + "index": sidx, + "id": scenario["id"], + "version": scenario["version"], + "initial_step": scenario["initial_step"], + "steps": step_entries, + } + ) + + app_bindings: dict[str, dict[str, Any]] = {} + for scenario in scenarios: + for binding in scenario["app_bindings"]: + app_bindings[binding["id"]] = binding + app_entries = [ + { + "id": app_id, + "app_cpp": APP_CPP[binding["app"]], + "la_config": binding.get("config") if binding["app"] == "LA_DETECTOR" else None, + } + for app_id, binding in sorted(app_bindings.items(), key=lambda item: item[0]) + ] + + return { + "spec_hash": spec_hash, + "scenario_count": len(scenarios), + "scenarios": normalized, + "app_entries": app_entries, + } + + +def _render_template(template_name: str, context: dict[str, Any]) -> str: + env = Environment(loader=FileSystemLoader(str(_template_dir())), trim_blocks=True, lstrip_blocks=True) + template = env.get_template(template_name) + return template.render(**context).rstrip() + "\n" + + +def _write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def generate_cpp_files(scenarios: list[dict[str, Any]], out_dir: Path) -> str: + spec_hash = _story_spec_hash(scenarios) + context = _create_cpp_context(scenarios, spec_hash) + _write_text(out_dir / "scenarios_gen.h", _render_template("scenarios_gen.h.j2", context)) + _write_text(out_dir / "scenarios_gen.cpp", _render_template("scenarios_gen.cpp.j2", context)) + _write_text(out_dir / "apps_gen.h", _render_template("apps_gen.h.j2", context)) + _write_text(out_dir / "apps_gen.cpp", _render_template("apps_gen.cpp.j2", context)) + return spec_hash + + +def _json_compact(payload: dict[str, Any]) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _write_json_with_checksum(root: Path, rel_path: str, payload: dict[str, Any]) -> None: + blob = _json_compact(payload) + out_path = root / rel_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(blob) + (out_path.with_suffix(".sha256")).write_text(_sha_hex(blob) + "\n", encoding="utf-8") + + +def _resource_slug(resource_id: str, prefix: str) -> str: + slug = resource_id + if slug.startswith(prefix): + slug = slug[len(prefix) :] + return slug.lower() + + +def _resource_candidates(resource_root: Path, resource_type: str, resource_id: str) -> list[Path]: + candidates: list[Path] = [] + base = resource_root / resource_type + candidates.append(base / f"{resource_id}.json") + candidates.append(base / f"{resource_id.lower()}.json") + + if resource_type == "screens": + slug = _resource_slug(resource_id, "SCENE_") + candidates.append(base / f"{slug}.json") + elif resource_type == "audio": + slug = _resource_slug(resource_id, "PACK_") + candidates.append(base / f"{slug}.json") + elif resource_type == "actions": + alias = ACTION_FILE_ALIASES.get(resource_id) + if alias: + candidates.append(base / f"{alias}.json") + candidates.append(base / f"{alias.lower()}.json") + + # de-duplicate while preserving order + dedup: list[Path] = [] + seen: set[str] = set() + for path in candidates: + marker = str(path) + if marker in seen: + continue + seen.add(marker) + dedup.append(path) + return dedup + + +def _load_resource_payload( + resource_root: Path | None, resource_type: str, resource_id: str, required: bool = False +) -> dict[str, Any] | None: + if resource_root is None: + if required: + raise StoryGenerationError(f"Missing required {resource_type} resource '{resource_id}'") + return None + for path in _resource_candidates(resource_root, resource_type, resource_id): + if not path.exists(): + continue + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise StoryGenerationError(f"Invalid JSON resource {path}: {exc}") from exc + if not isinstance(payload, dict): + raise StoryGenerationError(f"Invalid resource type in {path}: expected object") + return payload + if required: + raise StoryGenerationError(f"Missing required {resource_type} resource '{resource_id}'") + return None + + +def _merge_app_payload(source_payload: dict[str, Any] | None, binding: dict[str, Any]) -> dict[str, Any]: + merged: dict[str, Any] = dict(source_payload or {}) + merged["id"] = binding["id"] + merged["app"] = binding["app"] + + binding_config = binding.get("config") + if binding_config is not None: + existing_config = merged.get("config") + if isinstance(existing_config, dict): + config = dict(existing_config) + else: + config = {} + config.update(binding_config) + merged["config"] = config + return merged + + +def _with_resource_id(payload: dict[str, Any] | None, resource_id: str) -> dict[str, Any]: + result = dict(payload or {}) + result["id"] = resource_id + return result + + +def _scene_profile(scene_id: str) -> dict[str, Any]: + profile = _active_scene_profiles().get(scene_id, DEFAULT_SCENE_PROFILE) + return copy.deepcopy(profile) + + +def _as_positive_int(value: Any, default_value: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default_value + if parsed < 0: + return 0 + return parsed + + +def _as_int_in_range(value: Any, default_value: int, min_value: int, max_value: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default_value + if parsed < min_value: + return min_value + if parsed > max_value: + return max_value + return parsed + + +def _as_bool(value: Any, default_value: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return default_value + + +def _as_choice(value: Any, allowed: set[str], default_value: str) -> str: + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + if normalized in allowed: + return normalized + return default_value + + +def _normalize_screen_effect(value: Any, default_value: str) -> str: + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + normalized = SCREEN_EFFECT_ALIASES.get(normalized, normalized) + if normalized in SCREEN_EFFECT_CHOICES: + return normalized + if any(token in normalized for token in ("scan", "radar", "wave", "sonar")): + return "scan" + return "pulse" + return _as_choice(default_value, SCREEN_EFFECT_CHOICES, "pulse") + + +def _normalize_transition_effect(value: Any, default_value: str) -> str: + if isinstance(value, str): + normalized = value.strip().lower().replace("-", "_") + normalized = TRANSITION_EFFECT_ALIASES.get(normalized, normalized) + if normalized in TRANSITION_EFFECT_CHOICES: + return normalized + return _as_choice(default_value, TRANSITION_EFFECT_CHOICES, "fade") + + +def _normalize_theme(theme_payload: Any, fallback_theme: dict[str, str]) -> dict[str, str]: + theme: dict[str, str] = { + "bg": fallback_theme["bg"], + "accent": fallback_theme["accent"], + "text": fallback_theme["text"], + } + if isinstance(theme_payload, dict): + for key in ("bg", "accent", "text"): + value = theme_payload.get(key) + if isinstance(value, str) and value.strip(): + theme[key] = value.strip() + return theme + + +def _normalize_text_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_TEXT_OPTIONS) + profile_options = profile.get("text") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + return { + "show_title": _as_bool(src.get("show_title"), _as_bool(base.get("show_title"), False)), + "show_subtitle": _as_bool(src.get("show_subtitle"), _as_bool(base.get("show_subtitle"), True)), + "show_symbol": _as_bool(src.get("show_symbol"), _as_bool(base.get("show_symbol"), True)), + "title_case": _as_choice( + src.get("title_case"), + TEXT_CASE_CHOICES, + _as_choice(base.get("title_case"), TEXT_CASE_CHOICES, "upper"), + ), + "subtitle_case": _as_choice( + src.get("subtitle_case"), + TEXT_CASE_CHOICES, + _as_choice(base.get("subtitle_case"), TEXT_CASE_CHOICES, "raw"), + ), + "title_align": _as_choice( + src.get("title_align"), + TEXT_ALIGN_CHOICES, + _as_choice(base.get("title_align"), TEXT_ALIGN_CHOICES, "top"), + ), + "subtitle_align": _as_choice( + src.get("subtitle_align"), + TEXT_ALIGN_CHOICES, + _as_choice(base.get("subtitle_align"), TEXT_ALIGN_CHOICES, "bottom"), + ), + } + + +def _normalize_framing_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_FRAMING_OPTIONS) + profile_options = profile.get("framing") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + return { + "preset": _as_choice( + src.get("preset"), + FRAMING_PRESET_CHOICES, + _as_choice(base.get("preset"), FRAMING_PRESET_CHOICES, "center"), + ), + "x_offset": _as_int_in_range(src.get("x_offset"), int(base.get("x_offset", 0)), -80, 80), + "y_offset": _as_int_in_range(src.get("y_offset"), int(base.get("y_offset", 0)), -80, 80), + "scale_pct": _as_int_in_range(src.get("scale_pct"), int(base.get("scale_pct", 100)), 60, 140), + } + + +def _normalize_scroll_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_SCROLL_OPTIONS) + profile_options = profile.get("scroll") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + mode_value = src.get("mode") + if isinstance(mode_value, str): + normalized_mode = mode_value.strip().lower().replace("-", "_") + mode_value = SCROLL_MODE_ALIASES.get(normalized_mode, normalized_mode) + mode = _as_choice(mode_value, SCROLL_MODE_CHOICES, _as_choice(base.get("mode"), SCROLL_MODE_CHOICES, "none")) + return { + "mode": mode, + "speed_ms": _as_int_in_range(src.get("speed_ms"), int(base.get("speed_ms", 4200)), 600, 20000), + "pause_ms": _as_int_in_range(src.get("pause_ms"), int(base.get("pause_ms", 900)), 0, 10000), + "loop": _as_bool(src.get("loop"), _as_bool(base.get("loop"), True)), + } + + +def _normalize_demo_options(payload: Any, profile: dict[str, Any]) -> dict[str, Any]: + base = dict(DEFAULT_DEMO_OPTIONS) + profile_options = profile.get("demo") + if isinstance(profile_options, dict): + base.update(profile_options) + src = payload if isinstance(payload, dict) else {} + + return { + "mode": _as_choice( + src.get("mode"), + DEMO_MODE_CHOICES, + _as_choice(base.get("mode"), DEMO_MODE_CHOICES, "standard"), + ), + "particle_count": _as_int_in_range(src.get("particle_count"), int(base.get("particle_count", 4)), 0, 4), + "strobe_level": _as_int_in_range(src.get("strobe_level"), int(base.get("strobe_level", 65)), 0, 100), + } + + +def _normalize_screen_timeline(payload: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: + timeline_source = payload.get("timeline") + visual_source = payload.get("visual") + timeline_obj: dict[str, Any] | None = None + if isinstance(timeline_source, dict): + timeline_obj = timeline_source + elif isinstance(visual_source, dict) and isinstance(visual_source.get("timeline"), dict): + timeline_obj = visual_source.get("timeline") + + timeline_nodes: list[Any] = [] + if isinstance(timeline_source, list): + timeline_nodes = list(timeline_source) + elif isinstance(timeline_obj, dict): + keyframes = timeline_obj.get("keyframes") + if isinstance(keyframes, list): + timeline_nodes = list(keyframes) + elif isinstance(timeline_obj.get("frames"), list): + timeline_nodes = list(timeline_obj.get("frames")) + elif isinstance(visual_source, dict) and isinstance(visual_source.get("timeline"), list): + timeline_nodes = list(visual_source.get("timeline")) + + default_frames = profile["timeline"] + base_theme = _normalize_theme(payload.get("theme"), profile["theme"]) + base_effect = _normalize_screen_effect(payload.get("effect"), str(profile["effect"])) + visual = payload.get("visual") + if isinstance(visual, dict): + base_speed = _as_positive_int(visual.get("effect_speed_ms"), profile["effect_speed_ms"]) + else: + base_speed = profile["effect_speed_ms"] + base_speed = _as_positive_int(payload.get("effect_speed_ms"), base_speed) + if base_speed <= 0: + base_speed = profile["effect_speed_ms"] + + normalized_frames: list[dict[str, Any]] = [] + if not timeline_nodes: + timeline_nodes = copy.deepcopy(default_frames) + + prev_at = 0 + prev_effect = base_effect + prev_speed = base_speed + prev_theme = dict(base_theme) + for frame in timeline_nodes: + if not isinstance(frame, dict): + continue + at_value = frame.get("at_ms", frame.get("time_ms", frame.get("t", prev_at))) + at_ms = _as_positive_int(at_value, prev_at) + if at_ms < prev_at: + at_ms = prev_at + + effect = _normalize_screen_effect(frame.get("effect", frame.get("fx", prev_effect)), prev_effect) + + speed_value = frame.get("speed_ms", frame.get("effect_speed_ms", frame.get("speed", prev_speed))) + speed_ms = _as_positive_int(speed_value, prev_speed) + if speed_ms <= 0: + speed_ms = prev_speed + + theme_payload = frame.get("theme") + if not isinstance(theme_payload, dict): + theme_payload = { + "bg": frame.get("bg"), + "accent": frame.get("accent"), + "text": frame.get("text"), + } + theme = _normalize_theme(theme_payload, prev_theme) + + normalized_frames.append( + { + "at_ms": at_ms, + "effect": effect, + "speed_ms": speed_ms, + "theme": theme, + } + ) + prev_at = at_ms + prev_effect = effect + prev_speed = speed_ms + prev_theme = dict(theme) + + if not normalized_frames: + normalized_frames = copy.deepcopy(default_frames) + + if normalized_frames[0]["at_ms"] != 0: + first = copy.deepcopy(normalized_frames[0]) + first["at_ms"] = 0 + normalized_frames.insert(0, first) + + if len(normalized_frames) == 1: + extra = copy.deepcopy(normalized_frames[0]) + extra["at_ms"] = max(1000, normalized_frames[0]["at_ms"] + 1000) + normalized_frames.append(extra) + + duration_ms = normalized_frames[-1]["at_ms"] + if timeline_obj is not None: + duration_ms = max(duration_ms, _as_positive_int(timeline_obj.get("duration_ms"), duration_ms)) + if duration_ms < 100: + duration_ms = 100 + + loop = True + if timeline_obj is not None and isinstance(timeline_obj.get("loop"), bool): + loop = bool(timeline_obj.get("loop")) + + return { + "loop": loop, + "duration_ms": duration_ms, + "keyframes": normalized_frames, + } + + +def _normalize_screen_payload(source_payload: dict[str, Any] | None, scene_id: str) -> dict[str, Any]: + payload: dict[str, Any] = dict(source_payload or {}) + payload["id"] = scene_id + + profile = _scene_profile(scene_id) + + for text_field in ("title", "subtitle", "symbol", "effect"): + value = payload.get(text_field) + if not isinstance(value, str) or not value.strip(): + payload[text_field] = profile[text_field] + payload["effect"] = _normalize_screen_effect(payload.get("effect"), str(profile["effect"])) + + text_options = _normalize_text_options(payload.get("text"), profile) + payload["text"] = text_options + + visual = payload.get("visual") + if not isinstance(visual, dict): + visual = {} + visual["show_title"] = text_options["show_title"] + visual["show_subtitle"] = text_options["show_subtitle"] + visual["show_symbol"] = text_options["show_symbol"] + visual["effect_speed_ms"] = _as_positive_int(visual.get("effect_speed_ms"), profile["effect_speed_ms"]) + if visual["effect_speed_ms"] <= 0: + visual["effect_speed_ms"] = profile["effect_speed_ms"] + payload["visual"] = visual + + payload["theme"] = _normalize_theme(payload.get("theme"), profile["theme"]) + payload["framing"] = _normalize_framing_options(payload.get("framing"), profile) + payload["scroll"] = _normalize_scroll_options(payload.get("scroll"), profile) + payload["demo"] = _normalize_demo_options(payload.get("demo"), profile) + + transition = payload.get("transition") + if isinstance(transition, str): + transition = {"effect": transition} + if not isinstance(transition, dict): + transition = {} + default_transition = profile["transition"] + effect = transition.get("effect", transition.get("type", default_transition["effect"])) + transition["effect"] = _normalize_transition_effect(effect, str(default_transition["effect"])) + transition["duration_ms"] = _as_positive_int(transition.get("duration_ms"), default_transition["duration_ms"]) + if transition["duration_ms"] <= 0: + transition["duration_ms"] = default_transition["duration_ms"] + payload["transition"] = transition + + payload["timeline"] = _normalize_screen_timeline(payload, profile) + return payload + + +def _load_screen_payload_with_legacy_fallback( + resource_root: Path | None, screen_id: str +) -> tuple[dict[str, Any] | None, bool]: + source_payload = _load_resource_payload(resource_root, "screens", screen_id, required=False) + if source_payload is not None: + return source_payload, False + if screen_id in _active_scene_profiles(): + # Legacy bundles may omit some known screen JSON files. For those scenes, + # generate payloads from profile defaults instead of failing hard. + return None, True + raise StoryGenerationError(f"Missing required screens resource '{screen_id}'") + + +def generate_bundle_files( + scenarios: list[dict[str, Any]], + out_dir: Path, + spec_hash: str, + resource_root: Path | None = None, +) -> None: + resources: dict[str, set[str]] = { + "screens": set(), + "audio": set(), + "actions": set(), + "apps": set(), + } + autogenerated_screens: list[str] = [] + bindings: dict[str, dict[str, Any]] = {} + + for scenario in scenarios: + scenario_payload = { + "id": scenario["id"], + "version": scenario["version"], + "estimated_duration_s": scenario.get("estimated_duration_s", 0), + "debug_transition_bypass_enabled": bool(scenario.get("debug_transition_bypass_enabled", False)), + "initial_step": scenario["initial_step"], + "app_bindings": scenario["app_bindings"], + "steps": scenario["steps"], + } + _write_json_with_checksum(out_dir, f"story/scenarios/{scenario['id']}.json", scenario_payload) + + for binding in scenario["app_bindings"]: + resources["apps"].add(binding["id"]) + bindings[binding["id"]] = { + "id": binding["id"], + "app": binding["app"], + "config": binding.get("config"), + } + + for step in scenario["steps"]: + if step["screen_scene_id"]: + resources["screens"].add(step["screen_scene_id"]) + if step["audio_pack_id"]: + resources["audio"].add(step["audio_pack_id"]) + for action in step["actions"]: + resources["actions"].add(action) + + for app_id in sorted(resources["apps"]): + binding = bindings[app_id] + source_payload = _load_resource_payload(resource_root, "apps", app_id) + app_payload = _merge_app_payload(source_payload, binding) + _write_json_with_checksum(out_dir, f"story/apps/{app_id}.json", app_payload) + + for screen_id in sorted(resources["screens"]): + source_payload, autogenerated = _load_screen_payload_with_legacy_fallback(resource_root, screen_id) + payload = _normalize_screen_payload(source_payload, screen_id) + _write_json_with_checksum(out_dir, f"story/screens/{screen_id}.json", payload) + if autogenerated: + autogenerated_screens.append(screen_id) + + for audio_id in sorted(resources["audio"]): + source_payload = _load_resource_payload(resource_root, "audio", audio_id) + payload = _with_resource_id(source_payload, audio_id) + _write_json_with_checksum(out_dir, f"story/audio/{audio_id}.json", payload) + + for action_id in sorted(resources["actions"]): + source_payload = _load_resource_payload(resource_root, "actions", action_id) + payload = _with_resource_id(source_payload, action_id) + _write_json_with_checksum(out_dir, f"story/actions/{action_id}.json", payload) + + manifest = { + "spec_hash": spec_hash, + "scenarios": [scenario["id"] for scenario in scenarios], + "resource_counts": {key: len(values) for key, values in resources.items()}, + "autogenerated_resources": { + "screens": autogenerated_screens, + }, + } + _write_json_with_checksum(out_dir, "story/manifest.json", manifest) + + +def create_archive(root: Path, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive_path, "w:gz") as tar: + for path in sorted(root.rglob("*")): + if path.is_file(): + tar.add(path, arcname=path.relative_to(root)) + + +def load_and_validate(paths: StoryPaths, spec_dir: Path | None = None, game_dir: Path | None = None) -> tuple[list[dict[str, Any]], list[str]]: + _require_deps() + actual_spec_dir = spec_dir or paths.story_specs_dir + actual_game_dir = game_dir or paths.game_scenarios_dir + + spec_files = _list_yaml_files(actual_spec_dir) + game_files = _list_yaml_files(actual_game_dir) + + runtime_game_files: list[Path] = [] + narrative_game_files: list[Path] = [] + template_files: list[Path] = [] + for file_path in game_files: + category = _classify_game_yaml(file_path) + if category == "runtime": + runtime_game_files.append(file_path) + elif category == "narrative": + narrative_game_files.append(file_path) + elif category == "template": + template_files.append(file_path) + elif category == "workbench": + continue + + _validate_yamale(_schema_path("story_spec_schema.yamale"), spec_files) + if runtime_game_files: + _validate_yamale(_schema_path("story_spec_schema.yamale"), runtime_game_files) + if template_files: + _validate_yamale(_schema_path("scenario_template_schema.yamale"), template_files) + + _validate_runtime_mirror(spec_files, runtime_game_files) + + runtime_story_files = _build_runtime_story_file_set(spec_files, runtime_game_files) + scenarios = _normalize_story_specs(runtime_story_files) + game_ids = _validate_game_scenarios(narrative_game_files) + return scenarios, game_ids + + +def _pretty_json(payload: dict[str, Any]) -> str: + return json.dumps(payload, indent=2, ensure_ascii=True) + "\n" + + +def _build_screen_payload_from_profile(scene_id: str) -> dict[str, Any]: + profile_payload = _scene_profile(scene_id) + return _normalize_screen_payload(profile_payload, scene_id) + + +def _legacy_screen_mirror_targets(scene_id: str) -> list[tuple[str, str]]: + targets: list[tuple[str, str]] = [(_scene_slug(scene_id), scene_id)] + for alias_id, canonical in LEGACY_SCREEN_ALIASES.items(): + if canonical != scene_id: + continue + alias_slug = _scene_slug(alias_id) + if any(existing_slug == alias_slug for existing_slug, _ in targets): + continue + targets.append((alias_slug, alias_id)) + return targets + + +def _sync_screens(paths: StoryPaths, check_only: bool) -> dict[str, Any]: + _activate_scene_profiles(paths) + story_screens_dir = paths.story_data_dir / "screens" + # Keep legacy payload mirrors out of the LittleFS data/ tree. + legacy_screens_dir = paths.fw_root / "legacy_payloads" / "fs_excluded" / "screens" + story_screens_dir.mkdir(parents=True, exist_ok=True) + legacy_screens_dir.mkdir(parents=True, exist_ok=True) + + story_written = 0 + story_unchanged = 0 + legacy_written = 0 + legacy_unchanged = 0 + drifts: list[str] = [] + + for scene_id in CANONICAL_SCREEN_SCENE_IDS: + payload = _build_screen_payload_from_profile(scene_id) + payload_text = _pretty_json(payload) + story_path = story_screens_dir / f"{scene_id}.json" + current_story = story_path.read_text(encoding="utf-8") if story_path.exists() else None + if current_story != payload_text: + if check_only: + drifts.append(str(story_path)) + else: + story_path.write_text(payload_text, encoding="utf-8") + story_written += 1 + else: + story_unchanged += 1 + + for slug, payload_id in _legacy_screen_mirror_targets(scene_id): + legacy_payload = dict(payload) + legacy_payload["id"] = payload_id + legacy_text = _pretty_json(legacy_payload) + legacy_path = legacy_screens_dir / f"{slug}.json" + current_legacy = legacy_path.read_text(encoding="utf-8") if legacy_path.exists() else None + if current_legacy != legacy_text: + if check_only: + drifts.append(str(legacy_path)) + else: + legacy_path.write_text(legacy_text, encoding="utf-8") + legacy_written += 1 + else: + legacy_unchanged += 1 + + if check_only and drifts: + raise StoryGenerationError( + "screen sync drift detected:\n" + "\n".join(f"- {path}" for path in sorted(set(drifts))) + ) + + return { + "check_only": check_only, + "story_count": len(CANONICAL_SCREEN_SCENE_IDS), + "story_written": story_written, + "story_unchanged": story_unchanged, + "legacy_written": legacy_written, + "legacy_unchanged": legacy_unchanged, + "legacy_dir": str(legacy_screens_dir), + "palette_path": str(_palette_path(paths)), + } + + +def run_validate(paths: StoryPaths, spec_dir: Path | None = None, game_dir: Path | None = None) -> dict[str, Any]: + _activate_scene_profiles(paths) + scenarios, game_ids = load_and_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + return { + "scenarios": [item["id"] for item in scenarios], + "scenario_count": len(scenarios), + "game_scenarios": game_ids, + "game_scenario_count": len(game_ids), + } + + +def run_generate_cpp( + paths: StoryPaths, + out_dir: Path | None = None, + spec_dir: Path | None = None, + game_dir: Path | None = None, +) -> dict[str, Any]: + _activate_scene_profiles(paths) + scenarios, game_ids = load_and_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + cpp_out = out_dir or paths.generated_cpp_dir + spec_hash = generate_cpp_files(scenarios, cpp_out) + return { + "spec_hash": spec_hash, + "out_dir": cpp_out, + "scenario_count": len(scenarios), + "game_scenario_count": len(game_ids), + } + + +def run_generate_bundle( + paths: StoryPaths, + out_dir: Path | None = None, + archive: Path | None = None, + spec_dir: Path | None = None, + game_dir: Path | None = None, +) -> dict[str, Any]: + _activate_scene_profiles(paths) + scenarios, game_ids = load_and_validate(paths, spec_dir=spec_dir, game_dir=game_dir) + bundle_out = out_dir or paths.bundle_root + spec_hash = _story_spec_hash(scenarios) + + if bundle_out.exists(): + for file_path in sorted(bundle_out.rglob("*"), reverse=True): + if file_path.is_file(): + file_path.unlink() + + resource_root = paths.story_data_dir if paths.story_data_dir.exists() else None + generate_bundle_files(scenarios, bundle_out, spec_hash, resource_root=resource_root) + + archive_path = archive + if archive_path is not None: + create_archive(bundle_out, archive_path) + + return { + "spec_hash": spec_hash, + "out_dir": bundle_out, + "archive": archive_path, + "scenario_count": len(scenarios), + "game_scenario_count": len(game_ids), + } + + +def run_sync_screens(paths: StoryPaths, check_only: bool = False) -> dict[str, Any]: + return _sync_screens(paths, check_only=check_only) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale new file mode 100644 index 0000000..9b355da --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/game_scenario_schema.yamale @@ -0,0 +1,49 @@ +id: str() +version: int(min=1) +title: str() +theme: str() +players: include('players') +ages: str() +duration_minutes: include('duration') +canon: include('canon') +stations: list(include('station'), min=1) +puzzles: list(include('puzzle'), min=1) +solution: include('solution') +solution_unique: bool() +finale_statement: str(required=False) +notes: str(required=False) + +--- +players: + min: int(min=1) + max: int(min=1) + +duration: + min: int(min=1) + max: int(min=1) + +canon: + introduction: str(required=False) + timeline: list(include('timeline_entry'), min=1) + stakes: str(required=False) + +timeline_entry: + label: str() + note: str() + +station: + name: str() + focus: str() + clue: str() + +puzzle: + id: str() + type: str() + clue: str() + effect: str() + +solution: + culprit: str() + motive: str() + method: str() + proof: list(str(), min=1) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/scenario_template_schema.yamale b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/scenario_template_schema.yamale new file mode 100644 index 0000000..a58723a --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/scenario_template_schema.yamale @@ -0,0 +1,2 @@ +prompt_input: any() +current_firmware_snapshot: any(required=False) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/story_spec_schema.yamale b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/story_spec_schema.yamale new file mode 100644 index 0000000..bcae1e1 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/schemas/story_spec_schema.yamale @@ -0,0 +1,37 @@ +id: str() +version: int(min=1) +initial_step: str() +estimated_duration_s: int(required=False, min=0) +debug_transition_bypass_enabled: bool(required=False) +app_bindings: list(include('app_binding'), min=1) +steps: list(include('step'), min=1) + +--- +app_binding: + id: str() + app: enum('LA_DETECTOR', 'AUDIO_PACK', 'SCREEN_SCENE', 'MP3_GATE', 'WIFI_STACK', 'ESPNOW_STACK', 'QR_UNLOCK_APP') + config: include('la_config', required=False) + +la_config: + hold_ms: int(required=False, min=100) + unlock_event: str(required=False) + require_listening: bool(required=False) + +step: + step_id: str() + screen_scene_id: str() + audio_pack_id: str(required=False) + actions: list(str(), required=False) + apps: list(str(), min=1) + mp3_gate_open: bool() + transitions: list(include('transition')) + +transition: + id: str(required=False) + trigger: enum('on_event', 'after_ms', 'immediate') + event_type: enum('none', 'unlock', 'audio_done', 'timer', 'serial', 'button', 'espnow', 'action') + event_name: str() + target_step_id: str() + after_ms: int(min=0) + priority: int(min=0) + debug_only: bool(required=False) diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 new file mode 100644 index 0000000..9f2bf17 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.cpp.j2 @@ -0,0 +1,57 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by zacus_story_gen_ai (Yamale + Jinja2) +// spec_hash: {{ spec_hash }} +// scenarios: {{ scenario_count }} + +#include "apps_gen.h" + +#include + +namespace { +constexpr AppBindingDef kGeneratedAppBindings[] = { +{% for app in app_entries %} {"{{ app.id }}", {{ app.app_cpp }}}, +{% endfor %}}; +{% set la_configs = app_entries | selectattr('la_config') | list %} +{% if la_configs %} +constexpr LaDetectorAppConfigDef kGeneratedLaConfigs[] = { +{% for app in la_configs %} {"{{ app.id }}", true, {{ app.la_config.hold_ms }}U, "{{ app.la_config.unlock_event }}", {{ 'true' if app.la_config.require_listening else 'false' }}}, +{% endfor %}}; +{% endif %} +} // namespace + +const AppBindingDef* generatedAppBindingById(const char* id) { + if (id == nullptr || id[0] == '\0') { + return nullptr; + } + for (const AppBindingDef& binding : kGeneratedAppBindings) { + if (binding.id != nullptr && strcmp(binding.id, id) == 0) { + return &binding; + } + } + return nullptr; +} + +uint8_t generatedAppBindingCount() { + return static_cast(sizeof(kGeneratedAppBindings) / sizeof(kGeneratedAppBindings[0])); +} + +const char* generatedAppBindingIdAt(uint8_t index) { + if (index >= generatedAppBindingCount()) { + return nullptr; + } + return kGeneratedAppBindings[index].id; +} + +const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id) { + if (id == nullptr || id[0] == '\0') { + return nullptr; + } +{% if la_configs %} + for (const LaDetectorAppConfigDef& cfg : kGeneratedLaConfigs) { + if (cfg.bindingId != nullptr && strcmp(cfg.bindingId, id) == 0) { + return &cfg; + } + } +{% endif %} + return nullptr; +} diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.h.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.h.j2 new file mode 100644 index 0000000..8d146af --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/apps_gen.h.j2 @@ -0,0 +1,23 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by zacus_story_gen_ai (Yamale + Jinja2) +// spec_hash: {{ spec_hash }} +// scenarios: {{ scenario_count }} + +#pragma once + +#include + +#include "../core/scenario_def.h" + +struct LaDetectorAppConfigDef { + const char* bindingId; + bool hasConfig; + uint32_t holdMs; + const char* unlockEvent; + bool requireListening; +}; + +const AppBindingDef* generatedAppBindingById(const char* id); +uint8_t generatedAppBindingCount(); +const char* generatedAppBindingIdAt(uint8_t index); +const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id); diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 new file mode 100644 index 0000000..e4583c7 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.cpp.j2 @@ -0,0 +1,78 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by zacus_story_gen_ai (Yamale + Jinja2) +// spec_hash: {{ spec_hash }} +// scenarios: {{ scenario_count }} + +#include "scenarios_gen.h" + +#include + +namespace { +{% for scenario in scenarios %} +{% for step in scenario.steps %} +{% if step.actions %} +constexpr const char* {{ step.prefix }}Actions[] = { +{% for action in step.actions %} "{{ action }}", +{% endfor %}}; +{% endif %} +{% if step.apps %} +constexpr const char* {{ step.prefix }}Apps[] = { +{% for app_id in step.apps %} "{{ app_id }}", +{% endfor %}}; +{% endif %} +{% if step.transitions %} +constexpr TransitionDef {{ step.prefix }}Transitions[] = { +{% for tr in step.transitions %} {"{{ tr.id }}", {{ tr.trigger_cpp }}, {{ tr.event_cpp }}, "{{ tr.event_name }}", {{ tr.after_ms }}U, "{{ tr.target_step_id }}", {{ tr.priority }}U, {{ tr.debug_only }}}, +{% endfor %}}; +{% endif %} +{% endfor %} + +constexpr StepDef kScenario{{ scenario.index }}Steps[] = { +{% for step in scenario.steps %} {"{{ step.id }}", {{ "{" }}{% if step.screen_scene_id == 'nullptr' %}nullptr{% else %}"{{ step.screen_scene_id }}"{% endif %}, {% if step.audio_pack_id == 'nullptr' %}nullptr{% else %}"{{ step.audio_pack_id }}"{% endif %}, {% if step.actions %}{{ step.prefix }}Actions{% else %}nullptr{% endif %}, {{ step.actions|length }}U, {% if step.apps %}{{ step.prefix }}Apps{% else %}nullptr{% endif %}, {{ step.apps|length }}U{{ "}" }}, {% if step.transitions %}{{ step.prefix }}Transitions{% else %}nullptr{% endif %}, {{ step.transitions|length }}U, {{ step.mp3_gate_open }}}, +{% endfor %}}; + +constexpr ScenarioDef kScenario{{ scenario.index }} = { + "{{ scenario.id }}", + {{ scenario.version }}U, + kScenario{{ scenario.index }}Steps, + {{ scenario.steps|length }}U, + "{{ scenario.initial_step }}", +}; + +{% endfor %} +constexpr const ScenarioDef* kGeneratedScenarios[] = { +{% for scenario in scenarios %} &kScenario{{ scenario.index }}, +{% endfor %}}; + +} // namespace + +const ScenarioDef* generatedScenarioById(const char* id) { + if (id == nullptr || id[0] == '\0') { + return generatedScenarioDefault(); + } + for (const ScenarioDef* scenario : kGeneratedScenarios) { + if (scenario != nullptr && scenario->id != nullptr && strcmp(scenario->id, id) == 0) { + return scenario; + } + } + return nullptr; +} + +const ScenarioDef* generatedScenarioDefault() { + return (generatedScenarioCount() == 0U) ? nullptr : kGeneratedScenarios[0]; +} + +uint8_t generatedScenarioCount() { + return static_cast(sizeof(kGeneratedScenarios) / sizeof(kGeneratedScenarios[0])); +} + +const char* generatedScenarioIdAt(uint8_t index) { + if (index >= generatedScenarioCount()) { + return nullptr; + } + return kGeneratedScenarios[index]->id; +} + +const char* generatedScenarioSpecHash() { + return "{{ spec_hash }}"; +} diff --git a/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 new file mode 100644 index 0000000..1234706 --- /dev/null +++ b/lib/zacus_story_gen_ai/src/zacus_story_gen_ai/templates/scenarios_gen.h.j2 @@ -0,0 +1,16 @@ +// AUTO-GENERATED FILE - DO NOT EDIT +// Generated by zacus_story_gen_ai (Yamale + Jinja2) +// spec_hash: {{ spec_hash }} +// scenarios: {{ scenario_count }} + +#pragma once + +#include + +#include "../core/scenario_def.h" + +const ScenarioDef* generatedScenarioById(const char* id); +const ScenarioDef* generatedScenarioDefault(); +uint8_t generatedScenarioCount(); +const char* generatedScenarioIdAt(uint8_t index); +const char* generatedScenarioSpecHash(); diff --git a/lib/zacus_story_gen_ai/tests/conftest.py b/lib/zacus_story_gen_ai/tests/conftest.py new file mode 100644 index 0000000..fdcbc1f --- /dev/null +++ b/lib/zacus_story_gen_ai/tests/conftest.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) diff --git a/lib/zacus_story_gen_ai/tests/test_generator.py b/lib/zacus_story_gen_ai/tests/test_generator.py new file mode 100644 index 0000000..056b7af --- /dev/null +++ b/lib/zacus_story_gen_ai/tests/test_generator.py @@ -0,0 +1,305 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from zacus_story_gen_ai.generator import ( + StoryGenerationError, + StoryPaths, + run_generate_bundle, + run_generate_cpp, + run_validate, +) + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _paths(tmp_path: Path) -> StoryPaths: + fw = tmp_path / "hardware" / "firmware" + repo = tmp_path + return StoryPaths( + fw_root=fw, + repo_root=repo, + game_scenarios_dir=repo / "game" / "scenarios", + story_specs_dir=fw / "docs" / "protocols" / "story_specs" / "scenarios", + story_data_dir=fw / "data" / "story", + generated_cpp_dir=fw / "hardware" / "libs" / "story" / "src" / "generated", + bundle_root=fw / "artifacts" / "story_fs" / "deploy", + ) + + +def _seed(tmp_path: Path) -> StoryPaths: + paths = _paths(tmp_path) + _write( + paths.game_scenarios_dir / "zacus_v1.yaml", + """ +id: zacus_v1 +version: 1 +title: Demo +theme: Demo +players: {min: 6, max: 10} +ages: 8+ +duration_minutes: {min: 60, max: 90} +canon: + timeline: + - label: L1 + note: N1 +stations: + - name: S1 + focus: F1 + clue: C1 +puzzles: + - id: P1 + type: t + clue: c + effect: e +solution: + culprit: X + motive: M + method: D + proof: [a, b, c] +solution_unique: true +""".strip(), + ) + _write( + paths.story_specs_dir / "default.yaml", + """ +id: DEFAULT +version: 2 +initial_step: STEP_WAIT_UNLOCK +app_bindings: + - id: APP_SCREEN + app: SCREEN_SCENE + - id: APP_GATE + app: MP3_GATE +steps: + - step_id: STEP_WAIT_UNLOCK + screen_scene_id: SCENE_LOCKED + audio_pack_id: "" + actions: [ACTION_TRACE_STEP] + apps: [APP_SCREEN, APP_GATE] + mp3_gate_open: false + transitions: + - trigger: on_event + event_type: unlock + event_name: UNLOCK + target_step_id: STEP_DONE + after_ms: 0 + priority: 100 + - step_id: STEP_DONE + screen_scene_id: SCENE_READY + audio_pack_id: "" + actions: [ACTION_REFRESH_SD] + apps: [APP_SCREEN, APP_GATE] + mp3_gate_open: true + transitions: [] +""".strip(), + ) + return paths + + +def _seed_default_screen_payloads(paths: StoryPaths) -> None: + _write( + paths.story_data_dir / "screens" / "SCENE_LOCKED.json", + json.dumps({"id": "SCENE_LOCKED", "title": "Locked"}), + ) + _write( + paths.story_data_dir / "screens" / "SCENE_READY.json", + json.dumps({"id": "SCENE_READY", "title": "Ready"}), + ) + + +def test_validate(tmp_path: Path) -> None: + paths = _seed(tmp_path) + result = run_validate(paths) + assert result["scenario_count"] == 1 + assert result["game_scenario_count"] == 1 + + +def test_generate_cpp_and_bundle(tmp_path: Path) -> None: + paths = _seed(tmp_path) + cpp = run_generate_cpp(paths) + _seed_default_screen_payloads(paths) + bundle = run_generate_bundle(paths) + assert cpp["spec_hash"] + assert (paths.generated_cpp_dir / "scenarios_gen.cpp").exists() + assert (paths.generated_cpp_dir / "apps_gen.cpp").exists() + assert (paths.bundle_root / "story" / "scenarios" / "DEFAULT.json").exists() + assert (paths.bundle_root / "story" / "manifest.sha256").exists() + assert bundle["scenario_count"] == 1 + + +def test_generate_bundle_uses_resource_payloads(tmp_path: Path) -> None: + paths = _seed(tmp_path) + _seed_default_screen_payloads(paths) + _write(paths.story_data_dir / "screens" / "SCENE_LOCKED.json", '{"id":"SCENE_LOCKED","title":"Custom Locked"}') + _write( + paths.story_data_dir / "apps" / "APP_SCREEN.json", + '{"id":"APP_SCREEN","app":"SCREEN_SCENE","config":{"show_title":true,"fps":30}}', + ) + _write( + paths.story_data_dir / "actions" / "ACTION_TRACE_STEP.json", + '{"id":"ACTION_TRACE_STEP","type":"trace_step","config":{"serial_log":false}}', + ) + + run_generate_bundle(paths) + + screen_payload = json.loads((paths.bundle_root / "story" / "screens" / "SCENE_LOCKED.json").read_text()) + app_payload = json.loads((paths.bundle_root / "story" / "apps" / "APP_SCREEN.json").read_text()) + action_payload = json.loads((paths.bundle_root / "story" / "actions" / "ACTION_TRACE_STEP.json").read_text()) + + assert screen_payload["title"] == "Custom Locked" + assert app_payload["app"] == "SCREEN_SCENE" + assert app_payload["config"]["show_title"] is True + assert app_payload["config"]["fps"] == 30 + assert action_payload["config"]["serial_log"] is False + + +def test_generate_bundle_rejects_unknown_scene_id(tmp_path: Path) -> None: + paths = _seed(tmp_path) + _write( + paths.story_specs_dir / "default.yaml", + """ +id: DEFAULT +version: 2 +initial_step: STEP_WAIT_UNLOCK +app_bindings: + - id: APP_SCREEN + app: SCREEN_SCENE +steps: + - step_id: STEP_WAIT_UNLOCK + screen_scene_id: SCENE_UNKNOWN + audio_pack_id: "" + apps: [APP_SCREEN] + mp3_gate_open: false + actions: [ACTION_TRACE_STEP] + transitions: [] +""".strip(), + ) + _seed_default_screen_payloads(paths) + with pytest.raises(StoryGenerationError, match="SCENE_UNKNOWN"): + run_generate_bundle(paths) + + +def test_generate_bundle_accepts_legacy_scene_alias(tmp_path: Path) -> None: + paths = _seed(tmp_path) + _write( + paths.story_specs_dir / "default.yaml", + """ +id: DEFAULT +version: 2 +initial_step: STEP_WAIT_UNLOCK +app_bindings: + - id: APP_SCREEN + app: SCREEN_SCENE +steps: + - step_id: STEP_WAIT_UNLOCK + screen_scene_id: SCENE_LA_DETECT + audio_pack_id: "" + apps: [APP_SCREEN] + mp3_gate_open: false + actions: [ACTION_TRACE_STEP] + transitions: [] +""".strip(), + ) + _seed_default_screen_payloads(paths) + bundle = run_generate_bundle(paths) + assert bundle["scenario_count"] == 1 + bundled = json.loads((paths.bundle_root / "story" / "scenarios" / "DEFAULT.json").read_text()) + assert bundled["steps"][0]["screen_scene_id"] == "SCENE_LA_DETECTOR" + + +def test_generate_bundle_rejects_missing_screen_asset(tmp_path: Path) -> None: + paths = _seed(tmp_path) + with pytest.raises(StoryGenerationError, match="Missing required screens resource 'SCENE_LOCKED'"): + run_generate_bundle(paths) + + +def test_generate_bundle_normalizes_screen_timeline_and_transition(tmp_path: Path) -> None: + paths = _seed(tmp_path) + _seed_default_screen_payloads(paths) + _write(paths.story_data_dir / "screens" / "SCENE_READY.json", '{"id":"SCENE_READY","title":"Ready Lite"}') + + run_generate_bundle(paths) + + payload = json.loads((paths.bundle_root / "story" / "screens" / "SCENE_READY.json").read_text()) + assert payload["id"] == "SCENE_READY" + assert payload["title"] == "Ready Lite" + assert isinstance(payload["transition"], dict) + assert payload["transition"]["effect"] + assert payload["transition"]["duration_ms"] > 0 + assert isinstance(payload["timeline"], dict) + assert isinstance(payload["timeline"]["keyframes"], list) + assert len(payload["timeline"]["keyframes"]) >= 2 + assert payload["timeline"]["keyframes"][0]["at_ms"] == 0 + assert isinstance(payload["text"], dict) + assert isinstance(payload["framing"], dict) + assert isinstance(payload["scroll"], dict) + assert isinstance(payload["demo"], dict) + + +def test_generate_bundle_normalizes_palette_options(tmp_path: Path) -> None: + paths = _seed(tmp_path) + _seed_default_screen_payloads(paths) + _write( + paths.story_data_dir / "screens" / "SCENE_READY.json", + """ +{ + "id": "SCENE_READY", + "title": "Ready custom", + "effect": "reward", + "text": { + "show_title": "1", + "show_subtitle": "true", + "title_case": "lower", + "subtitle_align": "center" + }, + "framing": { + "preset": "split", + "x_offset": 120, + "scale_pct": 20 + }, + "scroll": { + "mode": "ticker", + "speed_ms": 120, + "pause_ms": 12000, + "loop": "0" + }, + "demo": { + "mode": "arcade", + "particle_count": 9, + "strobe_level": 180 + }, + "transition": { + "effect": "crossfade", + "duration_ms": 0 + } +} +""".strip(), + ) + + run_generate_bundle(paths) + + payload = json.loads((paths.bundle_root / "story" / "screens" / "SCENE_READY.json").read_text()) + assert payload["effect"] == "celebrate" + assert payload["text"]["show_title"] is True + assert payload["text"]["show_subtitle"] is True + assert payload["text"]["title_case"] == "lower" + assert payload["text"]["subtitle_align"] == "center" + assert payload["framing"]["preset"] == "split" + assert payload["framing"]["x_offset"] == 80 + assert payload["framing"]["scale_pct"] == 60 + assert payload["scroll"]["mode"] == "marquee" + assert payload["scroll"]["speed_ms"] == 600 + assert payload["scroll"]["pause_ms"] == 10000 + assert payload["scroll"]["loop"] is False + assert payload["demo"]["mode"] == "arcade" + assert payload["demo"]["particle_count"] == 4 + assert payload["demo"]["strobe_level"] == 100 + assert payload["transition"]["effect"] == "fade" + assert payload["transition"]["duration_ms"] > 0 diff --git a/lib/zacus_story_portable/data/audio/uson_1_radio_lowmono.mp3 b/lib/zacus_story_portable/data/audio/uson_1_radio_lowmono.mp3 new file mode 100644 index 0000000..c58cdfc Binary files /dev/null and b/lib/zacus_story_portable/data/audio/uson_1_radio_lowmono.mp3 differ diff --git a/lib/zacus_story_portable/data/audio/uson_2_mode_hero.mp3 b/lib/zacus_story_portable/data/audio/uson_2_mode_hero.mp3 new file mode 100644 index 0000000..8875ac3 Binary files /dev/null and b/lib/zacus_story_portable/data/audio/uson_2_mode_hero.mp3 differ diff --git a/lib/zacus_story_portable/data/audio/uson_boot_arcade_lowmono.mp3 b/lib/zacus_story_portable/data/audio/uson_boot_arcade_lowmono.mp3 new file mode 100644 index 0000000..59ffdd1 Binary files /dev/null and b/lib/zacus_story_portable/data/audio/uson_boot_arcade_lowmono.mp3 differ diff --git a/lib/zacus_story_portable/data/audio/uson_boot_corrompu_lowmono.mp3 b/lib/zacus_story_portable/data/audio/uson_boot_corrompu_lowmono.mp3 new file mode 100644 index 0000000..392bbb5 Binary files /dev/null and b/lib/zacus_story_portable/data/audio/uson_boot_corrompu_lowmono.mp3 differ diff --git a/lib/zacus_story_portable/data/audio/uson_win_1_rapport_imprime.mp3 b/lib/zacus_story_portable/data/audio/uson_win_1_rapport_imprime.mp3 new file mode 100644 index 0000000..b9d781f Binary files /dev/null and b/lib/zacus_story_portable/data/audio/uson_win_1_rapport_imprime.mp3 differ diff --git a/lib/zacus_story_portable/data/net/config.json b/lib/zacus_story_portable/data/net/config.json new file mode 100644 index 0000000..16cf0f9 --- /dev/null +++ b/lib/zacus_story_portable/data/net/config.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "hostname": "u-son-radio", + "sta": { + "ssid": "", + "pass": "" + }, + "ap": { + "enabled": true, + "ssid": "U-SON-RADIO", + "pass": "usonradio" + }, + "web": { + "port": 80, + "auth": false, + "user": "admin", + "pass": "usonradio" + } +} diff --git a/lib/zacus_story_portable/data/radio/stations.json b/lib/zacus_story_portable/data/radio/stations.json new file mode 100644 index 0000000..fe05879 --- /dev/null +++ b/lib/zacus_story_portable/data/radio/stations.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "stations": [ + { + "id": 1, + "name": "NOVA Radio", + "url": "http://novazz.ice.infomaniak.ch/novazz-128.mp3", + "codec": "MP3", + "favorite": true, + "enabled": true + }, + { + "id": 2, + "name": "FG Chic", + "url": "http://radiofg.impek.com/fg", + "codec": "MP3", + "favorite": false, + "enabled": true + }, + { + "id": 3, + "name": "SomaFM Groove", + "url": "http://ice1.somafm.com/groovesalad-128-mp3", + "codec": "MP3", + "favorite": false, + "enabled": true + } + ] +} diff --git a/lib/zacus_story_portable/data/story/screens/broken.json b/lib/zacus_story_portable/data/story/screens/broken.json new file mode 100644 index 0000000..63ee8ae --- /dev/null +++ b/lib/zacus_story_portable/data/story/screens/broken.json @@ -0,0 +1,6 @@ +{ + "id": "SCENE_BROKEN", + "content": { + "description": "Ecran de transition, syst\u00e8me \"cass\u00e9\"" + } +} \ No newline at end of file diff --git a/lib/zacus_story_portable/data/story/screens/la_detector.json b/lib/zacus_story_portable/data/story/screens/la_detector.json new file mode 100644 index 0000000..8cd91e5 --- /dev/null +++ b/lib/zacus_story_portable/data/story/screens/la_detector.json @@ -0,0 +1,6 @@ +{ + "id": "SCENE_LA_DETECTOR", + "content": { + "description": "Ecran de d\u00e9tection LA" + } +} diff --git a/lib/zacus_story_portable/data/story/screens/locked.json b/lib/zacus_story_portable/data/story/screens/locked.json new file mode 100644 index 0000000..0ef8b9e --- /dev/null +++ b/lib/zacus_story_portable/data/story/screens/locked.json @@ -0,0 +1,6 @@ +{ + "id": "SCENE_LOCKED", + "content": { + "description": "Ecran verrouill\u00e9, d\u00e9but du sc\u00e9nario" + } +} \ No newline at end of file diff --git a/lib/zacus_story_portable/data/story/screens/ready.json b/lib/zacus_story_portable/data/story/screens/ready.json new file mode 100644 index 0000000..77cda46 --- /dev/null +++ b/lib/zacus_story_portable/data/story/screens/ready.json @@ -0,0 +1,6 @@ +{ + "id": "SCENE_READY", + "content": { + "description": "Ecran pr\u00eat, fin du sc\u00e9nario" + } +} \ No newline at end of file diff --git a/lib/zacus_story_portable/data/story/screens/win.json b/lib/zacus_story_portable/data/story/screens/win.json new file mode 100644 index 0000000..a0d9128 --- /dev/null +++ b/lib/zacus_story_portable/data/story/screens/win.json @@ -0,0 +1,6 @@ +{ + "id": "SCENE_WIN", + "content": { + "description": "Ecran de victoire" + } +} \ No newline at end of file diff --git a/lib/zacus_story_portable/include/zacus_story_portable/story_portable_runtime.h b/lib/zacus_story_portable/include/zacus_story_portable/story_portable_runtime.h new file mode 100644 index 0000000..b456eeb --- /dev/null +++ b/lib/zacus_story_portable/include/zacus_story_portable/story_portable_runtime.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +class StoryControllerV2; +class StoryFsManager; +struct ScenarioDef; + +struct StoryPortableConfig { + const char* fsRoot = "/story"; + bool preferLittleFs = true; + bool allowGeneratedFallback = true; + bool strictFsOnly = false; +}; + +struct StoryPortableSnapshot { + bool initialized = false; + bool scenarioFromLittleFs = false; + bool running = false; + bool mp3GateOpen = true; + bool testMode = false; + const char* scenarioId = nullptr; + const char* stepId = nullptr; + const char* lastError = "OK"; + const char* runtimeState = "idle"; +}; + +struct StoryPortableCatalogEntry { + char id[32] = {}; + bool fromLittleFs = false; + bool fromGenerated = false; + uint16_t version = 0U; + uint32_t estimatedDurationS = 0U; +}; + +class StoryPortableCatalog { + public: + static bool listScenarios(StoryFsManager* fsManager, + StoryPortableCatalogEntry* out, + size_t maxCount, + size_t* outCount); +}; + +class StoryPortableAssets { + public: + static bool validateChecksum(StoryFsManager* fsManager, + const char* resourceType, + const char* resourceId); + static void listResources(StoryFsManager* fsManager, const char* resourceType); + static bool fsInfo(StoryFsManager* fsManager, + uint32_t* totalBytes, + uint32_t* usedBytes, + uint16_t* scenarioCount); +}; + +class StoryPortableRuntime { + public: + enum class RuntimeState : uint8_t { + kIdle = 0, + kRunning, + kError, + }; + + StoryPortableRuntime() = default; + + void configure(const StoryPortableConfig& config); + void bind(StoryControllerV2* controllerV2, StoryFsManager* fsManager); + + bool begin(uint32_t nowMs); + bool loadScenario(const char* scenarioId, uint32_t nowMs, const char* source); + bool setScenario(const char* scenarioId, uint32_t nowMs, const char* source); + void update(uint32_t nowMs); + void stop(uint32_t nowMs, const char* source); + + StoryPortableSnapshot snapshot(bool enabled, uint32_t nowMs) const; + const char* lastError() const; + bool scenarioFromLittleFs() const; + const char* stateLabel() const; + RuntimeState state() const; + void setState(RuntimeState state); + + void printScenarioList(const char* source) const; + bool validateActiveScenario(const char* source) const; + bool validateChecksum(const char* resourceType, const char* resourceId) const; + void listResources(const char* resourceType) const; + bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes, uint16_t* scenarioCount) const; + + StoryControllerV2* controllerV2() const; + StoryFsManager* fsManager() const; + + private: + bool tryLoadFromLittleFs(const char* scenarioId, uint32_t nowMs, const char* source); + void setError(const char* message); + void clearError(); + + StoryPortableConfig config_ = {}; + StoryControllerV2* controllerV2_ = nullptr; + StoryFsManager* fsManager_ = nullptr; + bool scenarioFromLittleFs_ = false; + char lastError_[48] = "OK"; + RuntimeState state_ = RuntimeState::kIdle; +}; diff --git a/lib/zacus_story_portable/include/zacus_story_portable/tinyfsm_core.h b/lib/zacus_story_portable/include/zacus_story_portable/tinyfsm_core.h new file mode 100644 index 0000000..8dfb790 --- /dev/null +++ b/lib/zacus_story_portable/include/zacus_story_portable/tinyfsm_core.h @@ -0,0 +1,61 @@ +#pragma once + +namespace zacus_story_portable { +namespace tinyfsm { + +struct Event { + enum class Kind : unsigned char { + kUnknown = 0, + kBegin, + kLoad, + kUpdate, + kStop, + }; + + explicit Event(Kind value) : kind(value) {} + virtual ~Event() = default; + Kind kind; +}; + +template +class State { + public: + virtual ~State() = default; + virtual const char* name() const = 0; + virtual void onEnter(Derived&) {} + virtual void onExit(Derived&) {} + virtual void onEvent(Derived&, const Event&) = 0; +}; + +template +class Machine { + public: + explicit Machine(Derived& owner, State& initial) + : owner_(owner), current_(&initial) { + current_->onEnter(owner_); + } + + void dispatch(const Event& event) { + current_->onEvent(owner_, event); + } + + void transition(State& next) { + if (&next == current_) { + return; + } + current_->onExit(owner_); + current_ = &next; + current_->onEnter(owner_); + } + + const State& current() const { + return *current_; + } + + private: + Derived& owner_; + State* current_; +}; + +} // namespace tinyfsm +} // namespace zacus_story_portable diff --git a/lib/zacus_story_portable/pense-bete.md b/lib/zacus_story_portable/pense-bete.md new file mode 100644 index 0000000..03c650f --- /dev/null +++ b/lib/zacus_story_portable/pense-bete.md @@ -0,0 +1,3 @@ +les events/validator +j'ai le hardware pour faire des tes qui l'esp32-S3 freenove media kit +les story.validate c'est quoi ? ça peut être la caméra du media kit et un QR Code ?, l'écoute d'un LA pendant 3seconde (en cumulé), une action clavier ?, ça peut être et l'écoute et une couleur sur l'esp cam ? \ No newline at end of file diff --git a/lib/zacus_story_portable/protocol/ui_link_v2.h b/lib/zacus_story_portable/protocol/ui_link_v2.h new file mode 100644 index 0000000..aef8210 --- /dev/null +++ b/lib/zacus_story_portable/protocol/ui_link_v2.h @@ -0,0 +1,350 @@ +#pragma once + +#include +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + UILINK_V2_PROTO = 2, + UILINK_V2_MAX_LINE = 320, + UILINK_V2_MAX_FIELDS = 40, + UILINK_V2_TYPE_MAX = 16, + UILINK_V2_KEY_MAX = 24, + UILINK_V2_VALUE_MAX = 96, + UILINK_V2_HEARTBEAT_MS = 1000, + UILINK_V2_TIMEOUT_MS = 1500, +}; + +typedef enum UiLinkMsgType { + UILINK_MSG_UNKNOWN = 0, + UILINK_MSG_HELLO, + UILINK_MSG_ACK, + UILINK_MSG_CAPS, + UILINK_MSG_STAT, + UILINK_MSG_KEYFRAME, + UILINK_MSG_BTN, + UILINK_MSG_TOUCH, + UILINK_MSG_CMD, + UILINK_MSG_PING, + UILINK_MSG_PONG, +} UiLinkMsgType; + +typedef enum UiBtnId { + UI_BTN_UNKNOWN = 0, + UI_BTN_OK, + UI_BTN_NEXT, + UI_BTN_PREV, + UI_BTN_BACK, + UI_BTN_VOL_UP, + UI_BTN_VOL_DOWN, + UI_BTN_MODE, +} UiBtnId; + +typedef enum UiBtnAction { + UI_BTN_ACTION_UNKNOWN = 0, + UI_BTN_ACTION_DOWN, + UI_BTN_ACTION_UP, + UI_BTN_ACTION_CLICK, + UI_BTN_ACTION_LONG, +} UiBtnAction; + +typedef enum UiTouchAction { + UI_TOUCH_ACTION_UNKNOWN = 0, + UI_TOUCH_ACTION_DOWN, + UI_TOUCH_ACTION_MOVE, + UI_TOUCH_ACTION_UP, +} UiTouchAction; + +typedef struct UiLinkField { + char key[UILINK_V2_KEY_MAX]; + char value[UILINK_V2_VALUE_MAX]; +} UiLinkField; + +typedef struct UiLinkFrame { + UiLinkMsgType type; + char type_token[UILINK_V2_TYPE_MAX]; + UiLinkField fields[UILINK_V2_MAX_FIELDS]; + uint8_t field_count; + bool has_crc; + uint8_t crc_expected; + uint8_t crc_computed; + bool crc_ok; +} UiLinkFrame; + +static inline uint8_t uiLinkCrc8(const uint8_t* data, size_t len) { + uint8_t crc = 0x00u; + if (data == NULL) { + return crc; + } + for (size_t i = 0; i < len; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0u; bit < 8u; ++bit) { + if ((crc & 0x80u) != 0u) { + crc = (uint8_t)((crc << 1u) ^ 0x07u); + } else { + crc <<= 1u; + } + } + } + return crc; +} + +static inline bool uiLinkIsHex(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +static inline uint8_t uiLinkHexValue(char c) { + if (c >= '0' && c <= '9') { + return (uint8_t)(c - '0'); + } + if (c >= 'a' && c <= 'f') { + return (uint8_t)(10 + (c - 'a')); + } + return (uint8_t)(10 + (c - 'A')); +} + +static inline bool uiLinkParseHexByte(const char* s, uint8_t* out) { + if (s == NULL || out == NULL) { + return false; + } + if (!uiLinkIsHex(s[0]) || !uiLinkIsHex(s[1])) { + return false; + } + *out = (uint8_t)((uiLinkHexValue(s[0]) << 4u) | uiLinkHexValue(s[1])); + return true; +} + +static inline UiLinkMsgType uiLinkMsgTypeFromToken(const char* token) { + if (token == NULL) { + return UILINK_MSG_UNKNOWN; + } + if (strcmp(token, "HELLO") == 0) return UILINK_MSG_HELLO; + if (strcmp(token, "ACK") == 0) return UILINK_MSG_ACK; + if (strcmp(token, "CAPS") == 0) return UILINK_MSG_CAPS; + if (strcmp(token, "STAT") == 0) return UILINK_MSG_STAT; + if (strcmp(token, "KEYFRAME") == 0) return UILINK_MSG_KEYFRAME; + if (strcmp(token, "BTN") == 0) return UILINK_MSG_BTN; + if (strcmp(token, "TOUCH") == 0) return UILINK_MSG_TOUCH; + if (strcmp(token, "CMD") == 0) return UILINK_MSG_CMD; + if (strcmp(token, "PING") == 0) return UILINK_MSG_PING; + if (strcmp(token, "PONG") == 0) return UILINK_MSG_PONG; + return UILINK_MSG_UNKNOWN; +} + +static inline UiBtnId uiBtnIdFromToken(const char* token) { + if (token == NULL) { + return UI_BTN_UNKNOWN; + } + if (strcmp(token, "OK") == 0) return UI_BTN_OK; + if (strcmp(token, "NEXT") == 0) return UI_BTN_NEXT; + if (strcmp(token, "PREV") == 0) return UI_BTN_PREV; + if (strcmp(token, "BACK") == 0) return UI_BTN_BACK; + if (strcmp(token, "VOL_UP") == 0) return UI_BTN_VOL_UP; + if (strcmp(token, "VOL_DOWN") == 0) return UI_BTN_VOL_DOWN; + if (strcmp(token, "MODE") == 0) return UI_BTN_MODE; + return UI_BTN_UNKNOWN; +} + +static inline UiBtnAction uiBtnActionFromToken(const char* token) { + if (token == NULL) { + return UI_BTN_ACTION_UNKNOWN; + } + if (strcmp(token, "down") == 0) return UI_BTN_ACTION_DOWN; + if (strcmp(token, "up") == 0) return UI_BTN_ACTION_UP; + if (strcmp(token, "click") == 0) return UI_BTN_ACTION_CLICK; + if (strcmp(token, "long") == 0) return UI_BTN_ACTION_LONG; + return UI_BTN_ACTION_UNKNOWN; +} + +static inline UiTouchAction uiTouchActionFromToken(const char* token) { + if (token == NULL) { + return UI_TOUCH_ACTION_UNKNOWN; + } + if (strcmp(token, "down") == 0) return UI_TOUCH_ACTION_DOWN; + if (strcmp(token, "move") == 0) return UI_TOUCH_ACTION_MOVE; + if (strcmp(token, "up") == 0) return UI_TOUCH_ACTION_UP; + return UI_TOUCH_ACTION_UNKNOWN; +} + +static inline const UiLinkField* uiLinkFindField(const UiLinkFrame* frame, const char* key) { + if (frame == NULL || key == NULL) { + return NULL; + } + for (uint8_t i = 0u; i < frame->field_count; ++i) { + if (strcmp(frame->fields[i].key, key) == 0) { + return &frame->fields[i]; + } + } + return NULL; +} + +static inline bool uiLinkCopyBounded(char* dst, size_t dst_len, const char* src, size_t src_len) { + if (dst == NULL || dst_len == 0u || src == NULL) { + return false; + } + if (src_len >= dst_len) { + return false; + } + memcpy(dst, src, src_len); + dst[src_len] = '\0'; + return true; +} + +static inline bool uiLinkParseLine(const char* line, UiLinkFrame* out) { + if (line == NULL || out == NULL) { + return false; + } + + memset(out, 0, sizeof(*out)); + + size_t line_len = strnlen(line, UILINK_V2_MAX_LINE + 1u); + if (line_len == 0u || line_len > UILINK_V2_MAX_LINE) { + return false; + } + + while (line_len > 0u && (line[line_len - 1u] == '\n' || line[line_len - 1u] == '\r')) { + --line_len; + } + if (line_len == 0u) { + return false; + } + + const char* line_end = line + line_len; + const char* star = static_cast(memchr(line, '*', line_len)); + size_t payload_len = line_len; + if (star != NULL) { + out->has_crc = true; + payload_len = static_cast(star - line); + const size_t crc_index = payload_len + 1u; + if (crc_index + 2u != line_len) { + return false; + } + if (!uiLinkParseHexByte(&line[crc_index], &out->crc_expected)) { + return false; + } + } + + if (payload_len == 0u || payload_len > UILINK_V2_MAX_LINE) { + return false; + } + + out->crc_computed = uiLinkCrc8(reinterpret_cast(line), payload_len); + out->crc_ok = (!out->has_crc) || (out->crc_expected == out->crc_computed); + if (out->has_crc && !out->crc_ok) { + return false; + } + + const char* payload_end = line + payload_len; + const char* cursor = line; + const char* comma = + static_cast(memchr(cursor, ',', static_cast(payload_end - cursor))); + if (comma == NULL) { + if (!uiLinkCopyBounded(out->type_token, sizeof(out->type_token), cursor, payload_len)) { + return false; + } + out->type = uiLinkMsgTypeFromToken(out->type_token); + return out->type != UILINK_MSG_UNKNOWN; + } + + const size_t type_len = static_cast(comma - cursor); + if (!uiLinkCopyBounded(out->type_token, sizeof(out->type_token), cursor, type_len)) { + return false; + } + out->type = uiLinkMsgTypeFromToken(out->type_token); + if (out->type == UILINK_MSG_UNKNOWN) { + return false; + } + + cursor = comma + 1u; + while (cursor < payload_end) { + if (out->field_count >= UILINK_V2_MAX_FIELDS) { + return false; + } + + const size_t remaining = static_cast(payload_end - cursor); + const char* next = static_cast(memchr(cursor, ',', remaining)); + const size_t token_len = (next == NULL) ? remaining : static_cast(next - cursor); + if (token_len == 0u) { + return false; + } + + const char* eq = static_cast(memchr(cursor, '=', token_len)); + if (eq == NULL) { + return false; + } + const size_t key_len = static_cast(eq - cursor); + if (key_len == 0u) { + return false; + } + if (token_len <= key_len + 1u) { + return false; + } + const size_t value_len = token_len - key_len - 1u; + + UiLinkField* field = &out->fields[out->field_count]; + if (!uiLinkCopyBounded(field->key, sizeof(field->key), cursor, key_len)) { + return false; + } + if (!uiLinkCopyBounded(field->value, sizeof(field->value), eq + 1u, value_len)) { + return false; + } + + ++out->field_count; + if (next == NULL) { + break; + } + cursor = next + 1u; + } + + return true; +} + +static inline size_t uiLinkBuildLine(char* out, + size_t out_size, + const char* type, + const UiLinkField* fields, + uint8_t field_count) { + if (out == NULL || out_size < 8u || type == NULL || type[0] == '\0') { + return 0u; + } + + size_t len = 0u; + const int type_len = snprintf(out, out_size, "%s", type); + if (type_len <= 0) { + return 0u; + } + len = (size_t)type_len; + + for (uint8_t i = 0u; i < field_count; ++i) { + if (fields == NULL || fields[i].key[0] == '\0') { + return 0u; + } + const int w = snprintf(out + len, + out_size - len, + ",%s=%s", + fields[i].key, + fields[i].value); + if (w <= 0 || (size_t)w >= (out_size - len)) { + return 0u; + } + len += (size_t)w; + } + + const uint8_t crc = uiLinkCrc8((const uint8_t*)out, len); + const int tail = snprintf(out + len, out_size - len, "*%02X\n", (unsigned int)crc); + if (tail <= 0 || (size_t)tail >= (out_size - len)) { + return 0u; + } + len += (size_t)tail; + return len; +} + +#ifdef __cplusplus +} +#endif diff --git a/lib/zacus_story_portable/protocol/ui_link_v2.md b/lib/zacus_story_portable/protocol/ui_link_v2.md new file mode 100644 index 0000000..8d16672 --- /dev/null +++ b/lib/zacus_story_portable/protocol/ui_link_v2.md @@ -0,0 +1,163 @@ +# UI Link v2 UART protocol + +This document defines the canonical UART link between ESP32 audio firmware and interchangeable UI firmware (ESP8266 OLED, RP2040 TFT). + +## 1. Transport + +- Physical: UART 3.3V TTL, full duplex. +- Default UART pins on ESP32: + - TX: GPIO22 + - RX: GPIO19 + - GND: common ground required +- Default baud: 57600 (configurable, both sides must match). +- Note: 57600 is 3x faster than 19200, stable on ESP8266 SoftwareSerial. +- Frame format (ASCII, line based): + +``` +,k=v,k=v*CC\n +``` + +Where: +- `` is an uppercase token (`HELLO`, `STAT`, etc.). +- `k=v` fields are comma separated key/value pairs. +- `CC` is CRC8 in uppercase hex, computed over bytes before `*`. +- CRC polynomial: `0x07`, init `0x00`, no reflection, no xorout. + +## 2. Versioning and compatibility + +- `proto=2` is mandatory in `HELLO`. +- Policy is additive-only: + - Adding optional fields/messages is allowed. + - Removing/renaming existing fields requires a deprecation cycle. +- Unknown fields must be ignored. +- Unknown message types must be ignored. + +## 3. Connection lifecycle (hot-swap) + +1. UI boots or is hot-plugged, sends `HELLO`. +2. ESP32 validates `proto=2` and replies `ACK`. +3. ESP32 sends `KEYFRAME` immediately after `ACK`. +4. ESP32 continues periodic `STAT` updates. +5. ESP32 sends `PING` every 1000 ms. +6. UI must answer `PONG`. +7. If no valid frame from UI for 1500 ms, ESP32 marks UI disconnected (headless mode) and waits for next `HELLO`. + +Any new `HELLO` from UI at runtime must force immediate `ACK` + `KEYFRAME` resync. + +## 4. Message catalog + +## 4.1 UI -> ESP32 + +### HELLO + +Required fields: +- `proto=2` + +Recommended fields: +- `ui_type=OLED|TFT` +- `ui_id=` +- `fw=` +- `caps=` + +Example: + +``` +HELLO,proto=2,ui_type=TFT,ui_id=rp2040-01,fw=1.0.0,caps=btn:1;touch:1;display:tft*7A +``` + +### CAPS (optional refresh) + +Used to refresh capabilities after boot. + +### BTN + +- `id=NEXT|PREV|OK|BACK|VOL_UP|VOL_DOWN|MODE` +- `action=down|up|click|long` +- `ts=` + +### TOUCH (optional raw touch) + +- `x=` +- `y=` +- `action=down|move|up` +- `ts=` + +### CMD (optional) + +- `op=request_keyframe|set_page|...` +- optional `arg=` + +### PONG + +- optional `ms=` + +## 4.2 ESP32 -> UI + +### ACK + +- `proto=2` +- `session=` +- optional `caps=` + +### KEYFRAME + +Complete UI state snapshot. Must include all currently relevant fields so UI can render from scratch after reconnect. + +### STAT + +Compact periodic telemetry update (delta-friendly). Must include sequence/time and mode fields. + +### PING + +- `ms=` + +## 5. State field conventions + +These fields are shared by `STAT` and `KEYFRAME` (subset allowed in `STAT`, full set required in `KEYFRAME`): + +- `seq` frame sequence +- `ms` esp uptime +- `mode` `SIGNAL|MP3|U_LOCK|STORY` +- `la` 0/1 +- `mp3` 0/1 +- `sd` 0/1 +- `key` last logical key (0..6) +- `track`, `track_total` +- `vol` 0..100 +- `tune_off` -8..8 +- `tune_conf` 0..100 +- `u_lock_listen` 0/1 +- `hold` 0..100 +- `startup` 0..1 (0=inactive, 1=boot_validation) +- `app` 0..3 (0=ulock_wait, 1=ulock_listen, 2=uson, 3=mp3) +- `ui_page` +- `ui_cursor`, `ui_offset`, `ui_count` +- `queue` +- `repeat` +- `fx` +- `backend` +- `scan` +- `err` + +Implementations may add optional fields. + +## 6. Timing defaults + +- ESP32 `STAT` period: 250 ms (or on meaningful change) +- ESP32 `KEYFRAME` period: 1000 ms and on (re)connect +- ESP32 `PING`: every 1000 ms +- UI disconnect timeout at ESP32: 1500 ms +- UI reconnect request timeout for downstream state: 1000..1500 ms recommended + +## 7. Error handling + +- Invalid CRC: drop frame. +- Malformed frame: drop frame. +- Missing required `HELLO` fields: ignore until a valid `HELLO`. +- BTN/TOUCH outside context: ignore and keep link alive. + +## 8. Reference implementation + +Portable helpers (CRC, parse, build, field lookup) are defined in: + +- `protocol/ui_link_v2.h` diff --git a/lib/zacus_story_portable/src/story_portable_runtime.cpp b/lib/zacus_story_portable/src/story_portable_runtime.cpp new file mode 100644 index 0000000..b2d2d3f --- /dev/null +++ b/lib/zacus_story_portable/src/story_portable_runtime.cpp @@ -0,0 +1,567 @@ +#include "zacus_story_portable/story_portable_runtime.h" + +#include +#include + +#include +#include + +#include "core/scenario_def.h" +#include "fs/story_fs_manager.h" +#include "generated/scenarios_gen.h" +#include "zacus_story_portable/tinyfsm_core.h" + +class StoryControllerV2 { + public: + struct StoryControllerV2Snapshot { + bool enabled = false; + bool running = false; + bool paused = false; + const char* scenarioId = nullptr; + const char* stepId = nullptr; + bool mp3GateOpen = true; + uint8_t queueDepth = 0U; + const char* appHostError = "OK"; + const char* engineError = "OK"; + uint32_t etape2DueMs = 0U; + bool testMode = false; + }; + + bool begin(uint32_t nowMs); + bool setScenario(const char* scenarioId, uint32_t nowMs, const char* source); + bool setScenarioFromDefinition(const ScenarioDef& scenario, uint32_t nowMs, const char* source); + void reset(uint32_t nowMs, const char* source); + void update(uint32_t nowMs); + bool jumpToStep(const char* stepId, uint32_t nowMs, const char* source); + bool postSerialEvent(const char* eventName, uint32_t nowMs, const char* source); + void printScenarioList(const char* source) const; + bool validateActiveScenario(const char* source) const; + StoryControllerV2Snapshot snapshot(bool enabled, uint32_t nowMs) const; + const char* lastError() const; +}; + +namespace { + +constexpr size_t kCatalogMax = 24U; + +void copyText(char* out, size_t outLen, const char* value) { + if (out == nullptr || outLen == 0U) { + return; + } + out[0] = '\0'; + if (value == nullptr || value[0] == '\0') { + return; + } + snprintf(out, outLen, "%s", value); +} + +bool isOkCode(const char* code) { + return code != nullptr && strcmp(code, "OK") == 0; +} + +class BeginEvent final : public zacus_story_portable::tinyfsm::Event { + public: + explicit BeginEvent(uint32_t now) : Event(Event::Kind::kBegin), nowMs(now) {} + const uint32_t nowMs; + mutable bool ok = false; +}; + +class LoadEvent final : public zacus_story_portable::tinyfsm::Event { + public: + LoadEvent(const char* id, uint32_t now, const char* src) + : Event(Event::Kind::kLoad), scenarioId(id), nowMs(now), source(src) {} + + const char* scenarioId; + const uint32_t nowMs; + const char* source; + mutable bool ok = false; +}; + +class UpdateEvent final : public zacus_story_portable::tinyfsm::Event { + public: + explicit UpdateEvent(uint32_t now) : Event(Event::Kind::kUpdate), nowMs(now) {} + const uint32_t nowMs; +}; + +class StopEvent final : public zacus_story_portable::tinyfsm::Event { + public: + StopEvent(uint32_t now, const char* src) + : Event(Event::Kind::kStop), nowMs(now), source(src) {} + const uint32_t nowMs; + const char* source; +}; + +} // namespace + +bool StoryPortableCatalog::listScenarios(StoryFsManager* fsManager, + StoryPortableCatalogEntry* out, + size_t maxCount, + size_t* outCount) { + if (outCount != nullptr) { + *outCount = 0U; + } + if (out == nullptr || maxCount == 0U) { + return false; + } + + size_t count = 0U; + auto addOrMerge = [&](const char* id, bool fromFs, bool fromGenerated, uint16_t version, uint32_t durationS) { + if (id == nullptr || id[0] == '\0') { + return; + } + for (size_t i = 0U; i < count; ++i) { + if (strcmp(out[i].id, id) == 0) { + out[i].fromLittleFs = out[i].fromLittleFs || fromFs; + out[i].fromGenerated = out[i].fromGenerated || fromGenerated; + if (fromFs) { + out[i].version = version; + out[i].estimatedDurationS = durationS; + } + return; + } + } + if (count >= maxCount) { + return; + } + copyText(out[count].id, sizeof(out[count].id), id); + out[count].fromLittleFs = fromFs; + out[count].fromGenerated = fromGenerated; + out[count].version = version; + out[count].estimatedDurationS = durationS; + ++count; + }; + + if (fsManager != nullptr) { + StoryScenarioInfo infos[kCatalogMax] = {}; + size_t fsCount = 0U; + if (fsManager->listScenarios(infos, kCatalogMax, &fsCount)) { + for (size_t i = 0U; i < fsCount; ++i) { + addOrMerge(infos[i].id, true, false, infos[i].version, infos[i].estimatedDurationS); + } + } + } + + const uint8_t generatedCount = generatedScenarioCount(); + for (uint8_t i = 0U; i < generatedCount; ++i) { + const char* id = generatedScenarioIdAt(i); + addOrMerge(id, false, true, 0U, 0U); + } + + if (outCount != nullptr) { + *outCount = count; + } + return count > 0U; +} + +bool StoryPortableAssets::validateChecksum(StoryFsManager* fsManager, + const char* resourceType, + const char* resourceId) { + if (fsManager == nullptr) { + return false; + } + return fsManager->validateChecksum(resourceType, resourceId); +} + +void StoryPortableAssets::listResources(StoryFsManager* fsManager, const char* resourceType) { + if (fsManager == nullptr) { + return; + } + fsManager->listResources(resourceType); +} + +bool StoryPortableAssets::fsInfo(StoryFsManager* fsManager, + uint32_t* totalBytes, + uint32_t* usedBytes, + uint16_t* scenarioCount) { + if (fsManager == nullptr) { + return false; + } + return fsManager->fsInfo(totalBytes, usedBytes, scenarioCount); +} + +namespace { + +class IdleState; +class RunningState; +class ErrorState; + +IdleState& idleState(); +RunningState& runningState(); +ErrorState& errorState(); + +class IdleState final : public zacus_story_portable::tinyfsm::State { + public: + const char* name() const override { + return "idle"; + } + + void onEvent(StoryPortableRuntime& runtime, + const zacus_story_portable::tinyfsm::Event& event) override; +}; + +class RunningState final : public zacus_story_portable::tinyfsm::State { + public: + const char* name() const override { + return "running"; + } + + void onEvent(StoryPortableRuntime& runtime, + const zacus_story_portable::tinyfsm::Event& event) override; +}; + +class ErrorState final : public zacus_story_portable::tinyfsm::State { + public: + const char* name() const override { + return "error"; + } + + void onEvent(StoryPortableRuntime& runtime, + const zacus_story_portable::tinyfsm::Event& event) override; +}; + +IdleState& idleState() { + static IdleState state; + return state; +} + +RunningState& runningState() { + static RunningState state; + return state; +} + +ErrorState& errorState() { + static ErrorState state; + return state; +} + +bool runtimeApplyScenario(StoryPortableRuntime& runtime, + const char* scenarioId, + uint32_t nowMs, + const char* source) { + return runtime.setScenario(scenarioId, nowMs, source); +} + +void moveState(StoryPortableRuntime& runtime, StoryPortableRuntime::RuntimeState state) { + runtime.setState(state); +} + +void IdleState::onEvent(StoryPortableRuntime& runtime, + const zacus_story_portable::tinyfsm::Event& event) { + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kBegin) { + const auto& begin = static_cast(event); + LoadEvent load("DEFAULT", begin.nowMs, "story_portable_begin"); + onEvent(runtime, load); + begin.ok = load.ok; + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kLoad) { + const auto& load = static_cast(event); + const bool ok = runtimeApplyScenario(runtime, load.scenarioId, load.nowMs, load.source); + load.ok = ok; + moveState(runtime, ok ? StoryPortableRuntime::RuntimeState::kRunning + : StoryPortableRuntime::RuntimeState::kError); + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kStop) { + moveState(runtime, StoryPortableRuntime::RuntimeState::kIdle); + } +} + +void RunningState::onEvent(StoryPortableRuntime& runtime, + const zacus_story_portable::tinyfsm::Event& event) { + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kLoad) { + const auto& load = static_cast(event); + const bool ok = runtimeApplyScenario(runtime, load.scenarioId, load.nowMs, load.source); + load.ok = ok; + moveState(runtime, ok ? StoryPortableRuntime::RuntimeState::kRunning + : StoryPortableRuntime::RuntimeState::kError); + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kUpdate) { + const auto& update = static_cast(event); + if (runtime.controllerV2() != nullptr) { + runtime.controllerV2()->update(update.nowMs); + } + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kStop) { + const auto& stop = static_cast(event); + if (runtime.controllerV2() != nullptr) { + runtime.controllerV2()->reset(stop.nowMs, stop.source); + } + moveState(runtime, StoryPortableRuntime::RuntimeState::kIdle); + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kBegin) { + auto& begin = static_cast(event); + begin.ok = true; + } +} + +void ErrorState::onEvent(StoryPortableRuntime& runtime, + const zacus_story_portable::tinyfsm::Event& event) { + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kLoad) { + const auto& load = static_cast(event); + const bool ok = runtimeApplyScenario(runtime, load.scenarioId, load.nowMs, load.source); + load.ok = ok; + moveState(runtime, ok ? StoryPortableRuntime::RuntimeState::kRunning + : StoryPortableRuntime::RuntimeState::kError); + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kBegin) { + auto& begin = static_cast(event); + LoadEvent load("DEFAULT", begin.nowMs, "story_portable_begin"); + onEvent(runtime, load); + begin.ok = load.ok; + return; + } + + if (event.kind == zacus_story_portable::tinyfsm::Event::Kind::kStop) { + const auto& stop = static_cast(event); + if (runtime.controllerV2() != nullptr) { + runtime.controllerV2()->reset(stop.nowMs, stop.source); + } + moveState(runtime, StoryPortableRuntime::RuntimeState::kIdle); + } +} + +} // namespace + +void StoryPortableRuntime::configure(const StoryPortableConfig& config) { + config_ = config; +} + +void StoryPortableRuntime::bind(StoryControllerV2* controllerV2, StoryFsManager* fsManager) { + controllerV2_ = controllerV2; + fsManager_ = fsManager; +} + +bool StoryPortableRuntime::begin(uint32_t nowMs) { + BeginEvent event(nowMs); + switch (state_) { + case RuntimeState::kIdle: + idleState().onEvent(*this, event); + break; + case RuntimeState::kRunning: + runningState().onEvent(*this, event); + break; + case RuntimeState::kError: + default: + errorState().onEvent(*this, event); + break; + } + return event.ok; +} + +bool StoryPortableRuntime::tryLoadFromLittleFs(const char* scenarioId, uint32_t nowMs, const char* source) { + if (fsManager_ == nullptr || controllerV2_ == nullptr) { + return false; + } + if (!fsManager_->loadScenario(scenarioId)) { + return false; + } + const ScenarioDef* scenario = fsManager_->scenario(); + if (scenario == nullptr) { + return false; + } + return controllerV2_->setScenarioFromDefinition(*scenario, nowMs, source); +} + +bool StoryPortableRuntime::loadScenario(const char* scenarioId, uint32_t nowMs, const char* source) { + return setScenario(scenarioId, nowMs, source); +} + +bool StoryPortableRuntime::setScenario(const char* scenarioId, uint32_t nowMs, const char* source) { + if (controllerV2_ == nullptr) { + setError("controller_missing"); + state_ = RuntimeState::kError; + return false; + } + + const char* requested = (scenarioId != nullptr && scenarioId[0] != '\0') ? scenarioId : "DEFAULT"; + + if (config_.preferLittleFs) { + if (tryLoadFromLittleFs(requested, nowMs, source)) { + scenarioFromLittleFs_ = true; + clearError(); + state_ = RuntimeState::kRunning; + return true; + } + if (config_.strictFsOnly) { + setError("littlefs_scenario_missing"); + state_ = RuntimeState::kError; + return false; + } + } + + if (!config_.allowGeneratedFallback && config_.preferLittleFs) { + setError("fallback_disabled"); + state_ = RuntimeState::kError; + return false; + } + + if (controllerV2_->setScenario(requested, nowMs, source)) { + scenarioFromLittleFs_ = false; + clearError(); + state_ = RuntimeState::kRunning; + return true; + } + + setError("scenario_not_found"); + state_ = RuntimeState::kError; + return false; +} + +void StoryPortableRuntime::update(uint32_t nowMs) { + UpdateEvent event(nowMs); + switch (state_) { + case RuntimeState::kIdle: + idleState().onEvent(*this, event); + break; + case RuntimeState::kRunning: + runningState().onEvent(*this, event); + break; + case RuntimeState::kError: + default: + errorState().onEvent(*this, event); + break; + } +} + +void StoryPortableRuntime::stop(uint32_t nowMs, const char* source) { + StopEvent event(nowMs, source); + switch (state_) { + case RuntimeState::kIdle: + idleState().onEvent(*this, event); + break; + case RuntimeState::kRunning: + runningState().onEvent(*this, event); + break; + case RuntimeState::kError: + default: + errorState().onEvent(*this, event); + break; + } + if (state_ == RuntimeState::kIdle) { + clearError(); + } +} + +StoryPortableSnapshot StoryPortableRuntime::snapshot(bool enabled, uint32_t nowMs) const { + StoryPortableSnapshot out = {}; + out.scenarioFromLittleFs = scenarioFromLittleFs_; + out.lastError = lastError_; + out.runtimeState = stateLabel(); + + if (controllerV2_ == nullptr) { + return out; + } + + const StoryControllerV2::StoryControllerV2Snapshot raw = controllerV2_->snapshot(enabled, nowMs); + out.initialized = raw.enabled || raw.running; + out.running = raw.running; + out.mp3GateOpen = raw.mp3GateOpen; + out.testMode = raw.testMode; + out.scenarioId = raw.scenarioId; + out.stepId = raw.stepId; + if (isOkCode(lastError_)) { + const char* runtimeError = controllerV2_->lastError(); + if (runtimeError != nullptr && runtimeError[0] != '\0') { + out.lastError = runtimeError; + } + } + return out; +} + +const char* StoryPortableRuntime::lastError() const { + return lastError_; +} + +bool StoryPortableRuntime::scenarioFromLittleFs() const { + return scenarioFromLittleFs_; +} + +const char* StoryPortableRuntime::stateLabel() const { + switch (state_) { + case RuntimeState::kIdle: + return "idle"; + case RuntimeState::kRunning: + return "running"; + case RuntimeState::kError: + default: + return "error"; + } +} + +StoryPortableRuntime::RuntimeState StoryPortableRuntime::state() const { + return state_; +} + +void StoryPortableRuntime::setState(RuntimeState state) { + state_ = state; +} + +void StoryPortableRuntime::printScenarioList(const char* source) const { + StoryPortableCatalogEntry entries[kCatalogMax] = {}; + size_t count = 0U; + if (!StoryPortableCatalog::listScenarios(fsManager_, entries, kCatalogMax, &count)) { + if (controllerV2_ != nullptr) { + controllerV2_->printScenarioList(source); + } + return; + } + + Serial.printf("[STORY_PORTABLE] LIST via=%s count=%u\n", + source != nullptr ? source : "-", + static_cast(count)); + for (size_t i = 0U; i < count; ++i) { + Serial.printf("[STORY_PORTABLE] LIST[%u] id=%s fs=%u gen=%u version=%u duration_s=%lu\n", + static_cast(i), + entries[i].id[0] != '\0' ? entries[i].id : "-", + entries[i].fromLittleFs ? 1U : 0U, + entries[i].fromGenerated ? 1U : 0U, + static_cast(entries[i].version), + static_cast(entries[i].estimatedDurationS)); + } +} + +bool StoryPortableRuntime::validateActiveScenario(const char* source) const { + if (controllerV2_ == nullptr) { + return false; + } + return controllerV2_->validateActiveScenario(source); +} + +bool StoryPortableRuntime::validateChecksum(const char* resourceType, const char* resourceId) const { + return StoryPortableAssets::validateChecksum(fsManager_, resourceType, resourceId); +} + +void StoryPortableRuntime::listResources(const char* resourceType) const { + StoryPortableAssets::listResources(fsManager_, resourceType); +} + +bool StoryPortableRuntime::fsInfo(uint32_t* totalBytes, uint32_t* usedBytes, uint16_t* scenarioCount) const { + return StoryPortableAssets::fsInfo(fsManager_, totalBytes, usedBytes, scenarioCount); +} + +StoryControllerV2* StoryPortableRuntime::controllerV2() const { + return controllerV2_; +} + +StoryFsManager* StoryPortableRuntime::fsManager() const { + return fsManager_; +} + +void StoryPortableRuntime::setError(const char* message) { + copyText(lastError_, sizeof(lastError_), message); +} + +void StoryPortableRuntime::clearError() { + copyText(lastError_, sizeof(lastError_), "OK"); +} diff --git a/lib/zacus_story_portable/story_generator/RELEASE_STORY_V2.md b/lib/zacus_story_portable/story_generator/RELEASE_STORY_V2.md new file mode 100644 index 0000000..b7216ab --- /dev/null +++ b/lib/zacus_story_portable/story_generator/RELEASE_STORY_V2.md @@ -0,0 +1,5 @@ +# Story V2 Release Handbook + +Ce document a ete deplace vers: + +- `docs/protocols/RELEASE_STORY_V2.md` diff --git a/lib/zacus_story_portable/story_generator/gen_split_scenario.py b/lib/zacus_story_portable/story_generator/gen_split_scenario.py new file mode 100644 index 0000000..ed1ab63 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/gen_split_scenario.py @@ -0,0 +1,41 @@ +import os +import sys +import yaml + +# Usage: python gen_split_scenario.py + +def main(): + if len(sys.argv) != 3: + print("Usage: python gen_split_scenario.py ") + sys.exit(1) + scenario_yaml = sys.argv[1] + output_dir = sys.argv[2] + with open(scenario_yaml, 'r') as f: + scenario = yaml.safe_load(f) + os.makedirs(output_dir, exist_ok=True) + + # Apps écran + app_ids = set() + for step in scenario.get('steps', []): + for app in step.get('apps', []): + app_ids.add(app) + for app in app_ids: + app_file = os.path.join(output_dir, f"app_{app.lower()}.yaml") + with open(app_file, 'w') as f: + f.write(f"id: {app}\nconfig:\n # TODO: compléter la config\n") + + # Scènes + scene_ids = set() + for step in scenario.get('steps', []): + scene = step.get('screen_scene_id') + if scene: + scene_ids.add(scene) + for scene in scene_ids: + scene_file = os.path.join(output_dir, f"scene_{scene.lower()}.yaml") + with open(scene_file, 'w') as f: + f.write(f"id: {scene}\ncontent:\n # TODO: compléter le contenu\n") + + print(f"Génération terminée dans {output_dir}") + +if __name__ == "__main__": + main() diff --git a/lib/zacus_story_portable/story_generator/story_specs/README.md b/lib/zacus_story_portable/story_generator/story_specs/README.md new file mode 100644 index 0000000..2fb3140 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/README.md @@ -0,0 +1,26 @@ + +# ⚠️ DEPRECATED — Voir la source de vérité + +> **[Mise à jour 2026]** +> +> **Tous les assets LittleFS (scénarios, écrans, scènes, audio, etc.) sont désormais centralisés dans le dossier `hardware/firmware/data/` à la racine du projet.** +> +> Ce dossier unique sert de source pour le flash LittleFS sur ESP32, ESP8266 et RP2040. Les anciens dossiers `data/` dans les sous-projets doivent être migrés/supprimés. + + +**TOUS les specs, prompts, templates et scénarios Story V2 sont centralisés dans :** + +``` +hardware/firmware/docs/protocols/story_specs/ +``` + +Ce dossier (`story_generator/story_specs/`) n'est qu'une **référence historique** et n'est **PLUS MAINTENU**. + +## Redirects + +- **Prompts authoring**: `../docs/protocols/story_specs/prompts/` +- **Schéma + templates**: `../docs/protocols/story_specs/schema/` et `templates/` +- **Scénarios exemples**: `../docs/protocols/story_specs/scenarios/` +- **Guide authoring**: `../docs/protocols/GENERER_UN_SCENARIO_STORY_V2.md` + +Merci de mettre à jour tous vos repères vers la source de vérité canonique dans `docs/protocols/`. diff --git a/lib/zacus_story_portable/story_generator/story_specs/prompts/spectre_radio_lab.prompt.md b/lib/zacus_story_portable/story_generator/story_specs/prompts/spectre_radio_lab.prompt.md new file mode 100644 index 0000000..9a16920 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/prompts/spectre_radio_lab.prompt.md @@ -0,0 +1,5 @@ +# Prompt Story — SPECTRE_RADIO_LAB + +Ce prompt est desormais maintenu ici: + +- `docs/protocols/story_specs/prompts/spectre_radio_lab.prompt.md` diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/README.md b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/README.md new file mode 100644 index 0000000..70fba9c --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/README.md @@ -0,0 +1,39 @@ +# Spécification du scénario DEFAULT + +Ce dossier contient les fichiers individuels pour chaque app écran et chaque scène du scénario par défaut. + +- Chaque app écran (ex : APP_SCREEN, APP_GATE, etc.) doit être définie dans un fichier séparé (ex : app_screen.yaml). +- Chaque scène (ex : SCENE_LOCKED, SCENE_BROKEN, etc.) doit être définie dans un fichier séparé (ex : scene_locked.yaml). +- Tous ces fichiers seront stockés sur le LittleFS, dans un dossier par scénario (ici : DEFAULT). + +## Structure attendue + +- scenarios/DEFAULT/app_screen.yaml +- scenarios/DEFAULT/app_gate.yaml +- scenarios/DEFAULT/app_audio.yaml +- scenarios/DEFAULT/scene_locked.yaml +- scenarios/DEFAULT/scene_broken.yaml +- scenarios/DEFAULT/scene_la_detector.yaml +- scenarios/DEFAULT/scene_test_lab.yaml +- scenarios/DEFAULT/scene_win.yaml +- scenarios/DEFAULT/scene_ready.yaml + +## Exemple de fichier app écran +```yaml +id: APP_SCREEN +config: + type: SCREEN_SCENE + ... +``` + +## Exemple de fichier scène +```yaml +id: SCENE_LOCKED +content: + ... +``` + +## À faire +- Déplacer la définition de chaque app écran et scène dans un fichier dédié. +- Adapter les scripts de génération pour prendre en compte cette nouvelle structure. +- S’assurer que le firmware charge les apps/écrans/scènes depuis LittleFS, dossier par scénario. diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_audio.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_audio.yaml new file mode 100644 index 0000000..f1268fa --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_audio.yaml @@ -0,0 +1,4 @@ +id: APP_AUDIO +config: + type: AUDIO_PACK + description: Gestion des sons et packs audio diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_gate.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_gate.yaml new file mode 100644 index 0000000..6641c66 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_gate.yaml @@ -0,0 +1,4 @@ +id: APP_GATE +config: + type: MP3_GATE + description: Contrôle du passage/fin de scénario diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_screen.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_screen.yaml new file mode 100644 index 0000000..126a0d2 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/app_screen.yaml @@ -0,0 +1,4 @@ +id: APP_SCREEN +config: + type: SCREEN_SCENE + description: Ecran principal du scénario par défaut diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_broken.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_broken.yaml new file mode 100644 index 0000000..71c110a --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_broken.yaml @@ -0,0 +1,3 @@ +id: SCENE_BROKEN +content: + description: Ecran de transition, système "cassé" diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect 2.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect 2.yaml new file mode 100644 index 0000000..d822641 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect 2.yaml @@ -0,0 +1,3 @@ +id: SCENE_LA_DETECT +content: + description: Ecran de détection LA diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect 3.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect 3.yaml new file mode 100644 index 0000000..d822641 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect 3.yaml @@ -0,0 +1,3 @@ +id: SCENE_LA_DETECT +content: + description: Ecran de détection LA diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect.yaml new file mode 100644 index 0000000..d822641 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detect.yaml @@ -0,0 +1,3 @@ +id: SCENE_LA_DETECT +content: + description: Ecran de détection LA diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detector.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detector.yaml new file mode 100644 index 0000000..72dc86b --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_la_detector.yaml @@ -0,0 +1,3 @@ +id: SCENE_LA_DETECTOR +content: + description: Ecran de détection LA diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_locked.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_locked.yaml new file mode 100644 index 0000000..2cd5600 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_locked.yaml @@ -0,0 +1,3 @@ +id: SCENE_LOCKED +content: + description: Ecran verrouillé, début du scénario diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_ready.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_ready.yaml new file mode 100644 index 0000000..07d601d --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_ready.yaml @@ -0,0 +1,3 @@ +id: SCENE_READY +content: + description: Ecran prêt, fin du scénario diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab 2.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab 2.yaml new file mode 100644 index 0000000..c9a980e --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab 2.yaml @@ -0,0 +1,3 @@ +id: SCENE_TEST_LAB +content: + description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab 3.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab 3.yaml new file mode 100644 index 0000000..c9a980e --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab 3.yaml @@ -0,0 +1,3 @@ +id: SCENE_TEST_LAB +content: + description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab.yaml new file mode 100644 index 0000000..c9a980e --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_test_lab.yaml @@ -0,0 +1,3 @@ +id: SCENE_TEST_LAB +content: + description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_win.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_win.yaml new file mode 100644 index 0000000..7b4dbf9 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/DEFAULT/scene_win.yaml @@ -0,0 +1,3 @@ +id: SCENE_WIN +content: + description: Ecran de victoire diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/default_unlock_win_etape2.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/default_unlock_win_etape2.yaml new file mode 100644 index 0000000..f4ceb69 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/default_unlock_win_etape2.yaml @@ -0,0 +1,3 @@ +# NOTE: This scenario has moved. +# Canonical file: +# docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/example_unlock_express.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/example_unlock_express.yaml new file mode 100644 index 0000000..0c1afef --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/example_unlock_express.yaml @@ -0,0 +1,3 @@ +# NOTE: This scenario has moved. +# Canonical file: +# docs/protocols/story_specs/scenarios/example_unlock_express.yaml diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/example_unlock_express_done.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/example_unlock_express_done.yaml new file mode 100644 index 0000000..f0014f2 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/example_unlock_express_done.yaml @@ -0,0 +1,3 @@ +# NOTE: This scenario has moved. +# Canonical file: +# docs/protocols/story_specs/scenarios/example_unlock_express_done.yaml diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/spectre_radio_lab.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/spectre_radio_lab.yaml new file mode 100644 index 0000000..c8f612d --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/spectre_radio_lab.yaml @@ -0,0 +1,3 @@ +# NOTE: This scenario has moved. +# Canonical file: +# docs/protocols/story_specs/scenarios/spectre_radio_lab.yaml diff --git a/lib/zacus_story_portable/story_generator/story_specs/scenarios/zacus_v1_unlock_and_etape2.yaml b/lib/zacus_story_portable/story_generator/story_specs/scenarios/zacus_v1_unlock_and_etape2.yaml new file mode 100644 index 0000000..3ed4f0c --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/scenarios/zacus_v1_unlock_and_etape2.yaml @@ -0,0 +1,3 @@ +# NOTE: This scenario has moved. +# Canonical file: +# docs/protocols/story_specs/scenarios/zacus_v1_unlock_and_etape2.yaml diff --git a/lib/zacus_story_portable/story_generator/story_specs/schema/story_spec_v1.yaml b/lib/zacus_story_portable/story_generator/story_specs/schema/story_spec_v1.yaml new file mode 100644 index 0000000..bbc2965 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/schema/story_spec_v1.yaml @@ -0,0 +1,3 @@ +# NOTE: This schema has moved. +# Canonical file: +# docs/protocols/story_specs/schema/story_spec_v1.yaml diff --git a/lib/zacus_story_portable/story_generator/story_specs/templates/scenario.template.yaml b/lib/zacus_story_portable/story_generator/story_specs/templates/scenario.template.yaml new file mode 100644 index 0000000..23503d4 --- /dev/null +++ b/lib/zacus_story_portable/story_generator/story_specs/templates/scenario.template.yaml @@ -0,0 +1,3 @@ +# NOTE: This template has moved. +# Canonical file: +# docs/protocols/story_specs/templates/scenario.template.yaml diff --git a/lib/zacus_story_portable/story_screen_smoke.py b/lib/zacus_story_portable/story_screen_smoke.py new file mode 100644 index 0000000..daf3960 --- /dev/null +++ b/lib/zacus_story_portable/story_screen_smoke.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Drive Story V3 via serial JSON-lines and validate screen sync activity.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from collections import OrderedDict + +try: + import serial +except ImportError: + print("pip install pyserial", file=sys.stderr) + sys.exit(2) + +SCENE_SYNC_RE = re.compile(r"\[SCREEN_SYNC\] seq=(\d+)") + + +class ScreenTransitionLog: + def __init__(self) -> None: + self.events = OrderedDict() + self.steps = set() + self.sync_seq = [] + self.last_seq = None + self.sync_transitions = 0 + + def record(self, ts: float, step: str | None, seq: int | None) -> None: + if step: + self.steps.add(step) + if seq is not None: + self.sync_seq.append(seq) + if self.last_seq is not None and seq != self.last_seq: + self.sync_transitions += 1 + self.last_seq = seq + self.events[ts] = (step or "-", "" if seq is None else str(seq)) + + def summary(self) -> dict[str, object]: + return { + "steps": sorted(self.steps), + "step_count": len(self.steps), + "sync_samples": len(self.sync_seq), + "sync_transitions": self.sync_transitions, + } + + +def send_cmd(ser, cmd: str, label: str = "") -> None: + if label: + print(f" [cmd] {label}: {cmd}") + ser.write((cmd + "\n").encode("utf-8")) + ser.flush() + time.sleep(0.25) + + +def send_json_cmd(ser, cmd: str, data: dict[str, object] | None = None, label: str = "") -> None: + payload = {"cmd": cmd} + if data: + payload["data"] = data + send_cmd(ser, json.dumps(payload, separators=(",", ":")), label or cmd) + + +def parse_json_line(line: str) -> tuple[str | None, str | None]: + if not line.startswith("{"): + return None, None + try: + payload = json.loads(line) + except json.JSONDecodeError: + return None, None + if not isinstance(payload, dict): + return None, None + data = payload.get("data") + if not isinstance(data, dict): + return None, None + step = data.get("step") + scenario = data.get("scenario") + if isinstance(step, str) and isinstance(scenario, str): + return scenario, step + if isinstance(step, str): + return None, step + return None, None + + +def collect_and_parse(ser, duration_s: float, log: ScreenTransitionLog, label: str = "") -> int: + deadline = time.time() + duration_s + start_time = time.time() + line_count = 0 + while time.time() < deadline: + raw = ser.readline() + if not raw: + continue + + line = raw.decode("utf-8", errors="ignore").strip() + if not line: + continue + line_count += 1 + + ts = time.time() - start_time + seq = None + step = None + + match = SCENE_SYNC_RE.search(line) + if match: + seq = int(match.group(1)) + + _, json_step = parse_json_line(line) + if json_step: + step = json_step + + if seq is not None or step is not None: + log.record(ts, step, seq) + + if "[SCREEN_SYNC]" in line or line.startswith("{"): + prefix = " [rx]" if label else "[rx]" + print(f"{prefix} {line[:120]}") + return line_count + + +def verify_ui_link(ser) -> bool: + print("[test] Verifying UI link (stabilization 2s)...") + time.sleep(2.0) + send_cmd(ser, "UI_LINK_STATUS", "check link") + deadline = time.time() + 5.0 + while time.time() < deadline: + raw = ser.readline() + if not raw: + continue + line = raw.decode("utf-8", errors="ignore").strip() + if not line: + continue + if "[UI_LINK_STATUS]" in line: + print(f" [rx] {line[:120]}") + if "connected=1" in line: + print(" [ok] UI link connected") + return True + print("[fail] UI link not connected", file=sys.stderr) + return False + + +def main() -> int: + parser = argparse.ArgumentParser(description="Story V3 screen sync validation.") + parser.add_argument("--port", required=True, help="Serial port (ESP32)") + parser.add_argument("--baud", type=int, default=115200, help="Serial baud") + parser.add_argument("--min-steps", type=int, default=1, help="Minimum distinct steps") + parser.add_argument("--min-sync-transitions", type=int, default=1, help="Minimum SCREEN_SYNC seq transitions") + args = parser.parse_args() + + log = ScreenTransitionLog() + try: + with serial.Serial(args.port, args.baud, timeout=0.5) as ser: + time.sleep(1.2) + ser.reset_input_buffer() + + if not verify_ui_link(ser): + return 3 + + print("[test] Enabling story test mode...") + send_cmd(ser, "STORY_TEST_ON", "test mode ON") + send_cmd(ser, "STORY_TEST_DELAY 1000", "test delay") + + print("[test] Initial story status...") + send_json_cmd(ser, "story.status", label="story.status") + collect_and_parse(ser, 2.0, log, "phase=init") + + print("[test] Arming story...") + send_cmd(ser, "STORY_ARM", "arm") + send_json_cmd(ser, "story.status", label="story.status") + collect_and_parse(ser, 2.0, log, "phase=armed") + + print("[test] Forcing ETAPE2 transition...") + send_cmd(ser, "STORY_FORCE_ETAPE2", "force etape2") + send_json_cmd(ser, "story.status", label="story.status") + collect_and_parse(ser, 3.0, log, "phase=etape2") + + print("[test] Final status...") + send_json_cmd(ser, "story.status", label="story.status") + send_json_cmd(ser, "story.validate", label="story.validate") + collect_and_parse(ser, 2.0, log, "phase=final") + + send_cmd(ser, "STORY_TEST_OFF", "test mode OFF") + except Exception as exc: + print(f"[error] serial failure: {exc}", file=sys.stderr) + return 2 + + summary = log.summary() + print("\n[summary]") + print(f" Steps observed: {summary['step_count']} -> {summary['steps']}") + print(f" SCREEN_SYNC samples: {summary['sync_samples']}") + print(f" SCREEN_SYNC transitions: {summary['sync_transitions']}") + + fail_reasons = [] + if int(summary["step_count"]) < args.min_steps: + fail_reasons.append(f"steps={summary['step_count']} < min={args.min_steps}") + if int(summary["sync_transitions"]) < args.min_sync_transitions: + fail_reasons.append( + f"sync_transitions={summary['sync_transitions']} < min={args.min_sync_transitions}" + ) + + if fail_reasons: + print(f"\n[fail] {'; '.join(fail_reasons)}", file=sys.stderr) + return 4 + + print("\n[ok] screen validation passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lib/zacus_story_portable/sync_ui_screens.sh b/lib/zacus_story_portable/sync_ui_screens.sh new file mode 100755 index 0000000..0ab9f66 --- /dev/null +++ b/lib/zacus_story_portable/sync_ui_screens.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Synchronise les fichiers JSON d'écrans vers le dossier LittleFS de l'UI RP2040 +# Usage: ./tools/dev/sync_ui_screens.sh + +set -euo pipefail +cd "$(dirname "$0")/../.." +source tools/dev/layout_paths.sh + +SRC="$(pwd)/artifacts/littlefs_build/screens" +UI_TFT_SRC_ROOT="$(fw_ui_tft_src)" +DST="$(cd "$UI_TFT_SRC_ROOT/.." && pwd)/data" + +if [ ! -d "$SRC" ]; then + echo "[FAIL] Source screens introuvable: $SRC" >&2 + exit 1 +fi +mkdir -p "$DST" +cp -v "$SRC"/*.json "$DST"/ +echo "[OK] Synchronisation des écrans UI terminée." diff --git a/lib/zacus_story_portable/test_4scenarios_all.py b/lib/zacus_story_portable/test_4scenarios_all.py new file mode 100755 index 0000000..5877bdc --- /dev/null +++ b/lib/zacus_story_portable/test_4scenarios_all.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +""" +Complete test harness: 4 scenarios + screen validation + disconnect/reconnect + 4h stability +Uses pyserial for direct hardware communication +""" + +import sys +import time +import argparse +import json +from collections import OrderedDict +from datetime import datetime +import re + +try: + import serial +except ImportError: + print("pip install pyserial", file=sys.stderr) + sys.exit(2) + +def ts(msg): + """Print with timestamp""" + now = datetime.now().strftime("%H:%M:%S") + print(f"[{now}] {msg}", flush=True) + +def open_serial(port, baud): + """Open serial port""" + try: + ser = serial.Serial(port, baud, timeout=2) + time.sleep(0.5) + ser.flushInput() + ser.flushOutput() + return ser + except Exception as e: + ts(f"✗ Cannot open {port}: {e}") + return None + +def send_cmd(ser, cmd, label=""): + """Send a serial command""" + try: + if label: + ts(f" [cmd] {label}: {cmd}") + ser.write((cmd + "\n").encode("utf-8")) + ser.flush() + time.sleep(0.3) + except Exception as e: + ts(f" ✗ Send failed: {e}") + +def collect_responses(ser, duration_s=2.0): + """Collect serial responses""" + responses = [] + deadline = time.time() + duration_s + while time.time() < deadline: + try: + raw = ser.readline() + if raw: + line = raw.decode("utf-8", errors="ignore").strip() + if line and (line.startswith("{") or '[SCREEN_SYNC]' in line or '[UI_LINK_STATUS]' in line): + responses.append(line) + except: + pass + return responses + +class ScreenLog: + """Track screen transitions""" + def __init__(self): + self.screens = set() + self.steps = set() + self.transitions = [] + self.last_screen = None + self.last_step = None + + def parse(self, line): + """Extract synthetic screen/step from JSON status or screen sync lines.""" + screen = None + step = None + if line.startswith("{"): + try: + payload = json.loads(line) + except Exception: + payload = {} + data = payload.get("data", {}) if isinstance(payload, dict) else {} + if isinstance(data, dict): + raw_step = data.get("step") + if isinstance(raw_step, str): + step = raw_step + m = re.search(r'\[SCREEN_SYNC\]\s+seq=(\d+)', line) + if m: + screen = f"SYNC_{m.group(1)}" + return screen, step + + def record(self, screen, step): + """Record a state""" + if screen: + self.screens.add(screen) + if self.last_screen and self.last_screen != screen: + self.transitions.append((self.last_screen, screen, step)) + self.last_screen = screen + if step: + self.steps.add(step) + self.last_step = step + + def summary(self): + """Return summary""" + return { + 'screens': sorted(self.screens), + 'steps': sorted(self.steps), + 'transitions': self.transitions, + 'pass': len(self.transitions) > 0 or len(self.steps) > 0 + } + +def verify_ui_link(ser): + """Verify UI link before test""" + ts("Verifying UI link (3 sec stabilization)...") + time.sleep(3.0) + send_cmd(ser, "UI_LINK_STATUS", "check link") + responses = collect_responses(ser, 2.0) + for resp in responses: + if 'connected=1' in resp: + ts(" ✓ UI link connected") + return True + ts(" ✗ UI link not connected (continuing anyway)") + return True # Don't block + +def test_scenario(ser, scenario_id, scenario_name): + """Test single scenario with screen validation""" + ts(f"\n{'='*70}") + ts(f"Testing scenario: {scenario_name} ({scenario_id})") + ts(f"{'='*70}") + + log = ScreenLog() + start = time.time() + + try: + # Setup + send_cmd(ser, "STORY_TEST_ON", "test mode ON") + send_cmd(ser, "STORY_TEST_DELAY 1000", "test delay 1s") + send_cmd(ser, json.dumps({"cmd": "story.load", "data": {"scenario": scenario_id}}, separators=(",", ":")), f"load {scenario_name}") + time.sleep(0.5) + + # Collect initial + ts("Initial state...") + send_cmd(ser, json.dumps({"cmd": "story.status"}, separators=(",", ":")), "get status") + for resp in collect_responses(ser, 1.0): + screen, step = log.parse(resp) + log.record(screen, step) + if screen: + ts(f" → {screen} @ {step}") + + # Arm and transition + ts("Arming and forcing transition...") + send_cmd(ser, "STORY_ARM", "arm story") + time.sleep(0.5) + send_cmd(ser, "STORY_FORCE_ETAPE2", "force etape2") + time.sleep(0.5) + + # Collect final + ts("Final state...") + send_cmd(ser, json.dumps({"cmd": "story.status"}, separators=(",", ":")), "get final status") + for resp in collect_responses(ser, 1.0): + screen, step = log.parse(resp) + log.record(screen, step) + if screen: + ts(f" → {screen} @ {step}") + + # Disable + send_cmd(ser, "STORY_TEST_OFF", "disable test") + + except Exception as e: + ts(f"✗ Error: {e}") + return False + + # Report + elapsed = time.time() - start + summary = log.summary() + + ts(f"\n[result] {scenario_name}") + ts(f" Screens: {summary['screens']}") + ts(f" Steps: {summary['steps']}") + ts(f" Transitions: {summary['transitions']}") + ts(f" Duration: {elapsed:.1f}s") + + if summary['pass']: + ts(f"✓ PASSED\n") + return True + else: + ts(f"✗ FAILED (no transitions)\n") + return False + +def test_disconnect(ser, scenario_id='DEFAULT'): + """Test disconnect/reconnect resilience""" + ts(f"\n{'='*70}") + ts("Testing disconnect/reconnect") + ts(f"{'='*70}") + + try: + ts("Starting scenario...") + send_cmd(ser, "STORY_TEST_ON", "test on") + send_cmd(ser, json.dumps({"cmd": "story.load", "data": {"scenario": scenario_id}}, separators=(",", ":")), "load") + send_cmd(ser, "STORY_ARM", "arm") + time.sleep(1.0) + + ts("Simulating 2s disconnect...") + time.sleep(2.0) + + ts("Checking reconnect...") + send_cmd(ser, json.dumps({"cmd": "story.status"}, separators=(",", ":")), "status after disconnect") + for resp in collect_responses(ser, 1.0): + if '"running":true' in resp: + ts(" ✓ Reconnected, story still running") + send_cmd(ser, "STORY_TEST_OFF", "disable") + return True + + ts(" ✗ Not running after disconnect") + return False + + except Exception as e: + ts(f"✗ Error: {e}") + return False + +def test_4h_stable(ser): + """Run 4 scenarios in loop for 4 hours""" + ts(f"\n{'='*70}") + ts("4-hour stability test") + ts(f"{'='*70}") + + scenarios = [ + ('DEFAULT', 'default_unlock_win_etape2'), + ('EXPRESS', 'example_unlock_express'), + ('EXPRESS_DONE', 'example_unlock_express_done'), + ('SPECTRE', 'spectre_radio_lab'), + ] + + start = time.time() + duration_sec = 4 * 3600 + iteration = 0 + passes = 0 + fails = 0 + + while (time.time() - start) < duration_sec: + iteration += 1 + elapsed_min = (time.time() - start) / 60.0 + remaining_min = (duration_sec - (time.time() - start)) / 60.0 + + for sid, sname in scenarios: + if (time.time() - start) >= duration_sec: + break + ts(f"\n[{elapsed_min:.0f}m/{remaining_min:.0f}m] Iter {iteration}: {sname}") + if test_scenario(ser, sid, sname): + passes += 1 + else: + fails += 1 + + elapsed_sec = time.time() - start + ts(f"\n4-hour test complete ({elapsed_sec/3600:.1f}h actual)") + ts(f" Total: {passes + fails}") + ts(f" Passed: {passes}") + ts(f" Failed: {fails}") + rate = 100 * passes / (passes + fails) if (passes + fails) > 0 else 0 + ts(f" Pass rate: {rate:.1f}%\n") + + return fails == 0 + +def main(): + parser = argparse.ArgumentParser(description='Test 4 scenarios + stability') + parser.add_argument('--port', required=True, help='Serial port') + parser.add_argument('--baud', type=int, default=115200, help='Baud rate') + parser.add_argument('--mode', choices=['quick', 'disconnect', '4h'], default='quick', + help='quick: 4 scenarios, disconnect: +reconnect, 4h: 4h loop') + args = parser.parse_args() + + ser = open_serial(args.port, args.baud) + if not ser: + ts("✗ Cannot open serial port") + sys.exit(1) + + verify_ui_link(ser) + + scenarios = [ + ('DEFAULT', 'default_unlock_win_etape2'), + ('EXPRESS', 'example_unlock_express'), + ('EXPRESS_DONE', 'example_unlock_express_done'), + ('SPECTRE', 'spectre_radio_lab'), + ] + + passes = 0 + fails = 0 + + # Run 4 scenarios + for sid, sname in scenarios: + if test_scenario(ser, sid, sname): + passes += 1 + else: + fails += 1 + + # Disconnect test + if args.mode in ['disconnect', '4h']: + if test_disconnect(ser): + passes += 1 + else: + fails += 1 + + # 4h stability + if args.mode == '4h': + if test_4h_stable(ser): + passes += 1 + else: + fails += 1 + + # Close + try: + ser.close() + except: + pass + + # Final summary + ts(f"\n{'='*70}") + ts("FINAL SUMMARY") + ts(f"{'='*70}") + ts(f"Mode: {args.mode}") + ts(f"Total: {passes + fails}") + ts(f"Passed: {passes}") + ts(f"Failed: {fails}") + rate = 100 * passes / (passes + fails) if (passes + fails) > 0 else 0 + ts(f"Success rate: {rate:.1f}%") + ts(f"{'='*70}\n") + + sys.exit(0 if fails == 0 else 1) + +if __name__ == '__main__': + main() diff --git a/lib/zacus_story_portable/test_4scenarios_complete.py b/lib/zacus_story_portable/test_4scenarios_complete.py new file mode 100755 index 0000000..7e285c8 --- /dev/null +++ b/lib/zacus_story_portable/test_4scenarios_complete.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Test harness: 4 scenarios + screen validation + disconnect/reconnect + 4h stability +Extends story_screen_smoke.py with multi-scenario testing +""" + +import sys +import time +import argparse +from pathlib import Path +from datetime import datetime, timedelta +from collections import OrderedDict +import re + +try: + import serial +except ImportError: + print("pip install pyserial", file=sys.stderr) + sys.exit(2) + +def timestamped(msg): + ts = datetime.now().strftime("%H:%M:%S") + print(f"[{ts}] {msg}") + +def open_serial(port, baud): + """Open serial port""" + try: + ser = serial.Serial(port, baud, timeout=2) + time.sleep(0.5) + ser.flushInput() + ser.flushOutput() + return ser + except Exception as e: + timestamped(f"✗ Cannot open {port}: {e}") + return None + +def send_cmd(ser, cmd, label=""): + """Send a serial command""" + if label: + timestamped(f" → {label}: {cmd}") + try: + ser.write((cmd + "\n").encode("utf-8")) + ser.flush() + time.sleep(0.25) + except Exception as e: + timestamped(f" ✗ Send failed: {e}") + +def collect_responses(ser, duration_s=2.0): + """Collect serial responses for duration_s""" + responses = [] + deadline = time.time() + duration_s + while time.time() < deadline: + try: + raw = ser.readline() + if raw: + line = raw.decode("utf-8", errors="ignore").strip() + if line: + responses.append(line) + except: + pass + return responses + +class ScreenTransitionLog: + """Track screen scene transitions""" + def __init__(self): + self.events = OrderedDict() + self.scenes = set() + self.steps = set() + self.transitions = [] + + def parse_status(self, line): + """Extract screen, step from STORY_V2 STATUS line""" + match = re.search(r'screen=(\w+)', line) + screen = match.group(1) if match else None + match = re.search(r'step=(\w+)', line) + step = match.group(1) if match else None + return screen, step + + def add_event(self, seq, screen, step): + """Record an event with scene/step""" + if screen and step: + key = (screen, step) + if key not in self.events: + self.events[key] = seq + self.scenes.add(screen) + self.steps.add(step) + # Check transition + if len(self.events) > 1: + prev_key = list(self.events.keys())[-2] + self.transitions.append((prev_key[0], screen, step)) + + def summary(self): + """Return test summary""" + return { + 'scenes': sorted(list(self.scenes)), + 'steps': sorted(list(self.steps)), + 'transitions': self.transitions, + 'pass': len(self.transitions) > 0 + } + +def main(): + parser = argparse.ArgumentParser(description='Test 4 scenarios + stability') + parser.add_argument('--port', required=True, help='Serial port (e.g., /dev/cu.SLAB_USBtoUART7)') + parser.add_argument('--baud', type=int, default=115200, help='Baud rate') + parser.add_argument('--mode', choices=['quick', 'disconnect', '4h'], default='quick', + help='Test mode: quick (4 scenarios), disconnect (+ reconnect test), 4h (stability)') + args = parser.parse_args() + + # Verify UI link first + if not verify_ui_link(args.port, args.baud): + sys.exit(1) + + scenarios = [ + ('DEFAULT', 'default_unlock_win_etape2'), + ('EXPRESS', 'example_unlock_express'), + ('EXPRESS_DONE', 'example_unlock_express_done'), + ('SPECTRE', 'spectre_radio_lab'), + ] + + passes = 0 + fails = 0 + + # Test all 4 scenarios + for scenario_id, scenario_name in scenarios: + if test_scenario(args.port, args.baud, scenario_id, scenario_name): + passes += 1 + else: + fails += 1 + + # Disconnect/reconnect test + if args.mode in ['disconnect', '4h']: + if test_disconnect_reconnect(args.port, args.baud): + passes += 1 + else: + fails += 1 + + # 4-hour stability test + if args.mode == '4h': + if test_stability_4h(args.port, args.baud): + passes += 1 + else: + fails += 1 + + # Final summary + timestamped(f"\n{'='*70}") + timestamped(f"FINAL SUMMARY") + timestamped(f"{'='*70}") + timestamped(f"Mode: {args.mode}") + timestamped(f"Total tests: {passes + fails}") + timestamped(f"Passed: {passes}") + timestamped(f"Failed: {fails}") + timestamped(f"Success rate: {100*passes/(passes+fails) if (passes+fails) > 0 else 0:.1f}%") + timestamped(f"{'='*70}\n") + + sys.exit(0 if fails == 0 else 1) + +if __name__ == '__main__': + main() diff --git a/lib/zacus_story_portable/test_story_4scenarios.py b/lib/zacus_story_portable/test_story_4scenarios.py new file mode 100644 index 0000000..ca7b000 --- /dev/null +++ b/lib/zacus_story_portable/test_story_4scenarios.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Run a 4-scenario Story serial loop using current Freenove serial commands.""" + +import argparse +import re +import sys +import time +from collections import OrderedDict +from datetime import datetime +from pathlib import Path + +try: + import serial +except ImportError: + print("pip install pyserial", file=sys.stderr) + sys.exit(2) + +SCENARIOS = OrderedDict( + [ + ("DEFAULT", "DEFAULT"), + ("EXPRESS", "EXAMPLE_UNLOCK_EXPRESS"), + ("EXPRESS_DONE", "EXEMPLE_UNLOCK_EXPRESS_DONE"), + ("SPECTRE", "SPECTRE_RADIO_LAB"), + ] +) + +STATUS_RE = re.compile( + r"\bSTATUS\b.*\bscenario=([A-Z0-9_]+)\b.*\bstep=([A-Z0-9_]+)\b.*\bscreen=([A-Z0-9_]+)\b" +) + + +def ts(msg: str) -> str: + now = datetime.now().strftime("%H:%M:%S") + return f"[{now}] {msg}" + + +def open_serial(port: str, baud: int) -> serial.Serial: + ser = serial.Serial(port, baud, timeout=1.0) + time.sleep(0.5) + ser.flushInput() + ser.flushOutput() + return ser + + +def send_cmd(ser: serial.Serial, cmd: str) -> None: + ser.write((cmd + "\n").encode("utf-8")) + ser.flush() + time.sleep(0.2) + + +def collect_lines(ser: serial.Serial, duration_s: float = 2.0) -> list[str]: + lines = [] + deadline = time.time() + duration_s + while time.time() < deadline: + raw = ser.readline() + if not raw: + continue + line = raw.decode("utf-8", errors="ignore").strip() + if line: + lines.append(line) + return lines + + +def wait_for_token(ser: serial.Serial, token: str, timeout_s: float = 2.0) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + for line in collect_lines(ser, 0.2): + if token in line: + return True + return False + + +def wait_for_sc_load_ack(ser: serial.Serial, scenario_id: str, timeout_s: float = 2.5) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + for line in collect_lines(ser, 0.2): + if f"ACK SC_LOAD id={scenario_id} ok=1" in line: + return True + if f"ACK SC_LOAD id={scenario_id} ok=0" in line or "ERR SC_LOAD" in line: + return False + return False + + +def fetch_status(ser: serial.Serial, timeout_s: float = 2.5) -> tuple[str, str, str] | None: + deadline = time.time() + timeout_s + while time.time() < deadline: + send_cmd(ser, "STATUS") + for line in collect_lines(ser, 0.6): + match = STATUS_RE.search(line) + if match: + scenario_id, step_id, screen_id = match.groups() + return scenario_id, step_id, screen_id + return None + + +def run_scenario(ser: serial.Serial, name: str, scenario_id: str, log) -> bool: + log.write(ts(f"Scenario {name} ({scenario_id})\n")) + send_cmd(ser, f"SC_LOAD {scenario_id}") + if not wait_for_sc_load_ack(ser, scenario_id, 3.0): + log.write(ts(f"FAIL load {scenario_id}\n")) + return False + + status = fetch_status(ser, timeout_s=3.0) + if status is None: + log.write(ts(f"FAIL status {scenario_id}\n")) + return False + status_scenario, status_step, status_screen = status + if status_scenario != scenario_id: + log.write(ts(f"FAIL status scenario mismatch expected={scenario_id} got={status_scenario}\n")) + return False + if status_screen in {"", "n/a"}: + log.write(ts(f"FAIL status screen invalid step={status_step} screen={status_screen}\n")) + return False + + log.write( + ts( + "STATUS " + f"scenario={status_scenario} step={status_step} screen={status_screen}" + "\n" + ) + ) + # Low-cost behavior check: BTN_NEXT should always be accepted as a serial event command. + send_cmd(ser, "SC_EVENT serial BTN_NEXT") + if not wait_for_token(ser, "ACK SC_EVENT", 2.0): + log.write(ts("FAIL SC_EVENT serial BTN_NEXT\n")) + return False + + log.write(ts("OK\n")) + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description="Story 4-scenario serial loop") + parser.add_argument("--port", required=True, help="Serial port (ESP32)") + parser.add_argument("--baud", type=int, default=115200, help="Serial baud") + parser.add_argument("--log", default="", help="Optional log path") + args = parser.parse_args() + + log_path = args.log + if not log_path: + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + log_path = f"artifacts/rc_live/test_4scenarios_{stamp}.log" + Path(log_path).parent.mkdir(parents=True, exist_ok=True) + + try: + ser = open_serial(args.port, args.baud) + except Exception as exc: + print(ts(f"ERROR cannot open serial: {exc}")) + return 2 + + ok = True + with open(log_path, "w", encoding="utf-8") as log: + send_cmd(ser, "RESET") + collect_lines(ser, 1.0) + for name, scenario_id in SCENARIOS.items(): + if not run_scenario(ser, name, scenario_id, log): + ok = False + break + + ser.close() + print(ts(f"Log: {log_path}")) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..c684fa8 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,288 @@ +# +# PlatformIO Project Configuration File (global) +# Permet de builder tous les firmwares depuis la racine du projet +# +[platformio] +default_envs = freenove_esp32s3_full_with_ui +src_dir = hardware/firmware +boards_dir = boards + +; ===================== ESP32 ===================== + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +board_build.partitions = no_ota.csv +board_build.filesystem = littlefs +build_src_filter = + -<*> + + + +<../libs/story/src/> + +<../libs/story/story_engine.cpp> + -<../libs/story/src/ui/mp3_ui_model_v2.cpp> +monitor_speed = 115200 +monitor_filters = time, esp32_exception_decoder +monitor_echo = yes +monitor_eol = LF +monitor_rts = 0 +monitor_dtr = 0 +lib_deps = + earlephilhower/ESP8266Audio@^1.9.7 + https://github.com/pschatzmann/arduino-audio-tools.git#v1.2.2 + sensorium/Mozzi@^2.0.2 + https://github.com/pschatzmann/arduino-audio-driver.git#84c7de2eb47efe43bcc769783bb1853ee8a32f21 + bblanchon/ArduinoJson@^6.21.5 + https://github.com/me-no-dev/AsyncTCP.git + https://github.com/me-no-dev/ESPAsyncWebServer.git +build_flags = + -I$PROJECT_DIR/protocol + -Ihardware/firmware/esp32_audio/src + -Ihardware/libs/story/src + -Ihardware/libs/story + -DCORE_DEBUG_LEVEL=1 + -DUSON_STORY_V2_DEFAULT=1 + -DUSON_TRACK_CATALOG_MAX_TRACKS=200 + +; ===================== freenove_esp32s3_full_with_ui ===================== + +[env:freenove_esp32s3_full_with_ui] +platform = espressif32@^6.5.0 +# Physical board: ESP32-S3-WROOM-1-N16R8 (16MB flash / 16MB PSRAM) +board = freenove_esp32_s3_wroom_n16r8 +framework = arduino +upload_port = /dev/cu.usbmodem5AB907* +monitor_port = /dev/cu.usbmodem5AB907* +board_build.flash_size = 16MB +board_upload.flash_size = 16MB +board_upload.maximum_size = 6291456 +board_build.partitions = partitions/freenove_esp32s3_app6mb_fs6mb.csv +board_build.filesystem = littlefs +build_src_filter = + -<*> + +<../ui_freenove_allinone/src/> + -<../ui_freenove_allinone/src/app/runtime_web_service?2.cpp> + -<../ui_freenove_allinone/src/ui/fonts/lv_font_ibmplexmono_bold_12?2.c> + -<../ui_freenove_allinone/src/ui/fonts/lv_font_ibmplexmono_italic_24?2.c> + -<../ui_freenove_allinone/src/ui/ui_manager_display?2.cpp> + -<../ui_freenove_allinone/src/ui/ui_manager_intro?2.cpp> + -<../ui_freenove_allinone/src/main.cpp> + -<../ui_freenove_allinone/src/scenario_manager.cpp> + -<../ui_freenove_allinone/src/ui_manager.cpp> + -<../ui_freenove_allinone/src/audio_manager.cpp> + -<../ui_freenove_allinone/src/storage_manager.cpp> + -<../ui_freenove_allinone/src/camera_manager.cpp> + -<../ui_freenove_allinone/src/button_manager.cpp> + -<../ui_freenove_allinone/src/touch_manager.cpp> + -<../ui_freenove_allinone/src/hardware_manager.cpp> + -<../ui_freenove_allinone/src/network_manager.cpp> + -<../ui_freenove_allinone/src/media_manager.cpp> +monitor_speed = 115200 +monitor_filters = time, esp32_exception_decoder +monitor_echo = yes +monitor_eol = LF +monitor_rts = 0 +monitor_dtr = 0 +lib_deps = + bodmer/TFT_eSPI@^2.5.43 + lovyan03/LovyanGFX@^1.2.7 + lvgl/lvgl@^8.3.11 + esphome/ESP32-audioI2S@^2.3.0 + bblanchon/ArduinoJson@^6.21.5 + adafruit/Adafruit NeoPixel@^1.12.3 + https://github.com/alvarowolfx/ESP32QRCodeReader.git +build_flags = + -I$PROJECT_DIR/protocol + -I$PROJECT_DIR/../ui_freenove_allinone/include + -I$PROJECT_DIR/hardware/libs/story/src + -I$PROJECT_DIR/hardware/libs/story + -O2 + -ffast-math + -DCORE_DEBUG_LEVEL=0 + -DFREENOVE_USE_PSRAM=1 + -DFREENOVE_PSRAM_UI_DRAW_BUFFER=1 + -DFREENOVE_PSRAM_CAMERA_FRAMEBUFFER=1 + -DZACUS_FW_VERSION=\"freenove_esp32s3\" + -DUSE_LVGL=1 + -DUSE_AUDIO=1 + -DUSE_CAM=1 + -DUSE_SD=1 + -DUI_COLOR_256=0 + -DUI_COLOR_565=1 + -DUI_FORCE_THEME_256=0 + -DUI_DRAW_BUF_LINES=16 + -DUI_DRAW_BUF_IN_PSRAM=1 + -DUI_AUDIO_RINGBUF_IN_PSRAM=1 + -DUI_CAMERA_FB_IN_PSRAM=1 + -DUI_DMA_TX_IN_DRAM=1 + -DUI_DMA_FLUSH_ASYNC=1 + -DUI_DMA_RGB332_ASYNC_EXPERIMENTAL=0 + -DUI_DMA_TRANS_BUF_LINES=16 + -DUI_ENABLE_SIMD_PATH=1 + -DUI_SIMD_EXPERIMENTAL=0 + -DUI_SIMD_USE_ESP_DSP=1 + -DUI_FULL_FRAME_BENCH=0 + -DUI_DEMO_AUTORUN_WIN_ETAPE=0 + -DUI_WIN_ETAPE_SIMPLIFIED=0 + -DUI_FX_BACKEND_LGFX=1 + -DUI_FX_DMA_BLIT=0 + -DUI_BOING_SHADOW_ASM=1 + -DUI_FX_SPRITE_W=160 + -DUI_FX_SPRITE_H=120 + -DUI_FX_TARGET_FPS=18 + -DFREENOVE_ENABLE_TASK_TOPOLOGY=0 + -DUI_LV_MEM_SIZE_KB=128 + -DUI_FONT_PIXEL_ENABLE=1 + -DUI_FONT_TITLE_XL_ENABLE=1 + -DUI_ENABLE_MEM_MONITOR=0 + -DUI_ENABLE_PERF_MONITOR=0 + -DLV_CONF_INCLUDE_SIMPLE=1 + -DFREENOVE_MEDIA_KIT + -include ui_freenove_config.h + -DTFT_RGB_ORDER=TFT_BGR + -DFREENOVE_LCD_VARIANT_FNK0102A=0 + -DFREENOVE_LCD_VARIANT_FNK0102B=0 + -DFREENOVE_LCD_VARIANT_FNK0102H=1 + -DFREENOVE_LCD_USE_HSPI=1 + -DFREENOVE_HAS_TOUCH=0 + +[env:freenove_esp32s3] +extends = env:freenove_esp32s3_full_with_ui + +[env:freenove_esp32s3_touch] +extends = env:freenove_esp32s3_full_with_ui +build_unflags = + -DFREENOVE_HAS_TOUCH=0 +build_flags = + ${env:freenove_esp32s3_full_with_ui.build_flags} + -DFREENOVE_HAS_TOUCH=1 + +[env:esp32_release] +extends = env:esp32dev +build_unflags = -DUSON_STORY_V2_DEFAULT=1 +build_flags = ${env:esp32dev.build_flags} -DUSON_STORY_V2_DEFAULT=0 + +; ===================== ui_rp2040_ili9488 ===================== + +[env:ui_rp2040_ili9488] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git +board = rpipico +framework = arduino +build_src_filter = + -<*> + + +monitor_speed = 115200 +board_build.filesystem = littlefs +lib_deps = + bodmer/TFT_eSPI@^2.5.43 + paulstoffregen/XPT2046_Touchscreen@0.0.0-alpha+sha.26b691b2c8 + bblanchon/ArduinoJson@^6.21.5 + lvgl/lvgl@^8.3.11 +build_flags = + -I$PROJECT_DIR/protocol + -Ihardware/firmware/ui/rp2040_tft/include + -DLV_CONF_INCLUDE_SIMPLE + -DUSER_SETUP_LOADED=1 + -DTFT_MISO=4 + -DTFT_MOSI=3 + -DTFT_SCLK=2 + -DTFT_CS=5 + -DTFT_DC=6 + -DTFT_RST=7 + -DTOUCH_CS=9 + -DSPI_FREQUENCY=40000000 + -DSPI_READ_FREQUENCY=20000000 + -DSPI_TOUCH_FREQUENCY=2500000 + -DSUPPORT_TRANSACTIONS + -DUI_LCD_SPI_HOST=0 + -DUI_TOUCH_IRQ_PIN=15 + -DUI_UART_RX_PIN=1 + -DUI_UART_TX_PIN=0 + -DUI_SERIAL_BAUD=57600 + -DUI_ROTATION=1 + -DILI9488_DRIVER + -DTFT_RGB_ORDER=TFT_BGR + + + +; ===================== (ui_rp2040_ili9486) ===================== + +[env:ui_rp2040_ili9486] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git +board = rpipico +framework = arduino +build_src_filter = + -<*> + + +monitor_speed = 115200 +board_build.filesystem = littlefs +lib_deps = + bodmer/TFT_eSPI@^2.5.43 + paulstoffregen/XPT2046_Touchscreen@0.0.0-alpha+sha.26b691b2c8 + bblanchon/ArduinoJson@^6.21.5 + lvgl/lvgl@^8.3.11 +build_flags = + -I$PROJECT_DIR/protocol + -Ihardware/firmware/ui/rp2040_tft/include + -DLV_CONF_INCLUDE_SIMPLE + -DUSER_SETUP_LOADED=1 + -DFREENOVE_MEDIA_KIT + -include ui_freenove_config.h + -DFREENOVE_LCD_WIDTH=480 + -DFREENOVE_LCD_HEIGHT=320 + -DFREENOVE_TFT_CS=5 + -DFREENOVE_TFT_DC=6 + -DFREENOVE_TFT_RST=7 + -DFREENOVE_TFT_MOSI=3 + -DFREENOVE_TFT_MISO=4 + -DFREENOVE_TFT_SCK=2 + -DFREENOVE_TOUCH_CS=9 + -DFREENOVE_TOUCH_IRQ=15 + -DFREENOVE_UART_TX=0 + -DFREENOVE_UART_RX=1 + -DFREENOVE_LCD_ROTATION=1 + -DSPI_FREQUENCY=40000000 + -DSPI_READ_FREQUENCY=20000000 + -DSPI_TOUCH_FREQUENCY=2500000 + -DSUPPORT_TRANSACTIONS + -DUI_LCD_SPI_HOST=0 + -DUI_SERIAL_BAUD=57600 + -DUI_ROTATION=1 + -DILI9488_DRIVER + -DTFT_RGB_ORDER=TFT_BGR + +; ===================== ESP8266 OLED ===================== + +[env:esp8266_oled] +platform = espressif8266 +board = nodemcuv2 +framework = arduino +build_src_filter = + -<*> + + +monitor_speed = 115200 +monitor_filters = time, colorize, esp8266_exception_decoder +monitor_echo = yes +monitor_eol = LF +monitor_rts = 0 +monitor_dtr = 0 +lib_deps = + adafruit/Adafruit SSD1306@^2.5.13 + adafruit/Adafruit GFX Library@^1.12.1 + plerup/EspSoftwareSerial@^8.2.0 + olikraus/U8g2@^2.36.2 + bblanchon/ArduinoJson@^6.21.5 +build_flags = + -Iprotocol + +# +# Pour builder tous les firmwares : +# pio run +# Pour builder un firmware spécifique : +# pio run -e +# Pour flasher/monitorer : +# pio run -e -t upload --upload-port +# pio device monitor -e --port +# +# Voir docs/protocols/ pour la documentation centralisée +# diff --git a/ui_freenove_allinone/AGENT_FUSION.md b/ui_freenove_allinone/AGENT_FUSION.md new file mode 100644 index 0000000..d78a45f --- /dev/null +++ b/ui_freenove_allinone/AGENT_FUSION.md @@ -0,0 +1,37 @@ + +# AGENT_FUSION.md – Firmware All-in-One Freenove + +## Objectif + +Fusionner les rôles audio, scénario, UI, stockage et gestion hardware dans un firmware unique pour le Media Kit Freenove, avec structure modulaire, traçabilité agent, et conformité aux specs suivantes : + + +### Plan d’intégration complète (couverture specs) + +- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/) ✔️ +- Navigation UI dynamique (LVGL, écrans générés depuis fichiers) ✔️ +- Exécution de scénarios (lecture, transitions, actions, audio) ✔️ +- Gestion audio (lecture/stop, mapping fichiers LittleFS) ✔️ +- Gestion boutons et tactile (événements, mapping, callbacks) ✔️ +- Fallback robuste si fichier manquant (scénario par défaut) ✔️ +- Génération de logs et artefacts (logs/, artifacts/) ✔️ +- Validation hardware sur Freenove (affichage, audio, boutons, tactile) ⏳ +- Documentation et onboarding synchronisés ⏳ + +### Structure modulaire + +- audio_manager.{h,cpp} : gestion audio +- scenario_manager.{h,cpp} : gestion scénario +- ui_manager.{h,cpp} : gestion UI dynamique (LVGL) +- button_manager.{h,cpp} : gestion boutons +- touch_manager.{h,cpp} : gestion tactile +- storage_manager.{h,cpp} : gestion LittleFS, fallback + +### Points critiques à valider + +- Robustesse du fallback LittleFS +- Synchronisation UI/scénario/audio +- Mapping dynamique boutons/tactile +- Logs d’évidence et artefacts produits + +Voir AGENT_TODO.md pour le suivi détaillé et la progression. diff --git a/ui_freenove_allinone/AGENT_TODO.md b/ui_freenove_allinone/AGENT_TODO.md new file mode 100644 index 0000000..fe58301 --- /dev/null +++ b/ui_freenove_allinone/AGENT_TODO.md @@ -0,0 +1,56 @@ +## Validation hardware – TODO détaillée + +- [ ] Compiler et flasher le firmware sur le Freenove Media Kit +- [ ] Préparer les fichiers de test sur LittleFS (/data/scene_*.json, /data/screen_*.json, /data/*.wav) +- [ ] Vérifier l’affichage TFT (boot, écran dynamique, transitions) +- [ ] Tester la réactivité tactile (zones, coordonnées, mapping) +- [ ] Tester les boutons physiques (appui, mapping, transitions) +- [ ] Tester la lecture audio (fichiers présents/absents, fallback) +- [ ] Observer les logs série (initialisation, actions, erreurs) +- [ ] Générer et archiver les logs d’évidence (logs/) +- [ ] Produire un artefact de validation (artifacts/) +- [ ] Documenter toute anomalie ou limitation hardware constatée + +## Revue finale – Checklist agents + +- [ ] Vérifier la cohérence de la structure (dossiers, modules, scripts) +- [ ] Relire AGENT_FUSION.md (objectifs, couverture specs, structure) +- [ ] Relire README.md (usage, build, validation, modules) +- [ ] Relire la section onboarding (docs/QUICKSTART.md, etc.) +- [ ] Vérifier la présence et la robustesse du fallback LittleFS +- [ ] Vérifier la traçabilité des logs et artefacts +- [ ] Vérifier la synchronisation UI/scénario/audio +- [ ] Vérifier la gestion dynamique des boutons/tactile +- [ ] Vérifier la non-régression sur les autres firmwares (split) + +# TODO Agent – Firmware All-in-One Freenove + + +## Plan d’intégration détaillé (COMPLÉTÉ) + +- [x] Vérifier la présence du scénario par défaut sur LittleFS +- [x] Charger la liste des fichiers de scènes et d’écrans (data/) +- [x] Initialiser la navigation UI (LVGL, écrans dynamiques) +- [x] Mapper les callbacks boutons/tactile vers la navigation UI +- [x] Préparer le fallback LittleFS si fichier manquant +- [x] Logger l’initialisation (logs/) + +- [x] Boucle principale d’intégration + - [x] Navigation UI (LVGL, écrans dynamiques) + - [x] Exécution scénario (lecture, actions, transitions) + - [x] Gestion audio (lecture, stop, files LittleFS) + - [x] Gestion boutons/tactile (événements, mapping) + - [x] Gestion stockage (LittleFS, fallback) + - [x] Logs/artefacts + +## Validation hardware (EN COURS) + +- [ ] Tests sur Freenove Media Kit (affichage, audio, boutons, tactile) +- [ ] Génération de logs d’évidence (logs/) +- [ ] Production d’artefacts de validation (artifacts/) + +## Documentation + +- [ ] Mise à jour README.md (usage, build, structure) +- [ ] Mise à jour AGENT_FUSION.md (règles d’intégration, conventions) +- [ ] Synchronisation avec la doc onboarding principale diff --git a/ui_freenove_allinone/RC_FINAL_BOARD.md b/ui_freenove_allinone/RC_FINAL_BOARD.md new file mode 100644 index 0000000..a2571b3 --- /dev/null +++ b/ui_freenove_allinone/RC_FINAL_BOARD.md @@ -0,0 +1,155 @@ +# Multiplexage des broches – Attention + +Cible matérielle : **ESP32-S3-WROOM-1-N16R8**. + +Certaines broches sont partagées entre plusieurs fonctions : +- GPIO 4 : utilisé pour le rétroéclairage (BL) et le bouton 3 (BTN3) +- GPIO 5 : utilisé pour le Chip Select TFT (CS) et le bouton 4 (BTN4) + +Ce multiplexage impose : +- De ne pas activer simultanément les deux fonctions sur une même broche +- De documenter explicitement ce partage dans le firmware et la doc +- De vérifier le comportement lors des tests (priorité, conflits) + +Recommandation : ajouter des commentaires dans ui_freenove_config.h et tester chaque usage séparément. +# Périphériques additionnels – Détail par fonction + +## I2C +- SDA : GPIO 8 +- SCL : GPIO 9 +- Usage : bus capteurs (ex : MPU6050, DHT11) +- Remarque : vérifier pull-up, adresser chaque périphérique + +## LED +- LED : GPIO 13 +- Usage : indicateur d’état, feedback utilisateur +- Remarque : pilotable en PWM + +## Buzzer +- Buzzer : GPIO 12 +- Usage : signal sonore, alerte +- Remarque : pilotable en PWM + +## DHT11 +- DHT11 : GPIO 14 +- Usage : capteur température/humidité +- Remarque : nécessite librairie DHT11, attention au timing + +## MPU6050 +- MPU6050 : I2C (SDA=8, SCL=9), adresse 0x68 +- Usage : capteur IMU (accéléro/gyro) +- Remarque : vérifier alimentation, pull-up I2C +# Synthèse technique & onboarding audio ESP32-S3 (Freenove) + +### Absence de DAC matériel et fallback I2S + +- **ESP32-S3 ne possède pas de DAC intégré** : toute sortie audio doit passer par l’interface I2S. +- **I2S (Inter-IC Sound)** : protocole série dédié à l’audio, géré par deux périphériques sur ESP32-S3. Permet d’envoyer des samples vers un DAC externe ou un ampli via filtre passe-bas. +- **Modes supportés** : Standard (Philips/MSB/PCM), PDM, TDM. Mode standard ou PDM recommandé. +- **API Espressif** : utiliser les drivers ESP-IDF (`driver/i2s_std.h`, `driver/i2s_pdm.h`). +- **Exemple minimal** : initialiser canal TX, configurer mode standard, activer canal, envoyer samples. +- **Recommandations** : + - Utiliser un DAC externe (ex : ES8311, PCM5102, ampli + filtre RC) + - Adapter le mapping GPIO selon le schéma Freenove + - Pour PDM, exploiter le convertisseur PCM2PDM matériel + - Consulter les exemples Espressif : [i2s_basic/i2s_std](https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s/i2s_basic/i2s_std), [i2s_codec/i2s_es8311](https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s/i2s_codec/i2s_es8311) + +### Onboarding : Marche à suivre pour l’audio ESP32-S3 + +1. **Vérifier le schéma hardware** : identifier les broches I2S (BCLK, WS, DOUT) et le DAC/ampli. +2. **Configurer le firmware** : adapter le mapping dans `ui_freenove_config.h` et `platformio.ini`. +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). + +### Liens techniques + +- [Espressif I2S API Reference](https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/api-reference/peripherals/i2s.html) +- [Exemples I2S Espressif](https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s) +- [Datasheet ESP32-S3](https://www.espressif.com/sites/default/files/documentation/esp32-s3_datasheet_en.pdf) +- [Arduino-ESP32 Documentation](https://docs.espressif.com/projects/arduino-esp32/en/latest/) + +--- + +## Synthèse audit et traçabilité (17/02/2026) + +- Audit de cohérence exécuté (artifacts/audit/firmware_20260217) : RESULT=PASS +- Mapping hardware, scripts, onboarding, traçabilité validés +- Correction audio ESP32-S3 : fallback I2S, conditional compilation, documentation +- Evidence : logs et artefacts non versionnés, paths/timestamps tracés dans AGENT_TODO.md et rapport d’audit +- Rapport et synthèse prêts à partager (voir artifacts/audit/firmware_20260217) + +--- + +## Mode ciblé et automatisation (2026) + +- Pour cibler un environnement spécifique (ex : Freenove ESP32-S3), utiliser la variable d’environnement : + - `ZACUS_ENV="freenove_esp32s3" ./tools/dev/run_matrix_and_smoke.sh` +- Ne pas passer d’argument en ligne de commande (option non reconnue) +- Le cockpit détecte automatiquement le hardware et propose le build/smoke ciblé +- Les logs et artefacts sont synchronisés avec AGENT_TODO.md pour traçabilité +- En cas d’échec UI link, consulter les logs et relancer le test (retry automatique en cours d’intégration) + +--- + +# Onboarding : sélection d’environnement + +- La sélection de l’environnement hardware (Freenove ESP32-S3, ESP32, ESP8266, etc.) se fait **exclusivement** via la variable d’environnement `ZACUS_ENV`. +- Ne jamais passer l’environnement en argument CLI : tous les scripts (build, flash, smoke) détectent la cible via `ZACUS_ENV`. +- Exemple : pour tester sur Freenove ESP32-S3 : + + ```bash + export ZACUS_ENV="freenove_esp32s3" + ./tools/dev/cockpit.sh smoke + ``` + +- Les scripts cockpit.sh, build_all.sh, run_matrix_and_smoke.sh, etc. utilisent cette variable pour déterminer la cible, le mapping hardware, et les artefacts. + +# 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. +- Toute évolution ou échec du protocole UI link est traçable dans les logs et la documentation. + +# cockpit.sh : automatisation smoke test + +- cockpit.sh détecte automatiquement la cible hardware via `ZACUS_ENV` et lance le smoke test adapté (Freenove, ESP32, ESP8266, etc.) sans intervention manuelle. +- En mode CI ou batch (`CI=true` ou `ZACUS_BATCH=1`), les prompts USB sont désactivés : la détection est automatique, le countdown est skip, aucun blocage. + +# Synchronisation evidence/logs + +- Chaque run (succès ou échec) synchronise l’évidence (artefacts, logs, verdict) dans `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. + +# Prompt USB : optimisation CI/batch + +- En mode CI ou batch, les prompts USB sont automatiquement désactivés : aucun countdown, aucune interaction requise. +- Les scripts détectent le mode batch et adaptent le comportement pour garantir la non-interactivité. + +--- + +# Mapping hardware – ESP32-S3 Freenove Media Kit + +| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques | +|------------------|-----------------|-----------------------|--------------------------------| +| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) | +| TFT MOSI | GPIO 21 | MOSI | SPI écran | +| TFT MISO | -1 | MISO | non utilisé | +| TFT CS | -1 | CS | câblage board intégré | +| TFT DC | GPIO 45 | DC | Data/Command écran | +| TFT RESET | GPIO 20 | RESET | Reset écran | +| TFT BL | GPIO 2 | BL | Rétroéclairage | +| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) | +| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel | +| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques | +| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 | +| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 | +| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 | +| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions | + +**Remarques** : +- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE ` puis `AUDIO_TEST`. +- Profils fournis: `0=sketch19`, `1=swap_bck_ws`, `2=dout2_alt`. +- Le tactile est désactivé par défaut (`FREENOVE_HAS_TOUCH=0`). diff --git a/ui_freenove_allinone/README.md b/ui_freenove_allinone/README.md new file mode 100644 index 0000000..d99c715 --- /dev/null +++ b/ui_freenove_allinone/README.md @@ -0,0 +1,357 @@ + +--- +# 🎛️ Zacus Firmware – Freenove All-in-One + +![Funk](https://media.giphy.com/media/3o7TKtnuHOHHUjR38Y/giphy.gif) + +--- + +## 📝 Description + +Bienvenue dans le cockpit le plus funky du multivers ! Ce firmware fusionne UI, audio, scénarios et hardware sur la carte Freenove Media Kit (ESP32). Ici, chaque pixel danse, chaque bouton groove, et chaque boot est une fête. + +> *"Si tu entends un son rétro ou vois un plasma violet, c’est normal. Si le microcontrôleur se met à rapper, c’est probablement un bug… ou un feature caché."* + +--- + +## 🕹️ Mapping hardware (ESP32-S3 Freenove Media Kit) + +| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques | +|------------------|-----------------|-----------------------|--------------------------------| +| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) | +| TFT MOSI | GPIO 21 | MOSI | SPI écran | +| TFT MISO | -1 | MISO | non utilisé | +| TFT CS | -1 | CS | câblage board intégré | +| TFT DC | GPIO 45 | DC | Data/Command écran | +| TFT RESET | GPIO 20 | RESET | Reset écran | +| TFT BL | GPIO 2 | BL | Rétroéclairage | +| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) | +| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel | +| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques | +| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 | +| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 | +| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 | +| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions | + +**Remarques** : +- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE ` puis `AUDIO_TEST`. +- Profils fournis: `0=sketch19`, `1=swap_bck_ws`, `2=dout2_alt`. +- Le tactile est désactivé par défaut (`FREENOVE_HAS_TOUCH=0`). + +--- + +## 📦 Contenu du dossier + +- Sources : `src/` (app, ui, audio, storage, camera, drivers, system) +- Polices : `src/ui/fonts/` +- Layouts d’écran : `src/ui/screens/` +- Partition custom : `partitions/freenove_esp32s3_app6mb_fs6mb.csv` +- Mapping hardware détaillé (voir pour descendre plus haut ) + +--- + +## 🚀 Installation & usage + +1. Clone ce repo, chausse tes lunettes de soleil. +2. Va dans `hardware/firmware` et build comme un DJ : + ```sh + pio run -e freenove_allinone + pio run -e freenove_allinone -t upload --upload-port + ``` +3. Pour la version ESP32-S3 (partition 6MB app / 6MB FS) : + ```sh + pio run -e freenove_esp32s3 + pio run -e freenove_esp32s3 -t buildfs + pio run -e freenove_esp32s3 -t uploadfs --upload-port + pio run -e freenove_esp32s3 -t upload --upload-port + ``` +4. Branche, allume, et laisse la magie opérer. + +--- + +## 🧩 Fonctionnalités qui groovent + +- Navigation UI dynamique (LVGL, écrans générés depuis fichiers) +- Exécution de scénarios (lecture, transitions, actions, audio) +- Gestion audio (lecture/stop, mapping fichiers LittleFS) +- Gestion boutons et tactile (événements, mapping, callbacks) +- Fallback robuste si fichier manquant (scénario par défaut) +- Génération de logs et artefacts (logs/, artifacts/) +- Validation hardware sur Freenove (affichage, audio, boutons, tactile) +- Documentation et onboarding synchronisés +- Mode autonome (pas besoin d’ESP32 séparé) + +> *"Si tu rates un bouton, c’est que tu danses trop fort. Si tu vois un plasma violet, c’est que tu es dans le groove."* + +--- + +## 🛠️ Modules principaux + +- `audio_manager` : fait swinguer l’audio (lecture, stop, état) +- `scenario_manager` : enchaîne les étapes comme un DJ +- `ui_manager` : LVGL, écrans dynamiques, FX visuels +- `storage_manager` : LittleFS (init, vérif, groove des assets) +- `button_manager` : boutons physiques, pour les vrais +- `touch_manager` : tactile XPT2046, pour les DJ du futur + +--- + +## 🦄 Funky extras & notes + +- Firmware expérimental : fusion audio + UI, mode "party hard" activé +- Pour la compatibilité UI Link, prévoir un mode optionnel +- Si tu veux une intro Amiga92, active la scène `SCENE_WIN_ETAPE` et laisse-toi porter par le FX timeline (plasma, starfield, boingball…) +- Pour tester la scène MP3 : `SCENE_MP3_PLAYER` (overlay AmigaAMP, scan `/music`) +- Pour la caméra : `SCENE_CAMERA_SCAN` (Win311 overlay, boutons = SNAP/SAVE/GALLERY/DELETE/CLOSE) +- Pour tout le reste, consulte les logs, et si tu comprends tout du premier coup, tu gagnes un badge "Zacus Funk Master". + +--- + +## 🤝 Contribuer + +Merci de lire [../../../../../../CONTRIBUTING.md](../../../../../../CONTRIBUTING.md) avant toute PR. Les pull requests avec punchlines sont encouragées. + +--- + +## 👤 Contact + +Pour toute question, suggestion, ou battle de funk, ouvre une issue GitHub ou contacte l’auteur principal : +- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare) + +> *"Ce firmware a été validé par un oscilloscope, un grille-pain, et un synthé vintage. Si tu trouves un bug, c’est peut-être un easter egg ou feat foirée."* +--- + +# Firmware Freenove Media Kit All-in-One + +## Plan d’intégration complète (couverture specs) + +- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/) +- Navigation UI dynamique (LVGL, écrans générés depuis fichiers) +- Exécution de scénarios (lecture, transitions, actions, audio) +- Gestion audio (lecture/stop, mapping fichiers LittleFS) +- Gestion boutons et tactile (événements, mapping, callbacks) +- Fallback robuste si fichier manquant (scénario par défaut) +- Génération de logs et artefacts (logs/, artifacts/) +- Validation hardware sur Freenove (affichage, audio, boutons, tactile) +- Documentation et onboarding synchronisés + +## Structure modulaire + +- `src/app/*` : orchestration runtime (`main`, `scenario_manager`, coordinator serie) +- `src/ui/*` : LVGL scenes, fonts, FX helpers (`ui_manager`, `ui_fonts`, `ui/fx`) +- `src/audio/*` : gestion audio runtime (`audio_manager`) +- `src/storage/*` : LittleFS/SD et resolution assets (`storage_manager`) +- `src/camera/*` : camera runtime (`camera_manager`) +- `src/drivers/*` : board I/O (input, board, display HAL + SPI bus manager) +- `src/system/*` : metrics, boot report, reseau/media wrappers, task topology +- headers legacy (`include/*_manager.h`) conserves en facades vers les headers modulaires + +Ce firmware combine : +- Les fonctions audio/scénario (type ESP32 Audio) +- L’UI locale (affichage, interaction, tactile, boutons) +- Le tout sur un seul microcontrôleur (RP2040 ou ESP32 selon le kit) + + +## Fonctionnalités +- Lecture audio, gestion scénario, LittleFS +- Affichage TFT tactile (LVGL) +- Boutons physiques, capteurs, extensions +- Mode autonome (pas besoin d’ESP32 séparé) + +## Modules principaux +- audio_manager : gestion audio (lecture, stop, état) +- scenario_manager : gestion scénario (étapes, transitions) +- ui_manager : gestion UI (LVGL, écrans dynamiques) +- storage_manager : gestion LittleFS (init, vérification) +- button_manager : gestion boutons physiques +- touch_manager : gestion tactile XPT2046 + +## Validation hardware +- Compiler et flasher sur le Freenove Media Kit +- Vérifier l’affichage, la réactivité tactile et boutons +- Tester la lecture audio (fichiers dans /data/) +- Consulter les logs série pour le suivi d’exécution + +## Artefacts +- Firmware compilé (.bin) +- Logs de test hardware (logs/) +- Rapport de compatibilité assets LittleFS + +## Build + +Depuis `hardware/firmware` : + +```sh +pio run -e freenove_allinone +pio run -e freenove_allinone -t upload --upload-port +``` + +## Build Freenove ESP32-S3 (partition 6MB app / 6MB FS) + +```sh +pio run -e freenove_esp32s3 +pio run -e freenove_esp32s3 -t buildfs +pio run -e freenove_esp32s3 -t uploadfs --upload-port +pio run -e freenove_esp32s3 -t upload --upload-port +``` + +- Partition custom active: `partitions/freenove_esp32s3_app6mb_fs6mb.csv`. +- Le bundle story complet n'est plus embarque dans le firmware: `buildfs/uploadfs` est requis pour charger le contenu complet (screens/audio/actions/apps). +- Fallback embarque minimal conserve: `APP_WIFI` + scenario `DEFAULT` minimal. + +## Norme embarquee (audit + refacto) + +- Standard projet: `docs/skills/EMBEDDED_CPP_OO_ESP32S3_PIO_ARDUINO.md`. +- Toute revue technique Freenove doit citer cette norme pour les risques temps reel, memoire, concurrence et style OO. + +## Provisioning Wi-Fi + Auth WebUI + +- Au boot sans credentials NVS: + - mode setup AP actif; + - endpoints setup ouverts: `GET /api/provision/status`, `POST /api/wifi/connect`, `POST /api/network/wifi/connect`. +- Hors setup: + - auth Bearer requise sur `/api/*`: + - `Authorization: Bearer `. +- Provisioning persistant: + - API: `POST /api/wifi/connect` (ou `/api/network/wifi/connect`) avec `persist=1`. + - Serie: `WIFI_PROVISION `. +- Rotation token: + - Serie: `AUTH_TOKEN_ROTATE [token]`. +- Outils shell: + - `tools/dev/healthcheck_wifi.sh` et `tools/dev/rtos_wifi_health.sh` supportent `ZACUS_WEB_TOKEN`. + +## LVGL graphics stack (ESP32-S3) + +- Le runtime Freenove (`env:freenove_esp32s3*`) supporte: + - `LV_COLOR_DEPTH=8` (RGB332) avec conversion RGB565 au flush. + - draw buffers lignes en double-buffer. + - flush DMA asynchrone (overlap draw/transfert) avec fallback sync. +- Flags principaux (`platformio.ini`): + - `UI_COLOR_256`, `UI_COLOR_565`, `UI_FORCE_THEME_256` + - `UI_DRAW_BUF_LINES`, `UI_DRAW_BUF_IN_PSRAM` + - `UI_DMA_FLUSH_ASYNC`, `UI_DMA_TRANS_BUF_LINES` + - `UI_FULL_FRAME_BENCH`, `UI_LV_MEM_SIZE_KB` +- Commandes debug serie: + - `UI_GFX_STATUS` + - `UI_MEM_STATUS` +- Documentation associee: + - `docs/ui/graphics_stack.md` + - `docs/ui/lvgl_memory_budget.md` + - `docs/ui/fonts_fr.md` + +## Mapping hardware +- Voir `include/ui_freenove_config.h` pour l’adaptation des pins +- Schéma de branchement : se référer à la doc Freenove + +## Notes +- Ce firmware est expérimental et fusionne les logiques audio + UI. +- Pour la compatibilité UI Link, prévoir un mode optionnel. + +## Intro Amiga92 (`SCENE_WIN_ETAPE`) + +- Activation: l'intro A/B/C est lancee automatiquement quand `screen_scene_id == SCENE_WIN_ETAPE`. +- Mode runtime: `FX_ONLY_V9` avec rendu timeline FX (plasma/starfield/rasterbars + tunnel3d/rotozoom/wirecube + boingball) et overlay LVGL conserve. +- Sequence timeline verrouillee: `A(30000ms) -> B(15000ms) -> C(20000ms) -> C loop`. +- Mapping presets (default): + - A: `demo` + - B: `winner` + - C: `boingball` +- Font scroller default: `italic`. +- BPM default: `125`. +- Boing shadow path: assembleur S3 active par defaut (`UI_BOING_SHADOW_ASM=1`) avec fallback C automatique. +- Log de boot FX: `boing_shadow_path=asm|c`. + +### Overrides runtime (TXT + JSON) + +- Priorite de lecture: + 1) `/ui/scene_win_etape.json` + 2) `/SCENE_WIN_ETAPE.json` + 3) `/ui/SCENE_WIN_ETAPE.json` + 4) `/ui/scene_win_etape.txt` +- Cles supportees: + - `A_MS`, `B_MS`, `C_MS` + - `FX_PRESET_A`, `FX_PRESET_B`, `FX_PRESET_C` (`demo|winner|fireworks|boingball`) + - `FX_MODE_A`, `FX_MODE_B`, `FX_MODE_C` (`classic|starfield3d|dotsphere3d|voxel|raycorridor`) + - `FX_SCROLL_TEXT_A`, `FX_SCROLL_TEXT_B`, `FX_SCROLL_TEXT_C` + - `FX_SCROLL_FONT` (`basic|bold|outline|italic`) + - `FX_BPM` (`60..220`) +- Rupture de compatibilite volontaire: + - anciennes cles FX `FX_3D`, `FX_3D_QUALITY`, `FONT_MODE` ne sont plus prises en charge dans ce flux. + +Exemples: +- JSON: `data/SCENE_WIN_ETAPE.json` +- TXT: `data/ui/scene_win_etape.txt` + +### Perf notes + +- Tick fixe: `42 ms` (`~24 FPS` cible), `dt` clamp pour robustesse. +- Zero allocation par frame dans la boucle intro (`tickIntro`). +- Blit LGFX: fast-path 2x active (`UI_FX_BLIT_FAST_2X=1`) si ratio source/ecran exact, fallback scaler general sinon. +- Caps objets scene: `<=140` (petit ecran), `<=260` (grand ecran). +- Pools fixes: + - stars: `48` + - fireworks: `72` + - wave glyph+shadow: `64 + 64` +- `UI_GFX_STATUS` expose `fx_fps/fx_frames/fx_skip_busy` + compteurs `flush_block/overflow` pour diagnostiquer les saccades LVGL/FX. + +## Scenes demoscene exposees + +- IDs canoniques ajoutes: `SCENE_WINNER`, `SCENE_FIREWORKS`. +- Fichiers data: + - `data/story/screens/SCENE_WINNER.json` + - `data/story/screens/SCENE_FIREWORKS.json` +- Registry story: `hardware/libs/story/src/resources/screen_scene_registry.cpp`. + +## Scene lecteur MP3 (`SCENE_MP3_PLAYER`) + +- Scene canonique: `SCENE_MP3_PLAYER`. +- Aliases supportes: `SCENE_AUDIO_PLAYER`, `SCENE_MP3`. +- Data scene: `data/story/screens/SCENE_MP3_PLAYER.json`. +- Mode runtime: overlay LVGL "AmigaAMP" + backend `AudioPlayerService` (scan `/music`, fallback `/audio/music`, fallback `/audio`). +- Arbitrage audio: + - en scene MP3, l'audio scenario est suspendu; + - en sortie de scene MP3, pipeline audio scenario restaure. +- Commandes serie: + - `AMP_SHOW`, `AMP_HIDE`, `AMP_TOGGLE` + - `AMP_SCAN`, `AMP_PLAY `, `AMP_NEXT`, `AMP_PREV`, `AMP_STOP`, `AMP_STATUS` + +## Scene camera recorder (`SCENE_CAMERA_SCAN`) + +- Binding scene: entree `SCENE_CAMERA_SCAN` -> session recorder camera active (`RGB565/QVGA`) + overlay Win311 visible. +- Sortie scene: overlay masque, frame gelee purgee, retour mode snapshot legacy (`JPEG`). +- Mapping boutons physiques: + - `BTN1`: `SNAP/LIVE` + - `BTN2`: `SAVE` + - `BTN3`: `GALLERY` (appui long: `NEXT`) + - `BTN4`: `DELETE` + - `BTN5`: `CLOSE` overlay +- Commandes serie: + - `CAM_UI_SHOW`, `CAM_UI_HIDE`, `CAM_UI_TOGGLE` + - `CAM_REC_SNAP`, `CAM_REC_SAVE [auto|bmp|jpg|raw]` + - `CAM_REC_GALLERY`, `CAM_REC_NEXT`, `CAM_REC_DELETE`, `CAM_REC_STATUS` +- Ownership runtime: + - pendant `SCENE_CAMERA_SCAN`, les boutons physiques ne sont pas forwardes au scenario; + - commandes legacy `CAM_ON/CAM_OFF/CAM_SNAPSHOT` renvoient `camera_busy_recorder_owner`. + +### Visual verification mode + +- Build flags (env `freenove_esp32s3`) pour tests scene: + - `UI_FULL_FRAME_BENCH=1` + - `UI_DEMO_AUTORUN_WIN_ETAPE=1` +- Quand ces flags sont actifs: + - la scene `SCENE_WIN_ETAPE` demarre automatiquement au boot; + - la phase C continue en boucle tant que la scene reste active; + - `UI_GFX_STATUS` permet de verifier le mode runtime (depth/full_frame/source). + +### References consulted + +- https://www.pouet.net/prodlist.php?type%5B0%5D=cracktro +- https://www.youtube.com/results?search_query=amiga+cracktro +- https://www.youtube.com/results?search_query=amiga+demoscene+1992 +- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/hard_2.html +- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/ +- https://www.markwrobel.dk/project/amigamachinecode/ +- https://www.markwrobel.dk/post/amiga-machine-code-letter3-copper-revisited/ +- https://www.markwrobel.dk/post/amiga-machine-code-letter12-starfield-effect/ +- https://www.markwrobel.dk/post/amiga-machine-code-letter12-wave/ +- https://github.com/mrandreastoth/AmigaStyleDemo diff --git a/ui_freenove_allinone/include/README.md b/ui_freenove_allinone/include/README.md new file mode 100644 index 0000000..b7c2dc9 --- /dev/null +++ b/ui_freenove_allinone/include/README.md @@ -0,0 +1,4 @@ +# include/ +Headers pour le firmware Freenove all-in-one (fusion audio + UI). +- ui_freenove_config.h : mapping hardware +- Ajouter ici les headers fusionnés (audio, scenario, UI, etc.) diff --git a/ui_freenove_allinone/include/app/app_coordinator.h b/ui_freenove_allinone/include/app/app_coordinator.h new file mode 100644 index 0000000..df973bf --- /dev/null +++ b/ui_freenove_allinone/include/app/app_coordinator.h @@ -0,0 +1,18 @@ +// app_coordinator.h - runtime orchestration facade for setup/loop. +#pragma once + +#include + +#include "runtime/runtime_services.h" +#include "app/serial_command_router.h" + +class AppCoordinator { + public: + bool begin(RuntimeServices* services); + void tick(uint32_t now_ms); + void onSerialLine(const char* command_line, uint32_t now_ms); + + private: + RuntimeServices* services_ = nullptr; + SerialCommandRouter serial_router_; +}; diff --git a/ui_freenove_allinone/include/app/runtime_scene_service.h b/ui_freenove_allinone/include/app/runtime_scene_service.h new file mode 100644 index 0000000..9a1022e --- /dev/null +++ b/ui_freenove_allinone/include/app/runtime_scene_service.h @@ -0,0 +1,18 @@ +// runtime_scene_service.h - scene refresh + audio kick bridge. +#pragma once + +#include + +class RuntimeSceneService { + public: + using RefreshSceneFn = void (*)(bool force_render); + using StartPendingAudioFn = void (*)(); + + void configure(RefreshSceneFn refresh_scene, StartPendingAudioFn start_pending_audio); + void refreshSceneIfNeeded(bool force_render) const; + void startPendingAudioIfAny() const; + + private: + RefreshSceneFn refresh_scene_ = nullptr; + StartPendingAudioFn start_pending_audio_ = nullptr; +}; diff --git a/ui_freenove_allinone/include/app/runtime_serial_service.h b/ui_freenove_allinone/include/app/runtime_serial_service.h new file mode 100644 index 0000000..a58a82a --- /dev/null +++ b/ui_freenove_allinone/include/app/runtime_serial_service.h @@ -0,0 +1,18 @@ +// runtime_serial_service.h - dispatch bridge for serial + control actions. +#pragma once + +#include + +class RuntimeSerialService { + public: + using HandleSerialCommandFn = void (*)(const char* command_line, uint32_t now_ms); + using DispatchControlActionFn = bool (*)(const String& action_raw, uint32_t now_ms, String* out_error); + + void configure(HandleSerialCommandFn handle_serial_command, DispatchControlActionFn dispatch_control_action); + void handleSerialCommand(const char* command_line, uint32_t now_ms) const; + bool dispatchControlAction(const String& action_raw, uint32_t now_ms, String* out_error) const; + + private: + HandleSerialCommandFn handle_serial_command_ = nullptr; + DispatchControlActionFn dispatch_control_action_ = nullptr; +}; diff --git a/ui_freenove_allinone/include/app/runtime_web_service 2.h b/ui_freenove_allinone/include/app/runtime_web_service 2.h new file mode 100644 index 0000000..98575c5 --- /dev/null +++ b/ui_freenove_allinone/include/app/runtime_web_service 2.h @@ -0,0 +1,13 @@ +// runtime_web_service.h - Web UI route setup bridge. +#pragma once + +class RuntimeWebService { + public: + using SetupWebUiFn = void (*)(); + + void configure(SetupWebUiFn setup_web_ui); + void setupWebUi() const; + + private: + SetupWebUiFn setup_web_ui_ = nullptr; +}; diff --git a/ui_freenove_allinone/include/app/runtime_web_service.h b/ui_freenove_allinone/include/app/runtime_web_service.h new file mode 100644 index 0000000..98575c5 --- /dev/null +++ b/ui_freenove_allinone/include/app/runtime_web_service.h @@ -0,0 +1,13 @@ +// runtime_web_service.h - Web UI route setup bridge. +#pragma once + +class RuntimeWebService { + public: + using SetupWebUiFn = void (*)(); + + void configure(SetupWebUiFn setup_web_ui); + void setupWebUi() const; + + private: + SetupWebUiFn setup_web_ui_ = nullptr; +}; diff --git a/ui_freenove_allinone/include/app/scenario_manager.h b/ui_freenove_allinone/include/app/scenario_manager.h new file mode 100644 index 0000000..ef9f4b0 --- /dev/null +++ b/ui_freenove_allinone/include/app/scenario_manager.h @@ -0,0 +1,96 @@ +// scenario_manager.h - Story runtime wrapper for Freenove all-in-one. +#pragma once + +#include +#include + +#include "core/scenario_def.h" + +struct ScenarioSnapshot { + const ScenarioDef* scenario = nullptr; + const StepDef* step = nullptr; + const char* screen_scene_id = nullptr; + const char* audio_pack_id = nullptr; + const char* const* action_ids = nullptr; + uint8_t action_count = 0U; + bool mp3_gate_open = false; +}; + +class ScenarioManager { + public: + static const char* readScenarioField(JsonVariantConst root, + const char* const* candidates, + size_t candidate_count); + + bool begin(const char* scenario_file_path); + bool beginById(const char* scenario_id); + void reset(); + void tick(uint32_t now_ms); + + void notifyUnlock(uint32_t now_ms); + bool notifyUnlockEvent(const char* event_name, uint32_t now_ms); + void notifyButton(uint8_t key, bool long_press, uint32_t now_ms); + void notifyAudioDone(uint32_t now_ms); + bool notifyButtonEvent(const char* event_name, uint32_t now_ms); + bool notifyEspNowEvent(const char* event_name, uint32_t now_ms); + bool notifySerialEvent(const char* event_name, uint32_t now_ms); + bool notifyTimerEvent(const char* event_name, uint32_t now_ms); + bool notifyActionEvent(const char* event_name, uint32_t now_ms); + bool gotoScene(const char* scene_id, uint32_t now_ms, const char* source); + + ScenarioSnapshot snapshot() const; + bool consumeSceneChanged(); + bool consumeAudioRequest(String* out_audio_pack_id); + uint32_t transitionEventMask() const; + void setDebugTransitionBypassEnabled(bool enabled, const char* source); + bool debugTransitionBypassEnabled() const; + + private: + struct StepResourceOverride { + String step_id; + String screen_scene_id; + String audio_pack_id; + static constexpr uint8_t kMaxActionOverrides = 8U; + String action_ids[kMaxActionOverrides]; + const char* action_ptrs[kMaxActionOverrides] = {nullptr}; + uint8_t action_count = 0U; + }; + + static constexpr uint8_t kMaxStepResourceOverrides = 24U; + + void clearStepResourceOverrides(); + void loadStepResourceOverrides(const char* scenario_file_path); + const StepResourceOverride* findStepResourceOverride(const char* step_id) const; + void applyStepResourceOverride(const StepDef* step, + const char** out_screen_scene_id, + const char** out_audio_pack_id, + const char* const** out_action_ids = nullptr, + uint8_t* out_action_count = nullptr) const; + + bool dispatchEvent(StoryEventType type, const char* event_name, uint32_t now_ms, const char* source); + bool applyTransition(const TransitionDef& transition, uint32_t now_ms, const char* source, const char* event_name); + bool runImmediateTransitions(uint32_t now_ms, const char* source, const char* parent_event_name); + void evaluateAfterMsTransitions(uint32_t now_ms); + void enterStep(int8_t step_index, uint32_t now_ms, const char* source, const char* event_name = nullptr); + const StepDef* currentStep() const; + bool transitionMatches(const TransitionDef& transition, StoryEventType type, const char* event_name) const; + bool isTransitionAllowed(const TransitionDef& transition, const char* context, const char* event_name) const; + + const ScenarioDef* scenario_ = nullptr; + int8_t current_step_index_ = -1; + uint32_t step_entered_at_ms_ = 0U; + bool scene_changed_ = false; + bool test_mode_ = false; + bool timer_armed_ = false; + bool timer_fired_ = false; + uint32_t etape2_due_at_ms_ = 0U; + bool win_due_armed_ = false; + bool win_due_fired_ = false; + uint32_t win_due_at_ms_ = 0U; + bool debug_transition_bypass_enabled_ = false; + String pending_audio_pack_; + String forced_screen_scene_id_; + String initial_step_override_; + StepResourceOverride step_resource_overrides_[kMaxStepResourceOverrides]; + uint8_t step_resource_override_count_ = 0U; +}; diff --git a/ui_freenove_allinone/include/app/scene_fx_orchestrator.h b/ui_freenove_allinone/include/app/scene_fx_orchestrator.h new file mode 100644 index 0000000..5db753a --- /dev/null +++ b/ui_freenove_allinone/include/app/scene_fx_orchestrator.h @@ -0,0 +1,38 @@ +// scene_fx_orchestrator.h - explicit owner planning for scene runtime resources. +#pragma once + +#include + +enum class SceneRuntimeOwner : uint8_t { + kStory = 0, + kIntroFx, + kDirectFx, + kAmp, + kCamera, +}; + +struct SceneTransitionPlan { + bool should_apply = false; + bool owner_changed = false; + bool scene_changed = false; + SceneRuntimeOwner from_owner = SceneRuntimeOwner::kStory; + SceneRuntimeOwner to_owner = SceneRuntimeOwner::kStory; + const char* from_scene = ""; + const char* to_scene = ""; +}; + +class SceneFxOrchestrator { + public: + SceneTransitionPlan planTransition(const char* scene_id, bool scene_changed, bool force_refresh); + void applyTransition(const SceneTransitionPlan& plan); + SceneRuntimeOwner currentOwner() const; + const char* currentSceneId() const; + + private: + SceneRuntimeOwner classifyOwner(const char* scene_id) const; + static bool sameText(const char* lhs, const char* rhs); + + SceneRuntimeOwner current_owner_ = SceneRuntimeOwner::kStory; + char current_scene_id_[40] = ""; +}; + diff --git a/ui_freenove_allinone/include/app/serial_command_router.h b/ui_freenove_allinone/include/app/serial_command_router.h new file mode 100644 index 0000000..53b0def --- /dev/null +++ b/ui_freenove_allinone/include/app/serial_command_router.h @@ -0,0 +1,11 @@ +// serial_command_router.h - serial command dispatch bridge. +#pragma once + +#include + +#include "runtime/runtime_services.h" + +class SerialCommandRouter { + public: + bool dispatch(const char* command_line, uint32_t now_ms, RuntimeServices* services) const; +}; diff --git a/ui_freenove_allinone/include/audio/audio_manager.h b/ui_freenove_allinone/include/audio/audio_manager.h new file mode 100644 index 0000000..b4f7298 --- /dev/null +++ b/ui_freenove_allinone/include/audio/audio_manager.h @@ -0,0 +1,116 @@ +// audio_manager.h - Freenove audio playback manager backed by ESP32-audioI2S. +#pragma once + +#include +#include + +#include "ui_freenove_config.h" + +class Audio; + +class AudioManager { + public: + using AudioDoneCallback = void (*)(const char* track, void* ctx); + + AudioManager(); + ~AudioManager(); + AudioManager(const AudioManager&) = delete; + AudioManager& operator=(const AudioManager&) = delete; + AudioManager(AudioManager&&) = delete; + AudioManager& operator=(AudioManager&&) = delete; + + bool begin(); + bool play(const char* filename); + bool playDiagnosticTone(); + void stop(); + void update(); + bool isPlaying() const; + + void setVolume(uint8_t volume); + uint8_t volume() const; + const char* currentTrack() const; + bool setOutputProfile(uint8_t profile_index); + uint8_t outputProfile() const; + uint8_t outputProfileCount() const; + const char* outputProfileLabel(uint8_t profile_index) const; + bool setFxProfile(uint8_t fx_profile_index); + uint8_t fxProfile() const; + uint8_t fxProfileCount() const; + const char* fxProfileLabel(uint8_t fx_profile_index) const; + const char* activeCodec() const; + uint16_t activeBitrateKbps() const; + uint32_t underrunCount() const; + + void setAudioDoneCallback(AudioDoneCallback cb, void* ctx); + + private: + enum class AudioCodec : uint8_t { + kUnknown = 0U, + kMp3 = 1U, + kWav = 2U, + kAac = 3U, + kFlac = 4U, + }; + + bool ensurePlayer(); + bool requestPlay(const char* filename, bool diagnostic_tone); + void applyOutputProfile(); + void applyFxProfile(); + bool normalizeTrackPath(const char* input, String& out_path, bool& out_use_sd) const; + bool trackExists(const String& path, bool use_sd) const; + bool detectTrackCodecAndBitrate(const String& path, bool use_sd, AudioCodec& codec, uint16_t& bitrate_kbps) const; + const char* codecLabel(AudioCodec codec) const; + bool openTrack(const String& path, bool use_sd); + bool beginTrackPlayback(const String& path, + bool use_sd, + AudioCodec codec, + uint16_t bitrate_kbps, + bool diagnostic_tone); + void scheduleTrackStart(const String& path, + bool use_sd, + AudioCodec codec, + uint16_t bitrate_kbps, + bool diagnostic_tone, + uint32_t earliest_ms); + void tryStartPendingTrack(uint32_t now_ms); + void createRtosState(); + void destroyRtosState(); + bool startAudioPump(); + void stopAudioPump(); + void audioPumpLoop(); + static void audioPumpTaskEntry(void* arg); + void processPendingPlaybackEvents(); + void enqueuePlaybackDone(const char* track); + bool takeStateLock(uint32_t timeout_ms) const; + void releaseStateLock() const; + void clearTrackState(); + void finishPlaybackAndNotify(); + + std::unique_ptr