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
This commit is contained in:
Isaac
2026-03-01 20:08:00 +01:00
commit db7083845b
298 changed files with 192790 additions and 0 deletions
+34
View File
@@ -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
+210
View File
@@ -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 lenvironnement 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 lID USB (Freenove, ESP32, ESP8266, RP2040). Le log affiche le hardware détecté et lenvironnement PlatformIO ciblé.
Pour forcer un build/flash spécifique: ./tools/dev/cockpit.sh flash <env>
# 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 <PORT>`
- **Monitor**: `pio device monitor -e ui_rp2040_freenove --port <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=<PORT_ESP32>
make fast-ui-oled UI_OLED_PORT=<PORT_ESP8266>
make fast-ui-tft UI_TFT_PORT=<PORT_RP2040>
```
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 LUSB MAINTENANT ⚠️` three times, then waits for Enter while listing ports every 15s.
Each run writes deterministic artifacts under `artifacts/rc_live/<env_label>_<timestamp>/` and logs under `logs/rc_live/<env_label>_<timestamp>.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.
+98
View File
@@ -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)
+15
View File
@@ -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`
+26
View File
@@ -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"]
@@ -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
@@ -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
@@ -0,0 +1,2 @@
[console_scripts]
story-gen = zacus_story_gen_ai.cli:main
@@ -0,0 +1,3 @@
PyYAML>=6.0
yamale>=5.2.1
Jinja2>=3.1.0
@@ -0,0 +1 @@
zacus_story_gen_ai
@@ -0,0 +1,5 @@
"""Story generation library (Yamale + Jinja2) for Zacus firmware."""
from .generator import StoryGenerationError
__all__ = ["StoryGenerationError"]
@@ -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())
File diff suppressed because it is too large Load Diff
@@ -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)
@@ -0,0 +1,2 @@
prompt_input: any()
current_firmware_snapshot: any(required=False)
@@ -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)
@@ -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 <cstring>
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<uint8_t>(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;
}
@@ -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 <Arduino.h>
#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);
@@ -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 <cstring>
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<uint8_t>(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 }}";
}
@@ -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 <Arduino.h>
#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();
+9
View File
@@ -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))
@@ -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
@@ -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"
}
}
@@ -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
}
]
}
@@ -0,0 +1,6 @@
{
"id": "SCENE_BROKEN",
"content": {
"description": "Ecran de transition, syst\u00e8me \"cass\u00e9\""
}
}
@@ -0,0 +1,6 @@
{
"id": "SCENE_LA_DETECTOR",
"content": {
"description": "Ecran de d\u00e9tection LA"
}
}
@@ -0,0 +1,6 @@
{
"id": "SCENE_LOCKED",
"content": {
"description": "Ecran verrouill\u00e9, d\u00e9but du sc\u00e9nario"
}
}
@@ -0,0 +1,6 @@
{
"id": "SCENE_READY",
"content": {
"description": "Ecran pr\u00eat, fin du sc\u00e9nario"
}
}
@@ -0,0 +1,6 @@
{
"id": "SCENE_WIN",
"content": {
"description": "Ecran de victoire"
}
}
@@ -0,0 +1,104 @@
#pragma once
#include <Arduino.h>
#include <stddef.h>
#include <stdint.h>
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;
};
@@ -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 <typename Derived>
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 <typename Derived>
class Machine {
public:
explicit Machine(Derived& owner, State<Derived>& initial)
: owner_(owner), current_(&initial) {
current_->onEnter(owner_);
}
void dispatch(const Event& event) {
current_->onEvent(owner_, event);
}
void transition(State<Derived>& next) {
if (&next == current_) {
return;
}
current_->onExit(owner_);
current_ = &next;
current_->onEnter(owner_);
}
const State<Derived>& current() const {
return *current_;
}
private:
Derived& owner_;
State<Derived>* current_;
};
} // namespace tinyfsm
} // namespace zacus_story_portable
+3
View File
@@ -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 ?
@@ -0,0 +1,350 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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<const char*>(memchr(line, '*', line_len));
size_t payload_len = line_len;
if (star != NULL) {
out->has_crc = true;
payload_len = static_cast<size_t>(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<const uint8_t*>(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<const char*>(memchr(cursor, ',', static_cast<size_t>(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<size_t>(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<size_t>(payload_end - cursor);
const char* next = static_cast<const char*>(memchr(cursor, ',', remaining));
const size_t token_len = (next == NULL) ? remaining : static_cast<size_t>(next - cursor);
if (token_len == 0u) {
return false;
}
const char* eq = static_cast<const char*>(memchr(cursor, '=', token_len));
if (eq == NULL) {
return false;
}
const size_t key_len = static_cast<size_t>(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
@@ -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):
```
<TYPE>,k=v,k=v*CC\n
```
Where:
- `<TYPE>` 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=<stable-id>`
- `fw=<firmware-version>`
- `caps=<capability-string>`
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=<ui timestamp ms>`
### TOUCH (optional raw touch)
- `x=<px>`
- `y=<px>`
- `action=down|move|up`
- `ts=<ui timestamp ms>`
### CMD (optional)
- `op=request_keyframe|set_page|...`
- optional `arg=<value>`
### PONG
- optional `ms=<ui timestamp ms>`
## 4.2 ESP32 -> UI
### ACK
- `proto=2`
- `session=<counter>`
- optional `caps=<esp-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=<esp uptime 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`
@@ -0,0 +1,567 @@
#include "zacus_story_portable/story_portable_runtime.h"
#include <cstdio>
#include <cstring>
#include <ArduinoJson.h>
#include <FS.h>
#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<StoryPortableRuntime> {
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<StoryPortableRuntime> {
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<StoryPortableRuntime> {
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<const BeginEvent&>(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<const LoadEvent&>(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<const LoadEvent&>(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<const UpdateEvent&>(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<const StopEvent&>(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<const BeginEvent&>(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<const LoadEvent&>(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<const BeginEvent&>(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<const StopEvent&>(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<unsigned int>(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<unsigned int>(i),
entries[i].id[0] != '\0' ? entries[i].id : "-",
entries[i].fromLittleFs ? 1U : 0U,
entries[i].fromGenerated ? 1U : 0U,
static_cast<unsigned int>(entries[i].version),
static_cast<unsigned long>(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");
}
@@ -0,0 +1,5 @@
# Story V2 Release Handbook
Ce document a ete deplace vers:
- `docs/protocols/RELEASE_STORY_V2.md`
@@ -0,0 +1,41 @@
import os
import sys
import yaml
# Usage: python gen_split_scenario.py <scenario_yaml> <output_dir>
def main():
if len(sys.argv) != 3:
print("Usage: python gen_split_scenario.py <scenario_yaml> <output_dir>")
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()
@@ -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/`.
@@ -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`
@@ -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.
- Sassurer que le firmware charge les apps/écrans/scènes depuis LittleFS, dossier par scénario.
@@ -0,0 +1,4 @@
id: APP_AUDIO
config:
type: AUDIO_PACK
description: Gestion des sons et packs audio
@@ -0,0 +1,4 @@
id: APP_GATE
config:
type: MP3_GATE
description: Contrôle du passage/fin de scénario
@@ -0,0 +1,4 @@
id: APP_SCREEN
config:
type: SCREEN_SCENE
description: Ecran principal du scénario par défaut
@@ -0,0 +1,3 @@
id: SCENE_BROKEN
content:
description: Ecran de transition, système "cassé"
@@ -0,0 +1,3 @@
id: SCENE_LA_DETECT
content:
description: Ecran de détection LA
@@ -0,0 +1,3 @@
id: SCENE_LA_DETECT
content:
description: Ecran de détection LA
@@ -0,0 +1,3 @@
id: SCENE_LA_DETECT
content:
description: Ecran de détection LA
@@ -0,0 +1,3 @@
id: SCENE_LA_DETECTOR
content:
description: Ecran de détection LA
@@ -0,0 +1,3 @@
id: SCENE_LOCKED
content:
description: Ecran verrouillé, début du scénario
@@ -0,0 +1,3 @@
id: SCENE_READY
content:
description: Ecran prêt, fin du scénario
@@ -0,0 +1,3 @@
id: SCENE_TEST_LAB
content:
description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL
@@ -0,0 +1,3 @@
id: SCENE_TEST_LAB
content:
description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL
@@ -0,0 +1,3 @@
id: SCENE_TEST_LAB
content:
description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL
@@ -0,0 +1,3 @@
id: SCENE_WIN
content:
description: Ecran de victoire
@@ -0,0 +1,3 @@
# NOTE: This scenario has moved.
# Canonical file:
# docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml
@@ -0,0 +1,3 @@
# NOTE: This scenario has moved.
# Canonical file:
# docs/protocols/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_done.yaml
@@ -0,0 +1,3 @@
# NOTE: This scenario has moved.
# Canonical file:
# docs/protocols/story_specs/scenarios/spectre_radio_lab.yaml
@@ -0,0 +1,3 @@
# NOTE: This scenario has moved.
# Canonical file:
# docs/protocols/story_specs/scenarios/zacus_v1_unlock_and_etape2.yaml
@@ -0,0 +1,3 @@
# NOTE: This schema has moved.
# Canonical file:
# docs/protocols/story_specs/schema/story_spec_v1.yaml
@@ -0,0 +1,3 @@
# NOTE: This template has moved.
# Canonical file:
# docs/protocols/story_specs/templates/scenario.template.yaml
@@ -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())
+19
View File
@@ -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."
+331
View File
@@ -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()
+158
View File
@@ -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()
@@ -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())
+288
View File
@@ -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 =
-<*>
+<esp32_audio/src/>
+<../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 =
-<*>
+<ui/rp2040_tft/src/>
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 =
-<*>
+<ui/rp2040_tft/src/>
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 =
-<*>
+<ui/esp8266_oled/src/>
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 <nom_env>
# Pour flasher/monitorer :
# pio run -e <env> -t upload --upload-port <PORT>
# pio device monitor -e <env> --port <PORT>
#
# Voir docs/protocols/ pour la documentation centralisée
#
+37
View File
@@ -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 dinté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.
+56
View File
@@ -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 laffichage 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 dinté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 linitialisation (logs/)
- [x] Boucle principale dinté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 dartefacts de validation (artifacts/)
## Documentation
- [ ] Mise à jour README.md (usage, build, structure)
- [ ] Mise à jour AGENT_FUSION.md (règles dintégration, conventions)
- [ ] Synchronisation avec la doc onboarding principale
+155
View File
@@ -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 linterface I2S.
- **I2S (Inter-IC Sound)**: protocole série dédié à laudio, géré par deux périphériques sur ESP32-S3. Permet denvoyer 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 laudio 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 lexemple Espressif pour la configuration du canal, du clock, des slots et des GPIO.
5. **Tester laudio**: 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 daudit
- 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 denvironnement:
- `ZACUS_ENV="freenove_esp32s3" ./tools/dev/run_matrix_and_smoke.sh`
- Ne pas passer dargument 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 dintégration)
---
# Onboarding : sélection denvironnement
- La sélection de lenvironnement hardware (Freenove ESP32-S3, ESP32, ESP8266, etc.) se fait **exclusivement** via la variable denvironnement `ZACUS_ENV`.
- Ne jamais passer lenvironnement 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 <idx>` 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`).
+357
View File
@@ -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, cest normal. Si le microcontrôleur se met à rapper, cest 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 <idx>` 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 <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 <PORT>
pio run -e freenove_esp32s3 -t upload --upload-port <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 dESP32 séparé)
> *"Si tu rates un bouton, cest que tu danses trop fort. Si tu vois un plasma violet, cest que tu es dans le groove."*
---
## 🛠️ Modules principaux
- `audio_manager` : fait swinguer laudio (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 lauteur 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, cest peut-être un easter egg ou feat foirée."*
---
# Firmware Freenove Media Kit All-in-One
## Plan dinté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)
- LUI 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 dESP32 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 laffichage, la réactivité tactile et boutons
- Tester la lecture audio (fichiers dans /data/)
- Consulter les logs série pour le suivi dexé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 <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 <PORT>
pio run -e freenove_esp32s3 -t upload --upload-port <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 <token>`.
- Provisioning persistant:
- API: `POST /api/wifi/connect` (ou `/api/network/wifi/connect`) avec `persist=1`.
- Serie: `WIFI_PROVISION <ssid> <pass>`.
- 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 ladaptation 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 <idx|path>`, `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
+4
View File
@@ -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.)
@@ -0,0 +1,18 @@
// app_coordinator.h - runtime orchestration facade for setup/loop.
#pragma once
#include <Arduino.h>
#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_;
};
@@ -0,0 +1,18 @@
// runtime_scene_service.h - scene refresh + audio kick bridge.
#pragma once
#include <Arduino.h>
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;
};
@@ -0,0 +1,18 @@
// runtime_serial_service.h - dispatch bridge for serial + control actions.
#pragma once
#include <Arduino.h>
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;
};
@@ -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;
};
@@ -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;
};
@@ -0,0 +1,96 @@
// scenario_manager.h - Story runtime wrapper for Freenove all-in-one.
#pragma once
#include <Arduino.h>
#include <ArduinoJson.h>
#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;
};
@@ -0,0 +1,38 @@
// scene_fx_orchestrator.h - explicit owner planning for scene runtime resources.
#pragma once
#include <Arduino.h>
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] = "";
};
@@ -0,0 +1,11 @@
// serial_command_router.h - serial command dispatch bridge.
#pragma once
#include <Arduino.h>
#include "runtime/runtime_services.h"
class SerialCommandRouter {
public:
bool dispatch(const char* command_line, uint32_t now_ms, RuntimeServices* services) const;
};
@@ -0,0 +1,116 @@
// audio_manager.h - Freenove audio playback manager backed by ESP32-audioI2S.
#pragma once
#include <Arduino.h>
#include <memory>
#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<Audio> player_;
struct AudioRtosState;
std::unique_ptr<AudioRtosState> rtos_state_;
bool pump_task_enabled_ = false;
bool begun_ = false;
bool playing_ = false;
bool using_diagnostic_tone_ = false;
uint8_t volume_ = FREENOVE_AUDIO_MAX_VOLUME;
uint8_t fx_profile_ = 0U;
uint8_t output_profile_ = 0U;
String current_track_;
AudioCodec active_codec_ = AudioCodec::kUnknown;
uint16_t active_bitrate_kbps_ = 0U;
bool active_use_sd_ = false;
bool pending_start_ = false;
String pending_track_;
AudioCodec pending_codec_ = AudioCodec::kUnknown;
uint16_t pending_bitrate_kbps_ = 0U;
bool pending_use_sd_ = false;
bool pending_diagnostic_tone_ = false;
uint32_t reopen_earliest_ms_ = 0U;
uint32_t underrun_count_ = 0U;
uint32_t underrun_last_note_ms_ = 0U;
uint32_t underrun_last_log_ms_ = 0U;
mutable char current_track_snapshot_[96] = {0};
AudioDoneCallback done_cb_ = nullptr;
void* done_ctx_ = nullptr;
};
@@ -0,0 +1,94 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace audio {
enum class AudioCommandType : uint8_t {
kNone = 0,
kPlay,
kStop,
kSetVolume,
};
struct AudioCommand {
AudioCommandType type = AudioCommandType::kNone;
char path[96] = {0};
uint8_t value = 0U;
};
struct AudioStatus {
bool playing = false;
uint32_t underrun_count = 0U;
uint16_t buffered_chunks = 0U;
};
struct AudioByteRing {
static constexpr size_t kChunkBytes = 1536U;
static constexpr size_t kSlotCount = 96U;
uint8_t* data = nullptr;
size_t capacity_bytes = 0U;
size_t chunk_bytes = kChunkBytes;
uint16_t write_slot = 0U;
uint16_t read_slot = 0U;
uint16_t used_slots = 0U;
};
class AudioPipeline {
public:
static constexpr uint8_t kCommandQueueDepth = 8U;
bool begin(AudioByteRing* ring, uint8_t* backing, size_t backing_bytes) {
if (ring == nullptr || backing == nullptr) {
return false;
}
if (backing_bytes < (AudioByteRing::kChunkBytes * AudioByteRing::kSlotCount)) {
return false;
}
ring_ = ring;
ring_->data = backing;
ring_->capacity_bytes = backing_bytes;
ring_->chunk_bytes = AudioByteRing::kChunkBytes;
ring_->write_slot = 0U;
ring_->read_slot = 0U;
ring_->used_slots = 0U;
return true;
}
bool pushChunk(const uint8_t* chunk, size_t bytes) {
if (ring_ == nullptr || chunk == nullptr || bytes > ring_->chunk_bytes || ring_->used_slots >= AudioByteRing::kSlotCount) {
return false;
}
uint8_t* dst = ring_->data + static_cast<size_t>(ring_->write_slot) * ring_->chunk_bytes;
std::memcpy(dst, chunk, bytes);
if (bytes < ring_->chunk_bytes) {
std::memset(dst + bytes, 0, ring_->chunk_bytes - bytes);
}
ring_->write_slot = static_cast<uint16_t>((ring_->write_slot + 1U) % AudioByteRing::kSlotCount);
++ring_->used_slots;
return true;
}
bool popChunk(uint8_t* out_chunk, size_t out_bytes) {
if (ring_ == nullptr || out_chunk == nullptr || out_bytes < ring_->chunk_bytes || ring_->used_slots == 0U) {
return false;
}
const uint8_t* src = ring_->data + static_cast<size_t>(ring_->read_slot) * ring_->chunk_bytes;
std::memcpy(out_chunk, src, ring_->chunk_bytes);
ring_->read_slot = static_cast<uint16_t>((ring_->read_slot + 1U) % AudioByteRing::kSlotCount);
--ring_->used_slots;
return true;
}
uint16_t bufferedChunks() const {
return (ring_ != nullptr) ? ring_->used_slots : 0U;
}
private:
AudioByteRing* ring_ = nullptr;
};
} // namespace audio
@@ -0,0 +1,3 @@
#pragma once
#include "audio/audio_manager.h"
@@ -0,0 +1,3 @@
#pragma once
#include "drivers/input/button_manager.h"
@@ -0,0 +1,104 @@
// camera_manager.h - minimal esp_camera wrapper for snapshots.
#pragma once
#include <Arduino.h>
#include <vector>
class CameraManager {
public:
enum class RecorderSaveFormat : uint8_t {
kAuto = 0,
kBmp24,
kJpeg,
kRawRgb565,
};
struct Config {
bool enabled_on_boot = false;
char frame_size[16] = "VGA";
uint8_t jpeg_quality = 12U;
uint8_t fb_count = 1U;
uint32_t xclk_hz = 20000000UL;
char snapshot_dir[32] = "/picture";
};
struct Snapshot {
bool supported = false;
bool enabled = false;
bool initialized = false;
bool last_snapshot_ok = false;
uint32_t capture_count = 0U;
uint32_t fail_count = 0U;
uint32_t last_capture_ms = 0U;
uint16_t width = 0U;
uint16_t height = 0U;
uint8_t jpeg_quality = 12U;
uint8_t fb_count = 1U;
uint32_t xclk_hz = 20000000UL;
char frame_size[16] = "VGA";
char snapshot_dir[32] = "/picture";
char last_file[96] = "";
char last_error[64] = "";
bool recorder_session_active = false;
bool recorder_frozen = false;
uint16_t recorder_preview_width = 0U;
uint16_t recorder_preview_height = 0U;
char recorder_selected_file[96] = "";
};
CameraManager();
~CameraManager() = default;
CameraManager(const CameraManager&) = delete;
CameraManager& operator=(const CameraManager&) = delete;
CameraManager(CameraManager&&) = delete;
CameraManager& operator=(CameraManager&&) = delete;
bool begin(const Config& config);
bool start();
void stop();
bool isEnabled() const;
bool snapshotToFile(const char* filename_hint, String* out_path);
bool startRecorderSession();
void stopRecorderSession();
bool recorderSessionActive() const;
bool recorderSnapFreeze(uint16_t* preview_dst, int preview_w, int preview_h);
bool recorderHasFrozen() const;
bool recorderSaveFrozen(String* out_path, RecorderSaveFormat format);
void recorderDiscardFrozen();
bool recorderUpdatePreviewRgb565(uint16_t* dst, int dst_w, int dst_h);
int recorderListPhotos(String* out, int max_items, bool newest_first = true) const;
bool recorderRemoveFile(const char* path);
bool recorderSelectNextPhoto(String* in_out_path) const;
Snapshot snapshot() const;
private:
void setLastError(const char* message);
void clearLastError();
bool ensureSnapshotDir();
String buildSnapshotPath(const char* filename_hint) const;
bool initCameraForMode(bool recorder_mode);
bool saveRgb565AsBmp24(const char* path, const uint16_t* rgb565, int w, int h, int stride_px);
bool saveRgb565Raw(const char* path, const uint16_t* rgb565, int w, int h, int stride_px);
bool downscaleToRgb565(const uint16_t* src,
int src_w,
int src_h,
int src_stride_px,
uint16_t* dst,
int dst_w,
int dst_h) const;
void ensurePreviewMap(int src_w, int src_h, int dst_w, int dst_h) const;
static bool isPhotoExtension(const String& name);
static RecorderSaveFormat parseSaveFormatToken(const char* token);
Config config_;
Snapshot snapshot_;
bool recorder_mode_ = false;
bool recorder_frozen_ = false;
void* recorder_frozen_fb_ = nullptr;
mutable int preview_map_src_w_ = 0;
mutable int preview_map_src_h_ = 0;
mutable int preview_map_dst_w_ = 0;
mutable int preview_map_dst_h_ = 0;
mutable std::vector<uint16_t> preview_x_map_ = {};
mutable std::vector<uint16_t> preview_y_map_ = {};
};
@@ -0,0 +1,51 @@
#pragma once
#include <cstddef>
#include <cstdint>
namespace camera {
struct CameraFrameMeta {
uint32_t timestamp_ms = 0U;
uint16_t width = 0U;
uint16_t height = 0U;
size_t bytes = 0U;
bool jpeg = false;
};
class CameraPipeline {
public:
static constexpr uint8_t kFrameQueueDepth = 4U;
bool pushFrame(const CameraFrameMeta& frame) {
if (count_ >= kFrameQueueDepth) {
return false;
}
frames_[write_] = frame;
write_ = static_cast<uint8_t>((write_ + 1U) % kFrameQueueDepth);
++count_;
return true;
}
bool popFrame(CameraFrameMeta* out_frame) {
if (out_frame == nullptr || count_ == 0U) {
return false;
}
*out_frame = frames_[read_];
read_ = static_cast<uint8_t>((read_ + 1U) % kFrameQueueDepth);
--count_;
return true;
}
uint8_t pendingFrames() const {
return count_;
}
private:
CameraFrameMeta frames_[kFrameQueueDepth] = {};
uint8_t read_ = 0U;
uint8_t write_ = 0U;
uint8_t count_ = 0U;
};
} // namespace camera
@@ -0,0 +1,3 @@
#pragma once
#include "camera/camera_manager.h"
@@ -0,0 +1,183 @@
// hardware_manager.h - WS2812 + microphone + battery helpers for Freenove board.
#pragma once
#include <Arduino.h>
#include <Adafruit_NeoPixel.h>
#include <driver/i2s.h>
class HardwareManager {
public:
static constexpr uint8_t kMicWaveformCapacity = 16U;
static constexpr uint8_t kMicSpectrumBinCount = 5U;
enum class LedRuntimeMode : uint8_t {
kPalette = 0,
kBroken,
kTuner,
kSingleRandomBlink,
kDominantBandSingle,
};
struct LedPaletteEntry {
const char* scene_id;
uint8_t r;
uint8_t g;
uint8_t b;
uint8_t brightness;
bool pulse;
};
struct Snapshot {
bool ready = false;
bool ws2812_ready = false;
bool mic_ready = false;
bool battery_ready = false;
bool charging = false;
bool led_manual = false;
uint8_t led_r = 0U;
uint8_t led_g = 0U;
uint8_t led_b = 0U;
uint8_t led_brightness = 0U;
bool led_one_at_a_time = false;
uint8_t mic_level_percent = 0U;
uint16_t mic_peak = 0U;
uint16_t mic_noise_floor = 0U;
uint16_t mic_gain_percent = 100U;
uint16_t mic_freq_hz = 0U;
int16_t mic_pitch_cents = 0;
uint8_t mic_pitch_confidence = 0U;
uint8_t mic_waveform_count = 0U;
uint8_t mic_waveform_head = 0U;
uint8_t mic_waveform[kMicWaveformCapacity] = {0};
uint8_t mic_spectrum[kMicSpectrumBinCount] = {0};
uint16_t mic_spectrum_peak_hz = 0U;
uint16_t battery_mv = 0U;
uint16_t battery_cell_mv = 0U;
uint8_t battery_percent = 0U;
uint8_t last_button = 0U;
bool last_button_long = false;
uint32_t last_button_ms = 0U;
uint32_t button_count = 0U;
char led_mode[24] = "palette";
char scene_id[24] = "SCENE_READY";
};
HardwareManager();
~HardwareManager() = default;
HardwareManager(const HardwareManager&) = delete;
HardwareManager& operator=(const HardwareManager&) = delete;
HardwareManager(HardwareManager&&) = delete;
HardwareManager& operator=(HardwareManager&&) = delete;
bool begin();
void update(uint32_t now_ms);
void noteButton(uint8_t key, bool long_press, uint32_t now_ms);
void setSceneHint(const char* scene_id);
bool setManualLed(uint8_t r, uint8_t g, uint8_t b, uint8_t brightness, bool pulse);
void clearManualLed();
Snapshot snapshot() const;
const Snapshot& snapshotRef() const;
void setMicRuntimeEnabled(bool enabled);
bool micRuntimeEnabled() const;
void setSceneSingleRandomBlink(bool enabled,
uint8_t r,
uint8_t g,
uint8_t b,
uint8_t brightness,
bool dominant_band_sync = false);
private:
bool beginMic();
void updateMic(uint32_t now_ms);
void updateBattery(uint32_t now_ms);
void updateLed(uint32_t now_ms);
bool isBrokenSceneHint() const;
bool isTunerSceneHint() const;
void applyBrokenLedPattern(uint32_t now_ms, uint8_t base_r, uint8_t base_g, uint8_t base_b, uint8_t brightness);
void applyTunerLedPattern(uint32_t now_ms,
uint8_t base_r,
uint8_t base_g,
uint8_t base_b,
uint8_t brightness);
void applySingleRandomBlinkPattern(uint32_t now_ms,
uint8_t base_r,
uint8_t base_g,
uint8_t base_b,
uint8_t brightness);
void applyDominantBandSinglePattern(uint32_t now_ms, uint8_t brightness);
void estimatePitch(uint16_t& freq_hz, int16_t& cents, uint8_t& confidence, uint16_t& peak_for_window);
void estimatePitchFromSamples(const int16_t* samples,
size_t sample_count,
uint16_t& out_freq,
int16_t& out_cents,
uint8_t& out_confidence);
void applyPitchSmoothing(uint32_t now_ms,
uint16_t raw_freq,
int16_t raw_cents,
uint8_t raw_confidence,
uint16_t& smoothed_freq,
int16_t& smoothed_cents,
uint8_t& smoothed_confidence);
void setScenePalette(const char* scene_id);
const LedPaletteEntry* findPaletteForScene(const char* scene_id) const;
uint8_t batteryPercentFromMv(uint16_t cell_mv) const;
static uint8_t clampColor(int value);
static constexpr uint16_t kMicSampleRate = 16000U;
static constexpr uint16_t kMicReadSamples = 256U;
static constexpr uint32_t kMicPeriodMs = 40U;
static constexpr uint32_t kBatteryPeriodMs = 1200U;
static constexpr uint32_t kLedPeriodMs = 33U;
static constexpr uint32_t kButtonFlashMs = 180U;
static constexpr i2s_port_t kMicPort = I2S_NUM_1;
Snapshot snapshot_;
Adafruit_NeoPixel strip_;
bool mic_driver_ready_ = false;
bool led_pulse_ = false;
uint32_t next_mic_ms_ = 0U;
uint32_t next_battery_ms_ = 0U;
uint32_t next_led_ms_ = 0U;
uint32_t button_flash_until_ms_ = 0U;
uint8_t scene_r_ = 0U;
uint8_t scene_g_ = 0U;
uint8_t scene_b_ = 0U;
uint8_t scene_brightness_ = 0U;
bool manual_led_ = false;
bool manual_pulse_ = false;
uint8_t manual_r_ = 0U;
uint8_t manual_g_ = 0U;
uint8_t manual_b_ = 0U;
uint8_t manual_brightness_ = 0U;
bool scene_single_random_blink_ = false;
uint8_t scene_single_blink_r_ = 0U;
uint8_t scene_single_blink_g_ = 0U;
uint8_t scene_single_blink_b_ = 0U;
uint8_t scene_single_blink_brightness_ = 0U;
bool scene_single_blink_dominant_band_ = false;
LedRuntimeMode led_runtime_mode_ = LedRuntimeMode::kPalette;
uint16_t mic_agc_gain_q8_ = 256U;
uint16_t mic_noise_floor_raw_ = 120U;
uint32_t mic_last_signal_ms_ = 0U;
float pitch_confidence_ema_ = 0.0f;
uint8_t pitch_smoothing_count_ = 0U;
uint8_t pitch_smoothing_index_ = 0U;
uint32_t pitch_smoothing_last_ms_ = 0U;
static constexpr uint8_t kPitchSmoothingSamples = 3U;
static constexpr uint16_t kPitchSmoothingStaleMs = 260U;
uint16_t pitch_freq_window_[kPitchSmoothingSamples] = {0U};
int16_t pitch_cents_window_[kPitchSmoothingSamples] = {0};
uint8_t pitch_conf_window_[kPitchSmoothingSamples] = {0U};
bool mic_enabled_runtime_ = true;
// Keep DSP buffers off the loop task stack to avoid canary overflows.
int32_t mic_raw_samples_[kMicReadSamples] = {};
int16_t mic_samples_[kMicReadSamples] = {};
float pitch_centered_[kMicReadSamples] = {};
float pitch_energy_prefix_[kMicReadSamples + 1U] = {};
float pitch_corr_by_lag_[kMicReadSamples + 1U] = {};
};
@@ -0,0 +1,86 @@
#pragma once
#include <cstdint>
namespace drivers::display {
enum class DisplayHalBackend : uint8_t {
kTftEsPi = 0,
kLovyanGfx = 1,
};
struct DisplayHalConfig {
uint16_t width = 0U;
uint16_t height = 0U;
uint8_t rotation = 0U;
};
enum class OverlayFontFace : uint8_t {
kBuiltinSmall = 0,
kBuiltinMedium,
kBuiltinLarge,
kIbmRegular14,
kIbmRegular18,
kIbmBold12,
kIbmBold16,
kIbmBold20,
kIbmBold24,
kIbmItalic12,
kIbmItalic16,
kIbmItalic20,
kIbmItalic24,
kInter18,
kInter24,
kOrbitron28,
kBungee24,
kMonoton24,
kRubikGlitch24,
};
struct OverlayTextCommand {
const char* text = nullptr;
int16_t x = 0;
int16_t y = 0;
uint16_t color565 = 0xFFFFU;
uint16_t bg565 = 0x0000U;
OverlayFontFace font_face = OverlayFontFace::kBuiltinMedium;
uint8_t size = 1U;
bool opaque_bg = false;
};
class DisplayHal {
public:
virtual ~DisplayHal() = default;
virtual bool begin(const DisplayHalConfig& config) = 0;
virtual void fillScreen(uint16_t color565) = 0;
virtual bool initDma(bool use_double_buffer) = 0;
virtual bool dmaBusy() const = 0;
virtual bool waitDmaComplete(uint32_t timeout_us) = 0;
virtual bool startWrite() = 0;
virtual void endWrite() = 0;
virtual void setAddrWindow(int16_t x, int16_t y, int16_t w, int16_t h) = 0;
// Contract: both DMA image and pushColors(swap=true) consume the same logical RGB565 pixel format.
virtual void pushImageDma(int16_t x, int16_t y, int16_t w, int16_t h, const uint16_t* pixels) = 0;
virtual void pushColors(const uint16_t* pixels, uint32_t count, bool swap_bytes) = 0;
virtual void pushColor(uint16_t color565) = 0;
virtual bool drawOverlayLine(int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t color565) = 0;
virtual bool drawOverlayRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color565) = 0;
virtual bool fillOverlayRect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color565) = 0;
virtual bool drawOverlayCircle(int16_t x, int16_t y, int16_t radius, uint16_t color565) = 0;
virtual bool supportsOverlayText() const = 0;
virtual int16_t measureOverlayText(const char* text, OverlayFontFace font_face, uint8_t size) = 0;
virtual bool drawOverlayText(const OverlayTextCommand& command) = 0;
virtual uint16_t color565(uint8_t r, uint8_t g, uint8_t b) const = 0;
virtual DisplayHalBackend backend() const = 0;
};
DisplayHal& displayHal();
bool displayHalUsesLovyanGfx();
void displayHalInvalidateOverlay();
} // namespace drivers::display
@@ -0,0 +1,46 @@
#pragma once
#include <cstdint>
#if defined(ARDUINO_ARCH_ESP32)
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#endif
namespace drivers::display {
// Shared SPI arbitration for display backends and optional FX overlay.
class SpiBusManager {
public:
class Guard {
public:
explicit Guard(uint32_t timeout_ms = 100U);
~Guard();
Guard(const Guard&) = delete;
Guard& operator=(const Guard&) = delete;
bool locked() const;
private:
bool locked_ = false;
};
static SpiBusManager& instance();
bool begin();
bool lock(uint32_t timeout_ms = 100U);
void unlock();
SpiBusManager(const SpiBusManager&) = delete;
SpiBusManager& operator=(const SpiBusManager&) = delete;
private:
SpiBusManager() = default;
#if defined(ARDUINO_ARCH_ESP32)
SemaphoreHandle_t mutex_ = nullptr;
#endif
};
} // namespace drivers::display
@@ -0,0 +1,80 @@
// button_manager.h - button scan + long press detection.
#pragma once
#include <Arduino.h>
#if defined(ARDUINO_ARCH_ESP32)
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#endif
struct ButtonEvent {
uint8_t key = 0U;
bool long_press = false;
uint32_t ms = 0U;
};
class ButtonManager {
public:
ButtonManager() = default;
~ButtonManager() = default;
ButtonManager(const ButtonManager&) = delete;
ButtonManager& operator=(const ButtonManager&) = delete;
ButtonManager(ButtonManager&&) = delete;
ButtonManager& operator=(ButtonManager&&) = delete;
bool begin();
bool pollEvent(ButtonEvent* out_event);
bool isPressed(uint8_t key) const;
uint8_t currentKey() const;
int lastAnalogMilliVolts() const;
private:
bool startScanTask();
void stopScanTask();
bool runScan(ButtonEvent* out_event);
bool scanOnce(ButtonEvent* out_event);
#if defined(ARDUINO_ARCH_ESP32)
static void scanTaskEntry(void* arg);
void scanTaskMain();
static constexpr uint16_t kScanTaskStackWords = 2048U;
static constexpr uint8_t kScanTaskPriority = 1U;
static constexpr int8_t kScanTaskCore = 0;
static constexpr uint16_t kScanTaskDelayMs = 5U;
static constexpr uint8_t kButtonEventQueueDepth = 8U;
#endif
#if defined(ARDUINO_ARCH_ESP32)
QueueHandle_t event_queue_ = nullptr;
TaskHandle_t scan_task_ = nullptr;
bool scan_task_running_ = false;
mutable portMUX_TYPE state_lock_ = portMUX_INITIALIZER_UNLOCKED;
#else
// On non-ESP32 builds, keep synchronous polling without RTOS objects.
bool scan_task_running_ = false;
#endif
void lockState() const;
void unlockState() const;
uint8_t decodeAnalogKey(int millivolts) const;
bool pollAnalog(ButtonEvent* out_event);
bool pollDigital(ButtonEvent* out_event);
bool analog_mode_ = false;
int last_analog_mv_ = -1;
int voltage_thresholds_[6] = {0, 447, 730, 1008, 1307, 1659};
int threshold_range_mv_ = 70;
uint8_t analog_key_ = 0U;
uint8_t analog_raw_key_ = 0U;
uint32_t analog_pressed_at_ms_ = 0U;
uint32_t analog_raw_changed_ms_ = 0U;
bool digital_pressed_[4] = {false, false, false, false};
uint32_t digital_pressed_at_ms_[4] = {0U, 0U, 0U, 0U};
};
@@ -0,0 +1,23 @@
// touch_manager.h - optional touch bridge.
#pragma once
#include <Arduino.h>
struct TouchPoint {
int16_t x = 0;
int16_t y = 0;
bool touched = false;
};
class TouchManager {
public:
TouchManager() = default;
~TouchManager() = default;
TouchManager(const TouchManager&) = delete;
TouchManager& operator=(const TouchManager&) = delete;
TouchManager(TouchManager&&) = delete;
TouchManager& operator=(TouchManager&&) = delete;
bool begin();
bool poll(TouchPoint* out_point);
};
@@ -0,0 +1,3 @@
#pragma once
#include "drivers/board/hardware_manager.h"
+94
View File
@@ -0,0 +1,94 @@
#ifndef LV_CONF_H
#define LV_CONF_H
#ifndef UI_COLOR_256
#define UI_COLOR_256 1
#endif
#ifndef UI_COLOR_565
#define UI_COLOR_565 0
#endif
#if UI_COLOR_256 && UI_COLOR_565
#warning "UI_COLOR_565 overrides UI_COLOR_256"
#endif
#ifndef UI_LV_MEM_SIZE_KB
#define UI_LV_MEM_SIZE_KB 160
#endif
#ifndef UI_ENABLE_MEM_MONITOR
#define UI_ENABLE_MEM_MONITOR 1
#endif
#ifndef UI_ENABLE_PERF_MONITOR
#define UI_ENABLE_PERF_MONITOR 0
#endif
#if UI_COLOR_565
#define LV_COLOR_DEPTH 16
#elif UI_COLOR_256
#define LV_COLOR_DEPTH 8
#else
#define LV_COLOR_DEPTH 16
#endif
#define LV_COLOR_16_SWAP 0
#define LV_MEM_CUSTOM 0
#define LV_MEM_SIZE (UI_LV_MEM_SIZE_KB * 1024U)
#define LV_USE_LOG 0
#define LV_USE_ASSERT_NULL 0
#define LV_USE_ASSERT_MALLOC 0
#define LV_USE_ASSERT_STYLE 0
#define LV_USE_ASSERT_MEM_INTEGRITY 0
#define LV_USE_ASSERT_OBJ 0
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_MONTSERRAT_18 1
#define LV_FONT_MONTSERRAT_24 1
#define LV_FONT_MONTSERRAT_28 1
#define LV_FONT_MONTSERRAT_32 1
#define LV_FONT_MONTSERRAT_40 1
#define LV_USE_ARC 0
#define LV_USE_BAR 0
#define LV_USE_BTN 1
#define LV_USE_BTNMATRIX 0
#define LV_USE_CANVAS 1
#define LV_USE_CHECKBOX 0
#define LV_USE_DROPDOWN 0
#define LV_USE_IMG 1
#define LV_USE_LABEL 1
#define LV_LABEL_TEXT_SELECTION 0
#define LV_LABEL_LONG_TXT_HINT 1
#define LV_USE_LINE 1
#define LV_USE_ROLLER 0
#define LV_USE_SLIDER 0
#define LV_USE_SWITCH 0
#define LV_USE_TEXTAREA 0
#define LV_USE_TABLE 0
#define LV_USE_ANIMIMG 0
#define LV_USE_CALENDAR 0
#define LV_USE_CHART 0
#define LV_USE_COLORWHEEL 0
#define LV_USE_IMGBTN 0
#define LV_USE_KEYBOARD 0
#define LV_USE_LED 0
#define LV_USE_LIST 1
#define LV_USE_MENU 0
#define LV_USE_METER 0
#define LV_USE_MSGBOX 0
#define LV_USE_SPAN 0
#define LV_USE_SPINBOX 0
#define LV_USE_SPINNER 0
#define LV_USE_TABVIEW 0
#define LV_USE_TILEVIEW 1
#define LV_USE_WIN 0
#define LV_USE_THEME_DEFAULT 0
#define LV_USE_PERF_MONITOR UI_ENABLE_PERF_MONITOR
#define LV_USE_MEM_MONITOR UI_ENABLE_MEM_MONITOR
#endif // LV_CONF_H

Some files were not shown because too many files have changed in this diff Show More