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.
This commit is contained in:
agessaman committed 2026-08-28 14:37:03 -07:00
1 parent 86c4849e55
commit f3c81b559b
12 files changed
+120 -9

No files matched your search

+19
View File
@@ -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;
+2
View File
@@ -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;
+19
View File
@@ -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;
+2
View File
@@ -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;
+14
View File
@@ -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];
+1
View File
@@ -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
+9 -4
View File
@@ -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<uint16_t>(_timeout_s);
_prefs->display_flip = static_cast<uint8_t>(_flip);
}
};
+4
View File
@@ -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;
+2
View File
@@ -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;
+15 -2
View File
@@ -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;
}
+4 -3
View File
@@ -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;
@@ -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}}");