From 090958755fa349cac7b5006b6c2cd0a8cae203b4 Mon Sep 17 00:00:00 2001 From: deexsed <95432880+deexsed@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:15:30 +0300 Subject: [PATCH] perf(display): Cut partial present SPI and hot-path work Stream only dirty glyph/colon bands to the panel, cache Wi-Fi bars and status icon decode, and skip empty colon MOTION ticks. --- CHANGELOG.md | 9 + assets/README.md | 9 +- components/flint/assets/asset_store.cpp | 154 +++++++++++++++--- components/flint/display/display.cpp | 70 +++++++- components/flint/faces/colon_pulse.h | 31 ++++ components/flint/faces/face_digital.cpp | 31 ++-- .../flint/faces/face_digital_layout.cpp | 118 ++++++++++---- components/flint/faces/face_digital_layout.h | 9 +- components/flint/net/wifi_net.cpp | 115 +++++++------ components/flint/net/wifi_net.h | 2 + components/flint/shell/shell.cpp | 12 +- 11 files changed, 420 insertions(+), 140 deletions(-) create mode 100644 components/flint/faces/colon_pulse.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c3ac1..8379b01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ for firmware (`FLINT_OS_VERSION_*` / git tags). ### Changed +- Partial present: GRAM window + per-row `writePixelsDMA(w)` with FB stride + (SPI = w·h·2, not full panel); multi-rect uses one panel txn; DEBUG_LEVEL≥2 + logs avg present µs / w×h / px +- Digital face presents only changed time glyphs (not full timeBand); AM/PM is + a separate rect; time-font metrics cached across layout rebuilds +- `wifiNetBars()` returns maintain()/link-event cache (no `get_ap_info` per + FaceContext); resident asset pack prefer-cap + 4-slot status icon decode cache +- Colon MOTION dirty only on fade-quantum change; time glyph path sets font once + per batch - UiTok design space / safe inset come from board `canvas.max_*` and `safe_inset` via `board_config.inc` - Prefs dirty contract: `FLINT_EVT_SETTINGS` is soft → `onTick` (no paired `FORCE_REDRAW`) - Digital face layout helpers + UI geometry tokens for time / AM·PM / date bands diff --git a/assets/README.md b/assets/README.md index d00d1ef..01a268d 100644 --- a/assets/README.md +++ b/assets/README.md @@ -41,7 +41,8 @@ Runtime blit skips empty pixels (no solid size×size plate behind the glyph). ## Runtime blit -Pack loads **resident in RAM** when it fits a heap budget (grows with icons; -falls back to stream-from-LittleFS if larger). Icons decode to an RGB565 scratch -then one `pushImage`. Keep dirty/clip paths; do not expand full present to -compensate for slow draws. +Pack loads **resident in RAM** when size ≤ `kMaxResidentPackBytes` (96 KiB); +streams from LittleFS only if oversized or malloc fails (logged). Hot status +icons (≤16 px) keep a small RGB565+key decode cache across blits. Icons decode +to an RGB565 scratch then one `pushImage`. Keep dirty/clip paths; do not expand +full present to compensate for slow draws. diff --git a/components/flint/assets/asset_store.cpp b/components/flint/assets/asset_store.cpp index 3a0ceec..0a47c3d 100644 --- a/components/flint/assets/asset_store.cpp +++ b/components/flint/assets/asset_store.cpp @@ -21,6 +21,10 @@ constexpr size_t kMaxFiBytes = // Resident pack grows with icons; cap leaves headroom for FB on ESP32-C3. constexpr size_t kMaxResidentPackBytes = 96u * 1024u; constexpr size_t kMaxPackEntries = 192; +// Decoded RGB565+key for hot status icons (wifi 0–3 @ 16px). Splash brand is +// larger and rare — not cached here. +constexpr size_t kHotIconSlots = 4; +constexpr size_t kHotIconMaxPx = 16; bool gReady = false; bool gResident = false; @@ -35,6 +39,20 @@ uint8_t* gFiScratch = nullptr; // stream-mode one-FI buffer alignas(4) uint16_t gBlitRgb[kMaxIconPx * kMaxIconPx]; +struct HotIconSlot { + uint16_t iconId = 0xFFFF; + uint8_t sizePx = 0; + uint16_t fg = 0; + uint16_t w = 0; + uint16_t h = 0; + uint16_t key = 0; + bool hasKey = false; + bool valid = false; + alignas(4) uint16_t rgb[kHotIconMaxPx * kHotIconMaxPx]; +}; +HotIconSlot gHotIcons[kHotIconSlots]; +uint8_t gHotIconNext = 0; + void freePackMemory() { free(gPackBytes); gPackBytes = nullptr; @@ -49,6 +67,48 @@ void freePackMemory() { fclose(gPackFile); gPackFile = nullptr; } + for (size_t i = 0; i < kHotIconSlots; i++) { + gHotIcons[i].valid = false; + gHotIcons[i].iconId = 0xFFFF; + } + gHotIconNext = 0; +} + +HotIconSlot* hotIconFind(uint16_t iconId, uint8_t sizePx, uint16_t fg) { + for (size_t i = 0; i < kHotIconSlots; i++) { + HotIconSlot& s = gHotIcons[i]; + if (s.valid && s.iconId == iconId && s.sizePx == sizePx && s.fg == fg) { + return &s; + } + } + return nullptr; +} + +HotIconSlot* hotIconAlloc(uint16_t iconId, uint8_t sizePx, uint16_t fg, uint16_t w, + uint16_t h) { + if (w == 0 || h == 0 || w > kHotIconMaxPx || h > kHotIconMaxPx) { + return nullptr; + } + HotIconSlot& s = gHotIcons[gHotIconNext % kHotIconSlots]; + gHotIconNext = static_cast((gHotIconNext + 1u) % kHotIconSlots); + s.iconId = iconId; + s.sizePx = sizePx; + s.fg = fg; + s.w = w; + s.h = h; + s.valid = false; + return &s; +} + +void hotIconStore(HotIconSlot* slot, const uint16_t* rgb, uint16_t key, bool hasKey) { + if (!slot) { + return; + } + const size_t n = static_cast(slot->w) * static_cast(slot->h); + memcpy(slot->rgb, rgb, n * sizeof(uint16_t)); + slot->key = key; + slot->hasKey = hasKey; + slot->valid = true; } const FlintPackEntry* findEntry(uint16_t iconId, uint8_t sizePx) { @@ -142,15 +202,15 @@ bool loadPack() { gEntryCount = hdr.entry_count; gPackVer = hdr.pack_ver; - const size_t heap = flintFreeHeap(); - const size_t budget = - heap / 4u < kMaxResidentPackBytes ? heap / 4u : kMaxResidentPackBytes; - const bool wantResident = fileSize <= budget; - - if (wantResident) { + // Prefer resident whenever pack fits the hard cap — do not gate on heap/4. + // Stream only if oversized or malloc fails (log clearly either way). + const bool withinCap = fileSize <= kMaxResidentPackBytes; + if (withinCap) { gPackBytes = static_cast(malloc(fileSize)); if (!gPackBytes) { - LOGE("assets", "malloc pack %u failed — streaming", (unsigned)fileSize); + LOGE("assets", "malloc pack %u failed (cap=%u heap=%u) — streaming", + (unsigned)fileSize, (unsigned)kMaxResidentPackBytes, + (unsigned)flintFreeHeap()); } else { if (fseek(f, 0, SEEK_SET) != 0 || !readExact(f, gPackBytes, fileSize)) { LOGE("assets", "read pack body failed"); @@ -173,9 +233,12 @@ bool loadPack() { (unsigned)gPackSize); return true; } + } else { + LOGI("assets", "pack %u > resident cap %u — streaming", (unsigned)fileSize, + (unsigned)kMaxResidentPackBytes); } - // Stream mode: TOC in RAM, FI loaded on demand (pack larger than budget). + // Stream mode: TOC in RAM, FI loaded on demand. gEntriesOwned = static_cast(malloc(tocBytes)); gFiScratch = static_cast(malloc(kMaxFiBytes)); if (!gEntriesOwned || !gFiScratch) { @@ -200,9 +263,9 @@ bool loadPack() { freePackMemory(); return false; } - LOGI("assets", "pack stream ver=%u api_min=%u entries=%u bytes=%u budget=%u", + LOGI("assets", "pack stream ver=%u api_min=%u entries=%u bytes=%u heap=%u", (unsigned)gPackVer, (unsigned)hdr.api_min, (unsigned)gEntryCount, (unsigned)gPackSize, - (unsigned)budget); + (unsigned)flintFreeHeap()); return true; } @@ -294,11 +357,14 @@ uint16_t fiPalTransKey(const uint16_t* pal, uint16_t paletteLen, uint16_t tip) { } void blitL1(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint8_t* pix, int cx, - int cy, uint16_t fg, uint16_t /*bg*/) { + int cy, uint16_t fg, uint16_t /*bg*/, uint16_t* outKey) { int x0 = 0; int y0 = 0; fiBlitOrigin(hdr, cx, cy, &x0, &y0); const uint16_t key = fiTransKey(fg); + if (outKey) { + *outKey = key; + } const size_t stride = fiL1Stride(hdr.w); for (uint16_t y = 0; y < hdr.h; y++) { const uint8_t* row = pix + static_cast(y) * stride; @@ -319,14 +385,21 @@ void blitR565(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint8_t* pix, int gfx.pushImage(x0, y0, hdr.w, hdr.h, reinterpret_cast(pix)); } -void blitP4(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint16_t* pal, - const uint8_t* pix, int cx, int cy, uint16_t /*bg*/) { +bool blitP4(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint16_t* pal, + const uint8_t* pix, int cx, int cy, uint16_t /*bg*/, uint16_t* outKey, + bool* outHasKey) { int x0 = 0; int y0 = 0; fiBlitOrigin(hdr, cx, cy, &x0, &y0); const bool trans = (hdr.flags & kFiFlagHasTrans) != 0; const uint16_t tip = (pal && hdr.palette_len > 1) ? pal[1] : 0; const uint16_t key = fiPalTransKey(pal, hdr.palette_len, tip); + if (outKey) { + *outKey = key; + } + if (outHasKey) { + *outHasKey = trans; + } const size_t stride = (static_cast(hdr.w) + 1u) / 2u; for (uint16_t y = 0; y < hdr.h; y++) { const uint8_t* row = pix + static_cast(y) * stride; @@ -342,16 +415,24 @@ void blitP4(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint16_t* pal, } else { gfx.pushImage(x0, y0, hdr.w, hdr.h, gBlitRgb); } + return true; } -void blitP8(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint16_t* pal, - const uint8_t* pix, int cx, int cy, uint16_t /*bg*/) { +bool blitP8(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint16_t* pal, + const uint8_t* pix, int cx, int cy, uint16_t /*bg*/, uint16_t* outKey, + bool* outHasKey) { int x0 = 0; int y0 = 0; fiBlitOrigin(hdr, cx, cy, &x0, &y0); const bool trans = (hdr.flags & kFiFlagHasTrans) != 0; const uint16_t tip = (pal && hdr.palette_len > 1) ? pal[1] : 0; const uint16_t key = fiPalTransKey(pal, hdr.palette_len, tip); + if (outKey) { + *outKey = key; + } + if (outHasKey) { + *outHasKey = trans; + } for (uint16_t y = 0; y < hdr.h; y++) { const uint8_t* row = pix + static_cast(y) * hdr.w; uint16_t* dst = gBlitRgb + static_cast(y) * hdr.w; @@ -364,6 +445,7 @@ void blitP8(lgfx::LGFXBase& gfx, const FiHeader& hdr, const uint16_t* pal, } else { gfx.pushImage(x0, y0, hdr.w, hdr.h, gBlitRgb); } + return true; } } // namespace @@ -421,7 +503,20 @@ bool assetBlitIcon(lgfx::LGFXBase& gfx, FlintIconId id, uint8_t sizePx, int cx, if (!gReady || !gEntries) { return false; } - const FlintPackEntry* e = findEntry(static_cast(id), sizePx); + + const uint16_t iconId = static_cast(id); + if (HotIconSlot* hit = hotIconFind(iconId, sizePx, fg)) { + const int x0 = cx - static_cast(hit->w) / 2; + const int y0 = cy - static_cast(hit->h) / 2; + if (hit->hasKey) { + gfx.pushImage(x0, y0, hit->w, hit->h, hit->rgb, hit->key); + } else { + gfx.pushImage(x0, y0, hit->w, hit->h, hit->rgb); + } + return true; + } + + const FlintPackEntry* e = findEntry(iconId, sizePx); if (!e) { return false; } @@ -434,10 +529,16 @@ bool assetBlitIcon(lgfx::LGFXBase& gfx, FlintIconId id, uint8_t sizePx, int cx, return false; } + uint16_t key = 0; + bool hasKey = false; + bool decoded = false; + switch (static_cast(hdr.format)) { case FiFormat::L1: - blitL1(gfx, hdr, pix, cx, cy, fg, bg); - return true; + blitL1(gfx, hdr, pix, cx, cy, fg, bg, &key); + hasKey = true; + decoded = true; + break; case FiFormat::R565: blitR565(gfx, hdr, pix, cx, cy); return true; @@ -445,15 +546,24 @@ bool assetBlitIcon(lgfx::LGFXBase& gfx, FlintIconId id, uint8_t sizePx, int cx, if (!pal) { return false; } - blitP4(gfx, hdr, pal, pix, cx, cy, bg); - return true; + blitP4(gfx, hdr, pal, pix, cx, cy, bg, &key, &hasKey); + decoded = true; + break; case FiFormat::P8: if (!pal) { return false; } - blitP8(gfx, hdr, pal, pix, cx, cy, bg); - return true; + blitP8(gfx, hdr, pal, pix, cx, cy, bg, &key, &hasKey); + decoded = true; + break; default: return false; } + + if (decoded) { + if (HotIconSlot* slot = hotIconAlloc(iconId, sizePx, fg, hdr.w, hdr.h)) { + hotIconStore(slot, gBlitRgb, key, hasKey); + } + } + return true; } diff --git a/components/flint/display/display.cpp b/components/flint/display/display.cpp index e7637fd..15164cc 100644 --- a/components/flint/display/display.cpp +++ b/components/flint/display/display.cpp @@ -3,6 +3,10 @@ #include "config/debug.h" #include "util/flint_sys.h" +#if DEBUG_LEVEL >= 2 +#include "esp_timer.h" +#endif + LGFX tft; static LGFX_Sprite gCanvas(&tft); static bool gHasCanvas = false; @@ -37,13 +41,56 @@ static bool clampRect(int& x, int& y, int& w, int& h) { return w > 0 && h > 0; } -// Dirty band → panel via LovyanGFX DMA. Clip + full-FB pushImageDMA keeps -// src stride = SCR_W so Panel_LCD queues strided rows in one window (instead of -// setAddrWindow per scanline). +#if DEBUG_LEVEL >= 2 +static void logPresentMs(int w, int h, int64_t us) { + static uint32_t s_n = 0; + static uint32_t s_usSum = 0; + static uint32_t s_wSum = 0; + static uint32_t s_hSum = 0; + static uint32_t s_pxSum = 0; + s_n++; + s_usSum += static_cast(us > 0 ? us : 0); + s_wSum += static_cast(w); + s_hSum += static_cast(h); + s_pxSum += static_cast(w) * static_cast(h); + if (s_n >= 32u) { + // px must match dirty-rect w×h (colon band), not SCR_W×h. + LOGD("disp", "present avg %luus %lux%lu px=%lu n=%u spi=%luB", + (unsigned long)(s_usSum / s_n), (unsigned long)(s_wSum / s_n), + (unsigned long)(s_hSum / s_n), (unsigned long)(s_pxSum / s_n), (unsigned)s_n, + (unsigned long)((s_pxSum / s_n) * 2u)); + s_n = 0; + s_usSum = 0; + s_wSum = 0; + s_hSum = 0; + s_pxSum = 0; + } +} +#endif + +// Stream one dirty band. Caller must hold tft.startWrite() (nested OK). +static void pushRectDmaInner(const uint16_t* buf, int x, int y, int w, int h) { + tft.setWindow(x, y, x + w - 1, y + h - 1); + const uint16_t* row = + buf + static_cast(y) * static_cast(SCR_W) + static_cast(x); + for (int i = 0; i < h; i++) { + tft.writePixelsDMA(row, w); + row += SCR_W; + } +} + +// Dirty band → panel: GRAM window = rect; stream only w RGB565 samples per row +// with FB stride SCR_W. Width cut is explicit — not LovyanGFX clip-dependent. static void pushRectDma(const uint16_t* buf, int x, int y, int w, int h) { - tft.setClipRect(x, y, w, h); - tft.pushImageDMA(0, 0, SCR_W, SCR_H, buf); - tft.clearClipRect(); +#if DEBUG_LEVEL >= 2 + const int64_t t0 = esp_timer_get_time(); +#endif + tft.startWrite(); + pushRectDmaInner(buf, x, y, w, h); + tft.endWrite(); +#if DEBUG_LEVEL >= 2 + logPresentMs(w, h, esp_timer_get_time() - t0); +#endif } void displayBegin() { @@ -133,8 +180,7 @@ void displayPresentRects(const DisplayRect* rects, int count) { return; } - // Nested startWrite inside pushImageDMA is fine; outer txn keeps CS asserted - // across multiple dirty bands. + // One panel transaction for the whole dirty set (inner push has no txn). tft.startWrite(); for (int i = 0; i < count; i++) { int x = rects[i].x; @@ -144,7 +190,13 @@ void displayPresentRects(const DisplayRect* rects, int count) { if (!clampRect(x, y, w, h)) { continue; } - pushRectDma(buf, x, y, w, h); +#if DEBUG_LEVEL >= 2 + const int64_t t0 = esp_timer_get_time(); +#endif + pushRectDmaInner(buf, x, y, w, h); +#if DEBUG_LEVEL >= 2 + logPresentMs(w, h, esp_timer_get_time() - t0); +#endif } tft.endWrite(); } diff --git a/components/flint/faces/colon_pulse.h b/components/flint/faces/colon_pulse.h new file mode 100644 index 0000000..3a69c64 --- /dev/null +++ b/components/flint/faces/colon_pulse.h @@ -0,0 +1,31 @@ +#pragma once + +#include "display/display.h" +#include "display/ui_palette.h" +#include "display/ui_tokens.h" +#include "util/flint_sys.h" + +#include + +// Shared colon-pulse fade helpers (shell MOTION gate + digital face paint). + +inline uint8_t digitalColonFadeNow() { + const uint32_t phase = flintMillis() % UiTok::kColonPulsePeriodMs; + const uint32_t half = UiTok::kColonPulsePeriodMs / 2; + uint8_t t; + if (phase < half) { + t = static_cast((phase * 255UL) / half); + } else { + t = static_cast(((UiTok::kColonPulsePeriodMs - phase) * 255UL) / half); + } + return static_cast(140 + (t * 115UL) / 255); +} + +inline uint16_t digitalColonColor(const UiPalette& pal, uint8_t fade) { + return colorLerp(pal.textSecondary, pal.accent, fade); +} + +inline bool digitalColonFadeChanged(uint8_t fade, uint8_t lastFade) { + const int d = static_cast(fade) - static_cast(lastFade); + return d >= UiTok::kColonFadeQuant || d <= -UiTok::kColonFadeQuant; +} diff --git a/components/flint/faces/face_digital.cpp b/components/flint/faces/face_digital.cpp index e03a5c6..6e827d7 100644 --- a/components/flint/faces/face_digital.cpp +++ b/components/flint/faces/face_digital.cpp @@ -1,4 +1,5 @@ #include "sdk/flint_face_sdk.h" +#include "faces/colon_pulse.h" #include "faces/face_digital_layout.h" #include "faces/glance.h" #include "config/debug.h" @@ -76,6 +77,8 @@ class DigitalFace : public Face { bool wifiDirty = false; bool ampmDirty = false; bool colonDirty = false; + char prevTime[16]; + memcpy(prevTime, lastTime_, sizeof(prevTime)); DisplayRect dateBand{}; displaySetClipRect(timeBand.x, timeBand.y, @@ -113,24 +116,21 @@ class DigitalFace : public Face { return; } - DisplayRect rects[5]; + // Up to 8 time glyphs + AM/PM + date + wifi. + DisplayRect rects[11]; int n = 0; - if (colonDirty && !timeDirty && !ampmDirty) { - n = timeLayout_.appendColonRects(ctx.screenH, lastTime_, rects, 5); + if (colonDirty && !timeDirty) { + n = timeLayout_.appendColonRects(ctx.screenH, lastTime_, rects, 8); } else if (timeDirty) { - DisplayRect band = timeBand; - if (hour12_ || ampmDirty) { - const int right = ampmBand.x + ampmBand.w; - band.w = static_cast(right - band.x); - } - rects[n++] = band; - } else if (ampmDirty) { + n = timeLayout_.appendChangedGlyphRects(ctx.screenH, prevTime, lastTime_, rects, 8); + } + if (ampmDirty && n < 11) { rects[n++] = ampmBand; } - if (dateDirty) { + if (dateDirty && n < 11) { rects[n++] = dateBand; } - if (wifiDirty) { + if (wifiDirty && n < 11) { rects[n++] = wifiBand; } @@ -290,10 +290,11 @@ class DigitalFace : public Face { } lastColonFade_ = fade; auto& gfx = displayGfx(); + displaySetFont(gfx, timeFont()); const uint16_t fg = digitalColonColor(pal_, fade); for (int i = 0; lastTime_[i] != '\0'; i++) { if (lastTime_[i] == ':') { - timeLayout_.drawGlyph(gfx, i, ':', fg, pageBg(), ctx.screenH, timeFont()); + timeLayout_.drawGlyph(gfx, i, ':', fg, pageBg(), ctx.screenH); } } return true; @@ -305,9 +306,9 @@ class DigitalFace : public Face { } auto& gfx = displayGfx(); timeLayout_.ensure(ctx, showSeconds_, hour12_, timeFont()); + displaySetFont(gfx, timeFont()); const int ty = UiTok::timeY(ctx.screenH); if (force || (lastTime_[0] != '\0' && strlen(lastTime_) != strlen(timeStr))) { - displaySetFont(gfx, timeFont()); gfx.fillRect(0, ty - timeLayout_.ascent, ctx.screenW, timeLayout_.glyphH + UiTok::kTimeGlyphPadY, pageBg()); lastTime_[0] = '\0'; @@ -317,7 +318,7 @@ class DigitalFace : public Face { for (int i = 0; timeStr[i] != '\0'; i++) { if (force || lastTime_[i] == '\0' || timeStr[i] != lastTime_[i]) { const uint16_t fg = (timeStr[i] == ':') ? cFg : pal_.accent; - timeLayout_.drawGlyph(gfx, i, timeStr[i], fg, pageBg(), ctx.screenH, timeFont()); + timeLayout_.drawGlyph(gfx, i, timeStr[i], fg, pageBg(), ctx.screenH); lastTime_[i] = timeStr[i]; } } diff --git a/components/flint/faces/face_digital_layout.cpp b/components/flint/faces/face_digital_layout.cpp index 7e0a5a6..cc6b75f 100644 --- a/components/flint/faces/face_digital_layout.cpp +++ b/components/flint/faces/face_digital_layout.cpp @@ -1,38 +1,77 @@ #include "faces/face_digital_layout.h" #include "display/ui_tokens.h" -#include "util/flint_sys.h" -void DigitalTimeLayout::ensure(const FaceContext& ctx, bool wantSeconds, bool hour12, - const lgfx::IFont* timeFont) { - if (ready && withSeconds == wantSeconds) { +#include + +namespace { + +// Glyph metrics depend only on the time font — amortize textWidth across layout +// rebuilds (withSeconds / hour12 / screenW still recompute cellX). +struct TimeFontMetrics { + const lgfx::IFont* font = nullptr; + int digitW = 0; + int colonW = 0; + int glyphWDigit[10] = {}; + int glyphWDash = 0; + int ascent = 0; + int glyphH = 0; + bool valid = false; +}; + +TimeFontMetrics gTimeFontMetrics{}; + +void ensureTimeFontMetrics(const lgfx::IFont* timeFont) { + if (gTimeFontMetrics.valid && gTimeFontMetrics.font == timeFont) { return; } - auto& gfx = displayGfx(); displaySetFont(gfx, timeFont); - digitW = 0; + int digitW = 0; for (char c = '0'; c <= '9'; c++) { char t[2] = {c, 0}; const int w = displayTextWidth(gfx, t); - glyphWDigit[c - '0'] = w; + gTimeFontMetrics.glyphWDigit[c - '0'] = w; if (w > digitW) { digitW = w; } } { char t[2] = {'-', 0}; - glyphWDash = displayTextWidth(gfx, t); - if (glyphWDash > digitW) { - digitW = glyphWDash; + gTimeFontMetrics.glyphWDash = displayTextWidth(gfx, t); + if (gTimeFontMetrics.glyphWDash > digitW) { + digitW = gTimeFontMetrics.glyphWDash; } } - colonW = displayTextWidth(gfx, ":"); + int colonW = displayTextWidth(gfx, ":"); if (colonW < 1) { colonW = digitW / 3; } - ascent = displayFontAscent(gfx); - glyphH = ascent - displayFontDescent(gfx) + UiTok::kTimeGlyphPadY; + gTimeFontMetrics.digitW = digitW; + gTimeFontMetrics.colonW = colonW; + gTimeFontMetrics.ascent = displayFontAscent(gfx); + gTimeFontMetrics.glyphH = + gTimeFontMetrics.ascent - displayFontDescent(gfx) + UiTok::kTimeGlyphPadY; + gTimeFontMetrics.font = timeFont; + gTimeFontMetrics.valid = true; +} + +} // namespace + +void DigitalTimeLayout::ensure(const FaceContext& ctx, bool wantSeconds, bool hour12, + const lgfx::IFont* timeFont) { + if (ready && withSeconds == wantSeconds) { + return; + } + + ensureTimeFontMetrics(timeFont); + digitW = gTimeFontMetrics.digitW; + colonW = gTimeFontMetrics.colonW; + glyphWDash = gTimeFontMetrics.glyphWDash; + ascent = gTimeFontMetrics.ascent; + glyphH = gTimeFontMetrics.glyphH; + memcpy(glyphWDigit, gTimeFontMetrics.glyphWDigit, sizeof(glyphWDigit)); + withSeconds = wantSeconds; len = wantSeconds ? 8 : 5; @@ -96,9 +135,37 @@ int DigitalTimeLayout::appendColonRects(int screenH, const char* timeStr, Displa return n; } +int DigitalTimeLayout::appendChangedGlyphRects(int screenH, const char* prev, + const char* next, DisplayRect* out, + int cap) const { + if (!out || cap <= 0 || !next) { + return 0; + } + const size_t prevLen = prev ? strlen(prev) : 0; + const size_t nextLen = strlen(next); + if (prevLen == 0 || prevLen != nextLen || !ready) { + out[0] = timeBand(screenH); + return 1; + } + + const int ty = UiTok::timeY(screenH) - ascent; + const int h = glyphH > 0 ? glyphH + UiTok::kTimeGlyphPadY : UiTok::kTimeBandFallbackH; + int n = 0; + for (size_t i = 0; i < nextLen && n < cap; i++) { + if (prev[i] == next[i]) { + continue; + } + const bool colonSlot = withSeconds ? (i == 2 || i == 5) : (i == 2); + const int cellW = colonSlot ? (colonW > 0 ? colonW : digitW) : digitW; + out[n++] = DisplayRect{static_cast(cellX[i]), static_cast(ty), + static_cast(cellW), static_cast(h)}; + } + return n; +} + void DigitalTimeLayout::drawGlyph(lgfx::LGFXBase& gfx, int index, char ch, uint16_t fg, - uint16_t bg, int screenH, const lgfx::IFont* timeFont) const { - displaySetFont(gfx, timeFont); + uint16_t bg, int screenH) const { + // Caller must displaySetFont(timeFont) once per paint batch. const bool isColon = (ch == ':'); const int cellW = isColon ? colonW : digitW; const int x = cellX[index]; @@ -144,24 +211,3 @@ DisplayRect digitalDateBand(const FaceContext& ctx, const char* dateStr, int* io static_cast(UiTok::dateY(ctx.screenH) - UiTok::kDateBandAscentPad), static_cast(presentW), static_cast(bandH)}; } - -uint8_t digitalColonFadeNow() { - const uint32_t phase = flintMillis() % UiTok::kColonPulsePeriodMs; - const uint32_t half = UiTok::kColonPulsePeriodMs / 2; - uint8_t t; - if (phase < half) { - t = static_cast((phase * 255UL) / half); - } else { - t = static_cast(((UiTok::kColonPulsePeriodMs - phase) * 255UL) / half); - } - return static_cast(140 + (t * 115UL) / 255); -} - -uint16_t digitalColonColor(const UiPalette& pal, uint8_t fade) { - return colorLerp(pal.textSecondary, pal.accent, fade); -} - -bool digitalColonFadeChanged(uint8_t fade, uint8_t lastFade) { - const int d = static_cast(fade) - static_cast(lastFade); - return d >= UiTok::kColonFadeQuant || d <= -UiTok::kColonFadeQuant; -} diff --git a/components/flint/faces/face_digital_layout.h b/components/flint/faces/face_digital_layout.h index 9099a9a..2ba334a 100644 --- a/components/flint/faces/face_digital_layout.h +++ b/components/flint/faces/face_digital_layout.h @@ -28,14 +28,13 @@ struct DigitalTimeLayout { DisplayRect timeBand(int screenH) const; DisplayRect ampmBand(int screenH) const; int appendColonRects(int screenH, const char* timeStr, DisplayRect* out, int cap) const; + // Glyph cells where prev[i] != next[i]. Length mismatch / empty prev → one timeBand. + int appendChangedGlyphRects(int screenH, const char* prev, const char* next, + DisplayRect* out, int cap) const; void drawGlyph(lgfx::LGFXBase& gfx, int index, char ch, uint16_t fg, uint16_t bg, - int screenH, const lgfx::IFont* timeFont) const; + int screenH) const; }; DisplayRect digitalDateBand(const FaceContext& ctx, const char* dateStr, int* ioPresentW, const lgfx::IFont* bodyFont); - -uint8_t digitalColonFadeNow(); -uint16_t digitalColonColor(const UiPalette& pal, uint8_t fade); -bool digitalColonFadeChanged(uint8_t fade, uint8_t lastFade); diff --git a/components/flint/net/wifi_net.cpp b/components/flint/net/wifi_net.cpp index 3fb09df..e374e82 100644 --- a/components/flint/net/wifi_net.cpp +++ b/components/flint/net/wifi_net.cpp @@ -21,6 +21,13 @@ static bool s_connecting = false; static uint32_t s_connectStartedMs = 0; static uint32_t s_lastReconnectMs = 0; +// RSSI → bars sample cache. FaceContext reads wifiNetBars() every non-motion +// dirty; maintain() refreshes the ioctl at most every kRssiPollMs. +static constexpr uint32_t kRssiPollMs = 5000UL; +static uint8_t s_cachedBars = 0; +static int8_t s_cachedRssi = INT8_MIN; +static uint32_t s_lastRssiSampleMs = 0; + static const int WIFI_OK_BIT = BIT0; static void wifiMarkConnecting() { @@ -28,6 +35,52 @@ static void wifiMarkConnecting() { s_connectStartedMs = flintMillis() ? flintMillis() : 1; } +static uint8_t barsFromRssi(int8_t rssi) { + if (rssi == INT8_MIN) { + return 1; // associated but RSSI unread — show weakest connected glyph + } + // Typical indoor thresholds for a 3-bar status glyph. + if (rssi >= -55) { + return 3; + } + if (rssi >= -67) { + return 2; + } + return 1; +} + +static int8_t readStaRssi() { + wifi_ap_record_t ap{}; + if (esp_wifi_sta_get_ap_info(&ap) != ESP_OK) { + return INT8_MIN; + } + return ap.rssi; +} + +// Sample STA RSSI into the UI cache. Returns true when bar count changed. +static bool wifiRefreshBarsCache(bool force) { + if (!wifiNetIsConnected()) { + const bool changed = (s_cachedBars != 0) || (s_cachedRssi != INT8_MIN); + s_cachedBars = 0; + s_cachedRssi = INT8_MIN; + return changed; + } + + const uint32_t t = flintMillis(); + if (!force && s_lastRssiSampleMs != 0 && (t - s_lastRssiSampleMs) < kRssiPollMs) { + return false; + } + + s_lastRssiSampleMs = t ? t : 1; + s_cachedRssi = readStaRssi(); + const uint8_t bars = barsFromRssi(s_cachedRssi); + if (bars == s_cachedBars) { + return false; + } + s_cachedBars = bars; + return true; +} + static void wifiEventHandler(void* arg, esp_event_base_t base, int32_t id, void* data) { (void)arg; if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) { @@ -36,6 +89,9 @@ static void wifiEventHandler(void* arg, esp_event_base_t base, int32_t id, void* } else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) { xEventGroupClearBits(s_wifi_events, WIFI_OK_BIT); s_connecting = false; + s_cachedBars = 0; + s_cachedRssi = INT8_MIN; + s_lastRssiSampleMs = 0; flintEventEmit(FLINT_EVT_WIFI); // Immediate retry on drop — do NOT also reconnect from maintain() while in-flight. @@ -55,6 +111,7 @@ static void wifiEventHandler(void* arg, esp_event_base_t base, int32_t id, void* // Modem sleep after link is up — keeps association on a wall clock. esp_wifi_set_ps(WIFI_PS_MIN_MODEM); xEventGroupSetBits(s_wifi_events, WIFI_OK_BIT); + (void)wifiRefreshBarsCache(true); flintEventEmit(FLINT_EVT_WIFI); ip_event_got_ip_t* event = static_cast(data); LOGI("wifi", "up ip=" IPSTR " ps=min_modem", IP2STR(&event->ip_info.ip)); @@ -72,29 +129,16 @@ int8_t wifiNetRssi() { if (!wifiNetIsConnected()) { return INT8_MIN; } - wifi_ap_record_t ap{}; - if (esp_wifi_sta_get_ap_info(&ap) != ESP_OK) { - return INT8_MIN; + // Prefer maintain()/GOT_IP cache — FaceContext must not ioctl every second. + if (s_lastRssiSampleMs != 0) { + return s_cachedRssi; } - return ap.rssi; + return readStaRssi(); } uint8_t wifiNetBars() { - if (!wifiNetIsConnected()) { - return 0; - } - const int8_t rssi = wifiNetRssi(); - if (rssi == INT8_MIN) { - return 1; // associated but RSSI unread — show weakest connected glyph - } - // Typical indoor thresholds for a 3-bar status glyph. - if (rssi >= -55) { - return 3; - } - if (rssi >= -67) { - return 2; - } - return 1; + // Cached only — refreshed from wifiNetMaintain / link events. + return s_cachedBars; } void wifiNetBegin() { @@ -145,56 +189,35 @@ bool wifiNetWaitConnected(uint32_t timeoutMs) { return false; } - wifi_ap_record_t ap{}; - int8_t rssi = 0; - if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { - rssi = ap.rssi; - } + (void)wifiRefreshBarsCache(true); esp_netif_ip_info_t ip{}; esp_netif_get_ip_info(s_sta, &ip); - LOGI("wifi", "connected ip=" IPSTR " rssi=%d", IP2STR(&ip.ip), (int)rssi); + LOGI("wifi", "connected ip=" IPSTR " rssi=%d", IP2STR(&ip.ip), (int)s_cachedRssi); return true; } void wifiNetMaintain() { static bool wasConnected = false; - static uint8_t lastBars = 0xFF; - static uint32_t lastRssiSampleMs = 0; bool now = wifiNetIsConnected(); const bool rose = now && !wasConnected; if (rose) { // Rising edge: ensure modem sleep even if GOT_IP handler raced. esp_wifi_set_ps(WIFI_PS_MIN_MODEM); - wifi_ap_record_t ap{}; - int8_t rssi = 0; - if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { - rssi = ap.rssi; - } + (void)wifiRefreshBarsCache(true); esp_netif_ip_info_t ip{}; if (s_sta) { esp_netif_get_ip_info(s_sta, &ip); } - LOGI("wifi", "up ip=" IPSTR " rssi=%d ps=min_modem", IP2STR(&ip.ip), (int)rssi); + LOGI("wifi", "up ip=" IPSTR " rssi=%d ps=min_modem", IP2STR(&ip.ip), (int)s_cachedRssi); } if (!now && wasConnected) { LOGE("wifi", "down"); } wasConnected = now; - // Poll RSSI on link-up and every few seconds; emit when bar count changes. - uint8_t bars = 0; - if (now) { - const uint32_t t = flintMillis(); - if (rose || lastBars == 0xFF || (t - lastRssiSampleMs) >= 5000UL) { - lastRssiSampleMs = t ? t : 1; - bars = wifiNetBars(); - } else { - bars = lastBars; - } - } - if (bars != lastBars) { - lastBars = bars; + // Poll RSSI on a slow TTL; emit when bar count changes. + if (wifiRefreshBarsCache(false)) { flintEventEmit(FLINT_EVT_WIFI); } diff --git a/components/flint/net/wifi_net.h b/components/flint/net/wifi_net.h index e0f4885..acaf69a 100644 --- a/components/flint/net/wifi_net.h +++ b/components/flint/net/wifi_net.h @@ -8,6 +8,8 @@ bool wifiNetIsConnected(); bool wifiNetWaitConnected(uint32_t timeoutMs); // optional short wait // STA RSSI while associated; INT8_MIN when disconnected / unknown. +// Prefer the maintain()/link-event cache (no ioctl on the FaceContext path). int8_t wifiNetRssi(); // Discrete UI bars: 0 = off, 1–3 = signal strength from RSSI. +// Cached — refreshed by wifiNetMaintain / GOT_IP / disconnect (not per paint). uint8_t wifiNetBars(); diff --git a/components/flint/shell/shell.cpp b/components/flint/shell/shell.cpp index 790b1d1..6dd078d 100644 --- a/components/flint/shell/shell.cpp +++ b/components/flint/shell/shell.cpp @@ -6,6 +6,7 @@ #include "display/ui_tokens.h" #include "event/event.h" #include "faces/FaceRegistry.h" +#include "faces/colon_pulse.h" #include "settings/settings.h" #include "shell/shell_picker.h" #include "shell/shell_settings.h" @@ -108,13 +109,18 @@ void shellTick() { switch (gState) { case SHELL_FACE: { - // Colon pulse only while seconds are visible — skip always-on motion dirty - // when the face is minute-driven. + // Colon pulse: mark MOTION only when the fade quantum actually changes + // (avoids empty uiTakeDirty/onTick when digitalColonFadeChanged is false). static uint32_t lastMotionMs = 0; + static uint8_t lastMotionFade = 255; const uint32_t now = flintMillis(); if (uiWantsSeconds() && (now - lastMotionMs >= UiTok::kColonPulseTickMs)) { - uiMarkDirty(UI_DIRTY_MOTION); lastMotionMs = now; + const uint8_t fade = digitalColonFadeNow(); + if (digitalColonFadeChanged(fade, lastMotionFade)) { + lastMotionFade = fade; + uiMarkDirty(UI_DIRTY_MOTION); + } } uiFaceTick(); break;