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:
+34
@@ -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
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# Optimisation automatique hardware
|
||||||
|
Depuis février 2026, le cockpit détecte automatiquement le hardware connecté (port série, ID USB) et ne build/flash que l’environnement PlatformIO correspondant.
|
||||||
|
|
||||||
|
Pour builder ou flasher :
|
||||||
|
- Menu cockpit : "Build all firmware" ou "Flash auto (hardware détecté)"
|
||||||
|
- Ligne de commande : ./tools/dev/cockpit.sh flash (auto) ou ./tools/dev/cockpit.sh build (auto)
|
||||||
|
|
||||||
|
La détection est basée sur pyserial et l’ID USB (Freenove, ESP32, ESP8266, RP2040). Le log affiche le hardware détecté et l’environnement PlatformIO ciblé.
|
||||||
|
|
||||||
|
Pour forcer un build/flash spécifique : ./tools/dev/cockpit.sh flash <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 L’USB 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.
|
||||||
@@ -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)
|
||||||
@@ -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`
|
||||||
@@ -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 @@
|
|||||||
|
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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/`.
|
||||||
+5
@@ -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.
|
||||||
|
- S’assurer 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
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_BROKEN
|
||||||
|
content:
|
||||||
|
description: Ecran de transition, système "cassé"
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_LA_DETECT
|
||||||
|
content:
|
||||||
|
description: Ecran de détection LA
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_LA_DETECT
|
||||||
|
content:
|
||||||
|
description: Ecran de détection LA
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_LA_DETECT
|
||||||
|
content:
|
||||||
|
description: Ecran de détection LA
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_LA_DETECTOR
|
||||||
|
content:
|
||||||
|
description: Ecran de détection LA
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_LOCKED
|
||||||
|
content:
|
||||||
|
description: Ecran verrouillé, début du scénario
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_READY
|
||||||
|
content:
|
||||||
|
description: Ecran prêt, fin du scénario
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_TEST_LAB
|
||||||
|
content:
|
||||||
|
description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
id: SCENE_TEST_LAB
|
||||||
|
content:
|
||||||
|
description: Mire fixe en barres de couleur pour calibration palette et rendu LVGL
|
||||||
+3
@@ -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
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# NOTE: This scenario has moved.
|
||||||
|
# Canonical file:
|
||||||
|
# docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# NOTE: This scenario has moved.
|
||||||
|
# Canonical file:
|
||||||
|
# docs/protocols/story_specs/scenarios/example_unlock_express.yaml
|
||||||
+3
@@ -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
|
||||||
+3
@@ -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())
|
||||||
Executable
+19
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
#
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
|
||||||
|
# AGENT_FUSION.md – Firmware All-in-One Freenove
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Fusionner les rôles audio, scénario, UI, stockage et gestion hardware dans un firmware unique pour le Media Kit Freenove, avec structure modulaire, traçabilité agent, et conformité aux specs suivantes :
|
||||||
|
|
||||||
|
|
||||||
|
### Plan d’intégration complète (couverture specs)
|
||||||
|
|
||||||
|
- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/) ✔️
|
||||||
|
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers) ✔️
|
||||||
|
- Exécution de scénarios (lecture, transitions, actions, audio) ✔️
|
||||||
|
- Gestion audio (lecture/stop, mapping fichiers LittleFS) ✔️
|
||||||
|
- Gestion boutons et tactile (événements, mapping, callbacks) ✔️
|
||||||
|
- Fallback robuste si fichier manquant (scénario par défaut) ✔️
|
||||||
|
- Génération de logs et artefacts (logs/, artifacts/) ✔️
|
||||||
|
- Validation hardware sur Freenove (affichage, audio, boutons, tactile) ⏳
|
||||||
|
- Documentation et onboarding synchronisés ⏳
|
||||||
|
|
||||||
|
### Structure modulaire
|
||||||
|
|
||||||
|
- audio_manager.{h,cpp} : gestion audio
|
||||||
|
- scenario_manager.{h,cpp} : gestion scénario
|
||||||
|
- ui_manager.{h,cpp} : gestion UI dynamique (LVGL)
|
||||||
|
- button_manager.{h,cpp} : gestion boutons
|
||||||
|
- touch_manager.{h,cpp} : gestion tactile
|
||||||
|
- storage_manager.{h,cpp} : gestion LittleFS, fallback
|
||||||
|
|
||||||
|
### Points critiques à valider
|
||||||
|
|
||||||
|
- Robustesse du fallback LittleFS
|
||||||
|
- Synchronisation UI/scénario/audio
|
||||||
|
- Mapping dynamique boutons/tactile
|
||||||
|
- Logs d’évidence et artefacts produits
|
||||||
|
|
||||||
|
Voir AGENT_TODO.md pour le suivi détaillé et la progression.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## Validation hardware – TODO détaillée
|
||||||
|
|
||||||
|
- [ ] Compiler et flasher le firmware sur le Freenove Media Kit
|
||||||
|
- [ ] Préparer les fichiers de test sur LittleFS (/data/scene_*.json, /data/screen_*.json, /data/*.wav)
|
||||||
|
- [ ] Vérifier l’affichage TFT (boot, écran dynamique, transitions)
|
||||||
|
- [ ] Tester la réactivité tactile (zones, coordonnées, mapping)
|
||||||
|
- [ ] Tester les boutons physiques (appui, mapping, transitions)
|
||||||
|
- [ ] Tester la lecture audio (fichiers présents/absents, fallback)
|
||||||
|
- [ ] Observer les logs série (initialisation, actions, erreurs)
|
||||||
|
- [ ] Générer et archiver les logs d’évidence (logs/)
|
||||||
|
- [ ] Produire un artefact de validation (artifacts/)
|
||||||
|
- [ ] Documenter toute anomalie ou limitation hardware constatée
|
||||||
|
|
||||||
|
## Revue finale – Checklist agents
|
||||||
|
|
||||||
|
- [ ] Vérifier la cohérence de la structure (dossiers, modules, scripts)
|
||||||
|
- [ ] Relire AGENT_FUSION.md (objectifs, couverture specs, structure)
|
||||||
|
- [ ] Relire README.md (usage, build, validation, modules)
|
||||||
|
- [ ] Relire la section onboarding (docs/QUICKSTART.md, etc.)
|
||||||
|
- [ ] Vérifier la présence et la robustesse du fallback LittleFS
|
||||||
|
- [ ] Vérifier la traçabilité des logs et artefacts
|
||||||
|
- [ ] Vérifier la synchronisation UI/scénario/audio
|
||||||
|
- [ ] Vérifier la gestion dynamique des boutons/tactile
|
||||||
|
- [ ] Vérifier la non-régression sur les autres firmwares (split)
|
||||||
|
|
||||||
|
# TODO Agent – Firmware All-in-One Freenove
|
||||||
|
|
||||||
|
|
||||||
|
## Plan d’intégration détaillé (COMPLÉTÉ)
|
||||||
|
|
||||||
|
- [x] Vérifier la présence du scénario par défaut sur LittleFS
|
||||||
|
- [x] Charger la liste des fichiers de scènes et d’écrans (data/)
|
||||||
|
- [x] Initialiser la navigation UI (LVGL, écrans dynamiques)
|
||||||
|
- [x] Mapper les callbacks boutons/tactile vers la navigation UI
|
||||||
|
- [x] Préparer le fallback LittleFS si fichier manquant
|
||||||
|
- [x] Logger l’initialisation (logs/)
|
||||||
|
|
||||||
|
- [x] Boucle principale d’intégration
|
||||||
|
- [x] Navigation UI (LVGL, écrans dynamiques)
|
||||||
|
- [x] Exécution scénario (lecture, actions, transitions)
|
||||||
|
- [x] Gestion audio (lecture, stop, files LittleFS)
|
||||||
|
- [x] Gestion boutons/tactile (événements, mapping)
|
||||||
|
- [x] Gestion stockage (LittleFS, fallback)
|
||||||
|
- [x] Logs/artefacts
|
||||||
|
|
||||||
|
## Validation hardware (EN COURS)
|
||||||
|
|
||||||
|
- [ ] Tests sur Freenove Media Kit (affichage, audio, boutons, tactile)
|
||||||
|
- [ ] Génération de logs d’évidence (logs/)
|
||||||
|
- [ ] Production d’artefacts de validation (artifacts/)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [ ] Mise à jour README.md (usage, build, structure)
|
||||||
|
- [ ] Mise à jour AGENT_FUSION.md (règles d’intégration, conventions)
|
||||||
|
- [ ] Synchronisation avec la doc onboarding principale
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Multiplexage des broches – Attention
|
||||||
|
|
||||||
|
Cible matérielle : **ESP32-S3-WROOM-1-N16R8**.
|
||||||
|
|
||||||
|
Certaines broches sont partagées entre plusieurs fonctions :
|
||||||
|
- GPIO 4 : utilisé pour le rétroéclairage (BL) et le bouton 3 (BTN3)
|
||||||
|
- GPIO 5 : utilisé pour le Chip Select TFT (CS) et le bouton 4 (BTN4)
|
||||||
|
|
||||||
|
Ce multiplexage impose :
|
||||||
|
- De ne pas activer simultanément les deux fonctions sur une même broche
|
||||||
|
- De documenter explicitement ce partage dans le firmware et la doc
|
||||||
|
- De vérifier le comportement lors des tests (priorité, conflits)
|
||||||
|
|
||||||
|
Recommandation : ajouter des commentaires dans ui_freenove_config.h et tester chaque usage séparément.
|
||||||
|
# Périphériques additionnels – Détail par fonction
|
||||||
|
|
||||||
|
## I2C
|
||||||
|
- SDA : GPIO 8
|
||||||
|
- SCL : GPIO 9
|
||||||
|
- Usage : bus capteurs (ex : MPU6050, DHT11)
|
||||||
|
- Remarque : vérifier pull-up, adresser chaque périphérique
|
||||||
|
|
||||||
|
## LED
|
||||||
|
- LED : GPIO 13
|
||||||
|
- Usage : indicateur d’état, feedback utilisateur
|
||||||
|
- Remarque : pilotable en PWM
|
||||||
|
|
||||||
|
## Buzzer
|
||||||
|
- Buzzer : GPIO 12
|
||||||
|
- Usage : signal sonore, alerte
|
||||||
|
- Remarque : pilotable en PWM
|
||||||
|
|
||||||
|
## DHT11
|
||||||
|
- DHT11 : GPIO 14
|
||||||
|
- Usage : capteur température/humidité
|
||||||
|
- Remarque : nécessite librairie DHT11, attention au timing
|
||||||
|
|
||||||
|
## MPU6050
|
||||||
|
- MPU6050 : I2C (SDA=8, SCL=9), adresse 0x68
|
||||||
|
- Usage : capteur IMU (accéléro/gyro)
|
||||||
|
- Remarque : vérifier alimentation, pull-up I2C
|
||||||
|
# Synthèse technique & onboarding audio ESP32-S3 (Freenove)
|
||||||
|
|
||||||
|
### Absence de DAC matériel et fallback I2S
|
||||||
|
|
||||||
|
- **ESP32-S3 ne possède pas de DAC intégré** : toute sortie audio doit passer par l’interface I2S.
|
||||||
|
- **I2S (Inter-IC Sound)** : protocole série dédié à l’audio, géré par deux périphériques sur ESP32-S3. Permet d’envoyer des samples vers un DAC externe ou un ampli via filtre passe-bas.
|
||||||
|
- **Modes supportés** : Standard (Philips/MSB/PCM), PDM, TDM. Mode standard ou PDM recommandé.
|
||||||
|
- **API Espressif** : utiliser les drivers ESP-IDF (`driver/i2s_std.h`, `driver/i2s_pdm.h`).
|
||||||
|
- **Exemple minimal** : initialiser canal TX, configurer mode standard, activer canal, envoyer samples.
|
||||||
|
- **Recommandations** :
|
||||||
|
- Utiliser un DAC externe (ex : ES8311, PCM5102, ampli + filtre RC)
|
||||||
|
- Adapter le mapping GPIO selon le schéma Freenove
|
||||||
|
- Pour PDM, exploiter le convertisseur PCM2PDM matériel
|
||||||
|
- Consulter les exemples Espressif : [i2s_basic/i2s_std](https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s/i2s_basic/i2s_std), [i2s_codec/i2s_es8311](https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s/i2s_codec/i2s_es8311)
|
||||||
|
|
||||||
|
### Onboarding : Marche à suivre pour l’audio ESP32-S3
|
||||||
|
|
||||||
|
1. **Vérifier le schéma hardware** : identifier les broches I2S (BCLK, WS, DOUT) et le DAC/ampli.
|
||||||
|
2. **Configurer le firmware** : adapter le mapping dans `ui_freenove_config.h` et `platformio.ini`.
|
||||||
|
3. **Utiliser le fallback I2S** : remplacer tout appel à `dacWrite` par une routine I2S (`i2sWriteSample`).
|
||||||
|
4. **Initialiser I2S** : suivre l’exemple Espressif pour la configuration du canal, du clock, des slots et des GPIO.
|
||||||
|
5. **Tester l’audio** : utiliser un signal simple (ex : sinusoïde) pour valider la sortie.
|
||||||
|
6. **Documenter la traçabilité** : reporter les étapes, patchs et artefacts dans [docs/AGENT_TODO.md](docs/AGENT_TODO.md).
|
||||||
|
|
||||||
|
### Liens techniques
|
||||||
|
|
||||||
|
- [Espressif I2S API Reference](https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/api-reference/peripherals/i2s.html)
|
||||||
|
- [Exemples I2S Espressif](https://github.com/espressif/esp-idf/tree/master/examples/peripherals/i2s)
|
||||||
|
- [Datasheet ESP32-S3](https://www.espressif.com/sites/default/files/documentation/esp32-s3_datasheet_en.pdf)
|
||||||
|
- [Arduino-ESP32 Documentation](https://docs.espressif.com/projects/arduino-esp32/en/latest/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synthèse audit et traçabilité (17/02/2026)
|
||||||
|
|
||||||
|
- Audit de cohérence exécuté (artifacts/audit/firmware_20260217) : RESULT=PASS
|
||||||
|
- Mapping hardware, scripts, onboarding, traçabilité validés
|
||||||
|
- Correction audio ESP32-S3 : fallback I2S, conditional compilation, documentation
|
||||||
|
- Evidence : logs et artefacts non versionnés, paths/timestamps tracés dans AGENT_TODO.md et rapport d’audit
|
||||||
|
- Rapport et synthèse prêts à partager (voir artifacts/audit/firmware_20260217)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mode ciblé et automatisation (2026)
|
||||||
|
|
||||||
|
- Pour cibler un environnement spécifique (ex : Freenove ESP32-S3), utiliser la variable d’environnement :
|
||||||
|
- `ZACUS_ENV="freenove_esp32s3" ./tools/dev/run_matrix_and_smoke.sh`
|
||||||
|
- Ne pas passer d’argument en ligne de commande (option non reconnue)
|
||||||
|
- Le cockpit détecte automatiquement le hardware et propose le build/smoke ciblé
|
||||||
|
- Les logs et artefacts sont synchronisés avec AGENT_TODO.md pour traçabilité
|
||||||
|
- En cas d’échec UI link, consulter les logs et relancer le test (retry automatique en cours d’intégration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Onboarding : sélection d’environnement
|
||||||
|
|
||||||
|
- La sélection de l’environnement hardware (Freenove ESP32-S3, ESP32, ESP8266, etc.) se fait **exclusivement** via la variable d’environnement `ZACUS_ENV`.
|
||||||
|
- Ne jamais passer l’environnement en argument CLI : tous les scripts (build, flash, smoke) détectent la cible via `ZACUS_ENV`.
|
||||||
|
- Exemple : pour tester sur Freenove ESP32-S3 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ZACUS_ENV="freenove_esp32s3"
|
||||||
|
./tools/dev/cockpit.sh smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
- Les scripts cockpit.sh, build_all.sh, run_matrix_and_smoke.sh, etc. utilisent cette variable pour déterminer la cible, le mapping hardware, et les artefacts.
|
||||||
|
|
||||||
|
# UI link : retry et diagnostic
|
||||||
|
|
||||||
|
- Le check UI link (connexion UI ESP32/ESP8266) intègre désormais une logique de retry automatique (3 essais par défaut, paramétrable via `ZACUS_UI_LINK_RETRIES`).
|
||||||
|
- En cas d’échec, le script logue chaque tentative, synchronise l’évidence dans `docs/AGENT_TODO.md`, et produit un rapport détaillé dans les artefacts.
|
||||||
|
- Toute évolution ou échec du protocole UI link est traçable dans les logs et la documentation.
|
||||||
|
|
||||||
|
# cockpit.sh : automatisation smoke test
|
||||||
|
|
||||||
|
- cockpit.sh détecte automatiquement la cible hardware via `ZACUS_ENV` et lance le smoke test adapté (Freenove, ESP32, ESP8266, etc.) sans intervention manuelle.
|
||||||
|
- En mode CI ou batch (`CI=true` ou `ZACUS_BATCH=1`), les prompts USB sont désactivés : la détection est automatique, le countdown est skip, aucun blocage.
|
||||||
|
|
||||||
|
# Synchronisation evidence/logs
|
||||||
|
|
||||||
|
- Chaque run (succès ou échec) synchronise l’évidence (artefacts, logs, verdict) dans `docs/AGENT_TODO.md`.
|
||||||
|
- Les artefacts et logs sont produits dans `hardware/firmware/artifacts/` et `hardware/firmware/logs/`.
|
||||||
|
- La traçabilité est assurée pour chaque run, avec horodatage, chemin, et verdict.
|
||||||
|
|
||||||
|
# Prompt USB : optimisation CI/batch
|
||||||
|
|
||||||
|
- En mode CI ou batch, les prompts USB sont automatiquement désactivés : aucun countdown, aucune interaction requise.
|
||||||
|
- Les scripts détectent le mode batch et adaptent le comportement pour garantir la non-interactivité.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mapping hardware – ESP32-S3 Freenove Media Kit
|
||||||
|
|
||||||
|
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|
||||||
|
|------------------|-----------------|-----------------------|--------------------------------|
|
||||||
|
| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) |
|
||||||
|
| TFT MOSI | GPIO 21 | MOSI | SPI écran |
|
||||||
|
| TFT MISO | -1 | MISO | non utilisé |
|
||||||
|
| TFT CS | -1 | CS | câblage board intégré |
|
||||||
|
| TFT DC | GPIO 45 | DC | Data/Command écran |
|
||||||
|
| TFT RESET | GPIO 20 | RESET | Reset écran |
|
||||||
|
| TFT BL | GPIO 2 | BL | Rétroéclairage |
|
||||||
|
| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) |
|
||||||
|
| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel |
|
||||||
|
| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques |
|
||||||
|
| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 |
|
||||||
|
| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 |
|
||||||
|
| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 |
|
||||||
|
| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions |
|
||||||
|
|
||||||
|
**Remarques** :
|
||||||
|
- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE <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`).
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
|
||||||
|
---
|
||||||
|
# 🎛️ Zacus Firmware – Freenove All-in-One
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Description
|
||||||
|
|
||||||
|
Bienvenue dans le cockpit le plus funky du multivers ! Ce firmware fusionne UI, audio, scénarios et hardware sur la carte Freenove Media Kit (ESP32). Ici, chaque pixel danse, chaque bouton groove, et chaque boot est une fête.
|
||||||
|
|
||||||
|
> *"Si tu entends un son rétro ou vois un plasma violet, c’est normal. Si le microcontrôleur se met à rapper, c’est probablement un bug… ou un feature caché."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🕹️ Mapping hardware (ESP32-S3 Freenove Media Kit)
|
||||||
|
|
||||||
|
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|
||||||
|
|------------------|-----------------|-----------------------|--------------------------------|
|
||||||
|
| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) |
|
||||||
|
| TFT MOSI | GPIO 21 | MOSI | SPI écran |
|
||||||
|
| TFT MISO | -1 | MISO | non utilisé |
|
||||||
|
| TFT CS | -1 | CS | câblage board intégré |
|
||||||
|
| TFT DC | GPIO 45 | DC | Data/Command écran |
|
||||||
|
| TFT RESET | GPIO 20 | RESET | Reset écran |
|
||||||
|
| TFT BL | GPIO 2 | BL | Rétroéclairage |
|
||||||
|
| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) |
|
||||||
|
| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel |
|
||||||
|
| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques |
|
||||||
|
| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 |
|
||||||
|
| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 |
|
||||||
|
| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 |
|
||||||
|
| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions |
|
||||||
|
|
||||||
|
**Remarques** :
|
||||||
|
- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE <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 d’ESP32 séparé)
|
||||||
|
|
||||||
|
> *"Si tu rates un bouton, c’est que tu danses trop fort. Si tu vois un plasma violet, c’est que tu es dans le groove."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Modules principaux
|
||||||
|
|
||||||
|
- `audio_manager` : fait swinguer l’audio (lecture, stop, état)
|
||||||
|
- `scenario_manager` : enchaîne les étapes comme un DJ
|
||||||
|
- `ui_manager` : LVGL, écrans dynamiques, FX visuels
|
||||||
|
- `storage_manager` : LittleFS (init, vérif, groove des assets)
|
||||||
|
- `button_manager` : boutons physiques, pour les vrais
|
||||||
|
- `touch_manager` : tactile XPT2046, pour les DJ du futur
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🦄 Funky extras & notes
|
||||||
|
|
||||||
|
- Firmware expérimental : fusion audio + UI, mode "party hard" activé
|
||||||
|
- Pour la compatibilité UI Link, prévoir un mode optionnel
|
||||||
|
- Si tu veux une intro Amiga92, active la scène `SCENE_WIN_ETAPE` et laisse-toi porter par le FX timeline (plasma, starfield, boingball…)
|
||||||
|
- Pour tester la scène MP3 : `SCENE_MP3_PLAYER` (overlay AmigaAMP, scan `/music`)
|
||||||
|
- Pour la caméra : `SCENE_CAMERA_SCAN` (Win311 overlay, boutons = SNAP/SAVE/GALLERY/DELETE/CLOSE)
|
||||||
|
- Pour tout le reste, consulte les logs, et si tu comprends tout du premier coup, tu gagnes un badge "Zacus Funk Master".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contribuer
|
||||||
|
|
||||||
|
Merci de lire [../../../../../../CONTRIBUTING.md](../../../../../../CONTRIBUTING.md) avant toute PR. Les pull requests avec punchlines sont encouragées.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 Contact
|
||||||
|
|
||||||
|
Pour toute question, suggestion, ou battle de funk, ouvre une issue GitHub ou contacte l’auteur principal :
|
||||||
|
- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare)
|
||||||
|
|
||||||
|
> *"Ce firmware a été validé par un oscilloscope, un grille-pain, et un synthé vintage. Si tu trouves un bug, c’est peut-être un easter egg ou feat foirée."*
|
||||||
|
---
|
||||||
|
|
||||||
|
# Firmware Freenove Media Kit All-in-One
|
||||||
|
|
||||||
|
## Plan d’intégration complète (couverture specs)
|
||||||
|
|
||||||
|
- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/)
|
||||||
|
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
|
||||||
|
- Exécution de scénarios (lecture, transitions, actions, audio)
|
||||||
|
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
|
||||||
|
- Gestion boutons et tactile (événements, mapping, callbacks)
|
||||||
|
- Fallback robuste si fichier manquant (scénario par défaut)
|
||||||
|
- Génération de logs et artefacts (logs/, artifacts/)
|
||||||
|
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
|
||||||
|
- Documentation et onboarding synchronisés
|
||||||
|
|
||||||
|
## Structure modulaire
|
||||||
|
|
||||||
|
- `src/app/*` : orchestration runtime (`main`, `scenario_manager`, coordinator serie)
|
||||||
|
- `src/ui/*` : LVGL scenes, fonts, FX helpers (`ui_manager`, `ui_fonts`, `ui/fx`)
|
||||||
|
- `src/audio/*` : gestion audio runtime (`audio_manager`)
|
||||||
|
- `src/storage/*` : LittleFS/SD et resolution assets (`storage_manager`)
|
||||||
|
- `src/camera/*` : camera runtime (`camera_manager`)
|
||||||
|
- `src/drivers/*` : board I/O (input, board, display HAL + SPI bus manager)
|
||||||
|
- `src/system/*` : metrics, boot report, reseau/media wrappers, task topology
|
||||||
|
- headers legacy (`include/*_manager.h`) conserves en facades vers les headers modulaires
|
||||||
|
|
||||||
|
Ce firmware combine :
|
||||||
|
- Les fonctions audio/scénario (type ESP32 Audio)
|
||||||
|
- L’UI locale (affichage, interaction, tactile, boutons)
|
||||||
|
- Le tout sur un seul microcontrôleur (RP2040 ou ESP32 selon le kit)
|
||||||
|
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
- Lecture audio, gestion scénario, LittleFS
|
||||||
|
- Affichage TFT tactile (LVGL)
|
||||||
|
- Boutons physiques, capteurs, extensions
|
||||||
|
- Mode autonome (pas besoin d’ESP32 séparé)
|
||||||
|
|
||||||
|
## Modules principaux
|
||||||
|
- audio_manager : gestion audio (lecture, stop, état)
|
||||||
|
- scenario_manager : gestion scénario (étapes, transitions)
|
||||||
|
- ui_manager : gestion UI (LVGL, écrans dynamiques)
|
||||||
|
- storage_manager : gestion LittleFS (init, vérification)
|
||||||
|
- button_manager : gestion boutons physiques
|
||||||
|
- touch_manager : gestion tactile XPT2046
|
||||||
|
|
||||||
|
## Validation hardware
|
||||||
|
- Compiler et flasher sur le Freenove Media Kit
|
||||||
|
- Vérifier l’affichage, la réactivité tactile et boutons
|
||||||
|
- Tester la lecture audio (fichiers dans /data/)
|
||||||
|
- Consulter les logs série pour le suivi d’exécution
|
||||||
|
|
||||||
|
## Artefacts
|
||||||
|
- Firmware compilé (.bin)
|
||||||
|
- Logs de test hardware (logs/)
|
||||||
|
- Rapport de compatibilité assets LittleFS
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Depuis `hardware/firmware` :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pio run -e freenove_allinone
|
||||||
|
pio run -e freenove_allinone -t upload --upload-port <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 l’adaptation des pins
|
||||||
|
- Schéma de branchement : se référer à la doc Freenove
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ce firmware est expérimental et fusionne les logiques audio + UI.
|
||||||
|
- Pour la compatibilité UI Link, prévoir un mode optionnel.
|
||||||
|
|
||||||
|
## Intro Amiga92 (`SCENE_WIN_ETAPE`)
|
||||||
|
|
||||||
|
- Activation: l'intro A/B/C est lancee automatiquement quand `screen_scene_id == SCENE_WIN_ETAPE`.
|
||||||
|
- Mode runtime: `FX_ONLY_V9` avec rendu timeline FX (plasma/starfield/rasterbars + tunnel3d/rotozoom/wirecube + boingball) et overlay LVGL conserve.
|
||||||
|
- Sequence timeline verrouillee: `A(30000ms) -> B(15000ms) -> C(20000ms) -> C loop`.
|
||||||
|
- Mapping presets (default):
|
||||||
|
- A: `demo`
|
||||||
|
- B: `winner`
|
||||||
|
- C: `boingball`
|
||||||
|
- Font scroller default: `italic`.
|
||||||
|
- BPM default: `125`.
|
||||||
|
- Boing shadow path: assembleur S3 active par defaut (`UI_BOING_SHADOW_ASM=1`) avec fallback C automatique.
|
||||||
|
- Log de boot FX: `boing_shadow_path=asm|c`.
|
||||||
|
|
||||||
|
### Overrides runtime (TXT + JSON)
|
||||||
|
|
||||||
|
- Priorite de lecture:
|
||||||
|
1) `/ui/scene_win_etape.json`
|
||||||
|
2) `/SCENE_WIN_ETAPE.json`
|
||||||
|
3) `/ui/SCENE_WIN_ETAPE.json`
|
||||||
|
4) `/ui/scene_win_etape.txt`
|
||||||
|
- Cles supportees:
|
||||||
|
- `A_MS`, `B_MS`, `C_MS`
|
||||||
|
- `FX_PRESET_A`, `FX_PRESET_B`, `FX_PRESET_C` (`demo|winner|fireworks|boingball`)
|
||||||
|
- `FX_MODE_A`, `FX_MODE_B`, `FX_MODE_C` (`classic|starfield3d|dotsphere3d|voxel|raycorridor`)
|
||||||
|
- `FX_SCROLL_TEXT_A`, `FX_SCROLL_TEXT_B`, `FX_SCROLL_TEXT_C`
|
||||||
|
- `FX_SCROLL_FONT` (`basic|bold|outline|italic`)
|
||||||
|
- `FX_BPM` (`60..220`)
|
||||||
|
- Rupture de compatibilite volontaire:
|
||||||
|
- anciennes cles FX `FX_3D`, `FX_3D_QUALITY`, `FONT_MODE` ne sont plus prises en charge dans ce flux.
|
||||||
|
|
||||||
|
Exemples:
|
||||||
|
- JSON: `data/SCENE_WIN_ETAPE.json`
|
||||||
|
- TXT: `data/ui/scene_win_etape.txt`
|
||||||
|
|
||||||
|
### Perf notes
|
||||||
|
|
||||||
|
- Tick fixe: `42 ms` (`~24 FPS` cible), `dt` clamp pour robustesse.
|
||||||
|
- Zero allocation par frame dans la boucle intro (`tickIntro`).
|
||||||
|
- Blit LGFX: fast-path 2x active (`UI_FX_BLIT_FAST_2X=1`) si ratio source/ecran exact, fallback scaler general sinon.
|
||||||
|
- Caps objets scene: `<=140` (petit ecran), `<=260` (grand ecran).
|
||||||
|
- Pools fixes:
|
||||||
|
- stars: `48`
|
||||||
|
- fireworks: `72`
|
||||||
|
- wave glyph+shadow: `64 + 64`
|
||||||
|
- `UI_GFX_STATUS` expose `fx_fps/fx_frames/fx_skip_busy` + compteurs `flush_block/overflow` pour diagnostiquer les saccades LVGL/FX.
|
||||||
|
|
||||||
|
## Scenes demoscene exposees
|
||||||
|
|
||||||
|
- IDs canoniques ajoutes: `SCENE_WINNER`, `SCENE_FIREWORKS`.
|
||||||
|
- Fichiers data:
|
||||||
|
- `data/story/screens/SCENE_WINNER.json`
|
||||||
|
- `data/story/screens/SCENE_FIREWORKS.json`
|
||||||
|
- Registry story: `hardware/libs/story/src/resources/screen_scene_registry.cpp`.
|
||||||
|
|
||||||
|
## Scene lecteur MP3 (`SCENE_MP3_PLAYER`)
|
||||||
|
|
||||||
|
- Scene canonique: `SCENE_MP3_PLAYER`.
|
||||||
|
- Aliases supportes: `SCENE_AUDIO_PLAYER`, `SCENE_MP3`.
|
||||||
|
- Data scene: `data/story/screens/SCENE_MP3_PLAYER.json`.
|
||||||
|
- Mode runtime: overlay LVGL "AmigaAMP" + backend `AudioPlayerService` (scan `/music`, fallback `/audio/music`, fallback `/audio`).
|
||||||
|
- Arbitrage audio:
|
||||||
|
- en scene MP3, l'audio scenario est suspendu;
|
||||||
|
- en sortie de scene MP3, pipeline audio scenario restaure.
|
||||||
|
- Commandes serie:
|
||||||
|
- `AMP_SHOW`, `AMP_HIDE`, `AMP_TOGGLE`
|
||||||
|
- `AMP_SCAN`, `AMP_PLAY <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
|
||||||
@@ -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"
|
||||||
@@ -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
Reference in New Issue
Block a user