feat(ui): scene IR metadata + fx glitch/gyro
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
#include "driver/ledc.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_random.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -220,11 +221,84 @@ static lv_obj_t *make_row(lv_obj_t *parent, int y, const char *label_txt,
|
||||
return val;
|
||||
}
|
||||
|
||||
// Pulse animation on the scene code — SceneEffect::kPulse equivalent.
|
||||
// ─── Scene effects (ports of the original ui_manager SceneEffect set) ────────
|
||||
|
||||
// Pulse — SceneEffect::kPulse equivalent (opacity breathe on the symbol/code).
|
||||
static void pulse_anim_cb(void *obj, int32_t v) {
|
||||
lv_obj_set_style_opa((lv_obj_t *) obj, (lv_opa_t) v, 0);
|
||||
}
|
||||
|
||||
// Glitch — jitter + flicker on the title (text-artifact evocation of the
|
||||
// original glitch overlay; the full pixel-shred shader is a later phase).
|
||||
static lv_timer_t *s_glitch_timer;
|
||||
static void glitch_timer_cb(lv_timer_t *t) {
|
||||
(void) t;
|
||||
const int dx = (int) (esp_random() % 7) - 3;
|
||||
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, dx, 24);
|
||||
lv_obj_set_style_opa(s_scene_title,
|
||||
(esp_random() % 8) ? LV_OPA_COVER : LV_OPA_60, 0);
|
||||
}
|
||||
|
||||
// Gyrophare — rotating beacon ring around the scene.
|
||||
static lv_obj_t *s_gyro_arc;
|
||||
static void gyro_anim_cb(void *obj, int32_t v) {
|
||||
lv_arc_set_rotation((lv_obj_t *) obj, (uint16_t) v);
|
||||
}
|
||||
|
||||
static uint8_t s_fx_applied = 0xFF; // force first apply
|
||||
|
||||
static void start_pulse(void) {
|
||||
lv_anim_t a;
|
||||
lv_anim_init(&a);
|
||||
lv_anim_set_var(&a, s_scene_code);
|
||||
lv_anim_set_exec_cb(&a, pulse_anim_cb);
|
||||
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
|
||||
lv_anim_set_time(&a, 600);
|
||||
lv_anim_set_playback_time(&a, 600);
|
||||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||||
lv_anim_start(&a);
|
||||
}
|
||||
|
||||
static void apply_effect(uint8_t fx) {
|
||||
if (fx == s_fx_applied) return;
|
||||
s_fx_applied = fx;
|
||||
|
||||
// Stop everything, restore neutral state.
|
||||
lv_anim_del(s_scene_code, pulse_anim_cb);
|
||||
lv_obj_set_style_opa(s_scene_code, LV_OPA_COVER, 0);
|
||||
if (s_glitch_timer) lv_timer_pause(s_glitch_timer);
|
||||
lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24);
|
||||
lv_obj_set_style_opa(s_scene_title, LV_OPA_COVER, 0);
|
||||
if (s_gyro_arc) {
|
||||
lv_anim_del(s_gyro_arc, gyro_anim_cb);
|
||||
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
switch (fx) {
|
||||
case 0: // pulse (default)
|
||||
start_pulse();
|
||||
break;
|
||||
case 1: // glitch
|
||||
if (s_glitch_timer) lv_timer_resume(s_glitch_timer);
|
||||
break;
|
||||
case 2: // gyro
|
||||
if (s_gyro_arc) {
|
||||
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_anim_t a;
|
||||
lv_anim_init(&a);
|
||||
lv_anim_set_var(&a, s_gyro_arc);
|
||||
lv_anim_set_exec_cb(&a, gyro_anim_cb);
|
||||
lv_anim_set_values(&a, 0, 360);
|
||||
lv_anim_set_time(&a, 1200);
|
||||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||||
lv_anim_start(&a);
|
||||
}
|
||||
break;
|
||||
default: // none
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void build_scene_screen(void) {
|
||||
s_scr_scene = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_scr_scene, lv_color_hex(0x000000), 0);
|
||||
@@ -267,16 +341,23 @@ static void build_scene_screen(void) {
|
||||
lv_label_set_text(s_scene_subtitle, "");
|
||||
lv_obj_align(s_scene_subtitle, LV_ALIGN_BOTTOM_LEFT, 8, -10);
|
||||
|
||||
// Pulse on the code (original default scene effect).
|
||||
lv_anim_t a;
|
||||
lv_anim_init(&a);
|
||||
lv_anim_set_var(&a, s_scene_code);
|
||||
lv_anim_set_exec_cb(&a, pulse_anim_cb);
|
||||
lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50);
|
||||
lv_anim_set_time(&a, 600);
|
||||
lv_anim_set_playback_time(&a, 600);
|
||||
lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
|
||||
lv_anim_start(&a);
|
||||
// Effect plumbing: glitch timer (paused until selected) + gyro ring
|
||||
// (hidden until selected). Pulse starts via apply_effect on first status.
|
||||
s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL);
|
||||
lv_timer_pause(s_glitch_timer);
|
||||
|
||||
s_gyro_arc = lv_arc_create(s_scr_scene);
|
||||
lv_obj_set_size(s_gyro_arc, 300, 300);
|
||||
lv_obj_center(s_gyro_arc);
|
||||
lv_arc_set_bg_angles(s_gyro_arc, 0, 360);
|
||||
lv_arc_set_angles(s_gyro_arc, 0, 60); // 60° beacon segment
|
||||
lv_obj_remove_style(s_gyro_arc, NULL, LV_PART_KNOB);
|
||||
lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(0xFF3300),
|
||||
LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_width(s_gyro_arc, 8, LV_PART_INDICATOR);
|
||||
lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
static void build_status_screen(void) {
|
||||
@@ -330,15 +411,25 @@ static void apply_status(const display_status_t *s) {
|
||||
snprintf(code, sizeof(code), "Code: %s", s->code[0] ? s->code : "---");
|
||||
lv_label_set_text(s_lbl_code, code);
|
||||
|
||||
// Scene screen
|
||||
lv_label_set_text(s_scene_title, s->step_id);
|
||||
if (s->armed[0] && strcmp(s->armed, "none") != 0) {
|
||||
snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed);
|
||||
// Scene screen — scene metadata wins, with sensible fallbacks.
|
||||
lv_label_set_text(s_scene_title,
|
||||
s->scene_title[0] ? s->scene_title : s->step_id);
|
||||
if (s->scene_subtitle[0]) {
|
||||
lv_label_set_text(s_scene_subtitle, s->scene_subtitle);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "resolues: %u", (unsigned) s->solved_count);
|
||||
if (s->armed[0] && strcmp(s->armed, "none") != 0) {
|
||||
snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed);
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "resolues: %u",
|
||||
(unsigned) s->solved_count);
|
||||
}
|
||||
lv_label_set_text(s_scene_subtitle, tmp);
|
||||
}
|
||||
lv_label_set_text(s_scene_subtitle, tmp);
|
||||
lv_label_set_text(s_scene_code, s->code[0] ? s->code : "");
|
||||
// Center slot: scenario symbol when provided, else the assembled code.
|
||||
lv_label_set_text(s_scene_code,
|
||||
s->scene_symbol[0] ? s->scene_symbol
|
||||
: (s->code[0] ? s->code : ""));
|
||||
apply_effect(s->scene_effect);
|
||||
|
||||
// Viewfinder: visible only while the QR puzzle is armed. When visible,
|
||||
// the centered code label yields its slot to the canvas (code moves into
|
||||
|
||||
@@ -14,6 +14,14 @@ typedef struct {
|
||||
uint8_t solved_count;
|
||||
char ip[16]; // STA IP ("" = none)
|
||||
bool wake_active; // wake-word detector up
|
||||
|
||||
// Scene metadata (from the step's optional `scene` IR object; empty
|
||||
// strings = fall back to step_id / armed / code). Sizes mirror
|
||||
// puzzle_binding.h SB_MAX_*.
|
||||
char scene_title[48];
|
||||
char scene_subtitle[64];
|
||||
char scene_symbol[16];
|
||||
uint8_t scene_effect; // scene_effect_t value (0=pulse 1=glitch 2=gyro 3=none)
|
||||
} display_status_t;
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,8 @@ static puzzle_state_t *s_pstate = NULL;
|
||||
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};
|
||||
// Display scene metadata of the current step (lenient parse; defaults safe).
|
||||
static scene_binding_t s_current_scene = {0};
|
||||
|
||||
// Whitelist mirrored in the 4xx error message so the operator can
|
||||
// recover without grepping the source. Keep aligned with
|
||||
@@ -329,6 +331,7 @@ static esp_err_t scenario_apply_buffer(const char *body, size_t len,
|
||||
// New scenario supersedes any live puzzle — disarm and clear remembered step.
|
||||
local_puzzles_disarm();
|
||||
s_current_step_id[0] = '\0';
|
||||
memset(&s_current_scene, 0, sizeof(s_current_scene));
|
||||
|
||||
// Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3).
|
||||
schedule_restart();
|
||||
@@ -713,9 +716,14 @@ static esp_err_t handle_step_post(httpd_req_t *req) {
|
||||
// Disarm current puzzle before re-arming.
|
||||
local_puzzles_disarm();
|
||||
|
||||
// Parse binding for this step.
|
||||
// Parse binding for this step (+ display scene metadata, lenient).
|
||||
puzzle_binding_t binding;
|
||||
esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding);
|
||||
if (berr == ESP_OK) {
|
||||
// Scene parse can only fail on malformed JSON, which binding parse
|
||||
// already screened — ignore its return; defaults are safe.
|
||||
(void) scene_binding_from_ir(ir_json, step_id, &s_current_scene);
|
||||
}
|
||||
free(ir_json);
|
||||
|
||||
if (berr == ESP_ERR_NOT_FOUND) {
|
||||
@@ -869,6 +877,14 @@ void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
|
||||
}
|
||||
}
|
||||
|
||||
void game_endpoint_get_scene(scene_binding_t *out) {
|
||||
if (!out) return;
|
||||
// Lock-free snapshot, same class as get_puzzle_status: written on the
|
||||
// httpd task per step change, polled by main's status loop. A torn read
|
||||
// of display text during the copy is cosmetic and self-heals next poll.
|
||||
memcpy(out, &s_current_scene, sizeof(*out));
|
||||
}
|
||||
|
||||
// ─── public init ────────────────────────────────────────────────────────────
|
||||
|
||||
esp_err_t game_endpoint_init(httpd_handle_t server) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "puzzle_state.h"
|
||||
#include "puzzle_binding.h" // scene_binding_t (display scene metadata)
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -91,6 +92,15 @@ void game_endpoint_set_puzzle_state(puzzle_state_t *state);
|
||||
void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap,
|
||||
char *armed, size_t armed_cap);
|
||||
|
||||
/**
|
||||
* @brief Snapshot the display scene metadata of the current step.
|
||||
*
|
||||
* Filled from the step's optional `scene` IR object at POST /game/step time
|
||||
* (lenient parse — see puzzle_binding.h). Zeroed when no step is armed or a
|
||||
* new scenario is pushed. Lock-free copy; cosmetic torn reads possible.
|
||||
*/
|
||||
void game_endpoint_get_scene(scene_binding_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// puzzle_binding.h — extract a step's optional puzzle binding from Runtime 3 IR.
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
@@ -38,3 +39,35 @@ typedef struct {
|
||||
// Tolerance defaults to 1 when absent (sound only).
|
||||
esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
|
||||
puzzle_binding_t *out);
|
||||
|
||||
// ─── Scene metadata (display) ────────────────────────────────────────────────
|
||||
// Optional per-step `scene` object mirroring the original ui_manager scene
|
||||
// frame: {"title": "...", "subtitle": "...", "symbol": "...",
|
||||
// "effect": "pulse"|"glitch"|"gyro"|"none"}.
|
||||
// LENIENT parsing (unlike the strict puzzle constraints): scene fields are
|
||||
// display decoration and must never block a step change — over-long strings
|
||||
// are silently TRUNCATED, an unknown effect falls back to pulse.
|
||||
|
||||
#define SB_MAX_TITLE 48
|
||||
#define SB_MAX_SUBTITLE 64
|
||||
#define SB_MAX_SYMBOL 16
|
||||
|
||||
typedef enum {
|
||||
SB_FX_PULSE = 0, // default (original SceneEffect::kPulse)
|
||||
SB_FX_GLITCH,
|
||||
SB_FX_GYRO,
|
||||
SB_FX_NONE,
|
||||
} scene_effect_t;
|
||||
|
||||
typedef struct {
|
||||
bool present; // step has a scene object
|
||||
char title[SB_MAX_TITLE];
|
||||
char subtitle[SB_MAX_SUBTITLE];
|
||||
char symbol[SB_MAX_SYMBOL];
|
||||
scene_effect_t effect;
|
||||
} scene_binding_t;
|
||||
|
||||
// Same return contract as puzzle_binding_from_ir for ESP_OK/NOT_FOUND/
|
||||
// INVALID_ARG(malformed JSON only — scene content itself never fails).
|
||||
esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
|
||||
scene_binding_t *out);
|
||||
|
||||
@@ -165,3 +165,68 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// ─── Scene metadata (lenient — see header) ───────────────────────────────────
|
||||
|
||||
static void copy_scene_str(char *dst, size_t cap, const cJSON *node) {
|
||||
if (cJSON_IsString(node) && node->valuestring) {
|
||||
strncpy(dst, node->valuestring, cap - 1); // silent truncation OK
|
||||
dst[cap - 1] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
|
||||
scene_binding_t *out)
|
||||
{
|
||||
if (!ir_json || !step_id || !out) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->effect = SB_FX_PULSE;
|
||||
|
||||
cJSON *root = cJSON_Parse(ir_json);
|
||||
if (!root) return ESP_ERR_INVALID_ARG;
|
||||
if (!cJSON_IsObject(root)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; }
|
||||
|
||||
const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps");
|
||||
if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) {
|
||||
cJSON_Delete(root);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
const cJSON *step = NULL;
|
||||
const cJSON *item = NULL;
|
||||
cJSON_ArrayForEach(item, steps) {
|
||||
const cJSON *sid = cJSON_GetObjectItemCaseSensitive(item, "id");
|
||||
if (cJSON_IsString(sid) && strcmp(sid->valuestring, step_id) == 0) {
|
||||
step = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
|
||||
|
||||
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
|
||||
if (!scene || !cJSON_IsObject(scene)) {
|
||||
// No scene — present stays false, ESP_OK.
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
out->present = true;
|
||||
copy_scene_str(out->title, sizeof(out->title),
|
||||
cJSON_GetObjectItemCaseSensitive(scene, "title"));
|
||||
copy_scene_str(out->subtitle, sizeof(out->subtitle),
|
||||
cJSON_GetObjectItemCaseSensitive(scene, "subtitle"));
|
||||
copy_scene_str(out->symbol, sizeof(out->symbol),
|
||||
cJSON_GetObjectItemCaseSensitive(scene, "symbol"));
|
||||
|
||||
const cJSON *fx = cJSON_GetObjectItemCaseSensitive(scene, "effect");
|
||||
if (cJSON_IsString(fx) && fx->valuestring) {
|
||||
if (strcmp(fx->valuestring, "glitch") == 0) out->effect = SB_FX_GLITCH;
|
||||
else if (strcmp(fx->valuestring, "gyro") == 0) out->effect = SB_FX_GYRO;
|
||||
else if (strcmp(fx->valuestring, "none") == 0) out->effect = SB_FX_NONE;
|
||||
else out->effect = SB_FX_PULSE; // unknown → default, never an error
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// test_puzzle_binding.c — IDF-style host tests for puzzle_binding_from_ir.
|
||||
#include "unity.h"
|
||||
#include "puzzle_binding.h"
|
||||
#include <string.h>
|
||||
|
||||
// ── 1. QR step parses fully ──────────────────────────────────────────────────
|
||||
TEST_CASE("pbind: qr step parses fully", "[pbind]")
|
||||
@@ -110,3 +111,44 @@ TEST_CASE("pbind: fragment value 12 yields INVALID_ARG", "[pbind]")
|
||||
puzzle_binding_from_ir(ir, "STEP_X", &b));
|
||||
TEST_ASSERT_EQUAL(PB_NONE, b.type);
|
||||
}
|
||||
|
||||
// ── 8-10. Scene metadata (lenient parser) ────────────────────────────────────
|
||||
TEST_CASE("scene parses title/subtitle/symbol/effect", "[pbind]")
|
||||
{
|
||||
const char *ir =
|
||||
"{\"steps\":["
|
||||
"{\"id\":\"STEP_X\",\"scene\":{\"title\":\"MISSION\","
|
||||
"\"subtitle\":\"trouvez les QR\",\"symbol\":\"RUN\","
|
||||
"\"effect\":\"gyro\"}}"
|
||||
"]}";
|
||||
scene_binding_t s;
|
||||
TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s));
|
||||
TEST_ASSERT_TRUE(s.present);
|
||||
TEST_ASSERT_EQUAL_STRING("MISSION", s.title);
|
||||
TEST_ASSERT_EQUAL_STRING("trouvez les QR", s.subtitle);
|
||||
TEST_ASSERT_EQUAL_STRING("RUN", s.symbol);
|
||||
TEST_ASSERT_EQUAL(SB_FX_GYRO, s.effect);
|
||||
}
|
||||
|
||||
TEST_CASE("scene absent leaves present=false, defaults intact", "[pbind]")
|
||||
{
|
||||
const char *ir = "{\"steps\":[{\"id\":\"STEP_X\"}]}";
|
||||
scene_binding_t s;
|
||||
TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s));
|
||||
TEST_ASSERT_FALSE(s.present);
|
||||
TEST_ASSERT_EQUAL(SB_FX_PULSE, s.effect);
|
||||
}
|
||||
|
||||
TEST_CASE("scene unknown effect -> pulse; long title truncated", "[pbind]")
|
||||
{
|
||||
const char *ir =
|
||||
"{\"steps\":["
|
||||
"{\"id\":\"STEP_X\",\"scene\":{\"effect\":\"discoball\","
|
||||
"\"title\":\"0123456789012345678901234567890123456789012345678901234\"}}"
|
||||
"]}";
|
||||
scene_binding_t s;
|
||||
TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s));
|
||||
TEST_ASSERT_TRUE(s.present);
|
||||
TEST_ASSERT_EQUAL(SB_FX_PULSE, s.effect);
|
||||
TEST_ASSERT_EQUAL(SB_MAX_TITLE - 1, (int) strlen(s.title));
|
||||
}
|
||||
|
||||
+15
-4
@@ -649,10 +649,11 @@ void app_main(void) {
|
||||
for (;;) {
|
||||
// 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));
|
||||
// 500 ms so scene changes (POST /game/step) reach the display fast.
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
disp_sub++;
|
||||
|
||||
// ── Display status refresh (every 2 s) ──────────────────────────
|
||||
// ── Display status refresh (every 500 ms) ───────────────────────
|
||||
if (disp_err == ESP_OK) {
|
||||
display_status_t ds;
|
||||
memset(&ds, 0, sizeof(ds));
|
||||
@@ -684,11 +685,21 @@ void app_main(void) {
|
||||
}
|
||||
puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code));
|
||||
|
||||
// Scene metadata (title/subtitle/symbol/effect) of the step.
|
||||
scene_binding_t sb;
|
||||
game_endpoint_get_scene(&sb);
|
||||
if (sb.present) {
|
||||
memcpy(ds.scene_title, sb.title, sizeof(ds.scene_title));
|
||||
memcpy(ds.scene_subtitle, sb.subtitle, sizeof(ds.scene_subtitle));
|
||||
memcpy(ds.scene_symbol, sb.symbol, sizeof(ds.scene_symbol));
|
||||
}
|
||||
ds.scene_effect = (uint8_t) sb.effect;
|
||||
|
||||
display_ui_set_status(&ds);
|
||||
}
|
||||
|
||||
// ── 60 s heartbeat (every 30 × 2 s sub-ticks) ───────────────────
|
||||
if (disp_sub >= 30) {
|
||||
// ── 60 s heartbeat (every 120 × 500 ms sub-ticks) ───────────────
|
||||
if (disp_sub >= 120) {
|
||||
disp_sub = 0;
|
||||
tick++;
|
||||
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
|
||||
|
||||
Reference in New Issue
Block a user