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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] =?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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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 a3b49b9028ee292a3685f74af3a5432832c7d3bd Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:18:05 +0100 Subject: [PATCH 49/74] Add powerlimits to reconfigured radio settings as well as init settings. (#10025) * Add powerlimits to reconfigured radio settings as well as init settings. * Refactor preamble length handling for wide LoRa configurations * Moved the preamble setting to the main class and made the references static --- src/graphics/draw/MenuHandler.cpp | 4 +++- src/mesh/LR11x0Interface.cpp | 21 ++++++++++----------- src/mesh/RF95Interface.cpp | 3 +-- src/mesh/RadioInterface.cpp | 7 +++++++ src/mesh/RadioInterface.h | 21 +++++++++++++-------- src/mesh/SX126xInterface.cpp | 6 ++++-- src/mesh/SX128xInterface.cpp | 3 +-- 7 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 157d4c314..e92ba4839 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -163,9 +163,11 @@ void menuHandler::LoraRegionPicker(uint32_t duration) // Guard: without a reboot, reconfigure() applies the region directly. // Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot. + // TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora + // rather than a hardcoded check for LORA_24. 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"); + LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection"); return; } diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 9f808da26..7a193f7f3 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -71,12 +71,10 @@ template bool LR11x0Interface::init() RadioLibInterface::init(); - limitPower(LR1110_MAX_POWER); - - if ((power > LR1120_MAX_POWER) && - (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) { // clamp again if wide freq range - power = LR1120_MAX_POWER; - preambleLength = 12; // 12 is the default for operation above 2GHz + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR1120_MAX_POWER); + } else { + limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range } #ifdef LR11X0_RF_SWITCH_SUBGHZ @@ -177,6 +175,12 @@ template bool LR11x0Interface::reconfigure() err = lora.setSyncWord(syncWord); assert(err == RADIOLIB_ERR_NONE); + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR1120_MAX_POWER); + } else { + limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range + } + err = lora.setPreambleLength(preambleLength); assert(err == RADIOLIB_ERR_NONE); @@ -184,11 +188,6 @@ template bool LR11x0Interface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > LR1110_MAX_POWER) // This chip has lower power limits than some - power = LR1110_MAX_POWER; - if ((power > LR1120_MAX_POWER) && (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) // 2.4G power limit - power = LR1120_MAX_POWER; - err = lora.setOutputPower(power); assert(err == RADIOLIB_ERR_NONE); diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index b3aa72f7a..32c92de93 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -240,8 +240,7 @@ bool RF95Interface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > RF95_MAX_POWER) // This chip has lower power limits than some - power = RF95_MAX_POWER; + limitPower(RF95_MAX_POWER); #ifdef USE_RF95_RFO err = lora->setOutputPower(power, true); diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 19757b63c..b7b672d8b 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1038,6 +1038,13 @@ void RadioInterface::applyModemConfig() saveFreq(freq + loraConfig.frequency_offset); const char *channelName = channels.getName(channels.getPrimaryIndex()); + if (newRegion->wideLora) { // clamp if wide freq range + preambleLength = wideLoraPreambleLengthDefault; // 12 is the default for operation above 2GHz + } else { + preambleLength = + preambleLengthDefault; // 8 is default, but we use longer to increase the amount of sleep time when receiving + } + slotTimeMsec = computeSlotTimeMsec(); preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw); diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index a7176f388..a1c692e24 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -92,15 +92,20 @@ class RadioInterface uint8_t sf = 9; uint8_t cr = 5; - const uint8_t NUM_SYM_CAD = 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 - const uint8_t NUM_SYM_CAD_24GHZ = 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280 + static constexpr uint8_t NUM_SYM_CAD = + 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 + static constexpr uint8_t NUM_SYM_CAD_24GHZ = + 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280 uint32_t slotTimeMsec = computeSlotTimeMsec(); - uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving - uint32_t preambleTimeMsec = 165; // calculated on startup, this is the default for LongFast - const uint32_t PROCESSING_TIME_MSEC = - 4500; // time to construct, process and construct a packet again (empirically determined) - const uint8_t CWmin = 3; // minimum CWsize - const uint8_t CWmax = 8; // maximum CWsize + uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving + static constexpr uint16_t preambleLengthDefault = + 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving + static constexpr uint16_t wideLoraPreambleLengthDefault = 12; // 12 is default for wide Lora + uint32_t preambleTimeMsec = 165; // calculated on startup, this is the default for LongFast + static constexpr uint32_t PROCESSING_TIME_MSEC = + 4500; // time to construct, process and construct a packet again (empirically determined) + static constexpr uint8_t CWmin = 3; // minimum CWsize + static constexpr uint8_t CWmax = 8; // maximum CWsize meshtastic_MeshPacket *sendingPacket = NULL; // The packet we are currently sending uint32_t lastTxStart = 0L; diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index a4e82011e..bcb08f2c5 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -245,8 +245,10 @@ template bool SX126xInterface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > SX126X_MAX_POWER) // This chip has lower power limits than some - power = SX126X_MAX_POWER; + limitPower(SX126X_MAX_POWER); + // Make sure we reach the minimum power supported to turn the chip on (-9dBm) + if (power < -9) + power = -9; err = lora.setOutputPower(power); if (err != RADIOLIB_ERR_NONE) diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 0e882ef05..cb21c0770 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -144,8 +144,7 @@ template bool SX128xInterface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > SX128X_MAX_POWER) // This chip has lower power limits than some - power = SX128X_MAX_POWER; + limitPower(SX128X_MAX_POWER); err = lora.setOutputPower(power); if (err != RADIOLIB_ERR_NONE) From 8061f83704248db1427c52fbbd0be736fac8843d Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 10 Apr 2026 07:21:24 -0500 Subject: [PATCH 50/74] Modify log output to show milliseconds (#10115) Updated timestamp format to include milliseconds. --- src/RedirectablePrint.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 6ff7bbb18..9450f8990 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -137,7 +137,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format, if (color) { ::printf("\u001b[0m"); } - ::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000); + ::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000); #else printf("%s ", logLevel); if (color) { @@ -151,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format, if (color) { ::printf("\u001b[0m"); } - ::printf("| ??:??:?? %u ", millis() / 1000); + ::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000); #else printf("%s ", logLevel); if (color) { From b2c8cbb78db208fe0c83a2c5841256b1da651ae0 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:53:04 +0100 Subject: [PATCH 51/74] Enhance traffic management by adjusting position update interval and refining hop exhaustion logic based on channel congestion (#9921) --- src/mesh/Default.h | 4 ++-- src/modules/TrafficManagementModule.cpp | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 069ffc0eb..59425042e 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -32,8 +32,8 @@ #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 +#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells +#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 6936ef682..1ecb68c4b 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -7,6 +7,7 @@ #include "NodeDB.h" #include "Router.h" #include "TypeConversions.h" +#include "airtime.h" #include "concurrency/LockGuard.h" #include "configuration.h" #include "mesh-pb-constants.h" @@ -1001,7 +1002,11 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) 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); + // Only exhaust telemetry hops when channel is actually congested, mirroring the same + // airtime checks that gate self-generated telemetry in the telemetry modules. + const bool channelBusy = airTime && (!airTime->isTxAllowedChannelUtil(true) || !airTime->isTxAllowedAirUtil()); + const bool shouldExhaust = + ((channelBusy && isTelemetry && cfg.exhaust_hop_telemetry) || (isPosition && cfg.exhaust_hop_position)); if (!shouldExhaust || !isBroadcast(mp.to)) return; From 4990bf51e33de2824abc97bb6e24e3d6b9a672f9 Mon Sep 17 00:00:00 2001 From: Catalin Patulea Date: Fri, 10 Apr 2026 14:20:25 -0700 Subject: [PATCH 52/74] Delete PointerQueue::dequeuePtrFromISR, unused since commit db766f1 (#99). (#10090) --- src/mesh/PointerQueue.h | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/mesh/PointerQueue.h b/src/mesh/PointerQueue.h index b45245eb8..972eb65fd 100644 --- a/src/mesh/PointerQueue.h +++ b/src/mesh/PointerQueue.h @@ -17,14 +17,4 @@ template class PointerQueue : public TypedQueue return this->dequeue(&p, maxWait) ? p : nullptr; } - -#ifdef HAS_FREE_RTOS - // returns a ptr or null if the queue was empty - T *dequeuePtrFromISR(BaseType_t *higherPriWoken) - { - T *p; - - return this->dequeueFromISR(&p, higherPriWoken) ? p : nullptr; - } -#endif }; From 288d7a3e7b4f90ae7ad81391d4cbcb6acac1249b Mon Sep 17 00:00:00 2001 From: Catalin Patulea Date: Sat, 11 Apr 2026 04:35:42 -0700 Subject: [PATCH 53/74] Revert "Add include directive for mbedtls error handling in build flags" (#10073) This reverts commit 391928ed08c267647af641dfc807338d7ea30129. Since esp32_https_server commit e2c9f98, this is no longer necessary. Also, the esp32-common.ini '-include mbedtls/error.h' causes a build error under IDF v5 (https://github.com/meshtastic/firmware/pull/9122#issuecomment-4185767085): : fatal error: mbedtls/error.h: No such file or directory so this change fixes that error. --- variants/esp32/esp32-common.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index db826b3f9..b9d6f5c50 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -39,7 +39,6 @@ build_flags = -Wall -Wextra -Isrc/platform/esp32 - -include mbedtls/error.h -std=gnu++17 -DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG From 4ac74edf387271314df4468a5741bbf24d202415 Mon Sep 17 00:00:00 2001 From: Bob Iannucci Date: Sun, 12 Apr 2026 20:41:25 -0700 Subject: [PATCH 54/74] fix(native): implement BinarySemaphorePosix with proper pthread synchronization (#9895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native): implement BinarySemaphorePosix with proper pthread synchronization The BinarySemaphorePosix class (used on all Linux/portduino/native builds) had stub implementations: give() was a no-op and take() just called delay(msec) and returned false. This broke the cooperative thread scheduler on native platforms — threads could not wake the main loop, radio RX interrupts were missed, and telemetry never transmitted over the mesh. Replace the stubs with a proper binary semaphore using pthread_mutex_t + pthread_cond_t + bool signaled: - take(msec): pthread_cond_timedwait with CLOCK_REALTIME timeout, consumes signal atomically (binary semaphore semantics) - give(): sets signaled=true, signals condition variable - giveFromISR(): delegates to give(), sets pxHigherPriorityTaskWoken Tested on Raspberry Pi 3 Model B (ARM64, Debian Bookworm) with Adafruit LoRa Radio Bonnet (SX1276). Before fix: no radio TX/RX, no telemetry on mesh. After fix: bidirectional LoRa, MQTT gateway, telemetry all working. Co-Authored-By: Claude Opus 4.6 * ARCH_PORTDUINO * Refactor BinarySemaphorePosix header for ARCH_PORTDUINO * Change preprocessor directive from ifndef to ifdef * Gate new Semaphore code to Portduino and fix STM compilation * Binary Semaphore Posix better error handling --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Ben Meadors Co-authored-by: Jonathan Bennett --- src/concurrency/BinarySemaphorePosix.cpp | 78 +++++++++++++++++++++++- src/concurrency/BinarySemaphorePosix.h | 13 +++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/concurrency/BinarySemaphorePosix.cpp b/src/concurrency/BinarySemaphorePosix.cpp index dc49a489b..4bc60c31f 100644 --- a/src/concurrency/BinarySemaphorePosix.cpp +++ b/src/concurrency/BinarySemaphorePosix.cpp @@ -1,10 +1,85 @@ #include "concurrency/BinarySemaphorePosix.h" #include "configuration.h" +#include +#include + #ifndef HAS_FREE_RTOS namespace concurrency { +#ifdef ARCH_PORTDUINO + +BinarySemaphorePosix::BinarySemaphorePosix() +{ + if (pthread_mutex_init(&mutex, NULL) != 0) { + throw std::runtime_error("pthread_mutex_init failed"); + } + if (pthread_cond_init(&cond, NULL) != 0) { + pthread_mutex_destroy(&mutex); + throw std::runtime_error("pthread_cond_init failed"); + } + signaled = false; +} + +BinarySemaphorePosix::~BinarySemaphorePosix() +{ + pthread_cond_destroy(&cond); + pthread_mutex_destroy(&mutex); +} + +/** + * Returns false if we timed out + */ +bool BinarySemaphorePosix::take(uint32_t msec) +{ + pthread_mutex_lock(&mutex); + + if (!signaled) { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + + ts.tv_sec += msec / 1000; + ts.tv_nsec += (msec % 1000) * 1000000L; + if (ts.tv_nsec >= 1000000000L) { + ts.tv_sec += 1; + ts.tv_nsec -= 1000000000L; + } + + while (!signaled) { + int rc = pthread_cond_timedwait(&cond, &mutex, &ts); + if (rc == ETIMEDOUT) + break; + if (rc != 0) { + // Some other error occurred + pthread_mutex_unlock(&mutex); + throw std::runtime_error("pthread_cond_timedwait failed: " + std::to_string(rc)); + } + } + } + + bool wasSignaled = signaled; + signaled = false; // consume the signal (binary semaphore) + + pthread_mutex_unlock(&mutex); + return wasSignaled; +} + +void BinarySemaphorePosix::give() +{ + pthread_mutex_lock(&mutex); + signaled = true; + pthread_cond_signal(&cond); + pthread_mutex_unlock(&mutex); +} + +IRAM_ATTR void BinarySemaphorePosix::giveFromISR(BaseType_t *pxHigherPriorityTaskWoken) +{ + give(); + if (pxHigherPriorityTaskWoken) + *pxHigherPriorityTaskWoken = true; +} +#else BinarySemaphorePosix::BinarySemaphorePosix() {} @@ -22,7 +97,8 @@ bool BinarySemaphorePosix::take(uint32_t msec) void BinarySemaphorePosix::give() {} IRAM_ATTR void BinarySemaphorePosix::giveFromISR(BaseType_t *pxHigherPriorityTaskWoken) {} +#endif } // namespace concurrency -#endif \ No newline at end of file +#endif diff --git a/src/concurrency/BinarySemaphorePosix.h b/src/concurrency/BinarySemaphorePosix.h index 475b29874..80edb567b 100644 --- a/src/concurrency/BinarySemaphorePosix.h +++ b/src/concurrency/BinarySemaphorePosix.h @@ -2,6 +2,10 @@ #include "../freertosinc.h" +#ifdef ARCH_PORTDUINO +#include +#endif + namespace concurrency { @@ -9,7 +13,12 @@ namespace concurrency class BinarySemaphorePosix { - // SemaphoreHandle_t semaphore; + +#ifdef ARCH_PORTDUINO + pthread_mutex_t mutex; + pthread_cond_t cond; + bool signaled; +#endif public: BinarySemaphorePosix(); @@ -27,4 +36,4 @@ class BinarySemaphorePosix #endif -} // namespace concurrency \ No newline at end of file +} // namespace concurrency From 77f378dd539bb6d8d4bc16b1d8577b077802cf72 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Mon, 13 Apr 2026 21:05:23 +0400 Subject: [PATCH 55/74] Reduce key duplication by enabling hardware RNG (#8803) * Reduce key duplication by enabling hardware RNG * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Apply suggestion from @Copilot Use micros() for worst case random seed for nrf52 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Minor cleanup, remove dead code and clarify comment * trunk * Add useRadioEntropy bool, default false. --------- Co-authored-by: Ben Meadors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Jonathan Bennett --- src/mesh/CryptoEngine.cpp | 10 ++ src/mesh/HardwareRNG.cpp | 159 +++++++++++++++++++++++ src/mesh/HardwareRNG.h | 28 ++++ src/mesh/RadioLibInterface.cpp | 20 ++- src/mesh/RadioLibInterface.h | 6 + src/platform/nrf52/main-nrf52.cpp | 18 +-- src/platform/portduino/PortduinoGlue.cpp | 9 +- src/platform/rp2xx0/main-rp2xx0.cpp | 11 +- 8 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 src/mesh/HardwareRNG.cpp create mode 100644 src/mesh/HardwareRNG.h diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 4a613b644..daa7a3a75 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -4,6 +4,7 @@ #include #if !(MESHTASTIC_EXCLUDE_PKI) +#include "HardwareRNG.h" #include "NodeDB.h" #include "aes-ccm.h" #include "meshUtils.h" @@ -26,6 +27,15 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey) { // Mix in any randomness we can, to make key generation stronger. CryptRNG.begin(optstr(APP_VERSION)); + + uint8_t hardwareEntropy[64] = {0}; + if (HardwareRNG::fill(hardwareEntropy, sizeof(hardwareEntropy), true)) { + CryptRNG.stir(hardwareEntropy, sizeof(hardwareEntropy)); + } else { + LOG_WARN("Hardware entropy unavailable, falling back to software RNG"); + } + memset(hardwareEntropy, 0, sizeof(hardwareEntropy)); + if (myNodeInfo.device_id.size == 16) { CryptRNG.stir(myNodeInfo.device_id.bytes, myNodeInfo.device_id.size); } diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp new file mode 100644 index 000000000..b455128ac --- /dev/null +++ b/src/mesh/HardwareRNG.cpp @@ -0,0 +1,159 @@ +#include "HardwareRNG.h" + +#include +#include +#include + +#include "configuration.h" + +#if HAS_RADIO +#include "RadioLibInterface.h" +#endif + +#if defined(ARCH_NRF52) +#include +extern Adafruit_nRFCrypto nRFCrypto; +#elif defined(ARCH_ESP32) +#include +#elif defined(ARCH_RP2040) +#include +#elif defined(ARCH_PORTDUINO) +#include +#include +#include +#endif + +namespace HardwareRNG +{ + +namespace +{ +void fillWithRandomDevice(uint8_t *buffer, size_t length) +{ + std::random_device rd; + size_t offset = 0; + while (offset < length) { + uint32_t value = rd(); + size_t toCopy = std::min(length - offset, sizeof(value)); + memcpy(buffer + offset, &value, toCopy); + offset += toCopy; + } +} + +#if HAS_RADIO +bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) +{ + // Only attempt to pull entropy from the modem if it is initialized and exposes the helper. + // When the radio stack is disabled or has not yet been configured, we simply skip this step + // and return false so callers know no extra mixing occurred. + RadioLibInterface *radio = RadioLibInterface::instance; + if (!radio) { + LOG_ERROR("No radio instance available to provide entropy"); + return false; + } + + constexpr size_t chunkSize = 16; + uint8_t scratch[chunkSize]; + size_t offset = 0; + bool mixed = false; + + while (offset < length) { + size_t toCopy = std::min(length - offset, chunkSize); + + // randomBytes() returns false if the modem does not support it or is not ready + // (for instance, when the radio is powered down). We break immediately to avoid + // blocking or returning partially-filled entropy and simply report failure. + if (!radio->randomBytes(scratch, toCopy)) { + break; + } + + for (size_t i = 0; i < toCopy; ++i) { + buffer[offset + i] ^= scratch[i]; + } + + mixed = true; + offset += toCopy; + } + + // Avoid leaving the modem-sourced bytes sitting on the stack longer than needed. + if (mixed) { + memset(scratch, 0, sizeof(scratch)); + } + + return mixed; +} +#endif +} // namespace + +bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) +{ + if (!buffer || length == 0) { + return false; + } + + bool filled = false; + +#if defined(ARCH_NRF52) + // The Nordic SDK RNG provides cryptographic-quality randomness backed by hardware. + nRFCrypto.begin(); + auto result = nRFCrypto.Random.generate(buffer, length); + nRFCrypto.end(); + filled = result; +#elif defined(ARCH_ESP32) + // ESP32 exposes a true RNG via esp_fill_random(). + esp_fill_random(buffer, length); + filled = true; +#elif defined(ARCH_RP2040) + // RP2040 has a hardware random number generator accessible through the Arduino core. + size_t offset = 0; + while (offset < length) { + uint32_t value = rp2040.hwrand32(); + size_t toCopy = std::min(length - offset, sizeof(value)); + memcpy(buffer + offset, &value, toCopy); + offset += toCopy; + } + filled = true; +#elif defined(ARCH_PORTDUINO) + // Prefer the host OS RNG first when running under Portduino. + ssize_t generated = ::getrandom(buffer, length, 0); + if (generated == static_cast(length)) { + filled = true; + } + + if (!filled) { + fillWithRandomDevice(buffer, length); + filled = true; + } +#endif + + if (!filled) { + // As a last resort, fall back to std::random_device. This should only be reached + // if a platform-specific source was unavailable. + fillWithRandomDevice(buffer, length); + filled = true; + } + +#if HAS_RADIO + if (useRadioEntropy) { + // Best-effort: if the radio is active and can provide modem entropy, XOR it over the + // buffer to improve overall quality. We consider the filling a success if either a + // good platform RNG or the modem RNG provided data, so we return true as long as at + // least one of those steps succeeded. + filled = mixWithLoRaEntropy(buffer, length) || filled; + } +#endif + + return filled; +} + +bool seed(uint32_t &seedOut) +{ + uint32_t candidate = 0; + if (!fill(reinterpret_cast(&candidate), sizeof(candidate), true)) { + return false; + } + seedOut = candidate; + return true; +} + +} // namespace HardwareRNG diff --git a/src/mesh/HardwareRNG.h b/src/mesh/HardwareRNG.h new file mode 100644 index 000000000..2dacb6c23 --- /dev/null +++ b/src/mesh/HardwareRNG.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace HardwareRNG +{ + +/** + * Fill the provided buffer with random bytes sourced from the most + * appropriate hardware-backed RNG available on the current platform. + * + * @param buffer Destination buffer for random bytes + * @param length Number of bytes to write + * @param useRadioEntropy If true, attempt to mix radio entropy into the output as well. + * @return true if the buffer was fully populated with entropy, false on failure + */ +bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy = false); + +/** + * Populate a 32-bit seed value with hardware-backed randomness where possible. + * + * @param seedOut Destination for the generated seed value + * @return true if a seed was produced from a reliable entropy source + */ +bool seed(uint32_t &seedOut); + +} // namespace HardwareRNG diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 30cd587da..7ef707e0d 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -246,6 +246,24 @@ bool RadioLibInterface::findInTxQueue(NodeNum from, PacketId id) return txQueue.find(from, id); } +bool RadioLibInterface::randomBytes(uint8_t *buffer, size_t length) +{ + if (!buffer || length == 0 || !iface) { + return false; + } + + // Older RadioLib versions only expose random(min, max), so fill the buffer byte-by-byte. + for (size_t i = 0; i < length; ++i) { + int32_t value = iface->random(0, 255); + if (value < 0) { + return false; + } + buffer[i] = static_cast(value & 0xFF); + } + + return true; +} + /** radio helper thread callback. We never immediately transmit after any operation (either Rx or Tx). Instead we should wait a random multiple of 'slotTimes' (see definition in RadioInterface.h) taken from a contention window (CW) to lower the chance of collision. @@ -587,4 +605,4 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) return res == RADIOLIB_ERR_NONE; } -} \ No newline at end of file +} diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index ca3d78503..310ca76bb 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -172,6 +172,12 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */ virtual bool findInTxQueue(NodeNum from, PacketId id) override; + /** + * Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface. + * @return true if len bytes were produced, false otherwise. + */ + bool randomBytes(uint8_t *buffer, size_t length); + private: /** if we have something waiting to send, start a short (random) timer so we can come check for collision before actually * doing the transmit */ diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 73780b6eb..5cf3a4465 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -17,6 +17,7 @@ #include #include // #include +#include "HardwareRNG.h" #include "NodeDB.h" #include "PowerMon.h" #include "error.h" @@ -398,15 +399,14 @@ void nrf52Setup() #endif // Init random seed - union seedParts { - uint32_t seed32; - uint8_t seed8[4]; - } seed; - nRFCrypto.begin(); - nRFCrypto.Random.generate(seed.seed8, sizeof(seed.seed8)); - LOG_DEBUG("Set random seed %u", seed.seed32); - randomSeed(seed.seed32); - nRFCrypto.end(); + uint32_t seed = 0; + if (!HardwareRNG::seed(seed)) { + LOG_WARN("Hardware RNG seed unavailable, using PRNG fallback"); + // Use a hardware timer value as a fallback seed for better entropy + seed = micros(); + } + LOG_DEBUG("Set random seed %u", seed); + randomSeed(seed); // Set up nrfx watchdog. Do not enable the watchdog yet (we do that // the first time through the main loop), so that other threads can diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 4bbdbee7a..9e0a1b2a5 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1,4 +1,5 @@ #include "CryptoEngine.h" +#include "HardwareRNG.h" #include "PortduinoGPIO.h" #include "SPIChip.h" #include "mesh/RF95Interface.h" @@ -233,7 +234,9 @@ void portduinoSetup() std::cout << "Running in simulated mode." << std::endl; portduino_config.MaxNodes = 200; // Default to 200 nodes // Set the random seed equal to TCPPort to have a different seed per instance - randomSeed(TCPPort); + uint32_t seed = TCPPort; + HardwareRNG::seed(seed); + randomSeed(seed); return; } @@ -512,7 +515,9 @@ void portduinoSetup() #endif printf("MAC ADDRESS: %02X:%02X:%02X:%02X:%02X:%02X\n", dmac[0], dmac[1], dmac[2], dmac[3], dmac[4], dmac[5]); // Rather important to set this, if not running simulated. - randomSeed(time(NULL)); + uint32_t seed = static_cast(time(NULL)); + HardwareRNG::seed(seed); + randomSeed(seed); std::string defaultGpioChipName = gpioChipName + std::to_string(portduino_config.lora_default_gpiochip); diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index 6c73e385a..e59b0a9cd 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -1,3 +1,4 @@ +#include "HardwareRNG.h" #include "configuration.h" #include "hardware/xosc.h" #include @@ -98,10 +99,12 @@ void getMacAddr(uint8_t *dmac) void rp2040Setup() { - /* Sets a random seed to make sure we get different random numbers on each boot. - Taken from CPU cycle counter and ROSC oscillator, so should be pretty random. - */ - randomSeed(rp2040.hwrand32()); + /* Sets a random seed to make sure we get different random numbers on each boot. */ + uint32_t seed = 0; + if (!HardwareRNG::seed(seed)) { + seed = rp2040.hwrand32(); + } + randomSeed(seed); #ifdef RP2040_SLOW_CLOCK uint f_pll_sys = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_PLL_SYS_CLKSRC_PRIMARY); From d6cf67b6bc176aefc38b637e72bf031a89198903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 14 Apr 2026 12:19:58 +0200 Subject: [PATCH 56/74] Clarify behavior when no radio instance is present Add comment explaining silent behavior when no radio instance is available. --- src/mesh/HardwareRNG.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index b455128ac..f5a805487 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -48,7 +48,8 @@ bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) // and return false so callers know no extra mixing occurred. RadioLibInterface *radio = RadioLibInterface::instance; if (!radio) { - LOG_ERROR("No radio instance available to provide entropy"); + // Intentionally silent: this path runs during portduinoSetup() before the + // console/SerialConsole is initialized, so LOG_* here would dereference a null pointer. return false; } From 125c1b7f13d91b6d9c0dc7504940dd45e1d5b864 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 14 Apr 2026 06:41:04 -0500 Subject: [PATCH 57/74] Make consoleInit() Reentrant, and initialize it earlier on native (#10156) --- src/SerialConsole.cpp | 3 +++ src/platform/portduino/PortduinoGlue.cpp | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index e24aa3c57..2a3f08cbc 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -30,6 +30,9 @@ SerialConsole *console; void consoleInit() { + if (console) { + return; + } auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread #if defined(SERIAL_HAS_ON_RECEIVE) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 9e0a1b2a5..5f51ee083 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -182,6 +182,10 @@ void portduinoSetup() // Force stdout to be line buffered setvbuf(stdout, stdoutBuffer, _IOLBF, sizeof(stdoutBuffer)); + // We do this super early so that we can log from the rest of the init code + concurrency::hasBeenSetup = true; + consoleInit(); + if (portduino_config.force_simradio == true) { portduino_config.lora_module = use_simradio; } else if (configPath != nullptr) { From 01bd4cfb73bb7bc20ea4cf08c36d66c45b4bea35 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Tue, 14 Apr 2026 19:46:33 +0800 Subject: [PATCH 58/74] feat(stm32wl): add reboot-to-bootloader support via enter_dfu_mode_request (#10158) --- src/modules/AdminModule.cpp | 2 +- src/platform/stm32wl/main-stm32wl.cpp | 51 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 9a52a9aff..05bc0aa5d 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -480,7 +480,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #if HAS_SCREEN IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0)); #endif -#if defined(ARCH_NRF52) || defined(ARCH_RP2040) +#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) enterDfuMode(); #endif break; diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 93080f840..241020126 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -4,6 +4,57 @@ #include #include +// ─── Bootloader redirect ────────────────────────────────────────────────────── +// +// Why .noinit + constructor instead of TAMP backup registers: +// +// The STM32duino startup sequence initialises clocks which may call +// __HAL_RCC_BACKUPRESET_FORCE/RELEASE when configuring the LSE oscillator, +// wiping the entire backup domain (including TAMP->BKP0R) before setup() +// ever runs. The backup-register approach therefore cannot reliably survive +// a soft reset in this toolchain. +// +// Solution: store the magic in a .noinit SRAM variable. +// - NVIC_SystemReset() does NOT clear SRAM. +// - The linker script skips zero-init for .noinit sections. +// - __attribute__((constructor)) fires before main()/HAL_Init(), so we can +// intercept and jump before anything disturbs peripheral state. + +#define BOOTLOADER_MAGIC 0xD00DB007UL +#define SYS_MEM_BASE 0x1FFF0000UL + +// Placed in .noinit — not zeroed at startup, survives NVIC_SystemReset(). +__attribute__((section(".noinit"), used)) volatile uint32_t g_bootloaderMagic; + +// Fires before main() / HAL_Init(). Must use only core Cortex-M registers. +__attribute__((constructor(101), used)) static void earlyBootCheck(void) +{ + if (g_bootloaderMagic != BOOTLOADER_MAGIC) + return; + g_bootloaderMagic = 0; + + SysTick->CTRL = 0; + SysTick->LOAD = 0; + SysTick->VAL = 0; + for (int i = 0; i < 8; i++) { + NVIC->ICER[i] = 0xFFFFFFFF; + NVIC->ICPR[i] = 0xFFFFFFFF; + } + __DSB(); + __ISB(); + SCB->VTOR = SYS_MEM_BASE; + __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); + ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); + while (1) + ; +} + +void enterDfuMode() +{ + g_bootloaderMagic = BOOTLOADER_MAGIC; + HAL_NVIC_SystemReset(); +} + void setBluetoothEnable(bool enable) {} void playStartMelody() {} From 00fccd87f9f8776f5b9dec0a898bbbd9a2fa66ef Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:27:05 +0200 Subject: [PATCH 59/74] Update protobufs (#10161) Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/atak.pb.cpp | 51 + src/mesh/generated/meshtastic/atak.pb.h | 928 ++++++++++++++++++- src/mesh/generated/meshtastic/mesh.pb.cpp | 5 + src/mesh/generated/meshtastic/mesh.pb.h | 80 +- src/mesh/generated/meshtastic/portnums.pb.h | 2 + src/mesh/generated/meshtastic/telemetry.pb.h | 21 +- 7 files changed, 1064 insertions(+), 25 deletions(-) diff --git a/protobufs b/protobufs index e30092e61..940ac382a 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit e30092e6168b13341c2b7ec4be19c789ad5cd77f +Subproject commit 940ac382a7d143040da5a880237f84c48ee31f2b diff --git a/src/mesh/generated/meshtastic/atak.pb.cpp b/src/mesh/generated/meshtastic/atak.pb.cpp index bbafa33e2..3f1adea53 100644 --- a/src/mesh/generated/meshtastic/atak.pb.cpp +++ b/src/mesh/generated/meshtastic/atak.pb.cpp @@ -27,6 +27,33 @@ PB_BIND(meshtastic_PLI, meshtastic_PLI, AUTO) PB_BIND(meshtastic_AircraftTrack, meshtastic_AircraftTrack, AUTO) +PB_BIND(meshtastic_CotGeoPoint, meshtastic_CotGeoPoint, AUTO) + + +PB_BIND(meshtastic_DrawnShape, meshtastic_DrawnShape, 2) + + +PB_BIND(meshtastic_Marker, meshtastic_Marker, AUTO) + + +PB_BIND(meshtastic_RangeAndBearing, meshtastic_RangeAndBearing, AUTO) + + +PB_BIND(meshtastic_Route, meshtastic_Route, 2) + + +PB_BIND(meshtastic_Route_Link, meshtastic_Route_Link, AUTO) + + +PB_BIND(meshtastic_CasevacReport, meshtastic_CasevacReport, AUTO) + + +PB_BIND(meshtastic_EmergencyAlert, meshtastic_EmergencyAlert, AUTO) + + +PB_BIND(meshtastic_TaskRequest, meshtastic_TaskRequest, AUTO) + + PB_BIND(meshtastic_TAKPacketV2, meshtastic_TAKPacketV2, 2) @@ -41,3 +68,27 @@ 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 c12b042f4..d25fa6543 100644 --- a/src/mesh/generated/meshtastic/atak.pb.h +++ b/src/mesh/generated/meshtastic/atak.pb.h @@ -241,7 +241,91 @@ typedef enum _meshtastic_CotType { /* 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_CotType_b_f_t_a = 75, + /* u-d-f-m: Freehand telestration / annotation. Anchor at event point, + geometry carried via DrawnShape.vertices. May be truncated to + MAX_VERTICES by the sender. */ + meshtastic_CotType_CotType_u_d_f_m = 76, + /* u-d-p: Closed polygon. Geometry carried via DrawnShape.vertices, + implicitly closed (receiver duplicates first vertex as needed). */ + meshtastic_CotType_CotType_u_d_p = 77, + /* b-m-p-s-m: Spot map marker (colored dot at a point of interest). */ + meshtastic_CotType_CotType_b_m_p_s_m = 78, + /* b-m-p-c: Checkpoint (intermediate route control point). */ + meshtastic_CotType_CotType_b_m_p_c = 79, + /* u-r-b-c-c: Ranging circle (range rings centered on the event point). */ + meshtastic_CotType_CotType_u_r_b_c_c = 80, + /* u-r-b-bullseye: Bullseye with configurable range rings and bearing + reference (magnetic / true / grid). */ + meshtastic_CotType_CotType_u_r_b_bullseye = 81, + /* a-f-G-E-V-A: Friendly armored vehicle, user-selectable self PLI. */ + meshtastic_CotType_CotType_a_f_G_E_V_A = 82, + /* a-n-A: Neutral aircraft (friendly/hostile/unknown already present). */ + meshtastic_CotType_CotType_a_n_A = 83, + /* --- 2525 quick-drop: artillery (4) ---------------------------------- */ + meshtastic_CotType_CotType_a_u_G_U_C_F = 84, + meshtastic_CotType_CotType_a_n_G_U_C_F = 85, + meshtastic_CotType_CotType_a_h_G_U_C_F = 86, + meshtastic_CotType_CotType_a_f_G_U_C_F = 87, + /* --- 2525 quick-drop: building (4) ----------------------------------- */ + meshtastic_CotType_CotType_a_u_G_I = 88, + meshtastic_CotType_CotType_a_n_G_I = 89, + meshtastic_CotType_CotType_a_h_G_I = 90, + meshtastic_CotType_CotType_a_f_G_I = 91, + /* --- 2525 quick-drop: mine (4) --------------------------------------- */ + meshtastic_CotType_CotType_a_u_G_E_X_M = 92, + meshtastic_CotType_CotType_a_n_G_E_X_M = 93, + meshtastic_CotType_CotType_a_h_G_E_X_M = 94, + meshtastic_CotType_CotType_a_f_G_E_X_M = 95, + /* --- 2525 quick-drop: ship (3; a-f-S already at 17) ------------------ */ + meshtastic_CotType_CotType_a_u_S = 96, + meshtastic_CotType_CotType_a_n_S = 97, + meshtastic_CotType_CotType_a_h_S = 98, + /* --- 2525 quick-drop: sniper (4) ------------------------------------- */ + meshtastic_CotType_CotType_a_u_G_U_C_I_d = 99, + meshtastic_CotType_CotType_a_n_G_U_C_I_d = 100, + meshtastic_CotType_CotType_a_h_G_U_C_I_d = 101, + meshtastic_CotType_CotType_a_f_G_U_C_I_d = 102, + /* --- 2525 quick-drop: tank (4) --------------------------------------- */ + meshtastic_CotType_CotType_a_u_G_E_V_A_T = 103, + meshtastic_CotType_CotType_a_n_G_E_V_A_T = 104, + meshtastic_CotType_CotType_a_h_G_E_V_A_T = 105, + meshtastic_CotType_CotType_a_f_G_E_V_A_T = 106, + /* --- 2525 quick-drop: troops (3; a-f-G-U-C-I already at 2) ----------- */ + meshtastic_CotType_CotType_a_u_G_U_C_I = 107, + meshtastic_CotType_CotType_a_n_G_U_C_I = 108, + meshtastic_CotType_CotType_a_h_G_U_C_I = 109, + /* --- 2525 quick-drop: generic vehicle (3; a-u-G-E-V already at 69) --- */ + meshtastic_CotType_CotType_a_n_G_E_V = 110, + meshtastic_CotType_CotType_a_h_G_E_V = 111, + meshtastic_CotType_CotType_a_f_G_E_V = 112, + /* b-m-p-w-GOTO: Go To / bloodhound navigation target. */ + meshtastic_CotType_CotType_b_m_p_w_GOTO = 113, + /* b-m-p-c-ip: Initial point (mission planning). */ + meshtastic_CotType_CotType_b_m_p_c_ip = 114, + /* b-m-p-c-cp: Contact point (mission planning). */ + meshtastic_CotType_CotType_b_m_p_c_cp = 115, + /* b-m-p-s-p-op: Observation post. */ + meshtastic_CotType_CotType_b_m_p_s_p_op = 116, + /* u-d-v: 2D vehicle outline drawn on the map. */ + meshtastic_CotType_CotType_u_d_v = 117, + /* u-d-v-m: 3D vehicle model reference. */ + meshtastic_CotType_CotType_u_d_v_m = 118, + /* u-d-c-e: Non-circular ellipse (circle with distinct major/minor axes). */ + meshtastic_CotType_CotType_u_d_c_e = 119, + /* b-i-x-i: Quick Pic geotagged image marker. The image itself does not + ride on LoRa; this event references the image via iconset metadata. */ + meshtastic_CotType_CotType_b_i_x_i = 120, + /* b-t-f-d: GeoChat delivered receipt. Carried on the existing `chat` + payload_variant via GeoChat.receipt_for_uid + receipt_type. */ + meshtastic_CotType_CotType_b_t_f_d = 121, + /* b-t-f-r: GeoChat read receipt. Same wire slot as b-t-f-d. */ + meshtastic_CotType_CotType_b_t_f_r = 122, + /* b-a-o-c: Custom / generic emergency beacon. */ + meshtastic_CotType_CotType_b_a_o_c = 123, + /* t-s: Task / engage request. Structured payload carried via the new + TaskRequest typed variant. */ + meshtastic_CotType_CotType_t_s = 124 } meshtastic_CotType; /* Geopoint and altitude source */ @@ -256,10 +340,191 @@ typedef enum _meshtastic_GeoPointSource { meshtastic_GeoPointSource_GeoPointSource_NETWORK = 3 } meshtastic_GeoPointSource; +/* Receipt discriminator. Set alongside cot_type_id = b-t-f-d (delivered) + or b-t-f-r (read). ReceiptType_None is the default for a normal chat + message (cot_type_id = b-t-f). + + Receivers can detect a receipt by checking receipt_type != ReceiptType_None + without re-parsing the envelope cot_type_id. */ +typedef enum _meshtastic_GeoChat_ReceiptType { + meshtastic_GeoChat_ReceiptType_ReceiptType_None = 0, /* normal chat message */ + meshtastic_GeoChat_ReceiptType_ReceiptType_Delivered = 1, /* b-t-f-d delivered receipt */ + meshtastic_GeoChat_ReceiptType_ReceiptType_Read = 2 /* b-t-f-r read receipt */ +} meshtastic_GeoChat_ReceiptType; + +/* Shape kind discriminator. Drives receiver rendering and also controls + which optional fields below are meaningful. */ +typedef enum _meshtastic_DrawnShape_Kind { + /* Unspecified (do not use on the wire) */ + meshtastic_DrawnShape_Kind_Kind_Unspecified = 0, + /* u-d-c-c: User-drawn circle (uses major/minor/angle, anchor = event point) */ + meshtastic_DrawnShape_Kind_Kind_Circle = 1, + /* u-d-r: User-drawn rectangle (uses vertices = 4 corners) */ + meshtastic_DrawnShape_Kind_Kind_Rectangle = 2, + /* u-d-f: User-drawn polyline (uses vertices, not closed) */ + meshtastic_DrawnShape_Kind_Kind_Freeform = 3, + /* u-d-f-m: Freehand telestration / annotation (uses vertices, may be truncated) */ + meshtastic_DrawnShape_Kind_Kind_Telestration = 4, + /* u-d-p: Closed polygon (uses vertices, implicitly closed) */ + meshtastic_DrawnShape_Kind_Kind_Polygon = 5, + /* u-r-b-c-c: Ranging circle (major/minor/angle, stroke + optional fill) */ + meshtastic_DrawnShape_Kind_Kind_RangingCircle = 6, + /* u-r-b-bullseye: Bullseye ring with range rings and bearing reference */ + meshtastic_DrawnShape_Kind_Kind_Bullseye = 7, + /* u-d-c-e: Ellipse with distinct major/minor axes (same storage as + Kind_Circle — uses major_cm/minor_cm/angle_deg — but receivers + render it as a non-circular ellipse rather than a round circle). */ + meshtastic_DrawnShape_Kind_Kind_Ellipse = 8, + /* u-d-v: 2D vehicle outline drawn on the map. Vertices carry the + outline polygon; receivers draw it as a filled polygon. */ + meshtastic_DrawnShape_Kind_Kind_Vehicle2D = 9, + /* u-d-v-m: 3D vehicle model reference. Same vertex polygon as + Kind_Vehicle2D; receivers that support 3D rendering extrude it. */ + meshtastic_DrawnShape_Kind_Kind_Vehicle3D = 10 +} meshtastic_DrawnShape_Kind; + +/* Explicit stroke/fill/both discriminator. + + ATAK's source XML distinguishes "stroke-only polyline" from "closed shape + with both stroke and fill" by the presence of the element. + Both states can hash to all-zero color fields, so we carry the signal + explicitly. Parser sets this from (sawStrokeColor, sawFillColor) at the + end of parse; builder uses it to decide which of / + to emit in the reconstructed XML. */ +typedef enum _meshtastic_DrawnShape_StyleMode { + /* Unspecified — receiver infers from which color fields are non-zero. */ + meshtastic_DrawnShape_StyleMode_StyleMode_Unspecified = 0, + /* Stroke only. No in the source XML. Used for polylines, + ranging lines, bullseye rings. */ + meshtastic_DrawnShape_StyleMode_StyleMode_StrokeOnly = 1, + /* Fill only. No in the source XML. Rare but valid in + ATAK (solid region with no outline). */ + meshtastic_DrawnShape_StyleMode_StyleMode_FillOnly = 2, + /* Both stroke and fill present. Closed shapes: circle, rectangle, + polygon, ranging circle. */ + meshtastic_DrawnShape_StyleMode_StyleMode_StrokeAndFill = 3 +} meshtastic_DrawnShape_StyleMode; + +/* Marker kind. Used to pick sensible receiver defaults when the CoT type + alone is ambiguous (e.g. a-u-G could be a 2525 symbol or a custom icon + depending on the iconset path). */ +typedef enum _meshtastic_Marker_Kind { + /* Unspecified — fall back to TAKPacketV2.cot_type_id */ + meshtastic_Marker_Kind_Kind_Unspecified = 0, + /* b-m-p-s-m: Spot map marker */ + meshtastic_Marker_Kind_Kind_Spot = 1, + /* b-m-p-w: Route waypoint */ + meshtastic_Marker_Kind_Kind_Waypoint = 2, + /* b-m-p-c: Checkpoint */ + meshtastic_Marker_Kind_Kind_Checkpoint = 3, + /* b-m-p-s-p-i / b-m-p-s-p-loc: Self-position marker */ + meshtastic_Marker_Kind_Kind_SelfPosition = 4, + /* 2525B/C military symbol (iconsetpath = COT_MAPPING_2525B/...) */ + meshtastic_Marker_Kind_Kind_Symbol2525 = 5, + /* COT_MAPPING_SPOTMAP icon (e.g. colored dot) */ + meshtastic_Marker_Kind_Kind_SpotMap = 6, + /* Custom icon set (UUID/GroupName/filename.png) */ + meshtastic_Marker_Kind_Kind_CustomIcon = 7, + /* b-m-p-w-GOTO: Go To / bloodhound navigation waypoint. */ + meshtastic_Marker_Kind_Kind_GoToPoint = 8, + /* b-m-p-c-ip: Initial point (mission planning control point). */ + meshtastic_Marker_Kind_Kind_InitialPoint = 9, + /* b-m-p-c-cp: Contact point (mission planning control point). */ + meshtastic_Marker_Kind_Kind_ContactPoint = 10, + /* b-m-p-s-p-op: Observation post. */ + meshtastic_Marker_Kind_Kind_ObservationPost = 11, + /* b-i-x-i: Quick Pic geotagged image marker. iconset carries the + image reference (local filename or remote URL); the image itself + does not ride on the LoRa wire. */ + meshtastic_Marker_Kind_Kind_ImageMarker = 12 +} meshtastic_Marker_Kind; + +/* Travel method for the route. */ +typedef enum _meshtastic_Route_Method { + /* Unspecified / unknown */ + meshtastic_Route_Method_Method_Unspecified = 0, + /* Driving / vehicle */ + meshtastic_Route_Method_Method_Driving = 1, + /* Walking / foot */ + meshtastic_Route_Method_Method_Walking = 2, + /* Flying */ + meshtastic_Route_Method_Method_Flying = 3, + /* Swimming (individual) */ + meshtastic_Route_Method_Method_Swimming = 4, + /* Watercraft (boat) */ + meshtastic_Route_Method_Method_Watercraft = 5 +} meshtastic_Route_Method; + +/* Route direction (infil = ingress, exfil = egress). */ +typedef enum _meshtastic_Route_Direction { + /* Unspecified */ + meshtastic_Route_Direction_Direction_Unspecified = 0, + /* Infiltration (ingress) */ + meshtastic_Route_Direction_Direction_Infil = 1, + /* Exfiltration (egress) */ + meshtastic_Route_Direction_Direction_Exfil = 2 +} meshtastic_Route_Direction; + +/* Line 3: precedence / urgency. */ +typedef enum _meshtastic_CasevacReport_Precedence { + meshtastic_CasevacReport_Precedence_Precedence_Unspecified = 0, + meshtastic_CasevacReport_Precedence_Precedence_Urgent = 1, /* A - immediate, life-threatening */ + meshtastic_CasevacReport_Precedence_Precedence_UrgentSurgical = 2, /* B - needs surgery */ + meshtastic_CasevacReport_Precedence_Precedence_Priority = 3, /* C - within 4 hours */ + meshtastic_CasevacReport_Precedence_Precedence_Routine = 4, /* D - within 24 hours */ + meshtastic_CasevacReport_Precedence_Precedence_Convenience = 5 /* E - convenience */ +} meshtastic_CasevacReport_Precedence; + +/* Line 7: HLZ marking method. */ +typedef enum _meshtastic_CasevacReport_HlzMarking { + meshtastic_CasevacReport_HlzMarking_HlzMarking_Unspecified = 0, + meshtastic_CasevacReport_HlzMarking_HlzMarking_Panels = 1, + meshtastic_CasevacReport_HlzMarking_HlzMarking_PyroSignal = 2, + meshtastic_CasevacReport_HlzMarking_HlzMarking_Smoke = 3, + meshtastic_CasevacReport_HlzMarking_HlzMarking_None = 4, + meshtastic_CasevacReport_HlzMarking_HlzMarking_Other = 5 +} meshtastic_CasevacReport_HlzMarking; + +/* Line 6: security situation at the pickup zone. */ +typedef enum _meshtastic_CasevacReport_Security { + meshtastic_CasevacReport_Security_Security_Unspecified = 0, + meshtastic_CasevacReport_Security_Security_NoEnemy = 1, /* N - no enemy activity */ + meshtastic_CasevacReport_Security_Security_PossibleEnemy = 2, /* P - possible enemy */ + meshtastic_CasevacReport_Security_Security_EnemyInArea = 3, /* E - enemy, approach with caution */ + meshtastic_CasevacReport_Security_Security_EnemyInArmedContact = 4 /* X - armed escort required */ +} meshtastic_CasevacReport_Security; + +typedef enum _meshtastic_EmergencyAlert_Type { + meshtastic_EmergencyAlert_Type_Type_Unspecified = 0, + meshtastic_EmergencyAlert_Type_Type_Alert911 = 1, /* b-a-o-tbl */ + meshtastic_EmergencyAlert_Type_Type_RingTheBell = 2, /* b-a-o-pan */ + meshtastic_EmergencyAlert_Type_Type_InContact = 3, /* b-a-o-opn */ + meshtastic_EmergencyAlert_Type_Type_GeoFenceBreached = 4, /* b-a-g */ + meshtastic_EmergencyAlert_Type_Type_Custom = 5, /* b-a-o-c */ + meshtastic_EmergencyAlert_Type_Type_Cancel = 6 /* b-a-o-can */ +} meshtastic_EmergencyAlert_Type; + +typedef enum _meshtastic_TaskRequest_Priority { + meshtastic_TaskRequest_Priority_Priority_Unspecified = 0, + meshtastic_TaskRequest_Priority_Priority_Low = 1, + meshtastic_TaskRequest_Priority_Priority_Normal = 2, + meshtastic_TaskRequest_Priority_Priority_High = 3, + meshtastic_TaskRequest_Priority_Priority_Critical = 4 +} meshtastic_TaskRequest_Priority; + +typedef enum _meshtastic_TaskRequest_Status { + meshtastic_TaskRequest_Status_Status_Unspecified = 0, + meshtastic_TaskRequest_Status_Status_Pending = 1, /* assigned, not yet acknowledged */ + meshtastic_TaskRequest_Status_Status_Acknowledged = 2, /* assignee has seen it */ + meshtastic_TaskRequest_Status_Status_InProgress = 3, /* assignee is working it */ + meshtastic_TaskRequest_Status_Status_Completed = 4, /* task done */ + meshtastic_TaskRequest_Status_Status_Cancelled = 5 /* cancelled before completion */ +} meshtastic_TaskRequest_Status; + /* Struct definitions */ /* ATAK GeoChat message */ typedef struct _meshtastic_GeoChat { - /* The text message */ + /* The text message. Empty for receipts. */ char message[200]; /* Uid recipient of the message */ bool has_to; @@ -267,6 +532,14 @@ typedef struct _meshtastic_GeoChat { /* Callsign of the recipient for the message */ bool has_to_callsign; char to_callsign[120]; + /* UID of the chat message this event is acknowledging. Empty for a + normal chat message; set for delivered / read receipts. Paired with + receipt_type so receivers can match the ack back to the original + outbound GeoChat by its event uid. */ + char receipt_for_uid[48]; + /* Receipt kind discriminator. See ReceiptType doc. Default ReceiptType_None + means this is a regular chat message, not a receipt. */ + meshtastic_GeoChat_ReceiptType receipt_type; } meshtastic_GeoChat; /* ATAK Group @@ -360,6 +633,284 @@ typedef struct _meshtastic_AircraftTrack { char cot_host_id[64]; } meshtastic_AircraftTrack; +/* Compact geographic vertex used by repeated vertex lists in TAK geometry + payloads. Named with a `Cot` prefix to avoid a namespace collision with + `meshtastic.GeoPoint` in `device_ui.proto`, which is an unrelated zoom/ + latitude/longitude type used by the on-device map UI. + + Encoded as a signed DELTA from TAKPacketV2.latitude_i / longitude_i (the + enclosing event's anchor point). The absolute coordinate is recovered by + the receiver as `event.latitude_i + vertex.lat_delta_i` (and likewise for + longitude). + + Why deltas: a 32-vertex telestration with vertices clustered within a few + hundred meters of the anchor has per-vertex deltas in the ±10^4 range. + Under sint32+zigzag those encode as 2 bytes each (tag+varint), versus the + 4 bytes that sfixed32 would always require. At 32 vertices that is ~128 + bytes of savings — the difference between fitting under the LoRa MTU or + not. Absolute coordinates (values ~10^9) would cost sint32 varint 5 bytes + per field, which is why TAKPacketV2's top-level latitude_i / longitude_i + stay sfixed32 — only small values win with sint32. */ +typedef struct _meshtastic_CotGeoPoint { + /* Latitude delta from TAKPacketV2.latitude_i, in 1e-7 degree units. + Add to the enclosing event's latitude_i to recover the absolute latitude. */ + int32_t lat_delta_i; + /* Longitude delta from TAKPacketV2.longitude_i, in 1e-7 degree units. */ + int32_t lon_delta_i; +} meshtastic_CotGeoPoint; + +/* User-drawn tactical graphic: circle, rectangle, polygon, polyline, freehand + telestration, ranging circle, or bullseye. + + Covers CoT types u-d-c-c, u-d-r, u-d-f, u-d-f-m, u-d-p, u-r-b-c-c, + u-r-b-bullseye. The shape's anchor position is carried on + TAKPacketV2.latitude_i/longitude_i; polyline/polygon vertices are in the + `vertices` repeated field as `CotGeoPoint` deltas from that anchor. + + Colors use the Team enum as a 14-color palette (see color encoding below) + with a fixed32 exact-ARGB fallback for custom user-picked colors that + don't map to a palette entry. */ +typedef struct _meshtastic_DrawnShape { + /* Shape kind (circle, rectangle, freeform, etc.) */ + meshtastic_DrawnShape_Kind kind; + /* Explicit stroke/fill/both discriminator. See StyleMode doc. */ + meshtastic_DrawnShape_StyleMode style; + /* Ellipse major radius in centimeters. 0 for non-ellipse kinds. */ + uint32_t major_cm; + /* Ellipse minor radius in centimeters. 0 for non-ellipse kinds. */ + uint32_t minor_cm; + /* Ellipse rotation angle in degrees. Valid values are 0..360 inclusive; + 0 and 360 are equivalent rotations. In proto3, an unset uint32 reads + as 0, so senders should emit 0 when the angle is unspecified. */ + uint16_t angle_deg; + /* Stroke color as a named palette entry from the Team enum. If + Unspecifed_Color, the exact ARGB is carried in stroke_argb. + Valid only when style is StrokeOnly or StrokeAndFill. */ + meshtastic_Team stroke_color; + /* Stroke color as an exact 32-bit ARGB bit pattern. Always populated + on the wire; readers MUST use this value when stroke_color == + Unspecifed_Color and MAY use it to recover the exact original bytes + even when a palette entry is set. */ + uint32_t stroke_argb; + /* Stroke weight in tenths of a unit (e.g. 30 = 3.0). Typical ATAK + range 10..60. */ + uint16_t stroke_weight_x10; + /* Fill color as a named palette entry. See stroke_color docs. + Valid only when style is FillOnly or StrokeAndFill. */ + meshtastic_Team fill_color; + /* Fill color exact ARGB fallback. See stroke_argb docs. */ + uint32_t fill_argb; + /* Whether labels are rendered on this shape. */ + bool labels_on; + /* Vertex list for polyline/polygon/rectangle shapes. Capped at 32 by + the nanopb pool; senders MUST truncate longer inputs and set + `truncated = true`. */ + pb_size_t vertices_count; + meshtastic_CotGeoPoint vertices[32]; + /* True if the sender truncated `vertices` to fit the pool. */ + bool truncated; /* --- Bullseye-only fields. All ignored unless kind == Kind_Bullseye. --- */ + /* Bullseye distance in meters * 10 (e.g. 3285 = 328.5 m). 0 = unset. */ + uint32_t bullseye_distance_dm; + /* Bullseye bearing reference: 0 unset, 1 Magnetic, 2 True, 3 Grid. */ + uint8_t bullseye_bearing_ref; + /* Bullseye attribute bit flags: + bit 0: rangeRingVisible + bit 1: hasRangeRings + bit 2: edgeToCenter + bit 3: mils */ + uint8_t bullseye_flags; + /* Bullseye reference UID (anchor marker). Empty = anchor is self. */ + char bullseye_uid_ref[48]; +} meshtastic_DrawnShape; + +/* Fixed point of interest: spot marker, waypoint, checkpoint, 2525 symbol, + or custom icon. + + Covers CoT types b-m-p-s-m, b-m-p-w, b-m-p-c, b-m-p-s-p-i, b-m-p-s-p-loc, + plus a-u-G / a-f-G / a-h-G / a-n-G with iconset paths. The marker position + is carried on TAKPacketV2.latitude_i/longitude_i; fields below carry only + the marker-specific metadata. */ +typedef struct _meshtastic_Marker { + /* Marker kind */ + meshtastic_Marker_Kind kind; + /* Marker color as a named palette entry. If Unspecifed_Color, the exact + ARGB is in color_argb. */ + meshtastic_Team color; + /* Marker color exact ARGB bit pattern. Always populated on the wire. */ + uint32_t color_argb; + /* Status readiness flag (ATAK ). */ + bool readiness; + /* Parent link UID (ATAK ). Empty = no parent. + For spot/waypoint markers this is typically the producing TAK user's UID. */ + char parent_uid[48]; + /* Parent CoT type (e.g. "a-f-G-U-C"). Usually the parent TAK user's type. */ + char parent_type[24]; + /* Parent callsign (e.g. "HOPE"). */ + char parent_callsign[24]; + /* Iconset path stored verbatim. ATAK emits three flavors: + Kind_Symbol2525 -> "COT_MAPPING_2525B//" + Kind_SpotMap -> "COT_MAPPING_SPOTMAP//" + Kind_CustomIcon -> "//.png" + Stored end-to-end without prefix stripping; the ~19 bytes saved by + stripping well-known prefixes are not worth the builder-side bug + surface, and the dict compresses the repetition effectively. */ + char iconset[80]; +} meshtastic_Marker; + +/* Range and bearing measurement line from the event anchor to a target point. + + Covers CoT type u-rb-a. The anchor position is on + TAKPacketV2.latitude_i/longitude_i; the target endpoint is carried as a + CotGeoPoint — same delta-from-anchor encoding used by DrawnShape.vertices + so a self-anchored RAB (common case) encodes in zero bytes. */ +typedef struct _meshtastic_RangeAndBearing { + /* Target/anchor endpoint (delta-encoded from TAKPacketV2.latitude_i/longitude_i). */ + bool has_anchor; + meshtastic_CotGeoPoint anchor; + /* Anchor UID (from ). Empty = free-standing. */ + char anchor_uid[48]; + /* Range in centimeters (value * 100). Range 0..4294 km. */ + uint32_t range_cm; + /* Bearing in degrees * 100 (0..36000). */ + uint16_t bearing_cdeg; + /* Stroke color as a Team palette entry. See DrawnShape.stroke_color doc. */ + meshtastic_Team stroke_color; + /* Stroke color exact ARGB fallback. */ + uint32_t stroke_argb; + /* Stroke weight * 10 (e.g. 30 = 3.0). */ + uint16_t stroke_weight_x10; +} meshtastic_RangeAndBearing; + +/* Route waypoint or control point. Each link corresponds to one ATAK + entry inside the b-m-r event. */ +typedef struct _meshtastic_Route_Link { + /* Waypoint position (delta-encoded from TAKPacketV2.latitude_i/longitude_i). */ + bool has_point; + meshtastic_CotGeoPoint point; + /* Optional UID (empty = receiver derives). */ + char uid[48]; + /* Optional display callsign (e.g. "CP1"). Empty for unnamed control points. */ + char callsign[16]; + /* Link role: 0 = waypoint (b-m-p-w), 1 = checkpoint (b-m-p-c). */ + uint8_t link_type; +} meshtastic_Route_Link; + +/* Named route consisting of ordered waypoints and control points. + + Covers CoT type b-m-r. The first waypoint's position is on + TAKPacketV2.latitude_i/longitude_i; subsequent waypoints and checkpoints + are in `links`. Link count is capped at 16 by the nanopb pool; senders + MUST truncate longer routes and set `truncated = true`. */ +typedef struct _meshtastic_Route { + /* Travel method */ + meshtastic_Route_Method method; + /* Direction (infil/exfil) */ + meshtastic_Route_Direction direction; + /* Waypoint name prefix (e.g. "CP"). */ + char prefix[8]; + /* Stroke weight * 10 (e.g. 30 = 3.0). 0 = default. */ + uint16_t stroke_weight_x10; + /* Ordered list of route control points. Capped at 16. */ + pb_size_t links_count; + meshtastic_Route_Link links[16]; + /* True if the sender truncated `links` to fit the pool. */ + bool truncated; +} meshtastic_Route; + +/* 9-line MEDEVAC request (CoT type b-r-f-h-c). + + Mirrors the ATAK MedLine tool's <_medevac_> detail element. Every field + is optional (proto3 default); senders omit lines they don't have. The + envelope (TAKPacketV2.uid, cot_type_id=b-r-f-h-c, latitude_i/longitude_i, + altitude, callsign) carries Line 1 (location) and Line 2 (callsign). + + All numeric fields are tight varints so a complete 9-line request fits + in well under 100 bytes of proto on the wire. */ +typedef struct _meshtastic_CasevacReport { + /* Line 3: precedence / urgency. */ + meshtastic_CasevacReport_Precedence precedence; + /* Line 4: special equipment required, as a bitfield. + bit 0: none + bit 1: hoist + bit 2: extraction equipment + bit 3: ventilator + bit 4: blood */ + uint8_t equipment_flags; + /* Line 5: number of litter (stretcher-bound) patients. */ + uint8_t litter_patients; + /* Line 5: number of ambulatory (walking-wounded) patients. */ + uint8_t ambulatory_patients; + /* Line 6: security situation at the PZ. */ + meshtastic_CasevacReport_Security security; + /* Line 7: HLZ marking method. */ + meshtastic_CasevacReport_HlzMarking hlz_marking; + /* Line 7 supplementary: short free-text describing the zone marker + (e.g. "Green smoke", "VS-17 panel west"). Capped tight in options. */ + char zone_marker[16]; + /* --- Line 8: patient nationality counts --- */ + uint8_t us_military; + uint8_t us_civilian; + uint8_t non_us_military; + uint8_t non_us_civilian; + uint8_t epw; /* enemy prisoner of war */ + uint8_t child; + /* Line 9: terrain and obstacles at the PZ, as a bitfield. + bit 0: slope + bit 1: rough + bit 2: loose + bit 3: trees + bit 4: wires + bit 5: other */ + uint8_t terrain_flags; + /* Line 2: radio frequency / callsign metadata (e.g. "38.90 Mhz" or + "Victor 6"). Capped tight in options. */ + char frequency[16]; +} meshtastic_CasevacReport; + +/* Emergency alert / 911 beacon (CoT types b-a-o-tbl, b-a-o-pan, b-a-o-opn, + b-a-o-can, b-a-o-c, b-a-g). + + Small, high-priority structured record. The CoT type string is still set + on cot_type_id so receivers that ignore payload_variant can still display + the alert from the enum alone; the typed fields let modern receivers show + the authoring unit and handle cancel-referencing without XML parsing. */ +typedef struct _meshtastic_EmergencyAlert { + /* Alert discriminator. */ + meshtastic_EmergencyAlert_Type type; + /* UID of the unit that raised the alert. Often the same as + TAKPacketV2.uid but can be a parent device uid when a tracker raises + an alert on behalf of a dismount. */ + char authoring_uid[48]; + /* For Type_Cancel: the uid of the alert being cancelled. Empty for + non-cancel alert types. */ + char cancel_reference_uid[48]; +} meshtastic_EmergencyAlert; + +/* Task / engage request (CoT type t-s). + + Mirrors ATAK's TaskCotReceiver / CotTaskBuilder workflow. The envelope + carries the task's originating uid (implicit requester), position, and + creation time; the fields below carry structured metadata the raw-detail + fallback currently loses. + + Fields are deliberately lean — this variant is closer to the MTU ceiling + than the others, so every string is capped in options. */ +typedef struct _meshtastic_TaskRequest { + /* Short tag for the task category (e.g. "engage", "observe", "recon", + "rescue"). Free text on the wire so ATAK-specific task taxonomies + don't need proto coordination; capped tight in options. */ + char task_type[12]; + /* UID of the target / map item being tasked. */ + char target_uid[32]; + /* UID of the assigned unit. Empty = unassigned / broadcast task. */ + char assignee_uid[32]; + meshtastic_TaskRequest_Priority priority; + meshtastic_TaskRequest_Status status; + /* Optional short note (reason, constraints, grid reference). Capped + tight in options to keep the worst-case under the LoRa MTU. */ + char note[48]; +} meshtastic_TaskRequest; + 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: @@ -413,6 +964,12 @@ typedef struct _meshtastic_TAKPacketV2 { char phone[20]; /* CoT event type string, only populated when cot_type_id is CotType_Other */ char cot_type_str[32]; + /* Optional remarks / free-text annotation from the element. + Populated for non-GeoChat payload types (shapes, markers, routes, etc.) + when the original CoT event carried non-empty remarks text. + GeoChat messages carry their text in GeoChat.message instead. + Empty string (proto3 default) means no remarks were present. */ + pb_callback_t remarks; pb_size_t which_payload_variant; union { /* Position report (true = PLI, no extra fields beyond the common ones above) */ @@ -421,8 +978,26 @@ typedef struct _meshtastic_TAKPacketV2 { meshtastic_GeoChat chat; /* Aircraft track data (ADS-B, military air) */ meshtastic_AircraftTrack aircraft; - /* Generic CoT detail XML for unmapped types */ + /* Generic CoT detail XML for unmapped types. Kept as a fallback for CoT + types not yet promoted to a typed variant; drawings, markers, ranging + tools, and routes have dedicated variants below and should not land here. */ meshtastic_TAKPacketV2_raw_detail_t raw_detail; + /* User-drawn tactical graphic: circle, rectangle, polygon, polyline, + telestration, ranging circle, or bullseye. See DrawnShape. */ + meshtastic_DrawnShape shape; + /* Fixed point of interest: spot marker, waypoint, checkpoint, 2525 + symbol, or custom icon. See Marker. */ + meshtastic_Marker marker; + /* Range and bearing measurement line. See RangeAndBearing. */ + meshtastic_RangeAndBearing rab; + /* Named route with ordered waypoints and control points. See Route. */ + meshtastic_Route route; + /* 9-line MEDEVAC request. See CasevacReport. */ + meshtastic_CasevacReport casevac; + /* Emergency beacon / 911 alert. See EmergencyAlert. */ + meshtastic_EmergencyAlert emergency; + /* Task / engage request. See TaskRequest. */ + meshtastic_TaskRequest task; } payload_variant; } meshtastic_TAKPacketV2; @@ -445,14 +1020,63 @@ extern "C" { #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_CotType_MAX meshtastic_CotType_CotType_t_s +#define _meshtastic_CotType_ARRAYSIZE ((meshtastic_CotType)(meshtastic_CotType_CotType_t_s+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_GeoChat_ReceiptType_MIN meshtastic_GeoChat_ReceiptType_ReceiptType_None +#define _meshtastic_GeoChat_ReceiptType_MAX meshtastic_GeoChat_ReceiptType_ReceiptType_Read +#define _meshtastic_GeoChat_ReceiptType_ARRAYSIZE ((meshtastic_GeoChat_ReceiptType)(meshtastic_GeoChat_ReceiptType_ReceiptType_Read+1)) +#define _meshtastic_DrawnShape_Kind_MIN meshtastic_DrawnShape_Kind_Kind_Unspecified +#define _meshtastic_DrawnShape_Kind_MAX meshtastic_DrawnShape_Kind_Kind_Vehicle3D +#define _meshtastic_DrawnShape_Kind_ARRAYSIZE ((meshtastic_DrawnShape_Kind)(meshtastic_DrawnShape_Kind_Kind_Vehicle3D+1)) + +#define _meshtastic_DrawnShape_StyleMode_MIN meshtastic_DrawnShape_StyleMode_StyleMode_Unspecified +#define _meshtastic_DrawnShape_StyleMode_MAX meshtastic_DrawnShape_StyleMode_StyleMode_StrokeAndFill +#define _meshtastic_DrawnShape_StyleMode_ARRAYSIZE ((meshtastic_DrawnShape_StyleMode)(meshtastic_DrawnShape_StyleMode_StyleMode_StrokeAndFill+1)) + +#define _meshtastic_Marker_Kind_MIN meshtastic_Marker_Kind_Kind_Unspecified +#define _meshtastic_Marker_Kind_MAX meshtastic_Marker_Kind_Kind_ImageMarker +#define _meshtastic_Marker_Kind_ARRAYSIZE ((meshtastic_Marker_Kind)(meshtastic_Marker_Kind_Kind_ImageMarker+1)) + +#define _meshtastic_Route_Method_MIN meshtastic_Route_Method_Method_Unspecified +#define _meshtastic_Route_Method_MAX meshtastic_Route_Method_Method_Watercraft +#define _meshtastic_Route_Method_ARRAYSIZE ((meshtastic_Route_Method)(meshtastic_Route_Method_Method_Watercraft+1)) + +#define _meshtastic_Route_Direction_MIN meshtastic_Route_Direction_Direction_Unspecified +#define _meshtastic_Route_Direction_MAX meshtastic_Route_Direction_Direction_Exfil +#define _meshtastic_Route_Direction_ARRAYSIZE ((meshtastic_Route_Direction)(meshtastic_Route_Direction_Direction_Exfil+1)) + +#define _meshtastic_CasevacReport_Precedence_MIN meshtastic_CasevacReport_Precedence_Precedence_Unspecified +#define _meshtastic_CasevacReport_Precedence_MAX meshtastic_CasevacReport_Precedence_Precedence_Convenience +#define _meshtastic_CasevacReport_Precedence_ARRAYSIZE ((meshtastic_CasevacReport_Precedence)(meshtastic_CasevacReport_Precedence_Precedence_Convenience+1)) + +#define _meshtastic_CasevacReport_HlzMarking_MIN meshtastic_CasevacReport_HlzMarking_HlzMarking_Unspecified +#define _meshtastic_CasevacReport_HlzMarking_MAX meshtastic_CasevacReport_HlzMarking_HlzMarking_Other +#define _meshtastic_CasevacReport_HlzMarking_ARRAYSIZE ((meshtastic_CasevacReport_HlzMarking)(meshtastic_CasevacReport_HlzMarking_HlzMarking_Other+1)) + +#define _meshtastic_CasevacReport_Security_MIN meshtastic_CasevacReport_Security_Security_Unspecified +#define _meshtastic_CasevacReport_Security_MAX meshtastic_CasevacReport_Security_Security_EnemyInArmedContact +#define _meshtastic_CasevacReport_Security_ARRAYSIZE ((meshtastic_CasevacReport_Security)(meshtastic_CasevacReport_Security_Security_EnemyInArmedContact+1)) + +#define _meshtastic_EmergencyAlert_Type_MIN meshtastic_EmergencyAlert_Type_Type_Unspecified +#define _meshtastic_EmergencyAlert_Type_MAX meshtastic_EmergencyAlert_Type_Type_Cancel +#define _meshtastic_EmergencyAlert_Type_ARRAYSIZE ((meshtastic_EmergencyAlert_Type)(meshtastic_EmergencyAlert_Type_Type_Cancel+1)) + +#define _meshtastic_TaskRequest_Priority_MIN meshtastic_TaskRequest_Priority_Priority_Unspecified +#define _meshtastic_TaskRequest_Priority_MAX meshtastic_TaskRequest_Priority_Priority_Critical +#define _meshtastic_TaskRequest_Priority_ARRAYSIZE ((meshtastic_TaskRequest_Priority)(meshtastic_TaskRequest_Priority_Priority_Critical+1)) + +#define _meshtastic_TaskRequest_Status_MIN meshtastic_TaskRequest_Status_Status_Unspecified +#define _meshtastic_TaskRequest_Status_MAX meshtastic_TaskRequest_Status_Status_Cancelled +#define _meshtastic_TaskRequest_Status_ARRAYSIZE ((meshtastic_TaskRequest_Status)(meshtastic_TaskRequest_Status_Status_Cancelled+1)) + + +#define meshtastic_GeoChat_receipt_type_ENUMTYPE meshtastic_GeoChat_ReceiptType #define meshtastic_Group_role_ENUMTYPE meshtastic_MemberRole #define meshtastic_Group_team_ENUMTYPE meshtastic_Team @@ -461,6 +1085,30 @@ extern "C" { + +#define meshtastic_DrawnShape_kind_ENUMTYPE meshtastic_DrawnShape_Kind +#define meshtastic_DrawnShape_style_ENUMTYPE meshtastic_DrawnShape_StyleMode +#define meshtastic_DrawnShape_stroke_color_ENUMTYPE meshtastic_Team +#define meshtastic_DrawnShape_fill_color_ENUMTYPE meshtastic_Team + +#define meshtastic_Marker_kind_ENUMTYPE meshtastic_Marker_Kind +#define meshtastic_Marker_color_ENUMTYPE meshtastic_Team + +#define meshtastic_RangeAndBearing_stroke_color_ENUMTYPE meshtastic_Team + +#define meshtastic_Route_method_ENUMTYPE meshtastic_Route_Method +#define meshtastic_Route_direction_ENUMTYPE meshtastic_Route_Direction + + +#define meshtastic_CasevacReport_precedence_ENUMTYPE meshtastic_CasevacReport_Precedence +#define meshtastic_CasevacReport_security_ENUMTYPE meshtastic_CasevacReport_Security +#define meshtastic_CasevacReport_hlz_marking_ENUMTYPE meshtastic_CasevacReport_HlzMarking + +#define meshtastic_EmergencyAlert_type_ENUMTYPE meshtastic_EmergencyAlert_Type + +#define meshtastic_TaskRequest_priority_ENUMTYPE meshtastic_TaskRequest_Priority +#define meshtastic_TaskRequest_status_ENUMTYPE meshtastic_TaskRequest_Status + #define meshtastic_TAKPacketV2_cot_type_id_ENUMTYPE meshtastic_CotType #define meshtastic_TAKPacketV2_how_ENUMTYPE meshtastic_CotHow #define meshtastic_TAKPacketV2_team_ENUMTYPE meshtastic_Team @@ -471,26 +1119,46 @@ extern "C" { /* 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, ""} +#define meshtastic_GeoChat_init_default {"", false, "", false, "", "", _meshtastic_GeoChat_ReceiptType_MIN} #define meshtastic_Group_init_default {_meshtastic_MemberRole_MIN, _meshtastic_Team_MIN} #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_CotGeoPoint_init_default {0, 0} +#define meshtastic_DrawnShape_init_default {_meshtastic_DrawnShape_Kind_MIN, _meshtastic_DrawnShape_StyleMode_MIN, 0, 0, 0, _meshtastic_Team_MIN, 0, 0, _meshtastic_Team_MIN, 0, 0, 0, {meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default, meshtastic_CotGeoPoint_init_default}, 0, 0, 0, 0, ""} +#define meshtastic_Marker_init_default {_meshtastic_Marker_Kind_MIN, _meshtastic_Team_MIN, 0, 0, "", "", "", ""} +#define meshtastic_RangeAndBearing_init_default {false, meshtastic_CotGeoPoint_init_default, "", 0, 0, _meshtastic_Team_MIN, 0, 0} +#define meshtastic_Route_init_default {_meshtastic_Route_Method_MIN, _meshtastic_Route_Direction_MIN, "", 0, 0, {meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default, meshtastic_Route_Link_init_default}, 0} +#define meshtastic_Route_Link_init_default {false, meshtastic_CotGeoPoint_init_default, "", "", 0} +#define meshtastic_CasevacReport_init_default {_meshtastic_CasevacReport_Precedence_MIN, 0, 0, 0, _meshtastic_CasevacReport_Security_MIN, _meshtastic_CasevacReport_HlzMarking_MIN, "", 0, 0, 0, 0, 0, 0, 0, ""} +#define meshtastic_EmergencyAlert_init_default {_meshtastic_EmergencyAlert_Type_MIN, "", ""} +#define meshtastic_TaskRequest_init_default {"", "", "", _meshtastic_TaskRequest_Priority_MIN, _meshtastic_TaskRequest_Status_MIN, ""} +#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, "", "", "", "", "", "", "", {{NULL}, NULL}, 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_GeoChat_init_zero {"", false, "", false, "", "", _meshtastic_GeoChat_ReceiptType_MIN} #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}} +#define meshtastic_CotGeoPoint_init_zero {0, 0} +#define meshtastic_DrawnShape_init_zero {_meshtastic_DrawnShape_Kind_MIN, _meshtastic_DrawnShape_StyleMode_MIN, 0, 0, 0, _meshtastic_Team_MIN, 0, 0, _meshtastic_Team_MIN, 0, 0, 0, {meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero, meshtastic_CotGeoPoint_init_zero}, 0, 0, 0, 0, ""} +#define meshtastic_Marker_init_zero {_meshtastic_Marker_Kind_MIN, _meshtastic_Team_MIN, 0, 0, "", "", "", ""} +#define meshtastic_RangeAndBearing_init_zero {false, meshtastic_CotGeoPoint_init_zero, "", 0, 0, _meshtastic_Team_MIN, 0, 0} +#define meshtastic_Route_init_zero {_meshtastic_Route_Method_MIN, _meshtastic_Route_Direction_MIN, "", 0, 0, {meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero, meshtastic_Route_Link_init_zero}, 0} +#define meshtastic_Route_Link_init_zero {false, meshtastic_CotGeoPoint_init_zero, "", "", 0} +#define meshtastic_CasevacReport_init_zero {_meshtastic_CasevacReport_Precedence_MIN, 0, 0, 0, _meshtastic_CasevacReport_Security_MIN, _meshtastic_CasevacReport_HlzMarking_MIN, "", 0, 0, 0, 0, 0, 0, 0, ""} +#define meshtastic_EmergencyAlert_init_zero {_meshtastic_EmergencyAlert_Type_MIN, "", ""} +#define meshtastic_TaskRequest_init_zero {"", "", "", _meshtastic_TaskRequest_Priority_MIN, _meshtastic_TaskRequest_Status_MIN, ""} +#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, "", "", "", "", "", "", "", {{NULL}, NULL}, 0, {0}} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_GeoChat_message_tag 1 #define meshtastic_GeoChat_to_tag 2 #define meshtastic_GeoChat_to_callsign_tag 3 +#define meshtastic_GeoChat_receipt_for_uid_tag 4 +#define meshtastic_GeoChat_receipt_type_tag 5 #define meshtastic_Group_role_tag 1 #define meshtastic_Group_team_tag 2 #define meshtastic_Status_battery_tag 1 @@ -517,6 +1185,74 @@ extern "C" { #define meshtastic_AircraftTrack_rssi_x10_tag 7 #define meshtastic_AircraftTrack_gps_tag 8 #define meshtastic_AircraftTrack_cot_host_id_tag 9 +#define meshtastic_CotGeoPoint_lat_delta_i_tag 1 +#define meshtastic_CotGeoPoint_lon_delta_i_tag 2 +#define meshtastic_DrawnShape_kind_tag 1 +#define meshtastic_DrawnShape_style_tag 2 +#define meshtastic_DrawnShape_major_cm_tag 3 +#define meshtastic_DrawnShape_minor_cm_tag 4 +#define meshtastic_DrawnShape_angle_deg_tag 5 +#define meshtastic_DrawnShape_stroke_color_tag 6 +#define meshtastic_DrawnShape_stroke_argb_tag 7 +#define meshtastic_DrawnShape_stroke_weight_x10_tag 8 +#define meshtastic_DrawnShape_fill_color_tag 9 +#define meshtastic_DrawnShape_fill_argb_tag 10 +#define meshtastic_DrawnShape_labels_on_tag 11 +#define meshtastic_DrawnShape_vertices_tag 12 +#define meshtastic_DrawnShape_truncated_tag 13 +#define meshtastic_DrawnShape_bullseye_distance_dm_tag 14 +#define meshtastic_DrawnShape_bullseye_bearing_ref_tag 15 +#define meshtastic_DrawnShape_bullseye_flags_tag 16 +#define meshtastic_DrawnShape_bullseye_uid_ref_tag 17 +#define meshtastic_Marker_kind_tag 1 +#define meshtastic_Marker_color_tag 2 +#define meshtastic_Marker_color_argb_tag 3 +#define meshtastic_Marker_readiness_tag 4 +#define meshtastic_Marker_parent_uid_tag 5 +#define meshtastic_Marker_parent_type_tag 6 +#define meshtastic_Marker_parent_callsign_tag 7 +#define meshtastic_Marker_iconset_tag 8 +#define meshtastic_RangeAndBearing_anchor_tag 1 +#define meshtastic_RangeAndBearing_anchor_uid_tag 2 +#define meshtastic_RangeAndBearing_range_cm_tag 3 +#define meshtastic_RangeAndBearing_bearing_cdeg_tag 4 +#define meshtastic_RangeAndBearing_stroke_color_tag 5 +#define meshtastic_RangeAndBearing_stroke_argb_tag 6 +#define meshtastic_RangeAndBearing_stroke_weight_x10_tag 7 +#define meshtastic_Route_Link_point_tag 1 +#define meshtastic_Route_Link_uid_tag 2 +#define meshtastic_Route_Link_callsign_tag 3 +#define meshtastic_Route_Link_link_type_tag 4 +#define meshtastic_Route_method_tag 1 +#define meshtastic_Route_direction_tag 2 +#define meshtastic_Route_prefix_tag 3 +#define meshtastic_Route_stroke_weight_x10_tag 4 +#define meshtastic_Route_links_tag 5 +#define meshtastic_Route_truncated_tag 6 +#define meshtastic_CasevacReport_precedence_tag 1 +#define meshtastic_CasevacReport_equipment_flags_tag 2 +#define meshtastic_CasevacReport_litter_patients_tag 3 +#define meshtastic_CasevacReport_ambulatory_patients_tag 4 +#define meshtastic_CasevacReport_security_tag 5 +#define meshtastic_CasevacReport_hlz_marking_tag 6 +#define meshtastic_CasevacReport_zone_marker_tag 7 +#define meshtastic_CasevacReport_us_military_tag 8 +#define meshtastic_CasevacReport_us_civilian_tag 9 +#define meshtastic_CasevacReport_non_us_military_tag 10 +#define meshtastic_CasevacReport_non_us_civilian_tag 11 +#define meshtastic_CasevacReport_epw_tag 12 +#define meshtastic_CasevacReport_child_tag 13 +#define meshtastic_CasevacReport_terrain_flags_tag 14 +#define meshtastic_CasevacReport_frequency_tag 15 +#define meshtastic_EmergencyAlert_type_tag 1 +#define meshtastic_EmergencyAlert_authoring_uid_tag 2 +#define meshtastic_EmergencyAlert_cancel_reference_uid_tag 3 +#define meshtastic_TaskRequest_task_type_tag 1 +#define meshtastic_TaskRequest_target_uid_tag 2 +#define meshtastic_TaskRequest_assignee_uid_tag 3 +#define meshtastic_TaskRequest_priority_tag 4 +#define meshtastic_TaskRequest_status_tag 5 +#define meshtastic_TaskRequest_note_tag 6 #define meshtastic_TAKPacketV2_cot_type_id_tag 1 #define meshtastic_TAKPacketV2_how_tag 2 #define meshtastic_TAKPacketV2_callsign_tag 3 @@ -540,10 +1276,18 @@ extern "C" { #define meshtastic_TAKPacketV2_endpoint_tag 21 #define meshtastic_TAKPacketV2_phone_tag 22 #define meshtastic_TAKPacketV2_cot_type_str_tag 23 +#define meshtastic_TAKPacketV2_remarks_tag 24 #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 +#define meshtastic_TAKPacketV2_shape_tag 34 +#define meshtastic_TAKPacketV2_marker_tag 35 +#define meshtastic_TAKPacketV2_rab_tag 36 +#define meshtastic_TAKPacketV2_route_tag 37 +#define meshtastic_TAKPacketV2_casevac_tag 38 +#define meshtastic_TAKPacketV2_emergency_tag 39 +#define meshtastic_TAKPacketV2_task_tag 40 /* Struct field encoding specification for nanopb */ #define meshtastic_TAKPacket_FIELDLIST(X, a) \ @@ -565,7 +1309,9 @@ X(a, STATIC, ONEOF, BYTES, (payload_variant,detail,payload_variant.detai #define meshtastic_GeoChat_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, STRING, message, 1) \ X(a, STATIC, OPTIONAL, STRING, to, 2) \ -X(a, STATIC, OPTIONAL, STRING, to_callsign, 3) +X(a, STATIC, OPTIONAL, STRING, to_callsign, 3) \ +X(a, STATIC, SINGULAR, STRING, receipt_for_uid, 4) \ +X(a, STATIC, SINGULAR, UENUM, receipt_type, 5) #define meshtastic_GeoChat_CALLBACK NULL #define meshtastic_GeoChat_DEFAULT NULL @@ -608,6 +1354,114 @@ X(a, STATIC, SINGULAR, STRING, cot_host_id, 9) #define meshtastic_AircraftTrack_CALLBACK NULL #define meshtastic_AircraftTrack_DEFAULT NULL +#define meshtastic_CotGeoPoint_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, SINT32, lat_delta_i, 1) \ +X(a, STATIC, SINGULAR, SINT32, lon_delta_i, 2) +#define meshtastic_CotGeoPoint_CALLBACK NULL +#define meshtastic_CotGeoPoint_DEFAULT NULL + +#define meshtastic_DrawnShape_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, kind, 1) \ +X(a, STATIC, SINGULAR, UENUM, style, 2) \ +X(a, STATIC, SINGULAR, UINT32, major_cm, 3) \ +X(a, STATIC, SINGULAR, UINT32, minor_cm, 4) \ +X(a, STATIC, SINGULAR, UINT32, angle_deg, 5) \ +X(a, STATIC, SINGULAR, UENUM, stroke_color, 6) \ +X(a, STATIC, SINGULAR, FIXED32, stroke_argb, 7) \ +X(a, STATIC, SINGULAR, UINT32, stroke_weight_x10, 8) \ +X(a, STATIC, SINGULAR, UENUM, fill_color, 9) \ +X(a, STATIC, SINGULAR, FIXED32, fill_argb, 10) \ +X(a, STATIC, SINGULAR, BOOL, labels_on, 11) \ +X(a, STATIC, REPEATED, MESSAGE, vertices, 12) \ +X(a, STATIC, SINGULAR, BOOL, truncated, 13) \ +X(a, STATIC, SINGULAR, UINT32, bullseye_distance_dm, 14) \ +X(a, STATIC, SINGULAR, UINT32, bullseye_bearing_ref, 15) \ +X(a, STATIC, SINGULAR, UINT32, bullseye_flags, 16) \ +X(a, STATIC, SINGULAR, STRING, bullseye_uid_ref, 17) +#define meshtastic_DrawnShape_CALLBACK NULL +#define meshtastic_DrawnShape_DEFAULT NULL +#define meshtastic_DrawnShape_vertices_MSGTYPE meshtastic_CotGeoPoint + +#define meshtastic_Marker_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, kind, 1) \ +X(a, STATIC, SINGULAR, UENUM, color, 2) \ +X(a, STATIC, SINGULAR, FIXED32, color_argb, 3) \ +X(a, STATIC, SINGULAR, BOOL, readiness, 4) \ +X(a, STATIC, SINGULAR, STRING, parent_uid, 5) \ +X(a, STATIC, SINGULAR, STRING, parent_type, 6) \ +X(a, STATIC, SINGULAR, STRING, parent_callsign, 7) \ +X(a, STATIC, SINGULAR, STRING, iconset, 8) +#define meshtastic_Marker_CALLBACK NULL +#define meshtastic_Marker_DEFAULT NULL + +#define meshtastic_RangeAndBearing_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, MESSAGE, anchor, 1) \ +X(a, STATIC, SINGULAR, STRING, anchor_uid, 2) \ +X(a, STATIC, SINGULAR, UINT32, range_cm, 3) \ +X(a, STATIC, SINGULAR, UINT32, bearing_cdeg, 4) \ +X(a, STATIC, SINGULAR, UENUM, stroke_color, 5) \ +X(a, STATIC, SINGULAR, FIXED32, stroke_argb, 6) \ +X(a, STATIC, SINGULAR, UINT32, stroke_weight_x10, 7) +#define meshtastic_RangeAndBearing_CALLBACK NULL +#define meshtastic_RangeAndBearing_DEFAULT NULL +#define meshtastic_RangeAndBearing_anchor_MSGTYPE meshtastic_CotGeoPoint + +#define meshtastic_Route_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, method, 1) \ +X(a, STATIC, SINGULAR, UENUM, direction, 2) \ +X(a, STATIC, SINGULAR, STRING, prefix, 3) \ +X(a, STATIC, SINGULAR, UINT32, stroke_weight_x10, 4) \ +X(a, STATIC, REPEATED, MESSAGE, links, 5) \ +X(a, STATIC, SINGULAR, BOOL, truncated, 6) +#define meshtastic_Route_CALLBACK NULL +#define meshtastic_Route_DEFAULT NULL +#define meshtastic_Route_links_MSGTYPE meshtastic_Route_Link + +#define meshtastic_Route_Link_FIELDLIST(X, a) \ +X(a, STATIC, OPTIONAL, MESSAGE, point, 1) \ +X(a, STATIC, SINGULAR, STRING, uid, 2) \ +X(a, STATIC, SINGULAR, STRING, callsign, 3) \ +X(a, STATIC, SINGULAR, UINT32, link_type, 4) +#define meshtastic_Route_Link_CALLBACK NULL +#define meshtastic_Route_Link_DEFAULT NULL +#define meshtastic_Route_Link_point_MSGTYPE meshtastic_CotGeoPoint + +#define meshtastic_CasevacReport_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, precedence, 1) \ +X(a, STATIC, SINGULAR, UINT32, equipment_flags, 2) \ +X(a, STATIC, SINGULAR, UINT32, litter_patients, 3) \ +X(a, STATIC, SINGULAR, UINT32, ambulatory_patients, 4) \ +X(a, STATIC, SINGULAR, UENUM, security, 5) \ +X(a, STATIC, SINGULAR, UENUM, hlz_marking, 6) \ +X(a, STATIC, SINGULAR, STRING, zone_marker, 7) \ +X(a, STATIC, SINGULAR, UINT32, us_military, 8) \ +X(a, STATIC, SINGULAR, UINT32, us_civilian, 9) \ +X(a, STATIC, SINGULAR, UINT32, non_us_military, 10) \ +X(a, STATIC, SINGULAR, UINT32, non_us_civilian, 11) \ +X(a, STATIC, SINGULAR, UINT32, epw, 12) \ +X(a, STATIC, SINGULAR, UINT32, child, 13) \ +X(a, STATIC, SINGULAR, UINT32, terrain_flags, 14) \ +X(a, STATIC, SINGULAR, STRING, frequency, 15) +#define meshtastic_CasevacReport_CALLBACK NULL +#define meshtastic_CasevacReport_DEFAULT NULL + +#define meshtastic_EmergencyAlert_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, type, 1) \ +X(a, STATIC, SINGULAR, STRING, authoring_uid, 2) \ +X(a, STATIC, SINGULAR, STRING, cancel_reference_uid, 3) +#define meshtastic_EmergencyAlert_CALLBACK NULL +#define meshtastic_EmergencyAlert_DEFAULT NULL + +#define meshtastic_TaskRequest_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, task_type, 1) \ +X(a, STATIC, SINGULAR, STRING, target_uid, 2) \ +X(a, STATIC, SINGULAR, STRING, assignee_uid, 3) \ +X(a, STATIC, SINGULAR, UENUM, priority, 4) \ +X(a, STATIC, SINGULAR, UENUM, status, 5) \ +X(a, STATIC, SINGULAR, STRING, note, 6) +#define meshtastic_TaskRequest_CALLBACK NULL +#define meshtastic_TaskRequest_DEFAULT NULL + #define meshtastic_TAKPacketV2_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UENUM, cot_type_id, 1) \ X(a, STATIC, SINGULAR, UENUM, how, 2) \ @@ -632,14 +1486,29 @@ 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, CALLBACK, SINGULAR, STRING, remarks, 24) \ 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 +X(a, STATIC, ONEOF, BYTES, (payload_variant,raw_detail,payload_variant.raw_detail), 33) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,shape,payload_variant.shape), 34) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,marker,payload_variant.marker), 35) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,rab,payload_variant.rab), 36) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,route,payload_variant.route), 37) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,casevac,payload_variant.casevac), 38) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,emergency,payload_variant.emergency), 39) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,task,payload_variant.task), 40) +#define meshtastic_TAKPacketV2_CALLBACK pb_default_field_callback #define meshtastic_TAKPacketV2_DEFAULT NULL #define meshtastic_TAKPacketV2_payload_variant_chat_MSGTYPE meshtastic_GeoChat #define meshtastic_TAKPacketV2_payload_variant_aircraft_MSGTYPE meshtastic_AircraftTrack +#define meshtastic_TAKPacketV2_payload_variant_shape_MSGTYPE meshtastic_DrawnShape +#define meshtastic_TAKPacketV2_payload_variant_marker_MSGTYPE meshtastic_Marker +#define meshtastic_TAKPacketV2_payload_variant_rab_MSGTYPE meshtastic_RangeAndBearing +#define meshtastic_TAKPacketV2_payload_variant_route_MSGTYPE meshtastic_Route +#define meshtastic_TAKPacketV2_payload_variant_casevac_MSGTYPE meshtastic_CasevacReport +#define meshtastic_TAKPacketV2_payload_variant_emergency_MSGTYPE meshtastic_EmergencyAlert +#define meshtastic_TAKPacketV2_payload_variant_task_MSGTYPE meshtastic_TaskRequest extern const pb_msgdesc_t meshtastic_TAKPacket_msg; extern const pb_msgdesc_t meshtastic_GeoChat_msg; @@ -648,6 +1517,15 @@ 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_CotGeoPoint_msg; +extern const pb_msgdesc_t meshtastic_DrawnShape_msg; +extern const pb_msgdesc_t meshtastic_Marker_msg; +extern const pb_msgdesc_t meshtastic_RangeAndBearing_msg; +extern const pb_msgdesc_t meshtastic_Route_msg; +extern const pb_msgdesc_t meshtastic_Route_Link_msg; +extern const pb_msgdesc_t meshtastic_CasevacReport_msg; +extern const pb_msgdesc_t meshtastic_EmergencyAlert_msg; +extern const pb_msgdesc_t meshtastic_TaskRequest_msg; extern const pb_msgdesc_t meshtastic_TAKPacketV2_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ @@ -658,18 +1536,36 @@ extern const pb_msgdesc_t meshtastic_TAKPacketV2_msg; #define meshtastic_Contact_fields &meshtastic_Contact_msg #define meshtastic_PLI_fields &meshtastic_PLI_msg #define meshtastic_AircraftTrack_fields &meshtastic_AircraftTrack_msg +#define meshtastic_CotGeoPoint_fields &meshtastic_CotGeoPoint_msg +#define meshtastic_DrawnShape_fields &meshtastic_DrawnShape_msg +#define meshtastic_Marker_fields &meshtastic_Marker_msg +#define meshtastic_RangeAndBearing_fields &meshtastic_RangeAndBearing_msg +#define meshtastic_Route_fields &meshtastic_Route_msg +#define meshtastic_Route_Link_fields &meshtastic_Route_Link_msg +#define meshtastic_CasevacReport_fields &meshtastic_CasevacReport_msg +#define meshtastic_EmergencyAlert_fields &meshtastic_EmergencyAlert_msg +#define meshtastic_TaskRequest_fields &meshtastic_TaskRequest_msg #define meshtastic_TAKPacketV2_fields &meshtastic_TAKPacketV2_msg /* Maximum encoded size of messages (where known) */ -#define MESHTASTIC_MESHTASTIC_ATAK_PB_H_MAX_SIZE meshtastic_TAKPacketV2_size +/* meshtastic_TAKPacketV2_size depends on runtime parameters */ +#define MESHTASTIC_MESHTASTIC_ATAK_PB_H_MAX_SIZE meshtastic_Route_size #define meshtastic_AircraftTrack_size 134 +#define meshtastic_CasevacReport_size 70 #define meshtastic_Contact_size 242 -#define meshtastic_GeoChat_size 444 +#define meshtastic_CotGeoPoint_size 12 +#define meshtastic_DrawnShape_size 553 +#define meshtastic_EmergencyAlert_size 100 +#define meshtastic_GeoChat_size 495 #define meshtastic_Group_size 4 +#define meshtastic_Marker_size 191 #define meshtastic_PLI_size 31 +#define meshtastic_RangeAndBearing_size 84 +#define meshtastic_Route_Link_size 83 +#define meshtastic_Route_size 1379 #define meshtastic_Status_size 3 -#define meshtastic_TAKPacketV2_size 1027 -#define meshtastic_TAKPacket_size 705 +#define meshtastic_TAKPacket_size 756 +#define meshtastic_TaskRequest_size 132 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp index 7f1a738c6..3648d8850 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.cpp +++ b/src/mesh/generated/meshtastic/mesh.pb.cpp @@ -27,6 +27,9 @@ PB_BIND(meshtastic_KeyVerification, meshtastic_KeyVerification, AUTO) PB_BIND(meshtastic_StoreForwardPlusPlus, meshtastic_StoreForwardPlusPlus, 2) +PB_BIND(meshtastic_RemoteShell, meshtastic_RemoteShell, AUTO) + + PB_BIND(meshtastic_Waypoint, meshtastic_Waypoint, AUTO) @@ -129,6 +132,8 @@ PB_BIND(meshtastic_ChunkedPayloadResponse, meshtastic_ChunkedPayloadResponse, AU + + diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index fc6931d73..1c7eecd0d 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -308,8 +308,13 @@ 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. */ + /* Seeed studio T1000-E Pro tracker card. NRF52840 w/ LR2021 radio, + GPS, button, buzzer, and sensors. */ meshtastic_HardwareModel_TRACKER_T1000_E_PRO = 128, + /* Elecrow ThinkNode M7, M8 and M9 */ + meshtastic_HardwareModel_THINKNODE_M7 = 129, + meshtastic_HardwareModel_THINKNODE_M8 = 130, + meshtastic_HardwareModel_THINKNODE_M9 = 131, /* ------------------------------------------------------------------------------------------------------------------------------------------ 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. ------------------------------------------------------------------------------------------------------------------------------------------ */ @@ -513,6 +518,27 @@ typedef enum _meshtastic_StoreForwardPlusPlus_SFPP_message_type { meshtastic_StoreForwardPlusPlus_SFPP_message_type_LINK_PROVIDE_SECONDHALF = 6 } meshtastic_StoreForwardPlusPlus_SFPP_message_type; +/* Frame op code for PTY session control and stream transport. + + Values 1-63 are client->server requests. + Values 64-127 are server->client responses/events. */ +typedef enum _meshtastic_RemoteShell_OpCode { + meshtastic_RemoteShell_OpCode_OP_UNSET = 0, + /* Client -> server */ + meshtastic_RemoteShell_OpCode_OPEN = 1, + meshtastic_RemoteShell_OpCode_INPUT = 2, + meshtastic_RemoteShell_OpCode_RESIZE = 3, + meshtastic_RemoteShell_OpCode_CLOSE = 4, + meshtastic_RemoteShell_OpCode_PING = 5, + meshtastic_RemoteShell_OpCode_ACK = 6, + /* Server -> client */ + meshtastic_RemoteShell_OpCode_OPEN_OK = 64, + meshtastic_RemoteShell_OpCode_OUTPUT = 65, + meshtastic_RemoteShell_OpCode_CLOSED = 66, + meshtastic_RemoteShell_OpCode_ERROR = 67, + meshtastic_RemoteShell_OpCode_PONG = 68 +} meshtastic_RemoteShell_OpCode; + /* The priority of this message for sending. Higher priorities are sent first (when managing the transmit queue). This field is never sent over the air, it is only used internally inside of a local device node. @@ -845,6 +871,27 @@ typedef struct _meshtastic_StoreForwardPlusPlus { uint32_t chain_count; } meshtastic_StoreForwardPlusPlus; +typedef PB_BYTES_ARRAY_T(200) meshtastic_RemoteShell_payload_t; +/* The actual over-the-mesh message doing RemoteShell */ +typedef struct _meshtastic_RemoteShell { + /* Structured frame operation. */ + meshtastic_RemoteShell_OpCode op; + /* Logical PTY session identifier. */ + uint32_t session_id; + /* Monotonic sequence number for this frame. */ + uint32_t seq; + /* Cumulative ack sequence number. */ + uint32_t ack_seq; + /* Opaque bytes payload for INPUT/OUTPUT/ERROR and other frame bodies. */ + meshtastic_RemoteShell_payload_t payload; + /* Terminal size columns used for OPEN/RESIZE signaling. */ + uint32_t cols; + /* Terminal size rows used for OPEN/RESIZE signaling. */ + uint32_t rows; + /* Bit flags for protocol extensions. */ + uint32_t flags; +} meshtastic_RemoteShell; + /* Waypoint message, used to share arbitrary locations across the mesh */ typedef struct _meshtastic_Waypoint { /* Id of the waypoint */ @@ -1385,6 +1432,10 @@ extern "C" { #define _meshtastic_StoreForwardPlusPlus_SFPP_message_type_MAX meshtastic_StoreForwardPlusPlus_SFPP_message_type_LINK_PROVIDE_SECONDHALF #define _meshtastic_StoreForwardPlusPlus_SFPP_message_type_ARRAYSIZE ((meshtastic_StoreForwardPlusPlus_SFPP_message_type)(meshtastic_StoreForwardPlusPlus_SFPP_message_type_LINK_PROVIDE_SECONDHALF+1)) +#define _meshtastic_RemoteShell_OpCode_MIN meshtastic_RemoteShell_OpCode_OP_UNSET +#define _meshtastic_RemoteShell_OpCode_MAX meshtastic_RemoteShell_OpCode_PONG +#define _meshtastic_RemoteShell_OpCode_ARRAYSIZE ((meshtastic_RemoteShell_OpCode)(meshtastic_RemoteShell_OpCode_PONG+1)) + #define _meshtastic_MeshPacket_Priority_MIN meshtastic_MeshPacket_Priority_UNSET #define _meshtastic_MeshPacket_Priority_MAX meshtastic_MeshPacket_Priority_MAX #define _meshtastic_MeshPacket_Priority_ARRAYSIZE ((meshtastic_MeshPacket_Priority)(meshtastic_MeshPacket_Priority_MAX+1)) @@ -1415,6 +1466,8 @@ extern "C" { #define meshtastic_StoreForwardPlusPlus_sfpp_message_type_ENUMTYPE meshtastic_StoreForwardPlusPlus_SFPP_message_type +#define meshtastic_RemoteShell_op_ENUMTYPE meshtastic_RemoteShell_OpCode + @@ -1459,6 +1512,7 @@ extern "C" { #define meshtastic_Data_init_default {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} #define meshtastic_KeyVerification_init_default {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_default {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} +#define meshtastic_RemoteShell_init_default {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0} #define meshtastic_Waypoint_init_default {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_default {""} #define meshtastic_MqttClientProxyMessage_init_default {"", 0, {{0, {0}}}, 0} @@ -1492,6 +1546,7 @@ extern "C" { #define meshtastic_Data_init_zero {_meshtastic_PortNum_MIN, {0, {0}}, 0, 0, 0, 0, 0, 0, false, 0} #define meshtastic_KeyVerification_init_zero {0, {0, {0}}, {0, {0}}} #define meshtastic_StoreForwardPlusPlus_init_zero {_meshtastic_StoreForwardPlusPlus_SFPP_message_type_MIN, {0, {0}}, {0, {0}}, {0, {0}}, {0, {0}}, 0, 0, 0, 0, 0} +#define meshtastic_RemoteShell_init_zero {_meshtastic_RemoteShell_OpCode_MIN, 0, 0, 0, {0, {0}}, 0, 0, 0} #define meshtastic_Waypoint_init_zero {0, false, 0, false, 0, 0, 0, "", "", 0} #define meshtastic_StatusMessage_init_zero {""} #define meshtastic_MqttClientProxyMessage_init_zero {"", 0, {{0, {0}}}, 0} @@ -1581,6 +1636,14 @@ extern "C" { #define meshtastic_StoreForwardPlusPlus_encapsulated_from_tag 8 #define meshtastic_StoreForwardPlusPlus_encapsulated_rxtime_tag 9 #define meshtastic_StoreForwardPlusPlus_chain_count_tag 10 +#define meshtastic_RemoteShell_op_tag 1 +#define meshtastic_RemoteShell_session_id_tag 2 +#define meshtastic_RemoteShell_seq_tag 3 +#define meshtastic_RemoteShell_ack_seq_tag 4 +#define meshtastic_RemoteShell_payload_tag 5 +#define meshtastic_RemoteShell_cols_tag 6 +#define meshtastic_RemoteShell_rows_tag 7 +#define meshtastic_RemoteShell_flags_tag 8 #define meshtastic_Waypoint_id_tag 1 #define meshtastic_Waypoint_latitude_i_tag 2 #define meshtastic_Waypoint_longitude_i_tag 3 @@ -1813,6 +1876,18 @@ X(a, STATIC, SINGULAR, UINT32, chain_count, 10) #define meshtastic_StoreForwardPlusPlus_CALLBACK NULL #define meshtastic_StoreForwardPlusPlus_DEFAULT NULL +#define meshtastic_RemoteShell_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, op, 1) \ +X(a, STATIC, SINGULAR, UINT32, session_id, 2) \ +X(a, STATIC, SINGULAR, UINT32, seq, 3) \ +X(a, STATIC, SINGULAR, UINT32, ack_seq, 4) \ +X(a, STATIC, SINGULAR, BYTES, payload, 5) \ +X(a, STATIC, SINGULAR, UINT32, cols, 6) \ +X(a, STATIC, SINGULAR, UINT32, rows, 7) \ +X(a, STATIC, SINGULAR, UINT32, flags, 8) +#define meshtastic_RemoteShell_CALLBACK NULL +#define meshtastic_RemoteShell_DEFAULT NULL + #define meshtastic_Waypoint_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UINT32, id, 1) \ X(a, STATIC, OPTIONAL, SFIXED32, latitude_i, 2) \ @@ -2095,6 +2170,7 @@ extern const pb_msgdesc_t meshtastic_Routing_msg; extern const pb_msgdesc_t meshtastic_Data_msg; extern const pb_msgdesc_t meshtastic_KeyVerification_msg; extern const pb_msgdesc_t meshtastic_StoreForwardPlusPlus_msg; +extern const pb_msgdesc_t meshtastic_RemoteShell_msg; extern const pb_msgdesc_t meshtastic_Waypoint_msg; extern const pb_msgdesc_t meshtastic_StatusMessage_msg; extern const pb_msgdesc_t meshtastic_MqttClientProxyMessage_msg; @@ -2130,6 +2206,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_Data_fields &meshtastic_Data_msg #define meshtastic_KeyVerification_fields &meshtastic_KeyVerification_msg #define meshtastic_StoreForwardPlusPlus_fields &meshtastic_StoreForwardPlusPlus_msg +#define meshtastic_RemoteShell_fields &meshtastic_RemoteShell_msg #define meshtastic_Waypoint_fields &meshtastic_Waypoint_msg #define meshtastic_StatusMessage_fields &meshtastic_StatusMessage_msg #define meshtastic_MqttClientProxyMessage_fields &meshtastic_MqttClientProxyMessage_msg @@ -2185,6 +2262,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_NodeRemoteHardwarePin_size 29 #define meshtastic_Position_size 144 #define meshtastic_QueueStatus_size 23 +#define meshtastic_RemoteShell_size 241 #define meshtastic_RouteDiscovery_size 256 #define meshtastic_Routing_size 259 #define meshtastic_StatusMessage_size 81 diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h index a474e5b92..494ef4a54 100644 --- a/src/mesh/generated/meshtastic/portnums.pb.h +++ b/src/mesh/generated/meshtastic/portnums.pb.h @@ -76,6 +76,8 @@ typedef enum _meshtastic_PortNum { meshtastic_PortNum_ALERT_APP = 11, /* Module/port for handling key verification requests. */ meshtastic_PortNum_KEY_VERIFICATION_APP = 12, + /* Module/port for handling primitive remote shell access. */ + meshtastic_PortNum_REMOTE_SHELL_APP = 13, /* Provides a 'ping' service that replies to any packet it receives. Also serves as a small example module. ENCODING: ASCII Plaintext */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index f48d946a4..8c0fdd563 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -113,7 +113,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* SCD30 CO2, humidity, temperature sensor */ meshtastic_TelemetrySensorType_SCD30 = 49, /* SHT family of sensors for temperature and humidity */ - meshtastic_TelemetrySensorType_SHTXX = 50 + meshtastic_TelemetrySensorType_SHTXX = 50, + /* DS248X Bridge for one-wire temperature sensors */ + meshtastic_TelemetrySensorType_DS248X = 51 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -206,6 +208,9 @@ typedef struct _meshtastic_EnvironmentMetrics { /* Soil temperature measured (*C) */ bool has_soil_temperature; float soil_temperature; + /* One-wire temperature (*C) */ + pb_size_t one_wire_temperature_count; + float one_wire_temperature[8]; } meshtastic_EnvironmentMetrics; /* Power Metrics (voltage / current / etc) */ @@ -491,8 +496,8 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SHTXX -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SHTXX+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_DS248X +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_DS248X+1)) @@ -508,7 +513,7 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_DeviceMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_PowerMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_default {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -519,7 +524,7 @@ extern "C" { #define meshtastic_Nau7802Config_init_default {0, 0} #define meshtastic_SEN5XState_init_default {0, 0, 0, false, 0, false, 0, false, 0} #define meshtastic_DeviceMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0} -#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} +#define meshtastic_EnvironmentMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, 0, {0, 0, 0, 0, 0, 0, 0, 0}} #define meshtastic_PowerMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_AirQualityMetrics_init_zero {false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0} #define meshtastic_LocalStats_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} @@ -558,6 +563,7 @@ extern "C" { #define meshtastic_EnvironmentMetrics_rainfall_24h_tag 20 #define meshtastic_EnvironmentMetrics_soil_moisture_tag 21 #define meshtastic_EnvironmentMetrics_soil_temperature_tag 22 +#define meshtastic_EnvironmentMetrics_one_wire_temperature_tag 23 #define meshtastic_PowerMetrics_ch1_voltage_tag 1 #define meshtastic_PowerMetrics_ch1_current_tag 2 #define meshtastic_PowerMetrics_ch2_voltage_tag 3 @@ -683,7 +689,8 @@ X(a, STATIC, OPTIONAL, FLOAT, radiation, 18) \ X(a, STATIC, OPTIONAL, FLOAT, rainfall_1h, 19) \ X(a, STATIC, OPTIONAL, FLOAT, rainfall_24h, 20) \ X(a, STATIC, OPTIONAL, UINT32, soil_moisture, 21) \ -X(a, STATIC, OPTIONAL, FLOAT, soil_temperature, 22) +X(a, STATIC, OPTIONAL, FLOAT, soil_temperature, 22) \ +X(a, STATIC, REPEATED, FLOAT, one_wire_temperature, 23) #define meshtastic_EnvironmentMetrics_CALLBACK NULL #define meshtastic_EnvironmentMetrics_DEFAULT NULL @@ -852,7 +859,7 @@ extern const pb_msgdesc_t meshtastic_SEN5XState_msg; #define MESHTASTIC_MESHTASTIC_TELEMETRY_PB_H_MAX_SIZE meshtastic_Telemetry_size #define meshtastic_AirQualityMetrics_size 150 #define meshtastic_DeviceMetrics_size 27 -#define meshtastic_EnvironmentMetrics_size 113 +#define meshtastic_EnvironmentMetrics_size 161 #define meshtastic_HealthMetrics_size 11 #define meshtastic_HostMetrics_size 264 #define meshtastic_LocalStats_size 87 From 4587dc2d64e6d89d82e572917896ed6fedc51ab7 Mon Sep 17 00:00:00 2001 From: Tom Fifield Date: Wed, 15 Apr 2026 00:55:11 +1000 Subject: [PATCH 60/74] Add RADIOLIB_EXCLUDE_LR2021 in places that excluded LR11x0 (#10112) To save resources, some devices where LR11x0 was never an option excluded it from compilation, using RADIOLIB_EXCLUDE_LR11X0 . As we will soon have LR2021 support, apply the same treatment to that chip to these devices, by adding RADIOLIB_EXCLUDE_LR2021 --- variants/esp32/heltec_wireless_bridge/platformio.ini | 1 + variants/esp32s3/link32_s3_v1/platformio.ini | 1 + variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini | 1 + variants/nrf52840/meshlink/platformio.ini | 4 +++- variants/nrf52840/r1-neo/platformio.ini | 1 + variants/nrf52840/rak2560/platformio.ini | 1 + variants/nrf52840/rak3401_1watt/platformio.ini | 1 + variants/nrf52840/rak4631/platformio.ini | 1 + variants/nrf52840/rak4631_epaper/platformio.ini | 1 + variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini | 1 + variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini | 1 + variants/nrf52840/rak_wismeshtag/platformio.ini | 1 + variants/stm32/stm32.ini | 1 + 13 files changed, 15 insertions(+), 1 deletion(-) diff --git a/variants/esp32/heltec_wireless_bridge/platformio.ini b/variants/esp32/heltec_wireless_bridge/platformio.ini index 6f9de7a84..42a35697c 100644 --- a/variants/esp32/heltec_wireless_bridge/platformio.ini +++ b/variants/esp32/heltec_wireless_bridge/platformio.ini @@ -10,6 +10,7 @@ build_flags = -D BOARD_HAS_PSRAM -D RADIOLIB_EXCLUDE_LR11X0=1 -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_LR2021=1 -D MESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 -D MESHTASTIC_EXCLUDE_DETECTIONSENSOR=1 -D MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1 diff --git a/variants/esp32s3/link32_s3_v1/platformio.ini b/variants/esp32s3/link32_s3_v1/platformio.ini index acce3dafb..b11ffaad0 100644 --- a/variants/esp32s3/link32_s3_v1/platformio.ini +++ b/variants/esp32s3/link32_s3_v1/platformio.ini @@ -11,3 +11,4 @@ build_flags = -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 diff --git a/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini b/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini index e7eede80f..cecca3d81 100644 --- a/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini +++ b/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini @@ -11,4 +11,5 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/gat562_mesh_trial_tracker> diff --git a/variants/nrf52840/meshlink/platformio.ini b/variants/nrf52840/meshlink/platformio.ini index 28122d9bd..f3dc6185c 100644 --- a/variants/nrf52840/meshlink/platformio.ini +++ b/variants/nrf52840/meshlink/platformio.ini @@ -12,6 +12,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/meshlink> debug_tool = jlink @@ -30,6 +31,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -D USE_EINK -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 -D EINK_WIDTH=250 @@ -51,4 +53,4 @@ lib_deps = debug_tool = jlink ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ; Note: as of 6/2013 the serial/bootloader based programming takes approximately 30 seconds -;upload_protocol = jlink \ No newline at end of file +;upload_protocol = jlink diff --git a/variants/nrf52840/r1-neo/platformio.ini b/variants/nrf52840/r1-neo/platformio.ini index 85fe49cf1..0aaec2330 100644 --- a/variants/nrf52840/r1-neo/platformio.ini +++ b/variants/nrf52840/r1-neo/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/r1-neo> + + lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak2560/platformio.ini b/variants/nrf52840/rak2560/platformio.ini index 1703a13ae..54b66f4b2 100644 --- a/variants/nrf52840/rak2560/platformio.ini +++ b/variants/nrf52840/rak2560/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -DHAS_RAKPROT=1 ; Define if RAk OneWireSerial is used (disables GPS) build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak2560> + + + lib_deps = diff --git a/variants/nrf52840/rak3401_1watt/platformio.ini b/variants/nrf52840/rak3401_1watt/platformio.ini index bb8fa28df..889a17ed6 100644 --- a/variants/nrf52840/rak3401_1watt/platformio.ini +++ b/variants/nrf52840/rak3401_1watt/platformio.ini @@ -22,6 +22,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak3401_1watt> + lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 4a96fc8d9..179d73e92 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -21,6 +21,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} \ +<../variants/nrf52840/rak4631> \ + \ diff --git a/variants/nrf52840/rak4631_epaper/platformio.ini b/variants/nrf52840/rak4631_epaper/platformio.ini index caa6ea328..f71fb6301 100644 --- a/variants/nrf52840/rak4631_epaper/platformio.ini +++ b/variants/nrf52840/rak4631_epaper/platformio.ini @@ -11,6 +11,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631_epaper> lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini index 84a582fd9..670b2c415 100644 --- a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini +++ b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini @@ -13,6 +13,7 @@ build_flags = ${nrf52840_base.build_flags} -D RADIOLIB_EXCLUDE_SX128X=1 -D RADIOLIB_EXCLUDE_SX127X=1 -D RADIOLIB_EXCLUDE_LR11X0=1 + -D RADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631_epaper_onrxtx> lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini b/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini index 9b15e668a..f1641e7e4 100644 --- a/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini +++ b/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini @@ -21,6 +21,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631_nomadstar_meteor_pro> + + lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index 1e6e63e60..07fd6e73f 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -19,5 +19,6 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -DMESHTASTIC_EXCLUDE_WIFI=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak_wismeshtag> diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index d2c155398..542d08800 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -35,6 +35,7 @@ build_flags = -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -DHAL_DAC_MODULE_ONLY -DHAL_RNG_MODULE_ENABLED -Wl,--wrap=__assert_func From 9e182a595c83fcd18ac9dc84f1684e09d43eff8e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 13 Apr 2026 06:43:11 -0500 Subject: [PATCH 61/74] Enhance release notes generation with commit range comparison --- .github/workflows/main_matrix.yml | 6 ++- bin/generate_release_notes.py | 83 ++++++++++++++++++++----------- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index f0b16a31f..88395600a 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -301,10 +301,12 @@ jobs: id: release_notes run: | chmod +x ./bin/generate_release_notes.py - NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }}) + NOTES=$(./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD 2>release_notes.log) echo "notes<> $GITHUB_OUTPUT echo "$NOTES" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT + echo "### Release note range" >> $GITHUB_STEP_SUMMARY + cat release_notes.log >> $GITHUB_STEP_SUMMARY env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -466,7 +468,7 @@ jobs: - name: Generate release notes run: | chmod +x ./bin/generate_release_notes.py - ./bin/generate_release_notes.py ${{ needs.version.outputs.long }} > ./publish/release_notes.md + ./bin/generate_release_notes.py ${{ needs.version.outputs.long }} --compare-ref HEAD > ./publish/release_notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/bin/generate_release_notes.py b/bin/generate_release_notes.py index d0f1147da..533ff6909 100755 --- a/bin/generate_release_notes.py +++ b/bin/generate_release_notes.py @@ -1,25 +1,31 @@ #!/usr/bin/env python3 -""" -Generate release notes from merged PRs on develop and master branches. -Categorizes PRs into Enhancements and Bug Fixes/Maintenance sections. -""" +"""Generate release notes from the actual release commit range.""" -import subprocess -import re +import argparse import json +import re +import subprocess import sys -from datetime import datetime -def get_last_release_tag(): - """Get the most recent release tag.""" +def get_last_release_tag(compare_ref, exclude_tag=None): + """Get the most recent version tag merged into compare_ref.""" result = subprocess.run( - ["git", "describe", "--tags", "--abbrev=0"], + ["git", "tag", "--merged", compare_ref, "--sort=-version:refname", "v*"], capture_output=True, text=True, check=True, ) - return result.stdout.strip() + + for line in result.stdout.splitlines(): + candidate = line.strip() + if not candidate: + continue + if exclude_tag and candidate == exclude_tag: + continue + return candidate + + raise subprocess.CalledProcessError(result.returncode, result.args, output=result.stdout, stderr=result.stderr) def get_tag_date(tag): @@ -33,18 +39,18 @@ def get_tag_date(tag): return result.stdout.strip() -def get_merged_prs_since_tag(tag, branch): - """Get all merged PRs since the given tag on the specified branch.""" - # Get commits since tag on the branch - look for PR numbers in parentheses +def get_merged_prs_in_range(tag, compare_ref): + """Get all merged PRs in the git range between tag and compare_ref.""" result = subprocess.run( [ "git", "log", - f"{tag}..origin/{branch}", + f"{tag}..{compare_ref}", "--oneline", ], capture_output=True, text=True, + check=True, ) prs = [] @@ -65,6 +71,25 @@ def get_merged_prs_since_tag(tag, branch): return prs +def parse_args(): + """Parse CLI arguments.""" + parser = argparse.ArgumentParser( + description="Generate release notes from the actual release commit range." + ) + parser.add_argument("new_version", help="Version that will be tagged for this release") + parser.add_argument( + "--base-tag", + dest="base_tag", + help="Existing version tag to diff from. Defaults to the latest version tag merged into the compare ref.", + ) + parser.add_argument( + "--compare-ref", + default="HEAD", + help="Git ref to diff to. Defaults to HEAD.", + ) + return parser.parse_args() + + def get_pr_details(pr_number): """Get PR details from GitHub API via gh CLI.""" try: @@ -268,28 +293,28 @@ def get_new_contributors(pr_details_list, tag, repo="meshtastic/firmware"): def main(): - if len(sys.argv) < 2: - print("Usage: generate_release_notes.py ", file=sys.stderr) - sys.exit(1) - - new_version = sys.argv[1] + args = parse_args() + new_version = args.new_version + compare_ref = args.compare_ref + current_tag = f"v{new_version}" # Get last release tag try: - last_tag = get_last_release_tag() + last_tag = args.base_tag or get_last_release_tag(compare_ref, exclude_tag=current_tag) except subprocess.CalledProcessError: print("Error: Could not find last release tag", file=sys.stderr) sys.exit(1) - # Collect PRs from both branches - all_pr_numbers = set() + print( + f"Resolved release note range: {last_tag}..{compare_ref}", + file=sys.stderr, + ) - for branch in ["develop", "master"]: - try: - prs = get_merged_prs_since_tag(last_tag, branch) - all_pr_numbers.update(prs) - except Exception as e: - print(f"Warning: Could not get PRs from {branch}: {e}", file=sys.stderr) + try: + all_pr_numbers = set(get_merged_prs_in_range(last_tag, compare_ref)) + except subprocess.CalledProcessError as e: + print(f"Error: Could not get PRs for range {last_tag}..{compare_ref}: {e}", file=sys.stderr) + sys.exit(1) # Get details for all PRs enhancements = [] From a67eb15ad33e865220903c07866cbdc3fae11ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Mon, 13 Apr 2026 15:48:30 +0200 Subject: [PATCH 62/74] fix last cppcheck issue (#10154) --- src/input/CardputerKeyboard.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/input/CardputerKeyboard.cpp b/src/input/CardputerKeyboard.cpp index ec1ed383a..1bd695461 100644 --- a/src/input/CardputerKeyboard.cpp +++ b/src/input/CardputerKeyboard.cpp @@ -121,7 +121,6 @@ void CardputerKeyboard::pressed(uint8_t key) modifierFlag = 0; } - uint8_t next_key = 0; int row = (key - 1) / 10; int col = (key - 1) % 10; From d24d8806e13569d9dd88271cf8eef37736e58f97 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Mon, 13 Apr 2026 14:50:51 -0500 Subject: [PATCH 63/74] Fix heap blowout on TBeams (#10155) * Fix heap blowout on TBeams * Update src/graphics/draw/MessageRenderer.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Set MESSAGE_HISTORY_LIMIT to 10 for original ESP32 to optimize RAM usage * Optimize message frame allocation to prevent excessive memory usage * Refine message history limits for resource-constrained builds and cap cached lines to prevent heap overflow * Update src/graphics/draw/MessageRenderer.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/MessageStore.h | 7 +++++++ src/graphics/draw/MessageRenderer.cpp | 22 ++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/MessageStore.h b/src/MessageStore.h index 6203d8ed0..77271f1c9 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -21,8 +21,15 @@ // How many messages are stored (RAM + flash). // Define -DMESSAGE_HISTORY_LIMIT=N in build_flags to control memory usage. #ifndef MESSAGE_HISTORY_LIMIT +#if defined(ARCH_ESP32) && \ + !(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32S2)) +// Baseline ESP32 (non-PSRAM variants) has limited heap; reduce message history on resource-constrained builds. +// Override with -DMESSAGE_HISTORY_LIMIT=N if needed. +#define MESSAGE_HISTORY_LIMIT 10 +#else #define MESSAGE_HISTORY_LIMIT 20 #endif +#endif // Internal alias used everywhere in code – do NOT redefine elsewhere. #define MAX_MESSAGES_SAVED MESSAGE_HISTORY_LIMIT diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index 501a7ae2c..2fd9bf541 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -422,6 +422,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 std::vector isMine; // track alignment std::vector isHeader; // track header lines std::vector ackForLine; + // Hard limit on total cached lines to prevent unbounded growth from a single long message. + // Reserve to the actual cache cap up front, because a single message can expand to many more + // wrapped display lines than a small per-message estimate would predict. For a display + // rendering only ~5-30 lines at a time, caching more than this limit wastes heap. Stop + // appending once we reach MAX_CACHED_LINES to prevent a single message from blowing out the + // heap. + constexpr size_t MAX_CACHED_LINES = 100U; // ~5-6KB for std::string overhead on 32-bit (if each ~50-60 bytes avg) + allLines.reserve(MAX_CACHED_LINES); + isMine.reserve(MAX_CACHED_LINES); + isHeader.reserve(MAX_CACHED_LINES); + ackForLine.reserve(MAX_CACHED_LINES); for (auto it = filtered.rbegin(); it != filtered.rend(); ++it) { const auto &m = *it; @@ -565,16 +576,23 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 int wrapWidth = mine ? rightTextWidth : leftTextWidth; std::vector wrapped = generateLines(display, "", msgText, wrapWidth); + // Per-message wrap-line limit: even if wrapping produces many lines, cap them to prevent + // a single long message from consuming most or all of the cache. + constexpr size_t MAX_WRAPPED_LINES_PER_MSG = 20U; + size_t wrappedCount = 0; for (auto &ln : wrapped) { - allLines.push_back(ln); + if (allLines.size() >= MAX_CACHED_LINES || wrappedCount >= MAX_WRAPPED_LINES_PER_MSG) + break; // Cache limit or per-message limit reached; stop adding lines from this message + allLines.emplace_back(std::move(ln)); isMine.push_back(mine); isHeader.push_back(false); ackForLine.push_back(AckStatus::NONE); + ++wrappedCount; } } // Cache lines and heights - cachedLines = allLines; + cachedLines.swap(allLines); cachedHeights = calculateLineHeights(cachedLines, emotes, isHeader); std::vector blocks = buildMessageBlocks(isHeader, isMine); From 4059202a5c8d33e99dde76a538c67f8d46583958 Mon Sep 17 00:00:00 2001 From: Jennifer Sanchez <67692052+derpyspike@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:11:36 +0200 Subject: [PATCH 64/74] Added support for Spreading Factors 5 and 6 on compatible radios (#10160) --- src/mesh/MeshRadio.h | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 3c3a4cf65..089b4b189 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -4,6 +4,7 @@ #include "MeshTypes.h" #include "PointerQueue.h" #include "configuration.h" +#include "detect/LoRaRadioType.h" // Sentinel marking the end of a modem preset array static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = @@ -59,7 +60,7 @@ extern const RegionInfo *myRegion; extern void initRegion(); // Valid LoRa spread factor range and defaults -constexpr uint8_t LORA_SF_MIN = 7; +constexpr uint8_t LORA_SF_MIN = 5; constexpr uint8_t LORA_SF_MAX = 12; constexpr uint8_t LORA_SF_DEFAULT = 11; // LONG_FAST default @@ -71,10 +72,14 @@ constexpr uint8_t LORA_CR_DEFAULT = 5; // LONG_FAST default // Default bandwidth in kHz (LONG_FAST) constexpr float LORA_BW_DEFAULT_KHZ = 250.0f; -/// Clamp spread factor to the valid LoRa range [7, 12]. +/// Clamp spread factor to the valid LoRa range [5, 12]. /// Out-of-range values (including 0 from unset preset mode) return LORA_SF_DEFAULT. static inline uint8_t clampSpreadFactor(uint8_t sf) { + // We check for RF95 radios that are incompatible with Spreading Factors 5 and 6. + if (radioType == RF95_RADIO && (sf == 5 || sf == 6)) + return LORA_SF_DEFAULT; + if (sf < LORA_SF_MIN || sf > LORA_SF_MAX) return LORA_SF_DEFAULT; return sf; From 1341cd4078ecb7060df5d37ccd0c909c7e06f07f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:11:47 -0500 Subject: [PATCH 65/74] Automated version bumps (#10159) 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 0642fdb07..a1690186b 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.23 + https://github.com/meshtastic/firmware/releases?q=tag%3Av2.7.22 diff --git a/debian/changelog b/debian/changelog index b13a2ae9d..c3f1424a5 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +meshtasticd (2.7.23.0) unstable; urgency=medium + + * Version 2.7.23 + + -- GitHub Actions Tue, 14 Apr 2026 12:29:48 +0000 + meshtasticd (2.7.22.0) unstable; urgency=medium * Version 2.7.22 diff --git a/version.properties b/version.properties index 8621dd9c9..4ee342bb8 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ [VERSION] major = 2 minor = 7 -build = 22 +build = 23 From 0cab43fb43192cb81d585f3a0fded4fe1d286cc7 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 14 Apr 2026 14:32:48 -0500 Subject: [PATCH 66/74] Add PortduinoSetOptions to overwrite the realhardware bool (#10157) Co-authored-by: Ben Meadors --- src/platform/portduino/PortduinoGlue.cpp | 4 +++- variants/native/portduino.ini | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 5f51ee083..7833b3603 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -654,7 +654,9 @@ void portduinoSetup() if (verboseEnabled && portduino_config.logoutputlevel != level_trace) { portduino_config.logoutputlevel = level_debug; } - + if (portduino_config.lora_spi_dev != "") { + portduinoSetOptions({.realHardware = true}); + } return; } diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 86b1fe60a..87d8431a3 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/f566d364204416cdbf298e349213f7d551f793d9.zip + https://github.com/meshtastic/platform-native/archive/71ed55bb95feb3c43ebde1ec1e2e17643a424c04.zip framework = arduino build_src_filter = From 026213aab74c13a55915eeb8d4a1089efe321e43 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 16 Apr 2026 17:58:54 +0800 Subject: [PATCH 67/74] feat(stm32): Add STM32 ADC support to AnalogBatteryLevel (#9369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate STM32 battery monitoring into AnalogBatteryLevel, supporting external GPIO ADC pins as well as internal VBAT channel. Features: - ADC reading using STM32 LL (Lower Layer) macros supporting external ADC channels and internal VBAT channel (AVBAT) - ADC compensation using STM32 LL macros with factory-calibrated VREFINT (AVREF) for accurate voltage measurement - LFP battery OCV curve for STM32WL using AVBAT (STM32 VDD absolute maximum supply voltage 3.9V, direct connection of Li-Po batteries is not supported) Internal VBAT channel implemented in: - Russell - RAK3172 In these variants, ADC_MULTIPLIER = (1.01f * 3) = 3.30 as there is a 3:1 internal divider (DS13105 Rev 12 §5.3.21), and a bit of tolerance as the actual 10% spec leads to readings much too high. Assisted-by: Claude:sonnet-4-5 Signed-off-by: Andrew Yong --- src/Power.cpp | 39 ++++++++++++++++++++++++++++---- src/power.h | 5 ++++ variants/stm32/rak3172/variant.h | 5 ++++ variants/stm32/russell/variant.h | 10 ++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index d82c870ed..26b961525 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -40,6 +40,22 @@ #include "concurrency/LockGuard.h" #endif +#if defined(ARCH_STM32WL) && defined(BATTERY_PIN) +#include "stm32yyxx_ll_adc.h" + +/* Analog read resolution */ +#if defined(LL_ADC_RESOLUTION_12B) +#define LL_ADC_RESOLUTION LL_ADC_RESOLUTION_12B +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#elif defined(LL_ADC_DS_DATA_WIDTH_12_BIT) +#define LL_ADC_RESOLUTION LL_ADC_DS_DATA_WIDTH_12_BIT +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#else +#error "ADC resolution could not be defined!" +#endif +#define ADC_RANGE (1 << BATTERY_SENSE_RESOLUTION_BITS) +#endif + #if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" #include "target_specific.h" @@ -328,11 +344,17 @@ class AnalogBatteryLevel : public HasBatteryLevel float scaled = 0; battery_adcEnable(); -#ifdef ARCH_ESP32 // ADC block for espressif platforms +#ifdef ARCH_STM32WL + // STM32 ADC with VREFINT runtime calibration + Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION); + raw = analogRead(BATTERY_PIN); + scaled = __LL_ADC_CALC_DATA_TO_VOLTAGE(Vref, raw, LL_ADC_RESOLUTION); + scaled *= operativeAdcMultiplier; +#elif defined(ARCH_ESP32) // ADC block for espressif platforms raw = espAdcRead(); scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs); scaled *= operativeAdcMultiplier; -#else // block for all other platforms +#else // block for all other platforms #ifdef ARCH_NRF52 concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock); #endif @@ -530,6 +552,11 @@ class AnalogBatteryLevel : public HasBatteryLevel bool initial_read_done = false; float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS); uint32_t last_read_time_ms = 0; +#ifdef ARCH_STM32WL + // 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing + // (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset") + uint32_t Vref = 3300; +#endif #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) @@ -639,7 +666,9 @@ bool Power::analogInit() #define BATTERY_SENSE_RESOLUTION_BITS 10 #endif -#ifdef ARCH_ESP32 // ESP32 needs special analog stuff +#ifdef ARCH_STM32WL + analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); +#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff #ifndef ADC_WIDTH // max resolution by default static const adc_bits_width_t width = ADC_WIDTH_BIT_12; @@ -649,7 +678,7 @@ bool Power::analogInit() #ifndef BAT_MEASURE_ADC_UNIT // ADC1 adc1_config_width(width); adc1_config_channel_atten(adc_channel, atten); -#else // ADC2 +#else // ADC2 adc2_config_channel_atten(adc_channel, atten); #ifndef CONFIG_IDF_TARGET_ESP32S3 // ADC2 wifi bug workaround @@ -679,7 +708,7 @@ bool Power::analogInit() // NRF52 ADC init moved to powerHAL_init in nrf52 platform -#ifndef ARCH_ESP32 +#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL) analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); #endif diff --git a/src/power.h b/src/power.h index b129e2b74..d46eaadd2 100644 --- a/src/power.h +++ b/src/power.h @@ -15,8 +15,13 @@ // Device specific curves go in variant.h #ifndef OCV_ARRAY +#if defined(ARCH_STM32WL) && BATTERY_PIN == AVBAT +// STM32 VDD/VBAT absolute maximum is 4V so use an LFP curve +#define OCV_ARRAY 3650, 3400, 3340, 3320, 3300, 3280, 3270, 3260, 3240, 3200, 2500 +#else #define OCV_ARRAY 4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100 #endif +#endif /*Note: 12V lead acid is 6 cells, most board accept only 1 cell LiIon/LiPo*/ #ifndef NUM_CELLS diff --git a/variants/stm32/rak3172/variant.h b/variants/stm32/rak3172/variant.h index bd6decd4c..75e3e0c91 100644 --- a/variants/stm32/rak3172/variant.h +++ b/variants/stm32/rak3172/variant.h @@ -16,6 +16,11 @@ Do not expect a working Meshtastic device with this target. #define LED_POWER PA0 // Green LED #define LED_STATE_ON 1 +#define BATTERY_PIN AVBAT +// ADC_MULTIPLIER: 3.0 = internal 1:3 bridge divider (DS13105§3.18.3) +// Margin: 1.10 = AVBAT divider tolerance ±10% (Table 82) +#define ADC_MULTIPLIER (1.01f * 3) + #define RAK3172 #define SERIAL_PRINT_PORT 1 diff --git a/variants/stm32/russell/variant.h b/variants/stm32/russell/variant.h index 8773d5d8d..7b5d4e9a1 100644 --- a/variants/stm32/russell/variant.h +++ b/variants/stm32/russell/variant.h @@ -13,6 +13,16 @@ // #define EXT_CHRG_DETECT PA5 // #define EXT_PWR_DETECT PA4 +#define BATTERY_PIN AVBAT +// ADC_MULTIPLIER: 3.0 = internal 1:3 bridge divider (DS13105§3.18.3) +// Margin: 1.10 = AVBAT divider tolerance ±10% (Table 82) +#define ADC_MULTIPLIER (1.01f * 3) +/* +Sample OCV curve for Li-SOCl2 primary lithium cells (e.g. Saft cells have fresh OCV of 3.67V) +#define NUM_OCV_POINTS 11 +#define OCV_ARRAY 3670, 3650, 3630, 3610, 3590, 3560, 3530, 3480, 3400, 3200, 2500 +*/ + // Bosch Sensortec BME280 #define HAS_SENSOR 1 From 0ee5777c1593cf35e7137740e930b74fffb61c5a Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 16 Apr 2026 10:35:05 +0800 Subject: [PATCH 68/74] stm32wl(mem): fix getFreeHeap() underreporting on dynamic sbrk heap mallinfo().fordblks counts only free bytes within the committed arena. On STM32WL (newlib sbrk heap) the arena grows lazily from _end toward SP, so fordblks reads near-zero at early boot even when ~48 KB of addressable space remains. This caused NodeDB::isFull() to fire prematurely and evict nodes on a freshly booted device. Fix getFreeHeap() to include uncommitted sbrk headroom (SP - sbrk(0)) so the returned value reflects true available memory throughout the boot lifecycle. Introduce MESHTASTIC_DYNAMIC_SBRK_HEAP as an opt-in build flag (set in stm32.ini) so the fix is gated to platforms with a dynamic sbrk heap rather than a static heap. Future platforms with the same heap model can opt in by adding this flag. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 --- src/memGet.cpp | 22 +++++++++++++++++----- variants/stm32/stm32.ini | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/memGet.cpp b/src/memGet.cpp index 14e614014..570bbf5b2 100644 --- a/src/memGet.cpp +++ b/src/memGet.cpp @@ -10,8 +10,20 @@ #include "memGet.h" #include "configuration.h" -#ifdef ARCH_STM32WL +#if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) #include +#include // sbrk + +// Returns the uncommitted sbrk headroom: addressable space between the current heap +// break and the stack pointer that has not yet been committed to the arena. +// Currently used on: ARCH_STM32WL +static uint32_t sbrkHeadroom() +{ + uint32_t sp; + __asm volatile("mov %0, sp" : "=r"(sp)); + uint32_t heap_end = (uint32_t)sbrk(0); + return (sp > heap_end) ? (sp - heap_end) : 0; +} #endif MemGet memGet; @@ -28,9 +40,9 @@ uint32_t MemGet::getFreeHeap() return dbgHeapFree(); #elif defined(ARCH_RP2040) return rp2040.getFreeHeap(); -#elif defined(ARCH_STM32WL) +#elif defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) // Currently: ARCH_STM32WL struct mallinfo m = mallinfo(); - return m.fordblks; // Total free space (bytes) + return m.fordblks + sbrkHeadroom(); // Free space within arena + uncommitted sbrk headroom #else // this platform does not have heap management function implemented return UINT32_MAX; @@ -49,9 +61,9 @@ uint32_t MemGet::getHeapSize() return dbgHeapTotal(); #elif defined(ARCH_RP2040) return rp2040.getTotalHeap(); -#elif defined(ARCH_STM32WL) +#elif defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) // Currently: ARCH_STM32WL struct mallinfo m = mallinfo(); - return m.arena; // Non-mmapped space allocated (bytes) + return m.arena + sbrkHeadroom(); // Non-mmapped space allocated + uncommitted sbrk headroom #else // this platform does not have heap management function implemented return UINT32_MAX; diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 542d08800..c49db27f3 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -36,6 +36,7 @@ build_flags = -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 -DRADIOLIB_EXCLUDE_LR2021=1 + -DMESHTASTIC_DYNAMIC_SBRK_HEAP -DHAL_DAC_MODULE_ONLY -DHAL_RNG_MODULE_ENABLED -Wl,--wrap=__assert_func From 31418ca8214145497c2b8c80552675b8232d68c1 Mon Sep 17 00:00:00 2001 From: Chloe Bethel Date: Thu, 16 Apr 2026 12:21:16 +0100 Subject: [PATCH 69/74] stm32wl: reserve 2KB of stack via linker script to match NRF52, change sbrkHeadroom to use the start of the reserved stack region instead of the current stack pointer The linker script was created by merging variants/STM32WLxx/WL54JCI_WL55JCI_WLE4J(8-B-C)I_WLE5J(8-B-C)I/ldscript.ld and system/ldscript.ld from stm32duino/Arduino_Core_STM32. --- src/memGet.cpp | 14 ++- variants/stm32/stm32.ini | 3 + variants/stm32/stm32wle5xx.ld | 204 ++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 variants/stm32/stm32wle5xx.ld diff --git a/src/memGet.cpp b/src/memGet.cpp index 570bbf5b2..42a3430f6 100644 --- a/src/memGet.cpp +++ b/src/memGet.cpp @@ -14,16 +14,22 @@ #include #include // sbrk +#ifdef ARCH_STM32WL // Returns the uncommitted sbrk headroom: addressable space between the current heap // break and the stack pointer that has not yet been committed to the arena. -// Currently used on: ARCH_STM32WL static uint32_t sbrkHeadroom() { - uint32_t sp; - __asm volatile("mov %0, sp" : "=r"(sp)); + // defined in STM32 linker script + extern char _estack; + extern char _Min_Stack_Size; + + uint32_t max_sp = (uint32_t)(&_estack - &_Min_Stack_Size); uint32_t heap_end = (uint32_t)sbrk(0); - return (sp > heap_end) ? (sp - heap_end) : 0; + return (max_sp > heap_end) ? (max_sp - heap_end) : 0; } +#else +#error Unsupported architecture! +#endif #endif MemGet memGet; diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index c49db27f3..1efe18e3d 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -58,3 +58,6 @@ lib_deps = lib_ignore = OneButton + +; Set a custom linker script with a higher MinStackSize value, to match NRF52. +board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld \ No newline at end of file diff --git a/variants/stm32/stm32wle5xx.ld b/variants/stm32/stm32wle5xx.ld new file mode 100644 index 000000000..c13782926 --- /dev/null +++ b/variants/stm32/stm32wle5xx.ld @@ -0,0 +1,204 @@ +/* +****************************************************************************** +** +** File : LinkerScript.ld +** +** Author : STM32CubeIDE +** +** Abstract : Linker script for STM32WL55xC Device +** 256Kbytes FLASH +** 64Kbytes RAM +** +** Set heap size, stack size and stack location according +** to application requirements. +** +** Set memory bank area and size if external memory is used. +** +** Target : STMicroelectronics STM32 +** +** Distribution: The file is distributed as is without any warranty +** of any kind. +** +***************************************************************************** +** @attention +** +**

© Copyright (c) 2020 STMicroelectronics. +** All rights reserved.

+** +** This software component is licensed by ST under BSD 3-Clause license, +** the "License"; You may not use this file except in compliance with the +** License. You may obtain a copy of the License at: +** opensource.org/licenses/BSD-3-Clause +** +***************************************************************************** +*/ + +/* Entry Point */ +ENTRY(Reset_Handler) + +/* Highest address of the user mode stack */ +_estack = ORIGIN(RAM) + LENGTH(RAM); /* end of "RAM" Ram type memory */ + +_Min_Heap_Size = 0x200 ; /* required amount of heap */ +/* Modified from original to 2KB, to match NRF52 */ +_Min_Stack_Size = 2048 ; /* required amount of stack */ + +/* Memories definition */ +MEMORY +{ + RAM (xrw) : ORIGIN = 0x20000000, LENGTH = LD_MAX_DATA_SIZE + FLASH (rx) : ORIGIN = 0x08000000 + LD_FLASH_OFFSET, LENGTH = LD_MAX_SIZE - LD_FLASH_OFFSET +} + +/* Sections */ +SECTIONS +{ + /* The startup code into "FLASH" Rom type memory */ + .isr_vector : + { + . = ALIGN(4); + KEEP(*(.isr_vector)) /* Startup code */ + . = ALIGN(4); + } >FLASH + + /* The program code and other data into "FLASH" Rom type memory */ + .text : + { + . = ALIGN(4); + *(.text) /* .text sections (code) */ + *(.text*) /* .text* sections (code) */ + *(.glue_7) /* glue arm to thumb code */ + *(.glue_7t) /* glue thumb to arm code */ + *(.eh_frame) + + KEEP (*(.init)) + KEEP (*(.fini)) + + . = ALIGN(4); + _etext = .; /* define a global symbols at end of code */ + } >FLASH + + /* Constant data into "FLASH" Rom type memory */ + .rodata : + { + . = ALIGN(4); + *(.rodata) /* .rodata sections (constants, strings, etc.) */ + *(.rodata*) /* .rodata* sections (constants, strings, etc.) */ + . = ALIGN(4); + } >FLASH + + .ARM.extab (READONLY) : { + . = ALIGN(4); + *(.ARM.extab* .gnu.linkonce.armextab.*) + . = ALIGN(4); + } >FLASH + + .ARM (READONLY) : { + . = ALIGN(4); + __exidx_start = .; + *(.ARM.exidx*) + __exidx_end = .; + . = ALIGN(4); + } >FLASH + + .preinit_array (READONLY) : + { + . = ALIGN(4); + PROVIDE_HIDDEN (__preinit_array_start = .); + KEEP (*(.preinit_array*)) + PROVIDE_HIDDEN (__preinit_array_end = .); + . = ALIGN(4); + } >FLASH + + .init_array (READONLY) : + { + . = ALIGN(4); + PROVIDE_HIDDEN (__init_array_start = .); + KEEP (*(SORT(.init_array.*))) + KEEP (*(.init_array*)) + PROVIDE_HIDDEN (__init_array_end = .); + . = ALIGN(4); + } >FLASH + + .fini_array (READONLY) : + { + . = ALIGN(4); + PROVIDE_HIDDEN (__fini_array_start = .); + KEEP (*(SORT(.fini_array.*))) + KEEP (*(.fini_array*)) + PROVIDE_HIDDEN (__fini_array_end = .); + . = ALIGN(4); + } >FLASH + + /* Used by the startup to initialize data */ + _sidata = LOADADDR(.data); + + /* Initialized data sections into "RAM" Ram type memory */ + .data : + { + . = ALIGN(4); + _sdata = .; /* create a global symbol at data start */ + *(.data) /* .data sections */ + *(.data*) /* .data* sections */ + *(.RamFunc) /* .RamFunc sections */ + *(.RamFunc*) /* .RamFunc* sections */ + + . = ALIGN(4); + _edata = .; /* define a global symbol at data end */ + + } >RAM AT> FLASH + + /* Uninitialized data section into "RAM" Ram type memory */ + . = ALIGN(4); + .bss : + { + /* This is used by the startup in order to initialize the .bss section */ + _sbss = .; /* define a global symbol at bss start */ + __bss_start__ = _sbss; + *(.bss) + *(.bss*) + *(COMMON) + + . = ALIGN(4); + _ebss = .; /* define a global symbol at bss end */ + __bss_end__ = _ebss; + } >RAM + + /* Define a noinit output section and mark it as NOLOAD to prevent + * putting its contents into the resulting .bin file (which is the + * default). */ + .noinit (NOLOAD) : + { + /* Ensure output is aligned */ + . = ALIGN(4); + /* Define a global _snoinit (and _enoinit below) symbol just in case + * code wants to iterate over all noinit variables for some reason */ + _snoinit = .; + /* Actually import the .noinit and .noinit* import sections */ + *(.noinit) + *(.noinit*) + . = ALIGN(4); + _enoinit = .; + } >RAM + + /* User_heap_stack section, used to check that there is enough "RAM" Ram type memory left */ + ._user_heap_stack : + { + . = ALIGN(8); + PROVIDE ( end = . ); + PROVIDE ( _end = . ); + . = . + _Min_Heap_Size; + . = . + _Min_Stack_Size; + . = ALIGN(8); + } >RAM + + /* Remove information from the compiler libraries */ + /DISCARD/ : + { + libc.a ( * ) + libm.a ( * ) + libgcc.a ( * ) + } + + .ARM.attributes 0 : { *(.ARM.attributes) } +} From fe90c497950884d7dca4177af7ff4c4622216da3 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 16 Apr 2026 23:39:37 +0800 Subject: [PATCH 70/74] fix/feat(stm32/russell): Serial2 build fix and BME680 support (#10097) * fix(stm32/russell): define ENABLE_HWSERIAL2 and Serial2 pins The Russell board variant was missed during Initial serialModule cleanup (PR #9465), which began requiring Serial2 to be explicitly defined via ENABLE_HWSERIAL2 and PIN_SERIAL2_TX/RX rather than relying on implicit defaults, causing a build error. Signed-off-by: Andrew Yong * feat(stm32/russell): add BME680 support, exclude modules to fit flash The BME680 is hardware footprint compatible with the BME280 already present on the Russell board, so add it as an additional lib dep to enable environment sensing (temperature, humidity, pressure, gas resistance). The STM32 target has very limited flash. Even traceroute alone causes overflow, so the following modules are excluded to stay within budget: - RANGETEST - DETECTIONSENSOR - EXTERNALNOTIFICATION - POWERSTRESS - NEIGHBORINFO - TRACEROUTE - WAYPOINT AIR_QUALITY_SENSOR is also excluded as it requires the BSEC2 library for real IAQ output, which alone overflows flash by ~44KB on this target. The Adafruit BME680 library is used instead for raw sensor readings. Signed-off-by: Andrew Yong --------- Signed-off-by: Andrew Yong Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors --- variants/stm32/russell/platformio.ini | 10 ++++++++++ variants/stm32/russell/variant.h | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 0dd57a2c7..73cf7f81a 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -13,9 +13,19 @@ build_flags = ${stm32_base.build_flags} -Ivariants/stm32/russell -DPRIVATE_HW + -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 + -DMESHTASTIC_EXCLUDE_RANGETEST=1 + -DMESHTASTIC_EXCLUDE_DETECTIONSENSOR=1 + -DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1 + -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 + -DMESHTASTIC_EXCLUDE_NEIGHBORINFO=1 + -DMESHTASTIC_EXCLUDE_TRACEROUTE=1 + -DMESHTASTIC_EXCLUDE_WAYPOINT=1 lib_deps = ${stm32_base.lib_deps} # renovate: datasource=custom.pio depName=Adafruit BME280 packageName=adafruit/library/Adafruit BME280 Library adafruit/Adafruit BME280 Library@2.3.0 + # renovate: datasource=custom.pio depName=Adafruit_BME680 packageName=adafruit/library/Adafruit BME680 Library + adafruit/Adafruit BME680 Library@2.0.6 upload_port = stlink diff --git a/variants/stm32/russell/variant.h b/variants/stm32/russell/variant.h index 7b5d4e9a1..d36826c15 100644 --- a/variants/stm32/russell/variant.h +++ b/variants/stm32/russell/variant.h @@ -30,6 +30,11 @@ Sample OCV curve for Li-SOCl2 primary lithium cells (e.g. Saft cells have fresh #define ENABLE_HWSERIAL1 #define PIN_SERIAL1_RX PB7 #define PIN_SERIAL1_TX PB6 + +// Debug serial (USART2) +#define ENABLE_HWSERIAL2 +#define PIN_SERIAL2_TX PA2 +#define PIN_SERIAL2_RX PA3 #define HAS_GPS 1 #define PIN_GPS_STANDBY PA15 #define GPS_RX_PIN PB7 From 466cc4cecddd11cd1bb0d0b166bd658d116832b3 Mon Sep 17 00:00:00 2001 From: Ruledo Date: Thu, 16 Apr 2026 08:41:06 -0700 Subject: [PATCH 71/74] Add Luckfox Pico Max Waveshare Pico LoRa config (#10175) Add a meshtasticd config for the Luckfox Pico Max with the Waveshare Pico LoRa SX1262 TCXO HAT. Tested on hardware with successful SX1262 init, broadcast, and direct messaging. --- ...fox-pico-max-ws-raspberry-pi-pico-hat.yaml | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml diff --git a/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml b/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml new file mode 100644 index 000000000..e0cc6197b --- /dev/null +++ b/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml @@ -0,0 +1,31 @@ +# For use with Armbian luckfox-pico-max +# Waveshare LoRa HAT for Raspberry Pi Pico +# https://www.waveshare.com/wiki/Pico-LoRa-SX1262 + +Meta: + name: luckfox-pico-max-ws-raspberry-pi-pico-hat + support: community + compatible: + - luckfox-pico-max # Armbian + +Lora: + Module: sx1262 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + spidev: spidev0.0 + Busy: # GPIO1_C7 / GP2 + pin: 55 + gpiochip: 1 + line: 23 + CS: # GPIO1_C6 / GP3 + pin: 54 + gpiochip: 1 + line: 22 + Reset: # GPIO1_D1 / GP15 + pin: 57 + gpiochip: 1 + line: 25 + IRQ: # GPIO2_A2 / GP20 + pin: 66 + gpiochip: 2 + line: 2 From 2768080edf13973894f8608892a8d6ba8f9f68ea Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 16 Apr 2026 13:12:31 -0500 Subject: [PATCH 72/74] More cleanly remove LED_BUILTIN (#10179) * Test PR to remove LED_BUILTIN Comment out the LED_BUILTIN definition in platformio.ini * Add LED_BUILTIN definition to nrf52840.ini --- platformio.ini | 2 +- variants/nrf52840/nrf52840.ini | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/platformio.ini b/platformio.ini index 46170fe09..f0af061cb 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,7 +58,7 @@ build_flags = -Wno-missing-field-initializers -DMESHTASTIC_EXCLUDE_POWERMON=1 -DMESHTASTIC_EXCLUDE_STATUS=1 -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage - -DLED_BUILTIN=-1 + #-DLED_BUILTIN=-1 #-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now #-D OLED_PL=1 #-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs diff --git a/variants/nrf52840/nrf52840.ini b/variants/nrf52840/nrf52840.ini index 09b2ef97d..c5590cbc3 100644 --- a/variants/nrf52840/nrf52840.ini +++ b/variants/nrf52840/nrf52840.ini @@ -4,6 +4,7 @@ extends = nrf52_base build_flags = ${nrf52_base.build_flags} -DSERIAL_BUFFER_SIZE=4096 + -DLED_BUILTIN=-1 lib_deps = ${nrf52_base.lib_deps} @@ -79,4 +80,4 @@ debug_speed = 4000 ; The following is not needed because it automatically tries do this ;debug_server_ready_pattern = -.*GDB server started on port \d+.* -;debug_port = localhost:3333 \ No newline at end of file +;debug_port = localhost:3333 From 3a87e746a82b9f1319f93f2bab938a0a543ccdd4 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 16 Apr 2026 21:34:28 -0500 Subject: [PATCH 73/74] No longer need undefines, thanks to #10179 (#10180) --- platformio.ini | 1 - variants/esp32/chatter2/platformio.ini | 1 - variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini | 1 - variants/esp32/diy/hydra/platformio.ini | 1 - variants/esp32/diy/v1/platformio.ini | 1 - variants/esp32/heltec_v2.1/platformio.ini | 1 - variants/esp32/heltec_v2/platformio.ini | 1 - variants/esp32/nano-g1-explorer/platformio.ini | 1 - variants/esp32/nano-g1/platformio.ini | 1 - variants/esp32/radiomaster_900_bandit/platformio.ini | 1 - variants/esp32/radiomaster_900_bandit_micro/platformio.ini | 1 - variants/esp32/radiomaster_900_bandit_nano/platformio.ini | 1 - variants/esp32/station-g1/platformio.ini | 1 - variants/esp32/tbeam/platformio.ini | 1 - variants/esp32/tlora_v1/platformio.ini | 1 - variants/esp32/tlora_v2_1_16/platformio.ini | 2 +- variants/esp32/tlora_v2_1_16_tcxo/platformio.ini | 1 - variants/esp32/tlora_v3_3_0_tcxo/platformio.ini | 1 - variants/esp32c6/tlora_c6/platformio.ini | 1 - variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini | 1 - variants/esp32s3/heltec_sensor_hub/platformio.ini | 1 - variants/esp32s3/heltec_v3/platformio.ini | 1 - variants/esp32s3/heltec_v4/platformio.ini | 1 - variants/esp32s3/heltec_wsl_v3/platformio.ini | 1 - variants/rp2040/rpipicow/platformio.ini | 1 - 25 files changed, 1 insertion(+), 25 deletions(-) diff --git a/platformio.ini b/platformio.ini index 637f7e517..b9cf568e3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -58,7 +58,6 @@ build_flags = -Wno-missing-field-initializers -DMESHTASTIC_EXCLUDE_POWERMON=1 -DMESHTASTIC_EXCLUDE_STATUS=1 -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage - #-DLED_BUILTIN=-1 #-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now #-D OLED_PL=1 #-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 62d23b1e6..a14e407a1 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -8,7 +8,6 @@ build_flags = -I variants/esp32/chatter2 -DMESHTASTIC_EXCLUDE_WEBSERVER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -ULED_BUILTIN lib_deps = ${esp32_base.lib_deps} diff --git a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini index 3fdb738fc..2ddc5a2db 100644 --- a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini +++ b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini @@ -10,7 +10,6 @@ build_flags = -D EBYTE_E22 -D EBYTE_E22_900M30S ; Assume Tx power curve is identical to 900M30S as there is no documentation -I variants/esp32/diy/9m2ibr_aprs_lora_tracker - -ULED_BUILTIN build_src_filter = ${esp32_base.build_src_filter} +<../variants/esp32/diy/9m2ibr_aprs_lora_tracker> \ No newline at end of file diff --git a/variants/esp32/diy/hydra/platformio.ini b/variants/esp32/diy/hydra/platformio.ini index f23224f0b..3afd17e01 100644 --- a/variants/esp32/diy/hydra/platformio.ini +++ b/variants/esp32/diy/hydra/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D DIY_V1 -I variants/esp32/diy/hydra - -ULED_BUILTIN diff --git a/variants/esp32/diy/v1/platformio.ini b/variants/esp32/diy/v1/platformio.ini index 6be2bfd09..3d31fc24a 100644 --- a/variants/esp32/diy/v1/platformio.ini +++ b/variants/esp32/diy/v1/platformio.ini @@ -17,4 +17,3 @@ build_flags = -D DIY_V1 -D EBYTE_E22 -I variants/esp32/diy/v1 - -ULED_BUILTIN diff --git a/variants/esp32/heltec_v2.1/platformio.ini b/variants/esp32/heltec_v2.1/platformio.ini index 9fcb2388a..1f7caa16f 100644 --- a/variants/esp32/heltec_v2.1/platformio.ini +++ b/variants/esp32/heltec_v2.1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D HELTEC_V2_1 -I variants/esp32/heltec_v2.1 - -ULED_BUILTIN diff --git a/variants/esp32/heltec_v2/platformio.ini b/variants/esp32/heltec_v2/platformio.ini index fc9e05115..5f15fb321 100644 --- a/variants/esp32/heltec_v2/platformio.ini +++ b/variants/esp32/heltec_v2/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D HELTEC_V2_0 -I variants/esp32/heltec_v2 - -ULED_BUILTIN diff --git a/variants/esp32/nano-g1-explorer/platformio.ini b/variants/esp32/nano-g1-explorer/platformio.ini index b27ebf28e..6f57897a8 100644 --- a/variants/esp32/nano-g1-explorer/platformio.ini +++ b/variants/esp32/nano-g1-explorer/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D NANO_G1_EXPLORER -I variants/esp32/nano-g1-explorer - -ULED_BUILTIN diff --git a/variants/esp32/nano-g1/platformio.ini b/variants/esp32/nano-g1/platformio.ini index b2e392dbd..82d0f5e73 100644 --- a/variants/esp32/nano-g1/platformio.ini +++ b/variants/esp32/nano-g1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D NANO_G1 -I variants/esp32/nano-g1 - -ULED_BUILTIN diff --git a/variants/esp32/radiomaster_900_bandit/platformio.ini b/variants/esp32/radiomaster_900_bandit/platformio.ini index 0012f49d3..6729235ed 100644 --- a/variants/esp32/radiomaster_900_bandit/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit/platformio.ini @@ -9,7 +9,6 @@ build_flags = -DHAS_STK8XXX=1 -O2 -I variants/esp32/radiomaster_900_bandit - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool lib_deps = diff --git a/variants/esp32/radiomaster_900_bandit_micro/platformio.ini b/variants/esp32/radiomaster_900_bandit_micro/platformio.ini index e58d06f1e..32e9280e1 100644 --- a/variants/esp32/radiomaster_900_bandit_micro/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit_micro/platformio.ini @@ -13,6 +13,5 @@ build_flags = -DCONFIG_DISABLE_HAL_LOCKS=1 -O2 -I variants/esp32/radiomaster_900_bandit_nano - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/radiomaster_900_bandit_nano/platformio.ini b/variants/esp32/radiomaster_900_bandit_nano/platformio.ini index 7b3d187bf..924447ee4 100644 --- a/variants/esp32/radiomaster_900_bandit_nano/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit_nano/platformio.ini @@ -16,6 +16,5 @@ build_flags = -DCONFIG_DISABLE_HAL_LOCKS=1 -O2 -I variants/esp32/radiomaster_900_bandit_nano - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/station-g1/platformio.ini b/variants/esp32/station-g1/platformio.ini index 5a7f33485..20e29764c 100644 --- a/variants/esp32/station-g1/platformio.ini +++ b/variants/esp32/station-g1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D STATION_G1 -I variants/esp32/station-g1 - -ULED_BUILTIN diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index c9e6cce1f..96e9879ce 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -16,7 +16,6 @@ board_check = true build_flags = ${esp32_base.build_flags} -D TBEAM_V10 -I variants/esp32/tbeam - -ULED_BUILTIN upload_speed = 921600 [env:tbeam-displayshield] diff --git a/variants/esp32/tlora_v1/platformio.ini b/variants/esp32/tlora_v1/platformio.ini index 5f72d634e..c45cc2ce9 100644 --- a/variants/esp32/tlora_v1/platformio.ini +++ b/variants/esp32/tlora_v1/platformio.ini @@ -13,5 +13,4 @@ build_flags = ${esp32_base.build_flags} -D TLORA_V1 -I variants/esp32/tlora_v1 - -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32/tlora_v2_1_16/platformio.ini b/variants/esp32/tlora_v2_1_16/platformio.ini index 2ea9bbb50..a41c5016e 100644 --- a/variants/esp32/tlora_v2_1_16/platformio.ini +++ b/variants/esp32/tlora_v2_1_16/platformio.ini @@ -12,7 +12,7 @@ extends = esp32_base board = ttgo-lora32-v21 board_check = true build_flags = - ${esp32_base.build_flags} -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 -ULED_BUILTIN + ${esp32_base.build_flags} -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 upload_speed = 115200 [env:sugarcube] diff --git a/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini b/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini index 235ac7007..3cb64c976 100644 --- a/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini +++ b/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini @@ -7,5 +7,4 @@ build_flags = -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 -D LORA_TCXO_GPIO=33 - -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini b/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini index 38f14ffc5..d3669ce55 100644 --- a/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini +++ b/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini @@ -7,4 +7,3 @@ build_flags = -I variants/esp32/tlora_v2_1_16 -D LORA_TCXO_GPIO=12 -D BUTTON_PIN=0 - -ULED_BUILTIN \ No newline at end of file diff --git a/variants/esp32c6/tlora_c6/platformio.ini b/variants/esp32c6/tlora_c6/platformio.ini index 174e5e297..6b402d7c5 100644 --- a/variants/esp32c6/tlora_c6/platformio.ini +++ b/variants/esp32c6/tlora_c6/platformio.ini @@ -8,4 +8,3 @@ build_flags = -I variants/esp32c6/tlora_c6 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini b/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini index 6dd828433..0bb21581a 100644 --- a/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini +++ b/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini @@ -6,5 +6,4 @@ board_build.partitions = default_8MB.csv build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_capsule_sensor_v3 -D HELTEC_CAPSULE_SENSOR_V3 - -ULED_BUILTIN ;-D DEBUG_DISABLED ; uncomment this line to disable DEBUG output diff --git a/variants/esp32s3/heltec_sensor_hub/platformio.ini b/variants/esp32s3/heltec_sensor_hub/platformio.ini index 9a5384ccd..ab99e51ed 100644 --- a/variants/esp32s3/heltec_sensor_hub/platformio.ini +++ b/variants/esp32s3/heltec_sensor_hub/platformio.ini @@ -7,4 +7,3 @@ build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_sensor_hub -D HELTEC_SENSOR_HUB - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_v3/platformio.ini b/variants/esp32s3/heltec_v3/platformio.ini index fe31df094..2f53c8756 100644 --- a/variants/esp32s3/heltec_v3/platformio.ini +++ b/variants/esp32s3/heltec_v3/platformio.ini @@ -18,4 +18,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_V3 -I variants/esp32s3/heltec_v3 - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 0336bf983..5a5004a45 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -8,7 +8,6 @@ build_flags = -D HELTEC_V4 -D HAS_LORA_FEM=1 -I variants/esp32s3/heltec_v4 - -ULED_BUILTIN [env:heltec-v4] diff --git a/variants/esp32s3/heltec_wsl_v3/platformio.ini b/variants/esp32s3/heltec_wsl_v3/platformio.ini index 873300c3c..0903a6bc7 100644 --- a/variants/esp32s3/heltec_wsl_v3/platformio.ini +++ b/variants/esp32s3/heltec_wsl_v3/platformio.ini @@ -17,4 +17,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_WSL_V3 -I variants/esp32s3/heltec_wsl_v3 - -ULED_BUILTIN diff --git a/variants/rp2040/rpipicow/platformio.ini b/variants/rp2040/rpipicow/platformio.ini index 9b4b29a5b..99e02a1aa 100644 --- a/variants/rp2040/rpipicow/platformio.ini +++ b/variants/rp2040/rpipicow/platformio.ini @@ -22,7 +22,6 @@ build_flags = -D HW_SPI1_DEVICE -D HAS_UDP_MULTICAST=1 -fexceptions # for exception handling in MQTT - -ULED_BUILTIN build_src_filter = ${rp2040_base.build_src_filter} + lib_deps = ${rp2040_base.lib_deps} From 6208c243f96d72c5a401ee2a2f30c3a8e4ead2ec Mon Sep 17 00:00:00 2001 From: Jason P Date: Fri, 17 Apr 2026 08:42:56 -0500 Subject: [PATCH 74/74] BaseUI: Implementation of Status Message for Favorite and NodeList views (#9504) * Implementation of Status Message * Change drawNodeInfo to drawFavoriteNode * Truncate overflow on Favorite frame * Set MAX_RECENT_STATUSMESSAGES to 5 to meet memory usage targets --- platformio.ini | 1 - src/graphics/Screen.cpp | 4 +- src/graphics/draw/NodeListRenderer.cpp | 40 +++++++++++++++++- src/graphics/draw/UIRenderer.cpp | 56 +++++++++++++++++++++++++- src/graphics/draw/UIRenderer.h | 2 +- src/modules/StatusMessageModule.cpp | 15 ++++++- src/modules/StatusMessageModule.h | 15 ++++++- 7 files changed, 124 insertions(+), 9 deletions(-) diff --git a/platformio.ini b/platformio.ini index b9cf568e3..0205d1ad8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -56,7 +56,6 @@ build_flags = -Wno-missing-field-initializers -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware -DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1 -DMESHTASTIC_EXCLUDE_POWERMON=1 - -DMESHTASTIC_EXCLUDE_STATUS=1 -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage #-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now #-D OLED_PL=1 diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 55ec93db5..fa9d98a0e 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1197,7 +1197,7 @@ void Screen::setFrames(FrameFocus focus) for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { - favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo); + favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode); } } @@ -1226,7 +1226,7 @@ void Screen::setFrames(FrameFocus focus) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed) + prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed) // Focus on a specific frame, in the frame set we just created switch (focus) { diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 98644ee3b..654c27222 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -3,6 +3,9 @@ #include "CompassRenderer.h" #include "NodeDB.h" #include "NodeListRenderer.h" +#if !MESHTASTIC_EXCLUDE_STATUS +#include "modules/StatusMessageModule.h" +#endif #include "UIRenderer.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" // for getTime() function @@ -92,8 +95,41 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, // 1) Choose target candidate (long vs short) only if present const char *raw = nullptr; - if (node && node->has_user) { - raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; + +#if !MESHTASTIC_EXCLUDE_STATUS + // If long-name mode is enabled, and we have a recent status for this node, + // prefer "(short_name) statusText" as the raw candidate. + std::string composedFromStatus; + if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) { + const auto &recent = statusMessageModule->getRecentReceived(); + const StatusMessageModule::RecentStatus *found = nullptr; + for (auto it = recent.rbegin(); it != recent.rend(); ++it) { + if (it->fromNodeId == node->num && !it->statusText.empty()) { + found = &(*it); + break; + } + } + + if (found) { + const char *shortName = node->user.short_name; + composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size()); + composedFromStatus += "("; + if (shortName && *shortName) { + composedFromStatus += shortName; + } + composedFromStatus += ") "; + composedFromStatus += found->statusText; + + raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string + } + } +#endif + + // If we didn't compose from status, use normal long/short selection + if (!raw) { + if (node && node->has_user) { + raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; + } } // 2) Preserve UTF-8 names so emotes can be detected and rendered. diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index e3a4d13a2..78d109881 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -5,6 +5,9 @@ #include "MeshService.h" #include "NodeDB.h" #include "NodeListRenderer.h" +#if !MESHTASTIC_EXCLUDE_STATUS +#include "modules/StatusMessageModule.h" +#endif #include "UIRenderer.h" #include "airtime.h" #include "gps/GeoCoord.h" @@ -290,7 +293,7 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes // * Favorite Node Info * // ********************** // cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library -void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { if (favoritedNodes.empty()) return; @@ -342,6 +345,57 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false); } +#if !MESHTASTIC_EXCLUDE_STATUS + // === Optional: Last received StatusMessage line for this node === + // Display it directly under the username line (if we have one). + if (statusMessageModule) { + const auto &recent = statusMessageModule->getRecentReceived(); + const StatusMessageModule::RecentStatus *found = nullptr; + + // Search newest-to-oldest + for (auto it = recent.rbegin(); it != recent.rend(); ++it) { + if (it->fromNodeId == node->num && !it->statusText.empty()) { + found = &(*it); + break; + } + } + + if (found) { + std::string statusLine = std::string(" Status: ") + found->statusText; + { + const int screenW = display->getWidth(); + const int ellipseW = display->getStringWidth("..."); + int w = display->getStringWidth(statusLine.c_str()); + + // Only do work if it overflows + if (w > screenW) { + bool truncated = false; + if (ellipseW > screenW) { + statusLine.clear(); + } else { + while (!statusLine.empty()) { + // remove one char (byte) at a time + statusLine.pop_back(); + truncated = true; + + // Measure candidate with ellipsis appended + std::string candidate = statusLine + "..."; + if (display->getStringWidth(candidate.c_str()) <= screenW) { + statusLine = std::move(candidate); + break; + } + } + if (statusLine.empty() && ellipseW <= screenW) { + statusLine = "..."; + } + } + } + } + display->drawString(x, getTextPositions(display)[line++], statusLine.c_str()); + } + } +#endif + // === 2. Signal and Hops (combined on one line, if available) === char signalHopsStr[32] = ""; bool haveSignal = false; diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index a0bb0d849..a705d944d 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -50,7 +50,7 @@ class UIRenderer // Navigation bar overlay static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state); - static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/modules/StatusMessageModule.cpp b/src/modules/StatusMessageModule.cpp index 139a74d8e..0707a4f7d 100644 --- a/src/modules/StatusMessageModule.cpp +++ b/src/modules/StatusMessageModule.cpp @@ -29,10 +29,23 @@ int32_t StatusMessageModule::runOnce() ProcessMessage StatusMessageModule::handleReceived(const meshtastic_MeshPacket &mp) { if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { - meshtastic_StatusMessage incomingMessage; + meshtastic_StatusMessage incomingMessage = meshtastic_StatusMessage_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_StatusMessage_fields, &incomingMessage)) { + LOG_INFO("Received a NodeStatus message %s", incomingMessage.status); + + RecentStatus entry; + entry.fromNodeId = mp.from; + entry.statusText = incomingMessage.status; + + recentReceived.push_back(std::move(entry)); + + // Keep only last MAX_RECENT_STATUSMESSAGES + if (recentReceived.size() > MAX_RECENT_STATUSMESSAGES) { + recentReceived.erase(recentReceived.begin()); // drop oldest + } } } return ProcessMessage::CONTINUE; diff --git a/src/modules/StatusMessageModule.h b/src/modules/StatusMessageModule.h index c9ff54018..5090066e6 100644 --- a/src/modules/StatusMessageModule.h +++ b/src/modules/StatusMessageModule.h @@ -2,10 +2,11 @@ #if !MESHTASTIC_EXCLUDE_STATUS #include "SinglePortModule.h" #include "configuration.h" +#include +#include class StatusMessageModule : public SinglePortModule, private concurrency::OSThread { - public: /** Constructor * name is for debugging output @@ -19,16 +20,28 @@ class StatusMessageModule : public SinglePortModule, private concurrency::OSThre this->setInterval(1000 * 12 * 60 * 60); } // TODO: If we have a string, set the initial delay (15 minutes maybe) + + // Keep vector from reallocating as we fill up to MAX_RECENT_STATUSMESSAGES + recentReceived.reserve(MAX_RECENT_STATUSMESSAGES); } virtual int32_t runOnce() override; + struct RecentStatus { + uint32_t fromNodeId; // mp.from + std::string statusText; // incomingMessage.status + }; + + const std::vector &getRecentReceived() const { return recentReceived; } + protected: /** Called to handle a particular incoming message */ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; private: + static constexpr size_t MAX_RECENT_STATUSMESSAGES = 5; + std::vector recentReceived; }; extern StatusMessageModule *statusMessageModule;