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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-11 00:03:58 +01:00
parent 226e15de7b
commit 8d874ae31b
101 changed files with 9051 additions and 20983 deletions
+35
View File
@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
platformio:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install PlatformIO
run: python -m pip install --upgrade pip platformio
- name: Build
run: pio run
- name: Size report
run: pio run -t size
+3
View File
@@ -0,0 +1,3 @@
This Source Code Form is subject to the terms of the Mozilla Public License,
v. 2.0. If a copy of the MPL was not distributed with this file, You can
obtain one at https://mozilla.org/MPL/2.0/.
+59 -175
View File
@@ -1,218 +1,102 @@
# Optimisation automatique hardware
Depuis février 2026, le cockpit détecte automatiquement le hardware connecté (port série, ID USB) et ne build/flash que lenvironnement PlatformIO correspondant.
# ESP32_ZACUS
Pour builder ou flasher:
- Menu cockpit: "Build all firmware" ou "Flash auto (hardware détecté)"
- Ligne de commande: ./tools/dev/cockpit.sh flash (auto) ou ./tools/dev/cockpit.sh build (auto)
Workspace firmware centré sur une seule cible: Freenove FNK0102H avec `ui_freenove_allinone`.
La détection est basée sur pyserial et lID USB (Freenove, ESP32, ESP8266, RP2040). Le log affiche le hardware détecté et lenvironnement PlatformIO ciblé.
## Docs canoniques
Pour forcer un build/flash spécifique: ./tools/dev/cockpit.sh flash <env>
- `docs/QUICKSTART.md`: bootstrap, build, flash, monitor, smoke.
- `docs/FNK0102H_SOURCE_OF_TRUTH.md`: matrice board/config/pins/support.
- `README_ESP32_ZACUS.md`: notes runtime et signatures série utiles.
## Statut Freenove ESP32-S3 (2026-03-01)
## Arborescence utile
- Build firmware: OK (`freenove_esp32s3_full_with_ui`)
- Build/flash LittleFS: OK
- Flash firmware: OK
- Smoke série runtime: KO (reboot loop `rst:0x3`, `Saved PC:0x403cdb0a`)
- Détail et plan de debug: `README_ESP32_ZACUS.md`
- `platformio.ini`: env PlatformIO canonique `freenove_esp32s3_full_with_ui`.
- `ui_freenove_allinone/`: firmware UI/audio/caméra/réseau.
- `data/`: contenu LittleFS.
- `lib/`: bibliothèques runtime.
- `scripts/`: bootstrap et scripts repo.
- `tests/`: harness Sprint 1, Sprint 2, Sprint 3, Phase 9.
# Hardware Firmware
## Environnements supportés
> **[Mise à jour 2026]**
>
> **Tous les assets LittleFS (scénarios, écrans, scènes, audio, actions, etc.) sont désormais centralisés dans le dossier `data/` à la racine de `hardware/firmware/`.**
>
> Ce dossier unique sert de source pour le flash LittleFS sur ESP32, ESP8266 et RP2040. Les anciens dossiers `data/` dans les sous-projets doivent être migrés/supprimés (voir encart migration ci-dessous).
- `freenove_esp32s3_full_with_ui`: chemin release/stabilisation.
- `freenove_esp32s3`: alias du chemin canonique.
- `freenove_esp32s3_touch`: expérimental, hors release, sans CI.
## Démarrage rapide
Workspace PlatformIO unique pour 3 firmwares:
- `esp32_audio/`: firmware principal audio
- `ui/esp8266_oled/`: UI OLED legere
- `ui/rp2040_tft/`: UI TFT tactile (LVGL)
- `protocol/`: contrat UART partage (`ui_link_v2.md`, `ui_link_v2.h`)
### 🟦 Freenove Media Kit (RP2040)
Un environnement PlatformIO dédié est disponible pour le boîtier Freenove Media Kit:
- **Build**: `pio run -e ui_rp2040_freenove`
- **Flash**: `pio run -e ui_rp2040_freenove -t upload --upload-port <PORT>`
- **Monitor**: `pio device monitor -e ui_rp2040_freenove --port <PORT>`
Le mapping hardware (pins, écran, boutons) est défini dans `ui/rp2040_tft/include/ui_freenove_config.h`.
**Schéma de branchement**: voir `hardware/wiring/wiring.md` (section Freenove)
**Remarque**: adaptez les defines dans `ui_freenove_config.h` selon votre version du Media Kit (écran, boutons, etc.).
## 📚 Documentation
- **[État des lieux](docs/STATE_ANALYSIS.md)** - Analyse complète du firmware
- **[Recommandations Sprint](docs/SPRINT_RECOMMENDATIONS.md)** - Actions prioritaires
- **[Recovery WiFi/AP & Health](docs/WIFI_RECOVERY_AND_HEALTH.md)** - Procédure recovery AP, healthcheck, troubleshooting
Pour débuter : [docs/QUICKSTART.md](docs/QUICKSTART.md)
## Structure des assets LittleFS (cross-plateforme)
```
hardware/firmware/data/
story/
scenarios/
DEFAULT.json
apps/
screens/
audio/
actions/
audio/
radio/
net/
```bash
./scripts/bootstrap_platformio.sh
./scripts/doctor_repo.sh
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>
python -m platformio device monitor -b 115200 --port <PORT>
```
**Scripts de génération et de flash: toujours pointer vers ce dossier.**
## Guardrails runtime
---
- Si la PSRAM est détectée, le firmware démarre normalement puis autorise le réseau après validation UI.
- Si la PSRAM n'est pas détectée, le firmware bascule en `safe diagnostic mode`.
- En mode diagnostic, le firmware garde uniquement la pile série, l'affichage minimal et les boutons physiques.
- En mode diagnostic, caméra, FX, micro, audio, Wi-Fi, ESP-NOW, WebUI et partage de fichiers restent coupés.
## Build
## Vérifications minimales
```sh
pio run
```
Build cible:
```sh
pio run -e esp32dev
pio run -e esp32_release
pio run -e esp8266_oled
pio run -e ui_rp2040_ili9488
pio run -e ui_rp2040_ili9486
```
Script global:
```sh
./build_all.sh
```
Bootstrap local tools once:
```sh
./tools/dev/bootstrap_local.sh
```
## Story portable (generation + runtime)
- Generation library: `lib/zacus_story_gen_ai/` (Yamale + Jinja2).
- Runtime library: `lib/zacus_story_portable/` (tinyfsm-style internals).
- Story serial protocol: JSON-lines V3 (`story.*`), see `docs/protocols/story_v3_serial.md`.
- Canonical migration doc: `docs/STORY_PORTABILITY_MIGRATION.md`.
CLI:
```sh
./tools/dev/story-gen validate
./tools/dev/story-gen generate-cpp
./tools/dev/story-gen generate-bundle
./tools/dev/story-gen all
```bash
./scripts/doctor_repo.sh
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>
```
## Flash (cockpit)
```sh
./tools/dev/cockpit.sh flash
```
Options utiles:
- `ZACUS_FLASH_ESP32_ENVS="esp32dev esp32_release"`
- `ZACUS_FLASH_RP2040_ENVS="ui_rp2040_ili9488 ui_rp2040_ili9486"`
- `ZACUS_PORT_ESP32=... ZACUS_PORT_ESP8266=... ZACUS_PORT_RP2040=...`
---
## Migration LittleFS (2026)
- Déplacer tous les fichiers de scénario, écrans, scènes, audio, etc. dans `hardware/firmware/data/`.
- Adapter les scripts de génération et de flash pour pointer vers ce dossier.
- Supprimer les anciens dossiers `data/` dispersés dans les sous-projets (`ui/rp2040_tft/data/`, `esp32_audio/data/`, etc.).
- Mettre à jour tous les guides et onboarding pour refléter cette structure unique.
---
```sh
make fast-esp32 ESP32_PORT=<PORT_ESP32>
make fast-ui-oled UI_OLED_PORT=<PORT_ESP8266>
make fast-ui-tft UI_TFT_PORT=<PORT_RP2040>
```
Smoke série (manuel):
```sh
python3 tools/dev/serial_smoke.py --role auto --baud 115200 --wait-port 3 --allow-no-hardware
```
MacOS CP2102 duplicates share VID/PID=10C4:EA60/0001; the LOCATION (20-6.1.1=ESP32, 20-6.1.2=ESP8266) drives the detector. `tools/dev/ports_map.json` now uses `location -> role` and `vidpid -> role` mappings.
USB console monitoring uses `115200`. ESP8266 internal UI link SoftwareSerial stays at `57600` (internal link only).
## Serial smoke commands
- baseline smoke (auto handles already connected boards): `python3 tools/dev/serial_smoke.py --role auto --baud 115200 --wait-port 3 --allow-no-hardware`
- run every detected role: `python3 tools/dev/serial_smoke.py --role all --baud 115200 --wait-port 3 --allow-no-hardware`
- force hardware detection: `ZACUS_REQUIRE_HW=1 python3 tools/dev/serial_smoke.py --role auto --baud 115200 --wait-port 180`
- skip PlatformIO builds and just run smoke (useful when downloads are impossible): `ZACUS_SKIP_PIO=1 ./tools/dev/run_matrix_and_smoke.sh`
## Build + smoke combo
```sh
./tools/dev/run_matrix_and_smoke.sh
# or from repo root:
./hw_now.sh
```
Cockpit shortcut:
```sh
./tools/dev/cockpit.sh rc
```
`run_matrix_and_smoke.sh` ensures PlatformIO caches land under `$HOME/.platformio` (via `PLATFORMIO_CORE_DIR`) rather than inside the repo.
Before smoke it shows `⚠️ BRANCHE LUSB MAINTENANT ⚠️` three times, then waits for Enter while listing ports every 15s.
Each run writes deterministic artifacts under `artifacts/rc_live/<env_label>_<timestamp>/` and logs under `logs/rc_live/<env_label>_<timestamp>.log` (`summary.json`, `summary.md`, `ports_resolve.json`, `ui_link.log`, per-step logs).
The runner resolves macOS CP2102 by LOCATION (`20-6.1.1` ESP32, `20-6.1.2` ESP8266 USB), then enforces a dedicated `UI_LINK_STATUS connected=1` gate on ESP32.
When `ZACUS_ENV="freenove_esp32s3"` (single-board), ESP8266/UI-link/story-screen gates are marked `SKIP` with `not needed for combined board`.
Environment overrides:
- `ZACUS_REQUIRE_HW=1 ./tools/dev/run_matrix_and_smoke.sh` (fail when no hardware).
- `ZACUS_WAIT_PORT=3 ./tools/dev/run_matrix_and_smoke.sh` (override serial wait window for smoke).
- `ZACUS_NO_COUNTDOWN=1 ./tools/dev/run_matrix_and_smoke.sh` (skip the USB wait gate).
- `ZACUS_SKIP_SMOKE=1 ./tools/dev/run_matrix_and_smoke.sh` (build only, no serial smoke step).
- `ZACUS_ENV="esp32dev esp8266_oled" ./tools/dev/run_matrix_and_smoke.sh` (custom env subset).
- `ZACUS_ENV="freenove_esp32s3" ./tools/dev/run_matrix_and_smoke.sh` (single-board Freenove path).
- `ZACUS_FORCE_BUILD=1 ./tools/dev/run_matrix_and_smoke.sh` (force rebuild even when artifacts exist).
By default the smoke step exits 0 when no serial hardware is present; use `ZACUS_REQUIRE_HW=1` to enforce detection.
<!-- CHANTIER:AUDIT START -->
## Audit & Execution Plan (2026-03-10)
## Docs
### Snapshot
- Priority: `P2`
- Tech profile: `embedded`
- Workflows: `yes`
- Tests: `yes`
- Debt markers: `0`
- Source files: `258`
- Cablage ESP32/UI: `esp32_audio/WIRING.md`
- Cablage TFT: `ui/rp2040_tft/WIRING.md`
- Quickstart flash: `docs/QUICKSTART.md`
- RC board execution: `docs/RC_FINAL_BOARD.md`
- Protocole: `protocol/ui_link_v2.md`
- Cockpit command registry: `docs/_generated/COCKPIT_COMMANDS.md`
### Corrections Prioritaires
- [ ] Vérifier target PlatformIO et budget mémoire
- [ ] Ajouter/fiabiliser les commandes de vérification automatiques.
- [ ] Clore les points bloquants avant optimisation avancée.
## Codex prompts
### Optimisation
- [ ] Identifier le hotspot principal et mesurer avant/après.
- [ ] Réduire la complexité des modules les plus touchés.
Prompt files live under `tools/dev/codex_prompts/*.prompt.md` and are designed to be consumed by the automation-friendly `codex exec` command.
Run `./tools/dev/codex_prompt_menu.sh` to see a numbered menu, pick a prompt, and send it to `codex exec --sandbox workspace-write --output-last-message artifacts/rc_live/_codex_last_message.md`.
You can also launch this helper directly from the firmware cockpit (`./tools/dev/cockpit.sh` option 6) so the existing workflow keeps a single entry point.
### Mémoire chantier
- Control plane: `/Users/electron/.codex/memories/electron_rare_chantier`
- Repo card: `/Users/electron/.codex/memories/electron_rare_chantier/REPOS/ESP32_ZACUS.md`
Story authoring prompts live separately under `docs/protocols/story_specs/prompts/*.prompt.md`. They are not ops prompts, but can still be used with Codex tooling when needed.
<!-- CHANTIER:AUDIT END -->
+38 -118
View File
@@ -1,133 +1,53 @@
# ESP32_ZACUS Freenove ESP32-S3 UI Firmware
# ESP32_ZACUS Runtime Notes
Branche de travail dédiée pour optimisations et refactoring de l'interface Freenove.
Ce fichier résume le comportement de boot attendu sur la branche de stabilisation Freenove.
**Status**: 2026-03-01 MVP apps/assets intégrés, build + flash OK, runtime série en reboot loop (debug en cours).
## État courant
## Intégration en place (MVP/V1 groundwork)
- Toolchain figée sur `espressif32@6.5.0`.
- Cible officielle: Freenove FNK0102H / `freenove_esp32_s3_wroom` (`N16R8`, 16 MB flash / 8 MB PSRAM).
- Le tactile reste expérimental et hors chemin release.
- L'absence de PSRAM ne lance plus le shell applicatif complet.
- Runtime apps: registre + runtime manager + modules MVP/V1 branchés.
- NES core MVP: module `nes_emulator` (validation iNES mapper 0 + actions runtime).
- ROMs NES en LittleFS: 5 homebrews dans `data/apps/nes_emulator/roms/`.
- Assets FS: icônes PNG + SFX WAV + médias fallback MP3 générés dans `data/apps/**`.
- WebUI assets: header/favicon/sfx + fontes locales.
- Fontes WebUI: `PressStart2P` + `ComicNeue` servies depuis `LittleFS` (`/webui/assets/fonts/*.ttf`).
- PlatformIO: hook pre-build pour provisionner les fontes (`scripts/pio_prepare_webui_fonts.py`).
## Signatures série attendues
## Structure
```
ESP32_ZACUS/
├── ui_freenove_allinone/ # Freenove UI app (UI rendering, scenes, story integration)
├── lib/ # Story engine + auxiliary libraries
├── platformio.ini # PlatformIO config (Freenove environment)
└── README.md # General Zacus firmware guide
```
## Workflow
### Setup Local
```bash
cd ESP32_ZACUS
python3 -m venv .venv
source .venv/bin/activate
pip install platformio esptool pyserial
# List PlatformIO boards
pio boards | grep freenove
```
### Build & Upload Freenove (firmware + LittleFS)
```bash
pio run -e freenove_esp32s3_full_with_ui
pio run -e freenove_esp32s3_full_with_ui -t buildfs
pio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port /dev/cu.usbmodem5AB90753301
pio run -e freenove_esp32s3_full_with_ui -t upload --upload-port /dev/cu.usbmodem5AB90753301
```
### Serial Monitor (115200 baud)
```bash
pio device monitor -p /dev/cu.usbmodem5AB90753301 -b 115200
```
Or Python:
```bash
python3 << 'PY'
import serial, time
port = '/dev/cu.usbmodem5AB90753301'
ser = serial.Serial(port, 115200, timeout=0.5)
time.sleep(0.2)
ser.write(b'STATUS\n')
time.sleep(0.3)
for _ in range(10):
line = ser.readline().decode('utf-8', 'ignore').strip()
if line:
print(line)
ser.close()
PY
```
## Validation du 2026-03-01
- `build` firmware: **OK**.
- `buildfs`: **OK**.
- `uploadfs`: **OK**.
- `upload` firmware: **OK**.
- test série: **KO** (reboot loop immédiat).
Extrait série observé:
Boot normal:
```text
rst:0x3 (RTC_SW_SYS_RST),boot:0x8 (SPI_FAST_FLASH_BOOT)
Saved PC:0x403cdb0a
[MAIN] Freenove all-in-one boot
[MEM] psram_found=1
[APP] registry loaded=1 ...
```
## TODO immédiat (debug runtime)
Boot diagnostic sans PSRAM:
- Capturer un backtrace exploitable (désactiver reset auto/activer logs très tôt).
- Vérifier cohérence board/flash/PSRAM par rapport au hardware réel.
- Isoler les init précoces (PSRAM, display/camera/audio) pour trouver la séquence qui déclenche `esp_restart`.
- Refaire smoke série après correctif (boot stable + init runtime + endpoints web).
## Future Work
- **UI Optimization** (ui_freenove_allinone):
- Refactor LVGL rendering pipeline
- Scene/story integration improvements
- Touch input handling; audio trigger feedback
- **Story Engine** (lib/story/):
- Performance audit
- Memory footprint reduction
- ESP-NOW sync protocol optimization
- **Hardware Integration**:
- Audio codec I2S optimization
- PSRAM buffer management
- Camera/WiFi/Bluetooth coexistence
## Git Push to GitHub
Once repo created on GitHub:
```bash
cd ESP32_ZACUS
git remote add origin https://github.com/YOUR_USER/ESP32_ZACUS.git
git branch -m master main
git push -u origin main
```text
[MEM] PSRAM expected by build flags but not detected
[BOOT] safe diagnostic mode enabled: PSRAM required, app stack disabled
[SAFE] boot path: storage + serial + display + buttons only
```
## References
## Commandes série utiles
- [Freenove Board Docs](../hardware/docs/RC_FINAL_BOARD.md)
- [Story Engine Contract](../hardware/firmware/docs/ESP_NOW_API_CONTRACT_FREENOVE_V1.md)
- [Zacus Game Protocol](../hardware/firmware/docs/ARCHITECTURE_UML.md)
Mode diagnostic:
---
- `PING`
- `HELP`
- `STATUS`
- `BTN_READ`
- `LCD_BACKLIGHT 120`
**Last Updated**: 2026-03-01
**Authored by**: Agent (Copilot)
Mode normal:
- `STATUS`
- `APP_STATUS`
- `UI_MEM_STATUS`
- `NET_STATUS`
- `CAM_STATUS`
## Debug PSRAM
1. Lancer `./scripts/doctor_repo.sh`.
2. Flasher `freenove_esp32s3_full_with_ui`.
3. Ouvrir le monitor série à `115200`.
4. Si le mode diagnostic apparaît, vérifier le module réel, le mode flash/PSRAM et le câblage.
+54
View File
@@ -0,0 +1,54 @@
{
"build": {
"arduino": {
"ldscript": "esp32s3_out.ld",
"memory_type": "qio_opi",
"partitions": "default_8MB.csv"
},
"core": "esp32",
"extra_flags": [
"-DARDUINO_FREENOVE_ESP32_S3_WROOM",
"-DARDUINO_USB_MODE=1",
"-DARDUINO_RUNNING_CORE=1",
"-DARDUINO_EVENT_RUNNING_CORE=1",
"-DBOARD_HAS_PSRAM"
],
"f_cpu": "240000000L",
"f_flash": "80000000L",
"flash_mode": "qio",
"hwids": [
[
"0x303A",
"0x1001"
]
],
"mcu": "esp32s3",
"psram_type": "opi",
"variant": "esp32s3"
},
"connectivity": [
"bluetooth",
"wifi"
],
"debug": {
"default_tool": "esp-builtin",
"onboard_tools": [
"esp-builtin"
],
"openocd_target": "esp32s3.cfg"
},
"frameworks": [
"arduino",
"espidf"
],
"name": "Freenove ESP32-S3 WROOM N16R8 (16MB Flash / 8MB PSRAM)",
"upload": {
"flash_size": "16MB",
"maximum_ram_size": 327680,
"maximum_size": 16777216,
"require_upload_port": true,
"speed": 460800
},
"url": "https://github.com/Freenove/Freenove_ESP32_S3_WROOM_Board",
"vendor": "Freenove"
}
+73
View File
@@ -0,0 +1,73 @@
# FNK0102H Source Of Truth
## Cible officielle
| Élément | Valeur |
| --- | --- |
| Carte | Freenove FNK0102H |
| Module / board profile | Freenove ESP32-S3 WROOM N16R8 |
| Flash | 16 MB |
| PSRAM | 8 MB |
| Framework | Arduino |
| PlatformIO | `espressif32@6.5.0` |
| Env canonique | `freenove_esp32s3_full_with_ui` |
| `board` PlatformIO | `freenove_esp32_s3_wroom` |
| Board manifest local | `boards/freenove_esp32_s3_wroom.json` |
## Mémoire et build
| Paramètre | Valeur |
| --- | --- |
| `board_build.flash_size` | `16MB` |
| `board_upload.maximum_size` | `4194304` |
| `board_build.partitions` | `partitions/freenove_esp32s3_app4mb_fs12096kb.csv` |
| `FREENOVE_HAS_TOUCH` | `0` en release |
## Écran et entrées
| Fonction | GPIO / Paramètre |
| --- | --- |
| LCD variant | `FNK0102H_ST7796_320x480` |
| Rotation | `1` |
| TFT SCK | 47 |
| TFT MOSI | 21 |
| TFT DC | 45 |
| TFT RESET | 20 |
| TFT BL | 2 |
| Boutons 5 directions | GPIO19 via ladder analogique |
## Stockage et audio
| Fonction | GPIO |
| --- | --- |
| SD CMD | 38 |
| SD CLK | 39 |
| SD D0 | 40 |
| I2S WS | 41 |
| I2S BCK | 42 |
| I2S DOUT | 1 |
| I2S IN SCK / WS / DIN | 3 / 14 / 46 |
## Caméra
| Fonction | GPIO |
| --- | --- |
| XCLK | 15 |
| SIOD / SIOC | 4 / 5 |
| Y2 / Y3 / Y4 / Y5 / Y6 | 11 / 9 / 8 / 10 / 12 |
| Y7 / Y8 / Y9 | 18 / 17 / 16 |
| VSYNC / HREF / PCLK | 6 / 7 / 13 |
| PWDN / RESET | `-1` / `-1` |
## Hors support release
- `freenove_esp32s3_touch` est expérimental.
- Le tactile n'est pas une interface officielle du chemin release.
- Les broches `GPIO9` et `GPIO15` sont déjà utilisées par la caméra dans ce profil.
- Toute tentative d'activer le tactile doit être traitée comme une variante séparée, hors CI et hors release.
## Sources externes
- <https://docs.espressif.com/projects/arduino-esp32/en/latest/troubleshooting.html>
- <https://docs.freenove.com/projects/fnk0102/en/latest/>
- <https://freenove.com/products/fnk0102>
+70
View File
@@ -0,0 +1,70 @@
# Quickstart
## Prérequis
- `python3`
- accès USB vers la carte Freenove
- réseau local optionnel pour les tests HTTP
## Bootstrap
```bash
./scripts/bootstrap_platformio.sh
./scripts/doctor_repo.sh
source .venv/bin/activate
```
Le bootstrap crée une `.venv` locale et installe `platformio`, `esptool` et `pyserial`.
## Build
```bash
python -m platformio run -e freenove_esp32s3_full_with_ui
python -m platformio run -e freenove_esp32s3_full_with_ui -t buildfs
```
## Flash
```bash
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>
```
## Monitor
```bash
python -m platformio device monitor -b 115200 --port <PORT>
```
## Signatures attendues
Boot normal:
```text
[MAIN] Freenove all-in-one boot
[MEM] psram_found=1
```
Boot diagnostic sans PSRAM:
```text
[MEM] PSRAM expected by build flags but not detected
[BOOT] safe diagnostic mode enabled: PSRAM required, app stack disabled
[SAFE] boot path: storage + serial + display + buttons only
```
## Smoke minimal
```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>
```
## Procédure si la PSRAM est absente
1. Garder le monitor série ouvert.
2. Vérifier que le boot reste vivant en mode diagnostic.
3. Confirmer l'absence d'initialisation Wi-Fi, ESP-NOW, caméra et audio.
4. Vérifier le module réel et la config `platformio.ini` avant de reflasher.
@@ -0,0 +1,184 @@
#ifndef ESP32_CAMERA_PINS_H_
#define ESP32_CAMERA_PINS_H_
struct CameraPins
{
int PWDN_GPIO_NUM;
int RESET_GPIO_NUM;
int XCLK_GPIO_NUM;
int SIOD_GPIO_NUM;
int SIOC_GPIO_NUM;
int Y9_GPIO_NUM;
int Y8_GPIO_NUM;
int Y7_GPIO_NUM;
int Y6_GPIO_NUM;
int Y5_GPIO_NUM;
int Y4_GPIO_NUM;
int Y3_GPIO_NUM;
int Y2_GPIO_NUM;
int VSYNC_GPIO_NUM;
int HREF_GPIO_NUM;
int PCLK_GPIO_NUM;
};
#define CAMERA_MODEL_WROVER_KIT \
{ \
.PWDN_GPIO_NUM = -1, \
.RESET_GPIO_NUM = -1, \
.XCLK_GPIO_NUM = 21, \
.SIOD_GPIO_NUM = 26, \
.SIOC_GPIO_NUM = 27, \
.Y9_GPIO_NUM = 35, \
.Y8_GPIO_NUM = 34, \
.Y7_GPIO_NUM = 39, \
.Y6_GPIO_NUM = 36, \
.Y5_GPIO_NUM = 19, \
.Y4_GPIO_NUM = 18, \
.Y3_GPIO_NUM = 5, \
.Y2_GPIO_NUM = 4, \
.VSYNC_GPIO_NUM = 25, \
.HREF_GPIO_NUM = 23, \
.PCLK_GPIO_NUM = 22, \
}
#define CAMERA_MODEL_ESP_EYE \
{ \
.PWDN_GPIO_NUM = -1, \
.RESET_GPIO_NUM = -1, \
.XCLK_GPIO_NUM = 4, \
.SIOD_GPIO_NUM = 18, \
.SIOC_GPIO_NUM = 23, \
.Y9_GPIO_NUM = 36, \
.Y8_GPIO_NUM = 37, \
.Y7_GPIO_NUM = 38, \
.Y6_GPIO_NUM = 39, \
.Y5_GPIO_NUM = 35, \
.Y4_GPIO_NUM = 14, \
.Y3_GPIO_NUM = 13, \
.Y2_GPIO_NUM = 34, \
.VSYNC_GPIO_NUM = 5, \
.HREF_GPIO_NUM = 27, \
.PCLK_GPIO_NUM = 25, \
}
#define CAMERA_MODEL_M5STACK_PSRAM \
{ \
.PWDN_GPIO_NUM = -1, \
.RESET_GPIO_NUM = 15, \
.XCLK_GPIO_NUM = 27, \
.SIOD_GPIO_NUM = 25, \
.SIOC_GPIO_NUM = 23, \
.Y9_GPIO_NUM = 19, \
.Y8_GPIO_NUM = 36, \
.Y7_GPIO_NUM = 18, \
.Y6_GPIO_NUM = 39, \
.Y5_GPIO_NUM = 5, \
.Y4_GPIO_NUM = 34, \
.Y3_GPIO_NUM = 35, \
.Y2_GPIO_NUM = 32, \
.VSYNC_GPIO_NUM = 22, \
.HREF_GPIO_NUM = 26, \
.PCLK_GPIO_NUM = 21, \
}
#define CAMERA_MODEL_M5STACK_V2_PSRAM \
{ \
.PWDN_GPIO_NUM = -1, \
.RESET_GPIO_NUM = 15, \
.XCLK_GPIO_NUM = 27, \
.SIOD_GPIO_NUM = 22, \
.SIOC_GPIO_NUM = 23, \
.Y9_GPIO_NUM = 19, \
.Y8_GPIO_NUM = 36, \
.Y7_GPIO_NUM = 18, \
.Y6_GPIO_NUM = 39, \
.Y5_GPIO_NUM = 5, \
.Y4_GPIO_NUM = 34, \
.Y3_GPIO_NUM = 35, \
.Y2_GPIO_NUM = 32, \
.VSYNC_GPIO_NUM = 25, \
.HREF_GPIO_NUM = 26, \
.PCLK_GPIO_NUM = 21, \
}
#define CAMERA_MODEL_M5STACK_WIDE \
{ \
.PWDN_GPIO_NUM = -1, \
.RESET_GPIO_NUM = 15, \
.XCLK_GPIO_NUM = 27, \
.SIOD_GPIO_NUM = 22, \
.SIOC_GPIO_NUM = 23, \
.Y9_GPIO_NUM = 19, \
.Y8_GPIO_NUM = 36, \
.Y7_GPIO_NUM = 18, \
.Y6_GPIO_NUM = 39, \
.Y5_GPIO_NUM = 5, \
.Y4_GPIO_NUM = 34, \
.Y3_GPIO_NUM = 35, \
.Y2_GPIO_NUM = 32, \
.VSYNC_GPIO_NUM = 25, \
.HREF_GPIO_NUM = 26, \
.PCLK_GPIO_NUM = 21, \
}
#define CAMERA_MODEL_M5STACK_ESP32CAM \
{ \
.PWDN_GPIO_NUM = -1, \
.RESET_GPIO_NUM = 15, \
.XCLK_GPIO_NUM = 27, \
.SIOD_GPIO_NUM = 25, \
.SIOC_GPIO_NUM = 23, \
.Y9_GPIO_NUM = 19, \
.Y8_GPIO_NUM = 36, \
.Y7_GPIO_NUM = 18, \
.Y6_GPIO_NUM = 39, \
.Y5_GPIO_NUM = 5, \
.Y4_GPIO_NUM = 34, \
.Y3_GPIO_NUM = 35, \
.Y2_GPIO_NUM = 17, \
.VSYNC_GPIO_NUM = 22, \
.HREF_GPIO_NUM = 26, \
.PCLK_GPIO_NUM = 21, \
}
#define CAMERA_MODEL_AI_THINKER \
{ \
.PWDN_GPIO_NUM = 32, \
.RESET_GPIO_NUM = -1, \
.XCLK_GPIO_NUM = 0, \
.SIOD_GPIO_NUM = 26, \
.SIOC_GPIO_NUM = 27, \
.Y9_GPIO_NUM = 35, \
.Y8_GPIO_NUM = 34, \
.Y7_GPIO_NUM = 39, \
.Y6_GPIO_NUM = 36, \
.Y5_GPIO_NUM = 21, \
.Y4_GPIO_NUM = 19, \
.Y3_GPIO_NUM = 18, \
.Y2_GPIO_NUM = 5, \
.VSYNC_GPIO_NUM = 25, \
.HREF_GPIO_NUM = 23, \
.PCLK_GPIO_NUM = 22, \
}
#define CAMERA_MODEL_TTGO_T_JOURNAL \
{ \
.PWDN_GPIO_NUM = 0, \
.RESET_GPIO_NUM = 15, \
.XCLK_GPIO_NUM = 27, \
.SIOD_GPIO_NUM = 25, \
.SIOC_GPIO_NUM = 23, \
.Y9_GPIO_NUM = 19, \
.Y8_GPIO_NUM = 36, \
.Y7_GPIO_NUM = 18, \
.Y6_GPIO_NUM = 39, \
.Y5_GPIO_NUM = 5, \
.Y4_GPIO_NUM = 34, \
.Y3_GPIO_NUM = 35, \
.Y2_GPIO_NUM = 17, \
.VSYNC_GPIO_NUM = 22, \
.HREF_GPIO_NUM = 26, \
.PCLK_GPIO_NUM = 21, \
}
#endif //ESP32_CAMERA_PINS_H_
@@ -0,0 +1,63 @@
#ifndef ESP32_QR_CODE_ARDUINO_H_
#define ESP32_QR_CODE_ARDUINO_H_
#include "Arduino.h"
#include "ESP32CameraPins.h"
#include "esp_camera.h"
#ifndef QR_CODE_READER_STACK_SIZE
#define QR_CODE_READER_STACK_SIZE 40 * 1024
#endif
#ifndef QR_CODE_READER_TASK_PRIORITY
#define QR_CODE_READER_TASK_PRIORITY 5
#endif
enum QRCodeReaderSetupErr
{
SETUP_OK,
SETUP_NO_PSRAM_ERROR,
SETUP_CAMERA_INIT_ERROR,
};
/* This structure holds the decoded QR-code data */
struct QRCodeData
{
bool valid;
int dataType;
uint8_t payload[1024];
int payloadLen;
};
class ESP32QRCodeReader
{
private:
TaskHandle_t qrCodeTaskHandler;
CameraPins pins;
framesize_t frameSize;
public:
camera_config_t cameraConfig;
QueueHandle_t qrCodeQueue;
bool begun = false;
bool debug = false;
// Constructor
ESP32QRCodeReader();
ESP32QRCodeReader(CameraPins pins);
ESP32QRCodeReader(CameraPins pins, framesize_t frameSize);
ESP32QRCodeReader(framesize_t frameSize);
~ESP32QRCodeReader();
// Setup camera
QRCodeReaderSetupErr setup();
void begin();
void beginOnCore(BaseType_t core);
bool receiveQrCode(struct QRCodeData *qrCodeData, long timeoutMs);
void end();
void setDebug(bool);
};
#endif // ESP32_QR_CODE_ARDUINO_H_
+22
View File
@@ -0,0 +1,22 @@
{
"name": "ESP32QRCodeReader",
"version": "1.1.0",
"description": "A library to read QR Codes using an ESP32 with a camera module",
"keywords": "qrcode, esp32, camera",
"repository": {
"type": "git",
"url": "https://github.com/alvarowolfx/ESP32QRCodeReader"
},
"authors": [
{
"name": "Alvaro Viebrantz",
"email": "alvarowolfx@gmail.com",
"url": "https://github.com/alvarowolfx",
"maintainer": true
}
],
"license": "MIT",
"homepage": "https://github.com/alvarowolfx/ESP32QRCodeReader",
"frameworks": "arduino",
"platforms": "espressif32"
}
@@ -0,0 +1,9 @@
name=ESP32QRCodeReader
version=1.1.0
author=Alvaro Viebrantz <alvarowolfx@gmail.com>
maintainer=Alvaro Viebrantz <alvarowolfx@gmail.com>
sentence=A library to read QR Codes using an ESP32 with a camera module.
paragraph=A library to read QR Codes using an ESP32 with a camera module
category=Data Processing
url=https://github.com/alvarowolfx/ESP32QRCodeReader
architectures=*
@@ -0,0 +1,290 @@
#include "ESP32QRCodeReader.h"
#include "quirc/quirc.h"
#include "Arduino.h"
ESP32QRCodeReader::ESP32QRCodeReader() : ESP32QRCodeReader(CAMERA_MODEL_AI_THINKER, FRAMESIZE_QVGA)
{
}
ESP32QRCodeReader::ESP32QRCodeReader(framesize_t frameSize) : ESP32QRCodeReader(CAMERA_MODEL_AI_THINKER, frameSize)
{
}
ESP32QRCodeReader::ESP32QRCodeReader(CameraPins pins) : ESP32QRCodeReader(pins, FRAMESIZE_QVGA)
{
}
ESP32QRCodeReader::ESP32QRCodeReader(CameraPins pins, framesize_t frameSize) : pins(pins), frameSize(frameSize)
{
qrCodeQueue = xQueueCreate(10, sizeof(struct QRCodeData));
}
ESP32QRCodeReader::~ESP32QRCodeReader()
{
end();
}
QRCodeReaderSetupErr ESP32QRCodeReader::setup()
{
if (!psramFound())
{
return SETUP_NO_PSRAM_ERROR;
}
cameraConfig.ledc_channel = LEDC_CHANNEL_0;
cameraConfig.ledc_timer = LEDC_TIMER_0;
cameraConfig.pin_d0 = pins.Y2_GPIO_NUM;
cameraConfig.pin_d1 = pins.Y3_GPIO_NUM;
cameraConfig.pin_d2 = pins.Y4_GPIO_NUM;
cameraConfig.pin_d3 = pins.Y5_GPIO_NUM;
cameraConfig.pin_d4 = pins.Y6_GPIO_NUM;
cameraConfig.pin_d5 = pins.Y7_GPIO_NUM;
cameraConfig.pin_d6 = pins.Y8_GPIO_NUM;
cameraConfig.pin_d7 = pins.Y9_GPIO_NUM;
cameraConfig.pin_xclk = pins.XCLK_GPIO_NUM;
cameraConfig.pin_pclk = pins.PCLK_GPIO_NUM;
cameraConfig.pin_vsync = pins.VSYNC_GPIO_NUM;
cameraConfig.pin_href = pins.HREF_GPIO_NUM;
cameraConfig.pin_sscb_sda = pins.SIOD_GPIO_NUM;
cameraConfig.pin_sscb_scl = pins.SIOC_GPIO_NUM;
cameraConfig.pin_pwdn = pins.PWDN_GPIO_NUM;
cameraConfig.pin_reset = pins.RESET_GPIO_NUM;
cameraConfig.xclk_freq_hz = 10000000;
cameraConfig.pixel_format = PIXFORMAT_GRAYSCALE;
//cameraConfig.frame_size = FRAMESIZE_VGA;
cameraConfig.frame_size = frameSize;
cameraConfig.jpeg_quality = 15;
cameraConfig.fb_count = 1;
#if defined(CAMERA_MODEL_ESP_EYE)
pinMode(13, INPUT_PULLUP);
pinMode(14, INPUT_PULLUP);
#endif
// camera init
esp_err_t err = esp_camera_init(&cameraConfig);
if (err != ESP_OK)
{
return SETUP_CAMERA_INIT_ERROR;
}
return SETUP_OK;
}
void dumpData(const struct quirc_data *data)
{
Serial.printf("Version: %d\n", data->version);
Serial.printf("ECC level: %c\n", "MLHQ"[data->ecc_level]);
Serial.printf("Mask: %d\n", data->mask);
Serial.printf("Length: %d\n", data->payload_len);
Serial.printf("Payload: %s\n", data->payload);
}
void qrCodeDetectTask(void *taskData)
{
ESP32QRCodeReader *self = (ESP32QRCodeReader *)taskData;
camera_config_t camera_config = self->cameraConfig;
if (camera_config.frame_size > FRAMESIZE_SVGA)
{
if (self->debug)
{
Serial.println("Camera Size err");
}
vTaskDelete(NULL);
return;
}
struct quirc *q = NULL;
uint8_t *image = NULL;
camera_fb_t *fb = NULL;
uint16_t old_width = 0;
uint16_t old_height = 0;
if (self->debug)
{
Serial.printf("begin to qr_recoginze\r\n");
}
q = quirc_new();
if (q == NULL)
{
if (self->debug)
{
Serial.print("can't create quirc object\r\n");
}
vTaskDelete(NULL);
return;
}
while (true)
{
if (self->debug)
{
Serial.printf("alloc qr heap: %u\r\n", xPortGetFreeHeapSize());
Serial.printf("uxHighWaterMark = %d\r\n", uxTaskGetStackHighWaterMark(NULL));
Serial.print("begin camera get fb\r\n");
}
vTaskDelay(100 / portTICK_PERIOD_MS);
fb = esp_camera_fb_get();
if (!fb)
{
if (self->debug)
{
Serial.println("Camera capture failed");
}
continue;
}
if (old_width != fb->width || old_height != fb->height)
{
if (self->debug)
{
Serial.printf("Recognizer size change w h len: %d, %d, %d \r\n", fb->width, fb->height, fb->len);
Serial.println("Resize the QR-code recognizer.");
// Resize the QR-code recognizer.
}
if (quirc_resize(q, fb->width, fb->height) < 0)
{
if (self->debug)
{
Serial.println("Resize the QR-code recognizer err (cannot allocate memory).");
}
esp_camera_fb_return(fb);
fb = NULL;
image = NULL;
continue;
}
else
{
old_width = fb->width;
old_height = fb->height;
}
}
// Serial.printf("quirc_begin\r\n");
image = quirc_begin(q, NULL, NULL);
if (self->debug)
{
Serial.printf("Frame w h len: %d, %d, %d \r\n", fb->width, fb->height, fb->len);
}
memcpy(image, fb->buf, fb->len);
quirc_end(q);
if (self->debug)
{
Serial.printf("quirc_end\r\n");
}
int count = quirc_count(q);
if (count == 0)
{
if (self->debug)
{
Serial.printf("Error: not a valid qrcode\n");
}
esp_camera_fb_return(fb);
fb = NULL;
image = NULL;
continue;
}
for (int i = 0; i < count; i++)
{
struct quirc_code code;
struct quirc_data data;
quirc_decode_error_t err;
quirc_extract(q, i, &code);
err = quirc_decode(&code, &data);
struct QRCodeData qrCodeData;
if (err)
{
const char *error = quirc_strerror(err);
int len = strlen(error);
if (self->debug)
{
Serial.printf("Decoding FAILED: %s\n", error);
}
for (int i = 0; i < len; i++)
{
qrCodeData.payload[i] = error[i];
}
qrCodeData.valid = false;
qrCodeData.payload[len] = '\0';
qrCodeData.payloadLen = len;
}
else
{
if (self->debug)
{
Serial.printf("Decoding successful:\n");
dumpData(&data);
}
qrCodeData.dataType = data.data_type;
for (int i = 0; i < data.payload_len; i++)
{
qrCodeData.payload[i] = data.payload[i];
}
qrCodeData.valid = true;
qrCodeData.payload[data.payload_len] = '\0';
qrCodeData.payloadLen = data.payload_len;
}
xQueueSend(self->qrCodeQueue, &qrCodeData, (TickType_t)0);
if (self->debug)
{
Serial.println();
}
}
//Serial.printf("finish recoginize\r\n");
esp_camera_fb_return(fb);
fb = NULL;
image = NULL;
}
quirc_destroy(q);
vTaskDelete(NULL);
}
void ESP32QRCodeReader::begin()
{
beginOnCore(0);
}
void ESP32QRCodeReader::beginOnCore(BaseType_t core)
{
if (!begun)
{
xTaskCreatePinnedToCore(qrCodeDetectTask, "qrCodeDetectTask", QR_CODE_READER_STACK_SIZE, this, QR_CODE_READER_TASK_PRIORITY, &qrCodeTaskHandler, core);
begun = true;
}
}
bool ESP32QRCodeReader::receiveQrCode(struct QRCodeData *qrCodeData, long timeoutMs)
{
return xQueueReceive(qrCodeQueue, qrCodeData, (TickType_t)pdMS_TO_TICKS(timeoutMs)) != 0;
}
void ESP32QRCodeReader::end()
{
if (begun)
{
TaskHandle_t tmpTask = qrCodeTaskHandler;
if (qrCodeTaskHandler != NULL)
{
qrCodeTaskHandler = NULL;
vTaskDelete(tmpTask);
}
}
begun = false;
}
void ESP32QRCodeReader::setDebug(bool on)
{
debug = on;
}
@@ -0,0 +1,85 @@
/* This file is part of the OpenMV project.
* Copyright (c) 2013-2017 Ibrahim Abdelkader <iabdalkader@openmv.io> & Kwabena W. Agyeman <kwagyeman@openmv.io>
* This work is licensed under the MIT license, see the file LICENSE for details.
*/
#include "collections.h"
#define CHAR_BITS (sizeof(char) * 8)
#define CHAR_MASK (CHAR_BITS - 1)
#define CHAR_SHIFT IM_LOG2(CHAR_MASK)
//////////
// lifo //
//////////
void lifo_alloc(lifo_t *ptr, size_t size, size_t data_len)
{
ptr->len = 0;
ptr->size = size;
ptr->data_len = data_len;
ptr->data = (char *)ps_malloc(size * data_len);
}
void lifo_alloc_all(lifo_t *ptr, size_t *size, size_t data_len)
{
ptr->data = (char *)ps_malloc(255);
ptr->data_len = data_len;
ptr->size = 255 / data_len;
ptr->len = 0;
*size = ptr->size;
}
void lifo_free(lifo_t *ptr)
{
if (ptr->data)
{
free(ptr->data);
}
}
void lifo_clear(lifo_t *ptr)
{
ptr->len = 0;
}
size_t lifo_size(lifo_t *ptr)
{
return ptr->len;
}
bool lifo_is_not_empty(lifo_t *ptr)
{
return ptr->len;
}
bool lifo_is_not_full(lifo_t *ptr)
{
return ptr->len != ptr->size;
}
void lifo_enqueue(lifo_t *ptr, void *data)
{
memcpy(ptr->data + (ptr->len * ptr->data_len), data, ptr->data_len);
ptr->len += 1;
}
void lifo_dequeue(lifo_t *ptr, void *data)
{
if (data)
{
memcpy(data, ptr->data + ((ptr->len - 1) * ptr->data_len), ptr->data_len);
}
ptr->len -= 1;
}
void lifo_poke(lifo_t *ptr, void *data)
{
memcpy(ptr->data + (ptr->len * ptr->data_len), data, ptr->data_len);
}
void lifo_peek(lifo_t *ptr, void *data)
{
memcpy(data, ptr->data + ((ptr->len - 1) * ptr->data_len), ptr->data_len);
}
@@ -0,0 +1,33 @@
/* This file is part of the OpenMV project.
* Copyright (c) 2013-2017 Ibrahim Abdelkader <iabdalkader@openmv.io> & Kwabena W. Agyeman <kwagyeman@openmv.io>
* This work is licensed under the MIT license, see the file LICENSE for details.
*/
#ifndef __COLLECTIONS_H__
#define __COLLECTIONS_H__
#include <stdbool.h>
#include <stddef.h>
//////////
// lifo //
//////////
typedef struct lifo
{
size_t len, size, data_len;
char *data;
}
__attribute__((aligned(8))) lifo_t;
void lifo_alloc(lifo_t *ptr, size_t size, size_t data_len);
void lifo_alloc_all(lifo_t *ptr, size_t *size, size_t data_len);
void lifo_free(lifo_t *ptr);
void lifo_clear(lifo_t *ptr);
size_t lifo_size(lifo_t *ptr);
bool lifo_is_not_empty(lifo_t *ptr);
bool lifo_is_not_full(lifo_t *ptr);
void lifo_enqueue(lifo_t *ptr, void *data);
void lifo_dequeue(lifo_t *ptr, void *data);
void lifo_poke(lifo_t *ptr, void *data);
void lifo_peek(lifo_t *ptr, void *data);
#endif /* __COLLECTIONS_H__ */
@@ -0,0 +1,70 @@
/*
* This file is part of the OpenMV project.
* Copyright (c) 2013/2014 Ibrahim Abdelkader <i.abdalkader@gmail.com>
* This work is licensed under the MIT license, see the file LICENSE for details.
*
* Fast approximate math functions.
*
*/
#ifndef __FMATH_H
#define __FMATH_H
#include <stdint.h>
#include <math.h>
static inline float fast_sqrtf(float x)
{
//return sqrtf(x);
asm("fsqrt.s %0, %1"
: "=f"(x)
: "f"(x));
return x;
}
static inline int fast_floorf(float x)
{
return (int)(x);
}
static inline int fast_ceilf(float x)
{
return (int)(x + 0.9999f);
}
static inline int fast_roundf(float x)
{
return (int)(x);
}
static inline float fast_fabsf(float d)
{
return fabsf(d);
}
extern int fast_floorf(float x);
extern int fast_ceilf(float x);
extern int fast_roundf(float x);
extern float fast_atanf(float x);
extern float fast_atan2f(float y, float x);
extern float fast_expf(float x);
extern float fast_cbrtf(float d);
extern float fast_fabsf(float d);
extern float fast_log(float x);
extern float fast_log2(float x);
extern float fast_powf(float a, float b);
/*#define fast_sqrtf(x) (sqrtf(x))
#define fast_floorf(x) ((int)floorf(x))
#define fast_ceilf(x) ((int)ceilf(x))
#define fast_roundf(x) ((int)roundf(x))
#define fast_atanf(x) (atanf(x))
#define fast_atan2f(x,y) (atan2f((x),(y)))
#define fast_expf(x) (expf(x))
#define fast_cbrtf(x) (cbrtf(x))
#define fast_fabsf(x) (fabsf(x))
#define fast_log(x) (log(x))
#define fast_log2(x) (log2(x))
#define fast_powf(x,y) (powf((x),(y)))
*/
extern const float cos_table[360];
extern const float sin_table[360];
#endif // __FMATH_H
@@ -0,0 +1,16 @@
quirc -- QR-code recognition library
Copyright (C) 2010-2012 Daniel Beer <dlbeer@gmail.com>
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted, provided that the
above copyright notice and this permission notice appear in all
copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL
DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR
PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,193 @@
Quirc
=====
QR codes are a type of high-density matrix barcodes, and quirc is a library for
extracting and decoding them from images. It has several features which make it
a good choice for this purpose:
* It is fast enough to be used with realtime video: extracting and decoding
from VGA frame takes about 50 ms on a modern x86 core.
* It has a robust and tolerant recognition algorithm. It can correctly
recognise and decode QR codes which are rotated and/or oblique to the camera.
It can also distinguish and decode multiple codes within the same image.
* It is easy to use, with a simple API described in a single commented header
file (see below for an overview).
* It is small and easily embeddable, with no dependencies other than standard C
functions.
* It has a very small memory footprint: one byte per image pixel, plus a few kB
per decoder object.
* It uses no global mutable state, and is safe to use in a multithreaded
application.
* BSD-licensed, with almost no restrictions regarding use and/or modification.
The distribution comes with, in addition to the library, several test programs.
While the core library is very portable, these programs have some additional
dependencies. All of them require libjpeg, and two (`quirc-demo` and `inspect`)
require SDL. The camera demos use Linux-specific APIs:
### quirc-demo
This is an real-time demo which requires a camera and a graphical display. The
video stream is displayed on screen as it's received, and any QR codes
recognised are highlighted in the image, with the decoded information both
displayed on the image and printed on stdout.
### quirc-scanner
This program turns your camera into a barcode scanner. It's almost the same as
the `demo` application, but it doesn't display the video stream, and thus
doesn't require a graphical display.
### qrtest
This test is used to evaluate the performance of library. Given a directory
tree containing a bunch of JPEG images, it will attempt to locate and decode QR
codes in each image. Speed and success statistics are collected and printed on
stdout.
### inspect
This test is used for debugging. Given a single JPEG image, it will display a
diagram showing the internal state of the decoder as well as printing
additional information on stdout.
Installation
------------
To build the library and associated demos/tests, type `make`. If you need to
decode "large" image files build with `CFLAGS="-DQUIRC_MAX_REGIONS=65534" make`
instead. Note that this will increase the memory usage, it is discouraged for
low resource devices (i.e. embedded).
Type `make install` to install the library, header file and camera demos.
You can specify one or several of the following targets if you don't want, or
are unable to build everything:
* libquirc.a
* libquirc.so
* qrtest
* inspect
* quirc-scanner
* quirc-demo
Library use
-----------
All of the library's functionality is exposed through a single header file,
which you should include:
```C
#include <quirc.h>
```
To decode images, you'll need to instantiate a `struct quirc` object, which is
done with the `quirc_new` function. Later, when you no longer need to decode
anything, you should release the allocated memory with `quirc_destroy`:
```C
struct quirc *qr;
qr = quirc_new();
if (!qr) {
perror("Failed to allocate memory");
abort();
}
/* ... */
quirc_destroy(qr);
```
Having obtained a decoder object, you need to set the image size that you'll be
working with, which is done using `quirc_resize`:
```C
if (quirc_resize(qr, 640, 480) < 0) {
perror("Failed to allocate video memory");
abort();
}
```
`quirc_resize` and `quirc_new` are the only library functions which allocate
memory. If you plan to process a series of frames (or a video stream), you
probably want to allocate and size a single decoder and hold onto it to process
each frame.
Processing frames is done in two stages. The first stage is an
image-recognition stage called identification, which takes a grayscale image
and searches for QR codes. Using `quirc_begin` and `quirc_end`, you can feed a
grayscale image directly into the buffer that `quirc` uses for image
processing:
```C
uint8_t *image;
int w, h;
image = quirc_begin(qr, &w, &h);
/* Fill out the image buffer here.
* image is a pointer to a w*h bytes.
* One byte per pixel, w pixels per line, h lines in the buffer.
*/
quirc_end(qr);
```
Note that `quirc_begin` simply returns a pointer to a previously allocated
buffer. The buffer will contain uninitialized data. After the call to
`quirc_end`, the decoder holds a list of detected QR codes which can be queried
via `quirc_count` and `quirc_extract`.
At this point, the second stage of processing occurs -- decoding. This is done
via the call to `quirc_decode`, which is not associated with a decoder object.
```C
int num_codes;
int i;
/* We've previously fed an image to the decoder via
* quirc_begin/quirc_end.
*/
num_codes = quirc_count(qr);
for (i = 0; i < num_codes; i++) {
struct quirc_code code;
struct quirc_data data;
quirc_decode_error_t err;
quirc_extract(qr, i, &code);
/* Decoding stage */
err = quirc_decode(&code, &data);
if (err)
printf("DECODE FAILED: %s\n", quirc_strerror(err));
else
printf("Data: %s\n", data.payload);
}
```
`quirc_code` and `quirc_data` are flat structures which don't need to be
initialized or freed after use.
Copyright
---------
Copyright (C) 2010-2012 Daniel Beer <<dlbeer@gmail.com>>
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted, provided that the
above copyright notice and this permission notice appear in all
copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL
DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR
PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1,983 @@
/* quirc -- QR-code recognition library
* Copyright (C) 2010-2012 Daniel Beer <dlbeer@gmail.com>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#include "quirc_internal.h"
#include <string.h>
#include <stdlib.h>
#define MAX_POLY 64
/************************************************************************
* Galois fields
*/
struct galois_field
{
int p;
const uint8_t *log;
const uint8_t *exp;
} __attribute__((aligned(8)));
static const uint8_t gf16_exp[16] = {
0x01, 0x02, 0x04, 0x08, 0x03, 0x06, 0x0c, 0x0b,
0x05, 0x0a, 0x07, 0x0e, 0x0f, 0x0d, 0x09, 0x01};
static const uint8_t gf16_log[16] = {
0x00, 0x0f, 0x01, 0x04, 0x02, 0x08, 0x05, 0x0a,
0x03, 0x0e, 0x09, 0x07, 0x06, 0x0d, 0x0b, 0x0c};
static const struct galois_field gf16 = {
.p = 15,
.log = gf16_log,
.exp = gf16_exp};
static const uint8_t gf256_exp[256] = {
0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80,
0x1d, 0x3a, 0x74, 0xe8, 0xcd, 0x87, 0x13, 0x26,
0x4c, 0x98, 0x2d, 0x5a, 0xb4, 0x75, 0xea, 0xc9,
0x8f, 0x03, 0x06, 0x0c, 0x18, 0x30, 0x60, 0xc0,
0x9d, 0x27, 0x4e, 0x9c, 0x25, 0x4a, 0x94, 0x35,
0x6a, 0xd4, 0xb5, 0x77, 0xee, 0xc1, 0x9f, 0x23,
0x46, 0x8c, 0x05, 0x0a, 0x14, 0x28, 0x50, 0xa0,
0x5d, 0xba, 0x69, 0xd2, 0xb9, 0x6f, 0xde, 0xa1,
0x5f, 0xbe, 0x61, 0xc2, 0x99, 0x2f, 0x5e, 0xbc,
0x65, 0xca, 0x89, 0x0f, 0x1e, 0x3c, 0x78, 0xf0,
0xfd, 0xe7, 0xd3, 0xbb, 0x6b, 0xd6, 0xb1, 0x7f,
0xfe, 0xe1, 0xdf, 0xa3, 0x5b, 0xb6, 0x71, 0xe2,
0xd9, 0xaf, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88,
0x0d, 0x1a, 0x34, 0x68, 0xd0, 0xbd, 0x67, 0xce,
0x81, 0x1f, 0x3e, 0x7c, 0xf8, 0xed, 0xc7, 0x93,
0x3b, 0x76, 0xec, 0xc5, 0x97, 0x33, 0x66, 0xcc,
0x85, 0x17, 0x2e, 0x5c, 0xb8, 0x6d, 0xda, 0xa9,
0x4f, 0x9e, 0x21, 0x42, 0x84, 0x15, 0x2a, 0x54,
0xa8, 0x4d, 0x9a, 0x29, 0x52, 0xa4, 0x55, 0xaa,
0x49, 0x92, 0x39, 0x72, 0xe4, 0xd5, 0xb7, 0x73,
0xe6, 0xd1, 0xbf, 0x63, 0xc6, 0x91, 0x3f, 0x7e,
0xfc, 0xe5, 0xd7, 0xb3, 0x7b, 0xf6, 0xf1, 0xff,
0xe3, 0xdb, 0xab, 0x4b, 0x96, 0x31, 0x62, 0xc4,
0x95, 0x37, 0x6e, 0xdc, 0xa5, 0x57, 0xae, 0x41,
0x82, 0x19, 0x32, 0x64, 0xc8, 0x8d, 0x07, 0x0e,
0x1c, 0x38, 0x70, 0xe0, 0xdd, 0xa7, 0x53, 0xa6,
0x51, 0xa2, 0x59, 0xb2, 0x79, 0xf2, 0xf9, 0xef,
0xc3, 0x9b, 0x2b, 0x56, 0xac, 0x45, 0x8a, 0x09,
0x12, 0x24, 0x48, 0x90, 0x3d, 0x7a, 0xf4, 0xf5,
0xf7, 0xf3, 0xfb, 0xeb, 0xcb, 0x8b, 0x0b, 0x16,
0x2c, 0x58, 0xb0, 0x7d, 0xfa, 0xe9, 0xcf, 0x83,
0x1b, 0x36, 0x6c, 0xd8, 0xad, 0x47, 0x8e, 0x01};
static const uint8_t gf256_log[256] = {
0x00, 0xff, 0x01, 0x19, 0x02, 0x32, 0x1a, 0xc6,
0x03, 0xdf, 0x33, 0xee, 0x1b, 0x68, 0xc7, 0x4b,
0x04, 0x64, 0xe0, 0x0e, 0x34, 0x8d, 0xef, 0x81,
0x1c, 0xc1, 0x69, 0xf8, 0xc8, 0x08, 0x4c, 0x71,
0x05, 0x8a, 0x65, 0x2f, 0xe1, 0x24, 0x0f, 0x21,
0x35, 0x93, 0x8e, 0xda, 0xf0, 0x12, 0x82, 0x45,
0x1d, 0xb5, 0xc2, 0x7d, 0x6a, 0x27, 0xf9, 0xb9,
0xc9, 0x9a, 0x09, 0x78, 0x4d, 0xe4, 0x72, 0xa6,
0x06, 0xbf, 0x8b, 0x62, 0x66, 0xdd, 0x30, 0xfd,
0xe2, 0x98, 0x25, 0xb3, 0x10, 0x91, 0x22, 0x88,
0x36, 0xd0, 0x94, 0xce, 0x8f, 0x96, 0xdb, 0xbd,
0xf1, 0xd2, 0x13, 0x5c, 0x83, 0x38, 0x46, 0x40,
0x1e, 0x42, 0xb6, 0xa3, 0xc3, 0x48, 0x7e, 0x6e,
0x6b, 0x3a, 0x28, 0x54, 0xfa, 0x85, 0xba, 0x3d,
0xca, 0x5e, 0x9b, 0x9f, 0x0a, 0x15, 0x79, 0x2b,
0x4e, 0xd4, 0xe5, 0xac, 0x73, 0xf3, 0xa7, 0x57,
0x07, 0x70, 0xc0, 0xf7, 0x8c, 0x80, 0x63, 0x0d,
0x67, 0x4a, 0xde, 0xed, 0x31, 0xc5, 0xfe, 0x18,
0xe3, 0xa5, 0x99, 0x77, 0x26, 0xb8, 0xb4, 0x7c,
0x11, 0x44, 0x92, 0xd9, 0x23, 0x20, 0x89, 0x2e,
0x37, 0x3f, 0xd1, 0x5b, 0x95, 0xbc, 0xcf, 0xcd,
0x90, 0x87, 0x97, 0xb2, 0xdc, 0xfc, 0xbe, 0x61,
0xf2, 0x56, 0xd3, 0xab, 0x14, 0x2a, 0x5d, 0x9e,
0x84, 0x3c, 0x39, 0x53, 0x47, 0x6d, 0x41, 0xa2,
0x1f, 0x2d, 0x43, 0xd8, 0xb7, 0x7b, 0xa4, 0x76,
0xc4, 0x17, 0x49, 0xec, 0x7f, 0x0c, 0x6f, 0xf6,
0x6c, 0xa1, 0x3b, 0x52, 0x29, 0x9d, 0x55, 0xaa,
0xfb, 0x60, 0x86, 0xb1, 0xbb, 0xcc, 0x3e, 0x5a,
0xcb, 0x59, 0x5f, 0xb0, 0x9c, 0xa9, 0xa0, 0x51,
0x0b, 0xf5, 0x16, 0xeb, 0x7a, 0x75, 0x2c, 0xd7,
0x4f, 0xae, 0xd5, 0xe9, 0xe6, 0xe7, 0xad, 0xe8,
0x74, 0xd6, 0xf4, 0xea, 0xa8, 0x50, 0x58, 0xaf};
const static struct galois_field gf256 = {
.p = 255,
.log = gf256_log,
.exp = gf256_exp};
/************************************************************************
* Polynomial operations
*/
static void poly_add(uint8_t *dst, const uint8_t *src, uint8_t c,
int shift, const struct galois_field *gf)
{
int i;
int log_c = gf->log[c];
if (!c)
return;
for (i = 0; i < MAX_POLY; i++)
{
int p = i + shift;
uint8_t v = src[i];
if (p < 0 || p >= MAX_POLY)
continue;
if (!v)
continue;
dst[p] ^= gf->exp[(gf->log[v] + log_c) % gf->p];
}
}
static uint8_t poly_eval(const uint8_t *s, uint8_t x,
const struct galois_field *gf)
{
int i;
uint8_t sum = 0;
uint8_t log_x = gf->log[x];
if (!x)
return s[0];
for (i = 0; i < MAX_POLY; i++)
{
uint8_t c = s[i];
if (!c)
continue;
sum ^= gf->exp[(gf->log[c] + log_x * i) % gf->p];
}
return sum;
}
/************************************************************************
* Berlekamp-Massey algorithm for finding error locator polynomials.
*/
static void berlekamp_massey(const uint8_t *s, int N,
const struct galois_field *gf,
uint8_t *sigma)
{
uint8_t C[MAX_POLY];
uint8_t B[MAX_POLY];
int L = 0;
int m = 1;
uint8_t b = 1;
int n;
memset(B, 0, sizeof(B));
memset(C, 0, sizeof(C));
B[0] = 1;
C[0] = 1;
for (n = 0; n < N; n++)
{
uint8_t d = s[n];
uint8_t mult;
int i;
for (i = 1; i <= L; i++)
{
if (!(C[i] && s[n - i]))
continue;
d ^= gf->exp[(gf->log[C[i]] +
gf->log[s[n - i]]) %
gf->p];
}
mult = gf->exp[(gf->p - gf->log[b] + gf->log[d]) % gf->p];
if (!d)
{
m++;
}
else if (L * 2 <= n)
{
uint8_t T[MAX_POLY];
memcpy(T, C, sizeof(T));
poly_add(C, B, mult, m, gf);
memcpy(B, T, sizeof(B));
L = n + 1 - L;
b = d;
m = 1;
}
else
{
poly_add(C, B, mult, m, gf);
m++;
}
}
memcpy(sigma, C, MAX_POLY);
}
/************************************************************************
* Code stream error correction
*
* Generator polynomial for GF(2^8) is x^8 + x^4 + x^3 + x^2 + 1
*/
static int block_syndromes(const uint8_t *data, int bs, int npar, uint8_t *s)
{
int nonzero = 0;
int i;
memset(s, 0, MAX_POLY);
for (i = 0; i < npar; i++)
{
int j;
for (j = 0; j < bs; j++)
{
uint8_t c = data[bs - j - 1];
if (!c)
continue;
s[i] ^= gf256_exp[((int)gf256_log[c] +
i * j) %
255];
}
if (s[i])
nonzero = 1;
}
return nonzero;
}
static void eloc_poly(uint8_t *omega,
const uint8_t *s, const uint8_t *sigma,
int npar)
{
int i;
memset(omega, 0, MAX_POLY);
for (i = 0; i < npar; i++)
{
const uint8_t a = sigma[i];
const uint8_t log_a = gf256_log[a];
int j;
if (!a)
continue;
for (j = 0; j + 1 < MAX_POLY; j++)
{
const uint8_t b = s[j + 1];
if (i + j >= npar)
break;
if (!b)
continue;
omega[i + j] ^=
gf256_exp[(log_a + gf256_log[b]) % 255];
}
}
}
static quirc_decode_error_t correct_block(uint8_t *data,
const struct quirc_rs_params *ecc)
{
int npar = ecc->bs - ecc->dw;
uint8_t s[MAX_POLY];
uint8_t sigma[MAX_POLY];
uint8_t sigma_deriv[MAX_POLY];
uint8_t omega[MAX_POLY];
int i;
/* Compute syndrome vector */
if (!block_syndromes(data, ecc->bs, npar, s))
return QUIRC_SUCCESS;
berlekamp_massey(s, npar, &gf256, sigma);
/* Compute derivative of sigma */
memset(sigma_deriv, 0, MAX_POLY);
for (i = 0; i + 1 < MAX_POLY; i += 2)
sigma_deriv[i] = sigma[i + 1];
/* Compute error evaluator polynomial */
eloc_poly(omega, s, sigma, npar - 1);
/* Find error locations and magnitudes */
for (i = 0; i < ecc->bs; i++)
{
uint8_t xinv = gf256_exp[255 - i];
if (!poly_eval(sigma, xinv, &gf256))
{
uint8_t sd_x = poly_eval(sigma_deriv, xinv, &gf256);
uint8_t omega_x = poly_eval(omega, xinv, &gf256);
uint8_t error = gf256_exp[(255 - gf256_log[sd_x] +
gf256_log[omega_x]) %
255];
data[ecc->bs - i - 1] ^= error;
}
}
if (block_syndromes(data, ecc->bs, npar, s))
return QUIRC_ERROR_DATA_ECC;
return QUIRC_SUCCESS;
}
/************************************************************************
* Format value error correction
*
* Generator polynomial for GF(2^4) is x^4 + x + 1
*/
#define FORMAT_MAX_ERROR 3
#define FORMAT_SYNDROMES (FORMAT_MAX_ERROR * 2)
#define FORMAT_BITS 15
static int format_syndromes(uint16_t u, uint8_t *s)
{
int i;
int nonzero = 0;
memset(s, 0, MAX_POLY);
for (i = 0; i < FORMAT_SYNDROMES; i++)
{
int j;
s[i] = 0;
for (j = 0; j < FORMAT_BITS; j++)
if (u & (1 << j))
s[i] ^= gf16_exp[((i + 1) * j) % 15];
if (s[i])
nonzero = 1;
}
return nonzero;
}
static quirc_decode_error_t correct_format(uint16_t *f_ret)
{
uint16_t u = *f_ret;
int i;
uint8_t s[MAX_POLY];
uint8_t sigma[MAX_POLY];
/* Evaluate U (received codeword) at each of alpha_1 .. alpha_6
* to get S_1 .. S_6 (but we index them from 0).
*/
if (!format_syndromes(u, s))
return QUIRC_SUCCESS;
berlekamp_massey(s, FORMAT_SYNDROMES, &gf16, sigma);
/* Now, find the roots of the polynomial */
for (i = 0; i < 15; i++)
if (!poly_eval(sigma, gf16_exp[15 - i], &gf16))
u ^= (1 << i);
if (format_syndromes(u, s))
return QUIRC_ERROR_FORMAT_ECC;
*f_ret = u;
return QUIRC_SUCCESS;
}
/************************************************************************
* Decoder algorithm
*/
struct datastream
{
uint8_t raw[QUIRC_MAX_PAYLOAD];
int data_bits;
int ptr;
uint8_t data[QUIRC_MAX_PAYLOAD];
} __attribute__((aligned(8)));
static inline int grid_bit(const struct quirc_code *code, int x, int y)
{
int p = y * code->size + x;
return (code->cell_bitmap[p >> 3] >> (p & 7)) & 1;
}
static quirc_decode_error_t read_format(const struct quirc_code *code,
struct quirc_data *data, int which)
{
int i;
uint16_t format = 0;
uint16_t fdata;
quirc_decode_error_t err;
if (which)
{
for (i = 0; i < 7; i++)
format = (format << 1) |
grid_bit(code, 8, code->size - 1 - i);
for (i = 0; i < 8; i++)
format = (format << 1) |
grid_bit(code, code->size - 8 + i, 8);
}
else
{
static const int xs[15] = {
8, 8, 8, 8, 8, 8, 8, 8, 7, 5, 4, 3, 2, 1, 0};
static const int ys[15] = {
0, 1, 2, 3, 4, 5, 7, 8, 8, 8, 8, 8, 8, 8, 8};
for (i = 14; i >= 0; i--)
format = (format << 1) | grid_bit(code, xs[i], ys[i]);
}
format ^= 0x5412;
err = correct_format(&format);
if (err)
return err;
fdata = format >> 10;
data->ecc_level = fdata >> 3;
data->mask = fdata & 7;
return QUIRC_SUCCESS;
}
static int mask_bit(int mask, int i, int j)
{
switch (mask)
{
case 0:
return !((i + j) % 2);
case 1:
return !(i % 2);
case 2:
return !(j % 3);
case 3:
return !((i + j) % 3);
case 4:
return !(((i / 2) + (j / 3)) % 2);
case 5:
return !((i * j) % 2 + (i * j) % 3);
case 6:
return !(((i * j) % 2 + (i * j) % 3) % 2);
case 7:
return !(((i * j) % 3 + (i + j) % 2) % 2);
}
return 0;
}
static int reserved_cell(int version, int i, int j)
{
const struct quirc_version_info *ver = &quirc_version_db[version];
int size = version * 4 + 17;
int ai = -1, aj = -1, a;
/* Finder + format: top left */
if (i < 9 && j < 9)
return 1;
/* Finder + format: bottom left */
if (i + 8 >= size && j < 9)
return 1;
/* Finder + format: top right */
if (i < 9 && j + 8 >= size)
return 1;
/* Exclude timing patterns */
if (i == 6 || j == 6)
return 1;
/* Exclude version info, if it exists. Version info sits adjacent to
* the top-right and bottom-left finders in three rows, bounded by
* the timing pattern.
*/
if (version >= 7)
{
if (i < 6 && j + 11 >= size)
return 1;
if (i + 11 >= size && j < 6)
return 1;
}
/* Exclude alignment patterns */
for (a = 0; a < QUIRC_MAX_ALIGNMENT && ver->apat[a]; a++)
{
int p = ver->apat[a];
if (abs(p - i) < 3)
ai = a;
if (abs(p - j) < 3)
aj = a;
}
if (ai >= 0 && aj >= 0)
{
a--;
if (ai > 0 && ai < a)
return 1;
if (aj > 0 && aj < a)
return 1;
if (aj == a && ai == a)
return 1;
}
return 0;
}
static void read_bit(const struct quirc_code *code,
struct quirc_data *data,
struct datastream *ds, int i, int j)
{
int bitpos = ds->data_bits & 7;
int bytepos = ds->data_bits >> 3;
int v = grid_bit(code, j, i);
if (mask_bit(data->mask, i, j))
v ^= 1;
if (v)
ds->raw[bytepos] |= (0x80 >> bitpos);
ds->data_bits++;
}
static void read_data(const struct quirc_code *code,
struct quirc_data *data,
struct datastream *ds)
{
int y = code->size - 1;
int x = code->size - 1;
int dir = -1;
while (x > 0)
{
if (x == 6)
x--;
if (!reserved_cell(data->version, y, x))
read_bit(code, data, ds, y, x);
if (!reserved_cell(data->version, y, x - 1))
read_bit(code, data, ds, y, x - 1);
y += dir;
if (y < 0 || y >= code->size)
{
dir = -dir;
x -= 2;
y += dir;
}
}
}
static quirc_decode_error_t codestream_ecc(struct quirc_data *data,
struct datastream *ds)
{
const struct quirc_version_info *ver =
&quirc_version_db[data->version];
const struct quirc_rs_params *sb_ecc = &ver->ecc[data->ecc_level];
struct quirc_rs_params lb_ecc;
const int lb_count =
(ver->data_bytes - sb_ecc->bs * sb_ecc->ns) / (sb_ecc->bs + 1);
const int bc = lb_count + sb_ecc->ns;
const int ecc_offset = sb_ecc->dw * bc + lb_count;
int dst_offset = 0;
int i;
memcpy(&lb_ecc, sb_ecc, sizeof(lb_ecc));
lb_ecc.dw++;
lb_ecc.bs++;
for (i = 0; i < bc; i++)
{
uint8_t *dst = ds->data + dst_offset;
const struct quirc_rs_params *ecc =
(i < sb_ecc->ns) ? sb_ecc : &lb_ecc;
const int num_ec = ecc->bs - ecc->dw;
quirc_decode_error_t err;
int j;
for (j = 0; j < ecc->dw; j++)
dst[j] = ds->raw[j * bc + i];
for (j = 0; j < num_ec; j++)
dst[ecc->dw + j] = ds->raw[ecc_offset + j * bc + i];
err = correct_block(dst, ecc);
if (err)
return err;
dst_offset += ecc->dw;
}
ds->data_bits = dst_offset * 8;
return QUIRC_SUCCESS;
}
static inline int bits_remaining(const struct datastream *ds)
{
return ds->data_bits - ds->ptr;
}
static int take_bits(struct datastream *ds, int len)
{
int ret = 0;
while (len && (ds->ptr < ds->data_bits))
{
uint8_t b = ds->data[ds->ptr >> 3];
int bitpos = ds->ptr & 7;
ret <<= 1;
if ((b << bitpos) & 0x80)
ret |= 1;
ds->ptr++;
len--;
}
return ret;
}
static int numeric_tuple(struct quirc_data *data,
struct datastream *ds,
int bits, int digits)
{
int tuple;
int i;
if (bits_remaining(ds) < bits)
return -1;
tuple = take_bits(ds, bits);
for (i = digits - 1; i >= 0; i--)
{
data->payload[data->payload_len + i] = tuple % 10 + '0';
tuple /= 10;
}
data->payload_len += digits;
return 0;
}
static quirc_decode_error_t decode_numeric(struct quirc_data *data,
struct datastream *ds)
{
int bits = 14;
int count;
if (data->version < 10)
bits = 10;
else if (data->version < 27)
bits = 12;
count = take_bits(ds, bits);
if (data->payload_len + count + 1 > QUIRC_MAX_PAYLOAD)
return QUIRC_ERROR_DATA_OVERFLOW;
while (count >= 3)
{
if (numeric_tuple(data, ds, 10, 3) < 0)
return QUIRC_ERROR_DATA_UNDERFLOW;
count -= 3;
}
if (count >= 2)
{
if (numeric_tuple(data, ds, 7, 2) < 0)
return QUIRC_ERROR_DATA_UNDERFLOW;
count -= 2;
}
if (count)
{
if (numeric_tuple(data, ds, 4, 1) < 0)
return QUIRC_ERROR_DATA_UNDERFLOW;
count--;
}
return QUIRC_SUCCESS;
}
static int alpha_tuple(struct quirc_data *data,
struct datastream *ds,
int bits, int digits)
{
int tuple;
int i;
if (bits_remaining(ds) < bits)
return -1;
tuple = take_bits(ds, bits);
for (i = 0; i < digits; i++)
{
static const char *alpha_map =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
data->payload[data->payload_len + digits - i - 1] =
alpha_map[tuple % 45];
tuple /= 45;
}
data->payload_len += digits;
return 0;
}
static quirc_decode_error_t decode_alpha(struct quirc_data *data,
struct datastream *ds)
{
int bits = 13;
int count;
if (data->version < 10)
bits = 9;
else if (data->version < 27)
bits = 11;
count = take_bits(ds, bits);
if (data->payload_len + count + 1 > QUIRC_MAX_PAYLOAD)
return QUIRC_ERROR_DATA_OVERFLOW;
while (count >= 2)
{
if (alpha_tuple(data, ds, 11, 2) < 0)
return QUIRC_ERROR_DATA_UNDERFLOW;
count -= 2;
}
if (count)
{
if (alpha_tuple(data, ds, 6, 1) < 0)
return QUIRC_ERROR_DATA_UNDERFLOW;
count--;
}
return QUIRC_SUCCESS;
}
static quirc_decode_error_t decode_byte(struct quirc_data *data,
struct datastream *ds)
{
int bits = 16;
int count;
int i;
if (data->version < 10)
bits = 8;
count = take_bits(ds, bits);
if (data->payload_len + count + 1 > QUIRC_MAX_PAYLOAD)
return QUIRC_ERROR_DATA_OVERFLOW;
if (bits_remaining(ds) < count * 8)
return QUIRC_ERROR_DATA_UNDERFLOW;
for (i = 0; i < count; i++)
data->payload[data->payload_len++] = take_bits(ds, 8);
return QUIRC_SUCCESS;
}
static quirc_decode_error_t decode_kanji(struct quirc_data *data,
struct datastream *ds)
{
int bits = 12;
int count;
int i;
if (data->version < 10)
bits = 8;
else if (data->version < 27)
bits = 10;
count = take_bits(ds, bits);
if (data->payload_len + count * 2 + 1 > QUIRC_MAX_PAYLOAD)
return QUIRC_ERROR_DATA_OVERFLOW;
if (bits_remaining(ds) < count * 13)
return QUIRC_ERROR_DATA_UNDERFLOW;
for (i = 0; i < count; i++)
{
int d = take_bits(ds, 13);
int msB = d / 0xc0;
int lsB = d % 0xc0;
int intermediate = (msB << 8) | lsB;
uint16_t sjw;
if (intermediate + 0x8140 <= 0x9ffc)
{
/* bytes are in the range 0x8140 to 0x9FFC */
sjw = intermediate + 0x8140;
}
else
{
/* bytes are in the range 0xE040 to 0xEBBF */
sjw = intermediate + 0xc140;
}
data->payload[data->payload_len++] = sjw >> 8;
data->payload[data->payload_len++] = sjw & 0xff;
}
return QUIRC_SUCCESS;
}
static quirc_decode_error_t decode_eci(struct quirc_data *data,
struct datastream *ds)
{
if (bits_remaining(ds) < 8)
return QUIRC_ERROR_DATA_UNDERFLOW;
data->eci = take_bits(ds, 8);
if ((data->eci & 0xc0) == 0x80)
{
if (bits_remaining(ds) < 8)
return QUIRC_ERROR_DATA_UNDERFLOW;
data->eci = (data->eci << 8) | take_bits(ds, 8);
}
else if ((data->eci & 0xe0) == 0xc0)
{
if (bits_remaining(ds) < 16)
return QUIRC_ERROR_DATA_UNDERFLOW;
data->eci = (data->eci << 16) | take_bits(ds, 16);
}
return QUIRC_SUCCESS;
}
static quirc_decode_error_t decode_payload(struct quirc_data *data,
struct datastream *ds)
{
while (bits_remaining(ds) >= 4)
{
quirc_decode_error_t err = QUIRC_SUCCESS;
int type = take_bits(ds, 4);
switch (type)
{
case QUIRC_DATA_TYPE_NUMERIC:
err = decode_numeric(data, ds);
break;
case QUIRC_DATA_TYPE_ALPHA:
err = decode_alpha(data, ds);
break;
case QUIRC_DATA_TYPE_BYTE:
err = decode_byte(data, ds);
break;
case QUIRC_DATA_TYPE_KANJI:
err = decode_kanji(data, ds);
break;
case 7:
err = decode_eci(data, ds);
break;
default:
goto done;
}
if (err)
return err;
if (!(type & (type - 1)) && (type > data->data_type))
data->data_type = type;
}
done:
/* Add nul terminator to all payloads */
if (data->payload_len >= sizeof(data->payload))
data->payload_len--;
data->payload[data->payload_len] = 0;
return QUIRC_SUCCESS;
}
quirc_decode_error_t quirc_decode(const struct quirc_code *code,
struct quirc_data *data)
{
quirc_decode_error_t err;
struct datastream *ds = ps_malloc(sizeof(struct datastream));
if ((code->size - 17) % 4)
{
free(ds);
return QUIRC_ERROR_INVALID_GRID_SIZE;
}
memset(data, 0, sizeof(*data));
memset(ds, 0, sizeof(*ds));
data->version = (code->size - 17) / 4;
if (data->version < 1 ||
data->version > QUIRC_MAX_VERSION)
{
free(ds);
return QUIRC_ERROR_INVALID_VERSION;
}
/* Read format information -- try both locations */
err = read_format(code, data, 0);
if (err)
err = read_format(code, data, 1);
if (err)
{
free(ds);
return err;
}
read_data(code, data, ds);
err = codestream_ecc(data, ds);
if (err)
{
free(ds);
return err;
}
err = decode_payload(data, ds);
if (err)
{
free(ds);
return err;
}
free(ds);
return QUIRC_SUCCESS;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
/* quirc -- QR-code recognition library
* Copyright (C) 2010-2012 Daniel Beer <dlbeer@gmail.com>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#include <stdlib.h>
#include <string.h>
#include "quirc_internal.h"
#include <Arduino.h>
const char *quirc_version(void)
{
return "1.0";
}
//static struct quirc _q;
struct quirc *quirc_new(void)
{
struct quirc *q = ps_malloc(sizeof(*q));
if (!q)
return NULL;
memset(q, 0, sizeof(*q));
return q;
}
void quirc_destroy(struct quirc *q)
{
if (q->image)
if (q->image)
free(q->image);
if (sizeof(*q->image) != sizeof(*q->pixels))
if (q->pixels)
free(q->pixels);
if (q)
free(q);
}
//static quirc_pixel_t img_buf[320*240];
int quirc_resize(struct quirc *q, int w, int h)
{
if (q->image)
{
free(q->image);
}
uint8_t *new_image = ps_malloc(w * h);
if (!new_image)
return -1;
if (sizeof(*q->image) != sizeof(*q->pixels))
{ //should gray, 1==1
size_t new_size = w * h * sizeof(quirc_pixel_t);
if (q->pixels)
free(q->pixels);
quirc_pixel_t *new_pixels = ps_malloc(new_size);
if (!new_pixels)
{
free(new_image);
return -1;
}
q->pixels = new_pixels;
}
q->image = new_image;
q->w = w;
q->h = h;
return 0;
}
int quirc_count(const struct quirc *q)
{
return q->num_grids;
}
static const char *const error_table[] = {
[QUIRC_SUCCESS] = "Success",
[QUIRC_ERROR_INVALID_GRID_SIZE] = "Invalid grid size",
[QUIRC_ERROR_INVALID_VERSION] = "Invalid version",
[QUIRC_ERROR_FORMAT_ECC] = "Format data ECC failure",
[QUIRC_ERROR_DATA_ECC] = "ECC failure",
[QUIRC_ERROR_UNKNOWN_DATA_TYPE] = "Unknown data type",
[QUIRC_ERROR_DATA_OVERFLOW] = "Data overflow",
[QUIRC_ERROR_DATA_UNDERFLOW] = "Data underflow"};
const char *quirc_strerror(quirc_decode_error_t err)
{
if (err >= 0 && err < sizeof(error_table) / sizeof(error_table[0]))
return error_table[err];
return "Unknown error";
}
@@ -0,0 +1,178 @@
/* quirc -- QR-code recognition library
* Copyright (C) 2010-2012 Daniel Beer <dlbeer@gmail.com>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#ifndef QUIRC_H_
#define QUIRC_H_
#include <stdint.h>
#ifdef __cplusplus
extern "C"
{
#endif
struct quirc;
/* Obtain the library version string. */
const char *quirc_version(void);
/* Construct a new QR-code recognizer. This function will return NULL
* if sufficient memory could not be allocated.
*/
struct quirc *quirc_new(void);
/* Destroy a QR-code recognizer. */
void quirc_destroy(struct quirc *q);
/* Resize the QR-code recognizer. The size of an image must be
* specified before codes can be analyzed.
*
* This function returns 0 on success, or -1 if sufficient memory could
* not be allocated.
*/
int quirc_resize(struct quirc *q, int w, int h);
/* These functions are used to process images for QR-code recognition.
* quirc_begin() must first be called to obtain access to a buffer into
* which the input image should be placed. Optionally, the current
* width and height may be returned.
*
* After filling the buffer, quirc_end() should be called to process
* the image for QR-code recognition. The locations and content of each
* code may be obtained using accessor functions described below.
*/
uint8_t *quirc_begin(struct quirc *q, int *w, int *h);
void quirc_end(struct quirc *q);
/* This structure describes a location in the input image buffer. */
struct quirc_point
{
int x;
int y;
} __attribute__((aligned(8)));
/* This enum describes the various decoder errors which may occur. */
typedef enum
{
QUIRC_SUCCESS = 0,
QUIRC_ERROR_INVALID_GRID_SIZE,
QUIRC_ERROR_INVALID_VERSION,
QUIRC_ERROR_FORMAT_ECC,
QUIRC_ERROR_DATA_ECC,
QUIRC_ERROR_UNKNOWN_DATA_TYPE,
QUIRC_ERROR_DATA_OVERFLOW,
QUIRC_ERROR_DATA_UNDERFLOW
} quirc_decode_error_t;
/* Return a string error message for an error code. */
const char *quirc_strerror(quirc_decode_error_t err);
/* Limits on the maximum size of QR-codes and their content. */
#define QUIRC_MAX_BITMAP 3917
#define QUIRC_MAX_PAYLOAD 8896
/* QR-code ECC types. */
#define QUIRC_ECC_LEVEL_M 0
#define QUIRC_ECC_LEVEL_L 1
#define QUIRC_ECC_LEVEL_H 2
#define QUIRC_ECC_LEVEL_Q 3
/* QR-code data types. */
#define QUIRC_DATA_TYPE_NUMERIC 1
#define QUIRC_DATA_TYPE_ALPHA 2
#define QUIRC_DATA_TYPE_BYTE 4
#define QUIRC_DATA_TYPE_KANJI 8
/* Common character encodings */
#define QUIRC_ECI_ISO_8859_1 1
#define QUIRC_ECI_IBM437 2
#define QUIRC_ECI_ISO_8859_2 4
#define QUIRC_ECI_ISO_8859_3 5
#define QUIRC_ECI_ISO_8859_4 6
#define QUIRC_ECI_ISO_8859_5 7
#define QUIRC_ECI_ISO_8859_6 8
#define QUIRC_ECI_ISO_8859_7 9
#define QUIRC_ECI_ISO_8859_8 10
#define QUIRC_ECI_ISO_8859_9 11
#define QUIRC_ECI_WINDOWS_874 13
#define QUIRC_ECI_ISO_8859_13 15
#define QUIRC_ECI_ISO_8859_15 17
#define QUIRC_ECI_SHIFT_JIS 20
#define QUIRC_ECI_UTF_8 26
/* This structure is used to return information about detected QR codes
* in the input image.
*/
struct quirc_code
{
/* The four corners of the QR-code, from top left, clockwise */
struct quirc_point corners[4];
/* The number of cells across in the QR-code. The cell bitmap
* is a bitmask giving the actual values of cells. If the cell
* at (x, y) is black, then the following bit is set:
*
* cell_bitmap[i >> 3] & (1 << (i & 7))
*
* where i = (y * size) + x.
*/
int size;
uint8_t cell_bitmap[QUIRC_MAX_BITMAP];
} __attribute__((aligned(8)));
/* This structure holds the decoded QR-code data */
struct quirc_data
{
/* Various parameters of the QR-code. These can mostly be
* ignored if you only care about the data.
*/
int version;
int ecc_level;
int mask;
/* This field is the highest-valued data type found in the QR
* code.
*/
int data_type;
/* Data payload. For the Kanji datatype, payload is encoded as
* Shift-JIS. For all other datatypes, payload is ASCII text.
*/
uint8_t payload[QUIRC_MAX_PAYLOAD];
int payload_len;
/* ECI assignment number */
uint32_t eci;
} __attribute__((aligned(8)));
/* Return the number of QR-codes identified in the last processed
* image.
*/
int quirc_count(const struct quirc *q);
/* Extract the QR-code specified by the given index. */
void quirc_extract(const struct quirc *q, int index,
struct quirc_code *code);
/* Decode a QR-code, returning the payload data. */
quirc_decode_error_t quirc_decode(const struct quirc_code *code,
struct quirc_data *data);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,121 @@
/* quirc -- QR-code recognition library
* Copyright (C) 2010-2012 Daniel Beer <dlbeer@gmail.com>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#ifndef QUIRC_INTERNAL_H_
#define QUIRC_INTERNAL_H_
#include "quirc.h"
#define QUIRC_PIXEL_WHITE 0
#define QUIRC_PIXEL_BLACK 1
#define QUIRC_PIXEL_REGION 2
#ifndef QUIRC_MAX_REGIONS
#define QUIRC_MAX_REGIONS 254
#endif
#define QUIRC_MAX_CAPSTONES 32
#define QUIRC_MAX_GRIDS 8
#define QUIRC_PERSPECTIVE_PARAMS 8
#if QUIRC_MAX_REGIONS < UINT8_MAX
typedef uint8_t quirc_pixel_t;
#elif QUIRC_MAX_REGIONS < UINT16_MAX
typedef uint16_t quirc_pixel_t;
#else
#error "QUIRC_MAX_REGIONS > 65534 is not supported"
#endif
struct quirc_region
{
struct quirc_point seed;
int count;
int capstone;
} __attribute__((aligned(8)));
struct quirc_capstone
{
int ring;
int stone;
struct quirc_point corners[4];
struct quirc_point center;
float c[QUIRC_PERSPECTIVE_PARAMS];
int qr_grid;
} __attribute__((aligned(8)));
struct quirc_grid
{
/* Capstone indices */
int caps[3];
/* Alignment pattern region and corner */
int align_region;
struct quirc_point align;
/* Timing pattern endpoints */
struct quirc_point tpep[3];
int hscan;
int vscan;
/* Grid size and perspective transform */
int grid_size;
float c[QUIRC_PERSPECTIVE_PARAMS];
} __attribute__((aligned(8)));
struct quirc
{
uint8_t *image;
quirc_pixel_t *pixels;
int w;
int h;
int num_regions;
struct quirc_region regions[QUIRC_MAX_REGIONS];
int num_capstones;
struct quirc_capstone capstones[QUIRC_MAX_CAPSTONES];
int num_grids;
struct quirc_grid grids[QUIRC_MAX_GRIDS];
} __attribute__((aligned(8)));
/************************************************************************
* QR-code version information database
*/
#define QUIRC_MAX_VERSION 40
#define QUIRC_MAX_ALIGNMENT 7
struct quirc_rs_params
{
uint8_t bs; /* Small block size */
uint8_t dw; /* Small data words */
uint8_t ns; /* Number of small blocks */
} __attribute__((aligned(8)));
struct quirc_version_info
{
uint16_t data_bytes;
uint8_t apat[QUIRC_MAX_ALIGNMENT];
struct quirc_rs_params ecc[4];
} __attribute__((aligned(8)));
extern const struct quirc_version_info quirc_version_db[QUIRC_MAX_VERSION + 1];
#endif
@@ -0,0 +1,184 @@
/* quirc -- QR-code recognition library
* Copyright (C) 2010-2012 Daniel Beer <dlbeer@gmail.com>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#include "quirc_internal.h"
const struct quirc_version_info quirc_version_db[QUIRC_MAX_VERSION + 1] = {
{0},
{/* Version 1 */
.data_bytes = 26,
.apat = {0},
.ecc = {
{.bs = 26, .dw = 16, .ns = 1},
{.bs = 26, .dw = 19, .ns = 1},
{.bs = 26, .dw = 9, .ns = 1},
{.bs = 26, .dw = 13, .ns = 1}}},
{/* Version 2 */
.data_bytes = 44,
.apat = {6, 18, 0},
.ecc = {{.bs = 44, .dw = 28, .ns = 1}, {.bs = 44, .dw = 34, .ns = 1}, {.bs = 44, .dw = 16, .ns = 1}, {.bs = 44, .dw = 22, .ns = 1}}},
{/* Version 3 */
.data_bytes = 70,
.apat = {6, 22, 0},
.ecc = {{.bs = 70, .dw = 44, .ns = 1}, {.bs = 70, .dw = 55, .ns = 1}, {.bs = 35, .dw = 13, .ns = 2}, {.bs = 35, .dw = 17, .ns = 2}}},
{/* Version 4 */
.data_bytes = 100,
.apat = {6, 26, 0},
.ecc = {{.bs = 50, .dw = 32, .ns = 2}, {.bs = 100, .dw = 80, .ns = 1}, {.bs = 25, .dw = 9, .ns = 4}, {.bs = 50, .dw = 24, .ns = 2}}},
{/* Version 5 */
.data_bytes = 134,
.apat = {6, 30, 0},
.ecc = {{.bs = 67, .dw = 43, .ns = 2}, {.bs = 134, .dw = 108, .ns = 1}, {.bs = 33, .dw = 11, .ns = 2}, {.bs = 33, .dw = 15, .ns = 2}}},
{/* Version 6 */
.data_bytes = 172,
.apat = {6, 34, 0},
.ecc = {{.bs = 43, .dw = 27, .ns = 4}, {.bs = 86, .dw = 68, .ns = 2}, {.bs = 43, .dw = 15, .ns = 4}, {.bs = 43, .dw = 19, .ns = 4}}},
{/* Version 7 */
.data_bytes = 196,
.apat = {6, 22, 38, 0},
.ecc = {{.bs = 49, .dw = 31, .ns = 4}, {.bs = 98, .dw = 78, .ns = 2}, {.bs = 39, .dw = 13, .ns = 4}, {.bs = 32, .dw = 14, .ns = 2}}},
{/* Version 8 */
.data_bytes = 242,
.apat = {6, 24, 42, 0},
.ecc = {{.bs = 60, .dw = 38, .ns = 2}, {.bs = 121, .dw = 97, .ns = 2}, {.bs = 40, .dw = 14, .ns = 4}, {.bs = 40, .dw = 18, .ns = 4}}},
{/* Version 9 */
.data_bytes = 292,
.apat = {6, 26, 46, 0},
.ecc = {{.bs = 58, .dw = 36, .ns = 3}, {.bs = 146, .dw = 116, .ns = 2}, {.bs = 36, .dw = 12, .ns = 4}, {.bs = 36, .dw = 16, .ns = 4}}},
{/* Version 10 */
.data_bytes = 346,
.apat = {6, 28, 50, 0},
.ecc = {{.bs = 69, .dw = 43, .ns = 4}, {.bs = 86, .dw = 68, .ns = 2}, {.bs = 43, .dw = 15, .ns = 6}, {.bs = 43, .dw = 19, .ns = 6}}},
{/* Version 11 */
.data_bytes = 404,
.apat = {6, 30, 54, 0},
.ecc = {{.bs = 80, .dw = 50, .ns = 1}, {.bs = 101, .dw = 81, .ns = 4}, {.bs = 36, .dw = 12, .ns = 3}, {.bs = 50, .dw = 22, .ns = 4}}},
{/* Version 12 */
.data_bytes = 466,
.apat = {6, 32, 58, 0},
.ecc = {{.bs = 58, .dw = 36, .ns = 6}, {.bs = 116, .dw = 92, .ns = 2}, {.bs = 42, .dw = 14, .ns = 7}, {.bs = 46, .dw = 20, .ns = 4}}},
{/* Version 13 */
.data_bytes = 532,
.apat = {6, 34, 62, 0},
.ecc = {{.bs = 59, .dw = 37, .ns = 8}, {.bs = 133, .dw = 107, .ns = 4}, {.bs = 33, .dw = 11, .ns = 12}, {.bs = 44, .dw = 20, .ns = 8}}},
{/* Version 14 */
.data_bytes = 581,
.apat = {6, 26, 46, 66, 0},
.ecc = {{.bs = 64, .dw = 40, .ns = 4}, {.bs = 145, .dw = 115, .ns = 3}, {.bs = 36, .dw = 12, .ns = 11}, {.bs = 36, .dw = 16, .ns = 11}}},
{/* Version 15 */
.data_bytes = 655,
.apat = {6, 26, 48, 70, 0},
.ecc = {{.bs = 65, .dw = 41, .ns = 5}, {.bs = 109, .dw = 87, .ns = 5}, {.bs = 36, .dw = 12, .ns = 11}, {.bs = 54, .dw = 24, .ns = 5}}},
{/* Version 16 */
.data_bytes = 733,
.apat = {6, 26, 50, 74, 0},
.ecc = {{.bs = 73, .dw = 45, .ns = 7}, {.bs = 122, .dw = 98, .ns = 5}, {.bs = 45, .dw = 15, .ns = 3}, {.bs = 43, .dw = 19, .ns = 15}}},
{/* Version 17 */
.data_bytes = 815,
.apat = {6, 30, 54, 78, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 10}, {.bs = 135, .dw = 107, .ns = 1}, {.bs = 42, .dw = 14, .ns = 2}, {.bs = 50, .dw = 22, .ns = 1}}},
{/* Version 18 */
.data_bytes = 901,
.apat = {6, 30, 56, 82, 0},
.ecc = {{.bs = 69, .dw = 43, .ns = 9}, {.bs = 150, .dw = 120, .ns = 5}, {.bs = 42, .dw = 14, .ns = 2}, {.bs = 50, .dw = 22, .ns = 17}}},
{/* Version 19 */
.data_bytes = 991,
.apat = {6, 30, 58, 86, 0},
.ecc = {{.bs = 70, .dw = 44, .ns = 3}, {.bs = 141, .dw = 113, .ns = 3}, {.bs = 39, .dw = 13, .ns = 9}, {.bs = 47, .dw = 21, .ns = 17}}},
{/* Version 20 */
.data_bytes = 1085,
.apat = {6, 34, 62, 90, 0},
.ecc = {{.bs = 67, .dw = 41, .ns = 3}, {.bs = 135, .dw = 107, .ns = 3}, {.bs = 43, .dw = 15, .ns = 15}, {.bs = 54, .dw = 24, .ns = 15}}},
{/* Version 21 */
.data_bytes = 1156,
.apat = {6, 28, 50, 72, 92, 0},
.ecc = {{.bs = 68, .dw = 42, .ns = 17}, {.bs = 144, .dw = 116, .ns = 4}, {.bs = 46, .dw = 16, .ns = 19}, {.bs = 50, .dw = 22, .ns = 17}}},
{/* Version 22 */
.data_bytes = 1258,
.apat = {6, 26, 50, 74, 98, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 17}, {.bs = 139, .dw = 111, .ns = 2}, {.bs = 37, .dw = 13, .ns = 34}, {.bs = 54, .dw = 24, .ns = 7}}},
{/* Version 23 */
.data_bytes = 1364,
.apat = {6, 30, 54, 78, 102, 0},
.ecc = {{.bs = 75, .dw = 47, .ns = 4}, {.bs = 151, .dw = 121, .ns = 4}, {.bs = 45, .dw = 15, .ns = 16}, {.bs = 54, .dw = 24, .ns = 11}}},
{/* Version 24 */
.data_bytes = 1474,
.apat = {6, 28, 54, 80, 106, 0},
.ecc = {{.bs = 73, .dw = 45, .ns = 6}, {.bs = 147, .dw = 117, .ns = 6}, {.bs = 46, .dw = 16, .ns = 30}, {.bs = 54, .dw = 24, .ns = 11}}},
{/* Version 25 */
.data_bytes = 1588,
.apat = {6, 32, 58, 84, 110, 0},
.ecc = {{.bs = 75, .dw = 47, .ns = 8}, {.bs = 132, .dw = 106, .ns = 8}, {.bs = 45, .dw = 15, .ns = 22}, {.bs = 54, .dw = 24, .ns = 7}}},
{/* Version 26 */
.data_bytes = 1706,
.apat = {6, 30, 58, 86, 114, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 19}, {.bs = 142, .dw = 114, .ns = 10}, {.bs = 46, .dw = 16, .ns = 33}, {.bs = 50, .dw = 22, .ns = 28}}},
{/* Version 27 */
.data_bytes = 1828,
.apat = {6, 34, 62, 90, 118, 0},
.ecc = {{.bs = 73, .dw = 45, .ns = 22}, {.bs = 152, .dw = 122, .ns = 8}, {.bs = 45, .dw = 15, .ns = 12}, {.bs = 53, .dw = 23, .ns = 8}}},
{/* Version 28 */
.data_bytes = 1921,
.apat = {6, 26, 50, 74, 98, 122, 0},
.ecc = {{.bs = 73, .dw = 45, .ns = 3}, {.bs = 147, .dw = 117, .ns = 3}, {.bs = 45, .dw = 15, .ns = 11}, {.bs = 54, .dw = 24, .ns = 4}}},
{/* Version 29 */
.data_bytes = 2051,
.apat = {6, 30, 54, 78, 102, 126, 0},
.ecc = {{.bs = 73, .dw = 45, .ns = 21}, {.bs = 146, .dw = 116, .ns = 7}, {.bs = 45, .dw = 15, .ns = 19}, {.bs = 53, .dw = 23, .ns = 1}}},
{/* Version 30 */
.data_bytes = 2185,
.apat = {6, 26, 52, 78, 104, 130, 0},
.ecc = {{.bs = 75, .dw = 47, .ns = 19}, {.bs = 145, .dw = 115, .ns = 5}, {.bs = 45, .dw = 15, .ns = 23}, {.bs = 54, .dw = 24, .ns = 15}}},
{/* Version 31 */
.data_bytes = 2323,
.apat = {6, 30, 56, 82, 108, 134, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 2}, {.bs = 145, .dw = 115, .ns = 13}, {.bs = 45, .dw = 15, .ns = 23}, {.bs = 54, .dw = 24, .ns = 42}}},
{/* Version 32 */
.data_bytes = 2465,
.apat = {6, 34, 60, 86, 112, 138, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 10}, {.bs = 145, .dw = 115, .ns = 17}, {.bs = 45, .dw = 15, .ns = 19}, {.bs = 54, .dw = 24, .ns = 10}}},
{/* Version 33 */
.data_bytes = 2611,
.apat = {6, 30, 58, 86, 114, 142, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 14}, {.bs = 145, .dw = 115, .ns = 17}, {.bs = 45, .dw = 15, .ns = 11}, {.bs = 54, .dw = 24, .ns = 29}}},
{/* Version 34 */
.data_bytes = 2761,
.apat = {6, 34, 62, 90, 118, 146, 0},
.ecc = {{.bs = 74, .dw = 46, .ns = 14}, {.bs = 145, .dw = 115, .ns = 13}, {.bs = 46, .dw = 16, .ns = 59}, {.bs = 54, .dw = 24, .ns = 44}}},
{/* Version 35 */
.data_bytes = 2876,
.apat = {6, 30, 54, 78, 102, 126, 150},
.ecc = {{.bs = 75, .dw = 47, .ns = 12}, {.bs = 151, .dw = 121, .ns = 12}, {.bs = 45, .dw = 15, .ns = 22}, {.bs = 54, .dw = 24, .ns = 39}}},
{/* Version 36 */
.data_bytes = 3034,
.apat = {6, 24, 50, 76, 102, 128, 154},
.ecc = {{.bs = 75, .dw = 47, .ns = 6}, {.bs = 151, .dw = 121, .ns = 6}, {.bs = 45, .dw = 15, .ns = 2}, {.bs = 54, .dw = 24, .ns = 46}}},
{/* Version 37 */
.data_bytes = 3196,
.apat = {6, 28, 54, 80, 106, 132, 158},
.ecc = {{.bs = 74, .dw = 46, .ns = 29}, {.bs = 152, .dw = 122, .ns = 17}, {.bs = 45, .dw = 15, .ns = 24}, {.bs = 54, .dw = 24, .ns = 49}}},
{/* Version 38 */
.data_bytes = 3362,
.apat = {6, 32, 58, 84, 110, 136, 162},
.ecc = {{.bs = 74, .dw = 46, .ns = 13}, {.bs = 152, .dw = 122, .ns = 4}, {.bs = 45, .dw = 15, .ns = 42}, {.bs = 54, .dw = 24, .ns = 48}}},
{/* Version 39 */
.data_bytes = 3532,
.apat = {6, 26, 54, 82, 110, 138, 166},
.ecc = {{.bs = 75, .dw = 47, .ns = 40}, {.bs = 147, .dw = 117, .ns = 20}, {.bs = 45, .dw = 15, .ns = 10}, {.bs = 54, .dw = 24, .ns = 43}}},
{/* Version 40 */
.data_bytes = 3706,
.apat = {6, 30, 58, 86, 114, 142, 170},
.ecc = {{.bs = 75, .dw = 47, .ns = 18}, {.bs = 148, .dw = 118, .ns = 19}, {.bs = 45, .dw = 15, .ns = 20}, {.bs = 54, .dw = 24, .ns = 34}}}};
+20
View File
@@ -0,0 +1,20 @@
zlib License
Copyright (C) 2015, 2016 Lewis Van Winkle
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgement in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
+319
View File
@@ -0,0 +1,319 @@
[![Build Status](https://travis-ci.com/codeplea/tinyexpr.svg?branch=master)](https://travis-ci.com/codeplea/tinyexpr)
<img alt="TinyExpr logo" src="https://codeplea.com/public/content/tinyexpr_logo.png" align="right"/>
# TinyExpr
TinyExpr is a very small recursive descent parser and evaluation engine for
math expressions. It's handy when you want to add the ability to evaluate
math expressions at runtime without adding a bunch of cruft to your project.
In addition to the standard math operators and precedence, TinyExpr also supports
the standard C math functions and runtime binding of variables.
## Features
- **C99 with no dependencies**.
- Single source file and header file.
- Simple and fast.
- Implements standard operators precedence.
- Exposes standard C math functions (sin, sqrt, ln, etc.).
- Can add custom functions and variables easily.
- Can bind variables at eval-time.
- Released under the zlib license - free for nearly any use.
- Easy to use and integrate with your code
- Thread-safe, provided that your *malloc* is.
## Building
TinyExpr is self-contained in two files: `tinyexpr.c` and `tinyexpr.h`. To use
TinyExpr, simply add those two files to your project.
## Short Example
Here is a minimal example to evaluate an expression at runtime.
```C
#include "tinyexpr.h"
printf("%f\n", te_interp("5*5", 0)); /* Prints 25. */
```
## Usage
TinyExpr defines only four functions:
```C
double te_interp(const char *expression, int *error);
te_expr *te_compile(const char *expression, const te_variable *variables, int var_count, int *error);
double te_eval(const te_expr *expr);
void te_free(te_expr *expr);
```
## te_interp
```C
double te_interp(const char *expression, int *error);
```
`te_interp()` takes an expression and immediately returns the result of it. If there
is a parse error, `te_interp()` returns NaN.
If the `error` pointer argument is not 0, then `te_interp()` will set `*error` to the position
of the parse error on failure, and set `*error` to 0 on success.
**example usage:**
```C
int error;
double a = te_interp("(5+5)", 0); /* Returns 10. */
double b = te_interp("(5+5)", &error); /* Returns 10, error is set to 0. */
double c = te_interp("(5+5", &error); /* Returns NaN, error is set to 4. */
```
## te_compile, te_eval, te_free
```C
te_expr *te_compile(const char *expression, const te_variable *lookup, int lookup_len, int *error);
double te_eval(const te_expr *n);
void te_free(te_expr *n);
```
Give `te_compile()` an expression with unbound variables and a list of
variable names and pointers. `te_compile()` will return a `te_expr*` which can
be evaluated later using `te_eval()`. On failure, `te_compile()` will return 0
and optionally set the passed in `*error` to the location of the parse error.
You may also compile expressions without variables by passing `te_compile()`'s second
and third arguments as 0.
Give `te_eval()` a `te_expr*` from `te_compile()`. `te_eval()` will evaluate the expression
using the current variable values.
After you're finished, make sure to call `te_free()`.
**example usage:**
```C
double x, y;
/* Store variable names and pointers. */
te_variable vars[] = {{"x", &x}, {"y", &y}};
int err;
/* Compile the expression with variables. */
te_expr *expr = te_compile("sqrt(x^2+y^2)", vars, 2, &err);
if (expr) {
x = 3; y = 4;
const double h1 = te_eval(expr); /* Returns 5. */
x = 5; y = 12;
const double h2 = te_eval(expr); /* Returns 13. */
te_free(expr);
} else {
printf("Parse error at %d\n", err);
}
```
## Longer Example
Here is a complete example that will evaluate an expression passed in from the command
line. It also does error checking and binds the variables `x` and `y` to *3* and *4*, respectively.
```C
#include "tinyexpr.h"
#include <stdio.h>
int main(int argc, char *argv[])
{
if (argc < 2) {
printf("Usage: example2 \"expression\"\n");
return 0;
}
const char *expression = argv[1];
printf("Evaluating:\n\t%s\n", expression);
/* This shows an example where the variables
* x and y are bound at eval-time. */
double x, y;
te_variable vars[] = {{"x", &x}, {"y", &y}};
/* This will compile the expression and check for errors. */
int err;
te_expr *n = te_compile(expression, vars, 2, &err);
if (n) {
/* The variables can be changed here, and eval can be called as many
* times as you like. This is fairly efficient because the parsing has
* already been done. */
x = 3; y = 4;
const double r = te_eval(n); printf("Result:\n\t%f\n", r);
te_free(n);
} else {
/* Show the user where the error is at. */
printf("\t%*s^\nError near here", err-1, "");
}
return 0;
}
```
This produces the output:
$ example2 "sqrt(x^2+y2)"
Evaluating:
sqrt(x^2+y2)
^
Error near here
$ example2 "sqrt(x^2+y^2)"
Evaluating:
sqrt(x^2+y^2)
Result:
5.000000
## Binding to Custom Functions
TinyExpr can also call to custom functions implemented in C. Here is a short example:
```C
double my_sum(double a, double b) {
/* Example C function that adds two numbers together. */
return a + b;
}
te_variable vars[] = {
{"mysum", my_sum, TE_FUNCTION2} /* TE_FUNCTION2 used because my_sum takes two arguments. */
};
te_expr *n = te_compile("mysum(5, 6)", vars, 1, 0);
```
## How it works
`te_compile()` uses a simple recursive descent parser to compile your
expression into a syntax tree. For example, the expression `"sin x + 1/4"`
parses as:
![example syntax tree](doc/e1.png?raw=true)
`te_compile()` also automatically prunes constant branches. In this example,
the compiled expression returned by `te_compile()` would become:
![example syntax tree](doc/e2.png?raw=true)
`te_eval()` will automatically load in any variables by their pointer, and then evaluate
and return the result of the expression.
`te_free()` should always be called when you're done with the compiled expression.
## Speed
TinyExpr is pretty fast compared to C when the expression is short, when the
expression does hard calculations (e.g. exponentiation), and when some of the
work can be simplified by `te_compile()`. TinyExpr is slow compared to C when the
expression is long and involves only basic arithmetic.
Here is some example performance numbers taken from the included
**benchmark.c** program:
| Expression | te_eval time | native C time | slowdown |
| :------------- |-------------:| -----:|----:|
| sqrt(a^1.5+a^2.5) | 15,641 ms | 14,478 ms | 8% slower |
| a+5 | 765 ms | 563 ms | 36% slower |
| a+(5*2) | 765 ms | 563 ms | 36% slower |
| (a+5)*2 | 1422 ms | 563 ms | 153% slower |
| (1/(a+1)+2/(a+2)+3/(a+3)) | 5,516 ms | 1,266 ms | 336% slower |
## Grammar
TinyExpr parses the following grammar:
<list> = <expr> {"," <expr>}
<expr> = <term> {("+" | "-") <term>}
<term> = <factor> {("*" | "/" | "%") <factor>}
<factor> = <power> {"^" <power>}
<power> = {("-" | "+")} <base>
<base> = <constant>
| <variable>
| <function-0> {"(" ")"}
| <function-1> <power>
| <function-X> "(" <expr> {"," <expr>} ")"
| "(" <list> ")"
In addition, whitespace between tokens is ignored.
Valid variable names consist of a letter followed by any combination of:
letters, the digits *0* through *9*, and underscore. Constants can be integers
or floating-point numbers, and can be in decimal, hexadecimal (e.g., *0x57CEF7*),
or scientific notation (e.g., *1e3* for *1000*).
A leading zero is not required (e.g., *.5* for *0.5*).
## Functions supported
TinyExpr supports addition (+), subtraction/negation (-), multiplication (\*),
division (/), exponentiation (^) and modulus (%) with the normal operator
precedence (the one exception being that exponentiation is evaluated
left-to-right, but this can be changed - see below).
The following C math functions are also supported:
- abs (calls to *fabs*), acos, asin, atan, atan2, ceil, cos, cosh, exp, floor, ln (calls to *log*), log (calls to *log10* by default, see below), log10, pow, sin, sinh, sqrt, tan, tanh
The following functions are also built-in and provided by TinyExpr:
- fac (factorials e.g. `fac 5` == 120)
- ncr (combinations e.g. `ncr(6,2)` == 15)
- npr (permutations e.g. `npr(6,2)` == 30)
Also, the following constants are available:
- `pi`, `e`
## Compile-time options
By default, TinyExpr does exponentiation from left to right. For example:
`a^b^c == (a^b)^c` and `-a^b == (-a)^b`
This is by design. It's the way that spreadsheets do it (e.g. Excel, Google Sheets).
If you would rather have exponentiation work from right to left, you need to
define `TE_POW_FROM_RIGHT` when compiling `tinyexpr.c`. There is a
commented-out define near the top of that file. With this option enabled, the
behaviour is:
`a^b^c == a^(b^c)` and `-a^b == -(a^b)`
That will match how many scripting languages do it (e.g. Python, Ruby).
Also, if you'd like `log` to default to the natural log instead of `log10`,
then you can define `TE_NAT_LOG`.
## Hints
- All functions/types start with the letters *te*.
- To allow constant optimization, surround constant expressions in parentheses.
For example "x+(1+5)" will evaluate the "(1+5)" expression at compile time and
compile the entire expression as "x+6", saving a runtime calculation. The
parentheses are important, because TinyExpr will not change the order of
evaluation. If you instead compiled "x+1+5" TinyExpr will insist that "1" is
added to "x" first, and "5" is added the result second.
+17
View File
@@ -0,0 +1,17 @@
{
"name": "tinyexpr_vendor",
"version": "0.0.0+4a7456e",
"description": "Vendored tinyexpr from codeplea/tinyexpr commit 4a7456e2eab88b4c76053c1c4157639ccb930e2b",
"keywords": "math, expression, parser",
"repository": {
"type": "git",
"url": "https://github.com/codeplea/tinyexpr"
},
"license": "Zlib",
"frameworks": "*",
"platforms": "*",
"build": {
"srcDir": "src",
"includeDir": "src"
}
}
+734
View File
@@ -0,0 +1,734 @@
// SPDX-License-Identifier: Zlib
/*
* TINYEXPR - Tiny recursive descent parser and evaluation engine in C
*
* Copyright (c) 2015-2020 Lewis Van Winkle
*
* http://CodePlea.com
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgement in the product documentation would be
* appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be
* misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source distribution.
*/
/* COMPILE TIME OPTIONS */
/* Exponentiation associativity:
For a^b^c = (a^b)^c and -a^b = (-a)^b do nothing.
For a^b^c = a^(b^c) and -a^b = -(a^b) uncomment the next line.*/
/* #define TE_POW_FROM_RIGHT */
/* Logarithms
For log = base 10 log do nothing
For log = natural log uncomment the next line. */
/* #define TE_NAT_LOG */
#include "tinyexpr.h"
#include <stdlib.h>
#include <math.h>
#include <string.h>
#include <stdio.h>
#include <ctype.h>
#include <limits.h>
#ifndef NAN
#define NAN (0.0/0.0)
#endif
#ifndef INFINITY
#define INFINITY (1.0/0.0)
#endif
typedef double (*te_fun2)(double, double);
enum {
TOK_NULL = TE_CLOSURE7+1, TOK_ERROR, TOK_END, TOK_SEP,
TOK_OPEN, TOK_CLOSE, TOK_NUMBER, TOK_VARIABLE, TOK_INFIX
};
enum {TE_CONSTANT = 1};
typedef struct state {
const char *start;
const char *next;
int type;
union {double value; const double *bound; const void *function;};
void *context;
const te_variable *lookup;
int lookup_len;
} state;
#define TYPE_MASK(TYPE) ((TYPE)&0x0000001F)
#define IS_PURE(TYPE) (((TYPE) & TE_FLAG_PURE) != 0)
#define IS_FUNCTION(TYPE) (((TYPE) & TE_FUNCTION0) != 0)
#define IS_CLOSURE(TYPE) (((TYPE) & TE_CLOSURE0) != 0)
#define ARITY(TYPE) ( ((TYPE) & (TE_FUNCTION0 | TE_CLOSURE0)) ? ((TYPE) & 0x00000007) : 0 )
#define NEW_EXPR(type, ...) new_expr((type), (const te_expr*[]){__VA_ARGS__})
#define CHECK_NULL(ptr, ...) if ((ptr) == NULL) { __VA_ARGS__; return NULL; }
static te_expr *new_expr(const int type, const te_expr *parameters[]) {
const int arity = ARITY(type);
const int psize = sizeof(void*) * arity;
const int size = (sizeof(te_expr) - sizeof(void*)) + psize + (IS_CLOSURE(type) ? sizeof(void*) : 0);
te_expr *ret = malloc(size);
CHECK_NULL(ret);
memset(ret, 0, size);
if (arity && parameters) {
memcpy(ret->parameters, parameters, psize);
}
ret->type = type;
ret->bound = 0;
return ret;
}
void te_free_parameters(te_expr *n) {
if (!n) return;
switch (TYPE_MASK(n->type)) {
case TE_FUNCTION7: case TE_CLOSURE7: te_free(n->parameters[6]); /* Falls through. */
case TE_FUNCTION6: case TE_CLOSURE6: te_free(n->parameters[5]); /* Falls through. */
case TE_FUNCTION5: case TE_CLOSURE5: te_free(n->parameters[4]); /* Falls through. */
case TE_FUNCTION4: case TE_CLOSURE4: te_free(n->parameters[3]); /* Falls through. */
case TE_FUNCTION3: case TE_CLOSURE3: te_free(n->parameters[2]); /* Falls through. */
case TE_FUNCTION2: case TE_CLOSURE2: te_free(n->parameters[1]); /* Falls through. */
case TE_FUNCTION1: case TE_CLOSURE1: te_free(n->parameters[0]);
}
}
void te_free(te_expr *n) {
if (!n) return;
te_free_parameters(n);
free(n);
}
static double pi(void) {return 3.14159265358979323846;}
static double e(void) {return 2.71828182845904523536;}
static double fac(double a) {/* simplest version of fac */
if (a < 0.0)
return NAN;
if (a > UINT_MAX)
return INFINITY;
unsigned int ua = (unsigned int)(a);
unsigned long int result = 1, i;
for (i = 1; i <= ua; i++) {
if (i > ULONG_MAX / result)
return INFINITY;
result *= i;
}
return (double)result;
}
static double ncr(double n, double r) {
if (n < 0.0 || r < 0.0 || n < r) return NAN;
if (n > UINT_MAX || r > UINT_MAX) return INFINITY;
unsigned long int un = (unsigned int)(n), ur = (unsigned int)(r), i;
unsigned long int result = 1;
if (ur > un / 2) ur = un - ur;
for (i = 1; i <= ur; i++) {
if (result > ULONG_MAX / (un - ur + i))
return INFINITY;
result *= un - ur + i;
result /= i;
}
return result;
}
static double npr(double n, double r) {return ncr(n, r) * fac(r);}
#ifdef _MSC_VER
#pragma function (ceil)
#pragma function (floor)
#endif
static const te_variable functions[] = {
/* must be in alphabetical order */
{"abs", fabs, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"acos", acos, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"asin", asin, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"atan", atan, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"atan2", atan2, TE_FUNCTION2 | TE_FLAG_PURE, 0},
{"ceil", ceil, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"cos", cos, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"cosh", cosh, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"e", e, TE_FUNCTION0 | TE_FLAG_PURE, 0},
{"exp", exp, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"fac", fac, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"floor", floor, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"ln", log, TE_FUNCTION1 | TE_FLAG_PURE, 0},
#ifdef TE_NAT_LOG
{"log", log, TE_FUNCTION1 | TE_FLAG_PURE, 0},
#else
{"log", log10, TE_FUNCTION1 | TE_FLAG_PURE, 0},
#endif
{"log10", log10, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"ncr", ncr, TE_FUNCTION2 | TE_FLAG_PURE, 0},
{"npr", npr, TE_FUNCTION2 | TE_FLAG_PURE, 0},
{"pi", pi, TE_FUNCTION0 | TE_FLAG_PURE, 0},
{"pow", pow, TE_FUNCTION2 | TE_FLAG_PURE, 0},
{"sin", sin, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"sinh", sinh, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"sqrt", sqrt, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"tan", tan, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{"tanh", tanh, TE_FUNCTION1 | TE_FLAG_PURE, 0},
{0, 0, 0, 0}
};
static const te_variable *find_builtin(const char *name, int len) {
int imin = 0;
int imax = sizeof(functions) / sizeof(te_variable) - 2;
/*Binary search.*/
while (imax >= imin) {
const int i = (imin + ((imax-imin)/2));
int c = strncmp(name, functions[i].name, len);
if (!c) c = '\0' - functions[i].name[len];
if (c == 0) {
return functions + i;
} else if (c > 0) {
imin = i + 1;
} else {
imax = i - 1;
}
}
return 0;
}
static const te_variable *find_lookup(const state *s, const char *name, int len) {
int iters;
const te_variable *var;
if (!s->lookup) return 0;
for (var = s->lookup, iters = s->lookup_len; iters; ++var, --iters) {
if (strncmp(name, var->name, len) == 0 && var->name[len] == '\0') {
return var;
}
}
return 0;
}
static double add(double a, double b) {return a + b;}
static double sub(double a, double b) {return a - b;}
static double mul(double a, double b) {return a * b;}
static double divide(double a, double b) {return a / b;}
static double negate(double a) {return -a;}
static double comma(double a, double b) {(void)a; return b;}
void next_token(state *s) {
s->type = TOK_NULL;
do {
if (!*s->next){
s->type = TOK_END;
return;
}
/* Try reading a number. */
if ((s->next[0] >= '0' && s->next[0] <= '9') || s->next[0] == '.') {
s->value = strtod(s->next, (char**)&s->next);
s->type = TOK_NUMBER;
} else {
/* Look for a variable or builtin function call. */
if (isalpha(s->next[0])) {
const char *start;
start = s->next;
while (isalpha(s->next[0]) || isdigit(s->next[0]) || (s->next[0] == '_')) s->next++;
const te_variable *var = find_lookup(s, start, s->next - start);
if (!var) var = find_builtin(start, s->next - start);
if (!var) {
s->type = TOK_ERROR;
} else {
switch(TYPE_MASK(var->type))
{
case TE_VARIABLE:
s->type = TOK_VARIABLE;
s->bound = var->address;
break;
case TE_CLOSURE0: case TE_CLOSURE1: case TE_CLOSURE2: case TE_CLOSURE3: /* Falls through. */
case TE_CLOSURE4: case TE_CLOSURE5: case TE_CLOSURE6: case TE_CLOSURE7: /* Falls through. */
s->context = var->context; /* Falls through. */
case TE_FUNCTION0: case TE_FUNCTION1: case TE_FUNCTION2: case TE_FUNCTION3: /* Falls through. */
case TE_FUNCTION4: case TE_FUNCTION5: case TE_FUNCTION6: case TE_FUNCTION7: /* Falls through. */
s->type = var->type;
s->function = var->address;
break;
}
}
} else {
/* Look for an operator or special character. */
switch (s->next++[0]) {
case '+': s->type = TOK_INFIX; s->function = add; break;
case '-': s->type = TOK_INFIX; s->function = sub; break;
case '*': s->type = TOK_INFIX; s->function = mul; break;
case '/': s->type = TOK_INFIX; s->function = divide; break;
case '^': s->type = TOK_INFIX; s->function = pow; break;
case '%': s->type = TOK_INFIX; s->function = fmod; break;
case '(': s->type = TOK_OPEN; break;
case ')': s->type = TOK_CLOSE; break;
case ',': s->type = TOK_SEP; break;
case ' ': case '\t': case '\n': case '\r': break;
default: s->type = TOK_ERROR; break;
}
}
}
} while (s->type == TOK_NULL);
}
static te_expr *list(state *s);
static te_expr *expr(state *s);
static te_expr *power(state *s);
static te_expr *base(state *s) {
/* <base> = <constant> | <variable> | <function-0> {"(" ")"} | <function-1> <power> | <function-X> "(" <expr> {"," <expr>} ")" | "(" <list> ")" */
te_expr *ret;
int arity;
switch (TYPE_MASK(s->type)) {
case TOK_NUMBER:
ret = new_expr(TE_CONSTANT, 0);
CHECK_NULL(ret);
ret->value = s->value;
next_token(s);
break;
case TOK_VARIABLE:
ret = new_expr(TE_VARIABLE, 0);
CHECK_NULL(ret);
ret->bound = s->bound;
next_token(s);
break;
case TE_FUNCTION0:
case TE_CLOSURE0:
ret = new_expr(s->type, 0);
CHECK_NULL(ret);
ret->function = s->function;
if (IS_CLOSURE(s->type)) ret->parameters[0] = s->context;
next_token(s);
if (s->type == TOK_OPEN) {
next_token(s);
if (s->type != TOK_CLOSE) {
s->type = TOK_ERROR;
} else {
next_token(s);
}
}
break;
case TE_FUNCTION1:
case TE_CLOSURE1:
ret = new_expr(s->type, 0);
CHECK_NULL(ret);
ret->function = s->function;
if (IS_CLOSURE(s->type)) ret->parameters[1] = s->context;
next_token(s);
ret->parameters[0] = power(s);
CHECK_NULL(ret->parameters[0], te_free(ret));
break;
case TE_FUNCTION2: case TE_FUNCTION3: case TE_FUNCTION4:
case TE_FUNCTION5: case TE_FUNCTION6: case TE_FUNCTION7:
case TE_CLOSURE2: case TE_CLOSURE3: case TE_CLOSURE4:
case TE_CLOSURE5: case TE_CLOSURE6: case TE_CLOSURE7:
arity = ARITY(s->type);
ret = new_expr(s->type, 0);
CHECK_NULL(ret);
ret->function = s->function;
if (IS_CLOSURE(s->type)) ret->parameters[arity] = s->context;
next_token(s);
if (s->type != TOK_OPEN) {
s->type = TOK_ERROR;
} else {
int i;
for(i = 0; i < arity; i++) {
next_token(s);
ret->parameters[i] = expr(s);
CHECK_NULL(ret->parameters[i], te_free(ret));
if(s->type != TOK_SEP) {
break;
}
}
if(s->type != TOK_CLOSE || i != arity - 1) {
s->type = TOK_ERROR;
} else {
next_token(s);
}
}
break;
case TOK_OPEN:
next_token(s);
ret = list(s);
CHECK_NULL(ret);
if (s->type != TOK_CLOSE) {
s->type = TOK_ERROR;
} else {
next_token(s);
}
break;
default:
ret = new_expr(0, 0);
CHECK_NULL(ret);
s->type = TOK_ERROR;
ret->value = NAN;
break;
}
return ret;
}
static te_expr *power(state *s) {
/* <power> = {("-" | "+")} <base> */
int sign = 1;
while (s->type == TOK_INFIX && (s->function == add || s->function == sub)) {
if (s->function == sub) sign = -sign;
next_token(s);
}
te_expr *ret;
if (sign == 1) {
ret = base(s);
} else {
te_expr *b = base(s);
CHECK_NULL(b);
ret = NEW_EXPR(TE_FUNCTION1 | TE_FLAG_PURE, b);
CHECK_NULL(ret, te_free(b));
ret->function = negate;
}
return ret;
}
#ifdef TE_POW_FROM_RIGHT
static te_expr *factor(state *s) {
/* <factor> = <power> {"^" <power>} */
te_expr *ret = power(s);
CHECK_NULL(ret);
int neg = 0;
if (ret->type == (TE_FUNCTION1 | TE_FLAG_PURE) && ret->function == negate) {
te_expr *se = ret->parameters[0];
free(ret);
ret = se;
neg = 1;
}
te_expr *insertion = 0;
while (s->type == TOK_INFIX && (s->function == pow)) {
te_fun2 t = s->function;
next_token(s);
if (insertion) {
/* Make exponentiation go right-to-left. */
te_expr *p = power(s);
CHECK_NULL(p, te_free(ret));
te_expr *insert = NEW_EXPR(TE_FUNCTION2 | TE_FLAG_PURE, insertion->parameters[1], p);
CHECK_NULL(insert, te_free(p), te_free(ret));
insert->function = t;
insertion->parameters[1] = insert;
insertion = insert;
} else {
te_expr *p = power(s);
CHECK_NULL(p, te_free(ret));
te_expr *prev = ret;
ret = NEW_EXPR(TE_FUNCTION2 | TE_FLAG_PURE, ret, p);
CHECK_NULL(ret, te_free(p), te_free(prev));
ret->function = t;
insertion = ret;
}
}
if (neg) {
te_expr *prev = ret;
ret = NEW_EXPR(TE_FUNCTION1 | TE_FLAG_PURE, ret);
CHECK_NULL(ret, te_free(prev));
ret->function = negate;
}
return ret;
}
#else
static te_expr *factor(state *s) {
/* <factor> = <power> {"^" <power>} */
te_expr *ret = power(s);
CHECK_NULL(ret);
while (s->type == TOK_INFIX && (s->function == pow)) {
te_fun2 t = s->function;
next_token(s);
te_expr *p = power(s);
CHECK_NULL(p, te_free(ret));
te_expr *prev = ret;
ret = NEW_EXPR(TE_FUNCTION2 | TE_FLAG_PURE, ret, p);
CHECK_NULL(ret, te_free(p), te_free(prev));
ret->function = t;
}
return ret;
}
#endif
static te_expr *term(state *s) {
/* <term> = <factor> {("*" | "/" | "%") <factor>} */
te_expr *ret = factor(s);
CHECK_NULL(ret);
while (s->type == TOK_INFIX && (s->function == mul || s->function == divide || s->function == fmod)) {
te_fun2 t = s->function;
next_token(s);
te_expr *f = factor(s);
CHECK_NULL(f, te_free(ret));
te_expr *prev = ret;
ret = NEW_EXPR(TE_FUNCTION2 | TE_FLAG_PURE, ret, f);
CHECK_NULL(ret, te_free(f), te_free(prev));
ret->function = t;
}
return ret;
}
static te_expr *expr(state *s) {
/* <expr> = <term> {("+" | "-") <term>} */
te_expr *ret = term(s);
CHECK_NULL(ret);
while (s->type == TOK_INFIX && (s->function == add || s->function == sub)) {
te_fun2 t = s->function;
next_token(s);
te_expr *te = term(s);
CHECK_NULL(te, te_free(ret));
te_expr *prev = ret;
ret = NEW_EXPR(TE_FUNCTION2 | TE_FLAG_PURE, ret, te);
CHECK_NULL(ret, te_free(te), te_free(prev));
ret->function = t;
}
return ret;
}
static te_expr *list(state *s) {
/* <list> = <expr> {"," <expr>} */
te_expr *ret = expr(s);
CHECK_NULL(ret);
while (s->type == TOK_SEP) {
next_token(s);
te_expr *e = expr(s);
CHECK_NULL(e, te_free(ret));
te_expr *prev = ret;
ret = NEW_EXPR(TE_FUNCTION2 | TE_FLAG_PURE, ret, e);
CHECK_NULL(ret, te_free(e), te_free(prev));
ret->function = comma;
}
return ret;
}
#define TE_FUN(...) ((double(*)(__VA_ARGS__))n->function)
#define M(e) te_eval(n->parameters[e])
double te_eval(const te_expr *n) {
if (!n) return NAN;
switch(TYPE_MASK(n->type)) {
case TE_CONSTANT: return n->value;
case TE_VARIABLE: return *n->bound;
case TE_FUNCTION0: case TE_FUNCTION1: case TE_FUNCTION2: case TE_FUNCTION3:
case TE_FUNCTION4: case TE_FUNCTION5: case TE_FUNCTION6: case TE_FUNCTION7:
switch(ARITY(n->type)) {
case 0: return TE_FUN(void)();
case 1: return TE_FUN(double)(M(0));
case 2: return TE_FUN(double, double)(M(0), M(1));
case 3: return TE_FUN(double, double, double)(M(0), M(1), M(2));
case 4: return TE_FUN(double, double, double, double)(M(0), M(1), M(2), M(3));
case 5: return TE_FUN(double, double, double, double, double)(M(0), M(1), M(2), M(3), M(4));
case 6: return TE_FUN(double, double, double, double, double, double)(M(0), M(1), M(2), M(3), M(4), M(5));
case 7: return TE_FUN(double, double, double, double, double, double, double)(M(0), M(1), M(2), M(3), M(4), M(5), M(6));
default: return NAN;
}
case TE_CLOSURE0: case TE_CLOSURE1: case TE_CLOSURE2: case TE_CLOSURE3:
case TE_CLOSURE4: case TE_CLOSURE5: case TE_CLOSURE6: case TE_CLOSURE7:
switch(ARITY(n->type)) {
case 0: return TE_FUN(void*)(n->parameters[0]);
case 1: return TE_FUN(void*, double)(n->parameters[1], M(0));
case 2: return TE_FUN(void*, double, double)(n->parameters[2], M(0), M(1));
case 3: return TE_FUN(void*, double, double, double)(n->parameters[3], M(0), M(1), M(2));
case 4: return TE_FUN(void*, double, double, double, double)(n->parameters[4], M(0), M(1), M(2), M(3));
case 5: return TE_FUN(void*, double, double, double, double, double)(n->parameters[5], M(0), M(1), M(2), M(3), M(4));
case 6: return TE_FUN(void*, double, double, double, double, double, double)(n->parameters[6], M(0), M(1), M(2), M(3), M(4), M(5));
case 7: return TE_FUN(void*, double, double, double, double, double, double, double)(n->parameters[7], M(0), M(1), M(2), M(3), M(4), M(5), M(6));
default: return NAN;
}
default: return NAN;
}
}
#undef TE_FUN
#undef M
static void optimize(te_expr *n) {
/* Evaluates as much as possible. */
if (n->type == TE_CONSTANT) return;
if (n->type == TE_VARIABLE) return;
/* Only optimize out functions flagged as pure. */
if (IS_PURE(n->type)) {
const int arity = ARITY(n->type);
int known = 1;
int i;
for (i = 0; i < arity; ++i) {
optimize(n->parameters[i]);
if (((te_expr*)(n->parameters[i]))->type != TE_CONSTANT) {
known = 0;
}
}
if (known) {
const double value = te_eval(n);
te_free_parameters(n);
n->type = TE_CONSTANT;
n->value = value;
}
}
}
te_expr *te_compile(const char *expression, const te_variable *variables, int var_count, int *error) {
state s;
s.start = s.next = expression;
s.lookup = variables;
s.lookup_len = var_count;
next_token(&s);
te_expr *root = list(&s);
if (root == NULL) {
if (error) *error = -1;
return NULL;
}
if (s.type != TOK_END) {
te_free(root);
if (error) {
*error = (s.next - s.start);
if (*error == 0) *error = 1;
}
return 0;
} else {
optimize(root);
if (error) *error = 0;
return root;
}
}
double te_interp(const char *expression, int *error) {
te_expr *n = te_compile(expression, 0, 0, error);
double ret;
if (n) {
ret = te_eval(n);
te_free(n);
} else {
ret = NAN;
}
return ret;
}
static void pn (const te_expr *n, int depth) {
int i, arity;
printf("%*s", depth, "");
switch(TYPE_MASK(n->type)) {
case TE_CONSTANT: printf("%f\n", n->value); break;
case TE_VARIABLE: printf("bound %p\n", n->bound); break;
case TE_FUNCTION0: case TE_FUNCTION1: case TE_FUNCTION2: case TE_FUNCTION3:
case TE_FUNCTION4: case TE_FUNCTION5: case TE_FUNCTION6: case TE_FUNCTION7:
case TE_CLOSURE0: case TE_CLOSURE1: case TE_CLOSURE2: case TE_CLOSURE3:
case TE_CLOSURE4: case TE_CLOSURE5: case TE_CLOSURE6: case TE_CLOSURE7:
arity = ARITY(n->type);
printf("f%d", arity);
for(i = 0; i < arity; i++) {
printf(" %p", n->parameters[i]);
}
printf("\n");
for(i = 0; i < arity; i++) {
pn(n->parameters[i], depth + 1);
}
break;
}
}
void te_print(const te_expr *n) {
pn(n, 0);
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: Zlib
/*
* TINYEXPR - Tiny recursive descent parser and evaluation engine in C
*
* Copyright (c) 2015-2020 Lewis Van Winkle
*
* http://CodePlea.com
*
* This software is provided 'as-is', without any express or implied
* warranty. In no event will the authors be held liable for any damages
* arising from the use of this software.
*
* Permission is granted to anyone to use this software for any purpose,
* including commercial applications, and to alter it and redistribute it
* freely, subject to the following restrictions:
*
* 1. The origin of this software must not be misrepresented; you must not
* claim that you wrote the original software. If you use this software
* in a product, an acknowledgement in the product documentation would be
* appreciated but is not required.
* 2. Altered source versions must be plainly marked as such, and must not be
* misrepresented as being the original software.
* 3. This notice may not be removed or altered from any source distribution.
*/
#ifndef TINYEXPR_H
#define TINYEXPR_H
#ifdef __cplusplus
extern "C" {
#endif
typedef struct te_expr {
int type;
union {double value; const double *bound; const void *function;};
void *parameters[1];
} te_expr;
enum {
TE_VARIABLE = 0,
TE_FUNCTION0 = 8, TE_FUNCTION1, TE_FUNCTION2, TE_FUNCTION3,
TE_FUNCTION4, TE_FUNCTION5, TE_FUNCTION6, TE_FUNCTION7,
TE_CLOSURE0 = 16, TE_CLOSURE1, TE_CLOSURE2, TE_CLOSURE3,
TE_CLOSURE4, TE_CLOSURE5, TE_CLOSURE6, TE_CLOSURE7,
TE_FLAG_PURE = 32
};
typedef struct te_variable {
const char *name;
const void *address;
int type;
void *context;
} te_variable;
/* Parses the input expression, evaluates it, and frees it. */
/* Returns NaN on error. */
double te_interp(const char *expression, int *error);
/* Parses the input expression and binds variables. */
/* Returns NULL on error. */
te_expr *te_compile(const char *expression, const te_variable *variables, int var_count, int *error);
/* Evaluates the expression. */
double te_eval(const te_expr *n);
/* Prints debugging information on the syntax tree. */
void te_print(const te_expr *n);
/* Frees the expression. */
/* This is safe to call on NULL pointers. */
void te_free(te_expr *n);
#ifdef __cplusplus
}
#endif
#endif /*TINYEXPR_H*/
@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xF000, 0x2000,
app0, app, ota_0, 0x20000, 0x400000,
spiffs, data, spiffs, 0x420000, 0xBD0000,
coredump, data, coredump,0xFF0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 otadata data ota 0xF000 0x2000
4 app0 app ota_0 0x20000 0x400000
5 spiffs data spiffs 0x420000 0xBD0000
6 coredump data coredump 0xFF0000 0x10000
@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
otadata, data, ota, 0xF000, 0x2000,
app0, app, ota_0, 0x20000, 0x400000,
spiffs, data, spiffs, 0x420000, 0x3C0000,
coredump, data, coredump,0x7E0000, 0x10000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 otadata data ota 0xF000 0x2000
4 app0 app ota_0 0x20000 0x400000
5 spiffs data spiffs 0x420000 0x3C0000
6 coredump data coredump 0x7E0000 0x10000
+20 -201
View File
@@ -1,85 +1,28 @@
#
# PlatformIO Project Configuration File (global)
# Permet de builder tous les firmwares depuis la racine du projet
# PlatformIO Project Configuration File
# Cible release : Freenove FNK0102H / Freenove ESP32-S3 WROOM N16R8
#
[platformio]
default_envs = freenove_esp32s3_full_with_ui
src_dir = ui_freenove_allinone
; ===================== ESP32 =====================
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
board_build.partitions = no_ota.csv
board_build.filesystem = littlefs
build_src_filter =
-<*>
+<esp32_audio/src/>
+<../libs/story/src/>
+<../libs/story/story_engine.cpp>
-<../libs/story/src/ui/mp3_ui_model_v2.cpp>
monitor_speed = 115200
monitor_filters = time, esp32_exception_decoder
monitor_echo = yes
monitor_eol = LF
monitor_rts = 0
monitor_dtr = 0
lib_deps =
earlephilhower/ESP8266Audio@^1.9.7
https://github.com/pschatzmann/arduino-audio-tools.git#v1.2.2
sensorium/Mozzi@^2.0.2
https://github.com/pschatzmann/arduino-audio-driver.git#84c7de2eb47efe43bcc769783bb1853ee8a32f21
bblanchon/ArduinoJson@^6.21.5
https://github.com/me-no-dev/AsyncTCP.git
https://github.com/me-no-dev/ESPAsyncWebServer.git
build_flags =
-I$PROJECT_DIR/protocol
-Ihardware/firmware/esp32_audio/src
-Ihardware/libs/story/src
-Ihardware/libs/story
-DCORE_DEBUG_LEVEL=1
-DUSON_STORY_V2_DEFAULT=1
-DUSON_TRACK_CATALOG_MAX_TRACKS=200
; ===================== freenove_esp32s3_full_with_ui =====================
[env:freenove_esp32s3_full_with_ui]
platform = espressif32@^6.5.0
# Physical board: ESP32-S3-WROOM-1-N16R8 (16MB flash / 16MB PSRAM)
board = esp32-s3-devkitc-1
platform = espressif32@6.5.0
# Physical board profile: Freenove ESP32-S3 WROOM N16R8 (16MB flash / 8MB PSRAM)
board = freenove_esp32_s3_wroom
framework = arduino
board_build.arduino.memory_type = qio_opi
board_build.psram_type = opi
upload_port = /dev/cu.usbmodem5AB907*
monitor_port = /dev/cu.usbmodem5AB907*
extra_scripts = pre:scripts/pio_prepare_webui_fonts.py
board_build.flash_size = 16MB
board_upload.flash_size = 16MB
board_upload.maximum_size = 6291456
board_upload.maximum_size = 4194304
board_upload.offset_address = 0x20000
board_build.partitions = partitions/freenove_esp32s3_app6mb_fs6mb.csv
board_build.partitions = partitions/freenove_esp32s3_app4mb_fs12096kb.csv
board_build.filesystem = littlefs
build_src_filter =
-<*>
+<src/>
-<src/app/runtime_web_service?2.cpp>
-<src/ui/fonts/lv_font_ibmplexmono_bold_12?2.c>
-<src/ui/fonts/lv_font_ibmplexmono_italic_24?2.c>
-<src/ui/ui_manager_display?2.cpp>
-<src/ui/ui_manager_intro?2.cpp>
-<src/main.cpp>
-<src/scenario_manager.cpp>
-<src/ui_manager.cpp>
-<src/audio_manager.cpp>
-<src/storage_manager.cpp>
-<src/camera_manager.cpp>
-<src/button_manager.cpp>
-<src/touch_manager.cpp>
-<src/hardware_manager.cpp>
-<src/network_manager.cpp>
-<src/media_manager.cpp>
monitor_speed = 115200
monitor_filters = time, esp32_exception_decoder
monitor_echo = yes
@@ -87,23 +30,20 @@ monitor_eol = LF
monitor_rts = 0
monitor_dtr = 0
lib_deps =
bodmer/TFT_eSPI@^2.5.43
lovyan03/LovyanGFX@^1.2.7
lvgl/lvgl@^8.4.0
esphome/ESP32-audioI2S@^2.3.0
bblanchon/ArduinoJson@^6.21.5
adafruit/Adafruit NeoPixel@^1.12.3
https://github.com/alvarowolfx/ESP32QRCodeReader.git#dd07abcf062c4c1b120e0584df57c0c92ca77daf
https://github.com/codeplea/tinyexpr.git#4a7456e2eab88b4c76053c1c4157639ccb930e2b
bodmer/TFT_eSPI@2.5.43
lovyan03/LovyanGFX@1.2.7
lvgl/lvgl@8.4.0
esphome/ESP32-audioI2S@2.3.0
bblanchon/ArduinoJson@6.21.5
adafruit/Adafruit NeoPixel@1.12.3
build_flags =
-I$PROJECT_DIR/protocol
-I$PROJECT_DIR/ui_freenove_allinone/include
-I$PROJECT_DIR/lib/zacus_story_portable/include
-O2
-ffast-math
-DCORE_DEBUG_LEVEL=0
-DARDUINO_LOOP_STACK_SIZE=16384
-DZACUS_SPRINT_DIAG_MODE=1
-DZACUS_SPRINT_DIAG_MODE=0
-DBOARD_HAS_PSRAM
-DFREENOVE_USE_PSRAM=1
-DFREENOVE_PSRAM_UI_DRAW_BUFFER=1
@@ -157,138 +97,17 @@ extends = env:freenove_esp32s3_full_with_ui
[env:freenove_esp32s3_touch]
extends = env:freenove_esp32s3_full_with_ui
# Experimental only: release builds keep touch disabled because the official
# FNK0102H profile is the 5-way switch, and GPIO9/GPIO15 are already used by camera wiring.
build_unflags =
-DFREENOVE_HAS_TOUCH=0
build_flags =
${env:freenove_esp32s3_full_with_ui.build_flags}
-DFREENOVE_HAS_TOUCH=1
[env:esp32_release]
extends = env:esp32dev
build_unflags = -DUSON_STORY_V2_DEFAULT=1
build_flags = ${env:esp32dev.build_flags} -DUSON_STORY_V2_DEFAULT=0
; ===================== ui_rp2040_ili9488 =====================
[env:ui_rp2040_ili9488]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = rpipico
framework = arduino
build_src_filter =
-<*>
+<ui/rp2040_tft/src/>
monitor_speed = 115200
board_build.filesystem = littlefs
lib_deps =
bodmer/TFT_eSPI@^2.5.43
paulstoffregen/XPT2046_Touchscreen@0.0.0-alpha+sha.26b691b2c8
bblanchon/ArduinoJson@^6.21.5
lvgl/lvgl@^8.3.11
build_flags =
-I$PROJECT_DIR/protocol
-Ihardware/firmware/ui/rp2040_tft/include
-DLV_CONF_INCLUDE_SIMPLE
-DUSER_SETUP_LOADED=1
-DTFT_MISO=4
-DTFT_MOSI=3
-DTFT_SCLK=2
-DTFT_CS=5
-DTFT_DC=6
-DTFT_RST=7
-DTOUCH_CS=9
-DSPI_FREQUENCY=40000000
-DSPI_READ_FREQUENCY=20000000
-DSPI_TOUCH_FREQUENCY=2500000
-DSUPPORT_TRANSACTIONS
-DUI_LCD_SPI_HOST=0
-DUI_TOUCH_IRQ_PIN=15
-DUI_UART_RX_PIN=1
-DUI_UART_TX_PIN=0
-DUI_SERIAL_BAUD=57600
-DUI_ROTATION=1
-DILI9488_DRIVER
-DTFT_RGB_ORDER=TFT_BGR
; ===================== (ui_rp2040_ili9486) =====================
[env:ui_rp2040_ili9486]
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
board = rpipico
framework = arduino
build_src_filter =
-<*>
+<ui/rp2040_tft/src/>
monitor_speed = 115200
board_build.filesystem = littlefs
lib_deps =
bodmer/TFT_eSPI@^2.5.43
paulstoffregen/XPT2046_Touchscreen@0.0.0-alpha+sha.26b691b2c8
bblanchon/ArduinoJson@^6.21.5
lvgl/lvgl@^8.3.11
build_flags =
-I$PROJECT_DIR/protocol
-Ihardware/firmware/ui/rp2040_tft/include
-DLV_CONF_INCLUDE_SIMPLE
-DUSER_SETUP_LOADED=1
-DFREENOVE_MEDIA_KIT
-include ui_freenove_config.h
-DFREENOVE_LCD_WIDTH=480
-DFREENOVE_LCD_HEIGHT=320
-DFREENOVE_TFT_CS=5
-DFREENOVE_TFT_DC=6
-DFREENOVE_TFT_RST=7
-DFREENOVE_TFT_MOSI=3
-DFREENOVE_TFT_MISO=4
-DFREENOVE_TFT_SCK=2
-DFREENOVE_TOUCH_CS=9
-DFREENOVE_TOUCH_IRQ=15
-DFREENOVE_UART_TX=0
-DFREENOVE_UART_RX=1
-DFREENOVE_LCD_ROTATION=1
-DSPI_FREQUENCY=40000000
-DSPI_READ_FREQUENCY=20000000
-DSPI_TOUCH_FREQUENCY=2500000
-DSUPPORT_TRANSACTIONS
-DUI_LCD_SPI_HOST=0
-DUI_SERIAL_BAUD=57600
-DUI_ROTATION=1
-DILI9488_DRIVER
-DTFT_RGB_ORDER=TFT_BGR
; ===================== ESP8266 OLED =====================
[env:esp8266_oled]
platform = espressif8266
board = nodemcuv2
framework = arduino
build_src_filter =
-<*>
+<ui/esp8266_oled/src/>
monitor_speed = 115200
monitor_filters = time, colorize, esp8266_exception_decoder
monitor_echo = yes
monitor_eol = LF
monitor_rts = 0
monitor_dtr = 0
lib_deps =
adafruit/Adafruit SSD1306@^2.5.13
adafruit/Adafruit GFX Library@^1.12.1
plerup/EspSoftwareSerial@^8.2.0
olikraus/U8g2@^2.36.2
bblanchon/ArduinoJson@^6.21.5
build_flags =
-Iprotocol
#
# Pour builder tous les firmwares :
# pio run
# Pour builder un firmware spécifique :
# pio run -e <nom_env>
# Pour flasher/monitorer :
# pio run -e <env> -t upload --upload-port <PORT>
# pio device monitor -e <env> --port <PORT>
#
# Voir docs/protocols/ pour la documentation centralisée
# Builder : pio run
# Flasher : pio run -t upload
# Monitor : pio device monitor
# Filesystem : pio run -t uploadfs
#
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
EXIT_CODES = {
"normal_boot_ok": 0,
"safe_diagnostic_ok": 10,
"build_blocked": 20,
"runtime_regression": 30,
}
def load_text(path: Path | None) -> str:
if path is None or not path.exists():
return ""
return path.read_text(encoding="utf-8", errors="ignore")
def relevant_segment(text: str) -> str:
for marker in ("[HOST] ", "[MAIN] Freenove all-in-one boot", "[BOOT] safe diagnostic mode enabled"):
idx = text.rfind(marker if marker != "[HOST] " else "SENT RESET")
if idx != -1:
return text[idx:]
return text
def has_any(text: str, needles: list[str]) -> bool:
return any(needle in text for needle in needles)
def classify(text: str, build_ok: bool, upload_ok: bool) -> tuple[str, list[str]]:
evidence: list[str] = []
if not build_ok or not upload_ok:
if not build_ok:
evidence.append("build_ok=0")
if not upload_ok:
evidence.append("upload_ok=0")
return "build_blocked", evidence
segment = relevant_segment(text)
psram_match = re.findall(r"psram_found=(\d)", segment)
if psram_match:
evidence.append(f"psram_found={psram_match[-1]}")
safe_markers = [
"[BOOT] safe diagnostic mode enabled: PSRAM required, app stack disabled",
"[SAFE] boot path: storage + serial + display + buttons only",
]
safe_forbidden = [
"[NET]",
"[WEB]",
"[CAM] boot start",
"wifi:alloc pp wdev funcs fail",
"tag=fx_",
"tag=fx_sprite",
]
fatal_markers = [
"Guru Meditation",
"Backtrace:",
"abort()",
"panic",
"wifi:alloc pp wdev funcs fail",
"[MEM] alloc_fail",
]
if has_any(segment, safe_markers):
evidence.extend(marker for marker in safe_markers if marker in segment)
forbidden = [marker for marker in safe_forbidden if marker in segment]
if not forbidden and "STATUS mode=safe_diagnostic" in segment:
evidence.append("STATUS mode=safe_diagnostic")
return "safe_diagnostic_ok", evidence
evidence.extend(f"forbidden={marker}" for marker in forbidden)
return "runtime_regression", evidence
normal_markers = [
"psram_found=1",
"LVGL + display ready",
"PONG",
]
if has_any(segment, normal_markers):
evidence.extend(marker for marker in normal_markers if marker in segment)
fatals = [marker for marker in fatal_markers if marker in segment]
if "psram_found=1" in segment and "LVGL + display ready" in segment and not fatals:
return "normal_boot_ok", evidence
evidence.extend(f"fatal={marker}" for marker in fatals)
return "runtime_regression", evidence
if segment.strip():
evidence.append("boot segment captured but acceptance markers missing")
else:
evidence.append("boot log empty")
return "runtime_regression", evidence
def write_summary(path: Path, state: str, evidence: list[str], log_path: Path | None) -> None:
lines = [
f"state={state}",
]
if log_path is not None:
lines.append(f"log={log_path}")
for item in evidence:
lines.append(f"evidence={item}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Classify ESP32 boot logs.")
parser.add_argument("--log")
parser.add_argument("--summary", required=True)
parser.add_argument("--build-ok", type=int, choices=(0, 1), default=1)
parser.add_argument("--upload-ok", type=int, choices=(0, 1), default=1)
args = parser.parse_args()
log_path = Path(args.log) if args.log else None
summary_path = Path(args.summary)
text = load_text(log_path)
state, evidence = classify(text, build_ok=bool(args.build_ok), upload_ok=bool(args.upload_ok))
write_summary(summary_path, state, evidence, log_path)
print(state)
return EXIT_CODES[state]
if __name__ == "__main__":
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
VENV_DIR="${ZACUS_VENV_DIR:-$ROOT_DIR/.venv}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "[BOOTSTRAP] python3 not found" >&2
exit 1
fi
if [ ! -d "$VENV_DIR" ]; then
echo "[BOOTSTRAP] creating virtualenv at $VENV_DIR"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
python -m pip install --upgrade pip
python -m pip install platformio esptool pyserial
echo "[BOOTSTRAP] tool versions"
python -m platformio --version
python -m esptool version
python - <<'PY'
import serial
print(f"pyserial {serial.VERSION}")
PY
cat <<EOF
[BOOTSTRAP] next steps
source "$VENV_DIR/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>
python -m platformio device monitor -b 115200 --port <PORT>
EOF
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
STATUS=0
ok() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1"
}
err() {
printf '[ERR] %s\n' "$1" >&2
STATUS=1
}
cd "$ROOT_DIR"
for cmd in bash rg "$PYTHON_BIN"; do
if command -v "$cmd" >/dev/null 2>&1; then
ok "tool available: $cmd"
else
err "missing tool: $cmd"
fi
done
if command -v pio >/dev/null 2>&1; then
ok "pio available: $(pio --version | head -n 1)"
elif [ -x "$ROOT_DIR/.venv/bin/python" ] && "$ROOT_DIR/.venv/bin/python" -m platformio --version >/dev/null 2>&1; then
ok "platformio available in .venv: $("$ROOT_DIR/.venv/bin/python" -m platformio --version | head -n 1)"
else
warn "PlatformIO not found in PATH or .venv"
fi
platform_pin="$(rg -n '^platform = ' platformio.ini | sed 's/^[0-9]*://')"
ok "platform pin: ${platform_pin:-missing}"
echo "[INFO] supported envs"
rg '^\[env:' platformio.ini | sed 's/^\[env://; s/\]$//'
for path in \
README.md \
README_ESP32_ZACUS.md \
ui_freenove_allinone/README.md \
docs/QUICKSTART.md \
docs/FNK0102H_SOURCE_OF_TRUTH.md \
boards/freenove_esp32_s3_wroom.json \
scripts/bootstrap_platformio.sh \
scripts/doctor_repo.sh \
scripts/flash_monitor_audit.sh \
scripts/serial_boot_capture.py \
scripts/audit_boot_log.py \
tests/sprint1_utility_contract.py \
tests/sprint2_capture_contract.py \
tests/sprint3_audio_contract.py \
tests/phase9_ui_validation.py; do
if [ -e "$path" ]; then
ok "path present: $path"
else
err "path missing: $path"
fi
done
"$PYTHON_BIN" - <<'PY' || STATUS=1
from pathlib import Path
import re
import sys
root = Path.cwd()
files = [
root / "README.md",
root / "README_ESP32_ZACUS.md",
root / "ui_freenove_allinone" / "README.md",
root / "docs" / "QUICKSTART.md",
root / "docs" / "FNK0102H_SOURCE_OF_TRUTH.md",
]
legacy_needles = ("hardware/firmware", "tools/dev/", "protocol/", "../hardware/")
status = 0
for path in files:
text = path.read_text(encoding="utf-8")
for match in re.finditer(r"\[[^\]]+\]\(([^)]+)\)", text):
target = match.group(1).strip()
if not target or target.startswith(("http://", "https://", "#", "mailto:")):
continue
target = target.split("#", 1)[0]
if not (path.parent / target).resolve().exists():
print(f"[ERR] broken markdown link in {path.relative_to(root)} -> {target}")
status = 1
for needle in legacy_needles:
if needle in text:
print(f"[ERR] stale legacy reference in {path.relative_to(root)} -> {needle}")
status = 1
if status == 0:
print("[OK] markdown links and legacy path scan clean")
sys.exit(status)
PY
if [ "$STATUS" -ne 0 ]; then
err "doctor detected blocking issues"
else
ok "doctor completed without blocking issues"
fi
exit "$STATUS"
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_NAME="${ZACUS_ENV:-freenove_esp32s3_full_with_ui}"
MONITOR_SECONDS="${ZACUS_MONITOR_SECONDS:-90}"
DEFAULT_PORT="/dev/cu.usbmodem5AB90753301"
if [ -x "$ROOT_DIR/.venv/bin/python" ]; then
PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
else
PYTHON_BIN="${PYTHON_BIN:-python3}"
fi
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BUILD_ARTIFACT_DIR="$ROOT_DIR/artifacts/build"
MONITOR_ARTIFACT_DIR="$ROOT_DIR/artifacts/monitor"
AUDIT_ARTIFACT_DIR="$ROOT_DIR/artifacts/audit"
TEST_ARTIFACT_DIR="$ROOT_DIR/artifacts/tests"
SUMMARY_FILE="$AUDIT_ARTIFACT_DIR/${TIMESTAMP}_summary.txt"
MONITOR_LOG="$MONITOR_ARTIFACT_DIR/${TIMESTAMP}_boot.log"
ESPTOOL_LOG="$AUDIT_ARTIFACT_DIR/${TIMESTAMP}_esptool.log"
mkdir -p "$BUILD_ARTIFACT_DIR" "$MONITOR_ARTIFACT_DIR" "$AUDIT_ARTIFACT_DIR" "$TEST_ARTIFACT_DIR"
log() {
printf '[CHAIN] %s\n' "$1"
}
detect_port() {
if [ -n "${ZACUS_SERIAL_PORT:-}" ] && [ -e "${ZACUS_SERIAL_PORT}" ]; then
printf '%s\n' "${ZACUS_SERIAL_PORT}"
return 0
fi
if [ -e "$DEFAULT_PORT" ]; then
printf '%s\n' "$DEFAULT_PORT"
return 0
fi
mapfile -t ports < <(find /dev -maxdepth 1 -name 'cu.usbmodem*' | sort)
if [ "${#ports[@]}" -eq 1 ]; then
printf '%s\n' "${ports[0]}"
return 0
fi
return 1
}
run_stage() {
local stage="$1"
shift
local logfile="$BUILD_ARTIFACT_DIR/${TIMESTAMP}_${stage}.log"
log "stage=$stage"
if "$@" 2>&1 | tee "$logfile"; then
return 0
fi
return 1
}
write_blocked_summary() {
local build_ok="$1"
local upload_ok="$2"
"$PYTHON_BIN" "$ROOT_DIR/scripts/audit_boot_log.py" \
--summary "$SUMMARY_FILE" \
--build-ok "$build_ok" \
--upload-ok "$upload_ok" >/dev/null
}
run_upload_target() {
local target="$1"
local port="$2"
run_stage "$target" "$PYTHON_BIN" -m platformio run -e "$ENV_NAME" -t "$target" --upload-port "$port"
}
run_upload_with_retry() {
local result_var="$1"
local target="$2"
local port="$3"
if run_upload_target "$target" "$port"; then
printf -v "$result_var" '%s' "$port"
return 0
fi
log "retry stage=$target port=$port"
if [ -e "$port" ] && run_upload_target "$target" "$port"; then
printf -v "$result_var" '%s' "$port"
return 0
fi
local fallback_port
fallback_port="$(detect_port)" || return 1
if [ "$fallback_port" != "$port" ] && run_upload_target "$target" "$fallback_port"; then
printf -v "$result_var" '%s' "$fallback_port"
return 0
fi
return 1
}
run_test() {
local name="$1"
shift
local logfile="$TEST_ARTIFACT_DIR/${TIMESTAMP}_${name}.log"
log "test=$name"
"$@" 2>&1 | tee "$logfile"
}
main() {
cd "$ROOT_DIR"
local port
port="$(detect_port)" || {
printf 'state=hardware_unreachable\nreason=no_serial_port\n' >"$SUMMARY_FILE"
log "no serial port detected"
return 40
}
export ZACUS_SERIAL_PORT="$port"
if ! run_stage build "$PYTHON_BIN" -m platformio run -e "$ENV_NAME"; then
write_blocked_summary 0 0
return 20
fi
if ! run_stage buildfs "$PYTHON_BIN" -m platformio run -e "$ENV_NAME" -t buildfs; then
write_blocked_summary 0 0
return 20
fi
if ! run_upload_with_retry port uploadfs "$port"; then
write_blocked_summary 1 0
return 20
fi
export ZACUS_SERIAL_PORT="$port"
if ! run_upload_with_retry port upload "$port"; then
write_blocked_summary 1 0
return 20
fi
export ZACUS_SERIAL_PORT="$port"
if ! "$PYTHON_BIN" "$ROOT_DIR/scripts/serial_boot_capture.py" \
--port "$port" \
--baud 115200 \
--seconds "$MONITOR_SECONDS" \
--log "$MONITOR_LOG"; then
printf 'state=hardware_unreachable\nreason=serial_capture_failed\nport=%s\nlog=%s\n' "$port" "$MONITOR_LOG" >"$SUMMARY_FILE"
return 40
fi
local audit_state
set +e
audit_state="$("$PYTHON_BIN" "$ROOT_DIR/scripts/audit_boot_log.py" \
--log "$MONITOR_LOG" \
--summary "$SUMMARY_FILE" \
--build-ok 1 \
--upload-ok 1)"
local audit_rc=$?
set -e
if [ "$audit_state" = "safe_diagnostic_ok" ]; then
{
printf '[ESPTOOL] flash_id\n'
"$PYTHON_BIN" -m esptool --chip esp32s3 --port "$port" flash_id
printf '\n[ESPTOOL] read_mac\n'
"$PYTHON_BIN" -m esptool --chip esp32s3 --port "$port" read_mac
} 2>&1 | tee "$ESPTOOL_LOG"
{
printf 'finding=containment_logic_ok_root_cause_probable_hardware_or_memory_config\n'
printf 'expected_board=FNK0102H/ESP32-S3-WROOM-1-N16R8\n'
printf 'esptool_log=%s\n' "$ESPTOOL_LOG"
} >>"$SUMMARY_FILE"
return "$audit_rc"
fi
if [ "$audit_state" = "normal_boot_ok" ]; then
run_test sprint1 "$PYTHON_BIN" "$ROOT_DIR/tests/sprint1_utility_contract.py" --mode serial --cycles 1 --port "$port"
run_test sprint2 "$PYTHON_BIN" "$ROOT_DIR/tests/sprint2_capture_contract.py" --mode serial --cycles 1 --port "$port"
run_test sprint3 "$PYTHON_BIN" "$ROOT_DIR/tests/sprint3_audio_contract.py" --mode serial --cycles 1 --port "$port"
run_test phase9 "$PYTHON_BIN" "$ROOT_DIR/tests/phase9_ui_validation.py" --port "$port"
printf 'tests=serial_smoke_pass\n' >>"$SUMMARY_FILE"
return 0
fi
return "$audit_rc"
}
main "$@"
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
import serial
BOOT_BANNER = "[MAIN] Freenove all-in-one boot"
def host_line(text: str) -> str:
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
return f"[HOST] {stamp} {text}"
def open_serial(port: str, baud: int, timeout: float) -> serial.Serial:
ser = serial.Serial()
ser.port = port
ser.baudrate = baud
ser.timeout = timeout
ser.write_timeout = 1.0
ser.dtr = False
ser.rts = False
ser.open()
time.sleep(0.25)
return ser
def flush_lines(ser: serial.Serial, log_handle, deadline: float) -> list[str]:
lines: list[str] = []
pending = ""
while time.time() < deadline:
chunk = ser.read(ser.in_waiting or 1)
if not chunk:
continue
pending += chunk.decode("utf-8", errors="ignore")
while "\n" in pending:
raw_line, pending = pending.split("\n", 1)
line = raw_line.rstrip("\r")
log_handle.write(line + "\n")
log_handle.flush()
lines.append(line)
if pending:
line = pending.rstrip("\r")
log_handle.write(line + "\n")
log_handle.flush()
lines.append(line)
return lines
def send_command(ser: serial.Serial, log_handle, command: str, wait_s: float) -> list[str]:
log_handle.write(host_line(f"SENT {command}") + "\n")
log_handle.flush()
ser.write((command + "\n").encode("utf-8"))
ser.flush()
return flush_lines(ser, log_handle, time.time() + wait_s)
def wait_for_command_channel(ser: serial.Serial, log_handle, timeout_s: float) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
flush_lines(ser, log_handle, time.time() + 0.35)
response = send_command(ser, log_handle, "PING", wait_s=1.2)
if any("PONG" in line or "UNKNOWN PING" in line for line in response):
log_handle.write(host_line("COMMAND CHANNEL READY") + "\n")
log_handle.flush()
return True
time.sleep(0.4)
log_handle.write(host_line("COMMAND CHANNEL NOT READY") + "\n")
log_handle.flush()
return False
def wait_for_quiet_period(
ser: serial.Serial,
log_handle,
timeout_s: float,
quiet_s: float,
) -> tuple[bool, float]:
deadline = time.time() + timeout_s
last_rx_at = time.time()
while time.time() < deadline:
lines = flush_lines(ser, log_handle, time.time() + 0.25)
if lines:
last_rx_at = time.time()
elif (time.time() - last_rx_at) >= quiet_s:
return True, last_rx_at
return False, last_rx_at
def main() -> int:
parser = argparse.ArgumentParser(description="Capture a rebooted ESP32 boot log over serial.")
parser.add_argument("--port", required=True)
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--timeout", type=float, default=0.2)
parser.add_argument("--seconds", type=float, default=90.0)
parser.add_argument("--log", required=True)
args = parser.parse_args()
log_path = Path(args.log)
log_path.parent.mkdir(parents=True, exist_ok=True)
try:
with log_path.open("w", encoding="utf-8") as log_handle:
log_handle.write(host_line(f"OPEN port={args.port} baud={args.baud}") + "\n")
log_handle.flush()
ser = open_serial(args.port, args.baud, args.timeout)
try:
ready = wait_for_command_channel(ser, log_handle, timeout_s=20.0)
if ready:
ser.close()
time.sleep(1.2)
ser = open_serial(args.port, args.baud, args.timeout)
log_handle.write(host_line("REOPEN FOR BOOT CAPTURE") + "\n")
log_handle.flush()
capture_deadline = time.time() + args.seconds
boot_seen = False
post_boot_commands_sent = False
boot_seen_at = 0.0
last_rx_at = time.time()
phase_start = time.time()
while time.time() < capture_deadline:
lines = flush_lines(ser, log_handle, time.time() + 0.25)
if lines:
last_rx_at = time.time()
for line in lines:
if BOOT_BANNER in line:
boot_seen = True
boot_seen_at = time.time()
should_probe = False
if (
boot_seen
and not post_boot_commands_sent
and (
(time.time() - last_rx_at) >= 1.8
or (time.time() - boot_seen_at) >= 18.0
)
):
should_probe = True
if (
not boot_seen
and not post_boot_commands_sent
and (time.time() - phase_start) >= 18.0
):
should_probe = True
if should_probe:
send_command(ser, log_handle, "PING", wait_s=2.0)
send_command(ser, log_handle, "STATUS", wait_s=2.4)
post_boot_commands_sent = True
log_handle.write(host_line("CAPTURE COMPLETE") + "\n")
log_handle.flush()
finally:
try:
ser.close()
except Exception:
pass
except serial.SerialException as exc:
print(f"serial capture failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -0,0 +1,9 @@
# Implementation Audit TODO
Ce fichier sert de point d'ancrage pour les références historiques encore présentes dans le backlog.
Les documents canoniques à consulter en priorité sont:
- `docs/FNK0102H_SOURCE_OF_TRUTH.md`
- `docs/QUICKSTART.md`
- `README_ESP32_ZACUS.md`
+11 -2
View File
@@ -4,13 +4,15 @@ Phase 6: Hardware Validation - Interactive Serial Testing
Tests P1/P2 security hardening on ESP32-S3
"""
import argparse
import os
import serial
import sys
import time
from typing import Optional
class ESP32Tester:
def __init__(self, port: str = "/dev/cu.usbmodem5AB90753301", baudrate: int = 115200):
def __init__(self, port: str, baudrate: int = 115200):
self.port = port
self.baudrate = baudrate
self.ser: Optional[serial.Serial] = None
@@ -325,5 +327,12 @@ class ESP32Tester:
if __name__ == "__main__":
tester = ESP32Tester()
parser = argparse.ArgumentParser(description="Phase 6 hardware validation over serial.")
parser.add_argument("--port", default=os.environ.get("ZACUS_SERIAL_PORT", ""))
parser.add_argument("--baud", type=int, default=int(os.environ.get("ZACUS_SERIAL_BAUD", "115200")))
args = parser.parse_args()
if not args.port:
parser.error("--port or ZACUS_SERIAL_PORT is required")
tester = ESP32Tester(port=args.port, baudrate=args.baud)
tester.run_all_tests()
+7 -2
View File
@@ -3,15 +3,20 @@
###############################################################################
# Phase 6: Hardware Validation Smoke Tests
# Tests P1/P2 security hardening on live ESP32-S3 device
# Port: /dev/cu.usbmodem5AB90753301 @ 115200 baud
# Port: pass as argument or use ZACUS_SERIAL_PORT
###############################################################################
set -e
DEVICE_PORT="${1:-/dev/cu.usbmodem5AB90753301}"
DEVICE_PORT="${1:-${ZACUS_SERIAL_PORT:-}}"
BAUD_RATE="115200"
TEST_LOG="/tmp/phase6_test_results.log"
if [ -z "$DEVICE_PORT" ]; then
echo "Usage: $0 <PORT> or set ZACUS_SERIAL_PORT" >&2
exit 1
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
+76 -40
View File
@@ -1,22 +1,61 @@
#!/usr/bin/env python3
"""
Phase 9: Device UI Validation & Touch Input Testing
Phase 9: Device UI Validation
Tests:
1. Verify firmware boot & AmigaUIShell initialization
2. Monitor serial logs for UI animation frames
3. Validate system responsiveness (PING/STATUS)
4. Prepare for touch input mapping tests
4. Check repeated serial interactions for stability
"""
import argparse
import os
import serial
import time
import sys
from pathlib import Path
import time
# Configuration
SERIAL_PORT = "/dev/cu.usbmodem5AB90753301"
BAUD_RATE = 115200
TIMEOUT = 2.0
SERIAL_PORT = os.environ.get("ZACUS_SERIAL_PORT", "")
BAUD_RATE = int(os.environ.get("ZACUS_SERIAL_BAUD", "115200"))
TIMEOUT = float(os.environ.get("ZACUS_SERIAL_TIMEOUT", "2.0"))
def open_serial_port() -> serial.Serial:
ser = serial.Serial()
ser.port = SERIAL_PORT
ser.baudrate = BAUD_RATE
ser.timeout = TIMEOUT
ser.write_timeout = 1.0
ser.dtr = False
ser.rts = False
ser.open()
time.sleep(0.25)
return ser
def wait_for_boot_settle(ser: serial.Serial, timeout_s: float = 45.0, quiet_s: float = 2.0) -> None:
deadline = time.time() + timeout_s
last_rx = time.time()
while time.time() < deadline:
if ser.in_waiting > 0:
_ = ser.read(ser.in_waiting)
last_rx = time.time()
elif (time.time() - last_rx) >= quiet_s:
return
time.sleep(0.04)
def send_command(ser: serial.Serial, command: str, wait_s: float = 2.0) -> str:
ser.reset_input_buffer()
ser.write((command + "\n").encode("utf-8"))
ser.flush()
deadline = time.time() + wait_s
chunks = []
while time.time() < deadline:
if ser.in_waiting > 0:
chunks.append(ser.read(ser.in_waiting).decode("utf-8", errors="ignore"))
time.sleep(0.03)
return "".join(chunks)
def test_device_responsive():
"""Test 1: Device PING/STATUS"""
@@ -25,15 +64,12 @@ def test_device_responsive():
print("="*60)
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=TIMEOUT)
time.sleep(0.5) # Wait for serial to stabilize
ser = open_serial_port()
wait_for_boot_settle(ser)
# Send PING
print("[SERIAL] Sending: PING")
ser.write(b"PING\n")
time.sleep(0.2)
response = ser.readline().decode('utf-8', errors='ignore').strip()
response = send_command(ser, "PING", wait_s=2.2).strip()
if response:
print(f"[RESPONSE] {response}")
if "PONG" in response:
@@ -45,17 +81,10 @@ def test_device_responsive():
# Send STATUS
print("\n[SERIAL] Sending: STATUS")
ser.write(b"STATUS\n")
time.sleep(0.3)
status_lines = []
for _ in range(5):
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
raw_status = send_command(ser, "STATUS", wait_s=2.4)
status_lines = [line.strip() for line in raw_status.splitlines() if line.strip()]
for line in status_lines:
print(f"[STATUS] {line}")
status_lines.append(line)
if "scenario" in line.lower():
break
ser.close()
return len(status_lines) > 0
@@ -71,8 +100,7 @@ def capture_startup_logs():
print("="*60)
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=TIMEOUT)
time.sleep(0.5)
ser = open_serial_port()
print("[SERIAL] Waiting for boot logs (10 seconds)...")
print("-" * 60)
@@ -116,8 +144,8 @@ def monitor_animation_frames():
print("="*60)
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=TIMEOUT)
time.sleep(0.5)
ser = open_serial_port()
wait_for_boot_settle(ser)
print("[SERIAL] Monitoring for animation tick messages (5 seconds)...")
print("-" * 60)
@@ -168,27 +196,23 @@ def check_memory_stability():
print("="*60)
try:
ser = serial.Serial(SERIAL_PORT, BAUD_RATE, timeout=TIMEOUT)
time.sleep(0.5)
ser = open_serial_port()
wait_for_boot_settle(ser)
# Trigger multiple HELP commands to stress test
print("[SERIAL] Sending 5 HELP commands to monitor memory stability...")
for i in range(5):
ser.write(b"HELP\n")
time.sleep(0.2)
line = ser.readline(1024).decode('utf-8', errors='ignore').strip()
if "command" in line.lower():
raw = send_command(ser, "HELP", wait_s=2.0)
line = raw.strip()
if "command" in line.lower() or "CMDS " in line:
print(f" HELP #{i+1}: Command list received")
# Send STATUS to check current memory
print("\n[SERIAL] Final STATUS check...")
ser.write(b"STATUS\n")
time.sleep(0.3)
for _ in range(5):
line = ser.readline(1024).decode('utf-8', errors='ignore').strip()
raw_status = send_command(ser, "STATUS", wait_s=2.4)
for line in raw_status.splitlines():
line = line.strip()
if line:
print(f" {line}")
@@ -201,8 +225,20 @@ def check_memory_stability():
return False
def main():
global SERIAL_PORT, BAUD_RATE, TIMEOUT
parser = argparse.ArgumentParser(description="Phase 9 UI validation over serial.")
parser.add_argument("--port", default=SERIAL_PORT)
parser.add_argument("--baud", type=int, default=BAUD_RATE)
parser.add_argument("--timeout", type=float, default=TIMEOUT)
args = parser.parse_args()
if not args.port:
parser.error("--port or ZACUS_SERIAL_PORT is required")
SERIAL_PORT = args.port
BAUD_RATE = args.baud
TIMEOUT = args.timeout
print("\n" + ""*60)
print(" Phase 9: Device UI Validation & Touch Input Setup")
print(" Phase 9: Device UI Validation")
print(""*60)
results = {
+16 -9
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
@@ -100,11 +101,15 @@ class SerialRunner:
self.ser: Optional[serial.Serial] = None
def __enter__(self) -> "SerialRunner":
self.ser = serial.Serial(self.port, self.baud, timeout=self.timeout)
self.ser = serial.Serial()
self.ser.port = self.port
self.ser.baudrate = self.baud
self.ser.timeout = self.timeout
self.ser.write_timeout = 1.0
self.ser.dtr = False
self.ser.rts = False
time.sleep(0.8)
self.ser.reset_input_buffer()
self.ser.open()
time.sleep(0.25)
return self
def __exit__(self, exc_type, exc, tb) -> None:
@@ -127,7 +132,7 @@ class SerialRunner:
time.sleep(0.03)
return "".join(chunks)
def wait_for_boot_settle(self, timeout_s: float = 30.0, quiet_s: float = 1.6) -> None:
def wait_for_boot_settle(self, timeout_s: float = 45.0, quiet_s: float = 2.0) -> None:
if self.ser is None:
raise RuntimeError("serial connection closed")
deadline = time.time() + timeout_s
@@ -145,14 +150,14 @@ class SerialRunner:
self.wait_for_boot_settle()
last_raw = ""
for _ in range(retries):
raw = self.send("PING", wait_s=1.6)
raw = self.send("PING", wait_s=2.2)
if "PONG" in raw:
return
if "UNKNOWN PING" in raw:
# Command path is active even if PING isn't mapped as expected.
return
last_raw = raw
time.sleep(0.25)
time.sleep(0.4)
raise RuntimeError(
"serial command channel unavailable after PING retries "
f"(port={self.port}). Last output:\n{last_raw}"
@@ -313,12 +318,14 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Sprint 1 utility contract + endurance checks.")
parser.add_argument("--mode", choices=("serial", "http"), default="serial")
parser.add_argument("--cycles", type=int, default=20)
parser.add_argument("--port", default="/dev/cu.usbmodem5AB90753301")
parser.add_argument("--port", default=os.environ.get("ZACUS_SERIAL_PORT", ""))
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--timeout", type=float, default=2.0)
parser.add_argument("--base-url", default="http://192.168.4.1")
parser.add_argument("--token", default="")
parser.add_argument("--base-url", default=os.environ.get("ZACUS_BASE_URL", "http://192.168.4.1"))
parser.add_argument("--token", default=os.environ.get("ZACUS_WEB_TOKEN", ""))
args = parser.parse_args()
if args.mode == "serial" and not args.port:
parser.error("--port or ZACUS_SERIAL_PORT is required in serial mode")
started = time.time()
try:
+17 -10
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
@@ -100,11 +101,15 @@ class SerialRunner:
self.ser: Optional[serial.Serial] = None
def __enter__(self) -> "SerialRunner":
self.ser = serial.Serial(self.port, self.baud, timeout=self.timeout)
self.ser = serial.Serial()
self.ser.port = self.port
self.ser.baudrate = self.baud
self.ser.timeout = self.timeout
self.ser.write_timeout = 1.0
self.ser.dtr = False
self.ser.rts = False
time.sleep(0.8)
self.ser.reset_input_buffer()
self.ser.open()
time.sleep(0.25)
return self
def __exit__(self, exc_type, exc, tb) -> None:
@@ -127,7 +132,7 @@ class SerialRunner:
time.sleep(0.03)
return "".join(chunks)
def wait_for_boot_settle(self, timeout_s: float = 60.0, quiet_s: float = 3.5) -> None:
def wait_for_boot_settle(self, timeout_s: float = 60.0, quiet_s: float = 2.5) -> None:
if self.ser is None:
raise RuntimeError("serial connection closed")
deadline = time.time() + timeout_s
@@ -145,14 +150,14 @@ class SerialRunner:
self.wait_for_boot_settle()
last_raw = ""
for _ in range(retries):
raw = self.send("PING", wait_s=1.6)
raw = self.send("PING", wait_s=2.2)
if "PONG" in raw:
return
if "UNKNOWN PING" in raw:
# Command path is active even if PING isn't mapped as expected.
return
last_raw = raw
time.sleep(0.25)
time.sleep(0.4)
raise RuntimeError(
"serial command channel unavailable after PING retries "
f"(port={self.port}). Last output:\n{last_raw}"
@@ -232,7 +237,7 @@ def run_serial_cycles(port: str, baud: int, timeout: float, cycles: int) -> None
runner.wait_for_command_channel()
for app_id in APPS:
for cycle in range(1, cycles + 1):
open_out = runner.send(f"APP_OPEN {app_id} sprint2", wait_s=0.9)
open_out = runner.send(f"APP_OPEN {app_id} sprint2", wait_s=2.4)
if "ACK APP_OPEN ok=1" not in open_out:
fallback = runner.status()
if fallback.state not in ("running", "starting") or (
@@ -329,12 +334,14 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Sprint 2 capture contract + endurance checks.")
parser.add_argument("--mode", choices=("serial", "http"), default="serial")
parser.add_argument("--cycles", type=int, default=10)
parser.add_argument("--port", default="/dev/cu.usbmodem5AB90753301")
parser.add_argument("--port", default=os.environ.get("ZACUS_SERIAL_PORT", ""))
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--timeout", type=float, default=2.0)
parser.add_argument("--base-url", default="http://192.168.4.1")
parser.add_argument("--token", default="")
parser.add_argument("--base-url", default=os.environ.get("ZACUS_BASE_URL", "http://192.168.4.1"))
parser.add_argument("--token", default=os.environ.get("ZACUS_WEB_TOKEN", ""))
args = parser.parse_args()
if args.mode == "serial" and not args.port:
parser.error("--port or ZACUS_SERIAL_PORT is required in serial mode")
started = time.time()
try:
+347
View File
@@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Sprint 3 audio contract + endurance checks (audio_player, audiobook_player, kids_music).
Modes:
- Serial: APP_OPEN / APP_ACTION / APP_STATUS / APP_CLOSE
- HTTP: /api/apps/open|action|status|close
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
import requests
try:
import serial
except Exception: # pragma: no cover - optional until serial mode is used
serial = None
APPS: Sequence[str] = ("audio_player", "audiobook_player", "kids_music")
# (action_name, payload)
APP_ACTIONS: Dict[str, Sequence[Tuple[str, str]]] = {
"audio_player": (
("play", "/apps/audio_player/audio/default.mp3"),
("pause", ""),
("resume", ""),
("set_volume", "12"),
("stop", ""),
),
"audiobook_player": (
("open_book", "/apps/audiobook_player/audio/default.mp3"),
("play", ""),
("seek_ms", "12000"),
("bookmark_set", "12000"),
("bookmark_go", ""),
("pause", ""),
("stop", ""),
),
"kids_music": (
("play", "/apps/kids_music/audio/default.mp3"),
("pause", ""),
("resume", ""),
("stop", ""),
),
}
@dataclass
class Status:
app_id: str
state: str
mode: str
source: str
last_error: str
last_event: str
APP_STATUS_RE = re.compile(
r"APP_STATUS id=(?P<id>\S*) state=(?P<state>\S+) mode=(?P<mode>\S+) "
r"source=(?P<source>\S+) err=(?P<err>.*?) event=(?P<event>.*?) tick=(?P<tick>\d+) "
r"missing=(?P<missing>0x[0-9A-Fa-f]+)"
)
def parse_serial_status(raw: str) -> Optional[Status]:
for line in reversed(raw.splitlines()):
if "APP_STATUS " not in line:
continue
match = APP_STATUS_RE.search(line.strip())
if match is None:
continue
return Status(
app_id=match.group("id"),
state=match.group("state"),
mode=match.group("mode"),
source=match.group("source"),
last_error=match.group("err"),
last_event=match.group("event"),
)
return None
class SerialRunner:
def __init__(self, port: str, baud: int, timeout: float) -> None:
if serial is None:
raise RuntimeError("pyserial is required for serial mode")
self.port = port
self.baud = baud
self.timeout = timeout
self.ser: Optional[serial.Serial] = None
def __enter__(self) -> "SerialRunner":
self.ser = serial.Serial()
self.ser.port = self.port
self.ser.baudrate = self.baud
self.ser.timeout = self.timeout
self.ser.write_timeout = 1.0
self.ser.dtr = False
self.ser.rts = False
self.ser.open()
time.sleep(0.25)
return self
def __exit__(self, exc_type, exc, tb) -> None:
if self.ser is not None:
self.ser.close()
self.ser = None
def send(self, command: str, wait_s: float = 1.2) -> str:
if self.ser is None:
raise RuntimeError("serial connection closed")
self.ser.reset_input_buffer()
self.ser.write((command + "\r\n").encode("utf-8"))
self.ser.flush()
deadline = time.time() + wait_s
chunks: List[str] = []
while time.time() < deadline:
if self.ser.in_waiting > 0:
data = self.ser.read(self.ser.in_waiting).decode("utf-8", errors="ignore")
chunks.append(data)
time.sleep(0.03)
return "".join(chunks)
def wait_for_boot_settle(self, timeout_s: float = 45.0, quiet_s: float = 2.0) -> None:
if self.ser is None:
raise RuntimeError("serial connection closed")
deadline = time.time() + timeout_s
last_rx = time.time()
while time.time() < deadline:
if self.ser.in_waiting > 0:
_ = self.ser.read(self.ser.in_waiting)
last_rx = time.time()
elif (time.time() - last_rx) >= quiet_s:
return
time.sleep(0.04)
def wait_for_command_channel(self, retries: int = 8) -> None:
self.wait_for_boot_settle()
last_raw = ""
for _ in range(retries):
raw = self.send("PING", wait_s=2.2)
if "PONG" in raw or "UNKNOWN PING" in raw:
return
last_raw = raw
time.sleep(0.4)
raise RuntimeError(
"serial command channel unavailable after PING retries "
f"(port={self.port}). Last output:\n{last_raw}"
)
def status(self) -> Status:
last_raw = ""
for _ in range(4):
raw = self.send("APP_STATUS", wait_s=1.4)
parsed = parse_serial_status(raw)
if parsed is not None:
return parsed
last_raw = raw
time.sleep(0.25)
raise RuntimeError(f"APP_STATUS parse failed. Raw output:\n{last_raw}")
class ApiRunner:
def __init__(self, base_url: str, token: str) -> None:
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
if token:
self.session.headers["Authorization"] = f"Bearer {token}"
self.session.headers["Content-Type"] = "application/json"
def post(self, path: str, payload: dict) -> dict:
resp = self.session.post(f"{self.base_url}{path}", data=json.dumps(payload), timeout=8)
resp.raise_for_status()
return resp.json()
def get(self, path: str) -> dict:
resp = self.session.get(f"{self.base_url}{path}", timeout=8)
resp.raise_for_status()
return resp.json()
def status(self) -> Status:
body = self.get("/api/apps/status")
runtime = body.get("runtime", {})
return Status(
app_id=str(runtime.get("running_id", "")),
state=str(runtime.get("state", "")),
mode=str(runtime.get("mode", "")),
source=str(runtime.get("source", "")),
last_error=str(runtime.get("last_error", "")),
last_event=str(runtime.get("last_event", "")),
)
def wait_until_ready(self, timeout_s: float = 45.0) -> None:
deadline = time.time() + timeout_s
last_error = ""
while time.time() < deadline:
try:
self.get("/api/apps/status")
return
except Exception as exc: # pragma: no cover - network/device dependent
last_error = str(exc)
time.sleep(1.0)
raise RuntimeError(
f"API unreachable at {self.base_url} after {timeout_s:.0f}s (last_error={last_error})"
)
def assert_status_running(status: Status, app_id: str) -> None:
if status.state not in ("running", "starting"):
raise RuntimeError(f"{app_id}: unexpected state after open: {status.state}")
if status.app_id and status.app_id != app_id:
raise RuntimeError(f"{app_id}: running_id mismatch: got={status.app_id}")
def assert_status_idle(status: Status) -> None:
if status.state != "idle":
raise RuntimeError(f"expected idle after close, got={status.state}")
def run_serial_cycles(port: str, baud: int, timeout: float, cycles: int) -> None:
with SerialRunner(port=port, baud=baud, timeout=timeout) as runner:
runner.wait_for_command_channel()
for app_id in APPS:
for cycle in range(1, cycles + 1):
open_out = runner.send(f"APP_OPEN {app_id} sprint3", wait_s=0.9)
if "ACK APP_OPEN ok=1" not in open_out:
fallback = runner.status()
if fallback.state not in ("running", "starting") or (
fallback.app_id and fallback.app_id != app_id
):
raise RuntimeError(f"{app_id} cycle#{cycle}: APP_OPEN failed\n{open_out}")
st = runner.status()
assert_status_running(st, app_id)
for action, payload in APP_ACTIONS[app_id]:
cmd = f"APP_ACTION {action}" + (f" {payload}" if payload else "")
action_out = runner.send(cmd, wait_s=0.9)
if "ACK APP_ACTION ok=1" not in action_out:
st_mid = runner.status()
if st_mid.state not in ("running", "starting"):
raise RuntimeError(
f"{app_id} cycle#{cycle}: APP_ACTION failed ({cmd})\n{action_out}"
)
time.sleep(0.1 if action in ("play", "resume") else 0.05)
st_after = runner.status()
if st_after.last_error not in ("", "none"):
raise RuntimeError(
f"{app_id} cycle#{cycle}: runtime error={st_after.last_error} "
f"event={st_after.last_event}"
)
close_out = runner.send("APP_CLOSE sprint3_cycle", wait_s=0.9)
if "ACK APP_CLOSE ok=1" not in close_out:
st_close = runner.status()
if st_close.state != "idle":
raise RuntimeError(f"{app_id} cycle#{cycle}: APP_CLOSE failed\n{close_out}")
st_idle = runner.status()
assert_status_idle(st_idle)
print(f"[SERIAL] {app_id} cycle {cycle}/{cycles} OK")
def run_api_cycles(base_url: str, token: str, cycles: int) -> None:
runner = ApiRunner(base_url=base_url, token=token)
runner.wait_until_ready()
for app_id in APPS:
for cycle in range(1, cycles + 1):
opened = runner.post(
"/api/apps/open",
{"id": app_id, "mode": "sprint3", "source": "sprint3_test"},
)
if not opened.get("ok", False):
raise RuntimeError(f"{app_id} cycle#{cycle}: /open failed {opened}")
st = runner.status()
assert_status_running(st, app_id)
for action, payload in APP_ACTIONS[app_id]:
payload_json = {
"id": app_id,
"action": action,
"payload": payload,
"content_type": "text/plain",
}
acted = runner.post("/api/apps/action", payload_json)
if not acted.get("ok", False):
raise RuntimeError(f"{app_id} cycle#{cycle}: /action failed {acted}")
time.sleep(0.1 if action in ("play", "resume") else 0.05)
st_after = runner.status()
if st_after.last_error not in ("", "none"):
raise RuntimeError(
f"{app_id} cycle#{cycle}: runtime error={st_after.last_error} "
f"event={st_after.last_event}"
)
closed = runner.post("/api/apps/close", {"id": app_id, "reason": "sprint3_cycle"})
if not closed.get("ok", False):
raise RuntimeError(f"{app_id} cycle#{cycle}: /close failed {closed}")
st_idle = runner.status()
assert_status_idle(st_idle)
print(f"[HTTP] {app_id} cycle {cycle}/{cycles} OK")
def main() -> int:
parser = argparse.ArgumentParser(description="Sprint 3 audio contract + endurance checks.")
parser.add_argument("--mode", choices=("serial", "http"), default="serial")
parser.add_argument("--cycles", type=int, default=10)
parser.add_argument("--port", default=os.environ.get("ZACUS_SERIAL_PORT", ""))
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--timeout", type=float, default=2.0)
parser.add_argument("--base-url", default=os.environ.get("ZACUS_BASE_URL", "http://192.168.4.1"))
parser.add_argument("--token", default=os.environ.get("ZACUS_WEB_TOKEN", ""))
args = parser.parse_args()
if args.mode == "serial" and not args.port:
parser.error("--port or ZACUS_SERIAL_PORT is required in serial mode")
started = time.time()
try:
if args.mode == "serial":
run_serial_cycles(args.port, args.baud, args.timeout, args.cycles)
else:
run_api_cycles(args.base_url, args.token, args.cycles)
except Exception as exc:
print(f"FAILED: {exc}")
return 1
elapsed = time.time() - started
print(f"SUCCESS: sprint3 audio cycles completed ({args.cycles} cycles/app) in {elapsed:.1f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -1,6 +1,6 @@
# Multiplexage des broches Attention
Cible matérielle: **ESP32-S3-WROOM-1-N16R8**.
Cible matérielle: **Freenove ESP32-S3 WROOM N16R8**.
Certaines broches sont partagées entre plusieurs fonctions:
- GPIO 4: utilisé pour le rétroéclairage (BL) et le bouton 3 (BTN3)
+39 -343
View File
@@ -1,357 +1,53 @@
# ui_freenove_allinone
---
# 🎛️ Zacus Firmware Freenove All-in-One
Firmware principal Freenove pour `ESP32_ZACUS`.
![Funk](https://media.giphy.com/media/3o7TKtnuHOHHUjR38Y/giphy.gif)
## Profil matériel release
---
- Carte: Freenove FNK0102H.
- Board profile PlatformIO: `freenove_esp32_s3_wroom` (`N16R8`, 16 MB flash / 8 MB PSRAM).
- Écran: ST7796 320x480, rotation `1`.
- Entrée officielle: switch analogique 5 directions sur GPIO19.
- Touch: non supporté dans le chemin release.
## 📝 Description
## Mapping principal
Bienvenue dans le cockpit le plus funky du multivers! Ce firmware fusionne UI, audio, scénarios et hardware sur la carte Freenove Media Kit (ESP32). Ici, chaque pixel danse, chaque bouton groove, et chaque boot est une fête.
| Fonction | GPIO |
| --- | --- |
| TFT SCK | 47 |
| TFT MOSI | 21 |
| TFT DC | 45 |
| TFT RESET | 20 |
| TFT BL | 2 |
| Boutons analogiques | 19 |
| SD CMD / CLK / D0 | 38 / 39 / 40 |
| I2S WS / BCK / DOUT | 41 / 42 / 1 |
| Caméra XCLK / SIOD / SIOC | 15 / 4 / 5 |
> *"Si tu entends un son rétro ou vois un plasma violet, cest normal. Si le microcontrôleur se met à rapper, cest probablement un bug… ou un feature caché."*
---
## 🕹️ Mapping hardware (ESP32-S3 Freenove Media Kit)
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|------------------|-----------------|-----------------------|--------------------------------|
| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) |
| TFT MOSI | GPIO 21 | MOSI | SPI écran |
| TFT MISO | -1 | MISO | non utilisé |
| TFT CS | -1 | CS | câblage board intégré |
| TFT DC | GPIO 45 | DC | Data/Command écran |
| TFT RESET | GPIO 20 | RESET | Reset écran |
| TFT BL | GPIO 2 | BL | Rétroéclairage |
| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) |
| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel |
| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques |
| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 |
| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 |
| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 |
| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions |
**Remarques**:
- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE <idx>` puis `AUDIO_TEST`.
- Profils fournis: `0=sketch19`, `1=swap_bck_ws`, `2=dout2_alt`.
- Le tactile est désactivé par défaut (`FREENOVE_HAS_TOUCH=0`).
---
## 📦 Contenu du dossier
- Sources : `src/` (app, ui, audio, storage, camera, drivers, system)
- Polices : `src/ui/fonts/`
- Layouts d’écran : `src/ui/screens/`
- Partition custom : `partitions/freenove_esp32s3_app6mb_fs6mb.csv`
- Mapping hardware détaillé (voir pour descendre plus haut )
---
## 🚀 Installation & usage
1. Clone ce repo, chausse tes lunettes de soleil.
2. Va dans `hardware/firmware` et build comme un DJ:
```sh
pio run -e freenove_allinone
pio run -e freenove_allinone -t upload --upload-port <PORT>
```
3. Pour la version ESP32-S3 (partition 6MB app / 6MB FS):
```sh
pio run -e freenove_esp32s3
pio run -e freenove_esp32s3 -t buildfs
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
```
4. Branche, allume, et laisse la magie opérer.
---
## 🧩 Fonctionnalités qui groovent
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
- Exécution de scénarios (lecture, transitions, actions, audio)
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
- Gestion boutons et tactile (événements, mapping, callbacks)
- Fallback robuste si fichier manquant (scénario par défaut)
- Génération de logs et artefacts (logs/, artifacts/)
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
- Documentation et onboarding synchronisés
- Mode autonome (pas besoin dESP32 séparé)
> *"Si tu rates un bouton, cest que tu danses trop fort. Si tu vois un plasma violet, cest que tu es dans le groove."*
---
## 🛠️ Modules principaux
- `audio_manager` : fait swinguer laudio (lecture, stop, état)
- `scenario_manager` : enchaîne les étapes comme un DJ
- `ui_manager` : LVGL, écrans dynamiques, FX visuels
- `storage_manager` : LittleFS (init, vérif, groove des assets)
- `button_manager` : boutons physiques, pour les vrais
- `touch_manager` : tactile XPT2046, pour les DJ du futur
---
## 🦄 Funky extras & notes
- Firmware expérimental: fusion audio + UI, mode "party hard" activé
- Pour la compatibilité UI Link, prévoir un mode optionnel
- Si tu veux une intro Amiga92, active la scène `SCENE_WIN_ETAPE` et laisse-toi porter par le FX timeline (plasma, starfield, boingball…)
- Pour tester la scène MP3: `SCENE_MP3_PLAYER` (overlay AmigaAMP, scan `/music`)
- Pour la caméra: `SCENE_CAMERA_SCAN` (Win311 overlay, boutons = SNAP/SAVE/GALLERY/DELETE/CLOSE)
- Pour tout le reste, consulte les logs, et si tu comprends tout du premier coup, tu gagnes un badge "Zacus Funk Master".
---
## 🤝 Contribuer
Merci de lire [../../../../../../CONTRIBUTING.md](../../../../../../CONTRIBUTING.md) avant toute PR. Les pull requests avec punchlines sont encouragées.
---
## 👤 Contact
Pour toute question, suggestion, ou battle de funk, ouvre une issue GitHub ou contacte lauteur principal:
- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare)
> *"Ce firmware a été validé par un oscilloscope, un grille-pain, et un synthé vintage. Si tu trouves un bug, cest peut-être un easter egg ou feat foirée."*
---
# Firmware Freenove Media Kit All-in-One
## Plan dintégration complète (couverture specs)
- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/)
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
- Exécution de scénarios (lecture, transitions, actions, audio)
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
- Gestion boutons et tactile (événements, mapping, callbacks)
- Fallback robuste si fichier manquant (scénario par défaut)
- Génération de logs et artefacts (logs/, artifacts/)
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
- Documentation et onboarding synchronisés
## Structure modulaire
- `src/app/*` : orchestration runtime (`main`, `scenario_manager`, coordinator serie)
- `src/ui/*` : LVGL scenes, fonts, FX helpers (`ui_manager`, `ui_fonts`, `ui/fx`)
- `src/audio/*` : gestion audio runtime (`audio_manager`)
- `src/storage/*` : LittleFS/SD et resolution assets (`storage_manager`)
- `src/camera/*` : camera runtime (`camera_manager`)
- `src/drivers/*` : board I/O (input, board, display HAL + SPI bus manager)
- `src/system/*` : metrics, boot report, reseau/media wrappers, task topology
- headers legacy (`include/*_manager.h`) conserves en facades vers les headers modulaires
Ce firmware combine:
- Les fonctions audio/scénario (type ESP32 Audio)
- LUI locale (affichage, interaction, tactile, boutons)
- Le tout sur un seul microcontrôleur (RP2040 ou ESP32 selon le kit)
## Fonctionnalités
- Lecture audio, gestion scénario, LittleFS
- Affichage TFT tactile (LVGL)
- Boutons physiques, capteurs, extensions
- Mode autonome (pas besoin dESP32 séparé)
## Modules principaux
- audio_manager : gestion audio (lecture, stop, état)
- scenario_manager : gestion scénario (étapes, transitions)
- ui_manager : gestion UI (LVGL, écrans dynamiques)
- storage_manager : gestion LittleFS (init, vérification)
- button_manager : gestion boutons physiques
- touch_manager : gestion tactile XPT2046
## Validation hardware
- Compiler et flasher sur le Freenove Media Kit
- Vérifier laffichage, la réactivité tactile et boutons
- Tester la lecture audio (fichiers dans /data/)
- Consulter les logs série pour le suivi dexécution
## Artefacts
- Firmware compilé (.bin)
- Logs de test hardware (logs/)
- Rapport de compatibilité assets LittleFS
Le détail complet est maintenu dans `../docs/FNK0102H_SOURCE_OF_TRUTH.md`.
## Build
Depuis `hardware/firmware`:
Depuis la racine du repo:
```sh
pio run -e freenove_allinone
pio run -e freenove_allinone -t upload --upload-port <PORT>
```bash
source .venv/bin/activate
python -m platformio run -e freenove_esp32s3_full_with_ui
python -m platformio run -e freenove_esp32s3_full_with_ui -t buildfs
python -m platformio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port <PORT>
python -m platformio run -e freenove_esp32s3_full_with_ui -t upload --upload-port <PORT>
```
## Build Freenove ESP32-S3 (partition 6MB app / 6MB FS)
## Boot modes
```sh
pio run -e freenove_esp32s3
pio run -e freenove_esp32s3 -t buildfs
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
- Normal: UI complète, caméra, audio et réseau autorisés.
- `safe diagnostic mode`: affichage minimal + boutons + série uniquement si la PSRAM n'est pas détectée.
## Tests
```bash
python tests/sprint1_utility_contract.py --mode serial --port <PORT>
python tests/sprint2_capture_contract.py --mode serial --port <PORT>
python tests/sprint3_audio_contract.py --mode serial --port <PORT>
python tests/phase9_ui_validation.py --port <PORT>
```
- Partition custom active: `partitions/freenove_esp32s3_app6mb_fs6mb.csv`.
- Le bundle story complet n'est plus embarque dans le firmware: `buildfs/uploadfs` est requis pour charger le contenu complet (screens/audio/actions/apps).
- Fallback embarque minimal conserve: `APP_WIFI` + scenario `DEFAULT` minimal.
## Norme embarquee (audit + refacto)
- Standard projet: `docs/skills/EMBEDDED_CPP_OO_ESP32S3_PIO_ARDUINO.md`.
- Toute revue technique Freenove doit citer cette norme pour les risques temps reel, memoire, concurrence et style OO.
## Provisioning Wi-Fi + Auth WebUI
- Au boot sans credentials NVS:
- mode setup AP actif;
- endpoints setup ouverts: `GET /api/provision/status`, `POST /api/wifi/connect`, `POST /api/network/wifi/connect`.
- Hors setup:
- auth Bearer requise sur `/api/*`:
- `Authorization: Bearer <token>`.
- Provisioning persistant:
- API: `POST /api/wifi/connect` (ou `/api/network/wifi/connect`) avec `persist=1`.
- Serie: `WIFI_PROVISION <ssid> <pass>`.
- Rotation token:
- Serie: `AUTH_TOKEN_ROTATE [token]`.
- Outils shell:
- `tools/dev/healthcheck_wifi.sh` et `tools/dev/rtos_wifi_health.sh` supportent `ZACUS_WEB_TOKEN`.
## LVGL graphics stack (ESP32-S3)
- Le runtime Freenove (`env:freenove_esp32s3*`) supporte:
- `LV_COLOR_DEPTH=8` (RGB332) avec conversion RGB565 au flush.
- draw buffers lignes en double-buffer.
- flush DMA asynchrone (overlap draw/transfert) avec fallback sync.
- Flags principaux (`platformio.ini`):
- `UI_COLOR_256`, `UI_COLOR_565`, `UI_FORCE_THEME_256`
- `UI_DRAW_BUF_LINES`, `UI_DRAW_BUF_IN_PSRAM`
- `UI_DMA_FLUSH_ASYNC`, `UI_DMA_TRANS_BUF_LINES`
- `UI_FULL_FRAME_BENCH`, `UI_LV_MEM_SIZE_KB`
- Commandes debug serie:
- `UI_GFX_STATUS`
- `UI_MEM_STATUS`
- Documentation associee:
- `docs/ui/graphics_stack.md`
- `docs/ui/lvgl_memory_budget.md`
- `docs/ui/fonts_fr.md`
## Mapping hardware
- Voir `include/ui_freenove_config.h` pour ladaptation des pins
- Schéma de branchement: se référer à la doc Freenove
## Notes
- Ce firmware est expérimental et fusionne les logiques audio + UI.
- Pour la compatibilité UI Link, prévoir un mode optionnel.
## Intro Amiga92 (`SCENE_WIN_ETAPE`)
- Activation: l'intro A/B/C est lancee automatiquement quand `screen_scene_id == SCENE_WIN_ETAPE`.
- Mode runtime: `FX_ONLY_V9` avec rendu timeline FX (plasma/starfield/rasterbars + tunnel3d/rotozoom/wirecube + boingball) et overlay LVGL conserve.
- Sequence timeline verrouillee: `A(30000ms) -> B(15000ms) -> C(20000ms) -> C loop`.
- Mapping presets (default):
- A: `demo`
- B: `winner`
- C: `boingball`
- Font scroller default: `italic`.
- BPM default: `125`.
- Boing shadow path: assembleur S3 active par defaut (`UI_BOING_SHADOW_ASM=1`) avec fallback C automatique.
- Log de boot FX: `boing_shadow_path=asm|c`.
### Overrides runtime (TXT + JSON)
- Priorite de lecture:
1) `/ui/scene_win_etape.json`
2) `/SCENE_WIN_ETAPE.json`
3) `/ui/SCENE_WIN_ETAPE.json`
4) `/ui/scene_win_etape.txt`
- Cles supportees:
- `A_MS`, `B_MS`, `C_MS`
- `FX_PRESET_A`, `FX_PRESET_B`, `FX_PRESET_C` (`demo|winner|fireworks|boingball`)
- `FX_MODE_A`, `FX_MODE_B`, `FX_MODE_C` (`classic|starfield3d|dotsphere3d|voxel|raycorridor`)
- `FX_SCROLL_TEXT_A`, `FX_SCROLL_TEXT_B`, `FX_SCROLL_TEXT_C`
- `FX_SCROLL_FONT` (`basic|bold|outline|italic`)
- `FX_BPM` (`60..220`)
- Rupture de compatibilite volontaire:
- anciennes cles FX `FX_3D`, `FX_3D_QUALITY`, `FONT_MODE` ne sont plus prises en charge dans ce flux.
Exemples:
- JSON: `data/SCENE_WIN_ETAPE.json`
- TXT: `data/ui/scene_win_etape.txt`
### Perf notes
- Tick fixe: `42 ms` (`~24 FPS` cible), `dt` clamp pour robustesse.
- Zero allocation par frame dans la boucle intro (`tickIntro`).
- Blit LGFX: fast-path 2x active (`UI_FX_BLIT_FAST_2X=1`) si ratio source/ecran exact, fallback scaler general sinon.
- Caps objets scene: `<=140` (petit ecran), `<=260` (grand ecran).
- Pools fixes:
- stars: `48`
- fireworks: `72`
- wave glyph+shadow: `64 + 64`
- `UI_GFX_STATUS` expose `fx_fps/fx_frames/fx_skip_busy` + compteurs `flush_block/overflow` pour diagnostiquer les saccades LVGL/FX.
## Scenes demoscene exposees
- IDs canoniques ajoutes: `SCENE_WINNER`, `SCENE_FIREWORKS`.
- Fichiers data:
- `data/story/screens/SCENE_WINNER.json`
- `data/story/screens/SCENE_FIREWORKS.json`
- Registry story: `hardware/libs/story/src/resources/screen_scene_registry.cpp`.
## Scene lecteur MP3 (`SCENE_MP3_PLAYER`)
- Scene canonique: `SCENE_MP3_PLAYER`.
- Aliases supportes: `SCENE_AUDIO_PLAYER`, `SCENE_MP3`.
- Data scene: `data/story/screens/SCENE_MP3_PLAYER.json`.
- Mode runtime: overlay LVGL "AmigaAMP" + backend `AudioPlayerService` (scan `/music`, fallback `/audio/music`, fallback `/audio`).
- Arbitrage audio:
- en scene MP3, l'audio scenario est suspendu;
- en sortie de scene MP3, pipeline audio scenario restaure.
- Commandes serie:
- `AMP_SHOW`, `AMP_HIDE`, `AMP_TOGGLE`
- `AMP_SCAN`, `AMP_PLAY <idx|path>`, `AMP_NEXT`, `AMP_PREV`, `AMP_STOP`, `AMP_STATUS`
## Scene camera recorder (`SCENE_CAMERA_SCAN`)
- Binding scene: entree `SCENE_CAMERA_SCAN` -> session recorder camera active (`RGB565/QVGA`) + overlay Win311 visible.
- Sortie scene: overlay masque, frame gelee purgee, retour mode snapshot legacy (`JPEG`).
- Mapping boutons physiques:
- `BTN1`: `SNAP/LIVE`
- `BTN2`: `SAVE`
- `BTN3`: `GALLERY` (appui long: `NEXT`)
- `BTN4`: `DELETE`
- `BTN5`: `CLOSE` overlay
- Commandes serie:
- `CAM_UI_SHOW`, `CAM_UI_HIDE`, `CAM_UI_TOGGLE`
- `CAM_REC_SNAP`, `CAM_REC_SAVE [auto|bmp|jpg|raw]`
- `CAM_REC_GALLERY`, `CAM_REC_NEXT`, `CAM_REC_DELETE`, `CAM_REC_STATUS`
- Ownership runtime:
- pendant `SCENE_CAMERA_SCAN`, les boutons physiques ne sont pas forwardes au scenario;
- commandes legacy `CAM_ON/CAM_OFF/CAM_SNAPSHOT` renvoient `camera_busy_recorder_owner`.
### Visual verification mode
- Build flags (env `freenove_esp32s3`) pour tests scene:
- `UI_FULL_FRAME_BENCH=1`
- `UI_DEMO_AUTORUN_WIN_ETAPE=1`
- Quand ces flags sont actifs:
- la scene `SCENE_WIN_ETAPE` demarre automatiquement au boot;
- la phase C continue en boucle tant que la scene reste active;
- `UI_GFX_STATUS` permet de verifier le mode runtime (depth/full_frame/source).
### References consulted
- https://www.pouet.net/prodlist.php?type%5B0%5D=cracktro
- https://www.youtube.com/results?search_query=amiga+cracktro
- https://www.youtube.com/results?search_query=amiga+demoscene+1992
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/hard_2.html
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/
- https://www.markwrobel.dk/project/amigamachinecode/
- https://www.markwrobel.dk/post/amiga-machine-code-letter3-copper-revisited/
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-starfield-effect/
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-wave/
- https://github.com/mrandreastoth/AmigaStyleDemo
@@ -0,0 +1,39 @@
// app_module.h - Interface for pluggable app modules.
#pragma once
#include <Arduino.h>
struct RuntimeServices;
struct AppEntry;
enum class AppModuleState : uint8_t {
kIdle = 0,
kStarting,
kRunning,
kError,
};
class IAppModule {
public:
virtual ~IAppModule() = default;
virtual const char* id() const = 0;
// Called once when APP_OPEN arrives. Return true if launch succeeded.
virtual bool onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) = 0;
// Called once when APP_CLOSE arrives.
virtual void onClose(const char* reason, RuntimeServices* services) = 0;
// Called every frame while the app is running.
virtual void onTick(uint32_t now_ms, RuntimeServices* services) = 0;
// Dispatch an APP_ACTION. Return true if handled.
virtual bool onAction(const char* action, const char* payload, RuntimeServices* services) = 0;
// Current module state.
virtual AppModuleState state() const = 0;
// Last error (may be empty).
virtual const char* lastError() const { return ""; }
};
+36 -15
View File
@@ -1,25 +1,46 @@
// app_registry.h - app catalog loaded from LittleFS with embedded fallback.
// app_registry.h - App catalog loaded from registry.json.
#pragma once
#include <vector>
#include <Arduino.h>
#include "app/app_runtime_types.h"
struct AppEntry {
char id[24] = {0};
char title[32] = {0};
char category[16] = {0};
char icon_path[48] = {0};
char entry_screen[40] = {0};
bool enabled = true;
uint8_t required_capabilities = 0U; // bitmask (lightweight)
};
class StorageManager;
// Capability bits for required_capabilities bitmask.
namespace AppCap {
constexpr uint8_t kAudioOut = 0x01U;
constexpr uint8_t kAudioIn = 0x02U;
constexpr uint8_t kStorageFs = 0x04U;
constexpr uint8_t kStorageSd = 0x08U;
constexpr uint8_t kCamera = 0x10U;
constexpr uint8_t kWifi = 0x20U;
constexpr uint8_t kGpuUi = 0x40U;
} // namespace AppCap
class AppRegistry {
public:
bool loadFromFs(const StorageManager& storage, const char* registry_path = "/apps/registry.json");
const AppDescriptor* find(const char* id) const;
std::vector<AppDescriptor> listByCategory(const char* category) const;
const std::vector<AppDescriptor>& descriptors() const;
static constexpr uint8_t kMaxApps = 24U;
AppRegistry() = default;
bool loadFromJson(const char* json_path);
bool loadFromBuffer(const char* json_buffer, size_t len);
uint8_t count() const { return count_; }
const AppEntry* entry(uint8_t index) const;
const AppEntry* findById(const char* id) const;
uint8_t enabledCount() const;
const AppEntry* enabledEntry(uint8_t visible_index) const;
private:
bool loadFromJson(const char* json_text);
void loadFallbackCatalog();
static uint32_t parseCapabilityMask(const char* csv_caps);
static void copyText(char* out, size_t out_size, const char* text);
std::vector<AppDescriptor> descriptors_;
AppEntry entries_[kMaxApps];
uint8_t count_ = 0U;
};
@@ -1,31 +1,67 @@
// app_runtime_manager.h - app lifecycle and capability gate.
// app_runtime_manager.h - App lifecycle: open/close/action state machine.
#pragma once
#include <memory>
#include <Arduino.h>
#include "app/app_module.h"
#include "app/app_registry.h"
#include "app/app_runtime_types.h"
struct RuntimeServices;
class AppRuntimeManager {
public:
enum class State : uint8_t {
kIdle = 0,
kStarting,
kRunning,
kClosing,
kError,
};
struct Snapshot {
State state = State::kIdle;
const char* app_id = "";
const char* entry_screen = "";
const char* last_error = "";
uint32_t opened_at_ms = 0U;
};
static constexpr uint8_t kMaxModules = 24U;
AppRuntimeManager() = default;
void configure(AppRegistry* registry, const AppContext& context);
bool startApp(const AppStartRequest& request, uint32_t now_ms);
bool stopApp(const AppStopRequest& request, uint32_t now_ms);
bool handleAction(const AppAction& action, uint32_t now_ms);
void begin(AppRegistry* registry, RuntimeServices* services);
// Register a module (call during setup, before any open).
bool registerModule(IAppModule* module);
// App lifecycle.
bool open(const char* app_id, const char* mode, uint32_t now_ms);
bool close(const char* reason, uint32_t now_ms);
bool action(const char* action_name, const char* payload);
void tick(uint32_t now_ms);
AppRuntimeStatus current() const;
const AppDescriptor* currentDescriptor() const;
// Status.
State state() const { return state_; }
Snapshot snapshot() const;
bool isRunning() const { return state_ == State::kRunning; }
bool isIdle() const { return state_ == State::kIdle; }
const char* currentAppId() const { return current_app_id_; }
private:
uint32_t evaluateMissingCapabilities(const AppDescriptor& descriptor) const;
static void copyText(char* out, size_t out_size, const char* text);
IAppModule* findModule(const char* app_id) const;
void transitionTo(State next, const char* reason);
AppRegistry* registry_ = nullptr;
AppContext context_ = {};
std::unique_ptr<IAppModule> module_;
const AppDescriptor* current_descriptor_ = nullptr;
AppRuntimeStatus status_ = {};
RuntimeServices* services_ = nullptr;
IAppModule* modules_[kMaxModules] = {nullptr};
uint8_t module_count_ = 0U;
IAppModule* active_module_ = nullptr;
State state_ = State::kIdle;
char current_app_id_[24] = {0};
char current_entry_screen_[40] = {0};
char last_error_[64] = {0};
uint32_t opened_at_ms_ = 0U;
uint32_t close_requested_ms_ = 0U;
};
@@ -0,0 +1,27 @@
// default_app_registry.h - Embedded fallback app registry JSON.
#pragma once
static const char kDefaultAppRegistryJson[] = R"JSON({
"apps": [
{"id": "audio_player", "title": "Lecteur Audio", "category": "media", "entry_screen": "SCENE_AUDIO_PLAYER", "icon_path": "/apps/icons/audio.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "camera_video", "title": "Photo / Video", "category": "capture", "entry_screen": "SCENE_PHOTO_MANAGER", "icon_path": "/apps/icons/camera.bin", "enabled": true, "required_capabilities": ["CAP_CAMERA","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "dictaphone", "title": "Dictaphone", "category": "capture", "entry_screen": "SCENE_RECORDER", "icon_path": "/apps/icons/mic.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_IN","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "qr_scanner", "title": "QR Scanner", "category": "utility", "entry_screen": "SCENE_QR_DETECTOR", "icon_path": "/apps/icons/qr.bin", "enabled": true, "required_capabilities": ["CAP_CAMERA","CAP_GPU_UI"]},
{"id": "calculator", "title": "Calculatrice", "category": "utility", "entry_screen": "SCENE_CALCULATOR", "icon_path": "/apps/icons/calc.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "timer_tools", "title": "Chrono / Timer", "category": "utility", "entry_screen": "SCENE_TIMER", "icon_path": "/apps/icons/timer.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "flashlight", "title": "Lampe", "category": "utility", "entry_screen": "SCENE_FLASHLIGHT", "icon_path": "/apps/icons/flash.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "audiobook_player", "title": "Livres Audio", "category": "media", "entry_screen": "SCENE_AUDIOBOOK", "icon_path": "/apps/icons/book.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "kids_webradio", "title": "Webradio", "category": "kids", "entry_screen": "SCENE_WEBRADIO", "icon_path": "/apps/icons/radio.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_WIFI","CAP_GPU_UI"]},
{"id": "kids_podcast", "title": "Podcasts", "category": "kids", "entry_screen": "SCENE_PODCAST", "icon_path": "/apps/icons/podcast.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_WIFI","CAP_GPU_UI"]},
{"id": "kids_music", "title": "Musique", "category": "kids", "entry_screen": "SCENE_KIDS_MUSIC", "icon_path": "/apps/icons/music.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
{"id": "kids_yoga", "title": "Yoga", "category": "kids", "entry_screen": "SCENE_KIDS_YOGA", "icon_path": "/apps/icons/yoga.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
{"id": "kids_meditation", "title": "Meditation", "category": "kids", "entry_screen": "SCENE_KIDS_MEDITATION","icon_path": "/apps/icons/meditate.bin","enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
{"id": "kids_languages", "title": "Langues", "category": "kids", "entry_screen": "SCENE_KIDS_LANG", "icon_path": "/apps/icons/lang.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
{"id": "kids_math", "title": "Maths", "category": "kids", "entry_screen": "SCENE_KIDS_MATH", "icon_path": "/apps/icons/math.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_science", "title": "Sciences", "category": "kids", "entry_screen": "SCENE_KIDS_SCIENCE", "icon_path": "/apps/icons/science.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_geography", "title": "Geographie", "category": "kids", "entry_screen": "SCENE_KIDS_GEO", "icon_path": "/apps/icons/geo.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_drawing", "title": "Dessin", "category": "kids", "entry_screen": "SCENE_DRAWING", "icon_path": "/apps/icons/draw.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "kids_coloring", "title": "Coloriage", "category": "kids", "entry_screen": "SCENE_COLORING", "icon_path": "/apps/icons/color.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
{"id": "nes_emulator", "title": "NES Emu", "category": "emulator", "entry_screen": "SCENE_NES_EMU", "icon_path": "/apps/icons/nes.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI","CAP_STORAGE_FS"]}
]
})JSON";
@@ -34,7 +34,6 @@ class FileShareService {
String* out_error) const;
private:
static void copyText(char* out, size_t out_size, const char* text);
bool ensureSharedDirs() const;
bool resolveIncomingPath(const char* requested_path, String* out_full_path) const;
@@ -0,0 +1,20 @@
// audio_player_module.h - Audio player app module wrapping AmigaAudioPlayer.
#pragma once
#include "app/app_module.h"
class AudioPlayerModule : public IAppModule {
public:
const char* id() const override { return "audio_player"; }
bool onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) override;
void onClose(const char* reason, RuntimeServices* services) override;
void onTick(uint32_t now_ms, RuntimeServices* services) override;
bool onAction(const char* action, const char* payload, RuntimeServices* services) override;
AppModuleState state() const override { return state_; }
const char* lastError() const override { return last_error_; }
private:
AppModuleState state_ = AppModuleState::kIdle;
char last_error_[48] = {0};
};
@@ -27,6 +27,8 @@ class AudioManager {
void stop();
void update();
bool isPlaying() const;
void releaseOutputResources();
bool restoreOutputResources();
void setVolume(uint8_t volume);
uint8_t volume() const;
@@ -112,6 +114,8 @@ class AudioManager {
bool pending_use_sd_ = false;
bool pending_diagnostic_tone_ = false;
uint32_t reopen_earliest_ms_ = 0U;
bool restore_pending_ = false;
uint32_t restore_retry_not_before_ms_ = 0U;
uint32_t underrun_count_ = 0U;
uint32_t underrun_last_note_ms_ = 0U;
uint32_t underrun_last_log_ms_ = 0U;
+114 -48
View File
@@ -1,63 +1,108 @@
// mutex_manager.h - Thread-safe access protection for global AudioManager and ScenarioManager
// CRITICAL: Prevents race conditions between Arduino loop (core 1), WebServer (core 0), I2S callbacks
// mutex_manager.h - Thread-safe mutex management (OO singleton, atomic stats).
// Strategy: 2 separate mutexes (audio, scenario) for fine-grained protection.
// Lock ordering: ALWAYS audio → scenario to prevent deadlock.
// Overhead: ~50µs per lock/unlock on ESP32-S3 @ 240MHz.
#pragma once
#include <atomic>
#include <cstdint>
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
// ============================================================================
// MUTEX MANAGER - Dual-mutex strategy for g_audio and g_scenario protection
// Strategy: 2 separate mutexes (audio_mutex, scenario_mutex) for fine granularity
// Lock ordering: ALWAYS acquire audio_mutex before scenario_mutex to prevent deadlock
// ISR-safe: Timeout-based acquisition (max 1000ms) compatible with watchdog (30s)
// Overhead: ~50µs per lock/unlock operation on ESP32-S3 @ 240MHz
// MutexManager — singleton class with atomic stats
// Call sites unchanged: MutexManager::init(), MutexManager::takeAudioMutex()…
// ============================================================================
namespace MutexManager {
class MutexManager {
public:
// Meyer's singleton — thread-safe, zero-overhead after first call.
static MutexManager& instance();
// Initialize mutex system (call once in setup())
// Returns true if initialization successful, false on failure
bool init();
// --- Lifecycle (call once in setup / shutdown) ---
bool doInit();
void doDeinit();
// Cleanup (call on shutdown/restart)
void deinit();
// --- Low-level acquisition (prefer RAII guards below) ---
bool takeAudio(uint32_t timeout_ms);
void releaseAudio();
bool takeScenario(uint32_t timeout_ms);
void releaseScenario();
bool takeBoth(uint32_t timeout_ms);
void releaseBoth();
// Low-level mutex acquisition (prefer RAII guards below)
// timeout_ms: Max wait time (0 = no wait, portMAX_DELAY = infinite)
// Returns true if lock acquired, false on timeout
bool takeAudioMutex(uint32_t timeout_ms);
void releaseAudioMutex();
// --- Atomic stats ---
uint32_t audioLockCount() const { return audio_lock_count_.load(); }
uint32_t scenarioLockCount() const { return scenario_lock_count_.load(); }
uint32_t audioTimeoutCount() const { return audio_timeout_count_.load(); }
uint32_t scenarioTimeoutCount() const { return scenario_timeout_count_.load(); }
uint32_t maxAudioWaitUs() const { return max_audio_wait_us_.load(); }
uint32_t maxScenarioWaitUs() const { return max_scenario_wait_us_.load(); }
bool takeScenarioMutex(uint32_t timeout_ms);
void releaseScenarioMutex();
// --- Static wrappers — backward-compatible with old namespace API ---
static bool init() { return instance().doInit(); }
static void deinit() { instance().doDeinit(); }
static bool takeAudioMutex(uint32_t ms) { return instance().takeAudio(ms); }
static void releaseAudioMutex() { instance().releaseAudio(); }
static bool takeScenarioMutex(uint32_t ms) { return instance().takeScenario(ms); }
static void releaseScenarioMutex() { instance().releaseScenario(); }
static bool takeBothMutexes(uint32_t ms) { return instance().takeBoth(ms); }
static void releaseBothMutexes() { instance().releaseBoth(); }
static uint32_t getAudioLockCount() { return instance().audioLockCount(); }
static uint32_t getScenarioLockCount() { return instance().scenarioLockCount(); }
static uint32_t getAudioTimeoutCount() { return instance().audioTimeoutCount(); }
static uint32_t getScenarioTimeoutCount() { return instance().scenarioTimeoutCount(); }
static uint32_t getMaxAudioWaitUs() { return instance().maxAudioWaitUs(); }
static uint32_t getMaxScenarioWaitUs() { return instance().maxScenarioWaitUs(); }
// Acquire both mutexes with deadlock prevention (audio → scenario order)
// Returns true if both acquired, false if either acquisition failed
bool takeBothMutexes(uint32_t timeout_ms);
void releaseBothMutexes();
// Non-copyable, non-movable.
MutexManager(const MutexManager&) = delete;
MutexManager& operator=(const MutexManager&) = delete;
// Statistics (for debugging race conditions)
uint32_t audioLockCount();
uint32_t scenarioLockCount();
uint32_t audioTimeoutCount();
uint32_t scenarioTimeoutCount();
uint32_t maxAudioWaitUs();
uint32_t maxScenarioWaitUs();
private:
MutexManager() = default;
} // namespace MutexManager
void updateMaxWait(std::atomic<uint32_t>& max_field, uint32_t elapsed_us);
SemaphoreHandle_t audio_mutex_{nullptr};
SemaphoreHandle_t scenario_mutex_{nullptr};
std::atomic<uint32_t> audio_lock_count_{0U};
std::atomic<uint32_t> scenario_lock_count_{0U};
std::atomic<uint32_t> audio_timeout_count_{0U};
std::atomic<uint32_t> scenario_timeout_count_{0U};
std::atomic<uint32_t> max_audio_wait_us_{0U};
std::atomic<uint32_t> max_scenario_wait_us_{0U};
// Owner tracking for deadlock detection (volatile: written under semaphore).
volatile TaskHandle_t audio_owner_{nullptr};
volatile TaskHandle_t scenario_owner_{nullptr};
};
// ============================================================================
// RAII LOCK GUARDS - Automatic lock/unlock with timeout protection
// Usage: { AudioLock lock; g_audio.play(...); } // auto-release on scope exit
// RAII LOCK GUARDS — automatic lock/unlock on scope exit.
// Usage: { AudioLock lock; if (lock) g_audio.play(); }
// ============================================================================
class AudioLock {
public:
explicit AudioLock(uint32_t timeout_ms = 1000);
~AudioLock();
explicit AudioLock(uint32_t timeout_ms = 1000U)
: acquired_(MutexManager::instance().takeAudio(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] AudioLock FAILED (timeout %lu ms)\n",
static_cast<unsigned long>(timeout_ms));
}
}
~AudioLock() {
if (acquired_) {
MutexManager::instance().releaseAudio();
}
}
AudioLock(const AudioLock&) = delete;
AudioLock& operator=(const AudioLock&) = delete;
explicit operator bool() const { return acquired_; }
bool acquired() const { return acquired_; }
private:
@@ -66,10 +111,21 @@ class AudioLock {
class ScenarioLock {
public:
explicit ScenarioLock(uint32_t timeout_ms = 1000);
~ScenarioLock();
explicit ScenarioLock(uint32_t timeout_ms = 1000U)
: acquired_(MutexManager::instance().takeScenario(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] ScenarioLock FAILED (timeout %lu ms)\n",
static_cast<unsigned long>(timeout_ms));
}
}
~ScenarioLock() {
if (acquired_) {
MutexManager::instance().releaseScenario();
}
}
ScenarioLock(const ScenarioLock&) = delete;
ScenarioLock& operator=(const ScenarioLock&) = delete;
explicit operator bool() const { return acquired_; }
bool acquired() const { return acquired_; }
private:
@@ -78,10 +134,21 @@ class ScenarioLock {
class DualLock {
public:
explicit DualLock(uint32_t timeout_ms = 1000);
~DualLock();
explicit DualLock(uint32_t timeout_ms = 1000U)
: acquired_(MutexManager::instance().takeBoth(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] DualLock FAILED (timeout %lu ms)\n",
static_cast<unsigned long>(timeout_ms));
}
}
~DualLock() {
if (acquired_) {
MutexManager::instance().releaseBoth();
}
}
DualLock(const DualLock&) = delete;
DualLock& operator=(const DualLock&) = delete;
explicit operator bool() const { return acquired_; }
bool acquired() const { return acquired_; }
private:
@@ -89,14 +156,13 @@ class DualLock {
};
// ============================================================================
// INTEGRATION MACROS - Quick integration into existing code
// Example: AUDIO_GUARDED_CALL(g_audio.play("/music/track.mp3"))
// MACROS — quick guarded calls (unchanged API).
// ============================================================================
#define AUDIO_GUARDED_CALL(call) \
do { \
AudioLock _audio_lock(1000); \
if (!_audio_lock.acquired()) { \
AudioLock _audio_lock(1000U); \
if (!_audio_lock) { \
Serial.println("[MUTEX] WARN: Audio lock timeout"); \
} else { \
call; \
@@ -105,8 +171,8 @@ class DualLock {
#define SCENARIO_GUARDED_CALL(call) \
do { \
ScenarioLock _scenario_lock(1000); \
if (!_scenario_lock.acquired()) { \
ScenarioLock _scenario_lock(1000U); \
if (!_scenario_lock) { \
Serial.println("[MUTEX] WARN: Scenario lock timeout"); \
} else { \
call; \
@@ -115,8 +181,8 @@ class DualLock {
#define DUAL_GUARDED_CALL(call) \
do { \
DualLock _dual_lock(1000); \
if (!_dual_lock.acquired()) { \
DualLock _dual_lock(1000U); \
if (!_dual_lock) { \
Serial.println("[MUTEX] WARN: Dual lock timeout"); \
} else { \
call; \
@@ -0,0 +1,46 @@
// str_utils.h - shared string utilities (zero-dependency, header-only).
#pragma once
#include <cctype>
#include <cstddef>
#include <cstring>
namespace core {
/// Case-insensitive string comparison. Returns true if lhs == rhs ignoring case.
/// Safe with nullptr: (nullptr, nullptr) → false.
inline bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
for (size_t i = 0U;; ++i) {
const char a = lhs[i];
const char b = rhs[i];
if (a == '\0' && b == '\0') {
return true;
}
if (a == '\0' || b == '\0') {
return false;
}
if (std::tolower(static_cast<unsigned char>(a)) !=
std::tolower(static_cast<unsigned char>(b))) {
return false;
}
}
}
/// Safe bounded string copy. Always null-terminates dst.
/// No-op if dst is nullptr or dst_size is 0.
inline void copyText(char* dst, size_t dst_size, const char* src) {
if (dst == nullptr || dst_size == 0U) {
return;
}
if (src == nullptr) {
dst[0] = '\0';
return;
}
std::strncpy(dst, src, dst_size - 1U);
dst[dst_size - 1U] = '\0';
}
} // namespace core
@@ -89,6 +89,7 @@ class HardwareManager {
private:
bool beginMic();
void endMic();
void updateMic(uint32_t now_ms);
void updateBattery(uint32_t now_ms);
void updateLed(uint32_t now_ms);
@@ -2,6 +2,7 @@
#pragma once
#include <Arduino.h>
#include <freertos/portmacro.h>
#include <cstdint>
@@ -48,6 +49,8 @@ class PerfMonitor {
PerfSectionStats sections_[static_cast<uint8_t>(PerfSection::kCount)] = {};
uint32_t ui_dma_flush_count_ = 0U;
uint32_t ui_sync_flush_count_ = 0U;
// Spinlock protecting sections_ and flush counters (multi-task safe).
mutable portMUX_TYPE stats_mux_ = portMUX_INITIALIZER_UNLOCKED;
};
PerfMonitor& perfMonitor();
@@ -32,19 +32,52 @@ struct SimdBenchResult {
const SimdAccelStatus& status();
void resetBenchStatus();
// --- RGB565 buffer operations ---
/// memcpy wrapper for typed RGB565 buffers.
void simd_rgb565_copy(uint16_t* dst, const uint16_t* src, size_t n_px);
/// Fill n_px pixels with a constant RGB565 color. Uses 32-bit writes when aligned.
void simd_rgb565_fill(uint16_t* dst, uint16_t color565, size_t n_px);
/// Byte-swap every RGB565 pixel (host ↔ big-endian wire order).
/// Optimised: 2 pixels per iteration via 32-bit packing when aligned.
void simd_rgb565_bswap_copy(uint16_t* dst, const uint16_t* src, size_t n_px);
/// Alpha-blend src over dst in-place.
/// alpha = 255 → fully src, alpha = 0 → fully dst.
/// Optimised: 2 pixels per iteration via 32-bit masking when aligned.
void simd_rgb565_alpha_blend(uint16_t* dst, const uint16_t* src,
uint8_t alpha, size_t n_px);
/// Scale brightness of each pixel. level = 255 → no change, 0 → black.
/// Optimised: 2 pixels per iteration when aligned.
void simd_rgb565_brightness(uint16_t* dst, const uint16_t* src,
uint8_t level, size_t n_px);
// --- Color format conversions ---
/// Grayscale L8 → RGB565 via precomputed LUT. 2-pixel 32-bit fast path when aligned.
void simd_l8_to_rgb565(uint16_t* dst565, const uint8_t* src_l8, size_t n_px);
/// Indexed 8-bit palette → RGB565. 2-pixel 32-bit fast path when aligned.
void simd_index8_to_rgb565(uint16_t* dst565,
const uint8_t* idx8,
const uint16_t* pal565_256,
size_t n_px);
/// Packed RGB888 → RGB565. 32-bit read fast path when src is aligned.
void simd_rgb888_to_rgb565(uint16_t* dst565, const uint8_t* src_rgb888, size_t n_px);
/// YUV422 (YUYV) → RGB565. BT.601 full-range, 2 pixels per iteration.
void simd_yuv422_to_rgb565(uint16_t* dst565, const uint8_t* src_yuv422, size_t n_px);
// --- Audio DSP ---
/// Apply Q15 gain to int16 samples. Uses ESP-DSP when available, scalar fallback.
void simd_s16_gain_q15(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n);
/// Mix two int16 streams with independent Q15 gains. Uses ESP-DSP when available.
void simd_s16_mix2_q15(int16_t* dst,
const int16_t* a,
const int16_t* b,
@@ -52,8 +85,9 @@ void simd_s16_mix2_q15(int16_t* dst,
int16_t gb_q15,
size_t n);
// --- Self-test and benchmark ---
bool selfTest();
SimdBenchResult runBench(uint32_t loops, uint32_t pixels);
} // namespace runtime::simd
@@ -88,9 +88,7 @@ class NetworkManager {
static void onEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status);
static uint8_t parseHexByte(char high, char low, bool* ok);
static void copyText(char* out, size_t out_size, const char* text);
static void formatMac(const uint8_t* mac, char* out, size_t out_size);
static bool equalsIgnoreCase(const char* lhs, const char* rhs);
static const char* wifiModeLabel(uint8_t mode);
static const char* networkStateLabel(bool sta_connected,
bool sta_connecting,
@@ -0,0 +1,131 @@
// amiga_ui_shell.h - Workbench-style app launcher grid (LVGL).
#pragma once
#include <Arduino.h>
#include <lvgl.h>
#include "app/app_registry.h"
#include "app/app_runtime_manager.h"
namespace ui::workbench {
class AmigaUIShell {
public:
// Display layout: portrait 320x480 => 3 cols x 5 rows = 15 tiles/page.
// Landscape 480x320 => 4 cols x 3 rows = 12 tiles/page.
static constexpr uint8_t kGridColsPortrait = 3U;
static constexpr uint8_t kGridRowsPortrait = 5U;
static constexpr uint8_t kGridColsLandscape = 4U;
static constexpr uint8_t kGridRowsLandscape = 3U;
static constexpr uint8_t kTilesPerPage = 12U; // Conservative: fits both orientations.
static constexpr uint8_t kMaxPages = 3U;
static constexpr uint8_t kMaxVisibleTiles = kTilesPerPage * kMaxPages;
enum class InputKey : uint8_t {
kUp = 0,
kDown,
kLeft,
kRight,
kOk,
kBack,
};
struct Config {
bool landscape = true; // Default: rotation=1 => landscape.
uint16_t screen_w = 480U;
uint16_t screen_h = 320U;
uint8_t grid_cols = kGridColsLandscape;
uint8_t grid_rows = kGridRowsLandscape;
};
AmigaUIShell() = default;
~AmigaUIShell() = default;
AmigaUIShell(const AmigaUIShell&) = delete;
AmigaUIShell& operator=(const AmigaUIShell&) = delete;
bool begin(const Config& cfg, AppRegistry* registry, AppRuntimeManager* runtime);
void show();
void hide();
bool visible() const { return visible_; }
void toggle();
void handleInput(InputKey key);
void handleButtonKey(uint8_t hw_key, bool long_press);
void tick(uint32_t now_ms);
// Request app launch from the currently focused tile.
bool launchFocused(uint32_t now_ms);
bool requestCloseApp(const char* reason, uint32_t now_ms);
uint8_t currentPage() const { return current_page_; }
uint8_t pageCount() const { return page_count_; }
uint8_t focusIndex() const { return focus_index_; }
const AppEntry* focusedEntry() const;
private:
void createUi();
void destroyUi();
void rebuildGrid();
void updateFocusVisual();
void updateStatusBar(uint32_t now_ms);
void updateTileStates();
void navigatePage(int8_t delta);
void navigateFocus(int8_t dx, int8_t dy);
uint8_t tilesOnCurrentPage() const;
uint8_t globalIndexFromPageLocal(uint8_t local) const;
// Styles.
lv_style_t st_bg_;
lv_style_t st_topbar_;
lv_style_t st_tile_;
lv_style_t st_tile_focused_;
lv_style_t st_tile_disabled_;
lv_style_t st_label_;
lv_style_t st_status_;
lv_style_t st_page_indicator_;
bool styles_inited_ = false;
// Layout.
Config cfg_;
uint16_t tile_w_ = 0U;
uint16_t tile_h_ = 0U;
uint16_t grid_x_offset_ = 0U;
uint16_t grid_y_offset_ = 0U;
// Data.
AppRegistry* registry_ = nullptr;
AppRuntimeManager* runtime_ = nullptr;
uint8_t enabled_count_ = 0U;
uint8_t page_count_ = 0U;
uint8_t current_page_ = 0U;
uint8_t focus_index_ = 0U; // local index on current page
bool visible_ = false;
// LVGL objects.
lv_obj_t* root_ = nullptr;
lv_obj_t* topbar_ = nullptr;
lv_obj_t* topbar_label_ = nullptr;
lv_obj_t* topbar_status_ = nullptr;
lv_obj_t* grid_container_ = nullptr;
lv_obj_t* page_indicator_ = nullptr;
static constexpr uint8_t kMaxTilesPerPage = 15U; // Max visible on one page.
struct TileSlot {
lv_obj_t* container = nullptr;
lv_obj_t* icon_label = nullptr;
lv_obj_t* title_label = nullptr;
uint8_t global_app_index = 0xFFU;
};
TileSlot tiles_[kMaxTilesPerPage];
uint8_t tile_count_ = 0U;
// Launching feedback.
lv_obj_t* launch_overlay_ = nullptr;
lv_obj_t* launch_label_ = nullptr;
uint32_t launch_overlay_hide_ms_ = 0U;
uint32_t last_status_update_ms_ = 0U;
};
} // namespace ui::workbench
@@ -1,8 +1,9 @@
#pragma once
// Freenove Media Kit reference profile (Sketch_19):
// - FNK0102H ST7796 320x480 by default, rotation 1
// - Buttons via analog ladder on GPIO19
// Freenove FNK0102H release profile:
// - Freenove ESP32-S3 WROOM N8R8 (8MB flash / 8MB PSRAM)
// - ST7796 320x480, rotation 1
// - 5-way switch via analog ladder on GPIO19
// - I2S output BCLK=42, WS=41, DOUT=1
#ifndef FREENOVE_LCD_VARIANT_FNK0102A
@@ -135,6 +136,9 @@
#define FREENOVE_HAS_TOUCH 0
#endif
// Official release profile uses the 5-way switch only.
// The experimental touch wiring overlaps with the camera mapping on FNK0102H,
// so release builds keep touch disabled.
#if FREENOVE_HAS_TOUCH
#define FREENOVE_TOUCH_CS 9
#define FREENOVE_TOUCH_IRQ 15
@@ -221,7 +225,7 @@
#define FREENOVE_BAT_CHARGE_PIN -1
#endif
// Camera wiring from Freenove FNK0102 (ESP32S3_EYE profile).
// Camera wiring validated for the FNK0102H release profile.
#ifndef FREENOVE_CAM_ENABLE
#define FREENOVE_CAM_ENABLE 1
#endif
+6 -9
View File
@@ -1,6 +1,7 @@
// app_audio.cpp - Audio Player Implementation
#include "app/app_audio.h"
#include "audio_manager.h"
#include "core/str_utils.h"
AppAudio g_app_audio;
@@ -54,7 +55,7 @@ void AppAudio::onAction(const AppAction& action) {
} else if (strcmp(action.name, "stop") == 0) {
stop();
} else if (strcmp(action.name, "volume") == 0) {
int vol = atoi(action.payload);
int vol = static_cast<int>(strtol(action.payload, nullptr, 10));
setVolume(constrain(vol, 0, 100));
}
}
@@ -65,8 +66,7 @@ bool AppAudio::play(const char* filepath) {
return false;
}
strncpy(current_file_, filepath, sizeof(current_file_) - 1);
current_file_[sizeof(current_file_) - 1] = '\0';
core::copyText(current_file_, sizeof(current_file_), filepath);
playback_ms_ = 0;
duration_ms_ = 30000; // Assume 30 seconds for demo
@@ -74,9 +74,8 @@ bool AppAudio::play(const char* filepath) {
Serial.printf("[APP_AUDIO] Playing: %s (vol=%u%%)\n", current_file_, volume_percent_);
// TODO: Integrate with AudioManager for actual playback
if (context_.audio) {
// context_.audio->playFile(filepath);
context_.audio->play(filepath);
}
return true;
@@ -104,9 +103,8 @@ void AppAudio::stop() {
Serial.println("[APP_AUDIO] Stopped");
// TODO: Stop playback via AudioManager
if (context_.audio) {
// context_.audio->stop();
context_.audio->stop();
}
}
@@ -114,9 +112,8 @@ void AppAudio::setVolume(uint8_t vol_percent) {
volume_percent_ = vol_percent;
Serial.printf("[APP_AUDIO] Volume: %u%%\n", volume_percent_);
// TODO: Update hardware volume via AudioManager
if (context_.audio) {
// context_.audio->setVolume(vol_percent);
context_.audio->setVolume(vol_percent);
}
}
@@ -1,5 +1,6 @@
// app_calculator.cpp - Calculator Implementation
#include "app/app_calculator.h"
#include "core/str_utils.h"
AppCalculator g_app_calculator;
@@ -39,7 +40,7 @@ void AppCalculator::onAction(const AppAction& action) {
void AppCalculator::reset() {
result_ = 0.0;
strncpy(display_, "0", sizeof(display_) - 1);
core::copyText(display_, sizeof(display_), "0");
history_[0] = '\0';
}
@@ -42,7 +42,7 @@ void AppFlashlight::onAction(const AppAction& action) {
} else if (strcmp(action.name, "toggle") == 0) {
toggle();
} else if (strcmp(action.name, "intensity") == 0) {
int percent = atoi(action.payload);
int percent = static_cast<int>(strtol(action.payload, nullptr, 10));
setIntensity(constrain(percent, 0, 100));
}
}
+107 -154
View File
@@ -2,11 +2,9 @@
#include "app/app_registry.h"
#include <ArduinoJson.h>
#include <cctype>
#include <cstdio>
#include <cstring>
#include "core/str_utils.h"
#include "storage_manager.h"
namespace {
@@ -29,41 +27,35 @@ constexpr FallbackAppEntry kFallbackApps[] = {
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"},
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"},
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"},
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"},
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"},
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,
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"},
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"},
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"},
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"},
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"},
@@ -71,45 +63,33 @@ constexpr FallbackAppEntry kFallbackApps[] = {
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"},
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"},
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"},
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"},
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,80 +20,93 @@
namespace {
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;
}
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;
// ============================================================================
// 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;
}
};
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_.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 {
@@ -102,140 +115,88 @@ class BasicAppModule : public IAppModule {
}
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;
} else if (core::equalsIgnoreCase(action.name, "stop")) {
if (ctx_.media != nullptr) {
ctx_.media->stop(ctx_.audio);
} else if (ctx_.audio != nullptr) {
ctx_.audio->stop();
}
if (equalsIgnoreCase(action.name, "stop")) {
if (context_.media != nullptr) {
context_.media->stop(context_.audio);
} else if (context_.audio != nullptr) {
context_.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;
} 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");
}
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");
}
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();
}
}
status_.state = AppRuntimeState::kIdle;
copyText(status_.last_event, sizeof(status_.last_event), "end");
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();
}
AppRuntimeStatus status() const override {
return status_;
status_.state = AppRuntimeState::kIdle;
core::copyText(status_.last_event, sizeof(status_.last_event), "end");
}
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;
@@ -246,54 +207,65 @@ void AppRuntimeManager::configure(AppRegistry* registry, const AppContext& conte
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);
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");
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");
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;
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;
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), "");
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;
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);
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)) {
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) ||
const bool has = (context_.media != nullptr) ||
(context_.hardware != nullptr && context_.hardware->snapshotRef().mic_ready);
if (!has_audio_in) {
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())) {
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) && context_.storage == nullptr) {
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) && resource != nullptr &&
!resource->allowsCapability(runtime::resource::ResourceCapability::kStorageFs)) {
missing |= CAP_STORAGE_FS;
}
if (appCapabilityMaskHas(req, CAP_GPU_UI) && context_.ui == nullptr) {
if (appCapabilityMaskHas(req, CAP_GPU_UI)) {
if (context_.ui == nullptr || lacking(runtime::resource::ResourceCapability::kGpuUi))
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';
}
+6 -10
View File
@@ -1,6 +1,7 @@
// app_timer.cpp - Timer Implementation
#include "app/app_timer.h"
#include "hardware_manager.h"
#include "audio_manager.h"
#include "core/str_utils.h"
AppTimer g_app_timer;
@@ -44,7 +45,7 @@ void AppTimer::onAction(const AppAction& action) {
Serial.printf("[APP_TIMER] Action: %s payload=%s\n", action.name, action.payload);
if (strcmp(action.name, "start") == 0) {
uint32_t seconds = atoi(action.payload);
uint32_t seconds = static_cast<uint32_t>(strtol(action.payload, nullptr, 10));
startCountdown(seconds);
} else if (strcmp(action.name, "pause") == 0) {
pause();
@@ -81,7 +82,7 @@ void AppTimer::reset() {
running_ = false;
remaining_ms_ = 0;
total_ms_ = 0;
strncpy(display_, "00:00", sizeof(display_) - 1);
core::copyText(display_, sizeof(display_), "00:00");
}
void AppTimer::updateDisplay() {
@@ -93,13 +94,8 @@ 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 (core::equalsIgnoreCase(app_id, "audio_player")) {
return "/apps/audio_player/audio/default.mp3";
}
if (a == '\0' || b == '\0') {
return false;
if (core::equalsIgnoreCase(app_id, "audiobook_player")) {
return "/apps/audiobook_player/audio/default.mp3";
}
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, "kids_music")) {
return "/apps/kids_music/audio/default.mp3";
}
if (core::equalsIgnoreCase(app_id, "kids_webradio")) {
return "/apps/kids_webradio/audio/default.mp3";
}
if (core::equalsIgnoreCase(app_id, "kids_podcast")) {
return "/apps/kids_podcast/audio/default.mp3";
}
return kSharedBundledAudioTrack;
}
bool ensureDir(const char* path) {
@@ -142,14 +134,14 @@ bool endsWithIgnoreCase(const char* text, const char* suffix) {
return false;
}
const char* tail = text + (text_len - suffix_len);
return equalsIgnoreCase(tail, suffix);
return core::equalsIgnoreCase(tail, suffix);
}
bool parseJsonPayload(const AppAction& action, DynamicJsonDocument* out) {
if (out == nullptr) {
return false;
}
const bool looks_json = (action.content_type[0] != '\0' && equalsIgnoreCase(action.content_type, "application/json")) ||
const bool looks_json = (action.content_type[0] != '\0' && core::equalsIgnoreCase(action.content_type, "application/json")) ||
action.payload[0] == '{' || action.payload[0] == '[';
if (!looks_json || action.payload[0] == '\0') {
return false;
@@ -164,11 +156,11 @@ class ModuleBase : public IAppModule {
context_ = context;
status_ = {};
if (context.descriptor != nullptr) {
copyText(status_.id, sizeof(status_.id), context.descriptor->id);
core::copyText(status_.id, sizeof(status_.id), context.descriptor->id);
}
status_.state = AppRuntimeState::kRunning;
status_.started_at_ms = millis();
copyText(status_.last_event, sizeof(status_.last_event), "begin");
core::copyText(status_.last_event, sizeof(status_.last_event), "begin");
return context.descriptor != nullptr;
}
@@ -178,12 +170,12 @@ class ModuleBase : public IAppModule {
}
void handleAction(const AppAction& action) override {
copyText(status_.last_event, sizeof(status_.last_event), action.name);
core::copyText(status_.last_event, sizeof(status_.last_event), action.name);
}
void end() override {
status_.state = AppRuntimeState::kIdle;
copyText(status_.last_event, sizeof(status_.last_event), "end");
core::copyText(status_.last_event, sizeof(status_.last_event), "end");
}
AppRuntimeStatus status() const override {
@@ -192,7 +184,7 @@ class ModuleBase : public IAppModule {
protected:
void setError(const char* error) {
copyText(status_.last_error, sizeof(status_.last_error), error);
core::copyText(status_.last_error, sizeof(status_.last_error), error);
}
AppContext context_ = {};
@@ -215,8 +207,10 @@ class AudioPlayerModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "play")) {
const char* path = (action.payload[0] != '\0') ? action.payload : "/music/boot_radio.mp3";
if (core::equalsIgnoreCase(action.name, "play")) {
const char* path = (action.payload[0] != '\0')
? action.payload
: defaultBundledAudioForApp((context_.descriptor != nullptr) ? context_.descriptor->id : nullptr);
if (context_.storage != nullptr && !context_.storage->fileExists(path)) {
setError("missing_asset");
return;
@@ -229,7 +223,7 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "play_url")) {
if (core::equalsIgnoreCase(action.name, "play_url")) {
if (action.payload[0] == '\0') {
setError("missing_asset");
return;
@@ -243,7 +237,7 @@ class AudioPlayerModule : public ModuleBase {
return;
}
}
if (!context_.audio->playUrl(action.payload)) {
if (!context_.audio->play(action.payload)) {
if (!playOfflineFallback()) {
setError("network_unavailable");
}
@@ -253,7 +247,7 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "pause")) {
if (core::equalsIgnoreCase(action.name, "pause")) {
if (!paused_url_.isEmpty()) {
context_.audio->stop();
} else {
@@ -262,10 +256,10 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "resume")) {
if (core::equalsIgnoreCase(action.name, "resume")) {
bool ok = false;
if (!paused_url_.isEmpty()) {
ok = context_.audio->playUrl(paused_url_.c_str());
ok = context_.audio->play(paused_url_.c_str());
} else if (!paused_track_.isEmpty()) {
ok = context_.media->play(paused_track_.c_str(), context_.audio);
}
@@ -274,17 +268,17 @@ class AudioPlayerModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "stop")) {
if (core::equalsIgnoreCase(action.name, "stop")) {
context_.media->stop(context_.audio);
return;
}
if (equalsIgnoreCase(action.name, "set_volume")) {
if (core::equalsIgnoreCase(action.name, "set_volume")) {
const uint32_t volume = parseUint(action.payload, context_.audio->volume());
context_.audio->setVolume(static_cast<uint8_t>(volume > 100U ? 100U : volume));
return;
}
if (equalsIgnoreCase(action.name, "next") || equalsIgnoreCase(action.name, "prev")) {
copyText(status_.last_event, sizeof(status_.last_event), "playlist_not_configured");
if (core::equalsIgnoreCase(action.name, "next") || core::equalsIgnoreCase(action.name, "prev")) {
core::copyText(status_.last_event, sizeof(status_.last_event), "playlist_not_configured");
return;
}
}
@@ -298,13 +292,13 @@ class AudioPlayerModule : public ModuleBase {
String candidates[3];
candidates[0] = String("/apps/") + app_id + "/audio/offline.mp3";
candidates[1] = String("/apps/") + app_id + "/audio/default.mp3";
candidates[2] = "/music/boot_radio.mp3";
candidates[2] = kSharedBundledAudioTrack;
for (const String& candidate : candidates) {
if (!context_.storage->fileExists(candidate.c_str())) {
continue;
}
if (context_.media->play(candidate.c_str(), context_.audio)) {
copyText(status_.last_event, sizeof(status_.last_event), "offline_fallback");
core::copyText(status_.last_event, sizeof(status_.last_event), "offline_fallback");
setError("");
return true;
}
@@ -333,12 +327,12 @@ class AudiobookModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "open_book")) {
if (core::equalsIgnoreCase(action.name, "open_book")) {
current_book_ = action.payload;
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "play")) {
if (core::equalsIgnoreCase(action.name, "play")) {
const char* target = current_book_.isEmpty() ? action.payload : current_book_.c_str();
if (target == nullptr || target[0] == '\0') {
setError("missing_asset");
@@ -352,7 +346,7 @@ class AudiobookModule : public ModuleBase {
return;
}
}
if (!context_.audio->playUrl(target)) {
if (!context_.audio->play(target)) {
setError("network_unavailable");
}
return;
@@ -366,27 +360,27 @@ class AudiobookModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "pause")) {
if (core::equalsIgnoreCase(action.name, "pause")) {
context_.audio->stop();
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "stop")) {
if (core::equalsIgnoreCase(action.name, "stop")) {
context_.media->stop(context_.audio);
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "seek_ms")) {
if (core::equalsIgnoreCase(action.name, "seek_ms")) {
position_ms_ = parseUint(action.payload, position_ms_);
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "bookmark_set")) {
if (core::equalsIgnoreCase(action.name, "bookmark_set")) {
bookmark_ms_ = parseUint(action.payload, position_ms_);
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "bookmark_go")) {
if (core::equalsIgnoreCase(action.name, "bookmark_go")) {
position_ms_ = bookmark_ms_;
saveProgress();
return;
@@ -445,15 +439,23 @@ class CameraVideoModule : public ModuleBase {
status_.state = AppRuntimeState::kFailed;
return false;
}
if (!context.camera->startRecorderSession()) {
bool camera_ready = context.camera->startRecorderSession();
preview_on_ = camera_ready;
if (!camera_ready) {
camera_ready = context.camera->start();
preview_on_ = false;
}
if (!camera_ready) {
setError("camera_start_failed");
status_.state = AppRuntimeState::kFailed;
return false;
}
preview_on_ = true;
clip_active_ = false;
clip_frames_.clear();
setError("");
core::copyText(status_.last_event,
sizeof(status_.last_event),
preview_on_ ? "preview_ready" : "snapshot_ready");
updateStatusEvent();
return true;
}
@@ -479,11 +481,11 @@ class CameraVideoModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "preview_on")) {
if (core::equalsIgnoreCase(action.name, "preview_on")) {
if (!context_.camera->start()) {
setError("preview_on_failed");
} else {
@@ -493,26 +495,26 @@ class CameraVideoModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "preview_off")) {
if (core::equalsIgnoreCase(action.name, "preview_off")) {
context_.camera->stop();
preview_on_ = false;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "snapshot")) {
if (core::equalsIgnoreCase(action.name, "snapshot")) {
String out_path;
if (!context_.camera->snapshotToFile(nullptr, &out_path)) {
setError("snapshot_failed");
} else {
char event[40] = {0};
std::snprintf(event, sizeof(event), "snap=%u", static_cast<unsigned int>(out_path.length()));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
return;
}
if (equalsIgnoreCase(action.name, "clip_start")) {
if (core::equalsIgnoreCase(action.name, "clip_start")) {
clip_active_ = true;
clip_frames_.clear();
clip_started_ms_ = millis();
@@ -521,13 +523,13 @@ class CameraVideoModule : public ModuleBase {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "clip_stop")) {
if (core::equalsIgnoreCase(action.name, "clip_stop")) {
stopClipAndPersist();
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "list_media")) {
if (core::equalsIgnoreCase(action.name, "list_media")) {
static constexpr int kMaxListItems = 12;
String items[kMaxListItems];
const int count = context_.camera->recorderListPhotos(items, kMaxListItems, true);
@@ -536,17 +538,17 @@ class CameraVideoModule : public ModuleBase {
} else {
char event[40] = {0};
std::snprintf(event, sizeof(event), "list=%d", count);
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
return;
}
if (equalsIgnoreCase(action.name, "delete_media")) {
if (core::equalsIgnoreCase(action.name, "delete_media")) {
if (action.payload[0] == '\0' || !context_.camera->recorderRemoveFile(action.payload)) {
setError("delete_media_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
}
return;
}
@@ -598,7 +600,7 @@ class CameraVideoModule : public ModuleBase {
preview_on_ ? 1U : 0U,
clip_active_ ? 1U : 0U,
static_cast<unsigned int>(clip_frames_.size()));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
bool preview_on_ = false;
@@ -623,7 +625,7 @@ class QrScannerModule : public ModuleBase {
// Ensure CameraManager is not already holding the camera driver.
context.camera->stop();
scanning_ = false;
copyText(last_type_, sizeof(last_type_), "none");
core::copyText(last_type_, sizeof(last_type_), "none");
setError("");
updateStatusEvent();
return true;
@@ -631,23 +633,23 @@ class QrScannerModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "scan_start")) {
if (core::equalsIgnoreCase(action.name, "scan_start")) {
scanning_ = true;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "scan_stop")) {
if (core::equalsIgnoreCase(action.name, "scan_stop")) {
scanning_ = false;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "scan_once") || equalsIgnoreCase(action.name, "scan_payload")) {
if (core::equalsIgnoreCase(action.name, "scan_once") || core::equalsIgnoreCase(action.name, "scan_payload")) {
scanning_ = false;
classifyPayload(action.payload);
setError("");
@@ -664,18 +666,18 @@ class QrScannerModule : public ModuleBase {
private:
void classifyPayload(const char* payload) {
if (payload == nullptr || payload[0] == '\0') {
copyText(last_type_, sizeof(last_type_), "unknown");
core::copyText(last_type_, sizeof(last_type_), "unknown");
return;
}
if (std::strncmp(payload, "http://", 7U) == 0 || std::strncmp(payload, "https://", 8U) == 0) {
copyText(last_type_, sizeof(last_type_), "url");
core::copyText(last_type_, sizeof(last_type_), "url");
return;
}
if (std::strncmp(payload, "app:", 4U) == 0 || std::strncmp(payload, "zacus:", 6U) == 0) {
copyText(last_type_, sizeof(last_type_), "app");
core::copyText(last_type_, sizeof(last_type_), "app");
return;
}
copyText(last_type_, sizeof(last_type_), "text");
core::copyText(last_type_, sizeof(last_type_), "text");
}
void updateStatusEvent() {
@@ -685,7 +687,7 @@ class QrScannerModule : public ModuleBase {
"scan=%u type=%s",
scanning_ ? 1U : 0U,
last_type_);
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
bool scanning_ = false;
@@ -710,51 +712,51 @@ class DictaphoneModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "record_start")) {
if (core::equalsIgnoreCase(action.name, "record_start")) {
const uint16_t sec = static_cast<uint16_t>(parseUint(action.payload, 30U));
if (!context_.media->startRecording(sec, nullptr)) {
setError("record_start_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "recording=1");
core::copyText(status_.last_event, sizeof(status_.last_event), "recording=1");
}
return;
}
if (equalsIgnoreCase(action.name, "record_stop")) {
if (core::equalsIgnoreCase(action.name, "record_stop")) {
if (!context_.media->stopRecording()) {
setError("record_stop_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "recording=0");
core::copyText(status_.last_event, sizeof(status_.last_event), "recording=0");
}
return;
}
if (equalsIgnoreCase(action.name, "play_file")) {
if (core::equalsIgnoreCase(action.name, "play_file")) {
char path[120] = {0};
if (!normalizeRecordPath(action.payload, path, sizeof(path)) ||
!context_.media->play(path, context_.audio)) {
setError("play_file_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "play_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "play_ok");
}
return;
}
if (equalsIgnoreCase(action.name, "delete_file")) {
if (core::equalsIgnoreCase(action.name, "delete_file")) {
char path[120] = {0};
if (!normalizeRecordPath(action.payload, path, sizeof(path)) || !LittleFS.remove(path)) {
setError("delete_file_failed");
} else {
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "delete_ok");
}
return;
}
if (equalsIgnoreCase(action.name, "list_records")) {
if (core::equalsIgnoreCase(action.name, "list_records")) {
String list_json;
if (!context_.media->listFiles("records", &list_json)) {
setError("list_records_failed");
@@ -766,7 +768,7 @@ class DictaphoneModule : public ModuleBase {
}
char event[40] = {0};
std::snprintf(event, sizeof(event), "records=%u", static_cast<unsigned int>(count));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
return;
@@ -783,7 +785,7 @@ class DictaphoneModule : public ModuleBase {
private:
void updateStatusEvent() {
copyText(status_.last_event, sizeof(status_.last_event), "records=ready");
core::copyText(status_.last_event, sizeof(status_.last_event), "records=ready");
}
bool normalizeRecordPath(const char* payload, char* out, size_t out_size) const {
@@ -791,12 +793,12 @@ class DictaphoneModule : public ModuleBase {
return false;
}
if (payload[0] == '/') {
copyText(out, out_size, payload);
core::copyText(out, out_size, payload);
return true;
}
char path[120] = {0};
std::snprintf(path, sizeof(path), "/recorder/%s", payload);
copyText(out, out_size, path);
core::copyText(out, out_size, path);
return true;
}
};
@@ -819,7 +821,7 @@ class TimerToolsModule : public ModuleBase {
cd_done_notified_ = false;
countdown_visual_until_ms_ = 0U;
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "timer_ready");
core::copyText(status_.last_event, sizeof(status_.last_event), "timer_ready");
return true;
}
@@ -852,11 +854,11 @@ class TimerToolsModule : public ModuleBase {
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
const uint32_t now_ms = millis();
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
writeStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "sw_start")) {
if (core::equalsIgnoreCase(action.name, "sw_start")) {
if (!sw_running_) {
sw_running_ = true;
sw_started_ms_ = now_ms;
@@ -864,7 +866,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "sw_stop")) {
if (core::equalsIgnoreCase(action.name, "sw_stop")) {
if (sw_running_) {
sw_acc_ms_ += (now_ms - sw_started_ms_);
sw_running_ = false;
@@ -872,12 +874,12 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "sw_lap")) {
if (core::equalsIgnoreCase(action.name, "sw_lap")) {
sw_lap_ms_ = sw_running_ ? (sw_acc_ms_ + (now_ms - sw_started_ms_)) : sw_acc_ms_;
setError("");
return;
}
if (equalsIgnoreCase(action.name, "sw_reset")) {
if (core::equalsIgnoreCase(action.name, "sw_reset")) {
sw_running_ = false;
sw_started_ms_ = 0U;
sw_acc_ms_ = 0U;
@@ -886,7 +888,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_set")) {
if (core::equalsIgnoreCase(action.name, "cd_set")) {
DynamicJsonDocument body(192);
uint32_t seconds = 0U;
if (parseJsonPayload(action, &body) && body["seconds"].is<uint32_t>()) {
@@ -901,7 +903,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_start")) {
if (core::equalsIgnoreCase(action.name, "cd_start")) {
if (cd_duration_ms_ == 0U) {
cd_duration_ms_ = 60000U;
}
@@ -911,7 +913,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_pause")) {
if (core::equalsIgnoreCase(action.name, "cd_pause")) {
if (cd_running_) {
const uint32_t elapsed = now_ms - cd_started_ms_;
cd_duration_ms_ = (elapsed >= cd_duration_ms_) ? 0U : (cd_duration_ms_ - elapsed);
@@ -921,7 +923,7 @@ class TimerToolsModule : public ModuleBase {
setError("");
return;
}
if (equalsIgnoreCase(action.name, "cd_reset")) {
if (core::equalsIgnoreCase(action.name, "cd_reset")) {
cd_running_ = false;
cd_duration_ms_ = 0U;
cd_remaining_ms_ = 0U;
@@ -934,7 +936,7 @@ class TimerToolsModule : public ModuleBase {
private:
void notifyCountdownDone(uint32_t now_ms) {
copyText(status_.last_event, sizeof(status_.last_event), "countdown_done");
core::copyText(status_.last_event, sizeof(status_.last_event), "countdown_done");
setError("");
if (context_.storage != nullptr && context_.audio != nullptr && context_.media != nullptr) {
static constexpr const char* kDoneCandidates[] = {
@@ -964,7 +966,7 @@ class TimerToolsModule : public ModuleBase {
static_cast<unsigned long>(sw_elapsed_ms_),
static_cast<unsigned long>(cd_remaining_ms_),
cd_running_ ? 1U : 0U);
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
setError("");
}
@@ -998,17 +1000,17 @@ class FlashlightModule : public ModuleBase {
is_on_ = false;
level_ = 96U;
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "light_ready");
core::copyText(status_.last_event, sizeof(status_.last_event), "light_ready");
return true;
}
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "light_on")) {
if (core::equalsIgnoreCase(action.name, "light_on")) {
if (!applyLight(true)) {
setError("light_on_failed");
} else {
@@ -1016,14 +1018,14 @@ class FlashlightModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "light_off")) {
if (core::equalsIgnoreCase(action.name, "light_off")) {
context_.hardware->clearManualLed();
is_on_ = false;
setError("");
updateStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "light_toggle")) {
if (core::equalsIgnoreCase(action.name, "light_toggle")) {
if (!applyLight(!is_on_)) {
setError("light_toggle_failed");
} else {
@@ -1031,7 +1033,7 @@ class FlashlightModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "set_level")) {
if (core::equalsIgnoreCase(action.name, "set_level")) {
DynamicJsonDocument body(128);
uint32_t parsed = level_;
if (parseJsonPayload(action, &body) && body["level"].is<uint32_t>()) {
@@ -1087,7 +1089,7 @@ class FlashlightModule : public ModuleBase {
"on=%u level=%u",
is_on_ ? 1U : 0U,
static_cast<unsigned int>(level_));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
uint8_t level_ = 120U;
@@ -1206,17 +1208,17 @@ class CalculatorModule : public ModuleBase {
public:
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "status")) {
if (core::equalsIgnoreCase(action.name, "status")) {
writeStatusEvent();
return;
}
if (equalsIgnoreCase(action.name, "clear")) {
if (core::equalsIgnoreCase(action.name, "clear")) {
result_ = 0.0;
setError("");
writeStatusEvent();
return;
}
if (!equalsIgnoreCase(action.name, "eval")) {
if (!core::equalsIgnoreCase(action.name, "eval")) {
setError("unsupported_action");
return;
}
@@ -1231,7 +1233,7 @@ class CalculatorModule : public ModuleBase {
setError("eval_error");
char msg[40] = {0};
std::snprintf(msg, sizeof(msg), "eval_error@%d", error);
copyText(status_.last_event, sizeof(status_.last_event), msg);
core::copyText(status_.last_event, sizeof(status_.last_event), msg);
return;
}
result_ = value;
@@ -1252,7 +1254,7 @@ class CalculatorModule : public ModuleBase {
void writeStatusEvent() {
char msg[40] = {0};
std::snprintf(msg, sizeof(msg), "result=%.4f", result_);
copyText(status_.last_event, sizeof(status_.last_event), msg);
core::copyText(status_.last_event, sizeof(status_.last_event), msg);
}
double result_ = 0.0;
@@ -1282,14 +1284,14 @@ class KidsCreativeModule : public ModuleBase {
ModuleBase::handleAction(action);
DynamicJsonDocument body(384);
(void)parseJsonPayload(action, &body);
if (equalsIgnoreCase(action.name, "open_canvas")) {
if (core::equalsIgnoreCase(action.name, "open_canvas")) {
if (body["color"].is<const char*>()) {
current_color_ = body["color"].as<const char*>();
}
setError("");
return;
}
if (equalsIgnoreCase(action.name, "stroke")) {
if (core::equalsIgnoreCase(action.name, "stroke")) {
++stroke_count_;
if (body["color"].is<const char*>()) {
current_color_ = body["color"].as<const char*>();
@@ -1298,7 +1300,7 @@ class KidsCreativeModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "fill")) {
if (core::equalsIgnoreCase(action.name, "fill")) {
if (body["color"].is<const char*>()) {
current_color_ = body["color"].as<const char*>();
} else if (action.payload[0] != '\0') {
@@ -1306,18 +1308,18 @@ class KidsCreativeModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "undo")) {
if (core::equalsIgnoreCase(action.name, "undo")) {
if (stroke_count_ > 0U) {
--stroke_count_;
}
return;
}
if (equalsIgnoreCase(action.name, "clear") || equalsIgnoreCase(action.name, "clear_canvas")) {
if (core::equalsIgnoreCase(action.name, "clear") || core::equalsIgnoreCase(action.name, "clear_canvas")) {
stroke_count_ = 0U;
current_color_ = "#000000";
return;
}
if (equalsIgnoreCase(action.name, "save")) {
if (core::equalsIgnoreCase(action.name, "save")) {
String target = (action.payload[0] != '\0' && action.payload[0] != '{') ? String(action.payload) : String();
if (body["path"].is<const char*>()) {
target = body["path"].as<const char*>();
@@ -1330,7 +1332,7 @@ class KidsCreativeModule : public ModuleBase {
}
return;
}
if (equalsIgnoreCase(action.name, "load")) {
if (core::equalsIgnoreCase(action.name, "load")) {
String source = (action.payload[0] != '\0' && action.payload[0] != '{') ? String(action.payload) : String();
if (body["path"].is<const char*>()) {
source = body["path"].as<const char*>();
@@ -1401,7 +1403,7 @@ class KidsLearningModule : public ModuleBase {
ModuleBase::handleAction(action);
DynamicJsonDocument body(512);
(void)parseJsonPayload(action, &body);
if (equalsIgnoreCase(action.name, "lesson_open")) {
if (core::equalsIgnoreCase(action.name, "lesson_open")) {
if (body["lesson"].is<const char*>()) {
lesson_id_ = body["lesson"].as<const char*>();
} else if (action.payload[0] != '\0' && action.payload[0] != '{') {
@@ -1411,19 +1413,19 @@ class KidsLearningModule : public ModuleBase {
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "lesson_next")) {
if (core::equalsIgnoreCase(action.name, "lesson_next")) {
lesson_step_ += 1U;
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "lesson_prev")) {
if (core::equalsIgnoreCase(action.name, "lesson_prev")) {
if (lesson_step_ > 0U) {
lesson_step_ -= 1U;
}
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "quiz_answer")) {
if (core::equalsIgnoreCase(action.name, "quiz_answer")) {
String answer;
if (body["answer"].is<const char*>()) {
answer = body["answer"].as<const char*>();
@@ -1437,18 +1439,18 @@ class KidsLearningModule : public ModuleBase {
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "session_start") || equalsIgnoreCase(action.name, "play")) {
if (core::equalsIgnoreCase(action.name, "session_start") || core::equalsIgnoreCase(action.name, "play")) {
startGuidedAudio(action.payload, body);
return;
}
if (equalsIgnoreCase(action.name, "pause")) {
if (core::equalsIgnoreCase(action.name, "pause")) {
if (context_.audio != nullptr) {
context_.audio->stop();
}
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "session_stop") || equalsIgnoreCase(action.name, "stop")) {
if (core::equalsIgnoreCase(action.name, "session_stop") || core::equalsIgnoreCase(action.name, "stop")) {
if (context_.media != nullptr) {
context_.media->stop(context_.audio);
} else if (context_.audio != nullptr) {
@@ -1457,7 +1459,7 @@ class KidsLearningModule : public ModuleBase {
saveProgress();
return;
}
if (equalsIgnoreCase(action.name, "seek_ms")) {
if (core::equalsIgnoreCase(action.name, "seek_ms")) {
cursor_ms_ = parseUint(action.payload, cursor_ms_);
saveProgress();
return;
@@ -1483,7 +1485,7 @@ class KidsLearningModule : public ModuleBase {
} else {
target = String("/apps/") + app_id_ + "/audio/session.mp3";
if (!LittleFS.exists(target.c_str())) {
target = "/music/boot_radio.mp3";
target = defaultBundledAudioForApp(app_id_.c_str());
}
}
bool ok = false;
@@ -1495,7 +1497,7 @@ class KidsLearningModule : public ModuleBase {
}
}
if (startsWithIgnoreCase(target.c_str(), "http://") || startsWithIgnoreCase(target.c_str(), "https://")) {
ok = context_.audio->playUrl(target.c_str());
ok = context_.audio->play(target.c_str());
} else if (context_.storage != nullptr && context_.storage->fileExists(target.c_str()) && context_.media != nullptr) {
ok = context_.media->play(target.c_str(), context_.audio);
}
@@ -1596,13 +1598,13 @@ class NesEmulatorModule : public ModuleBase {
sizeof(event),
"fps=%lu",
static_cast<unsigned long>(target_fps_));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
}
}
void handleAction(const AppAction& action) override {
ModuleBase::handleAction(action);
if (equalsIgnoreCase(action.name, "list_roms")) {
if (core::equalsIgnoreCase(action.name, "list_roms")) {
uint32_t count = 0U;
File dir = LittleFS.open("/apps/nes_emulator/roms", "r");
if (dir && dir.isDirectory()) {
@@ -1619,7 +1621,7 @@ class NesEmulatorModule : public ModuleBase {
}
char event[40] = {0};
std::snprintf(event, sizeof(event), "roms=%lu", static_cast<unsigned long>(count));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
return;
}
@@ -1630,11 +1632,11 @@ class NesEmulatorModule : public ModuleBase {
rom_path = body["path"].as<const char*>();
}
if (equalsIgnoreCase(action.name, "rom_validate")) {
if (core::equalsIgnoreCase(action.name, "rom_validate")) {
validateRom(rom_path.c_str());
return;
}
if (equalsIgnoreCase(action.name, "rom_start")) {
if (core::equalsIgnoreCase(action.name, "rom_start")) {
if (!validateRom(rom_path.c_str())) {
return;
}
@@ -1652,48 +1654,48 @@ class NesEmulatorModule : public ModuleBase {
next_frame_ms_ = millis();
frame_count_ = 0U;
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "rom_start");
core::copyText(status_.last_event, sizeof(status_.last_event), "rom_start");
return;
}
if (equalsIgnoreCase(action.name, "rom_stop")) {
if (core::equalsIgnoreCase(action.name, "rom_stop")) {
emu_running_ = false;
rom_buffer_.clear();
rom_buffer_.shrink_to_fit();
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "rom_stop");
core::copyText(status_.last_event, sizeof(status_.last_event), "rom_stop");
return;
}
if (equalsIgnoreCase(action.name, "set_fps")) {
if (core::equalsIgnoreCase(action.name, "set_fps")) {
const uint32_t requested = parseUint(action.payload, target_fps_);
target_fps_ = (requested < 30U) ? 30U : (requested > 60U ? 60U : requested);
return;
}
if (equalsIgnoreCase(action.name, "input") ||
equalsIgnoreCase(action.name, "btn_down") ||
equalsIgnoreCase(action.name, "btn_up")) {
if (core::equalsIgnoreCase(action.name, "input") ||
core::equalsIgnoreCase(action.name, "btn_down") ||
core::equalsIgnoreCase(action.name, "btn_up")) {
uint16_t mask = input_mask_;
if (has_json && body["mask"].is<uint16_t>()) {
mask = body["mask"].as<uint16_t>();
} else {
mask = static_cast<uint16_t>(parseUint(action.payload, input_mask_));
}
if (equalsIgnoreCase(action.name, "btn_down")) {
if (core::equalsIgnoreCase(action.name, "btn_down")) {
input_mask_ |= mask;
} else if (equalsIgnoreCase(action.name, "btn_up")) {
} else if (core::equalsIgnoreCase(action.name, "btn_up")) {
input_mask_ &= static_cast<uint16_t>(~mask);
} else {
input_mask_ = mask;
}
return;
}
if (equalsIgnoreCase(action.name, "core_status")) {
if (core::equalsIgnoreCase(action.name, "core_status")) {
char event[56] = {0};
std::snprintf(event,
sizeof(event),
"run=%u frames=%lu",
emu_running_ ? 1U : 0U,
static_cast<unsigned long>(frame_count_));
copyText(status_.last_event, sizeof(status_.last_event), event);
core::copyText(status_.last_event, sizeof(status_.last_event), event);
return;
}
}
@@ -1756,7 +1758,7 @@ class NesEmulatorModule : public ModuleBase {
return false;
}
setError("");
copyText(status_.last_event, sizeof(status_.last_event), "rom_ok");
core::copyText(status_.last_event, sizeof(status_.last_event), "rom_ok");
return true;
}
@@ -1821,46 +1823,46 @@ class NesEmulatorModule : public ModuleBase {
} // namespace
std::unique_ptr<IAppModule> createAppModule(const AppDescriptor& descriptor) {
if (equalsIgnoreCase(descriptor.id, "audio_player") ||
equalsIgnoreCase(descriptor.id, "kids_webradio") ||
equalsIgnoreCase(descriptor.id, "kids_podcast") ||
equalsIgnoreCase(descriptor.id, "kids_music")) {
if (core::equalsIgnoreCase(descriptor.id, "audio_player") ||
core::equalsIgnoreCase(descriptor.id, "kids_webradio") ||
core::equalsIgnoreCase(descriptor.id, "kids_podcast") ||
core::equalsIgnoreCase(descriptor.id, "kids_music")) {
return std::unique_ptr<IAppModule>(new AudioPlayerModule());
}
if (equalsIgnoreCase(descriptor.id, "audiobook_player")) {
if (core::equalsIgnoreCase(descriptor.id, "audiobook_player")) {
return std::unique_ptr<IAppModule>(new AudiobookModule());
}
if (equalsIgnoreCase(descriptor.id, "camera_video")) {
if (core::equalsIgnoreCase(descriptor.id, "camera_video")) {
return std::unique_ptr<IAppModule>(new CameraVideoModule());
}
if (equalsIgnoreCase(descriptor.id, "qr_scanner")) {
if (core::equalsIgnoreCase(descriptor.id, "qr_scanner")) {
return std::unique_ptr<IAppModule>(new QrScannerModule());
}
if (equalsIgnoreCase(descriptor.id, "dictaphone")) {
if (core::equalsIgnoreCase(descriptor.id, "dictaphone")) {
return std::unique_ptr<IAppModule>(new DictaphoneModule());
}
if (equalsIgnoreCase(descriptor.id, "timer_tools")) {
if (core::equalsIgnoreCase(descriptor.id, "timer_tools")) {
return std::unique_ptr<IAppModule>(new TimerToolsModule());
}
if (equalsIgnoreCase(descriptor.id, "flashlight")) {
if (core::equalsIgnoreCase(descriptor.id, "flashlight")) {
return std::unique_ptr<IAppModule>(new FlashlightModule());
}
if (equalsIgnoreCase(descriptor.id, "calculator")) {
if (core::equalsIgnoreCase(descriptor.id, "calculator")) {
return std::unique_ptr<IAppModule>(new CalculatorModule());
}
if (equalsIgnoreCase(descriptor.id, "kids_drawing") ||
equalsIgnoreCase(descriptor.id, "kids_coloring")) {
if (core::equalsIgnoreCase(descriptor.id, "kids_drawing") ||
core::equalsIgnoreCase(descriptor.id, "kids_coloring")) {
return std::unique_ptr<IAppModule>(new KidsCreativeModule());
}
if (equalsIgnoreCase(descriptor.id, "kids_yoga") ||
equalsIgnoreCase(descriptor.id, "kids_meditation") ||
equalsIgnoreCase(descriptor.id, "kids_languages") ||
equalsIgnoreCase(descriptor.id, "kids_math") ||
equalsIgnoreCase(descriptor.id, "kids_science") ||
equalsIgnoreCase(descriptor.id, "kids_geography")) {
if (core::equalsIgnoreCase(descriptor.id, "kids_yoga") ||
core::equalsIgnoreCase(descriptor.id, "kids_meditation") ||
core::equalsIgnoreCase(descriptor.id, "kids_languages") ||
core::equalsIgnoreCase(descriptor.id, "kids_math") ||
core::equalsIgnoreCase(descriptor.id, "kids_science") ||
core::equalsIgnoreCase(descriptor.id, "kids_geography")) {
return std::unique_ptr<IAppModule>(new KidsLearningModule());
}
if (equalsIgnoreCase(descriptor.id, "nes_emulator")) {
if (core::equalsIgnoreCase(descriptor.id, "nes_emulator")) {
return std::unique_ptr<IAppModule>(new NesEmulatorModule());
}
return nullptr;
@@ -0,0 +1,66 @@
// audio_player_module.cpp - Audio player module bridging to AmigaAudioPlayer.
#include "app/modules/audio_player_module.h"
#include <cstring>
#include "app/app_registry.h"
#include "runtime/runtime_services.h"
bool AudioPlayerModule::onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) {
(void)entry;
(void)mode;
if (services == nullptr || services->audio == nullptr) {
std::strncpy(last_error_, "no_audio_service", sizeof(last_error_) - 1U);
state_ = AppModuleState::kError;
return false;
}
state_ = AppModuleState::kRunning;
last_error_[0] = '\0';
Serial.println("[APP_MOD] audio_player open");
return true;
}
void AudioPlayerModule::onClose(const char* reason, RuntimeServices* services) {
(void)reason;
if (services != nullptr && services->audio != nullptr) {
services->audio->stop();
}
state_ = AppModuleState::kIdle;
Serial.printf("[APP_MOD] audio_player close reason=%s\n", reason != nullptr ? reason : "n/a");
}
void AudioPlayerModule::onTick(uint32_t now_ms, RuntimeServices* services) {
(void)now_ms;
(void)services;
// AmigaAudioPlayer is ticked separately from main.cpp for now.
}
bool AudioPlayerModule::onAction(const char* action, const char* payload, RuntimeServices* services) {
if (action == nullptr || services == nullptr || services->audio == nullptr) {
return false;
}
if (std::strcmp(action, "play") == 0) {
return services->audio->play(payload);
}
if (std::strcmp(action, "stop") == 0) {
services->audio->stop();
return true;
}
if (std::strcmp(action, "pause") == 0) {
services->audio->stop();
return true;
}
if (std::strcmp(action, "set_volume") == 0 && payload != nullptr) {
const int vol = atoi(payload);
if (vol >= 0 && vol <= 100) {
// Map 0-100 to 0-21.
const uint8_t mapped = static_cast<uint8_t>((vol * 21U) / 100U);
services->audio->setVolume(mapped);
return true;
}
}
Serial.printf("[APP_MOD] audio_player unknown action=%s\n", action);
return false;
}
@@ -1,12 +0,0 @@
#include "app/runtime_web_service.h"
void RuntimeWebService::configure(SetupWebUiFn setup_web_ui) {
setup_web_ui_ = setup_web_ui;
}
void RuntimeWebService::setupWebUi() const {
if (setup_web_ui_ == nullptr) {
return;
}
setup_web_ui_();
}
@@ -22,11 +22,19 @@
#include <freertos/queue.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#if __has_include(<esp_heap_caps.h>)
#include <esp_heap_caps.h>
#define ZACUS_HAS_AUDIO_HEAP_CAPS 1
#else
#define ZACUS_HAS_AUDIO_HEAP_CAPS 0
#endif
#else
#define ZACUS_HAS_AUDIO_HEAP_CAPS 0
#endif
namespace {
constexpr char kDiagnosticTrackPath[] = "/music/boot_radio.mp3";
constexpr char kDiagnosticTrackPath[] = "/apps/audio_player/audio/default.mp3";
constexpr uint8_t kMaxTrackPathLen = 120U;
constexpr uint16_t kBitrateScanBytes = 4096U;
constexpr uint8_t kAudioDoneQueueDepth = 6U;
@@ -39,6 +47,8 @@ constexpr uint16_t kAudioPumpIdleDelayMs = 4U;
constexpr uint16_t kAudioStateLockTimeoutMs = 20U;
constexpr uint32_t kAudioUnderrunThresholdBytes = 768U;
constexpr uint32_t kAudioUnderrunCooldownMs = 250U;
constexpr size_t kAudioMinLargestDmaBlockBytes = 20000U;
constexpr uint32_t kAudioRestoreRetryDelayMs = 2500U;
struct AudioPinProfile {
int bck;
@@ -112,6 +122,28 @@ bool resolveFileSystem(bool use_sd, fs::FS*& out_fs, const char*& out_label) {
#endif
}
bool hasAudioDmaBudget(size_t* out_largest_dma, size_t* out_free_dma) {
#if ZACUS_HAS_AUDIO_HEAP_CAPS
const size_t largest_dma = heap_caps_get_largest_free_block(MALLOC_CAP_DMA);
const size_t free_dma = heap_caps_get_free_size(MALLOC_CAP_DMA);
if (out_largest_dma != nullptr) {
*out_largest_dma = largest_dma;
}
if (out_free_dma != nullptr) {
*out_free_dma = free_dma;
}
return largest_dma >= kAudioMinLargestDmaBlockBytes;
#else
if (out_largest_dma != nullptr) {
*out_largest_dma = 0U;
}
if (out_free_dma != nullptr) {
*out_free_dma = 0U;
}
return true;
#endif
}
uint16_t parseMp3BitrateHeader(const uint8_t* header, size_t header_len) {
if (header == nullptr || header_len < 4U) {
return 0U;
@@ -416,6 +448,15 @@ bool AudioManager::ensurePlayer() {
Serial.println("[AUDIO] player disabled: PSRAM not detected");
return false;
}
size_t largest_dma = 0U;
size_t free_dma = 0U;
if (!hasAudioDmaBudget(&largest_dma, &free_dma)) {
Serial.printf("[AUDIO] player deferred: largest_dma=%lu free_dma=%lu need>=%lu\n",
static_cast<unsigned long>(largest_dma),
static_cast<unsigned long>(free_dma),
static_cast<unsigned long>(kAudioMinLargestDmaBlockBytes));
return false;
}
player_ = std::unique_ptr<Audio>(new Audio());
if (!player_) {
Serial.println("[AUDIO] alloc failed for ESP32-audioI2S player");
@@ -442,6 +483,8 @@ bool AudioManager::begin() {
return false;
}
begun_ = true;
restore_pending_ = false;
restore_retry_not_before_ms_ = 0U;
g_audio_manager_instance = this;
pump_task_enabled_ = startAudioPump();
Serial.printf("[AUDIO] backend=ESP32-audioI2S profile=%u:%s fx=%u:%s vol=%u\n",
@@ -664,6 +707,14 @@ bool AudioManager::requestPlay(const char* filename, bool diagnostic_tone) {
}
last_stream_url_.remove(0);
if (!begun_) {
(void)restoreOutputResources();
if (!begun_) {
Serial.println("[AUDIO] play request denied: backend unavailable");
return false;
}
}
AudioCodec codec = AudioCodec::kUnknown;
uint16_t bitrate_kbps = 0U;
detectTrackCodecAndBitrate(normalized_path, use_sd, codec, bitrate_kbps);
@@ -712,6 +763,13 @@ bool AudioManager::requestPlayUrl(const char* url) {
if (url == nullptr || url[0] == '\0') {
return false;
}
if (!begun_) {
(void)restoreOutputResources();
if (!begun_) {
Serial.println("[AUDIO] stream request denied: backend unavailable");
return false;
}
}
if (!takeStateLock(kAudioStateLockTimeoutMs)) {
return false;
}
@@ -788,6 +846,60 @@ void AudioManager::stop() {
releaseStateLock();
}
void AudioManager::releaseOutputResources() {
stopAudioPump();
if (!takeStateLock(kAudioStateLockTimeoutMs)) {
Serial.println("[AUDIO] release lock timeout");
return;
}
pending_start_ = false;
pending_track_.remove(0);
pending_diagnostic_tone_ = false;
last_stream_url_.remove(0);
if (player_ != nullptr) {
player_->stopSong();
}
clearTrackState();
player_.reset();
begun_ = false;
#if defined(ARDUINO_ARCH_ESP32)
if (rtos_state_ != nullptr && rtos_state_->done_queue != nullptr) {
xQueueReset(rtos_state_->done_queue);
}
#endif
restore_pending_ = false;
restore_retry_not_before_ms_ = 0U;
releaseStateLock();
Serial.println("[AUDIO] backend released");
}
bool AudioManager::restoreOutputResources() {
if (begun_) {
restore_pending_ = false;
restore_retry_not_before_ms_ = 0U;
return true;
}
size_t largest_dma = 0U;
size_t free_dma = 0U;
if (!hasAudioDmaBudget(&largest_dma, &free_dma)) {
restore_pending_ = true;
restore_retry_not_before_ms_ = millis() + kAudioRestoreRetryDelayMs;
Serial.printf("[AUDIO] backend restore deferred largest_dma=%lu free_dma=%lu need>=%lu\n",
static_cast<unsigned long>(largest_dma),
static_cast<unsigned long>(free_dma),
static_cast<unsigned long>(kAudioMinLargestDmaBlockBytes));
return false;
}
const bool ok = begin();
if (!ok) {
restore_pending_ = true;
restore_retry_not_before_ms_ = millis() + kAudioRestoreRetryDelayMs;
Serial.println("[AUDIO] backend restore failed");
return false;
}
return true;
}
void AudioManager::finishPlaybackAndNotify() {
char finished_track[kAudioDoneTrackLen] = {0};
if (!takeStateLock(kAudioStateLockTimeoutMs)) {
@@ -836,6 +948,12 @@ void AudioManager::processPendingPlaybackEvents() {
void AudioManager::update() {
if (!begun_) {
if (restore_pending_) {
const uint32_t now_ms = millis();
if (now_ms >= restore_retry_not_before_ms_) {
(void)restoreOutputResources();
}
}
return;
}
bool finished_without_pump = false;
-476
View File
@@ -1,476 +0,0 @@
// audio_manager.cpp - audio playback over I2S.
#include "audio_manager.h"
#include <AudioFileSource.h>
#include <AudioFileSourceFS.h>
#include <AudioFileSourcePROGMEM.h>
#include <AudioGenerator.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorRTTTL.h>
#include <AudioGeneratorWAV.h>
#include <AudioOutputI2S.h>
#include <AudioOutputMixer.h>
#include <FS.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
#include <memory>
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<SD_MMC.h>)
#include <SD_MMC.h>
#define ZACUS_HAS_SD_AUDIO 1
#else
#define ZACUS_HAS_SD_AUDIO 0
#endif
#include "ui_freenove_config.h"
namespace {
constexpr uint8_t kVolumeMax = 21;
constexpr char kDiagnosticRtttl[] PROGMEM = "zacus:d=4,o=5,b=196:c,8e,8g,2c6";
struct AudioPinProfile {
int bck;
int ws;
int dout;
const char* label;
};
constexpr AudioPinProfile kAudioPinProfiles[] = {
{FREENOVE_I2S_BCK, FREENOVE_I2S_WS, FREENOVE_I2S_DOUT, "sketch19"},
{FREENOVE_I2S_WS, FREENOVE_I2S_BCK, FREENOVE_I2S_DOUT, "swap_bck_ws"},
{FREENOVE_I2S_BCK, FREENOVE_I2S_WS, 2, "dout2_alt"},
};
constexpr uint8_t kAudioPinProfileCount = static_cast<uint8_t>(sizeof(kAudioPinProfiles) / sizeof(kAudioPinProfiles[0]));
float volumeToGain(uint8_t volume) {
if (volume > kVolumeMax) {
volume = kVolumeMax;
}
return static_cast<float>(volume) / static_cast<float>(kVolumeMax);
}
bool endsWithIgnoreCase(const char* value, const char* suffix) {
if (value == nullptr || suffix == nullptr) {
return false;
}
const size_t value_len = std::strlen(value);
const size_t suffix_len = std::strlen(suffix);
if (suffix_len == 0U || value_len < suffix_len) {
return false;
}
const char* tail = value + (value_len - suffix_len);
for (size_t index = 0; index < suffix_len; ++index) {
const int lhs = std::tolower(static_cast<unsigned char>(tail[index]));
const int rhs = std::tolower(static_cast<unsigned char>(suffix[index]));
if (lhs != rhs) {
return false;
}
}
return true;
}
} // namespace
AudioManager::AudioManager() = default;
AudioManager::~AudioManager() {
stop();
delete mixer_;
mixer_ = nullptr;
delete output_;
output_ = nullptr;
}
bool AudioManager::begin() {
return ensureOutput();
}
bool AudioManager::playOnChannel(uint8_t channel_index,
AudioFileSource* source,
AudioGenerator* decoder,
const char* track) {
if (channel_index >= 2U || source == nullptr || decoder == nullptr || track == nullptr || track[0] == '\0') {
delete decoder;
delete source;
return false;
}
if (!ensureOutput() || channels_[channel_index].stub == nullptr) {
delete decoder;
delete source;
return false;
}
stopChannel(channel_index);
channels_[channel_index].source = source;
channels_[channel_index].decoder = decoder;
channels_[channel_index].track = track;
channels_[channel_index].stub->SetGain(0.0f);
if (!channels_[channel_index].decoder->begin(channels_[channel_index].source, channels_[channel_index].stub)) {
Serial.printf("[AUDIO] decoder begin failed: %s\n", track);
stopChannel(channel_index);
return false;
}
return true;
}
uint8_t AudioManager::selectTargetChannel() const {
if (!anyChannelRunning()) {
return 0U;
}
return (active_channel_ == 0U) ? 1U : 0U;
}
void AudioManager::startCrossfade(uint8_t from_channel, uint8_t to_channel) {
crossfade_active_ = true;
crossfade_from_ = from_channel;
crossfade_to_ = to_channel;
crossfade_started_ms_ = millis();
active_channel_ = to_channel;
applyChannelGains();
}
void AudioManager::stopCrossfade() {
crossfade_active_ = false;
}
void AudioManager::applyChannelGains() {
if (channels_[0].stub == nullptr || channels_[1].stub == nullptr) {
return;
}
const float base_gain = volumeToGain(volume_);
if (crossfade_active_) {
const uint32_t elapsed_ms = millis() - crossfade_started_ms_;
uint16_t progress_per_mille = 1000U;
if (crossfade_duration_ms_ > 0U && elapsed_ms < crossfade_duration_ms_) {
progress_per_mille = static_cast<uint16_t>((elapsed_ms * 1000U) / crossfade_duration_ms_);
}
if (progress_per_mille > 1000U) {
progress_per_mille = 1000U;
}
const float to_gain = base_gain * (static_cast<float>(progress_per_mille) / 1000.0f);
const float from_gain = base_gain - to_gain;
channels_[crossfade_from_].stub->SetGain(from_gain);
channels_[crossfade_to_].stub->SetGain(to_gain);
if (progress_per_mille >= 1000U) {
stopChannel(crossfade_from_);
stopCrossfade();
}
return;
}
for (uint8_t index = 0U; index < 2U; ++index) {
if (channels_[index].decoder == nullptr) {
channels_[index].stub->SetGain(0.0f);
continue;
}
channels_[index].stub->SetGain((index == active_channel_) ? base_gain : 0.0f);
}
}
bool AudioManager::play(const char* filename) {
if (filename == nullptr || filename[0] == '\0') {
Serial.println("[AUDIO] empty filename");
return false;
}
if (!ensureOutput()) {
return false;
}
String fixed_path = filename;
bool use_sd_mmc = false;
if (fixed_path.startsWith("/littlefs/")) {
fixed_path = fixed_path.substring(9);
if (!fixed_path.startsWith("/")) {
fixed_path = "/" + fixed_path;
}
}
if (fixed_path.startsWith("/sd/")) {
fixed_path = fixed_path.substring(3);
if (!fixed_path.startsWith("/")) {
fixed_path = "/" + fixed_path;
}
use_sd_mmc = true;
}
fs::FS* file_system = &LittleFS;
const char* storage_label = "LittleFS";
if (use_sd_mmc) {
#if ZACUS_HAS_SD_AUDIO
if (!SD_MMC.begin("/sdcard", true)) {
Serial.println("[AUDIO] SD_MMC unavailable for audio path");
return false;
}
file_system = &SD_MMC;
storage_label = "SD_MMC";
#else
Serial.println("[AUDIO] SD audio path requested but SD_MMC support is unavailable");
return false;
#endif
}
if (file_system == nullptr || !file_system->exists(fixed_path.c_str())) {
Serial.printf("[AUDIO] missing file: %s (%s)\n", fixed_path.c_str(), storage_label);
return false;
}
File metadata = file_system->open(fixed_path.c_str(), "r");
if (!metadata) {
Serial.printf("[AUDIO] failed to open file metadata: %s (%s)\n", fixed_path.c_str(), storage_label);
return false;
}
const size_t file_size = static_cast<size_t>(metadata.size());
metadata.close();
if (file_size == 0U) {
Serial.printf("[AUDIO] empty audio file: %s\n", fixed_path.c_str());
return false;
}
const bool had_running = anyChannelRunning();
const uint8_t previous_active = active_channel_;
const uint8_t target_channel = selectTargetChannel();
const bool is_wav = endsWithIgnoreCase(fixed_path.c_str(), ".wav");
// SECURITY FIX: Use std::make_unique to prevent memory leak if 2nd allocation fails
auto source = std::make_unique<AudioFileSourceFS>(*file_system, fixed_path.c_str());
auto decoder = is_wav ? std::make_unique<AudioGeneratorWAV>()
: std::make_unique<AudioGeneratorMP3>();
if (!playOnChannel(target_channel, source.get(), decoder.get(), fixed_path.c_str())) {
return false;
}
using_diagnostic_tone_ = false;
current_track_ = fixed_path.c_str();
playing_ = true;
if (had_running && channels_[previous_active].decoder != nullptr && previous_active != target_channel) {
startCrossfade(previous_active, target_channel);
} else {
active_channel_ = target_channel;
stopCrossfade();
applyChannelGains();
}
Serial.printf("[AUDIO] playing (%s): %s (%s)\n", is_wav ? "wav" : "mp3", fixed_path.c_str(), storage_label);
return true;
}
bool AudioManager::playDiagnosticTone() {
if (!ensureOutput()) {
return false;
}
const bool had_running = anyChannelRunning();
const uint8_t previous_active = active_channel_;
const uint8_t target_channel = selectTargetChannel();
// SECURITY FIX: Use std::make_unique to prevent memory leak if 2nd allocation fails
auto source = std::make_unique<AudioFileSourcePROGMEM>(kDiagnosticRtttl, std::strlen(kDiagnosticRtttl));
auto decoder = std::make_unique<AudioGeneratorRTTTL>();
if (!playOnChannel(target_channel, source.get(), decoder.get(), "builtin:rtttl")) {
Serial.println("[AUDIO] diagnostic tone begin failed");
return false;
}
using_diagnostic_tone_ = true;
current_track_ = "builtin:rtttl";
playing_ = true;
if (had_running && channels_[previous_active].decoder != nullptr && previous_active != target_channel) {
startCrossfade(previous_active, target_channel);
} else {
active_channel_ = target_channel;
stopCrossfade();
applyChannelGains();
}
Serial.printf("[AUDIO] playing diagnostic tone (profile=%u:%s)\n",
output_profile_,
outputProfileLabel(output_profile_));
return true;
}
void AudioManager::stopChannel(uint8_t channel_index) {
if (channel_index >= 2U) {
return;
}
AudioChannel& channel = channels_[channel_index];
if (channel.decoder != nullptr) {
channel.decoder->stop();
delete channel.decoder;
channel.decoder = nullptr;
}
if (channel.source != nullptr) {
channel.source->close();
delete channel.source;
channel.source = nullptr;
}
channel.track.remove(0);
if (channel.stub != nullptr) {
channel.stub->SetGain(0.0f);
}
}
void AudioManager::stopAllChannels() {
stopChannel(0U);
stopChannel(1U);
}
void AudioManager::stop() {
stopCrossfade();
stopAllChannels();
playing_ = false;
using_diagnostic_tone_ = false;
current_track_.remove(0);
}
bool AudioManager::anyChannelRunning() const {
return channels_[0].decoder != nullptr || channels_[1].decoder != nullptr;
}
void AudioManager::update() {
if (!playing_) {
return;
}
applyChannelGains();
String finished_track;
bool active_finished = false;
for (uint8_t index = 0U; index < 2U; ++index) {
AudioChannel& channel = channels_[index];
if (channel.decoder == nullptr) {
continue;
}
if (!channel.decoder->isRunning() || !channel.decoder->loop()) {
const bool was_active = (index == active_channel_);
const String ended_track = channel.track;
stopChannel(index);
if (was_active) {
active_finished = true;
finished_track = ended_track;
}
}
}
if (!crossfade_active_) {
applyChannelGains();
}
playing_ = anyChannelRunning();
if (playing_ && channels_[active_channel_].decoder != nullptr) {
current_track_ = channels_[active_channel_].track;
}
if (active_finished) {
if (!playing_) {
current_track_.remove(0);
}
finishPlaybackAndNotify(finished_track.c_str());
}
}
bool AudioManager::isPlaying() const {
return playing_;
}
void AudioManager::setVolume(uint8_t volume) {
if (volume > kVolumeMax) {
volume = kVolumeMax;
}
volume_ = volume;
applyChannelGains();
}
uint8_t AudioManager::volume() const {
return volume_;
}
const char* AudioManager::currentTrack() const {
return current_track_.c_str();
}
bool AudioManager::setOutputProfile(uint8_t profile_index) {
if (profile_index >= kAudioPinProfileCount) {
return false;
}
output_profile_ = profile_index;
applyOutputProfile();
return true;
}
uint8_t AudioManager::outputProfile() const {
return output_profile_;
}
uint8_t AudioManager::outputProfileCount() const {
return kAudioPinProfileCount;
}
const char* AudioManager::outputProfileLabel(uint8_t profile_index) const {
if (profile_index >= kAudioPinProfileCount) {
return "invalid";
}
return kAudioPinProfiles[profile_index].label;
}
void AudioManager::setAudioDoneCallback(AudioDoneCallback cb, void* ctx) {
done_cb_ = cb;
done_ctx_ = ctx;
}
bool AudioManager::ensureOutput() {
if (output_ == nullptr) {
output_ = new AudioOutputI2S(0, AudioOutputI2S::EXTERNAL_I2S);
}
if (output_ == nullptr) {
Serial.println("[AUDIO] failed to allocate I2S output");
return false;
}
if (mixer_ == nullptr) {
mixer_ = new AudioOutputMixer(32, output_);
}
if (mixer_ == nullptr) {
Serial.println("[AUDIO] failed to allocate mixer");
return false;
}
if (channels_[0].stub == nullptr) {
channels_[0].stub = mixer_->NewInput();
}
if (channels_[1].stub == nullptr) {
channels_[1].stub = mixer_->NewInput();
}
if (channels_[0].stub == nullptr || channels_[1].stub == nullptr) {
Serial.println("[AUDIO] failed to allocate mixer stubs");
return false;
}
output_->SetOutputModeMono(true);
output_->SetGain(1.0f);
applyOutputProfile();
applyChannelGains();
return true;
}
void AudioManager::applyOutputProfile() {
if (output_ == nullptr) {
return;
}
if (output_profile_ >= kAudioPinProfileCount) {
output_profile_ = 0U;
}
const AudioPinProfile& profile = kAudioPinProfiles[output_profile_];
output_->SetPinout(profile.bck, profile.ws, profile.dout);
Serial.printf("[AUDIO] ready (profile=%u:%s bck=%d ws=%d dout=%d mode=mono)\n",
output_profile_,
profile.label,
profile.bck,
profile.ws,
profile.dout);
}
void AudioManager::finishPlaybackAndNotify(const char* track) {
if (done_cb_ != nullptr) {
const char* reported_track = (track != nullptr && track[0] != '\0') ? track : current_track_.c_str();
done_cb_(reported_track, done_ctx_);
}
}
+7 -10
View File
@@ -6,6 +6,8 @@
#include <cstdio>
#include <esp_random.h>
#include "core/str_utils.h"
namespace AuthService {
namespace {
@@ -204,8 +206,7 @@ AuthStatus validateBearerToken(const char* auth_header) {
// Copy to buffer and trim whitespace
char token_copy[kTokenBufferSize];
std::strncpy(token_copy, provided_token, sizeof(token_copy) - 1);
token_copy[sizeof(token_copy) - 1] = '\0';
core::copyText(token_copy, sizeof(token_copy), provided_token);
// Trim trailing whitespace (newlines, spaces, etc)
size_t len = std::strlen(token_copy);
@@ -230,8 +231,7 @@ bool getCurrentToken(char* out_token_buffer, size_t buffer_size) {
if (!out_token_buffer || buffer_size < kTokenBufferSize || !g_initialized) {
return false;
}
std::strncpy(out_token_buffer, g_current_token, buffer_size - 1);
out_token_buffer[buffer_size - 1] = '\0';
core::copyText(out_token_buffer, buffer_size, g_current_token);
return true;
}
@@ -251,12 +251,10 @@ bool rotateToken(char* out_new_token_buffer, size_t buffer_size) {
}
// Update global state
std::strncpy(g_current_token, new_token, sizeof(g_current_token) - 1);
g_current_token[sizeof(g_current_token) - 1] = '\0';
core::copyText(g_current_token, sizeof(g_current_token), new_token);
// Return new token to caller
std::strncpy(out_new_token_buffer, new_token, buffer_size - 1);
out_new_token_buffer[buffer_size - 1] = '\0';
core::copyText(out_new_token_buffer, buffer_size, new_token);
Serial.printf("%s rotated - new token=%s\n", kLogTag, new_token);
return true;
@@ -280,8 +278,7 @@ bool reset() {
}
// Update global
std::strncpy(g_current_token, new_token, sizeof(g_current_token) - 1);
g_current_token[sizeof(g_current_token) - 1] = '\0';
core::copyText(g_current_token, sizeof(g_current_token), new_token);
Serial.printf("%s reset - new token=%s\n", kLogTag, new_token);
return true;
-209
View File
@@ -1,209 +0,0 @@
// button_manager.cpp - button scan + long press detection.
#include "button_manager.h"
#include "ui_freenove_config.h"
namespace {
constexpr uint32_t kDebounceMs = 30U;
constexpr uint32_t kLongPressMs = FREENOVE_BTN_LONG_PRESS_MS;
constexpr int kNoAnalogButtonMv = 2800;
const int kDigitalButtonPins[4] = {
FREENOVE_BTN_1,
FREENOVE_BTN_2,
FREENOVE_BTN_3,
FREENOVE_BTN_4,
};
} // namespace
bool ButtonManager::begin() {
#if FREENOVE_BTN_ANALOG_PIN >= 0
analog_mode_ = true;
analogReadResolution(12);
analogSetAttenuation(ADC_11db);
pinMode(FREENOVE_BTN_ANALOG_PIN, INPUT);
analog_key_ = 0U;
analog_raw_key_ = 0U;
analog_pressed_at_ms_ = 0U;
analog_raw_changed_ms_ = millis();
last_analog_mv_ = kNoAnalogButtonMv;
Serial.printf("[BTN] analog ladder mode on GPIO %d\n", FREENOVE_BTN_ANALOG_PIN);
#else
analog_mode_ = false;
last_analog_mv_ = -1;
for (uint8_t index = 0; index < 4; ++index) {
if (kDigitalButtonPins[index] >= 0) {
pinMode(kDigitalButtonPins[index], INPUT_PULLUP);
}
}
Serial.println("[BTN] digital mode");
#endif
return true;
}
bool ButtonManager::pollEvent(ButtonEvent* out_event) {
if (out_event == nullptr) {
return false;
}
if (analog_mode_) {
return pollAnalog(out_event);
}
return pollDigital(out_event);
}
bool ButtonManager::isPressed(uint8_t key) const {
if (key < 1U || key > 5U) {
return false;
}
if (analog_mode_) {
return analog_key_ == key;
}
if (key > 4U) {
return false;
}
return digital_pressed_[key - 1U];
}
uint8_t ButtonManager::currentKey() const {
if (analog_mode_) {
return analog_key_;
}
for (uint8_t index = 0; index < 4; ++index) {
if (digital_pressed_[index]) {
return static_cast<uint8_t>(index + 1U);
}
}
return 0U;
}
int ButtonManager::lastAnalogMilliVolts() const {
return last_analog_mv_;
}
uint8_t ButtonManager::decodeAnalogKey(int millivolts) const {
if (millivolts < 0) {
return 0U;
}
const int no_button_floor_mv = kNoAnalogButtonMv - threshold_range_mv_;
if (millivolts >= no_button_floor_mv) {
return 0U;
}
// Prefer midpoint buckets between nominal ladder voltages.
const int split_12 = (voltage_thresholds_[1] + voltage_thresholds_[2]) / 2;
const int split_23 = (voltage_thresholds_[2] + voltage_thresholds_[3]) / 2;
const int split_34 = (voltage_thresholds_[3] + voltage_thresholds_[4]) / 2;
const int split_45 = (voltage_thresholds_[4] + voltage_thresholds_[5]) / 2;
const int split_5n = (voltage_thresholds_[5] + no_button_floor_mv) / 2;
if (millivolts <= split_12) {
return 1U;
}
if (millivolts <= split_23) {
return 2U;
}
if (millivolts <= split_34) {
return 3U;
}
if (millivolts <= split_45) {
return 4U;
}
if (millivolts <= split_5n) {
return 5U;
}
// Fallback nearest-threshold match with wider tolerance for board variance.
int best_key = 1;
int best_delta = millivolts - voltage_thresholds_[1];
if (best_delta < 0) {
best_delta = -best_delta;
}
for (int index = 2; index <= 5; ++index) {
int delta = millivolts - voltage_thresholds_[index];
if (delta < 0) {
delta = -delta;
}
if (delta < best_delta) {
best_delta = delta;
best_key = index;
}
}
if (best_delta <= (threshold_range_mv_ * 7)) {
return static_cast<uint8_t>(best_key);
}
return 0U;
}
bool ButtonManager::pollAnalog(ButtonEvent* out_event) {
#if FREENOVE_BTN_ANALOG_PIN < 0
(void)out_event;
return false;
#else
const int analog_mv = analogReadMilliVolts(FREENOVE_BTN_ANALOG_PIN);
last_analog_mv_ = analog_mv;
const uint8_t raw_key = decodeAnalogKey(analog_mv);
const uint32_t now_ms = millis();
if (raw_key != analog_raw_key_) {
analog_raw_key_ = raw_key;
analog_raw_changed_ms_ = now_ms;
}
if ((now_ms - analog_raw_changed_ms_) < kDebounceMs) {
return false;
}
const uint8_t stable_key = analog_raw_key_;
if (stable_key == analog_key_) {
return false;
}
if (analog_key_ == 0U && stable_key > 0U) {
analog_key_ = stable_key;
analog_pressed_at_ms_ = now_ms;
return false;
}
if (analog_key_ == 0U) {
return false;
}
const uint8_t released_key = analog_key_;
const uint32_t hold_ms = now_ms - analog_pressed_at_ms_;
analog_key_ = stable_key;
analog_pressed_at_ms_ = (stable_key > 0U) ? now_ms : 0U;
if (hold_ms < kDebounceMs) {
return false;
}
out_event->key = released_key;
out_event->long_press = hold_ms >= kLongPressMs;
return true;
#endif
}
bool ButtonManager::pollDigital(ButtonEvent* out_event) {
const uint32_t now_ms = millis();
for (uint8_t index = 0; index < 4; ++index) {
if (kDigitalButtonPins[index] < 0) {
continue;
}
const bool pressed = (digitalRead(kDigitalButtonPins[index]) == LOW);
if (pressed && !digital_pressed_[index]) {
digital_pressed_[index] = true;
digital_pressed_at_ms_[index] = now_ms;
continue;
}
if (!pressed && digital_pressed_[index]) {
const uint32_t hold_ms = now_ms - digital_pressed_at_ms_[index];
digital_pressed_[index] = false;
if (hold_ms < kDebounceMs) {
continue;
}
out_event->key = static_cast<uint8_t>(index + 1U);
out_event->long_press = hold_ms >= kLongPressMs;
return true;
}
}
return false;
}
@@ -9,6 +9,7 @@
#include <cstring>
#include "ui_freenove_config.h"
#include "core/str_utils.h"
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_camera.h>) && FREENOVE_CAM_ENABLE
#include <esp_camera.h>
@@ -17,19 +18,14 @@
#define ZACUS_HAS_CAMERA 0
#endif
namespace {
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_heap_caps.h>)
#include <esp_heap_caps.h>
#define ZACUS_HAS_HEAP_CAPS 1
#else
#define ZACUS_HAS_HEAP_CAPS 0
#endif
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
namespace {
String normalizeDir(const char* dir) {
if (dir == nullptr || dir[0] == '\0') {
@@ -235,7 +231,7 @@ CameraManager::CameraManager() {
bool CameraManager::begin(const Config& config) {
config_ = config;
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
core::copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
if (config_.jpeg_quality < 4U) {
config_.jpeg_quality = 4U;
}
@@ -257,8 +253,8 @@ bool CameraManager::begin(const Config& config) {
snapshot_.jpeg_quality = config_.jpeg_quality;
snapshot_.fb_count = config_.fb_count;
snapshot_.xclk_hz = config_.xclk_hz;
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
core::copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
core::copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
recorder_mode_ = false;
recorder_frozen_ = false;
recorder_frozen_fb_ = nullptr;
@@ -273,8 +269,8 @@ bool CameraManager::begin(const Config& config) {
bool CameraManager::ensureSnapshotDir() {
const String dir = normalizeDir(config_.snapshot_dir);
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
core::copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
core::copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
if (LittleFS.exists(dir.c_str())) {
return true;
}
@@ -333,6 +329,12 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
cfg.pin_reset = FREENOVE_CAM_RESET;
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;
#endif
@@ -348,8 +350,13 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
cfg.jpeg_quality = 12U;
} else {
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = frameSizeFromText(config_.frame_size);
cfg.jpeg_quality = config_.jpeg_quality;
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);
@@ -360,7 +367,7 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
fallback.frame_size = FRAMESIZE_QQVGA;
fallback.fb_count = 1U;
} else {
fallback.frame_size = FRAMESIZE_QVGA;
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;
}
@@ -386,7 +393,7 @@ bool CameraManager::initCameraForMode(bool recorder_mode) {
snapshot_.initialized = true;
snapshot_.jpeg_quality = static_cast<uint8_t>(cfg.jpeg_quality);
snapshot_.fb_count = static_cast<uint8_t>(cfg.fb_count);
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), frameSizeToText(cfg.frame_size));
core::copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), frameSizeToText(cfg.frame_size));
snapshot_.width = frameSizeWidth(cfg.frame_size);
snapshot_.height = frameSizeHeight(cfg.frame_size);
recorder_mode_ = recorder_mode;
@@ -665,7 +672,7 @@ bool CameraManager::snapshotToFile(const char* filename_hint, String* out_path)
++snapshot_.capture_count;
snapshot_.width = frame->width;
snapshot_.height = frame->height;
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
core::copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
clearLastError();
if (out_path != nullptr) {
*out_path = path;
@@ -889,8 +896,8 @@ bool CameraManager::recorderSaveFrozen(String* out_path, RecorderSaveFormat form
snapshot_.last_snapshot_ok = true;
snapshot_.last_capture_ms = millis();
++snapshot_.capture_count;
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
copyText(snapshot_.recorder_selected_file, sizeof(snapshot_.recorder_selected_file), path.c_str());
core::copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
core::copyText(snapshot_.recorder_selected_file, sizeof(snapshot_.recorder_selected_file), path.c_str());
clearLastError();
if (out_path != nullptr) {
*out_path = path;
@@ -985,7 +992,7 @@ CameraManager::Snapshot CameraManager::snapshot() const {
}
void CameraManager::setLastError(const char* message) {
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
core::copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void CameraManager::clearLastError() {
-332
View File
@@ -1,332 +0,0 @@
// camera_manager.cpp - camera lifecycle + JPEG snapshots.
#include "camera_manager.h"
#include <FS.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
#include "ui_freenove_config.h"
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_camera.h>) && FREENOVE_CAM_ENABLE
#include <esp_camera.h>
#define ZACUS_HAS_CAMERA 1
#else
#define ZACUS_HAS_CAMERA 0
#endif
namespace {
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
String normalizeDir(const char* dir) {
if (dir == nullptr || dir[0] == '\0') {
return String("/picture");
}
String normalized = dir;
normalized.trim();
if (normalized.isEmpty()) {
normalized = "/picture";
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
if (normalized.endsWith("/")) {
normalized.remove(normalized.length() - 1U);
}
if (normalized.isEmpty()) {
normalized = "/picture";
}
return normalized;
}
String sanitizeFileBasename(const char* filename_hint) {
String base = (filename_hint != nullptr) ? filename_hint : "";
base.trim();
if (base.isEmpty()) {
base = "story_";
base += String(static_cast<unsigned long>(millis()));
}
// Keep names portable across FS/web flows.
for (size_t index = 0U; index < base.length(); ++index) {
const char ch = base[index];
const bool keep = std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-' || ch == '.';
if (!keep) {
base.setCharAt(index, '_');
}
}
if (!base.endsWith(".jpg") && !base.endsWith(".jpeg")) {
base += ".jpg";
}
return base;
}
#if ZACUS_HAS_CAMERA
framesize_t frameSizeFromText(const char* text) {
if (text == nullptr || text[0] == '\0') {
return FRAMESIZE_VGA;
}
char normalized[20] = {0};
std::strncpy(normalized, text, sizeof(normalized) - 1U);
for (size_t index = 0U; normalized[index] != '\0'; ++index) {
normalized[index] = static_cast<char>(std::toupper(static_cast<unsigned char>(normalized[index])));
}
if (std::strcmp(normalized, "QQVGA") == 0) {
return FRAMESIZE_QQVGA;
}
if (std::strcmp(normalized, "HQVGA") == 0) {
return FRAMESIZE_HQVGA;
}
if (std::strcmp(normalized, "QVGA") == 0) {
return FRAMESIZE_QVGA;
}
if (std::strcmp(normalized, "CIF") == 0) {
return FRAMESIZE_CIF;
}
if (std::strcmp(normalized, "VGA") == 0) {
return FRAMESIZE_VGA;
}
if (std::strcmp(normalized, "SVGA") == 0) {
return FRAMESIZE_SVGA;
}
if (std::strcmp(normalized, "XGA") == 0) {
return FRAMESIZE_XGA;
}
if (std::strcmp(normalized, "SXGA") == 0) {
return FRAMESIZE_SXGA;
}
if (std::strcmp(normalized, "UXGA") == 0) {
return FRAMESIZE_UXGA;
}
if (std::strcmp(normalized, "HD") == 0) {
return FRAMESIZE_HD;
}
if (std::strcmp(normalized, "FHD") == 0) {
return FRAMESIZE_FHD;
}
return FRAMESIZE_VGA;
}
#endif
} // namespace
CameraManager::CameraManager() {
snapshot_.supported = (ZACUS_HAS_CAMERA != 0);
}
bool CameraManager::begin(const Config& config) {
config_ = config;
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
if (config_.jpeg_quality < 4U) {
config_.jpeg_quality = 4U;
}
if (config_.jpeg_quality > 63U) {
config_.jpeg_quality = 63U;
}
if (config_.fb_count == 0U) {
config_.fb_count = 1U;
}
if (config_.fb_count > 2U) {
config_.fb_count = 2U;
}
if (config_.xclk_hz < 1000000UL) {
config_.xclk_hz = 10000000UL;
}
snapshot_.supported = (ZACUS_HAS_CAMERA != 0);
snapshot_.enabled = false;
snapshot_.initialized = false;
snapshot_.last_snapshot_ok = false;
snapshot_.capture_count = 0U;
snapshot_.fail_count = 0U;
snapshot_.last_capture_ms = 0U;
snapshot_.width = 0U;
snapshot_.height = 0U;
snapshot_.jpeg_quality = config_.jpeg_quality;
snapshot_.fb_count = config_.fb_count;
snapshot_.xclk_hz = config_.xclk_hz;
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
snapshot_.last_file[0] = '\0';
snapshot_.last_error[0] = '\0';
return true;
}
bool CameraManager::start() {
snapshot_.enabled = false;
#if ZACUS_HAS_CAMERA
if (snapshot_.initialized) {
snapshot_.enabled = true;
return true;
}
camera_config_t cfg = {};
cfg.ledc_channel = LEDC_CHANNEL_0;
cfg.ledc_timer = LEDC_TIMER_0;
cfg.pin_d0 = FREENOVE_CAM_Y2;
cfg.pin_d1 = FREENOVE_CAM_Y3;
cfg.pin_d2 = FREENOVE_CAM_Y4;
cfg.pin_d3 = FREENOVE_CAM_Y5;
cfg.pin_d4 = FREENOVE_CAM_Y6;
cfg.pin_d5 = FREENOVE_CAM_Y7;
cfg.pin_d6 = FREENOVE_CAM_Y8;
cfg.pin_d7 = FREENOVE_CAM_Y9;
cfg.pin_xclk = FREENOVE_CAM_XCLK;
cfg.pin_pclk = FREENOVE_CAM_PCLK;
cfg.pin_vsync = FREENOVE_CAM_VSYNC;
cfg.pin_href = FREENOVE_CAM_HREF;
cfg.pin_sscb_sda = FREENOVE_CAM_SIOD;
cfg.pin_sscb_scl = FREENOVE_CAM_SIOC;
cfg.pin_pwdn = FREENOVE_CAM_PWDN;
cfg.pin_reset = FREENOVE_CAM_RESET;
cfg.xclk_freq_hz = config_.xclk_hz;
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = frameSizeFromText(config_.frame_size);
cfg.jpeg_quality = config_.jpeg_quality;
cfg.fb_count = config_.fb_count;
#if defined(CAMERA_GRAB_LATEST)
cfg.grab_mode = CAMERA_GRAB_LATEST;
#endif
#if defined(CAMERA_FB_IN_PSRAM)
cfg.fb_location = CAMERA_FB_IN_PSRAM;
#endif
const esp_err_t status = esp_camera_init(&cfg);
if (status != ESP_OK) {
setLastError("camera_init_failed");
Serial.printf("[CAM] init failed err=0x%x\n", static_cast<unsigned int>(status));
return false;
}
snapshot_.initialized = true;
snapshot_.enabled = true;
clearLastError();
Serial.printf("[CAM] ready frame=%s quality=%u fb=%u xclk=%lu\n",
snapshot_.frame_size,
static_cast<unsigned int>(snapshot_.jpeg_quality),
static_cast<unsigned int>(snapshot_.fb_count),
static_cast<unsigned long>(snapshot_.xclk_hz));
return true;
#else
setLastError("camera_not_supported");
return false;
#endif
}
void CameraManager::stop() {
#if ZACUS_HAS_CAMERA
if (snapshot_.initialized) {
esp_camera_deinit();
}
#endif
snapshot_.initialized = false;
snapshot_.enabled = false;
}
bool CameraManager::isEnabled() const {
return snapshot_.enabled;
}
bool CameraManager::ensureSnapshotDir() {
String dir = normalizeDir(config_.snapshot_dir);
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
if (LittleFS.exists(dir.c_str())) {
return true;
}
if (LittleFS.mkdir(dir.c_str())) {
return true;
}
setLastError("snapshot_dir_error");
return false;
}
String CameraManager::buildSnapshotPath(const char* filename_hint) const {
String dir = normalizeDir(config_.snapshot_dir);
String file = sanitizeFileBasename(filename_hint);
if (file.startsWith("/")) {
return file;
}
return dir + "/" + file;
}
bool CameraManager::snapshotToFile(const char* filename_hint, String* out_path) {
if (out_path != nullptr) {
out_path->remove(0);
}
if (!start()) {
++snapshot_.fail_count;
return false;
}
if (!ensureSnapshotDir()) {
++snapshot_.fail_count;
return false;
}
#if ZACUS_HAS_CAMERA
camera_fb_t* frame = esp_camera_fb_get();
if (frame == nullptr) {
++snapshot_.fail_count;
setLastError("camera_capture_failed");
return false;
}
const String path = buildSnapshotPath(filename_hint);
File file = LittleFS.open(path.c_str(), "w");
if (!file) {
esp_camera_fb_return(frame);
++snapshot_.fail_count;
setLastError("snapshot_write_failed");
return false;
}
const size_t written = file.write(frame->buf, frame->len);
file.close();
if (written != frame->len) {
esp_camera_fb_return(frame);
++snapshot_.fail_count;
setLastError("snapshot_write_incomplete");
return false;
}
snapshot_.last_snapshot_ok = true;
snapshot_.last_capture_ms = millis();
++snapshot_.capture_count;
snapshot_.width = frame->width;
snapshot_.height = frame->height;
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
clearLastError();
if (out_path != nullptr) {
*out_path = path;
}
esp_camera_fb_return(frame);
return true;
#else
(void)filename_hint;
++snapshot_.fail_count;
setLastError("camera_not_supported");
return false;
#endif
}
CameraManager::Snapshot CameraManager::snapshot() const {
return snapshot_;
}
void CameraManager::setLastError(const char* message) {
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void CameraManager::clearLastError() {
snapshot_.last_error[0] = '\0';
}
+114 -179
View File
@@ -1,258 +1,193 @@
// mutex_manager.cpp - Thread-safe access protection implementation
// mutex_manager.cpp - MutexManager singleton implementation.
#include "core/mutex_manager.h"
namespace {
// ============================================================================
// Singleton
// ============================================================================
// Internal mutex handles
SemaphoreHandle_t g_audio_mutex = nullptr;
SemaphoreHandle_t g_scenario_mutex = nullptr;
MutexManager& MutexManager::instance() {
static MutexManager inst;
return inst;
}
// Statistics for debugging race conditions
uint32_t g_audio_lock_count = 0;
uint32_t g_scenario_lock_count = 0;
uint32_t g_audio_timeout_count = 0;
uint32_t g_scenario_timeout_count = 0;
uint32_t g_max_audio_wait_us = 0;
uint32_t g_max_scenario_wait_us = 0;
// ============================================================================
// Lifecycle
// ============================================================================
// Track mutex holder task for deadlock detection
TaskHandle_t g_audio_mutex_owner = nullptr;
TaskHandle_t g_scenario_mutex_owner = nullptr;
} // namespace
namespace MutexManager {
bool init() {
if (g_audio_mutex != nullptr || g_scenario_mutex != nullptr) {
bool MutexManager::doInit() {
if (audio_mutex_ != nullptr || scenario_mutex_ != nullptr) {
Serial.println("[MUTEX] WARNING: Already initialized");
return false;
}
g_audio_mutex = xSemaphoreCreateMutex();
if (g_audio_mutex == nullptr) {
audio_mutex_ = xSemaphoreCreateMutex();
if (audio_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Failed to create audio mutex");
return false;
}
g_scenario_mutex = xSemaphoreCreateMutex();
if (g_scenario_mutex == nullptr) {
scenario_mutex_ = xSemaphoreCreateMutex();
if (scenario_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Failed to create scenario mutex");
vSemaphoreDelete(g_audio_mutex);
g_audio_mutex = nullptr;
vSemaphoreDelete(audio_mutex_);
audio_mutex_ = nullptr;
return false;
}
// Reset stats
g_audio_lock_count = 0;
g_scenario_lock_count = 0;
g_audio_timeout_count = 0;
g_scenario_timeout_count = 0;
g_max_audio_wait_us = 0;
g_max_scenario_wait_us = 0;
g_audio_mutex_owner = nullptr;
g_scenario_mutex_owner = nullptr;
audio_lock_count_.store(0U);
scenario_lock_count_.store(0U);
audio_timeout_count_.store(0U);
scenario_timeout_count_.store(0U);
max_audio_wait_us_.store(0U);
max_scenario_wait_us_.store(0U);
audio_owner_ = nullptr;
scenario_owner_ = nullptr;
Serial.println("[MUTEX] Initialized: dual-mutex strategy (audio + scenario)");
Serial.println("[MUTEX] Initialized: dual-mutex (audio + scenario), atomic stats");
return true;
}
void deinit() {
if (g_audio_mutex != nullptr) {
vSemaphoreDelete(g_audio_mutex);
g_audio_mutex = nullptr;
void MutexManager::doDeinit() {
if (audio_mutex_ != nullptr) {
vSemaphoreDelete(audio_mutex_);
audio_mutex_ = nullptr;
}
if (g_scenario_mutex != nullptr) {
vSemaphoreDelete(g_scenario_mutex);
g_scenario_mutex = nullptr;
if (scenario_mutex_ != nullptr) {
vSemaphoreDelete(scenario_mutex_);
scenario_mutex_ = nullptr;
}
g_audio_mutex_owner = nullptr;
g_scenario_mutex_owner = nullptr;
audio_owner_ = nullptr;
scenario_owner_ = nullptr;
Serial.println("[MUTEX] Deinitialized");
}
bool takeAudioMutex(uint32_t timeout_ms) {
if (g_audio_mutex == nullptr) {
// ============================================================================
// Internal helper — lock-free CAS update of a max field.
// ============================================================================
void MutexManager::updateMaxWait(std::atomic<uint32_t>& max_field, uint32_t elapsed_us) {
uint32_t current = max_field.load(std::memory_order_relaxed);
while (elapsed_us > current &&
!max_field.compare_exchange_weak(current, elapsed_us,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
}
}
// ============================================================================
// Audio mutex
// ============================================================================
bool MutexManager::takeAudio(uint32_t timeout_ms) {
if (audio_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Audio mutex not initialized");
return false;
}
// Deadlock detection: check if current task already owns scenario mutex
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
if (g_scenario_mutex_owner == current_task) {
Serial.println("[MUTEX] ERROR: Deadlock prevented - scenario mutex already held, cannot acquire audio mutex");
Serial.printf("[MUTEX] Correct order: audio → scenario. Current: scenario → audio\n");
// Deadlock prevention: audio must be acquired before scenario.
TaskHandle_t current = xTaskGetCurrentTaskHandle();
if (scenario_owner_ == current) {
Serial.println("[MUTEX] ERROR: Deadlock prevented scenario held, cannot take audio");
Serial.printf("[MUTEX] Correct order: audio → scenario. Got: scenario → audio\n");
return false;
}
const uint32_t start_us = micros();
const TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(g_audio_mutex, ticks);
const TickType_t ticks = (timeout_ms == 0U) ? 0U : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(audio_mutex_, ticks);
const uint32_t elapsed_us = micros() - start_us;
if (result == pdTRUE) {
g_audio_lock_count++;
g_audio_mutex_owner = current_task;
if (elapsed_us > g_max_audio_wait_us) {
g_max_audio_wait_us = elapsed_us;
}
if (elapsed_us > 5000) { // Log if wait > 5ms
Serial.printf("[MUTEX] Audio lock acquired after %lu µs (contention detected)\n", elapsed_us);
++audio_lock_count_;
audio_owner_ = current;
updateMaxWait(max_audio_wait_us_, elapsed_us);
if (elapsed_us > 5000U) {
Serial.printf("[MUTEX] Audio lock acquired after %lu µs (contention)\n",
static_cast<unsigned long>(elapsed_us));
}
return true;
}
g_audio_timeout_count++;
Serial.printf("[MUTEX] ERROR: Audio mutex timeout after %lu ms (elapsed %lu µs)\n",
timeout_ms, elapsed_us);
++audio_timeout_count_;
Serial.printf("[MUTEX] ERROR: Audio timeout after %lu ms (elapsed %lu µs)\n",
static_cast<unsigned long>(timeout_ms),
static_cast<unsigned long>(elapsed_us));
return false;
}
void releaseAudioMutex() {
if (g_audio_mutex == nullptr) {
void MutexManager::releaseAudio() {
if (audio_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Audio mutex not initialized");
return;
}
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
if (g_audio_mutex_owner != current_task) {
Serial.println("[MUTEX] WARNING: Releasing audio mutex from different task than owner");
if (audio_owner_ != xTaskGetCurrentTaskHandle()) {
Serial.println("[MUTEX] WARNING: Audio released from non-owner task");
}
g_audio_mutex_owner = nullptr;
xSemaphoreGive(g_audio_mutex);
audio_owner_ = nullptr;
xSemaphoreGive(audio_mutex_);
}
bool takeScenarioMutex(uint32_t timeout_ms) {
if (g_scenario_mutex == nullptr) {
// ============================================================================
// Scenario mutex
// ============================================================================
bool MutexManager::takeScenario(uint32_t timeout_ms) {
if (scenario_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Scenario mutex not initialized");
return false;
}
const uint32_t start_us = micros();
const TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(g_scenario_mutex, ticks);
const TickType_t ticks = (timeout_ms == 0U) ? 0U : pdMS_TO_TICKS(timeout_ms);
const BaseType_t result = xSemaphoreTake(scenario_mutex_, ticks);
const uint32_t elapsed_us = micros() - start_us;
if (result == pdTRUE) {
g_scenario_lock_count++;
g_scenario_mutex_owner = xTaskGetCurrentTaskHandle();
if (elapsed_us > g_max_scenario_wait_us) {
g_max_scenario_wait_us = elapsed_us;
}
if (elapsed_us > 5000) { // Log if wait > 5ms
Serial.printf("[MUTEX] Scenario lock acquired after %lu µs (contention detected)\n", elapsed_us);
++scenario_lock_count_;
scenario_owner_ = xTaskGetCurrentTaskHandle();
updateMaxWait(max_scenario_wait_us_, elapsed_us);
if (elapsed_us > 5000U) {
Serial.printf("[MUTEX] Scenario lock acquired after %lu µs (contention)\n",
static_cast<unsigned long>(elapsed_us));
}
return true;
}
g_scenario_timeout_count++;
Serial.printf("[MUTEX] ERROR: Scenario mutex timeout after %lu ms (elapsed %lu µs)\n",
timeout_ms, elapsed_us);
++scenario_timeout_count_;
Serial.printf("[MUTEX] ERROR: Scenario timeout after %lu ms (elapsed %lu µs)\n",
static_cast<unsigned long>(timeout_ms),
static_cast<unsigned long>(elapsed_us));
return false;
}
void releaseScenarioMutex() {
if (g_scenario_mutex == nullptr) {
void MutexManager::releaseScenario() {
if (scenario_mutex_ == nullptr) {
Serial.println("[MUTEX] ERROR: Scenario mutex not initialized");
return;
}
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
if (g_scenario_mutex_owner != current_task) {
Serial.println("[MUTEX] WARNING: Releasing scenario mutex from different task than owner");
if (scenario_owner_ != xTaskGetCurrentTaskHandle()) {
Serial.println("[MUTEX] WARNING: Scenario released from non-owner task");
}
g_scenario_mutex_owner = nullptr;
xSemaphoreGive(g_scenario_mutex);
scenario_owner_ = nullptr;
xSemaphoreGive(scenario_mutex_);
}
bool takeBothMutexes(uint32_t timeout_ms) {
// Acquire audio mutex first (deadlock prevention order: audio → scenario)
if (!takeAudioMutex(timeout_ms)) {
// ============================================================================
// Both mutexes (deadlock-safe order: audio → scenario)
// ============================================================================
bool MutexManager::takeBoth(uint32_t timeout_ms) {
if (!takeAudio(timeout_ms)) {
return false;
}
// Then scenario mutex
if (!takeScenarioMutex(timeout_ms)) {
releaseAudioMutex(); // Rollback on failure
if (!takeScenario(timeout_ms)) {
releaseAudio(); // Rollback on partial failure.
return false;
}
return true;
}
void releaseBothMutexes() {
// Release in reverse order (scenario → audio)
releaseScenarioMutex();
releaseAudioMutex();
}
uint32_t audioLockCount() {
return g_audio_lock_count;
}
uint32_t scenarioLockCount() {
return g_scenario_lock_count;
}
uint32_t audioTimeoutCount() {
return g_audio_timeout_count;
}
uint32_t scenarioTimeoutCount() {
return g_scenario_timeout_count;
}
uint32_t maxAudioWaitUs() {
return g_max_audio_wait_us;
}
uint32_t maxScenarioWaitUs() {
return g_max_scenario_wait_us;
}
} // namespace MutexManager
// ============================================================================
// RAII LOCK GUARDS IMPLEMENTATION
// ============================================================================
AudioLock::AudioLock(uint32_t timeout_ms)
: acquired_(MutexManager::takeAudioMutex(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] AudioLock FAILED (timeout %lu ms)\n", timeout_ms);
}
}
AudioLock::~AudioLock() {
if (acquired_) {
MutexManager::releaseAudioMutex();
}
}
ScenarioLock::ScenarioLock(uint32_t timeout_ms)
: acquired_(MutexManager::takeScenarioMutex(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] ScenarioLock FAILED (timeout %lu ms)\n", timeout_ms);
}
}
ScenarioLock::~ScenarioLock() {
if (acquired_) {
MutexManager::releaseScenarioMutex();
}
}
DualLock::DualLock(uint32_t timeout_ms)
: acquired_(MutexManager::takeBothMutexes(timeout_ms)) {
if (!acquired_) {
Serial.printf("[MUTEX] DualLock FAILED (timeout %lu ms)\n", timeout_ms);
}
}
DualLock::~DualLock() {
if (acquired_) {
MutexManager::releaseBothMutexes();
}
void MutexManager::releaseBoth() {
releaseScenario(); // Reverse order: scenario → audio.
releaseAudio();
}
@@ -280,8 +280,13 @@ void HardwareManager::setMicRuntimeEnabled(bool enabled) {
return;
}
mic_enabled_runtime_ = enabled;
if (mic_enabled_runtime_ && !mic_driver_ready_) {
mic_driver_ready_ = beginMic();
}
snapshot_.mic_ready = mic_enabled_runtime_ && mic_driver_ready_;
if (!mic_enabled_runtime_) {
endMic();
snapshot_.mic_ready = false;
snapshot_.mic_level_percent = 0U;
snapshot_.mic_peak = 0U;
snapshot_.mic_freq_hz = 0U;
@@ -317,6 +322,12 @@ void HardwareManager::setSceneSingleRandomBlink(bool enabled,
}
bool HardwareManager::beginMic() {
if (mic_driver_ready_) {
return true;
}
if (!mic_enabled_runtime_) {
return false;
}
i2s_config_t config = {};
config.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX);
config.sample_rate = kMicSampleRate;
@@ -353,6 +364,14 @@ bool HardwareManager::beginMic() {
return true;
}
void HardwareManager::endMic() {
if (!mic_driver_ready_) {
return;
}
i2s_driver_uninstall(kMicPort);
mic_driver_ready_ = false;
}
void HardwareManager::updateMic(uint32_t now_ms) {
if (!mic_enabled_runtime_) {
return;
@@ -1,4 +1,4 @@
// 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,
@@ -13,8 +13,10 @@ void TouchEmulator::begin(uint8_t grid_cols, uint8_t grid_rows,
cursor_grid_index_ = 0;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Initialized: grid %ux%u, cell %ux%u, offset (%u,%u)\n",
grid_cols_, grid_rows_, cell_width_, cell_height_, offset_x_, offset_y_);
// One-time init log — always kept.
Serial.printf("[TOUCH_EMU] grid=%ux%u cell=%ux%u offset=(%u,%u)\n",
grid_cols_, grid_rows_, cell_width_, cell_height_,
offset_x_, offset_y_);
}
void TouchEmulator::moveUp() {
@@ -22,10 +24,10 @@ void TouchEmulator::moveUp() {
if (current_row > 0) {
cursor_grid_index_ -= grid_cols_;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move UP → grid[%u] (%u,%u)\n",
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] UP → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
Serial.printf("[TOUCH_EMU] Move UP blocked (already at top row)\n");
#endif
}
}
@@ -34,10 +36,10 @@ void TouchEmulator::moveDown() {
if (current_row < grid_rows_ - 1) {
cursor_grid_index_ += grid_cols_;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move DOWN → grid[%u] (%u,%u)\n",
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] DOWN → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
Serial.printf("[TOUCH_EMU] Move DOWN blocked (already at bottom row)\n");
#endif
}
}
@@ -45,55 +47,51 @@ void TouchEmulator::moveLeft() {
const uint8_t current_col = cursor_grid_index_ % grid_cols_;
if (current_col > 0) {
cursor_grid_index_--;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move LEFT → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
// Wrap to rightmost column of same row
// Wrap to rightmost column of same row.
cursor_grid_index_ = (cursor_grid_index_ / grid_cols_) * grid_cols_ + (grid_cols_ - 1);
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move LEFT (wrap) → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
}
updateCursorCoordinates();
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] LEFT → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
#endif
}
void TouchEmulator::moveRight() {
const uint8_t current_col = cursor_grid_index_ % grid_cols_;
if (current_col < grid_cols_ - 1) {
cursor_grid_index_++;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move RIGHT → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
} else {
// Wrap to leftmost column of same row
// Wrap to leftmost column of same row.
cursor_grid_index_ = (cursor_grid_index_ / grid_cols_) * grid_cols_;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Move RIGHT (wrap) → grid[%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
}
updateCursorCoordinates();
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] RIGHT → [%u] (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
#endif
}
void TouchEmulator::getCursorPosition(uint16_t* out_x, uint16_t* out_y, uint8_t* out_grid_index) const {
if (out_x) {
*out_x = cursor_x_;
}
if (out_y) {
*out_y = cursor_y_;
}
if (out_grid_index) {
*out_grid_index = cursor_grid_index_;
}
void TouchEmulator::getCursorPosition(uint16_t* out_x, uint16_t* out_y,
uint8_t* out_grid_index) const {
if (out_x) *out_x = cursor_x_;
if (out_y) *out_y = cursor_y_;
if (out_grid_index) *out_grid_index = cursor_grid_index_;
}
void TouchEmulator::setGridIndex(uint8_t index) {
const uint8_t max_index = grid_cols_ * grid_rows_ - 1;
const uint8_t max_index = static_cast<uint8_t>(grid_cols_ * grid_rows_ - 1);
if (index <= max_index) {
cursor_grid_index_ = index;
updateCursorCoordinates();
Serial.printf("[TOUCH_EMU] Set grid[%u] → (%u,%u)\n",
#if ZACUS_SPRINT_DIAG_MODE
Serial.printf("[TOUCH_EMU] set [%u] → (%u,%u)\n",
cursor_grid_index_, cursor_x_, cursor_y_);
#endif
} else {
Serial.printf("[TOUCH_EMU] Invalid grid index %u (max %u)\n", index, max_index);
// Always log invalid index — indicates a bug.
Serial.printf("[TOUCH_EMU] ERROR: invalid index %u (max %u)\n", index, max_index);
}
}
@@ -104,8 +102,6 @@ bool TouchEmulator::isValidPosition(uint8_t max_apps) const {
void TouchEmulator::updateCursorCoordinates() {
const uint8_t row = cursor_grid_index_ / grid_cols_;
const uint8_t col = cursor_grid_index_ % grid_cols_;
// Calculate center of grid cell
cursor_x_ = offset_x_ + (col * cell_width_) + (cell_width_ / 2);
cursor_y_ = offset_y_ + (row * cell_height_) + (cell_height_ / 2);
cursor_x_ = offset_x_ + (col * cell_width_) + (cell_width_ / 2U);
cursor_y_ = offset_y_ + (row * cell_height_) + (cell_height_ / 2U);
}
@@ -1,926 +0,0 @@
// hardware_manager.cpp - Freenove peripherals (WS2812, mic, battery, buttons).
#include "hardware_manager.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include "ui_freenove_config.h"
namespace {
constexpr uint8_t kDefaultLedBrightness = static_cast<uint8_t>(FREENOVE_WS2812_BRIGHTNESS);
constexpr float kTwoPi = 6.2831853f;
constexpr float kTunerReferenceHz = 440.0f;
constexpr uint16_t kTunerMinHz = 80U;
constexpr uint16_t kTunerMaxHz = 1200U;
constexpr uint16_t kLaDetectMinHz = 320U;
constexpr uint16_t kLaDetectMaxHz = 560U;
constexpr uint8_t kTunerMinConfidence = 18U;
constexpr uint8_t kTunerDisplayMinConfidence = 40U;
constexpr uint8_t kTunerDisplayMinLevelPct = 10U;
constexpr uint16_t kTunerDisplayMinPeak = 1000U;
constexpr uint16_t kMicAgcTargetPeak = 7600U;
constexpr uint16_t kMicAgcDefaultGainQ8 = 256U;
constexpr uint16_t kMicAgcMinGainQ8 = 192U;
constexpr uint16_t kMicAgcMaxGainQ8 = 1024U;
constexpr uint16_t kMicAgcActivePeakMin = 28U;
constexpr uint16_t kMicAgcSignalDisplayPeakMin = 170U;
constexpr uint16_t kMicAgcStrongSignalPeakMin = 640U;
constexpr uint16_t kMicAgcWeakSignalReleaseMs = 450U;
constexpr uint16_t kMicAgcMinLevelDen = 5600U;
constexpr uint16_t kMicAgcAmbientGateDiv = 10U;
constexpr uint16_t kMicAgcGainDeadbandQ8 = 18U;
constexpr uint16_t kMicAgcMaxGainStepUp = 48U;
constexpr uint16_t kMicAgcMaxGainStepDown = 16U;
constexpr HardwareManager::LedPaletteEntry kLedPalette[] = {
{"SCENE_LOCKED", 255U, 96U, 22U, 88U, true},
{"SCENE_U_SON_PROTO", 255U, 40U, 18U, 86U, true},
{"SCENE_LA_DETECT", 32U, 224U, 170U, 56U, true},
{"SCENE_LA_DETECTOR", 32U, 224U, 170U, 56U, true},
{"SCENE_SEARCH", 32U, 224U, 170U, 56U, true},
{"SCENE_WIN_ETAPE1", 244U, 203U, 74U, 80U, true},
{"SCENE_WIN_ETAPE2", 244U, 203U, 74U, 80U, true},
{"ZACUS_U-SON", 18U, 45U, 95U, 52U, false},
{"__DEFAULT__", 18U, 45U, 95U, 52U, false},
};
uint8_t clampU8(int value) {
if (value < 0) {
return 0U;
}
if (value > 255) {
return 255U;
}
return static_cast<uint8_t>(value);
}
uint32_t hash32(uint32_t value) {
value ^= value >> 16;
value *= 0x7feb352dUL;
value ^= value >> 15;
value *= 0x846ca68bUL;
value ^= value >> 16;
return value;
}
uint8_t computeLevelPercent(uint16_t effective_peak, uint16_t den) {
const uint8_t raw_level =
static_cast<uint8_t>(std::min<uint32_t>(100U, (static_cast<uint32_t>(effective_peak) * 100U) / den));
return raw_level;
}
} // namespace
HardwareManager::HardwareManager()
: strip_(FREENOVE_WS2812_COUNT, FREENOVE_WS2812_PIN, NEO_GRB + NEO_KHZ800) {
snapshot_.led_brightness = kDefaultLedBrightness;
snapshot_.mic_gain_percent = static_cast<uint16_t>((mic_agc_gain_q8_ * 100U) / 256U);
snapshot_.mic_noise_floor = mic_noise_floor_raw_;
setScenePalette("SCENE_READY");
}
bool HardwareManager::begin() {
snapshot_.ready = true;
#if FREENOVE_WS2812_PIN >= 0 && FREENOVE_WS2812_COUNT > 0
strip_.begin();
strip_.setBrightness(snapshot_.led_brightness);
strip_.clear();
strip_.show();
snapshot_.ws2812_ready = true;
Serial.printf("[HW] WS2812 ready pin=%d count=%d\n", FREENOVE_WS2812_PIN, FREENOVE_WS2812_COUNT);
#else
snapshot_.ws2812_ready = false;
#endif
#if FREENOVE_BAT_ADC_PIN >= 0
analogReadResolution(12);
analogSetAttenuation(ADC_11db);
pinMode(FREENOVE_BAT_ADC_PIN, INPUT);
snapshot_.battery_ready = true;
Serial.printf("[HW] battery ADC ready pin=%d\n", FREENOVE_BAT_ADC_PIN);
#else
snapshot_.battery_ready = false;
#endif
#if FREENOVE_BAT_CHARGE_PIN >= 0
pinMode(FREENOVE_BAT_CHARGE_PIN, INPUT_PULLUP);
#endif
snapshot_.mic_ready = beginMic();
if (snapshot_.mic_ready) {
Serial.printf("[HW] mic I2S ready sck=%d ws=%d din=%d\n", FREENOVE_I2S_IN_SCK, FREENOVE_I2S_IN_WS, FREENOVE_I2S_IN_DIN);
} else {
Serial.println("[HW] mic I2S unavailable");
}
next_led_ms_ = 0U;
next_mic_ms_ = 0U;
next_battery_ms_ = 0U;
update(0U);
return true;
}
void HardwareManager::update(uint32_t now_ms) {
updateMic(now_ms);
updateBattery(now_ms);
updateLed(now_ms);
}
void HardwareManager::noteButton(uint8_t key, bool long_press, uint32_t now_ms) {
snapshot_.last_button = key;
snapshot_.last_button_long = long_press;
snapshot_.last_button_ms = now_ms;
++snapshot_.button_count;
button_flash_until_ms_ = now_ms + kButtonFlashMs;
}
void HardwareManager::setSceneHint(const char* scene_id) {
if (scene_id == nullptr || scene_id[0] == '\0') {
return;
}
if (std::strncmp(snapshot_.scene_id, scene_id, sizeof(snapshot_.scene_id) - 1U) == 0) {
return;
}
setScenePalette(scene_id);
}
bool HardwareManager::setManualLed(uint8_t r, uint8_t g, uint8_t b, uint8_t brightness, bool pulse) {
manual_led_ = true;
manual_pulse_ = pulse;
manual_r_ = r;
manual_g_ = g;
manual_b_ = b;
manual_brightness_ = brightness;
snapshot_.led_manual = true;
next_led_ms_ = 0U;
return snapshot_.ws2812_ready;
}
void HardwareManager::clearManualLed() {
manual_led_ = false;
manual_pulse_ = false;
snapshot_.led_manual = false;
next_led_ms_ = 0U;
}
HardwareManager::Snapshot HardwareManager::snapshot() const {
return snapshot_;
}
const HardwareManager::Snapshot& HardwareManager::snapshotRef() const {
return snapshot_;
}
bool HardwareManager::beginMic() {
i2s_config_t config = {};
config.mode = static_cast<i2s_mode_t>(I2S_MODE_MASTER | I2S_MODE_RX);
config.sample_rate = kMicSampleRate;
config.bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT;
config.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT;
config.communication_format = I2S_COMM_FORMAT_STAND_I2S;
config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1;
config.dma_buf_count = 4;
config.dma_buf_len = 128;
config.use_apll = false;
config.tx_desc_auto_clear = false;
config.fixed_mclk = 0;
if (i2s_driver_install(kMicPort, &config, 0, nullptr) != ESP_OK) {
return false;
}
i2s_pin_config_t pin_config = {};
pin_config.bck_io_num = FREENOVE_I2S_IN_SCK;
pin_config.ws_io_num = FREENOVE_I2S_IN_WS;
pin_config.data_out_num = I2S_PIN_NO_CHANGE;
pin_config.data_in_num = FREENOVE_I2S_IN_DIN;
if (i2s_set_pin(kMicPort, &pin_config) != ESP_OK) {
i2s_driver_uninstall(kMicPort);
return false;
}
if (i2s_set_clk(kMicPort, kMicSampleRate, I2S_BITS_PER_SAMPLE_32BIT, I2S_CHANNEL_MONO) != ESP_OK) {
i2s_driver_uninstall(kMicPort);
return false;
}
mic_driver_ready_ = true;
return true;
}
void HardwareManager::updateMic(uint32_t now_ms) {
if (!snapshot_.mic_ready) {
return;
}
if (now_ms < next_mic_ms_) {
return;
}
next_mic_ms_ = now_ms + kMicPeriodMs;
size_t bytes_read = 0U;
if (i2s_read(kMicPort, mic_raw_samples_, sizeof(mic_raw_samples_), &bytes_read, 0) != ESP_OK || bytes_read == 0U) {
return;
}
const size_t sample_count = bytes_read / sizeof(int32_t);
if (sample_count == 0U) {
return;
}
uint16_t raw_peak = 0U;
uint32_t raw_abs_sum = 0U;
for (size_t index = 0U; index < sample_count; ++index) {
// INMP441 data arrives as signed PCM24 packed in 32-bit slots (left-aligned).
int32_t value = mic_raw_samples_[index] >> 16;
if (value > 32767) {
value = 32767;
} else if (value < -32768) {
value = -32768;
}
const uint16_t abs_raw = static_cast<uint16_t>((value < 0) ? -value : value);
if (abs_raw > raw_peak) {
raw_peak = abs_raw;
}
raw_abs_sum += static_cast<uint32_t>(abs_raw);
// Apply dynamic digital gain before pitch/level extraction.
int32_t scaled = (value * static_cast<int32_t>(mic_agc_gain_q8_)) / 256;
if (scaled > 32767) {
scaled = 32767;
} else if (scaled < -32768) {
scaled = -32768;
}
mic_samples_[index] = static_cast<int16_t>(scaled);
}
const uint16_t raw_abs_mean = static_cast<uint16_t>(
std::min<uint32_t>(65535U, raw_abs_sum / static_cast<uint32_t>(sample_count)));
// Track ambient floor from raw microphone average levels to avoid over-amplifying idle noise.
if (raw_abs_mean <= static_cast<uint16_t>(mic_noise_floor_raw_ + 24U)) {
mic_noise_floor_raw_ =
static_cast<uint16_t>((static_cast<uint32_t>(mic_noise_floor_raw_) * 31U + raw_abs_mean) / 32U);
} else {
mic_noise_floor_raw_ =
static_cast<uint16_t>((static_cast<uint32_t>(mic_noise_floor_raw_) * 127U + raw_abs_mean) / 128U);
}
if (mic_noise_floor_raw_ < 24U) {
mic_noise_floor_raw_ = 24U;
}
const uint16_t signal_abs_raw = (raw_abs_mean > mic_noise_floor_raw_)
? static_cast<uint16_t>(raw_abs_mean - mic_noise_floor_raw_)
: 0U;
const uint16_t dynamic_active_peak_min =
std::max<uint16_t>(kMicAgcActivePeakMin, static_cast<uint16_t>(mic_noise_floor_raw_ / kMicAgcAmbientGateDiv));
const bool has_signal_window = signal_abs_raw >= dynamic_active_peak_min;
const bool has_stale_signal = (now_ms - mic_last_signal_ms_) > static_cast<uint32_t>(kMicAgcWeakSignalReleaseMs);
if (has_signal_window) {
mic_last_signal_ms_ = now_ms;
}
uint16_t target_gain_q8 = mic_agc_gain_q8_;
if (has_signal_window) {
const uint32_t desired = (static_cast<uint32_t>(kMicAgcTargetPeak) * 256U) /
static_cast<uint32_t>(std::max<uint16_t>(signal_abs_raw, 1U));
target_gain_q8 = static_cast<uint16_t>(
std::min<uint32_t>(kMicAgcMaxGainQ8, std::max<uint32_t>(kMicAgcMinGainQ8, desired)));
} else if (raw_abs_mean <= static_cast<uint16_t>(mic_noise_floor_raw_ + 24U) || has_stale_signal) {
target_gain_q8 = kMicAgcDefaultGainQ8;
}
const bool gain_return_from_silence = !has_signal_window &&
(raw_abs_mean <= static_cast<uint16_t>(mic_noise_floor_raw_ + 24U) || has_stale_signal);
if ((target_gain_q8 > (mic_agc_gain_q8_ + kMicAgcGainDeadbandQ8))) {
uint16_t delta = static_cast<uint16_t>(target_gain_q8 - mic_agc_gain_q8_);
uint16_t step = static_cast<uint16_t>((delta / 10U) + 3U);
if (step < 6U) {
step = 6U;
}
if (gain_return_from_silence) {
step = static_cast<uint16_t>(std::max<uint16_t>(8U, (delta / 12U) + 2U));
if (step > kMicAgcMaxGainStepUp) {
step = kMicAgcMaxGainStepUp;
}
} else if (signal_abs_raw < kMicAgcStrongSignalPeakMin) {
step = static_cast<uint16_t>((step < 10U) ? 10U : step);
}
if (step > kMicAgcMaxGainStepUp) {
step = kMicAgcMaxGainStepUp;
}
mic_agc_gain_q8_ = static_cast<uint16_t>(mic_agc_gain_q8_ + step);
} else if ((mic_agc_gain_q8_ > (target_gain_q8 + kMicAgcGainDeadbandQ8))) {
uint16_t delta = static_cast<uint16_t>(mic_agc_gain_q8_ - target_gain_q8);
uint16_t step = static_cast<uint16_t>((delta / 10U) + 4U);
if (step < 8U) {
step = 8U;
}
if (signal_abs_raw > kMicAgcStrongSignalPeakMin) {
step = static_cast<uint16_t>((step < 24U) ? 24U : step);
}
if (gain_return_from_silence) {
step = static_cast<uint16_t>(std::max<uint16_t>(12U, (delta / 6U) + 4U));
if (step > kMicAgcMaxGainStepDown) {
step = kMicAgcMaxGainStepDown;
}
}
if (step > kMicAgcMaxGainStepDown) {
step = kMicAgcMaxGainStepDown;
}
mic_agc_gain_q8_ = static_cast<uint16_t>(mic_agc_gain_q8_ - step);
}
if (mic_agc_gain_q8_ < kMicAgcMinGainQ8) {
mic_agc_gain_q8_ = kMicAgcMinGainQ8;
} else if (mic_agc_gain_q8_ > kMicAgcMaxGainQ8) {
mic_agc_gain_q8_ = kMicAgcMaxGainQ8;
}
uint16_t peak = 0U;
for (size_t index = 0U; index < sample_count; ++index) {
int value = static_cast<int>(mic_samples_[index]);
if (value < 0) {
value = -value;
}
if (value > peak) {
peak = static_cast<uint16_t>(value);
}
}
const uint16_t noise_floor_scaled = static_cast<uint16_t>(
std::min<uint32_t>(4095U, (static_cast<uint32_t>(mic_noise_floor_raw_) * mic_agc_gain_q8_) / 256U));
const uint16_t effective_peak = (peak > noise_floor_scaled) ? static_cast<uint16_t>(peak - noise_floor_scaled) : 0U;
snapshot_.mic_peak = peak;
snapshot_.mic_noise_floor = mic_noise_floor_raw_;
snapshot_.mic_gain_percent = static_cast<uint16_t>((static_cast<uint32_t>(mic_agc_gain_q8_) * 100U) / 256U);
uint16_t freq_hz = 0U;
int16_t cents = 0;
uint8_t confidence = 0U;
estimatePitchFromSamples(mic_samples_,
sample_count,
freq_hz,
cents,
confidence);
const bool has_pitch = (confidence > 0U) && (freq_hz > 0U);
if (has_pitch) {
snapshot_.mic_freq_hz = freq_hz;
snapshot_.mic_pitch_cents = cents;
snapshot_.mic_pitch_confidence = confidence;
} else {
snapshot_.mic_freq_hz = 0U;
snapshot_.mic_pitch_cents = 0;
snapshot_.mic_pitch_confidence = 0U;
}
const uint16_t level_for_display = computeLevelPercent(effective_peak, kMicAgcMinLevelDen);
const uint16_t level_for_waveform = (effective_peak >= kMicAgcSignalDisplayPeakMin) ? level_for_display : 0U;
uint8_t level = 0U;
if (level_for_waveform > 0U) {
level = static_cast<uint8_t>(std::min<uint16_t>(100U, (static_cast<uint16_t>(snapshot_.mic_level_percent) * 3U + level_for_waveform) / 4U));
}
snapshot_.mic_level_percent = level;
snapshot_.mic_waveform[snapshot_.mic_waveform_head] = level;
snapshot_.mic_waveform_head = static_cast<uint8_t>((snapshot_.mic_waveform_head + 1U) % kMicWaveformCapacity);
if (snapshot_.mic_waveform_count < kMicWaveformCapacity) {
++snapshot_.mic_waveform_count;
}
}
void HardwareManager::updateBattery(uint32_t now_ms) {
if (!snapshot_.battery_ready) {
return;
}
if (now_ms < next_battery_ms_) {
return;
}
next_battery_ms_ = now_ms + kBatteryPeriodMs;
uint32_t total_mv = 0U;
uint8_t valid = 0U;
for (uint8_t index = 0U; index < 10U; ++index) {
const int mv = analogReadMilliVolts(FREENOVE_BAT_ADC_PIN);
if (mv <= 0) {
continue;
}
total_mv += static_cast<uint32_t>(mv);
++valid;
delayMicroseconds(120);
}
if (valid == 0U) {
return;
}
const float adc_mv = static_cast<float>(total_mv) / static_cast<float>(valid);
float cell_mv = adc_mv * FREENOVE_BAT_VOLT_MULTIPLIER + FREENOVE_BAT_VOLT_OFFSET_MV;
if (cell_mv < 0.0f) {
cell_mv = 0.0f;
}
snapshot_.battery_mv = static_cast<uint16_t>(adc_mv);
snapshot_.battery_cell_mv = static_cast<uint16_t>(cell_mv);
snapshot_.battery_percent = batteryPercentFromMv(snapshot_.battery_cell_mv);
#if FREENOVE_BAT_CHARGE_PIN >= 0
snapshot_.charging = (digitalRead(FREENOVE_BAT_CHARGE_PIN) == LOW);
#else
snapshot_.charging = false;
#endif
}
void HardwareManager::updateLed(uint32_t now_ms) {
if (!snapshot_.ws2812_ready) {
return;
}
if (now_ms < next_led_ms_) {
return;
}
next_led_ms_ = now_ms + kLedPeriodMs;
uint8_t base_r = scene_r_;
uint8_t base_g = scene_g_;
uint8_t base_b = scene_b_;
uint8_t brightness = scene_brightness_;
bool pulse = led_pulse_;
if (manual_led_) {
base_r = manual_r_;
base_g = manual_g_;
base_b = manual_b_;
brightness = manual_brightness_;
pulse = manual_pulse_;
}
if (button_flash_until_ms_ > now_ms) {
base_r = 255U;
base_g = 220U;
base_b = 120U;
brightness = 90U;
pulse = false;
}
if (!manual_led_ && button_flash_until_ms_ <= now_ms && isTunerSceneHint()) {
applyTunerLedPattern(now_ms, base_r, base_g, base_b, brightness);
return;
}
if (!manual_led_ && button_flash_until_ms_ <= now_ms && isBrokenSceneHint()) {
applyBrokenLedPattern(now_ms, base_r, base_g, base_b, brightness);
return;
}
float dim = 1.0f;
if (pulse) {
const float phase = static_cast<float>(now_ms % 1400U) / 1400.0f;
dim = 0.30f + (0.70f * (0.5f + 0.5f * std::sin(phase * kTwoPi)));
}
const uint8_t out_r = clampU8(static_cast<int>(static_cast<float>(base_r) * dim));
const uint8_t out_g = clampU8(static_cast<int>(static_cast<float>(base_g) * dim));
const uint8_t out_b = clampU8(static_cast<int>(static_cast<float>(base_b) * dim));
strip_.setBrightness(clampU8(brightness));
for (uint16_t index = 0U; index < FREENOVE_WS2812_COUNT; ++index) {
strip_.setPixelColor(index, out_r, out_g, out_b);
}
strip_.show();
snapshot_.led_r = out_r;
snapshot_.led_g = out_g;
snapshot_.led_b = out_b;
snapshot_.led_brightness = brightness;
}
bool HardwareManager::isBrokenSceneHint() const {
return (std::strcmp(snapshot_.scene_id, "SCENE_LOCKED") == 0);
}
bool HardwareManager::isTunerSceneHint() const {
return (std::strcmp(snapshot_.scene_id, "SCENE_LA_DETECT") == 0) ||
(std::strcmp(snapshot_.scene_id, "SCENE_LA_DETECTOR") == 0) ||
(std::strcmp(snapshot_.scene_id, "SCENE_SEARCH") == 0);
}
void HardwareManager::applyBrokenLedPattern(uint32_t now_ms,
uint8_t base_r,
uint8_t base_g,
uint8_t base_b,
uint8_t brightness) {
const uint16_t led_count = FREENOVE_WS2812_COUNT;
if (led_count == 0U) {
return;
}
uint8_t effective_brightness = brightness;
if (effective_brightness < 92U) {
effective_brightness = 92U;
}
if (effective_brightness > 148U) {
effective_brightness = 148U;
}
strip_.setBrightness(clampU8(effective_brightness));
uint8_t first_r = 0U;
uint8_t first_g = 0U;
uint8_t first_b = 0U;
const uint32_t slot = now_ms / 46U;
const uint32_t in_slot = now_ms % 46U;
const uint32_t slot_noise = hash32(slot * 2654435761UL + 0x9e3779b9UL);
const uint16_t primary_led = static_cast<uint16_t>(slot_noise % led_count);
const uint8_t primary_window_ms = static_cast<uint8_t>(7U + ((slot_noise >> 16) % 11U));
const bool primary_active = in_slot < primary_window_ms;
uint16_t secondary_led = primary_led;
bool secondary_active = false;
if (led_count > 1U) {
secondary_led = static_cast<uint16_t>((primary_led + 1U + ((slot_noise >> 8) % (led_count - 1U))) % led_count);
secondary_active = (((slot_noise >> 27) & 0x1U) == 1U) && (in_slot >= 24U) && (in_slot < 29U);
}
for (uint16_t index = 0U; index < led_count; ++index) {
const uint32_t led_noise = hash32(slot_noise ^ (static_cast<uint32_t>(index + 1U) * 0x27d4eb2dUL));
int out_r = 0;
int out_g = 0;
int out_b = 0;
if (primary_active && index == primary_led) {
const float attack = 1.0f - (static_cast<float>(in_slot) / static_cast<float>(primary_window_ms));
const float dim = 0.88f + 0.55f * attack;
out_r = static_cast<int>(static_cast<float>(base_r) * dim) + static_cast<int>((led_noise >> 0) & 0x2fU);
out_g = static_cast<int>(static_cast<float>(base_g) * (0.30f + 0.95f * attack)) +
static_cast<int>((led_noise >> 8) & 0x17U);
out_b = static_cast<int>(static_cast<float>(base_b) * (0.18f + 0.85f * attack)) +
static_cast<int>((led_noise >> 16) & 0x3fU);
} else if (secondary_active && index == secondary_led) {
out_r = static_cast<int>(base_r * 0.45f) + static_cast<int>((led_noise >> 8) & 0x1fU);
out_g = static_cast<int>(base_g * 0.28f) + static_cast<int>((led_noise >> 16) & 0x0fU);
out_b = static_cast<int>(base_b * 0.40f) + static_cast<int>((led_noise >> 24) & 0x2fU);
} else {
const bool ghost = (((led_noise + slot + index * 5U) % 23U) == 0U) && (in_slot < 3U);
if (ghost) {
out_r = static_cast<int>(base_r * 0.12f);
out_g = static_cast<int>(base_g * 0.08f);
out_b = static_cast<int>(base_b * 0.20f) + 26;
}
}
const uint8_t final_r = clampU8(out_r);
const uint8_t final_g = clampU8(out_g);
const uint8_t final_b = clampU8(out_b);
strip_.setPixelColor(index, final_r, final_g, final_b);
if (index == 0U) {
first_r = final_r;
first_g = final_g;
first_b = final_b;
}
}
strip_.show();
snapshot_.led_r = first_r;
snapshot_.led_g = first_g;
snapshot_.led_b = first_b;
snapshot_.led_brightness = effective_brightness;
}
void HardwareManager::applyTunerLedPattern(uint32_t now_ms,
uint8_t base_r,
uint8_t base_g,
uint8_t base_b,
uint8_t brightness) {
(void)base_r;
(void)base_g;
(void)base_b;
const uint16_t led_count = FREENOVE_WS2812_COUNT;
if (led_count == 0U) {
return;
}
uint8_t first_r = 0U;
uint8_t first_g = 0U;
uint8_t first_b = 0U;
uint8_t tuned_brightness = brightness;
if (tuned_brightness < 56U) {
tuned_brightness = 56U;
} else if (tuned_brightness > 136U) {
tuned_brightness = 136U;
}
strip_.setBrightness(tuned_brightness);
auto setLedScaled = [&](uint16_t index, uint8_t red, uint8_t green, uint8_t blue, float scale) {
if (index >= led_count || scale <= 0.01f) {
return;
}
if (scale > 1.0f) {
scale = 1.0f;
}
const uint8_t out_r = clampU8(static_cast<int>(static_cast<float>(red) * scale));
const uint8_t out_g = clampU8(static_cast<int>(static_cast<float>(green) * scale));
const uint8_t out_b = clampU8(static_cast<int>(static_cast<float>(blue) * scale));
strip_.setPixelColor(index, out_r, out_g, out_b);
if (index == 0U) {
first_r = out_r;
first_g = out_g;
first_b = out_b;
}
};
for (uint16_t index = 0U; index < led_count; ++index) {
strip_.setPixelColor(index, 0, 0, 0);
}
// No signal/noise state: keep all tuner LEDs off as requested.
const bool has_signal =
(snapshot_.mic_level_percent >= kTunerDisplayMinLevelPct) &&
(snapshot_.mic_peak >= kMicAgcSignalDisplayPeakMin) &&
((snapshot_.mic_pitch_confidence >= (kTunerDisplayMinConfidence / 2U)) || (snapshot_.mic_freq_hz > 0U));
if (!has_signal) {
strip_.show();
snapshot_.led_r = first_r;
snapshot_.led_g = first_g;
snapshot_.led_b = first_b;
snapshot_.led_brightness = tuned_brightness;
return;
}
const uint32_t slot = now_ms / 56U;
const float pulse = 0.84f + 0.16f * std::sin(static_cast<float>(slot % 180U) * (kTwoPi / 180.0f));
const float delta_hz = static_cast<float>(snapshot_.mic_freq_hz) - kTunerReferenceHz;
const float abs_delta_hz = std::fabs(delta_hz);
auto lerp_u8 = [](uint8_t a, uint8_t b, float t) -> uint8_t {
if (t < 0.0f) {
t = 0.0f;
} else if (t > 1.0f) {
t = 1.0f;
}
const float value = static_cast<float>(a) + (static_cast<float>(b) - static_cast<float>(a)) * t;
return clampU8(static_cast<int>(value));
};
// Logical tuner map aligned with UI guidance text:
// - "MONTE EN FREQUENCE" (delta < 0) drives the ascend side (near+extreme).
// - "DESCENDS EN FREQUENCE" (delta > 0) drives the descend side (near+extreme).
const uint16_t idx_descend_extreme = 0U;
const uint16_t idx_ascend_extreme = led_count - 1U;
const uint16_t idx_descend_near = (led_count >= 4U) ? 1U : idx_descend_extreme;
const uint16_t idx_ascend_near = (led_count >= 4U) ? (led_count - 2U) : idx_ascend_extreme;
const bool in_tune_center = (abs_delta_hz <= 1.8f);
if (in_tune_center) {
setLedScaled(idx_descend_near, 24U, 255U, 88U, pulse);
setLedScaled(idx_ascend_near, 24U, 255U, 88U, pulse);
setLedScaled(idx_descend_extreme, 255U, 64U, 0U, 0.05f);
if (idx_ascend_extreme != idx_descend_extreme) {
setLedScaled(idx_ascend_extreme, 255U, 64U, 0U, 0.05f);
}
} else {
const float ratio = std::fmin(1.0f, abs_delta_hz / 10.0f);
const float near_scale = 0.24f + 0.76f * std::fmin(1.0f, abs_delta_hz / 6.0f);
const float extreme_scale = 0.14f + 0.86f * ratio;
if (delta_hz < 0.0f) {
const uint8_t near_r = lerp_u8(30U, 255U, ratio);
const uint8_t near_g = lerp_u8(255U, 110U, ratio);
const uint8_t extreme_g = lerp_u8(120U, 0U, ratio);
setLedScaled(idx_ascend_near, near_r, near_g, 0U, near_scale);
setLedScaled(idx_ascend_extreme, 255U, extreme_g, 0U, extreme_scale);
setLedScaled(idx_descend_near, 24U, 255U, 88U, 0.10f);
} else {
const uint8_t near_r = lerp_u8(30U, 255U, ratio);
const uint8_t near_g = lerp_u8(255U, 110U, ratio);
const uint8_t extreme_g = lerp_u8(120U, 0U, ratio);
setLedScaled(idx_descend_near, near_r, near_g, 0U, near_scale);
setLedScaled(idx_descend_extreme, 255U, extreme_g, 0U, extreme_scale);
setLedScaled(idx_ascend_near, 24U, 255U, 88U, 0.10f);
}
}
if (led_count == 1U) {
if (in_tune_center) {
setLedScaled(0U, 24U, 255U, 88U, pulse);
} else {
setLedScaled(0U, 255U, 42U, 0U, 0.95f);
}
} else if (led_count == 2U) {
if (in_tune_center) {
setLedScaled(0U, 24U, 255U, 88U, pulse);
setLedScaled(1U, 24U, 255U, 88U, pulse);
} else if (delta_hz < 0.0f) {
setLedScaled(0U, 255U, 42U, 0U, 0.95f);
setLedScaled(1U, 255U, 180U, 0U, 0.55f);
} else {
setLedScaled(1U, 255U, 42U, 0U, 0.95f);
setLedScaled(0U, 255U, 180U, 0U, 0.55f);
}
}
strip_.show();
snapshot_.led_r = first_r;
snapshot_.led_g = first_g;
snapshot_.led_b = first_b;
snapshot_.led_brightness = tuned_brightness;
}
void HardwareManager::estimatePitch(uint16_t& freq_hz, int16_t& cents, uint8_t& confidence, uint16_t& peak_for_window) {
freq_hz = snapshot_.mic_freq_hz;
cents = snapshot_.mic_pitch_cents;
confidence = snapshot_.mic_pitch_confidence;
peak_for_window = snapshot_.mic_peak;
}
void HardwareManager::estimatePitchFromSamples(const int16_t* samples,
size_t sample_count,
uint16_t& out_freq,
int16_t& out_cents,
uint8_t& out_confidence) {
out_freq = 0U;
out_cents = 0;
out_confidence = 0U;
if (samples == nullptr || sample_count < 64U) {
return;
}
if (sample_count > kMicReadSamples) {
sample_count = kMicReadSamples;
}
int32_t sum = 0;
int16_t peak_sample = 0;
for (size_t index = 0U; index < sample_count; ++index) {
const int16_t sample = samples[index];
sum += sample;
const int16_t abs_sample = static_cast<int16_t>((sample < 0) ? -sample : sample);
if (abs_sample > peak_sample) {
peak_sample = abs_sample;
}
}
const float zero_reference = static_cast<float>(sum) / static_cast<float>(sample_count);
const uint16_t peak_for_window = static_cast<uint16_t>(peak_sample > 0 ? peak_sample : 0);
if (peak_for_window < 260U) {
return;
}
pitch_energy_prefix_[0] = 0.0f;
for (size_t index = 0U; index < sample_count; ++index) {
const float value = static_cast<float>(samples[index]) - zero_reference;
pitch_centered_[index] = value;
pitch_energy_prefix_[index + 1U] = pitch_energy_prefix_[index] + (value * value);
}
const uint16_t detect_min_hz = std::max<uint16_t>(kTunerMinHz, kLaDetectMinHz);
const uint16_t detect_max_hz = std::min<uint16_t>(kTunerMaxHz, kLaDetectMaxHz);
const int lag_min = static_cast<int>(kMicSampleRate / detect_max_hz);
int lag_max = static_cast<int>(kMicSampleRate / detect_min_hz);
if (lag_max > static_cast<int>(sample_count) - 8) {
lag_max = static_cast<int>(sample_count) - 8;
}
if (lag_min < 2 || lag_max <= lag_min) {
return;
}
std::fill_n(pitch_corr_by_lag_, kMicReadSamples + 1U, 0.0f);
int best_lag = 0;
float best_corr = -1.0f;
int second_lag = 0;
float second_corr = -1.0f;
for (int lag = lag_min; lag <= lag_max; ++lag) {
const size_t count = sample_count - static_cast<size_t>(lag);
float numerator = 0.0f;
for (size_t index = 0U; index < count; ++index) {
numerator += pitch_centered_[index] * pitch_centered_[index + static_cast<size_t>(lag)];
}
const float energy_a = pitch_energy_prefix_[count] - pitch_energy_prefix_[0];
const float energy_b = pitch_energy_prefix_[sample_count] - pitch_energy_prefix_[static_cast<size_t>(lag)];
if (energy_a <= 1.0f || energy_b <= 1.0f) {
continue;
}
const float denom = std::sqrt(energy_a * energy_b);
if (denom <= 1.0f) {
continue;
}
const float corr = numerator / denom;
pitch_corr_by_lag_[lag] = corr;
if (corr > best_corr) {
second_corr = best_corr;
second_lag = best_lag;
best_corr = corr;
best_lag = lag;
} else if (corr > second_corr) {
second_corr = corr;
second_lag = lag;
}
}
if (best_lag <= 0 || best_corr < 0.10f) {
return;
}
float refined_lag = static_cast<float>(best_lag);
if (best_lag > lag_min && best_lag < lag_max) {
const float y1 = pitch_corr_by_lag_[best_lag - 1];
const float y2 = pitch_corr_by_lag_[best_lag];
const float y3 = pitch_corr_by_lag_[best_lag + 1];
const float denom = (y1 - (2.0f * y2) + y3);
if (std::fabs(denom) > 0.0001f) {
float shift = 0.5f * (y1 - y3) / denom;
if (shift > 0.5f) {
shift = 0.5f;
} else if (shift < -0.5f) {
shift = -0.5f;
}
refined_lag += shift;
}
}
if (refined_lag <= 1.0f) {
return;
}
const float raw_freq = static_cast<float>(kMicSampleRate) / refined_lag;
if (raw_freq < static_cast<float>(kTunerMinHz) || raw_freq > static_cast<float>(kTunerMaxHz)) {
return;
}
if (raw_freq < static_cast<float>(kLaDetectMinHz) || raw_freq > static_cast<float>(kLaDetectMaxHz)) {
return;
}
const float corr_strength = std::max(0.0f, std::min(1.0f, best_corr));
float separation = best_corr - second_corr;
if (second_lag == 0 || separation < 0.0f) {
separation = 0.0f;
}
const float sep_strength = std::max(0.0f, std::min(1.0f, separation * 4.5f));
const float amp_strength = std::max(0.0f, std::min(1.0f, static_cast<float>(peak_for_window) / 24000.0f));
const uint8_t confidence = static_cast<uint8_t>(
std::round((corr_strength * 0.62f + sep_strength * 0.26f + amp_strength * 0.12f) * 100.0f));
if (confidence < 8U) {
return;
}
const float cents = 1200.0f * std::log2(raw_freq / kTunerReferenceHz);
if (!std::isfinite(cents)) {
return;
}
out_freq = static_cast<uint16_t>(raw_freq);
out_cents = static_cast<int16_t>(std::round(cents));
out_confidence = confidence;
}
void HardwareManager::setScenePalette(const char* scene_id) {
if (scene_id == nullptr || scene_id[0] == '\0') {
scene_id = "ZACUS_U-SON";
}
std::strncpy(snapshot_.scene_id, scene_id, sizeof(snapshot_.scene_id) - 1U);
snapshot_.scene_id[sizeof(snapshot_.scene_id) - 1U] = '\0';
const LedPaletteEntry* palette = findPaletteForScene(scene_id);
if (palette == nullptr) {
scene_r_ = 50U;
scene_g_ = 122U;
scene_b_ = 255U;
scene_brightness_ = kDefaultLedBrightness;
led_pulse_ = true;
return;
}
scene_r_ = palette->r;
scene_g_ = palette->g;
scene_b_ = palette->b;
scene_brightness_ = palette->brightness;
led_pulse_ = palette->pulse;
}
const HardwareManager::LedPaletteEntry* HardwareManager::findPaletteForScene(const char* scene_id) const {
if (scene_id == nullptr || scene_id[0] == '\0') {
return &kLedPalette[(sizeof(kLedPalette) / sizeof(kLedPalette[0])) - 1U];
}
for (size_t index = 0U; index < (sizeof(kLedPalette) / sizeof(kLedPalette[0])); ++index) {
const LedPaletteEntry& entry = kLedPalette[index];
if (std::strcmp(entry.scene_id, "__DEFAULT__") == 0) {
continue;
}
if (std::strcmp(entry.scene_id, scene_id) == 0) {
return &entry;
}
}
return &kLedPalette[(sizeof(kLedPalette) / sizeof(kLedPalette[0])) - 1U];
}
uint8_t HardwareManager::batteryPercentFromMv(uint16_t cell_mv) const {
const int min_mv = static_cast<int>(FREENOVE_BAT_VOLTAGE_MIN * 1000.0f);
const int max_mv = static_cast<int>(FREENOVE_BAT_VOLTAGE_MAX * 1000.0f);
if (cell_mv <= min_mv) {
return 0U;
}
if (cell_mv >= max_mv) {
return 100U;
}
return static_cast<uint8_t>((static_cast<uint32_t>(cell_mv - min_mv) * 100U) /
static_cast<uint32_t>(max_mv - min_mv));
}
uint8_t HardwareManager::clampColor(int value) {
return clampU8(value);
}
-328
View File
@@ -1,328 +0,0 @@
// media_manager.cpp - media catalog + playback + simulated recorder hooks.
#include "media_manager.h"
#include <ArduinoJson.h>
#include <FS.h>
#include <LittleFS.h>
#include <cctype>
#include <cstring>
#include "audio_manager.h"
namespace {
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
char toLowerAscii(char ch) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
for (;; ++index) {
const char l = lhs[index];
const char r = rhs[index];
if (l == '\0' && r == '\0') {
return true;
}
if (toLowerAscii(l) != toLowerAscii(r)) {
return false;
}
}
}
} // namespace
bool MediaManager::begin(const Config& config) {
config_ = config;
copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
if (config_.record_max_seconds == 0U) {
config_.record_max_seconds = 30U;
}
if (config_.record_max_seconds > 1800U) {
config_.record_max_seconds = 1800U;
}
snapshot_ = Snapshot();
snapshot_.ready = true;
snapshot_.record_limit_seconds = config_.record_max_seconds;
copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
ensureDir(config_.music_dir);
ensureDir(config_.picture_dir);
ensureDir(config_.record_dir);
return true;
}
void MediaManager::update(uint32_t now_ms, AudioManager* audio) {
if (audio != nullptr && snapshot_.playing && !audio->isPlaying()) {
snapshot_.playing = false;
snapshot_.playing_path[0] = '\0';
}
if (snapshot_.recording) {
const uint32_t elapsed_ms = now_ms - snapshot_.record_started_ms;
const uint16_t elapsed_seconds = static_cast<uint16_t>(elapsed_ms / 1000U);
snapshot_.record_elapsed_seconds = elapsed_seconds;
if (snapshot_.record_limit_seconds > 0U && elapsed_seconds >= snapshot_.record_limit_seconds) {
stopRecording();
}
}
}
void MediaManager::noteStepChange() {
if (config_.auto_stop_record_on_step_change && snapshot_.recording) {
stopRecording();
}
}
bool MediaManager::listFiles(const char* kind, String* out_json) const {
if (out_json == nullptr) {
return false;
}
out_json->remove(0);
const String dir = resolveKindDir(kind);
if (dir.isEmpty()) {
return false;
}
if (!LittleFS.exists(dir.c_str())) {
*out_json = "[]";
return true;
}
File folder = LittleFS.open(dir.c_str(), "r");
if (!folder || !folder.isDirectory()) {
return false;
}
DynamicJsonDocument document(2048);
JsonArray files = document.to<JsonArray>();
File entry = folder.openNextFile();
while (entry) {
if (!entry.isDirectory()) {
String path = entry.name();
if (!path.startsWith("/")) {
path = "/" + path;
}
files.add(path);
}
entry.close();
entry = folder.openNextFile();
}
serializeJson(files, *out_json);
folder.close();
return true;
}
bool MediaManager::play(const char* path, AudioManager* audio) {
if (audio == nullptr || path == nullptr || path[0] == '\0') {
setLastError("media_play_invalid_args");
return false;
}
String normalized_path = path;
normalized_path.trim();
if (normalized_path.isEmpty()) {
setLastError("media_play_empty_path");
return false;
}
if (!normalized_path.startsWith("/")) {
normalized_path = String(config_.music_dir) + "/" + normalized_path;
}
const bool ok = audio->play(normalized_path.c_str());
snapshot_.playing = ok;
if (ok) {
copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
clearLastError();
} else {
setLastError("media_play_failed");
}
return ok;
}
bool MediaManager::stop(AudioManager* audio) {
if (audio != nullptr) {
audio->stop();
}
snapshot_.playing = false;
snapshot_.playing_path[0] = '\0';
clearLastError();
return true;
}
bool MediaManager::startRecording(uint16_t seconds, const char* filename_hint) {
if (seconds == 0U) {
seconds = config_.record_max_seconds;
}
if (seconds > config_.record_max_seconds) {
seconds = config_.record_max_seconds;
}
if (seconds == 0U) {
seconds = 1U;
}
if (snapshot_.recording) {
setLastError("recorder_already_running");
return false;
}
if (!ensureDir(config_.record_dir)) {
setLastError("recorder_dir_missing");
return false;
}
const String filename = sanitizeFilename(filename_hint, "record", ".wav");
const String path = String(config_.record_dir) + "/" + filename;
if (!writeEmptyWav(path.c_str())) {
setLastError("recorder_create_failed");
return false;
}
snapshot_.recording = true;
snapshot_.record_limit_seconds = seconds;
snapshot_.record_started_ms = millis();
snapshot_.record_elapsed_seconds = 0U;
copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
clearLastError();
return true;
}
bool MediaManager::stopRecording() {
if (!snapshot_.recording) {
return true;
}
const uint32_t elapsed_ms = millis() - snapshot_.record_started_ms;
snapshot_.record_elapsed_seconds = static_cast<uint16_t>(elapsed_ms / 1000U);
snapshot_.recording = false;
clearLastError();
return true;
}
MediaManager::Snapshot MediaManager::snapshot() const {
return snapshot_;
}
void MediaManager::setLastError(const char* message) {
snapshot_.last_ok = false;
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void MediaManager::clearLastError() {
snapshot_.last_ok = true;
snapshot_.last_error[0] = '\0';
}
String MediaManager::normalizeDir(const char* path) const {
if (path == nullptr || path[0] == '\0') {
return String("/");
}
String normalized = path;
normalized.trim();
if (normalized.isEmpty()) {
return String("/");
}
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
if (normalized.length() > 1U && normalized.endsWith("/")) {
normalized.remove(normalized.length() - 1U);
}
return normalized;
}
String MediaManager::resolveKindDir(const char* kind) const {
if (kind == nullptr) {
return String();
}
if (equalsIgnoreCase(kind, "picture") || equalsIgnoreCase(kind, "pictures")) {
return config_.picture_dir;
}
if (equalsIgnoreCase(kind, "music") || equalsIgnoreCase(kind, "audio")) {
return config_.music_dir;
}
if (equalsIgnoreCase(kind, "recorder") || equalsIgnoreCase(kind, "record") || equalsIgnoreCase(kind, "records")) {
return config_.record_dir;
}
return String();
}
String MediaManager::sanitizeFilename(const char* hint, const char* default_prefix, const char* extension) const {
String filename = (hint != nullptr) ? hint : "";
filename.trim();
if (filename.isEmpty()) {
filename = default_prefix;
filename += "_";
filename += String(static_cast<unsigned long>(millis()));
}
for (size_t index = 0U; index < filename.length(); ++index) {
const char ch = filename[index];
const bool keep = std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-' || ch == '.';
if (!keep) {
filename.setCharAt(index, '_');
}
}
if (extension != nullptr && extension[0] != '\0' && !filename.endsWith(extension)) {
filename += extension;
}
return filename;
}
bool MediaManager::ensureDir(const char* path) const {
const String normalized = normalizeDir(path);
if (normalized.isEmpty()) {
return false;
}
if (LittleFS.exists(normalized.c_str())) {
return true;
}
return LittleFS.mkdir(normalized.c_str());
}
bool MediaManager::writeEmptyWav(const char* path) const {
if (path == nullptr || path[0] == '\0') {
return false;
}
File file = LittleFS.open(path, "w");
if (!file) {
return false;
}
const uint32_t sample_rate = 16000UL;
const uint16_t channels = 1U;
const uint16_t bits_per_sample = 16U;
const uint32_t data_size = 0UL;
const uint32_t byte_rate = sample_rate * channels * (bits_per_sample / 8U);
const uint16_t block_align = channels * (bits_per_sample / 8U);
const uint32_t chunk_size = 36UL + data_size;
file.write(reinterpret_cast<const uint8_t*>("RIFF"), 4U);
file.write(reinterpret_cast<const uint8_t*>(&chunk_size), sizeof(chunk_size));
file.write(reinterpret_cast<const uint8_t*>("WAVE"), 4U);
file.write(reinterpret_cast<const uint8_t*>("fmt "), 4U);
const uint32_t fmt_size = 16UL;
const uint16_t audio_format = 1U;
file.write(reinterpret_cast<const uint8_t*>(&fmt_size), sizeof(fmt_size));
file.write(reinterpret_cast<const uint8_t*>(&audio_format), sizeof(audio_format));
file.write(reinterpret_cast<const uint8_t*>(&channels), sizeof(channels));
file.write(reinterpret_cast<const uint8_t*>(&sample_rate), sizeof(sample_rate));
file.write(reinterpret_cast<const uint8_t*>(&byte_rate), sizeof(byte_rate));
file.write(reinterpret_cast<const uint8_t*>(&block_align), sizeof(block_align));
file.write(reinterpret_cast<const uint8_t*>(&bits_per_sample), sizeof(bits_per_sample));
file.write(reinterpret_cast<const uint8_t*>("data"), 4U);
file.write(reinterpret_cast<const uint8_t*>(&data_size), sizeof(data_size));
file.close();
return true;
}
@@ -1,981 +0,0 @@
// network_manager.cpp - WiFi + ESP-NOW runtime helpers for Freenove all-in-one.
#include "network_manager.h"
#include <ArduinoJson.h>
#include <WiFi.h>
#include <esp_now.h>
#include <cctype>
#include <cstring>
namespace {
NetworkManager* g_network_instance = nullptr;
bool timeReached(uint32_t now_ms, uint32_t target_ms) {
return static_cast<int32_t>(now_ms - target_ms) >= 0;
}
bool isBroadcastMac(const uint8_t mac[6]) {
if (mac == nullptr) {
return false;
}
for (uint8_t index = 0U; index < 6U; ++index) {
if (mac[index] != 0xFFU) {
return false;
}
}
return true;
}
const char* inferEnvelopeType(const char* payload) {
if (payload == nullptr || payload[0] == '\0') {
return "empty";
}
if (std::strncmp(payload, "SC_EVENT", 8U) == 0 || std::strncmp(payload, "SERIAL:", 7U) == 0 ||
std::strncmp(payload, "TIMER:", 6U) == 0 || std::strncmp(payload, "ACTION:", 7U) == 0 ||
std::strcmp(payload, "UNLOCK") == 0 || std::strcmp(payload, "AUDIO_DONE") == 0) {
return "story_event";
}
if (payload[0] == '{' || payload[0] == '[') {
return "json";
}
return "text";
}
bool looksLikeEspNowEnvelope(JsonVariantConst root) {
if (!root.is<JsonObjectConst>()) {
return false;
}
JsonObjectConst object = root.as<JsonObjectConst>();
return object["msg_id"].is<const char*>() && object["seq"].is<uint32_t>() && object["type"].is<const char*>() &&
object.containsKey("payload") && object["ack"].is<bool>();
}
} // namespace
bool NetworkManager::begin(const char* hostname) {
if (started_) {
return true;
}
WiFi.persistent(false);
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.mode(WIFI_STA);
if (hostname != nullptr && hostname[0] != '\0') {
WiFi.setHostname(hostname);
}
g_network_instance = this;
started_ = true;
refreshSnapshot();
Serial.printf("[NET] wifi ready hostname=%s\n", (hostname != nullptr) ? hostname : "none");
return true;
}
void NetworkManager::update(uint32_t now_ms) {
if (!started_) {
return;
}
refreshEspNowConnection(now_ms);
const bool connected_to_local = isConnectedToLocalTarget();
const bool was_retry_paused = local_retry_paused_;
bool force_refresh = false;
if (sta_connecting_) {
if (WiFi.status() == WL_CONNECTED) {
sta_connecting_ = false;
force_refresh = true;
} else if ((now_ms - sta_connect_requested_at_ms_) >= kStaConnectTimeoutMs) {
sta_connecting_ = false;
force_refresh = true;
}
}
const bool should_force_fallback = shouldForceFallbackAp();
if (should_force_fallback && !fallback_ap_active_ && fallback_ap_ssid_[0] != '\0') {
fallback_ap_active_ = startApInternal(fallback_ap_ssid_, fallback_ap_password_, false);
force_refresh = true;
} else if (!should_force_fallback && fallback_ap_active_ && !manual_ap_active_) {
WiFi.softAPdisconnect(true);
fallback_ap_active_ = false;
WiFi.mode(WIFI_STA);
force_refresh = true;
}
const bool should_retry_local = local_target_ssid_[0] != '\0' &&
(force_ap_if_not_local_ ? !connected_to_local : (WiFi.status() != WL_CONNECTED));
const uint8_t ap_clients = (fallback_ap_active_ && !manual_ap_active_) ? WiFi.softAPgetStationNum() : 0U;
local_retry_paused_ =
should_retry_local && fallback_ap_active_ && pause_local_retry_when_ap_client_ && (ap_clients > 0U);
if (local_retry_paused_ != was_retry_paused) {
force_refresh = true;
}
if (should_retry_local) {
if (local_retry_paused_) {
if (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_)) {
next_local_retry_at_ms_ = now_ms + local_retry_ms_;
Serial.printf("[NET] local retry paused ap_clients=%u\n", ap_clients);
}
} else if (!sta_connecting_ && (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_))) {
if (fallback_ap_active_ && equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
// Avoid self-association when fallback AP and local target share the same SSID.
WiFi.softAPdisconnect(true);
fallback_ap_active_ = false;
WiFi.mode(WIFI_STA);
Serial.println("[NET] local retry paused fallback AP (same ssid)");
}
const bool started = connectSta(local_target_ssid_, local_target_password_);
next_local_retry_at_ms_ = now_ms + local_retry_ms_;
force_refresh = true;
Serial.printf("[NET] local retry target=%s started=%u\n", local_target_ssid_, started ? 1U : 0U);
}
} else {
next_local_retry_at_ms_ = 0U;
local_retry_paused_ = false;
}
if (!force_refresh && (now_ms - last_refresh_ms_) < 350U) {
return;
}
last_refresh_ms_ = now_ms;
refreshSnapshot();
}
void NetworkManager::configureFallbackAp(const char* ssid, const char* password) {
if (ssid != nullptr && ssid[0] != '\0') {
copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
}
if (password != nullptr && password[0] != '\0') {
copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
}
Serial.printf("[NET] fallback AP configured ssid=%s\n", fallback_ap_ssid_);
}
void NetworkManager::configureLocalPolicy(const char* ssid,
const char* password,
bool force_if_not_local,
uint32_t retry_ms,
bool pause_retry_when_ap_client) {
if (ssid != nullptr && ssid[0] != '\0') {
copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
}
if (password != nullptr && password[0] != '\0') {
copyText(local_target_password_, sizeof(local_target_password_), password);
}
force_ap_if_not_local_ = force_if_not_local;
pause_local_retry_when_ap_client_ = pause_retry_when_ap_client;
if (retry_ms >= 1000U) {
local_retry_ms_ = retry_ms;
}
local_retry_paused_ = false;
next_local_retry_at_ms_ = 0U;
refreshSnapshot();
Serial.printf("[NET] local policy target=%s force_ap_if_not_local=%u retry_ms=%lu pause_retry_on_ap_client=%u\n",
local_target_ssid_,
force_ap_if_not_local_ ? 1U : 0U,
static_cast<unsigned long>(local_retry_ms_),
pause_local_retry_when_ap_client_ ? 1U : 0U);
}
bool NetworkManager::connectSta(const char* ssid, const char* password) {
if (!started_ && !begin(nullptr)) {
return false;
}
if (ssid == nullptr || ssid[0] == '\0') {
return false;
}
if (WiFi.status() == WL_CONNECTED && equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
sta_connecting_ = false;
refreshSnapshot();
return true;
}
const uint8_t mode = (manual_ap_active_ || fallback_ap_active_) ? WIFI_MODE_APSTA : WIFI_MODE_STA;
WiFi.mode(static_cast<wifi_mode_t>(mode));
WiFi.begin(ssid, (password != nullptr) ? password : "");
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
sta_connecting_ = true;
sta_connect_requested_at_ms_ = millis();
refreshSnapshot();
Serial.printf("[NET] wifi connect requested ssid=%s\n", ssid);
return true;
}
void NetworkManager::disconnectSta() {
if (!started_) {
return;
}
WiFi.disconnect(true, false);
sta_connecting_ = false;
local_retry_paused_ = false;
next_local_retry_at_ms_ = 0U;
snapshot_.sta_ssid[0] = '\0';
if (shouldForceFallbackAp() && !manual_ap_active_ && fallback_ap_ssid_[0] != '\0') {
fallback_ap_active_ = startApInternal(fallback_ap_ssid_, fallback_ap_password_, false);
}
refreshSnapshot();
Serial.println("[NET] wifi disconnected");
}
bool NetworkManager::startAp(const char* ssid, const char* password) {
return startApInternal(ssid, password, true);
}
bool NetworkManager::isConnectedToLocalTarget() const {
if (local_target_ssid_[0] == '\0' || WiFi.status() != WL_CONNECTED) {
return false;
}
if (!equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
return false;
}
return !isConnectedToSelfAp();
}
bool NetworkManager::isConnectedToSelfAp() const {
if (WiFi.status() != WL_CONNECTED) {
return false;
}
const uint8_t* sta_bssid = WiFi.BSSID();
if (sta_bssid == nullptr) {
return false;
}
uint8_t ap_mac[6] = {0};
WiFi.softAPmacAddress(ap_mac);
return std::memcmp(sta_bssid, ap_mac, 6U) == 0;
}
bool NetworkManager::shouldForceFallbackAp() const {
if (manual_ap_active_ || fallback_ap_ssid_[0] == '\0') {
return false;
}
if (force_ap_if_not_local_ && local_target_ssid_[0] != '\0') {
if (sta_connecting_) {
return false;
}
return !isConnectedToLocalTarget();
}
if (sta_connecting_) {
return false;
}
return WiFi.status() != WL_CONNECTED;
}
bool NetworkManager::startApInternal(const char* ssid, const char* password, bool manual_request) {
if (!started_ && !begin(nullptr)) {
return false;
}
if (ssid == nullptr || ssid[0] == '\0') {
return false;
}
if (password != nullptr && password[0] != '\0' && std::strlen(password) < 8U) {
Serial.println("[NET] AP password must be >= 8 chars");
return false;
}
WiFi.mode(WIFI_AP_STA);
bool ok = false;
if (password != nullptr && password[0] != '\0') {
ok = WiFi.softAP(ssid, password);
} else {
ok = WiFi.softAP(ssid);
}
if (ok) {
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
if (manual_request) {
manual_ap_active_ = true;
fallback_ap_active_ = false;
} else {
fallback_ap_active_ = true;
}
}
refreshSnapshot();
Serial.printf("[NET] AP %s ssid=%s mode=%s\n",
ok ? "on" : "failed",
ssid,
manual_request ? "manual" : "fallback");
return ok;
}
void NetworkManager::stopAp() {
if (!started_) {
return;
}
WiFi.softAPdisconnect(true);
manual_ap_active_ = false;
fallback_ap_active_ = false;
local_retry_paused_ = false;
if (WiFi.status() == WL_CONNECTED || sta_connecting_) {
WiFi.mode(WIFI_STA);
}
snapshot_.ap_ssid[0] = '\0';
refreshSnapshot();
Serial.println("[NET] AP off");
}
bool NetworkManager::enableEspNow() {
if (!started_ && !begin(nullptr)) {
return false;
}
if (espnow_enabled_) {
return true;
}
if (WiFi.getMode() == WIFI_MODE_NULL) {
WiFi.mode(WIFI_STA);
}
if (esp_now_init() != ESP_OK) {
Serial.println("[NET] esp_now_init failed");
return false;
}
esp_now_register_recv_cb(onEspNowRecv);
esp_now_register_send_cb(onEspNowSend);
espnow_enabled_ = true;
last_espnow_refresh_ms_ = millis();
espnow_refresh_fault_ = false;
refreshSnapshot();
Serial.println("[NET] ESP-NOW ready");
return true;
}
void NetworkManager::disableEspNow() {
if (!espnow_enabled_) {
return;
}
esp_now_deinit();
espnow_enabled_ = false;
peer_cache_count_ = 0U;
rx_queue_head_ = 0U;
rx_queue_tail_ = 0U;
rx_queue_count_ = 0U;
last_espnow_refresh_ms_ = 0U;
espnow_refresh_fault_ = false;
refreshSnapshot();
Serial.println("[NET] ESP-NOW off");
}
bool NetworkManager::parseMac(const char* text, uint8_t out_mac[6]) const {
if (text == nullptr || out_mac == nullptr) {
return false;
}
char compact[13] = {0};
size_t cursor = 0U;
for (size_t index = 0U; text[index] != '\0'; ++index) {
const char ch = text[index];
if (std::isxdigit(static_cast<unsigned char>(ch))) {
if (cursor >= 12U) {
return false;
}
compact[cursor++] = ch;
continue;
}
if (ch == ':' || ch == '-' || ch == ' ') {
continue;
}
return false;
}
if (cursor != 12U) {
return false;
}
bool ok = true;
for (uint8_t idx = 0U; idx < 6U; ++idx) {
out_mac[idx] = parseHexByte(compact[idx * 2U], compact[idx * 2U + 1U], &ok);
if (!ok) {
return false;
}
}
return true;
}
bool NetworkManager::addEspNowPeer(const char* mac_text) {
if (mac_text == nullptr || mac_text[0] == '\0') {
return false;
}
if (!ensureEspNowReady()) {
return false;
}
uint8_t mac[6] = {0};
if (!parseMac(mac_text, mac)) {
return false;
}
if (!addEspNowPeerInternal(mac)) {
return false;
}
cachePeer(mac);
refreshSnapshot();
return true;
}
bool NetworkManager::removeEspNowPeer(const char* mac_text) {
if (mac_text == nullptr || mac_text[0] == '\0') {
return false;
}
if (!ensureEspNowReady()) {
return false;
}
uint8_t mac[6] = {0};
if (!parseMac(mac_text, mac)) {
return false;
}
if (!removeEspNowPeerInternal(mac)) {
return false;
}
forgetPeer(mac);
refreshSnapshot();
return true;
}
uint8_t NetworkManager::espNowPeerCount() const {
return peer_cache_count_;
}
bool NetworkManager::espNowPeerAt(uint8_t index, char* out_mac, size_t out_capacity) const {
if (out_mac == nullptr || out_capacity == 0U || index >= peer_cache_count_) {
return false;
}
copyText(out_mac, out_capacity, peer_cache_[index]);
return true;
}
bool NetworkManager::sendEspNowText(const uint8_t mac[6], const char* text) {
if (!ensureEspNowReady()) {
return false;
}
if (mac == nullptr || text == nullptr || text[0] == '\0') {
return false;
}
const size_t payload_len = std::strlen(text);
if (payload_len == 0U || payload_len > kEspNowFrameCapacity) {
Serial.printf("[NET] ESP-NOW payload too large: %u bytes\n", static_cast<unsigned int>(payload_len));
return false;
}
if (!isBroadcastMac(mac)) {
if (!addEspNowPeerInternal(mac)) {
if (ensureEspNowReady() && addEspNowPeerInternal(mac)) {
// recovered
} else {
Serial.println("[NET] ESP-NOW add peer failed");
return false;
}
}
} else {
// ESP-NOW broadcast still needs an explicit peer on some SDK versions.
if (!addEspNowPeerInternal(mac)) {
if (!(ensureEspNowReady() && addEspNowPeerInternal(mac))) {
Serial.println("[NET] ESP-NOW add peer failed");
}
}
}
esp_err_t err = esp_now_send(mac,
reinterpret_cast<const uint8_t*>(text),
payload_len);
if (err == ESP_ERR_ESPNOW_NOT_INIT) {
// WiFi mode switches can deinit ESP-NOW internally: recover once, then retry the same payload.
espnow_enabled_ = false;
if (enableEspNow()) {
addEspNowPeerInternal(mac);
err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(text), payload_len);
}
}
if (err != ESP_OK) {
++espnow_tx_fail_;
Serial.printf("[NET] ESP-NOW send failed err=%d\n", static_cast<int>(err));
return false;
}
cachePeer(mac);
return true;
}
bool NetworkManager::ensureEspNowReady() {
if (!espnow_enabled_) {
return enableEspNow();
}
esp_now_peer_num_t peer_num = {};
const esp_err_t err = esp_now_get_peer_num(&peer_num);
if (err == ESP_OK) {
return true;
}
if (err == ESP_ERR_ESPNOW_NOT_INIT) {
espnow_enabled_ = false;
return enableEspNow();
}
Serial.printf("[NET] ESP-NOW health check err=%d\n", static_cast<int>(err));
return false;
}
void NetworkManager::refreshEspNowConnection(uint32_t now_ms) {
if (!espnow_enabled_) {
espnow_refresh_fault_ = false;
last_espnow_refresh_ms_ = now_ms;
return;
}
if ((now_ms - last_espnow_refresh_ms_) < kEspNowRefreshPeriodMs) {
return;
}
last_espnow_refresh_ms_ = now_ms;
if (!ensureEspNowReady()) {
if (!espnow_refresh_fault_) {
Serial.println("[NET] ESP-NOW periodic refresh failed");
}
espnow_refresh_fault_ = true;
return;
}
if (espnow_refresh_fault_) {
Serial.println("[NET] ESP-NOW periodic refresh recovered");
}
espnow_refresh_fault_ = false;
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
uint8_t mac[6] = {0};
if (!parseMac(peer_cache_[index], mac)) {
continue;
}
addEspNowPeerInternal(mac);
}
}
bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
if (target == nullptr || target[0] == '\0') {
return false;
}
if (text == nullptr || text[0] == '\0') {
return false;
}
String frame = text;
frame.trim();
if (frame.isEmpty()) {
return false;
}
bool is_envelope = false;
if (frame.startsWith("{")) {
StaticJsonDocument<512> document;
if (!deserializeJson(document, frame) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
is_envelope = true;
}
}
if (!is_envelope) {
StaticJsonDocument<512> envelope;
++espnow_tx_seq_;
char msg_id[32] = {0};
snprintf(msg_id,
sizeof(msg_id),
"M%08lX%06lu",
static_cast<unsigned long>(millis()),
static_cast<unsigned long>(espnow_tx_seq_));
envelope["msg_id"] = msg_id;
envelope["seq"] = espnow_tx_seq_;
envelope["type"] = inferEnvelopeType(frame.c_str());
envelope["payload"] = frame;
envelope["ack"] = false;
frame.remove(0);
serializeJson(envelope, frame);
}
if (equalsIgnoreCase(target, "broadcast")) {
const uint8_t broadcast_mac[6] = {0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xFFU};
return sendEspNowText(broadcast_mac, frame.c_str());
}
uint8_t mac[6] = {0};
if (!parseMac(target, mac)) {
return false;
}
return sendEspNowText(mac, frame.c_str());
}
NetworkManager::Snapshot NetworkManager::snapshot() const {
return snapshot_;
}
bool NetworkManager::consumeEspNowMessage(char* out_payload,
size_t payload_capacity,
char* out_peer,
size_t peer_capacity,
char* out_msg_id,
size_t msg_id_capacity,
uint32_t* out_seq,
char* out_type,
size_t type_capacity,
bool* out_ack_requested) {
if (rx_queue_count_ == 0U) {
return false;
}
const EspNowMessage& entry = rx_queue_[rx_queue_head_];
char normalized_payload[kPayloadCapacity] = {0};
copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
char msg_id[32] = {0};
uint32_t seq = 0U;
char envelope_type[24] = {0};
bool ack_requested = false;
if (entry.payload[0] == '{') {
StaticJsonDocument<512> document;
if (!deserializeJson(document, entry.payload) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
JsonVariantConst root = document.as<JsonVariantConst>();
copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
seq = root["seq"] | 0U;
copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
const bool envelope_ack = root["ack"] | false;
const bool ack_response = envelope_ack && std::strcmp(envelope_type, "ack") == 0;
if (ack_response) {
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
--rx_queue_count_;
return consumeEspNowMessage(out_payload,
payload_capacity,
out_peer,
peer_capacity,
out_msg_id,
msg_id_capacity,
out_seq,
out_type,
type_capacity,
out_ack_requested);
}
ack_requested = envelope_ack;
if (root["payload"].is<const char*>()) {
copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
} else if (!root["payload"].isNull()) {
String payload_text;
serializeJson(root["payload"], payload_text);
copyText(normalized_payload, sizeof(normalized_payload), payload_text.c_str());
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
}
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
if (out_payload != nullptr && payload_capacity > 0U) {
copyText(out_payload, payload_capacity, normalized_payload);
}
if (out_peer != nullptr && peer_capacity > 0U) {
copyText(out_peer, peer_capacity, entry.peer);
}
if (out_msg_id != nullptr && msg_id_capacity > 0U) {
copyText(out_msg_id, msg_id_capacity, msg_id);
}
if (out_seq != nullptr) {
*out_seq = seq;
}
if (out_type != nullptr && type_capacity > 0U) {
copyText(out_type, type_capacity, envelope_type);
}
if (out_ack_requested != nullptr) {
*out_ack_requested = ack_requested;
}
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
--rx_queue_count_;
return true;
}
void NetworkManager::onEspNowRecv(const uint8_t* mac_addr, const uint8_t* data, int data_len) {
if (g_network_instance == nullptr) {
return;
}
g_network_instance->handleEspNowRecv(mac_addr, data, data_len);
}
void NetworkManager::onEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status) {
if (g_network_instance == nullptr) {
return;
}
g_network_instance->handleEspNowSend(mac_addr, status);
}
uint8_t NetworkManager::parseHexByte(char high, char low, bool* ok) {
auto nibble = [](char ch) -> int {
if (ch >= '0' && ch <= '9') {
return ch - '0';
}
if (ch >= 'A' && ch <= 'F') {
return 10 + (ch - 'A');
}
if (ch >= 'a' && ch <= 'f') {
return 10 + (ch - 'a');
}
return -1;
};
const int hi = nibble(high);
const int lo = nibble(low);
if (hi < 0 || lo < 0) {
if (ok != nullptr) {
*ok = false;
}
return 0U;
}
if (ok != nullptr) {
*ok = true;
}
return static_cast<uint8_t>((hi << 4) | lo);
}
void NetworkManager::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
if (out == nullptr || out_size == 0U) {
return;
}
if (mac == nullptr) {
copyText(out, out_size, "00:00:00:00:00:00");
return;
}
snprintf(out,
out_size,
"%02X:%02X:%02X:%02X:%02X:%02X",
mac[0],
mac[1],
mac[2],
mac[3],
mac[4],
mac[5]);
}
bool NetworkManager::equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
while (lhs[index] != '\0' && rhs[index] != '\0') {
const char l = static_cast<char>(std::tolower(static_cast<unsigned char>(lhs[index])));
const char r = static_cast<char>(std::tolower(static_cast<unsigned char>(rhs[index])));
if (l != r) {
return false;
}
++index;
}
return lhs[index] == '\0' && rhs[index] == '\0';
}
const char* NetworkManager::wifiModeLabel(uint8_t mode) {
switch (mode) {
case WIFI_MODE_STA:
return "STA";
case WIFI_MODE_AP:
return "AP";
case WIFI_MODE_APSTA:
return "AP_STA";
default:
return "OFF";
}
}
const char* NetworkManager::networkStateLabel(bool sta_connected,
bool sta_connecting,
bool ap_enabled,
bool fallback_ap_active) {
if (sta_connected) {
return "connected";
}
if (sta_connecting) {
return "connecting";
}
if (ap_enabled && fallback_ap_active) {
return "ap_fallback";
}
if (ap_enabled) {
return "ap";
}
return "idle";
}
bool NetworkManager::addEspNowPeerInternal(const uint8_t mac[6]) {
if (!espnow_enabled_ || mac == nullptr) {
return false;
}
if (esp_now_is_peer_exist(mac)) {
return true;
}
esp_now_peer_info_t peer = {};
std::memcpy(peer.peer_addr, mac, 6U);
peer.channel = 0U;
peer.encrypt = false;
return esp_now_add_peer(&peer) == ESP_OK;
}
bool NetworkManager::removeEspNowPeerInternal(const uint8_t mac[6]) {
if (!espnow_enabled_ || mac == nullptr) {
return false;
}
if (!esp_now_is_peer_exist(mac)) {
return true;
}
const esp_err_t err = esp_now_del_peer(mac);
return err == ESP_OK;
}
void NetworkManager::cachePeer(const uint8_t mac[6]) {
char peer_text[18] = {0};
formatMac(mac, peer_text, sizeof(peer_text));
if (peer_text[0] == '\0') {
return;
}
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
if (std::strcmp(peer_cache_[index], peer_text) == 0) {
return;
}
}
if (peer_cache_count_ < kMaxPeerCache) {
copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
++peer_cache_count_;
return;
}
for (uint8_t index = 1U; index < kMaxPeerCache; ++index) {
copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
}
copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
}
void NetworkManager::forgetPeer(const uint8_t mac[6]) {
char peer_text[18] = {0};
formatMac(mac, peer_text, sizeof(peer_text));
if (peer_text[0] == '\0' || peer_cache_count_ == 0U) {
return;
}
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
if (std::strcmp(peer_cache_[index], peer_text) != 0) {
continue;
}
for (uint8_t move = index + 1U; move < peer_cache_count_; ++move) {
copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
}
peer_cache_[peer_cache_count_ - 1U][0] = '\0';
--peer_cache_count_;
return;
}
}
bool NetworkManager::queueEspNowMessage(const char* payload,
const char* peer,
const char* msg_id,
uint32_t seq,
const char* type,
bool ack_requested) {
if (payload == nullptr || payload[0] == '\0') {
return false;
}
if (rx_queue_count_ >= kRxQueueSize) {
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
--rx_queue_count_;
++espnow_drop_packets_;
}
EspNowMessage& slot = rx_queue_[rx_queue_tail_];
copyText(slot.payload, sizeof(slot.payload), payload);
copyText(slot.peer, sizeof(slot.peer), peer);
copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
copyText(slot.type, sizeof(slot.type), type);
slot.seq = seq;
slot.ack_requested = ack_requested;
rx_queue_tail_ = static_cast<uint8_t>((rx_queue_tail_ + 1U) % kRxQueueSize);
++rx_queue_count_;
return true;
}
void NetworkManager::refreshSnapshot() {
const wl_status_t wifi_status = WiFi.status();
const wifi_mode_t mode = WiFi.getMode();
const bool local_match = isConnectedToLocalTarget();
snapshot_.ready = started_;
snapshot_.sta_connected = (wifi_status == WL_CONNECTED);
snapshot_.sta_connecting = sta_connecting_;
snapshot_.ap_enabled = (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA);
snapshot_.espnow_enabled = espnow_enabled_;
snapshot_.local_match = local_match;
snapshot_.fallback_ap_active =
fallback_ap_active_ && !manual_ap_active_ && snapshot_.ap_enabled && !snapshot_.local_match;
snapshot_.local_retry_paused = local_retry_paused_;
snapshot_.rssi = snapshot_.sta_connected ? WiFi.RSSI() : 0;
copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target_ssid_);
copyText(snapshot_.mode, sizeof(snapshot_.mode), wifiModeLabel(static_cast<uint8_t>(mode)));
copyText(snapshot_.state,
sizeof(snapshot_.state),
networkStateLabel(snapshot_.sta_connected,
sta_connecting_,
snapshot_.ap_enabled,
snapshot_.fallback_ap_active));
if (snapshot_.sta_connected) {
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), WiFi.SSID().c_str());
copyText(snapshot_.ip, sizeof(snapshot_.ip), WiFi.localIP().toString().c_str());
} else if (snapshot_.ap_enabled) {
copyText(snapshot_.ip, sizeof(snapshot_.ip), WiFi.softAPIP().toString().c_str());
} else {
copyText(snapshot_.ip, sizeof(snapshot_.ip), "0.0.0.0");
}
if (snapshot_.ap_enabled) {
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), WiFi.softAPSSID().c_str());
snapshot_.ap_clients = WiFi.softAPgetStationNum();
} else {
snapshot_.ap_ssid[0] = '\0';
snapshot_.ap_clients = 0U;
}
snapshot_.espnow_peer_count = peer_cache_count_;
snapshot_.espnow_rx_packets = espnow_rx_packets_;
snapshot_.espnow_tx_ok = espnow_tx_ok_;
snapshot_.espnow_tx_fail = espnow_tx_fail_;
snapshot_.espnow_drop_packets = espnow_drop_packets_;
}
void NetworkManager::handleEspNowRecv(const uint8_t* mac_addr, const uint8_t* data, int data_len) {
++espnow_rx_packets_;
cachePeer(mac_addr);
char peer_text[18] = {0};
formatMac(mac_addr, peer_text, sizeof(peer_text));
copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
char payload[kPayloadCapacity] = {0};
const int safe_len = (data_len > 0) ? data_len : 0;
const size_t copy_len = (static_cast<size_t>(safe_len) < (sizeof(payload) - 1U)) ? static_cast<size_t>(safe_len)
: (sizeof(payload) - 1U);
if (data != nullptr && copy_len > 0U) {
std::memcpy(payload, data, copy_len);
}
payload[copy_len] = '\0';
snapshot_.espnow_last_seq = 0U;
snapshot_.espnow_last_ack = false;
snapshot_.last_msg_id[0] = '\0';
copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
queueEspNowMessage(payload, peer_text, "", 0U, "", false);
}
void NetworkManager::handleEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status) {
if (status == ESP_NOW_SEND_SUCCESS) {
++espnow_tx_ok_;
} else {
++espnow_tx_fail_;
}
cachePeer(mac_addr);
formatMac(mac_addr, snapshot_.last_peer, sizeof(snapshot_.last_peer));
}
-2
View File
@@ -1,2 +0,0 @@
// Placeholder pour compilation RP2040
// Ce fichier peut être remplacé par le code réel si besoin
@@ -1,27 +1,27 @@
#include "runtime/memory/caps_allocator.h"
#include <Arduino.h>
#include <atomic>
#include <cstdlib>
#if defined(ARDUINO_ARCH_ESP32)
#include <esp_heap_caps.h>
#endif
#include <cstdlib>
namespace runtime {
namespace memory {
namespace {
uint32_t g_alloc_failures = 0U;
std::atomic<uint32_t> g_alloc_failures{0U};
void noteAllocFailure(size_t bytes, const char* tag, const char* source) {
++g_alloc_failures;
const uint32_t count = ++g_alloc_failures;
Serial.printf("[MEM] alloc_fail source=%s bytes=%u tag=%s fail_count=%lu\n",
source,
static_cast<unsigned int>(bytes),
(tag != nullptr) ? tag : "n/a",
static_cast<unsigned long>(g_alloc_failures));
static_cast<unsigned long>(count));
}
} // namespace
@@ -167,7 +167,7 @@ void CapsAllocator::release(void* ptr) {
}
uint32_t CapsAllocator::failureCount() {
return g_alloc_failures;
return g_alloc_failures.load();
}
} // namespace memory
@@ -47,11 +47,13 @@ const char* sectionLabel(PerfSection section) {
} // namespace
void PerfMonitor::reset() {
portENTER_CRITICAL(&stats_mux_);
for (uint8_t index = 0U; index < static_cast<uint8_t>(PerfSection::kCount); ++index) {
sections_[index] = {};
}
ui_dma_flush_count_ = 0U;
ui_sync_flush_count_ = 0U;
portEXIT_CRITICAL(&stats_mux_);
}
uint32_t PerfMonitor::beginSample() const {
@@ -65,15 +67,18 @@ void PerfMonitor::endSample(PerfSection section, uint32_t started_us) {
void PerfMonitor::noteUiFlush(bool dma_used, uint32_t elapsed_us) {
noteSection(PerfSection::kUiFlush, elapsed_us);
portENTER_CRITICAL(&stats_mux_);
if (dma_used) {
++ui_dma_flush_count_;
} else {
++ui_sync_flush_count_;
}
portEXIT_CRITICAL(&stats_mux_);
}
PerfSnapshot PerfMonitor::snapshot() const {
PerfSnapshot out = {};
portENTER_CRITICAL(&stats_mux_);
out.loop = sections_[static_cast<uint8_t>(PerfSection::kLoop)];
out.ui_tick = sections_[static_cast<uint8_t>(PerfSection::kUiTick)];
out.ui_flush = sections_[static_cast<uint8_t>(PerfSection::kUiFlush)];
@@ -82,6 +87,7 @@ PerfSnapshot PerfMonitor::snapshot() const {
out.audio_update = sections_[static_cast<uint8_t>(PerfSection::kAudioUpdate)];
out.ui_dma_flush_count = ui_dma_flush_count_;
out.ui_sync_flush_count = ui_sync_flush_count_;
portEXIT_CRITICAL(&stats_mux_);
return out;
}
@@ -112,12 +118,14 @@ void PerfMonitor::noteSection(PerfSection section, uint32_t elapsed_us) {
if (index >= static_cast<uint8_t>(PerfSection::kCount)) {
return;
}
portENTER_CRITICAL(&stats_mux_);
PerfSectionStats& stats = sections_[index];
++stats.count;
stats.total_us += static_cast<uint64_t>(elapsed_us);
if (elapsed_us > stats.max_us) {
stats.max_us = elapsed_us;
}
portEXIT_CRITICAL(&stats_mux_);
}
uint32_t PerfMonitor::elapsedUs(uint32_t started_us, uint32_t ended_us) {
@@ -5,6 +5,7 @@
#include <cctype>
#include <cstring>
#include "core/str_utils.h"
namespace {
@@ -13,18 +14,6 @@ constexpr const char* kDefaultWifiSsid = "";
constexpr const char* kDefaultWifiPassword = "";
constexpr uint16_t kMaxLaToleranceHz = 10U;
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
void toLowerAsciiInPlace(char* text) {
if (text == nullptr) {
return;
@@ -58,7 +47,7 @@ void addEspNowBootPeer(RuntimeNetworkConfig* network_cfg, const char* mac_text)
if (network_cfg->espnow_boot_peer_count >= RuntimeNetworkConfig::kMaxEspNowBootPeers) {
return;
}
copyText(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count],
core::copyText(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count],
sizeof(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count]),
mac_text);
++network_cfg->espnow_boot_peer_count;
@@ -68,13 +57,13 @@ void resetRuntimeNetworkConfig(RuntimeNetworkConfig* network_cfg) {
if (network_cfg == nullptr) {
return;
}
copyText(network_cfg->hostname, sizeof(network_cfg->hostname), kDefaultWifiHostname);
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), kDefaultWifiSsid);
copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), kDefaultWifiPassword);
copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), kDefaultWifiSsid);
copyText(network_cfg->local_password, sizeof(network_cfg->local_password), kDefaultWifiPassword);
copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), "Freenove-Setup");
copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), kDefaultWifiPassword);
core::copyText(network_cfg->hostname, sizeof(network_cfg->hostname), kDefaultWifiHostname);
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), kDefaultWifiSsid);
core::copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), kDefaultWifiPassword);
core::copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), kDefaultWifiSsid);
core::copyText(network_cfg->local_password, sizeof(network_cfg->local_password), kDefaultWifiPassword);
core::copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), "Freenove-Setup");
core::copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), kDefaultWifiPassword);
network_cfg->force_ap_if_not_local = false;
network_cfg->pause_local_retry_when_ap_client = false;
network_cfg->local_retry_ms = RuntimeNetworkConfig::kDefaultLocalRetryMs;
@@ -138,30 +127,30 @@ void RuntimeConfigService::load(StorageManager& storage,
const bool pause_retry_when_ap_client = config["pause_local_retry_when_ap_client"] | false;
const uint32_t local_retry_ms = config["local_retry_ms"] | RuntimeNetworkConfig::kDefaultLocalRetryMs;
if (hostname[0] != '\0') {
copyText(network_cfg->hostname, sizeof(network_cfg->hostname), hostname);
core::copyText(network_cfg->hostname, sizeof(network_cfg->hostname), hostname);
}
if (local_ssid[0] != '\0') {
copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), local_ssid);
core::copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), local_ssid);
}
if (local_password[0] != '\0') {
copyText(network_cfg->local_password, sizeof(network_cfg->local_password), local_password);
core::copyText(network_cfg->local_password, sizeof(network_cfg->local_password), local_password);
}
if (test_ssid[0] != '\0') {
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), test_ssid);
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), test_ssid);
}
if (test_password[0] != '\0') {
copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), test_password);
core::copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), test_password);
}
if (ap_ssid[0] != '\0') {
copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), ap_ssid);
core::copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), ap_ssid);
}
if (ap_password[0] != '\0') {
copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), ap_password);
core::copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), ap_password);
}
if (ap_policy[0] != '\0') {
char policy_normalized[32] = {0};
copyText(policy_normalized, sizeof(policy_normalized), ap_policy);
core::copyText(policy_normalized, sizeof(policy_normalized), ap_policy);
toLowerAsciiInPlace(policy_normalized);
if (std::strcmp(policy_normalized, "force_if_not_local") == 0) {
network_cfg->force_ap_if_not_local = true;
@@ -177,10 +166,10 @@ void RuntimeConfigService::load(StorageManager& storage,
}
if (test_ssid[0] == '\0' && network_cfg->local_ssid[0] != '\0') {
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), network_cfg->local_ssid);
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), network_cfg->local_ssid);
}
if (test_password[0] == '\0' && network_cfg->local_password[0] != '\0') {
copyText(network_cfg->wifi_test_password,
core::copyText(network_cfg->wifi_test_password,
sizeof(network_cfg->wifi_test_password),
network_cfg->local_password);
}
@@ -246,7 +235,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* mic_event_name = config["mic_event_name"] | "";
if (mic_event_name[0] != '\0') {
copyText(hardware_cfg->mic_event_name, sizeof(hardware_cfg->mic_event_name), mic_event_name);
core::copyText(hardware_cfg->mic_event_name, sizeof(hardware_cfg->mic_event_name), mic_event_name);
}
if (config["la_trigger_enabled"].is<bool>()) {
hardware_cfg->mic_la_trigger_enabled = config["la_trigger_enabled"].as<bool>();
@@ -324,7 +313,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* la_event_name = config["la_event_name"] | "";
if (la_event_name[0] != '\0') {
copyText(hardware_cfg->mic_la_event_name, sizeof(hardware_cfg->mic_la_event_name), la_event_name);
core::copyText(hardware_cfg->mic_la_event_name, sizeof(hardware_cfg->mic_la_event_name), la_event_name);
}
if (config["battery_enabled"].is<bool>()) {
hardware_cfg->battery_enabled = config["battery_enabled"].as<bool>();
@@ -338,7 +327,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* battery_event_name = config["battery_low_event_name"] | "";
if (battery_event_name[0] != '\0') {
copyText(
core::copyText(
hardware_cfg->battery_low_event_name, sizeof(hardware_cfg->battery_low_event_name), battery_event_name);
}
} else {
@@ -357,7 +346,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* frame_size = config["frame_size"] | "";
if (frame_size[0] != '\0') {
copyText(camera_cfg->frame_size, sizeof(camera_cfg->frame_size), frame_size);
core::copyText(camera_cfg->frame_size, sizeof(camera_cfg->frame_size), frame_size);
}
if (config["jpeg_quality"].is<unsigned int>()) {
camera_cfg->jpeg_quality = static_cast<uint8_t>(config["jpeg_quality"].as<unsigned int>());
@@ -370,7 +359,7 @@ void RuntimeConfigService::load(StorageManager& storage,
}
const char* snapshot_dir = config["snapshot_dir"] | "";
if (snapshot_dir[0] != '\0') {
copyText(camera_cfg->snapshot_dir, sizeof(camera_cfg->snapshot_dir), snapshot_dir);
core::copyText(camera_cfg->snapshot_dir, sizeof(camera_cfg->snapshot_dir), snapshot_dir);
}
} else {
Serial.printf("[CAM] APP_CAMERA invalid json (%s)\n", error.c_str());
@@ -406,13 +395,13 @@ void RuntimeConfigService::load(StorageManager& storage,
const char* picture_dir = config["picture_dir"] | "";
const char* record_dir = config["record_dir"] | "";
if (music_dir[0] != '\0') {
copyText(media_cfg->music_dir, sizeof(media_cfg->music_dir), music_dir);
core::copyText(media_cfg->music_dir, sizeof(media_cfg->music_dir), music_dir);
}
if (picture_dir[0] != '\0') {
copyText(media_cfg->picture_dir, sizeof(media_cfg->picture_dir), picture_dir);
core::copyText(media_cfg->picture_dir, sizeof(media_cfg->picture_dir), picture_dir);
}
if (record_dir[0] != '\0') {
copyText(media_cfg->record_dir, sizeof(media_cfg->record_dir), record_dir);
core::copyText(media_cfg->record_dir, sizeof(media_cfg->record_dir), record_dir);
}
if (config["record_max_seconds"].is<unsigned int>()) {
media_cfg->record_max_seconds = static_cast<uint16_t>(config["record_max_seconds"].as<unsigned int>());
@@ -2,7 +2,7 @@
#include "runtime/simd/simd_accel.h"
#include <Arduino.h>
#include <atomic>
#include <cstring>
#include "runtime/memory/caps_allocator.h"
@@ -16,6 +16,7 @@
#endif
#if (UI_SIMD_USE_ESP_DSP != 0) && __has_include(<dsps_mul.h>)
#include <dsps_add.h>
#include <dsps_mul.h>
#define UI_SIMD_HAS_ESP_DSP 1
#else
@@ -33,12 +34,17 @@ constexpr uint32_t kBenchMaxPixels = 8192U;
constexpr uint32_t kBenchMinLoops = 1U;
constexpr uint32_t kBenchMaxLoops = 5000U;
// Gray LUT — built once, guarded by atomic flag (acquire/release semantics).
uint16_t g_gray8_to_rgb565[256] = {};
bool g_gray_lut_ready = false;
SimdAccelStatus g_status = {
};
std::atomic<bool> g_gray_lut_ready{false};
SimdAccelStatus g_status = {};
bool g_status_initialized = false;
// ============================================================================
// Internal helpers
// ============================================================================
void initStatusDefaults() {
g_status.simd_path_enabled = kSimdPathEnabled;
g_status.esp_dsp_enabled = (UI_SIMD_HAS_ESP_DSP != 0);
@@ -53,46 +59,50 @@ void ensureStatusInitialized() {
}
inline uint8_t clampU8(int32_t value) {
if (value < 0) {
return 0U;
}
if (value > 255) {
return 255U;
}
if (value < 0) return 0U;
if (value > 255) return 255U;
return static_cast<uint8_t>(value);
}
inline int16_t clampS16(int32_t value) {
if (value < -32768) {
return -32768;
}
if (value > 32767) {
return 32767;
}
if (value < -32768) return -32768;
if (value > 32767) return 32767;
return static_cast<int16_t>(value);
}
inline uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
const uint16_t red = static_cast<uint16_t>((r & 0xF8U) << 8U);
const uint16_t green = static_cast<uint16_t>((g & 0xFCU) << 3U);
const uint16_t blue = static_cast<uint16_t>(b >> 3U);
return static_cast<uint16_t>(red | green | blue);
return static_cast<uint16_t>(
(static_cast<uint16_t>((r & 0xF8U) << 8U)) |
(static_cast<uint16_t>((g & 0xFCU) << 3U)) |
(static_cast<uint16_t>( b >> 3U)));
}
// Decompose RGB565 into 8-bit channels.
inline void unpack565(uint16_t px, uint8_t& r, uint8_t& g, uint8_t& b) {
r = static_cast<uint8_t>((px >> 8U) & 0xF8U);
g = static_cast<uint8_t>((px >> 3U) & 0xFCU);
b = static_cast<uint8_t>((px << 3U) & 0xF8U);
}
// Thread-safe lazy LUT initialisation (acquire/release).
void ensureGrayLut() {
if (g_gray_lut_ready) {
if (g_gray_lut_ready.load(std::memory_order_acquire)) {
return;
}
for (uint16_t g = 0U; g < 256U; ++g) {
g_gray8_to_rgb565[g] = rgb565(static_cast<uint8_t>(g), static_cast<uint8_t>(g), static_cast<uint8_t>(g));
for (uint16_t i = 0U; i < 256U; ++i) {
g_gray8_to_rgb565[i] = rgb565(static_cast<uint8_t>(i),
static_cast<uint8_t>(i),
static_cast<uint8_t>(i));
}
g_gray_lut_ready = true;
g_gray_lut_ready.store(true, std::memory_order_release);
}
// ============================================================================
// Audio scalar kernels
// ============================================================================
void gainQ15Scalar(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n) {
if (dst == nullptr || src == nullptr || n == 0U) {
return;
}
if (dst == nullptr || src == nullptr || n == 0U) return;
for (size_t i = 0U; i < n; ++i) {
const int32_t mul = static_cast<int32_t>(src[i]) * static_cast<int32_t>(gain_q15);
const int32_t rounded = (mul >= 0) ? (mul + (1 << 14)) : (mul - (1 << 14));
@@ -106,37 +116,31 @@ void mixQ15Scalar(int16_t* dst,
int16_t ga_q15,
int16_t gb_q15,
size_t n) {
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) {
return;
}
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) return;
for (size_t i = 0U; i < n; ++i) {
const int32_t acc = static_cast<int32_t>(a[i]) * static_cast<int32_t>(ga_q15) +
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,9 +431,8 @@ 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];
@@ -296,26 +445,26 @@ void simd_yuv422_to_rgb565(uint16_t* dst565, const uint8_t* src_yuv422, size_t n
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,9 +478,18 @@ 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;
@@ -361,78 +519,105 @@ bool selfTest() {
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.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);
@@ -446,38 +631,33 @@ 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);
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);
}
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;
@@ -486,6 +666,7 @@ SimdBenchResult runBench(uint32_t loops, uint32_t pixels) {
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;
runtime::memory::CapsAllocator::release(l8);
runtime::memory::CapsAllocator::release(idx);
runtime::memory::CapsAllocator::release(pal);
@@ -1,670 +0,0 @@
// scenario_manager.cpp - Story transitions + timing hooks.
#include "scenario_manager.h"
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <cstring>
#include "scenarios/default_scenario_v2.h"
namespace {
constexpr uint32_t kEtape2DelayMs = 15UL * 60UL * 1000UL;
constexpr uint32_t kEtape2TestDelayMs = 5000U;
bool eventNameMatches(const char* expected, const char* actual) {
if (expected == nullptr || expected[0] == '\0') {
return true;
}
if (actual == nullptr) {
return false;
}
return std::strcmp(expected, actual) == 0;
}
const char* stringOrNull(JsonVariantConst value) {
if (!value.is<const char*>()) {
return nullptr;
}
const char* text = value.as<const char*>();
if (text == nullptr || text[0] == '\0') {
return nullptr;
}
return text;
}
bool loadScenarioIdFromFile(const char* scenario_file_path, String* out_scenario_id) {
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0' || out_scenario_id == nullptr) {
return false;
}
if (!LittleFS.exists(scenario_file_path)) {
return false;
}
File file = LittleFS.open(scenario_file_path, "r");
if (!file) {
Serial.printf("[SCENARIO] failed to open scenario config: %s\n", scenario_file_path);
return false;
}
const size_t file_size = static_cast<size_t>(file.size());
if (file_size == 0U || file_size > 12288U) {
file.close();
Serial.printf("[SCENARIO] unexpected scenario config size: %s (%u bytes)\n",
scenario_file_path,
static_cast<unsigned int>(file_size));
return false;
}
DynamicJsonDocument document(file_size + 512U);
const DeserializationError error = deserializeJson(document, file);
file.close();
if (error) {
Serial.printf("[SCENARIO] invalid scenario config json (%s): %s\n",
scenario_file_path,
error.c_str());
return false;
}
const char* const id_candidates[] = {"scenario", "scenario_id", "id"};
const char* scenario_id = ScenarioManager::readScenarioField(
document.as<JsonVariantConst>(), id_candidates, sizeof(id_candidates) / sizeof(id_candidates[0]));
if (scenario_id == nullptr || scenario_id[0] == '\0') {
Serial.printf("[SCENARIO] missing scenario id in config: %s\n", scenario_file_path);
return false;
}
*out_scenario_id = scenario_id;
return true;
}
} // namespace
const char* ScenarioManager::readScenarioField(JsonVariantConst root,
const char* const* candidates,
size_t candidate_count) {
if (candidates == nullptr || candidate_count == 0U || root.isNull()) {
return nullptr;
}
JsonObjectConst object = root.as<JsonObjectConst>();
if (object.isNull()) {
return nullptr;
}
for (size_t index = 0U; index < candidate_count; ++index) {
const char* key = candidates[index];
if (key == nullptr || key[0] == '\0') {
continue;
}
JsonVariantConst candidate = object[key];
if (!candidate.is<const char*>()) {
continue;
}
const char* text = candidate.as<const char*>();
if (text != nullptr && text[0] != '\0') {
return text;
}
}
return nullptr;
}
bool ScenarioManager::begin(const char* scenario_file_path) {
scenario_ = nullptr;
initial_step_override_.remove(0);
clearStepResourceOverrides();
String selected_scenario_id;
if (loadScenarioIdFromFile(scenario_file_path, &selected_scenario_id)) {
scenario_ = storyScenarioV2ById(selected_scenario_id.c_str());
if (scenario_ != nullptr) {
Serial.printf("[SCENARIO] selected id from %s: %s\n",
scenario_file_path,
selected_scenario_id.c_str());
} else {
Serial.printf("[SCENARIO] unknown id in %s: %s (fallback default)\n",
scenario_file_path,
selected_scenario_id.c_str());
}
} else if (scenario_file_path != nullptr && scenario_file_path[0] != '\0') {
Serial.printf("[SCENARIO] no valid scenario config at %s (fallback default)\n", scenario_file_path);
}
if (scenario_ == nullptr) {
scenario_ = storyScenarioV2Default();
}
if (scenario_ == nullptr) {
Serial.println("[SCENARIO] default scenario unavailable");
return false;
}
if (storyValidateScenarioDef(*scenario_, nullptr)) {
Serial.printf("[SCENARIO] loaded built-in scenario: %s v%u (%u steps)\n",
scenario_->id,
scenario_->version,
scenario_->stepCount);
} else {
Serial.printf("[SCENARIO] warning: validation failed for %s\n", scenario_->id);
}
loadStepResourceOverrides(scenario_file_path);
reset();
return true;
}
bool ScenarioManager::beginById(const char* scenario_id) {
scenario_ = nullptr;
initial_step_override_.remove(0);
clearStepResourceOverrides();
if (scenario_id != nullptr && scenario_id[0] != '\0') {
scenario_ = storyScenarioV2ById(scenario_id);
}
if (scenario_ == nullptr) {
Serial.printf("[SCENARIO] unknown scenario id: %s\n", (scenario_id != nullptr) ? scenario_id : "null");
return false;
}
if (storyValidateScenarioDef(*scenario_, nullptr)) {
Serial.printf("[SCENARIO] loaded built-in scenario by id: %s v%u (%u steps)\n",
scenario_->id,
scenario_->version,
scenario_->stepCount);
} else {
Serial.printf("[SCENARIO] warning: validation failed for %s\n", scenario_->id);
}
reset();
return true;
}
void ScenarioManager::reset() {
if (scenario_ == nullptr) {
return;
}
const char* initial_step_id = scenario_->initialStepId;
if (!initial_step_override_.isEmpty()) {
initial_step_id = initial_step_override_.c_str();
}
current_step_index_ = storyFindStepIndex(*scenario_, initial_step_id);
if (current_step_index_ < 0 && scenario_->stepCount > 0U) {
current_step_index_ = 0;
}
step_entered_at_ms_ = millis();
pending_audio_pack_.remove(0);
scene_changed_ = true;
timer_armed_ = false;
timer_fired_ = false;
etape2_due_at_ms_ = 0U;
const ScenarioSnapshot state = snapshot();
if (state.audio_pack_id != nullptr && state.audio_pack_id[0] != '\0') {
pending_audio_pack_ = state.audio_pack_id;
}
}
void ScenarioManager::tick(uint32_t now_ms) {
if (scenario_ == nullptr || current_step_index_ < 0) {
return;
}
evaluateAfterMsTransitions(now_ms);
if (timer_armed_ && !timer_fired_ && etape2_due_at_ms_ > 0U && now_ms >= etape2_due_at_ms_) {
timer_fired_ = true;
dispatchEvent(StoryEventType::kTimer, "ETAPE2_DUE", now_ms, "timer_due");
}
}
void ScenarioManager::notifyUnlock(uint32_t now_ms) {
timer_armed_ = true;
timer_fired_ = false;
etape2_due_at_ms_ = now_ms + (test_mode_ ? kEtape2TestDelayMs : kEtape2DelayMs);
dispatchEvent(StoryEventType::kUnlock, "UNLOCK", now_ms, "button_unlock");
}
void ScenarioManager::notifyButton(uint8_t key, bool long_press, uint32_t now_ms) {
const StepDef* step = currentStep();
if (step != nullptr && step->id != nullptr && std::strcmp(step->id, "STEP_WAIT_UNLOCK") == 0) {
// Contract: any single press (short or long) from lock screen jumps to LA detector.
if (key >= 1U && key <= 5U) {
if (dispatchEvent(StoryEventType::kSerial, "BTN_NEXT", now_ms, "btn_any_short")) {
return;
}
dispatchEvent(StoryEventType::kSerial, "NEXT", now_ms, "btn_any_short_legacy");
return;
}
}
switch (key) {
case 1:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_ETAPE2", now_ms, "btn1_long");
} else {
notifyUnlock(now_ms);
}
break;
case 2:
if (long_press) {
test_mode_ = !test_mode_;
Serial.printf("[SCENARIO] test_mode=%u\n", test_mode_ ? 1U : 0U);
}
break;
case 3:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_ETAPE2", now_ms, "btn3_long");
}
break;
case 4:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_DONE", now_ms, "btn4_long");
}
break;
case 5:
if (long_press) {
dispatchEvent(StoryEventType::kSerial, "FORCE_DONE", now_ms, "btn5_long");
} else {
if (!dispatchEvent(StoryEventType::kSerial, "BTN_NEXT", now_ms, "btn5_short")) {
dispatchEvent(StoryEventType::kSerial, "NEXT", now_ms, "btn5_short_legacy");
}
}
break;
default:
break;
}
}
void ScenarioManager::notifyAudioDone(uint32_t now_ms) {
dispatchEvent(StoryEventType::kAudioDone, "AUDIO_DONE", now_ms, "audio_done");
}
bool ScenarioManager::notifySerialEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "SERIAL_EVENT";
return dispatchEvent(StoryEventType::kSerial, name, now_ms, "serial_event");
}
bool ScenarioManager::notifyTimerEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "TIMER_EVENT";
return dispatchEvent(StoryEventType::kTimer, name, now_ms, "timer_event");
}
bool ScenarioManager::notifyActionEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "ACTION_EVENT";
return dispatchEvent(StoryEventType::kAction, name, now_ms, "action_event");
}
ScenarioSnapshot ScenarioManager::snapshot() const {
ScenarioSnapshot out;
out.scenario = scenario_;
out.step = currentStep();
if (out.step != nullptr) {
const char* screen_scene_id = out.step->resources.screenSceneId;
const char* audio_pack_id = out.step->resources.audioPackId;
const char* const* action_ids = out.step->resources.actionIds;
uint8_t action_count = out.step->resources.actionCount;
applyStepResourceOverride(out.step, &screen_scene_id, &audio_pack_id, &action_ids, &action_count);
out.screen_scene_id = screen_scene_id;
out.audio_pack_id = audio_pack_id;
out.action_ids = action_ids;
out.action_count = action_count;
out.mp3_gate_open = out.step->mp3GateOpen;
}
return out;
}
bool ScenarioManager::consumeSceneChanged() {
const bool changed = scene_changed_;
scene_changed_ = false;
return changed;
}
bool ScenarioManager::consumeAudioRequest(String* out_audio_pack_id) {
if (pending_audio_pack_.isEmpty()) {
return false;
}
if (out_audio_pack_id != nullptr) {
*out_audio_pack_id = pending_audio_pack_;
}
pending_audio_pack_.remove(0);
return true;
}
uint32_t ScenarioManager::transitionEventMask() const {
if (scenario_ == nullptr || scenario_->steps == nullptr) {
return 0U;
}
uint32_t mask = 0U;
for (uint8_t step_index = 0; step_index < scenario_->stepCount; ++step_index) {
const StepDef& step = scenario_->steps[step_index];
if (step.transitions == nullptr || step.transitionCount == 0U) {
continue;
}
for (uint8_t transition_index = 0; transition_index < step.transitionCount; ++transition_index) {
const TransitionDef& transition = step.transitions[transition_index];
if (transition.trigger != StoryTransitionTrigger::kOnEvent &&
transition.trigger != StoryTransitionTrigger::kAfterMs) {
continue;
}
const uint8_t event_index = static_cast<uint8_t>(transition.eventType);
if (event_index >= 31U) {
continue;
}
mask |= (1UL << event_index);
}
}
return mask;
}
bool ScenarioManager::dispatchEvent(StoryEventType type,
const char* event_name,
uint32_t now_ms,
const char* source) {
const StepDef* step = currentStep();
if (step == nullptr || step->transitionCount == 0U) {
return false;
}
const TransitionDef* selected = nullptr;
for (uint8_t i = 0; i < step->transitionCount; ++i) {
const TransitionDef& transition = step->transitions[i];
if (!transitionMatches(transition, type, event_name)) {
continue;
}
if (selected == nullptr || transition.priority > selected->priority) {
selected = &transition;
}
}
if (selected == nullptr) {
return false;
}
if (!applyTransition(*selected, now_ms, source)) {
return false;
}
runImmediateTransitions(now_ms, source);
return true;
}
bool ScenarioManager::applyTransition(const TransitionDef& transition,
uint32_t now_ms,
const char* source) {
if (scenario_ == nullptr || transition.targetStepId == nullptr) {
return false;
}
const int8_t target = storyFindStepIndex(*scenario_, transition.targetStepId);
if (target < 0) {
Serial.printf("[SCENARIO] invalid transition target: %s\n", transition.targetStepId);
return false;
}
enterStep(target, now_ms, source);
return true;
}
bool ScenarioManager::runImmediateTransitions(uint32_t now_ms, const char* source) {
bool moved = false;
for (uint8_t guard = 0; guard < 8U; ++guard) {
const StepDef* step = currentStep();
if (step == nullptr || step->transitionCount == 0U) {
break;
}
const TransitionDef* selected = nullptr;
for (uint8_t i = 0; i < step->transitionCount; ++i) {
const TransitionDef& transition = step->transitions[i];
if (transition.trigger != StoryTransitionTrigger::kImmediate) {
continue;
}
if (selected == nullptr || transition.priority > selected->priority) {
selected = &transition;
}
}
if (selected == nullptr) {
break;
}
if (!applyTransition(*selected, now_ms, source)) {
break;
}
moved = true;
}
return moved;
}
void ScenarioManager::evaluateAfterMsTransitions(uint32_t now_ms) {
const StepDef* step = currentStep();
if (step == nullptr || step->transitionCount == 0U) {
return;
}
const TransitionDef* selected = nullptr;
for (uint8_t i = 0; i < step->transitionCount; ++i) {
const TransitionDef& transition = step->transitions[i];
if (transition.trigger != StoryTransitionTrigger::kAfterMs) {
continue;
}
if (now_ms - step_entered_at_ms_ < transition.afterMs) {
continue;
}
if (selected == nullptr || transition.priority > selected->priority) {
selected = &transition;
}
}
if (selected != nullptr) {
if (applyTransition(*selected, now_ms, "after_ms")) {
runImmediateTransitions(now_ms, "after_ms");
}
}
}
void ScenarioManager::enterStep(int8_t step_index, uint32_t now_ms, const char* source) {
if (scenario_ == nullptr || step_index < 0 || step_index >= static_cast<int8_t>(scenario_->stepCount)) {
return;
}
current_step_index_ = step_index;
step_entered_at_ms_ = now_ms;
scene_changed_ = true;
const StepDef* step = currentStep();
if (step == nullptr) {
return;
}
pending_audio_pack_.remove(0);
const char* screen_scene_id = step->resources.screenSceneId;
const char* audio_pack_id = step->resources.audioPackId;
applyStepResourceOverride(step, &screen_scene_id, &audio_pack_id);
if (audio_pack_id != nullptr && audio_pack_id[0] != '\0') {
pending_audio_pack_ = audio_pack_id;
}
Serial.printf("[SCENARIO] step=%s via=%s\n", step->id, source != nullptr ? source : "n/a");
}
const StepDef* ScenarioManager::currentStep() const {
if (scenario_ == nullptr || current_step_index_ < 0 || current_step_index_ >= static_cast<int8_t>(scenario_->stepCount)) {
return nullptr;
}
return &scenario_->steps[current_step_index_];
}
bool ScenarioManager::transitionMatches(const TransitionDef& transition,
StoryEventType type,
const char* event_name) const {
if (transition.trigger != StoryTransitionTrigger::kOnEvent) {
return false;
}
if (transition.eventType != type) {
return false;
}
return eventNameMatches(transition.eventName, event_name);
}
void ScenarioManager::clearStepResourceOverrides() {
for (uint8_t index = 0; index < step_resource_override_count_; ++index) {
step_resource_overrides_[index].step_id.remove(0);
step_resource_overrides_[index].screen_scene_id.remove(0);
step_resource_overrides_[index].audio_pack_id.remove(0);
step_resource_overrides_[index].action_count = 0U;
for (uint8_t action_index = 0; action_index < StepResourceOverride::kMaxActionOverrides; ++action_index) {
step_resource_overrides_[index].action_ids[action_index].remove(0);
step_resource_overrides_[index].action_ptrs[action_index] = nullptr;
}
}
step_resource_override_count_ = 0U;
}
void ScenarioManager::loadStepResourceOverrides(const char* scenario_file_path) {
clearStepResourceOverrides();
if (scenario_file_path == nullptr || scenario_file_path[0] == '\0') {
return;
}
if (!LittleFS.exists(scenario_file_path)) {
return;
}
File file = LittleFS.open(scenario_file_path, "r");
if (!file) {
return;
}
const size_t file_size = static_cast<size_t>(file.size());
if (file_size == 0U || file_size > 12288U) {
file.close();
return;
}
DynamicJsonDocument document(file_size + 1024U);
const DeserializationError error = deserializeJson(document, file);
file.close();
if (error) {
Serial.printf("[SCENARIO] override parse failed (%s): %s\n", scenario_file_path, error.c_str());
return;
}
const char* const initial_step_keys[] = {"initial_step", "initialStepId"};
const char* initial_step =
readScenarioField(document.as<JsonVariantConst>(), initial_step_keys, sizeof(initial_step_keys) / sizeof(initial_step_keys[0]));
if (initial_step != nullptr) {
initial_step_override_ = initial_step;
Serial.printf("[SCENARIO] override initial_step=%s\n", initial_step_override_.c_str());
}
JsonArrayConst steps = document["steps"].as<JsonArrayConst>();
if (steps.isNull()) {
return;
}
for (JsonVariantConst variant : steps) {
if (!variant.is<JsonObjectConst>()) {
continue;
}
JsonObjectConst step_obj = variant.as<JsonObjectConst>();
const char* step_id = stringOrNull(step_obj["id"]);
if (step_id == nullptr) {
step_id = stringOrNull(step_obj["step_id"]);
}
if (step_id == nullptr) {
step_id = stringOrNull(step_obj["stepId"]);
}
if (step_id == nullptr) {
continue;
}
const char* const screen_keys[] = {"screen_scene_id", "screenSceneId"};
const char* screen_scene_id =
readScenarioField(variant, screen_keys, sizeof(screen_keys) / sizeof(screen_keys[0]));
if (screen_scene_id == nullptr) {
screen_scene_id = readScenarioField(
step_obj["resources"], screen_keys, sizeof(screen_keys) / sizeof(screen_keys[0]));
}
const char* const audio_keys[] = {"audio_pack_id", "audioPackId"};
const char* audio_pack_id =
readScenarioField(variant, audio_keys, sizeof(audio_keys) / sizeof(audio_keys[0]));
if (audio_pack_id == nullptr) {
audio_pack_id = readScenarioField(step_obj["resources"],
audio_keys,
sizeof(audio_keys) / sizeof(audio_keys[0]));
}
JsonArrayConst action_ids = step_obj["action_ids"].as<JsonArrayConst>();
if (action_ids.isNull()) {
action_ids = step_obj["actionIds"].as<JsonArrayConst>();
}
if (action_ids.isNull()) {
action_ids = step_obj["actions"].as<JsonArrayConst>();
}
if (action_ids.isNull()) {
action_ids = step_obj["resources"]["action_ids"].as<JsonArrayConst>();
}
if (action_ids.isNull()) {
action_ids = step_obj["resources"]["actionIds"].as<JsonArrayConst>();
}
const bool has_action_override = !action_ids.isNull() && action_ids.size() > 0U;
if (screen_scene_id == nullptr && audio_pack_id == nullptr && !has_action_override) {
continue;
}
if (step_resource_override_count_ >= kMaxStepResourceOverrides) {
Serial.printf("[SCENARIO] step overrides truncated at %u entries\n", kMaxStepResourceOverrides);
break;
}
StepResourceOverride& entry = step_resource_overrides_[step_resource_override_count_++];
entry.step_id = step_id;
entry.screen_scene_id = (screen_scene_id != nullptr) ? screen_scene_id : "";
entry.audio_pack_id = (audio_pack_id != nullptr) ? audio_pack_id : "";
entry.action_count = 0U;
for (uint8_t action_index = 0; action_index < StepResourceOverride::kMaxActionOverrides; ++action_index) {
entry.action_ids[action_index].remove(0);
entry.action_ptrs[action_index] = nullptr;
}
if (has_action_override) {
for (JsonVariantConst action_id_variant : action_ids) {
if (entry.action_count >= StepResourceOverride::kMaxActionOverrides) {
break;
}
if (!action_id_variant.is<const char*>()) {
continue;
}
const char* action_id = action_id_variant.as<const char*>();
if (action_id == nullptr || action_id[0] == '\0') {
continue;
}
entry.action_ids[entry.action_count] = action_id;
entry.action_ptrs[entry.action_count] = entry.action_ids[entry.action_count].c_str();
++entry.action_count;
}
}
}
if (step_resource_override_count_ > 0U) {
Serial.printf("[SCENARIO] loaded %u step resource overrides\n", step_resource_override_count_);
}
}
const ScenarioManager::StepResourceOverride* ScenarioManager::findStepResourceOverride(const char* step_id) const {
if (step_id == nullptr || step_id[0] == '\0') {
return nullptr;
}
for (uint8_t index = 0; index < step_resource_override_count_; ++index) {
const StepResourceOverride& candidate = step_resource_overrides_[index];
if (candidate.step_id == step_id) {
return &candidate;
}
}
return nullptr;
}
void ScenarioManager::applyStepResourceOverride(const StepDef* step,
const char** out_screen_scene_id,
const char** out_audio_pack_id,
const char* const** out_action_ids,
uint8_t* out_action_count) const {
if (step == nullptr) {
return;
}
const StepResourceOverride* entry = findStepResourceOverride(step->id);
if (entry == nullptr) {
return;
}
if (out_screen_scene_id != nullptr && !entry->screen_scene_id.isEmpty()) {
*out_screen_scene_id = entry->screen_scene_id.c_str();
}
if (out_audio_pack_id != nullptr && !entry->audio_pack_id.isEmpty()) {
*out_audio_pack_id = entry->audio_pack_id.c_str();
}
if (out_action_ids != nullptr && out_action_count != nullptr && entry->action_count > 0U) {
*out_action_ids = entry->action_ptrs;
*out_action_count = entry->action_count;
}
}
File diff suppressed because one or more lines are too long
@@ -12,6 +12,7 @@
#include "audio_manager.h"
#include "ui_freenove_config.h"
#include "core/str_utils.h"
namespace {
@@ -22,39 +23,10 @@ constexpr uint16_t kRecorderChannels = 1U;
constexpr uint32_t kRecorderCapturePeriodMs = 30U;
constexpr size_t kRecorderRawSamples = 256U;
void copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
char toLowerAscii(char ch) {
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
for (;; ++index) {
const char l = lhs[index];
const char r = rhs[index];
if (l == '\0' && r == '\0') {
return true;
}
if (toLowerAscii(l) != toLowerAscii(r)) {
return false;
}
}
}
bool isAsciiDigit(char ch) {
return ch >= '0' && ch <= '9';
}
@@ -107,9 +79,9 @@ int compareNaturalPath(const String& lhs, const String& rhs) {
bool MediaManager::begin(const Config& config) {
config_ = config;
copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
core::copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
core::copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
core::copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
if (config_.record_max_seconds == 0U) {
config_.record_max_seconds = 30U;
}
@@ -121,9 +93,9 @@ bool MediaManager::begin(const Config& config) {
snapshot_.ready = true;
snapshot_.record_simulated = false;
snapshot_.record_limit_seconds = config_.record_max_seconds;
copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
core::copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
core::copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
core::copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
ensureDir(config_.music_dir);
ensureDir(config_.picture_dir);
ensureDir(config_.record_dir);
@@ -222,7 +194,7 @@ bool MediaManager::play(const char* path, AudioManager* audio) {
const bool ok = audio->play(normalized_path.c_str());
snapshot_.playing = ok;
if (ok) {
copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
core::copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
clearLastError();
} else {
setLastError("media_play_failed");
@@ -271,7 +243,7 @@ bool MediaManager::startRecording(uint16_t seconds, const char* filename_hint) {
snapshot_.record_started_ms = millis();
snapshot_.record_elapsed_seconds = 0U;
next_capture_ms_ = snapshot_.record_started_ms;
copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
core::copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
clearLastError();
return true;
}
@@ -298,7 +270,7 @@ MediaManager::Snapshot MediaManager::snapshot() const {
void MediaManager::setLastError(const char* message) {
snapshot_.last_ok = false;
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
core::copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
}
void MediaManager::clearLastError() {
@@ -328,13 +300,13 @@ String MediaManager::resolveKindDir(const char* kind) const {
if (kind == nullptr) {
return String();
}
if (equalsIgnoreCase(kind, "picture") || equalsIgnoreCase(kind, "pictures")) {
if (core::equalsIgnoreCase(kind, "picture") || core::equalsIgnoreCase(kind, "pictures")) {
return config_.picture_dir;
}
if (equalsIgnoreCase(kind, "music") || equalsIgnoreCase(kind, "audio")) {
if (core::equalsIgnoreCase(kind, "music") || core::equalsIgnoreCase(kind, "audio")) {
return config_.music_dir;
}
if (equalsIgnoreCase(kind, "recorder") || equalsIgnoreCase(kind, "record") || equalsIgnoreCase(kind, "records")) {
if (core::equalsIgnoreCase(kind, "recorder") || core::equalsIgnoreCase(kind, "record") || core::equalsIgnoreCase(kind, "records")) {
return config_.record_dir;
}
return String();
@@ -9,6 +9,7 @@
#include <cctype>
#include <cstdio>
#include <cstring>
#include "core/str_utils.h"
namespace {
@@ -197,7 +198,7 @@ void NetworkManager::update(uint32_t now_ms) {
Serial.printf("[NET] local retry paused ap_clients=%u\n", ap_clients);
}
} else if (!sta_connecting_ && (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_))) {
if (fallback_ap_active_ && equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
if (fallback_ap_active_ && core::equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
// Avoid self-association when fallback AP and local target share the same SSID.
WiFi.softAPdisconnect(true);
fallback_ap_active_ = false;
@@ -223,10 +224,10 @@ void NetworkManager::update(uint32_t now_ms) {
void NetworkManager::configureFallbackAp(const char* ssid, const char* password) {
if (ssid != nullptr) {
copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
core::copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
}
if (password != nullptr) {
copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
core::copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
}
Serial.printf("[NET] fallback AP configured ssid=%s\n", fallback_ap_ssid_);
}
@@ -237,10 +238,10 @@ void NetworkManager::configureLocalPolicy(const char* ssid,
uint32_t retry_ms,
bool pause_retry_when_ap_client) {
if (ssid != nullptr) {
copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
core::copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
}
if (password != nullptr) {
copyText(local_target_password_, sizeof(local_target_password_), password);
core::copyText(local_target_password_, sizeof(local_target_password_), password);
}
force_ap_if_not_local_ = force_if_not_local;
pause_local_retry_when_ap_client_ = pause_retry_when_ap_client;
@@ -265,7 +266,7 @@ bool NetworkManager::connectSta(const char* ssid, const char* password) {
return false;
}
if (WiFi.status() == WL_CONNECTED && equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
if (WiFi.status() == WL_CONNECTED && core::equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
sta_connecting_ = false;
refreshSnapshot();
return true;
@@ -274,7 +275,7 @@ bool NetworkManager::connectSta(const char* ssid, const char* password) {
const uint8_t mode = (manual_ap_active_ || fallback_ap_active_) ? WIFI_MODE_APSTA : WIFI_MODE_STA;
WiFi.mode(static_cast<wifi_mode_t>(mode));
WiFi.begin(ssid, (password != nullptr) ? password : "");
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
core::copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
sta_connecting_ = true;
sta_connect_requested_at_ms_ = millis();
refreshSnapshot();
@@ -306,7 +307,7 @@ bool NetworkManager::isConnectedToLocalTarget() const {
if (local_target_ssid_[0] == '\0' || WiFi.status() != WL_CONNECTED) {
return false;
}
if (!equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
if (!core::equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
return false;
}
return !isConnectedToSelfAp();
@@ -361,7 +362,7 @@ bool NetworkManager::startApInternal(const char* ssid, const char* password, boo
ok = WiFi.softAP(ssid, nullptr, kEspNowPreferredChannel);
}
if (ok) {
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
core::copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
if (manual_request) {
manual_ap_active_ = true;
fallback_ap_active_ = false;
@@ -531,7 +532,7 @@ bool NetworkManager::espNowPeerAt(uint8_t index, char* out_mac, size_t out_capac
bool ok = false;
enterCritical(&rx_queue_mux_, false);
if (index < peer_cache_count_) {
copyText(out_mac, out_capacity, peer_cache_[index]);
core::copyText(out_mac, out_capacity, peer_cache_[index]);
ok = true;
}
exitCritical(&rx_queue_mux_, false);
@@ -671,7 +672,7 @@ bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
}
char frame[kEspNowFrameCapacity + 1U] = {0};
copyText(frame, sizeof(frame), text);
core::copyText(frame, sizeof(frame), text);
trimAsciiInPlace(frame);
if (frame[0] == '\0') {
return false;
@@ -699,7 +700,7 @@ bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
}
}
if (target != nullptr && target[0] != '\0' && !equalsIgnoreCase(target, kBroadcastTarget)) {
if (target != nullptr && target[0] != '\0' && !core::equalsIgnoreCase(target, kBroadcastTarget)) {
uint8_t target_mac[6] = {0};
if (parseMac(target, target_mac)) {
return sendEspNowText(target_mac, frame);
@@ -739,7 +740,7 @@ bool NetworkManager::consumeEspNowMessage(char* out_payload,
exitCritical(&rx_queue_mux_, false);
char normalized_payload[kPayloadCapacity] = {0};
copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
core::copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
char msg_id[32] = {0};
uint32_t seq = 0U;
char envelope_type[24] = {0};
@@ -749,9 +750,9 @@ bool NetworkManager::consumeEspNowMessage(char* out_payload,
StaticJsonDocument<512> document;
if (!deserializeJson(document, entry.payload) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
JsonVariantConst root = document.as<JsonVariantConst>();
copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
core::copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
seq = root["seq"] | 0U;
copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
core::copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
const bool envelope_ack = root["ack"] | false;
const bool ack_response = envelope_ack && std::strcmp(envelope_type, "ack") == 0;
if (ack_response) {
@@ -759,37 +760,37 @@ bool NetworkManager::consumeEspNowMessage(char* out_payload,
}
ack_requested = envelope_ack;
if (root["payload"].is<const char*>()) {
copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
core::copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
} else if (!root["payload"].isNull()) {
char payload_text[kPayloadCapacity] = {0};
const size_t payload_size = serializeJson(root["payload"], payload_text, sizeof(payload_text));
if (payload_size > 0U) {
copyText(normalized_payload, sizeof(normalized_payload), payload_text);
core::copyText(normalized_payload, sizeof(normalized_payload), payload_text);
}
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
core::copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
}
}
if (envelope_type[0] == '\0') {
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
core::copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
}
if (out_payload != nullptr && payload_capacity > 0U) {
copyText(out_payload, payload_capacity, normalized_payload);
core::copyText(out_payload, payload_capacity, normalized_payload);
}
if (out_peer != nullptr && peer_capacity > 0U) {
copyText(out_peer, peer_capacity, entry.peer);
core::copyText(out_peer, peer_capacity, entry.peer);
}
if (out_msg_id != nullptr && msg_id_capacity > 0U) {
copyText(out_msg_id, msg_id_capacity, msg_id);
core::copyText(out_msg_id, msg_id_capacity, msg_id);
}
if (out_seq != nullptr) {
*out_seq = seq;
}
if (out_type != nullptr && type_capacity > 0U) {
copyText(out_type, type_capacity, envelope_type);
core::copyText(out_type, type_capacity, envelope_type);
}
if (out_ack_requested != nullptr) {
*out_ack_requested = ack_requested;
@@ -839,24 +840,12 @@ uint8_t NetworkManager::parseHexByte(char high, char low, bool* ok) {
return static_cast<uint8_t>((hi << 4) | lo);
}
void NetworkManager::copyText(char* out, size_t out_size, const char* text) {
if (out == nullptr || out_size == 0U) {
return;
}
if (text == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, text, out_size - 1U);
out[out_size - 1U] = '\0';
}
void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
if (out == nullptr || out_size == 0U) {
return;
}
if (mac == nullptr) {
copyText(out, out_size, "00:00:00:00:00:00");
core::copyText(out, out_size, "00:00:00:00:00:00");
return;
}
snprintf(out,
@@ -870,22 +859,6 @@ void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
mac[5]);
}
bool NetworkManager::equalsIgnoreCase(const char* lhs, const char* rhs) {
if (lhs == nullptr || rhs == nullptr) {
return false;
}
size_t index = 0U;
while (lhs[index] != '\0' && rhs[index] != '\0') {
const char l = static_cast<char>(std::tolower(static_cast<unsigned char>(lhs[index])));
const char r = static_cast<char>(std::tolower(static_cast<unsigned char>(rhs[index])));
if (l != r) {
return false;
}
++index;
}
return lhs[index] == '\0' && rhs[index] == '\0';
}
const char* NetworkManager::wifiModeLabel(uint8_t mode) {
switch (mode) {
case WIFI_MODE_STA:
@@ -965,15 +938,15 @@ void NetworkManager::cachePeer(const uint8_t mac[6], bool from_isr) {
}
}
if (peer_cache_count_ < kMaxPeerCache) {
copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
core::copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
++peer_cache_count_;
exitCritical(&rx_queue_mux_, from_isr);
return;
}
for (uint8_t index = 1U; index < kMaxPeerCache; ++index) {
copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
core::copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
}
copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
core::copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
exitCritical(&rx_queue_mux_, from_isr);
}
@@ -996,7 +969,7 @@ void NetworkManager::forgetPeer(const uint8_t mac[6]) {
continue;
}
for (uint8_t move = index + 1U; move < peer_cache_count_; ++move) {
copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
core::copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
}
peer_cache_[peer_cache_count_ - 1U][0] = '\0';
--peer_cache_count_;
@@ -1023,10 +996,10 @@ bool NetworkManager::queueEspNowMessage(const char* payload,
++espnow_drop_packets_;
}
EspNowMessage& slot = rx_queue_[rx_queue_tail_];
copyText(slot.payload, sizeof(slot.payload), payload);
copyText(slot.peer, sizeof(slot.peer), peer);
copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
copyText(slot.type, sizeof(slot.type), type);
core::copyText(slot.payload, sizeof(slot.payload), payload);
core::copyText(slot.peer, sizeof(slot.peer), peer);
core::copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
core::copyText(slot.type, sizeof(slot.type), type);
slot.seq = seq;
slot.ack_requested = ack_requested;
rx_queue_tail_ = static_cast<uint8_t>((rx_queue_tail_ + 1U) % kRxQueueSize);
@@ -1054,19 +1027,19 @@ void NetworkManager::refreshSnapshot() {
char state_label[16] = {0};
uint8_t ap_clients = 0U;
copyText(local_target, sizeof(local_target), local_target_ssid_);
copyText(mode_label, sizeof(mode_label), wifiModeLabel(static_cast<uint8_t>(mode)));
copyText(state_label,
core::copyText(local_target, sizeof(local_target), local_target_ssid_);
core::copyText(mode_label, sizeof(mode_label), wifiModeLabel(static_cast<uint8_t>(mode)));
core::copyText(state_label,
sizeof(state_label),
networkStateLabel(sta_connected, sta_connecting_, ap_enabled, fallback_ap_active));
if (sta_connected) {
copyText(sta_ssid, sizeof(sta_ssid), WiFi.SSID().c_str());
copyText(ip, sizeof(ip), WiFi.localIP().toString().c_str());
core::copyText(sta_ssid, sizeof(sta_ssid), WiFi.SSID().c_str());
core::copyText(ip, sizeof(ip), WiFi.localIP().toString().c_str());
} else if (ap_enabled) {
copyText(ip, sizeof(ip), WiFi.softAPIP().toString().c_str());
core::copyText(ip, sizeof(ip), WiFi.softAPIP().toString().c_str());
}
if (ap_enabled) {
copyText(ap_ssid, sizeof(ap_ssid), WiFi.softAPSSID().c_str());
core::copyText(ap_ssid, sizeof(ap_ssid), WiFi.softAPSSID().c_str());
ap_clients = WiFi.softAPgetStationNum();
}
@@ -1081,12 +1054,12 @@ void NetworkManager::refreshSnapshot() {
snapshot_.local_retry_paused = local_retry_paused_;
snapshot_.rssi = rssi;
snapshot_.channel = channel;
copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target);
copyText(snapshot_.mode, sizeof(snapshot_.mode), mode_label);
copyText(snapshot_.state, sizeof(snapshot_.state), state_label);
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), sta_ssid);
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ap_ssid);
copyText(snapshot_.ip, sizeof(snapshot_.ip), ip);
core::copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target);
core::copyText(snapshot_.mode, sizeof(snapshot_.mode), mode_label);
core::copyText(snapshot_.state, sizeof(snapshot_.state), state_label);
core::copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), sta_ssid);
core::copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ap_ssid);
core::copyText(snapshot_.ip, sizeof(snapshot_.ip), ip);
snapshot_.ap_clients = ap_clients;
snapshot_.espnow_peer_count = peer_cache_count_;
snapshot_.espnow_rx_packets = espnow_rx_packets_;
@@ -1112,13 +1085,13 @@ void NetworkManager::handleEspNowRecv(const uint8_t* mac_addr, const uint8_t* da
cachePeer(mac_addr, from_isr);
enterCritical(&rx_queue_mux_, from_isr);
++espnow_rx_packets_;
copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
core::copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
core::copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
snapshot_.espnow_last_seq = 0U;
snapshot_.espnow_last_ack = false;
snapshot_.last_msg_id[0] = '\0';
copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
core::copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
core::copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
exitCritical(&rx_queue_mux_, from_isr);
queueEspNowMessage(payload, peer_text, "", 0U, "", false, from_isr);
}
@@ -1,29 +0,0 @@
// touch_manager.cpp - optional touch bridge.
#include "touch_manager.h"
#include "ui_freenove_config.h"
bool TouchManager::begin() {
#if FREENOVE_HAS_TOUCH
Serial.printf("[TOUCH] enabled cs=%d irq=%d\n", FREENOVE_TOUCH_CS, FREENOVE_TOUCH_IRQ);
#else
Serial.println("[TOUCH] disabled");
#endif
return true;
}
bool TouchManager::poll(TouchPoint* out_point) {
if (out_point == nullptr) {
return false;
}
#if FREENOVE_HAS_TOUCH
(void)out_point;
// Touchscreen support is optional and disabled by default on Freenove.
return false;
#else
out_point->x = 0;
out_point->y = 0;
out_point->touched = false;
return false;
#endif
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@
#include <Arduino.h>
#include <LittleFS.h>
#include <cstring>
#include "core/str_utils.h"
#include <lvgl.h>
@@ -14,18 +15,6 @@
namespace ui {
namespace {
void copyTextSafe(char* out, size_t out_size, const char* value) {
if (out == nullptr || out_size == 0U) {
return;
}
if (value == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, value, out_size - 1U);
out[out_size - 1U] = '\0';
}
} // namespace
void QrSceneController::onSceneEnter(QrScanController* scanner, lv_obj_t* subtitle_label) {
@@ -73,7 +62,7 @@ void QrSceneController::handleDecodedPayload(const char* payload,
lv_obj_t* subtitle_label,
lv_obj_t* symbol_label) {
const char* safe_payload = (payload != nullptr) ? payload : "";
copyTextSafe(last_payload_, sizeof(last_payload_), safe_payload);
core::copyText(last_payload_, sizeof(last_payload_), safe_payload);
last_decode_ms_ = now_ms;
last_match_ = decoder_valid && rules.matches(safe_payload);
feedback_until_ms_ = now_ms + (last_match_ ? 1800U : 900U);
@@ -90,7 +79,7 @@ void QrSceneController::handleDecodedPayload(const char* payload,
if (symbol_label != nullptr) {
lv_label_set_text(symbol_label, last_match_ ? "WINNER" : "QR");
}
copyTextSafe(pending_runtime_event_,
core::copyText(pending_runtime_event_,
sizeof(pending_runtime_event_),
last_match_ ? "QR_OK" : "QR_INVALID");
pending_runtime_event_valid_ = true;
@@ -133,7 +122,7 @@ bool QrSceneController::consumeRuntimeEvent(char* out_event, size_t capacity) {
if (out_event == nullptr || capacity == 0U || !pending_runtime_event_valid_) {
return false;
}
copyTextSafe(out_event, capacity, pending_runtime_event_);
core::copyText(out_event, capacity, pending_runtime_event_);
pending_runtime_event_[0] = '\0';
pending_runtime_event_valid_ = false;
return true;
@@ -143,7 +132,7 @@ bool QrSceneController::queueSimulatedPayload(const char* payload) {
if (payload == nullptr || payload[0] == '\0') {
return false;
}
copyTextSafe(simulated_payload_, sizeof(simulated_payload_), payload);
core::copyText(simulated_payload_, sizeof(simulated_payload_), payload);
simulated_pending_ = true;
return true;
}
@@ -5,22 +5,11 @@
#include <cctype>
#include <cstring>
#include <strings.h>
#include "core/str_utils.h"
namespace ui {
namespace {
void copyTextSafe(char* out, size_t out_size, const char* value) {
if (out == nullptr || out_size == 0U) {
return;
}
if (value == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, value, out_size - 1U);
out[out_size - 1U] = '\0';
}
bool startsWithCaseInsensitive(const char* value, const char* prefix) {
if (value == nullptr || prefix == nullptr) {
return false;
@@ -194,18 +183,18 @@ void QrValidationRules::configureFromPayload(JsonVariantConst root) {
if (value[0] == '\0' || expected_count_ >= kExpectedMax) {
continue;
}
copyTextSafe(expected_values_[expected_count_], sizeof(expected_values_[0]), value);
core::copyText(expected_values_[expected_count_], sizeof(expected_values_[0]), value);
++expected_count_;
}
} else {
const char* expected = qr["expected"] | "";
if (expected[0] != '\0') {
copyTextSafe(expected_values_[0], sizeof(expected_values_[0]), expected);
core::copyText(expected_values_[0], sizeof(expected_values_[0]), expected);
expected_count_ = 1U;
}
}
copyTextSafe(prefix_, sizeof(prefix_), qr["prefix"] | "");
copyTextSafe(contains_, sizeof(contains_), qr["contains"] | "");
core::copyText(prefix_, sizeof(prefix_), qr["prefix"] | "");
core::copyText(contains_, sizeof(contains_), qr["contains"] | "");
}
bool QrValidationRules::matches(const char* payload) const {
@@ -213,7 +202,7 @@ bool QrValidationRules::matches(const char* payload) const {
return false;
}
char buffer[192] = {0};
copyTextSafe(buffer, sizeof(buffer), payload);
core::copyText(buffer, sizeof(buffer), payload);
trimAsciiWhitespaceInPlace(buffer);
if (buffer[0] == '\0') {
return false;
@@ -234,7 +223,7 @@ bool QrValidationRules::matches(const char* payload) const {
return false;
}
char crc_buffer[192] = {0};
copyTextSafe(crc_buffer, sizeof(crc_buffer), buffer);
core::copyText(crc_buffer, sizeof(crc_buffer), buffer);
if (case_insensitive_) {
asciiUpperInPlace(crc_buffer);
}
+20 -31
View File
@@ -27,6 +27,7 @@
#include "ui/scene_state.h"
#include "ui/fx/fx_engine.h"
#include "ui_fonts.h"
#include "core/str_utils.h"
namespace {
@@ -141,18 +142,6 @@ int16_t activeDisplayHeight() {
return ((FREENOVE_LCD_ROTATION & 0x1U) != 0U) ? FREENOVE_LCD_WIDTH : FREENOVE_LCD_HEIGHT;
}
void copyTextSafe(char* out, size_t out_size, const char* value) {
if (out == nullptr || out_size == 0U) {
return;
}
if (value == nullptr) {
out[0] = '\0';
return;
}
std::strncpy(out, value, out_size - 1U);
out[out_size - 1U] = '\0';
}
void trimAsciiWhitespace(char* text) {
if (text == nullptr) {
return;
@@ -1622,9 +1611,9 @@ void UiManager::renderLgfxSceneTextOverlay(uint32_t now_ms) {
if (prev_blank && win_etape_credits_pause_ms_[prev] == 0U) {
return;
}
copyTextSafe(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, " ");
core::copyText(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, " ");
} else {
copyTextSafe(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, cleaned);
core::copyText(win_etape_credits_lines_[win_etape_credits_count_], kWinEtapeCreditsMaxLineChars, cleaned);
}
win_etape_credits_size_[win_etape_credits_count_] = current_size_tag;
win_etape_credits_align_[win_etape_credits_count_] = current_align_tag;
@@ -3860,10 +3849,10 @@ void UiManager::renderScene(const ScenarioDef* scenario,
const char* audio_pack_id_for_ui = (audio_pack_id != nullptr && audio_pack_id[0] != '\0') ? audio_pack_id : "";
if (normalized_scene_id == nullptr) {
scene_status_.valid = false;
copyTextSafe(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
copyTextSafe(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
copyTextSafe(scene_status_.scene_id, sizeof(scene_status_.scene_id), raw_scene_id);
copyTextSafe(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
core::copyText(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
core::copyText(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
core::copyText(scene_status_.scene_id, sizeof(scene_status_.scene_id), raw_scene_id);
core::copyText(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
UI_LOGI("unknown scene id '%s' in scenario=%s step=%s", raw_scene_id, scenario_id, step_id_for_log);
return;
}
@@ -5098,7 +5087,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
la_bg_last_ms_ = 0U;
la_overlay_caption_font_ = la_overlay_caption_font;
la_overlay_caption_size_ = la_overlay_caption_size;
copyTextSafe(la_overlay_caption_, sizeof(la_overlay_caption_), la_overlay_caption.c_str());
core::copyText(la_overlay_caption_, sizeof(la_overlay_caption_), la_overlay_caption.c_str());
warning_gyrophare_enabled_ = warning_gyrophare_enabled && (std::strcmp(scene_id, "SCENE_WARNING") == 0);
warning_gyrophare_disable_direct_fx_ = warning_gyrophare_disable_direct_fx;
warning_lgfx_only_ = warning_lgfx_only && (std::strcmp(scene_id, "SCENE_WARNING") == 0);
@@ -5110,7 +5099,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
warning_gyrophare_fps_ = warning_gyrophare_fps;
warning_gyrophare_speed_deg_per_sec_ = warning_gyrophare_speed_deg_per_sec;
warning_gyrophare_beam_width_deg_ = warning_gyrophare_beam_width_deg;
copyTextSafe(warning_gyrophare_message_, sizeof(warning_gyrophare_message_), warning_gyrophare_message.c_str());
core::copyText(warning_gyrophare_message_, sizeof(warning_gyrophare_message_), warning_gyrophare_message.c_str());
warning_gyrophare_.destroy();
if (warning_gyrophare_enabled_ && scene_root_ != nullptr) {
ui::effects::SceneGyrophareConfig gyro_config = {};
@@ -5555,19 +5544,19 @@ void UiManager::renderScene(const ScenarioDef* scenario,
scene_status_.accent_rgb = accent_rgb;
scene_status_.text_rgb = text_rgb;
}
copyTextSafe(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
copyTextSafe(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
copyTextSafe(scene_status_.scene_id, sizeof(scene_status_.scene_id), scene_id);
copyTextSafe(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
copyTextSafe(scene_status_.title, sizeof(scene_status_.title), title_ascii.c_str());
copyTextSafe(scene_status_.subtitle, sizeof(scene_status_.subtitle), subtitle_ascii.c_str());
copyTextSafe(scene_status_.symbol, sizeof(scene_status_.symbol), symbol_ascii.c_str());
copyTextSafe(scene_status_.symbol_align, sizeof(scene_status_.symbol_align), symbol_align_token);
copyTextSafe(scene_status_.text_backend,
core::copyText(scene_status_.scenario_id, sizeof(scene_status_.scenario_id), scenario_id);
core::copyText(scene_status_.step_id, sizeof(scene_status_.step_id), step_id_for_ui);
core::copyText(scene_status_.scene_id, sizeof(scene_status_.scene_id), scene_id);
core::copyText(scene_status_.audio_pack_id, sizeof(scene_status_.audio_pack_id), audio_pack_id_for_ui);
core::copyText(scene_status_.title, sizeof(scene_status_.title), title_ascii.c_str());
core::copyText(scene_status_.subtitle, sizeof(scene_status_.subtitle), subtitle_ascii.c_str());
core::copyText(scene_status_.symbol, sizeof(scene_status_.symbol), symbol_ascii.c_str());
core::copyText(scene_status_.symbol_align, sizeof(scene_status_.symbol_align), symbol_align_token);
core::copyText(scene_status_.text_backend,
sizeof(scene_status_.text_backend),
scene_use_lgfx_text_overlay_ ? "lgfx_overlay" : "lvgl");
copyTextSafe(scene_status_.effect, sizeof(scene_status_.effect), effectToToken(effect));
copyTextSafe(scene_status_.transition, sizeof(scene_status_.transition), transitionToToken(transition));
core::copyText(scene_status_.effect, sizeof(scene_status_.effect), effectToToken(effect));
core::copyText(scene_status_.transition, sizeof(scene_status_.transition), transitionToToken(transition));
std::strncpy(last_scene_id_, scene_id, sizeof(last_scene_id_) - 1U);
last_scene_id_[sizeof(last_scene_id_) - 1U] = '\0';
last_payload_crc_ = payload_crc;
@@ -1,899 +0,0 @@
#if defined(UI_MANAGER_SPLIT_IMPL)
void UiManager::initGraphicsPipeline() {
flush_ctx_ = {};
buffer_cfg_ = {};
graphics_stats_ = {};
pending_lvgl_flush_request_ = false;
pending_full_repaint_request_ = false;
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = 0U;
async_fallback_until_ms_ = 0U;
if (draw_buf1_owned_ && draw_buf1_ != nullptr) {
runtime::memory::CapsAllocator::release(draw_buf1_);
}
if (draw_buf2_owned_ && draw_buf2_ != nullptr) {
runtime::memory::CapsAllocator::release(draw_buf2_);
}
if (dma_trans_buf_owned_ && dma_trans_buf_ != nullptr) {
runtime::memory::CapsAllocator::release(dma_trans_buf_);
}
if (full_frame_buf_owned_ && full_frame_buf_ != nullptr) {
runtime::memory::CapsAllocator::release(full_frame_buf_);
}
draw_buf1_ = nullptr;
draw_buf2_ = nullptr;
draw_buf1_owned_ = false;
draw_buf2_owned_ = false;
dma_trans_buf_ = nullptr;
dma_trans_buf_pixels_ = 0U;
dma_trans_buf_owned_ = false;
full_frame_buf_ = nullptr;
full_frame_buf_owned_ = false;
color_lut_ready_ = false;
dma_requested_ = false;
dma_available_ = false;
async_flush_enabled_ = false;
buffer_cfg_.selected_trans_lines = 0U;
if (kUseColor256Runtime) {
for (uint16_t value = 0; value < 256U; ++value) {
const uint8_t r3 = static_cast<uint8_t>((value >> 5U) & 0x07U);
const uint8_t g3 = static_cast<uint8_t>((value >> 2U) & 0x07U);
const uint8_t b2 = static_cast<uint8_t>(value & 0x03U);
const uint8_t r5 = static_cast<uint8_t>((r3 * 31U + 3U) / 7U);
const uint8_t g6 = static_cast<uint8_t>((g3 * 63U + 3U) / 7U);
const uint8_t b5 = static_cast<uint8_t>((b2 * 31U + 1U) / 3U);
rgb332_to_565_lut_[value] =
static_cast<uint16_t>((static_cast<uint16_t>(r5) << 11U) |
(static_cast<uint16_t>(g6) << 5U) |
static_cast<uint16_t>(b5));
}
color_lut_ready_ = true;
}
if (!allocateDrawBuffers()) {
UI_LOGI("draw buffer allocation failed");
return;
}
initDmaEngine();
const uint16_t width = static_cast<uint16_t>(activeDisplayWidth());
uint32_t draw_pixels = static_cast<uint32_t>(width) * static_cast<uint32_t>(buffer_cfg_.lines);
if (buffer_cfg_.full_frame) {
const uint16_t height = static_cast<uint16_t>(activeDisplayHeight());
draw_pixels = static_cast<uint32_t>(width) * static_cast<uint32_t>(height);
}
lv_disp_draw_buf_init(&draw_buf_, draw_buf1_, draw_buf2_, draw_pixels);
}
bool UiManager::allocateDrawBuffers() {
const uint16_t width = static_cast<uint16_t>(activeDisplayWidth());
const uint16_t height = static_cast<uint16_t>(activeDisplayHeight());
if (width == 0U || height == 0U) {
return false;
}
const uint8_t bpp = static_cast<uint8_t>(sizeof(lv_color_t) * 8U);
buffer_cfg_.bpp = bpp;
buffer_cfg_.draw_in_psram = false;
buffer_cfg_.full_frame = false;
buffer_cfg_.double_buffer = false;
if (kUseFullFrameBenchRuntime) {
size_t full_pixels = 0U;
size_t full_bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(height), &full_pixels) ||
!runtime::memory::safeMulSize(full_pixels, sizeof(lv_color_t), &full_bytes)) {
UI_LOGI("full-frame size overflow, fallback to line buffers");
full_pixels = 0U;
full_bytes = 0U;
}
lv_color_t* full = nullptr;
#if defined(ARDUINO_ARCH_ESP32)
if (full_bytes > 0U) {
const size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
if (free_psram > (full_bytes + kFullFrameBenchMinFreePsram)) {
full = static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocPsram(full_bytes, "ui.full_frame_bench"));
}
}
#else
if (full_bytes > 0U) {
full = static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocDefault(full_bytes, "ui.full_frame_bench"));
}
#endif
if (full != nullptr) {
full_frame_buf_ = full;
full_frame_buf_owned_ = true;
draw_buf1_ = full_frame_buf_;
draw_buf1_owned_ = false;
draw_buf2_ = nullptr;
draw_buf2_owned_ = false;
buffer_cfg_.lines = height;
buffer_cfg_.full_frame = true;
buffer_cfg_.double_buffer = false;
buffer_cfg_.draw_in_psram = true;
UI_LOGI("draw buffer full-frame bench enabled bytes=%u", static_cast<unsigned int>(full_bytes));
return true;
}
UI_LOGI("full-frame bench requested but unavailable, fallback to line buffers");
}
uint16_t line_candidates[12] = {0};
uint8_t candidate_count = 0U;
auto add_line_candidate = [&](uint16_t lines) {
if (lines == 0U) {
return;
}
if (lines > height) {
lines = height;
}
for (uint8_t index = 0U; index < candidate_count; ++index) {
if (line_candidates[index] == lines) {
return;
}
}
if (candidate_count < static_cast<uint8_t>(sizeof(line_candidates) / sizeof(line_candidates[0]))) {
line_candidates[candidate_count++] = lines;
}
};
add_line_candidate(kDrawBufLinesRequested != 0U ? kDrawBufLinesRequested : 40U);
for (uint8_t index = 0U; index < (sizeof(kDrawLineFallbacks) / sizeof(kDrawLineFallbacks[0])); ++index) {
add_line_candidate(kDrawLineFallbacks[index]);
}
add_line_candidate(20U);
add_line_candidate(16U);
add_line_candidate(12U);
add_line_candidate(8U);
add_line_candidate(6U);
add_line_candidate(4U);
add_line_candidate(2U);
add_line_candidate(1U);
auto allocate_buffer = [&](size_t bytes, bool psram, const char* tag) -> lv_color_t* {
if (bytes == 0U) {
return nullptr;
}
if (psram) {
return static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocPsram(bytes, tag));
}
return static_cast<lv_color_t*>(
runtime::memory::CapsAllocator::allocInternalDma(bytes, tag));
};
auto try_allocate_draw = [&](bool draw_in_psram) -> bool {
for (uint8_t index = 0U; index < candidate_count; ++index) {
const uint16_t lines = line_candidates[index];
if (lines == 0U) {
continue;
}
size_t pixels = 0U;
size_t bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(lines), &pixels) ||
!runtime::memory::safeMulSize(pixels, sizeof(lv_color_t), &bytes)) {
UI_LOGD("draw buffer size overflow lines=%u", static_cast<unsigned int>(lines));
continue;
}
lv_color_t* first = allocate_buffer(bytes, draw_in_psram, "ui.draw.first");
if (first == nullptr) {
continue;
}
lv_color_t* second = allocate_buffer(bytes, draw_in_psram, "ui.draw.second");
if (second != nullptr) {
draw_buf1_ = first;
draw_buf2_ = second;
draw_buf1_owned_ = true;
draw_buf2_owned_ = true;
buffer_cfg_.lines = lines;
buffer_cfg_.double_buffer = true;
buffer_cfg_.draw_in_psram = draw_in_psram;
UI_LOGI("draw buffers ready lines=%u bytes=%u source=%s double=1",
static_cast<unsigned int>(lines),
static_cast<unsigned int>(bytes),
draw_in_psram ? "PSRAM" : "SRAM_DMA");
return true;
}
draw_buf1_ = first;
draw_buf2_ = nullptr;
draw_buf1_owned_ = true;
draw_buf2_owned_ = false;
buffer_cfg_.lines = lines;
buffer_cfg_.double_buffer = false;
buffer_cfg_.draw_in_psram = draw_in_psram;
UI_LOGI("draw buffer fallback mono lines=%u bytes=%u source=%s",
static_cast<unsigned int>(lines),
static_cast<unsigned int>(bytes),
draw_in_psram ? "PSRAM" : "SRAM_DMA");
return true;
}
return false;
};
const bool prefer_psram_for_trans = kUseColor256Runtime || kUsePsramLineBuffersRuntime;
const bool preferred_psram = prefer_psram_for_trans;
bool allocated = try_allocate_draw(preferred_psram);
if (!allocated) {
allocated = try_allocate_draw(!preferred_psram);
if (allocated) {
UI_LOGI("draw buffer source fallback=%s", (!preferred_psram) ? "PSRAM" : "SRAM_DMA");
}
}
if (!allocated) {
return false;
}
const bool needs_trans_buffer = kUseColor256Runtime || buffer_cfg_.draw_in_psram;
if (needs_trans_buffer) {
uint16_t trans_line_candidates[12] = {0};
uint8_t trans_candidate_count = 0U;
auto add_trans_candidate = [&](uint16_t lines) {
if (lines == 0U) {
return;
}
if (lines > height) {
lines = height;
}
for (uint8_t i = 0U; i < trans_candidate_count; ++i) {
if (trans_line_candidates[i] == lines) {
return;
}
}
if (trans_candidate_count <
static_cast<uint8_t>(sizeof(trans_line_candidates) / sizeof(trans_line_candidates[0]))) {
trans_line_candidates[trans_candidate_count++] = lines;
}
};
const uint16_t requested_trans_lines = (kDmaTransBufLinesRequested != 0U)
? kDmaTransBufLinesRequested
: buffer_cfg_.lines;
add_trans_candidate(requested_trans_lines);
add_trans_candidate(buffer_cfg_.lines);
add_trans_candidate(24U);
add_trans_candidate(16U);
add_trans_candidate(12U);
add_trans_candidate(8U);
add_trans_candidate(6U);
add_trans_candidate(4U);
add_trans_candidate(2U);
add_trans_candidate(1U);
uint16_t selected_trans_lines = 0U;
#if defined(ARDUINO_ARCH_ESP32)
for (uint8_t index = 0U; index < trans_candidate_count; ++index) {
const uint16_t trans_lines = trans_line_candidates[index];
size_t trans_pixels = 0U;
size_t trans_bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(trans_lines), &trans_pixels) ||
!runtime::memory::safeMulSize(trans_pixels, sizeof(uint16_t), &trans_bytes)) {
UI_LOGD("trans buffer size overflow lines=%u", static_cast<unsigned int>(trans_lines));
continue;
}
dma_trans_buf_ = static_cast<uint16_t*>(
kUseDmaTxInDramRuntime
? runtime::memory::CapsAllocator::allocInternalDma(trans_bytes, "ui.trans")
: runtime::memory::CapsAllocator::allocDefault(trans_bytes, "ui.trans"));
if (dma_trans_buf_ != nullptr) {
dma_trans_buf_owned_ = true;
dma_trans_buf_pixels_ = trans_pixels;
selected_trans_lines = trans_lines;
break;
}
}
#else
if (trans_candidate_count > 0U) {
const uint16_t trans_lines = trans_line_candidates[0];
size_t trans_pixels = 0U;
size_t trans_bytes = 0U;
if (!runtime::memory::safeMulSize(static_cast<size_t>(width), static_cast<size_t>(trans_lines), &trans_pixels) ||
!runtime::memory::safeMulSize(trans_pixels, sizeof(uint16_t), &trans_bytes)) {
UI_LOGD("trans buffer size overflow lines=%u", static_cast<unsigned int>(trans_lines));
} else {
dma_trans_buf_ = static_cast<uint16_t*>(
runtime::memory::CapsAllocator::allocDefault(trans_bytes, "ui.trans"));
}
if (dma_trans_buf_ != nullptr) {
dma_trans_buf_owned_ = true;
dma_trans_buf_pixels_ = trans_pixels;
selected_trans_lines = trans_lines;
}
}
#endif
if (dma_trans_buf_ != nullptr && selected_trans_lines > 0U) {
buffer_cfg_.selected_trans_lines = selected_trans_lines;
UI_LOGI("trans buffer ready lines=%u pixels=%u source=%s",
static_cast<unsigned int>(selected_trans_lines),
static_cast<unsigned int>(dma_trans_buf_pixels_),
kUseDmaTxInDramRuntime ? "INTERNAL_DMA" : "DEFAULT");
if (selected_trans_lines < buffer_cfg_.lines &&
(kUseAsyncDmaRuntime || kUseColor256Runtime || buffer_cfg_.draw_in_psram)) {
UI_LOGI("draw lines reduced for trans buffer: %u -> %u",
static_cast<unsigned int>(buffer_cfg_.lines),
static_cast<unsigned int>(selected_trans_lines));
buffer_cfg_.lines = selected_trans_lines;
}
} else {
buffer_cfg_.selected_trans_lines = 0U;
dma_trans_buf_owned_ = false;
dma_trans_buf_pixels_ = 0U;
UI_LOGI("trans buffer unavailable; async DMA may be disabled");
}
} else {
buffer_cfg_.selected_trans_lines = 0U;
}
return draw_buf1_ != nullptr;
}
bool UiManager::initDmaEngine() {
dma_requested_ = kUseAsyncDmaRuntime;
dma_available_ = false;
async_flush_enabled_ = false;
if (!dma_requested_) {
buffer_cfg_.dma_enabled = false;
return false;
}
dma_available_ = drivers::display::displayHal().initDma(false);
if (!dma_available_) {
UI_LOGI("DMA engine unavailable, keeping sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
const bool needs_trans_buffer = kUseColor256Runtime || buffer_cfg_.draw_in_psram;
if (needs_trans_buffer && dma_trans_buf_ == nullptr) {
UI_LOGI("DMA enabled but trans buffer missing, keeping sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
if (kUseColor256Runtime && !kUseRgb332AsyncExperimental) {
UI_LOGI("RGB332 async DMA disabled (UI_DMA_RGB332_ASYNC_EXPERIMENTAL=0), keeping sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
if (buffer_cfg_.full_frame) {
UI_LOGI("full-frame bench forces sync flush");
buffer_cfg_.dma_enabled = false;
return false;
}
async_flush_enabled_ = true;
buffer_cfg_.dma_enabled = true;
async_fallback_until_ms_ = 0U;
if (kUseColor256Runtime) {
UI_LOGI("DMA async enabled (RGB332 -> RGB565 via trans buffer)");
} else {
UI_LOGI("DMA async flush enabled");
}
return true;
}
bool UiManager::isDisplayOutputBusy() const {
if (flush_ctx_.pending) {
return true;
}
return drivers::display::displayHal().dmaBusy();
}
void UiManager::pollAsyncFlush() {
if (!flush_ctx_.pending) {
flush_pending_since_ms_ = 0U;
return;
}
const uint32_t now_ms = millis();
if (flush_pending_since_ms_ == 0U) {
flush_pending_since_ms_ = now_ms;
flush_last_progress_ms_ = now_ms;
}
auto recover_stalled_flush = [this]() {
const bool used_dma = flush_ctx_.using_dma;
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
pending_lvgl_flush_request_ = true;
pending_full_repaint_request_ = true;
graphics_stats_.flush_stall_count += 1U;
graphics_stats_.flush_recover_count += 1U;
if (used_dma && async_flush_enabled_) {
async_flush_enabled_ = false;
buffer_cfg_.dma_enabled = false;
graphics_stats_.async_fallback_count += 1U;
async_fallback_until_ms_ = millis() + kAsyncFallbackRecoverMs;
}
};
if (flush_ctx_.using_dma && dma_available_ && drivers::display::displayHal().dmaBusy()) {
graphics_stats_.flush_busy_poll_count += 1U;
if ((now_ms - flush_pending_since_ms_) >= kFlushStallTimeoutMs) {
recover_stalled_flush();
}
return;
}
completePendingFlush();
if (!flush_ctx_.pending) {
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = now_ms;
return;
}
if ((now_ms - flush_pending_since_ms_) >= kFlushStallTimeoutMs) {
recover_stalled_flush();
}
}
void UiManager::completePendingFlush() {
if (!flush_ctx_.pending) {
return;
}
drivers::display::DisplayHal& display = drivers::display::displayHal();
const uint32_t width = static_cast<uint32_t>(flush_ctx_.col_count);
const uint32_t height = static_cast<uint32_t>(flush_ctx_.row_count);
if (width == 0U || height == 0U || flush_ctx_.src == nullptr || flush_ctx_.disp == nullptr) {
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
return;
}
const uint32_t pixel_count = width * height;
const bool has_valid_dma_tx = flush_ctx_.prepared && (flush_ctx_.prepared_tx != nullptr);
bool use_dma = flush_ctx_.using_dma;
if (use_dma && !has_valid_dma_tx) {
use_dma = false;
}
if (flush_ctx_.using_dma && flush_ctx_.dma_in_flight) {
if (display.dmaBusy()) {
graphics_stats_.flush_busy_poll_count += 1U;
return;
}
if (!display.startWrite()) {
graphics_stats_.flush_busy_poll_count += 1U;
return;
}
display.endWrite();
flush_ctx_.dma_in_flight = false;
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
const uint32_t elapsed_us = micros() - flush_ctx_.started_ms;
graphics_stats_.flush_count += 1U;
graphics_stats_.dma_flush_count += 1U;
graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > graphics_stats_.flush_time_max_us) {
graphics_stats_.flush_time_max_us = elapsed_us;
}
perfMonitor().noteUiFlush(true, elapsed_us);
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
return;
} else {
if (flush_ctx_.using_dma && use_dma) {
if (!display.startWrite()) {
graphics_stats_.flush_busy_poll_count += 1U;
return;
}
display.pushImageDma(flush_ctx_.area.x1,
flush_ctx_.area.y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height),
flush_ctx_.prepared_tx);
display.endWrite();
flush_ctx_.dma_in_flight = true;
flush_last_progress_ms_ = millis();
return;
}
if (!display.startWrite()) {
return;
}
if (flush_ctx_.converted) {
display.setAddrWindow(flush_ctx_.area.x1,
flush_ctx_.area.y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height));
if (flush_ctx_.prepared && flush_ctx_.prepared_tx != nullptr) {
display.pushColors(flush_ctx_.prepared_tx, pixel_count, true);
} else if (dma_trans_buf_ != nullptr && dma_trans_buf_pixels_ >= width) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = flush_ctx_.src + (row * width);
convertLineRgb332ToRgb565(src_row, dma_trans_buf_, width);
display.pushColors(dma_trans_buf_, width, true);
}
} else {
static uint16_t row_buffer[(FREENOVE_LCD_WIDTH > FREENOVE_LCD_HEIGHT) ? FREENOVE_LCD_WIDTH
: FREENOVE_LCD_HEIGHT];
const uint32_t max_row = sizeof(row_buffer) / sizeof(row_buffer[0]);
const lv_color_t* src = flush_ctx_.src;
if (src != nullptr && width <= max_row) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = src + (row * width);
convertLineRgb332ToRgb565(src_row, row_buffer, width);
display.pushColors(row_buffer, width, true);
}
} else {
for (uint32_t pixel = 0U; pixel < pixel_count; ++pixel) {
#if LV_COLOR_DEPTH == 8
const uint16_t c565 = rgb332_to_565_lut_[src[pixel].full];
display.pushColor(c565);
#else
display.pushColor(static_cast<uint16_t>(src[pixel].full));
#endif
}
}
}
display.endWrite();
} else {
display.setAddrWindow(flush_ctx_.area.x1,
flush_ctx_.area.y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height));
display.pushColors(reinterpret_cast<const uint16_t*>(flush_ctx_.src), pixel_count, true);
display.endWrite();
}
}
if (flush_ctx_.disp != nullptr) {
lv_disp_flush_ready(flush_ctx_.disp);
}
const uint32_t elapsed_us = micros() - flush_ctx_.started_ms;
graphics_stats_.flush_count += 1U;
if (use_dma) {
graphics_stats_.dma_flush_count += 1U;
} else {
graphics_stats_.sync_flush_count += 1U;
}
graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > graphics_stats_.flush_time_max_us) {
graphics_stats_.flush_time_max_us = elapsed_us;
}
perfMonitor().noteUiFlush(use_dma, elapsed_us);
flush_ctx_ = {};
flush_pending_since_ms_ = 0U;
flush_last_progress_ms_ = millis();
}
uint16_t UiManager::convertLineRgb332ToRgb565(const lv_color_t* src,
uint16_t* dst,
uint32_t px_count) const {
if (src == nullptr || dst == nullptr || px_count == 0U || !color_lut_ready_) {
return 0U;
}
#if LV_COLOR_DEPTH == 8
if (sizeof(lv_color_t) == sizeof(uint8_t)) {
runtime::simd::simd_index8_to_rgb565(dst,
reinterpret_cast<const uint8_t*>(src),
rgb332_to_565_lut_,
static_cast<size_t>(px_count));
return static_cast<uint16_t>((px_count > 0xFFFFU) ? 0xFFFFU : px_count);
}
for (uint32_t index = 0U; index < px_count; ++index) {
dst[index] = rgb332_to_565_lut_[src[index].full];
}
#else
for (uint32_t index = 0U; index < px_count; ++index) {
dst[index] = src[index].full;
}
#endif
return static_cast<uint16_t>((px_count > 0xFFFFU) ? 0xFFFFU : px_count);
}
lv_color_t UiManager::quantize565ToTheme256(lv_color_t color) const {
if (!kUseThemeQuantizeRuntime) {
return color;
}
#if LV_COLOR_DEPTH == 16
lv_color32_t c32 = {};
c32.full = lv_color_to32(color);
const uint8_t r3 = static_cast<uint8_t>((static_cast<uint16_t>(c32.ch.red) * 7U + 127U) / 255U);
const uint8_t g3 = static_cast<uint8_t>((static_cast<uint16_t>(c32.ch.green) * 7U + 127U) / 255U);
const uint8_t b2 = static_cast<uint8_t>((static_cast<uint16_t>(c32.ch.blue) * 3U + 127U) / 255U);
const uint8_t rq = static_cast<uint8_t>((static_cast<uint16_t>(r3) * 255U) / 7U);
const uint8_t gq = static_cast<uint8_t>((static_cast<uint16_t>(g3) * 255U) / 7U);
const uint8_t bq = static_cast<uint8_t>((static_cast<uint16_t>(b2) * 255U) / 3U);
return lv_color_make(rq, gq, bq);
#else
return color;
#endif
}
void UiManager::invalidateFxOverlayObjects() {
if (intro_active_ && intro_root_ != nullptr) {
// Keep the LVGL overlay above FX by forcing an overlay redraw each rendered FX frame.
lv_obj_invalidate(intro_root_);
return;
}
bool invalidated = false;
auto invalidate_if_visible = [&](lv_obj_t* obj) {
if (obj == nullptr || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) {
return;
}
lv_obj_invalidate(obj);
invalidated = true;
};
if (intro_active_) {
invalidate_if_visible(intro_logo_shadow_label_);
invalidate_if_visible(intro_logo_label_);
invalidate_if_visible(intro_crack_scroll_label_);
invalidate_if_visible(intro_bottom_scroll_label_);
invalidate_if_visible(intro_clean_title_shadow_label_);
invalidate_if_visible(intro_clean_title_label_);
invalidate_if_visible(intro_clean_scroll_label_);
invalidate_if_visible(intro_debug_label_);
if (!kUseWinEtapeSimplifiedEffects) {
const uint8_t glyph_count =
(intro_wave_glyph_count_ > kIntroWaveGlyphMax) ? kIntroWaveGlyphMax : intro_wave_glyph_count_;
for (uint8_t index = 0U; index < glyph_count; ++index) {
invalidate_if_visible(intro_wave_slots_[index].shadow);
invalidate_if_visible(intro_wave_slots_[index].glyph);
}
}
} else {
if (!kUseLgfxTextOverlayRuntime) {
invalidate_if_visible(scene_title_label_);
invalidate_if_visible(scene_subtitle_label_);
invalidate_if_visible(scene_symbol_label_);
}
invalidate_if_visible(page_label_);
}
if (!invalidated) {
if (kUseLgfxTextOverlayRuntime && !intro_active_) {
drivers::display::displayHalInvalidateOverlay();
return;
}
if (intro_root_ != nullptr) {
lv_obj_invalidate(intro_root_);
return;
}
if (scene_root_ != nullptr) {
lv_obj_invalidate(scene_root_);
return;
}
drivers::display::displayHalInvalidateOverlay();
}
}
void UiManager::renderLgfxSceneTextOverlay() {
if (!kUseLgfxTextOverlayRuntime || !scene_status_.valid || intro_active_) {
return;
}
drivers::display::DisplayHal& display = drivers::display::displayHal();
if (!display.startWrite()) {
return;
}
auto to565 = [&display](uint32_t rgb) -> uint16_t {
if (rgb <= 0xFFFFUL) {
return static_cast<uint16_t>(rgb);
}
return display.color565(static_cast<uint8_t>((rgb >> 16U) & 0xFFU),
static_cast<uint8_t>((rgb >> 8U) & 0xFFU),
static_cast<uint8_t>(rgb & 0xFFU));
};
const int16_t width = activeDisplayWidth();
const int16_t height = activeDisplayHeight();
const uint16_t bg565 = to565(scene_status_.bg_rgb);
const uint16_t text565 = display.color565(0xFFU, 0xFFU, 0xFFU);
auto draw_center_line = [&](const char* text, int16_t y, uint8_t font) {
if (text == nullptr || text[0] == '\0') {
return;
}
display.setTextSize(1U);
display.setTextFont(font);
const int16_t text_width = display.textWidth(text);
int16_t x = static_cast<int16_t>((width - text_width) / 2);
if (x < 2) {
x = 2;
}
display.setTextColor(text565, bg565);
display.drawString(text, x, y);
};
if (scene_status_.show_title && scene_status_.title[0] != '\0') {
draw_center_line(scene_status_.title, 8, 4U);
}
if (scene_status_.show_symbol && scene_status_.symbol[0] != '\0') {
draw_center_line(scene_status_.symbol, static_cast<int16_t>((height / 2) - 14), 4U);
}
if (scene_status_.show_subtitle && scene_status_.subtitle[0] != '\0') {
const int16_t y = static_cast<int16_t>(height - 24);
draw_center_line(scene_status_.subtitle, y, 2U);
}
display.endWrite();
}
void UiManager::tryRenderLgfxSceneTextOverlay() {
if (!kUseLgfxTextOverlayRuntime || !lgfx_text_overlay_pending_) {
return;
}
if (intro_active_) {
lgfx_text_overlay_pending_ = false;
return;
}
if (!scene_status_.valid) {
lgfx_text_overlay_pending_ = false;
return;
}
pollAsyncFlush();
if (isDisplayOutputBusy()) {
return;
}
renderLgfxSceneTextOverlay();
lgfx_text_overlay_pending_ = false;
}
void UiManager::displayFlushCb(lv_disp_drv_t* disp, const lv_area_t* area, lv_color_t* color_p) {
if (disp == nullptr || area == nullptr || color_p == nullptr) {
if (disp != nullptr) {
lv_disp_flush_ready(disp);
}
return;
}
if (g_instance == nullptr) {
lv_disp_flush_ready(disp);
return;
}
UiManager* self = g_instance;
drivers::display::DisplayHal& display = drivers::display::displayHal();
if (self->isDisplayOutputBusy()) {
self->pollAsyncFlush();
if (self->isDisplayOutputBusy()) {
self->graphics_stats_.flush_overflow_count += 1U;
self->graphics_stats_.flush_blocked_count += 1U;
self->pending_lvgl_flush_request_ = true;
if (!self->pending_full_repaint_request_) {
self->pending_full_repaint_request_ = true;
}
lv_disp_flush_ready(disp);
return;
}
}
const uint32_t width = static_cast<uint32_t>(area->x2 - area->x1 + 1);
const uint32_t height = static_cast<uint32_t>(area->y2 - area->y1 + 1);
const uint32_t pixel_count = width * height;
const uint32_t started_us = micros();
const bool needs_convert = kUseColor256Runtime;
const bool needs_copy_to_trans = self->buffer_cfg_.draw_in_psram || self->buffer_cfg_.full_frame;
bool async_dma = self->async_flush_enabled_ && self->dma_available_ && !self->flush_ctx_.pending;
bool tx_pixels_prepared = false;
uint16_t* tx_pixels = reinterpret_cast<uint16_t*>(&color_p->full);
if (needs_convert || needs_copy_to_trans) {
if (self->dma_trans_buf_ != nullptr && pixel_count <= self->dma_trans_buf_pixels_) {
tx_pixels = self->dma_trans_buf_;
if (needs_convert) {
self->convertLineRgb332ToRgb565(color_p, tx_pixels, pixel_count);
} else {
std::memcpy(tx_pixels, reinterpret_cast<uint16_t*>(&color_p->full), pixel_count * sizeof(uint16_t));
}
tx_pixels_prepared = true;
} else {
async_dma = false;
}
}
if (async_dma) {
if (!display.startWrite()) {
self->graphics_stats_.flush_overflow_count += 1U;
self->graphics_stats_.flush_blocked_count += 1U;
self->pending_lvgl_flush_request_ = true;
self->pending_full_repaint_request_ = true;
lv_disp_flush_ready(disp);
return;
}
display.pushImageDma(area->x1,
area->y1,
static_cast<int16_t>(width),
static_cast<int16_t>(height),
tx_pixels);
const bool dma_done = display.waitDmaComplete(kLvglFlushDmaWaitUs);
display.endWrite();
const uint32_t elapsed_us = micros() - started_us;
self->graphics_stats_.flush_count += 1U;
self->graphics_stats_.dma_flush_count += 1U;
self->graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > self->graphics_stats_.flush_time_max_us) {
self->graphics_stats_.flush_time_max_us = elapsed_us;
}
if (!dma_done && self->async_flush_enabled_) {
self->graphics_stats_.flush_stall_count += 1U;
self->graphics_stats_.flush_recover_count += 1U;
self->graphics_stats_.async_fallback_count += 1U;
self->async_flush_enabled_ = false;
self->buffer_cfg_.dma_enabled = false;
self->async_fallback_until_ms_ = millis() + kAsyncFallbackRecoverMs;
self->pending_lvgl_flush_request_ = true;
self->pending_full_repaint_request_ = true;
}
perfMonitor().noteUiFlush(true, elapsed_us);
self->flush_pending_since_ms_ = 0U;
self->flush_last_progress_ms_ = millis();
lv_disp_flush_ready(disp);
return;
}
if (!display.startWrite()) {
self->graphics_stats_.flush_overflow_count += 1U;
self->graphics_stats_.flush_blocked_count += 1U;
self->pending_lvgl_flush_request_ = true;
self->pending_full_repaint_request_ = true;
lv_disp_flush_ready(disp);
return;
}
display.setAddrWindow(area->x1, area->y1, static_cast<int16_t>(width), static_cast<int16_t>(height));
if (needs_convert && !tx_pixels_prepared) {
static uint16_t row_buffer[(FREENOVE_LCD_WIDTH > FREENOVE_LCD_HEIGHT) ? FREENOVE_LCD_WIDTH
: FREENOVE_LCD_HEIGHT];
const uint32_t max_row = sizeof(row_buffer) / sizeof(row_buffer[0]);
if (self->dma_trans_buf_ != nullptr && self->dma_trans_buf_pixels_ >= width) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = color_p + (row * width);
self->convertLineRgb332ToRgb565(src_row, self->dma_trans_buf_, width);
display.pushColors(self->dma_trans_buf_, width, true);
}
} else if (width <= max_row) {
for (uint32_t row = 0U; row < height; ++row) {
const lv_color_t* src_row = color_p + (row * width);
self->convertLineRgb332ToRgb565(src_row, row_buffer, width);
display.pushColors(row_buffer, width, true);
}
} else {
for (uint32_t pixel = 0U; pixel < pixel_count; ++pixel) {
#if LV_COLOR_DEPTH == 8
const uint16_t c565 = self->rgb332_to_565_lut_[color_p[pixel].full];
display.pushColor(c565);
#else
display.pushColor(static_cast<uint16_t>(color_p[pixel].full));
#endif
}
}
} else if (needs_copy_to_trans && tx_pixels_prepared) {
display.pushColors(tx_pixels, pixel_count, true);
} else {
display.pushColors(tx_pixels, pixel_count, true);
}
display.endWrite();
const uint32_t elapsed_us = micros() - started_us;
self->graphics_stats_.flush_count += 1U;
self->graphics_stats_.sync_flush_count += 1U;
self->graphics_stats_.flush_time_total_us += elapsed_us;
if (elapsed_us > self->graphics_stats_.flush_time_max_us) {
self->graphics_stats_.flush_time_max_us = elapsed_us;
}
perfMonitor().noteUiFlush(false, elapsed_us);
self->flush_pending_since_ms_ = 0U;
self->flush_last_progress_ms_ = millis();
lv_disp_flush_ready(disp);
}
#endif // UI_MANAGER_SPLIT_IMPL
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
// amiga_ui_shell.cpp - Workbench-style app launcher grid (LVGL).
#include "ui/workbench/amiga_ui_shell.h"
#include <cstdio>
#include <cstring>
namespace ui::workbench {
// ── Amiga Workbench color palette ──
static constexpr lv_color_t kColBg = LV_COLOR_MAKE(0x00, 0x55, 0xAA); // Classic Amiga blue
static constexpr lv_color_t kColTopbar = LV_COLOR_MAKE(0xFF, 0x88, 0x00); // Orange title bar
static constexpr lv_color_t kColTile = LV_COLOR_MAKE(0x00, 0x44, 0x88); // Darker blue tile
static constexpr lv_color_t kColTileFocus = LV_COLOR_MAKE(0xFF, 0x88, 0x00); // Orange focus
static constexpr lv_color_t kColTileDisab = LV_COLOR_MAKE(0x33, 0x33, 0x55); // Grey-blue disabled
static constexpr lv_color_t kColTextWhite = LV_COLOR_MAKE(0xFF, 0xFF, 0xFF);
static constexpr lv_color_t kColTextBlack = LV_COLOR_MAKE(0x00, 0x00, 0x00);
static constexpr lv_color_t kColTextGrey = LV_COLOR_MAKE(0x88, 0x88, 0xAA);
static constexpr uint16_t kTopbarHeight = 24U;
static constexpr uint16_t kPageIndicatorH = 18U;
static constexpr uint16_t kTilePadding = 4U;
static constexpr uint16_t kTileCornerRad = 3U;
static constexpr uint16_t kGridPadTop = 4U;
static constexpr uint16_t kGridPadSide = 6U;
// ── Category icon symbols (pixel-art fallback using LV symbols) ──
static const char* categoryIcon(const char* category) {
if (category == nullptr) return LV_SYMBOL_FILE;
if (std::strcmp(category, "media") == 0) return LV_SYMBOL_AUDIO;
if (std::strcmp(category, "capture") == 0) return LV_SYMBOL_IMAGE;
if (std::strcmp(category, "utility") == 0) return LV_SYMBOL_SETTINGS;
if (std::strcmp(category, "kids") == 0) return LV_SYMBOL_HOME;
if (std::strcmp(category, "emulator") == 0) return LV_SYMBOL_PLAY;
return LV_SYMBOL_FILE;
}
// ── Init ──
bool AmigaUIShell::begin(const Config& cfg, AppRegistry* registry, AppRuntimeManager* runtime) {
cfg_ = cfg;
registry_ = registry;
runtime_ = runtime;
if (registry_ == nullptr) {
Serial.println("[WB] begin failed: no registry");
return false;
}
enabled_count_ = registry_->enabledCount();
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
page_count_ = (enabled_count_ + tiles_per_page - 1U) / tiles_per_page;
if (page_count_ == 0U) page_count_ = 1U;
if (page_count_ > kMaxPages) page_count_ = kMaxPages;
current_page_ = 0U;
focus_index_ = 0U;
// Compute tile dimensions.
const uint16_t grid_w = cfg_.screen_w - (kGridPadSide * 2U);
const uint16_t grid_h = cfg_.screen_h - kTopbarHeight - kPageIndicatorH - kGridPadTop;
tile_w_ = (grid_w - (kTilePadding * (cfg_.grid_cols - 1U))) / cfg_.grid_cols;
tile_h_ = (grid_h - (kTilePadding * (cfg_.grid_rows - 1U))) / cfg_.grid_rows;
grid_x_offset_ = kGridPadSide;
grid_y_offset_ = kTopbarHeight + kGridPadTop;
if (!styles_inited_) {
// Background.
lv_style_init(&st_bg_);
lv_style_set_bg_color(&st_bg_, kColBg);
lv_style_set_bg_opa(&st_bg_, LV_OPA_COVER);
lv_style_set_radius(&st_bg_, 0);
lv_style_set_border_width(&st_bg_, 0);
lv_style_set_pad_all(&st_bg_, 0);
// Top bar.
lv_style_init(&st_topbar_);
lv_style_set_bg_color(&st_topbar_, kColTopbar);
lv_style_set_bg_opa(&st_topbar_, LV_OPA_COVER);
lv_style_set_radius(&st_topbar_, 0);
lv_style_set_border_width(&st_topbar_, 0);
lv_style_set_pad_left(&st_topbar_, 6);
lv_style_set_pad_right(&st_topbar_, 6);
lv_style_set_pad_top(&st_topbar_, 2);
// Tile normal.
lv_style_init(&st_tile_);
lv_style_set_bg_color(&st_tile_, kColTile);
lv_style_set_bg_opa(&st_tile_, LV_OPA_COVER);
lv_style_set_radius(&st_tile_, kTileCornerRad);
lv_style_set_border_width(&st_tile_, 1);
lv_style_set_border_color(&st_tile_, kColTextGrey);
lv_style_set_pad_all(&st_tile_, 2);
// Tile focused.
lv_style_init(&st_tile_focused_);
lv_style_set_bg_color(&st_tile_focused_, kColTileFocus);
lv_style_set_bg_opa(&st_tile_focused_, LV_OPA_COVER);
lv_style_set_border_width(&st_tile_focused_, 2);
lv_style_set_border_color(&st_tile_focused_, kColTextWhite);
// Tile disabled.
lv_style_init(&st_tile_disabled_);
lv_style_set_bg_color(&st_tile_disabled_, kColTileDisab);
lv_style_set_bg_opa(&st_tile_disabled_, LV_OPA_70);
lv_style_set_text_color(&st_tile_disabled_, kColTextGrey);
// Label.
lv_style_init(&st_label_);
lv_style_set_text_color(&st_label_, kColTextWhite);
lv_style_set_text_font(&st_label_, &lv_font_montserrat_14);
// Status.
lv_style_init(&st_status_);
lv_style_set_text_color(&st_status_, kColTextBlack);
lv_style_set_text_font(&st_status_, &lv_font_montserrat_14);
// Page indicator.
lv_style_init(&st_page_indicator_);
lv_style_set_text_color(&st_page_indicator_, kColTextWhite);
lv_style_set_text_font(&st_page_indicator_, &lv_font_montserrat_14);
styles_inited_ = true;
}
Serial.printf("[WB] begin ok apps=%u pages=%u tile=%ux%u grid=%ux%u\n",
enabled_count_, page_count_, tile_w_, tile_h_,
cfg_.grid_cols, cfg_.grid_rows);
return true;
}
// ── Show / Hide ──
void AmigaUIShell::show() {
if (visible_) return;
if (root_ == nullptr) createUi();
rebuildGrid();
lv_obj_clear_flag(root_, LV_OBJ_FLAG_HIDDEN);
visible_ = true;
Serial.println("[WB] show");
}
void AmigaUIShell::hide() {
if (!visible_) return;
if (root_ != nullptr) {
lv_obj_add_flag(root_, LV_OBJ_FLAG_HIDDEN);
}
visible_ = false;
Serial.println("[WB] hide");
}
void AmigaUIShell::toggle() {
if (visible_) hide();
else show();
}
// ── Create LVGL UI ──
void AmigaUIShell::createUi() {
if (root_ != nullptr) return;
root_ = lv_obj_create(lv_layer_top());
lv_obj_add_style(root_, &st_bg_, 0);
lv_obj_set_size(root_, cfg_.screen_w, cfg_.screen_h);
lv_obj_set_pos(root_, 0, 0);
lv_obj_clear_flag(root_, LV_OBJ_FLAG_SCROLLABLE);
// ── Top bar ──
topbar_ = lv_obj_create(root_);
lv_obj_add_style(topbar_, &st_topbar_, 0);
lv_obj_set_size(topbar_, cfg_.screen_w, kTopbarHeight);
lv_obj_set_pos(topbar_, 0, 0);
lv_obj_clear_flag(topbar_, LV_OBJ_FLAG_SCROLLABLE);
topbar_label_ = lv_label_create(topbar_);
lv_obj_add_style(topbar_label_, &st_status_, 0);
lv_label_set_text(topbar_label_, "Workbench");
lv_obj_align(topbar_label_, LV_ALIGN_LEFT_MID, 0, 0);
topbar_status_ = lv_label_create(topbar_);
lv_obj_add_style(topbar_status_, &st_status_, 0);
lv_label_set_text(topbar_status_, "");
lv_obj_align(topbar_status_, LV_ALIGN_RIGHT_MID, 0, 0);
// ── Grid container ──
grid_container_ = lv_obj_create(root_);
lv_obj_set_style_bg_opa(grid_container_, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(grid_container_, 0, 0);
lv_obj_set_style_pad_all(grid_container_, 0, 0);
lv_obj_set_size(grid_container_,
cfg_.screen_w - (kGridPadSide * 2U),
cfg_.screen_h - kTopbarHeight - kPageIndicatorH - kGridPadTop);
lv_obj_set_pos(grid_container_, grid_x_offset_, grid_y_offset_);
lv_obj_clear_flag(grid_container_, LV_OBJ_FLAG_SCROLLABLE);
// ── Page indicator ──
page_indicator_ = lv_label_create(root_);
lv_obj_add_style(page_indicator_, &st_page_indicator_, 0);
lv_label_set_text(page_indicator_, "");
lv_obj_align(page_indicator_, LV_ALIGN_BOTTOM_MID, 0, -2);
// ── Launch overlay (hidden by default) ──
launch_overlay_ = lv_obj_create(root_);
lv_obj_set_style_bg_color(launch_overlay_, LV_COLOR_MAKE(0x00, 0x00, 0x00), 0);
lv_obj_set_style_bg_opa(launch_overlay_, LV_OPA_70, 0);
lv_obj_set_style_border_width(launch_overlay_, 0, 0);
lv_obj_set_style_radius(launch_overlay_, 8, 0);
lv_obj_set_size(launch_overlay_, 200, 50);
lv_obj_align(launch_overlay_, LV_ALIGN_CENTER, 0, 0);
lv_obj_clear_flag(launch_overlay_, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN);
launch_label_ = lv_label_create(launch_overlay_);
lv_obj_add_style(launch_label_, &st_label_, 0);
lv_label_set_text(launch_label_, "Ouverture...");
lv_obj_center(launch_label_);
Serial.println("[WB] UI created");
}
void AmigaUIShell::destroyUi() {
if (root_ != nullptr) {
lv_obj_del(root_);
root_ = nullptr;
topbar_ = nullptr;
topbar_label_ = nullptr;
topbar_status_ = nullptr;
grid_container_ = nullptr;
page_indicator_ = nullptr;
launch_overlay_ = nullptr;
launch_label_ = nullptr;
}
tile_count_ = 0U;
for (auto& t : tiles_) {
t.container = nullptr;
t.icon_label = nullptr;
t.title_label = nullptr;
t.global_app_index = 0xFFU;
}
}
// ── Grid rebuild ──
void AmigaUIShell::rebuildGrid() {
if (grid_container_ == nullptr || registry_ == nullptr) return;
// Clear existing tiles.
lv_obj_clean(grid_container_);
tile_count_ = 0U;
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
const uint8_t page_start = current_page_ * tiles_per_page;
uint8_t tiles_this_page = tilesOnCurrentPage();
for (uint8_t i = 0U; i < tiles_this_page && i < kMaxTilesPerPage; ++i) {
const uint8_t global_idx = page_start + i;
const AppEntry* app = registry_->enabledEntry(global_idx);
if (app == nullptr) break;
const uint8_t col = i % cfg_.grid_cols;
const uint8_t row = i / cfg_.grid_cols;
const int16_t x = static_cast<int16_t>(col * (tile_w_ + kTilePadding));
const int16_t y = static_cast<int16_t>(row * (tile_h_ + kTilePadding));
TileSlot& slot = tiles_[tile_count_];
slot.global_app_index = global_idx;
// Tile container.
slot.container = lv_obj_create(grid_container_);
lv_obj_add_style(slot.container, &st_tile_, 0);
lv_obj_set_size(slot.container, tile_w_, tile_h_);
lv_obj_set_pos(slot.container, x, y);
lv_obj_clear_flag(slot.container, LV_OBJ_FLAG_SCROLLABLE);
if (!app->enabled) {
lv_obj_add_style(slot.container, &st_tile_disabled_, 0);
}
// Icon (symbol fallback).
slot.icon_label = lv_label_create(slot.container);
lv_obj_set_style_text_font(slot.icon_label, &lv_font_montserrat_28, 0);
lv_obj_set_style_text_color(slot.icon_label, kColTextWhite, 0);
lv_label_set_text(slot.icon_label, categoryIcon(app->category));
lv_obj_align(slot.icon_label, LV_ALIGN_TOP_MID, 0, 4);
// Title label.
slot.title_label = lv_label_create(slot.container);
lv_obj_add_style(slot.title_label, &st_label_, 0);
lv_obj_set_style_text_font(slot.title_label, &lv_font_montserrat_14, 0);
lv_label_set_text(slot.title_label, app->title);
lv_label_set_long_mode(slot.title_label, LV_LABEL_LONG_DOT);
lv_obj_set_width(slot.title_label, tile_w_ - 8);
lv_obj_set_style_text_align(slot.title_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(slot.title_label, LV_ALIGN_BOTTOM_MID, 0, -2);
++tile_count_;
}
// Update page indicator.
if (page_count_ > 1U) {
char buf[24];
std::snprintf(buf, sizeof(buf), "%u / %u", current_page_ + 1U, page_count_);
lv_label_set_text(page_indicator_, buf);
lv_obj_clear_flag(page_indicator_, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(page_indicator_, LV_OBJ_FLAG_HIDDEN);
}
// Clamp focus.
if (focus_index_ >= tile_count_ && tile_count_ > 0U) {
focus_index_ = tile_count_ - 1U;
}
updateFocusVisual();
}
// ── Focus visual ──
void AmigaUIShell::updateFocusVisual() {
for (uint8_t i = 0U; i < tile_count_; ++i) {
TileSlot& slot = tiles_[i];
if (slot.container == nullptr) continue;
if (i == focus_index_) {
lv_obj_add_style(slot.container, &st_tile_focused_, 0);
} else {
lv_obj_remove_style(slot.container, &st_tile_focused_, 0);
lv_obj_add_style(slot.container, &st_tile_, 0);
}
}
}
// ── Navigation ──
void AmigaUIShell::navigateFocus(int8_t dx, int8_t dy) {
if (tile_count_ == 0U) return;
const uint8_t cols = cfg_.grid_cols;
uint8_t col = focus_index_ % cols;
uint8_t row = focus_index_ / cols;
if (dx > 0) {
if (col + 1U < cols && (focus_index_ + 1U) < tile_count_) {
++focus_index_;
} else {
// Wrap to next page.
navigatePage(1);
return;
}
} else if (dx < 0) {
if (col > 0U) {
--focus_index_;
} else {
navigatePage(-1);
return;
}
}
if (dy > 0) {
const uint8_t next = focus_index_ + cols;
if (next < tile_count_) {
focus_index_ = next;
}
} else if (dy < 0) {
if (focus_index_ >= cols) {
focus_index_ -= cols;
}
}
updateFocusVisual();
}
void AmigaUIShell::navigatePage(int8_t delta) {
if (page_count_ <= 1U) return;
int8_t next_page = static_cast<int8_t>(current_page_) + delta;
if (next_page < 0) next_page = static_cast<int8_t>(page_count_ - 1U);
if (next_page >= static_cast<int8_t>(page_count_)) next_page = 0;
current_page_ = static_cast<uint8_t>(next_page);
focus_index_ = 0U;
rebuildGrid();
}
void AmigaUIShell::handleInput(InputKey key) {
if (!visible_) return;
switch (key) {
case InputKey::kUp: navigateFocus(0, -1); break;
case InputKey::kDown: navigateFocus(0, 1); break;
case InputKey::kLeft: navigateFocus(-1, 0); break;
case InputKey::kRight: navigateFocus(1, 0); break;
case InputKey::kOk: launchFocused(millis()); break;
case InputKey::kBack:
if (runtime_ != nullptr && runtime_->isRunning()) {
requestCloseApp("back_button", millis());
}
break;
}
}
void AmigaUIShell::handleButtonKey(uint8_t hw_key, bool long_press) {
// Map physical 5-button ladder to InputKey.
// BTN1=UP, BTN2=DOWN, BTN3=OK, BTN4=BACK, BTN5=RIGHT(page next)
switch (hw_key) {
case 1U: handleInput(InputKey::kUp); break;
case 2U: handleInput(InputKey::kDown); break;
case 3U:
if (long_press) {
handleInput(InputKey::kBack);
} else {
handleInput(InputKey::kOk);
}
break;
case 4U: handleInput(InputKey::kBack); break;
case 5U: handleInput(InputKey::kRight); break;
default: break;
}
}
// ── Launch ──
bool AmigaUIShell::launchFocused(uint32_t now_ms) {
const AppEntry* app = focusedEntry();
if (app == nullptr || runtime_ == nullptr) return false;
// Show launch overlay.
if (launch_overlay_ != nullptr && launch_label_ != nullptr) {
char buf[48];
std::snprintf(buf, sizeof(buf), "Ouverture %s...", app->title);
lv_label_set_text(launch_label_, buf);
lv_obj_clear_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN);
launch_overlay_hide_ms_ = now_ms + 2000U;
}
const bool ok = runtime_->open(app->id, "default", now_ms);
if (!ok) {
if (launch_label_ != nullptr) {
lv_label_set_text(launch_label_, "Erreur ouverture");
launch_overlay_hide_ms_ = now_ms + 1500U;
}
Serial.printf("[WB] launch failed app=%s\n", app->id);
} else {
// Hide workbench, app scene will take over.
hide();
}
return ok;
}
bool AmigaUIShell::requestCloseApp(const char* reason, uint32_t now_ms) {
if (runtime_ == nullptr) return false;
const bool ok = runtime_->close(reason, now_ms);
if (ok) {
show(); // Return to workbench.
}
return ok;
}
const AppEntry* AmigaUIShell::focusedEntry() const {
if (tile_count_ == 0U || focus_index_ >= tile_count_) return nullptr;
const uint8_t global_idx = tiles_[focus_index_].global_app_index;
return registry_->enabledEntry(global_idx);
}
// ── Tick ──
void AmigaUIShell::tick(uint32_t now_ms) {
if (!visible_) return;
// Auto-hide launch overlay.
if (launch_overlay_ != nullptr &&
!lv_obj_has_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN) &&
launch_overlay_hide_ms_ != 0U &&
static_cast<int32_t>(now_ms - launch_overlay_hide_ms_) >= 0) {
lv_obj_add_flag(launch_overlay_, LV_OBJ_FLAG_HIDDEN);
launch_overlay_hide_ms_ = 0U;
}
// Periodic status bar update.
if (now_ms - last_status_update_ms_ >= 1000U) {
updateStatusBar(now_ms);
last_status_update_ms_ = now_ms;
}
}
void AmigaUIShell::updateStatusBar(uint32_t now_ms) {
if (topbar_status_ == nullptr) return;
char buf[40];
const uint32_t uptime_s = now_ms / 1000U;
const uint32_t m = (uptime_s / 60U) % 60U;
const uint32_t h = uptime_s / 3600U;
std::snprintf(buf, sizeof(buf), "%lu:%02lu", static_cast<unsigned long>(h), static_cast<unsigned long>(m));
lv_label_set_text(topbar_status_, buf);
}
void AmigaUIShell::updateTileStates() {
// Future: update tile badges based on runtime state.
}
uint8_t AmigaUIShell::tilesOnCurrentPage() const {
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
const uint8_t page_start = current_page_ * tiles_per_page;
if (page_start >= enabled_count_) return 0U;
const uint8_t remaining = enabled_count_ - page_start;
return (remaining < tiles_per_page) ? remaining : tiles_per_page;
}
uint8_t AmigaUIShell::globalIndexFromPageLocal(uint8_t local) const {
const uint8_t tiles_per_page = cfg_.grid_cols * cfg_.grid_rows;
return current_page_ * tiles_per_page + local;
}
} // namespace ui::workbench

Some files were not shown because too many files have changed in this diff Show More