Add QR Unlock App implementation and schema

- Implemented QrUnlockApp class with methods for lifecycle management (begin, start, update, stop, handleEvent) and snapshot retrieval.
- Created header file for QrUnlockApp with necessary method declarations and private members.
- Added Yamale schema for scenario template to define prompt input and optional firmware snapshot.
This commit is contained in:
Clément SAILLANT
2026-02-25 17:21:15 +01:00
parent d4ccd49b3f
commit cdbbed3fd0
57 changed files with 13647 additions and 313 deletions
@@ -0,0 +1,366 @@
id: DEFAULT
version: 2
initial_step: SCENE_U_SON_PROTO
app_bindings:
- id: APP_AUDIO
app: AUDIO_PACK
- id: APP_SCREEN
app: SCREEN_SCENE
- id: APP_GATE
app: MP3_GATE
- id: APP_WIFI
app: WIFI_STACK
- id: APP_ESPNOW
app: ESPNOW_STACK
- id: APP_QR_UNLOCK
app: QR_UNLOCK_APP
steps:
- step_id: SCENE_U_SON_PROTO
screen_scene_id: SCENE_U_SON_PROTO
audio_pack_id: PACK_BOOT_RADIO
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: SCENE_U_SON_PROTO
after_ms: 0
priority: 80
- trigger: on_event
event_type: button
event_name: ANY
target_step_id: SCENE_LA_DETECTOR
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_ETAPE2
target_step_id: SCENE_LA_DETECTOR
after_ms: 0
priority: 140
- step_id: SCENE_LA_DETECTOR
screen_scene_id: SCENE_LA_DETECTOR
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_QUEUE_SONAR
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: timer
event_name: ETAPE2_DUE
target_step_id: SCENE_U_SON_PROTO
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 110
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 115
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE1
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 130
- step_id: RTC_ESP_ETAPE1
screen_scene_id: SCENE_WIN_ETAPE1
audio_pack_id: PACK_CONFIRM_WIN_ETAPE1
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
- ACTION_ESP_NOW_SEND_ETAPE1
- ACTION_QUEUE_SONAR
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: espnow
event_name: ACK_WIN1
target_step_id: WIN_ETAPE1
after_ms: 0
priority: 130
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: WIN_ETAPE1
after_ms: 0
priority: 120
- step_id: WIN_ETAPE1
screen_scene_id: SCENE_WIN_ETAPE1
audio_pack_id: PACK_WIN
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_WARNING
after_ms: 0
priority: 110
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: STEP_WARNING
after_ms: 0
priority: 120
- trigger: on_event
event_type: espnow
event_name: ACK_WARNING
target_step_id: STEP_WARNING
after_ms: 0
priority: 130
- step_id: STEP_WARNING
screen_scene_id: SCENE_WARNING
audio_pack_id: PACK_BOOT_RADIO
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_WARNING
after_ms: 0
priority: 80
- trigger: on_event
event_type: button
event_name: ANY
target_step_id: SCENE_LEFOU_DETECTOR
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_ETAPE2
target_step_id: SCENE_LEFOU_DETECTOR
after_ms: 0
priority: 140
- step_id: SCENE_LEFOU_DETECTOR
screen_scene_id: SCENE_LEFOU_DETECTOR
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_QUEUE_SONAR
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: timer
event_name: ETAPE2_DUE
target_step_id: STEP_WARNING
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 110
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 115
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE2
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 130
- step_id: RTC_ESP_ETAPE2
screen_scene_id: SCENE_WIN_ETAPE2
audio_pack_id: PACK_CONFIRM_WIN_ETAPE2
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
- ACTION_ESP_NOW_SEND_ETAPE2
- ACTION_QUEUE_SONAR
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: espnow
event_name: ACK_WIN2
target_step_id: SCENE_QR_DETECTOR
after_ms: 0
priority: 130
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: SCENE_QR_DETECTOR
after_ms: 0
priority: 120
- step_id: SCENE_QR_DETECTOR
screen_scene_id: SCENE_QR_DETECTOR
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_QR_CODE_SCANNER_START
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
- APP_QR_UNLOCK
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: serial
event_name: QR_OK
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 140
- trigger: on_event
event_type: unlock
event_name: UNLOCK_QR
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 150
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 110
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE2
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 130
- step_id: SCENE_FINAL_WIN
screen_scene_id: SCENE_FINAL_WIN
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_WINNER
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: timer
event_name: WIN_DUE
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 140
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 110
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 120
- trigger: on_event
event_type: action
event_name: FORCE_WIN_ETAPE2
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 130
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE2
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 125
- step_id: STEP_MEDIA_MANAGER
screen_scene_id: SCENE_MEDIA_MANAGER
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_SET_BOOT_MEDIA_MANAGER
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: true
transitions: []
+109 -70
View File
@@ -1,9 +1,10 @@
# Scenario reel - template lisible + promptable.
#
# Comment l'utiliser:
# 1) Edite uniquement la section `prompt_input`.
# 2) Laisse `current_firmware_snapshot` comme reference de l'etat actuel code.
# 3) Envoie ce fichier a Codex: je convertis ensuite vers le YAML canonique + JSON runtime.
# Usage rapide:
# 1) Edite surtout `prompt_input`.
# 2) Si tu changes `steps_runtime_order`, garde la syntaxe canonique:
# <event_type>:<event_name>-><target_step_id>
# 3) Les ids de scenes/actions/packs doivent exister dans `data/story/**`.
prompt_input:
meta:
@@ -19,15 +20,18 @@ prompt_input:
qr_rule:
enabled: true
scene_id: "SCENE_CAMERA_SCAN"
scene_id: "SCENE_QR_DETECTOR"
expected_payload: "A_COMPLETER"
match_mode: "exact" # exact | prefix | contains
case_insensitive: false
success_event: "QR_OK"
success_events:
- "serial:QR_OK"
- "unlock:UNLOCK_QR"
failure_event: "QR_INVALID"
media_hub:
enabled: true
hub_step_id: "STEP_MEDIA_MANAGER"
hub_scene_id: "SCENE_MEDIA_MANAGER"
entries:
- "SCENE_PHOTO_MANAGER"
@@ -42,26 +46,24 @@ prompt_input:
rgb: "18/45/95"
scene_overrides:
- scene_id: "SCENE_LA_DETECTOR"
rgb: "dynamic_tuner_or_off"
rgb: "32/224/170"
custom_steps:
# Ajoute, retire, ou reordonne des steps ici.
# Format cible:
# - step_id: "STEP_X"
# scene: "SCENE_X"
# audio_pack: ""
# actions: ["ACTION_TRACE_STEP"]
# mp3_gate_open: false
# transitions:
# - "serial:BTN_NEXT->STEP_Y"
- "A_COMPLETER"
serial_test_plan:
- "SC_LOAD DEFAULT"
- "SCENE_GOTO SCENE_LOCKED"
- "SCENE_GOTO SCENE_CAMERA_SCAN"
- "SCENE_GOTO SCENE_QR_DETECTOR"
- "QR_SIM A_COMPLETER"
- "SC_EVENT espnow QR_OK"
- "SC_EVENT espnow ACK_WIN1"
- "SC_EVENT button ANY"
- "BOOT_MODE_STATUS"
- "RESET"
@@ -73,104 +75,141 @@ prompt_input:
- "Boot mode media_manager persistant apres QR valide"
notes:
- "Contraintes gameplay reelles a decrire ici."
- "Ajouter ici les exceptions ou aliases scene."
current_firmware_snapshot:
source_of_truth_files:
- "docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml"
- "data/story/scenarios/DEFAULT.json"
- "Decrire ici contraintes gameplay reelles."
- "Ajouter exceptions/aliases scene si necessaire."
scenario:
id: "DEFAULT"
version: 2
initial_step: "STEP_WAIT_UNLOCK"
initial_step: "SCENE_U_SON_PROTO"
app_bindings:
- "APP_AUDIO"
- "APP_SCREEN"
- "APP_GATE"
- "APP_WIFI"
- "APP_ESPNOW"
- "APP_QR_UNLOCK"
steps_runtime_order:
- step_id: "STEP_WAIT_UNLOCK"
screen_scene_id: "SCENE_LOCKED"
audio_pack_id: ""
actions:
- "ACTION_TRACE_STEP"
- "ACTION_HW_LED_READY"
transitions:
- "unlock:UNLOCK->STEP_U_SON_PROTO"
- "serial:BTN_NEXT->STEP_WAIT_ETAPE2"
- step_id: "STEP_U_SON_PROTO"
screen_scene_id: "SCENE_BROKEN"
- step_id: "SCENE_U_SON_PROTO"
screen_scene_id: "SCENE_U_SON_PROTO"
audio_pack_id: "PACK_BOOT_RADIO"
actions:
- "ACTION_TRACE_STEP"
- "ACTION_HW_LED_ALERT"
transitions:
- "audio_done:AUDIO_DONE->STEP_WAIT_ETAPE2"
- "serial:BTN_NEXT->STEP_WAIT_ETAPE2"
- "serial:FORCE_ETAPE2->STEP_ETAPE2"
- "audio_done:AUDIO_DONE->SCENE_U_SON_PROTO"
- "button:ANY->SCENE_LA_DETECTOR"
- "serial:FORCE_ETAPE2->SCENE_LA_DETECTOR"
- step_id: "STEP_WAIT_ETAPE2"
- step_id: "SCENE_LA_DETECTOR"
screen_scene_id: "SCENE_LA_DETECTOR"
audio_pack_id: ""
actions:
- "ACTION_TRACE_STEP"
- "ACTION_QUEUE_SONAR"
- "ACTION_CAMERA_SNAPSHOT"
- "ACTION_REC_START"
transitions:
- "timer:ETAPE2_DUE->STEP_ETAPE2"
- "serial:BTN_NEXT->STEP_ETAPE2"
- "unlock:UNLOCK->STEP_ETAPE2"
- "action:ACTION_FORCE_ETAPE2->STEP_ETAPE2"
- "serial:FORCE_ETAPE2->STEP_ETAPE2"
- "timer:ETAPE2_DUE->SCENE_U_SON_PROTO"
- "serial:BTN_NEXT->RTC_ESP_ETAPE1"
- "unlock:UNLOCK->RTC_ESP_ETAPE1"
- "action:ACTION_FORCE_ETAPE2->RTC_ESP_ETAPE1"
- "serial:FORCE_WIN_ETAPE1->RTC_ESP_ETAPE1"
- step_id: "STEP_ETAPE2"
screen_scene_id: "SCENE_WIN_ETAPE"
- step_id: "RTC_ESP_ETAPE1"
screen_scene_id: "SCENE_WIN_ETAPE1"
audio_pack_id: "PACK_CONFIRM_WIN_ETAPE1"
actions:
- "ACTION_TRACE_STEP"
- "ACTION_HW_LED_ALERT"
- "ACTION_ESP_NOW_SEND_ETAPE1"
- "ACTION_QUEUE_SONAR"
transitions:
- "espnow:ACK_WIN1->WIN_ETAPE1"
- "serial:FORCE_DONE->WIN_ETAPE1"
- step_id: "WIN_ETAPE1"
screen_scene_id: "SCENE_WIN_ETAPE1"
audio_pack_id: "PACK_WIN"
actions:
- "ACTION_TRACE_STEP"
- "ACTION_HW_LED_ALERT"
transitions:
- "serial:BTN_NEXT->STEP_DONE"
- "serial:FORCE_DONE->STEP_DONE"
- "serial:BTN_NEXT->STEP_WARNING"
- "serial:FORCE_DONE->STEP_WARNING"
- "espnow:ACK_WARNING->STEP_WARNING"
- step_id: "STEP_DONE"
screen_scene_id: "SCENE_CAMERA_SCAN"
- step_id: "STEP_WARNING"
screen_scene_id: "SCENE_WARNING"
audio_pack_id: "PACK_BOOT_RADIO"
actions:
- "ACTION_TRACE_STEP"
- "ACTION_HW_LED_ALERT"
transitions:
- "audio_done:AUDIO_DONE->STEP_WARNING"
- "button:ANY->SCENE_LEFOU_DETECTOR"
- "serial:FORCE_ETAPE2->SCENE_LEFOU_DETECTOR"
- step_id: "SCENE_LEFOU_DETECTOR"
screen_scene_id: "SCENE_LEFOU_DETECTOR"
audio_pack_id: ""
actions:
- "ACTION_TRACE_STEP"
- "ACTION_REC_STOP"
- "ACTION_HW_LED_READY"
- "ACTION_REFRESH_SD"
- "ACTION_QUEUE_SONAR"
transitions:
- "serial:QR_OK->STEP_MEDIA_MANAGER"
- "espnow:QR_OK->STEP_MEDIA_MANAGER"
- "timer:ETAPE2_DUE->STEP_WARNING"
- "serial:BTN_NEXT->RTC_ESP_ETAPE2"
- "unlock:UNLOCK->RTC_ESP_ETAPE2"
- "action:ACTION_FORCE_ETAPE2->RTC_ESP_ETAPE2"
- "serial:FORCE_WIN_ETAPE2->RTC_ESP_ETAPE2"
- step_id: "RTC_ESP_ETAPE2"
screen_scene_id: "SCENE_WIN_ETAPE2"
audio_pack_id: "PACK_CONFIRM_WIN_ETAPE2"
actions:
- "ACTION_TRACE_STEP"
- "ACTION_HW_LED_ALERT"
- "ACTION_ESP_NOW_SEND_ETAPE2"
- "ACTION_QUEUE_SONAR"
transitions:
- "espnow:ACK_WIN2->SCENE_QR_DETECTOR"
- "serial:FORCE_DONE->SCENE_QR_DETECTOR"
- step_id: "SCENE_QR_DETECTOR"
screen_scene_id: "SCENE_QR_DETECTOR"
audio_pack_id: ""
actions:
- "ACTION_TRACE_STEP"
- "ACTION_QR_CODE_SCANNER_START"
transitions:
- "serial:QR_OK->SCENE_FINAL_WIN"
- "unlock:UNLOCK_QR->SCENE_FINAL_WIN"
- "serial:BTN_NEXT->SCENE_FINAL_WIN"
- "action:ACTION_FORCE_ETAPE2->SCENE_FINAL_WIN"
- "serial:FORCE_WIN_ETAPE2->SCENE_FINAL_WIN"
- step_id: "SCENE_FINAL_WIN"
screen_scene_id: "SCENE_FINAL_WIN"
audio_pack_id: ""
actions:
- "ACTION_TRACE_STEP"
- "ACTION_WINNER"
transitions:
- "timer:WIN_DUE->STEP_MEDIA_MANAGER"
- "serial:BTN_NEXT->STEP_MEDIA_MANAGER"
- "unlock:UNLOCK->STEP_MEDIA_MANAGER"
- "action:FORCE_WIN_ETAPE2->STEP_MEDIA_MANAGER"
- "serial:FORCE_WIN_ETAPE2->STEP_MEDIA_MANAGER"
- step_id: "STEP_MEDIA_MANAGER"
screen_scene_id: "SCENE_MEDIA_MANAGER"
audio_pack_id: ""
actions:
- "ACTION_TRACE_STEP"
- "ACTION_SET_BOOT_MEDIA"
- "ACTION_HW_LED_READY"
mp3_gate_open: true
- "ACTION_SET_BOOT_MEDIA_MANAGER"
transitions: []
scene_catalog_runtime:
- scene_id: "SCENE_LOCKED"
hal: "camera=0 mic=0 amp=0 ws2812=1 led_auto=1"
- scene_id: "SCENE_LA_DETECTOR"
hal: "camera=0 mic=1 amp=0 ws2812=1 led_auto=1"
- scene_id: "SCENE_WIN_ETAPE"
hal: "camera=0 mic=0 amp=0 ws2812=1 led_auto=1"
- scene_id: "SCENE_MP3_PLAYER"
hal: "camera=0 mic=1 amp=1 ws2812=1 led_auto=1 rgb=18/45/95"
- scene_id: "SCENE_CAMERA_SCAN"
hal: "camera=0 mic=1 amp=0 ws2812=1 led_auto=1 rgb=18/45/95"
- scene_id: "SCENE_MEDIA_MANAGER"
hal: "camera=0 mic=0 amp=0 ws2812=1 led_auto=1 rgb=18/45/95"
current_firmware_snapshot:
source_to_update_files:
- "game/scenarios/default_unlock_win_etape2.yaml"
- "docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml"
- "data/story/scenarios/DEFAULT.json"
+393
View File
@@ -0,0 +1,393 @@
id: zacus_v2
version: 3
title: "Le mystère du Professeur Zacus — Version Réelle (U-SON, piano LEFOU, QR WIN)"
theme: "Enquête scientifique + stabilisation audio + épreuve Zone 4 + déverrouillage final"
players:
min: 6
max: 14
ages: "614 ans (911 recommandés)"
duration:
act_1_minutes: 45
act_2_minutes: 60
total_minutes: 105
# ============================================================
# RÉALITÉ FIRMWARE (aligné pack zip / runtime DEFAULT)
# ============================================================
firmware:
board_target: freenove_esp32s3
engine: story_engine_v2
runtime_scenario_id: "DEFAULT"
boot_policy:
startup_mode: story
persist_after_validation: true
rollback_command_required: true
after_success_boot: media_manager
qr_rule:
enabled: true
scene_id: "SCENE_QR_DETECTOR"
expected_payload: "WIN"
match_mode: exact
case_insensitive: true
success_event: "UNLOCK_QR"
failure_event: "QR_INVALID"
timeout_seconds: 30
timeout_event: "QR_TIMEOUT"
media_hub:
enabled: true
hub_scene_id: "SCENE_MEDIA_MANAGER"
entries:
- "SCENE_PHOTO_MANAGER"
- "SCENE_MP3_PLAYER"
- "SCENE_READY"
steps_reference_order:
- STEP_U_SON_PROTO
- STEP_LA_DETECTOR
- STEP_RTC_ESP_ETAPE1
- STEP_WIN_ETAPE1
- STEP_WARNING
- STEP_LEFOU_DETECTOR
- STEP_RTC_ESP_ETAPE2
- STEP_QR_DETECTOR
- STEP_FINAL_WIN
# ============================================================
# CANON NARRATIF (réalité terrain)
# ============================================================
canon:
introduction: >
Le Professeur Zacus a disparu après avoir déclenché une alerte cryptée.
Le prototype U-SON (un module sonore expérimental) a commencé à “dériver” :
ses signaux deviennent instables, comme si quelque chose perturbait le Campus.
Pour protéger U-SON, l’équipe doit prouver deux choses :
(1) stabiliser une référence scientifique (LA 440 Hz),
(2) respecter la règle de Zone 4 — linstallation personnelle de l’Électron Fou —
avant de récupérer la clé finale (QR “WIN”) cachée aux Archives.
stakes: >
Si l’équipe échoue, le Campus reste en mode sécurité et U-SON demeure instable.
Le vrai test nest pas la vitesse : cest la coopération, la précision et le calme.
lore_truths:
- "U-SON est le prototype à protéger (pas Auréole)."
- "Zone 4 : les générateurs de fréquence appartiennent à l’Électron Fou (installation personnelle)."
- "La règle de Zone 4 se valide par un code musical sur piano-alphabet."
reveal_finale: >
Zacus réapparaît : ce n’était pas une punition mais un test d’éthique.
U-SON ne doit pas devenir un objet de panique.
L’équipe gagne parce quelle a fait juste : stabilité, règle, entraide.
# ============================================================
# STRUCTURE EN 2 ACTES (45 + 60)
# ============================================================
acts:
- act: 1
title: "Acte 1 — Stabiliser U-SON (LA 440) (≈45 min)"
goal: "Valider Étape 1 via la référence LA 440 Hz."
runtime_steps:
- STEP_U_SON_PROTO
- STEP_LA_DETECTOR
- STEP_RTC_ESP_ETAPE1
- STEP_WIN_ETAPE1
- act: 2
title: "Acte 2 — Zone 4 (piano LEFOU) + Archives (QR WIN) (≈60 min)"
goal: "Valider Étape 2 via piano-alphabet (LEFOU), puis scanner QR WIN derrière le portrait."
runtime_steps:
- STEP_WARNING
- STEP_LEFOU_DETECTOR
- STEP_RTC_ESP_ETAPE2
- STEP_QR_DETECTOR
- STEP_FINAL_WIN
# ============================================================
# STATIONS (terrain)
# ============================================================
stations:
- id: STATION_ONDES
act: 1
name: "Atelier des Ondes"
focus: "Stabiliser la référence LA 440 Hz pour recaler U-SON."
firmware_anchor_step: "STEP_LA_DETECTOR"
clue: "La force ne sert à rien. Seule la stabilité ouvre le premier verrou."
- id: STATION_ZONE4_PIANO
act: 2
name: "Zone 4 — Studio de Résonance (Piano Alphabet)"
focus: "Jouer 5 lettres sur le piano-alphabet pour valider laccès."
firmware_anchor_step: "STEP_LEFOU_DETECTOR"
clue: "Le Fou ne valide pas la force. Il valide son nom."
piano_rule_summary: "Jouer L-E-F-O-U (5 notes)."
- id: STATION_ARCHIVES_QR
act: 2
name: "Salle des Archives"
focus: "Trouver et scanner la clé finale."
firmware_anchor_step: "STEP_QR_DETECTOR"
qr_location_real: "Derrière le portrait (cadre) dans la Salle des Archives."
clue: "Regarde là où Zacus surveille toujours."
# ============================================================
# PUZZLES (réels)
# ============================================================
puzzles:
- id: PUZZLE_LA_440
act: 1
type: "audio / stabilisation"
objective: "Produire un LA 440 Hz stable."
firmware_step: "STEP_LA_DETECTOR"
success_condition: >
Le son doit rester stable quelques secondes ; l’écran/LED confirme lalignement.
Ensuite, la transmission Étape 1 peut être validée (ACK_WIN1).
- id: PUZZLE_PIANO_ALPHABET_5
act: 2
type: "piano / code lettres"
objective: "Jouer 5 lettres sur le piano-alphabet."
firmware_step: "STEP_LEFOU_DETECTOR"
rule_real: >
Les 26 lettres (A à Z) sont indiquées sur les touches blanches (stickers).
On joue la séquence de 5 lettres dans lordre.
code_word_5_letters: "LEFOU"
sequence_letters: ["L","E","F","O","U"]
hint_logic: >
La clé de Zone 4 est le nom du propriétaire : l’Électron Fou.
“Son nom” = LEFOU.
validation_mode: >
Validation “réelle terrain” : le MJ écoute/observe la séquence,
puis déclenche la transition (unlock / BTN_NEXT / serial) vers la confirmation Étape 2.
- id: PUZZLE_QR_WIN
act: 2
type: "recherche / scan"
objective: "Scanner le QR final."
firmware_step: "STEP_QR_DETECTOR"
qr_payload: "WIN"
location: "Derrière le portrait aux Archives"
fail_safe: "Timeout 30s → QR_TIMEOUT → retour étape précédente + backup MJ."
# ============================================================
# DÉROULÉ PAR STEP (narratif aligné runtime)
# ============================================================
steps_narrative:
- step_id: STEP_U_SON_PROTO
scene: SCENE_U_SON_PROTO
act: 1
timebox_minutes: "58"
narrative: >
Boot radio. Transmission tronquée.
Zacus annonce que U-SON dérive et quil faut une référence stable pour recaler le système.
- step_id: STEP_LA_DETECTOR
scene: SCENE_LA_DETECTOR
act: 1
timebox_minutes: "3035"
narrative: >
Atelier des Ondes. Objectif : produire un LA 440 stable.
Le groupe sorganise : une personne fait le son, dautres surveillent le feedback.
- step_id: STEP_RTC_ESP_ETAPE1
scene: SCENE_WIN_ETAPE1
act: 1
timebox_minutes: "24"
narrative: >
Transmission distante (ESP-NOW). LACK confirme la validation Étape 1.
- step_id: STEP_WIN_ETAPE1
scene: SCENE_WIN_ETAPE1
act: 1
timebox_minutes: "58"
narrative: >
Victoire 1. Puis le campus bascule en WARNING : direction Zone 4.
- step_id: STEP_WARNING
scene: SCENE_WARNING
act: 2
timebox_minutes: "510"
narrative: >
Alerte rouge. Message clair : Zone 4, linstallation de l’Électron Fou.
Le système annonce quun code musical (5 lettres) sera requis.
- step_id: STEP_LEFOU_DETECTOR
scene: SCENE_LEFOU_DETECTOR
act: 2
timebox_minutes: "2535"
narrative: >
Zone 4. Piano-alphabet : les lettres sont sur les touches blanches.
L’équipe doit jouer LEFOU (L-E-F-O-U) pour prouver quelle a compris la règle.
- step_id: STEP_RTC_ESP_ETAPE2
scene: SCENE_WIN_ETAPE2
act: 2
timebox_minutes: "24"
narrative: >
Confirmation Étape 2. Le système donne la destination finale : les Archives,
et révèle que la clé est derrière le portrait.
- step_id: STEP_QR_DETECTOR
scene: SCENE_QR_DETECTOR
act: 2
timebox_minutes: "1015"
narrative: >
Aux Archives : chercher derrière le portrait, récupérer le QR, et le scanner.
QR “WIN” valide laccès final.
- step_id: STEP_FINAL_WIN
scene: SCENE_FINAL_WIN
act: 2
timebox_minutes: "58"
narrative: >
Victoire + révélation.
Récompense : bascule vers le Media Hub, et boot persistant après QR validé.
# ============================================================
# AUDIO (textes exacts + prompts TTS + FX)
# ============================================================
audio:
global_style:
audience: "enfants 911 (compréhensible 614)"
rules: ["phrases courtes", "rassurant", "pas anxiogène", "consignes claires"]
fx_profiles:
radio: "bandpass + souffle léger + bip"
system: "neutre + bip OK"
alarm: "bips rapides + voix urgente mais calme"
packs:
- pack_id: PACK_BOOT_RADIO
voice: "Professeur Zacus (calme, scientifique, rassurant)"
fx: [radio]
tracks:
- track_id: boot_intro
script_fr: >
Équipe Zacus… ici le Professeur Zacus.
Le prototype U-SON dérive. Les capteurs ne sont plus fiables.
Pour stabiliser U-SON, il vous faut une référence : le LA, quatre-cent-quarante hertz.
Rendez-vous à lAtelier des Ondes.
Gardez votre calme.
tts_prompt_excerpt: >
Voix adulte calme, rassurante, scientifique. Phrases courtes.
Effets: bip début + souffle léger + bandpass radio.
- track_id: warning_to_zone4
script_fr: >
Alerte.
Prochaine étape : Zone 4.
Linstallation de l’Électron Fou ne valide pas la force.
Elle valide une règle… au piano.
Cinq lettres. Cinq notes.
tts_prompt_excerpt: >
Voix plus urgente mais pas anxiogène.
Effets: bips rapides dalarme, puis radio léger.
step_map:
STEP_U_SON_PROTO: boot_intro
STEP_WARNING: warning_to_zone4
- pack_id: PACK_CONFIRM_WIN_ETAPE1
voice: "Système (neutre, clair)"
fx: [system]
tracks:
- track_id: confirm_win1
script_fr: >
Signal stabilisé.
Étape un validée.
Liaison confirmée.
tts_prompt_excerpt: "Voix neutre, très intelligible. Ajouter bip OK."
step_map:
STEP_RTC_ESP_ETAPE1: confirm_win1
- pack_id: PACK_WIN
voice: "Système positif (court)"
fx: [system]
tracks:
- track_id: win1_jingle
script_fr: >
Bravo équipe Zacus.
Première barrière franchie.
tts_prompt_excerpt: "Positif mais sobre. Mini jingle rétro discret."
step_map:
STEP_WIN_ETAPE1: win1_jingle
- pack_id: PACK_ZONE4_PIANO_HINT
optional: true
voice: "Système (didactique)"
fx: [system]
tracks:
- track_id: zone4_rule_soft
script_fr: >
Règle Zone 4 :
Les lettres sont sur les touches blanches.
Le code, cest le nom du propriétaire.
Cinq lettres.
tts_prompt_excerpt: "Très clair, pas trop long. Bip OK."
- track_id: zone4_rule_hard
script_fr: >
Indice :
Jouez… L. E. F. O. U.
tts_prompt_excerpt: "Indice secours (hard). Très lent, très clair."
note: "Optionnel : utilisable si le groupe bloque."
- pack_id: PACK_CONFIRM_WIN_ETAPE2
voice: "Système (neutre + indice final)"
fx: [system]
tracks:
- track_id: confirm_win2_archives
script_fr: >
Étape deux validée.
Rendez-vous aux Archives.
La clé finale est derrière le portrait.
tts_prompt_excerpt: "Neutre, très clair sur lindice. Ajouter bip OK."
step_map:
STEP_RTC_ESP_ETAPE2: confirm_win2_archives
- pack_id: PACK_FINAL_REVEAL
optional: true
voice: "Professeur Zacus (chaleureux, fier)"
fx: [radio]
tracks:
- track_id: final_reveal
script_fr: >
Ici Zacus.
Vous avez tenu la ligne.
U-SON ne demande pas la force…
mais la stabilité, le calme, et le bon sens.
Merci. Vous avez protégé le prototype.
tts_prompt_excerpt: "Chaleureux, fier, rassurant. Radio léger + fade-out."
step_map:
STEP_FINAL_WIN: final_reveal
# ============================================================
# QR (réalité + backup)
# ============================================================
qr:
payload: "WIN"
location: "Derrière le portrait dans la Salle des Archives"
lead_in_clue: "Regarde là où Zacus surveille toujours."
backups:
- "Second QR WIN dans la boîte MJ"
- "Commande MJ: QR_SIM WIN (si caméra capricieuse)"
# ============================================================
# PROPS / SETUP
# ============================================================
props_and_assets:
piano_alphabet:
description: "26 lettres AZ étiquetées sur les touches blanches (stickers)."
placement_note: >
Utiliser 26 touches blanches consécutives (gauche→droite) et coller A→Z.
Lordre physique est celui des stickers, pas celui de la musique.
required_word: "LEFOU"
portrait_archives:
description: "Portrait (cadre) dans la Salle des Archives."
behind_portrait: "QR WIN imprimé (et scotch repositionnable)."
notes: >
Cette version est 100% alignée avec la réalité “2 étapes + QR + Media Hub”.
Si tu veux, je peux aussi te générer la fiche MJ (indices soft/hard, cadence, placements)
et une version “short 90 min” sans casser le runtime.
@@ -1,38 +1,39 @@
{
"id": "DEFAULT",
"scenario": "DEFAULT",
"initial_step": "STEP_WAIT_UNLOCK",
"initial_step": "SCENE_U_SON_PROTO",
"steps": [
{
"id": "STEP_WAIT_UNLOCK",
"screen_scene_id": "SCENE_LOCKED",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_READY"
]
},
{
"id": "STEP_U_SON_PROTO",
"screen_scene_id": "SCENE_BROKEN",
"id": "SCENE_U_SON_PROTO",
"screen_scene_id": "SCENE_U_SON_PROTO",
"audio_pack_id": "PACK_BOOT_RADIO",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_MEDIA_PLAY_FILE"
"ACTION_HW_LED_ALERT"
]
},
{
"id": "STEP_WAIT_ETAPE2",
"screen_scene_id": "SCENE_CAMERA_SCAN",
"id": "SCENE_LA_DETECTOR",
"screen_scene_id": "SCENE_LA_DETECTOR",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_CAMERA_SNAPSHOT",
"ACTION_REC_START"
"ACTION_QUEUE_SONAR"
]
},
{
"id": "STEP_ETAPE2",
"screen_scene_id": "SCENE_SIGNAL_SPIKE",
"id": "RTC_ESP_ETAPE1",
"screen_scene_id": "SCENE_WIN_ETAPE1",
"audio_pack_id": "PACK_CONFIRM_WIN_ETAPE1",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_ESP_NOW_SEND_ETAPE1",
"ACTION_QUEUE_SONAR"
]
},
{
"id": "WIN_ETAPE1",
"screen_scene_id": "SCENE_WIN_ETAPE1",
"audio_pack_id": "PACK_WIN",
"action_ids": [
"ACTION_TRACE_STEP",
@@ -40,14 +41,57 @@
]
},
{
"id": "STEP_DONE",
"screen_scene_id": "SCENE_MEDIA_ARCHIVE",
"id": "STEP_WARNING",
"screen_scene_id": "SCENE_WARNING",
"audio_pack_id": "PACK_BOOT_RADIO",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_REC_STOP",
"ACTION_HW_LED_READY",
"ACTION_REFRESH_SD"
"ACTION_HW_LED_ALERT"
]
},
{
"id": "SCENE_LEFOU_DETECTOR",
"screen_scene_id": "SCENE_LEFOU_DETECTOR",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_QUEUE_SONAR"
]
},
{
"id": "RTC_ESP_ETAPE2",
"screen_scene_id": "SCENE_WIN_ETAPE2",
"audio_pack_id": "PACK_CONFIRM_WIN_ETAPE2",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_ESP_NOW_SEND_ETAPE2",
"ACTION_QUEUE_SONAR"
]
},
{
"id": "SCENE_QR_DETECTOR",
"screen_scene_id": "SCENE_QR_DETECTOR",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_QR_CODE_SCANNER_START"
]
},
{
"id": "SCENE_FINAL_WIN",
"screen_scene_id": "SCENE_FINAL_WIN",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_WINNER"
]
},
{
"id": "STEP_MEDIA_MANAGER",
"screen_scene_id": "SCENE_MEDIA_MANAGER",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_SET_BOOT_MEDIA_MANAGER"
],
"mp3_gate_open": true
}
],
"source": "littlefs-default-options"
@@ -0,0 +1,8 @@
{
"id": "ACTION_SET_BOOT_MEDIA_MANAGER",
"type": "set_boot_mode",
"config": {
"mode": "media_manager",
"persist_nvs": true
}
}
@@ -0,0 +1,8 @@
{
"id": "ACTION_ESP_NOW_SEND_ETAPE1",
"type": "espnow_send",
"config": {
"target": "broadcast",
"payload": "ACK_WIN1"
}
}
@@ -0,0 +1,8 @@
{
"id": "ACTION_ESP_NOW_SEND_ETAPE2",
"type": "espnow_send",
"config": {
"target": "broadcast",
"payload": "ACK_WIN2"
}
}
@@ -0,0 +1,7 @@
{
"id": "ACTION_QR_CODE_SCANNER_START",
"type": "qr_scanner_start",
"config": {
"enable": true
}
}
@@ -0,0 +1,7 @@
{
"id": "ACTION_WINNER",
"type": "winner_fx",
"config": {
"mode": "final_win"
}
}
@@ -0,0 +1,7 @@
{
"id": "APP_QR_UNLOCK",
"app": "QR_UNLOCK_APP",
"config": {
"mode": "strict_qr_gate"
}
}
@@ -0,0 +1,5 @@
{
"id": "PACK_CONFIRM_WIN_ETAPE1",
"file": "/music/confirm_win_etape1.mp3",
"volume": 100
}
@@ -0,0 +1,5 @@
{
"id": "PACK_CONFIRM_WIN_ETAPE2",
"file": "/music/confirm_win_etape2.mp3",
"volume": 100
}
@@ -2,7 +2,7 @@
"id": "DEFAULT",
"scenario": "DEFAULT",
"version": 2,
"initial_step": "STEP_WAIT_UNLOCK",
"initial_step": "SCENE_U_SON_PROTO",
"hardware_events": {
"button_short_1": "BTN_NEXT",
"button_short_2": "BTN_NEXT",
@@ -11,7 +11,7 @@
"button_short_5": "BTN_NEXT",
"button_long_3": "FORCE_ETAPE2",
"button_long_4": "FORCE_DONE",
"espnow_event": "SERIAL:<payload>"
"espnow_event": "ESPNOW:<payload>"
},
"app_bindings": [
"APP_AUDIO",
@@ -19,86 +19,412 @@
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
"APP_HARDWARE",
"APP_CAMERA",
"APP_MEDIA"
"APP_QR_UNLOCK"
],
"actions_catalog": [
"ACTION_TRACE_STEP",
"ACTION_FORCE_ETAPE2",
"ACTION_REFRESH_SD",
"ACTION_HW_LED_ALERT",
"ACTION_HW_LED_READY",
"ACTION_CAMERA_SNAPSHOT",
"ACTION_QUEUE_SONAR",
"ACTION_REC_START",
"ACTION_REC_STOP",
"ACTION_SET_BOOT_MEDIA"
"ACTION_ESP_NOW_SEND_ETAPE1",
"ACTION_ESP_NOW_SEND_ETAPE2",
"ACTION_QR_CODE_SCANNER_START",
"ACTION_WINNER",
"ACTION_SET_BOOT_MEDIA_MANAGER"
],
"steps": [
{
"id": "STEP_WAIT_UNLOCK",
"screen_scene_id": "SCENE_LOCKED",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_READY"
]
},
{
"id": "STEP_U_SON_PROTO",
"screen_scene_id": "SCENE_BROKEN",
"id": "SCENE_U_SON_PROTO",
"screen_scene_id": "SCENE_U_SON_PROTO",
"audio_pack_id": "PACK_BOOT_RADIO",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT"
],
"transitions": [
{
"id": "TR_SCENE_U_SON_PROTO_1",
"trigger": "on_event",
"event_type": "audio_done",
"event_name": "AUDIO_DONE",
"target_step_id": "SCENE_U_SON_PROTO",
"after_ms": 0,
"priority": 80
},
{
"id": "TR_SCENE_U_SON_PROTO_2",
"trigger": "on_event",
"event_type": "button",
"event_name": "ANY",
"target_step_id": "SCENE_LA_DETECTOR",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_SCENE_U_SON_PROTO_3",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_ETAPE2",
"target_step_id": "SCENE_LA_DETECTOR",
"after_ms": 0,
"priority": 140
}
]
},
{
"id": "STEP_WAIT_ETAPE2",
"id": "SCENE_LA_DETECTOR",
"screen_scene_id": "SCENE_LA_DETECTOR",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_QUEUE_SONAR",
"ACTION_CAMERA_SNAPSHOT",
"ACTION_REC_START"
"ACTION_QUEUE_SONAR"
],
"transitions": [
{
"id": "TR_SCENE_LA_DETECTOR_1",
"trigger": "on_event",
"event_type": "timer",
"event_name": "ETAPE2_DUE",
"target_step_id": "SCENE_U_SON_PROTO",
"after_ms": 0,
"priority": 100
},
{
"id": "TR_SCENE_LA_DETECTOR_2",
"trigger": "on_event",
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "RTC_ESP_ETAPE1",
"after_ms": 0,
"priority": 110
},
{
"id": "TR_SCENE_LA_DETECTOR_3",
"trigger": "on_event",
"event_type": "unlock",
"event_name": "UNLOCK",
"target_step_id": "RTC_ESP_ETAPE1",
"after_ms": 0,
"priority": 115
},
{
"id": "TR_SCENE_LA_DETECTOR_4",
"trigger": "on_event",
"event_type": "action",
"event_name": "ACTION_FORCE_ETAPE2",
"target_step_id": "RTC_ESP_ETAPE1",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_SCENE_LA_DETECTOR_5",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_WIN_ETAPE1",
"target_step_id": "RTC_ESP_ETAPE1",
"after_ms": 0,
"priority": 130
}
]
},
{
"id": "STEP_ETAPE2",
"screen_scene_id": "SCENE_WIN_ETAPE",
"id": "RTC_ESP_ETAPE1",
"screen_scene_id": "SCENE_WIN_ETAPE1",
"audio_pack_id": "PACK_CONFIRM_WIN_ETAPE1",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_ESP_NOW_SEND_ETAPE1",
"ACTION_QUEUE_SONAR"
],
"transitions": [
{
"id": "TR_RTC_ESP_ETAPE1_1",
"trigger": "on_event",
"event_type": "espnow",
"event_name": "ACK_WIN1",
"target_step_id": "WIN_ETAPE1",
"after_ms": 0,
"priority": 130
},
{
"id": "TR_RTC_ESP_ETAPE1_2",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_DONE",
"target_step_id": "WIN_ETAPE1",
"after_ms": 0,
"priority": 120
}
]
},
{
"id": "WIN_ETAPE1",
"screen_scene_id": "SCENE_WIN_ETAPE1",
"audio_pack_id": "PACK_WIN",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT"
]
},
{
"id": "STEP_DONE",
"screen_scene_id": "SCENE_CAMERA_SCAN",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_REC_STOP",
"ACTION_HW_LED_READY",
"ACTION_REFRESH_SD"
],
"transitions": [
{
"id": "TR_STEP_DONE_MEDIA_SERIAL",
"id": "TR_WIN_ETAPE1_1",
"trigger": "on_event",
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "STEP_WARNING",
"after_ms": 0,
"priority": 110
},
{
"id": "TR_WIN_ETAPE1_2",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_DONE",
"target_step_id": "STEP_WARNING",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_WIN_ETAPE1_3",
"trigger": "on_event",
"event_type": "espnow",
"event_name": "ACK_WARNING",
"target_step_id": "STEP_WARNING",
"after_ms": 0,
"priority": 130
}
]
},
{
"id": "STEP_WARNING",
"screen_scene_id": "SCENE_WARNING",
"audio_pack_id": "PACK_BOOT_RADIO",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT"
],
"transitions": [
{
"id": "TR_STEP_WARNING_1",
"trigger": "on_event",
"event_type": "audio_done",
"event_name": "AUDIO_DONE",
"target_step_id": "STEP_WARNING",
"after_ms": 0,
"priority": 80
},
{
"id": "TR_STEP_WARNING_2",
"trigger": "on_event",
"event_type": "button",
"event_name": "ANY",
"target_step_id": "SCENE_LEFOU_DETECTOR",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_STEP_WARNING_3",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_ETAPE2",
"target_step_id": "SCENE_LEFOU_DETECTOR",
"after_ms": 0,
"priority": 140
}
]
},
{
"id": "SCENE_LEFOU_DETECTOR",
"screen_scene_id": "SCENE_LEFOU_DETECTOR",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_QUEUE_SONAR"
],
"transitions": [
{
"id": "TR_SCENE_LEFOU_DETECTOR_1",
"trigger": "on_event",
"event_type": "timer",
"event_name": "ETAPE2_DUE",
"target_step_id": "STEP_WARNING",
"after_ms": 0,
"priority": 100
},
{
"id": "TR_SCENE_LEFOU_DETECTOR_2",
"trigger": "on_event",
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "RTC_ESP_ETAPE2",
"after_ms": 0,
"priority": 110
},
{
"id": "TR_SCENE_LEFOU_DETECTOR_3",
"trigger": "on_event",
"event_type": "unlock",
"event_name": "UNLOCK",
"target_step_id": "RTC_ESP_ETAPE2",
"after_ms": 0,
"priority": 115
},
{
"id": "TR_SCENE_LEFOU_DETECTOR_4",
"trigger": "on_event",
"event_type": "action",
"event_name": "ACTION_FORCE_ETAPE2",
"target_step_id": "RTC_ESP_ETAPE2",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_SCENE_LEFOU_DETECTOR_5",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_WIN_ETAPE2",
"target_step_id": "RTC_ESP_ETAPE2",
"after_ms": 0,
"priority": 130
}
]
},
{
"id": "RTC_ESP_ETAPE2",
"screen_scene_id": "SCENE_WIN_ETAPE2",
"audio_pack_id": "PACK_CONFIRM_WIN_ETAPE2",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_ESP_NOW_SEND_ETAPE2",
"ACTION_QUEUE_SONAR"
],
"transitions": [
{
"id": "TR_RTC_ESP_ETAPE2_1",
"trigger": "on_event",
"event_type": "espnow",
"event_name": "ACK_WIN2",
"target_step_id": "SCENE_QR_DETECTOR",
"after_ms": 0,
"priority": 130
},
{
"id": "TR_RTC_ESP_ETAPE2_2",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_DONE",
"target_step_id": "SCENE_QR_DETECTOR",
"after_ms": 0,
"priority": 120
}
]
},
{
"id": "SCENE_QR_DETECTOR",
"screen_scene_id": "SCENE_QR_DETECTOR",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_QR_CODE_SCANNER_START"
],
"transitions": [
{
"id": "TR_SCENE_QR_DETECTOR_1",
"trigger": "on_event",
"event_type": "serial",
"event_name": "QR_OK",
"target_step_id": "SCENE_FINAL_WIN",
"after_ms": 0,
"priority": 140
},
{
"id": "TR_SCENE_QR_DETECTOR_2",
"trigger": "on_event",
"event_type": "unlock",
"event_name": "UNLOCK_QR",
"target_step_id": "SCENE_FINAL_WIN",
"after_ms": 0,
"priority": 150
},
{
"id": "TR_SCENE_QR_DETECTOR_3",
"trigger": "on_event",
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "SCENE_FINAL_WIN",
"after_ms": 0,
"priority": 110
},
{
"id": "TR_SCENE_QR_DETECTOR_4",
"trigger": "on_event",
"event_type": "action",
"event_name": "ACTION_FORCE_ETAPE2",
"target_step_id": "SCENE_FINAL_WIN",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_SCENE_QR_DETECTOR_5",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_WIN_ETAPE2",
"target_step_id": "SCENE_FINAL_WIN",
"after_ms": 0,
"priority": 130
}
]
},
{
"id": "SCENE_FINAL_WIN",
"screen_scene_id": "SCENE_FINAL_WIN",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_WINNER"
],
"transitions": [
{
"id": "TR_SCENE_FINAL_WIN_1",
"trigger": "on_event",
"event_type": "timer",
"event_name": "WIN_DUE",
"target_step_id": "STEP_MEDIA_MANAGER",
"after_ms": 0,
"priority": 140
},
{
"id": "TR_STEP_DONE_MEDIA_ESPNOW",
"id": "TR_SCENE_FINAL_WIN_2",
"trigger": "on_event",
"event_type": "espnow",
"event_name": "QR_OK",
"event_type": "serial",
"event_name": "BTN_NEXT",
"target_step_id": "STEP_MEDIA_MANAGER",
"after_ms": 0,
"priority": 110
},
{
"id": "TR_SCENE_FINAL_WIN_3",
"trigger": "on_event",
"event_type": "unlock",
"event_name": "UNLOCK",
"target_step_id": "STEP_MEDIA_MANAGER",
"after_ms": 0,
"priority": 120
},
{
"id": "TR_SCENE_FINAL_WIN_4",
"trigger": "on_event",
"event_type": "action",
"event_name": "FORCE_WIN_ETAPE2",
"target_step_id": "STEP_MEDIA_MANAGER",
"after_ms": 0,
"priority": 130
},
{
"id": "TR_SCENE_FINAL_WIN_5",
"trigger": "on_event",
"event_type": "serial",
"event_name": "FORCE_WIN_ETAPE2",
"target_step_id": "STEP_MEDIA_MANAGER",
"after_ms": 0,
"priority": 125
}
]
},
@@ -107,8 +433,7 @@
"screen_scene_id": "SCENE_MEDIA_MANAGER",
"action_ids": [
"ACTION_TRACE_STEP",
"ACTION_SET_BOOT_MEDIA",
"ACTION_HW_LED_READY"
"ACTION_SET_BOOT_MEDIA_MANAGER"
],
"mp3_gate_open": true
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_FINAL_WIN",
"title": "FINAL WIN",
"subtitle": "Mission accomplie",
"symbol": "WIN",
"effect": "celebrate",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 320
},
"theme": {
"bg": "#1C0C2E",
"accent": "#FFCC5C",
"text": "#FFF7E4"
},
"transition": {
"effect": "fade",
"duration_ms": 240
}
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_LEFOU_DETECTOR",
"title": "DETECTEUR LEFOU",
"subtitle": "Analyse en cours",
"symbol": "AUDIO",
"effect": "wave",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 460
},
"theme": {
"bg": "#071B1A",
"accent": "#46E6C8",
"text": "#E9FFF9"
},
"transition": {
"effect": "zoom",
"duration_ms": 250
}
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_QR_DETECTOR",
"title": "ZACUS QR VALIDATION",
"subtitle": "Scan du QR final",
"symbol": "QR",
"effect": "none",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 0
},
"theme": {
"bg": "#102040",
"accent": "#5CA3FF",
"text": "#F3F7FF"
},
"transition": {
"effect": "fade",
"duration_ms": 180
}
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_U_SON_PROTO",
"title": "PROTO U-SON",
"subtitle": "Signal brouille",
"symbol": "ALERT",
"effect": "blink",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 180
},
"theme": {
"bg": "#2A0508",
"accent": "#FF4A45",
"text": "#FFF1F1"
},
"transition": {
"effect": "glitch",
"duration_ms": 160
}
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_WARNING",
"title": "ALERTE",
"subtitle": "Signal anormal",
"symbol": "WARN",
"effect": "blink",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 240
},
"theme": {
"bg": "#261209",
"accent": "#FF9A4A",
"text": "#FFF2E6"
},
"transition": {
"effect": "fade",
"duration_ms": 200
}
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_WIN_ETAPE1",
"title": "WIN ETAPE 1",
"subtitle": "Validation distante",
"symbol": "WIN",
"effect": "celebrate",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 360
},
"theme": {
"bg": "#1E0F32",
"accent": "#F5C64A",
"text": "#FFF8E4"
},
"transition": {
"effect": "zoom",
"duration_ms": 280
}
}
@@ -0,0 +1,22 @@
{
"id": "SCENE_WIN_ETAPE2",
"title": "WIN ETAPE 2",
"subtitle": "ACK en attente",
"symbol": "WIN",
"effect": "celebrate",
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 340
},
"theme": {
"bg": "#220F3A",
"accent": "#FFCE62",
"text": "#FFF8EA"
},
"transition": {
"effect": "zoom",
"duration_ms": 280
}
}
+41
View File
@@ -1,3 +1,44 @@
## [2026-02-25] Refonte scenario reel v2 + stabilisation verificators + upload/test Freenove
- Skills utilises (ordre):
- `freenove-firmware-orchestrator`
- `firmware-story-stack`
- `firmware-espnow-stack`
- `firmware-build-stack`
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260225_162241_wip.patch`
- `/tmp/zacus_checkpoint/20260225_162241_status.txt`
- Correctifs principaux:
- flow runtime applique sur `DEFAULT` avec `button`/`espnow`, QR gate `APP_QR_UNLOCK`, timer `WIN_DUE`, `STEP_MEDIA_MANAGER`.
- action boot cible maintenue `ACTION_SET_BOOT_MEDIA_MANAGER` avec compat runtime `ACTION_SET_BOOT_MEDIA`.
- LittleFS: fichiers action a nom court pour contourner la limite de nom (`ACTION_QR_SCAN_START.json`, `ACTION_BOOT_MEDIA_MGR.json`) tout en conservant les IDs longs en payload.
- `main.cpp`: `SC_EVENT`/`SC_EVENT_RAW` appliquent maintenant `refreshSceneIfNeeded(true)` + `startPendingAudioIfAny()` sur changement de step (corrige reset lors trigger `button`).
- `main.cpp`: commande `RESET` stoppe audio/amp/media avant reset scenario puis rerender immediate (corrige panic rollback media-manager).
- HAL scene `SCENE_FINAL_WIN`: LED fixee (pas de pulse) pour verification RGB stricte.
- scripts skills:
- `scene-verificator`: robustesse boot marker + triggers button/espnow + checks LED.
- `hal-verificator-status`: attentes par defaut alignees (`SCENE_QR_DETECTOR mic=0`, `SCENE_FINAL_WIN led=252/212/92`).
- `media-manager`: `AUDIO_STOP` avant QR et avant reset rollback + detection `scene=SCENE_MEDIA_MANAGER`/ACK step media.
- generator story: validation narrative game assouplie (runtime YAML reste strict) pour ne pas bloquer sur `game/scenarios/zacus_v2.yaml`.
- Gates executes:
- story gen:
- `PYTHONPATH=lib/zacus_story_gen_ai/src .venv/bin/python -m zacus_story_gen_ai.cli validate`
- `PYTHONPATH=lib/zacus_story_gen_ai/src .venv/bin/python -m zacus_story_gen_ai.cli generate-cpp`
- `PYTHONPATH=lib/zacus_story_gen_ai/src .venv/bin/python -m zacus_story_gen_ai.cli generate-bundle`
- build/upload:
- `pio run -e freenove_esp32s3`
- `pio run -e freenove_esp32s3_full_with_ui -t buildfs` ✅ (plus de warning LittleFS sur action QR/boot media manager)
- `pio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port /dev/cu.usbmodem5AB90753301`
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`
- smoke/verificators:
- `python3 tools/dev/serial_smoke.py --role esp32 --port /dev/cu.usbmodem5AB90753301 --baud 115200 --timeout 8 --wait-port 10`
- `~/.codex/skills/scene-verificator/scripts/run_scene_verification.sh /dev/cu.usbmodem5AB90753301 115200`
- `~/.codex/skills/fx-verificator/scripts/run_fx_verification.sh /dev/cu.usbmodem5AB90753301 115200`
- `~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh /dev/cu.usbmodem5AB90753301 115200`
- `~/.codex/skills/media-manager/scripts/run_media_manager_verification.sh /dev/cu.usbmodem5AB90753301 115200`
- Limitation materielle observee (non bloquante):
- erreurs camera DMA sur `SCENE_PHOTO_MANAGER` (`cam_dma_config ... malloc failed`) quand PSRAM libre faible; verifs scene/HAL/media passent malgre ces logs.
## [2026-02-25] Scope ESP-NOW + fix action boot media (LittleFS)
- Objectif:
+90
View File
@@ -0,0 +1,90 @@
# ESP-NOW API v1 (enveloppe `msg_id/seq/type/payload/ack`)
Date: 2026-02-21
Scope: contrat d'échange entre `RTC_BL_PHONE` (A252) et la seconde carte.
## 1. Objectif
Normaliser les trames ESP-NOW pour:
- corréler requête/réponse,
- conserver compatibilité legacy,
- simplifier l'intégration du second repo.
## 2. Requête v1 (nouveau format recommandé)
```json
{
"msg_id": "req-001",
"seq": 1,
"type": "command",
"ack": true,
"payload": {
"cmd": "STATUS",
"args": {}
}
}
```
Règles:
- `msg_id` sert à corréler la réponse.
- `seq` est un compteur local de trame (recommandé monotone par source).
- `type=command` déclenche l'exécution côté firmware.
- `ack=true` demande une réponse corrélée.
- `payload.cmd` obligatoire pour une commande dispatcher.
- `payload.args` optionnel; sérialisé puis passé au dispatcher.
## 3. Réponse v1 (ack corrélée)
```json
{
"msg_id": "req-001",
"seq": 1,
"type": "ack",
"ack": true,
"payload": {
"ok": true,
"code": "STATUS",
"data": {},
"error": ""
}
}
```
Règles:
- `msg_id` et `seq` reprennent la requête.
- `payload.ok=false` => `payload.error` non vide.
- `payload.data` contient le JSON de la commande si disponible.
- fallback possible `payload.data_raw` si la réponse n'est pas JSON.
## 4. Compatibilité legacy
Le firmware continue d'accepter les formats existants:
- `{"cmd":"..."}`
- `{"raw":"..."}`
- `{"command":"..."}`
- `{"action":"..."}`
- variantes imbriquées via `event`, `message`, `payload`
- format historique `rtcbl/1`:
- `{"proto":"rtcbl/1","id":"...","cmd":"...","args":{}}`
## 5. Commandes recommandées v1
- `STATUS`
- `RING`
- `CALL`
- `HOOK`
- `LIST_FILES`
- `PLAY_FILE`
- `WIFI_STATUS`
- `MQTT_STATUS`
- `ESPNOW_STATUS`
- `BT_STATUS`
## 6. Intégration second repo
Checklist minimum côté seconde carte:
1. Émettre `msg_id` unique + `seq` monotone.
2. Positionner `type=command` et `ack=true` pour obtenir une réponse.
3. Implémenter timeout de réponse (2-5s) et corréler sur `msg_id`.
4. Prévoir fallback legacy (`rtcbl/1` + formats `cmd/raw/command/action`) pour compat.
@@ -1,6 +1,6 @@
id: DEFAULT
version: 2
initial_step: STEP_WAIT_UNLOCK
initial_step: SCENE_U_SON_PROTO
app_bindings:
- id: APP_AUDIO
@@ -13,37 +13,12 @@ app_bindings:
app: WIFI_STACK
- id: APP_ESPNOW
app: ESPNOW_STACK
- id: APP_QR_UNLOCK
app: QR_UNLOCK_APP
steps:
- step_id: STEP_WAIT_UNLOCK
screen_scene_id: SCENE_LOCKED
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_READY
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: STEP_U_SON_PROTO
after_ms: 0
priority: 100
# Verrou gameplay: sur STEP_WAIT_UNLOCK, tout appui bouton (court/long, 1..5) est normalise en BTN_NEXT.
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_WAIT_ETAPE2
after_ms: 0
priority: 110
- step_id: STEP_U_SON_PROTO
screen_scene_id: SCENE_BROKEN
- step_id: SCENE_U_SON_PROTO
screen_scene_id: SCENE_U_SON_PROTO
audio_pack_id: PACK_BOOT_RADIO
actions:
- ACTION_TRACE_STEP
@@ -59,32 +34,29 @@ steps:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_WAIT_ETAPE2
target_step_id: SCENE_U_SON_PROTO
after_ms: 0
priority: 100
priority: 80
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_WAIT_ETAPE2
event_type: button
event_name: ANY
target_step_id: SCENE_LA_DETECTOR
after_ms: 0
priority: 110
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_ETAPE2
target_step_id: STEP_ETAPE2
target_step_id: SCENE_LA_DETECTOR
after_ms: 0
priority: 120
priority: 140
- step_id: STEP_WAIT_ETAPE2
- step_id: SCENE_LA_DETECTOR
screen_scene_id: SCENE_LA_DETECTOR
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_QUEUE_SONAR
- ACTION_CAMERA_SNAPSHOT
- ACTION_REC_START
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
@@ -94,36 +66,65 @@ steps:
- trigger: on_event
event_type: timer
event_name: ETAPE2_DUE
target_step_id: STEP_ETAPE2
target_step_id: SCENE_U_SON_PROTO
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_ETAPE2
after_ms: 0
priority: 105
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: STEP_ETAPE2
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 110
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: STEP_ETAPE2
event_type: unlock
event_name: UNLOCK
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 115
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_ETAPE2
target_step_id: STEP_ETAPE2
event_name: FORCE_WIN_ETAPE1
target_step_id: RTC_ESP_ETAPE1
after_ms: 0
priority: 130
- step_id: RTC_ESP_ETAPE1
screen_scene_id: SCENE_WIN_ETAPE1
audio_pack_id: PACK_CONFIRM_WIN_ETAPE1
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
- ACTION_ESP_NOW_SEND_ETAPE1
- ACTION_QUEUE_SONAR
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: espnow
event_name: ACK_WIN1
target_step_id: WIN_ETAPE1
after_ms: 0
priority: 130
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: WIN_ETAPE1
after_ms: 0
priority: 120
- step_id: STEP_ETAPE2
screen_scene_id: SCENE_WIN_ETAPE
- step_id: WIN_ETAPE1
screen_scene_id: SCENE_WIN_ETAPE1
audio_pack_id: PACK_WIN
actions:
- ACTION_TRACE_STEP
@@ -139,51 +140,223 @@ steps:
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_DONE
target_step_id: STEP_WARNING
after_ms: 0
priority: 110
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: STEP_DONE
target_step_id: STEP_WARNING
after_ms: 0
priority: 120
- trigger: on_event
event_type: espnow
event_name: ACK_WARNING
target_step_id: STEP_WARNING
after_ms: 0
priority: 130
- step_id: STEP_DONE
screen_scene_id: SCENE_CAMERA_SCAN
- step_id: STEP_WARNING
screen_scene_id: SCENE_WARNING
audio_pack_id: PACK_BOOT_RADIO
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: audio_done
event_name: AUDIO_DONE
target_step_id: STEP_WARNING
after_ms: 0
priority: 80
- trigger: on_event
event_type: button
event_name: ANY
target_step_id: SCENE_LEFOU_DETECTOR
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_ETAPE2
target_step_id: SCENE_LEFOU_DETECTOR
after_ms: 0
priority: 140
- step_id: SCENE_LEFOU_DETECTOR
screen_scene_id: SCENE_LEFOU_DETECTOR
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_REC_STOP
- ACTION_HW_LED_READY
- ACTION_REFRESH_SD
- ACTION_QUEUE_SONAR
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: timer
event_name: ETAPE2_DUE
target_step_id: STEP_WARNING
after_ms: 0
priority: 100
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 110
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 115
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE2
target_step_id: RTC_ESP_ETAPE2
after_ms: 0
priority: 130
- step_id: RTC_ESP_ETAPE2
screen_scene_id: SCENE_WIN_ETAPE2
audio_pack_id: PACK_CONFIRM_WIN_ETAPE2
actions:
- ACTION_TRACE_STEP
- ACTION_HW_LED_ALERT
- ACTION_ESP_NOW_SEND_ETAPE2
- ACTION_QUEUE_SONAR
apps:
- APP_AUDIO
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: espnow
event_name: ACK_WIN2
target_step_id: SCENE_QR_DETECTOR
after_ms: 0
priority: 130
- trigger: on_event
event_type: serial
event_name: FORCE_DONE
target_step_id: SCENE_QR_DETECTOR
after_ms: 0
priority: 120
- step_id: SCENE_QR_DETECTOR
screen_scene_id: SCENE_QR_DETECTOR
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_QR_CODE_SCANNER_START
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
- APP_QR_UNLOCK
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: serial
event_name: QR_OK
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 140
- trigger: on_event
event_type: unlock
event_name: UNLOCK_QR
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 150
- trigger: on_event
event_type: serial
event_name: BTN_NEXT
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 110
- trigger: on_event
event_type: action
event_name: ACTION_FORCE_ETAPE2
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 120
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE2
target_step_id: SCENE_FINAL_WIN
after_ms: 0
priority: 130
- step_id: SCENE_FINAL_WIN
screen_scene_id: SCENE_FINAL_WIN
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_WINNER
apps:
- APP_SCREEN
- APP_GATE
- APP_WIFI
- APP_ESPNOW
mp3_gate_open: false
transitions:
- trigger: on_event
event_type: timer
event_name: WIN_DUE
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 140
- trigger: on_event
event_type: espnow
event_name: QR_OK
event_type: serial
event_name: BTN_NEXT
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 110
- trigger: on_event
event_type: unlock
event_name: UNLOCK
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 120
- trigger: on_event
event_type: action
event_name: FORCE_WIN_ETAPE2
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 130
- trigger: on_event
event_type: serial
event_name: FORCE_WIN_ETAPE2
target_step_id: STEP_MEDIA_MANAGER
after_ms: 0
priority: 125
- step_id: STEP_MEDIA_MANAGER
screen_scene_id: SCENE_MEDIA_MANAGER
audio_pack_id: ""
actions:
- ACTION_TRACE_STEP
- ACTION_SET_BOOT_MEDIA
- ACTION_HW_LED_READY
- ACTION_SET_BOOT_MEDIA_MANAGER
apps:
- APP_SCREEN
- APP_GATE
@@ -21,7 +21,7 @@ Verifier que les couches hardware (camera, micro, amp) et les 4 LED WS2812 Freen
## Usage
```bash
~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh /dev/cu.usbmodem5AB90753301 115200 \
"SCENE_READY:cam=0,amp=0,mic=0,ws2812=1,led_auto=1,led=18/45/95;SCENE_LA_DETECTOR:cam=0,amp=0,mic=1,ws2812=1,led_auto=1,led=0/0/0;SCENE_MP3_PLAYER:cam=0,amp=1,mic=1,ws2812=1,led_auto=1,led=18/45/95;SCENE_CAMERA_SCAN:cam=0,amp=0,mic=1,ws2812=1,led_auto=1,led=18/45/95;SCENE_MEDIA_MANAGER:cam=0,amp=0,mic=0,ws2812=1,led_auto=1,led=18/45/95"
"SCENE_READY:cam=0,amp=0,mic=0,ws2812=1,led_auto=1,led=18/45/95;SCENE_LA_DETECTOR:cam=0,amp=0,mic=1,ws2812=1,led_auto=1,led=0/0/0;SCENE_MP3_PLAYER:cam=0,amp=1,mic=1,ws2812=1,led_auto=1,led=18/45/95;SCENE_QR_DETECTOR:cam=0,amp=0,mic=0,ws2812=1,led_auto=1,led=18/45/95;SCENE_FINAL_WIN:cam=0,amp=0,mic=0,ws2812=1,led_auto=1,led=252/212/92;SCENE_MEDIA_MANAGER:cam=0,amp=0,mic=0,ws2812=1,led_auto=1,led=18/45/95"
# Variante policy-only (pas de couleur exacte)
~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh /dev/cu.usbmodem5AB90753301 115200 \
@@ -5,9 +5,9 @@ Verifier le flux QR final, la persistance NVS du boot mode `MEDIA_MANAGER`, et l
## Verifications
- `BOOT_MODE_STATUS` doit exposer un etat coherent (`mode`, `media_validated`).
- Le script force `SCENE_GOTO SCENE_CAMERA_SCAN` avant `QR_SIM <payload>` pour un test deterministe.
- `QR_SIM` doit produire une evidence `UI_EVENT` (`SERIAL:QR_OK` ou `QR_OK`) et/ou un routage `SCENE_MEDIA_MANAGER`.
- Scope ESP-NOW actif: `SC_EVENT espnow QR_OK` doit aussi permettre le passage `STEP_DONE -> STEP_MEDIA_MANAGER`.
- Le script force `SCENE_GOTO SCENE_QR_DETECTOR`, puis `AUDIO_STOP` avant `QR_SIM <payload>` et avant les `RESET` de rollback.
- `QR_SIM` doit produire `QR_OK` et la transition vers `SCENE_FINAL_WIN`, puis `WIN_DUE` vers `SCENE_MEDIA_MANAGER`.
- Scope ESP-NOW actif: `SC_EVENT espnow QR_OK` doit aussi declencher le meme passage via le miroir serial.
- La persistence doit rester active apres `RESET` (retour `SCENE_MEDIA_MANAGER`).
- La verification post-reset repose sur `HW_STATUS scene=...` (pas de dependance `story.status`).
- Les scenes hub doivent etre atteignables: `SCENE_PHOTO_MANAGER`, `SCENE_MP3_PLAYER`, story (`SC_LOAD DEFAULT` + `SCENE_READY`).
@@ -21,5 +21,5 @@ Verifier le flux QR final, la persistance NVS du boot mode `MEDIA_MANAGER`, et l
## Usage
```bash
~/.codex/skills/media-manager/scripts/run_media_manager_verification.sh /dev/cu.usbmodem5AB90753301 115200 ZACUS:MEDIA_MANAGER \
"SCENE_CAMERA_SCAN:ws2812=1,led_auto=1,led=18/45/95;SCENE_MEDIA_MANAGER:ws2812=1,led_auto=1,led=18/45/95;SCENE_MP3_PLAYER:ws2812=1,led_auto=1,led=18/45/95;SCENE_READY:ws2812=1,led_auto=1,led=18/45/95"
"SCENE_QR_DETECTOR:ws2812=1,led_auto=1,led=18/45/95;SCENE_FINAL_WIN:ws2812=1,led_auto=1,led=252/212/92;SCENE_MEDIA_MANAGER:ws2812=1,led_auto=1,led=18/45/95;SCENE_MP3_PLAYER:ws2812=1,led_auto=1,led=18/45/95;SCENE_READY:ws2812=1,led_auto=1,led=18/45/95"
```
@@ -5,9 +5,9 @@ Valider l'enchainement des scenes story sur Freenove avec des verdicts stricts e
## Verifications
- `SCENE_GOTO` doit retourner `ACK ... ok=1` pour chaque scene cible.
- Les triggers de test (`BTN_*`, `story.validate`, `STORY_*`) doivent etre emis et verifies.
- Les triggers de test (`button:*`, `espnow:*`, `serial:*`) doivent etre emis et verifies.
- Chaque trigger doit montrer une progression (changement `SCREEN_SYNC` ou changement scene/status).
- Les alias de trigger sont geres: `BTN_*` (fallback `SC_EVENT serial ...`) et noms pointes (fallback `SC_EVENT_RAW ...`).
- Les alias de trigger sont geres: `button:*` -> `SC_EVENT button ...`, `espnow:*` -> `SC_EVENT espnow ...`, `BTN_*` -> `SC_EVENT serial ...`.
- `SCREEN_SYNC` doit montrer une transition de sequence, ou a defaut un changement de scene/status observable.
- `story.status` doit rester coherent avec scene/step attendus.
- Validation WS2812 stricte supportee par scene: `ws2812`, `led_auto`, `led=R/G/B`.
@@ -20,7 +20,7 @@ Valider l'enchainement des scenes story sur Freenove avec des verdicts stricts e
## Usage
```bash
~/.codex/skills/scene-verificator/scripts/run_scene_verification.sh /dev/cu.usbmodem5AB90753301 115200 \
"SCENE_LOCKED,SCENE_LA_DETECTOR,SCENE_WIN_ETAPE,SCENE_READY" \
"BTN_NEXT@SCENE_LOCKED,STORY_FORCE_ETAPE2@SCENE_LA_DETECTOR" \
"SCENE_LA_DETECTOR:ws2812=1,led_auto=1,led=0/0/0;SCENE_READY:ws2812=1,led_auto=1,led=18/45/95"
"SCENE_U_SON_PROTO,SCENE_LA_DETECTOR,SCENE_WIN_ETAPE1,SCENE_WARNING,SCENE_LEFOU_DETECTOR,SCENE_WIN_ETAPE2,SCENE_QR_DETECTOR,SCENE_FINAL_WIN,SCENE_MEDIA_MANAGER,SCENE_READY" \
"button:ANY@SCENE_U_SON_PROTO->SCENE_LA_DETECTOR,espnow:ACK_WIN1@SCENE_WIN_ETAPE1,serial:QR_OK@SCENE_QR_DETECTOR->SCENE_FINAL_WIN" \
"SCENE_QR_DETECTOR:ws2812=1,led_auto=1,led=18/45/95;SCENE_FINAL_WIN:ws2812=1,led_auto=1,led=252/212/92;SCENE_MEDIA_MANAGER:ws2812=1,led_auto=1,led=18/45/95"
```
@@ -30,6 +30,8 @@ class ScenarioManager {
void notifyUnlock(uint32_t now_ms);
void notifyButton(uint8_t key, bool long_press, uint32_t now_ms);
void notifyAudioDone(uint32_t now_ms);
bool notifyButtonEvent(const char* event_name, uint32_t now_ms);
bool notifyEspNowEvent(const char* event_name, uint32_t now_ms);
bool notifySerialEvent(const char* event_name, uint32_t now_ms);
bool notifyTimerEvent(const char* event_name, uint32_t now_ms);
bool notifyActionEvent(const char* event_name, uint32_t now_ms);
@@ -78,6 +80,9 @@ class ScenarioManager {
bool timer_armed_ = false;
bool timer_fired_ = false;
uint32_t etape2_due_at_ms_ = 0U;
bool win_due_armed_ = false;
bool win_due_fired_ = false;
uint32_t win_due_at_ms_ = 0U;
String pending_audio_pack_;
String forced_screen_scene_id_;
String initial_step_override_;
@@ -58,7 +58,7 @@ constexpr const char* kDiagAudioFile = "/music/boot_radio.mp3";
constexpr size_t kSerialLineCapacity = 192U;
constexpr bool kBootDiagnosticTone = true;
constexpr const char* kEspNowBroadcastTarget = "broadcast";
constexpr const char* kStepWinEtape = "STEP_ETAPE2";
constexpr const char* kStepWinEtape = "WIN_ETAPE1";
constexpr const char* kPackWin = "PACK_WIN";
constexpr const char* kWebAuthHeaderName = "Authorization";
constexpr const char* kWebAuthBearerPrefix = "Bearer ";
@@ -146,6 +146,12 @@ const char* audioPackToFile(const char* pack_id) {
if (std::strcmp(pack_id, "PACK_WIN") == 0) {
return "/music/win.mp3";
}
if (std::strcmp(pack_id, "PACK_CONFIRM_WIN_ETAPE1") == 0) {
return "/music/confirm_win_etape1.mp3";
}
if (std::strcmp(pack_id, "PACK_CONFIRM_WIN_ETAPE2") == 0) {
return "/music/confirm_win_etape2.mp3";
}
return "/music/placeholder.mp3";
}
@@ -525,6 +531,10 @@ const char* eventTypeName(StoryEventType type) {
return "timer";
case StoryEventType::kSerial:
return "serial";
case StoryEventType::kButton:
return "button";
case StoryEventType::kEspNow:
return "espnow";
case StoryEventType::kAction:
return "action";
default:
@@ -555,9 +565,12 @@ bool parseEventType(const char* text, StoryEventType* out_type) {
*out_type = StoryEventType::kSerial;
return true;
}
if (std::strcmp(normalized, "button") == 0 || std::strcmp(normalized, "btn") == 0) {
*out_type = StoryEventType::kButton;
return true;
}
if (std::strcmp(normalized, "espnow") == 0 || std::strcmp(normalized, "esp_now") == 0) {
// ESP-NOW events are bridged to story SERIAL:* tokens.
*out_type = StoryEventType::kSerial;
*out_type = StoryEventType::kEspNow;
return true;
}
if (std::strcmp(normalized, "action") == 0) {
@@ -577,6 +590,10 @@ const char* defaultEventNameForType(StoryEventType type) {
return "ETAPE2_DUE";
case StoryEventType::kSerial:
return "BTN_NEXT";
case StoryEventType::kButton:
return "ANY";
case StoryEventType::kEspNow:
return "EVENT";
case StoryEventType::kAction:
return "ACTION_FORCE_ETAPE2";
default:
@@ -610,6 +627,12 @@ bool buildEventTokenFromTypeName(StoryEventType type,
case StoryEventType::kSerial:
snprintf(out_event, out_capacity, "SERIAL:%s", normalized_name[0] != '\0' ? normalized_name : "BTN_NEXT");
return true;
case StoryEventType::kButton:
snprintf(out_event, out_capacity, "BUTTON:%s", normalized_name[0] != '\0' ? normalized_name : "ANY");
return true;
case StoryEventType::kEspNow:
snprintf(out_event, out_capacity, "ESPNOW:%s", normalized_name[0] != '\0' ? normalized_name : "EVENT");
return true;
case StoryEventType::kAction:
snprintf(out_event,
out_capacity,
@@ -678,11 +701,18 @@ bool normalizeEventTokenFromText(const char* raw_text, char* out_event, size_t o
snprintf(out_event, out_capacity, "SERIAL:%s", name[0] != '\0' ? name : "BTN_NEXT");
return true;
}
if (startsWithIgnoreCase(event, "BUTTON ")) {
char* name = event + 7;
trimAsciiInPlace(name);
toUpperAsciiInPlace(name);
snprintf(out_event, out_capacity, "BUTTON:%s", name[0] != '\0' ? name : "ANY");
return true;
}
if (startsWithIgnoreCase(event, "ESPNOW ")) {
char* name = event + 7;
trimAsciiInPlace(name);
toUpperAsciiInPlace(name);
snprintf(out_event, out_capacity, "SERIAL:%s", name[0] != '\0' ? name : "BTN_NEXT");
snprintf(out_event, out_capacity, "ESPNOW:%s", name[0] != '\0' ? name : "EVENT");
return true;
}
if (startsWithIgnoreCase(event, "TIMER ")) {
@@ -799,7 +829,18 @@ bool normalizeEspNowPayloadToScenarioEvent(const char* payload_text, char* out_e
}
}
return normalizeEventTokenFromText(normalized, out_event, out_capacity);
char normalized_event[kSerialLineCapacity] = {0};
if (!normalizeEventTokenFromText(normalized, normalized_event, sizeof(normalized_event))) {
return false;
}
if (std::strchr(normalized_event, ':') == nullptr &&
std::strcmp(normalized_event, "UNLOCK") != 0 &&
std::strcmp(normalized_event, "AUDIO_DONE") != 0) {
std::snprintf(out_event, out_capacity, "ESPNOW:%s", normalized_event);
return true;
}
copyText(out_event, out_capacity, normalized_event);
return true;
}
bool dispatchScenarioEventByType(StoryEventType type, const char* event_name, uint32_t now_ms);
@@ -2643,6 +2684,38 @@ bool executeStoryAction(const char* action_id, const ScenarioSnapshot& snapshot,
return g_media.stopRecording();
}
if (std::strcmp(action_id, "ACTION_ESP_NOW_SEND_ETAPE1") == 0 ||
std::strcmp(action_id, "ACTION_ESP_NOW_SEND_ETAPE2") == 0 ||
std::strcmp(action_type, "espnow_send") == 0) {
const char* target = action_doc["config"]["target"] | action_doc["config"]["peer"] | "broadcast";
const char* payload = action_doc["config"]["payload"] | "";
String fallback_payload;
if (payload[0] == '\0') {
const char* inferred = std::strstr(action_id, "ETAPE2") != nullptr ? "ACK_WIN2" : "ACK_WIN1";
fallback_payload = inferred;
payload = fallback_payload.c_str();
}
const bool ok = g_network.sendEspNowTarget(target, payload);
Serial.printf("[ACTION] ESPNOW_SEND id=%s target=%s payload=%s ok=%u\n",
action_id,
target,
payload,
ok ? 1U : 0U);
return ok;
}
if (std::strcmp(action_id, "ACTION_QR_CODE_SCANNER_START") == 0 ||
std::strcmp(action_type, "qr_scanner_start") == 0) {
Serial.println("[ACTION] QR scanner gate active");
return true;
}
if (std::strcmp(action_id, "ACTION_WINNER") == 0 ||
std::strcmp(action_type, "winner_fx") == 0) {
Serial.println("[ACTION] WINNER effect armed");
return true;
}
if (std::strcmp(action_id, "ACTION_SET_BOOT_MEDIA") == 0 ||
std::strcmp(action_id, "ACTION_SET_BOOT_MEDIA_MANAGER") == 0) {
const bool mode_ok = g_boot_mode_store.saveMode(BootModeStore::StartupMode::kMediaManager);
@@ -3839,13 +3912,17 @@ void printScenarioCoverage() {
const uint8_t audio_done = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kAudioDone))) ? 1U : 0U;
const uint8_t timer = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kTimer))) ? 1U : 0U;
const uint8_t serial = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kSerial))) ? 1U : 0U;
const uint8_t button = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kButton))) ? 1U : 0U;
const uint8_t espnow = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kEspNow))) ? 1U : 0U;
const uint8_t action = (mask & (1UL << static_cast<uint8_t>(StoryEventType::kAction))) ? 1U : 0U;
Serial.printf("SC_COVERAGE scenario=%s unlock=%u audio_done=%u timer=%u serial=%u action=%u\n",
Serial.printf("SC_COVERAGE scenario=%s unlock=%u audio_done=%u timer=%u serial=%u button=%u espnow=%u action=%u\n",
scenarioIdFromSnapshot(snapshot),
unlock,
audio_done,
timer,
serial,
button,
espnow,
action);
}
@@ -3865,23 +3942,14 @@ bool dispatchScenarioEventByType(StoryEventType type, const char* event_name, ui
return true;
case StoryEventType::kTimer:
return g_scenario.notifyTimerEvent(event_name, now_ms);
case StoryEventType::kSerial: {
const bool dispatched = g_scenario.notifySerialEvent(event_name, now_ms);
if (dispatched) {
return true;
}
if (event_name != nullptr && std::strcmp(event_name, "QR_OK") == 0) {
const ScenarioSnapshot snapshot = g_scenario.snapshot();
const bool boot_mode_ok = executeStoryAction("ACTION_SET_BOOT_MEDIA", snapshot, now_ms);
const bool goto_ok = g_scenario.gotoScene("SCENE_MEDIA_MANAGER", now_ms, "event_qr_ok_fallback");
if (boot_mode_ok || goto_ok) {
Serial.printf("[SC_EVENT] qr fallback mode_ok=%u goto_ok=%u\n",
boot_mode_ok ? 1U : 0U,
goto_ok ? 1U : 0U);
}
return boot_mode_ok || goto_ok;
}
return false;
case StoryEventType::kSerial:
return g_scenario.notifySerialEvent(event_name, now_ms);
case StoryEventType::kButton:
return g_scenario.notifyButtonEvent(event_name, now_ms);
case StoryEventType::kEspNow: {
const bool dispatched_espnow = g_scenario.notifyEspNowEvent(event_name, now_ms);
const bool dispatched_serial = g_scenario.notifySerialEvent(event_name, now_ms);
return dispatched_espnow || dispatched_serial;
}
case StoryEventType::kAction:
return g_scenario.notifyActionEvent(event_name, now_ms);
@@ -3933,6 +4001,14 @@ bool dispatchScenarioEventByName(const char* event_name, uint32_t now_ms) {
if (head_len == 6U && std::strncmp(normalized, "SERIAL", 6U) == 0) {
return g_scenario.notifySerialEvent(tail, now_ms);
}
if (head_len == 6U && std::strncmp(normalized, "BUTTON", 6U) == 0) {
return g_scenario.notifyButtonEvent(tail, now_ms);
}
if (head_len == 6U && std::strncmp(normalized, "ESPNOW", 6U) == 0) {
const bool dispatched_espnow = g_scenario.notifyEspNowEvent(tail, now_ms);
const bool dispatched_serial = g_scenario.notifySerialEvent(tail, now_ms);
return dispatched_espnow || dispatched_serial;
}
}
return g_scenario.notifySerialEvent(normalized, now_ms);
@@ -3954,6 +4030,8 @@ void runScenarioRevalidate(uint32_t now_ms) {
{StoryEventType::kAudioDone, "AUDIO_DONE"},
{StoryEventType::kTimer, "ETAPE2_DUE"},
{StoryEventType::kSerial, "FORCE_DONE"},
{StoryEventType::kButton, "ANY"},
{StoryEventType::kEspNow, "ACK_WIN1"},
{StoryEventType::kAction, "ACTION_FORCE_ETAPE2"},
};
const HardwareProbe hardware_probes[] = {
@@ -4461,11 +4539,20 @@ void handleSerialCommand(const char* command_line, uint32_t now_ms) {
return;
}
if (std::strcmp(command, "RESET") == 0) {
g_audio.stop();
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
if (g_amp_ready) {
g_amp_player.service().stop();
}
#endif
(void)g_media.stop(&g_audio);
g_scenario.reset();
if (g_boot_media_manager_mode) {
(void)g_scenario.gotoScene(kMediaManagerSceneId, now_ms, "boot_mode_media_manager_reset");
}
g_last_action_step_key[0] = '\0';
refreshSceneIfNeeded(true);
startPendingAudioIfAny();
Serial.println("ACK RESET");
return;
}
@@ -4653,6 +4740,11 @@ void handleSerialCommand(const char* command_line, uint32_t now_ms) {
const bool dispatched = dispatchScenarioEventByType(event_type, event_name, now_ms);
const ScenarioSnapshot after = g_scenario.snapshot();
const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
if (dispatched && changed) {
g_last_action_step_key[0] = '\0';
refreshSceneIfNeeded(true);
startPendingAudioIfAny();
}
Serial.printf("ACK SC_EVENT type=%s name=%s dispatched=%u changed=%u step=%s\n",
eventTypeName(event_type),
event_name,
@@ -4670,6 +4762,11 @@ void handleSerialCommand(const char* command_line, uint32_t now_ms) {
const bool dispatched = dispatchScenarioEventByName(argument, now_ms);
const ScenarioSnapshot after = g_scenario.snapshot();
const bool changed = std::strcmp(stepIdFromSnapshot(before), stepIdFromSnapshot(after)) != 0;
if (dispatched && changed) {
g_last_action_step_key[0] = '\0';
refreshSceneIfNeeded(true);
startPendingAudioIfAny();
}
Serial.printf("ACK SC_EVENT_RAW name=%s dispatched=%u changed=%u step=%s\n",
argument,
dispatched ? 1U : 0U,
@@ -5319,19 +5416,6 @@ void runRuntimeIteration(uint32_t now_ms) {
event_token[sizeof(event_token) - 1U] = '\0';
}
}
if (!dispatched && std::strcmp(runtime_event, "QR_OK") == 0) {
const ScenarioSnapshot snapshot = g_scenario.snapshot();
const bool boot_mode_ok = executeStoryAction("ACTION_SET_BOOT_MEDIA", snapshot, now_ms);
const bool goto_ok = g_scenario.gotoScene("SCENE_MEDIA_MANAGER", now_ms, "ui_qr_fallback");
dispatched = goto_ok;
if (boot_mode_ok || goto_ok) {
std::strncpy(event_token, "QR_OK_FALLBACK", sizeof(event_token) - 1U);
event_token[sizeof(event_token) - 1U] = '\0';
Serial.printf("[UI_EVENT] qr fallback mode_ok=%u goto_ok=%u\n",
boot_mode_ok ? 1U : 0U,
goto_ok ? 1U : 0U);
}
}
Serial.printf("[UI_EVENT] event=%s dispatched=%u\n", event_token, dispatched ? 1U : 0U);
if (dispatched) {
refreshSceneIfNeeded(true);
@@ -3,6 +3,7 @@
#include <ArduinoJson.h>
#include <LittleFS.h>
#include <cstdio>
#include <cstring>
#include "resources/screen_scene_registry.h"
@@ -12,6 +13,7 @@ namespace {
constexpr uint32_t kEtape2DelayMs = 15UL * 60UL * 1000UL;
constexpr uint32_t kEtape2TestDelayMs = 5000U;
constexpr uint32_t kWinDueDelayMs = 10UL * 60UL * 1000UL;
bool eventNameMatches(const char* expected, const char* actual) {
if (expected == nullptr || expected[0] == '\0') {
@@ -193,6 +195,9 @@ void ScenarioManager::reset() {
timer_armed_ = false;
timer_fired_ = false;
etape2_due_at_ms_ = 0U;
win_due_armed_ = false;
win_due_fired_ = false;
win_due_at_ms_ = 0U;
const ScenarioSnapshot state = snapshot();
if (state.audio_pack_id != nullptr && state.audio_pack_id[0] != '\0') {
@@ -209,6 +214,10 @@ void ScenarioManager::tick(uint32_t now_ms) {
timer_fired_ = true;
dispatchEvent(StoryEventType::kTimer, "ETAPE2_DUE", now_ms, "timer_due");
}
if (win_due_armed_ && !win_due_fired_ && win_due_at_ms_ > 0U && now_ms >= win_due_at_ms_) {
win_due_fired_ = true;
dispatchEvent(StoryEventType::kTimer, "WIN_DUE", now_ms, "timer_win_due");
}
}
void ScenarioManager::notifyUnlock(uint32_t now_ms) {
@@ -219,9 +228,21 @@ void ScenarioManager::notifyUnlock(uint32_t now_ms) {
}
void ScenarioManager::notifyButton(uint8_t key, bool long_press, uint32_t now_ms) {
(void)long_press;
const StepDef* step = currentStep();
if (dispatchEvent(StoryEventType::kButton, "ANY", now_ms, long_press ? "btn_any_long" : "btn_any_short")) {
return;
}
char button_event[24] = {0};
std::snprintf(button_event,
sizeof(button_event),
"BTN%u_%s",
static_cast<unsigned int>(key),
long_press ? "LONG" : "SHORT");
if (dispatchEvent(StoryEventType::kButton, button_event, now_ms, "btn_specific")) {
return;
}
if (step != nullptr && key >= 1U && key <= 5U && step->id != nullptr) {
const char* screen_scene_id = step->resources.screenSceneId;
if (std::strcmp(step->id, "STEP_WAIT_ETAPE2") == 0) {
@@ -253,6 +274,16 @@ void ScenarioManager::notifyAudioDone(uint32_t now_ms) {
dispatchEvent(StoryEventType::kAudioDone, "AUDIO_DONE", now_ms, "audio_done");
}
bool ScenarioManager::notifyButtonEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "ANY";
return dispatchEvent(StoryEventType::kButton, name, now_ms, "button_event");
}
bool ScenarioManager::notifyEspNowEvent(const char* event_name, uint32_t now_ms) {
const char* name = (event_name != nullptr && event_name[0] != '\0') ? event_name : "EVENT";
return dispatchEvent(StoryEventType::kEspNow, name, now_ms, "espnow_event");
}
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");
@@ -507,6 +538,14 @@ void ScenarioManager::enterStep(int8_t step_index, uint32_t now_ms, const char*
if (audio_pack_id != nullptr && audio_pack_id[0] != '\0') {
pending_audio_pack_ = audio_pack_id;
}
win_due_armed_ = false;
win_due_fired_ = false;
win_due_at_ms_ = 0U;
if (screen_scene_id != nullptr && std::strcmp(screen_scene_id, "SCENE_FINAL_WIN") == 0) {
win_due_armed_ = true;
win_due_fired_ = false;
win_due_at_ms_ = now_ms + kWinDueDelayMs;
}
Serial.printf("[SCENARIO] transition from_step=%s to_step=%s from_scene=%s to_scene=%s event=%s source=%s audio_pack=%s\n",
from_step,
step->id != nullptr ? step->id : "n/a",
@@ -18,7 +18,10 @@ bool isCameraSceneId(const char* scene_id) {
}
bool isIntroSceneId(const char* scene_id) {
return (scene_id != nullptr && std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0);
return (scene_id != nullptr &&
(std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE1") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE2") == 0));
}
bool isDirectFxSceneId(const char* scene_id) {
@@ -0,0 +1,472 @@
// 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>
#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");
AudioFileSource* source = new AudioFileSourceFS(*file_system, fixed_path.c_str());
AudioGenerator* decoder = is_wav ? static_cast<AudioGenerator*>(new AudioGeneratorWAV())
: static_cast<AudioGenerator*>(new AudioGeneratorMP3());
if (!playOnChannel(target_channel, source, decoder, 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();
AudioFileSource* source = new AudioFileSourcePROGMEM(kDiagnosticRtttl, std::strlen(kDiagnosticRtttl));
AudioGenerator* decoder = new AudioGeneratorRTTTL();
if (!playOnChannel(target_channel, source, decoder, "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_);
}
}
@@ -0,0 +1,209 @@
// 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;
}
@@ -0,0 +1,332 @@
// 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';
}
@@ -38,17 +38,24 @@ constexpr uint16_t kMicAgcMaxGainStepDown = 16U;
constexpr HardwareManager::LedPaletteEntry kLedPalette[] = {
{"SCENE_LOCKED", 255U, 96U, 22U, 88U, true},
{"SCENE_BROKEN", 255U, 40U, 18U, 86U, true},
{"SCENE_U_SON_PROTO", 255U, 40U, 18U, 86U, true},
{"SCENE_WARNING", 255U, 154U, 74U, 78U, true},
{"SCENE_SIGNAL_SPIKE", 255U, 40U, 18U, 86U, true},
{"SCENE_LA_DETECTOR", 32U, 224U, 170U, 56U, true},
{"SCENE_LEFOU_DETECTOR", 70U, 230U, 200U, 56U, true},
{"SCENE_SEARCH", 32U, 224U, 170U, 56U, true},
{"SCENE_WIN", 245U, 205U, 62U, 80U, true},
{"SCENE_WIN_ETAPE", 245U, 205U, 62U, 80U, true},
{"SCENE_WIN_ETAPE1", 244U, 203U, 74U, 80U, true},
{"SCENE_WIN_ETAPE2", 244U, 203U, 74U, 80U, true},
{"SCENE_FINAL_WIN", 252U, 212U, 92U, 76U, false},
{"SCENE_REWARD", 245U, 205U, 62U, 80U, true},
{"SCENE_READY", 18U, 45U, 95U, 52U, false},
{"SCENE_MP3_PLAYER", 18U, 45U, 95U, 52U, false},
{"SCENE_MEDIA_MANAGER", 18U, 45U, 95U, 52U, false},
{"SCENE_PHOTO_MANAGER", 18U, 45U, 95U, 52U, false},
{"SCENE_CAMERA_SCAN", 18U, 45U, 95U, 52U, false},
{"SCENE_QR_DETECTOR", 18U, 45U, 95U, 52U, false},
{"__DEFAULT__", 18U, 45U, 95U, 52U, false},
};
uint8_t clampU8(int value) {
@@ -0,0 +1,940 @@
// 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_BROKEN", 255U, 40U, 18U, 86U, true},
{"SCENE_U_SON_PROTO", 255U, 40U, 18U, 86U, true},
{"SCENE_WARNING", 255U, 154U, 74U, 78U, true},
{"SCENE_SIGNAL_SPIKE", 255U, 40U, 18U, 86U, true},
{"SCENE_LA_DETECT", 32U, 224U, 170U, 56U, true},
{"SCENE_LA_DETECTOR", 32U, 224U, 170U, 56U, true},
{"SCENE_LEFOU_DETECTOR", 70U, 230U, 200U, 56U, true},
{"SCENE_SEARCH", 32U, 224U, 170U, 56U, true},
{"SCENE_QR_DETECTOR", 18U, 45U, 95U, 50U, true},
{"SCENE_WIN_ETAPE1", 244U, 203U, 74U, 80U, true},
{"SCENE_WIN_ETAPE2", 244U, 203U, 74U, 80U, true},
{"SCENE_FINAL_WIN", 252U, 212U, 92U, 76U, false},
{"SCENE_WIN", 245U, 205U, 62U, 80U, true},
{"SCENE_REWARD", 245U, 205U, 62U, 80U, true},
{"SCENE_READY", 18U, 45U, 95U, 52U, false},
{"SCENE_MP3_PLAYER", 18U, 45U, 95U, 52U, false},
{"SCENE_MEDIA_MANAGER", 18U, 45U, 95U, 52U, false},
{"SCENE_PHOTO_MANAGER", 18U, 45U, 95U, 52U, false},
{"SCENE_CAMERA_SCAN", 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) ||
(std::strcmp(snapshot_.scene_id, "SCENE_BROKEN") == 0) ||
(std::strcmp(snapshot_.scene_id, "SCENE_SIGNAL_SPIKE") == 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 = "SCENE_READY";
}
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);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,328 @@
// 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;
}
@@ -0,0 +1,943 @@
// 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;
}
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;
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;
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;
}
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));
}
@@ -0,0 +1,661 @@
// 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) {
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["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
@@ -0,0 +1,29 @@
// 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
}
@@ -5010,10 +5010,13 @@ void UiManager::renderScene(const ScenarioDef* scenario,
const uint32_t payload_crc = hashScenePayload(screen_payload_json);
const bool static_state_changed = shouldApplySceneStaticState(scene_id, screen_payload_json, scene_changed);
const bool has_previous_scene = (last_scene_id_[0] != '\0');
const bool win_etape_intro_scene = (std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0);
const bool win_etape_intro_scene = (std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE1") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE2") == 0);
const bool direct_fx_scene = isDirectFxSceneId(scene_id);
const bool is_locked_scene = (std::strcmp(scene_id, "SCENE_LOCKED") == 0);
const bool qr_scene = (std::strcmp(scene_id, "SCENE_CAMERA_SCAN") == 0);
const bool qr_scene = (std::strcmp(scene_id, "SCENE_CAMERA_SCAN") == 0 ||
std::strcmp(scene_id, "SCENE_QR_DETECTOR") == 0);
if (static_state_changed && scene_changed && has_previous_scene) {
cleanupSceneTransitionAssets(last_scene_id_, scene_id);
}
@@ -5194,7 +5197,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
bg_rgb = 0x07070FUL;
accent_rgb = 0xFFB74EUL;
text_rgb = 0xF6FBFFUL;
} else if (std::strcmp(scene_id, "SCENE_BROKEN") == 0) {
} else if (std::strcmp(scene_id, "SCENE_BROKEN") == 0 || std::strcmp(scene_id, "SCENE_U_SON_PROTO") == 0) {
title = "PROTO U-SON";
subtitle = "Signal brouille";
symbol = "ALERT";
@@ -5202,6 +5205,14 @@ void UiManager::renderScene(const ScenarioDef* scenario,
bg_rgb = 0x2A0508UL;
accent_rgb = 0xFF4A45UL;
text_rgb = 0xFFD5D1UL;
} else if (std::strcmp(scene_id, "SCENE_WARNING") == 0) {
title = "ALERTE";
subtitle = "Signal anormal";
symbol = "WARN";
effect = SceneEffect::kBlink;
bg_rgb = 0x261209UL;
accent_rgb = 0xFF9A4AUL;
text_rgb = 0xFFF2E6UL;
} else if (std::strcmp(scene_id, "SCENE_LA_DETECTOR") == 0 || std::strcmp(scene_id, "SCENE_SEARCH") == 0) {
title = "DETECTEUR DE RESONNANCE";
subtitle = "";
@@ -5220,7 +5231,18 @@ void UiManager::renderScene(const ScenarioDef* scenario,
frame_split_layout = true;
frame_dy = 8;
}
} else if (std::strcmp(scene_id, "SCENE_CAMERA_SCAN") == 0) {
} else if (std::strcmp(scene_id, "SCENE_LEFOU_DETECTOR") == 0) {
title = "DETECTEUR LEFOU";
subtitle = "Analyse en cours";
symbol = "AUDIO";
effect = SceneEffect::kWave;
bg_rgb = 0x071B1AUL;
accent_rgb = 0x46E6C8UL;
text_rgb = 0xE9FFF9UL;
show_title = true;
show_subtitle = true;
show_symbol = true;
} else if (std::strcmp(scene_id, "SCENE_CAMERA_SCAN") == 0 || std::strcmp(scene_id, "SCENE_QR_DETECTOR") == 0) {
title = "ZACUS QR VALIDATION";
subtitle = "Scan du QR final";
symbol = "QR";
@@ -5292,7 +5314,9 @@ void UiManager::renderScene(const ScenarioDef* scenario,
accent_rgb = 0x66B4FFUL;
text_rgb = 0xF3F9FFUL;
show_symbol = false;
} else if (std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0) {
} else if (std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE1") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE2") == 0) {
title = "BRAVO!";
subtitle = audio_playing ? "Validation en cours..." : kWinEtapeWaitingSubtitle;
symbol = "WIN";
@@ -5308,6 +5332,17 @@ void UiManager::renderScene(const ScenarioDef* scenario,
win_etape_bravo_mode = true;
win_etape_fireworks = false;
subtitle_scroll_mode = SceneScrollMode::kNone;
} else if (std::strcmp(scene_id, "SCENE_FINAL_WIN") == 0) {
title = "FINAL WIN";
subtitle = "Mission accomplie";
symbol = "WIN";
effect = SceneEffect::kCelebrate;
bg_rgb = 0x1C0C2EUL;
accent_rgb = 0xFFCC5CUL;
text_rgb = 0xFFF7E4UL;
show_title = true;
show_subtitle = true;
show_symbol = true;
} else if (std::strcmp(scene_id, "SCENE_READY") == 0 || std::strcmp(scene_id, "SCENE_MEDIA_ARCHIVE") == 0) {
title = "PRET";
subtitle = "Scenario termine";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
#include "qr_unlock_app.h"
#include <cstdio>
bool QrUnlockApp::begin(const StoryAppContext& context) {
(void)context;
snapshot_.bindingId = binding_id_;
snapshot_.active = false;
snapshot_.status = "READY";
snapshot_.startedAtMs = 0U;
return true;
}
void QrUnlockApp::start(const StoryStepContext& stepContext) {
const char* binding_id = (stepContext.binding != nullptr) ? stepContext.binding->id : "APP_QR_UNLOCK";
std::snprintf(binding_id_, sizeof(binding_id_), "%s", (binding_id != nullptr) ? binding_id : "APP_QR_UNLOCK");
snapshot_.bindingId = binding_id_;
snapshot_.active = true;
snapshot_.status = "ACTIVE";
snapshot_.startedAtMs = stepContext.nowMs;
}
void QrUnlockApp::update(uint32_t nowMs, const StoryEventSink& sink) {
(void)nowMs;
(void)sink;
}
void QrUnlockApp::stop(const char* reason) {
snapshot_.active = false;
snapshot_.status = (reason != nullptr && reason[0] != '\0') ? reason : "STOPPED";
}
bool QrUnlockApp::handleEvent(const StoryEvent& event, const StoryEventSink& sink) {
(void)event;
(void)sink;
return false;
}
StoryAppSnapshot QrUnlockApp::snapshot() const {
return snapshot_;
}
@@ -0,0 +1,17 @@
#pragma once
#include "story_app.h"
class QrUnlockApp : public StoryApp {
public:
bool begin(const StoryAppContext& context) override;
void start(const StoryStepContext& stepContext) override;
void update(uint32_t nowMs, const StoryEventSink& sink) override;
void stop(const char* reason) override;
bool handleEvent(const StoryEvent& event, const StoryEventSink& sink) override;
StoryAppSnapshot snapshot() const override;
private:
char binding_id_[24] = {0};
StoryAppSnapshot snapshot_ = {};
};
@@ -31,7 +31,8 @@ bool StoryAppHost::begin(const StoryAppContext& context) {
context_ = context;
initialized_ = laDetectorApp_.begin(context_) && audioPackApp_.begin(context_) &&
screenSceneApp_.begin(context_) && mp3GateApp_.begin(context_) &&
wifiStackApp_.begin(context_) && espNowStackApp_.begin(context_);
wifiStackApp_.begin(context_) && espNowStackApp_.begin(context_) &&
qrUnlockApp_.begin(context_);
activeCount_ = 0U;
safeCopy(lastError_, sizeof(lastError_), initialized_ ? "OK" : "APP_BEGIN_FAIL");
lastDetail_[0] = '\0';
@@ -147,7 +148,8 @@ bool StoryAppHost::validateScenario(const ScenarioDef& scenario, StoryAppValidat
const bool supportedType =
(binding->type == StoryAppType::kLaDetector || binding->type == StoryAppType::kAudioPack ||
binding->type == StoryAppType::kScreenScene || binding->type == StoryAppType::kMp3Gate ||
binding->type == StoryAppType::kWifiStack || binding->type == StoryAppType::kEspNowStack);
binding->type == StoryAppType::kWifiStack || binding->type == StoryAppType::kEspNowStack ||
binding->type == StoryAppType::kQrUnlockApp);
if (!supportedType) {
local.ok = false;
local.code = "APP_BINDING_UNSUPPORTED";
@@ -205,6 +207,8 @@ StoryApp* StoryAppHost::appForType(StoryAppType type) {
return &wifiStackApp_;
case StoryAppType::kEspNowStack:
return &espNowStackApp_;
case StoryAppType::kQrUnlockApp:
return &qrUnlockApp_;
case StoryAppType::kNone:
default:
return nullptr;
@@ -6,6 +6,7 @@
#include "espnow_stack_app.h"
#include "la_detector_app.h"
#include "mp3_gate_app.h"
#include "qr_unlock_app.h"
#include "screen_scene_app.h"
#include "story_app.h"
#include "wifi_stack_app.h"
@@ -52,4 +53,5 @@ class StoryAppHost {
Mp3GateApp mp3GateApp_;
WifiStackApp wifiStackApp_;
EspNowStackApp espNowStackApp_;
QrUnlockApp qrUnlockApp_;
};
@@ -8,6 +8,8 @@ enum class StoryEventType : uint8_t {
kAudioDone,
kTimer,
kSerial,
kButton,
kEspNow,
kAction,
};
@@ -37,6 +39,7 @@ enum class StoryAppType : uint8_t {
kMp3Gate,
kWifiStack,
kEspNowStack,
kQrUnlockApp,
};
struct AppBindingDef {
@@ -48,6 +48,12 @@ StoryEventType parseEventType(const char* value) {
if (strcmp(value, "serial") == 0) {
return StoryEventType::kSerial;
}
if (strcmp(value, "button") == 0) {
return StoryEventType::kButton;
}
if (strcmp(value, "espnow") == 0 || strcmp(value, "esp_now") == 0) {
return StoryEventType::kEspNow;
}
if (strcmp(value, "action") == 0) {
return StoryEventType::kAction;
}
@@ -1,6 +1,6 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: a4e034ba637f
// spec_hash: aa9658456a0d
// scenarios: 5
#include "apps_gen.h"
@@ -13,6 +13,7 @@ constexpr AppBindingDef kGeneratedAppBindings[] = {
{"APP_ESPNOW", StoryAppType::kEspNowStack},
{"APP_GATE", StoryAppType::kMp3Gate},
{"APP_LA", StoryAppType::kLaDetector},
{"APP_QR_UNLOCK", StoryAppType::kQrUnlockApp},
{"APP_SCREEN", StoryAppType::kScreenScene},
{"APP_WIFI", StoryAppType::kWifiStack},
};
@@ -1,6 +1,6 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: a4e034ba637f
// spec_hash: aa9658456a0d
// scenarios: 5
#pragma once
@@ -1,6 +1,6 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: a4e034ba637f
// spec_hash: aa9658456a0d
// scenarios: 5
#include "scenarios_gen.h"
@@ -9,40 +9,43 @@
namespace {
constexpr const char* kSc0St0Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_HW_LED_READY",
};
constexpr const char* kSc0St0Apps[] = {
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St0Transitions[] = {
{"TR_STEP_WAIT_UNLOCK_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kUnlock, "UNLOCK", 0U, "STEP_U_SON_PROTO", 100U},
{"TR_STEP_WAIT_UNLOCK_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "STEP_WAIT_ETAPE2", 110U},
};
constexpr const char* kSc0St1Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
};
constexpr const char* kSc0St1Apps[] = {
constexpr const char* kSc0St0Apps[] = {
"APP_AUDIO",
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St0Transitions[] = {
{"TR_SCENE_U_SON_PROTO_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kAudioDone, "AUDIO_DONE", 0U, "SCENE_U_SON_PROTO", 80U},
{"TR_SCENE_U_SON_PROTO_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kButton, "ANY", 0U, "SCENE_LA_DETECTOR", 120U},
{"TR_SCENE_U_SON_PROTO_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_ETAPE2", 0U, "SCENE_LA_DETECTOR", 140U},
};
constexpr const char* kSc0St1Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_QUEUE_SONAR",
};
constexpr const char* kSc0St1Apps[] = {
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St1Transitions[] = {
{"TR_STEP_U_SON_PROTO_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kAudioDone, "AUDIO_DONE", 0U, "STEP_WAIT_ETAPE2", 100U},
{"TR_STEP_U_SON_PROTO_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "STEP_WAIT_ETAPE2", 110U},
{"TR_STEP_U_SON_PROTO_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_ETAPE2", 0U, "STEP_ETAPE2", 120U},
{"TR_SCENE_LA_DETECTOR_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kTimer, "ETAPE2_DUE", 0U, "SCENE_U_SON_PROTO", 100U},
{"TR_SCENE_LA_DETECTOR_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "RTC_ESP_ETAPE1", 110U},
{"TR_SCENE_LA_DETECTOR_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kUnlock, "UNLOCK", 0U, "RTC_ESP_ETAPE1", 115U},
{"TR_SCENE_LA_DETECTOR_4", StoryTransitionTrigger::kOnEvent, StoryEventType::kAction, "ACTION_FORCE_ETAPE2", 0U, "RTC_ESP_ETAPE1", 120U},
{"TR_SCENE_LA_DETECTOR_5", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_WIN_ETAPE1", 0U, "RTC_ESP_ETAPE1", 130U},
};
constexpr const char* kSc0St2Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_ESP_NOW_SEND_ETAPE1",
"ACTION_QUEUE_SONAR",
"ACTION_CAMERA_SNAPSHOT",
"ACTION_REC_START",
};
constexpr const char* kSc0St2Apps[] = {
"APP_AUDIO",
@@ -52,11 +55,8 @@ constexpr const char* kSc0St2Apps[] = {
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St2Transitions[] = {
{"TR_STEP_WAIT_ETAPE2_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kTimer, "ETAPE2_DUE", 0U, "STEP_ETAPE2", 100U},
{"TR_STEP_WAIT_ETAPE2_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "STEP_ETAPE2", 105U},
{"TR_STEP_WAIT_ETAPE2_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kUnlock, "UNLOCK", 0U, "STEP_ETAPE2", 110U},
{"TR_STEP_WAIT_ETAPE2_4", StoryTransitionTrigger::kOnEvent, StoryEventType::kAction, "ACTION_FORCE_ETAPE2", 0U, "STEP_ETAPE2", 115U},
{"TR_STEP_WAIT_ETAPE2_5", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_ETAPE2", 0U, "STEP_ETAPE2", 120U},
{"TR_RTC_ESP_ETAPE1_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kEspNow, "ACK_WIN1", 0U, "WIN_ETAPE1", 130U},
{"TR_RTC_ESP_ETAPE1_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_DONE", 0U, "WIN_ETAPE1", 120U},
};
constexpr const char* kSc0St3Actions[] = {
"ACTION_TRACE_STEP",
@@ -70,16 +70,100 @@ constexpr const char* kSc0St3Apps[] = {
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St3Transitions[] = {
{"TR_STEP_ETAPE2_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "STEP_DONE", 110U},
{"TR_STEP_ETAPE2_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_DONE", 0U, "STEP_DONE", 120U},
{"TR_WIN_ETAPE1_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "STEP_WARNING", 110U},
{"TR_WIN_ETAPE1_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_DONE", 0U, "STEP_WARNING", 120U},
{"TR_WIN_ETAPE1_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kEspNow, "ACK_WARNING", 0U, "STEP_WARNING", 130U},
};
constexpr const char* kSc0St4Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_REC_STOP",
"ACTION_HW_LED_READY",
"ACTION_REFRESH_SD",
"ACTION_HW_LED_ALERT",
};
constexpr const char* kSc0St4Apps[] = {
"APP_AUDIO",
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St4Transitions[] = {
{"TR_STEP_WARNING_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kAudioDone, "AUDIO_DONE", 0U, "STEP_WARNING", 80U},
{"TR_STEP_WARNING_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kButton, "ANY", 0U, "SCENE_LEFOU_DETECTOR", 120U},
{"TR_STEP_WARNING_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_ETAPE2", 0U, "SCENE_LEFOU_DETECTOR", 140U},
};
constexpr const char* kSc0St5Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_QUEUE_SONAR",
};
constexpr const char* kSc0St5Apps[] = {
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St5Transitions[] = {
{"TR_SCENE_LEFOU_DETECTOR_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kTimer, "ETAPE2_DUE", 0U, "STEP_WARNING", 100U},
{"TR_SCENE_LEFOU_DETECTOR_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "RTC_ESP_ETAPE2", 110U},
{"TR_SCENE_LEFOU_DETECTOR_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kUnlock, "UNLOCK", 0U, "RTC_ESP_ETAPE2", 115U},
{"TR_SCENE_LEFOU_DETECTOR_4", StoryTransitionTrigger::kOnEvent, StoryEventType::kAction, "ACTION_FORCE_ETAPE2", 0U, "RTC_ESP_ETAPE2", 120U},
{"TR_SCENE_LEFOU_DETECTOR_5", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_WIN_ETAPE2", 0U, "RTC_ESP_ETAPE2", 130U},
};
constexpr const char* kSc0St6Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_HW_LED_ALERT",
"ACTION_ESP_NOW_SEND_ETAPE2",
"ACTION_QUEUE_SONAR",
};
constexpr const char* kSc0St6Apps[] = {
"APP_AUDIO",
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St6Transitions[] = {
{"TR_RTC_ESP_ETAPE2_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kEspNow, "ACK_WIN2", 0U, "SCENE_QR_DETECTOR", 130U},
{"TR_RTC_ESP_ETAPE2_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_DONE", 0U, "SCENE_QR_DETECTOR", 120U},
};
constexpr const char* kSc0St7Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_QR_CODE_SCANNER_START",
};
constexpr const char* kSc0St7Apps[] = {
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
"APP_QR_UNLOCK",
};
constexpr TransitionDef kSc0St7Transitions[] = {
{"TR_SCENE_QR_DETECTOR_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "QR_OK", 0U, "SCENE_FINAL_WIN", 140U},
{"TR_SCENE_QR_DETECTOR_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kUnlock, "UNLOCK_QR", 0U, "SCENE_FINAL_WIN", 150U},
{"TR_SCENE_QR_DETECTOR_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "SCENE_FINAL_WIN", 110U},
{"TR_SCENE_QR_DETECTOR_4", StoryTransitionTrigger::kOnEvent, StoryEventType::kAction, "ACTION_FORCE_ETAPE2", 0U, "SCENE_FINAL_WIN", 120U},
{"TR_SCENE_QR_DETECTOR_5", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_WIN_ETAPE2", 0U, "SCENE_FINAL_WIN", 130U},
};
constexpr const char* kSc0St8Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_WINNER",
};
constexpr const char* kSc0St8Apps[] = {
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
"APP_ESPNOW",
};
constexpr TransitionDef kSc0St8Transitions[] = {
{"TR_SCENE_FINAL_WIN_1", StoryTransitionTrigger::kOnEvent, StoryEventType::kTimer, "WIN_DUE", 0U, "STEP_MEDIA_MANAGER", 140U},
{"TR_SCENE_FINAL_WIN_2", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "BTN_NEXT", 0U, "STEP_MEDIA_MANAGER", 110U},
{"TR_SCENE_FINAL_WIN_3", StoryTransitionTrigger::kOnEvent, StoryEventType::kUnlock, "UNLOCK", 0U, "STEP_MEDIA_MANAGER", 120U},
{"TR_SCENE_FINAL_WIN_4", StoryTransitionTrigger::kOnEvent, StoryEventType::kAction, "FORCE_WIN_ETAPE2", 0U, "STEP_MEDIA_MANAGER", 130U},
{"TR_SCENE_FINAL_WIN_5", StoryTransitionTrigger::kOnEvent, StoryEventType::kSerial, "FORCE_WIN_ETAPE2", 0U, "STEP_MEDIA_MANAGER", 125U},
};
constexpr const char* kSc0St9Actions[] = {
"ACTION_TRACE_STEP",
"ACTION_SET_BOOT_MEDIA_MANAGER",
};
constexpr const char* kSc0St9Apps[] = {
"APP_SCREEN",
"APP_GATE",
"APP_WIFI",
@@ -87,19 +171,24 @@ constexpr const char* kSc0St4Apps[] = {
};
constexpr StepDef kScenario0Steps[] = {
{"STEP_WAIT_UNLOCK", {"SCENE_LOCKED", nullptr, kSc0St0Actions, 2U, kSc0St0Apps, 4U}, kSc0St0Transitions, 2U, false},
{"STEP_U_SON_PROTO", {"SCENE_BROKEN", "PACK_BOOT_RADIO", kSc0St1Actions, 2U, kSc0St1Apps, 5U}, kSc0St1Transitions, 3U, false},
{"STEP_WAIT_ETAPE2", {"SCENE_LA_DETECTOR", nullptr, kSc0St2Actions, 4U, kSc0St2Apps, 5U}, kSc0St2Transitions, 5U, false},
{"STEP_ETAPE2", {"SCENE_WIN_ETAPE", "PACK_WIN", kSc0St3Actions, 2U, kSc0St3Apps, 5U}, kSc0St3Transitions, 2U, false},
{"STEP_DONE", {"SCENE_MEDIA_ARCHIVE", nullptr, kSc0St4Actions, 4U, kSc0St4Apps, 4U}, nullptr, 0U, true},
{"SCENE_U_SON_PROTO", {"SCENE_U_SON_PROTO", "PACK_BOOT_RADIO", kSc0St0Actions, 2U, kSc0St0Apps, 5U}, kSc0St0Transitions, 3U, false},
{"SCENE_LA_DETECTOR", {"SCENE_LA_DETECTOR", nullptr, kSc0St1Actions, 2U, kSc0St1Apps, 4U}, kSc0St1Transitions, 5U, false},
{"RTC_ESP_ETAPE1", {"SCENE_WIN_ETAPE1", "PACK_CONFIRM_WIN_ETAPE1", kSc0St2Actions, 4U, kSc0St2Apps, 5U}, kSc0St2Transitions, 2U, false},
{"WIN_ETAPE1", {"SCENE_WIN_ETAPE1", "PACK_WIN", kSc0St3Actions, 2U, kSc0St3Apps, 5U}, kSc0St3Transitions, 3U, false},
{"STEP_WARNING", {"SCENE_WARNING", "PACK_BOOT_RADIO", kSc0St4Actions, 2U, kSc0St4Apps, 5U}, kSc0St4Transitions, 3U, false},
{"SCENE_LEFOU_DETECTOR", {"SCENE_LEFOU_DETECTOR", nullptr, kSc0St5Actions, 2U, kSc0St5Apps, 4U}, kSc0St5Transitions, 5U, false},
{"RTC_ESP_ETAPE2", {"SCENE_WIN_ETAPE2", "PACK_CONFIRM_WIN_ETAPE2", kSc0St6Actions, 4U, kSc0St6Apps, 5U}, kSc0St6Transitions, 2U, false},
{"SCENE_QR_DETECTOR", {"SCENE_QR_DETECTOR", nullptr, kSc0St7Actions, 2U, kSc0St7Apps, 5U}, kSc0St7Transitions, 5U, false},
{"SCENE_FINAL_WIN", {"SCENE_FINAL_WIN", nullptr, kSc0St8Actions, 2U, kSc0St8Apps, 4U}, kSc0St8Transitions, 5U, false},
{"STEP_MEDIA_MANAGER", {"SCENE_MEDIA_MANAGER", nullptr, kSc0St9Actions, 2U, kSc0St9Apps, 4U}, nullptr, 0U, true},
};
constexpr ScenarioDef kScenario0 = {
"DEFAULT",
2U,
kScenario0Steps,
5U,
"STEP_WAIT_UNLOCK",
10U,
"SCENE_U_SON_PROTO",
};
constexpr const char* kSc1St0Actions[] = {
@@ -406,5 +495,5 @@ const char* generatedScenarioIdAt(uint8_t index) {
}
const char* generatedScenarioSpecHash() {
return "a4e034ba637f";
return "aa9658456a0d";
}
@@ -1,6 +1,6 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: a4e034ba637f
// spec_hash: aa9658456a0d
// scenarios: 5
#pragma once
@@ -13,11 +13,18 @@ struct SceneAliasDef {
constexpr ScreenSceneDef kScenes[] = {
{"SCENE_LOCKED", 0U, 0U},
{"SCENE_BROKEN", 0U, 0U},
{"SCENE_U_SON_PROTO", 0U, 0U},
{"SCENE_SEARCH", 1U, 1U},
{"SCENE_LA_DETECTOR", 1U, 1U},
{"SCENE_LEFOU_DETECTOR", 1U, 1U},
{"SCENE_WARNING", 1U, 1U},
{"SCENE_CAMERA_SCAN", 1U, 1U},
{"SCENE_QR_DETECTOR", 1U, 1U},
{"SCENE_SIGNAL_SPIKE", 1U, 2U},
{"SCENE_REWARD", 1U, 2U},
{"SCENE_WIN_ETAPE1", 1U, 2U},
{"SCENE_WIN_ETAPE2", 1U, 2U},
{"SCENE_FINAL_WIN", 1U, 2U},
{"SCENE_MEDIA_ARCHIVE", 2U, 2U},
{"SCENE_READY", 2U, 2U},
{"SCENE_WIN", 1U, 2U},
@@ -32,6 +39,8 @@ constexpr ScreenSceneDef kScenes[] = {
// Controlled legacy aliases used during the migration window.
constexpr SceneAliasDef kSceneAliases[] = {
{"SCENE_LA_DETECT", "SCENE_LA_DETECTOR"},
{"SCENE_U_SON", "SCENE_U_SON_PROTO"},
{"SCENE_LE_FOU_DETECTOR", "SCENE_LEFOU_DETECTOR"},
{"SCENE_LOCK", "SCENE_LOCKED"},
{"LOCKED", "SCENE_LOCKED"},
{"LOCK", "SCENE_LOCKED"},
@@ -28,8 +28,16 @@ except Exception: # pragma: no cover - dependency error surfaced at runtime
ALLOWED_TRIGGER = {"on_event", "after_ms", "immediate"}
ALLOWED_EVENT = {"none", "unlock", "audio_done", "timer", "serial", "action"}
ALLOWED_APP = {"LA_DETECTOR", "AUDIO_PACK", "SCREEN_SCENE", "MP3_GATE", "WIFI_STACK", "ESPNOW_STACK"}
ALLOWED_EVENT = {"none", "unlock", "audio_done", "timer", "serial", "button", "espnow", "action"}
ALLOWED_APP = {
"LA_DETECTOR",
"AUDIO_PACK",
"SCREEN_SCENE",
"MP3_GATE",
"WIFI_STACK",
"ESPNOW_STACK",
"QR_UNLOCK_APP",
}
EVENT_CPP = {
"none": "StoryEventType::kNone",
@@ -37,6 +45,8 @@ EVENT_CPP = {
"audio_done": "StoryEventType::kAudioDone",
"timer": "StoryEventType::kTimer",
"serial": "StoryEventType::kSerial",
"button": "StoryEventType::kButton",
"espnow": "StoryEventType::kEspNow",
"action": "StoryEventType::kAction",
}
TRIGGER_CPP = {
@@ -51,6 +61,7 @@ APP_CPP = {
"MP3_GATE": "StoryAppType::kMp3Gate",
"WIFI_STACK": "StoryAppType::kWifiStack",
"ESPNOW_STACK": "StoryAppType::kEspNowStack",
"QR_UNLOCK_APP": "StoryAppType::kQrUnlockApp",
}
DEFAULT_SCENE_PROFILE: dict[str, Any] = {
@@ -383,10 +394,173 @@ SCENE_PROFILES: dict[str, dict[str, Any]] = {
},
],
},
"SCENE_U_SON_PROTO": {
"title": "PROTO U-SON",
"subtitle": "Signal brouille",
"symbol": "ALERT",
"effect": "blink",
"effect_speed_ms": 180,
"theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"},
"transition": {"effect": "glitch", "duration_ms": 160},
"timeline": [
{
"at_ms": 0,
"effect": "blink",
"speed_ms": 180,
"theme": {"bg": "#2A0508", "accent": "#FF4A45", "text": "#FFF1F1"},
},
{
"at_ms": 900,
"effect": "scan",
"speed_ms": 520,
"theme": {"bg": "#3A0A10", "accent": "#FF7873", "text": "#FFF7F7"},
},
],
},
"SCENE_WARNING": {
"title": "ALERTE",
"subtitle": "Signal anormal",
"symbol": "WARN",
"effect": "blink",
"effect_speed_ms": 240,
"theme": {"bg": "#261209", "accent": "#FF9A4A", "text": "#FFF2E6"},
"transition": {"effect": "fade", "duration_ms": 200},
"timeline": [
{
"at_ms": 0,
"effect": "blink",
"speed_ms": 240,
"theme": {"bg": "#261209", "accent": "#FF9A4A", "text": "#FFF2E6"},
},
{
"at_ms": 1400,
"effect": "pulse",
"speed_ms": 520,
"theme": {"bg": "#31170C", "accent": "#FFC071", "text": "#FFF8EF"},
},
],
},
"SCENE_LEFOU_DETECTOR": {
"title": "DETECTEUR LEFOU",
"subtitle": "Analyse en cours",
"symbol": "AUDIO",
"effect": "wave",
"effect_speed_ms": 460,
"theme": {"bg": "#071B1A", "accent": "#46E6C8", "text": "#E9FFF9"},
"transition": {"effect": "zoom", "duration_ms": 250},
"timeline": [
{
"at_ms": 0,
"effect": "wave",
"speed_ms": 460,
"theme": {"bg": "#071B1A", "accent": "#46E6C8", "text": "#E9FFF9"},
},
{
"at_ms": 1200,
"effect": "radar",
"speed_ms": 620,
"theme": {"bg": "#0A2523", "accent": "#7FF2DA", "text": "#F2FFFC"},
},
],
},
"SCENE_WIN_ETAPE1": {
"title": "WIN ETAPE 1",
"subtitle": "Validation distante",
"symbol": "WIN",
"effect": "celebrate",
"effect_speed_ms": 360,
"theme": {"bg": "#1E0F32", "accent": "#F5C64A", "text": "#FFF8E4"},
"transition": {"effect": "zoom", "duration_ms": 280},
"timeline": [
{
"at_ms": 0,
"effect": "celebrate",
"speed_ms": 360,
"theme": {"bg": "#1E0F32", "accent": "#F5C64A", "text": "#FFF8E4"},
},
{
"at_ms": 1200,
"effect": "pulse",
"speed_ms": 260,
"theme": {"bg": "#2A1645", "accent": "#FFD97A", "text": "#FFFDF3"},
},
],
},
"SCENE_WIN_ETAPE2": {
"title": "WIN ETAPE 2",
"subtitle": "ACK en attente",
"symbol": "WIN",
"effect": "celebrate",
"effect_speed_ms": 340,
"theme": {"bg": "#220F3A", "accent": "#FFCE62", "text": "#FFF8EA"},
"transition": {"effect": "zoom", "duration_ms": 280},
"timeline": [
{
"at_ms": 0,
"effect": "celebrate",
"speed_ms": 340,
"theme": {"bg": "#220F3A", "accent": "#FFCE62", "text": "#FFF8EA"},
},
{
"at_ms": 1200,
"effect": "pulse",
"speed_ms": 260,
"theme": {"bg": "#2E1850", "accent": "#FFE18E", "text": "#FFFDF5"},
},
],
},
"SCENE_QR_DETECTOR": {
"title": "ZACUS QR VALIDATION",
"subtitle": "Scan du QR final",
"symbol": "QR",
"effect": "none",
"effect_speed_ms": 0,
"theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"},
"transition": {"effect": "fade", "duration_ms": 180},
"timeline": [
{
"at_ms": 0,
"effect": "none",
"speed_ms": 0,
"theme": {"bg": "#102040", "accent": "#5CA3FF", "text": "#F3F7FF"},
},
{
"at_ms": 1600,
"effect": "pulse",
"speed_ms": 520,
"theme": {"bg": "#142A52", "accent": "#8EC1FF", "text": "#FCFEFF"},
},
],
},
"SCENE_FINAL_WIN": {
"title": "FINAL WIN",
"subtitle": "Mission accomplie",
"symbol": "WIN",
"effect": "celebrate",
"effect_speed_ms": 320,
"theme": {"bg": "#1C0C2E", "accent": "#FFCC5C", "text": "#FFF7E4"},
"transition": {"effect": "fade", "duration_ms": 240},
"timeline": [
{
"at_ms": 0,
"effect": "celebrate",
"speed_ms": 320,
"theme": {"bg": "#1C0C2E", "accent": "#FFCC5C", "text": "#FFF7E4"},
},
{
"at_ms": 1400,
"effect": "blink",
"speed_ms": 220,
"theme": {"bg": "#2A1642", "accent": "#FFE18D", "text": "#FFFDF3"},
},
],
},
}
SCENE_ALIASES: dict[str, str] = {
"SCENE_LA_DETECT": "SCENE_LA_DETECTOR",
"SCENE_U_SON": "SCENE_U_SON_PROTO",
"SCENE_LE_FOU_DETECTOR": "SCENE_LEFOU_DETECTOR",
}
@@ -670,8 +844,10 @@ def _normalize_story_specs(files: list[Path]) -> list[dict[str, Any]]:
ValidationIssue(str(file_path), f"steps[{sidx}].transitions[{tidx}]", "must be mapping")
)
continue
trigger = str(transition.get("trigger", "on_event")).strip()
event_type = str(transition.get("event_type", "none")).strip()
trigger = str(transition.get("trigger", "on_event")).strip().lower()
event_type = str(transition.get("event_type", "none")).strip().lower()
if event_type == "esp_now":
event_type = "espnow"
event_name = str(transition.get("event_name", "")).strip()
target_step_id = str(transition.get("target_step_id", "")).strip()
after_ms = transition.get("after_ms", 0)
@@ -785,6 +961,67 @@ def _validate_game_scenarios(game_scenario_files: list[Path]) -> list[str]:
return sorted(ids)
def _classify_game_yaml(file_path: Path) -> str:
doc = _load_yaml(file_path)
has_runtime = bool(doc.get("initial_step")) and isinstance(doc.get("steps"), list)
has_narrative = bool(doc.get("title")) and isinstance(doc.get("stations"), list)
has_template = isinstance(doc.get("prompt_input"), dict)
if has_template and not has_runtime and not has_narrative:
return "template"
if has_runtime and has_narrative:
raise StoryGenerationError(f"Ambiguous game scenario type in {file_path}: both runtime and narrative keys detected")
if has_runtime:
return "runtime"
if has_narrative:
return "narrative"
raise StoryGenerationError(f"Unknown game scenario format in {file_path}: cannot detect runtime/narrative/template")
def _canonical_yaml_payload(path: Path) -> str:
data = _load_yaml(path)
return json.dumps(data, sort_keys=True, separators=(",", ":"))
def _validate_runtime_mirror(spec_files: list[Path], runtime_game_files: list[Path]) -> None:
spec_by_id: dict[str, Path] = {}
for spec_file in spec_files:
scenario_id = str(_load_yaml(spec_file).get("id", "")).strip()
if scenario_id:
spec_by_id[scenario_id] = spec_file
runtime_by_id: dict[str, Path] = {}
for runtime_file in runtime_game_files:
scenario_id = str(_load_yaml(runtime_file).get("id", "")).strip()
if scenario_id:
runtime_by_id[scenario_id] = runtime_file
if "DEFAULT" not in spec_by_id or "DEFAULT" not in runtime_by_id:
raise StoryGenerationError("Strict mirror requires DEFAULT runtime YAML in both docs/protocols and game/scenarios")
for scenario_id, runtime_file in runtime_by_id.items():
spec_file = spec_by_id.get(scenario_id)
if spec_file is None:
continue
if _canonical_yaml_payload(runtime_file) != _canonical_yaml_payload(spec_file):
raise StoryGenerationError(
"Runtime YAML mirror mismatch for scenario "
f"{scenario_id}: {runtime_file} != {spec_file}"
)
def _build_runtime_story_file_set(spec_files: list[Path], runtime_game_files: list[Path]) -> list[Path]:
selected: dict[str, Path] = {}
for spec_file in spec_files:
scenario_id = str(_load_yaml(spec_file).get("id", "")).strip()
if scenario_id:
selected[scenario_id] = spec_file
for runtime_file in runtime_game_files:
scenario_id = str(_load_yaml(runtime_file).get("id", "")).strip()
if scenario_id:
selected[scenario_id] = runtime_file
return [selected[key] for key in sorted(selected.keys())]
def _sha_hex(payload: bytes) -> str:
return hashlib.sha256(payload).hexdigest()
@@ -1381,11 +1618,29 @@ def load_and_validate(paths: StoryPaths, spec_dir: Path | None = None, game_dir:
spec_files = _list_yaml_files(actual_spec_dir)
game_files = _list_yaml_files(actual_game_dir)
_validate_yamale(_schema_path("story_spec_schema.yamale"), spec_files)
_validate_yamale(_schema_path("game_scenario_schema.yamale"), game_files)
runtime_game_files: list[Path] = []
narrative_game_files: list[Path] = []
template_files: list[Path] = []
for file_path in game_files:
category = _classify_game_yaml(file_path)
if category == "runtime":
runtime_game_files.append(file_path)
elif category == "narrative":
narrative_game_files.append(file_path)
elif category == "template":
template_files.append(file_path)
scenarios = _normalize_story_specs(spec_files)
game_ids = _validate_game_scenarios(game_files)
_validate_yamale(_schema_path("story_spec_schema.yamale"), spec_files)
if runtime_game_files:
_validate_yamale(_schema_path("story_spec_schema.yamale"), runtime_game_files)
if template_files:
_validate_yamale(_schema_path("scenario_template_schema.yamale"), template_files)
_validate_runtime_mirror(spec_files, runtime_game_files)
runtime_story_files = _build_runtime_story_file_set(spec_files, runtime_game_files)
scenarios = _normalize_story_specs(runtime_story_files)
game_ids = _validate_game_scenarios(narrative_game_files)
return scenarios, game_ids
@@ -0,0 +1,2 @@
prompt_input: any()
current_firmware_snapshot: any(required=False)
@@ -8,7 +8,7 @@ steps: list(include('step'), min=1)
---
app_binding:
id: str()
app: enum('LA_DETECTOR', 'AUDIO_PACK', 'SCREEN_SCENE', 'MP3_GATE', 'WIFI_STACK', 'ESPNOW_STACK')
app: enum('LA_DETECTOR', 'AUDIO_PACK', 'SCREEN_SCENE', 'MP3_GATE', 'WIFI_STACK', 'ESPNOW_STACK', 'QR_UNLOCK_APP')
config: include('la_config', required=False)
la_config:
@@ -28,7 +28,7 @@ step:
transition:
id: str(required=False)
trigger: enum('on_event', 'after_ms', 'immediate')
event_type: enum('none', 'unlock', 'audio_done', 'timer', 'serial', 'action')
event_type: enum('none', 'unlock', 'audio_done', 'timer', 'serial', 'button', 'espnow', 'action')
event_name: str()
target_step_id: str()
after_ms: int(min=0)