Root cause of the dead data path (assoc/scan/RPC fine, zero data frames): esp-hosted 1.4.x is formally incompatible with IDF 5.5 (esp-hosted-mcu#47) and the factory C6 slave firmware was ancient (couldn't even answer a version RPC). Waveshare's examples pin 1.4.* — do not follow them. Fix: - host: espressif/esp_hosted ^2.12 (+ esp_wifi_remote 1.6) - slave: 2.12.11 network_adapter.bin embedded in the app (c6_ota.c streams it to the C6 over the SDIO RPC channel at boot when the reported version is < 2.x; ~10s, no wires, idempotent) - RAM diet for hosted 2.x's footprint (MEMPOOL_PREFER_SPIRAM, reduced WIFI_RMT buffers) — without it internal SRAM famine boot-loops in xTaskCreateStaticPinnedToCore before app_main - conservative 20MHz SDIO clock for first verified data path Verified on hardware: DHCP lease (even that healed), 8/8 pings to router and tower-of-joy at 1-5ms, WebSocket to the gateway connected, device registered at /devices as desklock-p4 with fw version. Also: wifi_diag.c L1 SoftAP diagnostic mode (DESKLOCK-DIAG) with review fixes, boot ping ladder in net.c, static-IP fallback, face_status() line, docs for the whole failure taxonomy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
470 lines
14 KiB
C
470 lines
14 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 "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
|
|
#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, 2, 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 *tail;
|
|
lv_obj_t *head;
|
|
int x, y, len, speed;
|
|
bool alive;
|
|
} stream_t;
|
|
|
|
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]; }
|
|
|
|
static void stream_fill(stream_t *s)
|
|
{
|
|
char buf[16 * 4 + 16];
|
|
int pos = 0;
|
|
for (int i = 0; i < s->len; i++) {
|
|
pos += snprintf(buf + pos, sizeof(buf) - pos, "%s%s", i ? "\n" : "", rand_glyph());
|
|
}
|
|
lv_label_set_text(s->tail, buf);
|
|
lv_label_set_text(s->head, rand_glyph());
|
|
}
|
|
|
|
static void stream_spawn(stream_t *s, int speed)
|
|
{
|
|
s->x = (esp_random() % RAIN_COLS) * RAIN_CELL;
|
|
s->len = 6 + esp_random() % 9;
|
|
s->speed = speed + esp_random() % 4;
|
|
s->y = -(s->len + 1) * 26;
|
|
if (s->tail == NULL) {
|
|
s->tail = lv_label_create(F.rain_layer);
|
|
lv_obj_set_style_text_font(s->tail, &font_rain_22, 0);
|
|
lv_obj_set_style_text_color(s->tail, lv_color_hex(0x00A050), 0);
|
|
lv_obj_set_style_text_opa(s->tail, 170, 0);
|
|
s->head = lv_label_create(F.rain_layer);
|
|
lv_obj_set_style_text_font(s->head, &font_rain_22, 0);
|
|
lv_obj_set_style_text_color(s->head, lv_color_hex(0xC3FFD7), 0);
|
|
}
|
|
lv_obj_clear_flag(s->tail, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_clear_flag(s->head, LV_OBJ_FLAG_HIDDEN);
|
|
stream_fill(s);
|
|
s->alive = true;
|
|
}
|
|
|
|
static void stream_hide(stream_t *s)
|
|
{
|
|
s->alive = false;
|
|
if (s->tail != NULL) {
|
|
lv_obj_add_flag(s->tail, LV_OBJ_FLAG_HIDDEN);
|
|
lv_obj_add_flag(s->head, LV_OBJ_FLAG_HIDDEN);
|
|
}
|
|
}
|
|
|
|
static void rain_tick(void)
|
|
{
|
|
const face_def_t *d = &DEFS[F.state];
|
|
int target = (F.power == 2) ? 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;
|
|
lv_obj_set_pos(s->tail, s->x, s->y);
|
|
lv_obj_set_pos(s->head, s->x, s->y + s->len * 26);
|
|
if (F.mutate == 0 && (esp_random() % 3) == 0) {
|
|
lv_label_set_text(s->head, rand_glyph());
|
|
}
|
|
if (s->y > SCREEN + 26) {
|
|
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++;
|
|
}
|
|
}
|
|
F.mutate = (F.mutate + 1) % 4;
|
|
}
|
|
|
|
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;
|
|
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);
|
|
|
|
/* 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, "CONNECTING");
|
|
lv_obj_align(F.status, LV_ALIGN_CENTER, 0, 270);
|
|
|
|
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);
|
|
|
|
F.state = FACE_BOOT;
|
|
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();
|
|
}
|
|
}
|