Each stream periodically swaps to a different one of the pooled streak sprites on a staggered timer, so the columns keep changing rather than sitting statically tiled — the rain reads as alive in the corner of the eye. The pool stays at 5: each sprite is ~15 KB of internal RAM and more starves esp-hosted's internal-RAM task stacks into a boot loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
574 lines
20 KiB
C
574 lines
20 KiB
C
/* 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 <stdio.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#include "esp_random.h"
|
|
#include "esp_heap_caps.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_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;
|
|
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);
|
|
}
|
|
}
|
|
|
|
static void touch_cb(lv_event_t *e)
|
|
{
|
|
(void)e;
|
|
app_on_touch();
|
|
}
|
|
|
|
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);
|
|
|
|
/* 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();
|
|
}
|
|
}
|