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:
@@ -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
|
||||
@@ -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/.
|
||||
@@ -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 l’environnement 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 l’ID USB (Freenove, ESP32, ESP8266, RP2040). Le log affiche le hardware détecté et l’environnement 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 L’USB 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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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_
|
||||
@@ -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}}}};
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
[](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:
|
||||
|
||||

|
||||
|
||||
`te_compile()` also automatically prunes constant branches. In this example,
|
||||
the compiled expression returned by `te_compile()` would become:
|
||||
|
||||

|
||||
|
||||
`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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+734
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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,
|
||||
|
+20
-201
@@ -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
|
||||
#
|
||||
|
||||
Executable
+130
@@ -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())
|
||||
Executable
+41
@@ -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
|
||||
Executable
+110
@@ -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"
|
||||
Executable
+181
@@ -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 "$@"
|
||||
Executable
+175
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Executable
+347
@@ -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,6 +1,6 @@
|
||||
# Multiplexage des broches – Attention
|
||||
|
||||
Cible matérielle : **ESP32-S3-WROOM-1-N16R8**.
|
||||
Cible matérielle : **Freenove ESP32-S3 WROOM N16R8**.
|
||||
|
||||
Certaines broches sont partagées entre plusieurs fonctions :
|
||||
- GPIO 4 : utilisé pour le rétroéclairage (BL) et le bouton 3 (BTN3)
|
||||
|
||||
+39
-343
@@ -1,357 +1,53 @@
|
||||
# ui_freenove_allinone
|
||||
|
||||
---
|
||||
# 🎛️ Zacus Firmware – Freenove All-in-One
|
||||
Firmware principal Freenove pour `ESP32_ZACUS`.
|
||||
|
||||

|
||||
## Profil matériel release
|
||||
|
||||
---
|
||||
- Carte: Freenove FNK0102H.
|
||||
- Board profile PlatformIO: `freenove_esp32_s3_wroom` (`N16R8`, 16 MB flash / 8 MB PSRAM).
|
||||
- Écran: ST7796 320x480, rotation `1`.
|
||||
- Entrée officielle: switch analogique 5 directions sur GPIO19.
|
||||
- Touch: non supporté dans le chemin release.
|
||||
|
||||
## 📝 Description
|
||||
## Mapping principal
|
||||
|
||||
Bienvenue dans le cockpit le plus funky du multivers ! Ce firmware fusionne UI, audio, scénarios et hardware sur la carte Freenove Media Kit (ESP32). Ici, chaque pixel danse, chaque bouton groove, et chaque boot est une fête.
|
||||
| Fonction | GPIO |
|
||||
| --- | --- |
|
||||
| TFT SCK | 47 |
|
||||
| TFT MOSI | 21 |
|
||||
| TFT DC | 45 |
|
||||
| TFT RESET | 20 |
|
||||
| TFT BL | 2 |
|
||||
| Boutons analogiques | 19 |
|
||||
| SD CMD / CLK / D0 | 38 / 39 / 40 |
|
||||
| I2S WS / BCK / DOUT | 41 / 42 / 1 |
|
||||
| Caméra XCLK / SIOD / SIOC | 15 / 4 / 5 |
|
||||
|
||||
> *"Si tu entends un son rétro ou vois un plasma violet, c’est normal. Si le microcontrôleur se met à rapper, c’est probablement un bug… ou un feature caché."*
|
||||
|
||||
---
|
||||
|
||||
## 🕹️ Mapping hardware (ESP32-S3 Freenove Media Kit)
|
||||
|
||||
| Fonction | Broche ESP32-S3 | Signal TFT/Touch/Audio | Remarques |
|
||||
|------------------|-----------------|-----------------------|--------------------------------|
|
||||
| TFT SCK | GPIO 47 | SCK | SPI écran (FNK0102B) |
|
||||
| TFT MOSI | GPIO 21 | MOSI | SPI écran |
|
||||
| TFT MISO | -1 | MISO | non utilisé |
|
||||
| TFT CS | -1 | CS | câblage board intégré |
|
||||
| TFT DC | GPIO 45 | DC | Data/Command écran |
|
||||
| TFT RESET | GPIO 20 | RESET | Reset écran |
|
||||
| TFT BL | GPIO 2 | BL | Rétroéclairage |
|
||||
| Touch CS | GPIO 9 | CS (XPT2046) | optionnel (`FREENOVE_HAS_TOUCH`) |
|
||||
| Touch IRQ | GPIO 15 | IRQ (XPT2046) | optionnel |
|
||||
| Boutons | GPIO 19 | ADC ladder (5 touches)| key1..key5 par seuils analogiques |
|
||||
| Audio I2S WS | GPIO 41 | WS | profil principal Sketch_19 |
|
||||
| Audio I2S BCK | GPIO 42 | BCK | profil principal Sketch_19 |
|
||||
| Audio I2S DOUT | GPIO 1 | DOUT | profil principal Sketch_19 |
|
||||
| Alim écran/audio | 3V3/5V/GND | - | Respecter les tensions |
|
||||
|
||||
**Remarques** :
|
||||
- Profil audio runtime sélectionnable par série: `AUDIO_PROFILE <idx>` puis `AUDIO_TEST`.
|
||||
- Profils fournis: `0=sketch19`, `1=swap_bck_ws`, `2=dout2_alt`.
|
||||
- Le tactile est désactivé par défaut (`FREENOVE_HAS_TOUCH=0`).
|
||||
|
||||
---
|
||||
|
||||
## 📦 Contenu du dossier
|
||||
|
||||
- Sources : `src/` (app, ui, audio, storage, camera, drivers, system)
|
||||
- Polices : `src/ui/fonts/`
|
||||
- Layouts d’écran : `src/ui/screens/`
|
||||
- Partition custom : `partitions/freenove_esp32s3_app6mb_fs6mb.csv`
|
||||
- Mapping hardware détaillé (voir pour descendre plus haut )
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & usage
|
||||
|
||||
1. Clone ce repo, chausse tes lunettes de soleil.
|
||||
2. Va dans `hardware/firmware` et build comme un DJ :
|
||||
```sh
|
||||
pio run -e freenove_allinone
|
||||
pio run -e freenove_allinone -t upload --upload-port <PORT>
|
||||
```
|
||||
3. Pour la version ESP32-S3 (partition 6MB app / 6MB FS) :
|
||||
```sh
|
||||
pio run -e freenove_esp32s3
|
||||
pio run -e freenove_esp32s3 -t buildfs
|
||||
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
|
||||
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
|
||||
```
|
||||
4. Branche, allume, et laisse la magie opérer.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Fonctionnalités qui groovent
|
||||
|
||||
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
|
||||
- Exécution de scénarios (lecture, transitions, actions, audio)
|
||||
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
|
||||
- Gestion boutons et tactile (événements, mapping, callbacks)
|
||||
- Fallback robuste si fichier manquant (scénario par défaut)
|
||||
- Génération de logs et artefacts (logs/, artifacts/)
|
||||
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
|
||||
- Documentation et onboarding synchronisés
|
||||
- Mode autonome (pas besoin d’ESP32 séparé)
|
||||
|
||||
> *"Si tu rates un bouton, c’est que tu danses trop fort. Si tu vois un plasma violet, c’est que tu es dans le groove."*
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Modules principaux
|
||||
|
||||
- `audio_manager` : fait swinguer l’audio (lecture, stop, état)
|
||||
- `scenario_manager` : enchaîne les étapes comme un DJ
|
||||
- `ui_manager` : LVGL, écrans dynamiques, FX visuels
|
||||
- `storage_manager` : LittleFS (init, vérif, groove des assets)
|
||||
- `button_manager` : boutons physiques, pour les vrais
|
||||
- `touch_manager` : tactile XPT2046, pour les DJ du futur
|
||||
|
||||
---
|
||||
|
||||
## 🦄 Funky extras & notes
|
||||
|
||||
- Firmware expérimental : fusion audio + UI, mode "party hard" activé
|
||||
- Pour la compatibilité UI Link, prévoir un mode optionnel
|
||||
- Si tu veux une intro Amiga92, active la scène `SCENE_WIN_ETAPE` et laisse-toi porter par le FX timeline (plasma, starfield, boingball…)
|
||||
- Pour tester la scène MP3 : `SCENE_MP3_PLAYER` (overlay AmigaAMP, scan `/music`)
|
||||
- Pour la caméra : `SCENE_CAMERA_SCAN` (Win311 overlay, boutons = SNAP/SAVE/GALLERY/DELETE/CLOSE)
|
||||
- Pour tout le reste, consulte les logs, et si tu comprends tout du premier coup, tu gagnes un badge "Zacus Funk Master".
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contribuer
|
||||
|
||||
Merci de lire [../../../../../../CONTRIBUTING.md](../../../../../../CONTRIBUTING.md) avant toute PR. Les pull requests avec punchlines sont encouragées.
|
||||
|
||||
---
|
||||
|
||||
## 👤 Contact
|
||||
|
||||
Pour toute question, suggestion, ou battle de funk, ouvre une issue GitHub ou contacte l’auteur principal :
|
||||
- Clément SAILLANT — [github.com/electron-rare](https://github.com/electron-rare)
|
||||
|
||||
> *"Ce firmware a été validé par un oscilloscope, un grille-pain, et un synthé vintage. Si tu trouves un bug, c’est peut-être un easter egg ou feat foirée."*
|
||||
---
|
||||
|
||||
# Firmware Freenove Media Kit All-in-One
|
||||
|
||||
## Plan d’intégration complète (couverture specs)
|
||||
|
||||
- Fichiers de scènes et écrans individuels, stockés sur LittleFS (data/)
|
||||
- Navigation UI dynamique (LVGL, écrans générés depuis fichiers)
|
||||
- Exécution de scénarios (lecture, transitions, actions, audio)
|
||||
- Gestion audio (lecture/stop, mapping fichiers LittleFS)
|
||||
- Gestion boutons et tactile (événements, mapping, callbacks)
|
||||
- Fallback robuste si fichier manquant (scénario par défaut)
|
||||
- Génération de logs et artefacts (logs/, artifacts/)
|
||||
- Validation hardware sur Freenove (affichage, audio, boutons, tactile)
|
||||
- Documentation et onboarding synchronisés
|
||||
|
||||
## Structure modulaire
|
||||
|
||||
- `src/app/*` : orchestration runtime (`main`, `scenario_manager`, coordinator serie)
|
||||
- `src/ui/*` : LVGL scenes, fonts, FX helpers (`ui_manager`, `ui_fonts`, `ui/fx`)
|
||||
- `src/audio/*` : gestion audio runtime (`audio_manager`)
|
||||
- `src/storage/*` : LittleFS/SD et resolution assets (`storage_manager`)
|
||||
- `src/camera/*` : camera runtime (`camera_manager`)
|
||||
- `src/drivers/*` : board I/O (input, board, display HAL + SPI bus manager)
|
||||
- `src/system/*` : metrics, boot report, reseau/media wrappers, task topology
|
||||
- headers legacy (`include/*_manager.h`) conserves en facades vers les headers modulaires
|
||||
|
||||
Ce firmware combine :
|
||||
- Les fonctions audio/scénario (type ESP32 Audio)
|
||||
- L’UI locale (affichage, interaction, tactile, boutons)
|
||||
- Le tout sur un seul microcontrôleur (RP2040 ou ESP32 selon le kit)
|
||||
|
||||
|
||||
## Fonctionnalités
|
||||
- Lecture audio, gestion scénario, LittleFS
|
||||
- Affichage TFT tactile (LVGL)
|
||||
- Boutons physiques, capteurs, extensions
|
||||
- Mode autonome (pas besoin d’ESP32 séparé)
|
||||
|
||||
## Modules principaux
|
||||
- audio_manager : gestion audio (lecture, stop, état)
|
||||
- scenario_manager : gestion scénario (étapes, transitions)
|
||||
- ui_manager : gestion UI (LVGL, écrans dynamiques)
|
||||
- storage_manager : gestion LittleFS (init, vérification)
|
||||
- button_manager : gestion boutons physiques
|
||||
- touch_manager : gestion tactile XPT2046
|
||||
|
||||
## Validation hardware
|
||||
- Compiler et flasher sur le Freenove Media Kit
|
||||
- Vérifier l’affichage, la réactivité tactile et boutons
|
||||
- Tester la lecture audio (fichiers dans /data/)
|
||||
- Consulter les logs série pour le suivi d’exécution
|
||||
|
||||
## Artefacts
|
||||
- Firmware compilé (.bin)
|
||||
- Logs de test hardware (logs/)
|
||||
- Rapport de compatibilité assets LittleFS
|
||||
Le détail complet est maintenu dans `../docs/FNK0102H_SOURCE_OF_TRUTH.md`.
|
||||
|
||||
## Build
|
||||
|
||||
Depuis `hardware/firmware` :
|
||||
Depuis la racine du repo:
|
||||
|
||||
```sh
|
||||
pio run -e freenove_allinone
|
||||
pio run -e freenove_allinone -t upload --upload-port <PORT>
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python -m platformio run -e freenove_esp32s3_full_with_ui
|
||||
python -m platformio run -e freenove_esp32s3_full_with_ui -t buildfs
|
||||
python -m platformio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port <PORT>
|
||||
python -m platformio run -e freenove_esp32s3_full_with_ui -t upload --upload-port <PORT>
|
||||
```
|
||||
|
||||
## Build Freenove ESP32-S3 (partition 6MB app / 6MB FS)
|
||||
## Boot modes
|
||||
|
||||
```sh
|
||||
pio run -e freenove_esp32s3
|
||||
pio run -e freenove_esp32s3 -t buildfs
|
||||
pio run -e freenove_esp32s3 -t uploadfs --upload-port <PORT>
|
||||
pio run -e freenove_esp32s3 -t upload --upload-port <PORT>
|
||||
- Normal: UI complète, caméra, audio et réseau autorisés.
|
||||
- `safe diagnostic mode`: affichage minimal + boutons + série uniquement si la PSRAM n'est pas détectée.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python tests/sprint1_utility_contract.py --mode serial --port <PORT>
|
||||
python tests/sprint2_capture_contract.py --mode serial --port <PORT>
|
||||
python tests/sprint3_audio_contract.py --mode serial --port <PORT>
|
||||
python tests/phase9_ui_validation.py --port <PORT>
|
||||
```
|
||||
|
||||
- Partition custom active: `partitions/freenove_esp32s3_app6mb_fs6mb.csv`.
|
||||
- Le bundle story complet n'est plus embarque dans le firmware: `buildfs/uploadfs` est requis pour charger le contenu complet (screens/audio/actions/apps).
|
||||
- Fallback embarque minimal conserve: `APP_WIFI` + scenario `DEFAULT` minimal.
|
||||
|
||||
## Norme embarquee (audit + refacto)
|
||||
|
||||
- Standard projet: `docs/skills/EMBEDDED_CPP_OO_ESP32S3_PIO_ARDUINO.md`.
|
||||
- Toute revue technique Freenove doit citer cette norme pour les risques temps reel, memoire, concurrence et style OO.
|
||||
|
||||
## Provisioning Wi-Fi + Auth WebUI
|
||||
|
||||
- Au boot sans credentials NVS:
|
||||
- mode setup AP actif;
|
||||
- endpoints setup ouverts: `GET /api/provision/status`, `POST /api/wifi/connect`, `POST /api/network/wifi/connect`.
|
||||
- Hors setup:
|
||||
- auth Bearer requise sur `/api/*`:
|
||||
- `Authorization: Bearer <token>`.
|
||||
- Provisioning persistant:
|
||||
- API: `POST /api/wifi/connect` (ou `/api/network/wifi/connect`) avec `persist=1`.
|
||||
- Serie: `WIFI_PROVISION <ssid> <pass>`.
|
||||
- Rotation token:
|
||||
- Serie: `AUTH_TOKEN_ROTATE [token]`.
|
||||
- Outils shell:
|
||||
- `tools/dev/healthcheck_wifi.sh` et `tools/dev/rtos_wifi_health.sh` supportent `ZACUS_WEB_TOKEN`.
|
||||
|
||||
## LVGL graphics stack (ESP32-S3)
|
||||
|
||||
- Le runtime Freenove (`env:freenove_esp32s3*`) supporte:
|
||||
- `LV_COLOR_DEPTH=8` (RGB332) avec conversion RGB565 au flush.
|
||||
- draw buffers lignes en double-buffer.
|
||||
- flush DMA asynchrone (overlap draw/transfert) avec fallback sync.
|
||||
- Flags principaux (`platformio.ini`):
|
||||
- `UI_COLOR_256`, `UI_COLOR_565`, `UI_FORCE_THEME_256`
|
||||
- `UI_DRAW_BUF_LINES`, `UI_DRAW_BUF_IN_PSRAM`
|
||||
- `UI_DMA_FLUSH_ASYNC`, `UI_DMA_TRANS_BUF_LINES`
|
||||
- `UI_FULL_FRAME_BENCH`, `UI_LV_MEM_SIZE_KB`
|
||||
- Commandes debug serie:
|
||||
- `UI_GFX_STATUS`
|
||||
- `UI_MEM_STATUS`
|
||||
- Documentation associee:
|
||||
- `docs/ui/graphics_stack.md`
|
||||
- `docs/ui/lvgl_memory_budget.md`
|
||||
- `docs/ui/fonts_fr.md`
|
||||
|
||||
## Mapping hardware
|
||||
- Voir `include/ui_freenove_config.h` pour l’adaptation des pins
|
||||
- Schéma de branchement : se référer à la doc Freenove
|
||||
|
||||
## Notes
|
||||
- Ce firmware est expérimental et fusionne les logiques audio + UI.
|
||||
- Pour la compatibilité UI Link, prévoir un mode optionnel.
|
||||
|
||||
## Intro Amiga92 (`SCENE_WIN_ETAPE`)
|
||||
|
||||
- Activation: l'intro A/B/C est lancee automatiquement quand `screen_scene_id == SCENE_WIN_ETAPE`.
|
||||
- Mode runtime: `FX_ONLY_V9` avec rendu timeline FX (plasma/starfield/rasterbars + tunnel3d/rotozoom/wirecube + boingball) et overlay LVGL conserve.
|
||||
- Sequence timeline verrouillee: `A(30000ms) -> B(15000ms) -> C(20000ms) -> C loop`.
|
||||
- Mapping presets (default):
|
||||
- A: `demo`
|
||||
- B: `winner`
|
||||
- C: `boingball`
|
||||
- Font scroller default: `italic`.
|
||||
- BPM default: `125`.
|
||||
- Boing shadow path: assembleur S3 active par defaut (`UI_BOING_SHADOW_ASM=1`) avec fallback C automatique.
|
||||
- Log de boot FX: `boing_shadow_path=asm|c`.
|
||||
|
||||
### Overrides runtime (TXT + JSON)
|
||||
|
||||
- Priorite de lecture:
|
||||
1) `/ui/scene_win_etape.json`
|
||||
2) `/SCENE_WIN_ETAPE.json`
|
||||
3) `/ui/SCENE_WIN_ETAPE.json`
|
||||
4) `/ui/scene_win_etape.txt`
|
||||
- Cles supportees:
|
||||
- `A_MS`, `B_MS`, `C_MS`
|
||||
- `FX_PRESET_A`, `FX_PRESET_B`, `FX_PRESET_C` (`demo|winner|fireworks|boingball`)
|
||||
- `FX_MODE_A`, `FX_MODE_B`, `FX_MODE_C` (`classic|starfield3d|dotsphere3d|voxel|raycorridor`)
|
||||
- `FX_SCROLL_TEXT_A`, `FX_SCROLL_TEXT_B`, `FX_SCROLL_TEXT_C`
|
||||
- `FX_SCROLL_FONT` (`basic|bold|outline|italic`)
|
||||
- `FX_BPM` (`60..220`)
|
||||
- Rupture de compatibilite volontaire:
|
||||
- anciennes cles FX `FX_3D`, `FX_3D_QUALITY`, `FONT_MODE` ne sont plus prises en charge dans ce flux.
|
||||
|
||||
Exemples:
|
||||
- JSON: `data/SCENE_WIN_ETAPE.json`
|
||||
- TXT: `data/ui/scene_win_etape.txt`
|
||||
|
||||
### Perf notes
|
||||
|
||||
- Tick fixe: `42 ms` (`~24 FPS` cible), `dt` clamp pour robustesse.
|
||||
- Zero allocation par frame dans la boucle intro (`tickIntro`).
|
||||
- Blit LGFX: fast-path 2x active (`UI_FX_BLIT_FAST_2X=1`) si ratio source/ecran exact, fallback scaler general sinon.
|
||||
- Caps objets scene: `<=140` (petit ecran), `<=260` (grand ecran).
|
||||
- Pools fixes:
|
||||
- stars: `48`
|
||||
- fireworks: `72`
|
||||
- wave glyph+shadow: `64 + 64`
|
||||
- `UI_GFX_STATUS` expose `fx_fps/fx_frames/fx_skip_busy` + compteurs `flush_block/overflow` pour diagnostiquer les saccades LVGL/FX.
|
||||
|
||||
## Scenes demoscene exposees
|
||||
|
||||
- IDs canoniques ajoutes: `SCENE_WINNER`, `SCENE_FIREWORKS`.
|
||||
- Fichiers data:
|
||||
- `data/story/screens/SCENE_WINNER.json`
|
||||
- `data/story/screens/SCENE_FIREWORKS.json`
|
||||
- Registry story: `hardware/libs/story/src/resources/screen_scene_registry.cpp`.
|
||||
|
||||
## Scene lecteur MP3 (`SCENE_MP3_PLAYER`)
|
||||
|
||||
- Scene canonique: `SCENE_MP3_PLAYER`.
|
||||
- Aliases supportes: `SCENE_AUDIO_PLAYER`, `SCENE_MP3`.
|
||||
- Data scene: `data/story/screens/SCENE_MP3_PLAYER.json`.
|
||||
- Mode runtime: overlay LVGL "AmigaAMP" + backend `AudioPlayerService` (scan `/music`, fallback `/audio/music`, fallback `/audio`).
|
||||
- Arbitrage audio:
|
||||
- en scene MP3, l'audio scenario est suspendu;
|
||||
- en sortie de scene MP3, pipeline audio scenario restaure.
|
||||
- Commandes serie:
|
||||
- `AMP_SHOW`, `AMP_HIDE`, `AMP_TOGGLE`
|
||||
- `AMP_SCAN`, `AMP_PLAY <idx|path>`, `AMP_NEXT`, `AMP_PREV`, `AMP_STOP`, `AMP_STATUS`
|
||||
|
||||
## Scene camera recorder (`SCENE_CAMERA_SCAN`)
|
||||
|
||||
- Binding scene: entree `SCENE_CAMERA_SCAN` -> session recorder camera active (`RGB565/QVGA`) + overlay Win311 visible.
|
||||
- Sortie scene: overlay masque, frame gelee purgee, retour mode snapshot legacy (`JPEG`).
|
||||
- Mapping boutons physiques:
|
||||
- `BTN1`: `SNAP/LIVE`
|
||||
- `BTN2`: `SAVE`
|
||||
- `BTN3`: `GALLERY` (appui long: `NEXT`)
|
||||
- `BTN4`: `DELETE`
|
||||
- `BTN5`: `CLOSE` overlay
|
||||
- Commandes serie:
|
||||
- `CAM_UI_SHOW`, `CAM_UI_HIDE`, `CAM_UI_TOGGLE`
|
||||
- `CAM_REC_SNAP`, `CAM_REC_SAVE [auto|bmp|jpg|raw]`
|
||||
- `CAM_REC_GALLERY`, `CAM_REC_NEXT`, `CAM_REC_DELETE`, `CAM_REC_STATUS`
|
||||
- Ownership runtime:
|
||||
- pendant `SCENE_CAMERA_SCAN`, les boutons physiques ne sont pas forwardes au scenario;
|
||||
- commandes legacy `CAM_ON/CAM_OFF/CAM_SNAPSHOT` renvoient `camera_busy_recorder_owner`.
|
||||
|
||||
### Visual verification mode
|
||||
|
||||
- Build flags (env `freenove_esp32s3`) pour tests scene:
|
||||
- `UI_FULL_FRAME_BENCH=1`
|
||||
- `UI_DEMO_AUTORUN_WIN_ETAPE=1`
|
||||
- Quand ces flags sont actifs:
|
||||
- la scene `SCENE_WIN_ETAPE` demarre automatiquement au boot;
|
||||
- la phase C continue en boucle tant que la scene reste active;
|
||||
- `UI_GFX_STATUS` permet de verifier le mode runtime (depth/full_frame/source).
|
||||
|
||||
### References consulted
|
||||
|
||||
- https://www.pouet.net/prodlist.php?type%5B0%5D=cracktro
|
||||
- https://www.youtube.com/results?search_query=amiga+cracktro
|
||||
- https://www.youtube.com/results?search_query=amiga+demoscene+1992
|
||||
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/hard_2.html
|
||||
- https://www.theflatnet.de/pub/cbm/amiga/AmigaDevDocs/
|
||||
- https://www.markwrobel.dk/project/amigamachinecode/
|
||||
- https://www.markwrobel.dk/post/amiga-machine-code-letter3-copper-revisited/
|
||||
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-starfield-effect/
|
||||
- https://www.markwrobel.dk/post/amiga-machine-code-letter12-wave/
|
||||
- https://github.com/mrandreastoth/AmigaStyleDemo
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// app_module.h - Interface for pluggable app modules.
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
struct RuntimeServices;
|
||||
struct AppEntry;
|
||||
|
||||
enum class AppModuleState : uint8_t {
|
||||
kIdle = 0,
|
||||
kStarting,
|
||||
kRunning,
|
||||
kError,
|
||||
};
|
||||
|
||||
class IAppModule {
|
||||
public:
|
||||
virtual ~IAppModule() = default;
|
||||
|
||||
virtual const char* id() const = 0;
|
||||
|
||||
// Called once when APP_OPEN arrives. Return true if launch succeeded.
|
||||
virtual bool onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) = 0;
|
||||
|
||||
// Called once when APP_CLOSE arrives.
|
||||
virtual void onClose(const char* reason, RuntimeServices* services) = 0;
|
||||
|
||||
// Called every frame while the app is running.
|
||||
virtual void onTick(uint32_t now_ms, RuntimeServices* services) = 0;
|
||||
|
||||
// Dispatch an APP_ACTION. Return true if handled.
|
||||
virtual bool onAction(const char* action, const char* payload, RuntimeServices* services) = 0;
|
||||
|
||||
// Current module state.
|
||||
virtual AppModuleState state() const = 0;
|
||||
|
||||
// Last error (may be empty).
|
||||
virtual const char* lastError() const { return ""; }
|
||||
};
|
||||
@@ -1,25 +1,46 @@
|
||||
// app_registry.h - app catalog loaded from LittleFS with embedded fallback.
|
||||
// app_registry.h - App catalog loaded from registry.json.
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
#include <Arduino.h>
|
||||
|
||||
#include "app/app_runtime_types.h"
|
||||
struct AppEntry {
|
||||
char id[24] = {0};
|
||||
char title[32] = {0};
|
||||
char category[16] = {0};
|
||||
char icon_path[48] = {0};
|
||||
char entry_screen[40] = {0};
|
||||
bool enabled = true;
|
||||
uint8_t required_capabilities = 0U; // bitmask (lightweight)
|
||||
};
|
||||
|
||||
class StorageManager;
|
||||
// Capability bits for required_capabilities bitmask.
|
||||
namespace AppCap {
|
||||
constexpr uint8_t kAudioOut = 0x01U;
|
||||
constexpr uint8_t kAudioIn = 0x02U;
|
||||
constexpr uint8_t kStorageFs = 0x04U;
|
||||
constexpr uint8_t kStorageSd = 0x08U;
|
||||
constexpr uint8_t kCamera = 0x10U;
|
||||
constexpr uint8_t kWifi = 0x20U;
|
||||
constexpr uint8_t kGpuUi = 0x40U;
|
||||
} // namespace AppCap
|
||||
|
||||
class AppRegistry {
|
||||
public:
|
||||
bool loadFromFs(const StorageManager& storage, const char* registry_path = "/apps/registry.json");
|
||||
const AppDescriptor* find(const char* id) const;
|
||||
std::vector<AppDescriptor> listByCategory(const char* category) const;
|
||||
const std::vector<AppDescriptor>& descriptors() const;
|
||||
static constexpr uint8_t kMaxApps = 24U;
|
||||
|
||||
AppRegistry() = default;
|
||||
|
||||
bool loadFromJson(const char* json_path);
|
||||
bool loadFromBuffer(const char* json_buffer, size_t len);
|
||||
|
||||
uint8_t count() const { return count_; }
|
||||
const AppEntry* entry(uint8_t index) const;
|
||||
const AppEntry* findById(const char* id) const;
|
||||
|
||||
uint8_t enabledCount() const;
|
||||
const AppEntry* enabledEntry(uint8_t visible_index) const;
|
||||
|
||||
private:
|
||||
bool loadFromJson(const char* json_text);
|
||||
void loadFallbackCatalog();
|
||||
static uint32_t parseCapabilityMask(const char* csv_caps);
|
||||
static void copyText(char* out, size_t out_size, const char* text);
|
||||
|
||||
std::vector<AppDescriptor> descriptors_;
|
||||
AppEntry entries_[kMaxApps];
|
||||
uint8_t count_ = 0U;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,67 @@
|
||||
// app_runtime_manager.h - app lifecycle and capability gate.
|
||||
// app_runtime_manager.h - App lifecycle: open/close/action state machine.
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "app/app_module.h"
|
||||
#include "app/app_registry.h"
|
||||
#include "app/app_runtime_types.h"
|
||||
|
||||
struct RuntimeServices;
|
||||
|
||||
class AppRuntimeManager {
|
||||
public:
|
||||
enum class State : uint8_t {
|
||||
kIdle = 0,
|
||||
kStarting,
|
||||
kRunning,
|
||||
kClosing,
|
||||
kError,
|
||||
};
|
||||
|
||||
struct Snapshot {
|
||||
State state = State::kIdle;
|
||||
const char* app_id = "";
|
||||
const char* entry_screen = "";
|
||||
const char* last_error = "";
|
||||
uint32_t opened_at_ms = 0U;
|
||||
};
|
||||
|
||||
static constexpr uint8_t kMaxModules = 24U;
|
||||
|
||||
AppRuntimeManager() = default;
|
||||
|
||||
void configure(AppRegistry* registry, const AppContext& context);
|
||||
bool startApp(const AppStartRequest& request, uint32_t now_ms);
|
||||
bool stopApp(const AppStopRequest& request, uint32_t now_ms);
|
||||
bool handleAction(const AppAction& action, uint32_t now_ms);
|
||||
void begin(AppRegistry* registry, RuntimeServices* services);
|
||||
|
||||
// Register a module (call during setup, before any open).
|
||||
bool registerModule(IAppModule* module);
|
||||
|
||||
// App lifecycle.
|
||||
bool open(const char* app_id, const char* mode, uint32_t now_ms);
|
||||
bool close(const char* reason, uint32_t now_ms);
|
||||
bool action(const char* action_name, const char* payload);
|
||||
void tick(uint32_t now_ms);
|
||||
|
||||
AppRuntimeStatus current() const;
|
||||
const AppDescriptor* currentDescriptor() const;
|
||||
// Status.
|
||||
State state() const { return state_; }
|
||||
Snapshot snapshot() const;
|
||||
bool isRunning() const { return state_ == State::kRunning; }
|
||||
bool isIdle() const { return state_ == State::kIdle; }
|
||||
const char* currentAppId() const { return current_app_id_; }
|
||||
|
||||
private:
|
||||
uint32_t evaluateMissingCapabilities(const AppDescriptor& descriptor) const;
|
||||
static void copyText(char* out, size_t out_size, const char* text);
|
||||
IAppModule* findModule(const char* app_id) const;
|
||||
void transitionTo(State next, const char* reason);
|
||||
|
||||
AppRegistry* registry_ = nullptr;
|
||||
AppContext context_ = {};
|
||||
std::unique_ptr<IAppModule> module_;
|
||||
const AppDescriptor* current_descriptor_ = nullptr;
|
||||
AppRuntimeStatus status_ = {};
|
||||
RuntimeServices* services_ = nullptr;
|
||||
|
||||
IAppModule* modules_[kMaxModules] = {nullptr};
|
||||
uint8_t module_count_ = 0U;
|
||||
|
||||
IAppModule* active_module_ = nullptr;
|
||||
State state_ = State::kIdle;
|
||||
char current_app_id_[24] = {0};
|
||||
char current_entry_screen_[40] = {0};
|
||||
char last_error_[64] = {0};
|
||||
uint32_t opened_at_ms_ = 0U;
|
||||
uint32_t close_requested_ms_ = 0U;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// default_app_registry.h - Embedded fallback app registry JSON.
|
||||
#pragma once
|
||||
|
||||
static const char kDefaultAppRegistryJson[] = R"JSON({
|
||||
"apps": [
|
||||
{"id": "audio_player", "title": "Lecteur Audio", "category": "media", "entry_screen": "SCENE_AUDIO_PLAYER", "icon_path": "/apps/icons/audio.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
|
||||
{"id": "camera_video", "title": "Photo / Video", "category": "capture", "entry_screen": "SCENE_PHOTO_MANAGER", "icon_path": "/apps/icons/camera.bin", "enabled": true, "required_capabilities": ["CAP_CAMERA","CAP_STORAGE_FS","CAP_GPU_UI"]},
|
||||
{"id": "dictaphone", "title": "Dictaphone", "category": "capture", "entry_screen": "SCENE_RECORDER", "icon_path": "/apps/icons/mic.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_IN","CAP_STORAGE_FS","CAP_GPU_UI"]},
|
||||
{"id": "qr_scanner", "title": "QR Scanner", "category": "utility", "entry_screen": "SCENE_QR_DETECTOR", "icon_path": "/apps/icons/qr.bin", "enabled": true, "required_capabilities": ["CAP_CAMERA","CAP_GPU_UI"]},
|
||||
{"id": "calculator", "title": "Calculatrice", "category": "utility", "entry_screen": "SCENE_CALCULATOR", "icon_path": "/apps/icons/calc.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "timer_tools", "title": "Chrono / Timer", "category": "utility", "entry_screen": "SCENE_TIMER", "icon_path": "/apps/icons/timer.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "flashlight", "title": "Lampe", "category": "utility", "entry_screen": "SCENE_FLASHLIGHT", "icon_path": "/apps/icons/flash.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "audiobook_player", "title": "Livres Audio", "category": "media", "entry_screen": "SCENE_AUDIOBOOK", "icon_path": "/apps/icons/book.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
|
||||
{"id": "kids_webradio", "title": "Webradio", "category": "kids", "entry_screen": "SCENE_WEBRADIO", "icon_path": "/apps/icons/radio.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_WIFI","CAP_GPU_UI"]},
|
||||
{"id": "kids_podcast", "title": "Podcasts", "category": "kids", "entry_screen": "SCENE_PODCAST", "icon_path": "/apps/icons/podcast.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_WIFI","CAP_GPU_UI"]},
|
||||
{"id": "kids_music", "title": "Musique", "category": "kids", "entry_screen": "SCENE_KIDS_MUSIC", "icon_path": "/apps/icons/music.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_STORAGE_FS","CAP_GPU_UI"]},
|
||||
{"id": "kids_yoga", "title": "Yoga", "category": "kids", "entry_screen": "SCENE_KIDS_YOGA", "icon_path": "/apps/icons/yoga.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
|
||||
{"id": "kids_meditation", "title": "Meditation", "category": "kids", "entry_screen": "SCENE_KIDS_MEDITATION","icon_path": "/apps/icons/meditate.bin","enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
|
||||
{"id": "kids_languages", "title": "Langues", "category": "kids", "entry_screen": "SCENE_KIDS_LANG", "icon_path": "/apps/icons/lang.bin", "enabled": true, "required_capabilities": ["CAP_AUDIO_OUT","CAP_GPU_UI"]},
|
||||
{"id": "kids_math", "title": "Maths", "category": "kids", "entry_screen": "SCENE_KIDS_MATH", "icon_path": "/apps/icons/math.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "kids_science", "title": "Sciences", "category": "kids", "entry_screen": "SCENE_KIDS_SCIENCE", "icon_path": "/apps/icons/science.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "kids_geography", "title": "Geographie", "category": "kids", "entry_screen": "SCENE_KIDS_GEO", "icon_path": "/apps/icons/geo.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "kids_drawing", "title": "Dessin", "category": "kids", "entry_screen": "SCENE_DRAWING", "icon_path": "/apps/icons/draw.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "kids_coloring", "title": "Coloriage", "category": "kids", "entry_screen": "SCENE_COLORING", "icon_path": "/apps/icons/color.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI"]},
|
||||
{"id": "nes_emulator", "title": "NES Emu", "category": "emulator", "entry_screen": "SCENE_NES_EMU", "icon_path": "/apps/icons/nes.bin", "enabled": true, "required_capabilities": ["CAP_GPU_UI","CAP_STORAGE_FS"]}
|
||||
]
|
||||
})JSON";
|
||||
@@ -34,7 +34,6 @@ class FileShareService {
|
||||
String* out_error) const;
|
||||
|
||||
private:
|
||||
static void copyText(char* out, size_t out_size, const char* text);
|
||||
bool ensureSharedDirs() const;
|
||||
bool resolveIncomingPath(const char* requested_path, String* out_full_path) const;
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// audio_player_module.h - Audio player app module wrapping AmigaAudioPlayer.
|
||||
#pragma once
|
||||
|
||||
#include "app/app_module.h"
|
||||
|
||||
class AudioPlayerModule : public IAppModule {
|
||||
public:
|
||||
const char* id() const override { return "audio_player"; }
|
||||
|
||||
bool onOpen(const AppEntry& entry, const char* mode, RuntimeServices* services) override;
|
||||
void onClose(const char* reason, RuntimeServices* services) override;
|
||||
void onTick(uint32_t now_ms, RuntimeServices* services) override;
|
||||
bool onAction(const char* action, const char* payload, RuntimeServices* services) override;
|
||||
AppModuleState state() const override { return state_; }
|
||||
const char* lastError() const override { return last_error_; }
|
||||
|
||||
private:
|
||||
AppModuleState state_ = AppModuleState::kIdle;
|
||||
char last_error_[48] = {0};
|
||||
};
|
||||
@@ -27,6 +27,8 @@ class AudioManager {
|
||||
void stop();
|
||||
void update();
|
||||
bool isPlaying() const;
|
||||
void releaseOutputResources();
|
||||
bool restoreOutputResources();
|
||||
|
||||
void setVolume(uint8_t volume);
|
||||
uint8_t volume() const;
|
||||
@@ -112,6 +114,8 @@ class AudioManager {
|
||||
bool pending_use_sd_ = false;
|
||||
bool pending_diagnostic_tone_ = false;
|
||||
uint32_t reopen_earliest_ms_ = 0U;
|
||||
bool restore_pending_ = false;
|
||||
uint32_t restore_retry_not_before_ms_ = 0U;
|
||||
uint32_t underrun_count_ = 0U;
|
||||
uint32_t underrun_last_note_ms_ = 0U;
|
||||
uint32_t underrun_last_log_ms_ = 0U;
|
||||
|
||||
@@ -1,63 +1,108 @@
|
||||
// mutex_manager.h - Thread-safe access protection for global AudioManager and ScenarioManager
|
||||
// CRITICAL: Prevents race conditions between Arduino loop (core 1), WebServer (core 0), I2S callbacks
|
||||
// mutex_manager.h - Thread-safe mutex management (OO singleton, atomic stats).
|
||||
// Strategy: 2 separate mutexes (audio, scenario) for fine-grained protection.
|
||||
// Lock ordering: ALWAYS audio → scenario to prevent deadlock.
|
||||
// Overhead: ~50µs per lock/unlock on ESP32-S3 @ 240MHz.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
// ============================================================================
|
||||
// MUTEX MANAGER - Dual-mutex strategy for g_audio and g_scenario protection
|
||||
// Strategy: 2 separate mutexes (audio_mutex, scenario_mutex) for fine granularity
|
||||
// Lock ordering: ALWAYS acquire audio_mutex before scenario_mutex to prevent deadlock
|
||||
// ISR-safe: Timeout-based acquisition (max 1000ms) compatible with watchdog (30s)
|
||||
// Overhead: ~50µs per lock/unlock operation on ESP32-S3 @ 240MHz
|
||||
// MutexManager — singleton class with atomic stats
|
||||
// Call sites unchanged: MutexManager::init(), MutexManager::takeAudioMutex()…
|
||||
// ============================================================================
|
||||
|
||||
namespace MutexManager {
|
||||
class MutexManager {
|
||||
public:
|
||||
// Meyer's singleton — thread-safe, zero-overhead after first call.
|
||||
static MutexManager& instance();
|
||||
|
||||
// Initialize mutex system (call once in setup())
|
||||
// Returns true if initialization successful, false on failure
|
||||
bool init();
|
||||
// --- Lifecycle (call once in setup / shutdown) ---
|
||||
bool doInit();
|
||||
void doDeinit();
|
||||
|
||||
// Cleanup (call on shutdown/restart)
|
||||
void deinit();
|
||||
// --- Low-level acquisition (prefer RAII guards below) ---
|
||||
bool takeAudio(uint32_t timeout_ms);
|
||||
void releaseAudio();
|
||||
bool takeScenario(uint32_t timeout_ms);
|
||||
void releaseScenario();
|
||||
bool takeBoth(uint32_t timeout_ms);
|
||||
void releaseBoth();
|
||||
|
||||
// Low-level mutex acquisition (prefer RAII guards below)
|
||||
// timeout_ms: Max wait time (0 = no wait, portMAX_DELAY = infinite)
|
||||
// Returns true if lock acquired, false on timeout
|
||||
bool takeAudioMutex(uint32_t timeout_ms);
|
||||
void releaseAudioMutex();
|
||||
// --- Atomic stats ---
|
||||
uint32_t audioLockCount() const { return audio_lock_count_.load(); }
|
||||
uint32_t scenarioLockCount() const { return scenario_lock_count_.load(); }
|
||||
uint32_t audioTimeoutCount() const { return audio_timeout_count_.load(); }
|
||||
uint32_t scenarioTimeoutCount() const { return scenario_timeout_count_.load(); }
|
||||
uint32_t maxAudioWaitUs() const { return max_audio_wait_us_.load(); }
|
||||
uint32_t maxScenarioWaitUs() const { return max_scenario_wait_us_.load(); }
|
||||
|
||||
bool takeScenarioMutex(uint32_t timeout_ms);
|
||||
void releaseScenarioMutex();
|
||||
// --- Static wrappers — backward-compatible with old namespace API ---
|
||||
static bool init() { return instance().doInit(); }
|
||||
static void deinit() { instance().doDeinit(); }
|
||||
static bool takeAudioMutex(uint32_t ms) { return instance().takeAudio(ms); }
|
||||
static void releaseAudioMutex() { instance().releaseAudio(); }
|
||||
static bool takeScenarioMutex(uint32_t ms) { return instance().takeScenario(ms); }
|
||||
static void releaseScenarioMutex() { instance().releaseScenario(); }
|
||||
static bool takeBothMutexes(uint32_t ms) { return instance().takeBoth(ms); }
|
||||
static void releaseBothMutexes() { instance().releaseBoth(); }
|
||||
static uint32_t getAudioLockCount() { return instance().audioLockCount(); }
|
||||
static uint32_t getScenarioLockCount() { return instance().scenarioLockCount(); }
|
||||
static uint32_t getAudioTimeoutCount() { return instance().audioTimeoutCount(); }
|
||||
static uint32_t getScenarioTimeoutCount() { return instance().scenarioTimeoutCount(); }
|
||||
static uint32_t getMaxAudioWaitUs() { return instance().maxAudioWaitUs(); }
|
||||
static uint32_t getMaxScenarioWaitUs() { return instance().maxScenarioWaitUs(); }
|
||||
|
||||
// Acquire both mutexes with deadlock prevention (audio → scenario order)
|
||||
// Returns true if both acquired, false if either acquisition failed
|
||||
bool takeBothMutexes(uint32_t timeout_ms);
|
||||
void releaseBothMutexes();
|
||||
// Non-copyable, non-movable.
|
||||
MutexManager(const MutexManager&) = delete;
|
||||
MutexManager& operator=(const MutexManager&) = delete;
|
||||
|
||||
// Statistics (for debugging race conditions)
|
||||
uint32_t audioLockCount();
|
||||
uint32_t scenarioLockCount();
|
||||
uint32_t audioTimeoutCount();
|
||||
uint32_t scenarioTimeoutCount();
|
||||
uint32_t maxAudioWaitUs();
|
||||
uint32_t maxScenarioWaitUs();
|
||||
private:
|
||||
MutexManager() = default;
|
||||
|
||||
} // namespace MutexManager
|
||||
void updateMaxWait(std::atomic<uint32_t>& max_field, uint32_t elapsed_us);
|
||||
|
||||
SemaphoreHandle_t audio_mutex_{nullptr};
|
||||
SemaphoreHandle_t scenario_mutex_{nullptr};
|
||||
|
||||
std::atomic<uint32_t> audio_lock_count_{0U};
|
||||
std::atomic<uint32_t> scenario_lock_count_{0U};
|
||||
std::atomic<uint32_t> audio_timeout_count_{0U};
|
||||
std::atomic<uint32_t> scenario_timeout_count_{0U};
|
||||
std::atomic<uint32_t> max_audio_wait_us_{0U};
|
||||
std::atomic<uint32_t> max_scenario_wait_us_{0U};
|
||||
|
||||
// Owner tracking for deadlock detection (volatile: written under semaphore).
|
||||
volatile TaskHandle_t audio_owner_{nullptr};
|
||||
volatile TaskHandle_t scenario_owner_{nullptr};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RAII LOCK GUARDS - Automatic lock/unlock with timeout protection
|
||||
// Usage: { AudioLock lock; g_audio.play(...); } // auto-release on scope exit
|
||||
// RAII LOCK GUARDS — automatic lock/unlock on scope exit.
|
||||
// Usage: { AudioLock lock; if (lock) g_audio.play(…); }
|
||||
// ============================================================================
|
||||
|
||||
class AudioLock {
|
||||
public:
|
||||
explicit AudioLock(uint32_t timeout_ms = 1000);
|
||||
~AudioLock();
|
||||
explicit AudioLock(uint32_t timeout_ms = 1000U)
|
||||
: acquired_(MutexManager::instance().takeAudio(timeout_ms)) {
|
||||
if (!acquired_) {
|
||||
Serial.printf("[MUTEX] AudioLock FAILED (timeout %lu ms)\n",
|
||||
static_cast<unsigned long>(timeout_ms));
|
||||
}
|
||||
}
|
||||
~AudioLock() {
|
||||
if (acquired_) {
|
||||
MutexManager::instance().releaseAudio();
|
||||
}
|
||||
}
|
||||
AudioLock(const AudioLock&) = delete;
|
||||
AudioLock& operator=(const AudioLock&) = delete;
|
||||
explicit operator bool() const { return acquired_; }
|
||||
bool acquired() const { return acquired_; }
|
||||
|
||||
private:
|
||||
@@ -66,10 +111,21 @@ class AudioLock {
|
||||
|
||||
class ScenarioLock {
|
||||
public:
|
||||
explicit ScenarioLock(uint32_t timeout_ms = 1000);
|
||||
~ScenarioLock();
|
||||
explicit ScenarioLock(uint32_t timeout_ms = 1000U)
|
||||
: acquired_(MutexManager::instance().takeScenario(timeout_ms)) {
|
||||
if (!acquired_) {
|
||||
Serial.printf("[MUTEX] ScenarioLock FAILED (timeout %lu ms)\n",
|
||||
static_cast<unsigned long>(timeout_ms));
|
||||
}
|
||||
}
|
||||
~ScenarioLock() {
|
||||
if (acquired_) {
|
||||
MutexManager::instance().releaseScenario();
|
||||
}
|
||||
}
|
||||
ScenarioLock(const ScenarioLock&) = delete;
|
||||
ScenarioLock& operator=(const ScenarioLock&) = delete;
|
||||
explicit operator bool() const { return acquired_; }
|
||||
bool acquired() const { return acquired_; }
|
||||
|
||||
private:
|
||||
@@ -78,10 +134,21 @@ class ScenarioLock {
|
||||
|
||||
class DualLock {
|
||||
public:
|
||||
explicit DualLock(uint32_t timeout_ms = 1000);
|
||||
~DualLock();
|
||||
explicit DualLock(uint32_t timeout_ms = 1000U)
|
||||
: acquired_(MutexManager::instance().takeBoth(timeout_ms)) {
|
||||
if (!acquired_) {
|
||||
Serial.printf("[MUTEX] DualLock FAILED (timeout %lu ms)\n",
|
||||
static_cast<unsigned long>(timeout_ms));
|
||||
}
|
||||
}
|
||||
~DualLock() {
|
||||
if (acquired_) {
|
||||
MutexManager::instance().releaseBoth();
|
||||
}
|
||||
}
|
||||
DualLock(const DualLock&) = delete;
|
||||
DualLock& operator=(const DualLock&) = delete;
|
||||
explicit operator bool() const { return acquired_; }
|
||||
bool acquired() const { return acquired_; }
|
||||
|
||||
private:
|
||||
@@ -89,14 +156,13 @@ class DualLock {
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// INTEGRATION MACROS - Quick integration into existing code
|
||||
// Example: AUDIO_GUARDED_CALL(g_audio.play("/music/track.mp3"))
|
||||
// MACROS — quick guarded calls (unchanged API).
|
||||
// ============================================================================
|
||||
|
||||
#define AUDIO_GUARDED_CALL(call) \
|
||||
do { \
|
||||
AudioLock _audio_lock(1000); \
|
||||
if (!_audio_lock.acquired()) { \
|
||||
AudioLock _audio_lock(1000U); \
|
||||
if (!_audio_lock) { \
|
||||
Serial.println("[MUTEX] WARN: Audio lock timeout"); \
|
||||
} else { \
|
||||
call; \
|
||||
@@ -105,8 +171,8 @@ class DualLock {
|
||||
|
||||
#define SCENARIO_GUARDED_CALL(call) \
|
||||
do { \
|
||||
ScenarioLock _scenario_lock(1000); \
|
||||
if (!_scenario_lock.acquired()) { \
|
||||
ScenarioLock _scenario_lock(1000U); \
|
||||
if (!_scenario_lock) { \
|
||||
Serial.println("[MUTEX] WARN: Scenario lock timeout"); \
|
||||
} else { \
|
||||
call; \
|
||||
@@ -115,8 +181,8 @@ class DualLock {
|
||||
|
||||
#define DUAL_GUARDED_CALL(call) \
|
||||
do { \
|
||||
DualLock _dual_lock(1000); \
|
||||
if (!_dual_lock.acquired()) { \
|
||||
DualLock _dual_lock(1000U); \
|
||||
if (!_dual_lock) { \
|
||||
Serial.println("[MUTEX] WARN: Dual lock timeout"); \
|
||||
} else { \
|
||||
call; \
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// str_utils.h - shared string utilities (zero-dependency, header-only).
|
||||
#pragma once
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
|
||||
namespace core {
|
||||
|
||||
/// Case-insensitive string comparison. Returns true if lhs == rhs ignoring case.
|
||||
/// Safe with nullptr: (nullptr, nullptr) → false.
|
||||
inline bool equalsIgnoreCase(const char* lhs, const char* rhs) {
|
||||
if (lhs == nullptr || rhs == nullptr) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 0U;; ++i) {
|
||||
const char a = lhs[i];
|
||||
const char b = rhs[i];
|
||||
if (a == '\0' && b == '\0') {
|
||||
return true;
|
||||
}
|
||||
if (a == '\0' || b == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (std::tolower(static_cast<unsigned char>(a)) !=
|
||||
std::tolower(static_cast<unsigned char>(b))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe bounded string copy. Always null-terminates dst.
|
||||
/// No-op if dst is nullptr or dst_size is 0.
|
||||
inline void copyText(char* dst, size_t dst_size, const char* src) {
|
||||
if (dst == nullptr || dst_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (src == nullptr) {
|
||||
dst[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(dst, src, dst_size - 1U);
|
||||
dst[dst_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -89,6 +89,7 @@ class HardwareManager {
|
||||
|
||||
private:
|
||||
bool beginMic();
|
||||
void endMic();
|
||||
void updateMic(uint32_t now_ms);
|
||||
void updateBattery(uint32_t now_ms);
|
||||
void updateLed(uint32_t now_ms);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/portmacro.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
@@ -48,6 +49,8 @@ class PerfMonitor {
|
||||
PerfSectionStats sections_[static_cast<uint8_t>(PerfSection::kCount)] = {};
|
||||
uint32_t ui_dma_flush_count_ = 0U;
|
||||
uint32_t ui_sync_flush_count_ = 0U;
|
||||
// Spinlock protecting sections_ and flush counters (multi-task safe).
|
||||
mutable portMUX_TYPE stats_mux_ = portMUX_INITIALIZER_UNLOCKED;
|
||||
};
|
||||
|
||||
PerfMonitor& perfMonitor();
|
||||
|
||||
@@ -32,19 +32,52 @@ struct SimdBenchResult {
|
||||
const SimdAccelStatus& status();
|
||||
void resetBenchStatus();
|
||||
|
||||
// --- RGB565 buffer operations ---
|
||||
|
||||
/// memcpy wrapper for typed RGB565 buffers.
|
||||
void simd_rgb565_copy(uint16_t* dst, const uint16_t* src, size_t n_px);
|
||||
|
||||
/// Fill n_px pixels with a constant RGB565 color. Uses 32-bit writes when aligned.
|
||||
void simd_rgb565_fill(uint16_t* dst, uint16_t color565, size_t n_px);
|
||||
|
||||
/// Byte-swap every RGB565 pixel (host ↔ big-endian wire order).
|
||||
/// Optimised: 2 pixels per iteration via 32-bit packing when aligned.
|
||||
void simd_rgb565_bswap_copy(uint16_t* dst, const uint16_t* src, size_t n_px);
|
||||
|
||||
/// Alpha-blend src over dst in-place.
|
||||
/// alpha = 255 → fully src, alpha = 0 → fully dst.
|
||||
/// Optimised: 2 pixels per iteration via 32-bit masking when aligned.
|
||||
void simd_rgb565_alpha_blend(uint16_t* dst, const uint16_t* src,
|
||||
uint8_t alpha, size_t n_px);
|
||||
|
||||
/// Scale brightness of each pixel. level = 255 → no change, 0 → black.
|
||||
/// Optimised: 2 pixels per iteration when aligned.
|
||||
void simd_rgb565_brightness(uint16_t* dst, const uint16_t* src,
|
||||
uint8_t level, size_t n_px);
|
||||
|
||||
// --- Color format conversions ---
|
||||
|
||||
/// Grayscale L8 → RGB565 via precomputed LUT. 2-pixel 32-bit fast path when aligned.
|
||||
void simd_l8_to_rgb565(uint16_t* dst565, const uint8_t* src_l8, size_t n_px);
|
||||
|
||||
/// Indexed 8-bit palette → RGB565. 2-pixel 32-bit fast path when aligned.
|
||||
void simd_index8_to_rgb565(uint16_t* dst565,
|
||||
const uint8_t* idx8,
|
||||
const uint16_t* pal565_256,
|
||||
size_t n_px);
|
||||
|
||||
/// Packed RGB888 → RGB565. 32-bit read fast path when src is aligned.
|
||||
void simd_rgb888_to_rgb565(uint16_t* dst565, const uint8_t* src_rgb888, size_t n_px);
|
||||
|
||||
/// YUV422 (YUYV) → RGB565. BT.601 full-range, 2 pixels per iteration.
|
||||
void simd_yuv422_to_rgb565(uint16_t* dst565, const uint8_t* src_yuv422, size_t n_px);
|
||||
|
||||
// --- Audio DSP ---
|
||||
|
||||
/// Apply Q15 gain to int16 samples. Uses ESP-DSP when available, scalar fallback.
|
||||
void simd_s16_gain_q15(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n);
|
||||
|
||||
/// Mix two int16 streams with independent Q15 gains. Uses ESP-DSP when available.
|
||||
void simd_s16_mix2_q15(int16_t* dst,
|
||||
const int16_t* a,
|
||||
const int16_t* b,
|
||||
@@ -52,8 +85,9 @@ void simd_s16_mix2_q15(int16_t* dst,
|
||||
int16_t gb_q15,
|
||||
size_t n);
|
||||
|
||||
// --- Self-test and benchmark ---
|
||||
|
||||
bool selfTest();
|
||||
SimdBenchResult runBench(uint32_t loops, uint32_t pixels);
|
||||
|
||||
} // namespace runtime::simd
|
||||
|
||||
|
||||
@@ -88,9 +88,7 @@ class NetworkManager {
|
||||
static void onEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status);
|
||||
|
||||
static uint8_t parseHexByte(char high, char low, bool* ok);
|
||||
static void copyText(char* out, size_t out_size, const char* text);
|
||||
static void formatMac(const uint8_t* mac, char* out, size_t out_size);
|
||||
static bool equalsIgnoreCase(const char* lhs, const char* rhs);
|
||||
static const char* wifiModeLabel(uint8_t mode);
|
||||
static const char* networkStateLabel(bool sta_connected,
|
||||
bool sta_connecting,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// amiga_ui_shell.h - Workbench-style app launcher grid (LVGL).
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
#include "app/app_registry.h"
|
||||
#include "app/app_runtime_manager.h"
|
||||
|
||||
namespace ui::workbench {
|
||||
|
||||
class AmigaUIShell {
|
||||
public:
|
||||
// Display layout: portrait 320x480 => 3 cols x 5 rows = 15 tiles/page.
|
||||
// Landscape 480x320 => 4 cols x 3 rows = 12 tiles/page.
|
||||
static constexpr uint8_t kGridColsPortrait = 3U;
|
||||
static constexpr uint8_t kGridRowsPortrait = 5U;
|
||||
static constexpr uint8_t kGridColsLandscape = 4U;
|
||||
static constexpr uint8_t kGridRowsLandscape = 3U;
|
||||
|
||||
static constexpr uint8_t kTilesPerPage = 12U; // Conservative: fits both orientations.
|
||||
static constexpr uint8_t kMaxPages = 3U;
|
||||
static constexpr uint8_t kMaxVisibleTiles = kTilesPerPage * kMaxPages;
|
||||
|
||||
enum class InputKey : uint8_t {
|
||||
kUp = 0,
|
||||
kDown,
|
||||
kLeft,
|
||||
kRight,
|
||||
kOk,
|
||||
kBack,
|
||||
};
|
||||
|
||||
struct Config {
|
||||
bool landscape = true; // Default: rotation=1 => landscape.
|
||||
uint16_t screen_w = 480U;
|
||||
uint16_t screen_h = 320U;
|
||||
uint8_t grid_cols = kGridColsLandscape;
|
||||
uint8_t grid_rows = kGridRowsLandscape;
|
||||
};
|
||||
|
||||
AmigaUIShell() = default;
|
||||
~AmigaUIShell() = default;
|
||||
AmigaUIShell(const AmigaUIShell&) = delete;
|
||||
AmigaUIShell& operator=(const AmigaUIShell&) = delete;
|
||||
|
||||
bool begin(const Config& cfg, AppRegistry* registry, AppRuntimeManager* runtime);
|
||||
void show();
|
||||
void hide();
|
||||
bool visible() const { return visible_; }
|
||||
void toggle();
|
||||
|
||||
void handleInput(InputKey key);
|
||||
void handleButtonKey(uint8_t hw_key, bool long_press);
|
||||
void tick(uint32_t now_ms);
|
||||
|
||||
// Request app launch from the currently focused tile.
|
||||
bool launchFocused(uint32_t now_ms);
|
||||
bool requestCloseApp(const char* reason, uint32_t now_ms);
|
||||
|
||||
uint8_t currentPage() const { return current_page_; }
|
||||
uint8_t pageCount() const { return page_count_; }
|
||||
uint8_t focusIndex() const { return focus_index_; }
|
||||
const AppEntry* focusedEntry() const;
|
||||
|
||||
private:
|
||||
void createUi();
|
||||
void destroyUi();
|
||||
void rebuildGrid();
|
||||
void updateFocusVisual();
|
||||
void updateStatusBar(uint32_t now_ms);
|
||||
void updateTileStates();
|
||||
void navigatePage(int8_t delta);
|
||||
void navigateFocus(int8_t dx, int8_t dy);
|
||||
uint8_t tilesOnCurrentPage() const;
|
||||
uint8_t globalIndexFromPageLocal(uint8_t local) const;
|
||||
|
||||
// Styles.
|
||||
lv_style_t st_bg_;
|
||||
lv_style_t st_topbar_;
|
||||
lv_style_t st_tile_;
|
||||
lv_style_t st_tile_focused_;
|
||||
lv_style_t st_tile_disabled_;
|
||||
lv_style_t st_label_;
|
||||
lv_style_t st_status_;
|
||||
lv_style_t st_page_indicator_;
|
||||
bool styles_inited_ = false;
|
||||
|
||||
// Layout.
|
||||
Config cfg_;
|
||||
uint16_t tile_w_ = 0U;
|
||||
uint16_t tile_h_ = 0U;
|
||||
uint16_t grid_x_offset_ = 0U;
|
||||
uint16_t grid_y_offset_ = 0U;
|
||||
|
||||
// Data.
|
||||
AppRegistry* registry_ = nullptr;
|
||||
AppRuntimeManager* runtime_ = nullptr;
|
||||
uint8_t enabled_count_ = 0U;
|
||||
uint8_t page_count_ = 0U;
|
||||
uint8_t current_page_ = 0U;
|
||||
uint8_t focus_index_ = 0U; // local index on current page
|
||||
bool visible_ = false;
|
||||
|
||||
// LVGL objects.
|
||||
lv_obj_t* root_ = nullptr;
|
||||
lv_obj_t* topbar_ = nullptr;
|
||||
lv_obj_t* topbar_label_ = nullptr;
|
||||
lv_obj_t* topbar_status_ = nullptr;
|
||||
lv_obj_t* grid_container_ = nullptr;
|
||||
lv_obj_t* page_indicator_ = nullptr;
|
||||
|
||||
static constexpr uint8_t kMaxTilesPerPage = 15U; // Max visible on one page.
|
||||
struct TileSlot {
|
||||
lv_obj_t* container = nullptr;
|
||||
lv_obj_t* icon_label = nullptr;
|
||||
lv_obj_t* title_label = nullptr;
|
||||
uint8_t global_app_index = 0xFFU;
|
||||
};
|
||||
TileSlot tiles_[kMaxTilesPerPage];
|
||||
uint8_t tile_count_ = 0U;
|
||||
|
||||
// Launching feedback.
|
||||
lv_obj_t* launch_overlay_ = nullptr;
|
||||
lv_obj_t* launch_label_ = nullptr;
|
||||
uint32_t launch_overlay_hide_ms_ = 0U;
|
||||
|
||||
uint32_t last_status_update_ms_ = 0U;
|
||||
};
|
||||
|
||||
} // namespace ui::workbench
|
||||
@@ -1,8 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// Freenove Media Kit reference profile (Sketch_19):
|
||||
// - FNK0102H ST7796 320x480 by default, rotation 1
|
||||
// - Buttons via analog ladder on GPIO19
|
||||
// Freenove FNK0102H release profile:
|
||||
// - Freenove ESP32-S3 WROOM N8R8 (8MB flash / 8MB PSRAM)
|
||||
// - ST7796 320x480, rotation 1
|
||||
// - 5-way switch via analog ladder on GPIO19
|
||||
// - I2S output BCLK=42, WS=41, DOUT=1
|
||||
|
||||
#ifndef FREENOVE_LCD_VARIANT_FNK0102A
|
||||
@@ -135,6 +136,9 @@
|
||||
#define FREENOVE_HAS_TOUCH 0
|
||||
#endif
|
||||
|
||||
// Official release profile uses the 5-way switch only.
|
||||
// The experimental touch wiring overlaps with the camera mapping on FNK0102H,
|
||||
// so release builds keep touch disabled.
|
||||
#if FREENOVE_HAS_TOUCH
|
||||
#define FREENOVE_TOUCH_CS 9
|
||||
#define FREENOVE_TOUCH_IRQ 15
|
||||
@@ -221,7 +225,7 @@
|
||||
#define FREENOVE_BAT_CHARGE_PIN -1
|
||||
#endif
|
||||
|
||||
// Camera wiring from Freenove FNK0102 (ESP32S3_EYE profile).
|
||||
// Camera wiring validated for the FNK0102H release profile.
|
||||
#ifndef FREENOVE_CAM_ENABLE
|
||||
#define FREENOVE_CAM_ENABLE 1
|
||||
#endif
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// app_audio.cpp - Audio Player Implementation
|
||||
#include "app/app_audio.h"
|
||||
#include "audio_manager.h"
|
||||
#include "core/str_utils.h"
|
||||
|
||||
AppAudio g_app_audio;
|
||||
|
||||
@@ -54,7 +55,7 @@ void AppAudio::onAction(const AppAction& action) {
|
||||
} else if (strcmp(action.name, "stop") == 0) {
|
||||
stop();
|
||||
} else if (strcmp(action.name, "volume") == 0) {
|
||||
int vol = atoi(action.payload);
|
||||
int vol = static_cast<int>(strtol(action.payload, nullptr, 10));
|
||||
setVolume(constrain(vol, 0, 100));
|
||||
}
|
||||
}
|
||||
@@ -65,8 +66,7 @@ bool AppAudio::play(const char* filepath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
strncpy(current_file_, filepath, sizeof(current_file_) - 1);
|
||||
current_file_[sizeof(current_file_) - 1] = '\0';
|
||||
core::copyText(current_file_, sizeof(current_file_), filepath);
|
||||
|
||||
playback_ms_ = 0;
|
||||
duration_ms_ = 30000; // Assume 30 seconds for demo
|
||||
@@ -74,9 +74,8 @@ bool AppAudio::play(const char* filepath) {
|
||||
|
||||
Serial.printf("[APP_AUDIO] Playing: %s (vol=%u%%)\n", current_file_, volume_percent_);
|
||||
|
||||
// TODO: Integrate with AudioManager for actual playback
|
||||
if (context_.audio) {
|
||||
// context_.audio->playFile(filepath);
|
||||
context_.audio->play(filepath);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -104,9 +103,8 @@ void AppAudio::stop() {
|
||||
|
||||
Serial.println("[APP_AUDIO] Stopped");
|
||||
|
||||
// TODO: Stop playback via AudioManager
|
||||
if (context_.audio) {
|
||||
// context_.audio->stop();
|
||||
context_.audio->stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +112,8 @@ void AppAudio::setVolume(uint8_t vol_percent) {
|
||||
volume_percent_ = vol_percent;
|
||||
Serial.printf("[APP_AUDIO] Volume: %u%%\n", volume_percent_);
|
||||
|
||||
// TODO: Update hardware volume via AudioManager
|
||||
if (context_.audio) {
|
||||
// context_.audio->setVolume(vol_percent);
|
||||
context_.audio->setVolume(vol_percent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// app_calculator.cpp - Calculator Implementation
|
||||
#include "app/app_calculator.h"
|
||||
#include "core/str_utils.h"
|
||||
|
||||
AppCalculator g_app_calculator;
|
||||
|
||||
@@ -39,7 +40,7 @@ void AppCalculator::onAction(const AppAction& action) {
|
||||
|
||||
void AppCalculator::reset() {
|
||||
result_ = 0.0;
|
||||
strncpy(display_, "0", sizeof(display_) - 1);
|
||||
core::copyText(display_, sizeof(display_), "0");
|
||||
history_[0] = '\0';
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ void AppFlashlight::onAction(const AppAction& action) {
|
||||
} else if (strcmp(action.name, "toggle") == 0) {
|
||||
toggle();
|
||||
} else if (strcmp(action.name, "intensity") == 0) {
|
||||
int percent = atoi(action.payload);
|
||||
int percent = static_cast<int>(strtol(action.payload, nullptr, 10));
|
||||
setIntensity(constrain(percent, 0, 100));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
#include "app/app_registry.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/str_utils.h"
|
||||
#include "storage_manager.h"
|
||||
|
||||
namespace {
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,476 +0,0 @@
|
||||
// audio_manager.cpp - audio playback over I2S.
|
||||
#include "audio_manager.h"
|
||||
|
||||
#include <AudioFileSource.h>
|
||||
#include <AudioFileSourceFS.h>
|
||||
#include <AudioFileSourcePROGMEM.h>
|
||||
#include <AudioGenerator.h>
|
||||
#include <AudioGeneratorMP3.h>
|
||||
#include <AudioGeneratorRTTTL.h>
|
||||
#include <AudioGeneratorWAV.h>
|
||||
#include <AudioOutputI2S.h>
|
||||
#include <AudioOutputMixer.h>
|
||||
#include <FS.h>
|
||||
#include <LittleFS.h>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<SD_MMC.h>)
|
||||
#include <SD_MMC.h>
|
||||
#define ZACUS_HAS_SD_AUDIO 1
|
||||
#else
|
||||
#define ZACUS_HAS_SD_AUDIO 0
|
||||
#endif
|
||||
|
||||
#include "ui_freenove_config.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint8_t kVolumeMax = 21;
|
||||
constexpr char kDiagnosticRtttl[] PROGMEM = "zacus:d=4,o=5,b=196:c,8e,8g,2c6";
|
||||
|
||||
struct AudioPinProfile {
|
||||
int bck;
|
||||
int ws;
|
||||
int dout;
|
||||
const char* label;
|
||||
};
|
||||
|
||||
constexpr AudioPinProfile kAudioPinProfiles[] = {
|
||||
{FREENOVE_I2S_BCK, FREENOVE_I2S_WS, FREENOVE_I2S_DOUT, "sketch19"},
|
||||
{FREENOVE_I2S_WS, FREENOVE_I2S_BCK, FREENOVE_I2S_DOUT, "swap_bck_ws"},
|
||||
{FREENOVE_I2S_BCK, FREENOVE_I2S_WS, 2, "dout2_alt"},
|
||||
};
|
||||
|
||||
constexpr uint8_t kAudioPinProfileCount = static_cast<uint8_t>(sizeof(kAudioPinProfiles) / sizeof(kAudioPinProfiles[0]));
|
||||
|
||||
float volumeToGain(uint8_t volume) {
|
||||
if (volume > kVolumeMax) {
|
||||
volume = kVolumeMax;
|
||||
}
|
||||
return static_cast<float>(volume) / static_cast<float>(kVolumeMax);
|
||||
}
|
||||
|
||||
bool endsWithIgnoreCase(const char* value, const char* suffix) {
|
||||
if (value == nullptr || suffix == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const size_t value_len = std::strlen(value);
|
||||
const size_t suffix_len = std::strlen(suffix);
|
||||
if (suffix_len == 0U || value_len < suffix_len) {
|
||||
return false;
|
||||
}
|
||||
const char* tail = value + (value_len - suffix_len);
|
||||
for (size_t index = 0; index < suffix_len; ++index) {
|
||||
const int lhs = std::tolower(static_cast<unsigned char>(tail[index]));
|
||||
const int rhs = std::tolower(static_cast<unsigned char>(suffix[index]));
|
||||
if (lhs != rhs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioManager::AudioManager() = default;
|
||||
|
||||
AudioManager::~AudioManager() {
|
||||
stop();
|
||||
delete mixer_;
|
||||
mixer_ = nullptr;
|
||||
delete output_;
|
||||
output_ = nullptr;
|
||||
}
|
||||
|
||||
bool AudioManager::begin() {
|
||||
return ensureOutput();
|
||||
}
|
||||
|
||||
bool AudioManager::playOnChannel(uint8_t channel_index,
|
||||
AudioFileSource* source,
|
||||
AudioGenerator* decoder,
|
||||
const char* track) {
|
||||
if (channel_index >= 2U || source == nullptr || decoder == nullptr || track == nullptr || track[0] == '\0') {
|
||||
delete decoder;
|
||||
delete source;
|
||||
return false;
|
||||
}
|
||||
if (!ensureOutput() || channels_[channel_index].stub == nullptr) {
|
||||
delete decoder;
|
||||
delete source;
|
||||
return false;
|
||||
}
|
||||
|
||||
stopChannel(channel_index);
|
||||
channels_[channel_index].source = source;
|
||||
channels_[channel_index].decoder = decoder;
|
||||
channels_[channel_index].track = track;
|
||||
channels_[channel_index].stub->SetGain(0.0f);
|
||||
|
||||
if (!channels_[channel_index].decoder->begin(channels_[channel_index].source, channels_[channel_index].stub)) {
|
||||
Serial.printf("[AUDIO] decoder begin failed: %s\n", track);
|
||||
stopChannel(channel_index);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t AudioManager::selectTargetChannel() const {
|
||||
if (!anyChannelRunning()) {
|
||||
return 0U;
|
||||
}
|
||||
return (active_channel_ == 0U) ? 1U : 0U;
|
||||
}
|
||||
|
||||
void AudioManager::startCrossfade(uint8_t from_channel, uint8_t to_channel) {
|
||||
crossfade_active_ = true;
|
||||
crossfade_from_ = from_channel;
|
||||
crossfade_to_ = to_channel;
|
||||
crossfade_started_ms_ = millis();
|
||||
active_channel_ = to_channel;
|
||||
applyChannelGains();
|
||||
}
|
||||
|
||||
void AudioManager::stopCrossfade() {
|
||||
crossfade_active_ = false;
|
||||
}
|
||||
|
||||
void AudioManager::applyChannelGains() {
|
||||
if (channels_[0].stub == nullptr || channels_[1].stub == nullptr) {
|
||||
return;
|
||||
}
|
||||
const float base_gain = volumeToGain(volume_);
|
||||
|
||||
if (crossfade_active_) {
|
||||
const uint32_t elapsed_ms = millis() - crossfade_started_ms_;
|
||||
uint16_t progress_per_mille = 1000U;
|
||||
if (crossfade_duration_ms_ > 0U && elapsed_ms < crossfade_duration_ms_) {
|
||||
progress_per_mille = static_cast<uint16_t>((elapsed_ms * 1000U) / crossfade_duration_ms_);
|
||||
}
|
||||
if (progress_per_mille > 1000U) {
|
||||
progress_per_mille = 1000U;
|
||||
}
|
||||
const float to_gain = base_gain * (static_cast<float>(progress_per_mille) / 1000.0f);
|
||||
const float from_gain = base_gain - to_gain;
|
||||
channels_[crossfade_from_].stub->SetGain(from_gain);
|
||||
channels_[crossfade_to_].stub->SetGain(to_gain);
|
||||
if (progress_per_mille >= 1000U) {
|
||||
stopChannel(crossfade_from_);
|
||||
stopCrossfade();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint8_t index = 0U; index < 2U; ++index) {
|
||||
if (channels_[index].decoder == nullptr) {
|
||||
channels_[index].stub->SetGain(0.0f);
|
||||
continue;
|
||||
}
|
||||
channels_[index].stub->SetGain((index == active_channel_) ? base_gain : 0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioManager::play(const char* filename) {
|
||||
if (filename == nullptr || filename[0] == '\0') {
|
||||
Serial.println("[AUDIO] empty filename");
|
||||
return false;
|
||||
}
|
||||
if (!ensureOutput()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String fixed_path = filename;
|
||||
bool use_sd_mmc = false;
|
||||
if (fixed_path.startsWith("/littlefs/")) {
|
||||
fixed_path = fixed_path.substring(9);
|
||||
if (!fixed_path.startsWith("/")) {
|
||||
fixed_path = "/" + fixed_path;
|
||||
}
|
||||
}
|
||||
if (fixed_path.startsWith("/sd/")) {
|
||||
fixed_path = fixed_path.substring(3);
|
||||
if (!fixed_path.startsWith("/")) {
|
||||
fixed_path = "/" + fixed_path;
|
||||
}
|
||||
use_sd_mmc = true;
|
||||
}
|
||||
|
||||
fs::FS* file_system = &LittleFS;
|
||||
const char* storage_label = "LittleFS";
|
||||
if (use_sd_mmc) {
|
||||
#if ZACUS_HAS_SD_AUDIO
|
||||
if (!SD_MMC.begin("/sdcard", true)) {
|
||||
Serial.println("[AUDIO] SD_MMC unavailable for audio path");
|
||||
return false;
|
||||
}
|
||||
file_system = &SD_MMC;
|
||||
storage_label = "SD_MMC";
|
||||
#else
|
||||
Serial.println("[AUDIO] SD audio path requested but SD_MMC support is unavailable");
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (file_system == nullptr || !file_system->exists(fixed_path.c_str())) {
|
||||
Serial.printf("[AUDIO] missing file: %s (%s)\n", fixed_path.c_str(), storage_label);
|
||||
return false;
|
||||
}
|
||||
File metadata = file_system->open(fixed_path.c_str(), "r");
|
||||
if (!metadata) {
|
||||
Serial.printf("[AUDIO] failed to open file metadata: %s (%s)\n", fixed_path.c_str(), storage_label);
|
||||
return false;
|
||||
}
|
||||
const size_t file_size = static_cast<size_t>(metadata.size());
|
||||
metadata.close();
|
||||
if (file_size == 0U) {
|
||||
Serial.printf("[AUDIO] empty audio file: %s\n", fixed_path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool had_running = anyChannelRunning();
|
||||
const uint8_t previous_active = active_channel_;
|
||||
const uint8_t target_channel = selectTargetChannel();
|
||||
const bool is_wav = endsWithIgnoreCase(fixed_path.c_str(), ".wav");
|
||||
|
||||
// SECURITY FIX: Use std::make_unique to prevent memory leak if 2nd allocation fails
|
||||
auto source = std::make_unique<AudioFileSourceFS>(*file_system, fixed_path.c_str());
|
||||
auto decoder = is_wav ? std::make_unique<AudioGeneratorWAV>()
|
||||
: std::make_unique<AudioGeneratorMP3>();
|
||||
if (!playOnChannel(target_channel, source.get(), decoder.get(), fixed_path.c_str())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
using_diagnostic_tone_ = false;
|
||||
current_track_ = fixed_path.c_str();
|
||||
playing_ = true;
|
||||
if (had_running && channels_[previous_active].decoder != nullptr && previous_active != target_channel) {
|
||||
startCrossfade(previous_active, target_channel);
|
||||
} else {
|
||||
active_channel_ = target_channel;
|
||||
stopCrossfade();
|
||||
applyChannelGains();
|
||||
}
|
||||
Serial.printf("[AUDIO] playing (%s): %s (%s)\n", is_wav ? "wav" : "mp3", fixed_path.c_str(), storage_label);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioManager::playDiagnosticTone() {
|
||||
if (!ensureOutput()) {
|
||||
return false;
|
||||
}
|
||||
const bool had_running = anyChannelRunning();
|
||||
const uint8_t previous_active = active_channel_;
|
||||
const uint8_t target_channel = selectTargetChannel();
|
||||
|
||||
// SECURITY FIX: Use std::make_unique to prevent memory leak if 2nd allocation fails
|
||||
auto source = std::make_unique<AudioFileSourcePROGMEM>(kDiagnosticRtttl, std::strlen(kDiagnosticRtttl));
|
||||
auto decoder = std::make_unique<AudioGeneratorRTTTL>();
|
||||
if (!playOnChannel(target_channel, source.get(), decoder.get(), "builtin:rtttl")) {
|
||||
Serial.println("[AUDIO] diagnostic tone begin failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
using_diagnostic_tone_ = true;
|
||||
current_track_ = "builtin:rtttl";
|
||||
playing_ = true;
|
||||
if (had_running && channels_[previous_active].decoder != nullptr && previous_active != target_channel) {
|
||||
startCrossfade(previous_active, target_channel);
|
||||
} else {
|
||||
active_channel_ = target_channel;
|
||||
stopCrossfade();
|
||||
applyChannelGains();
|
||||
}
|
||||
Serial.printf("[AUDIO] playing diagnostic tone (profile=%u:%s)\n",
|
||||
output_profile_,
|
||||
outputProfileLabel(output_profile_));
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioManager::stopChannel(uint8_t channel_index) {
|
||||
if (channel_index >= 2U) {
|
||||
return;
|
||||
}
|
||||
AudioChannel& channel = channels_[channel_index];
|
||||
if (channel.decoder != nullptr) {
|
||||
channel.decoder->stop();
|
||||
delete channel.decoder;
|
||||
channel.decoder = nullptr;
|
||||
}
|
||||
if (channel.source != nullptr) {
|
||||
channel.source->close();
|
||||
delete channel.source;
|
||||
channel.source = nullptr;
|
||||
}
|
||||
channel.track.remove(0);
|
||||
if (channel.stub != nullptr) {
|
||||
channel.stub->SetGain(0.0f);
|
||||
}
|
||||
}
|
||||
|
||||
void AudioManager::stopAllChannels() {
|
||||
stopChannel(0U);
|
||||
stopChannel(1U);
|
||||
}
|
||||
|
||||
void AudioManager::stop() {
|
||||
stopCrossfade();
|
||||
stopAllChannels();
|
||||
playing_ = false;
|
||||
using_diagnostic_tone_ = false;
|
||||
current_track_.remove(0);
|
||||
}
|
||||
|
||||
bool AudioManager::anyChannelRunning() const {
|
||||
return channels_[0].decoder != nullptr || channels_[1].decoder != nullptr;
|
||||
}
|
||||
|
||||
void AudioManager::update() {
|
||||
if (!playing_) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyChannelGains();
|
||||
String finished_track;
|
||||
bool active_finished = false;
|
||||
|
||||
for (uint8_t index = 0U; index < 2U; ++index) {
|
||||
AudioChannel& channel = channels_[index];
|
||||
if (channel.decoder == nullptr) {
|
||||
continue;
|
||||
}
|
||||
if (!channel.decoder->isRunning() || !channel.decoder->loop()) {
|
||||
const bool was_active = (index == active_channel_);
|
||||
const String ended_track = channel.track;
|
||||
stopChannel(index);
|
||||
if (was_active) {
|
||||
active_finished = true;
|
||||
finished_track = ended_track;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!crossfade_active_) {
|
||||
applyChannelGains();
|
||||
}
|
||||
playing_ = anyChannelRunning();
|
||||
|
||||
if (playing_ && channels_[active_channel_].decoder != nullptr) {
|
||||
current_track_ = channels_[active_channel_].track;
|
||||
}
|
||||
|
||||
if (active_finished) {
|
||||
if (!playing_) {
|
||||
current_track_.remove(0);
|
||||
}
|
||||
finishPlaybackAndNotify(finished_track.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
bool AudioManager::isPlaying() const {
|
||||
return playing_;
|
||||
}
|
||||
|
||||
void AudioManager::setVolume(uint8_t volume) {
|
||||
if (volume > kVolumeMax) {
|
||||
volume = kVolumeMax;
|
||||
}
|
||||
volume_ = volume;
|
||||
applyChannelGains();
|
||||
}
|
||||
|
||||
uint8_t AudioManager::volume() const {
|
||||
return volume_;
|
||||
}
|
||||
|
||||
const char* AudioManager::currentTrack() const {
|
||||
return current_track_.c_str();
|
||||
}
|
||||
|
||||
bool AudioManager::setOutputProfile(uint8_t profile_index) {
|
||||
if (profile_index >= kAudioPinProfileCount) {
|
||||
return false;
|
||||
}
|
||||
output_profile_ = profile_index;
|
||||
applyOutputProfile();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t AudioManager::outputProfile() const {
|
||||
return output_profile_;
|
||||
}
|
||||
|
||||
uint8_t AudioManager::outputProfileCount() const {
|
||||
return kAudioPinProfileCount;
|
||||
}
|
||||
|
||||
const char* AudioManager::outputProfileLabel(uint8_t profile_index) const {
|
||||
if (profile_index >= kAudioPinProfileCount) {
|
||||
return "invalid";
|
||||
}
|
||||
return kAudioPinProfiles[profile_index].label;
|
||||
}
|
||||
|
||||
void AudioManager::setAudioDoneCallback(AudioDoneCallback cb, void* ctx) {
|
||||
done_cb_ = cb;
|
||||
done_ctx_ = ctx;
|
||||
}
|
||||
|
||||
bool AudioManager::ensureOutput() {
|
||||
if (output_ == nullptr) {
|
||||
output_ = new AudioOutputI2S(0, AudioOutputI2S::EXTERNAL_I2S);
|
||||
}
|
||||
if (output_ == nullptr) {
|
||||
Serial.println("[AUDIO] failed to allocate I2S output");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mixer_ == nullptr) {
|
||||
mixer_ = new AudioOutputMixer(32, output_);
|
||||
}
|
||||
if (mixer_ == nullptr) {
|
||||
Serial.println("[AUDIO] failed to allocate mixer");
|
||||
return false;
|
||||
}
|
||||
if (channels_[0].stub == nullptr) {
|
||||
channels_[0].stub = mixer_->NewInput();
|
||||
}
|
||||
if (channels_[1].stub == nullptr) {
|
||||
channels_[1].stub = mixer_->NewInput();
|
||||
}
|
||||
if (channels_[0].stub == nullptr || channels_[1].stub == nullptr) {
|
||||
Serial.println("[AUDIO] failed to allocate mixer stubs");
|
||||
return false;
|
||||
}
|
||||
|
||||
output_->SetOutputModeMono(true);
|
||||
output_->SetGain(1.0f);
|
||||
applyOutputProfile();
|
||||
applyChannelGains();
|
||||
return true;
|
||||
}
|
||||
|
||||
void AudioManager::applyOutputProfile() {
|
||||
if (output_ == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (output_profile_ >= kAudioPinProfileCount) {
|
||||
output_profile_ = 0U;
|
||||
}
|
||||
const AudioPinProfile& profile = kAudioPinProfiles[output_profile_];
|
||||
output_->SetPinout(profile.bck, profile.ws, profile.dout);
|
||||
Serial.printf("[AUDIO] ready (profile=%u:%s bck=%d ws=%d dout=%d mode=mono)\n",
|
||||
output_profile_,
|
||||
profile.label,
|
||||
profile.bck,
|
||||
profile.ws,
|
||||
profile.dout);
|
||||
}
|
||||
|
||||
void AudioManager::finishPlaybackAndNotify(const char* track) {
|
||||
if (done_cb_ != nullptr) {
|
||||
const char* reported_track = (track != nullptr && track[0] != '\0') ? track : current_track_.c_str();
|
||||
done_cb_(reported_track, done_ctx_);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <cstdio>
|
||||
#include <esp_random.h>
|
||||
|
||||
#include "core/str_utils.h"
|
||||
|
||||
namespace AuthService {
|
||||
|
||||
namespace {
|
||||
@@ -204,8 +206,7 @@ AuthStatus validateBearerToken(const char* auth_header) {
|
||||
|
||||
// Copy to buffer and trim whitespace
|
||||
char token_copy[kTokenBufferSize];
|
||||
std::strncpy(token_copy, provided_token, sizeof(token_copy) - 1);
|
||||
token_copy[sizeof(token_copy) - 1] = '\0';
|
||||
core::copyText(token_copy, sizeof(token_copy), provided_token);
|
||||
|
||||
// Trim trailing whitespace (newlines, spaces, etc)
|
||||
size_t len = std::strlen(token_copy);
|
||||
@@ -230,8 +231,7 @@ bool getCurrentToken(char* out_token_buffer, size_t buffer_size) {
|
||||
if (!out_token_buffer || buffer_size < kTokenBufferSize || !g_initialized) {
|
||||
return false;
|
||||
}
|
||||
std::strncpy(out_token_buffer, g_current_token, buffer_size - 1);
|
||||
out_token_buffer[buffer_size - 1] = '\0';
|
||||
core::copyText(out_token_buffer, buffer_size, g_current_token);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -251,12 +251,10 @@ bool rotateToken(char* out_new_token_buffer, size_t buffer_size) {
|
||||
}
|
||||
|
||||
// Update global state
|
||||
std::strncpy(g_current_token, new_token, sizeof(g_current_token) - 1);
|
||||
g_current_token[sizeof(g_current_token) - 1] = '\0';
|
||||
core::copyText(g_current_token, sizeof(g_current_token), new_token);
|
||||
|
||||
// Return new token to caller
|
||||
std::strncpy(out_new_token_buffer, new_token, buffer_size - 1);
|
||||
out_new_token_buffer[buffer_size - 1] = '\0';
|
||||
core::copyText(out_new_token_buffer, buffer_size, new_token);
|
||||
|
||||
Serial.printf("%s rotated - new token=%s\n", kLogTag, new_token);
|
||||
return true;
|
||||
@@ -280,8 +278,7 @@ bool reset() {
|
||||
}
|
||||
|
||||
// Update global
|
||||
std::strncpy(g_current_token, new_token, sizeof(g_current_token) - 1);
|
||||
g_current_token[sizeof(g_current_token) - 1] = '\0';
|
||||
core::copyText(g_current_token, sizeof(g_current_token), new_token);
|
||||
|
||||
Serial.printf("%s reset - new token=%s\n", kLogTag, new_token);
|
||||
return true;
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// button_manager.cpp - button scan + long press detection.
|
||||
#include "button_manager.h"
|
||||
|
||||
#include "ui_freenove_config.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kDebounceMs = 30U;
|
||||
constexpr uint32_t kLongPressMs = FREENOVE_BTN_LONG_PRESS_MS;
|
||||
constexpr int kNoAnalogButtonMv = 2800;
|
||||
|
||||
const int kDigitalButtonPins[4] = {
|
||||
FREENOVE_BTN_1,
|
||||
FREENOVE_BTN_2,
|
||||
FREENOVE_BTN_3,
|
||||
FREENOVE_BTN_4,
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ButtonManager::begin() {
|
||||
#if FREENOVE_BTN_ANALOG_PIN >= 0
|
||||
analog_mode_ = true;
|
||||
analogReadResolution(12);
|
||||
analogSetAttenuation(ADC_11db);
|
||||
pinMode(FREENOVE_BTN_ANALOG_PIN, INPUT);
|
||||
analog_key_ = 0U;
|
||||
analog_raw_key_ = 0U;
|
||||
analog_pressed_at_ms_ = 0U;
|
||||
analog_raw_changed_ms_ = millis();
|
||||
last_analog_mv_ = kNoAnalogButtonMv;
|
||||
Serial.printf("[BTN] analog ladder mode on GPIO %d\n", FREENOVE_BTN_ANALOG_PIN);
|
||||
#else
|
||||
analog_mode_ = false;
|
||||
last_analog_mv_ = -1;
|
||||
for (uint8_t index = 0; index < 4; ++index) {
|
||||
if (kDigitalButtonPins[index] >= 0) {
|
||||
pinMode(kDigitalButtonPins[index], INPUT_PULLUP);
|
||||
}
|
||||
}
|
||||
Serial.println("[BTN] digital mode");
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ButtonManager::pollEvent(ButtonEvent* out_event) {
|
||||
if (out_event == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (analog_mode_) {
|
||||
return pollAnalog(out_event);
|
||||
}
|
||||
return pollDigital(out_event);
|
||||
}
|
||||
|
||||
bool ButtonManager::isPressed(uint8_t key) const {
|
||||
if (key < 1U || key > 5U) {
|
||||
return false;
|
||||
}
|
||||
if (analog_mode_) {
|
||||
return analog_key_ == key;
|
||||
}
|
||||
if (key > 4U) {
|
||||
return false;
|
||||
}
|
||||
return digital_pressed_[key - 1U];
|
||||
}
|
||||
|
||||
uint8_t ButtonManager::currentKey() const {
|
||||
if (analog_mode_) {
|
||||
return analog_key_;
|
||||
}
|
||||
for (uint8_t index = 0; index < 4; ++index) {
|
||||
if (digital_pressed_[index]) {
|
||||
return static_cast<uint8_t>(index + 1U);
|
||||
}
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
int ButtonManager::lastAnalogMilliVolts() const {
|
||||
return last_analog_mv_;
|
||||
}
|
||||
|
||||
uint8_t ButtonManager::decodeAnalogKey(int millivolts) const {
|
||||
if (millivolts < 0) {
|
||||
return 0U;
|
||||
}
|
||||
|
||||
const int no_button_floor_mv = kNoAnalogButtonMv - threshold_range_mv_;
|
||||
if (millivolts >= no_button_floor_mv) {
|
||||
return 0U;
|
||||
}
|
||||
|
||||
// Prefer midpoint buckets between nominal ladder voltages.
|
||||
const int split_12 = (voltage_thresholds_[1] + voltage_thresholds_[2]) / 2;
|
||||
const int split_23 = (voltage_thresholds_[2] + voltage_thresholds_[3]) / 2;
|
||||
const int split_34 = (voltage_thresholds_[3] + voltage_thresholds_[4]) / 2;
|
||||
const int split_45 = (voltage_thresholds_[4] + voltage_thresholds_[5]) / 2;
|
||||
const int split_5n = (voltage_thresholds_[5] + no_button_floor_mv) / 2;
|
||||
if (millivolts <= split_12) {
|
||||
return 1U;
|
||||
}
|
||||
if (millivolts <= split_23) {
|
||||
return 2U;
|
||||
}
|
||||
if (millivolts <= split_34) {
|
||||
return 3U;
|
||||
}
|
||||
if (millivolts <= split_45) {
|
||||
return 4U;
|
||||
}
|
||||
if (millivolts <= split_5n) {
|
||||
return 5U;
|
||||
}
|
||||
|
||||
// Fallback nearest-threshold match with wider tolerance for board variance.
|
||||
int best_key = 1;
|
||||
int best_delta = millivolts - voltage_thresholds_[1];
|
||||
if (best_delta < 0) {
|
||||
best_delta = -best_delta;
|
||||
}
|
||||
for (int index = 2; index <= 5; ++index) {
|
||||
int delta = millivolts - voltage_thresholds_[index];
|
||||
if (delta < 0) {
|
||||
delta = -delta;
|
||||
}
|
||||
if (delta < best_delta) {
|
||||
best_delta = delta;
|
||||
best_key = index;
|
||||
}
|
||||
}
|
||||
if (best_delta <= (threshold_range_mv_ * 7)) {
|
||||
return static_cast<uint8_t>(best_key);
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
bool ButtonManager::pollAnalog(ButtonEvent* out_event) {
|
||||
#if FREENOVE_BTN_ANALOG_PIN < 0
|
||||
(void)out_event;
|
||||
return false;
|
||||
#else
|
||||
const int analog_mv = analogReadMilliVolts(FREENOVE_BTN_ANALOG_PIN);
|
||||
last_analog_mv_ = analog_mv;
|
||||
const uint8_t raw_key = decodeAnalogKey(analog_mv);
|
||||
const uint32_t now_ms = millis();
|
||||
|
||||
if (raw_key != analog_raw_key_) {
|
||||
analog_raw_key_ = raw_key;
|
||||
analog_raw_changed_ms_ = now_ms;
|
||||
}
|
||||
if ((now_ms - analog_raw_changed_ms_) < kDebounceMs) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t stable_key = analog_raw_key_;
|
||||
if (stable_key == analog_key_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (analog_key_ == 0U && stable_key > 0U) {
|
||||
analog_key_ = stable_key;
|
||||
analog_pressed_at_ms_ = now_ms;
|
||||
return false;
|
||||
}
|
||||
if (analog_key_ == 0U) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t released_key = analog_key_;
|
||||
const uint32_t hold_ms = now_ms - analog_pressed_at_ms_;
|
||||
analog_key_ = stable_key;
|
||||
analog_pressed_at_ms_ = (stable_key > 0U) ? now_ms : 0U;
|
||||
|
||||
if (hold_ms < kDebounceMs) {
|
||||
return false;
|
||||
}
|
||||
out_event->key = released_key;
|
||||
out_event->long_press = hold_ms >= kLongPressMs;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool ButtonManager::pollDigital(ButtonEvent* out_event) {
|
||||
const uint32_t now_ms = millis();
|
||||
for (uint8_t index = 0; index < 4; ++index) {
|
||||
if (kDigitalButtonPins[index] < 0) {
|
||||
continue;
|
||||
}
|
||||
const bool pressed = (digitalRead(kDigitalButtonPins[index]) == LOW);
|
||||
if (pressed && !digital_pressed_[index]) {
|
||||
digital_pressed_[index] = true;
|
||||
digital_pressed_at_ms_[index] = now_ms;
|
||||
continue;
|
||||
}
|
||||
if (!pressed && digital_pressed_[index]) {
|
||||
const uint32_t hold_ms = now_ms - digital_pressed_at_ms_[index];
|
||||
digital_pressed_[index] = false;
|
||||
if (hold_ms < kDebounceMs) {
|
||||
continue;
|
||||
}
|
||||
out_event->key = static_cast<uint8_t>(index + 1U);
|
||||
out_event->long_press = hold_ms >= kLongPressMs;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <cstring>
|
||||
|
||||
#include "ui_freenove_config.h"
|
||||
#include "core/str_utils.h"
|
||||
|
||||
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_camera.h>) && FREENOVE_CAM_ENABLE
|
||||
#include <esp_camera.h>
|
||||
@@ -17,19 +18,14 @@
|
||||
#define ZACUS_HAS_CAMERA 0
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_heap_caps.h>)
|
||||
#include <esp_heap_caps.h>
|
||||
#define ZACUS_HAS_HEAP_CAPS 1
|
||||
#else
|
||||
#define ZACUS_HAS_HEAP_CAPS 0
|
||||
#endif
|
||||
|
||||
void copyText(char* out, size_t out_size, const char* text) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (text == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, text, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
namespace {
|
||||
|
||||
String normalizeDir(const char* dir) {
|
||||
if (dir == nullptr || dir[0] == '\0') {
|
||||
@@ -235,7 +231,7 @@ CameraManager::CameraManager() {
|
||||
|
||||
bool CameraManager::begin(const Config& config) {
|
||||
config_ = config;
|
||||
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
|
||||
core::copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
|
||||
if (config_.jpeg_quality < 4U) {
|
||||
config_.jpeg_quality = 4U;
|
||||
}
|
||||
@@ -257,8 +253,8 @@ bool CameraManager::begin(const Config& config) {
|
||||
snapshot_.jpeg_quality = config_.jpeg_quality;
|
||||
snapshot_.fb_count = config_.fb_count;
|
||||
snapshot_.xclk_hz = config_.xclk_hz;
|
||||
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
|
||||
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
|
||||
core::copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
|
||||
core::copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
|
||||
recorder_mode_ = false;
|
||||
recorder_frozen_ = false;
|
||||
recorder_frozen_fb_ = nullptr;
|
||||
@@ -273,8 +269,8 @@ bool CameraManager::begin(const Config& config) {
|
||||
|
||||
bool CameraManager::ensureSnapshotDir() {
|
||||
const String dir = normalizeDir(config_.snapshot_dir);
|
||||
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
|
||||
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
|
||||
core::copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
|
||||
core::copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
|
||||
if (LittleFS.exists(dir.c_str())) {
|
||||
return true;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
// camera_manager.cpp - camera lifecycle + JPEG snapshots.
|
||||
#include "camera_manager.h"
|
||||
|
||||
#include <FS.h>
|
||||
#include <LittleFS.h>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include "ui_freenove_config.h"
|
||||
|
||||
#if defined(ARDUINO_ARCH_ESP32) && __has_include(<esp_camera.h>) && FREENOVE_CAM_ENABLE
|
||||
#include <esp_camera.h>
|
||||
#define ZACUS_HAS_CAMERA 1
|
||||
#else
|
||||
#define ZACUS_HAS_CAMERA 0
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
void copyText(char* out, size_t out_size, const char* text) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (text == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, text, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
String normalizeDir(const char* dir) {
|
||||
if (dir == nullptr || dir[0] == '\0') {
|
||||
return String("/picture");
|
||||
}
|
||||
String normalized = dir;
|
||||
normalized.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
normalized = "/picture";
|
||||
}
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized.remove(normalized.length() - 1U);
|
||||
}
|
||||
if (normalized.isEmpty()) {
|
||||
normalized = "/picture";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
String sanitizeFileBasename(const char* filename_hint) {
|
||||
String base = (filename_hint != nullptr) ? filename_hint : "";
|
||||
base.trim();
|
||||
if (base.isEmpty()) {
|
||||
base = "story_";
|
||||
base += String(static_cast<unsigned long>(millis()));
|
||||
}
|
||||
|
||||
// Keep names portable across FS/web flows.
|
||||
for (size_t index = 0U; index < base.length(); ++index) {
|
||||
const char ch = base[index];
|
||||
const bool keep = std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-' || ch == '.';
|
||||
if (!keep) {
|
||||
base.setCharAt(index, '_');
|
||||
}
|
||||
}
|
||||
if (!base.endsWith(".jpg") && !base.endsWith(".jpeg")) {
|
||||
base += ".jpg";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
#if ZACUS_HAS_CAMERA
|
||||
framesize_t frameSizeFromText(const char* text) {
|
||||
if (text == nullptr || text[0] == '\0') {
|
||||
return FRAMESIZE_VGA;
|
||||
}
|
||||
char normalized[20] = {0};
|
||||
std::strncpy(normalized, text, sizeof(normalized) - 1U);
|
||||
for (size_t index = 0U; normalized[index] != '\0'; ++index) {
|
||||
normalized[index] = static_cast<char>(std::toupper(static_cast<unsigned char>(normalized[index])));
|
||||
}
|
||||
if (std::strcmp(normalized, "QQVGA") == 0) {
|
||||
return FRAMESIZE_QQVGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "HQVGA") == 0) {
|
||||
return FRAMESIZE_HQVGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "QVGA") == 0) {
|
||||
return FRAMESIZE_QVGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "CIF") == 0) {
|
||||
return FRAMESIZE_CIF;
|
||||
}
|
||||
if (std::strcmp(normalized, "VGA") == 0) {
|
||||
return FRAMESIZE_VGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "SVGA") == 0) {
|
||||
return FRAMESIZE_SVGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "XGA") == 0) {
|
||||
return FRAMESIZE_XGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "SXGA") == 0) {
|
||||
return FRAMESIZE_SXGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "UXGA") == 0) {
|
||||
return FRAMESIZE_UXGA;
|
||||
}
|
||||
if (std::strcmp(normalized, "HD") == 0) {
|
||||
return FRAMESIZE_HD;
|
||||
}
|
||||
if (std::strcmp(normalized, "FHD") == 0) {
|
||||
return FRAMESIZE_FHD;
|
||||
}
|
||||
return FRAMESIZE_VGA;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
CameraManager::CameraManager() {
|
||||
snapshot_.supported = (ZACUS_HAS_CAMERA != 0);
|
||||
}
|
||||
|
||||
bool CameraManager::begin(const Config& config) {
|
||||
config_ = config;
|
||||
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), normalizeDir(config.snapshot_dir).c_str());
|
||||
if (config_.jpeg_quality < 4U) {
|
||||
config_.jpeg_quality = 4U;
|
||||
}
|
||||
if (config_.jpeg_quality > 63U) {
|
||||
config_.jpeg_quality = 63U;
|
||||
}
|
||||
if (config_.fb_count == 0U) {
|
||||
config_.fb_count = 1U;
|
||||
}
|
||||
if (config_.fb_count > 2U) {
|
||||
config_.fb_count = 2U;
|
||||
}
|
||||
if (config_.xclk_hz < 1000000UL) {
|
||||
config_.xclk_hz = 10000000UL;
|
||||
}
|
||||
|
||||
snapshot_.supported = (ZACUS_HAS_CAMERA != 0);
|
||||
snapshot_.enabled = false;
|
||||
snapshot_.initialized = false;
|
||||
snapshot_.last_snapshot_ok = false;
|
||||
snapshot_.capture_count = 0U;
|
||||
snapshot_.fail_count = 0U;
|
||||
snapshot_.last_capture_ms = 0U;
|
||||
snapshot_.width = 0U;
|
||||
snapshot_.height = 0U;
|
||||
snapshot_.jpeg_quality = config_.jpeg_quality;
|
||||
snapshot_.fb_count = config_.fb_count;
|
||||
snapshot_.xclk_hz = config_.xclk_hz;
|
||||
copyText(snapshot_.frame_size, sizeof(snapshot_.frame_size), config_.frame_size);
|
||||
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), config_.snapshot_dir);
|
||||
snapshot_.last_file[0] = '\0';
|
||||
snapshot_.last_error[0] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CameraManager::start() {
|
||||
snapshot_.enabled = false;
|
||||
#if ZACUS_HAS_CAMERA
|
||||
if (snapshot_.initialized) {
|
||||
snapshot_.enabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
camera_config_t cfg = {};
|
||||
cfg.ledc_channel = LEDC_CHANNEL_0;
|
||||
cfg.ledc_timer = LEDC_TIMER_0;
|
||||
cfg.pin_d0 = FREENOVE_CAM_Y2;
|
||||
cfg.pin_d1 = FREENOVE_CAM_Y3;
|
||||
cfg.pin_d2 = FREENOVE_CAM_Y4;
|
||||
cfg.pin_d3 = FREENOVE_CAM_Y5;
|
||||
cfg.pin_d4 = FREENOVE_CAM_Y6;
|
||||
cfg.pin_d5 = FREENOVE_CAM_Y7;
|
||||
cfg.pin_d6 = FREENOVE_CAM_Y8;
|
||||
cfg.pin_d7 = FREENOVE_CAM_Y9;
|
||||
cfg.pin_xclk = FREENOVE_CAM_XCLK;
|
||||
cfg.pin_pclk = FREENOVE_CAM_PCLK;
|
||||
cfg.pin_vsync = FREENOVE_CAM_VSYNC;
|
||||
cfg.pin_href = FREENOVE_CAM_HREF;
|
||||
cfg.pin_sscb_sda = FREENOVE_CAM_SIOD;
|
||||
cfg.pin_sscb_scl = FREENOVE_CAM_SIOC;
|
||||
cfg.pin_pwdn = FREENOVE_CAM_PWDN;
|
||||
cfg.pin_reset = FREENOVE_CAM_RESET;
|
||||
cfg.xclk_freq_hz = config_.xclk_hz;
|
||||
cfg.pixel_format = PIXFORMAT_JPEG;
|
||||
cfg.frame_size = frameSizeFromText(config_.frame_size);
|
||||
cfg.jpeg_quality = config_.jpeg_quality;
|
||||
cfg.fb_count = config_.fb_count;
|
||||
#if defined(CAMERA_GRAB_LATEST)
|
||||
cfg.grab_mode = CAMERA_GRAB_LATEST;
|
||||
#endif
|
||||
#if defined(CAMERA_FB_IN_PSRAM)
|
||||
cfg.fb_location = CAMERA_FB_IN_PSRAM;
|
||||
#endif
|
||||
|
||||
const esp_err_t status = esp_camera_init(&cfg);
|
||||
if (status != ESP_OK) {
|
||||
setLastError("camera_init_failed");
|
||||
Serial.printf("[CAM] init failed err=0x%x\n", static_cast<unsigned int>(status));
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot_.initialized = true;
|
||||
snapshot_.enabled = true;
|
||||
clearLastError();
|
||||
Serial.printf("[CAM] ready frame=%s quality=%u fb=%u xclk=%lu\n",
|
||||
snapshot_.frame_size,
|
||||
static_cast<unsigned int>(snapshot_.jpeg_quality),
|
||||
static_cast<unsigned int>(snapshot_.fb_count),
|
||||
static_cast<unsigned long>(snapshot_.xclk_hz));
|
||||
return true;
|
||||
#else
|
||||
setLastError("camera_not_supported");
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void CameraManager::stop() {
|
||||
#if ZACUS_HAS_CAMERA
|
||||
if (snapshot_.initialized) {
|
||||
esp_camera_deinit();
|
||||
}
|
||||
#endif
|
||||
snapshot_.initialized = false;
|
||||
snapshot_.enabled = false;
|
||||
}
|
||||
|
||||
bool CameraManager::isEnabled() const {
|
||||
return snapshot_.enabled;
|
||||
}
|
||||
|
||||
bool CameraManager::ensureSnapshotDir() {
|
||||
String dir = normalizeDir(config_.snapshot_dir);
|
||||
copyText(config_.snapshot_dir, sizeof(config_.snapshot_dir), dir.c_str());
|
||||
copyText(snapshot_.snapshot_dir, sizeof(snapshot_.snapshot_dir), dir.c_str());
|
||||
if (LittleFS.exists(dir.c_str())) {
|
||||
return true;
|
||||
}
|
||||
if (LittleFS.mkdir(dir.c_str())) {
|
||||
return true;
|
||||
}
|
||||
setLastError("snapshot_dir_error");
|
||||
return false;
|
||||
}
|
||||
|
||||
String CameraManager::buildSnapshotPath(const char* filename_hint) const {
|
||||
String dir = normalizeDir(config_.snapshot_dir);
|
||||
String file = sanitizeFileBasename(filename_hint);
|
||||
if (file.startsWith("/")) {
|
||||
return file;
|
||||
}
|
||||
return dir + "/" + file;
|
||||
}
|
||||
|
||||
bool CameraManager::snapshotToFile(const char* filename_hint, String* out_path) {
|
||||
if (out_path != nullptr) {
|
||||
out_path->remove(0);
|
||||
}
|
||||
if (!start()) {
|
||||
++snapshot_.fail_count;
|
||||
return false;
|
||||
}
|
||||
if (!ensureSnapshotDir()) {
|
||||
++snapshot_.fail_count;
|
||||
return false;
|
||||
}
|
||||
|
||||
#if ZACUS_HAS_CAMERA
|
||||
camera_fb_t* frame = esp_camera_fb_get();
|
||||
if (frame == nullptr) {
|
||||
++snapshot_.fail_count;
|
||||
setLastError("camera_capture_failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
const String path = buildSnapshotPath(filename_hint);
|
||||
File file = LittleFS.open(path.c_str(), "w");
|
||||
if (!file) {
|
||||
esp_camera_fb_return(frame);
|
||||
++snapshot_.fail_count;
|
||||
setLastError("snapshot_write_failed");
|
||||
return false;
|
||||
}
|
||||
const size_t written = file.write(frame->buf, frame->len);
|
||||
file.close();
|
||||
if (written != frame->len) {
|
||||
esp_camera_fb_return(frame);
|
||||
++snapshot_.fail_count;
|
||||
setLastError("snapshot_write_incomplete");
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot_.last_snapshot_ok = true;
|
||||
snapshot_.last_capture_ms = millis();
|
||||
++snapshot_.capture_count;
|
||||
snapshot_.width = frame->width;
|
||||
snapshot_.height = frame->height;
|
||||
copyText(snapshot_.last_file, sizeof(snapshot_.last_file), path.c_str());
|
||||
clearLastError();
|
||||
if (out_path != nullptr) {
|
||||
*out_path = path;
|
||||
}
|
||||
esp_camera_fb_return(frame);
|
||||
return true;
|
||||
#else
|
||||
(void)filename_hint;
|
||||
++snapshot_.fail_count;
|
||||
setLastError("camera_not_supported");
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
CameraManager::Snapshot CameraManager::snapshot() const {
|
||||
return snapshot_;
|
||||
}
|
||||
|
||||
void CameraManager::setLastError(const char* message) {
|
||||
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
|
||||
}
|
||||
|
||||
void CameraManager::clearLastError() {
|
||||
snapshot_.last_error[0] = '\0';
|
||||
}
|
||||
@@ -1,258 +1,193 @@
|
||||
// mutex_manager.cpp - Thread-safe access protection implementation
|
||||
// mutex_manager.cpp - MutexManager singleton implementation.
|
||||
#include "core/mutex_manager.h"
|
||||
|
||||
namespace {
|
||||
// ============================================================================
|
||||
// Singleton
|
||||
// ============================================================================
|
||||
|
||||
// Internal mutex handles
|
||||
SemaphoreHandle_t g_audio_mutex = nullptr;
|
||||
SemaphoreHandle_t g_scenario_mutex = nullptr;
|
||||
MutexManager& MutexManager::instance() {
|
||||
static MutexManager inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// Statistics for debugging race conditions
|
||||
uint32_t g_audio_lock_count = 0;
|
||||
uint32_t g_scenario_lock_count = 0;
|
||||
uint32_t g_audio_timeout_count = 0;
|
||||
uint32_t g_scenario_timeout_count = 0;
|
||||
uint32_t g_max_audio_wait_us = 0;
|
||||
uint32_t g_max_scenario_wait_us = 0;
|
||||
// ============================================================================
|
||||
// Lifecycle
|
||||
// ============================================================================
|
||||
|
||||
// Track mutex holder task for deadlock detection
|
||||
TaskHandle_t g_audio_mutex_owner = nullptr;
|
||||
TaskHandle_t g_scenario_mutex_owner = nullptr;
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace MutexManager {
|
||||
|
||||
bool init() {
|
||||
if (g_audio_mutex != nullptr || g_scenario_mutex != nullptr) {
|
||||
bool MutexManager::doInit() {
|
||||
if (audio_mutex_ != nullptr || scenario_mutex_ != nullptr) {
|
||||
Serial.println("[MUTEX] WARNING: Already initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
g_audio_mutex = xSemaphoreCreateMutex();
|
||||
if (g_audio_mutex == nullptr) {
|
||||
audio_mutex_ = xSemaphoreCreateMutex();
|
||||
if (audio_mutex_ == nullptr) {
|
||||
Serial.println("[MUTEX] ERROR: Failed to create audio mutex");
|
||||
return false;
|
||||
}
|
||||
|
||||
g_scenario_mutex = xSemaphoreCreateMutex();
|
||||
if (g_scenario_mutex == nullptr) {
|
||||
scenario_mutex_ = xSemaphoreCreateMutex();
|
||||
if (scenario_mutex_ == nullptr) {
|
||||
Serial.println("[MUTEX] ERROR: Failed to create scenario mutex");
|
||||
vSemaphoreDelete(g_audio_mutex);
|
||||
g_audio_mutex = nullptr;
|
||||
vSemaphoreDelete(audio_mutex_);
|
||||
audio_mutex_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset stats
|
||||
g_audio_lock_count = 0;
|
||||
g_scenario_lock_count = 0;
|
||||
g_audio_timeout_count = 0;
|
||||
g_scenario_timeout_count = 0;
|
||||
g_max_audio_wait_us = 0;
|
||||
g_max_scenario_wait_us = 0;
|
||||
g_audio_mutex_owner = nullptr;
|
||||
g_scenario_mutex_owner = nullptr;
|
||||
audio_lock_count_.store(0U);
|
||||
scenario_lock_count_.store(0U);
|
||||
audio_timeout_count_.store(0U);
|
||||
scenario_timeout_count_.store(0U);
|
||||
max_audio_wait_us_.store(0U);
|
||||
max_scenario_wait_us_.store(0U);
|
||||
audio_owner_ = nullptr;
|
||||
scenario_owner_ = nullptr;
|
||||
|
||||
Serial.println("[MUTEX] Initialized: dual-mutex strategy (audio + scenario)");
|
||||
Serial.println("[MUTEX] Initialized: dual-mutex (audio + scenario), atomic stats");
|
||||
return true;
|
||||
}
|
||||
|
||||
void deinit() {
|
||||
if (g_audio_mutex != nullptr) {
|
||||
vSemaphoreDelete(g_audio_mutex);
|
||||
g_audio_mutex = nullptr;
|
||||
void MutexManager::doDeinit() {
|
||||
if (audio_mutex_ != nullptr) {
|
||||
vSemaphoreDelete(audio_mutex_);
|
||||
audio_mutex_ = nullptr;
|
||||
}
|
||||
if (g_scenario_mutex != nullptr) {
|
||||
vSemaphoreDelete(g_scenario_mutex);
|
||||
g_scenario_mutex = nullptr;
|
||||
if (scenario_mutex_ != nullptr) {
|
||||
vSemaphoreDelete(scenario_mutex_);
|
||||
scenario_mutex_ = nullptr;
|
||||
}
|
||||
g_audio_mutex_owner = nullptr;
|
||||
g_scenario_mutex_owner = nullptr;
|
||||
audio_owner_ = nullptr;
|
||||
scenario_owner_ = nullptr;
|
||||
Serial.println("[MUTEX] Deinitialized");
|
||||
}
|
||||
|
||||
bool takeAudioMutex(uint32_t timeout_ms) {
|
||||
if (g_audio_mutex == nullptr) {
|
||||
// ============================================================================
|
||||
// Internal helper — lock-free CAS update of a max field.
|
||||
// ============================================================================
|
||||
|
||||
void MutexManager::updateMaxWait(std::atomic<uint32_t>& max_field, uint32_t elapsed_us) {
|
||||
uint32_t current = max_field.load(std::memory_order_relaxed);
|
||||
while (elapsed_us > current &&
|
||||
!max_field.compare_exchange_weak(current, elapsed_us,
|
||||
std::memory_order_relaxed,
|
||||
std::memory_order_relaxed)) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audio mutex
|
||||
// ============================================================================
|
||||
|
||||
bool MutexManager::takeAudio(uint32_t timeout_ms) {
|
||||
if (audio_mutex_ == nullptr) {
|
||||
Serial.println("[MUTEX] ERROR: Audio mutex not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Deadlock detection: check if current task already owns scenario mutex
|
||||
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
|
||||
if (g_scenario_mutex_owner == current_task) {
|
||||
Serial.println("[MUTEX] ERROR: Deadlock prevented - scenario mutex already held, cannot acquire audio mutex");
|
||||
Serial.printf("[MUTEX] Correct order: audio → scenario. Current: scenario → audio\n");
|
||||
// Deadlock prevention: audio must be acquired before scenario.
|
||||
TaskHandle_t current = xTaskGetCurrentTaskHandle();
|
||||
if (scenario_owner_ == current) {
|
||||
Serial.println("[MUTEX] ERROR: Deadlock prevented — scenario held, cannot take audio");
|
||||
Serial.printf("[MUTEX] Correct order: audio → scenario. Got: scenario → audio\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t start_us = micros();
|
||||
const TickType_t ticks = (timeout_ms == 0) ? 0 : pdMS_TO_TICKS(timeout_ms);
|
||||
const BaseType_t result = xSemaphoreTake(g_audio_mutex, ticks);
|
||||
const TickType_t ticks = (timeout_ms == 0U) ? 0U : pdMS_TO_TICKS(timeout_ms);
|
||||
const BaseType_t result = xSemaphoreTake(audio_mutex_, ticks);
|
||||
const uint32_t elapsed_us = micros() - start_us;
|
||||
|
||||
if (result == pdTRUE) {
|
||||
g_audio_lock_count++;
|
||||
g_audio_mutex_owner = current_task;
|
||||
if (elapsed_us > g_max_audio_wait_us) {
|
||||
g_max_audio_wait_us = elapsed_us;
|
||||
}
|
||||
if (elapsed_us > 5000) { // Log if wait > 5ms
|
||||
Serial.printf("[MUTEX] Audio lock acquired after %lu µs (contention detected)\n", elapsed_us);
|
||||
++audio_lock_count_;
|
||||
audio_owner_ = current;
|
||||
updateMaxWait(max_audio_wait_us_, elapsed_us);
|
||||
if (elapsed_us > 5000U) {
|
||||
Serial.printf("[MUTEX] Audio lock acquired after %lu µs (contention)\n",
|
||||
static_cast<unsigned long>(elapsed_us));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
g_audio_timeout_count++;
|
||||
Serial.printf("[MUTEX] ERROR: Audio mutex timeout after %lu ms (elapsed %lu µs)\n",
|
||||
timeout_ms, elapsed_us);
|
||||
++audio_timeout_count_;
|
||||
Serial.printf("[MUTEX] ERROR: Audio timeout after %lu ms (elapsed %lu µs)\n",
|
||||
static_cast<unsigned long>(timeout_ms),
|
||||
static_cast<unsigned long>(elapsed_us));
|
||||
return false;
|
||||
}
|
||||
|
||||
void releaseAudioMutex() {
|
||||
if (g_audio_mutex == nullptr) {
|
||||
void MutexManager::releaseAudio() {
|
||||
if (audio_mutex_ == nullptr) {
|
||||
Serial.println("[MUTEX] ERROR: Audio mutex not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
TaskHandle_t current_task = xTaskGetCurrentTaskHandle();
|
||||
if (g_audio_mutex_owner != current_task) {
|
||||
Serial.println("[MUTEX] WARNING: Releasing audio mutex from different task than owner");
|
||||
if (audio_owner_ != xTaskGetCurrentTaskHandle()) {
|
||||
Serial.println("[MUTEX] WARNING: Audio released from non-owner task");
|
||||
}
|
||||
audio_owner_ = nullptr;
|
||||
xSemaphoreGive(audio_mutex_);
|
||||
}
|
||||
|
||||
g_audio_mutex_owner = nullptr;
|
||||
xSemaphoreGive(g_audio_mutex);
|
||||
}
|
||||
// ============================================================================
|
||||
// Scenario mutex
|
||||
// ============================================================================
|
||||
|
||||
bool takeScenarioMutex(uint32_t timeout_ms) {
|
||||
if (g_scenario_mutex == nullptr) {
|
||||
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");
|
||||
}
|
||||
scenario_owner_ = nullptr;
|
||||
xSemaphoreGive(scenario_mutex_);
|
||||
}
|
||||
|
||||
g_scenario_mutex_owner = nullptr;
|
||||
xSemaphoreGive(g_scenario_mutex);
|
||||
}
|
||||
// ============================================================================
|
||||
// Both mutexes (deadlock-safe order: audio → scenario)
|
||||
// ============================================================================
|
||||
|
||||
bool takeBothMutexes(uint32_t timeout_ms) {
|
||||
// Acquire audio mutex first (deadlock prevention order: audio → scenario)
|
||||
if (!takeAudioMutex(timeout_ms)) {
|
||||
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);
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
// media_manager.cpp - media catalog + playback + simulated recorder hooks.
|
||||
#include "media_manager.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <FS.h>
|
||||
#include <LittleFS.h>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
#include "audio_manager.h"
|
||||
|
||||
namespace {
|
||||
|
||||
void copyText(char* out, size_t out_size, const char* text) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (text == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, text, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
char toLowerAscii(char ch) {
|
||||
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
}
|
||||
|
||||
bool equalsIgnoreCase(const char* lhs, const char* rhs) {
|
||||
if (lhs == nullptr || rhs == nullptr) {
|
||||
return false;
|
||||
}
|
||||
size_t index = 0U;
|
||||
for (;; ++index) {
|
||||
const char l = lhs[index];
|
||||
const char r = rhs[index];
|
||||
if (l == '\0' && r == '\0') {
|
||||
return true;
|
||||
}
|
||||
if (toLowerAscii(l) != toLowerAscii(r)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool MediaManager::begin(const Config& config) {
|
||||
config_ = config;
|
||||
copyText(config_.music_dir, sizeof(config_.music_dir), normalizeDir(config.music_dir).c_str());
|
||||
copyText(config_.picture_dir, sizeof(config_.picture_dir), normalizeDir(config.picture_dir).c_str());
|
||||
copyText(config_.record_dir, sizeof(config_.record_dir), normalizeDir(config.record_dir).c_str());
|
||||
if (config_.record_max_seconds == 0U) {
|
||||
config_.record_max_seconds = 30U;
|
||||
}
|
||||
if (config_.record_max_seconds > 1800U) {
|
||||
config_.record_max_seconds = 1800U;
|
||||
}
|
||||
|
||||
snapshot_ = Snapshot();
|
||||
snapshot_.ready = true;
|
||||
snapshot_.record_limit_seconds = config_.record_max_seconds;
|
||||
copyText(snapshot_.music_dir, sizeof(snapshot_.music_dir), config_.music_dir);
|
||||
copyText(snapshot_.picture_dir, sizeof(snapshot_.picture_dir), config_.picture_dir);
|
||||
copyText(snapshot_.record_dir, sizeof(snapshot_.record_dir), config_.record_dir);
|
||||
ensureDir(config_.music_dir);
|
||||
ensureDir(config_.picture_dir);
|
||||
ensureDir(config_.record_dir);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MediaManager::update(uint32_t now_ms, AudioManager* audio) {
|
||||
if (audio != nullptr && snapshot_.playing && !audio->isPlaying()) {
|
||||
snapshot_.playing = false;
|
||||
snapshot_.playing_path[0] = '\0';
|
||||
}
|
||||
if (snapshot_.recording) {
|
||||
const uint32_t elapsed_ms = now_ms - snapshot_.record_started_ms;
|
||||
const uint16_t elapsed_seconds = static_cast<uint16_t>(elapsed_ms / 1000U);
|
||||
snapshot_.record_elapsed_seconds = elapsed_seconds;
|
||||
if (snapshot_.record_limit_seconds > 0U && elapsed_seconds >= snapshot_.record_limit_seconds) {
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MediaManager::noteStepChange() {
|
||||
if (config_.auto_stop_record_on_step_change && snapshot_.recording) {
|
||||
stopRecording();
|
||||
}
|
||||
}
|
||||
|
||||
bool MediaManager::listFiles(const char* kind, String* out_json) const {
|
||||
if (out_json == nullptr) {
|
||||
return false;
|
||||
}
|
||||
out_json->remove(0);
|
||||
const String dir = resolveKindDir(kind);
|
||||
if (dir.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (!LittleFS.exists(dir.c_str())) {
|
||||
*out_json = "[]";
|
||||
return true;
|
||||
}
|
||||
|
||||
File folder = LittleFS.open(dir.c_str(), "r");
|
||||
if (!folder || !folder.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
DynamicJsonDocument document(2048);
|
||||
JsonArray files = document.to<JsonArray>();
|
||||
File entry = folder.openNextFile();
|
||||
while (entry) {
|
||||
if (!entry.isDirectory()) {
|
||||
String path = entry.name();
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
files.add(path);
|
||||
}
|
||||
entry.close();
|
||||
entry = folder.openNextFile();
|
||||
}
|
||||
serializeJson(files, *out_json);
|
||||
folder.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MediaManager::play(const char* path, AudioManager* audio) {
|
||||
if (audio == nullptr || path == nullptr || path[0] == '\0') {
|
||||
setLastError("media_play_invalid_args");
|
||||
return false;
|
||||
}
|
||||
String normalized_path = path;
|
||||
normalized_path.trim();
|
||||
if (normalized_path.isEmpty()) {
|
||||
setLastError("media_play_empty_path");
|
||||
return false;
|
||||
}
|
||||
if (!normalized_path.startsWith("/")) {
|
||||
normalized_path = String(config_.music_dir) + "/" + normalized_path;
|
||||
}
|
||||
|
||||
const bool ok = audio->play(normalized_path.c_str());
|
||||
snapshot_.playing = ok;
|
||||
if (ok) {
|
||||
copyText(snapshot_.playing_path, sizeof(snapshot_.playing_path), normalized_path.c_str());
|
||||
clearLastError();
|
||||
} else {
|
||||
setLastError("media_play_failed");
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool MediaManager::stop(AudioManager* audio) {
|
||||
if (audio != nullptr) {
|
||||
audio->stop();
|
||||
}
|
||||
snapshot_.playing = false;
|
||||
snapshot_.playing_path[0] = '\0';
|
||||
clearLastError();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MediaManager::startRecording(uint16_t seconds, const char* filename_hint) {
|
||||
if (seconds == 0U) {
|
||||
seconds = config_.record_max_seconds;
|
||||
}
|
||||
if (seconds > config_.record_max_seconds) {
|
||||
seconds = config_.record_max_seconds;
|
||||
}
|
||||
if (seconds == 0U) {
|
||||
seconds = 1U;
|
||||
}
|
||||
if (snapshot_.recording) {
|
||||
setLastError("recorder_already_running");
|
||||
return false;
|
||||
}
|
||||
if (!ensureDir(config_.record_dir)) {
|
||||
setLastError("recorder_dir_missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
const String filename = sanitizeFilename(filename_hint, "record", ".wav");
|
||||
const String path = String(config_.record_dir) + "/" + filename;
|
||||
if (!writeEmptyWav(path.c_str())) {
|
||||
setLastError("recorder_create_failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot_.recording = true;
|
||||
snapshot_.record_limit_seconds = seconds;
|
||||
snapshot_.record_started_ms = millis();
|
||||
snapshot_.record_elapsed_seconds = 0U;
|
||||
copyText(snapshot_.record_file, sizeof(snapshot_.record_file), path.c_str());
|
||||
clearLastError();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MediaManager::stopRecording() {
|
||||
if (!snapshot_.recording) {
|
||||
return true;
|
||||
}
|
||||
const uint32_t elapsed_ms = millis() - snapshot_.record_started_ms;
|
||||
snapshot_.record_elapsed_seconds = static_cast<uint16_t>(elapsed_ms / 1000U);
|
||||
snapshot_.recording = false;
|
||||
clearLastError();
|
||||
return true;
|
||||
}
|
||||
|
||||
MediaManager::Snapshot MediaManager::snapshot() const {
|
||||
return snapshot_;
|
||||
}
|
||||
|
||||
void MediaManager::setLastError(const char* message) {
|
||||
snapshot_.last_ok = false;
|
||||
copyText(snapshot_.last_error, sizeof(snapshot_.last_error), message);
|
||||
}
|
||||
|
||||
void MediaManager::clearLastError() {
|
||||
snapshot_.last_ok = true;
|
||||
snapshot_.last_error[0] = '\0';
|
||||
}
|
||||
|
||||
String MediaManager::normalizeDir(const char* path) const {
|
||||
if (path == nullptr || path[0] == '\0') {
|
||||
return String("/");
|
||||
}
|
||||
String normalized = path;
|
||||
normalized.trim();
|
||||
if (normalized.isEmpty()) {
|
||||
return String("/");
|
||||
}
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = "/" + normalized;
|
||||
}
|
||||
if (normalized.length() > 1U && normalized.endsWith("/")) {
|
||||
normalized.remove(normalized.length() - 1U);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
String MediaManager::resolveKindDir(const char* kind) const {
|
||||
if (kind == nullptr) {
|
||||
return String();
|
||||
}
|
||||
if (equalsIgnoreCase(kind, "picture") || equalsIgnoreCase(kind, "pictures")) {
|
||||
return config_.picture_dir;
|
||||
}
|
||||
if (equalsIgnoreCase(kind, "music") || equalsIgnoreCase(kind, "audio")) {
|
||||
return config_.music_dir;
|
||||
}
|
||||
if (equalsIgnoreCase(kind, "recorder") || equalsIgnoreCase(kind, "record") || equalsIgnoreCase(kind, "records")) {
|
||||
return config_.record_dir;
|
||||
}
|
||||
return String();
|
||||
}
|
||||
|
||||
String MediaManager::sanitizeFilename(const char* hint, const char* default_prefix, const char* extension) const {
|
||||
String filename = (hint != nullptr) ? hint : "";
|
||||
filename.trim();
|
||||
if (filename.isEmpty()) {
|
||||
filename = default_prefix;
|
||||
filename += "_";
|
||||
filename += String(static_cast<unsigned long>(millis()));
|
||||
}
|
||||
for (size_t index = 0U; index < filename.length(); ++index) {
|
||||
const char ch = filename[index];
|
||||
const bool keep = std::isalnum(static_cast<unsigned char>(ch)) || ch == '_' || ch == '-' || ch == '.';
|
||||
if (!keep) {
|
||||
filename.setCharAt(index, '_');
|
||||
}
|
||||
}
|
||||
if (extension != nullptr && extension[0] != '\0' && !filename.endsWith(extension)) {
|
||||
filename += extension;
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
bool MediaManager::ensureDir(const char* path) const {
|
||||
const String normalized = normalizeDir(path);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (LittleFS.exists(normalized.c_str())) {
|
||||
return true;
|
||||
}
|
||||
return LittleFS.mkdir(normalized.c_str());
|
||||
}
|
||||
|
||||
bool MediaManager::writeEmptyWav(const char* path) const {
|
||||
if (path == nullptr || path[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
File file = LittleFS.open(path, "w");
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint32_t sample_rate = 16000UL;
|
||||
const uint16_t channels = 1U;
|
||||
const uint16_t bits_per_sample = 16U;
|
||||
const uint32_t data_size = 0UL;
|
||||
const uint32_t byte_rate = sample_rate * channels * (bits_per_sample / 8U);
|
||||
const uint16_t block_align = channels * (bits_per_sample / 8U);
|
||||
const uint32_t chunk_size = 36UL + data_size;
|
||||
|
||||
file.write(reinterpret_cast<const uint8_t*>("RIFF"), 4U);
|
||||
file.write(reinterpret_cast<const uint8_t*>(&chunk_size), sizeof(chunk_size));
|
||||
file.write(reinterpret_cast<const uint8_t*>("WAVE"), 4U);
|
||||
file.write(reinterpret_cast<const uint8_t*>("fmt "), 4U);
|
||||
const uint32_t fmt_size = 16UL;
|
||||
const uint16_t audio_format = 1U;
|
||||
file.write(reinterpret_cast<const uint8_t*>(&fmt_size), sizeof(fmt_size));
|
||||
file.write(reinterpret_cast<const uint8_t*>(&audio_format), sizeof(audio_format));
|
||||
file.write(reinterpret_cast<const uint8_t*>(&channels), sizeof(channels));
|
||||
file.write(reinterpret_cast<const uint8_t*>(&sample_rate), sizeof(sample_rate));
|
||||
file.write(reinterpret_cast<const uint8_t*>(&byte_rate), sizeof(byte_rate));
|
||||
file.write(reinterpret_cast<const uint8_t*>(&block_align), sizeof(block_align));
|
||||
file.write(reinterpret_cast<const uint8_t*>(&bits_per_sample), sizeof(bits_per_sample));
|
||||
file.write(reinterpret_cast<const uint8_t*>("data"), 4U);
|
||||
file.write(reinterpret_cast<const uint8_t*>(&data_size), sizeof(data_size));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
@@ -1,981 +0,0 @@
|
||||
// network_manager.cpp - WiFi + ESP-NOW runtime helpers for Freenove all-in-one.
|
||||
#include "network_manager.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_now.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
NetworkManager* g_network_instance = nullptr;
|
||||
|
||||
bool timeReached(uint32_t now_ms, uint32_t target_ms) {
|
||||
return static_cast<int32_t>(now_ms - target_ms) >= 0;
|
||||
}
|
||||
|
||||
bool isBroadcastMac(const uint8_t mac[6]) {
|
||||
if (mac == nullptr) {
|
||||
return false;
|
||||
}
|
||||
for (uint8_t index = 0U; index < 6U; ++index) {
|
||||
if (mac[index] != 0xFFU) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* inferEnvelopeType(const char* payload) {
|
||||
if (payload == nullptr || payload[0] == '\0') {
|
||||
return "empty";
|
||||
}
|
||||
if (std::strncmp(payload, "SC_EVENT", 8U) == 0 || std::strncmp(payload, "SERIAL:", 7U) == 0 ||
|
||||
std::strncmp(payload, "TIMER:", 6U) == 0 || std::strncmp(payload, "ACTION:", 7U) == 0 ||
|
||||
std::strcmp(payload, "UNLOCK") == 0 || std::strcmp(payload, "AUDIO_DONE") == 0) {
|
||||
return "story_event";
|
||||
}
|
||||
if (payload[0] == '{' || payload[0] == '[') {
|
||||
return "json";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
|
||||
bool looksLikeEspNowEnvelope(JsonVariantConst root) {
|
||||
if (!root.is<JsonObjectConst>()) {
|
||||
return false;
|
||||
}
|
||||
JsonObjectConst object = root.as<JsonObjectConst>();
|
||||
return object["msg_id"].is<const char*>() && object["seq"].is<uint32_t>() && object["type"].is<const char*>() &&
|
||||
object.containsKey("payload") && object["ack"].is<bool>();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool NetworkManager::begin(const char* hostname) {
|
||||
if (started_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
WiFi.persistent(false);
|
||||
WiFi.setSleep(false);
|
||||
WiFi.setAutoReconnect(true);
|
||||
WiFi.mode(WIFI_STA);
|
||||
if (hostname != nullptr && hostname[0] != '\0') {
|
||||
WiFi.setHostname(hostname);
|
||||
}
|
||||
|
||||
g_network_instance = this;
|
||||
started_ = true;
|
||||
refreshSnapshot();
|
||||
Serial.printf("[NET] wifi ready hostname=%s\n", (hostname != nullptr) ? hostname : "none");
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkManager::update(uint32_t now_ms) {
|
||||
if (!started_) {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshEspNowConnection(now_ms);
|
||||
|
||||
const bool connected_to_local = isConnectedToLocalTarget();
|
||||
const bool was_retry_paused = local_retry_paused_;
|
||||
bool force_refresh = false;
|
||||
|
||||
if (sta_connecting_) {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
sta_connecting_ = false;
|
||||
force_refresh = true;
|
||||
} else if ((now_ms - sta_connect_requested_at_ms_) >= kStaConnectTimeoutMs) {
|
||||
sta_connecting_ = false;
|
||||
force_refresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
const bool should_force_fallback = shouldForceFallbackAp();
|
||||
if (should_force_fallback && !fallback_ap_active_ && fallback_ap_ssid_[0] != '\0') {
|
||||
fallback_ap_active_ = startApInternal(fallback_ap_ssid_, fallback_ap_password_, false);
|
||||
force_refresh = true;
|
||||
} else if (!should_force_fallback && fallback_ap_active_ && !manual_ap_active_) {
|
||||
WiFi.softAPdisconnect(true);
|
||||
fallback_ap_active_ = false;
|
||||
WiFi.mode(WIFI_STA);
|
||||
force_refresh = true;
|
||||
}
|
||||
|
||||
const bool should_retry_local = local_target_ssid_[0] != '\0' &&
|
||||
(force_ap_if_not_local_ ? !connected_to_local : (WiFi.status() != WL_CONNECTED));
|
||||
const uint8_t ap_clients = (fallback_ap_active_ && !manual_ap_active_) ? WiFi.softAPgetStationNum() : 0U;
|
||||
local_retry_paused_ =
|
||||
should_retry_local && fallback_ap_active_ && pause_local_retry_when_ap_client_ && (ap_clients > 0U);
|
||||
if (local_retry_paused_ != was_retry_paused) {
|
||||
force_refresh = true;
|
||||
}
|
||||
|
||||
if (should_retry_local) {
|
||||
if (local_retry_paused_) {
|
||||
if (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_)) {
|
||||
next_local_retry_at_ms_ = now_ms + local_retry_ms_;
|
||||
Serial.printf("[NET] local retry paused ap_clients=%u\n", ap_clients);
|
||||
}
|
||||
} else if (!sta_connecting_ && (next_local_retry_at_ms_ == 0U || timeReached(now_ms, next_local_retry_at_ms_))) {
|
||||
if (fallback_ap_active_ && equalsIgnoreCase(fallback_ap_ssid_, local_target_ssid_)) {
|
||||
// Avoid self-association when fallback AP and local target share the same SSID.
|
||||
WiFi.softAPdisconnect(true);
|
||||
fallback_ap_active_ = false;
|
||||
WiFi.mode(WIFI_STA);
|
||||
Serial.println("[NET] local retry paused fallback AP (same ssid)");
|
||||
}
|
||||
const bool started = connectSta(local_target_ssid_, local_target_password_);
|
||||
next_local_retry_at_ms_ = now_ms + local_retry_ms_;
|
||||
force_refresh = true;
|
||||
Serial.printf("[NET] local retry target=%s started=%u\n", local_target_ssid_, started ? 1U : 0U);
|
||||
}
|
||||
} else {
|
||||
next_local_retry_at_ms_ = 0U;
|
||||
local_retry_paused_ = false;
|
||||
}
|
||||
|
||||
if (!force_refresh && (now_ms - last_refresh_ms_) < 350U) {
|
||||
return;
|
||||
}
|
||||
last_refresh_ms_ = now_ms;
|
||||
refreshSnapshot();
|
||||
}
|
||||
|
||||
void NetworkManager::configureFallbackAp(const char* ssid, const char* password) {
|
||||
if (ssid != nullptr && ssid[0] != '\0') {
|
||||
copyText(fallback_ap_ssid_, sizeof(fallback_ap_ssid_), ssid);
|
||||
}
|
||||
if (password != nullptr && password[0] != '\0') {
|
||||
copyText(fallback_ap_password_, sizeof(fallback_ap_password_), password);
|
||||
}
|
||||
Serial.printf("[NET] fallback AP configured ssid=%s\n", fallback_ap_ssid_);
|
||||
}
|
||||
|
||||
void NetworkManager::configureLocalPolicy(const char* ssid,
|
||||
const char* password,
|
||||
bool force_if_not_local,
|
||||
uint32_t retry_ms,
|
||||
bool pause_retry_when_ap_client) {
|
||||
if (ssid != nullptr && ssid[0] != '\0') {
|
||||
copyText(local_target_ssid_, sizeof(local_target_ssid_), ssid);
|
||||
}
|
||||
if (password != nullptr && password[0] != '\0') {
|
||||
copyText(local_target_password_, sizeof(local_target_password_), password);
|
||||
}
|
||||
force_ap_if_not_local_ = force_if_not_local;
|
||||
pause_local_retry_when_ap_client_ = pause_retry_when_ap_client;
|
||||
if (retry_ms >= 1000U) {
|
||||
local_retry_ms_ = retry_ms;
|
||||
}
|
||||
local_retry_paused_ = false;
|
||||
next_local_retry_at_ms_ = 0U;
|
||||
refreshSnapshot();
|
||||
Serial.printf("[NET] local policy target=%s force_ap_if_not_local=%u retry_ms=%lu pause_retry_on_ap_client=%u\n",
|
||||
local_target_ssid_,
|
||||
force_ap_if_not_local_ ? 1U : 0U,
|
||||
static_cast<unsigned long>(local_retry_ms_),
|
||||
pause_local_retry_when_ap_client_ ? 1U : 0U);
|
||||
}
|
||||
|
||||
bool NetworkManager::connectSta(const char* ssid, const char* password) {
|
||||
if (!started_ && !begin(nullptr)) {
|
||||
return false;
|
||||
}
|
||||
if (ssid == nullptr || ssid[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED && equalsIgnoreCase(WiFi.SSID().c_str(), ssid)) {
|
||||
sta_connecting_ = false;
|
||||
refreshSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
const uint8_t mode = (manual_ap_active_ || fallback_ap_active_) ? WIFI_MODE_APSTA : WIFI_MODE_STA;
|
||||
WiFi.mode(static_cast<wifi_mode_t>(mode));
|
||||
WiFi.begin(ssid, (password != nullptr) ? password : "");
|
||||
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), ssid);
|
||||
sta_connecting_ = true;
|
||||
sta_connect_requested_at_ms_ = millis();
|
||||
refreshSnapshot();
|
||||
Serial.printf("[NET] wifi connect requested ssid=%s\n", ssid);
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkManager::disconnectSta() {
|
||||
if (!started_) {
|
||||
return;
|
||||
}
|
||||
WiFi.disconnect(true, false);
|
||||
sta_connecting_ = false;
|
||||
local_retry_paused_ = false;
|
||||
next_local_retry_at_ms_ = 0U;
|
||||
snapshot_.sta_ssid[0] = '\0';
|
||||
if (shouldForceFallbackAp() && !manual_ap_active_ && fallback_ap_ssid_[0] != '\0') {
|
||||
fallback_ap_active_ = startApInternal(fallback_ap_ssid_, fallback_ap_password_, false);
|
||||
}
|
||||
refreshSnapshot();
|
||||
Serial.println("[NET] wifi disconnected");
|
||||
}
|
||||
|
||||
bool NetworkManager::startAp(const char* ssid, const char* password) {
|
||||
return startApInternal(ssid, password, true);
|
||||
}
|
||||
|
||||
bool NetworkManager::isConnectedToLocalTarget() const {
|
||||
if (local_target_ssid_[0] == '\0' || WiFi.status() != WL_CONNECTED) {
|
||||
return false;
|
||||
}
|
||||
if (!equalsIgnoreCase(WiFi.SSID().c_str(), local_target_ssid_)) {
|
||||
return false;
|
||||
}
|
||||
return !isConnectedToSelfAp();
|
||||
}
|
||||
|
||||
bool NetworkManager::isConnectedToSelfAp() const {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
return false;
|
||||
}
|
||||
const uint8_t* sta_bssid = WiFi.BSSID();
|
||||
if (sta_bssid == nullptr) {
|
||||
return false;
|
||||
}
|
||||
uint8_t ap_mac[6] = {0};
|
||||
WiFi.softAPmacAddress(ap_mac);
|
||||
return std::memcmp(sta_bssid, ap_mac, 6U) == 0;
|
||||
}
|
||||
|
||||
bool NetworkManager::shouldForceFallbackAp() const {
|
||||
if (manual_ap_active_ || fallback_ap_ssid_[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (force_ap_if_not_local_ && local_target_ssid_[0] != '\0') {
|
||||
if (sta_connecting_) {
|
||||
return false;
|
||||
}
|
||||
return !isConnectedToLocalTarget();
|
||||
}
|
||||
if (sta_connecting_) {
|
||||
return false;
|
||||
}
|
||||
return WiFi.status() != WL_CONNECTED;
|
||||
}
|
||||
|
||||
bool NetworkManager::startApInternal(const char* ssid, const char* password, bool manual_request) {
|
||||
if (!started_ && !begin(nullptr)) {
|
||||
return false;
|
||||
}
|
||||
if (ssid == nullptr || ssid[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (password != nullptr && password[0] != '\0' && std::strlen(password) < 8U) {
|
||||
Serial.println("[NET] AP password must be >= 8 chars");
|
||||
return false;
|
||||
}
|
||||
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
bool ok = false;
|
||||
if (password != nullptr && password[0] != '\0') {
|
||||
ok = WiFi.softAP(ssid, password);
|
||||
} else {
|
||||
ok = WiFi.softAP(ssid);
|
||||
}
|
||||
if (ok) {
|
||||
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), ssid);
|
||||
if (manual_request) {
|
||||
manual_ap_active_ = true;
|
||||
fallback_ap_active_ = false;
|
||||
} else {
|
||||
fallback_ap_active_ = true;
|
||||
}
|
||||
}
|
||||
refreshSnapshot();
|
||||
Serial.printf("[NET] AP %s ssid=%s mode=%s\n",
|
||||
ok ? "on" : "failed",
|
||||
ssid,
|
||||
manual_request ? "manual" : "fallback");
|
||||
return ok;
|
||||
}
|
||||
|
||||
void NetworkManager::stopAp() {
|
||||
if (!started_) {
|
||||
return;
|
||||
}
|
||||
WiFi.softAPdisconnect(true);
|
||||
manual_ap_active_ = false;
|
||||
fallback_ap_active_ = false;
|
||||
local_retry_paused_ = false;
|
||||
if (WiFi.status() == WL_CONNECTED || sta_connecting_) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
}
|
||||
snapshot_.ap_ssid[0] = '\0';
|
||||
refreshSnapshot();
|
||||
Serial.println("[NET] AP off");
|
||||
}
|
||||
|
||||
bool NetworkManager::enableEspNow() {
|
||||
if (!started_ && !begin(nullptr)) {
|
||||
return false;
|
||||
}
|
||||
if (espnow_enabled_) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (WiFi.getMode() == WIFI_MODE_NULL) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
}
|
||||
if (esp_now_init() != ESP_OK) {
|
||||
Serial.println("[NET] esp_now_init failed");
|
||||
return false;
|
||||
}
|
||||
esp_now_register_recv_cb(onEspNowRecv);
|
||||
esp_now_register_send_cb(onEspNowSend);
|
||||
espnow_enabled_ = true;
|
||||
last_espnow_refresh_ms_ = millis();
|
||||
espnow_refresh_fault_ = false;
|
||||
refreshSnapshot();
|
||||
Serial.println("[NET] ESP-NOW ready");
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkManager::disableEspNow() {
|
||||
if (!espnow_enabled_) {
|
||||
return;
|
||||
}
|
||||
esp_now_deinit();
|
||||
espnow_enabled_ = false;
|
||||
peer_cache_count_ = 0U;
|
||||
rx_queue_head_ = 0U;
|
||||
rx_queue_tail_ = 0U;
|
||||
rx_queue_count_ = 0U;
|
||||
last_espnow_refresh_ms_ = 0U;
|
||||
espnow_refresh_fault_ = false;
|
||||
refreshSnapshot();
|
||||
Serial.println("[NET] ESP-NOW off");
|
||||
}
|
||||
|
||||
bool NetworkManager::parseMac(const char* text, uint8_t out_mac[6]) const {
|
||||
if (text == nullptr || out_mac == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char compact[13] = {0};
|
||||
size_t cursor = 0U;
|
||||
for (size_t index = 0U; text[index] != '\0'; ++index) {
|
||||
const char ch = text[index];
|
||||
if (std::isxdigit(static_cast<unsigned char>(ch))) {
|
||||
if (cursor >= 12U) {
|
||||
return false;
|
||||
}
|
||||
compact[cursor++] = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch == ':' || ch == '-' || ch == ' ') {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (cursor != 12U) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
for (uint8_t idx = 0U; idx < 6U; ++idx) {
|
||||
out_mac[idx] = parseHexByte(compact[idx * 2U], compact[idx * 2U + 1U], &ok);
|
||||
if (!ok) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NetworkManager::addEspNowPeer(const char* mac_text) {
|
||||
if (mac_text == nullptr || mac_text[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (!ensureEspNowReady()) {
|
||||
return false;
|
||||
}
|
||||
uint8_t mac[6] = {0};
|
||||
if (!parseMac(mac_text, mac)) {
|
||||
return false;
|
||||
}
|
||||
if (!addEspNowPeerInternal(mac)) {
|
||||
return false;
|
||||
}
|
||||
cachePeer(mac);
|
||||
refreshSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NetworkManager::removeEspNowPeer(const char* mac_text) {
|
||||
if (mac_text == nullptr || mac_text[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (!ensureEspNowReady()) {
|
||||
return false;
|
||||
}
|
||||
uint8_t mac[6] = {0};
|
||||
if (!parseMac(mac_text, mac)) {
|
||||
return false;
|
||||
}
|
||||
if (!removeEspNowPeerInternal(mac)) {
|
||||
return false;
|
||||
}
|
||||
forgetPeer(mac);
|
||||
refreshSnapshot();
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t NetworkManager::espNowPeerCount() const {
|
||||
return peer_cache_count_;
|
||||
}
|
||||
|
||||
bool NetworkManager::espNowPeerAt(uint8_t index, char* out_mac, size_t out_capacity) const {
|
||||
if (out_mac == nullptr || out_capacity == 0U || index >= peer_cache_count_) {
|
||||
return false;
|
||||
}
|
||||
copyText(out_mac, out_capacity, peer_cache_[index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NetworkManager::sendEspNowText(const uint8_t mac[6], const char* text) {
|
||||
if (!ensureEspNowReady()) {
|
||||
return false;
|
||||
}
|
||||
if (mac == nullptr || text == nullptr || text[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
const size_t payload_len = std::strlen(text);
|
||||
if (payload_len == 0U || payload_len > kEspNowFrameCapacity) {
|
||||
Serial.printf("[NET] ESP-NOW payload too large: %u bytes\n", static_cast<unsigned int>(payload_len));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isBroadcastMac(mac)) {
|
||||
if (!addEspNowPeerInternal(mac)) {
|
||||
if (ensureEspNowReady() && addEspNowPeerInternal(mac)) {
|
||||
// recovered
|
||||
} else {
|
||||
Serial.println("[NET] ESP-NOW add peer failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// ESP-NOW broadcast still needs an explicit peer on some SDK versions.
|
||||
if (!addEspNowPeerInternal(mac)) {
|
||||
if (!(ensureEspNowReady() && addEspNowPeerInternal(mac))) {
|
||||
Serial.println("[NET] ESP-NOW add peer failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t err = esp_now_send(mac,
|
||||
reinterpret_cast<const uint8_t*>(text),
|
||||
payload_len);
|
||||
if (err == ESP_ERR_ESPNOW_NOT_INIT) {
|
||||
// WiFi mode switches can deinit ESP-NOW internally: recover once, then retry the same payload.
|
||||
espnow_enabled_ = false;
|
||||
if (enableEspNow()) {
|
||||
addEspNowPeerInternal(mac);
|
||||
err = esp_now_send(mac, reinterpret_cast<const uint8_t*>(text), payload_len);
|
||||
}
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
++espnow_tx_fail_;
|
||||
Serial.printf("[NET] ESP-NOW send failed err=%d\n", static_cast<int>(err));
|
||||
return false;
|
||||
}
|
||||
cachePeer(mac);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool NetworkManager::ensureEspNowReady() {
|
||||
if (!espnow_enabled_) {
|
||||
return enableEspNow();
|
||||
}
|
||||
|
||||
esp_now_peer_num_t peer_num = {};
|
||||
const esp_err_t err = esp_now_get_peer_num(&peer_num);
|
||||
if (err == ESP_OK) {
|
||||
return true;
|
||||
}
|
||||
if (err == ESP_ERR_ESPNOW_NOT_INIT) {
|
||||
espnow_enabled_ = false;
|
||||
return enableEspNow();
|
||||
}
|
||||
Serial.printf("[NET] ESP-NOW health check err=%d\n", static_cast<int>(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
void NetworkManager::refreshEspNowConnection(uint32_t now_ms) {
|
||||
if (!espnow_enabled_) {
|
||||
espnow_refresh_fault_ = false;
|
||||
last_espnow_refresh_ms_ = now_ms;
|
||||
return;
|
||||
}
|
||||
if ((now_ms - last_espnow_refresh_ms_) < kEspNowRefreshPeriodMs) {
|
||||
return;
|
||||
}
|
||||
last_espnow_refresh_ms_ = now_ms;
|
||||
|
||||
if (!ensureEspNowReady()) {
|
||||
if (!espnow_refresh_fault_) {
|
||||
Serial.println("[NET] ESP-NOW periodic refresh failed");
|
||||
}
|
||||
espnow_refresh_fault_ = true;
|
||||
return;
|
||||
}
|
||||
if (espnow_refresh_fault_) {
|
||||
Serial.println("[NET] ESP-NOW periodic refresh recovered");
|
||||
}
|
||||
espnow_refresh_fault_ = false;
|
||||
|
||||
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
|
||||
uint8_t mac[6] = {0};
|
||||
if (!parseMac(peer_cache_[index], mac)) {
|
||||
continue;
|
||||
}
|
||||
addEspNowPeerInternal(mac);
|
||||
}
|
||||
}
|
||||
|
||||
bool NetworkManager::sendEspNowTarget(const char* target, const char* text) {
|
||||
if (target == nullptr || target[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (text == nullptr || text[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
String frame = text;
|
||||
frame.trim();
|
||||
if (frame.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool is_envelope = false;
|
||||
if (frame.startsWith("{")) {
|
||||
StaticJsonDocument<512> document;
|
||||
if (!deserializeJson(document, frame) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
|
||||
is_envelope = true;
|
||||
}
|
||||
}
|
||||
if (!is_envelope) {
|
||||
StaticJsonDocument<512> envelope;
|
||||
++espnow_tx_seq_;
|
||||
char msg_id[32] = {0};
|
||||
snprintf(msg_id,
|
||||
sizeof(msg_id),
|
||||
"M%08lX%06lu",
|
||||
static_cast<unsigned long>(millis()),
|
||||
static_cast<unsigned long>(espnow_tx_seq_));
|
||||
envelope["msg_id"] = msg_id;
|
||||
envelope["seq"] = espnow_tx_seq_;
|
||||
envelope["type"] = inferEnvelopeType(frame.c_str());
|
||||
envelope["payload"] = frame;
|
||||
envelope["ack"] = false;
|
||||
frame.remove(0);
|
||||
serializeJson(envelope, frame);
|
||||
}
|
||||
|
||||
if (equalsIgnoreCase(target, "broadcast")) {
|
||||
const uint8_t broadcast_mac[6] = {0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xFFU};
|
||||
return sendEspNowText(broadcast_mac, frame.c_str());
|
||||
}
|
||||
uint8_t mac[6] = {0};
|
||||
if (!parseMac(target, mac)) {
|
||||
return false;
|
||||
}
|
||||
return sendEspNowText(mac, frame.c_str());
|
||||
}
|
||||
|
||||
NetworkManager::Snapshot NetworkManager::snapshot() const {
|
||||
return snapshot_;
|
||||
}
|
||||
|
||||
bool NetworkManager::consumeEspNowMessage(char* out_payload,
|
||||
size_t payload_capacity,
|
||||
char* out_peer,
|
||||
size_t peer_capacity,
|
||||
char* out_msg_id,
|
||||
size_t msg_id_capacity,
|
||||
uint32_t* out_seq,
|
||||
char* out_type,
|
||||
size_t type_capacity,
|
||||
bool* out_ack_requested) {
|
||||
if (rx_queue_count_ == 0U) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const EspNowMessage& entry = rx_queue_[rx_queue_head_];
|
||||
char normalized_payload[kPayloadCapacity] = {0};
|
||||
copyText(normalized_payload, sizeof(normalized_payload), entry.payload);
|
||||
char msg_id[32] = {0};
|
||||
uint32_t seq = 0U;
|
||||
char envelope_type[24] = {0};
|
||||
bool ack_requested = false;
|
||||
|
||||
if (entry.payload[0] == '{') {
|
||||
StaticJsonDocument<512> document;
|
||||
if (!deserializeJson(document, entry.payload) && looksLikeEspNowEnvelope(document.as<JsonVariantConst>())) {
|
||||
JsonVariantConst root = document.as<JsonVariantConst>();
|
||||
copyText(msg_id, sizeof(msg_id), root["msg_id"] | "");
|
||||
seq = root["seq"] | 0U;
|
||||
copyText(envelope_type, sizeof(envelope_type), root["type"] | "");
|
||||
const bool envelope_ack = root["ack"] | false;
|
||||
const bool ack_response = envelope_ack && std::strcmp(envelope_type, "ack") == 0;
|
||||
if (ack_response) {
|
||||
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
|
||||
--rx_queue_count_;
|
||||
return consumeEspNowMessage(out_payload,
|
||||
payload_capacity,
|
||||
out_peer,
|
||||
peer_capacity,
|
||||
out_msg_id,
|
||||
msg_id_capacity,
|
||||
out_seq,
|
||||
out_type,
|
||||
type_capacity,
|
||||
out_ack_requested);
|
||||
}
|
||||
ack_requested = envelope_ack;
|
||||
if (root["payload"].is<const char*>()) {
|
||||
copyText(normalized_payload, sizeof(normalized_payload), root["payload"].as<const char*>());
|
||||
} else if (!root["payload"].isNull()) {
|
||||
String payload_text;
|
||||
serializeJson(root["payload"], payload_text);
|
||||
copyText(normalized_payload, sizeof(normalized_payload), payload_text.c_str());
|
||||
}
|
||||
if (envelope_type[0] == '\0') {
|
||||
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (envelope_type[0] == '\0') {
|
||||
copyText(envelope_type, sizeof(envelope_type), inferEnvelopeType(normalized_payload));
|
||||
}
|
||||
|
||||
if (out_payload != nullptr && payload_capacity > 0U) {
|
||||
copyText(out_payload, payload_capacity, normalized_payload);
|
||||
}
|
||||
if (out_peer != nullptr && peer_capacity > 0U) {
|
||||
copyText(out_peer, peer_capacity, entry.peer);
|
||||
}
|
||||
if (out_msg_id != nullptr && msg_id_capacity > 0U) {
|
||||
copyText(out_msg_id, msg_id_capacity, msg_id);
|
||||
}
|
||||
if (out_seq != nullptr) {
|
||||
*out_seq = seq;
|
||||
}
|
||||
if (out_type != nullptr && type_capacity > 0U) {
|
||||
copyText(out_type, type_capacity, envelope_type);
|
||||
}
|
||||
if (out_ack_requested != nullptr) {
|
||||
*out_ack_requested = ack_requested;
|
||||
}
|
||||
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
|
||||
--rx_queue_count_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkManager::onEspNowRecv(const uint8_t* mac_addr, const uint8_t* data, int data_len) {
|
||||
if (g_network_instance == nullptr) {
|
||||
return;
|
||||
}
|
||||
g_network_instance->handleEspNowRecv(mac_addr, data, data_len);
|
||||
}
|
||||
|
||||
void NetworkManager::onEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status) {
|
||||
if (g_network_instance == nullptr) {
|
||||
return;
|
||||
}
|
||||
g_network_instance->handleEspNowSend(mac_addr, status);
|
||||
}
|
||||
|
||||
uint8_t NetworkManager::parseHexByte(char high, char low, bool* ok) {
|
||||
auto nibble = [](char ch) -> int {
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
return ch - '0';
|
||||
}
|
||||
if (ch >= 'A' && ch <= 'F') {
|
||||
return 10 + (ch - 'A');
|
||||
}
|
||||
if (ch >= 'a' && ch <= 'f') {
|
||||
return 10 + (ch - 'a');
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const int hi = nibble(high);
|
||||
const int lo = nibble(low);
|
||||
if (hi < 0 || lo < 0) {
|
||||
if (ok != nullptr) {
|
||||
*ok = false;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
if (ok != nullptr) {
|
||||
*ok = true;
|
||||
}
|
||||
return static_cast<uint8_t>((hi << 4) | lo);
|
||||
}
|
||||
|
||||
void NetworkManager::copyText(char* out, size_t out_size, const char* text) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (text == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, text, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
void NetworkManager::formatMac(const uint8_t* mac, char* out, size_t out_size) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (mac == nullptr) {
|
||||
copyText(out, out_size, "00:00:00:00:00:00");
|
||||
return;
|
||||
}
|
||||
snprintf(out,
|
||||
out_size,
|
||||
"%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac[0],
|
||||
mac[1],
|
||||
mac[2],
|
||||
mac[3],
|
||||
mac[4],
|
||||
mac[5]);
|
||||
}
|
||||
|
||||
bool NetworkManager::equalsIgnoreCase(const char* lhs, const char* rhs) {
|
||||
if (lhs == nullptr || rhs == nullptr) {
|
||||
return false;
|
||||
}
|
||||
size_t index = 0U;
|
||||
while (lhs[index] != '\0' && rhs[index] != '\0') {
|
||||
const char l = static_cast<char>(std::tolower(static_cast<unsigned char>(lhs[index])));
|
||||
const char r = static_cast<char>(std::tolower(static_cast<unsigned char>(rhs[index])));
|
||||
if (l != r) {
|
||||
return false;
|
||||
}
|
||||
++index;
|
||||
}
|
||||
return lhs[index] == '\0' && rhs[index] == '\0';
|
||||
}
|
||||
|
||||
const char* NetworkManager::wifiModeLabel(uint8_t mode) {
|
||||
switch (mode) {
|
||||
case WIFI_MODE_STA:
|
||||
return "STA";
|
||||
case WIFI_MODE_AP:
|
||||
return "AP";
|
||||
case WIFI_MODE_APSTA:
|
||||
return "AP_STA";
|
||||
default:
|
||||
return "OFF";
|
||||
}
|
||||
}
|
||||
|
||||
const char* NetworkManager::networkStateLabel(bool sta_connected,
|
||||
bool sta_connecting,
|
||||
bool ap_enabled,
|
||||
bool fallback_ap_active) {
|
||||
if (sta_connected) {
|
||||
return "connected";
|
||||
}
|
||||
if (sta_connecting) {
|
||||
return "connecting";
|
||||
}
|
||||
if (ap_enabled && fallback_ap_active) {
|
||||
return "ap_fallback";
|
||||
}
|
||||
if (ap_enabled) {
|
||||
return "ap";
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
bool NetworkManager::addEspNowPeerInternal(const uint8_t mac[6]) {
|
||||
if (!espnow_enabled_ || mac == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (esp_now_is_peer_exist(mac)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_now_peer_info_t peer = {};
|
||||
std::memcpy(peer.peer_addr, mac, 6U);
|
||||
peer.channel = 0U;
|
||||
peer.encrypt = false;
|
||||
return esp_now_add_peer(&peer) == ESP_OK;
|
||||
}
|
||||
|
||||
bool NetworkManager::removeEspNowPeerInternal(const uint8_t mac[6]) {
|
||||
if (!espnow_enabled_ || mac == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (!esp_now_is_peer_exist(mac)) {
|
||||
return true;
|
||||
}
|
||||
const esp_err_t err = esp_now_del_peer(mac);
|
||||
return err == ESP_OK;
|
||||
}
|
||||
|
||||
void NetworkManager::cachePeer(const uint8_t mac[6]) {
|
||||
char peer_text[18] = {0};
|
||||
formatMac(mac, peer_text, sizeof(peer_text));
|
||||
if (peer_text[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
|
||||
if (std::strcmp(peer_cache_[index], peer_text) == 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (peer_cache_count_ < kMaxPeerCache) {
|
||||
copyText(peer_cache_[peer_cache_count_], sizeof(peer_cache_[peer_cache_count_]), peer_text);
|
||||
++peer_cache_count_;
|
||||
return;
|
||||
}
|
||||
for (uint8_t index = 1U; index < kMaxPeerCache; ++index) {
|
||||
copyText(peer_cache_[index - 1U], sizeof(peer_cache_[index - 1U]), peer_cache_[index]);
|
||||
}
|
||||
copyText(peer_cache_[kMaxPeerCache - 1U], sizeof(peer_cache_[kMaxPeerCache - 1U]), peer_text);
|
||||
}
|
||||
|
||||
void NetworkManager::forgetPeer(const uint8_t mac[6]) {
|
||||
char peer_text[18] = {0};
|
||||
formatMac(mac, peer_text, sizeof(peer_text));
|
||||
if (peer_text[0] == '\0' || peer_cache_count_ == 0U) {
|
||||
return;
|
||||
}
|
||||
for (uint8_t index = 0U; index < peer_cache_count_; ++index) {
|
||||
if (std::strcmp(peer_cache_[index], peer_text) != 0) {
|
||||
continue;
|
||||
}
|
||||
for (uint8_t move = index + 1U; move < peer_cache_count_; ++move) {
|
||||
copyText(peer_cache_[move - 1U], sizeof(peer_cache_[move - 1U]), peer_cache_[move]);
|
||||
}
|
||||
peer_cache_[peer_cache_count_ - 1U][0] = '\0';
|
||||
--peer_cache_count_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool NetworkManager::queueEspNowMessage(const char* payload,
|
||||
const char* peer,
|
||||
const char* msg_id,
|
||||
uint32_t seq,
|
||||
const char* type,
|
||||
bool ack_requested) {
|
||||
if (payload == nullptr || payload[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
if (rx_queue_count_ >= kRxQueueSize) {
|
||||
rx_queue_head_ = static_cast<uint8_t>((rx_queue_head_ + 1U) % kRxQueueSize);
|
||||
--rx_queue_count_;
|
||||
++espnow_drop_packets_;
|
||||
}
|
||||
EspNowMessage& slot = rx_queue_[rx_queue_tail_];
|
||||
copyText(slot.payload, sizeof(slot.payload), payload);
|
||||
copyText(slot.peer, sizeof(slot.peer), peer);
|
||||
copyText(slot.msg_id, sizeof(slot.msg_id), msg_id);
|
||||
copyText(slot.type, sizeof(slot.type), type);
|
||||
slot.seq = seq;
|
||||
slot.ack_requested = ack_requested;
|
||||
rx_queue_tail_ = static_cast<uint8_t>((rx_queue_tail_ + 1U) % kRxQueueSize);
|
||||
++rx_queue_count_;
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkManager::refreshSnapshot() {
|
||||
const wl_status_t wifi_status = WiFi.status();
|
||||
const wifi_mode_t mode = WiFi.getMode();
|
||||
const bool local_match = isConnectedToLocalTarget();
|
||||
|
||||
snapshot_.ready = started_;
|
||||
snapshot_.sta_connected = (wifi_status == WL_CONNECTED);
|
||||
snapshot_.sta_connecting = sta_connecting_;
|
||||
snapshot_.ap_enabled = (mode == WIFI_MODE_AP || mode == WIFI_MODE_APSTA);
|
||||
snapshot_.espnow_enabled = espnow_enabled_;
|
||||
snapshot_.local_match = local_match;
|
||||
snapshot_.fallback_ap_active =
|
||||
fallback_ap_active_ && !manual_ap_active_ && snapshot_.ap_enabled && !snapshot_.local_match;
|
||||
snapshot_.local_retry_paused = local_retry_paused_;
|
||||
snapshot_.rssi = snapshot_.sta_connected ? WiFi.RSSI() : 0;
|
||||
copyText(snapshot_.local_target, sizeof(snapshot_.local_target), local_target_ssid_);
|
||||
copyText(snapshot_.mode, sizeof(snapshot_.mode), wifiModeLabel(static_cast<uint8_t>(mode)));
|
||||
copyText(snapshot_.state,
|
||||
sizeof(snapshot_.state),
|
||||
networkStateLabel(snapshot_.sta_connected,
|
||||
sta_connecting_,
|
||||
snapshot_.ap_enabled,
|
||||
snapshot_.fallback_ap_active));
|
||||
|
||||
if (snapshot_.sta_connected) {
|
||||
copyText(snapshot_.sta_ssid, sizeof(snapshot_.sta_ssid), WiFi.SSID().c_str());
|
||||
copyText(snapshot_.ip, sizeof(snapshot_.ip), WiFi.localIP().toString().c_str());
|
||||
} else if (snapshot_.ap_enabled) {
|
||||
copyText(snapshot_.ip, sizeof(snapshot_.ip), WiFi.softAPIP().toString().c_str());
|
||||
} else {
|
||||
copyText(snapshot_.ip, sizeof(snapshot_.ip), "0.0.0.0");
|
||||
}
|
||||
|
||||
if (snapshot_.ap_enabled) {
|
||||
copyText(snapshot_.ap_ssid, sizeof(snapshot_.ap_ssid), WiFi.softAPSSID().c_str());
|
||||
snapshot_.ap_clients = WiFi.softAPgetStationNum();
|
||||
} else {
|
||||
snapshot_.ap_ssid[0] = '\0';
|
||||
snapshot_.ap_clients = 0U;
|
||||
}
|
||||
|
||||
snapshot_.espnow_peer_count = peer_cache_count_;
|
||||
snapshot_.espnow_rx_packets = espnow_rx_packets_;
|
||||
snapshot_.espnow_tx_ok = espnow_tx_ok_;
|
||||
snapshot_.espnow_tx_fail = espnow_tx_fail_;
|
||||
snapshot_.espnow_drop_packets = espnow_drop_packets_;
|
||||
}
|
||||
|
||||
void NetworkManager::handleEspNowRecv(const uint8_t* mac_addr, const uint8_t* data, int data_len) {
|
||||
++espnow_rx_packets_;
|
||||
cachePeer(mac_addr);
|
||||
|
||||
char peer_text[18] = {0};
|
||||
formatMac(mac_addr, peer_text, sizeof(peer_text));
|
||||
copyText(snapshot_.last_peer, sizeof(snapshot_.last_peer), peer_text);
|
||||
copyText(snapshot_.last_rx_peer, sizeof(snapshot_.last_rx_peer), peer_text);
|
||||
|
||||
char payload[kPayloadCapacity] = {0};
|
||||
const int safe_len = (data_len > 0) ? data_len : 0;
|
||||
const size_t copy_len = (static_cast<size_t>(safe_len) < (sizeof(payload) - 1U)) ? static_cast<size_t>(safe_len)
|
||||
: (sizeof(payload) - 1U);
|
||||
if (data != nullptr && copy_len > 0U) {
|
||||
std::memcpy(payload, data, copy_len);
|
||||
}
|
||||
payload[copy_len] = '\0';
|
||||
snapshot_.espnow_last_seq = 0U;
|
||||
snapshot_.espnow_last_ack = false;
|
||||
snapshot_.last_msg_id[0] = '\0';
|
||||
copyText(snapshot_.last_type, sizeof(snapshot_.last_type), inferEnvelopeType(payload));
|
||||
copyText(snapshot_.last_payload, sizeof(snapshot_.last_payload), payload);
|
||||
queueEspNowMessage(payload, peer_text, "", 0U, "", false);
|
||||
}
|
||||
|
||||
void NetworkManager::handleEspNowSend(const uint8_t* mac_addr, esp_now_send_status_t status) {
|
||||
if (status == ESP_NOW_SEND_SUCCESS) {
|
||||
++espnow_tx_ok_;
|
||||
} else {
|
||||
++espnow_tx_fail_;
|
||||
}
|
||||
cachePeer(mac_addr);
|
||||
formatMac(mac_addr, snapshot_.last_peer, sizeof(snapshot_.last_peer));
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Placeholder pour compilation RP2040
|
||||
// Ce fichier peut être remplacé par le code réel si besoin
|
||||
@@ -1,27 +1,27 @@
|
||||
#include "runtime/memory/caps_allocator.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
|
||||
#if defined(ARDUINO_ARCH_ESP32)
|
||||
#include <esp_heap_caps.h>
|
||||
#endif
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
namespace runtime {
|
||||
namespace memory {
|
||||
|
||||
namespace {
|
||||
|
||||
uint32_t g_alloc_failures = 0U;
|
||||
std::atomic<uint32_t> g_alloc_failures{0U};
|
||||
|
||||
void noteAllocFailure(size_t bytes, const char* tag, const char* source) {
|
||||
++g_alloc_failures;
|
||||
const uint32_t count = ++g_alloc_failures;
|
||||
Serial.printf("[MEM] alloc_fail source=%s bytes=%u tag=%s fail_count=%lu\n",
|
||||
source,
|
||||
static_cast<unsigned int>(bytes),
|
||||
(tag != nullptr) ? tag : "n/a",
|
||||
static_cast<unsigned long>(g_alloc_failures));
|
||||
static_cast<unsigned long>(count));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -167,7 +167,7 @@ void CapsAllocator::release(void* ptr) {
|
||||
}
|
||||
|
||||
uint32_t CapsAllocator::failureCount() {
|
||||
return g_alloc_failures;
|
||||
return g_alloc_failures.load();
|
||||
}
|
||||
|
||||
} // namespace memory
|
||||
|
||||
@@ -47,11 +47,13 @@ const char* sectionLabel(PerfSection section) {
|
||||
} // namespace
|
||||
|
||||
void PerfMonitor::reset() {
|
||||
portENTER_CRITICAL(&stats_mux_);
|
||||
for (uint8_t index = 0U; index < static_cast<uint8_t>(PerfSection::kCount); ++index) {
|
||||
sections_[index] = {};
|
||||
}
|
||||
ui_dma_flush_count_ = 0U;
|
||||
ui_sync_flush_count_ = 0U;
|
||||
portEXIT_CRITICAL(&stats_mux_);
|
||||
}
|
||||
|
||||
uint32_t PerfMonitor::beginSample() const {
|
||||
@@ -65,15 +67,18 @@ void PerfMonitor::endSample(PerfSection section, uint32_t started_us) {
|
||||
|
||||
void PerfMonitor::noteUiFlush(bool dma_used, uint32_t elapsed_us) {
|
||||
noteSection(PerfSection::kUiFlush, elapsed_us);
|
||||
portENTER_CRITICAL(&stats_mux_);
|
||||
if (dma_used) {
|
||||
++ui_dma_flush_count_;
|
||||
} else {
|
||||
++ui_sync_flush_count_;
|
||||
}
|
||||
portEXIT_CRITICAL(&stats_mux_);
|
||||
}
|
||||
|
||||
PerfSnapshot PerfMonitor::snapshot() const {
|
||||
PerfSnapshot out = {};
|
||||
portENTER_CRITICAL(&stats_mux_);
|
||||
out.loop = sections_[static_cast<uint8_t>(PerfSection::kLoop)];
|
||||
out.ui_tick = sections_[static_cast<uint8_t>(PerfSection::kUiTick)];
|
||||
out.ui_flush = sections_[static_cast<uint8_t>(PerfSection::kUiFlush)];
|
||||
@@ -82,6 +87,7 @@ PerfSnapshot PerfMonitor::snapshot() const {
|
||||
out.audio_update = sections_[static_cast<uint8_t>(PerfSection::kAudioUpdate)];
|
||||
out.ui_dma_flush_count = ui_dma_flush_count_;
|
||||
out.ui_sync_flush_count = ui_sync_flush_count_;
|
||||
portEXIT_CRITICAL(&stats_mux_);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -112,12 +118,14 @@ void PerfMonitor::noteSection(PerfSection section, uint32_t elapsed_us) {
|
||||
if (index >= static_cast<uint8_t>(PerfSection::kCount)) {
|
||||
return;
|
||||
}
|
||||
portENTER_CRITICAL(&stats_mux_);
|
||||
PerfSectionStats& stats = sections_[index];
|
||||
++stats.count;
|
||||
stats.total_us += static_cast<uint64_t>(elapsed_us);
|
||||
if (elapsed_us > stats.max_us) {
|
||||
stats.max_us = elapsed_us;
|
||||
}
|
||||
portEXIT_CRITICAL(&stats_mux_);
|
||||
}
|
||||
|
||||
uint32_t PerfMonitor::elapsedUs(uint32_t started_us, uint32_t ended_us) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include "core/str_utils.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -13,18 +14,6 @@ constexpr const char* kDefaultWifiSsid = "";
|
||||
constexpr const char* kDefaultWifiPassword = "";
|
||||
constexpr uint16_t kMaxLaToleranceHz = 10U;
|
||||
|
||||
void copyText(char* out, size_t out_size, const char* text) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (text == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, text, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
void toLowerAsciiInPlace(char* text) {
|
||||
if (text == nullptr) {
|
||||
return;
|
||||
@@ -58,7 +47,7 @@ void addEspNowBootPeer(RuntimeNetworkConfig* network_cfg, const char* mac_text)
|
||||
if (network_cfg->espnow_boot_peer_count >= RuntimeNetworkConfig::kMaxEspNowBootPeers) {
|
||||
return;
|
||||
}
|
||||
copyText(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count],
|
||||
core::copyText(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count],
|
||||
sizeof(network_cfg->espnow_boot_peers[network_cfg->espnow_boot_peer_count]),
|
||||
mac_text);
|
||||
++network_cfg->espnow_boot_peer_count;
|
||||
@@ -68,13 +57,13 @@ void resetRuntimeNetworkConfig(RuntimeNetworkConfig* network_cfg) {
|
||||
if (network_cfg == nullptr) {
|
||||
return;
|
||||
}
|
||||
copyText(network_cfg->hostname, sizeof(network_cfg->hostname), kDefaultWifiHostname);
|
||||
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), kDefaultWifiSsid);
|
||||
copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), kDefaultWifiPassword);
|
||||
copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), kDefaultWifiSsid);
|
||||
copyText(network_cfg->local_password, sizeof(network_cfg->local_password), kDefaultWifiPassword);
|
||||
copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), "Freenove-Setup");
|
||||
copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), kDefaultWifiPassword);
|
||||
core::copyText(network_cfg->hostname, sizeof(network_cfg->hostname), kDefaultWifiHostname);
|
||||
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), kDefaultWifiSsid);
|
||||
core::copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), kDefaultWifiPassword);
|
||||
core::copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), kDefaultWifiSsid);
|
||||
core::copyText(network_cfg->local_password, sizeof(network_cfg->local_password), kDefaultWifiPassword);
|
||||
core::copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), "Freenove-Setup");
|
||||
core::copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), kDefaultWifiPassword);
|
||||
network_cfg->force_ap_if_not_local = false;
|
||||
network_cfg->pause_local_retry_when_ap_client = false;
|
||||
network_cfg->local_retry_ms = RuntimeNetworkConfig::kDefaultLocalRetryMs;
|
||||
@@ -138,30 +127,30 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
const bool pause_retry_when_ap_client = config["pause_local_retry_when_ap_client"] | false;
|
||||
const uint32_t local_retry_ms = config["local_retry_ms"] | RuntimeNetworkConfig::kDefaultLocalRetryMs;
|
||||
if (hostname[0] != '\0') {
|
||||
copyText(network_cfg->hostname, sizeof(network_cfg->hostname), hostname);
|
||||
core::copyText(network_cfg->hostname, sizeof(network_cfg->hostname), hostname);
|
||||
}
|
||||
if (local_ssid[0] != '\0') {
|
||||
copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), local_ssid);
|
||||
core::copyText(network_cfg->local_ssid, sizeof(network_cfg->local_ssid), local_ssid);
|
||||
}
|
||||
if (local_password[0] != '\0') {
|
||||
copyText(network_cfg->local_password, sizeof(network_cfg->local_password), local_password);
|
||||
core::copyText(network_cfg->local_password, sizeof(network_cfg->local_password), local_password);
|
||||
}
|
||||
if (test_ssid[0] != '\0') {
|
||||
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), test_ssid);
|
||||
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), test_ssid);
|
||||
}
|
||||
if (test_password[0] != '\0') {
|
||||
copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), test_password);
|
||||
core::copyText(network_cfg->wifi_test_password, sizeof(network_cfg->wifi_test_password), test_password);
|
||||
}
|
||||
if (ap_ssid[0] != '\0') {
|
||||
copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), ap_ssid);
|
||||
core::copyText(network_cfg->ap_default_ssid, sizeof(network_cfg->ap_default_ssid), ap_ssid);
|
||||
}
|
||||
if (ap_password[0] != '\0') {
|
||||
copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), ap_password);
|
||||
core::copyText(network_cfg->ap_default_password, sizeof(network_cfg->ap_default_password), ap_password);
|
||||
}
|
||||
|
||||
if (ap_policy[0] != '\0') {
|
||||
char policy_normalized[32] = {0};
|
||||
copyText(policy_normalized, sizeof(policy_normalized), ap_policy);
|
||||
core::copyText(policy_normalized, sizeof(policy_normalized), ap_policy);
|
||||
toLowerAsciiInPlace(policy_normalized);
|
||||
if (std::strcmp(policy_normalized, "force_if_not_local") == 0) {
|
||||
network_cfg->force_ap_if_not_local = true;
|
||||
@@ -177,10 +166,10 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
}
|
||||
|
||||
if (test_ssid[0] == '\0' && network_cfg->local_ssid[0] != '\0') {
|
||||
copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), network_cfg->local_ssid);
|
||||
core::copyText(network_cfg->wifi_test_ssid, sizeof(network_cfg->wifi_test_ssid), network_cfg->local_ssid);
|
||||
}
|
||||
if (test_password[0] == '\0' && network_cfg->local_password[0] != '\0') {
|
||||
copyText(network_cfg->wifi_test_password,
|
||||
core::copyText(network_cfg->wifi_test_password,
|
||||
sizeof(network_cfg->wifi_test_password),
|
||||
network_cfg->local_password);
|
||||
}
|
||||
@@ -246,7 +235,7 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
}
|
||||
const char* mic_event_name = config["mic_event_name"] | "";
|
||||
if (mic_event_name[0] != '\0') {
|
||||
copyText(hardware_cfg->mic_event_name, sizeof(hardware_cfg->mic_event_name), mic_event_name);
|
||||
core::copyText(hardware_cfg->mic_event_name, sizeof(hardware_cfg->mic_event_name), mic_event_name);
|
||||
}
|
||||
if (config["la_trigger_enabled"].is<bool>()) {
|
||||
hardware_cfg->mic_la_trigger_enabled = config["la_trigger_enabled"].as<bool>();
|
||||
@@ -324,7 +313,7 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
}
|
||||
const char* la_event_name = config["la_event_name"] | "";
|
||||
if (la_event_name[0] != '\0') {
|
||||
copyText(hardware_cfg->mic_la_event_name, sizeof(hardware_cfg->mic_la_event_name), la_event_name);
|
||||
core::copyText(hardware_cfg->mic_la_event_name, sizeof(hardware_cfg->mic_la_event_name), la_event_name);
|
||||
}
|
||||
if (config["battery_enabled"].is<bool>()) {
|
||||
hardware_cfg->battery_enabled = config["battery_enabled"].as<bool>();
|
||||
@@ -338,7 +327,7 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
}
|
||||
const char* battery_event_name = config["battery_low_event_name"] | "";
|
||||
if (battery_event_name[0] != '\0') {
|
||||
copyText(
|
||||
core::copyText(
|
||||
hardware_cfg->battery_low_event_name, sizeof(hardware_cfg->battery_low_event_name), battery_event_name);
|
||||
}
|
||||
} else {
|
||||
@@ -357,7 +346,7 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
}
|
||||
const char* frame_size = config["frame_size"] | "";
|
||||
if (frame_size[0] != '\0') {
|
||||
copyText(camera_cfg->frame_size, sizeof(camera_cfg->frame_size), frame_size);
|
||||
core::copyText(camera_cfg->frame_size, sizeof(camera_cfg->frame_size), frame_size);
|
||||
}
|
||||
if (config["jpeg_quality"].is<unsigned int>()) {
|
||||
camera_cfg->jpeg_quality = static_cast<uint8_t>(config["jpeg_quality"].as<unsigned int>());
|
||||
@@ -370,7 +359,7 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
}
|
||||
const char* snapshot_dir = config["snapshot_dir"] | "";
|
||||
if (snapshot_dir[0] != '\0') {
|
||||
copyText(camera_cfg->snapshot_dir, sizeof(camera_cfg->snapshot_dir), snapshot_dir);
|
||||
core::copyText(camera_cfg->snapshot_dir, sizeof(camera_cfg->snapshot_dir), snapshot_dir);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[CAM] APP_CAMERA invalid json (%s)\n", error.c_str());
|
||||
@@ -406,13 +395,13 @@ void RuntimeConfigService::load(StorageManager& storage,
|
||||
const char* picture_dir = config["picture_dir"] | "";
|
||||
const char* record_dir = config["record_dir"] | "";
|
||||
if (music_dir[0] != '\0') {
|
||||
copyText(media_cfg->music_dir, sizeof(media_cfg->music_dir), music_dir);
|
||||
core::copyText(media_cfg->music_dir, sizeof(media_cfg->music_dir), music_dir);
|
||||
}
|
||||
if (picture_dir[0] != '\0') {
|
||||
copyText(media_cfg->picture_dir, sizeof(media_cfg->picture_dir), picture_dir);
|
||||
core::copyText(media_cfg->picture_dir, sizeof(media_cfg->picture_dir), picture_dir);
|
||||
}
|
||||
if (record_dir[0] != '\0') {
|
||||
copyText(media_cfg->record_dir, sizeof(media_cfg->record_dir), record_dir);
|
||||
core::copyText(media_cfg->record_dir, sizeof(media_cfg->record_dir), record_dir);
|
||||
}
|
||||
if (config["record_max_seconds"].is<unsigned int>()) {
|
||||
media_cfg->record_max_seconds = static_cast<uint16_t>(config["record_max_seconds"].as<unsigned int>());
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include "runtime/simd/simd_accel.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
|
||||
#include "runtime/memory/caps_allocator.h"
|
||||
@@ -16,6 +16,7 @@
|
||||
#endif
|
||||
|
||||
#if (UI_SIMD_USE_ESP_DSP != 0) && __has_include(<dsps_mul.h>)
|
||||
#include <dsps_add.h>
|
||||
#include <dsps_mul.h>
|
||||
#define UI_SIMD_HAS_ESP_DSP 1
|
||||
#else
|
||||
@@ -33,12 +34,17 @@ constexpr uint32_t kBenchMaxPixels = 8192U;
|
||||
constexpr uint32_t kBenchMinLoops = 1U;
|
||||
constexpr uint32_t kBenchMaxLoops = 5000U;
|
||||
|
||||
// Gray LUT — built once, guarded by atomic flag (acquire/release semantics).
|
||||
uint16_t g_gray8_to_rgb565[256] = {};
|
||||
bool g_gray_lut_ready = false;
|
||||
SimdAccelStatus g_status = {
|
||||
};
|
||||
std::atomic<bool> g_gray_lut_ready{false};
|
||||
|
||||
SimdAccelStatus g_status = {};
|
||||
bool g_status_initialized = false;
|
||||
|
||||
// ============================================================================
|
||||
// Internal helpers
|
||||
// ============================================================================
|
||||
|
||||
void initStatusDefaults() {
|
||||
g_status.simd_path_enabled = kSimdPathEnabled;
|
||||
g_status.esp_dsp_enabled = (UI_SIMD_HAS_ESP_DSP != 0);
|
||||
@@ -53,46 +59,50 @@ void ensureStatusInitialized() {
|
||||
}
|
||||
|
||||
inline uint8_t clampU8(int32_t value) {
|
||||
if (value < 0) {
|
||||
return 0U;
|
||||
}
|
||||
if (value > 255) {
|
||||
return 255U;
|
||||
}
|
||||
if (value < 0) return 0U;
|
||||
if (value > 255) return 255U;
|
||||
return static_cast<uint8_t>(value);
|
||||
}
|
||||
|
||||
inline int16_t clampS16(int32_t value) {
|
||||
if (value < -32768) {
|
||||
return -32768;
|
||||
}
|
||||
if (value > 32767) {
|
||||
return 32767;
|
||||
}
|
||||
if (value < -32768) return -32768;
|
||||
if (value > 32767) return 32767;
|
||||
return static_cast<int16_t>(value);
|
||||
}
|
||||
|
||||
inline uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
|
||||
const uint16_t red = static_cast<uint16_t>((r & 0xF8U) << 8U);
|
||||
const uint16_t green = static_cast<uint16_t>((g & 0xFCU) << 3U);
|
||||
const uint16_t blue = static_cast<uint16_t>(b >> 3U);
|
||||
return static_cast<uint16_t>(red | green | blue);
|
||||
return static_cast<uint16_t>(
|
||||
(static_cast<uint16_t>((r & 0xF8U) << 8U)) |
|
||||
(static_cast<uint16_t>((g & 0xFCU) << 3U)) |
|
||||
(static_cast<uint16_t>( b >> 3U)));
|
||||
}
|
||||
|
||||
// Decompose RGB565 into 8-bit channels.
|
||||
inline void unpack565(uint16_t px, uint8_t& r, uint8_t& g, uint8_t& b) {
|
||||
r = static_cast<uint8_t>((px >> 8U) & 0xF8U);
|
||||
g = static_cast<uint8_t>((px >> 3U) & 0xFCU);
|
||||
b = static_cast<uint8_t>((px << 3U) & 0xF8U);
|
||||
}
|
||||
|
||||
// Thread-safe lazy LUT initialisation (acquire/release).
|
||||
void ensureGrayLut() {
|
||||
if (g_gray_lut_ready) {
|
||||
if (g_gray_lut_ready.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
for (uint16_t g = 0U; g < 256U; ++g) {
|
||||
g_gray8_to_rgb565[g] = rgb565(static_cast<uint8_t>(g), static_cast<uint8_t>(g), static_cast<uint8_t>(g));
|
||||
for (uint16_t i = 0U; i < 256U; ++i) {
|
||||
g_gray8_to_rgb565[i] = rgb565(static_cast<uint8_t>(i),
|
||||
static_cast<uint8_t>(i),
|
||||
static_cast<uint8_t>(i));
|
||||
}
|
||||
g_gray_lut_ready = true;
|
||||
g_gray_lut_ready.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audio scalar kernels
|
||||
// ============================================================================
|
||||
|
||||
void gainQ15Scalar(int16_t* dst, const int16_t* src, int16_t gain_q15, size_t n) {
|
||||
if (dst == nullptr || src == nullptr || n == 0U) {
|
||||
return;
|
||||
}
|
||||
if (dst == nullptr || src == nullptr || n == 0U) return;
|
||||
for (size_t i = 0U; i < n; ++i) {
|
||||
const int32_t mul = static_cast<int32_t>(src[i]) * static_cast<int32_t>(gain_q15);
|
||||
const int32_t rounded = (mul >= 0) ? (mul + (1 << 14)) : (mul - (1 << 14));
|
||||
@@ -106,37 +116,31 @@ void mixQ15Scalar(int16_t* dst,
|
||||
int16_t ga_q15,
|
||||
int16_t gb_q15,
|
||||
size_t n) {
|
||||
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) {
|
||||
return;
|
||||
}
|
||||
if (dst == nullptr || a == nullptr || b == nullptr || n == 0U) return;
|
||||
for (size_t i = 0U; i < n; ++i) {
|
||||
const int32_t acc = static_cast<int32_t>(a[i]) * static_cast<int32_t>(ga_q15) +
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user