Rework the speaker volume from a 0-100 percentage to an 11-step level (0..11, mapped to the codec's percent), with mute that remembers the prior level so unmute restores it. Any non-silent change plays the gong as feedback; a new change cuts the in-flight gong off and restarts it rather than queueing another. The tap overlay shows the level number and 0..11 bar, and a "11" drifts up off the bar when you hit maximum. Also lands the device side of gateway volume commands: gw_client routes a "command" message to face_volume_command, which applies the change and shows a compact auto-hiding volume HUD (a centered bar) — the gateway half that sends these lives in a following commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LKPbR6DY2JygHbyLjxm7Uu
924 lines
34 KiB
C
924 lines
34 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 "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);
|
|
}
|