From f3c81b559b3c8ab994e7eb8d5093d22cf43f510f Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 28 Aug 2026 14:37:03 -0700 Subject: [PATCH] feat(display): add runtime display.flip for panel orientation `set display.flip 0|1` (also off/on) turns the panel 180 degrees from its compiled DISPLAY_ROTATION, persisted in MQTTPrefs alongside display.timeout and applied live without a reboot. Adding 2 to the compiled rotation rather than setting an absolute value keeps portrait portrait and landscape landscape, so the DisplayViewport geometry never changes with it and the setting cannot produce a nonsensical mix. DisplayDriver gains a defaulted no-op setFlipped(), so no other display driver is affected. The compiled rotation was verified identical across `pio run` and `build.sh` on two machines (movi a11, 2 at the setRotation call site), yet the panel read upside down for one tester and upright for another - which is what a board mounted either way up looks like. No single compiled constant satisfies both, so orientation becomes a setting rather than another rebuild. Runtime-only, like display_timeout_secs: LegacyV1MQTTPrefs and the frozen binary payload sizes are untouched, and the JSON group is an append that older firmware skips. --- examples/simple_repeater/UITask.cpp | 19 ++++++++++++ examples/simple_repeater/UITask.h | 2 ++ examples/simple_room_server/UITask.cpp | 19 ++++++++++++ examples/simple_room_server/UITask.h | 2 ++ src/helpers/CommonCLI_Observer.cpp | 14 +++++++++ src/helpers/MQTTDefaults.h | 1 + src/helpers/MQTTPrefsSerializer.h | 13 ++++++--- src/helpers/MQTTPrefsStorage.h | 4 +++ src/helpers/ui/DisplayDriver.h | 2 ++ src/helpers/ui/ST7789LCDDisplay.cpp | 17 +++++++++-- src/helpers/ui/ST7789LCDDisplay.h | 7 +++-- .../test_mqtt_prefs_serializer.cpp | 29 +++++++++++++++++++ 12 files changed, 120 insertions(+), 9 deletions(-) diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 70fa78912..f845577cc 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -32,6 +32,23 @@ static inline bool millisReached(unsigned long now, unsigned long deadline) { return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; } +// Applies `display.flip` when it changes, forcing a complete repaint because +// the panel's existing contents are now the wrong way up. +void UITask::applyDisplayFlip() { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs == NULL || _observer_prefs->display_flip == _flip_seen) return; + _flip_seen = _observer_prefs->display_flip; + _display->setFlipped(_flip_seen != 0); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; +#endif +} + // `display.timeout` when the observer prefs are available, otherwise the // compiled-in default. Read on every use so a `set display.timeout` takes // effect immediately. @@ -72,6 +89,7 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only #endif _display->turnOn(); + applyDisplayFlip(); #ifdef DISPLAY_TOUCH_TOGGLE _touch.begin(); #endif @@ -407,6 +425,7 @@ void UITask::loop() { // `_auto_off` is only armed on activity, so a timeout changed at runtime has // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. + applyDisplayFlip(); unsigned long timeout = displayTimeoutMillis(); if (timeout != _timeout_seen) { _timeout_seen = timeout; diff --git a/examples/simple_repeater/UITask.h b/examples/simple_repeater/UITask.h index ad6c5de17..65ca1674c 100644 --- a/examples/simple_repeater/UITask.h +++ b/examples/simple_repeater/UITask.h @@ -55,6 +55,8 @@ class UITask { MQTTPrefs* _observer_prefs = NULL; #endif unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change + uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply + void applyDisplayFlip(); unsigned long displayTimeoutMillis() const; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 2e9818e58..998cc7e29 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -31,6 +31,23 @@ static inline bool millisReached(unsigned long now, unsigned long deadline) { return (int32_t)((uint32_t)now - (uint32_t)deadline) >= 0; } +// Applies `display.flip` when it changes, forcing a complete repaint because +// the panel's existing contents are now the wrong way up. +void UITask::applyDisplayFlip() { +#ifdef WITH_MQTT_BRIDGE + if (_observer_prefs == NULL || _observer_prefs->display_flip == _flip_seen) return; + _flip_seen = _observer_prefs->display_flip; + _display->setFlipped(_flip_seen != 0); +#ifdef DISPLAY_REDRAW_ON_CHANGE + _frame_valid = false; +#endif +#ifdef DISPLAY_ACTIVITY_DASHBOARD + _rows_valid = false; +#endif + _next_refresh = 0; +#endif +} + // `display.timeout` when the observer prefs are available, otherwise the // compiled-in default. Read on every use so a `set display.timeout` takes // effect immediately. @@ -68,6 +85,7 @@ void UITask::begin(NodePrefs* node_prefs, const char* build_date, const char* fi ObserverDashboard::applyDarkPalette(); // retunes UIColor for this target only #endif _display->turnOn(); + applyDisplayFlip(); #ifdef DISPLAY_TOUCH_TOGGLE _touch.begin(); #endif @@ -386,6 +404,7 @@ void UITask::loop() { // `_auto_off` is only armed on activity, so a timeout changed at runtime has // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. + applyDisplayFlip(); unsigned long timeout = displayTimeoutMillis(); if (timeout != _timeout_seen) { _timeout_seen = timeout; diff --git a/examples/simple_room_server/UITask.h b/examples/simple_room_server/UITask.h index 37005e184..96f63ac4b 100644 --- a/examples/simple_room_server/UITask.h +++ b/examples/simple_room_server/UITask.h @@ -52,6 +52,8 @@ class UITask { MQTTPrefs* _observer_prefs = NULL; #endif unsigned long _timeout_seen = 0; // to notice a live `display.timeout` change + uint8_t _flip_seen = 0xFF; // 0xFF forces the first apply + void applyDisplayFlip(); unsigned long displayTimeoutMillis() const; diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 4cf668450..9b2b2d302 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -266,6 +266,18 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf } } } + } else if (memcmp(config, "display.flip ", 13) == 0) { + const char* val = &config[13]; + if (strcmp(val, "0") == 0 || strcmp(val, "off") == 0) { + _mqtt_prefs.display_flip = 0; + } else if (strcmp(val, "1") == 0 || strcmp(val, "on") == 0) { + _mqtt_prefs.display_flip = 1; + } else { + strcpy(reply, "Error: display.flip must be 0/1 (or off/on)"); + return true; + } + if (!persistObserverPrefs(reply)) return true; + strcpy(reply, _mqtt_prefs.display_flip ? "OK - display rotated 180" : "OK - display upright"); } else if (memcmp(config, "display.timeout ", 16) == 0) { const char* val = &config[16]; bool all_digits = (*val != '\0'); @@ -887,6 +899,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.radio_watchdog_minutes); } else if (memcmp(config, "display.timeout", 15) == 0) { sprintf(reply, "> %d", (uint32_t)_mqtt_prefs.display_timeout_secs); + } else if (memcmp(config, "display.flip", 12) == 0) { + strcpy(reply, _mqtt_prefs.display_flip ? "> on" : "> off"); #ifdef WITH_MQTT_BRIDGE } else if (memcmp(config, "mqtt.origin", 11) == 0) { char effective_origin[32]; diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index 701401331..c390da0b4 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -111,6 +111,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; + prefs->display_flip = 0; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsSerializer.h b/src/helpers/MQTTPrefsSerializer.h index 42e564774..f783b52c4 100644 --- a/src/helpers/MQTTPrefsSerializer.h +++ b/src/helpers/MQTTPrefsSerializer.h @@ -368,19 +368,24 @@ class MQTTPrefsSerializer : public ConfigSerializer { class DisplayPrefs : public ConfigSerializer { MQTTPrefs* _prefs; - int32_t _timeout_s; - bool _seen_timeout = false; + int32_t _timeout_s, _flip; + bool _seen_timeout = false, _seen_flip = false; protected: - void structure() override { defStrict("timeout_s", _timeout_s, _seen_timeout); } + void structure() override { + defStrict("timeout_s", _timeout_s, _seen_timeout); + defStrict("flip", _flip, _seen_flip); + } public: explicit DisplayPrefs(MQTTPrefs* prefs) - : _prefs(prefs), _timeout_s(prefs->display_timeout_secs) {} + : _prefs(prefs), _timeout_s(prefs->display_timeout_secs), _flip(prefs->display_flip) {} void apply(bool* repaired) { if (_timeout_s < 0 || _timeout_s > DISPLAY_TIMEOUT_MAX_SECS) { _timeout_s = DISPLAY_TIMEOUT_DEFAULT_SECS; *repaired = true; } + if (_flip < 0 || _flip > 1) { _flip = 0; *repaired = true; } _prefs->display_timeout_secs = static_cast(_timeout_s); + _prefs->display_flip = static_cast(_flip); } }; diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index 1ee4a99f2..7401825b8 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -122,6 +122,10 @@ struct MQTTPrefs { // only - deliberately absent from LegacyV1MQTTPrefs, so the frozen binary // layout and its four payload sizes are unchanged. uint16_t display_timeout_secs; + + // Rotate the panel 180 degrees from its compiled DISPLAY_ROTATION, for boards + // mounted the other way up. Runtime only, like display_timeout_secs. + uint8_t display_flip; }; static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index b76a1b6ca..74d3f2f68 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -27,6 +27,8 @@ public: virtual void turnOff() = 0; virtual void clear() = 0; virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; + // Rotate 180 degrees from the compiled orientation. No-op where unsupported. + virtual void setFlipped(bool flipped) { } virtual void setTextSize(int sz) = 0; virtual void setColor(ColorVal c) = 0; virtual void setCursor(int x, int y) = 0; diff --git a/src/helpers/ui/ST7789LCDDisplay.cpp b/src/helpers/ui/ST7789LCDDisplay.cpp index 6ccdd24ba..4d37ebd3a 100644 --- a/src/helpers/ui/ST7789LCDDisplay.cpp +++ b/src/helpers/ui/ST7789LCDDisplay.cpp @@ -33,6 +33,19 @@ static DisplayViewport::Geometry portraitViewport(int16_t physical_width, int16_ } #endif +// The compiled orientation, optionally turned 180 degrees by `display.flip`. +// Adding 2 keeps portrait portrait and landscape landscape, so the viewport +// geometry below never has to change with it. +uint8_t ST7789LCDDisplay::effectiveRotation() const { + return (uint8_t)((DISPLAY_ROTATION + (_flipped ? 2 : 0)) & 3); +} + +void ST7789LCDDisplay::setFlipped(bool flipped) { + if (_flipped == flipped) return; + _flipped = flipped; + if (_panel_ready) display.setRotation(effectiveRotation()); +} + bool ST7789LCDDisplay::i2c_probe(TwoWire& wire, uint8_t addr) { return true; } @@ -88,7 +101,7 @@ bool ST7789LCDDisplay::begin() { #endif display.init(DISPLAY_WIDTH, DISPLAY_HEIGHT); - display.setRotation(DISPLAY_ROTATION); + display.setRotation(effectiveRotation()); display.setSPISpeed(40e6); @@ -107,9 +120,9 @@ bool ST7789LCDDisplay::begin() { if (PIN_TFT_LEDA_CTL != -1) { digitalWrite(PIN_TFT_LEDA_CTL, PIN_TFT_LEDA_CTL_ACTIVE); } - _panel_ready = true; #endif + _panel_ready = true; _isOn = true; } diff --git a/src/helpers/ui/ST7789LCDDisplay.h b/src/helpers/ui/ST7789LCDDisplay.h index baa72bf01..24c5a04b0 100644 --- a/src/helpers/ui/ST7789LCDDisplay.h +++ b/src/helpers/ui/ST7789LCDDisplay.h @@ -13,9 +13,8 @@ class ST7789LCDDisplay : public DisplayDriver { #endif Adafruit_ST7789 display; bool _isOn; -#ifdef HELTEC_V4_R8_TFT - bool _panel_ready = false; // panel configured once; wake is backlight-only -#endif + bool _panel_ready = false; // panel has been configured at least once + bool _flipped = false; uint16_t _color; RefCountedDigitalPin* _peripher_power; @@ -27,6 +26,7 @@ class ST7789LCDDisplay : public DisplayDriver { void printFitted(const char* str, uint16_t available_width); #endif + uint8_t effectiveRotation() const; bool i2c_probe(TwoWire& wire, uint8_t addr); public: #ifdef USE_PIN_TFT @@ -59,6 +59,7 @@ public: void turnOff() override; void clear() override; void startFrame(ColorVal bkg = UIColor::window_bkg) override; + void setFlipped(bool flipped) override; void setTextSize(int sz) override; void setColor(ColorVal c) override; void setCursor(int x, int y) override; diff --git a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp index 8c8bf32c0..421ea52b0 100644 --- a/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp +++ b/test/test_mqtt_prefs_serializer/test_mqtt_prefs_serializer.cpp @@ -79,6 +79,7 @@ static MQTTPrefs defaults() { prefs.alert_min_interval_min = 60; prefs.mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; prefs.display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS; + prefs.display_flip = 0; strcpy(prefs.snmp_community, "public"); for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) { strcpy(prefs.mqtt_slot_preset[i], "none"); @@ -396,6 +397,34 @@ TEST(MQTTPrefsSerializer, DisplayTimeoutRoundTrips) { } } +TEST(MQTTPrefsSerializer, DisplayFlipRoundTripsAndRepairs) { + MQTTPrefs source = defaults(); + EXPECT_EQ(0, source.display_flip); + source.display_flip = 1; + + OutputStream output; + MQTTPrefsSerializer writer(&source); + ASSERT_TRUE(writer.saveSerial(output)); + + MQTTPrefs loaded = defaults(); + InputStream input(output.text()); + MQTTPrefsSerializer reader(&loaded); + ASSERT_TRUE(reader.loadSerial(input)); + bool repaired = false; + ASSERT_TRUE(reader.apply(&repaired)); + EXPECT_FALSE(repaired); + EXPECT_EQ(1, loaded.display_flip); + + MQTTPrefs prefs = defaults(); + InputStream bogus("{version:1,display:{flip:7}}"); + MQTTPrefsSerializer bogus_serializer(&prefs); + ASSERT_TRUE(bogus_serializer.loadSerial(bogus)); + repaired = false; + ASSERT_TRUE(bogus_serializer.apply(&repaired)); + EXPECT_TRUE(repaired); + EXPECT_EQ(0, prefs.display_flip); +} + TEST(MQTTPrefsSerializer, RepairsDisplayTimeoutOutOfRange) { MQTTPrefs prefs = defaults(); InputStream input("{version:1,display:{timeout_s:99999}}");