From fb678b9337cc1193032998d93a48827b6682f0ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:44:06 -0500 Subject: [PATCH 01/52] Update platform-native digest to 135b91e (#10300) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 87d8431a3..b276d2779 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/71ed55bb95feb3c43ebde1ec1e2e17643a424c04.zip + https://github.com/meshtastic/platform-native/archive/135b91e953db0b5f44d278f8ebd5b8d985fc03d8.zip framework = arduino build_src_filter = From 554188e90edc07ffed9b1170d01e38ed7b541447 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 14:49:37 -0500 Subject: [PATCH 02/52] Fix main function to setup and loop for Unity test framework --- test/test_utf8/test_main.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/test_utf8/test_main.cpp b/test/test_utf8/test_main.cpp index 7ac64653d..5a074e96e 100644 --- a/test/test_utf8/test_main.cpp +++ b/test/test_utf8/test_main.cpp @@ -163,7 +163,7 @@ void test_above_max_codepoint() TEST_ASSERT_TRUE(sanitizeUtf8(buf, sizeof(buf))); } -int main(int argc, char **argv) +void setup() { UNITY_BEGIN(); @@ -191,5 +191,7 @@ int main(int argc, char **argv) RUN_TEST(test_valid_max_codepoint); RUN_TEST(test_above_max_codepoint); - return UNITY_END(); + exit(UNITY_END()); } + +void loop() {} From 7800dc3c8dba00aecb57bf6b2539930bb491bce2 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 15:04:58 -0500 Subject: [PATCH 03/52] Enhance UTF-8 sanitization logic and add delays in test setup for reliable timing --- src/meshUtils.cpp | 4 ++-- test/test_transmit_history/test_main.cpp | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/meshUtils.cpp b/src/meshUtils.cpp index 89c548887..f2ee20589 100644 --- a/src/meshUtils.cpp +++ b/src/meshUtils.cpp @@ -124,10 +124,10 @@ bool sanitizeUtf8(char *buf, size_t bufSize) if (!buf || bufSize == 0) return false; - // Ensure null-terminated within buffer + // Ensure null-terminated within buffer; report if we had to enforce it + bool replaced = (buf[bufSize - 1] != '\0'); buf[bufSize - 1] = '\0'; - bool replaced = false; size_t i = 0; size_t len = strlen(buf); diff --git a/test/test_transmit_history/test_main.cpp b/test/test_transmit_history/test_main.cpp index 3bd84b55c..c242aa646 100644 --- a/test/test_transmit_history/test_main.cpp +++ b/test/test_transmit_history/test_main.cpp @@ -303,6 +303,10 @@ void setup() { initializeTestEnvironment(); + // Wait for portduino's millis() clock to start ticking before tests run + testDelay(10); + testDelay(2000); + UNITY_BEGIN(); RUN_TEST(test_setLastSentToMesh_stores_millis); From aec0805a27c8eb47ad037210071462d40419e18b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 25 Apr 2026 15:32:19 -0500 Subject: [PATCH 04/52] Trying another guard approach --- src/mesh/HardwareRNG.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index 58a17d795..b79b0d012 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -48,9 +48,11 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) // and return false so callers know no extra mixing occurred. RadioLibInterface *radio = RadioLibInterface::instance; if (!radio) { -#ifndef PIO_UNIT_TESTING - LOG_ERROR("No radio instance available to provide entropy"); -#endif + // This path can run during portduinoSetup() before the console is initialized, + // both for unit-test binaries and the simulator's meshtasticd; LOG_* dereferences `console`. + if (console) { + LOG_ERROR("No radio instance available to provide entropy"); + } return false; } From 4ccdd8009051bc13b1360c8949ed85562a954ca7 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sat, 25 Apr 2026 20:42:14 -0500 Subject: [PATCH 05/52] Add search duration check for exceeding 15 minutes (#10293) * Add search duration check for exceeding 15 minutes Added a condition to check if the search duration exceeds 15 minutes, indicating too long of a search. * trunk * Fix searchedTooLong: move 15-min cap before UINT32_MAX check, cache elapsed, add constexpr Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/b7f74430-9e7e-4a6f-8095-6176c1eee972 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Update src/gps/GPSUpdateScheduling.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Remove dead UINT32_MAX branch from searchedTooLong Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/6dad5b56-902e-4d0e-90c1-038a9c2df364 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/gps/GPSUpdateScheduling.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/gps/GPSUpdateScheduling.cpp b/src/gps/GPSUpdateScheduling.cpp index 5eaf7a8ba..53d6c833f 100644 --- a/src/gps/GPSUpdateScheduling.cpp +++ b/src/gps/GPSUpdateScheduling.cpp @@ -70,20 +70,25 @@ bool GPSUpdateScheduling::isUpdateDue() // Have we been searching for a GPS position for too long? bool GPSUpdateScheduling::searchedTooLong() { + constexpr uint32_t oneMinuteMs = 60UL * 1000UL; + constexpr uint32_t maxSearchClampMs = 15UL * oneMinuteMs; // Hard cap: 15 minutes is always too long + uint32_t elapsed = elapsedSearchMs(); + + // Anything over 15 minutes is too long, regardless of the broadcast interval. + // TODO: Make a smarter algorithm that backs off the search dwell time when not getting a lock. + if (elapsed > maxSearchClampMs) + return true; + uint32_t minimumOrConfiguredSecs = Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, default_broadcast_interval_secs); uint32_t maxSearchMs = Default::getConfiguredOrDefaultMs(minimumOrConfiguredSecs, default_broadcast_interval_secs); - // If broadcast interval set to max, no such thing as "too long" - if (maxSearchMs == UINT32_MAX) - return false; // If we've been searching longer than our position broadcast interval: that's too long - else if (elapsedSearchMs() > maxSearchMs) + if (elapsed > maxSearchMs) return true; // Otherwise, not too long yet! - else - return false; + return false; } // Updates the predicted time-to-get-lock, by exponentially smoothing the latest observation From 8dde4eeee196df6f2141ce0e463f93f995af433a Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:44:56 -0400 Subject: [PATCH 06/52] BaseUI: Color Support for TFT Nodes (#10233) * True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager) * Theme support - New and some Classic Themes! * Colored Compass --------- Co-authored-by: Jason P Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors --- src/graphics/Screen.cpp | 112 +-- src/graphics/SharedUIDisplay.cpp | 320 +++++-- src/graphics/SharedUIDisplay.h | 4 +- src/graphics/TFTColorRegions.cpp | 819 ++++++++++++++++ src/graphics/TFTColorRegions.h | 163 ++++ src/graphics/TFTDisplay.cpp | 185 +++- src/graphics/TFTDisplay.h | 3 +- src/graphics/TFTPalette.h | 70 ++ src/graphics/draw/ClockRenderer.cpp | 10 +- src/graphics/draw/CompassRenderer.cpp | 120 +-- src/graphics/draw/CompassRenderer.h | 1 + src/graphics/draw/DebugRenderer.cpp | 35 +- src/graphics/draw/MenuHandler.cpp | 185 ++-- src/graphics/draw/MenuHandler.h | 19 +- src/graphics/draw/MessageRenderer.cpp | 144 ++- src/graphics/draw/NodeListRenderer.cpp | 57 ++ src/graphics/draw/NotificationRenderer.cpp | 36 +- src/graphics/draw/UIRenderer.cpp | 886 +++++++++++++----- src/graphics/draw/UIRenderer.h | 2 + src/motion/AccelerometerThread.h | 0 src/motion/BMA423Sensor.cpp | 0 src/motion/BMA423Sensor.h | 0 src/motion/BMX160Sensor.cpp | 0 src/motion/BMX160Sensor.h | 0 src/motion/ICM20948Sensor.cpp | 0 src/motion/ICM20948Sensor.h | 0 src/motion/LIS3DHSensor.cpp | 0 src/motion/LIS3DHSensor.h | 0 src/motion/LSM6DS3Sensor.cpp | 0 src/motion/LSM6DS3Sensor.h | 0 src/motion/MPU6050Sensor.cpp | 0 src/motion/MPU6050Sensor.h | 0 src/motion/MotionSensor.cpp | 0 src/motion/MotionSensor.h | 0 src/motion/STK8XXXSensor.cpp | 0 src/motion/STK8XXXSensor.h | 0 src/sleep.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- .../heltec_vision_master_t190/platformio.ini | 2 +- .../m5stack_cardputer_adv/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/variant.h | 3 +- .../seeed-sensecap-indicator/variant.h | 2 +- variants/esp32s3/station-g2/pins_arduino.h | 0 variants/esp32s3/station-g2/platformio.ini | 0 variants/esp32s3/station-g2/variant.h | 0 variants/esp32s3/t-deck/variant.h | 2 +- variants/esp32s3/tlora-pager/variant.h | 2 +- .../esp32s3/tracksenger/internal/variant.h | 3 +- variants/esp32s3/tracksenger/lcd/variant.h | 3 +- variants/esp32s3/tracksenger/oled/variant.h | 3 +- variants/esp32s3/unphone/variant.h | 4 +- .../heltec_mesh_node_t114/platformio.ini | 2 +- .../nrf52840/heltec_mesh_node_t114/variant.h | 3 - .../nrf52840/heltec_mesh_solar/platformio.ini | 2 +- 54 files changed, 2536 insertions(+), 674 deletions(-) create mode 100644 src/graphics/TFTColorRegions.cpp create mode 100644 src/graphics/TFTColorRegions.h create mode 100644 src/graphics/TFTPalette.h mode change 100644 => 100755 src/motion/AccelerometerThread.h mode change 100644 => 100755 src/motion/BMA423Sensor.cpp mode change 100644 => 100755 src/motion/BMA423Sensor.h mode change 100644 => 100755 src/motion/BMX160Sensor.cpp mode change 100644 => 100755 src/motion/BMX160Sensor.h mode change 100644 => 100755 src/motion/ICM20948Sensor.cpp mode change 100644 => 100755 src/motion/ICM20948Sensor.h mode change 100644 => 100755 src/motion/LIS3DHSensor.cpp mode change 100644 => 100755 src/motion/LIS3DHSensor.h mode change 100644 => 100755 src/motion/LSM6DS3Sensor.cpp mode change 100644 => 100755 src/motion/LSM6DS3Sensor.h mode change 100644 => 100755 src/motion/MPU6050Sensor.cpp mode change 100644 => 100755 src/motion/MPU6050Sensor.h mode change 100644 => 100755 src/motion/MotionSensor.cpp mode change 100644 => 100755 src/motion/MotionSensor.h mode change 100644 => 100755 src/motion/STK8XXXSensor.cpp mode change 100644 => 100755 src/motion/STK8XXXSensor.h mode change 100644 => 100755 variants/esp32s3/station-g2/pins_arduino.h mode change 100644 => 100755 variants/esp32s3/station-g2/platformio.ini mode change 100644 => 100755 variants/esp32s3/station-g2/variant.h diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index f315011d8..e8a7f685e 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -39,6 +39,7 @@ along with this program. If not, see . #include "draw/NodeListRenderer.h" #include "draw/NotificationRenderer.h" #include "draw/UIRenderer.h" +#include "graphics/TFTColorRegions.h" #include "modules/CannedMessageModule.h" #if !MESHTASTIC_EXCLUDE_GPS @@ -54,6 +55,7 @@ along with this program. If not, see . #include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTPalette.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "input/TouchScreenImpl1.h" @@ -69,12 +71,6 @@ along with this program. If not, see . #include "target_specific.h" extern MessageStore messageStore; -#if USE_TFTDISPLAY -extern uint16_t TFT_MESH; -#else -uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); -#endif - #if HAS_WIFI && !defined(ARCH_PORTDUINO) #include "mesh/wifi/WiFiAPClient.h" #endif @@ -109,6 +105,27 @@ namespace graphics // A text message frame + debug frame + all the node infos FrameCallback *normalFrames; static uint32_t targetFramerate = IDLE_FRAMERATE; +#if GRAPHICS_TFT_COLORING_ENABLED +static inline void prepareFrameColorRegions() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + clearTFTColorRegions(); + // Full-frame FrameMono inversion for themes that need it (e.g. light themes). + if (isThemeFullFrameInvert()) { + setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(), + screen->getHeight()); + } +#endif +} +#endif + +static inline void updateUiFrame(OLEDDisplayUi *ui) +{ +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif + ui->update(); +} // Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization uint32_t logo_timeout = 5000; // 4 seconds for EACH logo @@ -227,7 +244,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } // Called to trigger a banner with custom message and duration @@ -249,7 +266,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } // Called to trigger a banner with custom message and duration @@ -273,7 +290,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, @@ -296,7 +313,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); ui->setTargetFPS(60); - ui->update(); + updateUiFrame(ui); } static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) @@ -388,30 +405,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O { graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES]; - int32_t rawRGB = uiconfig.screen_rgb_color; - - // Only validate the combined value once - if (rawRGB > 0 && rawRGB <= 255255255) { - LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB); - // Extract each component as a normal int first - int r = (rawRGB >> 16) & 0xFF; - int g = (rawRGB >> 8) & 0xFF; - int b = rawRGB & 0xFF; - if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) { - TFT_MESH = COLOR565(static_cast(r), static_cast(g), static_cast(b)); - } -#ifdef TFT_MESH_OVERRIDE - } else if (rawRGB == 0) { - LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE); - // Default to TFT_MESH_OVERRIDE if available - TFT_MESH = TFT_MESH_OVERRIDE; -#endif - } else { - // Default best readable yellow color - LOG_INFO("Setting screen RGB color to default: (255,255,128)"); - TFT_MESH = COLOR565(255, 255, 128); - } - #if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64) dispdev = new SH1106Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); @@ -474,9 +467,13 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #endif #if defined(USE_ST7789) - static_cast(dispdev)->setRGB(TFT_MESH); + // Keep firmware and ST7789 driver region structs layout-compatible: + // we pass `graphics::colorRegions` through a type cast below. + static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion), + "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion"); + static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions); #elif defined(USE_ST7796) - static_cast(dispdev)->setRGB(TFT_MESH); + static_cast(dispdev)->setRGB(TFTPalette::White); #endif ui = new OLEDDisplayUi(dispdev); @@ -663,16 +660,16 @@ void Screen::setup() static_cast(dispdev)->setSubtype(7); #endif -#if defined(USE_ST7789) && defined(TFT_MESH) - // Apply custom RGB color (e.g. Heltec T114/T190) - static_cast(dispdev)->setRGB(TFT_MESH); +#if defined(USE_ST7789) + static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion), + "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion"); + static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions); #endif #if defined(MUZI_BASE) dispdev->delayPoweron = true; #endif -#if defined(USE_ST7796) && defined(TFT_MESH) - // Custom text color, if defined in variant.h - static_cast(dispdev)->setRGB(TFT_MESH); +#if defined(USE_ST7796) + static_cast(dispdev)->setRGB(TFTPalette::White); #endif // Initialize display and UI system @@ -718,7 +715,7 @@ void Screen::setup() #endif { const char *region = myRegion ? myRegion->name : nullptr; - graphics::UIRenderer::drawIconScreen(region, display, state, x, y); + graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y); } }; ui->setFrames(alertFrames, 1); @@ -757,9 +754,9 @@ void Screen::setup() // Turn on display and trigger first draw handleSetOn(true); graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width()); - ui->update(); + updateUiFrame(ui); #ifndef USE_EINK - ui->update(); // Some SSD1306 clones drop the first draw, so run twice + updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice #endif serialSinceMsec = millis(); @@ -832,7 +829,7 @@ void Screen::forceDisplay(bool forceUiUpdate) do { startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. delay(10); - ui->update(); + updateUiFrame(ui); } while (ui->getUiState()->lastUpdate < startUpdate); // Return to normal frame rate @@ -903,9 +900,9 @@ int32_t Screen::runOnce() static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); ui->setFrames(bootOEMFrames, bootOEMFrameCount); - ui->update(); + updateUiFrame(ui); #ifndef USE_EINK - ui->update(); + updateUiFrame(ui); #endif showingOEMBootScreen = false; } @@ -996,7 +993,7 @@ int32_t Screen::runOnce() // this must be before the frameState == FIXED check, because we always // want to draw at least one FIXED frame before doing forceDisplay - ui->update(); + updateUiFrame(ui); // Switch to a low framerate (to save CPU) when we are not in transition // but we should only call setTargetFPS when framestate changes, because @@ -1058,7 +1055,7 @@ void Screen::setSSLFrames() // LOG_DEBUG("Show SSL frames"); static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen}; ui->setFrames(sslFrames, 1); - ui->update(); + updateUiFrame(ui); } } @@ -1094,7 +1091,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) do { startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow.. delay(1); - ui->update(); + updateUiFrame(ui); } while (ui->getUiState()->lastUpdate < startUpdate); #if defined(USE_EINK_PARALLELDISPLAY) @@ -1469,9 +1466,15 @@ void Screen::blink() dispdev->setBrightness(254); while (count > 0) { dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight()); +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif dispdev->display(); delay(50); dispdev->clear(); +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif dispdev->display(); delay(50); count = count - 1; @@ -1605,6 +1608,9 @@ void Screen::setFastFramerate() { #if defined(M5STACK_UNITC6L) dispdev->clear(); +#if GRAPHICS_TFT_COLORING_ENABLED + prepareFrameColorRegions(); +#endif dispdev->display(); #endif // We are about to start a transition so speed up fps @@ -1816,7 +1822,7 @@ int Screen::handleInputEvent(const InputEvent *event) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); setFastFramerate(); // Draw ASAP - ui->update(); + updateUiFrame(ui); return 0; } @@ -1831,7 +1837,7 @@ int Screen::handleInputEvent(const InputEvent *event) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); setFastFramerate(); // Draw ASAP - ui->update(); + updateUiFrame(ui); menuHandler::handleMenuSwitch(dispdev); return 0; diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index ec50654ae..becd3e75d 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -1,16 +1,20 @@ #include "configuration.h" #if HAS_SCREEN #include "MeshService.h" +#include "NodeDB.h" #include "RTC.h" #include "draw/NodeListRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/draw/UIRenderer.h" #include "main.h" #include "meshtastic/config.pb.h" #include "modules/ExternalNotificationModule.h" #include "power.h" #include +#include #include namespace graphics @@ -65,6 +69,12 @@ uint32_t lastBlinkShared = 0; bool isMailIconVisible = true; uint32_t lastMailBlink = 0; +static inline bool useClockHeaderAccentTheme(uint32_t themeId) +{ + return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen || + themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite; +} + // ********************************* // * Rounded Header when inverted * // ********************************* @@ -85,7 +95,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, // ************************* // * Common Header Drawing * // ************************* -void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date) +void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date, + bool transparent_background, bool use_title_color_override, uint16_t title_color_override) { constexpr int HEADER_OFFSET_Y = 1; y += HEADER_OFFSET_Y; @@ -100,30 +111,93 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti const int screenW = display->getWidth(); const int screenH = display->getHeight(); + const int headerHeight = highlightHeight + 2; + const uint16_t headerColorForRoles = getThemeHeaderBg(); + // Color TFT headers use a fixed dark background + white glyphs. + // Keep legacy inverted bitmap behavior only for monochrome displays. + const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background); +#if GRAPHICS_TFT_COLORING_ENABLED + int statusLeftEndX = 0; + int statusRightStartX = screenW; + const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0'); + const auto activeThemeId = getActiveTheme().id; + const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId); +#endif + + { + const uint16_t headerColor = getThemeHeaderBg(); + const uint16_t headerTextColor = getThemeHeaderText(); + const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor; + uint16_t headerStatusColor = getThemeHeaderStatus(); +#if GRAPHICS_TFT_COLORING_ENABLED + // Clock frame uses transparent header + date + empty title. + // For accent clock themes (Pink/Creamsicle + classic monochrome), tint + // status items (battery outline, %, date, mail icon) to the header accent. + if (useClockHeaderAccent) { + headerStatusColor = getThemeHeaderBg(); + } + + if (transparent_background) { + // Transparent clock headers should inherit whatever body off-color is + // already active under the header (important for light/inverted themes). + const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg()); + setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW, + headerHeight); + setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor); + setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor); + } else if (useInvertedHeaderStyle) { + setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW, + headerHeight); + setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole); + setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor); + } else { + setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW, + headerHeight); + setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor); + setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor); + } +#endif - if (!force_no_invert) { // === Inverted Header Background === - if (isInverted) { + if (useInvertedHeaderStyle) { display->setColor(BLACK); - display->fillRect(0, 0, screenW, highlightHeight + 2); + display->fillRect(0, 0, screenW, headerHeight); display->setColor(WHITE); drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2); display->setColor(BLACK); } else { display->setColor(BLACK); - display->fillRect(0, 0, screenW, highlightHeight + 2); - display->setColor(WHITE); - if (currentResolution == ScreenResolution::High) { - display->drawLine(0, 20, screenW, 20); - } else { - display->drawLine(0, 14, screenW, 14); + display->fillRect(0, 0, screenW, headerHeight); +// Keep the legacy white separator for monochrome displays only when header background is visible. +#if !GRAPHICS_TFT_COLORING_ENABLED + if (!transparent_background) { + display->setColor(WHITE); + if (currentResolution == ScreenResolution::High) { + display->drawLine(0, 20, screenW, 20); + } else { + display->drawLine(0, 14, screenW, 14); + } } +#endif } + if (transparent_background) { + display->setColor(WHITE); + } + +#if GRAPHICS_TFT_COLORING_ENABLED + // TFT role coloring expects foreground glyph bits to be "set". + display->setColor(WHITE); +#endif + // === Screen Title === const char *headerTitle = titleStr ? titleStr : ""; const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); const int titleX = (SCREEN_WIDTH - titleWidth) / 2; +#if GRAPHICS_TFT_COLORING_ENABLED + const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2); + registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL); +#endif UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -152,6 +226,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH); const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + bool hasBatteryFillRegion = false; + int16_t batteryFillRegionX = 0; + int16_t batteryFillRegionY = 0; + int16_t batteryFillRegionW = 0; + int16_t batteryFillRegionH = 0; +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent); + if (useClockHeaderAccent) { + batteryFillColor = getThemeHeaderBg(); + } +#endif int batteryX = 1; int batteryY = HEADER_OFFSET_Y + 1; @@ -180,6 +265,15 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12); int fillWidth = 14 * chargePercent / 100; display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11); +#if GRAPHICS_TFT_COLORING_ENABLED + if (fillWidth > 0) { + hasBatteryFillRegion = true; + batteryFillRegionX = batteryX + 1; + batteryFillRegionY = batteryY + 1; + batteryFillRegionW = fillWidth; + batteryFillRegionH = 11; + } +#endif } batteryX += 18; // Icon + 2 pixels } else { @@ -194,21 +288,41 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int fillHeight = 8 * chargePercent / 100; int fillY = batteryY - fillHeight; display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight); +#if GRAPHICS_TFT_COLORING_ENABLED + if (fillHeight > 0) { + hasBatteryFillRegion = true; + batteryFillRegionX = batteryX + 1; + batteryFillRegionY = fillY + 10; + batteryFillRegionW = 5; + batteryFillRegionH = fillHeight; + } +#endif } batteryX += 9; // Icon + 2 pixels } } +#if GRAPHICS_TFT_COLORING_ENABLED + statusLeftEndX = batteryX + 2; +#endif if (chargePercent != 101) { // === Battery % Display === char chargeStr[4]; snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent); int chargeNumWidth = display->getStringWidth(chargeStr); + const int percentWidth = display->getStringWidth("%"); + const int percentX = batteryX + chargeNumWidth - 1; display->drawString(batteryX, textY, chargeStr); - display->drawString(batteryX + chargeNumWidth - 1, textY, "%"); + display->drawString(percentX, textY, "%"); +#if GRAPHICS_TFT_COLORING_ENABLED + statusLeftEndX = percentX + percentWidth + 2; +#endif if (isBold) { display->drawString(batteryX + 1, textY, chargeStr); - display->drawString(batteryX + chargeNumWidth, textY, "%"); + display->drawString(percentX + 1, textY, "%"); +#if GRAPHICS_TFT_COLORING_ENABLED + statusLeftEndX = percentX + percentWidth + 3; +#endif } } @@ -253,6 +367,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti timeStrWidth = display->getStringWidth(timeStr); } timeX = screenW - xOffset - timeStrWidth + 3; +#if GRAPHICS_TFT_COLORING_ENABLED + statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16); +#endif // === Show Mail or Mute Icon to the Left of Time === int iconRightEdge = timeX - 2; @@ -278,7 +395,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconW = 16, iconH = 12; int iconX = iconRightEdge - iconW; int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); display->setColor(BLACK); @@ -293,7 +410,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } else { int iconX = iconRightEdge - (mail_width - 2); int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); display->setColor(BLACK); @@ -309,7 +426,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconX = iconRightEdge - mute_symbol_big_width; int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2); display->setColor(BLACK); @@ -323,7 +440,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti int iconX = iconRightEdge - mute_symbol_width; int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; - if (isInverted && !force_no_invert) { + if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2); display->setColor(BLACK); @@ -351,7 +468,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } else { // === No Time Available: Mail/Mute Icon Moves to Far Right === int iconRightEdge = screenW - xOffset; - +#if GRAPHICS_TFT_COLORING_ENABLED + statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12); +#endif bool showMail = false; #ifndef USE_EINK @@ -393,6 +512,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } } } +#endif +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight); + if (statusRightStartX < screenW) { + registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight); + } + if (hasBatteryFillRegion) { + registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH, + batteryFillColor, headerColorForRoles); + } #endif display->setColor(WHITE); // Reset for other UI } @@ -430,14 +559,23 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) return; const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; + const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); + const int footerH = (connection_icon_height * scale) + (2 * scale); + const int iconX = 0; + const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); + const int iconW = connection_icon_width * scale; + const int iconH = connection_icon_height * scale; + +#if GRAPHICS_TFT_COLORING_ENABLED + // Only tint the link glyph itself on TFT; keep the footer background black. + setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH); +#endif + display->setColor(BLACK); - display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale), - (connection_icon_height * scale) + (2 * scale)); + display->fillRect(0, footerY, SCREEN_WIDTH, footerH); display->setColor(WHITE); if (currentResolution == ScreenResolution::High) { const int bytesPerRow = (connection_icon_width + 7) / 8; - int iconX = 0; - int iconY = SCREEN_HEIGHT - (connection_icon_height * 2); for (int yy = 0; yy < connection_icon_height; ++yy) { const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; @@ -451,65 +589,127 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) } } else { - display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height, - connection_icon); + display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon); } } bool isAllowedPunctuation(char c) { - const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ "; - return allowed.find(c) != std::string::npos; + switch (c) { + case '.': + case ',': + case '!': + case '?': + case ';': + case ':': + case '-': + case '_': + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '\'': + case '"': + case '@': + case '#': + case '$': + case '/': + case '\\': + case '&': + case '+': + case '=': + case '%': + case '~': + case '^': + case ' ': + return true; + default: + return false; + } } -static void replaceAll(std::string &s, const std::string &from, const std::string &to) +static inline size_t utf8CodePointLength(unsigned char lead) { - if (from.empty()) - return; - size_t pos = 0; - while ((pos = s.find(from, pos)) != std::string::npos) { - s.replace(pos, from.size(), to); - pos += to.size(); + if ((lead & 0x80) == 0x00) { + return 1; } + if ((lead & 0xE0) == 0xC0) { + return 2; + } + if ((lead & 0xF0) == 0xE0) { + return 3; + } + if ((lead & 0xF8) == 0xF0) { + return 4; + } + return 1; } std::string sanitizeString(const std::string &input) { + static constexpr char kReplacementChar = static_cast(0xBF); // Inverted question mark in ISO-8859-1. std::string output; + output.reserve(input.size()); bool inReplacement = false; - - // Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first. - std::string s = input; - - // Curly single quotes: ‘ ’ - replaceAll(s, "\xE2\x80\x98", "'"); // U+2018 - replaceAll(s, "\xE2\x80\x99", "'"); // U+2019 - - // Curly double quotes: “ ” - replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C - replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D - - // En dash / Em dash: – — - replaceAll(s, "\xE2\x80\x93", "-"); // U+2013 - replaceAll(s, "\xE2\x80\x94", "-"); // U+2014 - - // Non-breaking space - replaceAll(s, "\xC2\xA0", " "); // U+00A0 - - // Now do your original sanitize pass over the normalized string. - for (unsigned char uc : s) { - char c = static_cast(uc); - if (std::isalnum(uc) || isAllowedPunctuation(c)) { - output += c; - inReplacement = false; - } else { + const size_t inputSize = input.size(); + size_t i = 0; + while (i < inputSize) { + const unsigned char byte0 = static_cast(input[i]); + char normalized = '\0'; + size_t consumed = 0; + if (byte0 < 0x80) { + normalized = static_cast(byte0); + consumed = 1; + } else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast(input[i + 1]) == 0x80) { + // Smart punctuation: ' ' \" \" - - + switch (static_cast(input[i + 2])) { + case 0x98: + case 0x99: + normalized = '\''; + consumed = 3; + break; + case 0x9C: + case 0x9D: + normalized = '\"'; + consumed = 3; + break; + case 0x93: + case 0x94: + normalized = '-'; + consumed = 3; + break; + default: + break; + } + } else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast(input[i + 1]) == 0xA0) { + // Non-breaking space. + normalized = ' '; + consumed = 2; + } + if (consumed == 0) { + size_t seqLen = utf8CodePointLength(byte0); + if (seqLen > (inputSize - i)) { + seqLen = 1; + } if (!inReplacement) { - output += static_cast(0xBF); // ISO-8859-1 for inverted question mark + output.push_back(kReplacementChar); inReplacement = true; } + i += seqLen; + continue; } + const unsigned char normalizedUc = static_cast(normalized); + if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) { + output.push_back(normalized); + inReplacement = false; + } else if (!inReplacement) { + output.push_back(kReplacementChar); + inReplacement = true; + } + i += consumed; } - return output; } diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h index 35e767056..95244d099 100644 --- a/src/graphics/SharedUIDisplay.h +++ b/src/graphics/SharedUIDisplay.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace graphics @@ -52,7 +53,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, // Shared battery/time/mail header void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false, - bool show_date = false); + bool show_date = false, bool transparent_background = false, bool use_title_color_override = false, + uint16_t title_color_override = 0); // Shared battery/time/mail header void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y); diff --git a/src/graphics/TFTColorRegions.cpp b/src/graphics/TFTColorRegions.cpp new file mode 100644 index 000000000..877835ea6 --- /dev/null +++ b/src/graphics/TFTColorRegions.cpp @@ -0,0 +1,819 @@ +#include "TFTColorRegions.h" +#include "NodeDB.h" +#include "TFTPalette.h" + +#include + +namespace graphics +{ +TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS]; + +namespace +{ + +struct TFTRoleColorsBe { + uint16_t onColorBe; + uint16_t offColorBe; +}; + +static uint8_t colorRegionCount = 0; +static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u; +static constexpr uint32_t kFnv1aPrime = 16777619u; + +static constexpr uint16_t toBe565(uint16_t color) +{ + return static_cast((color >> 8) | (color << 8)); +} + +static constexpr bool kRoleIsBody[static_cast(TFTColorRole::Count)] = { + false, // HeaderBackground + false, // HeaderTitle + false, // HeaderStatus + true, // SignalBars + true, // ConnectionIcon + true, // UtilizationFill + true, // FavoriteNode + true, // ActionMenuBorder + true, // ActionMenuBody + true, // ActionMenuTitle + true, // FrameMono + false, // BootSplash + true, // FavoriteNodeBGHighlight + false, // NavigationBar + false // NavigationArrow +}; + +static inline bool isBodyColorRole(TFTColorRole role) +{ + return kRoleIsBody[static_cast(role)]; +} + +static inline bool isMonochromeTheme(uint32_t themeId) +{ + return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite; +} + +static inline uint16_t getMonochromeAccent(uint32_t themeId) +{ + return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen + : (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed + : TFTPalette::White; +} + +static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to) +{ + if (value == from) { + value = to; + } +} + +static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value) +{ + return (hash ^ value) * kFnv1aPrime; +} + +static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value) +{ + hash = fnv1aAppendByte(hash, static_cast(value & 0xFF)); + hash = fnv1aAppendByte(hash, static_cast((value >> 8) & 0xFF)); + return hash; +} + +// Compile-time header color overrides (backward-compatible) +#ifdef TFT_HEADER_BG_COLOR_OVERRIDE +static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE; +#else +static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray; +#endif + +#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE +static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE; +#else +static constexpr uint16_t kTitleColor = TFTPalette::White; +#endif + +#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE +static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE; +#else +static constexpr uint16_t kStatusColor = TFTPalette::White; +#endif + +// Theme definitions +// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color +// against each entry's .uniqueIdentifier field. + +static const TFTThemeDef kThemes[] = { + + // Default Dark (ThemeID::DefaultDark = 0) + { + ThemeID::DefaultDark, // id + "Default Dark", // name + 0, // uniqueIdentifier + // roles[TFTColorRole::Count] + { + {kHeaderBackground, TFTPalette::Black}, // HeaderBackground + {kHeaderBackground, kTitleColor}, // HeaderTitle + {kHeaderBackground, kStatusColor}, // HeaderStatus + {TFTPalette::Good, TFTPalette::Black}, // SignalBars + {TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::Good, TFTPalette::Black}, // UtilizationFill + {TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode + {TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::Black}, // BootSplash + {TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight + {kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg) + {kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::Good, // batteryFillGood + TFTPalette::Medium, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + false, // fullFrameInvert + true, // visible + }, + + // Default Light (ThemeID::DefaultLight = 1) + { + ThemeID::DefaultLight, // id + "Default Light", // name + 1, // uniqueIdentifier + { + {TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground + {TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle + {TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus + {TFTPalette::Good, TFTPalette::White}, // SignalBars + {TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon + {TFTPalette::Good, TFTPalette::White}, // UtilizationFill + {TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode + {TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder + {TFTPalette::Black, TFTPalette::White}, // ActionMenuBody + {TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::Black}, // BootSplash + {TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight + {TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg) + {TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::Good, // batteryFillGood + TFTPalette::Medium, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Christmas (ThemeID::Christmas = 2) + { + ThemeID::Christmas, // id + "Christmas", // name + 2, // uniqueIdentifier + { + {TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground + {TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle + {TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus + {TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars + {TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon + {TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill + {TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode + {TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody + {TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Pine, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash + {TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight + {TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg) + {TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::ChristmasGreen, // batteryFillGood + TFTPalette::Gold, // batteryFillMedium + TFTPalette::ChristmasRed, // batteryFillBad + true, // fullFrameInvert + false, // visible + }, + + // Pink (ThemeID::Pink = 3) light variant + { + ThemeID::Pink, // id + "Pink", // name + 3, // uniqueIdentifier + { + {TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground + {TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle + {TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus + {TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars + {TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon + {TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill + {TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode + {TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder + {TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody + {TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::HotPink}, // BootSplash + {TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight + {TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg) + {TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::DeepPink, // batteryFillGood + TFTPalette::HotPink, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Blue (ThemeID::Blue = 4) dark variant + { + ThemeID::Blue, // id + "Blue", // name + 4, // uniqueIdentifier + { + {TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground + {TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle + {TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus + {TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars + {TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon + {TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill + {TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode + {TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody + {TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Navy, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash + {TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight + {TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg) + {TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::SkyBlue, // batteryFillGood + TFTPalette::Medium, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Creamsicle (ThemeID::Creamsicle = 5)light variant + { + ThemeID::Creamsicle, // id + "Creamsicle", // name + 5, // uniqueIdentifier + { + {TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground + {TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle + {TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus + {TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars + {TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon + {TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill + {TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode + {TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder + {TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody + {TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono + {TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash + {TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight + {TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg) + {TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg) + }, + TFTPalette::DeepOrange, // batteryFillGood + TFTPalette::Gold, // batteryFillMedium + TFTPalette::Bad, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome + // Pure single-color-on-black look. Every role maps foreground pixels to + // the theme color and background pixels to Black. + { + ThemeID::MeshtasticGreen, // id + "Meshtastic Green", // name + 6, // uniqueIdentifier + { + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg) + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar + {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow + }, + TFTPalette::Black, // batteryFillGood + TFTPalette::Black, // batteryFillMedium + TFTPalette::Black, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Classic Red (ThemeID::ClassicRed = 7) classic monochrome + { + ThemeID::ClassicRed, // id + "Classic Red", // name + 7, // uniqueIdentifier + { + {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground + {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle + {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus + {TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars + {TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill + {TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode + {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg) + {TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash + {TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight + {TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar + {TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow + }, + TFTPalette::Black, // batteryFillGood + TFTPalette::Black, // batteryFillMedium + TFTPalette::Black, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, + + // Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome + { + ThemeID::MonochromeWhite, // id + "Monochrome White", // name + 8, // uniqueIdentifier + { + {TFTPalette::White, TFTPalette::Black}, // HeaderBackground + {TFTPalette::White, TFTPalette::Black}, // HeaderTitle + {TFTPalette::White, TFTPalette::Black}, // HeaderStatus + {TFTPalette::White, TFTPalette::Black}, // SignalBars + {TFTPalette::White, TFTPalette::Black}, // ConnectionIcon + {TFTPalette::White, TFTPalette::Black}, // UtilizationFill + {TFTPalette::White, TFTPalette::Black}, // FavoriteNode + {TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder + {TFTPalette::White, TFTPalette::Black}, // ActionMenuBody + {TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle + {TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg) + {TFTPalette::White, TFTPalette::Black}, // BootSplash + {TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight + {TFTPalette::White, TFTPalette::Black}, // NavigationBar + {TFTPalette::White, TFTPalette::Black}, // NavigationArrow + }, + TFTPalette::Black, // batteryFillGood + TFTPalette::Black, // batteryFillMedium + TFTPalette::Black, // batteryFillBad + true, // fullFrameInvert + true, // visible + }, +}; + +static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]); + +// Resolve the kThemes[] index for the currently persisted theme. Called at +// boot (indirectly via getActiveTheme()) and whenever the active theme is +// queried, so uiconfig.screen_rgb_color remains the single source of truth. +// Matches against .uniqueIdentifier - that's the field whose value is stored +// in the user's config. Falls back to 0 (DefaultDark) if no match is found, +// which gracefully handles removed or retired themes. +static inline size_t resolveThemeIndex() +{ + const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F; + for (size_t i = 0; i < kInternalThemeCount; i++) { + if (kThemes[i].uniqueIdentifier == savedIdentifier) + return i; + } + return 0; // Default Dark fallback +} + +static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height) +{ + if (width <= 0 || height <= 0) { + return false; + } + + if (x < 0) { + width += x; + x = 0; + } + if (y < 0) { + height += y; + y = 0; + } + + return width > 0 && height > 0; +} + +static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe) +{ + // Keep the last slot permanently disabled as a sentinel for ST7789 scans. + // This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries. + if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) { + memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2)); + colorRegionCount = MAX_TFT_COLOR_REGIONS - 2; + } + + TFTColorRegion ®ion = colorRegions[colorRegionCount++]; + region.x = x; + region.y = y; + region.width = width; + region.height = height; + region.onColorBe = onColorBe; + region.offColorBe = offColorBe; + region.enabled = true; + + // Keep one disabled sentinel after the active range for ST7789 countColorRegions(). + if (colorRegionCount < MAX_TFT_COLOR_REGIONS) { + colorRegions[colorRegionCount].enabled = false; + } + colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false; +} + +// Current working role colors (big-endian). Initialised to Dark defaults; +// call loadThemeDefaults() after boot / theme change to refresh. +static TFTRoleColorsBe roleColors[static_cast(TFTColorRole::Count)] = { + {toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground + {toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle + {toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus + {toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars + {toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon + {toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill + {toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode + {toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder + {toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody + {toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle + {toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono + {toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash + {toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight + {toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar + {toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow +}; + +} // namespace + +// Theme accessors + +const TFTThemeDef &getActiveTheme() +{ + return kThemes[resolveThemeIndex()]; +} + +// Visible-theme accessors +// These iterate only themes flagged .visible = true, preserving kThemes[] +// order. Menu code should use these so hidden themes don't appear in the +// picker while still applying correctly if their ID is persisted. + +size_t getVisibleThemeCount() +{ + size_t count = 0; + for (size_t i = 0; i < kInternalThemeCount; i++) { + if (kThemes[i].visible) + count++; + } + return count; +} + +const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex) +{ + size_t seen = 0; + for (size_t i = 0; i < kInternalThemeCount; i++) { + if (!kThemes[i].visible) + continue; + if (seen == visibleIndex) + return kThemes[i]; + seen++; + } + // Fallback: return first theme (never trust a bad index). + return kThemes[0]; +} + +size_t getActiveVisibleThemeIndex() +{ + const size_t active = resolveThemeIndex(); + if (!kThemes[active].visible) + return SIZE_MAX; + size_t visibleIdx = 0; + for (size_t i = 0; i < active; i++) { + if (kThemes[i].visible) + visibleIdx++; + } + return visibleIdx; +} + +uint16_t getThemeHeaderBg() +{ +#if GRAPHICS_TFT_COLORING_ENABLED +#ifdef TFT_HEADER_BG_COLOR_OVERRIDE + return TFT_HEADER_BG_COLOR_OVERRIDE; +#else + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderBackground)].onColor; +#endif +#else + return TFTPalette::DarkGray; +#endif +} + +uint16_t getThemeHeaderText() +{ +#if GRAPHICS_TFT_COLORING_ENABLED +#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE + return TFT_HEADER_TITLE_COLOR_OVERRIDE; +#else + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderTitle)].offColor; +#endif +#else + return TFTPalette::White; +#endif +} + +uint16_t getThemeHeaderStatus() +{ +#if GRAPHICS_TFT_COLORING_ENABLED +#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE + return TFT_HEADER_STATUS_COLOR_OVERRIDE; +#else + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderStatus)].offColor; +#endif +#else + return TFTPalette::White; +#endif +} + +uint16_t getThemeBodyBg() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::FrameMono)].onColor; +#else + return TFTPalette::Black; +#endif +} + +uint16_t getThemeBodyFg() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::FrameMono)].offColor; +#else + return TFTPalette::White; +#endif +} + +bool isThemeFullFrameInvert() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return kThemes[resolveThemeIndex()].fullFrameInvert; +#else + return false; +#endif +} + +uint16_t getThemeBatteryFillColor(int batteryPercent) +{ + const TFTThemeDef &theme = kThemes[resolveThemeIndex()]; + if (batteryPercent <= 20) { + return theme.batteryFillBad; + } + if (batteryPercent <= 50) { + return theme.batteryFillMedium; + } + return theme.batteryFillGood; +} + +void loadThemeDefaults() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + const TFTThemeDef &theme = kThemes[resolveThemeIndex()]; + for (uint8_t i = 0; i < static_cast(TFTColorRole::Count); i++) { + roleColors[i].onColorBe = toBe565(theme.roles[i].onColor); + roleColors[i].offColorBe = toBe565(theme.roles[i].offColor); + } +#endif +} + +// Role color assignment with theme-aware transforms + +void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return; +#endif + + const uint8_t index = static_cast(role); + if (index >= static_cast(TFTColorRole::Count)) { + return; + } + + const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F; + const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight); + const bool isBodyRole = !isHighlightRole && isBodyColorRole(role); + + // Classic monochrome themes collapse all non-black accents into one tone. + if (isMonochromeTheme(themeId)) { + if (onColor != TFTPalette::Black) { + onColor = getMonochromeAccent(themeId); + } + } else { + switch (themeId) { + case ThemeID::DefaultLight: + if (isHighlightRole) { + // High-contrast highlight chips on light UI. + onColor = TFTPalette::Black; + offColor = TFTPalette::Yellow; + } else if (isBodyRole) { + // Invert body colors for readability on white frames. + if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) { + offColor = TFTPalette::White; + } + replaceColor(onColor, TFTPalette::White, TFTPalette::Black); + } + break; + case ThemeID::Christmas: + if (isHighlightRole || isBodyRole) { + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold); + replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine); + } + break; + case ThemeID::Pink: + if (isHighlightRole) { + onColor = TFTPalette::Black; + offColor = TFTPalette::HotPink; + } else if (isBodyRole) { + replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink); + replaceColor(onColor, TFTPalette::White, TFTPalette::Black); + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink); + } + break; + case ThemeID::Creamsicle: + if (isHighlightRole) { + onColor = TFTPalette::Black; + offColor = TFTPalette::CreamOrange; + } else if (isBodyRole) { + replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream); + replaceColor(onColor, TFTPalette::White, TFTPalette::Black); + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange); + } + break; + case ThemeID::Blue: + if (isHighlightRole || isBodyRole) { + replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue); + replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy); + } + break; + default: + break; + } + } + + roleColors[index].onColorBe = toBe565(onColor); + roleColors[index].offColorBe = toBe565(offColor); +} + +// Region registration + +void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return; +#endif + + const uint8_t roleIndex = static_cast(role); + if (roleIndex >= static_cast(TFTColorRole::Count)) { + return; + } + + if (!normalizeRegion(x, y, width, height)) { + return; + } + + const TFTRoleColorsBe &colors = roleColors[roleIndex]; + appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe); +} + +void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width, + int16_t height) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + (void)role; + (void)onColor; + (void)offColor; + (void)x; + (void)y; + (void)width; + (void)height; + return; +#else + setTFTColorRole(role, onColor, offColor); + registerTFTColorRegion(role, x, y, width, height); +#endif +} + +void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return; +#endif + + if (!normalizeRegion(x, y, width, height)) + return; + + appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor)); +} + +void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + (void)boxLeft; + (void)boxTop; + (void)boxWidth; + (void)boxHeight; + return; +#else + // Use theme-appropriate menu colors. + const TFTThemeDef &theme = kThemes[resolveThemeIndex()]; + const TFTThemeRoleColor &menuBody = theme.roles[static_cast(TFTColorRole::ActionMenuBody)]; + const TFTThemeRoleColor &menuBorder = theme.roles[static_cast(TFTColorRole::ActionMenuBorder)]; + + // Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly. + setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1, + boxWidth + 2, boxHeight + 2); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight); + + setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth, + 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1); + registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight); + registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight); +#endif +} + +// Frame signature & utilities + +uint32_t getTFTColorFrameSignature() +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return 0; +#else + uint32_t hash = kFnv1aOffsetBasis; + hash = fnv1aAppendByte(hash, colorRegionCount); + for (uint8_t i = 0; i < colorRegionCount; i++) { + const TFTColorRegion &r = colorRegions[i]; + hash = fnv1aAppendU16(hash, static_cast(r.x)); + hash = fnv1aAppendU16(hash, static_cast(r.y)); + hash = fnv1aAppendU16(hash, static_cast(r.width)); + hash = fnv1aAppendU16(hash, static_cast(r.height)); + hash = fnv1aAppendU16(hash, r.onColorBe); + hash = fnv1aAppendU16(hash, r.offColorBe); + } + + return hash; +#endif +} + +uint8_t getTFTColorRegionCount() +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + return 0; +#else + return colorRegionCount; +#endif +} + +void clearTFTColorRegions() +{ + for (uint8_t i = 0; i < colorRegionCount; i++) { + colorRegions[i].enabled = false; + } + if (colorRegionCount < MAX_TFT_COLOR_REGIONS) { + colorRegions[colorRegionCount].enabled = false; + } + colorRegionCount = 0; +} + +uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor) +{ + for (int i = static_cast(colorRegionCount) - 1; i >= 0; i--) { + const TFTColorRegion &r = colorRegions[i]; + if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) { + return isset ? r.onColorBe : r.offColorBe; + } + } + return isset ? defaultOnColor : defaultOffColor; +} + +uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor) +{ +#if !GRAPHICS_TFT_COLORING_ENABLED + (void)x; + (void)y; + return defaultOffColor; +#else + const uint16_t defaultOffBe = toBe565(defaultOffColor); + const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe); + return static_cast((sampledBe >> 8) | (sampledBe << 8)); +#endif +} + +} // namespace graphics diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h new file mode 100644 index 000000000..fd35bdb1a --- /dev/null +++ b/src/graphics/TFTColorRegions.h @@ -0,0 +1,163 @@ +#pragma once + +#include "configuration.h" +#include + +namespace graphics +{ + +struct TFTColorRegion { + int16_t x; + int16_t y; + int16_t width; + int16_t height; + uint16_t onColorBe; + uint16_t offColorBe; + // Required by ST7789 driver: it scans until the first disabled entry. + bool enabled = false; +}; + +static constexpr size_t MAX_TFT_COLOR_REGIONS = 48; +extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS]; + +enum class TFTColorRole : uint8_t { + HeaderBackground = 0, + HeaderTitle, + HeaderStatus, + SignalBars, + ConnectionIcon, + UtilizationFill, + FavoriteNode, + ActionMenuBorder, + ActionMenuBody, + ActionMenuTitle, + FrameMono, + BootSplash, + FavoriteNodeBGHighlight, + NavigationBar, + NavigationArrow, + Count +}; + +#if HAS_TFT || defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \ + defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \ + defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR) +#define GRAPHICS_TFT_COLORING_ENABLED 1 +#else +#define GRAPHICS_TFT_COLORING_ENABLED 0 +#endif + +static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0; +constexpr bool isTFTColoringEnabled() +{ + return kTFTColoringEnabled; +} + +void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor); +void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height); +// Convenience helper for the common "set role then register one region" flow. +void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width, + int16_t height); +// Register a region using explicit colors (no role lookup). Use when the +// color comes from a theme field rather than a role (e.g. battery fill). +void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor); +void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight); +uint32_t getTFTColorFrameSignature(); +uint8_t getTFTColorRegionCount(); +void clearTFTColorRegions(); +uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor); +// Resolve effective region-mapped OFF color at a coordinate in native-endian RGB565. +uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor); + +// -- Theme engine ------------------------------------------------------ +// Each theme has four fields that work together: +// +// id - ThemeID:: constant, used for in-code references. +// name - human-readable label shown in the theme picker. +// uniqueIdentifier - the stable numeric value persisted to +// uiconfig.screen_rgb_color and restored at boot. +// This is a CONTRACT with saved configs on disk - once +// assigned, never reuse or renumber, even if the theme is +// deleted or the kThemes[] array is reordered. +// visible - controls whether a theme appears in the picker menu. +// Hidden themes can still be restored and applied if their +// uniqueIdentifier is persisted. +// +// Display order in the menu is controlled by kThemes[] array order among +// themes where visible == true, NOT by any numeric value above. +// +// To add a new theme: +// 1. Add a unique constant in ThemeID below (next unused value). +// 2. Add a kThemes[] entry at the desired menu position, with a unique +// uniqueIdentifier that has never been used by any prior theme. +// 3. Set visible=true if it should appear in the picker. +// +// To retire a theme without breaking saved configs: +// - Preferred: keep the entry and set visible=false so existing saved +// uniqueIdentifier values still resolve to the same theme. +// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark +// when the persisted uniqueIdentifier no longer matches any theme. +// - Do NOT reuse a retired uniqueIdentifier for a future theme. +namespace ThemeID +{ +constexpr uint32_t DefaultDark = 0; +constexpr uint32_t DefaultLight = 1; +constexpr uint32_t Christmas = 2; +constexpr uint32_t Pink = 3; +constexpr uint32_t Blue = 4; +constexpr uint32_t Creamsicle = 5; +constexpr uint32_t MeshtasticGreen = 6; +constexpr uint32_t ClassicRed = 7; +constexpr uint32_t MonochromeWhite = 8; +} // namespace ThemeID + +// Per-role color pair stored in native (little-endian) RGB565 format. +struct TFTThemeRoleColor { + uint16_t onColor; + uint16_t offColor; +}; + +// Complete theme definition. +struct TFTThemeDef { + uint32_t id; // ThemeID constant - in-code identifier for this theme. + const char *name; // Human-readable label shown in the theme picker. + uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color. + // Never reuse or renumber - see file-level notes above. + TFTThemeRoleColor roles[static_cast(TFTColorRole::Count)]; + uint16_t batteryFillGood; + uint16_t batteryFillMedium; + uint16_t batteryFillBad; + bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes) + bool visible; // Show in the theme picker menu. Hidden themes still apply + // correctly if their uniqueIdentifier is persisted (dev/legacy themes). +}; + +// Count of themes whose .visible flag is true. Use this when building menus. +size_t getVisibleThemeCount(); + +// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes +// are skipped, preserving kThemes[] order among the visible entries. +const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex); + +// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark). +const TFTThemeDef &getActiveTheme(); + +// Return the visible-theme index for the currently active theme, or SIZE_MAX +// if the active theme is hidden (so menus can show "no selection"). +size_t getActiveVisibleThemeIndex(); + +// Convenience accessors - safe to call even when coloring is compiled out. +uint16_t getThemeHeaderBg(); +uint16_t getThemeHeaderText(); +uint16_t getThemeHeaderStatus(); +uint16_t getThemeBodyBg(); +uint16_t getThemeBodyFg(); +bool isThemeFullFrameInvert(); +uint16_t getThemeBatteryFillColor(int batteryPercent); + +// Reinitialise default roleColors from the active theme. Call after a +// theme change so that any role registered without a prior setTFTColorRole() +// picks up theme-appropriate defaults. +void loadThemeDefaults(); + +} // namespace graphics diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 4c8272955..7df0c57cc 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -16,12 +16,6 @@ extern SX1509 gpioExtender; #endif -#ifdef TFT_MESH_OVERRIDE -uint16_t TFT_MESH = TFT_MESH_OVERRIDE; -#else -uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94); -#endif - #if defined(ST7735S) #include // Graphics and font library for ST7735 driver chip @@ -1140,7 +1134,9 @@ static LGFX *tft = nullptr; #endif #include "SPILock.h" +#include "TFTColorRegions.h" #include "TFTDisplay.h" +#include "TFTPalette.h" #include #ifdef UNPHONE @@ -1150,6 +1146,25 @@ extern unPhone unphone; GpioPin *TFTDisplay::backlightEnable = NULL; +namespace +{ +static constexpr uint8_t kFullRepaintChunkRows = 8; + +static inline uint16_t getThemeDefaultOnColor() +{ + return graphics::TFTPalette::White; +} + +static inline uint16_t getThemeDefaultOffColor() +{ +#if GRAPHICS_TFT_COLORING_ENABLED + return graphics::getThemeBodyBg(); +#else + return TFT_BLACK; +#endif +} +} // namespace + TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus) { LOG_DEBUG("TFTDisplay!"); @@ -1189,14 +1204,15 @@ TFTDisplay::~TFTDisplay() free(linePixelBuffer); linePixelBuffer = nullptr; } + if (repaintChunkBuffer != nullptr) { + free(repaintChunkBuffer); + repaintChunkBuffer = nullptr; + } } // Write the buffer to the display memory void TFTDisplay::display(bool fromBlank) { - if (fromBlank) - tft->fillScreen(TFT_BLACK); - concurrency::LockGuard g(spiLock); uint32_t x, y; @@ -1205,12 +1221,70 @@ void TFTDisplay::display(bool fromBlank) uint32_t x_FirstPixelUpdate; uint32_t x_LastPixelUpdate; bool isset, dblbuf_isset; - uint16_t colorTftMesh, colorTftBlack; + uint16_t colorTftWhite, colorTftBlack; bool somethingChanged = false; - // Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step - colorTftMesh = __builtin_bswap16(TFT_MESH); - colorTftBlack = __builtin_bswap16(TFT_BLACK); + // Theme defaults for non-role pixels. + const uint16_t defaultOnColor = getThemeDefaultOnColor(); + const uint16_t defaultOffColor = getThemeDefaultOffColor(); + static uint16_t lastDefaultOnColor = 0; + static uint16_t lastDefaultOffColor = 0; + static bool haveLastDefaults = false; + const bool themeDefaultsChanged = + !haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor); + const bool forceFullRepaint = fromBlank || themeDefaultsChanged; + + // If theme defaults changed, reset panel background immediately so stale pixels don't linger. + if (forceFullRepaint) { + tft->fillScreen(defaultOffColor); + } + + colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8); + colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8); + +#if GRAPHICS_TFT_COLORING_ENABLED + static uint32_t lastColorFrameSignature = 0; + const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0; + const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature(); + const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature); + + // When region roles/layout changed, color can differ even with identical monochrome glyph bits. + // Repaint full frame only for those frames, then return to diff-based updates. + if (forceFullColorRepaint) { + for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) { + const uint32_t rowsThisChunk = min(kFullRepaintChunkRows, displayHeight - yStart); + for (uint32_t row = 0; row < rowsThisChunk; row++) { + y = yStart + row; + y_byteIndex = (y / 8) * displayWidth; + y_byteMask = (1 << (y & 7)); + + uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth); + for (x = 0; x < displayWidth; x++) { + isset = (buffer[x + y_byteIndex] & y_byteMask) != 0; + if (hasColorRegions) { + chunkRow[x] = graphics::resolveTFTColorPixel(static_cast(x), static_cast(y), isset, + colorTftWhite, colorTftBlack); + } else { + chunkRow[x] = isset ? colorTftWhite : colorTftBlack; + } + } + } +#if defined(HACKADAY_COMMUNICATOR) + tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk); +#else + tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer); +#endif + } + + memcpy(buffer_back, buffer, displayBufferSize); + lastColorFrameSignature = colorFrameSignature; + haveLastDefaults = true; + lastDefaultOnColor = defaultOnColor; + lastDefaultOffColor = defaultOffColor; + graphics::clearTFTColorRegions(); + return; + } +#endif y = 0; while (y < displayHeight) { @@ -1219,7 +1293,7 @@ void TFTDisplay::display(bool fromBlank) // Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas. if (y_byteMask == 1) { - if (!fromBlank) { + if (!forceFullRepaint) { for (x = 0; x < displayWidth; x++) { if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex]) break; @@ -1237,13 +1311,14 @@ void TFTDisplay::display(bool fromBlank) } } - // Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating - for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) { - isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask; + // Step 2: Scan this row for changed span (first and last changed pixel). + uint32_t x_FirstChanged = 0; + for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) { + isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask; - if (!fromBlank) { + if (!forceFullRepaint) { // get src pixel in the page based ordering the OLED lib uses - dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask; + dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask; if (isset != dblbuf_isset) { break; } @@ -1253,43 +1328,51 @@ void TFTDisplay::display(bool fromBlank) } // Did we find a pixel that needs updating on this row? - if (x_FirstPixelUpdate < displayWidth) { - // Align the first pixel for update to an even number so the total alignment of - // the data will be at 32-bit boundary, which is required by GDMA SPI transfers. - x_FirstPixelUpdate &= ~1; - - // Step 3a: copy rest of the pixels in this row into the pixel line buffer, - // while also recording the last pixel in the row that needs updating. - // Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set. - for (x = x_FirstPixelUpdate; x < displayWidth; x++) { - isset = buffer[x + y_byteIndex] & y_byteMask; - linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack; - - if (!fromBlank) { - dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask; + if (x_FirstChanged < displayWidth) { + uint32_t x_LastChanged = displayWidth - 1; + while (x_LastChanged > x_FirstChanged) { + isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask; + if (!forceFullRepaint) { + dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask; if (isset != dblbuf_isset) { - x_LastPixelUpdate = x; + break; } } else if (isset) { - x_LastPixelUpdate = x; + break; } + x_LastChanged--; } - // Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs. - // Most displays will have even number of pixels in a row -- this will be in bounds - // of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.) - x_LastPixelUpdate |= 1; - // Ensure the last pixel index does not exceed the display width. + + // Align the first pixel for update to an even number so the total alignment of + // the data will be at 32-bit boundary, which is required by GDMA SPI transfers. + x_FirstPixelUpdate = x_FirstChanged & ~1U; + x_LastPixelUpdate = x_LastChanged | 1U; if (x_LastPixelUpdate >= displayWidth) { x_LastPixelUpdate = displayWidth - 1; } + + // Step 3: Copy only the changed span into the pixel line buffer. + for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) { + isset = buffer[x + y_byteIndex] & y_byteMask; +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) { + linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast(x), static_cast(y), isset, + colorTftWhite, colorTftBlack); + } else { + linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack; + } +#else + linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack; +#endif + } #if defined(HACKADAY_COMMUNICATOR) tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate], (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1); #else // Step 4: Send the changed pixels on this line to the screen as a single block transfer. // This function accepts pixel data MSB first so it can dump the memory straight out the SPI port. - tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1, - &linePixelBuffer[x_FirstPixelUpdate]); + tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1, + &linePixelBuffer[x_FirstPixelUpdate]); #endif somethingChanged = true; } @@ -1298,6 +1381,14 @@ void TFTDisplay::display(bool fromBlank) // Copy the Buffer to the Back Buffer if (somethingChanged) memcpy(buffer_back, buffer, displayBufferSize); + +#if GRAPHICS_TFT_COLORING_ENABLED + lastColorFrameSignature = colorFrameSignature; +#endif + haveLastDefaults = true; + lastDefaultOnColor = defaultOnColor; + lastDefaultOffColor = defaultOffColor; + graphics::clearTFTColorRegions(); } void TFTDisplay::sdlLoop() @@ -1511,7 +1602,7 @@ bool TFTDisplay::connect() #else tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label #endif - tft->fillScreen(TFT_BLACK); + tft->fillScreen(getThemeDefaultOffColor()); if (this->linePixelBuffer == NULL) { this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth); @@ -1521,6 +1612,14 @@ bool TFTDisplay::connect() return false; } } + if (this->repaintChunkBuffer == NULL) { + this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows); + + if (!this->repaintChunkBuffer) { + LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n"); + return false; + } + } return true; } diff --git a/src/graphics/TFTDisplay.h b/src/graphics/TFTDisplay.h index a64922d23..2c86f05d2 100644 --- a/src/graphics/TFTDisplay.h +++ b/src/graphics/TFTDisplay.h @@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay virtual bool connect() override; uint16_t *linePixelBuffer = nullptr; -}; \ No newline at end of file + uint16_t *repaintChunkBuffer = nullptr; +}; diff --git a/src/graphics/TFTPalette.h b/src/graphics/TFTPalette.h new file mode 100644 index 000000000..516a9f057 --- /dev/null +++ b/src/graphics/TFTPalette.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +namespace graphics +{ +namespace TFTPalette +{ + +constexpr uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue) +{ + return static_cast(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | ((blue & 0xF8) >> 3)); +} + +constexpr uint16_t Black = 0x0000; +constexpr uint16_t White = 0xFFFF; +constexpr uint16_t DarkGray = 0x4208; +constexpr uint16_t Gray = 0x8410; +constexpr uint16_t LightGray = 0xC618; + +constexpr uint16_t Red = rgb565(255, 0, 0); +constexpr uint16_t Green = rgb565(0, 255, 0); +constexpr uint16_t Blue = rgb565(0, 130, 252); +constexpr uint16_t Yellow = rgb565(255, 255, 0); +constexpr uint16_t Orange = rgb565(255, 165, 0); +constexpr uint16_t Cyan = rgb565(0, 255, 255); +constexpr uint16_t Magenta = rgb565(255, 0, 255); + +constexpr uint16_t Good = Green; +constexpr uint16_t Medium = Yellow; +constexpr uint16_t Bad = Red; + +// Christmas / seasonal accent colors +constexpr uint16_t ChristmasRed = rgb565(178, 34, 34); +constexpr uint16_t ChristmasGreen = rgb565(0, 128, 0); +constexpr uint16_t Gold = rgb565(255, 215, 0); +constexpr uint16_t Pine = rgb565(15, 35, 10); + +// Pink theme colors (light variant) +constexpr uint16_t HotPink = rgb565(255, 105, 180); +constexpr uint16_t PalePink = rgb565(255, 228, 235); +constexpr uint16_t DeepPink = rgb565(200, 50, 120); + +// Blue theme colors (dark variant) +constexpr uint16_t SkyBlue = rgb565(100, 180, 255); +constexpr uint16_t Navy = rgb565(15, 15, 50); +constexpr uint16_t DeepBlue = rgb565(30, 60, 120); + +// Creamsicle theme colors (light variant) +constexpr uint16_t CreamOrange = rgb565(255, 140, 50); +constexpr uint16_t DeepOrange = rgb565(220, 100, 20); +constexpr uint16_t Cream = rgb565(255, 248, 235); + +// Classic monochrome theme accent colors (single-color-on-black themes) +constexpr uint16_t MeshtasticGreen = rgb565(0x67, 0xEA, 0x94); +constexpr uint16_t ClassicRed = rgb565(255, 64, 64); +// Monochrome White reuses TFTPalette::White above. + +// Fast contrast picker for monochrome glyph overlays on arbitrary RGB565 backgrounds. +// Uses channel-sum brightness approximation to keep code size small. +constexpr uint16_t pickReadableMonoFg(uint16_t backgroundColor) +{ + const uint16_t r = (backgroundColor >> 11) & 0x1F; + const uint16_t g = (backgroundColor >> 5) & 0x3F; + const uint16_t b = backgroundColor & 0x1F; + return ((r + g + b) >= 70) ? DarkGray : White; +} + +} // namespace TFTPalette +} // namespace graphics diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp index 66bbe1bfe..5045174be 100644 --- a/src/graphics/draw/ClockRenderer.cpp +++ b/src/graphics/draw/ClockRenderer.cpp @@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1 // === Set Title, Blank for Clock const char *titleStr = ""; // === Header === - graphics::drawCommonHeader(display, x, y, titleStr, true, true); + graphics::drawCommonHeader(display, x, y, titleStr, true, true, true); uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone char timeString[16]; @@ -293,11 +293,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1 // Draw an analog clock void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { +#if GRAPHICS_TFT_COLORING_ENABLED + // Clear previous frame pixels so moving hands don't leave stale artifacts on TFT light theme. + display->clear(); +#endif display->setTextAlignment(TEXT_ALIGN_LEFT); // === Set Title, Blank for Clock const char *titleStr = ""; // === Header === - graphics::drawCommonHeader(display, x, y, titleStr, true, true); + graphics::drawCommonHeader(display, x, y, titleStr, true, true, true); // clock face center coordinates int16_t centerX = display->getWidth() / 2; @@ -478,4 +482,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } // namespace ClockRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp index fe54d68e7..1ee02194c 100644 --- a/src/graphics/draw/CompassRenderer.cpp +++ b/src/graphics/draw/CompassRenderer.cpp @@ -9,113 +9,61 @@ namespace graphics { namespace CompassRenderer { - -// Point helper class for compass calculations -struct Point { - float x, y; - Point(float x, float y) : x(x), y(y) {} - - void rotate(float angle) - { - float cos_a = cosf(angle); - float sin_a = sinf(angle); - float new_x = x * cos_a - y * sin_a; - float new_y = x * sin_a + y * cos_a; - x = new_x; - y = new_y; - } - - void scale(float factor) - { - x *= factor; - y *= factor; - } - - void translate(float dx, float dy) - { - x += dx; - y += dy; - } -}; - void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius) { - // Show the compass heading (not implemented in original) - // This could draw a "N" indicator or north arrow - // For now, we'll draw a simple north indicator - // const float radius = 17.0f; if (currentResolution == ScreenResolution::High) { radius += 4; } - float northX = 0.0f; - float northY = -radius; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) { - const float c = cosf(-myHeading); - const float s = sinf(-myHeading); - const float rx = northX * c - northY * s; - const float ry = northX * s + northY * c; - northX = rx; - northY = ry; - } - northX += compassX; - northY += compassY; + + const float northAngle = (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -myHeading : 0.0f; + const int16_t nX = compassX + static_cast((radius - 1) * sinf(northAngle)); + const int16_t nY = compassY - static_cast((radius - 1) * cosf(northAngle)); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_CENTER); +#if !GRAPHICS_TFT_COLORING_ENABLED display->setColor(BLACK); const int16_t nLabelWidth = display->getStringWidth("N"); if (currentResolution == ScreenResolution::High) { - display->fillRect(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6); + display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6); } else { - display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6); + display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6); } - display->setColor(WHITE); - display->drawString(northX, northY - 3, "N"); -} - -void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) -{ - Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially - float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f; - Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY); - - Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow}; - - for (int i = 0; i < 4; i++) { - arrowPoints[i]->rotate(headingRadian); - arrowPoints[i]->scale(compassDiam * 0.6); - arrowPoints[i]->translate(compassX, compassY); - } - -#ifdef USE_EINK - display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); -#else - display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); #endif - display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y); + display->setColor(WHITE); + display->drawString(nX, nY - 3, "N"); } void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing) { - float radians = bearing * DEG_TO_RAD; + const float radians = bearing * DEG_TO_RAD; + const float sinA = sinf(radians); + const float cosA = cosf(radians); + const float tipHalf = size * 0.5f; + const float lx = -(size / 6.0f); + const float ly = size / 4.0f; + const float rx = (size / 6.0f); + const float ry = size / 4.0f; + const float tx = 0.0f; + const float ty = size / 4.5f; - Point tip(0, -size / 2); - Point left(-size / 6, size / 4); - Point right(size / 6, size / 4); - Point tail(0, size / 4.5); + const int16_t tipX = static_cast(x + (tipHalf * sinA)); + const int16_t tipY = static_cast(y - (tipHalf * cosA)); + const int16_t leftX = static_cast(x + (lx * cosA) - (ly * sinA)); + const int16_t leftY = static_cast(y + (lx * sinA) + (ly * cosA)); + const int16_t rightX = static_cast(x + (rx * cosA) - (ry * sinA)); + const int16_t rightY = static_cast(y + (rx * sinA) + (ry * cosA)); + const int16_t tailX = static_cast(x + (tx * cosA) - (ty * sinA)); + const int16_t tailY = static_cast(y + (tx * sinA) + (ty * cosA)); - tip.rotate(radians); - left.rotate(radians); - right.rotate(radians); - tail.rotate(radians); + display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY); + display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY); +} - tip.translate(x, y); - left.translate(x, y); - right.translate(x, y); - tail.translate(x, y); - - display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y); - display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y); +void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian) +{ + const int16_t size = static_cast(compassDiam * 0.6f); + drawArrowToNode(display, compassX, compassY, size, headingRadian * RAD_TO_DEG); } bool getHeadingRadians(double lat, double lon, float &headingRadian) diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h index d77623847..41adf6e64 100644 --- a/src/graphics/draw/CompassRenderer.h +++ b/src/graphics/draw/CompassRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "graphics/Screen.h" +#include "mesh/generated/meshtastic/mesh.pb.h" #include #include diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 7a12650ca..67136437a 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -11,6 +11,8 @@ #include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" #include "graphics/images.h" #include "main.h" @@ -469,9 +471,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int chUtil_y = getTextPositions(display)[line] + 3; int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; + int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7; int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3; int chutil_percent = airTime->channelUtilizationPercent(); + const int raw_chutil_percent = chutil_percent; int centerofscreen = SCREEN_WIDTH / 2; int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; @@ -479,7 +483,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(starting_position, getTextPositions(display)[line], chUtil); - // Force 56% or higher to show a full 100% bar, text would still show related percent. + // Force 61% or higher to show a full 100% bar, text would still show related percent. if (chutil_percent >= 61) { chutil_percent = 100; } @@ -492,9 +496,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, float weight3 = 0.20; // Weight for 40–100% float totalWeight = weight1 + weight2 + weight3; - int seg1 = chutil_bar_width * (weight1 / totalWeight); - int seg2 = chutil_bar_width * (weight2 / totalWeight); - int seg3 = chutil_bar_width * (weight3 / totalWeight); + int seg1 = chutil_bar_max_fill * (weight1 / totalWeight); + int seg2 = chutil_bar_max_fill * (weight2 / totalWeight); + int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors int fillRight = 0; @@ -511,7 +515,17 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, // Fill progress if (fillRight > 0) { - display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (raw_chutil_percent >= 60) { + UtilizationFillColor = TFTPalette::Bad; + } else if (raw_chutil_percent >= 35) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, + starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); +#endif + display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); } display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line++], @@ -584,6 +598,17 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x display->setColor(WHITE); display->drawRect(barX, barY, adjustedBarWidth, barHeight); +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (percent >= 80) { + UtilizationFillColor = TFTPalette::Bad; + } else if (percent >= 60) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, barX + 1, barY + 1, + fillWidth - 1, barHeight - 2); +#endif + display->fillRect(barX, barY, fillWidth, barHeight); display->setColor(WHITE); #endif diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index e92ba4839..f31cb405b 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -11,6 +11,7 @@ #include "buzz.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" #include "graphics/draw/MessageRenderer.h" #include "graphics/draw/UIRenderer.h" #include "input/RotaryEncoderInterruptImpl1.h" @@ -30,8 +31,6 @@ #include #include -extern uint16_t TFT_MESH; - namespace graphics { @@ -2028,109 +2027,6 @@ void menuHandler::switchToMUIMenu() screen->showOverlayBanner(bannerOptions); } -void menuHandler::TFTColorPickerMenu(OLEDDisplay *display) -{ - static const ScreenColorOption colorOptions[] = { - {"Back", OptionsAction::Back}, - {"Default", OptionsAction::Select, ScreenColor(0, 0, 0, true)}, - {"Meshtastic Green", OptionsAction::Select, ScreenColor(0x67, 0xEA, 0x94)}, - {"Yellow", OptionsAction::Select, ScreenColor(255, 255, 128)}, - {"Red", OptionsAction::Select, ScreenColor(255, 64, 64)}, - {"Orange", OptionsAction::Select, ScreenColor(255, 160, 20)}, - {"Purple", OptionsAction::Select, ScreenColor(204, 153, 255)}, - {"Blue", OptionsAction::Select, ScreenColor(0, 0, 255)}, - {"Teal", OptionsAction::Select, ScreenColor(16, 102, 102)}, - {"Cyan", OptionsAction::Select, ScreenColor(0, 255, 255)}, - {"Ice", OptionsAction::Select, ScreenColor(173, 216, 230)}, - {"Pink", OptionsAction::Select, ScreenColor(255, 105, 180)}, - {"White", OptionsAction::Select, ScreenColor(255, 255, 255)}, - {"Gray", OptionsAction::Select, ScreenColor(128, 128, 128)}, - }; - - constexpr size_t colorCount = sizeof(colorOptions) / sizeof(colorOptions[0]); - static std::array colorLabels{}; - - auto bannerOptions = createStaticBannerOptions( - "Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void { - if (option.action == OptionsAction::Back) { - menuQueue = SystemBaseMenu; - screen->runNow(); - return; - } - - if (!option.hasValue) { - return; - } - -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ - HAS_TFT || defined(HACKADAY_COMMUNICATOR) - const ScreenColor &color = option.value; - if (color.useVariant) { - LOG_INFO("Setting color to system default or defined variant"); - } else { - LOG_INFO("Setting color to %s", option.label); - } - - uint8_t r = color.r; - uint8_t g = color.g; - uint8_t b = color.b; - - display->setColor(BLACK); - display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); - display->setColor(WHITE); - - if (color.useVariant || (r == 0 && g == 0 && b == 0)) { -#ifdef TFT_MESH_OVERRIDE - TFT_MESH = TFT_MESH_OVERRIDE; -#else - TFT_MESH = COLOR565(255, 255, 128); -#endif - } else { - TFT_MESH = COLOR565(r, g, b); - } - -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) - static_cast(screen->getDisplayDevice())->setRGB(TFT_MESH); -#endif - - screen->setFrames(graphics::Screen::FOCUS_SYSTEM); - if (color.useVariant || (r == 0 && g == 0 && b == 0)) { - uiconfig.screen_rgb_color = 0; - } else { - uiconfig.screen_rgb_color = - (static_cast(r) << 16) | (static_cast(g) << 8) | static_cast(b); - } - LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color); - saveUIConfig(); -#endif - }); - - int initialSelection = 0; - if (uiconfig.screen_rgb_color == 0) { - initialSelection = 1; - } else { - uint32_t currentColor = uiconfig.screen_rgb_color; - for (size_t i = 0; i < colorCount; ++i) { - if (!colorOptions[i].hasValue) { - continue; - } - const ScreenColor &color = colorOptions[i].value; - if (color.useVariant) { - continue; - } - uint32_t encoded = - (static_cast(color.r) << 16) | (static_cast(color.g) << 8) | static_cast(color.b); - if (encoded == currentColor) { - initialSelection = static_cast(i); - break; - } - } - } - bannerOptions.InitialSelected = initialSelection; - - screen->showOverlayBanner(bannerOptions); -} - void menuHandler::rebootMenu() { static const char *optionsArray[] = {"Back", "Confirm"}; @@ -2318,9 +2214,9 @@ void menuHandler::screenOptionsMenu() bool hasSupportBrightness = false; #endif - enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles }; - static const char *optionsArray[6] = {"Back"}; - static int optionsEnumArray[6] = {Back}; + enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme }; + static const char *optionsArray[7] = {"Back"}; + static int optionsEnumArray[7] = {Back}; int options = 1; // Only show brightness for B&W displays @@ -2329,13 +2225,6 @@ void menuHandler::screenOptionsMenu() optionsEnumArray[options++] = Brightness; } - // Only show screen color for TFT displays -#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \ - HAS_TFT || defined(HACKADAY_COMMUNICATOR) - optionsArray[options] = "Screen Color"; - optionsEnumArray[options++] = ScreenColor; -#endif - optionsArray[options] = "Frame Visibility"; optionsEnumArray[options++] = FrameToggles; @@ -2345,6 +2234,11 @@ void menuHandler::screenOptionsMenu() optionsArray[options] = "Message Bubbles"; optionsEnumArray[options++] = MessageBubbles; +#if GRAPHICS_TFT_COLORING_ENABLED + optionsArray[options] = "Theme"; + optionsEnumArray[options++] = Theme; +#endif + BannerOverlayOptions bannerOptions; bannerOptions.message = "Display Options"; bannerOptions.optionsArrayPtr = optionsArray; @@ -2354,9 +2248,6 @@ void menuHandler::screenOptionsMenu() if (selected == Brightness) { menuHandler::menuQueue = menuHandler::BrightnessPicker; screen->runNow(); - } else if (selected == ScreenColor) { - menuHandler::menuQueue = menuHandler::TftColorMenuPicker; - screen->runNow(); } else if (selected == FrameToggles) { menuHandler::menuQueue = menuHandler::FrameToggles; screen->runNow(); @@ -2366,6 +2257,9 @@ void menuHandler::screenOptionsMenu() } else if (selected == MessageBubbles) { menuHandler::menuQueue = menuHandler::MessageBubblesMenu; screen->runNow(); + } else if (selected == Theme) { + menuHandler::menuQueue = menuHandler::ThemeMenu; + screen->runNow(); } else { menuQueue = SystemBaseMenu; screen->runNow(); @@ -2649,6 +2543,53 @@ void menuHandler::messageBubblesMenu() screen->showOverlayBanner(bannerOptions); } +void menuHandler::themeMenu() +{ + // Build menu dynamically from the theme table. + // Only visible themes appear! + // Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes. + // Bump kMaxThemesInMenu if you add more themes than will fit here. + constexpr size_t kMaxThemesInMenu = 15; + const size_t visibleCount = getVisibleThemeCount(); + static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"}; + const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu; + const int options = static_cast(shownCount) + 1; // +1 for Back + + for (size_t i = 0; i < shownCount; i++) { + optionsArray[i + 1] = getVisibleThemeByIndex(i).name; + } + + BannerOverlayOptions bannerOptions; + bannerOptions.message = "Theme"; + bannerOptions.optionsArrayPtr = optionsArray; + bannerOptions.optionsCount = options; + + // Highlight the currently active theme (visible index + 1 for the Back + // offset). If the active theme is hidden, leave selection on "Back". + const size_t activeVisible = getActiveVisibleThemeIndex(); + bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast(activeVisible) + 1; + + bannerOptions.bannerCallback = [](int selected) -> void { + if (selected == 0) { + // Back + menuHandler::menuQueue = menuHandler::ScreenOptionsMenu; + screen->runNow(); + } else { + // Selection is an index into the VISIBLE themes (1-based, slot 0 is Back). + const size_t visibleIdx = static_cast(selected - 1); + if (visibleIdx < getVisibleThemeCount()) { + // Persist the theme's uniqueIdentifier so boot-time + // resolveThemeIndex() can restore this theme on next startup. + uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3); + loadThemeDefaults(); + saveUIConfig(); + screen->runNow(); + } + } + }; + screen->showOverlayBanner(bannerOptions); +} + void menuHandler::handleMenuSwitch(OLEDDisplay *display) { if (menuQueue != MenuNone) @@ -2724,9 +2665,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case MuiPicker: switchToMUIMenu(); break; - case TftColorMenuPicker: - TFTColorPickerMenu(display); - break; case BrightnessPicker: BrightnessPickerMenu(); break; @@ -2799,6 +2737,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display) case MessageBubblesMenu: messageBubblesMenu(); break; + case ThemeMenu: + themeMenu(); + break; } menuQueue = MenuNone; } @@ -2810,4 +2751,4 @@ void menuHandler::saveUIConfig() } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h index 4a0360412..3ac9e606e 100644 --- a/src/graphics/draw/MenuHandler.h +++ b/src/graphics/draw/MenuHandler.h @@ -30,7 +30,6 @@ class menuHandler ResetNodeDbMenu, BuzzerModeMenuPicker, MuiPicker, - TftColorMenuPicker, BrightnessPicker, RebootMenu, ShutdownMenu, @@ -55,7 +54,8 @@ class menuHandler NodeNameLengthMenu, FrameToggles, DisplayUnits, - MessageBubblesMenu + MessageBubblesMenu, + ThemeMenu }; static screenMenus menuQueue; static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu @@ -89,7 +89,6 @@ class menuHandler static void GPSPositionBroadcastMenu(); static void BuzzerModeMenu(); static void switchToMUIMenu(); - static void TFTColorPickerMenu(OLEDDisplay *display); static void nodeListMenu(); static void resetNodeDBMenu(); static void BrightnessPickerMenu(); @@ -110,6 +109,7 @@ class menuHandler static void frameTogglesMenu(); static void displayUnitsMenu(); static void messageBubblesMenu(); + static void themeMenu(); static void textMessageMenu(); private: @@ -136,23 +136,10 @@ template struct MenuOption { MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {} }; -struct ScreenColor { - uint8_t r; - uint8_t g; - uint8_t b; - bool useVariant; - - explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false) - : r(rIn), g(gIn), b(bIn), useVariant(variantIn) - { - } -}; - using RadioPresetOption = MenuOption; using LoraRegionOption = MenuOption; using TimezoneOption = MenuOption; using CompassOption = MenuOption; -using ScreenColorOption = MenuOption; using GPSToggleOption = MenuOption; using GPSFormatOption = MenuOption; using NodeNameOption = MenuOption; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 2fd9bf541..2260c57df 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -11,6 +11,8 @@ #include "graphics/Screen.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" #include "graphics/emotes.h" #include "main.h" @@ -254,6 +256,76 @@ struct MessageBlock { bool mine; }; +#if GRAPHICS_TFT_COLORING_ENABLED +static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine) +{ + uint16_t bubbleOnColor; + uint16_t bubbleOffColor; + + if (themeId == ThemeID::Blue) { + bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White; + bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue; + } else { + bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg(); + bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray; + } + + setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor); +} + +static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius) +{ + if (w <= 0 || h <= 0) { + return; + } + + if (radius <= 0 || w < 3 || h < 3) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h); + return; + } + + // Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while + // scrolling long message lists (which can flatten older bubble corners). + int capRows = 0; + if (radius >= 4 && h >= 5) { + capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps) + } else if (radius >= 2 && h >= 3) { + capRows = 1; // 3 regions total + } + if (capRows <= 0) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h); + return; + } + + for (int row = 0; row < capRows; ++row) { + int inset = 0; + if (radius >= 4) { + inset = (row == 0) ? 2 : 1; + } else if (radius >= 2) { + inset = 1; + } + const int stripW = w - (inset * 2); + if (stripW <= 0) { + continue; + } + + const int topY = y + row; + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1); + + const int bottomY = y + h - 1 - row; + if (bottomY != topY) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1); + } + } + + const int middleY = y + capRows; + const int middleH = h - (capRows * 2); + if (middleH > 0) { + registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH); + } +} +#endif + static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine) { if (isHeaderLine) { @@ -648,6 +720,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int contentBottom = scrollBottom; // already excludes nav line const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN; const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2); +#if GRAPHICS_TFT_COLORING_ENABLED + const uint32_t themeId = getActiveTheme().id; + // Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark. + const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue); +#endif std::vector lineTop; lineTop.resize(cachedLines.size()); @@ -686,6 +763,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]); int bottomY = visualBottom + BUBBLE_PAD_Y; + // On high-res screens, keep a 1px gap under the header + if (currentResolution == ScreenResolution::High) { + const int minTopY = contentTop + 1; + if (topY < minTopY) { + // Preserve bubble height when we push it down from the header. + const int shift = minTopY - topY; + topY = minTopY; + bottomY += shift; + } + } + if (bi + 1 < blocks.size()) { int nextHeaderIndex = (int)blocks[bi + 1].start; int nextTop = lineTop[nextHeaderIndex]; @@ -735,24 +823,56 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int by = topY; const int bw = bubbleW; const int bh = bubbleH; +#if GRAPHICS_TFT_COLORING_ENABLED + const bool drawBubbleOutline = !useDarkModeBubbleFill; +#else + const bool drawBubbleOutline = true; +#endif +#if GRAPHICS_TFT_COLORING_ENABLED + if (useDarkModeBubbleFill) { + setDarkModeBubbleRoleColors(themeId, b.mine); + registerRoundedBubbleFillRegion(bx, by, bw, bh, r); + } +#endif - // Draw the 4 corner arcs using drawCircleQuads - display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left - display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right - display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left - display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right + if (drawBubbleOutline) { + // Draw the 4 corner arcs using drawCircleQuads + display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left + display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right + display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left + display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right - // Draw the 4 edges between corners - display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge - display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge - display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge - display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge + // Draw the 4 edges between corners + display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge + display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge + display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge + display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge + } } else if (bubbleW > 1 && bubbleH > 1) { // Fallback to simple rectangle for very small bubbles - display->drawRect(bubbleX, topY, bubbleW, bubbleH); +#if GRAPHICS_TFT_COLORING_ENABLED + const bool drawBubbleOutline = !useDarkModeBubbleFill; +#else + const bool drawBubbleOutline = true; +#endif +#if GRAPHICS_TFT_COLORING_ENABLED + if (useDarkModeBubbleFill) { + setDarkModeBubbleRoleColors(themeId, b.mine); + registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH); + } +#endif + if (drawBubbleOutline) { + display->drawRect(bubbleX, topY, bubbleW, bubbleH); + } } } } // end if (showBubbles) +#if GRAPHICS_TFT_COLORING_ENABLED + if (useDarkModeBubbleFill) { + // Restore theme role defaults so other screens keep their intended palette. + loadThemeDefaults(); + } +#endif // Render visible lines int lineY = yOffset; @@ -772,7 +892,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 headerX = x + textIndent; } graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1, - false); + true); // Draw underline just under header text int underlineY = lineY + FONT_HEIGHT_SMALL; diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 201d267e3..00ae74b58 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -11,6 +11,8 @@ #include "gps/RTC.h" // for getTime() function #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/images.h" #include "meshUtils.h" #include @@ -213,6 +215,33 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, } } +static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName, + int16_t nameX, int16_t y, int nameMaxWidth) +{ + if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) { + return; + } + + const int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName); + const int regionWidth = min(textWidth, max(0, nameMaxWidth)); + if (regionWidth <= 0) { + return; + } + + // Node list rows can begin a couple of pixels inside header space. + // Clamp favorite-name color region below the header to avoid black overlap there. + const int16_t minContentY = static_cast(FONT_HEIGHT_SMALL + 1); + const int16_t regionY = max(y, minContentY); + const int16_t yClip = regionY - y; + const int16_t regionHeight = static_cast(FONT_HEIGHT_SMALL - yClip); + if (regionHeight <= 0) { + return; + } + + setAndRegisterTFTColorRole(TFTColorRole::FavoriteNode, TFTPalette::Yellow, TFTPalette::Black, nameX, regionY, regionWidth, + regionHeight); +} + // ============================= // Entry Renderers // ============================= @@ -227,6 +256,9 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char timeStr[10]; @@ -286,6 +318,9 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -315,6 +350,19 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int int barStartX = x + barsXOffset; int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2; + if (bars > 0) { + uint16_t signalBarsColor = TFTPalette::Bad; + if (bars >= 3) { + signalBarsColor = TFTPalette::Good; + } else if (bars == 2) { + signalBarsColor = TFTPalette::Medium; + } + + // Highest bar reaches 6 px in this renderer. + setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barStartX, barStartY - 6, + (kBarCount * kBarWidth) + ((kBarCount - 1) * kBarGap), 6); + } + for (int b = 0; b < kBarCount; b++) { if (b < bars) { int height = (b * 2); @@ -350,6 +398,9 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char distStr[10] = ""; @@ -455,6 +506,9 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 char nodeName[96]; UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), nameMaxWidth); +#if GRAPHICS_TFT_COLORING_ENABLED + applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth); +#endif bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -710,6 +764,9 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight); +#endif // Text display->drawString(boxLeft + padding, boxTop + padding, buf); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 31eb2c3c8..cca60d1e2 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -7,6 +7,8 @@ #include "UIRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/images.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" @@ -608,6 +610,9 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight); +#endif // Draw Content int16_t lineY = boxTop + vPadding; @@ -630,7 +635,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) { background_yOffset = -1; } - display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset); + const int16_t titleBarY = boxTop + 1; + const int16_t titleBarHeight = effectiveLineHeight - background_yOffset; + display->fillRect(boxLeft, titleBarY, boxWidth, titleBarHeight); +#if GRAPHICS_TFT_COLORING_ENABLED + if (alertBannerOptions > 0) { + const uint16_t titleTextColor = + (getActiveTheme().id == ThemeID::DefaultLight) ? TFTPalette::Black : getThemeHeaderText(); + // Keep title role away from border/corner pixels so rounded-corner masks are not remapped to the title text + // color. + if (boxWidth > 2 && titleBarHeight > 0) { + setAndRegisterTFTColorRole(TFTColorRole::ActionMenuTitle, getThemeHeaderBg(), titleTextColor, boxLeft + 1, + titleBarY, boxWidth - 2, titleBarHeight); + } + } +#endif display->setColor(BLACK); int yOffset = 3; if (current_notification_type == notificationTypeEnum::node_picker) { @@ -650,6 +669,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay const int barSpacing = 2; const int barHeightStep = 2; const int gap = 6; + const int maxBarHeight = totalBars * barHeightStep; int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true); int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap; @@ -664,6 +684,20 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay int baseX = groupStartX + textWidth + gap; int baseY = lineY + effectiveLineHeight - 1; +#if GRAPHICS_TFT_COLORING_ENABLED + if (graphics::bannerSignalBars > 0) { + uint16_t signalBarsColor = TFTPalette::Medium; + if (graphics::bannerSignalBars <= 1) { + signalBarsColor = TFTPalette::Bad; + } else if (graphics::bannerSignalBars >= 4) { + signalBarsColor = TFTPalette::Good; + } + const int activeBars = min(graphics::bannerSignalBars, totalBars); + const int regionWidth = activeBars * barWidth + (activeBars - 1) * barSpacing; + setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, baseX, + baseY - maxBarHeight, regionWidth, maxBarHeight); + } +#endif for (int b = 0; b < totalBars; b++) { int barHeight = (b + 1) * barHeightStep; int x = baseX + b * (barWidth + barSpacing); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 4bf4df4bf..b75bcd17b 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -13,6 +13,8 @@ #include "gps/GeoCoord.h" #include "graphics/EmoteRenderer.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" #include "graphics/TimeFormatters.h" #include "graphics/images.h" #include "main.h" @@ -30,6 +32,7 @@ namespace graphics { NodeNum UIRenderer::currentFavoriteNodeNum = 0; std::vector graphics::UIRenderer::favoritedNodes; +static bool gBootSplashBoldPass = false; static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) { @@ -41,6 +44,347 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) } } +struct StandardCompassNeedlePoints { + int16_t northTipX; + int16_t northTipY; + int16_t northLeftX; + int16_t northLeftY; + int16_t northRightX; + int16_t northRightY; + int16_t southTipX; + int16_t southTipY; + int16_t southLeftX; + int16_t southLeftY; + int16_t southRightX; + int16_t southRightY; +}; + +static inline void swapPoint(int16_t &ax, int16_t &ay, int16_t &bx, int16_t &by) +{ + const int16_t tx = ax; + const int16_t ty = ay; + ax = bx; + ay = by; + bx = tx; + by = ty; +} + +static inline void transformNeedlePoint(float localX, float localY, float sinHeading, float cosHeading, float scale, + int16_t centerX, int16_t centerY, int16_t &outX, int16_t &outY) +{ + const float x = ((localX * cosHeading) - (localY * sinHeading)) * scale + centerX; + const float y = ((localX * sinHeading) + (localY * cosHeading)) * scale + centerY; + outX = static_cast(x); + outY = static_cast(y); +} + +static float getCompassRingAngleOffset(float heading) +{ + return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f; +} + +static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY, + uint16_t compassDiam, float headingRadian, + float centerGapPx) +{ + // Standard-style symmetric needle with a narrow waist and a tiny center gap + // between north/south halves to prevent seam bleed while rotating. + const float scaledDiam = compassDiam * 0.76f; + const float gapNormHalf = (centerGapPx * 0.5f) / scaledDiam; + const float sinHeading = sinf(headingRadian); + const float cosHeading = cosf(headingRadian); + + StandardCompassNeedlePoints points{}; + transformNeedlePoint(0.0f, -0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northTipX, points.northTipY); + transformNeedlePoint(-0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northLeftX, + points.northLeftY); + transformNeedlePoint(0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northRightX, + points.northRightY); + transformNeedlePoint(0.0f, 0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southTipX, points.southTipY); + transformNeedlePoint(-0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southLeftX, + points.southLeftY); + transformNeedlePoint(0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southRightX, + points.southRightY); + return points; +} + +static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + float heading) +{ + int16_t labelRadius = compassRadius; + // CompassRenderer::drawCompassNorth() expands radius on high-res by +4. + // Compensate so label placement stays aligned with the current UI layout. + if (currentResolution == ScreenResolution::High && labelRadius > 4) { + labelRadius -= 4; + } + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, heading, labelRadius); +} + +static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading) +{ + const StandardCompassNeedlePoints points = + computeStandardCompassNeedlePoints(compassX, compassY, static_cast(compassRadius * 2), -heading, 0.0f); + +#ifdef USE_EINK + display->setColor(WHITE); + display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX, + points.northRightY); + display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); +#else + // OLED variant: same needle geometry as TFT, but monochrome contrast. + display->setColor(WHITE); + display->fillTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX, + points.northRightY); + display->setColor(BLACK); + display->fillTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); + // Keep a white outline so the black half remains visible on dark backgrounds. + display->setColor(WHITE); + display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); +#endif + + display->drawCircle(compassX, compassY, compassRadius); + drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading); +} + +#if GRAPHICS_TFT_COLORING_ENABLED +struct NeedleColorBand { + int16_t xMin; + int16_t xMax; + int16_t yMin; + int16_t yMax; + bool used; +}; + +static constexpr int kNeedleBandCount = 6; + +static inline void registerNeedleSpan(NeedleColorBand (&bands)[kNeedleBandCount], int16_t bandTop, int16_t bandHeight, int16_t y, + int16_t a, int16_t b) +{ + if (a > b) { + const int16_t t = a; + a = b; + b = t; + } + + int band = (static_cast(y - bandTop) * kNeedleBandCount) / bandHeight; + if (band < 0) { + band = 0; + } else if (band >= kNeedleBandCount) { + band = kNeedleBandCount - 1; + } + + NeedleColorBand ®ion = bands[band]; + if (!region.used) { + region.used = true; + region.xMin = a; + region.xMax = b; + region.yMin = y; + region.yMax = y; + return; + } + if (a < region.xMin) + region.xMin = a; + if (b > region.xMax) + region.xMax = b; + if (y < region.yMin) + region.yMin = y; + if (y > region.yMax) + region.yMax = y; +} + +static void drawNeedleHalfAndRegisterBands(OLEDDisplay *display, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2, + int16_t y2, uint16_t onColor, uint16_t offColor) +{ + // Important for maintainers: + // The compass needle rotates continuously, so color-region registration must + // track triangle shape (or a close approximation), not only one AABB. + // Coarse rectangles can leak south color into north at diagonal angles. + // Keep this banded approach unless a replacement preserves per-angle coverage. + // Performance note: draw the triangle once via fillTriangle(), then build + // band regions in software for accurate color-role registration. + display->fillTriangle(x0, y0, x1, y1, x2, y2); + + if (y0 > y1) + swapPoint(x0, y0, x1, y1); + if (y1 > y2) + swapPoint(x1, y1, x2, y2); + if (y0 > y1) + swapPoint(x0, y0, x1, y1); + + NeedleColorBand bands[kNeedleBandCount] = {}; + + const int16_t bandTop = y0; + const int16_t bandBottom = y2; + const int16_t bandHeight = (bandBottom >= bandTop) ? static_cast(bandBottom - bandTop + 1) : 1; + + const int32_t dx01 = x1 - x0; + const int32_t dy01 = y1 - y0; + const int32_t dx02 = x2 - x0; + const int32_t dy02 = y2 - y0; + const int32_t dx12 = x2 - x1; + const int32_t dy12 = y2 - y1; + + int32_t sa = 0; + int32_t sb = 0; + int16_t y = y0; + + const int16_t last = (y1 == y2) ? y1 : static_cast(y1 - 1); + for (; y <= last; y++) { + const int16_t a = static_cast(x0 + ((dy01 != 0) ? (sa / dy01) : 0)); + const int16_t b = static_cast(x0 + ((dy02 != 0) ? (sb / dy02) : 0)); + sa += dx01; + sb += dx02; + registerNeedleSpan(bands, bandTop, bandHeight, y, a, b); + } + + sa = dx12 * static_cast(y - y1); + sb = dx02 * static_cast(y - y0); + for (; y <= y2; y++) { + const int16_t a = static_cast(x1 + ((dy12 != 0) ? (sa / dy12) : 0)); + const int16_t b = static_cast(x0 + ((dy02 != 0) ? (sb / dy02) : 0)); + sa += dx12; + sb += dx02; + registerNeedleSpan(bands, bandTop, bandHeight, y, a, b); + } + + for (int i = 0; i < kNeedleBandCount; i++) { + if (!bands[i].used) + continue; + registerTFTColorRegionDirect(bands[i].xMin, bands[i].yMin, bands[i].xMax - bands[i].xMin + 1, + bands[i].yMax - bands[i].yMin + 1, onColor, offColor); + } +} + +static inline void drawCompassCardinalLabel(OLEDDisplay *display, int16_t x, int16_t y, const char *label, int16_t textWidth) +{ + const int16_t labelTop = y - (FONT_HEIGHT_SMALL / 2); + const int16_t padX = 1; + const int16_t padY = 1; + + // Clear any ring/tick pixels behind the label so letters remain clean. + display->setColor(BLACK); + display->fillRect(x - (textWidth / 2) - padX, labelTop - padY, textWidth + (padX * 2), FONT_HEIGHT_SMALL + (padY * 2)); + + display->setColor(WHITE); + display->drawString(x, labelTop, label); +} + +static inline void drawCompassCardinalLabels(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + float heading) +{ + const float northAngle = getCompassRingAngleOffset(heading); + const float radius = compassRadius - 1.0f; + const float sinNorth = sinf(northAngle); + const float cosNorth = cosf(northAngle); + + const int16_t nX = compassX + static_cast(radius * sinNorth); + const int16_t nY = compassY - static_cast(radius * cosNorth); + const int16_t eX = compassX + static_cast(radius * cosNorth); + const int16_t eY = compassY + static_cast(radius * sinNorth); + const int16_t sX = compassX - static_cast(radius * sinNorth); + const int16_t sY = compassY + static_cast(radius * cosNorth); + const int16_t wX = compassX - static_cast(radius * cosNorth); + const int16_t wY = compassY - static_cast(radius * sinNorth); + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + const int16_t labelWidth = static_cast(display->getStringWidth("N")); + drawCompassCardinalLabel(display, nX, nY, "N", labelWidth); + drawCompassCardinalLabel(display, eX, eY, "E", labelWidth); + drawCompassCardinalLabel(display, sX, sY, "S", labelWidth); + drawCompassCardinalLabel(display, wX, wY, "W", labelWidth); +} + +static inline void drawCompassDegreeMarkers(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + float heading) +{ + const float baseAngle = getCompassRingAngleOffset(heading); + + constexpr int16_t majorLen = 5; + constexpr int16_t minorLen = 3; + + display->setColor(WHITE); + constexpr float kStepAngle = 15.0f * DEG_TO_RAD; + const float sinStep = sinf(kStepAngle); + const float cosStep = cosf(kStepAngle); + float sinAngle = sinf(baseAngle); + float cosAngle = cosf(baseAngle); + bool isMajor = true; + for (int tick = 0; tick < 24; tick++) { + const int16_t tickLen = isMajor ? majorLen : minorLen; + + const int16_t xOuter = compassX + static_cast((compassRadius - 1) * sinAngle); + const int16_t yOuter = compassY - static_cast((compassRadius - 1) * cosAngle); + const int16_t xInner = compassX + static_cast((compassRadius - tickLen) * sinAngle); + const int16_t yInner = compassY - static_cast((compassRadius - tickLen) * cosAngle); + display->drawLine(xInner, yInner, xOuter, yOuter); + + // Rotate [sin, cos] by a fixed step instead of recomputing trig 24x/frame. + const float nextSin = (sinAngle * cosStep) + (cosAngle * sinStep); + const float nextCos = (cosAngle * cosStep) - (sinAngle * sinStep); + sinAngle = nextSin; + cosAngle = nextCos; + isMajor = !isMajor; + } +} + +static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, + float headingRadian, uint16_t needleOffColor) +{ + const StandardCompassNeedlePoints points = + computeStandardCompassNeedlePoints(compassX, compassY, compassDiam, headingRadian, 9.0f); + + display->setColor(WHITE); +#ifdef USE_EINK + display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX, + points.northRightY); + display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX, + points.southRightY); +#else + // NOTE: do not collapse these to one region per half during "flash + // optimization". The needle spins, and coarse rectangles will bleed color + // across halves at diagonal angles. + drawNeedleHalfAndRegisterBands(display, points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, + points.northRightX, points.northRightY, TFTPalette::Red, needleOffColor); + drawNeedleHalfAndRegisterBands(display, points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, + points.southRightX, points.southRightY, TFTPalette::Blue, needleOffColor); +#endif +} + +static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading) +{ + // Compass colors should follow whatever background role is already active at this location. + const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg()); + const uint16_t compassGlyphColor = TFTPalette::pickReadableMonoFg(compassBgColor); + const int16_t pad = 2; + const int16_t labelPadX = static_cast(display->getStringWidth("W") / 2) + 2; + const int16_t labelPadY = static_cast(FONT_HEIGHT_SMALL / 2) + 2; + const int16_t boxX = compassX - compassRadius - pad - labelPadX; + const int16_t boxY = compassY - compassRadius - pad - labelPadY; + const int16_t boxW = (compassRadius * 2) + (pad * 2) + 1 + (labelPadX * 2); + const int16_t boxH = (compassRadius * 2) + (pad * 2) + 1 + (labelPadY * 2); + // Never let compass-local tint regions override the header role regions. + const int16_t bodyTop = static_cast(getTextPositions(display)[1]); + int16_t clippedY = boxY; + int16_t clippedH = boxH; + if (clippedY < bodyTop) { + clippedH = static_cast(clippedH - (bodyTop - clippedY)); + clippedY = bodyTop; + } + if (clippedH > 0) { + registerTFTColorRegionDirect(boxX, clippedY, boxW, clippedH, compassGlyphColor, compassBgColor); + } + + drawStandardCompassNeedle(display, compassX, compassY, static_cast(compassRadius * 2), -heading, compassBgColor); + display->drawCircle(compassX, compassY, compassRadius); + drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading); + drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading); +} +#endif // GRAPHICS_TFT_COLORING_ENABLED + static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_t compassY, const char *statusLine1, const char *statusLine2) { @@ -50,6 +394,99 @@ static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_ display->setTextAlignment(TEXT_ALIGN_LEFT); } +static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + bool showCompass, float myHeading, float bearing, const char *statusLine1, + const char *statusLine2) +{ + // Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text. + display->drawCircle(compassX, compassY, compassRadius); + if (showCompass) { + CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); + } else { + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } +} + +static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, + bool validHeading, float heading, const char *statusLine1, const char *statusLine2) +{ + // Shared "position screen" compass renderer: use mono/TFT path only when heading is valid. + if (validHeading) { +#if GRAPHICS_TFT_COLORING_ENABLED + drawTftCompass(display, compassX, compassY, compassRadius, heading); +#else + drawMonoCompass(display, compassX, compassY, compassRadius, heading); +#endif + } else { + display->drawCircle(compassX, compassY, compassRadius); + drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); + } +} + +static bool computeLandscapeCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t topY, int16_t *compassX, + int16_t *compassY, int16_t *compassRadius) +{ + // Keep compass vertically centered in the body area while reserving footer/nav space. + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); + const int16_t usableHeight = bottomY - topY - 5; + int16_t radius = usableHeight / 2; + if (radius < 8) { + radius = 8; + } + + *compassRadius = radius; + *compassX = xOffset + SCREEN_WIDTH - radius - 8; + *compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + return true; +} + +static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t yBelowContent, int16_t bottomReserved, + int16_t margin, int16_t *compassX, int16_t *compassY, int16_t *compassRadius) +{ + // Return false when content leaves no room for a readable compass. + int availableHeight = SCREEN_HEIGHT - yBelowContent - bottomReserved - margin; + if (availableHeight < FONT_HEIGHT_SMALL * 2) { + return false; + } + + int16_t radius = static_cast(availableHeight / 2); + if (radius < 8) { + radius = 8; + } + if (radius * 2 > SCREEN_WIDTH - 16) { + radius = (SCREEN_WIDTH - 16) / 2; + } + + *compassRadius = radius; + *compassX = xOffset + (SCREEN_WIDTH / 2); + *compassY = static_cast(yBelowContent + (availableHeight / 2)); + return true; +} + +static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const std::string &statusText) +{ + // Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down. + char rawStatus[96]; + snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText.c_str()); + + char clippedStatus[96]; + UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth()); + display->drawString(x, y, clippedStatus); +} + +static int computeChannelUtilizationFill(int percent, int maxFill) +{ + // Compact linear fill mapping for the utilization bar. + if (percent <= 0 || maxFill <= 0) { + return 0; + } + if (percent >= 100) { + return maxFill; + } + return (maxFill * percent + 50) / 100; +} + void graphics::UIRenderer::rebuildFavoritedNodes() { favoritedNodes.clear(); @@ -331,7 +768,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat snprintf(titlestr, sizeof(titlestr), "*%s*", shortName); // === Draw battery/time/mail header (common across screens) === - graphics::drawCommonHeader(display, x, y, titlestr); + graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow); // ===== DYNAMIC ROW STACKING WITH YOUR MACROS ===== // 1. Each potential info row has a macro-defined Y position (not regular increments!). @@ -349,8 +786,13 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr; } + // Print node's long name (e.g. "Backpack Node") if (username) { - // Print node's long name (e.g. "Backpack Node") +#if GRAPHICS_TFT_COLORING_ENABLED + const int usernameWidth = UIRenderer::measureStringWithEmotes(display, username); + setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black, x, + getTextPositions(display)[line], usernameWidth, FONT_HEIGHT_SMALL); +#endif UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false); } @@ -370,37 +812,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } if (found) { - std::string statusLine = std::string(" Status: ") + found->statusText; - { - const int screenW = display->getWidth(); - const int ellipseW = display->getStringWidth("..."); - int w = display->getStringWidth(statusLine.c_str()); - - // Only do work if it overflows - if (w > screenW) { - bool truncated = false; - if (ellipseW > screenW) { - statusLine.clear(); - } else { - while (!statusLine.empty()) { - // remove one char (byte) at a time - statusLine.pop_back(); - truncated = true; - - // Measure candidate with ellipsis appended - std::string candidate = statusLine + "..."; - if (display->getStringWidth(candidate.c_str()) <= screenW) { - statusLine = std::move(candidate); - break; - } - } - if (statusLine.empty() && ellipseW <= screenW) { - statusLine = "..."; - } - } - } - } - display->drawString(x, getTextPositions(display)[line++], statusLine.c_str()); + drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], found->statusText); } } #endif @@ -492,6 +904,16 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat if (!hi) maxBarHeight -= 1; int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2; + int totalBarsWidth = (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap); + + uint16_t signalBarsColor = TFTPalette::Good; + if (qualityLabel && strcmp(qualityLabel, "Fair") == 0) { + signalBarsColor = TFTPalette::Medium; + } else if (qualityLabel && strcmp(qualityLabel, "Bad") == 0) { + signalBarsColor = TFTPalette::Bad; + } + setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barX, barY, totalBarsWidth, + maxBarHeight); for (int bi = 0; bi < kMaxBars; bi++) { int barHeight = maxBarHeight * (bi + 1) / kMaxBars; @@ -509,23 +931,20 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } } - curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2; + curX += totalBarsWidth + 2; } // Draw hops for non-zero-hop nodes as: number + hop icon. // This path is mutually exclusive with the zero-hop signal-bars path above. if (showHops) { - // hop label display->drawString(curX, yPos, "Hop:"); curX += display->getStringWidth("Hop:") + 2; - // hop count char hopCount[6]; snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away); display->drawString(curX, yPos, hopCount); curX += display->getStringWidth(hopCount) + 2; - // hop icon const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2; display->drawXbm(curX, iconY, hop_width, hop_height, hop); curX += hop_width + 1; @@ -567,48 +986,29 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat bool haveDistance = false; if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) { - double lat1 = ourNode->position.latitude_i * 1e-7; - double lon1 = ourNode->position.longitude_i * 1e-7; - double lat2 = node->position.latitude_i * 1e-7; - double lon2 = node->position.longitude_i * 1e-7; - double earthRadiusKm = 6371.0; - double dLat = (lat2 - lat1) * DEG_TO_RAD; - double dLon = (lon2 - lon1) * DEG_TO_RAD; - double a = - sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2); - double c = 2 * atan2(sqrt(a), sqrt(1 - a)); - double distanceKm = earthRadiusKm * c; - + // Use shared meter conversion, then format display units with lightweight integer rounding. + const float distanceMeters = + GeoCoord::latLongToMeter(DegD(node->position.latitude_i), DegD(node->position.longitude_i), + DegD(ourNode->position.latitude_i), DegD(ourNode->position.longitude_i)); if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) { - double miles = distanceKm * 0.621371; - if (miles < 0.1) { - int feet = (int)(miles * 5280); - if (feet > 0 && feet < 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet); - haveDistance = true; - } else if (feet >= 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:¼mi", leftSideSpacing); - haveDistance = true; - } + const int feet = static_cast((distanceMeters * METERS_TO_FEET) + 0.5f); + if (feet > 0 && feet < 1000) { + snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet); + haveDistance = true; } else { - int roundedMiles = (int)(miles + 0.5); - if (roundedMiles > 0 && roundedMiles < 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, roundedMiles); + const int miles = (feet + 2640) / 5280; // rounded to nearest mile + if (miles > 0 && miles < 1000) { + snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, miles); haveDistance = true; } } } else { - if (distanceKm < 1.0) { - int meters = (int)(distanceKm * 1000); - if (meters > 0 && meters < 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters); - haveDistance = true; - } else if (meters >= 1000) { - snprintf(distStr, sizeof(distStr), "%sDistance:1km", leftSideSpacing); - haveDistance = true; - } + const int meters = static_cast(distanceMeters + 0.5f); + if (meters > 0 && meters < 1000) { + snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters); + haveDistance = true; } else { - int km = (int)(distanceKm + 0.5); + const int km = (meters + 500) / 1000; // rounded to nearest km if (km > 0 && km < 1000) { snprintf(distStr, sizeof(distStr), "%sDistance:%dkm", leftSideSpacing, km); haveDistance = true; @@ -693,64 +1093,29 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat } // --- Compass Rendering: landscape (wide) screens use the original side-aligned logic --- - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - if (showCompass || statusLine1) { + if (showCompass || statusLine1) { + int16_t compassX = 0; + int16_t compassY = 0; + int16_t compassRadius = 0; + if (SCREEN_WIDTH > SCREEN_HEIGHT) { const int16_t topY = getTextPositions(display)[1]; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); - const int16_t usableHeight = bottomY - topY - 5; - int16_t compassRadius = usableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; - const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - const int16_t compassDiam = compassRadius * 2; - - display->drawCircle(compassX, compassY, compassRadius); - if (showCompass) { - CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); - } - } - // else show nothing - } else { - // Portrait or square: put compass at the bottom and centered, scaled to fit available space - if (showCompass || statusLine1) { - int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2) - : getTextPositions(display)[1]; - const int margin = 4; -// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) ----------- + computeLandscapeCompassPlacement(display, x, topY, &compassX, &compassY, &compassRadius); + } else { + const int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2) + : getTextPositions(display)[1]; #if defined(USE_EINK) const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8; const int navBarHeight = iconSize + 6; #else const int navBarHeight = 0; #endif - // --------- END PATCH FOR EINK NAV BAR ----------- - int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin; - - if (availableHeight < FONT_HEIGHT_SMALL * 2) + if (!computeBottomCompassPlacement(display, x, yBelowContent, navBarHeight, 4, &compassX, &compassY, + &compassRadius)) { return; - - int compassRadius = availableHeight / 2; - if (compassRadius < 8) - compassRadius = 8; - if (compassRadius * 2 > SCREEN_WIDTH - 16) - compassRadius = (SCREEN_WIDTH - 16) / 2; - - int compassX = x + SCREEN_WIDTH / 2; - int compassY = yBelowContent + availableHeight / 2; - - display->drawCircle(compassX, compassY, compassRadius); - if (showCompass) { - graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius); - graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); } } - // else show nothing + drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1, + statusLine2); } #endif graphics::drawCommonFooter(display, x, y); @@ -776,12 +1141,6 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta // === Content below header === - // Determine if we need to show 4 or 5 rows on the screen - int rows = 4; - if (!config.bluetooth.enabled) { - rows = 5; - } - // === First Row: Region / Channel Utilization and Uptime === bool origBold = config.display.heading_bold; config.display.heading_bold = false; @@ -845,13 +1204,15 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta // === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) === const char *chUtil = "ChUtil:"; char chUtilPercentage[10]; - snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); + int chutil_percent = static_cast(airTime->channelUtilizationPercent() + 0.5f); + snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%d%%", chutil_percent); int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; int chUtil_y = getTextPositions(display)[line] + 3; int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50; + int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border if (!config.bluetooth.enabled) { #if defined(USE_EINK) chutil_bar_width = (currentResolution == ScreenResolution::High) ? 50 : 30; @@ -864,50 +1225,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta if (!config.bluetooth.enabled) { extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 1; } - int chutil_percent = airTime->channelUtilizationPercent(); + const int raw_chutil_percent = chutil_percent; - int centerofscreen = SCREEN_WIDTH / 2; - int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; - int starting_position = centerofscreen - total_line_content_width; - if (!config.bluetooth.enabled) { - starting_position = 0; - } + // With BT disabled we pin this row left to make room for the extra "BT off" indicator. + const int starting_position = config.bluetooth.enabled ? x : 0; display->drawString(starting_position, getTextPositions(display)[line], chUtil); - // Force 56% or higher to show a full 100% bar, text would still show related percent. + // Force 61% or higher to show a full 100% bar, text would still show related percent. if (chutil_percent >= 61) { chutil_percent = 100; } - // Weighting for nonlinear segments - float milestone1 = 25; - float milestone2 = 40; - float weight1 = 0.45; // Weight for 0–25% - float weight2 = 0.35; // Weight for 25–40% - float weight3 = 0.20; // Weight for 40–100% - float totalWeight = weight1 + weight2 + weight3; - - int seg1 = chutil_bar_width * (weight1 / totalWeight); - int seg2 = chutil_bar_width * (weight2 / totalWeight); - int seg3 = chutil_bar_width * (weight3 / totalWeight); - - int fillRight = 0; - - if (chutil_percent <= milestone1) { - fillRight = (seg1 * (chutil_percent / milestone1)); - } else if (chutil_percent <= milestone2) { - fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); - } else { - fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); - } + int fillRight = computeChannelUtilizationFill(chutil_percent, chutil_bar_max_fill); // Draw outline display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); // Fill progress if (fillRight > 0) { - display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); +#if GRAPHICS_TFT_COLORING_ENABLED + uint16_t UtilizationFillColor = TFTPalette::Good; + if (raw_chutil_percent >= 60) { + UtilizationFillColor = TFTPalette::Bad; + } else if (raw_chutil_percent >= 35) { + UtilizationFillColor = TFTPalette::Medium; + } + setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, + starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); +#endif + display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2); } display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line], @@ -938,9 +1285,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) { textWidth = UIRenderer::measureStringWithEmotes(display, combinedName); nameX = (SCREEN_WIDTH - textWidth) / 2; - UIRenderer::drawStringWithEmotes( - display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, - combinedName, FONT_HEIGHT_SMALL, 1, false); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset, combinedName, + FONT_HEIGHT_SMALL, 1, false); } else { // === LongName Centered === textWidth = UIRenderer::measureStringWithEmotes(display, longName); @@ -1116,6 +1462,9 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED // draw centered icon left to right and centered above the one line of app text #if defined(M5STACK_UNITC6L) display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); + if (gBootSplashBoldPass) { + display->drawXbm(x + (SCREEN_WIDTH - 50) / 2 + 1, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits); + } display->setFont(FONT_MEDIUM); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); @@ -1125,6 +1474,9 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED int msgX = x + (SCREEN_WIDTH - msgWidth) / 2; int msgY = y; display->drawString(msgX, msgY, upperMsg); + if (gBootSplashBoldPass) { + display->drawString(msgX + 1, msgY, upperMsg); + } } // Draw version and short name in bottom middle char footer[64]; @@ -1137,6 +1489,10 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED int footerX = x + ((SCREEN_WIDTH - footerW) / 2); UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1, false); + if (gBootSplashBoldPass) { + UIRenderer::drawStringWithEmotes(display, footerX + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, + 1, false); + } screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1147,21 +1503,35 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED display->setFont(FONT_MEDIUM); display->setTextAlignment(TEXT_ALIGN_LEFT); const char *title = "meshtastic.org"; - display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); + display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title); + if (gBootSplashBoldPass) { + display->drawString(x + getStringCenteredX(title) + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title); + } display->setFont(FONT_SMALL); // Draw region in upper left - if (upperMsg) - display->drawString(x + 0, y + 0, upperMsg); + if (upperMsg) { + display->drawString(x + 5, y + 5, upperMsg); + if (gBootSplashBoldPass) { + display->drawString(x + 6, y + 5, upperMsg); + } + } // Draw version and short name in upper right const char *version = xstr(APP_VERSION_SHORT); - int versionX = x + SCREEN_WIDTH - display->getStringWidth(version); - display->drawString(versionX, y + 0, version); + int versionX = x + SCREEN_WIDTH - display->getStringWidth(version) - 5; + display->drawString(versionX, y + 5, version); + if (gBootSplashBoldPass) { + display->drawString(versionX + 1, y + 5, version); + } if (owner.short_name && owner.short_name[0]) { const char *shortName = owner.short_name; int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName); - int shortNameX = x + SCREEN_WIDTH - shortNameW; - UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false); + int shortNameX = x + SCREEN_WIDTH - shortNameW - 5; + UIRenderer::drawStringWithEmotes(display, shortNameX, y + 5 + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false); + if (gBootSplashBoldPass) { + UIRenderer::drawStringWithEmotes(display, shortNameX + 1, y + 5 + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, + false); + } } screen->forceDisplay(); @@ -1169,6 +1539,20 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED #endif } +void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ +#if GRAPHICS_TFT_COLORING_ENABLED + // Meshtastic brand green background with black foreground text/icon on TFT startup screen. + static constexpr uint16_t kMeshtasticGreen = TFTPalette::rgb565(103, 234, 145); + setAndRegisterTFTColorRole(TFTColorRole::BootSplash, TFTPalette::Black, kMeshtasticGreen, x, y, SCREEN_WIDTH, SCREEN_HEIGHT); + gBootSplashBoldPass = true; +#endif + drawIconScreen(upperMsg, display, state, x, y); +#if GRAPHICS_TFT_COLORING_ENABLED + gBootSplashBoldPass = false; +#endif +} + // **************************** // * My Position Screen * // **************************** @@ -1296,45 +1680,23 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU int16_t compassRadius = usableHeight / 2; if (compassRadius < 8) compassRadius = 8; - const int16_t compassDiam = compassRadius * 2; const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8; // Center vertically and nudge down slightly to keep "N" clear of header const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - display->drawCircle(compassX, compassY, compassRadius); - if (validHeading) { - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading); - - // "N" label - float northAngle = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); - } + drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1, + statusLine2); } else { // Portrait or square: put compass at the bottom and centered, scaled to fit available space // For E-Ink screens, account for navigation bar at the bottom! - int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2; - const int margin = 4; - int availableHeight = + const int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2; #if defined(USE_EINK) - SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink + const int margin = 4; + int availableHeight = SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink #else - SCREEN_HEIGHT - yBelowContent - margin; + const int margin = 4; + int availableHeight = SCREEN_HEIGHT - yBelowContent - margin; #endif if (availableHeight < FONT_HEIGHT_SMALL * 2) @@ -1349,29 +1711,8 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU int compassX = x + SCREEN_WIDTH / 2; int compassY = yBelowContent + availableHeight / 2; - display->drawCircle(compassX, compassY, compassRadius); - if (validHeading) { - CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); - - // "N" label - float northAngle = 0; - if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) - northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); - } else { - drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2); - } + drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1, + statusLine2); } } #endif @@ -1443,18 +1784,21 @@ void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *sta #endif // Navigation bar overlay implementation -static int8_t lastFrameIndex = -1; +static int16_t lastFrameIndex = -1; static uint32_t lastFrameChangeTime = 0; constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; // cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) { - int currentFrame = state->currentFrame; + uint8_t frameToHighlight = state->currentFrame; + if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) { + frameToHighlight = state->transitionFrameTarget; + } // Detect frame change and record time - if (currentFrame != lastFrameIndex) { - lastFrameIndex = currentFrame; + if (frameToHighlight != lastFrameIndex) { + lastFrameIndex = frameToHighlight; lastFrameChangeTime = millis(); } @@ -1473,15 +1817,15 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta usableWidth = iconSize; const size_t iconsPerPage = usableWidth / (iconSize + spacing); - const size_t currentPage = currentFrame / iconsPerPage; + const size_t currentPage = frameToHighlight / iconsPerPage; const size_t pageStart = currentPage * iconsPerPage; const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons); const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing; const int xStart = (SCREEN_WIDTH - totalWidth) / 2; - bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS; - int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT; + const bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS; + const int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT; #if defined(USE_EINK) // Only show bar briefly after switching frames @@ -1512,25 +1856,54 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta // Pre-calculate bounding rect const int rectX = xStart - 2 - bigOffset; + const int rectY = y - 2; const int rectWidth = totalWidth + 4 + (bigOffset * 2); const int rectHeight = iconSize + 6; // Clear background and draw border display->setColor(BLACK); - display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2); +#if GRAPHICS_TFT_COLORING_ENABLED + // NavigationBar and NavigationArrow roles are fully defined in the theme table. + // We must call setTFTColorRole() before registerTFTColorRegion() because + // registerTFTColorRegion() snapshots colors from the roleColors[] working array, + // and loadThemeDefaults() isn't guaranteed to have run since boot. + const TFTThemeDef &theme = getActiveTheme(); + const auto &navBarRole = theme.roles[static_cast(TFTColorRole::NavigationBar)]; + const auto &navArrowRole = theme.roles[static_cast(TFTColorRole::NavigationArrow)]; + + setAndRegisterTFTColorRole(TFTColorRole::NavigationBar, navBarRole.onColor, navBarRole.offColor, rectX, rectY, rectWidth, + rectHeight); + setTFTColorRole(TFTColorRole::NavigationArrow, navArrowRole.onColor, navArrowRole.offColor); + display->fillRect(rectX, rectY, rectWidth, rectHeight); +#else + // Keep legacy OLED behavior untouched. + display->fillRect(rectX + 1, rectY, rectWidth - 2, rectHeight - 2); + display->setColor(WHITE); + display->drawRect(rectX, rectY, rectWidth, rectHeight); +#endif + + // Icons are 1-bit glyphs and must be drawn with WHITE to set pixels. display->setColor(WHITE); - display->drawRect(rectX, y - 2, rectWidth, rectHeight); // Icon drawing loop for the current page for (size_t i = pageStart; i < pageEnd; ++i) { const uint8_t *icon = screen->indicatorIcons[i]; const int x = xStart + (i - pageStart) * (iconSize + spacing); - const bool isActive = (i == static_cast(currentFrame)); + const bool isActive = (i == static_cast(frameToHighlight)); if (isActive) { +#if GRAPHICS_TFT_COLORING_ENABLED + // Active icon inverts on TFT: white chip with black glyph. + // Keep the buffer visibly different too, so dirty-rect updates include this region. + registerTFTColorRegion(TFTColorRole::NavigationBar, x - 1, y - 1, iconSize + 2, iconSize + 2); + display->setColor(WHITE); + display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2); + display->setColor(BLACK); +#else display->setColor(WHITE); display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4); display->setColor(BLACK); +#endif } if (currentResolution == ScreenResolution::High) { @@ -1544,22 +1917,17 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta } } - // Compact arrow drawer + display->setColor(WHITE); + + const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1; + const int halfH = rectHeight / 2; + const int top = rectY + (rectHeight - halfH) / 2; + const int bottom = top + halfH - 1; + const int midY = top + (halfH / 2); + const int maxW = 4; + auto drawArrow = [&](bool rightSide) { - display->setColor(WHITE); - - const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1; - const int halfH = rectHeight / 2; - - const int top = (y - 2) + (rectHeight - halfH) / 2; - const int bottom = top + halfH - 1; - const int midY = top + (halfH / 2); - - const int maxW = 4; - - // Determine left X coordinate - int baseX = rightSide ? (rectX + rectWidth + offset) : // right arrow - (rectX - offset - 1); // left arrow + int baseX = rightSide ? (rectX + rectWidth + offset) : (rectX - offset - 1); for (int yy = top; yy <= bottom; yy++) { int dist = abs(yy - midY); @@ -1574,21 +1942,43 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta } } }; + // Right arrow - if (pageEnd < totalIcons) { + if (navBarVisible && pageEnd < totalIcons) { + int baseX = rectX + rectWidth + offset; + int regionX = baseX; + +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTColorRegion(TFTColorRole::NavigationArrow, regionX, top, maxW, halfH); +#endif + drawArrow(true); } // Left arrow - if (pageStart > 0) { + if (navBarVisible && pageStart > 0) { + int baseX = rectX - offset - 1; + int regionX = baseX - maxW + 1; + +#if GRAPHICS_TFT_COLORING_ENABLED + registerTFTColorRegion(TFTColorRole::NavigationArrow, regionX, top, maxW, halfH); +#endif + drawArrow(false); } // Knock the corners off the square +#if GRAPHICS_TFT_COLORING_ENABLED + // TFT corner mask + registerTFTColorRegion(TFTColorRole::NavigationArrow, rectX, rectY, 1, 1); + registerTFTColorRegion(TFTColorRole::NavigationArrow, rectX + rectWidth - 1, rectY, 1, 1); +#else + // monochrome styling only display->setColor(BLACK); - display->drawRect(rectX, y - 2, 1, 1); - display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1); + display->drawRect(rectX, rectY, 1, 1); + display->drawRect(rectX + rectWidth - 1, rectY, 1, 1); display->setColor(WHITE); +#endif } void UIRenderer::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index a705d944d..0aeace42e 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -54,6 +54,8 @@ class UIRenderer static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + // Icon and screen drawing functions static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h old mode 100644 new mode 100755 diff --git a/src/motion/BMA423Sensor.cpp b/src/motion/BMA423Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/BMA423Sensor.h b/src/motion/BMA423Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/BMX160Sensor.cpp b/src/motion/BMX160Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/LIS3DHSensor.cpp b/src/motion/LIS3DHSensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/LIS3DHSensor.h b/src/motion/LIS3DHSensor.h old mode 100644 new mode 100755 diff --git a/src/motion/LSM6DS3Sensor.cpp b/src/motion/LSM6DS3Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/LSM6DS3Sensor.h b/src/motion/LSM6DS3Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/MPU6050Sensor.cpp b/src/motion/MPU6050Sensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/MPU6050Sensor.h b/src/motion/MPU6050Sensor.h old mode 100644 new mode 100755 diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h old mode 100644 new mode 100755 diff --git a/src/motion/STK8XXXSensor.cpp b/src/motion/STK8XXXSensor.cpp old mode 100644 new mode 100755 diff --git a/src/motion/STK8XXXSensor.h b/src/motion/STK8XXXSensor.h old mode 100644 new mode 100755 diff --git a/src/sleep.cpp b/src/sleep.cpp index a2a943a1f..792781f6d 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -79,7 +79,7 @@ RTC_DATA_ATTR int bootCount = 0; */ void setCPUFast(bool on) { -#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT +#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT && !defined(T_LORA_PAGER) && !defined(T_DECK) if (isWifiAvailable()) { /* diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5a5004a45..d6634aa74 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -120,7 +120,7 @@ build_flags = -D TFT_OFFSET_Y=0 -D TFT_OFFSET_ROTATION=0 -D SCREEN_ROTATE - -D SCREEN_TRANSITION_FRAMERATE=5 + -D SCREEN_TRANSITION_FRAMERATE=30 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 -D TOUCH_I2C_PORT=0 @@ -133,4 +133,4 @@ lib_deps = ${heltec_v4_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.19 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master - https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip \ No newline at end of file + https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip diff --git a/variants/esp32s3/heltec_vision_master_t190/platformio.ini b/variants/esp32s3/heltec_vision_master_t190/platformio.ini index 3dab9f93c..c0c3b2f0e 100644 --- a/variants/esp32s3/heltec_vision_master_t190/platformio.ini +++ b/variants/esp32s3/heltec_vision_master_t190/platformio.ini @@ -20,5 +20,5 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip upload_speed = 921600 diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini index 69c4f52a5..1144994a0 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini +++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini @@ -13,7 +13,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip # renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix diff --git a/variants/esp32s3/picomputer-s3/variant.h b/variants/esp32s3/picomputer-s3/variant.h index 7b6218f87..60afac002 100644 --- a/variants/esp32s3/picomputer-s3/variant.h +++ b/variants/esp32s3/picomputer-s3/variant.h @@ -47,10 +47,9 @@ #define TFT_OFFSET_Y 0 #define TFT_OFFSET_ROTATION 0 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) #define INPUTBROKER_MATRIX_TYPE 1 diff --git a/variants/esp32s3/seeed-sensecap-indicator/variant.h b/variants/esp32s3/seeed-sensecap-indicator/variant.h index f946528ae..8fa9e2393 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/variant.h +++ b/variants/esp32s3/seeed-sensecap-indicator/variant.h @@ -36,7 +36,7 @@ #define TFT_OFFSET_ROTATION 0 #define TFT_BL 45 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 // fps +#define SCREEN_TRANSITION_FRAMERATE 30 // fps #define USE_TFTDISPLAY 1 #define HAS_TOUCHSCREEN 1 diff --git a/variants/esp32s3/station-g2/pins_arduino.h b/variants/esp32s3/station-g2/pins_arduino.h old mode 100644 new mode 100755 diff --git a/variants/esp32s3/station-g2/platformio.ini b/variants/esp32s3/station-g2/platformio.ini old mode 100644 new mode 100755 diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h old mode 100644 new mode 100755 diff --git a/variants/esp32s3/t-deck/variant.h b/variants/esp32s3/t-deck/variant.h index 5d885579a..eb1bbdfef 100644 --- a/variants/esp32s3/t-deck/variant.h +++ b/variants/esp32s3/t-deck/variant.h @@ -20,7 +20,7 @@ #define TFT_OFFSET_Y 0 #define TFT_OFFSET_ROTATION 0 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness #define USE_TFTDISPLAY 1 #define HAS_PHYSICAL_KEYBOARD 1 diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h index d97f864c3..3d6397475 100644 --- a/variants/esp32s3/tlora-pager/variant.h +++ b/variants/esp32s3/tlora-pager/variant.h @@ -18,7 +18,7 @@ #define TFT_OFFSET_Y 0 #define TFT_OFFSET_ROTATION 3 #define SCREEN_ROTATE -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 #define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness #define USE_TFTDISPLAY 1 #define HAS_PHYSICAL_KEYBOARD 1 diff --git a/variants/esp32s3/tracksenger/internal/variant.h b/variants/esp32s3/tracksenger/internal/variant.h index f9a20c901..b2822c24b 100644 --- a/variants/esp32s3/tracksenger/internal/variant.h +++ b/variants/esp32s3/tracksenger/internal/variant.h @@ -73,7 +73,6 @@ #define SX126X_DIO3_TCXO_VOLTAGE 1.8 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) // keyboard changes @@ -89,4 +88,4 @@ { \ 26, 37, 17, 16, 15, 7 \ } -// #end keyboard \ No newline at end of file +// #end keyboard diff --git a/variants/esp32s3/tracksenger/lcd/variant.h b/variants/esp32s3/tracksenger/lcd/variant.h index 029f7753b..6c32ff279 100644 --- a/variants/esp32s3/tracksenger/lcd/variant.h +++ b/variants/esp32s3/tracksenger/lcd/variant.h @@ -97,7 +97,6 @@ #define SX126X_DIO3_TCXO_VOLTAGE 1.8 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) // keyboard changes @@ -113,4 +112,4 @@ { \ 26, 37, 17, 16, 15, 7 \ } -// #end keyboard \ No newline at end of file +// #end keyboard diff --git a/variants/esp32s3/tracksenger/oled/variant.h b/variants/esp32s3/tracksenger/oled/variant.h index 1f1fbbaa1..72762c7af 100644 --- a/variants/esp32s3/tracksenger/oled/variant.h +++ b/variants/esp32s3/tracksenger/oled/variant.h @@ -74,7 +74,6 @@ #define SX126X_DIO3_TCXO_VOLTAGE 1.8 // Picomputer gets a white on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255) // keyboard changes @@ -90,4 +89,4 @@ { \ 26, 37, 17, 16, 15, 7 \ } -// #end keyboard \ No newline at end of file +// #end keyboard diff --git a/variants/esp32s3/unphone/variant.h b/variants/esp32s3/unphone/variant.h index 268eedea5..76c66ca64 100644 --- a/variants/esp32s3/unphone/variant.h +++ b/variants/esp32s3/unphone/variant.h @@ -35,7 +35,7 @@ #define TFT_OFFSET_ROTATION 6 // unPhone's screen wired unusually, 0 typical #define TFT_INVERT false #define SCREEN_ROTATE true -#define SCREEN_TRANSITION_FRAMERATE 5 +#define SCREEN_TRANSITION_FRAMERATE 30 #define USE_TFTDISPLAY 1 #define HAS_TOUCHSCREEN 1 @@ -74,4 +74,4 @@ // #define BATTERY_PIN 13 // battery V measurement pin; vbat divider is here // #define ADC_CHANNEL ADC2_GPIO13_CHANNEL -// #define BAT_MEASURE_ADC_UNIT 2 \ No newline at end of file +// #define BAT_MEASURE_ADC_UNIT 2 diff --git a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini index c9f998240..77beb4d33 100644 --- a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini +++ b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini @@ -23,4 +23,4 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_ lib_deps = ${nrf52840_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip diff --git a/variants/nrf52840/heltec_mesh_node_t114/variant.h b/variants/nrf52840/heltec_mesh_node_t114/variant.h index e7385c4bb..509f749a8 100644 --- a/variants/nrf52840/heltec_mesh_node_t114/variant.h +++ b/variants/nrf52840/heltec_mesh_node_t114/variant.h @@ -57,9 +57,6 @@ extern "C" { #define TFT_OFFSET_X 0 #define TFT_OFFSET_Y 0 -// T114 gets a muted yellow on black display -#define TFT_MESH_OVERRIDE COLOR565(255, 255, 128) - // #define TFT_OFFSET_ROTATION 0 // #define SCREEN_ROTATE // #define SCREEN_TRANSITION_FRAMERATE 5 diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini index 1b6f59a68..ae68455dc 100644 --- a/variants/nrf52840/heltec_mesh_solar/platformio.ini +++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini @@ -132,4 +132,4 @@ build_flags = ${heltec_mesh_solar_base.build_flags} lib_deps = ${heltec_mesh_solar_base.lib_deps} # renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main - https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip + https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip From b148fac34059a0946b0706feef2d528227d62830 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Apr 2026 09:49:39 -0500 Subject: [PATCH 07/52] Update framework version reference for Adafruit nRF52 to latest master branch --- variants/nrf52840/nrf52.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index f42c29308..d11f4fc56 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -7,7 +7,7 @@ extends = arduino_base platform_packages = ; our custom Git version with C++17 support in platform.txt # TODO renovate - platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#cpp17-platform + platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#master ; Don't renovate toolchain-gccarmnoneeabi platformio/toolchain-gccarmnoneeabi@~1.90301.0 From 24c4162a755e7124b9bc36b6241502974c5a395f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sun, 26 Apr 2026 19:58:23 -0500 Subject: [PATCH 08/52] Standardize PMU IRQ handling and enable power button cancel on tbeam-s3 (#10285) * Standardize PMU IRQ handling and enable power button as cancel on tbeam s3 * Original T-beam, too --- src/Power.cpp | 29 +++++++----------------- variants/esp32/tbeam/variant.h | 7 +++--- variants/esp32s3/t-watch-s3/variant.h | 1 + variants/esp32s3/tbeam-s3-core/variant.h | 6 ++--- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 49e95bd0c..17715e848 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -1000,11 +1000,8 @@ int32_t Power::runOnce() powerFSM.trigger(EVENT_POWER_CONNECTED); } -#ifdef T_WATCH_S3 - /* - In the T-Watch S3 this code fragment reacts to the short press of the button by switching the - display on and off - */ +#ifdef PMU_POWER_BUTTON_IS_CANCEL + // cancel action also turns the screen on and off. if (PMU->isPekeyShortPressIrq()) { LOG_INFO("Input: Corona Button Click"); InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_CANCEL, .kbchar = 0, .touchX = 0, .touchY = 0}; @@ -1027,13 +1024,6 @@ int32_t Power::runOnce() LOG_DEBUG("Battery removed"); } */ -#ifndef T_WATCH_S3 // FIXME - why is this triggering on the T-Watch S3? - if (PMU->isPekeyLongPressIrq()) { - LOG_DEBUG("PEK long button press"); - if (screen) - screen->setOn(false); - } -#endif PMU->clearIrqStatus(); } @@ -1102,7 +1092,7 @@ void Power::attachPowerInterrupts() if (PMU) { attachInterrupt( PMU_IRQ, - [] { + []() { pmu_irq = true; power->setIntervalFromNow(0); runASAP = true; @@ -1405,19 +1395,16 @@ bool Power::axpChipInit() uint64_t pmuIrqMask = 0; if (PMU->getChipModel() == XPOWERS_AXP192) { - pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_BAT_INSERT_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ; + pmuIrqMask = XPOWERS_AXP192_VBUS_INSERT_IRQ | XPOWERS_AXP192_VBUS_REMOVE_IRQ | XPOWERS_AXP192_PKEY_SHORT_IRQ; } else if (PMU->getChipModel() == XPOWERS_AXP2101) { - pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_BAT_INSERT_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ; + pmuIrqMask = XPOWERS_AXP2101_VBUS_INSERT_IRQ | XPOWERS_AXP2101_VBUS_REMOVE_IRQ | XPOWERS_AXP2101_PKEY_SHORT_IRQ; } pinMode(PMU_IRQ, INPUT); - // we do not look for AXPXXX_CHARGING_FINISHED_IRQ & AXPXXX_CHARGING_IRQ - // because it occurs repeatedly while there is no battery also it could cause - // inadvertent waking from light sleep just because the battery filled we - // don't look for AXPXXX_BATT_REMOVED_IRQ because it occurs repeatedly while - // no battery installed we don't look at AXPXXX_VBUS_REMOVED_IRQ because we - // don't have anything hooked to vbus + // We wake on IRQ, so only enable the IRQs that we care about. + // we want USB plug and unplug to update the screen and LED status, + // and short press on the power button to trigger the "cancel" action in the UI (which also turns the screen on and off). PMU->enableIRQ(pmuIrqMask); PMU->clearIrqStatus(); diff --git a/variants/esp32/tbeam/variant.h b/variants/esp32/tbeam/variant.h index cca52cb9a..e51855b1a 100644 --- a/variants/esp32/tbeam/variant.h +++ b/variants/esp32/tbeam/variant.h @@ -35,9 +35,10 @@ // code) #endif -// Leave undefined to disable our PMU IRQ handler. DO NOT ENABLE THIS because the pmuirq can cause sperious interrupts -// and waking from light sleep -// #define PMU_IRQ 35 +// Voiding more warranties. +#define PMU_IRQ 35 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) + #define HAS_AXP192 #define GPS_UBLOX #define GPS_RX_PIN 34 diff --git a/variants/esp32s3/t-watch-s3/variant.h b/variants/esp32s3/t-watch-s3/variant.h index aca491a6d..fddd98304 100644 --- a/variants/esp32s3/t-watch-s3/variant.h +++ b/variants/esp32s3/t-watch-s3/variant.h @@ -42,6 +42,7 @@ #define DAC_I2S_MCLK -1 #define HAS_AXP2101 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) // PCF8563 RTC Module #define PCF8563_RTC 0x51 diff --git a/variants/esp32s3/tbeam-s3-core/variant.h b/variants/esp32s3/tbeam-s3-core/variant.h index 2637e7f78..11e463364 100644 --- a/variants/esp32s3/tbeam-s3-core/variant.h +++ b/variants/esp32s3/tbeam-s3-core/variant.h @@ -46,9 +46,9 @@ #define LR11X0_DIO_AS_RF_SWITCH #endif -// Leave undefined to disable our PMU IRQ handler. DO NOT ENABLE THIS because the pmuirq can cause sperious interrupts -// and waking from light sleep -// #define PMU_IRQ 40 +// Voiding warrenties, we're gonna try the IRQ +#define PMU_IRQ 40 +#define PMU_POWER_BUTTON_IS_CANCEL // maps a short click of the power button to a cancel action (turning off the screen) #define HAS_AXP2101 // PCF8563 RTC Module From bfadf0c36ae0cc50c76b2e1b73d10b1c1f15b8d5 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:02:42 -0500 Subject: [PATCH 09/52] fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311) Router::handleReceived stores its allocCopy of the encrypted packet in the class member p_encrypted. callModules() invokes module replies that re-enter the router via MeshService::sendToMesh -> Router::sendLocal, which on a broadcast reply recursively calls handleReceived. The inner call overwrites the outer's p_encrypted without releasing it; on the way out it nulls the member, the outer release(p_encrypted) now releases nullptr, and the original allocation is permanently leaked. ~381 B per recursion. Promote p_encrypted to a function-local so each invocation owns its own copy for its full lifetime. The MQTT-publish null check at the call site (added by PR #9136 as a workaround for this bug) stays in place because allocCopy can still legitimately return nullptr on packetPool exhaustion. Copilot's review of PR #8999 (the original introduction) flagged this exact pattern at merge time: "Storing p_encrypted as a class member can cause issues with recursive or concurrent calls to handleReceived() since each call would overwrite the previous packet pointer." The historical reason for the member (S&F needing to retain the encrypted copy across calls) was satisfied differently by PR #9799 (ServerAPI converted to std::unique_ptr + cleanup on connection close), so the member is no longer load-bearing. Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort). Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403, request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run: | Build | heapFree drift | rebootCount delta | | this patch | -1.5 KB (noise)| 0 | | stock 2.7.13 | -73 KB (8.1KB/min) | +1 (OOM crash) | Co-authored-by: Claude Opus 4.7 Co-authored-by: Ben Meadors --- src/mesh/Router.cpp | 11 +++++++---- src/mesh/Router.h | 3 --- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index e0473a14e..ffeb7c539 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -736,9 +736,13 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // Also, we should set the time from the ISR and it should have msec level resolution p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone - // Store a copy of encrypted packet for MQTT + // Store a copy of the encrypted packet for MQTT. + // Local, not a class member: handleReceived re-enters itself when a module + // reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal, + // and a member would be silently overwritten without release on the inner + // call. Each invocation now owns its own copy (issue #9632, #10101, #8729). DEBUG_HEAP_BEFORE; - p_encrypted = packetPool.allocCopy(*p); + meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); // Take those raw bytes and convert them back into a well structured protobuf we can understand @@ -832,8 +836,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) #endif } - packetPool.release(p_encrypted); // Release the encrypted packet - p_encrypted = nullptr; + packetPool.release(p_encrypted); // Release the encrypted packet (release() handles nullptr) } void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 0f342d57b..bd4188693 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -92,9 +92,6 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; - // pointer to the encrypted packet - meshtastic_MeshPacket *p_encrypted = nullptr; - protected: friend class RoutingModule; From 87f1f9d349759d043d62647a2e44c2f953cc1372 Mon Sep 17 00:00:00 2001 From: nightjoker7 <47129685+nightjoker7@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:02:42 -0500 Subject: [PATCH 10/52] fix(Router): localize p_encrypted to prevent recursive-overwrite leak (#10311) Router::handleReceived stores its allocCopy of the encrypted packet in the class member p_encrypted. callModules() invokes module replies that re-enter the router via MeshService::sendToMesh -> Router::sendLocal, which on a broadcast reply recursively calls handleReceived. The inner call overwrites the outer's p_encrypted without releasing it; on the way out it nulls the member, the outer release(p_encrypted) now releases nullptr, and the original allocation is permanently leaked. ~381 B per recursion. Promote p_encrypted to a function-local so each invocation owns its own copy for its full lifetime. The MQTT-publish null check at the call site (added by PR #9136 as a workaround for this bug) stays in place because allocCopy can still legitimately return nullptr on packetPool exhaustion. Copilot's review of PR #8999 (the original introduction) flagged this exact pattern at merge time: "Storing p_encrypted as a class member can cause issues with recursive or concurrent calls to handleReceived() since each call would overwrite the previous packet pointer." The historical reason for the member (S&F needing to retain the encrypted copy across calls) was satisfied differently by PR #9799 (ServerAPI converted to std::unique_ptr + cleanup on connection close), so the member is no longer load-bearing. Reproduces issues #9632 / #10101 / #8729 (heap leak when MeshMonitor connected; TCP drops on Station G2 / LILYGO ServerAPI dump abort). Hardware A/B on Station G2 under sustained TCP-API retry storm (open :4403, request config, disconnect mid-stream, repeat at ~0.6/s) - 9 min run: | Build | heapFree drift | rebootCount delta | | this patch | -1.5 KB (noise)| 0 | | stock 2.7.13 | -73 KB (8.1KB/min) | +1 (OOM crash) | Co-authored-by: Claude Opus 4.7 Co-authored-by: Ben Meadors --- src/mesh/Router.cpp | 11 +++++++---- src/mesh/Router.h | 3 --- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index b231261b5..eb5fd41ff 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -719,9 +719,13 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) // Also, we should set the time from the ISR and it should have msec level resolution p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone - // Store a copy of encrypted packet for MQTT + // Store a copy of the encrypted packet for MQTT. + // Local, not a class member: handleReceived re-enters itself when a module + // reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal, + // and a member would be silently overwritten without release on the inner + // call. Each invocation now owns its own copy (issue #9632, #10101, #8729). DEBUG_HEAP_BEFORE; - p_encrypted = packetPool.allocCopy(*p); + meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); // Take those raw bytes and convert them back into a well structured protobuf we can understand @@ -815,8 +819,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) #endif } - packetPool.release(p_encrypted); // Release the encrypted packet - p_encrypted = nullptr; + packetPool.release(p_encrypted); // Release the encrypted packet (release() handles nullptr) } void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 0f342d57b..bd4188693 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -92,9 +92,6 @@ class Router : protected concurrency::OSThread, protected PacketHistory before us */ uint32_t rxDupe = 0, txRelayCanceled = 0; - // pointer to the encrypted packet - meshtastic_MeshPacket *p_encrypted = nullptr; - protected: friend class RoutingModule; From 06a6c3ee2062efcf8281b74c54eef35146e3ea18 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Apr 2026 22:07:07 -0500 Subject: [PATCH 11/52] Native MacOS hello world (#10309) * Native MacOS hello world * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update variants/native/portduino/platformio.ini Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: ensure null-termination in getSerialString() and handle len==0 Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: Jonathan Bennett Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- bin/build-native.sh | 3 +- src/Power.cpp | 2 + src/RedirectablePrint.h | 2 +- src/input/InputBroker.cpp | 3 ++ src/input/LinuxInput.h | 5 ++- src/input/LinuxInputImpl.h | 3 +- src/mesh/HardwareRNG.cpp | 12 ++++- src/mesh/MeshRadio.h | 7 ++- src/mesh/RadioLibInterface.cpp | 2 +- src/mesh/RadioLibInterface.h | 2 +- src/mqtt/MQTT.cpp | 2 +- src/platform/portduino/PortduinoGlue.cpp | 18 +++++--- src/platform/portduino/USBHal.h | 10 +++-- variants/native/portduino.ini | 30 ++++++++----- variants/native/portduino/platformio.ini | 56 ++++++++++++++++++++++++ 15 files changed, 129 insertions(+), 28 deletions(-) diff --git a/bin/build-native.sh b/bin/build-native.sh index f35e46a87..e34b75580 100755 --- a/bin/build-native.sh +++ b/bin/build-native.sh @@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION pio pkg install --environment "$PIO_ENV" || platformioFailed pio run --environment "$PIO_ENV" || platformioFailed -cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)" +os_name=$(uname -s | tr '[:upper:]' '[:lower:]') +cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)" cp bin/native-install.* $OUTDIR/ diff --git a/src/Power.cpp b/src/Power.cpp index 17715e848..bb9f554be 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -781,8 +781,10 @@ void Power::reboot() rp2040.reboot(); #elif defined(ARCH_PORTDUINO) deInitApiServer(); +#ifdef __linux__ if (aLinuxInputImpl) aLinuxInputImpl->deInit(); +#endif SPI.end(); Wire.end(); Serial1.end(); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index c66226171..8535933fc 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -1,8 +1,8 @@ #pragma once #include "../freertosinc.h" +#include "Print.h" #include "mesh/generated/meshtastic/mesh.pb.h" -#include #include #include diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index b7c9b27a9..393cbc0ec 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -390,8 +390,11 @@ void InputBroker::Init() seesawRotary = nullptr; } } +#ifdef __linux__ + // Linux evdev keyboard input only — macOS has no . aLinuxInputImpl = new LinuxInputImpl(); aLinuxInputImpl->init(); +#endif } #endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL diff --git a/src/input/LinuxInput.h b/src/input/LinuxInput.h index 43d08493c..673d29b3c 100644 --- a/src/input/LinuxInput.h +++ b/src/input/LinuxInput.h @@ -1,5 +1,8 @@ #pragma once -#if ARCH_PORTDUINO +// Linux evdev keyboard input. Only compiled on Linux portduino targets; +// macOS / non-Linux builds have no or epoll, and the +// headless build doesn't need real keyboards anyway. +#if ARCH_PORTDUINO && defined(__linux__) #include "InputBroker.h" #include "concurrency/OSThread.h" #include diff --git a/src/input/LinuxInputImpl.h b/src/input/LinuxInputImpl.h index e734b0294..716c6619a 100644 --- a/src/input/LinuxInputImpl.h +++ b/src/input/LinuxInputImpl.h @@ -1,4 +1,5 @@ -#ifdef ARCH_PORTDUINO +// Linux evdev impl. Same Linux-only gating as LinuxInput.h. +#if defined(ARCH_PORTDUINO) && defined(__linux__) #pragma once #include "LinuxInput.h" #include "main.h" diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index b79b0d012..a34a9477c 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -19,8 +19,12 @@ extern Adafruit_nRFCrypto nRFCrypto; #include #elif defined(ARCH_PORTDUINO) #include -#include #include +#ifdef __linux__ +#include // getrandom() +#else +#include // arc4random_buf() on Darwin/BSD +#endif #endif namespace HardwareRNG @@ -119,10 +123,16 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) filled = true; #elif defined(ARCH_PORTDUINO) // Prefer the host OS RNG first when running under Portduino. +#ifdef __linux__ ssize_t generated = ::getrandom(buffer, length, 0); if (generated == static_cast(length)) { filled = true; } +#else + // arc4random_buf is available on Darwin/BSD and cannot fail. + ::arc4random_buf(buffer, length); + filled = true; +#endif if (!filled) { fillWithRandomDevice(buffer, length); diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 089b4b189..fe4788bff 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -6,8 +6,11 @@ #include "configuration.h" #include "detect/LoRaRadioType.h" -// Sentinel marking the end of a modem preset array -static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = +// Sentinel marking the end of a modem preset array. Declared `const` rather +// than `constexpr` because the cast from 0xFF to the enum is out-of-range and +// therefore not a valid constant expression on Clang 16+ (Apple Clang on +// macOS). The value is only ever compared at runtime, so static-init is fine. +static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = static_cast(0xFF); // Region profile: bundles the preset list with regulatory parameters shared across regions diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 6024d06b6..de468cf97 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -119,7 +119,7 @@ bool RadioLibInterface::canSendImmediately() return true; } -bool RadioLibInterface::receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag) +bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag) { bool detected = (irq & (syncWordHeaderValidFlag | preambleDetectedFlag)); // Handle false detections diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 2859558ed..0740561f9 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -220,7 +220,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; - bool receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag); + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. * Subclasses can customize, but must also call this base method */ diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index aba06c210..283fcffb1 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -32,7 +32,7 @@ #include #include -#include +#include "IPAddress.h" #if defined(ARCH_PORTDUINO) #include #elif !defined(ntohl) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 7833b3603..fd26926d9 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -9,13 +9,10 @@ #include "PortduinoGlue.h" #include "SHA256.h" #include "api/ServerAPI.h" -#include "linux/gpio/LinuxGPIOPin.h" #include "meshUtils.h" #include #include #include -#include -#include #include #include #include @@ -25,6 +22,12 @@ #include #include +#ifdef PORTDUINO_LINUX_HARDWARE +#include "linux/gpio/LinuxGPIOPin.h" +#include +#include +#endif + #ifdef PORTDUINO_LINUX_HARDWARE #include #endif @@ -130,9 +133,9 @@ void getMacAddr(uint8_t *dmac) } } else if (portduino_config.mac_address.length() > 11) { MAC_from_string(portduino_config.mac_address, dmac); - exit; + return; } else { - +#ifdef PORTDUINO_LINUX_HARDWARE struct hci_dev_info di = {0}; di.dev_id = 0; bdaddr_t bdaddr; @@ -152,6 +155,11 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#else + // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; + // the caller can override via the --hwid CLI flag or the YAML config. + (void)dmac; +#endif } } diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 1725763f2..9496b2ccb 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -5,6 +5,7 @@ #include "platform/portduino/PortduinoGlue.h" #include #include +#include #include #include #include @@ -34,7 +35,7 @@ class Ch341Hal : public RadioLibHal : RadioLibHal(PI_INPUT, PI_OUTPUT, PI_LOW, PI_HIGH, PI_RISING, PI_FALLING) { if (serial != "") { - strncpy(pinedio.serial_number, serial.c_str(), 8); + std::strncpy(pinedio.serial_number, serial.c_str(), 8); pinedio_set_option(&pinedio, PINEDIO_OPTION_SEARCH_SERIAL, 1); } // LOG_INFO("USB Serial: %s", pinedio.serial_number); @@ -59,8 +60,11 @@ class Ch341Hal : public RadioLibHal void getSerialString(char *_serial, size_t len) { - len = len > 8 ? 8 : len; - strncpy(_serial, pinedio.serial_number, len); + if (len == 0) + return; + size_t bytesCopied = (len - 1) < 8 ? (len - 1) : 8; + std::strncpy(_serial, pinedio.serial_number, bytesCopied); + _serial[bytesCopied] = '\0'; } void getProductString(char *_product_string, size_t len) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index b276d2779..35c8c6697 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/135b91e953db0b5f44d278f8ebd5b8d985fc03d8.zip + https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip framework = arduino build_src_filter = @@ -37,25 +37,35 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680 https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip -build_flags = +; Cross-platform build flags shared between native (Linux) and native-macos builds. +; Anything Linux-only (libbluetooth, libgpiod, libi2c, /sys/class/gpio, +; PORTDUINO_LINUX_HARDWARE, glibc-style _FORTIFY_SOURCE) goes in `build_flags` +; instead. UDP multicast also lives there because the firmware's +; UdpMulticastHandler.h unconditionally `#include ` on non-NRF52 +; targets, which requires the framework's bundled WiFi shim that we +; lib_ignore on macOS. +build_flags_common = ${arduino_base.build_flags} -D ARCH_PORTDUINO -fPIC - -D_FORTIFY_SOURCE=2 - -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED - -DPORTDUINO_LINUX_HARDWARE - -DHAS_UDP_MULTICAST=1 -lpthread - -lstdc++fs - -lbluetooth - -lgpiod -lyaml-cpp - -li2c -luv -std=gnu17 -std=gnu++17 + +build_flags = + ${portduino_base.build_flags_common} + -DHAS_UDP_MULTICAST=1 + -D_FORTIFY_SOURCE=2 + -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 + -DPORTDUINO_LINUX_HARDWARE + -lstdc++fs + -lbluetooth + -lgpiod + -li2c lib_ignore = Adafruit NeoPixel Adafruit ST7735 and ST7789 Library diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 045e3edea..c497d0c17 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -117,3 +117,59 @@ build_flags = -lgcov --coverage -fprofile-abs-path -fsanitize=address ${env:nati test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s + +; --------------------------------------------------------------------------- +; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that +; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 +; USB-SPI bridge. No BlueZ, libgpiod, or Linux I2C — those require Linux. +; +; Prerequisites (Homebrew): +; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; +; The macOS-side patches now live upstream: +; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, +; empty-variant-dir guard. Pulled via `portduino_base.platform` zip pin. +; * meshtastic/framework-portduino — LinuxHardwareI2C macOS stubs, AsyncUDP +; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C +; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. +; This env therefore only carries the firmware-side build flags and src filter. +; --------------------------------------------------------------------------- +[env:native-macos] +extends = native_base +; Apple's ld doesn't accept GNU ld's `-Wl,-Map,` syntax (inherited from +; the top-level platformio.ini). Strip it; the linker map isn't useful for +; the macOS dev loop anyway, and Apple ld's equivalent (`-Wl,-map,`) +; uses different argument shape. +build_unflags = -Wl,-Map,"${platformio.build_dir}"/output.map +build_flags = ${portduino_base.build_flags_common} + -I variants/native/portduino + -I/opt/homebrew/include + -I/opt/homebrew/opt/argp-standalone/include + -I/opt/homebrew/opt/yaml-cpp/include + -L/opt/homebrew/lib + -L/opt/homebrew/opt/argp-standalone/lib + -L/opt/homebrew/opt/yaml-cpp/lib + -largp + -DPORTDUINO_DARWIN + ; Headless build — variants/native/portduino/variant.h would otherwise + ; default HAS_SCREEN to 1 and pull in screen-renderer source that uses + ; VLA-with-initializer (a GNU/GCC extension Apple Clang rejects). + ; MESHTASTIC_EXCLUDE_SCREEN gates the optional `screen->setHeading(...)`- + ; style screen-driver hooks scattered through sensor sources. + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 + !pkg-config --libs openssl --silence-errors || : +; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist +; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX +; (which we lib_ignore on macOS for the issue). Neither is needed +; for the headless build. +build_src_filter = ${native_base.build_src_filter} + - + - + - + - +; LovyanGFX includes (Linux-only) and is only needed by TFT +; variants — not relevant for the headless macOS build. +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX From 47a6c4c6a032d4444efc32f9659ab713649cc205 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 06:22:50 -0500 Subject: [PATCH 12/52] Upgrade trunk (#10317) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index f90f4f4ac..178a1cc9e 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,15 +8,15 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.524 - - renovate@43.141.0 + - checkov@3.2.525 + - renovate@43.142.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 - bandit@1.9.4 - trivy@0.70.0 - taplo@0.10.0 - - ruff@0.15.11 + - ruff@0.15.12 - isort@8.0.1 - markdownlint@0.48.0 - oxipng@10.1.1 From 126861fd1635963707b5ed3902f65aa1c1dbe23b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 26 Apr 2026 22:07:07 -0500 Subject: [PATCH 13/52] Native MacOS hello world (#10309) * Native MacOS hello world * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update variants/native/portduino/platformio.ini Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: ensure null-termination in getSerialString() and handle len==0 Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/e5647919-2255-48ad-bcaa-7a2c2fdbf212 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: Jonathan Bennett Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- bin/build-native.sh | 3 +- src/Power.cpp | 2 + src/RedirectablePrint.h | 2 +- src/input/InputBroker.cpp | 3 ++ src/input/LinuxInput.h | 5 ++- src/input/LinuxInputImpl.h | 3 +- src/mesh/HardwareRNG.cpp | 12 ++++- src/mesh/MeshRadio.h | 27 ++++++++++++ src/mesh/RadioLibInterface.cpp | 2 +- src/mesh/RadioLibInterface.h | 2 +- src/mqtt/MQTT.cpp | 2 +- src/platform/portduino/PortduinoGlue.cpp | 18 +++++--- src/platform/portduino/USBHal.h | 10 +++-- variants/native/portduino.ini | 30 ++++++++----- variants/native/portduino/platformio.ini | 56 ++++++++++++++++++++++++ 15 files changed, 151 insertions(+), 26 deletions(-) diff --git a/bin/build-native.sh b/bin/build-native.sh index f35e46a87..e34b75580 100755 --- a/bin/build-native.sh +++ b/bin/build-native.sh @@ -31,5 +31,6 @@ basename=meshtasticd-$1-$VERSION pio pkg install --environment "$PIO_ENV" || platformioFailed pio run --environment "$PIO_ENV" || platformioFailed -cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_linux_$(uname -m)" +os_name=$(uname -s | tr '[:upper:]' '[:lower:]') +cp "$BUILDDIR/meshtasticd" "$OUTDIR/meshtasticd_${os_name}_$(uname -m)" cp bin/native-install.* $OUTDIR/ diff --git a/src/Power.cpp b/src/Power.cpp index ecdda8dd9..1ea3a64c2 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -755,8 +755,10 @@ void Power::reboot() rp2040.reboot(); #elif defined(ARCH_PORTDUINO) deInitApiServer(); +#ifdef __linux__ if (aLinuxInputImpl) aLinuxInputImpl->deInit(); +#endif SPI.end(); Wire.end(); Serial1.end(); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index c66226171..8535933fc 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -1,8 +1,8 @@ #pragma once #include "../freertosinc.h" +#include "Print.h" #include "mesh/generated/meshtastic/mesh.pb.h" -#include #include #include diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index b7c9b27a9..393cbc0ec 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -390,8 +390,11 @@ void InputBroker::Init() seesawRotary = nullptr; } } +#ifdef __linux__ + // Linux evdev keyboard input only — macOS has no . aLinuxInputImpl = new LinuxInputImpl(); aLinuxInputImpl->init(); +#endif } #endif #if !MESHTASTIC_EXCLUDE_INPUTBROKER && HAS_TRACKBALL diff --git a/src/input/LinuxInput.h b/src/input/LinuxInput.h index 43d08493c..673d29b3c 100644 --- a/src/input/LinuxInput.h +++ b/src/input/LinuxInput.h @@ -1,5 +1,8 @@ #pragma once -#if ARCH_PORTDUINO +// Linux evdev keyboard input. Only compiled on Linux portduino targets; +// macOS / non-Linux builds have no or epoll, and the +// headless build doesn't need real keyboards anyway. +#if ARCH_PORTDUINO && defined(__linux__) #include "InputBroker.h" #include "concurrency/OSThread.h" #include diff --git a/src/input/LinuxInputImpl.h b/src/input/LinuxInputImpl.h index e734b0294..716c6619a 100644 --- a/src/input/LinuxInputImpl.h +++ b/src/input/LinuxInputImpl.h @@ -1,4 +1,5 @@ -#ifdef ARCH_PORTDUINO +// Linux evdev impl. Same Linux-only gating as LinuxInput.h. +#if defined(ARCH_PORTDUINO) && defined(__linux__) #pragma once #include "LinuxInput.h" #include "main.h" diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index b79b0d012..a34a9477c 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -19,8 +19,12 @@ extern Adafruit_nRFCrypto nRFCrypto; #include #elif defined(ARCH_PORTDUINO) #include -#include #include +#ifdef __linux__ +#include // getrandom() +#else +#include // arc4random_buf() on Darwin/BSD +#endif #endif namespace HardwareRNG @@ -119,10 +123,16 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) filled = true; #elif defined(ARCH_PORTDUINO) // Prefer the host OS RNG first when running under Portduino. +#ifdef __linux__ ssize_t generated = ::getrandom(buffer, length, 0); if (generated == static_cast(length)) { filled = true; } +#else + // arc4random_buf is available on Darwin/BSD and cannot fail. + ::arc4random_buf(buffer, length); + filled = true; +#endif if (!filled) { fillWithRandomDevice(buffer, length); diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 646ca86eb..1b2fd0962 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -6,6 +6,33 @@ #include "configuration.h" #include "detect/LoRaRadioType.h" +// Sentinel marking the end of a modem preset array. Declared `const` rather +// than `constexpr` because the cast from 0xFF to the enum is out-of-range and +// therefore not a valid constant expression on Clang 16+ (Apple Clang on +// macOS). The value is only ever compared at runtime, so static-init is fine. +static const meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = + static_cast(0xFF); + +// Region profile: bundles the preset list with regulatory parameters shared across regions +struct RegionProfile { + const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default + float spacing; // gaps between radio channels + float padding; // padding at each side of the "operating channel" + bool audioPermitted; + bool licensedOnly; // a region profile for licensed operators only + int8_t textThrottle; // throttle for text - future expansion + int8_t positionThrottle; // throttle for location data - future expansion + int8_t telemetryThrottle; // throttle for telemetry - future expansion + uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place +}; + +extern const RegionProfile PROFILE_STD; +extern const RegionProfile PROFILE_EU868; +extern const RegionProfile PROFILE_UNDEF; +// extern const RegionProfile PROFILE_LITE; +// extern const RegionProfile PROFILE_NARROW; +// extern const RegionProfile PROFILE_HAM; + // Map from old region names to new region enums struct RegionInfo { meshtastic_Config_LoRaConfig_RegionCode code; diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 7ef707e0d..5121ac433 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -109,7 +109,7 @@ bool RadioLibInterface::canSendImmediately() return true; } -bool RadioLibInterface::receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag) +bool RadioLibInterface::receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag) { bool detected = (irq & (syncWordHeaderValidFlag | preambleDetectedFlag)); // Handle false detections diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 310ca76bb..9ee608214 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -213,7 +213,7 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; - bool receiveDetected(uint16_t irq, ulong syncWordHeaderValidFlag, ulong preambleDetectedFlag); + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. * Subclasses can customize, but must also call this base method */ diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index ac022a1ab..902fd1c2b 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -32,7 +32,7 @@ #include #include -#include +#include "IPAddress.h" #if defined(ARCH_PORTDUINO) #include #elif !defined(ntohl) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 660bad0f2..6f8077720 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -9,13 +9,10 @@ #include "PortduinoGlue.h" #include "SHA256.h" #include "api/ServerAPI.h" -#include "linux/gpio/LinuxGPIOPin.h" #include "meshUtils.h" #include #include #include -#include -#include #include #include #include @@ -25,6 +22,12 @@ #include #include +#ifdef PORTDUINO_LINUX_HARDWARE +#include "linux/gpio/LinuxGPIOPin.h" +#include +#include +#endif + #ifdef PORTDUINO_LINUX_HARDWARE #include #endif @@ -130,9 +133,9 @@ void getMacAddr(uint8_t *dmac) } } else if (portduino_config.mac_address.length() > 11) { MAC_from_string(portduino_config.mac_address, dmac); - exit; + return; } else { - +#ifdef PORTDUINO_LINUX_HARDWARE struct hci_dev_info di = {0}; di.dev_id = 0; bdaddr_t bdaddr; @@ -152,6 +155,11 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#else + // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; + // the caller can override via the --hwid CLI flag or the YAML config. + (void)dmac; +#endif } } diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 1725763f2..9496b2ccb 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -5,6 +5,7 @@ #include "platform/portduino/PortduinoGlue.h" #include #include +#include #include #include #include @@ -34,7 +35,7 @@ class Ch341Hal : public RadioLibHal : RadioLibHal(PI_INPUT, PI_OUTPUT, PI_LOW, PI_HIGH, PI_RISING, PI_FALLING) { if (serial != "") { - strncpy(pinedio.serial_number, serial.c_str(), 8); + std::strncpy(pinedio.serial_number, serial.c_str(), 8); pinedio_set_option(&pinedio, PINEDIO_OPTION_SEARCH_SERIAL, 1); } // LOG_INFO("USB Serial: %s", pinedio.serial_number); @@ -59,8 +60,11 @@ class Ch341Hal : public RadioLibHal void getSerialString(char *_serial, size_t len) { - len = len > 8 ? 8 : len; - strncpy(_serial, pinedio.serial_number, len); + if (len == 0) + return; + size_t bytesCopied = (len - 1) < 8 ? (len - 1) : 8; + std::strncpy(_serial, pinedio.serial_number, bytesCopied); + _serial[bytesCopied] = '\0'; } void getProductString(char *_product_string, size_t len) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index b276d2779..35c8c6697 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/135b91e953db0b5f44d278f8ebd5b8d985fc03d8.zip + https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip framework = arduino build_src_filter = @@ -37,25 +37,35 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit_BME680 packageName=adafruit/Adafruit_BME680 https://github.com/adafruit/Adafruit_BME680/archive/refs/tags/2.0.6.zip -build_flags = +; Cross-platform build flags shared between native (Linux) and native-macos builds. +; Anything Linux-only (libbluetooth, libgpiod, libi2c, /sys/class/gpio, +; PORTDUINO_LINUX_HARDWARE, glibc-style _FORTIFY_SOURCE) goes in `build_flags` +; instead. UDP multicast also lives there because the firmware's +; UdpMulticastHandler.h unconditionally `#include ` on non-NRF52 +; targets, which requires the framework's bundled WiFi shim that we +; lib_ignore on macOS. +build_flags_common = ${arduino_base.build_flags} -D ARCH_PORTDUINO -fPIC - -D_FORTIFY_SOURCE=2 - -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 -Isrc/platform/portduino -DRADIOLIB_EEPROM_UNSUPPORTED - -DPORTDUINO_LINUX_HARDWARE - -DHAS_UDP_MULTICAST=1 -lpthread - -lstdc++fs - -lbluetooth - -lgpiod -lyaml-cpp - -li2c -luv -std=gnu17 -std=gnu++17 + +build_flags = + ${portduino_base.build_flags_common} + -DHAS_UDP_MULTICAST=1 + -D_FORTIFY_SOURCE=2 + -fstack-protector-all -Wstack-protector --param ssp-buffer-size=4 + -DPORTDUINO_LINUX_HARDWARE + -lstdc++fs + -lbluetooth + -lgpiod + -li2c lib_ignore = Adafruit NeoPixel Adafruit ST7735 and ST7789 Library diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 045e3edea..c497d0c17 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -117,3 +117,59 @@ build_flags = -lgcov --coverage -fprofile-abs-path -fsanitize=address ${env:nati test_testing_command = ${platformio.build_dir}/${this.__env__}/meshtasticd -s + +; --------------------------------------------------------------------------- +; Native build for macOS (Darwin / arm64 + x86_64). Headless meshtasticd that +; runs in SimRadio mode (`-s`) or against real LoRa hardware via a CH341 +; USB-SPI bridge. No BlueZ, libgpiod, or Linux I2C — those require Linux. +; +; Prerequisites (Homebrew): +; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; +; The macOS-side patches now live upstream: +; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, +; empty-variant-dir guard. Pulled via `portduino_base.platform` zip pin. +; * meshtastic/framework-portduino — LinuxHardwareI2C macOS stubs, AsyncUDP +; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C +; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. +; This env therefore only carries the firmware-side build flags and src filter. +; --------------------------------------------------------------------------- +[env:native-macos] +extends = native_base +; Apple's ld doesn't accept GNU ld's `-Wl,-Map,` syntax (inherited from +; the top-level platformio.ini). Strip it; the linker map isn't useful for +; the macOS dev loop anyway, and Apple ld's equivalent (`-Wl,-map,`) +; uses different argument shape. +build_unflags = -Wl,-Map,"${platformio.build_dir}"/output.map +build_flags = ${portduino_base.build_flags_common} + -I variants/native/portduino + -I/opt/homebrew/include + -I/opt/homebrew/opt/argp-standalone/include + -I/opt/homebrew/opt/yaml-cpp/include + -L/opt/homebrew/lib + -L/opt/homebrew/opt/argp-standalone/lib + -L/opt/homebrew/opt/yaml-cpp/lib + -largp + -DPORTDUINO_DARWIN + ; Headless build — variants/native/portduino/variant.h would otherwise + ; default HAS_SCREEN to 1 and pull in screen-renderer source that uses + ; VLA-with-initializer (a GNU/GCC extension Apple Clang rejects). + ; MESHTASTIC_EXCLUDE_SCREEN gates the optional `screen->setHeading(...)`- + ; style screen-driver hooks scattered through sensor sources. + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 + !pkg-config --libs openssl --silence-errors || : +; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist +; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX +; (which we lib_ignore on macOS for the issue). Neither is needed +; for the headless build. +build_src_filter = ${native_base.build_src_filter} + - + - + - + - +; LovyanGFX includes (Linux-only) and is only needed by TFT +; variants — not relevant for the headless macOS build. +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX From 4234fe6f8649b0f64fb29ebd7ee8da21fc58ddea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:50:01 -0500 Subject: [PATCH 14/52] Update meshtastic/device-ui digest to 7289329 (#10313) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ben Meadors --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 102c93a31..3f8f77228 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/56e1da4e7d30abcd746a2092a30e422f8cf5fc2b.zip + https://github.com/meshtastic/device-ui/archive/728932970996ec91bdb93cb6dae29c2cb70c66e2.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 048e5187baa717341de22eac6b5f1c107cf8f42c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:50:13 -0500 Subject: [PATCH 15/52] Update platform-native digest to 4ea5e09 (#10314) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From f037ce216535d30d8886a6fd52274b92e8d8309c Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:25:19 +0800 Subject: [PATCH 16/52] add heltec-v4-r8 board (#10268) * add heltec-v4-r8 board * Fixed default SPI pin and macro definition errors. * update platformio.ini according device-ui LGFX display definitions Co-authored-by: Copilot * fix commit reference --------- Co-authored-by: Ben Meadors Co-authored-by: mverch67 Co-authored-by: Copilot Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- boards/heltec_v4_r8.json | 43 ++++++ src/configuration.h | 3 + src/graphics/TFTDisplay.cpp | 19 ++- src/mesh/NodeDB.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- variants/esp32s3/heltec_v4_r8/pins_arduino.h | 56 +++++++ variants/esp32s3/heltec_v4_r8/platformio.ini | 145 +++++++++++++++++++ variants/esp32s3/heltec_v4_r8/variant.h | 72 +++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 boards/heltec_v4_r8.json create mode 100644 variants/esp32s3/heltec_v4_r8/pins_arduino.h create mode 100644 variants/esp32s3/heltec_v4_r8/platformio.ini create mode 100644 variants/esp32s3/heltec_v4_r8/variant.h diff --git a/boards/heltec_v4_r8.json b/boards/heltec_v4_r8.json new file mode 100644 index 000000000..6dd97c84b --- /dev/null +++ b/boards/heltec_v4_r8.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_v4_r8" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} diff --git a/src/configuration.h b/src/configuration.h index efd9ddcf7..e0284e6c9 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -162,6 +162,9 @@ along with this program. If not, see . #elif defined(HELTEC_MESH_NODE_T096) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7 +#elif defined(HELTEC_V4_R8) +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7 #else // If a board enables USE_KCT8103L_PA but does not match a known variant and has // not already provided a PA curve, fail at compile time to avoid unsafe defaults. diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 7df0c57cc..a28924ba6 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -422,7 +422,7 @@ static LGFX *tft = nullptr; #elif defined(ST7789_CS) #include // Graphics and font library for ST7735 driver chip -#ifdef HELTEC_V4_TFT +#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) #include "chsc6x.h" #include "lgfx/v1/Touch.hpp" namespace lgfx @@ -444,7 +444,11 @@ class TOUCH_CHSC6X : public ITouch bool init(void) override { if (chsc6xTouch == nullptr) { +#if (TOUCH_I2C_PORT == 1) chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#else + chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#endif } chsc6xTouch->chsc6x_init(); return true; @@ -481,7 +485,7 @@ class LGFX : public lgfx::LGFX_Device #if HAS_TOUCHSCREEN #if defined(T_WATCH_S3) || defined(ELECROW) lgfx::Touch_FT5x06 _touch_instance; -#elif defined(HELTEC_V4_TFT) +#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) lgfx::TOUCH_CHSC6X _touch_instance; #else lgfx::Touch_GT911 _touch_instance; @@ -500,7 +504,11 @@ class LGFX : public lgfx::LGFX_Device cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing // 80MHz by an integer) cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving - cfg.spi_3wire = false; +#ifdef SPI_3_WIRE + cfg.spi_3wire = SPI_3_WIRE; +#else + cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin +#endif cfg.use_lock = true; // Set to true to use transaction locking cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / // SPI_DMA_CH_AUTO=auto setting) @@ -550,8 +558,11 @@ class LGFX : public lgfx::LGFX_Device cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI +#if defined(HAS_SDCARD) cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) - +#else + cfg.bus_shared = false; +#endif // Set the following only when the display is shifted with a driver with a variable number of pixels, such as the // ST7735 or ILI9163. // cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 6e57e89f6..0193585e2 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -688,7 +688,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32); #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ - defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT)) && \ + defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index d6634aa74..ca81ab435 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -89,8 +89,10 @@ build_flags = -D VIEW_240x320 -D DISPLAY_SET_RESOLUTION -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=true -D LGFX_PIN_SCK=17 -D LGFX_PIN_MOSI=33 + -D LGFX_PIN_MISO=-1 -D LGFX_PIN_DC=16 -D LGFX_PIN_CS=15 -D LGFX_PIN_BL=21 @@ -123,7 +125,7 @@ build_flags = -D SCREEN_TRANSITION_FRAMERATE=30 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 - -D TOUCH_I2C_PORT=0 + -D TOUCH_I2C_PORT=1 -D TOUCH_SLAVE_ADDRESS=0x2E -D SCREEN_TOUCH_INT=TOUCH_INT_PIN -D SCREEN_TOUCH_RST=TOUCH_RST_PIN diff --git a/variants/esp32s3/heltec_v4_r8/pins_arduino.h b/variants/esp32s3/heltec_v4_r8/pins_arduino.h new file mode 100644 index 000000000..631f07513 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini new file mode 100644 index 000000000..747cc8c49 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -0,0 +1,145 @@ +[heltec_v4_r8_base] +extends = esp32s3_base +board = heltec_v4_r8 +board_check = true +board_build.partitions = default_16MB.csv +build_flags = + ${esp32s3_base.build_flags} + -D HELTEC_V4_R8 + -D HAS_LORA_FEM=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/heltec_v4_r8 + -ULED_BUILTIN + +[env:heltec-v4-r8-oled] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 +custom_meshtastic_images = heltec_v4_r8.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} + -D HELTEC_V4_R8_OLED + -D USE_SSD1306 ; Heltec_v4_R8 has an SSD1315 display (compatible with SSD1306 driver) + -D LED_POWER=46 + -D RESET_OLED=21 + -D I2C_SDA=17 + -D I2C_SCL=18 + +[env:heltec-v4-r8-tft] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 TFT +custom_meshtastic_images = heltec_v4_r8_tft.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} ;-Os + -D HELTEC_V4_R8_TFT + -D I2C_SDA=17 + -D I2C_SCL=18 + -D PIN_BUTTON2=46 + -D ALT_BUTTON_PIN=PIN_BUTTON2 + -D ALT_BUTTON_ACTIVE_LOW=false + -D PIN_BUZZER=4 + -D USE_PIN_BUZZER=PIN_BUZZER + -D CONFIG_ARDUHAL_LOG_COLORS + -D RADIOLIB_DEBUG_SPI=0 + -D RADIOLIB_DEBUG_PROTOCOL=0 + -D RADIOLIB_DEBUG_BASIC=0 + -D RADIOLIB_VERBOSE_ASSERT=0 + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D RAM_SIZE=5120 + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D USE_PACKET_API + -D LGFX_DRIVER=LGFX_HELTEC_V4_TFT + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_HELTEC_V4_TFT.h\" + -D VIEW_240x320 + -D DISPLAY_SET_RESOLUTION + -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=false + -D LGFX_PIN_SCK=16 + -D LGFX_PIN_MOSI=15 + -D LGFX_PIN_MISO=45 + -D LGFX_PIN_DC=48 + -D LGFX_PIN_CS=47 + -D LGFX_PIN_BL=44 + -D LGFX_PIN_RST=21 + -D CUSTOM_TOUCH_DRIVER + -D TOUCH_SDA_PIN=I2C_SDA + -D TOUCH_SCL_PIN=I2C_SCL + -D TOUCH_INT_PIN=-1 + -D TOUCH_RST_PIN=-1 +;base UI + -D TFT_CS=LGFX_PIN_CS + -D ST7789_CS=TFT_CS + -D ST7789_RS=LGFX_PIN_DC + -D ST7789_SDA=LGFX_PIN_MOSI + -D ST7789_SCK=LGFX_PIN_SCK + -D ST7789_RESET=LGFX_PIN_RST + -D ST7789_MISO=LGFX_PIN_MISO + -D ST7789_BUSY=-1 + -D ST7789_BL=LGFX_PIN_BL + -D ST7789_SPI_HOST=SPI3_HOST + -D TFT_BL=ST7789_BL + -D SPI_FREQUENCY=75000000 + -D SPI_READ_FREQUENCY=SPI_FREQUENCY + -D SPI_3_WIRE=false + -D TFT_HEIGHT=320 + -D TFT_WIDTH=240 + -D TFT_OFFSET_X=0 + -D TFT_OFFSET_Y=0 + -D TFT_OFFSET_ROTATION=0 + -D SCREEN_ROTATE + -D SCREEN_TRANSITION_FRAMERATE=5 + -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness + -D HAS_TOUCHSCREEN=1 + -D TOUCH_I2C_PORT=0 + -D TOUCH_SLAVE_ADDRESS=0x2E + -D SCREEN_TOUCH_INT=TOUCH_INT_PIN + -D SCREEN_TOUCH_RST=TOUCH_RST_PIN + +; Have SPI interface SD card slot + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SDCARD_USER_SPI_BEGIN + -D SPI_MOSI=LGFX_PIN_MOSI + -D SPI_SCK=LGFX_PIN_SCK + -D SPI_MISO=LGFX_PIN_MISO + -D SPI_CS=3 + -D SDCARD_CS=SPI_CS + -D SD_SPI_FREQUENCY=SPI_FREQUENCY + +lib_deps = ${heltec_v4_r8_base.lib_deps} + ${device-ui_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.19 + # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master + https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h new file mode 100644 index 000000000..1f638f24c --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -0,0 +1,72 @@ +#define VEXT_ENABLE 40 // active low, powers the oled display and the lora antenna boost +#define VEXT_ON_VALUE LOW +#define BUTTON_PIN 0 + +#define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage +#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider +#define ADC_MULTIPLIER 4.9 * 1.035 + +#define USE_SX1262 +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 12 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 13 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TCXO is enabled + +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA +// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna +// Control logic (from KCT8103L datasheet): +// Transmit PA: CSD=1, CTX=1, CPS=1 +// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF) +// Receive bypass: CSD=1, CTX=1, CPS=0 +// Shutdown: CSD=0, CTX=X, CPS=X +// Pin mapping: +// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH) +// CSD (pin 4) -> GPIO2: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO5: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO7 +// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH) + +#define USE_KCT8103L_PA +#define LORA_PA_POWER 7 // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD 2 // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX 5 // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +#if HAS_TFT +#define USE_TFTDISPLAY 1 +#endif +/* + * GPS pins + */ +#define GPS_L76K +#define PIN_GPS_EN (42) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (41) +// Seems to be missing on this new board +#define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS +#define GPS_THREAD_INTERVAL 50 From d7db0f5829876e22d7fcf0640566d0f40d87ec24 Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:25:19 +0800 Subject: [PATCH 17/52] add heltec-v4-r8 board (#10268) * add heltec-v4-r8 board * Fixed default SPI pin and macro definition errors. * update platformio.ini according device-ui LGFX display definitions Co-authored-by: Copilot * fix commit reference --------- Co-authored-by: Ben Meadors Co-authored-by: mverch67 Co-authored-by: Copilot Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- boards/heltec_v4_r8.json | 43 ++++++ src/configuration.h | 3 + src/graphics/TFTDisplay.cpp | 19 ++- src/mesh/NodeDB.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- variants/esp32s3/heltec_v4_r8/pins_arduino.h | 56 +++++++ variants/esp32s3/heltec_v4_r8/platformio.ini | 145 +++++++++++++++++++ variants/esp32s3/heltec_v4_r8/variant.h | 72 +++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 boards/heltec_v4_r8.json create mode 100644 variants/esp32s3/heltec_v4_r8/pins_arduino.h create mode 100644 variants/esp32s3/heltec_v4_r8/platformio.ini create mode 100644 variants/esp32s3/heltec_v4_r8/variant.h diff --git a/boards/heltec_v4_r8.json b/boards/heltec_v4_r8.json new file mode 100644 index 000000000..6dd97c84b --- /dev/null +++ b/boards/heltec_v4_r8.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_v4_r8" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} diff --git a/src/configuration.h b/src/configuration.h index 0ce28ed28..2c084174d 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -157,6 +157,9 @@ along with this program. If not, see . #elif defined(HELTEC_MESH_NODE_T096) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7 +#elif defined(HELTEC_V4_R8) +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7 #else // If a board enables USE_KCT8103L_PA but does not match a known variant and has // not already provided a PA curve, fail at compile time to avoid unsafe defaults. diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 005ead292..b69d79483 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -428,7 +428,7 @@ static LGFX *tft = nullptr; #elif defined(ST7789_CS) #include // Graphics and font library for ST7735 driver chip -#ifdef HELTEC_V4_TFT +#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) #include "chsc6x.h" #include "lgfx/v1/Touch.hpp" namespace lgfx @@ -450,7 +450,11 @@ class TOUCH_CHSC6X : public ITouch bool init(void) override { if (chsc6xTouch == nullptr) { +#if (TOUCH_I2C_PORT == 1) chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#else + chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#endif } chsc6xTouch->chsc6x_init(); return true; @@ -487,7 +491,7 @@ class LGFX : public lgfx::LGFX_Device #if HAS_TOUCHSCREEN #if defined(T_WATCH_S3) || defined(ELECROW) lgfx::Touch_FT5x06 _touch_instance; -#elif defined(HELTEC_V4_TFT) +#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) lgfx::TOUCH_CHSC6X _touch_instance; #else lgfx::Touch_GT911 _touch_instance; @@ -506,7 +510,11 @@ class LGFX : public lgfx::LGFX_Device cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing // 80MHz by an integer) cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving - cfg.spi_3wire = false; +#ifdef SPI_3_WIRE + cfg.spi_3wire = SPI_3_WIRE; +#else + cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin +#endif cfg.use_lock = true; // Set to true to use transaction locking cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / // SPI_DMA_CH_AUTO=auto setting) @@ -556,8 +564,11 @@ class LGFX : public lgfx::LGFX_Device cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI +#if defined(HAS_SDCARD) cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) - +#else + cfg.bus_shared = false; +#endif // Set the following only when the display is shifted with a driver with a variable number of pixels, such as the // ST7735 or ILI9163. // cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 33500830d..4b79882bd 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -688,7 +688,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32); #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ - defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT)) && \ + defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5a5004a45..103cac941 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -89,8 +89,10 @@ build_flags = -D VIEW_240x320 -D DISPLAY_SET_RESOLUTION -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=true -D LGFX_PIN_SCK=17 -D LGFX_PIN_MOSI=33 + -D LGFX_PIN_MISO=-1 -D LGFX_PIN_DC=16 -D LGFX_PIN_CS=15 -D LGFX_PIN_BL=21 @@ -123,7 +125,7 @@ build_flags = -D SCREEN_TRANSITION_FRAMERATE=5 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 - -D TOUCH_I2C_PORT=0 + -D TOUCH_I2C_PORT=1 -D TOUCH_SLAVE_ADDRESS=0x2E -D SCREEN_TOUCH_INT=TOUCH_INT_PIN -D SCREEN_TOUCH_RST=TOUCH_RST_PIN diff --git a/variants/esp32s3/heltec_v4_r8/pins_arduino.h b/variants/esp32s3/heltec_v4_r8/pins_arduino.h new file mode 100644 index 000000000..631f07513 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini new file mode 100644 index 000000000..747cc8c49 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -0,0 +1,145 @@ +[heltec_v4_r8_base] +extends = esp32s3_base +board = heltec_v4_r8 +board_check = true +board_build.partitions = default_16MB.csv +build_flags = + ${esp32s3_base.build_flags} + -D HELTEC_V4_R8 + -D HAS_LORA_FEM=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/heltec_v4_r8 + -ULED_BUILTIN + +[env:heltec-v4-r8-oled] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 +custom_meshtastic_images = heltec_v4_r8.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} + -D HELTEC_V4_R8_OLED + -D USE_SSD1306 ; Heltec_v4_R8 has an SSD1315 display (compatible with SSD1306 driver) + -D LED_POWER=46 + -D RESET_OLED=21 + -D I2C_SDA=17 + -D I2C_SCL=18 + +[env:heltec-v4-r8-tft] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 TFT +custom_meshtastic_images = heltec_v4_r8_tft.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} ;-Os + -D HELTEC_V4_R8_TFT + -D I2C_SDA=17 + -D I2C_SCL=18 + -D PIN_BUTTON2=46 + -D ALT_BUTTON_PIN=PIN_BUTTON2 + -D ALT_BUTTON_ACTIVE_LOW=false + -D PIN_BUZZER=4 + -D USE_PIN_BUZZER=PIN_BUZZER + -D CONFIG_ARDUHAL_LOG_COLORS + -D RADIOLIB_DEBUG_SPI=0 + -D RADIOLIB_DEBUG_PROTOCOL=0 + -D RADIOLIB_DEBUG_BASIC=0 + -D RADIOLIB_VERBOSE_ASSERT=0 + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D RAM_SIZE=5120 + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D USE_PACKET_API + -D LGFX_DRIVER=LGFX_HELTEC_V4_TFT + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_HELTEC_V4_TFT.h\" + -D VIEW_240x320 + -D DISPLAY_SET_RESOLUTION + -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=false + -D LGFX_PIN_SCK=16 + -D LGFX_PIN_MOSI=15 + -D LGFX_PIN_MISO=45 + -D LGFX_PIN_DC=48 + -D LGFX_PIN_CS=47 + -D LGFX_PIN_BL=44 + -D LGFX_PIN_RST=21 + -D CUSTOM_TOUCH_DRIVER + -D TOUCH_SDA_PIN=I2C_SDA + -D TOUCH_SCL_PIN=I2C_SCL + -D TOUCH_INT_PIN=-1 + -D TOUCH_RST_PIN=-1 +;base UI + -D TFT_CS=LGFX_PIN_CS + -D ST7789_CS=TFT_CS + -D ST7789_RS=LGFX_PIN_DC + -D ST7789_SDA=LGFX_PIN_MOSI + -D ST7789_SCK=LGFX_PIN_SCK + -D ST7789_RESET=LGFX_PIN_RST + -D ST7789_MISO=LGFX_PIN_MISO + -D ST7789_BUSY=-1 + -D ST7789_BL=LGFX_PIN_BL + -D ST7789_SPI_HOST=SPI3_HOST + -D TFT_BL=ST7789_BL + -D SPI_FREQUENCY=75000000 + -D SPI_READ_FREQUENCY=SPI_FREQUENCY + -D SPI_3_WIRE=false + -D TFT_HEIGHT=320 + -D TFT_WIDTH=240 + -D TFT_OFFSET_X=0 + -D TFT_OFFSET_Y=0 + -D TFT_OFFSET_ROTATION=0 + -D SCREEN_ROTATE + -D SCREEN_TRANSITION_FRAMERATE=5 + -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness + -D HAS_TOUCHSCREEN=1 + -D TOUCH_I2C_PORT=0 + -D TOUCH_SLAVE_ADDRESS=0x2E + -D SCREEN_TOUCH_INT=TOUCH_INT_PIN + -D SCREEN_TOUCH_RST=TOUCH_RST_PIN + +; Have SPI interface SD card slot + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SDCARD_USER_SPI_BEGIN + -D SPI_MOSI=LGFX_PIN_MOSI + -D SPI_SCK=LGFX_PIN_SCK + -D SPI_MISO=LGFX_PIN_MISO + -D SPI_CS=3 + -D SDCARD_CS=SPI_CS + -D SD_SPI_FREQUENCY=SPI_FREQUENCY + +lib_deps = ${heltec_v4_r8_base.lib_deps} + ${device-ui_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.19 + # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master + https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h new file mode 100644 index 000000000..1f638f24c --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -0,0 +1,72 @@ +#define VEXT_ENABLE 40 // active low, powers the oled display and the lora antenna boost +#define VEXT_ON_VALUE LOW +#define BUTTON_PIN 0 + +#define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage +#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider +#define ADC_MULTIPLIER 4.9 * 1.035 + +#define USE_SX1262 +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 12 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 13 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TCXO is enabled + +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA +// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna +// Control logic (from KCT8103L datasheet): +// Transmit PA: CSD=1, CTX=1, CPS=1 +// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF) +// Receive bypass: CSD=1, CTX=1, CPS=0 +// Shutdown: CSD=0, CTX=X, CPS=X +// Pin mapping: +// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH) +// CSD (pin 4) -> GPIO2: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO5: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO7 +// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH) + +#define USE_KCT8103L_PA +#define LORA_PA_POWER 7 // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD 2 // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX 5 // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +#if HAS_TFT +#define USE_TFTDISPLAY 1 +#endif +/* + * GPS pins + */ +#define GPS_L76K +#define PIN_GPS_EN (42) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (41) +// Seems to be missing on this new board +#define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS +#define GPS_THREAD_INTERVAL 50 From c0425d74448d06019f0d4bfc0a27cedeeb2f5a84 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 27 Apr 2026 14:33:19 -0400 Subject: [PATCH 18/52] Actions: Build MacOS binary (#10319) Preliminary CI for the MacOS builds Co-authored-by: Copilot --- .github/workflows/build_macos_bin.yml | 51 +++++++++++++++++++++++++++ .github/workflows/main_matrix.yml | 15 ++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/build_macos_bin.yml diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml new file mode 100644 index 000000000..cde2dd816 --- /dev/null +++ b/.github/workflows/build_macos_bin.yml @@ -0,0 +1,51 @@ +name: Build MacOS Binary + +on: + workflow_call: + inputs: + macos_ver: + required: false + default: "26" # ARM64 + type: string + +permissions: + contents: read + +jobs: + build-MacOS: + runs-on: macos-${{ inputs.macos_ver }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install deps + shell: bash + run: | + brew update + brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config + + - name: Get release version string + run: | + echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + id: version + + - name: Build for MacOS + run: | + platformio run -e native-macos + env: + PKG_VERSION: ${{ steps.version.outputs.long }} + # Errors in this step should not fail the entire workflow while MacOS support is in development. + continue-on-error: true + + - name: List output files + run: ls -lah .pio/build/native-macos/ + + - name: Store binaries as an artifact + uses: actions/upload-artifact@v7 + with: + name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }} + overwrite: true + path: | + .pio/build/native-macos/meshtasticd diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 88395600a..3505d950e 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -116,6 +116,20 @@ jobs: build_location: local secrets: inherit + MacOS: + strategy: + fail-fast: false + matrix: + macos_ver: + - "26" # ARM64 + # - '26-intel' # x86_64 + - "15" # ARM64 + # - '15-intel' # x86_64 + uses: ./.github/workflows/build_macos_bin.yml + with: + macos_ver: ${{ matrix.macos_ver }} + # secrets: inherit + package-pio-deps-native-tft: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} uses: ./.github/workflows/package_pio_deps.yml @@ -286,6 +300,7 @@ jobs: - gather-artifacts - build-debian-src - package-pio-deps-native-tft + # - MacOS steps: - name: Checkout uses: actions/checkout@v6 From 6c7ffa105470b0b6392e93f0a5ad42dcf402691b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 08:31:08 -0500 Subject: [PATCH 19/52] macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86, PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing `pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's bundled CH34x driver only matches the CH340 *UART* variant (PID 0x7523), so the CH341A's interface 0 is left unclaimed and libusb opens / configures / claims it directly via IOUSBHostInterface. End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow. Two changes: 1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of the literal `8` to `Ch341Hal::getSerialString()`. The function in `USBHal.h:61-68` treats `len` as buffer size and reserves one slot for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`), so passing 8 produced a 7-char serial — which then broke the `strlen(serial) == 8` check at line 502, skipping the auto-MAC derivation from serial + product string. On Linux this was masked by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157, but on macOS that fallback is `__linux__`-guarded so the serial path is mandatory and the truncation left `mac_address` empty, causing the daemon to exit with `*** Blank MAC Address not allowed!`. 2. **`variants/native/portduino/platformio.ini`**: expand the `[env:native-macos]` comment block with a "Real LoRa hardware on macOS" section. Documents: - Why no upstream library change is needed (Apple kext targets CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is correct for macOS in this case). - How to point `meshtasticd` at an existing platform-agnostic `bin/config.d/lora-*.yaml` for CH341 hardware. - The auto-MAC-derivation contract (now working with this fix). - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure mode where a third-party WCH `CH34xVCPDriver` *would* claim interface 0 (`kmutil unload -b ` workaround). No upstream library forks, no PR chain, no additional lib_deps — the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does the right thing on macOS already. Co-Authored-By: Claude Opus 4.7 (1M context) * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/platform/portduino/PortduinoGlue.cpp | 11 ++++++-- variants/native/portduino/platformio.ini | 33 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index fd26926d9..0f5b10a07 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -494,10 +494,17 @@ void portduinoSetup() exit(EXIT_FAILURE); } char serial[9] = {0}; - ch341Hal->getSerialString(serial, 8); + // Pass the full buffer size (9 = 8 chars + null) to getSerialString, + // not 8. The function treats `len` as buffer size and reserves one + // slot for the null terminator, so passing 8 produced a 7-char serial + // and broke the `strlen(serial) == 8` check below — masked on Linux + // by the BlueZ HCI MAC fallback in getMacAddr(), but on macOS (where + // the BlueZ path is __linux__-guarded) it left mac_address empty and + // meshtasticd refused to start. + ch341Hal->getSerialString(serial, sizeof(serial)); std::cout << "CH341 Serial " << serial << std::endl; char product_string[96] = {0}; - ch341Hal->getProductString(product_string, 95); + ch341Hal->getProductString(product_string, sizeof(product_string)); std::cout << "CH341 Product " << product_string << std::endl; if (strlen(serial) == 8 && portduino_config.mac_address.length() < 12) { std::cout << "Deriving MAC address from Serial and Product String" << std::endl; diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index c497d0c17..e493da77b 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -133,6 +133,39 @@ test_testing_command = ; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C ; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. ; This env therefore only carries the firmware-side build flags and src filter. +; +; Real LoRa hardware on macOS: +; The same lib_dep `pine64/libch341-spi-userspace` used on Linux works on +; macOS as-is — its `libusb_detach_kernel_driver()` call is `__linux__`- +; guarded, but on macOS the kernel doesn't bind a driver to a CH341A SPI +; bridge (PID 0x5512; bDeviceClass=0xff vendor-specific) by default, so +; no detach is needed. Apple's bundled CH34x driver targets the CH340 +; *UART* variant (PID 0x7523) — different product. libusb opens the device +; and claims interface 0 directly via IOUSBHostInterface. +; +; To use, point `meshtasticd` at any of the existing `bin/config.d/lora-*.yaml` +; files that specify `spidev: ch341` — they're platform-agnostic. Example: +; pio run -e native-macos +; mkdir -p ~/.meshtasticd && cp bin/config-dist.yaml ~/.meshtasticd/config.yaml +; # Edit ~/.meshtasticd/config.yaml: ConfigDirectory: ./config.d/ +; mkdir ~/.meshtasticd/config.d && cp bin/config.d/lora-meshstick-1262.yaml ~/.meshtasticd/config.d/ +; cd ~/.meshtasticd && /path/to/firmware/.pio/build/native-macos/meshtasticd +; +; The MAC address auto-derives from the CH341's USB serial + product string +; (PortduinoGlue.cpp ~497-518); on Linux a BlueZ HCI socket is the fallback +; when that path isn't taken, but BlueZ is `__linux__`-guarded so the +; serial-derivation path is mandatory on macOS. Override with +; `MACAddress: AA:BB:CC:DD:EE:FF` in config.yaml's `General:` section if +; the device's serial isn't 8 hex chars. +; +; Diagnosing CH341 issues on macOS: +; ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512 +; Children should be `IOUSBHostInterface`. If a vendor driver class +; (e.g. `com.wch.CH34xVCPDriver` from a third-party WCH installer) +; claims interface 0, libusb will fail with LIBUSB_ERROR_BUSY. +; Workaround: `sudo kmutil unload -b `. +; LIBUSB_DEBUG=4 .pio/build/native-macos/meshtasticd +; Verbose libusb trace — useful when claim_interface fails. ; --------------------------------------------------------------------------- [env:native-macos] extends = native_base From 9c72767c0133361131f27ca039455a81ec8d4a1e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 08:31:08 -0500 Subject: [PATCH 20/52] macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86, PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing `pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's bundled CH34x driver only matches the CH340 *UART* variant (PID 0x7523), so the CH341A's interface 0 is left unclaimed and libusb opens / configures / claims it directly via IOUSBHostInterface. End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow. Two changes: 1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of the literal `8` to `Ch341Hal::getSerialString()`. The function in `USBHal.h:61-68` treats `len` as buffer size and reserves one slot for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`), so passing 8 produced a 7-char serial — which then broke the `strlen(serial) == 8` check at line 502, skipping the auto-MAC derivation from serial + product string. On Linux this was masked by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157, but on macOS that fallback is `__linux__`-guarded so the serial path is mandatory and the truncation left `mac_address` empty, causing the daemon to exit with `*** Blank MAC Address not allowed!`. 2. **`variants/native/portduino/platformio.ini`**: expand the `[env:native-macos]` comment block with a "Real LoRa hardware on macOS" section. Documents: - Why no upstream library change is needed (Apple kext targets CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is correct for macOS in this case). - How to point `meshtasticd` at an existing platform-agnostic `bin/config.d/lora-*.yaml` for CH341 hardware. - The auto-MAC-derivation contract (now working with this fix). - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure mode where a third-party WCH `CH34xVCPDriver` *would* claim interface 0 (`kmutil unload -b ` workaround). No upstream library forks, no PR chain, no additional lib_deps — the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does the right thing on macOS already. Co-Authored-By: Claude Opus 4.7 (1M context) * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/platform/portduino/PortduinoGlue.cpp | 11 ++++++-- variants/native/portduino/platformio.ini | 33 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 6f8077720..fbaa3c98c 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -490,10 +490,17 @@ void portduinoSetup() exit(EXIT_FAILURE); } char serial[9] = {0}; - ch341Hal->getSerialString(serial, 8); + // Pass the full buffer size (9 = 8 chars + null) to getSerialString, + // not 8. The function treats `len` as buffer size and reserves one + // slot for the null terminator, so passing 8 produced a 7-char serial + // and broke the `strlen(serial) == 8` check below — masked on Linux + // by the BlueZ HCI MAC fallback in getMacAddr(), but on macOS (where + // the BlueZ path is __linux__-guarded) it left mac_address empty and + // meshtasticd refused to start. + ch341Hal->getSerialString(serial, sizeof(serial)); std::cout << "CH341 Serial " << serial << std::endl; char product_string[96] = {0}; - ch341Hal->getProductString(product_string, 95); + ch341Hal->getProductString(product_string, sizeof(product_string)); std::cout << "CH341 Product " << product_string << std::endl; if (strlen(serial) == 8 && portduino_config.mac_address.length() < 12) { std::cout << "Deriving MAC address from Serial and Product String" << std::endl; diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index c497d0c17..e493da77b 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -133,6 +133,39 @@ test_testing_command = ; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C ; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. ; This env therefore only carries the firmware-side build flags and src filter. +; +; Real LoRa hardware on macOS: +; The same lib_dep `pine64/libch341-spi-userspace` used on Linux works on +; macOS as-is — its `libusb_detach_kernel_driver()` call is `__linux__`- +; guarded, but on macOS the kernel doesn't bind a driver to a CH341A SPI +; bridge (PID 0x5512; bDeviceClass=0xff vendor-specific) by default, so +; no detach is needed. Apple's bundled CH34x driver targets the CH340 +; *UART* variant (PID 0x7523) — different product. libusb opens the device +; and claims interface 0 directly via IOUSBHostInterface. +; +; To use, point `meshtasticd` at any of the existing `bin/config.d/lora-*.yaml` +; files that specify `spidev: ch341` — they're platform-agnostic. Example: +; pio run -e native-macos +; mkdir -p ~/.meshtasticd && cp bin/config-dist.yaml ~/.meshtasticd/config.yaml +; # Edit ~/.meshtasticd/config.yaml: ConfigDirectory: ./config.d/ +; mkdir ~/.meshtasticd/config.d && cp bin/config.d/lora-meshstick-1262.yaml ~/.meshtasticd/config.d/ +; cd ~/.meshtasticd && /path/to/firmware/.pio/build/native-macos/meshtasticd +; +; The MAC address auto-derives from the CH341's USB serial + product string +; (PortduinoGlue.cpp ~497-518); on Linux a BlueZ HCI socket is the fallback +; when that path isn't taken, but BlueZ is `__linux__`-guarded so the +; serial-derivation path is mandatory on macOS. Override with +; `MACAddress: AA:BB:CC:DD:EE:FF` in config.yaml's `General:` section if +; the device's serial isn't 8 hex chars. +; +; Diagnosing CH341 issues on macOS: +; ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512 +; Children should be `IOUSBHostInterface`. If a vendor driver class +; (e.g. `com.wch.CH34xVCPDriver` from a third-party WCH installer) +; claims interface 0, libusb will fail with LIBUSB_ERROR_BUSY. +; Workaround: `sudo kmutil unload -b `. +; LIBUSB_DEBUG=4 .pio/build/native-macos/meshtasticd +; Verbose libusb trace — useful when claim_interface fails. ; --------------------------------------------------------------------------- [env:native-macos] extends = native_base From 6d31915e57762177c42fd61115cf1223afb8275f Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Tue, 28 Apr 2026 21:46:01 +0200 Subject: [PATCH 21/52] use adc_channel_t in variant.h --- variants/esp32s3/heltec_v4_r8/variant.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h index 1f638f24c..d59f0ae2c 100644 --- a/variants/esp32s3/heltec_v4_r8/variant.h +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -3,7 +3,7 @@ #define BUTTON_PIN 0 #define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage -#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_CHANNEL ADC_CHANNEL_0 #define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider #define ADC_MULTIPLIER 4.9 * 1.035 From 8d8ff21e7c4b0735fe72cc537021516932651e8c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 19:03:50 -0500 Subject: [PATCH 22/52] Add clamping logic for milliseconds conversion and unit tests (#10326) * Add clamping logic for milliseconds conversion and unit tests * Simplify comments in secondsToMsClamped function Removed detailed comments about seconds to milliseconds conversion. --- src/mesh/Default.cpp | 26 +++++++++----- test/test_default/test_main.cpp | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index 3ecd766f1..7a2d9e410 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -2,18 +2,21 @@ #include "meshUtils.h" +// Convert seconds to ms, clamping at INT32_MAX (~24.86 days) +static inline uint32_t secondsToMsClamped(uint32_t secs) +{ + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + return (secs > MAX_MS / 1000U) ? MAX_MS : secs * 1000U; +} + uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval) { - if (configuredInterval > 0) - return configuredInterval * 1000; - return defaultInterval * 1000; + return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : defaultInterval); } uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval) { - if (configuredInterval > 0) - return configuredInterval * 1000; - return default_broadcast_interval_secs * 1000; + return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : default_broadcast_interval_secs); } uint32_t Default::getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue) @@ -47,7 +50,14 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d meshtastic_Config_DeviceConfig_Role_TAK_TRACKER)) return getConfiguredOrDefaultMs(configured, defaultValue); - return getConfiguredOrDefaultMs(configured, defaultValue) * congestionScalingCoefficient(numOnlineNodes); + // Saturate at INT32_MAX to match secondsToMsClamped: float→uint32_t when + // out of range is UB, and the result is consumed as an int32_t downstream. + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + uint32_t base = getConfiguredOrDefaultMs(configured, defaultValue); + float coef = congestionScalingCoefficient(numOnlineNodes); + if (static_cast(base) * static_cast(coef) >= static_cast(MAX_MS)) + return MAX_MS; + return base * coef; } uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue) @@ -66,4 +76,4 @@ uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured) #else return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit; #endif -} \ No newline at end of file +} diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index 9da367897..4202d7b8d 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -127,6 +127,60 @@ void test_client_uses_public_channel_minimums() TEST_ASSERT_EQUAL_UINT32(60 * 60, position); } +// --- Saturation/clamp tests for getConfiguredOrDefaultMs[Scaled] --- +// These guard the INT32_MAX clamp added to avoid uint32 wrap of secs*1000 and +// to keep results safe to cast to int32_t for OSThread runOnce returns. + +void test_ms_below_threshold() +{ + // Ordinary value passes through unchanged. + TEST_ASSERT_EQUAL_UINT32(60000U, Default::getConfiguredOrDefaultMs(60, 0)); +} + +void test_ms_at_threshold() +{ + // INT32_MAX / 1000 = 2,147,483 — largest secs that does not clamp. + TEST_ASSERT_EQUAL_UINT32(2147483000U, Default::getConfiguredOrDefaultMs(2147483U, 0)); +} + +void test_ms_just_above_threshold() +{ + // One second over the boundary must saturate, not wrap. + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(2147484U, 0)); +} + +void test_ms_uint32_max() +{ + // default_sds_secs == UINT32_MAX on non-routers must not wrap. + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(UINT32_MAX, 0)); +} + +void test_ms_default_clamps() +{ + // Clamp also applies when the default-arg path is taken (configured == 0). + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(0, UINT32_MAX)); +} + +void test_ms_result_is_int32_safe() +{ + // Regression guard for runOnce returns: cast to int32_t must not go negative. + int32_t result = static_cast(Default::getConfiguredOrDefaultMs(UINT32_MAX, 0)); + TEST_ASSERT_GREATER_OR_EQUAL_INT32(0, result); +} + +void test_scaled_overflow_saturates() +{ + // long_fast (SF11/BW250) with a 24h base and heavy congestion overflows + // the uint32 result without the double-precision guard. Must saturate. + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.use_preset = false; + config.lora.spread_factor = 11; + config.lora.bandwidth = 250; + + uint32_t res = Default::getConfiguredOrDefaultMsScaled(0, ONE_DAY, 1000); + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); +} + void setup() { // Small delay to match other test mains @@ -140,6 +194,13 @@ void setup() RUN_TEST(test_router_uses_router_minimums); RUN_TEST(test_router_late_uses_router_minimums); RUN_TEST(test_client_uses_public_channel_minimums); + RUN_TEST(test_ms_below_threshold); + RUN_TEST(test_ms_at_threshold); + RUN_TEST(test_ms_just_above_threshold); + RUN_TEST(test_ms_uint32_max); + RUN_TEST(test_ms_default_clamps); + RUN_TEST(test_ms_result_is_int32_safe); + RUN_TEST(test_scaled_overflow_saturates); exit(UNITY_END()); } From 11df30a85fd7860535c8bd45728849efeb649ceb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:04:16 -0500 Subject: [PATCH 23/52] Upgrade trunk (#10324) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 178a1cc9e..41bb110a9 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.525 - - renovate@43.142.0 + - renovate@43.147.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From c0e52e6e1c2a5e5aaa960de853f5f9f917084ec5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:10:17 -0500 Subject: [PATCH 24/52] Update meshtastic/device-ui digest to 1ddcc9d (#10328) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 3f8f77228..9348d97d9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/728932970996ec91bdb93cb6dae29c2cb70c66e2.zip + https://github.com/meshtastic/device-ui/archive/1ddcc9da2e60c013d6fc515fb73fb63fac75f9fd.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 22a9346fe03a8c049bf6a9a2377a8b39d6b63bf9 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 12:16:25 -0400 Subject: [PATCH 25/52] Debian: Correctly fail upon failure (#10341) Fake success is BS! We should fail when we fail. Fixes issues with Debian sourcedebs silently failing to build ocassionally (due to github 502s, etc). --- debian/ci_pack_sdeb.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/ci_pack_sdeb.sh b/debian/ci_pack_sdeb.sh index 7b2418ff6..d35aeef24 100755 --- a/debian/ci_pack_sdeb.sh +++ b/debian/ci_pack_sdeb.sh @@ -1,4 +1,5 @@ #!/usr/bin/bash +set -e export DEBEMAIL="jbennett@incomsystems.biz" export PLATFORMIO_LIBDEPS_DIR=pio/libdeps export PLATFORMIO_PACKAGES_DIR=pio/packages From 9ec63b5eb2c236457ce307c896e98f00837a82c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:55:48 -0500 Subject: [PATCH 26/52] Upgrade trunk (#10336) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 41bb110a9..77ee7ecc3 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.525 - - renovate@43.147.0 + - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From 7be5426f343e40272146086830a8686f0f144a58 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 14:00:01 -0400 Subject: [PATCH 27/52] Do not FACTORY_INSTALL on ARCH_PORTDUINO (#10343) --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4b79882bd..b526bc869 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1205,7 +1205,7 @@ void NodeDB::loadFromDisk() spiLock->unlock(); #endif #ifdef FSCom -#ifdef FACTORY_INSTALL +#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO) spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); From 089af764ec04f715fa3bc60d0fb6e6a30ed4fcb8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 16:41:21 -0500 Subject: [PATCH 28/52] Replace FSCom.format() with FSCom.rmDir() for directory cleanup in NodeDB::loadFromDisk() --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b526bc869..a0212794f 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1209,7 +1209,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); - FSCom.format(); + FSCom.rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); if (f2) { From 195f42af826634acf3120252c64e8255de03c7c4 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 16:57:21 -0500 Subject: [PATCH 29/52] Doesn't FSCom --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index a0212794f..6d13952e5 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1209,7 +1209,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); - FSCom.rmDir("/prefs"); + rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); if (f2) { From ad23c42fcc9531509f8cf674b738a3b7b193ec07 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:09:21 -0500 Subject: [PATCH 30/52] Update meshtastic/device-ui digest to 4bf593a (#10346) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 9348d97d9..71c362a79 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/1ddcc9da2e60c013d6fc515fb73fb63fac75f9fd.zip + https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 478444eb02836fd5c6ae0e5887840994c9679289 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 20:31:59 -0400 Subject: [PATCH 31/52] Docker-Alpine: Align version between build/main stages (#10347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FROM python:3.14-alpine3.23 AS builder FROM alpine:3.23 the alpine version needs to match in both stages 😅 --- alpine.Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 75c9aa594..40a4990bb 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -3,7 +3,8 @@ # trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions -FROM python:3.14-alpine3.22 AS builder +# Ensure the Alpine version is updated in both stages of the container! +FROM python:3.14-alpine3.23 AS builder ARG PIO_ENV=native ENV PIP_ROOT_USER_ACTION=ignore @@ -60,4 +61,4 @@ EXPOSE 4403 CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ] -HEALTHCHECK NONE \ No newline at end of file +HEALTHCHECK NONE From 3a87fc82c088b5ab4d06958af762bbd755676f17 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 19:54:05 -0500 Subject: [PATCH 32/52] Add documentation for macOS support in Copilot and Agent instructions --- .github/copilot-instructions.md | 4 +++- AGENTS.md | 24 +++++++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d7457102..29d5f6b00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,7 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po - **RP2040/RP2350** - Raspberry Pi Pico variants - **STM32WL** - STM32 with integrated LoRa - **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.) +- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup ### Supported Radio Chips @@ -369,7 +370,7 @@ To reduce avoidable agent mistakes, assume these tools are available (or install - **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs` - **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing - **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`) -- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts) +- **Containerized native testing**: `docker` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`) Fallback expectations for agents: @@ -388,6 +389,7 @@ Build commands: pio run -e tbeam # Build specific target pio run -e tbeam -t upload # Build and upload pio run -e native # Build native/Linux version +pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini) ``` ### Build Manifest diff --git a/AGENTS.md b/AGENTS.md index 8f3474640..ca6794322 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,17 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don ## Quick command reference -| Action | Command | -| -------------------------------- | ----------------------------------------------------------------------------------- | -| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | -| Clean + rebuild | `pio run -e -t clean && pio run -e ` | -| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | -| Run firmware unit tests (native) | `pio test -e native` | -| Run MCP hardware tests | `./mcp-server/run-tests.sh` | -| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | -| Format before commit | `trunk fmt` | -| Regenerate protobuf bindings | `bin/regen-protos.sh` | -| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | +| Action | Command | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | +| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) | +| Clean + rebuild | `pio run -e -t clean && pio run -e ` | +| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | +| Run firmware unit tests (native) | `pio test -e native` | +| Run MCP hardware tests | `./mcp-server/run-tests.sh` | +| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | +| Format before commit | `trunk fmt` | +| Regenerate protobuf bindings | `bin/regen-protos.sh` | +| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | ## MCP server (device + test automation) @@ -121,6 +122,7 @@ Sequence these; don't parallelize on the same port. - **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access. - **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. - **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. +- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b `. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit. ## Environment variables (test harness) From 24d64a0013b62abdac05c471f58ffec025c8396f Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 22:04:49 -0400 Subject: [PATCH 33/52] Docker: Build for riscv64 (#10345) Upstream support has been added in Debian and Alpine. Only build as part of `docker_manifest` (Beta/Alpha/Daily) releases, because these will take a **while** thanks to qemu. Co-authored-by: Copilot --- .github/workflows/docker_build.yml | 4 +++- .github/workflows/docker_manifest.yml | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index d9b23a7e8..8a3ef0e6c 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -73,7 +73,9 @@ jobs: - name: Sanitize platform string id: sanitize_platform # Replace slashes with underscores - run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT + env: + plat: ${{ inputs.platform }} + run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT - name: Docker login if: ${{ inputs.push }} diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml index b2fd12599..4bfdfe37e 100644 --- a/.github/workflows/docker_manifest.yml +++ b/.github/workflows/docker_manifest.yml @@ -43,6 +43,15 @@ jobs: push: true secrets: inherit + docker-debian-riscv64: + uses: ./.github/workflows/docker_build.yml + with: + distro: debian + platform: linux/riscv64 + runs-on: ubuntu-24.04-arm + push: true + secrets: inherit + docker-alpine-amd64: uses: ./.github/workflows/docker_build.yml with: @@ -70,16 +79,27 @@ jobs: push: true secrets: inherit + docker-alpine-riscv64: + uses: ./.github/workflows/docker_build.yml + with: + distro: alpine + platform: linux/riscv64 + runs-on: ubuntu-24.04-arm + push: true + secrets: inherit + docker-manifest: needs: # Debian - docker-debian-amd64 - docker-debian-arm64 - docker-debian-armv7 + - docker-debian-riscv64 # Alpine - docker-alpine-amd64 - docker-alpine-arm64 - docker-alpine-armv7 + - docker-alpine-riscv64 runs-on: ubuntu-24.04 steps: - name: Checkout code @@ -162,6 +182,7 @@ jobs: meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }} + meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }} - name: Docker meta (Alpine) id: meta_alpine @@ -182,3 +203,4 @@ jobs: meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }} + meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }} From e19f531059952e7e9e0bc5e54a163abb384fac20 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:05:16 -0400 Subject: [PATCH 34/52] Update Screen.cpp (#10344) --- src/graphics/Screen.cpp | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index e8a7f685e..d02938df9 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -524,6 +524,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) delay(100); #endif #if !ARCH_PORTDUINO +#if defined(USE_ST7789) && defined(VTFT_CTRL) + // Ensure panel power rail is enabled before sending wake commands. + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, LOW); +#endif dispdev->displayOn(); #endif @@ -545,10 +550,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) ui->init(); #endif #if defined(USE_ST7789) && defined(VTFT_LEDA) -#ifdef VTFT_CTRL - pinMode(VTFT_CTRL, OUTPUT); - digitalWrite(VTFT_CTRL, LOW); -#endif ui->init(); #ifdef ESP_PLATFORM analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); @@ -589,23 +590,22 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) #endif #ifdef USE_ST7789 SPI1.end(); -#if defined(ARCH_ESP32) + // Keep TFT control pins in deterministic states while timed-off. + // Floating/default pin states can corrupt panel edge rows on wake. #ifdef VTFT_LEDA - pinMode(VTFT_LEDA, ANALOG); + pinMode(VTFT_LEDA, OUTPUT); + digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON); #endif #ifdef VTFT_CTRL - pinMode(VTFT_CTRL, ANALOG); -#endif - pinMode(ST7789_RESET, ANALOG); - pinMode(ST7789_RS, ANALOG); - pinMode(ST7789_NSS, ANALOG); -#else - nrf_gpio_cfg_default(VTFT_LEDA); - nrf_gpio_cfg_default(VTFT_CTRL); - nrf_gpio_cfg_default(ST7789_RESET); - nrf_gpio_cfg_default(ST7789_RS); - nrf_gpio_cfg_default(ST7789_NSS); + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, HIGH); #endif + pinMode(ST7789_RESET, OUTPUT); + digitalWrite(ST7789_RESET, HIGH); + pinMode(ST7789_RS, OUTPUT); + digitalWrite(ST7789_RS, HIGH); + pinMode(ST7789_NSS, OUTPUT); + digitalWrite(ST7789_NSS, HIGH); #endif #ifdef USE_ST7796 SPI1.end(); From a0951f23c3d5fa369d2bb0b5a64b8ed8f408470f Mon Sep 17 00:00:00 2001 From: Joe <85746415+WB3IHY@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:00:50 -0400 Subject: [PATCH 35/52] fix: MQTT connection on Portduino/Linux native nodes (#10330) isConnectedToNetwork() always returned false on ARCH_PORTDUINO because none of HAS_WIFI, HAS_ETHERNET, or USE_WS5500 are defined for Linux native builds. This caused wantsLink() to always return false, preventing the MQTT thread from ever connecting at boot. Fix: return true for ARCH_PORTDUINO since Linux always has network access available. Co-authored-by: Jonathan Bennett --- src/mqtt/MQTT.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 283fcffb1..97636b601 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -350,6 +350,8 @@ inline bool isConnectedToNetwork() return WiFi.isConnected(); #elif HAS_ETHERNET return Ethernet.linkStatus() == LinkON; +#elif defined(ARCH_PORTDUINO) + return true; #else return false; #endif From 83adfd417a080503afb092067c5004068a094072 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:39:52 -0500 Subject: [PATCH 36/52] Upgrade trunk (#10354) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 77ee7ecc3..1913c6604 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,7 +4,7 @@ cli: plugins: sources: - id: trunk - ref: v1.7.6 + ref: v1.8.0 uri: https://github.com/trunk-io/plugins lint: enabled: @@ -36,7 +36,7 @@ lint: - bin/** runtimes: enabled: - - python@3.10.8 + - python@3.14.4 - go@1.21.0 - node@22.16.0 actions: From 173ac58ed70ab7dc74fad11686a59246de79ef06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:45:20 -0500 Subject: [PATCH 37/52] Update protobufs (#10357) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/apponly.pb.h | 2 +- src/mesh/generated/meshtastic/config.pb.h | 12 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- .../generated/meshtastic/serial_hal.pb.cpp | 19 +++ src/mesh/generated/meshtastic/serial_hal.pb.h | 135 ++++++++++++++++++ 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 src/mesh/generated/meshtastic/serial_hal.pb.cpp create mode 100644 src/mesh/generated/meshtastic/serial_hal.pb.h diff --git a/protobufs b/protobufs index 249a80855..1d6f1a71f 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 249a80855a2adb76fb0904dac8bf6285d45f330f +Subproject commit 1d6f1a71ff329fa52ad8bb7899951e96f8280a1f diff --git a/src/mesh/generated/meshtastic/apponly.pb.h b/src/mesh/generated/meshtastic/apponly.pb.h index ce766878b..88cbcb5e6 100644 --- a/src/mesh/generated/meshtastic/apponly.pb.h +++ b/src/mesh/generated/meshtastic/apponly.pb.h @@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size -#define meshtastic_ChannelSet_size 682 +#define meshtastic_ChannelSet_size 685 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 0e14334d5..d614a6438 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -618,6 +618,8 @@ typedef struct _meshtastic_Config_LoRaConfig { bool config_ok_to_mqtt; /* Set where LORA FEM is enabled, disabled, or not present */ meshtastic_Config_LoRaConfig_FEM_LNA_Mode fem_lna_mode; + /* Don't use radiolib to initialize the radio, instead listen for a serialHal connection */ + bool serial_hal_only; } meshtastic_Config_LoRaConfig; typedef struct _meshtastic_Config_BluetoothConfig { @@ -779,7 +781,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_default {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_default, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_default {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN} +#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_default {0} @@ -790,7 +792,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_zero {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_zero, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_zero {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN} +#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_zero {0} @@ -877,6 +879,7 @@ extern "C" { #define meshtastic_Config_LoRaConfig_ignore_mqtt_tag 104 #define meshtastic_Config_LoRaConfig_config_ok_to_mqtt_tag 105 #define meshtastic_Config_LoRaConfig_fem_lna_mode_tag 106 +#define meshtastic_Config_LoRaConfig_serial_hal_only_tag 107 #define meshtastic_Config_BluetoothConfig_enabled_tag 1 #define meshtastic_Config_BluetoothConfig_mode_tag 2 #define meshtastic_Config_BluetoothConfig_fixed_pin_tag 3 @@ -1029,7 +1032,8 @@ X(a, STATIC, SINGULAR, BOOL, pa_fan_disabled, 15) \ X(a, STATIC, REPEATED, UINT32, ignore_incoming, 103) \ X(a, STATIC, SINGULAR, BOOL, ignore_mqtt, 104) \ X(a, STATIC, SINGULAR, BOOL, config_ok_to_mqtt, 105) \ -X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) +X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) \ +X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107) #define meshtastic_Config_LoRaConfig_CALLBACK NULL #define meshtastic_Config_LoRaConfig_DEFAULT NULL @@ -1086,7 +1090,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_BluetoothConfig_size 10 #define meshtastic_Config_DeviceConfig_size 100 #define meshtastic_Config_DisplayConfig_size 36 -#define meshtastic_Config_LoRaConfig_size 88 +#define meshtastic_Config_LoRaConfig_size 91 #define meshtastic_Config_NetworkConfig_IpV4Config_size 20 #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 1d6cd32f9..6d03dc643 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -361,7 +361,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2429 +#define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1737 #define meshtastic_NodeInfoLite_size 196 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 8425c122a..27f5ad7bf 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -205,7 +205,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 754 +#define meshtastic_LocalConfig_size 757 #define meshtastic_LocalModuleConfig_size 820 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/serial_hal.pb.cpp b/src/mesh/generated/meshtastic/serial_hal.pb.cpp new file mode 100644 index 000000000..183bc48f6 --- /dev/null +++ b/src/mesh/generated/meshtastic/serial_hal.pb.cpp @@ -0,0 +1,19 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/serial_hal.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_SerialHalCommand, meshtastic_SerialHalCommand, 2) + + +PB_BIND(meshtastic_SerialHalResponse, meshtastic_SerialHalResponse, 2) + + + + + + + diff --git a/src/mesh/generated/meshtastic/serial_hal.pb.h b/src/mesh/generated/meshtastic/serial_hal.pb.h new file mode 100644 index 000000000..5dfcdf1ca --- /dev/null +++ b/src/mesh/generated/meshtastic/serial_hal.pb.h @@ -0,0 +1,135 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED +#include + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Enum definitions */ +typedef enum _meshtastic_SerialHalCommand_Type { + meshtastic_SerialHalCommand_Type_UNSET = 0, + meshtastic_SerialHalCommand_Type_PIN_MODE = 1, + meshtastic_SerialHalCommand_Type_DIGITAL_WRITE = 2, + meshtastic_SerialHalCommand_Type_DIGITAL_READ = 3, + meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT = 4, + meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT = 5, + meshtastic_SerialHalCommand_Type_SPI_TRANSFER = 6, + meshtastic_SerialHalCommand_Type_NOOP = 7 +} meshtastic_SerialHalCommand_Type; + +typedef enum _meshtastic_SerialHalResponse_Result { + meshtastic_SerialHalResponse_Result_OK = 0, + meshtastic_SerialHalResponse_Result_ERROR = 1, + meshtastic_SerialHalResponse_Result_BAD_REQUEST = 2, + meshtastic_SerialHalResponse_Result_UNSUPPORTED = 3 +} meshtastic_SerialHalResponse_Result; + +/* Struct definitions */ +typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalCommand_data_t; +typedef struct _meshtastic_SerialHalCommand { + /* Host-assigned request id. Replies echo this id back in + SerialHalResponse.transaction_id. */ + uint32_t transaction_id; + meshtastic_SerialHalCommand_Type type; + uint32_t pin; + uint32_t value; + uint32_t mode; + meshtastic_SerialHalCommand_data_t data; +} meshtastic_SerialHalCommand; + +typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalResponse_data_t; +typedef struct _meshtastic_SerialHalResponse { + /* Matches the originating SerialHalCommand.transaction_id for normal + request/response traffic. + + A value of 0 indicates an unsolicited interrupt notification generated by + the device. In that case, the host should interpret value as the GPIO pin + that triggered. */ + uint32_t transaction_id; + meshtastic_SerialHalResponse_Result result; + /* Used by DIGITAL_READ replies and interrupt notifications. For interrupt + notifications (transaction_id == 0), this carries the pin number. */ + uint32_t value; + meshtastic_SerialHalResponse_data_t data; + char error[80]; +} meshtastic_SerialHalResponse; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Helper constants for enums */ +#define _meshtastic_SerialHalCommand_Type_MIN meshtastic_SerialHalCommand_Type_UNSET +#define _meshtastic_SerialHalCommand_Type_MAX meshtastic_SerialHalCommand_Type_NOOP +#define _meshtastic_SerialHalCommand_Type_ARRAYSIZE ((meshtastic_SerialHalCommand_Type)(meshtastic_SerialHalCommand_Type_NOOP+1)) + +#define _meshtastic_SerialHalResponse_Result_MIN meshtastic_SerialHalResponse_Result_OK +#define _meshtastic_SerialHalResponse_Result_MAX meshtastic_SerialHalResponse_Result_UNSUPPORTED +#define _meshtastic_SerialHalResponse_Result_ARRAYSIZE ((meshtastic_SerialHalResponse_Result)(meshtastic_SerialHalResponse_Result_UNSUPPORTED+1)) + +#define meshtastic_SerialHalCommand_type_ENUMTYPE meshtastic_SerialHalCommand_Type + +#define meshtastic_SerialHalResponse_result_ENUMTYPE meshtastic_SerialHalResponse_Result + + +/* Initializer values for message structs */ +#define meshtastic_SerialHalCommand_init_default {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}} +#define meshtastic_SerialHalResponse_init_default {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""} +#define meshtastic_SerialHalCommand_init_zero {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}} +#define meshtastic_SerialHalResponse_init_zero {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_SerialHalCommand_transaction_id_tag 1 +#define meshtastic_SerialHalCommand_type_tag 2 +#define meshtastic_SerialHalCommand_pin_tag 3 +#define meshtastic_SerialHalCommand_value_tag 4 +#define meshtastic_SerialHalCommand_mode_tag 5 +#define meshtastic_SerialHalCommand_data_tag 6 +#define meshtastic_SerialHalResponse_transaction_id_tag 1 +#define meshtastic_SerialHalResponse_result_tag 2 +#define meshtastic_SerialHalResponse_value_tag 3 +#define meshtastic_SerialHalResponse_data_tag 4 +#define meshtastic_SerialHalResponse_error_tag 5 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_SerialHalCommand_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, type, 2) \ +X(a, STATIC, SINGULAR, UINT32, pin, 3) \ +X(a, STATIC, SINGULAR, UINT32, value, 4) \ +X(a, STATIC, SINGULAR, UINT32, mode, 5) \ +X(a, STATIC, SINGULAR, BYTES, data, 6) +#define meshtastic_SerialHalCommand_CALLBACK NULL +#define meshtastic_SerialHalCommand_DEFAULT NULL + +#define meshtastic_SerialHalResponse_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, result, 2) \ +X(a, STATIC, SINGULAR, UINT32, value, 3) \ +X(a, STATIC, SINGULAR, BYTES, data, 4) \ +X(a, STATIC, SINGULAR, STRING, error, 5) +#define meshtastic_SerialHalResponse_CALLBACK NULL +#define meshtastic_SerialHalResponse_DEFAULT NULL + +extern const pb_msgdesc_t meshtastic_SerialHalCommand_msg; +extern const pb_msgdesc_t meshtastic_SerialHalResponse_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_SerialHalCommand_fields &meshtastic_SerialHalCommand_msg +#define meshtastic_SerialHalResponse_fields &meshtastic_SerialHalResponse_msg + +/* Maximum encoded size of messages (where known) */ +#define MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_MAX_SIZE meshtastic_SerialHalResponse_size +#define meshtastic_SerialHalCommand_size 541 +#define meshtastic_SerialHalResponse_size 610 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif From 21cef8c2e5d46c31022e3ecedba6a58d84e73045 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 30 Apr 2026 13:51:29 -0500 Subject: [PATCH 38/52] Add TCP support for Meshtastic MCP interface / tests and update docs (#10355) * Add TCP support for Meshtastic MCP interface / tests and update docs * Address TCP endpoint validation and error handling in connection * TCP connection handling and device listing logic * Fix docstring formatting in normalize_tcp_endpoint function --- .github/copilot-instructions.md | 2 + AGENTS.md | 27 +- mcp-server/README.md | 76 +++- mcp-server/src/meshtastic_mcp/connection.py | 169 +++++++- mcp-server/src/meshtastic_mcp/devices.py | 66 ++- mcp-server/src/meshtastic_mcp/flash.py | 19 +- mcp-server/src/meshtastic_mcp/hw_tools.py | 7 +- .../src/meshtastic_mcp/serial_session.py | 4 + mcp-server/tests/unit/test_connection_tcp.py | 383 ++++++++++++++++++ 9 files changed, 714 insertions(+), 39 deletions(-) create mode 100644 mcp-server/tests/unit/test_connection_tcp.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 29d5f6b00..fe9af4359 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -575,6 +575,8 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v `confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`. +**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes". + ### Hardware test suite (`mcp-server/run-tests.sh`) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. diff --git a/AGENTS.md b/AGENTS.md index ca6794322..cdccda1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,16 +126,17 @@ Sequence these; don't parallelize on the same port. ## Environment variables (test harness) -| Var | Purpose | -| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | -| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | -| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | -| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | -| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | -| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | -| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | -| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | -| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | -| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | -| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | +| Var | Purpose | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | +| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | +| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | +| `MESHTASTIC_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. | +| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | +| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | +| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | +| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | +| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | +| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | +| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | +| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | diff --git a/mcp-server/README.md b/mcp-server/README.md index 7a36a6fac..22ce77fbc 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -166,15 +166,73 @@ rather than auto-`sudo`'ing mid-run. ## Environment variables -| Var | Default | Purpose | -| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | -| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | -| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | -| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | -| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | -| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | -| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | -| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | +| Var | Default | Purpose | +| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | +| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | +| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | +| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | +| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | +| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | +| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | +| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) | + +## TCP / native-host nodes + +The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd` +binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino). +The daemon exposes the meshtastic TCP API on port `4403` rather than a USB +serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`: + +```bash +# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini +# for full Homebrew prereqs and CH341 LoRa-adapter setup). +pio run -e native-macos +~/.meshtasticd/meshtasticd + +# 2. Point the MCP server at it. +export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403 +``` + +**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the +USB adapter's serial-number / product strings. Many cheap CH341 dongles +(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0` +and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address +not allowed!`. Set the MAC explicitly in `config.yaml`: + +```yaml +# Under General: +MACAddress: 02:CA:FE:BA:BE:01 +``` + +Use a locally-administered address (first byte's second-LSB set, e.g. +`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI. + +There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in +`meshtasticd --help`, but it is **currently broken** in +`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the +function strips colons from its parameter but then reads bytes from the +global `portduino_config.mac_address`, so `--hwid` is silently overridden +when `MACAddress:` is also set, and crashes the daemon (uncaught +`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML +form until that's fixed upstream. + +`list_devices` will surface the daemon as `tcp://localhost:4403` with +`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`, +`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs +auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"` +explicitly to target a different daemon. + +**Tools that don't apply to a TCP/native node** (no USB hardware to operate +on) raise a clear `ConnectionError` rather than failing mysteriously: +`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, +`serial_open` (use info/admin tools directly), and the vendor escape hatches +`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env +similarly raises — there's no upload step; use `build` and run the binary +directly. + +The pytest harness in `tests/` still assumes USB-attached devices per role — +TCP-aware fixtures are not part of this surface yet. ## Hardware Test Suite diff --git a/mcp-server/src/meshtastic_mcp/connection.py b/mcp-server/src/meshtastic_mcp/connection.py index 17a7e2c89..7dbf847b9 100644 --- a/mcp-server/src/meshtastic_mcp/connection.py +++ b/mcp-server/src/meshtastic_mcp/connection.py @@ -1,4 +1,4 @@ -"""Context manager for meshtastic.SerialInterface connections. +"""Context manager for meshtastic interface connections (serial + TCP). Every info/admin tool goes through `connect(port)` so we have a single place that: @@ -6,8 +6,16 @@ that: - fails fast if a serial_session is already holding the port, - guarantees `.close()` is called, even on exception. -The `SerialInterface` blocks on construction waiting for the node database; -that's fine for v1 since every tool is a short-lived request. +Two transports: + - Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`. + - TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino + headless build) addressed as `tcp://host[:port]` (default port 4403). + Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is + set, so `resolve_port(None)` auto-selects it like a USB candidate. + +Both `SerialInterface` and `TCPInterface` block on construction waiting for +the node database; that's fine for v1 since every tool is a short-lived +request. """ from __future__ import annotations @@ -17,20 +25,107 @@ from typing import Iterator from . import devices, registry +DEFAULT_TCP_PORT = 4403 +TCP_SCHEME = "tcp://" +TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST" + class ConnectionError(RuntimeError): pass +def is_tcp_port(port: str | None) -> bool: + return bool(port) and port.startswith(TCP_SCHEME) + + +def parse_tcp_port(port: str) -> tuple[str, int]: + """Parse `tcp://host[:port]` → (host, port). Defaults to 4403. + + Validates host shape (non-empty, no path separators) and port range + (1..65535). Raises `ConnectionError` on malformed input — never lets + a raw `ValueError` bubble up to a tool surface. + """ + if not port.startswith(TCP_SCHEME): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'." + ) + rest = port[len(TCP_SCHEME) :] + if ":" in rest: + host, port_str = rest.rsplit(":", 1) + try: + tcp_port = int(port_str) + except ValueError as e: + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer." + ) from e + else: + host, tcp_port = rest, DEFAULT_TCP_PORT + if not host: + raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.") + if any(c in host for c in ("/", "\\")): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: host {host!r} contains a path " + "separator. TCP hostnames cannot contain '/' or '\\' — did you " + "pass a serial port path or a Windows drive path by mistake?" + ) + if not (1 <= tcp_port <= 65535): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range " + "(must be 1..65535)." + ) + return host, tcp_port + + +def normalize_tcp_endpoint(endpoint: str) -> str: + r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical + `tcp://host:port` form. One place that owns the lock-key shape. + + Defers all validation to `parse_tcp_port`, so path-like inputs + (`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports, + and out-of-range ports raise `ConnectionError` here too. + """ + if endpoint.startswith(TCP_SCHEME): + canonical = endpoint + elif ":" in endpoint: + canonical = f"{TCP_SCHEME}{endpoint}" + else: + canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}" + host, port = parse_tcp_port(canonical) + return f"{TCP_SCHEME}{host}:{port}" + + +def reject_if_tcp(port: str | None, tool_name: str) -> None: + """Raise if `port` is a TCP endpoint — for tools that need real USB + hardware (flash, bootloader, vendor escape hatches, serial monitor). + + Only checks the explicit arg; auto-selection via env var is the caller's + responsibility to handle if it matters. + """ + if is_tcp_port(port): + raise ConnectionError( + f"{tool_name} is not applicable to TCP/native nodes ({port}). " + "This tool requires USB-attached hardware." + ) + + def resolve_port(port: str | None) -> str: - """Pick a port: explicit > sole likely_meshtastic candidate > error.""" + """Pick a port: explicit > sole likely_meshtastic candidate > error. + + A `tcp://` string passes through (after canonicalization). When `port` + is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST` + is consulted via `devices.list_devices()`. + """ if port: + if is_tcp_port(port): + return normalize_tcp_endpoint(port) return port candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]] if not candidates: raise ConnectionError( - "No Meshtastic devices detected. Plug one in or pass `port` explicitly. " - "Run `list_devices` with include_unknown=True to see all serial ports." + "No Meshtastic devices detected. Plug one in, set " + f"{TCP_HOST_ENV}= for a meshtasticd daemon, " + "or pass `port` explicitly. Run `list_devices` with " + "include_unknown=True to see all serial ports." ) if len(candidates) > 1: ports = ", ".join(c["port"] for c in candidates) @@ -43,17 +138,62 @@ def resolve_port(port: str | None) -> str: @contextmanager def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator: - """Open a `meshtastic.SerialInterface` and always close it. + """Open a meshtastic interface (serial or TCP) and always close it. - Raises `ConnectionError` immediately if another serial session holds the - port (a `pio device monitor` in `serial_sessions/`, for instance). + For serial: raises `ConnectionError` immediately if another serial + session holds the port (a `pio device monitor` in `serial_sessions/`). + For TCP: no exclusive-access requirement, so the serial-session check + is skipped — but the `port_lock` still serializes parallel `connect()` + calls to the same daemon endpoint. + + `timeout_s` is plumbed through to both `SerialInterface(timeout=...)` + and `TCPInterface(timeout=...)`. The meshtastic library uses the value + as the reply-wait deadline for `localNode.waitForConfig()` during + construction and for any subsequent admin RPC. `int()`-converted at + the boundary because the upstream API expects whole seconds. """ + resolved = resolve_port(port) + timeout = int(timeout_s) + + if is_tcp_port(resolved): + from meshtastic.tcp_interface import ( + TCPInterface, # type: ignore[import-untyped] + ) + + host, tcp_port = parse_tcp_port(resolved) + lock = registry.port_lock(resolved) + if not lock.acquire(blocking=False): + raise ConnectionError( + f"TCP endpoint {resolved} is busy — another device operation " + "is in flight. Retry shortly." + ) + + iface = None + try: + iface = TCPInterface( + hostname=host, + portNumber=tcp_port, + connectNow=True, + noProto=False, + timeout=timeout, + ) + yield iface + finally: + if iface is not None: + try: + iface.close() + except Exception: + pass + try: + lock.release() + except RuntimeError: + pass + return + from meshtastic.serial_interface import ( SerialInterface, # type: ignore[import-untyped] ) - resolved = resolve_port(port) - active = registry.active_session_for_port(resolved) if active is not None: raise ConnectionError( @@ -70,7 +210,12 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator: iface = None try: - iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False) + iface = SerialInterface( + devPath=resolved, + connectNow=True, + noProto=False, + timeout=timeout, + ) yield iface finally: if iface is not None: diff --git a/mcp-server/src/meshtastic_mcp/devices.py b/mcp-server/src/meshtastic_mcp/devices.py index c4805c1ab..976e893a0 100644 --- a/mcp-server/src/meshtastic_mcp/devices.py +++ b/mcp-server/src/meshtastic_mcp/devices.py @@ -1,13 +1,18 @@ -"""USB/serial device discovery. +"""USB/serial + TCP device discovery. Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with the richer metadata (`serial.tools.list_ports.comports()`) so callers see VID/PID, descriptions, and manufacturer strings alongside the "is this likely a Meshtastic device" signal. + +If `MESHTASTIC_MCP_TCP_HOST=` is set, a synthetic entry for the +`meshtasticd` daemon at that endpoint is prepended to the result, so +`resolve_port(None)` auto-selects it like a USB candidate. """ from __future__ import annotations +import os from typing import Any from serial.tools import list_ports @@ -19,6 +24,45 @@ def _to_hex(value: int | None) -> str | None: return f"0x{value:04x}" +def _tcp_endpoint_from_env() -> dict[str, Any] | None: + """Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set. + + If the env var is malformed (non-integer port, path-like host, etc.), + return an entry with `likely_meshtastic=False` and the parser error in + the description, rather than raising — `list_devices` is the diagnostic + tool a user reaches for when their env var isn't working, so it must + not crash on misconfiguration. + """ + host = os.environ.get("MESHTASTIC_MCP_TCP_HOST") + if not host: + return None + # Lazy import to avoid a circular dependency (connection imports devices). + from . import connection + + try: + port = connection.normalize_tcp_endpoint(host) + description = "meshtasticd (TCP)" + likely = True + except connection.ConnectionError as e: + # Surface the raw env-var value plus the parser's reason so the + # user can see exactly what they set and why it was rejected. + # Don't double the scheme if the user already prefixed `tcp://`. + port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}" + description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}" + likely = False + return { + "port": port, + "vid": None, + "pid": None, + "description": description, + "manufacturer": None, + "product": None, + "serial_number": None, + "likely_meshtastic": likely, + "blacklisted": False, + } + + def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]: """Return enriched info for serial ports, flagging Meshtastic candidates. @@ -70,6 +114,22 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]: } ) - # Stable ordering: likely_meshtastic first, then by port path - results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"])) + # Append the TCP endpoint (if env var set) and sort everything together. + tcp_entry = _tcp_endpoint_from_env() + if tcp_entry is not None: + results.append(tcp_entry) + + # Stable ordering: likely_meshtastic first; within rank, TCP wins over + # USB (explicit env-var configuration takes precedence over USB + # enumeration); then by port path. A misconfigured TCP entry has + # likely_meshtastic=False and lands among the other ignored entries — + # it does NOT pre-empt real USB devices at the top of the list. + results.sort( + key=lambda r: ( + not r["likely_meshtastic"], + not r["port"].startswith("tcp://"), + r["port"], + ) + ) + return results diff --git a/mcp-server/src/meshtastic_mcp/flash.py b/mcp-server/src/meshtastic_mcp/flash.py index 2c41a7c21..e11197d5f 100644 --- a/mcp-server/src/meshtastic_mcp/flash.py +++ b/mcp-server/src/meshtastic_mcp/flash.py @@ -17,7 +17,7 @@ from typing import Any import serial -from . import boards, config, devices, pio, userprefs +from . import boards, config, connection, devices, pio, userprefs # Meshtastic variants use both `esp32s3` and `esp32-s3` style names across # variants/*/platformio.ini (no consistency enforced). Accept both spellings. @@ -46,6 +46,18 @@ def _require_confirm(confirm: bool, operation: str) -> None: ) +def _reject_native_env(env: str, operation: str) -> None: + """`native*` envs build a host executable, not firmware — there's no + upload step. The user wants `build` (or just runs the binary directly). + """ + if env.startswith("native"): + raise FlashError( + f"{operation} is not applicable for env {env!r}: native envs " + "produce a host executable, not flashable firmware. Use `build` " + "instead, then run the resulting binary directly." + ) + + def _artifacts_for(env: str) -> list[Path]: build_dir = config.firmware_root() / ".pio" / "build" / env if not build_dir.is_dir(): @@ -141,6 +153,8 @@ def flash( that pio performs will pick up the injected values. """ _require_confirm(confirm, "flash") + _reject_native_env(env, "flash") + connection.reject_if_tcp(port, "flash") with userprefs.temporary_overrides(userprefs_overrides) as effective: result = pio.run( ["run", "-e", env, "-t", "upload", "--upload-port", port], @@ -200,6 +214,7 @@ def erase_and_flash( in that case) since a cached factory.bin would not reflect the new prefs. """ _require_confirm(confirm, "erase_and_flash") + connection.reject_if_tcp(port, "erase_and_flash") _check_esp32_env(env) if userprefs_overrides and skip_build: @@ -257,6 +272,7 @@ def update_flash( overrides are provided we always force a rebuild. """ _require_confirm(confirm, "update_flash") + connection.reject_if_tcp(port, "update_flash") _check_esp32_env(env) if userprefs_overrides and skip_build: @@ -391,6 +407,7 @@ def touch_1200bps( Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`. """ + connection.reject_if_tcp(port, "touch_1200bps") before_list = devices.list_devices(include_unknown=True) before_ports = {d["port"] for d in before_list} diff --git a/mcp-server/src/meshtastic_mcp/hw_tools.py b/mcp-server/src/meshtastic_mcp/hw_tools.py index 4275539ba..1835f4ef1 100644 --- a/mcp-server/src/meshtastic_mcp/hw_tools.py +++ b/mcp-server/src/meshtastic_mcp/hw_tools.py @@ -16,7 +16,7 @@ import subprocess from pathlib import Path from typing import Any, Sequence -from . import config, pio +from . import config, connection, pio _TIMEOUT_SHORT = 30 _TIMEOUT_LONG = 600 @@ -102,6 +102,7 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]: def esptool_chip_info(port: str) -> dict[str, Any]: + connection.reject_if_tcp(port, "esptool_chip_info") binary = config.esptool_bin() # `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash. combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT) @@ -116,6 +117,7 @@ def esptool_chip_info(port: str) -> dict[str, Any]: def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]: """Full-chip erase. Leaves the device unbootable until reflashed.""" _require_confirm(confirm, "esptool_erase_flash") + connection.reject_if_tcp(port, "esptool_erase_flash") binary = config.esptool_bin() # esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name # first; if it fails with unknown command, retry old. @@ -134,6 +136,7 @@ def esptool_raw( """Raw esptool passthrough. Destructive subcommands require confirm=True.""" if not args: raise ToolError("args must not be empty") + connection.reject_if_tcp(port, "esptool_raw") # Find the first non-flag arg (the subcommand). subcommand = next((a for a in args if not a.startswith("-")), None) if subcommand and subcommand.replace("-", "_") in { @@ -156,6 +159,7 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"} def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]: _require_confirm(confirm, "nrfutil_dfu") + connection.reject_if_tcp(port, "nrfutil_dfu") pkg = Path(package_path).expanduser() if not pkg.is_file(): raise ToolError(f"Package not found: {pkg}") @@ -213,6 +217,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]: def picotool_info(port: str | None = None) -> dict[str, Any]: """Read device info from a Pico in BOOTSEL mode. `port` is informational only — picotool auto-detects.""" + connection.reject_if_tcp(port, "picotool_info") binary = config.picotool_bin() res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT) if res["exit_code"] != 0: diff --git a/mcp-server/src/meshtastic_mcp/serial_session.py b/mcp-server/src/meshtastic_mcp/serial_session.py index b9c71d1d0..43537323f 100644 --- a/mcp-server/src/meshtastic_mcp/serial_session.py +++ b/mcp-server/src/meshtastic_mcp/serial_session.py @@ -71,6 +71,10 @@ def open_session( If `env` is supplied, pio resolves baud and filters from platformio.ini. Otherwise uses the supplied `baud` and `filters` (default `['direct']`). """ + # Lazy import to avoid circular: registry imports serial_session. + from . import connection + + connection.reject_if_tcp(port, "serial_open") args = ["device", "monitor", "--port", port, "--no-reconnect"] effective_filters: list[str] effective_baud: int = baud diff --git a/mcp-server/tests/unit/test_connection_tcp.py b/mcp-server/tests/unit/test_connection_tcp.py new file mode 100644 index 000000000..54b7e9b47 --- /dev/null +++ b/mcp-server/tests/unit/test_connection_tcp.py @@ -0,0 +1,383 @@ +"""TCP transport plumbing in connection.py + devices.py. + +Pure-Python tests — no real device or daemon required. Mocks `TCPInterface` +when exercising `connect()`. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from meshtastic_mcp import connection, devices + +# ---------- helpers -------------------------------------------------------- + + +class TestIsTcpPort: + def test_tcp_scheme(self) -> None: + assert connection.is_tcp_port("tcp://localhost") is True + assert connection.is_tcp_port("tcp://localhost:4403") is True + assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True + + def test_serial_paths(self) -> None: + assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False + assert connection.is_tcp_port("/dev/ttyUSB0") is False + assert connection.is_tcp_port("COM3") is False + + def test_empty_or_none(self) -> None: + assert connection.is_tcp_port(None) is False + assert connection.is_tcp_port("") is False + + +class TestParseTcpPort: + def test_default_port(self) -> None: + assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403) + + def test_explicit_port(self) -> None: + assert connection.parse_tcp_port("tcp://localhost:9999") == ( + "localhost", + 9999, + ) + + def test_ip_with_port(self) -> None: + assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == ( + "192.168.1.50", + 4403, + ) + + +class TestNormalizeTcpEndpoint: + def test_bare_host(self) -> None: + assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403" + + def test_host_port(self) -> None: + assert ( + connection.normalize_tcp_endpoint("localhost:5000") + == "tcp://localhost:5000" + ) + + def test_full_url(self) -> None: + assert ( + connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403" + ) + assert ( + connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999") + == "tcp://1.2.3.4:9999" + ) + + def test_idempotent(self) -> None: + once = connection.normalize_tcp_endpoint("localhost:4403") + twice = connection.normalize_tcp_endpoint(once) + assert once == twice == "tcp://localhost:4403" + + def test_path_like_endpoint_rejected(self) -> None: + # Serial port paths and Windows drive paths are common config typos + # (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject + # rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL. + with pytest.raises(connection.ConnectionError, match="path separator"): + connection.normalize_tcp_endpoint("/dev/cu.foo") + with pytest.raises(connection.ConnectionError): + connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403") + with pytest.raises(connection.ConnectionError): + connection.normalize_tcp_endpoint(r"C:\Windows\System32") + + def test_non_integer_port_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="not an integer"): + connection.normalize_tcp_endpoint("tcp://host:notaport") + with pytest.raises(connection.ConnectionError, match="not an integer"): + connection.normalize_tcp_endpoint("host:notaport") + + def test_empty_host_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="empty host"): + connection.normalize_tcp_endpoint("tcp://:4403") + + def test_port_out_of_range_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("tcp://host:0") + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("tcp://host:65536") + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("host:99999") + + +class TestParseTcpPortValidation: + def test_missing_scheme_rejected(self) -> None: + # parse_tcp_port is a low-level helper that requires the scheme. + # Misuse should fail loudly rather than silently mis-parsing. + with pytest.raises(connection.ConnectionError, match="expected"): + connection.parse_tcp_port("localhost:4403") + + def test_negative_port_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.parse_tcp_port("tcp://host:-1") + + +# ---------- reject_if_tcp -------------------------------------------------- + + +class TestRejectIfTcp: + def test_rejects_tcp(self) -> None: + with pytest.raises(connection.ConnectionError, match="not applicable"): + connection.reject_if_tcp("tcp://localhost", "esptool_chip_info") + + def test_passes_through_serial(self) -> None: + connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise + + def test_passes_through_none(self) -> None: + # None means "auto-detect"; not the explicit-arg case we guard. + connection.reject_if_tcp(None, "esptool_chip_info") # no raise + + +# ---------- resolve_port --------------------------------------------------- + + +class TestResolvePort: + def test_explicit_serial_passthrough(self) -> None: + assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999" + + def test_explicit_tcp_normalized(self) -> None: + assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403" + + def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch.object(devices, "list_devices", return_value=[]): + with pytest.raises( + connection.ConnectionError, match="No Meshtastic devices" + ): + connection.resolve_port(None) + + def test_no_port_one_candidate_selected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}] + with patch.object(devices, "list_devices", return_value=fake): + assert connection.resolve_port(None) == "/dev/cu.usbmodem1" + + def test_no_port_multiple_candidates_errors( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + fake = [ + {"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}, + {"port": "/dev/cu.usbmodem2", "likely_meshtastic": True}, + ] + with patch.object(devices, "list_devices", return_value=fake): + with pytest.raises(connection.ConnectionError, match="Multiple"): + connection.resolve_port(None) + + def test_env_var_surfaces_tcp_via_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost") + # Don't patch list_devices — let the real env-var path run, but stub + # the USB enumeration to keep the test hermetic. + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + assert connection.resolve_port(None) == "tcp://localhost:4403" + + +# ---------- devices.list_devices TCP entry -------------------------------- + + +class TestDevicesTcpEntry: + def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + assert all(not d["port"].startswith("tcp://") for d in ds) + + def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + tcp = [d for d in ds if d["port"].startswith("tcp://")] + assert len(tcp) == 1 + assert tcp[0]["port"] == "tcp://myhost:9999" + assert tcp[0]["likely_meshtastic"] is True + assert tcp[0]["description"] == "meshtasticd (TCP)" + + def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + assert ds, "expected at least the TCP entry" + assert ds[0]["port"].startswith("tcp://") + + def test_invalid_env_var_does_not_break_list_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `list_devices` is the diagnostic tool reached for when an env var + # isn't working — it must not throw on misconfiguration. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices(include_unknown=True) + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["likely_meshtastic"] is False + assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"] + assert "not an integer" in tcp[0]["description"] + + def test_invalid_env_var_excluded_from_resolve_port_autodetect( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `likely_meshtastic=False` keeps the bad TCP entry out of the + # auto-select path — `resolve_port(None)` should still report + # "no Meshtastic devices" rather than picking a broken endpoint. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + with pytest.raises(connection.ConnectionError, match="No Meshtastic"): + connection.resolve_port(None) + + def test_invalid_env_var_does_not_double_tcp_scheme( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`, + # the diagnostic entry must surface the raw value as-is rather than + # producing `tcp://tcp://host:bad`. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices(include_unknown=True) + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["port"] == "tcp://host:notaport" + assert "tcp://tcp://" not in tcp[0]["port"] + + def test_invalid_env_var_does_not_pre_empt_real_usb_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Sort ordering: a misconfigured TCP env var must NOT take position 0 + # ahead of real USB candidates. Position 0 is reserved for the highest + # rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker + # within rank. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + + # Stub a USB Meshtastic candidate (Espressif VID, port present in + # findPorts). + class FakeInfo: + def __init__(self, device: str, vid: int, pid: int) -> None: + self.device = device + self.vid = vid + self.pid = pid + self.description = "Heltec V3" + self.manufacturer = "Espressif" + self.product = "USB JTAG/serial" + self.serial_number = "abc" + + fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001) + with patch( + "meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port] + ), patch( + "meshtastic.util.findPorts", + return_value=["/dev/cu.usbmodem4201"], + ): + ds = devices.list_devices(include_unknown=True) + + assert ds, "expected at least the USB + TCP entries" + # Real USB candidate must be at position 0 — it's likely_meshtastic. + assert ds[0]["port"] == "/dev/cu.usbmodem4201" + assert ds[0]["likely_meshtastic"] is True + # The malformed TCP entry exists but lands among the unlikely entries. + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["likely_meshtastic"] is False + assert ds.index(tcp[0]) > 0 + + def test_likely_tcp_entry_wins_tiebreak_over_usb( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Conversely, a *valid* TCP env var should sort ahead of USB + # candidates of equal likely_meshtastic rank — explicit env-var + # configuration is a precedence signal. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403") + + class FakeInfo: + def __init__(self, device: str, vid: int, pid: int) -> None: + self.device = device + self.vid = vid + self.pid = pid + self.description = "Heltec V3" + self.manufacturer = "Espressif" + self.product = "USB JTAG/serial" + self.serial_number = "abc" + + fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001) + with patch( + "meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port] + ), patch( + "meshtastic.util.findPorts", + return_value=["/dev/cu.usbmodem4201"], + ): + ds = devices.list_devices() + + assert ds[0]["port"] == "tcp://localhost:4403" + assert ds[0]["likely_meshtastic"] is True + + +# ---------- connect() routing --------------------------------------------- + + +class TestConnectRoutesTcp: + def test_connect_uses_tcp_interface_for_tcp_port( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)` + and never touches `SerialInterface`.""" + # Make sure the env var doesn't leak in and confuse resolve_port. + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch( + "meshtastic.serial_interface.SerialInterface" + ) as mock_serial: + mock_tcp.return_value.close.return_value = None + with connection.connect(port="tcp://example.com:1234", timeout_s=12.0): + pass + + mock_tcp.assert_called_once_with( + hostname="example.com", + portNumber=1234, + connectNow=True, + noProto=False, + timeout=12, + ) + mock_serial.assert_not_called() + + def test_connect_plumbs_timeout_to_serial_interface( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify the serial branch also propagates `timeout_s` so callers + passing a custom timeout to `device_info` / `list_nodes` / etc. don't + silently get the library default.""" + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + + with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch( + "meshtastic.tcp_interface.TCPInterface" + ) as mock_tcp: + mock_serial.return_value.close.return_value = None + with connection.connect(port="/dev/cu.fake", timeout_s=20.0): + pass + + mock_serial.assert_called_once_with( + devPath="/dev/cu.fake", + connectNow=True, + noProto=False, + timeout=20, + ) + mock_tcp.assert_not_called() + + def test_connect_releases_lock_on_tcp_failure( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp: + mock_tcp.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + with connection.connect(port="tcp://locktest:4403"): + pass + + # Lock should be released — a second connect attempt must not fail + # with "busy". + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp: + mock_tcp.return_value.close.return_value = None + with connection.connect(port="tcp://locktest:4403"): + pass From 7066abbb86739be7e3a907fb40ed2bd4744f637b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 30 Apr 2026 13:52:42 -0500 Subject: [PATCH 39/52] Fix MAC_from_string to use input parameter instead of global config for MAC address parsing (#10356) * Fix MAC_from_string to use input parameter instead of global config for MAC address parsing * Enhance MAC_from_string validation and error handling * Add missing include for in PortduinoGlue.cpp --- src/platform/portduino/PortduinoGlue.cpp | 33 ++-- test/test_mac_from_string/test_main.cpp | 195 +++++++++++++++++++++++ 2 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 test/test_mac_from_string/test_main.cpp diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index fbaa3c98c..eeb56240d 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1052,17 +1053,31 @@ static bool ends_with(std::string_view str, std::string_view suffix) bool MAC_from_string(std::string mac_str, uint8_t *dmac) { mac_str.erase(std::remove(mac_str.begin(), mac_str.end(), ':'), mac_str.end()); - if (mac_str.length() == 12) { - dmac[0] = std::stoi(portduino_config.mac_address.substr(0, 2), nullptr, 16); - dmac[1] = std::stoi(portduino_config.mac_address.substr(2, 2), nullptr, 16); - dmac[2] = std::stoi(portduino_config.mac_address.substr(4, 2), nullptr, 16); - dmac[3] = std::stoi(portduino_config.mac_address.substr(6, 2), nullptr, 16); - dmac[4] = std::stoi(portduino_config.mac_address.substr(8, 2), nullptr, 16); - dmac[5] = std::stoi(portduino_config.mac_address.substr(10, 2), nullptr, 16); - return true; - } else { + if (mac_str.length() != 12) { return false; } + // Validate every character is a hex digit before parsing. std::stoi + // would otherwise skip leading whitespace and silently truncate at the + // first non-digit, which is too lenient for a MAC address. + for (char c : mac_str) { + if (!isxdigit(static_cast(c))) { + return false; + } + } + // Parse into a temporary so dmac is not partially modified if a later + // byte fails. At least one caller in getMacAddr() ignores the bool + // return, so leaving stale bytes in dmac on failure would silently + // produce a wrong MAC. + uint8_t tmp[6]; + try { + for (int i = 0; i < 6; i++) { + tmp[i] = static_cast(std::stoi(mac_str.substr(i * 2, 2), nullptr, 16)); + } + } catch (const std::exception &) { + return false; + } + memcpy(dmac, tmp, 6); + return true; } std::string exec(const char *cmd) diff --git a/test/test_mac_from_string/test_main.cpp b/test/test_mac_from_string/test_main.cpp new file mode 100644 index 000000000..c9d2289cc --- /dev/null +++ b/test/test_mac_from_string/test_main.cpp @@ -0,0 +1,195 @@ +// Unit tests for MAC_from_string in src/platform/portduino/PortduinoGlue.cpp. +// +// Regression coverage for when the function stripped colons from +// its mac_str parameter but then read bytes from the global +// portduino_config.mac_address. Symptoms: --hwid silently ignored when +// MACAddress: was also set, and SIGABRT (stoi: no conversion) when --hwid +// was used without MACAddress: in config.yaml. +#include "Arduino.h" +#include "TestUtil.h" +#include +#include +#include +#include + +// Forward-declare instead of including PortduinoGlue.h to avoid pulling in +// LR11x0Interface, USBHal, mesh.pb.h, yaml-cpp, and the full portduino_config +// struct just to test a self-contained string parser. The symbol is defined +// in PortduinoGlue.cpp and resolved at link time. +bool MAC_from_string(std::string mac_str, uint8_t *dmac); + +void setUp(void) {} +void tearDown(void) {} + +// --- Happy-path parsing --- + +void test_colon_separated_uppercase() +{ + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xBB, dmac[1]); + TEST_ASSERT_EQUAL_HEX8(0xCC, dmac[2]); + TEST_ASSERT_EQUAL_HEX8(0xDD, dmac[3]); + TEST_ASSERT_EQUAL_HEX8(0xEE, dmac[4]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_colon_separated_lowercase() +{ + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("02:ca:fe:ba:be:01", dmac)); + TEST_ASSERT_EQUAL_HEX8(0x02, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xCA, dmac[1]); + TEST_ASSERT_EQUAL_HEX8(0xFE, dmac[2]); + TEST_ASSERT_EQUAL_HEX8(0xBA, dmac[3]); + TEST_ASSERT_EQUAL_HEX8(0xBE, dmac[4]); + TEST_ASSERT_EQUAL_HEX8(0x01, dmac[5]); +} + +void test_no_colons_packed_hex() +{ + // The CLI form produced by some tools — 12 hex chars, no separators. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AABBCCDDEEFF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_two_distinct_inputs_yield_distinct_outputs() +{ + // Direct regression for the original bug: parsing two different MAC + // strings in succession must produce two different byte sequences. + // Pre-fix, both calls would have produced identical bytes derived from + // the (untouched) global portduino_config.mac_address. + uint8_t a[6] = {0}; + uint8_t b[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", a)); + TEST_ASSERT_TRUE(MAC_from_string("02:CA:FE:BA:BE:01", b)); + TEST_ASSERT_NOT_EQUAL(0, std::memcmp(a, b, 6)); + TEST_ASSERT_EQUAL_HEX8(0xAA, a[0]); + TEST_ASSERT_EQUAL_HEX8(0x02, b[0]); +} + +void test_does_not_read_external_state() +{ + // The function must derive every byte from its parameter, not from any + // global. Provide a unique MAC and verify all six bytes match the input + // exactly — leaves no room for the function to be smuggling bytes from + // elsewhere. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("12:34:56:78:9A:BC", dmac)); + const uint8_t expected[6] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC}; + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, dmac, 6); +} + +// --- Rejected inputs --- +// Pre-fix, the empty/short cases either crashed (stoi exception on substr("") +// of the empty global) or silently filled dmac with stale bytes. Post-fix, +// the length guard rejects them cleanly with `false` and dmac is unchanged. + +void test_empty_string_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("", dmac)); + // dmac must be untouched on failure. + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_too_short_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_too_long_returns_false() +{ + uint8_t dmac[6] = {0}; + // 14 hex chars after colon-strip > 12. + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:FF:00", dmac)); +} + +void test_only_colons_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string(":::::", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_extra_colons_still_parses() +{ + // Colon stripping happens before length check, so an unconventional + // grouping that totals 12 hex chars after stripping is still accepted. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AABB:CCDD:EEFF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_non_hex_input_returns_false() +{ + // 12 chars of non-hex would have made std::stoi throw before the + // try/catch wrapper was added, killing the daemon. Now must return false + // and leave dmac untouched. + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_partial_hex_failure_preserves_dmac() +{ + // First five bytes are valid hex; the sixth ("ZZ") is not. Without the + // temp-buffer staging, dmac would be partially overwritten with the five + // good bytes plus stale data in slot 5 — silently producing a wrong MAC + // since the only caller that uses this in getMacAddr() ignores the bool + // return value. + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:ZZ", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_embedded_non_hex_returns_false() +{ + // std::stoi tolerates leading whitespace and a "0x" prefix, so a stray + // space inside a 2-char window like " F" would silently parse as 0xF. + // The per-character isxdigit() pre-check rejects these. The 14-char + // "0xAABBCCDDEEFF" is also rejected by the length check. + uint8_t dmac[6] = {0}; + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE: F", dmac)); + TEST_ASSERT_FALSE(MAC_from_string("0xAABBCCDDEEFF", dmac)); +} + +// --- Unity lifecycle --- + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_colon_separated_uppercase); + RUN_TEST(test_colon_separated_lowercase); + RUN_TEST(test_no_colons_packed_hex); + RUN_TEST(test_two_distinct_inputs_yield_distinct_outputs); + RUN_TEST(test_does_not_read_external_state); + RUN_TEST(test_empty_string_returns_false); + RUN_TEST(test_too_short_returns_false); + RUN_TEST(test_too_long_returns_false); + RUN_TEST(test_only_colons_returns_false); + RUN_TEST(test_extra_colons_still_parses); + RUN_TEST(test_non_hex_input_returns_false); + RUN_TEST(test_partial_hex_failure_preserves_dmac); + RUN_TEST(test_embedded_non_hex_returns_false); + exit(UNITY_END()); +} + +void loop() {} From 4ee959810795e94b6046c0bc4854f5ec6a859927 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 30 Apr 2026 16:22:11 -0400 Subject: [PATCH 40/52] Docker: Install grpcio-tools from distro (#10358) Use distro provided Python at build time (instead of the `python` images from dockerhub) and install `grpcio-tools` using the distro provided packages. This should speed up build times, ESPECIALLY on riscv64 (where prebuilt `grpcio-tools` wheels are not provided on pip). Co-authored-by: Copilot --- Dockerfile | 4 +++- alpine.Dockerfile | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e00d81658..ba013cb15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,17 @@ # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions -FROM python:3.14-slim-trixie AS builder +FROM debian:trixie AS builder ARG PIO_ENV=native ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Etc/UTC # Install Dependencies ENV PIP_ROOT_USER_ACTION=ignore +ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apt-get update && apt-get install --no-install-recommends -y \ curl wget g++ zip git ca-certificates pkg-config \ + python3-pip python3-grpc-tools \ libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \ libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \ libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 40a4990bb..6d1b999e2 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -4,12 +4,18 @@ # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions # Ensure the Alpine version is updated in both stages of the container! -FROM python:3.14-alpine3.23 AS builder +FROM alpine:3.23 AS builder ARG PIO_ENV=native -ENV PIP_ROOT_USER_ACTION=ignore +# Enable Alpine community repository (for 'py3-grpcio-tools') +RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories + +# Install Dependencies +ENV PIP_ROOT_USER_ACTION=ignore +ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apk --no-cache add \ bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \ + py3-pip py3-grpcio-tools \ libgpiod-dev yaml-cpp-dev bluez-dev \ libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \ libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \ From 1eb860a3fc3b2654458bfeffdd27ac004b0bf9e2 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 1 May 2026 21:25:19 +0800 Subject: [PATCH 41/52] fix(stm32wl,nrf52,fs): flash hardening, FS platform unification, write-behind LFS cache (FORMAT BREAK) (#10171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stm32wl: check HAL_FLASH_Unlock() return in _internal_flash_erase _internal_flash_prog already checks HAL_FLASH_Unlock() and returns LFS_ERR_IO on failure. _internal_flash_erase discarded the return value, proceeding to erase even if the flash was not unlocked. Apply the same check for consistency and safety. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: fix _internal_flash_prog to abort on first write error Previously the programming loop continued to the next doubleword after HAL_FLASH_Program() failed, potentially writing to invalid addresses and returning a misleading error code only at the end (last iteration). HAL_FLASH_Lock() was also skipped on the mid-loop early return path. - Move bounds check before the loop (validate full range at once) - Break on first HAL error so subsequent doublewords are not written - Move HAL_FLASH_Lock() after the loop so it always runs Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: clear stale flash SR error flags before erase and program Stale error flags in FLASH->SR from a previous failed operation can cause HAL_FLASH_Program() or HAL_FLASHEx_Erase() to return HAL_ERROR immediately without attempting the operation. Add __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS) after each HAL_FLASH_Unlock() in both _internal_flash_prog and _internal_flash_erase to ensure a clean state before each operation. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: reject flash prog writes not aligned to 8-byte doubleword The STM32WL HAL minimum write unit is one 64-bit doubleword (8 bytes). _internal_flash_prog silently truncated any trailing bytes when size % 8 != 0 because dw_count = size / 8 drops the remainder. Return LFS_ERR_INVAL early so LittleFS sees the error rather than a silent short write. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fix(nrf52,fs): use atomic SafeFile rename instead of direct write NRF52 was bypassing the .tmp/readback/rename path entirely — openFile() deleted the target file and wrote directly to it, and close() returned true without verifying the write or renaming anything. Adafruit_LittleFS::rename() calls lfs_rename() directly (confirmed at Adafruit_LittleFS.cpp:205). Remove both ARCH_NRF52 guards so NRF52 follows the same write-to-.tmp → readback-hash → rename path used by all other platforms. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fix(admin): skip uiconfig.proto save on devices without a screen handleStoreDeviceUIConfig() was writing /prefs/uiconfig.proto unconditionally. MenuHandler.cpp is already gated behind #if HAS_SCREEN, so there is no path that populates UI config on screen-less platforms. Guard the save with #if HAS_SCREEN to avoid wasting a flash block on devices that will never use it. The read path (handleGetDeviceUIConfig) does not touch the filesystem and needs no change. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fs: enable format-on-retry for all platforms in saveToDisk The FSCom.format() call on save failure was guarded to ARCH_NRF52 with a comment that other platforms were not ready (bug #4184). STM32WL was added to the guard in a prior commit. All platforms now expose format semantics and the retry logic is identical — remove the guard. To keep NodeDB.cpp platform-agnostic and fix a CI failure on native-tft (portduino's fs::FS has no format() method), introduce fsFormat() in FSCommon as the single call-site for all callers: - Embedded (ESP32, NRF52, STM32WL, RP2040): delegates to FSCom.format() - Portduino: rmDir("/prefs") + FSBegin() (a no-op on portduino). rmDir("/prefs") is already called unconditionally by factoryReset() (NodeDB.cpp:504), so both primitives are proven on portduino. Replace both direct FSCom.format() calls in NodeDB.cpp with fsFormat(). Note: we do not run portduino locally — portduino/native build testers please verify the format-on-retry path. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * DO NOT MERGE: nrf52(fs): add File() default constructor bound to InternalFS Adds File() to the Adafruit LittleFS File class (in the Meshtastic Adafruit_nRF52_Arduino fork), delegating to File(InternalFS). This matches the default-constructible File API on all other platforms. The constructor is implemented in Adafruit_LittleFS_File.cpp rather than inline in the header to avoid a circular include between Adafruit_LittleFS_File.h and InternalFileSystem.h. FOLLOW-UP REQUIRED: nrf52.ini points to a commit SHA on the mesh-malaysia/Adafruit_nRF52_Arduino fork instead of the upstream meshtastic framework. Once meshtastic/Adafruit_nRF52_Arduino#5 is merged, revert nrf52.ini to point back to the upstream meshtastic framework URL. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): add File() default constructor and document LFS tunables Adds File() to STM32_LittleFS_Namespace::File, delegating to File(InternalFS). Implemented in the .cpp to avoid a circular include between STM32_LittleFS_File.h (which cannot include LittleFS.h) and the InternalFS extern declaration. This matches the File API on ESP32/RP2040/Portduino and is a prerequisite for removing the ARCH_STM32WL guard in xmodem.h. No behavior change — the constructor leaves the file in the same closed/unattached state as File(InternalFS) would. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fs: remove arch-specific ifdefs from FSCommon, SafeFile, xmodem Now that NRF52 and STM32WL have File() default constructors and NRF52 has working atomic SafeFile rename, the capability gaps are closed. Remove all per-arch guards across the shared FS layer: FSCommon.cpp — renameFile(): Use FSCom.rename() on all platforms. Adafruit_LittleFS::rename() calls lfs_rename() directly (Adafruit_LittleFS.cpp:205). The copy+delete fallback on NRF52/RP2040 was never necessary. FSCommon.cpp — getFiles(): Replace four ARCH_ESP32 guards with a single filepath pointer at the top of the loop (file.path() on ESP32, file.name() elsewhere). Fix strcpy(fileInfo.file_name, filepath): bounded to sizeof(fileInfo.file_name)-1 with explicit NUL termination to prevent overflow of the 228-byte meshtastic_FileInfo::file_name array. FSCommon.cpp — listDir(): Same filepath pointer approach. NRF52/STM32WL were in an else-branch that only logged but never deleted — now all platforms follow the unified del path. 12 guards → 2. Fix three strncpy(buffer, ..., sizeof(buffer)) calls that did not NUL-terminate when source length >= sizeof(buffer) (255 bytes). Add explicit buffer[sizeof(buffer)-1] = '\0' after each. FSCommon.cpp — rmDir(): Use listDir(del=true) everywhere. The ARCH_NRF52 rmdir_r() path and the ARCH_ESP32|RP2040|PORTDUINO listDir() path collapse to one line. SafeFile.cpp: ARCH_NRF52 bypass removed (handled in preceding commit). xmodem.h: File file; now works on all platforms via default constructors added in the two preceding commits. Remaining #ifdef ARCH_ESP32 in FSCommon.cpp: exactly 4, all for the file.path() vs file.name() API difference (ESP32 Arduino LittleFS returns the full path; all others return only the name). That difference lives in the framework and cannot be closed here. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): add write-behind page cache, reduce virtual block size and FS reservation (FORMAT BREAK) Adds a write-behind (RMW) page cache to the STM32WL LittleFS driver, modelled after the NRF52 Adafruit approach (flash_cache.c). This allows LFS to use 256-byte virtual blocks backed by 2048-byte physical pages: the erase/prog callbacks accumulate changes in a 2 KB RAM buffer; the sync callback (and page eviction on page-change) flushes with a single HAL physical-erase + doubleword-program pass. LFS tunables changed (FORMAT BREAK — superblock parameters): block_size: 2048 B → 256 B (8 virtual blocks per physical page) read_size: 2048 B → 256 B (= block_size) prog_size: 2048 B → 256 B (= block_size; hardware min is 8 B) block_count: 112 → 80 (14 phys pages → 10 phys pages = 20 KiB) Benefits: - Internal fragmentation: max 2047 B/file → max 255 B/file - Heap per open LFS file: ~4 KB → 512 B (prog + read buffers) - Code flash headroom: 6.7 KB → ~14.1 KB (+7.4 KB) - Block budget: 80 virtual blocks, worst-case peak ~20, ~60 free Updates board_upload.maximum_size in wio-e5/platformio.ini from 233472 (256 KB − 28 KB) to 241664 (256 KB − 20 KB) to match the reduced FS reservation. Justification for the format break: the prior STM32WL firmware had several flash write bugs fixed earlier in this series (missing error flag clearing, no abort on first write failure, unaligned write acceptance). These bugs very likely caused silent config corruption on deployed devices. The format break should be treated as an enhancement: it provides a clean, reliably-written starting point. Users will need to reconfigure their device once after this update. Correctness fixes applied to the cache implementation: - alignas(8) on _page_cache: the buffer was uint8_t[] (alignment 1) but _flash_cache_flush casts it to const uint64_t* — undefined behaviour per C++ standard, potential Cortex-M hardfault. alignas(8) guarantees the required alignment for the doubleword cast. - HAL_FLASH_Lock() return value: was discarded. Now assigned to lock_rc and propagated into rc if prior writes succeeded, so LFS sees the error rather than a false success. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): reduce FS reservation from 10 pages to 7 pages (FORMAT BREAK) Reduces LFS_FLASH_TOTAL_SIZE from 10 × 2 KiB pages (20 KiB) to 7 × 2 KiB pages (14 KiB), freeing 6 KiB for firmware. board_upload.maximum_size updated accordingly across all STM32WL variants: 241664 (256 KiB - 20 KiB) → 247808 (256 KiB - 14 KiB) This is a FORMAT BREAK: existing filesystems must be erased before use. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Andrew Yong * fix(fs): return false in renameFile() when FSCom is not defined Avoids undefined behavior and -Wreturn-type warnings in configurations that compile FSCommon.cpp without a filesystem backend. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 --------- Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- src/FSCommon.cpp | 158 +++++------- src/FSCommon.h | 1 + src/SafeFile.cpp | 7 - src/mesh/NodeDB.cpp | 4 +- src/modules/AdminModule.cpp | 2 + src/platform/stm32wl/LittleFS.cpp | 235 +++++++++++------- src/platform/stm32wl/STM32_LittleFS_File.cpp | 6 + src/platform/stm32wl/STM32_LittleFS_File.h | 1 + src/xmodem.h | 4 - variants/stm32/CDEBYTE_E77-MBL/platformio.ini | 2 +- variants/stm32/milesight_gs301/platformio.ini | 2 +- variants/stm32/rak3172/platformio.ini | 2 +- variants/stm32/russell/platformio.ini | 2 +- variants/stm32/wio-e5/platformio.ini | 2 +- 14 files changed, 222 insertions(+), 206 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index f215be80f..8fafc6c52 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to) bool renameFile(const char *pathFrom, const char *pathTo) { #ifdef FSCom - -#ifdef ARCH_ESP32 - // take SPI Lock spiLock->lock(); - // rename was fixed for ESP32 IDF LittleFS in April bool result = FSCom.rename(pathFrom, pathTo); spiLock->unlock(); return result; #else - // copyFile does its own locking. - if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) { - return true; - } else { - return false; - } -#endif - + return false; #endif } #include +/** + * @brief Platform-agnostic filesystem format / wipe. + * + * On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the + * native FSCom.format() which erases and reinitialises the LittleFS + * partition. + * + * On Portduino the fs::FS backend has no format() method. We instead + * delete /prefs (the only meshtastic data directory written at runtime) + * and return. rmDir("/prefs") is already called unconditionally by + * factoryReset() so this is a proven primitive on Portduino. + * FSBegin() is a no-op (#define FSBegin() true) on Portduino. + * + * @return true on success, false on failure or if no filesystem is configured. + */ +bool fsFormat() +{ +#ifdef FSCom +#if defined(ARCH_PORTDUINO) + rmDir("/prefs"); + return FSBegin(); +#else + return FSCom.format(); +#endif +#else + return false; +#endif +} + /** * @brief Get the list of files in a directory. * @@ -123,23 +141,21 @@ std::vector getFiles(const char *dirname, uint8_t levels) File file = root.openNextFile(); while (file) { +#ifdef ARCH_ESP32 + const char *filepath = file.path(); +#else + const char *filepath = file.name(); +#endif if (file.isDirectory() && !String(file.name()).endsWith(".")) { if (levels) { -#ifdef ARCH_ESP32 - std::vector subDirFilenames = getFiles(file.path(), levels - 1); -#else - std::vector subDirFilenames = getFiles(file.name(), levels - 1); -#endif + std::vector subDirFilenames = getFiles(filepath, levels - 1); filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end()); file.close(); } } else { meshtastic_FileInfo fileInfo = {"", static_cast(file.size())}; -#ifdef ARCH_ESP32 - strcpy(fileInfo.file_name, file.path()); -#else - strcpy(fileInfo.file_name, file.name()); -#endif + strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1); + fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0'; if (!String(fileInfo.file_name).endsWith(".")) { filenames.push_back(fileInfo); } @@ -163,98 +179,59 @@ std::vector getFiles(const char *dirname, uint8_t levels) void listDir(const char *dirname, uint8_t levels, bool del) { #ifdef FSCom -#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) char buffer[255]; -#endif File root = FSCom.open(dirname, FILE_O_READ); - if (!root) { + if (!root || !root.isDirectory()) return; - } - if (!root.isDirectory()) { - return; - } File file = root.openNextFile(); - while ( - file && - file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395) + while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395 +#ifdef ARCH_ESP32 + const char *filepath = file.path(); +#else + const char *filepath = file.name(); +#endif if (file.isDirectory() && !String(file.name()).endsWith(".")) { if (levels) { -#ifdef ARCH_ESP32 - listDir(file.path(), levels - 1, del); + listDir(filepath, levels - 1, del); if (del) { - LOG_DEBUG("Remove %s", file.path()); - strncpy(buffer, file.path(), sizeof(buffer)); + LOG_DEBUG("Remove %s", filepath); + strncpy(buffer, filepath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; file.close(); FSCom.rmdir(buffer); } else { file.close(); } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - listDir(file.name(), levels - 1, del); - if (del) { - LOG_DEBUG("Remove %s", file.name()); - strncpy(buffer, file.name(), sizeof(buffer)); - file.close(); - FSCom.rmdir(buffer); - } else { - file.close(); - } -#else - LOG_DEBUG(" %s (directory)", file.name()); - listDir(file.name(), levels - 1, del); - file.close(); -#endif } } else { -#ifdef ARCH_ESP32 if (del) { - LOG_DEBUG("Delete %s", file.path()); - strncpy(buffer, file.path(), sizeof(buffer)); + LOG_DEBUG("Delete %s", filepath); + strncpy(buffer, filepath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; file.close(); FSCom.remove(buffer); } else { - LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size()); + LOG_DEBUG(" %s (%i Bytes)", filepath, file.size()); file.close(); } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - if (del) { - LOG_DEBUG("Delete %s", file.name()); - strncpy(buffer, file.name(), sizeof(buffer)); - file.close(); - FSCom.remove(buffer); - } else { - LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size()); - file.close(); - } -#else - LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size()); - file.close(); -#endif } file = root.openNextFile(); } #ifdef ARCH_ESP32 - if (del) { - LOG_DEBUG("Remove %s", root.path()); - strncpy(buffer, root.path(), sizeof(buffer)); - root.close(); - FSCom.rmdir(buffer); - } else { - root.close(); - } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - if (del) { - LOG_DEBUG("Remove %s", root.name()); - strncpy(buffer, root.name(), sizeof(buffer)); - root.close(); - FSCom.rmdir(buffer); - } else { - root.close(); - } + const char *rootpath = root.path(); #else - root.close(); + const char *rootpath = root.name(); #endif + if (del) { + LOG_DEBUG("Remove %s", rootpath); + strncpy(buffer, rootpath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; + root.close(); + FSCom.rmdir(buffer); + } else { + root.close(); + } #endif } @@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del) void rmDir(const char *dirname) { #ifdef FSCom - -#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) listDir(dirname, 10, true); -#elif defined(ARCH_NRF52) - // nRF52 implementation of LittleFS has a recursive delete function - FSCom.rmdir_r(dirname); -#endif - #endif } diff --git a/src/FSCommon.h b/src/FSCommon.h index fdc0b76ec..9fe71e47b 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -52,6 +52,7 @@ void fsInit(); void fsListFiles(); bool copyFile(const char *from, const char *to); bool renameFile(const char *pathFrom, const char *pathTo); +bool fsFormat(); std::vector getFiles(const char *dirname, uint8_t levels); void listDir(const char *dirname, uint8_t levels, bool del = false); void rmDir(const char *dirname); diff --git a/src/SafeFile.cpp b/src/SafeFile.cpp index 39436f18e..0173fde81 100644 --- a/src/SafeFile.cpp +++ b/src/SafeFile.cpp @@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic) { concurrency::LockGuard g(spiLock); LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic); -#ifdef ARCH_NRF52 - FSCom.remove(filename); - return FSCom.open(filename, FILE_O_WRITE); -#endif if (!fullAtomic) { FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists) } @@ -67,9 +63,6 @@ bool SafeFile::close() f.close(); spiLock->unlock(); -#ifdef ARCH_NRF52 - return true; -#endif if (!testReadback()) return false; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 083db6561..ac6880ade 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1611,12 +1611,10 @@ bool NodeDB::saveToDisk(int saveWhat) if (!success) { LOG_ERROR("Failed to save to disk, retrying"); -#ifdef ARCH_NRF52 // @geeksville is not ready yet to say we should do this on other platforms. See bug #4184 discussion spiLock->lock(); - FSCom.format(); + fsFormat(); spiLock->unlock(); -#endif success = saveToDiskNoRetry(saveWhat); RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 865ac38f5..7b249f656 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1412,7 +1412,9 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot) void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg) { +#if HAS_SCREEN nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg); +#endif } void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) diff --git a/src/platform/stm32wl/LittleFS.cpp b/src/platform/stm32wl/LittleFS.cpp index 40f32eca8..4b57fb151 100644 --- a/src/platform/stm32wl/LittleFS.cpp +++ b/src/platform/stm32wl/LittleFS.cpp @@ -25,23 +25,27 @@ #include "LittleFS.h" #include "stm32wlxx_hal_flash.h" -/********************************************************************************************************************** - * Macro definitions - **********************************************************************************************************************/ -/** This macro is used to suppress compiler messages about a parameter not being used in a function. */ +/** Suppress unused-parameter warnings. */ #define LFS_UNUSED(p) (void)((p)) -#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) +#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) // physical flash erase granularity: 2048 B #define STM32WL_PAGE_COUNT (FLASH_PAGE_NB) #define STM32WL_FLASH_BASE (FLASH_BASE) /* - * FLASH_SIZE from stm32wle5xx.h will read the actual FLASH size from the chip. - * FLASH_END_ADDR is calculated from FLASH_SIZE. - * Use the last 28 KiB of the FLASH + * LFS tunables — all of these are stored in the LFS superblock. + * Changing ANY of them is incompatible with the existing on-disk format; + * the filesystem will be detected as corrupted and reformatted on first boot. + * + * LFS_FLASH_TOTAL_SIZE and LFS_BLOCK_SIZE are the only values to edit here. + * All other parameters are derived. + * + * FLASH_END_ADDR is computed from FLASH_SIZE (read from the chip at link time). */ -#define LFS_FLASH_TOTAL_SIZE (14 * 2048) /* needs to be a multiple of LFS_BLOCK_SIZE */ -#define LFS_BLOCK_SIZE (2048) +#define LFS_FLASH_TOTAL_SIZE \ + (7 * STM32WL_PAGE_SIZE) /* 14 KiB — last 7 physical pages (FORMAT BREAK: reduced from 10 pages / 20 KiB) */ +#define LFS_BLOCK_SIZE (256) /* virtual block size (FORMAT BREAK if changed) */ + #define LFS_FLASH_ADDR_END (FLASH_END_ADDR) #define LFS_FLASH_ADDR_BASE (LFS_FLASH_ADDR_END - LFS_FLASH_TOTAL_SIZE + 1) @@ -51,6 +55,80 @@ #define _LFS_DBG(fmt, ...) printf("%s:%d (%s): " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__) #endif +//--------------------------------------------------------------------+ +// Write-behind page cache +// +// LFS requires block_size == erase granularity, but the STM32WL flash +// erases in 2048-byte pages. To use smaller virtual LFS blocks we +// maintain a single-page RAM cache: the LFS erase/prog callbacks only +// update this buffer; the physical erase+reprogram is deferred to +// _internal_flash_sync() (or triggered automatically when a different +// physical page is addressed). +// +// This mirrors the approach used by the NRF52 Adafruit driver +// (flash_cache.c / flash_nrf5x.c) but adapted for the 2048-byte STM32WL +// page size and HAL doubleword-program requirement. +//--------------------------------------------------------------------+ + +alignas(8) static uint8_t _page_cache[STM32WL_PAGE_SIZE]; +static uint32_t _page_cache_addr = UINT32_MAX; // UINT32_MAX = no page cached +static bool _page_cache_dirty = false; + +/** Flush the cached page to flash (physical erase + doubleword program). */ +static int _flash_cache_flush(void) +{ + if (!_page_cache_dirty) + return LFS_ERR_OK; + + FLASH_EraseInitTypeDef erase = { + .TypeErase = FLASH_TYPEERASE_PAGES, + .Page = (_page_cache_addr - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE, + .NbPages = 1, + }; + uint32_t page_error = 0; + + if (HAL_FLASH_Unlock() != HAL_OK) + return LFS_ERR_IO; + __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS); + + HAL_StatusTypeDef rc = HAL_FLASHEx_Erase(&erase, &page_error); + if (rc == HAL_OK) { + const uint64_t *p = (const uint64_t *)_page_cache; + uint32_t addr = _page_cache_addr; + for (size_t i = 0; i < STM32WL_PAGE_SIZE / 8; i++) { + rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr, *p++); + if (rc != HAL_OK) + break; + addr += 8; + } + } + HAL_StatusTypeDef lock_rc = HAL_FLASH_Lock(); + if (rc == HAL_OK) + rc = lock_rc; + + if (rc == HAL_OK) + _page_cache_dirty = false; + return rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; +} + +/** + * Ensure the physical page containing `page_addr` is loaded into the cache. + * If a different dirty page is already cached it is flushed first. + */ +static int _flash_cache_load(uint32_t page_addr) +{ + if (_page_cache_addr == page_addr) + return LFS_ERR_OK; // already cached + + int rc = _flash_cache_flush(); + if (rc != LFS_ERR_OK) + return rc; + + memcpy(_page_cache, (const void *)page_addr, STM32WL_PAGE_SIZE); + _page_cache_addr = page_addr; + return LFS_ERR_OK; +} + //--------------------------------------------------------------------+ // LFS Disk IO //--------------------------------------------------------------------+ @@ -59,111 +137,82 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l { LFS_UNUSED(c); - if (!buffer || !size) { - _LFS_DBG("%s Invalid parameter!\r\n", __func__); - return LFS_ERR_INVAL; - } + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off); - - memcpy(buffer, (void *)address, size); + if (_page_cache_addr == page_addr) + memcpy(buffer, _page_cache + page_off, size); + else + memcpy(buffer, (const void *)addr, size); return LFS_ERR_OK; } -// Program a region in a block. The block must have previously -// been erased. Negative error codes are propogated to the user. -// May return LFS_ERR_CORRUPT if the block should be considered bad. +// Program a region in a block. The block must have previously been erased. +// Writes are accumulated in the page cache and flushed on sync or page eviction. static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off); - HAL_StatusTypeDef hal_rc = HAL_OK; - uint32_t dw_count = size / 8; - uint64_t *bufp = (uint64_t *)buffer; - LFS_UNUSED(c); - _LFS_DBG("Programming %d bytes/%d doublewords at address 0x%08x/block %d, offset %d.", size, dw_count, address, block, off); - if (HAL_FLASH_Unlock() != HAL_OK) { - return LFS_ERR_IO; - } - for (uint32_t i = 0; i < dw_count; i++) { - if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) { - _LFS_DBG("Wanted to program out of bound of FLASH: 0x%08x.\n", address); - HAL_FLASH_Lock(); - return LFS_ERR_INVAL; - } - hal_rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, address, *bufp); - if (hal_rc != HAL_OK) { - /* Error occurred while writing data in Flash memory. - * User can add here some code to deal with this error. - */ - _LFS_DBG("Program error at (0x%08x), 0x%X, error: 0x%08x\n", address, hal_rc, HAL_FLASH_GetError()); - } - address += 8; - bufp += 1; - } - if (HAL_FLASH_Lock() != HAL_OK) { - return LFS_ERR_IO; - } + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO + int rc = _flash_cache_load(page_addr); + if (rc != LFS_ERR_OK) + return rc; + + memcpy(_page_cache + page_off, buffer, size); + _page_cache_dirty = true; + return LFS_ERR_OK; } -// Erase a block. A block must be erased before being programmed. -// The state of an erased block is undefined. Negative error codes -// are propogated to the user. -// May return LFS_ERR_CORRUPT if the block should be considered bad. +// Erase a virtual block. Marks the corresponding region in the page cache as 0xFF. +// Physical erase of the containing page is deferred until sync or page eviction. static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) { - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE); - HAL_StatusTypeDef hal_rc; - FLASH_EraseInitTypeDef EraseInitStruct = {.TypeErase = FLASH_TYPEERASE_PAGES, .Page = 0, .NbPages = 1}; - uint32_t PAGEError = 0; - LFS_UNUSED(c); - if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) { - _LFS_DBG("Wanted to erase out of bound of FLASH: 0x%08x.\n", address); - return LFS_ERR_INVAL; - } - /* calculate the absolute page, i.e. what the ST wants */ - EraseInitStruct.Page = (address - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE; - _LFS_DBG("Erasing block %d at 0x%08x... ", block, address); - HAL_FLASH_Unlock(); - hal_rc = HAL_FLASHEx_Erase(&EraseInitStruct, &PAGEError); - HAL_FLASH_Lock(); + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO + int rc = _flash_cache_load(page_addr); + if (rc != LFS_ERR_OK) + return rc; + + memset(_page_cache + page_off, 0xFF, LFS_BLOCK_SIZE); + _page_cache_dirty = true; + return LFS_ERR_OK; } -// Sync the state of the underlying block device. Negative error codes -// are propogated to the user. +// Flush the write-behind cache to flash. static int _internal_flash_sync(const struct lfs_config *c) { LFS_UNUSED(c); - // write function performs no caching. No need for sync. - - return LFS_ERR_OK; + return _flash_cache_flush(); } -static struct lfs_config _InternalFSConfig = {.context = NULL, +static struct lfs_config _InternalFSConfig = { + .context = NULL, - .read = _internal_flash_read, - .prog = _internal_flash_prog, - .erase = _internal_flash_erase, - .sync = _internal_flash_sync, + .read = _internal_flash_read, + .prog = _internal_flash_prog, + .erase = _internal_flash_erase, + .sync = _internal_flash_sync, - .read_size = LFS_BLOCK_SIZE, - .prog_size = LFS_BLOCK_SIZE, - .block_size = LFS_BLOCK_SIZE, - .block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE, - .lookahead = 128, + .read_size = LFS_BLOCK_SIZE, + .prog_size = LFS_BLOCK_SIZE, + .block_size = LFS_BLOCK_SIZE, + .block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE, + .lookahead = 128, - .read_buffer = NULL, - .prog_buffer = NULL, - .lookahead_buffer = NULL, - .file_buffer = NULL}; + .read_buffer = NULL, + .prog_buffer = NULL, + .lookahead_buffer = NULL, + .file_buffer = NULL, +}; LittleFS InternalFS; @@ -179,17 +228,17 @@ bool LittleFS::begin(void) /* There is not enough space on this device for a filesystem. */ return false; } - // failed to mount, erase all pages then format and mount again + // failed to mount, erase all virtual blocks then format and mount again if (!STM32_LittleFS::begin()) { - // Erase all pages of internal flash region for Filesystem. - for (uint32_t addr = LFS_FLASH_ADDR_BASE; addr < (LFS_FLASH_ADDR_END + 1); addr += STM32WL_PAGE_SIZE) { - _internal_flash_erase(&_InternalFSConfig, (addr - LFS_FLASH_ADDR_BASE) / STM32WL_PAGE_SIZE); + for (lfs_block_t block = 0; block < _InternalFSConfig.block_count; block++) { + _internal_flash_erase(&_InternalFSConfig, block); } + _flash_cache_flush(); // flush the last cached page // lfs format this->format(); - // mount again if still failed, give up + // mount again; if still failed, give up if (!STM32_LittleFS::begin()) return false; } diff --git a/src/platform/stm32wl/STM32_LittleFS_File.cpp b/src/platform/stm32wl/STM32_LittleFS_File.cpp index 349187a02..a85a3ee02 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.cpp +++ b/src/platform/stm32wl/STM32_LittleFS_File.cpp @@ -22,6 +22,7 @@ * THE SOFTWARE. */ +#include "LittleFS.h" #include "STM32_LittleFS.h" #include @@ -391,3 +392,8 @@ void File::rewindDirectory(void) } _fs->_unlockFS(); } + +// Default constructor — binds to the global InternalFS instance. +// Allows File to be declared without an explicit filesystem argument, +// matching the API of ESP32/RP2040/Portduino File objects. +File::File() : File(InternalFS) {} diff --git a/src/platform/stm32wl/STM32_LittleFS_File.h b/src/platform/stm32wl/STM32_LittleFS_File.h index 2b48b02e0..71b98352c 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.h +++ b/src/platform/stm32wl/STM32_LittleFS_File.h @@ -44,6 +44,7 @@ class File : public Stream public: explicit File(STM32_LittleFS &fs); File(char const *filename, uint8_t mode, STM32_LittleFS &fs); + File(); // default-constructs against InternalFS; defined in STM32_LittleFS_File.cpp public: bool open(char const *filename, uint8_t mode); diff --git a/src/xmodem.h b/src/xmodem.h index 4cfcb43e1..7b665e0ac 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -61,11 +61,7 @@ class XModemAdapter uint16_t packetno = 0; -#if defined(ARCH_NRF52) || defined(ARCH_STM32WL) - File file = File(FSCom); -#else File file; -#endif char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0}; diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini index b4c0c958f..cb980db10 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini +++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini @@ -1,7 +1,7 @@ [env:CDEBYTE_E77-MBL] extends = stm32_base board = ebyte_e77_dev -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) board_level = extra build_flags = ${stm32_base.build_flags} diff --git a/variants/stm32/milesight_gs301/platformio.ini b/variants/stm32/milesight_gs301/platformio.ini index 73b9cf7ea..8bc063a91 100644 --- a/variants/stm32/milesight_gs301/platformio.ini +++ b/variants/stm32/milesight_gs301/platformio.ini @@ -4,7 +4,7 @@ extends = stm32_base board = wiscore_rak3172 ; Convenient choice as the same USART is used for programming/debug board_level = extra -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/milesight_gs301 diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index 4d96e98f9..de8f2b74b 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -2,7 +2,7 @@ extends = stm32_base board = wiscore_rak3172 board_level = pr -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/rak3172 diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 73cf7f81a..57f73f6d0 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -8,7 +8,7 @@ extends = stm32_base board = wiscore_rak3172 board_level = extra -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/russell diff --git a/variants/stm32/wio-e5/platformio.ini b/variants/stm32/wio-e5/platformio.ini index c8dbb2b72..8c7579aa3 100644 --- a/variants/stm32/wio-e5/platformio.ini +++ b/variants/stm32/wio-e5/platformio.ini @@ -2,7 +2,7 @@ extends = stm32_base board = lora_e5_dev_board board_level = pr -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/wio-e5 From 90744ee0b767b354b7194f05b570ad4ca154f25d Mon Sep 17 00:00:00 2001 From: Jason P Date: Fri, 1 May 2026 08:46:53 -0500 Subject: [PATCH 42/52] Update PhoneAPI.cpp to reduce chattiness (#10367) --- src/mesh/PhoneAPI.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 2a3a2a26c..9ff27d33e 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -512,11 +512,6 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard, // nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name); - // Occasional progress logging. (readIndex==2 will be true for the first non-us node) - if (readIndex == 2 || readIndex % 20 == 0) { - LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); - } - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag; fromRadioScratch.node_info = infoToSend; prefetchNodeInfos(); @@ -647,9 +642,11 @@ void PhoneAPI::releaseQueueStatusPhonePacket() void PhoneAPI::prefetchNodeInfos() { bool added = false; + bool wasEmpty = false; // Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment. { concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = nodeInfoQueue.empty(); while (nodeInfoQueue.size() < kNodePrefetchDepth) { auto nextNode = nodeDB->readNextMeshNode(readIndex); if (!nextNode) @@ -663,11 +660,15 @@ void PhoneAPI::prefetchNodeInfos() info.via_mqtt = isUs ? false : info.via_mqtt; info.is_favorite = info.is_favorite || isUs; nodeInfoQueue.push_back(info); + // Log progress here (at fetch time) so readIndex is accurate and each value logs only once. + if (readIndex == 2 || readIndex % 20 == 0) { + LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); + } added = true; } } - if (added) + if (added && wasEmpty) onNowHasData(0); } From 55f40ecdfdd3d41a5a19d7ed4c787efbca86fedb Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 1 May 2026 08:56:49 -0500 Subject: [PATCH 43/52] Add ulfius webserver support to macos native target (#10366) * Add ulfius webserver support to macos native target * fix: update PiWebServer docs for macOS and add explicit cstring include Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/3ce82582-23e0-4afe-b22f-b24f81721488 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add --cflags to openssl@3 pkg-config and fix apt package name Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/1a6c59aa-4393-4134-8cee-61eeee0e9127 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/raspihttp/PiWebServer.cpp | 35 +++++++++++++++------ src/mesh/raspihttp/PiWebServer.h | 6 +++- src/platform/portduino/PortduinoGlue.cpp | 40 ++++++++++++++++++++++-- variants/native/portduino/platformio.ini | 38 +++++++++++++++++++++- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 3e9dbe8c2..5485f8eb2 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -1,22 +1,34 @@ /* -Adds a WebServer and WebService callbacks to meshtastic as Linux Version. The WebServer & Webservices -runs in a real linux thread beside the portdunio threading emulation. It replaces the complete ESP32 -Webserver libs including generation of SSL certifcicates, because the use ESP specific details in -the lib that can't be emulated. +Adds a WebServer and WebService callbacks to meshtastic via the Portduino/native target (Linux and +macOS). The WebServer & Webservices run in a real host thread beside the Portduino threading +emulation. It replaces the complete ESP32 Webserver libs including generation of SSL certificates, +because those libs use ESP-specific details that can't be emulated. The WebServices adapt to the two major phoneapi functions "handleAPIv1FromRadio,handleAPIv1ToRadio" -The WebServer just adds basaic support to deliver WebContent, so it can be used to -deliver the WebGui definded by the WebClient Project. +The WebServer just adds basic support to deliver WebContent, so it can be used to +deliver the WebGui defined by the WebClient Project. Steps to get it running: -1.) Add these Linux Libs to the compile and target machine: + +Linux (apt): +1.) Add these libs to the compile and target machine: sudo apt update && \ - apt -y install openssl libssl-dev libopenssl libsdl2-dev \ + apt -y install openssl libssl-dev libsdl2-dev \ libulfius-dev liborcania-dev +macOS (Homebrew): +1.) Install prerequisites via Homebrew: + + brew install ulfius openssl@3 + + The PlatformIO env (native-macos) picks up compiler/linker flags via + `pkg-config`. In particular, OpenSSL needs `pkg-config --cflags --libs openssl@3` + so both the Homebrew include path and linker flags are provided; ulfius and its + dependencies (liborcania, libyder) are also resolved via `pkg-config`. + 2.) Configure the root directory of the web Content in the config.yaml file. - The followinng tags should be included and set at your needs + The following tags should be included and set at your needs Example entry in the config.yaml Webserver: @@ -34,7 +46,10 @@ Author: Marc Philipp Hammermann mail: marchammermann@googlemail.com */ -#ifdef PORTDUINO_LINUX_HARDWARE +// Mirrors the guard in PiWebServer.h — see comment there. macOS Homebrew +// provides ulfius + deps; Linux pulls them via apt. Either way, this +// translation unit only compiles when the headers are present. +#ifdef ARCH_PORTDUINO #if __has_include() #include "PiWebServer.h" #include "NodeDB.h" diff --git a/src/mesh/raspihttp/PiWebServer.h b/src/mesh/raspihttp/PiWebServer.h index 74b094f8c..24b7de4b1 100644 --- a/src/mesh/raspihttp/PiWebServer.h +++ b/src/mesh/raspihttp/PiWebServer.h @@ -1,5 +1,9 @@ #pragma once -#ifdef PORTDUINO_LINUX_HARDWARE +// Portduino webserver is built whenever the ulfius headers are reachable, +// not only on Linux. macOS users can `brew install ulfius` to enable it; +// without ulfius the entire body is skipped and main.cpp's matching +// __has_include guard avoids referencing the type. +#ifdef ARCH_PORTDUINO #if __has_include() #include "PhoneAPI.h" #include "ulfius-cfg.h" diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index eeb56240d..1f59e78b5 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -33,6 +33,16 @@ #include #endif +#ifdef __APPLE__ +// Used by getMacAddr()'s macOS fallback to read the en0 link-layer address. +// `getifaddrs()` is the BSD-portable way; `` provides the +// `sockaddr_dl` cast and the `LLADDR()` macro that points at the 6-byte MAC. +#include // strcmp, memcpy +#include +#include +#include +#endif + #include "platform/portduino/USBHal.h" portduino_config_struct portduino_config; @@ -156,9 +166,35 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#elif defined(__APPLE__) + // No BlueZ on macOS, but we can fall back to the host's primary + // network interface MAC. `en0` is Wi-Fi on every shipping Mac + // (Ethernet, when present, is en1 or higher), which gives the user + // the same kind of stable, host-derived identifier that the BlueZ + // path provides on Linux. If en0 isn't found or has no MAC, dmac is + // left untouched and the caller's "Blank MAC Address not allowed!" + // check will still fire — preserving existing behavior for users + // who deliberately rely on --hwid or YAML override. + struct ifaddrs *ifap = nullptr; + if (getifaddrs(&ifap) == 0) { + for (struct ifaddrs *p = ifap; p != nullptr; p = p->ifa_next) { + if (p->ifa_addr == nullptr || p->ifa_addr->sa_family != AF_LINK) { + continue; + } + if (strcmp(p->ifa_name, "en0") != 0) { + continue; + } + auto *sdl = reinterpret_cast(p->ifa_addr); + if (sdl->sdl_alen == 6) { + memcpy(dmac, LLADDR(sdl), 6); + break; + } + } + freeifaddrs(ifap); + } #else - // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; - // the caller can override via the --hwid CLI flag or the YAML config. + // No platform-specific MAC source; leave dmac at its default. Caller + // can override via the --hwid CLI flag or the YAML config. (void)dmac; #endif } diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index e493da77b..0a47e7283 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -125,6 +125,8 @@ test_testing_command = ; ; Prerequisites (Homebrew): ; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; # Optional: enable the HTTP API (PiWebServer) on macOS: +; brew install ulfius ; ; The macOS-side patches now live upstream: ; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, @@ -191,7 +193,16 @@ build_flags = ${portduino_base.build_flags_common} ; style screen-driver hooks scattered through sensor sources. -DHAS_SCREEN=0 -DMESHTASTIC_EXCLUDE_SCREEN=1 - !pkg-config --libs openssl --silence-errors || : + ; openssl@3 is the keg-only Homebrew formula; --cflags is required so the + ; compiler finds in the Homebrew prefix (not just the linker). + !pkg-config --cflags --libs openssl@3 --silence-errors || : + ; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius + ; headers are reachable via `#if __has_include()`. The `|| :` + ; tail keeps the build green when the user hasn't run `brew install ulfius` + ; — they just don't get the HTTP API in that case. + !pkg-config --cflags --libs libulfius --silence-errors || : + !pkg-config --cflags --libs liborcania --silence-errors || : + !pkg-config --cflags --libs libyder --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX ; (which we lib_ignore on macOS for the issue). Neither is needed @@ -206,3 +217,28 @@ build_src_filter = ${native_base.build_src_filter} lib_ignore = ${portduino_base.lib_ignore} LovyanGFX + +; --------------------------------------------------------------------------- +; Same as [env:native-macos] but built with AddressSanitizer for catching +; use-after-free, leaks, and OOB access during local development. Headless +; (no SDL/X11/libinput) so it stays cheap to build. Mirrors the shape of +; [env:native-tft-debug] but without the TFT/X11 dependencies. +; +; pio run -e native-macos-debug +; .pio/build/native-macos-debug/meshtasticd -s +; +; ASan runtime tuning (set in the shell before launching): +; ASAN_OPTIONS=detect_leaks=1:halt_on_error=0:abort_on_error=1 +; MallocStackLogging=1 # macOS: nicer stack traces in malloc reports +; --------------------------------------------------------------------------- +[env:native-macos-debug] +extends = native_base +build_type = debug +build_unflags = ${env:native-macos.build_unflags} +build_flags = ${env:native-macos.build_flags} + -O0 + -g + -fsanitize=address + -fno-omit-frame-pointer +build_src_filter = ${env:native-macos.build_src_filter} +lib_ignore = ${env:native-macos.lib_ignore} From c0fcf807c068dbbdbfd2005b28cb23f8990e71b6 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 1 May 2026 10:42:17 -0400 Subject: [PATCH 44/52] MacOS: Correct pkg-config name `openssl` for ulfius. (#10369) --- .github/workflows/build_macos_bin.yml | 2 +- variants/native/portduino/platformio.ini | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml index cde2dd816..d0e89d7da 100644 --- a/.github/workflows/build_macos_bin.yml +++ b/.github/workflows/build_macos_bin.yml @@ -24,7 +24,7 @@ jobs: shell: bash run: | brew update - brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config + brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius - name: Get release version string run: | diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 0a47e7283..6d1bd02f3 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -195,14 +195,12 @@ build_flags = ${portduino_base.build_flags_common} -DMESHTASTIC_EXCLUDE_SCREEN=1 ; openssl@3 is the keg-only Homebrew formula; --cflags is required so the ; compiler finds in the Homebrew prefix (not just the linker). - !pkg-config --cflags --libs openssl@3 --silence-errors || : + !pkg-config --cflags --libs openssl --silence-errors || : ; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius ; headers are reachable via `#if __has_include()`. The `|| :` ; tail keeps the build green when the user hasn't run `brew install ulfius` ; — they just don't get the HTTP API in that case. !pkg-config --cflags --libs libulfius --silence-errors || : - !pkg-config --cflags --libs liborcania --silence-errors || : - !pkg-config --cflags --libs libyder --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX ; (which we lib_ignore on macOS for the issue). Neither is needed From 0240a00d0934e021dea8fab56630a67b41676284 Mon Sep 17 00:00:00 2001 From: Austin Lane Date: Fri, 1 May 2026 10:55:32 -0400 Subject: [PATCH 45/52] MacOS: Re-Add Orcania/Yder --- variants/native/portduino/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 6d1bd02f3..d334a1901 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -200,6 +200,8 @@ build_flags = ${portduino_base.build_flags_common} ; headers are reachable via `#if __has_include()`. The `|| :` ; tail keeps the build green when the user hasn't run `brew install ulfius` ; — they just don't get the HTTP API in that case. + !pkg-config --cflags --libs liborcania --silence-errors || : + !pkg-config --cflags --libs libyder --silence-errors || : !pkg-config --cflags --libs libulfius --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX From b7a9555905cf9bc22ef01028e108ab9fbf5a1a01 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 1 May 2026 12:50:07 -0500 Subject: [PATCH 46/52] Update RadioLib dependency to a specific commit (#10370) Exploratory PR to test new radiolib change --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 195879bff..8a3129075 100644 --- a/platformio.ini +++ b/platformio.ini @@ -120,7 +120,7 @@ lib_deps = [radiolib_base] lib_deps = # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib - https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip + https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip [device-ui_base] lib_deps = From 7cb071c780d7cb4290d7b39ac1a399dc62437901 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 18:25:08 -0400 Subject: [PATCH 47/52] Update platform-native digest to cab4b21 (#10372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 35c8c6697..6ff3d0686 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip + https://github.com/meshtastic/platform-native/archive/cab4b21d902973e43c938dab3cf4844ba02547ec.zip framework = arduino build_src_filter = From 2d761f645349d7bf9a6a4f22e20569330b1d0a88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 07:23:49 -0500 Subject: [PATCH 48/52] Upgrade trunk (#10364) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 1913c6604..d2ccc60a4 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,7 +8,7 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.525 + - checkov@3.2.526 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 From 41f53177a12f3370059477ab41fd8ba3f1c17722 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 2 May 2026 08:25:24 -0500 Subject: [PATCH 49/52] Use OBS instead of launchpad (#10375) --- .github/workflows/build_debian_src.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index d1bcd8898..8d2076b11 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -32,10 +32,15 @@ jobs: shell: bash working-directory: meshtasticd run: | + # Build-tools (notably platformio) come from the Meshtastic project + # on the OpenSUSE Build Service: + # https://build.opensuse.org/project/show/network:Meshtastic:build-tools + echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \ + | sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list + curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null sudo apt-get update -y --fix-missing - sudo apt-get install -y software-properties-common build-essential devscripts equivs - sudo add-apt-repository ppa:meshtastic/build-tools -y - sudo apt-get update -y --fix-missing + sudo apt-get install -y build-essential devscripts equivs sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control - name: Import GPG key From b2bda3b07ada98a543284abd46a179725b04df5a Mon Sep 17 00:00:00 2001 From: Jord <650645+Jord-JD@users.noreply.github.com> Date: Sat, 2 May 2026 22:38:06 +0100 Subject: [PATCH 50/52] Enable MESHTASTIC_PREHOP_DROP by default (#9924) --- src/configuration.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configuration.h b/src/configuration.h index e0284e6c9..d263d9ae1 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -80,7 +80,7 @@ along with this program. If not, see . // Pre-hop drop handling (compile-time flag). #ifndef MESHTASTIC_PREHOP_DROP -#define MESHTASTIC_PREHOP_DROP 0 +#define MESHTASTIC_PREHOP_DROP 1 #endif /// Convert a preprocessor name into a quoted string From 6ea0d5ebbaa6580b16ab8e5ba2109430901ac162 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 4 May 2026 16:19:48 -0500 Subject: [PATCH 51/52] Add TFT_BACKLIGHT_ON for cardputer to fix builds (#10387) --- variants/esp32s3/m5stack_cardputer_adv/variant.h | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.h b/variants/esp32s3/m5stack_cardputer_adv/variant.h index 5fdb1436e..48437cd13 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.h +++ b/variants/esp32s3/m5stack_cardputer_adv/variant.h @@ -9,6 +9,7 @@ #define ST7789_BUSY -1 // #define VTFT_CTRL 38 #define VTFT_LEDA 38 +#define TFT_BACKLIGHT_ON HIGH // #define ST7789_BL (32+6) #define ST7789_SPI_HOST SPI2_HOST // #define TFT_BL (32+6) From fcef46f4b0591efa1e957a8df15c4c277521a4d7 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:07 +0100 Subject: [PATCH 52/52] dependency swap - INA3221Sensor (#10379) * dependency swap - INA3221Sensor update INA3221 initialization and measurement methods for compatibility with Rob Tillaart's library Co-authored-by: Copilot * Addresses copilot review Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refine comments on USB detection and INA3221 Updated comments regarding USB detection and INA3221 usage. * Fix static_assert conditions for INA3221 channel definitions Co-authored-by: Copilot * moved macro defines earlier to allow better use. Co-authored-by: Copilot * Add raw register read methods for bus voltage and shunt current in INA3221Sensor Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- platformio.ini | 4 +- .../Telemetry/Sensor/INA3221Sensor.cpp | 67 ++++++++++++++----- src/modules/Telemetry/Sensor/INA3221Sensor.h | 25 +++++-- variants/nrf52840/rak3401_1watt/variant.h | 4 +- variants/nrf52840/rak4631/variant.h | 4 +- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/platformio.ini b/platformio.ini index 8a3129075..95c89a3ef 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,8 +170,8 @@ lib_deps = https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614 https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip - # renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow - https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip + # renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT + https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip # renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip # renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU diff --git a/src/modules/Telemetry/Sensor/INA3221Sensor.cpp b/src/modules/Telemetry/Sensor/INA3221Sensor.cpp index 78081132a..d3b9b16f0 100644 --- a/src/modules/Telemetry/Sensor/INA3221Sensor.cpp +++ b/src/modules/Telemetry/Sensor/INA3221Sensor.cpp @@ -16,10 +16,28 @@ int32_t INA3221Sensor::runOnce() return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; } if (!status) { - ina3221.begin(nodeTelemetrySensorsMap[sensorType].second); - ina3221.setShuntRes(100, 100, 100); // 0.1 Ohm shunt resistors - status = true; + // Re-initialise with the address and Wire bus from the telemetry sensors map. + // (Rob Tillaart INA3221_RT takes address + TwoWire*, unlike sgtwilko which took Wire in begin().) + ina3221 = INA3221(nodeTelemetrySensorsMap[sensorType].first, nodeTelemetrySensorsMap[sensorType].second); + status = ina3221.begin(); + if (status) { + // Default all three channels to a 0.1 Ω shunt resistor. + // Override per-variant by defining INA3221_SHUNT_R_CH1/CH2/CH3 (in Ohms) in variant.h. +#ifndef INA3221_SHUNT_R_CH1 +#define INA3221_SHUNT_R_CH1 0.1f +#endif +#ifndef INA3221_SHUNT_R_CH2 +#define INA3221_SHUNT_R_CH2 0.1f +#endif +#ifndef INA3221_SHUNT_R_CH3 +#define INA3221_SHUNT_R_CH3 0.1f +#endif + ina3221.setShuntR(0, INA3221_SHUNT_R_CH1); + ina3221.setShuntR(1, INA3221_SHUNT_R_CH2); + ina3221.setShuntR(2, INA3221_SHUNT_R_CH3); + } } else { + // Already initialised; status stays true and initI2CSensor() returns next poll interval. status = true; } return initI2CSensor(); @@ -27,12 +45,14 @@ int32_t INA3221Sensor::runOnce() void INA3221Sensor::setup() {} -struct _INA3221Measurement INA3221Sensor::getMeasurement(ina3221_ch_t ch) +struct _INA3221Measurement INA3221Sensor::getMeasurement(uint8_t ch) { struct _INA3221Measurement measurement; - measurement.voltage = ina3221.getVoltage(ch); - measurement.current = ina3221.getCurrent(ch); + measurement.voltage = ina3221.getBusVoltage(ch); // Volts + // getCurrent_mA() is used instead of getCurrent() because Rob Tillaart's getCurrent() + // returns Amperes; the telemetry proto and VoltageSensor/CurrentSensor interfaces expect mA. + measurement.current = ina3221.getCurrent_mA(ch); // milliAmps return measurement; } @@ -43,7 +63,7 @@ struct _INA3221Measurements INA3221Sensor::getMeasurements() // INA3221 has 3 channels starting from 0 for (int i = 0; i < 3; i++) { - measurements.measurements[i] = getMeasurement((ina3221_ch_t)i); + measurements.measurements[i] = getMeasurement((uint8_t)i); } return measurements; @@ -87,24 +107,41 @@ bool INA3221Sensor::getPowerMetrics(meshtastic_Telemetry *measurement) measurement->variant.power_metrics.has_ch3_voltage = true; measurement->variant.power_metrics.has_ch3_current = true; - measurement->variant.power_metrics.ch1_voltage = m.measurements[INA3221_CH1].voltage; - measurement->variant.power_metrics.ch1_current = m.measurements[INA3221_CH1].current; - measurement->variant.power_metrics.ch2_voltage = m.measurements[INA3221_CH2].voltage; - measurement->variant.power_metrics.ch2_current = m.measurements[INA3221_CH2].current; - measurement->variant.power_metrics.ch3_voltage = m.measurements[INA3221_CH3].voltage; - measurement->variant.power_metrics.ch3_current = m.measurements[INA3221_CH3].current; + // INA3221 channel indices are zero-based (0=CH1, 1=CH2, 2=CH3). + measurement->variant.power_metrics.ch1_voltage = m.measurements[0].voltage; + measurement->variant.power_metrics.ch1_current = m.measurements[0].current; + measurement->variant.power_metrics.ch2_voltage = m.measurements[1].voltage; + measurement->variant.power_metrics.ch2_current = m.measurements[1].current; + measurement->variant.power_metrics.ch3_voltage = m.measurements[2].voltage; + measurement->variant.power_metrics.ch3_current = m.measurements[2].current; return true; } uint16_t INA3221Sensor::getBusVoltageMv() { - return lround(ina3221.getVoltage(BAT_CH) * 1000); + return lround(ina3221.getBusVoltage_mV(BAT_CH)); } int16_t INA3221Sensor::getCurrentMa() { - return lround(ina3221.getCurrent(BAT_CH)); + return lround(ina3221.getCurrent_mA(BAT_CH)); +} + +// Bus voltage register (0x02 + ch*2): bits [15:3] unsigned, 1 LSB = 8 mV (datasheet p.6). +// Voltage raw units: 1 count = 8 mV, so V_mV = raw * 8. +int16_t INA3221Sensor::getRawBusVoltage(uint8_t ch) +{ + return (int16_t)(ina3221.getRegister(0x02 + ch * 2) >> 3); +} + +// Shunt voltage register (0x01 + ch*2): bits [15:3] signed two's complement, 1 LSB = 40 µV (datasheet p.6). +// Current raw units are shunt-voltage counts: 1 count = 40 uV, signed. +// I_mA = (raw * 40 uV) / R_mOhm, because uV / mOhm = mA. +// Example for 100 mOhm shunt: I_mA = raw * 40 / 100 = raw * 0.4. +int16_t INA3221Sensor::getRawShuntCurrent(uint8_t ch) +{ + return (int16_t)(ina3221.getRegister(0x01 + ch * 2) >> 3); } #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/INA3221Sensor.h b/src/modules/Telemetry/Sensor/INA3221Sensor.h index 0581f92f6..bb2dbe7b3 100644 --- a/src/modules/Telemetry/Sensor/INA3221Sensor.h +++ b/src/modules/Telemetry/Sensor/INA3221Sensor.h @@ -1,3 +1,9 @@ +// INA3221 channel aliases (zero-based: 0 = CH1, 1 = CH2, 2 = CH3). +// Defined before configuration.h so variant.h can use them in INA3221_ENV_CH / INA3221_BAT_CH. +#define INA3221_CH1 0 +#define INA3221_CH2 1 +#define INA3221_CH3 2 + #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() @@ -9,26 +15,29 @@ #include #ifndef INA3221_ENV_CH -#define INA3221_ENV_CH INA3221_CH1 +#define INA3221_ENV_CH INA3221_CH1 // channel to report in environment metrics (default: CH1) #endif #ifndef INA3221_BAT_CH -#define INA3221_BAT_CH INA3221_CH1 +#define INA3221_BAT_CH INA3221_CH1 // channel for device_battery_ina_address (default: CH1) #endif class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor { private: - INA3221 ina3221 = INA3221(INA3221_ADDR42_SDA); + // Placeholder constructor; re-initialised with correct address and Wire in runOnce(). + INA3221 ina3221 = INA3221(INA3221_ADDR); // channel to report voltage/current for environment metrics - static const ina3221_ch_t ENV_CH = INA3221_ENV_CH; + static const uint8_t ENV_CH = INA3221_ENV_CH; + static_assert(INA3221_ENV_CH >= 0 && INA3221_ENV_CH <= 2, "INA3221_ENV_CH must be 0, 1, or 2"); // channel to report battery voltage for device_battery_ina_address - static const ina3221_ch_t BAT_CH = INA3221_BAT_CH; + static const uint8_t BAT_CH = INA3221_BAT_CH; + static_assert(INA3221_BAT_CH >= 0 && INA3221_BAT_CH <= 2, "INA3221_BAT_CH must be 0, 1, or 2"); // get a single measurement for a channel - struct _INA3221Measurement getMeasurement(ina3221_ch_t ch); + struct _INA3221Measurement getMeasurement(uint8_t ch); // get all measurements for all channels struct _INA3221Measurements getMeasurements(); @@ -45,6 +54,10 @@ class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor bool getMetrics(meshtastic_Telemetry *measurement) override; virtual uint16_t getBusVoltageMv() override; virtual int16_t getCurrentMa() override; + + // Raw register reads (bits [15:3] right-shifted), no conversion applied. + int16_t getRawBusVoltage(uint8_t ch); + int16_t getRawShuntCurrent(uint8_t ch); }; struct _INA3221Measurement { diff --git a/variants/nrf52840/rak3401_1watt/variant.h b/variants/nrf52840/rak3401_1watt/variant.h index 80b09cf69..a154e6a41 100644 --- a/variants/nrf52840/rak3401_1watt/variant.h +++ b/variants/nrf52840/rak3401_1watt/variant.h @@ -166,7 +166,9 @@ static const uint8_t SCK = PIN_SPI_SCK; // Testing USB detection #define NRF_APM // If using a power chip like the INA3221 you can override the default battery voltage channel below -// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging +// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging. +// INA3221Sensor.h provides INA3221_CH1/INA3221_CH2/INA3221_CH3 compatibility aliases, so +// board variants can continue to use the named channel constants here. // #define INA3221_BAT_CH INA3221_CH2 // #define INA3221_ENV_CH INA3221_CH1 diff --git a/variants/nrf52840/rak4631/variant.h b/variants/nrf52840/rak4631/variant.h index 6a6b32f27..8ea272a15 100644 --- a/variants/nrf52840/rak4631/variant.h +++ b/variants/nrf52840/rak4631/variant.h @@ -220,7 +220,9 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG // Testing USB detection #define NRF_APM // If using a power chip like the INA3221 you can override the default battery voltage channel below -// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging +// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging. +// INA3221Sensor.h provides compatibility aliases such as INA3221_CH1/INA3221_CH2/INA3221_CH3, +// so board variants can continue to use the channel names below. // #define INA3221_BAT_CH INA3221_CH2 // #define INA3221_ENV_CH INA3221_CH1