/* The DeskLock face: black screen, glyph expressions, matrix rain. * Contract: sim/face/index.html + docs/architecture.md "Face design". * All lv_timer callbacks run in the LVGL task; face_set() locks for other tasks. */ #include #include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_random.h" #include "esp_heap_caps.h" #include "esp_rom_crc.h" #include "hal/usb_serial_jtag_ll.h" #include "bsp/esp-bsp.h" #include "lvgl.h" #include "desklock.h" LV_FONT_DECLARE(font_face_140); LV_FONT_DECLARE(font_rain_22); LV_FONT_DECLARE(font_phosphor_64); /* Phosphor icon glyphs (Private Use Area, UTF-8) baked into font_phosphor_64. */ #define ICON_MIC "\xEE\x8C\xA6" /* ph-microphone U+E326 */ #define ICON_MIC_OFF "\xEE\x8C\xA8" /* ph-microphone-slash U+E328 */ #define ICON_VOL_UP "\xEE\x91\x8A" /* ph-speaker-high U+E44A */ #define ICON_VOL_DOWN "\xEE\x91\x8C" /* ph-speaker-low U+E44C */ #define ICON_VOL_MUTE "\xEE\x91\x9A" /* ph-speaker-slash U+E45A */ #define ICON_EQ "\xEE\xAE\xBC" /* ph-equalizer U+EBBC */ #define ICON_SLIDERS "\xEE\x90\xB2" /* ph-sliders U+E432 */ #define ICON_SLIDERS_H "\xEE\x90\xB4" /* ph-sliders-horizontal U+E434 */ LV_FONT_DECLARE(font_rage_64); #define SCREEN 800 #define RAIN_MAX 40 #define RAIN_CELL 20 /* Rain is drawn as a small pool of pre-rendered "streak" sprites in INTERNAL RAM, * reused across the on-screen streams. Moving a stream is then a cheap opaque blit * (write-only, no per-frame glyph rasterization and no read-back-to-blend), and the * sprite pixels are read from internal RAM instead of PSRAM — so the rain stops * competing with the DSI's framebuffer read on the PSRAM bus (the "thinking" * underrun). One sprite is regenerated at a time to keep visual variance. */ #define CELL_H 26 /* px per glyph row */ #define SPRITE_GLYPHS 12 /* streak length in a sprite */ #define SPRITE_W 24 /* sprite width (glyph ~22px) */ #define SPRITE_H (SPRITE_GLYPHS * CELL_H) #define SPRITE_POOL 5 /* distinct pre-rendered streaks. Capped at 5: * each ~15 KB in internal RAM, and more starves * esp-hosted's internal-RAM task stacks (boot loop). */ #define RAIN_COLS (SCREEN / RAIN_CELL) #define TICK_MS 33 /* power ladder (docs/architecture.md "Power management") */ #define AMBIENT_AFTER_MS (2 * 60 * 1000) #define DORMANT_AFTER_MS (10 * 60 * 1000) typedef struct { const char *eyes; const char *mouth; bool blink; bool thought; bool talk; int rain_streams; int rain_speed; /* px per tick, before per-stream jitter */ } face_def_t; static const face_def_t DEFS[] = { [FACE_BOOT] = { "- -", "\\_/", false, false, false, 6, 3 }, [FACE_IDLE] = { "- -", "\\_/", true, false, false, 4, 2 }, [FACE_LISTENING] = { "O O", "o", true, false, false, 16, 4 }, [FACE_PENSIVE] = { "\xC2\xB7 \xC2\xB7", "~", false, true, false, 7, 3 }, [FACE_EFFORT] = { "> <", "~", false, false, false, 40, 9 }, [FACE_SPEAKING] = { "^ ^", "o", true, false, true, 14, 5 }, [FACE_RAGE] = { NULL, "", false, false, false, 34, 12 }, [FACE_ERROR] = { "x x", "-", false, false, false, 0, 0 }, }; static const char *TALK[] = { "o", "O", "-", "O", "=", "o" }; static const struct { const char *text; int ms; } RAGE_FRAMES[] = { { "(\xC2\xB0\xE2\x96\xA1\xC2\xB0) \xE2\x94\xAC\xE2\x94\x80\xE2\x94\xAC", 900 }, { "(\xE2\x95\xAF\xC2\xB0\xE2\x96\xA1\xC2\xB0)\xE2\x95\xAF\xEF\xB8\xB5 \xE2\x94\xBB\xE2\x94\x81\xE2\x94\xBB", 1300 }, { "\xE2\x94\xAC\xE2\x94\x80\xE2\x94\xAC \xE3\x83\x8E( \xC2\xBA_\xC2\xBA \xE3\x83\x8E)", 1400 }, }; #define RAGE_LOOP_MS (900 + 1300 + 1400) #define RAGE_AUTO_EXIT_LOOPS 2 /* rain glyph pool: ASCII + katakana (must exist in font_rain_22) */ static const char *GLYPHS[] = { "0","1","2","3","4","5","6","7","8","9","A","C","E","F","H","K","Z", "$","#","%","*","+","=","<",">", "\xE3\x82\xA2","\xE3\x82\xA4","\xE3\x82\xA6","\xE3\x82\xA8","\xE3\x82\xAA", "\xE3\x82\xAB","\xE3\x82\xAD","\xE3\x82\xAF","\xE3\x82\xB1","\xE3\x82\xB3", "\xE3\x82\xB5","\xE3\x82\xB7","\xE3\x82\xB9","\xE3\x82\xBB","\xE3\x82\xBD", "\xE3\x82\xBF","\xE3\x83\x81","\xE3\x83\x84","\xE3\x83\x86","\xE3\x83\x88", "\xE3\x83\x8A","\xE3\x83\x8B","\xE3\x83\x8C","\xE3\x83\x8D","\xE3\x83\x8E", }; #define NGLYPHS (sizeof(GLYPHS) / sizeof(GLYPHS[0])) typedef struct { lv_obj_t *img; /* lv_image showing one of the pool sprites */ int sprite; /* which pool sprite (index into sprite_dsc) */ int flip_in; /* ticks until this stream swaps to another pool sprite */ int x, y, speed; bool alive; } stream_t; /* Pre-rendered streak sprite pool (RGB565, opaque, black background baked in). */ static lv_color16_t *sprite_buf[SPRITE_POOL]; static lv_image_dsc_t sprite_dsc[SPRITE_POOL]; static struct { face_state_t state; lv_obj_t *rain_layer; lv_obj_t *shade; lv_obj_t *eyes; lv_obj_t *mouth; lv_obj_t *thought; lv_obj_t *clock_lbl; lv_obj_t *status; lv_obj_t *elapsed; lv_obj_t *arc; lv_obj_t *controls; /* tap-to-reveal control overlay (mic + volume) */ lv_obj_t *vol_bar; lv_obj_t *vol_lbl; /* level number 0..11, or "MUTE" */ lv_timer_t *ctl_timer; /* one-shot auto-hide for the controls */ lv_obj_t *vol_hud; /* lightweight volume feedback for voice commands */ lv_obj_t *hud_bar; lv_obj_t *hud_lbl; lv_timer_t *hud_timer; /* one-shot auto-hide for the HUD */ stream_t streams[RAIN_MAX]; int arc_rot; uint32_t state_since; uint32_t last_activity; int power; /* 0 active, 1 ambient, 2 dormant */ int blink_in_ms; int blink_hold_ms; int talk_i; int talk_ms; int dots_i; int dots_ms; int rage_ms; int mutate; } F; static uint32_t now_ms(void) { return lv_tick_get(); } static const char *rand_glyph(void) { return GLYPHS[esp_random() % NGLYPHS]; } /* Render a fresh random streak into pool sprite k: a katakana column, dim green * fading up from a bright leading glyph, on an opaque black background. Done once * per sprite (not per frame) via an off-screen canvas. */ static void sprite_render(int k) { if (sprite_buf[k] == NULL) { return; } lv_obj_t *cv = lv_canvas_create(F.rain_layer); lv_canvas_set_buffer(cv, sprite_buf[k], SPRITE_W, SPRITE_H, LV_COLOR_FORMAT_RGB565); lv_canvas_fill_bg(cv, lv_color_black(), LV_OPA_COVER); lv_layer_t layer; lv_canvas_init_layer(cv, &layer); for (int i = 0; i < SPRITE_GLYPHS; i++) { int from_head = SPRITE_GLYPHS - 1 - i; /* 0 = bright leading glyph */ lv_draw_label_dsc_t dsc; lv_draw_label_dsc_init(&dsc); dsc.font = &font_rain_22; dsc.align = LV_TEXT_ALIGN_CENTER; dsc.text = rand_glyph(); if (from_head == 0) { dsc.color = lv_color_hex(0xC3FFD7); /* bright head */ } else { int g = 0xA0 - from_head * 9; /* fade green up the tail */ if (g < 0x28) g = 0x28; dsc.color = lv_color_make(0x00, g, 0x40); } lv_area_t a = { 0, i * CELL_H, SPRITE_W - 1, i * CELL_H + CELL_H - 1 }; lv_draw_label(&layer, &dsc, &a); } lv_canvas_finish_layer(cv, &layer); lv_obj_delete(cv); /* keep sprite_buf[k]; drop the canvas object */ sprite_dsc[k].header.magic = LV_IMAGE_HEADER_MAGIC; sprite_dsc[k].header.cf = LV_COLOR_FORMAT_RGB565; sprite_dsc[k].header.w = SPRITE_W; sprite_dsc[k].header.h = SPRITE_H; sprite_dsc[k].header.stride = SPRITE_W * 2; sprite_dsc[k].data = (const uint8_t *)sprite_buf[k]; sprite_dsc[k].data_size = SPRITE_W * SPRITE_H * 2; } static void stream_spawn(stream_t *s, int speed) { s->x = (esp_random() % RAIN_COLS) * RAIN_CELL; s->speed = speed + esp_random() % 4; s->y = -SPRITE_H; s->sprite = esp_random() % SPRITE_POOL; s->flip_in = 12 + esp_random() % 28; if (s->img == NULL) { s->img = lv_image_create(F.rain_layer); } lv_image_set_src(s->img, &sprite_dsc[s->sprite]); lv_obj_set_pos(s->img, s->x, s->y); lv_obj_clear_flag(s->img, LV_OBJ_FLAG_HIDDEN); s->alive = true; } static void stream_hide(stream_t *s) { s->alive = false; if (s->img != NULL) { lv_obj_add_flag(s->img, LV_OBJ_FLAG_HIDDEN); } } static void rain_tick(void) { const face_def_t *d = &DEFS[F.state]; /* Half-rate: advance the rain every OTHER tick (double the step so the fall * looks identical). Halves how often the 80 rain labels invalidate, cutting the * per-frame PSRAM write traffic that competes with the DSI framebuffer read on * the same bus — a secondary easing of the underrun the reduced DPI clock fixes. * The clock/face still update every tick. */ static uint8_t phase; if (phase++ & 1) { return; } /* Rain is the reachability signal: it only falls when the gateway is live. * A disconnect drains it gracefully (streams finish falling, none respawn). * Also drain it while audio is active (listening or the butler speaking): * the streams fall away rather than freezing (less jarring). */ int target = (F.power == 2 || !gw_connected() || audio_is_playing() || audio_capture_active()) ? 0 : d->rain_streams; int alive = 0; for (int i = 0; i < RAIN_MAX; i++) { stream_t *s = &F.streams[i]; if (!s->alive) { continue; } alive++; s->y += s->speed * 2; /* cheap blit of a pre-rendered sprite */ lv_obj_set_pos(s->img, s->x, s->y); if (--s->flip_in <= 0) { /* swap to a different streak sprite: the "flip" */ s->sprite = (s->sprite + 1 + esp_random() % (SPRITE_POOL - 1)) % SPRITE_POOL; lv_image_set_src(s->img, &sprite_dsc[s->sprite]); s->flip_in = 12 + esp_random() % 28; } if (s->y > SCREEN) { if (alive > target) { stream_hide(s); alive--; } else { stream_spawn(s, d->rain_speed); } } } for (int i = 0; i < RAIN_MAX && alive < target; i++) { if (!F.streams[i].alive) { stream_spawn(&F.streams[i], d->rain_speed); alive++; } } /* Variance: regenerate one pool sprite periodically so the reused streaks vary * over time instead of looking tiled. */ static uint16_t regen; if (++regen >= 90) { regen = 0; sprite_render(esp_random() % SPRITE_POOL); } } static void apply_power(int level) { if (level == F.power) { return; } F.power = level; bsp_display_brightness_set(level == 0 ? 100 : level == 1 ? 35 : 5); if (level == 2) { for (int i = 0; i < RAIN_MAX; i++) { stream_hide(&F.streams[i]); } } } static void power_tick(void) { /* conversation states hold the ladder at active */ if (F.state != FACE_IDLE && F.state != FACE_ERROR) { F.last_activity = now_ms(); } uint32_t idle_ms = now_ms() - F.last_activity; apply_power(idle_ms > DORMANT_AFTER_MS ? 2 : idle_ms > AMBIENT_AFTER_MS ? 1 : 0); } static void set_eyes_mouth(void) { const face_def_t *d = &DEFS[F.state]; if (F.state == FACE_RAGE) { lv_obj_set_style_text_font(F.eyes, &font_rage_64, 0); lv_label_set_text(F.eyes, RAGE_FRAMES[0].text); lv_obj_add_flag(F.mouth, LV_OBJ_FLAG_HIDDEN); return; } lv_obj_set_style_text_font(F.eyes, &font_face_140, 0); lv_label_set_text(F.eyes, d->eyes); lv_label_set_text(F.mouth, d->mouth); lv_obj_clear_flag(F.mouth, LV_OBJ_FLAG_HIDDEN); } static void blink_tick(void) { const face_def_t *d = &DEFS[F.state]; if (!d->blink || F.power == 2) { return; } if (F.blink_hold_ms > 0) { F.blink_hold_ms -= TICK_MS; if (F.blink_hold_ms <= 0) { lv_label_set_text(F.eyes, d->eyes); } return; } F.blink_in_ms -= TICK_MS; if (F.blink_in_ms <= 0) { char blinked[24]; int j = 0; for (const char *p = d->eyes; *p && j < 23; p++) { blinked[j++] = (*p == ' ') ? ' ' : '_'; } blinked[j] = '\0'; lv_label_set_text(F.eyes, blinked); F.blink_hold_ms = 130; F.blink_in_ms = 2600 + esp_random() % 3600; } } static void rage_tick(void) { if (F.state != FACE_RAGE) { return; } F.rage_ms += TICK_MS; if (F.rage_ms >= RAGE_AUTO_EXIT_LOOPS * RAGE_LOOP_MS) { F.state = FACE_IDLE; /* auto-compose: put the table back, return to idle */ set_eyes_mouth(); lv_obj_add_flag(F.arc, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); return; } int t = F.rage_ms % RAGE_LOOP_MS; int idx = t < 900 ? 0 : t < 2200 ? 1 : 2; lv_label_set_text(F.eyes, RAGE_FRAMES[idx].text); } static void tick_cb(lv_timer_t *timer) { (void)timer; rain_tick(); blink_tick(); rage_tick(); power_tick(); if (F.state == FACE_EFFORT) { F.arc_rot = (F.arc_rot + 9) % 360; lv_arc_set_rotation(F.arc, F.arc_rot); char buf[20]; snprintf(buf, sizeof(buf), "[ %lus ]", (unsigned long)((now_ms() - F.state_since) / 1000)); lv_label_set_text(F.elapsed, buf); } const face_def_t *d = &DEFS[F.state]; if (d->talk) { F.talk_ms += TICK_MS; if (F.talk_ms >= 150) { F.talk_ms = 0; lv_label_set_text(F.mouth, TALK[F.talk_i++ % 6]); } } if (d->thought) { F.dots_ms += TICK_MS; if (F.dots_ms >= 480) { F.dots_ms = 0; static const char *DOTS[] = { ".", "..", "..." }; lv_label_set_text(F.thought, DOTS[F.dots_i++ % 3]); } } else { lv_label_set_text(F.thought, ""); } } static void clock_cb(lv_timer_t *timer) { (void)timer; /* Sustained-outage escalation: a brief wedge-recovery (~15 s) just drains * the rain and keeps the calm face; only a real >30 s outage shows x_x. */ static uint32_t disc_since; if (!gw_connected()) { if (disc_since == 0) { disc_since = now_ms(); } if (now_ms() - disc_since > 30000 && F.state == FACE_IDLE) { face_set(FACE_ERROR); } } else { disc_since = 0; if (F.state == FACE_ERROR) { face_set(FACE_IDLE); } } if (F.state != FACE_IDLE) { lv_obj_add_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); return; } time_t t = time(NULL); struct tm tm; localtime_r(&t, &tm); if (tm.tm_year > 100) { char buf[8]; snprintf(buf, sizeof(buf), "%02d:%02d", tm.tm_hour, tm.tm_min); lv_label_set_text(F.clock_lbl, buf); lv_obj_clear_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); } } /* --- tap-to-reveal controls: mic + volume down/up + level bar --- */ static void controls_update_vol(void) { int lvl = audio_level_get(); lv_bar_set_value(F.vol_bar, lvl, LV_ANIM_OFF); if (audio_is_muted()) { lv_label_set_text(F.vol_lbl, "MUTE"); } else { char buf[8]; snprintf(buf, sizeof(buf), "%d", lvl); lv_label_set_text(F.vol_lbl, buf); } } static void controls_hide(void) { lv_obj_add_flag(F.controls, LV_OBJ_FLAG_HIDDEN); lv_timer_pause(F.ctl_timer); } static void controls_show(void) { controls_update_vol(); lv_obj_clear_flag(F.controls, LV_OBJ_FLAG_HIDDEN); face_activity(); /* wake the screen from any dim state */ lv_timer_reset(F.ctl_timer); lv_timer_resume(F.ctl_timer); } /* Audible feedback at the new volume: any non-silent change plays the gong. A new * change cuts the in-flight gong off and restarts it (audio.c) rather than queueing. */ static void volume_feedback(void) { if (!audio_is_muted() && audio_level_get() > 0) { audio_play_gong(); } } /* "This one goes to eleven": a big 11 drifts up and fades when max is hit. */ static void eleven_ty_cb(void *o, int32_t v) { lv_obj_set_style_translate_y((lv_obj_t *)o, v, 0); } static void eleven_opa_cb(void *o, int32_t v) { lv_obj_set_style_text_opa((lv_obj_t *)o, (lv_opa_t)v, 0); } static void eleven_del_cb(lv_anim_t *a) { lv_obj_del((lv_obj_t *)a->var); } static void eleven_pop(lv_obj_t *anchor) { lv_obj_t *l = lv_label_create(lv_layer_top()); /* floats above everything */ lv_label_set_text(l, "11"); lv_obj_set_style_text_font(l, &lv_font_montserrat_48, 0); lv_obj_set_style_text_color(l, lv_color_hex(0xC3FFD7), 0); lv_obj_update_layout(l); lv_area_t a; /* start just above the bar */ lv_obj_get_coords(anchor, &a); lv_obj_set_pos(l, (a.x1 + a.x2) / 2 - lv_obj_get_width(l) / 2, a.y1 - lv_obj_get_height(l) - 4); lv_anim_t up; /* drift up (via translate, keeps align) */ lv_anim_init(&up); lv_anim_set_var(&up, l); lv_anim_set_exec_cb(&up, eleven_ty_cb); lv_anim_set_values(&up, 0, -200); lv_anim_set_duration(&up, 1100); lv_anim_set_path_cb(&up, lv_anim_path_ease_out); lv_anim_set_deleted_cb(&up, eleven_del_cb); /* deletes the label (cancels the fade too) */ lv_anim_start(&up); lv_anim_t fade; lv_anim_init(&fade); lv_anim_set_var(&fade, l); lv_anim_set_exec_cb(&fade, eleven_opa_cb); lv_anim_set_values(&fade, LV_OPA_COVER, LV_OPA_TRANSP); lv_anim_set_duration(&fade, 1100); lv_anim_start(&fade); } /* --- voice-command volume HUD (shown when a gateway command changes volume) --- */ static void hud_update(void) { int lvl = audio_level_get(); if (audio_is_muted()) { lv_bar_set_value(F.hud_bar, 0, LV_ANIM_OFF); lv_label_set_text(F.hud_lbl, "MUTE"); } else { lv_bar_set_value(F.hud_bar, lvl, LV_ANIM_OFF); char b[8]; snprintf(b, sizeof(b), "%d", lvl); lv_label_set_text(F.hud_lbl, b); } } static void hud_autohide_cb(lv_timer_t *t) { (void)t; lv_obj_add_flag(F.vol_hud, LV_OBJ_FLAG_HIDDEN); lv_timer_pause(F.hud_timer); } static void hud_show(void) { hud_update(); lv_obj_clear_flag(F.vol_hud, LV_OBJ_FLAG_HIDDEN); face_activity(); lv_timer_reset(F.hud_timer); lv_timer_resume(F.hud_timer); } /* Apply a gateway volume command (runs on the websocket task). */ void face_volume_command(const char *action, int level) { if (strcmp(action, "volume_up") == 0) { audio_level_set(audio_level_get() + 1); } else if (strcmp(action, "volume_down") == 0) { audio_level_set(audio_level_get() - 1); } else if (strcmp(action, "volume_set") == 0) { audio_level_set(level); } else if (strcmp(action, "mute") == 0) { audio_mute(true); } else if (strcmp(action, "unmute") == 0) { audio_mute(false); } else { return; } bsp_display_lock(UINT32_MAX); hud_show(); if (!audio_is_muted() && audio_level_get() == AUDIO_VOL_MAX) { eleven_pop(F.hud_bar); } bsp_display_unlock(); volume_feedback(); } static void ctl_autohide_cb(lv_timer_t *t) { (void)t; controls_hide(); } static void mic_cb(lv_event_t *e) { (void)e; controls_hide(); app_on_touch(); /* the existing tap-to-talk action */ } static void volup_cb(lv_event_t *e) { (void)e; audio_level_set(audio_level_get() + 1); controls_update_vol(); if (audio_level_get() == AUDIO_VOL_MAX) { eleven_pop(F.vol_bar); } volume_feedback(); lv_timer_reset(F.ctl_timer); } static void voldown_cb(lv_event_t *e) { (void)e; audio_level_set(audio_level_get() - 1); controls_update_vol(); volume_feedback(); lv_timer_reset(F.ctl_timer); } /* tap anywhere (the underlying catcher, reachable only while controls are hidden) * reveals the controls; a tap on the dim scrim behind them dismisses. */ static void touch_cb(lv_event_t *e) { (void)e; controls_show(); } static void scrim_cb(lv_event_t *e) { (void)e; controls_hide(); } static lv_obj_t *icon_btn(lv_obj_t *parent, const char *icon, int diam, lv_event_cb_t cb) { lv_obj_t *b = lv_button_create(parent); lv_obj_set_size(b, diam, diam); lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(b, lv_color_hex(0x0A140E), 0); lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0); lv_obj_set_style_border_color(b, lv_color_hex(0x00A050), 0); lv_obj_set_style_border_width(b, 2, 0); lv_obj_set_style_shadow_width(b, 0, 0); lv_obj_t *lbl = lv_label_create(b); lv_obj_set_style_text_font(lbl, &font_phosphor_64, 0); lv_obj_set_style_text_color(lbl, lv_color_hex(0xC3FFD7), 0); lv_label_set_text(lbl, icon); lv_obj_center(lbl); lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL); return b; } void face_init(void) { bsp_display_lock(UINT32_MAX); lv_obj_t *scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_black(), 0); F.rain_layer = lv_obj_create(scr); lv_obj_remove_style_all(F.rain_layer); lv_obj_set_size(F.rain_layer, SCREEN, SCREEN); lv_obj_set_style_bg_opa(F.rain_layer, LV_OPA_TRANSP, 0); lv_obj_clear_flag(F.rain_layer, LV_OBJ_FLAG_SCROLLABLE); /* Allocate the pre-rendered streak sprites in INTERNAL RAM (so their per-frame * reads stay off the PSRAM bus the DSI is reading the framebuffer from) and * render the initial pool. ~15 KB each. */ for (int k = 0; k < SPRITE_POOL; k++) { sprite_buf[k] = heap_caps_malloc(SPRITE_W * SPRITE_H * sizeof(lv_color16_t), MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); if (sprite_buf[k] == NULL) { /* internal RAM famine: fall back to PSRAM (still cheaper than per-frame * rasterization, just without the bus-relief benefit) */ sprite_buf[k] = heap_caps_malloc(SPRITE_W * SPRITE_H * sizeof(lv_color16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); } sprite_render(k); } /* radial-ish shade so rain doesn't run through the face */ F.shade = lv_obj_create(scr); lv_obj_remove_style_all(F.shade); lv_obj_set_size(F.shade, 520, 520); lv_obj_center(F.shade); lv_obj_set_style_radius(F.shade, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(F.shade, lv_color_black(), 0); lv_obj_set_style_bg_opa(F.shade, 215, 0); F.eyes = lv_label_create(scr); lv_obj_set_style_text_font(F.eyes, &font_face_140, 0); lv_obj_set_style_text_color(F.eyes, lv_color_hex(0xADFFC8), 0); lv_obj_align(F.eyes, LV_ALIGN_CENTER, 0, -90); F.mouth = lv_label_create(scr); lv_obj_set_style_text_font(F.mouth, &font_face_140, 0); lv_obj_set_style_text_color(F.mouth, lv_color_hex(0xADFFC8), 0); lv_obj_align(F.mouth, LV_ALIGN_CENTER, 0, 70); F.thought = lv_label_create(scr); lv_obj_set_style_text_font(F.thought, &font_face_140, 0); lv_obj_set_style_text_color(F.thought, lv_color_hex(0x63CF8B), 0); lv_obj_align(F.thought, LV_ALIGN_CENTER, 170, -170); F.clock_lbl = lv_label_create(scr); lv_obj_set_style_text_font(F.clock_lbl, &lv_font_montserrat_48, 0); lv_obj_set_style_text_color(F.clock_lbl, lv_color_hex(0x2F7A4B), 0); lv_obj_align(F.clock_lbl, LV_ALIGN_CENTER, 0, 200); lv_obj_add_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); F.status = lv_label_create(scr); lv_obj_set_style_text_font(F.status, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.status, lv_color_hex(0x2F7A4B), 0); lv_label_set_text(F.status, ""); lv_obj_align(F.status, LV_ALIGN_CENTER, 0, 270); lv_obj_add_flag(F.status, LV_OBJ_FLAG_HIDDEN); /* only shown in diag mode */ F.elapsed = lv_label_create(scr); lv_obj_set_style_text_font(F.elapsed, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.elapsed, lv_color_hex(0x2F7A4B), 0); lv_obj_set_style_bg_color(F.elapsed, lv_color_black(), 0); lv_obj_set_style_bg_opa(F.elapsed, 205, 0); lv_obj_set_style_pad_all(F.elapsed, 8, 0); lv_obj_align(F.elapsed, LV_ALIGN_CENTER, 0, 300); lv_obj_add_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); F.arc = lv_arc_create(scr); lv_obj_set_size(F.arc, 780, 780); lv_obj_center(F.arc); lv_arc_set_bg_angles(F.arc, 0, 40); lv_arc_set_value(F.arc, 100); lv_obj_remove_style(F.arc, NULL, LV_PART_KNOB); lv_obj_clear_flag(F.arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_width(F.arc, 6, LV_PART_MAIN); lv_obj_set_style_arc_color(F.arc, lv_color_hex(0x37FF8B), LV_PART_MAIN); lv_obj_set_style_arc_opa(F.arc, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_arc_opa(F.arc, LV_OPA_TRANSP, LV_PART_INDICATOR); lv_obj_add_flag(F.arc, LV_OBJ_FLAG_HIDDEN); /* full-screen touch catcher, topmost */ lv_obj_t *touch = lv_obj_create(scr); lv_obj_remove_style_all(touch); lv_obj_set_size(touch, SCREEN, SCREEN); lv_obj_add_flag(touch, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(touch, touch_cb, LV_EVENT_CLICKED, NULL); /* --- tap-to-reveal control overlay: dim scrim + mic + volume down/up + bar --- */ F.controls = lv_obj_create(scr); /* above the touch catcher */ lv_obj_remove_style_all(F.controls); lv_obj_set_size(F.controls, SCREEN, SCREEN); lv_obj_set_style_bg_color(F.controls, lv_color_black(), 0); lv_obj_set_style_bg_opa(F.controls, 200, 0); /* dim the face behind */ lv_obj_add_flag(F.controls, LV_OBJ_FLAG_CLICKABLE); lv_obj_clear_flag(F.controls, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(F.controls, scrim_cb, LV_EVENT_CLICKED, NULL); lv_obj_add_flag(F.controls, LV_OBJ_FLAG_HIDDEN); /* hidden until tapped */ lv_obj_t *mic = icon_btn(F.controls, ICON_MIC, 150, mic_cb); lv_obj_align(mic, LV_ALIGN_CENTER, 0, -30); lv_obj_t *vdn = icon_btn(F.controls, ICON_VOL_DOWN, 110, voldown_cb); lv_obj_align(vdn, LV_ALIGN_CENTER, -230, -30); lv_obj_t *vup = icon_btn(F.controls, ICON_VOL_UP, 110, volup_cb); lv_obj_align(vup, LV_ALIGN_CENTER, 230, -30); F.vol_bar = lv_bar_create(F.controls); lv_obj_set_size(F.vol_bar, 360, 16); lv_obj_align(F.vol_bar, LV_ALIGN_CENTER, 0, 140); lv_bar_set_range(F.vol_bar, 0, AUDIO_VOL_MAX); lv_obj_set_style_bg_color(F.vol_bar, lv_color_hex(0x0A140E), LV_PART_MAIN); lv_obj_set_style_bg_opa(F.vol_bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(F.vol_bar, lv_color_hex(0x00C860), LV_PART_INDICATOR); lv_obj_set_style_radius(F.vol_bar, 8, LV_PART_MAIN); lv_obj_set_style_radius(F.vol_bar, 8, LV_PART_INDICATOR); F.vol_lbl = lv_label_create(F.controls); lv_obj_set_style_text_font(F.vol_lbl, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.vol_lbl, lv_color_hex(0xC3FFD7), 0); lv_label_set_text(F.vol_lbl, ""); lv_obj_align(F.vol_lbl, LV_ALIGN_CENTER, 0, 180); F.ctl_timer = lv_timer_create(ctl_autohide_cb, 5000, NULL); lv_timer_pause(F.ctl_timer); /* voice-command volume HUD: just a slim volume bar (+ level number), centered * and auto-hiding — separate from the tap overlay so a spoken "volume up" * doesn't pop the mic button. The "11" animation flows off this bar. */ F.vol_hud = lv_obj_create(scr); lv_obj_remove_style_all(F.vol_hud); lv_obj_set_size(F.vol_hud, 560, 64); lv_obj_center(F.vol_hud); lv_obj_set_style_bg_color(F.vol_hud, lv_color_black(), 0); lv_obj_set_style_bg_opa(F.vol_hud, 180, 0); lv_obj_set_style_radius(F.vol_hud, 16, 0); lv_obj_set_style_pad_all(F.vol_hud, 10, 0); lv_obj_clear_flag(F.vol_hud, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(F.vol_hud, LV_OBJ_FLAG_HIDDEN); /* bar centered (symmetric on screen); level number hangs off to the right */ F.hud_bar = lv_bar_create(F.vol_hud); lv_obj_set_size(F.hud_bar, 360, 18); lv_obj_align(F.hud_bar, LV_ALIGN_CENTER, 0, 0); lv_bar_set_range(F.hud_bar, 0, AUDIO_VOL_MAX); lv_obj_set_style_bg_color(F.hud_bar, lv_color_hex(0x0A140E), LV_PART_MAIN); lv_obj_set_style_bg_opa(F.hud_bar, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_bg_color(F.hud_bar, lv_color_hex(0x00C860), LV_PART_INDICATOR); lv_obj_set_style_radius(F.hud_bar, 9, LV_PART_MAIN); lv_obj_set_style_radius(F.hud_bar, 9, LV_PART_INDICATOR); F.hud_lbl = lv_label_create(F.vol_hud); lv_obj_set_style_text_font(F.hud_lbl, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.hud_lbl, lv_color_hex(0xC3FFD7), 0); lv_label_set_text(F.hud_lbl, ""); lv_obj_align(F.hud_lbl, LV_ALIGN_RIGHT_MID, -6, 0); F.hud_timer = lv_timer_create(hud_autohide_cb, 2000, NULL); lv_timer_pause(F.hud_timer); /* Boot straight into the calm idle face (dry — rain gate holds it off until * the gateway connects). No "CONNECTING" banner churn on every recovery. */ F.state = FACE_IDLE; F.last_activity = now_ms(); F.state_since = now_ms(); F.blink_in_ms = 3000; set_eyes_mouth(); lv_timer_create(tick_cb, TICK_MS, NULL); lv_timer_create(clock_cb, 1000, NULL); bsp_display_unlock(); } void face_set(face_state_t state) { bsp_display_lock(UINT32_MAX); if (state != F.state) { F.state = state; F.state_since = now_ms(); F.rage_ms = 0; set_eyes_mouth(); bool effort = (state == FACE_EFFORT); lv_obj_set_style_opa(F.eyes, state == FACE_ERROR ? 115 : 255, 0); lv_obj_set_style_opa(F.mouth, state == FACE_ERROR ? 115 : 255, 0); if (effort) { lv_obj_clear_flag(F.arc, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(F.arc, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); } if (state != FACE_BOOT) { lv_obj_add_flag(F.status, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_clear_flag(F.status, LV_OBJ_FLAG_HIDDEN); } } bsp_display_unlock(); } face_state_t face_get(void) { return F.state; } void face_status(const char *text) { bsp_display_lock(UINT32_MAX); lv_label_set_text(F.status, text); lv_obj_clear_flag(F.status, LV_OBJ_FLAG_HIDDEN); bsp_display_unlock(); } void face_activity(void) { F.last_activity = now_ms(); if (F.power != 0) { bsp_display_lock(UINT32_MAX); apply_power(0); bsp_display_unlock(); } } /* --- dev screenshot over USB (see the device-screenshot skill) --------------- */ /* Blocking write straight to the USB-serial-JTAG TX FIFO. Bypasses stdout/printf * — the primary console is UART at 115200 baud (~11 KB/s), far too slow for a * frame; the USB FIFO runs at USB speed. Waits for FIFO space so nothing drops. */ static void shot_usb_write(const uint8_t *data, size_t len) { size_t sent = 0; while (sent < len) { if (!usb_serial_jtag_ll_txfifo_writable()) { vTaskDelay(1); continue; } sent += usb_serial_jtag_ll_write_txfifo(data + sent, len - sent); usb_serial_jtag_ll_txfifo_flush(); } } /* Render the live screen to an RGB565 buffer, 2x-downscale it, and stream it as * RAW BINARY straight over the USB-serial-JTAG, framed by a ###SHOT_BEGIN ...### * text header (carrying byte count + CRC) and ###SHOT_END###, so the host can * rebuild a PNG. `overlay` forces the tap controls in-frame. Called only from the * DESKLOCK_DEVMODE screenshot watcher; not part of normal operation. */ #define SHOT_DS 2 /* downscale factor (2 -> 400x400) */ void face_screenshot_dump(bool overlay) { /* Snapshot into our OWN PSRAM buffer — the default lv_snapshot_take() allocs * the 1.28 MB frame from the small LVGL heap and returns NULL. */ const int W = SCREEN, H = SCREEN; const uint32_t stride = lv_draw_buf_width_to_stride(W, LV_COLOR_FORMAT_RGB565); const size_t buf_size = (size_t)stride * H; uint8_t *mem = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!mem) { printf("###SHOT_FAIL mem###\n"); return; } lv_draw_buf_t dbuf; lv_draw_buf_init(&dbuf, W, H, LV_COLOR_FORMAT_RGB565, stride, mem, buf_size); bsp_display_lock(UINT32_MAX); if (overlay) { /* force the tap overlay in-frame */ controls_show(); lv_timer_pause(F.ctl_timer); /* don't let it auto-hide mid-capture */ } lv_result_t rc = lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_RGB565, &dbuf); bsp_display_unlock(); if (rc != LV_RESULT_OK) { printf("###SHOT_FAIL take=%d###\n", (int)rc); heap_caps_free(mem); return; } const int ow = W / SHOT_DS, oh = H / SHOT_DS; const size_t olen = (size_t)ow * oh * 2; uint8_t *out = heap_caps_malloc(olen, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); if (!out) { printf("###SHOT_FAIL malloc###\n"); heap_caps_free(mem); return; } for (int y = 0; y < oh; y++) { const uint16_t *src = (const uint16_t *)(dbuf.data + (size_t)(y * SHOT_DS) * dbuf.header.stride); uint16_t *dst = (uint16_t *)(out + (size_t)y * ow * 2); for (int x = 0; x < ow; x++) dst[x] = src[x * SHOT_DS]; } heap_caps_free(mem); const uint32_t crc = esp_rom_crc32_le(0, out, olen); char hdr[128]; int hlen = snprintf(hdr, sizeof(hdr), "\n###SHOT_BEGIN w=%d h=%d fmt=rgb565le bytes=%u crc=0x%08x bin=1###\n", ow, oh, (unsigned)olen, (unsigned)crc); shot_usb_write((const uint8_t *)hdr, hlen); shot_usb_write(out, olen); /* raw RGB565-LE, exactly `bytes` long */ static const char end[] = "\n###SHOT_END###\n"; shot_usb_write((const uint8_t *)end, sizeof(end) - 1); heap_caps_free(out); }