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:
@@ -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
@@ -1,357 +1,53 @@
|
||||
# ui_freenove_allinone
|
||||
|
||||
---
|
||||
# 🎛️ Zacus Firmware – Freenove All-in-One
|
||||
Firmware principal Freenove pour `ESP32_ZACUS`.
|
||||
|
||||

|
||||
## 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, c’est normal. Si le microcontrôleur se met à rapper, c’est probablement un bug… ou un feature caché."*
|
||||
|
||||
---
|
||||
|
||||
## 🕹️ Mapping hardware (ESP32-S3 Freenove Media Kit)
|
||||
|
||||
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|
||||
|------------------|-----------------|-----------------------|--------------------------------|
|
||||
| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) |
|
||||
| TFT MOSI | GPIO 21 | MOSI | SPI écran |
|
||||
| TFT MISO | -1 | MISO | non utilisé |
|
||||
| TFT CS | -1 | CS | câblage board intégré |
|
||||
| TFT DC | GPIO 45 | DC | Data/Command écran |
|
||||
| TFT RESET | GPIO 20 | RESET | Reset écran |
|
||||
| TFT BL | GPIO 2 | BL | Rétroéclairage |
|
||||
| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) |
|
||||
| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel |
|
||||
| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques |
|
||||
| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 |
|
||||
| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 |
|
||||
| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 |
|
||||
| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions |
|
||||
|
||||
**Remarques** :
|
||||
- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE <idx>` puis `AUDIO_TEST`.
|
||||
- Profils fournis: `0=sketch19`, `1=swap_bck_ws`, `2=dout2_alt`.
|
||||
- Le tactile est désactivé par défaut (`FREENOVE_HAS_TOUCH=0`).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Contenu du dossier
|
||||
|
||||
- Sources : `src/` (app, ui, audio, storage, camera, drivers, system)
|
||||
- Polices : `src/ui/fonts/`
|
||||
- Layouts d’écran : `src/ui/screens/`
|
||||
- Partition custom : `partitions/freenove_esp32s3_app6mb_fs6mb.csv`
|
||||
- Mapping hardware détaillé (voir pour descendre plus haut )
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & usage
|
||||
|
||||
1. Clone ce repo, chausse tes lunettes de soleil.
|
||||
2. Va dans `hardware/firmware` et build comme un DJ :
|
||||
```sh
|
||||
pio run -e freenove_allinone
|
||||
pio run -e freenove_allinone -t upload --upload-port <PORT>
|
||||
```
|
||||
3. Pour la version ESP32-S3 (partition 6MB app / 6MB FS) :
|
||||
```sh
|
||||
pio run -e freenove_esp32s3
|
||||
pio run -e freenove_esp32s3 -t buildfs
|
||||
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
|
||||
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
|
||||
```
|
||||
4. Branche, allume, et laisse la magie opérer.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Fonctionnalités qui groovent
|
||||
|
||||
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
|
||||
- Exécution de scénarios (lecture, transitions, actions, audio)
|
||||
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
|
||||
- Gestion boutons et tactile (événements, mapping, callbacks)
|
||||
- Fallback robuste si fichier manquant (scénario par défaut)
|
||||
- Génération de logs et artefacts (logs/, artifacts/)
|
||||
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
|
||||
- Documentation et onboarding synchronisés
|
||||
- Mode autonome (pas besoin d’ESP32 séparé)
|
||||
|
||||
> *"Si tu rates un bouton, c’est que tu danses trop fort. Si tu vois un plasma violet, c’est que tu es dans le groove."*
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Modules principaux
|
||||
|
||||
- `audio_manager` : fait swinguer l’audio (lecture, stop, état)
|
||||
- `scenario_manager` : enchaîne les étapes comme un DJ
|
||||
- `ui_manager` : LVGL, écrans dynamiques, FX visuels
|
||||
- `storage_manager` : LittleFS (init, vérif, groove des assets)
|
||||
- `button_manager` : boutons physiques, pour les vrais
|
||||
- `touch_manager` : tactile XPT2046, pour les DJ du futur
|
||||
|
||||
---
|
||||
|
||||
## 🦄 Funky extras & notes
|
||||
|
||||
- Firmware expérimental : fusion audio + UI, mode "party hard" activé
|
||||
- Pour la compatibilité UI Link, prévoir un mode optionnel
|
||||
- Si tu veux une intro Amiga92, active la scène `SCENE_WIN_ETAPE` et laisse-toi porter par le FX timeline (plasma, starfield, boingball…)
|
||||
- Pour tester la scène MP3 : `SCENE_MP3_PLAYER` (overlay AmigaAMP, scan `/music`)
|
||||
- Pour la caméra : `SCENE_CAMERA_SCAN` (Win311 overlay, boutons = SNAP/SAVE/GALLERY/DELETE/CLOSE)
|
||||
- Pour tout le reste, consulte les logs, et si tu comprends tout du premier coup, tu gagnes un badge "Zacus Funk Master".
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribuer
|
||||
|
||||
Merci de lire [../../../../../../CONTRIBUTING.md](../../../../../../CONTRIBUTING.md) avant toute PR. Les pull requests avec punchlines sont encouragées.
|
||||
|
||||
---
|
||||
|
||||
## 👤 Contact
|
||||
|
||||
Pour toute question, suggestion, ou battle de funk, ouvre une issue GitHub ou contacte l’auteur principal :
|
||||
- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare)
|
||||
|
||||
> *"Ce firmware a été validé par un oscilloscope, un grille-pain, et un synthé vintage. Si tu trouves un bug, c’est peut-être un easter egg ou feat foirée."*
|
||||
---
|
||||
|
||||
# Firmware Freenove Media Kit All-in-One
|
||||
|
||||
## Plan d’intégration complète (couverture specs)
|
||||
|
||||
- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/)
|
||||
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
|
||||
- Exécution de scénarios (lecture, transitions, actions, audio)
|
||||
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
|
||||
- Gestion boutons et tactile (événements, mapping, callbacks)
|
||||
- Fallback robuste si fichier manquant (scénario par défaut)
|
||||
- Génération de logs et artefacts (logs/, artifacts/)
|
||||
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
|
||||
- Documentation et onboarding synchronisés
|
||||
|
||||
## Structure modulaire
|
||||
|
||||
- `src/app/*` : orchestration runtime (`main`, `scenario_manager`, coordinator serie)
|
||||
- `src/ui/*` : LVGL scenes, fonts, FX helpers (`ui_manager`, `ui_fonts`, `ui/fx`)
|
||||
- `src/audio/*` : gestion audio runtime (`audio_manager`)
|
||||
- `src/storage/*` : LittleFS/SD et resolution assets (`storage_manager`)
|
||||
- `src/camera/*` : camera runtime (`camera_manager`)
|
||||
- `src/drivers/*` : board I/O (input, board, display HAL + SPI bus manager)
|
||||
- `src/system/*` : metrics, boot report, reseau/media wrappers, task topology
|
||||
- headers legacy (`include/*_manager.h`) conserves en facades vers les headers modulaires
|
||||
|
||||
Ce firmware combine :
|
||||
- Les fonctions audio/scénario (type ESP32 Audio)
|
||||
- L’UI locale (affichage, interaction, tactile, boutons)
|
||||
- Le tout sur un seul microcontrôleur (RP2040 ou ESP32 selon le kit)
|
||||
|
||||
|
||||
## Fonctionnalités
|
||||
- Lecture audio, gestion scénario, LittleFS
|
||||
- Affichage TFT tactile (LVGL)
|
||||
- Boutons physiques, capteurs, extensions
|
||||
- Mode autonome (pas besoin d’ESP32 séparé)
|
||||
|
||||
## Modules principaux
|
||||
- audio_manager : gestion audio (lecture, stop, état)
|
||||
- scenario_manager : gestion scénario (étapes, transitions)
|
||||
- ui_manager : gestion UI (LVGL, écrans dynamiques)
|
||||
- storage_manager : gestion LittleFS (init, vérification)
|
||||
- button_manager : gestion boutons physiques
|
||||
- touch_manager : gestion tactile XPT2046
|
||||
|
||||
## Validation hardware
|
||||
- Compiler et flasher sur le Freenove Media Kit
|
||||
- Vérifier l’affichage, la réactivité tactile et boutons
|
||||
- Tester la lecture audio (fichiers dans /data/)
|
||||
- Consulter les logs série pour le suivi d’exécution
|
||||
|
||||
## Artefacts
|
||||
- Firmware compilé (.bin)
|
||||
- Logs de test hardware (logs/)
|
||||
- Rapport de compatibilité assets LittleFS
|
||||
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 l’adaptation des pins
|
||||
- Schéma de branchement : se référer à la doc Freenove
|
||||
|
||||
## Notes
|
||||
- Ce firmware est expérimental et fusionne les logiques audio + UI.
|
||||
- Pour la compatibilité UI Link, prévoir un mode optionnel.
|
||||
|
||||
## Intro Amiga92 (`SCENE_WIN_ETAPE`)
|
||||
|
||||
- Activation: l'intro A/B/C est lancee automatiquement quand `screen_scene_id == SCENE_WIN_ETAPE`.
|
||||
- Mode runtime: `FX_ONLY_V9` avec rendu timeline FX (plasma/starfield/rasterbars + tunnel3d/rotozoom/wirecube + boingball) et overlay LVGL conserve.
|
||||
- Sequence timeline verrouillee: `A(30000ms) -> B(15000ms) -> C(20000ms) -> C loop`.
|
||||
- Mapping presets (default):
|
||||
- A: `demo`
|
||||
- B: `winner`
|
||||
- C: `boingball`
|
||||
- Font scroller default: `italic`.
|
||||
- BPM default: `125`.
|
||||
- Boing shadow path: assembleur S3 active par defaut (`UI_BOING_SHADOW_ASM=1`) avec fallback C automatique.
|
||||
- Log de boot FX: `boing_shadow_path=asm|c`.
|
||||
|
||||
### Overrides runtime (TXT + JSON)
|
||||
|
||||
- Priorite de lecture:
|
||||
1) `/ui/scene_win_etape.json`
|
||||
2) `/SCENE_WIN_ETAPE.json`
|
||||
3) `/ui/SCENE_WIN_ETAPE.json`
|
||||
4) `/ui/scene_win_etape.txt`
|
||||
- Cles supportees:
|
||||
- `A_MS`, `B_MS`, `C_MS`
|
||||
- `FX_PRESET_A`, `FX_PRESET_B`, `FX_PRESET_C` (`demo|winner|fireworks|boingball`)
|
||||
- `FX_MODE_A`, `FX_MODE_B`, `FX_MODE_C` (`classic|starfield3d|dotsphere3d|voxel|raycorridor`)
|
||||
- `FX_SCROLL_TEXT_A`, `FX_SCROLL_TEXT_B`, `FX_SCROLL_TEXT_C`
|
||||
- `FX_SCROLL_FONT` (`basic|bold|outline|italic`)
|
||||
- `FX_BPM` (`60..220`)
|
||||
- Rupture de compatibilite volontaire:
|
||||
- anciennes cles FX `FX_3D`, `FX_3D_QUALITY`, `FONT_MODE` ne sont plus prises en charge dans ce flux.
|
||||
|
||||
Exemples:
|
||||
- JSON: `data/SCENE_WIN_ETAPE.json`
|
||||
- TXT: `data/ui/scene_win_etape.txt`
|
||||
|
||||
### Perf notes
|
||||
|
||||
- Tick fixe: `42 ms` (`~24 FPS` cible), `dt` clamp pour robustesse.
|
||||
- Zero allocation par frame dans la boucle intro (`tickIntro`).
|
||||
- Blit LGFX: fast-path 2x active (`UI_FX_BLIT_FAST_2X=1`) si ratio source/ecran exact, fallback scaler general sinon.
|
||||
- Caps objets scene: `<=140` (petit ecran), `<=260` (grand ecran).
|
||||
- Pools fixes:
|
||||
- stars: `48`
|
||||
- fireworks: `72`
|
||||
- wave glyph+shadow: `64 + 64`
|
||||
- `UI_GFX_STATUS` expose `fx_fps/fx_frames/fx_skip_busy` + compteurs `flush_block/overflow` pour diagnostiquer les saccades LVGL/FX.
|
||||
|
||||
## Scenes demoscene exposees
|
||||
|
||||
- IDs canoniques ajoutes: `SCENE_WINNER`, `SCENE_FIREWORKS`.
|
||||
- Fichiers data:
|
||||
- `data/story/screens/SCENE_WINNER.json`
|
||||
- `data/story/screens/SCENE_FIREWORKS.json`
|
||||
- Registry story: `hardware/libs/story/src/resources/screen_scene_registry.cpp`.
|
||||
|
||||
## Scene lecteur MP3 (`SCENE_MP3_PLAYER`)
|
||||
|
||||
- Scene canonique: `SCENE_MP3_PLAYER`.
|
||||
- Aliases supportes: `SCENE_AUDIO_PLAYER`, `SCENE_MP3`.
|
||||
- Data scene: `data/story/screens/SCENE_MP3_PLAYER.json`.
|
||||
- Mode runtime: overlay LVGL "AmigaAMP" + backend `AudioPlayerService` (scan `/music`, fallback `/audio/music`, fallback `/audio`).
|
||||
- Arbitrage audio:
|
||||
- en scene MP3, l'audio scenario est suspendu;
|
||||
- en sortie de scene MP3, pipeline audio scenario restaure.
|
||||
- Commandes serie:
|
||||
- `AMP_SHOW`, `AMP_HIDE`, `AMP_TOGGLE`
|
||||
- `AMP_SCAN`, `AMP_PLAY <idx|path>`, `AMP_NEXT`, `AMP_PREV`, `AMP_STOP`, `AMP_STATUS`
|
||||
|
||||
## Scene camera recorder (`SCENE_CAMERA_SCAN`)
|
||||
|
||||
- Binding scene: entree `SCENE_CAMERA_SCAN` -> session recorder camera active (`RGB565/QVGA`) + overlay Win311 visible.
|
||||
- Sortie scene: overlay masque, frame gelee purgee, retour mode snapshot legacy (`JPEG`).
|
||||
- Mapping boutons physiques:
|
||||
- `BTN1`: `SNAP/LIVE`
|
||||
- `BTN2`: `SAVE`
|
||||
- `BTN3`: `GALLERY` (appui long: `NEXT`)
|
||||
- `BTN4`: `DELETE`
|
||||
- `BTN5`: `CLOSE` overlay
|
||||
- Commandes serie:
|
||||
- `CAM_UI_SHOW`, `CAM_UI_HIDE`, `CAM_UI_TOGGLE`
|
||||
- `CAM_REC_SNAP`, `CAM_REC_SAVE [auto|bmp|jpg|raw]`
|
||||
- `CAM_REC_GALLERY`, `CAM_REC_NEXT`, `CAM_REC_DELETE`, `CAM_REC_STATUS`
|
||||
- Ownership runtime:
|
||||
- pendant `SCENE_CAMERA_SCAN`, les boutons physiques ne sont pas forwardes au scenario;
|
||||
- commandes legacy `CAM_ON/CAM_OFF/CAM_SNAPSHOT` renvoient `camera_busy_recorder_owner`.
|
||||
|
||||
### Visual verification mode
|
||||
|
||||
- Build flags (env `freenove_esp32s3`) pour tests scene:
|
||||
- `UI_FULL_FRAME_BENCH=1`
|
||||
- `UI_DEMO_AUTORUN_WIN_ETAPE=1`
|
||||
- Quand ces flags sont actifs:
|
||||
- la scene `SCENE_WIN_ETAPE` demarre automatiquement au boot;
|
||||
- la phase C continue en boucle tant que la scene reste active;
|
||||
- `UI_GFX_STATUS` permet de verifier le mode runtime (depth/full_frame/source).
|
||||
|
||||
### References consulted
|
||||
|
||||
- https://www.pouet.net/prodlist.php?type%5B0%5D=cracktro
|
||||
- https://www.youtube.com/results?search_query=amiga+cracktro
|
||||
- https://www.youtube.com/results?search_query=amiga+demoscene+1992
|
||||
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/hard_2.html
|
||||
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/
|
||||
- https://www.markwrobel.dk/project/amigamachinecode/
|
||||
- https://www.markwrobel.dk/post/amiga-machine-code-letter3-copper-revisited/
|
||||
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-starfield-effect/
|
||||
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-wave/
|
||||
- https://github.com/mrandreastoth/AmigaStyleDemo
|
||||
|
||||
@@ -0,0 +1,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 ""; }
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user