diff --git a/idf_zacus/components/display_ui/CMakeLists.txt b/idf_zacus/components/display_ui/CMakeLists.txt new file mode 100644 index 0000000..d078e49 --- /dev/null +++ b/idf_zacus/components/display_ui/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS "display_ui.cpp" + INCLUDE_DIRS "include" + PRIV_REQUIRES driver local_puzzles +) + +# LovyanGFX requires C++17. +target_compile_options(${COMPONENT_LIB} PRIVATE -std=gnu++17) diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp new file mode 100644 index 0000000..800d753 --- /dev/null +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -0,0 +1,291 @@ +// display_ui.cpp — ST7796 status screen via LovyanGFX, Phase 1. +// +// Panel / bus configuration faithfully replicates the original Freenove +// Arduino firmware (ui_freenove_allinone/src/drivers/display/display_hal_lgfx.cpp +// FreenoveLgfxDevice, lines ~132-208): +// +// - Panel: Panel_ST7796 +// - Bus: Bus_SPI, SPI2_HOST (FSPI; FREENOVE_LCD_USE_HSPI == 0) +// - Freq: 80 MHz write / 20 MHz read +// - spi_mode 0, spi_3wire false, use_lock false, dma_channel 0 (CPU mode) +// - Pins: SCK=47, MOSI=21, MISO=-1, DC=45, CS=-1, RST=20, BL=2 +// - Panel: memory/panel 320×480, offset_x/y 0, offset_rotation 0, +// dummy_read_pixel 8, dummy_read_bits 1, readable false (no MISO), +// invert true (TFT_INVERSION_ON), rgb_order true (not BGR), +// dlen_16bit false, bus_shared true +// - Rotation: 1 (landscape) +// +// BACKLIGHT (Phase 1): +// The reference firmware uses Light_PWM (LEDC channel 7, 44100 Hz) on +// GPIO 2. Phase 1 uses plain gpio_set_level(TFT_PIN_BL, 1) instead, +// because qr_puzzle already claims LEDC_TIMER_0 / LEDC_CHANNEL_0 for the +// camera XCLK — calling Light_PWM::init() on any channel would attempt to +// reconfigure the LEDC timer and may conflict. Phase 2 will switch to +// Light_PWM on a free timer/channel pair once the XCLK LEDC allocation is +// moved to the esp32-camera driver's own management. +// +// Thread model: +// All LovyanGFX rendering runs exclusively on the display_task (prio 3, +// stack 4096). display_ui_set_status() copies the status under a mutex +// and sets a dirty flag; the task picks it up within 250 ms. + +#include "display_ui.h" +#include "board_pins_mediakit.h" + +#include + +#include "driver/gpio.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include "freertos/task.h" + +#define LGFX_USE_V1 +#include + +static const char *TAG = "display_ui"; + +// ─── LovyanGFX device (faithful port of FreenoveLgfxDevice) ────────────────── + +class ZacusDisplay final : public lgfx::LGFX_Device { + public: + ZacusDisplay() { + // ── SPI bus ────────────────────────────────────────────────────────── + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI2_HOST; // FSPI (FREENOVE_LCD_USE_HSPI == 0) + cfg.spi_mode = 0; + cfg.freq_write = 80000000; // 80 MHz + cfg.freq_read = 20000000; // 20 MHz + cfg.spi_3wire = false; + cfg.use_lock = false; + // CPU mode — DMA out-link init can be unavailable on this + // board/runtime mix (same note as original firmware, line ~145). + cfg.dma_channel = 0; + cfg.pin_sclk = TFT_PIN_SCK; + cfg.pin_mosi = TFT_PIN_MOSI; + cfg.pin_miso = TFT_PIN_MISO; // -1 (not wired) + cfg.pin_dc = TFT_PIN_DC; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + // ── Panel ───────────────────────────────────────────────────────────── + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = -1; // CS not wired + cfg.pin_rst = TFT_PIN_RST; + cfg.pin_busy = -1; + cfg.memory_width = 320; + cfg.memory_height = 480; + cfg.panel_width = 320; + cfg.panel_height = 480; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 0; + cfg.dummy_read_pixel = 8; + cfg.dummy_read_bits = 1; + cfg.readable = false; // MISO == -1 + cfg.invert = true; // TFT_INVERSION_ON in original + cfg.rgb_order = true; // not BGR + cfg.dlen_16bit = false; + cfg.bus_shared = true; + _panel_instance.config(cfg); + } + + // Backlight handled externally (plain GPIO, see file header). + + setPanel(&_panel_instance); + } + + private: + lgfx::Bus_SPI _bus_instance; + lgfx::Panel_ST7796 _panel_instance; +}; + +static ZacusDisplay s_lcd; + +// ─── Status state ───────────────────────────────────────────────────────────── + +static display_status_t s_status; +static SemaphoreHandle_t s_mutex = NULL; +static volatile bool s_dirty = false; +static bool s_running = false; + +// ─── Palette (Amiga Workbench-ish) ──────────────────────────────────────────── +// Keep tasteful — this is a status screen, not a demo. +static const uint32_t COL_BG = 0x0055AA; // Workbench blue +static const uint32_t COL_TITLEBAR = 0x0000AA; // darker blue +static const uint32_t COL_TITLE_TXT = 0xFFFFFF; // white +static const uint32_t COL_LABEL = 0xAAAAAA; // mid grey +static const uint32_t COL_VALUE = 0xFFFFFF; // white +static const uint32_t COL_CODE = 0xFF8800; // Workbench orange + +// ─── Rendering (called only from display_task) ──────────────────────────────── + +static void render_status(const display_status_t *s) { + const int W = s_lcd.width(); // 480 in rotation 1 + const int H = s_lcd.height(); // 320 in rotation 1 + + // Title bar + s_lcd.fillRect(0, 0, W, 28, COL_TITLEBAR); + s_lcd.setTextColor(COL_TITLE_TXT); + s_lcd.setFont(&fonts::Font4); + s_lcd.setCursor(8, 5); + s_lcd.print("ZACUS MASTER v1.0"); + + // Background for content area + s_lcd.fillRect(0, 28, W, H - 28, COL_BG); + + // Helper lambda: label + value pair on one line + int y = 36; + const int LINE_H = 28; + + auto draw_row = [&](const char *label, const char *value, uint32_t vcol) { + s_lcd.setFont(&fonts::Font2); + s_lcd.setTextColor(COL_LABEL); + s_lcd.setCursor(8, y); + s_lcd.print(label); + + s_lcd.setTextColor(vcol); + s_lcd.setCursor(160, y); + s_lcd.print(value); + + y += LINE_H; + }; + + // IP + draw_row("IP :", s[0].ip[0] ? s[0].ip : "(aucune)", COL_VALUE); + + // Wake detector + draw_row("Wake :", s[0].wake_active ? "oui" : "non", COL_VALUE); + + // Current step + draw_row("Etape :", s[0].step_id[0] ? s[0].step_id : "(aucune)", COL_VALUE); + + // Armed puzzle type + draw_row("Enigme :", s[0].armed[0] ? s[0].armed : "none", COL_VALUE); + + // Solved count + char solved_str[8]; + snprintf(solved_str, sizeof(solved_str), "%u", (unsigned) s[0].solved_count); + draw_row("Resolues :", solved_str, COL_VALUE); + + // Assembled code — big font, orange + s_lcd.setFont(&fonts::Font6); + s_lcd.setTextColor(COL_CODE); + s_lcd.setCursor(8, y + 4); + s_lcd.print("Code: "); + s_lcd.print(s[0].code[0] ? s[0].code : "---"); +} + +static void render_splash(void) { + const int W = s_lcd.width(); + const int H = s_lcd.height(); + + s_lcd.fillScreen(COL_BG); + + // Title bar + s_lcd.fillRect(0, 0, W, 36, COL_TITLEBAR); + s_lcd.setTextColor(COL_TITLE_TXT); + s_lcd.setFont(&fonts::Font4); + s_lcd.setCursor(8, 8); + s_lcd.print("ZACUS MASTER"); + + // Sub-title + s_lcd.setFont(&fonts::Font2); + s_lcd.setTextColor(COL_VALUE); + s_lcd.setCursor(8, 50); + s_lcd.print("Demarrage..."); + + // Version line + s_lcd.setTextColor(COL_LABEL); + s_lcd.setCursor(8, H - 20); + s_lcd.print("FNK0102H ESP-IDF 5.4 Phase 1"); +} + +// ─── Display task ───────────────────────────────────────────────────────────── + +static void display_task(void *arg) { + (void) arg; + display_status_t local_status; + memset(&local_status, 0, sizeof(local_status)); + + for (;;) { + vTaskDelay(pdMS_TO_TICKS(250)); + + if (!s_dirty) { + continue; + } + + // Snapshot under mutex + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + memcpy(&local_status, &s_status, sizeof(local_status)); + s_dirty = false; + xSemaphoreGive(s_mutex); + } else { + continue; + } + + render_status(&local_status); + } +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +extern "C" esp_err_t display_ui_init(void) { + // Backlight on (plain GPIO — Phase 1; see header for LEDC Phase 2 plan). + gpio_config_t bl_cfg = {}; + bl_cfg.pin_bit_mask = (1ULL << TFT_PIN_BL); + bl_cfg.mode = GPIO_MODE_OUTPUT; + bl_cfg.pull_up_en = GPIO_PULLUP_DISABLE; + bl_cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; + bl_cfg.intr_type = GPIO_INTR_DISABLE; + gpio_config(&bl_cfg); + gpio_set_level((gpio_num_t) TFT_PIN_BL, 1); + + // Initialise panel + if (!s_lcd.init()) { + ESP_LOGE(TAG, "LovyanGFX panel init failed"); + return ESP_FAIL; + } + + s_lcd.setRotation(1); // landscape, matches FREENOVE_LCD_ROTATION 1 + s_lcd.setBrightness(255); + + ESP_LOGI(TAG, "ST7796 panel up (%dx%d)", + (int) s_lcd.width(), (int) s_lcd.height()); + + render_splash(); + + // State + s_mutex = xSemaphoreCreateMutex(); + if (!s_mutex) { + ESP_LOGE(TAG, "failed to create mutex"); + return ESP_ERR_NO_MEM; + } + memset(&s_status, 0, sizeof(s_status)); + s_dirty = false; + s_running = true; + + // Spawn refresh task (all rendering lives here) + BaseType_t rc = xTaskCreate(display_task, "display_ui", 4096, NULL, 3, NULL); + if (rc != pdPASS) { + ESP_LOGE(TAG, "xTaskCreate failed"); + return ESP_ERR_NO_MEM; + } + + ESP_LOGI(TAG, "display_ui_init OK — splash visible"); + return ESP_OK; +} + +extern "C" void display_ui_set_status(const display_status_t *s) { + if (!s || !s_running || !s_mutex) { + return; + } + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + memcpy(&s_status, s, sizeof(s_status)); + s_dirty = true; + xSemaphoreGive(s_mutex); + } +} diff --git a/idf_zacus/components/display_ui/idf_component.yml b/idf_zacus/components/display_ui/idf_component.yml new file mode 100644 index 0000000..580f3f3 --- /dev/null +++ b/idf_zacus/components/display_ui/idf_component.yml @@ -0,0 +1,16 @@ +## display_ui managed-component manifest. +## +## LovyanGFX is not published on the Espressif component registry; it is +## distributed as a standard ESP-IDF component on GitHub. The IDF component +## manager supports git: sources — on the first `idf.py build` it clones the +## repo at the requested tag into managed_components/. +## +## Trust note: lovyan03/LovyanGFX is the SAME upstream the original Arduino +## firmware already depends on (platformio.ini line 34: lovyan03/LovyanGFX@1.2.7). +## Pinned below to the exact commit SHA of release tag 1.2.21 so the dependency +## is reproducible and tamper-evident (a moved tag cannot change what we build). + +dependencies: + lovyan03/LovyanGFX: + git: https://github.com/lovyan03/LovyanGFX.git + version: "4e689dba65135c2d91b180dc0a27a3cedebcfb5e" # tag 1.2.21 diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h new file mode 100644 index 0000000..5cc92b3 --- /dev/null +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include "esp_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char step_id[64]; // current scenario step ("" = none) + char armed[8]; // "qr" | "sound" | "none" + char code[16]; // assembled puzzle code + uint8_t solved_count; + char ip[16]; // STA IP ("" = none) + bool wake_active; // wake-word detector up +} display_status_t; + +/** + * @brief Initialise the ST7796 panel via LovyanGFX and show a splash screen. + * + * Must be called once from app_main after NVS init but before the idle loop. + * Non-fatal: a failure is logged and the rest of the firmware continues. + * The internal refresh task is spawned here; it polls for status changes + * every 250 ms. + * + * @return ESP_OK on success, or a driver error code. + */ +esp_err_t display_ui_init(void); + +/** + * @brief Copy a new status snapshot and request an async redraw. + * + * Thread-safe. The copy is protected by an internal mutex; the actual + * rendering happens on the display task (LovyanGFX is not thread-safe + * across tasks). + * + * @param s Pointer to the caller's status struct. May be NULL (no-op). + */ +void display_ui_set_status(const display_status_t *s); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 1f4db0b..66dfaf3 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -36,6 +36,8 @@ static const char *TAG = "game_endpoint"; static puzzle_state_t *s_pstate = NULL; // Last step id armed via POST /game/step. Empty string = none. static char s_current_step_id[64] = {0}; +// Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet). +static char s_current_armed[8] = {0}; // Whitelist mirrored in the 4xx error message so the operator can // recover without grepping the source. Keep aligned with @@ -724,6 +726,8 @@ static esp_err_t handle_step_post(httpd_req_t *req) { s_current_step_id[sizeof(s_current_step_id) - 1] = '\0'; if (binding.type == PB_NONE) { + strncpy(s_current_armed, "none", sizeof(s_current_armed) - 1); + s_current_armed[sizeof(s_current_armed) - 1] = '\0'; char buf[128]; snprintf(buf, sizeof(buf), "{\"step_id\":\"%s\",\"armed\":\"none\"}", step_id); @@ -750,6 +754,8 @@ static esp_err_t handle_step_post(httpd_req_t *req) { if (aerr != ESP_OK) { return send_error(req, "500 Internal Server Error", esp_err_to_name(aerr)); } + strncpy(s_current_armed, "qr", sizeof(s_current_armed) - 1); + s_current_armed[sizeof(s_current_armed) - 1] = '\0'; char buf[128]; snprintf(buf, sizeof(buf), "{\"step_id\":\"%s\",\"armed\":\"qr\"}", step_id); @@ -772,6 +778,8 @@ static esp_err_t handle_step_post(httpd_req_t *req) { if (aerr != ESP_OK) { return send_error(req, "500 Internal Server Error", esp_err_to_name(aerr)); } + strncpy(s_current_armed, "sound", sizeof(s_current_armed) - 1); + s_current_armed[sizeof(s_current_armed) - 1] = '\0'; char buf[128]; snprintf(buf, sizeof(buf), "{\"step_id\":\"%s\",\"armed\":\"sound\"}", step_id); @@ -836,6 +844,24 @@ void game_endpoint_set_puzzle_state(puzzle_state_t *state) { ESP_LOGI(TAG, "puzzle_state registered (GET /game/puzzle_state + POST /game/step active)"); } +// ─── public getter for display_ui (Phase 1) ────────────────────────────────── +// +// Copies the statics written by the step handler. Reading without a mutex is +// acceptable here — the game master arms one step at a time and the display is +// informational; a torn read produces at worst a one-cycle stale value. + +void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, + char *armed, size_t armed_cap) { + if (step_id && step_cap > 0) { + strncpy(step_id, s_current_step_id, step_cap - 1); + step_id[step_cap - 1] = '\0'; + } + if (armed && armed_cap > 0) { + strncpy(armed, s_current_armed, armed_cap - 1); + armed[armed_cap - 1] = '\0'; + } +} + // ─── public init ──────────────────────────────────────────────────────────── esp_err_t game_endpoint_init(httpd_handle_t server) { diff --git a/idf_zacus/components/game_endpoint/include/game_endpoint.h b/idf_zacus/components/game_endpoint/include/game_endpoint.h index adbdad2..620c38c 100644 --- a/idf_zacus/components/game_endpoint/include/game_endpoint.h +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -74,6 +74,23 @@ esp_err_t game_endpoint_init(httpd_handle_t server); */ void game_endpoint_set_puzzle_state(puzzle_state_t *state); +/** + * @brief Thread-safe snapshot of the current step id and armed puzzle type. + * + * Copies the last step armed via POST /game/step into caller-supplied buffers. + * Both buffers are NUL-terminated on return. Either pointer may be NULL if + * the caller does not need that field. + * + * Returns "" / "" when no step has been armed yet. + * + * @param step_id Output buffer for the current step id. + * @param step_cap Capacity of step_id buffer (recommend 64). + * @param armed Output buffer: "qr" | "sound" | "none" (or "" = nothing yet). + * @param armed_cap Capacity of armed buffer (recommend 8). + */ +void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, + char *armed, size_t armed_cap); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/components/local_puzzles/include/board_pins_mediakit.h b/idf_zacus/components/local_puzzles/include/board_pins_mediakit.h index 186f5c3..b0e4481 100644 --- a/idf_zacus/components/local_puzzles/include/board_pins_mediakit.h +++ b/idf_zacus/components/local_puzzles/include/board_pins_mediakit.h @@ -91,3 +91,35 @@ * * Note: GPIO -1 (PWDN, RESET) indicates "not wired" and is not allocated. */ + +/* ── TFT display (FNK0102H ST7796 320×480) ───────────────────────────────── + * + * Source: ui_freenove_allinone/include/ui_freenove_config.h (validated firmware) + * docs/FNK0102H_SOURCE_OF_TRUTH.md (canonical pin tables) + * + * SPI host: SPI2_HOST (FSPI) — FREENOVE_LCD_USE_HSPI == 0 in the reference + * firmware, so the default FSPI host is used (not HSPI/SPI3_HOST). + * Write frequency: 80 MHz. Read frequency: 20 MHz. + * CS pin: -1 (not wired — the panel is the only device on this SPI bus). + * + * Coexistence with camera/mic/speaker: + * GPIO 20 (TFT_PIN_RST): not present in the camera/mic/speaker tables above. + * GPIO 21 (TFT_PIN_MOSI): not present in the camera/mic/speaker tables above. + * GPIO 45 (TFT_PIN_DC): not present in the camera/mic/speaker tables above. + * GPIO 47 (TFT_PIN_SCK): not present in the camera/mic/speaker tables above. + * GPIO 2 (TFT_PIN_BL): not present in the camera/mic/speaker tables above. + * RESULT: ZERO collision with any already-mapped GPIO. + * + * BACKLIGHT / LEDC NOTE (Phase 1): + * The reference firmware uses Light_PWM (LEDC) on GPIO 2. In Phase 1 we + * drive the backlight as plain GPIO (full-on) because qr_puzzle already + * claims LEDC_TIMER_0 / LEDC_CHANNEL_0 for the camera XCLK. Phase 2 will + * switch to Light_PWM on a free LEDC channel/timer once the XCLK is moved + * to the esp32-camera driver's own LEDC allocation. + */ +#define TFT_PIN_SCK 47 /* SPI clock */ +#define TFT_PIN_MOSI 21 /* SPI MOSI */ +#define TFT_PIN_MISO -1 /* not wired */ +#define TFT_PIN_DC 45 /* D/C (register select) */ +#define TFT_PIN_RST 20 /* hardware reset */ +#define TFT_PIN_BL 2 /* backlight enable (Phase 1: plain GPIO, full-on) */ diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index d64ab3e..0d9a88c 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -21,4 +21,6 @@ idf_component_register( esp_netif esp_event espressif__mdns + display_ui + puzzle_state ) diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index f864d9f..0baabb8 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -51,6 +51,7 @@ #include "local_puzzles.h" #include "mic_broker.h" #include "board_pins_mediakit.h" +#include "display_ui.h" // Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move // this to NVS so the field operator can repoint the firmware without a flash. @@ -409,6 +410,14 @@ void app_main(void) { ESP_ERROR_CHECK(nvs_err); ESP_LOGI(TAG, "NVS initialized"); + // Phase 1 display: bring up the ST7796 TFT early so the splash is visible + // while the rest of the subsystems initialise. Non-fatal on failure. + esp_err_t disp_err = display_ui_init(); + if (disp_err != ESP_OK) { + ESP_LOGW(TAG, "display_ui_init: %s (continuing without display)", + esp_err_to_name(disp_err)); + } + bool sta_ok = wifi_bring_up(); ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback"); @@ -631,19 +640,60 @@ void app_main(void) { ota_server_mark_valid(); ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)"); - uint32_t tick = 0; + uint32_t tick = 0; + uint32_t disp_sub = 0; // sub-counter: display refresh every 2 s for (;;) { - vTaskDelay(pdMS_TO_TICKS(60000)); - tick++; - const uint32_t uptime_ms = (uint32_t) esp_log_timestamp(); - ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s", - (unsigned) tick, - (unsigned long long) (uptime_ms / 1000)); - // Drive the slice-3/4 subsystems from the heartbeat. Once we have - // a real game loop these will move to a dedicated FreeRTOS task - // running at ~5 Hz; for now 60 s is enough to keep mood + media - // simulation state coherent without spamming the log. - media_manager_update(uptime_ms); - npc_engine_update(uptime_ms); + // Sleep in 2 s increments so the display stays responsive. + // Every 30 sub-ticks (~60 s) we do the full heartbeat log. + vTaskDelay(pdMS_TO_TICKS(2000)); + disp_sub++; + + // ── Display status refresh (every 2 s) ────────────────────────── + if (disp_err == ESP_OK) { + display_status_t ds; + memset(&ds, 0, sizeof(ds)); + + // IP address (STA only; empty when in AP mode) + esp_netif_t *sta_if = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (sta_if) { + esp_netif_ip_info_t ip_info; + if (esp_netif_get_ip_info(sta_if, &ip_info) == ESP_OK && + ip_info.ip.addr != 0) { + snprintf(ds.ip, sizeof(ds.ip), IPSTR, + IP2STR(&ip_info.ip)); + } + } + + // Wake-word detector state + ds.wake_active = voice_pipeline_wake_word_active(); + + // Step + armed type from game_endpoint + game_endpoint_get_puzzle_status(ds.step_id, sizeof(ds.step_id), + ds.armed, sizeof(ds.armed)); + + // Solved count + assembled code from puzzle_state + ds.solved_count = 0; + for (int i = 1; i <= PUZZLE_MAX_ID; i++) { + if (s_pstate.solved[i]) { + ds.solved_count++; + } + } + puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code)); + + display_ui_set_status(&ds); + } + + // ── 60 s heartbeat (every 30 × 2 s sub-ticks) ─────────────────── + if (disp_sub >= 30) { + disp_sub = 0; + tick++; + const uint32_t uptime_ms = (uint32_t) esp_log_timestamp(); + ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s", + (unsigned) tick, + (unsigned long long) (uptime_ms / 1000)); + // Drive the slice-3/4 subsystems from the heartbeat. + media_manager_update(uptime_ms); + npc_engine_update(uptime_ms); + } } }