Fix PIN spacing and keep message navigation hints visible

Use measured text rows for companion and repeater layouts, retain the V4 6px footer, and add overlap and navigation regressions. Correct the MQTT logging sleep documentation contract test.
This commit is contained in:
mikecarper committed 2026-09-09 10:20:22 -07:00
1 parent 726598559a
commit 7241adb146
16 files changed
+343 -102

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# Home-screen text spacing
On non-Indicator companions, a visible Bluetooth PIN or connection status owns
a separate bottom-aligned block. It replaces the ordinary inbox instruction,
clock and WiFi rows while displayed. The PIN stays below the inbox title; the
label and value use measured font heights, including the MeshPocket's larger
e-paper font. An unusually short viewport may omit the label, never overlap
the title or clip the PIN. Indicator's dedicated pairing layout is unchanged.
The repeater home page also stacks measured font-height rows instead of fixed
ten-pixel offsets. Long names and status lines are ellipsized horizontally.
Run `python3 -m unittest discover -s test -p test_home_text_spacing.py -v` on a
host with a C++ compiler. This renders the actual companion and repeater home
branches, recording every text rectangle and rejecting overlaps or clipping.
It covers 128x64 OLED, 160x80 T096, 250x122 MeshPocket and larger viewports,
PIN/connected/disabled/prompt states, WiFi variants, long strings and a sweep
of font heights. It also checks the static reader hint. The separate native
display and T096 tests exercise the driver drawing/measurement methods.
Hardware acceptance: check that the full six-digit PIN is near the bottom,
with no text over it; connect/disconnect Bluetooth and verify no stale digits;
then check the repeater's five rows and the V4's non-blinking reader footer.
+8 -7
View File
@@ -32,18 +32,19 @@ Readers without the button hint retain the y=14 origin and y=22 message text.
On V4 and other single-button builds using this reader, the home screen says
`hold button: inbox`. Hold the user button for about 1.2 seconds to open it.
The bottom alternates between `<- 2 tap 1 tap -> long press: exit` and
`<<- 4 tap 3 tap ->>` every three seconds. Double tap goes to the previous
The bottom continuously shows `4 <<- 2 <- tap -> 1 ->> 3 hold: Exit` without alternating or
blinking. Double tap goes to the previous
message, one tap advances, three taps select the next channel, and four taps
select the previous channel. Channel selection cycles through All, configured
channels, and direct messages, starting at the newest message in each filter.
The header shows `All`, `Ch N`, or `DM`, including when a channel is empty.
Hold to return home; advancing past the last message also returns home.
The hints share the same footer space and use `hold: X` when the longer exit
label does not fit. Both fit in the V4's existing 8-pixel footer at y=56, leaving
five message rows. Narrower screens split the hint across lines; switching
hints does not change the space reserved for text. The hints appear even when
the inbox is empty. These are button taps. Touchscreen and joystick builds retain
The complete hint fits in the V4's existing 8-pixel footer at y=56, leaving
five message rows. The footer keeps the same 6px font, using 108 pixels with
10-pixel margins on the V4. Narrower screens first use the compact `hold:X`
hint without the extra spaces or arrow dashes, then split the
same controls across fixed lines. The hint appears even when the inbox is
empty. These are button taps. Touchscreen and joystick builds retain
instructions appropriate to their controls. During the first eight
seconds after startup, holding the button on an ordinary home page enters
CLI rescue instead; wait for that startup window to finish before opening
+16 -51
View File
@@ -656,7 +656,11 @@ public:
#else
display.setTextSize(2);
sprintf(tmp, "INBOX: %d", _task->getPreviewCount());
display.drawTextCentered(display.width() / 2, body_top + 2, tmp);
const int home_content_top = body_top + 2 + display.textLineHeight()
+ (display.height() > 64 ? 2 : 0);
mesh::ui::drawTextCenteredEllipsized(display,
{0, body_top + 2, display.width(), display.textLineHeight()},
body_top + 2, tmp);
#endif
#ifdef UI_DEDICATED_PAIRING_BLOCK
const mesh::ui::CompanionHomeLayout layout =
@@ -770,15 +774,7 @@ public:
mesh::ui::shouldDisplayBluetoothPairingPin(
bluetooth_enabled, bluetooth_connected, bluetooth_pin)
&& (!_task->isPairingPromptActive() || display.height() > 64);
const bool compact_pairing =
mesh::ui::usesCompactCompanionPairingLayout(
display.width(), display.height())
&& (bluetooth_connected || show_bluetooth_pin);
if (compact_pairing) {
const mesh::ui::CompactCompanionPairingLayout layout =
mesh::ui::makeCompactCompanionPairingLayout(
display.width(), display.height());
if (bluetooth_connected || show_bluetooth_pin) {
char pairing_pin[16];
const char* pairing_label = bluetooth_connected
? "BLUETOOTH" : "BLUETOOTH PIN";
@@ -789,22 +785,15 @@ public:
pairing_value = pairing_pin;
}
// This repaint deliberately removes the ordinary instruction and
// Wi-Fi/IP rows. Both occupy this same lower area on a 128x64 screen.
display.setColor(UIColor::title_bkg);
mesh::ui::clearDisplayRegion(display, layout.pairing);
display.setColor(UIColor::title_txt);
display.setTextSize(1);
mesh::ui::drawTextCenteredEllipsized(
display, layout.pairing, layout.pairing_label_y, pairing_label);
display.setTextSize(2);
mesh::ui::drawTextCenteredEllipsized(
display, layout.pairing, layout.pairing_value_y, pairing_value);
mesh::ui::drawBottomPairingBlock(display, home_content_top,
pairing_label, pairing_value);
} else {
mesh::ui::BoundedTextRows rows(display,
{0, home_content_top, display.width(),
display.height() - home_content_top});
display.setTextSize(1);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 43,
PRESS_LABEL ": inbox");
rows.draw(PRESS_LABEL ": inbox");
#ifdef UI_SHOW_CLOCK
display.setTextSize(3);
@@ -813,10 +802,10 @@ public:
now += (int32_t)tz * 3600;
DateTime dt (now);
sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute());
display.drawTextCentered(display.width() / 2, 60, tmp);
rows.draw(tmp);
display.setTextSize(1);
sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year());
display.drawTextCentered(display.width() / 2, 80, tmp);
rows.draw(tmp);
#endif
#ifdef WIFI_SSID
@@ -832,31 +821,8 @@ public:
strcpy(tmp, "WiFi: SETUP");
}
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 54, tmp);
rows.draw(tmp);
#endif
if (bluetooth_connected) {
display.setColor(UIColor::warning_txt);
display.setTextSize(1);
#ifdef UI_SHOW_CLOCK
display.drawTextCentered(display.width() / 2, 110,
"< Connected >");
#else
display.drawTextCentered(display.width() / 2, 43,
"< Connected >");
#endif
} else if (show_bluetooth_pin) { // BT pin
display.setColor(UIColor::warning_txt);
snprintf(tmp, sizeof(tmp), "Pin:%06u",
(unsigned int)bluetooth_pin);
#ifdef UI_SHOW_CLOCK
display.setTextSize(1);
display.drawTextCentered(display.width() / 2, 110, tmp);
#else
display.setTextSize(2);
display.drawTextCentered(display.width() / 2, 43, tmp);
#endif
}
}
#endif
#if UI_MESSAGES_HOME_PAGE == 1
@@ -1500,8 +1466,7 @@ public:
#endif
const mesh::ui::ButtonReaderHintLayout hint =
mesh::ui::makeButtonReaderHintLayout(
reader_text, hint_line_height, body_bottom,
(millis() / 3000U) % 2 != 0);
reader_text, hint_line_height, body_bottom);
body_bottom = hint.top;
DisplayDriver& header = reader_text;
#else
+11 -14
View File
@@ -4,6 +4,7 @@
#include <helpers/UsbLogging.h>
#include <helpers/CommonCLI.h>
#include <helpers/ui/WiFiSetupQrDisplay.h>
#include <helpers/ui/CompanionHomeLayout.h>
#ifdef DISPLAY_REDRAW_ON_CHANGE
#include <helpers/ui/DisplayFrameSignature.h>
@@ -209,42 +210,38 @@ void UITask::renderCurrScreen() {
renderDashboard();
return;
#endif
// node name
_display->setCursor(0, 0);
// Reserve a full measured font-height for each row on OLED/TFT/e-paper.
mesh::ui::BoundedTextRows rows(*_display,
{0, 0, _display->width(), _display->height()});
_display->setTextSize(1);
_display->setColor(UIColor::primary_txt);
_display->print(_node_prefs->node_name);
rows.draw(_node_prefs->node_name, false);
// freq / sf
_display->setCursor(0, 20);
sprintf(tmp, "FREQ: %06.3f SF%d", _node_prefs->freq, _node_prefs->sf);
_display->print(tmp);
rows.draw(tmp, false);
// bw / cr
_display->setCursor(0, 30);
sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr);
_display->print(tmp);
rows.draw(tmp, false);
#ifdef WITH_MQTT_BRIDGE
// Display IP address for MQTT bridge devices
if (WiFi.status() == WL_CONNECTED) {
IPAddress ip = WiFi.localIP();
_display->setCursor(0, 40);
_display->setColor(UIColor::primary_txt);
snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
_display->print(tmp);
rows.draw(tmp, false);
} else
#endif
{
_display->setCursor(0, 40);
snprintf(tmp, sizeof(tmp), "BAT: %.2fV", _board->getBattMilliVolts() / 1000.0f);
_display->print(tmp);
rows.draw(tmp, false);
}
// Keep power-saving state visible even when the MQTT IP occupies row 40.
_display->setCursor(0, 50);
// Keep power-saving state visible even when the MQTT IP replaces battery.
snprintf(tmp, sizeof(tmp), "PowerSaving: %s", _node_prefs->powersaving_enabled ? "ON" : "off");
_display->print(tmp);
rows.draw(tmp, false);
}
}
+62
View File
@@ -157,5 +157,67 @@ inline void drawTextCenteredEllipsized(DisplayDriver& display,
}
}
// Stack real font-height rows, never fixed ten-pixel baselines. E-paper's
// smallest font is already taller than an entire OLED row.
class BoundedTextRows {
DisplayDriver& display;
DisplayRegion region;
int next_y;
int gap;
public:
BoundedTextRows(DisplayDriver& d, const DisplayRegion& r, int spacing = 2)
: display(d), region(r), next_y(r.y), gap(spacing) {}
bool draw(const char* text, bool centered = true) {
const int height = display.textLineHeight();
if (height <= 0 || !displayRegionContainsLine(region, next_y, height))
return false;
if (centered) {
drawTextCenteredEllipsized(display, region, next_y, text);
} else {
display.drawTextEllipsized(region.x, next_y, region.width, text);
}
next_y += height + gap;
return true;
}
};
// 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,
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;
display.setTextSize(1);
const int label_height = display.textLineHeight();
int value_size = 2;
display.setTextSize(value_size);
if (display.getTextWidth(value) > width
|| display.textLineHeight() > available) {
display.setTextSize(value_size = 1);
}
const int value_height = display.textLineHeight();
if (value_height <= 0 || value_height > available) return;
// 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
&& label_height + 2 + value_height <= available;
const int block_height = value_height + (show_label ? label_height + 2 : 0);
const DisplayRegion region = {margin, bottom - block_height, width, block_height};
display.setColor(UIColor::title_bkg);
clearDisplayRegion(display, region);
display.setColor(UIColor::title_txt);
if (show_label) {
display.setTextSize(1);
drawTextCenteredEllipsized(display, region, region.y, label);
}
display.setTextSize(value_size);
drawTextCenteredEllipsized(display, region, bottom - value_height, value);
}
} // namespace ui
} // namespace mesh
+13 -20
View File
@@ -12,35 +12,28 @@ struct ButtonReaderHintLayout {
const char* lines[3];
};
// Alternate page and group navigation within the same footer. Measure both
// views so changing the hint never repaginates text or hides a message row.
// All gestures stay visible: no blinking between page and channel controls.
// Separate the controls where space permits, retaining the existing font.
// Narrow displays use the compact hint before needing additional rows.
inline ButtonReaderHintLayout makeButtonReaderHintLayout(
DisplayDriver& text, int line_height, int bottom, bool show_groups = false) {
(void)show_groups; // Kept for source compatibility; no timed alternation.
ButtonReaderHintLayout layout = {
0, line_height, 1, {"<- 2 tap 1 tap -> long press: exit", nullptr, nullptr}};
const char* group_line = "<<- 4 tap 3 tap ->>";
if (text.getTextWidth(layout.lines[0]) > text.width()
&& text.getTextWidth("<-2 tap 1 tap-> hold: X") <= text.width()) {
layout.lines[0] = "<-2 tap 1 tap-> hold: X";
}
if (text.getTextWidth(layout.lines[0]) > text.width()
|| text.getTextWidth(group_line) > text.width()) {
0, line_height, 1, {"4 <<- 2 <- tap -> 1 ->> 3 hold: Exit", nullptr, nullptr}};
if (text.getTextWidth(layout.lines[0]) > text.width())
layout.lines[0] = "4<<2<tap>1>>3 hold:X";
if (text.getTextWidth(layout.lines[0]) > text.width()) {
layout.line_count = 2;
layout.lines[0] = "<- 2 tap 1 tap ->";
layout.lines[1] = "long press: exit";
layout.lines[0] = "4<<2<tap>1>>3";
layout.lines[1] = "hold:X";
if (text.getTextWidth(layout.lines[0]) > text.width()
|| text.getTextWidth(group_line) > text.width()
|| text.getTextWidth(layout.lines[1]) > text.width()) {
layout.line_count = 3;
layout.lines[0] = "<- 2 tap";
layout.lines[1] = "1 tap ->";
layout.lines[2] = "hold: X";
layout.lines[0] = "4<<2<";
layout.lines[1] = ">1>>3";
layout.lines[2] = "holdX";
}
}
if (show_groups) {
layout.lines[0] = layout.line_count < 3 ? group_line : "<<- 4 tap";
if (layout.line_count == 3) layout.lines[1] = "3 tap ->>";
}
layout.top = bottom - layout.line_count * line_height;
if (layout.top < 0) layout.top = 0;
return layout;
+4 -2
View File
@@ -98,12 +98,14 @@ void SSD1306Display::startFrame(ColorVal bkg) {
display.clearDisplay(); // TODO: apply 'bkg'
_color = SSD1306_WHITE;
display.setTextColor(_color);
display.setTextSize(1);
setTextSize(1);
display.setTextWrap(false); // Layout/ellipsis owns wrapping and row spacing.
display.cp437(true); // Use full 256 char 'Code Page 437' font
}
void SSD1306Display::setTextSize(int sz) {
display.setTextSize(sz);
_text_size = sz > 0 ? sz : 1;
display.setTextSize(_text_size);
}
void SSD1306Display::setColor(ColorVal c) {
+2
View File
@@ -26,6 +26,7 @@ class SSD1306Display : public DisplayDriver {
uint8_t _base_rotation = 0;
#endif
uint8_t _color;
int _text_size = 1;
RefCountedDigitalPin* _peripher_power;
bool i2c_probe(TwoWire& wire, uint8_t addr);
@@ -48,6 +49,7 @@ public:
void clear() override;
void startFrame(ColorVal bkg = UIColor::window_bkg) override;
void setTextSize(int sz) override;
int textLineHeight() override { return 8 * _text_size; }
void setColor(ColorVal c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
+5
View File
@@ -605,6 +605,7 @@ void ST7735Display::startFrame(ColorVal bkg) {
sprite->setTextColor(curr_color = UIColor::primary_txt);
sprite->setFreeFont();
sprite->setTextSize(1); // This one affects size of Please wait... message
sprite->setTextWrap(false);
//sprite->cp437(true); // Use full 256 char 'Code Page 437' font
}
@@ -613,6 +614,10 @@ void ST7735Display::setTextSize(int sz) {
sprite->setTextSize(sz);
}
int ST7735Display::textLineHeight() {
return spriteReady() ? sprite->fontHeight() : 8;
}
void ST7735Display::setColor(ColorVal c) {
if (!spriteReady()) return;
curr_color = c;
+1
View File
@@ -36,6 +36,7 @@ public:
void clear() override;
void startFrame(ColorVal bkg = UIColor::window_bkg) override;
void setTextSize(int sz) override;
int textLineHeight() override;
void setColor(ColorVal c) override;
void setCursor(int x, int y) override;
void print(const char* str) override;
+6
View File
@@ -3,6 +3,7 @@
#include <vector>
#include <helpers/ui/ST7735Display.h>
#include <helpers/ui/SmallMessageText.h>
#include <helpers/ui/CompanionHomeLayout.h>
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;
@@ -16,6 +17,7 @@ struct Canvas {
void setTextColor(int) {}
void print(const char*) { ++prints; }
int textWidth(const char* s) { return std::strlen(s) * 6 * size; }
int fontHeight() { return 8 * size; }
void fillRect(int xx, int yy, int w, int h, int) {
assert(xx >= 0 && yy >= 0 && xx + w <= 160 && yy + h <= 80);
for (int row = yy; row < yy + h; ++row)
@@ -45,6 +47,10 @@ int main() {
display.setCursor(159, 79);
assert(canvas.x == 159 && canvas.y == 79);
assert(display.getTextWidth("MMMM") == 24);
assert(display.textLineHeight() == 8);
display.setTextSize(2);
assert(display.textLineHeight() == 16);
display.setTextSize(1);
assert(!display.useSmallMessageFont());
// Every logical pixel covers its own exact physical cell, without gaps or
// overlap. In native mode each of these cells must be exactly one pixel.
+2 -1
View File
@@ -84,7 +84,8 @@ static_assert(mesh::ui::isBluetoothPairingPromptActive(
full_ui = UI_VARIANTS[0].read_text(encoding="utf-8")
self.assertNotIn("else if (bluetooth_pin != 0)", full_ui)
self.assertIn("else if (show_bluetooth_pin) { // BT pin", full_ui)
self.assertIn("if (bluetooth_connected || show_bluetooth_pin)", full_ui)
self.assertIn("drawBottomPairingBlock(display, home_content_top,", full_ui)
self.assertIn("_pairing_screen_until = 0;", full_ui)
def test_incoming_pairing_overrides_setup_screen_with_top_pin(self):
@@ -292,12 +292,29 @@ TEST(SmallMessageText, GroupHintFitsV4FooterWithoutLosingMessageRows) {
const auto hint = mesh::ui::makeButtonReaderHintLayout(
text, text.lineHeight(), display.height(), true);
ASSERT_EQ(1, hint.line_count);
EXPECT_STREQ("<<- 4 tap 3 tap ->>", hint.lines[0]);
EXPECT_STREQ("4 <<- 2 <- tap -> 1 ->> 3 hold: Exit", hint.lines[0]);
EXPECT_EQ(6, text.capitalHeight());
EXPECT_EQ(108, text.getTextWidth(hint.lines[0]));
EXPECT_GT(text.getTextWidth(hint.lines[0]), text.getTextWidth("4<<2<tap>1>>3 hold:X"));
EXPECT_LE(text.getTextWidth(hint.lines[0]), 128);
EXPECT_EQ(56, hint.top);
EXPECT_EQ(5, text.lineCount(2 * text.lineHeight(), hint.top));
}
TEST(SmallMessageText, HintDropsExtraSpacesBeforeAddingRows) {
for (int width : {64, 107, 108, 128}) {
TestDisplay display(width, 128);
mesh::ui::SmallMessageText text(display);
const auto hint = mesh::ui::makeButtonReaderHintLayout(
text, text.lineHeight(), display.height());
ASSERT_EQ(1, hint.line_count);
EXPECT_STREQ(width >= text.getTextWidth("4 <<- 2 <- tap -> 1 ->> 3 hold: Exit")
? "4 <<- 2 <- tap -> 1 ->> 3 hold: Exit" : "4<<2<tap>1>>3 hold:X", hint.lines[0]);
EXPECT_LE(text.getTextWidth(hint.lines[0]), width);
EXPECT_EQ(120, hint.top);
}
}
TEST(SmallMessageText, Full160CharacterSamplesFitInFiveRows) {
class RecordingText : public mesh::ui::SmallMessageText {
public:
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Render production home-page branches; reject overlapping/clipped text boxes."""
from pathlib import Path
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
PREAMBLE = r'''
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/BluetoothPairingUiPolicy.h>
#include <helpers/ui/ReaderNavigationHint.h>
#include <cassert>
#include <cstdio>
#include <string>
#include <vector>
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"
struct Display : DisplayDriver {
struct Row { mesh::ui::DisplayRegion box; std::string text; };
std::vector<Row> 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) {}
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; }
void setColor(ColorVal) override {}
void setCursor(int a,int b) override { x=a;y=b; }
uint16_t getTextWidth(const char* text) override {
int w=0; for(;*text;++text) w += small==8 ? 6*size : (*text=='i'?3:9)*size;
return w;
}
void print(const char* text) override {
if(!*text) return;
mesh::ui::DisplayRegion box{x,y,getTextWidth(text),textLineHeight()};
assert(x>=0 && y>=0 && box.right()<=width() && box.bottom()<=height());
for(const auto& row:rows) assert(!mesh::ui::displayRegionsOverlap(box,row.box));
rows.push_back({box,text});
}
void fillRect(int x,int y,int w,int h) override {
assert(x>=0 && y>=0 && w>=0 && h>=0 && x+w<=width() && y+h<=height());
}
void drawRect(int x,int y,int w,int h) override { fillRect(x,y,w,h); }
void drawXbm(int,int,const uint8_t*,int,int) override {}
const Row* find(const char* text) {
for(const auto& row:rows) if(row.text==text) return &row;
return nullptr;
}
};
struct Task {
bool enabled=true,connected=false,prompt=false;
int count=0;
bool isBluetoothEnabled(){return enabled;} bool hasBluetoothConnection(){return connected;}
bool isPairingPromptActive(){return prompt;} int getPreviewCount(){return count;}
};
struct Mesh { uint32_t pin=123456; uint32_t getBLEPin(){return pin;} } the_mesh;
struct HomePage { enum { FIRST=0 }; };
struct IPAddress { int operator[](int i){return 255-i;} };
struct Wifi { IPAddress localIP(){return {};} int status(){return 3;} } WiFi;
constexpr int WL_CONNECTED=3;
bool isCompanionWiFiEnabled(){return true;}
bool isCompanionWiFiConnected(){return true;}
bool hasCompanionWiFiCredentials(){return true;}
struct Prefs { const char* node_name="A very long repeater name that must be truncated safely";
float freq=910.525,bw=62.5; int sf=7,cr=5; bool powersaving_enabled=true;
};
struct Board { int getBattMilliVolts(){return 4100;} };
'''
SCENARIOS = r'''
int main() {
for(auto profile: {std::vector<int>{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}) {
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);
bool pin_expected=task.enabled && !task.connected && the_mesh.pin
&& (!task.prompt || d.height()>64);
assert((d.find("123456")!=nullptr)==pin_expected);
if(pin_expected) {
auto pin=d.find("123456");
assert(pin->box.y>=d.height()/2);
assert(pin->box.bottom()==d.height()-(d.height()>64?2:0));
assert(!d.find("Press: inbox"));
}
if(task.connected) assert(d.find("CONNECTED"));
}
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);
const auto hint=mesh::ui::makeButtonReaderHintLayout(d,d.textLineHeight(),d.height());
const auto later=mesh::ui::makeButtonReaderHintLayout(d,d.textLineHeight(),d.height(),true);
for(int i=0;i<hint.line_count;++i) assert(std::string(hint.lines[i])==later.lines[i]);
mesh::ui::drawButtonReaderHint(d,hint);
if(d.width()==128) {
assert(hint.line_count==1);
assert(d.find("4<<2<tap>1>>3 hold:X"));
}
}
// 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(const auto& row:d.rows) assert(row.box.y>=h/3);
}
}
'''
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 =')
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'
+ 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:
binary = str(Path(temp) / 'spacing')
flags = ['-DWIFI_SSID="test"', '-DWITH_MQTT_BRIDGE'] if wifi else []
result = subprocess.run(['c++', '-std=c++11', '-fsanitize=address,undefined',
'-fno-pie', '-no-pie', '-I'+str(ROOT/'src'),
*flags, '-x', 'c++', '-', '-o', binary],
input=PREAMBLE+implementation+SCENARIOS,
text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run([binary], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == '__main__':
unittest.main()
+21 -5
View File
@@ -12,7 +12,7 @@ ROOT = Path(__file__).resolve().parents[1]
def method(path, signature):
text = (ROOT / path).read_text()
text = (ROOT / path).read_text(encoding="utf-8")
start = text.index(signature)
end = text.index("{", start) + 1
depth = 1
@@ -98,7 +98,7 @@ class LoggingSleepContractTest(unittest.TestCase):
"docs/firmware_picker.md", "docs/releases/1.17.1.5.md",
):
with self.subTest(path=path):
text = (ROOT / path).read_text()
text = (ROOT / path).read_text(encoding="utf-8")
blocks = re.findall(r"```text\n(.*?)```", text, re.S)
self.assertTrue(any("powersaving off\nset usb.logging on\n" in block
for block in blocks), path)
@@ -108,9 +108,25 @@ class LoggingSleepContractTest(unittest.TestCase):
for path in ("docs/cli_commands.md", "docs/role_feature_switches.md",
"docs/releases/1.17.1.5.md"):
with self.subTest(path=path):
text = " ".join((ROOT / path).read_text().split())
self.assertRegex(text, r"WiFi/MQTT-only logging.*?does not need")
self.assertIn("get bridge.running", text)
text = (ROOT / path).read_text(encoding="utf-8")
paragraphs = [
" ".join(block.split())
for block in re.split(r"\n\s*\n", text)
if "WiFi/MQTT-only logging" in block
]
self.assertTrue(paragraphs, f"{path}: missing WiFi-only sleep guidance")
for paragraph in paragraphs:
self.assertTrue(
re.search(r"WiFi/MQTT-only logging.*?does not need", paragraph),
f"{path}: missing running-MQTT sleep exception",
)
# Check the actual guidance, not an unrelated bridge-command
# reference elsewhere in the document. MQTT has its own
# shared running-state command across firmware roles.
self.assertTrue(
"`get mqtt.running`" in paragraph,
f"{path}: WiFi-only sleep guidance must check `get mqtt.running`",
)
def test_running_mqtt_prevents_sleep_in_repeater_and_room(self):
running = method("src/helpers/bridges/MQTTBridge.h",
+1 -1
View File
@@ -16,7 +16,7 @@ class T096NativeResolutionTest(unittest.TestCase):
source = (FIXTURE / "render.cpp").read_text() + "\n"
source += "\n".join(line for line in driver.splitlines()
if line.startswith("#define SCALE_")) + "\n"
for name in ("setTextSize", "setColor", "setCursor", "print", "fillRect",
for name in ("setTextSize", "textLineHeight", "setColor", "setCursor", "print", "fillRect",
"drawRect", "drawXbm", "getTextWidth"):
start = driver.index(f"ST7735Display::{name}(")
start = driver.rfind("\n", 0, start) + 1