From fc361ca94be22aa8458f74fa4570bdf10da2129d Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 28 Aug 2026 09:21:38 -0700 Subject: [PATCH] feat(display): add R8 portrait observer layouts --- examples/simple_repeater/UITask.cpp | 87 +++++++++++- examples/simple_repeater/UITask.h | 9 +- examples/simple_room_server/UITask.cpp | 83 ++++++++++- examples/simple_room_server/UITask.h | 9 +- src/helpers/ui/DisplayFrameSignature.h | 23 +++ src/helpers/ui/DisplayViewport.h | 41 ++++++ src/helpers/ui/ST7789LCDDisplay.cpp | 134 ++++++++++++++++++ src/helpers/ui/ST7789LCDDisplay.h | 8 ++ test/README.md | 1 + .../test_display_viewport.cpp | 83 +++++++++++ variants/heltec_v4_r8/platformio.ini | 16 +++ 11 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 src/helpers/ui/DisplayFrameSignature.h create mode 100644 src/helpers/ui/DisplayViewport.h create mode 100644 test/test_display_viewport/test_display_viewport.cpp diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 2cd75ee4..e8f48ed3 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -3,6 +3,10 @@ #include #include +#ifdef DISPLAY_REDRAW_ON_CHANGE +#include +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -40,6 +44,9 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi _started_at = millis(); _node_prefs = node_prefs; _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) user_btn.begin(); @@ -163,6 +170,54 @@ void UITask::renderCurrScreen() { } } +#ifdef DISPLAY_REDRAW_ON_CHANGE +uint32_t UITask::getFrameSignature() { + uint32_t signature = DisplayFrameSignature::INITIAL; + char tmp[80]; + + if (millis() < _started_at + BOOT_SCREEN_MILLIS) { + signature = DisplayFrameSignature::append(signature, "boot"); + return DisplayFrameSignature::append(signature, _version_info); + } + + if (_powering_off_at > 0) { + return DisplayFrameSignature::append(signature, "powering-off"); + } + +#ifdef WITH_WEBCONFIG + if (WebConfigServer::isRebootPending()) { + return DisplayFrameSignature::append(signature, "rebooting"); + } + + char wc_ssid[33], wc_ip[16]; + if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + signature = DisplayFrameSignature::append(signature, "setup"); + signature = DisplayFrameSignature::append(signature, wc_ssid); + return DisplayFrameSignature::append(signature, wc_ip); + } +#endif + + signature = DisplayFrameSignature::append(signature, "home"); + signature = DisplayFrameSignature::append(signature, _node_prefs->node_name); + snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); + signature = DisplayFrameSignature::append(signature, tmp); + snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + signature = DisplayFrameSignature::append(signature, tmp); + +#ifdef WITH_MQTT_BRIDGE + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + signature = DisplayFrameSignature::append(signature, tmp); + } else { + signature = DisplayFrameSignature::append(signature, "wifi-disconnected"); + } +#endif + + return signature; +} +#endif + void UITask::loop() { #if defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) int ev = user_btn.check(); @@ -171,6 +226,9 @@ void UITask::loop() { // TODO: any action ? } else { _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif } _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer } else if (ev == BUTTON_EVENT_LONG_PRESS) { @@ -184,21 +242,40 @@ void UITask::loop() { // While the setup portal is up there's no user button to wake the screen // reliably - keep it on so the join instructions stay visible. if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { - if (!_display->isOn()) _display->turnOn(); + if (!_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif + } _auto_off = millis() + AUTO_OFF_MILLIS; } #endif if (_display->isOn()) { if (millis() >= _next_refresh) { - _display->startFrame(); - renderCurrScreen(); - _display->endFrame(); + bool redraw = true; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t frame_signature = getFrameSignature(); + redraw = !_frame_valid || frame_signature != _last_frame_signature; +#endif + if (redraw) { + _display->startFrame(); + renderCurrScreen(); + _display->endFrame(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _last_frame_signature = frame_signature; + _frame_valid = true; +#endif + } - _next_refresh = millis() + 1000; // refresh every second + _next_refresh = millis() + 1000; // check for visible changes every second } if (millis() > _auto_off) { _display->turnOff(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif } } diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index d8e3ce1d..f0ce4221 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -13,10 +13,17 @@ class UITask { unsigned long _powering_off_at = 0; unsigned long _started_at = 0; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t _last_frame_signature = 0; + bool _frame_valid = false; + + uint32_t getFrameSignature(); +#endif + void renderCurrScreen(); public: UITask(mesh::MainBoard& board, DisplayDriver& display) : _board(&board), _display(&display) { _next_read = _next_refresh = 0; } void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); void loop(); -}; \ No newline at end of file +}; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 97562297..884ab528 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -2,6 +2,10 @@ #include #include +#ifdef DISPLAY_REDRAW_ON_CHANGE +#include +#endif + #ifndef USER_BTN_PRESSED #define USER_BTN_PRESSED LOW #endif @@ -36,6 +40,9 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi _auto_off = millis() + AUTO_OFF_MILLIS; _node_prefs = node_prefs; _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif // strip off dash and commit hash by changing dash to null terminator // e.g: v1.2.3-abcdef -> v1.2.3 @@ -144,6 +151,50 @@ void UITask::renderCurrScreen() { } } +#ifdef DISPLAY_REDRAW_ON_CHANGE +uint32_t UITask::getFrameSignature() { + uint32_t signature = DisplayFrameSignature::INITIAL; + char tmp[80]; + + if (millis() < BOOT_SCREEN_MILLIS) { + signature = DisplayFrameSignature::append(signature, "boot"); + return DisplayFrameSignature::append(signature, _version_info); + } + +#ifdef WITH_WEBCONFIG + if (WebConfigServer::isRebootPending()) { + return DisplayFrameSignature::append(signature, "rebooting"); + } + + char wc_ssid[33], wc_ip[16]; + if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) { + signature = DisplayFrameSignature::append(signature, "setup"); + signature = DisplayFrameSignature::append(signature, wc_ssid); + return DisplayFrameSignature::append(signature, wc_ip); + } +#endif + + signature = DisplayFrameSignature::append(signature, "home"); + signature = DisplayFrameSignature::append(signature, _node_prefs->node_name); + snprintf(tmp, sizeof(tmp), "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf); + signature = DisplayFrameSignature::append(signature, tmp); + snprintf(tmp, sizeof(tmp), "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); + signature = DisplayFrameSignature::append(signature, tmp); + +#ifdef WITH_MQTT_BRIDGE + if (WiFi.status() == WL_CONNECTED) { + IPAddress ip = WiFi.localIP(); + snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); + signature = DisplayFrameSignature::append(signature, tmp); + } else { + signature = DisplayFrameSignature::append(signature, "wifi-disconnected"); + } +#endif + + return signature; +} +#endif + void UITask::loop() { #ifdef PIN_USER_BTN if (millis() >= _next_read) { @@ -154,6 +205,9 @@ void UITask::loop() { // TODO: any action ? } else { _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif } _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer } @@ -167,21 +221,40 @@ void UITask::loop() { // While the setup portal is up there's no user button to wake the screen // reliably - keep it on so the join instructions stay visible. if (WebConfigServer::getSetupInfo(NULL, 0, NULL, 0)) { - if (!_display->isOn()) _display->turnOn(); + if (!_display->isOn()) { + _display->turnOn(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif + } _auto_off = millis() + AUTO_OFF_MILLIS; } #endif if (_display->isOn()) { if (millis() >= _next_refresh) { - _display->startFrame(); - renderCurrScreen(); - _display->endFrame(); + bool redraw = true; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t frame_signature = getFrameSignature(); + redraw = !_frame_valid || frame_signature != _last_frame_signature; +#endif + if (redraw) { + _display->startFrame(); + renderCurrScreen(); + _display->endFrame(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _last_frame_signature = frame_signature; + _frame_valid = true; +#endif + } - _next_refresh = millis() + 1000; // refresh every second + _next_refresh = millis() + 1000; // check for visible changes every second } if (millis() > _auto_off) { _display->turnOff(); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif } } } diff --git a/examples/simple_room_server/UITask.h b/examples/simple_room_server/UITask.h index a27259f1..7ef88778 100644 --- a/examples/simple_room_server/UITask.h +++ b/examples/simple_room_server/UITask.h @@ -10,10 +10,17 @@ class UITask { NodePrefs* _node_prefs; char _version_info[32]; +#ifdef DISPLAY_REDRAW_ON_CHANGE + uint32_t _last_frame_signature = 0; + bool _frame_valid = false; + + uint32_t getFrameSignature(); +#endif + void renderCurrScreen(); public: UITask(DisplayDriver& display) : _display(&display) { _next_read = _next_refresh = 0; } void begin(NodePrefs* node_prefs, const char* build_date, const char* firmware_version); void loop(); -}; \ No newline at end of file +}; diff --git a/src/helpers/ui/DisplayFrameSignature.h b/src/helpers/ui/DisplayFrameSignature.h new file mode 100644 index 00000000..9cf545d6 --- /dev/null +++ b/src/helpers/ui/DisplayFrameSignature.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace DisplayFrameSignature { + +static constexpr uint32_t INITIAL = 2166136261u; + +inline uint32_t append(uint32_t signature, const char *text) { + if (text) { + while (*text) { + signature ^= static_cast(*text++); + signature *= 16777619u; + } + } + + // Separate adjacent fields so { "ab", "c" } differs from { "a", "bc" }. + signature ^= 0xFFu; + signature *= 16777619u; + return signature; +} + +} // namespace DisplayFrameSignature diff --git a/src/helpers/ui/DisplayViewport.h b/src/helpers/ui/DisplayViewport.h new file mode 100644 index 00000000..a3812c3e --- /dev/null +++ b/src/helpers/ui/DisplayViewport.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace DisplayViewport { + +struct Geometry { + int16_t logical_width; + int16_t logical_height; + int16_t physical_width; + int16_t physical_height; + + int16_t mapX(int16_t x) const { + return static_cast((static_cast(x) * physical_width) / logical_width); + } + + int16_t mapY(int16_t y) const { + return static_cast((static_cast(y) * physical_height) / logical_height); + } + + uint16_t spanX(int16_t x, int16_t width) const { return static_cast(mapX(x + width) - mapX(x)); } + + uint16_t spanY(int16_t y, int16_t height) const { + return static_cast(mapY(y + height) - mapY(y)); + } + + uint16_t logicalWidthForPhysical(uint16_t width) const { + return static_cast((static_cast(width) * logical_width + physical_width - 1) / + physical_width); + } +}; + +inline uint8_t selectTextScale(uint16_t width_at_scale_one, uint8_t preferred_scale, uint8_t minimum_scale, + uint16_t available_width) { + if (static_cast(width_at_scale_one) * preferred_scale <= available_width) { + return preferred_scale; + } + return minimum_scale; +} + +} // namespace DisplayViewport diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index 137dfa15..1d00fd15 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -1,5 +1,9 @@ #include "ST7789LCDDisplay.h" +#ifdef ST7789_PORTRAIT_PROFILE + #include "DisplayViewport.h" +#endif + #ifndef PIN_TFT_MISO #define PIN_TFT_MISO -1 #endif @@ -19,6 +23,16 @@ #define DISPLAY_WIDTH 240 #define DISPLAY_HEIGHT 320 +#ifdef ST7789_PORTRAIT_PROFILE + #ifndef ST7789_PORTRAIT_TEXT_SCALE + #define ST7789_PORTRAIT_TEXT_SCALE 2 + #endif + +static DisplayViewport::Geometry portraitViewport(int16_t physical_width, int16_t physical_height) { + return {128, 64, physical_width, physical_height}; +} +#endif + bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { return true; } @@ -64,7 +78,13 @@ bool ST7789LCDDisplay::begin() { display.fillScreen(ST77XX_BLACK); display.setTextColor(ST77XX_WHITE); + #ifdef ST7789_PORTRAIT_PROFILE + _logical_text_size = 1; + display.setTextSize(ST7789_PORTRAIT_TEXT_SCALE); + display.setTextWrap(false); + #else display.setTextSize(2 * DISPLAY_SCALE_X); + #endif display.cp437(true); // Use full 256 char 'Code Page 437' font #ifdef HELTEC_V4_R8_TFT @@ -113,12 +133,22 @@ void ST7789LCDDisplay::clear() { void ST7789LCDDisplay::startFrame(ColorVal bkg) { display.fillScreen(bkg); display.setTextColor(_color = UIColor::primary_txt); +#ifdef ST7789_PORTRAIT_PROFILE + _logical_text_size = 1; + display.setTextSize(ST7789_PORTRAIT_TEXT_SCALE); +#else display.setTextSize(1 * DISPLAY_SCALE_X); // This one affects size of Please wait... message +#endif display.cp437(true); // Use full 256 char 'Code Page 437' font } void ST7789LCDDisplay::setTextSize(int sz) { +#ifdef ST7789_PORTRAIT_PROFILE + _logical_text_size = sz > 0 ? static_cast(sz) : 1; + display.setTextSize(_logical_text_size * ST7789_PORTRAIT_TEXT_SCALE); +#else display.setTextSize(sz * DISPLAY_SCALE_X); +#endif } void ST7789LCDDisplay::setColor(ColorVal c) { @@ -126,24 +156,70 @@ void ST7789LCDDisplay::setColor(ColorVal c) { } void ST7789LCDDisplay::setCursor(int x, int y) { +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + display.setCursor(viewport.mapX(x), viewport.mapY(y)); +#else display.setCursor(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y); +#endif } void ST7789LCDDisplay::print(const char* str) { +#ifdef ST7789_PORTRAIT_PROFILE + int16_t cursor_x = display.getCursorX(); + if (cursor_x < 0) { + cursor_x = 0; + display.setCursor(cursor_x, display.getCursorY()); + } + if (cursor_x >= display.width()) return; + + printFitted(str, static_cast(display.width() - cursor_x)); +#else display.print(str); +#endif } void ST7789LCDDisplay::fillRect(int x, int y, int w, int h) { +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + display.fillRect(viewport.mapX(x), viewport.mapY(y), viewport.spanX(x, w), viewport.spanY(y, h), _color); +#else display.fillRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color); +#endif } void ST7789LCDDisplay::drawRect(int x, int y, int w, int h) { +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + display.drawRect(viewport.mapX(x), viewport.mapY(y), viewport.spanX(x, w), viewport.spanY(y, h), _color); +#else display.drawRect(x * DISPLAY_SCALE_X, y * DISPLAY_SCALE_Y, w * DISPLAY_SCALE_X, h * DISPLAY_SCALE_Y, _color); +#endif } void ST7789LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { uint8_t byteWidth = (w + 7) / 8; +#ifdef ST7789_PORTRAIT_PROFILE + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + int16_t physical_y = viewport.mapY(y); + + for (int j = 0; j < h; j++) { + // Scale both bitmap axes from the logical X ratio so logo pixels stay square. + int16_t y0 = physical_y + viewport.mapX(j); + int16_t y1 = physical_y + viewport.mapX(j + 1); + for (int i = 0; i < w; i++) { + uint8_t byte = bits[j * byteWidth + i / 8]; + bool pixelOn = byte & (0x80 >> (i & 7)); + + if (pixelOn) { + int16_t x0 = viewport.mapX(x + i); + int16_t x1 = viewport.mapX(x + i + 1); + display.fillRect(x0, y0, x1 - x0, y1 - y0, _color); + } + } + } +#else for (int j = 0; j < h; j++) { for (int i = 0; i < w; i++) { uint8_t byte = bits[j * byteWidth + i / 8]; @@ -158,16 +234,74 @@ void ST7789LCDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) } } } +#endif } uint16_t ST7789LCDDisplay::getTextWidth(const char* str) { +#ifdef ST7789_PORTRAIT_PROFILE + uint8_t physical_scale = selectTextScale(str, display.width()); + uint16_t physical_width = measureTextWidth(str, physical_scale); + if (physical_width > display.width()) physical_width = display.width(); + + DisplayViewport::Geometry viewport = portraitViewport(display.width(), display.height()); + return viewport.logicalWidthForPhysical(physical_width); +#else int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); return w / DISPLAY_SCALE_X; +#endif } +#ifdef ST7789_PORTRAIT_PROFILE +uint16_t ST7789LCDDisplay::measureTextWidth(const char* str, uint8_t physical_scale) { + int16_t x1, y1; + uint16_t w, h; + display.setTextSize(physical_scale); + display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); + return w; +} + +uint8_t ST7789LCDDisplay::selectTextScale(const char* str, uint16_t available_width) { + uint16_t width_at_scale_one = measureTextWidth(str, 1); + uint8_t preferred_scale = _logical_text_size * ST7789_PORTRAIT_TEXT_SCALE; + return DisplayViewport::selectTextScale(width_at_scale_one, preferred_scale, _logical_text_size, + available_width); +} + +void ST7789LCDDisplay::printFitted(const char* str, uint16_t available_width) { + if (!str || available_width == 0) return; + + uint8_t physical_scale = selectTextScale(str, available_width); + if (measureTextWidth(str, physical_scale) <= available_width) { + display.print(str); + return; + } + + static const char* ellipsis = "..."; + uint16_t ellipsis_width = measureTextWidth(ellipsis, physical_scale); + if (ellipsis_width > available_width) return; + + char fitted[256]; + size_t len = strlen(str); + if (len > sizeof(fitted) - 4) len = sizeof(fitted) - 4; + memcpy(fitted, str, len); + fitted[len] = 0; + + while (len > 0 && measureTextWidth(fitted, physical_scale) + ellipsis_width > available_width) { + --len; + while (len > 0 && (static_cast(fitted[len]) & 0xC0) == 0x80) + --len; + fitted[len] = 0; + } + + memcpy(fitted + len, ellipsis, 4); + display.setTextSize(physical_scale); + display.print(fitted); +} +#endif + void ST7789LCDDisplay::endFrame() { // display.display(); } diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h index b5127d35..f6d7446b 100644 --- a/src/helpers/ui/ST7789LCDDisplay.h +++ b/src/helpers/ui/ST7789LCDDisplay.h @@ -16,6 +16,14 @@ class ST7789LCDDisplay : public DisplayDriver { uint16_t _color; RefCountedDigitalPin* _peripher_power; +#ifdef ST7789_PORTRAIT_PROFILE + uint8_t _logical_text_size = 1; + + uint16_t measureTextWidth(const char* str, uint8_t physical_scale); + uint8_t selectTextScale(const char* str, uint16_t available_width); + void printFitted(const char* str, uint16_t available_width); +#endif + bool i2c_probe(TwoWire& wire, uint8_t addr); public: #ifdef USE_PIN_TFT diff --git a/test/README.md b/test/README.md index 6fde2d7f..901227ef 100644 --- a/test/README.md +++ b/test/README.md @@ -30,6 +30,7 @@ does not reflect the GoogleTest count — run the built binary directly | `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries | | `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover; WiFi current-outage start sticky across STA reconnect attempts | | `test_alert_fault_policy` | `src/helpers/AlertFaultPolicy.h` | WiFi/MQTT fault edge detector; `OutageSnapshot` (down / started_ms / initiating reason) fed to tick and `formatWifiAlert`; reason-8 reconnects change neither duration nor initiating reason; flap between status polls; down at `millis()==0`; packed 64-bit cross-task word; rate-limit floor and first-fire; 5 s poll cadence and `millis()` rollover | +| `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | logical-to-physical portrait mapping; fractional span coverage; fitted-width conversion; preferred/fallback text scaling; stable visible-frame change detection | | `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover | | `test_mqtt_packet_filter` | `src/helpers/MQTTPacketFilter.h` | per-slot 0-15 allowlist parsing/formatting, numeric and named spellings; exact bounds; membership; candidate/eligible split and retry-completion policy; pre-queue union gate; default-mask detection | | `test_mqtt_runtime_buffer_lifecycle` | `src/helpers/MQTTRuntimeBufferLifecycle.h` | idempotent allocation/release; partial-allocation degradation; retry of only missing buffers | diff --git a/test/test_display_viewport/test_display_viewport.cpp b/test/test_display_viewport/test_display_viewport.cpp new file mode 100644 index 00000000..35378bd0 --- /dev/null +++ b/test/test_display_viewport/test_display_viewport.cpp @@ -0,0 +1,83 @@ +#include "helpers/ui/DisplayFrameSignature.h" +#include "helpers/ui/DisplayViewport.h" + +#include + +namespace { + +const DisplayViewport::Geometry kPortrait{ 128, 64, 240, 320 }; + +} // namespace + +TEST(DisplayViewport, MapsLogicalBoundsToPortraitPanel) { + EXPECT_EQ(0, kPortrait.mapX(0)); + EXPECT_EQ(240, kPortrait.mapX(128)); + EXPECT_EQ(0, kPortrait.mapY(0)); + EXPECT_EQ(320, kPortrait.mapY(64)); +} + +TEST(DisplayViewport, MapsObserverRowsAcrossPortraitHeight) { + EXPECT_EQ(0, kPortrait.mapY(0)); + EXPECT_EQ(70, kPortrait.mapY(14)); + EXPECT_EQ(100, kPortrait.mapY(20)); + EXPECT_EQ(120, kPortrait.mapY(24)); + EXPECT_EQ(150, kPortrait.mapY(30)); + EXPECT_EQ(200, kPortrait.mapY(40)); + EXPECT_EQ(240, kPortrait.mapY(48)); + EXPECT_EQ(250, kPortrait.mapY(50)); +} + +TEST(DisplayViewport, FractionalHorizontalSpansHaveNoGapsOrOverlaps) { + int16_t previous_end = 0; + int total_width = 0; + + for (int x = 0; x < 128; ++x) { + int16_t start = kPortrait.mapX(x); + int16_t end = kPortrait.mapX(x + 1); + EXPECT_EQ(previous_end, start); + EXPECT_TRUE(end - start == 1 || end - start == 2); + total_width += end - start; + previous_end = end; + } + + EXPECT_EQ(240, previous_end); + EXPECT_EQ(240, total_width); +} + +TEST(DisplayViewport, ConvertsFittedPhysicalWidthBackToLogicalWidth) { + EXPECT_EQ(0, kPortrait.logicalWidthForPhysical(0)); + EXPECT_EQ(64, kPortrait.logicalWidthForPhysical(120)); + EXPECT_EQ(122, kPortrait.logicalWidthForPhysical(228)); + EXPECT_EQ(128, kPortrait.logicalWidthForPhysical(240)); +} + +TEST(DisplayViewport, SelectsPreferredTextScaleOnlyWhenItFits) { + EXPECT_EQ(2, DisplayViewport::selectTextScale(114, 2, 1, 240)); + EXPECT_EQ(1, DisplayViewport::selectTextScale(150, 2, 1, 240)); + EXPECT_EQ(1, DisplayViewport::selectTextScale(192, 2, 1, 229)); + EXPECT_EQ(1, DisplayViewport::selectTextScale(300, 2, 1, 240)); +} + +TEST(DisplayFrameSignature, ChangesWithVisibleContent) { + uint32_t initial = DisplayFrameSignature::INITIAL; + uint32_t home = DisplayFrameSignature::append(initial, "home"); + + EXPECT_EQ(home, DisplayFrameSignature::append(initial, "home")); + EXPECT_NE(home, DisplayFrameSignature::append(initial, "setup")); + EXPECT_NE(DisplayFrameSignature::append(home, "10.0.0.1"), DisplayFrameSignature::append(home, "10.0.0.2")); +} + +TEST(DisplayFrameSignature, KeepsAdjacentFieldsDistinct) { + uint32_t first = DisplayFrameSignature::append(DisplayFrameSignature::INITIAL, "ab"); + first = DisplayFrameSignature::append(first, "c"); + + uint32_t second = DisplayFrameSignature::append(DisplayFrameSignature::INITIAL, "a"); + second = DisplayFrameSignature::append(second, "bc"); + + EXPECT_NE(first, second); +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 246eb785..9025d422 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -334,6 +334,7 @@ build_flags = -D ESP32_CPU_FREQ=160 -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm -D WITH_SNMP=1 + -D DISPLAY_REDRAW_ON_CHANGE=1 build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + @@ -351,6 +352,13 @@ lib_deps = paulstoffregen/Time@1.6.1 0neblock/SNMP_Agent +[env:heltec_v4_r8_tft_portrait_repeater_observer_mqtt] +extends = env:heltec_v4_r8_tft_repeater_observer_mqtt +build_flags = + ${env:heltec_v4_r8_tft_repeater_observer_mqtt.build_flags} + -D ST7789_PORTRAIT_PROFILE=1 + -D DISPLAY_ROTATION=2 + [env:heltec_v4_r8_tft_room_server] extends = heltec_v4_r8_tft build_flags = @@ -393,6 +401,7 @@ build_flags = -D ESP32_CPU_FREQ=160 -D MQTT_WIFI_TX_POWER=WIFI_POWER_11dBm -D WITH_SNMP=1 + -D DISPLAY_REDRAW_ON_CHANGE=1 build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + @@ -410,6 +419,13 @@ lib_deps = paulstoffregen/Time@1.6.1 0neblock/SNMP_Agent +[env:heltec_v4_r8_tft_portrait_room_server_observer_mqtt] +extends = env:heltec_v4_r8_tft_room_server_observer_mqtt +build_flags = + ${env:heltec_v4_r8_tft_room_server_observer_mqtt.build_flags} + -D ST7789_PORTRAIT_PROFILE=1 + -D DISPLAY_ROTATION=2 + [env:heltec_v4_r8_tft_terminal_chat] extends = heltec_v4_r8_tft build_flags =