From 7ff11a2d6419a2dfe8148fc0bac8de390812c90e Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 8 Sep 2026 09:30:31 -0700 Subject: [PATCH] Restore the V4 WiFi setup QR with a one-pixel border --- examples/companion_radio/ui-new/UITask.cpp | 15 +++++++-- src/helpers/ui/DisplayDriver.cpp | 25 +++++++++++---- src/helpers/ui/DisplayDriver.h | 13 ++++++++ src/helpers/ui/WiFiSetupQrDisplay.h | 17 +++++++--- .../test_display_driver.cpp | 31 +++++++++++++++++++ test/test_heltec_v4_wifi_setup_page.py | 10 ++++-- 6 files changed, 94 insertions(+), 17 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 20541ce3..8d8431a2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -14,6 +14,7 @@ #if UI_WIFI_SETUP_HOME_PAGE == 1 #include #include + #include #endif #if defined(ESP32) #include @@ -124,9 +125,17 @@ static bool drawCompactCompanionWiFiSetupPage( bool wifi_configured) { if (display.width() > 128 || display.height() > 64) return false; - // Four small rows fit below the shared title bar and page dots. The OLED - // deliberately uses text instead of a QR: at this scale the SSID, portal IP, - // and physical-button action are more useful and remain readable. + // Use the whole compact screen for an active setup QR. Clear the shared + // title/page dots first so they cannot overlap its one-pixel white border. + if (setup_active) { + display.setCompactText(false); + display.setColor(UIColor::window_bkg); + display.fillRect(0, 0, display.width(), display.height()); + if (mesh::ui::drawWiFiSetupQr( + display, setup_ssid, setup_ip, true, "HOLD STOP")) return true; + } + + // Keep the readable text fallback for unsupported panels or QR payloads. display.setCompactText(true); display.setTextSize(1); display.setColor(UIColor::primary_txt); diff --git a/src/helpers/ui/DisplayDriver.cpp b/src/helpers/ui/DisplayDriver.cpp index e338ad0e..37a8c021 100644 --- a/src/helpers/ui/DisplayDriver.cpp +++ b/src/helpers/ui/DisplayDriver.cpp @@ -5,9 +5,15 @@ #include bool DisplayDriver::drawQrCode(const char* text, int x, int y, int size) { + return drawQrCodeWithBorder(text, x, y, size, -1); +} + +bool DisplayDriver::drawQrCodeWithBorder(const char* text, int x, int y, + int size, int border_pixels) { if (text == nullptr || text[0] == 0 || x < 0 || y < 0 || size <= 0 || size > width() || size > height() - || x > width() - size || y > height() - size) { + || x > width() - size || y > height() - size + || border_pixels < -1 || border_pixels > (size - 1) / 2) { return false; } @@ -28,13 +34,17 @@ bool DisplayDriver::drawQrCode(const char* text, int x, int y, int size) { if (!initialized) return false; static constexpr int quiet_zone = 4; - const int module_size = size / (qr.size + quiet_zone * 2); + const int module_size = border_pixels < 0 + ? size / (qr.size + quiet_zone * 2) + : (size - border_pixels * 2) / qr.size; if (module_size <= 0) return false; - const int used_size = module_size * (qr.size + quiet_zone * 2); + const int border = border_pixels < 0 + ? quiet_zone * module_size : border_pixels; + const int used_size = module_size * qr.size + border * 2; const int left = x + (size - used_size) / 2; const int top = y + (size - used_size) / 2; - const int matrix_left = left + quiet_zone * module_size; - const int matrix_top = top + quiet_zone * module_size; + const int matrix_left = left + border; + const int matrix_top = top + border; // Dark-theme OLED/TFT drivers use a dark window and light popup text; // e-paper reverses that convention. Always emit the conventional dark @@ -42,7 +52,10 @@ bool DisplayDriver::drawQrCode(const char* text, int x, int y, int size) { const ColorVal light = isEink() ? UIColor::window_bkg : UIColor::popup_txt; const ColorVal dark = isEink() ? UIColor::primary_txt : UIColor::window_bkg; setColor(light); - fillRect(x, y, size, size); + // An explicit border is exact, even if a larger QR version leaves unused + // space in the requested square. Do not add that space to the white border. + if (border_pixels < 0) fillRect(x, y, size, size); + else fillRect(left, top, used_size, used_size); setColor(dark); for (uint8_t row = 0; row < qr.size; ++row) { for (uint8_t column = 0; column < qr.size; ++column) { diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index d4e1e7b0..0bec7412 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -74,6 +74,10 @@ public: // fallback layout in place. #if defined(ESP32_PLATFORM) && defined(MESHCORE_HAS_REAL_DISPLAY) virtual bool drawQrCode(const char* text, int x, int y, int size); + // Explicit pixel border for compact setup screens; -1 keeps the normal + // four-module quiet zone. The matrix always uses whole pixels. + virtual bool drawQrCodeWithBorder(const char* text, int x, int y, int size, + int border_pixels); #else virtual bool drawQrCode(const char* text, int x, int y, int size) { (void)text; @@ -82,6 +86,15 @@ public: (void)size; return false; } + virtual bool drawQrCodeWithBorder(const char* text, int x, int y, int size, + int border_pixels) { + (void)text; + (void)x; + (void)y; + (void)size; + (void)border_pixels; + return false; + } #endif virtual uint16_t getTextWidth(const char* str) = 0; virtual bool getTouch(int* x, int* y) { diff --git a/src/helpers/ui/WiFiSetupQrDisplay.h b/src/helpers/ui/WiFiSetupQrDisplay.h index c5840145..170eed05 100644 --- a/src/helpers/ui/WiFiSetupQrDisplay.h +++ b/src/helpers/ui/WiFiSetupQrDisplay.h @@ -13,7 +13,8 @@ namespace ui { // panels so a normal setup IP has the full 66-pixel text row beside it. // Returns false when the payload or panel geometry cannot be represented. inline bool drawWiFiSetupQr(DisplayDriver& display, const char* ssid, - const char* address) { + const char* address, bool compact_border = false, + const char* action = nullptr) { char payload[256]; const char* password = nullptr; #ifdef WEBCONFIG_AP_PASSWORD @@ -27,18 +28,24 @@ inline bool drawWiFiSetupQr(DisplayDriver& display, const char* ssid, int qr_size = display.height() < display.width() / 2 ? display.height() : display.width() / 2; static constexpr int compact_qr_size = (21 + 4 * 2) * 2; - if (display.height() == 64 && qr_size >= compact_qr_size) { + if (compact_border && qr_size >= 44) { + // Version 1: 21 modules at 2 pixels, plus an exact one-pixel border. + qr_size = 44; + } else if (display.height() == 64 && qr_size >= compact_qr_size) { qr_size = compact_qr_size; } - if (!display.drawQrCode(payload, 0, 0, qr_size)) return false; + const bool rendered = compact_border + ? display.drawQrCodeWithBorder(payload, 0, 0, qr_size, 1) + : display.drawQrCode(payload, 0, 0, qr_size); + if (!rendered) return false; const int text_x = qr_size + 4; display.setTextSize(1); display.setColor(UIColor::primary_txt); display.setCursor(text_x, 2); - display.print("SCAN TO"); + display.print(action ? "SCAN JOIN" : "SCAN TO"); display.setCursor(text_x, 12); - display.print("JOIN:"); + display.print(action ? action : "JOIN:"); display.drawTextEllipsized( text_x, 22, display.width() - text_x, ssid); display.setCursor(text_x, 37); diff --git a/test/test_display_driver/test_display_driver.cpp b/test/test_display_driver/test_display_driver.cpp index 5b460799..f407d2a4 100644 --- a/test/test_display_driver/test_display_driver.cpp +++ b/test/test_display_driver/test_display_driver.cpp @@ -106,6 +106,7 @@ public: int qr_x = -1; int qr_y = -1; int qr_size = -1; + int qr_border = -1; QrTestDisplay(int width, int height, int glyph_width) : TestDisplay(width, height, glyph_width) {} @@ -116,6 +117,11 @@ public: qr_size = size; return true; } + bool drawQrCodeWithBorder(const char* text, int x, int y, int size, + int border) override { + qr_border = border; + return drawQrCode(text, x, y, size); + } }; } // namespace @@ -163,6 +169,31 @@ TEST(WiFiSetupQrPayload, EncodesOpenSetupNetwork) { EXPECT_LE(strlen(small_payload), 17U); } +TEST(DisplayDriver, V4QrUsesOnePixelBorderAndKeepsActionsBesideTheCode) { + QrTestDisplay display(128, 64, 6); + ASSERT_TRUE(mesh::ui::drawWiFiSetupQr( + display, "MC-F7F0", "192.168.4.1", true, "HOLD STOP")); + EXPECT_EQ(44, display.qr_size); + EXPECT_EQ(1, display.qr_border); + EXPECT_EQ(21 * 2 + 2 * display.qr_border, display.qr_size); + ASSERT_EQ(5U, display.rows.size()); + EXPECT_EQ("SCAN JOIN", display.rows[0].text); + EXPECT_EQ("HOLD STOP", display.rows[1].text); + EXPECT_EQ("192.168.4.1", display.rows.back().text); + for (const auto& row : display.rows) { + EXPECT_GE(row.x, display.qr_size + 4); + EXPECT_LE(row.x + display.getTextWidth(row.text.c_str()), display.width()); + EXPECT_LE(row.y + 8, display.height()); + } +} + +TEST(DisplayDriver, V4QrKeepsFallbackWhenRendererIsUnavailable) { + TestDisplay display(128, 64, 6); + EXPECT_FALSE(mesh::ui::drawWiFiSetupQr( + display, "MC-F7F0", "192.168.4.1", true, "HOLD STOP")); + EXPECT_TRUE(display.rows.empty()); +} + TEST(WiFiSetupQrPayload, EscapesProtectedNetworkFields) { char payload[96]; EXPECT_TRUE(mesh::ui::buildWiFiSetupQrPayload( diff --git a/test/test_heltec_v4_wifi_setup_page.py b/test/test_heltec_v4_wifi_setup_page.py index 4ef8bc54..4a4e452b 100644 --- a/test/test_heltec_v4_wifi_setup_page.py +++ b/test/test_heltec_v4_wifi_setup_page.py @@ -41,7 +41,7 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase): ) self.assertNotIn("UI_WIFI_SETUP_HOME_PAGE", R8_PROFILE.read_text()) - def test_compact_page_fits_128_by_64_without_qr_or_cli_copy(self): + def test_compact_page_restores_qr_with_a_readable_text_fallback(self): ui = UI.read_text(encoding="utf-8") start = ui.index("static bool drawCompactCompanionWiFiSetupPage") end = ui.index("\nstatic void drawCompanionWiFiSetupPage", start) @@ -53,8 +53,12 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase): self.assertIn('"HOLD STOP AP" : "HOLD START AP"', compact) self.assertIn('snprintf(open_ip, sizeof(open_ip), "OPEN %s", setup_ip)', compact) self.assertIn("display.drawTextEllipsized", compact) - self.assertNotIn("drawQrCode", compact) - self.assertNotIn("buildWiFiSetupQrPayload", compact) + self.assertIn("mesh::ui::drawWiFiSetupQr(", compact) + self.assertIn('display, setup_ssid, setup_ip, true, "HOLD STOP"', compact) + self.assertLess(compact.index("display.fillRect(0, 0,"), + compact.index("mesh::ui::drawWiFiSetupQr(")) + self.assertLess(compact.index("mesh::ui::drawWiFiSetupQr("), + compact.index('"SETUP AP ACTIVE"')) self.assertNotIn("start webconfig", compact.lower()) # Default size-one glyphs are six by eight logical pixels.