fix(heltec): sleep the T1 and T096 panels on screen-off to stop image retention (#11894)

* fix(heltec-t1): sleep the panel on screen-off to stop image retention

The T1 is excluded from the LovyanGFX sleep()/wakeup() calls because it uses
TFT_eSPI, so DISPLAYOFF only dropped the backlight and the ST7735 kept driving
the last frame unlit for the whole screen-off timeout. That constant static
image is what burns ghost pixels into the panel.

Add opt-in TFT_SLEEP_WHEN_OFF for the TFT_eSPI path: DISPOFF + SLPIN on
screen-off, SLPOUT (120 ms) + DISPON on wake, issued as raw MIPI DCS since
TFT_eSPI exposes no sleep API. Frame memory survives sleep-in, so the previous
frame reappears and the dirty-window diff continues unchanged. The sleep flag
keeps the double displayOn() in Screen::handleSetOn() from paying the delay
twice.

* fix(heltec-t096): sleep the panel on screen-off to stop image retention

The T096 sits behind the same TFT_eSPI exclusion as the T1, so DISPLAYOFF only
dropped its backlight while the ST7735S kept driving the last frame unlit for
the whole screen-off timeout. Same panel, same bus, same burn-in.

Opt in to TFT_SLEEP_WHEN_OFF; the TFTDisplay side of the fix is already generic.

* fix(tft): harden the TFT_SLEEP_WHEN_OFF wake/sleep sequence

Drive VTFT_CTRL LOW before SLPOUT, so the rail is up before the panel is
addressed. Wait out the remainder of the 120 ms the controller needs after
SLPIN before sending SLPOUT, so a wake landing as the screen timeout fires is
not dropped. Guard DISPLAYOFF on panelAsleep to match DISPLAYON.

Correct the T1 VTFT_CTRL comment: LOW enables the rail, not HIGH.

* fix(tft): wait out only what is left of the sleep-in window before SLPOUT

Throttle::isWithinTimespanMs() is true for the whole 120 ms after SLPIN, so the
wake path paid a fresh 120 ms on top of however much had already elapsed. A wake
119 ms after the SLPIN waited ~120 ms rather than ~1 ms, up to 119 ms of
avoidable latency on every quick off/on.

Add Throttle::remainingMs(), which returns what is left of the interval and
saturates at 0 instead of underflowing to a ~49 day wait. It reads the clock
once, so a caller that tests and then waits cannot be preempted between the two
and land on that underflow - which a separate isWithinTimespanMs() plus
subtraction at the call site could.

heltec-mesh-node-t1 and -t096 both build; neither is board_level = pr, so CI
does not compile this path. Docker native suite green, 1515/1515.

* trunk

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
This commit is contained in:
authored and GitHub committed 2026-09-23 09:10:38 +00:00
1 parent 08cd97ea2d
commit b1470cd719
6 files changed
+141 -40

No files matched your search

+52
View File
@@ -55,6 +55,54 @@ void test_hasElapsed_boundary_is_inclusive()
TEST_ASSERT_FALSE(Throttle::hasElapsed(9001, 1000)); // 999ms elapsed
}
// --- remainingMs() ---
// The point of the helper: what is left of the window, not the whole window. A caller that waits
// out the remainder must not pay again for time that has already gone by.
void test_remainingMs_returns_only_what_is_left()
{
Time::setTestMillis(10000);
TEST_ASSERT_EQUAL_UINT32(500, Throttle::remainingMs(9500, 1000)); // 500ms elapsed of a 1000ms window
TEST_ASSERT_EQUAL_UINT32(1, Throttle::remainingMs(9001, 1000)); // 999ms elapsed
TEST_ASSERT_EQUAL_UINT32(1000, Throttle::remainingMs(10000, 1000)); // nothing elapsed yet
}
// Saturates at 0 rather than underflowing to a ~49 day wait, which is what a bare
// intervalMs - elapsed would produce once the interval has passed.
void test_remainingMs_saturates_at_zero_once_elapsed()
{
Time::setTestMillis(10000);
TEST_ASSERT_EQUAL_UINT32(0, Throttle::remainingMs(9000, 1000)); // exactly the interval
TEST_ASSERT_EQUAL_UINT32(0, Throttle::remainingMs(8000, 1000)); // well past it
TEST_ASSERT_EQUAL_UINT32(0, Throttle::remainingMs(0, 1000));
}
// Nonzero exactly while isWithinTimespanMs() is true, so `if (remainingMs(...))` is a drop-in for
// the predicate at a call site that then waits out the rest.
void test_remainingMs_is_nonzero_exactly_within_the_window()
{
Time::setTestMillis(10000);
const uint32_t cases[][2] = {{9500, 1000}, {8000, 1000}, {9000, 1000}, {9001, 1000}, {10000, 1}, {0, 5000}};
for (auto &c : cases) {
TEST_ASSERT_EQUAL(Throttle::isWithinTimespanMs(c[0], c[1]), Throttle::remainingMs(c[0], c[1]) != 0);
}
}
void test_remainingMs_survives_millis_wrap()
{
const uint32_t lastRun = 0xFFFFFF00u; // 256ms before the wrap
Time::setTestMillis(lastRun);
Time::advanceTestMillis(100); // still before the wrap
TEST_ASSERT_EQUAL_UINT32(900, Throttle::remainingMs(lastRun, 1000));
Time::advanceTestMillis(200); // wraps to 0x0000002C - 300ms elapsed in total
TEST_ASSERT_EQUAL_UINT32(700, Throttle::remainingMs(lastRun, 1000));
Time::advanceTestMillis(800); // 1100ms elapsed, past both the window and the wrap
TEST_ASSERT_EQUAL_UINT32(0, Throttle::remainingMs(lastRun, 1000));
}
// --- rollover: the headline property ---
// A window opened just before the 32-bit wrap must still close correctly after it.
@@ -226,6 +274,10 @@ void setup()
RUN_TEST(test_isWithinTimespan_boundary_is_exclusive);
RUN_TEST(test_hasElapsed_is_complement_of_isWithinTimespan);
RUN_TEST(test_hasElapsed_boundary_is_inclusive);
RUN_TEST(test_remainingMs_returns_only_what_is_left);
RUN_TEST(test_remainingMs_saturates_at_zero_once_elapsed);
RUN_TEST(test_remainingMs_is_nonzero_exactly_within_the_window);
RUN_TEST(test_remainingMs_survives_millis_wrap);
RUN_TEST(test_isWithinTimespan_survives_millis_wrap);
RUN_TEST(test_long_interval_survives_wrap);
RUN_TEST(test_deadlinePassed_basic);