[verified] Retry dropped display refreshes

This commit is contained in:
torlando-agent[bot]
2026-08-06 03:43:14 +00:00
parent b02e734330
commit aa386d2a15
8 changed files with 129 additions and 20 deletions
+9 -1
View File
@@ -26,11 +26,16 @@ bool Display::_hw_initialized = false;
volatile uint32_t Display::_flush_count = 0;
volatile uint32_t Display::_last_flush_ms = 0;
uint32_t Display::_last_health_log_ms = 0;
DisplayRefreshRetry Display::_refresh_retry;
void Display::set_spi_mutex(SemaphoreHandle_t mutex) {
_spi_mutex = mutex;
}
bool Display::consume_refresh_retry() {
return _refresh_retry.consume();
}
bool Display::init() {
if (_initialized) {
return true;
@@ -271,7 +276,10 @@ void Display::draw_rect(int16_t x, int16_t y, int16_t w, int16_t h, uint16_t col
void Display::lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p) {
if (_spi_mutex && xSemaphoreTake(_spi_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
// Skip this frame — LVGL will retry next tick
// LVGL clears the invalidated area after flush_ready(), so explicitly
// request a redraw after the current refresh cycle. Otherwise stale
// pixels remain indefinitely when SD/LoRa owns the shared SPI bus.
_refresh_retry.mark_failed();
lv_disp_flush_ready(drv);
return;
}
+5
View File
@@ -5,6 +5,7 @@
#define HARDWARE_TDECK_DISPLAY_H
#include "Config.h"
#include "DisplayRefreshRetry.h"
#ifdef ARDUINO
#include <Arduino.h>
@@ -86,6 +87,9 @@ public:
*/
static void lvgl_flush_cb(lv_disp_drv_t* drv, const lv_area_t* area, lv_color_t* color_p);
/** Consume a redraw requested by a flush that lost the shared SPI bus. */
static bool consume_refresh_retry();
private:
// Show boot splash image, called from init_hardware_only()
static void show_splash();
@@ -154,6 +158,7 @@ private:
static volatile uint32_t _flush_count;
static volatile uint32_t _last_flush_ms;
static uint32_t _last_health_log_ms;
static DisplayRefreshRetry _refresh_retry;
public:
// Check display health (call from main loop periodically)
@@ -0,0 +1,30 @@
// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#ifndef HARDWARE_TDECK_DISPLAY_REFRESH_RETRY_H
#define HARDWARE_TDECK_DISPLAY_REFRESH_RETRY_H
namespace Hardware {
namespace TDeck {
// Coalesces one or more unflushed LVGL regions into a single redraw request.
// The flush callback and consumer run on the LVGL task, so no synchronization
// or allocation is required.
class DisplayRefreshRetry {
public:
void mark_failed() { _pending = true; }
bool consume() {
const bool pending = _pending;
_pending = false;
return pending;
}
private:
bool _pending = false;
};
} // namespace TDeck
} // namespace Hardware
#endif // HARDWARE_TDECK_DISPLAY_REFRESH_RETRY_H
-14
View File
@@ -71,17 +71,6 @@ lv_obj_t* group_navigation_root(lv_group_t* group, lv_obj_t* focused) {
return root;
}
void normalize_group_focus_state(lv_group_t* group) {
lv_obj_t* focused = lv_group_get_focused(group);
lv_obj_t** node = static_cast<lv_obj_t**>(_lv_ll_get_head(&group->obj_ll));
while (node) {
if (*node != focused) {
lv_obj_clear_state(*node, LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY);
}
node = static_cast<lv_obj_t**>(_lv_ll_get_next(&group->obj_ll, node));
}
}
bool can_scroll(lv_obj_t* object, NavigationDirection direction) {
if (!object || !object_is_visible(object) ||
!lv_obj_has_flag(object, LV_OBJ_FLAG_SCROLLABLE)) return false;
@@ -149,7 +138,6 @@ void scroll_one_step(lv_obj_t* object, NavigationDirection direction) {
bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) {
if (!group) return false;
if (group->frozen) return false;
normalize_group_focus_state(group);
lv_obj_t* focused = lv_group_get_focused(group);
// A clipped focus target remains the spatial anchor while its container
// scrolls. Only a truly hidden/stale target needs insertion-order refocus.
@@ -158,7 +146,6 @@ bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) {
while (node) {
if (object_is_focus_candidate(*node)) {
lv_group_focus_obj(*node);
normalize_group_focus_state(group);
return true;
}
node = static_cast<lv_obj_t**>(_lv_ll_get_next(&group->obj_ll, node));
@@ -192,7 +179,6 @@ bool navigate_or_scroll(lv_group_t* group, NavigationDirection direction) {
scroll_one_step(ancestor, direction);
} else {
lv_group_focus_obj(target);
normalize_group_focus_state(group);
}
return true;
}
+15
View File
@@ -19,6 +19,19 @@ using namespace Hardware::TDeck;
namespace UI {
namespace LVGL {
namespace {
void retry_failed_display_flush() {
lv_obj_t* screen = lv_scr_act();
if (screen && Display::consume_refresh_retry()) {
// The previous refresh cycle has completed, so invalidation is safe
// here and guarantees that an acknowledged-but-unwritten area is redrawn.
lv_obj_invalidate(screen);
}
}
} // namespace
bool LVGLInit::_initialized = false;
lv_disp_t* LVGLInit::_display = nullptr;
lv_indev_t* LVGLInit::_keyboard = nullptr;
@@ -149,6 +162,7 @@ void LVGLInit::task_handler() {
}
lv_task_handler();
retry_failed_display_flush();
}
void LVGLInit::lvgl_task(void* param) {
@@ -171,6 +185,7 @@ void LVGLInit::lvgl_task(void* param) {
xSemaphoreTakeRecursive(_mutex, portMAX_DELAY);
#endif
lv_task_handler();
retry_failed_display_flush();
xSemaphoreGiveRecursive(_mutex);
// Feed watchdog and yield to other tasks
@@ -0,0 +1,22 @@
#include <cassert>
#include "lib/tdeck_ui/Hardware/TDeck/DisplayRefreshRetry.h"
int main() {
Hardware::TDeck::DisplayRefreshRetry retry;
assert(!retry.consume());
retry.mark_failed();
assert(retry.consume());
assert(!retry.consume());
// Multiple dropped regions before the refresh cycle completes coalesce
// into one full-screen retry on the next LVGL cycle.
retry.mark_failed();
retry.mark_failed();
assert(retry.consume());
assert(!retry.consume());
return 0;
}
@@ -0,0 +1,48 @@
"""Regression coverage for lossless display refresh after SPI contention."""
from pathlib import Path
import shutil
import subprocess
ROOT = Path(__file__).resolve().parents[2]
def test_display_refresh_retry_state(tmp_path):
cxx = shutil.which("clang++") or shutil.which("g++")
if not cxx:
raise RuntimeError("A C++17 compiler is required")
binary = tmp_path / "display_refresh_retry"
subprocess.run(
[
cxx,
"-std=c++17",
"-Wall",
"-Wextra",
"-Werror",
"-I",
str(ROOT),
str(ROOT / "tests/native/test_display_refresh_retry.cpp"),
"-o",
str(binary),
],
check=True,
cwd=ROOT,
)
subprocess.run([str(binary)], check=True)
def test_display_retry_is_wired_after_lvgl_refresh():
display = (ROOT / "lib/tdeck_ui/Hardware/TDeck/Display.cpp").read_text()
lvgl = (ROOT / "lib/tdeck_ui/UI/LVGL/LVGLInit.cpp").read_text()
trackball = (ROOT / "lib/tdeck_ui/Hardware/TDeck/Trackball.cpp").read_text()
assert "refresh_retry.mark_failed();" in display
assert "Skip this frame — LVGL will retry next tick" not in display
assert "Display::consume_refresh_retry()" in lvgl
assert "lv_obj_invalidate(screen)" in lvgl
# Focus state belongs to LVGL's group machinery. The display retry fixes
# stale LCD pixels without mutating non-owner widget state.
assert "normalize_group_focus_state" not in trackball
@@ -70,11 +70,6 @@ def test_trackball_and_nomadnet_directional_integration_contract():
assert "accum_y > 0 ? NavigationDirection::UP : NavigationDirection::DOWN" in trackball
assert "accum_x > 0 ? NavigationDirection::RIGHT : NavigationDirection::LEFT" in trackball
assert trackball.count("last_key_time = now;\n accum_x = 0;\n accum_y = 0;") == 2
# One LVGL group has exactly one focus owner. Normalize stale focus states
# observed physically so two buttons cannot remain highlighted together.
assert "normalize_group_focus_state(group);" in trackball
assert "LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY" in trackball
assert "LV_STATE_FOCUSED | LV_STATE_FOCUS_KEY | LV_STATE_EDITED" not in trackball
# NomadNet's document viewport must remain a bounded vertical scroll target;
# trackball movement reaches it through the generic LVGL navigation helper.