mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-22 02:29:51 +00:00
[verified] Retry dropped display refreshes
This commit is contained in:
@@ -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,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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user