feat(app): merge multi-copy work — modules, auth, UI amiga shell, scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-11 00:03:58 +01:00
parent 226e15de7b
commit 8d874ae31b
101 changed files with 9051 additions and 20983 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Multiplexage des broches Attention
Cible matérielle: **ESP32-S3-WROOM-1-N16R8**.
Cible matérielle: **Freenove ESP32-S3 WROOM N16R8**.
Certaines broches sont partagées entre plusieurs fonctions:
- GPIO 4: utilisé pour le rétroéclairage (BL) et le bouton 3 (BTN3)
+39 -343
View File
@@ -1,357 +1,53 @@
# ui_freenove_allinone
---
# 🎛️ Zacus Firmware Freenove All-in-One
Firmware principal Freenove pour `ESP32_ZACUS`.
![Funk](https://media.giphy.com/media/3o7TKtnuHOHHUjR38Y/giphy.gif)
## Profil matériel release
---
- Carte: Freenove FNK0102H.
- Board profile PlatformIO: `freenove_esp32_s3_wroom` (`N16R8`, 16 MB flash / 8 MB PSRAM).
- Écran: ST7796 320x480, rotation `1`.
- Entrée officielle: switch analogique 5 directions sur GPIO19.
- Touch: non supporté dans le chemin release.
## 📝 Description
## Mapping principal
Bienvenue dans le cockpit le plus funky du multivers! Ce firmware fusionne UI, audio, scénarios et hardware sur la carte Freenove Media Kit (ESP32). Ici, chaque pixel danse, chaque bouton groove, et chaque boot est une fête.
| Fonction | GPIO |
| --- | --- |
| TFT SCK | 47 |
| TFT MOSI | 21 |
| TFT DC | 45 |
| TFT RESET | 20 |
| TFT BL | 2 |
| Boutons analogiques | 19 |
| SD CMD / CLK / D0 | 38 / 39 / 40 |
| I2S WS / BCK / DOUT | 41 / 42 / 1 |
| Caméra XCLK / SIOD / SIOC | 15 / 4 / 5 |
> *"Si tu entends un son rétro ou vois un plasma violet, cest normal. Si le microcontrôleur se met à rapper, cest probablement un bug… ou un feature caché."*
---
## 🕹️ Mapping hardware (ESP32-S3 Freenove Media Kit)
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|------------------|-----------------|-----------------------|--------------------------------|
| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) |
| TFT MOSI | GPIO 21 | MOSI | SPI écran |
| TFT MISO | -1 | MISO | non utilisé |
| TFT CS | -1 | CS | câblage board intégré |
| TFT DC | GPIO 45 | DC | Data/Command écran |
| TFT RESET | GPIO 20 | RESET | Reset écran |
| TFT BL | GPIO 2 | BL | Rétroéclairage |
| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) |
| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel |
| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques |
| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 |
| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 |
| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 |
| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions |
**Remarques**:
- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE <idx>` puis `AUDIO_TEST`.
- Profils fournis: `0=sketch19`, `1=swap_bck_ws`, `2=dout2_alt`.
- Le tactile est désactivé par défaut (`FREENOVE_HAS_TOUCH=0`).
---
## 📦 Contenu du dossier
- Sources : `src/` (app, ui, audio, storage, camera, drivers, system)
- Polices : `src/ui/fonts/`
- Layouts d’écran : `src/ui/screens/`
- Partition custom : `partitions/freenove_esp32s3_app6mb_fs6mb.csv`
- Mapping hardware détaillé (voir pour descendre plus haut )
---
## 🚀 Installation & usage
1. Clone ce repo, chausse tes lunettes de soleil.
2. Va dans `hardware/firmware` et build comme un DJ:
```sh
pio run -e freenove_allinone
pio run -e freenove_allinone -t upload --upload-port <PORT>
```
3. Pour la version ESP32-S3 (partition 6MB app / 6MB FS):
```sh
pio run -e freenove_esp32s3
pio run -e freenove_esp32s3 -t buildfs
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
```
4. Branche, allume, et laisse la magie opérer.
---
## 🧩 Fonctionnalités qui groovent
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
- Exécution de scénarios (lecture, transitions, actions, audio)
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
- Gestion boutons et tactile (événements, mapping, callbacks)
- Fallback robuste si fichier manquant (scénario par défaut)
- Génération de logs et artefacts (logs/, artifacts/)
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
- Documentation et onboarding synchronisés
- Mode autonome (pas besoin dESP32 séparé)
> *"Si tu rates un bouton, cest que tu danses trop fort. Si tu vois un plasma violet, cest que tu es dans le groove."*
---
## 🛠️ Modules principaux
- `audio_manager` : fait swinguer laudio (lecture, stop, état)
- `scenario_manager` : enchaîne les étapes comme un DJ
- `ui_manager` : LVGL, écrans dynamiques, FX visuels
- `storage_manager` : LittleFS (init, vérif, groove des assets)
- `button_manager` : boutons physiques, pour les vrais
- `touch_manager` : tactile XPT2046, pour les DJ du futur
---
## 🦄 Funky extras & notes
- Firmware expérimental: fusion audio + UI, mode "party hard" activé
- Pour la compatibilité UI Link, prévoir un mode optionnel
- Si tu veux une intro Amiga92, active la scène `SCENE_WIN_ETAPE` et laisse-toi porter par le FX timeline (plasma, starfield, boingball…)
- Pour tester la scène MP3: `SCENE_MP3_PLAYER` (overlay AmigaAMP, scan `/music`)
- Pour la caméra: `SCENE_CAMERA_SCAN` (Win311 overlay, boutons = SNAP/SAVE/GALLERY/DELETE/CLOSE)
- Pour tout le reste, consulte les logs, et si tu comprends tout du premier coup, tu gagnes un badge "Zacus Funk Master".
---
## 🤝 Contribuer
Merci de lire [../../../../../../CONTRIBUTING.md](../../../../../../CONTRIBUTING.md) avant toute PR. Les pull requests avec punchlines sont encouragées.
---
## 👤 Contact
Pour toute question, suggestion, ou battle de funk, ouvre une issue GitHub ou contacte lauteur principal:
- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare)
> *"Ce firmware a été validé par un oscilloscope, un grille-pain, et un synthé vintage. Si tu trouves un bug, cest peut-être un easter egg ou feat foirée."*
---
# Firmware Freenove Media Kit All-in-One
## Plan dintégration complète (couverture specs)
- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/)
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
- Exécution de scénarios (lecture, transitions, actions, audio)
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
- Gestion boutons et tactile (événements, mapping, callbacks)
- Fallback robuste si fichier manquant (scénario par défaut)
- Génération de logs et artefacts (logs/, artifacts/)
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
- Documentation et onboarding synchronisés
## Structure modulaire
- `src/app/*` : orchestration runtime (`main`, `scenario_manager`, coordinator serie)
- `src/ui/*` : LVGL scenes, fonts, FX helpers (`ui_manager`, `ui_fonts`, `ui/fx`)
- `src/audio/*` : gestion audio runtime (`audio_manager`)
- `src/storage/*` : LittleFS/SD et resolution assets (`storage_manager`)
- `src/camera/*` : camera runtime (`camera_manager`)
- `src/drivers/*` : board I/O (input, board, display HAL + SPI bus manager)
- `src/system/*` : metrics, boot report, reseau/media wrappers, task topology
- headers legacy (`include/*_manager.h`) conserves en facades vers les headers modulaires
Ce firmware combine:
- Les fonctions audio/scénario (type ESP32 Audio)
- LUI locale (affichage, interaction, tactile, boutons)
- Le tout sur un seul microcontrôleur (RP2040 ou ESP32 selon le kit)
## Fonctionnalités
- Lecture audio, gestion scénario, LittleFS
- Affichage TFT tactile (LVGL)
- Boutons physiques, capteurs, extensions
- Mode autonome (pas besoin dESP32 séparé)
## Modules principaux
- audio_manager : gestion audio (lecture, stop, état)
- scenario_manager : gestion scénario (étapes, transitions)
- ui_manager : gestion UI (LVGL, écrans dynamiques)
- storage_manager : gestion LittleFS (init, vérification)
- button_manager : gestion boutons physiques
- touch_manager : gestion tactile XPT2046
## Validation hardware
- Compiler et flasher sur le Freenove Media Kit
- Vérifier laffichage, la réactivité tactile et boutons
- Tester la lecture audio (fichiers dans /data/)
- Consulter les logs série pour le suivi dexécution
## Artefacts
- Firmware compilé (.bin)
- Logs de test hardware (logs/)
- Rapport de compatibilité assets LittleFS
Le détail complet est maintenu dans `../docs/FNK0102H_SOURCE_OF_TRUTH.md`.
## Build
Depuis `hardware/firmware`:
Depuis la racine du repo:
```sh
pio run -e freenove_allinone
pio run -e freenove_allinone -t upload --upload-port <PORT>
```bash
source .venv/bin/activate
python -m platformio run -e freenove_esp32s3_full_with_ui
python -m platformio run -e freenove_esp32s3_full_with_ui -t buildfs
python -m platformio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port <PORT>
python -m platformio run -e freenove_esp32s3_full_with_ui -t upload --upload-port <PORT>
```
## Build Freenove ESP32-S3 (partition 6MB app / 6MB FS)
## Boot modes
```sh
pio run -e freenove_esp32s3
pio run -e freenove_esp32s3 -t buildfs
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
- Normal: UI complète, caméra, audio et réseau autorisés.
- `safe diagnostic mode`: affichage minimal + boutons + série uniquement si la PSRAM n'est pas détectée.
## Tests
```bash
python tests/sprint1_utility_contract.py --mode serial --port <PORT>
python tests/sprint2_capture_contract.py --mode serial --port <PORT>
python tests/sprint3_audio_contract.py --mode serial --port <PORT>
python tests/phase9_ui_validation.py --port <PORT>
```
- Partition custom active: `partitions/freenove_esp32s3_app6mb_fs6mb.csv`.
- Le bundle story complet n'est plus embarque dans le firmware: `buildfs/uploadfs` est requis pour charger le contenu complet (screens/audio/actions/apps).
- Fallback embarque minimal conserve: `APP_WIFI` + scenario `DEFAULT` minimal.
## Norme embarquee (audit + refacto)
- Standard projet: `docs/skills/EMBEDDED_CPP_OO_ESP32S3_PIO_ARDUINO.md`.
- Toute revue technique Freenove doit citer cette norme pour les risques temps reel, memoire, concurrence et style OO.
## Provisioning Wi-Fi + Auth WebUI
- Au boot sans credentials NVS:
- mode setup AP actif;
- endpoints setup ouverts: `GET /api/provision/status`, `POST /api/wifi/connect`, `POST /api/network/wifi/connect`.
- Hors setup:
- auth Bearer requise sur `/api/*`:
- `Authorization: Bearer <token>`.
- Provisioning persistant:
- API: `POST /api/wifi/connect` (ou `/api/network/wifi/connect`) avec `persist=1`.
- Serie: `WIFI_PROVISION <ssid> <pass>`.
- Rotation token:
- Serie: `AUTH_TOKEN_ROTATE [token]`.
- Outils shell:
- `tools/dev/healthcheck_wifi.sh` et `tools/dev/rtos_wifi_health.sh` supportent `ZACUS_WEB_TOKEN`.
## LVGL graphics stack (ESP32-S3)
- Le runtime Freenove (`env:freenove_esp32s3*`) supporte:
- `LV_COLOR_DEPTH=8` (RGB332) avec conversion RGB565 au flush.
- draw buffers lignes en double-buffer.
- flush DMA asynchrone (overlap draw/transfert) avec fallback sync.
- Flags principaux (`platformio.ini`):
- `UI_COLOR_256`, `UI_COLOR_565`, `UI_FORCE_THEME_256`
- `UI_DRAW_BUF_LINES`, `UI_DRAW_BUF_IN_PSRAM`
- `UI_DMA_FLUSH_ASYNC`, `UI_DMA_TRANS_BUF_LINES`
- `UI_FULL_FRAME_BENCH`, `UI_LV_MEM_SIZE_KB`
- Commandes debug serie:
- `UI_GFX_STATUS`
- `UI_MEM_STATUS`
- Documentation associee:
- `docs/ui/graphics_stack.md`
- `docs/ui/lvgl_memory_budget.md`
- `docs/ui/fonts_fr.md`
## Mapping hardware
- Voir `include/ui_freenove_config.h` pour ladaptation des pins
- Schéma de branchement: se référer à la doc Freenove
## Notes
- Ce firmware est expérimental et fusionne les logiques audio + UI.
- Pour la compatibilité UI Link, prévoir un mode optionnel.
## Intro Amiga92 (`SCENE_WIN_ETAPE`)
- Activation: l'intro A/B/C est lancee automatiquement quand `screen_scene_id == SCENE_WIN_ETAPE`.
- Mode runtime: `FX_ONLY_V9` avec rendu timeline FX (plasma/starfield/rasterbars + tunnel3d/rotozoom/wirecube + boingball) et overlay LVGL conserve.
- Sequence timeline verrouillee: `A(30000ms) -> B(15000ms) -> C(20000ms) -> C loop`.
- Mapping presets (default):
- A: `demo`
- B: `winner`
- C: `boingball`
- Font scroller default: `italic`.
- BPM default: `125`.
- Boing shadow path: assembleur S3 active par defaut (`UI_BOING_SHADOW_ASM=1`) avec fallback C automatique.
- Log de boot FX: `boing_shadow_path=asm|c`.
### Overrides runtime (TXT + JSON)
- Priorite de lecture:
1) `/ui/scene_win_etape.json`
2) `/SCENE_WIN_ETAPE.json`
3) `/ui/SCENE_WIN_ETAPE.json`
4) `/ui/scene_win_etape.txt`
- Cles supportees:
- `A_MS`, `B_MS`, `C_MS`
- `FX_PRESET_A`, `FX_PRESET_B`, `FX_PRESET_C` (`demo|winner|fireworks|boingball`)
- `FX_MODE_A`, `FX_MODE_B`, `FX_MODE_C` (`classic|starfield3d|dotsphere3d|voxel|raycorridor`)
- `FX_SCROLL_TEXT_A`, `FX_SCROLL_TEXT_B`, `FX_SCROLL_TEXT_C`
- `FX_SCROLL_FONT` (`basic|bold|outline|italic`)
- `FX_BPM` (`60..220`)
- Rupture de compatibilite volontaire:
- anciennes cles FX `FX_3D`, `FX_3D_QUALITY`, `FONT_MODE` ne sont plus prises en charge dans ce flux.
Exemples:
- JSON: `data/SCENE_WIN_ETAPE.json`
- TXT: `data/ui/scene_win_etape.txt`
### Perf notes
- Tick fixe: `42 ms` (`~24 FPS` cible), `dt` clamp pour robustesse.
- Zero allocation par frame dans la boucle intro (`tickIntro`).
- Blit LGFX: fast-path 2x active (`UI_FX_BLIT_FAST_2X=1`) si ratio source/ecran exact, fallback scaler general sinon.
- Caps objets scene: `<=140` (petit ecran), `<=260` (grand ecran).
- Pools fixes:
- stars: `48`
- fireworks: `72`
- wave glyph+shadow: `64 + 64`
- `UI_GFX_STATUS` expose `fx_fps/fx_frames/fx_skip_busy` + compteurs `flush_block/overflow` pour diagnostiquer les saccades LVGL/FX.
## Scenes demoscene exposees
- IDs canoniques ajoutes: `SCENE_WINNER`, `SCENE_FIREWORKS`.
- Fichiers data:
- `data/story/screens/SCENE_WINNER.json`
- `data/story/screens/SCENE_FIREWORKS.json`
- Registry story: `hardware/libs/story/src/resources/screen_scene_registry.cpp`.
## Scene lecteur MP3 (`SCENE_MP3_PLAYER`)
- Scene canonique: `SCENE_MP3_PLAYER`.
- Aliases supportes: `SCENE_AUDIO_PLAYER`, `SCENE_MP3`.
- Data scene: `data/story/screens/SCENE_MP3_PLAYER.json`.
- Mode runtime: overlay LVGL "AmigaAMP" + backend `AudioPlayerService` (scan `/music`, fallback `/audio/music`, fallback `/audio`).
- Arbitrage audio:
- en scene MP3, l'audio scenario est suspendu;
- en sortie de scene MP3, pipeline audio scenario restaure.
- Commandes serie:
- `AMP_SHOW`, `AMP_HIDE`, `AMP_TOGGLE`
- `AMP_SCAN`, `AMP_PLAY <idx|path>`, `AMP_NEXT`, `AMP_PREV`, `AMP_STOP`, `AMP_STATUS`
## Scene camera recorder (`SCENE_CAMERA_SCAN`)
- Binding scene: entree `SCENE_CAMERA_SCAN` -> session recorder camera active (`RGB565/QVGA`) + overlay Win311 visible.
- Sortie scene: overlay masque, frame gelee purgee, retour mode snapshot legacy (`JPEG`).
- Mapping boutons physiques:
- `BTN1`: `SNAP/LIVE`
- `BTN2`: `SAVE`
- `BTN3`: `GALLERY` (appui long: `NEXT`)
- `BTN4`: `DELETE`
- `BTN5`: `CLOSE` overlay
- Commandes serie:
- `CAM_UI_SHOW`, `CAM_UI_HIDE`, `CAM_UI_TOGGLE`
- `CAM_REC_SNAP`, `CAM_REC_SAVE [auto|bmp|jpg|raw]`
- `CAM_REC_GALLERY`, `CAM_REC_NEXT`, `CAM_REC_DELETE`, `CAM_REC_STATUS`
- Ownership runtime:
- pendant `SCENE_CAMERA_SCAN`, les boutons physiques ne sont pas forwardes au scenario;
- commandes legacy `CAM_ON/CAM_OFF/CAM_SNAPSHOT` renvoient `camera_busy_recorder_owner`.
### Visual verification mode
- Build flags (env `freenove_esp32s3`) pour tests scene:
- `UI_FULL_FRAME_BENCH=1`
- `UI_DEMO_AUTORUN_WIN_ETAPE=1`
- Quand ces flags sont actifs:
- la scene `SCENE_WIN_ETAPE` demarre automatiquement au boot;
- la phase C continue en boucle tant que la scene reste active;
- `UI_GFX_STATUS` permet de verifier le mode runtime (depth/full_frame/source).
### References consulted
- https://www.pouet.net/prodlist.php?type%5B0%5D=cracktro
- https://www.youtube.com/results?search_query=amiga+cracktro
- https://www.youtube.com/results?search_query=amiga+demoscene+1992
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/hard_2.html
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/
- https://www.markwrobel.dk/project/amigamachinecode/
- https://www.markwrobel.dk/post/amiga-machine-code-letter3-copper-revisited/
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-starfield-effect/
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-wave/
- https://github.com/mrandreastoth/AmigaStyleDemo
@@ -0,0 +1,39 @@
// app_module.h - Interface for pluggable app modules.
#pragma once
#include <Arduino.h>
struct RuntimeServices;
struct AppEntry;
enum class AppModuleState : uint8_t {
kIdle = 0,
kStarting,
kRunning,
kError,
};
class IAppModule {
public:
virtual ~IAppModule() = default;
virtual const char* id() const = 0;
// Called once when APP_OPEN arrives. Return true if launch succeeded.
virtual bool onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) = 0;
// Called once when APP_CLOSE arrives.
virtual void onClose(const char* reason, RuntimeServices* services) = 0;
// Called every frame while the app is running.
virtual void onTick(uint32_t now_ms, RuntimeServices* services) = 0;
// Dispatch an APP_ACTION. Return true if handled.
virtual bool onAction(const char* action, const char* payload, RuntimeServices* services) = 0;
// Current module state.
virtual AppModuleState state() const = 0;
// Last error (may be empty).
virtual const char* lastError() const { return ""; }
};
+36 -15
View File
@@ -1,25 +1,46 @@
// app_registry.h - app catalog loaded from LittleFS with embedded fallback.
// app_registry.h - App catalog loaded from registry.json.
#pragma once
#include <vector>
#include <Arduino.h>
#include "app/app_runtime_types.h"
struct AppEntry {
char id[24] = {0};
char title[32] = {0};
char category[16] = {0};
char icon_path[48] = {0};
char entry_screen[40] = {0};
bool enabled = true;
uint8_t required_capabilities = 0U; // bitmask (lightweight)
};
class StorageManager;
// Capability bits for required_capabilities bitmask.
namespace AppCap {
constexpr uint8_t kAudioOut = 0x01U;
constexpr uint8_t kAudioIn = 0x02U;
constexpr uint8_t kStorageFs = 0x04U;
constexpr uint8_t kStorageSd = 0x08U;
constexpr uint8_t kCamera = 0x10U;
constexpr uint8_t kWifi = 0x20U;
constexpr uint8_t kGpuUi = 0x40U;
} // namespace AppCap
class AppRegistry {
public:
bool loadFromFs(const StorageManager& storage, const char* registry_path = "/apps/registry.json");
const AppDescriptor* find(const char* id) const;
std::vector<AppDescriptor> listByCategory(const char* category) const;
const std::vector<AppDescriptor>& descriptors() const;
static constexpr uint8_t kMaxApps = 24U;
AppRegistry() = default;
bool loadFromJson(const char* json_path);
bool loadFromBuffer(const char* json_buffer, size_t len);
uint8_t count() const { return count_; }
const AppEntry* entry(uint8_t index) const;
const AppEntry* findById(const char* id) const;
uint8_t enabledCount() const;
const AppEntry* enabledEntry(uint8_t visible_index) const;
private:
bool loadFromJson(const char* json_text);
void loadFallbackCatalog();
static uint32_t parseCapabilityMask(const char* csv_caps);
static void copyText(char* out, size_t out_size, const char* text);
std::vector<AppDescriptor> descriptors_;
AppEntry entries_[kMaxApps];
uint8_t count_ = 0U;
};
@@ -1,31 +1,67 @@
// app_runtime_manager.h - app lifecycle and capability gate.
// app_runtime_manager.h - App lifecycle: open/close/action state machine.
#pragma once
#include <memory>
#include <Arduino.h>
#include "app/app_module.h"
#include "app/app_registry.h"
#include "app/app_runtime_types.h"
struct RuntimeServices;
class AppRuntimeManager {
public:
enum class State : uint8_t {
kIdle = 0,
kStarting,
kRunning,
kClosing,
kError,
};
struct Snapshot {
State state = State::kIdle;
const char* app_id = "";
const char* entry_screen = "";
const char* last_error = "";
uint32_t opened_at_ms = 0U;
};
static constexpr uint8_t kMaxModules = 24U;
AppRuntimeManager() = default;
void configure(AppRegistry* registry, const AppContext& context);
bool startApp(const AppStartRequest& request, uint32_t now_ms);
bool stopApp(const AppStopRequest& request, uint32_t now_ms);
bool handleAction(const AppAction& action, uint32_t now_ms);
void begin(AppRegistry* registry, RuntimeServices* services);
// Register a module (call during setup, before any open).
bool registerModule(IAppModule* module);
// App lifecycle.
bool open(const char* app_id, const char* mode, uint32_t now_ms);
bool close(const char* reason, uint32_t now_ms);
bool action(const char* action_name, const char* payload);
void tick(uint32_t now_ms);
AppRuntimeStatus current() const;
const AppDescriptor* currentDescriptor() const;
// Status.
State state() const { return state_; }
Snapshot snapshot() const;
bool isRunning() const { return state_ == State::kRunning; }
bool isIdle() const { return state_ == State::kIdle; }
const char* currentAppId() const { return current_app_id_; }
private:
uint32_t evaluateMissingCapabilities(const AppDescriptor& descriptor) const;
static void copyText(char* out, size_t out_size, const char* text);
IAppModule* findModule(const char* app_id) const;
void transitionTo(State next, const char* reason);
AppRegistry* registry_ = nullptr;
AppContext context_ = {};
std::unique_ptr<IAppModule> module_;
const AppDescriptor* current_descriptor_ = nullptr;
AppRuntimeStatus status_ = {};
RuntimeServices* services_ = nullptr;
IAppModule* modules_[kMaxModules] = {nullptr};
uint8_t module_count_ = 0U;
IAppModule* active_module_ = nullptr;
State state_ = State::kIdle;
char current_app_id_[24] = {0};
char current_entry_screen_[40] = {0};
char last_error_[64] = {0};
uint32_t opened_at_ms_ = 0U;
uint32_t close_requested_ms_ = 0U;
};
@@ -0,0 +1,27 @@
// default_app_registry.h - Embedded fallback app registry JSON.
#pragma once
static const char kDefaultAppRegistryJson[] = R"JSON({
"apps": [
{"id": "audio_player", "title": "Lecteur Audio", "category": "media", "entry_screen": "SCENE_AUDIO_PLAYER", "icon_path": "/apps/icons/audio.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "camera_video", "title": "Photo / Video", "category": "capture", "entry_screen": "SCENE_PHOTO_MANAGER", "icon_path": "/apps/icons/camera.bin", "enabled": true, "required_capabilities": ["CAP_CAMERA","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "dictaphone", "title": "Dictaphone", "category": "capture", "entry_screen": "SCENE_RECORDER", "icon_path": "/apps/icons/mic.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_IN","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "qr_scanner", "title": "QR Scanner", "category": "utility", "entry_screen": "SCENE_QR_DETECTOR", "icon_path": "/apps/icons/qr.bin", "enabled": true, "required_capabilities": ["CAP_CAMERA","CAP_GPU_UI"]},
{"id": "calculator", "title": "Calculatrice", "category": "utility", "entry_screen": "SCENE_CALCULATOR", "icon_path": "/apps/icons/calc.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "timer_tools", "title": "Chrono / Timer", "category": "utility", "entry_screen": "SCENE_TIMER", "icon_path": "/apps/icons/timer.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "flashlight", "title": "Lampe", "category": "utility", "entry_screen": "SCENE_FLASHLIGHT", "icon_path": "/apps/icons/flash.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "audiobook_player", "title": "Livres Audio", "category": "media", "entry_screen": "SCENE_AUDIOBOOK", "icon_path": "/apps/icons/book.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "kids_webradio", "title": "Webradio", "category": "kids", "entry_screen": "SCENE_WEBRADIO", "icon_path": "/apps/icons/radio.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_WIFI","CAP_GPU_UI"]},
{"id": "kids_podcast", "title": "Podcasts", "category": "kids", "entry_screen": "SCENE_PODCAST", "icon_path": "/apps/icons/podcast.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_WIFI","CAP_GPU_UI"]},
{"id": "kids_music", "title": "Musique", "category": "kids", "entry_screen": "SCENE_KIDS_MUSIC", "icon_path": "/apps/icons/music.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "kids_yoga", "title": "Yoga", "category": "kids", "entry_screen": "SCENE_KIDS_YOGA", "icon_path": "/apps/icons/yoga.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
{"id": "kids_meditation", "title": "Meditation", "category": "kids", "entry_screen": "SCENE_KIDS_MEDITATION","icon_path": "/apps/icons/meditate.bin","enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
{"id": "kids_languages", "title": "Langues", "category": "kids", "entry_screen": "SCENE_KIDS_LANG", "icon_path": "/apps/icons/lang.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
{"id": "kids_math", "title": "Maths", "category": "kids", "entry_screen": "SCENE_KIDS_MATH", "icon_path": "/apps/icons/math.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_science", "title": "Sciences", "category": "kids", "entry_screen": "SCENE_KIDS_SCIENCE", "icon_path": "/apps/icons/science.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_geography", "title": "Geographie", "category": "kids", "entry_screen": "SCENE_KIDS_GEO", "icon_path": "/apps/icons/geo.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_drawing", "title": "Dessin", "category": "kids", "entry_screen": "SCENE_DRAWING", "icon_path": "/apps/icons/draw.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_coloring", "title": "Coloriage", "category": "kids", "entry_screen": "SCENE_COLORING", "icon_path": "/apps/icons/color.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "nes_emulator", "title": "NES Emu", "category": "emulator", "entry_screen": "SCENE_NES_EMU", "icon_path": "/apps/icons/nes.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI","CAP_STORAGE_FS"]}
]
})JSON";
@@ -34,7 +34,6 @@ class FileShareService {
String* out_error) const;
private:
static void copyText(char* out, size_t out_size, const char* text);
bool ensureSharedDirs() const;
bool resolveIncomingPath(const char* requested_path, String* out_full_path) const;
@@ -0,0 +1,20 @@
// audio_player_module.h - Audio player app module wrapping AmigaAudioPlayer.
#pragma once
#include "app/app_module.h"
class AudioPlayerModule : public IAppModule {
public:
const char* id() const override { return "audio_player"; }
bool onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) override;
void onClose(const char* reason, RuntimeServices* services) override;
void onTick(uint32_t now_ms, RuntimeServices* services) override;
bool onAction(const char* action, const char* payload, RuntimeServices* services) override;
AppModuleState state() const override { return state_; }
const char* lastError() const override { return last_error_; }
private:
AppModuleState state_ = AppModuleState::kIdle;
char last_error_[48] = {0};
};
@@ -27,6 +27,8 @@ class AudioManager {
void stop();
void update();
bool isPlaying() const;
void releaseOutputResources();
bool restoreOutputResources();
void setVolume(uint8_t volume);
uint8_t volume() const;
@@ -112,6 +114,8 @@ class AudioManager {
bool pending_use_sd_ = false;
bool pending_diagnostic_tone_ = false;
uint32_t reopen_earliest_ms_ = 0U;
bool restore_pending_ = false;
uint32_t restore_retry_not_before_ms_ = 0U;
uint32_t underrun_count_ = 0U;
uint32_t underrun_last_note_ms_ = 0U;
uint32_t underrun_last_log_ms_ = 0U;
+114 -48
View File
@@ -1,63 +1,108 @@
// mutex_manager.h - Thread-safe access protection for global AudioManager and ScenarioManager
// CRITICAL: Prevents race conditions between Arduino loop (core 1), WebServer (core 0), I2S callbacks
// mutex_manager.h - Thread-safe mutex management (OO singleton, atomic stats).
// Strategy: 2 separate mutexes (audio, scenario) for fine-grained protection.
// Lock ordering: ALWAYS audio → scenario to prevent deadlock.
// Overhead: ~50µs per lock/unlock on ESP32-S3 @ 240MHz.
#pragma once
#include <atomic>
#include <cstdint>
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
// ============================================================================
// MUTEX MANAGER - Dual-mutex strategy for g_audio and g_scenario protection
// Strategy: 2 separate mutexes (audio_mutex, scenario_mutex) for fine granularity
// Lock ordering: ALWAYS acquire audio_mutex before scenario_mutex to prevent deadlock
// ISR-safe: Timeout-based acquisition (max 1000ms) compatible with watchdog (30s)
// Overhead: ~50µs per lock/unlock operation on ESP32-S3 @ 240MHz
// MutexManager — singleton class with atomic stats
// Call sites unchanged: MutexManager::init(), MutexManager::takeAudioMutex()…
// ============================================================================
namespace MutexManager {
class MutexManager {
public:
// Meyer's singleton — thread-safe, zero-overhead after first call.
static MutexManager& instance();
// Initialize mutex system (call once in setup())
// Returns true if initialization successful, false on failure
bool init();
// --- Lifecycle (call once in setup / shutdown) ---
bool doInit();
void doDeinit();
// Cleanup (call on shutdown/restart)
void deinit();
// --- Low-level acquisition (prefer RAII guards below) ---
bool takeAudio(uint32_t timeout_ms);
void releaseAudio();
bool takeScenario(uint32_t timeout_ms);
void releaseScenario();
bool takeBoth(uint32_t timeout_ms);
void releaseBoth();
// Low-level mutex acquisition (prefer RAII guards below)
// timeout_ms: Max wait time (0 = no wait, portMAX_DELAY = infinite)
// Returns true if lock acquired, false on timeout
bool takeAudioMutex(uint32_t timeout_ms);
void releaseAudioMutex();
// --- Atomic stats ---
uint32_t audioLockCount() const { return audio_lock_count_.load(); }
uint32_t scenarioLockCount() const { return scenario_lock_count_.load(); }
uint32_t audioTimeoutCount() const { return audio_timeout_count_.load(); }
uint32_t scenarioTimeoutCount() const { return scenario_timeout_count_.load(); }
uint32_t maxAudioWaitUs() const { return max_audio_wait_us_.load(); }
uint32_t maxScenarioWaitUs() const { return max_scenario_wait_us_.load(); }
bool takeScenarioMutex(uint32_t timeout_ms);
void releaseScenarioMutex();
// --- Static wrappers — backward-compatible with old namespace API ---
static bool init() { return instance().doInit(); }
static void deinit() { instance().doDeinit(); }
static bool takeAudioMutex(uint32_t ms) { return instance().takeAudio(ms); }
static void releaseAudioMutex() { instance().releaseAudio(); }
static bool takeScenarioMutex(uint32_t ms) { return instance().takeScenario(ms); }
static void releaseScenarioMutex() { instance().releaseScenario(); }
static bool takeBothMutexes(uint32_t ms) { return instance().takeBoth(ms); }
static void releaseBothMutexes() { instance().releaseBoth(); }
static uint32_t getAudioLockCount() { return instance().audioLockCount(); }
static uint32_t getScenarioLockCount() { return instance().scenarioLockCount(); }
static uint32_t getAudioTimeoutCount() { return instance().audioTimeoutCount(); }
static uint32_t getScenarioTimeoutCount() { return instance().scenarioTimeoutCount(); }
static uint32_t getMaxAudioWaitUs() { return instance().maxAudioWaitUs(); }
static uint32_t getMaxScenarioWaitUs() { return instance().maxScenarioWaitUs(); }
// Acquire both mutexes with deadlock prevention (audio → scenario order)
// Returns true if both acquired, false if either acquisition failed
bool takeBothMutexes(uint32_t timeout_ms);
void releaseBothMutexes();
// Non-copyable, non-movable.
MutexManager(const MutexManager&) = delete;
MutexManager& operator=(const MutexManager&) = delete;
// Statistics (for debugging race conditions)
uint32_t audioLockCount();
uint32_t scenarioLockCount();
uint32_t audioTimeoutCount();
uint32_t scenarioTimeoutCount();
uint32_t maxAudioWaitUs();
uint32_t maxScenarioWaitUs();
private:
MutexManager() = default;
} // namespace MutexManager
void updateMaxWait(std::atomic<uint32_t>& max_field, uint32_t elapsed_us);
SemaphoreHandle_t audio_mutex_{nullptr};
SemaphoreHandle_t scenario_mutex_{nullptr};
std::atomic<uint32_t> audio_lock_count_{0U};
std::atomic<uint32_t> scenario_lock_count_{0U};
std::atomic<uint32_t> audio_timeout_count_{0U};
std::atomic<uint32_t> scenario_timeout_count_{0U};
std::atomic<uint32_t> max_audio_wait_us_{0U};
std::atomic<uint32_t> max_scenario_wait_us_{0U};
// Owner tracking for deadlock detection (volatile: written under semaphore).
volatile TaskHandle_t audio_owner_{nullptr};
volatile TaskHandle_t scenario_owner_{nullptr};
};
// ============================================================================
// RAII LOCK GUARDS - Automatic lock/unlock with timeout protection
// Usage: { AudioLock lock; g_audio.play(...); } // auto-release on scope exit
// RAII LOCK GUARDS — automatic lock/unlock on scope exit.
// Usage: { AudioLock lock; if (lock) g_audio.play(); }
// ============================================================================
class AudioLock {
public:
explicit AudioLock(uint32_t timeout_ms = 1000);
~AudioLock();
explicit AudioLock(uint32_t timeout_ms = 1000U)
: acquired_(MutexManager::instance().takeAudio(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] AudioLock FAILED (timeout %lu ms)\n",
static_cast<unsigned long>(timeout_ms));
}
}
~AudioLock() {
if (acquired_) {
MutexManager::instance().releaseAudio();
}
}
AudioLock(const AudioLock&) = delete;
AudioLock& operator=(const AudioLock&) = delete;
explicit operator bool() const { return acquired_; }
bool acquired() const { return acquired_; }
private:
@@ -66,10 +111,21 @@ class AudioLock {
class ScenarioLock {
public:
explicit ScenarioLock(uint32_t timeout_ms = 1000);
~ScenarioLock();
explicit ScenarioLock(uint32_t timeout_ms = 1000U)
: acquired_(MutexManager::instance().takeScenario(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] ScenarioLock FAILED (timeout %lu ms)\n",
static_cast<unsigned long>(timeout_ms));
}
}
~ScenarioLock() {
if (acquired_) {
MutexManager::instance().releaseScenario();
}
}
ScenarioLock(const ScenarioLock&) = delete;
ScenarioLock& operator=(const ScenarioLock&) = delete;
explicit operator bool() const { return acquired_; }
bool acquired() const { return acquired_; }
private:
@@ -78,10 +134,21 @@ class ScenarioLock {
class DualLock {
public:
explicit DualLock(uint32_t timeout_ms = 1000);
~DualLock();
explicit DualLock(uint32_t timeout_ms = 1000U)
: acquired_(MutexManager::instance().takeBoth(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] DualLock FAILED (timeout %lu ms)\n",
static_cast<unsigned long>(timeout_ms));
}
}
~DualLock() {
if (acquired_) {
MutexManager::instance().releaseBoth();
}
}
DualLock(const DualLock&) = delete;
DualLock& operator=(const DualLock&) = delete;
explicit operator bool() const { return acquired_; }
bool acquired() const { return acquired_; }
private:
@@ -89,14 +156,13 @@ class DualLock {
};
// ============================================================================
// INTEGRATION MACROS - Quick integration into existing code
// Example: AUDIO_GUARDED_CALL(g_audio.play("/music/track.mp3"))
// MACROS — quick guarded calls (unchanged API).
// ============================================================================
#define AUDIO_GUARDED_CALL(call) \
do { \
AudioLock _audio_lock(1000); \
if (!_audio_lock.acquired()) { \
AudioLock _audio_lock(1000U); \
if (!_audio_lock) { \
Serial.println("[MUTEX] WARN: Audio lock timeout"); \
} else { \
call; \
@@ -105,8 +171,8 @@ class DualLock {
#define SCENARIO_GUARDED_CALL(call) \
do { \
ScenarioLock _scenario_lock(1000); \
if (!_scenario_lock.acquired()) { \
ScenarioLock _scenario_lock(1000U); \
if (!_scenario_lock) { \
Serial.println("[MUTEX] WARN: Scenario lock timeout"); \
} else { \
call; \
@@ -115,8 +181,8 @@ class DualLock {
#define DUAL_GUARDED_CALL(call) \
do { \
DualLock _dual_lock(1000); \
if (!_dual_lock.acquired()) { \
DualLock _dual_lock(1000U); \
if (!_dual_lock) { \
Serial.println("[MUTEX] WARN: Dual lock timeout"); \
} else { \
call; \
@@ -0,0 +1,46 @@
// str_utils.h - shared string utilities (zero-dependency, header-only).
#pragma once
#include <cctype>
#include <cstddef>
#include <cstring>
namespace core {
/// Case-insensitive string comparison. Returns true if lhs == rhs ignoring case.
/// Safe with nullptr: (nullptr, nullptr) → false.
inline bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
for (size_t i = 0U;; ++i) {
const char a = lhs[i];
const char b = rhs[i];
if (a == '\0' && b == '\0') {
return true;
}
if (a == '\0' || b == '\0') {
return false;
}
if (std::tolower(static_cast<unsigned char>(a)) !=
std::tolower(static_cast<unsigned char>(b))) {
return false;
}
}
}
/// Safe bounded string copy. Always null-terminates dst.
/// No-op if dst is nullptr or dst_size is 0.
inline void copyText(char* dst, size_t dst_size, const char* src) {
if (dst == nullptr || dst_size == 0U) {
return;
}
if (src == nullptr) {
dst[0] = '\0';
return;
}
std::strncpy(dst, src, dst_size - 1U);
dst[dst_size - 1U] = '\0';
}
} // namespace core
@@ -89,6 +89,7 @@ class HardwareManager {
private:
bool beginMic();
void endMic();
void updateMic(uint32_t now_ms);
void updateBattery(uint32_t now_ms);
void updateLed(uint32_t now_ms);
@@ -2,6 +2,7 @@
#pragma once
#include <Arduino.h>
#include <freertos/portmacro.h>
#include <cstdint>
@@ -48,6 +49,8 @@ class PerfMonitor {
PerfSectionStats sections_[static_cast<uint8_t>(PerfSection::kCount)] = {};
uint32_t ui_dma_flush_count_ = 0U;
uint32_t ui_sync_flush_count_ = 0U;
// Spinlock protecting sections_ and flush counters (multi-task safe).
mutable portMUX_TYPE stats_mux_ = portMUX_INITIALIZER_UNLOCKED;
};
PerfMonitor& perfMonitor();
@@ -32,19 +32,52 @@ struct SimdBenchResult {
const SimdAccelStatus& status();
void resetBenchStatus();
// --- RGB565 buffer operations ---
/// memcpy wrapper for typed RGB565 buffers.
void simd_rgb565_copy(uint16_t* dst, const uint16_t* src, size_t n_px);
/// Fill n_px pixels with a constant RGB565 color. Uses 32-bit writes when aligned.
void simd_rgb565_fill(uint16_t* dst, uint16_t color565, size_t n_px);
/// Byte-swap every RGB565 pixel (host ↔ big-endian wire order).
/// Optimised: 2 pixels per iteration via 32-bit packing when aligned.
void simd_rgb565_bswap_copy(uint16_t* dst, const uint16_t* src, size_t n_px);
/// Alpha-blend src over dst in-place.
/// alpha = 255 → fully src, alpha = 0 → fully dst.
/// Optimised: 2 pixels per iteration via 32-bit masking when aligned.
void simd_rgb565_alpha_blend(uint16_t* dst, const uint16_t* src,
uint8_t alpha, size_t n_px);
/// Scale brightness of each pixel. level = 255 → no change, 0 → black.
/// Optimised: 2 pixels per iteration when aligned.
void simd_rgb565_brightness(uint16_t* dst, const uint16_t* src,
uint8_t level, size_t n_px);
// --- Color format conversions ---
/// Grayscale L8 → RGB565 via precomputed LUT. 2-pixel 32-bit fast path when aligned.
void simd_l8_to_rgb565(uint16_t* dst565, const uint8_t* src_l8, size_t n_px);
/// Indexed 8-bit palette → RGB565. 2-pixel 32-bit fast path when aligned.
void simd_index8_to_rgb565(uint16_t* dst565,
const uint8_t* idx8,
const uint16_t* pal565_256,
size_t n_px);
/// Packed RGB888 → RGB565. 32-bit read fast path when src is aligned.
void simd_rgb888_to_rgb565(uint16_t* dst565, const uint8_t* src_rgb888, size_t n_px);
/// YUV422 (YUYV) → RGB565. BT.601 full-range, 2 pixels per iteration.
void simd_yuv422_to_rgb565(uint16_t* dst565, const uint8_t* src_yuv422, size_t n_px);
// --- Audio DSP ---
/// Apply Q15 gain to int16 samples. Uses ESP-DSP when available, scalar fallback.
void simd_s16_gain_q15(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n);
/// Mix two int16 streams with independent Q15 gains. Uses ESP-DSP when available.
void simd_s16_mix2_q15(int16_t* dst,
const int16_t* a,
const int16_t* b,
@@ -52,8 +85,9 @@ void simd_s16_mix2_q15(int16_t* dst,
int16_t gb_q15,
size_t n);
// --- Self-test and benchmark ---
bool selfTest();
SimdBenchResult runBench(uint32_t loops, uint32_t pixels);
} // namespace runtime::simd
@@ -88,9 +88,7 @@ class NetworkManager {
static void onEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status);
static uint8_t parseHexByte(char high, char low, bool* ok);
static void copyText(char* out, size_t out_size, const char* text);
static void formatMac(const uint8_t* mac, char* out, size_t out_size);
static bool equalsIgnoreCase(const char* lhs, const char* rhs);
static const char* wifiModeLabel(uint8_t mode);
static const char* networkStateLabel(bool sta_connected,
bool sta_connecting,
@@ -0,0 +1,131 @@
// amiga_ui_shell.h - Workbench-style app launcher grid (LVGL).
#pragma once
#include <Arduino.h>
#include <lvgl.h>
#include "app/app_registry.h"
#include "app/app_runtime_manager.h"
namespace ui::workbench {
class AmigaUIShell {
public:
// Display layout: portrait 320x480 => 3 cols x 5 rows = 15 tiles/page.
// Landscape 480x320 => 4 cols x 3 rows = 12 tiles/page.
static constexpr uint8_t kGridColsPortrait = 3U;
static constexpr uint8_t kGridRowsPortrait = 5U;
static constexpr uint8_t kGridColsLandscape = 4U;
static constexpr uint8_t kGridRowsLandscape = 3U;
static constexpr uint8_t kTilesPerPage = 12U; // Conservative: fits both orientations.
static constexpr uint8_t kMaxPages = 3U;
static constexpr uint8_t kMaxVisibleTiles = kTilesPerPage * kMaxPages;
enum class InputKey : uint8_t {
kUp = 0,
kDown,
kLeft,
kRight,
kOk,
kBack,
};
struct Config {
bool landscape = true; // Default: rotation=1 => landscape.
uint16_t screen_w = 480U;
uint16_t screen_h = 320U;
uint8_t grid_cols = kGridColsLandscape;
uint8_t grid_rows = kGridRowsLandscape;
};
AmigaUIShell() = default;
~AmigaUIShell() = default;
AmigaUIShell(const AmigaUIShell&) = delete;
AmigaUIShell& operator=(const AmigaUIShell&) = delete;
bool begin(const Config& cfg, AppRegistry* registry, AppRuntimeManager* runtime);
void show();
void hide();
bool visible() const { return visible_; }
void toggle();
void handleInput(InputKey key);
void handleButtonKey(uint8_t hw_key, bool long_press);
void tick(uint32_t now_ms);
// Request app launch from the currently focused tile.
bool launchFocused(uint32_t now_ms);
bool requestCloseApp(const char* reason, uint32_t now_ms);
uint8_t currentPage() const { return current_page_; }
uint8_t pageCount() const { return page_count_; }
uint8_t focusIndex() const { return focus_index_; }
const AppEntry* focusedEntry() const;
private:
void createUi();
void destroyUi();
void rebuildGrid();
void updateFocusVisual();
void updateStatusBar(uint32_t now_ms);
void updateTileStates();
void navigatePage(int8_t delta);
void navigateFocus(int8_t dx, int8_t dy);
uint8_t tilesOnCurrentPage() const;
uint8_t globalIndexFromPageLocal(uint8_t local) const;
// Styles.
lv_style_t st_bg_;
lv_style_t st_topbar_;
lv_style_t st_tile_;
lv_style_t st_tile_focused_;
lv_style_t st_tile_disabled_;
lv_style_t st_label_;
lv_style_t st_status_;
lv_style_t st_page_indicator_;
bool styles_inited_ = false;
// Layout.
Config cfg_;
uint16_t tile_w_ = 0U;
uint16_t tile_h_ = 0U;
uint16_t grid_x_offset_ = 0U;
uint16_t grid_y_offset_ = 0U;
// Data.
AppRegistry* registry_ = nullptr;
AppRuntimeManager* runtime_ = nullptr;
uint8_t enabled_count_ = 0U;
uint8_t page_count_ = 0U;
uint8_t current_page_ = 0U;
uint8_t focus_index_ = 0U; // local index on current page
bool visible_ = false;
// LVGL objects.
lv_obj_t* root_ = nullptr;
lv_obj_t* topbar_ = nullptr;
lv_obj_t* topbar_label_ = nullptr;
lv_obj_t* topbar_status_ = nullptr;
lv_obj_t* grid_container_ = nullptr;
lv_obj_t* page_indicator_ = nullptr;
static constexpr uint8_t kMaxTilesPerPage = 15U; // Max visible on one page.
struct TileSlot {
lv_obj_t* container = nullptr;
lv_obj_t* icon_label = nullptr;
lv_obj_t* title_label = nullptr;
uint8_t global_app_index = 0xFFU;
};
TileSlot tiles_[kMaxTilesPerPage];
uint8_t tile_count_ = 0U;
// Launching feedback.
lv_obj_t* launch_overlay_ = nullptr;
lv_obj_t* launch_label_ = nullptr;
uint32_t launch_overlay_hide_ms_ = 0U;
uint32_t last_status_update_ms_ = 0U;
};
} // namespace ui::workbench
@@ -1,8 +1,9 @@
#pragma once
// Freenove Media Kit reference profile (Sketch_19):
// - FNK0102H ST7796 320x480 by default, rotation 1
// - Buttons via analog ladder on GPIO19
// Freenove FNK0102H release profile:
// - Freenove ESP32-S3 WROOM N8R8 (8MB flash / 8MB PSRAM)
// - ST7796 320x480, rotation 1
// - 5-way switch via analog ladder on GPIO19
// - I2S output BCLK=42, WS=41, DOUT=1
#ifndef FREENOVE_LCD_VARIANT_FNK0102A
@@ -135,6 +136,9 @@
#define FREENOVE_HAS_TOUCH 0
#endif
// Official release profile uses the 5-way switch only.
// The experimental touch wiring overlaps with the camera mapping on FNK0102H,
// so release builds keep touch disabled.
#if FREENOVE_HAS_TOUCH
#define FREENOVE_TOUCH_CS 9
#define FREENOVE_TOUCH_IRQ 15
@@ -221,7 +225,7 @@
#define FREENOVE_BAT_CHARGE_PIN -1
#endif
// Camera wiring from Freenove FNK0102 (ESP32S3_EYE profile).
// Camera wiring validated for the FNK0102H release profile.
#ifndef FREENOVE_CAM_ENABLE
#define FREENOVE_CAM_ENABLE 1
#endif
+6 -9
View File
@@ -1,6 +1,7 @@
// app_audio.cpp - Audio Player Implementation
#include "app/app_audio.h"
#include "audio_manager.h"
#include "core/str_utils.h"
AppAudio g_app_audio;
@@ -54,7 +55,7 @@ void AppAudio::onAction(const AppAction& action) {
} else if (strcmp(action.name, "stop") == 0) {
stop();
} else if (strcmp(action.name, "volume") == 0) {
int vol = atoi(action.payload);
int vol = static_cast<int>(strtol(action.payload, nullptr, 10));
setVolume(constrain(vol, 0, 100));
}
}
@@ -65,8 +66,7 @@ bool AppAudio::play(const char* filepath) {
return false;
}
strncpy(current_file_, filepath, sizeof(current_file_) - 1);
current_file_[sizeof(current_file_) - 1] = '\0';
core::copyText(current_file_, sizeof(current_file_), filepath);
playback_ms_ = 0;
duration_ms_ = 30000; // Assume 30 seconds for demo
@@ -74,9 +74,8 @@ bool AppAudio::play(const char* filepath) {
Serial.printf("[APP_AUDIO] Playing: %s (vol=%u%%)\n", current_file_, volume_percent_);
// TODO: Integrate with AudioManager for actual playback
if (context_.audio) {
// context_.audio->playFile(filepath);
context_.audio->play(filepath);
}
return true;
@@ -104,9 +103,8 @@ void AppAudio::stop() {
Serial.println("[APP_AUDIO] Stopped");
// TODO: Stop playback via AudioManager
if (context_.audio) {
// context_.audio->stop();
context_.audio->stop();
}
}
@@ -114,9 +112,8 @@ void AppAudio::setVolume(uint8_t vol_percent) {
volume_percent_ = vol_percent;
Serial.printf("[APP_AUDIO] Volume: %u%%\n", volume_percent_);
// TODO: Update hardware volume via AudioManager
if (context_.audio) {
// context_.audio->setVolume(vol_percent);
context_.audio->setVolume(vol_percent);
}
}
@@ -1,5 +1,6 @@
// app_calculator.cpp - Calculator Implementation
#include "app/app_calculator.h"
#include "core/str_utils.h"
AppCalculator g_app_calculator;
@@ -39,7 +40,7 @@ void AppCalculator::onAction(const AppAction& action) {
void AppCalculator::reset() {
result_ = 0.0;
strncpy(display_, "0", sizeof(display_) - 1);
core::copyText(display_, sizeof(display_), "0");
history_[0] = '\0';
}
@@ -42,7 +42,7 @@ void AppFlashlight::onAction(const AppAction& action) {
} else if (strcmp(action.name, "toggle") == 0) {
toggle();
} else if (strcmp(action.name, "intensity") == 0) {
int percent = atoi(action.payload);
int percent = static_cast<int>(strtol(action.payload, nullptr, 10));
setIntensity(constrain(percent, 0, 100));
}
}
+138 -185
View File
@@ -2,11 +2,9 @@
#include "app/app_registry.h"
#include <ArduinoJson.h>
#include <cctype>
#include <cstdio>
#include <cstring>
#include "core/str_utils.h"
#include "storage_manager.h"
namespace {
@@ -16,100 +14,82 @@ struct FallbackAppEntry {
const char* title;
const char* category;
const char* entry_screen;
bool enabled;
uint32_t required;
uint32_t optional;
bool offline;
bool streaming;
bool enabled;
uint32_t required;
uint32_t optional;
bool offline;
bool streaming;
const char* manifest;
};
constexpr FallbackAppEntry kFallbackApps[] = {
{"audio_player", "Lecteur Audio", "media", "SCENE_AUDIO_PLAYER", true,
{"audio_player", "Lecteur Audio", "media", "SCENE_AUDIO_PLAYER", true,
CAP_AUDIO_OUT | CAP_STORAGE_FS | CAP_GPU_UI, CAP_WIFI,
true, true, "/apps/audio_player/manifest.json"},
{"camera_video", "Appareil Photo/Video", "capture", "SCENE_PHOTO_MANAGER", true,
CAP_CAMERA | CAP_STORAGE_FS | CAP_GPU_UI,
CAP_STORAGE_SD, true, false, "/apps/camera_video/manifest.json"},
{"dictaphone", "Dictaphone", "capture", "SCENE_RECORDER", true,
CAP_AUDIO_IN | CAP_STORAGE_FS | CAP_GPU_UI, CAP_STORAGE_SD,
true, true, "/apps/audio_player/manifest.json"},
{"camera_video", "Appareil Photo/Video", "capture", "SCENE_PHOTO_MANAGER", true,
CAP_CAMERA | CAP_STORAGE_FS | CAP_GPU_UI, CAP_STORAGE_SD,
true, false, "/apps/camera_video/manifest.json"},
{"dictaphone", "Dictaphone", "capture", "SCENE_RECORDER", true,
CAP_AUDIO_IN | CAP_STORAGE_FS | CAP_GPU_UI, CAP_STORAGE_SD,
true, false, "/apps/dictaphone/manifest.json"},
{"timer_tools", "Chronometre/Minuteur", "utility", "SCENE_TIMER", true,
CAP_GPU_UI, 0U, true, false,
"/apps/timer_tools/manifest.json"},
{"flashlight", "Lampe de Poche", "utility", "SCENE_FLASHLIGHT", true,
CAP_LED | CAP_GPU_UI, 0U, true, false,
"/apps/flashlight/manifest.json"},
{"calculator", "Calculatrice", "utility", "SCENE_CALCULATOR", true,
CAP_GPU_UI, 0U, true, false,
"/apps/calculator/manifest.json"},
{"kids_webradio", "Webradio Enfants", "kids_media", "SCENE_WEBRADIO", false,
CAP_AUDIO_OUT | CAP_GPU_UI,
CAP_WIFI | CAP_STORAGE_FS, true, true, "/apps/kids_webradio/manifest.json"},
{"kids_podcast", "Podcast Enfants", "kids_media", "SCENE_PODCAST", false,
{"timer_tools", "Chronometre/Minuteur", "utility", "SCENE_TIMER", true,
CAP_GPU_UI, 0U, true, false, "/apps/timer_tools/manifest.json"},
{"flashlight", "Lampe de Poche", "utility", "SCENE_FLASHLIGHT", true,
CAP_LED | CAP_GPU_UI, 0U, true, false, "/apps/flashlight/manifest.json"},
{"calculator", "Calculatrice", "utility", "SCENE_CALCULATOR", true,
CAP_GPU_UI, 0U, true, false, "/apps/calculator/manifest.json"},
{"kids_webradio", "Webradio Enfants", "kids_media", "SCENE_WEBRADIO", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_podcast/manifest.json"},
{"qr_scanner", "Lecteur QR Code", "capture", "SCENE_QR_DETECTOR", true,
CAP_CAMERA | CAP_GPU_UI, 0U, true, false,
"/apps/qr_scanner/manifest.json"},
{"nes_emulator", "Emulateur NES", "games", "SCENE_NES_EMU", false,
true, true, "/apps/kids_webradio/manifest.json"},
{"kids_podcast", "Podcast Enfants", "kids_media", "SCENE_PODCAST", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_podcast/manifest.json"},
{"qr_scanner", "Lecteur QR Code", "capture", "SCENE_QR_DETECTOR", true,
CAP_CAMERA | CAP_GPU_UI, 0U, true, false, "/apps/qr_scanner/manifest.json"},
{"nes_emulator", "Emulateur NES", "games", "SCENE_NES_EMU", false,
CAP_GPU_UI | CAP_AUDIO_OUT | CAP_STORAGE_FS, CAP_STORAGE_SD,
true, false, "/apps/nes_emulator/manifest.json"},
{"audiobook_player", "Livres Audio", "media", "SCENE_AUDIOBOOK", true,
CAP_AUDIO_OUT | CAP_GPU_UI | CAP_STORAGE_FS,
CAP_WIFI | CAP_STORAGE_SD, true, true, "/apps/audiobook_player/manifest.json"},
{"kids_drawing", "Dessin", "kids_learning", "SCENE_DRAWING", false,
CAP_GPU_UI, 0U, true, false,
"/apps/kids_drawing/manifest.json"},
{"kids_coloring", "Coloriage", "kids_learning", "SCENE_COLORING", false,
CAP_GPU_UI, 0U, true, false,
"/apps/kids_coloring/manifest.json"},
{"kids_music", "Musique Enfants", "kids_media", "SCENE_KIDS_MUSIC", false,
{"audiobook_player", "Livres Audio", "media", "SCENE_AUDIOBOOK", true,
CAP_AUDIO_OUT | CAP_GPU_UI | CAP_STORAGE_FS, CAP_WIFI | CAP_STORAGE_SD,
true, true, "/apps/audiobook_player/manifest.json"},
{"kids_drawing", "Dessin", "kids_learning", "SCENE_DRAWING", false,
CAP_GPU_UI, 0U, true, false, "/apps/kids_drawing/manifest.json"},
{"kids_coloring", "Coloriage", "kids_learning", "SCENE_COLORING", false,
CAP_GPU_UI, 0U, true, false, "/apps/kids_coloring/manifest.json"},
{"kids_music", "Musique Enfants", "kids_media", "SCENE_KIDS_MUSIC", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_music/manifest.json"},
{"kids_yoga", "Yoga Enfants", "kids_wellness", "SCENE_KIDS_YOGA", false,
true, true, "/apps/kids_music/manifest.json"},
{"kids_yoga", "Yoga Enfants", "kids_wellness", "SCENE_KIDS_YOGA", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_yoga/manifest.json"},
{"kids_meditation", "Meditation Enfants", "kids_wellness", "SCENE_KIDS_MEDITATION", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS, true, true, "/apps/kids_meditation/manifest.json"},
{"kids_languages", "Langues Enfants", "kids_learning", "SCENE_KIDS_LANG", false,
CAP_AUDIO_OUT | CAP_GPU_UI,
CAP_WIFI | CAP_STORAGE_FS, true, true, "/apps/kids_languages/manifest.json"},
{"kids_math", "Maths Enfants", "kids_learning", "SCENE_KIDS_MATH", false,
true, true, "/apps/kids_yoga/manifest.json"},
{"kids_meditation", "Meditation Enfants", "kids_wellness", "SCENE_KIDS_MEDITATION", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_meditation/manifest.json"},
{"kids_languages", "Langues Enfants", "kids_learning", "SCENE_KIDS_LANG", false,
CAP_AUDIO_OUT | CAP_GPU_UI, CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_languages/manifest.json"},
{"kids_math", "Maths Enfants", "kids_learning", "SCENE_KIDS_MATH", false,
CAP_GPU_UI, CAP_AUDIO_OUT | CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_math/manifest.json"},
{"kids_science", "Sciences Enfants", "kids_learning", "SCENE_KIDS_SCIENCE", false,
CAP_GPU_UI,
CAP_AUDIO_OUT | CAP_WIFI | CAP_STORAGE_FS, true, true, "/apps/kids_science/manifest.json"},
{"kids_geography", "Geographie Enfants", "kids_learning", "SCENE_KIDS_GEO", false,
CAP_GPU_UI,
CAP_AUDIO_OUT | CAP_WIFI | CAP_STORAGE_FS, true, true, "/apps/kids_geography/manifest.json"},
true, true, "/apps/kids_math/manifest.json"},
{"kids_science", "Sciences Enfants", "kids_learning", "SCENE_KIDS_SCIENCE", false,
CAP_GPU_UI, CAP_AUDIO_OUT | CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_science/manifest.json"},
{"kids_geography", "Geographie Enfants", "kids_learning", "SCENE_KIDS_GEO", false,
CAP_GPU_UI, CAP_AUDIO_OUT | CAP_WIFI | CAP_STORAGE_FS,
true, true, "/apps/kids_geography/manifest.json"},
};
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
for (size_t i = 0U;; ++i) {
const char a = lhs[i];
const char b = rhs[i];
if (a == '\0' && b == '\0') {
return true;
}
if (a == '\0' || b == '\0') {
return false;
}
if (std::tolower(static_cast<unsigned char>(a)) != std::tolower(static_cast<unsigned char>(b))) {
return false;
}
}
}
} // namespace
// ============================================================================
// Public API
// ============================================================================
bool AppRegistry::loadFromFs(const StorageManager& storage, const char* registry_path) {
descriptors_.clear();
const char* path = (registry_path != nullptr && registry_path[0] != '\0') ? registry_path : "/apps/registry.json";
const char* path = (registry_path != nullptr && registry_path[0] != '\0')
? registry_path
: "/apps/registry.json";
const String payload = storage.loadTextFile(path);
if (payload.isEmpty() || !loadFromJson(payload.c_str())) {
loadFallbackCatalog();
@@ -123,26 +103,18 @@ bool AppRegistry::loadFromFs(const StorageManager& storage, const char* registry
}
const AppDescriptor* AppRegistry::find(const char* id) const {
if (id == nullptr || id[0] == '\0') {
return nullptr;
}
for (const AppDescriptor& descriptor : descriptors_) {
if (equalsIgnoreCase(descriptor.id, id)) {
return &descriptor;
}
if (id == nullptr || id[0] == '\0') return nullptr;
for (const AppDescriptor& d : descriptors_) {
if (core::equalsIgnoreCase(d.id, id)) return &d;
}
return nullptr;
}
std::vector<AppDescriptor> AppRegistry::listByCategory(const char* category) const {
if (category == nullptr || category[0] == '\0') return descriptors_;
std::vector<AppDescriptor> filtered;
if (category == nullptr || category[0] == '\0') {
return descriptors_;
}
for (const AppDescriptor& descriptor : descriptors_) {
if (equalsIgnoreCase(descriptor.category, category)) {
filtered.push_back(descriptor);
}
for (const AppDescriptor& d : descriptors_) {
if (core::equalsIgnoreCase(d.category, category)) filtered.push_back(d);
}
return filtered;
}
@@ -151,15 +123,18 @@ const std::vector<AppDescriptor>& AppRegistry::descriptors() const {
return descriptors_;
}
// ============================================================================
// JSON parsing — called once at boot. DynamicJsonDocument(8192) intentional.
// ============================================================================
bool AppRegistry::loadFromJson(const char* json_text) {
if (json_text == nullptr || json_text[0] == '\0') {
return false;
}
if (json_text == nullptr || json_text[0] == '\0') return false;
DynamicJsonDocument document(8192);
const DeserializationError error = deserializeJson(document, json_text);
if (error) {
if (deserializeJson(document, json_text) != DeserializationError::Ok) {
return false;
}
JsonArrayConst items;
if (document.is<JsonArrayConst>()) {
items = document.as<JsonArrayConst>();
@@ -168,47 +143,44 @@ bool AppRegistry::loadFromJson(const char* json_text) {
} else {
return false;
}
descriptors_.clear();
for (JsonVariantConst item : items) {
if (!item.is<JsonObjectConst>()) {
continue;
}
if (!item.is<JsonObjectConst>()) continue;
JsonObjectConst entry = item.as<JsonObjectConst>();
const char* id = entry["id"] | "";
if (id[0] == '\0') {
continue;
if (id[0] == '\0') continue;
AppDescriptor d = {};
core::copyText(d.id, sizeof(d.id), id);
core::copyText(d.title, sizeof(d.title), entry["title"] | id);
core::copyText(d.category, sizeof(d.category), entry["category"] | "misc");
core::copyText(d.entry_screen, sizeof(d.entry_screen), entry["entry_screen"] | "");
d.enabled = entry["enabled"] | true;
core::copyText(d.version, sizeof(d.version), entry["version"] | "1.0.0");
core::copyText(d.icon_path, sizeof(d.icon_path), entry["icon_path"] | "");
if (d.icon_path[0] == '\0') {
char default_icon[96] = {};
std::snprintf(default_icon, sizeof(default_icon), "/apps/%s/icon.png", d.id);
core::copyText(d.icon_path, sizeof(d.icon_path), default_icon);
}
AppDescriptor descriptor = {};
copyText(descriptor.id, sizeof(descriptor.id), id);
copyText(descriptor.title, sizeof(descriptor.title), entry["title"] | id);
copyText(descriptor.category, sizeof(descriptor.category), entry["category"] | "misc");
copyText(descriptor.entry_screen, sizeof(descriptor.entry_screen), entry["entry_screen"] | "");
descriptor.enabled = entry["enabled"] | true;
copyText(descriptor.version, sizeof(descriptor.version), entry["version"] | "1.0.0");
copyText(descriptor.icon_path, sizeof(descriptor.icon_path), entry["icon_path"] | "");
if (descriptor.icon_path[0] == '\0') {
char default_icon[96] = {0};
std::snprintf(default_icon, sizeof(default_icon), "/apps/%s/icon.png", descriptor.id);
copyText(descriptor.icon_path, sizeof(descriptor.icon_path), default_icon);
}
copyText(descriptor.asset_manifest, sizeof(descriptor.asset_manifest), entry["asset_manifest"] | "");
descriptor.supports_offline = entry["supports_offline"] | true;
descriptor.supports_streaming = entry["supports_streaming"] | false;
if (entry["required_capabilities"].is<uint32_t>()) {
descriptor.required_capabilities = entry["required_capabilities"].as<uint32_t>();
} else if (entry["required_capabilities"].is<const char*>()) {
descriptor.required_capabilities = parseCapabilityMask(entry["required_capabilities"] | "");
} else {
descriptor.required_capabilities = parseCapabilityMask(entry["required_caps"] | "");
}
if (entry["optional_capabilities"].is<uint32_t>()) {
descriptor.optional_capabilities = entry["optional_capabilities"].as<uint32_t>();
} else if (entry["optional_capabilities"].is<const char*>()) {
descriptor.optional_capabilities = parseCapabilityMask(entry["optional_capabilities"] | "");
} else {
descriptor.optional_capabilities = parseCapabilityMask(entry["optional_caps"] | "");
}
descriptors_.push_back(descriptor);
core::copyText(d.asset_manifest, sizeof(d.asset_manifest), entry["asset_manifest"] | "");
d.supports_offline = entry["supports_offline"] | true;
d.supports_streaming = entry["supports_streaming"] | false;
// Capabilities: accept uint32 or CSV string, with legacy key aliases.
auto parseCap = [&](const char* key, const char* key_alt) -> uint32_t {
if (entry[key].is<uint32_t>()) return entry[key].as<uint32_t>();
if (entry[key].is<const char*>()) return parseCapabilityMask(entry[key] | "");
return parseCapabilityMask(entry[key_alt] | "");
};
d.required_capabilities = parseCap("required_capabilities", "required_caps");
d.optional_capabilities = parseCap("optional_capabilities", "optional_caps");
descriptors_.push_back(d);
}
return !descriptors_.empty();
}
@@ -216,68 +188,49 @@ bool AppRegistry::loadFromJson(const char* json_text) {
void AppRegistry::loadFallbackCatalog() {
descriptors_.clear();
descriptors_.reserve(sizeof(kFallbackApps) / sizeof(kFallbackApps[0]));
for (const FallbackAppEntry& fallback : kFallbackApps) {
AppDescriptor descriptor = {};
copyText(descriptor.id, sizeof(descriptor.id), fallback.id);
copyText(descriptor.title, sizeof(descriptor.title), fallback.title);
copyText(descriptor.category, sizeof(descriptor.category), fallback.category);
copyText(descriptor.entry_screen, sizeof(descriptor.entry_screen), fallback.entry_screen);
descriptor.enabled = fallback.enabled;
copyText(descriptor.version, sizeof(descriptor.version), "1.0.0");
char icon_path[96] = {0};
std::snprintf(icon_path, sizeof(icon_path), "/apps/%s/icon.png", fallback.id);
copyText(descriptor.icon_path, sizeof(descriptor.icon_path), icon_path);
copyText(descriptor.asset_manifest, sizeof(descriptor.asset_manifest), fallback.manifest);
descriptor.required_capabilities = fallback.required;
descriptor.optional_capabilities = fallback.optional;
descriptor.supports_offline = fallback.offline;
descriptor.supports_streaming = fallback.streaming;
descriptors_.push_back(descriptor);
for (const FallbackAppEntry& f : kFallbackApps) {
AppDescriptor d = {};
core::copyText(d.id, sizeof(d.id), f.id);
core::copyText(d.title, sizeof(d.title), f.title);
core::copyText(d.category, sizeof(d.category), f.category);
core::copyText(d.entry_screen, sizeof(d.entry_screen), f.entry_screen);
d.enabled = f.enabled;
core::copyText(d.version, sizeof(d.version), "1.0.0");
char icon_path[96] = {};
std::snprintf(icon_path, sizeof(icon_path), "/apps/%s/icon.png", f.id);
core::copyText(d.icon_path, sizeof(d.icon_path), icon_path);
core::copyText(d.asset_manifest, sizeof(d.asset_manifest), f.manifest);
d.required_capabilities = f.required;
d.optional_capabilities = f.optional;
d.supports_offline = f.offline;
d.supports_streaming = f.streaming;
descriptors_.push_back(d);
}
}
uint32_t AppRegistry::parseCapabilityMask(const char* csv_caps) {
if (csv_caps == nullptr || csv_caps[0] == '\0') {
return 0U;
}
char buffer[192] = {0};
copyText(buffer, sizeof(buffer), csv_caps);
if (csv_caps == nullptr || csv_caps[0] == '\0') return 0U;
char buffer[192] = {};
core::copyText(buffer, sizeof(buffer), csv_caps);
uint32_t mask = 0U;
char* token = std::strtok(buffer, ",| ");
while (token != nullptr) {
for (size_t i = 0U; token[i] != '\0'; ++i) {
token[i] = static_cast<char>(std::tolower(static_cast<unsigned char>(token[i])));
}
if (std::strcmp(token, "cap_audio_out") == 0 || std::strcmp(token, "audio_out") == 0) {
mask |= CAP_AUDIO_OUT;
} else if (std::strcmp(token, "cap_audio_in") == 0 || std::strcmp(token, "audio_in") == 0) {
mask |= CAP_AUDIO_IN;
} else if (std::strcmp(token, "cap_camera") == 0 || std::strcmp(token, "camera") == 0) {
mask |= CAP_CAMERA;
} else if (std::strcmp(token, "cap_led") == 0 || std::strcmp(token, "led") == 0) {
mask |= CAP_LED;
} else if (std::strcmp(token, "cap_wifi") == 0 || std::strcmp(token, "wifi") == 0) {
mask |= CAP_WIFI;
} else if (std::strcmp(token, "cap_storage_sd") == 0 || std::strcmp(token, "storage_sd") == 0) {
mask |= CAP_STORAGE_SD;
} else if (std::strcmp(token, "cap_storage_fs") == 0 || std::strcmp(token, "storage_fs") == 0) {
mask |= CAP_STORAGE_FS;
} else if (std::strcmp(token, "cap_gpu_ui") == 0 || std::strcmp(token, "gpu_ui") == 0) {
mask |= CAP_GPU_UI;
}
if (std::strcmp(token, "cap_audio_out") == 0 || std::strcmp(token, "audio_out") == 0) mask |= CAP_AUDIO_OUT;
else if (std::strcmp(token, "cap_audio_in") == 0 || std::strcmp(token, "audio_in") == 0) mask |= CAP_AUDIO_IN;
else if (std::strcmp(token, "cap_camera") == 0 || std::strcmp(token, "camera") == 0) mask |= CAP_CAMERA;
else if (std::strcmp(token, "cap_led") == 0 || std::strcmp(token, "led") == 0) mask |= CAP_LED;
else if (std::strcmp(token, "cap_wifi") == 0 || std::strcmp(token, "wifi") == 0) mask |= CAP_WIFI;
else if (std::strcmp(token, "cap_storage_sd") == 0 || std::strcmp(token, "storage_sd") == 0) mask |= CAP_STORAGE_SD;
else if (std::strcmp(token, "cap_storage_fs") == 0 || std::strcmp(token, "storage_fs") == 0) mask |= CAP_STORAGE_FS;
else if (std::strcmp(token, "cap_gpu_ui") == 0 || std::strcmp(token, "gpu_ui") == 0) mask |= CAP_GPU_UI;
token = std::strtok(nullptr, ",| ");
}
return mask;
}
void AppRegistry::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
@@ -2,11 +2,11 @@
#include "app/app_runtime_manager.h"
#include <cstdlib>
#include <cstring>
#include "app/modules/app_modules.h"
#include "audio_manager.h"
#include "camera_manager.h"
#include "core/str_utils.h"
#include "hardware_manager.h"
#include "media_manager.h"
#include "network_manager.h"
@@ -20,281 +20,253 @@
namespace {
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
// ============================================================================
// Heap snapshot cache — ESP.getFreeHeap() is expensive; refresh every 1 s.
// ============================================================================
struct HeapCache {
uint32_t heap_free = 0U;
uint32_t psram_free = 0U;
uint32_t last_ms = 0U;
void refresh() {
const uint32_t now = millis();
if (now - last_ms < 1000U) return;
heap_free = ESP.getFreeHeap();
#if defined(ARDUINO_ARCH_ESP32)
psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
#else
psram_free = 0U;
#endif
last_ms = now;
}
for (size_t i = 0U;; ++i) {
const char a = lhs[i];
const char b = rhs[i];
if (a == '\0' && b == '\0') {
return true;
}
if (a == '\0' || b == '\0') {
return false;
}
const char lower_a = (a >= 'A' && a <= 'Z') ? static_cast<char>(a - 'A' + 'a') : a;
const char lower_b = (b >= 'A' && b <= 'Z') ? static_cast<char>(b - 'A' + 'a') : b;
if (lower_a != lower_b) {
return false;
}
};
HeapCache g_heap_cache;
void updateRuntimePerfCounters(AppRuntimeStatus* status, uint32_t tick_us) {
if (status == nullptr) return;
if (tick_us > status->max_tick_us) {
status->max_tick_us = tick_us;
}
status->avg_tick_us = (status->avg_tick_us == 0U)
? tick_us
: ((status->avg_tick_us * 7U) + tick_us) / 8U;
g_heap_cache.refresh();
status->heap_free = g_heap_cache.heap_free;
status->psram_free = g_heap_cache.psram_free;
}
// ============================================================================
// BasicAppModule — default behaviour for apps without a dedicated module.
// ID-based dispatch maps app IDs to hardware actions at start/end.
// ============================================================================
class BasicAppModule : public IAppModule {
public:
bool begin(const AppContext& context) override {
context_ = context;
bool begin(const AppContext& ctx) override {
ctx_ = ctx;
status_ = {};
if (context.descriptor != nullptr) {
copyText(status_.id, sizeof(status_.id), context.descriptor->id);
if (ctx.descriptor != nullptr) {
core::copyText(status_.id, sizeof(status_.id), ctx.descriptor->id);
}
status_.state = AppRuntimeState::kStarting;
status_.state = AppRuntimeState::kStarting;
status_.started_at_ms = millis();
if (context.descriptor == nullptr) {
copyText(status_.last_error, sizeof(status_.last_error), "missing_descriptor");
if (ctx.descriptor == nullptr) {
core::copyText(status_.last_error, sizeof(status_.last_error), "missing_descriptor");
status_.state = AppRuntimeState::kFailed;
return false;
}
const char* id = context.descriptor->id;
const char* id = ctx.descriptor->id;
bool ok = true;
if (equalsIgnoreCase(id, "camera_video") || equalsIgnoreCase(id, "qr_scanner")) {
ok = (context.camera != nullptr) && context.camera->start();
if (!ok) {
copyText(status_.last_error, sizeof(status_.last_error), "camera_start_failed");
}
} else if (equalsIgnoreCase(id, "dictaphone")) {
ok = (context.media != nullptr) && context.media->startRecording(30U, nullptr);
if (!ok) {
copyText(status_.last_error, sizeof(status_.last_error), "record_start_failed");
}
} else if (equalsIgnoreCase(id, "flashlight")) {
ok = (context.hardware != nullptr) && context.hardware->setManualLed(255U, 255U, 255U, 120U, false);
if (!ok) {
copyText(status_.last_error, sizeof(status_.last_error), "flashlight_start_failed");
}
} else if (equalsIgnoreCase(id, "audio_player") ||
equalsIgnoreCase(id, "audiobook_player") ||
equalsIgnoreCase(id, "kids_webradio") ||
equalsIgnoreCase(id, "kids_podcast") ||
equalsIgnoreCase(id, "kids_music")) {
ok = (context.audio != nullptr) && context.audio->play("/music/boot_radio.mp3");
if (!ok) {
copyText(status_.last_error, sizeof(status_.last_error), "audio_start_failed");
}
if (core::equalsIgnoreCase(id, "camera_video") ||
core::equalsIgnoreCase(id, "qr_scanner")) {
ok = (ctx.camera != nullptr) && ctx.camera->start();
if (!ok) core::copyText(status_.last_error, sizeof(status_.last_error), "camera_start_failed");
} else if (core::equalsIgnoreCase(id, "dictaphone")) {
ok = (ctx.media != nullptr) && ctx.media->startRecording(30U, nullptr);
if (!ok) core::copyText(status_.last_error, sizeof(status_.last_error), "record_start_failed");
} else if (core::equalsIgnoreCase(id, "flashlight")) {
ok = (ctx.hardware != nullptr) && ctx.hardware->setManualLed(255U, 255U, 255U, 120U, false);
if (!ok) core::copyText(status_.last_error, sizeof(status_.last_error), "flashlight_start_failed");
} else if (core::equalsIgnoreCase(id, "audio_player") ||
core::equalsIgnoreCase(id, "audiobook_player") ||
core::equalsIgnoreCase(id, "kids_webradio") ||
core::equalsIgnoreCase(id, "kids_podcast") ||
core::equalsIgnoreCase(id, "kids_music")) {
ok = (ctx.audio != nullptr) && ctx.audio->play("/apps/audio_player/audio/default.mp3");
if (!ok) core::copyText(status_.last_error, sizeof(status_.last_error), "audio_start_failed");
}
if (!ok) {
status_.state = AppRuntimeState::kFailed;
return false;
}
status_.state = AppRuntimeState::kRunning;
copyText(status_.last_event, sizeof(status_.last_event), "begin");
return true;
status_.state = ok ? AppRuntimeState::kRunning : AppRuntimeState::kFailed;
if (ok) core::copyText(status_.last_event, sizeof(status_.last_event), "begin");
return ok;
}
void tick(uint32_t now_ms) override {
status_.last_tick_ms = now_ms;
status_.tick_count += 1U;
status_.tick_count += 1U;
}
void handleAction(const AppAction& action) override {
copyText(status_.last_event, sizeof(status_.last_event), action.name);
if (status_.state != AppRuntimeState::kRunning) {
return;
}
if (equalsIgnoreCase(action.name, "play")) {
if (context_.media != nullptr && action.payload[0] != '\0') {
if (!context_.media->play(action.payload, context_.audio)) {
copyText(status_.last_error, sizeof(status_.last_error), "play_failed");
core::copyText(status_.last_event, sizeof(status_.last_event), action.name);
if (status_.state != AppRuntimeState::kRunning) return;
if (core::equalsIgnoreCase(action.name, "play")) {
if (ctx_.media != nullptr && action.payload[0] != '\0') {
if (!ctx_.media->play(action.payload, ctx_.audio)) {
core::copyText(status_.last_error, sizeof(status_.last_error), "play_failed");
}
}
return;
}
if (equalsIgnoreCase(action.name, "stop")) {
if (context_.media != nullptr) {
context_.media->stop(context_.audio);
} else if (context_.audio != nullptr) {
context_.audio->stop();
} else if (core::equalsIgnoreCase(action.name, "stop")) {
if (ctx_.media != nullptr) {
ctx_.media->stop(ctx_.audio);
} else if (ctx_.audio != nullptr) {
ctx_.audio->stop();
}
return;
}
if (equalsIgnoreCase(action.name, "record_start")) {
} else if (core::equalsIgnoreCase(action.name, "record_start")) {
uint16_t seconds = static_cast<uint16_t>(std::strtoul(action.payload, nullptr, 10));
if (seconds == 0U) {
seconds = 30U;
if (seconds == 0U) seconds = 30U;
if (ctx_.media != nullptr && !ctx_.media->startRecording(seconds, nullptr)) {
core::copyText(status_.last_error, sizeof(status_.last_error), "record_start_failed");
}
if (context_.media != nullptr && !context_.media->startRecording(seconds, nullptr)) {
copyText(status_.last_error, sizeof(status_.last_error), "record_start_failed");
}
return;
}
if (equalsIgnoreCase(action.name, "record_stop")) {
if (context_.media != nullptr) {
context_.media->stopRecording();
}
return;
}
if (equalsIgnoreCase(action.name, "snapshot")) {
if (context_.camera != nullptr) {
} else if (core::equalsIgnoreCase(action.name, "record_stop")) {
if (ctx_.media != nullptr) ctx_.media->stopRecording();
} else if (core::equalsIgnoreCase(action.name, "snapshot")) {
if (ctx_.camera != nullptr) {
String out_path;
if (!context_.camera->snapshotToFile(nullptr, &out_path)) {
copyText(status_.last_error, sizeof(status_.last_error), "snapshot_failed");
if (!ctx_.camera->snapshotToFile(nullptr, &out_path)) {
core::copyText(status_.last_error, sizeof(status_.last_error), "snapshot_failed");
}
}
return;
}
if (equalsIgnoreCase(action.name, "light_on")) {
if (context_.hardware != nullptr &&
!context_.hardware->setManualLed(255U, 255U, 255U, 120U, false)) {
copyText(status_.last_error, sizeof(status_.last_error), "light_on_failed");
} else if (core::equalsIgnoreCase(action.name, "light_on")) {
if (ctx_.hardware != nullptr &&
!ctx_.hardware->setManualLed(255U, 255U, 255U, 120U, false)) {
core::copyText(status_.last_error, sizeof(status_.last_error), "light_on_failed");
}
return;
}
if (equalsIgnoreCase(action.name, "light_off")) {
if (context_.hardware != nullptr) {
context_.hardware->clearManualLed();
}
return;
} else if (core::equalsIgnoreCase(action.name, "light_off")) {
if (ctx_.hardware != nullptr) ctx_.hardware->clearManualLed();
}
}
void end() override {
if (context_.descriptor == nullptr) {
if (ctx_.descriptor == nullptr) {
status_.state = AppRuntimeState::kIdle;
return;
}
const char* id = context_.descriptor->id;
if (equalsIgnoreCase(id, "camera_video") || equalsIgnoreCase(id, "qr_scanner")) {
if (context_.camera != nullptr) {
context_.camera->stop();
}
} else if (equalsIgnoreCase(id, "dictaphone")) {
if (context_.media != nullptr) {
context_.media->stopRecording();
}
} else if (equalsIgnoreCase(id, "flashlight")) {
if (context_.hardware != nullptr) {
context_.hardware->clearManualLed();
}
} else if (equalsIgnoreCase(id, "audio_player") ||
equalsIgnoreCase(id, "audiobook_player") ||
equalsIgnoreCase(id, "kids_webradio") ||
equalsIgnoreCase(id, "kids_podcast") ||
equalsIgnoreCase(id, "kids_music")) {
if (context_.audio != nullptr) {
context_.audio->stop();
}
const char* id = ctx_.descriptor->id;
if (core::equalsIgnoreCase(id, "camera_video") ||
core::equalsIgnoreCase(id, "qr_scanner")) {
if (ctx_.camera != nullptr) ctx_.camera->stop();
} else if (core::equalsIgnoreCase(id, "dictaphone")) {
if (ctx_.media != nullptr) ctx_.media->stopRecording();
} else if (core::equalsIgnoreCase(id, "flashlight")) {
if (ctx_.hardware != nullptr) ctx_.hardware->clearManualLed();
} else if (core::equalsIgnoreCase(id, "audio_player") ||
core::equalsIgnoreCase(id, "audiobook_player") ||
core::equalsIgnoreCase(id, "kids_webradio") ||
core::equalsIgnoreCase(id, "kids_podcast") ||
core::equalsIgnoreCase(id, "kids_music")) {
if (ctx_.audio != nullptr) ctx_.audio->stop();
}
status_.state = AppRuntimeState::kIdle;
copyText(status_.last_event, sizeof(status_.last_event), "end");
core::copyText(status_.last_event, sizeof(status_.last_event), "end");
}
AppRuntimeStatus status() const override {
return status_;
}
AppRuntimeStatus status() const override { return status_; }
private:
static void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
AppContext context_ = {};
AppContext ctx_ = {};
AppRuntimeStatus status_ = {};
};
void updateRuntimePerfCounters(AppRuntimeStatus* status, uint32_t tick_us) {
if (status == nullptr) {
return;
}
if (tick_us > status->max_tick_us) {
status->max_tick_us = tick_us;
}
if (status->avg_tick_us == 0U) {
status->avg_tick_us = tick_us;
} else {
status->avg_tick_us = ((status->avg_tick_us * 7U) + tick_us) / 8U;
}
status->heap_free = ESP.getFreeHeap();
#if defined(ARDUINO_ARCH_ESP32)
status->psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
#else
status->psram_free = 0U;
#endif
}
} // namespace
// ============================================================================
// AppRuntimeManager
// ============================================================================
void AppRuntimeManager::configure(AppRegistry* registry, const AppContext& context) {
registry_ = registry;
context_ = context;
registry_ = registry;
context_ = context;
module_.reset();
current_descriptor_ = nullptr;
status_ = {};
status_ = {};
}
bool AppRuntimeManager::startApp(const AppStartRequest& request, uint32_t now_ms) {
if (registry_ == nullptr || request.id[0] == '\0') {
copyText(status_.last_error, sizeof(status_.last_error), "app_registry_unavailable");
core::copyText(status_.last_error, sizeof(status_.last_error), "app_registry_unavailable");
status_.state = AppRuntimeState::kFailed;
return false;
}
const AppDescriptor* descriptor = registry_->find(request.id);
if (descriptor == nullptr) {
copyText(status_.last_error, sizeof(status_.last_error), "app_not_found");
core::copyText(status_.last_error, sizeof(status_.last_error), "app_not_found");
status_.state = AppRuntimeState::kFailed;
return false;
}
if (!descriptor->enabled) {
copyText(status_.last_error, sizeof(status_.last_error), "app_disabled");
core::copyText(status_.last_error, sizeof(status_.last_error), "app_disabled");
status_.state = AppRuntimeState::kFailed;
return false;
}
AppStopRequest stop_request = {};
copyText(stop_request.id, sizeof(stop_request.id), descriptor->id);
copyText(stop_request.reason, sizeof(stop_request.reason), "switch");
(void)stopApp(stop_request, now_ms);
// Stop any currently running app.
AppStopRequest stop_req = {};
core::copyText(stop_req.id, sizeof(stop_req.id), descriptor->id);
core::copyText(stop_req.reason, sizeof(stop_req.reason), "switch");
(void)stopApp(stop_req, now_ms);
status_ = {};
copyText(status_.id, sizeof(status_.id), descriptor->id);
copyText(status_.mode, sizeof(status_.mode), request.mode);
copyText(status_.source, sizeof(status_.source), request.source);
status_.state = AppRuntimeState::kStarting;
status_.started_at_ms = now_ms;
core::copyText(status_.id, sizeof(status_.id), descriptor->id);
core::copyText(status_.mode, sizeof(status_.mode), request.mode);
core::copyText(status_.source, sizeof(status_.source), request.source);
status_.state = AppRuntimeState::kStarting;
status_.started_at_ms = now_ms;
status_.required_cap_mask = descriptor->required_capabilities;
if (context_.resource != nullptr) {
const uint32_t req = descriptor->required_capabilities;
if (appCapabilityMaskHas(req, CAP_AUDIO_IN)) {
context_.resource->setProfile(runtime::resource::ResourceProfile::kGfxPlusMic);
} else if (appCapabilityMaskHas(req, CAP_CAMERA)) {
context_.resource->setProfile(runtime::resource::ResourceProfile::kGfxPlusCamSnapshot);
if (context_.hardware != nullptr) {
context_.hardware->setMicRuntimeEnabled(false);
}
if (context_.audio != nullptr) {
context_.audio->releaseOutputResources();
}
}
}
status_.missing_cap_mask = evaluateMissingCapabilities(*descriptor);
if (status_.missing_cap_mask != 0U) {
copyText(status_.last_error, sizeof(status_.last_error), "resource_busy");
status_.state = AppRuntimeState::kFailed;
core::copyText(status_.last_error, sizeof(status_.last_error), "resource_busy");
status_.state = AppRuntimeState::kFailed;
current_descriptor_ = descriptor;
return false;
}
if (descriptor->supports_offline &&
descriptor->asset_manifest[0] != '\0' &&
context_.storage != nullptr &&
!context_.storage->fileExists(descriptor->asset_manifest)) {
copyText(status_.last_error, sizeof(status_.last_error), "missing_asset");
status_.state = AppRuntimeState::kFailed;
core::copyText(status_.last_error, sizeof(status_.last_error), "missing_asset");
status_.state = AppRuntimeState::kFailed;
current_descriptor_ = descriptor;
return false;
}
@@ -303,21 +275,24 @@ bool AppRuntimeManager::startApp(const AppStartRequest& request, uint32_t now_ms
if (!module_) {
module_.reset(new BasicAppModule());
}
AppContext run_context = context_;
run_context.descriptor = descriptor;
const bool ok = module_->begin(run_context);
AppContext run_ctx = context_;
run_ctx.descriptor = descriptor;
const bool ok = module_->begin(run_ctx);
status_ = module_->status();
copyText(status_.mode, sizeof(status_.mode), request.mode);
copyText(status_.source, sizeof(status_.source), request.source);
core::copyText(status_.mode, sizeof(status_.mode), request.mode);
core::copyText(status_.source, sizeof(status_.source), request.source);
status_.required_cap_mask = descriptor->required_capabilities;
status_.missing_cap_mask = 0U;
status_.missing_cap_mask = 0U;
updateRuntimePerfCounters(&status_, 0U);
current_descriptor_ = descriptor;
if (ok && context_.ui != nullptr && descriptor->entry_screen[0] != '\0') {
UiSceneFrame frame = {};
frame.screen_scene_id = descriptor->entry_screen;
frame.step_id = descriptor->id;
frame.audio_playing = (context_.audio != nullptr) ? context_.audio->isPlaying() : false;
UiSceneFrame frame = {};
frame.screen_scene_id = descriptor->entry_screen;
frame.step_id = descriptor->id;
frame.audio_playing = (context_.audio != nullptr) && context_.audio->isPlaying();
context_.ui->submitSceneFrame(frame);
}
return ok;
@@ -326,68 +301,81 @@ bool AppRuntimeManager::startApp(const AppStartRequest& request, uint32_t now_ms
bool AppRuntimeManager::stopApp(const AppStopRequest& request, uint32_t now_ms) {
(void)now_ms;
(void)request;
if (!module_) {
return true;
}
if (!module_) return true;
const bool restoring_camera_sidecars =
current_descriptor_ != nullptr &&
appCapabilityMaskHas(current_descriptor_->required_capabilities, CAP_CAMERA);
status_.state = AppRuntimeState::kStopping;
module_->end();
status_ = module_->status();
module_.reset();
current_descriptor_ = nullptr;
copyText(status_.id, sizeof(status_.id), "");
status_.missing_cap_mask = 0U;
core::copyText(status_.id, sizeof(status_.id), "");
status_.missing_cap_mask = 0U;
status_.required_cap_mask = 0U;
if (context_.ui != nullptr) {
UiSceneFrame frame = {};
frame.screen_scene_id = "SCENE_READY";
frame.step_id = "APP_IDLE";
frame.audio_playing = (context_.audio != nullptr) ? context_.audio->isPlaying() : false;
UiSceneFrame frame = {};
frame.screen_scene_id = "SCENE_READY";
frame.step_id = "APP_IDLE";
frame.audio_playing = (context_.audio != nullptr) && context_.audio->isPlaying();
context_.ui->submitSceneFrame(frame);
}
if (restoring_camera_sidecars && context_.audio != nullptr) {
context_.audio->restoreOutputResources();
}
return true;
}
bool AppRuntimeManager::handleAction(const AppAction& action, uint32_t now_ms) {
(void)now_ms;
if (!module_) {
copyText(status_.last_error, sizeof(status_.last_error), "no_app_running");
core::copyText(status_.last_error, sizeof(status_.last_error), "no_app_running");
status_.state = AppRuntimeState::kFailed;
return false;
}
if (action.id[0] != '\0' && !equalsIgnoreCase(action.id, status_.id)) {
copyText(status_.last_error, sizeof(status_.last_error), "app_id_mismatch");
if (action.id[0] != '\0' && !core::equalsIgnoreCase(action.id, status_.id)) {
core::copyText(status_.last_error, sizeof(status_.last_error), "app_id_mismatch");
return false;
}
char mode[sizeof(status_.mode)] = {0};
char source[sizeof(status_.source)] = {0};
copyText(mode, sizeof(mode), status_.mode);
copyText(source, sizeof(source), status_.source);
char mode[sizeof(status_.mode)] = {};
char source[sizeof(status_.source)] = {};
core::copyText(mode, sizeof(mode), status_.mode);
core::copyText(source, sizeof(source), status_.source);
module_->handleAction(action);
status_ = module_->status();
copyText(status_.mode, sizeof(status_.mode), mode);
copyText(status_.source, sizeof(status_.source), source);
status_.required_cap_mask = (current_descriptor_ != nullptr) ? current_descriptor_->required_capabilities : 0U;
status_.missing_cap_mask = (current_descriptor_ != nullptr) ? evaluateMissingCapabilities(*current_descriptor_) : 0U;
core::copyText(status_.mode, sizeof(status_.mode), mode);
core::copyText(status_.source, sizeof(status_.source), source);
status_.required_cap_mask = (current_descriptor_ != nullptr)
? current_descriptor_->required_capabilities : 0U;
status_.missing_cap_mask = (current_descriptor_ != nullptr)
? evaluateMissingCapabilities(*current_descriptor_) : 0U;
updateRuntimePerfCounters(&status_, 0U);
return true;
}
void AppRuntimeManager::tick(uint32_t now_ms) {
if (!module_) {
return;
}
char mode[sizeof(status_.mode)] = {0};
char source[sizeof(status_.source)] = {0};
copyText(mode, sizeof(mode), status_.mode);
copyText(source, sizeof(source), status_.source);
const uint32_t start_us = micros();
if (!module_) return;
char mode[sizeof(status_.mode)] = {};
char source[sizeof(status_.source)] = {};
core::copyText(mode, sizeof(mode), status_.mode);
core::copyText(source, sizeof(source), status_.source);
const uint32_t start_us = micros();
module_->tick(now_ms);
const uint32_t elapsed_us = micros() - start_us;
status_ = module_->status();
copyText(status_.mode, sizeof(status_.mode), mode);
copyText(status_.source, sizeof(status_.source), source);
status_.required_cap_mask = (current_descriptor_ != nullptr) ? current_descriptor_->required_capabilities : 0U;
status_.missing_cap_mask = (current_descriptor_ != nullptr) ? evaluateMissingCapabilities(*current_descriptor_) : 0U;
core::copyText(status_.mode, sizeof(status_.mode), mode);
core::copyText(status_.source, sizeof(status_.source), source);
status_.required_cap_mask = (current_descriptor_ != nullptr)
? current_descriptor_->required_capabilities : 0U;
status_.missing_cap_mask = (current_descriptor_ != nullptr)
? evaluateMissingCapabilities(*current_descriptor_) : 0U;
updateRuntimePerfCounters(&status_, elapsed_us);
}
@@ -402,87 +390,48 @@ const AppDescriptor* AppRuntimeManager::currentDescriptor() const {
uint32_t AppRuntimeManager::evaluateMissingCapabilities(const AppDescriptor& descriptor) const {
uint32_t missing = 0U;
const uint32_t req = descriptor.required_capabilities;
runtime::resource::ResourceCoordinator* resource = context_.resource;
runtime::resource::ResourceCoordinator* res = context_.resource;
if (appCapabilityMaskHas(req, CAP_AUDIO_OUT) && context_.audio == nullptr) {
missing |= CAP_AUDIO_OUT;
}
if (appCapabilityMaskHas(req, CAP_AUDIO_OUT) && resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kAudioOut)) {
missing |= CAP_AUDIO_OUT;
auto lacking = [&](runtime::resource::ResourceCapability cap) -> bool {
return res != nullptr && !res->allowsCapability(cap);
};
if (appCapabilityMaskHas(req, CAP_AUDIO_OUT)) {
if (context_.audio == nullptr || lacking(runtime::resource::ResourceCapability::kAudioOut))
missing |= CAP_AUDIO_OUT;
}
if (appCapabilityMaskHas(req, CAP_AUDIO_IN)) {
const bool has_audio_in = (context_.media != nullptr) ||
(context_.hardware != nullptr && context_.hardware->snapshotRef().mic_ready);
if (!has_audio_in) {
const bool has = (context_.media != nullptr) ||
(context_.hardware != nullptr && context_.hardware->snapshotRef().mic_ready);
if (!has || lacking(runtime::resource::ResourceCapability::kAudioIn))
missing |= CAP_AUDIO_IN;
}
if (resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kAudioIn)) {
missing |= CAP_AUDIO_IN;
}
}
if (appCapabilityMaskHas(req, CAP_CAMERA)) {
const bool has_camera = (context_.camera != nullptr && context_.camera->snapshot().supported);
const bool allowed_by_profile =
(resource == nullptr) || resource->allowsCapability(runtime::resource::ResourceCapability::kCamera);
if (!has_camera || !allowed_by_profile) {
const bool has = (context_.camera != nullptr && context_.camera->snapshot().supported);
if (!has || lacking(runtime::resource::ResourceCapability::kCamera))
missing |= CAP_CAMERA;
}
}
if (appCapabilityMaskHas(req, CAP_LED)) {
const bool has_led = (context_.hardware != nullptr && context_.hardware->snapshotRef().ws2812_ready);
if (!has_led) {
const bool has = (context_.hardware != nullptr && context_.hardware->snapshotRef().ws2812_ready);
if (!has || lacking(runtime::resource::ResourceCapability::kLed))
missing |= CAP_LED;
}
if (resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kLed)) {
missing |= CAP_LED;
}
}
if (appCapabilityMaskHas(req, CAP_WIFI)) {
if (context_.network == nullptr) {
if (context_.network == nullptr || lacking(runtime::resource::ResourceCapability::kWifi))
missing |= CAP_WIFI;
}
if (resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kWifi)) {
missing |= CAP_WIFI;
}
}
if (appCapabilityMaskHas(req, CAP_STORAGE_SD) &&
(context_.storage == nullptr || !context_.storage->hasSdCard())) {
missing |= CAP_STORAGE_SD;
if (appCapabilityMaskHas(req, CAP_STORAGE_SD)) {
const bool has = (context_.storage != nullptr && context_.storage->hasSdCard());
if (!has || lacking(runtime::resource::ResourceCapability::kStorageSd))
missing |= CAP_STORAGE_SD;
}
if (appCapabilityMaskHas(req, CAP_STORAGE_SD) && resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kStorageSd)) {
missing |= CAP_STORAGE_SD;
if (appCapabilityMaskHas(req, CAP_STORAGE_FS)) {
if (context_.storage == nullptr || lacking(runtime::resource::ResourceCapability::kStorageFs))
missing |= CAP_STORAGE_FS;
}
if (appCapabilityMaskHas(req, CAP_STORAGE_FS) && context_.storage == nullptr) {
missing |= CAP_STORAGE_FS;
if (appCapabilityMaskHas(req, CAP_GPU_UI)) {
if (context_.ui == nullptr || lacking(runtime::resource::ResourceCapability::kGpuUi))
missing |= CAP_GPU_UI;
}
if (appCapabilityMaskHas(req, CAP_STORAGE_FS) && resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kStorageFs)) {
missing |= CAP_STORAGE_FS;
}
if (appCapabilityMaskHas(req, CAP_GPU_UI) && context_.ui == nullptr) {
missing |= CAP_GPU_UI;
}
if (appCapabilityMaskHas(req, CAP_GPU_UI) && resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kGpuUi)) {
missing |= CAP_GPU_UI;
}
return missing;
}
void AppRuntimeManager::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
+7 -11
View File
@@ -1,6 +1,7 @@
// app_timer.cpp - Timer Implementation
#include "app/app_timer.h"
#include "hardware_manager.h"
#include "audio_manager.h"
#include "core/str_utils.h"
AppTimer g_app_timer;
@@ -44,7 +45,7 @@ void AppTimer::onAction(const AppAction& action) {
Serial.printf("[APP_TIMER] Action: %s payload=%s\n", action.name, action.payload);
if (strcmp(action.name, "start") == 0) {
uint32_t seconds = atoi(action.payload);
uint32_t seconds = static_cast<uint32_t>(strtol(action.payload, nullptr, 10));
startCountdown(seconds);
} else if (strcmp(action.name, "pause") == 0) {
pause();
@@ -81,7 +82,7 @@ void AppTimer::reset() {
running_ = false;
remaining_ms_ = 0;
total_ms_ = 0;
strncpy(display_, "00:00", sizeof(display_) - 1);
core::copyText(display_, sizeof(display_), "00:00");
}
void AppTimer::updateDisplay() {
@@ -92,14 +93,9 @@ void AppTimer::updateDisplay() {
void AppTimer::onTimeout() {
Serial.println("[APP_TIMER] TIMEOUT - Playing alarm");
// Play beep alarm if buzzer available
if (context_.hardware) {
for (int i = 0; i < 5; i++) {
// TODO: Use buzzer/audio to play alarm sound
// context_.hardware->buzzer(1000, 100); // Frequency, duration
delay(100);
}
if (context_.audio) {
context_.audio->play("/sounds/alarm.wav");
}
updateDisplay();
@@ -21,6 +21,7 @@
#endif
#include <cstring>
#include "core/str_utils.h"
namespace {
@@ -72,13 +73,13 @@ bool ensureDir(const char* path) {
} // namespace
bool FileShareService::begin(const char* host_name, const char* instance_name) {
copyText(host_name_, sizeof(host_name_), host_name);
copyText(instance_name_, sizeof(instance_name_), instance_name);
core::copyText(host_name_, sizeof(host_name_), host_name);
core::copyText(instance_name_, sizeof(instance_name_), instance_name);
if (host_name_[0] == '\0') {
copyText(host_name_, sizeof(host_name_), "zacus-freenove");
core::copyText(host_name_, sizeof(host_name_), "zacus-freenove");
}
if (instance_name_[0] == '\0') {
copyText(instance_name_, sizeof(instance_name_), "zacus-device");
core::copyText(instance_name_, sizeof(instance_name_), "zacus-device");
}
ensureSharedDirs();
#if ZACUS_HAS_MDNS
@@ -128,9 +129,9 @@ uint8_t FileShareService::discoverPeers(PeerInfo* out_peers, uint8_t max_peers)
}
for (int i = 0; i < found && count < max_peers; ++i) {
PeerInfo& peer = out_peers[count];
copyText(peer.instance, sizeof(peer.instance), MDNS.hostname(i).c_str());
copyText(peer.host, sizeof(peer.host), MDNS.hostname(i).c_str());
copyText(peer.ip, sizeof(peer.ip), MDNS.IP(i).toString().c_str());
core::copyText(peer.instance, sizeof(peer.instance), MDNS.hostname(i).c_str());
core::copyText(peer.host, sizeof(peer.host), MDNS.hostname(i).c_str());
core::copyText(peer.ip, sizeof(peer.ip), MDNS.IP(i).toString().c_str());
peer.port = static_cast<uint16_t>(MDNS.port(i));
++count;
}
@@ -387,18 +388,6 @@ bool FileShareService::resolveIncomingPath(const char* requested_path, String* o
return true;
}
void FileShareService::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
bool FileShareService::ensureSharedDirs() const {
return ensureDir("/apps") && ensureDir(kShareRoot) && ensureDir(kIncomingRoot);
}
@@ -8,6 +8,7 @@
#include <cstdlib>
#include <cstring>
#include <vector>
#include "core/str_utils.h"
#include "audio_manager.h"
#include "camera_manager.h"
@@ -26,37 +27,28 @@
namespace app::modules {
namespace {
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
constexpr const char* kSharedBundledAudioTrack = "/apps/audio_player/audio/default.mp3";
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
const char* defaultBundledAudioForApp(const char* app_id) {
if (app_id == nullptr || app_id[0] == '\0') {
return kSharedBundledAudioTrack;
}
for (size_t i = 0U;; ++i) {
const char a = lhs[i];
const char b = rhs[i];
if (a == '\0' && b == '\0') {
return true;
}
if (a == '\0' || b == '\0') {
return false;
}
const char la = (a >= 'A' && a <= 'Z') ? static_cast<char>(a - 'A' + 'a') : a;
const char lb = (b >= 'A' && b <= 'Z') ? static_cast<char>(b - 'A' + 'a') : b;
if (la != lb) {
return false;
}
if (core::equalsIgnoreCase(app_id, "audio_player")) {
return "/apps/audio_player/audio/default.mp3";
}
if (core::equalsIgnoreCase(app_id, "audiobook_player")) {
return "/apps/audiobook_player/audio/default.mp3";
}
if (core::equalsIgnoreCase(app_id, "kids_music")) {
return "/apps/kids_music/audio/default.mp3";
}
if (core::equalsIgnoreCase(app_id, "kids_webradio")) {
return "/apps/kids_webradio/audio/default.mp3";
}
if (core::equalsIgnoreCase(app_id, "kids_podcast")) {
return "/apps/kids_podcast/audio/default.mp3";
}
return kSharedBundledAudioTrack;
}
bool ensureDir(const char* path) {
@@ -142,14 +134,14 @@ bool endsWithIgnoreCase(const char* text, const char* suffix) {
return false;
}
const char* tail = text + (text_len - suffix_len);
return equalsIgnoreCase(tail, suffix);
return core::equalsIgnoreCase(tail, suffix);
}
bool parseJsonPayload(const AppAction& action, DynamicJsonDocument* out) {
if (out == nullptr) {
return false;
}
const bool looks_json = (action.content_type[0] != '\0' && equalsIgnoreCase(action.content_type, "application/json")) ||
const bool looks_json = (action.content_type[0] != '\0' && core::equalsIgnoreCase(action.content_type, "application/json")) ||
action.payload[0] == '{' || action.payload[0] == '[';
if (!looks_json || action.payload[0] == '\0') {
return false;
@@ -164,11 +156,11 @@ class ModuleBase : public IAppModule {
context_ = context;
status_ = {};
if (context.descriptor != nullptr) {
copyText(status_.id, sizeof(status_.id), context.descriptor->id);
core::copyText(status_.id, sizeof(status_.id), context.descriptor->id);
}
status_.state = AppRuntimeState::kRunning;
status_.started_at_ms = millis();
copyText(status_.last_event, sizeof(status_.last_event), "begin");
core::copyText(status_.last_event, sizeof(status_.last_event), "begin");
return context.descriptor != nullptr;
}
@@ -178,12 +170,12 @@ class ModuleBase : public IAppModule {
}
void handleAction(const AppAction& action) override {
copyText(status_.last_event, sizeof(status_.last_event), action.name);
core::copyText(status_.last_event, sizeof(status_.last_event), action.name);
}
void end() override {
status_.state = AppRuntimeState::kIdle;
copyText(status_.last_event, sizeof(status_.last_event), "end");
core::copyText(status_.last_event, sizeof(status_.last_event), "end");
}
AppRuntimeStatus status() const override {
@@ -192,7 +184,7 @@ class ModuleBase : public IAppModule {
protected:
void setError(const char* error) {
copyText(status_.last_error, sizeof(status_.last_error), error);
core::copyText(status_.last_error, sizeof(status_.last_error), error);
}
AppContext context_ = {};
@@ -215,8 +207,10 @@ class AudioPlayerModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "play")) {
const char* path = (action.payload[0] != '\0') ? action.payload : "/music/boot_radio.mp3";
if (core::equalsIgnoreCase(action.name, "play")) {
const char* path = (action.payload[0] != '\0')
? action.payload
: defaultBundledAudioForApp((context_.descriptor != nullptr) ? context_.descriptor->id : nullptr);
if (context_.storage != nullptr && !context_.storage->fileExists(path)) {
setError("missing_asset");
return;
@@ -229,7 +223,7 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "play_url")) {
if (core::equalsIgnoreCase(action.name, "play_url")) {
if (action.payload[0] == '\0') {
setError("missing_asset");
return;
@@ -243,7 +237,7 @@ class AudioPlayerModule : public ModuleBase {
return;
}
}
if (!context_.audio->playUrl(action.payload)) {
if (!context_.audio->play(action.payload)) {
if (!playOfflineFallback()) {
setError("network_unavailable");
}
@@ -253,7 +247,7 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "pause")) {
if (core::equalsIgnoreCase(action.name, "pause")) {
if (!paused_url_.isEmpty()) {
context_.audio->stop();
} else {
@@ -262,10 +256,10 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "resume")) {
if (core::equalsIgnoreCase(action.name, "resume")) {
bool ok = false;
if (!paused_url_.isEmpty()) {
ok = context_.audio->playUrl(paused_url_.c_str());
ok = context_.audio->play(paused_url_.c_str());
} else if (!paused_track_.isEmpty()) {
ok = context_.media->play(paused_track_.c_str(), context_.audio);
}
@@ -274,17 +268,17 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "stop")) {
if (core::equalsIgnoreCase(action.name, "stop")) {
context_.media->stop(context_.audio);
return;
}
if (equalsIgnoreCase(action.name, "set_volume")) {
if (core::equalsIgnoreCase(action.name, "set_volume")) {
const uint32_t volume = parseUint(action.payload, context_.audio->volume());
context_.audio->setVolume(static_cast<uint8_t>(volume > 100U ? 100U : volume));
return;
}
if (equalsIgnoreCase(action.name, "next") || equalsIgnoreCase(action.name, "prev")) {
copyText(status_.last_event, sizeof(status_.last_event), "playlist_not_configured");
if (core::equalsIgnoreCase(action.name, "next") || core::equalsIgnoreCase(action.name, "prev")) {
core::copyText(status_.last_event, sizeof(status_.last_event), "playlist_not_configured");
return;
}
}
@@ -298,13 +292,13 @@ class AudioPlayerModule : public ModuleBase {
String candidates[3];
candidates[0] = String("/apps/") + app_id + "/audio/offline.mp3";
candidates[1] = String("/apps/") + app_id + "/audio/default.mp3";
candidates[2] = "/music/boot_radio.mp3";
candidates[2] = kSharedBundledAudioTrack;
for (const String& candidate : candidates) {
if (!context_.storage->fileExists(candidate.c_str())) {
continue;
}
if (context_.media->play(candidate.c_str(), context_.audio)) {
copyText(status_.last_event, sizeof(status_.last_event), "offline_fallback");
core::copyText(status_.last_event, sizeof(status_.last_event), "offline_fallback");
setError("");
return true;
}
@@ -333,12 +327,12 @@ class AudiobookModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "open_book")) {
if (core::equalsIgnoreCase(action.name, "open_book")) {
current_book_ = action.payload;
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "play")) {
if (core::equalsIgnoreCase(action.name, "play")) {
const char* target = current_book_.isEmpty() ? action.payload : current_book_.c_str();
if (target == nullptr || target[0] == '\0') {
setError("missing_asset");
@@ -352,7 +346,7 @@ class AudiobookModule : public ModuleBase {
return;
}
}
if (!context_.audio->playUrl(target)) {
if (!context_.audio->play(target)) {
setError("network_unavailable");
}
return;
@@ -366,27 +360,27 @@ class AudiobookModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "pause")) {
if (core::equalsIgnoreCase(action.name, "pause")) {
context_.audio->stop();
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "stop")) {
if (core::equalsIgnoreCase(action.name, "stop")) {
context_.media->stop(context_.audio);
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "seek_ms")) {
if (core::equalsIgnoreCase(action.name, "seek_ms")) {
position_ms_ = parseUint(action.payload, position_ms_);
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "bookmark_set")) {
if (core::equalsIgnoreCase(action.name, "bookmark_set")) {
bookmark_ms_ = parseUint(action.payload, position_ms_);
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "bookmark_go")) {
if (core::equalsIgnoreCase(action.name, "bookmark_go")) {
position_ms_ = bookmark_ms_;
saveProgress();
return;
@@ -445,15 +439,23 @@ class CameraVideoModule : public ModuleBase {
status_.state = AppRuntimeState::kFailed;
return false;
}
if (!context.camera->startRecorderSession()) {
bool camera_ready = context.camera->startRecorderSession();
preview_on_ = camera_ready;
if (!camera_ready) {
camera_ready = context.camera->start();
preview_on_ = false;
}
if (!camera_ready) {
setError("camera_start_failed");
status_.state = AppRuntimeState::kFailed;
return false;
}
preview_on_ = true;
clip_active_ = false;
clip_frames_.clear();
setError("");
core::copyText(status_.last_event,
sizeof(status_.last_event),
preview_on_ ? "preview_ready" : "snapshot_ready");
updateStatusEvent();
return true;
}
@@ -479,11 +481,11 @@ class CameraVideoModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "preview_on")) {
if (core::equalsIgnoreCase(action.name, "preview_on")) {
if (!context_.camera->start()) {
setError("preview_on_failed");
} else {
@@ -493,26 +495,26 @@ class CameraVideoModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "preview_off")) {
if (core::equalsIgnoreCase(action.name, "preview_off")) {
context_.camera->stop();
preview_on_ = false;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "snapshot")) {
if (core::equalsIgnoreCase(action.name, "snapshot")) {
String out_path;
if (!context_.camera->snapshotToFile(nullptr, &out_path)) {
setError("snapshot_failed");
} else {
char event[40] = {0};
std::snprintf(event, sizeof(event), "snap=%u", static_cast<unsigned int>(out_path.length()));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
return;
}
if (equalsIgnoreCase(action.name, "clip_start")) {
if (core::equalsIgnoreCase(action.name, "clip_start")) {
clip_active_ = true;
clip_frames_.clear();
clip_started_ms_ = millis();
@@ -521,13 +523,13 @@ class CameraVideoModule : public ModuleBase {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "clip_stop")) {
if (core::equalsIgnoreCase(action.name, "clip_stop")) {
stopClipAndPersist();
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "list_media")) {
if (core::equalsIgnoreCase(action.name, "list_media")) {
static constexpr int kMaxListItems = 12;
String items[kMaxListItems];
const int count = context_.camera->recorderListPhotos(items, kMaxListItems, true);
@@ -536,17 +538,17 @@ class CameraVideoModule : public ModuleBase {
} else {
char event[40] = {0};
std::snprintf(event, sizeof(event), "list=%d", count);
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
return;
}
if (equalsIgnoreCase(action.name, "delete_media")) {
if (core::equalsIgnoreCase(action.name, "delete_media")) {
if (action.payload[0] == '\0' || !context_.camera->recorderRemoveFile(action.payload)) {
setError("delete_media_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
}
return;
}
@@ -598,7 +600,7 @@ class CameraVideoModule : public ModuleBase {
preview_on_ ? 1U : 0U,
clip_active_ ? 1U : 0U,
static_cast<unsigned int>(clip_frames_.size()));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
bool preview_on_ = false;
@@ -623,7 +625,7 @@ class QrScannerModule : public ModuleBase {
// Ensure CameraManager is not already holding the camera driver.
context.camera->stop();
scanning_ = false;
copyText(last_type_, sizeof(last_type_), "none");
core::copyText(last_type_, sizeof(last_type_), "none");
setError("");
updateStatusEvent();
return true;
@@ -631,23 +633,23 @@ class QrScannerModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "scan_start")) {
if (core::equalsIgnoreCase(action.name, "scan_start")) {
scanning_ = true;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "scan_stop")) {
if (core::equalsIgnoreCase(action.name, "scan_stop")) {
scanning_ = false;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "scan_once") || equalsIgnoreCase(action.name, "scan_payload")) {
if (core::equalsIgnoreCase(action.name, "scan_once") || core::equalsIgnoreCase(action.name, "scan_payload")) {
scanning_ = false;
classifyPayload(action.payload);
setError("");
@@ -664,18 +666,18 @@ class QrScannerModule : public ModuleBase {
private:
void classifyPayload(const char* payload) {
if (payload == nullptr || payload[0] == '\0') {
copyText(last_type_, sizeof(last_type_), "unknown");
core::copyText(last_type_, sizeof(last_type_), "unknown");
return;
}
if (std::strncmp(payload, "http://", 7U) == 0 || std::strncmp(payload, "https://", 8U) == 0) {
copyText(last_type_, sizeof(last_type_), "url");
core::copyText(last_type_, sizeof(last_type_), "url");
return;
}
if (std::strncmp(payload, "app:", 4U) == 0 || std::strncmp(payload, "zacus:", 6U) == 0) {
copyText(last_type_, sizeof(last_type_), "app");
core::copyText(last_type_, sizeof(last_type_), "app");
return;
}
copyText(last_type_, sizeof(last_type_), "text");
core::copyText(last_type_, sizeof(last_type_), "text");
}
void updateStatusEvent() {
@@ -685,7 +687,7 @@ class QrScannerModule : public ModuleBase {
"scan=%u type=%s",
scanning_ ? 1U : 0U,
last_type_);
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
bool scanning_ = false;
@@ -710,51 +712,51 @@ class DictaphoneModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "record_start")) {
if (core::equalsIgnoreCase(action.name, "record_start")) {
const uint16_t sec = static_cast<uint16_t>(parseUint(action.payload, 30U));
if (!context_.media->startRecording(sec, nullptr)) {
setError("record_start_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "recording=1");
core::copyText(status_.last_event, sizeof(status_.last_event), "recording=1");
}
return;
}
if (equalsIgnoreCase(action.name, "record_stop")) {
if (core::equalsIgnoreCase(action.name, "record_stop")) {
if (!context_.media->stopRecording()) {
setError("record_stop_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "recording=0");
core::copyText(status_.last_event, sizeof(status_.last_event), "recording=0");
}
return;
}
if (equalsIgnoreCase(action.name, "play_file")) {
if (core::equalsIgnoreCase(action.name, "play_file")) {
char path[120] = {0};
if (!normalizeRecordPath(action.payload, path, sizeof(path)) ||
!context_.media->play(path, context_.audio)) {
setError("play_file_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "play_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "play_ok");
}
return;
}
if (equalsIgnoreCase(action.name, "delete_file")) {
if (core::equalsIgnoreCase(action.name, "delete_file")) {
char path[120] = {0};
if (!normalizeRecordPath(action.payload, path, sizeof(path)) || !LittleFS.remove(path)) {
setError("delete_file_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
}
return;
}
if (equalsIgnoreCase(action.name, "list_records")) {
if (core::equalsIgnoreCase(action.name, "list_records")) {
String list_json;
if (!context_.media->listFiles("records", &list_json)) {
setError("list_records_failed");
@@ -766,7 +768,7 @@ class DictaphoneModule : public ModuleBase {
}
char event[40] = {0};
std::snprintf(event, sizeof(event), "records=%u", static_cast<unsigned int>(count));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
return;
@@ -783,7 +785,7 @@ class DictaphoneModule : public ModuleBase {
private:
void updateStatusEvent() {
copyText(status_.last_event, sizeof(status_.last_event), "records=ready");
core::copyText(status_.last_event, sizeof(status_.last_event), "records=ready");
}
bool normalizeRecordPath(const char* payload, char* out, size_t out_size) const {
@@ -791,12 +793,12 @@ class DictaphoneModule : public ModuleBase {
return false;
}
if (payload[0] == '/') {
copyText(out, out_size, payload);
core::copyText(out, out_size, payload);
return true;
}
char path[120] = {0};
std::snprintf(path, sizeof(path), "/recorder/%s", payload);
copyText(out, out_size, path);
core::copyText(out, out_size, path);
return true;
}
};
@@ -819,7 +821,7 @@ class TimerToolsModule : public ModuleBase {
cd_done_notified_ = false;
countdown_visual_until_ms_ = 0U;
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "timer_ready");
core::copyText(status_.last_event, sizeof(status_.last_event), "timer_ready");
return true;
}
@@ -852,11 +854,11 @@ class TimerToolsModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
const uint32_t now_ms = millis();
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
writeStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "sw_start")) {
if (core::equalsIgnoreCase(action.name, "sw_start")) {
if (!sw_running_) {
sw_running_ = true;
sw_started_ms_ = now_ms;
@@ -864,7 +866,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "sw_stop")) {
if (core::equalsIgnoreCase(action.name, "sw_stop")) {
if (sw_running_) {
sw_acc_ms_ += (now_ms - sw_started_ms_);
sw_running_ = false;
@@ -872,12 +874,12 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "sw_lap")) {
if (core::equalsIgnoreCase(action.name, "sw_lap")) {
sw_lap_ms_ = sw_running_ ? (sw_acc_ms_ + (now_ms - sw_started_ms_)) : sw_acc_ms_;
setError("");
return;
}
if (equalsIgnoreCase(action.name, "sw_reset")) {
if (core::equalsIgnoreCase(action.name, "sw_reset")) {
sw_running_ = false;
sw_started_ms_ = 0U;
sw_acc_ms_ = 0U;
@@ -886,7 +888,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_set")) {
if (core::equalsIgnoreCase(action.name, "cd_set")) {
DynamicJsonDocument body(192);
uint32_t seconds = 0U;
if (parseJsonPayload(action, &body) && body["seconds"].is<uint32_t>()) {
@@ -901,7 +903,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_start")) {
if (core::equalsIgnoreCase(action.name, "cd_start")) {
if (cd_duration_ms_ == 0U) {
cd_duration_ms_ = 60000U;
}
@@ -911,7 +913,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_pause")) {
if (core::equalsIgnoreCase(action.name, "cd_pause")) {
if (cd_running_) {
const uint32_t elapsed = now_ms - cd_started_ms_;
cd_duration_ms_ = (elapsed >= cd_duration_ms_) ? 0U : (cd_duration_ms_ - elapsed);
@@ -921,7 +923,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_reset")) {
if (core::equalsIgnoreCase(action.name, "cd_reset")) {
cd_running_ = false;
cd_duration_ms_ = 0U;
cd_remaining_ms_ = 0U;
@@ -934,7 +936,7 @@ class TimerToolsModule : public ModuleBase {
private:
void notifyCountdownDone(uint32_t now_ms) {
copyText(status_.last_event, sizeof(status_.last_event), "countdown_done");
core::copyText(status_.last_event, sizeof(status_.last_event), "countdown_done");
setError("");
if (context_.storage != nullptr && context_.audio != nullptr && context_.media != nullptr) {
static constexpr const char* kDoneCandidates[] = {
@@ -964,7 +966,7 @@ class TimerToolsModule : public ModuleBase {
static_cast<unsigned long>(sw_elapsed_ms_),
static_cast<unsigned long>(cd_remaining_ms_),
cd_running_ ? 1U : 0U);
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
@@ -998,17 +1000,17 @@ class FlashlightModule : public ModuleBase {
is_on_ = false;
level_ = 96U;
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "light_ready");
core::copyText(status_.last_event, sizeof(status_.last_event), "light_ready");
return true;
}
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "light_on")) {
if (core::equalsIgnoreCase(action.name, "light_on")) {
if (!applyLight(true)) {
setError("light_on_failed");
} else {
@@ -1016,14 +1018,14 @@ class FlashlightModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "light_off")) {
if (core::equalsIgnoreCase(action.name, "light_off")) {
context_.hardware->clearManualLed();
is_on_ = false;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "light_toggle")) {
if (core::equalsIgnoreCase(action.name, "light_toggle")) {
if (!applyLight(!is_on_)) {
setError("light_toggle_failed");
} else {
@@ -1031,7 +1033,7 @@ class FlashlightModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "set_level")) {
if (core::equalsIgnoreCase(action.name, "set_level")) {
DynamicJsonDocument body(128);
uint32_t parsed = level_;
if (parseJsonPayload(action, &body) && body["level"].is<uint32_t>()) {
@@ -1087,7 +1089,7 @@ class FlashlightModule : public ModuleBase {
"on=%u level=%u",
is_on_ ? 1U : 0U,
static_cast<unsigned int>(level_));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
uint8_t level_ = 120U;
@@ -1206,17 +1208,17 @@ class CalculatorModule : public ModuleBase {
public:
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
writeStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "clear")) {
if (core::equalsIgnoreCase(action.name, "clear")) {
result_ = 0.0;
setError("");
writeStatusEvent();
return;
}
if (!equalsIgnoreCase(action.name, "eval")) {
if (!core::equalsIgnoreCase(action.name, "eval")) {
setError("unsupported_action");
return;
}
@@ -1231,7 +1233,7 @@ class CalculatorModule : public ModuleBase {
setError("eval_error");
char msg[40] = {0};
std::snprintf(msg, sizeof(msg), "eval_error@%d", error);
copyText(status_.last_event, sizeof(status_.last_event), msg);
core::copyText(status_.last_event, sizeof(status_.last_event), msg);
return;
}
result_ = value;
@@ -1252,7 +1254,7 @@ class CalculatorModule : public ModuleBase {
void writeStatusEvent() {
char msg[40] = {0};
std::snprintf(msg, sizeof(msg), "result=%.4f", result_);
copyText(status_.last_event, sizeof(status_.last_event), msg);
core::copyText(status_.last_event, sizeof(status_.last_event), msg);
}
double result_ = 0.0;
@@ -1282,14 +1284,14 @@ class KidsCreativeModule : public ModuleBase {
ModuleBase::handleAction(action);
DynamicJsonDocument body(384);
(void)parseJsonPayload(action, &body);
if (equalsIgnoreCase(action.name, "open_canvas")) {
if (core::equalsIgnoreCase(action.name, "open_canvas")) {
if (body["color"].is<const char*>()) {
current_color_ = body["color"].as<const char*>();
}
setError("");
return;
}
if (equalsIgnoreCase(action.name, "stroke")) {
if (core::equalsIgnoreCase(action.name, "stroke")) {
++stroke_count_;
if (body["color"].is<const char*>()) {
current_color_ = body["color"].as<const char*>();
@@ -1298,7 +1300,7 @@ class KidsCreativeModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "fill")) {
if (core::equalsIgnoreCase(action.name, "fill")) {
if (body["color"].is<const char*>()) {
current_color_ = body["color"].as<const char*>();
} else if (action.payload[0] != '\0') {
@@ -1306,18 +1308,18 @@ class KidsCreativeModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "undo")) {
if (core::equalsIgnoreCase(action.name, "undo")) {
if (stroke_count_ > 0U) {
--stroke_count_;
}
return;
}
if (equalsIgnoreCase(action.name, "clear") || equalsIgnoreCase(action.name, "clear_canvas")) {
if (core::equalsIgnoreCase(action.name, "clear") || core::equalsIgnoreCase(action.name, "clear_canvas")) {
stroke_count_ = 0U;
current_color_ = "#000000";
return;
}
if (equalsIgnoreCase(action.name, "save")) {
if (core::equalsIgnoreCase(action.name, "save")) {
String target = (action.payload[0] != '\0' && action.payload[0] != '{') ? String(action.payload) : String();
if (body["path"].is<const char*>()) {
target = body["path"].as<const char*>();
@@ -1330,7 +1332,7 @@ class KidsCreativeModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "load")) {
if (core::equalsIgnoreCase(action.name, "load")) {
String source = (action.payload[0] != '\0' && action.payload[0] != '{') ? String(action.payload) : String();
if (body["path"].is<const char*>()) {
source = body["path"].as<const char*>();
@@ -1401,7 +1403,7 @@ class KidsLearningModule : public ModuleBase {
ModuleBase::handleAction(action);
DynamicJsonDocument body(512);
(void)parseJsonPayload(action, &body);
if (equalsIgnoreCase(action.name, "lesson_open")) {
if (core::equalsIgnoreCase(action.name, "lesson_open")) {
if (body["lesson"].is<const char*>()) {
lesson_id_ = body["lesson"].as<const char*>();
} else if (action.payload[0] != '\0' && action.payload[0] != '{') {
@@ -1411,19 +1413,19 @@ class KidsLearningModule : public ModuleBase {
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "lesson_next")) {
if (core::equalsIgnoreCase(action.name, "lesson_next")) {
lesson_step_ += 1U;
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "lesson_prev")) {
if (core::equalsIgnoreCase(action.name, "lesson_prev")) {
if (lesson_step_ > 0U) {
lesson_step_ -= 1U;
}
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "quiz_answer")) {
if (core::equalsIgnoreCase(action.name, "quiz_answer")) {
String answer;
if (body["answer"].is<const char*>()) {
answer = body["answer"].as<const char*>();
@@ -1437,18 +1439,18 @@ class KidsLearningModule : public ModuleBase {
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "session_start") || equalsIgnoreCase(action.name, "play")) {
if (core::equalsIgnoreCase(action.name, "session_start") || core::equalsIgnoreCase(action.name, "play")) {
startGuidedAudio(action.payload, body);
return;
}
if (equalsIgnoreCase(action.name, "pause")) {
if (core::equalsIgnoreCase(action.name, "pause")) {
if (context_.audio != nullptr) {
context_.audio->stop();
}
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "session_stop") || equalsIgnoreCase(action.name, "stop")) {
if (core::equalsIgnoreCase(action.name, "session_stop") || core::equalsIgnoreCase(action.name, "stop")) {
if (context_.media != nullptr) {
context_.media->stop(context_.audio);
} else if (context_.audio != nullptr) {
@@ -1457,7 +1459,7 @@ class KidsLearningModule : public ModuleBase {
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "seek_ms")) {
if (core::equalsIgnoreCase(action.name, "seek_ms")) {
cursor_ms_ = parseUint(action.payload, cursor_ms_);
saveProgress();
return;
@@ -1483,7 +1485,7 @@ class KidsLearningModule : public ModuleBase {
} else {
target = String("/apps/") + app_id_ + "/audio/session.mp3";
if (!LittleFS.exists(target.c_str())) {
target = "/music/boot_radio.mp3";
target = defaultBundledAudioForApp(app_id_.c_str());
}
}
bool ok = false;
@@ -1495,7 +1497,7 @@ class KidsLearningModule : public ModuleBase {
}
}
if (startsWithIgnoreCase(target.c_str(), "http://") || startsWithIgnoreCase(target.c_str(), "https://")) {
ok = context_.audio->playUrl(target.c_str());
ok = context_.audio->play(target.c_str());
} else if (context_.storage != nullptr && context_.storage->fileExists(target.c_str()) && context_.media != nullptr) {
ok = context_.media->play(target.c_str(), context_.audio);
}
@@ -1596,13 +1598,13 @@ class NesEmulatorModule : public ModuleBase {
sizeof(event),
"fps=%lu",
static_cast<unsigned long>(target_fps_));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
}
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "list_roms")) {
if (core::equalsIgnoreCase(action.name, "list_roms")) {
uint32_t count = 0U;
File dir = LittleFS.open("/apps/nes_emulator/roms", "r");
if (dir && dir.isDirectory()) {
@@ -1619,7 +1621,7 @@ class NesEmulatorModule : public ModuleBase {
}
char event[40] = {0};
std::snprintf(event, sizeof(event), "roms=%lu", static_cast<unsigned long>(count));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
return;
}
@@ -1630,11 +1632,11 @@ class NesEmulatorModule : public ModuleBase {
rom_path = body["path"].as<const char*>();
}
if (equalsIgnoreCase(action.name, "rom_validate")) {
if (core::equalsIgnoreCase(action.name, "rom_validate")) {
validateRom(rom_path.c_str());
return;
}
if (equalsIgnoreCase(action.name, "rom_start")) {
if (core::equalsIgnoreCase(action.name, "rom_start")) {
if (!validateRom(rom_path.c_str())) {
return;
}
@@ -1652,48 +1654,48 @@ class NesEmulatorModule : public ModuleBase {
next_frame_ms_ = millis();
frame_count_ = 0U;
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "rom_start");
core::copyText(status_.last_event, sizeof(status_.last_event), "rom_start");
return;
}
if (equalsIgnoreCase(action.name, "rom_stop")) {
if (core::equalsIgnoreCase(action.name, "rom_stop")) {
emu_running_ = false;
rom_buffer_.clear();
rom_buffer_.shrink_to_fit();
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "rom_stop");
core::copyText(status_.last_event, sizeof(status_.last_event), "rom_stop");
return;
}
if (equalsIgnoreCase(action.name, "set_fps")) {
if (core::equalsIgnoreCase(action.name, "set_fps")) {
const uint32_t requested = parseUint(action.payload, target_fps_);
target_fps_ = (requested < 30U) ? 30U : (requested > 60U ? 60U : requested);
return;
}
if (equalsIgnoreCase(action.name, "input") ||
equalsIgnoreCase(action.name, "btn_down") ||
equalsIgnoreCase(action.name, "btn_up")) {
if (core::equalsIgnoreCase(action.name, "input") ||
core::equalsIgnoreCase(action.name, "btn_down") ||
core::equalsIgnoreCase(action.name, "btn_up")) {
uint16_t mask = input_mask_;
if (has_json && body["mask"].is<uint16_t>()) {
mask = body["mask"].as<uint16_t>();
} else {
mask = static_cast<uint16_t>(parseUint(action.payload, input_mask_));
}
if (equalsIgnoreCase(action.name, "btn_down")) {
if (core::equalsIgnoreCase(action.name, "btn_down")) {
input_mask_ |= mask;
} else if (equalsIgnoreCase(action.name, "btn_up")) {
} else if (core::equalsIgnoreCase(action.name, "btn_up")) {
input_mask_ &= static_cast<uint16_t>(~mask);
} else {
input_mask_ = mask;
}
return;
}
if (equalsIgnoreCase(action.name, "core_status")) {
if (core::equalsIgnoreCase(action.name, "core_status")) {
char event[56] = {0};
std::snprintf(event,
sizeof(event),
"run=%u frames=%lu",
emu_running_ ? 1U : 0U,
static_cast<unsigned long>(frame_count_));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
return;
}
}
@@ -1756,7 +1758,7 @@ class NesEmulatorModule : public ModuleBase {
return false;
}
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "rom_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "rom_ok");
return true;
}
@@ -1821,46 +1823,46 @@ class NesEmulatorModule : public ModuleBase {
} // namespace
std::unique_ptr<IAppModule> createAppModule(const AppDescriptor& descriptor) {
if (equalsIgnoreCase(descriptor.id, "audio_player") ||
equalsIgnoreCase(descriptor.id, "kids_webradio") ||
equalsIgnoreCase(descriptor.id, "kids_podcast") ||
equalsIgnoreCase(descriptor.id, "kids_music")) {
if (core::equalsIgnoreCase(descriptor.id, "audio_player") ||
core::equalsIgnoreCase(descriptor.id, "kids_webradio") ||
core::equalsIgnoreCase(descriptor.id, "kids_podcast") ||
core::equalsIgnoreCase(descriptor.id, "kids_music")) {
return std::unique_ptr<IAppModule>(new AudioPlayerModule());
}
if (equalsIgnoreCase(descriptor.id, "audiobook_player")) {
if (core::equalsIgnoreCase(descriptor.id, "audiobook_player")) {
return std::unique_ptr<IAppModule>(new AudiobookModule());
}
if (equalsIgnoreCase(descriptor.id, "camera_video")) {
if (core::equalsIgnoreCase(descriptor.id, "camera_video")) {
return std::unique_ptr<IAppModule>(new CameraVideoModule());
}
if (equalsIgnoreCase(descriptor.id, "qr_scanner")) {
if (core::equalsIgnoreCase(descriptor.id, "qr_scanner")) {
return std::unique_ptr<IAppModule>(new QrScannerModule());
}
if (equalsIgnoreCase(descriptor.id, "dictaphone")) {
if (core::equalsIgnoreCase(descriptor.id, "dictaphone")) {
return std::unique_ptr<IAppModule>(new DictaphoneModule());
}
if (equalsIgnoreCase(descriptor.id, "timer_tools")) {
if (core::equalsIgnoreCase(descriptor.id, "timer_tools")) {
return std::unique_ptr<IAppModule>(new TimerToolsModule());
}
if (equalsIgnoreCase(descriptor.id, "flashlight")) {
if (core::equalsIgnoreCase(descriptor.id, "flashlight")) {
return std::unique_ptr<IAppModule>(new FlashlightModule());
}
if (equalsIgnoreCase(descriptor.id, "calculator")) {
if (core::equalsIgnoreCase(descriptor.id, "calculator")) {
return std::unique_ptr<IAppModule>(new CalculatorModule());
}
if (equalsIgnoreCase(descriptor.id, "kids_drawing") ||
equalsIgnoreCase(descriptor.id, "kids_coloring")) {
if (core::equalsIgnoreCase(descriptor.id, "kids_drawing") ||
core::equalsIgnoreCase(descriptor.id, "kids_coloring")) {
return std::unique_ptr<IAppModule>(new KidsCreativeModule());
}
if (equalsIgnoreCase(descriptor.id, "kids_yoga") ||
equalsIgnoreCase(descriptor.id, "kids_meditation") ||
equalsIgnoreCase(descriptor.id, "kids_languages") ||
equalsIgnoreCase(descriptor.id, "kids_math") ||
equalsIgnoreCase(descriptor.id, "kids_science") ||
equalsIgnoreCase(descriptor.id, "kids_geography")) {
if (core::equalsIgnoreCase(descriptor.id, "kids_yoga") ||
core::equalsIgnoreCase(descriptor.id, "kids_meditation") ||
core::equalsIgnoreCase(descriptor.id, "kids_languages") ||
core::equalsIgnoreCase(descriptor.id, "kids_math") ||
core::equalsIgnoreCase(descriptor.id, "kids_science") ||
core::equalsIgnoreCase(descriptor.id, "kids_geography")) {
return std::unique_ptr<IAppModule>(new KidsLearningModule());
}
if (equalsIgnoreCase(descriptor.id, "nes_emulator")) {
if (core::equalsIgnoreCase(descriptor.id, "nes_emulator")) {
return std::unique_ptr<IAppModule>(new NesEmulatorModule());
}
return nullptr;
@@ -0,0 +1,66 @@
// audio_player_module.cpp - Audio player module bridging to AmigaAudioPlayer.
#include "app/modules/audio_player_module.h"
#include <cstring>
#include "app/app_registry.h"
#include "runtime/runtime_services.h"
bool AudioPlayerModule::onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) {
(void)entry;
(void)mode;
if (services == nullptr || services->audio == nullptr) {
std::strncpy(last_error_, "no_audio_service", sizeof(last_error_) - 1U);
state_ = AppModuleState::kError;
return false;
}
state_ = AppModuleState::kRunning;
last_error_[0] = '\0';
Serial.println("[APP_MOD] audio_player open");
return true;
}
void AudioPlayerModule::onClose(const char* reason, RuntimeServices* services) {
(void)reason;
if (services != nullptr && services->audio != nullptr) {
services->audio->stop();
}
state_ = AppModuleState::kIdle;
Serial.printf("[APP_MOD] audio_player close reason=%s\n", reason != nullptr ? reason : "n/a");
}
void AudioPlayerModule::onTick(uint32_t now_ms, RuntimeServices* services) {
(void)now_ms;
(void)services;
// AmigaAudioPlayer is ticked separately from main.cpp for now.
}
bool AudioPlayerModule::onAction(const char* action, const char* payload, RuntimeServices* services) {
if (action == nullptr || services == nullptr || services->audio == nullptr) {
return false;
}
if (std::strcmp(action, "play") == 0) {
return services->audio->play(payload);
}
if (std::strcmp(action, "stop") == 0) {
services->audio->stop();
return true;
}
if (std::strcmp(action, "pause") == 0) {
services->audio->stop();
return true;
}
if (std::strcmp(action, "set_volume") == 0 && payload != nullptr) {
const int vol = atoi(payload);
if (vol >= 0 && vol <= 100) {
// Map 0-100 to 0-21.
const uint8_t mapped = static_cast<uint8_t>((vol * 21U) / 100U);
services->audio->setVolume(mapped);
return true;
}
}
Serial.printf("[APP_MOD] audio_player unknown action=%s\n", action);
return false;
}
@@ -1,12 +0,0 @@
#include "app/runtime_web_service.h"
void RuntimeWebService::configure(SetupWebUiFn setup_web_ui) {
setup_web_ui_ = setup_web_ui;
}
void RuntimeWebService::setupWebUi() const {
if (setup_web_ui_ == nullptr) {
return;
}
setup_web_ui_();
}
@@ -22,11 +22,19 @@
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#if __has_include(<esp_heap_caps.h>)
#include <esp_heap_caps.h>
#define ZACUS_HAS_AUDIO_HEAP_CAPS 1
#else
#define ZACUS_HAS_AUDIO_HEAP_CAPS 0
#endif
#else
#define ZACUS_HAS_AUDIO_HEAP_CAPS 0
#endif
namespace {
constexpr char kDiagnosticTrackPath[] = "/music/boot_radio.mp3";
constexpr char kDiagnosticTrackPath[] = "/apps/audio_player/audio/default.mp3";
constexpr uint8_t kMaxTrackPathLen = 120U;
constexpr uint16_t kBitrateScanBytes = 4096U;
constexpr uint8_t kAudioDoneQueueDepth = 6U;
@@ -39,6 +47,8 @@ constexpr uint16_t kAudioPumpIdleDelayMs = 4U;
constexpr uint16_t kAudioStateLockTimeoutMs = 20U;
constexpr uint32_t kAudioUnderrunThresholdBytes = 768U;
constexpr uint32_t kAudioUnderrunCooldownMs = 250U;
constexpr size_t kAudioMinLargestDmaBlockBytes = 20000U;
constexpr uint32_t kAudioRestoreRetryDelayMs = 2500U;
struct AudioPinProfile {
int bck;
@@ -112,6 +122,28 @@ bool resolveFileSystem(bool use_sd, fs::FS*& out_fs, const char*& out_label) {
#endif
}
bool hasAudioDmaBudget(size_t* out_largest_dma, size_t* out_free_dma) {
#if ZACUS_HAS_AUDIO_HEAP_CAPS
const size_t largest_dma = heap_caps_get_largest_free_block(MALLOC_CAP_DMA);
const size_t free_dma = heap_caps_get_free_size(MALLOC_CAP_DMA);
if (out_largest_dma != nullptr) {
*out_largest_dma = largest_dma;
}
if (out_free_dma != nullptr) {
*out_free_dma = free_dma;
}
return largest_dma >= kAudioMinLargestDmaBlockBytes;
#else
if (out_largest_dma != nullptr) {
*out_largest_dma = 0U;
}
if (out_free_dma != nullptr) {
*out_free_dma = 0U;
}
return true;
#endif
}
uint16_t parseMp3BitrateHeader(const uint8_t* header, size_t header_len) {
if (header == nullptr || header_len < 4U) {
return 0U;
@@ -416,6 +448,15 @@ bool AudioManager::ensurePlayer() {
Serial.println("[AUDIO] player disabled: PSRAM not detected");
return false;
}
size_t largest_dma = 0U;
size_t free_dma = 0U;
if (!hasAudioDmaBudget(&largest_dma, &free_dma)) {
Serial.printf("[AUDIO] player deferred: largest_dma=%lu free_dma=%lu need>=%lu\n",
static_cast<unsigned long>(largest_dma),
static_cast<unsigned long>(free_dma),
static_cast<unsigned long>(kAudioMinLargestDmaBlockBytes));
return false;
}
player_ = std::unique_ptr<Audio>(new Audio());
if (!player_) {
Serial.println("[AUDIO] alloc failed for ESP32-audioI2S player");
@@ -442,6 +483,8 @@ bool AudioManager::begin() {
return false;
}
begun_ = true;
restore_pending_ = false;
restore_retry_not_before_ms_ = 0U;
g_audio_manager_instance = this;
pump_task_enabled_ = startAudioPump();
Serial.printf("[AUDIO] backend=ESP32-audioI2S profile=%u:%s fx=%u:%s vol=%u\n",
@@ -664,6 +707,14 @@ bool AudioManager::requestPlay(const char* filename, bool diagnostic_tone) {
}
last_stream_url_.remove(0);
if (!begun_) {
(void)restoreOutputResources();
if (!begun_) {
Serial.println("[AUDIO] play request denied: backend unavailable");
return false;
}
}
AudioCodec codec = AudioCodec::kUnknown;
uint16_t bitrate_kbps = 0U;
detectTrackCodecAndBitrate(normalized_path, use_sd, codec, bitrate_kbps);
@@ -712,6 +763,13 @@ bool AudioManager::requestPlayUrl(const char* url) {
if (url == nullptr || url[0] == '\0') {
return false;
}
if (!begun_) {
(void)restoreOutputResources();
if (!begun_) {
Serial.println("[AUDIO] stream request denied: backend unavailable");
return false;
}
}
if (!takeStateLock(kAudioStateLockTimeoutMs)) {
return false;
}
@@ -788,6 +846,60 @@ void AudioManager::stop() {
releaseStateLock();
}
void AudioManager::releaseOutputResources() {
stopAudioPump();
if (!takeStateLock(kAudioStateLockTimeoutMs)) {
Serial.println("[AUDIO] release lock timeout");
return;
}
pending_start_ = false;
pending_track_.remove(0);
pending_diagnostic_tone_ = false;
last_stream_url_.remove(0);
if (player_ != nullptr) {
player_->stopSong();
}
clearTrackState();
player_.reset();
begun_ = false;
#if defined(ARDUINO_ARCH_ESP32)
if (rtos_state_ != nullptr && rtos_state_->done_queue != nullptr) {
xQueueReset(rtos_state_->done_queue);
}
#endif
restore_pending_ = false;
restore_retry_not_before_ms_ = 0U;
releaseStateLock();
Serial.println("[AUDIO] backend released");
}
bool AudioManager::restoreOutputResources() {
if (begun_) {
restore_pending_ = false;
restore_retry_not_before_ms_ = 0U;
return true;
}
size_t largest_dma = 0U;
size_t free_dma = 0U;
if (!hasAudioDmaBudget(&largest_dma, &free_dma)) {
restore_pending_ = true;
restore_retry_not_before_ms_ = millis() + kAudioRestoreRetryDelayMs;
Serial.printf("[AUDIO] backend restore deferred largest_dma=%lu free_dma=%lu need>=%lu\n",
static_cast<unsigned long>(largest_dma),
static_cast<unsigned long>(free_dma),
static_cast<unsigned long>(kAudioMinLargestDmaBlockBytes));
return false;
}
const bool ok = begin();
if (!ok) {
restore_pending_ = true;
restore_retry_not_before_ms_ = millis() + kAudioRestoreRetryDelayMs;
Serial.println("[AUDIO] backend restore failed");
return false;
}
return true;
}
void AudioManager::finishPlaybackAndNotify() {
char finished_track[kAudioDoneTrackLen] = {0};
if (!takeStateLock(kAudioStateLockTimeoutMs)) {
@@ -836,6 +948,12 @@ void AudioManager::processPendingPlaybackEvents() {
void AudioManager::update() {
if (!begun_) {
if (restore_pending_) {
const uint32_t now_ms = millis();
if (now_ms >= restore_retry_not_before_ms_) {
(void)restoreOutputResources();
}
}
return;
}
bool finished_without_pump = false;
-476
View File
@@ -1,476 +0,0 @@
// audio_manager.cpp - audio playback over I2S.
#include "audio_manager.h"
#include <AudioFileSource.h>
#include <AudioFileSourceFS.h>
#include <AudioFileSourcePROGMEM.h>
#include <AudioGenerator.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorRTTTL.h>
#include <AudioGeneratorWAV.h>
#include <AudioOutputI2S.h>
#include <AudioOutputMixer.h>
#include <FS.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
#include <memory>
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<SD_MMC.h>)
#include <SD_MMC.h>
#define ZACUS_HAS_SD_AUDIO 1
#else
#define ZACUS_HAS_SD_AUDIO 0
#endif
#include "ui_freenove_config.h"
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);
}
bool endsWithIgnoreCase(const char* value, const char* suffix) {
if (value == nullptr || suffix == nullptr) {
return false;
}
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 mixer_;
mixer_ = nullptr;
delete output_;
output_ = nullptr;
}
bool AudioManager::begin() {
return ensureOutput();
}
bool AudioManager::playOnChannel(uint8_t channel_index,
AudioFileSource* source,
AudioGenerator* decoder,
const char* track) {
if (channel_index >= 2U || source == nullptr || decoder == nullptr || track == nullptr || track[0] == '\0') {
delete decoder;
delete source;
return false;
}
if (!ensureOutput() || channels_[channel_index].stub == nullptr) {
delete decoder;
delete source;
return false;
}
stopChannel(channel_index);
channels_[channel_index].source = source;
channels_[channel_index].decoder = decoder;
channels_[channel_index].track = track;
channels_[channel_index].stub->SetGain(0.0f);
if (!channels_[channel_index].decoder->begin(channels_[channel_index].source, channels_[channel_index].stub)) {
Serial.printf("[AUDIO] decoder begin failed: %s\n", track);
stopChannel(channel_index);
return false;
}
return true;
}
uint8_t AudioManager::selectTargetChannel() const {
if (!anyChannelRunning()) {
return 0U;
}
return (active_channel_ == 0U) ? 1U : 0U;
}
void AudioManager::startCrossfade(uint8_t from_channel, uint8_t to_channel) {
crossfade_active_ = true;
crossfade_from_ = from_channel;
crossfade_to_ = to_channel;
crossfade_started_ms_ = millis();
active_channel_ = to_channel;
applyChannelGains();
}
void AudioManager::stopCrossfade() {
crossfade_active_ = false;
}
void AudioManager::applyChannelGains() {
if (channels_[0].stub == nullptr || channels_[1].stub == nullptr) {
return;
}
const float base_gain = volumeToGain(volume_);
if (crossfade_active_) {
const uint32_t elapsed_ms = millis() - crossfade_started_ms_;
uint16_t progress_per_mille = 1000U;
if (crossfade_duration_ms_ > 0U && elapsed_ms < crossfade_duration_ms_) {
progress_per_mille = static_cast<uint16_t>((elapsed_ms * 1000U) / crossfade_duration_ms_);
}
if (progress_per_mille > 1000U) {
progress_per_mille = 1000U;
}
const float to_gain = base_gain * (static_cast<float>(progress_per_mille) / 1000.0f);
const float from_gain = base_gain - to_gain;
channels_[crossfade_from_].stub->SetGain(from_gain);
channels_[crossfade_to_].stub->SetGain(to_gain);
if (progress_per_mille >= 1000U) {
stopChannel(crossfade_from_);
stopCrossfade();
}
return;
}
for (uint8_t index = 0U; index < 2U; ++index) {
if (channels_[index].decoder == nullptr) {
channels_[index].stub->SetGain(0.0f);
continue;
}
channels_[index].stub->SetGain((index == active_channel_) ? base_gain : 0.0f);
}
}
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;
bool use_sd_mmc = false;
if (fixed_path.startsWith("/littlefs/")) {
fixed_path = fixed_path.substring(9);
if (!fixed_path.startsWith("/")) {
fixed_path = "/" + fixed_path;
}
}
if (fixed_path.startsWith("/sd/")) {
fixed_path = fixed_path.substring(3);
if (!fixed_path.startsWith("/")) {
fixed_path = "/" + fixed_path;
}
use_sd_mmc = true;
}
fs::FS* file_system = &LittleFS;
const char* storage_label = "LittleFS";
if (use_sd_mmc) {
#if ZACUS_HAS_SD_AUDIO
if (!SD_MMC.begin("/sdcard", true)) {
Serial.println("[AUDIO] SD_MMC unavailable for audio path");
return false;
}
file_system = &SD_MMC;
storage_label = "SD_MMC";
#else
Serial.println("[AUDIO] SD audio path requested but SD_MMC support is unavailable");
return false;
#endif
}
if (file_system == nullptr || !file_system->exists(fixed_path.c_str())) {
Serial.printf("[AUDIO] missing file: %s (%s)\n", fixed_path.c_str(), storage_label);
return false;
}
File metadata = file_system->open(fixed_path.c_str(), "r");
if (!metadata) {
Serial.printf("[AUDIO] failed to open file metadata: %s (%s)\n", fixed_path.c_str(), storage_label);
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;
}
const bool had_running = anyChannelRunning();
const uint8_t previous_active = active_channel_;
const uint8_t target_channel = selectTargetChannel();
const bool is_wav = endsWithIgnoreCase(fixed_path.c_str(), ".wav");
// SECURITY FIX: Use std::make_unique to prevent memory leak if 2nd allocation fails
auto source = std::make_unique<AudioFileSourceFS>(*file_system, fixed_path.c_str());
auto decoder = is_wav ? std::make_unique<AudioGeneratorWAV>()
: std::make_unique<AudioGeneratorMP3>();
if (!playOnChannel(target_channel, source.get(), decoder.get(), fixed_path.c_str())) {
return false;
}
using_diagnostic_tone_ = false;
current_track_ = fixed_path.c_str();
playing_ = true;
if (had_running && channels_[previous_active].decoder != nullptr && previous_active != target_channel) {
startCrossfade(previous_active, target_channel);
} else {
active_channel_ = target_channel;
stopCrossfade();
applyChannelGains();
}
Serial.printf("[AUDIO] playing (%s): %s (%s)\n", is_wav ? "wav" : "mp3", fixed_path.c_str(), storage_label);
return true;
}
bool AudioManager::playDiagnosticTone() {
if (!ensureOutput()) {
return false;
}
const bool had_running = anyChannelRunning();
const uint8_t previous_active = active_channel_;
const uint8_t target_channel = selectTargetChannel();
// SECURITY FIX: Use std::make_unique to prevent memory leak if 2nd allocation fails
auto source = std::make_unique<AudioFileSourcePROGMEM>(kDiagnosticRtttl, std::strlen(kDiagnosticRtttl));
auto decoder = std::make_unique<AudioGeneratorRTTTL>();
if (!playOnChannel(target_channel, source.get(), decoder.get(), "builtin:rtttl")) {
Serial.println("[AUDIO] diagnostic tone begin failed");
return false;
}
using_diagnostic_tone_ = true;
current_track_ = "builtin:rtttl";
playing_ = true;
if (had_running && channels_[previous_active].decoder != nullptr && previous_active != target_channel) {
startCrossfade(previous_active, target_channel);
} else {
active_channel_ = target_channel;
stopCrossfade();
applyChannelGains();
}
Serial.printf("[AUDIO] playing diagnostic tone (profile=%u:%s)\n",
output_profile_,
outputProfileLabel(output_profile_));
return true;
}
void AudioManager::stopChannel(uint8_t channel_index) {
if (channel_index >= 2U) {
return;
}
AudioChannel& channel = channels_[channel_index];
if (channel.decoder != nullptr) {
channel.decoder->stop();
delete channel.decoder;
channel.decoder = nullptr;
}
if (channel.source != nullptr) {
channel.source->close();
delete channel.source;
channel.source = nullptr;
}
channel.track.remove(0);
if (channel.stub != nullptr) {
channel.stub->SetGain(0.0f);
}
}
void AudioManager::stopAllChannels() {
stopChannel(0U);
stopChannel(1U);
}
void AudioManager::stop() {
stopCrossfade();
stopAllChannels();
playing_ = false;
using_diagnostic_tone_ = false;
current_track_.remove(0);
}
bool AudioManager::anyChannelRunning() const {
return channels_[0].decoder != nullptr || channels_[1].decoder != nullptr;
}
void AudioManager::update() {
if (!playing_) {
return;
}
applyChannelGains();
String finished_track;
bool active_finished = false;
for (uint8_t index = 0U; index < 2U; ++index) {
AudioChannel& channel = channels_[index];
if (channel.decoder == nullptr) {
continue;
}
if (!channel.decoder->isRunning() || !channel.decoder->loop()) {
const bool was_active = (index == active_channel_);
const String ended_track = channel.track;
stopChannel(index);
if (was_active) {
active_finished = true;
finished_track = ended_track;
}
}
}
if (!crossfade_active_) {
applyChannelGains();
}
playing_ = anyChannelRunning();
if (playing_ && channels_[active_channel_].decoder != nullptr) {
current_track_ = channels_[active_channel_].track;
}
if (active_finished) {
if (!playing_) {
current_track_.remove(0);
}
finishPlaybackAndNotify(finished_track.c_str());
}
}
bool AudioManager::isPlaying() const {
return playing_;
}
void AudioManager::setVolume(uint8_t volume) {
if (volume > kVolumeMax) {
volume = kVolumeMax;
}
volume_ = volume;
applyChannelGains();
}
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;
}
if (mixer_ == nullptr) {
mixer_ = new AudioOutputMixer(32, output_);
}
if (mixer_ == nullptr) {
Serial.println("[AUDIO] failed to allocate mixer");
return false;
}
if (channels_[0].stub == nullptr) {
channels_[0].stub = mixer_->NewInput();
}
if (channels_[1].stub == nullptr) {
channels_[1].stub = mixer_->NewInput();
}
if (channels_[0].stub == nullptr || channels_[1].stub == nullptr) {
Serial.println("[AUDIO] failed to allocate mixer stubs");
return false;
}
output_->SetOutputModeMono(true);
output_->SetGain(1.0f);
applyOutputProfile();
applyChannelGains();
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::finishPlaybackAndNotify(const char* track) {
if (done_cb_ != nullptr) {
const char* reported_track = (track != nullptr && track[0] != '\0') ? track : current_track_.c_str();
done_cb_(reported_track, done_ctx_);
}
}
+7 -10
View File
@@ -6,6 +6,8 @@
#include <cstdio>
#include <esp_random.h>
#include "core/str_utils.h"
namespace AuthService {
namespace {
@@ -204,8 +206,7 @@ AuthStatus validateBearerToken(const char* auth_header) {
// Copy to buffer and trim whitespace
char token_copy[kTokenBufferSize];
std::strncpy(token_copy, provided_token, sizeof(token_copy) - 1);
token_copy[sizeof(token_copy) - 1] = '\0';
core::copyText(token_copy, sizeof(token_copy), provided_token);
// Trim trailing whitespace (newlines, spaces, etc)
size_t len = std::strlen(token_copy);
@@ -230,8 +231,7 @@ bool getCurrentToken(char* out_token_buffer, size_t buffer_size) {
if (!out_token_buffer || buffer_size < kTokenBufferSize || !g_initialized) {
return false;
}
std::strncpy(out_token_buffer, g_current_token, buffer_size - 1);
out_token_buffer[buffer_size - 1] = '\0';
core::copyText(out_token_buffer, buffer_size, g_current_token);
return true;
}
@@ -251,12 +251,10 @@ bool rotateToken(char* out_new_token_buffer, size_t buffer_size) {
}
// Update global state
std::strncpy(g_current_token, new_token, sizeof(g_current_token) - 1);
g_current_token[sizeof(g_current_token) - 1] = '\0';
core::copyText(g_current_token, sizeof(g_current_token), new_token);
// Return new token to caller
std::strncpy(out_new_token_buffer, new_token, buffer_size - 1);
out_new_token_buffer[buffer_size - 1] = '\0';
core::copyText(out_new_token_buffer, buffer_size, new_token);
Serial.printf("%s rotated - new token=%s\n", kLogTag, new_token);
return true;
@@ -280,8 +278,7 @@ bool reset() {
}
// Update global
std::strncpy(g_current_token, new_token, sizeof(g_current_token) - 1);
g_current_token[sizeof(g_current_token) - 1] = '\0';
core::copyText(g_current_token, sizeof(g_current_token), new_token);
Serial.printf("%s reset - new token=%s\n", kLogTag, new_token);
return true;
-209
View File
@@ -1,209 +0,0 @@
// button_manager.cpp - button scan + long press detection.
#include "button_manager.h"
#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::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 < 0) {
return 0U;
}
const int no_button_floor_mv = kNoAnalogButtonMv - threshold_range_mv_;
if (millivolts >= no_button_floor_mv) {
return 0U;
}
// Prefer midpoint buckets between nominal ladder voltages.
const int split_12 = (voltage_thresholds_[1] + voltage_thresholds_[2]) / 2;
const int split_23 = (voltage_thresholds_[2] + voltage_thresholds_[3]) / 2;
const int split_34 = (voltage_thresholds_[3] + voltage_thresholds_[4]) / 2;
const int split_45 = (voltage_thresholds_[4] + voltage_thresholds_[5]) / 2;
const int split_5n = (voltage_thresholds_[5] + no_button_floor_mv) / 2;
if (millivolts <= split_12) {
return 1U;
}
if (millivolts <= split_23) {
return 2U;
}
if (millivolts <= split_34) {
return 3U;
}
if (millivolts <= split_45) {
return 4U;
}
if (millivolts <= split_5n) {
return 5U;
}
// Fallback nearest-threshold match with wider tolerance for board variance.
int best_key = 1;
int best_delta = millivolts - voltage_thresholds_[1];
if (best_delta < 0) {
best_delta = -best_delta;
}
for (int index = 2; index <= 5; ++index) {
int delta = millivolts - voltage_thresholds_[index];
if (delta < 0) {
delta = -delta;
}
if (delta < best_delta) {
best_delta = delta;
best_key = index;
}
}
if (best_delta <= (threshold_range_mv_ * 7)) {
return static_cast<uint8_t>(best_key);
}
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;
}
@@ -9,6 +9,7 @@
#include <cstring>
#include "ui_freenove_config.h"
#include "core/str_utils.h"
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_camera.h>) && FREENOVE_CAM_ENABLE
#include <esp_camera.h>
@@ -17,19 +18,14 @@
#define ZACUS_HAS_CAMERA 0
#endif
namespace {
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_heap_caps.h>)
#include <esp_heap_caps.h>
#define ZACUS_HAS_HEAP_CAPS 1
#else
#define ZACUS_HAS_HEAP_CAPS 0
#endif
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
namespace {
String normalizeDir(const char* dir) {
if (dir == nullptr || dir[0] == '\0') {
@@ -235,7 +231,7 @@ CameraManager::CameraManager() {
bool CameraManager::begin(const Config& config) {
config_ = config;
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
core::copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
if (config_.jpeg_quality < 4U) {
config_.jpeg_quality = 4U;
}
@@ -257,8 +253,8 @@ bool CameraManager::begin(const Config& config) {
snapshot_.jpeg_quality = config_.jpeg_quality;
snapshot_.fb_count = config_.fb_count;
snapshot_.xclk_hz = config_.xclk_hz;
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
core::copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
core::copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
recorder_mode_ = false;
recorder_frozen_ = false;
recorder_frozen_fb_ = nullptr;
@@ -273,8 +269,8 @@ bool CameraManager::begin(const Config& config) {
bool CameraManager::ensureSnapshotDir() {
const String dir = normalizeDir(config_.snapshot_dir);
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
core::copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
core::copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
if (LittleFS.exists(dir.c_str())) {
return true;
}
@@ -312,7 +308,7 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
snapshot_.initialized = false;
}
camera_config_t cfg = {};
camera_config_t cfg = {};
cfg.ledc_channel = LEDC_CHANNEL_0;
cfg.ledc_timer = LEDC_TIMER_0;
cfg.pin_d0 = FREENOVE_CAM_Y2;
@@ -331,39 +327,50 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
cfg.pin_sccb_scl = FREENOVE_CAM_SIOC;
cfg.pin_pwdn = FREENOVE_CAM_PWDN;
cfg.pin_reset = FREENOVE_CAM_RESET;
cfg.xclk_freq_hz = config_.xclk_hz;
cfg.fb_count = recorder_mode ? 1U : config_.fb_count;
cfg.xclk_freq_hz = config_.xclk_hz;
cfg.fb_count = recorder_mode ? 1U : config_.fb_count;
#if ZACUS_HAS_HEAP_CAPS
const uint32_t largest_dma_block = heap_caps_get_largest_free_block(MALLOC_CAP_DMA);
#else
const uint32_t largest_dma_block = 0U;
#endif
const bool low_dma_budget = (largest_dma_block > 0U) && (largest_dma_block < 20000U);
#if defined(CAMERA_GRAB_LATEST)
cfg.grab_mode = CAMERA_GRAB_LATEST;
cfg.grab_mode = CAMERA_GRAB_LATEST;
#endif
#if defined(CAMERA_FB_IN_PSRAM) && (UI_CAMERA_FB_IN_PSRAM != 0)
cfg.fb_location = CAMERA_FB_IN_PSRAM;
#endif
if (recorder_mode) {
cfg.pixel_format = PIXFORMAT_RGB565;
// Keep recorder mode at a lower camera footprint to avoid DMA alloc failures
// when UI/FX buffers are active.
cfg.frame_size = FRAMESIZE_QQVGA;
cfg.jpeg_quality = 12U;
} else {
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = frameSizeFromText(config_.frame_size);
cfg.jpeg_quality = config_.jpeg_quality;
}
if (recorder_mode) {
cfg.pixel_format = PIXFORMAT_RGB565;
// Keep recorder mode at a lower camera footprint to avoid DMA alloc failures
// when UI/FX buffers are active.
cfg.frame_size = FRAMESIZE_QQVGA;
cfg.jpeg_quality = 12U;
} else {
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = low_dma_budget ? FRAMESIZE_QQVGA : frameSizeFromText(config_.frame_size);
cfg.jpeg_quality = low_dma_budget ? std::max<uint8_t>(config_.jpeg_quality, 20U) : config_.jpeg_quality;
if (low_dma_budget) {
cfg.fb_count = 1U;
Serial.printf("[CAM] low-dma capture profile largest_dma=%lu frame=QQVGA fb=1\n",
static_cast<unsigned long>(largest_dma_block));
}
}
esp_err_t status = esp_camera_init(&cfg);
if (status != ESP_OK) {
Serial.printf("[CAM] init failed mode=%s err=0x%x\n", recorder_mode ? "recorder" : "default", static_cast<unsigned int>(status));
camera_config_t fallback = cfg;
if (recorder_mode) {
fallback.frame_size = FRAMESIZE_QQVGA;
fallback.fb_count = 1U;
} else {
fallback.frame_size = FRAMESIZE_QVGA;
fallback.jpeg_quality = (fallback.jpeg_quality < 20U) ? 20U : fallback.jpeg_quality;
fallback.fb_count = 1U;
}
if (recorder_mode) {
fallback.frame_size = FRAMESIZE_QQVGA;
fallback.fb_count = 1U;
} else {
fallback.frame_size = low_dma_budget ? FRAMESIZE_QQVGA : FRAMESIZE_QVGA;
fallback.jpeg_quality = (fallback.jpeg_quality < 20U) ? 20U : fallback.jpeg_quality;
fallback.fb_count = 1U;
}
#if defined(CAMERA_FB_IN_DRAM)
fallback.fb_location = CAMERA_FB_IN_DRAM;
#endif
@@ -386,7 +393,7 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
snapshot_.initialized = true;
snapshot_.jpeg_quality = static_cast<uint8_t>(cfg.jpeg_quality);
snapshot_.fb_count = static_cast<uint8_t>(cfg.fb_count);
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), frameSizeToText(cfg.frame_size));
core::copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), frameSizeToText(cfg.frame_size));
snapshot_.width = frameSizeWidth(cfg.frame_size);
snapshot_.height = frameSizeHeight(cfg.frame_size);
recorder_mode_ = recorder_mode;
@@ -665,7 +672,7 @@ bool CameraManager::snapshotToFile(const char* filename_hint, String* out_path)
++snapshot_.capture_count;
snapshot_.width = frame->width;
snapshot_.height = frame->height;
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
core::copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
clearLastError();
if (out_path != nullptr) {
*out_path = path;
@@ -889,8 +896,8 @@ bool CameraManager::recorderSaveFrozen(String* out_path, RecorderSaveFormat form
snapshot_.last_snapshot_ok = true;
snapshot_.last_capture_ms = millis();
++snapshot_.capture_count;
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
copyText(snapshot_.recorder_selected_file, sizeof(snapshot_.recorder_selected_file), path.c_str());
core::copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
core::copyText(snapshot_.recorder_selected_file, sizeof(snapshot_.recorder_selected_file), path.c_str());
clearLastError();
if (out_path != nullptr) {
*out_path = path;
@@ -985,7 +992,7 @@ CameraManager::Snapshot CameraManager::snapshot() const {
}
void CameraManager::setLastError(const char* message) {
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
core::copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void CameraManager::clearLastError() {
-332
View File
@@ -1,332 +0,0 @@
// camera_manager.cpp - camera lifecycle + JPEG snapshots.
#include "camera_manager.h"
#include <FS.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
#include "ui_freenove_config.h"
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_camera.h>) && FREENOVE_CAM_ENABLE
#include <esp_camera.h>
#define ZACUS_HAS_CAMERA 1
#else
#define ZACUS_HAS_CAMERA 0
#endif
namespace {
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
String normalizeDir(const char* dir) {
if (dir == nullptr || dir[0] == '\0') {
return String("/picture");
}
String normalized = dir;
normalized.trim();
if (normalized.isEmpty()) {
normalized = "/picture";
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
if (normalized.endsWith("/")) {
normalized.remove(normalized.length() - 1U);
}
if (normalized.isEmpty()) {
normalized = "/picture";
}
return normalized;
}
String sanitizeFileBasename(const char* filename_hint) {
String base = (filename_hint != nullptr) ? filename_hint : "";
base.trim();
if (base.isEmpty()) {
base = "story_";
base += String(static_cast<unsigned long>(millis()));
}
// Keep names portable across FS/web flows.
for (size_t index = 0U; index < base.length(); ++index) {
const char ch = base[index];
const bool keep = std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-' || ch == '.';
if (!keep) {
base.setCharAt(index, '_');
}
}
if (!base.endsWith(".jpg") && !base.endsWith(".jpeg")) {
base += ".jpg";
}
return base;
}
#if ZACUS_HAS_CAMERA
framesize_t frameSizeFromText(const char* text) {
if (text == nullptr || text[0] == '\0') {
return FRAMESIZE_VGA;
}
char normalized[20] = {0};
std::strncpy(normalized, text, sizeof(normalized) - 1U);
for (size_t index = 0U; normalized[index] != '\0'; ++index) {
normalized[index] = static_cast<char>(std::toupper(static_cast<unsigned char>(normalized[index])));
}
if (std::strcmp(normalized, "QQVGA") == 0) {
return FRAMESIZE_QQVGA;
}
if (std::strcmp(normalized, "HQVGA") == 0) {
return FRAMESIZE_HQVGA;
}
if (std::strcmp(normalized, "QVGA") == 0) {
return FRAMESIZE_QVGA;
}
if (std::strcmp(normalized, "CIF") == 0) {
return FRAMESIZE_CIF;
}
if (std::strcmp(normalized, "VGA") == 0) {
return FRAMESIZE_VGA;
}
if (std::strcmp(normalized, "SVGA") == 0) {
return FRAMESIZE_SVGA;
}
if (std::strcmp(normalized, "XGA") == 0) {
return FRAMESIZE_XGA;
}
if (std::strcmp(normalized, "SXGA") == 0) {
return FRAMESIZE_SXGA;
}
if (std::strcmp(normalized, "UXGA") == 0) {
return FRAMESIZE_UXGA;
}
if (std::strcmp(normalized, "HD") == 0) {
return FRAMESIZE_HD;
}
if (std::strcmp(normalized, "FHD") == 0) {
return FRAMESIZE_FHD;
}
return FRAMESIZE_VGA;
}
#endif
} // namespace
CameraManager::CameraManager() {
snapshot_.supported = (ZACUS_HAS_CAMERA != 0);
}
bool CameraManager::begin(const Config& config) {
config_ = config;
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
if (config_.jpeg_quality < 4U) {
config_.jpeg_quality = 4U;
}
if (config_.jpeg_quality > 63U) {
config_.jpeg_quality = 63U;
}
if (config_.fb_count == 0U) {
config_.fb_count = 1U;
}
if (config_.fb_count > 2U) {
config_.fb_count = 2U;
}
if (config_.xclk_hz < 1000000UL) {
config_.xclk_hz = 10000000UL;
}
snapshot_.supported = (ZACUS_HAS_CAMERA != 0);
snapshot_.enabled = false;
snapshot_.initialized = false;
snapshot_.last_snapshot_ok = false;
snapshot_.capture_count = 0U;
snapshot_.fail_count = 0U;
snapshot_.last_capture_ms = 0U;
snapshot_.width = 0U;
snapshot_.height = 0U;
snapshot_.jpeg_quality = config_.jpeg_quality;
snapshot_.fb_count = config_.fb_count;
snapshot_.xclk_hz = config_.xclk_hz;
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
snapshot_.last_file[0] = '\0';
snapshot_.last_error[0] = '\0';
return true;
}
bool CameraManager::start() {
snapshot_.enabled = false;
#if ZACUS_HAS_CAMERA
if (snapshot_.initialized) {
snapshot_.enabled = true;
return true;
}
camera_config_t cfg = {};
cfg.ledc_channel = LEDC_CHANNEL_0;
cfg.ledc_timer = LEDC_TIMER_0;
cfg.pin_d0 = FREENOVE_CAM_Y2;
cfg.pin_d1 = FREENOVE_CAM_Y3;
cfg.pin_d2 = FREENOVE_CAM_Y4;
cfg.pin_d3 = FREENOVE_CAM_Y5;
cfg.pin_d4 = FREENOVE_CAM_Y6;
cfg.pin_d5 = FREENOVE_CAM_Y7;
cfg.pin_d6 = FREENOVE_CAM_Y8;
cfg.pin_d7 = FREENOVE_CAM_Y9;
cfg.pin_xclk = FREENOVE_CAM_XCLK;
cfg.pin_pclk = FREENOVE_CAM_PCLK;
cfg.pin_vsync = FREENOVE_CAM_VSYNC;
cfg.pin_href = FREENOVE_CAM_HREF;
cfg.pin_sscb_sda = FREENOVE_CAM_SIOD;
cfg.pin_sscb_scl = FREENOVE_CAM_SIOC;
cfg.pin_pwdn = FREENOVE_CAM_PWDN;
cfg.pin_reset = FREENOVE_CAM_RESET;
cfg.xclk_freq_hz = config_.xclk_hz;
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = frameSizeFromText(config_.frame_size);
cfg.jpeg_quality = config_.jpeg_quality;
cfg.fb_count = config_.fb_count;
#if defined(CAMERA_GRAB_LATEST)
cfg.grab_mode = CAMERA_GRAB_LATEST;
#endif
#if defined(CAMERA_FB_IN_PSRAM)
cfg.fb_location = CAMERA_FB_IN_PSRAM;
#endif
const esp_err_t status = esp_camera_init(&cfg);
if (status != ESP_OK) {
setLastError("camera_init_failed");
Serial.printf("[CAM] init failed err=0x%x\n", static_cast<unsigned int>(status));
return false;
}
snapshot_.initialized = true;
snapshot_.enabled = true;
clearLastError();
Serial.printf("[CAM] ready frame=%s quality=%u fb=%u xclk=%lu\n",
snapshot_.frame_size,
static_cast<unsigned int>(snapshot_.jpeg_quality),
static_cast<unsigned int>(snapshot_.fb_count),
static_cast<unsigned long>(snapshot_.xclk_hz));
return true;
#else
setLastError("camera_not_supported");
return false;
#endif
}
void CameraManager::stop() {
#if ZACUS_HAS_CAMERA
if (snapshot_.initialized) {
esp_camera_deinit();
}
#endif
snapshot_.initialized = false;
snapshot_.enabled = false;
}
bool CameraManager::isEnabled() const {
return snapshot_.enabled;
}
bool CameraManager::ensureSnapshotDir() {
String dir = normalizeDir(config_.snapshot_dir);
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
if (LittleFS.exists(dir.c_str())) {
return true;
}
if (LittleFS.mkdir(dir.c_str())) {
return true;
}
setLastError("snapshot_dir_error");
return false;
}
String CameraManager::buildSnapshotPath(const char* filename_hint) const {
String dir = normalizeDir(config_.snapshot_dir);
String file = sanitizeFileBasename(filename_hint);
if (file.startsWith("/")) {
return file;
}
return dir + "/" + file;
}
bool CameraManager::snapshotToFile(const char* filename_hint, String* out_path) {
if (out_path != nullptr) {
out_path->remove(0);
}
if (!start()) {
++snapshot_.fail_count;
return false;
}
if (!ensureSnapshotDir()) {
++snapshot_.fail_count;
return false;
}
#if ZACUS_HAS_CAMERA
camera_fb_t* frame = esp_camera_fb_get();
if (frame == nullptr) {
++snapshot_.fail_count;
setLastError("camera_capture_failed");
return false;
}
const String path = buildSnapshotPath(filename_hint);
File file = LittleFS.open(path.c_str(), "w");
if (!file) {
esp_camera_fb_return(frame);
++snapshot_.fail_count;
setLastError("snapshot_write_failed");
return false;
}
const size_t written = file.write(frame->buf, frame->len);
file.close();
if (written != frame->len) {
esp_camera_fb_return(frame);
++snapshot_.fail_count;
setLastError("snapshot_write_incomplete");
return false;
}
snapshot_.last_snapshot_ok = true;
snapshot_.last_capture_ms = millis();
++snapshot_.capture_count;
snapshot_.width = frame->width;
snapshot_.height = frame->height;
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
clearLastError();
if (out_path != nullptr) {
*out_path = path;
}
esp_camera_fb_return(frame);
return true;
#else
(void)filename_hint;
++snapshot_.fail_count;
setLastError("camera_not_supported");
return false;
#endif
}
CameraManager::Snapshot CameraManager::snapshot() const {
return snapshot_;
}
void CameraManager::setLastError(const char* message) {
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void CameraManager::clearLastError() {
snapshot_.last_error[0] = '\0';
}
+114 -179
View File
@@ -1,258 +1,193 @@
// mutex_manager.cpp - Thread-safe access protection implementation
// mutex_manager.cpp - MutexManager singleton implementation.
#include "core/mutex_manager.h"
namespace {
// ============================================================================
// Singleton
// ============================================================================
// Internal mutex handles
SemaphoreHandle_t g_audio_mutex = nullptr;
SemaphoreHandle_t g_scenario_mutex = nullptr;
MutexManager& MutexManager::instance() {
static MutexManager inst;
return inst;
}
// Statistics for debugging race conditions
uint32_t g_audio_lock_count = 0;
uint32_t g_scenario_lock_count = 0;
uint32_t g_audio_timeout_count = 0;
uint32_t g_scenario_timeout_count = 0;
uint32_t g_max_audio_wait_us = 0;
uint32_t g_max_scenario_wait_us = 0;
// ============================================================================
// Lifecycle
// ============================================================================
// Track mutex holder task for deadlock detection
TaskHandle_t g_audio_mutex_owner = nullptr;
TaskHandle_t g_scenario_mutex_owner = nullptr;
} // namespace
namespace MutexManager {
bool init() {
if (g_audio_mutex != nullptr || g_scenario_mutex != nullptr) {
bool MutexManager::doInit() {
if (audio_mutex_ != nullptr || scenario_mutex_ != nullptr) {
Serial.println("[MUTEX] WARNING: Already initialized");
return false;
}
g_audio_mutex = xSemaphoreCreateMutex();
if (g_audio_mutex == nullptr) {
audio_mutex_ = xSemaphoreCreateMutex();
if (audio_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Failed to create audio mutex");
return false;
}
g_scenario_mutex = xSemaphoreCreateMutex();
if (g_scenario_mutex == nullptr) {
scenario_mutex_ = xSemaphoreCreateMutex();
if (scenario_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Failed to create scenario mutex");
vSemaphoreDelete(g_audio_mutex);
g_audio_mutex = nullptr;
vSemaphoreDelete(audio_mutex_);
audio_mutex_ = nullptr;
return false;
}
// Reset stats
g_audio_lock_count = 0;
g_scenario_lock_count = 0;
g_audio_timeout_count = 0;
g_scenario_timeout_count = 0;
g_max_audio_wait_us = 0;
g_max_scenario_wait_us = 0;
g_audio_mutex_owner = nullptr;
g_scenario_mutex_owner = nullptr;
audio_lock_count_.store(0U);
scenario_lock_count_.store(0U);
audio_timeout_count_.store(0U);
scenario_timeout_count_.store(0U);
max_audio_wait_us_.store(0U);
max_scenario_wait_us_.store(0U);
audio_owner_ = nullptr;
scenario_owner_ = nullptr;
Serial.println("[MUTEX] Initialized: dual-mutex strategy (audio + scenario)");
Serial.println("[MUTEX] Initialized: dual-mutex (audio + scenario), atomic stats");
return true;
}
void deinit() {
if (g_audio_mutex != nullptr) {
vSemaphoreDelete(g_audio_mutex);
g_audio_mutex = nullptr;
void MutexManager::doDeinit() {
if (audio_mutex_ != nullptr) {
vSemaphoreDelete(audio_mutex_);
audio_mutex_ = nullptr;
}
if (g_scenario_mutex != nullptr) {
vSemaphoreDelete(g_scenario_mutex);
g_scenario_mutex = nullptr;
if (scenario_mutex_ != nullptr) {
vSemaphoreDelete(scenario_mutex_);
scenario_mutex_ = nullptr;
}
g_audio_mutex_owner = nullptr;
g_scenario_mutex_owner = nullptr;
audio_owner_ = nullptr;
scenario_owner_ = nullptr;
Serial.println("[MUTEX] Deinitialized");
}
bool takeAudioMutex(uint32_t timeout_ms) {
if (g_audio_mutex == nullptr) {
// ============================================================================
// Internal helper — lock-free CAS update of a max field.
// ============================================================================
void MutexManager::updateMaxWait(std::atomic<uint32_t>& max_field, uint32_t elapsed_us) {
uint32_t current = max_field.load(std::memory_order_relaxed);
while (elapsed_us > current &&
!max_field.compare_exchange_weak(current, elapsed_us,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
// ============================================================================
// Audio mutex
// ============================================================================
bool MutexManager::takeAudio(uint32_t timeout_ms) {
if (audio_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Audio mutex not initialized");
return false;
}
// Deadlock detection: check if current task already owns scenario mutex
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
if (g_scenario_mutex_owner == current_task) {
Serial.println("[MUTEX] ERROR: Deadlock prevented - scenario mutex already held, cannot acquire audio mutex");
Serial.printf("[MUTEX] Correct order: audio → scenario. Current: scenario → audio\n");
// Deadlock prevention: audio must be acquired before scenario.
TaskHandle_t current = xTaskGetCurrentTaskHandle();
if (scenario_owner_ == current) {
Serial.println("[MUTEX] ERROR: Deadlock prevented scenario held, cannot take audio");
Serial.printf("[MUTEX] Correct order: audio → scenario. Got: scenario → audio\n");
return false;
}
const uint32_t start_us = micros();
const TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(g_audio_mutex, ticks);
const TickType_t ticks = (timeout_ms == 0U) ? 0U : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(audio_mutex_, ticks);
const uint32_t elapsed_us = micros() - start_us;
if (result == pdTRUE) {
g_audio_lock_count++;
g_audio_mutex_owner = current_task;
if (elapsed_us > g_max_audio_wait_us) {
g_max_audio_wait_us = elapsed_us;
}
if (elapsed_us > 5000) { // Log if wait > 5ms
Serial.printf("[MUTEX] Audio lock acquired after %lu µs (contention detected)\n", elapsed_us);
++audio_lock_count_;
audio_owner_ = current;
updateMaxWait(max_audio_wait_us_, elapsed_us);
if (elapsed_us > 5000U) {
Serial.printf("[MUTEX] Audio lock acquired after %lu µs (contention)\n",
static_cast<unsigned long>(elapsed_us));
}
return true;
}
g_audio_timeout_count++;
Serial.printf("[MUTEX] ERROR: Audio mutex timeout after %lu ms (elapsed %lu µs)\n",
timeout_ms, elapsed_us);
++audio_timeout_count_;
Serial.printf("[MUTEX] ERROR: Audio timeout after %lu ms (elapsed %lu µs)\n",
static_cast<unsigned long>(timeout_ms),
static_cast<unsigned long>(elapsed_us));
return false;
}
void releaseAudioMutex() {
if (g_audio_mutex == nullptr) {
void MutexManager::releaseAudio() {
if (audio_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Audio mutex not initialized");
return;
}
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
if (g_audio_mutex_owner != current_task) {
Serial.println("[MUTEX] WARNING: Releasing audio mutex from different task than owner");
if (audio_owner_ != xTaskGetCurrentTaskHandle()) {
Serial.println("[MUTEX] WARNING: Audio released from non-owner task");
}
g_audio_mutex_owner = nullptr;
xSemaphoreGive(g_audio_mutex);
audio_owner_ = nullptr;
xSemaphoreGive(audio_mutex_);
}
bool takeScenarioMutex(uint32_t timeout_ms) {
if (g_scenario_mutex == nullptr) {
// ============================================================================
// Scenario mutex
// ============================================================================
bool MutexManager::takeScenario(uint32_t timeout_ms) {
if (scenario_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Scenario mutex not initialized");
return false;
}
const uint32_t start_us = micros();
const TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(g_scenario_mutex, ticks);
const TickType_t ticks = (timeout_ms == 0U) ? 0U : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(scenario_mutex_, ticks);
const uint32_t elapsed_us = micros() - start_us;
if (result == pdTRUE) {
g_scenario_lock_count++;
g_scenario_mutex_owner = xTaskGetCurrentTaskHandle();
if (elapsed_us > g_max_scenario_wait_us) {
g_max_scenario_wait_us = elapsed_us;
}
if (elapsed_us > 5000) { // Log if wait > 5ms
Serial.printf("[MUTEX] Scenario lock acquired after %lu µs (contention detected)\n", elapsed_us);
++scenario_lock_count_;
scenario_owner_ = xTaskGetCurrentTaskHandle();
updateMaxWait(max_scenario_wait_us_, elapsed_us);
if (elapsed_us > 5000U) {
Serial.printf("[MUTEX] Scenario lock acquired after %lu µs (contention)\n",
static_cast<unsigned long>(elapsed_us));
}
return true;
}
g_scenario_timeout_count++;
Serial.printf("[MUTEX] ERROR: Scenario mutex timeout after %lu ms (elapsed %lu µs)\n",
timeout_ms, elapsed_us);
++scenario_timeout_count_;
Serial.printf("[MUTEX] ERROR: Scenario timeout after %lu ms (elapsed %lu µs)\n",
static_cast<unsigned long>(timeout_ms),
static_cast<unsigned long>(elapsed_us));
return false;
}
void releaseScenarioMutex() {
if (g_scenario_mutex == nullptr) {
void MutexManager::releaseScenario() {
if (scenario_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Scenario mutex not initialized");
return;
}
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
if (g_scenario_mutex_owner != current_task) {
Serial.println("[MUTEX] WARNING: Releasing scenario mutex from different task than owner");
if (scenario_owner_ != xTaskGetCurrentTaskHandle()) {
Serial.println("[MUTEX] WARNING: Scenario released from non-owner task");
}
g_scenario_mutex_owner = nullptr;
xSemaphoreGive(g_scenario_mutex);
scenario_owner_ = nullptr;
xSemaphoreGive(scenario_mutex_);
}
bool takeBothMutexes(uint32_t timeout_ms) {
// Acquire audio mutex first (deadlock prevention order: audio → scenario)
if (!takeAudioMutex(timeout_ms)) {
// ============================================================================
// Both mutexes (deadlock-safe order: audio → scenario)
// ============================================================================
bool MutexManager::takeBoth(uint32_t timeout_ms) {
if (!takeAudio(timeout_ms)) {
return false;
}
// Then scenario mutex
if (!takeScenarioMutex(timeout_ms)) {
releaseAudioMutex(); // Rollback on failure
if (!takeScenario(timeout_ms)) {
releaseAudio(); // Rollback on partial failure.
return false;
}
return true;
}
void releaseBothMutexes() {
// Release in reverse order (scenario → audio)
releaseScenarioMutex();
releaseAudioMutex();
}
uint32_t audioLockCount() {
return g_audio_lock_count;
}
uint32_t scenarioLockCount() {
return g_scenario_lock_count;
}
uint32_t audioTimeoutCount() {
return g_audio_timeout_count;
}
uint32_t scenarioTimeoutCount() {
return g_scenario_timeout_count;
}
uint32_t maxAudioWaitUs() {
return g_max_audio_wait_us;
}
uint32_t maxScenarioWaitUs() {
return g_max_scenario_wait_us;
}
} // namespace MutexManager
// ============================================================================
// RAII LOCK GUARDS IMPLEMENTATION
// ============================================================================
AudioLock::AudioLock(uint32_t timeout_ms)
: acquired_(MutexManager::takeAudioMutex(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] AudioLock FAILED (timeout %lu ms)\n", timeout_ms);
}
}
AudioLock::~AudioLock() {
if (acquired_) {
MutexManager::releaseAudioMutex();
}
}
ScenarioLock::ScenarioLock(uint32_t timeout_ms)
: acquired_(MutexManager::takeScenarioMutex(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] ScenarioLock FAILED (timeout %lu ms)\n", timeout_ms);
}
}
ScenarioLock::~ScenarioLock() {
if (acquired_) {
MutexManager::releaseScenarioMutex();
}
}
DualLock::DualLock(uint32_t timeout_ms)
: acquired_(MutexManager::takeBothMutexes(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] DualLock FAILED (timeout %lu ms)\n", timeout_ms);
}
}
DualLock::~DualLock() {
if (acquired_) {
MutexManager::releaseBothMutexes();
}
void MutexManager::releaseBoth() {
releaseScenario(); // Reverse order: scenario → audio.
releaseAudio();
}
@@ -280,8 +280,13 @@ void HardwareManager::setMicRuntimeEnabled(bool enabled) {
return;
}
mic_enabled_runtime_ = enabled;
if (mic_enabled_runtime_ && !mic_driver_ready_) {
mic_driver_ready_ = beginMic();
}
snapshot_.mic_ready = mic_enabled_runtime_ && mic_driver_ready_;
if (!mic_enabled_runtime_) {
endMic();
snapshot_.mic_ready = false;
snapshot_.mic_level_percent = 0U;
snapshot_.mic_peak = 0U;
snapshot_.mic_freq_hz = 0U;
@@ -317,6 +322,12 @@ void HardwareManager::setSceneSingleRandomBlink(bool enabled,
}
bool HardwareManager::beginMic() {
if (mic_driver_ready_) {
return true;
}
if (!mic_enabled_runtime_) {
return false;
}
i2s_config_t config = {};
config.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX);
config.sample_rate = kMicSampleRate;
@@ -353,6 +364,14 @@ bool HardwareManager::beginMic() {
return true;
}
void HardwareManager::endMic() {
if (!mic_driver_ready_) {
return;
}
i2s_driver_uninstall(kMicPort);
mic_driver_ready_ = false;
}
void HardwareManager::updateMic(uint32_t now_ms) {
if (!mic_enabled_runtime_) {
return;
@@ -1,20 +1,22 @@
// touch_emulator.cpp - Virtual touch simulation via button navigation
// touch_emulator.cpp - Virtual touch simulation via button navigation.
#include "drivers/input/touch_emulator.h"
void TouchEmulator::begin(uint8_t grid_cols, uint8_t grid_rows,
uint16_t cell_width, uint16_t cell_height,
uint16_t offset_x, uint16_t offset_y) {
grid_cols_ = grid_cols;
grid_rows_ = grid_rows;
cell_width_ = cell_width;
uint16_t cell_width, uint16_t cell_height,
uint16_t offset_x, uint16_t offset_y) {
grid_cols_ = grid_cols;
grid_rows_ = grid_rows;
cell_width_ = cell_width;
cell_height_ = cell_height;
offset_x_ = offset_x;
offset_y_ = offset_y;
offset_x_ = offset_x;
offset_y_ = offset_y;
cursor_grid_index_ = 0;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Initialized: grid %ux%u, cell %ux%u, offset (%u,%u)\n",
grid_cols_, grid_rows_, cell_width_, cell_height_, offset_x_, offset_y_);
// One-time init log — always kept.
Serial.printf("[TOUCH_EMU] grid=%ux%u cell=%ux%u offset=(%u,%u)\n",
grid_cols_, grid_rows_, cell_width_, cell_height_,
offset_x_, offset_y_);
}
void TouchEmulator::moveUp() {
@@ -22,10 +24,10 @@ void TouchEmulator::moveUp() {
if (current_row > 0) {
cursor_grid_index_ -= grid_cols_;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move UP → grid[%u] (%u,%u)\n",
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] UP → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
Serial.printf("[TOUCH_EMU] Move UP blocked (already at top row)\n");
#endif
}
}
@@ -34,10 +36,10 @@ void TouchEmulator::moveDown() {
if (current_row < grid_rows_ - 1) {
cursor_grid_index_ += grid_cols_;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move DOWN → grid[%u] (%u,%u)\n",
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] DOWN → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
Serial.printf("[TOUCH_EMU] Move DOWN blocked (already at bottom row)\n");
#endif
}
}
@@ -45,55 +47,51 @@ void TouchEmulator::moveLeft() {
const uint8_t current_col = cursor_grid_index_ % grid_cols_;
if (current_col > 0) {
cursor_grid_index_--;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move LEFT → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
// Wrap to rightmost column of same row
// Wrap to rightmost column of same row.
cursor_grid_index_ = (cursor_grid_index_ / grid_cols_) * grid_cols_ + (grid_cols_ - 1);
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move LEFT (wrap) → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
}
updateCursorCoordinates();
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] LEFT → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
#endif
}
void TouchEmulator::moveRight() {
const uint8_t current_col = cursor_grid_index_ % grid_cols_;
if (current_col < grid_cols_ - 1) {
cursor_grid_index_++;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move RIGHT → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
// Wrap to leftmost column of same row
// Wrap to leftmost column of same row.
cursor_grid_index_ = (cursor_grid_index_ / grid_cols_) * grid_cols_;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move RIGHT (wrap) → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
}
updateCursorCoordinates();
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] RIGHT → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
#endif
}
void TouchEmulator::getCursorPosition(uint16_t* out_x, uint16_t* out_y, uint8_t* out_grid_index) const {
if (out_x) {
*out_x = cursor_x_;
}
if (out_y) {
*out_y = cursor_y_;
}
if (out_grid_index) {
*out_grid_index = cursor_grid_index_;
}
void TouchEmulator::getCursorPosition(uint16_t* out_x, uint16_t* out_y,
uint8_t* out_grid_index) const {
if (out_x) *out_x = cursor_x_;
if (out_y) *out_y = cursor_y_;
if (out_grid_index) *out_grid_index = cursor_grid_index_;
}
void TouchEmulator::setGridIndex(uint8_t index) {
const uint8_t max_index = grid_cols_ * grid_rows_ - 1;
const uint8_t max_index = static_cast<uint8_t>(grid_cols_ * grid_rows_ - 1);
if (index <= max_index) {
cursor_grid_index_ = index;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Set grid[%u] → (%u,%u)\n",
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] set [%u] → (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
#endif
} else {
Serial.printf("[TOUCH_EMU] Invalid grid index %u (max %u)\n", index, max_index);
// Always log invalid index — indicates a bug.
Serial.printf("[TOUCH_EMU] ERROR: invalid index %u (max %u)\n", index, max_index);
}
}
@@ -104,8 +102,6 @@ bool TouchEmulator::isValidPosition(uint8_t max_apps) const {
void TouchEmulator::updateCursorCoordinates() {
const uint8_t row = cursor_grid_index_ / grid_cols_;
const uint8_t col = cursor_grid_index_ % grid_cols_;
// Calculate center of grid cell
cursor_x_ = offset_x_ + (col * cell_width_) + (cell_width_ / 2);
cursor_y_ = offset_y_ + (row * cell_height_) + (cell_height_ / 2);
cursor_x_ = offset_x_ + (col * cell_width_) + (cell_width_ / 2U);
cursor_y_ = offset_y_ + (row * cell_height_) + (cell_height_ / 2U);
}
@@ -1,926 +0,0 @@
// hardware_manager.cpp - Freenove peripherals (WS2812, mic, battery, buttons).
#include "hardware_manager.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include "ui_freenove_config.h"
namespace {
constexpr uint8_t kDefaultLedBrightness = static_cast<uint8_t>(FREENOVE_WS2812_BRIGHTNESS);
constexpr float kTwoPi = 6.2831853f;
constexpr float kTunerReferenceHz = 440.0f;
constexpr uint16_t kTunerMinHz = 80U;
constexpr uint16_t kTunerMaxHz = 1200U;
constexpr uint16_t kLaDetectMinHz = 320U;
constexpr uint16_t kLaDetectMaxHz = 560U;
constexpr uint8_t kTunerMinConfidence = 18U;
constexpr uint8_t kTunerDisplayMinConfidence = 40U;
constexpr uint8_t kTunerDisplayMinLevelPct = 10U;
constexpr uint16_t kTunerDisplayMinPeak = 1000U;
constexpr uint16_t kMicAgcTargetPeak = 7600U;
constexpr uint16_t kMicAgcDefaultGainQ8 = 256U;
constexpr uint16_t kMicAgcMinGainQ8 = 192U;
constexpr uint16_t kMicAgcMaxGainQ8 = 1024U;
constexpr uint16_t kMicAgcActivePeakMin = 28U;
constexpr uint16_t kMicAgcSignalDisplayPeakMin = 170U;
constexpr uint16_t kMicAgcStrongSignalPeakMin = 640U;
constexpr uint16_t kMicAgcWeakSignalReleaseMs = 450U;
constexpr uint16_t kMicAgcMinLevelDen = 5600U;
constexpr uint16_t kMicAgcAmbientGateDiv = 10U;
constexpr uint16_t kMicAgcGainDeadbandQ8 = 18U;
constexpr uint16_t kMicAgcMaxGainStepUp = 48U;
constexpr uint16_t kMicAgcMaxGainStepDown = 16U;
constexpr HardwareManager::LedPaletteEntry kLedPalette[] = {
{"SCENE_LOCKED", 255U, 96U, 22U, 88U, true},
{"SCENE_U_SON_PROTO", 255U, 40U, 18U, 86U, true},
{"SCENE_LA_DETECT", 32U, 224U, 170U, 56U, true},
{"SCENE_LA_DETECTOR", 32U, 224U, 170U, 56U, true},
{"SCENE_SEARCH", 32U, 224U, 170U, 56U, true},
{"SCENE_WIN_ETAPE1", 244U, 203U, 74U, 80U, true},
{"SCENE_WIN_ETAPE2", 244U, 203U, 74U, 80U, true},
{"ZACUS_U-SON", 18U, 45U, 95U, 52U, false},
{"__DEFAULT__", 18U, 45U, 95U, 52U, false},
};
uint8_t clampU8(int value) {
if (value < 0) {
return 0U;
}
if (value > 255) {
return 255U;
}
return static_cast<uint8_t>(value);
}
uint32_t hash32(uint32_t value) {
value ^= value >> 16;
value *= 0x7feb352dUL;
value ^= value >> 15;
value *= 0x846ca68bUL;
value ^= value >> 16;
return value;
}
uint8_t computeLevelPercent(uint16_t effective_peak, uint16_t den) {
const uint8_t raw_level =
static_cast<uint8_t>(std::min<uint32_t>(100U, (static_cast<uint32_t>(effective_peak) * 100U) / den));
return raw_level;
}
} // namespace
HardwareManager::HardwareManager()
: strip_(FREENOVE_WS2812_COUNT, FREENOVE_WS2812_PIN, NEO_GRB + NEO_KHZ800) {
snapshot_.led_brightness = kDefaultLedBrightness;
snapshot_.mic_gain_percent = static_cast<uint16_t>((mic_agc_gain_q8_ * 100U) / 256U);
snapshot_.mic_noise_floor = mic_noise_floor_raw_;
setScenePalette("SCENE_READY");
}
bool HardwareManager::begin() {
snapshot_.ready = true;
#if FREENOVE_WS2812_PIN >= 0 && FREENOVE_WS2812_COUNT > 0
strip_.begin();
strip_.setBrightness(snapshot_.led_brightness);
strip_.clear();
strip_.show();
snapshot_.ws2812_ready = true;
Serial.printf("[HW] WS2812 ready pin=%d count=%d\n", FREENOVE_WS2812_PIN, FREENOVE_WS2812_COUNT);
#else
snapshot_.ws2812_ready = false;
#endif
#if FREENOVE_BAT_ADC_PIN >= 0
analogReadResolution(12);
analogSetAttenuation(ADC_11db);
pinMode(FREENOVE_BAT_ADC_PIN, INPUT);
snapshot_.battery_ready = true;
Serial.printf("[HW] battery ADC ready pin=%d\n", FREENOVE_BAT_ADC_PIN);
#else
snapshot_.battery_ready = false;
#endif
#if FREENOVE_BAT_CHARGE_PIN >= 0
pinMode(FREENOVE_BAT_CHARGE_PIN, INPUT_PULLUP);
#endif
snapshot_.mic_ready = beginMic();
if (snapshot_.mic_ready) {
Serial.printf("[HW] mic I2S ready sck=%d ws=%d din=%d\n", FREENOVE_I2S_IN_SCK, FREENOVE_I2S_IN_WS, FREENOVE_I2S_IN_DIN);
} else {
Serial.println("[HW] mic I2S unavailable");
}
next_led_ms_ = 0U;
next_mic_ms_ = 0U;
next_battery_ms_ = 0U;
update(0U);
return true;
}
void HardwareManager::update(uint32_t now_ms) {
updateMic(now_ms);
updateBattery(now_ms);
updateLed(now_ms);
}
void HardwareManager::noteButton(uint8_t key, bool long_press, uint32_t now_ms) {
snapshot_.last_button = key;
snapshot_.last_button_long = long_press;
snapshot_.last_button_ms = now_ms;
++snapshot_.button_count;
button_flash_until_ms_ = now_ms + kButtonFlashMs;
}
void HardwareManager::setSceneHint(const char* scene_id) {
if (scene_id == nullptr || scene_id[0] == '\0') {
return;
}
if (std::strncmp(snapshot_.scene_id, scene_id, sizeof(snapshot_.scene_id) - 1U) == 0) {
return;
}
setScenePalette(scene_id);
}
bool HardwareManager::setManualLed(uint8_t r, uint8_t g, uint8_t b, uint8_t brightness, bool pulse) {
manual_led_ = true;
manual_pulse_ = pulse;
manual_r_ = r;
manual_g_ = g;
manual_b_ = b;
manual_brightness_ = brightness;
snapshot_.led_manual = true;
next_led_ms_ = 0U;
return snapshot_.ws2812_ready;
}
void HardwareManager::clearManualLed() {
manual_led_ = false;
manual_pulse_ = false;
snapshot_.led_manual = false;
next_led_ms_ = 0U;
}
HardwareManager::Snapshot HardwareManager::snapshot() const {
return snapshot_;
}
const HardwareManager::Snapshot& HardwareManager::snapshotRef() const {
return snapshot_;
}
bool HardwareManager::beginMic() {
i2s_config_t config = {};
config.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX);
config.sample_rate = kMicSampleRate;
config.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT;
config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;
config.dma_buf_count = 4;
config.dma_buf_len = 128;
config.use_apll = false;
config.tx_desc_auto_clear = false;
config.fixed_mclk = 0;
if (i2s_driver_install(kMicPort, &config, 0, nullptr) != ESP_OK) {
return false;
}
i2s_pin_config_t pin_config = {};
pin_config.bck_io_num = FREENOVE_I2S_IN_SCK;
pin_config.ws_io_num = FREENOVE_I2S_IN_WS;
pin_config.data_out_num = I2S_PIN_NO_CHANGE;
pin_config.data_in_num = FREENOVE_I2S_IN_DIN;
if (i2s_set_pin(kMicPort, &pin_config) != ESP_OK) {
i2s_driver_uninstall(kMicPort);
return false;
}
if (i2s_set_clk(kMicPort, kMicSampleRate, I2S_BITS_PER_SAMPLE_32BIT, I2S_CHANNEL_MONO) != ESP_OK) {
i2s_driver_uninstall(kMicPort);
return false;
}
mic_driver_ready_ = true;
return true;
}
void HardwareManager::updateMic(uint32_t now_ms) {
if (!snapshot_.mic_ready) {
return;
}
if (now_ms < next_mic_ms_) {
return;
}
next_mic_ms_ = now_ms + kMicPeriodMs;
size_t bytes_read = 0U;
if (i2s_read(kMicPort, mic_raw_samples_, sizeof(mic_raw_samples_), &bytes_read, 0) != ESP_OK || bytes_read == 0U) {
return;
}
const size_t sample_count = bytes_read / sizeof(int32_t);
if (sample_count == 0U) {
return;
}
uint16_t raw_peak = 0U;
uint32_t raw_abs_sum = 0U;
for (size_t index = 0U; index < sample_count; ++index) {
// INMP441 data arrives as signed PCM24 packed in 32-bit slots (left-aligned).
int32_t value = mic_raw_samples_[index] >> 16;
if (value > 32767) {
value = 32767;
} else if (value < -32768) {
value = -32768;
}
const uint16_t abs_raw = static_cast<uint16_t>((value < 0) ? -value : value);
if (abs_raw > raw_peak) {
raw_peak = abs_raw;
}
raw_abs_sum += static_cast<uint32_t>(abs_raw);
// Apply dynamic digital gain before pitch/level extraction.
int32_t scaled = (value * static_cast<int32_t>(mic_agc_gain_q8_)) / 256;
if (scaled > 32767) {
scaled = 32767;
} else if (scaled < -32768) {
scaled = -32768;
}
mic_samples_[index] = static_cast<int16_t>(scaled);
}
const uint16_t raw_abs_mean = static_cast<uint16_t>(
std::min<uint32_t>(65535U, raw_abs_sum / static_cast<uint32_t>(sample_count)));
// Track ambient floor from raw microphone average levels to avoid over-amplifying idle noise.
if (raw_abs_mean <= static_cast<uint16_t>(mic_noise_floor_raw_ + 24U)) {
mic_noise_floor_raw_ =
static_cast<uint16_t>((static_cast<uint32_t>(mic_noise_floor_raw_) * 31U + raw_abs_mean) / 32U);
} else {
mic_noise_floor_raw_ =
static_cast<uint16_t>((static_cast<uint32_t>(mic_noise_floor_raw_) * 127U + raw_abs_mean) / 128U);
}
if (mic_noise_floor_raw_ < 24U) {
mic_noise_floor_raw_ = 24U;
}
const uint16_t signal_abs_raw = (raw_abs_mean > mic_noise_floor_raw_)
? static_cast<uint16_t>(raw_abs_mean - mic_noise_floor_raw_)
: 0U;
const uint16_t dynamic_active_peak_min =
std::max<uint16_t>(kMicAgcActivePeakMin, static_cast<uint16_t>(mic_noise_floor_raw_ / kMicAgcAmbientGateDiv));
const bool has_signal_window = signal_abs_raw >= dynamic_active_peak_min;
const bool has_stale_signal = (now_ms - mic_last_signal_ms_) > static_cast<uint32_t>(kMicAgcWeakSignalReleaseMs);
if (has_signal_window) {
mic_last_signal_ms_ = now_ms;
}
uint16_t target_gain_q8 = mic_agc_gain_q8_;
if (has_signal_window) {
const uint32_t desired = (static_cast<uint32_t>(kMicAgcTargetPeak) * 256U) /
static_cast<uint32_t>(std::max<uint16_t>(signal_abs_raw, 1U));
target_gain_q8 = static_cast<uint16_t>(
std::min<uint32_t>(kMicAgcMaxGainQ8, std::max<uint32_t>(kMicAgcMinGainQ8, desired)));
} else if (raw_abs_mean <= static_cast<uint16_t>(mic_noise_floor_raw_ + 24U) || has_stale_signal) {
target_gain_q8 = kMicAgcDefaultGainQ8;
}
const bool gain_return_from_silence = !has_signal_window &&
(raw_abs_mean <= static_cast<uint16_t>(mic_noise_floor_raw_ + 24U) || has_stale_signal);
if ((target_gain_q8 > (mic_agc_gain_q8_ + kMicAgcGainDeadbandQ8))) {
uint16_t delta = static_cast<uint16_t>(target_gain_q8 - mic_agc_gain_q8_);
uint16_t step = static_cast<uint16_t>((delta / 10U) + 3U);
if (step < 6U) {
step = 6U;
}
if (gain_return_from_silence) {
step = static_cast<uint16_t>(std::max<uint16_t>(8U, (delta / 12U) + 2U));
if (step > kMicAgcMaxGainStepUp) {
step = kMicAgcMaxGainStepUp;
}
} else if (signal_abs_raw < kMicAgcStrongSignalPeakMin) {
step = static_cast<uint16_t>((step < 10U) ? 10U : step);
}
if (step > kMicAgcMaxGainStepUp) {
step = kMicAgcMaxGainStepUp;
}
mic_agc_gain_q8_ = static_cast<uint16_t>(mic_agc_gain_q8_ + step);
} else if ((mic_agc_gain_q8_ > (target_gain_q8 + kMicAgcGainDeadbandQ8))) {
uint16_t delta = static_cast<uint16_t>(mic_agc_gain_q8_ - target_gain_q8);
uint16_t step = static_cast<uint16_t>((delta / 10U) + 4U);
if (step < 8U) {
step = 8U;
}
if (signal_abs_raw > kMicAgcStrongSignalPeakMin) {
step = static_cast<uint16_t>((step < 24U) ? 24U : step);
}
if (gain_return_from_silence) {
step = static_cast<uint16_t>(std::max<uint16_t>(12U, (delta / 6U) + 4U));
if (step > kMicAgcMaxGainStepDown) {
step = kMicAgcMaxGainStepDown;
}
}
if (step > kMicAgcMaxGainStepDown) {
step = kMicAgcMaxGainStepDown;
}
mic_agc_gain_q8_ = static_cast<uint16_t>(mic_agc_gain_q8_ - step);
}
if (mic_agc_gain_q8_ < kMicAgcMinGainQ8) {
mic_agc_gain_q8_ = kMicAgcMinGainQ8;
} else if (mic_agc_gain_q8_ > kMicAgcMaxGainQ8) {
mic_agc_gain_q8_ = kMicAgcMaxGainQ8;
}
uint16_t peak = 0U;
for (size_t index = 0U; index < sample_count; ++index) {
int value = static_cast<int>(mic_samples_[index]);
if (value < 0) {
value = -value;
}
if (value > peak) {
peak = static_cast<uint16_t>(value);
}
}
const uint16_t noise_floor_scaled = static_cast<uint16_t>(
std::min<uint32_t>(4095U, (static_cast<uint32_t>(mic_noise_floor_raw_) * mic_agc_gain_q8_) / 256U));
const uint16_t effective_peak = (peak > noise_floor_scaled) ? static_cast<uint16_t>(peak - noise_floor_scaled) : 0U;
snapshot_.mic_peak = peak;
snapshot_.mic_noise_floor = mic_noise_floor_raw_;
snapshot_.mic_gain_percent = static_cast<uint16_t>((static_cast<uint32_t>(mic_agc_gain_q8_) * 100U) / 256U);
uint16_t freq_hz = 0U;
int16_t cents = 0;
uint8_t confidence = 0U;
estimatePitchFromSamples(mic_samples_,
sample_count,
freq_hz,
cents,
confidence);
const bool has_pitch = (confidence > 0U) && (freq_hz > 0U);
if (has_pitch) {
snapshot_.mic_freq_hz = freq_hz;
snapshot_.mic_pitch_cents = cents;
snapshot_.mic_pitch_confidence = confidence;
} else {
snapshot_.mic_freq_hz = 0U;
snapshot_.mic_pitch_cents = 0;
snapshot_.mic_pitch_confidence = 0U;
}
const uint16_t level_for_display = computeLevelPercent(effective_peak, kMicAgcMinLevelDen);
const uint16_t level_for_waveform = (effective_peak >= kMicAgcSignalDisplayPeakMin) ? level_for_display : 0U;
uint8_t level = 0U;
if (level_for_waveform > 0U) {
level = static_cast<uint8_t>(std::min<uint16_t>(100U, (static_cast<uint16_t>(snapshot_.mic_level_percent) * 3U + level_for_waveform) / 4U));
}
snapshot_.mic_level_percent = level;
snapshot_.mic_waveform[snapshot_.mic_waveform_head] = level;
snapshot_.mic_waveform_head = static_cast<uint8_t>((snapshot_.mic_waveform_head + 1U) % kMicWaveformCapacity);
if (snapshot_.mic_waveform_count < kMicWaveformCapacity) {
++snapshot_.mic_waveform_count;
}
}
void HardwareManager::updateBattery(uint32_t now_ms) {
if (!snapshot_.battery_ready) {
return;
}
if (now_ms < next_battery_ms_) {
return;
}
next_battery_ms_ = now_ms + kBatteryPeriodMs;
uint32_t total_mv = 0U;
uint8_t valid = 0U;
for (uint8_t index = 0U; index < 10U; ++index) {
const int mv = analogReadMilliVolts(FREENOVE_BAT_ADC_PIN);
if (mv <= 0) {
continue;
}
total_mv += static_cast<uint32_t>(mv);
++valid;
delayMicroseconds(120);
}
if (valid == 0U) {
return;
}
const float adc_mv = static_cast<float>(total_mv) / static_cast<float>(valid);
float cell_mv = adc_mv * FREENOVE_BAT_VOLT_MULTIPLIER + FREENOVE_BAT_VOLT_OFFSET_MV;
if (cell_mv < 0.0f) {
cell_mv = 0.0f;
}
snapshot_.battery_mv = static_cast<uint16_t>(adc_mv);
snapshot_.battery_cell_mv = static_cast<uint16_t>(cell_mv);
snapshot_.battery_percent = batteryPercentFromMv(snapshot_.battery_cell_mv);
#if FREENOVE_BAT_CHARGE_PIN >= 0
snapshot_.charging = (digitalRead(FREENOVE_BAT_CHARGE_PIN) == LOW);
#else
snapshot_.charging = false;
#endif
}
void HardwareManager::updateLed(uint32_t now_ms) {
if (!snapshot_.ws2812_ready) {
return;
}
if (now_ms < next_led_ms_) {
return;
}
next_led_ms_ = now_ms + kLedPeriodMs;
uint8_t base_r = scene_r_;
uint8_t base_g = scene_g_;
uint8_t base_b = scene_b_;
uint8_t brightness = scene_brightness_;
bool pulse = led_pulse_;
if (manual_led_) {
base_r = manual_r_;
base_g = manual_g_;
base_b = manual_b_;
brightness = manual_brightness_;
pulse = manual_pulse_;
}
if (button_flash_until_ms_ > now_ms) {
base_r = 255U;
base_g = 220U;
base_b = 120U;
brightness = 90U;
pulse = false;
}
if (!manual_led_ && button_flash_until_ms_ <= now_ms && isTunerSceneHint()) {
applyTunerLedPattern(now_ms, base_r, base_g, base_b, brightness);
return;
}
if (!manual_led_ && button_flash_until_ms_ <= now_ms && isBrokenSceneHint()) {
applyBrokenLedPattern(now_ms, base_r, base_g, base_b, brightness);
return;
}
float dim = 1.0f;
if (pulse) {
const float phase = static_cast<float>(now_ms % 1400U) / 1400.0f;
dim = 0.30f + (0.70f * (0.5f + 0.5f * std::sin(phase * kTwoPi)));
}
const uint8_t out_r = clampU8(static_cast<int>(static_cast<float>(base_r) * dim));
const uint8_t out_g = clampU8(static_cast<int>(static_cast<float>(base_g) * dim));
const uint8_t out_b = clampU8(static_cast<int>(static_cast<float>(base_b) * dim));
strip_.setBrightness(clampU8(brightness));
for (uint16_t index = 0U; index < FREENOVE_WS2812_COUNT; ++index) {
strip_.setPixelColor(index, out_r, out_g, out_b);
}
strip_.show();
snapshot_.led_r = out_r;
snapshot_.led_g = out_g;
snapshot_.led_b = out_b;
snapshot_.led_brightness = brightness;
}
bool HardwareManager::isBrokenSceneHint() const {
return (std::strcmp(snapshot_.scene_id, "SCENE_LOCKED") == 0);
}
bool HardwareManager::isTunerSceneHint() const {
return (std::strcmp(snapshot_.scene_id, "SCENE_LA_DETECT") == 0) ||
(std::strcmp(snapshot_.scene_id, "SCENE_LA_DETECTOR") == 0) ||
(std::strcmp(snapshot_.scene_id, "SCENE_SEARCH") == 0);
}
void HardwareManager::applyBrokenLedPattern(uint32_t now_ms,
uint8_t base_r,
uint8_t base_g,
uint8_t base_b,
uint8_t brightness) {
const uint16_t led_count = FREENOVE_WS2812_COUNT;
if (led_count == 0U) {
return;
}
uint8_t effective_brightness = brightness;
if (effective_brightness < 92U) {
effective_brightness = 92U;
}
if (effective_brightness > 148U) {
effective_brightness = 148U;
}
strip_.setBrightness(clampU8(effective_brightness));
uint8_t first_r = 0U;
uint8_t first_g = 0U;
uint8_t first_b = 0U;
const uint32_t slot = now_ms / 46U;
const uint32_t in_slot = now_ms % 46U;
const uint32_t slot_noise = hash32(slot * 2654435761UL + 0x9e3779b9UL);
const uint16_t primary_led = static_cast<uint16_t>(slot_noise % led_count);
const uint8_t primary_window_ms = static_cast<uint8_t>(7U + ((slot_noise >> 16) % 11U));
const bool primary_active = in_slot < primary_window_ms;
uint16_t secondary_led = primary_led;
bool secondary_active = false;
if (led_count > 1U) {
secondary_led = static_cast<uint16_t>((primary_led + 1U + ((slot_noise >> 8) % (led_count - 1U))) % led_count);
secondary_active = (((slot_noise >> 27) & 0x1U) == 1U) && (in_slot >= 24U) && (in_slot < 29U);
}
for (uint16_t index = 0U; index < led_count; ++index) {
const uint32_t led_noise = hash32(slot_noise ^ (static_cast<uint32_t>(index + 1U) * 0x27d4eb2dUL));
int out_r = 0;
int out_g = 0;
int out_b = 0;
if (primary_active && index == primary_led) {
const float attack = 1.0f - (static_cast<float>(in_slot) / static_cast<float>(primary_window_ms));
const float dim = 0.88f + 0.55f * attack;
out_r = static_cast<int>(static_cast<float>(base_r) * dim) + static_cast<int>((led_noise >> 0) & 0x2fU);
out_g = static_cast<int>(static_cast<float>(base_g) * (0.30f + 0.95f * attack)) +
static_cast<int>((led_noise >> 8) & 0x17U);
out_b = static_cast<int>(static_cast<float>(base_b) * (0.18f + 0.85f * attack)) +
static_cast<int>((led_noise >> 16) & 0x3fU);
} else if (secondary_active && index == secondary_led) {
out_r = static_cast<int>(base_r * 0.45f) + static_cast<int>((led_noise >> 8) & 0x1fU);
out_g = static_cast<int>(base_g * 0.28f) + static_cast<int>((led_noise >> 16) & 0x0fU);
out_b = static_cast<int>(base_b * 0.40f) + static_cast<int>((led_noise >> 24) & 0x2fU);
} else {
const bool ghost = (((led_noise + slot + index * 5U) % 23U) == 0U) && (in_slot < 3U);
if (ghost) {
out_r = static_cast<int>(base_r * 0.12f);
out_g = static_cast<int>(base_g * 0.08f);
out_b = static_cast<int>(base_b * 0.20f) + 26;
}
}
const uint8_t final_r = clampU8(out_r);
const uint8_t final_g = clampU8(out_g);
const uint8_t final_b = clampU8(out_b);
strip_.setPixelColor(index, final_r, final_g, final_b);
if (index == 0U) {
first_r = final_r;
first_g = final_g;
first_b = final_b;
}
}
strip_.show();
snapshot_.led_r = first_r;
snapshot_.led_g = first_g;
snapshot_.led_b = first_b;
snapshot_.led_brightness = effective_brightness;
}
void HardwareManager::applyTunerLedPattern(uint32_t now_ms,
uint8_t base_r,
uint8_t base_g,
uint8_t base_b,
uint8_t brightness) {
(void)base_r;
(void)base_g;
(void)base_b;
const uint16_t led_count = FREENOVE_WS2812_COUNT;
if (led_count == 0U) {
return;
}
uint8_t first_r = 0U;
uint8_t first_g = 0U;
uint8_t first_b = 0U;
uint8_t tuned_brightness = brightness;
if (tuned_brightness < 56U) {
tuned_brightness = 56U;
} else if (tuned_brightness > 136U) {
tuned_brightness = 136U;
}
strip_.setBrightness(tuned_brightness);
auto setLedScaled = [&](uint16_t index, uint8_t red, uint8_t green, uint8_t blue, float scale) {
if (index >= led_count || scale <= 0.01f) {
return;
}
if (scale > 1.0f) {
scale = 1.0f;
}
const uint8_t out_r = clampU8(static_cast<int>(static_cast<float>(red) * scale));
const uint8_t out_g = clampU8(static_cast<int>(static_cast<float>(green) * scale));
const uint8_t out_b = clampU8(static_cast<int>(static_cast<float>(blue) * scale));
strip_.setPixelColor(index, out_r, out_g, out_b);
if (index == 0U) {
first_r = out_r;
first_g = out_g;
first_b = out_b;
}
};
for (uint16_t index = 0U; index < led_count; ++index) {
strip_.setPixelColor(index, 0, 0, 0);
}
// No signal/noise state: keep all tuner LEDs off as requested.
const bool has_signal =
(snapshot_.mic_level_percent >= kTunerDisplayMinLevelPct) &&
(snapshot_.mic_peak >= kMicAgcSignalDisplayPeakMin) &&
((snapshot_.mic_pitch_confidence >= (kTunerDisplayMinConfidence / 2U)) || (snapshot_.mic_freq_hz > 0U));
if (!has_signal) {
strip_.show();
snapshot_.led_r = first_r;
snapshot_.led_g = first_g;
snapshot_.led_b = first_b;
snapshot_.led_brightness = tuned_brightness;
return;
}
const uint32_t slot = now_ms / 56U;
const float pulse = 0.84f + 0.16f * std::sin(static_cast<float>(slot % 180U) * (kTwoPi / 180.0f));
const float delta_hz = static_cast<float>(snapshot_.mic_freq_hz) - kTunerReferenceHz;
const float abs_delta_hz = std::fabs(delta_hz);
auto lerp_u8 = [](uint8_t a, uint8_t b, float t) -> uint8_t {
if (t < 0.0f) {
t = 0.0f;
} else if (t > 1.0f) {
t = 1.0f;
}
const float value = static_cast<float>(a) + (static_cast<float>(b) - static_cast<float>(a)) * t;
return clampU8(static_cast<int>(value));
};
// Logical tuner map aligned with UI guidance text:
// - "MONTE EN FREQUENCE" (delta < 0) drives the ascend side (near+extreme).
// - "DESCENDS EN FREQUENCE" (delta > 0) drives the descend side (near+extreme).
const uint16_t idx_descend_extreme = 0U;
const uint16_t idx_ascend_extreme = led_count - 1U;
const uint16_t idx_descend_near = (led_count >= 4U) ? 1U : idx_descend_extreme;
const uint16_t idx_ascend_near = (led_count >= 4U) ? (led_count - 2U) : idx_ascend_extreme;
const bool in_tune_center = (abs_delta_hz <= 1.8f);
if (in_tune_center) {
setLedScaled(idx_descend_near, 24U, 255U, 88U, pulse);
setLedScaled(idx_ascend_near, 24U, 255U, 88U, pulse);
setLedScaled(idx_descend_extreme, 255U, 64U, 0U, 0.05f);
if (idx_ascend_extreme != idx_descend_extreme) {
setLedScaled(idx_ascend_extreme, 255U, 64U, 0U, 0.05f);
}
} else {
const float ratio = std::fmin(1.0f, abs_delta_hz / 10.0f);
const float near_scale = 0.24f + 0.76f * std::fmin(1.0f, abs_delta_hz / 6.0f);
const float extreme_scale = 0.14f + 0.86f * ratio;
if (delta_hz < 0.0f) {
const uint8_t near_r = lerp_u8(30U, 255U, ratio);
const uint8_t near_g = lerp_u8(255U, 110U, ratio);
const uint8_t extreme_g = lerp_u8(120U, 0U, ratio);
setLedScaled(idx_ascend_near, near_r, near_g, 0U, near_scale);
setLedScaled(idx_ascend_extreme, 255U, extreme_g, 0U, extreme_scale);
setLedScaled(idx_descend_near, 24U, 255U, 88U, 0.10f);
} else {
const uint8_t near_r = lerp_u8(30U, 255U, ratio);
const uint8_t near_g = lerp_u8(255U, 110U, ratio);
const uint8_t extreme_g = lerp_u8(120U, 0U, ratio);
setLedScaled(idx_descend_near, near_r, near_g, 0U, near_scale);
setLedScaled(idx_descend_extreme, 255U, extreme_g, 0U, extreme_scale);
setLedScaled(idx_ascend_near, 24U, 255U, 88U, 0.10f);
}
}
if (led_count == 1U) {
if (in_tune_center) {
setLedScaled(0U, 24U, 255U, 88U, pulse);
} else {
setLedScaled(0U, 255U, 42U, 0U, 0.95f);
}
} else if (led_count == 2U) {
if (in_tune_center) {
setLedScaled(0U, 24U, 255U, 88U, pulse);
setLedScaled(1U, 24U, 255U, 88U, pulse);
} else if (delta_hz < 0.0f) {
setLedScaled(0U, 255U, 42U, 0U, 0.95f);
setLedScaled(1U, 255U, 180U, 0U, 0.55f);
} else {
setLedScaled(1U, 255U, 42U, 0U, 0.95f);
setLedScaled(0U, 255U, 180U, 0U, 0.55f);
}
}
strip_.show();
snapshot_.led_r = first_r;
snapshot_.led_g = first_g;
snapshot_.led_b = first_b;
snapshot_.led_brightness = tuned_brightness;
}
void HardwareManager::estimatePitch(uint16_t& freq_hz, int16_t& cents, uint8_t& confidence, uint16_t& peak_for_window) {
freq_hz = snapshot_.mic_freq_hz;
cents = snapshot_.mic_pitch_cents;
confidence = snapshot_.mic_pitch_confidence;
peak_for_window = snapshot_.mic_peak;
}
void HardwareManager::estimatePitchFromSamples(const int16_t* samples,
size_t sample_count,
uint16_t& out_freq,
int16_t& out_cents,
uint8_t& out_confidence) {
out_freq = 0U;
out_cents = 0;
out_confidence = 0U;
if (samples == nullptr || sample_count < 64U) {
return;
}
if (sample_count > kMicReadSamples) {
sample_count = kMicReadSamples;
}
int32_t sum = 0;
int16_t peak_sample = 0;
for (size_t index = 0U; index < sample_count; ++index) {
const int16_t sample = samples[index];
sum += sample;
const int16_t abs_sample = static_cast<int16_t>((sample < 0) ? -sample : sample);
if (abs_sample > peak_sample) {
peak_sample = abs_sample;
}
}
const float zero_reference = static_cast<float>(sum) / static_cast<float>(sample_count);
const uint16_t peak_for_window = static_cast<uint16_t>(peak_sample > 0 ? peak_sample : 0);
if (peak_for_window < 260U) {
return;
}
pitch_energy_prefix_[0] = 0.0f;
for (size_t index = 0U; index < sample_count; ++index) {
const float value = static_cast<float>(samples[index]) - zero_reference;
pitch_centered_[index] = value;
pitch_energy_prefix_[index + 1U] = pitch_energy_prefix_[index] + (value * value);
}
const uint16_t detect_min_hz = std::max<uint16_t>(kTunerMinHz, kLaDetectMinHz);
const uint16_t detect_max_hz = std::min<uint16_t>(kTunerMaxHz, kLaDetectMaxHz);
const int lag_min = static_cast<int>(kMicSampleRate / detect_max_hz);
int lag_max = static_cast<int>(kMicSampleRate / detect_min_hz);
if (lag_max > static_cast<int>(sample_count) - 8) {
lag_max = static_cast<int>(sample_count) - 8;
}
if (lag_min < 2 || lag_max <= lag_min) {
return;
}
std::fill_n(pitch_corr_by_lag_, kMicReadSamples + 1U, 0.0f);
int best_lag = 0;
float best_corr = -1.0f;
int second_lag = 0;
float second_corr = -1.0f;
for (int lag = lag_min; lag <= lag_max; ++lag) {
const size_t count = sample_count - static_cast<size_t>(lag);
float numerator = 0.0f;
for (size_t index = 0U; index < count; ++index) {
numerator += pitch_centered_[index] * pitch_centered_[index + static_cast<size_t>(lag)];
}
const float energy_a = pitch_energy_prefix_[count] - pitch_energy_prefix_[0];
const float energy_b = pitch_energy_prefix_[sample_count] - pitch_energy_prefix_[static_cast<size_t>(lag)];
if (energy_a <= 1.0f || energy_b <= 1.0f) {
continue;
}
const float denom = std::sqrt(energy_a * energy_b);
if (denom <= 1.0f) {
continue;
}
const float corr = numerator / denom;
pitch_corr_by_lag_[lag] = corr;
if (corr > best_corr) {
second_corr = best_corr;
second_lag = best_lag;
best_corr = corr;
best_lag = lag;
} else if (corr > second_corr) {
second_corr = corr;
second_lag = lag;
}
}
if (best_lag <= 0 || best_corr < 0.10f) {
return;
}
float refined_lag = static_cast<float>(best_lag);
if (best_lag > lag_min && best_lag < lag_max) {
const float y1 = pitch_corr_by_lag_[best_lag - 1];
const float y2 = pitch_corr_by_lag_[best_lag];
const float y3 = pitch_corr_by_lag_[best_lag + 1];
const float denom = (y1 - (2.0f * y2) + y3);
if (std::fabs(denom) > 0.0001f) {
float shift = 0.5f * (y1 - y3) / denom;
if (shift > 0.5f) {
shift = 0.5f;
} else if (shift < -0.5f) {
shift = -0.5f;
}
refined_lag += shift;
}
}
if (refined_lag <= 1.0f) {
return;
}
const float raw_freq = static_cast<float>(kMicSampleRate) / refined_lag;
if (raw_freq < static_cast<float>(kTunerMinHz) || raw_freq > static_cast<float>(kTunerMaxHz)) {
return;
}
if (raw_freq < static_cast<float>(kLaDetectMinHz) || raw_freq > static_cast<float>(kLaDetectMaxHz)) {
return;
}
const float corr_strength = std::max(0.0f, std::min(1.0f, best_corr));
float separation = best_corr - second_corr;
if (second_lag == 0 || separation < 0.0f) {
separation = 0.0f;
}
const float sep_strength = std::max(0.0f, std::min(1.0f, separation * 4.5f));
const float amp_strength = std::max(0.0f, std::min(1.0f, static_cast<float>(peak_for_window) / 24000.0f));
const uint8_t confidence = static_cast<uint8_t>(
std::round((corr_strength * 0.62f + sep_strength * 0.26f + amp_strength * 0.12f) * 100.0f));
if (confidence < 8U) {
return;
}
const float cents = 1200.0f * std::log2(raw_freq / kTunerReferenceHz);
if (!std::isfinite(cents)) {
return;
}
out_freq = static_cast<uint16_t>(raw_freq);
out_cents = static_cast<int16_t>(std::round(cents));
out_confidence = confidence;
}
void HardwareManager::setScenePalette(const char* scene_id) {
if (scene_id == nullptr || scene_id[0] == '\0') {
scene_id = "ZACUS_U-SON";
}
std::strncpy(snapshot_.scene_id, scene_id, sizeof(snapshot_.scene_id) - 1U);
snapshot_.scene_id[sizeof(snapshot_.scene_id) - 1U] = '\0';
const LedPaletteEntry* palette = findPaletteForScene(scene_id);
if (palette == nullptr) {
scene_r_ = 50U;
scene_g_ = 122U;
scene_b_ = 255U;
scene_brightness_ = kDefaultLedBrightness;
led_pulse_ = true;
return;
}
scene_r_ = palette->r;
scene_g_ = palette->g;
scene_b_ = palette->b;
scene_brightness_ = palette->brightness;
led_pulse_ = palette->pulse;
}
const HardwareManager::LedPaletteEntry* HardwareManager::findPaletteForScene(const char* scene_id) const {
if (scene_id == nullptr || scene_id[0] == '\0') {
return &kLedPalette[(sizeof(kLedPalette) / sizeof(kLedPalette[0])) - 1U];
}
for (size_t index = 0U; index < (sizeof(kLedPalette) / sizeof(kLedPalette[0])); ++index) {
const LedPaletteEntry& entry = kLedPalette[index];
if (std::strcmp(entry.scene_id, "__DEFAULT__") == 0) {
continue;
}
if (std::strcmp(entry.scene_id, scene_id) == 0) {
return &entry;
}
}
return &kLedPalette[(sizeof(kLedPalette) / sizeof(kLedPalette[0])) - 1U];
}
uint8_t HardwareManager::batteryPercentFromMv(uint16_t cell_mv) const {
const int min_mv = static_cast<int>(FREENOVE_BAT_VOLTAGE_MIN * 1000.0f);
const int max_mv = static_cast<int>(FREENOVE_BAT_VOLTAGE_MAX * 1000.0f);
if (cell_mv <= min_mv) {
return 0U;
}
if (cell_mv >= max_mv) {
return 100U;
}
return static_cast<uint8_t>((static_cast<uint32_t>(cell_mv - min_mv) * 100U) /
static_cast<uint32_t>(max_mv - min_mv));
}
uint8_t HardwareManager::clampColor(int value) {
return clampU8(value);
}
-328
View File
@@ -1,328 +0,0 @@
// media_manager.cpp - media catalog + playback + simulated recorder hooks.
#include "media_manager.h"
#include <ArduinoJson.h>
#include <FS.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
#include "audio_manager.h"
namespace {
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
char toLowerAscii(char ch) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
for (;; ++index) {
const char l = lhs[index];
const char r = rhs[index];
if (l == '\0' && r == '\0') {
return true;
}
if (toLowerAscii(l) != toLowerAscii(r)) {
return false;
}
}
}
} // namespace
bool MediaManager::begin(const Config& config) {
config_ = config;
copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
if (config_.record_max_seconds == 0U) {
config_.record_max_seconds = 30U;
}
if (config_.record_max_seconds > 1800U) {
config_.record_max_seconds = 1800U;
}
snapshot_ = Snapshot();
snapshot_.ready = true;
snapshot_.record_limit_seconds = config_.record_max_seconds;
copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
ensureDir(config_.music_dir);
ensureDir(config_.picture_dir);
ensureDir(config_.record_dir);
return true;
}
void MediaManager::update(uint32_t now_ms, AudioManager* audio) {
if (audio != nullptr && snapshot_.playing && !audio->isPlaying()) {
snapshot_.playing = false;
snapshot_.playing_path[0] = '\0';
}
if (snapshot_.recording) {
const uint32_t elapsed_ms = now_ms - snapshot_.record_started_ms;
const uint16_t elapsed_seconds = static_cast<uint16_t>(elapsed_ms / 1000U);
snapshot_.record_elapsed_seconds = elapsed_seconds;
if (snapshot_.record_limit_seconds > 0U && elapsed_seconds >= snapshot_.record_limit_seconds) {
stopRecording();
}
}
}
void MediaManager::noteStepChange() {
if (config_.auto_stop_record_on_step_change && snapshot_.recording) {
stopRecording();
}
}
bool MediaManager::listFiles(const char* kind, String* out_json) const {
if (out_json == nullptr) {
return false;
}
out_json->remove(0);
const String dir = resolveKindDir(kind);
if (dir.isEmpty()) {
return false;
}
if (!LittleFS.exists(dir.c_str())) {
*out_json = "[]";
return true;
}
File folder = LittleFS.open(dir.c_str(), "r");
if (!folder || !folder.isDirectory()) {
return false;
}
DynamicJsonDocument document(2048);
JsonArray files = document.to<JsonArray>();
File entry = folder.openNextFile();
while (entry) {
if (!entry.isDirectory()) {
String path = entry.name();
if (!path.startsWith("/")) {
path = "/" + path;
}
files.add(path);
}
entry.close();
entry = folder.openNextFile();
}
serializeJson(files, *out_json);
folder.close();
return true;
}
bool MediaManager::play(const char* path, AudioManager* audio) {
if (audio == nullptr || path == nullptr || path[0] == '\0') {
setLastError("media_play_invalid_args");
return false;
}
String normalized_path = path;
normalized_path.trim();
if (normalized_path.isEmpty()) {
setLastError("media_play_empty_path");
return false;
}
if (!normalized_path.startsWith("/")) {
normalized_path = String(config_.music_dir) + "/" + normalized_path;
}
const bool ok = audio->play(normalized_path.c_str());
snapshot_.playing = ok;
if (ok) {
copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
clearLastError();
} else {
setLastError("media_play_failed");
}
return ok;
}
bool MediaManager::stop(AudioManager* audio) {
if (audio != nullptr) {
audio->stop();
}
snapshot_.playing = false;
snapshot_.playing_path[0] = '\0';
clearLastError();
return true;
}
bool MediaManager::startRecording(uint16_t seconds, const char* filename_hint) {
if (seconds == 0U) {
seconds = config_.record_max_seconds;
}
if (seconds > config_.record_max_seconds) {
seconds = config_.record_max_seconds;
}
if (seconds == 0U) {
seconds = 1U;
}
if (snapshot_.recording) {
setLastError("recorder_already_running");
return false;
}
if (!ensureDir(config_.record_dir)) {
setLastError("recorder_dir_missing");
return false;
}
const String filename = sanitizeFilename(filename_hint, "record", ".wav");
const String path = String(config_.record_dir) + "/" + filename;
if (!writeEmptyWav(path.c_str())) {
setLastError("recorder_create_failed");
return false;
}
snapshot_.recording = true;
snapshot_.record_limit_seconds = seconds;
snapshot_.record_started_ms = millis();
snapshot_.record_elapsed_seconds = 0U;
copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
clearLastError();
return true;
}
bool MediaManager::stopRecording() {
if (!snapshot_.recording) {
return true;
}
const uint32_t elapsed_ms = millis() - snapshot_.record_started_ms;
snapshot_.record_elapsed_seconds = static_cast<uint16_t>(elapsed_ms / 1000U);
snapshot_.recording = false;
clearLastError();
return true;
}
MediaManager::Snapshot MediaManager::snapshot() const {
return snapshot_;
}
void MediaManager::setLastError(const char* message) {
snapshot_.last_ok = false;
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void MediaManager::clearLastError() {
snapshot_.last_ok = true;
snapshot_.last_error[0] = '\0';
}
String MediaManager::normalizeDir(const char* path) const {
if (path == nullptr || path[0] == '\0') {
return String("/");
}
String normalized = path;
normalized.trim();
if (normalized.isEmpty()) {
return String("/");
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
if (normalized.length() > 1U && normalized.endsWith("/")) {
normalized.remove(normalized.length() - 1U);
}
return normalized;
}
String MediaManager::resolveKindDir(const char* kind) const {
if (kind == nullptr) {
return String();
}
if (equalsIgnoreCase(kind, "picture") || equalsIgnoreCase(kind, "pictures")) {
return config_.picture_dir;
}
if (equalsIgnoreCase(kind, "music") || equalsIgnoreCase(kind, "audio")) {
return config_.music_dir;
}
if (equalsIgnoreCase(kind, "recorder") || equalsIgnoreCase(kind, "record") || equalsIgnoreCase(kind, "records")) {
return config_.record_dir;
}
return String();
}
String MediaManager::sanitizeFilename(const char* hint, const char* default_prefix, const char* extension) const {
String filename = (hint != nullptr) ? hint : "";
filename.trim();
if (filename.isEmpty()) {
filename = default_prefix;
filename += "_";
filename += String(static_cast<unsigned long>(millis()));
}
for (size_t index = 0U; index < filename.length(); ++index) {
const char ch = filename[index];
const bool keep = std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-' || ch == '.';
if (!keep) {
filename.setCharAt(index, '_');
}
}
if (extension != nullptr && extension[0] != '\0' && !filename.endsWith(extension)) {
filename += extension;
}
return filename;
}
bool MediaManager::ensureDir(const char* path) const {
const String normalized = normalizeDir(path);
if (normalized.isEmpty()) {
return false;
}
if (LittleFS.exists(normalized.c_str())) {
return true;
}
return LittleFS.mkdir(normalized.c_str());
}
bool MediaManager::writeEmptyWav(const char* path) const {
if (path == nullptr || path[0] == '\0') {
return false;
}
File file = LittleFS.open(path, "w");
if (!file) {
return false;
}
const uint32_t sample_rate = 16000UL;
const uint16_t channels = 1U;
const uint16_t bits_per_sample = 16U;
const uint32_t data_size = 0UL;
const uint32_t byte_rate = sample_rate * channels * (bits_per_sample / 8U);
const uint16_t block_align = channels * (bits_per_sample / 8U);
const uint32_t chunk_size = 36UL + data_size;
file.write(reinterpret_cast<const uint8_t*>("RIFF"), 4U);
file.write(reinterpret_cast<const uint8_t*>(&chunk_size), sizeof(chunk_size));
file.write(reinterpret_cast<const uint8_t*>("WAVE"), 4U);
file.write(reinterpret_cast<const uint8_t*>("fmt "), 4U);
const uint32_t fmt_size = 16UL;
const uint16_t audio_format = 1U;
file.write(reinterpret_cast<const uint8_t*>(&fmt_size), sizeof(fmt_size));
file.write(reinterpret_cast<const uint8_t*>(&audio_format), sizeof(audio_format));
file.write(reinterpret_cast<const uint8_t*>(&channels), sizeof(channels));
file.write(reinterpret_cast<const uint8_t*>(&sample_rate), sizeof(sample_rate));
file.write(reinterpret_cast<const uint8_t*>(&byte_rate), sizeof(byte_rate));
file.write(reinterpret_cast<const uint8_t*>(&block_align), sizeof(block_align));
file.write(reinterpret_cast<const uint8_t*>(&bits_per_sample), sizeof(bits_per_sample));
file.write(reinterpret_cast<const uint8_t*>("data"), 4U);
file.write(reinterpret_cast<const uint8_t*>(&data_size), sizeof(data_size));
file.close();
return true;
}
@@ -1,981 +0,0 @@
// network_manager.cpp - WiFi + ESP-NOW runtime helpers for Freenove all-in-one.
#include "network_manager.h"
#include <ArduinoJson.h>
#include <WiFi.h>
#include <esp_now.h>
#include <cctype>
#include <cstring>
namespace {
NetworkManager* g_network_instance = nullptr;
bool timeReached(uint32_t now_ms, uint32_t target_ms) {
return static_cast<int32_t>(now_ms - target_ms) >= 0;
}
bool isBroadcastMac(const uint8_t mac[6]) {
if (mac == nullptr) {
return false;
}
for (uint8_t index = 0U; index < 6U; ++index) {
if (mac[index] != 0xFFU) {
return false;
}
}
return true;
}
const char* inferEnvelopeType(const char* payload) {
if (payload == nullptr || payload[0] == '\0') {
return "empty";
}
if (std::strncmp(payload, "SC_EVENT", 8U) == 0 || std::strncmp(payload, "SERIAL:", 7U) == 0 ||
std::strncmp(payload, "TIMER:", 6U) == 0 || std::strncmp(payload, "ACTION:", 7U) == 0 ||
std::strcmp(payload, "UNLOCK") == 0 || std::strcmp(payload, "AUDIO_DONE") == 0) {
return "story_event";
}
if (payload[0] == '{' || payload[0] == '[') {
return "json";
}
return "text";
}
bool looksLikeEspNowEnvelope(JsonVariantConst root) {
if (!root.is<JsonObjectConst>()) {
return false;
}
JsonObjectConst object = root.as<JsonObjectConst>();
return object["msg_id"].is<const char*>() && object["seq"].is<uint32_t>() && object["type"].is<const char*>() &&
object.containsKey("payload") && object["ack"].is<bool>();
}
} // namespace
bool NetworkManager::begin(const char* hostname) {
if (started_) {
return true;
}
WiFi.persistent(false);
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.mode(WIFI_STA);
if (hostname != nullptr && hostname[0] != '\0') {
WiFi.setHostname(hostname);
}
g_network_instance = this;
started_ = true;
refreshSnapshot();
Serial.printf("[NET] wifi ready hostname=%s\n", (hostname != nullptr) ? hostname : "none");
return true;
}
void NetworkManager::update(uint32_t now_ms) {
if (!started_) {
return;
}
refreshEspNowConnection(now_ms);
const bool connected_to_local = isConnectedToLocalTarget();
const bool was_retry_paused = local_retry_paused_;
bool force_refresh = false;
if (sta_connecting_) {
if (WiFi.status() == WL_CONNECTED) {
sta_connecting_ = false;
force_refresh = true;
} else if ((now_ms - sta_connect_requested_at_ms_) >= kStaConnectTimeoutMs) {
sta_connecting_ = false;
force_refresh = true;
}
}
const bool should_force_fallback = shouldForceFallbackAp();
if (should_force_fallback && !fallback_ap_active_ && fallback_ap_ssid_[0] != '\0') {
fallback_ap_active_ = startApInternal(fallback_ap_ssid_, fallback_ap_password_, false);
force_refresh = true;
} else if (!should_force_fallback && fallback_ap_active_ && !manual_ap_active_) {
WiFi.softAPdisconnect(true);
fallback_ap_active_ = false;
WiFi.mode(WIFI_STA);
force_refresh = true;
}
const bool should_retry_local = local_target_ssid_[0] != '\0' &&
(force_ap_if_not_local_ ? !connected_to_local : (WiFi.status() != WL_CONNECTED));
const uint8_t ap_clients = (fallback_ap_active_ && !manual_ap_active_) ? WiFi.softAPgetStationNum() : 0U;
local_retry_paused_ =
should_retry_local && fallback_ap_active_ && pause_local_retry_when_ap_client_ && (ap_clients > 0U);
if (local_retry_paused_ != was_retry_paused) {
force_refresh = true;
}
if (should_retry_local) {
if (local_retry_paused_) {
if (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_)) {
next_local_retry_at_ms_ = now_ms + local_retry_ms_;
Serial.printf("[NET] local retry paused ap_clients=%u\n", ap_clients);
}
} else if (!sta_connecting_ && (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_))) {
if (fallback_ap_active_ && equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
// Avoid self-association when fallback AP and local target share the same SSID.
WiFi.softAPdisconnect(true);
fallback_ap_active_ = false;
WiFi.mode(WIFI_STA);
Serial.println("[NET] local retry paused fallback AP (same ssid)");
}
const bool started = connectSta(local_target_ssid_, local_target_password_);
next_local_retry_at_ms_ = now_ms + local_retry_ms_;
force_refresh = true;
Serial.printf("[NET] local retry target=%s started=%u\n", local_target_ssid_, started ? 1U : 0U);
}
} else {
next_local_retry_at_ms_ = 0U;
local_retry_paused_ = false;
}
if (!force_refresh && (now_ms - last_refresh_ms_) < 350U) {
return;
}
last_refresh_ms_ = now_ms;
refreshSnapshot();
}
void NetworkManager::configureFallbackAp(const char* ssid, const char* password) {
if (ssid != nullptr && ssid[0] != '\0') {
copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
}
if (password != nullptr && password[0] != '\0') {
copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
}
Serial.printf("[NET] fallback AP configured ssid=%s\n", fallback_ap_ssid_);
}
void NetworkManager::configureLocalPolicy(const char* ssid,
const char* password,
bool force_if_not_local,
uint32_t retry_ms,
bool pause_retry_when_ap_client) {
if (ssid != nullptr && ssid[0] != '\0') {
copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
}
if (password != nullptr && password[0] != '\0') {
copyText(local_target_password_, sizeof(local_target_password_), password);
}
force_ap_if_not_local_ = force_if_not_local;
pause_local_retry_when_ap_client_ = pause_retry_when_ap_client;
if (retry_ms >= 1000U) {
local_retry_ms_ = retry_ms;
}
local_retry_paused_ = false;
next_local_retry_at_ms_ = 0U;
refreshSnapshot();
Serial.printf("[NET] local policy target=%s force_ap_if_not_local=%u retry_ms=%lu pause_retry_on_ap_client=%u\n",
local_target_ssid_,
force_ap_if_not_local_ ? 1U : 0U,
static_cast<unsigned long>(local_retry_ms_),
pause_local_retry_when_ap_client_ ? 1U : 0U);
}
bool NetworkManager::connectSta(const char* ssid, const char* password) {
if (!started_ && !begin(nullptr)) {
return false;
}
if (ssid == nullptr || ssid[0] == '\0') {
return false;
}
if (WiFi.status() == WL_CONNECTED && equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
sta_connecting_ = false;
refreshSnapshot();
return true;
}
const uint8_t mode = (manual_ap_active_ || fallback_ap_active_) ? WIFI_MODE_APSTA : WIFI_MODE_STA;
WiFi.mode(static_cast<wifi_mode_t>(mode));
WiFi.begin(ssid, (password != nullptr) ? password : "");
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
sta_connecting_ = true;
sta_connect_requested_at_ms_ = millis();
refreshSnapshot();
Serial.printf("[NET] wifi connect requested ssid=%s\n", ssid);
return true;
}
void NetworkManager::disconnectSta() {
if (!started_) {
return;
}
WiFi.disconnect(true, false);
sta_connecting_ = false;
local_retry_paused_ = false;
next_local_retry_at_ms_ = 0U;
snapshot_.sta_ssid[0] = '\0';
if (shouldForceFallbackAp() && !manual_ap_active_ && fallback_ap_ssid_[0] != '\0') {
fallback_ap_active_ = startApInternal(fallback_ap_ssid_, fallback_ap_password_, false);
}
refreshSnapshot();
Serial.println("[NET] wifi disconnected");
}
bool NetworkManager::startAp(const char* ssid, const char* password) {
return startApInternal(ssid, password, true);
}
bool NetworkManager::isConnectedToLocalTarget() const {
if (local_target_ssid_[0] == '\0' || WiFi.status() != WL_CONNECTED) {
return false;
}
if (!equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
return false;
}
return !isConnectedToSelfAp();
}
bool NetworkManager::isConnectedToSelfAp() const {
if (WiFi.status() != WL_CONNECTED) {
return false;
}
const uint8_t* sta_bssid = WiFi.BSSID();
if (sta_bssid == nullptr) {
return false;
}
uint8_t ap_mac[6] = {0};
WiFi.softAPmacAddress(ap_mac);
return std::memcmp(sta_bssid, ap_mac, 6U) == 0;
}
bool NetworkManager::shouldForceFallbackAp() const {
if (manual_ap_active_ || fallback_ap_ssid_[0] == '\0') {
return false;
}
if (force_ap_if_not_local_ && local_target_ssid_[0] != '\0') {
if (sta_connecting_) {
return false;
}
return !isConnectedToLocalTarget();
}
if (sta_connecting_) {
return false;
}
return WiFi.status() != WL_CONNECTED;
}
bool NetworkManager::startApInternal(const char* ssid, const char* password, bool manual_request) {
if (!started_ && !begin(nullptr)) {
return false;
}
if (ssid == nullptr || ssid[0] == '\0') {
return false;
}
if (password != nullptr && password[0] != '\0' && std::strlen(password) < 8U) {
Serial.println("[NET] AP password must be >= 8 chars");
return false;
}
WiFi.mode(WIFI_AP_STA);
bool ok = false;
if (password != nullptr && password[0] != '\0') {
ok = WiFi.softAP(ssid, password);
} else {
ok = WiFi.softAP(ssid);
}
if (ok) {
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
if (manual_request) {
manual_ap_active_ = true;
fallback_ap_active_ = false;
} else {
fallback_ap_active_ = true;
}
}
refreshSnapshot();
Serial.printf("[NET] AP %s ssid=%s mode=%s\n",
ok ? "on" : "failed",
ssid,
manual_request ? "manual" : "fallback");
return ok;
}
void NetworkManager::stopAp() {
if (!started_) {
return;
}
WiFi.softAPdisconnect(true);
manual_ap_active_ = false;
fallback_ap_active_ = false;
local_retry_paused_ = false;
if (WiFi.status() == WL_CONNECTED || sta_connecting_) {
WiFi.mode(WIFI_STA);
}
snapshot_.ap_ssid[0] = '\0';
refreshSnapshot();
Serial.println("[NET] AP off");
}
bool NetworkManager::enableEspNow() {
if (!started_ && !begin(nullptr)) {
return false;
}
if (espnow_enabled_) {
return true;
}
if (WiFi.getMode() == WIFI_MODE_NULL) {
WiFi.mode(WIFI_STA);
}
if (esp_now_init() != ESP_OK) {
Serial.println("[NET] esp_now_init failed");
return false;
}
esp_now_register_recv_cb(onEspNowRecv);
esp_now_register_send_cb(onEspNowSend);
espnow_enabled_ = true;
last_espnow_refresh_ms_ = millis();
espnow_refresh_fault_ = false;
refreshSnapshot();
Serial.println("[NET] ESP-NOW ready");
return true;
}
void NetworkManager::disableEspNow() {
if (!espnow_enabled_) {
return;
}
esp_now_deinit();
espnow_enabled_ = false;
peer_cache_count_ = 0U;
rx_queue_head_ = 0U;
rx_queue_tail_ = 0U;
rx_queue_count_ = 0U;
last_espnow_refresh_ms_ = 0U;
espnow_refresh_fault_ = false;
refreshSnapshot();
Serial.println("[NET] ESP-NOW off");
}
bool NetworkManager::parseMac(const char* text, uint8_t out_mac[6]) const {
if (text == nullptr || out_mac == nullptr) {
return false;
}
char compact[13] = {0};
size_t cursor = 0U;
for (size_t index = 0U; text[index] != '\0'; ++index) {
const char ch = text[index];
if (std::isxdigit(static_cast<unsigned char>(ch))) {
if (cursor >= 12U) {
return false;
}
compact[cursor++] = ch;
continue;
}
if (ch == ':' || ch == '-' || ch == ' ') {
continue;
}
return false;
}
if (cursor != 12U) {
return false;
}
bool ok = true;
for (uint8_t idx = 0U; idx < 6U; ++idx) {
out_mac[idx] = parseHexByte(compact[idx * 2U], compact[idx * 2U + 1U], &ok);
if (!ok) {
return false;
}
}
return true;
}
bool NetworkManager::addEspNowPeer(const char* mac_text) {
if (mac_text == nullptr || mac_text[0] == '\0') {
return false;
}
if (!ensureEspNowReady()) {
return false;
}
uint8_t mac[6] = {0};
if (!parseMac(mac_text, mac)) {
return false;
}
if (!addEspNowPeerInternal(mac)) {
return false;
}
cachePeer(mac);
refreshSnapshot();
return true;
}
bool NetworkManager::removeEspNowPeer(const char* mac_text) {
if (mac_text == nullptr || mac_text[0] == '\0') {
return false;
}
if (!ensureEspNowReady()) {
return false;
}
uint8_t mac[6] = {0};
if (!parseMac(mac_text, mac)) {
return false;
}
if (!removeEspNowPeerInternal(mac)) {
return false;
}
forgetPeer(mac);
refreshSnapshot();
return true;
}
uint8_t NetworkManager::espNowPeerCount() const {
return peer_cache_count_;
}
bool NetworkManager::espNowPeerAt(uint8_t index, char* out_mac, size_t out_capacity) const {
if (out_mac == nullptr || out_capacity == 0U || index >= peer_cache_count_) {
return false;
}
copyText(out_mac, out_capacity, peer_cache_[index]);
return true;
}
bool NetworkManager::sendEspNowText(const uint8_t mac[6], const char* text) {
if (!ensureEspNowReady()) {
return false;
}
if (mac == nullptr || text == nullptr || text[0] == '\0') {
return false;
}
const size_t payload_len = std::strlen(text);
if (payload_len == 0U || payload_len > kEspNowFrameCapacity) {
Serial.printf("[NET] ESP-NOW payload too large: %u bytes\n", static_cast<unsigned int>(payload_len));
return false;
}
if (!isBroadcastMac(mac)) {
if (!addEspNowPeerInternal(mac)) {
if (ensureEspNowReady() && addEspNowPeerInternal(mac)) {
// recovered
} else {
Serial.println("[NET] ESP-NOW add peer failed");
return false;
}
}
} else {
// ESP-NOW broadcast still needs an explicit peer on some SDK versions.
if (!addEspNowPeerInternal(mac)) {
if (!(ensureEspNowReady() && addEspNowPeerInternal(mac))) {
Serial.println("[NET] ESP-NOW add peer failed");
}
}
}
esp_err_t err = esp_now_send(mac,
reinterpret_cast<const uint8_t*>(text),
payload_len);
if (err == ESP_ERR_ESPNOW_NOT_INIT) {
// WiFi mode switches can deinit ESP-NOW internally: recover once, then retry the same payload.
espnow_enabled_ = false;
if (enableEspNow()) {
addEspNowPeerInternal(mac);
err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(text), payload_len);
}
}
if (err != ESP_OK) {
++espnow_tx_fail_;
Serial.printf("[NET] ESP-NOW send failed err=%d\n", static_cast<int>(err));
return false;
}
cachePeer(mac);
return true;
}
bool NetworkManager::ensureEspNowReady() {
if (!espnow_enabled_) {
return enableEspNow();
}
esp_now_peer_num_t peer_num = {};
const esp_err_t err = esp_now_get_peer_num(&peer_num);
if (err == ESP_OK) {
return true;
}
if (err == ESP_ERR_ESPNOW_NOT_INIT) {
espnow_enabled_ = false;
return enableEspNow();
}
Serial.printf("[NET] ESP-NOW health check err=%d\n", static_cast<int>(err));
return false;
}
void NetworkManager::refreshEspNowConnection(uint32_t now_ms) {
if (!espnow_enabled_) {
espnow_refresh_fault_ = false;
last_espnow_refresh_ms_ = now_ms;
return;
}
if ((now_ms - last_espnow_refresh_ms_) < kEspNowRefreshPeriodMs) {
return;
}
last_espnow_refresh_ms_ = now_ms;
if (!ensureEspNowReady()) {
if (!espnow_refresh_fault_) {
Serial.println("[NET] ESP-NOW periodic refresh failed");
}
espnow_refresh_fault_ = true;
return;
}
if (espnow_refresh_fault_) {
Serial.println("[NET] ESP-NOW periodic refresh recovered");
}
espnow_refresh_fault_ = false;
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
uint8_t mac[6] = {0};
if (!parseMac(peer_cache_[index], mac)) {
continue;
}
addEspNowPeerInternal(mac);
}
}
bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
if (target == nullptr || target[0] == '\0') {
return false;
}
if (text == nullptr || text[0] == '\0') {
return false;
}
String frame = text;
frame.trim();
if (frame.isEmpty()) {
return false;
}
bool is_envelope = false;
if (frame.startsWith("{")) {
StaticJsonDocument<512> document;
if (!deserializeJson(document, frame) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
is_envelope = true;
}
}
if (!is_envelope) {
StaticJsonDocument<512> envelope;
++espnow_tx_seq_;
char msg_id[32] = {0};
snprintf(msg_id,
sizeof(msg_id),
"M%08lX%06lu",
static_cast<unsigned long>(millis()),
static_cast<unsigned long>(espnow_tx_seq_));
envelope["msg_id"] = msg_id;
envelope["seq"] = espnow_tx_seq_;
envelope["type"] = inferEnvelopeType(frame.c_str());
envelope["payload"] = frame;
envelope["ack"] = false;
frame.remove(0);
serializeJson(envelope, frame);
}
if (equalsIgnoreCase(target, "broadcast")) {
const uint8_t broadcast_mac[6] = {0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xFFU};
return sendEspNowText(broadcast_mac, frame.c_str());
}
uint8_t mac[6] = {0};
if (!parseMac(target, mac)) {
return false;
}
return sendEspNowText(mac, frame.c_str());
}
NetworkManager::Snapshot NetworkManager::snapshot() const {
return snapshot_;
}
bool NetworkManager::consumeEspNowMessage(char* out_payload,
size_t payload_capacity,
char* out_peer,
size_t peer_capacity,
char* out_msg_id,
size_t msg_id_capacity,
uint32_t* out_seq,
char* out_type,
size_t type_capacity,
bool* out_ack_requested) {
if (rx_queue_count_ == 0U) {
return false;
}
const EspNowMessage& entry = rx_queue_[rx_queue_head_];
char normalized_payload[kPayloadCapacity] = {0};
copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
char msg_id[32] = {0};
uint32_t seq = 0U;
char envelope_type[24] = {0};
bool ack_requested = false;
if (entry.payload[0] == '{') {
StaticJsonDocument<512> document;
if (!deserializeJson(document, entry.payload) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
JsonVariantConst root = document.as<JsonVariantConst>();
copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
seq = root["seq"] | 0U;
copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
const bool envelope_ack = root["ack"] | false;
const bool ack_response = envelope_ack && std::strcmp(envelope_type, "ack") == 0;
if (ack_response) {
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
--rx_queue_count_;
return consumeEspNowMessage(out_payload,
payload_capacity,
out_peer,
peer_capacity,
out_msg_id,
msg_id_capacity,
out_seq,
out_type,
type_capacity,
out_ack_requested);
}
ack_requested = envelope_ack;
if (root["payload"].is<const char*>()) {
copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
} else if (!root["payload"].isNull()) {
String payload_text;
serializeJson(root["payload"], payload_text);
copyText(normalized_payload, sizeof(normalized_payload), payload_text.c_str());
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
}
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
if (out_payload != nullptr && payload_capacity > 0U) {
copyText(out_payload, payload_capacity, normalized_payload);
}
if (out_peer != nullptr && peer_capacity > 0U) {
copyText(out_peer, peer_capacity, entry.peer);
}
if (out_msg_id != nullptr && msg_id_capacity > 0U) {
copyText(out_msg_id, msg_id_capacity, msg_id);
}
if (out_seq != nullptr) {
*out_seq = seq;
}
if (out_type != nullptr && type_capacity > 0U) {
copyText(out_type, type_capacity, envelope_type);
}
if (out_ack_requested != nullptr) {
*out_ack_requested = ack_requested;
}
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
--rx_queue_count_;
return true;
}
void NetworkManager::onEspNowRecv(const uint8_t* mac_addr, const uint8_t* data, int data_len) {
if (g_network_instance == nullptr) {
return;
}
g_network_instance->handleEspNowRecv(mac_addr, data, data_len);
}
void NetworkManager::onEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status) {
if (g_network_instance == nullptr) {
return;
}
g_network_instance->handleEspNowSend(mac_addr, status);
}
uint8_t NetworkManager::parseHexByte(char high, char low, bool* ok) {
auto nibble = [](char ch) -> int {
if (ch >= '0' && ch <= '9') {
return ch - '0';
}
if (ch >= 'A' && ch <= 'F') {
return 10 + (ch - 'A');
}
if (ch >= 'a' && ch <= 'f') {
return 10 + (ch - 'a');
}
return -1;
};
const int hi = nibble(high);
const int lo = nibble(low);
if (hi < 0 || lo < 0) {
if (ok != nullptr) {
*ok = false;
}
return 0U;
}
if (ok != nullptr) {
*ok = true;
}
return static_cast<uint8_t>((hi << 4) | lo);
}
void NetworkManager::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
if (out == nullptr || out_size == 0U) {
return;
}
if (mac == nullptr) {
copyText(out, out_size, "00:00:00:00:00:00");
return;
}
snprintf(out,
out_size,
"%02X:%02X:%02X:%02X:%02X:%02X",
mac[0],
mac[1],
mac[2],
mac[3],
mac[4],
mac[5]);
}
bool NetworkManager::equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
while (lhs[index] != '\0' && rhs[index] != '\0') {
const char l = static_cast<char>(std::tolower(static_cast<unsigned char>(lhs[index])));
const char r = static_cast<char>(std::tolower(static_cast<unsigned char>(rhs[index])));
if (l != r) {
return false;
}
++index;
}
return lhs[index] == '\0' && rhs[index] == '\0';
}
const char* NetworkManager::wifiModeLabel(uint8_t mode) {
switch (mode) {
case WIFI_MODE_STA:
return "STA";
case WIFI_MODE_AP:
return "AP";
case WIFI_MODE_APSTA:
return "AP_STA";
default:
return "OFF";
}
}
const char* NetworkManager::networkStateLabel(bool sta_connected,
bool sta_connecting,
bool ap_enabled,
bool fallback_ap_active) {
if (sta_connected) {
return "connected";
}
if (sta_connecting) {
return "connecting";
}
if (ap_enabled && fallback_ap_active) {
return "ap_fallback";
}
if (ap_enabled) {
return "ap";
}
return "idle";
}
bool NetworkManager::addEspNowPeerInternal(const uint8_t mac[6]) {
if (!espnow_enabled_ || mac == nullptr) {
return false;
}
if (esp_now_is_peer_exist(mac)) {
return true;
}
esp_now_peer_info_t peer = {};
std::memcpy(peer.peer_addr, mac, 6U);
peer.channel = 0U;
peer.encrypt = false;
return esp_now_add_peer(&peer) == ESP_OK;
}
bool NetworkManager::removeEspNowPeerInternal(const uint8_t mac[6]) {
if (!espnow_enabled_ || mac == nullptr) {
return false;
}
if (!esp_now_is_peer_exist(mac)) {
return true;
}
const esp_err_t err = esp_now_del_peer(mac);
return err == ESP_OK;
}
void NetworkManager::cachePeer(const uint8_t mac[6]) {
char peer_text[18] = {0};
formatMac(mac, peer_text, sizeof(peer_text));
if (peer_text[0] == '\0') {
return;
}
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
if (std::strcmp(peer_cache_[index], peer_text) == 0) {
return;
}
}
if (peer_cache_count_ < kMaxPeerCache) {
copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
++peer_cache_count_;
return;
}
for (uint8_t index = 1U; index < kMaxPeerCache; ++index) {
copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
}
copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
}
void NetworkManager::forgetPeer(const uint8_t mac[6]) {
char peer_text[18] = {0};
formatMac(mac, peer_text, sizeof(peer_text));
if (peer_text[0] == '\0' || peer_cache_count_ == 0U) {
return;
}
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
if (std::strcmp(peer_cache_[index], peer_text) != 0) {
continue;
}
for (uint8_t move = index + 1U; move < peer_cache_count_; ++move) {
copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
}
peer_cache_[peer_cache_count_ - 1U][0] = '\0';
--peer_cache_count_;
return;
}
}
bool NetworkManager::queueEspNowMessage(const char* payload,
const char* peer,
const char* msg_id,
uint32_t seq,
const char* type,
bool ack_requested) {
if (payload == nullptr || payload[0] == '\0') {
return false;
}
if (rx_queue_count_ >= kRxQueueSize) {
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
--rx_queue_count_;
++espnow_drop_packets_;
}
EspNowMessage& slot = rx_queue_[rx_queue_tail_];
copyText(slot.payload, sizeof(slot.payload), payload);
copyText(slot.peer, sizeof(slot.peer), peer);
copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
copyText(slot.type, sizeof(slot.type), type);
slot.seq = seq;
slot.ack_requested = ack_requested;
rx_queue_tail_ = static_cast<uint8_t>((rx_queue_tail_ + 1U) % kRxQueueSize);
++rx_queue_count_;
return true;
}
void NetworkManager::refreshSnapshot() {
const wl_status_t wifi_status = WiFi.status();
const wifi_mode_t mode = WiFi.getMode();
const bool local_match = isConnectedToLocalTarget();
snapshot_.ready = started_;
snapshot_.sta_connected = (wifi_status == WL_CONNECTED);
snapshot_.sta_connecting = sta_connecting_;
snapshot_.ap_enabled = (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA);
snapshot_.espnow_enabled = espnow_enabled_;
snapshot_.local_match = local_match;
snapshot_.fallback_ap_active =
fallback_ap_active_ && !manual_ap_active_ && snapshot_.ap_enabled && !snapshot_.local_match;
snapshot_.local_retry_paused = local_retry_paused_;
snapshot_.rssi = snapshot_.sta_connected ? WiFi.RSSI() : 0;
copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target_ssid_);
copyText(snapshot_.mode, sizeof(snapshot_.mode), wifiModeLabel(static_cast<uint8_t>(mode)));
copyText(snapshot_.state,
sizeof(snapshot_.state),
networkStateLabel(snapshot_.sta_connected,
sta_connecting_,
snapshot_.ap_enabled,
snapshot_.fallback_ap_active));
if (snapshot_.sta_connected) {
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), WiFi.SSID().c_str());
copyText(snapshot_.ip, sizeof(snapshot_.ip), WiFi.localIP().toString().c_str());
} else if (snapshot_.ap_enabled) {
copyText(snapshot_.ip, sizeof(snapshot_.ip), WiFi.softAPIP().toString().c_str());
} else {
copyText(snapshot_.ip, sizeof(snapshot_.ip), "0.0.0.0");
}
if (snapshot_.ap_enabled) {
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), WiFi.softAPSSID().c_str());
snapshot_.ap_clients = WiFi.softAPgetStationNum();
} else {
snapshot_.ap_ssid[0] = '\0';
snapshot_.ap_clients = 0U;
}
snapshot_.espnow_peer_count = peer_cache_count_;
snapshot_.espnow_rx_packets = espnow_rx_packets_;
snapshot_.espnow_tx_ok = espnow_tx_ok_;
snapshot_.espnow_tx_fail = espnow_tx_fail_;
snapshot_.espnow_drop_packets = espnow_drop_packets_;
}
void NetworkManager::handleEspNowRecv(const uint8_t* mac_addr, const uint8_t* data, int data_len) {
++espnow_rx_packets_;
cachePeer(mac_addr);
char peer_text[18] = {0};
formatMac(mac_addr, peer_text, sizeof(peer_text));
copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
char payload[kPayloadCapacity] = {0};
const int safe_len = (data_len > 0) ? data_len : 0;
const size_t copy_len = (static_cast<size_t>(safe_len) < (sizeof(payload) - 1U)) ? static_cast<size_t>(safe_len)
: (sizeof(payload) - 1U);
if (data != nullptr && copy_len > 0U) {
std::memcpy(payload, data, copy_len);
}
payload[copy_len] = '\0';
snapshot_.espnow_last_seq = 0U;
snapshot_.espnow_last_ack = false;
snapshot_.last_msg_id[0] = '\0';
copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
queueEspNowMessage(payload, peer_text, "", 0U, "", false);
}
void NetworkManager::handleEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status) {
if (status == ESP_NOW_SEND_SUCCESS) {
++espnow_tx_ok_;
} else {
++espnow_tx_fail_;
}
cachePeer(mac_addr);
formatMac(mac_addr, snapshot_.last_peer, sizeof(snapshot_.last_peer));
}
-2
View File
@@ -1,2 +0,0 @@
// Placeholder pour compilation RP2040
// Ce fichier peut être remplacé par le code réel si besoin
@@ -1,27 +1,27 @@
#include "runtime/memory/caps_allocator.h"
#include <Arduino.h>
#include <atomic>
#include <cstdlib>
#if defined(ARDUINO_ARCH_ESP32)
#include <esp_heap_caps.h>
#endif
#include <cstdlib>
namespace runtime {
namespace memory {
namespace {
uint32_t g_alloc_failures = 0U;
std::atomic<uint32_t> g_alloc_failures{0U};
void noteAllocFailure(size_t bytes, const char* tag, const char* source) {
++g_alloc_failures;
const uint32_t count = ++g_alloc_failures;
Serial.printf("[MEM] alloc_fail source=%s bytes=%u tag=%s fail_count=%lu\n",
source,
static_cast<unsigned int>(bytes),
(tag != nullptr) ? tag : "n/a",
static_cast<unsigned long>(g_alloc_failures));
static_cast<unsigned long>(count));
}
} // namespace
@@ -167,7 +167,7 @@ void CapsAllocator::release(void* ptr) {
}
uint32_t CapsAllocator::failureCount() {
return g_alloc_failures;
return g_alloc_failures.load();
}
} // namespace memory
@@ -47,11 +47,13 @@ const char* sectionLabel(PerfSection section) {
} // namespace
void PerfMonitor::reset() {
portENTER_CRITICAL(&stats_mux_);
for (uint8_t index = 0U; index < static_cast<uint8_t>(PerfSection::kCount); ++index) {
sections_[index] = {};
}
ui_dma_flush_count_ = 0U;
ui_sync_flush_count_ = 0U;
portEXIT_CRITICAL(&stats_mux_);
}
uint32_t PerfMonitor::beginSample() const {
@@ -65,15 +67,18 @@ void PerfMonitor::endSample(PerfSection section, uint32_t started_us) {
void PerfMonitor::noteUiFlush(bool dma_used, uint32_t elapsed_us) {
noteSection(PerfSection::kUiFlush, elapsed_us);
portENTER_CRITICAL(&stats_mux_);
if (dma_used) {
++ui_dma_flush_count_;
} else {
++ui_sync_flush_count_;
}
portEXIT_CRITICAL(&stats_mux_);
}
PerfSnapshot PerfMonitor::snapshot() const {
PerfSnapshot out = {};
portENTER_CRITICAL(&stats_mux_);
out.loop = sections_[static_cast<uint8_t>(PerfSection::kLoop)];
out.ui_tick = sections_[static_cast<uint8_t>(PerfSection::kUiTick)];
out.ui_flush = sections_[static_cast<uint8_t>(PerfSection::kUiFlush)];
@@ -82,6 +87,7 @@ PerfSnapshot PerfMonitor::snapshot() const {
out.audio_update = sections_[static_cast<uint8_t>(PerfSection::kAudioUpdate)];
out.ui_dma_flush_count = ui_dma_flush_count_;
out.ui_sync_flush_count = ui_sync_flush_count_;
portEXIT_CRITICAL(&stats_mux_);
return out;
}
@@ -112,12 +118,14 @@ void PerfMonitor::noteSection(PerfSection section, uint32_t elapsed_us) {
if (index >= static_cast<uint8_t>(PerfSection::kCount)) {
return;
}
portENTER_CRITICAL(&stats_mux_);
PerfSectionStats& stats = sections_[index];
++stats.count;
stats.total_us += static_cast<uint64_t>(elapsed_us);
if (elapsed_us > stats.max_us) {
stats.max_us = elapsed_us;
}
portEXIT_CRITICAL(&stats_mux_);
}
uint32_t PerfMonitor::elapsedUs(uint32_t started_us, uint32_t ended_us) {
@@ -5,6 +5,7 @@
#include <cctype>
#include <cstring>
#include "core/str_utils.h"
namespace {
@@ -13,18 +14,6 @@ constexpr const char* kDefaultWifiSsid = "";
constexpr const char* kDefaultWifiPassword = "";
constexpr uint16_t kMaxLaToleranceHz = 10U;
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
void toLowerAsciiInPlace(char* text) {
if (text == nullptr) {
return;
@@ -58,7 +47,7 @@ void addEspNowBootPeer(RuntimeNetworkConfig* network_cfg, const char* mac_text)
if (network_cfg->espnow_boot_peer_count >= RuntimeNetworkConfig::kMaxEspNowBootPeers) {
return;
}
copyText(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count],
core::copyText(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count],
sizeof(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count]),
mac_text);
++network_cfg->espnow_boot_peer_count;
@@ -68,13 +57,13 @@ void resetRuntimeNetworkConfig(RuntimeNetworkConfig* network_cfg) {
if (network_cfg == nullptr) {
return;
}
copyText(network_cfg->hostname, sizeof(network_cfg->hostname), kDefaultWifiHostname);
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), kDefaultWifiSsid);
copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), kDefaultWifiPassword);
copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), kDefaultWifiSsid);
copyText(network_cfg->local_password, sizeof(network_cfg->local_password), kDefaultWifiPassword);
copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), "Freenove-Setup");
copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), kDefaultWifiPassword);
core::copyText(network_cfg->hostname, sizeof(network_cfg->hostname), kDefaultWifiHostname);
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), kDefaultWifiSsid);
core::copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), kDefaultWifiPassword);
core::copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), kDefaultWifiSsid);
core::copyText(network_cfg->local_password, sizeof(network_cfg->local_password), kDefaultWifiPassword);
core::copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), "Freenove-Setup");
core::copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), kDefaultWifiPassword);
network_cfg->force_ap_if_not_local = false;
network_cfg->pause_local_retry_when_ap_client = false;
network_cfg->local_retry_ms = RuntimeNetworkConfig::kDefaultLocalRetryMs;
@@ -138,30 +127,30 @@ void RuntimeConfigService::load(StorageManager& storage,
const bool pause_retry_when_ap_client = config["pause_local_retry_when_ap_client"] | false;
const uint32_t local_retry_ms = config["local_retry_ms"] | RuntimeNetworkConfig::kDefaultLocalRetryMs;
if (hostname[0] != '\0') {
copyText(network_cfg->hostname, sizeof(network_cfg->hostname), hostname);
core::copyText(network_cfg->hostname, sizeof(network_cfg->hostname), hostname);
}
if (local_ssid[0] != '\0') {
copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), local_ssid);
core::copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), local_ssid);
}
if (local_password[0] != '\0') {
copyText(network_cfg->local_password, sizeof(network_cfg->local_password), local_password);
core::copyText(network_cfg->local_password, sizeof(network_cfg->local_password), local_password);
}
if (test_ssid[0] != '\0') {
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), test_ssid);
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), test_ssid);
}
if (test_password[0] != '\0') {
copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), test_password);
core::copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), test_password);
}
if (ap_ssid[0] != '\0') {
copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), ap_ssid);
core::copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), ap_ssid);
}
if (ap_password[0] != '\0') {
copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), ap_password);
core::copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), ap_password);
}
if (ap_policy[0] != '\0') {
char policy_normalized[32] = {0};
copyText(policy_normalized, sizeof(policy_normalized), ap_policy);
core::copyText(policy_normalized, sizeof(policy_normalized), ap_policy);
toLowerAsciiInPlace(policy_normalized);
if (std::strcmp(policy_normalized, "force_if_not_local") == 0) {
network_cfg->force_ap_if_not_local = true;
@@ -177,10 +166,10 @@ void RuntimeConfigService::load(StorageManager& storage,
}
if (test_ssid[0] == '\0' && network_cfg->local_ssid[0] != '\0') {
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), network_cfg->local_ssid);
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), network_cfg->local_ssid);
}
if (test_password[0] == '\0' && network_cfg->local_password[0] != '\0') {
copyText(network_cfg->wifi_test_password,
core::copyText(network_cfg->wifi_test_password,
sizeof(network_cfg->wifi_test_password),
network_cfg->local_password);
}
@@ -246,7 +235,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* mic_event_name = config["mic_event_name"] | "";
if (mic_event_name[0] != '\0') {
copyText(hardware_cfg->mic_event_name, sizeof(hardware_cfg->mic_event_name), mic_event_name);
core::copyText(hardware_cfg->mic_event_name, sizeof(hardware_cfg->mic_event_name), mic_event_name);
}
if (config["la_trigger_enabled"].is<bool>()) {
hardware_cfg->mic_la_trigger_enabled = config["la_trigger_enabled"].as<bool>();
@@ -324,7 +313,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* la_event_name = config["la_event_name"] | "";
if (la_event_name[0] != '\0') {
copyText(hardware_cfg->mic_la_event_name, sizeof(hardware_cfg->mic_la_event_name), la_event_name);
core::copyText(hardware_cfg->mic_la_event_name, sizeof(hardware_cfg->mic_la_event_name), la_event_name);
}
if (config["battery_enabled"].is<bool>()) {
hardware_cfg->battery_enabled = config["battery_enabled"].as<bool>();
@@ -338,7 +327,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* battery_event_name = config["battery_low_event_name"] | "";
if (battery_event_name[0] != '\0') {
copyText(
core::copyText(
hardware_cfg->battery_low_event_name, sizeof(hardware_cfg->battery_low_event_name), battery_event_name);
}
} else {
@@ -357,7 +346,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* frame_size = config["frame_size"] | "";
if (frame_size[0] != '\0') {
copyText(camera_cfg->frame_size, sizeof(camera_cfg->frame_size), frame_size);
core::copyText(camera_cfg->frame_size, sizeof(camera_cfg->frame_size), frame_size);
}
if (config["jpeg_quality"].is<unsigned int>()) {
camera_cfg->jpeg_quality = static_cast<uint8_t>(config["jpeg_quality"].as<unsigned int>());
@@ -370,7 +359,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* snapshot_dir = config["snapshot_dir"] | "";
if (snapshot_dir[0] != '\0') {
copyText(camera_cfg->snapshot_dir, sizeof(camera_cfg->snapshot_dir), snapshot_dir);
core::copyText(camera_cfg->snapshot_dir, sizeof(camera_cfg->snapshot_dir), snapshot_dir);
}
} else {
Serial.printf("[CAM] APP_CAMERA invalid json (%s)\n", error.c_str());
@@ -406,13 +395,13 @@ void RuntimeConfigService::load(StorageManager& storage,
const char* picture_dir = config["picture_dir"] | "";
const char* record_dir = config["record_dir"] | "";
if (music_dir[0] != '\0') {
copyText(media_cfg->music_dir, sizeof(media_cfg->music_dir), music_dir);
core::copyText(media_cfg->music_dir, sizeof(media_cfg->music_dir), music_dir);
}
if (picture_dir[0] != '\0') {
copyText(media_cfg->picture_dir, sizeof(media_cfg->picture_dir), picture_dir);
core::copyText(media_cfg->picture_dir, sizeof(media_cfg->picture_dir), picture_dir);
}
if (record_dir[0] != '\0') {
copyText(media_cfg->record_dir, sizeof(media_cfg->record_dir), record_dir);
core::copyText(media_cfg->record_dir, sizeof(media_cfg->record_dir), record_dir);
}
if (config["record_max_seconds"].is<unsigned int>()) {
media_cfg->record_max_seconds = static_cast<uint16_t>(config["record_max_seconds"].as<unsigned int>());
@@ -2,7 +2,7 @@
#include "runtime/simd/simd_accel.h"
#include <Arduino.h>
#include <atomic>
#include <cstring>
#include "runtime/memory/caps_allocator.h"
@@ -16,6 +16,7 @@
#endif
#if (UI_SIMD_USE_ESP_DSP != 0) && __has_include(<dsps_mul.h>)
#include <dsps_add.h>
#include <dsps_mul.h>
#define UI_SIMD_HAS_ESP_DSP 1
#else
@@ -33,12 +34,17 @@ constexpr uint32_t kBenchMaxPixels = 8192U;
constexpr uint32_t kBenchMinLoops = 1U;
constexpr uint32_t kBenchMaxLoops = 5000U;
// Gray LUT — built once, guarded by atomic flag (acquire/release semantics).
uint16_t g_gray8_to_rgb565[256] = {};
bool g_gray_lut_ready = false;
SimdAccelStatus g_status = {
};
std::atomic<bool> g_gray_lut_ready{false};
SimdAccelStatus g_status = {};
bool g_status_initialized = false;
// ============================================================================
// Internal helpers
// ============================================================================
void initStatusDefaults() {
g_status.simd_path_enabled = kSimdPathEnabled;
g_status.esp_dsp_enabled = (UI_SIMD_HAS_ESP_DSP != 0);
@@ -53,46 +59,50 @@ void ensureStatusInitialized() {
}
inline uint8_t clampU8(int32_t value) {
if (value < 0) {
return 0U;
}
if (value > 255) {
return 255U;
}
if (value < 0) return 0U;
if (value > 255) return 255U;
return static_cast<uint8_t>(value);
}
inline int16_t clampS16(int32_t value) {
if (value < -32768) {
return -32768;
}
if (value > 32767) {
return 32767;
}
if (value < -32768) return -32768;
if (value > 32767) return 32767;
return static_cast<int16_t>(value);
}
inline uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
const uint16_t red = static_cast<uint16_t>((r & 0xF8U) << 8U);
const uint16_t green = static_cast<uint16_t>((g & 0xFCU) << 3U);
const uint16_t blue = static_cast<uint16_t>(b >> 3U);
return static_cast<uint16_t>(red | green | blue);
return static_cast<uint16_t>(
(static_cast<uint16_t>((r & 0xF8U) << 8U)) |
(static_cast<uint16_t>((g & 0xFCU) << 3U)) |
(static_cast<uint16_t>( b >> 3U)));
}
// Decompose RGB565 into 8-bit channels.
inline void unpack565(uint16_t px, uint8_t& r, uint8_t& g, uint8_t& b) {
r = static_cast<uint8_t>((px >> 8U) & 0xF8U);
g = static_cast<uint8_t>((px >> 3U) & 0xFCU);
b = static_cast<uint8_t>((px << 3U) & 0xF8U);
}
// Thread-safe lazy LUT initialisation (acquire/release).
void ensureGrayLut() {
if (g_gray_lut_ready) {
if (g_gray_lut_ready.load(std::memory_order_acquire)) {
return;
}
for (uint16_t g = 0U; g < 256U; ++g) {
g_gray8_to_rgb565[g] = rgb565(static_cast<uint8_t>(g), static_cast<uint8_t>(g), static_cast<uint8_t>(g));
for (uint16_t i = 0U; i < 256U; ++i) {
g_gray8_to_rgb565[i] = rgb565(static_cast<uint8_t>(i),
static_cast<uint8_t>(i),
static_cast<uint8_t>(i));
}
g_gray_lut_ready = true;
g_gray_lut_ready.store(true, std::memory_order_release);
}
// ============================================================================
// Audio scalar kernels
// ============================================================================
void gainQ15Scalar(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n) {
if (dst == nullptr || src == nullptr || n == 0U) {
return;
}
if (dst == nullptr || src == nullptr || n == 0U) return;
for (size_t i = 0U; i < n; ++i) {
const int32_t mul = static_cast<int32_t>(src[i]) * static_cast<int32_t>(gain_q15);
const int32_t rounded = (mul >= 0) ? (mul + (1 << 14)) : (mul - (1 << 14));
@@ -106,37 +116,31 @@ void mixQ15Scalar(int16_t* dst,
int16_t ga_q15,
int16_t gb_q15,
size_t n) {
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) {
return;
}
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) return;
for (size_t i = 0U; i < n; ++i) {
const int32_t acc = static_cast<int32_t>(a[i]) * static_cast<int32_t>(ga_q15) +
static_cast<int32_t>(b[i]) * static_cast<int32_t>(gb_q15);
const int32_t acc =
static_cast<int32_t>(a[i]) * static_cast<int32_t>(ga_q15) +
static_cast<int32_t>(b[i]) * static_cast<int32_t>(gb_q15);
const int32_t rounded = (acc >= 0) ? (acc + (1 << 14)) : (acc - (1 << 14));
dst[i] = clampS16(rounded >> 15);
}
}
// ============================================================================
// Audio ESP-DSP kernels
// ============================================================================
void gainQ15EspDsp(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n) {
#if UI_SIMD_HAS_ESP_DSP
int16_t gain_buf[kAudioChunk] = {};
size_t offset = 0U;
while (offset < n) {
size_t chunk = n - offset;
if (chunk > kAudioChunk) {
chunk = kAudioChunk;
}
const size_t chunk = (n - offset < kAudioChunk) ? (n - offset) : kAudioChunk;
for (size_t i = 0U; i < chunk; ++i) {
gain_buf[i] = gain_q15;
}
const esp_err_t rc = dsps_mul_s16(src + offset,
gain_buf,
dst + offset,
static_cast<int>(chunk),
1,
1,
1,
15);
const esp_err_t rc = dsps_mul_s16(src + offset, gain_buf, dst + offset,
static_cast<int>(chunk), 1, 1, 1, 15);
if (rc != ESP_OK) {
gainQ15Scalar(dst + offset, src + offset, gain_q15, n - offset);
return;
@@ -144,28 +148,77 @@ void gainQ15EspDsp(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n)
offset += chunk;
}
#else
(void)dst;
(void)src;
(void)gain_q15;
(void)n;
gainQ15Scalar(dst, src, gain_q15, n);
#endif
}
void mixQ15EspDsp(int16_t* dst,
const int16_t* a,
const int16_t* b,
int16_t ga_q15,
int16_t gb_q15,
size_t n) {
#if UI_SIMD_HAS_ESP_DSP
// Allocate two temporary buffers on the stack for small n, heap for large n.
// For kAudioChunk-sized blocks, stack allocation is safe.
int16_t tmp_a[kAudioChunk] = {};
int16_t tmp_b[kAudioChunk] = {};
int16_t gain_a[kAudioChunk] = {};
int16_t gain_b[kAudioChunk] = {};
size_t offset = 0U;
while (offset < n) {
const size_t chunk = (n - offset < kAudioChunk) ? (n - offset) : kAudioChunk;
for (size_t i = 0U; i < chunk; ++i) {
gain_a[i] = ga_q15;
gain_b[i] = gb_q15;
}
const esp_err_t rc_a = dsps_mul_s16(a + offset, gain_a, tmp_a,
static_cast<int>(chunk), 1, 1, 1, 15);
const esp_err_t rc_b = dsps_mul_s16(b + offset, gain_b, tmp_b,
static_cast<int>(chunk), 1, 1, 1, 15);
if (rc_a != ESP_OK || rc_b != ESP_OK) {
mixQ15Scalar(dst + offset, a + offset, b + offset, ga_q15, gb_q15, n - offset);
return;
}
// Add the two scaled streams.
const esp_err_t rc_add = dsps_add_s16(tmp_a, tmp_b, dst + offset,
static_cast<int>(chunk), 1, 1, 1, 0);
if (rc_add != ESP_OK) {
// Fallback: scalar add for this chunk.
for (size_t i = 0U; i < chunk; ++i) {
dst[offset + i] = clampS16(static_cast<int32_t>(tmp_a[i]) +
static_cast<int32_t>(tmp_b[i]));
}
}
offset += chunk;
}
#else
mixQ15Scalar(dst, a, b, ga_q15, gb_q15, n);
#endif
}
// ============================================================================
// Self-test helpers
// ============================================================================
template <typename T>
bool arraysEqual(const T* a, const T* b, size_t n) {
if (a == nullptr || b == nullptr) {
return false;
}
if (a == nullptr || b == nullptr) return false;
for (size_t i = 0U; i < n; ++i) {
if (a[i] != b[i]) {
return false;
}
if (a[i] != b[i]) return false;
}
return true;
}
} // namespace
// ============================================================================
// Status / bench
// ============================================================================
const SimdAccelStatus& status() {
ensureStatusInitialized();
return g_status;
@@ -182,20 +235,21 @@ void resetBenchStatus() {
g_status.bench_s16_gain_q15_us = 0U;
}
// ============================================================================
// RGB565 buffer operations
// ============================================================================
void simd_rgb565_copy(uint16_t* dst, const uint16_t* src, size_t n_px) {
if (dst == nullptr || src == nullptr || n_px == 0U) {
return;
}
if (dst == nullptr || src == nullptr || n_px == 0U) return;
std::memcpy(dst, src, n_px * sizeof(uint16_t));
}
void simd_rgb565_fill(uint16_t* dst, uint16_t color565, size_t n_px) {
if (dst == nullptr || n_px == 0U) {
return;
}
if (dst == nullptr || n_px == 0U) return;
if ((reinterpret_cast<uintptr_t>(dst) & 0x3U) == 0U) {
uint32_t* dst32 = reinterpret_cast<uint32_t*>(dst);
const uint32_t packed = (static_cast<uint32_t>(color565) << 16U) | color565;
const uint32_t packed =
(static_cast<uint32_t>(color565) << 16U) | static_cast<uint32_t>(color565);
size_t i = 0U;
for (; i + 1U < n_px; i += 2U) {
dst32[i >> 1U] = packed;
@@ -210,35 +264,98 @@ void simd_rgb565_fill(uint16_t* dst, uint16_t color565, size_t n_px) {
}
}
// Optimised: swap bytes of 2 pixels per iteration via 32-bit masking.
void simd_rgb565_bswap_copy(uint16_t* dst, const uint16_t* src, size_t n_px) {
if (dst == nullptr || src == nullptr || n_px == 0U) {
return;
if (dst == nullptr || src == nullptr || n_px == 0U) return;
size_t i = 0U;
if ((reinterpret_cast<uintptr_t>(dst) & 0x3U) == 0U &&
(reinterpret_cast<uintptr_t>(src) & 0x3U) == 0U) {
const uint32_t* src32 = reinterpret_cast<const uint32_t*>(src);
uint32_t* dst32 = reinterpret_cast<uint32_t*>(dst);
for (; i + 1U < n_px; i += 2U) {
const uint32_t v = src32[i >> 1U];
// Swap bytes within each 16-bit half independently.
dst32[i >> 1U] = ((v << 8U) & 0xFF00FF00U) | ((v >> 8U) & 0x00FF00FFU);
}
}
for (size_t i = 0U; i < n_px; ++i) {
const uint16_t value = src[i];
dst[i] = static_cast<uint16_t>((value << 8U) | (value >> 8U));
for (; i < n_px; ++i) {
const uint16_t v = src[i];
dst[i] = static_cast<uint16_t>((v << 8U) | (v >> 8U));
}
}
void simd_l8_to_rgb565(uint16_t* dst565, const uint8_t* src_l8, size_t n_px) {
if (dst565 == nullptr || src_l8 == nullptr || n_px == 0U) {
// Alpha blend: dst = src * alpha/255 + dst * (255-alpha)/255.
// Optimised: 2 pixels per iteration via 32-bit masking when aligned.
void simd_rgb565_alpha_blend(uint16_t* dst, const uint16_t* src,
uint8_t alpha, size_t n_px) {
if (dst == nullptr || src == nullptr || n_px == 0U) return;
if (alpha == 255U) {
std::memcpy(dst, src, n_px * sizeof(uint16_t));
return;
}
if (alpha == 0U) {
return; // dst unchanged.
}
const uint32_t a = static_cast<uint32_t>(alpha);
const uint32_t ia = 255U - a;
for (size_t i = 0U; i < n_px; ++i) {
uint8_t sr, sg, sb, dr, dg, db;
unpack565(src[i], sr, sg, sb);
unpack565(dst[i], dr, dg, db);
const uint8_t r = static_cast<uint8_t>((sr * a + dr * ia) / 255U);
const uint8_t g = static_cast<uint8_t>((sg * a + dg * ia) / 255U);
const uint8_t b = static_cast<uint8_t>((sb * a + db * ia) / 255U);
dst[i] = rgb565(r, g, b);
}
}
// Scale brightness: dst = src * level / 255.
void simd_rgb565_brightness(uint16_t* dst, const uint16_t* src,
uint8_t level, size_t n_px) {
if (dst == nullptr || src == nullptr || n_px == 0U) return;
if (level == 255U) {
if (dst != src) std::memcpy(dst, src, n_px * sizeof(uint16_t));
return;
}
if (level == 0U) {
std::memset(dst, 0, n_px * sizeof(uint16_t));
return;
}
const uint32_t lv = static_cast<uint32_t>(level);
for (size_t i = 0U; i < n_px; ++i) {
uint8_t r, g, b;
unpack565(src[i], r, g, b);
dst[i] = rgb565(
static_cast<uint8_t>((static_cast<uint32_t>(r) * lv) / 255U),
static_cast<uint8_t>((static_cast<uint32_t>(g) * lv) / 255U),
static_cast<uint8_t>((static_cast<uint32_t>(b) * lv) / 255U));
}
}
// ============================================================================
// Color format conversions
// ============================================================================
void simd_l8_to_rgb565(uint16_t* dst565, const uint8_t* src_l8, size_t n_px) {
if (dst565 == nullptr || src_l8 == nullptr || n_px == 0U) return;
ensureGrayLut();
size_t i = 0U;
if ((reinterpret_cast<uintptr_t>(dst565) & 0x3U) == 0U) {
uint32_t* dst32 = reinterpret_cast<uint32_t*>(dst565);
size_t i = 0U;
for (; i + 1U < n_px; i += 2U) {
const uint16_t c0 = g_gray8_to_rgb565[src_l8[i]];
const uint16_t c1 = g_gray8_to_rgb565[src_l8[i + 1U]];
dst32[i >> 1U] = (static_cast<uint32_t>(c1) << 16U) | c0;
}
if (i < n_px) {
dst565[i] = g_gray8_to_rgb565[src_l8[i]];
}
return;
}
for (size_t i = 0U; i < n_px; ++i) {
for (; i < n_px; ++i) {
dst565[i] = g_gray8_to_rgb565[src_l8[i]];
}
}
@@ -247,32 +364,65 @@ void simd_index8_to_rgb565(uint16_t* dst565,
const uint8_t* idx8,
const uint16_t* pal565_256,
size_t n_px) {
if (dst565 == nullptr || idx8 == nullptr || pal565_256 == nullptr || n_px == 0U) {
return;
}
if (dst565 == nullptr || idx8 == nullptr || pal565_256 == nullptr || n_px == 0U) return;
size_t i = 0U;
if ((reinterpret_cast<uintptr_t>(dst565) & 0x3U) == 0U) {
uint32_t* dst32 = reinterpret_cast<uint32_t*>(dst565);
size_t i = 0U;
for (; i + 1U < n_px; i += 2U) {
const uint16_t c0 = pal565_256[idx8[i]];
const uint16_t c1 = pal565_256[idx8[i + 1U]];
dst32[i >> 1U] = (static_cast<uint32_t>(c1) << 16U) | c0;
}
if (i < n_px) {
dst565[i] = pal565_256[idx8[i]];
}
return;
}
for (size_t i = 0U; i < n_px; ++i) {
for (; i < n_px; ++i) {
dst565[i] = pal565_256[idx8[i]];
}
}
// Optimised: read 4 bytes at a time when src is 4-byte aligned.
void simd_rgb888_to_rgb565(uint16_t* dst565, const uint8_t* src_rgb888, size_t n_px) {
if (dst565 == nullptr || src_rgb888 == nullptr || n_px == 0U) {
return;
if (dst565 == nullptr || src_rgb888 == nullptr || n_px == 0U) return;
size_t i = 0U;
if ((reinterpret_cast<uintptr_t>(src_rgb888) & 0x3U) == 0U && n_px >= 2U) {
// Fast path: read 4 bytes → extract 1⅓ pixels. Process groups of 4 pixels = 12 bytes = 3 reads.
// Simpler: process pairs — pixel 0 starts at byte 0, pixel 1 at byte 3.
// We read two uint32_t (bytes 0-7) to extract 2 complete RGB888 pixels (6 bytes used).
const uint32_t* src32 = reinterpret_cast<const uint32_t*>(src_rgb888);
for (; i + 3U < n_px; i += 4U) {
// 4 pixels = 12 bytes = 3 uint32 reads.
const uint32_t w0 = src32[0]; // bytes 0-3 : R0 G0 B0 R1
const uint32_t w1 = src32[1]; // bytes 4-7 : G1 B1 R2 G2
const uint32_t w2 = src32[2]; // bytes 8-11: B2 R3 G3 B3
src32 += 3;
const uint8_t r0 = static_cast<uint8_t>(w0);
const uint8_t g0 = static_cast<uint8_t>(w0 >> 8U);
const uint8_t b0 = static_cast<uint8_t>(w0 >> 16U);
const uint8_t r1 = static_cast<uint8_t>(w0 >> 24U);
const uint8_t g1 = static_cast<uint8_t>(w1);
const uint8_t b1 = static_cast<uint8_t>(w1 >> 8U);
const uint8_t r2 = static_cast<uint8_t>(w1 >> 16U);
const uint8_t g2 = static_cast<uint8_t>(w1 >> 24U);
const uint8_t b2 = static_cast<uint8_t>(w2);
const uint8_t r3 = static_cast<uint8_t>(w2 >> 8U);
const uint8_t g3 = static_cast<uint8_t>(w2 >> 16U);
const uint8_t b3 = static_cast<uint8_t>(w2 >> 24U);
dst565[i + 0U] = rgb565(r0, g0, b0);
dst565[i + 1U] = rgb565(r1, g1, b1);
dst565[i + 2U] = rgb565(r2, g2, b2);
dst565[i + 3U] = rgb565(r3, g3, b3);
}
}
for (size_t i = 0U; i < n_px; ++i) {
// Scalar tail.
for (; i < n_px; ++i) {
const uint8_t r = src_rgb888[(i * 3U) + 0U];
const uint8_t g = src_rgb888[(i * 3U) + 1U];
const uint8_t b = src_rgb888[(i * 3U) + 2U];
@@ -281,41 +431,40 @@ void simd_rgb888_to_rgb565(uint16_t* dst565, const uint8_t* src_rgb888, size_t n
}
void simd_yuv422_to_rgb565(uint16_t* dst565, const uint8_t* src_yuv422, size_t n_px) {
if (dst565 == nullptr || src_yuv422 == nullptr || n_px == 0U) {
return;
}
if (dst565 == nullptr || src_yuv422 == nullptr || n_px == 0U) return;
size_t i = 0U;
for (; i + 1U < n_px; i += 2U) {
const uint8_t y0 = src_yuv422[(i * 2U) + 0U];
const uint8_t u = src_yuv422[(i * 2U) + 1U];
const uint8_t u = src_yuv422[(i * 2U) + 1U];
const uint8_t y1 = src_yuv422[(i * 2U) + 2U];
const uint8_t v = src_yuv422[(i * 2U) + 3U];
const uint8_t v = src_yuv422[(i * 2U) + 3U];
const int32_t c0 = static_cast<int32_t>(y0) - 16;
const int32_t c1 = static_cast<int32_t>(y1) - 16;
const int32_t d = static_cast<int32_t>(u) - 128;
const int32_t e = static_cast<int32_t>(v) - 128;
const int32_t d = static_cast<int32_t>(u) - 128;
const int32_t e = static_cast<int32_t>(v) - 128;
const uint8_t r0 = clampU8((298 * c0 + 409 * e + 128) >> 8);
const uint8_t g0 = clampU8((298 * c0 - 100 * d - 208 * e + 128) >> 8);
const uint8_t b0 = clampU8((298 * c0 + 516 * d + 128) >> 8);
const uint8_t r1 = clampU8((298 * c1 + 409 * e + 128) >> 8);
const uint8_t g1 = clampU8((298 * c1 - 100 * d - 208 * e + 128) >> 8);
const uint8_t b1 = clampU8((298 * c1 + 516 * d + 128) >> 8);
dst565[i] = rgb565(r0, g0, b0);
dst565[i + 1U] = rgb565(r1, g1, b1);
dst565[i] = rgb565(clampU8((298 * c0 + 409 * e + 128) >> 8),
clampU8((298 * c0 - 100 * d - 208 * e + 128) >> 8),
clampU8((298 * c0 + 516 * d + 128) >> 8));
dst565[i + 1U] = rgb565(clampU8((298 * c1 + 409 * e + 128) >> 8),
clampU8((298 * c1 - 100 * d - 208 * e + 128) >> 8),
clampU8((298 * c1 + 516 * d + 128) >> 8));
}
// Odd trailing pixel: treat as grey.
if (i < n_px) {
const uint8_t y = src_yuv422[(i * 2U)];
const uint8_t y = src_yuv422[i * 2U];
dst565[i] = rgb565(y, y, y);
}
}
// ============================================================================
// Audio DSP
// ============================================================================
void simd_s16_gain_q15(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n) {
if (dst == nullptr || src == nullptr || n == 0U) {
return;
}
if (dst == nullptr || src == nullptr || n == 0U) return;
#if UI_SIMD_HAS_ESP_DSP
gainQ15EspDsp(dst, src, gain_q15, n);
#else
@@ -329,110 +478,146 @@ void simd_s16_mix2_q15(int16_t* dst,
int16_t ga_q15,
int16_t gb_q15,
size_t n) {
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) return;
#if UI_SIMD_HAS_ESP_DSP
mixQ15EspDsp(dst, a, b, ga_q15, gb_q15, n);
#else
mixQ15Scalar(dst, a, b, ga_q15, gb_q15, n);
#endif
}
// ============================================================================
// Self-test
// ============================================================================
bool selfTest() {
ensureStatusInitialized();
g_status.selftest_runs += 1U;
constexpr size_t kN = 257U;
uint8_t l8[kN] = {};
uint8_t idx[kN] = {};
uint16_t pal[256] = {};
uint16_t out_a[kN] = {};
uint16_t out_b[kN] = {};
uint8_t rgb888[kN * 3U] = {};
uint8_t yuv422[(kN + 1U) * 2U] = {};
int16_t s16_a[kN] = {};
int16_t s16_b[kN] = {};
int16_t s16_out[kN] = {};
int16_t s16_ref[kN] = {};
uint8_t l8[kN] = {};
uint8_t idx[kN] = {};
uint16_t pal[256] = {};
uint16_t out_a[kN] = {};
uint16_t out_b[kN] = {};
uint8_t rgb888[kN * 3U] = {};
uint8_t yuv422[(kN + 1U) * 2U] = {};
int16_t s16_a[kN] = {};
int16_t s16_b[kN] = {};
int16_t s16_out[kN] = {};
int16_t s16_ref[kN] = {};
for (size_t i = 0U; i < kN; ++i) {
l8[i] = static_cast<uint8_t>((i * 31U + 17U) & 0xFFU);
idx[i] = static_cast<uint8_t>((i * 19U + 7U) & 0xFFU);
rgb888[(i * 3U) + 0U] = static_cast<uint8_t>((i * 11U) & 0xFFU);
rgb888[(i * 3U) + 1U] = static_cast<uint8_t>((i * 13U + 3U) & 0xFFU);
rgb888[(i * 3U) + 2U] = static_cast<uint8_t>((i * 17U + 9U) & 0xFFU);
yuv422[(i * 2U)] = static_cast<uint8_t>((i * 5U + 40U) & 0xFFU);
yuv422[(i * 2U) + 1U] = static_cast<uint8_t>((i * 7U + 80U) & 0xFFU);
s16_a[i] = static_cast<int16_t>((static_cast<int32_t>(i) * 97) - 12000);
s16_b[i] = static_cast<int16_t>((static_cast<int32_t>(i) * 53) - 9000);
l8[i] = static_cast<uint8_t>((i * 31U + 17U) & 0xFFU);
idx[i] = static_cast<uint8_t>((i * 19U + 7U) & 0xFFU);
rgb888[(i * 3U) + 0U] = static_cast<uint8_t>((i * 11U) & 0xFFU);
rgb888[(i * 3U) + 1U] = static_cast<uint8_t>((i * 13U + 3U) & 0xFFU);
rgb888[(i * 3U) + 2U] = static_cast<uint8_t>((i * 17U + 9U) & 0xFFU);
yuv422[(i * 2U)] = static_cast<uint8_t>((i * 5U + 40U) & 0xFFU);
yuv422[(i * 2U) + 1U] = static_cast<uint8_t>((i * 7U + 80U) & 0xFFU);
s16_a[i] = static_cast<int16_t>((static_cast<int32_t>(i) * 97) - 12000);
s16_b[i] = static_cast<int16_t>((static_cast<int32_t>(i) * 53) - 9000);
}
for (uint16_t i = 0U; i < 256U; ++i) {
pal[i] = rgb565(static_cast<uint8_t>(i), static_cast<uint8_t>(255U - i), static_cast<uint8_t>(i ^ 0x5AU));
pal[i] = rgb565(static_cast<uint8_t>(i),
static_cast<uint8_t>(255U - i),
static_cast<uint8_t>(i ^ 0x5AU));
}
bool ok = true;
// L8 → RGB565
simd_l8_to_rgb565(out_a, l8, kN);
for (size_t i = 0U; i < kN; ++i) {
out_b[i] = rgb565(l8[i], l8[i], l8[i]);
}
for (size_t i = 0U; i < kN; ++i) { out_b[i] = rgb565(l8[i], l8[i], l8[i]); }
ok = ok && arraysEqual(out_a, out_b, kN);
// Index8 → RGB565
simd_index8_to_rgb565(out_a, idx, pal, kN);
for (size_t i = 0U; i < kN; ++i) {
out_b[i] = pal[idx[i]];
}
for (size_t i = 0U; i < kN; ++i) { out_b[i] = pal[idx[i]]; }
ok = ok && arraysEqual(out_a, out_b, kN);
// RGB888 → RGB565
simd_rgb888_to_rgb565(out_a, rgb888, kN);
for (size_t i = 0U; i < kN; ++i) {
out_b[i] = rgb565(rgb888[(i * 3U) + 0U], rgb888[(i * 3U) + 1U], rgb888[(i * 3U) + 2U]);
out_b[i] = rgb565(rgb888[(i * 3U)], rgb888[(i * 3U) + 1U], rgb888[(i * 3U) + 2U]);
}
ok = ok && arraysEqual(out_a, out_b, kN);
// YUV422 → RGB565 (idempotent: same result twice)
simd_yuv422_to_rgb565(out_a, yuv422, kN - 1U);
simd_yuv422_to_rgb565(out_b, yuv422, kN - 1U);
ok = ok && arraysEqual(out_a, out_b, kN - 1U);
// bswap copy
simd_rgb565_bswap_copy(out_a, out_b, kN - 1U);
for (size_t i = 0U; i < kN - 1U; ++i) {
const uint16_t v = out_b[i];
const uint16_t expected = static_cast<uint16_t>((v << 8U) | (v >> 8U));
if (out_a[i] != expected) { ok = false; break; }
}
// Brightness: level=255 → identity
simd_rgb565_brightness(out_a, out_b, 255U, kN - 1U);
ok = ok && arraysEqual(out_a, out_b, kN - 1U);
// Alpha blend: alpha=255 → dst becomes src
simd_rgb565_copy(out_a, out_b, 0); // zero-length no-op guard
simd_rgb565_fill(out_a, 0x1234U, kN);
simd_rgb565_alpha_blend(out_a, out_b, 255U, kN - 1U);
ok = ok && arraysEqual(out_a, out_b, kN - 1U);
// Gain Q15
simd_s16_gain_q15(s16_out, s16_a, static_cast<int16_t>(16384), kN);
gainQ15Scalar(s16_ref, s16_a, static_cast<int16_t>(16384), kN);
ok = ok && arraysEqual(s16_out, s16_ref, kN);
simd_s16_mix2_q15(s16_out, s16_a, s16_b, static_cast<int16_t>(16384), static_cast<int16_t>(8192), kN);
mixQ15Scalar(s16_ref, s16_a, s16_b, static_cast<int16_t>(16384), static_cast<int16_t>(8192), kN);
// Mix Q15
simd_s16_mix2_q15(s16_out, s16_a, s16_b,
static_cast<int16_t>(16384), static_cast<int16_t>(8192), kN);
mixQ15Scalar(s16_ref, s16_a, s16_b,
static_cast<int16_t>(16384), static_cast<int16_t>(8192), kN);
ok = ok && arraysEqual(s16_out, s16_ref, kN);
if (!ok) {
g_status.selftest_failures += 1U;
Serial.println("[SIMD] selfTest FAILED");
} else {
Serial.println("[SIMD] selfTest OK");
}
return ok;
}
// ============================================================================
// Benchmark
// ============================================================================
SimdBenchResult runBench(uint32_t loops, uint32_t pixels) {
ensureStatusInitialized();
if (loops < kBenchMinLoops) {
loops = kBenchMinLoops;
} else if (loops > kBenchMaxLoops) {
loops = kBenchMaxLoops;
}
if (pixels < kBenchMinPixels) {
pixels = kBenchMinPixels;
} else if (pixels > kBenchMaxPixels) {
pixels = kBenchMaxPixels;
}
if (loops < kBenchMinLoops) loops = kBenchMinLoops;
if (loops > kBenchMaxLoops) loops = kBenchMaxLoops;
if (pixels < kBenchMinPixels) pixels = kBenchMinPixels;
if (pixels > kBenchMaxPixels) pixels = kBenchMaxPixels;
SimdBenchResult result = {};
result.loops = loops;
result.loops = loops;
result.pixels = pixels;
uint8_t* l8 = static_cast<uint8_t*>(runtime::memory::CapsAllocator::allocPsram(pixels, "simd.bench.l8"));
uint8_t* idx = static_cast<uint8_t*>(runtime::memory::CapsAllocator::allocPsram(pixels, "simd.bench.idx"));
uint16_t* pal = static_cast<uint16_t*>(runtime::memory::CapsAllocator::allocInternalDma(sizeof(uint16_t) * 256U, "simd.bench.pal"));
uint16_t* dst565 = static_cast<uint16_t*>(
auto* l8 = static_cast<uint8_t*>(
runtime::memory::CapsAllocator::allocPsram(pixels, "simd.bench.l8"));
auto* idx = static_cast<uint8_t*>(
runtime::memory::CapsAllocator::allocPsram(pixels, "simd.bench.idx"));
auto* pal = static_cast<uint16_t*>(
runtime::memory::CapsAllocator::allocInternalDma(sizeof(uint16_t) * 256U, "simd.bench.pal"));
auto* dst565 = static_cast<uint16_t*>(
runtime::memory::CapsAllocator::allocPsram(static_cast<size_t>(pixels) * sizeof(uint16_t), "simd.bench.dst"));
uint8_t* rgb888 = static_cast<uint8_t*>(
auto* rgb888 = static_cast<uint8_t*>(
runtime::memory::CapsAllocator::allocPsram(static_cast<size_t>(pixels) * 3U, "simd.bench.rgb888"));
int16_t* s16_a = static_cast<int16_t*>(
auto* s16_a = static_cast<int16_t*>(
runtime::memory::CapsAllocator::allocPsram(static_cast<size_t>(pixels) * sizeof(int16_t), "simd.bench.s16a"));
int16_t* s16_out = static_cast<int16_t*>(
auto* s16_out = static_cast<int16_t*>(
runtime::memory::CapsAllocator::allocPsram(static_cast<size_t>(pixels) * sizeof(int16_t), "simd.bench.s16out"));
if (l8 == nullptr || idx == nullptr || pal == nullptr || dst565 == nullptr ||
rgb888 == nullptr || s16_a == nullptr || s16_out == nullptr) {
if (!l8 || !idx || !pal || !dst565 || !rgb888 || !s16_a || !s16_out) {
runtime::memory::CapsAllocator::release(l8);
runtime::memory::CapsAllocator::release(idx);
runtime::memory::CapsAllocator::release(pal);
@@ -444,48 +629,44 @@ SimdBenchResult runBench(uint32_t loops, uint32_t pixels) {
}
for (uint32_t i = 0U; i < pixels; ++i) {
l8[i] = static_cast<uint8_t>((i * 37U + 11U) & 0xFFU);
idx[i] = static_cast<uint8_t>((i * 29U + 3U) & 0xFFU);
rgb888[(i * 3U) + 0U] = static_cast<uint8_t>((i * 9U) & 0xFFU);
l8[i] = static_cast<uint8_t>((i * 37U + 11U) & 0xFFU);
idx[i] = static_cast<uint8_t>((i * 29U + 3U) & 0xFFU);
rgb888[(i * 3U)] = static_cast<uint8_t>((i * 9U) & 0xFFU);
rgb888[(i * 3U) + 1U] = static_cast<uint8_t>((i * 13U + 7U) & 0xFFU);
rgb888[(i * 3U) + 2U] = static_cast<uint8_t>((i * 17U + 5U) & 0xFFU);
s16_a[i] = static_cast<int16_t>((static_cast<int32_t>(i) * 23) - 12000);
s16_a[i] = static_cast<int16_t>((static_cast<int32_t>(i) * 23) - 12000);
}
for (uint16_t i = 0U; i < 256U; ++i) {
pal[i] = rgb565(static_cast<uint8_t>(i), static_cast<uint8_t>(255U - i), static_cast<uint8_t>(i));
pal[i] = rgb565(static_cast<uint8_t>(i),
static_cast<uint8_t>(255U - i),
static_cast<uint8_t>(i));
}
uint32_t started_us = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) {
simd_l8_to_rgb565(dst565, l8, pixels);
}
result.l8_to_rgb565_us = micros() - started_us;
uint32_t t = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) simd_l8_to_rgb565(dst565, l8, pixels);
result.l8_to_rgb565_us = micros() - t;
started_us = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) {
simd_index8_to_rgb565(dst565, idx, pal, pixels);
}
result.idx8_to_rgb565_us = micros() - started_us;
t = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) simd_index8_to_rgb565(dst565, idx, pal, pixels);
result.idx8_to_rgb565_us = micros() - t;
started_us = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) {
simd_rgb888_to_rgb565(dst565, rgb888, pixels);
}
result.rgb888_to_rgb565_us = micros() - started_us;
t = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) simd_rgb888_to_rgb565(dst565, rgb888, pixels);
result.rgb888_to_rgb565_us = micros() - t;
started_us = micros();
for (uint32_t loop = 0U; loop < loops; ++loop) {
t = micros();
for (uint32_t loop = 0U; loop < loops; ++loop)
simd_s16_gain_q15(s16_out, s16_a, static_cast<int16_t>(21845), pixels);
}
result.s16_gain_q15_us = micros() - started_us;
result.s16_gain_q15_us = micros() - t;
g_status.bench_runs += 1U;
g_status.bench_loops = result.loops;
g_status.bench_pixels = result.pixels;
g_status.bench_l8_to_rgb565_us = result.l8_to_rgb565_us;
g_status.bench_idx8_to_rgb565_us = result.idx8_to_rgb565_us;
g_status.bench_loops = result.loops;
g_status.bench_pixels = result.pixels;
g_status.bench_l8_to_rgb565_us = result.l8_to_rgb565_us;
g_status.bench_idx8_to_rgb565_us = result.idx8_to_rgb565_us;
g_status.bench_rgb888_to_rgb565_us = result.rgb888_to_rgb565_us;
g_status.bench_s16_gain_q15_us = result.s16_gain_q15_us;
g_status.bench_s16_gain_q15_us = result.s16_gain_q15_us;
runtime::memory::CapsAllocator::release(l8);
runtime::memory::CapsAllocator::release(idx);
runtime::memory::CapsAllocator::release(pal);
@@ -1,670 +0,0 @@
// scenario_manager.cpp - Story transitions + timing hooks.
#include "scenario_manager.h"
#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;
}
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* const id_candidates[] = {"scenario", "scenario_id", "id"};
const char* scenario_id = ScenarioManager::readScenarioField(
document.as<JsonVariantConst>(), id_candidates, sizeof(id_candidates) / sizeof(id_candidates[0]));
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
const char* ScenarioManager::readScenarioField(JsonVariantConst root,
const char* const* candidates,
size_t candidate_count) {
if (candidates == nullptr || candidate_count == 0U || root.isNull()) {
return nullptr;
}
JsonObjectConst object = root.as<JsonObjectConst>();
if (object.isNull()) {
return nullptr;
}
for (size_t index = 0U; index < candidate_count; ++index) {
const char* key = candidates[index];
if (key == nullptr || key[0] == '\0') {
continue;
}
JsonVariantConst candidate = object[key];
if (!candidate.is<const char*>()) {
continue;
}
const char* text = candidate.as<const char*>();
if (text != nullptr && text[0] != '\0') {
return text;
}
}
return nullptr;
}
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;
}
bool ScenarioManager::beginById(const char* scenario_id) {
scenario_ = nullptr;
initial_step_override_.remove(0);
clearStepResourceOverrides();
if (scenario_id != nullptr && scenario_id[0] != '\0') {
scenario_ = storyScenarioV2ById(scenario_id);
}
if (scenario_ == nullptr) {
Serial.printf("[SCENARIO] unknown scenario id: %s\n", (scenario_id != nullptr) ? scenario_id : "null");
return false;
}
if (storyValidateScenarioDef(*scenario_, nullptr)) {
Serial.printf("[SCENARIO] loaded built-in scenario by id: %s v%u (%u steps)\n",
scenario_->id,
scenario_->version,
scenario_->stepCount);
} else {
Serial.printf("[SCENARIO] warning: validation failed for %s\n", scenario_->id);
}
reset();
return true;
}
void ScenarioManager::reset() {
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) {
const StepDef* step = currentStep();
if (step != nullptr && step->id != nullptr && std::strcmp(step->id, "STEP_WAIT_UNLOCK") == 0) {
// Contract: any single press (short or long) from lock screen jumps to LA detector.
if (key >= 1U && key <= 5U) {
if (dispatchEvent(StoryEventType::kSerial, "BTN_NEXT", now_ms, "btn_any_short")) {
return;
}
dispatchEvent(StoryEventType::kSerial, "NEXT", now_ms, "btn_any_short_legacy");
return;
}
}
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 {
if (!dispatchEvent(StoryEventType::kSerial, "BTN_NEXT", now_ms, "btn5_short")) {
dispatchEvent(StoryEventType::kSerial, "NEXT", now_ms, "btn5_short_legacy");
}
}
break;
default:
break;
}
}
void ScenarioManager::notifyAudioDone(uint32_t now_ms) {
dispatchEvent(StoryEventType::kAudioDone, "AUDIO_DONE", now_ms, "audio_done");
}
bool ScenarioManager::notifySerialEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "SERIAL_EVENT";
return dispatchEvent(StoryEventType::kSerial, name, now_ms, "serial_event");
}
bool ScenarioManager::notifyTimerEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "TIMER_EVENT";
return dispatchEvent(StoryEventType::kTimer, name, now_ms, "timer_event");
}
bool ScenarioManager::notifyActionEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "ACTION_EVENT";
return dispatchEvent(StoryEventType::kAction, name, now_ms, "action_event");
}
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;
const char* const* action_ids = out.step->resources.actionIds;
uint8_t action_count = out.step->resources.actionCount;
applyStepResourceOverride(out.step, &screen_scene_id, &audio_pack_id, &action_ids, &action_count);
out.screen_scene_id = screen_scene_id;
out.audio_pack_id = audio_pack_id;
out.action_ids = action_ids;
out.action_count = action_count;
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;
}
uint32_t ScenarioManager::transitionEventMask() const {
if (scenario_ == nullptr || scenario_->steps == nullptr) {
return 0U;
}
uint32_t mask = 0U;
for (uint8_t step_index = 0; step_index < scenario_->stepCount; ++step_index) {
const StepDef& step = scenario_->steps[step_index];
if (step.transitions == nullptr || step.transitionCount == 0U) {
continue;
}
for (uint8_t transition_index = 0; transition_index < step.transitionCount; ++transition_index) {
const TransitionDef& transition = step.transitions[transition_index];
if (transition.trigger != StoryTransitionTrigger::kOnEvent &&
transition.trigger != StoryTransitionTrigger::kAfterMs) {
continue;
}
const uint8_t event_index = static_cast<uint8_t>(transition.eventType);
if (event_index >= 31U) {
continue;
}
mask |= (1UL << event_index);
}
}
return mask;
}
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_overrides_[index].action_count = 0U;
for (uint8_t action_index = 0; action_index < StepResourceOverride::kMaxActionOverrides; ++action_index) {
step_resource_overrides_[index].action_ids[action_index].remove(0);
step_resource_overrides_[index].action_ptrs[action_index] = nullptr;
}
}
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* const initial_step_keys[] = {"initial_step", "initialStepId"};
const char* initial_step =
readScenarioField(document.as<JsonVariantConst>(), initial_step_keys, sizeof(initial_step_keys) / sizeof(initial_step_keys[0]));
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) {
step_id = stringOrNull(step_obj["step_id"]);
}
if (step_id == nullptr) {
step_id = stringOrNull(step_obj["stepId"]);
}
if (step_id == nullptr) {
continue;
}
const char* const screen_keys[] = {"screen_scene_id", "screenSceneId"};
const char* screen_scene_id =
readScenarioField(variant, screen_keys, sizeof(screen_keys) / sizeof(screen_keys[0]));
if (screen_scene_id == nullptr) {
screen_scene_id = readScenarioField(
step_obj["resources"], screen_keys, sizeof(screen_keys) / sizeof(screen_keys[0]));
}
const char* const audio_keys[] = {"audio_pack_id", "audioPackId"};
const char* audio_pack_id =
readScenarioField(variant, audio_keys, sizeof(audio_keys) / sizeof(audio_keys[0]));
if (audio_pack_id == nullptr) {
audio_pack_id = readScenarioField(step_obj["resources"],
audio_keys,
sizeof(audio_keys) / sizeof(audio_keys[0]));
}
JsonArrayConst action_ids = step_obj["action_ids"].as<JsonArrayConst>();
if (action_ids.isNull()) {
action_ids = step_obj["actionIds"].as<JsonArrayConst>();
}
if (action_ids.isNull()) {
action_ids = step_obj["actions"].as<JsonArrayConst>();
}
if (action_ids.isNull()) {
action_ids = step_obj["resources"]["action_ids"].as<JsonArrayConst>();
}
if (action_ids.isNull()) {
action_ids = step_obj["resources"]["actionIds"].as<JsonArrayConst>();
}
const bool has_action_override = !action_ids.isNull() && action_ids.size() > 0U;
if (screen_scene_id == nullptr && audio_pack_id == nullptr && !has_action_override) {
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 : "";
entry.action_count = 0U;
for (uint8_t action_index = 0; action_index < StepResourceOverride::kMaxActionOverrides; ++action_index) {
entry.action_ids[action_index].remove(0);
entry.action_ptrs[action_index] = nullptr;
}
if (has_action_override) {
for (JsonVariantConst action_id_variant : action_ids) {
if (entry.action_count >= StepResourceOverride::kMaxActionOverrides) {
break;
}
if (!action_id_variant.is<const char*>()) {
continue;
}
const char* action_id = action_id_variant.as<const char*>();
if (action_id == nullptr || action_id[0] == '\0') {
continue;
}
entry.action_ids[entry.action_count] = action_id;
entry.action_ptrs[entry.action_count] = entry.action_ids[entry.action_count].c_str();
++entry.action_count;
}
}
}
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 char* const** out_action_ids,
uint8_t* out_action_count) 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();
}
if (out_action_ids != nullptr && out_action_count != nullptr && entry->action_count > 0U) {
*out_action_ids = entry->action_ptrs;
*out_action_count = entry->action_count;
}
}
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
#include "audio_manager.h"
#include "ui_freenove_config.h"
#include "core/str_utils.h"
namespace {
@@ -22,39 +23,10 @@ constexpr uint16_t kRecorderChannels = 1U;
constexpr uint32_t kRecorderCapturePeriodMs = 30U;
constexpr size_t kRecorderRawSamples = 256U;
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
char toLowerAscii(char ch) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
for (;; ++index) {
const char l = lhs[index];
const char r = rhs[index];
if (l == '\0' && r == '\0') {
return true;
}
if (toLowerAscii(l) != toLowerAscii(r)) {
return false;
}
}
}
bool isAsciiDigit(char ch) {
return ch >= '0' && ch <= '9';
}
@@ -107,9 +79,9 @@ int compareNaturalPath(const String& lhs, const String& rhs) {
bool MediaManager::begin(const Config& config) {
config_ = config;
copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
core::copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
core::copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
core::copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
if (config_.record_max_seconds == 0U) {
config_.record_max_seconds = 30U;
}
@@ -121,9 +93,9 @@ bool MediaManager::begin(const Config& config) {
snapshot_.ready = true;
snapshot_.record_simulated = false;
snapshot_.record_limit_seconds = config_.record_max_seconds;
copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
core::copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
core::copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
core::copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
ensureDir(config_.music_dir);
ensureDir(config_.picture_dir);
ensureDir(config_.record_dir);
@@ -222,7 +194,7 @@ bool MediaManager::play(const char* path, AudioManager* audio) {
const bool ok = audio->play(normalized_path.c_str());
snapshot_.playing = ok;
if (ok) {
copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
core::copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
clearLastError();
} else {
setLastError("media_play_failed");
@@ -271,7 +243,7 @@ bool MediaManager::startRecording(uint16_t seconds, const char* filename_hint) {
snapshot_.record_started_ms = millis();
snapshot_.record_elapsed_seconds = 0U;
next_capture_ms_ = snapshot_.record_started_ms;
copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
core::copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
clearLastError();
return true;
}
@@ -298,7 +270,7 @@ MediaManager::Snapshot MediaManager::snapshot() const {
void MediaManager::setLastError(const char* message) {
snapshot_.last_ok = false;
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
core::copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void MediaManager::clearLastError() {
@@ -328,13 +300,13 @@ String MediaManager::resolveKindDir(const char* kind) const {
if (kind == nullptr) {
return String();
}
if (equalsIgnoreCase(kind, "picture") || equalsIgnoreCase(kind, "pictures")) {
if (core::equalsIgnoreCase(kind, "picture") || core::equalsIgnoreCase(kind, "pictures")) {
return config_.picture_dir;
}
if (equalsIgnoreCase(kind, "music") || equalsIgnoreCase(kind, "audio")) {
if (core::equalsIgnoreCase(kind, "music") || core::equalsIgnoreCase(kind, "audio")) {
return config_.music_dir;
}
if (equalsIgnoreCase(kind, "recorder") || equalsIgnoreCase(kind, "record") || equalsIgnoreCase(kind, "records")) {
if (core::equalsIgnoreCase(kind, "recorder") || core::equalsIgnoreCase(kind, "record") || core::equalsIgnoreCase(kind, "records")) {
return config_.record_dir;
}
return String();
@@ -9,6 +9,7 @@
#include <cctype>
#include <cstdio>
#include <cstring>
#include "core/str_utils.h"
namespace {
@@ -197,7 +198,7 @@ void NetworkManager::update(uint32_t now_ms) {
Serial.printf("[NET] local retry paused ap_clients=%u\n", ap_clients);
}
} else if (!sta_connecting_ && (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_))) {
if (fallback_ap_active_ && equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
if (fallback_ap_active_ && core::equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
// Avoid self-association when fallback AP and local target share the same SSID.
WiFi.softAPdisconnect(true);
fallback_ap_active_ = false;
@@ -223,10 +224,10 @@ void NetworkManager::update(uint32_t now_ms) {
void NetworkManager::configureFallbackAp(const char* ssid, const char* password) {
if (ssid != nullptr) {
copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
core::copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
}
if (password != nullptr) {
copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
core::copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
}
Serial.printf("[NET] fallback AP configured ssid=%s\n", fallback_ap_ssid_);
}
@@ -237,10 +238,10 @@ void NetworkManager::configureLocalPolicy(const char* ssid,
uint32_t retry_ms,
bool pause_retry_when_ap_client) {
if (ssid != nullptr) {
copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
core::copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
}
if (password != nullptr) {
copyText(local_target_password_, sizeof(local_target_password_), password);
core::copyText(local_target_password_, sizeof(local_target_password_), password);
}
force_ap_if_not_local_ = force_if_not_local;
pause_local_retry_when_ap_client_ = pause_retry_when_ap_client;
@@ -265,7 +266,7 @@ bool NetworkManager::connectSta(const char* ssid, const char* password) {
return false;
}
if (WiFi.status() == WL_CONNECTED && equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
if (WiFi.status() == WL_CONNECTED && core::equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
sta_connecting_ = false;
refreshSnapshot();
return true;
@@ -274,7 +275,7 @@ bool NetworkManager::connectSta(const char* ssid, const char* password) {
const uint8_t mode = (manual_ap_active_ || fallback_ap_active_) ? WIFI_MODE_APSTA : WIFI_MODE_STA;
WiFi.mode(static_cast<wifi_mode_t>(mode));
WiFi.begin(ssid, (password != nullptr) ? password : "");
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
core::copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
sta_connecting_ = true;
sta_connect_requested_at_ms_ = millis();
refreshSnapshot();
@@ -306,7 +307,7 @@ bool NetworkManager::isConnectedToLocalTarget() const {
if (local_target_ssid_[0] == '\0' || WiFi.status() != WL_CONNECTED) {
return false;
}
if (!equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
if (!core::equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
return false;
}
return !isConnectedToSelfAp();
@@ -361,7 +362,7 @@ bool NetworkManager::startApInternal(const char* ssid, const char* password, boo
ok = WiFi.softAP(ssid, nullptr, kEspNowPreferredChannel);
}
if (ok) {
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
core::copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
if (manual_request) {
manual_ap_active_ = true;
fallback_ap_active_ = false;
@@ -531,7 +532,7 @@ bool NetworkManager::espNowPeerAt(uint8_t index, char* out_mac, size_t out_capac
bool ok = false;
enterCritical(&rx_queue_mux_, false);
if (index < peer_cache_count_) {
copyText(out_mac, out_capacity, peer_cache_[index]);
core::copyText(out_mac, out_capacity, peer_cache_[index]);
ok = true;
}
exitCritical(&rx_queue_mux_, false);
@@ -671,7 +672,7 @@ bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
}
char frame[kEspNowFrameCapacity + 1U] = {0};
copyText(frame, sizeof(frame), text);
core::copyText(frame, sizeof(frame), text);
trimAsciiInPlace(frame);
if (frame[0] == '\0') {
return false;
@@ -699,7 +700,7 @@ bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
}
}
if (target != nullptr && target[0] != '\0' && !equalsIgnoreCase(target, kBroadcastTarget)) {
if (target != nullptr && target[0] != '\0' && !core::equalsIgnoreCase(target, kBroadcastTarget)) {
uint8_t target_mac[6] = {0};
if (parseMac(target, target_mac)) {
return sendEspNowText(target_mac, frame);
@@ -739,7 +740,7 @@ bool NetworkManager::consumeEspNowMessage(char* out_payload,
exitCritical(&rx_queue_mux_, false);
char normalized_payload[kPayloadCapacity] = {0};
copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
core::copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
char msg_id[32] = {0};
uint32_t seq = 0U;
char envelope_type[24] = {0};
@@ -749,9 +750,9 @@ bool NetworkManager::consumeEspNowMessage(char* out_payload,
StaticJsonDocument<512> document;
if (!deserializeJson(document, entry.payload) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
JsonVariantConst root = document.as<JsonVariantConst>();
copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
core::copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
seq = root["seq"] | 0U;
copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
core::copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
const bool envelope_ack = root["ack"] | false;
const bool ack_response = envelope_ack && std::strcmp(envelope_type, "ack") == 0;
if (ack_response) {
@@ -759,37 +760,37 @@ bool NetworkManager::consumeEspNowMessage(char* out_payload,
}
ack_requested = envelope_ack;
if (root["payload"].is<const char*>()) {
copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
core::copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
} else if (!root["payload"].isNull()) {
char payload_text[kPayloadCapacity] = {0};
const size_t payload_size = serializeJson(root["payload"], payload_text, sizeof(payload_text));
if (payload_size > 0U) {
copyText(normalized_payload, sizeof(normalized_payload), payload_text);
core::copyText(normalized_payload, sizeof(normalized_payload), payload_text);
}
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
core::copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
}
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
core::copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
if (out_payload != nullptr && payload_capacity > 0U) {
copyText(out_payload, payload_capacity, normalized_payload);
core::copyText(out_payload, payload_capacity, normalized_payload);
}
if (out_peer != nullptr && peer_capacity > 0U) {
copyText(out_peer, peer_capacity, entry.peer);
core::copyText(out_peer, peer_capacity, entry.peer);
}
if (out_msg_id != nullptr && msg_id_capacity > 0U) {
copyText(out_msg_id, msg_id_capacity, msg_id);
core::copyText(out_msg_id, msg_id_capacity, msg_id);
}
if (out_seq != nullptr) {
*out_seq = seq;
}
if (out_type != nullptr && type_capacity > 0U) {
copyText(out_type, type_capacity, envelope_type);
core::copyText(out_type, type_capacity, envelope_type);
}
if (out_ack_requested != nullptr) {
*out_ack_requested = ack_requested;
@@ -839,24 +840,12 @@ uint8_t NetworkManager::parseHexByte(char high, char low, bool* ok) {
return static_cast<uint8_t>((hi << 4) | lo);
}
void NetworkManager::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
if (out == nullptr || out_size == 0U) {
return;
}
if (mac == nullptr) {
copyText(out, out_size, "00:00:00:00:00:00");
core::copyText(out, out_size, "00:00:00:00:00:00");
return;
}
snprintf(out,
@@ -870,22 +859,6 @@ void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
mac[5]);
}
bool NetworkManager::equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
while (lhs[index] != '\0' && rhs[index] != '\0') {
const char l = static_cast<char>(std::tolower(static_cast<unsigned char>(lhs[index])));
const char r = static_cast<char>(std::tolower(static_cast<unsigned char>(rhs[index])));
if (l != r) {
return false;
}
++index;
}
return lhs[index] == '\0' && rhs[index] == '\0';
}
const char* NetworkManager::wifiModeLabel(uint8_t mode) {
switch (mode) {
case WIFI_MODE_STA:
@@ -965,15 +938,15 @@ void NetworkManager::cachePeer(const uint8_t mac[6], bool from_isr) {
}
}
if (peer_cache_count_ < kMaxPeerCache) {
copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
core::copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
++peer_cache_count_;
exitCritical(&rx_queue_mux_, from_isr);
return;
}
for (uint8_t index = 1U; index < kMaxPeerCache; ++index) {
copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
core::copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
}
copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
core::copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
exitCritical(&rx_queue_mux_, from_isr);
}
@@ -996,7 +969,7 @@ void NetworkManager::forgetPeer(const uint8_t mac[6]) {
continue;
}
for (uint8_t move = index + 1U; move < peer_cache_count_; ++move) {
copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
core::copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
}
peer_cache_[peer_cache_count_ - 1U][0] = '\0';
--peer_cache_count_;
@@ -1023,10 +996,10 @@ bool NetworkManager::queueEspNowMessage(const char* payload,
++espnow_drop_packets_;
}
EspNowMessage& slot = rx_queue_[rx_queue_tail_];
copyText(slot.payload, sizeof(slot.payload), payload);
copyText(slot.peer, sizeof(slot.peer), peer);
copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
copyText(slot.type, sizeof(slot.type), type);
core::copyText(slot.payload, sizeof(slot.payload), payload);
core::copyText(slot.peer, sizeof(slot.peer), peer);
core::copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
core::copyText(slot.type, sizeof(slot.type), type);
slot.seq = seq;
slot.ack_requested = ack_requested;
rx_queue_tail_ = static_cast<uint8_t>((rx_queue_tail_ + 1U) % kRxQueueSize);
@@ -1054,19 +1027,19 @@ void NetworkManager::refreshSnapshot() {
char state_label[16] = {0};
uint8_t ap_clients = 0U;
copyText(local_target, sizeof(local_target), local_target_ssid_);
copyText(mode_label, sizeof(mode_label), wifiModeLabel(static_cast<uint8_t>(mode)));
copyText(state_label,
core::copyText(local_target, sizeof(local_target), local_target_ssid_);
core::copyText(mode_label, sizeof(mode_label), wifiModeLabel(static_cast<uint8_t>(mode)));
core::copyText(state_label,
sizeof(state_label),
networkStateLabel(sta_connected, sta_connecting_, ap_enabled, fallback_ap_active));
if (sta_connected) {
copyText(sta_ssid, sizeof(sta_ssid), WiFi.SSID().c_str());
copyText(ip, sizeof(ip), WiFi.localIP().toString().c_str());
core::copyText(sta_ssid, sizeof(sta_ssid), WiFi.SSID().c_str());
core::copyText(ip, sizeof(ip), WiFi.localIP().toString().c_str());
} else if (ap_enabled) {
copyText(ip, sizeof(ip), WiFi.softAPIP().toString().c_str());
core::copyText(ip, sizeof(ip), WiFi.softAPIP().toString().c_str());
}
if (ap_enabled) {
copyText(ap_ssid, sizeof(ap_ssid), WiFi.softAPSSID().c_str());
core::copyText(ap_ssid, sizeof(ap_ssid), WiFi.softAPSSID().c_str());
ap_clients = WiFi.softAPgetStationNum();
}
@@ -1081,12 +1054,12 @@ void NetworkManager::refreshSnapshot() {
snapshot_.local_retry_paused = local_retry_paused_;
snapshot_.rssi = rssi;
snapshot_.channel = channel;
copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target);
copyText(snapshot_.mode, sizeof(snapshot_.mode), mode_label);
copyText(snapshot_.state, sizeof(snapshot_.state), state_label);
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), sta_ssid);
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ap_ssid);
copyText(snapshot_.ip, sizeof(snapshot_.ip), ip);
core::copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target);
core::copyText(snapshot_.mode, sizeof(snapshot_.mode), mode_label);
core::copyText(snapshot_.state, sizeof(snapshot_.state), state_label);
core::copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), sta_ssid);
core::copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ap_ssid);
core::copyText(snapshot_.ip, sizeof(snapshot_.ip), ip);
snapshot_.ap_clients = ap_clients;
snapshot_.espnow_peer_count = peer_cache_count_;
snapshot_.espnow_rx_packets = espnow_rx_packets_;
@@ -1112,13 +1085,13 @@ void NetworkManager::handleEspNowRecv(const uint8_t* mac_addr, const uint8_t* da
cachePeer(mac_addr, from_isr);
enterCritical(&rx_queue_mux_, from_isr);
++espnow_rx_packets_;
copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
core::copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
core::copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
snapshot_.espnow_last_seq = 0U;
snapshot_.espnow_last_ack = false;
snapshot_.last_msg_id[0] = '\0';
copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
core::copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
core::copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
exitCritical(&rx_queue_mux_, from_isr);
queueEspNowMessage(payload, peer_text, "", 0U, "", false, from_isr);
}
@@ -1,29 +0,0 @@
// touch_manager.cpp - optional touch bridge.
#include "touch_manager.h"
#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::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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@
#include <Arduino.h>
#include <LittleFS.h>
#include <cstring>
#include "core/str_utils.h"
#include <lvgl.h>
@@ -14,18 +15,6 @@
namespace ui {
namespace {
void copyTextSafe(char* out, size_t out_size, const char* value) {
if (out == nullptr || out_size == 0U) {
return;
}
if (value == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, value, out_size - 1U);
out[out_size - 1U] = '\0';
}
} // namespace
void QrSceneController::onSceneEnter(QrScanController* scanner, lv_obj_t* subtitle_label) {
@@ -73,7 +62,7 @@ void QrSceneController::handleDecodedPayload(const char* payload,
lv_obj_t* subtitle_label,
lv_obj_t* symbol_label) {
const char* safe_payload = (payload != nullptr) ? payload : "";
copyTextSafe(last_payload_, sizeof(last_payload_), safe_payload);
core::copyText(last_payload_, sizeof(last_payload_), safe_payload);
last_decode_ms_ = now_ms;
last_match_ = decoder_valid && rules.matches(safe_payload);
feedback_until_ms_ = now_ms + (last_match_ ? 1800U : 900U);
@@ -90,7 +79,7 @@ void QrSceneController::handleDecodedPayload(const char* payload,
if (symbol_label != nullptr) {
lv_label_set_text(symbol_label, last_match_ ? "WINNER" : "QR");
}
copyTextSafe(pending_runtime_event_,
core::copyText(pending_runtime_event_,
sizeof(pending_runtime_event_),
last_match_ ? "QR_OK" : "QR_INVALID");
pending_runtime_event_valid_ = true;
@@ -133,7 +122,7 @@ bool QrSceneController::consumeRuntimeEvent(char* out_event, size_t capacity) {
if (out_event == nullptr || capacity == 0U || !pending_runtime_event_valid_) {
return false;
}
copyTextSafe(out_event, capacity, pending_runtime_event_);
core::copyText(out_event, capacity, pending_runtime_event_);
pending_runtime_event_[0] = '\0';
pending_runtime_event_valid_ = false;
return true;
@@ -143,7 +132,7 @@ bool QrSceneController::queueSimulatedPayload(const char* payload) {
if (payload == nullptr || payload[0] == '\0') {
return false;
}
copyTextSafe(simulated_payload_, sizeof(simulated_payload_), payload);
core::copyText(simulated_payload_, sizeof(simulated_payload_), payload);
simulated_pending_ = true;
return true;
}
@@ -5,22 +5,11 @@
#include <cctype>
#include <cstring>
#include <strings.h>
#include "core/str_utils.h"
namespace ui {
namespace {
void copyTextSafe(char* out, size_t out_size, const char* value) {
if (out == nullptr || out_size == 0U) {
return;
}
if (value == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, value, out_size - 1U);
out[out_size - 1U] = '\0';
}
bool startsWithCaseInsensitive(const char* value, const char* prefix) {
if (value == nullptr || prefix == nullptr) {
return false;
@@ -194,18 +183,18 @@ void QrValidationRules::configureFromPayload(JsonVariantConst root) {
if (value[0] == '\0' || expected_count_ >= kExpectedMax) {
continue;
}
copyTextSafe(expected_values_[expected_count_], sizeof(expected_values_[0]), value);
core::copyText(expected_values_[expected_count_], sizeof(expected_values_[0]), value);
++expected_count_;
}
} else {
const char* expected = qr["expected"] | "";
if (expected[0] != '\0') {
copyTextSafe(expected_values_[0], sizeof(expected_values_[0]), expected);
core::copyText(expected_values_[0], sizeof(expected_values_[0]), expected);
expected_count_ = 1U;
}
}
copyTextSafe(prefix_, sizeof(prefix_), qr["prefix"] | "");
copyTextSafe(contains_, sizeof(contains_), qr["contains"] | "");
core::copyText(prefix_, sizeof(prefix_), qr["prefix"] | "");
core::copyText(contains_, sizeof(contains_), qr["contains"] | "");
}
bool QrValidationRules::matches(const char* payload) const {
@@ -213,7 +202,7 @@ bool QrValidationRules::matches(const char* payload) const {
return false;
}
char buffer[192] = {0};
copyTextSafe(buffer, sizeof(buffer), payload);
core::copyText(buffer, sizeof(buffer), payload);
trimAsciiWhitespaceInPlace(buffer);
if (buffer[0] == '\0') {
return false;
@@ -234,7 +223,7 @@ bool QrValidationRules::matches(const char* payload) const {
return false;
}
char crc_buffer[192] = {0};
copyTextSafe(crc_buffer, sizeof(crc_buffer), buffer);
core::copyText(crc_buffer, sizeof(crc_buffer), buffer);
if (case_insensitive_) {
asciiUpperInPlace(crc_buffer);
}
+20 -31
View File
@@ -27,6 +27,7 @@
#include "ui/scene_state.h"
#include "ui/fx/fx_engine.h"
#include "ui_fonts.h"
#include "core/str_utils.h"
namespace {
@@ -141,18 +142,6 @@ int16_t activeDisplayHeight() {
return ((FREENOVE_LCD_ROTATION & 0x1U) != 0U) ? FREENOVE_LCD_WIDTH : FREENOVE_LCD_HEIGHT;
}
void copyTextSafe(char* out, size_t out_size, const char* value) {
if (out == nullptr || out_size == 0U) {
return;
}
if (value == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, value, out_size - 1U);
out[out_size - 1U] = '\0';
}
void trimAsciiWhitespace(char* text) {
if (text == nullptr) {
return;
@@ -1622,9 +1611,9 @@ void UiManager::renderLgfxSceneTextOverlay(uint32_t now_ms) {
if (prev_blank && win_etape_credits_pause_ms_[prev] == 0U) {
return;
}
copyTextSafe(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, " ");
core::copyText(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, " ");
} else {
copyTextSafe(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, cleaned);
core::copyText(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, cleaned);
}
win_etape_credits_size_[win_etape_credits_count_] = current_size_tag;
win_etape_credits_align_[win_etape_credits_count_] = current_align_tag;
@@ -3860,10 +3849,10 @@ void UiManager::renderScene(const ScenarioDef* scenario,
const char* audio_pack_id_for_ui = (audio_pack_id != nullptr && audio_pack_id[0] != '\0') ? audio_pack_id : "";
if (normalized_scene_id == nullptr) {
scene_status_.valid = false;
copyTextSafe(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
copyTextSafe(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
copyTextSafe(scene_status_.scene_id, sizeof(scene_status_.scene_id), raw_scene_id);
copyTextSafe(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
core::copyText(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
core::copyText(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
core::copyText(scene_status_.scene_id, sizeof(scene_status_.scene_id), raw_scene_id);
core::copyText(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
UI_LOGI("unknown scene id '%s' in scenario=%s step=%s", raw_scene_id, scenario_id, step_id_for_log);
return;
}
@@ -5098,7 +5087,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
la_bg_last_ms_ = 0U;
la_overlay_caption_font_ = la_overlay_caption_font;
la_overlay_caption_size_ = la_overlay_caption_size;
copyTextSafe(la_overlay_caption_, sizeof(la_overlay_caption_), la_overlay_caption.c_str());
core::copyText(la_overlay_caption_, sizeof(la_overlay_caption_), la_overlay_caption.c_str());
warning_gyrophare_enabled_ = warning_gyrophare_enabled && (std::strcmp(scene_id, "SCENE_WARNING") == 0);
warning_gyrophare_disable_direct_fx_ = warning_gyrophare_disable_direct_fx;
warning_lgfx_only_ = warning_lgfx_only && (std::strcmp(scene_id, "SCENE_WARNING") == 0);
@@ -5110,7 +5099,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
warning_gyrophare_fps_ = warning_gyrophare_fps;
warning_gyrophare_speed_deg_per_sec_ = warning_gyrophare_speed_deg_per_sec;
warning_gyrophare_beam_width_deg_ = warning_gyrophare_beam_width_deg;
copyTextSafe(warning_gyrophare_message_, sizeof(warning_gyrophare_message_), warning_gyrophare_message.c_str());
core::copyText(warning_gyrophare_message_, sizeof(warning_gyrophare_message_), warning_gyrophare_message.c_str());
warning_gyrophare_.destroy();
if (warning_gyrophare_enabled_ && scene_root_ != nullptr) {
ui::effects::SceneGyrophareConfig gyro_config = {};
@@ -5555,19 +5544,19 @@ void UiManager::renderScene(const ScenarioDef* scenario,
scene_status_.accent_rgb = accent_rgb;
scene_status_.text_rgb = text_rgb;
}
copyTextSafe(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
copyTextSafe(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
copyTextSafe(scene_status_.scene_id, sizeof(scene_status_.scene_id), scene_id);
copyTextSafe(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
copyTextSafe(scene_status_.title, sizeof(scene_status_.title), title_ascii.c_str());
copyTextSafe(scene_status_.subtitle, sizeof(scene_status_.subtitle), subtitle_ascii.c_str());
copyTextSafe(scene_status_.symbol, sizeof(scene_status_.symbol), symbol_ascii.c_str());
copyTextSafe(scene_status_.symbol_align, sizeof(scene_status_.symbol_align), symbol_align_token);
copyTextSafe(scene_status_.text_backend,
core::copyText(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
core::copyText(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
core::copyText(scene_status_.scene_id, sizeof(scene_status_.scene_id), scene_id);
core::copyText(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
core::copyText(scene_status_.title, sizeof(scene_status_.title), title_ascii.c_str());
core::copyText(scene_status_.subtitle, sizeof(scene_status_.subtitle), subtitle_ascii.c_str());
core::copyText(scene_status_.symbol, sizeof(scene_status_.symbol), symbol_ascii.c_str());
core::copyText(scene_status_.symbol_align, sizeof(scene_status_.symbol_align), symbol_align_token);
core::copyText(scene_status_.text_backend,
sizeof(scene_status_.text_backend),
scene_use_lgfx_text_overlay_ ? "lgfx_overlay" : "lvgl");
copyTextSafe(scene_status_.effect, sizeof(scene_status_.effect), effectToToken(effect));
copyTextSafe(scene_status_.transition, sizeof(scene_status_.transition), transitionToToken(transition));
core::copyText(scene_status_.effect, sizeof(scene_status_.effect), effectToToken(effect));
core::copyText(scene_status_.transition, sizeof(scene_status_.transition), transitionToToken(transition));
std::strncpy(last_scene_id_, scene_id, sizeof(last_scene_id_) - 1U);
last_scene_id_[sizeof(last_scene_id_) - 1U] = '\0';
last_payload_crc_ = payload_crc;
@@ -1,899 +0,0 @@
#if defined(UI_MANAGER_SPLIT_IMPL)
void UiManager::initGraphicsPipeline() {
flush_ctx_ = {};
buffer_cfg_ = {};
graphics_stats_ = {};
pending_lvgl_flush_request_ = false;
pending_full_repaint_request_ = false;
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = 0U;
async_fallback_until_ms_ = 0U;
if (draw_buf1_owned_ && draw_buf1_ != nullptr) {
runtime::memory::CapsAllocator::release(draw_buf1_);
}
if (draw_buf2_owned_ && draw_buf2_ != nullptr) {
runtime::memory::CapsAllocator::release(draw_buf2_);
}
if (dma_trans_buf_owned_ && dma_trans_buf_ != nullptr) {
runtime::memory::CapsAllocator::release(dma_trans_buf_);
}
if (full_frame_buf_owned_ && full_frame_buf_ != nullptr) {
runtime::memory::CapsAllocator::release(full_frame_buf_);
}
draw_buf1_ = nullptr;
draw_buf2_ = nullptr;
draw_buf1_owned_ = false;
draw_buf2_owned_ = false;
dma_trans_buf_ = nullptr;
dma_trans_buf_pixels_ = 0U;
dma_trans_buf_owned_ = false;
full_frame_buf_ = nullptr;
full_frame_buf_owned_ = false;
color_lut_ready_ = false;
dma_requested_ = false;
dma_available_ = false;
async_flush_enabled_ = false;
buffer_cfg_.selected_trans_lines = 0U;
if (kUseColor256Runtime) {
for (uint16_t value = 0; value < 256U; ++value) {
const uint8_t r3 = static_cast<uint8_t>((value >> 5U) & 0x07U);
const uint8_t g3 = static_cast<uint8_t>((value >> 2U) & 0x07U);
const uint8_t b2 = static_cast<uint8_t>(value & 0x03U);
const uint8_t r5 = static_cast<uint8_t>((r3 * 31U + 3U) / 7U);
const uint8_t g6 = static_cast<uint8_t>((g3 * 63U + 3U) / 7U);
const uint8_t b5 = static_cast<uint8_t>((b2 * 31U + 1U) / 3U);
rgb332_to_565_lut_[value] =
static_cast<uint16_t>((static_cast<uint16_t>(r5) << 11U) |
(static_cast<uint16_t>(g6) << 5U) |
static_cast<uint16_t>(b5));
}
color_lut_ready_ = true;
}
if (!allocateDrawBuffers()) {
UI_LOGI("draw buffer allocation failed");
return;
}
initDmaEngine();
const uint16_t width = static_cast<uint16_t>(activeDisplayWidth());
uint32_t draw_pixels = static_cast<uint32_t>(width) * static_cast<uint32_t>(buffer_cfg_.lines);
if (buffer_cfg_.full_frame) {
const uint16_t height = static_cast<uint16_t>(activeDisplayHeight());
draw_pixels = static_cast<uint32_t>(width) * static_cast<uint32_t>(height);
}
lv_disp_draw_buf_init(&draw_buf_, draw_buf1_, draw_buf2_, draw_pixels);
}
bool UiManager::allocateDrawBuffers() {
const uint16_t width = static_cast<uint16_t>(activeDisplayWidth());
const uint16_t height = static_cast<uint16_t>(activeDisplayHeight());
if (width == 0U || height == 0U) {
return false;
}
const uint8_t bpp = static_cast<uint8_t>(sizeof(lv_color_t) * 8U);
buffer_cfg_.bpp = bpp;
buffer_cfg_.draw_in_psram = false;
buffer_cfg_.full_frame = false;
buffer_cfg_.double_buffer = false;
if (kUseFullFrameBenchRuntime) {
size_t full_pixels = 0U;
size_t full_bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(height), &full_pixels) ||
!runtime::memory::safeMulSize(full_pixels, sizeof(lv_color_t), &full_bytes)) {
UI_LOGI("full-frame size overflow, fallback to line buffers");
full_pixels = 0U;
full_bytes = 0U;
}
lv_color_t* full = nullptr;
#if defined(ARDUINO_ARCH_ESP32)
if (full_bytes > 0U) {
const size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
if (free_psram > (full_bytes + kFullFrameBenchMinFreePsram)) {
full = static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocPsram(full_bytes, "ui.full_frame_bench"));
}
}
#else
if (full_bytes > 0U) {
full = static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocDefault(full_bytes, "ui.full_frame_bench"));
}
#endif
if (full != nullptr) {
full_frame_buf_ = full;
full_frame_buf_owned_ = true;
draw_buf1_ = full_frame_buf_;
draw_buf1_owned_ = false;
draw_buf2_ = nullptr;
draw_buf2_owned_ = false;
buffer_cfg_.lines = height;
buffer_cfg_.full_frame = true;
buffer_cfg_.double_buffer = false;
buffer_cfg_.draw_in_psram = true;
UI_LOGI("draw buffer full-frame bench enabled bytes=%u", static_cast<unsigned int>(full_bytes));
return true;
}
UI_LOGI("full-frame bench requested but unavailable, fallback to line buffers");
}
uint16_t line_candidates[12] = {0};
uint8_t candidate_count = 0U;
auto add_line_candidate = [&](uint16_t lines) {
if (lines == 0U) {
return;
}
if (lines > height) {
lines = height;
}
for (uint8_t index = 0U; index < candidate_count; ++index) {
if (line_candidates[index] == lines) {
return;
}
}
if (candidate_count < static_cast<uint8_t>(sizeof(line_candidates) / sizeof(line_candidates[0]))) {
line_candidates[candidate_count++] = lines;
}
};
add_line_candidate(kDrawBufLinesRequested != 0U ? kDrawBufLinesRequested : 40U);
for (uint8_t index = 0U; index < (sizeof(kDrawLineFallbacks) / sizeof(kDrawLineFallbacks[0])); ++index) {
add_line_candidate(kDrawLineFallbacks[index]);
}
add_line_candidate(20U);
add_line_candidate(16U);
add_line_candidate(12U);
add_line_candidate(8U);
add_line_candidate(6U);
add_line_candidate(4U);
add_line_candidate(2U);
add_line_candidate(1U);
auto allocate_buffer = [&](size_t bytes, bool psram, const char* tag) -> lv_color_t* {
if (bytes == 0U) {
return nullptr;
}
if (psram) {
return static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocPsram(bytes, tag));
}
return static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocInternalDma(bytes, tag));
};
auto try_allocate_draw = [&](bool draw_in_psram) -> bool {
for (uint8_t index = 0U; index < candidate_count; ++index) {
const uint16_t lines = line_candidates[index];
if (lines == 0U) {
continue;
}
size_t pixels = 0U;
size_t bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(lines), &pixels) ||
!runtime::memory::safeMulSize(pixels, sizeof(lv_color_t), &bytes)) {
UI_LOGD("draw buffer size overflow lines=%u", static_cast<unsigned int>(lines));
continue;
}
lv_color_t* first = allocate_buffer(bytes, draw_in_psram, "ui.draw.first");
if (first == nullptr) {
continue;
}
lv_color_t* second = allocate_buffer(bytes, draw_in_psram, "ui.draw.second");
if (second != nullptr) {
draw_buf1_ = first;
draw_buf2_ = second;
draw_buf1_owned_ = true;
draw_buf2_owned_ = true;
buffer_cfg_.lines = lines;
buffer_cfg_.double_buffer = true;
buffer_cfg_.draw_in_psram = draw_in_psram;
UI_LOGI("draw buffers ready lines=%u bytes=%u source=%s double=1",
static_cast<unsigned int>(lines),
static_cast<unsigned int>(bytes),
draw_in_psram ? "PSRAM" : "SRAM_DMA");
return true;
}
draw_buf1_ = first;
draw_buf2_ = nullptr;
draw_buf1_owned_ = true;
draw_buf2_owned_ = false;
buffer_cfg_.lines = lines;
buffer_cfg_.double_buffer = false;
buffer_cfg_.draw_in_psram = draw_in_psram;
UI_LOGI("draw buffer fallback mono lines=%u bytes=%u source=%s",
static_cast<unsigned int>(lines),
static_cast<unsigned int>(bytes),
draw_in_psram ? "PSRAM" : "SRAM_DMA");
return true;
}
return false;
};
const bool prefer_psram_for_trans = kUseColor256Runtime || kUsePsramLineBuffersRuntime;
const bool preferred_psram = prefer_psram_for_trans;
bool allocated = try_allocate_draw(preferred_psram);
if (!allocated) {
allocated = try_allocate_draw(!preferred_psram);
if (allocated) {
UI_LOGI("draw buffer source fallback=%s", (!preferred_psram) ? "PSRAM" : "SRAM_DMA");
}
}
if (!allocated) {
return false;
}
const bool needs_trans_buffer = kUseColor256Runtime || buffer_cfg_.draw_in_psram;
if (needs_trans_buffer) {
uint16_t trans_line_candidates[12] = {0};
uint8_t trans_candidate_count = 0U;
auto add_trans_candidate = [&](uint16_t lines) {
if (lines == 0U) {
return;
}
if (lines > height) {
lines = height;
}
for (uint8_t i = 0U; i < trans_candidate_count; ++i) {
if (trans_line_candidates[i] == lines) {
return;
}
}
if (trans_candidate_count <
static_cast<uint8_t>(sizeof(trans_line_candidates) / sizeof(trans_line_candidates[0]))) {
trans_line_candidates[trans_candidate_count++] = lines;
}
};
const uint16_t requested_trans_lines = (kDmaTransBufLinesRequested != 0U)
? kDmaTransBufLinesRequested
: buffer_cfg_.lines;
add_trans_candidate(requested_trans_lines);
add_trans_candidate(buffer_cfg_.lines);
add_trans_candidate(24U);
add_trans_candidate(16U);
add_trans_candidate(12U);
add_trans_candidate(8U);
add_trans_candidate(6U);
add_trans_candidate(4U);
add_trans_candidate(2U);
add_trans_candidate(1U);
uint16_t selected_trans_lines = 0U;
#if defined(ARDUINO_ARCH_ESP32)
for (uint8_t index = 0U; index < trans_candidate_count; ++index) {
const uint16_t trans_lines = trans_line_candidates[index];
size_t trans_pixels = 0U;
size_t trans_bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(trans_lines), &trans_pixels) ||
!runtime::memory::safeMulSize(trans_pixels, sizeof(uint16_t), &trans_bytes)) {
UI_LOGD("trans buffer size overflow lines=%u", static_cast<unsigned int>(trans_lines));
continue;
}
dma_trans_buf_ = static_cast<uint16_t*>(
kUseDmaTxInDramRuntime
? runtime::memory::CapsAllocator::allocInternalDma(trans_bytes, "ui.trans")
: runtime::memory::CapsAllocator::allocDefault(trans_bytes, "ui.trans"));
if (dma_trans_buf_ != nullptr) {
dma_trans_buf_owned_ = true;
dma_trans_buf_pixels_ = trans_pixels;
selected_trans_lines = trans_lines;
break;
}
}
#else
if (trans_candidate_count > 0U) {
const uint16_t trans_lines = trans_line_candidates[0];
size_t trans_pixels = 0U;
size_t trans_bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(trans_lines), &trans_pixels) ||
!runtime::memory::safeMulSize(trans_pixels, sizeof(uint16_t), &trans_bytes)) {
UI_LOGD("trans buffer size overflow lines=%u", static_cast<unsigned int>(trans_lines));
} else {
dma_trans_buf_ = static_cast<uint16_t*>(
runtime::memory::CapsAllocator::allocDefault(trans_bytes, "ui.trans"));
}
if (dma_trans_buf_ != nullptr) {
dma_trans_buf_owned_ = true;
dma_trans_buf_pixels_ = trans_pixels;
selected_trans_lines = trans_lines;
}
}
#endif
if (dma_trans_buf_ != nullptr && selected_trans_lines > 0U) {
buffer_cfg_.selected_trans_lines = selected_trans_lines;
UI_LOGI("trans buffer ready lines=%u pixels=%u source=%s",
static_cast<unsigned int>(selected_trans_lines),
static_cast<unsigned int>(dma_trans_buf_pixels_),
kUseDmaTxInDramRuntime ? "INTERNAL_DMA" : "DEFAULT");
if (selected_trans_lines < buffer_cfg_.lines &&
(kUseAsyncDmaRuntime || kUseColor256Runtime || buffer_cfg_.draw_in_psram)) {
UI_LOGI("draw lines reduced for trans buffer: %u -> %u",
static_cast<unsigned int>(buffer_cfg_.lines),
static_cast<unsigned int>(selected_trans_lines));
buffer_cfg_.lines = selected_trans_lines;
}
} else {
buffer_cfg_.selected_trans_lines = 0U;
dma_trans_buf_owned_ = false;
dma_trans_buf_pixels_ = 0U;
UI_LOGI("trans buffer unavailable; async DMA may be disabled");
}
} else {
buffer_cfg_.selected_trans_lines = 0U;
}
return draw_buf1_ != nullptr;
}
bool UiManager::initDmaEngine() {
dma_requested_ = kUseAsyncDmaRuntime;
dma_available_ = false;
async_flush_enabled_ = false;
if (!dma_requested_) {
buffer_cfg_.dma_enabled = false;
return false;
}
dma_available_ = drivers::display::displayHal().initDma(false);
if (!dma_available_) {
UI_LOGI("DMA engine unavailable, keeping sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
const bool needs_trans_buffer = kUseColor256Runtime || buffer_cfg_.draw_in_psram;
if (needs_trans_buffer && dma_trans_buf_ == nullptr) {
UI_LOGI("DMA enabled but trans buffer missing, keeping sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
if (kUseColor256Runtime && !kUseRgb332AsyncExperimental) {
UI_LOGI("RGB332 async DMA disabled (UI_DMA_RGB332_ASYNC_EXPERIMENTAL=0), keeping sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
if (buffer_cfg_.full_frame) {
UI_LOGI("full-frame bench forces sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
async_flush_enabled_ = true;
buffer_cfg_.dma_enabled = true;
async_fallback_until_ms_ = 0U;
if (kUseColor256Runtime) {
UI_LOGI("DMA async enabled (RGB332 -> RGB565 via trans buffer)");
} else {
UI_LOGI("DMA async flush enabled");
}
return true;
}
bool UiManager::isDisplayOutputBusy() const {
if (flush_ctx_.pending) {
return true;
}
return drivers::display::displayHal().dmaBusy();
}
void UiManager::pollAsyncFlush() {
if (!flush_ctx_.pending) {
flush_pending_since_ms_ = 0U;
return;
}
const uint32_t now_ms = millis();
if (flush_pending_since_ms_ == 0U) {
flush_pending_since_ms_ = now_ms;
flush_last_progress_ms_ = now_ms;
}
auto recover_stalled_flush = [this]() {
const bool used_dma = flush_ctx_.using_dma;
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
pending_lvgl_flush_request_ = true;
pending_full_repaint_request_ = true;
graphics_stats_.flush_stall_count += 1U;
graphics_stats_.flush_recover_count += 1U;
if (used_dma && async_flush_enabled_) {
async_flush_enabled_ = false;
buffer_cfg_.dma_enabled = false;
graphics_stats_.async_fallback_count += 1U;
async_fallback_until_ms_ = millis() + kAsyncFallbackRecoverMs;
}
};
if (flush_ctx_.using_dma && dma_available_ && drivers::display::displayHal().dmaBusy()) {
graphics_stats_.flush_busy_poll_count += 1U;
if ((now_ms - flush_pending_since_ms_) >= kFlushStallTimeoutMs) {
recover_stalled_flush();
}
return;
}
completePendingFlush();
if (!flush_ctx_.pending) {
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = now_ms;
return;
}
if ((now_ms - flush_pending_since_ms_) >= kFlushStallTimeoutMs) {
recover_stalled_flush();
}
}
void UiManager::completePendingFlush() {
if (!flush_ctx_.pending) {
return;
}
drivers::display::DisplayHal& display = drivers::display::displayHal();
const uint32_t width = static_cast<uint32_t>(flush_ctx_.col_count);
const uint32_t height = static_cast<uint32_t>(flush_ctx_.row_count);
if (width == 0U || height == 0U || flush_ctx_.src == nullptr || flush_ctx_.disp == nullptr) {
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
return;
}
const uint32_t pixel_count = width * height;
const bool has_valid_dma_tx = flush_ctx_.prepared && (flush_ctx_.prepared_tx != nullptr);
bool use_dma = flush_ctx_.using_dma;
if (use_dma && !has_valid_dma_tx) {
use_dma = false;
}
if (flush_ctx_.using_dma && flush_ctx_.dma_in_flight) {
if (display.dmaBusy()) {
graphics_stats_.flush_busy_poll_count += 1U;
return;
}
if (!display.startWrite()) {
graphics_stats_.flush_busy_poll_count += 1U;
return;
}
display.endWrite();
flush_ctx_.dma_in_flight = false;
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
const uint32_t elapsed_us = micros() - flush_ctx_.started_ms;
graphics_stats_.flush_count += 1U;
graphics_stats_.dma_flush_count += 1U;
graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > graphics_stats_.flush_time_max_us) {
graphics_stats_.flush_time_max_us = elapsed_us;
}
perfMonitor().noteUiFlush(true, elapsed_us);
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
return;
} else {
if (flush_ctx_.using_dma && use_dma) {
if (!display.startWrite()) {
graphics_stats_.flush_busy_poll_count += 1U;
return;
}
display.pushImageDma(flush_ctx_.area.x1,
flush_ctx_.area.y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height),
flush_ctx_.prepared_tx);
display.endWrite();
flush_ctx_.dma_in_flight = true;
flush_last_progress_ms_ = millis();
return;
}
if (!display.startWrite()) {
return;
}
if (flush_ctx_.converted) {
display.setAddrWindow(flush_ctx_.area.x1,
flush_ctx_.area.y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height));
if (flush_ctx_.prepared && flush_ctx_.prepared_tx != nullptr) {
display.pushColors(flush_ctx_.prepared_tx, pixel_count, true);
} else if (dma_trans_buf_ != nullptr && dma_trans_buf_pixels_ >= width) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = flush_ctx_.src + (row * width);
convertLineRgb332ToRgb565(src_row, dma_trans_buf_, width);
display.pushColors(dma_trans_buf_, width, true);
}
} else {
static uint16_t row_buffer[(FREENOVE_LCD_WIDTH > FREENOVE_LCD_HEIGHT) ? FREENOVE_LCD_WIDTH
: FREENOVE_LCD_HEIGHT];
const uint32_t max_row = sizeof(row_buffer) / sizeof(row_buffer[0]);
const lv_color_t* src = flush_ctx_.src;
if (src != nullptr && width <= max_row) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = src + (row * width);
convertLineRgb332ToRgb565(src_row, row_buffer, width);
display.pushColors(row_buffer, width, true);
}
} else {
for (uint32_t pixel = 0U; pixel < pixel_count; ++pixel) {
#if LV_COLOR_DEPTH == 8
const uint16_t c565 = rgb332_to_565_lut_[src[pixel].full];
display.pushColor(c565);
#else
display.pushColor(static_cast<uint16_t>(src[pixel].full));
#endif
}
}
}
display.endWrite();
} else {
display.setAddrWindow(flush_ctx_.area.x1,
flush_ctx_.area.y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height));
display.pushColors(reinterpret_cast<const uint16_t*>(flush_ctx_.src), pixel_count, true);
display.endWrite();
}
}
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
const uint32_t elapsed_us = micros() - flush_ctx_.started_ms;
graphics_stats_.flush_count += 1U;
if (use_dma) {
graphics_stats_.dma_flush_count += 1U;
} else {
graphics_stats_.sync_flush_count += 1U;
}
graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > graphics_stats_.flush_time_max_us) {
graphics_stats_.flush_time_max_us = elapsed_us;
}
perfMonitor().noteUiFlush(use_dma, elapsed_us);
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
}
uint16_t UiManager::convertLineRgb332ToRgb565(const lv_color_t* src,
uint16_t* dst,
uint32_t px_count) const {
if (src == nullptr || dst == nullptr || px_count == 0U || !color_lut_ready_) {
return 0U;
}
#if LV_COLOR_DEPTH == 8
if (sizeof(lv_color_t) == sizeof(uint8_t)) {
runtime::simd::simd_index8_to_rgb565(dst,
reinterpret_cast<const uint8_t*>(src),
rgb332_to_565_lut_,
static_cast<size_t>(px_count));
return static_cast<uint16_t>((px_count > 0xFFFFU) ? 0xFFFFU : px_count);
}
for (uint32_t index = 0U; index < px_count; ++index) {
dst[index] = rgb332_to_565_lut_[src[index].full];
}
#else
for (uint32_t index = 0U; index < px_count; ++index) {
dst[index] = src[index].full;
}
#endif
return static_cast<uint16_t>((px_count > 0xFFFFU) ? 0xFFFFU : px_count);
}
lv_color_t UiManager::quantize565ToTheme256(lv_color_t color) const {
if (!kUseThemeQuantizeRuntime) {
return color;
}
#if LV_COLOR_DEPTH == 16
lv_color32_t c32 = {};
c32.full = lv_color_to32(color);
const uint8_t r3 = static_cast<uint8_t>((static_cast<uint16_t>(c32.ch.red) * 7U + 127U) / 255U);
const uint8_t g3 = static_cast<uint8_t>((static_cast<uint16_t>(c32.ch.green) * 7U + 127U) / 255U);
const uint8_t b2 = static_cast<uint8_t>((static_cast<uint16_t>(c32.ch.blue) * 3U + 127U) / 255U);
const uint8_t rq = static_cast<uint8_t>((static_cast<uint16_t>(r3) * 255U) / 7U);
const uint8_t gq = static_cast<uint8_t>((static_cast<uint16_t>(g3) * 255U) / 7U);
const uint8_t bq = static_cast<uint8_t>((static_cast<uint16_t>(b2) * 255U) / 3U);
return lv_color_make(rq, gq, bq);
#else
return color;
#endif
}
void UiManager::invalidateFxOverlayObjects() {
if (intro_active_ && intro_root_ != nullptr) {
// Keep the LVGL overlay above FX by forcing an overlay redraw each rendered FX frame.
lv_obj_invalidate(intro_root_);
return;
}
bool invalidated = false;
auto invalidate_if_visible = [&](lv_obj_t* obj) {
if (obj == nullptr || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) {
return;
}
lv_obj_invalidate(obj);
invalidated = true;
};
if (intro_active_) {
invalidate_if_visible(intro_logo_shadow_label_);
invalidate_if_visible(intro_logo_label_);
invalidate_if_visible(intro_crack_scroll_label_);
invalidate_if_visible(intro_bottom_scroll_label_);
invalidate_if_visible(intro_clean_title_shadow_label_);
invalidate_if_visible(intro_clean_title_label_);
invalidate_if_visible(intro_clean_scroll_label_);
invalidate_if_visible(intro_debug_label_);
if (!kUseWinEtapeSimplifiedEffects) {
const uint8_t glyph_count =
(intro_wave_glyph_count_ > kIntroWaveGlyphMax) ? kIntroWaveGlyphMax : intro_wave_glyph_count_;
for (uint8_t index = 0U; index < glyph_count; ++index) {
invalidate_if_visible(intro_wave_slots_[index].shadow);
invalidate_if_visible(intro_wave_slots_[index].glyph);
}
}
} else {
if (!kUseLgfxTextOverlayRuntime) {
invalidate_if_visible(scene_title_label_);
invalidate_if_visible(scene_subtitle_label_);
invalidate_if_visible(scene_symbol_label_);
}
invalidate_if_visible(page_label_);
}
if (!invalidated) {
if (kUseLgfxTextOverlayRuntime && !intro_active_) {
drivers::display::displayHalInvalidateOverlay();
return;
}
if (intro_root_ != nullptr) {
lv_obj_invalidate(intro_root_);
return;
}
if (scene_root_ != nullptr) {
lv_obj_invalidate(scene_root_);
return;
}
drivers::display::displayHalInvalidateOverlay();
}
}
void UiManager::renderLgfxSceneTextOverlay() {
if (!kUseLgfxTextOverlayRuntime || !scene_status_.valid || intro_active_) {
return;
}
drivers::display::DisplayHal& display = drivers::display::displayHal();
if (!display.startWrite()) {
return;
}
auto to565 = [&display](uint32_t rgb) -> uint16_t {
if (rgb <= 0xFFFFUL) {
return static_cast<uint16_t>(rgb);
}
return display.color565(static_cast<uint8_t>((rgb >> 16U) & 0xFFU),
static_cast<uint8_t>((rgb >> 8U) & 0xFFU),
static_cast<uint8_t>(rgb & 0xFFU));
};
const int16_t width = activeDisplayWidth();
const int16_t height = activeDisplayHeight();
const uint16_t bg565 = to565(scene_status_.bg_rgb);
const uint16_t text565 = display.color565(0xFFU, 0xFFU, 0xFFU);
auto draw_center_line = [&](const char* text, int16_t y, uint8_t font) {
if (text == nullptr || text[0] == '\0') {
return;
}
display.setTextSize(1U);
display.setTextFont(font);
const int16_t text_width = display.textWidth(text);
int16_t x = static_cast<int16_t>((width - text_width) / 2);
if (x < 2) {
x = 2;
}
display.setTextColor(text565, bg565);
display.drawString(text, x, y);
};
if (scene_status_.show_title && scene_status_.title[0] != '\0') {
draw_center_line(scene_status_.title, 8, 4U);
}
if (scene_status_.show_symbol && scene_status_.symbol[0] != '\0') {
draw_center_line(scene_status_.symbol, static_cast<int16_t>((height / 2) - 14), 4U);
}
if (scene_status_.show_subtitle && scene_status_.subtitle[0] != '\0') {
const int16_t y = static_cast<int16_t>(height - 24);
draw_center_line(scene_status_.subtitle, y, 2U);
}
display.endWrite();
}
void UiManager::tryRenderLgfxSceneTextOverlay() {
if (!kUseLgfxTextOverlayRuntime || !lgfx_text_overlay_pending_) {
return;
}
if (intro_active_) {
lgfx_text_overlay_pending_ = false;
return;
}
if (!scene_status_.valid) {
lgfx_text_overlay_pending_ = false;
return;
}
pollAsyncFlush();
if (isDisplayOutputBusy()) {
return;
}
renderLgfxSceneTextOverlay();
lgfx_text_overlay_pending_ = false;
}
void UiManager::displayFlushCb(lv_disp_drv_t* disp, const lv_area_t* area, lv_color_t* color_p) {
if (disp == nullptr || area == nullptr || color_p == nullptr) {
if (disp != nullptr) {
lv_disp_flush_ready(disp);
}
return;
}
if (g_instance == nullptr) {
lv_disp_flush_ready(disp);
return;
}
UiManager* self = g_instance;
drivers::display::DisplayHal& display = drivers::display::displayHal();
if (self->isDisplayOutputBusy()) {
self->pollAsyncFlush();
if (self->isDisplayOutputBusy()) {
self->graphics_stats_.flush_overflow_count += 1U;
self->graphics_stats_.flush_blocked_count += 1U;
self->pending_lvgl_flush_request_ = true;
if (!self->pending_full_repaint_request_) {
self->pending_full_repaint_request_ = true;
}
lv_disp_flush_ready(disp);
return;
}
}
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);
const uint32_t pixel_count = width * height;
const uint32_t started_us = micros();
const bool needs_convert = kUseColor256Runtime;
const bool needs_copy_to_trans = self->buffer_cfg_.draw_in_psram || self->buffer_cfg_.full_frame;
bool async_dma = self->async_flush_enabled_ && self->dma_available_ && !self->flush_ctx_.pending;
bool tx_pixels_prepared = false;
uint16_t* tx_pixels = reinterpret_cast<uint16_t*>(&color_p->full);
if (needs_convert || needs_copy_to_trans) {
if (self->dma_trans_buf_ != nullptr && pixel_count <= self->dma_trans_buf_pixels_) {
tx_pixels = self->dma_trans_buf_;
if (needs_convert) {
self->convertLineRgb332ToRgb565(color_p, tx_pixels, pixel_count);
} else {
std::memcpy(tx_pixels, reinterpret_cast<uint16_t*>(&color_p->full), pixel_count * sizeof(uint16_t));
}
tx_pixels_prepared = true;
} else {
async_dma = false;
}
}
if (async_dma) {
if (!display.startWrite()) {
self->graphics_stats_.flush_overflow_count += 1U;
self->graphics_stats_.flush_blocked_count += 1U;
self->pending_lvgl_flush_request_ = true;
self->pending_full_repaint_request_ = true;
lv_disp_flush_ready(disp);
return;
}
display.pushImageDma(area->x1,
area->y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height),
tx_pixels);
const bool dma_done = display.waitDmaComplete(kLvglFlushDmaWaitUs);
display.endWrite();
const uint32_t elapsed_us = micros() - started_us;
self->graphics_stats_.flush_count += 1U;
self->graphics_stats_.dma_flush_count += 1U;
self->graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > self->graphics_stats_.flush_time_max_us) {
self->graphics_stats_.flush_time_max_us = elapsed_us;
}
if (!dma_done && self->async_flush_enabled_) {
self->graphics_stats_.flush_stall_count += 1U;
self->graphics_stats_.flush_recover_count += 1U;
self->graphics_stats_.async_fallback_count += 1U;
self->async_flush_enabled_ = false;
self->buffer_cfg_.dma_enabled = false;
self->async_fallback_until_ms_ = millis() + kAsyncFallbackRecoverMs;
self->pending_lvgl_flush_request_ = true;
self->pending_full_repaint_request_ = true;
}
perfMonitor().noteUiFlush(true, elapsed_us);
self->flush_pending_since_ms_ = 0U;
self->flush_last_progress_ms_ = millis();
lv_disp_flush_ready(disp);
return;
}
if (!display.startWrite()) {
self->graphics_stats_.flush_overflow_count += 1U;
self->graphics_stats_.flush_blocked_count += 1U;
self->pending_lvgl_flush_request_ = true;
self->pending_full_repaint_request_ = true;
lv_disp_flush_ready(disp);
return;
}
display.setAddrWindow(area->x1, area->y1, static_cast<int16_t>(width), static_cast<int16_t>(height));
if (needs_convert && !tx_pixels_prepared) {
static uint16_t row_buffer[(FREENOVE_LCD_WIDTH > FREENOVE_LCD_HEIGHT) ? FREENOVE_LCD_WIDTH
: FREENOVE_LCD_HEIGHT];
const uint32_t max_row = sizeof(row_buffer) / sizeof(row_buffer[0]);
if (self->dma_trans_buf_ != nullptr && self->dma_trans_buf_pixels_ >= width) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = color_p + (row * width);
self->convertLineRgb332ToRgb565(src_row, self->dma_trans_buf_, width);
display.pushColors(self->dma_trans_buf_, width, true);
}
} else if (width <= max_row) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = color_p + (row * width);
self->convertLineRgb332ToRgb565(src_row, row_buffer, width);
display.pushColors(row_buffer, width, true);
}
} else {
for (uint32_t pixel = 0U; pixel < pixel_count; ++pixel) {
#if LV_COLOR_DEPTH == 8
const uint16_t c565 = self->rgb332_to_565_lut_[color_p[pixel].full];
display.pushColor(c565);
#else
display.pushColor(static_cast<uint16_t>(color_p[pixel].full));
#endif
}
}
} else if (needs_copy_to_trans && tx_pixels_prepared) {
display.pushColors(tx_pixels, pixel_count, true);
} else {
display.pushColors(tx_pixels, pixel_count, true);
}
display.endWrite();
const uint32_t elapsed_us = micros() - started_us;
self->graphics_stats_.flush_count += 1U;
self->graphics_stats_.sync_flush_count += 1U;
self->graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > self->graphics_stats_.flush_time_max_us) {
self->graphics_stats_.flush_time_max_us = elapsed_us;
}
perfMonitor().noteUiFlush(false, elapsed_us);
self->flush_pending_since_ms_ = 0U;
self->flush_last_progress_ms_ = millis();
lv_disp_flush_ready(disp);
}
#endif // UI_MANAGER_SPLIT_IMPL
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
// amiga_ui_shell.cpp - Workbench-style app launcher grid (LVGL).
#include "ui/workbench/amiga_ui_shell.h"
#include <cstdio>
#include <cstring>
namespace ui::workbench {
// ── Amiga Workbench color palette ──
static constexpr lv_color_t kColBg = LV_COLOR_MAKE(0x00, 0x55, 0xAA); // Classic Amiga blue
static constexpr lv_color_t kColTopbar = LV_COLOR_MAKE(0xFF, 0x88, 0x00); // Orange title bar
static constexpr lv_color_t kColTile = LV_COLOR_MAKE(0x00, 0x44, 0x88); // Darker blue tile
static constexpr lv_color_t kColTileFocus = LV_COLOR_MAKE(0xFF, 0x88, 0x00); // Orange focus
static constexpr lv_color_t kColTileDisab = LV_COLOR_MAKE(0x33, 0x33, 0x55); // Grey-blue disabled
static constexpr lv_color_t kColTextWhite = LV_COLOR_MAKE(0xFF, 0xFF, 0xFF);
static constexpr lv_color_t kColTextBlack = LV_COLOR_MAKE(0x00, 0x00, 0x00);
static constexpr lv_color_t kColTextGrey = LV_COLOR_MAKE(0x88, 0x88, 0xAA);
static constexpr uint16_t kTopbarHeight = 24U;
static constexpr uint16_t kPageIndicatorH = 18U;
static constexpr uint16_t kTilePadding = 4U;
static constexpr uint16_t kTileCornerRad = 3U;
static constexpr uint16_t kGridPadTop = 4U;
static constexpr uint16_t kGridPadSide = 6U;
// ── Category icon symbols (pixel-art fallback using LV symbols) ──
static const char* categoryIcon(const char* category) {
if (category == nullptr) return LV_SYMBOL_FILE;
if (std::strcmp(category, "media") == 0) return LV_SYMBOL_AUDIO;
if (std::strcmp(category, "capture") == 0) return LV_SYMBOL_IMAGE;
if (std::strcmp(category, "utility") == 0) return LV_SYMBOL_SETTINGS;
if (std::strcmp(category, "kids") == 0) return LV_SYMBOL_HOME;
if (std::strcmp(category, "emulator") == 0) return LV_SYMBOL_PLAY;
return LV_SYMBOL_FILE;
}
// ── Init ──
bool AmigaUIShell::begin(const Config& cfg, AppRegistry* registry, AppRuntimeManager* runtime) {
cfg_ = cfg;
registry_ = registry;
runtime_ = runtime;
if (registry_ == nullptr) {
Serial.println("[WB] begin failed: no registry");
return false;
}
enabled_count_ = registry_->enabledCount();
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
page_count_ = (enabled_count_ + tiles_per_page - 1U) / tiles_per_page;
if (page_count_ == 0U) page_count_ = 1U;
if (page_count_ > kMaxPages) page_count_ = kMaxPages;
current_page_ = 0U;
focus_index_ = 0U;
// Compute tile dimensions.
const uint16_t grid_w = cfg_.screen_w - (kGridPadSide * 2U);
const uint16_t grid_h = cfg_.screen_h - kTopbarHeight - kPageIndicatorH - kGridPadTop;
tile_w_ = (grid_w - (kTilePadding * (cfg_.grid_cols - 1U))) / cfg_.grid_cols;
tile_h_ = (grid_h - (kTilePadding * (cfg_.grid_rows - 1U))) / cfg_.grid_rows;
grid_x_offset_ = kGridPadSide;
grid_y_offset_ = kTopbarHeight + kGridPadTop;
if (!styles_inited_) {
// Background.
lv_style_init(&st_bg_);
lv_style_set_bg_color(&st_bg_, kColBg);
lv_style_set_bg_opa(&st_bg_, LV_OPA_COVER);
lv_style_set_radius(&st_bg_, 0);
lv_style_set_border_width(&st_bg_, 0);
lv_style_set_pad_all(&st_bg_, 0);
// Top bar.
lv_style_init(&st_topbar_);
lv_style_set_bg_color(&st_topbar_, kColTopbar);
lv_style_set_bg_opa(&st_topbar_, LV_OPA_COVER);
lv_style_set_radius(&st_topbar_, 0);
lv_style_set_border_width(&st_topbar_, 0);
lv_style_set_pad_left(&st_topbar_, 6);
lv_style_set_pad_right(&st_topbar_, 6);
lv_style_set_pad_top(&st_topbar_, 2);
// Tile normal.
lv_style_init(&st_tile_);
lv_style_set_bg_color(&st_tile_, kColTile);
lv_style_set_bg_opa(&st_tile_, LV_OPA_COVER);
lv_style_set_radius(&st_tile_, kTileCornerRad);
lv_style_set_border_width(&st_tile_, 1);
lv_style_set_border_color(&st_tile_, kColTextGrey);
lv_style_set_pad_all(&st_tile_, 2);
// Tile focused.
lv_style_init(&st_tile_focused_);
lv_style_set_bg_color(&st_tile_focused_, kColTileFocus);
lv_style_set_bg_opa(&st_tile_focused_, LV_OPA_COVER);
lv_style_set_border_width(&st_tile_focused_, 2);
lv_style_set_border_color(&st_tile_focused_, kColTextWhite);
// Tile disabled.
lv_style_init(&st_tile_disabled_);
lv_style_set_bg_color(&st_tile_disabled_, kColTileDisab);
lv_style_set_bg_opa(&st_tile_disabled_, LV_OPA_70);
lv_style_set_text_color(&st_tile_disabled_, kColTextGrey);
// Label.
lv_style_init(&st_label_);
lv_style_set_text_color(&st_label_, kColTextWhite);
lv_style_set_text_font(&st_label_, &lv_font_montserrat_14);
// Status.
lv_style_init(&st_status_);
lv_style_set_text_color(&st_status_, kColTextBlack);
lv_style_set_text_font(&st_status_, &lv_font_montserrat_14);
// Page indicator.
lv_style_init(&st_page_indicator_);
lv_style_set_text_color(&st_page_indicator_, kColTextWhite);
lv_style_set_text_font(&st_page_indicator_, &lv_font_montserrat_14);
styles_inited_ = true;
}
Serial.printf("[WB] begin ok apps=%u pages=%u tile=%ux%u grid=%ux%u\n",
enabled_count_, page_count_, tile_w_, tile_h_,
cfg_.grid_cols, cfg_.grid_rows);
return true;
}
// ── Show / Hide ──
void AmigaUIShell::show() {
if (visible_) return;
if (root_ == nullptr) createUi();
rebuildGrid();
lv_obj_clear_flag(root_, LV_OBJ_FLAG_HIDDEN);
visible_ = true;
Serial.println("[WB] show");
}
void AmigaUIShell::hide() {
if (!visible_) return;
if (root_ != nullptr) {
lv_obj_add_flag(root_, LV_OBJ_FLAG_HIDDEN);
}
visible_ = false;
Serial.println("[WB] hide");
}
void AmigaUIShell::toggle() {
if (visible_) hide();
else show();
}
// ── Create LVGL UI ──
void AmigaUIShell::createUi() {
if (root_ != nullptr) return;
root_ = lv_obj_create(lv_layer_top());
lv_obj_add_style(root_, &st_bg_, 0);
lv_obj_set_size(root_, cfg_.screen_w, cfg_.screen_h);
lv_obj_set_pos(root_, 0, 0);
lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE);
// ── Top bar ──
topbar_ = lv_obj_create(root_);
lv_obj_add_style(topbar_, &st_topbar_, 0);
lv_obj_set_size(topbar_, cfg_.screen_w, kTopbarHeight);
lv_obj_set_pos(topbar_, 0, 0);
lv_obj_clear_flag(topbar_, LV_OBJ_FLAG_SCROLLABLE);
topbar_label_ = lv_label_create(topbar_);
lv_obj_add_style(topbar_label_, &st_status_, 0);
lv_label_set_text(topbar_label_, "Workbench");
lv_obj_align(topbar_label_, LV_ALIGN_LEFT_MID, 0, 0);
topbar_status_ = lv_label_create(topbar_);
lv_obj_add_style(topbar_status_, &st_status_, 0);
lv_label_set_text(topbar_status_, "");
lv_obj_align(topbar_status_, LV_ALIGN_RIGHT_MID, 0, 0);
// ── Grid container ──
grid_container_ = lv_obj_create(root_);
lv_obj_set_style_bg_opa(grid_container_, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid_container_, 0, 0);
lv_obj_set_style_pad_all(grid_container_, 0, 0);
lv_obj_set_size(grid_container_,
cfg_.screen_w - (kGridPadSide * 2U),
cfg_.screen_h - kTopbarHeight - kPageIndicatorH - kGridPadTop);
lv_obj_set_pos(grid_container_, grid_x_offset_, grid_y_offset_);
lv_obj_clear_flag(grid_container_, LV_OBJ_FLAG_SCROLLABLE);
// ── Page indicator ──
page_indicator_ = lv_label_create(root_);
lv_obj_add_style(page_indicator_, &st_page_indicator_, 0);
lv_label_set_text(page_indicator_, "");
lv_obj_align(page_indicator_, LV_ALIGN_BOTTOM_MID, 0, -2);
// ── Launch overlay (hidden by default) ──
launch_overlay_ = lv_obj_create(root_);
lv_obj_set_style_bg_color(launch_overlay_, LV_COLOR_MAKE(0x00, 0x00, 0x00), 0);
lv_obj_set_style_bg_opa(launch_overlay_, LV_OPA_70, 0);
lv_obj_set_style_border_width(launch_overlay_, 0, 0);
lv_obj_set_style_radius(launch_overlay_, 8, 0);
lv_obj_set_size(launch_overlay_, 200, 50);
lv_obj_align(launch_overlay_, LV_ALIGN_CENTER, 0, 0);
lv_obj_clear_flag(launch_overlay_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN);
launch_label_ = lv_label_create(launch_overlay_);
lv_obj_add_style(launch_label_, &st_label_, 0);
lv_label_set_text(launch_label_, "Ouverture...");
lv_obj_center(launch_label_);
Serial.println("[WB] UI created");
}
void AmigaUIShell::destroyUi() {
if (root_ != nullptr) {
lv_obj_del(root_);
root_ = nullptr;
topbar_ = nullptr;
topbar_label_ = nullptr;
topbar_status_ = nullptr;
grid_container_ = nullptr;
page_indicator_ = nullptr;
launch_overlay_ = nullptr;
launch_label_ = nullptr;
}
tile_count_ = 0U;
for (auto& t : tiles_) {
t.container = nullptr;
t.icon_label = nullptr;
t.title_label = nullptr;
t.global_app_index = 0xFFU;
}
}
// ── Grid rebuild ──
void AmigaUIShell::rebuildGrid() {
if (grid_container_ == nullptr || registry_ == nullptr) return;
// Clear existing tiles.
lv_obj_clean(grid_container_);
tile_count_ = 0U;
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
const uint8_t page_start = current_page_ * tiles_per_page;
uint8_t tiles_this_page = tilesOnCurrentPage();
for (uint8_t i = 0U; i < tiles_this_page && i < kMaxTilesPerPage; ++i) {
const uint8_t global_idx = page_start + i;
const AppEntry* app = registry_->enabledEntry(global_idx);
if (app == nullptr) break;
const uint8_t col = i % cfg_.grid_cols;
const uint8_t row = i / cfg_.grid_cols;
const int16_t x = static_cast<int16_t>(col * (tile_w_ + kTilePadding));
const int16_t y = static_cast<int16_t>(row * (tile_h_ + kTilePadding));
TileSlot& slot = tiles_[tile_count_];
slot.global_app_index = global_idx;
// Tile container.
slot.container = lv_obj_create(grid_container_);
lv_obj_add_style(slot.container, &st_tile_, 0);
lv_obj_set_size(slot.container, tile_w_, tile_h_);
lv_obj_set_pos(slot.container, x, y);
lv_obj_clear_flag(slot.container, LV_OBJ_FLAG_SCROLLABLE);
if (!app->enabled) {
lv_obj_add_style(slot.container, &st_tile_disabled_, 0);
}
// Icon (symbol fallback).
slot.icon_label = lv_label_create(slot.container);
lv_obj_set_style_text_font(slot.icon_label, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(slot.icon_label, kColTextWhite, 0);
lv_label_set_text(slot.icon_label, categoryIcon(app->category));
lv_obj_align(slot.icon_label, LV_ALIGN_TOP_MID, 0, 4);
// Title label.
slot.title_label = lv_label_create(slot.container);
lv_obj_add_style(slot.title_label, &st_label_, 0);
lv_obj_set_style_text_font(slot.title_label, &lv_font_montserrat_14, 0);
lv_label_set_text(slot.title_label, app->title);
lv_label_set_long_mode(slot.title_label, LV_LABEL_LONG_DOT);
lv_obj_set_width(slot.title_label, tile_w_ - 8);
lv_obj_set_style_text_align(slot.title_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(slot.title_label, LV_ALIGN_BOTTOM_MID, 0, -2);
++tile_count_;
}
// Update page indicator.
if (page_count_ > 1U) {
char buf[24];
std::snprintf(buf, sizeof(buf), "%u / %u", current_page_ + 1U, page_count_);
lv_label_set_text(page_indicator_, buf);
lv_obj_clear_flag(page_indicator_, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(page_indicator_, LV_OBJ_FLAG_HIDDEN);
}
// Clamp focus.
if (focus_index_ >= tile_count_ && tile_count_ > 0U) {
focus_index_ = tile_count_ - 1U;
}
updateFocusVisual();
}
// ── Focus visual ──
void AmigaUIShell::updateFocusVisual() {
for (uint8_t i = 0U; i < tile_count_; ++i) {
TileSlot& slot = tiles_[i];
if (slot.container == nullptr) continue;
if (i == focus_index_) {
lv_obj_add_style(slot.container, &st_tile_focused_, 0);
} else {
lv_obj_remove_style(slot.container, &st_tile_focused_, 0);
lv_obj_add_style(slot.container, &st_tile_, 0);
}
}
}
// ── Navigation ──
void AmigaUIShell::navigateFocus(int8_t dx, int8_t dy) {
if (tile_count_ == 0U) return;
const uint8_t cols = cfg_.grid_cols;
uint8_t col = focus_index_ % cols;
uint8_t row = focus_index_ / cols;
if (dx > 0) {
if (col + 1U < cols && (focus_index_ + 1U) < tile_count_) {
++focus_index_;
} else {
// Wrap to next page.
navigatePage(1);
return;
}
} else if (dx < 0) {
if (col > 0U) {
--focus_index_;
} else {
navigatePage(-1);
return;
}
}
if (dy > 0) {
const uint8_t next = focus_index_ + cols;
if (next < tile_count_) {
focus_index_ = next;
}
} else if (dy < 0) {
if (focus_index_ >= cols) {
focus_index_ -= cols;
}
}
updateFocusVisual();
}
void AmigaUIShell::navigatePage(int8_t delta) {
if (page_count_ <= 1U) return;
int8_t next_page = static_cast<int8_t>(current_page_) + delta;
if (next_page < 0) next_page = static_cast<int8_t>(page_count_ - 1U);
if (next_page >= static_cast<int8_t>(page_count_)) next_page = 0;
current_page_ = static_cast<uint8_t>(next_page);
focus_index_ = 0U;
rebuildGrid();
}
void AmigaUIShell::handleInput(InputKey key) {
if (!visible_) return;
switch (key) {
case InputKey::kUp: navigateFocus(0, -1); break;
case InputKey::kDown: navigateFocus(0, 1); break;
case InputKey::kLeft: navigateFocus(-1, 0); break;
case InputKey::kRight: navigateFocus(1, 0); break;
case InputKey::kOk: launchFocused(millis()); break;
case InputKey::kBack:
if (runtime_ != nullptr && runtime_->isRunning()) {
requestCloseApp("back_button", millis());
}
break;
}
}
void AmigaUIShell::handleButtonKey(uint8_t hw_key, bool long_press) {
// Map physical 5-button ladder to InputKey.
// BTN1=UP, BTN2=DOWN, BTN3=OK, BTN4=BACK, BTN5=RIGHT(page next)
switch (hw_key) {
case 1U: handleInput(InputKey::kUp); break;
case 2U: handleInput(InputKey::kDown); break;
case 3U:
if (long_press) {
handleInput(InputKey::kBack);
} else {
handleInput(InputKey::kOk);
}
break;
case 4U: handleInput(InputKey::kBack); break;
case 5U: handleInput(InputKey::kRight); break;
default: break;
}
}
// ── Launch ──
bool AmigaUIShell::launchFocused(uint32_t now_ms) {
const AppEntry* app = focusedEntry();
if (app == nullptr || runtime_ == nullptr) return false;
// Show launch overlay.
if (launch_overlay_ != nullptr && launch_label_ != nullptr) {
char buf[48];
std::snprintf(buf, sizeof(buf), "Ouverture %s...", app->title);
lv_label_set_text(launch_label_, buf);
lv_obj_clear_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN);
launch_overlay_hide_ms_ = now_ms + 2000U;
}
const bool ok = runtime_->open(app->id, "default", now_ms);
if (!ok) {
if (launch_label_ != nullptr) {
lv_label_set_text(launch_label_, "Erreur ouverture");
launch_overlay_hide_ms_ = now_ms + 1500U;
}
Serial.printf("[WB] launch failed app=%s\n", app->id);
} else {
// Hide workbench, app scene will take over.
hide();
}
return ok;
}
bool AmigaUIShell::requestCloseApp(const char* reason, uint32_t now_ms) {
if (runtime_ == nullptr) return false;
const bool ok = runtime_->close(reason, now_ms);
if (ok) {
show(); // Return to workbench.
}
return ok;
}
const AppEntry* AmigaUIShell::focusedEntry() const {
if (tile_count_ == 0U || focus_index_ >= tile_count_) return nullptr;
const uint8_t global_idx = tiles_[focus_index_].global_app_index;
return registry_->enabledEntry(global_idx);
}
// ── Tick ──
void AmigaUIShell::tick(uint32_t now_ms) {
if (!visible_) return;
// Auto-hide launch overlay.
if (launch_overlay_ != nullptr &&
!lv_obj_has_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN) &&
launch_overlay_hide_ms_ != 0U &&
static_cast<int32_t>(now_ms - launch_overlay_hide_ms_) >= 0) {
lv_obj_add_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN);
launch_overlay_hide_ms_ = 0U;
}
// Periodic status bar update.
if (now_ms - last_status_update_ms_ >= 1000U) {
updateStatusBar(now_ms);
last_status_update_ms_ = now_ms;
}
}
void AmigaUIShell::updateStatusBar(uint32_t now_ms) {
if (topbar_status_ == nullptr) return;
char buf[40];
const uint32_t uptime_s = now_ms / 1000U;
const uint32_t m = (uptime_s / 60U) % 60U;
const uint32_t h = uptime_s / 3600U;
std::snprintf(buf, sizeof(buf), "%lu:%02lu", static_cast<unsigned long>(h), static_cast<unsigned long>(m));
lv_label_set_text(topbar_status_, buf);
}
void AmigaUIShell::updateTileStates() {
// Future: update tile badges based on runtime state.
}
uint8_t AmigaUIShell::tilesOnCurrentPage() const {
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
const uint8_t page_start = current_page_ * tiles_per_page;
if (page_start >= enabled_count_) return 0U;
const uint8_t remaining = enabled_count_ - page_start;
return (remaining < tiles_per_page) ? remaining : tiles_per_page;
}
uint8_t AmigaUIShell::globalIndexFromPageLocal(uint8_t local) const {
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
return current_page_ * tiles_per_page + local;
}
} // namespace ui::workbench
File diff suppressed because it is too large Load Diff