Add Indicator WiFi setup as eighth home page

This commit is contained in:
mikecarper committed 2026-08-31 17:31:48 -07:00
1 parent a38e0370f9
commit 42888e6e3a
20 files changed
+1660 -1311

No files matched your search

+1 -1
View File
@@ -3429,7 +3429,7 @@ apply_companion_radio_full_profile() {
case "${env_name,,}" in
sensecapindicator-espnow_companion_radio_full|\
sensecapindicator-lora_companion_radio_full)
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_EXCLUSIVE_WIFI_BLE=1 -DINDICATOR_TRANSPORT_RENDER_PROFILE=1"
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DCOMPANION_EXCLUSIVE_WIFI_BLE=1 -DINDICATOR_TRANSPORT_RENDER_PROFILE=1 -DUI_WIFI_SETUP_HOME_PAGE=1 -DWEBCONFIG_AP_PREFIX='\"MC-Set\"'"
;;
esac
+9
View File
@@ -2,6 +2,15 @@
#include <stdint.h>
#ifndef UI_WIFI_SETUP_HOME_PAGE
#define UI_WIFI_SETUP_HOME_PAGE 0
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1 \
&& !(defined(ESP32) && defined(WIFI_SSID))
#error "UI_WIFI_SETUP_HOME_PAGE requires ESP32 infrastructure WiFi"
#endif
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) \
&& !(defined(ESP32) && defined(WIFI_SSID) && defined(BLE_PIN_CODE))
#error "COMPANION_EXCLUSIVE_WIFI_BLE requires ESP32, WIFI_SSID, and BLE_PIN_CODE"
+9 -102
View File
@@ -1173,7 +1173,8 @@ void halt() {
#endif
}
#if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS)
#if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS) \
&& UI_WIFI_SETUP_HOME_PAGE != 1
static DisplayDriver* companion_setup_display = nullptr;
static unsigned long companion_setup_display_refresh = 0;
@@ -1182,91 +1183,6 @@ void halt() {
&& companion_setup_display->height() >= 128;
}
static bool hasIndicatorSetupTypography() {
#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE)
// This flag is emitted only for the two SenseCAP Indicator Full builds.
// Runtime canvas dimensions may differ after allocation fallback (and
// across historical UI macro ordering), but the physical panel remains
// 480x480. Never send this board through the generic size-1 setup page.
return true;
#else
return false;
#endif
}
static int drawIndicatorSetupValue(int y, const char* value) {
companion_setup_display->setTextSize(2);
// The setup AP has a stable prefix. Split on its semantic boundary so
// the two large centered rows read cleanly instead of wrapping midway
// through "Setup".
if (value != nullptr
&& strncmp(value, "MeshCore-Setup-", 15) == 0) {
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, y, "MeshCore-");
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, y + 17, value + 9);
return 2;
}
const int max_width = companion_setup_display->width() - 12;
int line_height = 17;
// A configured SSID can contain 32 bytes. Keep ordinary names at the
// large size, but let the longest legal value use two complete size-1
// rows instead of silently losing its tail.
if (value != nullptr
&& companion_setup_display->getTextWidth(value) > max_width * 2) {
companion_setup_display->setTextSize(1);
line_height = 9;
}
return mesh::ui::drawTextWrapped(
*companion_setup_display, 6, y,
max_width, line_height, 2,
value != nullptr && value[0] != 0 ? value : "(not set)");
}
static void drawIndicatorSetupAddress(int y, const char* address) {
const char* value = address != nullptr && address[0] != 0
? address : "(not set)";
companion_setup_display->setTextSize(2);
if (companion_setup_display->getTextWidth(value)
> companion_setup_display->width()) {
companion_setup_display->setTextSize(1);
}
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, y, value);
}
static void renderIndicatorSetupDisplay(const char* title,
const char* wifi_name,
const char* address,
bool connecting) {
// Keep the requested sizes physically identical on a native canvas and
// the 320-to-480 emergency canvas. This page is sparse enough to use
// genuinely large type instead of relying on the global 480 boost.
companion_setup_display->setCompactText(true);
companion_setup_display->setTextSize(4);
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 0, title);
companion_setup_display->setTextSize(4);
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 35, "JOIN");
drawIndicatorSetupValue(70, wifi_name);
if (connecting) {
companion_setup_display->setTextSize(4);
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 108, "WAIT");
return;
}
companion_setup_display->setTextSize(4);
companion_setup_display->drawTextCentered(
companion_setup_display->width() / 2, 105, "OPEN");
// The scheme is implicit here so the address itself can remain nearly
// panel-wide at size 2. Long DHCP addresses fall back to size 1.
drawIndicatorSetupAddress(140, address);
}
static int drawCompanionSetupValue(int y, int max_lines,
const char* value) {
static constexpr int margin = 6;
@@ -1318,10 +1234,7 @@ void halt() {
char setup_ip[16] = {0};
if (WebConfigServer::getSetupInfo(setup_ssid, sizeof(setup_ssid),
setup_ip, sizeof(setup_ip))) {
if (hasIndicatorSetupTypography()) {
renderIndicatorSetupDisplay(
"SETUP", setup_ssid, setup_ip, false);
} else if (hasLargeCompanionSetupDisplay()) {
if (hasLargeCompanionSetupDisplay()) {
renderLargeCompanionSetupDisplay(
"WebUI setup", "Join open WiFi:", setup_ssid, setup_ip, false);
} else {
@@ -1338,10 +1251,7 @@ void halt() {
}
} else if (WiFi.status() == WL_CONNECTED) {
const String ip = WiFi.localIP().toString();
if (hasIndicatorSetupTypography()) {
renderIndicatorSetupDisplay(
"READY", configured_wifi_ssid, ip.c_str(), false);
} else if (hasLargeCompanionSetupDisplay()) {
if (hasLargeCompanionSetupDisplay()) {
renderLargeCompanionSetupDisplay(
"WebUI ready", "Join WiFi:", configured_wifi_ssid, ip.c_str(),
false);
@@ -1358,10 +1268,7 @@ void halt() {
companion_setup_display->width() / 2, 51, ip.c_str());
}
} else {
if (hasIndicatorSetupTypography()) {
renderIndicatorSetupDisplay(
"WIFI", configured_wifi_ssid, nullptr, true);
} else if (hasLargeCompanionSetupDisplay()) {
if (hasLargeCompanionSetupDisplay()) {
renderLargeCompanionSetupDisplay(
"WiFi connecting", "WiFi name:", configured_wifi_ssid, nullptr,
true);
@@ -1376,8 +1283,6 @@ void halt() {
companion_setup_display->width() / 2, 52, "Please wait...");
}
}
// Do not leak the setup page's fixed physical text scale into the normal
// UI after WebConfig exits.
companion_setup_display->setCompactText(false);
companion_setup_display->endFrame();
}
@@ -1779,7 +1684,8 @@ void setup() {
DisplayDriver* disp = NULL;
if (display.begin()) {
disp = &display;
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG)
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) \
&& UI_WIFI_SETUP_HOME_PAGE != 1
companion_setup_display = disp;
#endif
disp->startFrame();
@@ -2133,7 +2039,8 @@ void loop() {
// main/UI task.
display.serviceFontRecovery();
#endif
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG)
#if defined(ESP32) && defined(WIFI_SSID) && defined(WITH_WEBCONFIG) \
&& UI_WIFI_SETUP_HOME_PAGE != 1
if (isCompanionWiFiEnabled() && (the_mesh.isWebConfigSetupActive()
#ifdef WITH_MQTT_BRIDGE
|| !the_mesh.isMQTTConfigured()
+103 -2
View File
@@ -11,6 +11,10 @@
#ifdef WIFI_SSID
#include <WiFi.h>
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
#include <helpers/esp32/WebConfigServer.h>
#include <helpers/ui/WiFiSetupQrPayload.h>
#endif
#if defined(ESP32)
#include <esp_timer.h>
#endif
@@ -113,6 +117,70 @@ static void drawCompanionTransportChoice(DisplayDriver& display,
}
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
static void drawCompanionWiFiSetupPage(DisplayDriver& display) {
char setup_ssid[33] = {0};
char setup_ip[16] = {0};
const bool setup_active = WebConfigServer::getSetupInfo(
setup_ssid, sizeof(setup_ssid), setup_ip, sizeof(setup_ip));
// Keep the setup content below the shared title bar and page dots. Compact
// text makes its physical size identical on the 480 canvas and the
// 320-to-480 emergency canvas.
display.setCompactText(true);
display.setColor(UIColor::primary_txt);
if (setup_active) {
display.setTextSize(2);
display.drawTextCentered(display.width() / 2, 20, "WIFI SETUP");
display.drawTextCentered(display.width() / 2, 37, setup_ssid);
char payload[256];
const char* setup_password = nullptr;
#ifdef WEBCONFIG_AP_PASSWORD
setup_password = WEBCONFIG_AP_PASSWORD;
#endif
if (mesh::ui::buildWiFiSetupQrPayload(
payload, sizeof(payload), setup_ssid, setup_password)) {
static constexpr int qr_size = 105;
const int qr_x = (display.width() - qr_size) / 2;
if (display.drawQrCode(payload, qr_x, 55, qr_size)) {
display.setCompactText(false);
return;
}
}
display.setColor(UIColor::warning_txt);
display.setTextSize(2);
display.drawTextCentered(display.width() / 2, 70, "QR UNAVAILABLE");
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 100, setup_ip);
} else if (!isCompanionWiFiEnabled()) {
display.setTextSize(3);
display.drawTextCentered(display.width() / 2, 45, "WIFI OFF");
display.setTextSize(2);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 90, "SELECT WIFI");
display.drawTextCentered(display.width() / 2, 112, "THEN REBOOT");
} else if (WiFi.status() == WL_CONNECTED) {
display.setTextSize(2);
display.drawTextCentered(display.width() / 2, 25, "WIFI READY");
const String ssid = WiFi.SSID();
display.drawTextEllipsized(4, 60, display.width() - 8, ssid.c_str());
const String ip = WiFi.localIP().toString();
display.drawTextCentered(display.width() / 2, 95, ip.c_str());
} else {
display.setTextSize(3);
display.drawTextCentered(display.width() / 2, 45, "WIFI");
display.setTextSize(2);
display.setColor(UIColor::secondary_txt);
display.drawTextCentered(display.width() / 2, 90, "CONNECTING");
}
display.setCompactText(false);
}
#endif
#include "icons.h"
class SplashScreen : public UIScreen {
@@ -190,6 +258,9 @@ class HomeScreen : public UIScreen {
#endif
#ifndef UI_NO_HIBERNATE
SHUTDOWN,
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
WIFI_SETUP,
#endif
Count // keep as last
};
@@ -200,6 +271,9 @@ class HomeScreen : public UIScreen {
CompanionNodePrefs* _node_prefs;
uint8_t _page;
bool _shutdown_init;
#if UI_WIFI_SETUP_HOME_PAGE == 1
bool _wifi_setup_was_active;
#endif
uint32_t _uptime_last_millis;
uint64_t _uptime_millis;
AdvertPath recent[UI_RECENT_LIST_SIZE];
@@ -329,8 +403,17 @@ class HomeScreen : public UIScreen {
public:
HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, CompanionNodePrefs* node_prefs)
: _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0),
_shutdown_init(false), _uptime_last_millis(millis()), _uptime_millis(0),
sensors_lpp(200) { }
_shutdown_init(false),
#if UI_WIFI_SETUP_HOME_PAGE == 1
_wifi_setup_was_active(false),
#endif
_uptime_last_millis(millis()), _uptime_millis(0), sensors_lpp(200) {
#if UI_WIFI_SETUP_HOME_PAGE == 1
_wifi_setup_was_active = WebConfigServer::getSetupInfo(
nullptr, 0, nullptr, 0);
if (_wifi_setup_was_active) _page = HomePage::WIFI_SETUP;
#endif
}
void showFirstPage() { _page = HomePage::FIRST; }
@@ -343,6 +426,17 @@ public:
}
void poll() override {
#if UI_WIFI_SETUP_HOME_PAGE == 1
const bool wifi_setup_active = WebConfigServer::getSetupInfo(
nullptr, 0, nullptr, 0);
if (wifi_setup_active && !_wifi_setup_was_active) {
// Focus a newly opened setup portal once. Keeping this edge-triggered
// lets the user swipe away while setup and LoRa continue running.
_page = HomePage::WIFI_SETUP;
_task->gotoHomeScreen();
}
_wifi_setup_was_active = wifi_setup_active;
#endif
if (_shutdown_init && !_task->isButtonPressed()) { // must wait for USR button to be released
_task->shutdown();
}
@@ -817,6 +911,10 @@ public:
if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb;
else sensors_scroll_offset = 0;
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
} else if (_page == HomePage::WIFI_SETUP) {
drawCompanionWiFiSetupPage(display);
#endif
#ifndef UI_NO_HIBERNATE
} else if (_page == HomePage::SHUTDOWN) {
display.setColor(UIColor::corp_blue);
@@ -834,6 +932,9 @@ public:
if (_page == HomePage::RADIO) return UI_RADIO_REFRESH_MILLIS;
#if UI_MESSAGES_HOME_PAGE == 1
if (_page == HomePage::MESSAGES) return 1000;
#endif
#if UI_WIFI_SETUP_HOME_PAGE == 1
if (_page == HomePage::WIFI_SETUP) return 1000;
#endif
return 5000;
}
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -2,6 +2,9 @@
#ifdef WITH_WEBCONFIG
static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
"WEBCONFIG_AP_PREFIX must be at most 27 bytes");
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
@@ -773,7 +776,8 @@ bool WebConfigServer::startSetupMode(char reply[]) {
WiFi.setAutoReconnect(false);
bool disconnect_ok = WiFi.disconnect(false, true);
delay(100);
snprintf(_ap_ssid, sizeof(_ap_ssid), "MeshCore-Setup-%02X%02X", _pub_key[0], _pub_key[1]);
snprintf(_ap_ssid, sizeof(_ap_ssid), "%s-%02X%02X",
WEBCONFIG_AP_PREFIX, _pub_key[0], _pub_key[1]);
bool ap_ok = false;
for (uint8_t attempt = 1; attempt <= 6 && !ap_ok; ++attempt) {
#ifdef WEBCONFIG_AP_PASSWORD
+3
View File
@@ -52,6 +52,9 @@ class DNSServer;
#ifndef WEBCONFIG_SESSION_TTL_MS
#define WEBCONFIG_SESSION_TTL_MS (20UL * 60UL * 1000UL)
#endif
#ifndef WEBCONFIG_AP_PREFIX
#define WEBCONFIG_AP_PREFIX "MeshCore-Setup"
#endif
class WebConfigServer {
public:
+10
View File
@@ -67,6 +67,16 @@ public:
virtual void fillRect(int x, int y, int w, int h) = 0;
virtual void drawRect(int x, int y, int w, int h) = 0;
virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0;
// Optional QR renderer. Coordinates and size use the same logical units as
// the rest of the UI. Drivers without QR support leave the caller's
// fallback layout in place.
virtual bool drawQrCode(const char* text, int x, int y, int size) {
(void)text;
(void)x;
(void)y;
(void)size;
return false;
}
virtual uint16_t getTextWidth(const char* str) = 0;
virtual bool getTouch(int* x, int* y) {
(void)x;
+59
View File
@@ -1,6 +1,7 @@
#include "LGFXDisplay.h"
#include "ColorTheme.h"
#include "IndicatorRenderProfile.h"
#include <lgfx/utility/lgfx_qrcode.h>
#ifndef DISPLAY_ROTATION
#define DISPLAY_ROTATION 1
@@ -345,6 +346,64 @@ void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
}
}
bool LGFXDisplay::drawQrCode(const char* text, int x, int y, int size) {
if (text == nullptr || text[0] == 0 || x < 0 || y < 0 || size <= 0
|| size > width() || size > height()
|| x > width() - size || y > height() - size) {
return false;
}
// LovyanGFX's convenience renderer hardcodes pure colors. On the
// Indicator's indexed canvas its light color maps to palette slot 15,
// which is deliberately reserved for transparent emoji pixels. Generate
// the matrix directly so both colors use stable semantic palette entries.
static constexpr uint8_t max_version = 10;
static constexpr uint8_t max_modules = 4 * max_version + 17;
uint8_t modules[(max_modules * max_modules + 7) / 8];
QRCode qr = {};
bool initialized = false;
for (uint8_t version = 1; version <= max_version; ++version) {
if (lgfx_qrcode_initText(&qr, modules, version, ECC_LOW, text) == 0) {
initialized = true;
break;
}
}
if (!initialized) return false;
static constexpr int quiet_zone = 4;
const int scaled_size = size * _coordinateScale;
const int module_size = scaled_size / (qr.size + quiet_zone * 2);
if (module_size <= 0) return false;
const int used_size = module_size * (qr.size + quiet_zone * 2);
const int left = x * _coordinateScale + (scaled_size - used_size) / 2;
const int top = y * _coordinateScale + (scaled_size - used_size) / 2;
const uint32_t light = renderColor(UIColor::primary_txt);
const uint32_t dark = renderColor(UIColor::window_bkg);
buffer.fillRect(x * _coordinateScale, y * _coordinateScale,
scaled_size, scaled_size, light);
const int matrix_left = left + quiet_zone * module_size;
const int matrix_top = top + quiet_zone * module_size;
for (uint_fast8_t row = 0; row < qr.size; ++row) {
int run_start = -1;
for (uint_fast16_t column = 0; column <= qr.size; ++column) {
const bool filled = column < qr.size
&& lgfx_qrcode_getModule(&qr, column, row);
if (filled && run_start < 0) {
run_start = column;
} else if (!filled && run_start >= 0) {
buffer.fillRect(
matrix_left + run_start * module_size,
matrix_top + row * module_size,
(column - run_start) * module_size,
module_size, dark);
run_start = -1;
}
}
}
return true;
}
uint16_t LGFXDisplay::getTextWidth(const char* str) {
String mapped = mapText(str);
return (buffer.textWidth(mapped.c_str()) + _coordinateScale - 1)
+5
View File
@@ -69,6 +69,10 @@ public:
LGFXDisplay(int w, int h, LGFX_Device &disp)
: DisplayDriver(w/(UI_ZOOM*UI_COORD_SCALE),
h/(UI_ZOOM*UI_COORD_SCALE)), display(&disp) {}
LGFXDisplay(int logical_w, int logical_h, uint8_t coordinate_scale,
float output_zoom, LGFX_Device &disp)
: DisplayDriver(logical_w, logical_h), display(&disp),
_coordinateScale(coordinate_scale), _outputZoom(output_zoom) {}
bool begin();
bool isOn() override { return _isOn; }
void turnOn() override;
@@ -86,6 +90,7 @@ public:
void fillRect(int x, int y, int w, int h) override;
void drawRect(int x, int y, int w, int h) override;
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
bool drawQrCode(const char* text, int x, int y, int size) override;
uint16_t getTextWidth(const char* str) override;
void translateUTF8ToBlocks(char* dest, const char* src,
size_t dest_size) override;
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include <stddef.h>
#include <string.h>
namespace mesh {
namespace ui {
inline bool appendWiFiQrText(char*& output, size_t& remaining,
const char* value, bool escape) {
if (value == nullptr) return false;
while (*value != 0) {
const bool special = escape && strchr("\\;,\":", *value) != nullptr;
const size_t required = special ? 2 : 1;
if (remaining <= required) {
*output = 0;
return false;
}
if (special) {
*output++ = '\\';
--remaining;
}
*output++ = *value++;
--remaining;
}
*output = 0;
return true;
}
// Build the de-facto WiFi QR payload understood by Android and iOS cameras.
// A null or empty password denotes an open network.
inline bool buildWiFiSetupQrPayload(char* payload, size_t payload_size,
const char* ssid,
const char* password = nullptr) {
if (payload == nullptr || payload_size == 0
|| ssid == nullptr || ssid[0] == 0) {
return false;
}
char* output = payload;
size_t remaining = payload_size;
payload[0] = 0;
const bool protected_network = password != nullptr && password[0] != 0;
if (!appendWiFiQrText(output, remaining,
protected_network ? "WIFI:T:WPA;S:"
: "WIFI:T:nopass;S:",
false)
|| !appendWiFiQrText(output, remaining, ssid, true)) {
return false;
}
if (protected_network
&& (!appendWiFiQrText(output, remaining, ";P:", false)
|| !appendWiFiQrText(output, remaining, password, true))) {
return false;
}
return appendWiFiQrText(output, remaining, ";;", false);
}
} // namespace ui
} // namespace mesh
+10
View File
@@ -96,6 +96,16 @@ class ColorThemeTest(unittest.TestCase):
]
self.assertEqual(positions, sorted(positions))
def test_indicator_qr_avoids_transparent_palette_slot(self) -> None:
source = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text()
start = source.index("bool LGFXDisplay::drawQrCode")
end = source.index("uint16_t LGFXDisplay::getTextWidth", start)
qr = source[start:end]
self.assertIn("renderColor(UIColor::primary_txt)", qr)
self.assertIn("renderColor(UIColor::window_bkg)", qr)
self.assertNotIn("TRANSPARENT_EMOJI_PALETTE_INDEX", qr)
self.assertNotIn("buffer.qrcode", qr)
def test_pagination_uses_readable_accent_on_color_displays(self) -> None:
source = (ROOT / "examples/companion_radio/ui-new/UITask.cpp").read_text()
branch = re.search(
@@ -3,6 +3,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/CompanionHomeLayout.h>
#include <helpers/ui/DisplayTextLayout.h>
#include <helpers/ui/WiFiSetupQrPayload.h>
#include <string>
#include <vector>
@@ -98,6 +99,34 @@ TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) {
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
}
TEST(DisplayDriver, QrCodeIsOptionalByDefault) {
TestDisplay display;
EXPECT_FALSE(display.drawQrCode("WIFI:T:nopass;S:MC-Set-90DF;;",
27, 55, 105));
}
TEST(WiFiSetupQrPayload, EncodesOpenSetupNetwork) {
char payload[64];
EXPECT_TRUE(mesh::ui::buildWiFiSetupQrPayload(
payload, sizeof(payload), "MC-Set-90DF"));
EXPECT_STREQ("WIFI:T:nopass;S:MC-Set-90DF;;", payload);
}
TEST(WiFiSetupQrPayload, EscapesProtectedNetworkFields) {
char payload[96];
EXPECT_TRUE(mesh::ui::buildWiFiSetupQrPayload(
payload, sizeof(payload), "Cafe;West", "p,ass"));
EXPECT_STREQ("WIFI:T:WPA;S:Cafe\\;West;P:p\\,ass;;", payload);
}
TEST(WiFiSetupQrPayload, RejectsMissingOrTruncatedFields) {
char payload[8];
EXPECT_FALSE(mesh::ui::buildWiFiSetupQrPayload(
payload, sizeof(payload), ""));
EXPECT_FALSE(mesh::ui::buildWiFiSetupQrPayload(
payload, sizeof(payload), "MC-Set-90DF"));
}
TEST(DisplayDriver, FixedBufferDoesNotSplitUTF8Codepoint) {
TestDisplay display;
std::string text(254, 'A');
+20 -22
View File
@@ -164,31 +164,29 @@ class IndicatorDisplayProfileTest(unittest.TestCase):
self.assertLessEqual(pairing_label_y + 29, pairing_value_y)
self.assertLessEqual(pairing_value_y + 29, 160)
# Native setup mode intentionally uses most of the panel: a size-3
# title and size-2 action/value rows replace the legacy size-1 page.
for text, requested_size in (
("SETUP", 3),
("READY", 3),
("WIFI", 3),
("CONNECTING", 2),
("JOIN WIFI", 2),
("BROWSE", 2),
("192.168.4.1", 2),
):
width, _height = dimensions(text, requested_size, 3, 1.2)
# Page 8 retains the shared status bar and dots, then uses two equal
# text rows and a version-2 WiFi QR. Compact mode deliberately bypasses
# the native 1.2x chrome boost in both render profiles.
for text in ("WIFI SETUP", "MC-Set-90DF"):
width, _height = dimensions(text, 2, 3, 1.0)
self.assertLessEqual(width, 160)
title_height = dimensions("SETUP", 3, 3, 1.2)[1]
row_height = dimensions("JOIN WIFI", 2, 3, 1.2)[1]
self.assertLessEqual(2 + title_height, 34)
self.assertLessEqual(34 + row_height, 56)
self.assertLessEqual(56 + row_height, 77)
self.assertLessEqual(77 + row_height, 101)
self.assertLessEqual(101 + row_height, 124)
self.assertLessEqual(124 + row_height, 160)
label_height = dimensions("WIFI SETUP", 2, 3, 1.0)[1]
self.assertLessEqual(20 + label_height, 37)
self.assertLessEqual(37 + label_height, 55)
long_ssid = dimensions("ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", 1, 3, 1.2)
self.assertLessEqual(long_ssid[0], 2 * 148)
qr_size = 105
qr_version_2_modules = 25
quiet_zone_modules = 4
native_module_size = qr_size * 3 // (
qr_version_2_modules + quiet_zone_modules * 2
)
fallback_module_size = qr_size * 2 // (
qr_version_2_modules + quiet_zone_modules * 2
)
self.assertEqual(native_module_size, 9)
self.assertEqual(fallback_module_size * 1.5, 9)
self.assertEqual(55 + qr_size, 160)
if __name__ == "__main__":
+13 -10
View File
@@ -148,17 +148,20 @@ profile_end = build.index("\napply_radio_overrides()", profile_start)
profile = build[profile_start:profile_end]
flag = "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1"
assert profile.count(flag) == 1
exclusive_case = re.search(
r'case "\$\{env_name,,\}" in\s+'
r"sensecapindicator-espnow_companion_radio_full\|\\\s+"
r"sensecapindicator-lora_companion_radio_full\)\s+"
r'export PLATFORMIO_BUILD_FLAGS="\$\{PLATFORMIO_BUILD_FLAGS\} '
r"-DCOMPANION_EXCLUSIVE_WIFI_BLE=1 "
r"-DINDICATOR_TRANSPORT_RENDER_PROFILE=1\"\s+;;\s+esac",
profile,
exclusive_policy = profile.index(
"# Both Indicator Full layouts select exactly one secondary"
)
assert exclusive_case
assert "espnow" in exclusive_case.group(0).lower()
exclusive_start = profile.index(
"sensecapindicator-espnow_companion_radio_full|\\\n"
" sensecapindicator-lora_companion_radio_full)",
exclusive_policy,
)
exclusive_case = profile[exclusive_start:profile.index(";;", exclusive_start)]
assert "espnow" in exclusive_case.lower()
assert "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1" in exclusive_case
assert "-DINDICATOR_TRANSPORT_RENDER_PROFILE=1" in exclusive_case
assert "-DUI_WIFI_SETUP_HOME_PAGE=1" in exclusive_case
assert "-DWEBCONFIG_AP_PREFIX='\\\"MC-Set\\\"'" in exclusive_case
assert "get companion.transport" in readme
+40 -1
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
from pathlib import Path
import subprocess
import tempfile
import unittest
@@ -10,7 +12,7 @@ UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp"
class IndicatorMessagesProfileTest(unittest.TestCase):
def test_indicator_enables_seventh_messages_home_page(self):
def test_indicator_enables_messages_home_page(self):
profile = PROFILE.read_text()
ui = UI.read_text()
@@ -19,6 +21,43 @@ class IndicatorMessagesProfileTest(unittest.TestCase):
self.assertIn("#if UI_MESSAGES_HOME_PAGE == 1\n MESSAGES,", ui)
self.assertIn("_page == HomePage::MESSAGES", ui)
def test_wifi_setup_is_eighth_without_reordering_existing_pages(self):
ui = UI.read_text(encoding="utf-8")
enum_start = ui.index(" enum HomePage {")
enum_end = ui.index("\n };", enum_start) + len("\n };")
enum_source = ui[enum_start:enum_end].replace(
" enum HomePage", "enum HomePage"
)
source = f"""
#define UI_MESSAGES_HOME_PAGE 1
#define COMPANION_EXCLUSIVE_WIFI_BLE 1
#define ENV_INCLUDE_GPS 0
#define UI_SENSORS_PAGE 1
#define UI_NO_HIBERNATE 1
#define UI_WIFI_SETUP_HOME_PAGE 1
{enum_source}
static_assert(FIRST == 0, "first");
static_assert(MESSAGES == 1, "messages");
static_assert(RECENT == 2, "recent");
static_assert(RADIO == 3, "radio");
static_assert(TRANSPORT == 4, "transport");
static_assert(ADVERT == 5, "advert");
static_assert(SENSORS == 6, "sensors");
static_assert(WIFI_SETUP == 7, "wifi setup");
static_assert(Count == 8, "count");
int main() {{ return 0; }}
"""
with tempfile.TemporaryDirectory() as temp_dir:
executable = Path(temp_dir) / "home_pages"
compiled = subprocess.run(
["c++", "-std=c++11", "-x", "c++", "-", "-o", str(executable)],
input=source,
text=True,
capture_output=True,
check=False,
)
self.assertEqual(compiled.returncode, 0, compiled.stderr)
def test_full_message_chrome_uses_compact_visuals_only(self):
ui = UI.read_text(encoding="utf-8")
touch = (ROOT / "src/helpers/ui/TouchInput.h").read_text(
+123 -26
View File
@@ -12,6 +12,16 @@ MAIN = ROOT / "examples" / "companion_radio" / "main.cpp"
MESH = ROOT / "examples" / "companion_radio" / "MyMesh.cpp"
DATA_STORE = ROOT / "examples" / "companion_radio" / "DataStore.cpp"
PROFILE = ROOT / "variants" / "sensecap_indicator-espnow" / "platformio.ini"
UI = ROOT / "examples" / "companion_radio" / "ui-new" / "UITask.cpp"
INDICATOR_DISPLAY = (
ROOT / "variants" / "sensecap_indicator-espnow" / "SCIndicatorDisplay.h"
)
DISPLAY_DRIVER = ROOT / "src" / "helpers" / "ui" / "DisplayDriver.h"
LGFX_DISPLAY = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.h"
LGFX_DISPLAY_CPP = ROOT / "src" / "helpers" / "ui" / "LGFXDisplay.cpp"
WEBCONFIG_HEADER = ROOT / "src" / "helpers" / "esp32" / "WebConfigServer.h"
WEBCONFIG_SOURCE = ROOT / "src" / "helpers" / "esp32" / "WebConfigServer.cpp"
WIFI_QR_PAYLOAD = ROOT / "src" / "helpers" / "ui" / "WiFiSetupQrPayload.h"
class IndicatorRenderProfileTest(unittest.TestCase):
@@ -76,6 +86,7 @@ int main() {
self.assertIn("-UUI_ZOOM -DUI_ZOOM=1.0f", build)
self.assertIn("-UUI_COORD_SCALE -DUI_COORD_SCALE=3", build)
self.assertIn("-DINDICATOR_TRANSPORT_RENDER_PROFILE=1", build)
self.assertIn("-DUI_WIFI_SETUP_HOME_PAGE=1", build)
def test_runtime_selection_precedes_bluetooth_start(self):
main = MAIN.read_text(encoding="utf-8")
@@ -115,6 +126,23 @@ int main() {
self.assertLess(fallback_scale, fallback_zoom)
self.assertLess(fallback_zoom, retry)
def test_full_indicator_constructs_the_160_unit_viewport_explicitly(self):
display = INDICATOR_DISPLAY.read_text(encoding="utf-8")
self.assertIn("#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE)", display)
self.assertIn(
"SCIndicatorDisplay() : LGFXDisplay(160, 160, 3, 1.0f, disp)",
display,
)
lgfx = LGFX_DISPLAY.read_text(encoding="utf-8")
self.assertIn(
"LGFXDisplay(int logical_w, int logical_h, uint8_t coordinate_scale,",
lgfx,
)
self.assertIn("DisplayDriver(logical_w, logical_h)", lgfx)
self.assertIn("_coordinateScale(coordinate_scale)", lgfx)
self.assertIn("_outputZoom(output_zoom)", lgfx)
def test_native_font_increase_preserves_compact_chrome(self):
display = (ROOT / "src/helpers/ui/LGFXDisplay.cpp").read_text(
encoding="utf-8"
@@ -123,33 +151,102 @@ int main() {
self.assertIn("static_cast<uint16_t>(renderWidth()), _compactText", display)
self.assertIn("* profile_scale;", display)
def test_indicator_setup_page_uses_large_reflow_even_after_canvas_fallback(self):
def test_indicator_setup_is_a_normal_eighth_home_page(self):
ui = UI.read_text(encoding="utf-8")
page_start = ui.index("static void drawCompanionWiFiSetupPage")
page_end = ui.index("\n}\n#endif\n\n#include \"icons.h\"", page_start) + 2
page = ui[page_start:page_end]
self.assertIn("WebConfigServer::getSetupInfo", page)
self.assertIn('20, "WIFI SETUP"', page)
self.assertIn("37, setup_ssid", page)
self.assertIn("static constexpr int qr_size = 105;", page)
self.assertIn("payload, qr_x, 55, qr_size", page)
self.assertIn("mesh::ui::buildWiFiSetupQrPayload", page)
self.assertIn("display.setCompactText(true);", page)
self.assertGreaterEqual(page.count("display.setCompactText(false);"), 2)
self.assertNotIn("startFrame", page)
self.assertNotIn("endFrame", page)
enum_start = ui.index(" enum HomePage {")
enum = ui[enum_start : ui.index("\n };", enum_start)]
self.assertIn(
"#if UI_WIFI_SETUP_HOME_PAGE == 1\n WIFI_SETUP,\n#endif\n Count",
enum,
)
self.assertIn("_page == HomePage::WIFI_SETUP", ui)
self.assertIn("if (_page == HomePage::WIFI_SETUP) return 1000;", ui)
self.assertIn("wifi_setup_active && !_wifi_setup_was_active", ui)
main = MAIN.read_text(encoding="utf-8")
start = main.index("static bool hasIndicatorSetupTypography()")
end = main.index("static bool saveCompanionWiFi", start)
setup = main[start:end]
predicate = setup[: setup.index("static int drawIndicatorSetupValue")]
self.assertIn("#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE)", predicate)
self.assertIn("return true;", predicate)
self.assertNotIn("renderWidth()", predicate)
self.assertNotIn("renderHeight()", predicate)
self.assertIn("renderIndicatorSetupDisplay", setup)
self.assertIn('setCompactText(true);', setup)
self.assertIn('setTextSize(4);', setup)
self.assertIn('35, "JOIN"', setup)
self.assertIn('drawIndicatorSetupValue(70, wifi_name);', setup)
self.assertIn('strncmp(value, "MeshCore-Setup-", 15) == 0', setup)
self.assertIn('y, "MeshCore-"', setup)
self.assertIn('y + 17, value + 9', setup)
self.assertIn('getTextWidth(value) > max_width * 2', setup)
self.assertIn('line_height = 9;', setup)
self.assertIn('105, "OPEN"', setup)
self.assertIn('108, "WAIT"', setup)
self.assertIn('drawIndicatorSetupAddress(140, address);', setup)
self.assertIn('setCompactText(false);', setup)
self.assertIn('"SETUP", setup_ssid, setup_ip, false', setup)
self.assertIn('"READY", configured_wifi_ssid, ip.c_str(), false', setup)
self.assertIn('"WIFI", configured_wifi_ssid, nullptr, true', setup)
loop = main[main.index("\nvoid loop()") :]
display_dispatch = loop[loop.index("#ifdef DISPLAY_CLASS") :]
display_dispatch = display_dispatch[: display_dispatch.index("rtc_clock.tick();")]
self.assertIn("&& UI_WIFI_SETUP_HOME_PAGE != 1", display_dispatch)
self.assertIn("renderCompanionSetupDisplay();", display_dispatch)
self.assertIn("#else\n ui_task.loop();", display_dispatch)
def test_wifi_qr_payload_is_standard_and_escaped(self):
setup = WIFI_QR_PAYLOAD.read_text(encoding="utf-8")
self.assertIn("buildWiFiSetupQrPayload", setup)
self.assertIn('"WIFI:T:WPA;S:"', setup)
self.assertIn('"WIFI:T:nopass;S:"', setup)
self.assertIn('strchr("\\\\;,\\\":", *value)', setup)
self.assertIn('";P:"', setup)
self.assertIn('";;"', setup)
def test_legacy_setup_overlay_is_not_built_for_page_enabled_profile(self):
main = MAIN.read_text(encoding="utf-8")
start = main.index("static DisplayDriver* companion_setup_display")
declaration = main[start - 120 : start + 80]
self.assertIn("&& UI_WIFI_SETUP_HOME_PAGE != 1", declaration)
self.assertNotIn("renderIndicatorSetupDisplay", main)
self.assertNotIn("renderIndicatorSetupQrDisplay", main)
def test_lgfx_qr_renderer_scales_coordinates_and_keeps_quiet_zone(self):
driver = DISPLAY_DRIVER.read_text(encoding="utf-8")
self.assertIn(
"virtual bool drawQrCode(const char* text, int x, int y, int size)",
driver,
)
header = LGFX_DISPLAY.read_text(encoding="utf-8")
self.assertIn(
"bool drawQrCode(const char* text, int x, int y, int size) override;",
header,
)
implementation = LGFX_DISPLAY_CPP.read_text(encoding="utf-8")
qr = implementation[implementation.index("bool LGFXDisplay::drawQrCode") :]
qr = qr[: qr.index("\nuint16_t LGFXDisplay::getTextWidth")]
self.assertIn("lgfx_qrcode_initText", qr)
self.assertIn("lgfx_qrcode_getModule", qr)
self.assertIn("static constexpr int quiet_zone = 4;", qr)
self.assertIn("renderColor(UIColor::primary_txt)", qr)
self.assertIn("renderColor(UIColor::window_bkg)", qr)
self.assertIn("const int scaled_size = size * _coordinateScale;", qr)
self.assertIn("x > width() - size", qr)
self.assertNotIn("buffer.qrcode", qr)
self.assertNotIn("TFT_WHITE", qr)
self.assertNotIn("TFT_BLACK", qr)
def test_indicator_uses_short_captive_setup_ssid(self):
build = BUILD.read_text(encoding="utf-8")
short_prefix_flag = "-DWEBCONFIG_AP_PREFIX='\\\"MC-Set\\\"'"
self.assertEqual(build.count(short_prefix_flag), 1)
full_case_start = build.index(
"# Both Indicator Full layouts select exactly one secondary"
)
full_case = build[full_case_start : build.index(";;", full_case_start)]
self.assertIn(short_prefix_flag, full_case)
profile = PROFILE.read_text(encoding="utf-8")
self.assertNotIn("WEBCONFIG_AP_PREFIX", profile)
header = WEBCONFIG_HEADER.read_text(encoding="utf-8")
self.assertIn('#define WEBCONFIG_AP_PREFIX "MeshCore-Setup"', header)
source = WEBCONFIG_SOURCE.read_text(encoding="utf-8")
self.assertIn('"%s-%02X%02X"', source)
self.assertIn("WEBCONFIG_AP_PREFIX, _pub_key[0], _pub_key[1]", source)
self.assertIn("sizeof(WEBCONFIG_AP_PREFIX) <= 28", source)
def test_lora_fresh_install_uses_compiled_cascade_defaults(self):
profile = PROFILE.read_text(encoding="utf-8")
+20 -15
View File
@@ -45,7 +45,7 @@ wakes it without selecting anything.
The message preview is a non-destructive local inbox, newest first:
- A seventh home page, immediately after the status page, shows the newest
- A Messages home page, immediately after the status page, shows the newest
retained message for each heard channel. Each two-line row contains the
channel name, rollover-safe relative age, and a one-line ellipsized message
preview. Direct messages are grouped by contact and labelled `Direct`.
@@ -53,7 +53,7 @@ The message preview is a non-destructive local inbox, newest first:
Companion drains its unread queue. The oldest history is overwritten and a
reboot starts with an empty list; messages are not written to flash.
- Tap the center of the message-summary page to open the existing full message
view. Horizontal swipes continue to move among the seven home pages.
view. Horizontal swipes continue to move among the eight home pages.
- The first home page shows the retained local `INBOX` count, which remains
useful when a USB host has already drained the protocol's unread count. Tap
the center to open the inbox and channel selector even when the count is 0.
@@ -99,14 +99,20 @@ than claiming hundredth-dB measurement accuracy.
## USB + WiFi Companion
Build `SenseCapIndicator-LoRa_comp_radio_usb_wifi` to retain the USB Companion
Build `SenseCapIndicator-LoRa_companion_radio_full` to retain the USB Companion
link and expose the same framed Companion protocol over WiFi TCP port `5000`.
Fresh devices advertise an open `MeshCore-Setup-XXXX` access point, where the
four-character suffix identifies the device; join it and open
`http://192.168.4.1/` to save WiFi credentials. Saved credentials and the WiFi
on/off preference survive application-only reflashes. Setup mode explicitly
restores standard 2.4 GHz b/g/n operation, so a previously installed ESP-NOW
image cannot leave the setup AP hidden behind its long-range protocol setting.
Fresh Full devices advertise an open `MC-Set-XXXX` access point, where the
four-character suffix identifies the device. WiFi Setup is the eighth normal
home page and shows the SSID plus a WiFi-join QR while the captive AP is active.
Fresh setup starts on that page, but ordinary swipes still reach the other
seven pages and the LoRa modem continues running. The captive portal should
open automatically after joining, or browse to `http://192.168.4.1/` to save
WiFi credentials. The direct
`SenseCapIndicator-LoRa_comp_radio_usb_wifi` environment retains the generic
`MeshCore-Setup-XXXX` name. Saved credentials and the WiFi on/off preference
survive application-only reflashes. Setup mode explicitly restores standard
2.4 GHz b/g/n operation, so a previously installed ESP-NOW image cannot leave
the setup AP hidden behind its long-range protocol setting.
The combined profile uses minimum WiFi modem power saving by default. USB
remains available for recovery and management if the configured network is
@@ -144,12 +150,11 @@ uses separate full-width size-4 `INBOX` and count rows plus a large lower
action or BLE-status block. Long IP addresses retain a smaller bounded row
instead of clipping.
The Indicator WiFi setup/status screen is reflowed separately: its title and
short `JOIN`/`OPEN`/`WAIT` actions are size 4, while the setup SSID and IP
address use size 2. This large sparse layout uses a fixed physical scale on
both the native and 320x320 emergency canvases, so a failed native allocation
cannot turn the instructions back into the generic size-1 page. Other display
layouts retain their compact setup page.
The Indicator WiFi setup/status page uses a fixed physical scale on both the
native and 320x320 emergency canvases. Its content begins below the shared
status bar and eight page dots. The page only reads connection state; it never
starts or stops WebConfig, WiFi, ESP-NOW, or LoRa. Other display layouts retain
their existing setup overlay.
LoRa remains the primary radio in both LoRa modes. On the ESP-NOW layout,
selecting BLE leaves the primary ESP-NOW WiFi radio and its fixed channel
@@ -231,7 +231,16 @@ class SCIndicatorDisplay : public LGFXDisplay {
}
public:
#if defined(INDICATOR_TRANSPORT_RENDER_PROFILE)
// Full builds always use the established 160x160 logical layout. State it
// explicitly here because PlatformIO's inherited flag ordering can leave
// the variant's historical UI_COORD_SCALE value visible in this header.
// The 480px canvas is allocated before radio/transport startup; begin()
// retains its 320px emergency fallback if that allocation is unavailable.
SCIndicatorDisplay() : LGFXDisplay(160, 160, 3, 1.0f, disp) {}
#else
SCIndicatorDisplay() : LGFXDisplay(480, 480, disp) {}
#endif
bool begin() {
if (!prepareControllers()) return false;
+3 -3
View File
@@ -586,7 +586,7 @@ body.tab-cli{padding-bottom:0}
<li>Open Safari or Chrome on this device.</li>
<li>Go to <b style="font-size:17px" id="exit-url">http://192.168.4.1</b></li>
</ol>
<div class="note" style="margin-top:14px">Stay connected to the <b>MeshCore-Setup</b> WiFi network. Your progress here isn't carried over &mdash; the setup restarts in the browser, where it survives the screen locking.</div>
<div class="note" style="margin-top:14px">Stay connected to this device's <b>setup WiFi</b> network. Your progress here isn't carried over &mdash; the setup restarts in the browser, where it survives the screen locking.</div>
<p style="text-align:center;margin-top:10px"><a href="#" onclick="document.getElementById('exit-ov').classList.remove('show');return false" style="font-size:13px">Go back</a></p>
</div>
</div>
@@ -1471,12 +1471,12 @@ function wizardSave(){
if(acked){
// The node accepted the batch (202), but without its result we cannot
// know whether every field passed CLI validation.
showReboot("The node accepted the settings for processing, but the confirmation reply was lost. If every setting passed, its screen shows \"Config saved!\" and it reboots within 30 seconds. If it stays in setup mode, reconnect to MeshCore-Setup and correct the rejected field.",
showReboot("The node accepted the settings for processing, but the confirmation reply was lost. If every setting passed, its screen shows \"Config saved!\" and it reboots within 30 seconds. If it stays in setup mode, reconnect to its setup WiFi and correct the rejected field.",
false,"Settings sent - verify the node");
st.dirty={};
return;
}
$("#wz-err").textContent="Couldn't reach the node to save. Check this device is still on the MeshCore-Setup WiFi, then retry.";
$("#wz-err").textContent="Couldn't reach the node to save. Check this device is still on its setup WiFi, then retry.";
return;
}
var fails=(r.results||[]).filter(function(x){return!/^OK/i.test(x.reply)});