From 0501e177e9b2591f84c419318e426113f3ad2eed Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:37:34 -0400 Subject: [PATCH 01/50] T-mini Eink S3 Support for both InkHUD and BaseUI (#9856) * Tmini Eink fix * tuning * better refresh * Fix to lora pins to be like the original. * Update pins_arduino.h * removed dead flags from previous tests * Update src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/graphics/EInkDisplay2.cpp | 22 +- src/graphics/EInkDisplay2.h | 6 +- .../niche/Drivers/EInk/GDEW0102T4.cpp | 178 +++++++++++++++ src/graphics/niche/Drivers/EInk/GDEW0102T4.h | 55 +++++ src/graphics/niche/Drivers/EInk/UC8175.cpp | 203 ++++++++++++++++++ src/graphics/niche/Drivers/EInk/UC8175.h | 62 ++++++ .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 10 +- .../InkHUD/Applets/System/Tips/TipsApplet.cpp | 5 + src/graphics/niche/InkHUD/InkHUD.h | 5 +- src/graphics/niche/InkHUD/WindowManager.cpp | 12 +- .../niche/Inputs/TwoButtonExtended.cpp | 25 +++ src/graphics/niche/Inputs/TwoButtonExtended.h | 2 + src/input/UpDownInterruptImpl1.cpp | 10 +- src/sleep.cpp | 14 +- .../esp32s3/mini-epaper-s3/nicheGraphics.h | 131 +++++++++++ .../esp32s3/mini-epaper-s3/pins_arduino.h | 7 +- .../esp32s3/mini-epaper-s3/platformio.ini | 33 ++- variants/esp32s3/mini-epaper-s3/variant.h | 58 ++--- 18 files changed, 771 insertions(+), 67 deletions(-) create mode 100644 src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp create mode 100644 src/graphics/niche/Drivers/EInk/GDEW0102T4.h create mode 100644 src/graphics/niche/Drivers/EInk/UC8175.cpp create mode 100644 src/graphics/niche/Drivers/EInk/UC8175.h create mode 100644 variants/esp32s3/mini-epaper-s3/nicheGraphics.h diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index faf72e06d..12e229da3 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -143,6 +143,10 @@ bool EInkDisplay::connect() #ifdef ELECROW_ThinkNode_M1 // ThinkNode M1 has a hardware dimmable backlight. Start enabled digitalWrite(PIN_EINK_EN, HIGH); +#elif defined(MINI_EPAPER_S3) + // T-Mini Epaper S3 requires panel power rail enabled before SPI transfer. + digitalWrite(PIN_EINK_EN, HIGH); + delay(10); #else digitalWrite(PIN_EINK_EN, LOW); #endif @@ -202,7 +206,8 @@ bool EInkDisplay::connect() } #elif defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || \ - defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) + defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \ + defined(MINI_EPAPER_S3) { // Start HSPI hspi = new SPIClass(HSPI); @@ -216,9 +221,13 @@ bool EInkDisplay::connect() // Init GxEPD2 adafruitDisplay->init(); +#if defined(MINI_EPAPER_S3) + adafruitDisplay->setRotation(3); +#else adafruitDisplay->setRotation(3); #if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) adafruitDisplay->setRotation(0); +#endif #endif } #elif defined(PCA10059) || defined(ME25LS01) @@ -259,17 +268,6 @@ bool EInkDisplay::connect() adafruitDisplay->setRotation(3); adafruitDisplay->setPartialWindow(0, 0, EINK_WIDTH, EINK_HEIGHT); } -#elif defined(MINI_EPAPER_S3) - spi1 = new SPIClass(HSPI); - spi1->begin(PIN_SPI1_SCK, PIN_SPI1_MISO, PIN_SPI1_MOSI, PIN_EINK_CS); - - // Create GxEPD2 objects - auto lowLevel = new EINK_DISPLAY_MODEL(PIN_EINK_CS, PIN_EINK_DC, PIN_EINK_RES, PIN_EINK_BUSY, *spi1); - adafruitDisplay = new GxEPD2_BW(*lowLevel); - - // Init GxEPD2 - adafruitDisplay->init(); - adafruitDisplay->setRotation(1); #elif defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_VISION_MASTER_E213) // Detect display model, before starting SPI diff --git a/src/graphics/EInkDisplay2.h b/src/graphics/EInkDisplay2.h index 14adeda12..7a86b0f57 100644 --- a/src/graphics/EInkDisplay2.h +++ b/src/graphics/EInkDisplay2.h @@ -89,12 +89,12 @@ class EInkDisplay : public OLEDDisplay // If display uses HSPI #if defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E213) || \ defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || defined(CROWPANEL_ESP32S3_5_EPAPER) || \ - defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) + defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) || \ + defined(MINI_EPAPER_S3) SPIClass *hspi = NULL; #endif -#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK) || \ - defined(MINI_EPAPER_S3) +#if defined(HELTEC_MESH_POCKET) || defined(SEEED_WIO_TRACKER_L1_EINK) || defined(HELTEC_MESH_SOLAR_EINK) SPIClass *spi1 = NULL; #endif diff --git a/src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp b/src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp new file mode 100644 index 000000000..a670db0d0 --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/GDEW0102T4.cpp @@ -0,0 +1,178 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "./GDEW0102T4.h" + +#include + +using namespace NicheGraphics::Drivers; + +// LUTs from GxEPD2_102.cpp (GDEW0102T4 / UC8175). +static const uint8_t LUT_W_FULL[] = { + 0x60, 0x5A, 0x5A, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +static const uint8_t LUT_B_FULL[] = { + 0x90, 0x5A, 0x5A, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +static const uint8_t LUT_W_FAST[] = { + 0x60, 0x01, 0x01, 0x00, 0x00, 0x01, // + 0x80, 0x12, 0x00, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +static const uint8_t LUT_B_FAST[] = { + 0x90, 0x01, 0x01, 0x00, 0x00, 0x01, // + 0x40, 0x14, 0x00, 0x00, 0x00, 0x01, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // +}; + +GDEW0102T4::GDEW0102T4() : UC8175(width, height, supported) {} + +void GDEW0102T4::setFastConfig(FastConfig cfg) +{ + // Clamp out only clearly invalid PLL settings. + if (cfg.reg30 < 0x05) + cfg.reg30 = 0x05; + fastConfig = cfg; +} + +GDEW0102T4::FastConfig GDEW0102T4::getFastConfig() const +{ + return fastConfig; +} + +void GDEW0102T4::configCommon() +{ + // Init path aligned with GxEPD2_GDEW0102T4 (UC8175 family). + sendCommand(0xD2); + sendData(0x3F); + + sendCommand(0x00); + sendData(0x6F); + + sendCommand(0x01); + sendData(0x03); + sendData(0x00); + sendData(0x2B); + sendData(0x2B); + + sendCommand(0x06); + sendData(0x3F); + + sendCommand(0x2A); + sendData(0x00); + sendData(0x00); + + sendCommand(0x30); // PLL / drive clock + sendData(0x13); + + sendCommand(0x50); // Last border/data interval; subtle but can affect artifacts + sendData(0x57); + + sendCommand(0x60); + sendData(0x22); + + sendCommand(0x61); + sendData(width); + sendData(height); + + sendCommand(0x82); // VCOM DC setting + sendData(0x12); + + sendCommand(0xE3); + sendData(0x33); +} + +void GDEW0102T4::configFull() +{ + sendCommand(0x23); + sendData(LUT_W_FULL, sizeof(LUT_W_FULL)); + sendCommand(0x24); + sendData(LUT_B_FULL, sizeof(LUT_B_FULL)); + + powerOn(); +} + +void GDEW0102T4::configFast() +{ + uint8_t lutW[sizeof(LUT_W_FAST)]; + uint8_t lutB[sizeof(LUT_B_FAST)]; + memcpy(lutW, LUT_W_FAST, sizeof(LUT_W_FAST)); + memcpy(lutB, LUT_B_FAST, sizeof(LUT_B_FAST)); + + // Second stage duration bytes are the main "darkness vs ghosting" control for this panel. + lutW[7] = fastConfig.lutW2; + lutB[7] = fastConfig.lutB2; + + sendCommand(0x30); + sendData(fastConfig.reg30); + + sendCommand(0x50); + sendData(fastConfig.reg50); + + sendCommand(0x82); + sendData(fastConfig.reg82); + + sendCommand(0x23); + sendData(lutW, sizeof(lutW)); + sendCommand(0x24); + sendData(lutB, sizeof(lutB)); + + powerOn(); +} + +void GDEW0102T4::writeOldImage() +{ + // On this panel, FULL refresh is most reliable when "old image" is all white. + if (updateType == FULL) { + sendCommand(0x10); + // Use buffered writes of 0xFF to avoid per-byte SPI transactions. + const uint16_t chunkSize = 64; + uint8_t ffBuf[chunkSize]; + memset(ffBuf, 0xFF, sizeof(ffBuf)); + + uint32_t remaining = bufferSize; + while (remaining > 0) { + uint16_t toSend = remaining > chunkSize ? chunkSize : static_cast(remaining); + sendData(ffBuf, toSend); + remaining -= toSend; + } + return; + } + + // FAST refresh uses differential data (previous frame as old image). + if (previousBuffer) { + writeImage(0x10, previousBuffer); + } else { + writeImage(0x10, buffer); + } +} + +void GDEW0102T4::finalizeUpdate() +{ + // Keep panel out of deep-sleep between updates for better reliability of repeated FAST refresh. + powerOff(); +} + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/GDEW0102T4.h b/src/graphics/niche/Drivers/EInk/GDEW0102T4.h new file mode 100644 index 000000000..02df8b4fe --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/GDEW0102T4.h @@ -0,0 +1,55 @@ +/* + +E-Ink display driver + - GDEW0102T4 + - Controller: UC8175 + - Size: 1.02 inch + - Resolution: 80px x 128px + +*/ + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./UC8175.h" + +namespace NicheGraphics::Drivers +{ + +class GDEW0102T4 : public UC8175 +{ + private: + static constexpr uint16_t width = 80; + static constexpr uint16_t height = 128; + static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST); + + public: + struct FastConfig { + uint8_t reg30; + uint8_t reg50; + uint8_t reg82; + uint8_t lutW2; + uint8_t lutB2; + }; + + GDEW0102T4(); + void setFastConfig(FastConfig cfg); + FastConfig getFastConfig() const; + + protected: + void configCommon() override; + void configFull() override; + void configFast() override; + void writeOldImage() override; + void finalizeUpdate() override; + + private: + FastConfig fastConfig = {0x13, 0xF2, 0x12, 0x0E, 0x14}; +}; + +} // namespace NicheGraphics::Drivers + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/UC8175.cpp b/src/graphics/niche/Drivers/EInk/UC8175.cpp new file mode 100644 index 000000000..576b645bd --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/UC8175.cpp @@ -0,0 +1,203 @@ +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "./UC8175.h" + +#include + +#include "SPILock.h" + +using namespace NicheGraphics::Drivers; + +UC8175::UC8175(uint16_t width, uint16_t height, UpdateTypes supported) : EInk(width, height, supported) +{ + bufferRowSize = ((width - 1) / 8) + 1; + bufferSize = bufferRowSize * height; +} + +void UC8175::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst) +{ + this->spi = spi; + this->pin_dc = pin_dc; + this->pin_cs = pin_cs; + this->pin_busy = pin_busy; + this->pin_rst = pin_rst; + + pinMode(pin_dc, OUTPUT); + pinMode(pin_cs, OUTPUT); + pinMode(pin_busy, INPUT); + + // Reset is active LOW, hold HIGH when idle. + if (pin_rst != (uint8_t)-1) { + pinMode(pin_rst, OUTPUT); + digitalWrite(pin_rst, HIGH); + } + + if (!previousBuffer) { + previousBuffer = new uint8_t[bufferSize]; + if (previousBuffer) + memset(previousBuffer, 0xFF, bufferSize); + } +} + +void UC8175::update(uint8_t *imageData, UpdateTypes type) +{ + buffer = imageData; + updateType = (type == UpdateTypes::UNSPECIFIED) ? UpdateTypes::FULL : type; + + if (updateType == FAST && hasPreviousBuffer && previousBuffer && memcmp(previousBuffer, buffer, bufferSize) == 0) + return; + + reset(); + configCommon(); + + if (updateType == FAST) + configFast(); + else + configFull(); + + writeOldImage(); + writeNewImage(); + sendCommand(0x12); // Display refresh. + + if (previousBuffer) { + memcpy(previousBuffer, buffer, bufferSize); + hasPreviousBuffer = true; + } + + detachFromUpdate(); +} + +void UC8175::wait(uint32_t timeoutMs) +{ + if (failed) + return; + + uint32_t started = millis(); + while (digitalRead(pin_busy) == BUSY_ACTIVE) { + if ((millis() - started) > timeoutMs) { + failed = true; + break; + } + yield(); + } +} + +void UC8175::reset() +{ + if (pin_rst != (uint8_t)-1) { + digitalWrite(pin_rst, LOW); + delay(20); + digitalWrite(pin_rst, HIGH); + delay(20); + } else { + sendCommand(0x12); // Software reset. + delay(10); + } + + wait(3000); +} + +void UC8175::sendCommand(uint8_t command) +{ + if (failed) + return; + + spiLock->lock(); + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, LOW); + digitalWrite(pin_cs, LOW); + spi->transfer(command); + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); + spiLock->unlock(); +} + +void UC8175::sendData(uint8_t data) +{ + sendData(&data, 1); +} + +void UC8175::sendData(const uint8_t *data, uint32_t size) +{ + if (failed) + return; + + spiLock->lock(); + spi->beginTransaction(spiSettings); + digitalWrite(pin_dc, HIGH); + digitalWrite(pin_cs, LOW); + +#if defined(ARCH_ESP32) + spi->transferBytes(data, NULL, size); +#elif defined(ARCH_NRF52) + spi->transfer(data, NULL, size); +#else + for (uint32_t i = 0; i < size; ++i) + spi->transfer(data[i]); +#endif + + digitalWrite(pin_cs, HIGH); + digitalWrite(pin_dc, HIGH); + spi->endTransaction(); + spiLock->unlock(); +} + +void UC8175::powerOn() +{ + sendCommand(0x04); + wait(2000); +} + +void UC8175::powerOff() +{ + sendCommand(0x02); // Power off. + wait(1500); +} + +void UC8175::writeImage(uint8_t command, const uint8_t *image) +{ + sendCommand(command); + sendData(image, bufferSize); +} + +void UC8175::writeOldImage() +{ + if (updateType == FAST && previousBuffer) + writeImage(0x10, previousBuffer); + else + writeImage(0x10, buffer); +} + +void UC8175::writeNewImage() +{ + writeImage(0x13, buffer); +} + +void UC8175::detachFromUpdate() +{ + switch (updateType) { + case FAST: + return beginPolling(50, 400); + case FULL: + default: + return beginPolling(100, 2000); + } +} + +bool UC8175::isUpdateDone() +{ + return digitalRead(pin_busy) != BUSY_ACTIVE; +} + +void UC8175::finalizeUpdate() +{ + powerOff(); + + if (pin_rst != (uint8_t)-1) { + sendCommand(0x07); // Deep sleep. + sendData(0xA5); + } +} + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/Drivers/EInk/UC8175.h b/src/graphics/niche/Drivers/EInk/UC8175.h new file mode 100644 index 000000000..b248d4bea --- /dev/null +++ b/src/graphics/niche/Drivers/EInk/UC8175.h @@ -0,0 +1,62 @@ +// E-Ink base class for displays based on UC8175 / UC8176 style controller ICs. + +#pragma once + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +#include "configuration.h" + +#include "./EInk.h" + +namespace NicheGraphics::Drivers +{ + +class UC8175 : public EInk +{ + public: + UC8175(uint16_t width, uint16_t height, UpdateTypes supported); + void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override; + void update(uint8_t *imageData, UpdateTypes type) override; + + protected: + virtual void wait(uint32_t timeoutMs = 1000); + virtual void reset(); + virtual void sendCommand(uint8_t command); + virtual void sendData(uint8_t data); + virtual void sendData(const uint8_t *data, uint32_t size); + + virtual void configCommon() = 0; // Always run + virtual void configFull() = 0; // Run when updateType == FULL + virtual void configFast() = 0; // Run when updateType == FAST + + virtual void powerOn(); + virtual void powerOff(); + virtual void writeOldImage(); + virtual void writeNewImage(); + virtual void writeImage(uint8_t command, const uint8_t *image); + + virtual void detachFromUpdate(); + virtual bool isUpdateDone() override; + virtual void finalizeUpdate() override; + + protected: + static constexpr uint8_t BUSY_ACTIVE = LOW; + + uint16_t bufferRowSize = 0; + uint32_t bufferSize = 0; + uint8_t *buffer = nullptr; + uint8_t *previousBuffer = nullptr; + bool hasPreviousBuffer = false; + UpdateTypes updateType = UpdateTypes::UNSPECIFIED; + + uint8_t pin_dc = (uint8_t)-1; + uint8_t pin_cs = (uint8_t)-1; + uint8_t pin_busy = (uint8_t)-1; + uint8_t pin_rst = (uint8_t)-1; + SPIClass *spi = nullptr; + SPISettings spiSettings = SPISettings(8000000, MSBFIRST, SPI_MODE0); +}; + +} // namespace NicheGraphics::Drivers + +#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index a07e56665..b2ef1f714 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -349,13 +349,13 @@ void InkHUD::MenuApplet::execute(MenuItem item) handleFreeText = true; cm.freeTextItem.rawText.erase(); // clear the previous freetext message freeTextMode = true; // render input field instead of normal menu - // Open the on-screen keyboard if the joystick is enabled - if (settings->joystick.enabled) + // Open the on-screen keyboard only for full joystick devices + if (settings->joystick.enabled && !inkhud->twoWayRocker) inkhud->openKeyboard(); break; case STORE_CANNEDMESSAGE_SELECTION: - if (!settings->joystick.enabled) + if (!settings->joystick.enabled || inkhud->twoWayRocker) cm.selectedMessageItem = &cm.messageItems.at(cursor - 1); // Minus one: offset for the initial "Send Ping" entry else cm.selectedMessageItem = &cm.messageItems.at(cursor - 2); // Minus two: offset for the "Send Ping" and free text entry @@ -922,7 +922,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page) if (settings->userTiles.maxCount > 1) items.push_back(MenuItem("Layout", MenuAction::LAYOUT, MenuPage::OPTIONS)); items.push_back(MenuItem("Rotate", MenuAction::ROTATE, MenuPage::OPTIONS)); - if (settings->joystick.enabled) + if (settings->joystick.enabled && !inkhud->twoWayRocker) items.push_back(MenuItem("Align Joystick", MenuAction::ALIGN_JOYSTICK, MenuPage::EXIT)); items.push_back(MenuItem("Notifications", MenuAction::TOGGLE_NOTIFICATIONS, MenuPage::OPTIONS, &settings->optionalFeatures.notifications)); @@ -1751,7 +1751,7 @@ void InkHUD::MenuApplet::populateSendPage() items.push_back(MenuItem("Ping", MenuAction::SEND_PING, MenuPage::EXIT)); // If joystick is available, include the Free Text option - if (settings->joystick.enabled) + if (settings->joystick.enabled && !inkhud->twoWayRocker) items.push_back(MenuItem("Free Text", MenuAction::FREE_TEXT, MenuPage::SEND)); // One menu item for each canned message diff --git a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp index 6cac2644b..a45e8d9b5 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Tips/TipsApplet.cpp @@ -152,6 +152,11 @@ void InkHUD::TipsApplet::onRender(bool full) drawBullet("User Button"); drawBullet("- short press: next"); drawBullet("- long press: select or open menu"); + } else if (inkhud->twoWayRocker) { + drawBullet("Rocker + Button"); + drawBullet("- center press: open menu or select"); + drawBullet("- left/right: applet nav"); + drawBullet("- in menu: up/down"); } else { drawBullet("Joystick"); drawBullet("- press: open menu or select"); diff --git a/src/graphics/niche/InkHUD/InkHUD.h b/src/graphics/niche/InkHUD/InkHUD.h index 0e25b0900..abd53951a 100644 --- a/src/graphics/niche/InkHUD/InkHUD.h +++ b/src/graphics/niche/InkHUD/InkHUD.h @@ -88,6 +88,9 @@ class InkHUD // Used by TipsApplet to force menu to start on Region selection bool forceRegionMenu = false; + // Input mode hint for devices that use a left/right rocker plus center button + bool twoWayRocker = false; + // Updating the display // - called by various InkHUD components @@ -130,4 +133,4 @@ class InkHUD } // namespace NicheGraphics::InkHUD -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/WindowManager.cpp b/src/graphics/niche/InkHUD/WindowManager.cpp index ff324943b..c4a0813d8 100644 --- a/src/graphics/niche/InkHUD/WindowManager.cpp +++ b/src/graphics/niche/InkHUD/WindowManager.cpp @@ -143,7 +143,7 @@ void InkHUD::WindowManager::openMenu() // Bring the AlignStick applet to the foreground void InkHUD::WindowManager::openAlignStick() { - if (settings->joystick.enabled) { + if (settings->joystick.enabled && !inkhud->twoWayRocker) { AlignStickApplet *alignStick = (AlignStickApplet *)inkhud->getSystemApplet("AlignStick"); alignStick->bringToForeground(); } @@ -151,6 +151,9 @@ void InkHUD::WindowManager::openAlignStick() void InkHUD::WindowManager::openKeyboard() { + if (!settings->joystick.enabled || inkhud->twoWayRocker) + return; + KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard"); if (keyboard) { @@ -162,6 +165,9 @@ void InkHUD::WindowManager::openKeyboard() void InkHUD::WindowManager::closeKeyboard() { + if (!settings->joystick.enabled || inkhud->twoWayRocker) + return; + KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard"); if (keyboard) { @@ -477,7 +483,7 @@ void InkHUD::WindowManager::createSystemApplets() addSystemApplet("Logo", new LogoApplet, new Tile); addSystemApplet("Pairing", new PairingApplet, new Tile); addSystemApplet("Tips", new TipsApplet, new Tile); - if (settings->joystick.enabled) { + if (settings->joystick.enabled && !inkhud->twoWayRocker) { addSystemApplet("AlignStick", new AlignStickApplet, new Tile); addSystemApplet("Keyboard", new KeyboardApplet, new Tile); } @@ -503,7 +509,7 @@ void InkHUD::WindowManager::placeSystemTiles() inkhud->getSystemApplet("Logo")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); inkhud->getSystemApplet("Pairing")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); inkhud->getSystemApplet("Tips")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); - if (settings->joystick.enabled) { + if (settings->joystick.enabled && !inkhud->twoWayRocker) { inkhud->getSystemApplet("AlignStick")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height()); const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight(); inkhud->getSystemApplet("Keyboard") diff --git a/src/graphics/niche/Inputs/TwoButtonExtended.cpp b/src/graphics/niche/Inputs/TwoButtonExtended.cpp index 287fb943f..f979faca9 100644 --- a/src/graphics/niche/Inputs/TwoButtonExtended.cpp +++ b/src/graphics/niche/Inputs/TwoButtonExtended.cpp @@ -156,6 +156,24 @@ void TwoButtonExtended::setJoystickWiring(uint8_t uPin, uint8_t dPin, uint8_t lP pinMode(joystick[Direction::RIGHT].pin, internalPullup ? INPUT_PULLUP : INPUT); } +// Configures only left/right joystick directions for a two-way rocker +void TwoButtonExtended::setTwoWayRockerWiring(uint8_t leftPin, uint8_t rightPin, bool internalPullup) +{ + if (leftPin == rightPin) { + LOG_WARN("Attempted reuse of TwoWayRocker GPIO. Ignoring assignment"); + return; + } + + joystick[Direction::UP].pin = 0xFF; + joystick[Direction::DOWN].pin = 0xFF; + joystick[Direction::LEFT].pin = leftPin; + joystick[Direction::RIGHT].pin = rightPin; + joystickActiveLogic = LOW; + + pinMode(joystick[Direction::LEFT].pin, internalPullup ? INPUT_PULLUP : INPUT); + pinMode(joystick[Direction::RIGHT].pin, internalPullup ? INPUT_PULLUP : INPUT); +} + void TwoButtonExtended::setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs) { assert(whichButton < 2); @@ -229,6 +247,13 @@ void TwoButtonExtended::setJoystickPressHandlers(Callback uPress, Callback dPres joystick[Direction::RIGHT].onPress = rPress; } +// Set press handlers for a two-way rocker mapped to left/right directions +void TwoButtonExtended::setTwoWayRockerPressHandlers(Callback lPress, Callback rPress) +{ + joystick[Direction::LEFT].onPress = lPress; + joystick[Direction::RIGHT].onPress = rPress; +} + // Handle the start of a press to the primary button // Wakes our button thread void TwoButtonExtended::isrPrimary() diff --git a/src/graphics/niche/Inputs/TwoButtonExtended.h b/src/graphics/niche/Inputs/TwoButtonExtended.h index 23fd78a2a..eb536907d 100644 --- a/src/graphics/niche/Inputs/TwoButtonExtended.h +++ b/src/graphics/niche/Inputs/TwoButtonExtended.h @@ -45,6 +45,7 @@ class TwoButtonExtended : protected concurrency::OSThread void stop(); // Stop handling button input (disconnect ISRs for sleep) void setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup = false); void setJoystickWiring(uint8_t uPin, uint8_t dPin, uint8_t lPin, uint8_t rPin, bool internalPullup = false); + void setTwoWayRockerWiring(uint8_t leftPin, uint8_t rightPin, bool internalPullup = false); void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); void setJoystickDebounce(uint32_t debounceMs); void setHandlerDown(uint8_t whichButton, Callback onDown); @@ -54,6 +55,7 @@ class TwoButtonExtended : protected concurrency::OSThread void setJoystickDownHandlers(Callback uDown, Callback dDown, Callback ldown, Callback rDown); void setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp); void setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress); + void setTwoWayRockerPressHandlers(Callback lPress, Callback rPress); // Disconnect and reconnect interrupts for light sleep #ifdef ARCH_ESP32 diff --git a/src/input/UpDownInterruptImpl1.cpp b/src/input/UpDownInterruptImpl1.cpp index 906dcd2a8..4f62fd5fa 100644 --- a/src/input/UpDownInterruptImpl1.cpp +++ b/src/input/UpDownInterruptImpl1.cpp @@ -8,6 +8,14 @@ UpDownInterruptImpl1::UpDownInterruptImpl1() : UpDownInterruptBase("upDown1") {} bool UpDownInterruptImpl1::init() { +#if defined(INPUTDRIVER_TWO_WAY_ROCKER) && defined(INPUTDRIVER_TWO_WAY_ROCKER_LEFT) && defined(INPUTDRIVER_TWO_WAY_ROCKER_RIGHT) + moduleConfig.canned_message.updown1_enabled = true; + moduleConfig.canned_message.inputbroker_pin_a = INPUTDRIVER_TWO_WAY_ROCKER_LEFT; + moduleConfig.canned_message.inputbroker_pin_b = INPUTDRIVER_TWO_WAY_ROCKER_RIGHT; +#if defined(INPUTDRIVER_TWO_WAY_ROCKER_BTN) + moduleConfig.canned_message.inputbroker_pin_press = INPUTDRIVER_TWO_WAY_ROCKER_BTN; +#endif +#endif if (!moduleConfig.canned_message.updown1_enabled) { // Input device is disabled. @@ -46,4 +54,4 @@ void UpDownInterruptImpl1::handleIntUp() void UpDownInterruptImpl1::handleIntPressed() { upDownInterruptImpl1->intPressHandler(); -} \ No newline at end of file +} diff --git a/src/sleep.cpp b/src/sleep.cpp index 4fec16571..9c044eaf7 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -429,8 +429,13 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r gpio_num_t pin = (gpio_num_t)(config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN); gpio_wakeup_enable(pin, GPIO_INTR_LOW_LEVEL); #endif -#ifdef INPUTDRIVER_ENCODER_BTN - gpio_wakeup_enable((gpio_num_t)INPUTDRIVER_ENCODER_BTN, GPIO_INTR_LOW_LEVEL); +#if defined(INPUTDRIVER_TWO_WAY_ROCKER_BTN) || defined(INPUTDRIVER_ENCODER_BTN) +#if defined(INPUTDRIVER_TWO_WAY_ROCKER_BTN) +#define INPUTDRIVER_WAKE_BTN_PIN INPUTDRIVER_TWO_WAY_ROCKER_BTN +#else +#define INPUTDRIVER_WAKE_BTN_PIN INPUTDRIVER_ENCODER_BTN +#endif + gpio_wakeup_enable((gpio_num_t)INPUTDRIVER_WAKE_BTN_PIN, GPIO_INTR_LOW_LEVEL); #endif #if defined(WAKE_ON_TOUCH) gpio_wakeup_enable((gpio_num_t)SCREEN_TOUCH_INT, GPIO_INTR_LOW_LEVEL); @@ -471,8 +476,9 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r // Disable wake-on-button interrupt. Re-attach normal button-interrupts gpio_wakeup_disable(pin); #endif -#if defined(INPUTDRIVER_ENCODER_BTN) - gpio_wakeup_disable((gpio_num_t)INPUTDRIVER_ENCODER_BTN); +#ifdef INPUTDRIVER_WAKE_BTN_PIN + gpio_wakeup_disable((gpio_num_t)INPUTDRIVER_WAKE_BTN_PIN); +#undef INPUTDRIVER_WAKE_BTN_PIN #endif #if defined(WAKE_ON_TOUCH) gpio_wakeup_disable((gpio_num_t)SCREEN_TOUCH_INT); diff --git a/variants/esp32s3/mini-epaper-s3/nicheGraphics.h b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h new file mode 100644 index 000000000..86da4b8ce --- /dev/null +++ b/variants/esp32s3/mini-epaper-s3/nicheGraphics.h @@ -0,0 +1,131 @@ +#pragma once + +#include "configuration.h" + +#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS + +// InkHUD-specific components +#include "graphics/niche/InkHUD/InkHUD.h" + +// Applets +#include "graphics/niche/InkHUD/Applet.h" +#include "graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.h" +#include "graphics/niche/InkHUD/Applets/User/DM/DMApplet.h" +#include "graphics/niche/InkHUD/Applets/User/FavoritesMap/FavoritesMapApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.h" +#include "graphics/niche/InkHUD/Applets/User/Positions/PositionsApplet.h" +#include "graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.h" +#include "graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h" +#include "graphics/niche/InkHUD/SystemApplet.h" + +// Shared NicheGraphics components +#include "graphics/niche/Drivers/EInk/GDEW0102T4.h" +#include "graphics/niche/Inputs/TwoButtonExtended.h" + +void setupNicheGraphics() +{ + using namespace NicheGraphics; + + // Power-enable the E-Ink panel on this board before any SPI traffic. + pinMode(PIN_EINK_EN, OUTPUT); + digitalWrite(PIN_EINK_EN, HIGH); + delay(10); + + // Display uses HSPI on this board + SPIClass *hspi = new SPIClass(HSPI); + hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + + Drivers::GDEW0102T4 *displayDriver = new Drivers::GDEW0102T4; + displayDriver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY, PIN_EINK_RES); + // Tuned fast-refresh values reg30 reg50 reg82 lutW2 lutB2 = 11 F2 04 11 0D + displayDriver->setFastConfig({0x11, 0xF2, 0x04, 0x11, 0x0D}); + Drivers::EInk *driver = displayDriver; + + InkHUD::InkHUD *inkhud = InkHUD::InkHUD::getInstance(); + inkhud->setDriver(driver); + // Slightly stricter FAST/FULL + inkhud->setDisplayResilience(5, 1.5); + inkhud->twoWayRocker = true; + + // Fonts + InkHUD::Applet::fontLarge = FREESANS_9PT_WIN1252; + InkHUD::Applet::fontMedium = FREESANS_6PT_WIN1252; + InkHUD::Applet::fontSmall = FREESANS_6PT_WIN1252; + + // Small display defaults + inkhud->persistence->settings.rotation = 0; + inkhud->persistence->settings.userTiles.maxCount = 1; + inkhud->persistence->settings.userTiles.count = 1; + inkhud->persistence->settings.joystick.enabled = true; + inkhud->persistence->settings.joystick.aligned = true; + inkhud->persistence->settings.optionalMenuItems.nextTile = false; + + // Pick applets + // Note: order of applets determines priority of "auto-show" feature + inkhud->addApplet("All Messages", new InkHUD::AllMessageApplet, false, false); // - + inkhud->addApplet("DMs", new InkHUD::DMApplet, true, false); // Activated, not autoshown + inkhud->addApplet("Channel 0", new InkHUD::ThreadedMessageApplet(0), true, true); // Activated, Autoshown + inkhud->addApplet("Channel 1", new InkHUD::ThreadedMessageApplet(1), false, false); // - + inkhud->addApplet("Positions", new InkHUD::PositionsApplet, true); // Activated + inkhud->addApplet("Favorites Map", new InkHUD::FavoritesMapApplet, false, false); // - + inkhud->addApplet("Recents List", new InkHUD::RecentsListApplet, false, false); // - + inkhud->addApplet("Heard", new InkHUD::HeardApplet, true, false, 0); // Activated, not autoshown, default on tile 0 + // Start running InkHUD + inkhud->begin(); + + // Enforce two-way rocker behavior regardless of persisted settings. + inkhud->persistence->settings.joystick.enabled = true; + inkhud->persistence->settings.joystick.aligned = true; + inkhud->persistence->settings.optionalMenuItems.nextTile = false; + + // Inputs + Inputs::TwoButtonExtended *buttons = Inputs::TwoButtonExtended::getInstance(); + + // Center press (boot button) + buttons->setWiring(0, INPUTDRIVER_TWO_WAY_ROCKER_BTN, true); + // Match baseUI encoder long-press feel. + buttons->setTiming(0, 75, 300); + buttons->setHandlerShortPress(0, [inkhud]() { inkhud->shortpress(); }); + buttons->setHandlerLongPress(0, [inkhud]() { inkhud->longpress(); }); + + // LEFT rocker pin is IO4; RIGHT rocker pin is IO3. + buttons->setTwoWayRockerWiring(INPUTDRIVER_TWO_WAY_ROCKER_LEFT, INPUTDRIVER_TWO_WAY_ROCKER_RIGHT, true); + buttons->setJoystickDebounce(50); + + // Two-way rocker behavior: + // - when a system applet is handling input (menu, tips, etc): LEFT=up, RIGHT=down + // - otherwise: LEFT=previous applet, RIGHT=next applet + buttons->setTwoWayRockerPressHandlers( + [inkhud]() { + bool systemHandlingInput = false; + for (InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + if (systemHandlingInput) + inkhud->navUp(); + else + inkhud->prevApplet(); + }, + [inkhud]() { + bool systemHandlingInput = false; + for (InkHUD::SystemApplet *sa : inkhud->systemApplets) { + if (sa->handleInput) { + systemHandlingInput = true; + break; + } + } + + if (systemHandlingInput) + inkhud->navDown(); + else + inkhud->nextApplet(); + }); + + buttons->start(); +} + +#endif diff --git a/variants/esp32s3/mini-epaper-s3/pins_arduino.h b/variants/esp32s3/mini-epaper-s3/pins_arduino.h index a4b3c4bf7..afb2428a0 100644 --- a/variants/esp32s3/mini-epaper-s3/pins_arduino.h +++ b/variants/esp32s3/mini-epaper-s3/pins_arduino.h @@ -3,24 +3,23 @@ #include -#define USB_VID 0x303a +#define USB_VID 0x303A #define USB_PID 0x1001 // The default Wire will be mapped to PMU and RTC static const uint8_t SDA = 18; static const uint8_t SCL = 9; -// Default SPI will be mapped to Radio +// Default SPI (LoRa bus) static const uint8_t SS = -1; static const uint8_t MOSI = 17; static const uint8_t MISO = 6; static const uint8_t SCK = 8; +// SD card SPI bus #define SPI_MOSI (39) #define SPI_SCK (41) #define SPI_MISO (38) #define SPI_CS (40) -#define SDCARD_CS SPI_CS - #endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/mini-epaper-s3/platformio.ini b/variants/esp32s3/mini-epaper-s3/platformio.ini index f49be707f..5c3e64681 100644 --- a/variants/esp32s3/mini-epaper-s3/platformio.ini +++ b/variants/esp32s3/mini-epaper-s3/platformio.ini @@ -17,11 +17,15 @@ upload_protocol = esptool build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/mini-epaper-s3 - -DMINI_EPAPER_S3 - -DUSE_EINK - -DEINK_DISPLAY_MODEL=GxEPD2_102 - -DEINK_WIDTH=128 - -DEINK_HEIGHT=80 + -D MINI_EPAPER_S3 + -D USE_EINK + -D EINK_DISPLAY_MODEL=GxEPD2_102 + -D EINK_WIDTH=128 + -D EINK_HEIGHT=80 + -D USE_EINK_DYNAMICDISPLAY + -D EINK_LIMIT_FASTREFRESH=3 + -D EINK_BACKGROUND_USES_FAST + -D EINK_HASQUIRK_GHOSTING lib_deps = ${esp32s3_base.lib_deps} @@ -29,3 +33,22 @@ lib_deps = https://github.com/meshtastic/GxEPD2/archive/c7eb4c3c167cf396ef4f541cc5d4c6aa42f3c46b.zip # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 + +[env:mini-epaper-s3-inkhud] +extends = esp32s3_base, inkhud +board = mini-epaper-s3 +board_check = true +upload_protocol = esptool +build_src_filter = + ${esp32s3_base.build_src_filter} + ${inkhud.build_src_filter} +build_flags = + ${esp32s3_base.build_flags} + ${inkhud.build_flags} + -I variants/esp32s3/mini-epaper-s3 + -D MINI_EPAPER_S3 +lib_deps = + ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX + ${esp32s3_base.lib_deps} + # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib + lewisxhe/SensorLib@0.3.4 diff --git a/variants/esp32s3/mini-epaper-s3/variant.h b/variants/esp32s3/mini-epaper-s3/variant.h index b464c9b4a..0b640f9cf 100644 --- a/variants/esp32s3/mini-epaper-s3/variant.h +++ b/variants/esp32s3/mini-epaper-s3/variant.h @@ -1,46 +1,46 @@ -// Display (E-Ink) +#pragma once -#define PIN_EINK_CS 13 -#define PIN_EINK_BUSY 10 -#define PIN_EINK_RES 11 -#define PIN_EINK_SCLK 14 -#define PIN_EINK_MOSI 15 -#define PIN_EINK_DC 12 -#define PIN_EINK_EN 42 +#define GPS_DEFAULT_NOT_PRESENT 1 -#define SPI_INTERFACES_COUNT 2 -#define PIN_SPI1_MISO -1 -#define PIN_SPI1_MOSI PIN_EINK_MOSI -#define PIN_SPI1_SCK PIN_EINK_SCLK -#define DISPLAY_FORCE_SMALL_FONTS +// SD card (TF) +#define HAS_SDCARD +#define SDCARD_USE_SPI1 +#define SDCARD_CS 40 +#define SD_SPI_FREQUENCY 25000000U +// Built-in RTC (I2C) +#define PCF8563_RTC 0x51 +#define HAS_RTC 1 #define I2C_SDA SDA #define I2C_SCL SCL +// Battery voltage monitoring #define BATTERY_PIN 2 // A battery voltage measurement pin, voltage divider connected here to // measure battery voltage ratio of voltage divider = 2.0 (assumption) #define ADC_MULTIPLIER 2.11 // 2.0 + 10% for correction of display undervoltage. #define ADC_CHANNEL ADC1_GPIO2_CHANNEL -#define HAS_GPS 0 -#undef GPS_RX_PIN -#undef GPS_TX_PIN +// Display (E-Ink) +#define PIN_EINK_EN 42 +#define PIN_EINK_CS 13 +#define PIN_EINK_BUSY 10 +#define PIN_EINK_DC 12 +#define PIN_EINK_RES 11 +#define PIN_EINK_SCLK 14 +#define PIN_EINK_MOSI 15 +#define DISPLAY_FORCE_SMALL_FONTS -#define BUTTON_PIN 3 -#define BUTTON_NEED_PULLUP -#define ALT_BUTTON_PIN 4 -#define ALT_BUTTON_ACTIVE_LOW true -#define ALT_BUTTON_ACTIVE_PULLUP true -#define PIN_BUTTON3 0 - -// #define HAS_SDCARD 1 -// #define SDCARD_USE_SOFT_SPI - -// PCF85063 RTC Module -#define PCF85063_RTC 0x51 -#define HAS_RTC 1 +// Two-Way Rocker input (left/right + boot as press) +#define INPUTDRIVER_TWO_WAY_ROCKER +#define INPUTDRIVER_ENCODER_TYPE 2 +#define INPUTDRIVER_TWO_WAY_ROCKER_RIGHT 3 +#define INPUTDRIVER_TWO_WAY_ROCKER_LEFT 4 +#define INPUTDRIVER_TWO_WAY_ROCKER_BTN 0 +#define UPDOWN_LONG_PRESS_REPEAT_INTERVAL 150 +// LoRa (SX1262) #define USE_SX1262 + #define LORA_DIO1 5 #define LORA_SCK 8 #define LORA_MISO 6 From 79f469ce718958317e43809c174b23adcd0e800b Mon Sep 17 00:00:00 2001 From: Catalin Patulea Date: Tue, 10 Mar 2026 07:19:25 -0400 Subject: [PATCH 02/50] pioarduino Heltec v4: fix build due to LED_BUILTIN compile error. (#9875) Fixes this build error: : error: expected unqualified-id before '-' token /home//.platformio/packages/framework-arduinoespressif32/variants/esp32s3/pins_arduino.h:15:22: note: in expansion of macro 'LED_BUILTIN' 15 | static const uint8_t LED_BUILTIN = SOC_GPIO_PIN_COUNT + PIN_RGB_LED; | ^~~~~~~~~~~ More info: https://github.com/meshtastic/firmware/pull/9122#issuecomment-4028263894 The fix is consistent with variants/esp32s3/heltec_v3/platformio.ini This commit is intentionally on the develop branch, because it's harmless to develop branch, and makes us more ready for pioarduino when the time comes. Heltec v4 introduced in: https://github.com/meshtastic/firmware/pull/7845 Co-authored-by: Ben Meadors --- variants/esp32s3/heltec_v4/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 9591f2dc1..8d9921d6a 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -8,6 +8,7 @@ build_flags = -D HELTEC_V4 -D HAS_LORA_FEM=1 -I variants/esp32s3/heltec_v4 + -ULED_BUILTIN [env:heltec-v4] From 016e68ec53fca8a9074fa8abf20210e379a0db6b Mon Sep 17 00:00:00 2001 From: Clive Blackledge Date: Wed, 11 Mar 2026 04:12:12 -0700 Subject: [PATCH 03/50] Traffic Management Module for packet forwarding logic (#9358) * Add ESP32 Power Management lessons learned document Documents our experimentation with ESP-IDF DFS and why it doesn't work well for Meshtastic (RTOS locks, BLE locks, USB issues). Proposes simpler alternative: manual setCpuFrequencyMhz() control with explicit triggers for when to go fast vs slow. * Addition of traffic management module * Fixing compile issues, but may still need to update protobufs. * Fixing log2Floor in cuckoo hash function * Adding support for traffic management in PhoneAPI. * Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE. * Adding station-g2 and portduino varients to be able to use this module. * Spoofing from address for nodeinfo cache * Changing name and behavior for zero_hop_telemetry / zero_hop_position * Name change for exhausting telemetry packets and setting hop_limit to 1 so it will be 0 when sent. * Updated hop logic, including exhaustRequested flag to bypass some checks later in the code. * Reducing memory on nrf52 nodes further to 12 bytes per entry, 12KB total using 8 bit hashes with 0.4% collision. Probably ok. Adding portduino to the platforms that don't need to worry about memory as much. * Fixing hopsAway for nodeinfo responses. * traffic_management.nodeinfo_direct_response_min_hops -> traffic_management.nodeinfo_direct_response_max_hops * Removing dry run mode * Updates to UnifiedCacheEntry to use a common cache, created defaults for some values, reduced a couple bytes per entry by using a resolution-scale time selection based on configuration value. * Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants. * Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails. * Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit. * Creating consistent log messages * Remove docs/ESP32_Power_Management.md from traffic_module * Add unit tests for Traffic Management Module functionality * Fixing compile issues, but may still need to update protobufs. * Adding support for traffic management in PhoneAPI. * Making router_preserve_hops work without checking if the previous hop was a router. Also works for CLIENT_BASE. * Enhance traffic management logging and configuration. Updated log messages in NextHopRouter and Router to include more context. Adjusted traffic management configuration checks in AdminModule and improved cache handling in TrafficManagementModule. Ensured consistent enabling of traffic management across various variants. * Implement destructor for TrafficManagementModule and improve cache allocation handling. The destructor ensures proper deallocation of cache memory based on its allocation source (PSRAM or heap). Additionally, updated cache allocation logic to log warnings only when PSRAM allocation fails. * Update TrafficManagementModule with enhanced comments for clarity and improve cache handling logic. Update protobuf submodule to latest commit. * Add mock classes and unit tests for Traffic Management Module functionality. * Refactor setup and loop functions in test_main.cpp to include extern "C" linkage * Update comment to include reduced memory requirements Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Re-arranging comments for programmers with the attention span of less than 5 lines of code. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update comments in TrafficManagementModule to reflect changes in timestamp epoch handling and memory optimization details. * bug: Use node-wide config_ok_to_mqtt setting for cached NodeInfo replies. * Better way to handle clearing the ok_to_mqtt bit * Add bucketing to cuckoo hashing, allowing for 95% occupied rate before major eviction problems. * Extend nodeinfo cache for psram devices. * Refactor traffic management to make hop exhaustion packet-scoped. Nice catch. * Implement better position precision sanitization in TrafficManagementModule. * Added logic in TrafficManagementModule to invalidate stale traffic state. Also, added some tests to avoid future me from creating a regression here. * Fixing tests for native * Enhance TrafficManagementModule to improve NodeInfo response handling and position deduplication logic. Added tests to ensure local packets bypass transit filters and that NodeInfo requests correctly update the requester information in the cache. Updated deduplication checks to prevent dropping valid position packets under certain conditions. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/configuration.h | 1 + src/mesh/Default.h | 5 + src/mesh/NextHopRouter.cpp | 22 +- src/mesh/PhoneAPI.cpp | 5 + src/mesh/Router.cpp | 17 + src/mesh/mesh-pb-constants.h | 18 +- src/modules/AdminModule.cpp | 10 + src/modules/Modules.cpp | 11 + src/modules/TrafficManagementModule.cpp | 1410 +++++++++++++++++++ src/modules/TrafficManagementModule.h | 434 ++++++ test/test_traffic_management/test_main.cpp | 1160 +++++++++++++++ variants/esp32s3/heltec_v4/variant.h | 10 +- variants/esp32s3/station-g2/variant.h | 8 + variants/native/portduino/variant.h | 8 + variants/nrf52840/tracker-t1000-e/variant.h | 9 + 15 files changed, 3123 insertions(+), 5 deletions(-) create mode 100644 src/modules/TrafficManagementModule.cpp create mode 100644 src/modules/TrafficManagementModule.h create mode 100644 test/test_traffic_management/test_main.cpp diff --git a/src/configuration.h b/src/configuration.h index a5f2cd9a9..cc623455f 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -499,6 +499,7 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1 #define MESHTASTIC_EXCLUDE_STOREFORWARD 1 #define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1 +#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1 #define MESHTASTIC_EXCLUDE_ATAK 1 #define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1 #define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1 diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 4cfbdbcc8..f4633790e 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -30,6 +30,11 @@ #define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 + +// Traffic management defaults +#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells +#define default_traffic_mgmt_position_min_interval_secs ONE_DAY // 1 day between identical positions + #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS #else diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 5230e5b85..d686e9056 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -4,6 +4,9 @@ #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" #endif +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "NodeDB.h" NextHopRouter::NextHopRouter() {} @@ -126,15 +129,28 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast /* Check if we should be rebroadcasting this packet if so, do so. */ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) { - if (!isToUs(p) && !isFromUs(p) && p->hop_limit > 0) { + // Check if traffic management wants to exhaust this packet's hops + bool exhaustHops = false; +#if HAS_TRAFFIC_MANAGEMENT + if (trafficManagementModule && trafficManagementModule->shouldExhaustHops(*p)) { + exhaustHops = true; + } +#endif + + // Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay) + if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) { if (p->id != 0) { if (isRebroadcaster()) { if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) { meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it LOG_INFO("Rebroadcast received message coming from %x", p->relay_node); - // Use shared logic to determine if hop_limit should be decremented - if (shouldDecrementHopLimit(p)) { + // If exhausting hops, force hop_limit = 0 regardless of other logic + if (exhaustHops) { + tosend->hop_limit = 0; + LOG_INFO("Traffic management: exhausting hops for 0x%08x, setting hop_limit=0", getFrom(p)); + } else if (shouldDecrementHopLimit(p)) { + // Use shared logic to determine if hop_limit should be decremented tosend->hop_limit--; // bump down the hop count } else { LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit"); diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index a02f96ac5..7df6720a2 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -449,6 +449,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter; break; + case meshtastic_ModuleConfig_traffic_management_tag: + LOG_DEBUG("Send module config: traffic management"); + fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; + fromRadioScratch.moduleConfig.payload_variant.traffic_management = moduleConfig.traffic_management; + break; default: LOG_ERROR("Unknown module config type %d", config_state); } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2e4c4d7d9..db4b88413 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -11,6 +11,9 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" #endif @@ -95,6 +98,20 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) return true; } +#if HAS_TRAFFIC_MANAGEMENT + // When router_preserve_hops is enabled, preserve hops for decoded packets that are not + // position or telemetry (those have their own exhaust_hop controls). + if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled && + moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) { + LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p)); + if (trafficManagementModule) { + trafficManagementModule->recordRouterHopPreserved(); + } + return false; + } +#endif + // For subsequent hops, check if previous relay is a favorite router // Optimized search for favorite routers with matching last byte // Check ordering optimized for IoT devices (cheapest checks first) diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index e4f65aa28..eea7d4efc 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -69,6 +69,22 @@ static inline int get_max_num_nodes() /// Max number of channels allowed #define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0])) +// Traffic Management module configuration +// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 0 +#endif + +// Cache size for traffic management (number of nodes to track) +// Can be overridden per-variant based on available memory +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#if HAS_TRAFFIC_MANAGEMENT +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 +#else +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 0 +#endif +#endif + /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic /// returns the encoded packet size size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct); @@ -90,4 +106,4 @@ bool writecb(pb_ostream_t *stream, const uint8_t *buf, size_t count); */ bool is_in_helper(uint32_t n, const uint32_t *array, pb_size_t count); -#define is_in_repeated(name, n) is_in_helper(n, name, name##_count) \ No newline at end of file +#define is_in_repeated(name, n) is_in_helper(n, name, name##_count) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 8f0296227..6ffde70db 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1008,6 +1008,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.statusmessage = c.payload_variant.statusmessage; shouldReboot = false; break; + case meshtastic_ModuleConfig_traffic_management_tag: + LOG_INFO("Set module config: Traffic Management"); + moduleConfig.has_traffic_management = true; + moduleConfig.traffic_management = c.payload_variant.traffic_management; + break; } saveChanges(SEGMENT_MODULECONFIG, shouldReboot); return true; @@ -1193,6 +1198,11 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag; res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage; break; + case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG: + LOG_INFO("Get module config: Traffic Management"); + res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; + res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management; + break; } // NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior. diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 64e90c9c2..d3ab9076d 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -38,6 +38,9 @@ #include "modules/PowerStressModule.h" #endif #include "modules/RoutingModule.h" +#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "modules/TextMessageModule.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" @@ -120,6 +123,14 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_REPLYBOT new ReplyBotModule(); #endif + +#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT + // Instantiate only when enabled to avoid extra memory use and background work. + if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) { + trafficManagementModule = new TrafficManagementModule(); + } +#endif + #if !MESHTASTIC_EXCLUDE_ADMIN adminModule = new AdminModule(); #endif diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp new file mode 100644 index 000000000..6936ef682 --- /dev/null +++ b/src/modules/TrafficManagementModule.cpp @@ -0,0 +1,1410 @@ +#include "TrafficManagementModule.h" + +#if HAS_TRAFFIC_MANAGEMENT + +#include "Default.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "Router.h" +#include "TypeConversions.h" +#include "concurrency/LockGuard.h" +#include "configuration.h" +#include "mesh-pb-constants.h" +#include "meshUtils.h" +#include +#include + +#define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) +#define TM_LOG_INFO(fmt, ...) LOG_INFO("[TM] " fmt, ##__VA_ARGS__) +#define TM_LOG_WARN(fmt, ...) LOG_WARN("[TM] " fmt, ##__VA_ARGS__) + +// ============================================================================= +// Anonymous Namespace - Internal Helpers +// ============================================================================= + +namespace +{ + +constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval +constexpr uint32_t kUnknownResetMs = 60 * 1000UL; // Unknown packet window +constexpr uint8_t kMaxCuckooKicks = 16; // Max displacement chain length + +// NodeInfo direct response: enforced maximum hops by device role +// Both use maxHops logic (respond when hopsAway <= threshold) +// Config value is clamped to these role-based limits +// Note: nodeinfo_direct_response must also be enabled for this to take effect +constexpr uint32_t kRouterDefaultMaxHops = 3; // Routers: max 3 hops (can set lower via config) +constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot increase) + +/** + * Convert seconds to milliseconds with overflow protection. + */ +uint32_t secsToMs(uint32_t secs) +{ + uint64_t ms = static_cast(secs) * 1000ULL; + if (ms > UINT32_MAX) + return UINT32_MAX; + return static_cast(ms); +} + +/** + * Clamp precision to a valid dedup range. + * Invalid values use the module default precision. + */ +uint8_t sanitizePositionPrecision(uint8_t precision) +{ + if (precision > 0 && precision <= 32) + return precision; + + const uint8_t defaultPrecision = static_cast(default_traffic_mgmt_position_precision_bits); + if (defaultPrecision > 0 && defaultPrecision <= 32) + return defaultPrecision; + + // Someone done messed up if we reach here + return 32; +} + +/** + * Check if a timestamp is within a time window. + * Handles wrap-around correctly using unsigned subtraction. + */ +bool isWithinWindow(uint32_t nowMs, uint32_t startMs, uint32_t intervalMs) +{ + if (intervalMs == 0 || startMs == 0) + return false; + return (nowMs - startMs) < intervalMs; +} + +/** + * Truncate lat/lon to specified precision for position deduplication. + * + * The truncation works by masking off lower bits and rounding to the center + * of the resulting grid cell. This creates a stable truncated value even + * when GPS jitter causes small coordinate changes. + * + * @param value Raw latitude_i or longitude_i from position + * @param precision Number of significant bits to keep (0-32) + * @return Truncated and centered coordinate value + */ +int32_t truncateLatLon(int32_t value, uint8_t precision) +{ + if (precision == 0 || precision >= 32) + return value; + + // Create mask to zero out lower bits + uint32_t mask = UINT32_MAX << (32 - precision); + uint32_t truncated = static_cast(value) & mask; + + // Add half the truncation step to center in the grid cell + truncated += (1u << (31 - precision)); + return static_cast(truncated); +} + +/** + * Saturating increment for uint8_t counters. + * Prevents overflow by capping at UINT8_MAX (255). + */ +inline void saturatingIncrement(uint8_t &counter) +{ + if (counter < UINT8_MAX) + counter++; +} + +/** + * Return a short human-readable name for common port numbers. + * Falls back to "port:" for unknown ports. + */ +const char *portName(int portnum) +{ + switch (portnum) { + case meshtastic_PortNum_TEXT_MESSAGE_APP: + return "text"; + case meshtastic_PortNum_POSITION_APP: + return "position"; + case meshtastic_PortNum_NODEINFO_APP: + return "nodeinfo"; + case meshtastic_PortNum_ROUTING_APP: + return "routing"; + case meshtastic_PortNum_ADMIN_APP: + return "admin"; + case meshtastic_PortNum_TELEMETRY_APP: + return "telemetry"; + case meshtastic_PortNum_TRACEROUTE_APP: + return "traceroute"; + case meshtastic_PortNum_NEIGHBORINFO_APP: + return "neighborinfo"; + case meshtastic_PortNum_STORE_FORWARD_APP: + return "store-forward"; + case meshtastic_PortNum_WAYPOINT_APP: + return "waypoint"; + default: + return nullptr; + } +} + +} // namespace + +// ============================================================================= +// Module Instance +// ============================================================================= + +TrafficManagementModule *trafficManagementModule; + +// ============================================================================= +// Constructor +// ============================================================================= + +TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") +{ + // Module configuration + isPromiscuous = true; // See all packets, not just those addressed to us + encryptedOk = true; // Can process encrypted packets + stats = meshtastic_TrafficManagementStats_init_zero; + + // Initialize rolling epoch for relative timestamps + cacheEpochMs = millis(); + + // Calculate adaptive time resolutions from config (config changes require reboot) + // Resolution = max(60, min(339, interval/2)) for ~24 hour range with good precision + posTimeResolution = calcTimeResolution(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + rateTimeResolution = calcTimeResolution(moduleConfig.traffic_management.rate_limit_window_secs); + unknownTimeResolution = calcTimeResolution(kUnknownResetMs / 1000); // ~5 min default + + const auto &cfg = moduleConfig.traffic_management; + TM_LOG_INFO("Enabled: pos_dedup=%d nodeinfo_resp=%d rate_limit=%d drop_unknown=%d exhaust_telem=%d exhaust_pos=%d " + "preserve_hops=%d", + cfg.position_dedup_enabled, cfg.nodeinfo_direct_response, cfg.rate_limit_enabled, cfg.drop_unknown_enabled, + cfg.exhaust_hop_telemetry, cfg.exhaust_hop_position, cfg.router_preserve_hops); + TM_LOG_DEBUG("Time resolutions: pos=%us, rate=%us, unknown=%us", posTimeResolution, rateTimeResolution, + unknownTimeResolution); + +// Allocate unified cache (10 bytes/entry for all platforms) +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + const uint16_t allocSize = cacheSize(); + TM_LOG_INFO("Allocating unified cache: %u entries (%u bytes)", allocSize, + static_cast(allocSize * sizeof(UnifiedCacheEntry))); + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + // ESP32 with PSRAM: prefer PSRAM for large allocations + cache = static_cast(ps_calloc(allocSize, sizeof(UnifiedCacheEntry))); + if (cache) { + cacheFromPsram = true; + } else { + TM_LOG_WARN("PSRAM allocation failed, falling back to heap"); + cache = new UnifiedCacheEntry[allocSize](); + } +#else + // All other platforms: heap allocation + cache = new UnifiedCacheEntry[allocSize](); +#endif + +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + TM_LOG_INFO("Allocating NodeInfo cache: target=%u occupancy=%u%% payload=%u bytes (PSRAM) tags=%u bytes (%u-bit, %u slots, " + "%u buckets x %u)", + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetOccupancyPercent()), + static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry)), + static_cast(nodeInfoIndexMetadataBudgetBytes()), static_cast(nodeInfoTagBits()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), + static_cast(nodeInfoBucketSize())); + + nodeInfoIndex = static_cast(calloc(nodeInfoIndexMetadataBudgetBytes(), sizeof(uint8_t))); + if (!nodeInfoIndex) { + TM_LOG_WARN("NodeInfo index allocation failed; direct responses will fall back to NodeDB"); + } else { + nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); + if (nodeInfoPayload) { + nodeInfoPayloadFromPsram = true; + TM_LOG_INFO("NodeInfo bucketed cuckoo cache ready"); + } else { + TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); + free(nodeInfoIndex); + nodeInfoIndex = nullptr; + } + } +#else + TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target"); +#endif + + setIntervalFromNow(kMaintenanceIntervalMs); +} + +// Cache may have been allocated via ps_calloc (PSRAM, C allocator) or new[] (heap). +// Must use the matching deallocator: free() for ps_calloc, delete[] for new[]. +TrafficManagementModule::~TrafficManagementModule() +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (cache) { + // Cache may be from ps_calloc (PSRAM, C allocator) or new[] (heap). + // Use the matching deallocator for the allocation source. + if (cacheFromPsram) + free(cache); + else + delete[] cache; + cache = nullptr; + } +#endif + + if (nodeInfoPayload) { + if (nodeInfoPayloadFromPsram) + free(nodeInfoPayload); + else + delete[] nodeInfoPayload; + nodeInfoPayload = nullptr; + } + + if (nodeInfoIndex) { + free(nodeInfoIndex); + nodeInfoIndex = nullptr; + } +} + +// ============================================================================= +// Statistics +// ============================================================================= + +meshtastic_TrafficManagementStats TrafficManagementModule::getStats() const +{ + concurrency::LockGuard guard(&cacheLock); + return stats; +} + +void TrafficManagementModule::resetStats() +{ + concurrency::LockGuard guard(&cacheLock); + stats = meshtastic_TrafficManagementStats_init_zero; +} + +void TrafficManagementModule::recordRouterHopPreserved() +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return; + incrementStat(&stats.router_hops_preserved); +} + +void TrafficManagementModule::incrementStat(uint32_t *field) +{ + concurrency::LockGuard guard(&cacheLock); + (*field)++; +} + +// ============================================================================= +// Cuckoo Hash Table Operations +// ============================================================================= + +/** + * Find an existing entry for the given node. + * + * Cuckoo hashing guarantees that if an entry exists, it's in one of exactly + * two locations: hash1(node) or hash2(node). This provides O(1) lookup. + * + * @param node NodeNum to search for + * @return Pointer to entry if found, nullptr otherwise + */ +TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + return nullptr; +#else + if (!cache || node == 0) + return nullptr; + + // Check primary location + uint16_t h1 = cuckooHash1(node); + if (cache[h1].node == node) + return &cache[h1]; + + // Check alternate location + uint16_t h2 = cuckooHash2(node); + if (cache[h2].node == node) + return &cache[h2]; + + return nullptr; +#endif +} + +/** + * Find or create an entry for the given node using cuckoo hashing. + * + * If the node exists, returns the existing entry. Otherwise, attempts to + * insert a new entry using cuckoo displacement: + * + * 1. Try to insert at h1(node) - if empty, done + * 2. Try to insert at h2(node) - if empty, done + * 3. Kick existing entry from h1 to its alternate location + * 4. Repeat up to kMaxCuckooKicks times + * 5. If cycle detected or max kicks exceeded, evict oldest entry + * + * @param node NodeNum to find or create + * @param isNew Set to true if a new entry was created + * @return Pointer to entry, or nullptr if allocation failed + */ +TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + if (isNew) + *isNew = false; + return nullptr; +#else + if (!cache || node == 0) { + if (isNew) + *isNew = false; + return nullptr; + } + + // Check if entry already exists (O(1) lookup) + uint16_t h1 = cuckooHash1(node); + if (cache[h1].node == node) { + if (isNew) + *isNew = false; + return &cache[h1]; + } + + uint16_t h2 = cuckooHash2(node); + if (cache[h2].node == node) { + if (isNew) + *isNew = false; + return &cache[h2]; + } + + // Entry doesn't exist - try to insert + + // Prefer empty slot at h1 + if (cache[h1].node == 0) { + memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); + cache[h1].node = node; + if (isNew) + *isNew = true; + return &cache[h1]; + } + + // Try empty slot at h2 + if (cache[h2].node == 0) { + memset(&cache[h2], 0, sizeof(UnifiedCacheEntry)); + cache[h2].node = node; + if (isNew) + *isNew = true; + return &cache[h2]; + } + + // Both slots occupied - perform cuckoo displacement + // Start by kicking entry at h1 to its alternate location + UnifiedCacheEntry displaced = cache[h1]; + memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); + cache[h1].node = node; + + for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { + // Find alternate location for displaced entry + uint16_t altH1 = cuckooHash1(displaced.node); + uint16_t altH2 = cuckooHash2(displaced.node); + uint16_t altSlot = (altH1 == h1) ? altH2 : altH1; + + if (cache[altSlot].node == 0) { + // Found empty slot - insert displaced entry + cache[altSlot] = displaced; + if (isNew) + *isNew = true; + return &cache[h1]; + } + + // Kick entry from alternate slot + UnifiedCacheEntry temp = cache[altSlot]; + cache[altSlot] = displaced; + displaced = temp; + h1 = altSlot; + } + + // Cuckoo cycle detected or max kicks exceeded. + // The displaced entry has no valid cuckoo slot — drop it to preserve cache integrity. + // Placing it at an arbitrary slot would make it unreachable by findEntry(). + TM_LOG_DEBUG("Cuckoo cycle, evicting node 0x%08x", displaced.node); + + if (isNew) + *isNew = true; + return &cache[cuckooHash1(node)]; +#endif +} + +const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return nullptr; + + uint16_t payloadIndex = findNodeInfoPayloadIndex(node); + if (payloadIndex >= nodeInfoTargetEntries()) + return nullptr; + + return &nodeInfoPayload[payloadIndex]; +#else + (void)node; + return nullptr; +#endif +} + +uint16_t TrafficManagementModule::encodeNodeInfoTag(uint16_t payloadIndex) const +{ + if (payloadIndex >= nodeInfoTargetEntries()) + return 0; + return static_cast(payloadIndex + 1u); +} + +uint16_t TrafficManagementModule::decodeNodeInfoPayloadIndex(uint16_t tag) const +{ + if (tag == 0 || tag > nodeInfoTargetEntries()) + return UINT16_MAX; + return static_cast(tag - 1u); +} + +uint16_t TrafficManagementModule::getNodeInfoTag(uint16_t slot) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) + return 0; + + const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); + const uint16_t byteOffset = static_cast(bitOffset >> 3); + const uint8_t shift = static_cast(bitOffset & 7u); + uint32_t packed = 0; + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset]); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; + + return static_cast((packed >> shift) & nodeInfoTagMask()); +#else + (void)slot; + return 0; +#endif +} + +void TrafficManagementModule::setNodeInfoTag(uint16_t slot, uint16_t tag) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) + return; + + const uint16_t normalizedTag = static_cast(tag & nodeInfoTagMask()); + const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); + const uint16_t byteOffset = static_cast(bitOffset >> 3); + const uint8_t shift = static_cast(bitOffset & 7u); + uint32_t packed = 0; + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset]); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; + + const uint32_t mask = static_cast(nodeInfoTagMask()) << shift; + packed = (packed & ~mask) | ((static_cast(normalizedTag) << shift) & mask); + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset] = static_cast(packed & 0xFFu); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset + 1u] = static_cast((packed >> 8) & 0xFFu); + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset + 2u] = static_cast((packed >> 16) & 0xFFu); +#else + (void)slot; + (void)tag; +#endif +} + +uint16_t TrafficManagementModule::findNodeInfoPayloadIndex(NodeNum node) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return UINT16_MAX; + + const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; + + for (uint8_t b = 0; b < 2; b++) { + const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + uint16_t tag = getNodeInfoTag(static_cast(base + slot)); + if (tag == 0) + continue; + + uint16_t payloadIndex = decodeNodeInfoPayloadIndex(tag); + if (payloadIndex >= nodeInfoTargetEntries()) + continue; + + if (nodeInfoPayload[payloadIndex].node == node) + return payloadIndex; + } + } + + return UINT16_MAX; +#else + (void)node; + return UINT16_MAX; +#endif +} + +bool TrafficManagementModule::removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || node == 0 || payloadIndex >= nodeInfoTargetEntries()) + return false; + + const uint16_t payloadTag = encodeNodeInfoTag(payloadIndex); + if (payloadTag == 0) + return false; + const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; + + for (uint8_t b = 0; b < 2; b++) { + const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + const uint16_t indexSlot = static_cast(base + slot); + if (getNodeInfoTag(indexSlot) == payloadTag) { + setNodeInfoTag(indexSlot, 0); + return true; + } + } + } + + return false; +#else + (void)node; + (void)payloadIndex; + return false; +#endif +} + +uint16_t TrafficManagementModule::allocateNodeInfoPayloadSlot() +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload) + return UINT16_MAX; + + for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { + uint16_t idx = static_cast((nodeInfoAllocHint + tries) % nodeInfoTargetEntries()); + if (nodeInfoPayload[idx].node == 0) { + nodeInfoAllocHint = static_cast((idx + 1u) % nodeInfoTargetEntries()); + return idx; + } + } +#endif + return UINT16_MAX; +} + +uint16_t TrafficManagementModule::evictNodeInfoPayloadSlot() +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex) + return UINT16_MAX; + + for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { + uint16_t idx = static_cast(nodeInfoEvictCursor % nodeInfoTargetEntries()); + nodeInfoEvictCursor = static_cast((nodeInfoEvictCursor + 1u) % nodeInfoTargetEntries()); + + NodeNum oldNode = nodeInfoPayload[idx].node; + if (oldNode == 0) + continue; + + removeNodeInfoIndexEntry(oldNode, idx); // best effort; cache tolerates occasional stale miss + nodeInfoPayload[idx].node = 0; + return idx; + } +#endif + return UINT16_MAX; +} + +bool TrafficManagementModule::tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || !nodeInfoPayload || bucket >= nodeInfoBucketCount() || tag == 0) + return false; + + const uint16_t base = static_cast(bucket * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + const uint16_t indexSlot = static_cast(base + slot); + const uint16_t existingTag = getNodeInfoTag(indexSlot); + if (existingTag == 0) { + setNodeInfoTag(indexSlot, tag); + return true; + } + + // Opportunistically reuse stale tags that point at empty/invalid payload slots. + const uint16_t payloadIndex = decodeNodeInfoPayloadIndex(existingTag); + if (payloadIndex >= nodeInfoTargetEntries() || nodeInfoPayload[payloadIndex].node == 0) { + setNodeInfoTag(indexSlot, tag); + return true; + } + } +#else + (void)bucket; + (void)tag; +#endif + return false; +} + +TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, + bool *usedEmptySlot) +{ + if (usedEmptySlot) + *usedEmptySlot = false; + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return nullptr; + + uint16_t existing = findNodeInfoPayloadIndex(node); + if (existing < nodeInfoTargetEntries()) + return &nodeInfoPayload[existing]; + + const uint16_t beforeCount = countNodeInfoEntriesLocked(); + + uint16_t payloadIndex = allocateNodeInfoPayloadSlot(); + if (payloadIndex == UINT16_MAX) { + payloadIndex = evictNodeInfoPayloadSlot(); + if (payloadIndex == UINT16_MAX) + return nullptr; + } + + nodeInfoPayload[payloadIndex].node = node; + + // 4-way bucketed cuckoo insertion mirrors Cuckoo Filter practice from + // Fan et al. (CoNEXT 2014): high occupancy with short relocation chains. + uint16_t pending = encodeNodeInfoTag(payloadIndex); + uint16_t h1 = nodeInfoHash1(node); + uint16_t h2 = nodeInfoHash2(node); + + if (!tryInsertNodeInfoEntryInBucket(h1, pending) && !tryInsertNodeInfoEntryInBucket(h2, pending)) { + uint16_t currentBucket = h1; + for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { + const uint16_t base = static_cast(currentBucket * nodeInfoBucketSize()); + const uint16_t kickSlot = static_cast((node + kicks) & (nodeInfoBucketSize() - 1u)); + const uint16_t pos = static_cast(base + kickSlot); + + uint16_t displaced = getNodeInfoTag(pos); + setNodeInfoTag(pos, pending); + pending = displaced; + + uint16_t displacedPayload = decodeNodeInfoPayloadIndex(pending); + if (displacedPayload >= nodeInfoTargetEntries()) { + pending = 0; + break; + } + + NodeNum displacedNode = nodeInfoPayload[displacedPayload].node; + if (displacedNode == 0) { + pending = 0; + break; + } + + uint16_t altH1 = nodeInfoHash1(displacedNode); + uint16_t altH2 = nodeInfoHash2(displacedNode); + uint16_t altBucket = (altH1 == currentBucket) ? altH2 : altH1; + + if (tryInsertNodeInfoEntryInBucket(altBucket, pending)) { + pending = 0; + break; + } + + currentBucket = altBucket; + } + + if (pending != 0) { + uint16_t droppedPayload = decodeNodeInfoPayloadIndex(pending); + if (droppedPayload < nodeInfoTargetEntries()) + nodeInfoPayload[droppedPayload].node = 0; + TM_LOG_DEBUG("NodeInfo bucketed cuckoo overflow, dropped payload idx=%u", + static_cast(droppedPayload < nodeInfoTargetEntries() ? droppedPayload : UINT16_MAX)); + } + } + + uint16_t finalIndex = findNodeInfoPayloadIndex(node); + if (finalIndex >= nodeInfoTargetEntries()) { + // New entry did not survive insertion chain. + if (payloadIndex < nodeInfoTargetEntries() && nodeInfoPayload[payloadIndex].node == node) + nodeInfoPayload[payloadIndex].node = 0; + return nullptr; + } + + if (usedEmptySlot) { + const uint16_t afterCount = countNodeInfoEntriesLocked(); + *usedEmptySlot = afterCount > beforeCount; + } + + return &nodeInfoPayload[finalIndex]; +#else + (void)node; + return nullptr; +#endif +} + +uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex) + return 0; + + uint16_t count = 0; + for (uint16_t i = 0; i < nodeInfoIndexSlots(); i++) { + if (getNodeInfoTag(i) != 0) + count++; + } + return count; +#else + return 0; +#endif +} + +void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || mp.decoded.payload.size == 0) + return; + + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) + return; + + // Normalize user.id to the packet sender's node number. + snprintf(user.id, sizeof(user.id), "!%08x", getFrom(&mp)); + + bool usedEmptySlot = false; + uint16_t cachedCount = 0; + { + concurrency::LockGuard guard(&cacheLock); + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(getFrom(&mp), &usedEmptySlot); + if (!entry) + return; + + // Cache both payload and response metadata so direct replies can use + // richer context than "just the user protobuf" when PSRAM is present. + // This path is intentionally independent from NodeInfoModule/NodeDB. + entry->user = user; + entry->lastObservedMs = millis(); + entry->lastObservedRxTime = mp.rx_time; + entry->sourceChannel = mp.channel; + entry->hasDecodedBitfield = mp.decoded.has_bitfield; + entry->decodedBitfield = mp.decoded.bitfield; + + if (usedEmptySlot) + cachedCount = countNodeInfoEntriesLocked(); + } + + if (usedEmptySlot) { + TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u target (%u packed slots, %u-bit tags, %u-byte DRAM index)", + static_cast(cachedCount), static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoTagBits()), + static_cast(nodeInfoIndexMetadataBudgetBytes())); + } +#else + (void)mp; +#endif +} + +// ============================================================================= +// Epoch Management +// ============================================================================= + +/** + * Reset the timestamp epoch when relative offsets approach overflow. + * + * Called when epoch age exceeds ~19 hours (approaching 8-bit minute overflow). + * Invalidates all cached per-node traffic state. + */ +void TrafficManagementModule::resetEpoch(uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + TM_LOG_DEBUG("Resetting cache epoch"); + cacheEpochMs = nowMs; + + // Full flush avoids stale dedup identity/counters surviving epoch rollover. + memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); +#else + (void)nowMs; +#endif +} + +// ============================================================================= +// Position Hash (Compact Mode) +// ============================================================================= + +/** + * Compute 8-bit position fingerprint from truncated lat/lon coordinates. + * + * Unlike a hash, this is deterministic: adjacent grid cells have sequential + * fingerprints, so nearby positions never collide. The fingerprint extracts + * the lower 4 significant bits from each truncated coordinate. + * + * Example with precision=16: + * lat_truncated = 0x12340000 (top 16 bits significant) + * Significant portion = 0x1234, lower 4 bits = 0x4 + * + * fingerprint = (lat_low4 << 4) | lon_low4 = 8 bits total + * + * Collision: Two positions collide only if they differ by a multiple of 16 + * grid cells in BOTH lat and lon dimensions simultaneously - very unlikely + * for typical position update patterns. + * + * @param lat_truncated Precision-truncated latitude + * @param lon_truncated Precision-truncated longitude + * @param precision Number of significant bits (1-32) + * @return 8-bit fingerprint (4 bits lat + 4 bits lon) + */ +uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision) +{ + precision = sanitizePositionPrecision(precision); + + // Guard: if precision < 4, we have fewer bits to work with + // Take min(precision, 4) bits from each coordinate + uint8_t bitsToTake = (precision < 4) ? precision : 4; + + // Shift to move significant bits to bottom, then mask lower bits + // For precision=16: shift by 16 to get the 16 significant bits at bottom + uint8_t shift = 32 - precision; + uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); + uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); + + return static_cast((latBits << 4) | lonBits); +} + +// ============================================================================= +// Packet Handling +// ============================================================================= + +// Processing order matters: this module runs BEFORE RoutingModule in the callModules() loop. +// - STOP prevents RoutingModule from calling sniffReceived() → perhapsRebroadcast(), +// so the packet is fully consumed (not forwarded). +// - ignoreRequest suppresses the default "no one responded" NAK for want_response packets. +// - exhaustRequested is set by alterReceived() and checked by perhapsRebroadcast() to +// force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. +ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return ProcessMessage::CONTINUE; + + ignoreRequest = false; + exhaustRequested = false; // Reset per-packet; may be set by alterReceived() below + exhaustRequestedFrom = 0; + exhaustRequestedId = 0; + incrementStat(&stats.packets_inspected); + + const auto &cfg = moduleConfig.traffic_management; + const uint32_t nowMs = millis(); + + // ------------------------------------------------------------------------- + // Undecoded Packet Handling + // ------------------------------------------------------------------------- + // Packets we can't decode (wrong key, corruption, etc.) may indicate + // a misbehaving node. Track and optionally drop repeat offenders. + + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + if (cfg.drop_unknown_enabled && cfg.unknown_packet_threshold > 0) { + if (shouldDropUnknown(&mp, nowMs)) { + logAction("drop", &mp, "unknown"); + incrementStat(&stats.unknown_packet_drops); + ignoreRequest = true; // Suppress NAK for want_response packets + return ProcessMessage::STOP; // Consumed — will not be rebroadcast + } + } + return ProcessMessage::CONTINUE; + } + + // Learn NodeInfo payloads into the dedicated PSRAM cache. + if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) + cacheNodeInfoPacket(mp); + + // ------------------------------------------------------------------------- + // NodeInfo Direct Response + // ------------------------------------------------------------------------- + // When we see a unicast NodeInfo request for a node we know about, + // respond directly from cache instead of forwarding the request. + // STOP prevents the request from being rebroadcast toward the target node, + // and our cached response is sent back to the requestor with hop_limit=0. + + if (cfg.nodeinfo_direct_response && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && + !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { + if (shouldRespondToNodeInfo(&mp, true)) { + meshtastic_User requester = meshtastic_User_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { + nodeDB->updateUser(getFrom(&mp), requester, mp.channel); + } + logAction("respond", &mp, "nodeinfo-cache"); + incrementStat(&stats.nodeinfo_cache_hits); + ignoreRequest = true; // We responded; suppress default NAK + return ProcessMessage::STOP; // Consumed — request will not be forwarded + } + } + + // ------------------------------------------------------------------------- + // Position Deduplication + // ------------------------------------------------------------------------- + // Drop position broadcasts that haven't moved significantly since the + // last broadcast from this node. Uses truncated coordinates to ignore + // GPS jitter within the configured precision. + + if (!isFromUs(&mp) && !isToUs(&mp)) { + if (cfg.position_dedup_enabled && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position pos = meshtastic_Position_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { + if (shouldDropPosition(&mp, &pos, nowMs)) { + logAction("drop", &mp, "position-dedup"); + incrementStat(&stats.position_dedup_drops); + ignoreRequest = true; // Suppress NAK + return ProcessMessage::STOP; // Consumed — duplicate will not be rebroadcast + } + } + } + + // --------------------------------------------------------------------- + // Rate Limiting + // --------------------------------------------------------------------- + // Throttle nodes sending too many packets within a time window. + // Excludes routing and admin packets which are essential for mesh operation. + + if (cfg.rate_limit_enabled && cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { + if (mp.decoded.portnum != meshtastic_PortNum_ROUTING_APP && mp.decoded.portnum != meshtastic_PortNum_ADMIN_APP) { + if (isRateLimited(mp.from, nowMs)) { + logAction("drop", &mp, "rate-limit"); + incrementStat(&stats.rate_limit_drops); + ignoreRequest = true; // Suppress NAK + return ProcessMessage::STOP; // Consumed — throttled packet will not be rebroadcast + } + } + } + } + + return ProcessMessage::CONTINUE; +} + +void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return; + + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return; + + if (isFromUs(&mp)) + return; + + // ------------------------------------------------------------------------- + // Relayed Broadcast Hop Exhaustion + // ------------------------------------------------------------------------- + // For relayed telemetry or position broadcasts from other nodes, optionally + // set hop_limit=0 so they don't propagate further through the mesh. + + const auto &cfg = moduleConfig.traffic_management; + const bool isTelemetry = mp.decoded.portnum == meshtastic_PortNum_TELEMETRY_APP; + const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; + const bool shouldExhaust = (isTelemetry && cfg.exhaust_hop_telemetry) || (isPosition && cfg.exhaust_hop_position); + + if (!shouldExhaust || !isBroadcast(mp.to)) + return; + + if (mp.hop_limit > 0) { + const char *reason = isTelemetry ? "exhaust-hop-telemetry" : "exhaust-hop-position"; + logAction("exhaust", &mp, reason); + // Adjust hop_start so downstream nodes compute correct hopsAway (hop_start - hop_limit). + // Without this, hop_limit=0 with original hop_start would show inflated hopsAway. + mp.hop_start = mp.hop_start - mp.hop_limit + 1; + mp.hop_limit = 0; + // Signal perhapsRebroadcast() to allow one final relay with hop_limit=0. + // Without this flag, perhapsRebroadcast() would skip the packet since hop_limit==0. + // The packet-scoped flag is checked in NextHopRouter::perhapsRebroadcast() + // and forces tosend->hop_limit=0, ensuring no further propagation beyond the + // next node. + exhaustRequested = true; + exhaustRequestedFrom = getFrom(&mp); + exhaustRequestedId = mp.id; + incrementStat(&stats.hop_exhausted_packets); + } +} + +// ============================================================================= +// Periodic Maintenance +// ============================================================================= + +int32_t TrafficManagementModule::runOnce() +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return INT32_MAX; + +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + const uint32_t nowMs = millis(); + + // Check if epoch reset needed (~3.5 hours approaching 8-bit minute overflow) + if (needsEpochReset(nowMs)) { + concurrency::LockGuard guard(&cacheLock); + resetEpoch(nowMs); + return kMaintenanceIntervalMs; + } + + // Calculate TTLs for cache expiration + const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + const uint32_t positionTtlMs = positionIntervalMs * 4; + + const uint32_t rateIntervalMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + const uint32_t rateTtlMs = (rateIntervalMs > 0) ? rateIntervalMs * 2 : (10 * 60 * 1000UL); + + const uint32_t unknownTtlMs = kUnknownResetMs * 5; + + // Sweep cache and clear expired entries + uint16_t activeEntries = 0; + uint16_t expiredEntries = 0; + const uint32_t sweepStartMs = millis(); + + concurrency::LockGuard guard(&cacheLock); + for (uint16_t i = 0; i < cacheSize(); i++) { + if (cache[i].node == 0) + continue; + + bool anyValid = false; + + // Check and clear expired position data + if (cache[i].pos_time != 0) { + uint32_t posTimeMs = fromRelativePosTime(cache[i].pos_time); + if (!isWithinWindow(nowMs, posTimeMs, positionTtlMs)) { + cache[i].pos_fingerprint = 0; + cache[i].pos_time = 0; + } else { + anyValid = true; + } + } + + // Check and clear expired rate limit data + if (cache[i].rate_time != 0) { + uint32_t rateTimeMs = fromRelativeRateTime(cache[i].rate_time); + if (!isWithinWindow(nowMs, rateTimeMs, rateTtlMs)) { + cache[i].rate_count = 0; + cache[i].rate_time = 0; + } else { + anyValid = true; + } + } + + // Check and clear expired unknown tracking data + if (cache[i].unknown_time != 0) { + uint32_t unknownTimeMs = fromRelativeUnknownTime(cache[i].unknown_time); + if (!isWithinWindow(nowMs, unknownTimeMs, unknownTtlMs)) { + cache[i].unknown_count = 0; + cache[i].unknown_time = 0; + } else { + anyValid = true; + } + } + + // If all data expired, free the slot entirely + if (!anyValid) { + memset(&cache[i], 0, sizeof(UnifiedCacheEntry)); + expiredEntries++; + } else { + activeEntries++; + } + } + + TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, + static_cast(activeEntries), static_cast(cacheSize()), + static_cast(millis() - sweepStartMs)); + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (nodeInfoPayload && nodeInfoIndex) { + TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u target (%u packed slots, %u buckets, %u-bit tags, %u-byte index)", + static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), + static_cast(nodeInfoTagBits()), static_cast(nodeInfoIndexMetadataBudgetBytes())); + } +#endif + +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + + return kMaintenanceIntervalMs; +} + +// ============================================================================= +// Traffic Management Logic +// ============================================================================= + +bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)p; + (void)pos; + (void)nowMs; + return false; +#else + if (!pos->has_latitude_i || !pos->has_longitude_i) + return false; + + uint8_t precision = Default::getConfiguredOrDefault(moduleConfig.traffic_management.position_precision_bits, + default_traffic_mgmt_position_precision_bits); + precision = sanitizePositionPrecision(precision); + + const int32_t lat_truncated = truncateLatLon(pos->latitude_i, precision); + const int32_t lon_truncated = truncateLatLon(pos->longitude_i, precision); + const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); + const uint32_t minIntervalMs = secsToMs(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(p->from, &isNew); + if (!entry) + return false; + + // Compare fingerprint and check time window + // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop) + const bool hasPositionState = !isNew && entry->pos_time != 0; + const bool samePosition = hasPositionState && entry->pos_fingerprint == fingerprint; + const bool withinInterval = + hasPositionState && (minIntervalMs != 0) && isWithinWindow(nowMs, fromRelativePosTime(entry->pos_time), minIntervalMs); + + TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, + entry->pos_fingerprint, samePosition, withinInterval, isNew); + + // Update cache entry + entry->pos_fingerprint = fingerprint; + entry->pos_time = toRelativePosTime(nowMs); + + // Drop only if same position AND within the minimum interval + return samePosition && withinInterval; +#endif +} + +bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse) +{ + // Caller already verified: nodeinfo_direct_response, portnum, want_response, + // !isBroadcast, !isToUs, !isFromUs + + if (!isMinHopsFromRequestor(p)) + return false; + + meshtastic_User cachedUser = meshtastic_User_init_zero; + bool hasCachedUser = false; + + // Extra metadata consumed only by the PSRAM-backed cache path. + // Defaults preserve previous behavior when cache metadata is unavailable. + bool cachedHasDecodedBitfield = false; + uint8_t cachedDecodedBitfield = 0; + uint8_t cachedSourceChannel = 0; + uint32_t cachedLastObservedMs = 0; + uint32_t cachedLastObservedRxTime = 0; + + { + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(p->to); + if (entry) { + cachedUser = entry->user; + hasCachedUser = true; + cachedHasDecodedBitfield = entry->hasDecodedBitfield; + cachedDecodedBitfield = entry->decodedBitfield; + cachedSourceChannel = entry->sourceChannel; + cachedLastObservedMs = entry->lastObservedMs; + cachedLastObservedRxTime = entry->lastObservedRxTime; + } + } + + if (!hasCachedUser) { + // If the PSRAM cache exists but misses, we intentionally do not fall back + // to the node-wide table. This keeps the PSRAM direct-reply path separate + // from NodeInfoModule/NodeDB behavior when PSRAM is available. + if (nodeInfoPayload && nodeInfoIndex) { + TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to); + return false; + } + + // Fallback only when PSRAM cache is unavailable on this target. + // In this mode we use the node-wide table maintained by NodeInfoModule. + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); + if (!node || !node->has_user) + return false; + cachedUser = TypeConversions::ConvertToUser(node->num, node->user); + } + + if (!sendResponse) + return true; + + meshtastic_MeshPacket *reply = router->allocForSending(); + if (!reply) { + TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); + return false; + } + + reply->decoded.portnum = meshtastic_PortNum_NODEINFO_APP; + reply->decoded.payload.size = + pb_encode_to_bytes(reply->decoded.payload.bytes, sizeof(reply->decoded.payload.bytes), &meshtastic_User_msg, &cachedUser); + reply->decoded.want_response = false; + + // Start from cached bitfield metadata when available. This lets direct + // responses preserve more of the original packet semantics (PSRAM path), + // while still enforcing local policy for OK_TO_MQTT below. + if (cachedHasDecodedBitfield) + reply->decoded.bitfield = cachedDecodedBitfield; + else + reply->decoded.bitfield = 0; + + // Respect the node-wide config_ok_to_mqtt setting for direct NodeInfo replies. + // This response is spoofed from another node, so Router::perhapsEncode() + // will not auto-populate the bitfield via config_ok_to_mqtt for us. + reply->decoded.has_bitfield = true; + // Update only the OK_TO_MQTT bit; keep any other cached bits intact. + reply->decoded.bitfield &= ~BITFIELD_OK_TO_MQTT_MASK; + if (config.lora.config_ok_to_mqtt) + reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; + + if (hasCachedUser && cachedLastObservedMs != 0) { + uint32_t ageMs = millis() - cachedLastObservedMs; + TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, + static_cast(ageMs), static_cast(cachedSourceChannel), + static_cast(p->channel), static_cast(cachedLastObservedRxTime)); + } + + // Spoof the sender as the target node so the requestor sees a valid NodeInfo response. + // hop_limit=0 ensures this reply travels only one hop (direct to requestor). + reply->from = p->to; + reply->to = getFrom(p); + reply->channel = p->channel; + reply->decoded.request_id = p->id; + reply->hop_limit = 0; + // hop_start=0 is set explicitly because Router::send() only sets it for isFromUs(), + // and our spoofed from means isFromUs() is false. + reply->hop_start = 0; + reply->next_hop = nodeDB->getLastByteOfNodeNum(getFrom(p)); + reply->priority = meshtastic_MeshPacket_Priority_DEFAULT; + + service->sendToMesh(reply); + return true; +} + +bool TrafficManagementModule::isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const +{ + int8_t hopsAway = getHopsAway(*p, -1); + if (hopsAway < 0) + return false; + + // Both routers and clients use maxHops logic (respond when hopsAway <= threshold) + // Role determines the maximum allowed value (enforced limit, not just default) + bool isRouter = IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE); + + uint32_t roleLimit = isRouter ? kRouterDefaultMaxHops : kClientDefaultMaxHops; + uint32_t configValue = moduleConfig.traffic_management.nodeinfo_direct_response_max_hops; + + // Use config value if set, otherwise use role default, but always clamp to role limit + uint32_t maxHops = (configValue > 0) ? configValue : roleLimit; + if (maxHops > roleLimit) + maxHops = roleLimit; + + bool result = static_cast(hopsAway) <= maxHops; + TM_LOG_DEBUG("NodeInfo hops check: hopsAway=%d maxHops=%u roleLimit=%u isRouter=%d -> %s", hopsAway, maxHops, roleLimit, + isRouter, result ? "respond" : "skip"); + return result; +} + +bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)from; + (void)nowMs; + return false; +#else + const uint32_t windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + if (windowMs == 0 || moduleConfig.traffic_management.rate_limit_max_packets == 0) + return false; + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(from, &isNew); + if (!entry) + return false; + + // Check if window has expired + if (isNew || !isWithinWindow(nowMs, fromRelativeRateTime(entry->rate_time), windowMs)) { + entry->rate_time = toRelativeRateTime(nowMs); + entry->rate_count = 1; + return false; + } + + // Increment counter (saturates at 255) + saturatingIncrement(entry->rate_count); + + // Check against threshold (uint8_t max is 255, but config is uint32_t) + uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; + if (threshold > 255) + threshold = 255; + + bool limited = entry->rate_count > threshold; + if (limited || entry->rate_count == threshold) { + TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, entry->rate_count, threshold, + limited ? "DROP" : "at-limit"); + } + return limited; +#endif +} + +bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)p; + (void)nowMs; + return false; +#else + if (!moduleConfig.traffic_management.drop_unknown_enabled || moduleConfig.traffic_management.unknown_packet_threshold == 0) + return false; + + uint32_t windowMs = kUnknownResetMs; + if (moduleConfig.traffic_management.rate_limit_window_secs > 0) + windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(p->from, &isNew); + if (!entry) + return false; + + // Check if window has expired + if (isNew || !isWithinWindow(nowMs, fromRelativeUnknownTime(entry->unknown_time), windowMs)) { + entry->unknown_time = toRelativeUnknownTime(nowMs); + entry->unknown_count = 0; + } + + // Increment counter (saturates at 255) + saturatingIncrement(entry->unknown_count); + + // Check against threshold + uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; + if (threshold > 255) + threshold = 255; + + bool drop = entry->unknown_count > threshold; + if (drop || entry->unknown_count == threshold) { + TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, entry->unknown_count, threshold, + drop ? "DROP" : "at-limit"); + } + return drop; +#endif +} + +void TrafficManagementModule::logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const +{ + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + const char *name = portName(p->decoded.portnum); + if (name) { + TM_LOG_INFO("%s %s from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, name, getFrom(p), p->to, p->hop_limit, + p->hop_start, reason); + } else { + TM_LOG_INFO("%s port=%d from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, p->decoded.portnum, getFrom(p), p->to, + p->hop_limit, p->hop_start, reason); + } + } else { + TM_LOG_INFO("%s encrypted from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, getFrom(p), p->to, p->hop_limit, + p->hop_start, reason); + } +} + +#endif diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h new file mode 100644 index 000000000..fe3483a8e --- /dev/null +++ b/src/modules/TrafficManagementModule.h @@ -0,0 +1,434 @@ +#pragma once + +#include "MeshModule.h" +#include "concurrency/Lock.h" +#include "concurrency/OSThread.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include "mesh/generated/meshtastic/telemetry.pb.h" + +#if HAS_TRAFFIC_MANAGEMENT + +/** + * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. + * + * This module provides: + * - Position deduplication (drop redundant position broadcasts) + * - Per-node rate limiting (throttle chatty nodes) + * - Unknown packet filtering (drop undecoded packets from repeat offenders) + * - NodeInfo direct response (answer queries from cache to reduce mesh chatter) + * - Local-only telemetry/position (exhaust hop_limit for local broadcasts) + * - Router hop preservation (maintain hop_limit for router-to-router traffic) + * + * Memory Optimization: + * Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction + * compared to separate per-feature caches. Timestamps are stored as 8-bit relative + * offsets from a rolling epoch to further reduce memory footprint. + */ +class TrafficManagementModule : public MeshModule, private concurrency::OSThread +{ + public: + TrafficManagementModule(); + ~TrafficManagementModule(); + + // Singleton — no copying or moving + TrafficManagementModule(const TrafficManagementModule &) = delete; + TrafficManagementModule &operator=(const TrafficManagementModule &) = delete; + + meshtastic_TrafficManagementStats getStats() const; + void resetStats(); + void recordRouterHopPreserved(); + + /** + * Check if this packet should have its hops exhausted. + * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of + * router_preserve_hops or favorite node logic. + */ + bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const + { + return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id; + } + + protected: + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + bool wantPacket(const meshtastic_MeshPacket *p) override { return true; } + void alterReceived(meshtastic_MeshPacket &mp) override; + int32_t runOnce() override; + // Protected so test shims can force epoch rollover behavior. + void resetEpoch(uint32_t nowMs); + + private: + // ========================================================================= + // Unified Cache Entry (10 bytes) - Same for ALL platforms + // ========================================================================= + // + // A single compact structure used across ESP32, NRF52, and all other platforms. + // Memory: 10 bytes × 2048 entries = 20KB + // + // Position Fingerprinting: + // Instead of storing full coordinates (8 bytes) or a computed hash, + // we store an 8-bit fingerprint derived deterministically from the + // truncated lat/lon. This extracts the lower 4 significant bits from + // each coordinate: fingerprint = (lat_low4 << 4) | lon_low4 + // + // Benefits over hash: + // - Adjacent grid cells have sequential fingerprints (no collision) + // - Two positions only collide if 16+ grid cells apart in BOTH dimensions + // - Deterministic: same input always produces same output + // + // Adaptive Timestamp Resolution: + // All timestamps use 8-bit values with adaptive resolution calculated + // from config at startup. Resolution = max(60, min(339, interval/2)). + // - Min 60 seconds ensures reasonable precision + // - Max 339 seconds allows ~24 hour range (255 * 339 = 86445 sec) + // - interval/2 ensures at least 2 ticks per configured interval + // + // Layout: + // [0-3] node - NodeNum (4 bytes) + // [4] pos_fingerprint - 4 bits lat + 4 bits lon (1 byte) + // [5] rate_count - Packets in current window (1 byte) + // [6] unknown_count - Unknown packets count (1 byte) + // [7] pos_time - Position timestamp (1 byte, adaptive resolution) + // [8] rate_time - Rate window start (1 byte, adaptive resolution) + // [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution) + // + struct __attribute__((packed)) UnifiedCacheEntry { + NodeNum node; // 4 bytes - Node identifier (0 = empty slot) + uint8_t pos_fingerprint; // 1 byte - Lower 4 bits of lat + lon + uint8_t rate_count; // 1 byte - Packet count (saturates at 255) + uint8_t unknown_count; // 1 byte - Unknown packet count (saturates at 255) + uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution) + uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution) + uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution) + }; + static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes"); + + // ========================================================================= + // Cuckoo Hash Table Implementation + // ========================================================================= + // + // Cuckoo hashing provides O(1) worst-case lookup time using two hash functions. + // Each key can be in one of two possible locations (h1 or h2). On collision, + // the existing entry is "kicked" to its alternate location. + // + // Benefits over linear scan: + // - O(1) lookup vs O(n) - critical at packet processing rates + // - O(1) insertion (amortized) with simple eviction on cycles + // - ~95% load factor achievable + // + // Cache size rounds to power-of-2 for fast modulo via bitmask. + // TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048 + // + static constexpr uint16_t cacheSize(); + static constexpr uint16_t cacheMask(); + + // Hash functions for cuckoo hashing + inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); } + inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); } + static constexpr uint8_t cuckooHashBits(); + + // NodeInfo cache configuration (PSRAM path): + // - Payload lives in PSRAM + // - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing + // (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty". + static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store + static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95; + static constexpr uint8_t kNodeInfoBucketSize = 4; + static constexpr uint8_t kNodeInfoTagBits = 12; + static constexpr uint16_t kNodeInfoTagMask = static_cast((1u << kNodeInfoTagBits) - 1u); + static constexpr uint16_t kNodeInfoIndexSlotsRaw = + static_cast((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits); + static constexpr uint16_t kNodeInfoIndexSlots = + static_cast(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize)); + static constexpr uint16_t kNodeInfoTargetEntries = + static_cast((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u); + static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size"); + static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index"); + + static constexpr uint16_t nodeInfoTargetEntries(); + static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes(); + static constexpr uint8_t nodeInfoTargetOccupancyPercent(); + static constexpr uint8_t nodeInfoBucketSize(); + static constexpr uint8_t nodeInfoTagBits(); + static constexpr uint16_t nodeInfoTagMask(); + static constexpr uint16_t nodeInfoIndexSlots(); + static constexpr uint16_t nodeInfoBucketCount(); + static constexpr uint16_t nodeInfoBucketMask(); + static constexpr uint8_t nodeInfoBucketHashBits(); + inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); } + inline uint16_t nodeInfoHash2(NodeNum node) const + { + return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask(); + } + + // ========================================================================= + // Adaptive Timestamp Resolution + // ========================================================================= + // + // All timestamps use 8-bit values with adaptive resolution calculated from + // config at startup. This allows ~24 hour range while maintaining precision. + // + // Resolution formula: max(60, min(339, interval/2)) + // - 60 sec minimum ensures reasonable precision + // - 339 sec maximum allows 24 hour range (255 * 339 ≈ 86400 sec) + // - interval/2 ensures at least 2 ticks per configured interval + // + // Since config changes require reboot, resolution is calculated once. + // + uint32_t cacheEpochMs = 0; + uint16_t posTimeResolution = 60; // Seconds per tick for position + uint16_t rateTimeResolution = 60; // Seconds per tick for rate limiting + uint16_t unknownTimeResolution = 60; // Seconds per tick for unknown tracking + + // Calculate resolution from configured interval (called once at startup) + static uint16_t calcTimeResolution(uint32_t intervalSecs) + { + // Resolution = interval/2 to ensure at least 2 ticks per interval + // Clamped to [60, 339] for min precision and max 24h range + uint32_t res = (intervalSecs > 0) ? (intervalSecs / 2) : 60; + if (res < 60) + res = 60; + if (res > 339) + res = 339; + return static_cast(res); + } + + // Convert to/from 8-bit relative timestamps with given resolution + uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const + { + uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL); + return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast(ticks); + } + uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const + { + return cacheEpochMs + (static_cast(ticks) * resolutionSecs * 1000UL); + } + + // Convenience wrappers for each timestamp type + uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); } + uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); } + + uint8_t toRelativeRateTime(uint32_t nowMs) const { return toRelativeTime(nowMs, rateTimeResolution); } + uint32_t fromRelativeRateTime(uint8_t t) const { return fromRelativeTime(t, rateTimeResolution); } + + uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); } + uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); } + + // Epoch reset when any timestamp approaches overflow + // With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max) + bool needsEpochReset(uint32_t nowMs) const + { + uint16_t maxRes = posTimeResolution; + if (rateTimeResolution > maxRes) + maxRes = rateTimeResolution; + if (unknownTimeResolution > maxRes) + maxRes = unknownTimeResolution; + return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL); + } + // ========================================================================= + // Position Fingerprint + // ========================================================================= + // + // Computes 8-bit fingerprint from truncated lat/lon coordinates. + // Extracts lower 4 significant bits from each coordinate. + // + // fingerprint = (lat_low4 << 4) | lon_low4 + // + // Unlike a hash, adjacent grid cells have sequential fingerprints, + // so nearby positions never collide. Collisions only occur for + // positions 16+ grid cells apart in both dimensions. + // + // Guards: If precision < 4 bits, uses min(precision, 4) bits. + // + static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision); + + // ========================================================================= + // Cache Storage + // ========================================================================= + + mutable concurrency::Lock cacheLock; // Protects all cache access + UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms) + bool cacheFromPsram = false; // Tracks allocator for correct deallocation + + struct NodeInfoPayloadEntry { + // Node identifier associated with this payload slot. + // 0 means the slot is currently unused. + NodeNum node; + + // Cached NODEINFO_APP payload body. This is separate from NodeDB and is only + // used by the PSRAM-backed direct-response path in this module. + meshtastic_User user; + + // Extra response metadata captured from the latest observed NODEINFO_APP + // packet for this node. shouldRespondToNodeInfo() uses this metadata when + // building spoofed replies for requesting clients. + + // Last local uptime tick (millis) when this entry was refreshed. + uint32_t lastObservedMs; + + // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. + // If unavailable in packet, remains 0. + uint32_t lastObservedRxTime; + + // Channel where we most recently heard this node's NodeInfo. + uint8_t sourceChannel; + + // Cached decoded bitfield metadata from the source packet. + // We preserve non-OK_TO_MQTT bits in direct replies when available. + bool hasDecodedBitfield; + uint8_t decodedBitfield; + }; + + NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM + bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation + uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM + uint16_t nodeInfoAllocHint = 0; + uint16_t nodeInfoEvictCursor = 0; + + meshtastic_TrafficManagementStats stats; + + // Flag set during alterReceived() when packet should be exhausted. + // Checked by perhapsRebroadcast() to force hop_limit = 0 only for the + // matching packet key (from + id). Reset at start of handleReceived(). + bool exhaustRequested = false; + NodeNum exhaustRequestedFrom = 0; + PacketId exhaustRequestedId = 0; + + // ========================================================================= + // Cache Operations + // ========================================================================= + + // Find or create entry for node using cuckoo hashing + // Returns nullptr if cache is full and eviction fails + UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew); + + // Find existing entry (no creation) + UnifiedCacheEntry *findEntry(NodeNum node); + + // NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads) + const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; + NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot); + uint16_t findNodeInfoPayloadIndex(NodeNum node) const; + bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex); + uint16_t allocateNodeInfoPayloadSlot(); + uint16_t evictNodeInfoPayloadSlot(); + bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag); + uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const; + uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const; + uint16_t getNodeInfoTag(uint16_t slot) const; + void setNodeInfoTag(uint16_t slot, uint16_t tag); + uint16_t countNodeInfoEntriesLocked() const; + void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); + + // ========================================================================= + // Traffic Management Logic + // ========================================================================= + + bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs); + bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse); + bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const; + bool isRateLimited(NodeNum from, uint32_t nowMs); + bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs); + + void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const; + void incrementStat(uint32_t *field); +}; + +// ========================================================================= +// Compile-time Cache Size Calculations +// ========================================================================= +// +// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient +// cuckoo hash indexing (allows bitmask instead of modulo). +// +// These use C++11-compatible constexpr (single return statement). +// + +namespace detail +{ +// Helper: round up to next power of 2 using bit manipulation +constexpr uint16_t nextPow2(uint16_t n) +{ + return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1); +} + +// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr. +constexpr uint8_t log2Floor(uint16_t n) +{ + return n <= 1 ? 0 : static_cast(1 + log2Floor(static_cast(n >> 1))); +} + +// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr. +constexpr uint8_t log2Ceil(uint16_t n) +{ + return n <= 1 ? 0 : static_cast(1 + log2Floor(static_cast(n - 1))); +} +} // namespace detail + +constexpr uint16_t TrafficManagementModule::cacheSize() +{ + return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE); +} + +constexpr uint16_t TrafficManagementModule::cacheMask() +{ + return cacheSize() > 0 ? cacheSize() - 1 : 0; +} + +constexpr uint8_t TrafficManagementModule::cuckooHashBits() +{ + return detail::log2Floor(cacheSize()); +} + +constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries() +{ + return kNodeInfoTargetEntries; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes() +{ + return kNodeInfoIndexMetadataBudgetBytes; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent() +{ + return kNodeInfoTargetOccupancyPercent; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize() +{ + return kNodeInfoBucketSize; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoTagBits() +{ + return kNodeInfoTagBits; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoTagMask() +{ + return kNodeInfoTagMask; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots() +{ + return kNodeInfoIndexSlots; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount() +{ + return static_cast(nodeInfoIndexSlots() / nodeInfoBucketSize()); +} + +constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask() +{ + return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits() +{ + return detail::log2Floor(nodeInfoBucketCount()); +} + +extern TrafficManagementModule *trafficManagementModule; + +#endif diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp new file mode 100644 index 000000000..ec54f2312 --- /dev/null +++ b/test/test_traffic_management/test_main.cpp @@ -0,0 +1,1160 @@ +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define TM_TEST_ENTRY extern "C" +#else +#define TM_TEST_ENTRY +#endif + +#if HAS_TRAFFIC_MANAGEMENT + +#include "mesh/CryptoEngine.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#include "modules/TrafficManagementModule.h" +#include +#include +#include +#include +#include + +namespace +{ + +constexpr NodeNum kLocalNode = 0x11111111; +constexpr NodeNum kRemoteNode = 0x22222222; +constexpr NodeNum kTargetNode = 0x33333333; + +class MockNodeDB : public NodeDB +{ + public: + meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override + { + if (hasCachedNode && n == cachedNodeNum) + return &cachedNode; + return NodeDB::getMeshNode(n); + } + + void clearCachedNode() + { + hasCachedNode = false; + cachedNodeNum = 0; + cachedNode = meshtastic_NodeInfoLite_init_zero; + } + + void setCachedNode(NodeNum n) + { + clearCachedNode(); + hasCachedNode = true; + cachedNodeNum = n; + cachedNode.num = n; + cachedNode.has_user = true; + } + + private: + bool hasCachedNode = false; + NodeNum cachedNodeNum = 0; + meshtastic_NodeInfoLite cachedNode = meshtastic_NodeInfoLite_init_zero; +}; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } +}; + +class MockRouter : public Router +{ + public: + ~MockRouter() + { + // Router allocates a global crypt lock in its constructor. + // Clean it up here so each test can build a fresh mock router. + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +class TrafficManagementModuleTestShim : public TrafficManagementModule +{ + public: + using TrafficManagementModule::alterReceived; + using TrafficManagementModule::handleReceived; + using TrafficManagementModule::resetEpoch; + using TrafficManagementModule::runOnce; + + bool ignoreRequestFlag() const { return ignoreRequest; } +}; + +MockNodeDB *mockNodeDB = nullptr; + +static void resetTrafficConfig() +{ + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + moduleConfig.has_traffic_management = true; + moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; + moduleConfig.traffic_management.enabled = true; + + config = meshtastic_LocalConfig_init_zero; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + myNodeInfo.my_node_num = kLocalNode; + + router = nullptr; + service = nullptr; + + mockNodeDB->resetNodes(); + mockNodeDB->clearCachedNode(); + nodeDB = mockNodeDB; +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x1001; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + packet.decoded.has_bitfield = true; + packet.decoded.bitfield = 0; + return packet; +} + +static meshtastic_MeshPacket makeUnknownPacket(NodeNum from, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x2001; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.encrypted.size = 0; + return packet; +} + +static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32_t lon, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, to); + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.has_longitude_i = true; + pos.latitude_i = lat; + pos.longitude_i = lon; + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos); + return packet; +} + +static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + strncpy(user.short_name, shortName, sizeof(user.short_name) - 1); + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + +/** + * Verify the module is a no-op when traffic management is disabled. + * Important so config toggles cannot accidentally change routing behavior. + */ +static void test_tm_moduleDisabled_doesNothing(void) +{ + moduleConfig.has_traffic_management = false; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage result = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected); + TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify unknown-packet dropping uses N+1 threshold semantics. + * Important to catch off-by-one regressions in drop decisions. + */ +static void test_tm_unknownPackets_dropOnNPlusOne(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 2; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); + TEST_ASSERT_EQUAL_UINT32(3, stats.packets_inspected); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify duplicate position broadcasts inside the dedup window are dropped. + * Important because this is the primary airtime-saving behavior. + */ +static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_GREATER_THAN_UINT32(0, first.decoded.payload.size); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify changed coordinates are forwarded even with dedup enabled. + * Important so real movement updates are never suppressed as duplicates. + */ +static void test_tm_positionDedup_allowsMovedPosition(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket moved = makePositionPacket(kRemoteNode, 384221234, -1210845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(moved); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify rate limiting drops only after exceeding the configured threshold. + * Important to protect threshold semantics from off-by-one regressions. + */ +static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 3; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + ProcessMessage r4 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r4)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify routing/admin traffic is exempt from rate limiting. + * Important because throttling control traffic can destabilize the mesh. + */ +static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode); + meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode); + + for (int i = 0; i < 4; i++) { + ProcessMessage rr = module.handleReceived(routingPacket); + ProcessMessage ar = module.handleReceived(adminPacket); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(rr)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(ar)); + } + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify packets sourced from this node bypass dedup and rate limiting. + * Important so local transmissions are not accidentally self-throttled. + */ +static void test_tm_fromUs_bypassesPositionAndRateFilters(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); + meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode); + + ProcessMessage p1 = module.handleReceived(positionPacket); + ProcessMessage p2 = module.handleReceived(positionPacket); + ProcessMessage t1 = module.handleReceived(textPacket); + ProcessMessage t2 = module.handleReceived(textPacket); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify locally addressed packets are never dropped by transit shaping. + * Important so dedup/rate limiting do not suppress end-user delivery. + */ +static void test_tm_localDestination_bypassesTransitFilters(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + + ProcessMessage p1 = module.handleReceived(position1); + ProcessMessage p2 = module.handleReceived(position2); + ProcessMessage t1 = module.handleReceived(text1); + ProcessMessage t2 = module.handleReceived(text2); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify router role clamps NodeInfo response hops to router-safe maximum. + * Important so large config values cannot widen response scope unexpectedly. + */ +static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 5; + request.hop_limit = 1; // 4 hops away; router clamp should cap max at 3 + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify NodeInfo direct-response success path and reply packet fields. + * Important because this path consumes the request and generates a spoofed cached reply. + */ +static void test_tm_nodeinfo_directResponse_respondsFromCache(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.id = 0x13572468; + request.hop_start = 3; + request.hop_limit = 3; // direct request (0 hops away) + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); + TEST_ASSERT_FALSE(reply.decoded.want_response); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start); + TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield); +} + +/** + * Verify cached direct replies still preserve requester NodeInfo learning. + * Important so consuming the request does not skip NodeDB refresh for observers. + */ +static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); + request.to = kTargetNode; + request.decoded.want_response = true; + request.id = 0x01020304; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_TRUE(requestor->has_user); + TEST_ASSERT_EQUAL_STRING("requester-long", requestor->user.long_name); + TEST_ASSERT_EQUAL_STRING("rq", requestor->user.short_name); + TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); +} + +/** + * Verify client role only answers direct (0-hop) NodeInfo requests. + * Important so clients do not answer relayed requests outside intended scope. + */ +static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 2; + request.hop_limit = 1; // 1 hop away; clients are clamped to max 0 + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) +/** + * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. + * Important because fallback should only happen through node-wide data when + * the dedicated PSRAM cache does not exist. + */ +static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +/** + * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that + * shouldRespondToNodeInfo() uses cached bitfield metadata. + */ +static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + observed.decoded.has_bitfield = true; + observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; + observed.channel = 2; + observed.rx_time = 123456; + + ProcessMessage observedResult = module.handleReceived(observed); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.id = 0x24681357; + request.channel = 1; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(static_cast(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); +} + +/** + * Verify PSRAM cache misses do not fall back to NodeDB. + * Important so the dedicated PSRAM index stays logically separate from + * NodeInfoModule/NodeDB when PSRAM is available. + */ +static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif + +/** + * Verify relayed telemetry broadcasts are hop-exhausted when enabled. + * Important to prevent further mesh propagation while still allowing one relay step. + */ +static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 3; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_start); + TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify hop exhaustion skips unicast and local-origin packets. + * Important to avoid mutating traffic that should retain normal forwarding behavior. + */ +static void test_tm_alterReceived_skipsLocalAndUnicast(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode); + unicast.hop_start = 5; + unicast.hop_limit = 3; + module.alterReceived(unicast); + TEST_ASSERT_EQUAL_UINT8(3, unicast.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(unicast)); + + meshtastic_MeshPacket fromUs = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kLocalNode, NODENUM_BROADCAST); + fromUs.hop_start = 5; + fromUs.hop_limit = 3; + module.alterReceived(fromUs); + TEST_ASSERT_EQUAL_UINT8(3, fromUs.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(fromUs)); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); +} + +/** + * Verify position dedup window expires and later duplicates are allowed. + * Important so periodic identical reports can resume after cooldown. + */ +static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket third = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(third); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify interval=0 disables position deduplication. + * Important because this is an explicit configuration escape hatch. + */ +static void test_tm_positionDedup_intervalZero_neverDrops(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 0; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify precision values above 32 fall back to default precision. + * Important so invalid config uses the documented default behavior. + */ +static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 99; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify precision=32 does not collapse all positions to one fingerprint. + * Important to prevent false duplicate drops at the full-precision boundary. + */ +static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 32; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify invalid precision=0 is treated as full precision. + * Important so invalid config does not collapse all positions into one fingerprint. + */ +static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 0; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify epoch reset invalidates stale position identity for dedup. + * Important so reset paths cannot leak prior packet identity into new windows. + */ +static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterReset = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + module.resetEpoch(millis()); + ProcessMessage r2 = module.handleReceived(afterReset); + ProcessMessage r3 = module.handleReceived(duplicate); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify non-position cache state does not make the first fingerprint-0 position look duplicated. + * Important so unified cache entries from other features cannot leak into dedup decisions. + */ +static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 10; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); + meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); + + ProcessMessage seeded = module.handleReceived(text); + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(duplicate); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(seeded)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify rate-limit counters reset after the window expires. + * Important so temporary bursts do not cause persistent throttling. + */ +static void test_tm_rateLimit_resetsAfterWindowExpires(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 1; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); +} + +/** + * Verify rate-limit thresholds above 255 effectively clamp to 255. + * Important because counters are uint8_t and must not overflow behavior. + */ +static void test_tm_rateLimit_thresholdAbove255_clamps(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 300; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + for (int i = 0; i < 255; i++) { + ProcessMessage result = module.handleReceived(packet); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + } + ProcessMessage dropped = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); +} + +/** + * Verify unknown-packet tracking resets after its active window expires. + * Important so old unknown traffic does not trigger delayed drops. + */ +static void test_tm_unknownPackets_resetAfterWindowExpires(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 1; + moduleConfig.traffic_management.rate_limit_window_secs = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); +} + +/** + * Verify unknown threshold values above 255 clamp to the counter ceiling. + * Important to align config semantics with saturating counter storage. + */ +static void test_tm_unknownPackets_thresholdAbove255_clamps(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 300; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + for (int i = 0; i < 255; i++) { + ProcessMessage result = module.handleReceived(packet); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + } + ProcessMessage dropped = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); +} + +/** + * Verify relayed position broadcasts can also be hop-exhausted. + * Important because telemetry and position use separate exhaust flags. + */ +static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) +{ + moduleConfig.traffic_management.exhaust_hop_position = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 2; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); + TEST_ASSERT_EQUAL_UINT8(4, packet.hop_start); + TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify hop exhaustion ignores undecoded/encrypted packets. + * Important so we never mutate packets that were not decoded by this module. + */ +static void test_tm_alterReceived_skipsUndecodedPackets(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 3; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); +} + +/** + * Verify exhaustRequested is per-packet and resets on next handleReceived(). + * Important so a prior packet cannot leak hop-exhaust state into later packets. + */ +static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + telemetry.hop_start = 5; + telemetry.hop_limit = 3; + module.alterReceived(telemetry); + TEST_ASSERT_TRUE(module.shouldExhaustHops(telemetry)); + + meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + ProcessMessage result = module.handleReceived(text); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify exhaust requests are packet-scoped (from + id). + * Important so stale state from one packet cannot influence unrelated packets + * that pass through duplicate/rebroadcast paths before handleReceived(). + */ +static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + exhausted.id = 0x1010; + exhausted.hop_start = 5; + exhausted.hop_limit = 3; + module.alterReceived(exhausted); + + meshtastic_MeshPacket unrelated = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); + unrelated.id = 0x2020; + unrelated.hop_start = 4; + unrelated.hop_limit = 0; + + TEST_ASSERT_TRUE(module.shouldExhaustHops(exhausted)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(unrelated)); +} + +/** + * Verify runOnce() returns sleep-forever interval when module is disabled. + * Important to ensure the maintenance thread is effectively inert when off. + */ +static void test_tm_runOnce_disabledReturnsMaxInterval(void) +{ + moduleConfig.traffic_management.enabled = false; + TrafficManagementModuleTestShim module; + + int32_t interval = module.runOnce(); + + TEST_ASSERT_EQUAL_INT32(INT32_MAX, interval); +} + +/** + * Verify runOnce() returns the maintenance cadence when enabled. + * Important so periodic cache housekeeping continues at expected interval. + */ +static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void) +{ + TrafficManagementModuleTestShim module; + + int32_t interval = module.runOnce(); + + TEST_ASSERT_EQUAL_INT32(60 * 1000, interval); +} + +} // namespace + +void setUp(void) +{ + resetTrafficConfig(); +} +void tearDown(void) {} + +TM_TEST_ENTRY void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + + UNITY_BEGIN(); + RUN_TEST(test_tm_moduleDisabled_doesNothing); + RUN_TEST(test_tm_unknownPackets_dropOnNPlusOne); + RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow); + RUN_TEST(test_tm_positionDedup_allowsMovedPosition); + RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold); + RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts); + RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters); + RUN_TEST(test_tm_localDestination_bypassesTransitFilters); + RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops); + RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache); + RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); + RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) + RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); +#endif +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); + RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); +#endif + RUN_TEST(test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast); + RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); + RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); + RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); + RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); + RUN_TEST(test_tm_positionDedup_precision32_allowsDistinctPositions); + RUN_TEST(test_tm_positionDedup_precisionZero_allowsDistinctPositions); + RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset); + RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero); + RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires); + RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps); + RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires); + RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps); + RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast); + RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets); + RUN_TEST(test_tm_alterReceived_resetExhaustFlagOnNextPacket); + RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped); + RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval); + RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval); + exit(UNITY_END()); +} + +TM_TEST_ENTRY void loop() {} + +#else + +void setUp(void) {} +void tearDown(void) {} + +TM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +TM_TEST_ENTRY void loop() {} + +#endif diff --git a/variants/esp32s3/heltec_v4/variant.h b/variants/esp32s3/heltec_v4/variant.h index 83443c5f3..72f55d09f 100644 --- a/variants/esp32s3/heltec_v4/variant.h +++ b/variants/esp32s3/heltec_v4/variant.h @@ -29,6 +29,14 @@ #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 + // ---- GC1109 RF FRONT END CONFIGURATION ---- // The Heltec V4.2 uses a GC1109 FEM chip with integrated PA and LNA // RF path: SX1262 -> Pi attenuator -> GC1109 PA -> Antenna @@ -89,4 +97,4 @@ // 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 \ No newline at end of file +#define GPS_THREAD_INTERVAL 50 diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index 8f0b4b220..2d65a042c 100644 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -40,6 +40,14 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2 #define SX126X_MAX_POWER 19 #endif +// Enable Traffic Management Module for Station G2 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + /* #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define ADC_CHANNEL ADC1_GPIO4_CHANNEL diff --git a/variants/native/portduino/variant.h b/variants/native/portduino/variant.h index cba4aaedd..c23d17b8d 100644 --- a/variants/native/portduino/variant.h +++ b/variants/native/portduino/variant.h @@ -8,3 +8,11 @@ // RAK12002 RTC Module #define RV3028_RTC (uint8_t)0b1010010 + +// Enable Traffic Management Module for native/portduino +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif diff --git a/variants/nrf52840/tracker-t1000-e/variant.h b/variants/nrf52840/tracker-t1000-e/variant.h index b064dbc9f..de6916ac7 100644 --- a/variants/nrf52840/tracker-t1000-e/variant.h +++ b/variants/nrf52840/tracker-t1000-e/variant.h @@ -154,6 +154,15 @@ extern "C" { #define HAS_SCREEN 0 +// Enable Traffic Management Module for testing on T1000-E +// NRF52840 has 256KB RAM - 1024 entries uses ~10KB +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1024 +#endif + #ifdef __cplusplus } #endif From 82580c6798d05afbee5dfe8c23164c3bd9ac0804 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 11 Mar 2026 06:48:46 -0500 Subject: [PATCH 04/50] Initialize LoRaFEMInterface with default fem_type --- src/mesh/LoRaFEMInterface.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/LoRaFEMInterface.h b/src/mesh/LoRaFEMInterface.h index 0a7c810ef..9024abd3a 100644 --- a/src/mesh/LoRaFEMInterface.h +++ b/src/mesh/LoRaFEMInterface.h @@ -8,7 +8,7 @@ typedef enum { GC1109_PA, KCT8103L_PA, OTHER_FEM_TYPES } LoRaFEMType; class LoRaFEMInterface { public: - LoRaFEMInterface() {} + LoRaFEMInterface() : fem_type(OTHER_FEM_TYPES) {} virtual ~LoRaFEMInterface() {} void init(void); void setSleepModeEnable(void); From da808cb43bd8806f2af2734bb3d35483138d36d8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:52:18 -0500 Subject: [PATCH 05/50] Automated version bumps (#9886) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> Co-authored-by: Ben Meadors --- bin/org.meshtastic.meshtasticd.metainfo.xml | 3 +++ debian/changelog | 6 ++++++ version.properties | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index 3566ad506..fe3a3a533 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -87,6 +87,9 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.21 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.20 diff --git a/debian/changelog b/debian/changelog index 1e688ce80..13d751ecf 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +meshtasticd (2.7.21.0) unstable; urgency=medium + + * Version 2.7.21 + + -- GitHub Actions Wed, 11 Mar 2026 11:45:36 +0000 + meshtasticd (2.7.20.0) unstable; urgency=medium * Version 2.7.20 diff --git a/version.properties b/version.properties index 1bbb87be7..c25179ae2 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 7 -build = 20 +build = 21 From 3fcbfe437087f6fc96093ba22a17e7f00db57d70 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Thu, 12 Mar 2026 12:18:56 +0100 Subject: [PATCH 06/50] Remove a bunch of warnings in SEN5X (#9884) --- src/modules/Telemetry/Sensor/SEN5XSensor.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp index b0f0f9071..2feac6d5f 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.cpp @@ -23,7 +23,7 @@ bool SEN5XSensor::getVersion() } delay(20); // From Sensirion Datasheet - uint8_t versionBuffer[12]; + uint8_t versionBuffer[12]{}; size_t charNumber = readBuffer(&versionBuffer[0], 3); if (charNumber == 0) { LOG_ERROR("SEN5X: Error getting data ready flag value"); @@ -638,7 +638,7 @@ bool SEN5XSensor::readValues() LOG_DEBUG("SEN5X: Reading PM Values"); delay(20); // From Sensirion Datasheet - uint8_t dataBuffer[16]; + uint8_t dataBuffer[16]{}; size_t receivedNumber = readBuffer(&dataBuffer[0], 24); if (receivedNumber == 0) { LOG_ERROR("SEN5X: Error getting values"); @@ -691,7 +691,7 @@ bool SEN5XSensor::readPNValues(bool cumulative) LOG_DEBUG("SEN5X: Reading PN Values"); delay(20); // From Sensirion Datasheet - uint8_t dataBuffer[20]; + uint8_t dataBuffer[20]{}; size_t receivedNumber = readBuffer(&dataBuffer[0], 30); if (receivedNumber == 0) { LOG_ERROR("SEN5X: Error getting PN values"); From 4890f7084f0db062362be9f6481eb1148d69c44d Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 15 Mar 2026 00:34:19 +0000 Subject: [PATCH 07/50] Add spoof detection for UDP packets in UdpMulticastHandler (#9905) * Add spoof detection for UDP packets in UdpMulticastHandler * Implement isFromUs function for packet origin validation * ampersand --- src/mesh/udp/UdpMulticastHandler.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index f88b48d62..a5b0ef360 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -73,6 +73,11 @@ class UdpMulticastHandler final LOG_DEBUG("Decoding MeshPacket from UDP len=%u", packetLength); bool isPacketDecoded = pb_decode_from_bytes(packet.data(), packetLength, &meshtastic_MeshPacket_msg, &mp); if (isPacketDecoded && router && mp.which_payload_variant == meshtastic_MeshPacket_encrypted_tag) { + // Drop packets with spoofed local origin — no legitimate LAN node should send from=0 or our own nodeNum + if (isFromUs(&mp)) { + LOG_WARN("UDP packet with spoofed local from=0x%x, dropping", mp.from); + return; + } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; mp.pki_encrypted = false; mp.public_key.size = 0; @@ -113,4 +118,4 @@ class UdpMulticastHandler final AsyncUDP udp; bool isRunning; }; -#endif // HAS_UDP_MULTICAST \ No newline at end of file +#endif // HAS_UDP_MULTICAST From e51e6cad84c7e59e92a3d3372dfb1346f9b5719f Mon Sep 17 00:00:00 2001 From: Tom Fifield Date: Mon, 16 Mar 2026 19:28:21 +1100 Subject: [PATCH 08/50] Remove GPS Baudrate locking for Seeed Xiao S3 Kit (#9374) The Seeed Xiao S3 Kit's default GPS is an L76K which operates at 9600 baud, so when this variant was defined that baud rate was specified. However, this is a development board and it is expected that users can attach their own devices. This includes GPS, which may operate at a different baud rate. The current fixed baud rate prevents this, so this patch removes that setting. This will revert to the regular automatic probe method. This will successfully detect the L76K as before (the same speed as before since 9600 baud is the first baud rate checked), but also allow other GPSes at other baud rates to be detected. Thanks to @ScarpMarc for the report Fixes https://github.com/meshtastic/firmware/issues/9373#issuecomment-3774802763 --- variants/esp32s3/seeed_xiao_s3/variant.h | 1 - 1 file changed, 1 deletion(-) diff --git a/variants/esp32s3/seeed_xiao_s3/variant.h b/variants/esp32s3/seeed_xiao_s3/variant.h index 11bf48521..cbdbf8eb8 100644 --- a/variants/esp32s3/seeed_xiao_s3/variant.h +++ b/variants/esp32s3/seeed_xiao_s3/variant.h @@ -50,7 +50,6 @@ L76K GPS Module Information : https://www.seeedstudio.com/L76K-GNSS-Module-for-S #define GPS_RX_PIN 44 #define GPS_TX_PIN 43 #define HAS_GPS 1 -#define GPS_BAUDRATE 9600 #define GPS_THREAD_INTERVAL 50 #define PIN_SERIAL1_RX PIN_GPS_TX #define PIN_SERIAL1_TX PIN_GPS_RX From 53c21eb30d75bcc53d21d3e0085b36c0ff3d6236 Mon Sep 17 00:00:00 2001 From: Jord <650645+Jord-JD@users.noreply.github.com> Date: Mon, 16 Mar 2026 04:35:33 -0700 Subject: [PATCH 09/50] Deprecate/block packets with a missing/invalid hop_start value (pre-hop firmware) (related to issue #7369) (#9476) * Deprecate forwarding for invalid hop_start * Add pre-hop packet drop policy * Log ignored rebroadcasts for pre-hop packets * Respect pre-hop policy ALLOW in routing gates * Exempt local packets from pre-hop drop policy * Format pre-hop log line * Add MODERN_ONLY rebroadcast mode for pre-hop packets * Simplify implementation for drop packet only behaviour * Revert formatting-only changes * Match ReliableRouter EOF formatting * Make pre-hop drop a build-time flag * Rework to compile/build flag MESHTASTIC_PREHOP_DROP * Set MESHTASTIC_PREHOP_DROP off by default * Inline pre-hop hop_start validity check --------- Co-authored-by: Ben Meadors Co-authored-by: Jord <650645+DivineOmega@users.noreply.github.com> --- src/configuration.h | 5 +++++ src/mesh/NodeDB.cpp | 34 ++++++++++++++++++++++++++++++++++ src/mesh/NodeDB.h | 21 +++++++++++++++++++++ src/mesh/Router.cpp | 6 ++++++ 4 files changed, 66 insertions(+) diff --git a/src/configuration.h b/src/configuration.h index cc623455f..8936b9d98 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -78,6 +78,11 @@ along with this program. If not, see . // Configuration // ----------------------------------------------------------------------------- +// Pre-hop drop handling (compile-time flag). +#ifndef MESHTASTIC_PREHOP_DROP +#define MESHTASTIC_PREHOP_DROP 0 +#endif + /// Convert a preprocessor name into a quoted string #define xstr(s) ystr(s) #define ystr(s) #s diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 529f50003..454dc0478 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1646,6 +1646,25 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p) return delta; } +HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p) +{ + // Guard against invalid values. + if (p.hop_start < p.hop_limit) + return HopStartStatus::INVALID; + + if (p.hop_start == 0) { + // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a + // bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware + // version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as + // the bitfield is encrypted under the channel encryption key. + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield) + return HopStartStatus::VALID; + return HopStartStatus::MISSING_OR_UNKNOWN; + } + + return HopStartStatus::VALID; +} + int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown) { // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a @@ -1683,6 +1702,21 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly) #include "MeshModule.h" #include "Throttle.h" +static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000; + +void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) +{ + static uint32_t lastLogMs = 0; + if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) { + return; + } + lastLogMs = millis(); + const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag); + const bool hasBitfield = decoded && p.decoded.has_bitfield; + LOG_DEBUG("Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)", + context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield); +} + /** Update position info for this node based on received position data */ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index adf2b42ea..f6be963c1 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -114,6 +114,27 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p); /// Returns defaultIfUnknown if the number of hops couldn't be determined. int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1); +enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID }; + +/// Classify hop_start validity for forwarding decisions. +HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p); + +inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p) +{ +#if !MESHTASTIC_PREHOP_DROP + (void)p; + return false; +#else + if (isFromUs(&p)) { + return false; // local-originated packets should never be dropped by pre-hop drop policy + } + return classifyHopStart(p) != HopStartStatus::VALID; +#endif +} + +/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped. +void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context); + enum LoadFileResult { // Successfully opened the file LOAD_SUCCESS = 1, diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index db4b88413..2ffb2c56a 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -851,6 +851,12 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) return; } + if (shouldDropPacketForPreHop(*p)) { + logHopStartDrop(*p, "pre-hop drop"); + packetPool.release(p); + return; + } + if (shouldFilterReceived(p)) { LOG_DEBUG("Incoming msg was filtered from 0x%x", p->from); packetPool.release(p); From ac7a58cd4580df6337c4fa010166aa681cdf3545 Mon Sep 17 00:00:00 2001 From: Fernando Nunes Date: Mon, 16 Mar 2026 19:48:29 +0000 Subject: [PATCH 10/50] Fix: Traceroute through MQTT misses uplink node if MQTT is encrypted (#9798) * Attempt to fix issue 9713 * Code formatting issue. * Remade the fix to follow Copilot observations on PR * Rebuild after AI and GUVWAF * Update src/mesh/Router.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/mesh/Router.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * trunk fmt --------- Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: GUVWAF Co-authored-by: GUVWAF <78759985+GUVWAF@users.noreply.github.com> --- src/mesh/Router.cpp | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 2ffb2c56a..836cd1a22 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -802,8 +802,32 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) p_encrypted->pki_encrypted = true; // After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet if ((decodedState == DecodeState::DECODE_SUCCESS || p_encrypted->pki_encrypted) && moduleConfig.mqtt.enabled && - !isFromUs(p) && mqtt) + !isFromUs(p) && mqtt) { + if (decodedState == DecodeState::DECODE_SUCCESS && p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP && + moduleConfig.mqtt.encryption_enabled) { + // For TRACEROUTE_APP packets release the original encrypted packet and encrypt a new from the changed packet + // Only release the original after successful allocation to avoid losing an incomplete but valid packet + auto *p_encrypted_new = packetPool.allocCopy(*p); + if (p_encrypted_new) { + auto encodeResult = perhapsEncode(p_encrypted_new); + if (encodeResult != meshtastic_Routing_Error_NONE) { + // Encryption failed, release the new packet and fall back to sending the original encrypted packet to + // MQTT + LOG_WARN("Encryption of new TR packet failed, sending original TR to MQTT"); + packetPool.release(p_encrypted_new); + p_encrypted_new = nullptr; + } else { + // Successfully re-encrypted, release the original encrypted packet and use the new one for MQTT + packetPool.release(p_encrypted); + p_encrypted = p_encrypted_new; + } + } else { + // Allocation failed, log a warning and fall back to sending the original encrypted packet to MQTT + LOG_WARN("Failed to allocate new encrypted packet for TR, sending original TR to MQTT"); + } + } mqtt->onSend(*p_encrypted, *p, p->channel); + } } #endif } From 19d070c284e5ee41e13d2311a5dafe42885c495c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 17 Mar 2026 14:01:53 -0500 Subject: [PATCH 11/50] Trunk --- src/mesh/NodeDB.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 7ccee0941..605bb6211 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1717,8 +1717,9 @@ void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) lastLogMs = millis(); const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag); const bool hasBitfield = decoded && p.decoded.has_bitfield; - LOG_DEBUG("Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)", - context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield); + LOG_DEBUG( + "Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)", + context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield); } /** Update position info for this node based on received position data From 2ef09d17b980ae776ef76d2eeb20b3c5542b9bf9 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:42:37 -0400 Subject: [PATCH 12/50] BaseUI: Emote Refactoring (#9896) * Emote refactor for BaseUI * Trunk Check * Copilot suggestions --- src/graphics/EmoteRenderer.cpp | 434 +++++++++++++++++++++ src/graphics/EmoteRenderer.h | 79 ++++ src/graphics/SharedUIDisplay.cpp | 11 +- src/graphics/draw/MessageRenderer.cpp | 328 ++++------------ src/graphics/draw/NodeListRenderer.cpp | 101 +++-- src/graphics/draw/NodeListRenderer.h | 3 +- src/graphics/draw/NotificationRenderer.cpp | 74 +++- src/graphics/draw/UIRenderer.cpp | 130 +++--- src/graphics/draw/UIRenderer.h | 23 ++ src/modules/CannedMessageModule.cpp | 380 +++++++----------- 10 files changed, 940 insertions(+), 623 deletions(-) create mode 100644 src/graphics/EmoteRenderer.cpp create mode 100644 src/graphics/EmoteRenderer.h diff --git a/src/graphics/EmoteRenderer.cpp b/src/graphics/EmoteRenderer.cpp new file mode 100644 index 000000000..6fa0adb4c --- /dev/null +++ b/src/graphics/EmoteRenderer.cpp @@ -0,0 +1,434 @@ +#include "configuration.h" +#if HAS_SCREEN + +#include "graphics/EmoteRenderer.h" +#include +#include + +namespace graphics +{ +namespace EmoteRenderer +{ + +static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len) +{ +#if defined(OLED_UA) || defined(OLED_RU) + return display->getStringWidth(text, len, true); +#else + (void)len; + return display->getStringWidth(text); +#endif +} + +size_t utf8CharLen(uint8_t c) +{ + if ((c & 0xE0) == 0xC0) + return 2; + if ((c & 0xF0) == 0xE0) + return 3; + if ((c & 0xF8) == 0xF0) + return 4; + return 1; +} + +static inline bool isPossibleEmoteLead(uint8_t c) +{ + // All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges. + return c == 0xE2 || c == 0xF0; +} + +static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len) +{ + char chunk[5] = {0, 0, 0, 0, 0}; + if (len > 4) + len = 4; + memcpy(chunk, text, len); + return getStringWidth(display, chunk, len); +} + +static inline bool isFE0FAt(const char *s, size_t pos, size_t len) +{ + return pos + 2 < len && static_cast(s[pos]) == 0xEF && static_cast(s[pos + 1]) == 0xB8 && + static_cast(s[pos + 2]) == 0x8F; +} + +static inline bool isSkinToneAt(const char *s, size_t pos, size_t len) +{ + return pos + 3 < len && static_cast(s[pos]) == 0xF0 && static_cast(s[pos + 1]) == 0x9F && + static_cast(s[pos + 2]) == 0x8F && + (static_cast(s[pos + 3]) >= 0xBB && static_cast(s[pos + 3]) <= 0xBF); +} + +static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len) +{ + // Skip modifiers that do not change which bitmap we render. + if (isFE0FAt(s, pos, len)) + return 3; + if (isSkinToneAt(s, pos, len)) + return 4; + return 0; +} + +const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount) +{ + if (!label || !*label) + return nullptr; + + for (int i = 0; i < emoteCount; ++i) { + if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0) + return &emoteSet[i]; + } + + return nullptr; +} + +static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed, + size_t &matchScore) +{ + // Treat FE0F and skin-tone modifiers as optional while matching. + textConsumed = 0; + matchScore = 0; + if (!label || !*label || pos >= textLen) + return false; + + const size_t labelLen = strlen(label); + size_t ti = pos; + size_t li = 0; + + while (true) { + while (ti < textLen) { + const size_t skipLen = ignorableModifierLenAt(text, ti, textLen); + if (!skipLen) + break; + ti += skipLen; + } + while (li < labelLen) { + const size_t skipLen = ignorableModifierLenAt(label, li, labelLen); + if (!skipLen) + break; + li += skipLen; + } + + if (li >= labelLen) { + while (ti < textLen) { + const size_t skipLen = ignorableModifierLenAt(text, ti, textLen); + if (!skipLen) + break; + ti += skipLen; + } + textConsumed = ti - pos; + return textConsumed > 0; + } + + if (ti >= textLen) + return false; + + const uint8_t tc = static_cast(text[ti]); + const uint8_t lc = static_cast(label[li]); + const size_t tlen = utf8CharLen(tc); + const size_t llen = utf8CharLen(lc); + + if (tlen != llen || ti + tlen > textLen || li + llen > labelLen) + return false; + if (memcmp(text + ti, label + li, tlen) != 0) + return false; + + ti += tlen; + li += llen; + matchScore += llen; + } +} + +const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount) +{ + // Prefer the longest matching label at this byte offset. + const Emote *matched = nullptr; + matchLen = 0; + size_t bestScore = 0; + if (!text || pos >= textLen) + return nullptr; + + if (!isPossibleEmoteLead(static_cast(text[pos]))) + return nullptr; + + for (int i = 0; i < emoteCount; ++i) { + const char *label = emoteSet[i].label; + if (!label || !*label) + continue; + if (static_cast(label[0]) != static_cast(text[pos])) + continue; + + const size_t labelLen = strlen(label); + if (labelLen == 0) + continue; + + size_t candidateLen = 0; + size_t candidateScore = 0; + if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) { + candidateLen = labelLen; + candidateScore = labelLen; + } else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) { + // Matched with FE0F/skin tone modifiers treated as optional. + } else { + continue; + } + + if (candidateScore > bestScore) { + matched = &emoteSet[i]; + matchLen = candidateLen; + bestScore = candidateScore; + } + } + + return matched; +} + +static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight, + const Emote *emoteSet, int emoteCount, int emoteSpacing) +{ + // Scan once to collect width and tallest emote for this line. + LineMetrics metrics{0, fallbackHeight, false}; + if (!line) + return metrics; + + for (size_t i = 0; i < lineLen;) { + size_t matchLen = 0; + const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); + if (matched) { + metrics.hasEmote = true; + metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height); + if (display) + metrics.width += matched->width + emoteSpacing; + i += matchLen; + continue; + } + + const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); + if (skipLen) { + i += skipLen; + continue; + } + + const size_t charLen = utf8CharLen(static_cast(line[i])); + if (display) + metrics.width += getUtf8ChunkWidth(display, line + i, charLen); + i += charLen; + } + + return metrics; +} + +LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount, + int emoteSpacing) +{ + return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing); +} + +int maxEmoteHeight(const Emote *emoteSet, int emoteCount) +{ + int tallest = 0; + for (int i = 0; i < emoteCount; ++i) { + if (emoteSet[i].label && *emoteSet[i].label) + tallest = std::max(tallest, emoteSet[i].height); + } + return tallest; +} + +int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing) +{ + if (!display) + return 0; + + if (!line || !*line) + return 0; + + return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width; +} + +static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw, + bool fauxBold) +{ + // Draw plain-text runs in chunks so UTF-8 stays intact. + if (!text || len == 0) + return cursorX; + + char chunk[33]; + size_t pos = 0; + while (pos < len) { + size_t chunkLen = 0; + while (pos + chunkLen < len) { + const size_t charLen = utf8CharLen(static_cast(text[pos + chunkLen])); + if (chunkLen + charLen >= sizeof(chunk)) + break; + chunkLen += charLen; + } + + if (chunkLen == 0) { + chunkLen = std::min(len - pos, sizeof(chunk) - 1); + } + + memcpy(chunk, text + pos, chunkLen); + chunk[chunkLen] = '\0'; + if (draw) { + if (fauxBold) + display->drawString(cursorX + 1, fontY, chunk); + display->drawString(cursorX, fontY, chunk); + } + cursorX += getStringWidth(display, chunk, chunkLen); + pos += chunkLen; + } + + return cursorX; +} + +size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis, + const Emote *emoteSet, int emoteCount, int emoteSpacing) +{ + if (!out || outSize == 0) + return 0; + + out[0] = '\0'; + if (!display || !line || maxWidth <= 0) + return 0; + + const size_t lineLen = strlen(line); + const int suffixWidth = + (ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0; + const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : ""; + const size_t suffixLen = strlen(suffix); + const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0); + + if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) { + strncpy(out, line, outSize - 1); + out[outSize - 1] = '\0'; + return strlen(out); + } + + int used = 0; + size_t cut = 0; + for (size_t i = 0; i < lineLen;) { + // Keep whole emotes together when deciding where to cut. + int tokenWidth = 0; + size_t advance = 0; + + if (isPossibleEmoteLead(static_cast(line[i]))) { + size_t matchLen = 0; + const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); + if (matched) { + tokenWidth = matched->width + emoteSpacing; + advance = matchLen; + } + } + + if (advance == 0) { + const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); + if (skipLen) { + i += skipLen; + cut = i; + continue; + } + + const size_t charLen = utf8CharLen(static_cast(line[i])); + tokenWidth = getUtf8ChunkWidth(display, line + i, charLen); + advance = charLen; + } + + if (used + tokenWidth > availableWidth) + break; + + used += tokenWidth; + i += advance; + cut = i; + } + + if (cut == 0) { + strncpy(out, suffix, outSize - 1); + out[outSize - 1] = '\0'; + return strlen(out); + } + + size_t copyLen = cut; + if (copyLen > outSize - 1) + copyLen = outSize - 1; + if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) { + copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0; + } + + memcpy(out, line, copyLen); + size_t totalLen = copyLen; + if (suffixLen > 0 && totalLen < outSize - 1) { + memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen)); + totalLen += std::min(suffixLen, outSize - 1 - totalLen); + } + out[totalLen] = '\0'; + return totalLen; +} + +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet, + int emoteCount, int emoteSpacing, bool fauxBold) +{ + if (!line) + return; + + const size_t lineLen = strlen(line); + // Center text vertically when any emote is taller than the font. + const int maxIconHeight = + analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight; + const int lineHeight = std::max(fontHeight, maxIconHeight); + const int fontY = y + (lineHeight - fontHeight) / 2; + + int cursorX = x; + bool inBold = false; + + for (size_t i = 0; i < lineLen;) { + // Toggle faux bold. + if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') { + inBold = !inBold; + i += 2; + continue; + } + + const size_t skipLen = ignorableModifierLenAt(line, i, lineLen); + if (skipLen) { + i += skipLen; + continue; + } + + size_t matchLen = 0; + const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount); + if (matched) { + const int iconY = y + (lineHeight - matched->height) / 2; + display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap); + cursorX += matched->width + emoteSpacing; + i += matchLen; + continue; + } + + size_t next = i; + while (next < lineLen) { + // Stop the text run before the next emote or bold marker. + if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*') + break; + + if (ignorableModifierLenAt(line, next, lineLen)) + break; + + size_t nextMatchLen = 0; + if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr) + break; + + next += utf8CharLen(static_cast(line[next])); + } + + if (next == i) + next += utf8CharLen(static_cast(line[i])); + + cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold); + i = next; + } +} + +} // namespace EmoteRenderer +} // namespace graphics + +#endif // HAS_SCREEN diff --git a/src/graphics/EmoteRenderer.h b/src/graphics/EmoteRenderer.h new file mode 100644 index 000000000..93cee4b25 --- /dev/null +++ b/src/graphics/EmoteRenderer.h @@ -0,0 +1,79 @@ +#pragma once +#include "configuration.h" + +#if HAS_SCREEN +#include "graphics/emotes.h" +#include +#include +#include +#include + +namespace graphics +{ +namespace EmoteRenderer +{ + +struct LineMetrics { + int width; + int tallestHeight; + bool hasEmote; +}; + +size_t utf8CharLen(uint8_t c); + +const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet = emotes, int emoteCount = numEmotes); +const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes, + int emoteCount = numEmotes); +inline const Emote *findEmoteAt(const std::string &text, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes, + int emoteCount = numEmotes) +{ + return findEmoteAt(text.c_str(), text.length(), pos, matchLen, emoteSet, emoteCount); +} + +LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight = 0, const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1); +inline LineMetrics analyzeLine(OLEDDisplay *display, const std::string &line, int fallbackHeight = 0, + const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1) +{ + return analyzeLine(display, line.c_str(), fallbackHeight, emoteSet, emoteCount, emoteSpacing); +} +int maxEmoteHeight(const Emote *emoteSet = emotes, int emoteCount = numEmotes); + +int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet = emotes, int emoteCount = numEmotes, + int emoteSpacing = 1); +inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1) +{ + return measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing); +} +size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, + const char *ellipsis = "...", const Emote *emoteSet = emotes, int emoteCount = numEmotes, + int emoteSpacing = 1); +inline std::string truncateToWidth(OLEDDisplay *display, const std::string &line, int maxWidth, + const std::string &ellipsis = "...", const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1) +{ + if (!display || maxWidth <= 0) + return ""; + if (measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing) <= maxWidth) + return line; + + std::vector out(line.length() + ellipsis.length() + 1, '\0'); + truncateToWidth(display, line.c_str(), out.data(), out.size(), maxWidth, ellipsis.c_str(), emoteSet, emoteCount, + emoteSpacing); + return std::string(out.data()); +} + +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet = emotes, + int emoteCount = numEmotes, int emoteSpacing = 1, bool fauxBold = true); +inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight, + const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1, + bool fauxBold = true) +{ + drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSet, emoteCount, emoteSpacing, fauxBold); +} + +} // namespace EmoteRenderer +} // namespace graphics + +#endif // HAS_SCREEN diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index b86f3e32c..ec50654ae 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -121,11 +121,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti } // === Screen Title === - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(SCREEN_WIDTH / 2, y, titleStr); - if (config.display.heading_bold) { - display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr); - } + const char *headerTitle = titleStr ? titleStr : ""; + const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); + const int titleX = (SCREEN_WIDTH - titleWidth) / 2; + UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } display->setTextAlignment(TEXT_ALIGN_LEFT); @@ -515,4 +514,4 @@ std::string sanitizeString(const std::string &input) } } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 79d8b1ccd..501a7ae2c 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -7,6 +7,7 @@ #include "NodeDB.h" #include "UIRenderer.h" #include "gps/RTC.h" +#include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" @@ -34,44 +35,6 @@ static std::vector cachedLines; static std::vector cachedHeights; static bool manualScrolling = false; -// UTF-8 skip helper -static inline size_t utf8CharLen(uint8_t c) -{ - if ((c & 0xE0) == 0xC0) - return 2; - if ((c & 0xF0) == 0xE0) - return 3; - if ((c & 0xF8) == 0xF0) - return 4; - return 1; -} - -// Remove variation selectors (FE0F) and skin tone modifiers from emoji so they match your labels -static std::string normalizeEmoji(const std::string &s) -{ - std::string out; - for (size_t i = 0; i < s.size();) { - uint8_t c = static_cast(s[i]); - size_t len = utf8CharLen(c); - - if (c == 0xEF && i + 2 < s.size() && (uint8_t)s[i + 1] == 0xB8 && (uint8_t)s[i + 2] == 0x8F) { - i += 3; - continue; - } - - // Skip skin tone modifiers - if (c == 0xF0 && i + 3 < s.size() && (uint8_t)s[i + 1] == 0x9F && (uint8_t)s[i + 2] == 0x8F && - ((uint8_t)s[i + 3] >= 0xBB && (uint8_t)s[i + 3] <= 0xBF)) { - i += 4; - continue; - } - - out.append(s, i, len); - i += len; - } - return out; -} - // Scroll state (file scope so we can reset on new message) float scrollY = 0.0f; uint32_t lastTime = 0; @@ -110,102 +73,7 @@ void scrollDown() void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount) { - int cursorX = x; - const int fontHeight = FONT_HEIGHT_SMALL; - - // Step 1: Find tallest emote in the line - int maxIconHeight = fontHeight; - for (size_t i = 0; i < line.length();) { - bool matched = false; - for (int e = 0; e < emoteCount; ++e) { - size_t emojiLen = strlen(emotes[e].label); - if (line.compare(i, emojiLen, emotes[e].label) == 0) { - if (emotes[e].height > maxIconHeight) - maxIconHeight = emotes[e].height; - i += emojiLen; - matched = true; - break; - } - } - if (!matched) { - i += utf8CharLen(static_cast(line[i])); - } - } - - // Step 2: Baseline alignment - int lineHeight = std::max(fontHeight, maxIconHeight); - int baselineOffset = (lineHeight - fontHeight) / 2; - int fontY = y + baselineOffset; - - // Step 3: Render line in segments - size_t i = 0; - bool inBold = false; - - while (i < line.length()) { - // Check for ** start/end for faux bold - if (line.compare(i, 2, "**") == 0) { - inBold = !inBold; - i += 2; - continue; - } - - // Look ahead for the next emote match - size_t nextEmotePos = std::string::npos; - const Emote *matchedEmote = nullptr; - size_t emojiLen = 0; - - for (int e = 0; e < emoteCount; ++e) { - size_t pos = line.find(emotes[e].label, i); - if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) { - nextEmotePos = pos; - matchedEmote = &emotes[e]; - emojiLen = strlen(emotes[e].label); - } - } - - // Render normal text segment up to the emote or bold toggle - size_t nextControl = std::min(nextEmotePos, line.find("**", i)); - if (nextControl == std::string::npos) - nextControl = line.length(); - - if (nextControl > i) { - std::string textChunk = line.substr(i, nextControl - i); - if (inBold) { - // Faux bold: draw twice, offset by 1px - display->drawString(cursorX + 1, fontY, textChunk.c_str()); - } - display->drawString(cursorX, fontY, textChunk.c_str()); -#if defined(OLED_UA) || defined(OLED_RU) - cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true); -#else - cursorX += display->getStringWidth(textChunk.c_str()); -#endif - i = nextControl; - continue; - } - - // Render the emote (if found) - if (matchedEmote && i == nextEmotePos) { - int iconY = y + (lineHeight - matchedEmote->height) / 2; - display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap); - cursorX += matchedEmote->width + 1; - i += emojiLen; - continue; - } else { - // No more emotes — render the rest of the line - std::string remaining = line.substr(i); - if (inBold) { - display->drawString(cursorX + 1, fontY, remaining.c_str()); - } - display->drawString(cursorX, fontY, remaining.c_str()); -#if defined(OLED_UA) || defined(OLED_RU) - cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true); -#else - cursorX += display->getStringWidth(remaining.c_str()); -#endif - break; - } - } + graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount); } // Reset scroll state when new messages arrive @@ -377,32 +245,7 @@ static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8) static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount) { - std::string normalized = normalizeEmoji(line); - int totalWidth = 0; - - size_t i = 0; - while (i < normalized.length()) { - bool matched = false; - for (int e = 0; e < emoteCount; ++e) { - size_t emojiLen = strlen(emotes[e].label); - if (normalized.compare(i, emojiLen, emotes[e].label) == 0) { - totalWidth += emotes[e].width + 1; // +1 spacing - i += emojiLen; - matched = true; - break; - } - } - if (!matched) { - size_t charLen = utf8CharLen(static_cast(normalized[i])); -#if defined(OLED_UA) || defined(OLED_RU) - totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str(), charLen, true); -#else - totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str()); -#endif - i += charLen; - } - } - return totalWidth; + return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width; } struct MessageBlock { @@ -417,13 +260,7 @@ static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool i return lineTopY + (FONT_HEIGHT_SMALL - 1); } - int tallest = FONT_HEIGHT_SMALL; - for (int e = 0; e < numEmotes; ++e) { - if (line.find(emotes[e].label) != std::string::npos) { - if (emotes[e].height > tallest) - tallest = emotes[e].height; - } - } + const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight; const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest); const int iconTop = lineTopY + (lineHeight - tallest) / 2; @@ -536,30 +373,28 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH; // Title string depending on mode - static char titleBuf[32]; - const char *titleStr = "Messages"; + char titleStr[48]; + snprintf(titleStr, sizeof(titleStr), "Messages"); switch (currentMode) { case ThreadMode::ALL: - titleStr = "Messages"; + snprintf(titleStr, sizeof(titleStr), "Messages"); break; case ThreadMode::CHANNEL: { const char *cname = channels.getName(currentChannel); if (cname && cname[0]) { - snprintf(titleBuf, sizeof(titleBuf), "#%s", cname); + snprintf(titleStr, sizeof(titleStr), "#%s", cname); } else { - snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel); + snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel); } - titleStr = titleBuf; break; } case ThreadMode::DIRECT: { meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer); - if (node && node->has_user) { - snprintf(titleBuf, sizeof(titleBuf), "@%s", node->user.short_name); + if (node && node->has_user && node->user.short_name[0]) { + snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name); } else { - snprintf(titleBuf, sizeof(titleBuf), "@%08x", currentPeer); + snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer); } - titleStr = titleBuf; break; } } @@ -666,44 +501,50 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender); meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest); - char senderBuf[48] = ""; + char senderName[64] = ""; if (node && node->has_user) { - // Use long name if present - strncpy(senderBuf, node->user.long_name, sizeof(senderBuf) - 1); - senderBuf[sizeof(senderBuf) - 1] = '\0'; - } else { - // No long/short name → show NodeID in parentheses - snprintf(senderBuf, sizeof(senderBuf), "(%08x)", m.sender); + if (node->user.long_name[0]) { + strncpy(senderName, node->user.long_name, sizeof(senderName) - 1); + } else if (node->user.short_name[0]) { + strncpy(senderName, node->user.short_name, sizeof(senderName) - 1); + } + senderName[sizeof(senderName) - 1] = '\0'; + } + if (!senderName[0]) { + snprintf(senderName, sizeof(senderName), "(%08x)", m.sender); } - // If this is *our own* message, override senderBuf to who the recipient was + // If this is *our own* message, override senderName to who the recipient was bool mine = (m.sender == nodeDB->getNodeNum()); if (mine && node_recipient && node_recipient->has_user) { - strcpy(senderBuf, node_recipient->user.long_name); + if (node_recipient->user.long_name[0]) { + strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1); + senderName[sizeof(senderName) - 1] = '\0'; + } else if (node_recipient->user.short_name[0]) { + strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1); + senderName[sizeof(senderName) - 1] = '\0'; + } + } + // If recipient info is missing/empty, prefer a recipient identifier for outbound messages. + if (mine && (!node_recipient || !node_recipient->has_user || + (!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) { + snprintf(senderName, sizeof(senderName), "(%08x)", m.dest); } // Shrink Sender name if needed int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) - - display->getStringWidth(chanType) - display->getStringWidth(" @..."); + display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @..."); if (availWidth < 0) availWidth = 0; - - size_t origLen = strlen(senderBuf); - while (senderBuf[0] && display->getStringWidth(senderBuf) > availWidth) { - senderBuf[strlen(senderBuf) - 1] = '\0'; - } - - // If we actually truncated, append "..." - if (strlen(senderBuf) < origLen) { - strcat(senderBuf, "..."); - } + char truncatedSender[64]; + graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth); // Final header line - char headerStr[96]; + char headerStr[128]; if (mine) { if (currentMode == ThreadMode::ALL) { if (strcmp(chanType, "(DM)") == 0) { - snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, senderBuf); + snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender); } else { snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType); } @@ -711,11 +552,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 snprintf(headerStr, sizeof(headerStr), "%s", timeBuf); } } else { - snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, senderBuf, chanType); + snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType); } // Push header line - allLines.push_back(std::string(headerStr)); + allLines.push_back(headerStr); isMine.push_back(mine); isHeader.push_back(true); ackForLine.push_back(m.ackStatus); @@ -816,13 +657,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 topY = visualTop - BUBBLE_PAD_TOP_HEADER; } else { // Body start - bool thisLineHasEmote = false; - for (int e = 0; e < numEmotes; ++e) { - if (cachedLines[b.start].find(emotes[e].label) != std::string::npos) { - thisLineHasEmote = true; - break; - } - } + const bool thisLineHasEmote = + graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote; if (thisLineHasEmote) { constexpr int EMOTE_PADDING_ABOVE = 4; visualTop -= EMOTE_PADDING_ABOVE; @@ -851,7 +687,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 for (size_t i = b.start; i <= b.end; ++i) { int w = 0; if (isHeader[i]) { - w = display->getStringWidth(cachedLines[i].c_str()); + w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str()); if (b.mine) w += 12; // room for ACK/NACK/relay mark } else { @@ -907,7 +743,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 if (lineY > -cachedHeights[i] && lineY < scrollBottom) { if (isHeader[i]) { - int w = display->getStringWidth(cachedLines[i].c_str()); + int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str()); int headerX; if (isMine[i]) { // push header left to avoid overlap with scrollbar @@ -917,7 +753,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 } else { headerX = x + textIndent; } - display->drawString(headerX, lineY, cachedLines[i].c_str()); + graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1, + false); // Draw underline just under header text int underlineY = lineY + FONT_HEIGHT_SMALL; @@ -1005,11 +842,7 @@ std::vector generateLines(OLEDDisplay *display, const char *headerS } else { word += ch; std::string test = line + word; -#if defined(OLED_UA) || defined(OLED_RU) - uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true); -#else - uint16_t strWidth = display->getStringWidth(test.c_str()); -#endif + uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str()); if (strWidth > textWidth) { if (!line.empty()) lines.push_back(line); @@ -1038,31 +871,20 @@ std::vector calculateLineHeights(const std::vector &lines, con std::vector rowHeights; rowHeights.reserve(lines.size()); + std::vector lineMetrics; + lineMetrics.reserve(lines.size()); + + for (const auto &line : lines) { + lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes)); + } for (size_t idx = 0; idx < lines.size(); ++idx) { - const auto &line = lines[idx]; const int baseHeight = FONT_HEIGHT_SMALL; int lineHeight = baseHeight; - // Detect if THIS line or NEXT line contains an emote - bool hasEmote = false; - int tallestEmote = baseHeight; - for (int i = 0; i < numEmotes; ++i) { - if (line.find(emotes[i].label) != std::string::npos) { - hasEmote = true; - tallestEmote = std::max(tallestEmote, emotes[i].height); - } - } - - bool nextHasEmote = false; - if (idx + 1 < lines.size()) { - for (int i = 0; i < numEmotes; ++i) { - if (lines[idx + 1].find(emotes[i].label) != std::string::npos) { - nextHasEmote = true; - break; - } - } - } + const int tallestEmote = lineMetrics[idx].tallestHeight; + const bool hasEmote = lineMetrics[idx].hasEmote; + const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote; if (isHeaderVec[idx]) { // Header line spacing @@ -1112,22 +934,22 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht // Banner logic const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); - char longName[48] = "?"; - if (node && node->user.long_name) { - strncpy(longName, node->user.long_name, sizeof(longName) - 1); - longName[sizeof(longName) - 1] = '\0'; + char longName[64] = "?"; + if (node && node->has_user) { + if (node->user.long_name[0]) { + strncpy(longName, node->user.long_name, sizeof(longName) - 1); + longName[sizeof(longName) - 1] = '\0'; + } else if (node->user.short_name[0]) { + strncpy(longName, node->user.short_name, sizeof(longName) - 1); + longName[sizeof(longName) - 1] = '\0'; + } } int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20); if (availWidth < 0) availWidth = 0; - - size_t origLen = strlen(longName); - while (longName[0] && display->getStringWidth(longName) > availWidth) { - longName[strlen(longName) - 1] = '\0'; - } - if (strlen(longName) < origLen) { - strcat(longName, "..."); - } + char truncatedLongName[64]; + graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName), + availWidth); const char *msgRaw = reinterpret_cast(packet.decoded.payload.bytes); char banner[256]; @@ -1145,8 +967,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht } if (isAlert) { - if (longName && longName[0]) - snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); + if (truncatedLongName[0]) + snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName); else strcpy(banner, "Alert Received"); } else { @@ -1154,11 +976,11 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht if (isChannelMuted) return; - if (longName && longName[0]) { + if (truncatedLongName[0]) { if (currentResolution == ScreenResolution::UltraLow) { strcpy(banner, "New Message"); } else { - snprintf(banner, sizeof(banner), "New Message from\n%s", longName); + snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName); } } else strcpy(banner, "New Message"); @@ -1221,4 +1043,4 @@ void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet) } // namespace MessageRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index b36a5057c..98644ee3b 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -79,13 +79,15 @@ void scrollDown() // Utility Functions // ============================= -const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth) +std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth) { - static char nodeName[25]; // single static buffer we return - nodeName[0] = '\0'; + (void)display; + (void)columnWidth; - auto writeFallbackId = [&] { - std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0)); + auto fallbackId = [&] { + char id[12]; + std::snprintf(id, sizeof(id), "(%04X)", static_cast(node ? (node->num & 0xFFFF) : 0)); + return std::string(id); }; // 1) Choose target candidate (long vs short) only if present @@ -94,42 +96,10 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; } - // 2) Sanitize (empty if raw is null/empty) - std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{}; - - // 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed) - if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) { - writeFallbackId(); - } else { - // %.*s ensures null-termination and safe truncation to buffer size - 1 - std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast(sizeof(nodeName) - 1), s.c_str()); - } - - // 4) Width-based truncation + ellipsis (long-name mode only) - if (config.display.use_long_node_name && display) { - int availWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38); - if (availWidth < 0) - availWidth = 0; - - const size_t beforeLen = std::strlen(nodeName); - - // Trim from the end until it fits or is empty - size_t len = beforeLen; - while (len && display->getStringWidth(nodeName) > availWidth) { - nodeName[--len] = '\0'; - } - - // If truncated, append "..." (respect buffer size) - if (len < beforeLen) { - // Make sure there's room for "..." and '\0' - const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0' - const size_t needed = 3; // "..." - if (len > capForText - needed) { - len = capForText - needed; - nodeName[len] = '\0'; - } - std::strcat(nodeName, "..."); - } + // 2) Preserve UTF-8 names so emotes can be detected and rendered. + std::string nodeName = (raw && *raw) ? std::string(raw) : std::string{}; + if (nodeName.empty()) { + nodeName = fallbackId(); } return nodeName; @@ -163,6 +133,15 @@ const char *getCurrentModeTitle_Location(int screenWidth) } } +static int getNodeNameMaxWidth(int columnWidth, int baseWidth) +{ + if (!config.display.use_long_node_name) + return baseWidth; + + const int legacyLongNameWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38); + return std::max(0, std::min(baseWidth, legacyLongNameWidth)); +} + // Use dynamic timing based on mode unsigned long getModeCycleIntervalMs() { @@ -205,10 +184,13 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) { bool isLeftCol = (x < SCREEN_WIDTH / 2); - int nameMaxWidth = columnWidth - 25; + int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25); int timeOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7); - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char timeStr[10]; @@ -228,7 +210,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawString(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -255,19 +237,22 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int { bool isLeftCol = (x < SCREEN_WIDTH / 2); - int nameMaxWidth = columnWidth - 25; + int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25); int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19); int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17); int barsXOffset = columnWidth - barsOffset; - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -312,9 +297,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 { bool isLeftCol = (x < SCREEN_WIDTH / 2); int nameMaxWidth = - columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) + : (isLeftCol ? 20 : 22))); - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; char distStr[10] = ""; @@ -368,7 +357,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -414,14 +403,18 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16 // Adjust max text width depending on column and screen width int nameMaxWidth = - columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) + : (isLeftCol ? 20 : 22))); - const char *nodeName = getSafeNodeName(display, node, columnWidth); + const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3); + char nodeName[96]; + UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName), + nameMaxWidth); bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName); + UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false); if (node->is_favorite) { if (currentResolution == ScreenResolution::High) { drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display); @@ -828,4 +821,4 @@ void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields } // namespace NodeListRenderer } // namespace graphics -#endif \ No newline at end of file +#endif diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index e212c031b..be80a7d80 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -4,6 +4,7 @@ #include "mesh/generated/meshtastic/mesh.pb.h" #include #include +#include namespace graphics { @@ -56,7 +57,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, // Utility functions const char *getCurrentModeTitle_Nodes(int screenWidth); const char *getCurrentModeTitle_Location(int screenWidth); -const char *getSafeNodeName(meshtastic_NodeInfoLite *node, int columnWidth); +std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth); void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); // Scrolling controls diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 04c841884..31eb2c3c8 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -4,6 +4,7 @@ #include "DisplayFormatters.h" #include "NodeDB.h" #include "NotificationRenderer.h" +#include "UIRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" @@ -299,7 +300,7 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta for (int i = 0; i < lineCount; i++) { linePointers[i] = lineStarts[i]; } - char scratchLineBuffer[visibleTotalLines - lineCount][40]; + char scratchLineBuffer[visibleTotalLines - lineCount][64]; uint8_t firstOptionToShow = 0; if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) { @@ -312,28 +313,47 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta } int scratchLineNum = 0; for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) { - char temp_name[16] = {0}; - if (nodeDB->getMeshNodeByIndex(i + 1)->has_user) { - std::string sanitized = sanitizeString(nodeDB->getMeshNodeByIndex(i + 1)->user.long_name); - strncpy(temp_name, sanitized.c_str(), sizeof(temp_name) - 1); + char tempName[48] = {0}; + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1); + if (node && node->has_user) { + const char *rawName = nullptr; + if (node->user.long_name[0]) { + rawName = node->user.long_name; + } else if (node->user.short_name[0]) { + rawName = node->user.short_name; + } + if (rawName) { + const int arrowWidth = (currentResolution == ScreenResolution::High) + ? UIRenderer::measureStringWithEmotes(display, "> <") + : UIRenderer::measureStringWithEmotes(display, "><"); + const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth); + UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth); + } } else { - snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF)); + snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0)); + } + if (!tempName[0]) { + snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0)); } if (i == curSelected) { - selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num; + selectedNodenum = node ? node->num : 0; if (currentResolution == ScreenResolution::High) { strncpy(scratchLineBuffer[scratchLineNum], "> ", 3); - strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36); - strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3); + strncpy(scratchLineBuffer[scratchLineNum] + 2, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 3); + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; + const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1); + strncpy(scratchLineBuffer[scratchLineNum] + used, " <", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1); } else { strncpy(scratchLineBuffer[scratchLineNum], ">", 2); - strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37); - strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2); + strncpy(scratchLineBuffer[scratchLineNum] + 1, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 2); + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; + const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1); + strncpy(scratchLineBuffer[scratchLineNum] + used, "<", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1); } - scratchLineBuffer[scratchLineNum][39] = '\0'; + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; } else { - strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39); - scratchLineBuffer[scratchLineNum][39] = '\0'; + strncpy(scratchLineBuffer[scratchLineNum], tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 1); + scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0'; } linePointers[linesShown] = scratchLineBuffer[scratchLineNum++]; } @@ -501,7 +521,13 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay else // if the newline wasn't found, then pull string length from strlen lineLengths[lineCount] = strlen(lines[lineCount]); - lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); + if (current_notification_type == notificationTypeEnum::node_picker) { + char measureBuffer[64] = {0}; + strncpy(measureBuffer, lines[lineCount], std::min(lineLengths[lineCount], sizeof(measureBuffer) - 1)); + lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer); + } else { + lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); + } // Consider extra width for signal bars on lines that contain "Signal:" uint16_t potentialWidth = lineWidths[lineCount]; @@ -607,7 +633,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset); display->setColor(BLACK); int yOffset = 3; - display->drawString(textX, lineY - yOffset, lineBuffer); + if (current_notification_type == notificationTypeEnum::node_picker) { + UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false); + } else { + display->drawString(textX, lineY - yOffset, lineBuffer); + } display->setColor(WHITE); lineY += (effectiveLineHeight - 2 - background_yOffset); } else { @@ -626,7 +656,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay int totalWidth = textWidth + barsWidth; int groupStartX = boxLeft + (boxWidth - totalWidth) / 2; - display->drawString(groupStartX, lineY, lineBuffer); + if (current_notification_type == notificationTypeEnum::node_picker) { + UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false); + } else { + display->drawString(groupStartX, lineY, lineBuffer); + } int baseX = groupStartX + textWidth + gap; int baseY = lineY + effectiveLineHeight - 1; @@ -642,7 +676,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay } } } else { - display->drawString(textX, lineY, lineBuffer); + if (current_notification_type == notificationTypeEnum::node_picker) { + UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false); + } else { + display->drawString(textX, lineY, lineBuffer); + } } lineY += (effectiveLineHeight); } diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 25a70f16d..e3a4d13a2 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -8,6 +8,7 @@ #include "UIRenderer.h" #include "airtime.h" #include "gps/GeoCoord.h" +#include "graphics/EmoteRenderer.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TimeFormatters.h" #include "graphics/images.h" @@ -313,8 +314,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i #endif currentFavoriteNodeNum = node->num; // === Create the shortName and title string === - const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node"; - char titlestr[32] = {0}; + const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node"; + char titlestr[40]; snprintf(titlestr, sizeof(titlestr), "*%s*", shortName); // === Draw battery/time/mail header (common across screens) === @@ -328,7 +329,6 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i // List of available macro Y positions in order, from top to bottom. int line = 1; // which slot to use next - std::string usernameStr; // === 1. Long Name (always try to show first) === const char *username; if (currentResolution == ScreenResolution::UltraLow) { @@ -338,9 +338,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i } if (username) { - usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case // Print node's long name (e.g. "Backpack Node") - display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str()); + UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false); } // === 2. Signal and Hops (combined on one line, if available) === @@ -802,14 +801,12 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta // === Node Identity === int textWidth = 0; int nameX = 0; - char shortnameble[35]; - snprintf(shortnameble, sizeof(shortnameble), "%s", - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); + const char *shortName = owner.short_name ? owner.short_name : ""; // === ShortName Centered === - textWidth = display->getStringWidth(shortnameble); + textWidth = UIRenderer::measureStringWithEmotes(display, shortName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false); #else if (powerStatus->getHasBattery()) { char batStr[20]; @@ -904,36 +901,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta int textWidth = 0; int nameX = 0; int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5; - std::string longNameStr; - - if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) { - longNameStr = sanitizeString(ourNode->user.long_name); + const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : ""; + const char *shortName = owner.short_name ? owner.short_name : ""; + char combinedName[96]; + if (longName[0] && shortName[0]) { + snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName); + } else if (longName[0]) { + strncpy(combinedName, longName, sizeof(combinedName) - 1); + combinedName[sizeof(combinedName) - 1] = '\0'; + } else { + strncpy(combinedName, shortName, sizeof(combinedName) - 1); + combinedName[sizeof(combinedName) - 1] = '\0'; } - char shortnameble[35]; - snprintf(shortnameble, sizeof(shortnameble), "%s", - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); - - char combinedName[50]; - snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble); - if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) { - size_t len = strlen(combinedName); - if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) { - combinedName[len - 3] = '\0'; // Remove the last three characters - } - textWidth = display->getStringWidth(combinedName); + if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) { + textWidth = UIRenderer::measureStringWithEmotes(display, combinedName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString( - nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName); + UIRenderer::drawStringWithEmotes( + display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, + combinedName, FONT_HEIGHT_SMALL, 1, false); } else { // === LongName Centered === - textWidth = display->getStringWidth(longNameStr.c_str()); + textWidth = UIRenderer::measureStringWithEmotes(display, longName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str()); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1, + false); // === ShortName Centered === - textWidth = display->getStringWidth(shortnameble); + textWidth = UIRenderer::measureStringWithEmotes(display, shortName); nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, getTextPositions(display)[line++], shortnameble); + UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, + false); } #endif graphics::drawCommonFooter(display, x, y); @@ -1045,12 +1042,12 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState display->setTextAlignment(TEXT_ALIGN_LEFT); const char *pauseText = "Screen Paused"; const char *idText = owner.short_name; - const bool useId = haveGlyphs(idText); + const bool useId = (idText && idText[0]); constexpr uint8_t padding = 2; constexpr uint8_t dividerGap = 1; // Text widths - const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); + const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0; const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding; const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2); @@ -1075,7 +1072,7 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState // Draw: text if (useId) - display->drawString(idTextLeft, idTextTop, idText); + UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false); display->drawString(pauseTextLeft, pauseTextTop, pauseText); display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold @@ -1108,11 +1105,16 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED display->drawString(msgX, msgY, upperMsg); } // Draw version and short name in bottom middle - char buf[25]; - snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT), - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf); + char footer[64]; + if (owner.short_name && owner.short_name[0]) { + snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name); + } else { + snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT)); + } + int footerW = UIRenderer::measureStringWithEmotes(display, footer); + int footerX = x + ((SCREEN_WIDTH - footerW) / 2); + UIRenderer::drawStringWithEmotes(display, footerX, 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 @@ -1130,12 +1132,15 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED display->drawString(x + 0, y + 0, upperMsg); // Draw version and short name in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), - graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); + const char *version = xstr(APP_VERSION_SHORT); + int versionX = x + SCREEN_WIDTH - display->getStringWidth(version); + display->drawString(versionX, y + 0, 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); + } screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1365,11 +1370,15 @@ void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, O display->drawString(x + 0, y + 0, upperMsg); // Draw version and shortname in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); + const char *version = xstr(APP_VERSION_SHORT); + int versionX = x + SCREEN_WIDTH - display->getStringWidth(version); + display->drawString(versionX, y + 0, 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); + } screen->forceDisplay(); display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code @@ -1558,6 +1567,25 @@ std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t mi return uptime; } +int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing) +{ + return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing); +} + +size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, + const char *ellipsis, int emoteSpacing) +{ + return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes, + graphics::numEmotes, emoteSpacing); +} + +void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing, + bool fauxBold) +{ + graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes, + emoteSpacing, fauxBold); +} + } // namespace graphics #endif // HAS_SCREEN diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index 8f0d07881..a0bb0d849 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -1,6 +1,7 @@ #pragma once #include "NodeDB.h" +#include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" #include "graphics/emotes.h" #include @@ -80,6 +81,28 @@ class UIRenderer static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds); static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime); + // Shared BaseUI emote helpers. + static int measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing = 1); + static inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, int emoteSpacing = 1) + { + return measureStringWithEmotes(display, line.c_str(), emoteSpacing); + } + static size_t truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, + const char *ellipsis = "...", int emoteSpacing = 1); + static inline std::string truncateStringWithEmotes(OLEDDisplay *display, const std::string &line, int maxWidth, + const std::string &ellipsis = "...", int emoteSpacing = 1) + { + return graphics::EmoteRenderer::truncateToWidth(display, line, maxWidth, ellipsis, graphics::emotes, graphics::numEmotes, + emoteSpacing); + } + static void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing = 1, + bool fauxBold = true); + static inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight, + int emoteSpacing = 1, bool fauxBold = true) + { + drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold); + } + // Check if the display can render a string (detect special chars; emoji) static bool haveGlyphs(const char *str); }; // namespace UIRenderer diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index ae25de0cb..65e903134 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -13,10 +13,12 @@ #include "buzz.h" #include "detect/ScanI2C.h" #include "gps/RTC.h" +#include "graphics/EmoteRenderer.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/MessageRenderer.h" #include "graphics/draw/NotificationRenderer.h" +#include "graphics/draw/UIRenderer.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "input/SerialKeyboard.h" @@ -45,71 +47,6 @@ extern MessageStore messageStore; // Remove Canned message screen if no action is taken for some milliseconds #define INACTIVATE_AFTER_MS 20000 -// Tokenize a message string into emote/text segments -static std::vector> tokenizeMessageWithEmotes(const char *msg) -{ - std::vector> tokens; - int msgLen = strlen(msg); - int pos = 0; - while (pos < msgLen) { - const graphics::Emote *foundEmote = nullptr; - int foundLen = 0; - for (int j = 0; j < graphics::numEmotes; j++) { - const char *label = graphics::emotes[j].label; - int labelLen = strlen(label); - if (labelLen == 0) - continue; - if (strncmp(msg + pos, label, labelLen) == 0) { - if (!foundEmote || labelLen > foundLen) { - foundEmote = &graphics::emotes[j]; - foundLen = labelLen; - } - } - } - if (foundEmote) { - tokens.emplace_back(true, String(foundEmote->label)); - pos += foundLen; - } else { - // Find next emote - int nextEmote = msgLen; - for (int j = 0; j < graphics::numEmotes; j++) { - const char *label = graphics::emotes[j].label; - if (!label || !*label) - continue; - const char *found = strstr(msg + pos, label); - if (found && (found - msg) < nextEmote) { - nextEmote = found - msg; - } - } - int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos); - if (textLen > 0) { - tokens.emplace_back(false, String(msg + pos).substring(0, textLen)); - pos += textLen; - } else { - break; - } - } - } - return tokens; -} - -// Render a single emote token centered vertically on a row -static void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label) -{ - const graphics::Emote *emote = nullptr; - for (int j = 0; j < graphics::numEmotes; j++) { - if (label == graphics::emotes[j].label) { - emote = &graphics::emotes[j]; - break; - } - } - if (emote) { - int emoteYOffset = (rowHeight - emote->height) / 2; // vertically center the emote - display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap); - nextX += emote->width + 2; // spacing between tokens - } -} - namespace graphics { extern int bannerSignalBars; @@ -264,19 +201,20 @@ int CannedMessageModule::splitConfiguredMessages() } void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer) { - if (graphics::currentResolution == graphics::ScreenResolution::High) { - if (this->dest == NODENUM_BROADCAST) { - display->drawStringf(x, y, buffer, "To: #%s", channels.getName(this->channel)); - } else { - display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest)); - } + (void)buffer; + + char header[96]; + if (this->dest == NODENUM_BROADCAST) { + const char *channelName = channels.getName(this->channel); + snprintf(header, sizeof(header), "To: #%s", channelName ? channelName : "?"); } else { - if (this->dest == NODENUM_BROADCAST) { - display->drawStringf(x, y, buffer, "To: #%.20s", channels.getName(this->channel)); - } else { - display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest)); - } + snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest)); } + + const int maxWidth = std::max(0, display->getWidth() - x); + char truncatedHeader[96]; + graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth); + graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false); } void CannedMessageModule::resetSearch() @@ -370,6 +308,92 @@ bool CannedMessageModule::isCharInputAllowed() const { return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; } + +static int getRowHeightForEmoteText(const char *text, int minimumHeight, int emoteSpacing = 2) +{ + // Grow the row only when an emote is taller than the font. + const auto metrics = + graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", 0, graphics::emotes, graphics::numEmotes, emoteSpacing); + return std::max(minimumHeight, metrics.tallestHeight + 2); +} + +static void drawCenteredEmoteText(OLEDDisplay *display, int x, int y, int rowHeight, const char *text, int emoteSpacing = 2) +{ + // Center mixed text and emotes inside the row height. + const auto metrics = graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes, + graphics::numEmotes, emoteSpacing); + const int contentHeight = std::max(FONT_HEIGHT_SMALL, metrics.tallestHeight); + const int drawY = y + ((rowHeight - contentHeight) / 2); + graphics::EmoteRenderer::drawStringWithEmotes(display, x, drawY, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes, + graphics::numEmotes, emoteSpacing, false); +} + +static size_t firstWrappedTokenLen(const char *text) +{ + // Fall back to one full emote or one UTF-8 glyph when width is tiny. + if (!text || !*text) + return 0; + + const size_t textLen = strlen(text); + size_t matchLen = 0; + if (graphics::EmoteRenderer::findEmoteAt(text, textLen, 0, matchLen, graphics::emotes, graphics::numEmotes)) + return matchLen; + + return graphics::EmoteRenderer::utf8CharLen(static_cast(text[0])); +} + +static void drawWrappedEmoteText(OLEDDisplay *display, int x, int y, const char *text, int maxWidth, int minimumRowHeight, + int emoteSpacing = 2) +{ + // Wrap onto multiple rows without splitting emotes. + if (!display || !text || maxWidth <= 0) + return; + + constexpr size_t kLineBufferSize = 256; + char lineBuffer[kLineBufferSize]; + const size_t textLen = strlen(text); + size_t offset = 0; + int yCursor = y; + + while (offset < textLen) { + size_t copied = graphics::EmoteRenderer::truncateToWidth(display, text + offset, lineBuffer, sizeof(lineBuffer), maxWidth, + "", graphics::emotes, graphics::numEmotes, emoteSpacing); + size_t consumed = copied; + + if (copied == 0) { + consumed = firstWrappedTokenLen(text + offset); + if (consumed == 0) + break; + + const size_t fallbackLen = std::min(consumed, sizeof(lineBuffer) - 1); + memcpy(lineBuffer, text + offset, fallbackLen); + lineBuffer[fallbackLen] = '\0'; + consumed = fallbackLen; + } else if (text[offset + copied] != '\0') { + // Prefer wrapping at the last space when a full line does not fit. + size_t lastSpace = copied; + while (lastSpace > 0 && lineBuffer[lastSpace - 1] != ' ') + --lastSpace; + + if (lastSpace > 0) { + consumed = lastSpace; + while (consumed > 0 && lineBuffer[consumed - 1] == ' ') + --consumed; + lineBuffer[consumed] = '\0'; + } + } + + if (lineBuffer[0]) { + const int rowHeight = getRowHeightForEmoteText(lineBuffer, minimumRowHeight, emoteSpacing); + drawCenteredEmoteText(display, x, yCursor, rowHeight, lineBuffer, emoteSpacing); + yCursor += rowHeight; + } + + offset += std::max(consumed, 1); + while (offset < textLen && text[offset] == ' ') + ++offset; + } +} /** * Main input event dispatcher for CannedMessageModule. * Routes keyboard/button/touch input to the correct handler based on the current runState. @@ -491,18 +515,16 @@ bool CannedMessageModule::handleTabSwitch(const InputEvent *event) if (event->kbchar != 0x09) return false; - updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT - : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION); + const cannedMessageModuleRunState targetState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) + ? CANNED_MESSAGE_RUN_STATE_FREETEXT + : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; destIndex = 0; scrollIndex = 0; - // RESTORE THIS! - if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) + if (targetState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) updateDestinationSelectionList(); - updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT - : CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION, - true); + updateState(targetState, true); UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; @@ -1686,55 +1708,51 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O int xOffset = 0; int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset; - char entryText[64] = ""; + std::string entryText; // Draw Channels First if (itemIndex < numActiveChannels) { uint8_t channelIndex = this->activeChannelIndices[itemIndex]; - snprintf(entryText, sizeof(entryText), "#%s", channels.getName(channelIndex)); + const char *channelName = channels.getName(channelIndex); + entryText = std::string("#") + (channelName ? channelName : "?"); } // Then Draw Nodes else { int nodeIndex = itemIndex - numActiveChannels; if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) { meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; - if (node && node->user.long_name) { - strncpy(entryText, node->user.long_name, sizeof(entryText) - 1); - entryText[sizeof(entryText) - 1] = '\0'; + if (node) { + if (display->getWidth() <= 64) { + entryText = node->user.short_name; + } else if (node->user.long_name[0]) { + entryText = node->user.long_name; + } else { + entryText = node->user.short_name; + } } + int availWidth = display->getWidth() - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - ((node && node->is_favorite) ? 10 : 0); if (availWidth < 0) availWidth = 0; - - size_t origLen = strlen(entryText); - while (entryText[0] && display->getStringWidth(entryText) > availWidth) { - entryText[strlen(entryText) - 1] = '\0'; - } - if (strlen(entryText) < origLen) { - strcat(entryText, "..."); - } + char truncatedEntry[96]; + graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry), + availWidth); + entryText = truncatedEntry; // Prepend "* " if this is a favorite if (node && node->is_favorite) { - size_t len = strlen(entryText); - if (len + 2 < sizeof(entryText)) { - memmove(entryText + 2, entryText, len + 1); - entryText[0] = '*'; - entryText[1] = ' '; - } - } - if (node) { - if (display->getWidth() <= 64) { - snprintf(entryText, sizeof(entryText), "%s", node->user.short_name); - } + entryText = "* " + entryText; } + graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry), + availWidth); + entryText = truncatedEntry; } } - if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0) - strcpy(entryText, "?"); + if (entryText.empty() || entryText == "Unknown") + entryText = "?"; // Highlight background (if selected) if (itemIndex == destIndex) { @@ -1744,7 +1762,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O } // Draw entry text - display->drawString(xOffset + 2, yOffset, entryText); + graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false); display->setColor(WHITE); // Draw key icon (after highlight) @@ -1785,15 +1803,10 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla { const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height const int headerMargin = 2; // Extra pixels below header - const int labelGap = 6; + const int labelGap = 4; const int bitmapGapX = 4; - // Find max emote height (assume all same, or precalculated) - int maxEmoteHeight = 0; - for (int i = 0; i < graphics::numEmotes; ++i) - if (graphics::emotes[i].height > maxEmoteHeight) - maxEmoteHeight = graphics::emotes[i].height; - + const int maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight(); const int rowHeight = maxEmoteHeight + 2; // Place header at top, then compute start of emote list @@ -1840,14 +1853,16 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla display->setColor(BLACK); } - // Emote bitmap (left), 1px margin from highlight bar top - int emoteY = rowY + 1; - display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap); + // Emote bitmap (left), centered inside the row + int labelStartX = x + bitmapGapX; + const int emoteY = rowY + ((rowHeight - emote.height) / 2); + display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap); + labelStartX += emote.width; // Emote label (right of bitmap) display->setFont(FONT_MEDIUM); int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2); - display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label); + display->drawString(labelStartX + labelGap, labelY, emote.label); if (emoteIdx == emotePickerIndex) display->setColor(WHITE); @@ -2007,91 +2022,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st { int inputY = 0 + y + FONT_HEIGHT_SMALL; String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); - - // Tokenize input into (isEmote, token) pairs - const char *msg = msgWithCursor.c_str(); - std::vector> tokens = tokenizeMessageWithEmotes(msg); - - // Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed) - std::vector>> lines; - std::vector> currentLine; - int lineWidth = 0; - int maxWidth = display->getWidth(); - for (auto &token : tokens) { - if (token.first) { - // Emote - int tokenWidth = 0; - for (int j = 0; j < graphics::numEmotes; j++) { - if (token.second == graphics::emotes[j].label) { - tokenWidth = graphics::emotes[j].width + 2; - break; - } - } - if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - currentLine.push_back(token); - lineWidth += tokenWidth; - } else { - // Text: split by words and wrap inside word if needed - String text = token.second; - int pos = 0; - while (pos < static_cast(text.length())) { - // Find next space (or end) - int spacePos = text.indexOf(' ', pos); - int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space - String word = text.substring(pos, endPos); - int wordWidth = display->getStringWidth(word); - - if (lineWidth + wordWidth > maxWidth && lineWidth > 0) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - // If word itself too big, split by character - if (wordWidth > maxWidth) { - uint16_t charPos = 0; - while (charPos < word.length()) { - String oneChar = word.substring(charPos, charPos + 1); - int charWidth = display->getStringWidth(oneChar); - if (lineWidth + charWidth > maxWidth && lineWidth > 0) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - currentLine.push_back({false, oneChar}); - lineWidth += charWidth; - charPos++; - } - } else { - currentLine.push_back({false, word}); - lineWidth += wordWidth; - } - pos = endPos; - } - } - } - if (!currentLine.empty()) - lines.push_back(currentLine); - - // Draw lines with emotes - int rowHeight = FONT_HEIGHT_SMALL; - int yLine = inputY; - for (const auto &line : lines) { - int nextX = x; - for (const auto &token : line) { - if (token.first) { - // Emote rendering centralized in helper - renderEmote(display, nextX, yLine, rowHeight, token.second); - } else { - display->drawString(nextX, yLine, token.second); - nextX += display->getStringWidth(token.second); - } - } - yLine += rowHeight; - } + drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL); } #endif return; @@ -2106,7 +2037,6 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st const int baseRowSpacing = FONT_HEIGHT_SMALL - 4; int topMsg; - std::vector rowHeights; int _visibleRows; // Draw header (To: ...) @@ -2122,36 +2052,15 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st : 0; int countRows = std::min(messagesCount, _visibleRows); - // Build per-row max height based on all emotes in line - for (int i = 0; i < countRows; i++) { - const char *msg = getMessageByIndex(topMsg + i); - int maxEmoteHeight = 0; - for (int j = 0; j < graphics::numEmotes; j++) { - const char *label = graphics::emotes[j].label; - if (!label || !*label) - continue; - const char *search = msg; - while ((search = strstr(search, label))) { - if (graphics::emotes[j].height > maxEmoteHeight) - maxEmoteHeight = graphics::emotes[j].height; - search += strlen(label); // Advance past this emote - } - } - rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2)); - } - // Draw all message rows with multi-emote support int yCursor = listYOffset; for (int vis = 0; vis < countRows; vis++) { int msgIdx = topMsg + vis; int lineY = yCursor; const char *msg = getMessageByIndex(msgIdx); - int rowHeight = rowHeights[vis]; + int rowHeight = getRowHeightForEmoteText(msg, baseRowSpacing); bool _highlight = (msgIdx == currentMessageIndex); - // Multi-emote tokenization - std::vector> tokens = tokenizeMessageWithEmotes(msg); - // Vertically center based on rowHeight int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2; @@ -2168,17 +2077,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st int nextX = x + (_highlight ? 2 : 0); #endif - // Draw all tokens left to right - for (const auto &token : tokens) { - if (token.first) { - // Emote rendering centralized in helper - renderEmote(display, nextX, lineY, rowHeight, token.second); - } else { - // Text - display->drawString(nextX, lineY + textYOffset, token.second); - nextX += display->getStringWidth(token.second); - } - } + if (msg && *msg) + drawCenteredEmoteText(display, nextX, lineY, rowHeight, msg); #ifndef USE_EINK if (_highlight) display->setColor(WHITE); From 9f74fc11dedebc7bebf2e6ef5ffbccc31af82e37 Mon Sep 17 00:00:00 2001 From: fw190d13 Date: Thu, 19 Mar 2026 12:13:34 +0100 Subject: [PATCH 13/50] hexDump: Add const to the buf parameter in hexDump. (#9944) The function only reads the buffer, so marking it const clarifies intent and prevents accidental modification. --- src/RedirectablePrint.cpp | 2 +- src/RedirectablePrint.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 672c8334c..6ff7bbb18 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -345,7 +345,7 @@ void RedirectablePrint::log(const char *logLevel, const char *format, ...) return; } -void RedirectablePrint::hexDump(const char *logLevel, unsigned char *buf, uint16_t len) +void RedirectablePrint::hexDump(const char *logLevel, const unsigned char *buf, uint16_t len) { const char alphabet[17] = "0123456789abcdef"; log(logLevel, " +------------------------------------------------+ +----------------+"); diff --git a/src/RedirectablePrint.h b/src/RedirectablePrint.h index 45b62b7af..c66226171 100644 --- a/src/RedirectablePrint.h +++ b/src/RedirectablePrint.h @@ -44,7 +44,7 @@ class RedirectablePrint : public Print /** like printf but va_list based */ size_t vprintf(const char *logLevel, const char *format, va_list arg); - void hexDump(const char *logLevel, unsigned char *buf, uint16_t len); + void hexDump(const char *logLevel, const unsigned char *buf, uint16_t len); std::string mt_sprintf(const std::string fmt_str, ...); From c88b802e321fd1f6a4f0e5ec17ee1450241d9000 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:12:50 +0000 Subject: [PATCH 14/50] Remove early return during scan of BME address for BMP sensors (#9935) * Enable pre-hop drop handling by default * Remove early break if BME/DPS sensors are not detected at the BME address * revert sneaky change * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors --- src/detect/ScanI2CTwoWire.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 06862a2c4..2e00c11ce 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -341,7 +341,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = DPS310; break; } - break; + if (type == DPS310) { + break; + } default: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x00), 1); // GET_ID switch (registerValue) { From 959756abf17bb61690af705c27a3bb61d2436f15 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 19 Mar 2026 20:20:15 +0800 Subject: [PATCH 15/50] fix(tlora-pager): Remove SDCARD_USE_SPI1 so SX1262 and SD card can share SPI bus (#9870) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: - Inserting a µSD card causes RadioLib to hit a critical error and reboot - Device enters a boot loop as the SD card remains inserted Reproduction: - Insert a µSD card and power on - RadioLib reports a critical error on boot - Device reboots, repeating indefinitely Root cause: - On T-Lora Pager, SX1262 and the µSD slot share the same physical SPI bus (same SCK/MOSI/MISO pins, differentiated only by CS) - SDCARD_USE_SPI1 is intended for boards where SD is on a separate SPI bus; it initializes a second ESP32 SPI peripheral (SPI3) for SD - SPI2 is already driving those same pins for LoRa, so both controllers simultaneously drive the same GPIO lines, causing bus contention Fix: - Remove SDCARD_USE_SPI1 so both devices share a single SPI peripheral (SPI2), with CS pins providing device selection as intended - Tested on a custom fork of device-ui; LoRa and SD card map tiles both work correctly with an SD card inserted Signed-off-by: Andrew Yong --- variants/esp32s3/tlora-pager/platformio.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index d35562c2f..dc96113b0 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -26,7 +26,6 @@ build_flags = ${esp32s3_base.build_flags} -D T_LORA_PAGER -D BOARD_HAS_PSRAM -D HAS_SDCARD - -D SDCARD_USE_SPI1 -D ENABLE_ROTARY_PULLUP -D ENABLE_BUTTON_PULLUP -D ROTARY_BUXTRONICS From 1be2529fb92e029489f48844963a1d3dec24ab5f Mon Sep 17 00:00:00 2001 From: Wessel Date: Thu, 19 Mar 2026 14:11:10 +0100 Subject: [PATCH 16/50] Enable LNA by default for Heltec v4.3 (#9906) It should only be disabled by users that have problems with it. --- src/mesh/LoRaFEMInterface.cpp | 4 ++-- src/mesh/LoRaFEMInterface.h | 2 +- src/mesh/NodeDB.cpp | 2 +- src/mesh/SX126xInterface.cpp | 2 +- src/modules/AdminModule.cpp | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mesh/LoRaFEMInterface.cpp b/src/mesh/LoRaFEMInterface.cpp index bad795c3a..b05bb65ed 100644 --- a/src/mesh/LoRaFEMInterface.cpp +++ b/src/mesh/LoRaFEMInterface.cpp @@ -25,7 +25,7 @@ void LoRaFEMInterface::init(void) pinMode(LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(LORA_KCT8103L_PA_CSD, HIGH); pinMode(LORA_KCT8103L_PA_CTX, OUTPUT); - digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); + digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default setLnaCanControl(true); } else if (digitalRead(LORA_KCT8103L_PA_CSD) == LOW) { // FEM is GC1109 @@ -66,7 +66,7 @@ void LoRaFEMInterface::init(void) pinMode(LORA_KCT8103L_PA_CSD, OUTPUT); digitalWrite(LORA_KCT8103L_PA_CSD, HIGH); pinMode(LORA_KCT8103L_PA_CTX, OUTPUT); - digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); + digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default setLnaCanControl(true); #endif } diff --git a/src/mesh/LoRaFEMInterface.h b/src/mesh/LoRaFEMInterface.h index 9024abd3a..14220c6e3 100644 --- a/src/mesh/LoRaFEMInterface.h +++ b/src/mesh/LoRaFEMInterface.h @@ -22,7 +22,7 @@ class LoRaFEMInterface private: LoRaFEMType fem_type; - bool lna_enabled = false; + bool lna_enabled = true; bool lna_can_control = false; }; extern LoRaFEMInterface loraFEMInterface; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 605bb6211..428a64fcf 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -569,7 +569,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.lora.override_duty_cycle = false; config.lora.config_ok_to_mqtt = false; #if HAS_LORA_FEM - config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED; + config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED; #else config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; #endif diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 4dd90b6e6..2e9a3250d 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -60,7 +60,7 @@ template bool SX126xInterface::init() loraFEMInterface.init(); // Apply saved FEM LNA mode from config if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED); + loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); } #endif diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 6ffde70db..bafd184cf 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -800,7 +800,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) #if HAS_LORA_FEM // Apply FEM LNA mode from config (only meaningful on hardware that supports it) if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode == meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED); + loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); } else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { // Hardware FEM does not support LNA control; normalize stored config to match actual capability LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT"); From f04746a92888d56d743ccc079d2978f2d2bb4c0b Mon Sep 17 00:00:00 2001 From: Philip Lykov Date: Thu, 19 Mar 2026 15:37:39 +0200 Subject: [PATCH 17/50] Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout (#9754) * Fix RAK4631 Ethernet gateway API connection loss after W5100S brownout PoE power instability can brownout the W5100S while the nRF52 MCU keeps running, causing all chip registers (MAC, IP, sockets) to revert to defaults. The firmware had no mechanism to detect or recover from this. Changes: - Detect W5100S chip reset by periodically verifying MAC address register in reconnectETH(); on mismatch, perform full hardware reset and re-initialize Ethernet interface and services - Add deInitApiServer() for clean API server teardown during recovery - Add ~APIServerPort destructor to prevent memory leaks - Switch nRF52 from EthernetServer::available() to accept() to prevent the same connected client from being repeatedly re-reported - Add proactive dead-connection cleanup in APIServerPort::runOnce() - Add 15-minute TCP idle timeout to close half-open connections that consume limited W5100S hardware sockets Fixes meshtastic/firmware#6970 Made-with: Cursor * Log actual elapsed idle time instead of constant timeout value Address Copilot review comment: log millis() - lastContactMsec to show the real time since last client activity, rather than always logging the TCP_IDLE_TIMEOUT_MS constant. Made-with: Cursor * Update src/mesh/api/ServerAPI.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Stop UDP multicast handler during W5100S brownout recovery After a W5100S chip brownout, the udpHandler isRunning flag stays true while the underlying socket is dead. Without calling stop(), the subsequent start() no-ops and multicast is silently broken after recovery. Made-with: Cursor * Address Copilot review: recovery flags and timeout constant Move ethStartupComplete and ntp_renew reset to immediately after service teardown, before Ethernet.begin(). Previously, if DHCP failed the early return left ethStartupComplete=true, preventing service re-initialization on subsequent retries. Replace #define TCP_IDLE_TIMEOUT_MS with static constexpr uint32_t for type safety and better C++ practice. Made-with: Cursor --------- Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mesh/api/ServerAPI.cpp | 11 +++++- src/mesh/api/ethServerAPI.cpp | 9 +++++ src/mesh/api/ethServerAPI.h | 1 + src/mesh/eth/ethClient.cpp | 63 +++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/mesh/api/ServerAPI.cpp b/src/mesh/api/ServerAPI.cpp index 7bb1a8108..f3e7854ca 100644 --- a/src/mesh/api/ServerAPI.cpp +++ b/src/mesh/api/ServerAPI.cpp @@ -1,7 +1,10 @@ #include "ServerAPI.h" +#include "Throttle.h" #include "configuration.h" #include +static constexpr uint32_t TCP_IDLE_TIMEOUT_MS = 15 * 60 * 1000UL; + template ServerAPI::ServerAPI(T &_client) : StreamAPI(&client), concurrency::OSThread("ServerAPI"), client(_client) { @@ -28,6 +31,12 @@ template bool ServerAPI::checkIsConnected() template int32_t ServerAPI::runOnce() { if (client.connected()) { + if (lastContactMsec > 0 && !Throttle::isWithinTimespanMs(lastContactMsec, TCP_IDLE_TIMEOUT_MS)) { + LOG_WARN("TCP connection timeout, no data for %lu ms", (unsigned long)(millis() - lastContactMsec)); + close(); + enabled = false; + return 0; + } return StreamAPI::runOncePart(); } else { LOG_INFO("Client dropped connection, suspend API service"); @@ -57,7 +66,7 @@ template int32_t APIServerPort::runOnce() #else auto client = U::available(); #endif -#elif defined(ARCH_RP2040) +#elif defined(ARCH_RP2040) || defined(ARCH_NRF52) auto client = U::accept(); #else auto client = U::available(); diff --git a/src/mesh/api/ethServerAPI.cpp b/src/mesh/api/ethServerAPI.cpp index 10ff06df2..43ed74cf8 100644 --- a/src/mesh/api/ethServerAPI.cpp +++ b/src/mesh/api/ethServerAPI.cpp @@ -17,6 +17,15 @@ void initApiServer(int port) } } +void deInitApiServer() +{ + if (apiPort) { + LOG_INFO("Deinit API server"); + delete apiPort; + apiPort = nullptr; + } +} + ethServerAPI::ethServerAPI(EthernetClient &_client) : ServerAPI(_client) { LOG_INFO("Incoming ethernet connection"); diff --git a/src/mesh/api/ethServerAPI.h b/src/mesh/api/ethServerAPI.h index c616c87be..8f81ee6ff 100644 --- a/src/mesh/api/ethServerAPI.h +++ b/src/mesh/api/ethServerAPI.h @@ -24,4 +24,5 @@ class ethServerPort : public APIServerPort }; void initApiServer(int port = SERVER_API_DEFAULT_PORT); +void deInitApiServer(); #endif diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index a811ec16c..80741810a 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -32,6 +32,69 @@ static Periodic *ethEvent; static int32_t reconnectETH() { if (config.network.eth_enabled) { + + // Detect W5100S chip reset by verifying the MAC address register. + // PoE power instability can brownout the W5100S while the MCU keeps running, + // causing all chip registers (MAC, IP, sockets) to revert to defaults. + uint8_t currentMac[6]; + Ethernet.MACAddress(currentMac); + + uint8_t expectedMac[6]; + getMacAddr(expectedMac); + expectedMac[0] &= 0xfe; + + if (memcmp(currentMac, expectedMac, 6) != 0) { + LOG_WARN("W5100S MAC mismatch (chip reset detected), reinitializing Ethernet"); + + syslog.disable(); +#if !MESHTASTIC_EXCLUDE_SOCKETAPI + deInitApiServer(); +#endif +#if HAS_UDP_MULTICAST + if (udpHandler) { + udpHandler->stop(); + } +#endif + + ethStartupComplete = false; +#ifndef DISABLE_NTP + ntp_renew = 0; +#endif + +#ifdef PIN_ETHERNET_RESET + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, LOW); + delay(100); + digitalWrite(PIN_ETHERNET_RESET, HIGH); + delay(100); +#endif + +#ifdef RAK11310 + ETH_SPI_PORT.setSCK(PIN_SPI0_SCK); + ETH_SPI_PORT.setTX(PIN_SPI0_MOSI); + ETH_SPI_PORT.setRX(PIN_SPI0_MISO); + ETH_SPI_PORT.begin(); +#endif + Ethernet.init(ETH_SPI_PORT, PIN_ETHERNET_SS); + + int status = 0; + if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_DHCP) { + status = Ethernet.begin(expectedMac); + } else if (config.network.address_mode == meshtastic_Config_NetworkConfig_AddressMode_STATIC) { + Ethernet.begin(expectedMac, config.network.ipv4_config.ip, config.network.ipv4_config.dns, + config.network.ipv4_config.gateway, config.network.ipv4_config.subnet); + status = 1; + } + + if (status == 0) { + LOG_ERROR("Ethernet re-initialization failed, will retry"); + return 5000; + } + + LOG_INFO("Ethernet reinitialized - IP %u.%u.%u.%u", Ethernet.localIP()[0], Ethernet.localIP()[1], + Ethernet.localIP()[2], Ethernet.localIP()[3]); + } + Ethernet.maintain(); if (!ethStartupComplete) { // Start web server From 644d0d4a1505c78740c5fecad72f95deb019ade3 Mon Sep 17 00:00:00 2001 From: Niklas Wall Date: Thu, 19 Mar 2026 16:52:52 +0100 Subject: [PATCH 18/50] Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. (#9916) * Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. PKI DMs sent over UDP multicast had their pki_encrypted flag and public_key fields explicitly cleared before being forwarded to the LoRa radio. This caused the receiving node to treat the packet as a channel-encrypted message it couldn't decrypt, silently dropping it. The MQTT ingress path correctly preserves these fields. The UDP multicast ingress path should behave the same way. * Zeroize MeshPacket before decoding Zeroize MeshPacket before decoding to prevent data leakage. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors --- src/mesh/udp/UdpMulticastHandler.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index a5b0ef360..493cc5353 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -69,7 +69,7 @@ class UdpMulticastHandler final // FIXME(PORTDUINO): arduino lacks IPAddress::toString() LOG_DEBUG("UDP broadcast from: %s, len=%u", packet.remoteIP().toString().c_str(), packetLength); #endif - meshtastic_MeshPacket mp; + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; LOG_DEBUG("Decoding MeshPacket from UDP len=%u", packetLength); bool isPacketDecoded = pb_decode_from_bytes(packet.data(), packetLength, &meshtastic_MeshPacket_msg, &mp); if (isPacketDecoded && router && mp.which_payload_variant == meshtastic_MeshPacket_encrypted_tag) { @@ -79,9 +79,6 @@ class UdpMulticastHandler final return; } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; - mp.pki_encrypted = false; - mp.public_key.size = 0; - memset(mp.public_key.bytes, 0, sizeof(mp.public_key.bytes)); UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); // Unset received SNR/RSSI p->rx_snr = 0; From 0ea408e12b43b74f2cb6e4617e0ed6a124cb5f40 Mon Sep 17 00:00:00 2001 From: rcatal01 Date: Thu, 19 Mar 2026 14:00:00 -0400 Subject: [PATCH 19/50] fix: MQTT settings silently fail to persist when broker is unreachable (#9934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: MQTT settings silently fail to persist when broker is unreachable isValidConfig() was testing broker connectivity via connectPubSub() as part of config validation. When the broker was unreachable (network not ready, DNS failure, server down), the function returned false, causing AdminModule to skip saving settings entirely — silently. This removes the connectivity test from isValidConfig(), which now only validates configuration correctness (TLS support, default server port). Connectivity is handled by the MQTT module's existing reconnect loop. Fixes #9107 * Add client warning notification when MQTT broker is unreachable Per maintainer feedback: instead of silently saving when the broker can't be reached, send a WARNING notification to the client saying "MQTT settings saved, but could not reach the MQTT server." Settings still always persist regardless of connectivity — the core fix from the previous commit is preserved. The notification is purely advisory so users know to double-check their server address and credentials if the connection test fails. When the network is not available at all, the connectivity check is skipped entirely with a log message. * Address Copilot review feedback - Fix warning message wording: "Settings will be saved" instead of "Settings saved" (notification fires before AdminModule persists) - Add null check on clientNotificationPool.allocZeroed() to prevent crash if pool is exhausted (matches AdminModule::sendWarning pattern) - Fix test comments to accurately describe conditional connectivity check behavior and IS_RUNNING_TESTS compile-out * Remove connectivity check from isValidConfig entirely Reverts the advisory connectivity check added in the previous commit. While the intent was to warn users about unreachable brokers, connectPubSub() mutates the isConnected state of the running MQTT module and performs synchronous network operations that can block the config-save path. The cleanest approach: isValidConfig() validates config correctness only (TLS support, default server port). The MQTT reconnect loop handles connectivity after settings are persisted and the device reboots. If the broker is unreachable, the user will see it in the MQTT connection status — no special notification needed. This returns to the simpler design from the first commit, which was tested on hardware and confirmed working. * Use lightweight TCP check instead of connectPubSub for validation Per maintainer feedback: users need connectivity feedback, but connectPubSub() mutates the module's isConnected state. This uses a standalone MQTTClient TCP connection test that: - Checks if the server IP/port is reachable - Sends a WARNING notification if unreachable - Does NOT establish an MQTT session or mutate any module state - Does NOT block saving — isValidConfig always returns true The TCP test client is created locally, used, and destroyed within the function scope. No side effects on the running MQTT module. --------- Co-authored-by: Ben Meadors --- src/mqtt/MQTT.cpp | 32 ++++++++++++++++++++++---------- test/test_mqtt/MQTT.cpp | 33 ++++++++++++--------------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index c8183cfde..ac022a1ab 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -651,22 +651,34 @@ bool MQTT::isValidConfig(const meshtastic_ModuleConfig_MQTTConfig &config, MQTTC if (config.enabled && !config.proxy_to_client_enabled) { #if HAS_NETWORKING - std::unique_ptr clientConnection; if (config.tls_enabled) { -#if MQTT_SUPPORTS_TLS - MQTTClientTLS *tlsClient = new MQTTClientTLS; - clientConnection.reset(tlsClient); - tlsClient->setInsecure(); -#else +#if !MQTT_SUPPORTS_TLS LOG_ERROR("Invalid MQTT config: tls_enabled is not supported on this node"); return false; #endif - } else { - clientConnection.reset(new MQTTClient); } - std::unique_ptr pubSub(new PubSubClient); + // Perform a lightweight TCP connectivity check without using connectPubSub(), + // which mutates the module's isConnected state. This only checks if the server + // is reachable — it does not establish an MQTT session. + // Settings are always saved regardless of the result. if (isConnectedToNetwork()) { - return connectPubSub(parsed, *pubSub, (client != nullptr) ? *client : *clientConnection); + MQTTClient testClient; + if (!testClient.connect(parsed.serverAddr.c_str(), parsed.serverPort)) { + const char *warning = "Could not reach the MQTT server. Settings will be saved, but please verify the server " + "address and credentials."; + LOG_WARN(warning); +#if !IS_RUNNING_TESTS + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + if (cn) { + cn->level = meshtastic_LogRecord_Level_WARNING; + cn->time = getValidTime(RTCQualityFromNet); + strncpy(cn->message, warning, sizeof(cn->message) - 1); + cn->message[sizeof(cn->message) - 1] = '\0'; + service->sendClientNotification(cn); + } +#endif + } + testClient.stop(); } #else const char *warning = "Invalid MQTT config: proxy_to_client_enabled must be enabled on nodes that do not have a network"; diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 4a2eed87d..edf9a3983 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -818,16 +818,13 @@ void test_configEmptyIsValid(void) TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } -// Empty 'enabled' configuration is valid. +// Empty 'enabled' configuration is valid. A lightweight TCP check may be performed +// but does not affect the result. void test_configEnabledEmptyIsValid(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true}; - MockPubSubServer client; - TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client)); - TEST_ASSERT_TRUE(client.connected_); - TEST_ASSERT_EQUAL_STRING(default_mqtt_address, client.host_.c_str()); - TEST_ASSERT_EQUAL(1883, client.port_); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } // Configuration with the default server is valid. @@ -846,38 +843,32 @@ void test_configWithDefaultServerAndInvalidPort(void) TEST_ASSERT_FALSE(MQTT::isValidConfig(config)); } -// isValidConfig connects to a custom host and port. +// Custom host and port is valid. TCP reachability is checked but does not block saving. void test_configCustomHostAndPort(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server:1234"}; - MockPubSubServer client; - TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client)); - TEST_ASSERT_TRUE(client.connected_); - TEST_ASSERT_EQUAL_STRING("server", client.host_.c_str()); - TEST_ASSERT_EQUAL(1234, client.port_); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } -// isValidConfig returns false if a connection cannot be established. -void test_configWithConnectionFailure(void) +// An unreachable server is still a valid config — settings always save. +// A warning notification is sent in non-test builds, but isValidConfig returns true. +void test_configWithUnreachableServerIsStillValid(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server"}; - MockPubSubServer client; - client.refuseConnection_ = true; - TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client)); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); } // isValidConfig returns true when tls_enabled is supported, or false otherwise. void test_configWithTLSEnabled(void) { meshtastic_ModuleConfig_MQTTConfig config = {.enabled = true, .address = "server", .tls_enabled = true}; - MockPubSubServer client; #if MQTT_SUPPORTS_TLS - TEST_ASSERT_TRUE(MQTTUnitTest::isValidConfig(config, &client)); + TEST_ASSERT_TRUE(MQTT::isValidConfig(config)); #else - TEST_ASSERT_FALSE(MQTTUnitTest::isValidConfig(config, &client)); + TEST_ASSERT_FALSE(MQTT::isValidConfig(config)); #endif } @@ -927,7 +918,7 @@ void setup() RUN_TEST(test_configWithDefaultServer); RUN_TEST(test_configWithDefaultServerAndInvalidPort); RUN_TEST(test_configCustomHostAndPort); - RUN_TEST(test_configWithConnectionFailure); + RUN_TEST(test_configWithUnreachableServerIsStillValid); RUN_TEST(test_configWithTLSEnabled); exit(UNITY_END()); } From 4a534f02a48626f2addf742dced2f9e8321d5e16 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 19 Mar 2026 15:27:59 -0500 Subject: [PATCH 20/50] fix(gps): prevent GPS re-enablement in NOT_PRESENT mode --- src/gps/GPS.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 1260d8b15..cf5511a33 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -103,6 +103,14 @@ static int32_t gpsSwitch() if (gps) { int currentState = digitalRead(PIN_GPS_SWITCH); + // Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS. + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + gps->disable(); + lastState = currentState; + firstrun = false; + return 1000; + } + // if the switch is set to zero, disable the GPS Thread if (firstrun) if (currentState == LOW) From 22d63fa69c053a2981bf0f958ed5c94907f581e2 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:43:47 +0000 Subject: [PATCH 21/50] Lora settings expansion and validation logic improvement (#9878) * Enhance LoRa configuration with modem presets and validation logic * Rename bootstrapLoRaConfigFromPreset tests to validateModemConfig for clarity and consistency * additional tidy-ups to the validateModemConfig - still fundamentally broken at this point * Enhance region validation by adding numPresets to RegionInfo and implementing validateRegionConfig in RadioInterface * Add validation for modem configuration in applyModemConfig * Fix region unset handling and improve modem config validation in handleSetConfig * Refactor LoRa configuration validation methods and introduce clamping method for invalid settings * Update handleSetConfig to use fromOthers parameter to either correct or reject invalid settings * Fix some of the copilot review comments for LoRa configuration validation and clamping methods; add tests for region and preset handling * Redid the slot default checking and calculation. Should resolve the outstanding comments. * Add bandwidth calculation for LoRa modem preset fallback in clampConfigLora * Remove unused preset name variable in validateConfigLora and fix default frequency slot check in applyModemConfig * update tests for region handling * Got the synthetic colleague to add mock service for testing * Flash savings... hopefully * Refactor modem preset handling to use sentinel values and improve default preset retrieval * Refactor region handling to use profile structures for modem presets and channel calculations * added comments for clarity on parameters * Add shadow table tests and validateConfigLora enhancements for region presets * Add isFromUs tests for preset validation in AdminModule * Respond to copilot github review * address copilot comments * address null poointers * fix build errors * Fix the fix, undo the silly suggestions from synthetic reviewer. * we all float here * Fix include path for AdminModule in test_main.cpp * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * More suggestion fixes * admin module merge conflicts * admin module fixes from merge hell * fix: initialize default frequency slot and custom channel name; update LNA mode handling * save some bytes... * fix: simplify error logging for bandwidth checks in LoRa configuration * Update src/mesh/MeshRadio.h Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- protobufs | 2 +- src/graphics/draw/MenuHandler.cpp | 14 +- src/mesh/MeshRadio.h | 40 +- src/mesh/NodeDB.cpp | 2 +- src/mesh/RadioInterface.cpp | 441 ++++++++++----- src/mesh/RadioInterface.h | 16 +- src/modules/AdminModule.cpp | 184 ++++--- src/modules/AdminModule.h | 6 +- src/modules/esp32/AudioModule.cpp | 6 +- test/test_admin_radio/test_main.cpp | 814 ++++++++++++++++++++++++++++ test/test_radio/test_main.cpp | 82 ++- 11 files changed, 1355 insertions(+), 252 deletions(-) create mode 100644 test/test_admin_radio/test_main.cpp diff --git a/protobufs b/protobufs index eba2d94c8..a229208f2 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit eba2d94c8d53e798f560e12d63d0457e1e22759e +Subproject commit a229208f29a59cf1d8cfa24cbb7567a08f2d1771 diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index f57c39512..b069dfb9d 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -25,6 +25,7 @@ #include "modules/TraceRouteModule.h" #include #include +#include #include #include @@ -265,13 +266,24 @@ void menuHandler::FrequencySlotPicker() optionsEnumArray[options++] = 0; // Calculate number of channels (copied from RadioInterface::applyModemConfig()) + meshtastic_Config_LoRaConfig &loraConfig = config.lora; double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora) : bwCodeToKHz(loraConfig.bandwidth); uint32_t numChannels = 0; if (myRegion) { - numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0))); + // Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round() + const double spacing = myRegion->profile->spacing; + const double padding = myRegion->profile->padding; + const double channelBandwidthMHz = bw / 1000.0; + const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing; + const double denominator = spacing + (padding * 2) + channelBandwidthMHz; + if (denominator > 0.0) { + numChannels = static_cast(round(numerator / denominator)); + } else { + LOG_WARN("Invalid region configuration: non-positive channel spacing/width"); + } } else { LOG_WARN("Region not set, cannot calculate number of channels"); return; diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 07d956878..3c3a4cf65 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -5,18 +5,52 @@ #include "PointerQueue.h" #include "configuration.h" +// Sentinel marking the end of a modem preset array +static constexpr 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; float freqStart; float freqEnd; - float dutyCycle; - float spacing; + float dutyCycle; // modified by getEffectiveDutyCycle uint8_t powerLimit; // Or zero for not set - bool audioPermitted; bool freqSwitching; bool wideLora; + const RegionProfile *profile; const char *name; // EU433 etc + + // Preset accessors (delegate through profile) + meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; } + const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; } + size_t getNumPresets() const + { + size_t n = 0; + while (profile->presets[n] != MODEM_PRESET_END) + n++; + return n; + } }; extern const RegionInfo regions[]; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 38998af83..01be50c26 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1299,7 +1299,7 @@ void NodeDB::loadFromDisk() // Coerce LoRa config fields derived from presets while bootstrapping. // Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode. if (config.has_lora && config.lora.use_preset) { - RadioInterface::bootstrapLoRaConfigFromPreset(config.lora); + RadioInterface::clampConfigLora(config.lora); } #if defined(USERPREFS_LORA_TX_DISABLED) && USERPREFS_LORA_TX_DISABLED diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 4defd00ed..9ce944002 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" @@ -32,10 +33,32 @@ #include "STM32WLE5JCInterface.h" #endif -#define RDEF(name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, frequency_switching, wide_lora) \ +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + MODEM_PRESET_END}; + +// Region profiles: bundle preset list + regulatory parameters shared across regions +// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot +const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0}; + +#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \ { \ - meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, \ - frequency_switching, wide_lora, #name \ + meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \ + wide_lora, &profile_ptr, #name \ } const RegionInfo regions[] = { @@ -43,7 +66,7 @@ const RegionInfo regions[] = { https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ */ - RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false), + RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD), /* EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] @@ -51,8 +74,7 @@ const RegionInfo regions[] = { https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false), - + RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ @@ -67,33 +89,33 @@ const RegionInfo regions[] = { AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.) https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf */ - RDEF(EU_868, 869.4f, 869.65f, 10, 0, 27, false, false, false), + RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(CN, 470.0f, 510.0f, 100, 0, 19, true, false, false), + RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf https://qiita.com/ammo0613/items/d952154f1195b64dc29f */ - RDEF(JP, 920.5f, 923.5f, 100, 0, 13, true, false, false), + RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD), /* https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf Also used in Brazil. */ - RDEF(ANZ, 915.0f, 928.0f, 100, 0, 30, true, false, false), + RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD), /* 433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100 */ - RDEF(ANZ_433, 433.05f, 434.79f, 100, 0, 14, true, false, false), + RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD), /* https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf @@ -101,13 +123,13 @@ const RegionInfo regions[] = { Note: - We do LBT, so 100% is allowed. */ - RDEF(RU, 868.7f, 869.2f, 100, 0, 20, true, false, false), + RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD), /* https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0 https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters */ - RDEF(KR, 920.0f, 923.0f, 100, 0, 23, true, false, false), + RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD), /* Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor. @@ -115,42 +137,38 @@ const RegionInfo regions[] = { https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283 */ - RDEF(TW, 920.0f, 925.0f, 100, 0, 27, true, false, false), + RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(IN, 865.0f, 867.0f, 100, 0, 30, true, false, false), + RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD), /* https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752 https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf */ - RDEF(NZ_865, 864.0f, 868.0f, 100, 0, 36, true, false, false), + RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(TH, 920.0f, 925.0f, 100, 0, 16, true, false, false), + RDEF(TH, 920.0f, 925.0f, 100, 16, false, false, PROFILE_STD), /* 433,05-434,7 Mhz 10 mW - https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf - */ - RDEF(UA_433, 433.0f, 434.7f, 10, 0, 10, true, false, false), - - /* 868,0-868,6 Mhz 25 mW https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf */ - RDEF(UA_868, 868.0f, 868.6f, 1, 0, 14, true, false, false), + RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD), + RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD), /* Malaysia 433 - 435 MHz at 100mW, no restrictions. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_433, 433.0f, 435.0f, 100, 0, 20, true, false, false), + RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD), /* Malaysia @@ -159,14 +177,14 @@ const RegionInfo regions[] = { Frequency hopping is used for 919 - 923 MHz. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_919, 919.0f, 924.0f, 100, 0, 27, true, true, false), + RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD), /* Singapore SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions. https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf */ - RDEF(SG_923, 917.0f, 925.0f, 100, 0, 20, true, false, false), + RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD), /* Philippines @@ -176,8 +194,9 @@ const RegionInfo regions[] = { https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135 */ - RDEF(PH_433, 433.0f, 434.7f, 100, 0, 10, true, false, false), RDEF(PH_868, 868.0f, 869.4f, 100, 0, 14, true, false, false), - RDEF(PH_915, 915.0f, 918.0f, 100, 0, 24, true, false, false), + RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD), + RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD), + RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD), /* Kazakhstan @@ -185,37 +204,38 @@ const RegionInfo regions[] = { 863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields https://github.com/meshtastic/firmware/issues/7204 */ - RDEF(KZ_433, 433.075f, 434.775f, 100, 0, 10, true, false, false), - RDEF(KZ_863, 863.0f, 868.0f, 100, 0, 30, true, false, false), + RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD), + RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD), /* Nepal 865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use, specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf */ - RDEF(NP_865, 865.0f, 868.0f, 100, 0, 30, true, false, false), + RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD), /* Brazil 902 - 907.5 MHz , 1W power limit, no duty cycle restrictions https://github.com/meshtastic/firmware/issues/3741 */ - RDEF(BR_902, 902.0f, 907.5f, 100, 0, 30, true, false, false), + RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD), /* 2.4 GHZ WLAN Band equivalent. Only for SX128x chips. */ - RDEF(LORA_24, 2400.0f, 2483.5f, 100, 0, 10, true, false, true), + RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD), /* This needs to be last. Same as US. */ - RDEF(UNSET, 902.0f, 928.0f, 100, 0, 30, true, false, false) + RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF) }; const RegionInfo *myRegion; bool RadioInterface::uses_default_frequency_slot = true; +bool RadioInterface::uses_custom_channel_name = false; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1]; @@ -501,45 +521,14 @@ void initRegion() myRegion = r; } -void RadioInterface::bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig) +const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) { - if (!loraConfig.use_preset) { - return; - } - - // Find region info to determine whether "wide" LoRa is permitted (2.4 GHz uses wider bandwidth codes). const RegionInfo *r = regions; - for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != loraConfig.region; r++) + for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != code; r++) ; - - const bool regionWideLora = r->wideLora; - - float bwKHz = 0; - uint8_t sf = 0; - uint8_t cr = 0; - modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr); - - // If selected preset requests a bandwidth larger than the region span, fall back to LONG_FAST. - if (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && (r->freqEnd - r->freqStart) < (bwKHz / 1000.0f)) { - loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr); - } - - loraConfig.bandwidth = bwKHzToCode(bwKHz); - loraConfig.spread_factor = sf; + return r; } -/** - * ## LoRaWAN for North America - -LoRaWAN defines 64, 125 kHz channels from 902.3 to 914.9 MHz increments. - -The maximum output power for North America is +30 dBM. - -The band is from 902 to 928 MHz. It mentions channel number and its respective channel frequency. All the 13 channels are -separated by 2.16 MHz with respect to the adjacent channels. Channel zero starts at 903.08 MHz center frequency. -*/ - uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received) { uint32_t pl = 0; @@ -749,7 +738,7 @@ void RadioInterface::saveFreq(float freq) } /** - * Save our channel for later reuse. + * Save our frequency slot (aka channel) for later reuse. */ void RadioInterface::saveChannelNum(uint32_t channel_num) { @@ -772,113 +761,297 @@ uint32_t RadioInterface::getChannelNum() return savedChannelNum; } +/** + * Send an error-level client notification. Safe to call when service is null (e.g. in tests). + */ +static void sendErrorNotification(const char *msg) +{ + if (!service) + return; + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + if (!cn) + return; + cn->level = meshtastic_LogRecord_Level_ERROR; + snprintf(cn->message, sizeof(cn->message), "%s", msg); + service->sendClientNotification(cn); +} + +/** + * Checks if a region is valid for the current settings. + * Returns false if not compatible. + */ +bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig) +{ + const RegionInfo *newRegion = getRegion(loraConfig.region); + + // If you are not licensed, you can't use ham regions. + if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { + char err_string[160]; + snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + return false; + } + + return true; +} + +/** + * Internal helper: validate or clamp a LoRa config against its region. + * When clamp==false, returns false on first error (pure validation). + * When clamp==true, fixes invalid settings in-place and returns true. + */ +bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp) +{ + char err_string[160]; + float check_bw; + + const RegionInfo *newRegion = getRegion(loraConfig.region); + + const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + + // Check preset validity (only when use_preset is true) + if (loraConfig.use_preset) { + check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + + bool preset_valid = false; + for (size_t i = 0; i < newRegion->getNumPresets(); i++) { + if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) { + preset_valid = true; + break; + } + } + if (!preset_valid) { + const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true); + if (clamp) { + snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name, + defaultName); + } else { + snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name); + } + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + loraConfig.modem_preset = newRegion->getDefaultPreset(); + check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + } else { + return false; + } + } + } else { + check_bw = bwCodeToKHz(loraConfig.bandwidth); + } + + // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region: + // spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band + // padding = gap at the beginning and end of the slots (0 for no padding) + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); + + // Check if the region supports the requested bandwidth + if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) { + const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f; + snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora)); + check_bw = bwCodeToKHz(loraConfig.bandwidth); + + // Recompute slot width and number of slots based on the new bandwidth + freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); + } else { + return false; + } + } + + const char *channelName = channels.getName(channels.getPrimaryIndex()); + const char *presetNameDisplay = + DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; + uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots; + + if (loraConfig.override_frequency == 0) { + + // Check if we use the default frequency slot + uses_default_frequency_slot = + (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default + (newRegion->profile->overrideSlot != 0 && + loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override + ((newRegion->profile->overrideSlot == 0) && + ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override + + // check if user setting different to preset name + uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0); + + if (loraConfig.channel_num > numFreqSlots) { + snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num, + newRegion->name, numFreqSlots); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + if (uses_custom_channel_name) { // clamp to channel name hash + loraConfig.channel_num = + channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + } else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot + loraConfig.channel_num = + newRegion->profile->overrideSlot; // use the override slot specified by the region profile + uses_default_frequency_slot = true; + } else if (loraConfig.use_preset) { // clamp to preset slot + loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + uses_default_frequency_slot = true; + } else { // if not using preset, and no custom channel name, just clamp to default anyway + uses_default_frequency_slot = true; + }; + } else { + return false; + } + } // end of channel number check + } else { + // if we have a frequency override, we ignore the channel number and just use the override frequency + snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency); + } + return true; +} + +bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig) +{ + auto copy = loraConfig; + return checkOrClampConfigLora(copy, false); +} + +void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig) +{ + checkOrClampConfigLora(loraConfig, true); +} + /** * Pull our channel settings etc... from protobufs to the dumb interface settings + * Note: this must be given only settings which have been validated or clamped! */ void RadioInterface::applyModemConfig() { // Set up default configuration // No Sync Words in LORA mode meshtastic_Config_LoRaConfig &loraConfig = config.lora; - bool validConfig = false; // We need to check for a valid configuration - while (!validConfig) { - if (loraConfig.use_preset) { - modemPresetToParams(loraConfig.modem_preset, myRegion->wideLora, bw, sf, cr); - if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate != cr) { - cr = loraConfig.coding_rate; - LOG_INFO("Using custom Coding Rate %u", cr); - } - } else { - sf = loraConfig.spread_factor; + const RegionInfo *newRegion = getRegion(loraConfig.region); + myRegion = newRegion; + + if (loraConfig.use_preset) { + if (!validateConfigLora(loraConfig)) { + loraConfig.modem_preset = newRegion->getDefaultPreset(); + } + uint8_t newcr; + modemPresetToParams(loraConfig.modem_preset, newRegion->wideLora, bw, sf, newcr); + // If custom CR is being used already, check if the new preset is higher + if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) { + cr = newcr; + LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr); + } + // If the custom CR is higher than the preset, use it + else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) { cr = loraConfig.coding_rate; - bw = bwCodeToKHz(loraConfig.bandwidth); - } - - if ((myRegion->freqEnd - myRegion->freqStart) < bw / 1000) { - const float regionSpanKHz = (myRegion->freqEnd - myRegion->freqStart) * 1000.0f; - const float requestedBwKHz = bw; - const bool isWideRequest = requestedBwKHz >= 499.5f; // treat as 500 kHz preset - const char *presetName = - DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); - - char err_string[160]; - if (isWideRequest) { - snprintf(err_string, sizeof(err_string), "%s region too narrow for 500kHz preset (%s). Falling back to LongFast.", - myRegion->name, presetName); - } else { - snprintf(err_string, sizeof(err_string), "%s region span %.0fkHz < requested %.0fkHz. Falling back to LongFast.", - myRegion->name, regionSpanKHz, requestedBwKHz); - } - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); - cn->level = meshtastic_LogRecord_Level_ERROR; - snprintf(cn->message, sizeof(cn->message), "%s", err_string); - service->sendClientNotification(cn); - - // Set to default modem preset - loraConfig.use_preset = true; - loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + LOG_INFO("Using custom Coding Rate %u", cr); } else { - validConfig = true; + cr = loraConfig.coding_rate; } + + } else { // if not using preset, then just use the custom settings + if (validateConfigLora(loraConfig)) { + } else { + LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults", + newRegion->name); + clampConfigLora(loraConfig); + } + bw = bwCodeToKHz(loraConfig.bandwidth); + sf = loraConfig.spread_factor; + cr = loraConfig.coding_rate; } power = loraConfig.tx_power; - if ((power == 0) || ((power > myRegion->powerLimit) && !devicestate.owner.is_licensed)) - power = myRegion->powerLimit; + if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed)) + power = newRegion->powerLimit; if (power == 0) - power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of myRegion defaults + power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of newRegion defaults // to 0, currently no region has an actual power limit of 0 [dBm] so we can assume regions which have this // variable set to 0 don't have a valid power limit) // Set final tx_power back onto config loraConfig.tx_power = (int8_t)power; // cppcheck-suppress assignmentAddressToInteger - // Calculate the number of channels - uint32_t numChannels = floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000))); + uint32_t channel_num; + float freq; - // If user has manually specified a channel num, then use that, otherwise generate one by hashing the name - const char *channelName = channels.getName(channels.getPrimaryIndex()); - // channel_num is actually (channel_num - 1), since modulus (%) returns values from 0 to (numChannels - 1) - uint32_t channel_num = (loraConfig.channel_num ? loraConfig.channel_num - 1 : hash(channelName)) % numChannels; + // Calculate number of frequency slots (aka Channels): + // spacing = gap between channels (0 for continuous spectrum) and at the beginning of the band + // padding = gap at the beginning and end of the channel (0 for no padding) + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (bw / 1000); // in MHz + uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); - // Check if we use the default frequency slot - RadioInterface::uses_default_frequency_slot = - channel_num == - hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset)) % numChannels; - - // Old frequency selection formula - // float freq = myRegion->freqStart + ((((myRegion->freqEnd - myRegion->freqStart) / numChannels) / 2) * channel_num); - - // New frequency selection formula - float freq = myRegion->freqStart + (bw / 2000) + (channel_num * (bw / 1000)); + // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one. + // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to + // (numFreqSlots - 1). + uint32_t presetNameHashSlot = + hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; // override if we have a verbatim frequency if (loraConfig.override_frequency) { freq = loraConfig.override_frequency; channel_num = -1; + uses_default_frequency_slot = false; + } else { + + // If user has not manually specified a frequency slot, or has not specified one that is different than the default or the + // override for the new region, then use the default or override. If the user has not specified one, but has specified a + // custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually + // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1). + // NB: channel_num is also know as frequency slot but it's too late to fix now. + if (uses_default_frequency_slot) { + // if there's an override slot, use that + if (newRegion->profile->overrideSlot != 0) { + channel_num = newRegion->profile->overrideSlot - 1; + } else { + channel_num = presetNameHashSlot; + } + } else { // use the manually defined one + channel_num = loraConfig.channel_num - 1; + } + + // Calculate frequency: freqStart is band edge, add half bandwidth (plus optional padding) to get middle of first channel + // subsequent channels are spaced by freqSlotWidth + freq = newRegion->freqStart + (bw / 2000) + newRegion->profile->padding + (channel_num * freqSlotWidth); // in MHz } saveChannelNum(channel_num); saveFreq(freq + loraConfig.frequency_offset); + const char *channelName = channels.getName(channels.getPrimaryIndex()); slotTimeMsec = computeSlotTimeMsec(); preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw); LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset); - LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", myRegion->name, channelName, loraConfig.modem_preset, + LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", newRegion->name, channelName, loraConfig.modem_preset, channel_num, power); - LOG_INFO("myRegion->freqStart -> myRegion->freqEnd: %f -> %f (%f MHz)", myRegion->freqStart, myRegion->freqEnd, - myRegion->freqEnd - myRegion->freqStart); - LOG_INFO("numChannels: %d x %.3fkHz", numChannels, bw); + LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd, + newRegion->freqEnd - newRegion->freqStart); + LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw); + if (newRegion->profile->overrideSlot != 0) { + LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot); + } LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec); -} +} // end of applyModemConfig /** Slottime is the time to detect a transmission has started, consisting of: - CAD duration; @@ -992,4 +1165,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) sendingPacket = p; return p->encrypted.size + sizeof(PacketHeader); -} +} \ No newline at end of file diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 8f793f47a..a7176f388 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -127,7 +127,7 @@ class RadioInterface * Coerce LoRa config fields (bandwidth/spread_factor) derived from presets. * This is used during early bootstrapping so UIs that display these fields directly remain consistent. */ - static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); + // static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded? /** * Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving) @@ -234,6 +234,20 @@ class RadioInterface // Whether we use the default frequency slot given our LoRa config (region and modem preset) static bool uses_default_frequency_slot; + // Whether we have a custom channel name + static bool uses_custom_channel_name; + + static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp); + + // Check if a candidate region is compatible and valid. + static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig); + + // Check if a candidate radio configuration is valid. + static bool validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig); + + // Make a candidate radio configuration valid, even if it isn't. + static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig); + protected: int8_t power = 17; // Set by applyModemConfig() diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index bafd184cf..887553d62 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -24,6 +24,7 @@ #include "Default.h" #include "MeshRadio.h" +#include "RadioInterface.h" #include "TypeConversions.h" #if !MESHTASTIC_EXCLUDE_MQTT @@ -199,7 +200,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_set_config_tag: LOG_DEBUG("Client set config"); - handleSetConfig(r->set_config); + handleSetConfig(r->set_config, fromOthers); break; case meshtastic_AdminMessage_set_module_config_tag: @@ -626,7 +627,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) } } -void AdminModule::handleSetConfig(const meshtastic_Config &c) +void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) { auto changes = SEGMENT_CONFIG; auto existingRole = config.device.role; @@ -768,6 +769,69 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) validatedLora.spread_factor = LORA_SF_DEFAULT; } + // If we're setting a new region, check the region is valid and then init the region or discard the change + if (validatedLora.region != myRegion->code) { + // Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid + if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) { + // If we're setting region for the first time, init the region and regenerate the keys + if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (!owner.is_licensed) { + bool keygenSuccess = false; + if (config.security.private_key.size == 32) { + if (crypto->regeneratePublicKey(config.security.public_key.bytes, + config.security.private_key.bytes)) { + keygenSuccess = true; + } + } else { + LOG_INFO("Generate new PKI keys"); + crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); + keygenSuccess = true; + } + if (keygenSuccess) { + config.security.public_key.size = 32; + config.security.private_key.size = 32; + owner.public_key.size = 32; + memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); + } + } +#endif + // new region is valid and we're coming from an unset region, so enable tx + validatedLora.tx_enabled = true; + } + // If we're unsetting the region for some reason, disable tx + if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + validatedLora.tx_enabled = false; + } + // Ensure initRegion() uses the newly validated region + config.lora.region = validatedLora.region; + initRegion(); + if (myRegion->dutyCycle < 100) { + validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit + } + if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { + // Default root is in use, so subscribe to the appropriate MQTT topic for this region + sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); + } + changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; + } else { + // Region validation has failed, so just copy all of the old config over the new config + validatedLora = oldLoraConfig; + } + } // end of new region handling + + if (!RadioInterface::validateConfigLora(validatedLora)) { + if (fromOthers) { + LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); + // modem_preset set to use the old setting if the check fails + validatedLora.modem_preset = oldLoraConfig.modem_preset; + } else { + LOG_WARN("Invalid LoRa config received from client, using corrected values"); + RadioInterface::clampConfigLora(validatedLora); + } + // use_preset and bandwidth are coerced into valid values by the check. + } + // If no lora radio parameters change, don't need to reboot if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region && oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth && @@ -795,63 +859,21 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) digitalWrite(RF95_FAN_EN, HIGH ^ 0); } #endif - config.lora = validatedLora; #if HAS_LORA_FEM // Apply FEM LNA mode from config (only meaningful on hardware that supports it) + // Note that a rejected lora config will revert this as well. if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); - } else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { + loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); + } else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { // Hardware FEM does not support LNA control; normalize stored config to match actual capability LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT"); - config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; + validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; } #endif - // If we're setting region for the first time, init the region and regenerate the keys - if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } - } -#endif - config.lora.tx_enabled = true; - initRegion(); - if (myRegion->dutyCycle < 100) { - config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit - } - // Compare the entire string, we are sure of the length as a topic has never been set - if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) { - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; - } - } - if (config.lora.region != myRegion->code) { - // Region has changed so check whether there is a regulatory one we should be using instead. - // Additionally as a side-effect, assume a new value under myRegion - initRegion(); - if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { - // Default root is in use, so subscribe to the appropriate MQTT topic for this region - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - } + config.lora = validatedLora; // Finally, return the validated config back to the main config - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; - } break; } case meshtastic_Config_bluetooth_tag: @@ -899,10 +921,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) } if (requiresReboot && !hasOpenEditTransaction) { disableBluetooth(); - } + } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); -} +} // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) { @@ -1127,83 +1149,85 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default; if (req.decoded.want_response) { + const char *configName = "?"; switch (configType) { case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG: - LOG_INFO("Get module config: MQTT"); + configName = "MQTT"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag; res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt; break; case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG: - LOG_INFO("Get module config: Serial"); + configName = "Serial"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_serial_tag; res.get_module_config_response.payload_variant.serial = moduleConfig.serial; break; case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG: - LOG_INFO("Get module config: External Notification"); + configName = "External Notification"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag; res.get_module_config_response.payload_variant.external_notification = moduleConfig.external_notification; break; case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG: - LOG_INFO("Get module config: Store & Forward"); + configName = "Store & Forward"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag; res.get_module_config_response.payload_variant.store_forward = moduleConfig.store_forward; break; case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG: - LOG_INFO("Get module config: Range Test"); + configName = "Range Test"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_range_test_tag; res.get_module_config_response.payload_variant.range_test = moduleConfig.range_test; break; case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG: - LOG_INFO("Get module config: Telemetry"); + configName = "Telemetry"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag; res.get_module_config_response.payload_variant.telemetry = moduleConfig.telemetry; break; case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG: - LOG_INFO("Get module config: Canned Message"); + configName = "Canned Message"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag; res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message; break; case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG: - LOG_INFO("Get module config: Audio"); + configName = "Audio"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag; res.get_module_config_response.payload_variant.audio = moduleConfig.audio; break; case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG: - LOG_INFO("Get module config: Remote Hardware"); + configName = "Remote Hardware"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; res.get_module_config_response.payload_variant.remote_hardware = moduleConfig.remote_hardware; break; case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG: - LOG_INFO("Get module config: Neighbor Info"); + configName = "Neighbor Info"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag; res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info; break; case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG: - LOG_INFO("Get module config: Detection Sensor"); + configName = "Detection Sensor"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag; res.get_module_config_response.payload_variant.detection_sensor = moduleConfig.detection_sensor; break; case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG: - LOG_INFO("Get module config: Ambient Lighting"); + configName = "Ambient Lighting"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag; res.get_module_config_response.payload_variant.ambient_lighting = moduleConfig.ambient_lighting; break; case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG: - LOG_INFO("Get module config: Paxcounter"); + configName = "Paxcounter"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter; break; case meshtastic_AdminMessage_ModuleConfigType_STATUSMESSAGE_CONFIG: - LOG_INFO("Get module config: StatusMessage"); + configName = "StatusMessage"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag; res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage; break; case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG: - LOG_INFO("Get module config: Traffic Management"); + configName = "Traffic Management"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management; break; } + LOG_INFO("Get module config: %s", configName); // NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior. // So even if we internally use 0 to represent 'use default' we still need to send the value we are @@ -1386,23 +1410,17 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) { // Validate ham parameters before setting since this would bypass validation in the owner struct - if (*p.call_sign) { - const char *start = p.call_sign; - // Skip all whitespace - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham call_sign: must contain at least 1 non-whitespace character"); - return; - } - } - if (*p.short_name) { - const char *start = p.short_name; - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham short_name: must contain at least 1 non-whitespace character"); - return; + const char *fieldsToCheck[] = {p.call_sign, p.short_name}; + const char *fieldNames[] = {"call_sign", "short_name"}; + for (int i = 0; i < 2; i++) { + if (*fieldsToCheck[i]) { + const char *start = fieldsToCheck[i]; + while (*start && isspace((unsigned char)*start)) + start++; + if (*start == '\0') { + LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]); + return; + } } } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index c446887b3..5c690abbd 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -60,7 +60,11 @@ class AdminModule : public ProtobufModule, public Obser */ void handleSetOwner(const meshtastic_User &o); void handleSetChannel(const meshtastic_Channel &cc); - void handleSetConfig(const meshtastic_Config &c); + + protected: + void handleSetConfig(const meshtastic_Config &c, bool fromOthers); + + private: bool handleSetModuleConfig(const meshtastic_ModuleConfig &c); void handleSetChannel(); void handleSetHamMode(const meshtastic_HamParameters &req); diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index 77cc94359..37e3e9184 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -100,7 +100,7 @@ AudioModule::AudioModule() : SinglePortModule("Audio", meshtastic_PortNum_AUDIO_ // moduleConfig.audio.i2s_sck = 14; // moduleConfig.audio.ptt_pin = 39; - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { LOG_INFO("Set up codec2 in mode %u", (moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1); codec2 = codec2_create((moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1); memcpy(tx_header.magic, c2_magic, sizeof(c2_magic)); @@ -143,7 +143,7 @@ void AudioModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int int32_t AudioModule::runOnce() { - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { esp_err_t res; if (firstTime) { // Set up I2S Processor configuration. This will produce 16bit samples at 8 kHz instead of 12 from the ADC @@ -270,7 +270,7 @@ void AudioModule::sendPayload(NodeNum dest, bool wantReplies) ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp) { - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { auto &p = mp.decoded; if (!isFromUs(&mp)) { memcpy(rx_encode_frame, p.payload.bytes, p.payload.size); diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp new file mode 100644 index 000000000..9906bb94c --- /dev/null +++ b/test/test_admin_radio/test_main.cpp @@ -0,0 +1,814 @@ +/** + * Tests for the radio configuration validation and clamping functions + * introduced in the radio_interface_cherrypick branch. + * + * Targets: + * 1. getRegion() + * 2. RadioInterface::validateConfigRegion() + * 3. RadioInterface::validateConfigLora() + * 4. RadioInterface::clampConfigLora() + * 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF) + * 6. Channel spacing calculation (placeholder for future protobuf changes) + */ + +#include "MeshRadio.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "TestUtil.h" +#include "modules/AdminModule.h" +#include + +#include "meshtastic/config.pb.h" + +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +static MockMeshService *mockMeshService; + +// ----------------------------------------------------------------------- +// getRegion() tests +// ----------------------------------------------------------------------- +extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code); + +static void test_getRegion_returnsCorrectRegion_US() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code); + TEST_ASSERT_EQUAL_STRING("US", r->name); +} + +static void test_getRegion_returnsCorrectRegion_EU868() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code); + TEST_ASSERT_EQUAL_STRING("EU_868", r->name); +} + +static void test_getRegion_returnsCorrectRegion_LORA24() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code); + TEST_ASSERT_TRUE(r->wideLora); +} + +static void test_getRegion_unsetCodeReturnsUnsetEntry() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); + TEST_ASSERT_EQUAL_STRING("UNSET", r->name); +} + +static void test_getRegion_unknownCodeFallsToUnset() +{ + // A code not in the table should iterate to the UNSET sentinel + const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); +} + +// ----------------------------------------------------------------------- +// validateConfigRegion() tests +// ----------------------------------------------------------------------- + +static void test_validateConfigRegion_validRegionReturnsTrue() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + + // Ensure owner is not licensed (should not matter for non-licensed-only regions) + devicestate.owner.is_licensed = false; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg)); +} + +static void test_validateConfigRegion_unsetRegionReturnsTrue() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + + devicestate.owner.is_licensed = false; + + // UNSET region has licensedOnly=false, so should pass + TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg)); +} + +// ----------------------------------------------------------------------- +// Shadow tables for testing (preset lists → profiles → regions → lookup) +// ----------------------------------------------------------------------- + +// A minimal preset list with only one entry +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + MODEM_PRESET_END, +}; + +// A preset list that includes all turbo variants only +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = { + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + MODEM_PRESET_END, +}; + +// A restricted list simulating a hypothetical tight-regulation region +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + MODEM_PRESET_END, +}; + +// Mirrors PROFILE_STD but with non-zero spacing/padding for testing +static const RegionProfile TEST_PROFILE_SPACED = { + TEST_PRESETS_SINGLE, + /* spacing */ 0.025f, + /* padding */ 0.010f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ 0, +}; + +// A licensed-only profile for testing access control +static const RegionProfile TEST_PROFILE_LICENSED = { + TEST_PRESETS_RESTRICTED, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ false, + /* licensedOnly */ true, + /* textThrottle */ 5, + /* positionThrottle */ 10, + /* telemetryThrottle */ 10, + /* overrideSlot */ 3, +}; + +// Turbo-only profile +static const RegionProfile TEST_PROFILE_TURBO = { + TEST_PRESETS_TURBO_ONLY, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ 0, +}; + +static const RegionInfo testRegions[] = { + // A wide US-like region with spacing + padding + {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"}, + + // A narrow band simulating tight EU regulation + {meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED, + "TEST_EU_LICENSED"}, + + // A wide-LoRa region with turbo-only presets + {meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO, + "TEST_LORA24_TURBO"}, + + // Sentinel — must be last + {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"}, +}; + +static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code) +{ + const RegionInfo *r = testRegions; + while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + if (r->code == code) + return r; + r++; + } + return r; // Returns the UNSET sentinel +} + +// ----------------------------------------------------------------------- +// Shadow table tests +// ----------------------------------------------------------------------- + +static void test_shadowTable_spacedProfileHasNonZeroSpacing() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding); +} + +static void test_shadowTable_licensedProfileFlagsCorrect() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_TRUE(r->profile->licensedOnly); + TEST_ASSERT_FALSE(r->profile->audioPermitted); + TEST_ASSERT_EQUAL(3, r->profile->overrideSlot); +} + +static void test_shadowTable_presetCountMatchesExpected() +{ + const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(1, spaced->getNumPresets()); + + const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(2, licensed->getNumPresets()); + + const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(2, turbo->getNumPresets()); +} + +static void test_shadowTable_defaultPresetIsFirstInList() +{ + const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset()); + + const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset()); + + const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset()); +} + +static void test_shadowTable_channelSpacingWithPadding() +{ + // Verify channel count when spacing + padding are non-zero + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora); + float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f); + + // spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250 + // channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing); + + uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f); + // (928 - 902 + 0.025) / 0.295 = 88.2 → 88 + TEST_ASSERT_EQUAL_UINT32(88, numChannels); +} + +static void test_shadowTable_turboOnlyOnWideLora() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_TRUE(r->wideLora); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset()); + + // Verify wide-LoRa bandwidth for SHORT_TURBO + float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora); + TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode +} + +static void test_shadowTable_unknownCodeFallsToSentinel() +{ + const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); + TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name); +} + +// ----------------------------------------------------------------------- +// validateConfigLora() tests +// ----------------------------------------------------------------------- + +static void test_validateConfigLora_validPresetForUS() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_allStdPresetsValidForUS() +{ + meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }; + + for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = stdPresets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US"); + } +} + +static void test_validateConfigLora_turboPresetsInvalidForEU868() +{ + // EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868"); +} + +static void test_validateConfigLora_validPresetsForEU868() +{ + meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + }; + + for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + cfg.modem_preset = eu868Presets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868"); + } +} + +static void test_validateConfigLora_customBandwidthTooWideForEU868() +{ + // EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz + // A 500 kHz custom BW should be rejected + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 500; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_customBandwidthFitsUS() +{ + // US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = false; + cfg.bandwidth = 250; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_customBandwidthFitsEU868() +{ + // EU_868 spans 250 kHz, 125 kHz BW should fit + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 125; + cfg.spread_factor = 12; + cfg.coding_rate = 8; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_bogusPresetRejected() +{ + // A fabricated preset value not in any list should be rejected + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast() +{ + // UNSET uses PROFILE_UNDEF which has only LONG_FAST + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET"); +} + +static void test_validateConfigLora_allPresetsValidForLORA24() +{ + // LORA_24 uses PROFILE_STD (9 presets) with wideLora=true + meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }; + + for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + cfg.use_preset = true; + cfg.modem_preset = stdPresets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24"); + } +} + +// ----------------------------------------------------------------------- +// clampConfigLora() tests +// ----------------------------------------------------------------------- + +static void test_clampConfigLora_invalidPresetClampedToDefault() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset); +} + +static void test_clampConfigLora_validPresetUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_customBwTooWideClampedToDefaultBw() +{ + // EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW. + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 500; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora); + TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth); +} + +static void test_clampConfigLora_customBwValidLeftUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = false; + cfg.bandwidth = 125; + cfg.spread_factor = 12; + cfg.coding_rate = 8; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth); +} + +static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() +{ + // UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault() +{ + // LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD) + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset); +} + +// ----------------------------------------------------------------------- +// RegionInfo preset list integrity tests +// ----------------------------------------------------------------------- + +static void test_presetsStd_hasNineEntries() +{ + // PROFILE_STD should have exactly 9 presets + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(9, us->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets()); +} + +static void test_presetsEU868_hasSevenEntries() +{ + const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(7, eu->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets()); +} + +static void test_presetsUndef_hasOneEntry() +{ + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_EQUAL(1, unset->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets()); +} + +static void test_defaultPresetIsInAvailablePresets() +{ + // For every region, the defaultPreset must appear in its own availablePresets list + const RegionInfo *r = regions; + while (true) { + bool found = false; + for (size_t i = 0; i < r->getNumPresets(); i++) { + if (r->getAvailablePresets()[i] == r->getDefaultPreset()) { + found = true; + break; + } + } + char msg[80]; + snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name); + TEST_ASSERT_TRUE_MESSAGE(found, msg); + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; // UNSET is the sentinel, stop after it + r++; + } +} + +static void test_regionFieldsAreSane() +{ + // Basic sanity check: all regions have freqEnd > freqStart and a non-null name + const RegionInfo *r = regions; + while (true) { + char msg[80]; + snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name); + TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg); + TEST_ASSERT_NOT_NULL(r->name); + TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0"); + TEST_ASSERT_NOT_NULL(r->getAvailablePresets()); + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; + r++; + } +} + +static void test_onlyLORA24HasWideLora() +{ + // Verify that LORA_24 is the only region with wideLora=true + const RegionInfo *r = regions; + while (true) { + char msg[80]; + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { + snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name); + TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg); + } else { + snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name); + TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg); + } + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; + r++; + } +} + +// ----------------------------------------------------------------------- +// Channel spacing calculation (placeholder for future protobuf updates) +// ----------------------------------------------------------------------- + +static void test_channelSpacingCalculation_US_LONG_FAST() +{ + // Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000) + // US: spacing=0, padding=0 + // LONG_FAST on non-wide region: bw=250 kHz + // channelSpacing = 0 + 0 + 0.250 = 0.250 MHz + // numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104 + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f); + uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing); + TEST_ASSERT_EQUAL_UINT32(104, numChannels); +} + +static void test_channelSpacingCalculation_EU868_LONG_FAST() +{ + // EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0 + // LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz + // numChannels = round((0.25 + 0) / 0.250) = 1 + const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora); + float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f); + uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing); + TEST_ASSERT_EQUAL_UINT32(1, numChannels); +} + +// Placeholder: when protobuf region definitions include non-zero padding/spacing, +// add tests here to verify the channel count and frequency calculations. +static void test_channelSpacingCalculation_placeholder() +{ + // TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values, + // verify: + // - Channel count matches expected value for each (region, preset) pair + // - First channel frequency = freqStart + (bw/2000) + padding + // - Nth channel frequency = first + (n * channelSpacing) + // - overrideSlot, when non-zero, forces the channel_num + TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields"); +} + +// ----------------------------------------------------------------------- +// handleSetConfig fromOthers dispatch tests +// ----------------------------------------------------------------------- + +class AdminModuleTestShim : public AdminModule +{ + public: + using AdminModule::handleSetConfig; +}; + +static AdminModuleTestShim *testAdmin; + +static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, + meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora.region = region; + c.payload_variant.lora.use_preset = usePreset; + c.payload_variant.lora.modem_preset = preset; + return c; +} + +static void test_handleSetConfig_fromOthers_invalidPresetRejected() +{ + // Set up a known-good baseline in the global config + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with an invalid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + + testAdmin->handleSetConfig(c, true); // fromOthers = true + + // fromOthers=true: invalid preset should be rejected, old preset preserved + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); +} + +static void test_handleSetConfig_fromLocal_invalidPresetClamped() +{ + // Set up a known-good baseline + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with an invalid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + + testAdmin->handleSetConfig(c, false); // fromOthers = false (local client) + + // fromOthers=false: invalid preset should be clamped to the region's default + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset); +} + +static void test_handleSetConfig_fromOthers_validPresetAccepted() +{ + // Set up baseline + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with a valid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + + testAdmin->handleSetConfig(c, true); // fromOthers = true + + // Valid preset should be accepted regardless of fromOthers + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); +} + +// ----------------------------------------------------------------------- +// Test runner +// ----------------------------------------------------------------------- + +void setUp(void) +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; + testAdmin = new AdminModuleTestShim(); +} +void tearDown(void) +{ + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; + delete testAdmin; + testAdmin = nullptr; +} + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + + UNITY_BEGIN(); + + // getRegion() + RUN_TEST(test_getRegion_returnsCorrectRegion_US); + RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); + RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); + RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry); + RUN_TEST(test_getRegion_unknownCodeFallsToUnset); + + // validateConfigRegion() + RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue); + RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue); + + // Shadow table tests + RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing); + RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect); + RUN_TEST(test_shadowTable_presetCountMatchesExpected); + RUN_TEST(test_shadowTable_defaultPresetIsFirstInList); + RUN_TEST(test_shadowTable_channelSpacingWithPadding); + RUN_TEST(test_shadowTable_turboOnlyOnWideLora); + RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel); + + // validateConfigLora() + RUN_TEST(test_validateConfigLora_validPresetForUS); + RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS); + RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868); + RUN_TEST(test_validateConfigLora_validPresetsForEU868); + RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868); + RUN_TEST(test_validateConfigLora_customBandwidthFitsUS); + RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868); + RUN_TEST(test_validateConfigLora_bogusPresetRejected); + RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast); + RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24); + + // clampConfigLora() + RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); + RUN_TEST(test_clampConfigLora_validPresetUnchanged); + RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw); + RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged); + RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); + RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault); + + // RegionInfo preset list integrity + RUN_TEST(test_presetsStd_hasNineEntries); + RUN_TEST(test_presetsEU868_hasSevenEntries); + RUN_TEST(test_presetsUndef_hasOneEntry); + RUN_TEST(test_defaultPresetIsInAvailablePresets); + RUN_TEST(test_regionFieldsAreSane); + RUN_TEST(test_onlyLORA24HasWideLora); + + // Channel spacing (current + placeholder) + RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST); + RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST); + RUN_TEST(test_channelSpacingCalculation_placeholder); + + // handleSetConfig fromOthers dispatch + RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected); + RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped); + RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index fbe2b1b13..49eaabe5b 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -1,10 +1,19 @@ #include "MeshRadio.h" +#include "MeshService.h" #include "RadioInterface.h" #include "TestUtil.h" #include #include "meshtastic/config.pb.h" +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +static MockMeshService *mockMeshService; + static void test_bwCodeToKHz_specialMappings() { TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31)); @@ -21,7 +30,7 @@ static void test_bwCodeToKHz_passthrough() TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250)); } -static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse() +static void test_validateConfigLora_noopWhenUsePresetFalse() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = false; @@ -30,55 +39,78 @@ static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse() cfg.bandwidth = 123; cfg.spread_factor = 8; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); + RadioInterface::validateConfigLora(cfg); TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth); TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); } -static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion() +static void test_validateConfigLora_validPreset_nonWideRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor); + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); } -static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion() +static void test_validateConfigLora_validPreset_wideRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL_UINT16(800, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor); + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); } -static void test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan() +static void test_validateConfigLora_rejectsInvalidPresetForRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); - TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(11, cfg.spread_factor); + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); } -void setUp(void) {} -void tearDown(void) {} +static void test_clampConfigLora_invalidPresetClampedToDefault() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.use_preset = true; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_validPresetUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.use_preset = true; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); +} + +void setUp(void) +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; +} +void tearDown(void) +{ + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; +} void setup() { @@ -90,10 +122,12 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_bwCodeToKHz_specialMappings); RUN_TEST(test_bwCodeToKHz_passthrough); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan); + RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse); + RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion); + RUN_TEST(test_validateConfigLora_validPreset_wideRegion); + RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion); + RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); + RUN_TEST(test_clampConfigLora_validPresetUnchanged); exit(UNITY_END()); } From 3604c1255da92778ffdf51b0a803c2d6e5d377bd Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 20 Mar 2026 09:53:12 -0500 Subject: [PATCH 22/50] Consolidate PKI key generation logic into ensurePkiKeys method --- protobufs | 2 +- src/graphics/draw/MenuHandler.cpp | 23 ++-------------- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 20 ++------------ src/mesh/CryptoEngine.cpp | 27 +++++++++++++++++++ src/mesh/CryptoEngine.h | 1 + src/modules/AdminModule.cpp | 20 ++------------ 6 files changed, 35 insertions(+), 58 deletions(-) diff --git a/protobufs b/protobufs index a229208f2..eba2d94c8 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit a229208f29a59cf1d8cfa24cbb7567a08f2d1771 +Subproject commit eba2d94c8d53e798f560e12d63d0457e1e22759e diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index b069dfb9d..28836b536 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -163,28 +163,9 @@ void menuHandler::LoraRegionPicker(uint32_t duration) config.lora.region = selectedRegion; auto changes = SEGMENT_CONFIG; - // FIXME: This should be a method consolidated with the same logic in the admin message as well - // This is needed as we wait til picking the LoRa region to generate keys for the first time. #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - // public key is derived from private, so this will always have the same result. - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif config.lora.tx_enabled = true; diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index b2ef1f714..d489d21ee 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -177,24 +177,8 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) auto changes = SEGMENT_CONFIG; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 72216a63c..4a613b644 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -61,6 +61,33 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) } return true; } + +bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user) +{ + if (user.is_licensed) { + return false; + } + + bool keygenSuccess = false; + if (security.private_key.size == 32) { + if (regeneratePublicKey(security.public_key.bytes, security.private_key.bytes)) { + keygenSuccess = true; + } + } else { + LOG_INFO("Generate new PKI keys"); + generateKeyPair(security.public_key.bytes, security.private_key.bytes); + keygenSuccess = true; + } + + if (keygenSuccess) { + security.public_key.size = 32; + security.private_key.size = 32; + user.public_key.size = 32; + memcpy(user.public_key.bytes, security.public_key.bytes, 32); + } + + return keygenSuccess; +} #endif /** diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 19d572355..f40400331 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -36,6 +36,7 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey); virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey); + virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user); #endif void setDHPrivateKey(uint8_t *_private_key); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 887553d62..340a75080 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -776,24 +776,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // If we're setting region for the first time, init the region and regenerate the keys if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, - config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif // new region is valid and we're coming from an unset region, so enable tx From eb80d3141d2fe8deee3e13ec15f70bc19fcc9a94 Mon Sep 17 00:00:00 2001 From: Fernando Nunes Date: Fri, 20 Mar 2026 20:33:45 +0000 Subject: [PATCH 23/50] Fixes #9792 : Hop with Meshtastic ffff and ?dB is added to missing hop in traceroute (#9945) * Fix issue 9792, decode packet for TR test * Fix 9792: Assure packet id decoded for TR test * Potential fix for pull request finding Log improvement for failure to decode packet. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * trunk fmt --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Tom Fifield --- src/mesh/FloodingRouter.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 78602a9ec..13f98299f 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -91,10 +91,27 @@ void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) { if (nodeDB) nodeDB->updateFrom(*p); + #if !MESHTASTIC_EXCLUDE_TRACEROUTE + if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + // If we got a packet that is not decoded, try to decode it so we can check for traceroute. + auto decodedState = perhapsDecode(const_cast(p)); + if (decodedState == DecodeState::DECODE_SUCCESS) { + // parsing was successful, print for debugging + printPacket("reprocessPacket(DUP)", p); + } else { + // Fatal decoding error, we can't do anything with this packet + LOG_WARN( + "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute", + static_cast(decodedState), p->id, getFrom(p)); + return; + } + } + if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && - p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) + p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { traceRouteModule->processUpgradedPacket(*p); + } #endif } From f982b58d61270df31014b104c1eaf43e09ebb86d Mon Sep 17 00:00:00 2001 From: Okan Erturan Date: Sun, 22 Mar 2026 14:43:41 +0300 Subject: [PATCH 24/50] Fix: Enable touch-to-backlight on T-Echo (not just T-Echo Plus) (#9953) The touch-to-backlight feature was gated behind TTGO_T_ECHO_PLUS, but the regular T-Echo has the same backlight pin (PIN_EINK_EN, P1.11). This changes the guard to use PIN_EINK_EN only, so any device with an e-ink backlight pin gets the feature. Fixes #7630 Co-authored-by: Ben Meadors --- src/input/InputBroker.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index acf79f149..e3125ca12 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -34,7 +34,7 @@ #if defined(BUTTON_PIN_TOUCH) ButtonThread *TouchButtonThread = nullptr; -#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN) +#if defined(PIN_EINK_EN) static bool touchBacklightWasOn = false; static bool touchBacklightActive = false; #endif @@ -220,8 +220,8 @@ void InputBroker::Init() }; touchConfig.singlePress = INPUT_BROKER_NONE; touchConfig.longPress = INPUT_BROKER_BACK; -#if defined(TTGO_T_ECHO_PLUS) && defined(PIN_EINK_EN) - // On T-Echo Plus the touch pad should only drive the backlight, not UI navigation/sounds +#if defined(PIN_EINK_EN) + // Touch pad drives the backlight on devices with e-ink backlight pin touchConfig.longPress = INPUT_BROKER_NONE; touchConfig.suppressLeadUpSound = true; touchConfig.onPress = []() { From a632d0133f876f18361d6ffd3e578bd8aaf84551 Mon Sep 17 00:00:00 2001 From: Robert Sasak Date: Sun, 22 Mar 2026 14:42:13 +0100 Subject: [PATCH 25/50] Add LED_BUILTIN for variant tlora_v1 (#9973) --- variants/esp32/tlora_v1/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/esp32/tlora_v1/platformio.ini b/variants/esp32/tlora_v1/platformio.ini index c45cc2ce9..5f72d634e 100644 --- a/variants/esp32/tlora_v1/platformio.ini +++ b/variants/esp32/tlora_v1/platformio.ini @@ -13,4 +13,5 @@ build_flags = ${esp32_base.build_flags} -D TLORA_V1 -I variants/esp32/tlora_v1 + -ULED_BUILTIN upload_speed = 115200 From 8384659608fbd4ae414ac692610c2d72884cb2d9 Mon Sep 17 00:00:00 2001 From: Elwimen <8713394+Elwimen@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:39:41 +0100 Subject: [PATCH 26/50] fix: apply all LoRa config changes live without rebooting (#9962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply all LoRa config changes live without rebooting All LoRa radio settings (SF, BW, CR, frequency, power, preset, sx126x_rx_boosted_gain) now apply immediately via reconfigure() without requiring a node reboot. - AdminModule: requiresReboot = false for all LoRa config changes; LoRa changes were already handled by the configChanged observer calling reconfigure() but the reboot flag was set unnecessarily - AdminModule: validate LORA_24 region against radio hardware at config time; reject with BAD_REQUEST if hardware lacks 2.4 GHz capability (wideLora() returns false or no radio instance) - SX126xInterface/LR11x0Interface: apply sx126x_rx_boosted_gain in reconfigure(); register 0x08AC is writable in STDBY mode (SX1261/2 datasheet §9.6); retention registers written so setting survives warm-sleep cycles; log warning on setter failure - DebugRenderer: show BW/SF/CR on debug screen when custom modem is active instead of the preset name - DisplayFormatters: clarify comment on getModemPresetDisplayName * fix: remove redundant reboot after LoRa config changes in on-device menus Region, frequency slot, and radio preset pickers in MenuHandler all called reloadConfig() then immediately set rebootAtMsec. reloadConfig() already fires the configChanged observer which calls reconfigure(), so the forced reboot was unnecessary — same rationale as the parent commit. * fix: guard LORA_24 region selection against hardware capability in on-device menu Without a reboot, reconfigure() now applies region changes directly. Previously getRadio() caught the LORA_24-on-sub-GHz mismatch post-reboot and reverted to UNSET — that safety net is gone. Add an explicit wideLora() check in LoraRegionPicker so sub-GHz-only hardware silently ignores LORA_24 selection instead of attempting a live reconfigure with an invalid frequency. --------- Co-authored-by: elwimen Co-authored-by: Ben Meadors --- src/DisplayFormatters.cpp | 3 +- src/graphics/draw/DebugRenderer.cpp | 13 +++++++-- src/graphics/draw/MenuHandler.cpp | 12 ++++++-- src/mesh/LR11x0Interface.cpp | 5 ++++ src/mesh/SX126xInterface.cpp | 5 ++++ src/modules/AdminModule.cpp | 44 ++++++++++++++++++++--------- 6 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index d88f9fc9f..fdcf840dc 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC bool usePreset) { - // If use_preset is false, always return "Custom" + // If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels + // rely on this being a stable literal for channel-name hashing and default-channel detection. if (!usePreset) { return "Custom"; } diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 2069c71ec..27a50d3f9 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -408,7 +408,16 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(nameX, getTextPositions(display)[line++], device_role); // === Third Row: Radio Preset === - auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset); + // For custom modem settings show the actual parameters; for presets use the preset name. + char modeStr[16]; + if (!config.lora.use_preset) { + snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast(config.lora.bandwidth), + static_cast(config.lora.spread_factor), static_cast(config.lora.coding_rate)); + } else { + strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), + sizeof(modeStr) - 1); + modeStr[sizeof(modeStr) - 1] = '\0'; + } char regionradiopreset[25]; const char *region = myRegion ? myRegion->name : NULL; @@ -416,7 +425,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, if (currentResolution == ScreenResolution::UltraLow) { snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region); } else { - snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); + snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr); } } textWidth = display->getStringWidth(regionradiopreset); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 28836b536..daefff8ee 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -18,6 +18,7 @@ #include "main.h" #include "mesh/Default.h" #include "mesh/MeshTypes.h" +#include "mesh/RadioLibInterface.h" #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" @@ -160,6 +161,14 @@ void menuHandler::LoraRegionPicker(uint32_t duration) return; } + // Guard: without a reboot, reconfigure() applies the region directly. + // Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot. + if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 && + !(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) { + LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring LORA_24 selection"); + return; + } + config.lora.region = selectedRegion; auto changes = SEGMENT_CONFIG; @@ -181,7 +190,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration) } service->reloadConfig(changes); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); bannerOptions.durationMs = duration; @@ -300,7 +308,6 @@ void menuHandler::FrequencySlotPicker() config.lora.channel_num = selected; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }; screen->showOverlayBanner(bannerOptions); @@ -339,7 +346,6 @@ void menuHandler::radioPresetPicker() config.lora.channel_num = 0; // Reset to default channel for the preset config.lora.override_frequency = 0; // Clear any custom frequency service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); screen->showOverlayBanner(bannerOptions); diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 4fec06da4..9f808da26 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -192,6 +192,11 @@ template bool LR11x0Interface::reconfigure() err = lora.setOutputPower(power); assert(err == RADIOLIB_ERR_NONE); + // Apply RX gain mode — valid in STDBY, matches resetAGC() pattern + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err); + startReceive(); // restart receiving return RADIOLIB_ERR_NONE; diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 2e9a3250d..a4e82011e 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -253,6 +253,11 @@ template bool SX126xInterface::reconfigure() LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err); assert(err == RADIOLIB_ERR_NONE); + // Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err); + startReceive(); // restart receiving return RADIOLIB_ERR_NONE; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 340a75080..e3a501097 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -26,6 +26,7 @@ #include "MeshRadio.h" #include "RadioInterface.h" #include "TypeConversions.h" +#include "mesh/RadioLibInterface.h" #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" @@ -198,10 +199,35 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta handleSetOwner(r->set_owner); break; - case meshtastic_AdminMessage_set_config_tag: + case meshtastic_AdminMessage_set_config_tag: { LOG_DEBUG("Client set config"); - handleSetConfig(r->set_config, fromOthers); + + // Non-LoRa configs need no further validation. + if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) { + LOG_DEBUG("Non-LoRa config, applying directly"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + // Only LORA_24 requires hardware capability validation. + if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { + LOG_DEBUG("LoRa config, region is not LORA_24, applying directly"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + // Hardware supports 2.4 GHz — apply the config. + // Fail closed: null instance is treated as incapable. + if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) { + LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region"); + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; + } case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); @@ -816,17 +842,9 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // use_preset and bandwidth are coerced into valid values by the check. } - // If no lora radio parameters change, don't need to reboot - if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region && - oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth && - oldLoraConfig.spread_factor == validatedLora.spread_factor && - oldLoraConfig.coding_rate == validatedLora.coding_rate && oldLoraConfig.tx_power == validatedLora.tx_power && - oldLoraConfig.frequency_offset == validatedLora.frequency_offset && - oldLoraConfig.override_frequency == validatedLora.override_frequency && - oldLoraConfig.channel_num == validatedLora.channel_num && - oldLoraConfig.sx126x_rx_boosted_gain == validatedLora.sx126x_rx_boosted_gain) { - requiresReboot = false; - } + // All LoRa radio changes apply live via configChanged observer → reconfigure(). + // reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive. + requiresReboot = false; #if defined(ARCH_PORTDUINO) // If running on portduino and using SimRadio, do not require reboot From d293d654a0200fef8bbb11e7bcd716e4b5699c89 Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:54:46 +0800 Subject: [PATCH 27/50] add heltec_mesh_node_t096 board. (#9960) * add heltec_mesh_node_t096 board. * Fixed the GPS reset pin comments. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Added compiles if NUM_PA_POINTS is not defined. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Correct the pin description. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Specify the version of the dependency library TFT_eSPI. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Adding fields missing from the .ini file. * Modify the screen SPI frequency to 40 MHz. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors --- boards/heltec_mesh_node_t096.json | 54 +++++ src/configuration.h | 11 + src/graphics/TFTDisplay.cpp | 18 +- src/mesh/LoRaFEMInterface.cpp | 4 +- .../heltec_mesh_node_t096/platformio.ini | 34 +++ .../heltec_mesh_node_t096/variant.cpp | 76 +++++++ .../nrf52840/heltec_mesh_node_t096/variant.h | 202 ++++++++++++++++++ 7 files changed, 389 insertions(+), 10 deletions(-) create mode 100644 boards/heltec_mesh_node_t096.json create mode 100644 variants/nrf52840/heltec_mesh_node_t096/platformio.ini create mode 100644 variants/nrf52840/heltec_mesh_node_t096/variant.cpp create mode 100644 variants/nrf52840/heltec_mesh_node_t096/variant.h diff --git a/boards/heltec_mesh_node_t096.json b/boards/heltec_mesh_node_t096.json new file mode 100644 index 000000000..1e417c5b4 --- /dev/null +++ b/boards/heltec_mesh_node_t096.json @@ -0,0 +1,54 @@ +{ + "build": { + "arduino": { + "ldscript": "nrf52840_s140_v6.ld" + }, + "core": "nRF5", + "cpu": "cortex-m4", + "extra_flags": "-DNRF52840_XXAA", + "f_cpu": "64000000L", + "hwids": [ + ["0x239A", "0x4405"], + ["0x239A", "0x0029"], + ["0x239A", "0x002A"], + ["0x2886", "0x1667"] + ], + "usb_product": "HT-n5262G", + "mcu": "nrf52840", + "variant": "heltec_mesh_node_t096", + "variants_dir": "variants", + "bsp": { + "name": "adafruit" + }, + "softdevice": { + "sd_flags": "-DS140", + "sd_name": "s140", + "sd_version": "6.1.1", + "sd_fwid": "0x00B6" + }, + "bootloader": { + "settings_addr": "0xFF000" + } + }, + "connectivity": ["bluetooth"], + "debug": { + "jlink_device": "nRF52840_xxAA", + "onboard_tools": ["jlink"], + "svd_path": "nrf52840.svd", + "openocd_target": "nrf52840-mdk-rs" + }, + "frameworks": ["arduino"], + "name": "Heltec nrf (Adafruit BSP)", + "upload": { + "maximum_ram_size": 248832, + "maximum_size": 815104, + "speed": 115200, + "protocol": "nrfutil", + "protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"], + "use_1200bps_touch": true, + "require_upload_port": true, + "wait_for_upload_port": true + }, + "url": "https://heltec.org/", + "vendor": "Heltec" +} diff --git a/src/configuration.h b/src/configuration.h index 8936b9d98..11be86007 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -156,8 +156,19 @@ along with this program. If not, see . #ifdef USE_KCT8103L_PA // Power Amps are often non-linear, so we can use an array of values for the power curve +#if defined(HELTEC_WIRELESS_TRACKER_V2) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 12, 11, 10, 9, 8, 7 +#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 +#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. +#if !defined(NUM_PA_POINTS) || !defined(TX_GAIN_LORA) +#error "USE_KCT8103L_PA is defined, but no PA gain curve (NUM_PA_POINTS / TX_GAIN_LORA) is configured for this board." +#endif +#endif #endif #ifdef RAK13302 diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 1c2eb72d4..005ead292 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -1348,7 +1348,7 @@ void TFTDisplay::sendCommand(uint8_t com) digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON); #elif defined(HACKADAY_COMMUNICATOR) tft->displayOn(); -#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) +#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) tft->wakeup(); tft->powerSaveOff(); #endif @@ -1359,7 +1359,7 @@ void TFTDisplay::sendCommand(uint8_t com) #ifdef UNPHONE unphone.backlight(true); // using unPhone library #endif -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) #elif !defined(M5STACK) && !defined(ST7789_CS) && \ !defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function tft->setBrightness(172); @@ -1375,7 +1375,7 @@ void TFTDisplay::sendCommand(uint8_t com) digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON); #elif defined(HACKADAY_COMMUNICATOR) tft->displayOff(); -#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) +#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096) tft->sleep(); tft->powerSaveOn(); #endif @@ -1386,7 +1386,7 @@ void TFTDisplay::sendCommand(uint8_t com) #ifdef UNPHONE unphone.backlight(false); // using unPhone library #endif -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) #elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) tft->setBrightness(0); #endif @@ -1401,7 +1401,7 @@ void TFTDisplay::sendCommand(uint8_t com) void TFTDisplay::setDisplayBrightness(uint8_t _brightness) { -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) // todo #elif !defined(HACKADAY_COMMUNICATOR) tft->setBrightness(_brightness); @@ -1421,7 +1421,7 @@ bool TFTDisplay::hasTouch(void) { #ifdef RAK14014 return true; -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) +#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) return tft->touch() != nullptr; #else return false; @@ -1440,7 +1440,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y) } else { return false; } -#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) +#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) return tft->getTouch(x, y); #else return false; @@ -1457,7 +1457,7 @@ bool TFTDisplay::connect() { concurrency::LockGuard g(spiLock); LOG_INFO("Do TFT init"); -#ifdef RAK14014 +#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) tft = new TFT_eSPI; #elif defined(HACKADAY_COMMUNICATOR) bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */); @@ -1494,7 +1494,7 @@ bool TFTDisplay::connect() ft6336u.begin(); pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(SCREEN_TOUCH_INT), rak14014_tpIntHandle, FALLING); -#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) +#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) || defined(HELTEC_MESH_NODE_T096) tft->setRotation(1); // T-Deck has the TFT in landscape #elif defined(T_WATCH_S3) tft->setRotation(2); // T-Watch S3 left-handed orientation diff --git a/src/mesh/LoRaFEMInterface.cpp b/src/mesh/LoRaFEMInterface.cpp index b05bb65ed..b44c7539b 100644 --- a/src/mesh/LoRaFEMInterface.cpp +++ b/src/mesh/LoRaFEMInterface.cpp @@ -173,14 +173,16 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void) #endif #elif defined(USE_KCT8103L_PA) digitalWrite(LORA_KCT8103L_PA_CSD, HIGH); - rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD); if (lna_enabled) { digitalWrite(LORA_KCT8103L_PA_CTX, LOW); } else { digitalWrite(LORA_KCT8103L_PA_CTX, HIGH); } +#if defined(ARCH_ESP32) + rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD); rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX); #endif +#endif } void LoRaFEMInterface::setLNAEnable(bool enabled) diff --git a/variants/nrf52840/heltec_mesh_node_t096/platformio.ini b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini new file mode 100644 index 000000000..e1bdd529d --- /dev/null +++ b/variants/nrf52840/heltec_mesh_node_t096/platformio.ini @@ -0,0 +1,34 @@ +; First prototype nrf52840/sx1262 device +[env:heltec-mesh-node-t096] +custom_meshtastic_hw_model = 127 +custom_meshtastic_hw_model_slug = HELTEC_MESH_NODE_T096 +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec Mesh Node 096 +custom_meshtastic_images = heltec-mesh-node-t096.svg, heltec-mesh-node-t096-case.svg +custom_meshtastic_tags = Heltec + +extends = nrf52840_base +board = heltec_mesh_node_t096 +board_level = pr +debug_tool = jlink + +build_flags = ${nrf52840_base.build_flags} + -Ivariants/nrf52840/heltec_mesh_node_t096 + -D HAS_LORA_FEM=1 + -D HELTEC_MESH_NODE_T096 + -D USE_TFTDISPLAY=1 + -D USER_SETUP_LOADED + -D ST7735_DRIVER + -D ST7735_REDTAB160x80 + -D TFT_SPI_PORT=SPI1 + -D TFT_CS=ST7735_CS ; Chip select control + -D TFT_DC=ST7735_RS ; Data Command control pin + -D TFT_RST=ST7735_RESET ; Reset pin + -D TFT_BL=ST7735_BL ; LED back-light + -D TFT_BACKLIGHT_ON=LOW +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_node_t096> +lib_deps = + ${nrf52840_base.lib_deps} + bodmer/TFT_eSPI@2.5.43 ; renovate: datasource=platformio-registry depName=bodmer/TFT_eSPI diff --git a/variants/nrf52840/heltec_mesh_node_t096/variant.cpp b/variants/nrf52840/heltec_mesh_node_t096/variant.cpp new file mode 100644 index 000000000..29158e8ba --- /dev/null +++ b/variants/nrf52840/heltec_mesh_node_t096/variant.cpp @@ -0,0 +1,76 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "variant.h" +#include "nrf.h" +#include "wiring_constants.h" +#include "wiring_digital.h" + +const uint32_t g_ADigitalPinMap[] = { + // P0 - pins 0 and 1 are hardwired for xtal and should never be enabled + 0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, + + // P1 + 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47}; + +void initVariant() +{ + // LED1 + pinMode(PIN_LED1, OUTPUT); + ledOff(PIN_LED1); +} + +void variant_shutdown() +{ + nrf_gpio_cfg_default(VEXT_ENABLE); + nrf_gpio_cfg_default(ST7735_CS); + nrf_gpio_cfg_default(ST7735_RS); + nrf_gpio_cfg_default(ST7735_SDA); + nrf_gpio_cfg_default(ST7735_SCK); + nrf_gpio_cfg_default(ST7735_RESET); + nrf_gpio_cfg_default(ST7735_BL); + + nrf_gpio_cfg_default(PIN_LED1); + + // nrf_gpio_cfg_default(LORA_PA_POWER); + pinMode(LORA_PA_POWER, OUTPUT); + digitalWrite(LORA_PA_POWER, LOW); + + nrf_gpio_cfg_default(LORA_KCT8103L_PA_CSD); + nrf_gpio_cfg_default(LORA_KCT8103L_PA_CTX); + + pinMode(ADC_CTRL, OUTPUT); + digitalWrite(ADC_CTRL, LOW); + + nrf_gpio_cfg_default(SX126X_CS); + nrf_gpio_cfg_default(SX126X_DIO1); + nrf_gpio_cfg_default(SX126X_BUSY); + nrf_gpio_cfg_default(SX126X_RESET); + + nrf_gpio_cfg_default(PIN_SPI_MISO); + nrf_gpio_cfg_default(PIN_SPI_MOSI); + nrf_gpio_cfg_default(PIN_SPI_SCK); + + nrf_gpio_cfg_default(PIN_GPS_PPS); + nrf_gpio_cfg_default(PIN_GPS_RESET); + nrf_gpio_cfg_default(PIN_GPS_EN); + nrf_gpio_cfg_default(GPS_TX_PIN); + nrf_gpio_cfg_default(GPS_RX_PIN); +} \ No newline at end of file diff --git a/variants/nrf52840/heltec_mesh_node_t096/variant.h b/variants/nrf52840/heltec_mesh_node_t096/variant.h new file mode 100644 index 000000000..04e22af26 --- /dev/null +++ b/variants/nrf52840/heltec_mesh_node_t096/variant.h @@ -0,0 +1,202 @@ +/* + Copyright (c) 2014-2015 Arduino LLC. All right reserved. + Copyright (c) 2016 Sandeep Mistry All right reserved. + Copyright (c) 2018, Adafruit Industries (adafruit.com) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + See the GNU Lesser General Public License for more details. + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef _VARIANT_HELTEC_NRF_ +#define _VARIANT_HELTEC_NRF_ +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +#define VEXT_ENABLE (0 + 26) +#define VEXT_ON_VALUE HIGH + +// ST7735S TFT LCD +#define ST7735_CS (0 + 22) +#define ST7735_RS (0 + 15) // DC +#define ST7735_SDA (0 + 17) // MOSI +#define ST7735_SCK (0 + 20) +#define ST7735_RESET (0 + 13) +#define ST7735_MISO -1 +#define ST7735_BUSY -1 +#define ST7735_BL (32 + 12) +#define SPI_FREQUENCY 40000000 +#define SPI_READ_FREQUENCY 16000000 +#define SCREEN_ROTATE +#define TFT_HEIGHT 160 +#define TFT_WIDTH 80 +#define TFT_OFFSET_X 24 +#define TFT_OFFSET_Y 0 +#define TFT_INVERT false +#define SCREEN_TRANSITION_FRAMERATE 3 // fps +#define DISPLAY_FORCE_SMALL_FONTS + +// Number of pins defined in PinDescription array +#define PINS_COUNT (48) +#define NUM_DIGITAL_PINS (48) +#define NUM_ANALOG_INPUTS (1) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED1 (0 + 28) // green (confirmed on 1.0 board) +#define LED_BLUE PIN_LED1 // fake for bluefruit library +#define LED_GREEN PIN_LED1 +#define LED_STATE_ON 1 // State when LED is lit + +// #define HAS_NEOPIXEL // Enable the use of neopixels +// #define NEOPIXEL_COUNT 2 // How many neopixels are connected +// #define NEOPIXEL_DATA 14 // gpio pin used to send data to the neopixels +// #define NEOPIXEL_TYPE (NEO_GRB + NEO_KHZ800) // type of neopixels in use + +/* + * Buttons + */ +#define PIN_BUTTON1 (32 + 10) +// #define PIN_BUTTON2 (0 + 18) // 0.18 is labeled on the board as RESET but we configure it in the bootloader as a regular +// GPIO + +/* +No longer populated on PCB +*/ +#define PIN_SERIAL2_RX (0 + 9) +#define PIN_SERIAL2_TX (0 + 10) + +/* + * I2C + */ + +#define WIRE_INTERFACES_COUNT 2 + +// I2C bus 0 +#define PIN_WIRE_SDA (0 + 7) // SDA +#define PIN_WIRE_SCL (0 + 8) // SCL + +// I2C bus 1 +#define PIN_WIRE1_SDA (0 + 4) // SDA (secondary bus) +#define PIN_WIRE1_SCL (0 + 27) // SCL (secondary bus) + +/* + * Lora radio + */ + +#define USE_SX1262 +#define SX126X_CS (0 + 5) // FIXME - we really should define LORA_CS instead +#define LORA_CS (0 + 5) +#define SX126X_DIO1 (0 + 21) +#define SX126X_BUSY (0 + 19) +#define SX126X_RESET (0 + 16) +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The heltec_wireless_tracker_v2 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) -> GPIO12: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO41: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO30 +// 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 (0 + 30) // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD (0 + 12) // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX \ + (32 + 9) // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +/* + * SPI Interfaces + */ +#define SPI_INTERFACES_COUNT 2 + +// For LORA, spi 0 +#define PIN_SPI_MISO (0 + 14) +#define PIN_SPI_MOSI (0 + 11) +#define PIN_SPI_SCK (32 + 8) + +#define PIN_SPI1_MISO \ + ST7735_MISO // FIXME not really needed, but for now the SPI code requires something to be defined, pick an used GPIO +#define PIN_SPI1_MOSI ST7735_SDA +#define PIN_SPI1_SCK ST7735_SCK + +/* + * GPS pins + */ +#define GPS_UC6580 +#define GPS_BAUDRATE 115200 +#define PIN_GPS_RESET (32 + 14) // An output to reset UC6580 GPS. As per datasheet, low for > 100ms will reset the UC6580 +#define GPS_RESET_MODE LOW +#define PIN_GPS_EN (0 + 6) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (32 + 11) +#define GPS_TX_PIN (0 + 25) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (0 + 23) // This is for bits going TOWARDS the GPS + +#define GPS_THREAD_INTERVAL 50 + +#define PIN_SERIAL1_RX GPS_RX_PIN +#define PIN_SERIAL1_TX GPS_TX_PIN + +#define ADC_CTRL (32 + 15) +#define ADC_CTRL_ENABLED HIGH +#define BATTERY_PIN (0 + 3) +#define ADC_RESOLUTION 14 + +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#define BATTERY_SENSE_RESOLUTION 4096.0 +#undef AREF_VOLTAGE +#define AREF_VOLTAGE 3.0 +#define VBAT_AR_INTERNAL AR_INTERNAL_3_0 +#define ADC_MULTIPLIER (4.916F) + +// rf52840 AIN1 = Pin 3 +#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_1 + +// We have AIN1 with a VBAT divider so AIN1 = VBAT * (100/490) +// We have the device going deep sleep under 3.1V, which is AIN1 = 0.63V +// So we can wake up when VBAT>=VDD is restored to 3.3V, where AIN2 = 0.67V +// Ratio 0.67/3.3 = 0.20, so we can pick a bit higher, 2/8 VDD, which means +// VBAT=4.04V +#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_2_8 + +#define HAS_RTC 0 +#ifdef __cplusplus +} +#endif + +/*---------------------------------------------------------------------------- + * Arduino objects - C++ only + *----------------------------------------------------------------------------*/ + +#endif From bfaf6c6b20d70ae3cdb57ccdd4be21ba71a1c07a Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:47:52 +0000 Subject: [PATCH 28/50] fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users (#9958) * fix(routing): prevent licensed users from rebroadcasting packets from unlicensed or unknown users * fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users --------- Co-authored-by: Ben Meadors --- src/modules/RoutingModule.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index d87cf3a44..85e7f8c06 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -20,10 +20,11 @@ bool RoutingModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mesh if ((nodeDB->getMeshNode(mp.from) == NULL || !nodeDB->getMeshNode(mp.from)->has_user) && (nodeDB->getMeshNode(mp.to) == NULL || !nodeDB->getMeshNode(mp.to)->has_user)) return false; - } else if (owner.is_licensed && nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) { - // Don't let licensed users to rebroadcast packets from unlicensed users + } else if (owner.is_licensed && ((nodeDB->getLicenseStatus(mp.from) == UserLicenseStatus::NotLicensed) || + (nodeDB->getLicenseStatus(mp.to) == UserLicenseStatus::NotLicensed))) { + // Don't let licensed users to rebroadcast packets to or from unlicensed users // If we know they are in-fact unlicensed - LOG_DEBUG("Packet from unlicensed user, ignoring packet"); + LOG_DEBUG("Packet to or from unlicensed user, ignoring packet"); return false; } From 2ffe2d829c352fe7360411d08a3deab2044991b3 Mon Sep 17 00:00:00 2001 From: stmka_playgound <69413291+dev-nightcore@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:06:53 +0500 Subject: [PATCH 29/50] Fixes #9850: Double space issue with Cyrillic OLED font (#9971) * Fixes double space OLEDDisplayFontsRU.cpp * Fixes double space OLEDDisplayFontsUA.cpp --------- Co-authored-by: Ben Meadors --- src/graphics/fonts/OLEDDisplayFontsRU.cpp | 4 ++-- src/graphics/fonts/OLEDDisplayFontsUA.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/graphics/fonts/OLEDDisplayFontsRU.cpp b/src/graphics/fonts/OLEDDisplayFontsRU.cpp index 3a1159511..9766d36b2 100644 --- a/src/graphics/fonts/OLEDDisplayFontsRU.cpp +++ b/src/graphics/fonts/OLEDDisplayFontsRU.cpp @@ -10,7 +10,7 @@ const uint8_t ArialMT_Plain_10_RU[] PROGMEM = { 0xE0, // Number of chars: 224 // Jump Table: - 0xFF, 0xFF, 0x00, 0x0A, // 32 + 0xFF, 0xFF, 0x00, 0x03, // 32 0x00, 0x00, 0x04, 0x03, // 33 0x00, 0x04, 0x05, 0x04, // 34 0x00, 0x09, 0x09, 0x06, // 35 @@ -1766,4 +1766,4 @@ const uint8_t ArialMT_Plain_24_RU[] PROGMEM = { 0x3F, // 255 }; -#endif // OLED_RU \ No newline at end of file +#endif // OLED_RU diff --git a/src/graphics/fonts/OLEDDisplayFontsUA.cpp b/src/graphics/fonts/OLEDDisplayFontsUA.cpp index 8bc56ea94..deafa77aa 100644 --- a/src/graphics/fonts/OLEDDisplayFontsUA.cpp +++ b/src/graphics/fonts/OLEDDisplayFontsUA.cpp @@ -9,7 +9,7 @@ const uint8_t ArialMT_Plain_10_UA[] PROGMEM = { 0x20, // First char: 32 0xE0, // Number of chars: 224 // Jump Table: - 0xFF, 0xFF, 0x00, 0x0A, // 32 + 0xFF, 0xFF, 0x00, 0x03, // 32 0x00, 0x00, 0x04, 0x03, // 33 0x00, 0x04, 0x05, 0x04, // 34 0x00, 0x09, 0x09, 0x06, // 35 @@ -1924,4 +1924,4 @@ const uint8_t ArialMT_Plain_24_UA[] PROGMEM = { 0xFF, // 1103 }; -#endif // OLED_UA \ No newline at end of file +#endif // OLED_UA From abfa34663016842b868639e3ac84e92a3fcf6f1d Mon Sep 17 00:00:00 2001 From: Austin Lane Date: Mon, 23 Mar 2026 10:27:21 -0400 Subject: [PATCH 30/50] Cleanup GH Actions --- .github/actions/setup-base/action.yml | 2 -- .github/workflows/build_debian_src.yml | 5 ++--- .github/workflows/build_firmware.yml | 2 -- .github/workflows/docker_build.yml | 2 -- .github/workflows/docker_manifest.yml | 2 -- .github/workflows/hook_copr.yml | 2 -- .github/workflows/main_matrix.yml | 16 +++++----------- .github/workflows/package_pio_deps.yml | 2 -- .github/workflows/package_ppa.yml | 2 -- .github/workflows/test_native.yml | 7 ------- debian/ci_pack_sdeb.sh | 9 +++++++-- 11 files changed, 14 insertions(+), 37 deletions(-) diff --git a/.github/actions/setup-base/action.yml b/.github/actions/setup-base/action.yml index 80f5c6855..8e461998a 100644 --- a/.github/actions/setup-base/action.yml +++ b/.github/actions/setup-base/action.yml @@ -8,8 +8,6 @@ runs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Install dependencies shell: bash diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index b3744493b..381806b6c 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -28,8 +28,6 @@ jobs: with: submodules: recursive path: meshtasticd - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Install deps shell: bash @@ -42,6 +40,7 @@ jobs: sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control - name: Import GPG key + if: github.event_name != 'pull_request' && github.event_name != 'pull_request_target' uses: crazy-max/ghaction-import-gpg@v7 with: gpg_private_key: ${{ secrets.PPA_GPG_PRIVATE_KEY }} @@ -60,7 +59,7 @@ jobs: run: debian/ci_pack_sdeb.sh env: SERIES: ${{ inputs.series }} - GPG_KEY_ID: ${{ steps.gpg.outputs.keyid }} + GPG_KEY_ID: ${{ steps.gpg.outputs.keyid || '' }} PKG_VERSION: ${{ steps.version.outputs.deb }} - name: Store binaries as an artifact diff --git a/.github/workflows/build_firmware.yml b/.github/workflows/build_firmware.yml index c4c7a54e0..470104688 100644 --- a/.github/workflows/build_firmware.yml +++ b/.github/workflows/build_firmware.yml @@ -26,8 +26,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Build ${{ inputs.platform }} id: build diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 826117586..54c353b80 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -50,8 +50,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Get release version string run: | diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml index 0f209201a..eeaacd7bd 100644 --- a/.github/workflows/docker_manifest.yml +++ b/.github/workflows/docker_manifest.yml @@ -86,8 +86,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Get release version string run: | diff --git a/.github/workflows/hook_copr.yml b/.github/workflows/hook_copr.yml index eb4ebc57b..c51c05543 100644 --- a/.github/workflows/hook_copr.yml +++ b/.github/workflows/hook_copr.yml @@ -22,8 +22,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{ github.ref }} - repository: ${{ github.repository }} - name: Trigger COPR build uses: vidplace7/copr-build@main diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 39da22ae0..7467bf808 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -15,8 +15,7 @@ on: - "**.md" - version.properties - # Note: This is different from "pull_request". Need to specify ref when doing checkouts. - pull_request_target: + pull_request: branches: - master - develop @@ -88,8 +87,6 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Check ${{ matrix.check.board }} uses: meshtastic/gh-action-firmware@main with: @@ -173,9 +170,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - uses: actions/download-artifact@v8 with: @@ -245,7 +239,7 @@ jobs: needs: [build] steps: - uses: actions/checkout@v6 - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' with: filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head) fetch-depth: 0 @@ -263,21 +257,21 @@ jobs: overwrite: true path: manifests-new/*.mt.json - name: Find the merge base - if: github.event_name == 'pull_request_target' + if: github.event_name == 'pull_request' run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV env: base: ${{ github.base_ref }} head: ${{ github.sha }} # Currently broken (for-loop through EVERY artifact -- rate limiting) # - name: Download the old manifests - # if: github.event_name == 'pull_request_target' + # if: github.event_name == 'pull_request' # run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/ # env: # GH_TOKEN: ${{ github.token }} # merge_base: ${{ env.MERGE_BASE }} # repo: ${{ github.repository }} # - name: Do scan and post comment - # if: github.event_name == 'pull_request_target' + # if: github.event_name == 'pull_request' # run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/ release-artifacts: diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml index 8fe675cb1..d646f74f0 100644 --- a/.github/workflows/package_pio_deps.yml +++ b/.github/workflows/package_pio_deps.yml @@ -27,8 +27,6 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Setup Python uses: actions/setup-python@v6 diff --git a/.github/workflows/package_ppa.yml b/.github/workflows/package_ppa.yml index 86e655809..2fb814997 100644 --- a/.github/workflows/package_ppa.yml +++ b/.github/workflows/package_ppa.yml @@ -36,8 +36,6 @@ jobs: with: submodules: recursive path: meshtasticd - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Install deps shell: bash diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 9d1b475a0..d0179bba8 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -16,8 +16,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} submodules: recursive - name: Setup native build @@ -72,8 +70,6 @@ jobs: steps: - uses: actions/checkout@v6 with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} submodules: recursive - name: Setup native build @@ -128,9 +124,6 @@ jobs: if: always() steps: - uses: actions/checkout@v6 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - name: Get release version string run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT diff --git a/debian/ci_pack_sdeb.sh b/debian/ci_pack_sdeb.sh index ad5289f40..7b2418ff6 100755 --- a/debian/ci_pack_sdeb.sh +++ b/debian/ci_pack_sdeb.sh @@ -27,5 +27,10 @@ rm -rf debian/changelog dch --create --distribution "$SERIES" --package "$package" --newversion "$PKG_VERSION~$SERIES" \ "GitHub Actions Automatic packaging for $PKG_VERSION~$SERIES" -# Build the source deb -debuild -S -nc -k"$GPG_KEY_ID" +if [[ -n $GPG_KEY_ID ]]; then + # Build and sign the source deb + debuild -S -nc -k"$GPG_KEY_ID" +else + # Build the source deb without signing (forks) + debuild -S -nc +fi From 8ce1a872eb7e0cfb210153c04dd56b7e68874ac6 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 23 Mar 2026 21:15:56 -0400 Subject: [PATCH 31/50] Add timeout to PPA uploads (#9989) Don't allow dput to run for more than 15 minutes (successful runs take about ~8 minutes) --- .github/workflows/daily_packaging.yml | 2 +- .github/workflows/package_ppa.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/daily_packaging.yml b/.github/workflows/daily_packaging.yml index 7df688055..a7d2468e1 100644 --- a/.github/workflows/daily_packaging.yml +++ b/.github/workflows/daily_packaging.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: push: branches: - - master + - develop # Default branch, same as 'cron' above paths: - debian/** - "*.rpkg" diff --git a/.github/workflows/package_ppa.yml b/.github/workflows/package_ppa.yml index 2fb814997..a16bd9575 100644 --- a/.github/workflows/package_ppa.yml +++ b/.github/workflows/package_ppa.yml @@ -68,5 +68,6 @@ jobs: - name: Publish with dput if: ${{ github.event_name != 'pull_request_target' && github.event_name != 'pull_request' }} + timeout-minutes: 15 # dput is terrible, sometimes runs 'forever' run: | dput ${{ inputs.ppa_repo }} meshtasticd_${{ steps.version.outputs.deb }}~${{ inputs.series }}_source.changes From e14b8d385a39075973e127bb0f68190dc9ee8fbb Mon Sep 17 00:00:00 2001 From: Austin Lane Date: Mon, 23 Mar 2026 18:54:55 -0400 Subject: [PATCH 32/50] Remove unneeded GH perms Reduce perms to least-necessary Remove merge_queue.yml since it's never been used and is now stale Remove comment-artifact, it hasn't worked in ages. --- .github/workflows/build_debian_src.yml | 3 +- .github/workflows/build_one_target.yml | 2 +- .github/workflows/daily_packaging.yml | 2 +- .github/workflows/docker_build.yml | 2 +- .github/workflows/docker_manifest.yml | 2 +- .github/workflows/hook_copr.yml | 3 +- .github/workflows/main_matrix.yml | 23 +- .github/workflows/merge_queue.yml | 371 ------------------------- .github/workflows/package_obs.yml | 3 +- .github/workflows/package_pio_deps.yml | 3 +- .github/workflows/package_ppa.yml | 3 +- .github/workflows/update_protobufs.yml | 2 +- 12 files changed, 23 insertions(+), 396 deletions(-) delete mode 100644 .github/workflows/merge_queue.yml diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index 381806b6c..d1bcd8898 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -16,8 +16,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-debian-src: diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml index 0a1744edb..706b9cfe7 100644 --- a/.github/workflows/build_one_target.yml +++ b/.github/workflows/build_one_target.yml @@ -87,7 +87,7 @@ jobs: gather-artifacts: permissions: - contents: write + contents: read pull-requests: write runs-on: ubuntu-latest needs: [version, build] diff --git a/.github/workflows/daily_packaging.yml b/.github/workflows/daily_packaging.yml index a7d2468e1..16363f562 100644 --- a/.github/workflows/daily_packaging.yml +++ b/.github/workflows/daily_packaging.yml @@ -16,7 +16,7 @@ on: - .github/workflows/hook_copr.yml permissions: - contents: write + contents: read packages: write jobs: diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 54c353b80..72987c01e 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -37,7 +37,7 @@ on: value: ${{ jobs.docker-build.outputs.digest }} permissions: - contents: write + contents: read packages: write jobs: diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml index eeaacd7bd..b2fd12599 100644 --- a/.github/workflows/docker_manifest.yml +++ b/.github/workflows/docker_manifest.yml @@ -12,7 +12,7 @@ on: type: string permissions: - contents: write + contents: read packages: write jobs: diff --git a/.github/workflows/hook_copr.yml b/.github/workflows/hook_copr.yml index c51c05543..c419848a8 100644 --- a/.github/workflows/hook_copr.yml +++ b/.github/workflows/hook_copr.yml @@ -11,8 +11,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-copr-hook: diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 7467bf808..1221c171f 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -28,6 +28,8 @@ on: workflow_dispatch: +permissions: read-all + jobs: setup: strategy: @@ -123,9 +125,16 @@ jobs: test-native: if: ${{ !contains(github.ref_name, 'event/') && github.repository == 'meshtastic/firmware' }} + permissions: # Needed for dorny/test-reporter. + contents: read + actions: read + checks: write uses: ./.github/workflows/test_native.yml docker: + permissions: # Needed for pushing to GHCR. + contents: read + packages: write strategy: fail-fast: false matrix: @@ -150,9 +159,6 @@ jobs: gather-artifacts: # trunk-ignore(checkov/CKV2_GHA_1) if: github.repository == 'meshtastic/firmware' - permissions: - contents: write - pull-requests: write strategy: fail-fast: false matrix: @@ -225,13 +231,6 @@ jobs: path: ./*.elf retention-days: 30 - - uses: scruplelesswizard/comment-artifact@main - if: ${{ github.event_name == 'pull_request' }} - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation" - github-token: ${{ secrets.GITHUB_TOKEN }} - shame: if: github.repository == 'meshtastic/firmware' continue-on-error: true @@ -275,6 +274,8 @@ jobs: # run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/ release-artifacts: + permissions: # Needed for 'gh release upload'. + contents: write runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }} outputs: @@ -366,6 +367,8 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} release-firmware: + permissions: # Needed for 'gh release upload'. + contents: write strategy: fail-fast: false matrix: diff --git a/.github/workflows/merge_queue.yml b/.github/workflows/merge_queue.yml deleted file mode 100644 index ad8534984..000000000 --- a/.github/workflows/merge_queue.yml +++ /dev/null @@ -1,371 +0,0 @@ -name: Merge Queue -# Not sure how concurrency works in merge_queue, removing for now. -# concurrency: -# group: merge-queue-${{ github.head_ref || github.run_id }} -# cancel-in-progress: true -on: - # Merge group is a special trigger that is used to trigger the workflow when a merge group is created. - merge_group: - -jobs: - setup: - strategy: - fail-fast: true - matrix: - arch: - - all - - check - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: 3.x - cache: pip - - run: pip install -U platformio - - name: Generate matrix - id: jsonStep - run: | - if [[ "$GITHUB_HEAD_REF" == "" ]]; then - TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}}) - else - TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr) - fi - echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF" - echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT - outputs: - all: ${{ steps.jsonStep.outputs.all }} - check: ${{ steps.jsonStep.outputs.check }} - - version: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Get release version string - run: | - echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT - echo "deb=$(./bin/buildinfo.py deb)" >> $GITHUB_OUTPUT - id: version - env: - BUILD_LOCATION: local - outputs: - long: ${{ steps.version.outputs.long }} - deb: ${{ steps.version.outputs.deb }} - - check: - needs: setup - strategy: - fail-fast: true - matrix: - check: ${{ fromJson(needs.setup.outputs.check) }} - - runs-on: ubuntu-latest - if: ${{ github.event_name != 'workflow_dispatch' }} - steps: - - uses: actions/checkout@v6 - - name: Build base - id: base - uses: ./.github/actions/setup-base - - name: Check ${{ matrix.check.board }} - run: bin/check-all.sh ${{ matrix.check.board }} - - build: - needs: [setup, version] - strategy: - matrix: - build: ${{ fromJson(needs.setup.outputs.all) }} - uses: ./.github/workflows/build_firmware.yml - with: - version: ${{ needs.version.outputs.long }} - pio_env: ${{ matrix.build.board }} - platform: ${{ matrix.build.platform }} - - build-debian-src: - if: github.repository == 'meshtastic/firmware' - uses: ./.github/workflows/build_debian_src.yml - with: - series: UNRELEASED - build_location: local - secrets: inherit - - package-pio-deps-native-tft: - if: ${{ github.event_name == 'workflow_dispatch' }} - uses: ./.github/workflows/package_pio_deps.yml - with: - pio_env: native-tft - secrets: inherit - - test-native: - if: ${{ !contains(github.ref_name, 'event/') }} - uses: ./.github/workflows/test_native.yml - - docker: - strategy: - fail-fast: false - matrix: - distro: [debian, alpine] - platform: [linux/amd64, linux/arm64, linux/arm/v7] - pio_env: [native, native-tft] - exclude: - - distro: alpine - platform: linux/arm/v7 - - pio_env: native-tft - platform: linux/arm64 - - pio_env: native-tft - platform: linux/arm/v7 - uses: ./.github/workflows/docker_build.yml - with: - distro: ${{ matrix.distro }} - platform: ${{ matrix.platform }} - runs-on: ${{ contains(matrix.platform, 'arm') && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} - pio_env: ${{ matrix.pio_env }} - push: false - - gather-artifacts: - # trunk-ignore(checkov/CKV2_GHA_1) - permissions: - contents: write - pull-requests: write - strategy: - fail-fast: false - matrix: - arch: - - esp32 - - esp32s3 - - esp32c3 - - esp32c6 - - nrf52840 - - rp2040 - - rp2350 - - stm32 - runs-on: ubuntu-latest - needs: [version, build] - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - - uses: actions/download-artifact@v8 - with: - path: ./ - pattern: firmware-${{matrix.arch}}-* - merge-multiple: true - - - name: Display structure of downloaded files - run: ls -R - - - name: Move files up - run: mv -b -t ./ ./bin/device-*.sh ./bin/device-*.bat - - - name: Repackage in single firmware zip - uses: actions/upload-artifact@v7 - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - overwrite: true - path: | - ./firmware-*.bin - ./firmware-*.uf2 - ./firmware-*.hex - ./firmware-*.zip - ./device-*.sh - ./device-*.bat - ./littlefs-*.bin - ./bleota*bin - ./Meshtastic_nRF52_factory_erase*.uf2 - retention-days: 30 - - - uses: actions/download-artifact@v8 - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./output - - # For diagnostics - - name: Show artifacts - run: ls -lR - - - name: Device scripts permissions - run: | - chmod +x ./output/device-install.sh || true - chmod +x ./output/device-update.sh || true - - - name: Zip firmware - run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output - - - name: Repackage in single elfs zip - uses: actions/upload-artifact@v7 - with: - name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }} - overwrite: true - path: ./*.elf - retention-days: 30 - - - uses: scruplelesswizard/comment-artifact@main - if: ${{ github.event_name == 'pull_request' }} - with: - name: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - description: "Download firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip. This artifact will be available for 90 days from creation" - github-token: ${{ secrets.GITHUB_TOKEN }} - - release-artifacts: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' }} - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - needs: - - version - - gather-artifacts - - build-debian-src - - package-pio-deps-native-tft - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Create release - uses: softprops/action-gh-release@v2 - id: create_release - with: - draft: true - prerelease: true - name: Meshtastic Firmware ${{ needs.version.outputs.long }} Alpha - tag_name: v${{ needs.version.outputs.long }} - body: | - Autogenerated by github action, developer should edit as required before publishing... - - - name: Download source deb - uses: actions/download-artifact@v8 - with: - pattern: firmware-debian-${{ needs.version.outputs.deb }}~UNRELEASED-src - merge-multiple: true - path: ./output/debian-src - - - name: Download `native-tft` pio deps - uses: actions/download-artifact@v8 - with: - pattern: platformio-deps-native-tft-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./output/pio-deps-native-tft - - - name: Zip Linux sources - working-directory: output - run: | - zip -j -9 -r ./meshtasticd-${{ needs.version.outputs.deb }}-src.zip ./debian-src - zip -9 -r ./platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip ./pio-deps-native-tft - - # For diagnostics - - name: Display structure of downloaded files - run: ls -lR - - - name: Add Linux sources to GtiHub Release - # Only run when targeting master branch with workflow_dispatch - if: ${{ github.ref_name == 'master' }} - run: | - gh release upload v${{ needs.version.outputs.long }} ./output/meshtasticd-${{ needs.version.outputs.deb }}-src.zip - gh release upload v${{ needs.version.outputs.long }} ./output/platformio-deps-native-tft-${{ needs.version.outputs.long }}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - release-firmware: - strategy: - fail-fast: false - matrix: - arch: - - esp32 - - esp32s3 - - esp32c3 - - esp32c6 - - nrf52840 - - rp2040 - - rp2350 - - stm32 - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' }} - needs: [release-artifacts, version] - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: 3.x - - - uses: actions/download-artifact@v8 - with: - pattern: firmware-${{matrix.arch}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./output - - - name: Display structure of downloaded files - run: ls -lR - - - name: Device scripts permissions - run: | - chmod +x ./output/device-install.sh || true - chmod +x ./output/device-update.sh || true - - - name: Zip firmware - run: zip -j -9 -r ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./output - - - uses: actions/download-artifact@v8 - with: - name: debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./elfs - - - name: Zip debug elfs - run: zip -j -9 -r ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip ./elfs - - # For diagnostics - - name: Display structure of downloaded files - run: ls -lR - - - name: Add bins and debug elfs to GitHub Release - # Only run when targeting master branch with workflow_dispatch - if: ${{ github.ref_name == 'master' }} - run: | - gh release upload v${{ needs.version.outputs.long }} ./firmware-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip - gh release upload v${{ needs.version.outputs.long }} ./debug-elfs-${{matrix.arch}}-${{ needs.version.outputs.long }}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-firmware: - runs-on: ubuntu-24.04 - if: ${{ github.event_name == 'workflow_dispatch' }} - needs: [release-firmware, version] - env: - targets: |- - esp32,esp32s3,esp32c3,esp32c6,nrf52840,rp2040,rp2350,stm32 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: 3.x - - - uses: actions/download-artifact@v8 - with: - pattern: firmware-{${{ env.targets }}}-${{ needs.version.outputs.long }} - merge-multiple: true - path: ./publish - - - name: Publish firmware to meshtastic.github.io - uses: peaceiris/actions-gh-pages@v4 - env: - # On event/* branches, use the event name as the destination prefix - DEST_PREFIX: ${{ contains(github.ref_name, 'event/') && format('{0}/', github.ref_name) || '' }} - with: - deploy_key: ${{ secrets.DIST_PAGES_DEPLOY_KEY }} - external_repository: meshtastic/meshtastic.github.io - publish_branch: master - publish_dir: ./publish - destination_dir: ${{ env.DEST_PREFIX }}firmware-${{ needs.version.outputs.long }} - keep_files: true - user_name: github-actions[bot] - user_email: github-actions[bot]@users.noreply.github.com - commit_message: ${{ needs.version.outputs.long }} - enable_jekyll: true diff --git a/.github/workflows/package_obs.yml b/.github/workflows/package_obs.yml index 395b721a5..b491f0062 100644 --- a/.github/workflows/package_obs.yml +++ b/.github/workflows/package_obs.yml @@ -18,8 +18,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-debian-src: diff --git a/.github/workflows/package_pio_deps.yml b/.github/workflows/package_pio_deps.yml index d646f74f0..6bd256f52 100644 --- a/.github/workflows/package_pio_deps.yml +++ b/.github/workflows/package_pio_deps.yml @@ -16,8 +16,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: pkg-pio-libdeps: diff --git a/.github/workflows/package_ppa.yml b/.github/workflows/package_ppa.yml index a16bd9575..334a7016d 100644 --- a/.github/workflows/package_ppa.yml +++ b/.github/workflows/package_ppa.yml @@ -16,8 +16,7 @@ on: type: string permissions: - contents: write - packages: write + contents: read jobs: build-debian-src: diff --git a/.github/workflows/update_protobufs.yml b/.github/workflows/update_protobufs.yml index 35565d1e4..e9380467e 100644 --- a/.github/workflows/update_protobufs.yml +++ b/.github/workflows/update_protobufs.yml @@ -6,7 +6,7 @@ permissions: read-all jobs: update-protobufs: runs-on: ubuntu-latest - permissions: + permissions: # Needed for peter-evans/create-pull-request. contents: write pull-requests: write steps: From d3a86841b9c95c2bfabcb41567bbe1067465c175 Mon Sep 17 00:00:00 2001 From: Jason P Date: Wed, 25 Mar 2026 14:46:24 -0500 Subject: [PATCH 33/50] Update External Notifications with a full redo of logic gates (#10006) * Update External Notifications with a full redo of pathways * Correct comments. * Fix TWatch S3 and use HAS_DRV2605 --- src/modules/ExternalNotificationModule.cpp | 113 +++++++++++---------- 1 file changed, 58 insertions(+), 55 deletions(-) diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index cc7124f0e..3addc4b3a 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -350,12 +350,6 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP { // Trigger external notification if enabled and not muted; isSilenced is from temporary mute toggles if (moduleConfig.external_notification.enabled && !isSilenced) { -#ifdef T_WATCH_S3 - drv.setWaveform(0, 75); - drv.setWaveform(1, 56); - drv.setWaveform(2, 0); - drv.go(); -#endif if (!isFromUs(&mp)) { // Check if the message contains a bell character. Don't do this loop for every pin, just once. auto &p = mp.decoded; @@ -380,60 +374,69 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP const bool buzzerModeIsDirectOnly = (config.device.buzzer_mode == meshtastic_Config_DeviceConfig_BuzzerMode_DIRECT_MSG_ONLY); - if (containsBell || !is_muted) { - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_message || - moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_message_vibra || - ((moduleConfig.external_notification.alert_bell_buzzer || - moduleConfig.external_notification.alert_message_buzzer) && - canBuzz())) { - nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout - ? (moduleConfig.external_notification.nag_timeout * 1000) - : moduleConfig.external_notification.output_ms); - LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); - isNagging = true; - } + // Each output evaluates its own alert condition independently: + // alert_bell_* fires only when a bell character is present. + // alert_message_* fires on any non-muted message. - if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_message) { - LOG_INFO("externalNotificationModule - Notification Module or Bell"); - setExternalState(0, true); - } + // Alert when receiving a bell = alertBell: true + // Alert when receiving a message = alertMessage: true + const bool genericShouldAlert = (moduleConfig.external_notification.alert_bell && containsBell) || + (moduleConfig.external_notification.alert_message && !is_muted); - if (moduleConfig.external_notification.alert_bell_vibra || - moduleConfig.external_notification.alert_message_vibra) { - LOG_INFO("externalNotificationModule - Notification Module or Bell (Vibra)"); - setExternalState(1, true); - } + // Alert GPIO Vibra when receiving a bell = alertBellVibra: true + // Alert GPIO Vibra when receiving a message = alertMessageVibra: true + const bool vibraShouldAlert = (moduleConfig.external_notification.alert_bell_vibra && containsBell) || + (moduleConfig.external_notification.alert_message_vibra && !is_muted); - if ((moduleConfig.external_notification.alert_bell_buzzer || - moduleConfig.external_notification.alert_message_buzzer) && - canBuzz()) { - LOG_INFO("externalNotificationModule - Notification Module or Bell (Buzzer)"); - if (buzzerModeIsDirectOnly && !isDmToUs && !containsBell) { - LOG_INFO("Message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); - } else { - // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us -#ifdef T_LORA_PAGER - drv.setWaveform(0, 16); // Long buzzer 100% - drv.setWaveform(1, 0); // Pause - drv.setWaveform(2, 16); - drv.setWaveform(3, 0); - drv.setWaveform(4, 16); - drv.setWaveform(5, 0); - drv.setWaveform(6, 16); - drv.setWaveform(7, 0); - drv.go(); + // Alert GPIO Buzzer when receiving a bell = alertBellBuzzer: true + // Alert GPIO Buzzer when receiving a message = alertMessageBuzzer: true + const bool buzzerShouldAlert = canBuzz() && ((moduleConfig.external_notification.alert_bell_buzzer && containsBell) || + (moduleConfig.external_notification.alert_message_buzzer && !is_muted)); + + if (genericShouldAlert || vibraShouldAlert || buzzerShouldAlert) { + nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout + ? (moduleConfig.external_notification.nag_timeout * 1000) + : moduleConfig.external_notification.output_ms); + LOG_INFO("Toggling nagCycleCutoff to %lu", nagCycleCutoff); + isNagging = true; + } + + if (genericShouldAlert) { + LOG_INFO("externalNotificationModule - Generic alert"); + setExternalState(0, true); + } + + if (vibraShouldAlert) { + LOG_INFO("externalNotificationModule - Vibra alert"); + setExternalState(1, true); + } + + if (buzzerShouldAlert) { + LOG_INFO("externalNotificationModule - Buzzer alert"); + if (buzzerModeIsDirectOnly && !isDmToUs && !containsBell) { + LOG_INFO("Message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY"); + } else { + // Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us +#ifdef HAS_DRV2605 + drv.setWaveform(0, 16); // Long buzzer 100% + drv.setWaveform(1, 0); // Pause + drv.setWaveform(2, 16); + drv.setWaveform(3, 0); + drv.setWaveform(4, 16); + drv.setWaveform(5, 0); + drv.setWaveform(6, 16); + drv.setWaveform(7, 0); + drv.go(); #endif + + if (moduleConfig.external_notification.use_i2s_as_buzzer) { #ifdef HAS_I2S - if (moduleConfig.external_notification.use_i2s_as_buzzer) { - audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); - } else + audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone)); #endif - if (moduleConfig.external_notification.use_pwm) { - rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); - } else { - setExternalState(2, true); - } + } else if (moduleConfig.external_notification.use_pwm) { + rtttl::begin(config.device.buzzer_gpio, rtttlConfig.ringtone); + } else { + setExternalState(2, true); } } } @@ -513,4 +516,4 @@ int ExternalNotificationModule::handleInputEvent(const InputEvent *event) return 1; } return 0; -} \ No newline at end of file +} From 33e7f16c05f4c6b76bc403f2ed6a07dcf9e712f1 Mon Sep 17 00:00:00 2001 From: Chloe Bethel Date: Fri, 27 Mar 2026 11:52:00 +0000 Subject: [PATCH 34/50] Supporting STM32WL is like squeezing blood from a stone (#10015) --- variants/stm32/stm32.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 7a3b37642..d2c155398 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -27,7 +27,7 @@ build_flags = -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. -DHAS_SCREEN=0 ; Always disable screen for STM32, it is not supported. - -DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; This is REQUIRED for at least traceroute debug prints - without it the length ends up uninitialized. + ;-DPIO_FRAMEWORK_ARDUINO_NANOLIB_FLOAT_PRINTF ; Enable this if enabling debugg logging. It is REQUIRED for at least traceroute debug prints - without it the length returned by printf ends up uninitialized. -DDEBUG_MUTE ; You can #undef DEBUG_MUTE in certain source files if you need the logs. -fmerge-all-constants -ffunction-sections From c36ae159ed3cd046a3d301e71b9eecde91ffaca6 Mon Sep 17 00:00:00 2001 From: "Ethac.chen" Date: Fri, 27 Mar 2026 19:56:19 +0800 Subject: [PATCH 35/50] =?UTF-8?q?Fix=20rak=5Fwismeshtag=20low=E2=80=91volt?= =?UTF-8?q?age=20reboot=20hang=20after=20App=20configuration=20(#9897)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix TAG low‑voltage reboot hang after App configuration * nRF52: Move low-VDD System OFF logic to variant hook * Addressed review * serialize SAADC access with shared mutex for VDD and battery reads * raise LPCOMP wake threshold to ensure rising-edge wake * Trunk fmt --------- Co-authored-by: Ben Meadors --- src/Power.cpp | 8 ++++ src/platform/nrf52/Nrf52SaadcLock.cpp | 13 +++++++ src/platform/nrf52/Nrf52SaadcLock.h | 12 ++++++ src/platform/nrf52/main-nrf52.cpp | 26 ++++++++++--- variants/nrf52840/rak_wismeshtag/variant.cpp | 40 ++++++++++++++++++++ variants/nrf52840/rak_wismeshtag/variant.h | 36 +++++++++++++++++- 6 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 src/platform/nrf52/Nrf52SaadcLock.cpp create mode 100644 src/platform/nrf52/Nrf52SaadcLock.h diff --git a/src/Power.cpp b/src/Power.cpp index ea4fcf42a..d82c870ed 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -35,6 +35,11 @@ #include "nrfx_power.h" #endif +#if defined(ARCH_NRF52) +#include "Nrf52SaadcLock.h" +#include "concurrency/LockGuard.h" +#endif + #if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" #include "target_specific.h" @@ -328,6 +333,9 @@ class AnalogBatteryLevel : public HasBatteryLevel scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs); scaled *= operativeAdcMultiplier; #else // block for all other platforms +#ifdef ARCH_NRF52 + concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock); +#endif for (uint32_t i = 0; i < BATTERY_SENSE_SAMPLES; i++) { raw += analogRead(BATTERY_PIN); } diff --git a/src/platform/nrf52/Nrf52SaadcLock.cpp b/src/platform/nrf52/Nrf52SaadcLock.cpp new file mode 100644 index 000000000..21f4f4dfd --- /dev/null +++ b/src/platform/nrf52/Nrf52SaadcLock.cpp @@ -0,0 +1,13 @@ +#include "Nrf52SaadcLock.h" +#include "concurrency/Lock.h" +#include "configuration.h" + +#ifdef ARCH_NRF52 + +namespace concurrency +{ +static Lock nrf52SaadcLockInstance; +Lock *nrf52SaadcLock = &nrf52SaadcLockInstance; +} // namespace concurrency + +#endif diff --git a/src/platform/nrf52/Nrf52SaadcLock.h b/src/platform/nrf52/Nrf52SaadcLock.h new file mode 100644 index 000000000..77024eea3 --- /dev/null +++ b/src/platform/nrf52/Nrf52SaadcLock.h @@ -0,0 +1,12 @@ +#pragma once + +#ifdef ARCH_NRF52 + +namespace concurrency +{ +class Lock; +/** Shared mutex for SAADC configuration and reads (VDD + battery analog path). */ +extern Lock *nrf52SaadcLock; +} // namespace concurrency + +#endif diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index c705521ad..73780b6eb 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -25,6 +25,8 @@ #include "power.h" #include +#include "Nrf52SaadcLock.h" +#include "concurrency/LockGuard.h" #include #ifdef BQ25703A_ADDR @@ -51,6 +53,10 @@ uint16_t getVDDVoltage(); void variant_shutdown() __attribute__((weak)); void variant_shutdown() {} +// Optional variant hook called each nrf52Loop(); e.g. for low-VDD System OFF. +void variant_nrf52LoopHook(void) __attribute__((weak)); +void variant_nrf52LoopHook(void) {} + static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(0); static nrfx_wdt_channel_id nrfx_wdt_channel_id_nrf52_main; @@ -74,11 +80,18 @@ bool powerHAL_isVBUSConnected() bool powerHAL_isPowerLevelSafe() { - static bool powerLevelSafe = true; - uint16_t threshold = SAFE_VDD_VOLTAGE_THRESHOLD * 1000; // convert V to mV - uint16_t hysteresis = SAFE_VDD_VOLTAGE_THRESHOLD_HYST * 1000; +#ifdef SAFE_VDD_VOLTAGE_THRESHOLD_MV + uint16_t threshold = SAFE_VDD_VOLTAGE_THRESHOLD_MV; +#else + uint16_t threshold = (uint16_t)(SAFE_VDD_VOLTAGE_THRESHOLD * 1000.0f + 0.5f); // convert V to mV +#endif +#ifdef SAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV + uint16_t hysteresis = SAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV; +#else + uint16_t hysteresis = (uint16_t)(SAFE_VDD_VOLTAGE_THRESHOLD_HYST * 1000.0f + 0.5f); +#endif if (powerLevelSafe) { if (getVDDVoltage() < threshold) { @@ -125,11 +138,12 @@ void powerHAL_platformInit() // get VDD voltage (in millivolts) uint16_t getVDDVoltage() { - // we use the same values as regular battery read so there is no conflict on SAADC + concurrency::LockGuard guard(concurrency::nrf52SaadcLock); + + // Match battery read resolution; SAADC is shared with AnalogBatteryLevel in Power.cpp. analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); // VDD range on NRF52840 is 1.8-3.3V so we need to remap analog reference to 3.6V - // let's hope battery reading runs in same task and we don't have race condition analogReference(AR_INTERNAL); uint16_t vddADCRead = analogReadVDD(); @@ -326,6 +340,8 @@ void nrf52Loop() checkSDEvents(); reportLittleFSCorruptionOnce(); + + variant_nrf52LoopHook(); // Optional variant hook called each nrf52Loop(); } #ifdef USE_SEMIHOSTING diff --git a/variants/nrf52840/rak_wismeshtag/variant.cpp b/variants/nrf52840/rak_wismeshtag/variant.cpp index a035fbaf0..a0394b2dd 100644 --- a/variants/nrf52840/rak_wismeshtag/variant.cpp +++ b/variants/nrf52840/rak_wismeshtag/variant.cpp @@ -19,7 +19,11 @@ */ #include "variant.h" +#include "Arduino.h" +#include "FreeRTOS.h" #include "nrf.h" +#include "power/PowerHAL.h" +#include "sleep.h" #include "wiring_constants.h" #include "wiring_digital.h" @@ -40,3 +44,39 @@ void initVariant() pinMode(PIN_3V3_EN, OUTPUT); digitalWrite(PIN_3V3_EN, HIGH); } + +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +void variant_nrf52LoopHook(void) +{ + // If VDD stays unsafe for a while (brownout), force System OFF. + // Skip when VBUS present to allow recovery while USB-powered. + if (!powerHAL_isVBUSConnected()) { + // Rate-limit VDD safety checks: powerHAL_isPowerLevelSafe() calls getVDDVoltage() each time. + static constexpr uint32_t POWER_LEVEL_CHECK_INTERVAL_MS = 100; + static uint32_t last_vdd_check_ms = 0; + static bool last_power_level_safe = true; + + const uint32_t now = millis(); + if (last_vdd_check_ms == 0 || (uint32_t)(now - last_vdd_check_ms) >= POWER_LEVEL_CHECK_INTERVAL_MS) { + last_vdd_check_ms = now; + last_power_level_safe = powerHAL_isPowerLevelSafe(); + } + + // Do not use millis()==0 as a sentinel: at boot, millis() may be 0 while VDD is unsafe. + static bool low_vdd_timer_armed = false; + static uint32_t low_vdd_since_ms = 0; + + if (!last_power_level_safe) { + if (!low_vdd_timer_armed) { + low_vdd_since_ms = now; + low_vdd_timer_armed = true; + } + if ((uint32_t)(now - low_vdd_since_ms) >= (uint32_t)LOW_VDD_SYSTEMOFF_DELAY_MS) { + cpuDeepSleep(portMAX_DELAY); + } + } else { + low_vdd_timer_armed = false; + } + } +} +#endif diff --git a/variants/nrf52840/rak_wismeshtag/variant.h b/variants/nrf52840/rak_wismeshtag/variant.h index 5b20e4d93..9ea215e42 100644 --- a/variants/nrf52840/rak_wismeshtag/variant.h +++ b/variants/nrf52840/rak_wismeshtag/variant.h @@ -225,7 +225,41 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG #define AREF_VOLTAGE 3.0 #define VBAT_AR_INTERNAL AR_INTERNAL_3_0 #define ADC_MULTIPLIER 1.73 -#define OCV_ARRAY 4240, 4112, 4029, 3970, 3906, 3846, 3824, 3802, 3776, 3650, 3072 +#define OCV_ARRAY 4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990 // updated OCV array for rak_wismeshtag + +// Wake from System OFF when battery rises again (LPCOMP). +// BAT_ADC divider: R22=1M (top), R24=1.5M (bottom) => V_BAT_ADC = VBAT * (1.5 / (1.0 + 1.5)) = 0.6 * VBAT +// RAK4630 module: AIN0 = nrf52840 AIN3 = Pin 5 (A0/BATTERY_PIN) +#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_3 +// LPCOMP compares the selected input to a fraction of VDD (here 5/8 of VDD at the LPCOMP input). +// With VDD ≈ 3.3 V: threshold at input ≈ (5/8) * 3.3 V ≈ 2.06 V. +// BAT_ADC divider: V_BAT_ADC = 0.6 * VBAT → equivalent VBAT ≈ 2.06 / 0.6 ≈ 3.4 V (wake when battery recovers). +// +// Note: if VDD is drooping/tracking VBAT in the low-voltage region, using a fraction >= divider ratio helps ensure the +// input is below the threshold at shutdown; the intended wake event happens when the supply recovers enough for a rising +// crossing to occur. +#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_5_8 + +// Low voltage protection: +// If VDD is below SAFE_VDD_VOLTAGE_THRESHOLD for longer than this delay (and no USB VBUS), +// the device will enter System OFF to avoid brownout loops and flash corruption. +#ifndef LOW_VDD_SYSTEMOFF_DELAY_MS +#define LOW_VDD_SYSTEMOFF_DELAY_MS 5000 +#endif + +// Prefer integer mV so platform code avoids float→int truncation quirks (e.g. 0.1 V → 99 vs 100 mV). +#ifndef SAFE_VDD_VOLTAGE_THRESHOLD_MV +#define SAFE_VDD_VOLTAGE_THRESHOLD_MV 2900 +#endif +#ifndef SAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV +#define SAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV 100 +#endif +#ifndef SAFE_VDD_VOLTAGE_THRESHOLD +#define SAFE_VDD_VOLTAGE_THRESHOLD (SAFE_VDD_VOLTAGE_THRESHOLD_MV / 1000.0f) +#endif +#ifndef SAFE_VDD_VOLTAGE_THRESHOLD_HYST +#define SAFE_VDD_VOLTAGE_THRESHOLD_HYST (SAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV / 1000.0f) +#endif #define RAK_4631 1 From 0b7556b5904974a3bee88a42299bd4cf44726c55 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:39:26 +0100 Subject: [PATCH 36/50] MUI: WiFi map tile download: heltec V4 adaptations (#10011) * rotated MUI * mui-maps heltec-v4 adaptations --------- Co-authored-by: Ben Meadors --- platformio.ini | 2 +- src/graphics/tftSetup.cpp | 6 +++++- variants/esp32s3/heltec_v4/platformio.ini | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/platformio.ini b/platformio.ini index 2fcfc480d..9b32c61de 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/f36d2a953524e372b78c5b4147ec55f38716964e.zip + https://github.com/meshtastic/device-ui/archive/03fbf26f5d6095f2c7c77ee2d064af01669ac38c.zip ; Common libs for environmental measurements in telemetry module [environmental_base] diff --git a/src/graphics/tftSetup.cpp b/src/graphics/tftSetup.cpp index 5654fa02a..708cd8967 100644 --- a/src/graphics/tftSetup.cpp +++ b/src/graphics/tftSetup.cpp @@ -16,6 +16,10 @@ DeviceScreen *deviceScreen = nullptr; +#ifndef TFT_TASK_STACK_SIZE +#define TFT_TASK_STACK_SIZE 16384 +#endif + #ifdef ARCH_ESP32 // Get notified when the system is entering light sleep CallbackObserver tftSleepObserver = @@ -127,7 +131,7 @@ void tftSetup(void) #ifdef ARCH_ESP32 tftSleepObserver.observe(¬ifyLightSleep); endSleepObserver.observe(¬ifyLightSleepEnd); - xTaskCreatePinnedToCore(tft_task_handler, "tft", 10240, NULL, 1, NULL, 0); + xTaskCreatePinnedToCore(tft_task_handler, "tft", TFT_TASK_STACK_SIZE, NULL, 1, NULL, 0); #elif defined(ARCH_PORTDUINO) std::thread *tft_task = new std::thread([] { tft_task_handler(); }); #endif diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 9acf30c21..cb795c65d 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -68,7 +68,10 @@ build_flags = -D INPUTDRIVER_BUTTON_TYPE=0 -D HAS_SCREEN=1 -D HAS_TFT=1 - -D RAM_SIZE=1860 + -D MAP_TILES_GREY ; required for 2MB PSRAM + -D RAM_SIZE=1432 + -D STBI_ARENA_SIZE=450000 + -D LV_CACHE_DEF_SIZE=0 -D LV_LVGL_H_INCLUDE_SIMPLE -D LV_CONF_INCLUDE_SIMPLE -D LV_COMP_CONF_INCLUDE_SIMPLE From 5319bc7c2cc2eaa8f04af98f32cd9d4d46b64548 Mon Sep 17 00:00:00 2001 From: Philip Lykov Date: Mon, 30 Mar 2026 15:12:23 +0300 Subject: [PATCH 37/50] Fix W5100S socket exhaustion blocking MQTT and additional TCP clients (#9770) The W5100S Ethernet chip has only 4 hardware sockets. On RAK4631 Ethernet gateways with syslog and NTP enabled, all 4 sockets were permanently consumed (NTP UDP + Syslog UDP + TCP API listener + TCP API client), leaving none for MQTT, DHCP lease renewal, or additional TCP connections. - NTP: Remove permanent timeClient.begin() at startup; NTPClient::update() auto-initializes when needed. Add timeClient.end() after each query to release the UDP socket immediately. - Syslog: Remove socket allocation from Syslog::enable(). Open and close the UDP socket on-demand in _sendLog() around each message send. - MQTT: Fix socket leak in isValidConfig() where a successful test connection was never closed (PubSubClient destructor does not call disconnect). Add explicit pubSub->disconnect() before returning. Made-with: Cursor Co-authored-by: Ben Meadors --- src/DebugConfiguration.cpp | 12 ++++++++++-- src/mesh/eth/ethClient.cpp | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/DebugConfiguration.cpp b/src/DebugConfiguration.cpp index 08c7abc04..207feb8c0 100644 --- a/src/DebugConfiguration.cpp +++ b/src/DebugConfiguration.cpp @@ -98,7 +98,6 @@ Syslog &Syslog::logMask(uint8_t priMask) void Syslog::enable() { - this->_client->begin(this->_port); this->_enabled = true; } @@ -166,14 +165,21 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess if ((pri & LOG_FACMASK) == 0) pri = LOG_MAKEPRI(LOG_FAC(this->_priDefault), pri); + // W5100S: acquire UDP socket on-demand to avoid permanent socket consumption + if (!this->_client->begin(this->_port)) { + return false; + } + if (this->_server != NULL) { result = this->_client->beginPacket(this->_server, this->_port); } else { result = this->_client->beginPacket(this->_ip, this->_port); } - if (result != 1) + if (result != 1) { + this->_client->stop(); return false; + } this->_client->print('<'); this->_client->print(pri); @@ -193,6 +199,8 @@ inline bool Syslog::_sendLog(uint16_t pri, const char *appName, const char *mess this->_client->print(message); this->_client->endPacket(); + this->_client->stop(); // W5100S: release UDP socket for other services + return true; } diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 80741810a..440f7b76a 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -102,7 +102,6 @@ static int32_t reconnectETH() #ifndef DISABLE_NTP LOG_INFO("Start NTP time client"); - timeClient.begin(); timeClient.setUpdateInterval(60 * 60); // Update once an hour #endif @@ -159,6 +158,7 @@ static int32_t reconnectETH() LOG_ERROR("NTP Update failed"); ntp_renew = millis() + 300 * 1000; // failure, retry every 5 minutes } + timeClient.end(); // W5100S: release UDP socket for other services } #endif From 283ccb83d1fd53e1daca9fcbe1a4b37cc637a342 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:03:10 +0100 Subject: [PATCH 38/50] Configure NFC pins as GPIO for older bootloaders (#10016) * Configure NFC pins as GPIO for older bootloaders Should hopefully solve the issue in https://github.com/meshtastic/firmware/issues/9986 * Fix formatting of CONFIG_NFCT_PINS_AS_GPIOS flag in platformio.ini --------- Co-authored-by: Ben Meadors --- variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini b/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini index 3f9a4f7af..7018d054e 100644 --- a/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini +++ b/variants/nrf52840/seeed_xiao_nrf52840_kit/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${nrf52840_base.build_flags} -I src/platform/nrf52/softdevice/nrf52 -DSEEED_XIAO_NRF52840_KIT -DSEEED_XIAO_NRF_KIT_DEFAULT + -DCONFIG_NFCT_PINS_AS_GPIOS=1 board_build.ldscript = src/platform/nrf52/nrf52840_s140_v7.ld build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/seeed_xiao_nrf52840_kit> debug_tool = jlink @@ -31,4 +32,4 @@ board_level = extra build_flags = ${env:seeed_xiao_nrf52840_kit.build_flags} -DSEEED_XIAO_NRF52840_KIT -DSEEED_XIAO_NRF_KIT_I2C ; Define I2C variant - -USEEED_XIAO_NRF_KIT_DEFAULT ; Remove default define \ No newline at end of file + -USEEED_XIAO_NRF_KIT_DEFAULT ; Remove default define From f88bc732ccc7f852986179b91939322b89ef8e09 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:53:59 +0100 Subject: [PATCH 39/50] Improved manual build flow to make it easier (#8839) * Improved flow to make easier The emojis are intentional! I had minimal LLM input!!! * try and fix input variable sanitisation * and again * again * Copilot fixed it for me * copilot didn't fix it for me * copilot might have fixed it and I broke it by copypasting --------- Co-authored-by: Ben Meadors --- .github/workflows/build_one_target.yml | 64 ++++++++++++++++---------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml index 706b9cfe7..806df4e7a 100644 --- a/.github/workflows/build_one_target.yml +++ b/.github/workflows/build_one_target.yml @@ -4,9 +4,14 @@ on: workflow_dispatch: inputs: # trunk-ignore(checkov/CKV_GHA_7) + target: + type: string + required: false + description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets. arch: type: choice options: + - all - esp32 - esp32s3 - esp32c3 @@ -15,32 +20,18 @@ on: - rp2040 - rp2350 - stm32 - target: - type: string - required: false - description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets. - # find-target: - # type: boolean - # default: true - # description: 'Find the available targets' + description: Choose an arch to limit the search, or 'all' to search all architectures. + default: all permissions: read-all jobs: find-targets: - if: ${{ inputs.target == '' }} strategy: fail-fast: false matrix: arch: - - esp32 - - esp32s3 - - esp32c3 - - esp32c6 - - nrf52840 - - rp2040 - - rp2350 - - stm32 + - all runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 @@ -51,14 +42,37 @@ jobs: - run: pip install -U platformio - name: Generate matrix id: jsonStep + env: + BUILDTARGET: ${{ inputs.target }} + MATRIXARCH: ${{ inputs.arch }} run: | TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra) - echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY - echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY - echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY - echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY - echo "Targets:" >> $GITHUB_STEP_SUMMARY - echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY + if [ "$BUILDTARGET" = "" ]; then + echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY + echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY + echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY + echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY + echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY + echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY + echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY + echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY + else + echo "We build this one:" >> $GITHUB_STEP_SUMMARY + ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform') + echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY + echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY + echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "$ARCH" == "" ]]; then + echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY + else + echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY + fi + echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY + echo "arch=$ARCH" >> $GITHUB_OUTPUT + fi + outputs: + arch: ${{ steps.jsonStep.outputs.arch }} version: if: ${{ inputs.target != '' }} @@ -78,12 +92,12 @@ jobs: build: if: ${{ inputs.target != '' && inputs.arch != 'native' }} - needs: [version] + needs: [version, find-targets] uses: ./.github/workflows/build_firmware.yml with: version: ${{ needs.version.outputs.long }} pio_env: ${{ inputs.target }} - platform: ${{ inputs.arch }} + platform: ${{ needs.find-targets.outputs.arch }} gather-artifacts: permissions: From efd2613bd762df4c21bfceaa56d13f51aefab6cd Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:46:27 +0100 Subject: [PATCH 40/50] feat: add new configuration files for LR11xx variants (#9761) * feat: add new configuration files for LR11xx variants * style: reformat RF switch mode table for improved readability --- .../femtofox/femtofox_E80-900M2213S.yaml | 30 ++++++++++++ .../femtofox/femtofox_LR1121 generic.yaml | 46 +++++++++++++++++++ .../femtofox/femtofox_WIO-LR1121.yaml | 30 ++++++++++++ .../diy/nrf52_promicro_diy_tcxo/rfswitch.h | 16 +++++-- 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 bin/config.d/femtofox/femtofox_E80-900M2213S.yaml create mode 100644 bin/config.d/femtofox/femtofox_LR1121 generic.yaml create mode 100644 bin/config.d/femtofox/femtofox_WIO-LR1121.yaml diff --git a/bin/config.d/femtofox/femtofox_E80-900M2213S.yaml b/bin/config.d/femtofox/femtofox_E80-900M2213S.yaml new file mode 100644 index 000000000..2f2b24603 --- /dev/null +++ b/bin/config.d/femtofox/femtofox_E80-900M2213S.yaml @@ -0,0 +1,30 @@ +--- +Lora: +## Ebyte E80-900M22S +## This is a bit experimental +## +## + Module: lr1121 + gpiochip: 1 # subtract 32 from the gpio numbers + DIO3_TCXO_VOLTAGE: 1.8 + CS: 16 #pin6 / GPIO48 1C0 + IRQ: 23 #pin17 / GPIO55 1C7 + Busy: 22 #pin16 / GPIO54 1C6 + Reset: 25 #pin13 / GPIO57 1D1 + + + spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19) + spiSpeed: 2000000 + +rfswitch_table: + pins: [DIO5, DIO6, DIO7] + MODE_STBY: [LOW, LOW, LOW] + MODE_RX: [LOW, HIGH, LOW] + MODE_TX: [HIGH, HIGH, LOW] + MODE_TX_HP: [HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH] + MODE_WIFI: [LOW, LOW, LOW] + +General: + MACAddressSource: eth0 diff --git a/bin/config.d/femtofox/femtofox_LR1121 generic.yaml b/bin/config.d/femtofox/femtofox_LR1121 generic.yaml new file mode 100644 index 000000000..c66eebed5 --- /dev/null +++ b/bin/config.d/femtofox/femtofox_LR1121 generic.yaml @@ -0,0 +1,46 @@ +--- +Lora: +## Ebyte E80-900M22S +## This is a bit experimental +## +## + Module: lr1121 + gpiochip: 1 # subtract 32 from the gpio numbers + DIO3_TCXO_VOLTAGE: 1.8 + CS: 16 #pin6 / GPIO48 1C0 + IRQ: 23 #pin17 / GPIO55 1C7 + Busy: 22 #pin16 / GPIO54 1C6 + Reset: 25 #pin13 / GPIO57 1D1 + + + spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19) + spiSpeed: 2000000 + +rfswitch_table: + pins: + - DIO5 + - DIO6 + MODE_STBY: + - LOW + - LOW + MODE_RX: + - HIGH + - LOW + MODE_TX: + - HIGH + - HIGH + MODE_TX_HP: + - LOW + - HIGH + MODE_TX_HF: + - LOW + - LOW + MODE_GNSS: + - LOW + - LOW + MODE_WIFI: + - LOW + - LOW + +General: + MACAddressSource: eth0 diff --git a/bin/config.d/femtofox/femtofox_WIO-LR1121.yaml b/bin/config.d/femtofox/femtofox_WIO-LR1121.yaml new file mode 100644 index 000000000..c2ab76d46 --- /dev/null +++ b/bin/config.d/femtofox/femtofox_WIO-LR1121.yaml @@ -0,0 +1,30 @@ +--- +Lora: +## Ebyte E80-900M22S +## This is a bit experimental +## +## + Module: lr1121 + gpiochip: 1 # subtract 32 from the gpio numbers + DIO3_TCXO_VOLTAGE: 1.8 + CS: 16 #pin6 / GPIO48 1C0 + IRQ: 23 #pin17 / GPIO55 1C7 + Busy: 22 #pin16 / GPIO54 1C6 + Reset: 25 #pin13 / GPIO57 1D1 + + + spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19) + spiSpeed: 2000000 + +rfswitch_table: + pins: [DIO5, DIO6, DIO7] + MODE_STBY: [LOW, LOW, LOW] + MODE_RX: [LOW, LOW, LOW] + MODE_TX: [LOW, HIGH, LOW] + MODE_TX_HP: [HIGH, LOW, LOW] + # MODE_TX_HF: [] + # MODE_GNSS: [] + MODE_WIFI: [LOW, LOW, LOW] + +General: + MACAddressSource: eth0 diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h index 71508c037..ac7ef57c4 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h @@ -12,9 +12,15 @@ static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11 RADIOLIB_NC}; static const Module::RfSwitchMode_t rfswitch_table[] = { - // mode DIO5 DIO6 DIO7 - {LR11x0::MODE_STBY, {LOW, LOW, LOW}}, {LR11x0::MODE_RX, {LOW, HIGH, LOW}}, - {LR11x0::MODE_TX, {HIGH, HIGH, LOW}}, {LR11x0::MODE_TX_HP, {HIGH, LOW, LOW}}, - {LR11x0::MODE_TX_HF, {LOW, LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW, HIGH}}, - {LR11x0::MODE_WIFI, {LOW, LOW, LOW}}, END_OF_MODE_TABLE, + // clang-format off + // mode DIO5 DIO6 DIO7 + {LR11x0::MODE_STBY, {LOW, LOW, LOW}}, + {LR11x0::MODE_RX, {LOW, HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH, LOW}}, + {LR11x0::MODE_TX_HP, {HIGH, LOW, LOW}}, + {LR11x0::MODE_TX_HF, {LOW, LOW, LOW}}, + {LR11x0::MODE_GNSS, {LOW, LOW, HIGH}}, + {LR11x0::MODE_WIFI, {LOW, LOW, LOW}}, + END_OF_MODE_TABLE, + // clang-format on }; From e7ee4bea18f0b885d813704d72aa976f66e42635 Mon Sep 17 00:00:00 2001 From: notmasteryet <146979+notmasteryet@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:57:19 -0500 Subject: [PATCH 41/50] Fix TFTDisplay::display to align pixels at 32-bit boundary (#9956) * Fix TFTDisplay partial update: align spans to 32-bit boundary for GDMA The device, such as esp32c6, require 32-bit alignment for the data. The patch aligns start and end of the update pixels buffer. * Update src/graphics/TFTDisplay.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Meadors --- src/graphics/TFTDisplay.cpp | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 005ead292..4c8272955 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -1254,14 +1254,14 @@ 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; - // Quickly write out the first changed pixel (saves another array lookup) - linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack; - x_LastPixelUpdate = x_FirstPixelUpdate; - - // Step 3: copy all remaining pixels in this row into the pixel line buffer, - // while also recording the last pixel in the row that needs updating - for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) { + // 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; @@ -1274,6 +1274,14 @@ void TFTDisplay::display(bool fromBlank) x_LastPixelUpdate = x; } } + // 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. + if (x_LastPixelUpdate >= displayWidth) { + x_LastPixelUpdate = displayWidth - 1; + } #if defined(HACKADAY_COMMUNICATOR) tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate], (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1); From 726d539174cddf4fe8e3842787251ad0e136412a Mon Sep 17 00:00:00 2001 From: Patrickschell609 Date: Fri, 3 Apr 2026 07:07:59 -0400 Subject: [PATCH 42/50] fix: prevent division by zero in wind sensor averaging (#10059) SerialModule's weather station parser divides by velCount and dirCount to compute wind speed/direction averages. Both counters are only incremented when their respective sensor readings arrive, but the division runs whenever gotwind is true (set by EITHER reading) and the averaging interval has elapsed. If only WindDir arrives without WindSpeed (or vice versa), or if the timer fires before any readings accumulate, the division produces undefined behavior (floating-point divide by zero on embedded = NaN or hardware fault depending on platform). Fix: add velCount > 0 && dirCount > 0 guard to the averaging block. Co-authored-by: Patrickschell609 Co-authored-by: Claude Opus 4.6 Co-authored-by: Ben Meadors --- src/modules/SerialModule.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 7a969343e..20d4d7d8c 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -651,7 +651,7 @@ void SerialModule::processWXSerial() LOG_INFO("WS8X : %i %.1fg%.1f %.1fv %.1fv %.1fC rain: %.1f, %i sum", atoi(windDir), strtof(windVel, nullptr), strtof(windGust, nullptr), batVoltageF, capVoltageF, temperatureF, rain, rainSum); } - if (gotwind && !Throttle::isWithinTimespanMs(lastAveraged, averageIntervalMillis)) { + if (gotwind && !Throttle::isWithinTimespanMs(lastAveraged, averageIntervalMillis) && velCount > 0 && dirCount > 0) { // calculate averages and send to the mesh float velAvg = 1.0 * velSum / velCount; From 2e6519bb985e3a684ab755ca6279084e95973e23 Mon Sep 17 00:00:00 2001 From: Chloe Bethel Date: Fri, 3 Apr 2026 14:50:39 +0100 Subject: [PATCH 43/50] Add a hardfault handler so it's more obvious when STM32 crashes. (#10071) * Add hardfault handler so it's more obvious when STM32 crashes. * thanks copilot --- src/platform/stm32wl/hardfault_handler.s | 11 +++ src/platform/stm32wl/main-stm32wl.cpp | 98 +++++++++++++++++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/platform/stm32wl/hardfault_handler.s diff --git a/src/platform/stm32wl/hardfault_handler.s b/src/platform/stm32wl/hardfault_handler.s new file mode 100644 index 000000000..ab76096fa --- /dev/null +++ b/src/platform/stm32wl/hardfault_handler.s @@ -0,0 +1,11 @@ +.globl HardFault_Handler +.syntax unified +.thumb + +.type HardFault_Handler, %function +HardFault_Handler: +tst lr, #4 +ite eq +mrseq r0, msp +mrsne r0, psp +b HardFault_Handler_C \ No newline at end of file diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index e841f8f29..93080f840 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,5 +1,6 @@ #include "RTC.h" #include "configuration.h" +#include #include #include @@ -53,4 +54,99 @@ extern "C" void __wrap__tzset_unlocked_r(struct _reent *reent_ptr) { return; } -#endif \ No newline at end of file +#endif + +// Taken from https://interrupt.memfault.com/blog/cortex-m-hardfault-debug +typedef struct __attribute__((packed)) ContextStateFrame { + uint32_t r0; + uint32_t r1; + uint32_t r2; + uint32_t r3; + uint32_t r12; + uint32_t lr; + uint32_t return_address; + uint32_t xpsr; +} sContextStateFrame; + +// NOTE: If you are using CMSIS, the registers can also be +// accessed through CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk +#define HALT_IF_DEBUGGING() \ + do { \ + if ((*(volatile uint32_t *)0xE000EDF0) & (1 << 0)) { \ + __asm("bkpt 1"); \ + } \ + } while (0) + +static char hardfault_message_buffer[256]; + +// printf directly using srcwrapper's debug UART function. +static void debug_printf(const char *format, ...) +{ + va_list args; + va_start(args, format); + int length = vsnprintf(hardfault_message_buffer, sizeof(hardfault_message_buffer), format, args); + va_end(args); + + if (length < 0) + return; + uart_debug_write((uint8_t *)hardfault_message_buffer, min((unsigned int)length, sizeof(hardfault_message_buffer) - 1)); +} + +// N picked by guessing +#define DOT_TIME 1200000 +static void dot() +{ + digitalWrite(LED_POWER, LED_STATE_ON); + for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ + } + digitalWrite(LED_POWER, LED_STATE_OFF); + for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ + } +} + +static void dash() +{ + digitalWrite(LED_POWER, LED_STATE_ON); + for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ + } + digitalWrite(LED_POWER, LED_STATE_OFF); + for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ + } +} + +static void space() +{ + for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ + } +} + +// Disable optimizations for this function so "frame" argument +// does not get optimized away +extern "C" __attribute__((optimize("O0"))) void HardFault_Handler_C(sContextStateFrame *frame) +{ + debug_printf("HardFault!\r\n"); + debug_printf("r0: %08x\r\n", frame->r0); + debug_printf("r1: %08x\r\n", frame->r1); + debug_printf("r2: %08x\r\n", frame->r2); + debug_printf("r3: %08x\r\n", frame->r3); + debug_printf("r12: %08x\r\n", frame->r12); + debug_printf("lr: %08x\r\n", frame->lr); + debug_printf("pc[return address]: %08x\r\n", frame->return_address); + debug_printf("xpsr: %08x\r\n", frame->xpsr); + + HALT_IF_DEBUGGING(); + + // blink SOS forever + while (1) { + dot(); + dot(); + dot(); + dash(); + dash(); + dash(); + dot(); + dot(); + dot(); + space(); + } +} \ No newline at end of file From 222d3e6b62248a092f7df66c9be0c8a4a04d7fb8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 3 Apr 2026 16:27:39 -0500 Subject: [PATCH 44/50] Fix zero CR and add unit tests for applyModemConfig coding rate behavior (#10070) * Fix zero CR and add unit tests for applyModemConfig coding rate behavior * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: initialize region configuration in setUp for RadioInterface tests --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/mesh/RadioInterface.cpp | 2 +- test/test_radio/test_main.cpp | 95 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 9ce944002..0bbc39d41 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -961,7 +961,7 @@ void RadioInterface::applyModemConfig() cr = loraConfig.coding_rate; LOG_INFO("Using custom Coding Rate %u", cr); } else { - cr = loraConfig.coding_rate; + cr = newcr; } } else { // if not using preset, then just use the custom settings diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 49eaabe5b..a7d3d32d2 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -14,6 +14,23 @@ class MockMeshService : public MeshService static MockMeshService *mockMeshService; +// Test shim to expose protected radio parameters set by applyModemConfig() +class TestableRadioInterface : public RadioInterface +{ + public: + TestableRadioInterface() : RadioInterface() {} + uint8_t getCr() const { return cr; } + uint8_t getSf() const { return sf; } + float getBw() const { return bw; } + + // Override reconfigure to call the base which invokes applyModemConfig() + bool reconfigure() override { return RadioInterface::reconfigure(); } + + // Stubs for pure virtual methods required by RadioInterface + uint32_t getPacketTime(uint32_t, bool) override { return 0; } + ErrorCode send(meshtastic_MeshPacket *p) override { return ERRNO_OK; } +}; + static void test_bwCodeToKHz_specialMappings() { TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31)); @@ -100,13 +117,87 @@ static void test_clampConfigLora_validPresetUnchanged() TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); } +// ----------------------------------------------------------------------- +// applyModemConfig() coding rate tests (via reconfigure) +// ----------------------------------------------------------------------- + +static TestableRadioInterface *testRadio; + +// After fresh flash: coding_rate=0, use_preset=true, modem_preset=LONG_FAST +// CR should come from the preset (5 for LONG_FAST), not from the zero default. +static void test_applyModemConfig_freshFlashCodingRateNotZero() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + // coding_rate is 0 (default after init_zero, same as fresh flash) + + testRadio->reconfigure(); + + // LONG_FAST preset has cr=5; must never be 0 + TEST_ASSERT_EQUAL_UINT8(5, testRadio->getCr()); + TEST_ASSERT_EQUAL_UINT8(11, testRadio->getSf()); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 250.0f, testRadio->getBw()); +} + +// When coding_rate matches the preset exactly, should still use the preset value +static void test_applyModemConfig_codingRateMatchesPreset() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + config.lora.coding_rate = 8; // LONG_SLOW default is cr=8 + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr()); +} + +// Custom CR higher than preset should be used +static void test_applyModemConfig_customCodingRateHigherThanPreset() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + config.lora.coding_rate = 7; // LONG_FAST preset has cr=5, 7 > 5 + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(7, testRadio->getCr()); +} + +// Custom CR lower than preset: preset wins (higher is more robust) +static void test_applyModemConfig_customCodingRateLowerThanPreset() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + config.lora.coding_rate = 5; // LONG_SLOW preset has cr=8, 5 < 8 + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr()); +} + void setUp(void) { mockMeshService = new MockMeshService(); service = mockMeshService; + + // RadioInterface computes slotTimeMsec during construction and expects myRegion to be valid. + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + testRadio = new TestableRadioInterface(); } void tearDown(void) { + delete testRadio; + testRadio = nullptr; service = nullptr; delete mockMeshService; mockMeshService = nullptr; @@ -128,6 +219,10 @@ void setup() RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion); RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); RUN_TEST(test_clampConfigLora_validPresetUnchanged); + RUN_TEST(test_applyModemConfig_freshFlashCodingRateNotZero); + RUN_TEST(test_applyModemConfig_codingRateMatchesPreset); + RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset); + RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset); exit(UNITY_END()); } From 71c8143f72b033d362471acdecf0738bc59641ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:58:23 +1100 Subject: [PATCH 45/50] Update protobufs (#10074) Co-authored-by: fifieldt <1287116+fifieldt@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/protobufs b/protobufs index cb1f89372..349c1d5c1 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0 +Subproject commit 349c1d5c1e3ab716a65d7dab1597923b4542796d diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 477c3b31b..fc6931d73 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -308,6 +308,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_TDISPLAY_S3_PRO = 126, /* Heltec Mesh Node T096 board features an nRF52840 CPU and a TFT screen. */ meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 = 127, + /* Seeed studio T1000-E Pro tracker card. NRF52840 w/ LR2021 radio, GPS, button, buzzer, and sensors. */ + meshtastic_HardwareModel_TRACKER_T1000_E_PRO = 128, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ From 2f19a1d7a421c67c2674e73732a7019f13156953 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Sat, 4 Apr 2026 13:51:15 +0200 Subject: [PATCH 46/50] Consolidate SHTs into one class (#9859) * Consolidate SHTs into one class * Remove separate SHT imports * Create one single SHTXX sensor type * Let the SHTXXSensor class handle variant detection * Minor logging improvements * Add functions to set accuracy on SHT3X and SHT4X * Fix variable init in constructor * Add bus to SHT sensor init * Update src/modules/Telemetry/Sensor/SHTXXSensor.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/modules/Telemetry/Sensor/SHTXXSensor.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix accuracy conditions on SHTXX Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Merge upstream * Add SHT2X detection on 0x40 * Read second part of SHT2X serial number --------- Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- platformio.ini | 8 +- src/configuration.h | 6 +- src/detect/ScanI2C.h | 6 +- src/detect/ScanI2CTwoWire.cpp | 70 +++++++-- .../Telemetry/EnvironmentTelemetry.cpp | 44 ++---- src/modules/Telemetry/Sensor/SHT31Sensor.cpp | 31 ---- src/modules/Telemetry/Sensor/SHT31Sensor.h | 20 --- src/modules/Telemetry/Sensor/SHT4XSensor.cpp | 48 ------ src/modules/Telemetry/Sensor/SHT4XSensor.h | 20 --- src/modules/Telemetry/Sensor/SHTC3Sensor.cpp | 35 ----- src/modules/Telemetry/Sensor/SHTC3Sensor.h | 20 --- src/modules/Telemetry/Sensor/SHTXXSensor.cpp | 145 ++++++++++++++++++ src/modules/Telemetry/Sensor/SHTXXSensor.h | 29 ++++ 13 files changed, 254 insertions(+), 228 deletions(-) delete mode 100644 src/modules/Telemetry/Sensor/SHT31Sensor.cpp delete mode 100644 src/modules/Telemetry/Sensor/SHT31Sensor.h delete mode 100644 src/modules/Telemetry/Sensor/SHT4XSensor.cpp delete mode 100644 src/modules/Telemetry/Sensor/SHT4XSensor.h delete mode 100644 src/modules/Telemetry/Sensor/SHTC3Sensor.cpp delete mode 100644 src/modules/Telemetry/Sensor/SHTC3Sensor.h create mode 100644 src/modules/Telemetry/Sensor/SHTXXSensor.cpp create mode 100644 src/modules/Telemetry/Sensor/SHTXXSensor.h diff --git a/platformio.ini b/platformio.ini index 673966494..06cc6e583 100644 --- a/platformio.ini +++ b/platformio.ini @@ -195,16 +195,10 @@ lib_deps = adafruit/Adafruit BMP3XX Library@2.1.6 # renovate: datasource=custom.pio depName=Adafruit MAX1704X packageName=adafruit/library/Adafruit MAX1704X adafruit/Adafruit MAX1704X@1.0.3 - # renovate: datasource=custom.pio depName=Adafruit SHTC3 packageName=adafruit/library/Adafruit SHTC3 Library - adafruit/Adafruit SHTC3 Library@1.0.2 # renovate: datasource=custom.pio depName=Adafruit LPS2X packageName=adafruit/library/Adafruit LPS2X adafruit/Adafruit LPS2X@2.0.6 - # renovate: datasource=custom.pio depName=Adafruit SHT31 packageName=adafruit/library/Adafruit SHT31 Library - adafruit/Adafruit SHT31 Library@2.2.2 # renovate: datasource=custom.pio depName=Adafruit VEML7700 packageName=adafruit/library/Adafruit VEML7700 Library adafruit/Adafruit VEML7700 Library@2.1.6 - # renovate: datasource=custom.pio depName=Adafruit SHT4x packageName=adafruit/library/Adafruit SHT4x Library - adafruit/Adafruit SHT4x Library@1.0.5 # renovate: datasource=custom.pio depName=SparkFun Qwiic Scale NAU7802 packageName=sparkfun/library/SparkFun Qwiic Scale NAU7802 Arduino Library sparkfun/SparkFun Qwiic Scale NAU7802 Arduino Library@1.0.6 # renovate: datasource=custom.pio depName=ClosedCube OPT3001 packageName=closedcube/library/ClosedCube OPT3001 @@ -219,6 +213,8 @@ lib_deps = sensirion/Sensirion I2C SFA3x@1.0.0 # renovate: datasource=custom.pio depName=Sensirion I2C SCD30 packageName=sensirion/library/Sensirion I2C SCD30 sensirion/Sensirion I2C SCD30@1.0.0 + # renovate: datasource=custom.pio depName=arduino-sht packageName=sensirion/library/arduino-sht + sensirion/arduino-sht@1.2.6 ; Environmental sensors with BSEC2 (Bosch proprietary IAQ) [environmental_extra] diff --git a/src/configuration.h b/src/configuration.h index 11be86007..84dabee4e 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -231,7 +231,7 @@ along with this program. If not, see . #define BME_ADDR 0x76 #define BME_ADDR_ALTERNATE 0x77 #define MCP9808_ADDR 0x18 -#define INA_ADDR 0x40 +#define INA_ADDR 0x40 // same as SHT2X #define INA_ADDR_ALTERNATE 0x41 #define INA_ADDR_WAVESHARE_UPS 0x43 #define INA3221_ADDR 0x42 @@ -244,8 +244,8 @@ along with this program. If not, see . #define LPS22HB_ADDR 0x5C #define LPS22HB_ADDR_ALT 0x5D #define SFA30_ADDR 0x5D -#define SHT31_4x_ADDR 0x44 -#define SHT31_4x_ADDR_ALT 0x45 +#define SHTXX_ADDR 0x44 +#define SHTXX_ADDR_ALT 0x45 #define PMSA003I_ADDR 0x12 #define QMA6100P_ADDR 0x12 #define AHT10_ADDR 0x38 diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index cc83a8d7b..d451d3948 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -31,9 +31,6 @@ class ScanI2C INA3221, MAX17048, MCP9808, - SHT31, - SHT4X, - SHTC3, LPS22HB, QMC6310U, QMC6310N, @@ -94,7 +91,8 @@ class ScanI2C SFA30, CW2015, SCD30, - ADS1115 + ADS1115, + SHTXX } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 2e00c11ce..052b2245a 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -136,7 +136,9 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1 return match; } -/// for SEN5X detection +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR +// FIXME Move to a separate file for detection of sensors that require more complex interactions? +// For SEN5X detection // Note, this code needs to be called before setting the I2C bus speed // for the screen at high speed. The speed needs to be at 100kHz, otherwise // detection will not work @@ -174,6 +176,46 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address) return String(productName); } +#endif + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR +bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address) +{ + + i2cBus->beginTransmission(address); + i2cBus->write(0xFA); + i2cBus->write(0x0F); + + if (i2cBus->endTransmission() != 0) + return false; + + if (i2cBus->requestFrom(address, (uint8_t)8) != 8) + return false; + + // Just flush the data + while (i2cBus->available() < 8) { + i2cBus->read(); + } + + i2cBus->beginTransmission(address); + i2cBus->write(0xFC); + i2cBus->write(0xC9); + + if (i2cBus->endTransmission() != 0) + return false; + + if (i2cBus->requestFrom(address, (uint8_t)6) != 6) + return false; + + // Just flush the data + while (i2cBus->available() < 6) { + i2cBus->read(); + } + + // Assume we detect the SHT21 if something came back from the request + return true; +} +#endif #define SCAN_SIMPLE_CASE(ADDR, T, ...) \ case ADDR: \ @@ -371,7 +413,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; #endif #if !defined(M5STACK_UNITC6L) - case INA_ADDR: + case INA_ADDR: // Same as SHT2X case INA_ADDR_ALTERNATE: case INA_ADDR_WAVESHARE_UPS: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2); @@ -387,7 +429,12 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("INA260", (uint8_t)addr.address); type = INA260; } - } else { // Assume INA219 if INA260 ID is not found +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + } else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) { + logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address); + type = SHTXX; +#endif + } else { // Assume INA219 if none of the above ones are found logFoundDevice("INA219", (uint8_t)addr.address); type = INA219; } @@ -448,22 +495,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } break; } - case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT - case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR + case SHTXX_ADDR: // same as OPT3001_ADDR_ALT + case SHTXX_ADDR_ALT: // same as OPT3001_ADDR if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) { type = OPT3001; logFoundDevice("OPT3001", (uint8_t)addr.address); - } else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC) - type = SHT4X; - logFoundDevice("SHT4X", (uint8_t)addr.address); - } else { - type = SHT31; - logFoundDevice("SHT31", (uint8_t)addr.address); + } else { // SHTXX + type = SHTXX; + logFoundDevice("SHTXX", (uint8_t)addr.address); } break; - SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address) + SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address) case RCWL9620_ADDR: // get MAX30102 PARTID registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1); @@ -675,6 +719,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("BMX160", (uint8_t)addr.address); break; } else { +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR String prod = ""; prod = readSEN5xProductName(i2cBus, addr.address); if (prod.startsWith("SEN55")) { @@ -690,6 +735,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("Sensirion SEN50", addr.address); break; } +#endif if (addr.address == BMX160_ADDR) { type = BMX160; logFoundDevice("BMX160", (uint8_t)addr.address); diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index b7b6e04a9..684d408a1 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -66,18 +66,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/MCP9808Sensor.h" #endif -#if __has_include() -#include "Sensor/SHT31Sensor.h" -#endif - #if __has_include() #include "Sensor/LPS22HBSensor.h" #endif -#if __has_include() -#include "Sensor/SHTC3Sensor.h" -#endif - #if __has_include("RAK12035_SoilMoisture.h") && defined(RAK_4631) && RAK_4631 == 1 #include "Sensor/RAK12035Sensor.h" #endif @@ -94,8 +86,8 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/OPT3001Sensor.h" #endif -#if __has_include() -#include "Sensor/SHT4XSensor.h" +#if __has_include() +#include "Sensor/SHTXXSensor.h" #endif #if __has_include() @@ -155,6 +147,15 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) } LOG_INFO("Environment Telemetry adding I2C devices..."); + /* + Uncomment the preferences below if you want to use the module + without having to configure it from the PythonAPI or WebUI. + */ + + // moduleConfig.telemetry.environment_measurement_enabled = 1; + // moduleConfig.telemetry.environment_screen_enabled = 1; + // moduleConfig.telemetry.environment_update_interval = 15; + // order by priority of metrics/values (low top, high bottom) #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR @@ -202,15 +203,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::MCP9808); #endif -#if __has_include() - addSensor(i2cScanner, ScanI2C::DeviceType::SHT31); -#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::LPS22HB); #endif -#if __has_include() - addSensor(i2cScanner, ScanI2C::DeviceType::SHTC3); -#endif #if __has_include("RAK12035_SoilMoisture.h") && defined(RAK_4631) && RAK_4631 == 1 addSensor(i2cScanner, ScanI2C::DeviceType::RAK12035); #endif @@ -223,13 +218,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::OPT3001); #endif -#if __has_include() - addSensor(i2cScanner, ScanI2C::DeviceType::SHT4X); -#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::MLX90632); #endif - #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BMP_3XX); #endif @@ -245,7 +236,10 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BH1750); #endif - +#if __has_include() + // TODO Can we scan for multiple sensors connected on the same bus? + addSensor(i2cScanner, ScanI2C::DeviceType::SHTXX); +#endif #endif } @@ -260,14 +254,6 @@ int32_t EnvironmentTelemetryModule::runOnce() } uint32_t result = UINT32_MAX; - /* - Uncomment the preferences below if you want to use the module - without having to configure it from the PythonAPI or WebUI. - */ - - // moduleConfig.telemetry.environment_measurement_enabled = 1; - // moduleConfig.telemetry.environment_screen_enabled = 1; - // moduleConfig.telemetry.environment_update_interval = 15; if (!(moduleConfig.telemetry.environment_measurement_enabled || moduleConfig.telemetry.environment_screen_enabled || ENVIRONMENTAL_TELEMETRY_MODULE_ENABLE)) { diff --git a/src/modules/Telemetry/Sensor/SHT31Sensor.cpp b/src/modules/Telemetry/Sensor/SHT31Sensor.cpp deleted file mode 100644 index 67a36933d..000000000 --- a/src/modules/Telemetry/Sensor/SHT31Sensor.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "SHT31Sensor.h" -#include "TelemetrySensor.h" -#include - -SHT31Sensor::SHT31Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHT31, "SHT31") {} - -bool SHT31Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - LOG_INFO("Init sensor: %s", sensorName); - sht31 = Adafruit_SHT31(bus); - status = sht31.begin(dev->address.address); - initI2CSensor(); - return status; -} - -bool SHT31Sensor::getMetrics(meshtastic_Telemetry *measurement) -{ - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - measurement->variant.environment_metrics.temperature = sht31.readTemperature(); - measurement->variant.environment_metrics.relative_humidity = sht31.readHumidity(); - - return true; -} - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHT31Sensor.h b/src/modules/Telemetry/Sensor/SHT31Sensor.h deleted file mode 100644 index ecb7d63a6..000000000 --- a/src/modules/Telemetry/Sensor/SHT31Sensor.h +++ /dev/null @@ -1,20 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include - -class SHT31Sensor : public TelemetrySensor -{ - private: - Adafruit_SHT31 sht31; - - public: - SHT31Sensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; -}; - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHT4XSensor.cpp b/src/modules/Telemetry/Sensor/SHT4XSensor.cpp deleted file mode 100644 index b11795d97..000000000 --- a/src/modules/Telemetry/Sensor/SHT4XSensor.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "SHT4XSensor.h" -#include "TelemetrySensor.h" -#include - -SHT4XSensor::SHT4XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHT4X, "SHT4X") {} - -bool SHT4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - LOG_INFO("Init sensor: %s", sensorName); - - uint32_t serialNumber = 0; - - status = sht4x.begin(bus); - if (!status) { - return status; - } - - serialNumber = sht4x.readSerial(); - if (serialNumber != 0) { - LOG_DEBUG("serialNumber : %x", serialNumber); - status = 1; - } else { - LOG_DEBUG("Error trying to execute readSerial(): "); - status = 0; - } - - initI2CSensor(); - return status; -} - -bool SHT4XSensor::getMetrics(meshtastic_Telemetry *measurement) -{ - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - - sensors_event_t humidity, temp; - sht4x.getEvent(&humidity, &temp); - measurement->variant.environment_metrics.temperature = temp.temperature; - measurement->variant.environment_metrics.relative_humidity = humidity.relative_humidity; - return true; -} - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHT4XSensor.h b/src/modules/Telemetry/Sensor/SHT4XSensor.h deleted file mode 100644 index 7311d2366..000000000 --- a/src/modules/Telemetry/Sensor/SHT4XSensor.h +++ /dev/null @@ -1,20 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include - -class SHT4XSensor : public TelemetrySensor -{ - private: - Adafruit_SHT4x sht4x = Adafruit_SHT4x(); - - public: - SHT4XSensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; -}; - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTC3Sensor.cpp b/src/modules/Telemetry/Sensor/SHTC3Sensor.cpp deleted file mode 100644 index fdab0b266..000000000 --- a/src/modules/Telemetry/Sensor/SHTC3Sensor.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "SHTC3Sensor.h" -#include "TelemetrySensor.h" -#include - -SHTC3Sensor::SHTC3Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHTC3, "SHTC3") {} - -bool SHTC3Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - LOG_INFO("Init sensor: %s", sensorName); - status = shtc3.begin(bus); - - initI2CSensor(); - return status; -} - -bool SHTC3Sensor::getMetrics(meshtastic_Telemetry *measurement) -{ - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - - sensors_event_t humidity, temp; - shtc3.getEvent(&humidity, &temp); - - measurement->variant.environment_metrics.temperature = temp.temperature; - measurement->variant.environment_metrics.relative_humidity = humidity.relative_humidity; - - return true; -} - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTC3Sensor.h b/src/modules/Telemetry/Sensor/SHTC3Sensor.h deleted file mode 100644 index 51cee18f7..000000000 --- a/src/modules/Telemetry/Sensor/SHTC3Sensor.h +++ /dev/null @@ -1,20 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include - -class SHTC3Sensor : public TelemetrySensor -{ - private: - Adafruit_SHTC3 shtc3 = Adafruit_SHTC3(); - - public: - SHTC3Sensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; -}; - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTXXSensor.cpp b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp new file mode 100644 index 000000000..92cac7f77 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp @@ -0,0 +1,145 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "SHTXXSensor.h" +#include "TelemetrySensor.h" +#include + +SHTXXSensor::SHTXXSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHTXX, "SHTXX") {} + +void SHTXXSensor::getSensorVariant(SHTSensor::SHTSensorType sensorType) +{ + switch (sensorType) { + case SHTSensor::SHTSensorType::SHT2X: + sensorVariant = "SHT2x"; + break; + + case SHTSensor::SHTSensorType::SHT3X: + case SHTSensor::SHTSensorType::SHT85: + sensorVariant = "SHT3x/SHT85"; + break; + + case SHTSensor::SHTSensorType::SHT3X_ALT: + sensorVariant = "SHT3x"; + break; + + case SHTSensor::SHTSensorType::SHTW1: + case SHTSensor::SHTSensorType::SHTW2: + case SHTSensor::SHTSensorType::SHTC1: + case SHTSensor::SHTSensorType::SHTC3: + sensorVariant = "SHTC1/SHTC3/SHTW1/SHTW2"; + break; + + case SHTSensor::SHTSensorType::SHT4X: + sensorVariant = "SHT4x"; + break; + + default: + sensorVariant = "Unknown"; + break; + } +} + +bool SHTXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + _bus = bus; + _address = dev->address.address; + + if (sht.init(*_bus)) { + LOG_INFO("%s: init(): success", sensorName); + getSensorVariant(sht.mSensorType); + LOG_INFO("%s Sensor detected: %s on 0x%x", sensorName, sensorVariant, _address); + status = 1; + } else { + LOG_ERROR("%s: init(): failed", sensorName); + } + + initI2CSensor(); + return status; +} + +/** + * Accuracy setting of measurement. + * Not all sensors support changing the sampling accuracy (only SHT3X and SHT4X) + * SHTAccuracy: + * - SHT_ACCURACY_HIGH: Highest repeatability at the cost of slower measurement + * - SHT_ACCURACY_MEDIUM: Balanced repeatability and speed of measurement + * - SHT_ACCURACY_LOW: Fastest measurement but lowest repeatability + */ +bool SHTXXSensor::setAccuracy(SHTSensor::SHTAccuracy newAccuracy) +{ + // Only SHT3X-family (including alternates) and SHT4X support changing accuracy + if (sht.mSensorType != SHTSensor::SHTSensorType::SHT3X && sht.mSensorType != SHTSensor::SHTSensorType::SHT3X_ALT && + sht.mSensorType != SHTSensor::SHTSensorType::SHT85 && sht.mSensorType != SHTSensor::SHTSensorType::SHT4X) { + LOG_WARN("%s doesn't support accuracy setting", sensorVariant); + return false; + } + LOG_INFO("%s: setting new accuracy setting", sensorVariant); + accuracy = newAccuracy; + return sht.setAccuracy(accuracy); +} + +bool SHTXXSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + if (sht.readSample()) { + measurement->variant.environment_metrics.has_temperature = true; + measurement->variant.environment_metrics.has_relative_humidity = true; + measurement->variant.environment_metrics.temperature = sht.getTemperature(); + measurement->variant.environment_metrics.relative_humidity = sht.getHumidity(); + + LOG_INFO("%s (%s): Got: temp:%fdegC, hum:%f%%rh", sensorName, sensorVariant, + measurement->variant.environment_metrics.temperature, + measurement->variant.environment_metrics.relative_humidity); + + return true; + } else { + LOG_ERROR("%s (%s): read sample failed", sensorName, sensorVariant); + return false; + } +} + +AdminMessageHandleResult SHTXXSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_shtxx_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + // Check for sensor accuracy setting + if (request->sensor_config.shtxx_config.has_set_accuracy) { + SHTSensor::SHTAccuracy newAccuracy; + if (request->sensor_config.shtxx_config.set_accuracy == 0) { + newAccuracy = SHTSensor::SHT_ACCURACY_LOW; + } else if (request->sensor_config.shtxx_config.set_accuracy == 1) { + newAccuracy = SHTSensor::SHT_ACCURACY_MEDIUM; + } else if (request->sensor_config.shtxx_config.set_accuracy == 2) { + newAccuracy = SHTSensor::SHT_ACCURACY_HIGH; + } else { + LOG_ERROR("%s: incorrect accuracy setting", sensorName); + result = AdminMessageHandleResult::HANDLED; + break; + } + this->setAccuracy(newAccuracy); + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTXXSensor.h b/src/modules/Telemetry/Sensor/SHTXXSensor.h new file mode 100644 index 000000000..e354e113f --- /dev/null +++ b/src/modules/Telemetry/Sensor/SHTXXSensor.h @@ -0,0 +1,29 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +class SHTXXSensor : public TelemetrySensor +{ + private: + SHTSensor sht; + TwoWire *_bus{}; + uint8_t _address{}; + SHTSensor::SHTAccuracy accuracy{}; + bool setAccuracy(SHTSensor::SHTAccuracy newAccuracy); + + public: + SHTXXSensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + void getSensorVariant(SHTSensor::SHTSensorType); + const char *sensorVariant{}; + + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif \ No newline at end of file From 9322bcdb2139b498f7038fce72ce5b025e766fb0 Mon Sep 17 00:00:00 2001 From: Patrickschell609 Date: Sun, 5 Apr 2026 08:54:51 -0400 Subject: [PATCH 47/50] fix: redact MQTT password from log output (#10064) MQTT password was logged in cleartext via LOG_INFO when connecting to the broker, exposing credentials to anyone with log access. Replace the password format specifier with a static mask. Co-authored-by: Patrickschell609 Co-authored-by: Claude Opus 4.6 --- src/mqtt/MQTT.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index ac022a1ab..aba06c210 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -322,8 +322,8 @@ bool connectPubSub(const PubSubConfig &config, PubSubClient &pubSub, Client &cli pubSub.setClient(client); pubSub.setServer(config.serverAddr.c_str(), config.serverPort); - LOG_INFO("Connecting directly to MQTT server %s, port: %d, username: %s, password: %s", config.serverAddr.c_str(), - config.serverPort, config.mqttUsername, config.mqttPassword); + LOG_INFO("Connecting directly to MQTT server %s, port: %d, username: %s, password: ***", config.serverAddr.c_str(), + config.serverPort, config.mqttUsername); // Generate node ID from nodenum for client identification std::string nodeId = nodeDB->getNodeId(); From c728cfdaf4423dfa57880ea16b7e11a7afbabba6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:39:53 -0500 Subject: [PATCH 48/50] Automated version bumps (#10092) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- bin/org.meshtastic.meshtasticd.metainfo.xml | 3 +++ debian/changelog | 6 ++++++ version.properties | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/org.meshtastic.meshtasticd.metainfo.xml b/bin/org.meshtastic.meshtasticd.metainfo.xml index fe3a3a533..0642fdb07 100644 --- a/bin/org.meshtastic.meshtasticd.metainfo.xml +++ b/bin/org.meshtastic.meshtasticd.metainfo.xml @@ -87,6 +87,9 @@ + + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.22 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.21 diff --git a/debian/changelog b/debian/changelog index 13d751ecf..b13a2ae9d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +meshtasticd (2.7.22.0) unstable; urgency=medium + + * Version 2.7.22 + + -- GitHub Actions Mon, 06 Apr 2026 11:34:12 +0000 + meshtasticd (2.7.21.0) unstable; urgency=medium * Version 2.7.21 diff --git a/version.properties b/version.properties index c25179ae2..8621dd9c9 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 7 -build = 21 +build = 22 From 12f0a74557cb888527c5a966040b93cb8ed1c834 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 7 Apr 2026 08:43:29 -0500 Subject: [PATCH 49/50] Sync up custom metadata --- variants/esp32s3/elecrow_panel/platformio.ini | 3 +++ variants/esp32s3/heltec_v4/platformio.ini | 1 + .../esp32s3/heltec_vision_master_e213/platformio.ini | 1 + .../esp32s3/heltec_vision_master_e290/platformio.ini | 1 + variants/esp32s3/heltec_wireless_paper/platformio.ini | 1 + variants/esp32s3/mini-epaper-s3/platformio.ini | 3 ++- variants/esp32s3/picomputer-s3/platformio.ini | 1 + variants/esp32s3/rak3312/platformio.ini | 1 + variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 1 + .../esp32s3/seeed-sensecap-indicator/platformio.ini | 2 +- variants/esp32s3/t-beam-1w/platformio.ini | 1 + variants/esp32s3/t-deck-pro/platformio.ini | 1 + variants/esp32s3/t-deck/platformio.ini | 1 + variants/esp32s3/tlora-pager/platformio.ini | 1 + variants/esp32s3/tlora_t3s3_epaper/platformio.ini | 1 + variants/nrf52840/ELECROW-ThinkNode-M1/platformio.ini | 1 + variants/nrf52840/heltec_mesh_pocket/platformio.ini | 2 ++ .../nrf52840/seeed_wio_tracker_L1_eink/platformio.ini | 1 + variants/nrf52840/t-echo-plus/platformio.ini | 11 +++++++++++ variants/nrf52840/t-echo/platformio.ini | 1 + 20 files changed, 34 insertions(+), 2 deletions(-) diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index c357d41a5..1b91a02bb 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -84,6 +84,7 @@ custom_meshtastic_images = crowpanel_2_4.svg, crowpanel_2_8.svg custom_meshtastic_tags = Elecrow custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true extends = crowpanel_small_esp32s3_base build_flags = @@ -119,6 +120,7 @@ custom_meshtastic_images = crowpanel_3_5.svg custom_meshtastic_tags = Elecrow custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true extends = crowpanel_small_esp32s3_base board_level = pr @@ -158,6 +160,7 @@ custom_meshtastic_images = crowpanel_5_0.svg, crowpanel_7_0.svg custom_meshtastic_tags = Elecrow custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true extends = crowpanel_large_esp32s3_base build_flags = diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index cb795c65d..0336bf983 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -22,6 +22,7 @@ custom_meshtastic_images = heltec_v4.svg custom_meshtastic_tags = Heltec custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true extends = heltec_v4_base build_flags = diff --git a/variants/esp32s3/heltec_vision_master_e213/platformio.ini b/variants/esp32s3/heltec_vision_master_e213/platformio.ini index 1c4c69afe..bd1b73d2b 100644 --- a/variants/esp32s3/heltec_vision_master_e213/platformio.ini +++ b/variants/esp32s3/heltec_vision_master_e213/platformio.ini @@ -9,6 +9,7 @@ custom_meshtastic_images = heltec-vision-master-e213.svg custom_meshtastic_tags = Heltec custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 8MB +custom_meshtastic_has_ink_hud = true extends = esp32s3_base board = heltec_vision_master_e213 diff --git a/variants/esp32s3/heltec_vision_master_e290/platformio.ini b/variants/esp32s3/heltec_vision_master_e290/platformio.ini index 5affd24de..9fdb023de 100644 --- a/variants/esp32s3/heltec_vision_master_e290/platformio.ini +++ b/variants/esp32s3/heltec_vision_master_e290/platformio.ini @@ -10,6 +10,7 @@ custom_meshtastic_images = heltec-vision-master-e290.svg custom_meshtastic_tags = Heltec custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 8MB +custom_meshtastic_has_ink_hud = true extends = esp32s3_base board = heltec_vision_master_e290 diff --git a/variants/esp32s3/heltec_wireless_paper/platformio.ini b/variants/esp32s3/heltec_wireless_paper/platformio.ini index ce4bed30e..acd619c48 100644 --- a/variants/esp32s3/heltec_wireless_paper/platformio.ini +++ b/variants/esp32s3/heltec_wireless_paper/platformio.ini @@ -9,6 +9,7 @@ custom_meshtastic_display_name = Heltec Wireless Paper custom_meshtastic_images = heltec-wireless-paper.svg custom_meshtastic_tags = Heltec custom_meshtastic_partition_scheme = 8MB +custom_meshtastic_has_ink_hud = true extends = esp32s3_base board = heltec_wifi_lora_32_V3 diff --git a/variants/esp32s3/mini-epaper-s3/platformio.ini b/variants/esp32s3/mini-epaper-s3/platformio.ini index 5c3e64681..3e4802319 100644 --- a/variants/esp32s3/mini-epaper-s3/platformio.ini +++ b/variants/esp32s3/mini-epaper-s3/platformio.ini @@ -1,5 +1,5 @@ [env:mini-epaper-s3] -;custom_meshtastic_hw_model = +custom_meshtastic_hw_model = 125 custom_meshtastic_hw_model_slug = MINI_EPAPER_S3 custom_meshtastic_architecture = esp32-s3 custom_meshtastic_actively_supported = true @@ -8,6 +8,7 @@ custom_meshtastic_display_name = LILYGO Mini ePaper S3 E-Ink custom_meshtastic_images = mini-epaper-s3.svg custom_meshtastic_tags = LilyGo custom_meshtastic_requires_dfu = no +custom_meshtastic_has_mui = false extends = esp32s3_base board = mini-epaper-s3 diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index 82147f222..6f218a126 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -6,6 +6,7 @@ custom_meshtastic_actively_supported = true custom_meshtastic_support_level = 3 custom_meshtastic_display_name = Pi Computer S3 custom_meshtastic_partition_scheme = 8MB +custom_meshtastic_has_mui = true extends = esp32s3_base board = bpi_picow_esp32_s3 diff --git a/variants/esp32s3/rak3312/platformio.ini b/variants/esp32s3/rak3312/platformio.ini index 113c2f527..87e5b63ff 100644 --- a/variants/esp32s3/rak3312/platformio.ini +++ b/variants/esp32s3/rak3312/platformio.ini @@ -9,6 +9,7 @@ custom_meshtastic_images = rak_3312.svg custom_meshtastic_tags = RAK custom_meshtastic_requires_dfu = false custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = false extends = esp32s3_base board = wiscore_rak3312 diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index ca33093e0..7847410ae 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -20,6 +20,7 @@ custom_meshtastic_display_name = RAK WisMesh Tap V2 custom_meshtastic_images = rak-wismesh-tap-v2.svg custom_meshtastic_tags = RAK custom_meshtastic_partition_scheme = 8MB +custom_meshtastic_has_mui = true extends = esp32s3_base board = wiscore_rak3312 diff --git a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini index a4650f826..bb52f801b 100644 --- a/variants/esp32s3/seeed-sensecap-indicator/platformio.ini +++ b/variants/esp32s3/seeed-sensecap-indicator/platformio.ini @@ -9,7 +9,7 @@ custom_meshtastic_display_name = Seeed SenseCAP Indicator custom_meshtastic_images = seeed-sensecap-indicator.svg custom_meshtastic_tags = Seeed custom_meshtastic_partition_scheme = 8MB - = true +custom_meshtastic_has_mui = true extends = esp32s3_base platform_packages = diff --git a/variants/esp32s3/t-beam-1w/platformio.ini b/variants/esp32s3/t-beam-1w/platformio.ini index 9abf895db..b14f2fe3c 100644 --- a/variants/esp32s3/t-beam-1w/platformio.ini +++ b/variants/esp32s3/t-beam-1w/platformio.ini @@ -8,6 +8,7 @@ custom_meshtastic_support_level = 1 custom_meshtastic_display_name = LILYGO T-Beam 1W custom_meshtastic_images = tbeam-1w.svg custom_meshtastic_tags = LilyGo +custom_meshtastic_has_mui = false extends = esp32s3_base board = t-beam-1w diff --git a/variants/esp32s3/t-deck-pro/platformio.ini b/variants/esp32s3/t-deck-pro/platformio.ini index c5411da13..93ef8babf 100644 --- a/variants/esp32s3/t-deck-pro/platformio.ini +++ b/variants/esp32s3/t-deck-pro/platformio.ini @@ -9,6 +9,7 @@ custom_meshtastic_images = tdeck_pro.svg custom_meshtastic_tags = LilyGo custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = false extends = esp32s3_base board = t-deck-pro diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 7d79e934c..1b3599464 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -10,6 +10,7 @@ custom_meshtastic_images = t-deck.svg custom_meshtastic_tags = LilyGo custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true extends = esp32s3_base board = t-deck diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index dc96113b0..832f9d7d7 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -10,6 +10,7 @@ custom_meshtastic_images = lilygo-tlora-pager.svg custom_meshtastic_tags = LilyGo custom_meshtastic_requires_dfu = true custom_meshtastic_partition_scheme = 16MB +custom_meshtastic_has_mui = true extends = esp32s3_base board = t-deck-pro ; same as T-Deck Pro diff --git a/variants/esp32s3/tlora_t3s3_epaper/platformio.ini b/variants/esp32s3/tlora_t3s3_epaper/platformio.ini index f9d1ea7db..8f764bbe6 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/platformio.ini +++ b/variants/esp32s3/tlora_t3s3_epaper/platformio.ini @@ -8,6 +8,7 @@ custom_meshtastic_display_name = LILYGO T-LoRa T3-S3 E-Ink custom_meshtastic_images = tlora-t3s3-epaper.svg custom_meshtastic_tags = LilyGo custom_meshtastic_requires_dfu = true +custom_meshtastic_has_ink_hud = true extends = esp32s3_base board = tlora-t3s3-v1 diff --git a/variants/nrf52840/ELECROW-ThinkNode-M1/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M1/platformio.ini index 2a6cea73e..c169de8f7 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M1/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M1/platformio.ini @@ -8,6 +8,7 @@ custom_meshtastic_support_level = 1 custom_meshtastic_display_name = ThinkNode M1 custom_meshtastic_images = thinknode_m1.svg custom_meshtastic_tags = Elecrow +custom_meshtastic_has_ink_hud = true extends = nrf52840_base board = ThinkNode-M1 diff --git a/variants/nrf52840/heltec_mesh_pocket/platformio.ini b/variants/nrf52840/heltec_mesh_pocket/platformio.ini index 9fbcc890d..c9b599382 100644 --- a/variants/nrf52840/heltec_mesh_pocket/platformio.ini +++ b/variants/nrf52840/heltec_mesh_pocket/platformio.ini @@ -15,6 +15,7 @@ custom_meshtastic_display_name = Heltec Mesh Pocket custom_meshtastic_actively_supported = true custom_meshtastic_variant = 5000mAh custom_meshtastic_key = heltec_mesh_pocket +custom_meshtastic_has_ink_hud = true # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. build_flags = ${nrf52840_base.build_flags} @@ -78,6 +79,7 @@ custom_meshtastic_display_name = Heltec Mesh Pocket custom_meshtastic_actively_supported = true custom_meshtastic_variant = 10000mAh custom_meshtastic_key = heltec_mesh_pocket +custom_meshtastic_has_ink_hud = true # add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling. build_flags = ${nrf52840_base.build_flags} diff --git a/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini b/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini index d8fbaf8ff..26f4de565 100644 --- a/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini +++ b/variants/nrf52840/seeed_wio_tracker_L1_eink/platformio.ini @@ -7,6 +7,7 @@ custom_meshtastic_support_level = 1 custom_meshtastic_display_name = Seeed Wio Tracker L1 E-Ink custom_meshtastic_images = wio_tracker_l1_eink.svg custom_meshtastic_tags = Seeed +custom_meshtastic_has_ink_hud = true board = seeed_wio_tracker_L1 extends = nrf52840_base diff --git a/variants/nrf52840/t-echo-plus/platformio.ini b/variants/nrf52840/t-echo-plus/platformio.ini index 9ec9187e8..313eceadc 100644 --- a/variants/nrf52840/t-echo-plus/platformio.ini +++ b/variants/nrf52840/t-echo-plus/platformio.ini @@ -1,4 +1,15 @@ [env:t-echo-plus] +custom_meshtastic_hw_model = 33 +custom_meshtastic_hw_model_slug = T_ECHO_PLUS +custom_meshtastic_architecture = nrf52840 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = LILYGO T-Echo Plus +custom_meshtastic_images = t-echo_plus.svg +custom_meshtastic_tags = LilyGo +custom_meshtastic_requires_dfu = true +custom_meshtastic_has_ink_hud = true + extends = nrf52840_base board = t-echo board_level = pr diff --git a/variants/nrf52840/t-echo/platformio.ini b/variants/nrf52840/t-echo/platformio.ini index 89ce488ad..58ad029ae 100644 --- a/variants/nrf52840/t-echo/platformio.ini +++ b/variants/nrf52840/t-echo/platformio.ini @@ -8,6 +8,7 @@ custom_meshtastic_support_level = 1 custom_meshtastic_display_name = LILYGO T-Echo custom_meshtastic_images = t-echo.svg custom_meshtastic_tags = LilyGo +custom_meshtastic_has_ink_hud = true extends = nrf52840_base board = t-echo From 16cf9623515d699c6563c19b44b591e634e994f0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 13:36:09 -0500 Subject: [PATCH 50/50] Update protobufs (#10104) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/atak.pb.cpp | 12 + src/mesh/generated/meshtastic/atak.pb.h | 394 +++++++++++++++++++- src/mesh/generated/meshtastic/mesh.pb.h | 2 + src/mesh/generated/meshtastic/portnums.pb.h | 4 + 5 files changed, 412 insertions(+), 2 deletions(-) diff --git a/protobufs b/protobufs index cb1f89372..e30092e61 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0 +Subproject commit e30092e6168b13341c2b7ec4be19c789ad5cd77f diff --git a/src/mesh/generated/meshtastic/atak.pb.cpp b/src/mesh/generated/meshtastic/atak.pb.cpp index a0368cf6b..bbafa33e2 100644 --- a/src/mesh/generated/meshtastic/atak.pb.cpp +++ b/src/mesh/generated/meshtastic/atak.pb.cpp @@ -24,6 +24,18 @@ PB_BIND(meshtastic_Contact, meshtastic_Contact, AUTO) PB_BIND(meshtastic_PLI, meshtastic_PLI, AUTO) +PB_BIND(meshtastic_AircraftTrack, meshtastic_AircraftTrack, AUTO) + + +PB_BIND(meshtastic_TAKPacketV2, meshtastic_TAKPacketV2, 2) + + + + + + + + diff --git a/src/mesh/generated/meshtastic/atak.pb.h b/src/mesh/generated/meshtastic/atak.pb.h index 8533bcbf9..c12b042f4 100644 --- a/src/mesh/generated/meshtastic/atak.pb.h +++ b/src/mesh/generated/meshtastic/atak.pb.h @@ -65,6 +65,197 @@ typedef enum _meshtastic_MemberRole { meshtastic_MemberRole_K9 = 8 } meshtastic_MemberRole; +/* CoT how field values. + Represents how the coordinates were generated. */ +typedef enum _meshtastic_CotHow { + /* Unspecified */ + meshtastic_CotHow_CotHow_Unspecified = 0, + /* Human entered */ + meshtastic_CotHow_CotHow_h_e = 1, + /* Machine generated */ + meshtastic_CotHow_CotHow_m_g = 2, + /* Human GPS/INS derived */ + meshtastic_CotHow_CotHow_h_g_i_g_o = 3, + /* Machine relayed (imported from another system/gateway) */ + meshtastic_CotHow_CotHow_m_r = 4, + /* Machine fused (corroborated from multiple sources) */ + meshtastic_CotHow_CotHow_m_f = 5, + /* Machine predicted */ + meshtastic_CotHow_CotHow_m_p = 6, + /* Machine simulated */ + meshtastic_CotHow_CotHow_m_s = 7 +} meshtastic_CotHow; + +/* Well-known CoT event types. + When the type is known, use the enum value for efficient encoding. + For unknown types, set cot_type_id to CotType_Other and populate cot_type_str. */ +typedef enum _meshtastic_CotType { + /* Unknown or unmapped type, use cot_type_str */ + meshtastic_CotType_CotType_Other = 0, + /* a-f-G-U-C: Friendly ground unit combat */ + meshtastic_CotType_CotType_a_f_G_U_C = 1, + /* a-f-G-U-C-I: Friendly ground unit combat infantry */ + meshtastic_CotType_CotType_a_f_G_U_C_I = 2, + /* a-n-A-C-F: Neutral aircraft civilian fixed-wing */ + meshtastic_CotType_CotType_a_n_A_C_F = 3, + /* a-n-A-C-H: Neutral aircraft civilian helicopter */ + meshtastic_CotType_CotType_a_n_A_C_H = 4, + /* a-n-A-C: Neutral aircraft civilian */ + meshtastic_CotType_CotType_a_n_A_C = 5, + /* a-f-A-M-H: Friendly aircraft military helicopter */ + meshtastic_CotType_CotType_a_f_A_M_H = 6, + /* a-f-A-M: Friendly aircraft military */ + meshtastic_CotType_CotType_a_f_A_M = 7, + /* a-f-A-M-F-F: Friendly aircraft military fixed-wing fighter */ + meshtastic_CotType_CotType_a_f_A_M_F_F = 8, + /* a-f-A-M-H-A: Friendly aircraft military helicopter attack */ + meshtastic_CotType_CotType_a_f_A_M_H_A = 9, + /* a-f-A-M-H-U-M: Friendly aircraft military helicopter utility medium */ + meshtastic_CotType_CotType_a_f_A_M_H_U_M = 10, + /* a-h-A-M-F-F: Hostile aircraft military fixed-wing fighter */ + meshtastic_CotType_CotType_a_h_A_M_F_F = 11, + /* a-h-A-M-H-A: Hostile aircraft military helicopter attack */ + meshtastic_CotType_CotType_a_h_A_M_H_A = 12, + /* a-u-A-C: Unknown aircraft civilian */ + meshtastic_CotType_CotType_a_u_A_C = 13, + /* t-x-d-d: Tasking delete/disconnect */ + meshtastic_CotType_CotType_t_x_d_d = 14, + /* a-f-G-E-S-E: Friendly ground equipment sensor */ + meshtastic_CotType_CotType_a_f_G_E_S_E = 15, + /* a-f-G-E-V-C: Friendly ground equipment vehicle */ + meshtastic_CotType_CotType_a_f_G_E_V_C = 16, + /* a-f-S: Friendly sea */ + meshtastic_CotType_CotType_a_f_S = 17, + /* a-f-A-M-F: Friendly aircraft military fixed-wing */ + meshtastic_CotType_CotType_a_f_A_M_F = 18, + /* a-f-A-M-F-C-H: Friendly aircraft military fixed-wing cargo heavy */ + meshtastic_CotType_CotType_a_f_A_M_F_C_H = 19, + /* a-f-A-M-F-U-L: Friendly aircraft military fixed-wing utility light */ + meshtastic_CotType_CotType_a_f_A_M_F_U_L = 20, + /* a-f-A-M-F-L: Friendly aircraft military fixed-wing liaison */ + meshtastic_CotType_CotType_a_f_A_M_F_L = 21, + /* a-f-A-M-F-P: Friendly aircraft military fixed-wing patrol */ + meshtastic_CotType_CotType_a_f_A_M_F_P = 22, + /* a-f-A-C-H: Friendly aircraft civilian helicopter */ + meshtastic_CotType_CotType_a_f_A_C_H = 23, + /* a-n-A-M-F-Q: Neutral aircraft military fixed-wing drone */ + meshtastic_CotType_CotType_a_n_A_M_F_Q = 24, + /* b-t-f: GeoChat message */ + meshtastic_CotType_CotType_b_t_f = 25, + /* b-r-f-h-c: CASEVAC/MEDEVAC report */ + meshtastic_CotType_CotType_b_r_f_h_c = 26, + /* b-a-o-pan: Ring the bell / alert all */ + meshtastic_CotType_CotType_b_a_o_pan = 27, + /* b-a-o-opn: Troops in contact */ + meshtastic_CotType_CotType_b_a_o_opn = 28, + /* b-a-o-can: Cancel alert */ + meshtastic_CotType_CotType_b_a_o_can = 29, + /* b-a-o-tbl: 911 alert */ + meshtastic_CotType_CotType_b_a_o_tbl = 30, + /* b-a-g: Geofence breach alert */ + meshtastic_CotType_CotType_b_a_g = 31, + /* a-f-G: Friendly ground (generic) */ + meshtastic_CotType_CotType_a_f_G = 32, + /* a-f-G-U: Friendly ground unit (generic) */ + meshtastic_CotType_CotType_a_f_G_U = 33, + /* a-h-G: Hostile ground (generic) */ + meshtastic_CotType_CotType_a_h_G = 34, + /* a-u-G: Unknown ground (generic) */ + meshtastic_CotType_CotType_a_u_G = 35, + /* a-n-G: Neutral ground (generic) */ + meshtastic_CotType_CotType_a_n_G = 36, + /* b-m-r: Route */ + meshtastic_CotType_CotType_b_m_r = 37, + /* b-m-p-w: Route waypoint */ + meshtastic_CotType_CotType_b_m_p_w = 38, + /* b-m-p-s-p-i: Self-position marker */ + meshtastic_CotType_CotType_b_m_p_s_p_i = 39, + /* u-d-f: Freeform shape (line/polygon) */ + meshtastic_CotType_CotType_u_d_f = 40, + /* u-d-r: Rectangle */ + meshtastic_CotType_CotType_u_d_r = 41, + /* u-d-c-c: Circle */ + meshtastic_CotType_CotType_u_d_c_c = 42, + /* u-rb-a: Range/bearing line */ + meshtastic_CotType_CotType_u_rb_a = 43, + /* a-h-A: Hostile aircraft (generic) */ + meshtastic_CotType_CotType_a_h_A = 44, + /* a-u-A: Unknown aircraft (generic) */ + meshtastic_CotType_CotType_a_u_A = 45, + /* a-f-A-M-H-Q: Friendly aircraft military helicopter observation */ + meshtastic_CotType_CotType_a_f_A_M_H_Q = 46, + /* a-f-A-C-F: Friendly aircraft civilian fixed-wing */ + meshtastic_CotType_CotType_a_f_A_C_F = 47, + /* a-f-A-C: Friendly aircraft civilian (generic) */ + meshtastic_CotType_CotType_a_f_A_C = 48, + /* a-f-A-C-L: Friendly aircraft civilian lighter-than-air */ + meshtastic_CotType_CotType_a_f_A_C_L = 49, + /* a-f-A: Friendly aircraft (generic) */ + meshtastic_CotType_CotType_a_f_A = 50, + /* a-f-A-M-H-C: Friendly aircraft military helicopter cargo */ + meshtastic_CotType_CotType_a_f_A_M_H_C = 51, + /* a-n-A-M-F-F: Neutral aircraft military fixed-wing fighter */ + meshtastic_CotType_CotType_a_n_A_M_F_F = 52, + /* a-u-A-C-F: Unknown aircraft civilian fixed-wing */ + meshtastic_CotType_CotType_a_u_A_C_F = 53, + /* a-f-G-U-C-F-T-A: Friendly ground unit combat forces theater aviation */ + meshtastic_CotType_CotType_a_f_G_U_C_F_T_A = 54, + /* a-f-G-U-C-V-S: Friendly ground unit combat vehicle support */ + meshtastic_CotType_CotType_a_f_G_U_C_V_S = 55, + /* a-f-G-U-C-R-X: Friendly ground unit combat reconnaissance exploitation */ + meshtastic_CotType_CotType_a_f_G_U_C_R_X = 56, + /* a-f-G-U-C-I-Z: Friendly ground unit combat infantry mechanized */ + meshtastic_CotType_CotType_a_f_G_U_C_I_Z = 57, + /* a-f-G-U-C-E-C-W: Friendly ground unit combat engineer construction wheeled */ + meshtastic_CotType_CotType_a_f_G_U_C_E_C_W = 58, + /* a-f-G-U-C-I-L: Friendly ground unit combat infantry light */ + meshtastic_CotType_CotType_a_f_G_U_C_I_L = 59, + /* a-f-G-U-C-R-O: Friendly ground unit combat reconnaissance other */ + meshtastic_CotType_CotType_a_f_G_U_C_R_O = 60, + /* a-f-G-U-C-R-V: Friendly ground unit combat reconnaissance cavalry */ + meshtastic_CotType_CotType_a_f_G_U_C_R_V = 61, + /* a-f-G-U-H: Friendly ground unit headquarters */ + meshtastic_CotType_CotType_a_f_G_U_H = 62, + /* a-f-G-U-U-M-S-E: Friendly ground unit support medical surgical evacuation */ + meshtastic_CotType_CotType_a_f_G_U_U_M_S_E = 63, + /* a-f-G-U-S-M-C: Friendly ground unit support maintenance collection */ + meshtastic_CotType_CotType_a_f_G_U_S_M_C = 64, + /* a-f-G-E-S: Friendly ground equipment sensor (generic) */ + meshtastic_CotType_CotType_a_f_G_E_S = 65, + /* a-f-G-E: Friendly ground equipment (generic) */ + meshtastic_CotType_CotType_a_f_G_E = 66, + /* a-f-G-E-V-C-U: Friendly ground equipment vehicle utility */ + meshtastic_CotType_CotType_a_f_G_E_V_C_U = 67, + /* a-f-G-E-V-C-ps: Friendly ground equipment vehicle public safety */ + meshtastic_CotType_CotType_a_f_G_E_V_C_ps = 68, + /* a-u-G-E-V: Unknown ground equipment vehicle */ + meshtastic_CotType_CotType_a_u_G_E_V = 69, + /* a-f-S-N-N-R: Friendly sea surface non-naval rescue */ + meshtastic_CotType_CotType_a_f_S_N_N_R = 70, + /* a-f-F-B: Friendly force boundary */ + meshtastic_CotType_CotType_a_f_F_B = 71, + /* b-m-p-s-p-loc: Self-position location marker */ + meshtastic_CotType_CotType_b_m_p_s_p_loc = 72, + /* b-i-v: Imagery/video */ + meshtastic_CotType_CotType_b_i_v = 73, + /* b-f-t-r: File transfer request */ + meshtastic_CotType_CotType_b_f_t_r = 74, + /* b-f-t-a: File transfer acknowledgment */ + meshtastic_CotType_CotType_b_f_t_a = 75 +} meshtastic_CotType; + +/* Geopoint and altitude source */ +typedef enum _meshtastic_GeoPointSource { + /* Unspecified */ + meshtastic_GeoPointSource_GeoPointSource_Unspecified = 0, + /* GPS derived */ + meshtastic_GeoPointSource_GeoPointSource_GPS = 1, + /* User entered */ + meshtastic_GeoPointSource_GeoPointSource_USER = 2, + /* Network/external */ + meshtastic_GeoPointSource_GeoPointSource_NETWORK = 3 +} meshtastic_GeoPointSource; + /* Struct definitions */ /* ATAK GeoChat message */ typedef struct _meshtastic_GeoChat { @@ -146,6 +337,95 @@ typedef struct _meshtastic_TAKPacket { } payload_variant; } meshtastic_TAKPacket; +/* Aircraft track information from ADS-B or military air tracking. + Covers the majority of observed real-world CoT traffic. */ +typedef struct _meshtastic_AircraftTrack { + /* ICAO hex identifier (e.g. "AD237C") */ + char icao[8]; + /* Aircraft registration (e.g. "N946AK") */ + char registration[16]; + /* Flight number/callsign (e.g. "ASA864") */ + char flight[16]; + /* ICAO aircraft type designator (e.g. "B39M") */ + char aircraft_type[8]; + /* Transponder squawk code (0-7777 octal) */ + uint16_t squawk; + /* ADS-B emitter category (e.g. "A3") */ + char category[4]; + /* Received signal strength * 10 (e.g. -194 for -19.4 dBm) */ + int32_t rssi_x10; + /* Whether receiver has GPS fix */ + bool gps; + /* CoT host ID for source attribution */ + char cot_host_id[64]; +} meshtastic_AircraftTrack; + +typedef PB_BYTES_ARRAY_T(220) meshtastic_TAKPacketV2_raw_detail_t; +/* ATAK v2 packet with expanded CoT field support and zstd dictionary compression. + Sent on ATAK_PLUGIN_V2 port. The wire payload is: + [1 byte flags][zstd-compressed TAKPacketV2 protobuf] + Flags byte: bits 0-5 = dictionary ID, bits 6-7 = reserved. */ +typedef struct _meshtastic_TAKPacketV2 { + /* Well-known CoT event type enum. + Use CotType_Other with cot_type_str for unknown types. */ + meshtastic_CotType cot_type_id; + /* How the coordinates were generated */ + meshtastic_CotHow how; + /* Callsign */ + char callsign[120]; + /* Team color assignment */ + meshtastic_Team team; + /* Role of the group member */ + meshtastic_MemberRole role; + /* Latitude, multiply by 1e-7 to get degrees in floating point */ + int32_t latitude_i; + /* Longitude, multiply by 1e-7 to get degrees in floating point */ + int32_t longitude_i; + /* Altitude in meters (HAE) */ + int32_t altitude; + /* Speed in cm/s */ + uint32_t speed; + /* Course in degrees * 100 (0-36000) */ + uint16_t course; + /* Battery level 0-100 */ + uint8_t battery; + /* Geopoint source */ + meshtastic_GeoPointSource geo_src; + /* Altitude source */ + meshtastic_GeoPointSource alt_src; + /* Device UID (UUID string or device ID like "ANDROID-xxxx") */ + char uid[48]; + /* Device callsign */ + char device_callsign[120]; + /* Stale time as seconds offset from event time */ + uint16_t stale_seconds; + /* TAK client version string */ + char tak_version[64]; + /* TAK device model */ + char tak_device[32]; + /* TAK platform (ATAK-CIV, WebTAK, etc.) */ + char tak_platform[32]; + /* TAK OS version */ + char tak_os[16]; + /* Connection endpoint */ + char endpoint[32]; + /* Phone number */ + char phone[20]; + /* CoT event type string, only populated when cot_type_id is CotType_Other */ + char cot_type_str[32]; + pb_size_t which_payload_variant; + union { + /* Position report (true = PLI, no extra fields beyond the common ones above) */ + bool pli; + /* ATAK GeoChat message */ + meshtastic_GeoChat chat; + /* Aircraft track data (ADS-B, military air) */ + meshtastic_AircraftTrack aircraft; + /* Generic CoT detail XML for unmapped types */ + meshtastic_TAKPacketV2_raw_detail_t raw_detail; + } payload_variant; +} meshtastic_TAKPacketV2; + #ifdef __cplusplus extern "C" { @@ -160,6 +440,18 @@ extern "C" { #define _meshtastic_MemberRole_MAX meshtastic_MemberRole_K9 #define _meshtastic_MemberRole_ARRAYSIZE ((meshtastic_MemberRole)(meshtastic_MemberRole_K9+1)) +#define _meshtastic_CotHow_MIN meshtastic_CotHow_CotHow_Unspecified +#define _meshtastic_CotHow_MAX meshtastic_CotHow_CotHow_m_s +#define _meshtastic_CotHow_ARRAYSIZE ((meshtastic_CotHow)(meshtastic_CotHow_CotHow_m_s+1)) + +#define _meshtastic_CotType_MIN meshtastic_CotType_CotType_Other +#define _meshtastic_CotType_MAX meshtastic_CotType_CotType_b_f_t_a +#define _meshtastic_CotType_ARRAYSIZE ((meshtastic_CotType)(meshtastic_CotType_CotType_b_f_t_a+1)) + +#define _meshtastic_GeoPointSource_MIN meshtastic_GeoPointSource_GeoPointSource_Unspecified +#define _meshtastic_GeoPointSource_MAX meshtastic_GeoPointSource_GeoPointSource_NETWORK +#define _meshtastic_GeoPointSource_ARRAYSIZE ((meshtastic_GeoPointSource)(meshtastic_GeoPointSource_GeoPointSource_NETWORK+1)) + #define meshtastic_Group_role_ENUMTYPE meshtastic_MemberRole @@ -169,6 +461,14 @@ extern "C" { +#define meshtastic_TAKPacketV2_cot_type_id_ENUMTYPE meshtastic_CotType +#define meshtastic_TAKPacketV2_how_ENUMTYPE meshtastic_CotHow +#define meshtastic_TAKPacketV2_team_ENUMTYPE meshtastic_Team +#define meshtastic_TAKPacketV2_role_ENUMTYPE meshtastic_MemberRole +#define meshtastic_TAKPacketV2_geo_src_ENUMTYPE meshtastic_GeoPointSource +#define meshtastic_TAKPacketV2_alt_src_ENUMTYPE meshtastic_GeoPointSource + + /* Initializer values for message structs */ #define meshtastic_TAKPacket_init_default {0, false, meshtastic_Contact_init_default, false, meshtastic_Group_init_default, false, meshtastic_Status_init_default, 0, {meshtastic_PLI_init_default}} #define meshtastic_GeoChat_init_default {"", false, "", false, ""} @@ -176,12 +476,16 @@ extern "C" { #define meshtastic_Status_init_default {0} #define meshtastic_Contact_init_default {"", ""} #define meshtastic_PLI_init_default {0, 0, 0, 0, 0} +#define meshtastic_AircraftTrack_init_default {"", "", "", "", 0, "", 0, 0, ""} +#define meshtastic_TAKPacketV2_init_default {_meshtastic_CotType_MIN, _meshtastic_CotHow_MIN, "", _meshtastic_Team_MIN, _meshtastic_MemberRole_MIN, 0, 0, 0, 0, 0, 0, _meshtastic_GeoPointSource_MIN, _meshtastic_GeoPointSource_MIN, "", "", 0, "", "", "", "", "", "", "", 0, {0}} #define meshtastic_TAKPacket_init_zero {0, false, meshtastic_Contact_init_zero, false, meshtastic_Group_init_zero, false, meshtastic_Status_init_zero, 0, {meshtastic_PLI_init_zero}} #define meshtastic_GeoChat_init_zero {"", false, "", false, ""} #define meshtastic_Group_init_zero {_meshtastic_MemberRole_MIN, _meshtastic_Team_MIN} #define meshtastic_Status_init_zero {0} #define meshtastic_Contact_init_zero {"", ""} #define meshtastic_PLI_init_zero {0, 0, 0, 0, 0} +#define meshtastic_AircraftTrack_init_zero {"", "", "", "", 0, "", 0, 0, ""} +#define meshtastic_TAKPacketV2_init_zero {_meshtastic_CotType_MIN, _meshtastic_CotHow_MIN, "", _meshtastic_Team_MIN, _meshtastic_MemberRole_MIN, 0, 0, 0, 0, 0, 0, _meshtastic_GeoPointSource_MIN, _meshtastic_GeoPointSource_MIN, "", "", 0, "", "", "", "", "", "", "", 0, {0}} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_GeoChat_message_tag 1 @@ -204,6 +508,42 @@ extern "C" { #define meshtastic_TAKPacket_pli_tag 5 #define meshtastic_TAKPacket_chat_tag 6 #define meshtastic_TAKPacket_detail_tag 7 +#define meshtastic_AircraftTrack_icao_tag 1 +#define meshtastic_AircraftTrack_registration_tag 2 +#define meshtastic_AircraftTrack_flight_tag 3 +#define meshtastic_AircraftTrack_aircraft_type_tag 4 +#define meshtastic_AircraftTrack_squawk_tag 5 +#define meshtastic_AircraftTrack_category_tag 6 +#define meshtastic_AircraftTrack_rssi_x10_tag 7 +#define meshtastic_AircraftTrack_gps_tag 8 +#define meshtastic_AircraftTrack_cot_host_id_tag 9 +#define meshtastic_TAKPacketV2_cot_type_id_tag 1 +#define meshtastic_TAKPacketV2_how_tag 2 +#define meshtastic_TAKPacketV2_callsign_tag 3 +#define meshtastic_TAKPacketV2_team_tag 4 +#define meshtastic_TAKPacketV2_role_tag 5 +#define meshtastic_TAKPacketV2_latitude_i_tag 6 +#define meshtastic_TAKPacketV2_longitude_i_tag 7 +#define meshtastic_TAKPacketV2_altitude_tag 8 +#define meshtastic_TAKPacketV2_speed_tag 9 +#define meshtastic_TAKPacketV2_course_tag 10 +#define meshtastic_TAKPacketV2_battery_tag 11 +#define meshtastic_TAKPacketV2_geo_src_tag 12 +#define meshtastic_TAKPacketV2_alt_src_tag 13 +#define meshtastic_TAKPacketV2_uid_tag 14 +#define meshtastic_TAKPacketV2_device_callsign_tag 15 +#define meshtastic_TAKPacketV2_stale_seconds_tag 16 +#define meshtastic_TAKPacketV2_tak_version_tag 17 +#define meshtastic_TAKPacketV2_tak_device_tag 18 +#define meshtastic_TAKPacketV2_tak_platform_tag 19 +#define meshtastic_TAKPacketV2_tak_os_tag 20 +#define meshtastic_TAKPacketV2_endpoint_tag 21 +#define meshtastic_TAKPacketV2_phone_tag 22 +#define meshtastic_TAKPacketV2_cot_type_str_tag 23 +#define meshtastic_TAKPacketV2_pli_tag 30 +#define meshtastic_TAKPacketV2_chat_tag 31 +#define meshtastic_TAKPacketV2_aircraft_tag 32 +#define meshtastic_TAKPacketV2_raw_detail_tag 33 /* Struct field encoding specification for nanopb */ #define meshtastic_TAKPacket_FIELDLIST(X, a) \ @@ -255,12 +595,60 @@ X(a, STATIC, SINGULAR, UINT32, course, 5) #define meshtastic_PLI_CALLBACK NULL #define meshtastic_PLI_DEFAULT NULL +#define meshtastic_AircraftTrack_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, icao, 1) \ +X(a, STATIC, SINGULAR, STRING, registration, 2) \ +X(a, STATIC, SINGULAR, STRING, flight, 3) \ +X(a, STATIC, SINGULAR, STRING, aircraft_type, 4) \ +X(a, STATIC, SINGULAR, UINT32, squawk, 5) \ +X(a, STATIC, SINGULAR, STRING, category, 6) \ +X(a, STATIC, SINGULAR, SINT32, rssi_x10, 7) \ +X(a, STATIC, SINGULAR, BOOL, gps, 8) \ +X(a, STATIC, SINGULAR, STRING, cot_host_id, 9) +#define meshtastic_AircraftTrack_CALLBACK NULL +#define meshtastic_AircraftTrack_DEFAULT NULL + +#define meshtastic_TAKPacketV2_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, cot_type_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, how, 2) \ +X(a, STATIC, SINGULAR, STRING, callsign, 3) \ +X(a, STATIC, SINGULAR, UENUM, team, 4) \ +X(a, STATIC, SINGULAR, UENUM, role, 5) \ +X(a, STATIC, SINGULAR, SFIXED32, latitude_i, 6) \ +X(a, STATIC, SINGULAR, SFIXED32, longitude_i, 7) \ +X(a, STATIC, SINGULAR, SINT32, altitude, 8) \ +X(a, STATIC, SINGULAR, UINT32, speed, 9) \ +X(a, STATIC, SINGULAR, UINT32, course, 10) \ +X(a, STATIC, SINGULAR, UINT32, battery, 11) \ +X(a, STATIC, SINGULAR, UENUM, geo_src, 12) \ +X(a, STATIC, SINGULAR, UENUM, alt_src, 13) \ +X(a, STATIC, SINGULAR, STRING, uid, 14) \ +X(a, STATIC, SINGULAR, STRING, device_callsign, 15) \ +X(a, STATIC, SINGULAR, UINT32, stale_seconds, 16) \ +X(a, STATIC, SINGULAR, STRING, tak_version, 17) \ +X(a, STATIC, SINGULAR, STRING, tak_device, 18) \ +X(a, STATIC, SINGULAR, STRING, tak_platform, 19) \ +X(a, STATIC, SINGULAR, STRING, tak_os, 20) \ +X(a, STATIC, SINGULAR, STRING, endpoint, 21) \ +X(a, STATIC, SINGULAR, STRING, phone, 22) \ +X(a, STATIC, SINGULAR, STRING, cot_type_str, 23) \ +X(a, STATIC, ONEOF, BOOL, (payload_variant,pli,payload_variant.pli), 30) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,chat,payload_variant.chat), 31) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,aircraft,payload_variant.aircraft), 32) \ +X(a, STATIC, ONEOF, BYTES, (payload_variant,raw_detail,payload_variant.raw_detail), 33) +#define meshtastic_TAKPacketV2_CALLBACK NULL +#define meshtastic_TAKPacketV2_DEFAULT NULL +#define meshtastic_TAKPacketV2_payload_variant_chat_MSGTYPE meshtastic_GeoChat +#define meshtastic_TAKPacketV2_payload_variant_aircraft_MSGTYPE meshtastic_AircraftTrack + extern const pb_msgdesc_t meshtastic_TAKPacket_msg; extern const pb_msgdesc_t meshtastic_GeoChat_msg; extern const pb_msgdesc_t meshtastic_Group_msg; extern const pb_msgdesc_t meshtastic_Status_msg; extern const pb_msgdesc_t meshtastic_Contact_msg; extern const pb_msgdesc_t meshtastic_PLI_msg; +extern const pb_msgdesc_t meshtastic_AircraftTrack_msg; +extern const pb_msgdesc_t meshtastic_TAKPacketV2_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ #define meshtastic_TAKPacket_fields &meshtastic_TAKPacket_msg @@ -269,14 +657,18 @@ extern const pb_msgdesc_t meshtastic_PLI_msg; #define meshtastic_Status_fields &meshtastic_Status_msg #define meshtastic_Contact_fields &meshtastic_Contact_msg #define meshtastic_PLI_fields &meshtastic_PLI_msg +#define meshtastic_AircraftTrack_fields &meshtastic_AircraftTrack_msg +#define meshtastic_TAKPacketV2_fields &meshtastic_TAKPacketV2_msg /* Maximum encoded size of messages (where known) */ -#define MESHTASTIC_MESHTASTIC_ATAK_PB_H_MAX_SIZE meshtastic_TAKPacket_size +#define MESHTASTIC_MESHTASTIC_ATAK_PB_H_MAX_SIZE meshtastic_TAKPacketV2_size +#define meshtastic_AircraftTrack_size 134 #define meshtastic_Contact_size 242 #define meshtastic_GeoChat_size 444 #define meshtastic_Group_size 4 #define meshtastic_PLI_size 31 #define meshtastic_Status_size 3 +#define meshtastic_TAKPacketV2_size 1027 #define meshtastic_TAKPacket_size 705 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 477c3b31b..fc6931d73 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -308,6 +308,8 @@ typedef enum _meshtastic_HardwareModel { meshtastic_HardwareModel_TDISPLAY_S3_PRO = 126, /* Heltec Mesh Node T096 board features an nRF52840 CPU and a TFT screen. */ meshtastic_HardwareModel_HELTEC_MESH_NODE_T096 = 127, + /* Seeed studio T1000-E Pro tracker card. NRF52840 w/ LR2021 radio, GPS, button, buzzer, and sensors. */ + meshtastic_HardwareModel_TRACKER_T1000_E_PRO = 128, /* ------------------------------------------------------------------------------------------------------------------------------------------ Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits. ------------------------------------------------------------------------------------------------------------------------------------------ */ diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h index bd1fe48c4..a474e5b92 100644 --- a/src/mesh/generated/meshtastic/portnums.pb.h +++ b/src/mesh/generated/meshtastic/portnums.pb.h @@ -150,6 +150,10 @@ typedef enum _meshtastic_PortNum { arbitrary telemetry over meshtastic that is not covered by telemetry.proto ENCODING: CayenneLLP */ meshtastic_PortNum_CAYENNE_APP = 77, + /* ATAK Plugin V2 + Portnum for payloads from the official Meshtastic ATAK plugin using + TAKPacketV2 with zstd dictionary compression. */ + meshtastic_PortNum_ATAK_PLUGIN_V2 = 78, /* GroupAlarm integration Used for transporting GroupAlarm-related messages between Meshtastic nodes and companion applications/services. */