Files
desklock/firmware/main/face.c
T
jpmschweitzerandClaude Opus 4.8 ebdb1f0457
Test, Build and Push / test-gateway (push) Successful in 10s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped
Cycle rain streams through the sprite pool for a sense of change
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
2026-07-15 18:34:17 +02:00

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();
}
}