diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 39a31702c..1e9c89e00 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -466,6 +466,7 @@ class HomeScreen : public UIScreen { CompanionNodePrefs* _node_prefs; uint8_t _page; bool _shutdown_init; + bool _pairing_pin_visible = false; #if UI_WIFI_SETUP_HOME_PAGE == 1 bool _wifi_setup_was_active; bool _wifi_was_connected; @@ -960,7 +961,7 @@ public: #endif _page = HomePage::FIRST; } - bool isFirstPage() const { return _page == HomePage::FIRST; } + bool hasPairingPin() const { return _pairing_pin_visible; } #if COMPANION_FEATURE_READER bool isRadioPage() const { @@ -1027,6 +1028,7 @@ public: } int render(DisplayDriver& display) override { + _pairing_pin_visible = false; #if UI_POCKET_GUI return renderPocket(display); #endif @@ -1191,6 +1193,9 @@ public: display.setTextSize(pairing_value_size); mesh::ui::drawTextCenteredEllipsized( display, layout.pairing, layout.pairing_value_y, pairing_value); + _pairing_pin_visible = pairing_value == pairing_pin + && display.getTextWidth(pairing_value) <= layout.pairing.width + && layout.pairing_value_y + display.textLineHeight() <= display.height(); } else if (expanded_home) { display.setColor(UIColor::secondary_txt); display.setTextSize(3); @@ -1237,8 +1242,10 @@ public: pairing_value = pairing_pin; } - mesh::ui::drawBottomPairingBlock(display, home_content_top, - pairing_label, pairing_value); + const bool value_drawn = + mesh::ui::drawBottomPairingBlock(display, home_content_top, + pairing_label, pairing_value); + _pairing_pin_visible = value_drawn && !show_client; } else { mesh::ui::BoundedTextRows rows(display, {0, home_content_top, display.width(), @@ -2617,11 +2624,9 @@ void UITask::renderPairingBanner() { if (_display == NULL || !isPairingScreenActive()) return; const uint32_t pairing_pin = the_mesh.getBLEPin(); if (pairing_pin == 0) return; -#if defined(HELTEC_T096) - // The native T096 home page already shows the larger PIN at the bottom. - if (curr == home && static_cast(home)->isFirstPage() - && _display->height() > 64) return; -#endif + // Keep one complete PIN per frame. Other pages and layouts without their + // own PIN still need this overlay during pairing. + if (curr == home && static_cast(home)->hasPairingPin()) return; char prompt[24]; snprintf(prompt, sizeof(prompt), "PAIR PIN %06u", diff --git a/src/helpers/ui/CompanionHomeLayout.h b/src/helpers/ui/CompanionHomeLayout.h index f873570ab..8d80f3956 100644 --- a/src/helpers/ui/CompanionHomeLayout.h +++ b/src/helpers/ui/CompanionHomeLayout.h @@ -195,13 +195,14 @@ public: // PIN/connection status owns the bottom of every non-Indicator home screen, // including native 160x80 T096 and 250x122 MeshPocket panels. The caller must // not draw ordinary instructions/network rows in this same area. -inline void drawBottomPairingBlock(DisplayDriver& display, int content_top, +// Return true only when the complete value fits and was drawn. +inline bool drawBottomPairingBlock(DisplayDriver& display, int content_top, const char* label, const char* value) { const int bottom = display.height() - (display.height() > 64 ? 2 : 0); const int available = bottom - content_top; const int margin = display.width() >= 16 ? 4 : 0; const int width = display.width() - 2 * margin; - if (available <= 0 || width <= 0) return; + if (available <= 0 || width <= 0) return false; display.setTextSize(1); const int label_height = display.textLineHeight(); int value_size = 2; @@ -211,7 +212,7 @@ inline void drawBottomPairingBlock(DisplayDriver& display, int content_top, display.setTextSize(value_size = 1); } const int value_height = display.textLineHeight(); - if (value_height <= 0 || value_height > available) return; + if (value_height <= 0 || value_height > available) return false; // Prefer a readable complete PIN; omit its label on unusually short // viewports instead of colliding with the title or clipping the digits. const bool show_label = label_height > 0 @@ -227,6 +228,7 @@ inline void drawBottomPairingBlock(DisplayDriver& display, int content_top, } display.setTextSize(value_size); drawTextCenteredEllipsized(display, region, bottom - value_height, value); + return display.getTextWidth(value) <= width; } } // namespace ui diff --git a/test/test_companion_pairing_display.py b/test/test_companion_pairing_display.py index 87aaa3fae..a1476ae5f 100644 --- a/test/test_companion_pairing_display.py +++ b/test/test_companion_pairing_display.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run the production pairing UI methods and OLED selection with USB present.""" +"""Run production pairing UI methods and keep exactly one visible PIN.""" from pathlib import Path import subprocess import tempfile @@ -52,9 +52,10 @@ struct Interfaces { }; struct UIScreen {}; struct HomeScreen : UIScreen { - bool first = true; - bool isFirstPage() const { return first; } - void showFirstPage() { first = true; } + bool pairing_pin_visible = false; + bool hasPairingPin() const { return pairing_pin_visible; } + void showFirstPage() {} + void flushPocketSettings() {} }; struct MsgPreviewScreen : UIScreen { bool hasMessages() const { return false; } }; struct Mesh { @@ -96,23 +97,28 @@ struct UITask { }; @METHODS@ static void homePairing(UITask* _task, TestDisplay& display) { + bool& _pairing_pin_visible = _task->home_screen.pairing_pin_visible; + _pairing_pin_visible = false; +#if !UI_POCKET_GUI const int home_content_top = 20; @HOME_SELECTION@ +#endif } static void checkPairingPresentation(UITask& ui, TestDisplay& display) { display.text.clear(); homePairing(&ui, display); + const bool home_pin = std::count(display.text.begin(), display.text.end(), "246810") == 1; + assert(ui.home_screen.hasPairingPin() == home_pin); ui.renderPairingBanner(); -#if defined(HELTEC_T096) - if (display.height() > 64) { + if (home_pin) { assert(std::count(display.text.begin(), display.text.end(), "246810") == 1); assert(std::count(display.text.begin(), display.text.end(), "PAIR PIN 246810") == 0); // Another home page or asynchronous screen still needs the top PIN. - ui.home_screen.first = false; + ui.home_screen.pairing_pin_visible = false; display.text.clear(); ui.renderPairingBanner(); assert(display.text.back() == "PAIR PIN 246810"); - ui.home_screen.showFirstPage(); + ui.home_screen.pairing_pin_visible = true; ui.curr = ui.msg_preview; display.text.clear(); ui.renderPairingBanner(); @@ -120,7 +126,6 @@ static void checkPairingPresentation(UITask& ui, TestDisplay& display) { ui.curr = ui.home; return; } -#endif assert(display.text.back() == "PAIR PIN 246810"); } int main(int argc, char** argv) { @@ -155,7 +160,13 @@ int main(int argc, char** argv) { assert(!ui.isPairingScreenActive() && !ui._pairing_from_button); display.text.clear(); homePairing(&ui, display); - assert(display.text.back() == "246810"); // USB must not replace the idle PIN. +#if UI_POCKET_GUI + assert(display.text.empty()); // Pocket's board view has no standalone PIN. +#else + if (display.height() >= 64) + assert(display.text.back() == "246810"); // USB must not replace the idle PIN. + else assert(display.text.empty()); // Too short: only the pairing overlay fits. +#endif interfaces.pending = true; ui.servicePairingState(); @@ -164,7 +175,10 @@ int main(int argc, char** argv) { assert(!ui.isPairingScreenActive()); display.text.clear(); homePairing(&ui, display); - assert(display.text.back() == "CONNECTED"); +#if !UI_POCKET_GUI + if (display.height() >= 64) assert(display.text.back() == "CONNECTED"); + else assert(display.text.empty()); +#endif // Explicit off must remain off; disabling BLE must not reveal a stale PIN. interfaces.ble = false; @@ -183,7 +197,11 @@ int main(int argc, char** argv) { assert(display.isOn() && !ui.isPairingScreenActive()); display.text.clear(); homePairing(&ui, display); - assert(display.text.back() == "CONNECTED"); // Only USB status remains. +#if !UI_POCKET_GUI + if (display.height() >= 64) + assert(display.text.back() == "CONNECTED"); // Only USB status remains. + else assert(display.text.empty()); +#endif } ''' @@ -213,8 +231,11 @@ class CompanionPairingDisplayTests(unittest.TestCase): cpp, binary = Path(temp) / "test.cpp", Path(temp) / "test" cpp.write_text(program) for profile, flags, sizes in ( - ("oled", [], [(128, 64)]), + ("shared", [], [(128, 64), (160, 80), (80, 160), (128, 128), + (240, 135), (320, 170), (320, 240), (240, 320), + (250, 122), (200, 200), (128, 24)]), ("t096", ["-DHELTEC_T096=1"], [(160, 80), (80, 160), (128, 64)]), + ("pocket", ["-DUI_POCKET_GUI=1"], [(250, 122), (200, 200)]), ): with self.subTest(profile=profile): result = subprocess.run([ diff --git a/test/test_home_text_spacing.py b/test/test_home_text_spacing.py index bfbef1cfa..d222cf7c7 100644 --- a/test/test_home_text_spacing.py +++ b/test/test_home_text_spacing.py @@ -21,17 +21,20 @@ ColorVal UIColor::window_bkg=0, UIColor::title_bkg=0, UIColor::title_txt=1; ColorVal UIColor::primary_txt=1, UIColor::secondary_txt=1, UIColor::warning_txt=1; ColorVal UIColor::popup_bkg=0, UIColor::popup_txt=1, UIColor::corp_blue=1; #define PRESS_LABEL "Press" +#define SELECT_LABEL "Select" struct Display : DisplayDriver { struct Row { mesh::ui::DisplayRegion box; std::string text; }; std::vector rows; - int size=1,x=0,y=0,small,large; - Display(int w,int h,int a,int b):DisplayDriver(w,h),small(a),large(b) {} + int size=1,x=0,y=0,small,large,render_size; + Display(int w,int h,int a,int b,int r=0):DisplayDriver(w,h),small(a),large(b),render_size(r) {} + int renderWidth() const override { return render_size?render_size:width(); } + int renderHeight() const override { return render_size?render_size:height(); } bool isOn() override { return true; } void turnOn() override {} void turnOff() override {} void clear() override { rows.clear(); } void startFrame(ColorVal=0) override { clear(); } void endFrame() override {} void setTextSize(int s) override { size=s; } - int textLineHeight() override { return size==1?small:large; } + int textLineHeight() override { return size==1?small:size==2?large:size*small; } void setColor(ColorVal) override {} void setCursor(int a,int b) override { x=a;y=b; } uint16_t getTextWidth(const char* text) override { @@ -86,24 +89,37 @@ struct Board { int getBattMilliVolts(){return 4100;} }; SCENARIOS = r''' int main() { - for(auto profile: {std::vector{128,64,8,16}, {160,80,8,16}, - {250,122,21,27}, {250,122,22,29}, {200,200,22,29}, {240,135,10,20}}) { - Display d(profile[0],profile[1],profile[2],profile[3]); Task task; - for(int state=0;state<5;++state) for(int count:{0,99,99999}) { +#ifdef UI_DEDICATED_PAIRING_BLOCK + const std::vector> profiles={{160,160,8,16,320},{160,160,8,16,480}}; +#else + const std::vector> profiles={{128,64,8,16}, {160,80,8,16}, + {250,122,21,27}, {250,122,22,29}, {200,200,22,29}, {240,135,10,20}}; +#endif + for(auto profile:profiles) { + Display d(profile[0],profile[1],profile[2],profile[3],profile.size()>4?profile[4]:0); Task task; + bool pairing_pin_visible=true; + for(int state=0;state<5;++state) for(int count:{0,99,99999}) for(uint32_t pin:{123456U,12345U}) { task.enabled=state!=2; task.connected=state==1; task.prompt=state==4; - task.count=count; the_mesh.pin=state==3?0:123456; - d.clear(); home(d,&task); + task.count=count; the_mesh.pin=state==3?0:pin; + d.clear(); home(d,&task,pairing_pin_visible); bool pin_expected=task.enabled && !task.connected && the_mesh.pin && (!task.prompt || d.height()>64); - assert((d.find("123456")!=nullptr)==pin_expected); + char pin_text[16]; snprintf(pin_text,sizeof(pin_text),"%06u",(unsigned)the_mesh.pin); + assert((d.find(pin_text)!=nullptr)==pin_expected); + assert(pairing_pin_visible==(d.find(pin_text)!=nullptr)); if(pin_expected) { - auto pin=d.find("123456"); + auto pin=d.find(pin_text); assert(pin->box.y>=d.height()/2); +#ifndef UI_DEDICATED_PAIRING_BLOCK assert(pin->box.bottom()==d.height()-(d.height()>64?2:0)); +#endif assert(!d.find("Press: inbox")); } - if(task.connected) assert(d.find("CONNECTED")); + if(task.connected) assert(d.find("CONNECTED") || d.find("LINKED")); } + // A render on another page must clear a PIN retained from the first page. + pairing_pin_visible=true; d.clear(); home(d,&task,pairing_pin_visible,1); + assert(!pairing_pin_visible); Prefs prefs; Board board; d.clear(); repeater(&d,&prefs,&board); assert(d.rows.size()==5); // Never solve overlap by losing a status row. d.clear(); d.setTextSize(1); @@ -118,9 +134,10 @@ int main() { } // Sweep font sizes and short viewports: retain complete digits, omit only // a label that cannot fit, and never draw outside the reserved lower band. - for(int h=16;h<=160;++h) for(int line=8;line<=32;++line) { - Display d(160,h,line,2*line); - mesh::ui::drawBottomPairingBlock(d,h/3,"BLUETOOTH PIN","012345"); + for(int width:{32,80,160}) for(int h=16;h<=160;++h) for(int line=8;line<=32;++line) { + Display d(width,h,line,2*line); + const bool pin_visible=mesh::ui::drawBottomPairingBlock(d,h/3,"BLUETOOTH PIN","012345"); + assert(pin_visible==(d.find("012345")!=nullptr)); for(const auto& row:d.rows) assert(row.box.y>=h/3); } } @@ -130,23 +147,26 @@ int main() { class HomeTextSpacingTest(unittest.TestCase): def test_actual_home_and_repeater_branches(self): source = (ROOT / 'examples/companion_radio/ui-new/UITask.cpp').read_text() - start = source.index(' const int normal_line =') + start = source.index(' _pairing_pin_visible = false;') metrics = source[start:source.index(' display.setColor', start)] start = source.index(' if (_page == HomePage::FIRST) {', start) home = source[start:source.index('#if UI_MESSAGES_HOME_PAGE == 1', start)] repeater = (ROOT / 'examples/simple_repeater/UITask.cpp').read_text() start = repeater.index(' // Reserve a full measured font-height') repeater = repeater[start:repeater.index('\n }\n}', start)] - implementation = ('void home(DisplayDriver& display, Task* _task) {\n' - 'display.setTextSize(1); char tmp[80]; int _page=0;\n' + implementation = ('void home(DisplayDriver& display, Task* _task, ' + 'bool& _pairing_pin_visible, int _page=0) {\n' + 'char tmp[80];\n' + metrics + 'display.drawTextEllipsized(0,2,display.width(),"Node name");\n' + home + '\n}}\n' 'void repeater(DisplayDriver* _display, Prefs* _node_prefs, Board* _board) {' 'char tmp[80];\n' + repeater + '\n}\n') - for wifi in (False, True): - with self.subTest(wifi=wifi), tempfile.TemporaryDirectory() as temp: + for wifi, dedicated in ((False, False), (True, False), (False, True), (True, True)): + with self.subTest(wifi=wifi, dedicated=dedicated), tempfile.TemporaryDirectory() as temp: binary = str(Path(temp) / 'spacing') flags = ['-DWIFI_SSID="test"', '-DWITH_MQTT_BRIDGE'] if wifi else [] + if dedicated: + flags.append('-DUI_DEDICATED_PAIRING_BLOCK=1') result = subprocess.run(['c++', '-std=c++11', '-fsanitize=address,undefined', '-fno-pie', '-no-pie', '-I'+str(ROOT/'src'), *flags, '-x', 'c++', '-', '-o', binary],