Add default scenario and associated screens and audio packs

- Created default scenario JSON file with steps and audio references.
- Added screen definitions for SCENE_BROKEN, SCENE_LA_DETECT, SCENE_LOCKED, SCENE_READY, SCENE_WIN, and their respective themes and effects.
- Introduced audio packs for boot radio and win sounds.
- Updated story scenario files with new screen and audio references.
- Configured LVGL settings in lv_conf.h for UI components.
This commit is contained in:
Clément SAILLANT
2026-02-20 21:01:28 +01:00
parent fa6fcef21f
commit bca6e050ad
43 changed files with 2928 additions and 265 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
{
"id": "DEFAULT",
"scenario": "DEFAULT",
"initial_step": "STEP_WAIT_UNLOCK",
"steps": [
{
"id": "STEP_WAIT_UNLOCK",
"screen_scene_id": "SCENE_LOCKED"
},
{
"id": "STEP_U_SON_PROTO",
"screen_scene_id": "SCENE_BROKEN",
"audio_pack_id": "PACK_BOOT_RADIO"
},
{
"id": "STEP_WAIT_ETAPE2",
"screen_scene_id": "SCENE_LA_DETECT"
},
{
"id": "STEP_ETAPE2",
"screen_scene_id": "SCENE_WIN",
"audio_pack_id": "PACK_WIN"
},
{
"id": "STEP_DONE",
"screen_scene_id": "SCENE_READY"
}
],
"source": "littlefs-default"
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_BROKEN",
"effect": "blink",
"theme": {
"bg": "#2A0508",
"accent": "#FF4A45",
"text": "#FFF1F1"
},
"content": {
"description": "Ecran de transition prototype"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_LA_DETECT",
"effect": "scan",
"theme": {
"bg": "#041F1B",
"accent": "#2CE5A6",
"text": "#EFFFF8"
},
"content": {
"description": "Detection en cours"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_LOCKED",
"effect": "pulse",
"theme": {
"bg": "#08152D",
"accent": "#3E8DFF",
"text": "#F5F8FF"
},
"content": {
"description": "Ecran verrouille, debut du scenario"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "SCENE_READY",
"effect": "pulse",
"theme": {
"bg": "#0F2A12",
"accent": "#6CD96B",
"text": "#EDFFED"
},
"content": {
"description": "Pret"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "SCENE_WIN",
"effect": "celebrate",
"theme": {
"bg": "#231038",
"accent": "#F4CB4A",
"text": "#FFF8E2"
},
"content": {
"description": "Victoire"
}
}
@@ -0,0 +1,5 @@
{
"id": "PACK_BOOT_RADIO",
"file": "/music/boot_radio.mp3",
"volume": 100
}
@@ -0,0 +1,5 @@
{
"id": "PACK_MORSE_HINT",
"file": "/music/morse_hint.mp3",
"volume": 100
}
@@ -0,0 +1,5 @@
{
"id": "PACK_SONAR_HINT",
"file": "/music/sonar_hint.mp3",
"volume": 100
}
@@ -0,0 +1,5 @@
{
"id": "PACK_WIN",
"file": "/music/win.mp3",
"volume": 100
}
@@ -0,0 +1,33 @@
{
"id": "DEFAULT",
"scenario": "DEFAULT",
"version": 2,
"initial_step": "STEP_WAIT_UNLOCK",
"steps": [
{
"id": "STEP_WAIT_UNLOCK",
"screen_scene_id": "SCENE_LOCKED"
},
{
"id": "STEP_U_SON_PROTO",
"screen_scene_id": "SCENE_BROKEN",
"audio_pack_id": "PACK_BOOT_RADIO"
},
{
"id": "STEP_WAIT_ETAPE2",
"screen_scene_id": "SCENE_LA_DETECT"
},
{
"id": "STEP_ETAPE2",
"screen_scene_id": "SCENE_WIN",
"audio_pack_id": "PACK_WIN"
},
{
"id": "STEP_DONE",
"screen_scene_id": "SCENE_READY"
}
],
"source": "story_selector",
"screen_root": "/story/screens",
"audio_root": "/story/audio"
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_BROKEN",
"title": "PROTO U-SON",
"subtitle": "Signal brouille",
"symbol": "ALERT",
"effect": "blink",
"theme": {
"bg": "#2A0508",
"accent": "#FF4A45",
"text": "#FFF1F1"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_LA_DETECT",
"title": "DETECTION",
"subtitle": "Balayage en cours",
"symbol": "SCAN",
"effect": "scan",
"theme": {
"bg": "#041F1B",
"accent": "#2CE5A6",
"text": "#EFFFF8"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_LOCKED",
"title": "VERROUILLE",
"subtitle": "Attente de debloquage",
"symbol": "LOCK",
"effect": "pulse",
"theme": {
"bg": "#08152D",
"accent": "#3E8DFF",
"text": "#F5F8FF"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_READY",
"title": "PRET",
"subtitle": "Scenario termine",
"symbol": "READY",
"effect": "pulse",
"theme": {
"bg": "#0F2A12",
"accent": "#6CD96B",
"text": "#EDFFED"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_REWARD",
"title": "RECOMPENSE",
"subtitle": "Indice debloque",
"symbol": "WIN",
"effect": "celebrate",
"theme": {
"bg": "#2A103E",
"accent": "#F9D860",
"text": "#FFF9E6"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_SEARCH",
"title": "RECHERCHE",
"subtitle": "Analyse des indices",
"symbol": "SCAN",
"effect": "scan",
"theme": {
"bg": "#05261F",
"accent": "#35E7B0",
"text": "#EFFFF8"
}
}
@@ -0,0 +1,12 @@
{
"id": "SCENE_WIN",
"title": "VICTOIRE",
"subtitle": "Etape validee",
"symbol": "WIN",
"effect": "celebrate",
"theme": {
"bg": "#231038",
"accent": "#F4CB4A",
"text": "#FFF8E2"
}
}
+18
View File
@@ -9,6 +9,24 @@
7. Consigner les logs et artefacts produits (logs/rc_live/freenove_esp32s3_YYYYMMDD.log, artifacts/rc_live/freenove_esp32s3_YYYYMMDD.html).
8. Documenter toute anomalie ou fail dans AGENT_TODO.md.
## [2026-02-20] Freenove audio + écran + chaîne scénario/écran (Codex)
- Checkpoint sécurité exécuté: branche `main`, `git diff --stat` capturé, patch/status sauvegardés:
- `/tmp/zacus_checkpoint/20260220_203024_wip.patch`
- `/tmp/zacus_checkpoint/20260220_203024_status.txt`
- Scan artefacts trackés (`.pio`, `.platformio`, `logs`, `dist`, `build`, `node_modules`, `.venv`): aucun chemin tracké.
- Build/flash Freenove validés (`/dev/cu.usbmodem5AB90753301`):
- `pio run -e freenove_esp32s3_full_with_ui` PASS
- `pio run -e freenove_esp32s3_full_with_ui -t buildfs` PASS
- `pio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port /dev/cu.usbmodem5AB90753301` PASS
- `pio run -e freenove_esp32s3_full_with_ui -t upload --upload-port /dev/cu.usbmodem5AB90753301` PASS
- Validation série (`pio device monitor --port /dev/cu.usbmodem5AB90753301 --baud 115200`) :
- `AUDIO_TEST` PASS (tonalité intégrée RTTTL, sans dépendance LittleFS)
- `AUDIO_TEST_FS` PASS (`/music/boot_radio.mp3`)
- `AUDIO_PROFILE 0/1/2` PASS (switch runtime pinout I2S)
- `UNLOCK` -> transition `STEP_U_SON_PROTO`, audio pack `PACK_BOOT_RADIO`, puis `audio_done` -> transition `STEP_WAIT_ETAPE2` PASS.
- Incohérence restante observée au boot: warning HAL SPI `spiAttachMISO(): HSPI Does not have default pins on ESP32S3!` (non bloquant en exécution actuelle).
## TODO Freenove ESP32-S3 (contrat agent)
### [2026-02-17] Log détaillé des étapes réalisées Freenove ESP32-S3
+15 -22
View File
@@ -2,28 +2,21 @@
| Fonction | Pin (ESP32-S3) | Définition macro | Remarque/Conflit |
|------------------|----------------|-------------------------|------------------|
| LCD Width | 480 | FREENOVE_LCD_WIDTH | |
| LCD Height | 320 | FREENOVE_LCD_HEIGHT | |
| TFT SCK | 2/18 | FREENOVE_TFT_SCK | Différence board |
| TFT MOSI | 3/23 | FREENOVE_TFT_MOSI | |
| TFT MISO | 4/19 | FREENOVE_TFT_MISO | Optionnel |
| TFT CS | 5 | FREENOVE_TFT_CS | Partagé BTN_4 |
| TFT DC | 6/16 | FREENOVE_TFT_DC | |
| TFT RST | 7/17 | FREENOVE_TFT_RST | |
| TFT BL | 4 | FREENOVE_TFT_BL | Partagé BTN_3 |
| Touch CS | 9/21 | FREENOVE_TOUCH_CS | |
| Touch IRQ | 15/22 | FREENOVE_TOUCH_IRQ | |
| UART TX | 43/0/1 | FREENOVE_UART_TX | Adapter board |
| UART RX | 44/3 | FREENOVE_UART_RX | Adapter board |
| I2S WS | 25 | FREENOVE_I2S_WS | ESP32 only |
| I2S BCK | 26 | FREENOVE_I2S_BCK | ESP32 only |
| I2S DOUT | 27 | FREENOVE_I2S_DOUT | ESP32 only |
| LED | 13 | FREENOVE_LED | Si dispo |
| Buzzer | 12 | FREENOVE_BUZZER | Si dispo |
| DHT11 | 14 | FREENOVE_DHT11 | Si dispo |
| I2C SDA | 8 | FREENOVE_I2C_SDA | Si utilisé |
| I2C SCL | 9 | FREENOVE_I2C_SCL | Si utilisé |
| MPU6050 Addr | 0x68 | FREENOVE_MPU6050_ADDR | Si utilisé |
| LCD Width | 320 | FREENOVE_LCD_WIDTH | panneau physique |
| LCD Height | 480 | FREENOVE_LCD_HEIGHT | panneau physique |
| TFT SCK | 47 | FREENOVE_TFT_SCK | profil Sketch_19 |
| TFT MOSI | 21 | FREENOVE_TFT_MOSI | profil Sketch_19 |
| TFT MISO | -1 | FREENOVE_TFT_MISO | non utilisé |
| TFT CS | -1 | FREENOVE_TFT_CS | board intégré |
| TFT DC | 45 | FREENOVE_TFT_DC | |
| TFT RST | 20 | FREENOVE_TFT_RST | |
| TFT BL | 2 | FREENOVE_TFT_BL | |
| Touch CS | 9 | FREENOVE_TOUCH_CS | optionnel |
| Touch IRQ | 15 | FREENOVE_TOUCH_IRQ | optionnel |
| Boutons | 19 | FREENOVE_BTN_ANALOG_PIN | ladder analogique|
| I2S WS | 41 | FREENOVE_I2S_WS | profil principal |
| I2S BCK | 42 | FREENOVE_I2S_BCK | profil principal |
| I2S DOUT | 1 | FREENOVE_I2S_DOUT | profil principal |
### Librairies recommandées
- [TFT_eSPI](https://github.com/Bodmer/TFT_eSPI) (écran TFT)
@@ -132,26 +132,22 @@ Recommandation: ajouter des commentaires dans ui_freenove_config.h et tester
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|------------------|-----------------|-----------------------|--------------------------------|
| TFT SCK | GPIO 18 | SCK | SPI écran |
| TFT MOSI | GPIO 23 | MOSI | SPI écran |
| TFT MISO | GPIO 19 | MISO | SPI écran (si utilisé) |
| TFT CS | GPIO 5 | CS | Chip Select écran |
| TFT DC | GPIO 16 | DC | Data/Command écran |
| TFT RESET | GPIO 17 | RESET | Reset écran |
| TFT BL | GPIO 4 | BL | Rétroéclairage (PWM possible) |
| Touch CS | GPIO 21 | CS (XPT2046) | SPI tactile |
| Touch IRQ | GPIO 22 | IRQ (XPT2046) | Interruption tactile |
| Bouton 1 | GPIO 2 | BTN1 | Pull-up interne |
| Bouton 2 | GPIO 3 | BTN2 | Pull-up interne |
| Bouton 3 | GPIO 4 | BTN3 | Pull-up interne |
| Bouton 4 | GPIO 5 | BTN4 | Pull-up interne |
| Audio I2S WS | GPIO 25 | WS | I2S audio |
| Audio I2S BCK | GPIO 26 | BCK | I2S audio |
| Audio I2S DOUT | GPIO 27 | DOUT | I2S audio |
| 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**:
- Adapter les GPIO selon la version du kit (voir schéma Freenove officiel)
- Certains signaux peuvent être multiplexés selon les options du kit
- Pour laudio, vérifier la compatibilité du codec (DAC/AMP)
- Pour le tactile, vérifier le contrôleur (XPT2046 ou autre)
- 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`).
@@ -2,29 +2,25 @@
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|------------------|-----------------|-----------------------|--------------------------------|
| TFT SCK | GPIO 18 | SCK | SPI écran |
| TFT MOSI | GPIO 23 | MOSI | SPI écran |
| TFT MISO | GPIO 19 | MISO | SPI écran (si utilisé) |
| TFT CS | GPIO 5 | CS | Chip Select écran |
| TFT DC | GPIO 16 | DC | Data/Command écran |
| TFT RESET | GPIO 17 | RESET | Reset écran |
| TFT BL | GPIO 4 | BL | Rétroéclairage (PWM possible) |
| Touch CS | GPIO 21 | CS (XPT2046) | SPI tactile |
| Touch IRQ | GPIO 22 | IRQ (XPT2046) | Interruption tactile |
| Bouton 1 | GPIO 2 | BTN1 | Pull-up interne |
| Bouton 2 | GPIO 3 | BTN2 | Pull-up interne |
| Bouton 3 | GPIO 4 | BTN3 | Pull-up interne |
| Bouton 4 | GPIO 5 | BTN4 | Pull-up interne |
| Audio I2S WS | GPIO 25 | WS | I2S audio |
| Audio I2S BCK | GPIO 26 | BCK | I2S audio |
| Audio I2S DOUT | GPIO 27 | DOUT | I2S audio |
| 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**:
- Adapter les GPIO selon la version du kit (voir schéma Freenove officiel)
- Certains signaux peuvent être multiplexés selon les options du kit
- Pour laudio, vérifier la compatibilité du codec (DAC/AMP)
- Pour le tactile, vérifier le contrôleur (XPT2046 ou autre)
- 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`).
# Firmware Freenove Media Kit All-in-One
@@ -1,10 +1,47 @@
// audio_manager.h - Interface gestion audio
// audio_manager.h - audio playback over I2S for Freenove all-in-one firmware.
#pragma once
#include <Arduino.h>
class AudioManager {
public:
void begin();
void play(const char* filename);
using AudioDoneCallback = void (*)(const char* track, void* ctx);
AudioManager();
~AudioManager();
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;
void setAudioDoneCallback(AudioDoneCallback cb, void* ctx);
private:
bool ensureOutput();
void applyOutputProfile();
void stopDecoder();
void finishPlaybackAndNotify();
class AudioGenerator* decoder_ = nullptr;
class AudioFileSource* source_ = nullptr;
class AudioOutputI2S* output_ = nullptr;
bool playing_ = false;
bool using_diagnostic_tone_ = false;
uint8_t volume_ = 21;
uint8_t output_profile_ = 0U;
String current_track_;
AudioDoneCallback done_cb_ = nullptr;
void* done_ctx_ = nullptr;
};
@@ -1,8 +1,37 @@
// button_manager.h - Interface gestion boutons
// button_manager.h - button scan + long press detection.
#pragma once
#include <Arduino.h>
struct ButtonEvent {
uint8_t key = 0U;
bool long_press = false;
};
class ButtonManager {
public:
void begin();
bool isPressed(int btn);
bool begin();
bool pollEvent(ButtonEvent* out_event);
bool isPressed(uint8_t key) const;
uint8_t currentKey() const;
int lastAnalogMilliVolts() const;
private:
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,27 @@
#ifndef LV_CONF_H
#define LV_CONF_H
#define LV_COLOR_DEPTH 16
#define LV_COLOR_16_SWAP 0
#define LV_MEM_CUSTOM 0
#define LV_MEM_SIZE (96U * 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_USE_THEME_DEFAULT 1
#define LV_USE_LABEL 1
#define LV_USE_BTN 1
#define LV_USE_PERF_MONITOR 0
#define LV_USE_MEM_MONITOR 0
#endif // LV_CONF_H
@@ -1,9 +1,64 @@
// scenario_manager.h - Interface gestion scénario
// scenario_manager.h - Story runtime wrapper for Freenove all-in-one.
#pragma once
#include <Arduino.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;
bool mp3_gate_open = false;
};
class ScenarioManager {
public:
void begin();
void nextStep();
bool begin(const char* scenario_file_path);
void reset();
void tick(uint32_t now_ms);
void notifyUnlock(uint32_t now_ms);
void notifyButton(uint8_t key, bool long_press, uint32_t now_ms);
void notifyAudioDone(uint32_t now_ms);
ScenarioSnapshot snapshot() const;
bool consumeSceneChanged();
bool consumeAudioRequest(String* out_audio_pack_id);
private:
struct StepResourceOverride {
String step_id;
String screen_scene_id;
String audio_pack_id;
};
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;
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);
bool runImmediateTransitions(uint32_t now_ms, const char* source);
void evaluateAfterMsTransitions(uint32_t now_ms);
void enterStep(int8_t step_index, uint32_t now_ms, const char* source);
const StepDef* currentStep() const;
bool transitionMatches(const TransitionDef& transition, StoryEventType type, 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;
String pending_audio_pack_;
String initial_step_override_;
StepResourceOverride step_resource_overrides_[kMaxStepResourceOverrides];
uint8_t step_resource_override_count_ = 0U;
};
@@ -1,8 +1,16 @@
// storage_manager.h - Interface gestion LittleFS
// storage_manager.h - LittleFS helpers and fallback provisioning.
#pragma once
#include <Arduino.h>
class StorageManager {
public:
void begin();
bool exists(const char* path);
bool begin();
bool ensurePath(const char* path);
bool fileExists(const char* path) const;
String loadTextFile(const char* path) const;
String loadScenePayloadById(const char* scene_id) const;
String resolveAudioPathByPackId(const char* pack_id) const;
bool ensureDefaultScenarioFile(const char* path);
uint32_t checksum(const char* path) const;
};
@@ -1,8 +1,16 @@
// touch_manager.h - Interface gestion tactile
// 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:
void begin();
bool getTouch(int16_t* x, int16_t* y);
bool begin();
bool poll(TouchPoint* out_point);
};
@@ -1,53 +1,102 @@
#pragma once
// --- Core UI defines ---
#define SPI_FREQUENCY 40000000 // Fréquence SPI écran
#define SPI_READ_FREQUENCY 20000000 // Fréquence SPI lecture
#define SPI_TOUCH_FREQUENCY 2500000 // Fréquence SPI tactile
#define UI_LCD_SPI_HOST 0 // SPI Host LCD
#define UI_SERIAL_BAUD 57600 // Baudrate UI link interne
#define UI_ROTATION 1 // Rotation UI par défaut
#define ILI9488_DRIVER // Driver écran ILI9488
#define TFT_RGB_ORDER TFT_BGR // Ordre RGB écran
// Freenove Media Kit reference profile (Sketch_19):
// - FNK0102B ST7796 320x480 by default, rotation 1
// - Buttons via analog ladder on GPIO19
// - I2S output BCLK=42, WS=41, DOUT=1
// Configuration hardware Freenove Media Kit All-in-One (ESP32-S3)
#ifndef FREENOVE_LCD_VARIANT_FNK0102A
#define FREENOVE_LCD_VARIANT_FNK0102A 0
#endif
// --- Display ---
#define FREENOVE_LCD_WIDTH 480 // Largeur écran
#define FREENOVE_LCD_HEIGHT 320 // Hauteur écran
#define FREENOVE_LCD_ROTATION 1 // Rotation écran (0-3)
#ifndef FREENOVE_LCD_VARIANT_FNK0102B
#define FREENOVE_LCD_VARIANT_FNK0102B 1
#endif
// --- TFT SPI ---
#define FREENOVE_TFT_SCK 18 // GPIO 18 : SCK (SPI écran)
#define FREENOVE_TFT_MOSI 23 // GPIO 23 : MOSI (SPI écran)
#define FREENOVE_TFT_MISO 19 // GPIO 19 : MISO (optionnel)
#define FREENOVE_TFT_CS 5 // GPIO 5 : CS (partagé BTN4)
#define FREENOVE_TFT_DC 16 // GPIO 16 : DC (commande/données)
#define FREENOVE_TFT_RST 17 // GPIO 17 : RESET écran
#define FREENOVE_TFT_BL 4 // GPIO 4 : BL (partagé BTN3)
#if FREENOVE_LCD_VARIANT_FNK0102A && FREENOVE_LCD_VARIANT_FNK0102B
#error "Select one LCD variant only (FNK0102A xor FNK0102B)."
#endif
// --- Touch SPI (XPT2046) ---
#define FREENOVE_TOUCH_CS 21 // GPIO 21 : CS tactile
#define FREENOVE_TOUCH_IRQ 22 // GPIO 22 : IRQ tactile
#if !FREENOVE_LCD_VARIANT_FNK0102A && !FREENOVE_LCD_VARIANT_FNK0102B
#error "At least one LCD variant must be enabled."
#endif
// --- Buttons (internal pull-up) ---
#define FREENOVE_BTN_1 2 // GPIO 2 : Bouton 1
#define FREENOVE_BTN_2 3 // GPIO 3 : Bouton 2
#define FREENOVE_BTN_3 4 // GPIO 4 : Bouton 3 (BL shared)
#define FREENOVE_BTN_4 5 // GPIO 5 : Bouton 4 (CS shared)
// Attention : ne pas activer BL et BTN3 ou CS et BTN4 simultanément.
#define UI_LCD_SPI_HOST 1
#define UI_SERIAL_BAUD 57600
#define UI_ROTATION 1
// --- Audio (I2S only) ---
#define FREENOVE_I2S_WS 25 // GPIO 25 : I2S WS (obligatoire)
#define FREENOVE_I2S_BCK 26 // GPIO 26 : I2S BCK
#define FREENOVE_I2S_DOUT 27 // GPIO 27 : I2S DOUT
#if FREENOVE_LCD_VARIANT_FNK0102A
#define FREENOVE_LCD_LABEL "FNK0102A_ST7789_135x240"
#define FREENOVE_LCD_WIDTH 135
#define FREENOVE_LCD_HEIGHT 240
#define ST7789_DRIVER
#define TFT_WIDTH 135
#define TFT_HEIGHT 240
#define TFT_SDA_READ
#define TFT_RGB_ORDER TFT_BGR
#define TFT_INVERSION_ON
#else
#define FREENOVE_LCD_LABEL "FNK0102B_ST7796_320x480"
#define FREENOVE_LCD_WIDTH 320
#define FREENOVE_LCD_HEIGHT 480
#define ST7796_DRIVER
#define TFT_INVERSION_ON
#endif
// --- Misc peripherals ---
#define FREENOVE_LED 13 // GPIO 13 : indicateur LED
#define FREENOVE_BUZZER 12 // GPIO 12 : buzzer (PWM)
#define FREENOVE_DHT11 14 // GPIO 14 : DHT11 (si utilisé)
#define FREENOVE_I2C_SDA 8 // GPIO 8 : SDA I2C (MPU6050, capteurs)
#define FREENOVE_I2C_SCL 9 // GPIO 9 : SCL I2C
#define FREENOVE_MPU6050_ADDR 0x68 // Adresse I2C MPU6050 (optionnel)
#define FREENOVE_LCD_ROTATION 1
// Ce firmware repose sur l'I2S pour l'audio et sur un seul ESP32-S3; UI link externe n'est pas requis.
// TFT SPI pins (official Freenove setup).
#define FREENOVE_TFT_SCK 47
#define FREENOVE_TFT_MOSI 21
#define FREENOVE_TFT_MISO -1
#define FREENOVE_TFT_CS -1
#define FREENOVE_TFT_DC 45
#define FREENOVE_TFT_RST 20
#define FREENOVE_TFT_BL 2
// TFT_eSPI bridge defines.
#define USER_SETUP_LOADED 1
#define TFT_MOSI FREENOVE_TFT_MOSI
#define TFT_SCLK FREENOVE_TFT_SCK
#define TFT_MISO FREENOVE_TFT_MISO
#define TFT_CS FREENOVE_TFT_CS
#define TFT_DC FREENOVE_TFT_DC
#define TFT_RST FREENOVE_TFT_RST
#define TFT_BL FREENOVE_TFT_BL
#define TFT_BACKLIGHT_ON HIGH
#define SPI_FREQUENCY 80000000
#define SPI_READ_FREQUENCY 20000000
#define SUPPORT_TRANSACTIONS
#define USE_FSPI_PORT
// Buttons: analog ladder by default (5 keys).
#define FREENOVE_BTN_ANALOG_PIN 19
#define FREENOVE_BTN_1 -1
#define FREENOVE_BTN_2 -1
#define FREENOVE_BTN_3 -1
#define FREENOVE_BTN_4 -1
#define FREENOVE_BTN_LONG_PRESS_MS 700
#ifndef FREENOVE_HAS_TOUCH
#define FREENOVE_HAS_TOUCH 0
#endif
#if FREENOVE_HAS_TOUCH
#define FREENOVE_TOUCH_CS 9
#define FREENOVE_TOUCH_IRQ 15
#define SPI_TOUCH_FREQUENCY 2500000
#define TOUCH_CS FREENOVE_TOUCH_CS
#else
#define TOUCH_CS -1
#endif
// Storage and audio buses from Freenove reference.
#define FREENOVE_SDMMC_CMD 38
#define FREENOVE_SDMMC_CLK 39
#define FREENOVE_SDMMC_D0 40
#define FREENOVE_I2S_WS 41
#define FREENOVE_I2S_BCK 42
#define FREENOVE_I2S_DOUT 1
#define FREENOVE_I2S_IN_SCK 3
#define FREENOVE_I2S_IN_WS 14
#define FREENOVE_I2S_IN_DIN 46
@@ -1,8 +1,65 @@
// ui_manager.h - Interface gestion UI
// ui_manager.h - LVGL/TFT scene renderer for Freenove all-in-one.
#pragma once
#include <Arduino.h>
#include <lvgl.h>
#include "core/scenario_def.h"
#include "ui/player_ui_model.h"
class UiManager {
public:
void begin();
bool begin();
void update();
void renderScene(const ScenarioDef* scenario,
const char* screen_scene_id,
const char* step_id,
const char* audio_pack_id,
bool audio_playing,
const char* screen_payload_json);
void handleButton(uint8_t key, bool long_press);
void handleTouch(int16_t x, int16_t y, bool touched);
private:
enum class SceneEffect : uint8_t {
kNone = 0,
kPulse,
kScan,
kBlink,
kCelebrate,
};
void createWidgets();
void updatePageLine();
void stopSceneAnimations();
void applySceneEffect(SceneEffect effect);
static void displayFlushCb(lv_disp_drv_t* disp, const lv_area_t* area, lv_color_t* color_p);
static void keypadReadCb(lv_indev_drv_t* drv, lv_indev_data_t* data);
static void touchReadCb(lv_indev_drv_t* drv, lv_indev_data_t* data);
static void animSetY(void* obj, int32_t value);
static void animSetOpa(void* obj, int32_t value);
static void animSetSize(void* obj, int32_t value);
static void animSetWidth(void* obj, int32_t value);
bool ready_ = false;
PlayerUiModel player_ui_;
lv_obj_t* scene_root_ = nullptr;
lv_obj_t* scene_core_ = nullptr;
lv_obj_t* scene_ring_outer_ = nullptr;
lv_obj_t* scene_ring_inner_ = nullptr;
lv_obj_t* scene_fx_bar_ = nullptr;
lv_obj_t* page_label_ = nullptr;
lv_obj_t* scene_particles_[4] = {nullptr, nullptr, nullptr, nullptr};
SceneEffect current_effect_ = SceneEffect::kNone;
uint32_t pending_key_code_ = LV_KEY_ENTER;
bool key_press_pending_ = false;
bool key_release_pending_ = false;
int16_t touch_x_ = 0;
int16_t touch_y_ = 0;
bool touch_pressed_ = false;
};
@@ -1,33 +1,289 @@
// audio_manager.cpp - Gestion audio pour firmware all-in-one
// audio_manager.cpp - audio playback over I2S.
#include "audio_manager.h"
#include <Arduino.h>
#include <AudioFileSource.h>
#include <AudioFileSourceLittleFS.h>
#include <AudioFileSourcePROGMEM.h>
#include <AudioGenerator.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorRTTTL.h>
#include <AudioGeneratorWAV.h>
#include <AudioOutputI2S.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
static bool playing = false;
#include "ui_freenove_config.h"
void AudioManager::begin() {
// Ex: initialiser I2S ou PWM (placeholder)
Serial.println("[AUDIO] Init audio (I2S/PWM)");
namespace {
constexpr uint8_t kVolumeMax = 21;
constexpr char kDiagnosticRtttl[] PROGMEM = "zacus:d=4,o=5,b=196:c,8e,8g,2c6";
struct AudioPinProfile {
int bck;
int ws;
int dout;
const char* label;
};
constexpr AudioPinProfile kAudioPinProfiles[] = {
{FREENOVE_I2S_BCK, FREENOVE_I2S_WS, FREENOVE_I2S_DOUT, "sketch19"},
{FREENOVE_I2S_WS, FREENOVE_I2S_BCK, FREENOVE_I2S_DOUT, "swap_bck_ws"},
{FREENOVE_I2S_BCK, FREENOVE_I2S_WS, 2, "dout2_alt"},
};
constexpr uint8_t kAudioPinProfileCount = static_cast<uint8_t>(sizeof(kAudioPinProfiles) / sizeof(kAudioPinProfiles[0]));
float volumeToGain(uint8_t volume) {
if (volume > kVolumeMax) {
volume = kVolumeMax;
}
return static_cast<float>(volume) / static_cast<float>(kVolumeMax);
}
void AudioManager::play(const char* filename) {
if (!LittleFS.exists(filename)) {
Serial.print("[AUDIO] Fichier absent: "); Serial.println(filename);
playing = false;
return;
bool endsWithIgnoreCase(const char* value, const char* suffix) {
if (value == nullptr || suffix == nullptr) {
return false;
}
Serial.print("[AUDIO] Lecture: "); Serial.println(filename);
// TODO: lecture réelle (I2S, PWM, etc.)
playing = true;
const size_t value_len = std::strlen(value);
const size_t suffix_len = std::strlen(suffix);
if (suffix_len == 0U || value_len < suffix_len) {
return false;
}
const char* tail = value + (value_len - suffix_len);
for (size_t index = 0; index < suffix_len; ++index) {
const int lhs = std::tolower(static_cast<unsigned char>(tail[index]));
const int rhs = std::tolower(static_cast<unsigned char>(suffix[index]));
if (lhs != rhs) {
return false;
}
}
return true;
}
} // namespace
AudioManager::AudioManager() = default;
AudioManager::~AudioManager() {
stop();
delete output_;
output_ = nullptr;
}
bool AudioManager::begin() {
return ensureOutput();
}
bool AudioManager::play(const char* filename) {
if (filename == nullptr || filename[0] == '\0') {
Serial.println("[AUDIO] empty filename");
return false;
}
if (!ensureOutput()) {
return false;
}
String fixed_path = filename;
if (fixed_path.startsWith("/littlefs/")) {
fixed_path = fixed_path.substring(9);
if (!fixed_path.startsWith("/")) {
fixed_path = "/" + fixed_path;
}
}
if (!LittleFS.exists(fixed_path.c_str())) {
Serial.printf("[AUDIO] missing file: %s\n", fixed_path.c_str());
return false;
}
File metadata = LittleFS.open(fixed_path.c_str(), "r");
if (!metadata) {
Serial.printf("[AUDIO] failed to open file metadata: %s\n", fixed_path.c_str());
return false;
}
const size_t file_size = static_cast<size_t>(metadata.size());
metadata.close();
if (file_size == 0U) {
Serial.printf("[AUDIO] empty audio file: %s\n", fixed_path.c_str());
return false;
}
stopDecoder();
const bool is_wav = endsWithIgnoreCase(fixed_path.c_str(), ".wav");
source_ = new AudioFileSourceLittleFS(fixed_path.c_str());
decoder_ = is_wav ? static_cast<AudioGenerator*>(new AudioGeneratorWAV())
: static_cast<AudioGenerator*>(new AudioGeneratorMP3());
if (source_ == nullptr || decoder_ == nullptr) {
Serial.println("[AUDIO] allocation failure");
stopDecoder();
return false;
}
output_->SetGain(volumeToGain(volume_));
if (!decoder_->begin(source_, output_)) {
Serial.printf("[AUDIO] decoder begin failed: %s\n", fixed_path.c_str());
stopDecoder();
return false;
}
using_diagnostic_tone_ = false;
current_track_ = fixed_path.c_str();
playing_ = true;
Serial.printf("[AUDIO] playing (%s): %s\n", is_wav ? "wav" : "mp3", fixed_path.c_str());
return true;
}
bool AudioManager::playDiagnosticTone() {
if (!ensureOutput()) {
return false;
}
stopDecoder();
source_ = new AudioFileSourcePROGMEM(kDiagnosticRtttl, std::strlen(kDiagnosticRtttl));
decoder_ = new AudioGeneratorRTTTL();
if (source_ == nullptr || decoder_ == nullptr) {
Serial.println("[AUDIO] diagnostic tone allocation failure");
stopDecoder();
return false;
}
output_->SetGain(volumeToGain(volume_));
if (!decoder_->begin(source_, output_)) {
Serial.println("[AUDIO] diagnostic tone begin failed");
stopDecoder();
return false;
}
using_diagnostic_tone_ = true;
current_track_ = "builtin:rtttl";
playing_ = true;
Serial.printf("[AUDIO] playing diagnostic tone (profile=%u:%s)\n",
output_profile_,
outputProfileLabel(output_profile_));
return true;
}
void AudioManager::stop() {
Serial.println("[AUDIO] Stop");
playing = false;
stopDecoder();
}
void AudioManager::update() {
if (!playing_ || decoder_ == nullptr) {
return;
}
if (!decoder_->isRunning()) {
finishPlaybackAndNotify();
return;
}
if (!decoder_->loop()) {
finishPlaybackAndNotify();
}
}
bool AudioManager::isPlaying() const {
return playing;
return playing_;
}
void AudioManager::setVolume(uint8_t volume) {
if (volume > kVolumeMax) {
volume = kVolumeMax;
}
volume_ = volume;
if (output_ != nullptr) {
output_->SetGain(volumeToGain(volume_));
}
}
uint8_t AudioManager::volume() const {
return volume_;
}
const char* AudioManager::currentTrack() const {
return current_track_.c_str();
}
bool AudioManager::setOutputProfile(uint8_t profile_index) {
if (profile_index >= kAudioPinProfileCount) {
return false;
}
output_profile_ = profile_index;
applyOutputProfile();
return true;
}
uint8_t AudioManager::outputProfile() const {
return output_profile_;
}
uint8_t AudioManager::outputProfileCount() const {
return kAudioPinProfileCount;
}
const char* AudioManager::outputProfileLabel(uint8_t profile_index) const {
if (profile_index >= kAudioPinProfileCount) {
return "invalid";
}
return kAudioPinProfiles[profile_index].label;
}
void AudioManager::setAudioDoneCallback(AudioDoneCallback cb, void* ctx) {
done_cb_ = cb;
done_ctx_ = ctx;
}
bool AudioManager::ensureOutput() {
if (output_ == nullptr) {
output_ = new AudioOutputI2S(0, AudioOutputI2S::EXTERNAL_I2S);
}
if (output_ == nullptr) {
Serial.println("[AUDIO] failed to allocate I2S output");
return false;
}
output_->SetOutputModeMono(true);
output_->SetGain(volumeToGain(volume_));
applyOutputProfile();
return true;
}
void AudioManager::applyOutputProfile() {
if (output_ == nullptr) {
return;
}
if (output_profile_ >= kAudioPinProfileCount) {
output_profile_ = 0U;
}
const AudioPinProfile& profile = kAudioPinProfiles[output_profile_];
output_->SetPinout(profile.bck, profile.ws, profile.dout);
Serial.printf("[AUDIO] ready (profile=%u:%s bck=%d ws=%d dout=%d mode=mono)\n",
output_profile_,
profile.label,
profile.bck,
profile.ws,
profile.dout);
}
void AudioManager::stopDecoder() {
if (decoder_ != nullptr) {
decoder_->stop();
delete decoder_;
decoder_ = nullptr;
}
if (source_ != nullptr) {
source_->close();
delete source_;
source_ = nullptr;
}
using_diagnostic_tone_ = false;
playing_ = false;
}
void AudioManager::finishPlaybackAndNotify() {
const String finished_track = current_track_;
stopDecoder();
if (done_cb_ != nullptr) {
done_cb_(finished_track.c_str(), done_ctx_);
}
}
@@ -1,11 +1,172 @@
// button_manager.cpp - Gestion des boutons physiques
// button_manager.cpp - button scan + long press detection.
#include "button_manager.h"
void ButtonManager::begin() {
// TODO: Initialiser les GPIO pour les boutons
#include "ui_freenove_config.h"
namespace {
constexpr uint32_t kDebounceMs = 30U;
constexpr uint32_t kLongPressMs = FREENOVE_BTN_LONG_PRESS_MS;
constexpr int kNoAnalogButtonMv = 2800;
const int kDigitalButtonPins[4] = {
FREENOVE_BTN_1,
FREENOVE_BTN_2,
FREENOVE_BTN_3,
FREENOVE_BTN_4,
};
} // namespace
bool ButtonManager::begin() {
#if FREENOVE_BTN_ANALOG_PIN >= 0
analog_mode_ = true;
analogReadResolution(12);
analogSetAttenuation(ADC_11db);
pinMode(FREENOVE_BTN_ANALOG_PIN, INPUT);
analog_key_ = 0U;
analog_raw_key_ = 0U;
analog_pressed_at_ms_ = 0U;
analog_raw_changed_ms_ = millis();
last_analog_mv_ = kNoAnalogButtonMv;
Serial.printf("[BTN] analog ladder mode on GPIO %d\n", FREENOVE_BTN_ANALOG_PIN);
#else
analog_mode_ = false;
last_analog_mv_ = -1;
for (uint8_t index = 0; index < 4; ++index) {
if (kDigitalButtonPins[index] >= 0) {
pinMode(kDigitalButtonPins[index], INPUT_PULLUP);
}
}
Serial.println("[BTN] digital mode");
#endif
return true;
}
bool ButtonManager::isPressed(int btn) {
// TODO: Lire l'état du bouton (par numéro)
bool ButtonManager::pollEvent(ButtonEvent* out_event) {
if (out_event == nullptr) {
return false;
}
if (analog_mode_) {
return pollAnalog(out_event);
}
return pollDigital(out_event);
}
bool ButtonManager::isPressed(uint8_t key) const {
if (key < 1U || key > 5U) {
return false;
}
if (analog_mode_) {
return analog_key_ == key;
}
if (key > 4U) {
return false;
}
return digital_pressed_[key - 1U];
}
uint8_t ButtonManager::currentKey() const {
if (analog_mode_) {
return analog_key_;
}
for (uint8_t index = 0; index < 4; ++index) {
if (digital_pressed_[index]) {
return static_cast<uint8_t>(index + 1U);
}
}
return 0U;
}
int ButtonManager::lastAnalogMilliVolts() const {
return last_analog_mv_;
}
uint8_t ButtonManager::decodeAnalogKey(int millivolts) const {
if (millivolts >= (kNoAnalogButtonMv - threshold_range_mv_)) {
return 0U;
}
for (int index = 1; index < 6; ++index) {
const int threshold = voltage_thresholds_[index];
if (millivolts >= (threshold - threshold_range_mv_) &&
millivolts <= (threshold + threshold_range_mv_)) {
if (index >= 1 && index <= 5) {
return static_cast<uint8_t>(index);
}
return 0U;
}
}
return 0U;
}
bool ButtonManager::pollAnalog(ButtonEvent* out_event) {
#if FREENOVE_BTN_ANALOG_PIN < 0
(void)out_event;
return false;
#else
const int analog_mv = analogReadMilliVolts(FREENOVE_BTN_ANALOG_PIN);
last_analog_mv_ = analog_mv;
const uint8_t raw_key = decodeAnalogKey(analog_mv);
const uint32_t now_ms = millis();
if (raw_key != analog_raw_key_) {
analog_raw_key_ = raw_key;
analog_raw_changed_ms_ = now_ms;
}
if ((now_ms - analog_raw_changed_ms_) < kDebounceMs) {
return false;
}
const uint8_t stable_key = analog_raw_key_;
if (stable_key == analog_key_) {
return false;
}
if (analog_key_ == 0U && stable_key > 0U) {
analog_key_ = stable_key;
analog_pressed_at_ms_ = now_ms;
return false;
}
if (analog_key_ == 0U) {
return false;
}
const uint8_t released_key = analog_key_;
const uint32_t hold_ms = now_ms - analog_pressed_at_ms_;
analog_key_ = stable_key;
analog_pressed_at_ms_ = (stable_key > 0U) ? now_ms : 0U;
if (hold_ms < kDebounceMs) {
return false;
}
out_event->key = released_key;
out_event->long_press = hold_ms >= kLongPressMs;
return true;
#endif
}
bool ButtonManager::pollDigital(ButtonEvent* out_event) {
const uint32_t now_ms = millis();
for (uint8_t index = 0; index < 4; ++index) {
if (kDigitalButtonPins[index] < 0) {
continue;
}
const bool pressed = (digitalRead(kDigitalButtonPins[index]) == LOW);
if (pressed && !digital_pressed_[index]) {
digital_pressed_[index] = true;
digital_pressed_at_ms_[index] = now_ms;
continue;
}
if (!pressed && digital_pressed_[index]) {
const uint32_t hold_ms = now_ms - digital_pressed_at_ms_[index];
digital_pressed_[index] = false;
if (hold_ms < kDebounceMs) {
continue;
}
out_event->key = static_cast<uint8_t>(index + 1U);
out_event->long_press = hold_ms >= kLongPressMs;
return true;
}
}
return false;
}
@@ -1,17 +1,21 @@
// main.cpp - Firmware Freenove Media Kit All-in-One (fusion audio + UI)
// main.cpp - Freenove ESP32-S3 all-in-one runtime loop.
#include <Arduino.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include <lvgl.h>
#include <LittleFS.h>
#include "../include/ui_freenove_config.h"
#include <cstdlib>
#include <cstring>
#include "../include/audio_manager.h"
#include "../include/scenario_manager.h"
#include "audio_manager.h"
#include "button_manager.h"
#include "scenario_manager.h"
#include "storage_manager.h"
#include "touch_manager.h"
#include "ui_manager.h"
TFT_eSPI tft;
XPT2046_Touchscreen touch(FREENOVE_TOUCH_CS, FREENOVE_TOUCH_IRQ);
namespace {
constexpr const char* kDefaultScenarioFile = "/story/scenarios/DEFAULT.json";
constexpr const char* kDiagAudioFile = "/music/boot_radio.mp3";
constexpr size_t kSerialLineCapacity = 96U;
constexpr bool kBootDiagnosticTone = true;
AudioManager g_audio;
ScenarioManager g_scenario;
@@ -19,91 +23,341 @@ UiManager g_ui;
StorageManager g_storage;
ButtonManager g_buttons;
TouchManager g_touch;
char g_serial_line[kSerialLineCapacity] = {0};
size_t g_serial_line_len = 0U;
// Variables d'état
String currentScene = "/data/scene_default.json";
String currentScreen = "/data/screen_default.json";
unsigned long lastUiUpdate = 0;
const unsigned long UI_UPDATE_INTERVAL = 30; // ms
void logEvent(const char* msg) {
Serial.println(msg);
// TODO: écrire dans logs/ si besoin
const char* audioPackToFile(const char* pack_id) {
if (pack_id == nullptr || pack_id[0] == '\0') {
return nullptr;
}
if (std::strcmp(pack_id, "PACK_BOOT_RADIO") == 0) {
return "/music/boot_radio.mp3";
}
if (std::strcmp(pack_id, "PACK_SONAR_HINT") == 0) {
return "/music/sonar_hint.mp3";
}
if (std::strcmp(pack_id, "PACK_MORSE_HINT") == 0) {
return "/music/morse_hint.mp3";
}
if (std::strcmp(pack_id, "PACK_WIN") == 0) {
return "/music/win.mp3";
}
return "/music/placeholder.mp3";
}
void onAudioFinished(const char* track, void* ctx) {
(void)ctx;
Serial.printf("[MAIN] audio done: %s\n", track != nullptr ? track : "unknown");
g_scenario.notifyAudioDone(millis());
}
void printButtonRead() {
Serial.printf("BTN mv=%d key=%u\n", g_buttons.lastAnalogMilliVolts(), g_buttons.currentKey());
}
void printRuntimeStatus() {
const ScenarioSnapshot snapshot = g_scenario.snapshot();
const char* scenario_id =
(snapshot.scenario != nullptr && snapshot.scenario->id != nullptr) ? snapshot.scenario->id : "n/a";
const char* step_id = (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a";
const char* screen_id = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "n/a";
const char* audio_pack = (snapshot.audio_pack_id != nullptr) ? snapshot.audio_pack_id : "n/a";
Serial.printf("STATUS scenario=%s step=%s screen=%s pack=%s audio=%u track=%s profile=%u:%s vol=%u key=%u mv=%d\n",
scenario_id,
step_id,
screen_id,
audio_pack,
g_audio.isPlaying() ? 1 : 0,
g_audio.currentTrack(),
g_audio.outputProfile(),
g_audio.outputProfileLabel(g_audio.outputProfile()),
g_audio.volume(),
g_buttons.currentKey(),
g_buttons.lastAnalogMilliVolts());
}
void refreshSceneIfNeeded(bool force_render) {
const bool changed = g_scenario.consumeSceneChanged();
if (!force_render && !changed) {
return;
}
const ScenarioSnapshot snapshot = g_scenario.snapshot();
const char* step_id = (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a";
const String screen_payload = g_storage.loadScenePayloadById(snapshot.screen_scene_id);
Serial.printf("[UI] render step=%s screen=%s pack=%s playing=%u\n",
step_id,
snapshot.screen_scene_id != nullptr ? snapshot.screen_scene_id : "n/a",
snapshot.audio_pack_id != nullptr ? snapshot.audio_pack_id : "n/a",
g_audio.isPlaying() ? 1U : 0U);
g_ui.renderScene(snapshot.scenario,
snapshot.screen_scene_id,
step_id,
snapshot.audio_pack_id,
g_audio.isPlaying(),
screen_payload.isEmpty() ? nullptr : screen_payload.c_str());
}
void startPendingAudioIfAny() {
String audio_pack;
if (!g_scenario.consumeAudioRequest(&audio_pack)) {
return;
}
const String configured_path = g_storage.resolveAudioPathByPackId(audio_pack.c_str());
const char* mapped_path = audioPackToFile(audio_pack.c_str());
if (configured_path.isEmpty() && mapped_path == nullptr) {
if (g_audio.playDiagnosticTone()) {
Serial.printf("[MAIN] audio pack=%s has no asset mapping, fallback=builtin_tone\n", audio_pack.c_str());
return;
}
Serial.printf("[MAIN] audio pack=%s has no asset mapping and no fallback tone\n", audio_pack.c_str());
g_scenario.notifyAudioDone(millis());
return;
}
if (!configured_path.isEmpty() && g_audio.play(configured_path.c_str())) {
Serial.printf("[MAIN] audio pack=%s path=%s source=story_audio_json\n",
audio_pack.c_str(),
configured_path.c_str());
return;
}
if (mapped_path != nullptr && g_audio.play(mapped_path)) {
Serial.printf("[MAIN] audio pack=%s path=%s source=pack_map\n", audio_pack.c_str(), mapped_path);
return;
}
if (g_audio.play(kDiagAudioFile)) {
Serial.printf("[MAIN] audio fallback for pack=%s fallback=%s\n", audio_pack.c_str(), kDiagAudioFile);
return;
}
if (g_audio.playDiagnosticTone()) {
Serial.printf("[MAIN] audio fallback for pack=%s fallback=builtin_tone\n", audio_pack.c_str());
return;
}
// If audio cannot start (missing/invalid file), unblock scenario transitions.
Serial.printf("[MAIN] audio fallback failed for pack=%s\n", audio_pack.c_str());
g_scenario.notifyAudioDone(millis());
}
void handleSerialCommand(const char* command_line, uint32_t now_ms) {
if (command_line == nullptr || command_line[0] == '\0') {
return;
}
char command[kSerialLineCapacity] = {0};
std::strncpy(command, command_line, kSerialLineCapacity - 1U);
char* argument = nullptr;
for (size_t index = 0; index < kSerialLineCapacity && command[index] != '\0'; ++index) {
if (command[index] == ' ') {
command[index] = '\0';
argument = &command[index + 1U];
break;
}
}
while (argument != nullptr && *argument == ' ') {
++argument;
}
if (argument != nullptr && *argument == '\0') {
argument = nullptr;
}
if (std::strcmp(command, "PING") == 0) {
Serial.println("PONG");
return;
}
if (std::strcmp(command, "HELP") == 0) {
Serial.println("CMDS PING STATUS BTN_READ NEXT UNLOCK RESET AUDIO_TEST AUDIO_TEST_FS AUDIO_PROFILE <idx> AUDIO_STATUS VOL <0..21> AUDIO_STOP STOP");
return;
}
if (std::strcmp(command, "STATUS") == 0) {
printRuntimeStatus();
return;
}
if (std::strcmp(command, "BTN_READ") == 0) {
printButtonRead();
return;
}
if (std::strcmp(command, "NEXT") == 0) {
g_scenario.notifyButton(5, false, now_ms);
Serial.println("ACK NEXT");
return;
}
if (std::strcmp(command, "UNLOCK") == 0) {
g_scenario.notifyUnlock(now_ms);
Serial.println("ACK UNLOCK");
return;
}
if (std::strcmp(command, "RESET") == 0) {
g_scenario.reset();
Serial.println("ACK RESET");
return;
}
if (std::strcmp(command, "AUDIO_TEST") == 0) {
g_audio.stop();
const bool ok = g_audio.playDiagnosticTone();
Serial.printf("ACK AUDIO_TEST %u\n", ok ? 1 : 0);
return;
}
if (std::strcmp(command, "AUDIO_TEST_FS") == 0) {
g_audio.stop();
const bool ok = g_audio.play(kDiagAudioFile);
Serial.printf("ACK AUDIO_TEST_FS %u\n", ok ? 1 : 0);
return;
}
if (std::strcmp(command, "AUDIO_PROFILE") == 0) {
if (argument == nullptr) {
Serial.printf("AUDIO_PROFILE current=%u label=%s count=%u\n",
g_audio.outputProfile(),
g_audio.outputProfileLabel(g_audio.outputProfile()),
g_audio.outputProfileCount());
return;
}
char* end = nullptr;
const unsigned long parsed = std::strtoul(argument, &end, 10);
if (end == argument || (end != nullptr && *end != '\0') || parsed > 255UL) {
Serial.println("ERR AUDIO_PROFILE_ARG");
return;
}
const uint8_t profile = static_cast<uint8_t>(parsed);
const bool ok = g_audio.setOutputProfile(profile);
Serial.printf("ACK AUDIO_PROFILE %u %u %s\n",
profile,
ok ? 1U : 0U,
ok ? g_audio.outputProfileLabel(profile) : "invalid");
return;
}
if (std::strcmp(command, "AUDIO_STATUS") == 0) {
Serial.printf("AUDIO_STATUS playing=%u track=%s profile=%u:%s vol=%u\n",
g_audio.isPlaying() ? 1U : 0U,
g_audio.currentTrack(),
g_audio.outputProfile(),
g_audio.outputProfileLabel(g_audio.outputProfile()),
g_audio.volume());
return;
}
if (std::strcmp(command, "VOL") == 0) {
if (argument == nullptr) {
Serial.printf("VOL %u\n", g_audio.volume());
return;
}
char* end = nullptr;
const unsigned long parsed = std::strtoul(argument, &end, 10);
if (end == argument || (end != nullptr && *end != '\0') || parsed > 21UL) {
Serial.println("ERR VOL_ARG");
return;
}
g_audio.setVolume(static_cast<uint8_t>(parsed));
Serial.printf("ACK VOL %u\n", g_audio.volume());
return;
}
if (std::strcmp(command, "AUDIO_STOP") == 0) {
g_audio.stop();
Serial.println("ACK AUDIO_STOP");
return;
}
if (std::strcmp(command, "STOP") == 0) {
g_audio.stop();
Serial.println("ACK STOP");
return;
}
Serial.printf("UNKNOWN %s\n", command_line);
}
void pollSerialCommands(uint32_t now_ms) {
while (Serial.available() > 0) {
const int raw = Serial.read();
if (raw < 0) {
break;
}
const char ch = static_cast<char>(raw);
if (ch == '\r' || ch == '\n') {
if (g_serial_line_len == 0U) {
continue;
}
g_serial_line[g_serial_line_len] = '\0';
handleSerialCommand(g_serial_line, now_ms);
g_serial_line_len = 0U;
continue;
}
if (g_serial_line_len + 1U >= kSerialLineCapacity) {
g_serial_line_len = 0U;
Serial.println("ERR CMD_TOO_LONG");
continue;
}
g_serial_line[g_serial_line_len++] = ch;
}
}
} // namespace
void setup() {
Serial.begin(115200);
delay(100);
Serial.println("[FREENOVE] All-in-One boot");
Serial.println("[MAIN] Freenove all-in-one boot");
// LittleFS
if (!LittleFS.begin()) {
Serial.println("[FREENOVE] LittleFS mount FAIL");
} else {
Serial.println("[FREENOVE] LittleFS mount OK");
if (!g_storage.begin()) {
Serial.println("[MAIN] storage init failed");
}
g_storage.ensurePath("/data");
g_storage.ensurePath("/scenarios");
g_storage.ensurePath("/scenarios/data");
g_storage.ensurePath("/screens");
g_storage.ensurePath("/story");
g_storage.ensurePath("/story/scenarios");
g_storage.ensurePath("/story/screens");
g_storage.ensurePath("/story/audio");
g_storage.ensurePath("/picture");
g_storage.ensurePath("/music");
g_storage.ensurePath("/audio");
g_storage.ensurePath("/recorder");
g_storage.ensureDefaultScenarioFile(kDefaultScenarioFile);
Serial.printf("[MAIN] default scenario checksum=%lu\n",
static_cast<unsigned long>(g_storage.checksum(kDefaultScenarioFile)));
g_buttons.begin();
g_touch.begin();
g_audio.begin();
Serial.printf("[MAIN] audio profile=%u:%s count=%u\n",
g_audio.outputProfile(),
g_audio.outputProfileLabel(g_audio.outputProfile()),
g_audio.outputProfileCount());
g_audio.setAudioDoneCallback(onAudioFinished, nullptr);
if (kBootDiagnosticTone) {
g_audio.playDiagnosticTone();
}
// SPI + écran
SPI.setSCK(FREENOVE_TFT_SCK);
// TODO: Vérifier la présence du scénario par défaut sur LittleFS
logEvent("[FREENOVE] All-in-One boot");
// TODO: Initialiser la navigation UI (LVGL, écrans dynamiques)
// TODO: Mapper les callbacks boutons/tactile vers la navigation UI
// TODO: Préparer le fallback LittleFS si fichier manquant
// TODO: Logger l'initialisation (logs/)
SPI.setTX(FREENOVE_TFT_MOSI);
SPI.setRX(FREENOVE_TFT_MISO);
SPI.begin();
// === BOUCLE PRINCIPALE D'INTÉGRATION ===
if (!g_scenario.begin(kDefaultScenarioFile)) {
Serial.println("[MAIN] scenario init failed");
}
// 1. Navigation UI (LVGL, écrans dynamiques)
// - Afficher l'écran courant (chargé depuis data/)
// - Gérer les transitions (boutons/tactile)
// - Mettre à jour l'affichage selon l'état du scénario
// 2. Exécution du scénario
// - Lire le fichier de scénario courant (data/scene_xxx.json)
// - Exécuter les actions (audio, transitions, effets)
// - Gérer les transitions de scène (fin, next, choix UI)
// 3. Gestion audio
// - Jouer les fichiers audio associés à la scène/écran
// - Stopper/mettre en pause selon l'état du scénario
// - Gérer les erreurs de lecture (fallback, logs)
// 4. Gestion stockage
// - Vérifier la présence des fichiers nécessaires (LittleFS)
// - Fallback si fichier manquant (scénario par défaut)
// - Logger les accès/erreurs
// 5. Gestion boutons/tactile
// - Lire les événements boutons/tactile
// - Mapper vers actions UI/scénario
// - Logger les interactions
// 6. Logs/artefacts
// - Générer des logs d'évidence (logs/)
// - Produire des artefacts pour validation
// TODO: Implémenter chaque étape dans les modules correspondants
// LVGL init
lv_init();
// TODO: config LVGL port, buffer, etc.
// Init audio et scénario
g_audio.begin();
g_scenario.begin();
// TODO: init boutons, capteurs Freenove
g_ui.begin();
refreshSceneIfNeeded(true);
startPendingAudioIfAny();
}
void loop() {
// Boucle fusionnée audio + UI + scénario
// Exécution du scénario (exemple)
// g_scenario.nextStep();
// Gestion audio (exemple)
// if (!g_audio.isPlaying()) g_audio.play("/audio/step1.mp3");
// TODO: gestion UI, boutons, capteurs
lv_timer_handler();
delay(10);
const uint32_t now_ms = millis();
pollSerialCommands(now_ms);
ButtonEvent event;
while (g_buttons.pollEvent(&event)) {
Serial.printf("[MAIN] button key=%u long=%u\n", event.key, event.long_press ? 1 : 0);
g_ui.handleButton(event.key, event.long_press);
g_scenario.notifyButton(event.key, event.long_press, now_ms);
}
TouchPoint touch;
if (g_touch.poll(&touch)) {
g_ui.handleTouch(touch.x, touch.y, touch.touched);
} else {
g_ui.handleTouch(0, 0, false);
}
g_audio.update();
g_scenario.tick(now_ms);
startPendingAudioIfAny();
refreshSceneIfNeeded(false);
g_ui.update();
delay(5);
}
@@ -1,14 +1,525 @@
// scenario_manager.cpp - Gestion des scénarios pour firmware all-in-one
// scenario_manager.cpp - Story transitions + timing hooks.
#include "scenario_manager.h"
void ScenarioManager::begin() {
// TODO: Charger le scénario par défaut depuis LittleFS
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <cstring>
#include "scenarios/default_scenario_v2.h"
namespace {
constexpr uint32_t kEtape2DelayMs = 15UL * 60UL * 1000UL;
constexpr uint32_t kEtape2TestDelayMs = 5000U;
bool eventNameMatches(const char* expected, const char* actual) {
if (expected == nullptr || expected[0] == '\0') {
return true;
}
if (actual == nullptr) {
return false;
}
return std::strcmp(expected, actual) == 0;
}
void ScenarioManager::nextStep() {
// TODO: Passer à l'étape suivante du scénario
const char* stringOrNull(JsonVariantConst value) {
if (!value.is<const char*>()) {
return nullptr;
}
const char* text = value.as<const char*>();
if (text == nullptr || text[0] == '\0') {
return nullptr;
}
return text;
}
bool loadScenarioIdFromFile(const char* scenario_file_path, String* out_scenario_id) {
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0' || out_scenario_id == nullptr) {
return false;
}
if (!LittleFS.exists(scenario_file_path)) {
return false;
}
File file = LittleFS.open(scenario_file_path, "r");
if (!file) {
Serial.printf("[SCENARIO] failed to open scenario config: %s\n", scenario_file_path);
return false;
}
const size_t file_size = static_cast<size_t>(file.size());
if (file_size == 0U || file_size > 12288U) {
file.close();
Serial.printf("[SCENARIO] unexpected scenario config size: %s (%u bytes)\n",
scenario_file_path,
static_cast<unsigned int>(file_size));
return false;
}
DynamicJsonDocument document(file_size + 512U);
const DeserializationError error = deserializeJson(document, file);
file.close();
if (error) {
Serial.printf("[SCENARIO] invalid scenario config json (%s): %s\n",
scenario_file_path,
error.c_str());
return false;
}
const char* scenario_id = nullptr;
JsonVariantConst candidate = document["scenario"];
if (candidate.is<const char*>()) {
scenario_id = candidate.as<const char*>();
}
if ((scenario_id == nullptr || scenario_id[0] == '\0')) {
candidate = document["scenario_id"];
if (candidate.is<const char*>()) {
scenario_id = candidate.as<const char*>();
}
}
if ((scenario_id == nullptr || scenario_id[0] == '\0')) {
candidate = document["id"];
if (candidate.is<const char*>()) {
scenario_id = candidate.as<const char*>();
}
}
if (scenario_id == nullptr || scenario_id[0] == '\0') {
Serial.printf("[SCENARIO] missing scenario id in config: %s\n", scenario_file_path);
return false;
}
*out_scenario_id = scenario_id;
return true;
}
} // namespace
bool ScenarioManager::begin(const char* scenario_file_path) {
scenario_ = nullptr;
initial_step_override_.remove(0);
clearStepResourceOverrides();
String selected_scenario_id;
if (loadScenarioIdFromFile(scenario_file_path, &selected_scenario_id)) {
scenario_ = storyScenarioV2ById(selected_scenario_id.c_str());
if (scenario_ != nullptr) {
Serial.printf("[SCENARIO] selected id from %s: %s\n",
scenario_file_path,
selected_scenario_id.c_str());
} else {
Serial.printf("[SCENARIO] unknown id in %s: %s (fallback default)\n",
scenario_file_path,
selected_scenario_id.c_str());
}
} else if (scenario_file_path != nullptr && scenario_file_path[0] != '\0') {
Serial.printf("[SCENARIO] no valid scenario config at %s (fallback default)\n", scenario_file_path);
}
if (scenario_ == nullptr) {
scenario_ = storyScenarioV2Default();
}
if (scenario_ == nullptr) {
Serial.println("[SCENARIO] default scenario unavailable");
return false;
}
if (storyValidateScenarioDef(*scenario_, nullptr)) {
Serial.printf("[SCENARIO] loaded built-in scenario: %s v%u (%u steps)\n",
scenario_->id,
scenario_->version,
scenario_->stepCount);
} else {
Serial.printf("[SCENARIO] warning: validation failed for %s\n", scenario_->id);
}
loadStepResourceOverrides(scenario_file_path);
reset();
return true;
}
void ScenarioManager::reset() {
// TODO: Réinitialiser le scénario
if (scenario_ == nullptr) {
return;
}
const char* initial_step_id = scenario_->initialStepId;
if (!initial_step_override_.isEmpty()) {
initial_step_id = initial_step_override_.c_str();
}
current_step_index_ = storyFindStepIndex(*scenario_, initial_step_id);
if (current_step_index_ < 0 && scenario_->stepCount > 0U) {
current_step_index_ = 0;
}
step_entered_at_ms_ = millis();
pending_audio_pack_.remove(0);
scene_changed_ = true;
timer_armed_ = false;
timer_fired_ = false;
etape2_due_at_ms_ = 0U;
const ScenarioSnapshot state = snapshot();
if (state.audio_pack_id != nullptr && state.audio_pack_id[0] != '\0') {
pending_audio_pack_ = state.audio_pack_id;
}
}
void ScenarioManager::tick(uint32_t now_ms) {
if (scenario_ == nullptr || current_step_index_ < 0) {
return;
}
evaluateAfterMsTransitions(now_ms);
if (timer_armed_ && !timer_fired_ && etape2_due_at_ms_ > 0U && now_ms >= etape2_due_at_ms_) {
timer_fired_ = true;
dispatchEvent(StoryEventType::kTimer, "ETAPE2_DUE", now_ms, "timer_due");
}
}
void ScenarioManager::notifyUnlock(uint32_t now_ms) {
timer_armed_ = true;
timer_fired_ = false;
etape2_due_at_ms_ = now_ms + (test_mode_ ? kEtape2TestDelayMs : kEtape2DelayMs);
dispatchEvent(StoryEventType::kUnlock, "UNLOCK", now_ms, "button_unlock");
}
void ScenarioManager::notifyButton(uint8_t key, bool long_press, uint32_t now_ms) {
switch (key) {
case 1:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_ETAPE2", now_ms, "btn1_long");
} else {
notifyUnlock(now_ms);
}
break;
case 2:
if (long_press) {
test_mode_ = !test_mode_;
Serial.printf("[SCENARIO] test_mode=%u\n", test_mode_ ? 1U : 0U);
}
break;
case 3:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_ETAPE2", now_ms, "btn3_long");
}
break;
case 4:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_DONE", now_ms, "btn4_long");
}
break;
case 5:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_DONE", now_ms, "btn5_long");
} else {
dispatchEvent(StoryEventType::kSerial, "NEXT", now_ms, "btn5_short");
}
break;
default:
break;
}
}
void ScenarioManager::notifyAudioDone(uint32_t now_ms) {
dispatchEvent(StoryEventType::kAudioDone, "AUDIO_DONE", now_ms, "audio_done");
}
ScenarioSnapshot ScenarioManager::snapshot() const {
ScenarioSnapshot out;
out.scenario = scenario_;
out.step = currentStep();
if (out.step != nullptr) {
const char* screen_scene_id = out.step->resources.screenSceneId;
const char* audio_pack_id = out.step->resources.audioPackId;
applyStepResourceOverride(out.step, &screen_scene_id, &audio_pack_id);
out.screen_scene_id = screen_scene_id;
out.audio_pack_id = audio_pack_id;
out.mp3_gate_open = out.step->mp3GateOpen;
}
return out;
}
bool ScenarioManager::consumeSceneChanged() {
const bool changed = scene_changed_;
scene_changed_ = false;
return changed;
}
bool ScenarioManager::consumeAudioRequest(String* out_audio_pack_id) {
if (pending_audio_pack_.isEmpty()) {
return false;
}
if (out_audio_pack_id != nullptr) {
*out_audio_pack_id = pending_audio_pack_;
}
pending_audio_pack_.remove(0);
return true;
}
bool ScenarioManager::dispatchEvent(StoryEventType type,
const char* event_name,
uint32_t now_ms,
const char* source) {
const StepDef* step = currentStep();
if (step == nullptr || step->transitionCount == 0U) {
return false;
}
const TransitionDef* selected = nullptr;
for (uint8_t i = 0; i < step->transitionCount; ++i) {
const TransitionDef& transition = step->transitions[i];
if (!transitionMatches(transition, type, event_name)) {
continue;
}
if (selected == nullptr || transition.priority > selected->priority) {
selected = &transition;
}
}
if (selected == nullptr) {
return false;
}
if (!applyTransition(*selected, now_ms, source)) {
return false;
}
runImmediateTransitions(now_ms, source);
return true;
}
bool ScenarioManager::applyTransition(const TransitionDef& transition,
uint32_t now_ms,
const char* source) {
if (scenario_ == nullptr || transition.targetStepId == nullptr) {
return false;
}
const int8_t target = storyFindStepIndex(*scenario_, transition.targetStepId);
if (target < 0) {
Serial.printf("[SCENARIO] invalid transition target: %s\n", transition.targetStepId);
return false;
}
enterStep(target, now_ms, source);
return true;
}
bool ScenarioManager::runImmediateTransitions(uint32_t now_ms, const char* source) {
bool moved = false;
for (uint8_t guard = 0; guard < 8U; ++guard) {
const StepDef* step = currentStep();
if (step == nullptr || step->transitionCount == 0U) {
break;
}
const TransitionDef* selected = nullptr;
for (uint8_t i = 0; i < step->transitionCount; ++i) {
const TransitionDef& transition = step->transitions[i];
if (transition.trigger != StoryTransitionTrigger::kImmediate) {
continue;
}
if (selected == nullptr || transition.priority > selected->priority) {
selected = &transition;
}
}
if (selected == nullptr) {
break;
}
if (!applyTransition(*selected, now_ms, source)) {
break;
}
moved = true;
}
return moved;
}
void ScenarioManager::evaluateAfterMsTransitions(uint32_t now_ms) {
const StepDef* step = currentStep();
if (step == nullptr || step->transitionCount == 0U) {
return;
}
const TransitionDef* selected = nullptr;
for (uint8_t i = 0; i < step->transitionCount; ++i) {
const TransitionDef& transition = step->transitions[i];
if (transition.trigger != StoryTransitionTrigger::kAfterMs) {
continue;
}
if (now_ms - step_entered_at_ms_ < transition.afterMs) {
continue;
}
if (selected == nullptr || transition.priority > selected->priority) {
selected = &transition;
}
}
if (selected != nullptr) {
if (applyTransition(*selected, now_ms, "after_ms")) {
runImmediateTransitions(now_ms, "after_ms");
}
}
}
void ScenarioManager::enterStep(int8_t step_index, uint32_t now_ms, const char* source) {
if (scenario_ == nullptr || step_index < 0 || step_index >= static_cast<int8_t>(scenario_->stepCount)) {
return;
}
current_step_index_ = step_index;
step_entered_at_ms_ = now_ms;
scene_changed_ = true;
const StepDef* step = currentStep();
if (step == nullptr) {
return;
}
pending_audio_pack_.remove(0);
const char* screen_scene_id = step->resources.screenSceneId;
const char* audio_pack_id = step->resources.audioPackId;
applyStepResourceOverride(step, &screen_scene_id, &audio_pack_id);
if (audio_pack_id != nullptr && audio_pack_id[0] != '\0') {
pending_audio_pack_ = audio_pack_id;
}
Serial.printf("[SCENARIO] step=%s via=%s\n", step->id, source != nullptr ? source : "n/a");
}
const StepDef* ScenarioManager::currentStep() const {
if (scenario_ == nullptr || current_step_index_ < 0 || current_step_index_ >= static_cast<int8_t>(scenario_->stepCount)) {
return nullptr;
}
return &scenario_->steps[current_step_index_];
}
bool ScenarioManager::transitionMatches(const TransitionDef& transition,
StoryEventType type,
const char* event_name) const {
if (transition.trigger != StoryTransitionTrigger::kOnEvent) {
return false;
}
if (transition.eventType != type) {
return false;
}
return eventNameMatches(transition.eventName, event_name);
}
void ScenarioManager::clearStepResourceOverrides() {
for (uint8_t index = 0; index < step_resource_override_count_; ++index) {
step_resource_overrides_[index].step_id.remove(0);
step_resource_overrides_[index].screen_scene_id.remove(0);
step_resource_overrides_[index].audio_pack_id.remove(0);
}
step_resource_override_count_ = 0U;
}
void ScenarioManager::loadStepResourceOverrides(const char* scenario_file_path) {
clearStepResourceOverrides();
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0') {
return;
}
if (!LittleFS.exists(scenario_file_path)) {
return;
}
File file = LittleFS.open(scenario_file_path, "r");
if (!file) {
return;
}
const size_t file_size = static_cast<size_t>(file.size());
if (file_size == 0U || file_size > 12288U) {
file.close();
return;
}
DynamicJsonDocument document(file_size + 1024U);
const DeserializationError error = deserializeJson(document, file);
file.close();
if (error) {
Serial.printf("[SCENARIO] override parse failed (%s): %s\n", scenario_file_path, error.c_str());
return;
}
const char* initial_step = stringOrNull(document["initial_step"]);
if (initial_step == nullptr) {
initial_step = stringOrNull(document["initialStepId"]);
}
if (initial_step != nullptr) {
initial_step_override_ = initial_step;
Serial.printf("[SCENARIO] override initial_step=%s\n", initial_step_override_.c_str());
}
JsonArrayConst steps = document["steps"].as<JsonArrayConst>();
if (steps.isNull()) {
return;
}
for (JsonVariantConst variant : steps) {
if (!variant.is<JsonObjectConst>()) {
continue;
}
JsonObjectConst step_obj = variant.as<JsonObjectConst>();
const char* step_id = stringOrNull(step_obj["id"]);
if (step_id == nullptr) {
continue;
}
const char* screen_scene_id = stringOrNull(step_obj["screen_scene_id"]);
if (screen_scene_id == nullptr) {
screen_scene_id = stringOrNull(step_obj["screenSceneId"]);
}
if (screen_scene_id == nullptr) {
screen_scene_id = stringOrNull(step_obj["resources"]["screen_scene_id"]);
}
if (screen_scene_id == nullptr) {
screen_scene_id = stringOrNull(step_obj["resources"]["screenSceneId"]);
}
const char* audio_pack_id = stringOrNull(step_obj["audio_pack_id"]);
if (audio_pack_id == nullptr) {
audio_pack_id = stringOrNull(step_obj["audioPackId"]);
}
if (audio_pack_id == nullptr) {
audio_pack_id = stringOrNull(step_obj["resources"]["audio_pack_id"]);
}
if (audio_pack_id == nullptr) {
audio_pack_id = stringOrNull(step_obj["resources"]["audioPackId"]);
}
if (screen_scene_id == nullptr && audio_pack_id == nullptr) {
continue;
}
if (step_resource_override_count_ >= kMaxStepResourceOverrides) {
Serial.printf("[SCENARIO] step overrides truncated at %u entries\n", kMaxStepResourceOverrides);
break;
}
StepResourceOverride& entry = step_resource_overrides_[step_resource_override_count_++];
entry.step_id = step_id;
entry.screen_scene_id = (screen_scene_id != nullptr) ? screen_scene_id : "";
entry.audio_pack_id = (audio_pack_id != nullptr) ? audio_pack_id : "";
}
if (step_resource_override_count_ > 0U) {
Serial.printf("[SCENARIO] loaded %u step resource overrides\n", step_resource_override_count_);
}
}
const ScenarioManager::StepResourceOverride* ScenarioManager::findStepResourceOverride(const char* step_id) const {
if (step_id == nullptr || step_id[0] == '\0') {
return nullptr;
}
for (uint8_t index = 0; index < step_resource_override_count_; ++index) {
const StepResourceOverride& candidate = step_resource_overrides_[index];
if (candidate.step_id == step_id) {
return &candidate;
}
}
return nullptr;
}
void ScenarioManager::applyStepResourceOverride(const StepDef* step,
const char** out_screen_scene_id,
const char** out_audio_pack_id) const {
if (step == nullptr) {
return;
}
const StepResourceOverride* entry = findStepResourceOverride(step->id);
if (entry == nullptr) {
return;
}
if (out_screen_scene_id != nullptr && !entry->screen_scene_id.isEmpty()) {
*out_screen_scene_id = entry->screen_scene_id.c_str();
}
if (out_audio_pack_id != nullptr && !entry->audio_pack_id.isEmpty()) {
*out_audio_pack_id = entry->audio_pack_id.c_str();
}
}
@@ -1,11 +1,342 @@
// storage_manager.cpp - Gestion LittleFS
// storage_manager.cpp - LittleFS provisioning helpers.
#include "storage_manager.h"
void StorageManager::begin() {
// TODO: Initialiser LittleFS
#include <ArduinoJson.h>
#include <LittleFS.h>
#include "scenarios/default_scenario_v2.h"
namespace {
constexpr const char* kRequiredDirectories[] = {
"/data",
"/picture",
"/music",
"/audio",
"/recorder",
"/story",
"/story/scenarios",
"/story/screens",
"/story/audio",
"/story/apps",
"/story/actions",
"/scenarios",
"/scenarios/data",
"/screens",
};
uint32_t fnv1aUpdate(uint32_t hash, uint8_t value) {
hash ^= value;
hash *= 16777619UL;
return hash;
}
bool StorageManager::exists(const char* path) {
// TODO: Vérifier l'existence d'un fichier
return false;
bool ensureParentDirectories(const char* file_path) {
if (file_path == nullptr || file_path[0] != '/') {
return false;
}
String parent_path = file_path;
const int last_slash = parent_path.lastIndexOf('/');
if (last_slash <= 0) {
return true;
}
parent_path = parent_path.substring(0, static_cast<unsigned int>(last_slash));
if (parent_path.isEmpty()) {
return true;
}
int segment_start = 1;
String current_path;
while (segment_start < static_cast<int>(parent_path.length())) {
const int next_slash = parent_path.indexOf('/', segment_start);
const int segment_end = (next_slash < 0) ? static_cast<int>(parent_path.length()) : next_slash;
if (segment_end <= segment_start) {
break;
}
current_path += "/";
current_path += parent_path.substring(segment_start, static_cast<unsigned int>(segment_end));
if (!LittleFS.exists(current_path.c_str()) && !LittleFS.mkdir(current_path.c_str())) {
Serial.printf("[FS] mkdir failed: %s\n", current_path.c_str());
return false;
}
if (next_slash < 0) {
break;
}
segment_start = next_slash + 1;
}
return true;
}
String normalizeAssetPath(const char* raw_path) {
if (raw_path == nullptr || raw_path[0] == '\0') {
return String();
}
String normalized = raw_path;
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
return normalized;
}
String sceneIdToSlug(const char* scene_id) {
if (scene_id == nullptr || scene_id[0] == '\0') {
return String();
}
String slug = scene_id;
if (slug.startsWith("SCENE_")) {
slug = slug.substring(6);
}
slug.toLowerCase();
return slug;
}
String packIdToSlug(const char* pack_id) {
if (pack_id == nullptr || pack_id[0] == '\0') {
return String();
}
String slug = pack_id;
if (slug.startsWith("PACK_")) {
slug = slug.substring(5);
}
slug.toLowerCase();
return slug;
}
} // namespace
bool StorageManager::begin() {
if (!LittleFS.begin()) {
Serial.println("[FS] LittleFS mount failed");
return false;
}
for (const char* path : kRequiredDirectories) {
ensurePath(path);
}
Serial.println("[FS] LittleFS ready");
return true;
}
bool StorageManager::ensurePath(const char* path) {
if (path == nullptr || path[0] == '\0') {
return false;
}
if (LittleFS.exists(path)) {
return true;
}
if (!LittleFS.mkdir(path)) {
Serial.printf("[FS] mkdir failed: %s\n", path);
return false;
}
Serial.printf("[FS] mkdir: %s\n", path);
return true;
}
bool StorageManager::fileExists(const char* path) const {
if (path == nullptr || path[0] == '\0') {
return false;
}
return LittleFS.exists(path);
}
String StorageManager::loadTextFile(const char* path) const {
if (!fileExists(path)) {
return String();
}
File file = LittleFS.open(path, "r");
if (!file) {
return String();
}
String payload;
payload.reserve(static_cast<size_t>(file.size()) + 1U);
while (file.available()) {
payload += static_cast<char>(file.read());
}
file.close();
return payload;
}
String StorageManager::loadScenePayloadById(const char* scene_id) const {
if (scene_id == nullptr || scene_id[0] == '\0') {
return String();
}
const String id = scene_id;
const String slug = sceneIdToSlug(scene_id);
const String candidates[] = {
"/story/screens/" + id + ".json",
"/story/screens/" + slug + ".json",
"/screens/" + id + ".json",
"/screens/" + slug + ".json",
"/scenarios/data/scene_" + slug + ".json",
};
for (const String& candidate : candidates) {
if (!fileExists(candidate.c_str())) {
continue;
}
Serial.printf("[FS] scene %s -> %s\n", scene_id, candidate.c_str());
return loadTextFile(candidate.c_str());
}
Serial.printf("[FS] scene payload missing for id=%s\n", scene_id);
return String();
}
String StorageManager::resolveAudioPathByPackId(const char* pack_id) const {
if (pack_id == nullptr || pack_id[0] == '\0') {
return String();
}
const String id = pack_id;
const String slug = packIdToSlug(pack_id);
const String json_candidates[] = {
"/story/audio/" + id + ".json",
"/story/audio/" + slug + ".json",
"/audio/" + id + ".json",
"/audio/" + slug + ".json",
};
for (const String& json_path : json_candidates) {
if (!fileExists(json_path.c_str())) {
continue;
}
const String payload = loadTextFile(json_path.c_str());
if (payload.isEmpty()) {
continue;
}
StaticJsonDocument<384> document;
const DeserializationError error = deserializeJson(document, payload);
if (error) {
Serial.printf("[FS] invalid audio pack json %s (%s)\n", json_path.c_str(), error.c_str());
continue;
}
const char* file_path = document["file"] | document["path"] | document["asset"];
if (file_path == nullptr || file_path[0] == '\0') {
file_path = document["content"]["file"] | document["content"]["path"] | document["content"]["asset"];
}
const char* asset_id = "";
if (file_path == nullptr || file_path[0] == '\0') {
asset_id = document["asset_id"] | "";
if (asset_id[0] == '\0') {
asset_id = document["assetId"] | "";
}
if (asset_id[0] == '\0') {
asset_id = document["content"]["asset_id"] | "";
}
if (asset_id[0] == '\0') {
asset_id = document["content"]["assetId"] | "";
}
if (asset_id[0] != '\0') {
String asset_name = asset_id;
const String asset_candidates[] = {
"/music/" + asset_name,
"/audio/" + asset_name,
"/music/" + asset_name + ".mp3",
"/audio/" + asset_name + ".mp3",
"/music/" + asset_name + ".wav",
"/audio/" + asset_name + ".wav",
};
for (const String& asset_candidate : asset_candidates) {
if (fileExists(asset_candidate.c_str())) {
Serial.printf("[FS] audio pack %s asset_id -> %s (%s)\n",
pack_id,
asset_candidate.c_str(),
json_path.c_str());
return asset_candidate;
}
}
}
Serial.printf("[FS] audio pack missing file/path: %s\n", json_path.c_str());
continue;
}
String normalized = normalizeAssetPath(file_path);
if (normalized.isEmpty()) {
continue;
}
if (!fileExists(normalized.c_str())) {
Serial.printf("[FS] audio pack path missing on FS: %s (%s)\n", normalized.c_str(), json_path.c_str());
continue;
}
Serial.printf("[FS] audio pack %s -> %s (%s)\n", pack_id, normalized.c_str(), json_path.c_str());
return normalized;
}
const String direct_candidates[] = {
"/music/" + id + ".mp3",
"/music/" + id + ".wav",
"/audio/" + id + ".mp3",
"/audio/" + id + ".wav",
"/music/" + slug + ".mp3",
"/music/" + slug + ".wav",
"/audio/" + slug + ".mp3",
"/audio/" + slug + ".wav",
};
for (const String& candidate : direct_candidates) {
if (fileExists(candidate.c_str())) {
Serial.printf("[FS] audio pack %s fallback direct=%s\n", pack_id, candidate.c_str());
return candidate;
}
}
return String();
}
bool StorageManager::ensureDefaultScenarioFile(const char* path) {
if (path == nullptr || path[0] == '\0') {
return false;
}
if (fileExists(path)) {
return true;
}
if (!ensureParentDirectories(path)) {
Serial.printf("[FS] cannot create parent directory for: %s\n", path);
return false;
}
const ScenarioDef* scenario = storyScenarioV2Default();
if (scenario == nullptr) {
Serial.println("[FS] built-in scenario unavailable");
return false;
}
File file = LittleFS.open(path, "w");
if (!file) {
Serial.printf("[FS] cannot create default scenario file: %s\n", path);
return false;
}
file.printf("{\"scenario\":\"%s\",\"source\":\"auto-fallback\",\"version\":%u,\"step_count\":%u}\n",
scenario->id != nullptr ? scenario->id : "DEFAULT",
scenario->version,
scenario->stepCount);
file.close();
Serial.printf("[FS] default scenario provisioned: %s\n", path);
return true;
}
uint32_t StorageManager::checksum(const char* path) const {
if (!fileExists(path)) {
return 0U;
}
File file = LittleFS.open(path, "r");
if (!file) {
return 0U;
}
uint32_t hash = 2166136261UL;
while (file.available()) {
const int value = file.read();
if (value < 0) {
break;
}
hash = fnv1aUpdate(hash, static_cast<uint8_t>(value));
}
file.close();
return hash;
}
@@ -1,11 +1,29 @@
// touch_manager.cpp - Gestion du tactile XPT2046
// touch_manager.cpp - optional touch bridge.
#include "touch_manager.h"
void TouchManager::begin() {
// TODO: Initialiser le contrôleur tactile
#include "ui_freenove_config.h"
bool TouchManager::begin() {
#if FREENOVE_HAS_TOUCH
Serial.printf("[TOUCH] enabled cs=%d irq=%d\n", FREENOVE_TOUCH_CS, FREENOVE_TOUCH_IRQ);
#else
Serial.println("[TOUCH] disabled");
#endif
return true;
}
bool TouchManager::getTouch(int16_t* x, int16_t* y) {
// TODO: Lire la position tactile
bool TouchManager::poll(TouchPoint* out_point) {
if (out_point == nullptr) {
return false;
}
#if FREENOVE_HAS_TOUCH
(void)out_point;
// Touchscreen support is optional and disabled by default on Freenove.
return false;
#else
out_point->x = 0;
out_point->y = 0;
out_point->touched = false;
return false;
#endif
}
@@ -1,10 +1,640 @@
// ui_manager.cpp - Gestion UI (LVGL, écrans, boutons)
// ui_manager.cpp - LVGL binding for TFT + keypad events.
#include "ui_manager.h"
void UiManager::begin() {
// TODO: Initialiser LVGL, écran, boutons, etc.
#include <ArduinoJson.h>
#include <TFT_eSPI.h>
#include <cstdlib>
#include <cstring>
#include "ui_freenove_config.h"
namespace {
constexpr uint16_t kDrawBufferLines = 24;
TFT_eSPI g_tft = TFT_eSPI(FREENOVE_LCD_WIDTH, FREENOVE_LCD_HEIGHT);
lv_disp_draw_buf_t g_draw_buf;
lv_color_t g_draw_pixels[FREENOVE_LCD_WIDTH * kDrawBufferLines];
UiManager* g_instance = nullptr;
int16_t activeDisplayWidth() {
lv_disp_t* display = lv_disp_get_default();
if (display != nullptr) {
return static_cast<int16_t>(lv_disp_get_hor_res(display));
}
return ((FREENOVE_LCD_ROTATION & 0x1U) != 0U) ? FREENOVE_LCD_HEIGHT : FREENOVE_LCD_WIDTH;
}
int16_t activeDisplayHeight() {
lv_disp_t* display = lv_disp_get_default();
if (display != nullptr) {
return static_cast<int16_t>(lv_disp_get_ver_res(display));
}
return ((FREENOVE_LCD_ROTATION & 0x1U) != 0U) ? FREENOVE_LCD_WIDTH : FREENOVE_LCD_HEIGHT;
}
uint32_t toLvKey(uint8_t key, bool long_press) {
(void)long_press;
const uint8_t rotation = static_cast<uint8_t>(FREENOVE_LCD_ROTATION & 0x3U);
switch (key) {
case 1:
return LV_KEY_ENTER;
case 2:
if (rotation == 0U) {
return LV_KEY_PREV;
}
if (rotation == 1U) {
return LV_KEY_LEFT;
}
if (rotation == 2U) {
return LV_KEY_NEXT;
}
return LV_KEY_RIGHT;
case 3:
if (rotation == 0U) {
return LV_KEY_NEXT;
}
if (rotation == 1U) {
return LV_KEY_RIGHT;
}
if (rotation == 2U) {
return LV_KEY_PREV;
}
return LV_KEY_LEFT;
case 4:
if (rotation == 0U) {
return LV_KEY_LEFT;
}
if (rotation == 1U) {
return LV_KEY_NEXT;
}
if (rotation == 2U) {
return LV_KEY_RIGHT;
}
return LV_KEY_PREV;
case 5:
if (rotation == 0U) {
return LV_KEY_RIGHT;
}
if (rotation == 1U) {
return LV_KEY_PREV;
}
if (rotation == 2U) {
return LV_KEY_LEFT;
}
return LV_KEY_NEXT;
default:
return LV_KEY_ENTER;
}
}
bool parseHexColor(const char* text, lv_color_t* out_color) {
if (text == nullptr || text[0] == '\0' || out_color == nullptr) {
return false;
}
const char* begin = text;
if (begin[0] == '#') {
++begin;
}
char* end = nullptr;
const unsigned long value = strtoul(begin, &end, 16);
if (end == begin || *end != '\0' || value > 0xFFFFFFUL) {
return false;
}
*out_color = lv_color_hex(static_cast<uint32_t>(value));
return true;
}
} // namespace
bool UiManager::begin() {
if (ready_) {
return true;
}
g_instance = this;
lv_init();
g_tft.begin();
g_tft.setRotation(FREENOVE_LCD_ROTATION);
g_tft.fillScreen(TFT_BLACK);
lv_disp_draw_buf_init(&g_draw_buf, g_draw_pixels, nullptr, FREENOVE_LCD_WIDTH * kDrawBufferLines);
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
if ((FREENOVE_LCD_ROTATION & 0x1U) != 0U) {
disp_drv.hor_res = FREENOVE_LCD_HEIGHT;
disp_drv.ver_res = FREENOVE_LCD_WIDTH;
} else {
disp_drv.hor_res = FREENOVE_LCD_WIDTH;
disp_drv.ver_res = FREENOVE_LCD_HEIGHT;
}
disp_drv.flush_cb = displayFlushCb;
disp_drv.draw_buf = &g_draw_buf;
lv_disp_drv_register(&disp_drv);
static lv_indev_drv_t keypad_drv;
lv_indev_drv_init(&keypad_drv);
keypad_drv.type = LV_INDEV_TYPE_KEYPAD;
keypad_drv.read_cb = keypadReadCb;
lv_indev_drv_register(&keypad_drv);
#if FREENOVE_HAS_TOUCH
static lv_indev_drv_t touch_drv;
lv_indev_drv_init(&touch_drv);
touch_drv.type = LV_INDEV_TYPE_POINTER;
touch_drv.read_cb = touchReadCb;
lv_indev_drv_register(&touch_drv);
#endif
player_ui_.reset();
createWidgets();
ready_ = true;
Serial.println("[UI] LVGL + TFT ready");
return true;
}
void UiManager::update() {
// TODO: Rafraîchir l'affichage, gérer les interactions
if (!ready_) {
return;
}
if (player_ui_.consumeDirty()) {
updatePageLine();
}
lv_timer_handler();
}
void UiManager::renderScene(const ScenarioDef* scenario,
const char* screen_scene_id,
const char* step_id,
const char* audio_pack_id,
bool audio_playing,
const char* screen_payload_json) {
(void)step_id;
(void)audio_pack_id;
if (!ready_) {
return;
}
const char* scenario_id = (scenario != nullptr && scenario->id != nullptr) ? scenario->id : "N/A";
const char* scene_id = (screen_scene_id != nullptr && screen_scene_id[0] != '\0') ? screen_scene_id : "SCENE_READY";
SceneEffect effect = SceneEffect::kPulse;
lv_color_t bg = lv_color_hex(0x07132A);
lv_color_t accent = lv_color_hex(0x2A76FF);
lv_color_t secondary = lv_color_hex(0xE8F1FF);
if (std::strcmp(scene_id, "SCENE_LOCKED") == 0) {
effect = SceneEffect::kPulse;
bg = lv_color_hex(0x08152D);
accent = lv_color_hex(0x3E8DFF);
secondary = lv_color_hex(0xDBE8FF);
} else if (std::strcmp(scene_id, "SCENE_BROKEN") == 0) {
effect = SceneEffect::kBlink;
bg = lv_color_hex(0x2A0508);
accent = lv_color_hex(0xFF4A45);
secondary = lv_color_hex(0xFFD5D1);
} else if (std::strcmp(scene_id, "SCENE_LA_DETECT") == 0 || std::strcmp(scene_id, "SCENE_SEARCH") == 0) {
effect = SceneEffect::kScan;
bg = lv_color_hex(0x041F1B);
accent = lv_color_hex(0x2CE5A6);
secondary = lv_color_hex(0xD9FFF0);
} else if (std::strcmp(scene_id, "SCENE_WIN") == 0 || std::strcmp(scene_id, "SCENE_REWARD") == 0) {
effect = SceneEffect::kCelebrate;
bg = lv_color_hex(0x231038);
accent = lv_color_hex(0xF4CB4A);
secondary = lv_color_hex(0xFFF6C7);
} else if (std::strcmp(scene_id, "SCENE_READY") == 0) {
effect = SceneEffect::kPulse;
bg = lv_color_hex(0x0F2A12);
accent = lv_color_hex(0x6CD96B);
secondary = lv_color_hex(0xE8FFE7);
}
if (screen_payload_json != nullptr && screen_payload_json[0] != '\0') {
StaticJsonDocument<1024> document;
const DeserializationError error = deserializeJson(document, screen_payload_json);
if (!error) {
const char* payload_effect = document["effect"] | document["visual"]["effect"] | document["content"]["effect"] | "";
if (std::strcmp(payload_effect, "none") == 0 || std::strcmp(payload_effect, "steady") == 0) {
effect = SceneEffect::kNone;
} else if (std::strcmp(payload_effect, "pulse") == 0) {
effect = SceneEffect::kPulse;
} else if (std::strcmp(payload_effect, "scan") == 0) {
effect = SceneEffect::kScan;
} else if (std::strcmp(payload_effect, "blink") == 0 || std::strcmp(payload_effect, "glitch") == 0) {
effect = SceneEffect::kBlink;
} else if (std::strcmp(payload_effect, "celebrate") == 0) {
effect = SceneEffect::kCelebrate;
}
const char* payload_bg = document["theme"]["bg"] | document["visual"]["theme"]["bg"] | document["bg"] | "";
const char* payload_accent =
document["theme"]["accent"] | document["visual"]["theme"]["accent"] | document["accent"] | "";
const char* payload_secondary =
document["theme"]["text"] | document["visual"]["theme"]["text"] | document["text"] | "";
parseHexColor(payload_bg, &bg);
parseHexColor(payload_accent, &accent);
parseHexColor(payload_secondary, &secondary);
} else {
Serial.printf("[UI] invalid scene payload (%s)\n", error.c_str());
}
}
stopSceneAnimations();
current_effect_ = effect;
lv_obj_set_style_bg_color(scene_root_, bg, LV_PART_MAIN);
lv_obj_set_style_bg_color(scene_core_, accent, LV_PART_MAIN);
lv_obj_set_style_border_color(scene_core_, secondary, LV_PART_MAIN);
lv_obj_set_style_border_color(scene_ring_outer_, accent, LV_PART_MAIN);
lv_obj_set_style_border_color(scene_ring_inner_, secondary, LV_PART_MAIN);
lv_obj_set_style_bg_color(scene_fx_bar_, accent, LV_PART_MAIN);
for (lv_obj_t* particle : scene_particles_) {
lv_obj_set_style_bg_color(particle, secondary, LV_PART_MAIN);
}
lv_obj_set_style_bg_opa(scene_core_, audio_playing ? LV_OPA_COVER : LV_OPA_80, LV_PART_MAIN);
applySceneEffect(effect);
updatePageLine();
Serial.printf("[UI] scene=%s effect=%u scenario=%s audio=%u\n",
scene_id,
static_cast<unsigned int>(effect),
scenario_id,
audio_playing ? 1U : 0U);
}
void UiManager::handleButton(uint8_t key, bool long_press) {
UiAction action;
action.source = long_press ? UiActionSource::kKeyLong : UiActionSource::kKeyShort;
action.key = key;
player_ui_.applyAction(action);
pending_key_code_ = toLvKey(key, long_press);
key_press_pending_ = true;
}
void UiManager::handleTouch(int16_t x, int16_t y, bool touched) {
touch_x_ = x;
touch_y_ = y;
touch_pressed_ = touched;
}
void UiManager::createWidgets() {
lv_obj_t* root = lv_scr_act();
lv_obj_set_style_bg_color(root, lv_color_hex(0x000000), LV_PART_MAIN);
scene_root_ = lv_obj_create(root);
lv_obj_remove_style_all(scene_root_);
lv_obj_set_size(scene_root_, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_opa(scene_root_, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_color(scene_root_, lv_color_hex(0x07132A), LV_PART_MAIN);
lv_obj_clear_flag(scene_root_, LV_OBJ_FLAG_SCROLLABLE);
scene_ring_outer_ = lv_obj_create(scene_root_);
lv_obj_remove_style_all(scene_ring_outer_);
lv_obj_set_style_radius(scene_ring_outer_, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(scene_ring_outer_, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(scene_ring_outer_, 3, LV_PART_MAIN);
lv_obj_set_style_border_opa(scene_ring_outer_, LV_OPA_70, LV_PART_MAIN);
lv_obj_set_style_border_color(scene_ring_outer_, lv_color_hex(0x2A76FF), LV_PART_MAIN);
scene_ring_inner_ = lv_obj_create(scene_root_);
lv_obj_remove_style_all(scene_ring_inner_);
lv_obj_set_style_radius(scene_ring_inner_, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(scene_ring_inner_, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(scene_ring_inner_, 2, LV_PART_MAIN);
lv_obj_set_style_border_opa(scene_ring_inner_, LV_OPA_80, LV_PART_MAIN);
lv_obj_set_style_border_color(scene_ring_inner_, lv_color_hex(0xC8DCFF), LV_PART_MAIN);
scene_core_ = lv_obj_create(scene_root_);
lv_obj_remove_style_all(scene_core_);
lv_obj_set_style_radius(scene_core_, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(scene_core_, LV_OPA_90, LV_PART_MAIN);
lv_obj_set_style_bg_color(scene_core_, lv_color_hex(0x2A76FF), LV_PART_MAIN);
lv_obj_set_style_border_width(scene_core_, 2, LV_PART_MAIN);
lv_obj_set_style_border_color(scene_core_, lv_color_hex(0xE8F1FF), LV_PART_MAIN);
scene_fx_bar_ = lv_obj_create(scene_root_);
lv_obj_remove_style_all(scene_fx_bar_);
lv_obj_set_style_radius(scene_fx_bar_, 4, LV_PART_MAIN);
lv_obj_set_style_bg_opa(scene_fx_bar_, LV_OPA_80, LV_PART_MAIN);
lv_obj_set_style_bg_color(scene_fx_bar_, lv_color_hex(0x2A76FF), LV_PART_MAIN);
for (lv_obj_t*& particle : scene_particles_) {
particle = lv_obj_create(scene_root_);
lv_obj_remove_style_all(particle);
lv_obj_set_size(particle, 10, 10);
lv_obj_set_style_radius(particle, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_color(particle, lv_color_hex(0xE8F1FF), LV_PART_MAIN);
lv_obj_set_style_bg_opa(particle, LV_OPA_90, LV_PART_MAIN);
lv_obj_add_flag(particle, LV_OBJ_FLAG_HIDDEN);
}
page_label_ = lv_label_create(scene_root_);
lv_obj_add_flag(page_label_, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_style_text_opa(page_label_, LV_OPA_60, LV_PART_MAIN);
lv_obj_set_style_text_color(page_label_, lv_color_hex(0xFFFFFF), LV_PART_MAIN);
stopSceneAnimations();
}
void UiManager::updatePageLine() {
if (page_label_ == nullptr || lv_obj_has_flag(page_label_, LV_OBJ_FLAG_HIDDEN)) {
return;
}
const PlayerUiSnapshot snapshot = player_ui_.snapshot();
lv_label_set_text_fmt(page_label_,
"UI %s c=%u o=%u",
playerUiPageLabel(snapshot.page),
snapshot.cursor,
snapshot.offset);
}
void UiManager::stopSceneAnimations() {
if (scene_root_ == nullptr) {
return;
}
const int16_t width = activeDisplayWidth();
const int16_t height = activeDisplayHeight();
int16_t min_dim = (width < height) ? width : height;
if (min_dim < 120) {
min_dim = 120;
}
lv_anim_del(scene_root_, nullptr);
lv_obj_set_style_opa(scene_root_, LV_OPA_COVER, LV_PART_MAIN);
if (scene_ring_outer_ != nullptr) {
lv_anim_del(scene_ring_outer_, nullptr);
int16_t outer = min_dim - 44;
if (outer < 88) {
outer = 88;
}
lv_obj_set_size(scene_ring_outer_, outer, outer);
lv_obj_center(scene_ring_outer_);
lv_obj_set_style_opa(scene_ring_outer_, LV_OPA_80, LV_PART_MAIN);
}
if (scene_ring_inner_ != nullptr) {
lv_anim_del(scene_ring_inner_, nullptr);
int16_t inner = min_dim - 104;
if (inner < 64) {
inner = 64;
}
lv_obj_set_size(scene_ring_inner_, inner, inner);
lv_obj_center(scene_ring_inner_);
lv_obj_set_style_opa(scene_ring_inner_, LV_OPA_80, LV_PART_MAIN);
}
if (scene_core_ != nullptr) {
lv_anim_del(scene_core_, nullptr);
int16_t core = min_dim - 170;
if (core < 50) {
core = 50;
}
lv_obj_set_size(scene_core_, core, core);
lv_obj_center(scene_core_);
lv_obj_set_style_opa(scene_core_, LV_OPA_COVER, LV_PART_MAIN);
}
if (scene_fx_bar_ != nullptr) {
lv_anim_del(scene_fx_bar_, nullptr);
int16_t bar_width = width - 120;
if (bar_width < 80) {
bar_width = 80;
}
lv_obj_set_size(scene_fx_bar_, bar_width, 8);
lv_obj_align(scene_fx_bar_, LV_ALIGN_CENTER, 0, (height / 2) - 12);
lv_obj_set_style_opa(scene_fx_bar_, LV_OPA_90, LV_PART_MAIN);
}
for (lv_obj_t* particle : scene_particles_) {
if (particle == nullptr) {
continue;
}
lv_anim_del(particle, nullptr);
lv_obj_center(particle);
lv_obj_set_style_opa(particle, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_add_flag(particle, LV_OBJ_FLAG_HIDDEN);
}
if (page_label_ != nullptr && !lv_obj_has_flag(page_label_, LV_OBJ_FLAG_HIDDEN)) {
lv_obj_align(page_label_, LV_ALIGN_BOTTOM_LEFT, 10, -8);
}
}
void UiManager::applySceneEffect(SceneEffect effect) {
if (scene_root_ == nullptr || scene_core_ == nullptr || scene_fx_bar_ == nullptr) {
return;
}
if (effect == SceneEffect::kNone) {
return;
}
const int16_t width = activeDisplayWidth();
const int16_t height = activeDisplayHeight();
int16_t min_dim = (width < height) ? width : height;
if (min_dim < 120) {
min_dim = 120;
}
lv_anim_t anim;
lv_anim_init(&anim);
lv_anim_set_repeat_count(&anim, LV_ANIM_REPEAT_INFINITE);
lv_anim_set_playback_time(&anim, 0);
if (effect == SceneEffect::kPulse) {
int16_t core_small = min_dim / 4;
if (core_small < 46) {
core_small = 46;
}
int16_t core_large = core_small + (min_dim / 7);
if (core_large < (core_small + 18)) {
core_large = core_small + 18;
}
lv_anim_set_var(&anim, scene_core_);
lv_anim_set_exec_cb(&anim, animSetSize);
lv_anim_set_values(&anim, core_small, core_large);
lv_anim_set_time(&anim, 640);
lv_anim_set_playback_time(&anim, 640);
lv_anim_start(&anim);
if (scene_ring_inner_ != nullptr) {
lv_anim_t ring_anim;
lv_anim_init(&ring_anim);
lv_anim_set_var(&ring_anim, scene_ring_inner_);
lv_anim_set_exec_cb(&ring_anim, animSetOpa);
lv_anim_set_values(&ring_anim, 90, LV_OPA_COVER);
lv_anim_set_time(&ring_anim, 640);
lv_anim_set_playback_time(&ring_anim, 640);
lv_anim_set_repeat_count(&ring_anim, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&ring_anim);
}
return;
}
if (effect == SceneEffect::kScan) {
int16_t bar_width = width - 84;
if (bar_width < 90) {
bar_width = 90;
}
lv_obj_set_size(scene_fx_bar_, bar_width, 10);
lv_obj_align(scene_fx_bar_, LV_ALIGN_TOP_MID, 0, 20);
lv_anim_set_var(&anim, scene_fx_bar_);
lv_anim_set_exec_cb(&anim, animSetY);
lv_anim_set_values(&anim, 20, height - 28);
lv_anim_set_time(&anim, 920);
lv_anim_set_playback_time(&anim, 920);
lv_anim_start(&anim);
return;
}
if (effect == SceneEffect::kBlink) {
lv_anim_set_var(&anim, scene_root_);
lv_anim_set_exec_cb(&anim, animSetOpa);
lv_anim_set_values(&anim, 120, LV_OPA_COVER);
lv_anim_set_time(&anim, 170);
lv_anim_set_playback_time(&anim, 170);
lv_anim_start(&anim);
return;
}
if (effect == SceneEffect::kCelebrate) {
if (scene_ring_outer_ != nullptr) {
int16_t ring_small = min_dim - 88;
if (ring_small < 84) {
ring_small = 84;
}
int16_t ring_large = min_dim - 22;
if (ring_large < (ring_small + 22)) {
ring_large = ring_small + 22;
}
lv_anim_t ring_anim;
lv_anim_init(&ring_anim);
lv_anim_set_var(&ring_anim, scene_ring_outer_);
lv_anim_set_exec_cb(&ring_anim, animSetSize);
lv_anim_set_values(&ring_anim, ring_small, ring_large);
lv_anim_set_time(&ring_anim, 460);
lv_anim_set_playback_time(&ring_anim, 460);
lv_anim_set_repeat_count(&ring_anim, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&ring_anim);
}
lv_anim_t width_anim;
lv_anim_init(&width_anim);
lv_anim_set_var(&width_anim, scene_fx_bar_);
lv_anim_set_exec_cb(&width_anim, animSetWidth);
lv_anim_set_values(&width_anim, 36, width - 36);
lv_anim_set_time(&width_anim, 420);
lv_anim_set_playback_time(&width_anim, 420);
lv_anim_set_repeat_count(&width_anim, LV_ANIM_REPEAT_INFINITE);
lv_anim_start(&width_anim);
const int16_t dx = min_dim / 5;
const int16_t dy = min_dim / 7;
for (uint8_t index = 0; index < 4U; ++index) {
lv_obj_t* particle = scene_particles_[index];
if (particle == nullptr) {
continue;
}
const int16_t x_offset = ((index % 2U) == 0U) ? -dx : dx;
const int16_t y_offset = (index < 2U) ? -dy : dy;
lv_obj_clear_flag(particle, LV_OBJ_FLAG_HIDDEN);
lv_obj_align(particle, LV_ALIGN_CENTER, x_offset, y_offset);
lv_anim_t particle_opa;
lv_anim_init(&particle_opa);
lv_anim_set_var(&particle_opa, particle);
lv_anim_set_exec_cb(&particle_opa, animSetOpa);
lv_anim_set_values(&particle_opa, 80, LV_OPA_COVER);
lv_anim_set_time(&particle_opa, 260);
lv_anim_set_playback_time(&particle_opa, 260);
lv_anim_set_repeat_count(&particle_opa, LV_ANIM_REPEAT_INFINITE);
lv_anim_set_delay(&particle_opa, static_cast<uint16_t>(index * 40U));
lv_anim_start(&particle_opa);
}
}
}
void UiManager::animSetY(void* obj, int32_t value) {
if (obj == nullptr) {
return;
}
lv_obj_set_y(static_cast<lv_obj_t*>(obj), value);
}
void UiManager::animSetOpa(void* obj, int32_t value) {
if (obj == nullptr) {
return;
}
lv_obj_set_style_opa(static_cast<lv_obj_t*>(obj), static_cast<lv_opa_t>(value), LV_PART_MAIN);
}
void UiManager::animSetSize(void* obj, int32_t value) {
if (obj == nullptr) {
return;
}
if (value < 24) {
value = 24;
}
lv_obj_set_size(static_cast<lv_obj_t*>(obj), value, value);
}
void UiManager::animSetWidth(void* obj, int32_t value) {
if (obj == nullptr) {
return;
}
if (value < 16) {
value = 16;
}
lv_obj_set_width(static_cast<lv_obj_t*>(obj), value);
}
void UiManager::displayFlushCb(lv_disp_drv_t* disp, const lv_area_t* area, lv_color_t* color_p) {
(void)disp;
const uint32_t width = static_cast<uint32_t>(area->x2 - area->x1 + 1);
const uint32_t height = static_cast<uint32_t>(area->y2 - area->y1 + 1);
g_tft.startWrite();
g_tft.setAddrWindow(area->x1, area->y1, width, height);
g_tft.pushColors(reinterpret_cast<uint16_t*>(&color_p->full), width * height, true);
g_tft.endWrite();
lv_disp_flush_ready(disp);
}
void UiManager::keypadReadCb(lv_indev_drv_t* drv, lv_indev_data_t* data) {
(void)drv;
if (g_instance == nullptr) {
data->state = LV_INDEV_STATE_REL;
data->key = LV_KEY_ENTER;
return;
}
data->key = g_instance->pending_key_code_;
if (g_instance->key_press_pending_) {
data->state = LV_INDEV_STATE_PR;
g_instance->key_press_pending_ = false;
g_instance->key_release_pending_ = true;
return;
}
if (g_instance->key_release_pending_) {
data->state = LV_INDEV_STATE_REL;
g_instance->key_release_pending_ = false;
return;
}
data->state = LV_INDEV_STATE_REL;
}
void UiManager::touchReadCb(lv_indev_drv_t* drv, lv_indev_data_t* data) {
(void)drv;
if (g_instance == nullptr) {
data->state = LV_INDEV_STATE_REL;
return;
}
data->point.x = g_instance->touch_x_;
data->point.y = g_instance->touch_y_;
data->state = g_instance->touch_pressed_ ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
}
+16 -14
View File
@@ -43,17 +43,17 @@ build_flags =
-DUSON_STORY_V2_DEFAULT=1
-DUSON_TRACK_CATALOG_MAX_TRACKS=200
[env:freenove_esp32s3]
[env:freenove_esp32s3_full_with_ui]
platform = espressif32@^6.5.0
board = esp32-s3-devkitc-1
framework = arduino
board_build.partitions = no_ota.csv
board_build.filesystem = littlefs
build_src_filter =
+<*>
-<*>
+<../../ui_freenove_allinone/src/>
+<../../../libs/story/src/>
+<../../../libs/story/story_engine.cpp>
-<ui/mp3_ui_model_v2.cpp>
-<../../../libs/story/src/ui/mp3_ui_model_v2.cpp>
monitor_speed = 115200
monitor_filters = time, esp32_exception_decoder
@@ -62,25 +62,27 @@ monitor_eol = LF
monitor_rts = 0
monitor_dtr = 0
lib_deps =
bodmer/TFT_eSPI@^2.5.43
lvgl/lvgl@^8.3.11
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
-Ihardware/firmware/ui_freenove_allinone/include
-DLV_CONF_INCLUDE_SIMPLE
-DUSER_SETUP_LOADED=1
-I$PROJECT_DIR/hardware/firmware/ui_freenove_allinone/include
-I$PROJECT_DIR/hardware/libs/story/src
-I$PROJECT_DIR/hardware/libs/story
-DCORE_DEBUG_LEVEL=1
-DLV_CONF_INCLUDE_SIMPLE=1
-DFREENOVE_MEDIA_KIT
-include ui_freenove_config.h
-DFREENOVE_LCD_VARIANT_FNK0102A=0
-DFREENOVE_LCD_VARIANT_FNK0102B=1
-DFREENOVE_HAS_TOUCH=0
-DSUPPORT_TRANSACTIONS
[env:freenove_esp32s3]
extends = env:freenove_esp32s3_full_with_ui
[env:esp32_release]
extends = env:esp32dev
build_unflags = -DUSON_STORY_V2_DEFAULT=1