docs: importer zacus_conversation_bundle_v3 et clôturer le blocage ZIP intake #106
@@ -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: []
|
||||
@@ -0,0 +1,215 @@
|
||||
# Scenario reel - template lisible + promptable.
|
||||
#
|
||||
# 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:
|
||||
request_name: "A_COMPLETER"
|
||||
owner: "A_COMPLETER"
|
||||
board_target: "freenove_esp32s3"
|
||||
objective: "A_COMPLETER"
|
||||
|
||||
boot_policy:
|
||||
startup_mode: "story" # story | media_manager
|
||||
persist_after_validation: true
|
||||
rollback_command_required: true
|
||||
|
||||
qr_rule:
|
||||
enabled: true
|
||||
scene_id: "SCENE_QR_DETECTOR"
|
||||
expected_payload: "A_COMPLETER"
|
||||
match_mode: "exact" # exact | prefix | contains
|
||||
case_insensitive: false
|
||||
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"
|
||||
- "SCENE_MP3_PLAYER"
|
||||
- "SCENE_READY"
|
||||
|
||||
led_policy:
|
||||
strict_rgb: true
|
||||
defaults:
|
||||
ws2812: 1
|
||||
led_auto: 1
|
||||
rgb: "18/45/95"
|
||||
scene_overrides:
|
||||
- scene_id: "SCENE_LA_DETECTOR"
|
||||
rgb: "32/224/170"
|
||||
|
||||
custom_steps:
|
||||
# Format cible:
|
||||
# - step_id: "STEP_X"
|
||||
# scene: "SCENE_X"
|
||||
# audio_pack: ""
|
||||
# actions: ["ACTION_TRACE_STEP"]
|
||||
# transitions:
|
||||
# - "serial:BTN_NEXT->STEP_Y"
|
||||
- "A_COMPLETER"
|
||||
|
||||
serial_test_plan:
|
||||
- "SC_LOAD DEFAULT"
|
||||
- "SCENE_GOTO SCENE_QR_DETECTOR"
|
||||
- "QR_SIM A_COMPLETER"
|
||||
- "SC_EVENT espnow ACK_WIN1"
|
||||
- "SC_EVENT button ANY"
|
||||
- "BOOT_MODE_STATUS"
|
||||
- "RESET"
|
||||
|
||||
acceptance_criteria:
|
||||
- "Pas de PANIC|ASSERT|ABORT|REBOOT|rst:"
|
||||
- "Transitions scene avec ACK ... ok=1"
|
||||
- "Ownership camera/mic/amp coherent"
|
||||
- "LED WS2812 conformes policy + RGB"
|
||||
- "Boot mode media_manager persistant apres QR valide"
|
||||
|
||||
notes:
|
||||
- "Decrire ici contraintes gameplay reelles."
|
||||
- "Ajouter exceptions/aliases scene si necessaire."
|
||||
|
||||
scenario:
|
||||
id: "DEFAULT"
|
||||
version: 2
|
||||
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: "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->SCENE_U_SON_PROTO"
|
||||
- "button:ANY->SCENE_LA_DETECTOR"
|
||||
- "serial:FORCE_ETAPE2->SCENE_LA_DETECTOR"
|
||||
|
||||
- step_id: "SCENE_LA_DETECTOR"
|
||||
screen_scene_id: "SCENE_LA_DETECTOR"
|
||||
audio_pack_id: ""
|
||||
actions:
|
||||
- "ACTION_TRACE_STEP"
|
||||
- "ACTION_QUEUE_SONAR"
|
||||
transitions:
|
||||
- "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: "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_WARNING"
|
||||
- "serial:FORCE_DONE->STEP_WARNING"
|
||||
- "espnow:ACK_WARNING->STEP_WARNING"
|
||||
|
||||
- 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_QUEUE_SONAR"
|
||||
transitions:
|
||||
- "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_MANAGER"
|
||||
transitions: []
|
||||
|
||||
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.
|
||||
@@ -56,3 +56,4 @@ __pycache__/
|
||||
|
||||
# Autres à adapter selon besoins
|
||||
tools/Voicebox.app/
|
||||
hardware/ChatGPT_file_exemple/
|
||||
|
||||
@@ -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,73 @@
|
||||
{
|
||||
"id": "SCENE_LA_DETECT",
|
||||
"title": "DETECTEUR DE RESONNANCE",
|
||||
"subtitle": "",
|
||||
"symbol": "AUDIO",
|
||||
"effect": "wave",
|
||||
"visual": {
|
||||
"show_title": true,
|
||||
"show_symbol": true,
|
||||
"effect_speed_ms": 480,
|
||||
"waveform": {
|
||||
"enabled": true,
|
||||
"sample_count": 16,
|
||||
"amplitude_pct": 100,
|
||||
"jitter": true
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#49D9FF",
|
||||
"text": "#E8F6FF"
|
||||
},
|
||||
"timeline": {
|
||||
"loop": true,
|
||||
"duration_ms": 2400,
|
||||
"keyframes": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"effect": "wave",
|
||||
"speed_ms": 480,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#49D9FF",
|
||||
"text": "#E8F6FF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 800,
|
||||
"effect": "radar",
|
||||
"speed_ms": 620,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#7EE8FF",
|
||||
"text": "#F2FAFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 1600,
|
||||
"effect": "wave",
|
||||
"speed_ms": 340,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#D8FF6B",
|
||||
"text": "#F9FFD8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 2400,
|
||||
"effect": "radar",
|
||||
"speed_ms": 700,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#49D9FF",
|
||||
"text": "#E8F6FF"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"transition": {
|
||||
"effect": "zoom",
|
||||
"duration_ms": 260
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@
|
||||
{
|
||||
"id": "ACTION_SET_BOOT_MEDIA",
|
||||
"type": "set_boot_mode",
|
||||
"config": {
|
||||
"mode": "media_manager",
|
||||
"persist_nvs": 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,67 +19,423 @@
|
||||
"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_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"
|
||||
],
|
||||
"transitions": [
|
||||
{
|
||||
"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_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"
|
||||
],
|
||||
"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_SCENE_FINAL_WIN_2",
|
||||
"trigger": "on_event",
|
||||
"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
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "story_selector",
|
||||
|
||||
@@ -1,32 +1,51 @@
|
||||
{
|
||||
"id": "SCENE_CAMERA_SCAN",
|
||||
"title": "CAMERA RECORDER",
|
||||
"subtitle": "Mode capture",
|
||||
"symbol": "",
|
||||
"title": "ZACUS QR VALIDATION",
|
||||
"subtitle": "Scanne le QR final",
|
||||
"symbol": "QR",
|
||||
"effect": "none",
|
||||
"qr": {
|
||||
"expected": [
|
||||
"ZACUS:MEDIA_MANAGER",
|
||||
"MEDIA_MANAGER_OK"
|
||||
],
|
||||
"prefix": "ZACUS:",
|
||||
"contains": "MEDIA_MANAGER",
|
||||
"caseInsensitive": true
|
||||
},
|
||||
"visual": {
|
||||
"show_title": false,
|
||||
"show_subtitle": false,
|
||||
"show_symbol": false,
|
||||
"show_title": true,
|
||||
"show_subtitle": true,
|
||||
"show_symbol": true,
|
||||
"effect_speed_ms": 0
|
||||
},
|
||||
"theme": {
|
||||
"bg": "#050A14",
|
||||
"accent": "#6EDBFF",
|
||||
"text": "#EAF6FF"
|
||||
"bg": "#102040",
|
||||
"accent": "#5CA3FF",
|
||||
"text": "#F3F7FF"
|
||||
},
|
||||
"timeline": {
|
||||
"loop": true,
|
||||
"duration_ms": 1200,
|
||||
"duration_ms": 1400,
|
||||
"keyframes": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"effect": "none",
|
||||
"speed_ms": 0,
|
||||
"theme": {
|
||||
"bg": "#050A14",
|
||||
"accent": "#6EDBFF",
|
||||
"text": "#EAF6FF"
|
||||
"bg": "#102040",
|
||||
"accent": "#5CA3FF",
|
||||
"text": "#F3F7FF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 700,
|
||||
"effect": "pulse",
|
||||
"speed_ms": 520,
|
||||
"theme": {
|
||||
"bg": "#162B52",
|
||||
"accent": "#89BEFF",
|
||||
"text": "#F7FBFF"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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,73 @@
|
||||
{
|
||||
"id": "SCENE_LA_DETECT",
|
||||
"title": "DETECTEUR DE RESONNANCE",
|
||||
"subtitle": "",
|
||||
"symbol": "AUDIO",
|
||||
"effect": "wave",
|
||||
"visual": {
|
||||
"show_title": true,
|
||||
"show_symbol": true,
|
||||
"effect_speed_ms": 480,
|
||||
"waveform": {
|
||||
"enabled": true,
|
||||
"sample_count": 16,
|
||||
"amplitude_pct": 100,
|
||||
"jitter": true
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#49D9FF",
|
||||
"text": "#E8F6FF"
|
||||
},
|
||||
"timeline": {
|
||||
"loop": true,
|
||||
"duration_ms": 2400,
|
||||
"keyframes": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"effect": "wave",
|
||||
"speed_ms": 480,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#49D9FF",
|
||||
"text": "#E8F6FF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 800,
|
||||
"effect": "radar",
|
||||
"speed_ms": 620,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#7EE8FF",
|
||||
"text": "#F2FAFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 1600,
|
||||
"effect": "wave",
|
||||
"speed_ms": 340,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#D8FF6B",
|
||||
"text": "#F9FFD8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 2400,
|
||||
"effect": "radar",
|
||||
"speed_ms": 700,
|
||||
"theme": {
|
||||
"bg": "#000000",
|
||||
"accent": "#49D9FF",
|
||||
"text": "#E8F6FF"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"transition": {
|
||||
"effect": "zoom",
|
||||
"duration_ms": 260
|
||||
}
|
||||
}
|
||||
@@ -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,48 @@
|
||||
{
|
||||
"id": "SCENE_MEDIA_MANAGER",
|
||||
"title": "MEDIA MANAGER",
|
||||
"subtitle": "PHOTO / MP3 / STORY",
|
||||
"symbol": "MEDIA",
|
||||
"effect": "radar",
|
||||
"visual": {
|
||||
"show_title": true,
|
||||
"show_subtitle": true,
|
||||
"show_symbol": true,
|
||||
"effect_speed_ms": 620
|
||||
},
|
||||
"theme": {
|
||||
"bg": "#081A34",
|
||||
"accent": "#8BC4FF",
|
||||
"text": "#EAF6FF"
|
||||
},
|
||||
"timeline": {
|
||||
"loop": true,
|
||||
"duration_ms": 1800,
|
||||
"keyframes": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"effect": "radar",
|
||||
"speed_ms": 620,
|
||||
"theme": {
|
||||
"bg": "#081A34",
|
||||
"accent": "#8BC4FF",
|
||||
"text": "#EAF6FF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"at_ms": 900,
|
||||
"effect": "pulse",
|
||||
"speed_ms": 520,
|
||||
"theme": {
|
||||
"bg": "#0C2448",
|
||||
"accent": "#A6D4FF",
|
||||
"text": "#F5FAFF"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"transition": {
|
||||
"effect": "fade",
|
||||
"duration_ms": 220
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "SCENE_PHOTO_MANAGER",
|
||||
"title": "PHOTO MANAGER",
|
||||
"subtitle": "Capture / sauvegarde JPEG",
|
||||
"symbol": "PHOTO",
|
||||
"effect": "none",
|
||||
"visual": {
|
||||
"show_title": true,
|
||||
"show_subtitle": true,
|
||||
"show_symbol": true,
|
||||
"effect_speed_ms": 0
|
||||
},
|
||||
"theme": {
|
||||
"bg": "#0B1A2E",
|
||||
"accent": "#86CCFF",
|
||||
"text": "#EEF6FF"
|
||||
},
|
||||
"timeline": {
|
||||
"loop": true,
|
||||
"duration_ms": 1200,
|
||||
"keyframes": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"effect": "none",
|
||||
"speed_ms": 0,
|
||||
"theme": {
|
||||
"bg": "#0B1A2E",
|
||||
"accent": "#86CCFF",
|
||||
"text": "#EEF6FF"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"transition": {
|
||||
"effect": "fade",
|
||||
"duration_ms": 200
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
PNG UI assets for SCENE_CAMERA_SCAN overlay.
|
||||
|
||||
Files:
|
||||
- ok.png / ok_48.png : success icon (neon check)
|
||||
- bad.png / bad_48.png : failure icon (neon cross)
|
||||
- scanlines.png : subtle scanlines tile (16x16 RGBA)
|
||||
- reticle.png : reticle overlay (256x256 RGBA)
|
||||
|
||||
These are provided for easy customization. The default firmware UI uses embedded LVGL image descriptors.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 164 B |
@@ -1,3 +1,229 @@
|
||||
## [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:
|
||||
- inclure ESP-NOW dans le passage vers `STEP_MEDIA_MANAGER`,
|
||||
- corriger l'action de boot media qui provoquait un warning buildfs.
|
||||
- Actions:
|
||||
- action renommee `ACTION_SET_BOOT_MEDIA` (`data/story/actions/ACTION_SET_BOOT_MEDIA.json`) pour rester compatible LittleFS.
|
||||
- references scenario mises a jour:
|
||||
- `docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml`
|
||||
- `data/story/scenarios/DEFAULT.json`
|
||||
- transition ESP-NOW ajoutee:
|
||||
- `on_event espnow QR_OK -> STEP_MEDIA_MANAGER`.
|
||||
- compat runtime conservee dans `main.cpp`:
|
||||
- accepte `ACTION_SET_BOOT_MEDIA` + alias legacy `ACTION_SET_BOOT_MEDIA_MANAGER`.
|
||||
- Gates:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3_full_with_ui -t buildfs` ✅
|
||||
- warning `unable to open '/story/actions/ACTION_SET_BOOT_MEDIA_MANAGER.json.` disparu.
|
||||
|
||||
## [2026-02-25] Passes completes ChatGPT examples -> firmware (QR + media + gates)
|
||||
|
||||
- Skills utilises:
|
||||
- `chatgpt-file-exemple-intake`
|
||||
- `freenove-firmware-orchestrator`
|
||||
- Pass 1 (QR assets):
|
||||
- integration des assets exemples dans `data/ui/qr/` (`ok.png`, `ok_48.png`, `bad.png`, `bad_48.png`, `reticle.png`, `scanlines.png`, `README.txt`).
|
||||
- hook runtime ajoute dans `ui/qr/qr_scene_controller.cpp` avec trace presence assets (`[QR_UI] assets ...`).
|
||||
- Pass 2 (audio/media depuis exemples):
|
||||
- extraction logique "natural sort" pour catalogues media dans `ui_freenove_allinone/src/system/media/media_manager.cpp`.
|
||||
- `MEDIA_LIST` retourne maintenant un ordre stable/humain (`track2` avant `track10`).
|
||||
- Pass 3 (QR tool + full gates):
|
||||
- `tools/dev/gen_qr_crc16.py` etendu avec `--scope data|suffix` + `--prefix`.
|
||||
- builds + uploads executes:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3_full_with_ui -t buildfs` ✅ (warning LittleFS deja connu sur `ACTION_SET_BOOT_MEDIA_MANAGER.json`)
|
||||
- `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` ✅
|
||||
- verifications executees:
|
||||
- `serial_smoke.py` ✅
|
||||
- `scene-verificator` ✅
|
||||
- `fx-verificator` ✅ (1er run panic puis rerun PASS apres reset)
|
||||
- `hal-verificator-status` ✅
|
||||
- `media-manager` ✅
|
||||
- `run_stress_tests.py` ❌ (scenario `DEFAULT` n'atteint pas `STEP_DONE`, sans panic sur ce run).
|
||||
|
||||
## [2026-02-25] Intake fichiers ChatGPT_file_exemple (integration controlee)
|
||||
|
||||
- Skills utilises:
|
||||
- `chatgpt-file-exemple-intake` (inventaire + mapping source->cible)
|
||||
- `freenove-firmware-orchestrator` (respect architecture Freenove existante)
|
||||
- Inventaire execute sur `hardware/ChatGPT_file_exemple/**`:
|
||||
- candidats utiles identifies sur QR flow (`qr_scan_controller.*`, `ui_manager.*`, `gen_qr_crc16.py`, assets PNG).
|
||||
- Decision d'integration:
|
||||
- conserve architecture canonique actuelle sous `hardware/firmware/**` (pas de copie brute des arbres imbriques `.../hardware/firmware/hardware/firmware/...`).
|
||||
- extraction d'un delta utile depuis l'exemple QR:
|
||||
- `tools/dev/gen_qr_crc16.py` enrichi avec:
|
||||
- `--scope data|suffix`
|
||||
- `--prefix <text>` (obligatoire en scope `suffix`)
|
||||
- validation prefix + calcul CRC sur suffixe.
|
||||
- fichiers exemples non integres tels quels (assets/UI patch complet) car deja couverts partiellement ou non references runtime.
|
||||
- Validation rapide:
|
||||
- `python3 tools/dev/gen_qr_crc16.py "ZACUS:ETAPE1" --ci --scope data` ✅
|
||||
- `python3 tools/dev/gen_qr_crc16.py "ZACUS:ETAPE1:42" --ci --scope suffix --prefix "ZACUS:"` ✅
|
||||
|
||||
## [2026-02-25] Scenario reel template - sync etat code + format promptable
|
||||
|
||||
- Fichier mis a jour: `game/scenarios/scenario_reel_template.yaml`
|
||||
- Refonte du template pour usage "promptable":
|
||||
- section editable unique `prompt_input` (besoin metier),
|
||||
- section reference `current_firmware_snapshot` (etat runtime actuel derive du code/spec).
|
||||
- Snapshot aligne sur l'etat courant:
|
||||
- flow `DEFAULT` avec final `QR_OK -> STEP_MEDIA_MANAGER`,
|
||||
- scenes hub media (`SCENE_MEDIA_MANAGER`, `SCENE_MP3_PLAYER`, `SCENE_PHOTO_MANAGER`, `SCENE_READY`),
|
||||
- HAL/LED runtime connus pour scenes critiques.
|
||||
- Validation:
|
||||
- parse YAML OK (`python3` + `yaml.safe_load`).
|
||||
|
||||
## [2026-02-25] Etat scenario + template de saisie "reel"
|
||||
|
||||
- Etat courant source spec:
|
||||
- `docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml`
|
||||
- flow actif: `STEP_WAIT_UNLOCK -> STEP_U_SON_PROTO/STEP_WAIT_ETAPE2 -> STEP_ETAPE2 -> STEP_DONE (SCENE_CAMERA_SCAN) -> STEP_MEDIA_MANAGER (SCENE_MEDIA_MANAGER via QR_OK)`.
|
||||
- Nouveau fichier de saisie simplifie pour expression besoin metier:
|
||||
- `game/scenarios/scenario_reel_template.yaml`
|
||||
- format orienté "brief scenario reel" (catalogue scenes, besoins HAL, LED, steps, transitions, acceptance).
|
||||
- usage: l'utilisateur edite ce template, puis Codex convertit en scenario YAML canonique + JSON runtime.
|
||||
|
||||
## [2026-02-25] HAL skill update - WS2812 (4 LED Freenove)
|
||||
|
||||
- Skill global `hal-verificator-status` etendu pour verifier aussi les LED WS2812:
|
||||
- `HW_STATUS ws2812` (`ws2812=0|1`)
|
||||
- `HW_STATUS auto` (`led_auto=0|1`)
|
||||
- couleur exacte optionnelle `HW_STATUS led=R,G,B` (`led=R/G/B` ou `led=R,G,B`)
|
||||
- Script mis a jour:
|
||||
- `~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh`
|
||||
- parsing expectations enrichi (`cam|amp|mic|ws2812|led_auto|led`)
|
||||
- verdict ligne detaillee avec etats LED.
|
||||
- Documentation skill:
|
||||
- global `~/.codex/skills/hal-verificator-status/SKILL.md`
|
||||
- global `~/.codex/skills/hal-verificator-status/references/checklist.md`
|
||||
- miroir repo `docs/skills/hal-verificator-status.md`
|
||||
- Verification rapide:
|
||||
- `run_hal_verification.sh /dev/cu.usbmodem5AB90753301 115200 \"SCENE_READY:cam=0,amp=0,mic=0,ws2812=1,led_auto=1;SCENE_MP3_PLAYER:cam=0,amp=1,ws2812=1,led_auto=1;SCENE_CAMERA_SCAN:cam=0,amp=0,ws2812=1,led_auto=1\"` ✅
|
||||
|
||||
## [2026-02-25] QR final + boot MEDIA_MANAGER + skill media-manager
|
||||
|
||||
- Checkpoint securite execute:
|
||||
- `/tmp/zacus_checkpoint/20260225_102014_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260225_102014_status.txt`
|
||||
- Firmware/UI:
|
||||
- ajout module QR `ui/qr/qr_scan_controller.*` + integration `UiManager` (`consumeRuntimeEvent`, `simulateQrPayload`, parsing regles `qr.expected/prefix/contains`).
|
||||
- pont runtime event UI -> story (`SERIAL:QR_OK` / `SERIAL:QR_INVALID`) dans `main.cpp`.
|
||||
- ajout persistance NVS `BootModeStore` (`zacus_boot`, `startup_mode`, `media_validated`) + commandes `BOOT_MODE_STATUS|SET|CLEAR`.
|
||||
- action story `ACTION_SET_BOOT_MEDIA_MANAGER` applique mode persistant `media_manager`.
|
||||
- `SCENE_CAMERA_SCAN` repasse en QR-only, nouvelle scene `SCENE_PHOTO_MANAGER`, scene hub `SCENE_MEDIA_MANAGER`.
|
||||
- policy reset: `RESET` reroute vers `SCENE_MEDIA_MANAGER` si mode boot media actif.
|
||||
- Story/data:
|
||||
- MAJ scenario YAML: `docs/protocols/story_specs/scenarios/default_unlock_win_etape2.yaml` (transition `QR_OK` -> `STEP_MEDIA_MANAGER`).
|
||||
- MAJ runtime JSON: `data/story/scenarios/DEFAULT.json`, `data/story/screens/SCENE_CAMERA_SCAN.json`, ajout `SCENE_MEDIA_MANAGER.json`, `SCENE_PHOTO_MANAGER.json`, `ACTION_SET_BOOT_MEDIA_MANAGER.json`.
|
||||
- generation: `./tools/dev/story-gen validate` ✅, `./tools/dev/story-gen generate-bundle` ✅ (`artifacts/story_fs/deploy`).
|
||||
- Skills:
|
||||
- creation globale `~/.codex/skills/media-manager/` (`SKILL.md`, `agents/openai.yaml`, `scripts/run_media_manager_verification.sh`, `references/checklist.md`).
|
||||
- miroir repo: `docs/skills/media-manager.md`.
|
||||
- Gates executes:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3_full_with_ui -t buildfs` ✅ (warning d'indexation FS a surveiller sur `ACTION_SET_BOOT_MEDIA_MANAGER.json`, build final OK)
|
||||
- `python3 tools/dev/serial_smoke.py --role esp32 --port /dev/cu.usbmodem5AB90753301 --baud 115200 --timeout 8 --wait-port 10` ✅
|
||||
|
||||
## [2026-02-25] Skill HAL_VERIFCATOR_STATUS (scene-aware hardware gating)
|
||||
|
||||
- Creation skill global:
|
||||
- `~/.codex/skills/hal-verificator-status/SKILL.md`
|
||||
- `~/.codex/skills/hal-verificator-status/agents/openai.yaml`
|
||||
- `~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh`
|
||||
- `~/.codex/skills/hal-verificator-status/references/checklist.md`
|
||||
- Scope validation couverte:
|
||||
- camera (`CAM_STATUS rec_scene`), amp (`AMP_STATUS scene`), micro (`RESOURCE_STATUS mic_should_run`).
|
||||
- verification activation/desactivation par scene via plan explicite `SCENE:cam=,amp=,mic=`.
|
||||
- Miroir doc versionne:
|
||||
- `docs/skills/hal-verificator-status.md`
|
||||
- Ajustement robustesse:
|
||||
- `AMP_STATUS ready=0` interprete comme `amp=0` (pas de faux negatif quand player non initialise).
|
||||
- attentes partielles supportees par scene (`cam|amp|mic` optionnels).
|
||||
|
||||
## [2026-02-25] Follow-up verificators (scene/fx/hal)
|
||||
|
||||
- `scene-verificator`:
|
||||
- alias triggers ajoutes (`BTN_*` -> `SC_EVENT serial`, noms pointes -> `SC_EVENT_RAW`).
|
||||
- argument trigger vide supporte (desactive les triggers custom sans fallback implicite).
|
||||
- critere global assoupli: accepte progression via `SCREEN_SYNC` ou changements `scene/status`.
|
||||
- `fx-verificator`:
|
||||
- script etendu (`scenes_csv`, `seconds_per_scene`) et verdict per-scene (`max(fx_fps)>0`).
|
||||
- Gates executes:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3_full_with_ui -t buildfs` ✅
|
||||
- `python3 tools/dev/serial_smoke.py --role esp32 --port /dev/cu.usbmodem5AB90753301 --baud 115200 --timeout 8 --wait-port 10` ✅
|
||||
- Resultats a investiguer:
|
||||
- `scene-verificator` avec trigger `story.validate` provoque panic serie (`Guru Meditation ... Unhandled debug exception`).
|
||||
- `hal-verificator` montre `SCENE_MP3_PLAYER` avec `mic_should_run=1` (si on force `mic=0`, echec attendu).
|
||||
- Integration changement detecte dans workspace:
|
||||
- fichier `data/screens/la_detect.json` integre au scope utilisateur.
|
||||
- ajout alias story `data/story/screens/SCENE_LA_DETECT.json` pour coherer avec `SCENE_LA_DETECT` deja supporte cote runtime.
|
||||
- gate `pio run -e freenove_esp32s3_full_with_ui -t buildfs` relancee ✅ (fichier inclus dans LittleFS).
|
||||
|
||||
## [2026-02-25] Scene/FX orchestrator refactor + VERIFICATOR skills
|
||||
|
||||
- Checkpoint securite:
|
||||
- `/tmp/zacus_checkpoint/20260225_093916_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260225_093916_status.txt`
|
||||
- Refactor runtime:
|
||||
- ajout orchestrateur `SceneFxOrchestrator` (`ui_freenove_allinone/include|src/app/scene_fx_orchestrator.*`) pour planifier ownership `Story/IntroFx/DirectFx/Amp/Camera`.
|
||||
- `main.cpp`: ordre explicite transition owner `pre-exit -> render scene -> post-enter`.
|
||||
- `UiManager`: cache scene payload hash + `audio_playing` pour eviter reinitialisations statiques inutiles; cleanup FX recentre.
|
||||
- `ScenarioManager`: logs de transition structures (`from_step`, `to_step`, `from_scene`, `to_scene`, `event`, `source`, `audio_pack`) + trace chaines immediates.
|
||||
- `resolve_ports.py`: remap mono-port robuste (`esp8266 -> esp32`) pour setup Freenove single-usb.
|
||||
- Skills crees (global + scripts):
|
||||
- `~/.codex/skills/scene-verificator/`
|
||||
- `~/.codex/skills/fx-verificator/`
|
||||
- `scene-verificator` etendu pour piloter des triggers de test (`CMD@SCENE`) et verifier la progression post-trigger.
|
||||
- Miroir doc versionne:
|
||||
- `docs/skills/scene-verificator.md`
|
||||
- `docs/skills/fx-verificator.md`
|
||||
- Verification a executer en fin de run:
|
||||
- build Freenove, serial smoke, scene chain, FX status, stress test.
|
||||
|
||||
## [2026-02-24] Plan v4 integration (FX3D modes + SCENE_CAMERA_SCAN recorder Win311)
|
||||
|
||||
- Skills chain executee (ordre): `freenove-firmware-orchestrator` -> `firmware-graphics-stack` -> `firmware-camera-stack` -> `firmware-fx-overlay-lovyangfx` -> `firmware-build-stack`.
|
||||
@@ -1613,3 +1839,90 @@
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` ✅
|
||||
- monitor serie manuel ✅ (`fx_fps~8`, `PONG`, logs WIN_ETAPE actifs).
|
||||
|
||||
## Skill intake exemples pre-patch (2026-02-25)
|
||||
|
||||
- Checkpoint:
|
||||
- `/tmp/zacus_checkpoint/20260225_112752_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260225_112752_status.txt`
|
||||
- Nouveau skill global ajoute pour les depots d'exemples utilisateur:
|
||||
- `~/.codex/skills/chatgpt-file-exemple-intake/SKILL.md`
|
||||
- `~/.codex/skills/chatgpt-file-exemple-intake/agents/openai.yaml`
|
||||
- `~/.codex/skills/chatgpt-file-exemple-intake/references/checklist.md`
|
||||
- `~/.codex/skills/chatgpt-file-exemple-intake/scripts/scan_example_candidates.sh`
|
||||
- Miroir documentation repo:
|
||||
- `docs/skills/chatgpt-file-exemple-intake.md`
|
||||
- Regle operationnelle formalisee:
|
||||
- `hardware/ChatGPT_file_exemple/**` est une source de reference pre-patch, jamais une source compilee.
|
||||
- Integration uniquement par extraction de deltas vers `hardware/firmware/**`.
|
||||
|
||||
## Integration delta depuis hardware/ChatGPT_file_exemple (2026-02-25)
|
||||
|
||||
- Source example integree (premier passage):
|
||||
- `hardware/ChatGPT_file_exemple/QR validator/zacus_qr_crc16_prefix_patch/.../ui_manager.cpp`
|
||||
- Deltas portes dans code canonique:
|
||||
- `hardware/firmware/ui_freenove_allinone/include/ui/ui_manager.h`
|
||||
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
|
||||
- Integration effectuee:
|
||||
- support optionnel `qr.crc16` (bool ou objet `{enabled, sep}`) dans payload scene,
|
||||
- validation CRC16/CCITT-FALSE sur payload QR (`DATA<sep>CRCHEX`),
|
||||
- matching `expected/prefix/contains` applique sur `DATA` apres validation CRC,
|
||||
- reset des regles QR inclut etat CRC16.
|
||||
- Validation:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- Passes supplementaires (QR examples -> canonique):
|
||||
- ajout outil local `tools/dev/gen_qr_crc16.py` (payload + PNG optionnel), derive du dossier exemple et corrige (quoting/erreurs).
|
||||
- ajout doc canonique `docs/protocols/qr_scan_crc16.md` (contrat payload `qr.crc16`, format DATA+CRC, usage script).
|
||||
- verification script: `python3 tools/dev/gen_qr_crc16.py "ZACUS:MEDIA_MANAGER" --ci` ✅
|
||||
- Pass integration QR (suite):
|
||||
- compat champ longueur payload QR dans `qr_scan_controller.cpp` (`payloadLen`/`payload_len`) pour robustesse version lib.
|
||||
- gates executes:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` ✅
|
||||
- `python3 tools/dev/serial_smoke.py --role esp32 --port /dev/cu.usbmodem5AB90753301 --baud 115200 --timeout 8 --wait-port 10` ✅
|
||||
|
||||
## Orchestrator pass - refactor QR ownership hors UiManager massif (2026-02-25)
|
||||
|
||||
- Baseline/checkpoint:
|
||||
- `main`
|
||||
- `/tmp/zacus_checkpoint/20260225_114336_wip.patch`
|
||||
- `/tmp/zacus_checkpoint/20260225_114336_status.txt`
|
||||
- Skill chain utilisee (ordre):
|
||||
1. `freenove-firmware-orchestrator`
|
||||
2. `firmware-graphics-stack`
|
||||
3. `firmware-camera-stack`
|
||||
4. `firmware-build-stack`
|
||||
- Audit constats:
|
||||
- logique QR payload/rules concentree dans `ui_manager.cpp` (couplage parsing+matching+CRC avec rendu),
|
||||
- evenement UI `QR_OK` non consomme par le story runtime (`dispatched=0`) car scenario compile ne porte pas encore la transition YAML.
|
||||
- Phase A (architecture):
|
||||
- extraction logique matching/payload QR vers module dedie:
|
||||
- `hardware/firmware/ui_freenove_allinone/include/ui/qr/qr_validation_rules.h`
|
||||
- `hardware/firmware/ui_freenove_allinone/src/ui/qr/qr_validation_rules.cpp`
|
||||
- `UiManager` delegue desormais `qr_rules_.configureFromPayload(...)` + `qr_rules_.matches(...)`.
|
||||
- Phase B (securite memoire):
|
||||
- copies bornees et parsing CRC16 encapsules dans le module QR dedie,
|
||||
- suppression de helpers QR redondants dans `ui_manager.cpp` (moins de surface mutable).
|
||||
- Phase C (runtime/perf):
|
||||
- parsing des regles QR applique uniquement sur changement statique de scene/payload (`qr_scene && static_state_changed`).
|
||||
- Correction enchainement scene/story:
|
||||
- `main.cpp`: fallback orchestration pour `QR_OK` quand transition story compilee absente:
|
||||
- applique `ACTION_SET_BOOT_MEDIA_MANAGER`,
|
||||
- force `SCENE_MEDIA_MANAGER` via `g_scenario.gotoScene(..., "ui_qr_fallback")`.
|
||||
- Outils/skills verification:
|
||||
- ajout utilitaire QR CRC: `tools/dev/gen_qr_crc16.py`
|
||||
- doc contrat QR: `docs/protocols/qr_scan_crc16.md`
|
||||
- mise a jour script skill global media-manager pour flow deterministe:
|
||||
- force `SCENE_GOTO SCENE_CAMERA_SCAN` avant `QR_SIM`,
|
||||
- accepte evidences `UI_EVENT` prefixe/non-prefixe,
|
||||
- verification post-reset via `HW_STATUS scene=...` (plus de dependance `story.status`).
|
||||
- Gates executees:
|
||||
- `pio run -e freenove_esp32s3` ✅
|
||||
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` ✅
|
||||
- `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 ...` ✅
|
||||
- `~/.codex/skills/fx-verificator/scripts/run_fx_verification.sh ...` ✅
|
||||
- `~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh ...` ✅
|
||||
- `~/.codex/skills/media-manager/scripts/run_media_manager_verification.sh ...` ✅
|
||||
- Limitation residuelle:
|
||||
- la transition `QR_OK -> STEP_MEDIA_MANAGER` n'est pas encore presente dans le scenario compile C++ (`scenarios_gen.cpp`) ; fallback runtime temporaire en place tant que la regeneration C++ canonique n'est pas complete.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,53 @@
|
||||
# QR Scan CRC16 Contract (Freenove)
|
||||
|
||||
This document defines the QR validation contract used by `SCENE_CAMERA_SCAN`.
|
||||
|
||||
## Payload contract
|
||||
|
||||
Scene payload accepts:
|
||||
|
||||
```json
|
||||
{
|
||||
"qr": {
|
||||
"expected": ["ZACUS:ETAPE1", "ZACUS:BONUS"],
|
||||
"prefix": "ZACUS:",
|
||||
"contains": "ETAPE",
|
||||
"caseInsensitive": true,
|
||||
"crc16": { "enabled": true, "sep": "*" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported forms:
|
||||
- `expected`: string or array (any-of)
|
||||
- `prefix`: starts-with match
|
||||
- `contains`: substring match
|
||||
- `caseInsensitive`: ASCII case-insensitive matching
|
||||
- `crc16`: either boolean or object `{ enabled, sep }`
|
||||
- legacy `crcSep` is also accepted
|
||||
|
||||
## CRC format
|
||||
|
||||
When CRC16 is enabled, runtime expects:
|
||||
|
||||
`<DATA><sep><CRC16_HEX>`
|
||||
|
||||
- CRC algorithm: `CRC-16/CCITT-FALSE` (`poly=0x1021`, `init=0xFFFF`)
|
||||
- If `caseInsensitive=true`, CRC is computed on ASCII-uppercase DATA
|
||||
- Matching (`expected/prefix/contains`) is applied on DATA after CRC strip/validation
|
||||
|
||||
## Generator script
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
python3 tools/dev/gen_qr_crc16.py "ZACUS:ETAPE1" --ci
|
||||
python3 tools/dev/gen_qr_crc16.py "ZACUS:ETAPE1" --ci --png qr_etape1.png
|
||||
```
|
||||
|
||||
`--ci` must match scene payload `caseInsensitive=true`.
|
||||
|
||||
## Notes
|
||||
|
||||
CRC16 is an integrity helper, not an anti-forgery signature.
|
||||
For anti-cheat requirements, use a keyed MAC/signature scheme.
|
||||
+264
-64
@@ -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,24 +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_MEDIA_ARCHIVE
|
||||
- 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: 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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Skill: ChatGPT File Exemple Intake
|
||||
|
||||
Use this skill when you drop pre-patch examples in `hardware/ChatGPT_file_exemple/**`.
|
||||
|
||||
## Purpose
|
||||
- Treat example files as reference only.
|
||||
- Integrate useful deltas into canonical files under `hardware/firmware/**`.
|
||||
- Avoid blind copy of nested paths and duplicate compile units.
|
||||
|
||||
## Workflow
|
||||
1. Inventory files in `hardware/ChatGPT_file_exemple/**`.
|
||||
2. Map each example file to a canonical target in `hardware/firmware/**`.
|
||||
3. Extract only required changes with focused diffs.
|
||||
4. Patch canonical files minimally.
|
||||
5. Validate with requested build/upload/smoke gates.
|
||||
|
||||
## Guardrails
|
||||
- Do not compile files from `hardware/ChatGPT_file_exemple/**`.
|
||||
- Do not commit machine-specific absolute paths.
|
||||
- Report integrated vs skipped examples explicitly.
|
||||
|
||||
## Skill location
|
||||
- `~/.codex/skills/chatgpt-file-exemple-intake/`
|
||||
- `SKILL.md`
|
||||
- `agents/openai.yaml`
|
||||
- `references/checklist.md`
|
||||
- `scripts/scan_example_candidates.sh`
|
||||
@@ -0,0 +1,18 @@
|
||||
# FX_VERIFICATOR
|
||||
|
||||
## Objectif
|
||||
Valider la sante runtime FX (intro/direct FX) sur Freenove via telemetrie serie.
|
||||
|
||||
## Verifications
|
||||
- `UI_GFX_STATUS` detecte et parsable.
|
||||
- `fx_fps > 0` sur scenes FX (`SCENE_WIN_ETAPE`, `SCENE_WINNER`, `SCENE_FIREWORKS`).
|
||||
- Compteurs de pression (`fx_skip_busy`, `flush_block`, `flush_overflow`) surveilles.
|
||||
- Aucun marqueur fatal (`PANIC`, `ASSERT`, `ABORT`, `REBOOT`, `rst:`).
|
||||
|
||||
## Script global associe
|
||||
- `~/.codex/skills/fx-verificator/scripts/run_fx_verification.sh`
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
~/.codex/skills/fx-verificator/scripts/run_fx_verification.sh /dev/cu.usbmodem5AB90753301 115200
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
# HAL_VERIFCATOR_STATUS
|
||||
|
||||
## Objectif
|
||||
Verifier que les couches hardware (camera, micro, amp) et les 4 LED WS2812 Freenove sont bien activees/desactivees selon la scene et le besoin runtime.
|
||||
|
||||
## Verifications
|
||||
- `SCENE_GOTO` doit retourner `ACK ... ok=1` pour chaque scene testee.
|
||||
- `CAM_STATUS rec_scene` doit correspondre a l'etat attendu (`cam=0|1`).
|
||||
- `AMP_STATUS scene` doit correspondre a l'etat attendu (`amp=0|1`).
|
||||
- `RESOURCE_STATUS mic_should_run` doit correspondre a l'etat attendu (`mic=0|1`).
|
||||
- `HW_STATUS ws2812` doit correspondre a l'etat attendu (`ws2812=0|1`).
|
||||
- `HW_STATUS auto` doit correspondre a la politique LED attendue (`led_auto=0|1`).
|
||||
- `HW_STATUS led=R,G,B` peut etre verifie en couleur exacte (`led=R/G/B` ou `led=R,G,B`).
|
||||
- Grammaire LED alignee avec `scene-verificator` et `media-manager`.
|
||||
- Les cles `cam`, `amp`, `mic`, `ws2812`, `led_auto`, `led` sont optionnelles par scene.
|
||||
- Aucun marqueur fatal (`PANIC`, `ASSERT`, `ABORT`, `REBOOT`, `rst:`).
|
||||
|
||||
## Script global associe
|
||||
- `~/.codex/skills/hal-verificator-status/scripts/run_hal_verification.sh`
|
||||
|
||||
## 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_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 \
|
||||
"SCENE_READY:cam=0,amp=0,mic=0,ws2812=1,led_auto=1;SCENE_MP3_PLAYER:cam=0,amp=1,ws2812=1,led_auto=1"
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# MEDIA_MANAGER
|
||||
|
||||
## Objectif
|
||||
Verifier le flux QR final, la persistance NVS du boot mode `MEDIA_MANAGER`, et la disponibilite des entrees Photo/MP3/Story avec rollback explicite.
|
||||
|
||||
## Verifications
|
||||
- `BOOT_MODE_STATUS` doit exposer un etat coherent (`mode`, `media_validated`).
|
||||
- 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`).
|
||||
- Validation WS2812 stricte sur scenes critiques: `ws2812`, `led_auto`, `led=R/G/B`.
|
||||
- Rollback obligatoire via `BOOT_MODE_SET STORY` puis `BOOT_MODE_CLEAR`.
|
||||
- Echec strict si `PANIC|ASSERT|ABORT|REBOOT|rst:`.
|
||||
|
||||
## Script global associe
|
||||
- `~/.codex/skills/media-manager/scripts/run_media_manager_verification.sh`
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
~/.codex/skills/media-manager/scripts/run_media_manager_verification.sh /dev/cu.usbmodem5AB90753301 115200 ZACUS:MEDIA_MANAGER \
|
||||
"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"
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
# SCENE_VERIFICATOR
|
||||
|
||||
## Objectif
|
||||
Valider l'enchainement des scenes story sur Freenove avec des verdicts stricts et reproductibles, y compris les triggers de test qui doivent provoquer des passages de scene.
|
||||
|
||||
## Verifications
|
||||
- `SCENE_GOTO` doit retourner `ACK ... ok=1` pour chaque scene cible.
|
||||
- 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: `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`.
|
||||
- Pour `CMD@SCENE->TARGET`, la LED de la scene cible est aussi verifiee si configuree.
|
||||
- Aucun marqueur fatal (`PANIC`, `ASSERT`, `ABORT`, `REBOOT`, `rst:`).
|
||||
|
||||
## Script global associe
|
||||
- `~/.codex/skills/scene-verificator/scripts/run_scene_verification.sh`
|
||||
|
||||
## Usage
|
||||
```bash
|
||||
~/.codex/skills/scene-verificator/scripts/run_scene_verification.sh /dev/cu.usbmodem5AB90753301 115200 \
|
||||
"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"
|
||||
```
|
||||
+8
-3
@@ -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);
|
||||
@@ -63,10 +65,10 @@ class ScenarioManager {
|
||||
uint8_t* out_action_count = nullptr) const;
|
||||
|
||||
bool dispatchEvent(StoryEventType type, const char* event_name, uint32_t now_ms, const char* source);
|
||||
bool applyTransition(const TransitionDef& transition, uint32_t now_ms, const char* source);
|
||||
bool runImmediateTransitions(uint32_t now_ms, const char* source);
|
||||
bool applyTransition(const TransitionDef& transition, uint32_t now_ms, const char* source, const char* event_name);
|
||||
bool runImmediateTransitions(uint32_t now_ms, const char* source, const char* parent_event_name);
|
||||
void evaluateAfterMsTransitions(uint32_t now_ms);
|
||||
void enterStep(int8_t step_index, uint32_t now_ms, const char* source);
|
||||
void enterStep(int8_t step_index, uint32_t now_ms, const char* source, const char* event_name = nullptr);
|
||||
const StepDef* currentStep() const;
|
||||
bool transitionMatches(const TransitionDef& transition, StoryEventType type, const char* event_name) const;
|
||||
|
||||
@@ -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_;
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// scene_fx_orchestrator.h - explicit owner planning for scene runtime resources.
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
enum class SceneRuntimeOwner : uint8_t {
|
||||
kStory = 0,
|
||||
kIntroFx,
|
||||
kDirectFx,
|
||||
kAmp,
|
||||
kCamera,
|
||||
};
|
||||
|
||||
struct SceneTransitionPlan {
|
||||
bool should_apply = false;
|
||||
bool owner_changed = false;
|
||||
bool scene_changed = false;
|
||||
SceneRuntimeOwner from_owner = SceneRuntimeOwner::kStory;
|
||||
SceneRuntimeOwner to_owner = SceneRuntimeOwner::kStory;
|
||||
const char* from_scene = "";
|
||||
const char* to_scene = "";
|
||||
};
|
||||
|
||||
class SceneFxOrchestrator {
|
||||
public:
|
||||
SceneTransitionPlan planTransition(const char* scene_id, bool scene_changed, bool force_refresh);
|
||||
void applyTransition(const SceneTransitionPlan& plan);
|
||||
SceneRuntimeOwner currentOwner() const;
|
||||
const char* currentSceneId() const;
|
||||
|
||||
private:
|
||||
SceneRuntimeOwner classifyOwner(const char* scene_id) const;
|
||||
static bool sameText(const char* lhs, const char* rhs);
|
||||
|
||||
SceneRuntimeOwner current_owner_ = SceneRuntimeOwner::kStory;
|
||||
char current_scene_id_[40] = "";
|
||||
};
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
// boot_mode_store.h - NVS persistence for startup mode policy.
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
class BootModeStore {
|
||||
public:
|
||||
enum class StartupMode : uint8_t {
|
||||
kStory = 0,
|
||||
kMediaManager = 1,
|
||||
};
|
||||
|
||||
bool loadMode(StartupMode* out_mode) const;
|
||||
bool saveMode(StartupMode mode) const;
|
||||
bool clearMode() const;
|
||||
|
||||
bool isMediaValidated() const;
|
||||
bool setMediaValidated(bool validated) const;
|
||||
|
||||
static const char* modeLabel(StartupMode mode);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
#pragma once
|
||||
|
||||
#include "app/scene_fx_orchestrator.h"
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
// qr_scan_controller.h - QR code scan helper for Freenove camera scene.
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
namespace ui {
|
||||
|
||||
struct QrScanResult {
|
||||
uint32_t at_ms = 0U;
|
||||
bool decoder_valid = false;
|
||||
uint16_t payload_len = 0U;
|
||||
char payload[192] = {0};
|
||||
};
|
||||
|
||||
class QrScanController {
|
||||
public:
|
||||
QrScanController() = default;
|
||||
~QrScanController();
|
||||
|
||||
QrScanController(const QrScanController&) = delete;
|
||||
QrScanController& operator=(const QrScanController&) = delete;
|
||||
|
||||
bool begin();
|
||||
bool ready() const { return ready_; }
|
||||
void setEnabled(bool enabled) { enabled_ = enabled; }
|
||||
bool enabled() const { return enabled_; }
|
||||
bool poll(QrScanResult* out, uint32_t timeout_ms = 0U);
|
||||
|
||||
private:
|
||||
bool ready_ = false;
|
||||
bool enabled_ = false;
|
||||
void* reader_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// qr_scene_controller.h - QR scene lifecycle + runtime event helper.
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
struct _lv_obj_t;
|
||||
typedef struct _lv_obj_t lv_obj_t;
|
||||
|
||||
namespace ui {
|
||||
|
||||
class QrScanController;
|
||||
class QrValidationRules;
|
||||
|
||||
class QrSceneController {
|
||||
public:
|
||||
void onSceneEnter(QrScanController* scanner, lv_obj_t* subtitle_label);
|
||||
void onSceneExit(QrScanController* scanner);
|
||||
void tick(uint32_t now_ms,
|
||||
QrScanController* scanner,
|
||||
const QrValidationRules& rules,
|
||||
lv_obj_t* subtitle_label,
|
||||
lv_obj_t* symbol_label);
|
||||
|
||||
bool consumeRuntimeEvent(char* out_event, size_t capacity);
|
||||
bool queueSimulatedPayload(const char* payload);
|
||||
|
||||
private:
|
||||
void handleDecodedPayload(const char* payload,
|
||||
bool decoder_valid,
|
||||
uint32_t now_ms,
|
||||
const QrValidationRules& rules,
|
||||
lv_obj_t* subtitle_label,
|
||||
lv_obj_t* symbol_label);
|
||||
|
||||
bool scene_active_ = false;
|
||||
bool last_match_ = false;
|
||||
uint32_t last_decode_ms_ = 0U;
|
||||
uint32_t feedback_until_ms_ = 0U;
|
||||
char last_payload_[192] = {0};
|
||||
char pending_runtime_event_[24] = {0};
|
||||
bool pending_runtime_event_valid_ = false;
|
||||
char simulated_payload_[192] = {0};
|
||||
bool simulated_pending_ = false;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// qr_validation_rules.h - QR payload rule parsing + matching helpers.
|
||||
#pragma once
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace ui {
|
||||
|
||||
class QrValidationRules {
|
||||
public:
|
||||
static constexpr uint8_t kExpectedMax = 4U;
|
||||
|
||||
void clear();
|
||||
void configureFromPayload(JsonVariantConst root);
|
||||
bool matches(const char* payload) const;
|
||||
|
||||
private:
|
||||
bool case_insensitive_ = false;
|
||||
char expected_values_[kExpectedMax][64] = {};
|
||||
uint8_t expected_count_ = 0U;
|
||||
char prefix_[64] = {0};
|
||||
char contains_[64] = {0};
|
||||
bool crc16_enabled_ = false;
|
||||
char crc16_sep_ = '*';
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
#include "hardware_manager.h"
|
||||
#include "ui/fx/fx_engine.h"
|
||||
#include "ui/player_ui_model.h"
|
||||
#include "ui/qr/qr_scene_controller.h"
|
||||
#include "ui/qr/qr_scan_controller.h"
|
||||
#include "ui/qr/qr_validation_rules.h"
|
||||
|
||||
struct UiSceneFrame {
|
||||
const ScenarioDef* scenario = nullptr;
|
||||
@@ -104,6 +107,8 @@ class UiManager {
|
||||
void setLaMetrics(const UiLaMetrics& metrics);
|
||||
void submitSceneFrame(const UiSceneFrame& frame);
|
||||
void submitInputEvent(const UiInputEvent& event);
|
||||
bool consumeRuntimeEvent(char* out_event, size_t capacity);
|
||||
bool simulateQrPayload(const char* payload);
|
||||
void dumpStatus(UiStatusTopic topic) const;
|
||||
UiMemorySnapshot memorySnapshot() const;
|
||||
|
||||
@@ -381,6 +386,9 @@ class UiManager {
|
||||
void renderMicrophoneWaveform();
|
||||
uint16_t resolveAnimMs(uint16_t fallback_ms) const;
|
||||
void applyThemeColors(uint32_t bg_rgb, uint32_t accent_rgb, uint32_t text_rgb);
|
||||
static uint32_t hashScenePayload(const char* payload);
|
||||
bool shouldApplySceneStaticState(const char* scene_id, const char* payload_json, bool scene_changed) const;
|
||||
void applySceneDynamicState(const String& subtitle, bool show_subtitle, bool audio_playing, uint32_t text_rgb);
|
||||
void resetSceneTimeline();
|
||||
void onTimelineTick(uint16_t elapsed_ms);
|
||||
bool isWinEtapeSceneId(const char* scene_id) const;
|
||||
@@ -504,6 +512,8 @@ class UiManager {
|
||||
int8_t timeline_effect_index_ = -1;
|
||||
uint8_t particleIndexForObj(const lv_obj_t* target) const;
|
||||
char last_scene_id_[40] = {0};
|
||||
uint32_t last_payload_crc_ = 0U;
|
||||
bool last_audio_playing_ = false;
|
||||
uint8_t demo_particle_count_ = 4U;
|
||||
uint8_t demo_strobe_level_ = 65U;
|
||||
bool win_etape_fireworks_mode_ = false;
|
||||
@@ -602,4 +612,7 @@ class UiManager {
|
||||
int16_t touch_y_ = 0;
|
||||
bool touch_pressed_ = false;
|
||||
ui::fx::FxEngine fx_engine_;
|
||||
ui::QrScanController qr_scan_;
|
||||
ui::QrValidationRules qr_rules_;
|
||||
ui::QrSceneController qr_scene_controller_;
|
||||
};
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
#include "runtime/app_coordinator.h"
|
||||
#include "runtime/la_trigger_service.h"
|
||||
#include "runtime/perf/perf_monitor.h"
|
||||
#include "runtime/provisioning/boot_mode_store.h"
|
||||
#include "runtime/provisioning/credential_store.h"
|
||||
#include "runtime/resource/resource_coordinator.h"
|
||||
#include "runtime/scene_fx_orchestrator.h"
|
||||
#include "runtime/simd/simd_accel.h"
|
||||
#include "runtime/simd/simd_accel_bench.h"
|
||||
#include "runtime/runtime_config_service.h"
|
||||
@@ -56,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 ";
|
||||
@@ -69,7 +71,8 @@ constexpr const char* kAmpMusicPathPrimary = "/music";
|
||||
constexpr const char* kAmpMusicPathFallback1 = "/audio/music";
|
||||
constexpr const char* kAmpMusicPathFallback2 = "/audio";
|
||||
#endif
|
||||
constexpr const char* kCameraSceneId = "SCENE_CAMERA_SCAN";
|
||||
constexpr const char* kCameraSceneId = "SCENE_PHOTO_MANAGER";
|
||||
constexpr const char* kMediaManagerSceneId = "SCENE_MEDIA_MANAGER";
|
||||
|
||||
AudioManager g_audio;
|
||||
ScenarioManager g_scenario;
|
||||
@@ -82,6 +85,7 @@ HardwareManager g_hardware;
|
||||
CameraManager g_camera;
|
||||
MediaManager g_media;
|
||||
CredentialStore g_credential_store;
|
||||
BootModeStore g_boot_mode_store;
|
||||
RuntimeNetworkConfig g_network_cfg;
|
||||
RuntimeHardwareConfig g_hardware_cfg;
|
||||
CameraManager::Config g_camera_cfg;
|
||||
@@ -101,6 +105,7 @@ LaTriggerRuntimeState g_la_trigger;
|
||||
bool g_la_dispatch_in_progress = false;
|
||||
bool g_has_ring_sent_for_win_etape = false;
|
||||
bool g_win_etape_ui_refresh_pending = false;
|
||||
bool g_boot_media_manager_mode = false;
|
||||
bool g_setup_mode = true;
|
||||
bool g_web_auth_required = false;
|
||||
bool g_resource_profile_auto = true;
|
||||
@@ -111,6 +116,7 @@ size_t g_serial_line_len = 0U;
|
||||
RuntimeServices g_runtime_services;
|
||||
AppCoordinator g_app_coordinator;
|
||||
runtime::resource::ResourceCoordinator g_resource_coordinator;
|
||||
SceneFxOrchestrator g_scene_fx_orchestrator;
|
||||
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
|
||||
ui::audio::AmigaAudioPlayer g_amp_player;
|
||||
bool g_amp_ready = false;
|
||||
@@ -140,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";
|
||||
}
|
||||
|
||||
@@ -238,6 +250,36 @@ void copyText(char* out, size_t out_size, const char* text) {
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
BootModeStore::StartupMode currentStartupMode() {
|
||||
return g_boot_media_manager_mode ? BootModeStore::StartupMode::kMediaManager : BootModeStore::StartupMode::kStory;
|
||||
}
|
||||
|
||||
void applyStartupMode(BootModeStore::StartupMode mode) {
|
||||
g_boot_media_manager_mode = (mode == BootModeStore::StartupMode::kMediaManager);
|
||||
}
|
||||
|
||||
void printBootModeStatus() {
|
||||
const BootModeStore::StartupMode mode = currentStartupMode();
|
||||
Serial.printf("BOOT_MODE_STATUS mode=%s media_validated=%u\n",
|
||||
BootModeStore::modeLabel(mode),
|
||||
g_boot_mode_store.isMediaValidated() ? 1U : 0U);
|
||||
}
|
||||
|
||||
bool parseBootModeToken(const char* token, BootModeStore::StartupMode* out_mode) {
|
||||
if (token == nullptr || out_mode == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (std::strcmp(token, "STORY") == 0) {
|
||||
*out_mode = BootModeStore::StartupMode::kStory;
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(token, "MEDIA_MANAGER") == 0 || std::strcmp(token, "MEDIA") == 0) {
|
||||
*out_mode = BootModeStore::StartupMode::kMediaManager;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void applySceneResourcePolicy(const ScenarioSnapshot& snapshot) {
|
||||
if (!g_resource_profile_auto) {
|
||||
return;
|
||||
@@ -489,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:
|
||||
@@ -519,6 +565,14 @@ 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) {
|
||||
*out_type = StoryEventType::kEspNow;
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(normalized, "action") == 0) {
|
||||
*out_type = StoryEventType::kAction;
|
||||
return true;
|
||||
@@ -536,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:
|
||||
@@ -569,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,
|
||||
@@ -637,6 +701,20 @@ 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, "ESPNOW:%s", name[0] != '\0' ? name : "EVENT");
|
||||
return true;
|
||||
}
|
||||
if (startsWithIgnoreCase(event, "TIMER ")) {
|
||||
char* name = event + 6;
|
||||
trimAsciiInPlace(name);
|
||||
@@ -751,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);
|
||||
@@ -765,11 +854,13 @@ void startPendingAudioIfAny();
|
||||
bool isAmpSceneId(const char* scene_id);
|
||||
bool ensureAmpInitialized();
|
||||
size_t scanAmpPlaylistWithFallback();
|
||||
void setAmpSceneActive(bool active);
|
||||
void syncAmpSceneState(const ScenarioSnapshot& snapshot);
|
||||
void printAmpStatus();
|
||||
#endif
|
||||
bool isCameraSceneId(const char* scene_id);
|
||||
bool ensureCameraUiInitialized();
|
||||
void setCameraSceneActive(bool active);
|
||||
void syncCameraSceneState(const ScenarioSnapshot& snapshot);
|
||||
bool dispatchCameraSceneButton(uint8_t key, bool long_press);
|
||||
void printCameraRecorderStatus();
|
||||
@@ -1433,10 +1524,14 @@ size_t scanAmpPlaylistWithFallback() {
|
||||
|
||||
void syncAmpSceneState(const ScenarioSnapshot& snapshot) {
|
||||
const bool want_amp_scene = isAmpSceneId(snapshot.screen_scene_id);
|
||||
if (want_amp_scene == g_amp_scene_active) {
|
||||
setAmpSceneActive(want_amp_scene);
|
||||
}
|
||||
|
||||
void setAmpSceneActive(bool active) {
|
||||
if (active == g_amp_scene_active) {
|
||||
return;
|
||||
}
|
||||
g_amp_scene_active = want_amp_scene;
|
||||
g_amp_scene_active = active;
|
||||
if (g_amp_scene_active) {
|
||||
g_audio.stop();
|
||||
if (ensureAmpInitialized()) {
|
||||
@@ -1507,10 +1602,14 @@ bool ensureCameraUiInitialized() {
|
||||
|
||||
void syncCameraSceneState(const ScenarioSnapshot& snapshot) {
|
||||
const bool want_camera_scene = isCameraSceneId(snapshot.screen_scene_id);
|
||||
if (want_camera_scene == g_camera_scene_active) {
|
||||
setCameraSceneActive(want_camera_scene);
|
||||
}
|
||||
|
||||
void setCameraSceneActive(bool active) {
|
||||
if (active == g_camera_scene_active) {
|
||||
return;
|
||||
}
|
||||
g_camera_scene_active = want_camera_scene;
|
||||
g_camera_scene_active = active;
|
||||
if (g_camera_scene_active) {
|
||||
if (!ensureCameraUiInitialized()) {
|
||||
g_camera_scene_active = false;
|
||||
@@ -2585,6 +2684,49 @@ 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);
|
||||
const bool flag_ok = g_boot_mode_store.setMediaValidated(true);
|
||||
applyStartupMode(BootModeStore::StartupMode::kMediaManager);
|
||||
Serial.printf("[ACTION] SET_BOOT_MEDIA_MANAGER mode_ok=%u validated_ok=%u\n",
|
||||
mode_ok ? 1U : 0U,
|
||||
flag_ok ? 1U : 0U);
|
||||
return mode_ok && flag_ok;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2937,6 +3079,49 @@ bool dispatchControlAction(const String& action_raw, uint32_t now_ms, String* ou
|
||||
printMediaStatus();
|
||||
return true;
|
||||
}
|
||||
if (action.equalsIgnoreCase("BOOT_MODE_STATUS")) {
|
||||
printBootModeStatus();
|
||||
return true;
|
||||
}
|
||||
if (action.equalsIgnoreCase("BOOT_MODE_CLEAR")) {
|
||||
const bool ok = g_boot_mode_store.clearMode();
|
||||
applyStartupMode(BootModeStore::StartupMode::kStory);
|
||||
if (!ok && out_error != nullptr) {
|
||||
*out_error = "boot_mode_clear_failed";
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if (startsWithIgnoreCase(action.c_str(), "BOOT_MODE_SET ")) {
|
||||
String mode_text = action.substring(static_cast<unsigned int>(std::strlen("BOOT_MODE_SET ")));
|
||||
mode_text.trim();
|
||||
mode_text.toUpperCase();
|
||||
BootModeStore::StartupMode mode = BootModeStore::StartupMode::kStory;
|
||||
if (!parseBootModeToken(mode_text.c_str(), &mode)) {
|
||||
if (out_error != nullptr) {
|
||||
*out_error = "boot_mode_set_arg";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const bool ok = g_boot_mode_store.saveMode(mode);
|
||||
if (!ok) {
|
||||
if (out_error != nullptr) {
|
||||
*out_error = "boot_mode_set_failed";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
applyStartupMode(mode);
|
||||
(void)g_boot_mode_store.setMediaValidated(mode == BootModeStore::StartupMode::kMediaManager);
|
||||
return true;
|
||||
}
|
||||
if (startsWithIgnoreCase(action.c_str(), "QR_SIM ")) {
|
||||
String payload = action.substring(static_cast<unsigned int>(std::strlen("QR_SIM ")));
|
||||
payload.trim();
|
||||
const bool ok = !payload.isEmpty() && g_ui.simulateQrPayload(payload.c_str());
|
||||
if (!ok && out_error != nullptr) {
|
||||
*out_error = "qr_sim_arg";
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
if (startsWithIgnoreCase(action.c_str(), "WIFI_CONNECT ")) {
|
||||
String ssid;
|
||||
@@ -3727,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);
|
||||
}
|
||||
|
||||
@@ -3755,6 +3944,13 @@ bool dispatchScenarioEventByType(StoryEventType type, const char* event_name, ui
|
||||
return g_scenario.notifyTimerEvent(event_name, now_ms);
|
||||
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);
|
||||
default:
|
||||
@@ -3805,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);
|
||||
@@ -3826,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[] = {
|
||||
@@ -3938,20 +4144,31 @@ void serialDispatchBridge(const char* command_line, uint32_t now_ms, RuntimeServ
|
||||
|
||||
void refreshSceneIfNeeded(bool force_render) {
|
||||
const bool changed = g_scenario.consumeSceneChanged();
|
||||
if (!force_render && !changed) {
|
||||
const ScenarioSnapshot snapshot = g_scenario.snapshot();
|
||||
const SceneTransitionPlan transition =
|
||||
g_scene_fx_orchestrator.planTransition(snapshot.screen_scene_id, changed, force_render);
|
||||
if (!transition.should_apply) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ScenarioSnapshot snapshot = g_scenario.snapshot();
|
||||
const uint32_t now_ms = millis();
|
||||
|
||||
// Explicit transition ordering: pre-exit -> release old owner resources.
|
||||
if (transition.owner_changed) {
|
||||
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
|
||||
if (transition.from_owner == SceneRuntimeOwner::kAmp) {
|
||||
setAmpSceneActive(false);
|
||||
}
|
||||
#endif
|
||||
if (transition.from_owner == SceneRuntimeOwner::kCamera) {
|
||||
setCameraSceneActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (g_hardware_started && g_hardware_cfg.led_auto_from_scene && snapshot.screen_scene_id != nullptr) {
|
||||
g_hardware.setSceneHint(snapshot.screen_scene_id);
|
||||
}
|
||||
executeStoryActionsForStep(snapshot, now_ms);
|
||||
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
|
||||
syncAmpSceneState(snapshot);
|
||||
#endif
|
||||
syncCameraSceneState(snapshot);
|
||||
|
||||
const char* step_id = (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a";
|
||||
const String screen_payload = g_storage.loadScenePayloadById(snapshot.screen_scene_id);
|
||||
@@ -3969,6 +4186,19 @@ void refreshSceneIfNeeded(bool force_render) {
|
||||
frame.audio_playing = g_audio.isPlaying();
|
||||
frame.screen_payload_json = screen_payload.isEmpty() ? nullptr : screen_payload.c_str();
|
||||
g_ui.submitSceneFrame(frame);
|
||||
|
||||
// Apply new owner resources after scene config is committed in UI.
|
||||
if (transition.owner_changed) {
|
||||
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
|
||||
if (transition.to_owner == SceneRuntimeOwner::kAmp) {
|
||||
setAmpSceneActive(true);
|
||||
}
|
||||
#endif
|
||||
if (transition.to_owner == SceneRuntimeOwner::kCamera) {
|
||||
setCameraSceneActive(true);
|
||||
}
|
||||
}
|
||||
g_scene_fx_orchestrator.applyTransition(transition);
|
||||
}
|
||||
|
||||
void startPendingAudioIfAny() {
|
||||
@@ -4078,7 +4308,9 @@ void handleSerialCommand(const char* command_line, uint32_t now_ms) {
|
||||
"MIC_TUNER_STATUS [ON|OFF|<period_ms>] "
|
||||
"CAM_STATUS CAM_ON CAM_OFF CAM_SNAPSHOT [filename] "
|
||||
"CAM_UI_SHOW CAM_UI_HIDE CAM_UI_TOGGLE CAM_REC_SNAP CAM_REC_SAVE [auto|bmp|jpg|raw] CAM_REC_GALLERY CAM_REC_NEXT CAM_REC_DELETE CAM_REC_STATUS "
|
||||
"QR_SIM <payload> "
|
||||
"MEDIA_LIST <picture|music|recorder> MEDIA_PLAY <path> MEDIA_STOP REC_START [seconds] [filename] REC_STOP REC_STATUS "
|
||||
"BOOT_MODE_STATUS BOOT_MODE_SET <STORY|MEDIA_MANAGER> BOOT_MODE_CLEAR "
|
||||
"NET_STATUS WIFI_STATUS WIFI_TEST WIFI_STA <ssid> <pass> WIFI_CONNECT <ssid> <pass> WIFI_PROVISION <ssid> <pass> WIFI_FORGET WIFI_DISCONNECT "
|
||||
"AUTH_STATUS AUTH_TOKEN_ROTATE [token] "
|
||||
"WIFI_AP_ON [ssid] [pass] WIFI_AP_OFF "
|
||||
@@ -4307,8 +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;
|
||||
}
|
||||
@@ -4435,7 +4679,9 @@ void handleSerialCommand(const char* command_line, uint32_t now_ms) {
|
||||
std::strcmp(command, "CAM_REC_DELETE") == 0 || std::strcmp(command, "MEDIA_LIST") == 0 ||
|
||||
std::strcmp(command, "MEDIA_PLAY") == 0 || std::strcmp(command, "MEDIA_STOP") == 0 ||
|
||||
std::strcmp(command, "REC_START") == 0 || std::strcmp(command, "REC_STOP") == 0 ||
|
||||
std::strcmp(command, "SCENE_GOTO") == 0) {
|
||||
std::strcmp(command, "SCENE_GOTO") == 0 || std::strcmp(command, "QR_SIM") == 0 ||
|
||||
std::strcmp(command, "BOOT_MODE_STATUS") == 0 || std::strcmp(command, "BOOT_MODE_SET") == 0 ||
|
||||
std::strcmp(command, "BOOT_MODE_CLEAR") == 0) {
|
||||
String action = command;
|
||||
if (argument != nullptr && argument[0] != '\0') {
|
||||
action += " ";
|
||||
@@ -4494,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,
|
||||
@@ -4511,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,
|
||||
@@ -4854,6 +5110,17 @@ void setup() {
|
||||
}
|
||||
RuntimeConfigService::load(g_storage, &g_network_cfg, &g_hardware_cfg, &g_camera_cfg, &g_media_cfg);
|
||||
loadBootProvisioningState();
|
||||
{
|
||||
BootModeStore::StartupMode startup_mode = BootModeStore::StartupMode::kStory;
|
||||
if (g_boot_mode_store.loadMode(&startup_mode)) {
|
||||
applyStartupMode(startup_mode);
|
||||
} else {
|
||||
applyStartupMode(BootModeStore::StartupMode::kStory);
|
||||
}
|
||||
Serial.printf("[BOOT] startup_mode=%s media_validated=%u\n",
|
||||
BootModeStore::modeLabel(currentStartupMode()),
|
||||
g_boot_mode_store.isMediaValidated() ? 1U : 0U);
|
||||
}
|
||||
g_resource_coordinator.begin();
|
||||
Serial.printf("[MAIN] default scenario checksum=%lu\n",
|
||||
static_cast<unsigned long>(g_storage.checksum(kDefaultScenarioFile)));
|
||||
@@ -4931,6 +5198,10 @@ void setup() {
|
||||
if (!g_scenario.begin(kDefaultScenarioFile)) {
|
||||
Serial.println("[MAIN] scenario init failed");
|
||||
}
|
||||
if (g_boot_media_manager_mode) {
|
||||
const bool routed = g_scenario.gotoScene(kMediaManagerSceneId, millis(), "boot_mode_media_manager");
|
||||
Serial.printf("[BOOT] route media_manager scene=%s ok=%u\n", kMediaManagerSceneId, routed ? 1U : 0U);
|
||||
}
|
||||
g_last_action_step_key[0] = '\0';
|
||||
|
||||
g_ui.begin();
|
||||
@@ -5133,6 +5404,24 @@ void runRuntimeIteration(uint32_t now_ms) {
|
||||
refreshSceneIfNeeded(false);
|
||||
const uint32_t ui_started_us = perfMonitor().beginSample();
|
||||
g_ui.tick(now_ms);
|
||||
char runtime_event[24] = {0};
|
||||
while (g_ui.consumeRuntimeEvent(runtime_event, sizeof(runtime_event))) {
|
||||
char event_token[40] = "SERIAL:";
|
||||
std::strncat(event_token, runtime_event, sizeof(event_token) - std::strlen(event_token) - 1U);
|
||||
bool dispatched = dispatchScenarioEventByName(event_token, now_ms);
|
||||
if (!dispatched) {
|
||||
dispatched = dispatchScenarioEventByName(runtime_event, now_ms);
|
||||
if (dispatched) {
|
||||
std::strncpy(event_token, runtime_event, sizeof(event_token) - 1U);
|
||||
event_token[sizeof(event_token) - 1U] = '\0';
|
||||
}
|
||||
}
|
||||
Serial.printf("[UI_EVENT] event=%s dispatched=%u\n", event_token, dispatched ? 1U : 0U);
|
||||
if (dispatched) {
|
||||
refreshSceneIfNeeded(true);
|
||||
}
|
||||
runtime_event[0] = '\0';
|
||||
}
|
||||
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
|
||||
if (g_amp_ready) {
|
||||
g_amp_player.tick(now_ms);
|
||||
|
||||
+79
-13
@@ -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");
|
||||
@@ -283,8 +314,8 @@ bool ScenarioManager::gotoScene(const char* scene_id, uint32_t now_ms, const cha
|
||||
}
|
||||
const char* enter_source =
|
||||
(source != nullptr && source[0] != '\0') ? source : "scene_goto";
|
||||
enterStep(static_cast<int8_t>(index), now_ms, enter_source);
|
||||
runImmediateTransitions(now_ms, enter_source);
|
||||
enterStep(static_cast<int8_t>(index), now_ms, enter_source, "scene_goto");
|
||||
runImmediateTransitions(now_ms, enter_source, "scene_goto");
|
||||
return true;
|
||||
}
|
||||
const char* normalized_scene = storyNormalizeScreenSceneId(scene_id);
|
||||
@@ -389,16 +420,17 @@ bool ScenarioManager::dispatchEvent(StoryEventType type,
|
||||
if (selected == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (!applyTransition(*selected, now_ms, source)) {
|
||||
if (!applyTransition(*selected, now_ms, source, event_name)) {
|
||||
return false;
|
||||
}
|
||||
runImmediateTransitions(now_ms, source);
|
||||
runImmediateTransitions(now_ms, source, event_name);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ScenarioManager::applyTransition(const TransitionDef& transition,
|
||||
uint32_t now_ms,
|
||||
const char* source) {
|
||||
const char* source,
|
||||
const char* event_name) {
|
||||
if (scenario_ == nullptr || transition.targetStepId == nullptr) {
|
||||
return false;
|
||||
}
|
||||
@@ -407,12 +439,13 @@ bool ScenarioManager::applyTransition(const TransitionDef& transition,
|
||||
Serial.printf("[SCENARIO] invalid transition target: %s\n", transition.targetStepId);
|
||||
return false;
|
||||
}
|
||||
enterStep(target, now_ms, source);
|
||||
enterStep(target, now_ms, source, event_name);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ScenarioManager::runImmediateTransitions(uint32_t now_ms, const char* source) {
|
||||
bool ScenarioManager::runImmediateTransitions(uint32_t now_ms, const char* source, const char* parent_event_name) {
|
||||
bool moved = false;
|
||||
uint8_t hop_count = 0U;
|
||||
for (uint8_t guard = 0; guard < 8U; ++guard) {
|
||||
const StepDef* step = currentStep();
|
||||
if (step == nullptr || step->transitionCount == 0U) {
|
||||
@@ -431,11 +464,18 @@ bool ScenarioManager::runImmediateTransitions(uint32_t now_ms, const char* sourc
|
||||
if (selected == nullptr) {
|
||||
break;
|
||||
}
|
||||
if (!applyTransition(*selected, now_ms, source)) {
|
||||
++hop_count;
|
||||
if (!applyTransition(*selected, now_ms, source, parent_event_name != nullptr ? parent_event_name : "immediate")) {
|
||||
break;
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
if (hop_count > 1U) {
|
||||
Serial.printf("[SCENARIO] immediate_chain hops=%u source=%s event=%s\n",
|
||||
static_cast<unsigned int>(hop_count),
|
||||
(source != nullptr && source[0] != '\0') ? source : "-",
|
||||
(parent_event_name != nullptr && parent_event_name[0] != '\0') ? parent_event_name : "-");
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
@@ -459,17 +499,28 @@ void ScenarioManager::evaluateAfterMsTransitions(uint32_t now_ms) {
|
||||
}
|
||||
}
|
||||
if (selected != nullptr) {
|
||||
if (applyTransition(*selected, now_ms, "after_ms")) {
|
||||
runImmediateTransitions(now_ms, "after_ms");
|
||||
if (applyTransition(*selected, now_ms, "after_ms", "after_ms")) {
|
||||
runImmediateTransitions(now_ms, "after_ms", "after_ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ScenarioManager::enterStep(int8_t step_index, uint32_t now_ms, const char* source) {
|
||||
void ScenarioManager::enterStep(int8_t step_index, uint32_t now_ms, const char* source, const char* event_name) {
|
||||
if (scenario_ == nullptr || step_index < 0 || step_index >= static_cast<int8_t>(scenario_->stepCount)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const StepDef* previous_step = currentStep();
|
||||
const char* from_step = (previous_step != nullptr && previous_step->id != nullptr) ? previous_step->id : "n/a";
|
||||
const char* from_scene = (previous_step != nullptr) ? previous_step->resources.screenSceneId : nullptr;
|
||||
const char* from_pack = (previous_step != nullptr) ? previous_step->resources.audioPackId : nullptr;
|
||||
if (previous_step != nullptr) {
|
||||
applyStepResourceOverride(previous_step, &from_scene, &from_pack);
|
||||
}
|
||||
if (!forced_screen_scene_id_.isEmpty()) {
|
||||
from_scene = forced_screen_scene_id_.c_str();
|
||||
}
|
||||
|
||||
current_step_index_ = step_index;
|
||||
step_entered_at_ms_ = now_ms;
|
||||
scene_changed_ = true;
|
||||
@@ -487,7 +538,22 @@ 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;
|
||||
}
|
||||
Serial.printf("[SCENARIO] step=%s via=%s\n", step->id, source != nullptr ? source : "n/a");
|
||||
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",
|
||||
(from_scene != nullptr && from_scene[0] != '\0') ? from_scene : "n/a",
|
||||
(screen_scene_id != nullptr && screen_scene_id[0] != '\0') ? screen_scene_id : "n/a",
|
||||
(event_name != nullptr && event_name[0] != '\0') ? event_name : "-",
|
||||
(source != nullptr && source[0] != '\0') ? source : "-",
|
||||
(audio_pack_id != nullptr && audio_pack_id[0] != '\0') ? audio_pack_id : "n/a");
|
||||
}
|
||||
|
||||
const StepDef* ScenarioManager::currentStep() const {
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
#include "app/scene_fx_orchestrator.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
bool isAmpSceneId(const char* scene_id) {
|
||||
if (scene_id == nullptr || scene_id[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
return std::strcmp(scene_id, "SCENE_MP3_PLAYER") == 0 ||
|
||||
std::strcmp(scene_id, "SCENE_AUDIO_PLAYER") == 0 ||
|
||||
std::strcmp(scene_id, "SCENE_MP3") == 0;
|
||||
}
|
||||
|
||||
bool isCameraSceneId(const char* scene_id) {
|
||||
return (scene_id != nullptr && std::strcmp(scene_id, "SCENE_PHOTO_MANAGER") == 0);
|
||||
}
|
||||
|
||||
bool isIntroSceneId(const char* scene_id) {
|
||||
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) {
|
||||
if (scene_id == nullptr || scene_id[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
return std::strcmp(scene_id, "SCENE_WINNER") == 0 || std::strcmp(scene_id, "SCENE_FIREWORKS") == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SceneTransitionPlan SceneFxOrchestrator::planTransition(const char* scene_id,
|
||||
bool scene_changed,
|
||||
bool force_refresh) {
|
||||
const char* target_scene = (scene_id != nullptr && scene_id[0] != '\0') ? scene_id : "SCENE_READY";
|
||||
const SceneRuntimeOwner target_owner = classifyOwner(target_scene);
|
||||
|
||||
SceneTransitionPlan plan = {};
|
||||
plan.should_apply = scene_changed || force_refresh || !sameText(current_scene_id_, target_scene);
|
||||
plan.owner_changed = (target_owner != current_owner_);
|
||||
plan.scene_changed = scene_changed || !sameText(current_scene_id_, target_scene);
|
||||
plan.from_owner = current_owner_;
|
||||
plan.to_owner = target_owner;
|
||||
plan.from_scene = current_scene_id_;
|
||||
plan.to_scene = target_scene;
|
||||
return plan;
|
||||
}
|
||||
|
||||
void SceneFxOrchestrator::applyTransition(const SceneTransitionPlan& plan) {
|
||||
if (!plan.should_apply || plan.to_scene == nullptr) {
|
||||
return;
|
||||
}
|
||||
current_owner_ = plan.to_owner;
|
||||
std::strncpy(current_scene_id_, plan.to_scene, sizeof(current_scene_id_) - 1U);
|
||||
current_scene_id_[sizeof(current_scene_id_) - 1U] = '\0';
|
||||
}
|
||||
|
||||
SceneRuntimeOwner SceneFxOrchestrator::currentOwner() const {
|
||||
return current_owner_;
|
||||
}
|
||||
|
||||
const char* SceneFxOrchestrator::currentSceneId() const {
|
||||
return current_scene_id_;
|
||||
}
|
||||
|
||||
SceneRuntimeOwner SceneFxOrchestrator::classifyOwner(const char* scene_id) const {
|
||||
if (isCameraSceneId(scene_id)) {
|
||||
return SceneRuntimeOwner::kCamera;
|
||||
}
|
||||
if (isAmpSceneId(scene_id)) {
|
||||
return SceneRuntimeOwner::kAmp;
|
||||
}
|
||||
if (isIntroSceneId(scene_id)) {
|
||||
return SceneRuntimeOwner::kIntroFx;
|
||||
}
|
||||
if (isDirectFxSceneId(scene_id)) {
|
||||
return SceneRuntimeOwner::kDirectFx;
|
||||
}
|
||||
return SceneRuntimeOwner::kStory;
|
||||
}
|
||||
|
||||
bool SceneFxOrchestrator::sameText(const char* lhs, const char* rhs) {
|
||||
if (lhs == nullptr || rhs == nullptr) {
|
||||
return false;
|
||||
}
|
||||
return std::strcmp(lhs, rhs) == 0;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
+13
-2
@@ -38,14 +38,25 @@ 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", 88U, 214U, 92U, 52U, true},
|
||||
{"__DEFAULT__", 50U, 122U, 255U, 50U, 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) {
|
||||
if (value < 0) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// boot_mode_store.cpp - NVS startup mode persistence.
|
||||
#include "runtime/provisioning/boot_mode_store.h"
|
||||
|
||||
#include <Preferences.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char* kNamespace = "zacus_boot";
|
||||
constexpr const char* kKeyStartupMode = "startup_mode";
|
||||
constexpr const char* kKeyMediaValidated = "media_validated";
|
||||
|
||||
} // namespace
|
||||
|
||||
bool BootModeStore::loadMode(StartupMode* out_mode) const {
|
||||
if (out_mode == nullptr) {
|
||||
return false;
|
||||
}
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kNamespace, true)) {
|
||||
return false;
|
||||
}
|
||||
const String mode_text = prefs.getString(kKeyStartupMode, "story");
|
||||
prefs.end();
|
||||
if (mode_text.equalsIgnoreCase("media_manager")) {
|
||||
*out_mode = StartupMode::kMediaManager;
|
||||
} else {
|
||||
*out_mode = StartupMode::kStory;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BootModeStore::saveMode(StartupMode mode) const {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kNamespace, false)) {
|
||||
return false;
|
||||
}
|
||||
const size_t written = prefs.putString(kKeyStartupMode, modeLabel(mode));
|
||||
prefs.end();
|
||||
return written > 0U;
|
||||
}
|
||||
|
||||
bool BootModeStore::clearMode() const {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kNamespace, false)) {
|
||||
return false;
|
||||
}
|
||||
prefs.remove(kKeyStartupMode);
|
||||
prefs.remove(kKeyMediaValidated);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BootModeStore::isMediaValidated() const {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kNamespace, true)) {
|
||||
return false;
|
||||
}
|
||||
const bool validated = prefs.getBool(kKeyMediaValidated, false);
|
||||
prefs.end();
|
||||
return validated;
|
||||
}
|
||||
|
||||
bool BootModeStore::setMediaValidated(bool validated) const {
|
||||
Preferences prefs;
|
||||
if (!prefs.begin(kNamespace, false)) {
|
||||
return false;
|
||||
}
|
||||
prefs.putBool(kKeyMediaValidated, validated);
|
||||
prefs.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* BootModeStore::modeLabel(StartupMode mode) {
|
||||
return (mode == StartupMode::kMediaManager) ? "media_manager" : "story";
|
||||
}
|
||||
@@ -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
+65
-4
@@ -4,8 +4,10 @@
|
||||
#include <ArduinoJson.h>
|
||||
#include <FS.h>
|
||||
#include <LittleFS.h>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "audio_manager.h"
|
||||
|
||||
@@ -44,6 +46,54 @@ bool equalsIgnoreCase(const char* lhs, const char* rhs) {
|
||||
}
|
||||
}
|
||||
|
||||
bool isAsciiDigit(char ch) {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
|
||||
int compareNaturalPath(const String& lhs, const String& rhs) {
|
||||
const char* a = lhs.c_str();
|
||||
const char* b = rhs.c_str();
|
||||
size_t ia = 0U;
|
||||
size_t ib = 0U;
|
||||
while (a[ia] != '\0' && b[ib] != '\0') {
|
||||
const char ca = a[ia];
|
||||
const char cb = b[ib];
|
||||
if (isAsciiDigit(ca) && isAsciiDigit(cb)) {
|
||||
unsigned long va = 0UL;
|
||||
unsigned long vb = 0UL;
|
||||
while (isAsciiDigit(a[ia])) {
|
||||
va = (va * 10UL) + static_cast<unsigned long>(a[ia] - '0');
|
||||
++ia;
|
||||
}
|
||||
while (isAsciiDigit(b[ib])) {
|
||||
vb = (vb * 10UL) + static_cast<unsigned long>(b[ib] - '0');
|
||||
++ib;
|
||||
}
|
||||
if (va < vb) {
|
||||
return -1;
|
||||
}
|
||||
if (va > vb) {
|
||||
return 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const char la = toLowerAscii(ca);
|
||||
const char lb = toLowerAscii(cb);
|
||||
if (la < lb) {
|
||||
return -1;
|
||||
}
|
||||
if (la > lb) {
|
||||
return 1;
|
||||
}
|
||||
++ia;
|
||||
++ib;
|
||||
}
|
||||
if (a[ia] == '\0' && b[ib] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
return (a[ia] == '\0') ? -1 : 1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool MediaManager::begin(const Config& config) {
|
||||
@@ -110,8 +160,8 @@ bool MediaManager::listFiles(const char* kind, String* out_json) const {
|
||||
return false;
|
||||
}
|
||||
|
||||
DynamicJsonDocument document(2048);
|
||||
JsonArray files = document.to<JsonArray>();
|
||||
std::vector<String> listed_paths;
|
||||
listed_paths.reserve(32U);
|
||||
File entry = folder.openNextFile();
|
||||
while (entry) {
|
||||
if (!entry.isDirectory()) {
|
||||
@@ -119,13 +169,24 @@ bool MediaManager::listFiles(const char* kind, String* out_json) const {
|
||||
if (!path.startsWith("/")) {
|
||||
path = "/" + path;
|
||||
}
|
||||
files.add(path);
|
||||
listed_paths.push_back(path);
|
||||
}
|
||||
entry.close();
|
||||
entry = folder.openNextFile();
|
||||
}
|
||||
serializeJson(files, *out_json);
|
||||
folder.close();
|
||||
|
||||
// Keep media list stable and human-friendly for MP3/photo browsing.
|
||||
std::sort(listed_paths.begin(), listed_paths.end(), [](const String& lhs, const String& rhs) {
|
||||
return compareNaturalPath(lhs, rhs) < 0;
|
||||
});
|
||||
|
||||
DynamicJsonDocument document(4096);
|
||||
JsonArray files = document.to<JsonArray>();
|
||||
for (const String& path : listed_paths) {
|
||||
files.add(path);
|
||||
}
|
||||
serializeJson(files, *out_json);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
// qr_scan_controller.cpp
|
||||
#include "ui/qr/qr_scan_controller.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#if defined(USE_CAM) && (USE_CAM != 0) && __has_include(<ESP32QRCodeReader.h>) && __has_include(<ESP32CameraPins.h>)
|
||||
#define ZACUS_QR_READER_AVAILABLE 1
|
||||
#include <ESP32CameraPins.h>
|
||||
#include <ESP32QRCodeReader.h>
|
||||
#else
|
||||
#define ZACUS_QR_READER_AVAILABLE 0
|
||||
#endif
|
||||
|
||||
namespace ui {
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
auto qrPayloadLength(const T& qr, int) -> decltype(qr.payloadLen) {
|
||||
return qr.payloadLen;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
auto qrPayloadLength(const T& qr, long) -> decltype(qr.payload_len) {
|
||||
return qr.payload_len;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QrScanController::~QrScanController() {
|
||||
#if ZACUS_QR_READER_AVAILABLE
|
||||
if (reader_ != nullptr) {
|
||||
delete reinterpret_cast<ESP32QRCodeReader*>(reader_);
|
||||
reader_ = nullptr;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
bool QrScanController::begin() {
|
||||
if (ready_) {
|
||||
return true;
|
||||
}
|
||||
#if !ZACUS_QR_READER_AVAILABLE
|
||||
Serial.println("[QR] scanner unavailable (missing dependency or USE_CAM=0)");
|
||||
return false;
|
||||
#else
|
||||
CameraPins pins = {};
|
||||
pins.PWDN_GPIO_NUM = FREENOVE_CAM_PWDN;
|
||||
pins.RESET_GPIO_NUM = FREENOVE_CAM_RESET;
|
||||
pins.XCLK_GPIO_NUM = FREENOVE_CAM_XCLK;
|
||||
pins.SIOD_GPIO_NUM = FREENOVE_CAM_SIOD;
|
||||
pins.SIOC_GPIO_NUM = FREENOVE_CAM_SIOC;
|
||||
pins.Y9_GPIO_NUM = FREENOVE_CAM_Y9;
|
||||
pins.Y8_GPIO_NUM = FREENOVE_CAM_Y8;
|
||||
pins.Y7_GPIO_NUM = FREENOVE_CAM_Y7;
|
||||
pins.Y6_GPIO_NUM = FREENOVE_CAM_Y6;
|
||||
pins.Y5_GPIO_NUM = FREENOVE_CAM_Y5;
|
||||
pins.Y4_GPIO_NUM = FREENOVE_CAM_Y4;
|
||||
pins.Y3_GPIO_NUM = FREENOVE_CAM_Y3;
|
||||
pins.Y2_GPIO_NUM = FREENOVE_CAM_Y2;
|
||||
pins.VSYNC_GPIO_NUM = FREENOVE_CAM_VSYNC;
|
||||
pins.HREF_GPIO_NUM = FREENOVE_CAM_HREF;
|
||||
pins.PCLK_GPIO_NUM = FREENOVE_CAM_PCLK;
|
||||
auto* reader = new ESP32QRCodeReader(pins);
|
||||
if (reader == nullptr) {
|
||||
Serial.println("[QR] scanner alloc failed");
|
||||
return false;
|
||||
}
|
||||
reader->setup();
|
||||
reader->beginOnCore(0);
|
||||
reader_ = reinterpret_cast<void*>(reader);
|
||||
ready_ = true;
|
||||
enabled_ = false;
|
||||
Serial.println("[QR] scanner ready");
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool QrScanController::poll(QrScanResult* out, uint32_t timeout_ms) {
|
||||
if (!ready_ || !enabled_ || out == nullptr) {
|
||||
return false;
|
||||
}
|
||||
#if !ZACUS_QR_READER_AVAILABLE
|
||||
(void)timeout_ms;
|
||||
return false;
|
||||
#else
|
||||
auto* reader = reinterpret_cast<ESP32QRCodeReader*>(reader_);
|
||||
if (reader == nullptr) {
|
||||
return false;
|
||||
}
|
||||
QRCodeData qr;
|
||||
if (!reader->receiveQrCode(&qr, timeout_ms)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
out->at_ms = millis();
|
||||
out->decoder_valid = qr.valid;
|
||||
out->payload_len = 0U;
|
||||
out->payload[0] = '\0';
|
||||
const int payload_len = static_cast<int>(qrPayloadLength(qr, 0));
|
||||
if (!qr.valid || payload_len <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t max_len = sizeof(out->payload) - 1U;
|
||||
const size_t copy_len = (static_cast<size_t>(payload_len) < max_len) ? static_cast<size_t>(payload_len) : max_len;
|
||||
std::memcpy(out->payload, qr.payload, copy_len);
|
||||
out->payload[copy_len] = '\0';
|
||||
out->payload_len = static_cast<uint16_t>(copy_len);
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
// qr_scene_controller.cpp
|
||||
|
||||
#include "ui/qr/qr_scene_controller.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <LittleFS.h>
|
||||
#include <cstring>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include "ui/qr/qr_scan_controller.h"
|
||||
#include "ui/qr/qr_validation_rules.h"
|
||||
|
||||
namespace ui {
|
||||
namespace {
|
||||
|
||||
void copyTextSafe(char* out, size_t out_size, const char* value) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (value == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, value, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void QrSceneController::onSceneEnter(QrScanController* scanner, lv_obj_t* subtitle_label) {
|
||||
scene_active_ = true;
|
||||
bool qr_ready = false;
|
||||
if (scanner != nullptr) {
|
||||
qr_ready = scanner->begin();
|
||||
scanner->setEnabled(qr_ready);
|
||||
}
|
||||
last_decode_ms_ = 0U;
|
||||
feedback_until_ms_ = 0U;
|
||||
last_payload_[0] = '\0';
|
||||
last_match_ = false;
|
||||
pending_runtime_event_[0] = '\0';
|
||||
pending_runtime_event_valid_ = false;
|
||||
simulated_payload_[0] = '\0';
|
||||
simulated_pending_ = false;
|
||||
const bool has_reticle = LittleFS.exists("/ui/qr/reticle.png");
|
||||
const bool has_scanlines = LittleFS.exists("/ui/qr/scanlines.png");
|
||||
Serial.printf("[QR_UI] assets reticle=%u scanlines=%u\n", has_reticle ? 1U : 0U, has_scanlines ? 1U : 0U);
|
||||
if (subtitle_label != nullptr) {
|
||||
lv_label_set_text(subtitle_label, qr_ready ? "SCANNE UN QR CODE..." : "CAMERA/QR indisponible");
|
||||
lv_obj_clear_flag(subtitle_label, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_set_style_text_color(subtitle_label, lv_color_hex(0xE8F1FF), LV_PART_MAIN);
|
||||
}
|
||||
}
|
||||
|
||||
void QrSceneController::onSceneExit(QrScanController* scanner) {
|
||||
scene_active_ = false;
|
||||
if (scanner != nullptr) {
|
||||
scanner->setEnabled(false);
|
||||
}
|
||||
feedback_until_ms_ = 0U;
|
||||
last_decode_ms_ = 0U;
|
||||
simulated_pending_ = false;
|
||||
simulated_payload_[0] = '\0';
|
||||
pending_runtime_event_valid_ = false;
|
||||
pending_runtime_event_[0] = '\0';
|
||||
}
|
||||
|
||||
void QrSceneController::handleDecodedPayload(const char* payload,
|
||||
bool decoder_valid,
|
||||
uint32_t now_ms,
|
||||
const QrValidationRules& rules,
|
||||
lv_obj_t* subtitle_label,
|
||||
lv_obj_t* symbol_label) {
|
||||
const char* safe_payload = (payload != nullptr) ? payload : "";
|
||||
copyTextSafe(last_payload_, sizeof(last_payload_), safe_payload);
|
||||
last_decode_ms_ = now_ms;
|
||||
last_match_ = decoder_valid && rules.matches(safe_payload);
|
||||
feedback_until_ms_ = now_ms + (last_match_ ? 1800U : 900U);
|
||||
if (subtitle_label != nullptr) {
|
||||
if (last_match_) {
|
||||
lv_label_set_text(subtitle_label, "VALIDATION OK");
|
||||
lv_obj_set_style_text_color(subtitle_label, lv_color_hex(0x70FF8A), LV_PART_MAIN);
|
||||
} else {
|
||||
lv_label_set_text(subtitle_label, "CODE INVALIDE");
|
||||
lv_obj_set_style_text_color(subtitle_label, lv_color_hex(0xFF6A6A), LV_PART_MAIN);
|
||||
}
|
||||
lv_obj_clear_flag(subtitle_label, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
if (symbol_label != nullptr) {
|
||||
lv_label_set_text(symbol_label, last_match_ ? "WINNER" : "QR");
|
||||
}
|
||||
copyTextSafe(pending_runtime_event_,
|
||||
sizeof(pending_runtime_event_),
|
||||
last_match_ ? "QR_OK" : "QR_INVALID");
|
||||
pending_runtime_event_valid_ = true;
|
||||
Serial.printf("[QR] %s payload=%s\n", last_match_ ? "OK" : "INVALID", safe_payload);
|
||||
}
|
||||
|
||||
void QrSceneController::tick(uint32_t now_ms,
|
||||
QrScanController* scanner,
|
||||
const QrValidationRules& rules,
|
||||
lv_obj_t* subtitle_label,
|
||||
lv_obj_t* symbol_label) {
|
||||
if (!scene_active_ || scanner == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (feedback_until_ms_ != 0U && now_ms > feedback_until_ms_) {
|
||||
feedback_until_ms_ = 0U;
|
||||
if (subtitle_label != nullptr) {
|
||||
lv_label_set_text(subtitle_label, "SCANNE UN QR CODE...");
|
||||
lv_obj_set_style_text_color(subtitle_label, lv_color_hex(0xE8F1FF), LV_PART_MAIN);
|
||||
lv_obj_clear_flag(subtitle_label, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
if (simulated_pending_) {
|
||||
simulated_pending_ = false;
|
||||
handleDecodedPayload(simulated_payload_, true, now_ms, rules, subtitle_label, symbol_label);
|
||||
simulated_payload_[0] = '\0';
|
||||
return;
|
||||
}
|
||||
if (last_decode_ms_ != 0U && (now_ms - last_decode_ms_) < 250U) {
|
||||
return;
|
||||
}
|
||||
QrScanResult result;
|
||||
if (!scanner->poll(&result, 0U)) {
|
||||
return;
|
||||
}
|
||||
handleDecodedPayload(result.payload, result.decoder_valid, now_ms, rules, subtitle_label, symbol_label);
|
||||
}
|
||||
|
||||
bool QrSceneController::consumeRuntimeEvent(char* out_event, size_t capacity) {
|
||||
if (out_event == nullptr || capacity == 0U || !pending_runtime_event_valid_) {
|
||||
return false;
|
||||
}
|
||||
copyTextSafe(out_event, capacity, pending_runtime_event_);
|
||||
pending_runtime_event_[0] = '\0';
|
||||
pending_runtime_event_valid_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool QrSceneController::queueSimulatedPayload(const char* payload) {
|
||||
if (payload == nullptr || payload[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
copyTextSafe(simulated_payload_, sizeof(simulated_payload_), payload);
|
||||
simulated_pending_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
// qr_validation_rules.cpp
|
||||
|
||||
#include "ui/qr/qr_validation_rules.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <strings.h>
|
||||
|
||||
namespace ui {
|
||||
namespace {
|
||||
|
||||
void copyTextSafe(char* out, size_t out_size, const char* value) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (value == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, value, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
bool startsWithCaseInsensitive(const char* value, const char* prefix) {
|
||||
if (value == nullptr || prefix == nullptr) {
|
||||
return false;
|
||||
}
|
||||
while (*prefix != '\0') {
|
||||
if (*value == '\0') {
|
||||
return false;
|
||||
}
|
||||
const char lhs = static_cast<char>(std::tolower(static_cast<unsigned char>(*value)));
|
||||
const char rhs = static_cast<char>(std::tolower(static_cast<unsigned char>(*prefix)));
|
||||
if (lhs != rhs) {
|
||||
return false;
|
||||
}
|
||||
++value;
|
||||
++prefix;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool containsCaseInsensitive(const char* haystack, const char* needle) {
|
||||
if (haystack == nullptr || needle == nullptr || needle[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
const size_t haystack_len = std::strlen(haystack);
|
||||
const size_t needle_len = std::strlen(needle);
|
||||
if (needle_len > haystack_len) {
|
||||
return false;
|
||||
}
|
||||
for (size_t index = 0U; index + needle_len <= haystack_len; ++index) {
|
||||
bool ok = true;
|
||||
for (size_t offset = 0U; offset < needle_len; ++offset) {
|
||||
const char lhs = static_cast<char>(std::tolower(static_cast<unsigned char>(haystack[index + offset])));
|
||||
const char rhs = static_cast<char>(std::tolower(static_cast<unsigned char>(needle[offset])));
|
||||
if (lhs != rhs) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isAsciiSpace(char c) {
|
||||
return (c == ' ' || c == '\t' || c == '\r' || c == '\n');
|
||||
}
|
||||
|
||||
void trimAsciiWhitespaceInPlace(char* text) {
|
||||
if (text == nullptr) {
|
||||
return;
|
||||
}
|
||||
char* start = text;
|
||||
while (*start != '\0' && isAsciiSpace(*start)) {
|
||||
++start;
|
||||
}
|
||||
if (start != text) {
|
||||
std::memmove(text, start, std::strlen(start) + 1U);
|
||||
}
|
||||
size_t length = std::strlen(text);
|
||||
while (length > 0U && isAsciiSpace(text[length - 1U])) {
|
||||
text[length - 1U] = '\0';
|
||||
--length;
|
||||
}
|
||||
}
|
||||
|
||||
void asciiUpperInPlace(char* text) {
|
||||
if (text == nullptr) {
|
||||
return;
|
||||
}
|
||||
for (; *text != '\0'; ++text) {
|
||||
*text = static_cast<char>(std::toupper(static_cast<unsigned char>(*text)));
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t crc16CcittFalse(const uint8_t* data, size_t len) {
|
||||
uint16_t crc = 0xFFFFu;
|
||||
for (size_t index = 0U; index < len; ++index) {
|
||||
crc ^= static_cast<uint16_t>(data[index]) << 8U;
|
||||
for (uint8_t bit = 0U; bit < 8U; ++bit) {
|
||||
crc = ((crc & 0x8000u) != 0U) ? static_cast<uint16_t>((crc << 1U) ^ 0x1021u)
|
||||
: static_cast<uint16_t>(crc << 1U);
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool parseHex16(const char* text, uint16_t* out) {
|
||||
if (text == nullptr || out == nullptr) {
|
||||
return false;
|
||||
}
|
||||
while (*text != '\0' && isAsciiSpace(*text)) {
|
||||
++text;
|
||||
}
|
||||
if (text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) {
|
||||
text += 2;
|
||||
}
|
||||
uint32_t value = 0U;
|
||||
uint8_t digits = 0U;
|
||||
for (; *text != '\0'; ++text) {
|
||||
const char c = *text;
|
||||
if (isAsciiSpace(c)) {
|
||||
break;
|
||||
}
|
||||
uint8_t nibble = 0xFFU;
|
||||
if (c >= '0' && c <= '9') {
|
||||
nibble = static_cast<uint8_t>(c - '0');
|
||||
} else if (c >= 'a' && c <= 'f') {
|
||||
nibble = static_cast<uint8_t>(c - 'a' + 10);
|
||||
} else if (c >= 'A' && c <= 'F') {
|
||||
nibble = static_cast<uint8_t>(c - 'A' + 10);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
value = (value << 4U) | nibble;
|
||||
++digits;
|
||||
if (digits > 4U) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (digits == 0U) {
|
||||
return false;
|
||||
}
|
||||
*out = static_cast<uint16_t>(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void QrValidationRules::clear() {
|
||||
expected_count_ = 0U;
|
||||
for (uint8_t index = 0U; index < kExpectedMax; ++index) {
|
||||
expected_values_[index][0] = '\0';
|
||||
}
|
||||
prefix_[0] = '\0';
|
||||
contains_[0] = '\0';
|
||||
case_insensitive_ = false;
|
||||
crc16_enabled_ = false;
|
||||
crc16_sep_ = '*';
|
||||
}
|
||||
|
||||
void QrValidationRules::configureFromPayload(JsonVariantConst root) {
|
||||
clear();
|
||||
if (root.isNull()) {
|
||||
return;
|
||||
}
|
||||
const JsonObjectConst qr = root["qr"].as<JsonObjectConst>();
|
||||
if (qr.isNull()) {
|
||||
return;
|
||||
}
|
||||
case_insensitive_ = qr["caseInsensitive"] | false;
|
||||
if (qr["crc16"].is<JsonObjectConst>()) {
|
||||
const JsonObjectConst crc = qr["crc16"].as<JsonObjectConst>();
|
||||
crc16_enabled_ = crc["enabled"] | true;
|
||||
const char* sep = crc["sep"] | "*";
|
||||
if (sep != nullptr && sep[0] != '\0') {
|
||||
crc16_sep_ = sep[0];
|
||||
}
|
||||
} else {
|
||||
crc16_enabled_ = qr["crc16"] | false;
|
||||
const char* sep = qr["crcSep"] | "*";
|
||||
if (sep != nullptr && sep[0] != '\0') {
|
||||
crc16_sep_ = sep[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (qr["expected"].is<JsonArrayConst>()) {
|
||||
for (JsonVariantConst item : qr["expected"].as<JsonArrayConst>()) {
|
||||
const char* value = item | "";
|
||||
if (value[0] == '\0' || expected_count_ >= kExpectedMax) {
|
||||
continue;
|
||||
}
|
||||
copyTextSafe(expected_values_[expected_count_], sizeof(expected_values_[0]), value);
|
||||
++expected_count_;
|
||||
}
|
||||
} else {
|
||||
const char* expected = qr["expected"] | "";
|
||||
if (expected[0] != '\0') {
|
||||
copyTextSafe(expected_values_[0], sizeof(expected_values_[0]), expected);
|
||||
expected_count_ = 1U;
|
||||
}
|
||||
}
|
||||
copyTextSafe(prefix_, sizeof(prefix_), qr["prefix"] | "");
|
||||
copyTextSafe(contains_, sizeof(contains_), qr["contains"] | "");
|
||||
}
|
||||
|
||||
bool QrValidationRules::matches(const char* payload) const {
|
||||
if (payload == nullptr || payload[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
char buffer[192] = {0};
|
||||
copyTextSafe(buffer, sizeof(buffer), payload);
|
||||
trimAsciiWhitespaceInPlace(buffer);
|
||||
if (buffer[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
const char* match_str = buffer;
|
||||
|
||||
if (crc16_enabled_) {
|
||||
const char separator = (crc16_sep_ != '\0') ? crc16_sep_ : '*';
|
||||
char* sep_pos = std::strrchr(buffer, separator);
|
||||
if (sep_pos == nullptr) {
|
||||
return false;
|
||||
}
|
||||
*sep_pos = '\0';
|
||||
char* crc_text = sep_pos + 1;
|
||||
trimAsciiWhitespaceInPlace(crc_text);
|
||||
uint16_t expected_crc = 0U;
|
||||
if (!parseHex16(crc_text, &expected_crc)) {
|
||||
return false;
|
||||
}
|
||||
char crc_buffer[192] = {0};
|
||||
copyTextSafe(crc_buffer, sizeof(crc_buffer), buffer);
|
||||
if (case_insensitive_) {
|
||||
asciiUpperInPlace(crc_buffer);
|
||||
}
|
||||
const uint16_t actual_crc =
|
||||
crc16CcittFalse(reinterpret_cast<const uint8_t*>(crc_buffer), std::strlen(crc_buffer));
|
||||
if (actual_crc != expected_crc) {
|
||||
return false;
|
||||
}
|
||||
trimAsciiWhitespaceInPlace(buffer);
|
||||
if (buffer[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
match_str = buffer;
|
||||
}
|
||||
|
||||
if (expected_count_ > 0U) {
|
||||
for (uint8_t index = 0U; index < expected_count_; ++index) {
|
||||
const char* expected = expected_values_[index];
|
||||
if (expected[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
if (case_insensitive_) {
|
||||
if (strcasecmp(match_str, expected) == 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (std::strcmp(match_str, expected) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prefix_[0] != '\0') {
|
||||
return case_insensitive_ ? startsWithCaseInsensitive(match_str, prefix_)
|
||||
: (std::strncmp(match_str, prefix_, std::strlen(prefix_)) == 0);
|
||||
}
|
||||
if (contains_[0] != '\0') {
|
||||
return case_insensitive_ ? containsCaseInsensitive(match_str, contains_)
|
||||
: (std::strstr(match_str, contains_) != nullptr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -141,6 +141,18 @@ int16_t activeDisplayHeight() {
|
||||
return ((FREENOVE_LCD_ROTATION & 0x1U) != 0U) ? FREENOVE_LCD_WIDTH : FREENOVE_LCD_HEIGHT;
|
||||
}
|
||||
|
||||
void copyTextSafe(char* out, size_t out_size, const char* value) {
|
||||
if (out == nullptr || out_size == 0U) {
|
||||
return;
|
||||
}
|
||||
if (value == nullptr) {
|
||||
out[0] = '\0';
|
||||
return;
|
||||
}
|
||||
std::strncpy(out, value, out_size - 1U);
|
||||
out[out_size - 1U] = '\0';
|
||||
}
|
||||
|
||||
uint32_t pseudoRandom32(uint32_t value) {
|
||||
value ^= (value << 13U);
|
||||
value ^= (value >> 17U);
|
||||
@@ -856,6 +868,14 @@ void UiManager::submitInputEvent(const UiInputEvent& event) {
|
||||
handleButton(event.key, event.long_press);
|
||||
}
|
||||
|
||||
bool UiManager::consumeRuntimeEvent(char* out_event, size_t capacity) {
|
||||
return qr_scene_controller_.consumeRuntimeEvent(out_event, capacity);
|
||||
}
|
||||
|
||||
bool UiManager::simulateQrPayload(const char* payload) {
|
||||
return qr_scene_controller_.queueSimulatedPayload(payload);
|
||||
}
|
||||
|
||||
void UiManager::dumpStatus(UiStatusTopic topic) const {
|
||||
if (topic == UiStatusTopic::kMemory) {
|
||||
dumpMemoryStatus();
|
||||
@@ -917,6 +937,7 @@ void UiManager::update() {
|
||||
updatePageLine();
|
||||
}
|
||||
renderMicrophoneWaveform();
|
||||
qr_scene_controller_.tick(now_ms, &qr_scan_, qr_rules_, scene_subtitle_label_, scene_symbol_label_);
|
||||
pollAsyncFlush();
|
||||
const bool flush_busy =
|
||||
isDisplayOutputBusy();
|
||||
@@ -4910,6 +4931,53 @@ void UiManager::renderMicrophoneWaveform() {
|
||||
stability_pct);
|
||||
}
|
||||
|
||||
uint32_t UiManager::hashScenePayload(const char* payload) {
|
||||
// FNV-1a 32-bit keeps payload-delta checks deterministic and cheap on MCU.
|
||||
uint32_t hash = 2166136261UL;
|
||||
if (payload == nullptr) {
|
||||
return hash;
|
||||
}
|
||||
for (size_t index = 0U; payload[index] != '\0'; ++index) {
|
||||
hash ^= static_cast<uint8_t>(payload[index]);
|
||||
hash *= 16777619UL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
bool UiManager::shouldApplySceneStaticState(const char* scene_id,
|
||||
const char* payload_json,
|
||||
bool scene_changed) const {
|
||||
const uint32_t payload_hash = hashScenePayload(payload_json);
|
||||
if (scene_changed) {
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(last_scene_id_, scene_id) != 0) {
|
||||
return true;
|
||||
}
|
||||
return payload_hash != last_payload_crc_;
|
||||
}
|
||||
|
||||
void UiManager::applySceneDynamicState(const String& subtitle,
|
||||
bool show_subtitle,
|
||||
bool audio_playing,
|
||||
uint32_t text_rgb) {
|
||||
if (scene_subtitle_label_ != nullptr) {
|
||||
const String subtitle_ui = asciiFallbackForUiText(subtitle.c_str());
|
||||
lv_label_set_text(scene_subtitle_label_, subtitle_ui.c_str());
|
||||
if (show_subtitle && subtitle.length() > 0U) {
|
||||
lv_obj_clear_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_move_foreground(scene_subtitle_label_);
|
||||
lv_obj_set_style_text_color(scene_subtitle_label_, lv_color_hex(text_rgb), LV_PART_MAIN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
if (scene_core_ != nullptr) {
|
||||
lv_obj_set_style_bg_opa(scene_core_, audio_playing ? LV_OPA_COVER : LV_OPA_80, LV_PART_MAIN);
|
||||
}
|
||||
last_audio_playing_ = audio_playing;
|
||||
}
|
||||
|
||||
void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
const char* screen_scene_id,
|
||||
const char* step_id,
|
||||
@@ -4939,21 +5007,24 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
}
|
||||
const char* scene_id = normalized_scene_id;
|
||||
const bool scene_changed = (std::strcmp(last_scene_id_, scene_id) != 0);
|
||||
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);
|
||||
if (scene_changed && has_previous_scene) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (!win_etape_intro_scene && intro_active_) {
|
||||
if (static_state_changed && !win_etape_intro_scene && intro_active_) {
|
||||
stopIntroAndCleanup();
|
||||
}
|
||||
if (win_etape_intro_scene && intro_active_ && !scene_changed) {
|
||||
return;
|
||||
}
|
||||
if (!direct_fx_scene) {
|
||||
if (static_state_changed && !direct_fx_scene) {
|
||||
direct_fx_scene_active_ = false;
|
||||
}
|
||||
|
||||
@@ -5126,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";
|
||||
@@ -5134,9 +5205,15 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
bg_rgb = 0x2A0508UL;
|
||||
accent_rgb = 0xFF4A45UL;
|
||||
text_rgb = 0xFFD5D1UL;
|
||||
} else if (std::strcmp(scene_id, "SCENE_LA_DETECTOR") == 0 ||
|
||||
std::strcmp(scene_id, "SCENE_SEARCH") == 0 ||
|
||||
std::strcmp(scene_id, "SCENE_CAMERA_SCAN") == 0) {
|
||||
} 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 = "";
|
||||
symbol = "AUDIO";
|
||||
@@ -5154,6 +5231,53 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
frame_split_layout = true;
|
||||
frame_dy = 8;
|
||||
}
|
||||
} 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";
|
||||
effect = SceneEffect::kNone;
|
||||
transition = SceneTransition::kFade;
|
||||
transition_ms = 180U;
|
||||
bg_rgb = 0x102040UL;
|
||||
accent_rgb = 0x5CA3FFUL;
|
||||
text_rgb = 0xF3F7FFUL;
|
||||
show_title = true;
|
||||
show_subtitle = true;
|
||||
show_symbol = true;
|
||||
waveform_enabled = false;
|
||||
} else if (std::strcmp(scene_id, "SCENE_MEDIA_MANAGER") == 0) {
|
||||
title = "MEDIA MANAGER";
|
||||
subtitle = "PHOTO / MP3 / STORY";
|
||||
symbol = "MEDIA";
|
||||
effect = SceneEffect::kRadar;
|
||||
bg_rgb = 0x081A34UL;
|
||||
accent_rgb = 0x8BC4FFUL;
|
||||
text_rgb = 0xEAF6FFUL;
|
||||
show_title = true;
|
||||
show_subtitle = true;
|
||||
show_symbol = true;
|
||||
} else if (std::strcmp(scene_id, "SCENE_PHOTO_MANAGER") == 0) {
|
||||
title = "PHOTO MANAGER";
|
||||
subtitle = "Capture JPEG";
|
||||
symbol = "PHOTO";
|
||||
effect = SceneEffect::kNone;
|
||||
bg_rgb = 0x0B1A2EUL;
|
||||
accent_rgb = 0x86CCFFUL;
|
||||
text_rgb = 0xEEF6FFUL;
|
||||
show_title = true;
|
||||
show_subtitle = true;
|
||||
show_symbol = true;
|
||||
} else if (std::strcmp(scene_id, "SCENE_SIGNAL_SPIKE") == 0) {
|
||||
title = "PIC DE SIGNAL";
|
||||
subtitle = "Interference detectee";
|
||||
@@ -5190,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";
|
||||
@@ -5206,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";
|
||||
@@ -5216,12 +5353,21 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
text_rgb = 0xE8FFE7UL;
|
||||
}
|
||||
|
||||
resetSceneTimeline();
|
||||
if (static_state_changed) {
|
||||
resetSceneTimeline();
|
||||
}
|
||||
|
||||
if (static_state_changed) {
|
||||
qr_rules_.clear();
|
||||
}
|
||||
|
||||
if (screen_payload_json != nullptr && screen_payload_json[0] != '\0') {
|
||||
DynamicJsonDocument document(4096);
|
||||
const DeserializationError error = deserializeJson(document, screen_payload_json);
|
||||
if (!error) {
|
||||
if (qr_scene && static_state_changed) {
|
||||
qr_rules_.configureFromPayload(document.as<JsonVariantConst>());
|
||||
}
|
||||
const char* payload_title = document["title"] | document["content"]["title"] | document["visual"]["title"] | "";
|
||||
const char* payload_subtitle =
|
||||
document["subtitle"] | document["content"]["subtitle"] | document["visual"]["subtitle"] | "";
|
||||
@@ -5545,7 +5691,9 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
transition_ms = 220U;
|
||||
subtitle_scroll_mode = SceneScrollMode::kNone;
|
||||
win_etape_fireworks = false;
|
||||
resetSceneTimeline();
|
||||
if (static_state_changed) {
|
||||
resetSceneTimeline();
|
||||
}
|
||||
}
|
||||
if (win_etape_bravo_mode) {
|
||||
title = "BRAVO!";
|
||||
@@ -5556,7 +5704,9 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
subtitle = "VERIFICATION EN COURS";
|
||||
}
|
||||
if (is_locked_scene) {
|
||||
resetSceneTimeline();
|
||||
if (static_state_changed) {
|
||||
resetSceneTimeline();
|
||||
}
|
||||
}
|
||||
if (win_etape_bravo_mode && timeline_keyframe_count_ > 1U) {
|
||||
timeline_keyframe_count_ = 1U;
|
||||
@@ -5564,7 +5714,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
timeline_loop_ = true;
|
||||
timeline_effect_index_ = -1;
|
||||
}
|
||||
if (direct_fx_scene) {
|
||||
if (static_state_changed && direct_fx_scene) {
|
||||
direct_fx_scene_active_ = fx_engine_.config().lgfx_backend;
|
||||
if (direct_fx_scene_active_) {
|
||||
direct_fx_scene_preset_ =
|
||||
@@ -5581,113 +5731,115 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
fx_engine_.setScrollText(nullptr);
|
||||
}
|
||||
}
|
||||
} else if (!win_etape_intro_scene) {
|
||||
} else if (static_state_changed && !win_etape_intro_scene) {
|
||||
direct_fx_scene_active_ = false;
|
||||
if (!intro_active_) {
|
||||
fx_engine_.setEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopSceneAnimations();
|
||||
demo_particle_count_ = demo_particle_count;
|
||||
demo_strobe_level_ = demo_strobe_level;
|
||||
if (demo_mode == "cinematic") {
|
||||
if (demo_particle_count_ > 2U) {
|
||||
demo_particle_count_ = 2U;
|
||||
if (static_state_changed) {
|
||||
stopSceneAnimations();
|
||||
demo_particle_count_ = demo_particle_count;
|
||||
demo_strobe_level_ = demo_strobe_level;
|
||||
if (demo_mode == "cinematic") {
|
||||
if (demo_particle_count_ > 2U) {
|
||||
demo_particle_count_ = 2U;
|
||||
}
|
||||
if (transition_ms < 300U) {
|
||||
transition_ms = 300U;
|
||||
}
|
||||
} else if (demo_mode == "arcade") {
|
||||
if (transition_ms < 140U) {
|
||||
transition_ms = 140U;
|
||||
}
|
||||
if (effect_speed_ms < 240U && effect_speed_ms != 0U) {
|
||||
effect_speed_ms = 240U;
|
||||
}
|
||||
} else if (demo_mode == "fireworks") {
|
||||
if (demo_particle_count_ < 3U) {
|
||||
demo_particle_count_ = 3U;
|
||||
}
|
||||
if (demo_strobe_level_ < 82U) {
|
||||
demo_strobe_level_ = 82U;
|
||||
}
|
||||
if (effect_speed_ms == 0U || effect_speed_ms > 460U) {
|
||||
effect_speed_ms = 300U;
|
||||
}
|
||||
if (transition_ms < 200U) {
|
||||
transition_ms = 200U;
|
||||
}
|
||||
}
|
||||
if (transition_ms < 300U) {
|
||||
transition_ms = 300U;
|
||||
current_effect_ = effect;
|
||||
effect_speed_ms_ = effect_speed_ms;
|
||||
if (effect_speed_ms_ == 0U && demo_mode == "arcade") {
|
||||
effect_speed_ms_ = 240U;
|
||||
}
|
||||
} else if (demo_mode == "arcade") {
|
||||
if (transition_ms < 140U) {
|
||||
transition_ms = 140U;
|
||||
win_etape_fireworks_mode_ = win_etape_fireworks;
|
||||
applyThemeColors(bg_rgb, accent_rgb, text_rgb);
|
||||
const String title_ui = asciiFallbackForUiText(title.c_str());
|
||||
const String subtitle_ui = asciiFallbackForUiText(subtitle.c_str());
|
||||
lv_label_set_text(scene_title_label_, title_ui.c_str());
|
||||
lv_label_set_text(scene_subtitle_label_, subtitle_ui.c_str());
|
||||
const char* symbol_glyph = mapSymbolToken(symbol.c_str());
|
||||
lv_label_set_text(scene_symbol_label_, (symbol_glyph != nullptr) ? symbol_glyph : LV_SYMBOL_PLAY);
|
||||
if (win_etape_bravo_mode) {
|
||||
show_title = true;
|
||||
}
|
||||
if (effect_speed_ms < 240U && effect_speed_ms != 0U) {
|
||||
effect_speed_ms = 240U;
|
||||
if (show_title) {
|
||||
lv_obj_clear_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
} else if (demo_mode == "fireworks") {
|
||||
if (demo_particle_count_ < 3U) {
|
||||
demo_particle_count_ = 3U;
|
||||
if (show_symbol) {
|
||||
lv_obj_clear_flag(scene_symbol_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_symbol_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
if (demo_strobe_level_ < 82U) {
|
||||
demo_strobe_level_ = 82U;
|
||||
if (show_subtitle && subtitle.length() > 0U) {
|
||||
lv_obj_clear_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
if (effect_speed_ms == 0U || effect_speed_ms > 460U) {
|
||||
effect_speed_ms = 300U;
|
||||
applyTextLayout(title_align, subtitle_align);
|
||||
if (scene_title_label_ != nullptr && !lv_obj_has_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN)) {
|
||||
lv_obj_move_foreground(scene_title_label_);
|
||||
lv_obj_set_style_opa(scene_title_label_, LV_OPA_COVER, LV_PART_MAIN);
|
||||
lv_obj_set_style_transform_angle(scene_title_label_, 0, LV_PART_MAIN);
|
||||
}
|
||||
if (transition_ms < 200U) {
|
||||
transition_ms = 200U;
|
||||
if (scene_subtitle_label_ != nullptr && !lv_obj_has_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN)) {
|
||||
lv_obj_move_foreground(scene_subtitle_label_);
|
||||
lv_obj_set_style_opa(scene_subtitle_label_, LV_OPA_COVER, LV_PART_MAIN);
|
||||
lv_obj_set_style_transform_angle(scene_subtitle_label_, 0, LV_PART_MAIN);
|
||||
}
|
||||
applySceneFraming(frame_dx, frame_dy, frame_scale_pct, frame_split_layout);
|
||||
applySubtitleScroll(subtitle_scroll_mode, subtitle_scroll_speed_ms, subtitle_scroll_pause_ms, subtitle_scroll_loop);
|
||||
for (lv_obj_t* particle : scene_particles_) {
|
||||
lv_obj_set_style_bg_color(particle, lv_color_hex(text_rgb), LV_PART_MAIN);
|
||||
}
|
||||
|
||||
if (timeline_keyframe_count_ > 1U && timeline_duration_ms_ > 0U) {
|
||||
timeline_effect_index_ = -1;
|
||||
onTimelineTick(0U);
|
||||
|
||||
lv_anim_t timeline_anim;
|
||||
lv_anim_init(&timeline_anim);
|
||||
lv_anim_set_var(&timeline_anim, scene_root_);
|
||||
lv_anim_set_exec_cb(&timeline_anim, animTimelineTickCb);
|
||||
lv_anim_set_values(&timeline_anim, 0, timeline_duration_ms_);
|
||||
lv_anim_set_time(&timeline_anim, timeline_duration_ms_);
|
||||
lv_anim_set_repeat_count(&timeline_anim, timeline_loop_ ? LV_ANIM_REPEAT_INFINITE : 0U);
|
||||
lv_anim_set_playback_time(&timeline_anim, 0);
|
||||
lv_anim_start(&timeline_anim);
|
||||
} else {
|
||||
applySceneEffect(effect);
|
||||
}
|
||||
if (scene_changed && has_previous_scene) {
|
||||
applySceneTransition(transition, transition_ms);
|
||||
}
|
||||
}
|
||||
current_effect_ = effect;
|
||||
effect_speed_ms_ = effect_speed_ms;
|
||||
if (effect_speed_ms_ == 0U && demo_mode == "arcade") {
|
||||
effect_speed_ms_ = 240U;
|
||||
}
|
||||
win_etape_fireworks_mode_ = win_etape_fireworks;
|
||||
applyThemeColors(bg_rgb, accent_rgb, text_rgb);
|
||||
const String title_ui = asciiFallbackForUiText(title.c_str());
|
||||
const String subtitle_ui = asciiFallbackForUiText(subtitle.c_str());
|
||||
lv_label_set_text(scene_title_label_, title_ui.c_str());
|
||||
lv_label_set_text(scene_subtitle_label_, subtitle_ui.c_str());
|
||||
const char* symbol_glyph = mapSymbolToken(symbol.c_str());
|
||||
lv_label_set_text(scene_symbol_label_, (symbol_glyph != nullptr) ? symbol_glyph : LV_SYMBOL_PLAY);
|
||||
if (win_etape_bravo_mode) {
|
||||
show_title = true;
|
||||
}
|
||||
if (show_title) {
|
||||
lv_obj_clear_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
if (show_symbol) {
|
||||
lv_obj_clear_flag(scene_symbol_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_symbol_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
if (show_subtitle && subtitle.length() > 0U) {
|
||||
lv_obj_clear_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
applyTextLayout(title_align, subtitle_align);
|
||||
if (scene_title_label_ != nullptr && !lv_obj_has_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN)) {
|
||||
lv_obj_move_foreground(scene_title_label_);
|
||||
lv_obj_set_style_opa(scene_title_label_, LV_OPA_COVER, LV_PART_MAIN);
|
||||
lv_obj_set_style_transform_angle(scene_title_label_, 0, LV_PART_MAIN);
|
||||
}
|
||||
if (scene_subtitle_label_ != nullptr && !lv_obj_has_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN)) {
|
||||
lv_obj_move_foreground(scene_subtitle_label_);
|
||||
lv_obj_set_style_opa(scene_subtitle_label_, LV_OPA_COVER, LV_PART_MAIN);
|
||||
lv_obj_set_style_transform_angle(scene_subtitle_label_, 0, LV_PART_MAIN);
|
||||
}
|
||||
applySceneFraming(frame_dx, frame_dy, frame_scale_pct, frame_split_layout);
|
||||
applySubtitleScroll(subtitle_scroll_mode, subtitle_scroll_speed_ms, subtitle_scroll_pause_ms, subtitle_scroll_loop);
|
||||
for (lv_obj_t* particle : scene_particles_) {
|
||||
lv_obj_set_style_bg_color(particle, lv_color_hex(text_rgb), LV_PART_MAIN);
|
||||
}
|
||||
|
||||
lv_obj_set_style_bg_opa(scene_core_, audio_playing ? LV_OPA_COVER : LV_OPA_80, LV_PART_MAIN);
|
||||
if (timeline_keyframe_count_ > 1U && timeline_duration_ms_ > 0U) {
|
||||
timeline_effect_index_ = -1;
|
||||
onTimelineTick(0U);
|
||||
|
||||
lv_anim_t timeline_anim;
|
||||
lv_anim_init(&timeline_anim);
|
||||
lv_anim_set_var(&timeline_anim, scene_root_);
|
||||
lv_anim_set_exec_cb(&timeline_anim, animTimelineTickCb);
|
||||
lv_anim_set_values(&timeline_anim, 0, timeline_duration_ms_);
|
||||
lv_anim_set_time(&timeline_anim, timeline_duration_ms_);
|
||||
lv_anim_set_repeat_count(&timeline_anim, timeline_loop_ ? LV_ANIM_REPEAT_INFINITE : 0U);
|
||||
lv_anim_set_playback_time(&timeline_anim, 0);
|
||||
lv_anim_start(&timeline_anim);
|
||||
} else {
|
||||
applySceneEffect(effect);
|
||||
}
|
||||
if (scene_changed && has_previous_scene) {
|
||||
applySceneTransition(transition, transition_ms);
|
||||
}
|
||||
if (is_locked_scene && scene_title_label_ != nullptr) {
|
||||
if (static_state_changed && is_locked_scene && scene_title_label_ != nullptr) {
|
||||
lv_obj_clear_flag(scene_title_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_move_foreground(scene_title_label_);
|
||||
const bool title_bounce_inverted =
|
||||
@@ -5714,7 +5866,7 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
lv_obj_set_style_transform_angle(scene_title_label_, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_text_color(scene_title_label_, lv_color_hex(0xFFFFFFUL), LV_PART_MAIN);
|
||||
}
|
||||
if (is_locked_scene && scene_subtitle_label_ != nullptr) {
|
||||
if (static_state_changed && is_locked_scene && scene_subtitle_label_ != nullptr) {
|
||||
lv_obj_clear_flag(scene_subtitle_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_move_foreground(scene_subtitle_label_);
|
||||
lv_anim_t subtitle_lock_jitter_x;
|
||||
@@ -5747,26 +5899,39 @@ void UiManager::renderScene(const ScenarioDef* scenario,
|
||||
lv_obj_set_style_transform_angle(scene_subtitle_label_, 0, LV_PART_MAIN);
|
||||
lv_obj_set_style_text_color(scene_subtitle_label_, lv_color_hex(0xFFFFFFUL), LV_PART_MAIN);
|
||||
}
|
||||
if (is_locked_scene && scene_symbol_label_ != nullptr) {
|
||||
if (static_state_changed && is_locked_scene && scene_symbol_label_ != nullptr) {
|
||||
lv_obj_add_flag(scene_symbol_label_, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_label_set_text(scene_symbol_label_, "");
|
||||
}
|
||||
|
||||
if (static_state_changed) {
|
||||
if (qr_scene) {
|
||||
qr_scene_controller_.onSceneEnter(&qr_scan_, scene_subtitle_label_);
|
||||
} else {
|
||||
qr_scene_controller_.onSceneExit(&qr_scan_);
|
||||
}
|
||||
}
|
||||
|
||||
applySceneDynamicState(subtitle, show_subtitle, audio_playing, text_rgb);
|
||||
std::strncpy(last_scene_id_, scene_id, sizeof(last_scene_id_) - 1U);
|
||||
last_scene_id_[sizeof(last_scene_id_) - 1U] = '\0';
|
||||
updatePageLine();
|
||||
UI_LOGI("scene=%s effect=%u speed=%u title=%u symbol=%u scenario=%s audio=%u timeline=%u transition=%u:%u",
|
||||
scene_id,
|
||||
static_cast<unsigned int>(effect),
|
||||
static_cast<unsigned int>(effect_speed_ms_),
|
||||
show_title ? 1U : 0U,
|
||||
show_symbol ? 1U : 0U,
|
||||
scenario_id,
|
||||
audio_playing ? 1U : 0U,
|
||||
static_cast<unsigned int>(timeline_keyframe_count_),
|
||||
static_cast<unsigned int>(transition),
|
||||
static_cast<unsigned int>(transition_ms));
|
||||
if (win_etape_intro_scene) {
|
||||
startIntroIfNeeded(scene_changed);
|
||||
last_payload_crc_ = payload_crc;
|
||||
if (static_state_changed) {
|
||||
updatePageLine();
|
||||
UI_LOGI("scene=%s effect=%u speed=%u title=%u symbol=%u scenario=%s audio=%u timeline=%u transition=%u:%u",
|
||||
scene_id,
|
||||
static_cast<unsigned int>(effect),
|
||||
static_cast<unsigned int>(effect_speed_ms_),
|
||||
show_title ? 1U : 0U,
|
||||
show_symbol ? 1U : 0U,
|
||||
scenario_id,
|
||||
audio_playing ? 1U : 0U,
|
||||
static_cast<unsigned int>(timeline_keyframe_count_),
|
||||
static_cast<unsigned int>(transition),
|
||||
static_cast<unsigned int>(transition_ms));
|
||||
if (win_etape_intro_scene) {
|
||||
startIntroIfNeeded(static_state_changed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6005,17 +6170,15 @@ void UiManager::cleanupSceneTransitionAssets(const char* from_scene_id, const ch
|
||||
const bool from_direct_fx = isDirectFxSceneId(from_scene_id);
|
||||
const bool to_direct_fx = isDirectFxSceneId(to_scene_id);
|
||||
UI_LOGI("cleanup scene assets transition %s -> %s", from_scene_id, to_scene_id);
|
||||
if (from_win_etape || to_win_etape || from_direct_fx || to_direct_fx) {
|
||||
if (intro_active_) {
|
||||
stopIntroAndCleanup();
|
||||
}
|
||||
direct_fx_scene_active_ = false;
|
||||
fx_engine_.setEnabled(false);
|
||||
fx_engine_.reset();
|
||||
const bool touches_fx_owner = from_win_etape || to_win_etape || from_direct_fx || to_direct_fx;
|
||||
direct_fx_scene_active_ = false;
|
||||
if (!touches_fx_owner) {
|
||||
return;
|
||||
}
|
||||
if (intro_active_) {
|
||||
stopIntroAndCleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
direct_fx_scene_active_ = false;
|
||||
fx_engine_.setEnabled(false);
|
||||
fx_engine_.reset();
|
||||
}
|
||||
|
||||
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},
|
||||
@@ -25,11 +32,15 @@ constexpr ScreenSceneDef kScenes[] = {
|
||||
{"SCENE_FIREWORKS", 1U, 2U},
|
||||
{"SCENE_WIN_ETAPE", 1U, 2U},
|
||||
{"SCENE_MP3_PLAYER", 1U, 2U},
|
||||
{"SCENE_MEDIA_MANAGER", 1U, 2U},
|
||||
{"SCENE_PHOTO_MANAGER", 1U, 2U},
|
||||
};
|
||||
|
||||
// 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] = {
|
||||
@@ -177,31 +188,59 @@ SCENE_PROFILES: dict[str, dict[str, Any]] = {
|
||||
],
|
||||
},
|
||||
"SCENE_CAMERA_SCAN": {
|
||||
"title": "CAMERA SCAN",
|
||||
"subtitle": "Capture des indices visuels",
|
||||
"symbol": "SCAN",
|
||||
"title": "ZACUS QR VALIDATION",
|
||||
"subtitle": "Scanne le 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"},
|
||||
},
|
||||
],
|
||||
},
|
||||
"SCENE_MEDIA_MANAGER": {
|
||||
"title": "MEDIA MANAGER",
|
||||
"subtitle": "PHOTO / MP3 / STORY",
|
||||
"symbol": "READY",
|
||||
"effect": "radar",
|
||||
"effect_speed_ms": 840,
|
||||
"theme": {"bg": "#041A24", "accent": "#5CE6FF", "text": "#E9FBFF"},
|
||||
"transition": {"effect": "slide_left", "duration_ms": 230},
|
||||
"effect_speed_ms": 620,
|
||||
"theme": {"bg": "#081A34", "accent": "#8BC4FF", "text": "#EAF6FF"},
|
||||
"transition": {"effect": "fade", "duration_ms": 220},
|
||||
"timeline": [
|
||||
{
|
||||
"at_ms": 0,
|
||||
"effect": "radar",
|
||||
"speed_ms": 840,
|
||||
"theme": {"bg": "#041A24", "accent": "#5CE6FF", "text": "#E9FBFF"},
|
||||
"speed_ms": 620,
|
||||
"theme": {"bg": "#081A34", "accent": "#8BC4FF", "text": "#EAF6FF"},
|
||||
},
|
||||
{
|
||||
"at_ms": 1200,
|
||||
"effect": "wave",
|
||||
"speed_ms": 620,
|
||||
"theme": {"bg": "#072838", "accent": "#8AF1FF", "text": "#F5FEFF"},
|
||||
"effect": "pulse",
|
||||
"speed_ms": 520,
|
||||
"theme": {"bg": "#0C2448", "accent": "#A6D4FF", "text": "#F5FAFF"},
|
||||
},
|
||||
],
|
||||
},
|
||||
"SCENE_PHOTO_MANAGER": {
|
||||
"title": "PHOTO MANAGER",
|
||||
"subtitle": "Capture JPEG",
|
||||
"symbol": "SCAN",
|
||||
"effect": "none",
|
||||
"effect_speed_ms": 0,
|
||||
"theme": {"bg": "#0B1A2E", "accent": "#86CCFF", "text": "#EEF6FF"},
|
||||
"transition": {"effect": "fade", "duration_ms": 200},
|
||||
"timeline": [
|
||||
{
|
||||
"at_ms": 2200,
|
||||
"effect": "radar",
|
||||
"speed_ms": 760,
|
||||
"theme": {"bg": "#041A24", "accent": "#5CE6FF", "text": "#E9FBFF"},
|
||||
"at_ms": 0,
|
||||
"effect": "none",
|
||||
"speed_ms": 0,
|
||||
"theme": {"bg": "#0B1A2E", "accent": "#86CCFF", "text": "#EEF6FF"},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -355,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -642,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)
|
||||
@@ -757,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()
|
||||
|
||||
@@ -1353,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)
|
||||
|
||||
@@ -57,6 +57,17 @@ board_build.filesystem = littlefs
|
||||
build_src_filter =
|
||||
-<*>
|
||||
+<../../ui_freenove_allinone/src/>
|
||||
-<../../ui_freenove_allinone/src/audio_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/button_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/camera_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/hardware_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/main.cpp>
|
||||
-<../../ui_freenove_allinone/src/media_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/network_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/scenario_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/storage_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/touch_manager.cpp>
|
||||
-<../../ui_freenove_allinone/src/ui_manager.cpp>
|
||||
+<../../../libs/story/src/core/scenario_def.cpp>
|
||||
+<../../../libs/story/src/generated/scenarios_gen.cpp>
|
||||
+<../../../libs/story/src/resources/screen_scene_registry.cpp>
|
||||
@@ -76,6 +87,7 @@ lib_deps =
|
||||
esphome/ESP32-audioI2S@^2.3.0
|
||||
bblanchon/ArduinoJson@^6.21.5
|
||||
adafruit/Adafruit NeoPixel@^1.12.3
|
||||
https://github.com/alvarowolfx/ESP32QRCodeReader.git
|
||||
build_flags =
|
||||
-I$PROJECT_DIR/protocol
|
||||
-I$PROJECT_DIR/hardware/firmware/ui_freenove_allinone/include
|
||||
|
||||
Executable
+140
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate Zacus QR payloads with CRC16/CCITT-FALSE and optional PNG export.
|
||||
|
||||
Format:
|
||||
<DATA><sep><CRC16_HEX>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def crc16_ccitt_false(data: bytes) -> int:
|
||||
crc = 0xFFFF
|
||||
for b in data:
|
||||
crc ^= b << 8
|
||||
for _ in range(8):
|
||||
if crc & 0x8000:
|
||||
crc = ((crc << 1) & 0xFFFF) ^ 0x1021
|
||||
else:
|
||||
crc = (crc << 1) & 0xFFFF
|
||||
return crc & 0xFFFF
|
||||
|
||||
|
||||
def ascii_upper(value: str) -> str:
|
||||
chars = []
|
||||
for ch in value:
|
||||
code = ord(ch)
|
||||
if 97 <= code <= 122:
|
||||
chars.append(chr(code - 32))
|
||||
else:
|
||||
chars.append(ch)
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate QR payload DATA+CRC16 for SCENE_CAMERA_SCAN validation"
|
||||
)
|
||||
parser.add_argument("data", help="DATA part without CRC suffix")
|
||||
parser.add_argument(
|
||||
"--sep",
|
||||
default="*",
|
||||
help="Separator between DATA and CRC (first character used, default: *)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ci",
|
||||
action="store_true",
|
||||
help="Case-insensitive CRC mode (CRC computed on ASCII uppercased DATA)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
default="data",
|
||||
choices=["data", "suffix"],
|
||||
help="CRC scope: full DATA or suffix after --prefix",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prefix",
|
||||
default="",
|
||||
help="Prefix used when --scope suffix",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--png",
|
||||
default="",
|
||||
help="Optional output PNG path (requires qrcode[pil])",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def export_png(payload: str, output_path: str) -> None:
|
||||
try:
|
||||
import qrcode
|
||||
from qrcode.constants import ERROR_CORRECT_Q
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"qrcode dependency missing; install with: pip install 'qrcode[pil]'"
|
||||
) from exc
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=ERROR_CORRECT_Q,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(payload)
|
||||
qr.make(fit=True)
|
||||
image = qr.make_image(fill_color="black", back_color="white")
|
||||
image.save(output_path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
|
||||
sep = args.sep[0] if args.sep else ""
|
||||
if not sep:
|
||||
print("ERROR: --sep cannot be empty", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
data = args.data.strip()
|
||||
if not data:
|
||||
print("ERROR: data is empty", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
crc_view = data
|
||||
if args.scope == "suffix":
|
||||
if not args.prefix:
|
||||
print("ERROR: --prefix is required when --scope suffix", file=sys.stderr)
|
||||
return 2
|
||||
if args.ci:
|
||||
if not ascii_upper(data).startswith(ascii_upper(args.prefix)):
|
||||
print("ERROR: data does not start with prefix (ci)", file=sys.stderr)
|
||||
return 2
|
||||
else:
|
||||
if not data.startswith(args.prefix):
|
||||
print("ERROR: data does not start with prefix", file=sys.stderr)
|
||||
return 2
|
||||
crc_view = data[len(args.prefix) :]
|
||||
if not crc_view:
|
||||
print("ERROR: empty CRC scope after prefix", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
crc_input = ascii_upper(crc_view) if args.ci else crc_view
|
||||
crc = crc16_ccitt_false(crc_input.encode("utf-8"))
|
||||
payload = f"{data}{sep}{crc:04X}"
|
||||
print(payload)
|
||||
|
||||
if args.png:
|
||||
try:
|
||||
export_png(payload, args.png)
|
||||
except RuntimeError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"PNG saved: {args.png}", file=sys.stderr)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -405,25 +405,44 @@ def run_scenario_legacy_sc(
|
||||
log_line(log_fp, f"WARN scenario {scenario} load failed: {info}")
|
||||
return False
|
||||
|
||||
for cmd in ("UNLOCK", "NEXT", "NEXT", "NEXT"):
|
||||
send_cmd(ser, cmd)
|
||||
ok, info, critical = scan_for_patterns(
|
||||
ser,
|
||||
2.0,
|
||||
log_fp,
|
||||
ring,
|
||||
[
|
||||
re.compile(rf"ACK {cmd}$", re.IGNORECASE),
|
||||
re.compile(r"step=STEP_(DONE|WIN)", re.IGNORECASE),
|
||||
re.compile(r"STEP_(DONE|WIN)", re.IGNORECASE),
|
||||
],
|
||||
require_ui_link,
|
||||
)
|
||||
if not ok and critical:
|
||||
log_line(log_fp, f"CRITICAL {info}")
|
||||
# Legacy Freenove story flow expects SC_EVENT serial BTN_NEXT transitions.
|
||||
# Keep NEXT as fallback for backward compatibility.
|
||||
drive_steps = [
|
||||
("UNLOCK", "UNLOCK"),
|
||||
("SC_EVENT serial BTN_NEXT", "NEXT"),
|
||||
("SC_EVENT serial BTN_NEXT", "NEXT"),
|
||||
("SC_EVENT serial BTN_NEXT", "NEXT"),
|
||||
]
|
||||
for primary_cmd, fallback_cmd in drive_steps:
|
||||
progressed = False
|
||||
for cmd in (primary_cmd, fallback_cmd):
|
||||
send_cmd(ser, cmd)
|
||||
ok, info, critical = scan_for_patterns(
|
||||
ser,
|
||||
2.4,
|
||||
log_fp,
|
||||
ring,
|
||||
[
|
||||
re.compile(r"ACK\s+SC_EVENT\s+ok=1", re.IGNORECASE),
|
||||
re.compile(r"ACK\s+UNLOCK(\s+ok=1)?", re.IGNORECASE),
|
||||
re.compile(r"ACK\s+NEXT(\s+ok=1)?", re.IGNORECASE),
|
||||
re.compile(r"\[SCENARIO\]\s+transition", re.IGNORECASE),
|
||||
re.compile(r"step=STEP_(DONE|WIN)", re.IGNORECASE),
|
||||
re.compile(r"STEP_(DONE|WIN)", re.IGNORECASE),
|
||||
],
|
||||
require_ui_link,
|
||||
)
|
||||
if not ok and critical:
|
||||
log_line(log_fp, f"CRITICAL {info}")
|
||||
return False
|
||||
if line_has_done(info):
|
||||
return True
|
||||
if ok:
|
||||
progressed = True
|
||||
break
|
||||
if not progressed:
|
||||
log_line(log_fp, f"WARN scenario {scenario} no progression after `{primary_cmd}`")
|
||||
return False
|
||||
if line_has_done(info):
|
||||
return True
|
||||
|
||||
send_cmd(ser, "STATUS")
|
||||
ok, info, critical = scan_for_patterns(
|
||||
|
||||
@@ -225,6 +225,17 @@ def resolve_ports(args):
|
||||
details.update(manual_details)
|
||||
notes = classify_notes + manual_notes
|
||||
|
||||
# Single-board Freenove setups often expose one usbmodem endpoint that must be treated as ESP32.
|
||||
if "esp32" in required and not found.get("esp32"):
|
||||
esp8266_port = found.get("esp8266", "")
|
||||
if esp8266_port and len(snapshot) == 1:
|
||||
found["esp32"] = esp8266_port
|
||||
details["esp32"] = {
|
||||
"location": details.get("esp8266", {}).get("location", ""),
|
||||
"reason": "single-port-remap:esp8266->esp32",
|
||||
}
|
||||
notes.append(f"single-port remap applied for esp32: {esp8266_port}")
|
||||
|
||||
best_found = found
|
||||
best_details = details
|
||||
|
||||
|
||||
@@ -26,5 +26,18 @@ Bundle de travail conversationnel/IA conservé en dehors de la source canon game
|
||||
## Notes
|
||||
- Ce bundle n'est pas promu automatiquement dans `game/scenarios/*.yaml`.
|
||||
- Toute promotion doit passer par une revue fonctionnelle dédiée.
|
||||
Files:
|
||||
- scenario_promptable_template.yaml : your editable template (prompt_input)
|
||||
- scenario_canonical.yaml : canonical YAML (copy into docs/... target)
|
||||
- scenario_runtime.json : runtime JSON (copy into data/story/scenarios/DEFAULT.json)
|
||||
- fsm_mermaid.md : FSM diagram
|
||||
- CODEX_PROMPT.md : ready-to-paste Codex prompt
|
||||
- zacus_v2.yaml : narrative scenario aligned with runtime + gameplay reality
|
||||
- CONVERSATION_SUMMARY.md : what changed / key decisions
|
||||
|
||||
Typical flow:
|
||||
1) Update prompt_input in scenario_promptable_template.yaml if needed.
|
||||
2) Use canonical/runtime files to update repo targets.
|
||||
3) Use zacus_v2.yaml as the printed narrative / MJ reference for the real game.
|
||||
|
||||
Generated: 2026-02-25
|
||||
|
||||
Reference in New Issue
Block a user