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:
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "SCENE_READY",
|
||||
"effect": "pulse",
|
||||
"theme": {
|
||||
"bg": "#0F2A12",
|
||||
"accent": "#6CD96B",
|
||||
"text": "#EDFFED"
|
||||
},
|
||||
"content": {
|
||||
"description": "Pret"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 l’audio, 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 l’audio, 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
|
||||
+58
-3
@@ -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);
|
||||
};
|
||||
|
||||
+92
-43
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user