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:
@@ -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: []
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "6–14 ans (9–11 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 — l’installation 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 n’est pas la vitesse : c’est 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 qu’elle 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 l’accè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 l’alignement.
|
||||
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 l’ordre.
|
||||
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: "5–8"
|
||||
narrative: >
|
||||
Boot radio. Transmission tronquée.
|
||||
Zacus annonce que U-SON dérive et qu’il faut une référence stable pour recaler le système.
|
||||
|
||||
- step_id: STEP_LA_DETECTOR
|
||||
scene: SCENE_LA_DETECTOR
|
||||
act: 1
|
||||
timebox_minutes: "30–35"
|
||||
narrative: >
|
||||
Atelier des Ondes. Objectif : produire un LA 440 stable.
|
||||
Le groupe s’organise : une personne fait le son, d’autres surveillent le feedback.
|
||||
|
||||
- step_id: STEP_RTC_ESP_ETAPE1
|
||||
scene: SCENE_WIN_ETAPE1
|
||||
act: 1
|
||||
timebox_minutes: "2–4"
|
||||
narrative: >
|
||||
Transmission distante (ESP-NOW). L’ACK confirme la validation Étape 1.
|
||||
|
||||
- step_id: STEP_WIN_ETAPE1
|
||||
scene: SCENE_WIN_ETAPE1
|
||||
act: 1
|
||||
timebox_minutes: "5–8"
|
||||
narrative: >
|
||||
Victoire 1. Puis le campus bascule en WARNING : direction Zone 4.
|
||||
|
||||
- step_id: STEP_WARNING
|
||||
scene: SCENE_WARNING
|
||||
act: 2
|
||||
timebox_minutes: "5–10"
|
||||
narrative: >
|
||||
Alerte rouge. Message clair : Zone 4, l’installation de l’Électron Fou.
|
||||
Le système annonce qu’un code musical (5 lettres) sera requis.
|
||||
|
||||
- step_id: STEP_LEFOU_DETECTOR
|
||||
scene: SCENE_LEFOU_DETECTOR
|
||||
act: 2
|
||||
timebox_minutes: "25–35"
|
||||
narrative: >
|
||||
Zone 4. Piano-alphabet : les lettres sont sur les touches blanches.
|
||||
L’équipe doit jouer LEFOU (L-E-F-O-U) pour prouver qu’elle a compris la règle.
|
||||
|
||||
- step_id: STEP_RTC_ESP_ETAPE2
|
||||
scene: SCENE_WIN_ETAPE2
|
||||
act: 2
|
||||
timebox_minutes: "2–4"
|
||||
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: "10–15"
|
||||
narrative: >
|
||||
Aux Archives : chercher derrière le portrait, récupérer le QR, et le scanner.
|
||||
QR “WIN” valide l’accès final.
|
||||
|
||||
- step_id: STEP_FINAL_WIN
|
||||
scene: SCENE_FINAL_WIN
|
||||
act: 2
|
||||
timebox_minutes: "5–8"
|
||||
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 9–11 (compréhensible 6–14)"
|
||||
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 à l’Atelier 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.
|
||||
L’installation 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 d’alarme, 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, c’est 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 l’indice. 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 A–Z étiquetées sur les touches blanches (stickers)."
|
||||
placement_note: >
|
||||
Utiliser 26 touches blanches consécutives (gauche→droite) et coller A→Z.
|
||||
L’ordre 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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
+241
-68
@@ -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"
|
||||
```
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+40
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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';
|
||||
}
|
||||
+7
@@ -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
|
||||
|
||||
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
prompt_input: any()
|
||||
current_firmware_snapshot: any(required=False)
|
||||
+2
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user