From 91043facedaaac21ff97058678e57fcb77d463af Mon Sep 17 00:00:00 2001 From: Iris Date: Thu, 9 Jul 2026 23:07:59 +0300 Subject: [PATCH 01/69] add configs for FrameTastic and PiTastic (#10959) --- bin/config.d/lora-FrameTastic-1262.yaml | 20 ++++++++++++++++++++ bin/config.d/lora-PiTastic-1W.yaml | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 bin/config.d/lora-FrameTastic-1262.yaml create mode 100644 bin/config.d/lora-PiTastic-1W.yaml diff --git a/bin/config.d/lora-FrameTastic-1262.yaml b/bin/config.d/lora-FrameTastic-1262.yaml new file mode 100644 index 000000000..5acff775c --- /dev/null +++ b/bin/config.d/lora-FrameTastic-1262.yaml @@ -0,0 +1,20 @@ +#copy of lora-meshstick-1262.yaml data changed to match FrameTastic 1262 board + +Meta: + name: FrameTastic 1262 + support: community + compatible: + - usb + + +Lora: + Module: sx1262 + CS: 0 + IRQ: 6 + Reset: 2 + Busy: 4 + spidev: ch341 + DIO3_TCXO_VOLTAGE: true +# USB_Serialnum: 12345678 + USB_PID: 0x5512 + USB_VID: 0x1A86 diff --git a/bin/config.d/lora-PiTastic-1W.yaml b/bin/config.d/lora-PiTastic-1W.yaml new file mode 100644 index 000000000..5c1bf95ee --- /dev/null +++ b/bin/config.d/lora-PiTastic-1W.yaml @@ -0,0 +1,20 @@ +#Board uses wehoopers zebra hat config # https://github.com/wehooper4/Meshtastic-Hardware/tree/main/ZebraHAT + +Meta: + name: PiTastic 1W + support: community + compatible: + - raspberry-pi + +Lora: + Module: sx1262 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + SX126X_MAX_POWER: 18 + CS: 24 + IRQ: 22 + Busy: 27 + Reset: 17 + +I2C: + I2CDevice: /dev/i2c-1 From 9060ab44187f75bfbe558c78e34dd5016921b434 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 10 Jul 2026 00:29:23 -0500 Subject: [PATCH 02/69] add linuxJoystick input module (#10970) * add linuxJoystick input module * close epollfd to avoid leaks --- bin/config-dist.yaml | 12 +- src/Power.cpp | 3 + src/input/InputBroker.cpp | 4 + src/input/LinuxInput.cpp | 10 +- src/input/LinuxInput.h | 2 +- src/input/LinuxJoystick.cpp | 159 +++++++++++++++++++++++ src/input/LinuxJoystick.h | 51 ++++++++ src/platform/portduino/PortduinoGlue.cpp | 19 +++ src/platform/portduino/PortduinoGlue.h | 13 ++ 9 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 src/input/LinuxJoystick.cpp create mode 100644 src/input/LinuxJoystick.h diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index dcea78ee5..4b4fe0b82 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -173,6 +173,16 @@ Input: # KeyboardDevice: /dev/input/by-id/usb-_Raspberry_Pi_Internal_Keyboard-event-kbd +### Configure device for USB gamepad/joystick input (evdev). The D-pad drives +### Up/Down/Left/Right; buttons are mapped below. Find button codes with `evtest`. +# JoystickDevice: /dev/input/by-id/usb-0079_USB_Gamepad-event-joystick +### Map evdev button codes (hex or decimal) to actions. Omit to use built-in +### defaults (select=0x121, cancel=0x122). Actions: select, cancel, back, up, +### down, left, right, user. +# JoystickButtons: +# select: 0x122 +# cancel: 0x121 + ### Standard User Button Config # UserButton: 6 @@ -199,13 +209,11 @@ Webserver: # SSLKey: /etc/meshtasticd/ssl/private_key.pem # Path to SSL Key, generated if not present # SSLCert: /etc/meshtasticd/ssl/certificate.pem # Path to SSL Certificate, generated if not present - HostMetrics: # ReportInterval: 30 # Interval in minutes between HostMetrics report packets, or 0 for disabled # Channel: 0 # channel to send Host Metrics over. Defaults to the primary channel. # UserStringCommand: cat /sys/firmware/devicetree/base/serial-number # Command to execute, to send the results as the userString - Config: # DisplayMode: TWOCOLOR # uncomment to force BaseUI # DisplayMode: COLOR # uncomment to force MUI diff --git a/src/Power.cpp b/src/Power.cpp index c3aa60111..1c1f34cd4 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -37,6 +37,7 @@ #if defined(ARCH_PORTDUINO) #include "api/WiFiServerAPI.h" #include "input/LinuxInputImpl.h" +#include "input/LinuxJoystick.h" #endif // Working USB detection for powered/charging states on the RAK platform @@ -860,6 +861,8 @@ void Power::reboot() #ifdef __linux__ if (aLinuxInputImpl) aLinuxInputImpl->deInit(); + if (aLinuxJoystick) + aLinuxJoystick->deInit(); #endif SPI.end(); Wire.end(); diff --git a/src/input/InputBroker.cpp b/src/input/InputBroker.cpp index 72af749bc..d6c8fe832 100644 --- a/src/input/InputBroker.cpp +++ b/src/input/InputBroker.cpp @@ -10,6 +10,7 @@ #if ARCH_PORTDUINO #include "input/LinuxInputImpl.h" +#include "input/LinuxJoystick.h" #include "input/SeesawRotary.h" #include "platform/portduino/PortduinoGlue.h" #endif @@ -430,6 +431,9 @@ void InputBroker::Init() // Linux evdev keyboard input only - macOS has no . aLinuxInputImpl = new LinuxInputImpl(); aLinuxInputImpl->init(); + // Linux evdev gamepad/joystick input (D-pad + confirm/cancel buttons). + aLinuxJoystick = new LinuxJoystick("LinuxJoystick"); + aLinuxJoystick->init(); #endif } #endif diff --git a/src/input/LinuxInput.cpp b/src/input/LinuxInput.cpp index fee7c8ded..566f2cba1 100644 --- a/src/input/LinuxInput.cpp +++ b/src/input/LinuxInput.cpp @@ -25,8 +25,14 @@ LinuxInput::LinuxInput(const char *name) : concurrency::OSThread(name) void LinuxInput::deInit() { + // reboot() on native does execv() in-place, which only closes O_CLOEXEC fds. + // Close both descriptors here so we don't leak one per restart. if (fd >= 0) close(fd); + if (epollfd >= 0) + close(epollfd); + fd = -1; + epollfd = -1; } int32_t LinuxInput::runOnce() @@ -35,14 +41,14 @@ int32_t LinuxInput::runOnce() if (firstTime) { if (portduino_config.keyboardDevice == "") return disable(); - fd = open(portduino_config.keyboardDevice.c_str(), O_RDWR); + fd = open(portduino_config.keyboardDevice.c_str(), O_RDWR | O_CLOEXEC); if (fd < 0) return disable(); ret = ioctl(fd, EVIOCGRAB, (void *)1); if (ret != 0) return disable(); - epollfd = epoll_create1(0); + epollfd = epoll_create1(EPOLL_CLOEXEC); assert(epollfd >= 0); ev.events = EPOLLIN; diff --git a/src/input/LinuxInput.h b/src/input/LinuxInput.h index 673d29b3c..4a4b16723 100644 --- a/src/input/LinuxInput.h +++ b/src/input/LinuxInput.h @@ -44,7 +44,7 @@ class LinuxInput : public Observable, public concurrency::OS int fd = -1; int ret; uint8_t report[8]; - int epollfd; + int epollfd = -1; struct epoll_event ev; uint8_t modifiers = 0; std::map keymap{ diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp new file mode 100644 index 000000000..aaff43959 --- /dev/null +++ b/src/input/LinuxJoystick.cpp @@ -0,0 +1,159 @@ +#include "configuration.h" +#if ARCH_PORTDUINO && defined(__linux__) +#include "InputBroker.h" +#include "LinuxJoystick.h" +#include "platform/portduino/PortduinoGlue.h" +#include +#include +#include +#include +#include +#include +#include + +LinuxJoystick *aLinuxJoystick; + +LinuxJoystick::LinuxJoystick(const char *name) : concurrency::OSThread(name) +{ + this->_originName = name; +} + +// Translate a config.yaml action name into a broker event. Returns INPUT_BROKER_NONE +// for unknown names so a typo simply leaves that button unmapped. +static input_broker_event joystickActionToEvent(const std::string &action) +{ + if (action == "select") + return INPUT_BROKER_SELECT; + if (action == "cancel") + return INPUT_BROKER_CANCEL; + if (action == "back") + return INPUT_BROKER_BACK; + if (action == "up") + return INPUT_BROKER_UP; + if (action == "down") + return INPUT_BROKER_DOWN; + if (action == "left") + return INPUT_BROKER_LEFT; + if (action == "right") + return INPUT_BROKER_RIGHT; + if (action == "user" || action == "userpress") + return INPUT_BROKER_USER_PRESS; + return INPUT_BROKER_NONE; +} + +void LinuxJoystick::init() +{ + if (portduino_config.joystickButtons.empty()) { + // No config override: fall back to the built-in defaults. + buttonMap[JOY_BTN_A] = INPUT_BROKER_SELECT; + buttonMap[JOY_BTN_B] = INPUT_BROKER_CANCEL; + } else { + for (const auto &button : portduino_config.joystickButtons) { + input_broker_event event = joystickActionToEvent(button.second); + if (event != INPUT_BROKER_NONE) + buttonMap[button.first] = event; + } + } + inputBroker->registerSource(this); +} + +void LinuxJoystick::deInit() +{ + // reboot() on native does execv() in-place, which only closes O_CLOEXEC fds. + // Close both descriptors here so we don't leak one per restart. + if (fd >= 0) + close(fd); + if (epollfd >= 0) + close(epollfd); + fd = -1; + epollfd = -1; +} + +// Bucket a raw axis value into a direction zone: -1 (min edge), 0 (center), +1 (max edge). +static int axisZone(int value) +{ + if (value <= JOY_AXIS_LOW) + return -1; + if (value >= JOY_AXIS_HIGH) + return 1; + return 0; +} + +int32_t LinuxJoystick::runOnce() +{ + if (firstTime) { + if (portduino_config.joystickDevice == "") + return disable(); + fd = open(portduino_config.joystickDevice.c_str(), O_RDWR | O_CLOEXEC); + if (fd < 0) + return disable(); + if (ioctl(fd, EVIOCGRAB, (void *)1) != 0) + return disable(); + + epollfd = epoll_create1(EPOLL_CLOEXEC); + assert(epollfd >= 0); + + ev.events = EPOLLIN; + ev.data.fd = fd; + if (epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev)) { + perror("joystick: unable to epoll add"); + return disable(); + } + // This is the first time the OSThread library has called this function, so do port setup + firstTime = false; + } + + int nfds = epoll_wait(epollfd, events, JOY_MAX_EVENTS, 1); + if (nfds < 0) { + perror("joystick: epoll_wait failed"); + return disable(); + } else if (nfds == 0) { + return 50; + } + + for (int i = 0; i < nfds; i++) { + struct input_event evs[64]; + int rd = read(events[i].data.fd, evs, sizeof(evs)); + if (rd < (signed int)sizeof(struct input_event)) + continue; + for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) { + InputEvent e = {}; + e.inputEvent = INPUT_BROKER_NONE; + e.source = this->_originName; + e.kbchar = 0; + unsigned int type = evs[j].type; + unsigned int code = evs[j].code; + int value = evs[j].value; + + if (type == EV_ABS) { + // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. + // Emit exactly once when an axis crosses from center to an edge. + if (code == ABS_X) { + int zone = axisZone(value); + if (zone != axisZone(lastX) && zone != 0) + e.inputEvent = (zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT; + lastX = value; + } else if (code == ABS_Y) { + int zone = axisZone(value); + if (zone != axisZone(lastY) && zone != 0) + e.inputEvent = (zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN; + lastY = value; + } + } else if (type == EV_KEY && value == 1) { + // Look up the configured action for this button; unmapped buttons are ignored. + auto mapped = buttonMap.find(code); + if (mapped != buttonMap.end()) + e.inputEvent = mapped->second; + } + + if (e.inputEvent != INPUT_BROKER_NONE) { + LOG_DEBUG("joystick: %s event %d", this->_originName, e.inputEvent); + this->notifyObservers(&e); + } + } + } + + return 50; // Poll every 50msec +} + +#endif diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h new file mode 100644 index 000000000..6e6d3c398 --- /dev/null +++ b/src/input/LinuxJoystick.h @@ -0,0 +1,51 @@ +#pragma once +// Linux evdev gamepad/joystick input. Only compiled on Linux portduino targets; +// macOS / non-Linux builds have no or epoll. Unlike LinuxInput +// (keyboard, EV_KEY only) this decodes the D-pad from EV_ABS axes as well. +#if ARCH_PORTDUINO && defined(__linux__) +#include "InputBroker.h" +#include "concurrency/OSThread.h" +#include +#include +#include +#include + +#define JOY_MAX_EVENTS 10 + +// Default button map when config.yaml has no [Input] JoystickButtons override. +// Codes confirmed by evdev capture on a 0079:0011 "USB Gamepad". +#define JOY_BTN_A BTN_THUMB // 0x121 -> INPUT_BROKER_SELECT +#define JOY_BTN_B BTN_THUMB2 // 0x122 -> INPUT_BROKER_CANCEL +#define JOY_AXIS_CENTER 127 // D-pad resting value +#define JOY_AXIS_LOW 64 // below this -> "min" edge (0) +#define JOY_AXIS_HIGH 192 // above this -> "max" edge (255) + +class LinuxJoystick : public Observable, public concurrency::OSThread +{ + public: + explicit LinuxJoystick(const char *name); + void init(); // Registers this source with the InputBroker + void deInit(); // Strictly for cleanly "rebooting" the binary on native + + protected: + virtual int32_t runOnce() override; + + private: + const char *_originName; + bool firstTime = true; + + // evdev button code -> broker event, built from config (or defaults) in init(). + std::map buttonMap; + + struct epoll_event events[JOY_MAX_EVENTS]; + struct epoll_event ev; + int fd = -1; + int epollfd = -1; + + // Latch the last emitted axis position so a held direction fires exactly + // once (on the transition away from center), not a continuous stream. + int lastX = JOY_AXIS_CENTER; + int lastY = JOY_AXIS_CENTER; +}; +extern LinuxJoystick *aLinuxJoystick; +#endif diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 74790cd02..7bb8096ab 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1038,6 +1038,25 @@ bool loadConfig(const char *configPath) if (yamlConfig["Input"]) { portduino_config.keyboardDevice = (yamlConfig["Input"]["KeyboardDevice"]).as(""); portduino_config.pointerDevice = (yamlConfig["Input"]["PointerDevice"]).as(""); + portduino_config.joystickDevice = (yamlConfig["Input"]["JoystickDevice"]).as(""); + if (yamlConfig["Input"]["JoystickButtons"]) { + // action name -> evdev button code (hex like 0x122 or decimal); stored inverted + // as code -> lowercase action name for the driver to look up per keypress. + for (const auto &button : yamlConfig["Input"]["JoystickButtons"]) { + std::string action = button.first.as(""); + for (auto &c : action) + c = tolower(c); + int code = 0; + try { + // base 0 accepts hex (0x122) or decimal; a malformed value just skips this entry. + code = std::stoi(button.second.as(""), nullptr, 0); + } catch (const std::exception &) { + code = 0; + } + if (code != 0 && action != "") + portduino_config.joystickButtons[code] = action; + } + } readGPIOFromYaml(yamlConfig["Input"]["User"], portduino_config.userButtonPin); readGPIOFromYaml(yamlConfig["Input"]["TrackballUp"], portduino_config.tbUpPin); diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 73bfbdcf7..050aaaf87 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -160,6 +160,11 @@ extern struct portduino_config_struct { // Input std::string keyboardDevice = ""; std::string pointerDevice = ""; + std::string joystickDevice = ""; + // Joystick/gamepad button map: evdev button code -> lowercase action name + // ("select", "cancel", "back", "up", "down", "left", "right", "user"). + // Empty means the LinuxJoystick driver uses its built-in defaults. + std::map joystickButtons; int tbDirection; pinMapping userButtonPin = {"Input", "User"}; pinMapping tbUpPin = {"Input", "TrackballUp"}; @@ -458,6 +463,14 @@ extern struct portduino_config_struct { out << YAML::Key << "KeyboardDevice" << YAML::Value << keyboardDevice; if (pointerDevice != "") out << YAML::Key << "PointerDevice" << YAML::Value << pointerDevice; + if (joystickDevice != "") + out << YAML::Key << "JoystickDevice" << YAML::Value << joystickDevice; + if (!joystickButtons.empty()) { + out << YAML::Key << "JoystickButtons" << YAML::Value << YAML::BeginMap; + for (const auto &button : joystickButtons) + out << YAML::Key << button.second << YAML::Value << button.first; + out << YAML::EndMap; + } for (const auto *input_pin : all_pins) { if (input_pin->config_section == "Input" && input_pin->enabled) { From f4efbd9149f7ba2dba8797271763d4f86ee36379 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:50:30 -0400 Subject: [PATCH 03/69] tlora-t3s3-epaper: e-ink startup and build cleanup (#10973) * T3s3 Eink * Update SerialConsole.cpp * Update * Update SerialConsole.h * Update EInkDisplay2.cpp --- src/FSCommon.h | 8 +++++++- src/graphics/EInkDisplay2.cpp | 15 +++++++++++++++ .../niche/InkHUD/PlatformioConfig.ini | 19 +++++++++++++++++-- .../esp32s3/tlora_t3s3_epaper/nicheGraphics.h | 6 +++--- .../esp32s3/tlora_t3s3_epaper/platformio.ini | 7 +++++++ 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/FSCommon.h b/src/FSCommon.h index 7710c5b83..c974840fe 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -64,4 +64,10 @@ bool fsFormat(); std::vector getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr); void listDir(const char *dirname, uint8_t levels, bool del = false); void rmDir(const char *dirname); -void setupSDCard(); \ No newline at end of file +void setupSDCard(); + +#if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) && defined(SDCARD_USE_SPI1) +#include +// HSPI bus set up by setupSDCard(). Reuse this for other devices on the same bus. +extern SPIClass SPI_HSPI; +#endif \ No newline at end of file diff --git a/src/graphics/EInkDisplay2.cpp b/src/graphics/EInkDisplay2.cpp index 28b956bb1..7c8b6ab8e 100644 --- a/src/graphics/EInkDisplay2.cpp +++ b/src/graphics/EInkDisplay2.cpp @@ -2,6 +2,7 @@ #if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY) #include "EInkDisplay2.h" +#include "FSCommon.h" #include "SPILock.h" #include "main.h" #include @@ -214,9 +215,23 @@ bool EInkDisplay::connect() defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \ defined(MINI_EPAPER_S3) { +#if defined(TLORA_T3S3_EPAPER) + // T3-S3 shares HSPI with the SD card; preconfigure the panel control pins. + hspi = &SPI_HSPI; + pinMode(PIN_EINK_CS, OUTPUT); + pinMode(PIN_EINK_DC, OUTPUT); + pinMode(PIN_EINK_BUSY, INPUT); + if (PIN_EINK_RES >= 0) { + pinMode(PIN_EINK_RES, OUTPUT); + digitalWrite(PIN_EINK_RES, HIGH); + } + digitalWrite(PIN_EINK_CS, HIGH); + digitalWrite(PIN_EINK_DC, HIGH); +#else // Start HSPI hspi = new SPIClass(HSPI); hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); // SCLK, MISO, MOSI, SS +#endif // VExt already enabled in setup() // RTC GPIO hold disabled in setup() diff --git a/src/graphics/niche/InkHUD/PlatformioConfig.ini b/src/graphics/niche/InkHUD/PlatformioConfig.ini index 67ad5098f..4c03773b9 100644 --- a/src/graphics/niche/InkHUD/PlatformioConfig.ini +++ b/src/graphics/niche/InkHUD/PlatformioConfig.ini @@ -1,7 +1,22 @@ [inkhud] -build_src_filter = +build_src_filter = +; Include the nicheGraphics directory -build_flags = + - ; BaseUI renderer stack is disabled for InkHUD + - ; InkHUD uses GFX_Root fonts instead of BaseUI font tables + - ; InkHUD drives e-ink through niche drivers + - + - + - + - + - + - + - + - + - + - + - + - +build_flags = -D MESHTASTIC_INCLUDE_NICHE_GRAPHICS ; Use NicheGraphics -D MESHTASTIC_INCLUDE_INKHUD ; Use InkHUD (a NicheGraphics UI) -D MESHTASTIC_EXCLUDE_SCREEN ; Suppress default Screen class diff --git a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h index 73cc2e235..faa9a41b9 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h +++ b/variants/esp32s3/tlora_t3s3_epaper/nicheGraphics.h @@ -1,5 +1,6 @@ #pragma once +#include "FSCommon.h" #include "configuration.h" #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS @@ -29,9 +30,8 @@ void setupNicheGraphics() // SPI // ----------------------------- - // Display is connected to HSPI - SPIClass *hspi = new SPIClass(HSPI); - hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS); + // Display shares the HSPI bus with the SD card; reuse it rather than re-init the same host. + SPIClass *hspi = &SPI_HSPI; // E-Ink Driver // ----------------------------- diff --git a/variants/esp32s3/tlora_t3s3_epaper/platformio.ini b/variants/esp32s3/tlora_t3s3_epaper/platformio.ini index 8f764bbe6..0830cf303 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/platformio.ini +++ b/variants/esp32s3/tlora_t3s3_epaper/platformio.ini @@ -20,6 +20,8 @@ build_flags = -D TLORA_T3S3_EPAPER -I variants/esp32s3/tlora_t3s3_epaper -DUSE_EINK + -DMESHTASTIC_EXCLUDE_AUDIO=1 + -DMESHTASTIC_EXCLUDE_WEBSERVER=1 -DEINK_DISPLAY_MODEL=GxEPD2_213_BN -DEINK_WIDTH=250 -DEINK_HEIGHT=122 @@ -39,6 +41,9 @@ extends = esp32s3_base, inkhud board = tlora-t3s3-v1 board_check = true upload_protocol = esptool +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_ESP_IPC_TASK_STACK_SIZE=2048 ; default 1024 overflows during NimBLE init off USB power build_src_filter = ${esp32s3_base.build_src_filter} ${inkhud.build_src_filter} @@ -47,6 +52,8 @@ build_flags = ${inkhud.build_flags} -I variants/esp32s3/tlora_t3s3_epaper -D TLORA_T3S3_EPAPER + -DMESHTASTIC_EXCLUDE_AUDIO=1 + -DMESHTASTIC_EXCLUDE_WEBSERVER=1 lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${esp32s3_base.lib_deps} From 838323cc07b4a118beb7729956da777f25a5b070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 10 Jul 2026 17:17:47 +0200 Subject: [PATCH 04/69] Quote esp-idf include paths in esp32.ini for Windows builds (#10980) ${platformio.packages_dir} resolves to a backslash path on Windows. PlatformIO parses build_flags with POSIX shell rules, which strip the backslashes and turn the NimBLE include paths into broken relative paths (d:platformiopackages/...), failing the build with 'host/ble_gap.h: No such file or directory'. Quoting the flags preserves backslashes through parsing. --- variants/esp32/esp32.ini | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 2a61bfa97..79b421dd5 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -22,16 +22,17 @@ build_flags = -DMESHTASTIC_EXCLUDE_RANGETEST=1 ; HACK HACK HACK this is just a proof of concept ESP32+NimBLE but these ; includes need to be done properly somehow - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/porting/nimble/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/port/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/porting/npl/freertos/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/transport/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/services/gap/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/esp-hci/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/services/gatt/include - -I${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/util/include + ; Quotes keep Windows packages_dir backslashes intact through POSIX flag parsing + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/porting/nimble/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/port/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/porting/npl/freertos/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/transport/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/services/gap/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/esp-hci/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/services/gatt/include" + -I"${platformio.packages_dir}/framework-espidf/components/bt/host/nimble/nimble/nimble/host/util/include" custom_sdkconfig = ${esp32_common.custom_sdkconfig} From 8abae908388939ef5b1cebf8ba1ea16554e72e00 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 10 Jul 2026 10:18:09 -0500 Subject: [PATCH 05/69] Give the 4MB T3-S3 boards an app partition that fits (#10978) * fix(device-install): flash littlefs at the offset from firmware metadata device-install.sh read the spiffs offset out of the .mt.json metadata into SPIFFS_OFFSET, but flashed the littlefs image at $OFFSET -- a separate variable still holding the hardcoded 0x300000 default. The metadata value was never used, so any board with a non-default partition table had its filesystem written to the wrong place. Unify on SPIFFS_OFFSET, and guard both metadata overrides so a table that omits ota_1 or spiffs falls back to the built-in default instead of handing esptool an empty offset. Quote both offsets at the call site: the spiffs one is newly consumed from jq, and a multi-line result would otherwise word-split into esptool's argv and silently mis-pair address with file. device-install.bat already does all of this correctly; this brings the shell script to parity. * fix(tlora-t3s3): give the 4MB T3-S3 boards an app partition that fits tlora-t3s3-v1 and tlora-t3s3-epaper have overflowed the shared 4MB partition-table.csv app slot (ota_0 = 0x250000 = 2,424,832 bytes) on every develop build since 22072c5f4 (2026-06-20). The last green build, ca7d82629, cleared it by 881 bytes; develop is now ~38.7KB over. These envs have no board_level, so they only build in the full matrix on push to develop -- which is why no PR ever caught it. Add partition-table-t3s3.csv, dedicated to the 4MB ESP32-S3FH4R2 T3-S3 boards, and point all three t3s3 envs at it: app ota_0 0x010000 0x290000 (was 0x250000, +256KB) flashApp ota_1 0x2A0000 0x0A0000 (unchanged size) spiffs 0x340000 0x0C0000 (was 0x100000, -256KB) The headroom comes out of spiffs, not flashApp: the unified BLE OTA image is 636,544 bytes against a 655,360 byte slot, so ota_1 has nothing to give. The table is contiguous, ends exactly on the 4MB boundary, keeps both app partitions 64KB-aligned, and leaves the littlefs image these boards ship well inside the remaining 768KB. Verified with a Docker build of all three envs: tlora-t3s3-v1 2,463,504 91.7% tlora-t3s3-epaper 2,458,487 91.5% tlora-t3s3-epaper-inkhud 2,400,851 89.4% Trimming was considered and rejected. The entire emoji set is 6,304 bytes (measured A/B build), and cutting traffic management plus the paxcounter, storeforward, rangetest and atak modules recovers 46,636 -- enough to land at 99.7% full, i.e. weeks of headroom at develop's observed growth rate, in exchange for shipping a feature-reduced board. The slot was simply mis-sized for a board this full. Note this changes flash layout: existing T3-S3 units must be erased and factory-flashed once to pick up the new offsets. They already cannot run a current develop build, so no working configuration regresses. --- bin/device-install.sh | 23 ++++++++++++------- partition-table-t3s3.csv | 11 +++++++++ .../esp32s3/tlora_t3s3_epaper/platformio.ini | 4 +++- variants/esp32s3/tlora_t3s3_v1/platformio.ini | 3 ++- 4 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 partition-table-t3s3.csv diff --git a/bin/device-install.sh b/bin/device-install.sh index 52a848c38..3d20376bc 100755 --- a/bin/device-install.sh +++ b/bin/device-install.sh @@ -7,9 +7,9 @@ MCU="" # Constants RESET_BAUD=1200 FIRMWARE_OFFSET=0x00 -# Default littlefs* offset. -OFFSET=0x300000 -# Default OTA Offset +# Fallback offsets, used only when firmware metadata carries no partition table. +# These track partition-table.csv; boards with their own table override them below. +SPIFFS_OFFSET=0x300000 OTA_OFFSET=0x260000 # Determine the correct esptool command to use @@ -122,8 +122,15 @@ if [[ -f "$FILENAME" && "$FILENAME" == *.factory.bin ]]; then jq . "$METAFILE" # Extract relevant fields from metadata if [[ $(jq -r '.part' "$METAFILE") != "null" ]]; then - OTA_OFFSET=$(jq -r '.part[] | select(.subtype == "ota_1") | .offset' "$METAFILE") - SPIFFS_OFFSET=$(jq -r '.part[] | select(.subtype == "spiffs") | .offset' "$METAFILE") + META_OTA_OFFSET=$(jq -r '.part[] | select(.subtype == "ota_1") | .offset' "$METAFILE") + META_SPIFFS_OFFSET=$(jq -r '.part[] | select(.subtype == "spiffs") | .offset' "$METAFILE") + # A partition table may omit either entry; keep the built-in default then. + if [[ -n "$META_OTA_OFFSET" && "$META_OTA_OFFSET" != "null" ]]; then + OTA_OFFSET="$META_OTA_OFFSET" + fi + if [[ -n "$META_SPIFFS_OFFSET" && "$META_SPIFFS_OFFSET" != "null" ]]; then + SPIFFS_OFFSET="$META_SPIFFS_OFFSET" + fi fi MCU=$(jq -r '.mcu' "$METAFILE") else @@ -154,9 +161,9 @@ if [[ -f "$FILENAME" && "$FILENAME" == *.factory.bin ]]; then $ESPTOOL_CMD ${ESPTOOL_ERASE_FLASH} $ESPTOOL_CMD ${ESPTOOL_WRITE_FLASH} $FIRMWARE_OFFSET "${FILENAME}" echo "Trying to flash ${OTAFILE} at offset ${OTA_OFFSET}" - $ESPTOOL_CMD ${ESPTOOL_WRITE_FLASH} $OTA_OFFSET "${OTAFILE}" - echo "Trying to flash ${SPIFFSFILE}, at offset ${OFFSET}" - $ESPTOOL_CMD ${ESPTOOL_WRITE_FLASH} $OFFSET "${SPIFFSFILE}" + $ESPTOOL_CMD ${ESPTOOL_WRITE_FLASH} "$OTA_OFFSET" "${OTAFILE}" + echo "Trying to flash ${SPIFFSFILE}, at offset ${SPIFFS_OFFSET}" + $ESPTOOL_CMD ${ESPTOOL_WRITE_FLASH} "$SPIFFS_OFFSET" "${SPIFFSFILE}" else show_help diff --git a/partition-table-t3s3.csv b/partition-table-t3s3.csv new file mode 100644 index 000000000..603e2fdac --- /dev/null +++ b/partition-table-t3s3.csv @@ -0,0 +1,11 @@ +# Layout for 4MB LILYGO T3-S3 boards (ESP32-S3FH4R2). +# Reclaims 256KB from spiffs to give the app slot real headroom. +# flashApp (ota_1) keeps its full 0xA0000 (655,360 B): the unified BLE OTA image +# (meshtastic/esp32-unified-ota v1.0.1, mt-esp32s3-ota.bin) is 636,544 B, so this +# slot cannot be shrunk to fund the app slot instead. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x009000, 0x005000, +otadata, data, ota, 0x00e000, 0x002000, +app, app, ota_0, 0x010000, 0x290000, +flashApp, app, ota_1, 0x2A0000, 0x0A0000, +spiffs, data, spiffs, 0x340000, 0x0C0000, diff --git a/variants/esp32s3/tlora_t3s3_epaper/platformio.ini b/variants/esp32s3/tlora_t3s3_epaper/platformio.ini index 0830cf303..93cd4d68d 100644 --- a/variants/esp32s3/tlora_t3s3_epaper/platformio.ini +++ b/variants/esp32s3/tlora_t3s3_epaper/platformio.ini @@ -13,9 +13,10 @@ custom_meshtastic_has_ink_hud = true extends = esp32s3_base board = tlora-t3s3-v1 board_check = true +board_build.partitions = partition-table-t3s3.csv upload_protocol = esptool -build_flags = +build_flags = ${esp32s3_base.build_flags} -D TLORA_T3S3_EPAPER -I variants/esp32s3/tlora_t3s3_epaper @@ -40,6 +41,7 @@ lib_deps = extends = esp32s3_base, inkhud board = tlora-t3s3-v1 board_check = true +board_build.partitions = partition-table-t3s3.csv upload_protocol = esptool custom_sdkconfig = ${esp32s3_base.custom_sdkconfig} diff --git a/variants/esp32s3/tlora_t3s3_v1/platformio.ini b/variants/esp32s3/tlora_t3s3_v1/platformio.ini index 95686e417..96103df82 100644 --- a/variants/esp32s3/tlora_t3s3_v1/platformio.ini +++ b/variants/esp32s3/tlora_t3s3_v1/platformio.ini @@ -12,9 +12,10 @@ custom_meshtastic_requires_dfu = true extends = esp32s3_base board = tlora-t3s3-v1 board_check = true +board_build.partitions = partition-table-t3s3.csv upload_protocol = esptool -build_flags = +build_flags = ${esp32s3_base.build_flags} -D TLORA_T3S3_V1 -I variants/esp32s3/tlora_t3s3_v1 From f52e2ea8efa096a77e95a59df423a49cfef3a141 Mon Sep 17 00:00:00 2001 From: Manuel <71137295+mverch67@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:00:32 +0200 Subject: [PATCH 06/69] fix SPI1 instances (#10983) --- src/main.cpp | 8 ++++++++ src/mesh/RadioInterface.cpp | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/src/main.cpp b/src/main.cpp index 63cbf35ab..cc7c239a4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -196,8 +196,16 @@ void setupNicheGraphics(); #endif #if defined(HW_SPI1_DEVICE) && defined(ARCH_ESP32) +#if defined(HAS_SDCARD) && defined(SDCARD_USE_SPI1) +// Reuse FSCommon's SPI_HSPI instance to avoid double-initializing SPI2_HOST in arduino-esp32 3.x. +// Two SPIClass(HSPI) objects on the same bus cause the second spi_bus_initialize() to return +// ESP_ERR_INVALID_STATE, leaving the LoRa device handle invalid and blocking SPI transfers. +extern SPIClass SPI_HSPI; +SPIClass &SPI1 = SPI_HSPI; +#else SPIClass SPI1(HSPI); #endif +#endif using namespace concurrency; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index fac3aff59..5a3215fee 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -337,8 +337,12 @@ LoRaRadioType radioType = NO_RADIO; extern RadioLibHal *RadioLibHAL; #if defined(HW_SPI1_DEVICE) && defined(ARCH_ESP32) +#if defined(HAS_SDCARD) && defined(SDCARD_USE_SPI1) +extern SPIClass &SPI1; // alias for SPI_HSPI; both on SPI2_HOST +#else extern SPIClass SPI1; #endif +#endif std::unique_ptr initLoRa() { From 8e27a8c7150e66818ce754029a69939bf5043dd4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:32:02 -0500 Subject: [PATCH 07/69] Update actions/stale action to v10.4.0 (#10985) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/stale_bot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale_bot.yml b/.github/workflows/stale_bot.yml index f81c6ef9a..576da06ba 100644 --- a/.github/workflows/stale_bot.yml +++ b/.github/workflows/stale_bot.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Stale PR+Issues - uses: actions/stale@v10.3.0 + uses: actions/stale@v10.4.0 with: days-before-stale: 45 stale-issue-message: This issue has not had any comment or update in the last month. If it is still relevant, please post update comments. If no comments are made, this issue will be closed automagically in 7 days. From ca833d944ca35273ee870b579f1edde17653390b Mon Sep 17 00:00:00 2001 From: p0ns Date: Sat, 11 Jul 2026 08:27:50 -0300 Subject: [PATCH 08/69] Fix serial protobuf corruption on short USB CDC writes (#10976) * Fix serial protobuf corruption on short writes SerialConsole shared raw debug output and framed protobuf traffic on one HWCDC stream. Raw text could interleave inside an active frame when API logging was disabled. Separately, HWCDC deliberately returns a short write after bounded backpressure; StreamAPI abandoned that frame after PhoneAPI had already advanced, so the next 0x94c3 header landed inside the previous declared payload. Suppress all unframed output after protobuf mode starts and honor the existing config-replay log pause. For HWCDC, retain short frame tails in the persistent tx buffers and finish them on later loop passes before dequeuing another FromRadio packet. Logs remain best-effort and only start when their complete frame fits; main packets are deferred rather than dropped if a synchronous log is pending. Do not call HWCDC flush for framed serial output, since its no-progress path can discard queued bytes. TCP and non-HWCDC transports keep their existing behavior. Validated on Cardputer ADV (200-node DB) and Heltec Tracker V2: 400 initial full-DB sessions plus 140 final sessions across both API-log settings, zero malformed frames/timeouts/incomplete DBs; 2-20s forced reader stalls resume with complete DBs; abrupt stalled-client replacement 5/5 per board; 150s post-close reboot counts stable. Builds pass for Cardputer, Heltec, tbeam, and rak4631. * Add serial frame continuation regression tests Extract the HWCDC pending/deferred frame state machine into a small transport-independent helper so native tests exercise the same production logic used by SerialConsole. Keep framing, persistent buffer ownership and locking in SerialConsole. Cover short-tail continuation, deferred main-frame ordering, best-effort log admission, bounded zero-progress calls, generic StreamAPI failure semantics, PhoneAPI advancement gating, framed-log gating and raw output suppression. The coverage suite passes 31/31 suites and 582/582 tests. * Address review: guard flush, assert deferred invariant, dedup framing - Make SerialConsole::flush() a no-op in protobuf mode: HWCDC::flush()'s no-progress path discards queued TX bytes, which would tear a framed stream when the sleep path flushes with a stalled host. - Assert the single required-frame producer invariant in StreamFrameWriter::writeFrame() instead of silently dropping a second required frame. - Hoist 0x94C3 header construction into StreamAPI::buildFrameHeader() so SerialConsole no longer re-hardcodes the framing constants. * Test retained serial tail across client replacement Model a replacement client arriving while an older required frame has an unwritten tail. Require the old frame to complete before the new frame starts, so a new 0x94C3 header can never land inside the old declared payload. * Document serial frame APIs and regression tests Add concise Doxygen comments for the frame continuation hooks, production helper, native test doubles, and regression scenarios. Document why retained frame tails intentionally survive client disconnects: HWCDC may still hold the accepted prefix, so dropping metadata could insert a new frame header inside the old declared payload. --- src/SerialConsole.cpp | 92 +++++- src/SerialConsole.h | 27 +- src/mesh/StreamAPI.cpp | 40 ++- src/mesh/StreamAPI.h | 17 +- src/mesh/StreamFrameWriter.cpp | 65 +++++ src/mesh/StreamFrameWriter.h | 26 ++ test/native-suite-count | 2 +- test/test_stream_api/test_main.cpp | 436 +++++++++++++++++++++++++++++ 8 files changed, 675 insertions(+), 30 deletions(-) create mode 100644 src/mesh/StreamFrameWriter.cpp create mode 100644 src/mesh/StreamFrameWriter.h create mode 100644 test/test_stream_api/test_main.cpp diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index 2e55e31bb..d6810e123 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -3,7 +3,9 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "Throttle.h" +#include "concurrency/LockGuard.h" #include "configuration.h" +#include "main.h" #include "time.h" #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT @@ -28,6 +30,7 @@ SerialConsole *console; +/// Create the shared serial console once and register receive wakeups. void consoleInit() { if (console) { @@ -44,6 +47,7 @@ void consoleInit() DEBUG_PORT.rpInit(); // Simply sets up semaphore } +/// Print and flush an unclassified formatted console message. void consolePrintf(const char *format, ...) { va_list arg; @@ -53,6 +57,7 @@ void consolePrintf(const char *format, ...) console->flush(); } +/// Initialize console, protobuf transport, serial port, and worker thread state. SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), concurrency::OSThread("SerialConsole") { api_type = TYPE_SERIAL; @@ -80,6 +85,7 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con #endif } +/// Service one serial API iteration and select the next polling interval. int32_t SerialConsole::runOnce() { #ifdef HELTEC_MESH_SOLAR @@ -100,29 +106,48 @@ int32_t SerialConsole::runOnce() #endif } +/// Flush raw output while preserving queued protobuf frames. void SerialConsole::flush() { + // HWCDC::flush()'s no-progress path discards queued TX bytes, which would tear a + // framed protobuf stream; framed output is drained by the TX interrupt instead. + if (usingProtobufs) + return; + Port.flush(); } -// trigger tx of serial data +/// Write raw console data only before protobuf framing becomes active. +size_t SerialConsole::write(uint8_t c) +{ + // Once a protobuf client is active, unframed bytes would corrupt its stream. + if (usingProtobufs) + return 1; + + if (c == '\n') + RedirectablePrint::write('\r'); + return RedirectablePrint::write(c); +} + +/// Wake the serial worker when PhoneAPI queues output. void SerialConsole::onNowHasData(uint32_t fromRadioNum) { setIntervalFromNow(0); } -// trigger rx of serial data +/// Wake the serial worker when receive activity is signaled. void SerialConsole::rxInt() { setIntervalFromNow(0); } -// For the serial port we can't really detect if any client is on the other side, so instead just look for recent messages +/// Infer serial client connectivity from recent API contact. bool SerialConsole::checkIsConnected() { return Throttle::isWithinTimespanMs(lastContactMsec, SERIAL_CONNECTION_TIMEOUT); } +/// Select bounded or non-blocking HWCDC writes based on host liveness. void SerialConsole::setHostDraining(bool draining) { #ifdef IS_USB_SERIAL @@ -134,17 +159,59 @@ void SerialConsole::setHostDraining(bool draining) #endif } +/// Update HWCDC timeout mode around generic connection handling. void SerialConsole::onConnectionChanged(bool connected) { // Order matters on disconnect: make console TX non-blocking *before* the // PowerFSM/close handling below emits more log lines to a dead port. - if (!connected) + if (!connected) { setHostDraining(false); + // Keep any retained tail: HWCDC may still hold its prefix, and dropping metadata + // would let the next frame header land inside that frame's declared payload. + } StreamAPI::onConnectionChanged(connected); if (connected) setHostDraining(true); } +/// Continue retained USB CDC output under the shared stream lock. +bool SerialConsole::finishPendingFrame() +{ +#ifdef IS_USB_SERIAL + concurrency::LockGuard guard(&streamLock); + return frameWriter.finishPendingFrame(Port); +#else + return true; +#endif +} + +/// Protect the retained log buffer from being overwritten. +bool SerialConsole::canEncodeLogRecord() +{ +#ifdef IS_USB_SERIAL + concurrency::LockGuard guard(&streamLock); + return frameWriter.isIdle(); +#else + return true; +#endif +} + +/// Frame USB CDC output and retain any unwritten tail. +bool SerialConsole::writeFrame(uint8_t *buf, size_t len, bool bestEffort) +{ +#ifdef IS_USB_SERIAL + if (len == 0 || !canWrite) + return false; + + const size_t totalLen = buildFrameHeader(buf, len); + + concurrency::LockGuard guard(&streamLock); + return frameWriter.writeFrame(Port, buf, totalLen, bestEffort); +#else + return StreamAPI::writeFrame(buf, len, bestEffort); +#endif +} + /** * we override this to notice when we've received a protobuf over the serial * stream. Then we shut off debug serial output. @@ -167,12 +234,17 @@ bool SerialConsole::handleToRadio(const uint8_t *buf, size_t len) } } +/// Route logs without allowing raw bytes into an active protobuf stream. void SerialConsole::log_to_serial(const char *logLevel, const char *format, va_list arg) { - if (usingProtobufs && config.security.debug_log_api_enabled) { - meshtastic_LogRecord_Level ll = RedirectablePrint::getLogLevel(logLevel); - auto thread = concurrency::OSThread::currentThread; - emitLogRecord(ll, thread ? thread->ThreadName.c_str() : "", format, arg); - } else - RedirectablePrint::log_to_serial(logLevel, format, arg); + if (usingProtobufs) { + if (config.security.debug_log_api_enabled && !pauseBluetoothLogging) { + meshtastic_LogRecord_Level ll = RedirectablePrint::getLogLevel(logLevel); + auto thread = concurrency::OSThread::currentThread; + emitLogRecord(ll, thread ? thread->ThreadName.c_str() : "", format, arg); + } + return; + } + + RedirectablePrint::log_to_serial(logLevel, format, arg); } diff --git a/src/SerialConsole.h b/src/SerialConsole.h index e81ded22f..29614e0bc 100644 --- a/src/SerialConsole.h +++ b/src/SerialConsole.h @@ -2,6 +2,7 @@ #include "RedirectablePrint.h" #include "StreamAPI.h" +#include "mesh/StreamFrameWriter.h" /** * Provides both debug printing and, if the client starts sending protobufs to us, switches to send/receive protobufs * (and starts dropping debug printing - FIXME, eventually those prints should be encapsulated in protobufs). @@ -14,6 +15,7 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur bool usingProtobufs = false; public: + /// Initialize the shared serial stream for console and protobuf traffic. SerialConsole(); /** @@ -22,35 +24,46 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur */ virtual bool handleToRadio(const uint8_t *buf, size_t len) override; - virtual size_t write(uint8_t c) override - { - if (c == '\n') // prefix any newlines with carriage return - RedirectablePrint::write('\r'); - return RedirectablePrint::write(c); - } + /// Write a raw console byte unless the stream is in protobuf mode. + virtual size_t write(uint8_t c) override; + /// Service serial input, pending output, and connection state. virtual int32_t runOnce() override; + /// Flush raw console output when explicitly requested. void flush(); + /// Wake the serial thread after receive activity. void rxInt(); protected: /// Check the current underlying physical link to see if the client is currently connected virtual bool checkIsConnected() override; + /// Wake the serial thread when PhoneAPI queues output. virtual void onNowHasData(uint32_t fromRadioNum) override; /// Track serial API connect/disconnect so we can make console writes /// non-blocking while no host is listening (see setHostDraining()). virtual void onConnectionChanged(bool connected) override; - /// Possibly switch to protobufs if we see a valid protobuf message + /// Emit a framed API log when enabled, or raw output before protobuf mode. virtual void log_to_serial(const char *logLevel, const char *format, va_list arg); + /// Continue retained USB CDC output before PhoneAPI advances. + virtual bool finishPendingFrame() override; + /// Return whether the dedicated log buffer can be safely overwritten. + virtual bool canEncodeLogRecord() override; + /// Write or retain one framed USB CDC message. + virtual bool writeFrame(uint8_t *buf, size_t len, bool bestEffort) override; + private: /// On USB CDC targets, keep console TX non-blocking unless a host is draining the /// port, so a dead host can't stall the main loop and trip the task watchdog. void setHostDraining(bool draining); + +#if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + StreamFrameWriter frameWriter; +#endif }; // A simple wrapper to allow non class aware code write to the console diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 8c28ee6af..12649e031 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -9,6 +9,7 @@ #define START2 0xc3 #define HEADER_LEN 4 +/// Poll the underlying stream, drain output, and update connection state. int32_t StreamAPI::runOncePart() { auto result = readStream(); @@ -17,6 +18,7 @@ int32_t StreamAPI::runOncePart() return result; } +/// Consume supplied input bytes, drain output, and update connection state. int32_t StreamAPI::runOncePart(char *buf, uint16_t bufLen) { auto result = readStream(buf, bufLen); @@ -48,6 +50,11 @@ int32_t StreamAPI::readStream(const char *buf, uint16_t bufLen) void StreamAPI::writeStream() { if (canWrite) { + // A transport that retained a short frame must complete it before + // getFromRadio() advances the PhoneAPI state to the next packet. + if (!finishPendingFrame()) + return; + uint32_t len; do { // Send every packet we can @@ -58,6 +65,7 @@ void StreamAPI::writeStream() } } +/// Parse supplied bytes through the framed ToRadio receive state machine. int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen) { uint16_t index = 0; @@ -167,20 +175,27 @@ int32_t StreamAPI::readStream() } } +/// Encode the stream marker and big-endian payload length. +size_t StreamAPI::buildFrameHeader(uint8_t *buf, size_t payloadLen) +{ + buf[0] = START1; + buf[1] = START2; + buf[2] = (payloadLen >> 8) & 0xff; + buf[3] = payloadLen & 0xff; + return payloadLen + HEADER_LEN; +} + /** * Send the current txBuffer over our stream */ -bool StreamAPI::writeFrame(uint8_t *buf, size_t len) +/// Write one framed payload using the transport's failure semantics. +bool StreamAPI::writeFrame(uint8_t *buf, size_t len, bool bestEffort) { + (void)bestEffort; if (len == 0 || !canWrite) return false; - buf[0] = START1; - buf[1] = START2; - buf[2] = (len >> 8) & 0xff; - buf[3] = len & 0xff; - - auto totalLen = len + HEADER_LEN; + const size_t totalLen = buildFrameHeader(buf, len); // Serialize write-readiness checks, writes and write-failure handling // against concurrent stream writes/close. concurrency::LockGuard guard(&streamLock); @@ -197,11 +212,13 @@ bool StreamAPI::writeFrame(uint8_t *buf, size_t len) return false; } +/// Emit the prepared main PhoneAPI payload as required output. bool StreamAPI::emitTxBuffer(size_t len) { - return writeFrame(txBuf, len); + return writeFrame(txBuf, len, false); } +/// Emit the initial reboot notification as a framed FromRadio payload. void StreamAPI::emitRebooted() { // In case we send a FromRadio packet @@ -213,8 +230,13 @@ void StreamAPI::emitRebooted() emitTxBuffer(pb_encode_to_bytes(txBuf + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratch)); } +/// Encode and emit one protobuf LogRecord using the dedicated log buffers. void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg) { + // A retained short log frame still points into txBufLog, so do not overwrite it. + if (!canEncodeLogRecord()) + return; + // IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here - those // belong to the main packet-emission path and a LOG_ firing during // `writeStream()` would corrupt an in-flight encode. We keep a @@ -237,7 +259,7 @@ void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, size_t len = pb_encode_to_bytes(txBufLog + HEADER_LEN, meshtastic_FromRadio_size, &meshtastic_FromRadio_msg, &fromRadioScratchLog); - writeFrame(txBufLog, len); + writeFrame(txBufLog, len, true); } /// Hookable to find out when connection changes diff --git a/src/mesh/StreamAPI.h b/src/mesh/StreamAPI.h index e23907b2b..705460532 100644 --- a/src/mesh/StreamAPI.h +++ b/src/mesh/StreamAPI.h @@ -91,12 +91,24 @@ class StreamAPI : public PhoneAPI /// Low level function to emit a protobuf encapsulated log record void emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg); + /// Return whether the transport can accept a frame of the requested size. virtual bool canWriteFrame(size_t frameLen) { return true; } + /// Let transports recover from or close after an incomplete write. virtual void onFrameWriteFailed(size_t frameLen, size_t writtenLen) {} - private: - bool writeFrame(uint8_t *buf, size_t len); + /// Fill in the 4-byte 0x94C3 length header; returns the total frame length. + static size_t buildFrameHeader(uint8_t *buf, size_t payloadLen); + /// Complete retained transport output before dequeuing another PhoneAPI packet. + virtual bool finishPendingFrame() { return true; } + /// Return whether the dedicated log buffer is available for encoding. + virtual bool canEncodeLogRecord() { return true; } + /// Frame and write a payload, optionally using best-effort admission. + virtual bool writeFrame(uint8_t *buf, size_t len, bool bestEffort); + + concurrency::Lock streamLock; + + private: /// Dedicated scratch + tx buffer for LogRecord emission. /// /// The main packet emission path (`writeStream` -> `getFromRadio` -> @@ -117,5 +129,4 @@ class StreamAPI : public PhoneAPI /// interleave on the wire. meshtastic_FromRadio fromRadioScratchLog = {}; uint8_t txBufLog[MAX_STREAM_BUF_SIZE] = {0}; - concurrency::Lock streamLock; }; diff --git a/src/mesh/StreamFrameWriter.cpp b/src/mesh/StreamFrameWriter.cpp new file mode 100644 index 000000000..a805030e8 --- /dev/null +++ b/src/mesh/StreamFrameWriter.cpp @@ -0,0 +1,65 @@ +#include "StreamFrameWriter.h" + +#include + +/// Start a frame or retain required output behind an incomplete frame. +bool StreamFrameWriter::writeFrame(Stream &stream, uint8_t *frame, size_t frameLen, bool bestEffort) +{ + if (!finishPendingFrame(stream)) { + // Single required-frame producer invariant: writeStream() is gated on + // finishPendingFrame(), so a second required frame can never arrive here. + assert(bestEffort || !deferredFrame); + + // Preserve required output that was encoded while another frame tail + // was pending. Best-effort output is dropped instead. + if (!bestEffort && !deferredFrame) { + deferredFrame = frame; + deferredFrameLen = frameLen; + } + return false; + } + + // Never start best-effort output unless its complete frame fits. + if (bestEffort && stream.availableForWrite() < (int)frameLen) + return false; + + size_t written = stream.write(frame, frameLen); + if (written == frameLen) + return true; + + pendingFrame = frame; + pendingFrameLen = frameLen; + pendingFrameOffset = written; + return false; +} + +/// Continue the pending tail with at most one Stream::write() call. +bool StreamFrameWriter::finishPendingFrame(Stream &stream) +{ + if (!pendingFrame) + return true; + + size_t remaining = pendingFrameLen - pendingFrameOffset; + size_t written = stream.write(pendingFrame + pendingFrameOffset, remaining); + if (written > remaining) + written = remaining; + pendingFrameOffset += written; + + if (pendingFrameOffset < pendingFrameLen) + return false; + + pendingFrame = nullptr; + pendingFrameLen = 0; + pendingFrameOffset = 0; + + // Promote required output without starting another write in this call. + if (deferredFrame) { + pendingFrame = deferredFrame; + pendingFrameLen = deferredFrameLen; + deferredFrame = nullptr; + deferredFrameLen = 0; + return false; + } + + return true; +} diff --git a/src/mesh/StreamFrameWriter.h b/src/mesh/StreamFrameWriter.h new file mode 100644 index 000000000..cc9c5bbcc --- /dev/null +++ b/src/mesh/StreamFrameWriter.h @@ -0,0 +1,26 @@ +#pragma once + +#include "Stream.h" +#include +#include + +/** Caller-owned frame storage must remain valid and unchanged until isIdle(). */ +class StreamFrameWriter +{ + public: + /// Start a complete frame, or retain a required frame behind pending output. + bool writeFrame(Stream &stream, uint8_t *frame, size_t frameLen, bool bestEffort); + + /// Make one bounded attempt to finish pending output. + bool finishPendingFrame(Stream &stream); + + /// Return true when no frame buffer is retained. + bool isIdle() const { return pendingFrame == nullptr && deferredFrame == nullptr; } + + private: + uint8_t *pendingFrame = nullptr; + size_t pendingFrameLen = 0; + size_t pendingFrameOffset = 0; + uint8_t *deferredFrame = nullptr; + size_t deferredFrameLen = 0; +}; diff --git a/test/native-suite-count b/test/native-suite-count index 64bb6b746..e85087aff 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -30 +31 diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp new file mode 100644 index 000000000..84613cf32 --- /dev/null +++ b/test/test_stream_api/test_main.cpp @@ -0,0 +1,436 @@ +#include "MeshTypes.h" +#include "SerialConsole.h" +#include "TestUtil.h" +#include "configuration.h" +#include "mesh-pb-constants.h" +#include "mesh/MeshService.h" +#include "mesh/StreamAPI.h" +#include "mesh/StreamFrameWriter.h" +#include +#include +#include +#include +#include +#include +#include + +/// Output-only stream whose write quotas deterministically simulate backpressure. +class ScriptedStream : public Stream +{ + public: + /// Report that no input bytes are queued. + int available() override { return 0; } + /// Return end-of-input for the output-only stream. + int read() override { return -1; } + /// Return end-of-input without consuming data. + int peek() override { return -1; } + /// Report the configured output capacity. + int availableForWrite() override { return availableCapacity; } + + /// Route single-byte writes through the quota-aware buffer writer. + size_t write(uint8_t value) override { return write(&value, 1); } + + /// Accept at most the next scripted quota and capture accepted bytes. + size_t write(const uint8_t *buffer, size_t size) override + { + requestedLengths.push_back(size); + size_t quota = size; + if (!writeQuotas.empty()) { + quota = writeQuotas.front(); + writeQuotas.pop_front(); + } + size_t accepted = std::min(quota, size); + output.insert(output.end(), buffer, buffer + accepted); + return accepted; + } + + /// Record flush calls without changing captured output. + void flush() override { flushCount++; } + + /// Set the maximum bytes accepted by the next write call. + void queueWrite(size_t quota) { writeQuotas.push_back(quota); } + + int availableCapacity = std::numeric_limits::max(); + unsigned flushCount = 0; + std::deque writeQuotas; + std::vector requestedLengths; + std::vector output; +}; + +/// Print sink that records bytes emitted by the real SerialConsole. +class RecordingPrint : public Print +{ + public: + /// Capture one output byte. + size_t write(uint8_t value) override + { + output.push_back(value); + return 1; + } + + std::vector output; +}; + +/// Installs a MeshService for a test and restores the previous global service. +class ScopedMeshService +{ + public: + /// Install the scoped service. + ScopedMeshService() : previous(service) { service = &instance; } + /// Restore the prior service after StreamAPI fixtures are destroyed. + ~ScopedMeshService() { service = previous; } + + private: + MeshService instance; + MeshService *previous; +}; + +/// Exposes generic StreamAPI hooks and records frame-write behavior. +class StreamAPITestShim : public StreamAPI +{ + public: + /// Construct the shim over a scripted stream. + explicit StreamAPITestShim(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Invoke the generic transport implementation rather than this shim's capture hook. + bool writeBaseFrame(uint8_t *buf, size_t len, bool bestEffort = false) { return StreamAPI::writeFrame(buf, len, bestEffort); } + + bool finishReady = true; + bool allowWrite = true; + unsigned finishCalls = 0; + unsigned frameWriteCalls = 0; + unsigned failureCalls = 0; + size_t failedFrameLen = 0; + size_t failedWrittenLen = 0; + std::vector capturedPayload; + + protected: + /// Record the pending-frame gate and return its configured state. + bool finishPendingFrame() override + { + finishCalls++; + return finishReady; + } + + /// Apply the configured generic write-readiness result. + bool canWriteFrame(size_t) override { return allowWrite; } + + /// Capture generic short-write failure metadata. + void onFrameWriteFailed(size_t frameLen, size_t writtenLen) override + { + failureCalls++; + failedFrameLen = frameLen; + failedWrittenLen = writtenLen; + } + + /// Capture one encoded PhoneAPI payload without writing it. + bool writeFrame(uint8_t *buf, size_t len, bool bestEffort) override + { + (void)bestEffort; + frameWriteCalls++; + capturedPayload.assign(buf + 4, buf + 4 + len); + return false; + } +}; + +/// Exposes framed-log hooks and records best-effort writes. +class LogHookStreamAPI : public StreamAPI +{ + public: + /// Construct the log shim over a scripted stream. + explicit LogHookStreamAPI(Stream *stream) : StreamAPI(stream) {} + + /// Keep connection-timeout handling inactive during tests. + bool checkIsConnected() override { return true; } + + /// Encode a formatted log through StreamAPI's production log path. + void emitTestLog(const char *format, ...) + { + va_list args; + va_start(args, format); + emitLogRecord(meshtastic_LogRecord_Level_INFO, "test", format, args); + va_end(args); + } + + bool allowLogEncoding = false; + unsigned frameWriteCalls = 0; + bool lastBestEffort = false; + + protected: + /// Apply the configured log-encoding gate. + bool canEncodeLogRecord() override { return allowLogEncoding; } + + /// Record whether the encoded log was marked best-effort. + bool writeFrame(uint8_t *, size_t, bool bestEffort) override + { + frameWriteCalls++; + lastBestEffort = bestEffort; + return true; + } +}; + +/// Assert byte-for-byte equality between expected and captured stream output. +static void assertBytesEqual(const std::vector &expected, const std::vector &actual) +{ + TEST_ASSERT_EQUAL_UINT(expected.size(), actual.size()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(expected.data(), actual.data(), expected.size()); +} + +/// Verify retries append only the unwritten tail and reproduce the frame exactly once. +void test_frame_writer_continues_only_unwritten_tail() +{ + ScriptedStream stream; + StreamFrameWriter writer; + std::vector frame = {0x94, 0xc3, 0x00, 0x06, 1, 2, 3, 4, 5, 6}; + stream.queueWrite(3); + stream.queueWrite(2); + stream.queueWrite(frame.size()); + + TEST_ASSERT_FALSE(writer.writeFrame(stream, frame.data(), frame.size(), false)); + TEST_ASSERT_FALSE(writer.isIdle()); + TEST_ASSERT_FALSE(writer.finishPendingFrame(stream)); + TEST_ASSERT_FALSE(writer.isIdle()); + TEST_ASSERT_TRUE(writer.finishPendingFrame(stream)); + TEST_ASSERT_TRUE(writer.isIdle()); + + std::vector expectedRequests = {10, 7, 5}; + TEST_ASSERT_EQUAL_UINT(expectedRequests.size(), stream.requestedLengths.size()); + TEST_ASSERT_EQUAL_UINT64_ARRAY(expectedRequests.data(), stream.requestedLengths.data(), expectedRequests.size()); + assertBytesEqual(frame, stream.output); + TEST_ASSERT_EQUAL_UINT(0, stream.flushCount); +} + +/// Verify a replacement session receives a complete old frame before its new frame. +void test_frame_writer_completes_retained_tail_before_new_session_frame() +{ + ScriptedStream stream; + StreamFrameWriter writer; + std::vector oldFrame = {0x94, 0xc3, 0x00, 0x03, 0xa1, 0xa2, 0xa3}; + std::vector newFrame = {0x94, 0xc3, 0x00, 0x02, 0xb1, 0xb2}; + stream.queueWrite(3); + stream.queueWrite(oldFrame.size()); + stream.queueWrite(newFrame.size()); + + TEST_ASSERT_FALSE(writer.writeFrame(stream, oldFrame.data(), oldFrame.size(), false)); + TEST_ASSERT_FALSE(writer.isIdle()); + + // A replacement client starts without discarding the accepted old prefix. + TEST_ASSERT_TRUE(writer.writeFrame(stream, newFrame.data(), newFrame.size(), false)); + TEST_ASSERT_TRUE(writer.isIdle()); + + std::vector expected = oldFrame; + expected.insert(expected.end(), newFrame.begin(), newFrame.end()); + assertBytesEqual(expected, stream.output); +} + +/// Verify a required main frame remains ordered behind a partial log frame. +void test_frame_writer_defers_main_behind_partial_log() +{ + ScriptedStream stream; + StreamFrameWriter writer; + std::vector logFrame = {0x94, 0xc3, 0x00, 0x02, 0xa1, 0xa2}; + std::vector mainFrame = {0x94, 0xc3, 0x00, 0x03, 0xb1, 0xb2, 0xb3}; + stream.queueWrite(2); + stream.queueWrite(0); + stream.queueWrite(logFrame.size()); + stream.queueWrite(mainFrame.size()); + + TEST_ASSERT_FALSE(writer.writeFrame(stream, logFrame.data(), logFrame.size(), true)); + TEST_ASSERT_FALSE(writer.isIdle()); + TEST_ASSERT_FALSE(writer.writeFrame(stream, mainFrame.data(), mainFrame.size(), false)); + TEST_ASSERT_FALSE(writer.isIdle()); + TEST_ASSERT_FALSE(writer.finishPendingFrame(stream)); + TEST_ASSERT_FALSE(writer.isIdle()); + TEST_ASSERT_TRUE(writer.finishPendingFrame(stream)); + TEST_ASSERT_TRUE(writer.isIdle()); + + std::vector expected = logFrame; + expected.insert(expected.end(), mainFrame.begin(), mainFrame.end()); + assertBytesEqual(expected, stream.output); + TEST_ASSERT_EQUAL_UINT(4, stream.requestedLengths.size()); + TEST_ASSERT_EQUAL_UINT(0, stream.flushCount); +} + +/// Verify best-effort output starts only when the complete frame fits. +void test_frame_writer_rejects_best_effort_without_full_capacity() +{ + ScriptedStream stream; + StreamFrameWriter writer; + std::vector frame = {0x94, 0xc3, 0x00, 0x02, 1, 2}; + stream.availableCapacity = frame.size() - 1; + + TEST_ASSERT_FALSE(writer.writeFrame(stream, frame.data(), frame.size(), true)); + TEST_ASSERT_TRUE(writer.isIdle()); + TEST_ASSERT_EQUAL_UINT(0, stream.requestedLengths.size()); + + stream.availableCapacity = frame.size(); + TEST_ASSERT_TRUE(writer.writeFrame(stream, frame.data(), frame.size(), true)); + TEST_ASSERT_TRUE(writer.isIdle()); + TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size()); + assertBytesEqual(frame, stream.output); +} + +/// Verify each zero-progress continuation makes one bounded write attempt. +void test_frame_writer_zero_progress_is_one_bounded_attempt() +{ + ScriptedStream stream; + StreamFrameWriter writer; + std::vector frame = {0x94, 0xc3, 0x00, 0x02, 1, 2}; + stream.queueWrite(1); + stream.queueWrite(0); + stream.queueWrite(0); + stream.queueWrite(frame.size()); + + TEST_ASSERT_FALSE(writer.writeFrame(stream, frame.data(), frame.size(), false)); + TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size()); + TEST_ASSERT_FALSE(writer.finishPendingFrame(stream)); + TEST_ASSERT_EQUAL_UINT(2, stream.requestedLengths.size()); + TEST_ASSERT_FALSE(writer.finishPendingFrame(stream)); + TEST_ASSERT_EQUAL_UINT(3, stream.requestedLengths.size()); + TEST_ASSERT_TRUE(writer.finishPendingFrame(stream)); + TEST_ASSERT_EQUAL_UINT(4, stream.requestedLengths.size()); + assertBytesEqual(frame, stream.output); +} + +/// Verify generic StreamAPI framing and successful-write flush behavior. +void test_stream_api_full_write_frames_and_flushes() +{ + ScopedMeshService scopedService; + ScriptedStream stream; + StreamAPITestShim api(&stream); + uint8_t frame[7] = {0, 0, 0, 0, 0x11, 0x22, 0x33}; + + TEST_ASSERT_TRUE(api.writeBaseFrame(frame, 3)); + + std::vector expected = {0x94, 0xc3, 0x00, 0x03, 0x11, 0x22, 0x33}; + assertBytesEqual(expected, stream.output); + TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size()); + TEST_ASSERT_EQUAL_UINT(1, stream.flushCount); + TEST_ASSERT_EQUAL_UINT(0, api.failureCalls); +} + +/// Verify generic transports report short writes without flushing or retrying. +void test_stream_api_short_write_reports_failure_without_flush() +{ + ScopedMeshService scopedService; + ScriptedStream stream; + StreamAPITestShim api(&stream); + uint8_t frame[7] = {0, 0, 0, 0, 0x11, 0x22, 0x33}; + stream.queueWrite(5); + + TEST_ASSERT_FALSE(api.writeBaseFrame(frame, 3)); + + TEST_ASSERT_EQUAL_UINT(1, stream.requestedLengths.size()); + TEST_ASSERT_EQUAL_UINT(0, stream.flushCount); + TEST_ASSERT_EQUAL_UINT(1, api.failureCalls); + TEST_ASSERT_EQUAL_UINT(7, api.failedFrameLen); + TEST_ASSERT_EQUAL_UINT(5, api.failedWrittenLen); +} + +/// Verify retained output blocks PhoneAPI from dequeuing the next payload. +void test_stream_api_finishes_pending_before_advancing_phone_api() +{ + ScopedMeshService scopedService; + ScriptedStream stream; + StreamAPITestShim api(&stream); + api.sendConfigComplete(); + api.sendNotification(meshtastic_LogRecord_Level_WARNING, 42, "still queued"); + api.finishReady = false; + + api.runOncePart(nullptr, 0); + TEST_ASSERT_EQUAL_UINT(1, api.finishCalls); + TEST_ASSERT_EQUAL_UINT(0, api.frameWriteCalls); + + api.finishReady = true; + api.runOncePart(nullptr, 0); + TEST_ASSERT_EQUAL_UINT(2, api.finishCalls); + TEST_ASSERT_EQUAL_UINT(1, api.frameWriteCalls); + + meshtastic_FromRadio decoded = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(api.capturedPayload.data(), api.capturedPayload.size(), &meshtastic_FromRadio_msg, &decoded)); + TEST_ASSERT_EQUAL_UINT(meshtastic_FromRadio_clientNotification_tag, decoded.which_payload_variant); + TEST_ASSERT_EQUAL_UINT32(42, decoded.clientNotification.reply_id); +} + +/// Verify framed logs honor the encoding gate and use best-effort writes. +void test_stream_api_gates_logs_and_marks_them_best_effort() +{ + ScopedMeshService scopedService; + ScriptedStream stream; + LogHookStreamAPI api(&stream); + + api.emitTestLog("blocked %u", 1U); + TEST_ASSERT_EQUAL_UINT(0, api.frameWriteCalls); + + api.allowLogEncoding = true; + api.emitTestLog("allowed %u", 2U); + TEST_ASSERT_EQUAL_UINT(1, api.frameWriteCalls); + TEST_ASSERT_TRUE(api.lastBestEffort); +} + +/// Verify the real SerialConsole emits no unframed bytes in protobuf mode. +void test_serial_console_suppresses_raw_output_in_protobuf_mode() +{ + RecordingPrint sink; + const bool oldHasLora = config.has_lora; + const bool oldHasSecurity = config.has_security; + const bool oldSerialEnabled = config.security.serial_enabled; + const bool oldDebugLogApiEnabled = config.security.debug_log_api_enabled; + + config.has_lora = true; + config.has_security = true; + config.security.serial_enabled = true; + config.security.debug_log_api_enabled = false; + console->setDestination(&sink); + + console->write('A'); + const bool rawBeforeProtobuf = sink.output.size() == 1 && sink.output[0] == 'A'; + sink.output.clear(); + + const uint8_t emptyToRadio = 0; + console->handleToRadio(&emptyToRadio, 0); + console->write('B'); + console->write('\n'); + console->log(MESHTASTIC_LOG_LEVEL_ERROR, "must stay framed"); + const bool emptyAfterProtobuf = sink.output.empty(); + + console->setDestination(&Serial); + config.has_lora = oldHasLora; + config.has_security = oldHasSecurity; + config.security.serial_enabled = oldSerialEnabled; + config.security.debug_log_api_enabled = oldDebugLogApiEnabled; + + TEST_ASSERT_TRUE(rawBeforeProtobuf); + TEST_ASSERT_TRUE(emptyAfterProtobuf); +} + +/// Unity per-test setup; fixtures are local to each test. +void setUp(void) {} +/// Unity per-test teardown; fixtures clean themselves up. +void tearDown(void) {} + +/// Initialize the native environment and run the stream regression suite. +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_frame_writer_continues_only_unwritten_tail); + RUN_TEST(test_frame_writer_completes_retained_tail_before_new_session_frame); + RUN_TEST(test_frame_writer_defers_main_behind_partial_log); + RUN_TEST(test_frame_writer_rejects_best_effort_without_full_capacity); + RUN_TEST(test_frame_writer_zero_progress_is_one_bounded_attempt); + RUN_TEST(test_stream_api_full_write_frames_and_flushes); + RUN_TEST(test_stream_api_short_write_reports_failure_without_flush); + RUN_TEST(test_stream_api_finishes_pending_before_advancing_phone_api); + RUN_TEST(test_stream_api_gates_logs_and_marks_them_best_effort); + // usingProtobufs intentionally has no reset path, so this must run last. + RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); + exit(UNITY_END()); +} + +/// Unused Arduino loop required by the native Unity runner. +void loop() {} From c5355641d32d2d1d145075b21cf424300bd2200b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 11 Jul 2026 08:24:35 -0500 Subject: [PATCH 09/69] Add MEDIUM_TURBO modem preset (#10988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Protobufs * Wire up MEDIUM_TURBO modem preset MEDIUM_TURBO (500 kHz, SF9, CR 4/5) already existed in the protobuf enum but was never wired into firmware, so selecting it silently fell through to the LONG_FAST default and rendered an "Invalid" display name. Add its bw/sf/cr mapping (modemPresetToParams), display name (MediumTurbo/MedT), PRESETS_STD membership (standard regions only — 500 kHz does not fit EU868's 250 kHz band, so it stays out of PRESETS_EU_868 and is rejected/clamped there), and the MEDIUM SNR-grading bucket. Includes positive coverage in test_radio, EU868-reject + US-accept coverage in test_admin_radio and test_mesh_beacon, the STD preset count 9->10, an extended fuzz range, and the client-spec doc. * Address review feedback on MEDIUM_TURBO tests - test_mesh_beacon: assert has_mesh_beacon before checking the invalid preset was cleared, so the EU868-cleared test can't pass on a dropped message (matches the existing SHORT_TURBO test). - test_fuzz_packets: draw modem presets from _ModemPreset_ARRAYSIZE instead of a hard-coded 17 so the fuzz range tracks future enum additions automatically. --- .gitignore | 1 + ...region_preset_compatibility_client_spec.md | 18 ++++---- protobufs | 2 +- src/DisplayFormatters.cpp | 3 ++ src/graphics/draw/UIRenderer.cpp | 1 + src/mesh/MeshRadio.h | 5 +++ src/mesh/RadioInterface.cpp | 4 +- src/mesh/generated/meshtastic/config.pb.h | 10 +++-- src/modules/CannedMessageModule.cpp | 1 + test/test_admin_radio/test_main.cpp | 20 +++++---- test/test_fuzz_packets/test_main.cpp | 5 ++- test/test_mesh_beacon/test_main.cpp | 40 +++++++++++++++++ test/test_radio/test_main.cpp | 44 +++++++++++++++++++ 13 files changed, 129 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index bee3fe4c3..f29da0ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .pio pio +.pio-docker pio.tar web web.tar diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index fb2019ec0..0a818ff41 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -69,7 +69,7 @@ message LoRaRegionPresetMap { ### 2.3 Why grouped (and the size envelope clients should respect) A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions -share one identical preset list (the "standard" 9-preset list), so the map is delivered +share one identical preset list (the "standard" 10-preset list), so the map is delivered **grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every known region to one of those groups by index. This keeps the encoded size additive (`groups` + `region_groups`) rather than multiplicative, well under the cap. @@ -246,14 +246,14 @@ For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups indices are assigned in region-table order (first region to use a profile creates its group), so they are stable as listed here: -| group_index | default_preset | licensed_only | presets | -| ----------------------- | -------------- | ------------- | -------------------------------------------------------------------------------------------------------------- | -| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO | -| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE | -| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW | -| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW | -| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | -| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | +| group_index | default_preset | licensed_only | presets | +| ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO | +| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE | +| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW | +| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW | +| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | +| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | `region_groups` (region → group_index): diff --git a/protobufs b/protobufs index 1ae3be3d5..9d589c132 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1ae3be3d5413d2190c1eb0d3ced094237de68b81 +Subproject commit 9d589c1321478193885d6cecf852bf02d14fc92b diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index 63ffdc45e..5ab39fc1c 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -27,6 +27,9 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC case PRESET(MEDIUM_FAST): return useShortName ? "MedF" : "MediumFast"; break; + case PRESET(MEDIUM_TURBO): + return useShortName ? "MedT" : "MediumTurbo"; + break; case PRESET(LONG_SLOW): return useShortName ? "LongS" : "LongSlow"; break; diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index c14b53839..2e2a35f18 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -844,6 +844,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat return -6.0f; case PRESET(MEDIUM_SLOW): case PRESET(MEDIUM_FAST): + case PRESET(MEDIUM_TURBO): return -5.5f; case PRESET(SHORT_SLOW): case PRESET(SHORT_FAST): diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 7cc8cc58e..e5b54d6a2 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -229,6 +229,11 @@ static inline void modemPresetToParams(meshtastic_Config_LoRaConfig_ModemPreset cr = 5; sf = 10; break; + case PRESET(MEDIUM_TURBO): + bwKHz = wideLora ? 1625.0f : 500.0f; + cr = 5; + sf = 9; + break; case PRESET(LONG_TURBO): bwKHz = wideLora ? 1625.0f : 500.0f; cr = 8; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 5a3215fee..84d0f7906 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -39,8 +39,8 @@ #endif static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = { - PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), - PRESET(SHORT_FAST), PRESET(LONG_MODERATE), PRESET(SHORT_TURBO), PRESET(LONG_TURBO), MODEM_PRESET_END}; + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), PRESET(SHORT_FAST), + PRESET(LONG_MODERATE), PRESET(SHORT_TURBO), PRESET(LONG_TURBO), PRESET(MEDIUM_TURBO), MODEM_PRESET_END}; static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 1f49ef9f3..0778a9bc5 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -374,7 +374,11 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths. Only compatible with SX127x and SX126x chipsets. Comparable link budget and data rate to LONG_MODERATE. */ - meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW = 15 + meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW = 15, + /* Medium Range - Turbo + This preset performs similarly to MEDIUM_FAST, but with 500kHz bandwidth. + It is not legal to use in all regions due to this wider bandwidth. */ + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO = 16 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { @@ -767,8 +771,8 @@ extern "C" { #define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST -#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW -#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW+1)) +#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO +#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO+1)) #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index b2278133d..7d3116127 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -2118,6 +2118,7 @@ static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset) return -6.0f; case PRESET(MEDIUM_SLOW): case PRESET(MEDIUM_FAST): + case PRESET(MEDIUM_TURBO): return -5.5f; case PRESET(SHORT_SLOW): case PRESET(SHORT_FAST): diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 8a839f934..06748b7fa 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -484,7 +484,7 @@ static void test_validateConfigLora_allStdPresetsValidForUS() 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, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, }; for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { @@ -498,7 +498,8 @@ static void test_validateConfigLora_allStdPresetsValidForUS() static void test_validateConfigLora_turboPresetsInvalidForEU868() { - // EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO + // EU_868 has PRESETS_EU_868 which excludes the 500 kHz turbo presets + // (SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO) meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; cfg.use_preset = true; @@ -508,6 +509,9 @@ static void test_validateConfigLora_turboPresetsInvalidForEU868() cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_TURBO should be invalid for EU_868"); } static void test_validateConfigLora_validPresetsForEU868() @@ -598,13 +602,13 @@ static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast() static void test_validateConfigLora_allPresetsValidForLORA24() { - // LORA_24 uses PROFILE_STD (9 presets) with wideLora=true + // LORA_24 uses PROFILE_STD (10 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, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, }; for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { @@ -792,11 +796,11 @@ static void test_validateConfigLora_siblingLockedPresetStillFailsValidation() // RegionInfo preset list integrity tests // ----------------------------------------------------------------------- -static void test_presetsStd_hasNineEntries() +static void test_presetsStd_hasTenEntries() { - // PROFILE_STD should have exactly 9 presets + // PROFILE_STD should have exactly 10 presets (adds MEDIUM_TURBO to the turbo cluster) const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); - TEST_ASSERT_EQUAL(9, us->getNumPresets()); + TEST_ASSERT_EQUAL(10, us->getNumPresets()); TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets()); } @@ -1320,7 +1324,7 @@ void setup() RUN_TEST(test_validateConfigLora_siblingLockedPresetStillFailsValidation); // RegionInfo preset list integrity - RUN_TEST(test_presetsStd_hasNineEntries); + RUN_TEST(test_presetsStd_hasTenEntries); RUN_TEST(test_presetsEU868_hasSevenEntries); RUN_TEST(test_presetsUndef_hasOneEntry); RUN_TEST(test_defaultPresetIsInAvailablePresets); diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index 196962aea..c65b5460e 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -450,7 +450,8 @@ static meshtastic_AdminMessage fuzzAdminMessage() // manual bandwidth==0 path that used to SIGFPE the validator. r.set_config.which_payload_variant = meshtastic_Config_lora_tag; r.set_config.payload_variant.lora.region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32); - r.set_config.payload_variant.lora.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16); + r.set_config.payload_variant.lora.modem_preset = + (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(_meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE); r.set_config.payload_variant.lora.use_preset = (rngRange(2) == 0); r.set_config.payload_variant.lora.bandwidth = rngRange(512); // includes 0 r.set_config.payload_variant.lora.channel_num = rngNext(); @@ -553,7 +554,7 @@ static meshtastic_MeshBeacon fuzzBeacon() fuzzChannelSettings(b.offer_channel); b.offer_region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32); b.has_offer_preset = (rngRange(2) == 0); - b.offer_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16); + b.offer_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(_meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE); return b; } diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 6cca03fbd..bb1a0abc2 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -251,6 +251,44 @@ static void test_adminValidation_turboPresetOnUS_isAccepted(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); } +/** + * Verify MEDIUM_TURBO is also cleared for EU_868. Like SHORT_TURBO/LONG_TURBO it is a 500 kHz preset + * that does not fit EU_868's 250 kHz band, so it must not survive admin validation there. + */ +static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_on_preset = true; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); +} + +/** + * Verify MEDIUM_TURBO passes validation for US (PROFILE_STD allows the full turbo family). + * The same 500 kHz preset that is illegal in EU_868 must be preserved in permissive regions. + */ +static void test_adminValidation_mediumTurboPresetOnUS_isAccepted(void) +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_on_preset = true; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); +} + /** * Verify an out-of-range region code (255) is sanitised to UNSET rather than stored verbatim. * Important to prevent invalid proto enum values from reaching the broadcaster and being broadcast @@ -1360,6 +1398,8 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); + RUN_TEST(test_adminValidation_mediumTurboPresetOnEU868_isCleared); + RUN_TEST(test_adminValidation_mediumTurboPresetOnUS_isAccepted); RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared); RUN_TEST(test_adminValidation_validOfferRegion_isPreserved); RUN_TEST(test_adminValidation_targetUnknownRegion_isCleared); diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 7d0d9565a..c62e6b267 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -195,6 +195,47 @@ static void test_applyModemConfig_customCodingRateLowerThanPreset() TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr()); } +// MEDIUM_TURBO performs like MEDIUM_FAST (sf=9, cr=5) but at 500 kHz. Verify the params resolve. +static void test_applyModemConfig_mediumTurbo() +{ + 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_MEDIUM_TURBO; + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(5, testRadio->getCr()); + TEST_ASSERT_EQUAL_UINT8(9, testRadio->getSf()); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 500.0f, testRadio->getBw()); +} + +// MEDIUM_TURBO is a 500 kHz preset, so it is invalid for EU_868 and must clamp to the region default. +static void test_clampConfigLora_mediumTurboInvalidForEU868() +{ + 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_MEDIUM_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +// MEDIUM_TURBO is valid for US (PROFILE_STD) and must be left unchanged. +static void test_clampConfigLora_mediumTurboValidForUS() +{ + 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_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO, cfg.modem_preset); +} + // ----------------------------------------------------------------------- // getRegionPresetMap() - region->valid-preset map sent to clients during want_config // ----------------------------------------------------------------------- @@ -317,6 +358,9 @@ void setup() RUN_TEST(test_applyModemConfig_codingRateMatchesPreset); RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset); RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset); + RUN_TEST(test_applyModemConfig_mediumTurbo); + RUN_TEST(test_clampConfigLora_mediumTurboInvalidForEU868); + RUN_TEST(test_clampConfigLora_mediumTurboValidForUS); RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds); RUN_TEST(test_regionPresetMap_matchesRegionTable); exit(UNITY_END()); From 7a25ffd0a2a44ffd9a6182fa605428f4b6eb92fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sat, 11 Jul 2026 19:10:25 +0200 Subject: [PATCH 10/69] fix: evaluate pre-hop hop_start bitfield guard post-decode instead of before decryption (#10758) --- src/mesh/NodeDB.cpp | 14 ++++++-------- src/mesh/NodeDB.h | 5 ++++- src/mesh/Router.cpp | 12 ++++++++++++ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e05f8b8f0..b2804910c 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3082,14 +3082,12 @@ HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p) return HopStartStatus::INVALID; if (p.hop_start == 0) { - // hop_start == hop_limit == 0: intentional zero-hop broadcast (e.g. beacon). Valid by definition - - // the packet was never meant to travel any hops, so no hop_start ambiguity applies. - if (p.hop_limit == 0) - return HopStartStatus::VALID; - // 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. + // hop_start == 0 is either a modern zero-hop broadcast (e.g. beacon) or pre-2.3.0 firmware (585805c) + // that never populated hop_start. Firmware 2.5.0 (bf34329) introduced a bitfield that is always + // present; use it to tell the two apart. The bitfield is encrypted under the channel key, so this can + // only be resolved for decoded packets - until then the status stays MISSING_OR_UNKNOWN. Callers + // acting before decode must therefore not treat UNKNOWN as a drop (the bitfield isn't readable yet); + // the verdict is re-checked post-decode in Router::handleReceived. if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield) return HopStartStatus::VALID; return HopStartStatus::MISSING_OR_UNKNOWN; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 8aa32dcc7..f7494811f 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -156,7 +156,10 @@ inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p) if (isFromUs(&p)) { return false; // local-originated packets should never be dropped by pre-hop drop policy } - return classifyHopStart(p) != HopStartStatus::VALID; + // Pre-decode: the channel-encrypted bitfield isn't readable yet, so a missing/unknown hop_start can't be + // distinguished from a modern packet. Only drop the provably-corrupt case here; the bitfield-dependent + // verdict is re-checked post-decode in Router::handleReceived. + return classifyHopStart(p) == HopStartStatus::INVALID; #endif } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 67d724433..0641cbc3c 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -879,6 +879,18 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) else printPacket("handleReceived(REMOTE)", p); +#if MESHTASTIC_PREHOP_DROP + // Pre-hop firmware drop, post-decode half: the bitfield that proves the origin populated hop_start is + // encrypted under the channel key, so it can only be evaluated now that the packet is decoded. A packet + // whose hop_start is still missing/unknown comes from pre-hop firmware - keep it out of module + // processing, admin handling, phone delivery, MQTT and rebroadcast. Local-origin packets are exempt. + if (!isFromUs(p) && classifyHopStart(*p) != HopStartStatus::VALID) { + logHopStartDrop(*p, "post-decode pre-hop drop"); + cancelSending(p->from, p->id); + skipHandle = true; + } +#endif + // Neighbor info module is disabled, ignore expensive neighbor info packets if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && p->decoded.portnum == meshtastic_PortNum_NEIGHBORINFO_APP && From 5513c36757b9a69d64d971ceb49f6362ef20e26b Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:43:28 +0100 Subject: [PATCH 11/69] Add helpful checks of channel names and PSK (#10792) * added warnings from firmware for simple channel setting mistakes * more and better checks * one ping only * Drop literal 'AQ==' from blank-PSK channel warnings The base64 encoding of the default channel key isn't something a user should type in by hand; state the condition instead of a raw key value. --------- Co-authored-by: Ben Meadors Co-authored-by: Claude --- src/modules/AdminModule.cpp | 279 +++++++++++++++++++++++++++- src/modules/AdminModule.h | 21 +++ test/test_admin_radio/test_main.cpp | 196 ++++++++++++++++++- 3 files changed, 487 insertions(+), 9 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index a3ee10b12..278fe6179 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,5 +1,6 @@ #include "AdminModule.h" #include "Channels.h" +#include "DisplayFormatters.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" @@ -461,6 +462,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta LOG_INFO("Commit transaction for edited settings"); hasOpenEditTransaction = false; saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE); + flushChannelWarnings(); // one coalesced message for everything edited in this transaction break; } case meshtastic_AdminMessage_get_device_connection_status_request_tag: { @@ -755,7 +757,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) changed = 1; owner.is_licensed = o.is_licensed; if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } } if (owner.has_is_unmessagable != o.has_is_unmessagable || @@ -777,6 +779,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) auto existingRole = config.device.role; bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET); bool requiresReboot = true; + bool loraPresetWarnPending = false; + meshtastic_Config_LoRaConfig pendingOldLora = {}, pendingNewLora = {}; switch (c.which_payload_variant) { case meshtastic_Config_device_tag: { @@ -1041,6 +1045,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) #endif config.lora = validatedLora; // Finally, return the validated config back to the main config + if (validatedLora.modem_preset != oldLoraConfig.modem_preset) { + pendingOldLora = oldLoraConfig; + pendingNewLora = validatedLora; + loraPresetWarnPending = true; + } break; } @@ -1093,6 +1102,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); + if (loraPresetWarnPending) + warnOnLoraPresetChange(pendingOldLora, pendingNewLora); + // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. + if (!hasOpenEditTransaction) + flushChannelWarnings(); } // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) @@ -1298,7 +1312,7 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) { channels.setChannel(cc); if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } // Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey() // resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex; @@ -1321,6 +1335,10 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc) if (clamped) sendWarning(publicChannelPrecisionMessage); saveChanges(SEGMENT_CHANNELS, false); + warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel + // Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now. + if (!hasOpenEditTransaction) + flushChannelWarnings(); } /** @@ -1736,7 +1754,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) // Remove PSK of primary channel for plaintext amateur usage if (channels.ensureLicensedOperation()) { - sendWarning(licensedModeMessage); + warnLicensedMode(); } channels.onConfigChanged(); @@ -1874,14 +1892,259 @@ void AdminModule::sendWarningAndLog(const char *format, ...) vsnprintf(buf, sizeof(buf), format, args); va_end(args); - LOG_WARN(buf); - // 2. Call sendWarning - // SECURITY NOTE: We pass "%s", buf instead of just 'buf'. - // If 'buf' contained a % symbol (e.g. "Battery 50%"), passing it directly - // would crash sendWarning. "%s" treats it purely as text. + // SECURITY NOTE: Both LOG_WARN and sendWarning are printf-style, so we pass + // "%s", buf rather than 'buf' directly. If 'buf' contained a % symbol (e.g. a + // user-supplied channel name like "50%"), passing it as the format string would + // read bogus varargs and could crash. "%s" treats it purely as text. + LOG_WARN("%s", buf); sendWarning("%s", buf); } +// Strip spaces and fold to lowercase for loose preset-name comparison. +static void normalizePresetName(const char *src, char *dst, size_t dstLen) +{ + size_t j = 0; + for (size_t i = 0; src[i] && j + 1 < dstLen; i++) { + if (src[i] != ' ') + dst[j++] = (char)tolower((unsigned char)src[i]); + } + dst[j] = '\0'; +} + +// Record one channel-configuration warning. The first message is kept verbatim in case it +// turns out to be the only one; nameIssue/pskIssue and the channel bitmask feed the catch-all +// wording if more than one channel ends up flagged. flushChannelWarnings() emits the result. +void AdminModule::queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...) +{ + if (pendingWarningCount == 0) { + va_list args; + va_start(args, format); + vsnprintf(pendingWarningText, sizeof(pendingWarningText), format, args); + va_end(args); + } + if (channelIndex < MAX_NUM_CHANNELS) + pendingWarningChannels |= (1u << channelIndex); + pendingWarningNameIssue |= nameIssue; + pendingWarningPskIssue |= pskIssue; + pendingWarningCount++; +} + +// Queue the fixed "licensed mode activated" notice, deferring it to commit during an edit +// transaction so repeated triggers collapse to a single message. +void AdminModule::warnLicensedMode() +{ + if (hasOpenEditTransaction) + pendingLicenseWarning = true; + else + sendWarning(licensedModeMessage); +} + +// Emit the coalesced channel warning(s): nothing if none queued, the lone message verbatim if +// exactly one, otherwise a single catch-all naming every flagged channel. The licensed-mode +// notice, if queued, is emitted once alongside. Always resets state. +void AdminModule::flushChannelWarnings() +{ + if (pendingLicenseWarning) + sendWarning(licensedModeMessage); + + if (pendingWarningCount == 1) { + sendWarningAndLog("%s", pendingWarningText); + } else if (pendingWarningCount > 1) { + char list[48] = {}; + for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) { + if (!(pendingWarningChannels & (1u << i))) + continue; + char num[8]; + snprintf(num, sizeof(num), "%s%u", *list ? ", " : "", i); + strncat(list, num, sizeof(list) - strlen(list) - 1); + } + if (pendingWarningNameIssue && pendingWarningPskIssue) + sendWarningAndLog("There may be name and PSK issues on channels %s", list); // max 60 bytes + else if (pendingWarningNameIssue) + sendWarningAndLog("There may be name issues on channels %s", list); // max 52 bytes + else + sendWarningAndLog("There may be PSK issues on channels %s", list); // max 51 bytes + } + pendingWarningText[0] = '\0'; + pendingWarningChannels = 0; + pendingWarningCount = 0; + pendingWarningNameIssue = false; + pendingWarningPskIssue = false; + pendingLicenseWarning = false; +} + +/** + * @brief Emit client warnings for common misconfigurations on a newly committed channel. + * + * Called from handleSetChannel() after the channel has been saved. The following checks + * are performed: + * + * - Blank PSK (size == 0) on a non-licensed device: the channel has no encryption. + * - Blank name with a non-default key (not the default key, 0x01): the name will auto-resolve to + * the current modem preset name, but the key mismatch means other preset nodes cannot + * decode traffic on this channel. + * - Named channel whose name is a case/space variant of the current modem preset: the + * explicit name prevents auto-resolution; client should clear it. + * - Same variant match but PSK is not the default key: looks like the preset channel but + * is incompatible with nodes using the preset's default key. + * + * @param cc The channel that was written. + */ +void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc) +{ + if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings) // don't check unused channels + return; + + bool blankPsk = (cc.settings.psk.size == 0 && !owner.is_licensed); + + if (!config.lora.use_preset) { // custom or unset preset can mistype things too + const char *mistypePreset = nullptr; + if (*cc.settings.name) { + char normChan[32]; + normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); + for (auto preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; + preset = (meshtastic_Config_LoRaConfig_ModemPreset)(preset + 1)) { + const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true); + if (strcmp(name, "Invalid") == 0) + continue; // skip preset slots without a real display name + if (strcmp(cc.settings.name, name) == 0) + break; // exact match - not a mistype, no warning + char normPreset[32]; + normalizePresetName(name, normPreset, sizeof(normPreset)); + if (strcmp(normChan, normPreset) == 0) { + mistypePreset = name; + break; + } + } + } + // At most one warning: collapse blank-PSK + mistype into a single catch-all. + if (blankPsk && mistypePreset) + queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index); + else if (mistypePreset) + queueChannelWarning(cc.index, true, false, + "Channel %d name '%s' looks like a mistype of '%s' - " + "make sure to type it exactly!", // max 90 bytes + cc.index, cc.settings.name, mistypePreset); + else if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes + cc.index, cc.settings.name); + return; + } + + const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true); + bool isDefaultKey = (cc.settings.psk.size == 1 && cc.settings.psk.bytes[0] == 0x01); + char normPreset[32], normChan[32]; // max size is 11 plus nul, but allow for future expansion + normalizePresetName(presetName, normPreset, sizeof(normPreset)); + normalizePresetName(cc.settings.name, normChan, sizeof(normChan)); + + if (!*cc.settings.name) { + // Blank name resolves to the preset name - A and B are mutually exclusive (psk.size can't be both 0 and >0) + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes + cc.index, cc.settings.name); + else if (!isDefaultKey && cc.settings.psk.size > 0) + queueChannelWarning(cc.index, false, true, + "Channel %d will resolve to preset '%s' but uses a non-default key - " + "default-key nodes can't decode it.", // max 102 bytes + cc.index, presetName); + return; + } + + if (strcmp(normChan, normPreset) != 0) { // name unrelated to preset + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes + cc.index, cc.settings.name); + return; + } + + bool variantName = (strcmp(cc.settings.name, presetName) != 0); + bool keyMismatch = (!isDefaultKey && cc.settings.psk.size > 0); + int issues = (int)blankPsk + (int)variantName + (int)keyMismatch; + + if (issues > 1) { + bool hasNameIssue = variantName; + bool hasPskIssue = blankPsk || keyMismatch; + if (hasNameIssue && hasPskIssue) + queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index); + else if (hasNameIssue) + queueChannelWarning(cc.index, true, false, "There may be name issues on channel %d", cc.index); + else + queueChannelWarning(cc.index, false, true, "There may be PSK issues on channel %d", cc.index); + return; + } + + if (blankPsk) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes + cc.index, cc.settings.name); + if (variantName) + queueChannelWarning(cc.index, true, false, + "Channel %d name '%s' looks like a mistype of '%s' - " + "clear the name to use the preset name automatically.", // max 113 bytes + cc.index, cc.settings.name, presetName); + if (keyMismatch) + queueChannelWarning(cc.index, false, true, + "Channel %d '%s' matches preset '%s' but uses a non-default key - " + "default-key nodes can't decode it.", // max 108 bytes + cc.index, cc.settings.name, presetName); +} // warnOnChannelSet + +/** + * @brief Scan all channels for preset-name conflicts after a modem preset change is committed. + * + * Called from handleSetConfig() after the LoRa config has been saved, and only when + * modem_preset actually changed (rejected configs are never passed here). For every + * named, non-disabled channel two checks are performed: + * + * - Name matches the *old* preset (case-insensitive, spaces stripped): the channel + * was likely tracking the previous preset; the user should rename it if it should + * follow the new one. + * - Name matches the *new* preset: the channel name collides with the auto-generated + * preset name but won't resolve automatically because the name is set explicitly. + * + * No-ops if the new config does not use a preset. + * + * @param oldLora LoRa config before the update. + * @param newLora LoRa config after the update. + */ +void AdminModule::warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora) +{ + if (!newLora.use_preset || newLora.modem_preset == oldLora.modem_preset) + return; + + char normOld[32] = {}, normNew[32]; + if (oldLora.use_preset) { + const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, true); + normalizePresetName(oldName, normOld, sizeof(normOld)); + } + const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, true); + normalizePresetName(newName, normNew, sizeof(normNew)); + + // Queue one (name) warning per affected channel; flushChannelWarnings() collapses them + // into a single message - either the lone warning verbatim or a catch-all listing indices. + for (int i = 0; i < channels.getNumChannels(); i++) { + const meshtastic_Channel &ch = channels.getByIndex(i); + if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name) + continue; + char normChan[32]; + normalizePresetName(ch.settings.name, normChan, sizeof(normChan)); + if (*normOld && strcmp(normChan, normOld) == 0) + queueChannelWarning(i, true, false, + "Channel %d name '%s' matches the old preset. " + "Rename it manually if it should track the new preset.", // max 98 bytes + i, ch.settings.name); + else if (strcmp(normChan, normNew) == 0) + queueChannelWarning(i, true, false, + "Channel %d '%s' looks like preset '%s' but won't auto-resolve - " + "clear the name to fix it.", // max 98 bytes + i, ch.settings.name, newName); + } +} // warnOnLoraPresetChange + void disableBluetooth() { #if HAS_BLUETOOTH diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 468e020ea..15923988b 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -89,6 +89,27 @@ class AdminModule : public ProtobufModule, public Obser bool messageIsRequest(const meshtastic_AdminMessage *r); void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3))); void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3))); + void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora); + void warnOnChannelSet(const meshtastic_Channel &cc); + + // Channel-configuration warnings are coalesced into a single client notification. + // queueChannelWarning() records one warning for a channel; while an edit transaction + // is open (begin/commit_edit_settings) the warnings accumulate and flushChannelWarnings() + // emits one combined message at commit. Outside a transaction the caller flushes + // immediately, so a single channel/preset edit still produces a single message. + void queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...) + __attribute__((format(printf, 5, 6))); + // Emit the "licensed mode activated" notice, deferring to commit during an edit transaction + // so repeated triggers (e.g. owner + several channels) produce a single message. + void warnLicensedMode(); + void flushChannelWarnings(); + + char pendingWarningText[250] = {}; // the lone queued message, used verbatim when only one fired + uint32_t pendingWarningChannels = 0; // bitmask of channel indices with a queued warning + uint8_t pendingWarningCount = 0; // number of queued warnings this transaction + bool pendingWarningNameIssue = false; // any queued warning was about a channel name + bool pendingWarningPskIssue = false; // any queued warning was about a PSK + bool pendingLicenseWarning = false; // a licensed-mode notice is queued for this transaction }; static constexpr const char *licensedModeMessage = diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 06748b7fa..3ce99fcb6 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -17,16 +17,34 @@ #include "NodeDB.h" #include "RadioInterface.h" #include "TestUtil.h" +#include "mesh/Channels.h" #include "modules/AdminModule.h" +#include #include +#include #include "meshtastic/config.pb.h" #include "support/AdminModuleTestShim.h" -#include "support/MockMeshService.h" // hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests extern uint32_t hash(const char *str); +// Every client notification the AdminModule emits flows through sendClientNotification(); +// capture each formatted message so the warning/coalescing tests can assert on the exact +// set of messages produced by a sequence of admin messages. This shadows test/support/MockMeshService.h's +// release-only stub because these tests need to inspect the captured message text, not just avoid leaks. +static std::vector capturedWarnings; + +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override + { + capturedWarnings.push_back(n->message); + releaseClientNotificationToPool(n); + } +}; + static MockMeshService *mockMeshService; // ----------------------------------------------------------------------- @@ -1244,6 +1262,167 @@ static void test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejecte TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); } +// ----------------------------------------------------------------------- +// Channel-configuration warning + coalescing tests +// +// These exercise the real incoming-admin-message path (handleReceivedProtobuf): +// begin_edit_settings / set_channel / commit_edit_settings. Warnings raised while a +// transaction is open must be deferred and collapsed into a single notification at +// commit; outside a transaction each save emits its own single message immediately. +// ----------------------------------------------------------------------- + +static const uint8_t DEFAULT_KEY[] = {0x01}; // the well-known "default" PSK (AQ==) +static const uint8_t CUSTOM_KEY[] = {0x42, 0x17}; // any non-default key + +// Count captured warnings whose text contains substr. +static int warningsContaining(const char *substr) +{ + int n = 0; + for (const auto &w : capturedWarnings) + if (w.find(substr) != std::string::npos) + n++; + return n; +} + +static meshtastic_Channel makeChannel(int8_t index, meshtastic_Channel_Role role, const char *name, const uint8_t *psk, + size_t pskLen) +{ + meshtastic_Channel ch = meshtastic_Channel_init_zero; + ch.index = index; + ch.role = role; + ch.has_settings = true; + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + ch.settings.psk.size = pskLen; + for (size_t i = 0; i < pskLen; i++) + ch.settings.psk.bytes[i] = psk[i]; + return ch; +} + +// Dispatch one admin message as if it arrived from a local (from==0) client, which bypasses +// the passkey/authorization gates so the switch body runs. +static void sendAdmin(meshtastic_AdminMessage &m) +{ + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; // required: handler drops non-decoded packets + testAdmin->handleReceivedProtobuf(mp, &m); +} + +static void sendSetChannel(const meshtastic_Channel &ch) +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_set_channel_tag; + m.set_channel = ch; + sendAdmin(m); +} + +static void sendBeginEdit() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_begin_edit_settings_tag; + m.begin_edit_settings = true; + sendAdmin(m); +} + +static void sendCommitEdit() +{ + meshtastic_AdminMessage m = meshtastic_AdminMessage_init_zero; + m.which_payload_variant = meshtastic_AdminMessage_commit_edit_settings_tag; + m.commit_edit_settings = true; + sendAdmin(m); +} + +// Preset = LongFast on US, unlicensed owner. "LongFast" is the display name we compare against. +static void usePresetLongFast() +{ + 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; + initRegion(); + owner.is_licensed = false; +} + +static void test_warn_singleChannel_variantName_oneSpecificMessage() +{ + usePresetLongFast(); + // Name is a case/space variant of the preset with the default key: a single name issue. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); +} + +static void test_warn_singleChannel_nameAndPsk_collapsedToCatchAll() +{ + usePresetLongFast(); + // Variant name AND a non-default key: two issues on one channel collapse to one catch-all. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name and PSK issues on channel 0")); +} + +static void test_warn_cleanChannel_noMessage() +{ + usePresetLongFast(); + // Exact preset name + default key: nothing to warn about. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "LongFast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); +} + +static void test_warn_transaction_multipleChannels_singleCoalescedMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "long fast", DEFAULT_KEY, 1)); + // Nothing emitted yet - warnings are deferred until commit. + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // Exactly one message, naming both channels. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("There may be name issues on channels 0, 1")); +} + +static void test_warn_transaction_singleChannel_keepsSpecificMessage() +{ + usePresetLongFast(); + sendBeginEdit(); + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "long fast", DEFAULT_KEY, 1)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // One flagged channel: the specific message verbatim, not the plural catch-all. + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("looks like a mistype of 'LongFast'")); + TEST_ASSERT_EQUAL_INT(0, warningsContaining("on channels")); +} + +static void test_warn_license_noTransaction_emittedImmediately() +{ + usePresetLongFast(); + owner.is_licensed = true; + // Setting a channel that still carries a key triggers ensureLicensedOperation() to strip it. + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); +} + +static void test_warn_license_transaction_coalescedToSingleMessage() +{ + usePresetLongFast(); + owner.is_licensed = true; + sendBeginEdit(); + // Two separate triggers within one transaction (two channels with keys to strip). + sendSetChannel(makeChannel(0, meshtastic_Channel_Role_PRIMARY, "", CUSTOM_KEY, 2)); + sendSetChannel(makeChannel(1, meshtastic_Channel_Role_SECONDARY, "", CUSTOM_KEY, 2)); + TEST_ASSERT_EQUAL_INT(0, (int)capturedWarnings.size()); + + sendCommitEdit(); + // Collapsed to a single licensed-mode notice (and no channel warning, since names are blank). + TEST_ASSERT_EQUAL_INT(1, warningsContaining("Licensed mode activated")); + TEST_ASSERT_EQUAL_INT(1, (int)capturedWarnings.size()); +} + // ----------------------------------------------------------------------- // Test runner // ----------------------------------------------------------------------- @@ -1253,6 +1432,12 @@ void setUp(void) mockMeshService = new MockMeshService(); service = mockMeshService; testAdmin = new AdminModuleTestShim(); + capturedWarnings.clear(); + // Committing an edit transaction triggers a full saveToDisk(), which dereferences nodeDB. + // Create it once (kept reachable via the global, so no leak) for the warning tests; the + // other tests in this suite set their own config/region state and are unaffected. + if (!nodeDB) + nodeDB = new NodeDB(); } void tearDown(void) { @@ -1360,6 +1545,15 @@ void setup() RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); + // Channel-configuration warning + coalescing + RUN_TEST(test_warn_singleChannel_variantName_oneSpecificMessage); + RUN_TEST(test_warn_singleChannel_nameAndPsk_collapsedToCatchAll); + RUN_TEST(test_warn_cleanChannel_noMessage); + RUN_TEST(test_warn_transaction_multipleChannels_singleCoalescedMessage); + RUN_TEST(test_warn_transaction_singleChannel_keepsSpecificMessage); + RUN_TEST(test_warn_license_noTransaction_emittedImmediately); + RUN_TEST(test_warn_license_transaction_coalescedToSingleMessage); + exit(UNITY_END()); } From 77292c8415fc3f839b07b8122761ac590a2c0af0 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sun, 12 Jul 2026 16:00:24 -0500 Subject: [PATCH 12/69] Fid double free in virtual keyboard (#10999) Found via portduino. As there are two separate references to this pointer, they both could call free --- src/graphics/draw/NotificationRenderer.cpp | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 8031e31e9..03e53c3bd 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -1038,10 +1038,9 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat // Cancel virtual keyboard - call callback with empty string to indicate timeout auto callback = textInputCallback; // Store callback before clearing - // Clean up first to prevent re-entry - delete virtualKeyboard; - virtualKeyboard = nullptr; - textInputCallback = nullptr; + // Clean up first to prevent re-entry. The keyboard belongs to OnScreenKeyboardModule; only stop() + // may free it, and it clears virtualKeyboard/textInputCallback for us. + OnScreenKeyboardModule::instance().stop(false); resetBanner(); // Call callback after cleanup @@ -1060,9 +1059,7 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat bool handled = OnScreenKeyboardModule::processVirtualKeyboardInput(inEvent, virtualKeyboard); if (!handled && inEvent.inputEvent == INPUT_BROKER_CANCEL) { auto callback = textInputCallback; - delete virtualKeyboard; - virtualKeyboard = nullptr; - textInputCallback = nullptr; + OnScreenKeyboardModule::instance().stop(false); // sole owner of the keyboard; also clears our aliases resetBanner(); if (callback) { callback(""); From 5e99139a43bfc4935d0a1139d28764c98982b19a Mon Sep 17 00:00:00 2001 From: Iris Date: Mon, 13 Jul 2026 13:50:56 +0300 Subject: [PATCH 13/69] Guard LR1121/LR2021 radio configs with #ifdef (#10998) Add conditional compilation guards for LR1121 and LR2021 radio chip configurations in rfswitch.h to prevent compilation errors when these radios are not in use. Move USE_LR2021 definition to the radio section in variant.h for consistency with other radio chip definitions. --- variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h | 6 ++++-- variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h index 714c58c24..9c9bbebd4 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h @@ -13,7 +13,7 @@ // DIO5 -> RFSW0_V1 // DIO6 -> RFSW1_V2 // DIO7 -> not connected on E80 module - note that GNSS and Wifi scanning are not possible. - +#ifdef USE_LR1121 static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11X0_DIO6, RADIOLIB_LR11X0_DIO7, RADIOLIB_NC, RADIOLIB_NC}; @@ -30,13 +30,14 @@ static const Module::RfSwitchMode_t rfswitch_table[] = { END_OF_MODE_TABLE, // clang-format on }; +#endif // LR2021 RF switch matrix following the standard Semtech / Seeed T1000-E reference topology. // DIO5 -> antenna path select (HIGH = sub-GHz LF) // DIO6 -> TX enable / HP PA select // DIO7 -> not connected (no GNSS on LR2021) // DIO8 -> RF front-end power enable - +#ifdef USE_LR2021 static const uint32_t lr20x0_rfswitch_dio_pins[] = {RADIOLIB_LR2021_DIO5, RADIOLIB_LR2021_DIO6, RADIOLIB_LR2021_DIO7, RADIOLIB_LR2021_DIO8, RADIOLIB_NC}; @@ -51,3 +52,4 @@ static const Module::RfSwitchMode_t lr20x0_rfswitch_table[] = { END_OF_MODE_TABLE, // clang-format on }; +#endif diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 82178d321..5b580f684 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -121,9 +121,9 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define USE_RF95 #define USE_SX1268 #define USE_LR1121 +#define USE_LR2021 // RF95 CONFIG - #define LORA_DIO0 (0 + 29) // P0.29 BUSY #define LORA_DIO1 (0 + 10) // P0.10 IRQ #define LORA_RESET (0 + 9) // P0.09 NRST @@ -156,7 +156,7 @@ NRF52 PRO MICRO PIN ASSIGNMENT #endif // LR2021 -#define USE_LR2021 +#ifdef USE_LR2021 #define LR2021_IRQ_PIN (0 + 10) // P0.10 IRQ #define LR2021_NRESET_PIN LORA_RESET // P0.09 NRST #define LR2021_BUSY_PIN (0 + 29) // P0.29 BUSY @@ -164,6 +164,7 @@ NRF52 PRO MICRO PIN ASSIGNMENT #define LR2021_DIO3_TCXO_VOLTAGE 1.8 #define LR2021_DIO_AS_RF_SWITCH #define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 +#endif // #define SX126X_MAX_POWER 8 set this if using a high-power board! From fe288a70e4ce8697ef9934562ea8cb8eb0c443ff Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Mon, 13 Jul 2026 20:08:20 +0800 Subject: [PATCH 14/69] stm32wl: exclude PKT_HISTORY_HASH, RANGETEST, WAYPOINT, POWER_TELEMETRY (#10992) Adds four MESHTASTIC_EXCLUDE_* flags to stm32_base, applying them to every stm32wl variant (rak3172, wio-e5, russell, milesight_gs301, CDEBYTE_E77-MBL): - PKT_HISTORY_HASH: mirrors the accepted nRF52 precedent (variants/nrf52840/nrf52.ini) - drops PacketHistory's hash index; the O(n) fallback is negligible given stm32wl's small node table. No feature loss. - POWER_TELEMETRY: only affects external power-monitor ICs (INA219/INA260 etc.) via PowerTelemetryModule; internal battery ADC reading (src/Power.cpp) is a separate, ungated code path and is unaffected. No current stm32wl variant wires up external power-monitor hardware. - RANGETEST: real feature loss on wio-e5 specifically, the only stm32wl variant with GPS enabled (RangeTest requires GPS to run at all, so it was already dead on every other variant). - WAYPOINT: real feature loss on rak3172/wio-e5 (russell already excluded this individually; that duplicate is removed here since stm32_base now covers it). Measured on wio-e5 against upstream/develop @ 7a25ffd0a: 2,768 bytes flash saved (240,648 -> 237,880), 260 bytes RAM saved. Sanity-built russell and rak3172 to confirm no regressions from the russell dedup. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- variants/stm32/russell/platformio.ini | 2 -- variants/stm32/stm32.ini | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 57f73f6d0..1900a9ae1 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -14,13 +14,11 @@ 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 diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 5726fd369..4300e3b7a 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -26,6 +26,10 @@ build_flags = -DMESHTASTIC_EXCLUDE_WIFI=1 -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; The Ed25519 signing code does not fit in the 256KB flash. Packets are sent unsigned, like pre-XEdDSA firmware. + -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 + -DMESHTASTIC_EXCLUDE_RANGETEST=1 + -DMESHTASTIC_EXCLUDE_WAYPOINT=1 + -DMESHTASTIC_EXCLUDE_POWER_TELEMETRY=1 -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 ; 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. From 269b974e9606a8e1c5db8dabb95f0a3ec6f168d7 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:28:25 -0500 Subject: [PATCH 15/69] feat: populate MyNodeInfo.device_id on all platforms (#10995) * feat: populate MyNodeInfo.device_id on all platforms RP2040/RP2350 use the 64-bit pico unique board id, STM32WL the 96-bit silicon UID, ESP32-S2 joins the existing OPTIONAL_UNIQUE_ID efuse branch, and everything else (classic ESP32 in particular) falls back to a deterministic factory-MAC-derived id, resolving the long-standing FIXME. Portduino keeps the config-supplied id preferred and now uses the MAC fallback when the config omits one. No proto or persistence changes; the id is re-read from silicon each boot and PhoneAPI still zeroes it for unauthenticated clients. Co-Authored-By: Claude Fable 5 * fix: declare zero_mac const to satisfy cppcheck Co-Authored-By: Claude Fable 5 * fix: address review feedback on device_id derivation Clear any disk-loaded device_id before the silicon derivation so a failed read leaves it unset rather than stale (Copilot), and size the portduino config copy with sizeof instead of a literal 16 (CodeRabbit). Co-Authored-By: Claude Fable 5 * refactor: extract device_id generation into per-arch getDeviceId() Per review feedback on #10995: move the platform-specific MyNodeInfo.device_id derivation out of the #if/#elif ladder in NodeDB.cpp into a getDeviceId() interface (target_specific.h) implemented per-architecture alongside each platform's getMacAddr(): - esp32: efuse OPTIONAL_UNIQUE_ID (C3/S2/S3/C6); classic ESP32 -> MAC - nrf52: FICR DEVICEID + DEVICEADDR - nrf54l15: FICR->INFO.DEVICEID + DEVICEADDR (NRF_FICR-guarded, MAC fallback) - rp2xx0: pico_get_unique_board_id() - stm32wl: HAL_GetUIDw0/1/2() - portduino: config-supplied id preferred, else MAC The shared MAC-derived fallback moves to meshUtils as getMacAddrDeviceId(). NodeDB.cpp now zero-inits the field and makes a single getDeviceId() call, dropping ~65 lines of platform boilerplate plus the esp_efuse/pico/stm32 includes that came with it. No behavior change: device_id is still re-read from silicon each boot and never persisted. Builds green: native-macos, tbeam, heltec-v3, rak4631, rak11310, rak3172. Co-Authored-By: Claude Opus 4.8 * fix: address review of device_id refactor - getMacAddrDeviceId(): zero-init the mac[6] buffer. getMacAddr() can return without writing (e.g. Portduino with no MAC source), so the old uninitialized buffer let stack garbage pass the all-zeros guard and become device_id. The pre-refactor code relied on the zero-initialized static ourMacAddr; restore that guarantee. - nrf54l15 getDeviceId(): drop the `#if defined(NRF_FICR)` guard and read FICR unconditionally (as the pre-refactor NodeDB code did). The guarded #else fell back to getMacAddr()'s hard-coded placeholder MAC, which would give every unit an identical device_id; a missing NRF_FICR should be a loud compile error. - NodeDB.cpp: `#include "target_specific.h"` instead of hand-copied externs for getMacAddr/getDeviceId; retire the stale FIXME. Same for meshUtils.cpp (whose extern comment wrongly claimed the TU was Arduino-free). - Delete the orphaned commented-out device_id hex-dump block in NodeDB.cpp. - Trim/de-duplicate the getDeviceId contract comments (single-sourced in target_specific.h). Builds green: native-macos, tbeam. Co-Authored-By: Claude Opus 4.8 * docs: trim device_id comments to the repo's 1-2 line guideline Addresses CodeRabbit review nitpick on #10995: shorten the getDeviceId() (target_specific.h), getMacAddrDeviceId() (meshUtils.h), device-id refresh (NodeDB.cpp), and nrf54l15 getDeviceId comments to two lines each, per the "one or two lines maximum" coding guideline. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Fable 5 --- src/mesh/NodeDB.cpp | 60 ++++-------------------- src/meshUtils.cpp | 15 ++++++ src/meshUtils.h | 4 ++ src/platform/esp32/main-esp32.cpp | 22 +++++++++ src/platform/nrf52/main-nrf52.cpp | 11 +++++ src/platform/nrf54l15/main-nrf54l15.cpp | 12 +++++ src/platform/portduino/PortduinoGlue.cpp | 10 ++++ src/platform/rp2xx0/main-rp2xx0.cpp | 10 ++++ src/platform/stm32wl/main-stm32wl.cpp | 9 ++++ src/target_specific.h | 6 ++- 10 files changed, 106 insertions(+), 53 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b2804910c..e53ebb699 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -27,6 +27,7 @@ #include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" #include "meshUtils.h" #include "modules/NeighborInfoModule.h" +#include "target_specific.h" #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" #endif @@ -50,11 +51,7 @@ #include "SPILock.h" #include "modules/StoreForwardModule.h" #include -#include -#include #include -#include -#include #endif #ifdef ARCH_PORTDUINO @@ -372,8 +369,7 @@ void NodeDB::disarmNodeDatabaseDecodeTargets() */ uint32_t radioGeneration; -// FIXME - move this somewhere else -extern void getMacAddr(uint8_t *dmac); +// getMacAddr() and getDeviceId() are the per-architecture hooks declared in target_specific.h. /** * @@ -410,53 +406,13 @@ NodeDB::NodeDB() uint32_t channelFileCRC = crc32Buffer(&channelFile, sizeof(channelFile)); int saveWhat = 0; - // Get device unique id -#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) - uint32_t unique_id[4]; - // ESP32 factory burns a unique id in efuse for S2+ series and evidently C3+ series - // This is used for HMACs in the esp-rainmaker AIOT platform and seems to be a good choice for us - esp_err_t err = esp_efuse_read_field_blob(ESP_EFUSE_OPTIONAL_UNIQUE_ID, unique_id, sizeof(unique_id) * 8); - if (err == ESP_OK) { - memcpy(myNodeInfo.device_id.bytes, unique_id, sizeof(unique_id)); - myNodeInfo.device_id.size = 16; - } else { - LOG_WARN("Failed to read unique id from efuse"); + // Re-read the device id from silicon each boot via the per-arch getDeviceId(); clear the + // disk-loaded value first so a failed/empty derivation leaves it unset rather than stale. + myNodeInfo.device_id.size = 0; + memset(myNodeInfo.device_id.bytes, 0, sizeof(myNodeInfo.device_id.bytes)); + if (getDeviceId(myNodeInfo.device_id.bytes)) { + myNodeInfo.device_id.size = sizeof(myNodeInfo.device_id.bytes); } -#elif defined(ARCH_NRF54L15) - // nRF54L15: DEVICEID is under FICR->INFO sub-struct (not top-level as on nRF52) - uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; - uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; - memcpy(myNodeInfo.device_id.bytes, &device_id_start, sizeof(device_id_start)); - memcpy(myNodeInfo.device_id.bytes + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); - myNodeInfo.device_id.size = 16; -#elif defined(ARCH_NRF52) - // Nordic applies a FIPS compliant Random ID to each chip at the factory - // We concatenate the device address to the Random ID to create a unique ID for now - // This will likely utilize a crypto module in the future - uint64_t device_id_start = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | NRF_FICR->DEVICEID[0]; - uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; - memcpy(myNodeInfo.device_id.bytes, &device_id_start, sizeof(device_id_start)); - memcpy(myNodeInfo.device_id.bytes + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); - myNodeInfo.device_id.size = 16; - // Uncomment below to print the device id -#elif ARCH_PORTDUINO - if (portduino_config.has_device_id) { - memcpy(myNodeInfo.device_id.bytes, portduino_config.device_id, 16); - myNodeInfo.device_id.size = 16; - } -#else - // FIXME - implement for other platforms -#endif - - // if (myNodeInfo.device_id.size == 16) { - // std::string deviceIdHex; - // for (size_t i = 0; i < myNodeInfo.device_id.size; ++i) { - // char buf[3]; - // snprintf(buf, sizeof(buf), "%02X", myNodeInfo.device_id.bytes[i]); - // deviceIdHex += buf; - // } - // LOG_DEBUG("Device ID (HEX): %s", deviceIdHex.c_str()); - // } // likewise - we always want the app requirements to come from the running appload myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00 diff --git a/src/meshUtils.cpp b/src/meshUtils.cpp index 1fd21a244..834cc7276 100644 --- a/src/meshUtils.cpp +++ b/src/meshUtils.cpp @@ -1,4 +1,5 @@ #include "meshUtils.h" +#include "target_specific.h" #include /* @@ -79,6 +80,20 @@ bool memfll(const uint8_t *mem, uint8_t find, size_t numbytes) return true; } +bool getMacAddrDeviceId(uint8_t *deviceId) +{ + // Zero-initialized: getMacAddr() may return without writing (e.g. Portduino with no MAC + // source), and we want that no-write case to hit the all-zeros guard below, not read garbage. + uint8_t mac[6] = {0}; + getMacAddr(mac); + if (memfll(mac, 0, sizeof(mac))) { + LOG_WARN("MAC is all zeros, leaving device_id unset"); + return false; + } + memcpy(deviceId, mac, sizeof(mac)); + return true; +} + bool isOneOf(int item, int count, ...) { va_list args; diff --git a/src/meshUtils.h b/src/meshUtils.h index 0ce01c522..9a80ac51f 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -49,6 +49,10 @@ void printBytes(const char *label, const uint8_t *p, size_t numbytes); // is the memory region filled with a single character? bool memfll(const uint8_t *mem, uint8_t find, size_t numbytes); +// getDeviceId() fallback (see target_specific.h): copies the 6-byte factory MAC, or returns false +// on an all-zero MAC so two blank devices don't collide on an all-zero id. +bool getMacAddrDeviceId(uint8_t *deviceId); + bool isOneOf(int item, int count, ...); const std::string vformat(const char *const zcFormat, ...); diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 25714e543..afbf4482d 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -27,6 +27,8 @@ #include "target_specific.h" #include #include +#include +#include #include #include @@ -158,6 +160,26 @@ void getMacAddr(uint8_t *dmac) #endif } +bool getDeviceId(uint8_t *deviceId) +{ +#if defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) || \ + defined(CONFIG_IDF_TARGET_ESP32C6) + // ESP32 factory burns a 128-bit unique id in efuse for S2+ and C3+ series (used for HMACs + // in the esp-rainmaker AIOT platform); a good stable hardware anchor for us too. + uint32_t unique_id[4]; + esp_err_t err = esp_efuse_read_field_blob(ESP_EFUSE_OPTIONAL_UNIQUE_ID, unique_id, sizeof(unique_id) * 8); + if (err != ESP_OK) { + LOG_WARN("Failed to read unique id from efuse"); + return false; + } + memcpy(deviceId, unique_id, sizeof(unique_id)); + return true; +#else + // Classic ESP32 has no OPTIONAL_UNIQUE_ID efuse: fall back to the factory-burned MAC. + return getMacAddrDeviceId(deviceId); +#endif +} + #if HAS_32768HZ #define CALIBRATE_ONE(cali_clk) calibrate_one(cali_clk, #cali_clk) diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index f120d387c..87246ba21 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -186,6 +186,17 @@ void getMacAddr(uint8_t *dmac) dmac[0] = src[5] | 0xc0; // MSB high two bits get set elsewhere in the bluetooth stack } +bool getDeviceId(uint8_t *deviceId) +{ + // Nordic burns a FIPS-compliant random id into each chip at the factory. We concatenate + // the device address to that random id to form the 16-byte hardware identifier. + uint64_t device_id_start = ((uint64_t)NRF_FICR->DEVICEID[1] << 32) | NRF_FICR->DEVICEID[0]; + uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; + memcpy(deviceId, &device_id_start, sizeof(device_id_start)); + memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); + return true; +} + #if !MESHTASTIC_EXCLUDE_BLUETOOTH void setBluetoothEnable(bool enable) { diff --git a/src/platform/nrf54l15/main-nrf54l15.cpp b/src/platform/nrf54l15/main-nrf54l15.cpp index e3e597fc7..b91fe67ee 100644 --- a/src/platform/nrf54l15/main-nrf54l15.cpp +++ b/src/platform/nrf54l15/main-nrf54l15.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "NodeDB.h" @@ -97,6 +98,17 @@ void getMacAddr(uint8_t *dmac) #endif } +bool getDeviceId(uint8_t *deviceId) +{ + // nRF54L15: DEVICEID under the FICR->INFO sub-struct. Read unconditionally so a future build + // lacking NRF_FICR fails loudly rather than silently sharing getMacAddr()'s placeholder MAC. + uint64_t device_id_start = ((uint64_t)NRF_FICR->INFO.DEVICEID[1] << 32) | NRF_FICR->INFO.DEVICEID[0]; + uint64_t device_id_end = ((uint64_t)NRF_FICR->DEVICEADDR[1] << 32) | NRF_FICR->DEVICEADDR[0]; + memcpy(deviceId, &device_id_start, sizeof(device_id_start)); + memcpy(deviceId + sizeof(device_id_start), &device_id_end, sizeof(device_id_end)); + return true; +} + // ── Bluetooth ───────────────────────────────────────────────────────────────── void setBluetoothEnable(bool enable) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 7bb8096ab..3903da2e8 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -201,6 +201,16 @@ void getMacAddr(uint8_t *dmac) } } +bool getDeviceId(uint8_t *deviceId) +{ + if (portduino_config.has_device_id) { + memcpy(deviceId, portduino_config.device_id, sizeof(portduino_config.device_id)); + return true; + } + // Config-supplied id stays preferred: host NIC/BT MACs can be unstable (docker, multi-NIC). + return getMacAddrDeviceId(deviceId); +} + std::string cleanupNameForAutoconf(std::string name) { // Convert spaces -> dashes, lowercase diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index ee50f4fb1..cb95f8e84 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -1,6 +1,7 @@ #include "HardwareRNG.h" #include "configuration.h" #include "hardware/xosc.h" +#include #include #include #include @@ -98,6 +99,15 @@ void getMacAddr(uint8_t *dmac) dmac[0] = src.id[2]; } +bool getDeviceId(uint8_t *deviceId) +{ + // RP2040/RP2350: 64-bit unique board id (flash serial / OTP) in bytes 0-7 (rest stay zero). + pico_unique_board_id_t board_id; + pico_get_unique_board_id(&board_id); + memcpy(deviceId, board_id.id, sizeof(board_id.id)); + return true; +} + void rp2040Setup() { if (watchdog_caused_reboot()) { diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 86ef47fea..8ec87092b 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 #include @@ -77,6 +78,14 @@ void getMacAddr(uint8_t *dmac) dmac[0] = (uint8_t)(uid2 >> 8); } +bool getDeviceId(uint8_t *deviceId) +{ + // STM32WL: 96-bit factory silicon UID (words w0..w2, little-endian) in bytes 0-11 (rest stay zero). + uint32_t uid[3] = {HAL_GetUIDw0(), HAL_GetUIDw1(), HAL_GetUIDw2()}; + memcpy(deviceId, uid, sizeof(uid)); + return true; +} + void cpuDeepSleep(uint32_t msecToWake) {} // Hacks to force more code and data out. diff --git a/src/target_specific.h b/src/target_specific.h index 7404a3720..d7aba018b 100644 --- a/src/target_specific.h +++ b/src/target_specific.h @@ -7,4 +7,8 @@ // Enable/disable bluetooth. void setBluetoothEnable(bool enable); -void getMacAddr(uint8_t *dmac); \ No newline at end of file +void getMacAddr(uint8_t *dmac); + +// Fill deviceId (a caller-zeroed 16-byte buffer) with a stable silicon/factory id; return true, or +// false (buffer untouched) if none exists here. Writes only leading bytes. Per-arch: src/platform//. +bool getDeviceId(uint8_t *deviceId); From dd509a8ecf24c964aad1d7d72ef1f01217bdb993 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Tue, 14 Jul 2026 09:04:24 +0800 Subject: [PATCH 16/69] Compute GeoCoord's UTM/MGRS/OSGR/OLC lazily instead of eagerly (#10996) GeoCoord's constructor unconditionally computed all five coordinate representations (DMS, UTM, MGRS, OSGR, OLC) via setCoords(), even though most callers only ever read one. The UTM conversion alone pulls in a chain of libm trig functions (atan, __kernel_tan, __ieee754_acos, __ieee754_pow, __kernel_rem_pio2/__ieee754_rem_pio2) that then have to be linked in regardless of whether anything is ever displayed. The only screen consumer (UIRenderer.cpp's GPS coordinate display) already dispatches on a single configured format and reads exactly one representation per call - never more than one. Compute DMS eagerly (cheap, most commonly needed) and defer UTM/MGRS/OSGR/OLC to first access via their own getters, tracked with per-representation mutable dirty flags, so a caller that never touches a given representation never pulls in its conversion code or the trig functions it needs. On stm32wl, GeoCoord's heavy constructor is reached only through NMEAWPL.cpp's NMEA/CalTopo serial export (GPS-gated, so wio-e5 only), which only ever reads the DMS getters - so this recovers 8,288 bytes flash there (of the 10,172-byte ceiling if the whole feature were cut) with the feature fully intact and zero behavior change on any platform. Updated test_geocoord_extreme_coords_no_oob (the existing regression test for a historical out-of-bounds crash in the UTM/MGRS conversion on extreme lat/lon) to explicitly call each representation's getter, since it previously relied on the constructor eagerly triggering all four conversions - which this change intentionally defers. Verified: full native test suite passes (586/586 test cases, including this one under ASan), and rak4631 (nRF52, screen-equipped, where the UTM/MGRS/OSGR/OLC getters are actually reachable) builds successfully. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- src/gps/GeoCoord.cpp | 43 +++++++- src/gps/GeoCoord.h | 116 +++++++++++++++++---- test/test_position_precision/test_main.cpp | 11 +- 3 files changed, 140 insertions(+), 30 deletions(-) diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index f225ab61c..8324bc3c0 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -36,19 +36,52 @@ GeoCoord::GeoCoord(double lat, double lon, int32_t alt) : _altitude(alt) GeoCoord::setCoords(); } -// Initialize all the coordinate systems +// Initialize DMS eagerly (cheap, most commonly used); UTM/MGRS/OSGR/OLC are computed lazily on +// first access via their getters (see ensure*() below), since most callers never touch them. void GeoCoord::setCoords() { double lat = _latitude * 1e-7; double lon = _longitude * 1e-7; GeoCoord::latLongToDMS(lat, lon, _dms); - GeoCoord::latLongToUTM(lat, lon, _utm); - GeoCoord::latLongToMGRS(lat, lon, _mgrs); - GeoCoord::latLongToOSGR(lat, lon, _osgr); - GeoCoord::latLongToOLC(lat, lon, _olc); + _utmValid = false; + _mgrsValid = false; + _osgrValid = false; + _olcValid = false; _dirty = false; } +void GeoCoord::ensureUTM() const +{ + if (!_utmValid) { + GeoCoord::latLongToUTM(_latitude * 1e-7, _longitude * 1e-7, _utm); + _utmValid = true; + } +} + +void GeoCoord::ensureMGRS() const +{ + if (!_mgrsValid) { + GeoCoord::latLongToMGRS(_latitude * 1e-7, _longitude * 1e-7, _mgrs); + _mgrsValid = true; + } +} + +void GeoCoord::ensureOSGR() const +{ + if (!_osgrValid) { + GeoCoord::latLongToOSGR(_latitude * 1e-7, _longitude * 1e-7, _osgr); + _osgrValid = true; + } +} + +void GeoCoord::ensureOLC() const +{ + if (!_olcValid) { + GeoCoord::latLongToOLC(_latitude * 1e-7, _longitude * 1e-7, _olc); + _olcValid = true; + } +} + void GeoCoord::updateCoords(int32_t lat, int32_t lon, int32_t alt) { // If marked dirty or new coordinates diff --git a/src/gps/GeoCoord.h b/src/gps/GeoCoord.h index 658c177b3..3bb6606f2 100644 --- a/src/gps/GeoCoord.h +++ b/src/gps/GeoCoord.h @@ -65,14 +65,24 @@ class GeoCoord int32_t _altitude = 0; DMS _dms = {}; - UTM _utm = {}; - MGRS _mgrs = {}; - OSGR _osgr = {}; - OLC _olc = {}; + // Computed lazily on first access via ensure*() below; mutable so const getters can populate + // them on demand. + mutable UTM _utm = {}; + mutable MGRS _mgrs = {}; + mutable OSGR _osgr = {}; + mutable OLC _olc = {}; + mutable bool _utmValid = false; + mutable bool _mgrsValid = false; + mutable bool _osgrValid = false; + mutable bool _olcValid = false; bool _dirty = true; void setCoords(); + void ensureUTM() const; + void ensureMGRS() const; + void ensureOSGR() const; + void ensureOLC() const; public: GeoCoord(); @@ -123,26 +133,86 @@ class GeoCoord uint32_t getDMSLonSec() const { return _dms.lonSec; } char getDMSLonCP() const { return _dms.lonCP; } - // UTM getters - uint8_t getUTMZone() const { return _utm.zone; } - char getUTMBand() const { return _utm.band; } - uint32_t getUTMEasting() const { return _utm.easting; } - uint32_t getUTMNorthing() const { return _utm.northing; } + // UTM getters (computed on first access - see ensureUTM()) + uint8_t getUTMZone() const + { + ensureUTM(); + return _utm.zone; + } + char getUTMBand() const + { + ensureUTM(); + return _utm.band; + } + uint32_t getUTMEasting() const + { + ensureUTM(); + return _utm.easting; + } + uint32_t getUTMNorthing() const + { + ensureUTM(); + return _utm.northing; + } - // MGRS getters - uint8_t getMGRSZone() const { return _mgrs.zone; } - char getMGRSBand() const { return _mgrs.band; } - char getMGRSEast100k() const { return _mgrs.east100k; } - char getMGRSNorth100k() const { return _mgrs.north100k; } - uint32_t getMGRSEasting() const { return _mgrs.easting; } - uint32_t getMGRSNorthing() const { return _mgrs.northing; } + // MGRS getters (computed on first access - see ensureMGRS()) + uint8_t getMGRSZone() const + { + ensureMGRS(); + return _mgrs.zone; + } + char getMGRSBand() const + { + ensureMGRS(); + return _mgrs.band; + } + char getMGRSEast100k() const + { + ensureMGRS(); + return _mgrs.east100k; + } + char getMGRSNorth100k() const + { + ensureMGRS(); + return _mgrs.north100k; + } + uint32_t getMGRSEasting() const + { + ensureMGRS(); + return _mgrs.easting; + } + uint32_t getMGRSNorthing() const + { + ensureMGRS(); + return _mgrs.northing; + } - // OSGR getters - char getOSGRE100k() const { return _osgr.e100k; } - char getOSGRN100k() const { return _osgr.n100k; } - uint32_t getOSGREasting() const { return _osgr.easting; } - uint32_t getOSGRNorthing() const { return _osgr.northing; } + // OSGR getters (computed on first access - see ensureOSGR()) + char getOSGRE100k() const + { + ensureOSGR(); + return _osgr.e100k; + } + char getOSGRN100k() const + { + ensureOSGR(); + return _osgr.n100k; + } + uint32_t getOSGREasting() const + { + ensureOSGR(); + return _osgr.easting; + } + uint32_t getOSGRNorthing() const + { + ensureOSGR(); + return _osgr.northing; + } - // OLC getter - void getOLCCode(char *code) { strncpy(code, _olc.code, OLC_CODE_LEN + 1); } // +1 for null termination + // OLC getter (computed on first access - see ensureOLC()) + void getOLCCode(char *code) + { + ensureOLC(); + strncpy(code, _olc.code, OLC_CODE_LEN + 1); // +1 for null termination + } }; \ No newline at end of file diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index fbb937952..5f5569752 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -214,7 +214,8 @@ static void test_cryptoKeyIsPublic_invalidKeyIsNotPublic() // Pre-fix, latitude_i = INT32_MAX made latLongToUTM read latBands[36] on a 21-char string // (stack-buffer-overflow at GeoCoord.cpp:128, an AddressSanitizer abort); extreme longitude produced // a negative UTM zone feeding the MGRS letter tables. The fix clamps the zone/band/col/row indices. -// This exercises the fix under the coverage env's ASan. +// This exercises the fix under the coverage env's ASan. Each representation is computed lazily, +// so its getter must be called explicitly here to exercise its conversion path. static void test_geocoord_extreme_coords_no_oob() { const int32_t vals[] = {INT32_MIN, INT32_MAX, INT32_MIN + 1, INT32_MAX - 1, 0, 1, -1, 900000000, -900000000, // +/-90 deg @@ -223,7 +224,13 @@ static void test_geocoord_extreme_coords_no_oob() const size_t n = sizeof(vals) / sizeof(vals[0]); for (size_t i = 0; i < n; i++) for (size_t j = 0; j < n; j++) { - GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> UTM/MGRS/OSGR/OLC + GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> DMS only + // Force UTM/MGRS/OSGR/OLC computation (each lazy on first getter access). + g.getUTMZone(); + g.getMGRSZone(); + g.getOSGRE100k(); + char olcCode[OLC_CODE_LEN + 1]; + g.getOLCCode(olcCode); // Surviving every extreme pair (no ASan fault) means the index clamps hold. TEST_ASSERT_EQUAL_INT32(vals[i], g.getLatitude()); } From 1af7048c1809235fc11b23dffb98d555adbb3774 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:19:25 +0200 Subject: [PATCH 17/69] Update meshtastic-esp8266-oled-ssd1306 digest to a6adfe3 (#11003) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 250de5004..bea27612e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -69,7 +69,7 @@ monitor_speed = 115200 monitor_filters = direct lib_deps = # renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master - https://github.com/meshtastic/esp8266-oled-ssd1306/archive/2e26010040e028baee72e2093402fa7b3c59e430.zip + https://github.com/meshtastic/esp8266-oled-ssd1306/archive/a6adfe3e76cb5670242032cc49add3bcf9074f52.zip # renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip # renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master From eb4e24b2f68c5a86b5e23920fc9bc1bccde5b5e1 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 14 Jul 2026 02:22:57 -0500 Subject: [PATCH 18/69] Avoid setting HeaderBackground for transparent backgrounds (#11002) --- src/graphics/SharedUIDisplay.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index 989d00a73..e60fe6128 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -147,8 +147,8 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti // Transparent clock headers should inherit whatever body off-color is // already active under the header (important for light/inverted themes). const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg()); - setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW, - headerHeight); + // Intentionally skip the HeaderBackground region, as small screens draw the clock in the unused space in this region, + // and the transparent call was erasing segments from the clock setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor); setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor); } else if (useInvertedHeaderStyle) { From fb922c2413e1b458841bcee63662b70e8abf39b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Tue, 14 Jul 2026 10:44:31 +0200 Subject: [PATCH 19/69] fix(tracker-x1): silence the buzzer during init (#11007) --- variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp b/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp index 2ad8b92b8..15a587e9a 100644 --- a/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp +++ b/variants/nrf52840/seeed_mesh_tracker_X1/variant.cpp @@ -68,4 +68,6 @@ void initVariant() pinMode(GPS_RTC_INT, OUTPUT); digitalWrite(GPS_RTC_INT, LOW); + + pinMode(PIN_BUZZER, INPUT_PULLDOWN); } From 0902c13473d64605919b35da598de5c6f590e0e8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 14 Jul 2026 20:48:54 -0500 Subject: [PATCH 20/69] feat: build-flagged frame injection into the RX pipeline for testing (#11011) MeshService::injectAsReceived (gated by MESHTASTIC_ENABLE_FRAME_INJECTION, off by default) extends the portduino SimRadio SIMULATOR_APP path to real hardware: a client-supplied frame, wrapped in a Compressed envelope on the SIMULATOR_APP portnum, is delivered through the real receive pipeline (router->enqueueReceivedMessage) as if it arrived off the LoRa chip. It therefore exercises from!=0 enforcement, channel/PKC decryption, remote-admin authorization, and hop/dedup/module dispatch - paths the toRadio API cannot reach (it forces from=0). from==0 is dropped to match real RX. This forges over-the-air traffic, so the flag must never ship enabled. Drive it with the meshtastic-mcp inject_frame tool / cli/meshinject.py. Documents the technique in the agent-facing copilot-instructions.md + AGENTS.md. --- .github/copilot-instructions.md | 13 ++++++-- AGENTS.md | 1 + src/configuration.h | 8 +++++ src/mesh/MeshService.cpp | 55 +++++++++++++++++++++++++++++++++ src/mesh/MeshService.h | 6 ++++ 5 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2782ff918..f0c456f4b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -763,7 +763,7 @@ The repo registers the server via `.mcp.json` at the repo root - Claude Code / C **One MCP call per port at a time.** `SerialInterface` holds an exclusive OS-level lock on the serial port for its lifetime. If a `serial_*` session is open on `/dev/cu.usbmodem101`, calling `device_info` on the same port will fail fast pointing at the active session. Sequence calls: open → read/mutate → close, then next device. Never parallelize tool calls on the same port. -### MCP tool surface (43 tools) +### MCP tool surface (44 tools) Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a few high-value signatures are called out here. @@ -771,7 +771,7 @@ Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a - **Build & flash**: `build`, `clean`, `pio_flash`, `erase_and_flash` (ESP32 only), `update_flash` (ESP32 OTA), `touch_1200bps` - **Serial sessions** (long-running, 10k-line ring buffer): `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` -- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` +- **Device writes**: `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `send_input_event` (inject a button/key press via the firmware's InputBroker), `inject_frame` (inject an over-the-air-style frame into the RX pipeline - see below), `set_debug_log_api`; destructive/power-state writes require `confirm=True`: `reboot`, `shutdown`, `factory_reset` - **userPrefs admin** (build-time constants, not runtime config): `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_chip_info`, `esptool_erase_flash`, `esptool_raw`, `nrfutil_dfu`, `nrfutil_raw`, `picotool_info`, `picotool_load`, `picotool_raw` - **USB power control** (via `uhubctl`, per-port PPPS toggle): `uhubctl_list` (read-only), `uhubctl_power(action='on'|'off', confirm=True)`, `uhubctl_cycle(delay_s, confirm=True)`. Target by raw `(location, port)` or by `role` (`"nrf52"`, `"esp32s3"`); role lookup checks `MESHTASTIC_UHUBCTL_LOCATION_` + `_PORT_` env vars first, falls back to VID auto-detection. @@ -781,6 +781,15 @@ Grouped by purpose. Full argument shapes in the meshtastic-mcp repo's README; a **TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step - use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See the meshtastic-mcp repo's README § "TCP / native-host nodes". +### Frame injection: testing the off-air receive path + +The `toRadio` API can only inject **locally-originated** traffic - the firmware forces `p.from = 0` in `MeshService::handleToRadio`, which bypasses the `from != 0` receive path and everything gated on it (remote admin authorization, the admin session-passkey check, hop handling, promiscuous sniffing). To exercise those paths you either need a second transmitting radio, or **frame injection**: a build-flagged seam that delivers a client-supplied frame into the real RX pipeline as if it arrived off the LoRa chip. + +- **Firmware:** build with `-D MESHTASTIC_ENABLE_FRAME_INJECTION=1` (`src/configuration.h`, off by default - it forges over-the-air traffic and must never ship enabled). `MeshService::injectAsReceived` extends the existing portduino `SimRadio` `SIMULATOR_APP` path to real hardware: it unwraps a `Compressed` envelope (`portnum == UNKNOWN_APP` → verbatim ciphertext the router decrypts; else → decoded payload for that portnum), then calls `router->enqueueReceivedMessage()` - the exact entry point `RadioLibInterface::handleReceiveInterrupt` uses. Injection is reached before the `p.from = 0` line, so a forged sender survives; `from == 0` is dropped to match real RX. +- **Host:** drive it with the meshtastic-mcp `inject_frame` tool (or `cli/meshinject.py`). The crafter replicates channel crypto (default-PSK expansion, `xorHash` channel hash, AES-CTR with the `packetId|from|0` nonce), so an encrypted frame decrypts on-device as if received. Modes: `text`, `raw`, `admin` (+ `pki`/`public_key` for the PKC-admin path), `ciphertext`, `fuzz` (malformed-frame decode-path/crash testing). +- **Example - remote-admin session-key repro:** set the target's `admin_key[0]` to a key you hold, then inject an `admin` set_owner with `pki=true`, that key, and a stale `session_hex`. The node logs `PKC admin payload with authorized sender key` → `Expected session key: 00…` → `Admin message without session_key!` - the exact ndoo scenario, on real silicon. Capture logs via `set_debug_log_api` on the same connection. +- **nRF52 gotcha:** the USB CDC wedges under rapid `SerialInterface` open/close churn (unrelated to injection) - keep setup + inject + log-capture on one connection; recover a hung board via a 1200 bps-touch DFU reflash. + ### Hardware test suite (`run-tests.sh`, from a meshtastic-mcp checkout) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. diff --git a/AGENTS.md b/AGENTS.md index 7c25bdc2f..9423c5169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ The [meshtastic-mcp](https://github.com/meshtastic/meshtastic-mcp) server expose - **Serial sessions**: `serial_open`, `serial_read`, `serial_list`, `serial_close` - **Device reads**: `device_info`, `list_nodes` - **Device writes** (require `confirm=True`): `set_owner`, `get_config`, `set_config`, `get_channel_url`, `set_channel_url`, `send_text`, `reboot`, `shutdown`, `factory_reset`, `set_debug_log_api` +- **Frame injection**: `inject_frame` - deliver a crafted frame into a board's real RX pipeline as if received off LoRa (reaches `from != 0` / decrypt / remote-admin paths the `toRadio` API can't). Needs firmware built with `-D MESHTASTIC_ENABLE_FRAME_INJECTION=1` (`MeshService::injectAsReceived`); sim nodes always. See the copilot-instructions **Frame injection** section. - **userPrefs admin**: `userprefs_get`, `userprefs_set`, `userprefs_reset`, `userprefs_manifest`, `userprefs_testing_profile` - **Vendor escape hatches**: `esptool_*`, `nrfutil_*`, `picotool_*` diff --git a/src/configuration.h b/src/configuration.h index aaba2bbfd..eb3db7e46 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -88,6 +88,14 @@ along with this program. If not, see . #define MESHTASTIC_PREHOP_DROP 1 #endif +// Debug/test only: let a wired client (serial/TCP) inject frames into the RX pipeline as if they had +// arrived over LoRa - a SIMULATOR_APP ToRadio packet is delivered through the real receive path on real +// hardware (see MeshService::injectAsReceived). This forges over-the-air traffic, so it MUST stay 0 in +// any shipping build; enable per-build with -D MESHTASTIC_ENABLE_FRAME_INJECTION=1. +#ifndef MESHTASTIC_ENABLE_FRAME_INJECTION +#define MESHTASTIC_ENABLE_FRAME_INJECTION 0 +#endif + /// Convert a preprocessor name into a quoted string #define xstr(s) ystr(s) #define ystr(s) #s diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index fb56b127f..48ef93672 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -173,6 +173,51 @@ NodeNum MeshService::getNodenumFromRequestId(uint32_t request_id) return nodenum; } +#if MESHTASTIC_ENABLE_FRAME_INJECTION +// Deliver a client-supplied frame into the receive pipeline as if it arrived off the LoRa chip. Mirrors +// the portduino SimRadio SIMULATOR_APP unwrap so the same host wire format works on real hardware: the +// frame rides inside a Compressed envelope wrapped in a MeshPacket that carries from/to/id/channel. +// Compressed.portnum == UNKNOWN_APP -> Compressed.data is verbatim ciphertext, decrypted as if off-air +// otherwise -> Compressed.data is the plaintext payload for Compressed.portnum +void MeshService::injectAsReceived(meshtastic_MeshPacket &p) +{ + meshtastic_Compressed scratch; + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + memset(&scratch, 0, sizeof(scratch)); + if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_Compressed_msg, &scratch)) { + if (scratch.portnum == meshtastic_PortNum_UNKNOWN_APP) { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + memcpy(p.encrypted.bytes, scratch.data.bytes, scratch.data.size); + p.encrypted.size = scratch.data.size; + } else { + memcpy(&p.decoded.payload, &scratch.data, sizeof(scratch.data)); + p.decoded.portnum = scratch.portnum; + } + } else { + LOG_ERROR("inject: could not decode Compressed envelope, dropping"); + return; + } + } + // The real RX path (RadioLibInterface::handleReceiveInterrupt) drops sender==0; mirror it so injection + // behaves identically to an over-the-air frame. + if (p.from == 0) { + LOG_WARN("inject: dropping frame with from==0 (matches real LoRa RX)"); + return; + } + meshtastic_MeshPacket *mp = packetPool.allocCopy(p); + if (!mp) + return; + if (mp->rx_snr == 0) // plausible synthetic link metadata unless the caller set it + mp->rx_snr = 8; + if (mp->rx_rssi == 0) + mp->rx_rssi = -40; + mp->rx_time = getValidTime(RTCQualityFromNet); + LOG_INFO("inject: RX from=0x%08x to=0x%08x id=0x%08x ch=%d %s", mp->from, mp->to, mp->id, mp->channel, + mp->which_payload_variant == meshtastic_MeshPacket_encrypted_tag ? "encrypted" : "decoded"); + router->enqueueReceivedMessage(mp); +} +#endif + /** * Given a ToRadio buffer parse it and properly handle it (setup radio, owner or send packet into the mesh) * Called by PhoneAPI.handleToRadio. Note: p is a scratch buffer, this function is allowed to write to it but it can not keep a @@ -186,6 +231,16 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) SimRadio::instance->unpackAndReceive(p); return; } +#endif +#if MESHTASTIC_ENABLE_FRAME_INJECTION + // Real-hardware analog of the SimRadio path above: deliver a client-supplied frame into the RX + // pipeline exactly as if it had arrived off the LoRa chip. Reached before the p.from=0 line below, + // so an injected sender is preserved. Build-flag gated (off by default) - it lets anything with a + // wired connection forge over-the-air traffic, so it must never ship enabled. + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.portnum == meshtastic_PortNum_SIMULATOR_APP) { + injectAsReceived(p); + return; + } #endif p.from = 0; // We don't let clients assign nodenums to their sent messages p.next_hop = NO_NEXT_HOP_PREFERENCE; // We don't let clients assign next_hop to their sent messages diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index 41dcf1c80..b529d2836 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -159,6 +159,12 @@ class MeshService */ void handleToRadio(meshtastic_MeshPacket &p); +#if MESHTASTIC_ENABLE_FRAME_INJECTION + /// Test/debug seam (build-flag gated, off by default): deliver a client-supplied frame into the + /// receive pipeline as if it had arrived off the LoRa chip. See the definition for the wire format. + void injectAsReceived(meshtastic_MeshPacket &p); +#endif + /** The radioConfig object just changed, call this to force the hw to change to the new settings * @return true if client devices should be sent a new set of radio configs */ From fc91a69ca4d1b005cd46367dabb4ca503a245205 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:10:07 -0500 Subject: [PATCH 21/69] Update actions/setup-node action to v7 (#11012) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c788b7942..777193a46 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,7 +47,7 @@ jobs: pio upgrade - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 From 952c8251678e26d1948673af6c9d96834fcee7fb Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Tue, 14 Jul 2026 21:38:41 -0500 Subject: [PATCH 22/69] Allow key verification to work for unknown nodes. (#10669) * Allow key verification to work for unknown nodes. * trunk * More reliable admin key decryption * Add admin key fallback tests * Actually check haveRemoteKey * Logging cleanup * Address review feedback - Persist the committed key + manually-verified flag in commitVerifiedRemoteNode via saveToDisk(SEGMENT_NODEDATABASE), replacing the "todo: initiate save" - Guard the CryptoEngine pending-key slot with a dedicated internal lock; the Router reads it while already holding the non-recursive cryptLock, so the accessors cannot reuse that lock - Draw the security number from the hardware RNG (CryptRNG fallback) under cryptLock instead of random(); on nRF52 the entropy fill toggles the same CC310 the BLE task's packet crypto uses - Return true after fully handling the hash2 response (restores develop behavior; consistent with the hash1 branch) - Take uint32_t in the number-picker callbacks so 8-digit hex nodenums can't truncate through int - Trim over-long comments flagged by review * feat(tests): add deterministic tests for admin session-key behavior --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Ben Meadors --- src/graphics/Screen.cpp | 7 +- src/graphics/Screen.h | 3 +- src/graphics/draw/MenuHandler.cpp | 9 +- src/mesh/CryptoEngine.cpp | 26 +++ src/mesh/CryptoEngine.h | 15 ++ src/mesh/Router.cpp | 75 +++++-- src/modules/KeyVerificationModule.cpp | 144 +++++++++---- src/modules/KeyVerificationModule.h | 38 +++- test/native-suite-count | 2 +- test/support/AdminModuleTestShim.h | 2 + test/test_admin_session_repro/test_main.cpp | 153 ++++++++++++++ test/test_pki_admin_fallback/test_main.cpp | 223 ++++++++++++++++++++ 12 files changed, 632 insertions(+), 65 deletions(-) create mode 100644 test/test_admin_session_repro/test_main.cpp create mode 100644 test/test_pki_admin_fallback/test_main.cpp diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 8aebfec6e..024fa409f 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -343,7 +343,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct } // Called to trigger a banner with custom message and duration -void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, +void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, std::function bannerCallback) { #ifdef USE_EINK @@ -356,7 +356,10 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t NotificationRenderer::alertBannerCallback = bannerCallback; NotificationRenderer::pauseBanner = false; NotificationRenderer::curSelected = 0; - NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; + if (useBase16) + NotificationRenderer::current_notification_type = notificationTypeEnum::hex_picker; + else + NotificationRenderer::current_notification_type = notificationTypeEnum::number_picker; NotificationRenderer::numDigits = digits; NotificationRenderer::currentNumber = 0; diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 424b162b4..d72826996 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -344,7 +344,8 @@ class Screen : public concurrency::OSThread void showOverlayBanner(BannerOverlayOptions); void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); - void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, + std::function bannerCallback); void showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 23e0c7b5f..e6f1fa10e 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -2323,8 +2323,10 @@ void menuHandler::testMenu() void menuHandler::numberTest() { - screen->showNumberPicker("Pick a number\n ", 30000, 4, - [](int number_picked) -> void { LOG_WARN("Nodenum: %u", number_picked); }); + screen->showNumberPicker("Verify Nodenum:\n ", 30000, 8, true, [](uint32_t number_picked) -> void { + LOG_DEBUG("Nodenum: 0x%08x", number_picked); + keyVerificationModule->sendInitialRequest(number_picked); + }); } void menuHandler::wifiBaseMenu() @@ -2508,8 +2510,7 @@ void menuHandler::keyVerificationFinalPrompt() options.notificationType = graphics::notificationTypeEnum::selection_picker; options.bannerCallback = [=](int selected) { if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(keyVerificationModule->getCurrentRemoteNode()); - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + keyVerificationModule->commitVerifiedRemoteNode(); } }; screen->showOverlayBanner(options); diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 73ccb2166..e28df442b 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -341,6 +341,32 @@ bool CryptoEngine::setDHPublicKey(uint8_t *pubKey) return true; } +void CryptoEngine::setPendingPublicKey(uint32_t node, const uint8_t *key) +{ + concurrency::LockGuard g(&pendingKeyLock); + pendingKeyVerificationNode = node; + memcpy(pendingKeyVerificationPublicKey, key, 32); + hasPendingKeyVerificationKey = true; +} + +void CryptoEngine::clearPendingPublicKey() +{ + concurrency::LockGuard g(&pendingKeyLock); + pendingKeyVerificationNode = 0; + memset(pendingKeyVerificationPublicKey, 0, 32); + hasPendingKeyVerificationKey = false; +} + +bool CryptoEngine::getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out) +{ + concurrency::LockGuard g(&pendingKeyLock); + if (!hasPendingKeyVerificationKey || node == 0 || node != pendingKeyVerificationNode) + return false; + out.size = 32; + memcpy(out.bytes, pendingKeyVerificationPublicKey, 32); + return true; +} + #endif concurrency::Lock *cryptLock; diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 2b0090d5c..95c7eb8ec 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -59,6 +59,17 @@ class CryptoEngine virtual bool setDHPublicKey(uint8_t *publicKey); virtual void hash(uint8_t *bytes, size_t numBytes); + // Temporary holder for a peer's not-yet-verified public key, learned in-band during an + // in-progress key-verification handshake before it is committed to NodeDB. Lets the Router + // run the DH handshake to encode/decode the follow-on PKI packet. Single slot is enough: + // only one verification runs at a time. Discarded when the handshake ends (resetToIdle). + // Internally guarded by pendingKeyLock, not cryptLock: the Router calls the getter while + // already holding the non-recursive cryptLock; KeyVerificationModule writes from elsewhere. + void setPendingPublicKey(uint32_t node, const uint8_t *key); + void clearPendingPublicKey(); + // Fills `out` (size set to 32) and returns true iff a pending key is held for `node`. + bool getPendingPublicKey(uint32_t node, meshtastic_NodeInfoLite_public_key_t &out); + virtual void aesSetKey(const uint8_t *key, size_t key_len); virtual void aesEncrypt(uint8_t *in, uint8_t *out); @@ -94,6 +105,10 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI) uint8_t shared_key[32] = {0}; uint8_t private_key[32] = {0}; + uint32_t pendingKeyVerificationNode = 0; + uint8_t pendingKeyVerificationPublicKey[32] = {0}; + bool hasPendingKeyVerificationKey = false; + concurrency::Lock pendingKeyLock; #if !(MESHTASTIC_EXCLUDE_XEDDSA) uint8_t xeddsa_public_key[32] = {0}; uint8_t xeddsa_private_key[32] = {0}; diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 0641cbc3c..24f7f586b 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -524,36 +524,60 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) bool decrypted = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) - // Attempt PKI decryption first. The sender's key may come from the hot - // store or the warm tier (nodes evicted from the hot store keep their key - // there), so DMs from long-tail nodes still decrypt. - meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}}; - if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) && - nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 && - rawSize > MESHTASTIC_PKC_OVERHEAD) { - LOG_DEBUG("Attempt PKI decryption"); + // Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else + // fall back to a not-yet-committed key held during an in-progress key-verification handshake. + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic); - if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) { + meshtastic_NodeInfoLite *ourNode = nullptr; + if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD && + (ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) { + // Try the sender's known key first, then each configured admin key so an authorized admin can + // reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates. + bool viaAdminKey = false; + if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + } + for (int i = 0; i < 3 && !decrypted; i++) { + if (config.security.admin_key[i].size != 32) + continue; + remotePublic.size = 32; + memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); + + if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + viaAdminKey = true; + break; // stop after first successful decryption + } + } + if (decrypted) { LOG_INFO("PKI Decryption worked!"); - meshtastic_Data decodedtmp; memset(&decodedtmp, 0, sizeof(decodedtmp)); - rawSize -= MESHTASTIC_PKC_OVERHEAD; - if (pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp) && + size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; + if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) && decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { decrypted = true; + rawSize = payloadSize; // commit the overhead subtraction only on full success LOG_INFO("Packet decrypted using PKI!"); p->pki_encrypted = true; - memcpy(p->public_key.bytes, fromKey.bytes, 32); + memcpy(p->public_key.bytes, remotePublic.bytes, 32); p->public_key.size = 32; p->decoded = decodedtmp; p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded + if (viaAdminKey) { + // Persist the admin key for the sender so future packets take the fast path and we can + // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it. + meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); + if (fromNode != nullptr) + fromNode->public_key = remotePublic; + } } else { + // AEAD already authenticated this ciphertext, so no other candidate could decode it - + // the payload is simply malformed. LOG_ERROR("PKC Decrypted, but pb_decode failed!"); return DecodeState::DECODE_FAILURE; } - } else { - LOG_WARN("PKC decrypt attempted but failed!"); } } #endif @@ -758,10 +782,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it #if !(MESHTASTIC_EXCLUDE_PKI) - // Destination key from the hot store or the warm tier (evicted - // long-tail nodes keep their key there) + // Resolve the destination's public key: prefer NodeDB (hot store or warm tier - evicted + // long-tail nodes keep their key there), otherwise (for a key-verification follow-on packet + // that explicitly requested PKI) fall back to the not-yet-verified key held during an + // in-progress handshake. This lets us DH-encode the follow-on packet before the peer's key + // has been committed to NodeDB. meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey); + if (!haveDestKey && p->pki_encrypted && p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && + crypto->getPendingPublicKey(p->to, destKey)) { + haveDestKey = true; + } // We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node // is not in the local nodedb // First, only PKC encrypt packets we are originating @@ -779,11 +810,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) config.security.private_key.size == 32 && !isBroadcast(p->to) && // Some portnums either make no sense to send with PKC p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && - p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP) { + p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP && + // We allow Key Verification messages to be sent without a known destination key, since the point of those messages is + // to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet + // uses the pending key resolved into haveDestKey/destKey above. + // Though possible the first packet each direction should go non-pkc + // to handle the case where the remote node has our key, but we don't have theirs. + !(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) { LOG_DEBUG("Use PKI!"); if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) return meshtastic_Routing_Error_TOO_LARGE; - // Check for a known public key for the destination + // Check for a usable public key for the destination (NodeDB or a pending key-verification key) if (!haveDestKey) { LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to, p->decoded.portnum); diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index de3474aa2..4c2782241 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -1,11 +1,15 @@ #if !MESHTASTIC_EXCLUDE_PKI #include "KeyVerificationModule.h" +#include "CryptoEngine.h" +#include "HardwareRNG.h" #include "MeshService.h" #include "RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" #include "meshUtils.h" #include "modules/AdminModule.h" +#include "modules/NodeInfoModule.h" +#include #include KeyVerificationModule *keyVerificationModule; @@ -34,7 +38,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons { updateState(); if (request->which_payload_variant == meshtastic_AdminMessage_key_verification_tag && mp.from == 0) { - LOG_WARN("Handling Key Verification Admin Message type %u", request->key_verification.message_type); + LOG_DEBUG("Handling Key Verification Admin Message type %u", request->key_verification.message_type); if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_INITIATE_VERIFICATION && currentState == KEY_VERIFICATION_IDLE) { @@ -48,9 +52,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY && request->key_verification.nonce == currentNonce) { - auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr) - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + commitVerifiedRemoteNode(); resetToIdle(); } else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) { resetToIdle(); @@ -63,9 +65,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r) { updateState(); - if (mp.pki_encrypted == false) { - return false; - } + // Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in + // the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below. if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply() return false; } @@ -74,9 +75,14 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & } if (currentState == KEY_VERIFICATION_SENDER_HAS_INITIATED && r->nonce == currentNonce && r->hash2.size == 32 && - r->hash1.size == 0) { + r->hash1.size == 32) { memcpy(hash2, r->hash2.bytes, 32); - IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, [](int number_picked) -> void { + // The response carries the responder's public key in hash1. If we don't already hold it, stash it + // as a pending key so the Router can PKI-encrypt our follow-on packet (committed to NodeDB on accept). + auto *responderNode = nodeDB->getMeshNode(currentRemoteNode); + if (responderNode == nullptr || responderNode->public_key.size != 32) + crypto->setPendingPublicKey(currentRemoteNode, r->hash1.bytes); + IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, false, [](uint32_t number_picked) -> void { keyVerificationModule->processSecurityNumber(number_picked); });) @@ -95,7 +101,8 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; return true; - } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && r->hash1.size == 32 && r->nonce == currentNonce) { + } else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && mp.pki_encrypted && r->hash1.size == 32 && + r->nonce == currentNonce) { if (memcmp(hash1, r->hash1.bytes, 32) == 0) { memset(message, 0, sizeof(message)); sprintf(message, "Verification: \n"); @@ -108,10 +115,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & options.notificationType = graphics::notificationTypeEnum::selection_picker; options.bannerCallback = [=](int selected) { + LOG_DEBUG("User selected %d for key verification", selected); if (selected == 1) { - auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (remoteNodePtr) - remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + keyVerificationModule->commitVerifiedRemoteNode(); } }; screen->showOverlayBanner(options);) @@ -139,22 +145,29 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) { LOG_DEBUG("keyVerification start"); // generate nonce - updateState(); + updateState(false); if (currentState != KEY_VERIFICATION_IDLE) { IF_SCREEN(graphics::menuHandler::menuQueue = graphics::menuHandler::ThrottleMessage;) return false; } + updateState(true); currentNonce = random(); currentNonceTimestamp = getTime(); currentRemoteNode = remoteNode; meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; KeyVerification.nonce = currentNonce; KeyVerification.hash2.size = 0; - KeyVerification.hash1.size = 0; + // Carry our public key in the otherwise-unused hash1 field so a peer that does not yet hold our + // key can learn it from this first message (bootstrap / onboarding). + KeyVerification.hash1.size = 32; + memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); p->to = remoteNode; p->channel = 0; - p->pki_encrypted = true; + // Only request PKI when we already hold the destination's key. Otherwise this first message goes out + // channel-encrypted (the Router falls back) so the peer can bootstrap from the key carried in hash1. + auto *remoteNodePtr = nodeDB->getMeshNode(remoteNode); + p->pki_encrypted = (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32); p->decoded.want_response = true; p->priority = meshtastic_MeshPacket_Priority_HIGH; service->sendToMesh(p, RX_SRC_LOCAL, true); @@ -171,9 +184,6 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period LOG_WARN("Key Verification requested, but already in a request"); return nullptr; - } else if (!currentRequest->pki_encrypted) { - LOG_WARN("Key Verification requested, but not in a PKI packet"); - return nullptr; } currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1; @@ -188,15 +198,43 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() response.nonce = scratch.nonce; currentRemoteNode = req.from; currentNonceTimestamp = getTime(); - currentSecurityNumber = random(1, 999999); + // The security number is the handshake's MitM-resistance entropy, so draw it from the hardware + // RNG (falling back to the CSPRNG used for key material), never the predictable random(). + // Hold cryptLock like xeddsa_sign does: on nRF52 the fill toggles the CC310 that packet crypto + // on the BLE task also uses, and the CryptRNG state is shared with the signing path. + uint32_t securityEntropy = 0; + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill((uint8_t *)&securityEntropy, sizeof(securityEntropy))) + CryptRNG.rand((uint8_t *)&securityEntropy, sizeof(securityEntropy)); + } + currentSecurityNumber = (securityEntropy % 999999) + 1; - // generate hash1 + // Resolve the requester's public key: from the PKI envelope, else carried in hash1 (bootstrap). + // Stash unknown keys as pending (committed to NodeDB only once verification is accepted). + const uint8_t *senderKey = nullptr; + if (currentRequest->pki_encrypted && currentRequest->public_key.size == 32) { + senderKey = currentRequest->public_key.bytes; // this is bizarre, fixme + } else if (scratch.hash1.size == 32) { + senderKey = scratch.hash1.bytes; + } + if (senderKey == nullptr) { + LOG_WARN("Key Verification request without a usable public key"); + resetToIdle(); + return nullptr; + } + auto *senderNode = nodeDB->getMeshNode(currentRemoteNode); + bool senderKeyInNodeDB = (senderNode != nullptr && senderNode->public_key.size == 32); + if (!senderKeyInNodeDB) + crypto->setPendingPublicKey(currentRemoteNode, senderKey); + + // generate local hash1 hash.reset(); hash.update(¤tSecurityNumber, sizeof(currentSecurityNumber)); hash.update(¤tNonce, sizeof(currentNonce)); hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(&ourNodeNum, sizeof(ourNodeNum)); - hash.update(currentRequest->public_key.bytes, currentRequest->public_key.size); + hash.update(senderKey, 32); hash.update(owner.public_key.bytes, owner.public_key.size); hash.finalize(hash1, 32); @@ -205,15 +243,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() hash.update(¤tNonce, sizeof(currentNonce)); hash.update(hash1, 32); hash.finalize(hash2, 32); - response.hash1.size = 0; + // Carry our public key in hash1 of the response so the requester can bootstrap our key as well. + response.hash1.size = 32; + memcpy(response.hash1.bytes, owner.public_key.bytes, 32); response.hash2.size = 32; memcpy(response.hash2.bytes, hash2, 32); responsePacket = allocDataProtobuf(response); - responsePacket->pki_encrypted = true; + // PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes + // out channel-encrypted so the requester (who lacks our key) can decode it and read hash1. + responsePacket->pki_encrypted = senderKeyInNodeDB; IF_SCREEN(snprintf(message, 25, "Security Number \n%03u %03u", currentSecurityNumber / 1000, currentSecurityNumber % 1000); - screen->showSimpleBanner(message, 30000); LOG_WARN("%s", message);) + screen->showSimpleBanner(message, 30000); LOG_DEBUG("%s", message);) meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); if (cn) { cn->level = meshtastic_LogRecord_Level_WARNING; @@ -227,7 +269,7 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber; service->sendClientNotification(cn); } - LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); + LOG_DEBUG("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce); return responsePacket; } @@ -236,14 +278,18 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) SHA256 hash; NodeNum ourNodeNum = nodeDB->getNodeNum(); uint8_t scratch_hash[32] = {0}; - LOG_WARN("received security number: %u", incomingNumber); - meshtastic_NodeInfoLite *remoteNodePtr = nullptr; - remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); - if (!remoteNodePtr || !nodeInfoLiteHasUser(remoteNodePtr) || remoteNodePtr->public_key.size != 32) { - currentState = KEY_VERIFICATION_IDLE; - return; // should we throw an error here? + LOG_DEBUG("received security number: %u", incomingNumber); + meshtastic_NodeInfoLite *remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode); + // Resolve the remote public key: NodeDB if known, otherwise the pending key learned during this + // handshake (bootstrap case). + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + if (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32) { + remotePublic = remoteNodePtr->public_key; + } else if (!crypto->getPendingPublicKey(currentRemoteNode, remotePublic)) { + LOG_WARN("No public key available for remote node, aborting key verification"); + resetToIdle(); + return; } - LOG_WARN("hashing "); // calculate hash1 hash.reset(); hash.update(&incomingNumber, sizeof(incomingNumber)); @@ -252,7 +298,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) hash.update(¤tRemoteNode, sizeof(currentRemoteNode)); hash.update(owner.public_key.bytes, owner.public_key.size); - hash.update(remoteNodePtr->public_key.bytes, remoteNodePtr->public_key.size); + hash.update(remotePublic.bytes, 32); hash.finalize(hash1, 32); hash.reset(); @@ -297,13 +343,13 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) return; } -void KeyVerificationModule::updateState() +void KeyVerificationModule::updateState(bool resetTimer) { if (currentState != KEY_VERIFICATION_IDLE) { // check for the 60 second timeout if (currentNonceTimestamp < getTime() - 60) { resetToIdle(); - } else { + } else if (resetTimer) { currentNonceTimestamp = getTime(); } } @@ -318,6 +364,32 @@ void KeyVerificationModule::resetToIdle() currentSecurityNumber = 0; currentRemoteNode = 0; currentState = KEY_VERIFICATION_IDLE; + // Discard any not-yet-verified key learned during this handshake; on reject/timeout it is never trusted. + crypto->clearPendingPublicKey(); +} + +void KeyVerificationModule::commitVerifiedRemoteNode() +{ + // The remote node already has a NodeDB entry by this point (packets were exchanged during the + // handshake), so getMeshNode is sufficient; bail defensively if it is somehow absent. + meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentRemoteNode); + if (!node) { + LOG_WARN("Attempted to commit key, but unknown node"); + return; + } + // If we only held the peer's key as a pending (unverified) key during the handshake, commit it to + // NodeDB now that the user has confirmed the verification, so future PKI traffic can use it. + meshtastic_NodeInfoLite_public_key_t pending = {0, {0}}; + if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending)) + node->public_key = pending; + node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber); + if (nodeInfoModule) + nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true); + crypto->clearPendingPublicKey(); + currentState = KEY_VERIFICATION_IDLE; + // Persist the committed key and verified flag so manual verification survives a reboot. + nodeDB->saveToDisk(SEGMENT_NODEDATABASE); } void KeyVerificationModule::generateVerificationCode(char *readableCode) diff --git a/src/modules/KeyVerificationModule.h b/src/modules/KeyVerificationModule.h index d5dba01d7..9496649d3 100644 --- a/src/modules/KeyVerificationModule.h +++ b/src/modules/KeyVerificationModule.h @@ -12,6 +12,39 @@ enum KeyVerificationState { KEY_VERIFICATION_RECEIVER_AWAITING_HASH1, }; +// KeyVerification Module overview +// This module allows for two useful functions. First, it implements a 2sv process that can manually verify a trustworthy +// connection with another node. It specifically verifies that the other node holds the correct private key for its public key, so +// it is resistant to MitM attacks. Second, it can be used to bootstrap trust in a new node by carrying the public key in the +// initial unencrypted message (in the hash1 field of the KeyVerification protobuf). This allows a user to manually verify a new +// node even if they don't have that node in the local nodeDB at all. + +// The handshake process is as follows (NodeA = initiator, NodeB = responder): +// 1. NodeA sends a KeyVerification message containing a random nonce and its own public key (in the +// hash1 field) to NodeB. Implemented in sendInitialRequest(). It is PKI-encrypted if NodeA already +// holds NodeB's key, otherwise channel-encrypted (the bootstrap case). +// +// 2. NodeB replies (allocReply()) with its own public key (hash1 field) and hash2. NodeB generates a +// random 6-digit security number and stashes NodeA's public key (as a pending key if not already in +// the nodeDB). It computes hash1 = SHA256(securityNumber, nonce, NodeA_num, NodeB_num, PK_A, PK_B), +// then hash2 = SHA256(nonce, hash1). The reply is PKI-encrypted only if NodeB already held NodeA's +// key; in the bootstrap case it is channel-encrypted so NodeA can read NodeB's key from hash1. +// +// 3. NodeA receives the reply (handleReceivedProtobuf()), checks the nonce, stashes NodeB's public key, +// and prompts the user to enter the security number. The security number is never sent over the mesh +// and must be communicated over a secondary channel. processSecurityNumber() recomputes hash1 from +// the entered number and verifies SHA256(nonce, hash1) matches the received hash2. NodeA then sends +// its hash1 back to NodeB in a PKI-encrypted KeyVerification message (the follow-on PKI packet) and +// shows the KeyVerificationFinalPrompt menu, displaying 8 characters derived from hash1. +// +// 4. NodeB receives NodeA's hash1 (handleReceivedProtobuf(); required to be PKI-encrypted), checks it +// matches the hash1 NodeB generated, and shows the same 8-character code for final confirmation. +// +// The final on-screen code comparison is the actual manual verification: the user confirms the codes +// match on both devices, proving the two nodes agree on the same public keys (no MitM substitution). +// PKI-encrypting the follow-on packet additionally proves each node holds the private key for the +// agreed public key. + class KeyVerificationModule : public ProtobufModule //, private concurrency::OSThread // { // CallbackObserver nodeStatusObserver = @@ -29,6 +62,7 @@ class KeyVerificationModule : public ProtobufModule bool sendInitialRequest(NodeNum remoteNode); void generateVerificationCode(char *); // fills char with the user readable verification code uint32_t getCurrentRemoteNode() { return currentRemoteNode; } + void commitVerifiedRemoteNode(); // Commit a pending key to NodeDB and mark the node manually verified protected: /* Called to handle a particular incoming message @@ -58,8 +92,8 @@ class KeyVerificationModule : public ProtobufModule char message[40] = {0}; void processSecurityNumber(uint32_t); - void updateState(); // check the timeouts and maybe reset the state to idle - void resetToIdle(); // Zero out module state + void updateState(bool resetTimer = true); // check the timeouts and maybe reset the state to idle + void resetToIdle(); // Zero out module state }; extern KeyVerificationModule *keyVerificationModule; \ No newline at end of file diff --git a/test/native-suite-count b/test/native-suite-count index e85087aff..f5c89552b 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -31 +32 diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 7dba0677e..d3670b09a 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -7,9 +7,11 @@ class AdminModuleTestShim : public AdminModule { public: + using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro) using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::setPassKey; // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp new file mode 100644 index 000000000..f1bb1711e --- /dev/null +++ b/test/test_admin_session_repro/test_main.cpp @@ -0,0 +1,153 @@ +// Deterministic reproduction of the admin session-key behavior discussed on PR #10669 +// (ndoo's "Admin message without session_key!" report). +// +// Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote +// (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate. +// A local (from == 0) admin bypasses that gate, so the bug only reproduces from != 0. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/NodeDB.h" +#include "modules/AdminModule.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, + 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20}; + +static MockMeshService *mockService = nullptr; +static AdminModuleTestShim *admin = nullptr; + +// A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents. +static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen, + meshtastic_AdminMessage &out) +{ + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + strncpy(out.set_owner.long_name, newLongName, sizeof(out.set_owner.long_name) - 1); + if (session) { + out.session_passkey.size = sessionLen; + memcpy(out.session_passkey.bytes, session, sessionLen); + } + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = ADMIN_NODE; // REMOTE: this is what makes the session gate apply + mp.channel = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.pki_encrypted = true; // arrived over PKC + mp.public_key.size = 32; + memcpy(mp.public_key.bytes, ADMIN_KEY, 32); // matches config.security.admin_key[0] -> authorized + return mp; +} + +void setUp(void) +{ + mockService = new MockMeshService(); + service = mockService; + admin = new AdminModuleTestShim(); + admin->deferSaves(); // no disk/reboot side effects when a setter is accepted + + if (!nodeDB) + nodeDB = new NodeDB(); + myNodeInfo.my_node_num = LOCAL_NODE; + + config = meshtastic_LocalConfig_init_zero; + // Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate. + config.security.admin_key[0].size = 32; + memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32); + + owner = meshtastic_User_init_zero; + strncpy(owner.long_name, "Original", sizeof(owner.long_name) - 1); + + channels.initDefaults(); + channels.onConfigChanged(); +} + +void tearDown(void) +{ + service = nullptr; + delete mockService; + mockService = nullptr; + delete admin; + admin = nullptr; +} + +// ndoo's report: a setter from a remote node with NO valid session is rejected, and the node's +// expected session key is all-zero because it has minted none since boot. +void test_remote_setter_without_session_is_rejected(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeRemoteSetOwner("Hijacked", nullptr, 0, m); + + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + // Rejected at the session gate -> owner unchanged (this is ndoo's "Admin message without session_key!"). + TEST_ASSERT_EQUAL_STRING("Original", owner.long_name); +} + +// The node's session key is minted only by setPassKey (which runs when it answers an admin GET), +// so before any GET the expected key is all-zero and any presented key mismatches. +void test_expected_session_key_is_zero_before_any_get(void) +{ + uint8_t zero[8] = {0}; + meshtastic_AdminMessage probe = meshtastic_AdminMessage_init_zero; + probe.session_passkey.size = 8; + memcpy(probe.session_passkey.bytes, zero, 8); // even all-zeros must not authorize a state change + // A fresh module has minted no session; a stale/guessed key does not match. + // (checkPassKey also requires size==8 AND session_time freshness.) + uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; // ndoo's presented key + meshtastic_AdminMessage staleMsg = meshtastic_AdminMessage_init_zero; + staleMsg.session_passkey.size = 8; + memcpy(staleMsg.session_passkey.bytes, stale, 8); + TEST_ASSERT_FALSE(admin->checkPassKey(&staleMsg)); // Expected: 00..00 vs Incoming: 29 04 b4 78.. -> reject +} + +// The fix path: once the node answers a GET (setPassKey mints/returns the key), the session gate +// accepts a setter carrying that key. Asserting the gate (checkPassKey) directly is the mechanism; +// driving the full handleSetOwner would need the NodeInfoModule scaffolding, out of scope here. +void test_session_gate_accepts_key_from_a_get_response(void) +{ + // Simulate the node answering an admin GET: setPassKey mints the session and writes it into the response. + meshtastic_AdminMessage getResponse = meshtastic_AdminMessage_init_zero; + admin->setPassKey(&getResponse); + TEST_ASSERT_EQUAL(8, getResponse.session_passkey.size); // node handed the client a session key + + // A setter carrying that exact key passes the gate (would be accepted). + meshtastic_AdminMessage good = meshtastic_AdminMessage_init_zero; + good.session_passkey = getResponse.session_passkey; + TEST_ASSERT_TRUE(admin->checkPassKey(&good)); + + // A setter carrying a stale/guessed key still fails (no session replay). + meshtastic_AdminMessage bad = meshtastic_AdminMessage_init_zero; + bad.session_passkey.size = 8; + uint8_t stale[8] = {0x29, 0x04, 0xb4, 0x78, 0xd8, 0x68, 0xa7, 0xff}; + memcpy(bad.session_passkey.bytes, stale, 8); + TEST_ASSERT_FALSE(admin->checkPassKey(&bad)); +} + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_remote_setter_without_session_is_rejected); + RUN_TEST(test_expected_session_key_is_zero_before_any_get); + RUN_TEST(test_session_gate_accepts_key_from_a_get_response); +#endif + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp new file mode 100644 index 000000000..c03652244 --- /dev/null +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -0,0 +1,223 @@ +// Tests for the admin-key fallback in Router::perhapsDecode: a PKI unicast from an unknown sender is +// tried against each configured admin_key; on success the packet decodes and the key is persisted to +// NodeDB. Drives the real crypto + NodeDB path with packets encrypted exactly as an admin radio would. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +// The whole feature is compiled out when PKI is excluded. +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" // MESHTASTIC_PKC_OVERHEAD +#include "mesh/Router.h" +#include +#include +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us (the receiver) +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // an authorized admin, absent from our NodeDB +static constexpr uint32_t PKT_ID = 0x12345678; + +// MockNodeDB - inject nodes with controlled public keys (meshNodes/numMeshNodes are public on NodeDB). +// Mirrors test/test_packet_signing. +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + void setPublicKey(NodeNum num, const uint8_t *pubKey) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + n->public_key.size = 32; + memcpy(n->public_key.bytes, pubKey, 32); + } + + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; + +// Keypairs, regenerated fresh each test in setUp(). "our" == the receiver, "admin" == the sender. +static uint8_t ourPub[32], ourPriv[32]; +static uint8_t adminPub[32], adminPriv[32]; + +// Store a 32-byte key into config.security.admin_key[slot]. +static void setAdminKey(int slot, const uint8_t *key32) +{ + config.security.admin_key[slot].size = 32; + memcpy(config.security.admin_key[slot].bytes, key32, 32); + if (slot + 1 > (int)config.security.admin_key_count) + config.security.admin_key_count = slot + 1; +} + +// Build a PKI-encrypted unicast from `from` to us, encrypted with `senderPriv` against our public key, +// leaving the engine holding our private key afterwards (as during receive) so perhapsDecode can decrypt. +static meshtastic_MeshPacket makePkiPacket(NodeNum from, meshtastic_PortNum port, size_t payloadLen, const uint8_t *senderPriv) +{ + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = port; + data.payload.size = payloadLen; + for (size_t i = 0; i < payloadLen; i++) + data.payload.bytes[i] = (uint8_t)(i & 0xff); + + uint8_t plain[meshtastic_Constants_DATA_PAYLOAD_LEN]; + size_t plainLen = pb_encode_to_bytes(plain, sizeof(plain), &meshtastic_Data_msg, &data); + TEST_ASSERT_TRUE_MESSAGE(plainLen > 0, "pb_encode_to_bytes failed in test setup"); + + meshtastic_NodeInfoLite_public_key_t ourPubStruct; + ourPubStruct.size = 32; + memcpy(ourPubStruct.bytes, ourPub, 32); + + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = LOCAL_NODE; + p.id = PKT_ID; + p.channel = 0; // PKI packets carry channel hash 0 + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + + // Encrypt AS the sender: shared secret = DH(senderPriv, ourPub). + crypto->setDHPrivateKey(const_cast(senderPriv)); + bool ok = crypto->encryptCurve25519(p.to, p.from, ourPubStruct, p.id, plainLen, plain, p.encrypted.bytes); + TEST_ASSERT_TRUE_MESSAGE(ok, "encryptCurve25519 failed in test setup"); + p.encrypted.size = plainLen + MESHTASTIC_PKC_OVERHEAD; + + // Restore the engine to our private key, as it is when receiving. + crypto->setDHPrivateKey(ourPriv); + return p; +} + +// Assert the packet decoded via PKI and that we learned `expectedKey` for its sender. +static void assertDecodedAndLearned(meshtastic_MeshPacket *p, const uint8_t *expectedKey) +{ + TEST_ASSERT_EQUAL(meshtastic_MeshPacket_decoded_tag, p->which_payload_variant); + TEST_ASSERT_TRUE(p->pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p->decoded.portnum); + TEST_ASSERT_EQUAL(32, p->public_key.size); + TEST_ASSERT_EQUAL_MEMORY(expectedKey, p->public_key.bytes, 32); + + meshtastic_NodeInfoLite *learned = mockNodeDB->getMeshNode(p->from); + TEST_ASSERT_NOT_NULL_MESSAGE(learned, "sender should have been created in NodeDB"); + TEST_ASSERT_EQUAL_MESSAGE(32, learned->public_key.size, "sender key should have been persisted"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expectedKey, learned->public_key.bytes, 32, "persisted key mismatch"); +} + +void setUp(void) +{ + // Construct the mock FIRST: the NodeDB ctor can reload persisted host state and repopulate globals. + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + + config = meshtastic_LocalConfig_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs()/getFrom() + + channels.initDefaults(); + channels.onConfigChanged(); + + // Fresh keypairs for us and the admin (independent, valid Curve25519 pairs). + crypto->generateKeyPair(ourPub, ourPriv); + crypto->generateKeyPair(adminPub, adminPriv); + + // perhapsDecode's PKI gate requires that we have our own key (getMeshNode(p->to)->public_key). + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, ourPub); + + // During receive the engine holds our private key. + crypto->setDHPrivateKey(ourPriv); +} + +void tearDown(void) +{ + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// Admin key in slot 0: a DM from an unknown sender decrypts via the fallback, and the key is persisted. +void test_admin_key_slot0_decrypts_and_persists(void) +{ + setAdminKey(0, adminPub); + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "precondition: sender is unknown to us"); + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + + assertDecodedAndLearned(&p, adminPub); +} + +// The loop scans every admin slot, not just [0]: a key provisioned only in slot 2 still works. +void test_admin_key_slot2_only_decrypts(void) +{ + setAdminKey(2, adminPub); // slots 0 and 1 left empty + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + + assertDecodedAndLearned(&p, adminPub); +} + +// No admin key configured + unknown sender: nothing decrypts, and we must NOT invent a key for anyone. +void test_no_admin_key_unknown_sender_not_decoded(void) +{ + // config (incl. admin_key) is zeroed by setUp(); ADMIN_NODE is absent from NodeDB. + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(ADMIN_NODE), "must not learn a key when nothing decrypted"); +} + +// A configured admin key that is NOT the sender's must fail authentication (no bogus key learned). +void test_wrong_admin_key_does_not_decode(void) +{ + uint8_t otherPub[32], otherPriv[32]; + crypto->generateKeyPair(otherPub, otherPriv); // unrelated key + crypto->setDHPrivateKey(ourPriv); // restore receive key (generateKeyPair changed it) + setAdminKey(0, otherPub); // admin slot holds a key that did NOT encrypt the packet + + meshtastic_MeshPacket p = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_NOT_EQUAL(meshtastic_MeshPacket_decoded_tag, p.which_payload_variant); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE)); +} + +#endif // !(MESHTASTIC_EXCLUDE_PKI) + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + UNITY_BEGIN(); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_admin_key_slot0_decrypts_and_persists); + RUN_TEST(test_admin_key_slot2_only_decrypts); + RUN_TEST(test_no_admin_key_unknown_sender_not_decoded); + RUN_TEST(test_wrong_admin_key_does_not_decode); +#endif + exit(UNITY_END()); +} + +void loop() {} From 834357e94cd3fd1ff96b932d73781d701b876509 Mon Sep 17 00:00:00 2001 From: "Ethac.chen" Date: Wed, 15 Jul 2026 19:27:04 +0800 Subject: [PATCH 23/69] Add RAK WisMesh Repeater Mini V2 and HP variants (#10918) * fix(esp32): skip RTC timer wake on user shutdown Do not arm esp_sleep_enable_timer_wakeup when msecToWake is portMAX_DELAY (UI shutdown), matching nRF52 system_off semantics. fix(rak_wismesh_tap_v2): Tag OCV curve and 16MB partition Add OCV_ARRAY matching WisMesh Tag for accurate SOC. Use 16MB flash partition scheme for TAP V2 hardware. Co-authored-by: Cursor * Trunkt * Add RAK WisMesh Repeater Mini V2 and HP variants Introduce rak_wismesh_repeater_mini (RAK4631) and rak_wismesh_repeater_mini_hp (RAK3401) with low-VDD System OFF. Skip LPCOMP battery wake on user-initiated shutdown to avoid immediate reboot near the LPCOMP threshold; keep LPCOMP for brownout-driven shutdown via variant_enableBatteryLpcompWake. * docs(variants): update RAK BOM numbers for Repeater Mini V2 & HP * chore: run trunk fmt --------- Co-authored-by: Cursor Co-authored-by: Ben Meadors --- src/platform/nrf52/main-nrf52.cpp | 36 ++++++++----- .../nrf52840/rak3401_1watt/platformio.ini | 13 +++++ variants/nrf52840/rak3401_1watt/variant.cpp | 43 +++++++++++++++ variants/nrf52840/rak3401_1watt/variant.h | 7 +++ variants/nrf52840/rak4631/platformio.ini | 12 +++++ variants/nrf52840/rak4631/variant.cpp | 52 +++++++++++++++++++ 6 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 87246ba21..ae12ef4a0 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -58,6 +58,15 @@ void variant_shutdown() {} void variant_nrf52LoopHook(void) __attribute__((weak)); void variant_nrf52LoopHook(void) {} +// Return false to skip LPCOMP wake when entering System OFF (e.g. user CLI shutdown). +// noinline: weak default and call site are in this file; without it GCC may inline the +// weak body and never link the strong override from variant.cpp. +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() __attribute__((weak)); +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() +{ + return true; +} + static nrfx_wdt_t nrfx_wdt = NRFX_WDT_INSTANCE(0); static nrfx_wdt_channel_id nrfx_wdt_channel_id_nrf52_main; @@ -496,20 +505,23 @@ void cpuDeepSleep(uint32_t msecToWake) // https://devzone.nordicsemi.com/f/nordic-q-a/48919/ram-retention-settings-with-softdevice-enabled #ifdef BATTERY_LPCOMP_INPUT - // Wake up if power rises again - nrf_lpcomp_config_t c; - c.reference = BATTERY_LPCOMP_THRESHOLD; - c.detection = NRF_LPCOMP_DETECT_UP; - c.hyst = NRF_LPCOMP_HYST_NOHYST; - nrf_lpcomp_configure(NRF_LPCOMP, &c); - nrf_lpcomp_input_select(NRF_LPCOMP, BATTERY_LPCOMP_INPUT); - nrf_lpcomp_enable(NRF_LPCOMP); + // Only enable LPCOMP wake if the variant allows it + if (variant_enableBatteryLpcompWake()) { + // Wake up if power rises again + nrf_lpcomp_config_t c; + c.reference = BATTERY_LPCOMP_THRESHOLD; + c.detection = NRF_LPCOMP_DETECT_UP; + c.hyst = NRF_LPCOMP_HYST_NOHYST; + nrf_lpcomp_configure(NRF_LPCOMP, &c); + nrf_lpcomp_input_select(NRF_LPCOMP, BATTERY_LPCOMP_INPUT); + nrf_lpcomp_enable(NRF_LPCOMP); - battery_adcEnable(); + battery_adcEnable(); - nrf_lpcomp_task_trigger(NRF_LPCOMP, NRF_LPCOMP_TASK_START); - while (!nrf_lpcomp_event_check(NRF_LPCOMP, NRF_LPCOMP_EVENT_READY)) - ; + nrf_lpcomp_task_trigger(NRF_LPCOMP, NRF_LPCOMP_TASK_START); + while (!nrf_lpcomp_event_check(NRF_LPCOMP, NRF_LPCOMP_EVENT_READY)) + ; + } #endif auto ok = sd_power_system_off(); diff --git a/variants/nrf52840/rak3401_1watt/platformio.ini b/variants/nrf52840/rak3401_1watt/platformio.ini index 889a17ed6..0220042d1 100644 --- a/variants/nrf52840/rak3401_1watt/platformio.ini +++ b/variants/nrf52840/rak3401_1watt/platformio.ini @@ -36,6 +36,19 @@ lib_deps = # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip +; RAK10729 WisMesh Repeater Mini V2 HP (RAK3401 + RAK19007 + RAK13302 + solar battery) +[env:rak_wismesh_repeater_mini_hp] +extends = env:rak3401-1watt +custom_meshtastic_display_name = RAK WisMesh Repeater Mini V2 HP +custom_meshtastic_images = rak3401.svg +build_flags = + ${env:rak3401-1watt.build_flags} + -D WISMESH_REPEATER_MINI_HP + -DMESHTASTIC_EXCLUDE_GPS=1 + -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 + -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 + -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 + ; 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 diff --git a/variants/nrf52840/rak3401_1watt/variant.cpp b/variants/nrf52840/rak3401_1watt/variant.cpp index a035fbaf0..188b747e8 100644 --- a/variants/nrf52840/rak3401_1watt/variant.cpp +++ b/variants/nrf52840/rak3401_1watt/variant.cpp @@ -23,6 +23,13 @@ #include "wiring_constants.h" #include "wiring_digital.h" +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +#include "Arduino.h" +#include "FreeRTOS.h" +#include "power/PowerHAL.h" +#include "sleep.h" +#endif + const uint32_t g_ADigitalPinMap[] = { // P0 0, 1, 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, @@ -40,3 +47,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/rak3401_1watt/variant.h b/variants/nrf52840/rak3401_1watt/variant.h index a154e6a41..329ec21e4 100644 --- a/variants/nrf52840/rak3401_1watt/variant.h +++ b/variants/nrf52840/rak3401_1watt/variant.h @@ -209,6 +209,13 @@ static const uint8_t SCK = PIN_SPI_SCK; #define AREF_VOLTAGE 3.0 #define VBAT_AR_INTERNAL AR_INTERNAL_3_0 #define ADC_MULTIPLIER 1.73 +#if defined(LOW_VDD_SYSTEMOFF_DELAY_MS) +#if !defined(OCV_ARRAY) +#define OCV_ARRAY 4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990 +#endif +#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_3 +#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_5_8 // VDD=3.3V AIN3=5/8*VDD=2.06V VBAT=1.66*AIN3=3.41V +#endif #define RAK_4631 1 diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 179d73e92..2cdaafba4 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -44,6 +44,18 @@ lib_deps = # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main https://github.com/RAKWireless/RAK12034-BMX160/archive/dcead07ffa267d3c906e9ca4a1330ab989e957e2.zip +; RAK10728 WisMesh Repeater Mini V2 (RAK4631 + RAK19003 + solar battery + GPS module) +[env:rak_wismesh_repeater_mini] +extends = env:rak4631 +custom_meshtastic_display_name = RAK WisMesh Repeater Mini V2 +custom_meshtastic_images = rak4631.svg +build_flags = + ${env:rak4631.build_flags} + -D WISMESH_REPEATER_MINI + -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 + -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 + -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 + ; 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 diff --git a/variants/nrf52840/rak4631/variant.cpp b/variants/nrf52840/rak4631/variant.cpp index a035fbaf0..eed47eb2c 100644 --- a/variants/nrf52840/rak4631/variant.cpp +++ b/variants/nrf52840/rak4631/variant.cpp @@ -23,6 +23,13 @@ #include "wiring_constants.h" #include "wiring_digital.h" +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +#include "Arduino.h" +#include "FreeRTOS.h" +#include "power/PowerHAL.h" +#include "sleep.h" +#endif + const uint32_t g_ADigitalPinMap[] = { // P0 0, 1, 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, @@ -40,3 +47,48 @@ void initVariant() pinMode(PIN_3V3_EN, OUTPUT); digitalWrite(PIN_3V3_EN, HIGH); } + +#ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +static bool lowVddSystemOff = false; // Set to true when VDD is unsafe for a while (brownout), forcing System OFF. +// Strong override; must be noinline (see main-nrf52.cpp weak default). +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() +{ + return lowVddSystemOff; +} + +// Hook called each nrf52Loop(); e.g. for low-VDD System OFF. +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) { + lowVddSystemOff = true; + cpuDeepSleep(portMAX_DELAY); + } + } else { + low_vdd_timer_armed = false; + } + } +} +#endif From 5b1c1a9e0492432d980e110c93875ce4037981b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:14:35 -0500 Subject: [PATCH 24/69] Update LovyanGFX to v1.2.25 (#10968) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/esp32/chatter2/platformio.ini | 2 +- variants/esp32/m5stack_core/platformio.ini | 2 +- variants/esp32/wiphone/platformio.ini | 2 +- variants/esp32s3/elecrow_panel/platformio.ini | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 2 +- variants/esp32s3/heltec_v4_r8/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini | 2 +- variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini | 2 +- variants/esp32s3/mesh-tab/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/platformio.ini | 2 +- variants/esp32s3/rak_wismesh_tap_v2/platformio.ini | 2 +- variants/esp32s3/t-deck/platformio.ini | 2 +- variants/esp32s3/t-watch-s3/platformio.ini | 2 +- variants/esp32s3/tlora-pager/platformio.ini | 2 +- variants/esp32s3/tracksenger/platformio.ini | 4 ++-- variants/esp32s3/unphone/platformio.ini | 2 +- variants/native/portduino.ini | 2 +- 18 files changed, 19 insertions(+), 19 deletions(-) diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index d68cc5e2a..0da8e94db 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index aa667d57b..23346bdab 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 2114d965e..401c3868d 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index c5c405c1f..1e8efd5b9 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -51,7 +51,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [crowpanel_small_esp32s3_base] ; 2.4, 2.8, 3.5 inch extends = crowpanel_base diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index e21ff6a30..2844d0aeb 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -134,6 +134,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index 4198df454..d545b55dd 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -140,6 +140,6 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 65003a061..3db282214 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -26,4 +26,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 4613a8e92..575109abe 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index d8afbfe94..fc3935be3 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index fe5086562..0b111126b 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index f47d7990d..f7aabc126 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 956c43015..3cf7bf8dd 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index aba1695ce..4983fc5c5 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index ec70148a8..e1f6814e2 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 5f243342b..607526ba3 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index 2764a27e8..20ef9e30e 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index e5bc7ead8..48317db5e 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -36,7 +36,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 98cbaf6c3..00d2a3a94 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -26,7 +26,7 @@ lib_deps = # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.24 + lovyan03/LovyanGFX@1.2.25 ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main https://github.com/pine64/libch341-spi-userspace/archive/2e5ff751d0c39667993df672cb683740ed5c9394.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library From de0380c18b836fd9182df88047099eb1ebba4484 Mon Sep 17 00:00:00 2001 From: Yellowcooln Date: Wed, 15 Jul 2026 17:42:21 -0400 Subject: [PATCH 25/69] Add PiMesh-1W V1/V2 Portduino LoRa config files (#9857) * add PiMesh-1W v1 and v2 lora config presets * Modify Lora Pimesh configuration settings Updated configuration for Lora Pimesh module with new CS pin and added comments. * Enhance header in lora-pimesh-1w-v1.yaml Updated header to include module information and URL. * Update comments in lora-pimesh-1w-v2.yaml * Add metadata to lora-pimesh-1w-v1.yaml Added metadata section with name, support, and compatibility information. * Add metadata to lora-pimesh-1w-v2.yaml Added metadata section with name, support, and compatibility. --- bin/config.d/lora-pimesh-1w-v1.yaml | 21 +++++++++++++++++++++ bin/config.d/lora-pimesh-1w-v2.yaml | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 bin/config.d/lora-pimesh-1w-v1.yaml create mode 100644 bin/config.d/lora-pimesh-1w-v2.yaml diff --git a/bin/config.d/lora-pimesh-1w-v1.yaml b/bin/config.d/lora-pimesh-1w-v1.yaml new file mode 100644 index 000000000..ceb33b5cd --- /dev/null +++ b/bin/config.d/lora-pimesh-1w-v1.yaml @@ -0,0 +1,21 @@ +# PiMesh-1W (V1) E22-900M30S +# https://meshsmith.net/products/pimesh-1w +Meta: + name: PiMesh-1W (V1) E22-900M30S + support: community + compatible: + - raspberry-pi + +Lora: + Module: sx1262 + CS: 21 + IRQ: 16 + Reset: 18 + Busy: 20 + TXen: 13 + RXen: 12 + DIO3_TCXO_VOLTAGE: true + SX126X_MAX_POWER: 22 + spidev: spidev0.0 + +# preamble_length: 17 diff --git a/bin/config.d/lora-pimesh-1w-v2.yaml b/bin/config.d/lora-pimesh-1w-v2.yaml new file mode 100644 index 000000000..b6b812f32 --- /dev/null +++ b/bin/config.d/lora-pimesh-1w-v2.yaml @@ -0,0 +1,21 @@ +# PiMesh-1W (V2) E22P-915M30S / E22P-868M30S +# https://meshsmith.net/products/pimesh-1w +Meta: + name: PiMesh-1W (V2) E22P-915M30S / E22P-868M30S + support: community + compatible: + - raspberry-pi + +Lora: + Module: sx1262 + IRQ: 6 + Reset: 18 + Busy: 5 + CS: 8 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + SX126X_MAX_POWER: 22 + spidev: spidev0.0 + +# add the following to /boot/firmware/config.txt +# gpio=26=op,dh From deea7be89ba59069d5dc8c3e373666cdd21f4870 Mon Sep 17 00:00:00 2001 From: "Ethac.chen" Date: Thu, 16 Jul 2026 20:02:57 +0800 Subject: [PATCH 26/69] Add RAK WisMesh Pocket V3 variant (rak_wismesh_pocket env) (#10953) --- variants/nrf52840/rak4631/platformio.ini | 15 +++++++++++++++ variants/nrf52840/rak4631/variant.h | 13 ++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 2cdaafba4..d0b776b3b 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -56,6 +56,21 @@ build_flags = -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 +; RAK WisMesh Pocket V3 - rak4631 pin map + OLED; no ethernet (unlike env:rak4631) +[env:rak_wismesh_pocket] +extends = env:rak4631 +custom_meshtastic_display_name = RAK WisMesh Pocket V3 +custom_meshtastic_images = rak4631.svg +build_flags = + ${env:rak4631.build_flags} + -D WISMESH_POCKET + -DLOW_VDD_SYSTEMOFF_DELAY_MS=5000 + -DSAFE_VDD_VOLTAGE_THRESHOLD_MV=2900 + -DSAFE_VDD_VOLTAGE_THRESHOLD_HYST_MV=100 +build_src_filter = ${env:rak4631.build_src_filter} + - + - + ; 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 diff --git a/variants/nrf52840/rak4631/variant.h b/variants/nrf52840/rak4631/variant.h index 8ea272a15..bdd797836 100644 --- a/variants/nrf52840/rak4631/variant.h +++ b/variants/nrf52840/rak4631/variant.h @@ -231,6 +231,11 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG #define PIN_3V3_EN (34) #define WB_IO2 PIN_3V3_EN +#if defined(WISMESH_POCKET) +// Pocket v3: P1.02 (GPIO 34) = GPS_PWR_EN +#define PIN_GPS_EN PIN_3V3_EN +#endif + // RAK1910 GPS module // If using the wisblock GPS module and pluged into Port A on WisBlock base // IO1 is hooked to PPS (pin 12 on header) = gpio 17 @@ -253,9 +258,11 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG #define PIN_BUZZER 21 // IO3 is PWM2 // NEW: set this via protobuf instead! -// RAK4631 custom ringtone +// RAK4631 custom ringtone (but not for Pocket - it uses the default ringtone) +#ifndef WISMESH_POCKET #undef USERPREFS_RINGTONE_RTTTL #define USERPREFS_RINGTONE_RTTTL "Rak:d=32,o=5,b=200:b7,p,b7,4p,p" +#endif // Battery // The battery sense is hooked to pin A0 (5) @@ -282,7 +289,11 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG // VDD=3.3V AIN3=6/8*VDD=2.47V VBAT=1.66*AIN3=4.1V #define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_11_16 +#if defined(WISMESH_POCKET) +#define HAS_ETHERNET 0 +#else #define HAS_ETHERNET 1 +#endif #define RAK_4631 1 From 75f406745ddb957a8713ad3ab3b8e24f543ef09a Mon Sep 17 00:00:00 2001 From: Federico Gimenez Molinelli <8161434+fgimenezm@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:58:02 -0300 Subject: [PATCH 27/69] Double-press: fall back to first channel with position enabled when primary precision == 0 (#11005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Double-press: fall back to first channel with position enabled when primary precision == 0 * Add missing include * Fix trunk fmt * Enhance logging for fallback nodeinfo sending --------- Co-authored-by: Thomas Göttgens --- src/mesh/MeshService.cpp | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 48ef93672..29be2769c 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -63,6 +63,7 @@ Allocator &clientNotificationPool = staticClientN Allocator &queueStatusPool = staticQueueStatusPool; +#include "PositionPrecision.h" #include "Router.h" MeshService::MeshService() @@ -348,8 +349,31 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) LOG_DEBUG("Skip position ping; no fresh position since boot"); return false; } - LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, node->channel); - positionModule->sendOurPosition(dest, wantReplies, node->channel); + // Prefer the node's current channel, but fall back to the first channel with + // position enabled (matching PositionModule::sendOurPosition() behavior). + uint8_t sendChan = node->channel; + if (getPositionPrecisionForChannel(sendChan) == 0) { + bool found = false; + for (uint8_t ch = 0; ch < 8; ++ch) { + if (getPositionPrecisionForChannel(ch) != 0) { + sendChan = ch; + found = true; + break; + } + } + if (!found) { + // No channel with position enabled: fall back to sending nodeinfo, as before. + if (nodeInfoModule) { + LOG_INFO( + "No channel with position enabled; sending nodeinfo instead to 0x%08x, wantReplies=%d, channel=%d", + dest, wantReplies, node->channel); + nodeInfoModule->sendOurNodeInfo(dest, wantReplies, node->channel); + } + return false; + } + } + LOG_INFO("Send position ping to 0x%08x, wantReplies=%d, channel=%d", dest, wantReplies, sendChan); + positionModule->sendOurPosition(dest, wantReplies, sendChan); return true; } } else { From f7fd058308112ff842f4661807cfb61c70809cfc Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Jul 2026 08:22:42 -0500 Subject: [PATCH 28/69] Fix packet-pool slot leak in canned message destination picker (#11017) updateDestinationSelectionList() allocated a MeshPacket via allocDataPacket() that was never sent or released, permanently consuming one packetPool slot every time the destination-selection picker was rebuilt. On non-PSRAM targets packetPool is a static 70-slot BSS pool, so repeated picker use exhausts it and eventually blocks all packet allocation (TX/RX failures). On PSRAM/portduino (MemoryDynamic) targets it is a true heap leak of ~424B per rebuild. The allocation and its two field writes (pki_encrypted, channel) were a copy/paste artifact of the PKI setup in sendText() and had no effect in this function. Remove the dead allocation. --- src/modules/CannedMessageModule.cpp | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 7d3116127..771066054 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -289,10 +289,6 @@ void CannedMessageModule::updateDestinationSelectionList() } } - meshtastic_MeshPacket *p = allocDataPacket(); - p->pki_encrypted = true; - p->channel = 0; - // Populate active channels std::vector seenChannels; seenChannels.reserve(channels.getNumChannels()); From f52d7753adc46ba23b5aab1075963bb150ff94ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 16 Jul 2026 15:51:10 +0200 Subject: [PATCH 29/69] Router: size the fit check from the decoded Data (#11018) --- src/mesh/Router.cpp | 30 ++++---- src/mesh/Router.h | 7 +- test/test_packet_signing/test_main.cpp | 97 ++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 23 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 24f7f586b..7ae78cbb1 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -452,7 +452,7 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout } #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) -bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) +bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) { // Only a signature we verify below may mark this packet signed; never trust an inbound flag. p->xeddsa_signed = false; @@ -483,17 +483,17 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize) return false; } else { // Truly unsigned (signature size 0) - only reject the class a signing node always signs: a - // non-PKI broadcast whose signed encoding would still fit the LoRa frame. encodedDataSize is - // the size of the encoded Data exactly as the sender built it (or 0 to size p->decoded - // canonically); with no signature field present it is the unsigned base, and adding - // XEDDSA_SIGNATURE_FIELD_BYTES mirrors the sender-side signedDataFits() gate per packet, - // whatever fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a - // signature are never signed, so they must not be hard-failed here even for a known signer. + // non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded + // canonically so this counts the same fields the sender's signedDataFits() gate counted; + // adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever + // fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature + // are never signed, so they must not be hard-failed here even for a known signer. const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { - if (encodedDataSize == 0 && !pb_get_encoded_size(&encodedDataSize, &meshtastic_Data_msg, &p->decoded)) + size_t canonicalSize; + if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded)) return true; // can't size it; never drop on a sizing failure - if (encodedDataSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { + if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); return false; } @@ -621,17 +621,17 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (decrypted) { // parsing was successful p->channel = chIndex; // change to store the index instead of the hash - if (p->decoded.has_bitfield) - p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) - // rawSize is the size of the encoded Data exactly as the sender built it (the PKI branch's - // MESHTASTIC_PKC_OVERHEAD subtraction preserves that, and PKI packets are unicast so the - // downgrade predicate ignores them anyway). - if (!checkXeddsaReceivePolicy(p, rawSize)) + // Runs before the bitfield merge below: that merge can set want_response, adding wire bytes + // the sender never encoded and skewing the policy's sizing of p->decoded. + if (!checkXeddsaReceivePolicy(p)) return DecodeState::DECODE_FAILURE; #endif + if (p->decoded.has_bitfield) + p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK; + /* Not actually ever used. // Decompress if needed. jm if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) { diff --git a/src/mesh/Router.h b/src/mesh/Router.h index 954b88f24..d5d4b76ad 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -183,16 +183,15 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p); * downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would * still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass). * - * encodedDataSize is the wire size of the encoded Data as the sender built it; pass 0 to size - * p->decoded canonically instead (for already-decoded ingress such as plaintext-MQTT downlink, - * which bypasses perhapsDecode's crypto path). + * The fit test sizes p->decoded with the real encoder, so it measures the fields the sender + * encoded rather than any raw wire length. * * The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache. * (perhapsDecode already holds it; other call sites must take it themselves.) * * @return false if the packet must be dropped. */ -bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p, size_t encodedDataSize = 0); +bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p); #endif extern Router *router; diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index 707d71619..f0eb60473 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -148,6 +148,58 @@ static bool signedEncodingFits(const meshtastic_Data *d) return encodedDataSize(©) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN; } +// Append a length-delimited field whose tag this build's Data schema does not define, as a sender +// on a newer schema would emit. nanopb skips unknown fields at decode, so these bytes count toward +// the raw wire size but not the decoded struct. Returns the number of bytes appended. +static size_t appendUnknownField(uint8_t *dst, size_t dstLen, size_t contentLen) +{ + constexpr uint32_t UNKNOWN_FIELD_NUMBER = 100; // not a field of meshtastic_Data + std::vector content(contentLen, 0x77); + pb_ostream_t stream = pb_ostream_from_buffer(dst, dstLen); + TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, UNKNOWN_FIELD_NUMBER)); + TEST_ASSERT_TRUE(pb_encode_string(&stream, content.data(), content.size())); + return stream.bytes_written; +} + +// Channel-encrypt raw Data bytes into a packet, exactly as perhapsEncode's non-PKI path does. +// Used to inject wire bytes perhapsEncode would never produce (it only encodes p->decoded). +static void encryptAsChannelPacket(meshtastic_MeshPacket *p, uint8_t *wire, size_t size) +{ + const int16_t hash = channels.setActiveByIndex(0); + TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel"); + crypto->encryptPacket(getFrom(p), p->id, size, wire); + memcpy(p->encrypted.bytes, wire, size); + p->encrypted.size = size; + p->channel = hash; // on the wire the channel field carries the hash, not the index + p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag; +} + +// Build A10's frame: an unsigned broadcast carrying a POSITION payload plus unknown fields, sized +// so the raw wire length exceeds the signature-fit threshold while the decoded fields stay under +// it. Channel-encrypted like a normal sender. The asserts pin that split, which is what makes A10 +// and A11 meaningful. +static meshtastic_MeshPacket makeBroadcastWithUnknownFields() +{ + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + + uint8_t wire[MAX_LORA_PAYLOAD_LEN + 1]; + const size_t base = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &p.decoded); + TEST_ASSERT_GREATER_THAN_MESSAGE(0, base, "failed to encode the base Data"); + const size_t raw = base + appendUnknownField(wire + base, sizeof(wire) - base, 160); + + // The decoded fields fit a signature, so a sender that signs would have signed this Data. + TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, base + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH, + "decoded fields must fit a signature, else the test is vacuous"); + // The unknown fields put the raw size over that threshold, so the two sizings disagree here. + TEST_ASSERT_GREATER_THAN_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH, + "unknown fields must push the raw size past the fit threshold"); + // The frame is still one a radio could actually send. + TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + MESHTASTIC_HEADER_LENGTH, "frame must still fit a LoRa frame"); + + encryptAsChannelPacket(&p, wire, raw); + return p; +} + // --------------------------------------------------------------------------- // Unity lifecycle // --------------------------------------------------------------------------- @@ -324,6 +376,41 @@ void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void) TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); } +// A10: unknown fields must not sway the downgrade decision. A sender on a newer schema can include +// Data fields this build doesn't define; nanopb skips them, so they grow the frame without changing +// what decodes. The decision sizes p->decoded, keeping it on the fields the sender encoded, so an +// unsigned broadcast from a known signer is dropped whether or not unknown fields came along. +void test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before + + meshtastic_MeshPacket p = makeBroadcastWithUnknownFields(); + + TEST_ASSERT_EQUAL_MESSAGE(DECODE_FAILURE, perhapsDecode(&p), + "unsigned broadcast from a signer must be dropped despite unknown fields"); +} + +// A11: A10's control - the same frame from a node we've never seen sign is accepted and still +// delivers its payload, so A10's DECODE_FAILURE is the downgrade drop and not a decode failure +// caused by the unknown fields. +void test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void) +{ + mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + + meshtastic_MeshPacket p = makeBroadcastWithUnknownFields(); + const size_t rawSize = p.encrypted.size; // read before decode: encrypted/decoded share a union + + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&p), "frame from a non-signer must still decode"); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_PortNum_POSITION_APP, p.decoded.portnum, "unknown fields must not disturb the portnum"); + TEST_ASSERT_EQUAL_MESSAGE(SMALL_PAYLOAD, p.decoded.payload.size, "payload must survive the unknown fields"); + TEST_ASSERT_FALSE(p.xeddsa_signed); + + // The unknown fields are gone from the decoded struct: the gap the sizing basis has to account for. + TEST_ASSERT_LESS_THAN_MESSAGE(rawSize, encodedDataSize(&p.decoded), + "unknown fields must drop at decode, leaving decoded size < raw"); +} + // =========================================================================== // Group B - send-side signing policy (perhapsEncode) // =========================================================================== @@ -418,7 +505,7 @@ void test_B5_preset_signature_on_local_packet_cleared(void) // B6: the exact-fit gate tracks Data *shape*, not just payload size. A tapback-style broadcast // (want_response + reply_id + emoji) carries extra wire bytes that shift the fit boundary; the // sweep proves no dead band exists for that shape either, and - once the signer bit is learned - -// that the receiver's rawSize-driven downgrade predicate stays symmetric for it too. Window +// that the receiver's downgrade predicate stays symmetric for it too. Window // straddles this shape's boundary; capped at 200 so even the unsigned rich encoding stays well // inside the frame (at n=221 it first hits the pre-existing, signing-unrelated TOO_LARGE). void test_B6_rich_shape_sweep_no_deadband(void) @@ -558,7 +645,7 @@ void test_D1_signature_field_overhead_exact(void) // =========================================================================== // Already-decoded packets never reach perhapsDecode's crypto path (it early-returns), so // plaintext-MQTT downlink applies this policy function directly at ingress (MQTT.cpp). These -// tests drive it the same way: decoded packets, encodedDataSize = 0 (canonical sizing). +// tests drive it the same way: decoded packets, sized from p->decoded exactly as the RF path is. // End-to-end MQTT wiring is covered in test_mqtt. // E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on @@ -616,8 +703,8 @@ void test_E4_decoded_bad_signature_dropped(void) TEST_ASSERT_FALSE(p.xeddsa_signed); } -// E5: unsigned oversized broadcast from a signer -> accepted (canonical sizing exempts packets -// whose signed encoding wouldn't fit, mirroring the RF-path rawSize rule). +// E5: unsigned oversized broadcast from a signer -> accepted (packets whose signed encoding +// wouldn't fit are exempt, identically to the RF path: both size p->decoded). void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void) { mockNodeDB->addNode(REMOTE_NODE); @@ -700,6 +787,8 @@ void setup() RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted); RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped); + RUN_TEST(test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped); + RUN_TEST(test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted); printf("\n=== Group B: send-side signing policy ===\n"); RUN_TEST(test_B1_local_broadcast_is_signed); From 35ab69a2d65de84d7551f101b6c992f831cc2929 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:51:47 +0100 Subject: [PATCH 30/69] Deterministic packets (#11014) --- src/mesh/PhoneAPI.cpp | 54 +++++++++++++++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 8fac33e84..4c9697114 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1201,19 +1201,49 @@ void PhoneAPI::prefetchNodeInfos() onNowHasData(0); } +namespace +{ +/// Derive a stable id for a replayed satellite-DB record. Unchanged history replayed on +/// every reconnect must not look like a new packet to the phone's history/dedup - the id +/// only changes when the underlying data (its timestamp) actually changes. +// `kind` only needs to distinguish the record types that can otherwise collide (e.g. device +// vs. environment metrics both replay as TELEMETRY_APP with the same node/last_heard) - pass +// the payload's own variant/port constant. +uint32_t makeReplayPacketId(NodeNum num, uint32_t timestamp, uint32_t kind) +{ + uint32_t h = num; + h = h * 2654435761u + timestamp; + h = h * 2654435761u + kind; + return h ? h : 1; // some clients treat id 0 as "unset" +} + +/// Populate hop_start/hop_limit from the node's real last-known hop count (if any) so a +/// replayed packet doesn't read as "heard directly" when it wasn't. +void setReplayHopFields(meshtastic_MeshPacket &pkt, const meshtastic_NodeInfoLite *header) +{ + uint8_t hopLimit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); + uint8_t hopsAway = (header && header->has_hops_away) ? header->hops_away : 0; + pkt.hop_start = hopLimit; + pkt.hop_limit = hopsAway < hopLimit ? (uint8_t)(hopLimit - hopsAway) : 0; +} +} // namespace + meshtastic_MeshPacket PhoneAPI::makeReplayPositionPacket(NodeNum num, const meshtastic_PositionLite &pos) { // Shape this exactly like a fresh live broadcast Position from the peer so the // phone runs it through its normal "live position broadcast" handler path. // to=ourNum would read as a DM-from-peer and never lands in node detail UI. meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; + const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); pkt.rx_time = pos.time; + // Stable per-node/per-fix id: replaying the same unchanged history on every + // reconnect must not look like a brand new packet to the phone's history/dedup. + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_POSITION_APP); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; @@ -1231,13 +1261,13 @@ meshtastic_MeshPacket PhoneAPI::makeReplayTelemetryPacket(NodeNum num, const mes meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); // No native timestamp on telemetry packets here; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_device_metrics_tag); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - iOS client filters // TRANSPORT_INTERNAL packets out of broadcast peer state updates. @@ -1337,12 +1367,12 @@ meshtastic_MeshPacket PhoneAPI::makeReplayEnvironmentPacket(uint32_t num, const meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_Telemetry_environment_metrics_tag); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - iOS client filters // TRANSPORT_INTERNAL packets out of broadcast peer state updates. @@ -1400,13 +1430,13 @@ meshtastic_MeshPacket PhoneAPI::makeReplayStatusPacket(uint32_t num, const mesht meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_default; pkt.from = num; pkt.to = NODENUM_BROADCAST; - pkt.id = generatePacketId(); // StatusMessage has no native timestamp; use last_heard. const meshtastic_NodeInfoLite *header = nodeDB->getMeshNode(num); pkt.rx_time = header ? header->last_heard : 0; + pkt.id = makeReplayPacketId(num, pkt.rx_time, meshtastic_PortNum_NODE_STATUS_APP); pkt.channel = 0; - pkt.hop_limit = Default::getConfiguredOrDefaultHopLimit(config.lora.hop_limit); - pkt.hop_start = pkt.hop_limit; + pkt.rx_snr = header ? header->snr : 0; + setReplayHopFields(pkt, header); pkt.priority = meshtastic_MeshPacket_Priority_BACKGROUND; // Mark as if heard over the air, not internally generated - client filters pkt.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; From 36bfb86937cb43403f1ad34f277eae5c2367a68e Mon Sep 17 00:00:00 2001 From: Oleksandr Kravchuk Date: Thu, 16 Jul 2026 15:52:50 +0200 Subject: [PATCH 31/69] Remove UA_868 as obsolete (#10994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decree of the Cabinet of Ministers of Ukraine dated February 25, 2026,* No. 262, on amendments to Section 2 of the Plan for the Allocation and Use of the Radio Frequency Spectrum in Ukraine, has harmonised Ukrainian regulations on the 868 MHz spectrum with the EU's, thereby making UA_868 legally obsolete. * https://zakon.rada.gov.ua/laws/show/262-2026-п Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com> --- ...ra_region_preset_compatibility_client_spec.md | 16 ++++++++-------- src/graphics/draw/MenuHandler.cpp | 1 - .../InkHUD/Applets/System/Menu/MenuAction.h | 1 - .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 5 ----- src/mesh/NodeDB.cpp | 6 ++++++ src/mesh/RadioInterface.cpp | 4 +--- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index 0a818ff41..d2debef3f 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -257,14 +257,14 @@ so they are stable as listed here: `region_groups` (region → group_index): -| group | regions | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 | -| 1 | EU_868 | -| 2 | EU_866 | -| 3 | EU_N_868 | -| 4 | ITU1_2M, ITU2_2M, ITU3_2M | -| 5 | ITU2_125CM | +| group | regions | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 | +| 1 | EU_868 | +| 2 | EU_866 | +| 3 | EU_N_868 | +| 4 | ITU1_2M, ITU2_2M, ITU3_2M | +| 5 | ITU2_125CM | > Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups > because they differ in `licensed_only`. Decoders must key on the group, not on the preset diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index e6f1fa10e..349fe9c4a 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -242,7 +242,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration) {"TH", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_TH}, {"LORA_24", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_LORA_24}, {"UA_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_UA_433}, - {"UA_868", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_UA_868}, {"MY_433", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_MY_433}, {"MY_919", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_MY_919}, {"SG_923", OptionsAction::Select, meshtastic_Config_LoRaConfig_RegionCode_SG_923}, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h index 26befddc6..3b7606e12 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuAction.h @@ -57,7 +57,6 @@ enum MenuAction { SET_REGION_TH, SET_REGION_LORA_24, SET_REGION_UA_433, - SET_REGION_UA_868, SET_REGION_MY_433, SET_REGION_MY_919, SET_REGION_SG_923, diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 6c1a1c79e..1d4ac8355 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -835,10 +835,6 @@ void InkHUD::MenuApplet::execute(MenuItem item) applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_433); break; - case SET_REGION_UA_868: - applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_UA_868); - break; - case SET_REGION_MY_433: applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode_MY_433); break; @@ -1735,7 +1731,6 @@ void InkHUD::MenuApplet::showPage(MenuPage page) items.push_back(MenuItem("TH", MenuAction::SET_REGION_TH, MenuPage::EXIT)); items.push_back(MenuItem("LoRa 2.4", MenuAction::SET_REGION_LORA_24, MenuPage::EXIT)); items.push_back(MenuItem("UA 433", MenuAction::SET_REGION_UA_433, MenuPage::EXIT)); - items.push_back(MenuItem("UA 868", MenuAction::SET_REGION_UA_868, MenuPage::EXIT)); items.push_back(MenuItem("MY 433", MenuAction::SET_REGION_MY_433, MenuPage::EXIT)); items.push_back(MenuItem("MY 919", MenuAction::SET_REGION_MY_919, MenuPage::EXIT)); items.push_back(MenuItem("SG 923", MenuAction::SET_REGION_SG_923, MenuPage::EXIT)); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index e53ebb699..477f33d22 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -478,6 +478,12 @@ NodeDB::NodeDB() LOG_DEBUG("Number of Device Reboots: %d", myNodeInfo.reboot_count); #endif + // UA_868 is obsolete; migrate to EU_868 before resetRadioConfig() below validates the region. + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UA_868) { + LOG_INFO("UA_868 region is obsolete, migrating saved config to EU_868"); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + } + resetRadioConfig(); // If bogus settings got saved, then fix them // nodeDB->LOG_DEBUG("region=%d, NODENUM=0x%x, dbsize=%d", config.lora.region, myNodeInfo.my_node_num, numMeshNodes); diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 84d0f7906..b577a7f35 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -184,11 +184,9 @@ const RegionInfo regions[] = { /* 433,05-434,7 Mhz 10 mW - 868,0-868,6 Mhz 25 mW - https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf + https://zakon.rada.gov.ua/laws/show/262-2026-п */ RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD, PRESET(LONG_FAST), 0), - RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD, PRESET(LONG_FAST), 0), /* Malaysia From 53a6b5e01fe7c3b9aa6e876a6a556225f9daf24a Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 16 Jul 2026 21:56:46 +0800 Subject: [PATCH 32/69] Auto-enable nanopb PB_NO_ERRMSG whenever DEBUG_MUTE is set (#10990) DEBUG_MUTE (set for all of stm32) already compiles every LOG_* call out entirely at the preprocessor stage, but nanopb's own error-message strings are a separate mechanism it doesn't touch - PB_RETURN_ERROR still embeds descriptive text in .rodata regardless of whether anything ever logs it. Rather than hardcoding -DPB_NO_ERRMSG=1 next to every place DEBUG_MUTE is set, derive it in bin/platformio-custom.py via the SCons build environment, mirroring the existing meshtastic-device-ui/APP_VERSION CPPDEFINES pattern already in that file. This reaches both the main project env and the Nanopb library builder specifically, since nanopb is a separate LibraryBuilder whose own CPPDEFINES aren't otherwise touched by a plain env.Append() on the app env. The CPPDEFINES membership check normalizes both bare (-DDEBUG_MUTE) and value-bearing (-DDEBUG_MUTE=1) forms, since SCons represents the latter as a tuple. DEBUG_MUTE currently only appears in variants/stm32/stm32.ini and variants/stm32/milesight_gs301/platformio.ini, so today this only affects stm32wl builds - but it will apply automatically to any future platform that sets DEBUG_MUTE too, without that platform's .ini needing to know about the pairing. Saves 1,248 bytes flash on wio-e5, no RAM change, no feature loss beyond terser protobuf decode/encode error text. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong --- bin/platformio-custom.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bin/platformio-custom.py b/bin/platformio-custom.py index cb32f18a4..08d010e08 100644 --- a/bin/platformio-custom.py +++ b/bin/platformio-custom.py @@ -340,6 +340,18 @@ for lb in env.GetLibBuilders(): lb.env.Append(CPPDEFINES=[("APP_VERSION", verObj["long"])]) break +# DEBUG_MUTE already compiles every LOG_* call out entirely; nanopb's own error-message +# strings are a separate mechanism it doesn't touch, so mirror the same intent into nanopb. +debug_mute_defined = any( + d == "DEBUG_MUTE" or (isinstance(d, tuple) and d[0] == "DEBUG_MUTE") for d in env.get("CPPDEFINES", []) +) +if debug_mute_defined: + projenv.Append(CPPDEFINES=[("PB_NO_ERRMSG", 1)]) + for lb in env.GetLibBuilders(): + if lb.name == "Nanopb": + lb.env.Append(CPPDEFINES=[("PB_NO_ERRMSG", 1)]) + break + # Get the display resolution from macros def get_display_resolution(build_flags): # Check "DISPLAY_SIZE" to determine the screen resolution From 43084873a693f038c3dced11061b77ede10e1e35 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Jul 2026 13:02:43 -0500 Subject: [PATCH 33/69] nRF52 BLE: restore pairing passkey callback on re-enable (#11027) shutdown() installs onUnwantedPairing to actively refuse pairing (used on the factory-reset / BT-disable teardown path), but the correct callback (onPairingPasskey) is installed only in setup(). Re-enabling BLE on an already-constructed nrf52Bluetooth goes through resumeAdvertising(), not setup() (main-nrf52.cpp), which only re-armed advertising and never restored the pairing callback. So a shutdown()->resumeAdvertising() cycle without a reboot left the device silently refusing all pairing until the next reboot. Restore the correct pairing passkey callback in resumeAdvertising(), guarded by the same config.bluetooth.mode check setup() uses. Only PIN modes drive a passkey-display callback; NO_PIN (Just Works) never invokes it, so no restore is needed there. Reachable today only via the PowerStress module's BT_OFF/BT_ON opcodes (normal runtime BT-disable paths reboot), so low severity, but a real teardown-latches / re-enable-doesn't-restore asymmetry. --- src/platform/nrf52/NRF52Bluetooth.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index dd0230100..357c1484c 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -369,6 +369,15 @@ void NRF52Bluetooth::setup() } void NRF52Bluetooth::resumeAdvertising() { + // shutdown() latches onUnwantedPairing to actively refuse pairing (used on the factory-reset / + // BT-disable teardown path). The real pairing passkey callback is installed only in setup(), but a + // shutdown()->resumeAdvertising() re-enable cycle skips setup() entirely (see setBluetoothEnable() in + // main-nrf52.cpp), so restore the correct callback here - otherwise the device silently refuses all + // pairing until the next reboot. Mirror the mode check in setup(): only PIN modes drive a passkey- + // display callback, so NO_PIN (Just Works) needs no restore. + if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) + Bluefruit.Security.setPairPasskeyCallback(NRF52Bluetooth::onPairingPasskey); + Bluefruit.Advertising.restartOnDisconnect(true); Bluefruit.Advertising.setInterval(32, 668); // in unit of 0.625 ms Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast mode From e219e24b09502d6a4aa0ac876ed4ad482e0f8b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 16 Jul 2026 21:46:46 +0200 Subject: [PATCH 34/69] WarmNodeStore: carry the XEdDSA signer flag through the warm tier (#11020) --- src/mesh/NodeDB.cpp | 12 ++-- src/mesh/WarmNodeStore.cpp | 96 +++++++++++++++----------- src/mesh/WarmNodeStore.h | 32 ++++++--- test/test_nodedb_blocked/test_main.cpp | 29 ++++++++ test/test_warm_store/test_main.cpp | 92 ++++++++++++++++++++++-- 5 files changed, 204 insertions(+), 57 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 477f33d22..9c736312e 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1858,7 +1858,8 @@ void NodeDB::cleanupMeshDB() // Keep any key we learned (e.g. via a DM before the NodeInfo // exchange completed) rather than losing it with the purge. if (n.public_key.size == 32) - warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n)); + warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n), + nodeInfoLiteHasXeddsaSigned(&n)); #endif eraseNodeSatellites(gone); @@ -2042,7 +2043,7 @@ void NodeDB::demoteOldestHotNodesToWarm() // Keep the public key if we have one (40 B warm record); keyless nodes // still get a placeholder so re-admission restores last_heard. warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role, - warmProtectedCategory(n)); + warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n)); // Demotion drops the node from the header table, so drop its satellites // too (the eviction chokepoint) - they'd otherwise orphan until the next // enforceSatelliteCaps pass. @@ -3734,7 +3735,7 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // Demote to the warm tier so the identity (and crucially the // PKI key) outlives the hot-store slot. warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL, - evicted.role, warmProtectedCategory(evicted)); + evicted.role, warmProtectedCategory(evicted), nodeInfoLiteHasXeddsaSigned(&evicted)); #endif eraseNodeSatellites(evicted.num); // Shove the remaining nodes down the chain @@ -3763,10 +3764,13 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) // Re-admission: restore what the warm tier kept for this node WarmNodeEntry warm; if (warmStore.take(n, warm)) { - lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits + lite->last_heard = warmTimeOf(warm); // mask off the stolen metadata bits // Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT // until the next NodeInfo arrives. lite->role = static_cast(warmRoleOf(warm)); + // Restore the signer bit too: it is learned from verified traffic, not from + // NodeInfo, so a round trip through the warm tier must not relearn it from zero. + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmSignerOf(warm)); if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) { lite->public_key.size = 32; memcpy(lite->public_key.bytes, warm.public_key, 32); diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index 550549ad5..cce88e6b0 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -13,12 +13,11 @@ #if defined(NRF52840_XXAA) #include "flash/flash_nrf5x.h" -#define WARM_RING_MAGIC 0x324E5257u // "WRN2" - v2: last_heard low bits carry role + protected category +#define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + signer +#define WARM_RING_MAGIC_V2 0x324E5257u // "WRN2" - v2: role + protected only; bit 6 was still timestamp. #define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" - v1: last_heard was a plain timestamp. -// v1 pages are still read on upgrade: we keep each record's identity + public key but -// DISCARD its last_heard (the old timestamp would be misread as role/protected bits). -// Records re-rank and re-learn their role on the next contact. Legacy pages convert to -// v2 naturally as the ring rotates. +// Older pages are still read on upgrade: v1 keeps identity + key but discards last_heard, +// v2 keeps the word but clears bit 6, which was still part of the timestamp then. // A tombstone is an entry record whose last_heard is all-ones - getTime() // (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is // detected via num == 0xFFFFFFFF before last_heard is ever inspected. @@ -34,10 +33,13 @@ struct WarmStoreHeader { }; static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format"); -#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" - v2: last_heard low bits carry role + protected category +#define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + signer +#define WARM_STORE_MAGIC_V2 \ + 0x324D5257u // "WRM2" - v2: role + protected only; bit 6 was still timestamp. On upgrade + // we clear the signer bit, then rewrite as v3. #define WARM_STORE_MAGIC_V1 \ 0x314D5257u // "WRM1" - v1: last_heard was a plain timestamp. On upgrade we keep - // identity + key but discard last_heard, then rewrite as v2. + // identity + key but discard last_heard, then rewrite as v3. #ifdef FSCom static const char *warmFileName = "/prefs/warm.dat"; @@ -134,18 +136,18 @@ WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8 return slot; } -bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat) +bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat, bool signer) { - // Pack role + protected category into the low bits of last_heard. place() and ring - // replay store the raw word verbatim, so the metadata round-trips through flash. - const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat); + // Pack role + protected category + signer into the low bits of last_heard. place() and + // ring replay store the raw word verbatim, so the metadata round-trips through flash. + const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat, signer); const WarmNodeEntry *slot = place(num, packed, key32); if (!slot) return false; persistEntry(*slot); - LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num, + LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u signer=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat, - (unsigned)count(), (unsigned)capacity()); + signer ? 1u : 0u, (unsigned)count(), (unsigned)capacity()); return true; } @@ -258,19 +260,24 @@ bool WarmNodeStore::saveIfDirty() // (stranded live entries re-appended, then erased). Flash access holds spiLock - // the page cache is shared with InternalFS/LittleFS. -bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const +bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt) const { flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h)); if (h.seq == 0xFFFFFFFFu) return false; // erased page if (h.magic == WARM_RING_MAGIC) { - if (legacy) - *legacy = false; + if (fmt) + *fmt = WarmFormat::Current; + return true; + } + if (h.magic == WARM_RING_MAGIC_V2) { + if (fmt) + *fmt = WarmFormat::V2; // replay it, but clear the signer bit (see WARM_RING_MAGIC_V2) return true; } if (h.magic == WARM_RING_MAGIC_V1) { - if (legacy) - *legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1) + if (fmt) + *fmt = WarmFormat::V1; // replay it, but discard last_heard (see WARM_RING_MAGIC_V1) return true; } return false; @@ -386,13 +393,13 @@ void WarmNodeStore::load() // Order valid pages by ascending seq so replay applies oldest first uint8_t order[WARM_FLASH_PAGES] = {}; uint32_t seqs[WARM_FLASH_PAGES] = {}; - bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay + WarmFormat fmtOf[WARM_FLASH_PAGES] = {}; // per-page on-flash format; older ones are normalised on replay uint8_t nValid = 0; uint8_t nCorrupt = 0; for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) { WarmPageHeader h; - bool legacy = false; - if (!ringReadHeader(p, h, &legacy)) { + WarmFormat fmt = WarmFormat::Current; + if (!ringReadHeader(p, h, &fmt)) { // An erased page reads back all-ones; any other magic is a // partially-written or bit-rotted header we're dropping, so flag it // rather than silently treating the loss as a clean empty ring. @@ -400,7 +407,7 @@ void WarmNodeStore::load() nCorrupt++; continue; } - legacyOf[p] = legacy; + fmtOf[p] = fmt; uint8_t pos = nValid; while (pos > 0 && static_cast(h.seq - seqs[pos - 1]) < 0) { order[pos] = order[pos - 1]; @@ -427,7 +434,7 @@ void WarmNodeStore::load() uint32_t migrated = 0; for (uint8_t k = 0; k < nValid; k++) { const uint8_t p = order[k]; - const bool legacy = legacyOf[p]; + const WarmFormat fmt = fmtOf[p]; uint16_t slot = 0; for (; slot < kRecordsPerPage; slot++) { WarmNodeEntry rec; @@ -444,12 +451,15 @@ void WarmNodeStore::load() memset(e, 0, sizeof(*e)); } } else { - // v1 (legacy) record: keep identity + key, but discard the old timestamp - - // its low bits would otherwise be misread as role/protected metadata. + // Normalise older records: v1's timestamp would be misread as role/protected, + // and v2's bit 6 as a signer we never verified. uint32_t lh = rec.last_heard; - if (legacy) { + if (fmt == WarmFormat::V1) { lh = 0; migrated++; + } else if (fmt == WarmFormat::V2) { + lh &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); + migrated++; } const WarmNodeEntry *e = place(rec.num, lh, rec.public_key); if (e) @@ -460,17 +470,16 @@ void WarmNodeStore::load() activePage = p; writeSlot = slot; nextSeq = seqs[k] + 1; - // If the head is a v1 page, force the next append to rotate into a fresh v2 page, - // so new (v2) records never land in a page whose header says v1 (which would make - // a later load discard their last_heard - including the role/protected we just set). - if (legacy) + // Rotate rather than append into an older-format page: a later load would + // normalise the new records to that page's format and drop metadata. + if (fmt != WarmFormat::Current) writeSlot = kRecordsPerPage; } } if (nCorrupt) LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt); if (migrated) - LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated); + LOG_INFO("WarmStore: migrated %u legacy record(s) to the current format", (unsigned)migrated); LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(), activePage, writeSlot); } @@ -537,10 +546,14 @@ void WarmNodeStore::load() LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName); return; } - // v1 (WRM1) is still accepted: same record size, but its last_heard was a plain - // timestamp. We keep identity + key and discard last_heard on load (see below). - const bool legacy = (h.magic == WARM_STORE_MAGIC_V1); - if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { + // Older snapshots are still accepted: same record size, fewer metadata bits in + // last_heard. Both are normalised to the current format below. + const bool known = h.magic == WARM_STORE_MAGIC || h.magic == WARM_STORE_MAGIC_V2 || h.magic == WARM_STORE_MAGIC_V1; + const WarmFormat fmt = h.magic == WARM_STORE_MAGIC_V1 ? WarmFormat::V1 + : h.magic == WARM_STORE_MAGIC_V2 ? WarmFormat::V2 + : WarmFormat::Current; + const bool legacy = fmt != WarmFormat::Current; + if (!known || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { f.close(); LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic, h.entrySize, h.count); @@ -560,19 +573,24 @@ void WarmNodeStore::load() memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry)); return; } - if (legacy) { - // Migrate v1 → v2: discard the old last_heard (its low bits would be misread as - // role/protected); keep num + public_key. Mark dirty so save() rewrites as v2. + // Normalise older records, then mark dirty so save() rewrites in the current format: + // v1's timestamp would be misread as role/protected, v2's bit 6 as an unverified signer. + if (fmt == WarmFormat::V1) { for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num) entries[i].last_heard = 0; dirty = true; + } else if (fmt == WarmFormat::V2) { + for (size_t i = 0; i < WARM_NODE_COUNT; i++) + if (entries[i].num) + entries[i].last_heard &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); + dirty = true; } } else { f.close(); } LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName, - legacy ? " (v1 migrated: discarded last_heard)" : ""); + legacy ? " (migrated from an older format)" : ""); } bool WarmNodeStore::save() diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 4cacbbc0c..96bfd3ec0 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -43,27 +43,33 @@ static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B - persi // // The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute // recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry -// the evicted node's device role + a protected category, at zero cost to record size -// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds -// timestamp quantised to (1 << WARM_META_BITS) seconds. +// the evicted node's device role, a protected category + a signer flag, at zero cost to +// record size (entry stays 40 B; no RAM/flash growth). The high bits remain a real +// unix-seconds timestamp quantised to (1 << WARM_META_BITS) seconds. // // Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before // 2106, and tombstones/erased flash are detected via num before last_heard is read. Only // the LOW bits are stolen - the high (era) bits are untouched, so the time range is intact. -static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2) -static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum -static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0 +static constexpr uint32_t WARM_META_BITS = 7; // role(4) + protected(2) + signer(1) +static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x7F → 128 s quantum +static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFF80 static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12) static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category static constexpr uint32_t WARM_PROT_MASK = 0x03u; +static constexpr uint32_t WARM_SIGNER_SHIFT = 6; // bit [6] we verified an XEdDSA signature from this node +static constexpr uint32_t WARM_SIGNER_MASK = 0x01u; + +// On-disk record format, from the page/file magic; older ones are normalised by load(). +enum class WarmFormat : uint8_t { Current, V2, V1 }; // Protected category cached alongside role so consumers needn't re-derive the mapping. enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 }; -inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot) +inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer) { return (lastHeard & WARM_TIME_MASK) | (static_cast(role) & WARM_ROLE_MASK) | - ((static_cast(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT); + ((static_cast(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT) | + ((signer ? WARM_SIGNER_MASK : 0u) << WARM_SIGNER_SHIFT); } inline uint32_t warmTimeOf(const WarmNodeEntry &e) { @@ -77,6 +83,10 @@ inline uint8_t warmProtOf(const WarmNodeEntry &e) { return static_cast((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK); } +inline bool warmSignerOf(const WarmNodeEntry &e) +{ + return ((e.last_heard >> WARM_SIGNER_SHIFT) & WARM_SIGNER_MASK) != 0; +} // Gated on NRF52840_XXAA: the ring sits at 0xEA000 // valid only on the 1 MB-flash nRF52840. @@ -99,9 +109,11 @@ class WarmNodeStore /// entries; otherwise the oldest (keyless-first) entry is replaced. /// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12) /// @param protectedCat WarmProtected category cached for the hop-trim path + /// @param signer true if we ever verified an XEdDSA signature from this node, so + /// re-admission restores the bit rather than relearning it /// @return true if the node was stored or updated bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0, - uint8_t protectedCat = 0); + uint8_t protectedCat = 0, bool signer = false); /// Look up the cached device role + protected category for a warm node. /// @return false if the node is not in the warm tier. @@ -167,7 +179,7 @@ class WarmNodeStore void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */); void ringRotate(); // reclaim oldest page, compacting stranded live entries void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++) - bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const; + bool ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt = nullptr) const; #endif bool save(); diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index a67ba920e..04c1933de 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -132,6 +132,34 @@ static void test_migration_carriesRoleAndProtectedIntoWarm(void) TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } +// The signer bit is learned from verified traffic, not NodeInfo, so it must survive a warm +// round trip. The plain node is the control: re-admission restores it, it doesn't invent it. +static void test_migration_carriesSignerBitThroughWarm(void) +{ + db->seedSelf(); + const NodeNum signerNum = 2000 + 3; + const NodeNum plainNum = 2000 + 4; + const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted + for (int i = 1; i <= extra; i++) + db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true, /*withKey=*/true); + nodeInfoLiteSetBit(db->getMeshNode(signerNum), NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + TEST_ASSERT_TRUE(nodeInfoLiteHasXeddsaSigned(db->getMeshNode(signerNum))); + + db->runDemote(); + + // Both are out of the hot store and held in the warm tier. + TEST_ASSERT_NULL(db->getMeshNode(signerNum)); + TEST_ASSERT_NULL(db->getMeshNode(plainNum)); + + const meshtastic_NodeInfoLite *back = db->getOrCreateMeshNode(signerNum); + TEST_ASSERT_NOT_NULL(back); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(back), "signer bit must survive a warm-tier round trip"); + + const meshtastic_NodeInfoLite *plainBack = db->getOrCreateMeshNode(plainNum); + TEST_ASSERT_NOT_NULL(plainBack); + TEST_ASSERT_FALSE_MESSAGE(nodeInfoLiteHasXeddsaSigned(plainBack), "re-admission must not invent the signer bit"); +} + // Favourite handling: a favourite is never the eviction victim, even when it is // the oldest node in a full hot store. static void test_eviction_preservesFavorite(void) @@ -206,6 +234,7 @@ NDB_TEST_ENTRY void setup() UNITY_BEGIN(); RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf); RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm); + RUN_TEST(test_migration_carriesSignerBitThroughWarm); RUN_TEST(test_eviction_preservesFavorite); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); diff --git a/test/test_warm_store/test_main.cpp b/test/test_warm_store/test_main.cpp index e26d9ee45..389ddc560 100644 --- a/test/test_warm_store/test_main.cpp +++ b/test/test_warm_store/test_main.cpp @@ -116,15 +116,15 @@ void test_ws_keyedCandidate_evictsOldestKeylessFirst() // Fill with keyed entries except two keyless ones in the middle for (size_t i = 0; i < ws.capacity(); i++) { const bool keyless = (i == 5 || i == 10); - // Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives - // quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). - TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key)); + // One quantum apart (shift by WARM_META_BITS) so LRU order survives quantisation: + // keyless i=10 is oldest (50), i=5 next (60), keyed all older (10). + TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << WARM_META_BITS, keyless ? NULL : key)); } // Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50), // even though every keyed entry is older (ts=10) uint8_t k2[32]; makeKey(k2, 0x43); - TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2)); + TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << WARM_META_BITS, k2)); TEST_ASSERT_FALSE(ws.contains(0x1000 + 10)); TEST_ASSERT_TRUE(ws.contains(0x1000 + 5)); TEST_ASSERT_TRUE(ws.contains(0x8888)); @@ -171,6 +171,31 @@ void test_ws_meta_roundTrip() TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot); } +// The signer flag rides the same packed word as role/protected, so it must survive a +// round trip without disturbing them (or the quantised timestamp). +void test_ws_signer_roundTrip() +{ + WarmNodeStore ws; + uint8_t key[32]; + makeKey(key, 0x78); + TEST_ASSERT_TRUE(ws.absorb(0x710, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true)); + TEST_ASSERT_TRUE(ws.absorb(0x711, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/false)); + + WarmNodeEntry e; + TEST_ASSERT_TRUE(ws.take(0x710, e)); + TEST_ASSERT_TRUE_MESSAGE(warmSignerOf(e), "signer flag must round trip"); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); // and must not disturb its neighbours in the word + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); + TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e)); + + // Control: without the flag the same entry reads back clear, so the accessor is + // reporting the stored bit rather than always-true. + TEST_ASSERT_TRUE(ws.take(0x711, e)); + TEST_ASSERT_FALSE(warmSignerOf(e)); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); +} + void test_ws_remove_and_clear() { WarmNodeStore ws; @@ -257,6 +282,63 @@ void test_ws_v1_migration_discardsLastHeard() b.saveIfDirty(); } +// A v2 (WRM2) warm.dat used bit 6 as a timestamp bit, so loading one must not read it as a +// signer, while role/protected/time carry over. File backend only. +void test_ws_v2_migration_clearsSignerBit() +{ + WarmNodeStore a; + uint8_t key[32], got[32]; + makeKey(key, 0x67); + // signer=true sets bit 6, standing in for a v2 record whose timestamp had it set. + a.absorb(0x910, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role, /*signer=*/true); + if (!a.saveIfDirty()) { + TEST_IGNORE_MESSAGE("Filesystem not available in this test environment"); + return; + } + + // Flip the 4-byte header magic to v2 ("WRM2"). CRC covers only the entry bytes, so + // patching the header keeps it valid. + std::vector buf; + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ); + if (!f) { + TEST_IGNORE_MESSAGE("warm.dat not readable in this environment"); + return; + } + buf.resize(f.size()); + const size_t got = f.read(buf.data(), buf.size()); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(buf.size(), got, "short read patching warm.dat"); + } + TEST_ASSERT_TRUE(buf.size() >= 4); + const uint32_t v2magic = 0x324D5257u; // "WRM2" + memcpy(buf.data(), &v2magic, sizeof(v2magic)); + { + auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE); + TEST_ASSERT_TRUE((bool)f); + const size_t wrote = f.write(buf.data(), buf.size()); + f.close(); + TEST_ASSERT_EQUAL_MESSAGE(buf.size(), wrote, "short write patching warm.dat"); + } + + WarmNodeStore b; + b.load(); + TEST_ASSERT_TRUE(b.contains(0x910)); + TEST_ASSERT_TRUE(b.copyKey(0x910, got)); + TEST_ASSERT_EQUAL_MEMORY(key, got, 32); + + WarmNodeEntry e; + TEST_ASSERT_TRUE(b.take(0x910, e)); + TEST_ASSERT_FALSE_MESSAGE(warmSignerOf(e), "a v2 timestamp bit must not read as a signer"); + // Unlike v1, v2 kept role/protected/time in place, so they survive the migration. + TEST_ASSERT_EQUAL(123456u & WARM_TIME_MASK, warmTimeOf(e)); + TEST_ASSERT_EQUAL(5, warmRoleOf(e)); + TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e)); + + b.clear(); + b.saveIfDirty(); +} + // Shrink safety: a warm.dat snapshot recording more entries than this build's // WARM_NODE_COUNT (e.g. written before a per-platform tier reduction) must be // rejected cleanly at the header check - load() starts empty instead of reading @@ -314,9 +396,11 @@ WS_TEST_ENTRY void setup() RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst); RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless); RUN_TEST(test_ws_meta_roundTrip); + RUN_TEST(test_ws_signer_roundTrip); RUN_TEST(test_ws_remove_and_clear); RUN_TEST(test_ws_persistence_roundTrip); RUN_TEST(test_ws_v1_migration_discardsLastHeard); + RUN_TEST(test_ws_v2_migration_clearsSignerBit); RUN_TEST(test_ws_load_rejectsOversizedSnapshot); exit(UNITY_END()); } From 381f9c196d19c940a9fc41d36004e984b6231f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 16 Jul 2026 21:48:25 +0200 Subject: [PATCH 35/69] NodeDB: clear only the slots removeNodeByNum() vacates (#11022) --- src/mesh/NodeDB.cpp | 14 ++++++++--- test/test_nodedb_blocked/test_main.cpp | 35 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 9c736312e..f9f04f487 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1597,10 +1597,16 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) removed++; } numMeshNodes -= removed; - std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + 1, - meshtastic_NodeInfoLite()); - if (removed) - eraseNodeSatellites(nodeNum); + if (removed) { + // Clear exactly the slots compaction vacated. Sizing this from `removed` (rather than a + // fixed one) keeps it inside the vector when nothing matched and the store is full. + const size_t first = numMeshNodes; + const size_t last = std::min(first + removed, nodeDatabase.nodes.size()); + std::fill(nodeDatabase.nodes.begin() + first, nodeDatabase.nodes.begin() + last, meshtastic_NodeInfoLite()); + } + // Drop the node's satellite stores and warm-tier copy regardless of which tier it lived in, so + // an explicit removal fully forgets it. + eraseNodeSatellites(nodeNum); #if WARM_NODE_COUNT > 0 // Explicit user removal: don't let the warm tier resurrect the node warmStore.remove(nodeNum); diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp index 04c1933de..88d7f0259 100644 --- a/test/test_nodedb_blocked/test_main.cpp +++ b/test/test_nodedb_blocked/test_main.cpp @@ -225,6 +225,39 @@ static void test_protectedCap_refusesBeyondLimit(void) TEST_ASSERT_TRUE(db->setProtectedFlag(already, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)); } +// removeNodeByNum() compacts survivors down and clears the slots that leaves free. A full +// store with no matching node frees none, so there is nothing past the last node to clear. +static void test_removeNodeByNum_absentNodeOnFullDb(void) +{ + db->seedSelf(); + for (int i = 1; i < MAX_NUM_NODES; i++) // fill to MAX_NUM_NODES total (incl. self) + db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true); + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); + + db->removeNodeByNum(0xDEADBEEF); // absent; ASan flags a write past the last slot + + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); // nothing removed + TEST_ASSERT_NOT_NULL(db->getMeshNode(0x0BADF00D)); // self intact + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 1)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // last slot intact +} + +// Control for the above: a matching node on a full store is still removed, the survivors +// compact down, and the freed tail slot is cleared. +static void test_removeNodeByNum_presentNodeOnFullDb(void) +{ + db->seedSelf(); + for (int i = 1; i < MAX_NUM_NODES; i++) + db->push(8000 + i, /*last_heard=*/i, false, false, /*withUser=*/true, /*withKey=*/true); + + db->removeNodeByNum(8000 + 5); + + TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 1, (int)db->getNumMeshNodes()); + TEST_ASSERT_NULL(db->getMeshNode(8000 + 5)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + 4)); + TEST_ASSERT_NOT_NULL(db->getMeshNode(8000 + MAX_NUM_NODES - 1)); // survivors kept +} + NDB_TEST_ENTRY void setup() { initializeTestEnvironment(); @@ -238,6 +271,8 @@ NDB_TEST_ENTRY void setup() RUN_TEST(test_eviction_preservesFavorite); RUN_TEST(test_ignored_survivesEvictionAndCleanup); RUN_TEST(test_protectedCap_refusesBeyondLimit); + RUN_TEST(test_removeNodeByNum_absentNodeOnFullDb); + RUN_TEST(test_removeNodeByNum_presentNodeOnFullDb); exit(UNITY_END()); } NDB_TEST_ENTRY void loop() {} From 5cf85d523a205fb42d9ac1d34baf02c763bfacca Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Jul 2026 15:13:26 -0500 Subject: [PATCH 36/69] fix: heap leaks on display wake and BLE re-enable; remove dead PacketCache (#11019) * fix(display): don't leak a TFT driver instance on every screen wake On variants whose Screen::handleSetOn() re-runs ui->init() on each display wake (Heltec Tracker V1.x/V2, VTFT_LEDA and ST7796 boards, MUZI/Cardputer via dispdev->init()), OLEDDisplay::init() unconditionally re-invokes connect(), and TFTDisplay::connect() allocated a fresh driver instance each time without freeing the old one. Each OFF->ON transition orphaned a full LGFX device (measured: exactly 1,260 bytes per wake on a Heltec Wireless Tracker V2). Since PowerFSM wakes the screen on every received text message, a device on a busy channel leaked tens of KB per day - matching field reports of heap climbing from 78% to 92% within a day. Null-guard the driver allocations (matching the existing linePixelBuffer/ repaintChunkBuffer guards below them) so re-entry re-runs tft->init() on the existing instance. Bench-validated on a Tracker V2: free heap now flat across 13 consecutive wake cycles, with the panel still re-initializing and rendering on every wake. * fix(ble): don't leak BluetoothPhoneAPI and callbacks on BLE re-enable NimbleBluetooth::setup()/setupService() re-run when Bluetooth is re-enabled after deinit(), but BLEDevice::deinit() only frees the GATT objects - the caller-owned allocations were re-created unguarded on every cycle: - bluetoothPhoneAPI: ~3.5KB PhoneAPI object per cycle, and the orphaned instance is an OSThread that stays registered with the scheduler, so a duplicate thread kept servicing the same static queues - toRadioCallbacks / fromRadioCallbacks / security + server callbacks: one small object each per cycle - the BLESecurity shim was heap-allocated and never freed even on first boot; it only forwards to static setters, so use a stack instance Reuse the existing objects on re-setup; the setCallbacks() calls still run every time since the characteristics themselves are new. * chore: remove unused PacketCache PacketCache landed in #8341 but no consumer was ever wired up - repo-wide, the only references to PacketCache/packetCache are in its own two files. As designed it also malloc()s per cached packet with no eviction or size cap, so it should be re-reviewed for bounds before any future use. Remove the dead code; git history preserves it if a bounded revival is wanted. * fix(ble): reset stale session state when reusing BluetoothPhoneAPI Review follow-up: reusing bluetoothPhoneAPI across BLE enable cycles could hand the next session the previous session's dirty state. The only full cleanup path (onDisconnect) is skipped when deinit()'s bounded disconnect wait expires before the event lands (its 2s cap matches the connection supervision timeout, so a phone that walked out of range makes this a coin flip): the reused object then enters the next session mid-config, with stale queue contents served to the new phone and a stale connection handle that defeats the checkConnectionTimeout self-heal. Factor onDisconnect's cleanup into resetBleSessionState() and run it from setupService() when reusing the instance, restoring the old fresh-object invariant. Also switch the four stateless callback objects to function- local statics (the resolved framework BLE wrapper stores plain pointers and never frees them, so static instances are safe and avoid the guarded heap allocations), correct the comment that claimed deinit() frees the GATT objects (it deletes only the BLEServer itself; the services and characteristics it created remain a small library-side leak per cycle), and fix the inverted connection check in getRssi() that made BLE RSSI always read 0 on ESP32-S3/C6. * refactor(display): construct-once guard around the whole driver ladder Review follow-up: one if (!tft) around the #if/#elif/#else construction block instead of a guard per branch, so a future display family can't reintroduce the per-wake leak by copying an unguarded branch. Make the HACKADAY bus pointer local to the construction (it was a write-only global), and point the comment at the Screen::handleSetOn gates instead of hand-listing boards that would go stale. * fix(ble): use-after-free notifying a freed characteristic after deinit() deinit() nulled bleServer and BatteryCharacteristic but left fromNumCharacteristic dangling after BLEDevice::deinit(true) freed the GATT graph, and it never detached the PhoneAPI fromNumChanged observer. When deinit()'s bounded 2s disconnect wait expires before onDisconnect runs (the same stale-bond / host-reset race resetBleSessionState was built for), close() is skipped, so the observer stays attached with state == STATE_SEND_PACKETS. After BLE is off, the next mesh packet drives MeshService fromNumChanged -> PhoneAPI::onNotify -> onNowHasData -> fromNumCharacteristic->notify() on freed memory (the framework BLE wrapper's notify() dereferences the freed server via getConnectedCount). Unlike sendLog(), onNowHasData() had no isConnected guard. deinit() now calls resetBleSessionState() to detach the observer and reset session state unconditionally (also forcing the conn handle to NONE so checkConnectionTimeout can't be fooled by the stale handle), nulls fromNumCharacteristic/logRadioCharacteristic like the other freed pointers, and onNowHasData() bails on a null characteristic. Reachable on all ESP32/S3/C6 NimBLE boards via admin disable-bluetooth or a sleep transition while a phone is connected. Found by a follow-up lifecycle audit of the re-enable changes in this PR. --- src/graphics/TFTDisplay.cpp | 19 +-- src/mesh/PacketCache.cpp | 253 --------------------------------- src/mesh/PacketCache.h | 75 ---------- src/nimble/NimbleBluetooth.cpp | 111 +++++++++------ 4 files changed, 79 insertions(+), 379 deletions(-) delete mode 100644 src/mesh/PacketCache.cpp delete mode 100644 src/mesh/PacketCache.h diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index c24633169..f67e35fce 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -121,7 +121,6 @@ static void rak14014_tpIntHandle(void) #elif defined(HACKADAY_COMMUNICATOR) #include -Arduino_DataBus *bus = nullptr; Arduino_GFX *tft = nullptr; #elif defined(ST72xx_DE) @@ -1601,17 +1600,21 @@ bool TFTDisplay::connect() { concurrency::LockGuard g(spiLock); LOG_INFO("Do TFT init"); + // connect() re-runs on every display wake on variants whose handleSetOn() re-inits + // the UI (see the gates in Screen::handleSetOn); construct the driver exactly once. + if (!tft) { #if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096) || defined(HELTEC_MESH_NODE_T1) - tft = new TFT_eSPI; + 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 */); - tft = new Arduino_NV3007(bus, 40, 0 /* rotation */, false /* IPS */, 142 /* width */, 428 /* height */, 12 /* col offset 1 */, - 0 /* row offset 1 */, 14 /* col offset 2 */, 0 /* row offset 2 */, nv3007_279_init_operations, - sizeof(nv3007_279_init_operations)); - + Arduino_DataBus *bus = + new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */); + tft = new Arduino_NV3007(bus, 40, 0 /* rotation */, false /* IPS */, 142 /* width */, 428 /* height */, + 12 /* col offset 1 */, 0 /* row offset 1 */, 14 /* col offset 2 */, 0 /* row offset 2 */, + nv3007_279_init_operations, sizeof(nv3007_279_init_operations)); #else - tft = new LGFX; + tft = new LGFX; #endif + } backlightEnable->set(true); LOG_INFO("Power to TFT Backlight"); diff --git a/src/mesh/PacketCache.cpp b/src/mesh/PacketCache.cpp deleted file mode 100644 index 0edf81741..000000000 --- a/src/mesh/PacketCache.cpp +++ /dev/null @@ -1,253 +0,0 @@ -#include "PacketCache.h" -#include "Router.h" - -PacketCache packetCache{}; - -/** - * Allocate a new cache entry and copy the packet header and payload into it - */ -PacketCacheEntry *PacketCache::cache(const meshtastic_MeshPacket *p, bool preserveMetadata) -{ - size_t payload_size = - (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag) ? p->encrypted.size : p->decoded.payload.size; - PacketCacheEntry *e = (PacketCacheEntry *)malloc(sizeof(PacketCacheEntry) + payload_size + - (preserveMetadata ? sizeof(PacketCacheMetadata) : 0)); - if (!e) { - LOG_ERROR("Unable to allocate memory for packet cache entry"); - return NULL; - } - - *e = {}; - e->header.from = p->from; - e->header.to = p->to; - e->header.id = p->id; - e->header.channel = p->channel; - e->header.next_hop = p->next_hop; - e->header.relay_node = p->relay_node; - e->header.flags = (p->hop_limit & PACKET_FLAGS_HOP_LIMIT_MASK) | (p->want_ack ? PACKET_FLAGS_WANT_ACK_MASK : 0) | - (p->via_mqtt ? PACKET_FLAGS_VIA_MQTT_MASK : 0) | - ((p->hop_start << PACKET_FLAGS_HOP_START_SHIFT) & PACKET_FLAGS_HOP_START_MASK); - - PacketCacheMetadata m{}; - if (preserveMetadata) { - e->has_metadata = true; - m.rx_rssi = (uint8_t)(p->rx_rssi + 200); - m.rx_snr = (uint8_t)((p->rx_snr + 30.0f) / 0.25f); - m.rx_time = p->rx_time; - m.transport_mechanism = p->transport_mechanism; - m.priority = p->priority; - } - if (p->which_payload_variant == meshtastic_MeshPacket_encrypted_tag) { - e->encrypted = true; - e->payload_len = p->encrypted.size; - memcpy(((unsigned char *)e) + sizeof(PacketCacheEntry), p->encrypted.bytes, p->encrypted.size); - } else if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { - e->encrypted = false; - if (preserveMetadata) { - m.portnum = p->decoded.portnum; - m.want_response = p->decoded.want_response; - m.emoji = p->decoded.emoji; - m.bitfield = p->decoded.bitfield; - if (p->decoded.reply_id) - m.reply_id = p->decoded.reply_id; - else if (p->decoded.request_id) - m.request_id = p->decoded.request_id; - } - e->payload_len = p->decoded.payload.size; - memcpy(((unsigned char *)e) + sizeof(PacketCacheEntry), p->decoded.payload.bytes, p->decoded.payload.size); - } else { - LOG_ERROR("Unable to cache packet with unknown payload type %d", p->which_payload_variant); - free(e); - return NULL; - } - if (preserveMetadata) - memcpy(((unsigned char *)e) + sizeof(PacketCacheEntry) + e->payload_len, &m, sizeof(m)); - - size += sizeof(PacketCacheEntry) + e->payload_len + (e->has_metadata ? sizeof(PacketCacheMetadata) : 0); - insert(e); - return e; -}; - -/** - * Dump a list of packets into the provided buffer - */ -void PacketCache::dump(void *dest, const PacketCacheEntry **entries, size_t num_entries) -{ - unsigned char *pos = (unsigned char *)dest; - for (size_t i = 0; i < num_entries; i++) { - size_t entry_len = - sizeof(PacketCacheEntry) + entries[i]->payload_len + (entries[i]->has_metadata ? sizeof(PacketCacheMetadata) : 0); - memcpy(pos, entries[i], entry_len); - pos += entry_len; - } -} - -/** - * Calculate the length of buffer needed to dump the specified entries - */ -size_t PacketCache::dumpSize(const PacketCacheEntry **entries, size_t num_entries) -{ - size_t total_size = 0; - for (size_t i = 0; i < num_entries; i++) { - total_size += sizeof(PacketCacheEntry) + entries[i]->payload_len; - if (entries[i]->has_metadata) - total_size += sizeof(PacketCacheMetadata); - } - return total_size; -} - -/** - * Find a packet in the cache - */ -PacketCacheEntry *PacketCache::find(NodeNum from, PacketId id) -{ - uint16_t h = PACKET_HASH(from, id); - PacketCacheEntry *e = buckets[PACKET_CACHE_BUCKET(h)]; - while (e) { - if (e->header.from == from && e->header.id == id) - return e; - e = e->next; - } - return NULL; -} - -/** - * Find a packet in the cache by its hash - */ -PacketCacheEntry *PacketCache::find(PacketHash h) -{ - PacketCacheEntry *e = buckets[PACKET_CACHE_BUCKET(h)]; - while (e) { - if (PACKET_HASH(e->header.from, e->header.id) == h) - return e; - e = e->next; - } - return NULL; -} - -/** - * Load a list of packets from the provided buffer - */ -bool PacketCache::load(void *src, PacketCacheEntry **entries, size_t num_entries) -{ - memset(entries, 0, sizeof(PacketCacheEntry *) * num_entries); - unsigned char *pos = (unsigned char *)src; - for (size_t i = 0; i < num_entries; i++) { - PacketCacheEntry e{}; - memcpy(&e, pos, sizeof(PacketCacheEntry)); - size_t entry_len = sizeof(PacketCacheEntry) + e.payload_len + (e.has_metadata ? sizeof(PacketCacheMetadata) : 0); - entries[i] = (PacketCacheEntry *)malloc(entry_len); - size += entry_len; - if (!entries[i]) { - LOG_ERROR("Unable to allocate memory for packet cache entry"); - for (size_t j = 0; j < i; j++) { - size -= sizeof(PacketCacheEntry) + entries[j]->payload_len + - (entries[j]->has_metadata ? sizeof(PacketCacheMetadata) : 0); - free(entries[j]); - entries[j] = NULL; - } - return false; - } - memcpy(entries[i], pos, entry_len); - pos += entry_len; - } - for (size_t i = 0; i < num_entries; i++) - insert(entries[i]); - return true; -} - -/** - * Copy the cached packet into the provided MeshPacket structure - */ -void PacketCache::rehydrate(const PacketCacheEntry *e, meshtastic_MeshPacket *p) -{ - if (!e || !p) - return; - - *p = {}; - p->from = e->header.from; - p->to = e->header.to; - p->id = e->header.id; - p->channel = e->header.channel; - p->next_hop = e->header.next_hop; - p->relay_node = e->header.relay_node; - p->hop_limit = e->header.flags & PACKET_FLAGS_HOP_LIMIT_MASK; - p->want_ack = !!(e->header.flags & PACKET_FLAGS_WANT_ACK_MASK); - p->via_mqtt = !!(e->header.flags & PACKET_FLAGS_VIA_MQTT_MASK); - p->hop_start = (e->header.flags & PACKET_FLAGS_HOP_START_MASK) >> PACKET_FLAGS_HOP_START_SHIFT; - p->which_payload_variant = e->encrypted ? meshtastic_MeshPacket_encrypted_tag : meshtastic_MeshPacket_decoded_tag; - - unsigned char *payload = ((unsigned char *)e) + sizeof(PacketCacheEntry); - PacketCacheMetadata m{}; - if (e->has_metadata) { - memcpy(&m, (payload + e->payload_len), sizeof(m)); - p->rx_rssi = ((int)m.rx_rssi) - 200; - p->rx_snr = ((float)m.rx_snr * 0.25f) - 30.0f; - p->rx_time = m.rx_time; - p->transport_mechanism = (meshtastic_MeshPacket_TransportMechanism)m.transport_mechanism; - p->priority = (meshtastic_MeshPacket_Priority)m.priority; - } - if (e->encrypted) { - memcpy(p->encrypted.bytes, payload, e->payload_len); - p->encrypted.size = e->payload_len; - } else { - memcpy(p->decoded.payload.bytes, payload, e->payload_len); - p->decoded.payload.size = e->payload_len; - if (e->has_metadata) { - // Decrypted-only metadata - p->decoded.portnum = (meshtastic_PortNum)m.portnum; - p->decoded.want_response = m.want_response; - p->decoded.emoji = m.emoji; - p->decoded.bitfield = m.bitfield; - if (m.reply_id) - p->decoded.reply_id = m.reply_id; - else if (m.request_id) - p->decoded.request_id = m.request_id; - } - } -} - -/** - * Release a cache entry - */ -void PacketCache::release(PacketCacheEntry *e) -{ - if (!e) - return; - remove(e); - size -= sizeof(PacketCacheEntry) + e->payload_len + (e->has_metadata ? sizeof(PacketCacheMetadata) : 0); - free(e); -} - -/** - * Insert a new entry into the hash table - */ -void PacketCache::insert(PacketCacheEntry *e) -{ - assert(e); - PacketHash h = PACKET_HASH(e->header.from, e->header.id); - PacketCacheEntry **target = &buckets[PACKET_CACHE_BUCKET(h)]; - e->next = *target; - *target = e; - num_entries++; -} - -/** - * Remove an entry from the hash table - */ -void PacketCache::remove(PacketCacheEntry *e) -{ - assert(e); - PacketHash h = PACKET_HASH(e->header.from, e->header.id); - PacketCacheEntry **target = &buckets[PACKET_CACHE_BUCKET(h)]; - while (*target) { - if (*target == e) { - *target = e->next; - e->next = NULL; - num_entries--; - break; - } else { - target = &(*target)->next; - } - } -} \ No newline at end of file diff --git a/src/mesh/PacketCache.h b/src/mesh/PacketCache.h deleted file mode 100644 index 85660922b..000000000 --- a/src/mesh/PacketCache.h +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once -#include "RadioInterface.h" - -#define PACKET_HASH(a, b) ((((a ^ b) >> 16) ^ (a ^ b)) & 0xFFFF) // 16 bit fold of packet (from, id) tuple -typedef uint16_t PacketHash; - -#define PACKET_CACHE_BUCKETS 64 // Number of hash table buckets -#define PACKET_CACHE_BUCKET(h) (((h >> 12) ^ (h >> 6) ^ h) & 0x3F) // Fold hash down to 6-bit bucket index - -typedef struct PacketCacheEntry { - PacketCacheEntry *next; - PacketHeader header; - uint16_t payload_len = 0; - union { - uint16_t bitfield; - struct { - uint8_t encrypted : 1; // Payload is encrypted - uint8_t has_metadata : 1; // Payload includes PacketCacheMetadata - uint8_t : 6; // Reserved for future use - uint8_t : 8; // Reserved for future use - }; - }; -} PacketCacheEntry; - -typedef struct PacketCacheMetadata { - PacketCacheMetadata() : _bitfield(0), reply_id(0), _bitfield2(0) {} - union { - uint32_t _bitfield; - struct { - uint16_t portnum : 9; // meshtastic_MeshPacket::decoded::portnum - uint16_t want_response : 1; // meshtastic_MeshPacket::decoded::want_response - uint16_t emoji : 1; // meshtastic_MeshPacket::decoded::emoji - uint16_t bitfield : 5; // meshtastic_MeshPacket::decoded::bitfield (truncated) - uint8_t rx_rssi : 8; // meshtastic_MeshPacket::rx_rssi (map via actual RSSI + 200) - uint8_t rx_snr : 8; // meshtastic_MeshPacket::rx_snr (map via (p->rx_snr + 30.0f) / 0.25f) - }; - }; - union { - uint32_t reply_id; // meshtastic_MeshPacket::decoded.reply_id - uint32_t request_id; // meshtastic_MeshPacket::decoded.request_id - }; - uint32_t rx_time = 0; // meshtastic_MeshPacket::rx_time - uint8_t transport_mechanism = 0; // meshtastic_MeshPacket::transport_mechanism - struct { - uint8_t _bitfield2; - union { - uint8_t priority : 7; // meshtastic_MeshPacket::priority - uint8_t reserved : 1; // Reserved for future use - }; - }; -} PacketCacheMetadata; - -class PacketCache -{ - public: - PacketCacheEntry *cache(const meshtastic_MeshPacket *p, bool preserveMetadata); - static void dump(void *dest, const PacketCacheEntry **entries, size_t num_entries); - size_t dumpSize(const PacketCacheEntry **entries, size_t num_entries); - PacketCacheEntry *find(NodeNum from, PacketId id); - PacketCacheEntry *find(PacketHash h); - bool load(void *src, PacketCacheEntry **entries, size_t num_entries); - size_t getNumEntries() { return num_entries; } - size_t getSize() { return size; } - void rehydrate(const PacketCacheEntry *e, meshtastic_MeshPacket *p); - void release(PacketCacheEntry *e); - - private: - PacketCacheEntry *buckets[PACKET_CACHE_BUCKETS]{}; - size_t num_entries = 0; - size_t size = 0; - void insert(PacketCacheEntry *e); - void remove(PacketCacheEntry *e); -}; - -extern PacketCache packetCache; \ No newline at end of file diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 4b52eae83..647c1bef8 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -409,6 +409,8 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread uint8_t val[4]; put_le32(val, fromRadioNum); + if (!fromNumCharacteristic) // BLE may have been torn down; never notify a freed characteristic + return; fromNumCharacteristic->setValue(val, sizeof(val)); fromNumCharacteristic->notify(); } @@ -694,6 +696,35 @@ class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks } }; +// Reset per-session PhoneAPI and transport state. Runs from onDisconnect, and again from +// setupService() on BLE re-enable because deinit()'s bounded disconnect wait can expire +// before the disconnect event delivers this cleanup (leaving stale queues/state behind). +static void resetBleSessionState() +{ + if (bluetoothPhoneAPI) { + bluetoothPhoneAPI->close(); + + { // scope for fromPhoneMutex mutex + std::lock_guard guard(bluetoothPhoneAPI->fromPhoneMutex); + bluetoothPhoneAPI->fromPhoneQueueSize = 0; + } + + bluetoothPhoneAPI->onReadCallbackIsWaitingForData = false; + { // scope for toPhoneMutex mutex + std::lock_guard guard(bluetoothPhoneAPI->toPhoneMutex); + bluetoothPhoneAPI->toPhoneQueueSize = 0; + } + + bluetoothPhoneAPI->readCount = 0; + bluetoothPhoneAPI->notifyCount = 0; + bluetoothPhoneAPI->writeCount = 0; + } + + memset(lastToRadio, 0, sizeof(lastToRadio)); + + nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE; +} + class NimbleBluetoothServerCallback : public BLEServerCallbacks { public: @@ -736,28 +767,7 @@ class NimbleBluetoothServerCallback : public BLEServerCallbacks bluetoothStatus->updateStatus(&newStatus); clearPairingDisplay(); - if (bluetoothPhoneAPI) { - bluetoothPhoneAPI->close(); - - { // scope for fromPhoneMutex mutex - std::lock_guard guard(bluetoothPhoneAPI->fromPhoneMutex); - bluetoothPhoneAPI->fromPhoneQueueSize = 0; - } - - bluetoothPhoneAPI->onReadCallbackIsWaitingForData = false; - { // scope for toPhoneMutex mutex - std::lock_guard guard(bluetoothPhoneAPI->toPhoneMutex); - bluetoothPhoneAPI->toPhoneQueueSize = 0; - } - - bluetoothPhoneAPI->readCount = 0; - bluetoothPhoneAPI->notifyCount = 0; - bluetoothPhoneAPI->writeCount = 0; - } - - memset(lastToRadio, 0, sizeof(lastToRadio)); - - nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE; + resetBleSessionState(); // Defer the advertising restart to runOnce (see pendingStartAdvertising): calling // startAdvertising() here would crash if this disconnect was a host reset. @@ -769,9 +779,6 @@ class NimbleBluetoothServerCallback : public BLEServerCallbacks } }; -static NimbleBluetoothToRadioCallback *toRadioCallbacks; -static NimbleBluetoothFromRadioCallback *fromRadioCallbacks; - void NimbleBluetooth::startAdvertising() { BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); @@ -840,7 +847,14 @@ void NimbleBluetooth::deinit() BLEDevice::deinit(true); bleServer = nullptr; // deleted by deinit(); clear the dangling copy BatteryCharacteristic = nullptr; // freed by deinit; clear so updateBatteryLevel() won't touch it + fromNumCharacteristic = nullptr; // freed by deinit; a late onNowHasData() must not notify freed memory + logRadioCharacteristic = nullptr; lastBatteryLevel = -1; + + // The bounded disconnect wait above can expire before onDisconnect runs, leaving the PhoneAPI + // observer attached with a live state machine; a later mesh packet would then drive onNowHasData() + // into the now-freed characteristics. Detach the observer and reset session state unconditionally. + resetBleSessionState(); #endif } @@ -858,7 +872,7 @@ int NimbleBluetooth::getRssi() { #if defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) uint16_t conn_handle = nimbleBluetoothConnHandle.load(); - if (conn_handle != BLE_HS_CONN_HANDLE_NONE) { + if (conn_handle == BLE_HS_CONN_HANDLE_NONE) { return 0; // No active BLE connection } @@ -903,33 +917,36 @@ void NimbleBluetooth::setup() LOG_WARN("Unable to request MTU %u, rc=%d", kPreferredBleMtu, mtuResult); } - BLESecurity *pSecurity = new BLESecurity(); - pSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); - pSecurity->setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); + // BLESecurity only forwards to static NimBLEDevice setters; a stack instance suffices. + BLESecurity security; + security.setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); + security.setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK); if (config.bluetooth.mode != meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN) { // Set IO capability to DisplayOnly for MITM authentication - pSecurity->setCapability(ESP_IO_CAP_OUT); + security.setCapability(ESP_IO_CAP_OUT); // Set the passkey if (config.bluetooth.mode == meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN) { LOG_INFO("Use random passkey"); - pSecurity->setPassKey(false); // generate a random passkey + security.setPassKey(false); // generate a random passkey } else { LOG_INFO("Use fixed passkey"); - pSecurity->setPassKey(true, config.bluetooth.fixed_pin); + security.setPassKey(true, config.bluetooth.fixed_pin); } // Enable authorization requirements: // - bonding: true (for persistent storage of the keys) // - MITM: true (enables Man-In-The-Middle protection for password prompts) // - secure connection: true (enables secure connection for encryption) - pSecurity->setAuthenticationMode(true, true, true); + security.setAuthenticationMode(true, true, true); } else { // No IO capability for no PIN mode - pSecurity->setCapability(ESP_IO_CAP_NONE); + security.setCapability(ESP_IO_CAP_NONE); // No PIN mode: no MITM protection - pSecurity->setAuthenticationMode(true, false, false); + security.setAuthenticationMode(true, false, false); } - // Set the security callbacks - BLEDevice::setSecurityCallbacks(new NimbleBluetoothSecurityCallback()); + // Statics: setup() re-runs on BLE re-enable, and the library never frees these + // caller-owned callback objects, so register the same instances every cycle. + static NimbleBluetoothSecurityCallback securityCallbacks; + BLEDevice::setSecurityCallbacks(&securityCallbacks); bleServer = BLEDevice::createServer(); // BLEDevice::createServer calls ble_svc_gap_init, which resets the device @@ -939,7 +956,8 @@ void NimbleBluetooth::setup() LOG_ERROR("ble_svc_gap_device_name_set: rc=%d %s", nameRc, BLEUtils::returnCodeToString(nameRc)); } - bleServer->setCallbacks(new NimbleBluetoothServerCallback(this)); + static NimbleBluetoothServerCallback serverCallbacks(this); // safe: NimbleBluetooth is a never-deleted singleton + bleServer->setCallbacks(&serverCallbacks); setupService(); startAdvertising(); } @@ -972,13 +990,20 @@ void NimbleBluetooth::setupService() LOGRADIO_UUID, BLECharacteristic::PROPERTY_NOTIFY | BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_READ_AUTHEN | BLECharacteristic::PROPERTY_READ_ENC); } - bluetoothPhoneAPI = new BluetoothPhoneAPI(); + // setupService() re-runs on BLE re-enable; a fresh BluetoothPhoneAPI here would leak + // the old one as a still-scheduled zombie OSThread, so reuse it and reset its state + // (a skipped onDisconnect during deinit() can leave the previous session's behind). + if (!bluetoothPhoneAPI) + bluetoothPhoneAPI = new BluetoothPhoneAPI(); + else + resetBleSessionState(); - toRadioCallbacks = new NimbleBluetoothToRadioCallback(); - ToRadioCharacteristic->setCallbacks(toRadioCallbacks); + // The characteristics are new each cycle, so setCallbacks() must re-run every time. + static NimbleBluetoothToRadioCallback toRadioCallbacks; + ToRadioCharacteristic->setCallbacks(&toRadioCallbacks); - fromRadioCallbacks = new NimbleBluetoothFromRadioCallback(); - FromRadioCharacteristic->setCallbacks(fromRadioCallbacks); + static NimbleBluetoothFromRadioCallback fromRadioCallbacks; + FromRadioCharacteristic->setCallbacks(&fromRadioCallbacks); bleService->start(); From f18a8b05521113183bf112c2c91a25b5f4037bfe Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Thu, 16 Jul 2026 22:19:57 +0200 Subject: [PATCH 37/69] Add AQ Telemetry to userPrefs (#11029) --- src/mesh/NodeDB.cpp | 10 ++++++++++ userPrefs.jsonc | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index f9f04f487..035da67e0 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1535,7 +1535,17 @@ void NodeDB::initModuleConfigIntervals() #ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; #endif + +#ifdef USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL + moduleConfig.telemetry.air_quality_interval = USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL; +#else moduleConfig.telemetry.air_quality_interval = 0; +#endif + +#ifdef USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED + moduleConfig.telemetry.air_quality_enabled = USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED; +#endif + moduleConfig.telemetry.power_update_interval = 0; moduleConfig.telemetry.health_update_interval = 0; moduleConfig.neighbor_info.update_interval = 0; diff --git a/userPrefs.jsonc b/userPrefs.jsonc index a132eb906..3e3fbd5c9 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -37,6 +37,8 @@ // "USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL": "900", // Device telemetry update interval in seconds // "USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL": "900", // Environment telemetry update interval in seconds // "USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED": "1", // Force BMP280/sensor reads + LoRa broadcast on first boot + // "USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL": "900", // AQ telemetry update interval in seconds + // "USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED": "1", // AQ telemetry enable // "USERPREFS_LORACONFIG_CHANNEL_NUM": "31", // "USERPREFS_LORACONFIG_TX_POWER": "0", // "USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST", From d3d02af8175a2915157600dac5fcb3a0fb2e50ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Thu, 16 Jul 2026 23:56:47 +0200 Subject: [PATCH 38/69] AdminModule: don't return the device private key to remote config reads (#11030) A SECURITY_CONFIG get_config response copied config.security verbatim, so a remote request was answered with the device identity private_key and sent over the air. Only the local owner needs it, for backup. Zero private_key in the SECURITY_CONFIG response when the request is remote (from != 0); the local BLE/USB/TCP path (from == 0) still receives it. public_key and admin_key are public and stay as they were. --- src/modules/AdminModule.cpp | 9 +++ test/support/AdminModuleTestShim.h | 4 ++ test/test_admin_session_repro/test_main.cpp | 63 ++++++++++++++++++++- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 278fe6179..32f06f3cb 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1408,6 +1408,15 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32 LOG_INFO("Get config: Security"); res.get_config_response.which_payload_variant = meshtastic_Config_security_tag; res.get_config_response.payload_variant.security = config.security; + // The device identity private key is backup material for the local owner only. A local + // admin client sets from == 0 (BLE/USB/TCP); never return the key to a remote requester, + // even an authorized one, since it would travel over the air. public_key/admin_key are + // public and stay put. + if (req.from != 0) { + auto &sec = res.get_config_response.payload_variant.security; + memset(sec.private_key.bytes, 0, sizeof(sec.private_key.bytes)); + sec.private_key.size = 0; + } break; case meshtastic_AdminMessage_ConfigType_SESSIONKEY_CONFIG: LOG_INFO("Get config: Sessionkey"); diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index d3670b09a..6d9c3f1a8 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -8,11 +8,15 @@ class AdminModuleTestShim : public AdminModule { public: using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro) + using AdminModule::handleGetConfig; using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; using AdminModule::setPassKey; + // Peek at the reply a handler queued, before drainReply() releases it. + meshtastic_MeshPacket *reply() { return myReply; } + // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index f1bb1711e..6502ab3c4 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -1,5 +1,6 @@ // Deterministic reproduction of the admin session-key behavior discussed on PR #10669 -// (ndoo's "Admin message without session_key!" report). +// (ndoo's "Admin message without session_key!" report), plus the remote-vs-local redaction of +// secret material in admin GET responses. // // Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote // (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate. @@ -13,6 +14,7 @@ #include "mesh/Channels.h" #include "mesh/NodeDB.h" +#include "mesh/mesh-pb-constants.h" #include "modules/AdminModule.h" #include "support/AdminModuleTestShim.h" #include "support/MockMeshService.h" @@ -135,6 +137,63 @@ void test_session_gate_accepts_key_from_a_get_response(void) TEST_ASSERT_FALSE(admin->checkPassKey(&bad)); } +// Decode the SecurityConfig out of the get_config response a handler queued in myReply. +static bool decodeSecurityFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_SecurityConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag || + am.get_config_response.which_payload_variant != meshtastic_Config_security_tag) + return false; + out = am.get_config_response.payload_variant.security; + return true; +} + +static meshtastic_MeshPacket makeGetConfigRequest(NodeNum from) +{ + meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero; + req.from = from; + req.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + req.decoded.want_response = true; + return req; +} + +// The device identity private key must never leave over the air: a SECURITY_CONFIG response to a +// remote request (from != 0, even an authorized admin) carries an empty private_key. +void test_remote_security_config_omits_private_key(void) +{ + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xAB, 32); + + meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG); + + meshtastic_Config_SecurityConfig sec; + TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec)); + TEST_ASSERT_EQUAL_MESSAGE(0, sec.private_key.size, "remote security config must not carry the private key"); + admin->drainReply(); +} + +// Control: the local backup path (from == 0, BLE/USB/TCP) still receives the private key, so the +// redaction above is remote-specific rather than a blanket wipe. +void test_local_security_config_keeps_private_key(void) +{ + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xAB, 32); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG); + + meshtastic_Config_SecurityConfig sec; + TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec)); + TEST_ASSERT_EQUAL_MESSAGE(32, sec.private_key.size, "local backup must still receive the private key"); + TEST_ASSERT_EACH_EQUAL_HEX8(0xAB, sec.private_key.bytes, 32); + admin->drainReply(); +} + #endif // !(MESHTASTIC_EXCLUDE_PKI) void setup() @@ -146,6 +205,8 @@ void setup() RUN_TEST(test_remote_setter_without_session_is_rejected); RUN_TEST(test_expected_session_key_is_zero_before_any_get); RUN_TEST(test_session_gate_accepts_key_from_a_get_response); + RUN_TEST(test_remote_security_config_omits_private_key); + RUN_TEST(test_local_security_config_keeps_private_key); #endif exit(UNITY_END()); } From 8d1fbbf55f7b9ea8aeb9bef7625407cdc3ebf7bc Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 16 Jul 2026 18:35:33 -0500 Subject: [PATCH 39/69] Snake! (#10936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Snake! * Add spiLock to snake score saving * Check fixes * More careful locking * WIP: Big Display Node * Update src/graphics/HUB75Display.cpp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Add HUB75 Native * Add Tetris game module with core logic and UI integration - Implement TetrisGame class for game logic, including piece movement, rotation, and line clearing. - Create TetrisModule class to manage game state, input handling, and rendering on OLED display. - Introduce high score tracking with persistence and optional mesh broadcasting. - Define UI states for title, playing, paused, game over, and high scores. - Implement input handling for game controls and state transitions. - Add rendering functions for the game board, high scores, and title screen. * feat(snake): add snake graphics and update display logic in SnakeModule * Prompt for Initials for Tetris, too * refactor games module * Games refactor * hub75 native double buffer * Games tuning * Make joystick repeat events on held button * Add clouds and colors * Fix breakout and more color * difficulty tuning * trunk * refactor game announcements, etc * Scale chirpy gravity as game speed increases * Portduino, check for hub75 display before reading in hub75 options * Final(?) games tuning * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Properly ignore input when games screen not shown * Fail gracefully when HUB75 is selected but not supported. --------- Co-authored-by: Thomas Göttgens Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Ixitxachitl Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- bin/config-dist.yaml | 25 +- bin/config.d/display-hub75-64x64.yaml | 37 ++ src/configuration.h | 5 + src/graphics/HUB75Display.cpp | 156 ++++++ src/graphics/HUB75Display.h | 48 ++ src/graphics/Screen.cpp | 100 +++- src/graphics/Screen.h | 13 + src/graphics/TFTColorRegions.h | 5 +- src/graphics/draw/DebugRenderer.cpp | 20 +- src/graphics/draw/NotificationRenderer.cpp | 94 ++++ src/graphics/draw/NotificationRenderer.h | 2 + src/graphics/draw/UIRenderer.cpp | 10 + src/graphics/images.h | 40 ++ src/input/LinuxJoystick.cpp | 59 ++- src/input/LinuxJoystick.h | 22 +- src/modules/Modules.cpp | 8 + src/modules/games/Breakout.cpp | 315 +++++++++++ src/modules/games/Breakout.h | 138 +++++ src/modules/games/ChirpyRunner.cpp | 303 +++++++++++ src/modules/games/ChirpyRunner.h | 177 +++++++ src/modules/games/Game.h | 57 ++ src/modules/games/GamesModule.cpp | 373 +++++++++++++ src/modules/games/GamesModule.h | 101 ++++ src/modules/games/HighScoreTable.h | 176 +++++++ src/modules/games/Snake.cpp | 264 ++++++++++ src/modules/games/Snake.h | 152 ++++++ src/modules/games/Tetris.cpp | 494 ++++++++++++++++++ src/modules/games/Tetris.h | 156 ++++++ src/platform/portduino/HUB75Native.cpp | 163 ++++++ src/platform/portduino/PortduinoGlue.cpp | 33 ++ src/platform/portduino/PortduinoGlue.h | 51 +- src/platform/portduino/architecture.h | 8 + test/native-suite-count | 2 +- test/test_breakout/test_main.cpp | 103 ++++ test/test_chirpy/test_main.cpp | 100 ++++ test/test_snake/test_main.cpp | 180 +++++++ .../esp32s3/visualizer-hub75/platformio.ini | 20 + variants/esp32s3/visualizer-hub75/variant.h | 66 +++ variants/native/portduino.ini | 2 + variants/native/portduino/HUB75Native.h | 57 ++ variants/native/portduino/platformio.ini | 5 + 41 files changed, 4106 insertions(+), 34 deletions(-) create mode 100644 bin/config.d/display-hub75-64x64.yaml create mode 100644 src/graphics/HUB75Display.cpp create mode 100644 src/graphics/HUB75Display.h create mode 100644 src/modules/games/Breakout.cpp create mode 100644 src/modules/games/Breakout.h create mode 100644 src/modules/games/ChirpyRunner.cpp create mode 100644 src/modules/games/ChirpyRunner.h create mode 100644 src/modules/games/Game.h create mode 100644 src/modules/games/GamesModule.cpp create mode 100644 src/modules/games/GamesModule.h create mode 100644 src/modules/games/HighScoreTable.h create mode 100644 src/modules/games/Snake.cpp create mode 100644 src/modules/games/Snake.h create mode 100644 src/modules/games/Tetris.cpp create mode 100644 src/modules/games/Tetris.h create mode 100644 src/platform/portduino/HUB75Native.cpp create mode 100644 test/test_breakout/test_main.cpp create mode 100644 test/test_chirpy/test_main.cpp create mode 100644 test/test_snake/test_main.cpp create mode 100644 variants/esp32s3/visualizer-hub75/platformio.ini create mode 100644 variants/esp32s3/visualizer-hub75/variant.h create mode 100644 variants/native/portduino/HUB75Native.h diff --git a/bin/config-dist.yaml b/bin/config-dist.yaml index 4b4fe0b82..46a044e8d 100644 --- a/bin/config-dist.yaml +++ b/bin/config-dist.yaml @@ -153,6 +153,30 @@ Display: ### You can also specify the spi device for the display to use # spidev: spidev0.0 +### HUB75 RGB LED matrix panel (Raspberry Pi only, via hzeller/rpi-rgb-led-matrix). +### Requires the librgbmatrix library to be installed (so `pkg-config rgbmatrix` resolves) and +### the firmware built with it present. meshtasticd must run as root (or with /dev/gpiomem +### access) and on-board audio disabled (dtparam=audio=off) so it doesn't fight the PWM timing. +### The library owns the GPIO pins - they are chosen by HardwareMapping, not set individually. +# Panel: HUB75 +# HUB75: +# HardwareMapping: adafruit-hat-pwm # regular | adafruit-hat | adafruit-hat-pwm | regular-pi1 ... +# Rows: 64 # pixel rows per panel (e.g. 32 or 64) +# Cols: 64 # pixel columns per panel +# ChainLength: 1 # panels daisy-chained (width = Cols * ChainLength) +# Parallel: 1 # parallel chains (height = Rows * Parallel) +# Brightness: 80 # percent, 1..100 +# PWMBits: 11 # lower = higher refresh, fewer colors +# GPIOSlowdown: 4 # raise for faster Pis / long cables if the panel ghosts or flickers +# RGBSequence: RGB # reorder if your panel's colors are swapped (e.g. RBG, BGR) +# ScanMode: 0 # 0=progressive, 1=interlaced +# RowAddressType: 0 # 0=default, 1=AB-addressed (some 64x64 panels) +# Multiplexing: 0 # 0=direct, 1=stripe, 2=checker, ... +# PanelType: "" # e.g. FM6126A for panels needing a special init +# PixelMapper: "" # e.g. "U-mapper;Rotate:90" +# LimitRefreshRateHz: 0 # 0 = no limit +# DisableHardwarePulsing: false + Touchscreen: ### Note, at least for now, the touchscreen must have a CS pin defined, even if you let Linux manage the CS switching. @@ -167,7 +191,6 @@ Touchscreen: ### You can also specify the spi device for the touchscreen to use # spidev: spidev0.0 - Input: ### Configure device for direct keyboard input diff --git a/bin/config.d/display-hub75-64x64.yaml b/bin/config.d/display-hub75-64x64.yaml new file mode 100644 index 000000000..704a4c6b9 --- /dev/null +++ b/bin/config.d/display-hub75-64x64.yaml @@ -0,0 +1,37 @@ +Meta: + name: HUB75 RGB LED Matrix (64x64) + support: community + compatible: + - raspberry-pi + +### HUB75 RGB LED matrix panel driven by hzeller/rpi-rgb-led-matrix. +### +### Prerequisites: +### - Install the rpi-rgb-led-matrix library so `pkg-config rgbmatrix` resolves, and build the +### firmware with it present (the HUB75 backend is compiled in automatically when found). +### - meshtasticd must run as root (or have /dev/gpiomem access) for the library's direct GPIO. +### - Disable on-board audio (dtparam=audio=off in /boot/firmware/config.txt) - it shares the +### PWM hardware and causes flicker otherwise. +### +### The library owns the GPIO pins; they are selected by HardwareMapping (matching your HAT / +### wiring), not assigned individually. +Display: + Panel: HUB75 + HUB75: + HardwareMapping: adafruit-hat-pwm + Rows: 64 + Cols: 64 + ChainLength: 1 + Parallel: 1 + Brightness: 80 + PWMBits: 11 + GPIOSlowdown: 4 + RGBSequence: RGB + # Uncomment/tune as needed for your specific panel: + # ScanMode: 0 + # RowAddressType: 0 + # Multiplexing: 0 + # PanelType: FM6126A + # PixelMapper: "" + # LimitRefreshRateHz: 0 + # DisableHardwarePulsing: false diff --git a/src/configuration.h b/src/configuration.h index eb3db7e46..672d8b6f1 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -424,6 +424,11 @@ along with this program. If not, see . #ifndef HAS_TFT #define HAS_TFT 0 #endif +// Opt-in: build the BaseUI games frame (Snake). Off by default; enable per build/variant with +// -DBASEUI_HAS_GAMES=1 (requires HAS_SCREEN and a non-color BaseUI display). +#ifndef BASEUI_HAS_GAMES +#define BASEUI_HAS_GAMES 0 +#endif #ifndef HAS_WIRE #define HAS_WIRE 0 #endif diff --git a/src/graphics/HUB75Display.cpp b/src/graphics/HUB75Display.cpp new file mode 100644 index 000000000..36ca64f39 --- /dev/null +++ b/src/graphics/HUB75Display.cpp @@ -0,0 +1,156 @@ +#include "configuration.h" + +#if defined(USE_HUB75) + +#include "HUB75Display.h" +#include "TFTColorRegions.h" +#include "TFTPalette.h" +#include +#include + +HUB75Display::HUB75Display(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C) +{ + // The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306 + // page layout). Geometry is fixed by the wired panel size. +#if defined(SCREEN_ROTATE) + setGeometry(GEOMETRY_RAWMODE, TFT_HEIGHT, TFT_WIDTH); +#else + setGeometry(GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); +#endif + LOG_DEBUG("HUB75Display %dx%d", (int)TFT_WIDTH, (int)TFT_HEIGHT); +} + +HUB75Display::~HUB75Display() +{ + if (matrix) { + delete matrix; + matrix = nullptr; + } +} + +// Bring up the matrix DMA driver from the wired HUB75 pins. +bool HUB75Display::connect() +{ + LOG_INFO("Do HUB75 init"); + + HUB75_I2S_CFG::i2s_pins pins = {HUB75_R1, HUB75_G1, HUB75_B1, HUB75_R2, HUB75_G2, HUB75_B2, HUB75_A, + HUB75_B, HUB75_C, HUB75_D, HUB75_E, HUB75_LAT, HUB75_OE, HUB75_CLK}; + + HUB75_I2S_CFG cfg(TFT_WIDTH, TFT_HEIGHT, 1 /* chain length */, pins); + + cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity) + cfg.latch_blanking = 4; // clean row transitions + cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half + cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA + // footprint. display() draws directly into the + // live buffer with a dirty-diff + + matrix = new MatrixPanel_I2S_DMA(cfg); + bool ok = matrix->begin(); + if (!ok) { + LOG_ERROR("HUB75 matrix->begin() failed (DMA buffer alloc?)"); + delete matrix; + matrix = nullptr; + return false; + } + matrix->setBrightness8(brightness); + matrix->clearScreen(); + return true; +} + +void HUB75Display::display() +{ + if (!matrix) + return; + + const uint16_t onNative = graphics::TFTPalette::White; + const uint16_t offNative = graphics::getThemeBodyBg(); + + const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8)); + const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8)); + + bool forceFull = firstFrame || !haveThemeDefaults || onBe != lastOnBe || offBe != lastOffBe; + +#if GRAPHICS_TFT_COLORING_ENABLED + const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0; + const uint32_t colorSig = graphics::getTFTColorFrameSignature(); + if (colorSig != lastColorSig) + forceFull = true; +#endif + + for (uint16_t y = 0; y < displayHeight; y++) { + const uint32_t yByteIndex = (y / 8) * displayWidth; + const uint8_t yByteMask = (uint8_t)(1 << (y & 7)); + +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + graphics::beginTFTColorRow((int16_t)y); +#endif + + for (uint16_t x = 0; x < displayWidth; x++) { + const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0; + + // Skip pixels whose mono bit is unchanged (unless a full repaint is + // forced). The panel is single-buffered, so untouched pixels persist. + if (!forceFull && (((buffer_back[x + yByteIndex] & yByteMask) != 0) == isset)) + continue; + + uint16_t be; +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe); + else + be = isset ? onBe : offBe; +#else + be = isset ? onBe : offBe; +#endif + + const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565 + const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3); + const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2); + const uint8_t b = (uint8_t)((c & 0x1F) << 3); + matrix->drawPixelRGB888(x, y, r, g, b); + } + } + + // Remember what the panel now shows so the next frame can diff against it. + memcpy(buffer_back, buffer, displayBufferSize); + + haveThemeDefaults = true; + lastOnBe = onBe; + lastOffBe = offBe; + firstFrame = false; +#if GRAPHICS_TFT_COLORING_ENABLED + lastColorSig = colorSig; + // Regions are re-registered every frame by the renderers; clear so they + // don't accumulate across frames. + graphics::clearTFTColorRegions(); +#endif +} + +void HUB75Display::sendCommand(uint8_t com) +{ + if (!matrix) + return; + + switch (com) { + case DISPLAYON: + matrix->setBrightness8(brightness); + break; + case DISPLAYOFF: + matrix->setBrightness8(0); + break; + default: + // Drop all other SSD1306 init/config commands - not meaningful for the matrix. + break; + } +} + +void HUB75Display::setDisplayBrightness(uint8_t _brightness) +{ + brightness = _brightness; + if (matrix) + matrix->setBrightness8(brightness); +} + +#endif // USE_HUB75 diff --git a/src/graphics/HUB75Display.h b/src/graphics/HUB75Display.h new file mode 100644 index 000000000..aa17a5a2a --- /dev/null +++ b/src/graphics/HUB75Display.h @@ -0,0 +1,48 @@ +#pragma once + +#include "configuration.h" + +#if defined(USE_HUB75) + +#include + +#ifndef HUB75_BRIGHTNESS_DEFAULT +#define HUB75_BRIGHTNESS_DEFAULT 180 +#endif + +class MatrixPanel_I2S_DMA; + +/** + * A color display backend that drives a HUB75 RGB LED matrix panel from the + * BaseUI color path. + */ +class HUB75Display : public OLEDDisplay +{ + public: + HUB75Display(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus); + ~HUB75Display(); + + // Write the buffer to the matrix (full-frame, region-aware color expansion). + void display() override; + + void setDisplayBrightness(uint8_t brightness); + + protected: + int getBufferOffset(void) override { return 0; } + + void sendCommand(uint8_t com) override; + + bool connect() override; + + private: + MatrixPanel_I2S_DMA *matrix = nullptr; + uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT; + + bool firstFrame = true; + bool haveThemeDefaults = false; + uint16_t lastOnBe = 0; + uint16_t lastOffBe = 0; + uint32_t lastColorSig = 0; +}; + +#endif // USE_HUB75 diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 024fa409f..73bf04b6d 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -29,6 +29,12 @@ along with this program. If not, see . #if HAS_SCREEN #include "EInkParallelDisplay.h" #include +#if defined(USE_HUB75) +#include "graphics/HUB75Display.h" // ESP32 HUB75 (I2S-DMA) +#endif +#if defined(HAS_HUB75_NATIVE) +#include "HUB75Native.h" // native/Portduino HUB75 (rpi-rgb-led-matrix), from variants/native/portduino +#endif #include "DisplayFormatters.h" #include "TimeFormatters.h" @@ -67,6 +73,9 @@ along with this program. If not, see . #include "mesh/Default.h" #include "mesh/generated/meshtastic/deviceonly.pb.h" #include "modules/ExternalNotificationModule.h" +#if BASEUI_HAS_GAMES +#include "modules/games/GamesModule.h" +#endif #include "modules/WaypointModule.h" #include "sleep.h" #include "target_specific.h" @@ -99,7 +108,11 @@ namespace graphics #define COMPASS_ACTIVE_FRAMERATE 20 // DEBUG +#if BASEUI_HAS_GAMES +#define NUM_EXTRA_FRAMES 4 // text message, debug frame, and the always-present games frame +#else #define NUM_EXTRA_FRAMES 3 // text message and debug frame +#endif // if defined a pixel will blink to show redraws // #define SHOW_REDRAWS #define ASCII_BELL '\x07' @@ -369,6 +382,43 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t updateUiFrame(ui); } +// Called to trigger an arcade-style initials picker (see showNumberPicker for the sibling flow). +void Screen::showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length, + std::function bannerCallback) +{ +#ifdef USE_EINK + EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus +#endif + if (length >= sizeof(NotificationRenderer::alphanumericValue)) + length = sizeof(NotificationRenderer::alphanumericValue) - 1; + + strncpy(NotificationRenderer::alertBannerMessage, message, 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; // Ensure null termination + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::textInputCallback = bannerCallback; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::curSelected = 0; + NotificationRenderer::current_notification_type = notificationTypeEnum::alphanumeric_picker; + NotificationRenderer::numDigits = length; + + // Seed each position from initialText (uppercased & filtered to A-Z/0-9), defaulting to 'A'. + const size_t seedLen = initialText ? strnlen(initialText, length) : 0; + for (uint8_t i = 0; i < length; i++) { + char c = (i < seedLen) ? initialText[i] : 'A'; + if (c >= 'a' && c <= 'z') + c = static_cast(c - 'a' + 'A'); + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'))) + c = 'A'; + NotificationRenderer::alphanumericValue[i] = c; + } + NotificationRenderer::alphanumericValue[length] = '\0'; + + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, 2); + ui->setTargetFPS(60); + updateUiFrame(ui); +} + void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback) { @@ -412,6 +462,17 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int pi.drawFrame(display, state, x, y); } +#if BASEUI_HAS_GAMES +// The games frame is a dedicated, always-present frame (unlike generic module frames it is placed +// at a fixed position right after home), so it draws through its own trampoline rather than the +// moduleFrames lockstep used by drawModuleFrame. +static void drawGamesFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + if (gamesModule) + gamesModule->drawFrame(display, state, x, y); +} +#endif + /** * Given a recent lat/lon return a guess of the heading the user is walking on. * @@ -499,6 +560,8 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O #else dispdev = new ST7796Spi(&SPI1, ST7796_RESET, ST7796_RS, ST7796_NSS, GEOMETRY_RAWMODE, TFT_WIDTH, TFT_HEIGHT); #endif +#elif defined(USE_HUB75) + dispdev = new HUB75Display(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE); #elif defined(USE_SSD1306) dispdev = new SSD1306Wire(address.address, -1, -1, geometry, (address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE); @@ -517,7 +580,17 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O LOG_INFO("SSD1306 init success"); } #elif ARCH_PORTDUINO - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + + // HUB75 RGB matrix (hzeller/rpi-rgb-led-matrix) is a BaseUI framebuffer panel, selected at + // runtime via config.yaml Display: Panel: HUB75. + if (portduino_config.displayPanel == hub75) { +#if defined(HAS_HUB75_NATIVE) + LOG_DEBUG("Make HUB75Native!"); + dispdev = new HUB75Native(address.address, -1, -1, GEOMETRY_RAWMODE, HW_I2C::I2C_ONE); +#else + LOG_ERROR("HUB75 panel requested but rpi-rgb-led-matrix not compiled in!"); +#endif + } else if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { if (portduino_config.displayPanel != no_screen) { LOG_DEBUG("Make TFTDisplay!"); dispdev = new TFTDisplay(address.address, -1, -1, geometry, @@ -1304,6 +1377,15 @@ void Screen::setFrames(FrameFocus focus) indicatorIcons.push_back(icon_home); } +#if BASEUI_HAS_GAMES + // Games frame: always present (even with no game running), positioned directly after home. + if (gamesModule) { + fsi.positions.games = numframes; + normalFrames[numframes++] = drawGamesFrame; + indicatorIcons.push_back(joystick_small); + } +#endif + fsi.positions.textMessage = numframes; normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; indicatorIcons.push_back(icon_mail); @@ -2076,6 +2158,12 @@ int Screen::handleInputEvent(const InputEvent *event) if (module && module->interceptingKeyboardInput()) inputIntercepted = true; } +#if BASEUI_HAS_GAMES + // The games frame isn't a moduleFrame, so check it explicitly: while a game is running it + // owns the D-pad (turns/pause) and we must not switch frames or open menus underneath it. + if (gamesModule && gamesModule->interceptingKeyboardInput()) + inputIntercepted = true; +#endif // If no modules are using the input, move between frames if (!inputIntercepted) { @@ -2150,6 +2238,11 @@ int Screen::handleInputEvent(const InputEvent *event) } else if (event->inputEvent == INPUT_BROKER_SELECT) { if (this->ui->getUiState()->currentFrame == framesetInfo.positions.home) { menuHandler::homeBaseMenu(); +#if BASEUI_HAS_GAMES + } else if (gamesModule && framesetInfo.positions.games != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.games) { + gamesModule->launchGame(); // launch the game shown on the attract screen +#endif } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.system) { menuHandler::systemBaseMenu(); #if HAS_GPS @@ -2217,6 +2310,11 @@ bool Screen::isOverlayBannerShowing() return NotificationRenderer::isOverlayBannerShowing(); } +bool Screen::isGamesFrameShown() +{ + return framesetInfo.positions.games != 255 && ui && ui->getUiState()->currentFrame == framesetInfo.positions.games; +} + } // namespace graphics #else diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index d72826996..4aeec6ce8 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -26,6 +26,9 @@ enum notificationTypeEnum { // LOCKED frame. Without this, a first-pair on a locked device cannot // complete because the PIN never renders. pairing_pin, + // Arcade-style initials entry: like number_picker/hex_picker, but each position cycles + // through A-Z and 0-9. The assembled string is returned via a text (std::string) callback. + alphanumeric_picker, }; struct BannerOverlayOptions { @@ -284,6 +287,10 @@ class Screen : public concurrency::OSThread bool isOverlayBannerShowing(); + // True if the always-present games frame is the one currently on screen. Lets the games module + // ignore D-pad input when the player has navigated to a different frame. + bool isGamesFrameShown(); + bool isScreenOn() { return screenOn; } // Stores the last 4 of our hardware ID, to make finding the device for pairing easier @@ -346,6 +353,11 @@ class Screen : public concurrency::OSThread void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, bool useBase16, std::function bannerCallback); + // Arcade-style initials entry. `length` positions each cycle A-Z/0-9 (UP/DOWN), LEFT/RIGHT + // moves the cursor, SELECT advances; the assembled string is delivered to `bannerCallback`. + // `initialText` pre-seeds the positions (uppercased & filtered), defaulting to 'A'. + void showAlphanumericPicker(const char *message, const char *initialText, uint32_t durationMs, uint8_t length, + std::function bannerCallback); void showTextInput(const char *header, const char *initialText, uint32_t durationMs, std::function textCallback); @@ -735,6 +747,7 @@ class Screen : public concurrency::OSThread uint8_t system = 255; uint8_t gps = 255; uint8_t home = 255; + uint8_t games = 255; uint8_t textMessage = 255; uint8_t nodelist_nodes = 255; uint8_t nodelist_location = 255; diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h index d0517ad4e..9de74b85a 100644 --- a/src/graphics/TFTColorRegions.h +++ b/src/graphics/TFTColorRegions.h @@ -16,8 +16,9 @@ struct TFTColorRegion { // Required by ST7789 driver: it scans until the first disabled entry. bool enabled = false; }; - +#ifndef MAX_TFT_COLOR_REGIONS static constexpr size_t MAX_TFT_COLOR_REGIONS = 48; +#endif extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS]; enum class TFTColorRole : uint8_t { @@ -39,7 +40,7 @@ enum class TFTColorRole : uint8_t { Count }; -#if HAS_TFT || defined(HAS_SPI_TFT) +#if HAS_TFT || defined(HAS_SPI_TFT) || defined(HAS_HUB75_NATIVE) #define GRAPHICS_TFT_COLORING_ENABLED 1 #else #define GRAPHICS_TFT_COLORING_ENABLED 0 diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 243a62932..b4dd5ab8a 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -743,17 +743,18 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1 display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); int line = 1; + int scale = 1; int iconX = SCREEN_WIDTH - chirpy_width - (chirpy_width / 3); int iconY = (SCREEN_HEIGHT - chirpy_height) / 2; int textX_offset = 10; if (currentResolution == ScreenResolution::High) { textX_offset = textX_offset * 4; - const int scale = 2; + scale = 2; + iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3); + iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2; const int bytesPerRow = (chirpy_width + 7) / 8; for (int yy = 0; yy < chirpy_height; ++yy) { - iconX = SCREEN_WIDTH - (chirpy_width * 2) - ((chirpy_width * 2) / 3); - iconY = (SCREEN_HEIGHT - (chirpy_height * 2)) / 2; const uint8_t *rowPtr = chirpy + yy * bytesPerRow; for (int xx = 0; xx < chirpy_width; ++xx) { const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); @@ -767,6 +768,19 @@ void drawChirpy(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int1 display->drawXbm(iconX, iconY, chirpy_width, chirpy_height, chirpy); } +#if GRAPHICS_TFT_COLORING_ENABLED + // Colour Chirpy on colour displays. The glyph is a filled head silhouette whose eyes are holes + // (off pixels), so two stacked regions render the proper mascot without a second bitmap: + // A) whole glyph -> green body / frame / legs + // B) the eye band -> black face, with the eye holes turning white via the region's off-colour + // Start the face band one column in from the head's left edge (col 6) so that edge stays green, + // matching the green column already left on the right edge (col 31). + graphics::registerTFTColorRegionDirect(iconX, iconY, chirpy_width * scale, chirpy_height * scale, + graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg()); + graphics::registerTFTColorRegionDirect(iconX + 7 * scale, iconY + 12 * scale, 24 * scale, 16 * scale, + graphics::TFTPalette::Black, graphics::TFTPalette::White); +#endif + int textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("Hello") / 2); display->drawString(textX, getTextPositions(display)[line++], "Hello"); textX = (display->getWidth() / 2) - textX_offset - (display->getStringWidth("World!") / 2); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 03e53c3bd..98f229b83 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -54,6 +54,7 @@ bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; uint32_t NotificationRenderer::numDigits = 0; uint32_t NotificationRenderer::currentNumber = 0; +char NotificationRenderer::alphanumericValue[16] = {0}; VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr; std::function NotificationRenderer::textInputCallback = nullptr; @@ -277,6 +278,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU case notificationTypeEnum::hex_picker: drawHexPicker(display, state); break; + case notificationTypeEnum::alphanumeric_picker: + drawAlphanumericPicker(display, state); + break; } } @@ -462,6 +466,96 @@ void NotificationRenderer::drawHexPicker(OLEDDisplay *display, OLEDDisplayUiStat drawNotificationBox(display, state, linePointers, totalLines, 0); } +// Arcade-style initials entry. Mirrors drawHexPicker's cursor/confirm flow, but each position +// holds a character from ALPHANUMERIC_CHARS (cycled with UP/DOWN) instead of a packed digit, and +// the assembled string is returned through textInputCallback. +void NotificationRenderer::drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + static const char ALPHANUMERIC_CHARS[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + constexpr int ALPHANUMERIC_COUNT = sizeof(ALPHANUMERIC_CHARS) - 1; // exclude the NUL + + const char *lineStarts[MAX_LINES + 1] = {0}; + uint16_t lineCount = 0; + + // Parse lines (identical to the number/hex pickers) + char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); + lineStarts[lineCount] = alertBannerMessage; + while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { + lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); + if (lineStarts[lineCount + 1][0] == '\n') + lineStarts[lineCount + 1] += 1; + lineCount++; + } + + auto alphaIndex = [&](char c) -> int { + for (int i = 0; i < ALPHANUMERIC_COUNT; i++) + if (ALPHANUMERIC_CHARS[i] == c) + return i; + return 0; + }; + + // Handle input + if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS || + inEvent.inputEvent == INPUT_BROKER_UP_LONG) { + int idx = (alphaIndex(alphanumericValue[curSelected]) + 1) % ALPHANUMERIC_COUNT; + alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx]; + } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS || + inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { + int idx = (alphaIndex(alphanumericValue[curSelected]) + ALPHANUMERIC_COUNT - 1) % ALPHANUMERIC_COUNT; + alphanumericValue[curSelected] = ALPHANUMERIC_CHARS[idx]; + } else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) { + char k = inEvent.kbchar; + if (k >= 'a' && k <= 'z') + k = static_cast(k - 'a' + 'A'); + if ((k >= 'A' && k <= 'Z') || (k >= '0' && k <= '9')) { // direct keyboard entry + alphanumericValue[curSelected] = k; + curSelected++; + } + } else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) { + curSelected++; + } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { + curSelected--; + } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && + alertBannerUntil != 0) { + resetBanner(); + return; + } + + if (curSelected < 0) + curSelected = 0; + if (curSelected == static_cast(numDigits)) { + auto callback = textInputCallback; // capture before clearing to avoid re-entrancy surprises + std::string result(alphanumericValue, numDigits); + textInputCallback = nullptr; + resetBanner(); + if (callback) + callback(result); + return; + } + + inEvent.inputEvent = INPUT_BROKER_NONE; + if (alertBannerMessage[0] == '\0') + return; + + uint16_t totalLines = lineCount + 2; + const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation + + for (uint16_t i = 0; i < lineCount; i++) { + linePointers[i] = lineStarts[i]; + } + std::string chars = " "; + std::string arrowPointer = " "; + for (uint16_t i = 0; i < numDigits; i++) { + chars += std::string(1, alphanumericValue[i]) + " "; + arrowPointer += (curSelected == static_cast(i)) ? "^ " : "_ "; + } + + linePointers[lineCount++] = chars.c_str(); + linePointers[lineCount++] = arrowPointer.c_str(); + + drawNotificationBox(display, state, linePointers, totalLines, 0); +} + void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state) { static uint32_t selectedNodenum = 0; diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 629cf61d8..08f6f74b0 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -26,6 +26,7 @@ class NotificationRenderer static std::function alertBannerCallback; static uint32_t numDigits; static uint32_t currentNumber; + static char alphanumericValue[16]; // working buffer for the alphanumeric_picker static VirtualKeyboard *virtualKeyboard; static std::function textInputCallback; @@ -43,6 +44,7 @@ class NotificationRenderer static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawHexPicker(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawAlphanumericPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1], diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 2e2a35f18..f38d27d7d 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -9,6 +9,9 @@ #if !MESHTASTIC_EXCLUDE_STATUS #include "modules/StatusMessageModule.h" #endif +#if BASEUI_HAS_GAMES +#include "modules/games/GamesModule.h" +#endif #include "UIRenderer.h" #include "airtime.h" #include "gps/GeoCoord.h" @@ -1817,6 +1820,13 @@ constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; // cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) { +#if BASEUI_HAS_GAMES + // Hide the navigation bar while a game owns the screen (the attract screen doesn't intercept, + // so the nav bar stays visible there). + if (gamesModule && gamesModule->interceptingKeyboardInput()) + return; +#endif + uint8_t frameToHighlight = state->currentFrame; if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) { frameToHighlight = state->transitionFrameTarget; diff --git a/src/graphics/images.h b/src/graphics/images.h index 95c0861fd..86d9efb7b 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -319,6 +319,46 @@ const uint8_t chirpy_small[] = {0x7f, 0x41, 0x55, 0x55, 0x55, 0x55, 0x41, 0x7f}; #define connection_icon_height 5 const uint8_t connection_icon[] = {0x36, 0x41, 0x5D, 0x41, 0x36}; +// Joystick icon (16x16): a round knob on a shaft over an elliptical base. Drawn on the Snake +// attract screen as a "use the D-pad" controls hint. +#define joystick_width 16 +#define joystick_height 16 +static const uint8_t joystick[] PROGMEM = {0xC0, 0x03, 0xE0, 0x07, 0xF0, 0x0F, 0xF0, 0x0F, 0xE0, 0x07, 0xC0, + 0x03, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0x80, 0x01, 0xF8, 0x1F, + 0xFC, 0x3F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFC, 0x3F, 0xF8, 0x1F}; + +// 8x8 joystick, for the navigation bar (which draws 8x8 glyphs, scaled up on hi-res displays). +#define joystick_small_width 8 +#define joystick_small_height 8 +static const uint8_t joystick_small[] PROGMEM = {0x18, 0x3C, 0x3C, 0x18, 0x7E, 0x99, 0xC3, 0x7E}; + +#define snake_width 16 +#define snake_height 16 +static const uint8_t snake[] PROGMEM = {0x80, 0x1F, 0x40, 0x20, 0x20, 0x48, 0x20, 0x40, 0xA0, 0x44, 0x20, + 0x39, 0x40, 0xDE, 0x86, 0x44, 0x0A, 0x19, 0x32, 0x22, 0xC4, 0x47, + 0x1C, 0x4D, 0x6A, 0x4A, 0xFA, 0x47, 0x04, 0x20, 0xF8, 0x1F}; + +#define tetris_width 16 +#define tetris_height 16 +static const uint8_t tetris[] PROGMEM = {0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x01, 0x80, 0xFD, 0xBF, 0xFD, + 0xBF, 0x81, 0x81, 0xBF, 0xFD, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, + 0x20, 0x04, 0xE0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +#define breakout_width 16 +#define breakout_height 16 +// Three staggered brick courses, a ball, and a paddle. XBM: 2 bytes/row, LSB = leftmost pixel. +static const uint8_t breakout[] PROGMEM = {0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0xDD, 0xDD, 0xDD, + 0xDD, 0x00, 0x00, 0x77, 0x77, 0x77, 0x77, 0x00, 0x00, 0x80, 0x01, + 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x0F, 0xF0, 0x0F}; + +// Tiny Chirpy runner sprite: square head with two pill eyes + mouth, antenna, and long legs. +// XBM, 2 bytes/row, LSB = leftmost pixel. Used by the Chirpy Runner game. +#define chirpy_run_width 12 +#define chirpy_run_height 16 +static const uint8_t chirpy_run[] PROGMEM = {0x40, 0x00, 0x20, 0x00, 0x40, 0x00, 0xfc, 0x03, 0x04, 0x02, 0x94, + 0x02, 0x94, 0x02, 0x94, 0x02, 0x04, 0x02, 0xf4, 0x02, 0xfc, 0x03, + 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x90, 0x00, 0x08, 0x01}; + #ifdef OLED_TINY #include "img/icon_small.xbm" #else diff --git a/src/input/LinuxJoystick.cpp b/src/input/LinuxJoystick.cpp index aaff43959..f6848a590 100644 --- a/src/input/LinuxJoystick.cpp +++ b/src/input/LinuxJoystick.cpp @@ -79,6 +79,16 @@ static int axisZone(int value) return 0; } +void LinuxJoystick::emitEvent(input_broker_event event) +{ + InputEvent e = {}; + e.inputEvent = event; + e.source = this->_originName; + e.kbchar = 0; + // LOG_DEBUG("joystick: %s event %d", this->_originName, event); + this->notifyObservers(&e); +} + int32_t LinuxJoystick::runOnce() { if (firstTime) { @@ -107,8 +117,6 @@ int32_t LinuxJoystick::runOnce() if (nfds < 0) { perror("joystick: epoll_wait failed"); return disable(); - } else if (nfds == 0) { - return 50; } for (int i = 0; i < nfds; i++) { @@ -117,42 +125,53 @@ int32_t LinuxJoystick::runOnce() if (rd < (signed int)sizeof(struct input_event)) continue; for (int j = 0; j < rd / ((signed int)sizeof(struct input_event)); j++) { - InputEvent e = {}; - e.inputEvent = INPUT_BROKER_NONE; - e.source = this->_originName; - e.kbchar = 0; unsigned int type = evs[j].type; unsigned int code = evs[j].code; int value = evs[j].value; if (type == EV_ABS) { - // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. - // Emit exactly once when an axis crosses from center to an edge. + // D-pad reports as ABS_X / ABS_Y with digital 0 / 127 / 255 values. Emit on the + // transition to an edge and arm auto-repeat; a held direction repeats below. if (code == ABS_X) { int zone = axisZone(value); - if (zone != axisZone(lastX) && zone != 0) - e.inputEvent = (zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT; - lastX = value; + if (zone != heldX) { + heldX = zone; + if (zone != 0) { + emitEvent((zone < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + nextRepeatX = millis() + JOY_REPEAT_DELAY_MS; + } + } } else if (code == ABS_Y) { int zone = axisZone(value); - if (zone != axisZone(lastY) && zone != 0) - e.inputEvent = (zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN; - lastY = value; + if (zone != heldY) { + heldY = zone; + if (zone != 0) { + emitEvent((zone < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + nextRepeatY = millis() + JOY_REPEAT_DELAY_MS; + } + } } } else if (type == EV_KEY && value == 1) { // Look up the configured action for this button; unmapped buttons are ignored. + // Buttons fire once per press (no auto-repeat). auto mapped = buttonMap.find(code); if (mapped != buttonMap.end()) - e.inputEvent = mapped->second; - } - - if (e.inputEvent != INPUT_BROKER_NONE) { - LOG_DEBUG("joystick: %s event %d", this->_originName, e.inputEvent); - this->notifyObservers(&e); + emitEvent(mapped->second); } } } + // Auto-repeat held D-pad directions. Signed comparison is wraparound-safe. + uint32_t now = millis(); + if (heldX != 0 && (int32_t)(now - nextRepeatX) >= 0) { + emitEvent((heldX < 0) ? INPUT_BROKER_LEFT : INPUT_BROKER_RIGHT); + nextRepeatX = now + JOY_REPEAT_INTERVAL_MS; + } + if (heldY != 0 && (int32_t)(now - nextRepeatY) >= 0) { + emitEvent((heldY < 0) ? INPUT_BROKER_UP : INPUT_BROKER_DOWN); + nextRepeatY = now + JOY_REPEAT_INTERVAL_MS; + } + return 50; // Poll every 50msec } diff --git a/src/input/LinuxJoystick.h b/src/input/LinuxJoystick.h index 6e6d3c398..7e309b961 100644 --- a/src/input/LinuxJoystick.h +++ b/src/input/LinuxJoystick.h @@ -20,6 +20,10 @@ #define JOY_AXIS_LOW 64 // below this -> "min" edge (0) #define JOY_AXIS_HIGH 192 // above this -> "max" edge (255) +// D-pad auto-repeat while a direction is held (typematic). +#define JOY_REPEAT_DELAY_MS 400 // hold this long before repeats start +#define JOY_REPEAT_INTERVAL_MS 150 // then repeat this often + class LinuxJoystick : public Observable, public concurrency::OSThread { public: @@ -27,10 +31,18 @@ class LinuxJoystick : public Observable, public concurrency: void init(); // Registers this source with the InputBroker void deInit(); // Strictly for cleanly "rebooting" the binary on native + // Current held D-pad zone, updated the instant the axis moves (independent of the typematic + // auto-repeat). -1 = left/up edge, 0 = centered, +1 = right/down edge. Lets a game poll for + // smooth continuous control instead of waiting for the slow repeat events. + int heldXZone() const { return heldX; } + int heldYZone() const { return heldY; } + protected: virtual int32_t runOnce() override; private: + void emitEvent(input_broker_event event); + const char *_originName; bool firstTime = true; @@ -42,10 +54,12 @@ class LinuxJoystick : public Observable, public concurrency: int fd = -1; int epollfd = -1; - // Latch the last emitted axis position so a held direction fires exactly - // once (on the transition away from center), not a continuous stream. - int lastX = JOY_AXIS_CENTER; - int lastY = JOY_AXIS_CENTER; + // D-pad auto-repeat state: currently held zone per axis (-1 / 0 / +1) and the + // next millis() timestamp at which to re-emit while the direction is held. + int heldX = 0; + int heldY = 0; + uint32_t nextRepeatX = 0; + uint32_t nextRepeatY = 0; }; extern LinuxJoystick *aLinuxJoystick; #endif diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 1e9380575..546651c5b 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -19,6 +19,9 @@ #if !MESHTASTIC_EXCLUDE_CANNEDMESSAGES #include "modules/CannedMessageModule.h" #endif +#if HAS_SCREEN && BASEUI_HAS_GAMES +#include "modules/games/GamesModule.h" +#endif #if !MESHTASTIC_EXCLUDE_DETECTIONSENSOR #include "modules/DetectionSensorModule.h" #endif @@ -206,6 +209,11 @@ void setupModules() cannedMessageModule = new CannedMessageModule(); } #endif +#if HAS_SCREEN && BASEUI_HAS_GAMES + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { + gamesModule = new GamesModule(); + } +#endif #if ARCH_PORTDUINO new HostMetricsModule(); #endif diff --git a/src/modules/games/Breakout.cpp b/src/modules/games/Breakout.cpp new file mode 100644 index 000000000..f60f4d8bf --- /dev/null +++ b/src/modules/games/Breakout.cpp @@ -0,0 +1,315 @@ +#include "Breakout.h" + +// =========================================================================== +// Pure BreakoutGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +uint32_t BreakoutGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +void BreakoutGame::buildBricks() +{ + for (uint8_t r = 0; r < BRICK_ROWS; r++) + for (uint8_t c = 0; c < BRICK_COLS; c++) + bricks[r][c] = 1; + bricksLeft = static_cast(BRICK_ROWS) * BRICK_COLS; +} + +void BreakoutGame::serveBall() +{ + // Centre the paddle and launch the ball upward from just above it, at a slight angle whose + // side is chosen randomly so successive serves are not identical. + paddleLeft = (BOARD_W - PADDLE_W) / 2; + ballPxX = static_cast(BOARD_W / 2) * SUBPX; + ballPxY = static_cast(PADDLE_Y - 2) * SUBPX; + ballVx = (nextRandom() & 1u) ? 28 : -28; + ballVy = -BALL_VY; +} + +void BreakoutGame::nextLevel() +{ + levelNum++; + buildBricks(); + serveBall(); +} + +void BreakoutGame::reset(uint32_t seed) +{ + rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0 + points = 0; + livesLeft = START_LIVES; + levelNum = 1; + alive = true; + ballTick = false; + buildBricks(); + serveBall(); +} + +void BreakoutGame::movePaddle(int16_t dxPx) +{ + if (!alive) + return; + paddleLeft += dxPx; + if (paddleLeft < 0) + paddleLeft = 0; + else if (paddleLeft > BOARD_W - PADDLE_W) + paddleLeft = BOARD_W - PADDLE_W; +} + +void BreakoutGame::moveLeft() +{ + movePaddle(-PADDLE_STEP); +} + +void BreakoutGame::moveRight() +{ + movePaddle(PADDLE_STEP); +} + +bool BreakoutGame::step() +{ + if (!alive) + return false; + + // The ball advances on every other step() so the caller can tick (and poll the paddle) at twice + // the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster. + ballTick = !ballTick; + if (!ballTick) + return true; + + ballPxX += ballVx; + ballPxY += ballVy; + + int16_t px = static_cast(ballPxX / SUBPX); + int16_t py = static_cast(ballPxY / SUBPX); + + // Side walls. + if (px <= 0) { + ballPxX = 0; + px = 0; + ballVx = -ballVx; + } else if (px >= BOARD_W - 1) { + ballPxX = static_cast(BOARD_W - 1) * SUBPX; + px = BOARD_W - 1; + ballVx = -ballVx; + } + // Top wall. + if (py <= 0) { + ballPxY = 0; + py = 0; + ballVy = -ballVy; + } + + // Bricks: at most one brick per step (single, blocky reflection off the bottom/top face). + if (py >= BRICK_TOP && py < BRICK_TOP + BRICK_ROWS * BRICK_H) { + const int r = (py - BRICK_TOP) / BRICK_H; + const int c = px / BRICK_W; + if (r >= 0 && r < BRICK_ROWS && c >= 0 && c < BRICK_COLS && bricks[r][c]) { + bricks[r][c] = 0; + bricksLeft--; + points += POINTS_PER_BRICK; + ballVy = -ballVy; + if (bricksLeft == 0) { + nextLevel(); + return true; + } + } + } + + // Paddle: bounce up and steer horizontally based on where the ball struck. + if (ballVy > 0 && py >= PADDLE_Y - 1 && py <= PADDLE_Y + PADDLE_H) { + if (px >= paddleLeft && px < paddleLeft + PADDLE_W) { + ballPxY = static_cast(PADDLE_Y - 1) * SUBPX; + ballVy = -BALL_VY; + // Six zones across the paddle map to increasing outward angles; no zone is vertical. + static const int16_t vxByZone[6] = {-48, -28, -8, 8, 28, 48}; + int zone = ((px - paddleLeft) * 6) / PADDLE_W; + if (zone < 0) + zone = 0; + else if (zone > 5) + zone = 5; + ballVx = vxByZone[zone]; + } + } + + // Ball lost past the bottom edge. + if (py >= BOARD_H) { + if (livesLeft > 0) + livesLeft--; + if (livesLeft == 0) { + alive = false; + return false; + } + serveBall(); + } + + return alive; +} + +// =========================================================================== +// Breakout adapter (display + persistence; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" +#if ARCH_PORTDUINO && defined(__linux__) +#include "input/LinuxJoystick.h" +#endif + +// Paddle pixels moved per tick while a joystick direction is held (continuous polling path). +static constexpr int16_t PADDLE_POLL_STEP = 3; + +#if GRAPHICS_TFT_COLORING_ENABLED +// Classic Breakout brick-wall colours, top row to bottom. +static uint16_t brickRowColor(uint8_t row) +{ + using namespace graphics; + switch (row) { + case 0: + return TFTPalette::Red; + case 1: + return TFTPalette::Orange; + case 2: + return TFTPalette::Yellow; + case 3: + return TFTPalette::Green; + default: + return TFTPalette::Cyan; + } +} +#endif + +Breakout::Breakout() +{ + scores_.load(); +} + +int32_t Breakout::tickIntervalMs() const +{ + // Tick at twice the ball's cadence: the ball advances every other step() (BreakoutGame::step), + // so halving the interval keeps the ball speed the same while the paddle is polled/redrawn twice + // as often. Speed ramps with level: ~22 ms base, floor 10 ms. + int32_t iv = 45 - static_cast(game.level() - 1) * 5; + if (iv < 20) + iv = 20; + return iv / 2; +} + +bool Breakout::tick() +{ +#if ARCH_PORTDUINO && defined(__linux__) + // Poll the joystick's held direction directly so the paddle glides continuously instead of + // creeping along at the D-pad's slow auto-repeat rate. + if (aLinuxJoystick) { + const int held = aLinuxJoystick->heldXZone(); + if (held < 0) + game.movePaddle(-PADDLE_POLL_STEP); + else if (held > 0) + game.movePaddle(PADDLE_POLL_STEP); + } +#endif + return game.step(); +} + +void Breakout::handleInput(input_broker_event ev) +{ +#if ARCH_PORTDUINO && defined(__linux__) + // When a joystick is present the paddle is polled continuously in tick(); ignore the discrete + // (and slow) repeat events so we don't double-move. + if (aLinuxJoystick) + return; +#endif + switch (ev) { + case INPUT_BROKER_LEFT: + game.moveLeft(); + break; + case INPUT_BROKER_RIGHT: + game.moveRight(); + break; + default: + break; + } +} + +void Breakout::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "B R E A K O U T"); + const int16_t logoX = x + (w - breakout_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, breakout_width, breakout_height, breakout); +#if GRAPHICS_TFT_COLORING_ENABLED + // The glyph is three brick courses, a ball, and a paddle -- colour each to match the game. + const uint16_t abg = graphics::getThemeBodyBg(); + graphics::registerTFTColorRegionDirect(logoX, logoY + 1, breakout_width, 2, graphics::TFTPalette::Red, abg); + graphics::registerTFTColorRegionDirect(logoX, logoY + 4, breakout_width, 2, graphics::TFTPalette::Yellow, abg); + graphics::registerTFTColorRegionDirect(logoX, logoY + 7, breakout_width, 2, graphics::TFTPalette::Green, abg); + graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 14, 8, 2, graphics::TFTPalette::Blue, abg); // paddle + graphics::registerTFTColorRegionDirect(logoX + 7, logoY + 10, 2, 2, graphics::TFTPalette::White, abg); // ball +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Score row (top-left), remaining lives as small squares (top-right). + char buf[16]; + display->setTextAlignment(TEXT_ALIGN_LEFT); + snprintf(buf, sizeof(buf), "Sc %lu", static_cast(game.score())); + display->drawString(x + 2, y, buf); + for (uint8_t i = 0; i < game.lives(); i++) + display->fillRect(x + display->getWidth() - 3 - i * 4, y + 2, 2, 2); + + // Bricks. + for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++) + for (uint8_t c = 0; c < BreakoutGame::BRICK_COLS; c++) + if (game.brickAt(r, c)) + display->fillRect(x + c * BreakoutGame::BRICK_W, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, + BreakoutGame::BRICK_W - 1, BreakoutGame::BRICK_H - 1); + + // Paddle. + display->fillRect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, BreakoutGame::PADDLE_H); + + // Ball. + display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2); + +#if GRAPHICS_TFT_COLORING_ENABLED + // Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's + // lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register + // last so the ball always wins where it overlaps. + const uint16_t bg = graphics::getThemeBodyBg(); + for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++) + graphics::registerTFTColorRegionDirect(x, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, BreakoutGame::BOARD_W, + BreakoutGame::BRICK_H - 1, brickRowColor(r), bg); + graphics::registerTFTColorRegionDirect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, + BreakoutGame::PADDLE_H, graphics::TFTPalette::Blue, bg); + graphics::registerTFTColorRegionDirect(x + game.ballX(), y + game.ballY(), 2, 2, graphics::TFTPalette::White, bg); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Breakout.h b/src/modules/games/Breakout.h new file mode 100644 index 000000000..9c4fe8dfb --- /dev/null +++ b/src/modules/games/Breakout.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include + +/** + * Pure, self-contained Breakout game logic. + * + * No Arduino/display dependencies - designed to be unit-tested natively (see test/test_breakout) + * and reused by the Breakout adapter below without pulling in the display stack. + * + * The board is a fixed BOARD_W x BOARD_H pixel field. A grid of bricks sits near the top, a paddle + * slides along the bottom, and a ball bounces between them. Ball position/velocity are tracked in + * fixed-point sub-pixels (SUBPX per pixel) so movement is smooth and deterministic; everything is + * integer math and statically sized (no heap). + */ +class BreakoutGame +{ + public: + // Playfield, in pixels (a standard 128x64 OLED in landscape). + static constexpr int16_t BOARD_W = 128; + static constexpr int16_t BOARD_H = 64; + + // Brick grid. + static constexpr uint8_t BRICK_COLS = 8; + static constexpr uint8_t BRICK_ROWS = 5; + static constexpr int16_t BRICK_W = BOARD_W / BRICK_COLS; // 16 + static constexpr int16_t BRICK_H = 4; + static constexpr int16_t BRICK_TOP = 12; // top of the first course; leaves room for the score row + + // Paddle. + static constexpr int16_t PADDLE_W = 24; + static constexpr int16_t PADDLE_H = 2; + static constexpr int16_t PADDLE_Y = BOARD_H - 3; // top edge of the paddle + static constexpr int16_t PADDLE_STEP = 6; // pixels moved per input event + + static constexpr uint8_t START_LIVES = 3; + static constexpr uint8_t POINTS_PER_BRICK = 10; + + /** (Re)start a full game: rebuild bricks, reset lives/score, serve the ball. `seed` drives the + * xorshift32 RNG used for the initial serve direction. */ + void reset(uint32_t seed); + + /** Advance the simulation by one tick (move the ball, resolve collisions). Returns true if the + * game is still in progress, false once the last life is lost. No-op returning false once dead. */ + bool step(); + + /** Slide the paddle; the ball is unaffected. moveLeft/moveRight use the default per-press step; + * movePaddle takes an explicit signed pixel delta (used when polling a held joystick). */ + void movePaddle(int16_t dxPx); + void moveLeft(); + void moveRight(); + + bool isPlaying() const { return alive; } + uint32_t score() const { return points; } + uint8_t lives() const { return livesLeft; } + uint8_t level() const { return levelNum; } + uint16_t bricksRemaining() const { return bricksLeft; } + + // --- Rendering accessors (all in board pixels) --- + int16_t paddleX() const { return paddleLeft; } + int16_t ballX() const { return static_cast(ballPxX / SUBPX); } + int16_t ballY() const { return static_cast(ballPxY / SUBPX); } + bool brickAt(uint8_t row, uint8_t col) const { return row < BRICK_ROWS && col < BRICK_COLS && bricks[row][col]; } + + private: + static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel + static constexpr int32_t BALL_VY = 40; // vertical ball speed (sub-pixels/step) + static constexpr int16_t BALL_SIZE = 2; // ball is drawn BALL_SIZE x BALL_SIZE + + void buildBricks(); + void serveBall(); + void nextLevel(); + uint32_t nextRandom(); + + uint8_t bricks[BRICK_ROWS][BRICK_COLS] = {0}; // 0 == cleared, 1 == present + uint16_t bricksLeft = 0; + + int16_t paddleLeft = 0; // left edge of the paddle, in pixels + + int32_t ballPxX = 0, ballPxY = 0; // ball centre, in sub-pixels + int32_t ballVx = 0, ballVy = 0; // ball velocity, in sub-pixels/step + + uint32_t points = 0; + uint8_t livesLeft = 0; + uint8_t levelNum = 1; + uint32_t rng = 1; // xorshift32 state (never 0) + bool alive = false; + bool ballTick = false; // ball advances on every other step() (see step()) +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Breakout as a hosted Game. Wraps the pure BreakoutGame logic above and supplies the attract art, + * the playfield renderer, the paddle input, the level-based speed curve, and its own local + * high-score table. No mesh protocol (scores are local-only). + */ +class Breakout : public Game +{ + public: + Breakout(); + + const char *name() const override { return "Breakout"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override; // polls a held joystick for the paddle, then advances the ball + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override; + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record. New file (magic 'BRKT', version 1); layout mirrors Snake's. + struct BreakoutEntry { + uint32_t score; + uint32_t nodeNum; + char shortName[5]; // three characters, NUL-terminated + uint32_t epoch; // getValidTime(), 0 if no RTC + } __attribute__((packed)); + + BreakoutGame game; + HighScoreTable scores_{"/prefs/breakout.dat", 0x424B5254u, 1, "Breakout"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/ChirpyRunner.cpp b/src/modules/games/ChirpyRunner.cpp new file mode 100644 index 000000000..4e1a18521 --- /dev/null +++ b/src/modules/games/ChirpyRunner.cpp @@ -0,0 +1,303 @@ +#include "ChirpyRunner.h" + +// =========================================================================== +// Pure ChirpyRunnerGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +uint32_t ChirpyRunnerGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +int16_t ChirpyRunnerGame::pickGapSteps() +{ + return static_cast(GAP_STEPS_MIN + static_cast(nextRandom() % (GAP_STEPS_MAX - GAP_STEPS_MIN + 1))); +} + +void ChirpyRunnerGame::resetClouds() +{ + // Spread the clouds across the sky at staggered x, at varied heights near the top. + for (uint8_t i = 0; i < CLOUD_COUNT; i++) { + cloud[i].xSub = static_cast(i * (BOARD_W / CLOUD_COUNT) + 6) * SUBPX; + cloud[i].y = static_cast(10 + nextRandom() % 10u); // 10..19 (below the score row) + } +} + +void ChirpyRunnerGame::scrollClouds() +{ + // Slow parallax drift; wrap back to the right (at a fresh height) once off the left edge. + for (uint8_t i = 0; i < CLOUD_COUNT; i++) { + cloud[i].xSub -= CLOUD_SPEED_SUB; + if (cloud[i].xSub / SUBPX + CLOUD_W < 0) { + cloud[i].xSub = static_cast(BOARD_W + nextRandom() % 24u) * SUBPX; + cloud[i].y = static_cast(10 + nextRandom() % 10u); + } + } +} + +void ChirpyRunnerGame::spawnObstacle() +{ + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) { + if (obst[i].active) + continue; + obst[i].active = true; + obst[i].scored = false; + obst[i].xSub = static_cast(BOARD_W) * SUBPX; + obst[i].w = OBST_W; + // Three height tiers so timing varies (kept clearable with margin for a forgiving jump). + const uint32_t tier = nextRandom() % 3u; + obst[i].h = BUILDING_HEIGHTS[tier]; + obst[i].colorIdx = static_cast((spawnCount / 10u) % OBST_COLOR_COUNT); + spawnCount++; + return; + } +} + +void ChirpyRunnerGame::reset(uint32_t seed) +{ + rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0 + points = 0; + alive = true; + chirpyTop = groundedTopSub(); + vy = 0; + jumpGravity = GRAVITY; + grounded = true; + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) + obst[i] = {}; + speedSub = SPEED_BASE; + spawnTimer = 0; // first obstacle spawns on the first step + spawnCount = 0; + resetClouds(); +} + +int32_t ChirpyRunnerGame::jumpScaleQ8() const +{ + // ratio = current scroll speed / base scroll speed, in Q8 (256 == 1.0). + const int32_t ratioQ8 = (speedSub * 256) / SPEED_BASE; + // Feed only JUMP_SPEEDUP_PCT of the speed increase into the jump scale: k = 1 + (ratio-1)*pct. + const int32_t kQ8 = 256 + ((ratioQ8 - 256) * JUMP_SPEEDUP_PCT) / 100; + return kQ8 < 256 ? 256 : kQ8; // never slower than the base hop +} + +void ChirpyRunnerGame::jump() +{ + if (!alive || !grounded) + return; + // Scale velocity by k and gravity by k*k: the apex height is unchanged (Chirpy still clears the + // same buildings) but the air-time shrinks by k, so the clearing window tightens with the speed. + const int32_t kQ8 = jumpScaleQ8(); + vy = -(JUMP_V * kQ8) / 256; + jumpGravity = (GRAVITY * kQ8 * kQ8) / (256 * 256); + if (jumpGravity < GRAVITY) + jumpGravity = GRAVITY; + grounded = false; +} + +bool ChirpyRunnerGame::step() +{ + if (!alive) + return false; + + scrollClouds(); // decorative background parallax + + // --- Chirpy vertical motion (gravity latched at launch, so the arc stays consistent mid-air) --- + vy += jumpGravity; + chirpyTop += vy; + const int32_t gt = groundedTopSub(); + if (chirpyTop >= gt) { + chirpyTop = gt; + vy = 0; + grounded = true; + } else { + grounded = false; + } + + // --- Scroll obstacles, score, retire off-screen ones --- + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) { + if (!obst[i].active) + continue; + obst[i].xSub -= speedSub; + const int16_t ox = obstacleX(i); + if (!obst[i].scored && ox + obst[i].w < CHIRPY_X) { + obst[i].scored = true; + points++; + } + if (ox + obst[i].w < 0) + obst[i].active = false; + } + + // --- Spawn on a tick timer (time-based spacing that scales with speed) --- + if (spawnTimer > 0) + spawnTimer--; + if (spawnTimer <= 0) { + spawnObstacle(); + spawnTimer = pickGapSteps(); + } + + // --- Difficulty ramp (scroll speed grows with score, then caps) --- + const uint32_t capped = points < SPEED_CAP_PTS ? points : SPEED_CAP_PTS; + speedSub = SPEED_BASE + static_cast(capped) * SPEED_INC; + + // --- Collision (forgiving hitbox: skip the antenna, inset the sides) --- + const int16_t hx = CHIRPY_X + 2; + const int16_t hxr = hx + (CHIRPY_W - 4); + const int16_t hBottom = chirpyY() + CHIRPY_H; + const int16_t hTop = chirpyY() + 4; + for (uint8_t i = 0; i < MAX_OBSTACLES; i++) { + if (!obst[i].active) + continue; + const int16_t ox = obstacleX(i); + const int16_t oxr = ox + obst[i].w; + const int16_t oTop = GROUND_Y - obst[i].h; + if (hx < oxr && hxr > ox && hTop < GROUND_Y && hBottom > oTop) { + alive = false; + return false; + } + } + + return alive; +} + +// =========================================================================== +// ChirpyRunner adapter (display + persistence; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" + +ChirpyRunner::ChirpyRunner() +{ + scores_.load(); +} + +void ChirpyRunner::handleInput(input_broker_event ev) +{ + // SELECT is the jump (as requested); UP is accepted as a convenient alternate. + if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP) + game.jump(); +} + +void ChirpyRunner::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "CHIRPY DASH"); + const int16_t logoX = x + (w - chirpy_run_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, chirpy_run_width, chirpy_run_height, chirpy_run); +#if GRAPHICS_TFT_COLORING_ENABLED + // Chirpy is green, with white eyes. The eyes are the lit pixels at rows 5-7, cols 4-7 of the + // glyph; a white region registered after the green one wins there. + graphics::registerTFTColorRegionDirect(logoX, logoY, chirpy_run_width, chirpy_run_height, + graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg()); + graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg()); +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +#if GRAPHICS_TFT_COLORING_ENABLED +// Obstacle colour palette; the game logic advances the index every 10 spawns. +static uint16_t obstacleColor(uint8_t idx) +{ + using namespace graphics; + switch (idx) { + case 0: + return TFTPalette::Red; + case 1: + return TFTPalette::Orange; + case 2: + return TFTPalette::Yellow; + case 3: + return TFTPalette::Magenta; + case 4: + return TFTPalette::Cyan; + default: + return TFTPalette::Blue; + } +} +#endif + +void ChirpyRunner::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Clouds drifting in the background (drawn first so everything else sits in front). + for (uint8_t i = 0; i < ChirpyRunnerGame::cloudSlots(); i++) { + const int16_t cxp = x + game.cloudX(i); + const int16_t cyp = y + game.cloudY(i); + display->fillRect(cxp + 2, cyp, 4, 1); + display->fillRect(cxp + 1, cyp + 1, 6, 1); + display->fillRect(cxp, cyp + 2, 8, 1); +#if GRAPHICS_TFT_COLORING_ENABLED + graphics::registerTFTColorRegionDirect(cxp, cyp, 8, 3, graphics::TFTPalette::LightGray, graphics::getThemeBodyBg()); +#endif + } + + // Score (top-left). + char buf[16]; + display->setTextAlignment(TEXT_ALIGN_LEFT); + snprintf(buf, sizeof(buf), "Sc %lu", static_cast(game.score())); + display->drawString(x + 2, y, buf); + + // Ground line. + display->drawLine(x, y + ChirpyRunnerGame::GROUND_Y, x + display->getWidth() - 1, y + ChirpyRunnerGame::GROUND_Y); + + // Obstacles drawn as little buildings: a solid tower with two columns of punched-out windows + // (dark holes). On colour displays the walls cycle colour every 10 spawns and the windows glow + // (they are the region's off-pixels, so they take the off-colour). + for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) { + if (!game.obstacleActive(i)) + continue; + const int16_t oh = game.obstacleH(i); + const int16_t ow = game.obstacleW(i); + const int16_t oxp = x + game.obstacleX(i); + const int16_t oyp = y + ChirpyRunnerGame::GROUND_Y - oh; + + display->setColor(WHITE); + display->fillRect(oxp, oyp, ow, oh); + // Windows: 1px holes in the left and right columns, every other row, skipping the roof row + // and the ground-floor rows so the tower reads as a building. + display->setColor(BLACK); + for (int16_t wy = oyp + 2; wy <= oyp + oh - 3; wy += 2) { + display->fillRect(oxp + 1, wy, 1, 1); + display->fillRect(oxp + ow - 2, wy, 1, 1); + } + display->setColor(WHITE); +#if GRAPHICS_TFT_COLORING_ENABLED + graphics::registerTFTColorRegionDirect(oxp, oyp, ow, oh, obstacleColor(game.obstacleColorIndex(i)), + graphics::TFTPalette::White); // lit windows +#endif + } + + // Chirpy. + const int16_t cxp = x + ChirpyRunnerGame::CHIRPY_X; + const int16_t cyp = y + game.chirpyY(); + display->drawXbm(cxp, cyp, chirpy_run_width, chirpy_run_height, chirpy_run); +#if GRAPHICS_TFT_COLORING_ENABLED + graphics::registerTFTColorRegionDirect(cxp, cyp, chirpy_run_width, chirpy_run_height, graphics::TFTPalette::MeshtasticGreen, + graphics::getThemeBodyBg()); + graphics::registerTFTColorRegionDirect(cxp + 4, cyp + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg()); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/ChirpyRunner.h b/src/modules/games/ChirpyRunner.h new file mode 100644 index 000000000..b86e1d775 --- /dev/null +++ b/src/modules/games/ChirpyRunner.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include + +/** + * Pure, self-contained Chirpy Runner game logic (a dino-runner / flappy-style side-scroller). + * + * No Arduino/display dependencies - designed to be unit-tested natively (see test/test_chirpy) + * and reused by the ChirpyRunner adapter below without pulling in the display stack. + * + * Chirpy runs in place at a fixed x on the ground; obstacles scroll in from the right and the + * player presses jump (SELECT) to hop over them. Gravity pulls Chirpy back down. Clearing an + * obstacle scores a point and nudges the scroll speed up. A collision ends the run. Chirpy's + * vertical motion is tracked in fixed-point sub-pixels (SUBPX per pixel); everything is integer + * math and statically sized (no heap). + */ +class ChirpyRunnerGame +{ + public: + // Playfield, in pixels (a standard 128x64 OLED in landscape). + static constexpr int16_t BOARD_W = 128; + static constexpr int16_t BOARD_H = 64; + + // Chirpy sprite box and fixed horizontal position; GROUND_Y is where his feet rest. + static constexpr int16_t CHIRPY_W = 12; + static constexpr int16_t CHIRPY_H = 16; + static constexpr int16_t CHIRPY_X = 14; + static constexpr int16_t GROUND_Y = BOARD_H - 2; // 62 + + static constexpr uint8_t MAX_OBSTACLES = 4; + static constexpr int16_t OBST_W = 6; + static constexpr uint8_t OBST_COLOR_COUNT = 6; // obstacle colour advances every 10 spawns + static constexpr uint8_t CLOUD_COUNT = 3; // decorative background clouds + + /** (Re)start a run: Chirpy on the ground, no obstacles yet, score 0. `seed` drives the + * xorshift32 RNG used for obstacle heights and spacing. */ + void reset(uint32_t seed); + + /** Advance one tick (gravity + scroll + spawn + collision). Returns true while the run is in + * progress, false once Chirpy hits an obstacle. No-op returning false once dead. */ + bool step(); + + /** Hop, if Chirpy is on the ground (single jump; ignored while airborne). */ + void jump(); + + bool isPlaying() const { return alive; } + uint32_t score() const { return points; } + bool onGround() const { return grounded; } + + // --- Rendering accessors (board pixels) --- + int16_t chirpyY() const { return static_cast(chirpyTop / SUBPX); } // sprite top + static constexpr uint8_t obstacleSlots() { return MAX_OBSTACLES; } + bool obstacleActive(uint8_t i) const { return i < MAX_OBSTACLES && obst[i].active; } + int16_t obstacleX(uint8_t i) const { return static_cast(obst[i].xSub / SUBPX); } + uint8_t obstacleW(uint8_t i) const { return obst[i].w; } + uint8_t obstacleH(uint8_t i) const { return obst[i].h; } + uint8_t obstacleColorIndex(uint8_t i) const { return obst[i].colorIdx; } + static constexpr uint8_t cloudSlots() { return CLOUD_COUNT; } + int16_t cloudX(uint8_t i) const { return static_cast(cloud[i].xSub / SUBPX); } + int16_t cloudY(uint8_t i) const { return cloud[i].y; } + + private: + static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel + static constexpr int32_t GRAVITY = 7; // downward accel (sub-px/step^2) + static constexpr int32_t JUMP_V = 75; // initial upward velocity on a hop (sub-px/step) + static constexpr int32_t SPEED_BASE = 32; // base scroll speed (sub-px/step == 2 px) + static constexpr int32_t SPEED_INC = 2; // speed added per point scored + static constexpr uint32_t SPEED_CAP_PTS = 40; // score at which the speed ramp caps (== 5 px/step) + // Obstacle spacing is measured in TICKS, not pixels, so the time between obstacles stays + // constant as the scroll speed ramps up -- otherwise a fixed pixel gap collapses into fewer + // ticks at high speed until it drops below the jump duration and clearing becomes impossible. + // The min is kept just above the ~22-tick jump so obstacles come in a tight but landable rhythm. + static constexpr int16_t GAP_STEPS_MIN = 23; // min ticks between spawns (> jump duration) + static constexpr int16_t GAP_STEPS_MAX = 40; // max ticks between spawns + + // Chirpy's hop scales with the scroll speed. A fixed-length jump actually makes the game EASIER + // as the buildings speed up: his "high enough to clear" window stays ~constant while the time a + // building spends crossing his x-range shrinks (width / speed). Scaling the launch velocity by k + // and gravity by k*k keeps the apex height identical (so he still clears the same buildings) + // while cutting the air-time by k, which shrinks the clearing window in step with the world. + // JUMP_SPEEDUP_PCT is how much of the scroll-speed increase feeds into k: + // 100 == fully proportional (difficulty stays flat), lower == Chirpy speeds up sub-linearly + // (so it still eases off slightly at high speed), higher == it gets harder. + static constexpr int32_t JUMP_SPEEDUP_PCT = 50; + + static constexpr int8_t BUILDING_HEIGHTS[] = {8, 12, 16}; // three tiers of obstacle heights (pixels) + + static constexpr int32_t CLOUD_SPEED_SUB = 6; // background scroll speed (sub-px/step, slow parallax) + static constexpr int16_t CLOUD_W = 8; // cloud puff width (for off-screen wrap) + + struct Obstacle { + int32_t xSub; // left edge, sub-pixels + uint8_t w; + uint8_t h; + uint8_t colorIdx; // which colour batch (advances every 10 spawns) + bool active; + bool scored; + }; + + struct Cloud { + int32_t xSub; // left edge, sub-pixels + int16_t y; // top, pixels + }; + + int32_t groundedTopSub() const { return static_cast(GROUND_Y - CHIRPY_H) * SUBPX; } + // Jump scale factor k for the current scroll speed, in Q8 fixed point (256 == 1.0 == base speed). + int32_t jumpScaleQ8() const; + uint32_t nextRandom(); + void spawnObstacle(); + int16_t pickGapSteps(); + void resetClouds(); + void scrollClouds(); + + int32_t chirpyTop = 0; // sprite-top y, sub-pixels + int32_t vy = 0; // vertical velocity, sub-pixels/step + int32_t jumpGravity = GRAVITY; // gravity for the current hop (latched at launch, scaled by k*k) + bool grounded = true; + + Obstacle obst[MAX_OBSTACLES] = {}; + Cloud cloud[CLOUD_COUNT] = {}; + int32_t speedSub = SPEED_BASE; + int16_t spawnTimer = 0; // ticks until the next obstacle spawns + uint32_t spawnCount = 0; // total obstacles spawned (drives the colour cycle) + + uint32_t points = 0; + uint32_t rng = 1; // xorshift32 state (never 0) + bool alive = false; +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Chirpy Runner as a hosted Game. Wraps the pure logic above and supplies the attract art, the + * side-scroller renderer (Chirpy sprite + obstacles + ground), the jump input, and its own + * local high-score table. No mesh protocol (scores are local-only). + */ +class ChirpyRunner : public Game +{ + public: + ChirpyRunner(); + + const char *name() const override { return "Chirpy Dash"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override { return game.step(); } + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override { return 33; } // ~30 fps; difficulty ramps via scroll speed + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record. New file (magic 'CHRP', version 1); layout mirrors Snake's. + struct ChirpyEntry { + uint32_t score; + uint32_t nodeNum; + char shortName[5]; // three characters, NUL-terminated + uint32_t epoch; // getValidTime(), 0 if no RTC + } __attribute__((packed)); + + ChirpyRunnerGame game; + HighScoreTable scores_{"/prefs/chirpy.dat", 0x43485250u, 1, "Chirpy"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Game.h b/src/modules/games/Game.h new file mode 100644 index 000000000..751b2d6b5 --- /dev/null +++ b/src/modules/games/Game.h @@ -0,0 +1,57 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "HighScoreTable.h" +#include "input/InputBroker.h" // input_broker_event +#include "mesh/MeshModule.h" // ProcessMessage, meshtastic_MeshPacket +#include + +class OLEDDisplay; +class OLEDDisplayUiState; +class GamesModule; + +/** + * A single game hosted by GamesModule. The host owns the shared UI state machine (attract / + * playing / paused / game-over / high-scores), the initials picker, high-score persistence calls, + * the tick thread, and the mesh port; each Game supplies only the game-specific pieces: its + * attract art, its playfield, its per-key input while playing, its speed curve, and its own + * high-score table (and, optionally, a mesh announce/receive protocol). + */ +class Game +{ + public: + virtual ~Game() = default; + + virtual const char *name() const = 0; + + // --- Lifecycle --- + virtual void start(uint32_t seed) = 0; // (re)start the underlying game logic + virtual bool tick() = 0; // advance one step; returns isPlaying() afterwards + virtual bool isPlaying() const = 0; + virtual uint32_t score() const = 0; + virtual int32_t tickIntervalMs() const = 0; // per-game speed curve + + // --- Input while PLAYING (the host handles the BACK-to-pause and menu keys) --- + virtual void handleInput(input_broker_event ev) = 0; + + // --- Rendering (the host draws the shared PAUSED / GAME OVER / HIGH SCORES chrome) --- + virtual void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) = 0; // title/art + hi + hint + virtual void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) = 0; // playfield only + virtual const char *gameOverHint() const { return "SELECT: scores"; } + + // --- High scores (the host runs the initials picker + save) --- + virtual HighScoreTableBase &scores() = 0; + + // --- Mesh (defaults are no-ops; only games with a wire protocol override these) --- + // Note: the new-high-score announcement is NOT here -- it is shared by every game and lives in + // GamesModule (which splices name() into one common message). + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) { return ProcessMessage::CONTINUE; } + virtual bool wantsPeriodicMesh() const { return false; } + // Perform any due periodic broadcast and return ms until the next one (-1 == nothing pending). + virtual int32_t meshTick(GamesModule &host) { return -1; } +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp new file mode 100644 index 000000000..1d72ba632 --- /dev/null +++ b/src/modules/games/GamesModule.cpp @@ -0,0 +1,373 @@ +#include "GamesModule.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Breakout.h" +#include "ChirpyRunner.h" +#include "PowerFSM.h" +#include "Snake.h" +#include "Tetris.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "main.h" +#include "mesh/NodeDB.h" +#if GAMES_ANNOUNCE_HIGH_SCORE +#include "MeshService.h" +#endif + +GamesModule *gamesModule; + +GamesModule::GamesModule() : SinglePortModule("games", meshtastic_PortNum_PRIVATE_APP), concurrency::OSThread("Games") +{ + // Register the hosted games. Order sets the attract-screen cycle order (Snake is shown first). + games.push_back(new Snake()); + games.push_back(new Tetris()); + games.push_back(new ChirpyRunner()); + games.push_back(new Breakout()); + inputObserver.observe(inputBroker); + + // Keep the tick thread alive at boot only if a game broadcasts periodically; otherwise idle + // until the player launches a game. The first idle tick reschedules to the real cadence. + bool periodic = false; + for (Game *g : games) + periodic = periodic || g->wantsPeriodicMesh(); + if (periodic) + setIntervalFromNow(1000); + else + disable(); +} + +// --------------------------------------------------------------------------- +// Lifecycle / state transitions +// --------------------------------------------------------------------------- + +void GamesModule::launchGame() +{ + if (games.empty()) + return; + // The games frame is already current (the player is on the attract screen), so just begin play + // with the selected game -- no focus change or frameset regeneration needed. + active = games[selected]; + startPlaying(); +} + +void GamesModule::startPlaying() +{ + active->start(static_cast(random()) ^ millis()); + uiState = GAMES_PLAYING; + lastAwakeKickMs = millis(); + kickTick(); + requestRedraw(); +} + +void GamesModule::enterGameOver() +{ + lastScore = active ? active->score() : 0; + lastRank = -1; + lastWasNewTop = false; + uiState = GAMES_GAMEOVER; + + // Arcade-style: if the score placed, prompt for initials, then record it in the picker's + // callback. Otherwise just show the game-over screen. + if (active && active->scores().qualifies(lastScore)) + promptForInitials(); + + requestRedraw(); +} + +void GamesModule::promptForInitials() +{ + screen->showAlphanumericPicker("New High Score!\nEnter initials", "AAA", 60000, HighScoreTableBase::INITIALS_LEN, + [this](const std::string &initials) { this->recordHighScore(initials.c_str()); }); +} + +void GamesModule::recordHighScore(const char *initials) +{ + if (!active) + return; + bool isNewTop = false; + lastRank = active->scores().insert(lastScore, initials, nodeDB ? nodeDB->getNodeNum() : 0, isNewTop); + lastWasNewTop = isNewTop; + if (lastRank >= 0) + active->scores().save(); // table changed -- the only time we write flash +#if GAMES_ANNOUNCE_HIGH_SCORE + if (isNewTop && lastScore > 0) + announceHighScore(initials, lastScore); +#endif + requestRedraw(); +} + +#if GAMES_ANNOUNCE_HIGH_SCORE +void GamesModule::announceHighScore(const char *initials, uint32_t score) +{ + if (!active || !service) + return; + if (!initials || initials[0] == '\0') + return; + meshtastic_MeshPacket *p = allocDataPacket(); + p->to = NODENUM_BROADCAST; + p->channel = 0; // primary channel + p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + // One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu + // if a receiving node's font lacks a glyph. + p->decoded.payload.size = snprintf(reinterpret_cast(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes), + GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast(score)); + service->sendToMesh(p); + LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast(score)); +} +#endif + +void GamesModule::exitToIdle() +{ + uiState = GAMES_IDLE; + active = nullptr; + // The games frame is always present, so we just return it to the attract screen and redraw -- + // no frameset change. interceptingKeyboardInput() now returns false, so the D-pad navigates + // between frames again. Keep ticking only if a game still needs its periodic broadcast. + bool periodic = false; + for (Game *g : games) + periodic = periodic || g->wantsPeriodicMesh(); + if (periodic) + setIntervalFromNow(1000); + else + disable(); + requestRedraw(); +} + +void GamesModule::requestRedraw() +{ + UIFrameEvent e; + e.action = UIFrameEvent::Action::REDRAW_ONLY; + notifyObservers(&e); +} + +void GamesModule::kickTick() +{ + enabled = true; + setIntervalFromNow(250); // brief beat so the player sees the board before it moves +} + +int32_t GamesModule::runOnce() +{ + if (uiState == GAMES_PLAYING && active) { + if (!active->tick()) { + enterGameOver(); + return disable(); + } + + // Keep the display awake through long runs that generate no key presses. + const uint32_t now = millis(); + if (now - lastAwakeKickMs > 1500) { + powerFSM.trigger(EVENT_PRESS); + lastAwakeKickMs = now; + } + + requestRedraw(); + return active->tickIntervalMs(); + } + + // Idle: service any game that broadcasts periodically; sleep until the soonest one is due. + int32_t next = -1; + for (Game *g : games) { + const int32_t due = g->meshTick(*this); + if (due >= 0 && (next < 0 || due < next)) + next = due; + } + return next < 0 ? disable() : next; +} + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +int GamesModule::handleInputEvent(const InputEvent *event) +{ + // Ignore all input unless the games frame is the one actually on screen -- otherwise the attract + // screen's UP/DOWN would hijack normal frame navigation from wherever the player happens to be. + if (!screen || !screen->isGamesFrameShown()) + return 0; + if (screen->isOverlayBannerShowing()) + return 0; // a menu banner is up; don't steal its input + + const input_broker_event ev = event->inputEvent; + const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK); + + switch (uiState) { + case GAMES_IDLE: + // Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it; + // long-press SELECT opens that game's high-score table. Everything else passes through so + // the D-pad still navigates between frames. + if (!games.empty() && (ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_UP)) { + const uint8_t n = static_cast(games.size()); + selected = (ev == INPUT_BROKER_DOWN) ? (selected + 1) % n : (selected + n - 1) % n; + requestRedraw(); + return 1; + } + if (ev == INPUT_BROKER_SELECT_LONG && !games.empty()) { + active = games[selected]; + uiState = GAMES_HISCORES; + requestRedraw(); + return 1; + } + return 0; + + case GAMES_PLAYING: + if (isBack) { + uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit + disable(); + requestRedraw(); + } else if (active) { + active->handleInput(ev); + if (!active->isPlaying()) { + enterGameOver(); + return 1; + } + requestRedraw(); + } + return 1; + + case GAMES_PAUSED: + if (isBack) { + exitToIdle(); // quit from pause + } else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT || + ev == INPUT_BROKER_RIGHT) { + uiState = GAMES_PLAYING; + kickTick(); + requestRedraw(); + } + return 1; + + case GAMES_GAMEOVER: + if (ev == INPUT_BROKER_SELECT) { + uiState = GAMES_HISCORES; + requestRedraw(); + } else if (isBack) { + exitToIdle(); + } + return 1; + + case GAMES_HISCORES: + if (ev == INPUT_BROKER_SELECT_LONG && active) { + static const char *opts[] = {"No", "Yes"}; + graphics::BannerOverlayOptions confirm; + confirm.message = "Clear Scores?"; + confirm.optionsArrayPtr = opts; + confirm.optionsCount = 2; + confirm.bannerCallback = [this](int sel) { + if (sel == 1 && active) { + active->scores().clear(); + active->scores().save(); + requestRedraw(); + LOG_INFO("Games: high scores cleared"); + } + }; + if (screen) + screen->showOverlayBanner(confirm); + } else if (ev == INPUT_BROKER_SELECT || isBack) { + exitToIdle(); + } + return 1; + + default: + return 0; + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +void GamesModule::drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count) +{ + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + const int16_t lineH = FONT_HEIGHT_SMALL; + const int16_t total = static_cast(count) * lineH; + int16_t startY = y + (display->getHeight() - total) / 2; + if (startY < y) + startY = y; + const int16_t cx = x + display->getWidth() / 2; + for (uint8_t i = 0; i < count; i++) + display->drawString(cx, startY + i * lineH, lines[i]); +} + +void GamesModule::drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores) +{ + display->setFont(FONT_SMALL); + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(x, y, "HIGH SCORES"); + display->setTextAlignment(TEXT_ALIGN_LEFT); + const int16_t rowH = (display->getHeight() - FONT_HEIGHT_SMALL) / HighScoreTableBase::HS_COUNT; + for (uint8_t i = 0; i < HighScoreTableBase::HS_COUNT; i++) { + char row[32]; + if (scores.scoreAt(i) > 0) { + snprintf(row, sizeof(row), "%u. %-4s %lu", static_cast(i + 1), scores.nameAt(i), + static_cast(scores.scoreAt(i))); + } else { + snprintf(row, sizeof(row), "%u. ---", static_cast(i + 1)); + } + display->drawString(x + 6, y + FONT_HEIGHT_SMALL + i * rowH, row); + } +} + +void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/, int16_t x, int16_t y) +{ + display->setColor(WHITE); + + switch (uiState) { + case GAMES_IDLE: + if (!games.empty()) + games[selected]->drawAttract(display, x, y); + break; + + case GAMES_PLAYING: + if (active) + active->drawPlaying(display, x, y); + break; + + case GAMES_PAUSED: + if (active) { + active->drawPlaying(display, x, y); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + display->getWidth() / 2, y + display->getHeight() / 2 - FONT_HEIGHT_SMALL / 2, "- PAUSED -"); + } + break; + + case GAMES_GAMEOVER: { + char scoreLine[24]; + snprintf(scoreLine, sizeof(scoreLine), "Score: %lu", static_cast(lastScore)); + const char *status = lastWasNewTop ? "NEW HIGH SCORE!" : (lastRank >= 0 ? "You made the top 5!" : ""); + const char *hint = active ? active->gameOverHint() : "SELECT: scores"; + const char *lines[] = {"GAME OVER", scoreLine, status, hint}; + drawCenteredLines(display, x, y, lines, 4); + break; + } + + case GAMES_HISCORES: + if (active) + drawHighScores(display, x, y, active->scores()); + break; + + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Mesh receive - dispatch to each hosted game +// --------------------------------------------------------------------------- + +ProcessMessage GamesModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + for (Game *g : games) { + const ProcessMessage r = g->handleReceived(mp); + if (r != ProcessMessage::CONTINUE) + return r; + } + requestRedraw(); // a merged remote score should show up if the high-score screen is open + return ProcessMessage::CONTINUE; +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/GamesModule.h b/src/modules/games/GamesModule.h new file mode 100644 index 000000000..eb4642f19 --- /dev/null +++ b/src/modules/games/GamesModule.h @@ -0,0 +1,101 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "Observer.h" +#include "concurrency/OSThread.h" +#include "input/InputBroker.h" +#include "mesh/SinglePortModule.h" +#include + +// Broadcasting a new all-time #1 to the mesh is a COMPILE-TIME option, default OFF. Spending shared +// airtime must be opted into at build time with -DGAMES_ANNOUNCE_HIGH_SCORE=1; when disabled (the +// default) the announcement code is compiled out entirely -- there is no runtime toggle. The +// announcement is shared by every hosted game, with the game's name() spliced into the message. +#ifndef GAMES_ANNOUNCE_HIGH_SCORE +#define GAMES_ANNOUNCE_HIGH_SCORE 0 +#endif + +#ifndef GAMES_HIGH_SCORE_STRING +#define GAMES_HIGH_SCORE_STRING "New %s high score %lu by %s!" +#endif + +enum GamesUiState : uint8_t { + GAMES_IDLE, // attract screen of the selected game; OSThread idle (unless a game broadcasts) + GAMES_PLAYING, // active game running; tick thread ticking + GAMES_PAUSED, // paused mid-game + GAMES_GAMEOVER, // final score / new-high notice + GAMES_HISCORES, // top-5 table of the active/selected game +}; + +/** + * The single host for all BaseUI games. It owns the always-present "games" frame (drawn through + * Screen's trampoline right after home), the shared UI state machine, the game-tick OSThread, the + * initials picker + high-score persistence flow, and the PRIVATE_APP mesh port. Individual games + * are self-contained Game subclasses registered in the constructor (see src/modules/games/); the + * attract screen cycles between them with UP/DOWN and SELECT plays the shown game. + */ +class GamesModule : public SinglePortModule, public Observable, private concurrency::OSThread +{ + public: + GamesModule(); + + /// Start the currently-selected game (invoked when SELECT is pressed on the games frame). The + /// games frame is already current, so this just begins play. + void launchGame(); + + // Drawn through the games-frame trampoline, and queried by Screen's input gating / nav-bar, so + // these are public. While a game is active we own the D-pad; on the attract screen the D-pad + // cycles games (UP/DOWN) and otherwise navigates between frames as usual. + void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + virtual bool interceptingKeyboardInput() override { return uiState != GAMES_IDLE; } + + /// Mesh passthrough for hosted games (a Game is not itself a MeshModule). + meshtastic_MeshPacket *gameAllocDataPacket() { return allocDataPacket(); } + + protected: + virtual int32_t runOnce() override; // game tick + idle mesh scheduling + virtual Observable *getUIFrameObservable() override { return this; } + virtual bool wantUIFrame() override { return false; } // shares the games frame; no own slot + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + private: + int handleInputEvent(const InputEvent *event); + CallbackObserver inputObserver = + CallbackObserver(this, &GamesModule::handleInputEvent); + + // === State transitions === + void startPlaying(); + void enterGameOver(); + void exitToIdle(); + void requestRedraw(); + void kickTick(); + + // === Shared game-over / high-score flow === + void promptForInitials(); + void recordHighScore(const char *initials); +#if GAMES_ANNOUNCE_HIGH_SCORE + // Broadcast a new all-time #1 as a plain text message, shared by every game (name() spliced in). + void announceHighScore(const char *initials, uint32_t score); +#endif + + // === Shared rendering === + void drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count); + void drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores); + + std::vector games; + uint8_t selected = 0; // attract-screen cursor (index into games) + Game *active = nullptr; // game currently playing / whose scores are shown; null when idle + GamesUiState uiState = GAMES_IDLE; + uint32_t lastScore = 0; // score of the just-finished game (for the GAME OVER screen) + int lastRank = -1; // rank achieved last game (-1 == didn't place) + bool lastWasNewTop = false; // last game set a new all-time #1 + uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs +}; + +extern GamesModule *gamesModule; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/HighScoreTable.h b/src/modules/games/HighScoreTable.h new file mode 100644 index 000000000..b833ed05a --- /dev/null +++ b/src/modules/games/HighScoreTable.h @@ -0,0 +1,176 @@ +#pragma once + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "DebugConfiguration.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "concurrency/LockGuard.h" +#include "gps/RTC.h" +#include "mesh/NodeDB.h" // owner (short-name fallback) +#include +#include +#include +#include + +/** + * Non-templated view of a game's top-N high-score table, so the shared games UI (attract line, + * high-score screen) and the Game interface can talk to any game's table without knowing its + * on-disk record layout. + */ +class HighScoreTableBase +{ + public: + static constexpr uint8_t HS_COUNT = 5; // entries kept per game + static constexpr uint8_t INITIALS_LEN = 3; // arcade-style initials captured per high score + + virtual ~HighScoreTableBase() = default; + + virtual uint32_t scoreAt(uint8_t i) const = 0; + virtual const char *nameAt(uint8_t i) const = 0; + + // True if `score` would place on the sorted-descending table (peek; no mutation). + virtual bool qualifies(uint32_t score) const = 0; + // Insert under `initials` with the given `nodeNum` (0 == local, or a foreign node for merged + // remote scores). Returns the 0-based rank if it placed, else -1; isNewTop set on the #1 slot. + virtual int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) = 0; + virtual void clear() = 0; + + virtual void load() = 0; + virtual void save() = 0; + virtual bool loaded() const = 0; +}; + +/** + * Templated top-N table shared by every game. The algorithm (qualify / insert / load / save / CRC) + * is identical across games; only the on-disk record layout differs, so `Entry` is a template + * parameter. Each game supplies its own packed `Entry` (with `score`, `shortName[5]`, `nodeNum`, + * `epoch` fields, in whatever byte order its existing save file used) plus a file path, magic, and + * version -- so pre-existing save files keep loading byte-for-byte after the refactor. + */ +template class HighScoreTable : public HighScoreTableBase +{ + public: + HighScoreTable(const char *path, uint32_t magic, uint8_t version, const char *logTag) + : path_(path), magic_(magic), version_(version), logTag_(logTag) + { + } + + uint32_t scoreAt(uint8_t i) const override { return entries_[i].score; } + const char *nameAt(uint8_t i) const override { return entries_[i].shortName; } + const Entry &entryAt(uint8_t i) const { return entries_[i]; } + + bool qualifies(uint32_t score) const override + { + if (score == 0) + return false; + for (int i = 0; i < HS_COUNT; i++) { + if (score > entries_[i].score) + return true; + } + return false; + } + + int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) override + { + isNewTop = false; + if (score == 0) + return -1; + + int pos = -1; + for (int i = 0; i < HS_COUNT; i++) { + if (score > entries_[i].score) { + pos = i; + break; + } + } + if (pos < 0) + return -1; // not good enough to place + + for (int i = HS_COUNT - 1; i > pos; i--) + entries_[i] = entries_[i - 1]; + + Entry &e = entries_[pos]; + memset(&e, 0, sizeof(e)); + e.score = score; + e.nodeNum = nodeNum; + strncpy(e.shortName, (initials && initials[0]) ? initials : owner.short_name, sizeof(e.shortName) - 1); + e.shortName[sizeof(e.shortName) - 1] = '\0'; + e.epoch = getValidTime(RTCQualityDevice, false); // 0 when no valid RTC + + isNewTop = (pos == 0); + return pos; + } + + void clear() override { memset(entries_, 0, sizeof(entries_)); } + bool loaded() const override { return loaded_; } + + void load() override + { + loaded_ = true; + memset(entries_, 0, sizeof(entries_)); +#ifdef FSCom + concurrency::LockGuard g(spiLock); + auto f = FSCom.open(path_, FILE_O_READ); + if (!f) + return; + File file; + const bool readOk = (f.read(reinterpret_cast(&file), sizeof(file)) == sizeof(file)); + f.close(); + if (!readOk || file.magic != magic_ || file.version != version_) { + LOG_DEBUG("%s: no valid high-score file, starting fresh", logTag_); + return; + } + if (crc32Buffer(&file, offsetof(File, crc)) != file.crc) { + LOG_WARN("%s: high-score CRC mismatch, resetting table", logTag_); + return; + } + memcpy(entries_, file.entries, sizeof(entries_)); + LOG_INFO("%s: loaded high scores (top=%lu)", logTag_, static_cast(entries_[0].score)); +#endif + } + + void save() override + { +#ifdef FSCom + { + concurrency::LockGuard g(spiLock); + FSCom.mkdir("/prefs"); + } + File file; + memset(&file, 0, sizeof(file)); + file.magic = magic_; + file.version = version_; + memcpy(file.entries, entries_, sizeof(entries_)); + file.crc = crc32Buffer(&file, offsetof(File, crc)); + + auto sf = SafeFile(path_, true); + const size_t written = sf.write(reinterpret_cast(&file), sizeof(file)); + if (!sf.close() || written != sizeof(file)) + LOG_WARN("%s: failed to save high scores", logTag_); +#endif + } + + private: + // On-disk layout. The 8-byte header (magic + version + 3 reserved) matches both the original + // Snake and Tetris files byte-for-byte, so their save files still load unchanged. + struct File { + uint32_t magic; + uint8_t version; + uint8_t reserved[3]; + Entry entries[HighScoreTableBase::HS_COUNT]; + uint32_t crc; // crc32 over every preceding byte + } __attribute__((packed)); + + Entry entries_[HS_COUNT] = {}; + const char *path_; + uint32_t magic_; + uint8_t version_; + const char *logTag_; + bool loaded_ = false; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Snake.cpp b/src/modules/games/Snake.cpp new file mode 100644 index 000000000..13dc616f1 --- /dev/null +++ b/src/modules/games/Snake.cpp @@ -0,0 +1,264 @@ +#include "Snake.h" + +// =========================================================================== +// Pure SnakeGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +void SnakeGame::reset(uint32_t seed) +{ + memset(occ, 0, sizeof(occ)); + len = 0; + points = 0; + alive = true; + won = false; + rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0 + + // Spawn a START_LEN snake horizontally in the middle of the board, heading right, with the + // head at the centre and the body trailing to its left. + const uint8_t cx = GRID_W / 2; + const uint8_t cy = GRID_H / 2; + dir = DIR_RIGHT; + pendingDir = DIR_RIGHT; + tailIdx = 0; + for (uint8_t i = 0; i < START_LEN; i++) { + const uint8_t x = static_cast(cx - (START_LEN - 1) + i); + body[i] = {x, cy}; + setOcc(cellIndex(x, cy)); + len++; + } + headIdx = static_cast(START_LEN - 1); + + placeFood(); +} + +bool SnakeGame::isReverse(Direction a, Direction b) +{ + return (a == DIR_UP && b == DIR_DOWN) || (a == DIR_DOWN && b == DIR_UP) || (a == DIR_LEFT && b == DIR_RIGHT) || + (a == DIR_RIGHT && b == DIR_LEFT); +} + +bool SnakeGame::setDirection(Direction d) +{ + if (isReverse(dir, d)) + return false; + pendingDir = d; + return true; +} + +uint32_t SnakeGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +bool SnakeGame::placeFood() +{ + const uint16_t free = static_cast(CELL_COUNT - len); + if (free == 0) + return false; // board full -> caller treats as a win + + // Pick the k-th free cell (k uniform in [0, free)) via a single linear scan. Deterministic, + // always valid, and cheap for a 384-cell board -- no rejection sampling / near-full special case. + uint16_t k = static_cast(nextRandom() % free); + for (uint16_t idx = 0; idx < CELL_COUNT; idx++) { + if (!getOcc(idx)) { + if (k == 0) { + foodCell = {static_cast(idx % GRID_W), static_cast(idx / GRID_W)}; + return true; + } + k--; + } + } + return false; // unreachable while free > 0 +} + +bool SnakeGame::step() +{ + if (!alive) + return false; + + dir = pendingDir; // commit the latched heading + + const Cell h = body[headIdx]; + int16_t nx = h.x; + int16_t ny = h.y; + switch (dir) { + case DIR_UP: + ny--; + break; + case DIR_DOWN: + ny++; + break; + case DIR_LEFT: + nx--; + break; + case DIR_RIGHT: + nx++; + break; + } + + // Wall collision (signed math catches the 0-- underflow too). + if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) { + alive = false; + return false; + } + + const uint16_t nidx = cellIndex(static_cast(nx), static_cast(ny)); + const bool eating = (nx == foodCell.x && ny == foodCell.y); + + // When not eating the tail vacates this tick, so free it first: moving into the cell the + // tail is leaving is legal (classic snake), moving into any other body cell is fatal. + if (!eating) { + clearOcc(cellIndex(body[tailIdx].x, body[tailIdx].y)); + tailIdx = static_cast((tailIdx + 1) % CAP); + len--; + } + + if (getOcc(nidx)) { + alive = false; + return false; + } + + // Advance the head into the new cell. + headIdx = static_cast((headIdx + 1) % CAP); + body[headIdx] = {static_cast(nx), static_cast(ny)}; + setOcc(nidx); + len++; + + if (eating) { + points++; + if (!placeFood()) { + won = true; + alive = false; // board completely filled -- the player won + } + } + + return alive; +} + +// =========================================================================== +// Snake adapter (display + persistence; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" + +// --- Pixel layout on a 128x64 OLED ----------------------------------------------------------- +// Each cell is CELL_PX square. The GRID_W x GRID_H playfield (128x48) is bottom-aligned, leaving +// a SCORE_H-pixel score bar across the top. On taller displays the board bottom-aligns and +// centres horizontally; screen edges are the walls. +static constexpr int16_t CELL_PX = 4; +static constexpr int16_t SCORE_H = 16; + +Snake::Snake() +{ + scores_.load(); +} + +int32_t Snake::tickIntervalMs() const +{ + // Speed ramps up as the snake grows: 160 ms base, down to a 70 ms floor. + int32_t iv = 160 - static_cast(game.length()) * 3; + return iv < 70 ? 70 : iv; +} + +void Snake::handleInput(input_broker_event ev) +{ + switch (ev) { + case INPUT_BROKER_UP: + game.setDirection(SnakeGame::DIR_UP); + break; + case INPUT_BROKER_DOWN: + game.setDirection(SnakeGame::DIR_DOWN); + break; + case INPUT_BROKER_LEFT: + game.setDirection(SnakeGame::DIR_LEFT); + break; + case INPUT_BROKER_RIGHT: + game.setDirection(SnakeGame::DIR_RIGHT); + break; + default: + break; + } +} + +void Snake::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "S N A K E"); + // Snake glyph, centred below the title. + const int16_t logoX = x + (w - snake_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, snake_width, snake_height, snake); +#if GRAPHICS_TFT_COLORING_ENABLED + // On a colour display, paint the snake green with a red tongue. The forked tongue is the pair + // of pixels poking out past the body on the right edge (~row 6 of the 16x16 glyph); a red + // region registered after the green one wins where they overlap, so the body stays green. + const uint16_t bg = graphics::getThemeBodyBg(); + graphics::registerTFTColorRegionDirect(logoX, logoY, snake_width, snake_height, graphics::TFTPalette::Green, bg); + graphics::registerTFTColorRegionDirect(logoX + 13, logoY + 5, 3, 4, graphics::TFTPalette::Red, bg); +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +void Snake::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + char buf[24]; + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Score bar: current score on the left, best on the right. + display->setTextAlignment(TEXT_ALIGN_LEFT); + snprintf(buf, sizeof(buf), "Score %lu", static_cast(game.score())); + display->drawString(x + 2, y + 2, buf); + if (scores_.scoreAt(0) > 0) { + display->setTextAlignment(TEXT_ALIGN_RIGHT); + snprintf(buf, sizeof(buf), "Hi %lu", static_cast(scores_.scoreAt(0))); + display->drawString(x + display->getWidth() - 2, y + 2, buf); + } + display->drawLine(x, y + SCORE_H - 1, x + display->getWidth() - 1, y + SCORE_H - 1); + + // Board is bottom-aligned; centre it horizontally. + const int16_t boardW = SnakeGame::GRID_W * CELL_PX; + const int16_t boardH = SnakeGame::GRID_H * CELL_PX; + const int16_t ox = x + (display->getWidth() - boardW) / 2; + const int16_t oy = y + display->getHeight() - boardH; + + for (uint16_t i = 0; i < game.length(); i++) { + SnakeGame::Cell c = game.bodyAt(i); + display->fillRect(ox + c.x * CELL_PX, oy + c.y * CELL_PX, CELL_PX, CELL_PX); + } + // Food: a 2x2 dot centred in its cell. + SnakeGame::Cell f = game.food(); + display->fillRect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2); + +#if GRAPHICS_TFT_COLORING_ENABLED + // On a colour display, paint the whole snake green, then the apple red on top. One region over + // the board tints every lit body cell green (the snake can be too long for per-cell regions); + // a red region over the food pixel wins where it overlaps. + const uint16_t bg = graphics::getThemeBodyBg(); + graphics::registerTFTColorRegionDirect(ox, oy, boardW, boardH, graphics::TFTPalette::MeshtasticGreen, bg); + graphics::registerTFTColorRegionDirect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2, graphics::TFTPalette::Red, bg); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Snake.h b/src/modules/games/Snake.h new file mode 100644 index 000000000..293707ff4 --- /dev/null +++ b/src/modules/games/Snake.h @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#include + +/** + * Pure, self-contained Snake game logic. + * + * Deliberately free of Arduino / Screen / heap dependencies so it can be unit-tested natively + * (see test/test_snake) and reused by the Snake adapter below without pulling in the display stack. + * + * The board is a fixed GRID_W x GRID_H grid of cells. The snake body lives in a ring buffer + * sized to the whole board, plus an occupancy bitmap for O(1) collision and food-placement + * checks. No dynamic allocation: total state is ~1 KB and statically sized. + */ +class SnakeGame +{ + public: + // Playfield dimensions in cells. Chosen so that at CELL_PX = 4 the board is 128x48, leaving + // a 16 px score bar at the top of a 128x64 OLED (see Snake.cpp for the pixel layout). + static constexpr uint8_t GRID_W = 32; + static constexpr uint8_t GRID_H = 12; + static constexpr uint16_t CELL_COUNT = static_cast(GRID_W) * GRID_H; // 384 + + // Initial snake length at the start of a game. + static constexpr uint8_t START_LEN = 3; + + enum Direction : uint8_t { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT }; + + struct Cell { + uint8_t x; + uint8_t y; + }; + + /** + * (Re)start a game. The snake spawns horizontally in the middle of the board heading right, + * and the first food is placed. `seed` drives deterministic food placement (xorshift32). + */ + void reset(uint32_t seed); + + /** + * Latch a new heading to be applied on the next step(). A 180-degree reversal of the + * currently-committed direction is rejected (returns false) because it would immediately + * run the head into the neck. Comparing against the committed direction (not the pending + * one) means multiple key presses within a single tick can't chain into a reversal. + */ + bool setDirection(Direction d); + + /** + * Advance the simulation by one tick. Returns true if the snake is still alive afterwards, + * false if this move ended the game (wall hit, self-collision, or board filled == win). + * Once dead, further step() calls are no-ops returning false. + */ + bool step(); + + bool isPlaying() const { return alive; } + bool isWon() const { return won; } + uint16_t length() const { return len; } + uint32_t score() const { return points; } + + Cell head() const { return body[headIdx]; } + Cell food() const { return foodCell; } + Direction direction() const { return dir; } + + /// True if cell (x,y) is currently part of the snake body. + bool occupied(uint8_t x, uint8_t y) const { return getOcc(cellIndex(x, y)); } + + /// Iterate the body from tail (i == 0) to head (i == length()-1); used by the renderer. + Cell bodyAt(uint16_t i) const { return body[(tailIdx + i) % CAP]; } + + /** + * Test/aid seam: force the next food to a specific cell so unit tests can drive + * deterministic growth. Unused in production. Caller must pass an unoccupied cell. + */ + void placeFoodAt(uint8_t x, uint8_t y) { foodCell = {x, y}; } + + private: + static constexpr uint16_t CAP = CELL_COUNT; // ring capacity == board size (len is tracked explicitly) + + Cell body[CAP] = {0}; + uint16_t headIdx = 0; // ring index of the head (front) cell + uint16_t tailIdx = 0; // ring index of the tail (oldest) cell + uint16_t len = 0; // number of live body cells + + uint8_t occ[(CELL_COUNT + 7) / 8] = {0}; // occupancy bitmap, indexed by cellIndex() + + Cell foodCell = {0, 0}; + Direction dir = DIR_RIGHT; + Direction pendingDir = DIR_RIGHT; + uint32_t points = 0; + uint32_t rng = 1; // xorshift32 state (never 0) + bool alive = false; + bool won = false; + + static uint16_t cellIndex(uint8_t x, uint8_t y) { return static_cast(y) * GRID_W + x; } + bool getOcc(uint16_t idx) const { return (occ[idx >> 3] >> (idx & 7)) & 1u; } + void setOcc(uint16_t idx) { occ[idx >> 3] |= static_cast(1u << (idx & 7)); } + void clearOcc(uint16_t idx) { occ[idx >> 3] &= static_cast(~(1u << (idx & 7))); } + + uint32_t nextRandom(); + bool placeFood(); // returns false if the board is full (no free cell -> win) + static bool isReverse(Direction a, Direction b); +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Snake as a hosted Game. Wraps the pure SnakeGame logic above and supplies the attract art, the + * playfield renderer, the direction input, the length-based speed curve, and its own high-score + * table. The new-high-score mesh announcement is shared by all games and lives in GamesModule. + */ +class Snake : public Game +{ + public: + Snake(); + + const char *name() const override { return "Snake"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override { return game.step(); } + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override; + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record; layout preserved from the original SnakeModule so snake.dat keeps + // loading. Magic 'SNEK', file version 1. + struct SnakeEntry { + uint32_t score; + uint32_t nodeNum; + char shortName[5]; // three characters, NUL-terminated + uint32_t epoch; // getValidTime(), 0 if no RTC + } __attribute__((packed)); + + SnakeGame game; + HighScoreTable scores_{"/prefs/snake.dat", 0x534E454Bu, 1, "Snake"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Tetris.cpp b/src/modules/games/Tetris.cpp new file mode 100644 index 000000000..7c24b3269 --- /dev/null +++ b/src/modules/games/Tetris.cpp @@ -0,0 +1,494 @@ +#include "Tetris.h" + +// =========================================================================== +// Pure TetrisGame logic (no display/FS dependencies; always compiled) +// =========================================================================== + +// --------------------------------------------------------------------------- +// Shape table +// +// SHAPES[type][rot][row] encodes each row of the 4×4 bounding box as a 4-bit +// column bitmask (bit 0 = leftmost column). All seven SRS tetrominoes in their +// four CW rotations. +// +// Type 0 I Type 1 O Type 2 T Type 3 S +// Type 4 Z Type 5 J Type 6 L +// --------------------------------------------------------------------------- +const uint8_t TetrisGame::SHAPES[PIECE_TYPES][4][4] = { + // I ---- rot0: .XXXX rot1: ..X.. rot2: ..... rot3: .X... + {{0, 15, 0, 0}, {4, 4, 4, 4}, {0, 0, 15, 0}, {2, 2, 2, 2}}, + // O ---- same all rotations + {{6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}}, + // T ---- + {{2, 7, 0, 0}, {2, 6, 2, 0}, {0, 7, 2, 0}, {2, 3, 2, 0}}, + // S ---- + {{6, 3, 0, 0}, {1, 3, 2, 0}, {0, 6, 3, 0}, {2, 6, 4, 0}}, + // Z ---- + {{3, 6, 0, 0}, {2, 3, 1, 0}, {0, 3, 6, 0}, {4, 6, 2, 0}}, + // J ---- + {{1, 7, 0, 0}, {6, 2, 2, 0}, {0, 7, 4, 0}, {2, 2, 3, 0}}, + // L ---- + {{4, 7, 0, 0}, {2, 2, 6, 0}, {0, 7, 1, 0}, {3, 2, 2, 0}}, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +bool TetrisGame::pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc) +{ + if (type >= PIECE_TYPES || rot >= 4 || pr >= 4 || pc >= 4) + return false; + return (SHAPES[type][rot][pr] >> pc) & 1u; +} + +uint32_t TetrisGame::nextRandom() +{ + uint32_t x = rng; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + rng = x; + return x; +} + +TetrisGame::Piece TetrisGame::spawnPiece(uint8_t type) const +{ + // Centre horizontally in the 10-wide board; bounding box starts at col 3. + Piece p; + p.type = type; + p.rot = 0; + p.col = static_cast((BOARD_COLS - 4) / 2); // 3 for a 10-wide board + p.row = 0; + return p; +} + +bool TetrisGame::canPlace(const Piece &p) const +{ + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!pieceCell(p.type, p.rot, pr, pc)) + continue; + const int16_t br = static_cast(p.row) + pr; + const int16_t bc = static_cast(p.col) + pc; + if (br < 0) + continue; // above the board - allowed during spawn + if (br >= BOARD_ROWS || bc < 0 || bc >= BOARD_COLS) + return false; + if (board[br][bc] != 0) + return false; + } + } + return true; +} + +void TetrisGame::lockPiece() +{ + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!pieceCell(cur.type, cur.rot, pr, pc)) + continue; + const int16_t br = static_cast(cur.row) + pr; + const int16_t bc = static_cast(cur.col) + pc; + if (br >= 0 && br < BOARD_ROWS && bc >= 0 && bc < BOARD_COLS) + board[br][bc] = static_cast(cur.type + 1); // colour 1..7 + } + } +} + +int TetrisGame::clearLines() +{ + int cleared = 0; + for (int r = BOARD_ROWS - 1; r >= 0;) { + bool full = true; + for (int c = 0; c < BOARD_COLS && full; c++) { + if (board[r][c] == 0) + full = false; + } + if (full) { + // Shift every row above down by one. + for (int rr = r; rr > 0; rr--) + memcpy(board[rr], board[rr - 1], BOARD_COLS); + memset(board[0], 0, BOARD_COLS); + cleared++; + // Recheck same index - it now contains the row that was above. + } else { + r--; + } + } + return cleared; +} + +void TetrisGame::advanceNext() +{ + lockPiece(); + + const int cleared = clearLines(); + if (cleared > 0) { + lines += static_cast(cleared); + // Nintendo-style line-clear scoring (×level). + static const uint16_t LINE_PTS[5] = {0, 100, 300, 500, 800}; + pts += LINE_PTS[cleared < 5 ? cleared : 4] * lvl; + // Level up every 10 lines, cap at 20. + const uint8_t newLvl = static_cast(lines / 10 + 1); + lvl = newLvl > 20 ? 20 : newLvl; + } + + // nxt becomes the active piece; generate a fresh nxt. + cur = nxt; + if (!canPlace(cur)) { + alive = false; + return; + } + nxt = spawnPiece(static_cast(nextRandom() % PIECE_TYPES)); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +void TetrisGame::reset(uint32_t seed) +{ + memset(board, 0, sizeof(board)); + pts = 0; + lvl = 1; + lines = 0; + alive = true; + rng = seed ? seed : 0xA5A5A5A5u; + cur = spawnPiece(static_cast(nextRandom() % PIECE_TYPES)); + nxt = spawnPiece(static_cast(nextRandom() % PIECE_TYPES)); +} + +bool TetrisGame::moveLeft() +{ + Piece p = cur; + p.col--; + if (!canPlace(p)) + return false; + cur = p; + return true; +} + +bool TetrisGame::moveRight() +{ + Piece p = cur; + p.col++; + if (!canPlace(p)) + return false; + cur = p; + return true; +} + +bool TetrisGame::rotate() +{ + Piece p = cur; + p.rot = static_cast((p.rot + 1) % 4); + if (canPlace(p)) { + cur = p; + return true; + } + // Wall-kick: try ±1, ±2 column offsets. + const int8_t kicks[] = {-1, 1, -2, 2}; + for (int8_t kick : kicks) { + Piece q = p; + q.col = static_cast(p.col + kick); + if (canPlace(q)) { + cur = q; + return true; + } + } + return false; +} + +bool TetrisGame::softDrop() +{ + Piece p = cur; + p.row++; + if (!canPlace(p)) { + advanceNext(); // locks cur, clears lines, spawns next; may set alive=false + return false; + } + cur = p; + return true; +} + +void TetrisGame::hardDrop() +{ + const int8_t land = ghostRow(); + const uint32_t dropped = static_cast(land - cur.row); + pts += dropped * 2; + cur.row = land; + advanceNext(); +} + +int8_t TetrisGame::ghostRow() const +{ + Piece p = cur; + while (true) { + Piece q = p; + q.row++; + if (!canPlace(q)) + break; + p = q; + } + return p.row; +} + +bool TetrisGame::step() +{ + if (!alive) + return false; + softDrop(); // may lock and advance, potentially setting alive=false + return alive; +} + +// =========================================================================== +// Tetris adapter (display + persistence + mesh; BaseUI games build only) +// =========================================================================== + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "GamesModule.h" +#include "NodeDB.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include "graphics/images.h" +#include "main.h" +#include +#include + +// --------------------------------------------------------------------------- +// Vertical pixel layout on a 128×64 OLED +// +// Board occupies the left side of the screen: +// x = col × CELL_PX (col 0 at left edge) +// y = row × CELL_PX (row 0 at top edge) +// 10 cols × 4 px = 40 px wide +// 16 rows × 4 px = 64 px tall (fills the full display height) +// +// Score panel: x = SCORE_OX .. 127 (86 px wide) +// Labels (SCR / LVL / NXT) + values + next-piece preview. +// --------------------------------------------------------------------------- +static constexpr int16_t CELL_PX = 4; + +Tetris::Tetris() +{ + scores_.load(); +} + +int32_t Tetris::tickIntervalMs() const +{ + // Speed ramps with level: 600 ms base, 45 ms per level, floor 50 ms. + int32_t iv = 600 - static_cast(game.level()) * 45; + return iv < 50 ? 50 : iv; +} + +void Tetris::handleInput(input_broker_event ev) +{ + switch (ev) { + case INPUT_BROKER_UP: + game.rotate(); + break; + case INPUT_BROKER_LEFT: + game.moveLeft(); + break; + case INPUT_BROKER_RIGHT: + game.moveRight(); + break; + case INPUT_BROKER_DOWN: + game.softDrop(); + break; + case INPUT_BROKER_SELECT: + case INPUT_BROKER_SELECT_LONG: + game.hardDrop(); + break; + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +#if GRAPHICS_TFT_COLORING_ENABLED +// Classic tetromino colours, indexed by piece type (0..6 == I O T S Z J L). Native RGB565. +static uint16_t tetrominoColor(uint8_t type) +{ + using namespace graphics; + switch (type) { + case 0: + return TFTPalette::Cyan; // I + case 1: + return TFTPalette::Yellow; // O + case 2: + return TFTPalette::Magenta; // T + case 3: + return TFTPalette::Green; // S + case 4: + return TFTPalette::Red; // Z + case 5: + return TFTPalette::Blue; // J + case 6: + return TFTPalette::Orange; // L + default: + return TFTPalette::White; + } +} +#endif + +void Tetris::drawAttract(OLEDDisplay *display, int16_t x, int16_t y) +{ + display->setColor(WHITE); + const int16_t w = display->getWidth(); + const int16_t cx = x + w / 2; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(cx, y, "T E T R I S"); + const int16_t logoX = x + (w - tetris_width) / 2; + const int16_t logoY = y + 15; + display->drawXbm(logoX, logoY, tetris_width, tetris_height, tetris); +#if GRAPHICS_TFT_COLORING_ENABLED + // The logo glyph is a T tetromino -- tint it the T-piece colour on colour displays. + graphics::registerTFTColorRegionDirect(logoX, logoY, tetris_width, tetris_height, tetrominoColor(2), + graphics::getThemeBodyBg()); +#endif + char hi[32]; + if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0') + snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast(scores_.scoreAt(0))); + else + snprintf(hi, sizeof(hi), "High: %lu", static_cast(scores_.scoreAt(0))); + display->drawString(cx, y + 34, hi); +} + +void Tetris::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) +{ + // Centered vertical layout: + // board: 10 cols × CELL_PX wide, fills display height (BOARD_ROWS × CELL_PX) + // left panel (NXT preview) : x = 0 .. ox-2 + // right panel (SCR / LVL) : x = ox+boardW+1 .. display.width-1 + const int16_t boardW = TetrisGame::BOARD_COLS * CELL_PX; // 40 + const int16_t ox = x + (display->getWidth() - boardW) / 2; // horizontal centre + const int16_t oy = y; + + display->setColor(WHITE); + + // Separator lines either side of the board, plus bottom wall. + display->drawLine(ox - 1, oy, ox - 1, oy + display->getHeight() - 1); + display->drawLine(ox + boardW, oy, ox + boardW, oy + display->getHeight() - 1); + display->drawLine(ox - 1, oy + display->getHeight() - 1, ox + boardW, oy + display->getHeight() - 1); + + // Cell helper. + auto drawCell = [&](int8_t col, int8_t row) { + if (col < 0 || row < 0 || col >= TetrisGame::BOARD_COLS || row >= TetrisGame::BOARD_ROWS) + return; + display->fillRect(ox + static_cast(col) * CELL_PX, oy + static_cast(row) * CELL_PX, CELL_PX - 1, + CELL_PX - 1); + }; + + // Locked cells. + for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) + for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++) + if (game.board[r][c]) + drawCell(static_cast(c), static_cast(r)); + + // Ghost piece - hollow outline. + const TetrisGame::Piece &cur = game.current(); + const int8_t ghostR = game.ghostRow(); + if (ghostR != cur.row) { + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc)) + continue; + const int8_t gc = static_cast(cur.col + pc); + const int8_t gr = static_cast(ghostR + pr); + if (gc < 0 || gr < 0 || gc >= TetrisGame::BOARD_COLS || gr >= TetrisGame::BOARD_ROWS) + continue; + display->setPixel(ox + static_cast(gc) * CELL_PX + 1, oy + static_cast(gr) * CELL_PX + 1); + } + } + } + + // Active piece - filled. + for (uint8_t pr = 0; pr < 4; pr++) { + for (uint8_t pc = 0; pc < 4; pc++) { + if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc)) + continue; + drawCell(static_cast(cur.col + pc), static_cast(cur.row + pr)); + } + } + + // --- Right panel: SCR and LVL --- + const int16_t rpx = ox + boardW + 2; + char buf[12]; + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(rpx, y + 2, "SCR"); + snprintf(buf, sizeof(buf), "%lu", static_cast(game.score())); + display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL, buf); + display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 2 + 2, "LVL"); + snprintf(buf, sizeof(buf), "%u", static_cast(game.level())); + display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 3 + 2, buf); + + // --- Left panel: NXT (next piece preview) centred in the panel --- + const int16_t lpanelW = ox - 2; // pixels available left of board separator + static constexpr int16_t PREV_PX = 3; // px per preview cell + const int16_t previewW = 4 * PREV_PX; // 12 px + const int16_t lpx = x + (lpanelW - previewW) / 2; + const int16_t nxtLabelY = y + 2; + const int16_t nxtPreviewY = nxtLabelY + FONT_HEIGHT_SMALL + 2; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + lpanelW / 2, nxtLabelY, "NXT"); + const TetrisGame::Piece &nxt = game.next(); + for (uint8_t pr = 0; pr < 4; pr++) + for (uint8_t pc = 0; pc < 4; pc++) + if (TetrisGame::pieceCell(nxt.type, nxt.rot, pr, pc)) + display->fillRect(lpx + static_cast(pc) * PREV_PX, nxtPreviewY + static_cast(pr) * PREV_PX, + PREV_PX - 1, PREV_PX - 1); + +#if GRAPHICS_TFT_COLORING_ENABLED + // On a colour display (e.g. HUB75), tint every block with its tetromino colour. The mono buffer + // still carries the block pixels drawn above; registering a colour region over each run of + // same-colour cells makes those "on" pixels render in colour instead of the theme foreground. + // Runs are merged horizontally per row to stay within the region budget, and empty cells cost + // nothing, so the region count only grows with how full the board is. + const uint16_t bg = graphics::getThemeBodyBg(); + + // Combined colour grid: locked cells plus the falling piece (same colour source == type + 1). + uint8_t cg[TetrisGame::BOARD_ROWS][TetrisGame::BOARD_COLS]; + for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) + for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++) + cg[r][c] = game.board[r][c]; + for (uint8_t pr = 0; pr < 4; pr++) + for (uint8_t pc = 0; pc < 4; pc++) { + if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc)) + continue; + const int br = cur.row + pr, bc = cur.col + pc; + if (br >= 0 && br < TetrisGame::BOARD_ROWS && bc >= 0 && bc < TetrisGame::BOARD_COLS) + cg[br][bc] = static_cast(cur.type + 1); + } + for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) { + uint8_t c = 0; + while (c < TetrisGame::BOARD_COLS) { + const uint8_t v = cg[r][c]; + if (v == 0) { + c++; + continue; + } + const uint8_t c0 = c; + while (c < TetrisGame::BOARD_COLS && cg[r][c] == v) + c++; + const int16_t rx = ox + static_cast(c0) * CELL_PX; + const int16_t ry = oy + static_cast(r) * CELL_PX; + const int16_t rw = static_cast(c - c0) * CELL_PX - 1; // span the run, drop the trailing gap + graphics::registerTFTColorRegionDirect(rx, ry, rw, CELL_PX - 1, tetrominoColor(static_cast(v - 1)), bg); + } + } + // Next-piece preview: one region over the 4x4 grid tinted with its colour. + graphics::registerTFTColorRegionDirect(lpx, nxtPreviewY, 4 * PREV_PX, 4 * PREV_PX, tetrominoColor(nxt.type), bg); +#endif +} + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/modules/games/Tetris.h b/src/modules/games/Tetris.h new file mode 100644 index 000000000..4b429ec2e --- /dev/null +++ b/src/modules/games/Tetris.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include + +/** + * Pure, self-contained Tetris game logic. + * + * No Arduino/display dependencies - designed to be unit-tested natively and reused by the Tetris + * adapter below without pulling in the display stack. + * + * Board coordinate system: col=0 is leftmost, row=0 is top (gravity goes toward higher rows). + * board[row][col] holds 0 (empty) or 1..7 (locked piece colour index). + * No dynamic allocation: total struct size is ~260 bytes. + */ +class TetrisGame +{ + public: + static constexpr uint8_t BOARD_COLS = 10; + static constexpr uint8_t BOARD_ROWS = 16; // 16×4 px = 64 px - fills a standard 64px OLED + static constexpr uint8_t PIECE_TYPES = 7; // I O T S Z J L + + struct Piece { + int8_t col; // left column of the 4×4 bounding box (may be negative during spawn) + int8_t row; // top row of the 4×4 bounding box (may be negative during spawn) + uint8_t type; // 0..6 + uint8_t rot; // 0..3 + }; + + // board[row][col]: 0 = empty, 1..7 = locked piece colour + uint8_t board[BOARD_ROWS][BOARD_COLS]; + + /** Start (or restart) the game. seed drives the xorshift32 RNG. */ + void reset(uint32_t seed); + + /** Shift the current piece left one column. Returns true if it moved. */ + bool moveLeft(); + + /** Shift the current piece right one column. Returns true if it moved. */ + bool moveRight(); + + /** + * Rotate the current piece CW. Tries a basic wall-kick (±1, ±2 column) if the + * natural rotation overlaps a wall or locked cell. Returns true if it rotated. + */ + bool rotate(); + + /** + * Move the current piece down one row. If it cannot fall it is locked, + * lines are cleared, the next piece becomes current, and a new next is generated. + * Returns false after locking (game may or may not be over). + */ + bool softDrop(); + + /** + * Instantly drop the current piece to where it would land and lock it. + * Awards 2 pts per row dropped. + */ + void hardDrop(); + + /** + * Gravity tick: same as softDrop() - move down one row, lock if needed. + * Returns true while the game is alive, false after game-over. + */ + bool step(); + + bool isPlaying() const { return alive; } + uint32_t score() const { return pts; } + uint8_t level() const { return lvl; } + uint16_t linesCleared() const { return lines; } + + const Piece ¤t() const { return cur; } + const Piece &next() const { return nxt; } + + /** + * Returns the top row the current piece would occupy if instantly dropped. + * Used by the renderer to show a ghost/shadow piece. + */ + int8_t ghostRow() const; + + /** + * Returns true if cell (pr, pc) within the 4×4 bounding box is filled for + * the given piece type and rotation. Safe for any (type, rot, pr, pc). + */ + static bool pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc); + + private: + Piece cur = {}; + Piece nxt = {}; + uint32_t pts = 0; + uint8_t lvl = 1; + uint16_t lines = 0; + uint32_t rng = 1; // xorshift32 state - must never be 0 + bool alive = false; + + uint32_t nextRandom(); + Piece spawnPiece(uint8_t type) const; + void advanceNext(); // lock cur, clear lines, shift nxt→cur, spawn new nxt + bool canPlace(const Piece &p) const; + void lockPiece(); + int clearLines(); // returns number of lines cleared (0..4) + + // Shape table: SHAPES[type][rot][row] = 4-bit column bitmask. + // Bit 0 = leftmost column (col 0), bit 3 = rightmost (col 3) of the 4×4 box. + static const uint8_t SHAPES[PIECE_TYPES][4][4]; +}; + +#include "configuration.h" + +#if HAS_SCREEN && BASEUI_HAS_GAMES + +#include "Game.h" +#include "HighScoreTable.h" + +/** + * Tetris as a hosted Game. Wraps the pure TetrisGame logic above and supplies the attract art, the + * portrait playfield renderer, the rotate/move/drop input, the level-based speed curve, and its + * own high-score table. The new-high-score mesh announcement is shared by all games and lives in + * GamesModule. + */ +class Tetris : public Game +{ + public: + Tetris(); + + const char *name() const override { return "Tetris"; } + + void start(uint32_t seed) override { game.reset(seed); } + bool tick() override { return game.step(); } + bool isPlaying() const override { return game.isPlaying(); } + uint32_t score() const override { return game.score(); } + int32_t tickIntervalMs() const override; + + void handleInput(input_broker_event ev) override; + + void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override; + void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override; + const char *gameOverHint() const override { return "SEL: scores BCK: exit"; } + + HighScoreTableBase &scores() override { return scores_; } + + private: + // On-disk high-score record; layout preserved from the original TetrisModule so tetris.dat + // keeps loading. Magic 'TETR', file version 1. + struct TetrisEntry { + uint32_t score; + char shortName[5]; // NUL-terminated 3-char display name + uint32_t nodeNum; + uint32_t epoch; + } __attribute__((packed)); + + TetrisGame game; + HighScoreTable scores_{"/prefs/tetris.dat", 0x54455452u, 1, "Tetris"}; +}; + +#endif // HAS_SCREEN && BASEUI_HAS_GAMES diff --git a/src/platform/portduino/HUB75Native.cpp b/src/platform/portduino/HUB75Native.cpp new file mode 100644 index 000000000..55f0c1415 --- /dev/null +++ b/src/platform/portduino/HUB75Native.cpp @@ -0,0 +1,163 @@ +#include "configuration.h" + +#if defined(HAS_HUB75_NATIVE) + +#include "HUB75Native.h" +#include "PortduinoGlue.h" +#include "graphics/TFTColorRegions.h" +#include "graphics/TFTPalette.h" +#include + +HUB75Native::HUB75Native(uint8_t, int, int, OLEDDISPLAY_GEOMETRY, HW_I2C) +{ + // The BaseUI treats the panel as a generic raw framebuffer (not an SSD1306 page layout). The + // panel size comes from config.yaml at runtime (cols*chain x rows*parallel, computed in + // loadConfig()). Config is fully loaded by portduinoSetup() before Screen constructs. + setGeometry(GEOMETRY_RAWMODE, portduino_config.displayWidth, portduino_config.displayHeight); + LOG_DEBUG("HUB75Native %dx%d", portduino_config.displayWidth, portduino_config.displayHeight); +} + +HUB75Native::~HUB75Native() +{ + if (matrix) { + delete matrix; + matrix = nullptr; + } +} + +// Bring up the rpi-rgb-led-matrix driver from config.yaml. The library owns the GPIO pins +// (selected by HardwareMapping) and runs its own refresh thread, so there is no DMA/I2S setup. +bool HUB75Native::connect() +{ + LOG_INFO("Do HUB75 init"); + + rgb_matrix::RGBMatrix::Options options; + options.hardware_mapping = portduino_config.hub75_hardware_mapping.c_str(); + options.rows = portduino_config.hub75_rows; + options.cols = portduino_config.hub75_cols; + options.chain_length = portduino_config.hub75_chain_length; + options.parallel = portduino_config.hub75_parallel; + options.pwm_bits = portduino_config.hub75_pwm_bits; + options.pwm_lsb_nanoseconds = portduino_config.hub75_pwm_lsb_nanoseconds; + options.brightness = portduino_config.hub75_brightness; + options.scan_mode = portduino_config.hub75_scan_mode; + options.row_address_type = portduino_config.hub75_row_address_type; + options.multiplexing = portduino_config.hub75_multiplexing; + options.disable_hardware_pulsing = portduino_config.hub75_disable_hardware_pulsing; + options.show_refresh_rate = portduino_config.hub75_show_refresh_rate; + options.inverse_colors = portduino_config.hub75_inverse_colors; + // RGB order is handled by the library instead of a software swap (see display()). + options.led_rgb_sequence = portduino_config.hub75_led_rgb_sequence.c_str(); + options.limit_refresh_rate_hz = portduino_config.hub75_limit_refresh_rate_hz; + if (!portduino_config.hub75_pixel_mapper_config.empty()) + options.pixel_mapper_config = portduino_config.hub75_pixel_mapper_config.c_str(); + if (!portduino_config.hub75_panel_type.empty()) + options.panel_type = portduino_config.hub75_panel_type.c_str(); + + rgb_matrix::RuntimeOptions runtime; + runtime.gpio_slowdown = portduino_config.hub75_gpio_slowdown; + runtime.daemon = 0; // run in-process; the library starts its own refresh thread + runtime.drop_privileges = 0; // meshtasticd manages its own privileges (keeps GPIO/LoRa access) + + matrix = rgb_matrix::CreateMatrixFromOptions(options, runtime); + if (matrix == nullptr) { + LOG_ERROR("HUB75 CreateMatrixFromOptions() failed (need root / /dev/gpiomem on a Pi?)"); + return false; + } + brightness = portduino_config.hub75_brightness; // library scale is 1..100 + matrix->SetBrightness(brightness); // applies to the matrix and all its FrameCanvases + matrix->Clear(); + // Offscreen buffer for double-buffered, tear-free presentation (see display()). + offscreen = matrix->CreateFrameCanvas(); + return true; +} + +void HUB75Native::display() +{ + if (!matrix || !offscreen) + return; + + const uint16_t onNative = graphics::TFTPalette::White; + const uint16_t offNative = graphics::getThemeBodyBg(); + + const uint16_t onBe = (uint16_t)((onNative >> 8) | (onNative << 8)); + const uint16_t offBe = (uint16_t)((offNative >> 8) | (offNative << 8)); + +#if GRAPHICS_TFT_COLORING_ENABLED + const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0; +#endif + + // Full-frame redraw into the offscreen canvas every call (no dirty-diff needed at these + // resolutions), then SwapOnVSync() below presents it atomically for tear-free output. + for (uint16_t y = 0; y < displayHeight; y++) { + const uint32_t yByteIndex = (y / 8) * displayWidth; + const uint8_t yByteMask = (uint8_t)(1 << (y & 7)); + +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + graphics::beginTFTColorRow((int16_t)y); +#endif + + for (uint16_t x = 0; x < displayWidth; x++) { + const bool isset = (buffer[x + yByteIndex] & yByteMask) != 0; + + uint16_t be; +#if GRAPHICS_TFT_COLORING_ENABLED + if (hasColorRegions) + be = graphics::resolveTFTColorPixelRow((int16_t)x, isset, onBe, offBe); + else + be = isset ? onBe : offBe; +#else + be = isset ? onBe : offBe; +#endif + + const uint16_t c = (uint16_t)((be >> 8) | (be << 8)); // back to native RGB565 + const uint8_t r = (uint8_t)(((c >> 11) & 0x1F) << 3); + const uint8_t g = (uint8_t)(((c >> 5) & 0x3F) << 2); + const uint8_t b = (uint8_t)((c & 0x1F) << 3); + offscreen->SetPixel(x, y, r, g, b); + } + } + +#if GRAPHICS_TFT_COLORING_ENABLED + // Regions are re-registered every frame by the renderers; clear so they don't accumulate. + graphics::clearTFTColorRegions(); +#endif + + // Present the finished frame at the next vsync; the returned canvas (the previously shown one) + // becomes our next offscreen buffer. + offscreen = matrix->SwapOnVSync(offscreen); +} + +void HUB75Native::sendCommand(uint8_t com) +{ + if (!matrix || !offscreen) + return; + + switch (com) { + case DISPLAYON: + matrix->SetBrightness(brightness); + break; + case DISPLAYOFF: + // rpi-rgb-led-matrix clamps brightness 0 up to 1 and its refresh thread keeps scanning the + // presented canvas, so SetBrightness(0) alone leaves the last frame faintly lit. Present a + // cleared frame so the panel actually goes black while asleep. (matrix->Clear() would only + // clear the RGBMatrix's own buffer, not the FrameCanvas we swap in.) Wake repaints via the + // next display(). + offscreen->Clear(); + offscreen = matrix->SwapOnVSync(offscreen); + break; + default: + // Drop all other SSD1306 init/config commands - not meaningful for the matrix. + break; + } +} + +void HUB75Native::setDisplayBrightness(uint8_t _brightness) +{ + brightness = _brightness; + if (matrix) + matrix->SetBrightness(brightness); +} + +#endif // HAS_HUB75_NATIVE diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 3903da2e8..8da3d085b 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1017,6 +1017,39 @@ bool loadConfig(const char *configPath) } } } +#if !defined(HAS_HUB75_NATIVE) + if (portduino_config.displayPanel == hub75) { + std::cerr << "HUB75 display panel selected, but this build does not support HUB75" << std::endl; + exit(EXIT_FAILURE); + } +#endif + // HUB75 RGB matrix (Raspberry Pi). Options map onto rgb_matrix::RGBMatrix::Options + + // RuntimeOptions; the library owns its GPIO pins so nothing is read via readGPIOFromYaml. + if (portduino_config.displayPanel == hub75 && yamlConfig["Display"]["HUB75"]) { + YAML::Node hub75 = yamlConfig["Display"]["HUB75"]; + portduino_config.hub75_hardware_mapping = hub75["HardwareMapping"].as("regular"); + portduino_config.hub75_rows = hub75["Rows"].as(64); + portduino_config.hub75_cols = hub75["Cols"].as(64); + portduino_config.hub75_chain_length = hub75["ChainLength"].as(1); + portduino_config.hub75_parallel = hub75["Parallel"].as(1); + portduino_config.hub75_pwm_bits = hub75["PWMBits"].as(11); + portduino_config.hub75_pwm_lsb_nanoseconds = hub75["PWMLSBNanoseconds"].as(130); + portduino_config.hub75_brightness = hub75["Brightness"].as(100); + portduino_config.hub75_scan_mode = hub75["ScanMode"].as(0); + portduino_config.hub75_row_address_type = hub75["RowAddressType"].as(0); + portduino_config.hub75_multiplexing = hub75["Multiplexing"].as(0); + portduino_config.hub75_disable_hardware_pulsing = hub75["DisableHardwarePulsing"].as(false); + portduino_config.hub75_show_refresh_rate = hub75["ShowRefreshRate"].as(false); + portduino_config.hub75_inverse_colors = hub75["InverseColors"].as(false); + portduino_config.hub75_led_rgb_sequence = hub75["RGBSequence"].as("RGB"); + portduino_config.hub75_pixel_mapper_config = hub75["PixelMapper"].as(""); + portduino_config.hub75_panel_type = hub75["PanelType"].as(""); + portduino_config.hub75_limit_refresh_rate_hz = hub75["LimitRefreshRateHz"].as(0); + portduino_config.hub75_gpio_slowdown = hub75["GPIOSlowdown"].as(1); + // The BaseUI framebuffer geometry is the full panel size in pixels. + portduino_config.displayWidth = portduino_config.hub75_cols * portduino_config.hub75_chain_length; + portduino_config.displayHeight = portduino_config.hub75_rows * portduino_config.hub75_parallel; + } } if (yamlConfig["Touchscreen"]) { if (yamlConfig["Touchscreen"]["Module"].as("") == "XPT2046") diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index 050aaaf87..b5a47787e 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -34,7 +34,7 @@ inline const std::unordered_map configProducts = { {"RAK6421-13300-S1", "lora-RAK6421-13300-slot1.yaml"}, {"RAK6421-13300-S2", "lora-RAK6421-13300-slot2.yaml"}}; -enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d }; +enum screen_modules { no_screen, x11, fb, st7789, st7735, st7735s, st7796, ili9341, ili9342, ili9486, ili9488, hx8357d, hub75 }; enum touchscreen_modules { no_touchscreen, xpt2046, stmpe610, gt911, ft5x06 }; enum portduino_log_level { level_error, level_warn, level_info, level_debug, level_trace }; enum lora_module_enum { @@ -83,7 +83,7 @@ extern struct portduino_config_struct { std::map screen_names = {{x11, "X11"}, {fb, "FB"}, {st7789, "ST7789"}, {st7735, "ST7735"}, {st7735s, "ST7735S"}, {st7796, "ST7796"}, {ili9341, "ILI9341"}, {ili9342, "ILI9342"}, {ili9486, "ILI9486"}, - {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}}; + {ili9488, "ILI9488"}, {hx8357d, "HX8357D"}, {hub75, "HUB75"}}; lora_module_enum lora_module; bool has_rfswitch_table = false; @@ -147,6 +147,29 @@ extern struct portduino_config_struct { pinMapping displayBacklightPWMChannel = {"Display", "BacklightPWMChannel"}; pinMapping displayReset = {"Display", "Reset"}; + // Display -> HUB75 (Raspberry Pi RGB matrix via hzeller/rpi-rgb-led-matrix). + // These mirror rgb_matrix::RGBMatrix::Options + RuntimeOptions; the library owns the GPIO + // pins (chosen by hub75_hardware_mapping), so there are no per-pin mappings here. + std::string hub75_hardware_mapping = "regular"; + int hub75_rows = 64; + int hub75_cols = 64; + int hub75_chain_length = 1; + int hub75_parallel = 1; + int hub75_pwm_bits = 11; + int hub75_pwm_lsb_nanoseconds = 130; + int hub75_brightness = 100; // percent, 1..100 + int hub75_scan_mode = 0; + int hub75_row_address_type = 0; + int hub75_multiplexing = 0; + bool hub75_disable_hardware_pulsing = false; + bool hub75_show_refresh_rate = false; + bool hub75_inverse_colors = false; + std::string hub75_led_rgb_sequence = "RGB"; + std::string hub75_pixel_mapper_config = ""; + std::string hub75_panel_type = ""; + int hub75_limit_refresh_rate_hz = 0; + int hub75_gpio_slowdown = 1; // RuntimeOptions; higher for faster Pis / long cables + // Touchscreen std::string touchscreen_spi_dev = ""; int touchscreen_spi_dev_int = 0; @@ -423,6 +446,30 @@ extern struct portduino_config_struct { out << YAML::Key << "OffsetRotate" << YAML::Value << displayOffsetRotate; + if (displayPanel == hub75) { + out << YAML::Key << "HUB75" << YAML::Value << YAML::BeginMap; + out << YAML::Key << "HardwareMapping" << YAML::Value << hub75_hardware_mapping; + out << YAML::Key << "Rows" << YAML::Value << hub75_rows; + out << YAML::Key << "Cols" << YAML::Value << hub75_cols; + out << YAML::Key << "ChainLength" << YAML::Value << hub75_chain_length; + out << YAML::Key << "Parallel" << YAML::Value << hub75_parallel; + out << YAML::Key << "PWMBits" << YAML::Value << hub75_pwm_bits; + out << YAML::Key << "PWMLSBNanoseconds" << YAML::Value << hub75_pwm_lsb_nanoseconds; + out << YAML::Key << "Brightness" << YAML::Value << hub75_brightness; + out << YAML::Key << "ScanMode" << YAML::Value << hub75_scan_mode; + out << YAML::Key << "RowAddressType" << YAML::Value << hub75_row_address_type; + out << YAML::Key << "Multiplexing" << YAML::Value << hub75_multiplexing; + out << YAML::Key << "DisableHardwarePulsing" << YAML::Value << hub75_disable_hardware_pulsing; + out << YAML::Key << "ShowRefreshRate" << YAML::Value << hub75_show_refresh_rate; + out << YAML::Key << "InverseColors" << YAML::Value << hub75_inverse_colors; + out << YAML::Key << "RGBSequence" << YAML::Value << hub75_led_rgb_sequence; + out << YAML::Key << "PixelMapper" << YAML::Value << hub75_pixel_mapper_config; + out << YAML::Key << "PanelType" << YAML::Value << hub75_panel_type; + out << YAML::Key << "LimitRefreshRateHz" << YAML::Value << hub75_limit_refresh_rate_hz; + out << YAML::Key << "GPIOSlowdown" << YAML::Value << hub75_gpio_slowdown; + out << YAML::EndMap; // HUB75 + } + out << YAML::EndMap; // Display } diff --git a/src/platform/portduino/architecture.h b/src/platform/portduino/architecture.h index b1698a4eb..a35cecfb3 100644 --- a/src/platform/portduino/architecture.h +++ b/src/platform/portduino/architecture.h @@ -30,4 +30,12 @@ #define TB_LEFT (uint8_t) portduino_config.tbLeftPin.pin #define TB_RIGHT (uint8_t) portduino_config.tbRightPin.pin #define TB_PRESS (uint8_t) portduino_config.tbPressPin.pin +#endif + +// HUB75 RGB-matrix panel support on Raspberry Pi (Portduino) turns on automatically when the +// hzeller/rpi-rgb-led-matrix library is installed - its headers land on the include path via +// `pkg-config rgbmatrix` (wired up in variants/native/portduino/platformio.ini). When absent, +// HAS_HUB75_NATIVE stays undefined and the backend compiles out. See src/graphics/HUB75Display.cpp. +#if defined(ARCH_PORTDUINO) && __has_include() +#define HAS_HUB75_NATIVE 1 #endif \ No newline at end of file diff --git a/test/native-suite-count b/test/native-suite-count index f5c89552b..8f92bfdd4 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -32 +35 diff --git a/test/test_breakout/test_main.cpp b/test/test_breakout/test_main.cpp new file mode 100644 index 000000000..1ca2da5e2 --- /dev/null +++ b/test/test_breakout/test_main.cpp @@ -0,0 +1,103 @@ +#include "TestUtil.h" +#include "modules/games/Breakout.h" +#include + +// Pure-logic tests for BreakoutGame: initial serve/brick state, paddle clamping, brick-clearing on +// a straight-up serve, and the ball staying within the board. No device globals or display stack. + +static const uint32_t kSeed = 0xC0FFEEu; + +void test_reset_initialState() +{ + BreakoutGame game; + game.reset(kSeed); + TEST_ASSERT_TRUE(game.isPlaying()); + TEST_ASSERT_EQUAL_UINT8(BreakoutGame::START_LIVES, game.lives()); + TEST_ASSERT_EQUAL_UINT8(1, game.level()); + TEST_ASSERT_EQUAL_UINT32(0, game.score()); + // Every brick present at the start. + TEST_ASSERT_EQUAL_UINT16(static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS, game.bricksRemaining()); + // Paddle centred, ball above it and inside the board. + TEST_ASSERT_EQUAL_INT16((BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W) / 2, game.paddleX()); + TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W); + TEST_ASSERT_TRUE(game.ballY() >= 0 && game.ballY() < BreakoutGame::BOARD_H); +} + +void test_paddle_clampsToEdges() +{ + BreakoutGame game; + game.reset(kSeed); + for (int i = 0; i < 100; i++) + game.moveLeft(); + TEST_ASSERT_EQUAL_INT16(0, game.paddleX()); + for (int i = 0; i < 100; i++) + game.moveRight(); + TEST_ASSERT_EQUAL_INT16(BreakoutGame::BOARD_W - BreakoutGame::PADDLE_W, game.paddleX()); +} + +void test_serve_clearsABrickAndScores() +{ + BreakoutGame game; + game.reset(kSeed); + // The ball serves upward from just above the paddle straight into the brick field; within a + // few dozen steps it must clear at least one brick and score. + for (int i = 0; + i < 60 && game.bricksRemaining() == static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS; i++) + game.step(); + TEST_ASSERT_TRUE(game.bricksRemaining() < static_cast(BreakoutGame::BRICK_ROWS) * BreakoutGame::BRICK_COLS); + TEST_ASSERT_TRUE(game.score() > 0); +} + +void test_ball_staysInBounds() +{ + BreakoutGame game; + game.reset(kSeed); + // Drive the paddle to follow the ball so the game keeps going, and check the ball never leaves + // the board horizontally across a long run. + for (int i = 0; i < 500 && game.isPlaying(); i++) { + if (game.ballX() < game.paddleX()) + game.moveLeft(); + else + game.moveRight(); + game.step(); + TEST_ASSERT_TRUE(game.ballX() >= 0 && game.ballX() < BreakoutGame::BOARD_W); + TEST_ASSERT_TRUE(game.ballY() >= 0); + } +} + +void test_deadGame_stepIsNoOp() +{ + BreakoutGame game; + game.reset(kSeed); + // Park the paddle in a corner and never move it; the ball is eventually lost every life. + game.moveLeft(); + for (int i = 0; i < 20000 && game.isPlaying(); i++) { + for (int j = 0; j < 40; j++) // hold the paddle pinned left + game.moveLeft(); + game.step(); + } + TEST_ASSERT_FALSE(game.isPlaying()); + const uint32_t scoreBefore = game.score(); + TEST_ASSERT_FALSE(game.step()); // stays dead, no further change + TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score()); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_reset_initialState); + RUN_TEST(test_paddle_clampsToEdges); + RUN_TEST(test_serve_clearsABrickAndScores); + RUN_TEST(test_ball_staysInBounds); + RUN_TEST(test_deadGame_stepIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_chirpy/test_main.cpp b/test/test_chirpy/test_main.cpp new file mode 100644 index 000000000..487b2830f --- /dev/null +++ b/test/test_chirpy/test_main.cpp @@ -0,0 +1,100 @@ +#include "TestUtil.h" +#include "modules/games/ChirpyRunner.h" +#include + +// Pure-logic tests for ChirpyRunnerGame: initial state, jump lifts Chirpy off the ground, +// obstacles spawn and a collision ends the run, and a dead game is inert. No device globals. + +static const uint32_t kSeed = 0xC0FFEEu; + +void test_reset_initialState() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + TEST_ASSERT_TRUE(game.isPlaying()); + TEST_ASSERT_EQUAL_UINT32(0, game.score()); + TEST_ASSERT_TRUE(game.onGround()); + TEST_ASSERT_EQUAL_INT16(ChirpyRunnerGame::GROUND_Y - ChirpyRunnerGame::CHIRPY_H, game.chirpyY()); +} + +void test_jump_liftsChirpy() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + const int16_t groundY = game.chirpyY(); + game.jump(); + game.step(); + TEST_ASSERT_FALSE(game.onGround()); + TEST_ASSERT_TRUE(game.chirpyY() < groundY); // rose above the ground rest position +} + +void test_jump_ignoredWhileAirborne() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + game.jump(); + game.step(); + const int16_t yAfterFirst = game.chirpyY(); + // A second jump mid-air must not re-launch: after another step Chirpy keeps descending toward + // the ground under gravity rather than shooting back up. + game.jump(); + game.step(); + // Not asserting exact physics, only that we're still airborne and moving as one arc, not reset. + TEST_ASSERT_FALSE(game.onGround()); + (void)yAfterFirst; +} + +void test_obstacleSpawnsAndCollisionEndsGame() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + // One step spawns the first obstacle. + game.step(); + bool any = false; + for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) + any = any || game.obstacleActive(i); + TEST_ASSERT_TRUE(any); + + // Never jumping, an obstacle must reach grounded Chirpy and end the run. + int steps = 0; + while (game.isPlaying() && steps < 2000) { + game.step(); + steps++; + } + TEST_ASSERT_FALSE(game.isPlaying()); +} + +void test_deadGame_stepIsNoOp() +{ + ChirpyRunnerGame game; + game.reset(kSeed); + int steps = 0; + while (game.isPlaying() && steps < 2000) { + game.step(); + steps++; + } + TEST_ASSERT_FALSE(game.isPlaying()); + const uint32_t scoreBefore = game.score(); + TEST_ASSERT_FALSE(game.step()); // stays dead, no further change + TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score()); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_reset_initialState); + RUN_TEST(test_jump_liftsChirpy); + RUN_TEST(test_jump_ignoredWhileAirborne); + RUN_TEST(test_obstacleSpawnsAndCollisionEndsGame); + RUN_TEST(test_deadGame_stepIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_snake/test_main.cpp b/test/test_snake/test_main.cpp new file mode 100644 index 000000000..8668e66a5 --- /dev/null +++ b/test/test_snake/test_main.cpp @@ -0,0 +1,180 @@ +#include "TestUtil.h" +#include "modules/games/Snake.h" +#include + +// Pure-logic tests for SnakeGame: ring-buffer advance, reversal rejection, wall/self collision, +// growth on eat, and food-placement validity. No device globals or display stack required. + +static const uint32_t kSeed = 0xC0FFEEu; + +// Count how many board cells the snake currently occupies (cross-check for len()). +static uint16_t countOccupied(const SnakeGame &game) +{ + uint16_t n = 0; + for (uint8_t y = 0; y < SnakeGame::GRID_H; y++) + for (uint8_t x = 0; x < SnakeGame::GRID_W; x++) + if (game.occupied(x, y)) + n++; + return n; +} + +static void test_reset_initialState() +{ + SnakeGame game; + game.reset(kSeed); + + TEST_ASSERT_TRUE(game.isPlaying()); + TEST_ASSERT_FALSE(game.isWon()); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); + TEST_ASSERT_EQUAL_UINT32(0u, game.score()); + TEST_ASSERT_EQUAL_INT(SnakeGame::DIR_RIGHT, game.direction()); + + // Head spawns at board centre; the whole test file relies on this anchor. + SnakeGame::Cell head = game.head(); + TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_W / 2, head.x); + TEST_ASSERT_EQUAL_UINT8(SnakeGame::GRID_H / 2, head.y); + + // Exactly START_LEN cells occupied, and the head is one of them. + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game)); + TEST_ASSERT_TRUE(game.occupied(head.x, head.y)); +} + +static void test_food_isValidAndOffBody() +{ + SnakeGame game; + game.reset(kSeed); + SnakeGame::Cell food = game.food(); + TEST_ASSERT_TRUE(food.x < SnakeGame::GRID_W); + TEST_ASSERT_TRUE(food.y < SnakeGame::GRID_H); + TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); // food never spawns on the snake +} + +static void test_setDirection_rejectsReversal() +{ + SnakeGame game; + game.reset(kSeed); // heading right + + TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); // 180 reversal -> rejected + TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_UP)); // perpendicular -> ok + TEST_ASSERT_TRUE(game.setDirection(SnakeGame::DIR_RIGHT)); // same as committed dir -> ok (no-op) + + // A double-input within one tick can't chain into a reversal: after latching UP, LEFT is + // still checked against the committed RIGHT and rejected, so the neck stays safe. + game.setDirection(SnakeGame::DIR_UP); + TEST_ASSERT_FALSE(game.setDirection(SnakeGame::DIR_LEFT)); +} + +static void test_step_movesAndTailFollows() +{ + SnakeGame game; + game.reset(kSeed); + SnakeGame::Cell head = game.head(); + game.placeFoodAt(0, 0); // corner, off the snake -> guaranteed non-eating step + + TEST_ASSERT_TRUE(game.step()); + SnakeGame::Cell newHead = game.head(); + TEST_ASSERT_EQUAL_UINT8(head.x + 1, newHead.x); // moved one cell right + TEST_ASSERT_EQUAL_UINT8(head.y, newHead.y); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, game.length()); // length unchanged when not eating + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN, countOccupied(game)); + TEST_ASSERT_EQUAL_UINT32(0u, game.score()); +} + +static void test_eat_growsAndScores() +{ + SnakeGame game; + game.reset(kSeed); + SnakeGame::Cell head = game.head(); + game.placeFoodAt(head.x + 1, head.y); // food directly ahead + + TEST_ASSERT_TRUE(game.step()); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, game.length()); // grew by one + TEST_ASSERT_EQUAL_UINT32(1u, game.score()); + TEST_ASSERT_EQUAL_UINT16(SnakeGame::START_LEN + 1, countOccupied(game)); + + // A fresh food was placed and is not on the snake. + SnakeGame::Cell food = game.food(); + TEST_ASSERT_FALSE(game.occupied(food.x, food.y)); +} + +static void test_wallCollision_endsGame() +{ + SnakeGame game; + game.reset(kSeed); + game.placeFoodAt(0, 0); + game.setDirection(SnakeGame::DIR_UP); // head is at mid-height; drive straight up into the wall + + bool alive = true; + int guard = 0; + while (alive && guard++ < SnakeGame::GRID_H + 4) { + game.placeFoodAt(0, 0); // keep food out of the way each tick + alive = game.step(); + } + TEST_ASSERT_FALSE(alive); + TEST_ASSERT_FALSE(game.isPlaying()); +} + +static void test_selfCollision_endsGame() +{ + SnakeGame game; + game.reset(kSeed); + TEST_ASSERT_EQUAL_UINT8(16, game.head().x); // anchor the deterministic path below + TEST_ASSERT_EQUAL_UINT8(6, game.head().y); + + // Grow to length 5 along a straight horizontal line (cells (14..18, 6)). + game.placeFoodAt(17, 6); + TEST_ASSERT_TRUE(game.step()); + game.placeFoodAt(18, 6); + TEST_ASSERT_TRUE(game.step()); + TEST_ASSERT_EQUAL_UINT16(5, game.length()); + + // Curl back on itself: DOWN, LEFT, then UP re-enters an occupied body cell. + game.setDirection(SnakeGame::DIR_DOWN); + game.placeFoodAt(0, 0); + TEST_ASSERT_TRUE(game.step()); + game.setDirection(SnakeGame::DIR_LEFT); + game.placeFoodAt(0, 0); + TEST_ASSERT_TRUE(game.step()); + game.setDirection(SnakeGame::DIR_UP); + game.placeFoodAt(0, 0); + TEST_ASSERT_FALSE(game.step()); // bites its own body + TEST_ASSERT_FALSE(game.isPlaying()); +} + +static void test_deadGame_stepIsNoOp() +{ + SnakeGame game; + game.reset(kSeed); + game.setDirection(SnakeGame::DIR_UP); + for (int i = 0; i < SnakeGame::GRID_H + 4; i++) { + game.placeFoodAt(0, 0); + game.step(); + } + TEST_ASSERT_FALSE(game.isPlaying()); + uint32_t scoreBefore = game.score(); + TEST_ASSERT_FALSE(game.step()); // stays dead, no state change + TEST_ASSERT_EQUAL_UINT32(scoreBefore, game.score()); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_reset_initialState); + RUN_TEST(test_food_isValidAndOffBody); + RUN_TEST(test_setDirection_rejectsReversal); + RUN_TEST(test_step_movesAndTailFollows); + RUN_TEST(test_eat_growsAndScores); + RUN_TEST(test_wallCollision_endsGame); + RUN_TEST(test_selfCollision_endsGame); + RUN_TEST(test_deadGame_stepIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/variants/esp32s3/visualizer-hub75/platformio.ini b/variants/esp32s3/visualizer-hub75/platformio.ini new file mode 100644 index 000000000..fb321da26 --- /dev/null +++ b/variants/esp32s3/visualizer-hub75/platformio.ini @@ -0,0 +1,20 @@ +; Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) + 128x64 HUB75 matrix + Wio-SX1262 - DIY +[env:visualizer-hub75] +extends = esp32s3_base +board = esp32-s3-devkitc-1 +board_check = true +board_build.partitions = default_16MB.csv +board_level = extra +board_upload.flash_size = 16MB ; N16R8 -> 16MB flash +board_build.arduino.memory_type = qio_opi ; N16R8 -> 8MB octal (OPI) PSRAM +build_flags = + ${esp32s3_base.build_flags} + -D VISUALIZER_HUB75 + -D BOARD_HAS_PSRAM + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -I variants/esp32s3/visualizer-hub75 +lib_deps = + ${esp32s3_base.lib_deps} + # renovate: datasource=github-tags depName=ESP32-HUB75-MatrixPanel-I2S-DMA packageName=mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA + https://github.com/mrfaptastic/ESP32-HUB75-MatrixPanel-I2S-DMA/archive/refs/tags/3.0.14.zip diff --git a/variants/esp32s3/visualizer-hub75/variant.h b/variants/esp32s3/visualizer-hub75/variant.h new file mode 100644 index 000000000..0481024e3 --- /dev/null +++ b/variants/esp32s3/visualizer-hub75/variant.h @@ -0,0 +1,66 @@ +// Big Visualizer - ESP32-S3-DevKitC-1 (N16R8) driving a 128x64 HUB75 RGB matrix +// via the seengreat "RGB Matrix Adapter Board (E)" Rev. 2.2 (V2.x), plus a +// Wio-SX1262 (for XIAO, Seeed p/n 6379) LoRa header board. + +#define BUTTON_PIN 0 + +#define I2C_SDA 41 +#define I2C_SCL 42 + +// --- Display: HUB75 via seengreat Shield (V2.x, hard-wired) ----------------- +#define HAS_SPI_TFT 1 // enable the color BaseUI path (TFTColorRegions/theming) +#define DISPLAY_FORCE_SMALL_FONTS // 128x64 panel: keep OLED-sized fonts, not big TFT fonts +#define USE_HUB75 // select HUB75Display in Screen.cpp + +#define TFT_WIDTH 128 +#define TFT_HEIGHT 64 +#define HUB75_BRIGHTNESS_DEFAULT 180 + +// RGB data lines. +// This panel is BGR: R and B are swapped per half vs. the nominal seengreat +// mapping (R1<->B1, R2<->B2), verified at bring-up. Software-only fix; the +// shield board is unchanged. G / address / control lines stay as routed. +#define HUB75_R1 17 +#define HUB75_G1 8 +#define HUB75_B1 18 +#define HUB75_R2 15 +#define HUB75_G2 1 +#define HUB75_B2 16 +// Row-address lines (1/32 scan -> A..E) +#define HUB75_A 7 +#define HUB75_B 48 +#define HUB75_C 6 +#define HUB75_D 47 +#define HUB75_E 2 +// Control lines +#define HUB75_CLK 5 +#define HUB75_LAT 21 +#define HUB75_OE 4 + +// --- Radio: Wio-SX1262 for XIAO (header board, Seeed p/n 6379) --------------- +#define USE_SX1262 + +#define LORA_SCK 12 +#define LORA_MISO 13 +#define LORA_MOSI 11 +#define LORA_CS 10 +#define LORA_RESET 9 +#define LORA_DIO1 40 + +#define SX126X_CS LORA_CS +#define SX126X_SCK LORA_SCK +#define SX126X_MOSI LORA_MOSI +#define SX126X_MISO LORA_MISO +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY 14 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +#define SX126X_RXEN 39 +#define SX126X_TXEN RADIOLIB_NC + +#define SX126X_MAX_POWER 22 + +#define LED_HEARTBEAT 38 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 00d2a3a94..044f64166 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -56,6 +56,8 @@ build_flags_common = -luv -std=gnu17 -std=gnu++17 + -DBASEUI_HAS_GAMES=1 + -DMAX_TFT_COLOR_REGIONS=64 build_flags = ${portduino_base.build_flags_common} diff --git a/variants/native/portduino/HUB75Native.h b/variants/native/portduino/HUB75Native.h new file mode 100644 index 000000000..3b9e2728e --- /dev/null +++ b/variants/native/portduino/HUB75Native.h @@ -0,0 +1,57 @@ +#pragma once + +#include "configuration.h" + +#if defined(HAS_HUB75_NATIVE) + +#include + +#ifndef HUB75_BRIGHTNESS_DEFAULT +#define HUB75_BRIGHTNESS_DEFAULT 100 // rpi-rgb-led-matrix brightness is a 1..100 percentage +#endif + +namespace rgb_matrix +{ +class RGBMatrix; +class FrameCanvas; +} // namespace rgb_matrix + +/** + * Native (Raspberry Pi / Portduino) HUB75 RGB-matrix display backend. + * + * Drives the panel with hzeller/rpi-rgb-led-matrix. Like the ESP32 HUB75Display it plugs into the + * BaseUI color path by subclassing OLEDDisplay and expanding the mono framebuffer into RGB via the + * shared TFTColorRegions/TFTPalette helpers. It is a separate class (not shared with the ESP32 + * driver) so no rpi-rgb-led-matrix code lives in src/graphics/. All wiring/tuning comes from + * config.yaml (portduino_config.hub75_*); the library owns the GPIO pins. + * + * Bodies live in src/platform/portduino/HUB75Native.cpp (compiled native-only). + */ +class HUB75Native : public OLEDDisplay +{ + public: + HUB75Native(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus); + ~HUB75Native(); + + // Redraw the whole panel from the BaseUI buffer (region-aware color expansion). + void display() override; + + void setDisplayBrightness(uint8_t brightness); + + protected: + int getBufferOffset(void) override { return 0; } + + void sendCommand(uint8_t com) override; + + bool connect() override; + + private: + rgb_matrix::RGBMatrix *matrix = nullptr; + // Offscreen buffer for tear-free updates: display() draws here, then SwapOnVSync() atomically + // presents it. Without this, writing into the live canvas while the refresh thread scans it + // tears (worse when display() is contended during packet TX/RX). + rgb_matrix::FrameCanvas *offscreen = nullptr; + uint8_t brightness = HUB75_BRIGHTNESS_DEFAULT; // 1..100 +}; + +#endif // HAS_HUB75_NATIVE diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index e71386b67..5694a0d3b 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -20,6 +20,11 @@ build_flags = ${native_base.build_flags} !pkg-config --libs openssl --silence-errors || : !pkg-config --cflags --libs sdl2 --silence-errors || : !pkg-config --cflags --libs libbsd-overlay --silence-errors || : + ; Optional HUB75 RGB-matrix support on Raspberry Pi via hzeller/rpi-rgb-led-matrix. + ; When the lib is installed (provides rgbmatrix.pc), these add its include/link flags and + ; __has_include() enables HAS_HUB75_NATIVE (see configuration.h). Absent -> no-op. + !pkg-config --cflags rgbmatrix --silence-errors || : + !pkg-config --libs rgbmatrix --silence-errors || : [env:native-tft] extends = native_base From cf0cb9087a477a28f8a7cf49ee970a3df1015432 Mon Sep 17 00:00:00 2001 From: Jorropo Date: Fri, 17 Jul 2026 01:57:50 +0200 Subject: [PATCH 40/69] Remove some deadcode (#11034) * chore: remove empty handleWebResponse() stub in PiWebServer * chore: remove unused PowerStatus::knowsUSB() * chore: remove orphaned fsListFiles() declaration * chore: remove stale #if 0 SPI-comms test in SX126xInterface * chore: remove stale #if 0 cert-delete debug block in WebServer --- src/FSCommon.h | 1 - src/PowerStatus.h | 3 --- src/mesh/SX126xInterface.cpp | 24 ------------------------ src/mesh/http/WebServer.cpp | 8 -------- src/mesh/raspihttp/PiWebServer.cpp | 2 -- 5 files changed, 38 deletions(-) diff --git a/src/FSCommon.h b/src/FSCommon.h index c974840fe..080ede691 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -57,7 +57,6 @@ using namespace Adafruit_LittleFS_Namespace; #endif void fsInit(); -void fsListFiles(); bool copyFile(const char *from, const char *to); bool renameFile(const char *pathFrom, const char *pathTo); bool fsFormat(); diff --git a/src/PowerStatus.h b/src/PowerStatus.h index fe4543e08..0adca488a 100644 --- a/src/PowerStatus.h +++ b/src/PowerStatus.h @@ -51,9 +51,6 @@ class PowerStatus : public Status bool getHasUSB() const { return hasUSB == OptTrue; } - /// Can we even know if this board has USB power or not - bool knowsUSB() const { return hasUSB != OptUnknown; } - bool getIsCharging() const { return isCharging == OptTrue; } int getBatteryVoltageMv() const { return batteryVoltageMv; } diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index ae4c485dd..817134b58 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -173,30 +173,6 @@ template bool SX126xInterface::init() LOG_WARN("Failed to apply SX1262 register 0x8B5 patch for RX improvement"); } -#if 0 - // Read/write a register we are not using (only used for FSK mode) to test SPI comms - uint8_t crcLSB = 0; - int err = lora.readRegister(SX126X_REG_CRC_POLYNOMIAL_LSB, &crcLSB, 1); - if(err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - //if(crcLSB != 0x0f) - // RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - crcLSB = 0x5a; - err = lora.writeRegister(SX126X_REG_CRC_POLYNOMIAL_LSB, &crcLSB, 1); - if(err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - err = lora.readRegister(SX126X_REG_CRC_POLYNOMIAL_LSB, &crcLSB, 1); - if(err != RADIOLIB_ERR_NONE) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - - if(crcLSB != 0x5a) - RECORD_CRITICALERROR(CriticalErrorCode_SX1262Failure); - // If we got this far register accesses (and therefore SPI comms) are good -#endif - if (res == RADIOLIB_ERR_NONE) res = lora.setCRC(RADIOLIB_SX126X_LORA_CRC_ON); diff --git a/src/mesh/http/WebServer.cpp b/src/mesh/http/WebServer.cpp index fddc118a7..5e42aa389 100644 --- a/src/mesh/http/WebServer.cpp +++ b/src/mesh/http/WebServer.cpp @@ -96,14 +96,6 @@ static void taskCreateCert(void *parameter) { prefs.begin("MeshtasticHTTPS", false); -#if 0 - // Delete the saved certs (used in debugging) - LOG_DEBUG("Delete any saved SSL keys"); - // prefs.clear(); - prefs.remove("PK"); - prefs.remove("cert"); -#endif - LOG_INFO("Checking if we have a saved SSL Certificate"); size_t pkLen = prefs.getBytesLength("PK"); diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 49cd3ddb2..b4c99cd27 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -231,8 +231,6 @@ int callback_static_file(const struct _u_request *request, struct _u_response *r } } -static void handleWebResponse() {} - /* * Adapt the radioapi to the Webservice handleAPIv1ToRadio * Trigger : WebGui(SAVE)->WebServcice->phoneApi From b71c1adb26a71dc8d514d8125a2f327799eb4cb5 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 17 Jul 2026 08:00:45 +0800 Subject: [PATCH 41/69] stm32wl: add hardware RTC support (rak3172) (#10961) * stm32wl: add hardware RTC support infrastructure Wires the STM32WL chip's internal RTC (running off the LSE 32.768kHz crystal) into meshtastic's existing time-of-day framework (perhapsSetRTC()/readFromRTC()), following the same pattern already used for I2C RTC chips (RV3028, PCF8563/85063, RX8130CE). LSE is started and polled manually before ever calling into the STM32RTC library, with our own bounded timeout - the library's own internal LSE startup path has no bounded fallback and hangs forever via Error_Handler() if the crystal never locks, so this is required for a board with a missing/faulty crystal to boot normally rather than hang. Gated behind a new HAS_LSE variant flag (currently unset everywhere, so this is inert until a variant opts in - see follow-up commit). Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * gps: qualify RTC.h includes to avoid case-insensitive filesystem collision with STM32RTC The stm32duino STM32RTC library (added to lib_deps in a follow-up commit) ships its own src/rtc.h. On case-insensitive filesystems (the macOS default), an unqualified #include "RTC.h"/ from any file outside src/gps/ resolves to the library's rtc.h instead of src/gps/RTC.h, since PlatformIO's LDF puts lib_deps include paths ahead of the project's own -Isrc/gps. Qualify every include as gps/RTC.h so it can't collide with any same-named header a future dependency might ship, regardless of filesystem case sensitivity. Purely mechanical, no behavior change. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl(rak3172): enable hardware RTC support Opts rak3172 into the HAS_LSE infrastructure added previously: sets STM32WL_LSE_DRIVE to a conservative default and pulls in the STM32RTC library. rak3172 has ~63KB flash headroom going in; build-verified at 76.7% flash usage after this change (up from a 73.8% baseline), well within budget. wio-e5 is not opted in here despite sharing the same STM32WLE5 chip - it's already at 96.8% flash usage today (GPS + I2C sensor support compiled in, unlike rak3172), leaving too little headroom to safely add STM32RTC without first trimming something else. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl: add docstrings for LSE/RTC setup functions Addresses CodeRabbit's docstring coverage check on PR #10961. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 * stm32wl: address CodeRabbit nitpicks on PR #10961 - Brace the single-statement HAS_LSE branch in perhapsSetRTC() to match the sibling readFromRTC() branch's style. - Quote the RTC.h include in PhoneAPI.cpp for consistency with every other qualified include site. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 5 --------- Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- src/RedirectablePrint.cpp | 2 +- src/gps/GPS.cpp | 2 +- src/gps/NMEAWPL.cpp | 2 +- src/gps/RTC.cpp | 34 +++++++++++- src/gps/RTC.h | 5 ++ src/graphics/SharedUIDisplay.cpp | 2 +- src/graphics/draw/UIRenderer.cpp | 2 +- src/graphics/niche/InkHUD/Applet.cpp | 2 +- .../Applets/Bases/NodeList/NodeListApplet.cpp | 2 +- .../InkHUD/Applets/System/Menu/MenuApplet.cpp | 2 +- .../Notification/NotificationApplet.cpp | 2 +- .../InkHUD/Applets/User/Heard/HeardApplet.cpp | 2 +- .../User/RecentsList/RecentsListApplet.cpp | 2 +- .../ThreadedMessage/ThreadedMessageApplet.cpp | 2 +- src/graphics/niche/InkHUD/Events.cpp | 2 +- src/main.cpp | 6 ++- src/main.h | 3 +- src/mesh/MeshService.cpp | 2 +- src/mesh/NodeDB.cpp | 2 +- src/mesh/PhoneAPI.cpp | 2 +- src/mesh/Router.cpp | 2 +- src/mesh/StreamAPI.cpp | 2 +- src/mesh/TransmitHistory.cpp | 2 +- src/mesh/eth/ethClient.cpp | 2 +- src/mesh/wifi/WiFiAPClient.cpp | 2 +- src/modules/AdminModule.cpp | 2 +- src/modules/ExternalNotificationModule.cpp | 2 +- src/modules/KeyVerificationModule.cpp | 2 +- src/modules/MeshBeaconModule.cpp | 2 +- src/modules/NeighborInfoModule.cpp | 2 +- src/modules/NodeInfoModule.cpp | 2 +- src/modules/PositionModule.cpp | 2 +- src/modules/PowerStressModule.cpp | 2 +- src/modules/RangeTestModule.cpp | 2 +- src/modules/RemoteHardwareModule.cpp | 2 +- src/modules/SerialModule.cpp | 2 +- src/modules/StoreForwardModule.cpp | 2 +- src/modules/Telemetry/AirQualityTelemetry.cpp | 2 +- src/modules/Telemetry/DeviceTelemetry.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 2 +- src/modules/Telemetry/HealthTelemetry.cpp | 2 +- src/modules/Telemetry/PowerTelemetry.cpp | 2 +- src/modules/Telemetry/Sensor/PMSA003ISensor.h | 2 +- src/modules/Telemetry/Sensor/SCD4XSensor.h | 2 +- src/modules/Telemetry/Sensor/SEN5XSensor.h | 2 +- src/modules/Telemetry/Sensor/SFA30Sensor.h | 2 +- src/modules/esp32/AudioModule.cpp | 2 +- src/mqtt/MQTT.cpp | 2 +- src/platform/stm32wl/architecture.h | 16 ++++++ src/platform/stm32wl/main-stm32wl.cpp | 54 ++++++++++++++++++- src/security/EncryptedStorage.cpp | 2 +- variants/stm32/rak3172/platformio.ini | 5 ++ variants/stm32/rak3172/variant.h | 3 ++ 53 files changed, 167 insertions(+), 49 deletions(-) diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 3ca197ca4..e53806ac7 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -1,8 +1,8 @@ #include "RedirectablePrint.h" #include "NodeDB.h" -#include "RTC.h" #include "concurrency/OSThread.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "memGet.h" #include "mesh/generated/meshtastic/mesh.pb.h" diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 430030422..0a942f10d 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -8,10 +8,10 @@ #include "GpioLogic.h" #include "NodeDB.h" #include "PowerMon.h" -#include "RTC.h" #include "Throttle.h" #include "buzz.h" #include "concurrency/Periodic.h" +#include "gps/RTC.h" #include "meshUtils.h" #include "main.h" // pmu_found diff --git a/src/gps/NMEAWPL.cpp b/src/gps/NMEAWPL.cpp index f4249ca62..5259427e6 100644 --- a/src/gps/NMEAWPL.cpp +++ b/src/gps/NMEAWPL.cpp @@ -1,7 +1,7 @@ #if !MESHTASTIC_EXCLUDE_GPS #include "NMEAWPL.h" #include "GeoCoord.h" -#include "RTC.h" +#include "gps/RTC.h" #include /* ------------------------------------------- diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index ad0bdec04..400bfd1aa 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -1,4 +1,4 @@ -#include "RTC.h" +#include "gps/RTC.h" #include "configuration.h" #include "detect/ScanI2C.h" #include "main.h" @@ -7,6 +7,10 @@ #include #include +#if HAS_LSE +#include +#endif + static RTCQuality currentQuality = RTCQualityNone; uint32_t lastSetFromPhoneNtpOrGps = 0; @@ -211,6 +215,30 @@ RTCSetResult readFromRTC() return RTCSetResultSuccess; } } +#elif HAS_LSE + if (stm32wlRtcAvailable()) { + uint32_t now = millis(); + tv.tv_sec = STM32RTC::getInstance().getEpoch(); + tv.tv_usec = 0; + uint32_t printableEpoch = tv.tv_sec; // Print lib only supports 32 bit but time_t can be 64 bit on some platforms +#ifdef BUILD_EPOCH + if (tv.tv_sec < BUILD_EPOCH) { + if (Throttle::isWithinTimespanMs(lastTimeValidationWarning, TIME_VALIDATION_WARNING_INTERVAL_MS) == false) { + LOG_WARN("Ignore time (%ld) before build epoch (%ld)!", printableEpoch, BUILD_EPOCH); + lastTimeValidationWarning = millis(); + } + return RTCSetResultInvalidTime; + } +#endif + if (currentQuality == RTCQualityNone) { + RTCQuality oldQuality = currentQuality; + timeStartMsec = now; + zeroOffsetSecs = tv.tv_sec; + currentQuality = RTCQualityDevice; + triggerNodeInfoCheckOnTimeSource(oldQuality, currentQuality); + } + return RTCSetResultSuccess; + } #else return readFromSystemTimeFallback(); #endif @@ -335,6 +363,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd LOG_WARN("Failed to set time for RX8130CE"); } } +#elif HAS_LSE + if (stm32wlRtcAvailable()) { + STM32RTC::getInstance().setEpoch(tv->tv_sec); + } #elif defined(ARCH_ESP32) || defined(ARCH_RP2040) settimeofday(tv, NULL); #endif diff --git a/src/gps/RTC.h b/src/gps/RTC.h index b69a99ec9..2eb5293ea 100644 --- a/src/gps/RTC.h +++ b/src/gps/RTC.h @@ -8,6 +8,11 @@ #include #endif +#if HAS_LSE +// True once the STM32WL LSE crystal has locked and the hardware RTC is running (see stm32wlSetup()). +bool stm32wlRtcAvailable(); +#endif + enum RTCQuality { /// We haven't had our RTC set yet diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp index e60fe6128..88ba3f96b 100644 --- a/src/graphics/SharedUIDisplay.cpp +++ b/src/graphics/SharedUIDisplay.cpp @@ -3,8 +3,8 @@ #include "MeshService.h" #include "NodeDB.h" #include "Power.h" -#include "RTC.h" #include "draw/NodeListRenderer.h" +#include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TFTColorRegions.h" diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index f38d27d7d..53f016645 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -24,8 +24,8 @@ #include "main.h" #include "target_specific.h" #include -#include #include +#include // External variables extern graphics::Screen *screen; diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp index 82c67c9ff..d2fdc41f9 100644 --- a/src/graphics/niche/InkHUD/Applet.cpp +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -6,7 +6,7 @@ #include "main.h" -#include "RTC.h" +#include "gps/RTC.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp index 3da912a78..69dbf61af 100644 --- a/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/Bases/NodeList/NodeListApplet.cpp @@ -1,6 +1,6 @@ #ifdef MESHTASTIC_INCLUDE_INKHUD -#include "RTC.h" +#include "gps/RTC.h" #include "GeoCoord.h" #include "NodeDB.h" diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index 1d4ac8355..ac1fd1e73 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -8,9 +8,9 @@ #include "MeshService.h" #include "MessageStore.h" #include "Power.h" -#include "RTC.h" #include "Router.h" #include "airtime.h" +#include "gps/RTC.h" #include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h" #include "graphics/niche/Utils/FlashData.h" #include "main.h" diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index 195de8ecd..682c4de5e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -9,7 +9,7 @@ #include "meshUtils.h" #include "modules/TextMessageModule.h" -#include "RTC.h" +#include "gps/RTC.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp index 94a87d23d..f3ea67a77 100644 --- a/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/Heard/HeardApplet.cpp @@ -1,6 +1,6 @@ #ifdef MESHTASTIC_INCLUDE_INKHUD -#include "RTC.h" +#include "gps/RTC.h" #include "gps/GeoCoord.h" diff --git a/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp index 1ccf7fc14..2f16da5f5 100644 --- a/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/RecentsList/RecentsListApplet.cpp @@ -2,7 +2,7 @@ #include "./RecentsListApplet.h" -#include "RTC.h" +#include "gps/RTC.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp index 0edde0f7f..31aeaa814 100644 --- a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp @@ -2,7 +2,7 @@ #include "./ThreadedMessageApplet.h" -#include "RTC.h" +#include "gps/RTC.h" #include "mesh/NodeDB.h" using namespace NicheGraphics; diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index 6a2c8b46d..ddb4a57b7 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -4,8 +4,8 @@ #include "MessageStore.h" #include "PowerFSM.h" -#include "RTC.h" #include "buzz.h" +#include "gps/RTC.h" #include "modules/ExternalNotificationModule.h" #include "modules/TextMessageModule.h" #include "sleep.h" diff --git a/src/main.cpp b/src/main.cpp index cc7c239a4..47d51dea3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,13 +22,13 @@ #include "FSCommon.h" #include "Power.h" -#include "RTC.h" #include "SPILock.h" #include "Throttle.h" #include "concurrency/OSThread.h" #include "concurrency/Periodic.h" #include "detect/ScanI2C.h" #include "error.h" +#include "gps/RTC.h" #if !MESHTASTIC_EXCLUDE_I2C #include "detect/ScanI2CConsumer.h" @@ -816,6 +816,10 @@ void setup() rp2040Setup(); #endif +#ifdef ARCH_STM32WL + stm32wlSetup(); +#endif + // We do this as early as possible because this loads preferences from flash // but we need to do this after main cpu init (esp32setup), because we need the random seed set nodeDB = new NodeDB; diff --git a/src/main.h b/src/main.h index 36995f694..98dcccc71 100644 --- a/src/main.h +++ b/src/main.h @@ -113,7 +113,8 @@ extern bool runASAP; extern bool pauseBluetoothLogging; -void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode(); +void nrf52Setup(), esp32Setup(), nrf52Loop(), esp32Loop(), rp2040Setup(), rp2040Loop(), clearBonds(), enterDfuMode(), + stm32wlSetup(); #ifdef ARCH_ESP32 void esp32ReleaseBluetoothMemoryIfUnused(); #endif diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index 29be2769c..ca062abb9 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -11,8 +11,8 @@ #include "NodeDB.h" #include "Power.h" #include "PowerFSM.h" -#include "RTC.h" #include "TypeConversions.h" +#include "gps/RTC.h" #include "graphics/draw/MessageRenderer.h" #include "main.h" #include "mesh-pb-constants.h" diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 035da67e0..1e88ea873 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -13,7 +13,6 @@ #include "NodeDB.h" #include "PacketHistory.h" #include "PowerFSM.h" -#include "RTC.h" #include "RadioInterface.h" #include "Router.h" #include "SPILock.h" @@ -21,6 +20,7 @@ #include "TransmitHistory.h" #include "TypeConversions.h" #include "error.h" +#include "gps/RTC.h" #include "main.h" #include "memory/MemAudit.h" #include "mesh-pb-constants.h" diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 4c9697114..8b924a167 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -38,7 +38,7 @@ #include "mqtt/MQTT.h" #endif #include "Throttle.h" -#include +#include "gps/RTC.h" namespace { diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 7ae78cbb1..aa0c96b8a 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -5,7 +5,7 @@ #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" -#include "RTC.h" +#include "gps/RTC.h" #include "configuration.h" #include "main.h" diff --git a/src/mesh/StreamAPI.cpp b/src/mesh/StreamAPI.cpp index 12649e031..5dd1ef99b 100644 --- a/src/mesh/StreamAPI.cpp +++ b/src/mesh/StreamAPI.cpp @@ -1,9 +1,9 @@ #include "StreamAPI.h" #include "PowerFSM.h" -#include "RTC.h" #include "Throttle.h" #include "concurrency/LockGuard.h" #include "configuration.h" +#include "gps/RTC.h" #define START1 0x94 #define START2 0xc3 diff --git a/src/mesh/TransmitHistory.cpp b/src/mesh/TransmitHistory.cpp index 9238942ac..35144ec0d 100644 --- a/src/mesh/TransmitHistory.cpp +++ b/src/mesh/TransmitHistory.cpp @@ -1,7 +1,7 @@ #include "TransmitHistory.h" #include "FSCommon.h" -#include "RTC.h" #include "SPILock.h" +#include "gps/RTC.h" #include #ifdef FSCom diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index 46981498f..bf6be0b9c 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -1,8 +1,8 @@ #include "mesh/eth/ethClient.h" #include "NodeDB.h" -#include "RTC.h" #include "concurrency/Periodic.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "mesh/api/ethServerAPI.h" #include "target_specific.h" diff --git a/src/mesh/wifi/WiFiAPClient.cpp b/src/mesh/wifi/WiFiAPClient.cpp index 3d0f4baaa..d84bbfeb7 100644 --- a/src/mesh/wifi/WiFiAPClient.cpp +++ b/src/mesh/wifi/WiFiAPClient.cpp @@ -1,8 +1,8 @@ #include "configuration.h" #if HAS_WIFI #include "NodeDB.h" -#include "RTC.h" #include "concurrency/Periodic.h" +#include "gps/RTC.h" #include "mesh/wifi/WiFiAPClient.h" #include "main.h" diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 32f06f3cb..b98df39cd 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -5,8 +5,8 @@ #include "NodeDB.h" #include "PositionPrecision.h" #include "PowerFSM.h" -#include "RTC.h" #include "SPILock.h" +#include "gps/RTC.h" #include "input/InputBroker.h" #include "meshUtils.h" #include diff --git a/src/modules/ExternalNotificationModule.cpp b/src/modules/ExternalNotificationModule.cpp index a6757e04b..276c382a1 100644 --- a/src/modules/ExternalNotificationModule.cpp +++ b/src/modules/ExternalNotificationModule.cpp @@ -16,10 +16,10 @@ #include "ExternalNotificationModule.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "buzz/buzz.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "mesh/generated/meshtastic/rtttl.pb.h" #include diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index 4c2782241..c275ce1d9 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -3,7 +3,7 @@ #include "CryptoEngine.h" #include "HardwareRNG.h" #include "MeshService.h" -#include "RTC.h" +#include "gps/RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" #include "meshUtils.h" diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 6ab917ef9..2e9d865a6 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -2,11 +2,11 @@ #include "Default.h" #include "DisplayFormatters.h" #include "NodeDB.h" -#include "RTC.h" #include "RadioInterface.h" #include "Router.h" #include "TransmitHistory.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include #include diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index 6c4b452ba..a626bbcaa 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -2,7 +2,7 @@ #include "Default.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" +#include "gps/RTC.h" #include NeighborInfoModule *neighborInfoModule; diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index c86c54aff..0e40daf10 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -3,10 +3,10 @@ #include "MeshService.h" #include "NodeDB.h" #include "NodeStatus.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include #include diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index d3a702946..18931da12 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -5,13 +5,13 @@ #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "TypeConversions.h" #include "airtime.h" #include "configuration.h" #include "gps/GeoCoord.h" +#include "gps/RTC.h" #include "main.h" #include "meshUtils.h" #include "meshtastic/atak.pb.h" diff --git a/src/modules/PowerStressModule.cpp b/src/modules/PowerStressModule.cpp index 1c073a10a..b818e8895 100644 --- a/src/modules/PowerStressModule.cpp +++ b/src/modules/PowerStressModule.cpp @@ -2,9 +2,9 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerMon.h" -#include "RTC.h" #include "Router.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "sleep.h" #include "target_specific.h" diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index 57001919d..c2e3f76aa 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -13,12 +13,12 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "SPILock.h" #include "airtime.h" #include "configuration.h" #include "gps/GeoCoord.h" +#include "gps/RTC.h" #include #include diff --git a/src/modules/RemoteHardwareModule.cpp b/src/modules/RemoteHardwareModule.cpp index 04cfeb651..ef2b23c8f 100644 --- a/src/modules/RemoteHardwareModule.cpp +++ b/src/modules/RemoteHardwareModule.cpp @@ -1,9 +1,9 @@ #include "RemoteHardwareModule.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 23e8e9790..94b27c0cc 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -3,9 +3,9 @@ #include "MeshService.h" #include "NMEAWPL.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "configuration.h" +#include "gps/RTC.h" #include #include diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 413006d6e..3254d11a3 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -15,11 +15,11 @@ #include "StoreForwardModule.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" #include "Throttle.h" #include "airtime.h" #include "configuration.h" +#include "gps/RTC.h" #include "memGet.h" #include "mesh-pb-constants.h" #include "mesh/generated/meshtastic/storeforward.pb.h" diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index ceef4dbb6..7ab0ed3d4 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -9,11 +9,11 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "UnitConversions.h" #include "detect/ScanI2CTwoWire.h" +#include "gps/RTC.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index 06e220296..d8f17963d 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -4,11 +4,11 @@ #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" -#include "RTC.h" #include "RadioLibInterface.h" #include "Router.h" #include "TransmitHistory.h" #include "configuration.h" +#include "gps/RTC.h" #include "main.h" #include "memGet.h" #include diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 876d9ca07..63273239d 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -9,11 +9,11 @@ #include "NodeDB.h" #include "Power.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "UnitConversions.h" #include "buzz.h" +#include "gps/RTC.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" #include "main.h" diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index 56700010f..f68c92e1b 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -9,10 +9,10 @@ #include "NodeDB.h" #include "Power.h" #include "PowerFSM.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" #include "UnitConversions.h" +#include "gps/RTC.h" #include "main.h" #include "sleep.h" #include "target_specific.h" diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 3fa111e43..816f02898 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -9,9 +9,9 @@ #include "Power.h" #include "PowerFSM.h" #include "PowerTelemetry.h" -#include "RTC.h" #include "Router.h" #include "TransmitHistory.h" +#include "gps/RTC.h" #include "graphics/SharedUIDisplay.h" #include "main.h" #include "sleep.h" diff --git a/src/modules/Telemetry/Sensor/PMSA003ISensor.h b/src/modules/Telemetry/Sensor/PMSA003ISensor.h index 9b9cad37a..419c28bd9 100644 --- a/src/modules/Telemetry/Sensor/PMSA003ISensor.h +++ b/src/modules/Telemetry/Sensor/PMSA003ISensor.h @@ -4,8 +4,8 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" +#include "gps/RTC.h" #define PMSA003I_I2C_CLOCK_SPEED 100000 #define PMSA003I_FRAME_LENGTH 32 diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h index 0e1a46f44..065c82a13 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.h +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -4,8 +4,8 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" +#include "gps/RTC.h" #include // Max speed 400kHz diff --git a/src/modules/Telemetry/Sensor/SEN5XSensor.h b/src/modules/Telemetry/Sensor/SEN5XSensor.h index 935c8cb21..5d84b8916 100644 --- a/src/modules/Telemetry/Sensor/SEN5XSensor.h +++ b/src/modules/Telemetry/Sensor/SEN5XSensor.h @@ -4,9 +4,9 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" #include "Wire.h" +#include "gps/RTC.h" // Warm up times for SEN5X from the datasheet #ifndef SEN5X_WARMUP_MS_1 diff --git a/src/modules/Telemetry/Sensor/SFA30Sensor.h b/src/modules/Telemetry/Sensor/SFA30Sensor.h index 009138b90..a72bef252 100644 --- a/src/modules/Telemetry/Sensor/SFA30Sensor.h +++ b/src/modules/Telemetry/Sensor/SFA30Sensor.h @@ -4,8 +4,8 @@ #include "../detect/ReClockI2C.h" #include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "RTC.h" #include "TelemetrySensor.h" +#include "gps/RTC.h" #include #define SFA30_I2C_CLOCK_SPEED 100000 diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index b1d0f9d9a..6da8534e4 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -4,8 +4,8 @@ #include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" -#include "RTC.h" #include "Router.h" +#include "gps/RTC.h" /* AudioModule diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index b6f0ce24d..907888bff 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -38,7 +38,7 @@ #include #define ntohl __ntohl #endif -#include +#include MQTT *mqtt; diff --git a/src/platform/stm32wl/architecture.h b/src/platform/stm32wl/architecture.h index d269b3fa1..0aa59ff70 100644 --- a/src/platform/stm32wl/architecture.h +++ b/src/platform/stm32wl/architecture.h @@ -16,6 +16,22 @@ #ifndef HAS_WIRE #define HAS_WIRE 1 #endif +#ifndef HAS_LSE +#define HAS_LSE 0 +#endif + +// How long to wait for the LSE 32.768kHz crystal to lock before giving up on hardware RTC support. +// Override in a variant's variant.h if that board's crystal needs longer to stabilize. +#ifndef STM32WL_LSE_TIMEOUT_MS +#define STM32WL_LSE_TIMEOUT_MS 2000 +#endif + +// A variant that sets HAS_LSE must also define STM32WL_LSE_DRIVE - catch that mistake here, not as a confusing +// HAL compile error deep in main-stm32wl.cpp. +#if HAS_LSE && !defined(STM32WL_LSE_DRIVE) +#error \ + "HAS_LSE is set but STM32WL_LSE_DRIVE is not defined - set it in the variant's variant.h to one of RCC_LSEDRIVE_LOW/MEDIUMLOW/MEDIUMHIGH/HIGH" +#endif // // set HW_VENDOR diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 8ec87092b..97b63a965 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,10 +1,21 @@ -#include "RTC.h" #include "configuration.h" +#include "gps/RTC.h" +#include #include #include #include #include +#if HAS_LSE +#include + +// LSEDRV is a 2-bit RCC_BDCR field where every combination is a legal drive level, so this covers all 4 values. +static_assert((STM32WL_LSE_DRIVE & ~RCC_LSEDRIVE_HIGH) == 0, + "STM32WL_LSE_DRIVE must be one of RCC_LSEDRIVE_LOW/MEDIUMLOW/MEDIUMHIGH/HIGH"); + +static bool stm32wlRtcValid = false; +#endif + // ─── Bootloader redirect ────────────────────────────────────────────────────── // // Why .noinit + constructor instead of TAMP backup registers: @@ -86,6 +97,47 @@ bool getDeviceId(uint8_t *deviceId) return true; } +#if HAS_LSE +// Starts the LSE crystal with a bounded timeout and, if it locks, brings up the STM32 hardware RTC on it. +void stm32wlSetup() +{ + HAL_PWR_EnableBkUpAccess(); + __HAL_RCC_LSEDRIVE_CONFIG(STM32WL_LSE_DRIVE); + __HAL_RCC_LSE_CONFIG(RCC_LSE_ON); + + uint32_t start = millis(); + bool lseReady = false; + while (Throttle::isWithinTimespanMs(start, STM32WL_LSE_TIMEOUT_MS)) { + if (__HAL_RCC_GET_FLAG(RCC_FLAG_LSERDY)) { + lseReady = true; + break; + } + delay(5); + } + + if (lseReady) { + STM32RTC &rtc = STM32RTC::getInstance(); + rtc.setClockSource(STM32RTC::LSE_CLOCK); + rtc.begin(); + stm32wlRtcValid = true; + LOG_INFO("STM32WL: LSE locked, hardware RTC available"); + } else { + // Don't leave a failed oscillator burning current. + __HAL_RCC_LSE_CONFIG(RCC_LSE_OFF); + LOG_WARN("STM32WL: LSE failed to start within %dms (crystal missing/faulty?) - hardware RTC unavailable", + STM32WL_LSE_TIMEOUT_MS); + } +} + +// True once stm32wlSetup() has confirmed the LSE crystal is locked and the hardware RTC is running. +bool stm32wlRtcAvailable() +{ + return stm32wlRtcValid; +} +#else +void stm32wlSetup() {} +#endif + void cpuDeepSleep(uint32_t msecToWake) {} // Hacks to force more code and data out. diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp index e2c4deb05..206bb4ac2 100644 --- a/src/security/EncryptedStorage.cpp +++ b/src/security/EncryptedStorage.cpp @@ -5,10 +5,10 @@ // Common includes - available for all platform implementations #include "EncryptedStorage.h" #include "FSCommon.h" -#include "RTC.h" #include "SPILock.h" #include "SafeFile.h" #include "SecureZero.h" +#include "gps/RTC.h" #include #ifdef ARCH_NRF52 diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index de8f2b74b..514668a12 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -16,4 +16,9 @@ build_flags = -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +lib_deps = + ${stm32_base.lib_deps} + # renovate: datasource=github-tags depName=STM32RTC packageName=stm32duino/STM32RTC + https://github.com/stm32duino/STM32RTC/archive/refs/tags/1.9.0.zip + upload_port = stlink diff --git a/variants/stm32/rak3172/variant.h b/variants/stm32/rak3172/variant.h index 75e3e0c91..b7afefc3f 100644 --- a/variants/stm32/rak3172/variant.h +++ b/variants/stm32/rak3172/variant.h @@ -24,4 +24,7 @@ Do not expect a working Meshtastic device with this target. #define RAK3172 #define SERIAL_PRINT_PORT 1 +#define HAS_LSE 1 +#define STM32WL_LSE_DRIVE RCC_LSEDRIVE_LOW + #endif From df73185274fa17f5f850eccde54372141bd478ce Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:04:10 -0500 Subject: [PATCH 42/69] Update meshtastic-esp8266-oled-ssd1306 digest to 8999e86 (#11038) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index bea27612e..c726c5403 100644 --- a/platformio.ini +++ b/platformio.ini @@ -69,7 +69,7 @@ monitor_speed = 115200 monitor_filters = direct lib_deps = # renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master - https://github.com/meshtastic/esp8266-oled-ssd1306/archive/a6adfe3e76cb5670242032cc49add3bcf9074f52.zip + https://github.com/meshtastic/esp8266-oled-ssd1306/archive/8999e86fe1878902b068e875e3f18b8e4a87f37b.zip # renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip # renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master From 166c808cdf9ab6766c5bd894330eb2604f2f1f6d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:04:45 -0500 Subject: [PATCH 43/69] Update meshtastic/device-ui digest to 33917d5 (#11039) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index c726c5403..720a65534 100644 --- a/platformio.ini +++ b/platformio.ini @@ -127,7 +127,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/effbb925a7cbd3ab794dfcc5fa16228217d18408.zip + https://github.com/meshtastic/device-ui/archive/33917d583eb3f4d6f93a24122a88101221afcc2d.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From bdf92e118351ba1a169cc5b16377707ea399ac1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 02:18:31 +0200 Subject: [PATCH 44/69] AudioModule: validate codec2 header and bound the RX decode reads (#11036) * AudioModule: validate codec2 header and bound the RX decode reads Two issues reachable from a crafted AUDIO_APP payload: The RX path built a temp codec2 from rx_encode_frame[3] whenever the frame header did not match ours. codec2_create returns NULL for an invalid mode byte, and the next call dereferenced it. Only decode frames that carry our own header (magic + mode) and drop the rest, so the untrusted mode byte never reaches codec2_create. The decode loop advanced by the frame size while testing only i < rx_encode_frame_index, so a payload length that was not a multiple of the frame size read past the received data and could read past rx_encode_frame. Bound each read to i + frameSize <= the received length clamped to the buffer, and clamp the receive memcpy to the buffer. Behavior change: audio frames whose codec2 mode differs from this node's configured mode are dropped instead of decoded with a temporary codec. * AudioModule: pass byte count (not sample count) to i2s_write * AudioModule: fix the mirror sample/byte bug on the i2s_read capture path --- src/modules/esp32/AudioModule.cpp | 45 +++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index 6da8534e4..815e426a2 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -68,23 +68,23 @@ void run_codec2(void *parameter) } if (audioModule->radio_state == RadioState::rx) { size_t bytesOut = 0; - if (memcmp(audioModule->rx_encode_frame, &audioModule->tx_header, sizeof(audioModule->tx_header)) == 0) { - for (int i = 4; i < audioModule->rx_encode_frame_index; i += audioModule->encode_codec_size) { + // Reject frames not carrying our own header (magic + mode); an invalid mode byte + // would else NULL-deref via codec2_create. The bound keeps reads inside the buffer. + const int headerLen = sizeof(audioModule->tx_header); + const int frameSize = audioModule->encode_codec_size; + int limit = audioModule->rx_encode_frame_index; + if (limit > (int)sizeof(audioModule->rx_encode_frame)) + limit = sizeof(audioModule->rx_encode_frame); + if (frameSize > 0 && limit >= headerLen && + memcmp(audioModule->rx_encode_frame, &audioModule->tx_header, headerLen) == 0) { + for (int i = headerLen; i + frameSize <= limit; i += frameSize) { codec2_decode(audioModule->codec2, audioModule->output_buffer, audioModule->rx_encode_frame + i); - i2s_write(I2S_PORT, &audioModule->output_buffer, audioModule->adc_buffer_size, &bytesOut, - pdMS_TO_TICKS(500)); + // adc_buffer_size is a sample count; i2s_write wants bytes (int16_t samples). + i2s_write(I2S_PORT, &audioModule->output_buffer, audioModule->adc_buffer_size * sizeof(int16_t), + &bytesOut, pdMS_TO_TICKS(500)); } } else { - // if the buffer header does not match our own codec, make a temp decoding setup. - CODEC2 *tmp_codec2 = codec2_create(audioModule->rx_encode_frame[3]); - codec2_set_lpc_post_filter(tmp_codec2, 1, 0, 0.8, 0.2); - int tmp_encode_codec_size = (codec2_bits_per_frame(tmp_codec2) + 7) / 8; - int tmp_adc_buffer_size = codec2_samples_per_frame(tmp_codec2); - for (int i = 4; i < audioModule->rx_encode_frame_index; i += tmp_encode_codec_size) { - codec2_decode(tmp_codec2, audioModule->output_buffer, audioModule->rx_encode_frame + i); - i2s_write(I2S_PORT, &audioModule->output_buffer, tmp_adc_buffer_size, &bytesOut, pdMS_TO_TICKS(500)); - } - codec2_destroy(tmp_codec2); + LOG_WARN("Audio: dropping frame with mismatched or short codec2 header"); } } } @@ -213,16 +213,18 @@ int32_t AudioModule::runOnce() } } if (radio_state == RadioState::tx) { - // Get I2S data from the microphone and place in data buffer + // Get I2S data from the microphone and place in data buffer. adc_buffer_size is a + // sample count; i2s_read and adc_buffer_index are in bytes, so scale by int16_t. size_t bytesIn = 0; - res = i2s_read(I2S_PORT, adc_buffer + adc_buffer_index, adc_buffer_size - adc_buffer_index, &bytesIn, + const size_t frameBytes = adc_buffer_size * sizeof(uint16_t); + res = i2s_read(I2S_PORT, (uint8_t *)adc_buffer + adc_buffer_index, frameBytes - adc_buffer_index, &bytesIn, pdMS_TO_TICKS(40)); // wait 40ms for audio to arrive. if (res == ESP_OK) { adc_buffer_index += bytesIn; - if (adc_buffer_index == adc_buffer_size) { + if (adc_buffer_index >= frameBytes) { adc_buffer_index = 0; - memcpy((void *)speech, (void *)adc_buffer, 2 * adc_buffer_size); + memcpy((void *)speech, (void *)adc_buffer, frameBytes); // Notify run_codec2 task that the buffer is ready. radio_state = RadioState::tx; BaseType_t xHigherPriorityTaskWoken = pdFALSE; @@ -273,9 +275,12 @@ ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp) 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); + size_t n = p.payload.size; + if (n > sizeof(rx_encode_frame)) // bound a crafted payload to the RX buffer + n = sizeof(rx_encode_frame); + memcpy(rx_encode_frame, p.payload.bytes, n); radio_state = RadioState::rx; - rx_encode_frame_index = p.payload.size; + rx_encode_frame_index = n; // Notify run_codec2 task that the buffer is ready. BaseType_t xHigherPriorityTaskWoken = pdFALSE; vTaskNotifyGiveFromISR(codec2HandlerTask, &xHigherPriorityTaskWoken); From 88f20d349a31b1bfb0e149654c5db357e6919232 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:31:30 -0400 Subject: [PATCH 45/69] T5S3 ePaper build fix (#10791) * Build fix * update * Update platformio.ini * Update SerialConsole.cpp * Remove Network provisioning for this build * Update platformio.ini Per Vid, this is not needed once his PR is merged * fix: resolve merge conflict in SerialConsole.cpp - use setHostDraining(false) from develop --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/platform/extra_variants/t5s3_epaper/variant.cpp | 13 +++++++------ variants/esp32s3/t5s3_epaper/platformio.ini | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index a829bb980..9a38ddf73 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -418,19 +418,20 @@ void t5BacklightHandleUserInput() void t5TouchSetForcedByTimeout(bool forced) { + if (touchForcedByTimeout == forced) { + return; + } + touchForcedByTimeout = forced; touchStateEpoch++; touchIndicatorRefreshPending = true; if (forced) { - // While timeout-forced, keep controller asleep to avoid stale IRQ chatter. + // Timeout only gates touch input in software. Avoid GT911 I2C here because + // PowerFSM same-state transitions can fire from phone contact and screen timeout. touchNeedsWake = false; - if (touchControllerReady && !touchLightSleepActive) { - touch.sleep(); - } } else if (touchInputEnabled && touchControllerReady && !touchLightSleepActive) { - // Defer wake until readTouch() so I2C settles post-state transition. - touchNeedsWake = true; + touchNeedsWake = false; } #ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index 9dbb6d32d..781b382b5 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -1,7 +1,7 @@ [t5s3_epaper_base] extends = esp32s3_base board = t5-epaper-s3 -board_build.partition = default_16MB.csv +board_build.partitions = default_16MB.csv board_check = true upload_protocol = esptool build_flags = -fno-strict-aliasing @@ -30,8 +30,8 @@ lib_deps = [env:t5s3_epaper_inkhud] extends = t5s3_epaper_base, inkhud -board_level = extra # inkhud port is incomplete -board_check = false +board_level = extra +board_check = true build_flags = ${t5s3_epaper_base.build_flags} ${inkhud.build_flags} From 8ebdae99219f5252c636ad21bbaf1f6ccebb569e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 02:45:22 +0200 Subject: [PATCH 46/69] Bound payload.bytes prints by payload.size (#11032) decoded.payload.bytes is a 233-byte protobuf field that is not NUL-terminated. Printing it with a plain "%s" reads until a NUL, which for a full-length payload with no NUL runs past the field. Use "%.*s" with payload.size, matching the write form already used a few lines up in SerialModule. Live sites: RangeTestModule appendFile, SerialModule text output. The same fix is applied to the commented-out debug lines in RangeTestModule and Router so the pattern is consistent if they are re-enabled. --- src/mesh/Router.cpp | 2 +- src/modules/RangeTestModule.cpp | 6 +++--- src/modules/SerialModule.cpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index aa0c96b8a..9b1cc3ff2 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -753,7 +753,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) LOG_DEBUG("Original length - %d ", p->decoded.payload.size); LOG_DEBUG("Compressed length - %d ", compressed_len); - LOG_DEBUG("Original message - %s ", p->decoded.payload.bytes); + LOG_DEBUG("Original message - %.*s ", (int)p->decoded.payload.size, p->decoded.payload.bytes); // If the compressed length is greater than or equal to the original size, don't use the compressed form if (compressed_len >= p->decoded.payload.size) { diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index c2e3f76aa..a02f0594e 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -154,7 +154,7 @@ ProcessMessage RangeTestModuleRadio::handleReceived(const meshtastic_MeshPacket NodeInfoLite *n = nodeDB->getMeshNode(getFrom(&mp)); LOG_DEBUG("-----------------------------------------"); - LOG_DEBUG("p.payload.bytes \"%s\"", p.payload.bytes); + LOG_DEBUG("p.payload.bytes \"%.*s\"", (int)p.payload.size, p.payload.bytes); LOG_DEBUG("p.payload.size %d", p.payload.size); LOG_DEBUG("---- Received Packet:"); LOG_DEBUG("mp.from %d", mp.from); @@ -192,7 +192,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(getFrom(&mp)); /* LOG_DEBUG("-----------------------------------------"); - LOG_DEBUG("p.payload.bytes \"%s\"", p.payload.bytes); + LOG_DEBUG("p.payload.bytes \"%.*s\"", (int)p.payload.size, p.payload.bytes); LOG_DEBUG("p.payload.size %d", p.payload.size); LOG_DEBUG("---- Received Packet:"); LOG_DEBUG("mp.from %d", mp.from); @@ -307,7 +307,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp) fileToAppend.printf("%d,", mp.hop_limit); // Packet Hop Limit // TODO: If quotes are found in the payload, it has to be escaped. - fileToAppend.printf("\"%s\"\n", p.payload.bytes); + fileToAppend.printf("\"%.*s\"\n", (int)p.payload.size, p.payload.bytes); fileToAppend.printf("%i,", mp.rx_rssi); // RX RSSI fileToAppend.flush(); diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 94b27c0cc..aab79ffd5 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -396,7 +396,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp lastRxID = mp.id; // LOG_DEBUG("* * Message came this device"); // serialPrint->println("* * Message came this device"); - serialPrint->printf("%s", p.payload.bytes); + serialPrint->printf("%.*s", (int)p.payload.size, p.payload.bytes); } } } else { @@ -408,7 +408,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp)); const char *sender = nodeInfoLiteHasUser(node) ? node->short_name : "???"; serialPrint->println(); - serialPrint->printf("%s: %s", sender, p.payload.bytes); + serialPrint->printf("%s: %.*s", sender, (int)p.payload.size, p.payload.bytes); serialPrint->println(); } else if ((moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_NMEA || moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO) && From ed3afdbc61192a33b6ee32780f50f39537d9e03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 09:43:22 +0200 Subject: [PATCH 47/69] TrafficManagement: don't learn identity from a signer's unsigned NodeInfo (#11035) --- src/modules/TrafficManagementModule.cpp | 7 ++- test/test_traffic_management/test_main.cpp | 55 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 89ad128cc..f10a9f0d4 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -744,8 +744,13 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { if (shouldRespondToNodeInfo(&mp, true)) { + // Unicast NodeInfo is never signed, so a known signer's identity claim here is + // unauthenticated: don't overwrite its stored name. The cached response is unaffected. + const meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); + const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed; meshtastic_User requester = meshtastic_User_init_zero; - if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { + if (!unauthenticatedSigner && + 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"); diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index c203438f4..f64ea814f 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -110,6 +110,21 @@ class MockNodeDB : public NodeDB clearCachedNode(); } + // Seed a hot-store node that has been learned as an XEdDSA signer, with a known name, so the + // identity-update gate can be exercised. Distinct from setCachedNode() so a separate cached + // node (the direct-response target) can coexist. + void setSignerHotNode(NodeNum n, const char *longName) + { + if (meshNodes->size() < 2) + meshNodes->resize(2); + (*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero; + (*meshNodes)[1].num = n; + nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + nodeInfoLiteSetBit(&(*meshNodes)[1], NODEINFO_BITFIELD_HAS_USER_MASK, true); + strncpy((*meshNodes)[1].long_name, longName, sizeof((*meshNodes)[1].long_name) - 1); + numMeshNodes = 2; + } + private: bool hasCachedNode = false; NodeNum cachedNodeNum = 0; @@ -589,6 +604,45 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); } +/** + * A unicast NodeInfo request is never signed, so a known signer's identity claim on the + * direct-response path is unauthenticated. It must not overwrite the stored name (spoofing + * defense), while the direct response itself still goes out. The non-signer case + * (test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo) is the control: it still learns. + */ +static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk"); + request.to = kTargetNode; + request.decoded.want_response = true; + request.id = 0x0A0B0C0D; + request.hop_start = 3; + request.hop_limit = 3; + request.xeddsa_signed = false; // unicast: never signed + + ProcessMessage result = module.handleReceived(request); + + // The response still went out (the request was consumed from cache)... + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits); + // ...but the known signer's stored name was not overwritten by the unauthenticated claim. + const meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_EQUAL_STRING("victim-real", requestor->long_name); +} + /** * Verify client role only answers direct (0-hop) NodeInfo requests. * Important so clients do not answer relayed requests outside intended scope. @@ -1715,6 +1769,7 @@ TM_TEST_ENTRY void setup() 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_directResponse_ignoresUnsignedSignerIdentity); RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); #if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); From a7fde715c629e2e430d7394179b72836e327751e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 12:21:32 +0200 Subject: [PATCH 48/69] PhoneAPI: gate local admin on the connection, not the wire from (#11033) * PhoneAPI: gate local admin on the connection, not the wire from The lockdown admin check in handleToRadioPacket only ran when p.from == 0. from is a client-supplied wire field, and MeshService::handleToRadio rewrites it to 0 before AdminModule sees the packet. A client could therefore set from != 0 to skip the !getAdminAuthorized() drop, then have the packet normalized back to a local-admin identity and executed - unauthorized admin from an unauthorized connection. Every packet in handleToRadioPacket already comes from the local connection, so locality is a property of the connection, not of from. Move the decision into classifyLocalAdminPacket(), which ignores from and keys only on the admin variant and the connection's authorization: lockdown_auth is delivered inline, any other admin from an unauthorized connection is dropped, authorized admin passes through. The classifier is compiled unconditionally and unit-tested; the guarded caller (built only in the nRF52 lockdown config) calls it. Test: an unauthorized connection's ADMIN_APP packet with from != 0 is classified DropUnauthorized. * PhoneAPI: wipe the encoded lockdown passphrase, shorten comments --------- Co-authored-by: Ben Meadors --- src/mesh/PhoneAPI.cpp | 56 ++++++++++++++++-------- src/mesh/PhoneAPI.h | 14 ++++++ test/test_stream_api/test_main.cpp | 69 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 18 deletions(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 8b924a167..f42e3dbb3 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -1697,6 +1697,19 @@ bool PhoneAPI::wasSeenRecently(uint32_t id) return false; } +PhoneAPI::LocalAdminGate PhoneAPI::classifyLocalAdminPacket(const meshtastic_MeshPacket &p, bool adminAuthorized, + meshtastic_AdminMessage &outAdmin) +{ + if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP) + return LocalAdminGate::NotAdmin; + outAdmin = meshtastic_AdminMessage_init_zero; + if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &outAdmin)) + return LocalAdminGate::NotAdmin; // undecodable: let the normal reject path respond + if (outAdmin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) + return LocalAdminGate::LockdownAuth; // the passphrase itself; authenticate regardless of prior state + return adminAuthorized ? LocalAdminGate::AuthorizedPassThrough : LocalAdminGate::DropUnauthorized; +} + /** * Handle a packet that the phone wants us to send. It is our responsibility to free the packet to the pool */ @@ -1721,26 +1734,33 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p) // the gate here closes that race and covers H6/H7 from the // audit: get_config_request and set_config from unauthed // clients no longer reach AdminModule at all. - if (p.from == 0 && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && - p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) { + // Gate on the connection, not the wire `from`, which a client can forge to a non-zero value to + // bypass the check before MeshService normalizes it back to a local identity. + // Scope the decoded admin message: it holds the plaintext passphrase, so bound its lifetime. + { meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero; - if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) { - if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) { - handleLockdownAuthInline(admin.lockdown_auth); - // Wipe the decoded passphrase scratch - the byte array in - // p.decoded.payload.bytes is wiped by handleLockdownAuthInline. - volatile uint8_t *adminVol = const_cast(admin.lockdown_auth.passphrase.bytes); - for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++) - adminVol[i] = 0; - return true; - } - if (!getAdminAuthorized()) { - LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant); - return false; - } + switch (classifyLocalAdminPacket(p, getAdminAuthorized(), admin)) { + case LocalAdminGate::LockdownAuth: { + handleLockdownAuthInline(admin.lockdown_auth); + // The encoded wipe is the security-critical one: nothing else clears the passphrase from + // the packet buffer. handleLockdownAuthInline already zeroes the decoded copy up to its + // size; re-zero the full scratch capacity here as defense in depth. + volatile uint8_t *adminVol = const_cast(admin.lockdown_auth.passphrase.bytes); + for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++) + adminVol[i] = 0; + volatile uint8_t *encodedVol = const_cast(p.decoded.payload.bytes); + for (size_t i = 0; i < sizeof(p.decoded.payload.bytes); i++) + encodedVol[i] = 0; + p.decoded.payload.size = 0; // keep the length consistent with the wiped buffer + return true; + } + case LocalAdminGate::DropUnauthorized: + LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant); + return false; + case LocalAdminGate::NotAdmin: + case LocalAdminGate::AuthorizedPassThrough: + break; // normal handling } - // pb_decode failure: fall through to normal handling so the - // regular Router/AdminModule reject path can respond. } #endif diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index ba7b13567..ab04c178b 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -319,4 +319,18 @@ class PhoneAPI /// If the mesh service tells us fromNum has changed, tell the phone virtual int onNotify(uint32_t newValue) override; + + public: + /// How the lockdown admin gate should treat a phone->radio packet. + enum class LocalAdminGate { + NotAdmin, ///< Not a decodable ADMIN_APP payload; normal handling. + LockdownAuth, ///< A lockdown_auth payload; authenticate the connection inline. + DropUnauthorized, ///< Admin payload from a connection that has not authenticated; drop. + AuthorizedPassThrough, ///< Admin payload from an authorized connection; normal handling. + }; + + /// Classify a phone->radio packet for the lockdown admin gate, ignoring the wire `from` (which a + /// client can forge) and deciding on the connection's authorization. Fills outAdmin for lockdown. + static LocalAdminGate classifyLocalAdminPacket(const meshtastic_MeshPacket &p, bool adminAuthorized, + meshtastic_AdminMessage &outAdmin); }; diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 84613cf32..5a657d441 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -408,6 +408,73 @@ void test_serial_console_suppresses_raw_output_in_protobuf_mode() TEST_ASSERT_TRUE(emptyAfterProtobuf); } +// Build a phone->radio ADMIN_APP packet carrying `admin`, with an arbitrary wire `from`. +static meshtastic_MeshPacket makeAdminPacket(NodeNum from, const meshtastic_AdminMessage &admin) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &admin); + return p; +} + +// The lockdown admin gate must decide on the connection's authorization, not the wire `from`. A +// client that sets from != 0 previously skipped the gate, so an unauthorized connection could run +// admin. classifyLocalAdminPacket ignores `from`, so the same spoofed packet is still dropped. +static void test_lockdown_admin_gate_ignores_wire_from(void) +{ + meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero; + setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + meshtastic_MeshPacket spoofed = makeAdminPacket(0x12345678, setter); // from != 0, the bypass + + meshtastic_AdminMessage out; + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::DropUnauthorized, + (int)PhoneAPI::classifyLocalAdminPacket(spoofed, /*adminAuthorized=*/false, out), + "unauthorized admin with from != 0 must still be dropped"); + // Control: an authorized connection's identical packet passes through. + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::AuthorizedPassThrough, + (int)PhoneAPI::classifyLocalAdminPacket(spoofed, /*adminAuthorized=*/true, out), + "authorized admin must not be dropped"); + + // lockdown_auth is the authentication itself, so it is delivered inline regardless of from/auth. + meshtastic_AdminMessage la = meshtastic_AdminMessage_init_zero; + la.which_payload_variant = meshtastic_AdminMessage_lockdown_auth_tag; + meshtastic_MeshPacket authPkt = makeAdminPacket(0x99, la); + TEST_ASSERT_EQUAL((int)PhoneAPI::LocalAdminGate::LockdownAuth, + (int)PhoneAPI::classifyLocalAdminPacket(authPkt, /*adminAuthorized=*/false, out)); + + // A non-admin packet is outside the gate entirely. + meshtastic_MeshPacket text = meshtastic_MeshPacket_init_zero; + text.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + text.decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + TEST_ASSERT_EQUAL((int)PhoneAPI::LocalAdminGate::NotAdmin, + (int)PhoneAPI::classifyLocalAdminPacket(text, /*adminAuthorized=*/false, out)); +} + +// An ADMIN_APP packet whose payload is not a decodable AdminMessage must fall through to the +// normal reject path (NotAdmin), never be acted on as an admin command. The authorized control +// proves the decode-failure check runs before the auth branch, so it can't pass for the wrong reason. +static void test_lockdown_admin_gate_rejects_undecodable_admin(void) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + // Length-delimited field (tag 0x0A) claiming 16 bytes with none following: pb_decode fails. + p.decoded.payload.bytes[0] = 0x0A; + p.decoded.payload.bytes[1] = 0x10; + p.decoded.payload.size = 2; + + meshtastic_AdminMessage out; + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::NotAdmin, + (int)PhoneAPI::classifyLocalAdminPacket(p, /*adminAuthorized=*/false, out), + "undecodable ADMIN_APP payload must fall through to the reject path"); + TEST_ASSERT_EQUAL_MESSAGE((int)PhoneAPI::LocalAdminGate::NotAdmin, + (int)PhoneAPI::classifyLocalAdminPacket(p, /*adminAuthorized=*/true, out), + "undecodable ADMIN_APP payload must not pass through even when authorized"); +} + /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} /// Unity per-test teardown; fixtures clean themselves up. @@ -427,6 +494,8 @@ void setup() RUN_TEST(test_stream_api_short_write_reports_failure_without_flush); RUN_TEST(test_stream_api_finishes_pending_before_advancing_phone_api); RUN_TEST(test_stream_api_gates_logs_and_marks_them_best_effort); + RUN_TEST(test_lockdown_admin_gate_ignores_wire_from); + RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin); // usingProtobufs intentionally has no reset path, so this must run last. RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); exit(UNITY_END()); From a3c8778032a1c247b6382bbae68a4b3e20a7c3ca Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 17 Jul 2026 18:26:12 +0800 Subject: [PATCH 49/69] stm32wl(rak3172): implement cpuDeepSleep()/shutdown() via STM32LowPower (#10993) * stm32wl(rak3172): implement cpuDeepSleep()/shutdown() via STM32LowPower cpuDeepSleep() was an empty stub, so the SDS deep-sleep PowerFSM state and low-battery shutdown did nothing but leave the CPU running at full power. Power::shutdown() also didn't include STM32WL in its arch list, so an explicit shutdown command just logged a FIXME warning. Adds STM32LowPower as a lib_dep (rak3172 only, mirroring STM32RTC) and implements cpuDeepSleep() using it. Standby mode is used for both the finite-wake (SDS/low battery) and forever (shutdown) paths, via LowPower.shutdown(), with or without an RTC alarm. If the LSE-backed hardware RTC never came up (stm32wlRtcAvailable() false), this is a no-op - safer than sleeping without a confirmed wake source. LowPower.shutdown() resets the MCU on wake and should never return; if it somehow does, force a reset via HAL_NVIC_SystemReset() rather than hanging the device silently forever. Gated behind the existing HAS_LSE flag, so this is inert on every variant but rak3172. Assisted-by: Claude Sonnet 5 Signed-off-by: Andrew Yong * feat(stm32wl): Warn and reset when trying to deepSleep without RTC This is to prevent leaving the firmware catanonic when firmware has run its shutdown routine but doesn't actually shutdown. Signed-off-by: Andrew Yong --------- Signed-off-by: Andrew Yong --- src/Power.cpp | 2 +- src/platform/stm32wl/main-stm32wl.cpp | 31 ++++++++++++++++++++++++++- variants/stm32/rak3172/platformio.ini | 2 ++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Power.cpp b/src/Power.cpp index 1c1f34cd4..4118dac53 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -906,7 +906,7 @@ void Power::shutdown() #if HAS_SCREEN messageStore.saveToFlash(); #endif -#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) +#if defined(ARCH_NRF52) || defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) #ifdef PIN_LED1 ledOff(PIN_LED1); #endif diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index 97b63a965..d429c5662 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -7,6 +7,7 @@ #include #if HAS_LSE +#include #include // LSEDRV is a 2-bit RCC_BDCR field where every combination is a legal drive level, so this covers all 4 values. @@ -120,6 +121,7 @@ void stm32wlSetup() rtc.setClockSource(STM32RTC::LSE_CLOCK); rtc.begin(); stm32wlRtcValid = true; + LowPower.begin(); LOG_INFO("STM32WL: LSE locked, hardware RTC available"); } else { // Don't leave a failed oscillator burning current. @@ -138,7 +140,34 @@ bool stm32wlRtcAvailable() void stm32wlSetup() {} #endif -void cpuDeepSleep(uint32_t msecToWake) {} +void cpuDeepSleep(uint32_t msecToWake) +{ +#if HAS_LSE + if (!stm32wlRtcAvailable()) { + // Hardware can't shutdown, but firmware has already prepared itself for shutdown + // Do not leave the device unresponsive, reset instead + LOG_WARN("STM32WL: hardware RTC failed, cannot deep sleep/shutdown"); + if (Serial) { + Serial.flush(); + Serial.end(); + } + HAL_NVIC_SystemReset(); + } + + if (Serial) { + Serial.flush(); + Serial.end(); + } + + if (msecToWake != portMAX_DELAY) { + LowPower.shutdown(msecToWake); + } else { + LowPower.shutdown(); + } + // RTC wakes from shutdown into MCU reset, so this code should never be reached + HAL_NVIC_SystemReset(); +#endif +} // Hacks to force more code and data out. diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index 514668a12..5ed5be1d7 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -20,5 +20,7 @@ lib_deps = ${stm32_base.lib_deps} # renovate: datasource=github-tags depName=STM32RTC packageName=stm32duino/STM32RTC https://github.com/stm32duino/STM32RTC/archive/refs/tags/1.9.0.zip + # renovate: datasource=github-tags depName=STM32LowPower packageName=stm32duino/STM32LowPower + https://github.com/stm32duino/STM32LowPower/archive/refs/tags/1.5.0.zip upload_port = stlink From d5f78a37d3e31b576778e1aa3b7322df9a88ee2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 13:31:20 +0200 Subject: [PATCH 50/69] XModem: reject path-traversal filenames in the transfer handler (#11037) The SOH/STX control frame carries a client-supplied filename that was passed straight to FSCom open/remove/exists, so a ".." component could write, read, or delete outside the filesystem root. On embedded LittleFS this is largely inert (no parent of the partition root); on the Portduino daemon FSCom is the host filesystem under a mountpoint, so it is a real arbitrary-path write/read/delete. Validate the filename before any FS access: reject empty and any ".." path component, and NAK the transfer. Absolute and subdirectory paths are still accepted - the file manager transfers them from the manifest and PortduinoFS confines them to its mountpoint - so only traversal out of the root is blocked. Reachable only from a local client connection (PhoneAPI: BLE/USB/serial/TCP), not over the RF mesh; on the daemon the TCP API makes it network-reachable. native-suite-count goes to 34: +1 for the new test_xmodem suite and +1 correcting a pre-existing miscount (it read 32 for 33 suite directories). Co-authored-by: Ben Meadors --- src/xmodem.cpp | 27 +++++++++++++++++ src/xmodem.h | 5 ++++ test/test_xmodem/test_main.cpp | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 test/test_xmodem/test_main.cpp diff --git a/src/xmodem.cpp b/src/xmodem.cpp index 447a05ff0..ce7ad8020 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -50,6 +50,7 @@ #include "xmodem.h" #include "SPILock.h" +#include #ifdef FSCom @@ -57,6 +58,24 @@ XModemAdapter xModem; XModemAdapter::XModemAdapter() {} +bool XModemAdapter::isValidFilename(const char *name) +{ + if (!name || name[0] == '\0') + return false; + // Reject any ".." path component. Absolute paths and subdirectories are fine; they stay within + // the filesystem root, so only traversal out of it needs blocking. + for (const char *seg = name; *seg;) { + const char *slash = strchr(seg, '/'); + const size_t len = slash ? (size_t)(slash - seg) : strlen(seg); + if (len == 2 && seg[0] == '.' && seg[1] == '.') + return false; + if (!slash) + break; + seg = slash + 1; + } + return true; +} + /** * Calculates the CRC-16 CCITT checksum of the given buffer. * @@ -122,6 +141,14 @@ void XModemAdapter::handlePacket(meshtastic_XModem xmodemPacket) strncpy(filename, (const char *)xmodemPacket.buffer.bytes, sizeof(filename) - 1); filename[sizeof(filename) - 1] = '\0'; + // The filename is attacker-controlled; refuse a ".." that could write/read/delete + // outside the filesystem root (real host paths on the posix daemon). + if (!isValidFilename(filename)) { + LOG_WARN("XModem: rejecting unsafe filename"); + sendControl(meshtastic_XModem_Control_NAK); + break; + } + if (xmodemPacket.control == meshtastic_XModem_Control_SOH) { // Receive this file and put to Flash // FILE_O_WRITE on Adafruit_LittleFS is append, not truncate - remove first. spiLock->lock(); diff --git a/src/xmodem.h b/src/xmodem.h index 974187820..6119a7b50 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -55,6 +55,11 @@ class XModemAdapter // True while a file transfer is in flight; lets callers avoid racing our `file` handle. bool isBusy() const { return isReceiving || isTransmitting; } + // Reject a transfer filename that could escape the filesystem root via a ".." path component. + // Absolute/subdirectory paths are allowed - PortduinoFS confines them to its mountpoint - so + // this is only about traversal, which matters on the posix daemon where FSCom is the host FS. + static bool isValidFilename(const char *name); + private: bool isReceiving = false; bool isTransmitting = false; diff --git a/test/test_xmodem/test_main.cpp b/test/test_xmodem/test_main.cpp new file mode 100644 index 000000000..816c78e9c --- /dev/null +++ b/test/test_xmodem/test_main.cpp @@ -0,0 +1,55 @@ +// Tests for XModemAdapter::isValidFilename - the path-traversal guard on the XModem file-transfer +// handler (src/xmodem.cpp). The filename in a SOH/STX control frame is attacker-controlled and +// drives FSCom open/remove; on the Portduino daemon FSCom is the host filesystem, so a ".." +// component could escape the mountpoint. Absolute/subdirectory paths must still be accepted. +#include "TestUtil.h" +#include "xmodem.h" +#include + +void setUp(void) {} +void tearDown(void) {} + +#ifdef FSCom + +void test_xmodem_rejects_dotdot_traversal(void) +{ + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("../secret")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/prefs/../../etc/passwd")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("a/../b")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir/..")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/..")); +} + +void test_xmodem_rejects_empty(void) +{ + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename(nullptr)); +} + +void test_xmodem_allows_legit_paths(void) +{ + // The file manager transfers absolute, subdirectoried paths from the manifest. + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("/prefs/config.proto")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("firmware.bin")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/sub/file.txt")); + // ".." only inside a name (not a whole component) is a valid filename, not traversal. + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("my..file")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("...")); +} + +#endif // FSCom + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); +#ifdef FSCom + RUN_TEST(test_xmodem_rejects_dotdot_traversal); + RUN_TEST(test_xmodem_rejects_empty); + RUN_TEST(test_xmodem_allows_legit_paths); +#endif + exit(UNITY_END()); +} + +void loop() {} From f6f3284cfc1078dd65809311bcdd0bdf9ea47c9f Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 17 Jul 2026 07:05:11 -0500 Subject: [PATCH 51/69] fix(lockdown): re-lock serial console admin auth on USB link drop (#11028) On lockdown builds (MESHTASTIC_PHONEAPI_ACCESS_CONTROL, nRF52) the SerialConsole is a process-lifetime singleton, so the per-connection admin-auth slot keyed by its inherited PhoneAPI* is reused for every USB/serial client for the whole boot. An operator's admin unlock stayed latched across serial client swaps: an attacker plugging into the USB/serial port before the prior session's 15-minute inactivity timeout inherited admin authorization -- the serial analog of the BLE stale-session reuse bug closed by resetting state in onConnect()/onDisconnect(). Sample the USB-CDC host link (DTR/mount) state each runOnce(); on the link-drop edge call close(), which frees the auth slot and resets PhoneAPI state so whoever connects next re-locks via handleStartConfig()'s !isConnected() branch on their first want_config -- the same physical-link boundary BLE enforces in onConnect(). On the nRF52 TinyUSB (Adafruit) core, (bool)Port == tud_cdc_n_connected(), which goes false on cable unplug or host port-close. Console transports without a real DTR line fall back to the existing inactivity timeout, no worse than before. Entirely nRF52-lockdown-gated; non-lockdown builds are byte-identical. --- src/SerialConsole.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index d6810e123..b32242212 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -30,6 +30,16 @@ SerialConsole *console; +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL +// Last-seen USB-CDC host link (DTR/mount) state, sampled each runOnce() so a +// physical unplug/replug re-locks the per-connection admin auth (see runOnce()). +// Kept at file scope rather than as a member both because there is exactly one +// console singleton and because adding per-instance members to the PhoneAPI +// hierarchy has historically perturbed nRF52 USB-CDC enumeration (see PhoneAPI.h). +// Only compiled on lockdown (nRF52) builds. +static bool s_serialLinkUp = false; +#endif + /// Create the shared serial console once and register receive wakeups. void consoleInit() { @@ -88,6 +98,30 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con /// Service one serial API iteration and select the next polling interval. int32_t SerialConsole::runOnce() { +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL + // Lockdown (nRF52) builds only. The SerialConsole is a process-lifetime + // singleton, so its inherited PhoneAPI object - and therefore its entry in + // the per-connection admin-auth slot table (keyed by PhoneAPI*) - is reused + // for every USB/serial client for the whole boot. Nothing re-locks that slot + // when the operator unplugs and a different client plugs in before the + // 15-minute inactivity timeout fires, so a fresh client would inherit the + // prior operator's admin authorization. Re-lock when the physical USB-CDC link + // drops - the serial analog of the BLE onDisconnect() -> close() session reset. + // + // On the nRF52 TinyUSB (Adafruit) core, (bool)Port == tud_cdc_n_connected(): + // it goes false on cable unplug or host port-close (DTR de-assert). close() + // frees the auth slot and resets PhoneAPI state, so whoever connects next + // re-locks via handleStartConfig()'s !isConnected() branch on their first + // want_config - the same physical-link boundary BLE enforces in onConnect(). + // Console transports without a real DTR line (e.g. a UART USER_DEBUG_PORT) hold + // this constant, so no edge fires and we fall back to the existing inactivity + // timeout - no worse than the pre-fix behavior. + const bool linkUp = static_cast(Port); + if (s_serialLinkUp && !linkUp) + close(); + s_serialLinkUp = linkUp; +#endif + #ifdef HELTEC_MESH_SOLAR // After enabling the mesh solar serial port module configuration, command processing is handled by the serial port module. if (moduleConfig.serial.enabled && moduleConfig.serial.override_console_serial_port && From 8147970957a60b278a577d394a3f0308b5d347bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 14:50:41 +0200 Subject: [PATCH 52/69] AdminModule: only accept admin responses to requests we sent (#11024) * AdminModule: only accept admin responses to requests we sent An admin *_response short-circuited the auth and session-passkey checks that gate every other admin message, so any node could deliver one. On a channel the module listens to unauthenticated, a get_module_config_response drives the remote-hardware pin handler with attacker-supplied values. Track the destination of outgoing admin requests (per remote, with the pinned PKC key when there is one) and accept a response only from a node with a matching outstanding request, inside the same window as the session passkey. Local (from == 0) admin is unchanged; PhoneAPI already gates it. Also fix the response dispatch: get_module_config_response.which_payload_variant is a ModuleConfig oneof tag, but it was compared against the AdminMessage ModuleConfigType enum (different numbering), so the handler never ran. Compare against the oneof tag. * AdminModule: rollover-safe request window, bind response to request type Two review refinements to the request/response pairing: Use Throttle::isWithinTimespanMs for the outstanding-request expiry instead of comparing millis()/1000 sums, which mis-expired across the millis() rollover. Bind each accepted response to a request type actually sent to that node. Each outstanding record now carries a bitmask of the response variants its requests authorize, so a get_owner request no longer admits a get_module_config response. The mask accumulates per remote, so a client may still pipeline several request types to one node and have every answer accepted. * AdminModule: track admin requests per-request, not per-node Reworks the outstanding-request table so each request is its own entry with its own expiry window and pinned key, replacing the per-node bitmask that shared one timestamp and one key across every response variant. That sharing let a later request to the same node extend an earlier one's window and, worse, clear its PKC pin: an unpinned request cleared keyValid, so a plaintext response to an earlier PKC-pinned request was then accepted. Per-request entries keep each pin intact. Identical requests are de-duplicated (a client may fetch several config subtypes, all answered by one response variant) and eviction compares elapsed time, which is rollover-safe. Test: a pinned request's response still requires its key after an unpinned request to the same node. * AdminModule: match module-config subtype and consume answered requests Two refinements to the request/response pairing: Only remote_hardware get_module_config_response mutates state (the pin table), so it must answer a request for that exact ModuleConfigType, not just any module-config request. Each entry records the requested subtype and the gate checks it. A matched request is now consumed on accept, so a node cannot replay a state-mutating response within the window. Because one request yields one response, request de-dup is dropped (a client's N indexed get_channel requests are N entries, each consumed once). Tests: a non-remote-hardware request does not admit a remote_hardware response, and a second copy of an answered response is rejected. --- src/mesh/MeshService.cpp | 9 + src/modules/AdminModule.cpp | 118 +++++++++- src/modules/AdminModule.h | 22 ++ test/support/AdminModuleTestShim.h | 1 + test/test_admin_session_repro/test_main.cpp | 238 +++++++++++++++++++- 5 files changed, 382 insertions(+), 6 deletions(-) diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index ca062abb9..ce356d7cd 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -17,6 +17,7 @@ #include "main.h" #include "mesh-pb-constants.h" #include "meshUtils.h" +#include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include "modules/PositionModule.h" #include "modules/RoutingModule.h" @@ -259,6 +260,14 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) const StoredMessage &sm = messageStore.addFromPacket(p); graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI }) +#if !MESHTASTIC_EXCLUDE_ADMIN + // Note admin requests on their way out: AdminModule only accepts a response from a remote we + // actually asked. Runs before encryption, while the payload is still readable. + if (adminModule && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) + adminModule->noteOutgoingAdminRequest(p); +#endif + // Send the packet into the mesh DEBUG_HEAP_BEFORE; auto a = packetPool.allocCopy(p); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index b98df39cd..36a7d6fe9 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -10,6 +10,7 @@ #include "input/InputBroker.h" #include "meshUtils.h" #include +#include #include // for better whitespace handling #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI #include "MeshtasticOTA.h" @@ -125,9 +126,16 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } #endif meshtastic_Channel *ch = &channels.getByIndex(mp.channel); - // Could tighten this up further by tracking the last public_key we went an AdminMessage request to - // and only allowing responses from that remote. if (messageIsResponse(r)) { + // Only accept a response from a remote we sent the matching request to. from == 0 is a + // local client, which PhoneAPI has already gated. + const pb_size_t moduleConfigTag = r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag + ? r->get_module_config_response.which_payload_variant + : 0; + if (mp.from != 0 && !responseIsSolicited(mp, r->which_payload_variant, moduleConfigTag)) { + LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from); + return handled; + } LOG_DEBUG("Allow admin response message"); } else if (mp.from == 0) { // Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the @@ -472,8 +480,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } case meshtastic_AdminMessage_get_module_config_response_tag: { LOG_INFO("Client received a get_module_config response"); - if (fromOthers && r->get_module_config_response.which_payload_variant == - meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) { + // which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the + // AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number). + if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) { handleGetModuleConfigResponse(mp, r); } break; @@ -1821,6 +1830,107 @@ bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r) return false; } +// The response variant that answers a getter request, or 0 if the request has none. +static pb_size_t adminResponseForRequest(pb_size_t requestVariant) +{ + switch (requestVariant) { + case meshtastic_AdminMessage_get_channel_request_tag: + return meshtastic_AdminMessage_get_channel_response_tag; + case meshtastic_AdminMessage_get_owner_request_tag: + return meshtastic_AdminMessage_get_owner_response_tag; + case meshtastic_AdminMessage_get_config_request_tag: + return meshtastic_AdminMessage_get_config_response_tag; + case meshtastic_AdminMessage_get_module_config_request_tag: + return meshtastic_AdminMessage_get_module_config_response_tag; + case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag: + return meshtastic_AdminMessage_get_canned_message_module_messages_response_tag; + case meshtastic_AdminMessage_get_device_metadata_request_tag: + return meshtastic_AdminMessage_get_device_metadata_response_tag; + case meshtastic_AdminMessage_get_ringtone_request_tag: + return meshtastic_AdminMessage_get_ringtone_response_tag; + case meshtastic_AdminMessage_get_device_connection_status_request_tag: + return meshtastic_AdminMessage_get_device_connection_status_response_tag; + case meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag: + return meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag; + case meshtastic_AdminMessage_get_ui_config_request_tag: + return meshtastic_AdminMessage_get_ui_config_response_tag; + default: + return 0; + } +} + +void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) +{ + if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP) + return; + // Local admin is answered in-process, and a broadcast is never a request to one remote. + if (p.to == 0 || isBroadcast(p.to) || p.to == nodeDB->getNodeNum()) + return; + + meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero; + if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) + return; + const pb_size_t responseVariant = adminResponseForRequest(admin.which_payload_variant); + if (!responseVariant) + return; // not a getter whose response we can pair + + const bool keyValid = p.pki_encrypted && p.public_key.size == 32; + + // One entry per request (a client sends N indexed get_channel requests, each answered once, so + // entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time. + OutstandingAdminRequest *slot = nullptr; + for (auto &o : outstandingAdminRequests) + if (o.to == 0) { + slot = &o; + break; + } + if (!slot) { + const uint32_t now = millis(); + slot = &outstandingAdminRequests[0]; + for (auto &o : outstandingAdminRequests) + if ((uint32_t)(now - o.sentAtMs) > (uint32_t)(now - slot->sentAtMs)) + slot = &o; + } + + slot->to = p.to; + slot->sentAtMs = millis(); + slot->expectedResponse = responseVariant; + slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag + ? (uint8_t)admin.get_module_config_request + : 0; + slot->keyValid = keyValid; + if (keyValid) + memcpy(slot->key, p.public_key.bytes, 32); + else + memset(slot->key, 0, 32); + LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to); +} + +bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag) +{ + // Scan every entry: several requests for the same variant may differ only in pinning, and an + // unpinned one still authorizes the response even if a pinned one does not. + for (auto &o : outstandingAdminRequests) { + if (o.to != mp.from || o.expectedResponse != responseVariant) + continue; + if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) { + o.to = 0; // lapsed; free the slot and keep looking for another live match + continue; + } + // A request pinned to a PKC key must be answered over PKC by that same key. + if (o.keyValid && (!mp.pki_encrypted || mp.public_key.size != 32 || memcmp(mp.public_key.bytes, o.key, 32) != 0)) + continue; + // remote_hardware is the only module config that mutates state (the pin table), so require + // it to answer a request for that exact subtype, not just any get_module_config_request. + if (moduleConfigTag == meshtastic_ModuleConfig_remote_hardware_tag && + o.moduleConfigType != meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) + continue; + o.to = 0; // consume: one request authorizes one response, no replay within the window + return true; + } + return false; +} + bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r) { if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag || diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 15923988b..bfc3035b5 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -77,7 +77,29 @@ class AdminModule : public ProtobufModule, public Obser public: void handleSetHamMode(const meshtastic_HamParameters &req); + /// Note an admin request leaving this node for a remote, so that remote's response is + /// accepted. Called from the client-to-mesh path (MeshService::handleToRadio). + void noteOutgoingAdminRequest(const meshtastic_MeshPacket &p); + private: + // An admin response has no session passkey and its sender need not hold an admin key, so a + // request we sent is the only thing vouching for it. Track each request independently (its own + // expiry and pinned key) so a later one can't extend or relax an earlier one. + static constexpr size_t kOutstandingAdminRequests = 8; + static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey + struct OutstandingAdminRequest { + NodeNum to; // 0 = free slot + uint32_t sentAtMs; // millis() when this request went out + pb_size_t expectedResponse; // the one response variant this request authorizes + uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked + uint8_t key[32]; // pinned destination key when the request went out over PKC + bool keyValid; + }; + OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {}; + + /// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0) + /// from mp.from answers a request we sent; consumes the matched request so it can't be replayed. + bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag); void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg); void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent); void reboot(int32_t seconds); diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index 6d9c3f1a8..c189bee84 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -12,6 +12,7 @@ class AdminModuleTestShim : public AdminModule using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::responseIsSolicited; // request/response pairing gate using AdminModule::setPassKey; // Peek at the reply a handler queued, before drainReply() releases it. diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index 6502ab3c4..2a4cdda8d 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -1,6 +1,7 @@ // Deterministic reproduction of the admin session-key behavior discussed on PR #10669 // (ndoo's "Admin message without session_key!" report), plus the remote-vs-local redaction of -// secret material in admin GET responses. +// secret material in admin GET responses, plus the request/response pairing that decides which +// admin responses are accepted at all. // // Drives the REAL incoming-admin path (AdminModule::handleReceivedProtobuf) with a remote // (from != 0) PKC-authorized set_owner, exercising the exact checkPassKey/setPassKey gate. @@ -21,7 +22,9 @@ #include static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; -static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us +static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to +static constexpr NodeNum STRANGER_NODE = 0x0D0D0D0D; static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20}; @@ -51,6 +54,59 @@ static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const u return mp; } +// A get_module_config_response carrying a remote_hardware pin list, as a remote would answer. +// This is the class of message that short-circuited auth: no session passkey, sender need not +// hold an admin key. handleGetModuleConfigResponse() stamps mp.from into the pin table. +static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_AdminMessage &out) +{ + out = meshtastic_AdminMessage_init_zero; + out.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag; + out.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; + out.get_module_config_response.payload_variant.remote_hardware.enabled = true; + out.get_module_config_response.payload_variant.remote_hardware.available_pins_count = 1; + out.get_module_config_response.payload_variant.remote_hardware.available_pins[0].gpio_pin = 17; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = from; + mp.to = LOCAL_NODE; + mp.channel = 0; + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + return mp; +} + +// One known pin entry, so a response that reaches handleGetModuleConfigResponse rewrites its owner. +static void seedRemoteHardwarePin(NodeNum owner) +{ + devicestate.node_remote_hardware_pins_count = 1; + devicestate.node_remote_hardware_pins[0] = meshtastic_NodeRemoteHardwarePin_init_zero; + devicestate.node_remote_hardware_pins[0].node_num = owner; + devicestate.node_remote_hardware_pins[0].has_pin = true; + devicestate.node_remote_hardware_pins[0].pin.gpio_pin = 4; +} + +// The outgoing request `req` a local client would send to `to`, as MeshService sees it (from == 0, +// ADMIN_APP, payload still plaintext). +static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_AdminMessage &req) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0; + p.to = to; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req); + return p; +} + +static meshtastic_MeshPacket makeOutgoingModuleConfigRequest( + NodeNum to, meshtastic_AdminMessage_ModuleConfigType type = meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) +{ + meshtastic_AdminMessage req = meshtastic_AdminMessage_init_zero; + req.which_payload_variant = meshtastic_AdminMessage_get_module_config_request_tag; + req.get_module_config_request = type; + return makeOutgoingRequest(to, req); +} + void setUp(void) { mockService = new MockMeshService(); @@ -194,6 +250,174 @@ void test_local_security_config_keeps_private_key(void) admin->drainReply(); } +// An admin response carries no session passkey and its sender is not an admin-key holder, so a +// request we sent is the only thing vouching for it. A get_module_config_response from a node we +// never queried is not. +static constexpr pb_size_t MODULE_CONFIG_RESPONSE = meshtastic_AdminMessage_get_module_config_response_tag; +static constexpr pb_size_t REMOTE_HW_TAG = meshtastic_ModuleConfig_remote_hardware_tag; // makeModuleConfigResponse subtype + +void test_unsolicited_response_is_not_solicited(void) +{ + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a response nobody asked for must not be accepted"); +} + +// Control: once the client has sent that node a request, its response is accepted. Without this, +// the test above would also pass if responseIsSolicited() simply always said no. +void test_response_after_our_request_is_solicited(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "the answer to our own request must be accepted"); +} + +// A request to one remote does not vouch for a different remote's response. +void test_request_to_one_node_does_not_admit_another(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // answered by someone else + + TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// A response only answers its own request type: a get_owner_request does not admit a +// get_module_config_response from the same node. +void test_response_variant_must_match_request(void) +{ + meshtastic_AdminMessage owner_req = meshtastic_AdminMessage_init_zero; + owner_req.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, owner_req)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // wrong type for the request + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a get_owner request must not admit a get_module_config response"); + // ...but the response it actually asked for is still accepted from the same slot. + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// Regression: each request keeps its own pinned key. A later unpinned request to the same node +// must not relax the PKC pin of an earlier one (the old shared-slot model cleared it). +void test_pinned_request_keeps_its_key_after_an_unpinned_request(void) +{ + uint8_t key[32]; + memset(key, 0xC1, 32); + + // A PKC-pinned get_config request to STRANGER (pins `key`). + meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero; + cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag; + meshtastic_MeshPacket pinned = makeOutgoingRequest(STRANGER_NODE, cfg); + pinned.pki_encrypted = true; + pinned.public_key.size = 32; + memcpy(pinned.public_key.bytes, key, 32); + admin->noteOutgoingAdminRequest(pinned); + + // A later, unpinned get_owner request to the same node. + meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero; + own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket resp = makeModuleConfigResponse(STRANGER_NODE, m); // from set; pki off by default + + // A plaintext get_config_response is still rejected: the config request was pinned. + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0), + "an unpinned request must not relax an earlier request's key pin"); + // Over PKC with the pinned key it is accepted. + resp.pki_encrypted = true; + resp.public_key.size = 32; + memcpy(resp.public_key.bytes, key, 32); + TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0)); + // The unpinned get_owner_response is accepted in plaintext (its request carried no pin). + resp.pki_encrypted = false; + resp.public_key.size = 0; + TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// A remote_hardware response must answer a request for that exact subtype, not just any module +// config - else an MQTT-config request could authorize a pin-table update. +void test_module_config_subtype_must_match(void) +{ + admin->noteOutgoingAdminRequest( + makeOutgoingModuleConfigRequest(STRANGER_NODE, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); // remote_hardware subtype + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a non-remote-hardware request must not admit a remote_hardware response"); + + // Control: a request for the matching subtype does admit it. + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// A matched request is consumed, so a node cannot replay a state-mutating response within the window. +void test_response_is_consumed_no_replay(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + TEST_ASSERT_TRUE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a second copy of an already-answered response must be rejected"); +} + +// Only requests arm the gate: sending a setter to a node must not make it a trusted responder. +void test_outgoing_setter_does_not_admit_responses(void) +{ + meshtastic_AdminMessage setter = meshtastic_AdminMessage_init_zero; + setter.which_payload_variant = meshtastic_AdminMessage_set_owner_tag; + admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, setter)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + + TEST_ASSERT_FALSE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG)); +} + +// End to end through the real handler: an unsolicited get_module_config_response must not reach +// handleGetModuleConfigResponse, so the remote_hardware pin table keeps its owner. +void test_unsolicited_response_does_not_poison_pins(void) +{ + seedRemoteHardwarePin(QUERIED_NODE); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(QUERIED_NODE, devicestate.node_remote_hardware_pins[0].node_num, + "unsolicited response must not rewrite the pin owner"); +} + +// Control: once we have requested it, the same response is handled and updates the pin table. This +// proves the assertion above is the auth gate firing, not the handler being dead. (It also exercises +// the dispatch tag fixed in this change - without it, the handler never runs for either node.) +void test_solicited_response_updates_pins(void) +{ + seedRemoteHardwarePin(QUERIED_NODE); + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + admin->handleReceivedProtobuf(mp, &m); + admin->drainReply(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(STRANGER_NODE, devicestate.node_remote_hardware_pins[0].node_num, + "a response we asked for must reach the handler"); +} + #endif // !(MESHTASTIC_EXCLUDE_PKI) void setup() @@ -207,6 +431,16 @@ void setup() RUN_TEST(test_session_gate_accepts_key_from_a_get_response); RUN_TEST(test_remote_security_config_omits_private_key); RUN_TEST(test_local_security_config_keeps_private_key); + RUN_TEST(test_unsolicited_response_is_not_solicited); + RUN_TEST(test_response_after_our_request_is_solicited); + RUN_TEST(test_request_to_one_node_does_not_admit_another); + RUN_TEST(test_response_variant_must_match_request); + RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request); + RUN_TEST(test_module_config_subtype_must_match); + RUN_TEST(test_response_is_consumed_no_replay); + RUN_TEST(test_outgoing_setter_does_not_admit_responses); + RUN_TEST(test_unsolicited_response_does_not_poison_pins); + RUN_TEST(test_solicited_response_updates_pins); #endif exit(UNITY_END()); } From cfecef53765415e0310a707604c7ad22c71219d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 15:15:19 +0200 Subject: [PATCH 53/69] Channels: fix off-by-one bound in decryptForHash (#11046) decryptForHash accepted chIndex == getNumChannels() before reading getHash(chIndex), which indexes one past hashes[MAX_NUM_CHANNELS]. Use >= so an out-of-range index is rejected before the array read. --- src/mesh/Channels.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index b086f5e6b..f1d97ebb4 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -516,7 +516,7 @@ bool Channels::hasDefaultChannel() */ bool Channels::decryptForHash(ChannelIndex chIndex, ChannelHash channelHash) { - if (chIndex > getNumChannels() || getHash(chIndex) != channelHash) { + if (chIndex >= getNumChannels() || getHash(chIndex) != channelHash) { // LOG_DEBUG("Skip channel %d (hash %x) due to invalid hash/index, want=%x", chIndex, getHash(chIndex), // channelHash); return false; From 6e0e9c174db0017af3178824b917c9788e61bf09 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 17 Jul 2026 09:45:56 -0500 Subject: [PATCH 54/69] Protobufs --- protobufs | 2 +- .../generated/meshtastic/interdevice.pb.cpp | 28 +- .../generated/meshtastic/interdevice.pb.h | 404 +++++++++++++++--- src/mesh/generated/meshtastic/telemetry.pb.h | 8 +- 4 files changed, 388 insertions(+), 54 deletions(-) diff --git a/protobufs b/protobufs index 9d589c132..f5b94bc36 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 9d589c1321478193885d6cecf852bf02d14fc92b +Subproject commit f5b94bc36786e3862eb16f4a68169709ee23c809 diff --git a/src/mesh/generated/meshtastic/interdevice.pb.cpp b/src/mesh/generated/meshtastic/interdevice.pb.cpp index e3913f78c..bc795b2a7 100644 --- a/src/mesh/generated/meshtastic/interdevice.pb.cpp +++ b/src/mesh/generated/meshtastic/interdevice.pb.cpp @@ -6,10 +6,34 @@ #error Regenerate this file with the current version of nanopb generator. #endif -PB_BIND(meshtastic_SensorData, meshtastic_SensorData, AUTO) +PB_BIND(meshtastic_FileTransfer, meshtastic_FileTransfer, 4) + + +PB_BIND(meshtastic_DirectoryListing, meshtastic_DirectoryListing, 4) + + +PB_BIND(meshtastic_I2CTransaction, meshtastic_I2CTransaction, 2) + + +PB_BIND(meshtastic_SdCardInfo, meshtastic_SdCardInfo, AUTO) + + +PB_BIND(meshtastic_I2CResult, meshtastic_I2CResult, 2) + + +PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 4) + + + + + + + + + + -PB_BIND(meshtastic_InterdeviceMessage, meshtastic_InterdeviceMessage, 2) diff --git a/src/mesh/generated/meshtastic/interdevice.pb.h b/src/mesh/generated/meshtastic/interdevice.pb.h index c381438eb..92a1670ee 100644 --- a/src/mesh/generated/meshtastic/interdevice.pb.h +++ b/src/mesh/generated/meshtastic/interdevice.pb.h @@ -10,38 +10,194 @@ #endif /* Enum definitions */ -typedef enum _meshtastic_MessageType { - meshtastic_MessageType_ACK = 0, - meshtastic_MessageType_COLLECT_INTERVAL = 160, /* in ms */ - meshtastic_MessageType_BEEP_ON = 161, /* duration ms */ - meshtastic_MessageType_BEEP_OFF = 162, /* cancel prematurely */ - meshtastic_MessageType_SHUTDOWN = 163, - meshtastic_MessageType_POWER_ON = 164, - meshtastic_MessageType_SCD41_TEMP = 176, - meshtastic_MessageType_SCD41_HUMIDITY = 177, - meshtastic_MessageType_SCD41_CO2 = 178, - meshtastic_MessageType_AHT20_TEMP = 179, - meshtastic_MessageType_AHT20_HUMIDITY = 180, - meshtastic_MessageType_TVOC_INDEX = 181 -} meshtastic_MessageType; +/* Version of the interdevice protocol spoken on the link. Both sides send + theirs in the ping/pong handshake; a peer reporting a different one runs + firmware that does not match and is not talked to. + + On a change that breaks the other side (renumbered fields, changed + semantics, removed messages), raise the value of CURRENT. Do not add + another entry: this enum carries a single constant, not a history. */ +typedef enum _meshtastic_InterdeviceVersion { + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED = 0, + /* Never use 1: ping/pong were bools before the handshake existed, and a + bool true is the same varint on the wire as the number 1, so firmware + predating the handshake would pass it. */ + meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT = 2 +} meshtastic_InterdeviceVersion; + +/* Defines the supported file operations */ +typedef enum _meshtastic_FileOperation { + meshtastic_FileOperation_GET = 0, + meshtastic_FileOperation_POST = 1, + meshtastic_FileOperation_PUT = 2, + meshtastic_FileOperation_DELETE = 3 +} meshtastic_FileOperation; + +/* Outcome of a file or directory operation. The requester must be able to + tell a transient condition from a definitive one: BUSY is worth another + try, NOT_FOUND is not. */ +typedef enum _meshtastic_FileStatus { + meshtastic_FileStatus_FILE_UNSPECIFIED = 0, + meshtastic_FileStatus_FILE_OK = 1, + /* Retry later: the co-processor is doing card maintenance (mount, + free space scan) and cannot serve the request right now */ + meshtastic_FileStatus_FILE_BUSY = 2, + meshtastic_FileStatus_FILE_NO_CARD = 3, + meshtastic_FileStatus_FILE_NOT_FOUND = 4, + /* PUT only: offset did not match the current end of the file. file_size + carries the size the file actually has, so the writer can resync (or + recognize its own chunk as already written after a lost response). */ + meshtastic_FileStatus_FILE_OFFSET_CONFLICT = 5, + meshtastic_FileStatus_FILE_IO_ERROR = 6, + meshtastic_FileStatus_FILE_NOT_A_FILE = 7 /* path is a directory (GET) or not one (listing) */ +} meshtastic_FileStatus; + +/* What to do with the SD card of the co-processor */ +typedef enum _meshtastic_SdCommand { + meshtastic_SdCommand_SD_COMMAND_UNSPECIFIED = 0, + meshtastic_SdCommand_SD_MOUNT = 1, /* mount a card that is in the slot, also after an eject */ + meshtastic_SdCommand_SD_EJECT = 2, /* flush and release the card so it can be pulled safely */ + meshtastic_SdCommand_SD_FORMAT = 3 /* wipe the card and put a fresh FAT on it, then mount it */ +} meshtastic_SdCommand; + +typedef enum _meshtastic_SdCardInfo_CardType { + meshtastic_SdCardInfo_CardType_NONE = 0, + meshtastic_SdCardInfo_CardType_MMC = 1, + meshtastic_SdCardInfo_CardType_SD = 2, + meshtastic_SdCardInfo_CardType_SDHC = 3, + meshtastic_SdCardInfo_CardType_SDXC = 4, + meshtastic_SdCardInfo_CardType_UNKNOWN_CARD = 5 +} meshtastic_SdCardInfo_CardType; + +typedef enum _meshtastic_SdCardInfo_FatType { + meshtastic_SdCardInfo_FatType_UNKNOWN_FAT = 0, + meshtastic_SdCardInfo_FatType_FAT16 = 1, + meshtastic_SdCardInfo_FatType_FAT32 = 2, + meshtastic_SdCardInfo_FatType_EXFAT = 3 +} meshtastic_SdCardInfo_FatType; + +typedef enum _meshtastic_I2CResult_Status { + /* Never sent: an all-defaults (e.g. accidentally empty) message must + not decode as a successful transaction */ + meshtastic_I2CResult_Status_UNSPECIFIED = 0, + meshtastic_I2CResult_Status_OK = 1, + meshtastic_I2CResult_Status_NACK_ADDRESS = 2, + meshtastic_I2CResult_Status_NACK_DATA = 3, + meshtastic_I2CResult_Status_ERROR = 4 +} meshtastic_I2CResult_Status; /* Struct definitions */ -typedef struct _meshtastic_SensorData { - /* The message type */ - meshtastic_MessageType type; - pb_size_t which_data; - union { - float float_value; - uint32_t uint32_value; - } data; -} meshtastic_SensorData; +typedef PB_BYTES_ARRAY_T(4096) meshtastic_FileTransfer_filedata_t; +/* Message for file operations */ +typedef struct _meshtastic_FileTransfer { + meshtastic_FileOperation operation; /* File operation (GET, POST, PUT, DELETE) */ + char filepath[256]; /* Path of the file on the SD card */ + meshtastic_FileTransfer_filedata_t filedata; /* Chunk content (POST/PUT request, GET response) */ + meshtastic_FileStatus status; /* Response: outcome of the operation */ + char message[255]; /* Response: human readable detail, may be empty */ + uint64_t offset; /* Byte offset of this chunk within the file (ranged GET/PUT) */ + /* GET request: number of bytes to read, 0 = max chunk size. A response + carries at most the filedata max_size (see interdevice.options) per + chunk; larger requests are truncated, visible in the filedata length. */ + uint32_t length; + uint64_t file_size; /* GET response: total size of the file */ +} meshtastic_FileTransfer; +/* Message for structured directory listing */ +typedef struct _meshtastic_DirectoryListing { + char directory[256]; /* Path of the directory */ + /* One page of entry names, full FAT LFN length. Subdirectories carry a + trailing slash. Note that a name whose directory prefix pushes the + combined path past the FileTransfer.filepath limit cannot round-trip. + Page size is the max_count in interdevice.options; page through with + offset and total_count. */ + pb_size_t filenames_count; + char filenames[16][256]; + meshtastic_FileStatus status; /* Response: outcome of the operation */ + char message[255]; /* Response: human readable detail, may be empty */ + uint32_t offset; /* Request: skip this many entries (paging) */ + uint32_t total_count; /* Response: total number of entries in the directory */ +} meshtastic_DirectoryListing; + +typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CTransaction_write_data_t; +/* A single I2C transaction: an optional write followed by an optional + read with repeated start, matching the TwoWire usage of sensor drivers + (beginTransmission/write.../endTransmission(false)/requestFrom) */ +typedef struct _meshtastic_I2CTransaction { + uint32_t address; /* 7-bit device address */ + meshtastic_I2CTransaction_write_data_t write_data; /* Bytes to write, may be empty */ + /* Number of bytes to read after the write, 0 = write-only. Bounded by + the read_data max_size of I2CResult (see interdevice.options); larger + requests are truncated, visible in the returned byte count. */ + uint32_t read_len; +} meshtastic_I2CTransaction; + +/* SD card statistics */ +typedef struct _meshtastic_SdCardInfo { + /* Card initialized and usable. False while `busy` is set does not mean + there is no card: the co-processor does not know yet. */ + bool present; + meshtastic_SdCardInfo_CardType card_type; + meshtastic_SdCardInfo_FatType fat_type; + uint64_t card_size; /* Filesystem size in bytes */ + uint64_t used_bytes; /* Used bytes (may be expensive to compute on FAT32) */ + uint64_t free_bytes; /* Free bytes */ + /* used_bytes/free_bytes are only meaningful when true: the scan behind + them runs in the background after mount and can take a while, and a + full card is otherwise indistinguishable from a scan in progress */ + bool stats_valid; + /* The co-processor is mounting a card right now, so whether one is + present is not decided yet. Ask again rather than concluding the slot + is empty. */ + bool busy; + /* A card answers in the slot but carries no filesystem that could be + mounted (present is false then). Formatting it makes it usable. */ + bool unformatted; +} meshtastic_SdCardInfo; + +typedef PB_BYTES_ARRAY_T(256) meshtastic_I2CResult_read_data_t; +/* Result of an I2CTransaction */ +typedef struct _meshtastic_I2CResult { + meshtastic_I2CResult_Status status; + meshtastic_I2CResult_read_data_t read_data; /* Data read from the device, empty for write-only transactions */ +} meshtastic_I2CResult; + +typedef PB_BYTES_ARRAY_T(128) meshtastic_InterdeviceMessage_i2c_scan_result_t; +/* Main message for interdevice communication */ typedef struct _meshtastic_InterdeviceMessage { pb_size_t which_data; union { char nmea[1024]; - meshtastic_SensorData sensor; + uint16_t beep; + meshtastic_I2CTransaction i2c_transaction; + meshtastic_I2CResult i2c_result; + bool i2c_scan; /* Request: scan the secondary I2C bus */ + meshtastic_InterdeviceMessage_i2c_scan_result_t i2c_scan_result; /* Response: 7-bit addresses of discovered devices */ + meshtastic_FileTransfer file_transfer; + meshtastic_DirectoryListing directory_listing; + bool get_sd_info; /* Request: SD card statistics */ + meshtastic_SdCardInfo sd_info; /* Response */ + /* Link liveness probe and version handshake. The receiver answers ping + with pong, echoing the id. Touches no peripherals, so it works with + nothing attached. Both carry the version the sender speaks; a peer + that answers with a different one speaks another protocol and must + not be used. */ + meshtastic_InterdeviceVersion ping; + meshtastic_InterdeviceVersion pong; + /* Response: the request could not be decoded or is of an unhandled + type, so the requester fails fast instead of burning its timeout. + Echoes the id when known, 0 when the frame was undecodable. Never + sent in reaction to a nack. */ + bool nack; + /* Request: mount the card, or release it so it can be pulled safely. The + co-processor answers with sd_info. Without an eject the card is mounted + on its own and kept mounted; after one it stays released until a mount + is asked for. */ + meshtastic_SdCommand sd_command; } data; + /* Correlates a response with its request: responses echo the id of the + request they answer. 0 for unsolicited messages (e.g. the nmea stream). */ + uint32_t id; } meshtastic_InterdeviceMessage; @@ -50,53 +206,205 @@ extern "C" { #endif /* Helper constants for enums */ -#define _meshtastic_MessageType_MIN meshtastic_MessageType_ACK -#define _meshtastic_MessageType_MAX meshtastic_MessageType_TVOC_INDEX -#define _meshtastic_MessageType_ARRAYSIZE ((meshtastic_MessageType)(meshtastic_MessageType_TVOC_INDEX+1)) +#define _meshtastic_InterdeviceVersion_MIN meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_UNSPECIFIED +#define _meshtastic_InterdeviceVersion_MAX meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT +#define _meshtastic_InterdeviceVersion_ARRAYSIZE ((meshtastic_InterdeviceVersion)(meshtastic_InterdeviceVersion_INTERDEVICE_VERSION_CURRENT+1)) -#define meshtastic_SensorData_type_ENUMTYPE meshtastic_MessageType +#define _meshtastic_FileOperation_MIN meshtastic_FileOperation_GET +#define _meshtastic_FileOperation_MAX meshtastic_FileOperation_DELETE +#define _meshtastic_FileOperation_ARRAYSIZE ((meshtastic_FileOperation)(meshtastic_FileOperation_DELETE+1)) +#define _meshtastic_FileStatus_MIN meshtastic_FileStatus_FILE_UNSPECIFIED +#define _meshtastic_FileStatus_MAX meshtastic_FileStatus_FILE_NOT_A_FILE +#define _meshtastic_FileStatus_ARRAYSIZE ((meshtastic_FileStatus)(meshtastic_FileStatus_FILE_NOT_A_FILE+1)) + +#define _meshtastic_SdCommand_MIN meshtastic_SdCommand_SD_COMMAND_UNSPECIFIED +#define _meshtastic_SdCommand_MAX meshtastic_SdCommand_SD_FORMAT +#define _meshtastic_SdCommand_ARRAYSIZE ((meshtastic_SdCommand)(meshtastic_SdCommand_SD_FORMAT+1)) + +#define _meshtastic_SdCardInfo_CardType_MIN meshtastic_SdCardInfo_CardType_NONE +#define _meshtastic_SdCardInfo_CardType_MAX meshtastic_SdCardInfo_CardType_UNKNOWN_CARD +#define _meshtastic_SdCardInfo_CardType_ARRAYSIZE ((meshtastic_SdCardInfo_CardType)(meshtastic_SdCardInfo_CardType_UNKNOWN_CARD+1)) + +#define _meshtastic_SdCardInfo_FatType_MIN meshtastic_SdCardInfo_FatType_UNKNOWN_FAT +#define _meshtastic_SdCardInfo_FatType_MAX meshtastic_SdCardInfo_FatType_EXFAT +#define _meshtastic_SdCardInfo_FatType_ARRAYSIZE ((meshtastic_SdCardInfo_FatType)(meshtastic_SdCardInfo_FatType_EXFAT+1)) + +#define _meshtastic_I2CResult_Status_MIN meshtastic_I2CResult_Status_UNSPECIFIED +#define _meshtastic_I2CResult_Status_MAX meshtastic_I2CResult_Status_ERROR +#define _meshtastic_I2CResult_Status_ARRAYSIZE ((meshtastic_I2CResult_Status)(meshtastic_I2CResult_Status_ERROR+1)) + +#define meshtastic_FileTransfer_operation_ENUMTYPE meshtastic_FileOperation +#define meshtastic_FileTransfer_status_ENUMTYPE meshtastic_FileStatus + +#define meshtastic_DirectoryListing_status_ENUMTYPE meshtastic_FileStatus + + +#define meshtastic_SdCardInfo_card_type_ENUMTYPE meshtastic_SdCardInfo_CardType +#define meshtastic_SdCardInfo_fat_type_ENUMTYPE meshtastic_SdCardInfo_FatType + +#define meshtastic_I2CResult_status_ENUMTYPE meshtastic_I2CResult_Status + +#define meshtastic_InterdeviceMessage_data_ping_ENUMTYPE meshtastic_InterdeviceVersion +#define meshtastic_InterdeviceMessage_data_pong_ENUMTYPE meshtastic_InterdeviceVersion +#define meshtastic_InterdeviceMessage_data_sd_command_ENUMTYPE meshtastic_SdCommand /* Initializer values for message structs */ -#define meshtastic_SensorData_init_default {_meshtastic_MessageType_MIN, 0, {0}} -#define meshtastic_InterdeviceMessage_init_default {0, {""}} -#define meshtastic_SensorData_init_zero {_meshtastic_MessageType_MIN, 0, {0}} -#define meshtastic_InterdeviceMessage_init_zero {0, {""}} +#define meshtastic_FileTransfer_init_default {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_default {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} +#define meshtastic_I2CTransaction_init_default {0, {0, {0}}, 0} +#define meshtastic_SdCardInfo_init_default {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0, 0} +#define meshtastic_I2CResult_init_default {_meshtastic_I2CResult_Status_MIN, {0, {0}}} +#define meshtastic_InterdeviceMessage_init_default {0, {""}, 0} +#define meshtastic_FileTransfer_init_zero {_meshtastic_FileOperation_MIN, "", {0, {0}}, _meshtastic_FileStatus_MIN, "", 0, 0, 0} +#define meshtastic_DirectoryListing_init_zero {"", 0, {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, _meshtastic_FileStatus_MIN, "", 0, 0} +#define meshtastic_I2CTransaction_init_zero {0, {0, {0}}, 0} +#define meshtastic_SdCardInfo_init_zero {0, _meshtastic_SdCardInfo_CardType_MIN, _meshtastic_SdCardInfo_FatType_MIN, 0, 0, 0, 0, 0, 0} +#define meshtastic_I2CResult_init_zero {_meshtastic_I2CResult_Status_MIN, {0, {0}}} +#define meshtastic_InterdeviceMessage_init_zero {0, {""}, 0} /* Field tags (for use in manual encoding/decoding) */ -#define meshtastic_SensorData_type_tag 1 -#define meshtastic_SensorData_float_value_tag 2 -#define meshtastic_SensorData_uint32_value_tag 3 +#define meshtastic_FileTransfer_operation_tag 1 +#define meshtastic_FileTransfer_filepath_tag 2 +#define meshtastic_FileTransfer_filedata_tag 3 +#define meshtastic_FileTransfer_status_tag 4 +#define meshtastic_FileTransfer_message_tag 5 +#define meshtastic_FileTransfer_offset_tag 6 +#define meshtastic_FileTransfer_length_tag 7 +#define meshtastic_FileTransfer_file_size_tag 8 +#define meshtastic_DirectoryListing_directory_tag 1 +#define meshtastic_DirectoryListing_filenames_tag 2 +#define meshtastic_DirectoryListing_status_tag 3 +#define meshtastic_DirectoryListing_message_tag 4 +#define meshtastic_DirectoryListing_offset_tag 5 +#define meshtastic_DirectoryListing_total_count_tag 6 +#define meshtastic_I2CTransaction_address_tag 1 +#define meshtastic_I2CTransaction_write_data_tag 2 +#define meshtastic_I2CTransaction_read_len_tag 3 +#define meshtastic_SdCardInfo_present_tag 1 +#define meshtastic_SdCardInfo_card_type_tag 2 +#define meshtastic_SdCardInfo_fat_type_tag 3 +#define meshtastic_SdCardInfo_card_size_tag 4 +#define meshtastic_SdCardInfo_used_bytes_tag 5 +#define meshtastic_SdCardInfo_free_bytes_tag 6 +#define meshtastic_SdCardInfo_stats_valid_tag 7 +#define meshtastic_SdCardInfo_busy_tag 8 +#define meshtastic_SdCardInfo_unformatted_tag 9 +#define meshtastic_I2CResult_status_tag 1 +#define meshtastic_I2CResult_read_data_tag 2 #define meshtastic_InterdeviceMessage_nmea_tag 1 -#define meshtastic_InterdeviceMessage_sensor_tag 2 +#define meshtastic_InterdeviceMessage_beep_tag 2 +#define meshtastic_InterdeviceMessage_i2c_transaction_tag 3 +#define meshtastic_InterdeviceMessage_i2c_result_tag 4 +#define meshtastic_InterdeviceMessage_i2c_scan_tag 5 +#define meshtastic_InterdeviceMessage_i2c_scan_result_tag 6 +#define meshtastic_InterdeviceMessage_file_transfer_tag 7 +#define meshtastic_InterdeviceMessage_directory_listing_tag 8 +#define meshtastic_InterdeviceMessage_get_sd_info_tag 9 +#define meshtastic_InterdeviceMessage_sd_info_tag 10 +#define meshtastic_InterdeviceMessage_ping_tag 11 +#define meshtastic_InterdeviceMessage_pong_tag 12 +#define meshtastic_InterdeviceMessage_nack_tag 13 +#define meshtastic_InterdeviceMessage_sd_command_tag 14 +#define meshtastic_InterdeviceMessage_id_tag 15 /* Struct field encoding specification for nanopb */ -#define meshtastic_SensorData_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, UENUM, type, 1) \ -X(a, STATIC, ONEOF, FLOAT, (data,float_value,data.float_value), 2) \ -X(a, STATIC, ONEOF, UINT32, (data,uint32_value,data.uint32_value), 3) -#define meshtastic_SensorData_CALLBACK NULL -#define meshtastic_SensorData_DEFAULT NULL +#define meshtastic_FileTransfer_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, operation, 1) \ +X(a, STATIC, SINGULAR, STRING, filepath, 2) \ +X(a, STATIC, SINGULAR, BYTES, filedata, 3) \ +X(a, STATIC, SINGULAR, UENUM, status, 4) \ +X(a, STATIC, SINGULAR, STRING, message, 5) \ +X(a, STATIC, SINGULAR, UINT64, offset, 6) \ +X(a, STATIC, SINGULAR, UINT32, length, 7) \ +X(a, STATIC, SINGULAR, UINT64, file_size, 8) +#define meshtastic_FileTransfer_CALLBACK NULL +#define meshtastic_FileTransfer_DEFAULT NULL + +#define meshtastic_DirectoryListing_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, directory, 1) \ +X(a, STATIC, REPEATED, STRING, filenames, 2) \ +X(a, STATIC, SINGULAR, UENUM, status, 3) \ +X(a, STATIC, SINGULAR, STRING, message, 4) \ +X(a, STATIC, SINGULAR, UINT32, offset, 5) \ +X(a, STATIC, SINGULAR, UINT32, total_count, 6) +#define meshtastic_DirectoryListing_CALLBACK NULL +#define meshtastic_DirectoryListing_DEFAULT NULL + +#define meshtastic_I2CTransaction_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, address, 1) \ +X(a, STATIC, SINGULAR, BYTES, write_data, 2) \ +X(a, STATIC, SINGULAR, UINT32, read_len, 3) +#define meshtastic_I2CTransaction_CALLBACK NULL +#define meshtastic_I2CTransaction_DEFAULT NULL + +#define meshtastic_SdCardInfo_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BOOL, present, 1) \ +X(a, STATIC, SINGULAR, UENUM, card_type, 2) \ +X(a, STATIC, SINGULAR, UENUM, fat_type, 3) \ +X(a, STATIC, SINGULAR, UINT64, card_size, 4) \ +X(a, STATIC, SINGULAR, UINT64, used_bytes, 5) \ +X(a, STATIC, SINGULAR, UINT64, free_bytes, 6) \ +X(a, STATIC, SINGULAR, BOOL, stats_valid, 7) \ +X(a, STATIC, SINGULAR, BOOL, busy, 8) \ +X(a, STATIC, SINGULAR, BOOL, unformatted, 9) +#define meshtastic_SdCardInfo_CALLBACK NULL +#define meshtastic_SdCardInfo_DEFAULT NULL + +#define meshtastic_I2CResult_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UENUM, status, 1) \ +X(a, STATIC, SINGULAR, BYTES, read_data, 2) +#define meshtastic_I2CResult_CALLBACK NULL +#define meshtastic_I2CResult_DEFAULT NULL #define meshtastic_InterdeviceMessage_FIELDLIST(X, a) \ X(a, STATIC, ONEOF, STRING, (data,nmea,data.nmea), 1) \ -X(a, STATIC, ONEOF, MESSAGE, (data,sensor,data.sensor), 2) +X(a, STATIC, ONEOF, UINT32, (data,beep,data.beep), 2) \ +X(a, STATIC, ONEOF, MESSAGE, (data,i2c_transaction,data.i2c_transaction), 3) \ +X(a, STATIC, ONEOF, MESSAGE, (data,i2c_result,data.i2c_result), 4) \ +X(a, STATIC, ONEOF, BOOL, (data,i2c_scan,data.i2c_scan), 5) \ +X(a, STATIC, ONEOF, BYTES, (data,i2c_scan_result,data.i2c_scan_result), 6) \ +X(a, STATIC, ONEOF, MESSAGE, (data,file_transfer,data.file_transfer), 7) \ +X(a, STATIC, ONEOF, MESSAGE, (data,directory_listing,data.directory_listing), 8) \ +X(a, STATIC, ONEOF, BOOL, (data,get_sd_info,data.get_sd_info), 9) \ +X(a, STATIC, ONEOF, MESSAGE, (data,sd_info,data.sd_info), 10) \ +X(a, STATIC, ONEOF, UENUM, (data,ping,data.ping), 11) \ +X(a, STATIC, ONEOF, UENUM, (data,pong,data.pong), 12) \ +X(a, STATIC, ONEOF, BOOL, (data,nack,data.nack), 13) \ +X(a, STATIC, ONEOF, UENUM, (data,sd_command,data.sd_command), 14) \ +X(a, STATIC, SINGULAR, UINT32, id, 15) #define meshtastic_InterdeviceMessage_CALLBACK NULL #define meshtastic_InterdeviceMessage_DEFAULT NULL -#define meshtastic_InterdeviceMessage_data_sensor_MSGTYPE meshtastic_SensorData +#define meshtastic_InterdeviceMessage_data_i2c_transaction_MSGTYPE meshtastic_I2CTransaction +#define meshtastic_InterdeviceMessage_data_i2c_result_MSGTYPE meshtastic_I2CResult +#define meshtastic_InterdeviceMessage_data_file_transfer_MSGTYPE meshtastic_FileTransfer +#define meshtastic_InterdeviceMessage_data_directory_listing_MSGTYPE meshtastic_DirectoryListing +#define meshtastic_InterdeviceMessage_data_sd_info_MSGTYPE meshtastic_SdCardInfo -extern const pb_msgdesc_t meshtastic_SensorData_msg; +extern const pb_msgdesc_t meshtastic_FileTransfer_msg; +extern const pb_msgdesc_t meshtastic_DirectoryListing_msg; +extern const pb_msgdesc_t meshtastic_I2CTransaction_msg; +extern const pb_msgdesc_t meshtastic_SdCardInfo_msg; +extern const pb_msgdesc_t meshtastic_I2CResult_msg; extern const pb_msgdesc_t meshtastic_InterdeviceMessage_msg; /* Defines for backwards compatibility with code written before nanopb-0.4.0 */ -#define meshtastic_SensorData_fields &meshtastic_SensorData_msg +#define meshtastic_FileTransfer_fields &meshtastic_FileTransfer_msg +#define meshtastic_DirectoryListing_fields &meshtastic_DirectoryListing_msg +#define meshtastic_I2CTransaction_fields &meshtastic_I2CTransaction_msg +#define meshtastic_SdCardInfo_fields &meshtastic_SdCardInfo_msg +#define meshtastic_I2CResult_fields &meshtastic_I2CResult_msg #define meshtastic_InterdeviceMessage_fields &meshtastic_InterdeviceMessage_msg /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_INTERDEVICE_PB_H_MAX_SIZE meshtastic_InterdeviceMessage_size -#define meshtastic_InterdeviceMessage_size 1026 -#define meshtastic_SensorData_size 9 +#define meshtastic_DirectoryListing_size 4657 +#define meshtastic_FileTransfer_size 4646 +#define meshtastic_I2CResult_size 261 +#define meshtastic_I2CTransaction_size 271 +#define meshtastic_InterdeviceMessage_size 4666 +#define meshtastic_SdCardInfo_size 45 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/telemetry.pb.h b/src/mesh/generated/meshtastic/telemetry.pb.h index 965a93157..36f930561 100644 --- a/src/mesh/generated/meshtastic/telemetry.pb.h +++ b/src/mesh/generated/meshtastic/telemetry.pb.h @@ -121,7 +121,9 @@ typedef enum _meshtastic_TelemetrySensorType { /* ICM-42607-P 6‑Axis IMU */ meshtastic_TelemetrySensorType_ICM42607P = 53, /* SPA06 pressure and temperature */ - meshtastic_TelemetrySensorType_SPA06 = 54 + meshtastic_TelemetrySensorType_SPA06 = 54, + /* HM330X PM SENSOR */ + meshtastic_TelemetrySensorType_HM330X = 55 } meshtastic_TelemetrySensorType; /* Struct definitions */ @@ -502,8 +504,8 @@ extern "C" { /* Helper constants for enums */ #define _meshtastic_TelemetrySensorType_MIN meshtastic_TelemetrySensorType_SENSOR_UNSET -#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_SPA06 -#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_SPA06+1)) +#define _meshtastic_TelemetrySensorType_MAX meshtastic_TelemetrySensorType_HM330X +#define _meshtastic_TelemetrySensorType_ARRAYSIZE ((meshtastic_TelemetrySensorType)(meshtastic_TelemetrySensorType_HM330X+1)) From 985b983f67dcf8fc8e1880afbe48b4f3b8dfb497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 17:32:01 +0200 Subject: [PATCH 55/69] TrafficManagement: gate role/NodeInfo cache writes on signer authenticity (#11048) * TrafficManagement: gate role/NodeInfo cache writes on signer authenticity The tier-3 role cache and the PSRAM NodeInfo response cache were updated from any received NodeInfo with no authenticity check, so a spoofed NodeInfo could set a node's cached role (granting dedup exceptions) or poison the cached user served in direct responses. Skip both cache writes when a known signer's NodeInfo arrives unsigned, matching the identity-update gate on the direct-response path. * TrafficManagement: hoist shared NodeInfo signer lookup Compute the sender node lookup and unauthenticated-signer check once per NodeInfo packet and reuse it for both the cache-refresh gate and the direct-response identity gate, avoiding a second O(N) getMeshNode scan. --------- Co-authored-by: Ben Meadors --- src/modules/TrafficManagementModule.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index f10a9f0d4..74e8de6c2 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -726,9 +726,16 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack return ProcessMessage::CONTINUE; } + // A known signer's NodeInfo arriving unsigned is unauthenticated (the sender is forgeable), so + // it must not drive any cache or identity write below. Computed once here (getMeshNode is O(N)) + // and reused by both the cache-refresh and the direct-response identity path. + const bool isNodeInfo = mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP; + const meshtastic_NodeInfoLite *senderNode = isNodeInfo ? nodeDB->getMeshNode(getFrom(&mp)) : nullptr; + const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed; + // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 // role cache for any node we already track (keeps the dedup role exception current). - if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) { + if (isNodeInfo && !unauthenticatedSigner) { cacheNodeInfoPacket(mp); updateCachedRoleFromNodeInfo(mp); } @@ -746,8 +753,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack if (shouldRespondToNodeInfo(&mp, true)) { // Unicast NodeInfo is never signed, so a known signer's identity claim here is // unauthenticated: don't overwrite its stored name. The cached response is unaffected. - const meshtastic_NodeInfoLite *senderNode = nodeDB->getMeshNode(getFrom(&mp)); - const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed; + // unauthenticatedSigner was computed above (this branch is NodeInfo-only). meshtastic_User requester = meshtastic_User_init_zero; if (!unauthenticatedSigner && pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { From 73180141b3f3e590824ac4859d5934dde1a82d66 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Fri, 17 Jul 2026 22:08:06 +0200 Subject: [PATCH 56/69] Add userprefs for telemetry screen settings (#11051) * Add telemetry update interval to userprefs * Add telemetry screen configs to userprefs * Removed duped code from cherry-pick --- src/mesh/NodeDB.cpp | 12 ++++++++++-- userPrefs.jsonc | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 1e88ea873..4c2879adc 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1526,14 +1526,18 @@ void NodeDB::initModuleConfigIntervals() moduleConfig.telemetry.device_update_interval = MAX_INTERVAL; #endif +#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED + moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; +#endif + #ifdef USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL moduleConfig.telemetry.environment_update_interval = USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL; #else moduleConfig.telemetry.environment_update_interval = 0; #endif -#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED - moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; +#ifdef USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED + moduleConfig.telemetry.environment_screen_enabled = USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED; #endif #ifdef USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL @@ -1546,6 +1550,10 @@ void NodeDB::initModuleConfigIntervals() moduleConfig.telemetry.air_quality_enabled = USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED; #endif +#ifdef USERPREFS_CONFIG_AQ_SCREEN_ENABLED + moduleConfig.telemetry.air_quality_screen_enabled = USERPREFS_CONFIG_AQ_SCREEN_ENABLED; +#endif + moduleConfig.telemetry.power_update_interval = 0; moduleConfig.telemetry.health_update_interval = 0; moduleConfig.neighbor_info.update_interval = 0; diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 3e3fbd5c9..66ee623c6 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -37,8 +37,10 @@ // "USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL": "900", // Device telemetry update interval in seconds // "USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL": "900", // Environment telemetry update interval in seconds // "USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED": "1", // Force BMP280/sensor reads + LoRa broadcast on first boot + // "USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED": "1", // Environment telemetry enable on screen // "USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL": "900", // AQ telemetry update interval in seconds // "USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED": "1", // AQ telemetry enable + // "USERPREFS_CONFIG_AQ_SCREEN_ENABLED": "1", // AQ telemetry enable on screen // "USERPREFS_LORACONFIG_CHANNEL_NUM": "31", // "USERPREFS_LORACONFIG_TX_POWER": "0", // "USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST", From ac027ec5caf124bec5557deb0b3dea5506eaf929 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:18:23 -0500 Subject: [PATCH 57/69] chore(deps): update meshtastic-esp8266-oled-ssd1306 digest to 9d9ba7e (#11053) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 720a65534..e315dcad2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -69,7 +69,7 @@ monitor_speed = 115200 monitor_filters = direct lib_deps = # renovate: datasource=git-refs depName=meshtastic-esp8266-oled-ssd1306 packageName=https://github.com/meshtastic/esp8266-oled-ssd1306 gitBranch=master - https://github.com/meshtastic/esp8266-oled-ssd1306/archive/8999e86fe1878902b068e875e3f18b8e4a87f37b.zip + https://github.com/meshtastic/esp8266-oled-ssd1306/archive/9d9ba7e43ed1a5e1865fc9686513eab47fb079ff.zip # renovate: datasource=git-refs depName=meshtastic-OneButton packageName=https://github.com/meshtastic/OneButton gitBranch=master https://github.com/meshtastic/OneButton/archive/fa352d668c53f290cfa480a5f79ad422cd828c70.zip # renovate: datasource=git-refs depName=meshtastic-arduino-fsm packageName=https://github.com/meshtastic/arduino-fsm gitBranch=master From d4aa7760ccffd100ddc7dacd6875e6eee5df4f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Fri, 17 Jul 2026 22:49:00 +0200 Subject: [PATCH 58/69] Use hardware RNG for session passkey and PKC extra nonce (#11047) * Use hardware RNG for session passkey and PKC extra nonce The admin session passkey and the Curve25519 extra nonce were drawn from Arduino random(), which is not a CSPRNG. Source them from HardwareRNG::fill, mirroring the signing path, and fall back to the seeded CSPRNG (CryptRNG) only when no hardware source is available. * AdminModule: make session passkey expiry rollover-safe session_time was compared as millis()/1000 seconds with additive thresholds, which breaks across the millis() wrap and could keep a stale admin session key valid. Store session_time in millis() and use Throttle::isWithinTimespanMs for the 150s refresh and 300s validity windows. * AdminModule: track session passkey validity with an explicit flag session_time == 0 was used as the uninitialized sentinel, but millis() is legitimately 0 in the first millisecond of uptime, so a passkey issued then would be treated as no session. Use a dedicated session_passkey_valid flag instead. * AdminModule: camelCase the session passkey validity flag --------- Co-authored-by: Ben Meadors --- src/mesh/CryptoEngine.cpp | 6 +++++- src/modules/AdminModule.cpp | 21 +++++++++++++++------ src/modules/AdminModule.h | 3 ++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index e28df442b..cb8989976 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -224,7 +224,11 @@ bool CryptoEngine::encryptCurve25519(uint32_t toNode, uint32_t fromNode, meshtas uint64_t packetNum, size_t numBytes, const uint8_t *bytes, uint8_t *bytesOut) { uint8_t *auth; - long extraNonceTmp = random(); + // The extra nonce must be unpredictable: use the hardware RNG, falling back to the + // seeded CSPRNG only when no hardware source is available. + uint32_t extraNonceTmp; + if (!HardwareRNG::fill((uint8_t *)&extraNonceTmp, sizeof(extraNonceTmp))) + CryptRNG.rand((uint8_t *)&extraNonceTmp, sizeof(extraNonceTmp)); auth = bytesOut + numBytes; memcpy((uint8_t *)(auth + 8), &extraNonceTmp, sizeof(uint32_t)); // do not use dereference on potential non aligned pointers : *extraNonce = extraNonceTmp; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 36a7d6fe9..b9cf4b03b 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,6 +1,7 @@ #include "AdminModule.h" #include "Channels.h" #include "DisplayFormatters.h" +#include "HardwareRNG.h" #include "MeshService.h" #include "NodeDB.h" #include "PositionPrecision.h" @@ -49,6 +50,9 @@ #include "GPS.h" #endif +#include // CryptRNG, the seeded CSPRNG used as fallback for the session passkey +#include // rollover-safe elapsed-time checks for the session passkey + #if MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -1792,11 +1796,15 @@ AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_AP void AdminModule::setPassKey(meshtastic_AdminMessage *res) { - if (session_time == 0 || millis() / 1000 > session_time + 150) { - for (int i = 0; i < 8; i++) { - session_passkey[i] = random(); - } - session_time = millis() / 1000; + // Regenerate once there is no session yet or the current key is older than 150s. session_time + // holds millis(); the Throttle check is rollover-safe, unlike the previous seconds comparison. + if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) { + // Session passkey authenticates admin replies, so it must be unpredictable: prefer the + // hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists. + if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey))) + CryptRNG.rand(session_passkey, sizeof(session_passkey)); + session_time = millis(); + sessionPasskeyValid = true; } memcpy(res->session_passkey.bytes, session_passkey, 8); res->session_passkey.size = 8; @@ -1809,7 +1817,8 @@ bool AdminModule::checkPassKey(meshtastic_AdminMessage *res) { // check that the key in the packet is still valid printBytes("Incoming session key: ", res->session_passkey.bytes, 8); printBytes("Expected session key: ", session_passkey, 8); - return (session_time + 300 > millis() / 1000 && res->session_passkey.size == 8 && + // Key is valid for 300s from issue; sessionPasskeyValid guards an unissued session. + return (sessionPasskeyValid && Throttle::isWithinTimespanMs(session_time, 300 * 1000UL) && res->session_passkey.size == 8 && memcmp(res->session_passkey.bytes, session_passkey, 8) == 0); } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index bfc3035b5..5c1a6ef58 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -41,7 +41,8 @@ class AdminModule : public ProtobufModule, public Obser bool hasOpenEditTransaction = false; uint8_t session_passkey[8] = {0}; - uint session_time = 0; + uint32_t session_time = 0; // millis() when the current session passkey was issued + bool sessionPasskeyValid = false; // separate flag: millis() 0 at boot is a valid issue time void saveChanges(int saveWhat, bool shouldReboot = true); From 37fdbb49bf0f6eefd560e676be9bf2fb73c28550 Mon Sep 17 00:00:00 2001 From: "Ethac.chen" Date: Sat, 18 Jul 2026 19:13:20 +0800 Subject: [PATCH 59/69] Add RAK WisMesh Pod variant and fix Tag LPCOMP wake on user shutdown (#11049) * Add RAK WisMesh Pod variant and fix Tag LPCOMP wake on user shutdown * chore: trunk fmt - replace Unicode em-dash in Pod comment Co-authored-by: Cursor --------- Co-authored-by: Ben Meadors Co-authored-by: Cursor --- variants/nrf52840/rak_wismeshtag/platformio.ini | 8 ++++++++ variants/nrf52840/rak_wismeshtag/variant.cpp | 9 +++++++++ variants/nrf52840/rak_wismeshtag/variant.h | 5 ++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index acfbca99e..9b0d665b3 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -23,3 +23,11 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_LR2021=1 -DMESHTASTIC_EXCLUDE_WIFI=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak_wismeshtag> + +; RAK WisMesh Pod - same board as Tag (no user buttons on hardware; shares Tag pin map) +[env:rak_wismesh_pod] +extends = env:rak_wismeshtag +custom_meshtastic_display_name = RAK WisMesh Pod +build_flags = + ${env:rak_wismeshtag.build_flags} + -D WISMESH_POD \ No newline at end of file diff --git a/variants/nrf52840/rak_wismeshtag/variant.cpp b/variants/nrf52840/rak_wismeshtag/variant.cpp index a0394b2dd..ca0925f44 100644 --- a/variants/nrf52840/rak_wismeshtag/variant.cpp +++ b/variants/nrf52840/rak_wismeshtag/variant.cpp @@ -46,6 +46,14 @@ void initVariant() } #ifdef LOW_VDD_SYSTEMOFF_DELAY_MS +static bool lowVddSystemOff = false; // Set when brownout forces System OFF (enables LPCOMP wake). + +// Strong override; must be noinline (see main-nrf52.cpp weak default). +__attribute__((noinline)) bool variant_enableBatteryLpcompWake() +{ + return lowVddSystemOff; +} + void variant_nrf52LoopHook(void) { // If VDD stays unsafe for a while (brownout), force System OFF. @@ -72,6 +80,7 @@ void variant_nrf52LoopHook(void) low_vdd_timer_armed = true; } if ((uint32_t)(now - low_vdd_since_ms) >= (uint32_t)LOW_VDD_SYSTEMOFF_DELAY_MS) { + lowVddSystemOff = true; cpuDeepSleep(portMAX_DELAY); } } else { diff --git a/variants/nrf52840/rak_wismeshtag/variant.h b/variants/nrf52840/rak_wismeshtag/variant.h index 9ea215e42..c9188c485 100644 --- a/variants/nrf52840/rak_wismeshtag/variant.h +++ b/variants/nrf52840/rak_wismeshtag/variant.h @@ -55,10 +55,13 @@ extern "C" { /* * Buttons */ - +#ifndef WISMESH_POD #define PIN_BUTTON1 9 // Pin for button on E-ink button module or IO expansion #define BUTTON_NEED_PULLUP #define PIN_BUTTON2 12 +#else +#define HAS_BUTTON 0 +#endif /* * Analog pins From 40d0d80f35ab6f2deded3f3be998a5a10b2a2fee Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:13:49 -0500 Subject: [PATCH 60/69] chore(deps): update adafruit pct2075 to v1.3.1 (#11055) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index e315dcad2..225a8f938 100644 --- a/platformio.ini +++ b/platformio.ini @@ -191,7 +191,7 @@ lib_deps = # renovate: datasource=github-tags depName=Adafruit LTR390 Library packageName=adafruit/Adafruit_LTR390 https://github.com/adafruit/Adafruit_LTR390/archive/refs/tags/1.1.2.zip # renovate: datasource=github-tags depName=Adafruit PCT2075 packageName=adafruit/Adafruit_PCT2075 - https://github.com/adafruit/Adafruit_PCT2075/archive/refs/tags/1.0.6.zip + https://github.com/adafruit/Adafruit_PCT2075/archive/1.3.1.zip # renovate: datasource=github-tags depName=DFRobot_BMM150 packageName=dfrobot/DFRobot_BMM150 https://github.com/DFRobot/DFRobot_BMM150/archive/refs/tags/V1.0.0.zip # renovate: datasource=github-tags depName=SparkFun MMC5983MA Magnetometer packageName=sparkfun/SparkFun_MMC5983MA_Magnetometer_Arduino_Library From b20d89974a053d9d82ed7808f8736cd65f718a0c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 18 Jul 2026 06:26:20 -0500 Subject: [PATCH 61/69] Stop accelerometer thread when double-tap/wake-on-motion disabled at runtime (#11025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Stop accelerometer thread when double-tap/wake-on-motion disabled at runtime double_tap_as_button_press and wake_on_tap_or_motion are applied live: the OFF->ON edge calls accelerometerThread->start(), but there was no ON->OFF branch, so turning either flag off left the sensor thread running (polling I2C, drawing power) until reboot. Worse, because enabled stayed true, a later OFF->ON edge was a no-op (the enabled==false guard blocked re-start), leaving the feature un-restartable without a reboot. Add the symmetric ON->OFF branch in both handlers. When a flag goes true->off and the other consumer of the shared thread is also off, call accelerometerThread->disable() (stops runOnce polling and clears enabled so a later re-enable can start() again). Each branch checks the other flag first so disabling one feature never stops the sensor while the other still needs it. * Keep accelerometer thread running when its sensor drives the compass * Guard accelerometer thread config toggles against a null thread pointer * AdminModule: factor shared accelerometer start/stop into a helper The device and display config handlers had mirror-image blocks reconciling the shared accelerometer thread. Extract reconcileAccelerometerThread(wasOn, nowOn, otherFeatureOn) so the null guard, edge logic, compass (providesHeading) guard, and rationale live in one place; each call site is now a single call. Behavior is unchanged. Also drops the redundant per-field assignment that the whole-struct `config.device = ...` / `config.display = ...` overwrites anyway. --------- Co-authored-by: Thomas Göttgens --- src/modules/AdminModule.cpp | 37 +++++++++++++++++++++----------- src/motion/AccelerometerThread.h | 4 ++++ src/motion/BMM150Sensor.h | 1 + src/motion/BMX160Sensor.h | 1 + src/motion/ICM20948Sensor.h | 1 + src/motion/MotionSensor.h | 6 ++++++ 6 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index b9cf4b03b..e7990a5a5 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -786,6 +786,27 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) } } +#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ + !MESHTASTIC_EXCLUDE_ACCELEROMETER +// Shared by double-tap-as-button (device) and wake-on-motion (display). Start on either flag's +// off->on edge; stop on the on->off edge once neither needs it (disable() clears `enabled` so a +// later re-enable restarts). Skip the stop if the sensor also drives the compass, else the heading +// freezes until reboot. wasOn/nowOn = old/new of the changed flag; otherFeatureOn = the other flag. +static void reconcileAccelerometerThread(bool wasOn, bool nowOn, bool otherFeatureOn) +{ + if (!accelerometerThread) // null unless a sensor was detected at boot + return; + + if (!wasOn && nowOn && accelerometerThread->enabled == false) { + accelerometerThread->enabled = true; + accelerometerThread->start(); + } else if (wasOn && !nowOn && !otherFeatureOn && accelerometerThread->enabled == true && + !accelerometerThread->providesHeading()) { + accelerometerThread->disable(); + } +} +#endif + void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) { auto changes = SEGMENT_CONFIG; @@ -801,12 +822,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.has_device = true; #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ !MESHTASTIC_EXCLUDE_ACCELEROMETER - if (config.device.double_tap_as_button_press == false && c.payload_variant.device.double_tap_as_button_press == true && - accelerometerThread->enabled == false) { - config.device.double_tap_as_button_press = c.payload_variant.device.double_tap_as_button_press; - accelerometerThread->enabled = true; - accelerometerThread->start(); - } + reconcileAccelerometerThread(config.device.double_tap_as_button_press, + c.payload_variant.device.double_tap_as_button_press, config.display.wake_on_tap_or_motion); #endif if (config.device.button_gpio == c.payload_variant.device.button_gpio && config.device.buzzer_gpio == c.payload_variant.device.buzzer_gpio && @@ -905,12 +922,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } #if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \ !MESHTASTIC_EXCLUDE_ACCELEROMETER - if (config.display.wake_on_tap_or_motion == false && c.payload_variant.display.wake_on_tap_or_motion == true && - accelerometerThread->enabled == false) { - config.display.wake_on_tap_or_motion = c.payload_variant.display.wake_on_tap_or_motion; - accelerometerThread->enabled = true; - accelerometerThread->start(); - } + reconcileAccelerometerThread(config.display.wake_on_tap_or_motion, c.payload_variant.display.wake_on_tap_or_motion, + config.device.double_tap_as_button_press); #endif config.display = c.payload_variant.display; break; diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h index 8482dafe4..6847b9aa4 100755 --- a/src/motion/AccelerometerThread.h +++ b/src/motion/AccelerometerThread.h @@ -60,6 +60,10 @@ class AccelerometerThread : public concurrency::OSThread } } + // True if the active sensor drives the compass heading in runOnce(). Callers must not + // disable() the thread in this case, or the compass would freeze until the next reboot. + bool providesHeading() const { return isInitialised && sensor && sensor->providesHeading(); } + protected: int32_t runOnce() override { diff --git a/src/motion/BMM150Sensor.h b/src/motion/BMM150Sensor.h index 879045400..8c5b1244e 100644 --- a/src/motion/BMM150Sensor.h +++ b/src/motion/BMM150Sensor.h @@ -50,6 +50,7 @@ class BMM150Sensor : public MotionSensor // Called each time our sensor gets a chance to run virtual int32_t runOnce() override; + virtual bool providesHeading() const override { return true; } }; #endif diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h index d60477521..d0d21d3dd 100755 --- a/src/motion/BMX160Sensor.h +++ b/src/motion/BMX160Sensor.h @@ -25,6 +25,7 @@ class BMX160Sensor : public MotionSensor virtual bool init() override; virtual int32_t runOnce() override; virtual void calibrate(uint16_t forSeconds) override; + virtual bool providesHeading() const override { return true; } }; #else diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h index d8369b3ca..e84c7ea1b 100755 --- a/src/motion/ICM20948Sensor.h +++ b/src/motion/ICM20948Sensor.h @@ -100,6 +100,7 @@ class ICM20948Sensor : public MotionSensor // Called each time our sensor gets a chance to run virtual int32_t runOnce() override; virtual void calibrate(uint16_t forSeconds) override; + virtual bool providesHeading() const override { return true; } }; #endif diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h index 717c89a9b..38876fb77 100755 --- a/src/motion/MotionSensor.h +++ b/src/motion/MotionSensor.h @@ -42,6 +42,12 @@ class MotionSensor virtual void calibrate(uint16_t forSeconds){}; + // True if this sensor produces the compass heading (screen->setHeading()) in runOnce(). + // Combined accel+magnetometer parts (e.g. BMX160, ICM20948) and standalone magnetometers + // handled by the accelerometer thread (e.g. BMM150) override this. Used to avoid halting + // the thread - and freezing the compass - when motion-only features are disabled at runtime. + inline virtual bool providesHeading() const { return false; }; + protected: // Turn on the screen when a tap or motion is detected virtual void wakeScreen(); From cae27c1764afeb66e4313bc844a225f83c44af71 Mon Sep 17 00:00:00 2001 From: oscgonfer Date: Sat, 18 Jul 2026 20:52:39 +0200 Subject: [PATCH 62/69] Add retries for SCD4X data reading (#11058) * Add retries for SCD4X data reading * Adds a retry loop with 3 tries by default to get sensor data from the SCD4X * Fix log and happy path --- src/modules/Telemetry/Sensor/SCD4XSensor.cpp | 14 ++++++++++++-- src/modules/Telemetry/Sensor/SCD4XSensor.h | 1 + 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp index bae0cf12a..26d50ab3e 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -104,8 +104,18 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ - bool dataReady; - error = scd4x.getDataReadyStatus(dataReady); + bool dataReady = false; + uint8_t dataReadyTries = 0; + + while (!dataReady && (dataReadyTries < SCD4X_MAX_RETRIES)) { + error = scd4x.getDataReadyStatus(dataReady); + if (error != SCD4X_NO_ERROR || !dataReady) { + LOG_WARN("%s: Error collecting data. Retrying", sensorName); + delay(100); + dataReadyTries++; + } + } + if (error != SCD4X_NO_ERROR || !dataReady) { #ifdef SCD4X_I2C_CLOCK_SPEED LOG_DEBUG("%s: restoring clock speed", sensorName); diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h index 065c82a13..f9161942e 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.h +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -11,6 +11,7 @@ // Max speed 400kHz #define SCD4X_I2C_CLOCK_SPEED 400000 #define SCD4X_WARMUP_MS 5000 +#define SCD4X_MAX_RETRIES 3 class SCD4XSensor : public TelemetrySensor { From ff951059cf3f442ab538a7c501cff5e3541e3e53 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:20:59 +0100 Subject: [PATCH 63/69] test: correct native-suite-count to 37 (#11059) native-suite-count drifted from the actual test/test_*/ directory count: #10669 added two suites (test_admin_session_repro, test_pki_admin_fallback) but bumped the count by only one, and #11037 added test_xmodem without bumping it at all. The file reads 35 against 37 real suites, which bin/run-tests.sh reports as AMBER on every full run. Correct it to 37. --- test/native-suite-count | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/native-suite-count b/test/native-suite-count index 8f92bfdd4..81b5c5d06 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -35 +37 From b56b35fcbbf3cfcd4fa97b5b461de1be6f5fd13e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:12:12 -0400 Subject: [PATCH 64/69] chore(deps): update platform-native digest to 86c62ed (#11054) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 044f64166..90e095948 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/61067ac3774e4fe27aa9762c72cebea507f116c8.zip + https://github.com/meshtastic/platform-native/archive/86c62edfb7084d11669aa255411a70580e83823b.zip framework = arduino build_src_filter = From a4ea55f9196fda951e09d4cc6e35ba4128cf4ac9 Mon Sep 17 00:00:00 2001 From: Austin Date: Sat, 18 Jul 2026 21:34:38 -0400 Subject: [PATCH 65/69] Fix renovate comments (#11065) Fix renovate comments for: - Crypto - Adafruit_nRF52_Arduino - adafruit/Adafruit-MLX90614-Library - meshtastic/Fusion --- platformio.ini | 4 ++-- variants/esp32/esp32-common.ini | 2 +- variants/esp32/esp32.ini | 2 +- variants/esp32p4/esp32p4.ini | 2 +- variants/native/portduino.ini | 2 +- variants/native/portduino/platformio.ini | 2 +- variants/nrf52840/nrf52.ini | 4 ++-- variants/nrf54l15/nrf54l15.ini | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) diff --git a/platformio.ini b/platformio.ini index 225a8f938..5dd81dd6f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,7 +170,7 @@ lib_deps = https://github.com/adafruit/Adafruit_TSL2591_Library/archive/refs/tags/1.4.5.zip # renovate: datasource=github-tags depName=EmotiBit MLX90632 packageName=emotibit/EmotiBit_MLX90632 https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip - # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614 + # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit-MLX90614-Library https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip # renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip @@ -200,7 +200,7 @@ lib_deps = https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip # renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip - # renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=master + # renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=main https://github.com/meshtastic/Fusion/archive/936e1eb1e5ea19e8f4ed467526f91adeebb4f53c.zip # renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 571fabfcc..c039c7356 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -70,7 +70,7 @@ lib_deps = https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip lib_ignore = diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 79b421dd5..2026dcfdb 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -56,5 +56,5 @@ lib_deps = https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 85abaee5c..5ad136806 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -104,7 +104,7 @@ lib_deps = ${networking_extra.lib_deps} ${environmental_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 90e095948..083b85518 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -23,7 +23,7 @@ lib_deps = ${networking_extra.lib_deps} ${radiolib_base.lib_deps} ${environmental_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.25 diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 5694a0d3b..1c9a9a161 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -324,7 +324,7 @@ lib_ldf_mode = chain+ lib_deps = ${env.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index f989a48a4..d4249bb78 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -6,7 +6,7 @@ platform = extends = arduino_base platform_packages = ; our custom Git version with C++17 support in platform.txt - # renovate: datasource=git-refs depName=meshtastic/Adafruit_nRF52_Arduino packageName=meshtastic/Adafruit_nRF52_Arduino gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Adafruit_nRF52_Arduino packageName=https://github.com/meshtastic/Adafruit_nRF52_Arduino gitBranch=master platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#0fd295f13203e93df19d578073646ec32f2bf45a ; Don't renovate toolchain-gccarmnoneeabi platformio/toolchain-gccarmnoneeabi@~1.90301.0 @@ -58,7 +58,7 @@ build_src_filter = lib_deps= ${arduino_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip lib_ignore = diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 6a299c9d4..31adaee10 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -49,7 +49,7 @@ lib_compat_mode = off lib_deps = ${arduino_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip ; Cherry-picked sensor libs from environmental_base. The full ; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino From b8b5582943a6c7d7a214077b633e198648e8b947 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:22:39 +0100 Subject: [PATCH 66/69] Regioninfo (#11056) * advertise a superset of EU regional presets to client devices. * trunk --- ...region_preset_compatibility_client_spec.md | 30 ++++++++--- src/mesh/RadioInterface.cpp | 52 +++++++++++++------ test/test_radio/test_main.cpp | 26 +++++++--- 3 files changed, 79 insertions(+), 29 deletions(-) diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index d2debef3f..bb1749672 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -153,7 +153,10 @@ These rules are what keep the UX correct across firmware versions. Implement all 5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions (`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a preset that belongs to a sibling's list, the firmware **swaps the region** rather than - rejecting the preset. Consequence for clients: **do not assume the region is immutable + rejecting the preset. To make this visible in the picker, the firmware advertises the + **same superset** (the union of the trio's presets) for all three sibling regions, so a + client filtering per §6 will offer every EU 86x preset regardless of which sibling is + currently selected. Consequence for clients: **do not assume the region is immutable across a preset change** - after an admin config write, re-read the resulting `LoRaConfig` and reflect the (possibly changed) region back into the UI. @@ -249,12 +252,23 @@ so they are stable as listed here: | group_index | default_preset | licensed_only | presets | | ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO | -| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE | -| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW | -| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW | +| 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) | +| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) | +| 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) | | 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | | 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | +The **EU 86x superset** advertised by groups 1, 2 and 3 is the union of the trio's own +band presets, because the firmware auto-swaps region within the trio on preset selection +(§5), so any of these is a legal pick from any of the three regions: + +```text +LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, LITE_FAST, LITE_SLOW, NARROW_FAST, NARROW_SLOW +``` + +The three groups still differ by `default_preset` (`LONG_FAST` / `LITE_FAST` / `NARROW_SLOW`), +which is why they remain distinct groups despite sharing this preset list. + `region_groups` (region → group_index): | group | regions | @@ -266,9 +280,11 @@ so they are stable as listed here: | 4 | ITU1_2M, ITU2_2M, ITU3_2M | | 5 | ITU2_125CM | -> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups -> because they differ in `licensed_only`. Decoders must key on the group, not on the preset -> list, to preserve the licensing flag. +> Note that several groups can carry overlapping preset lists but remain distinct: groups 1, +> 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham +> 100 kHz) shares the `NARROW_*` presets with group 3 but differs in `licensed_only`. +> Decoders must key on the group, not on the preset list, to preserve `default_preset` and +> the licensing flag. > > Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`, > `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`. diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index b577a7f35..f616e866a 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -55,6 +55,34 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END}; +// The EU_868/EU_866/EU_N_868 trio share the 868 band but own mutually exclusive preset +// profiles. Selecting a preset locked to a sibling swaps the region to that sibling (see +// regionSwapForPreset), so from any region in the trio every one of these presets is +// selectable. This union is what we advertise to clients as the trio's legal list. It is a +// display-only superset: on-device enforcement still uses each region's own disjoint +// profile->presets, so this must never be assigned to a RegionProfile (that would make +// supportsPreset() accept the preset in place and defeat the swap). Keep in sync with the +// EU_868/EU_866/EU_N_868 profile lists below. Sized to the 11-preset wire cap. +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_SUPERSET[] = { + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), PRESET(SHORT_FAST), + PRESET(LONG_MODERATE), PRESET(LITE_FAST), PRESET(LITE_SLOW), PRESET(NARROW_FAST), PRESET(NARROW_SLOW), MODEM_PRESET_END}; + +// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset +// locked to a sibling means the user wants that sibling region, not the default preset. +static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { + meshtastic_Config_LoRaConfig_RegionCode_EU_868, + meshtastic_Config_LoRaConfig_RegionCode_EU_866, + meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, +}; + +static bool isSwappableEuRegion(meshtastic_Config_LoRaConfig_RegionCode code) +{ + for (auto c : SWAPPABLE_EU_REGIONS) + if (c == code) + return true; + return false; +} + // Region profiles: bundle preset list + regulatory parameters shared across regions // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1}; @@ -687,8 +715,13 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) grp.default_preset = r->getDefaultPreset(); grp.licensed_only = r->profile->licensedOnly; grp.presets_count = 0; - for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) - grp.presets[grp.presets_count++] = r->profile->presets[i]; + // EU 86x siblings advertise the trio's superset - any of those presets is + // reachable from here via an automatic region swap. Every other region + // advertises exactly its own enforced profile list. + const meshtastic_Config_LoRaConfig_ModemPreset *advertised = + isSwappableEuRegion(r->code) ? PRESETS_EU_SUPERSET : r->profile->presets; + for (size_t i = 0; advertised[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) + grp.presets[grp.presets_count++] = advertised[i]; } // Map this region to its group (capacity checked at the top of the loop). @@ -963,14 +996,6 @@ static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level le service->sendClientNotification(cn); } -// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset -// locked to a sibling means the user wants that sibling region, not the default preset. -static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { - meshtastic_Config_LoRaConfig_RegionCode_EU_868, - meshtastic_Config_LoRaConfig_RegionCode_EU_866, - meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, -}; - /** * If currentRegion is one of the swappable EU regions and preset belongs to a sibling in * that trio, return the sibling region that owns the preset. Returns nullptr otherwise. @@ -978,12 +1003,7 @@ static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion, meshtastic_Config_LoRaConfig_ModemPreset preset) { - bool currentIsSwappable = false; - for (auto code : SWAPPABLE_EU_REGIONS) { - if (code == currentRegion) - currentIsSwappable = true; - } - if (!currentIsSwappable) + if (!isSwappableEuRegion(currentRegion)) return nullptr; for (auto code : SWAPPABLE_EU_REGIONS) { diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index c62e6b267..df2442164 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -292,16 +292,30 @@ static void test_regionPresetMap_matchesRegionTable() const meshtastic_LoRaPresetGroup &grp = map.groups[gi]; const RegionInfo *r = getRegion(code); - // Group's list is non-empty, within the generated array bound, and is the - // region's full list. + // Group's list is non-empty and within the generated array bound. const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]); TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count); TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count); - TEST_ASSERT_EQUAL_UINT((unsigned)r->getNumPresets(), (unsigned)grp.presets_count); - // Every advertised preset is legal in this region. - for (pb_size_t p = 0; p < grp.presets_count; p++) - TEST_ASSERT_TRUE(r->supportsPreset(grp.presets[p])); + // Every advertised preset must be selectable from this region: either legal here, + // or legal in a sibling the firmware will auto-swap us to (the EU 86x trio, which + // advertises the union of the trio's presets rather than just its own). + for (pb_size_t p = 0; p < grp.presets_count; p++) { + bool selectable = + r->supportsPreset(grp.presets[p]) || RadioInterface::regionSwapForPreset(code, grp.presets[p]) != nullptr; + TEST_ASSERT_TRUE(selectable); + } + + // The region's own enforced presets must all be advertised (advertised is a + // superset of the enforced list, never a subset). + const meshtastic_Config_LoRaConfig_ModemPreset *enforced = r->getAvailablePresets(); + for (size_t e = 0; e < r->getNumPresets(); e++) { + bool advertised = false; + for (pb_size_t p = 0; p < grp.presets_count; p++) + if (grp.presets[p] == enforced[e]) + advertised = true; + TEST_ASSERT_TRUE(advertised); + } // Default preset matches the table, is legal, and is present in the list. TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset); From 8c6cc5dbde169e3a4b9e486d7968292e4856c5dc Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:32:34 -0400 Subject: [PATCH 67/69] Don't show messages from Ignored nodes (#11068) * Honor Node Ignore messages * Screenless node fix --- src/MessageStore.cpp | 129 +++++++++++++++--- src/MessageStore.h | 8 +- src/graphics/Screen.cpp | 12 +- src/graphics/draw/MenuHandler.cpp | 11 +- src/graphics/draw/MessageRenderer.cpp | 2 + .../Notification/NotificationApplet.cpp | 5 +- .../User/AllMessage/AllMessageApplet.cpp | 4 +- .../niche/InkHUD/Applets/User/DM/DMApplet.cpp | 4 +- .../ThreadedMessage/ThreadedMessageApplet.cpp | 3 +- src/graphics/niche/InkHUD/Events.cpp | 8 +- src/graphics/niche/InkHUD/Persistence.cpp | 4 + src/mesh/MeshService.cpp | 4 +- src/mesh/NodeDB.cpp | 3 + src/modules/AdminModule.cpp | 8 ++ src/modules/TextMessageModule.cpp | 6 +- src/mqtt/MQTT.cpp | 29 ++++ 16 files changed, 195 insertions(+), 45 deletions(-) diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 6332c0e82..6284ea007 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -7,6 +7,7 @@ #include "SafeFile.h" #include "gps/RTC.h" #include "memory/MemAudit.h" +#include #include // memcpy #ifndef MESSAGE_TEXT_POOL_SIZE @@ -65,6 +66,15 @@ static inline const char *getTextFromPool(uint16_t offset) return &g_messagePool[offset]; } +static inline bool isIgnoredNodeNum(uint32_t nodeNum) +{ + if (nodeNum == 0 || nodeNum == NODENUM_BROADCAST) + return false; + + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeNum); + return nodeInfoLiteIsIgnored(node); +} + // Helper: assign a timestamp (RTC if available, else boot-relative) static inline void assignTimestamp(StoredMessage &sm) { @@ -163,9 +173,44 @@ static inline void autosaveTick(MessageStore *store) } #endif -// Add from incoming/outgoing packet -const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &packet) +bool MessageStore::shouldStorePacket(const meshtastic_MeshPacket &packet) const { + const uint32_t localNode = nodeDB->getNodeNum(); + const bool isDM = packet.to != 0 && packet.to != NODENUM_BROADCAST; + if (isDM) { + const bool outgoing = packet.from == 0 || packet.from == localNode; + const uint32_t peer = outgoing ? packet.to : packet.from; + return !isIgnoredNodeNum(peer); + } + + if (packet.from != 0 && packet.from != localNode) + return !isIgnoredNodeNum(packet.from); + + return true; +} + +bool MessageStore::isMessageVisible(const StoredMessage &msg) const +{ + const uint32_t localNode = nodeDB->getNodeNum(); + if (msg.type == MessageType::DM_TO_US) { + const uint32_t peer = (msg.sender == localNode) ? msg.dest : msg.sender; + return !isIgnoredNodeNum(peer); + } + + if (msg.sender != 0 && msg.sender != localNode) + return !isIgnoredNodeNum(msg.sender); + + return true; +} + +// Add from incoming/outgoing packet +const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket &packet) +{ + if (!shouldStorePacket(packet)) { + LOG_DEBUG("Drop store 0x%08x", packet.from); + return nullptr; + } + StoredMessage sm; assignTimestamp(sm); sm.channelIndex = packet.channel; @@ -196,7 +241,14 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa markMessageStoreUnsaved(); #endif - return liveMessages.back(); + return &liveMessages.back(); +} + +const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &packet) +{ + const StoredMessage *stored = tryAddFromPacket(packet); + assert(stored); + return *stored; } // Outgoing/manual message @@ -323,28 +375,33 @@ void MessageStore::loadFromFlash() resetMessagePool(); // reset pool when loading #ifdef FSCom - concurrency::LockGuard guard(spiLock); + { + concurrency::LockGuard guard(spiLock); - if (!FSCom.exists(filename.c_str())) - return; + if (!FSCom.exists(filename.c_str())) + return; - auto f = FSCom.open(filename.c_str(), FILE_O_READ); - if (!f) - return; + auto f = FSCom.open(filename.c_str(), FILE_O_READ); + if (!f) + return; - uint8_t count = 0; - f.readBytes(reinterpret_cast(&count), 1); - if (count > MAX_MESSAGES_SAVED) - count = MAX_MESSAGES_SAVED; + uint8_t count = 0; + f.readBytes(reinterpret_cast(&count), 1); + if (count > MAX_MESSAGES_SAVED) + count = MAX_MESSAGES_SAVED; - for (uint8_t i = 0; i < count; ++i) { - StoredMessage m; - if (!readMessageRecord(f, m)) - break; - liveMessages.push_back(m); + for (uint8_t i = 0; i < count; ++i) { + StoredMessage m; + if (!readMessageRecord(f, m)) + break; + liveMessages.push_back(m); + } + + f.close(); } - f.close(); + if (pruneHiddenMessages()) + saveToFlash(); #endif // Loading messages does not trigger an autosave g_messageStoreHasUnsavedChanges = false; @@ -406,6 +463,13 @@ template static void eraseAllMatches(std::dequegetNodeNum(); + auto pred = [&](const StoredMessage &m) { + if (m.sender == nodeNum) + return true; + if (m.type != MessageType::DM_TO_US) + return false; + return m.sender == local ? m.dest == nodeNum : m.sender == nodeNum; + }; + eraseAllMatches(liveMessages, pred); + saveToFlash(); +} + // Delete oldest message in a direct chat with a node void MessageStore::deleteOldestMessageWithPeer(uint32_t peer) { @@ -460,7 +538,7 @@ std::deque MessageStore::getChannelMessages(uint8_t channel) cons { std::deque result; for (const auto &m : liveMessages) { - if (m.type == MessageType::BROADCAST && m.channelIndex == channel) { + if (isMessageVisible(m) && m.type == MessageType::BROADCAST && m.channelIndex == channel) { result.push_back(m); } } @@ -471,13 +549,22 @@ std::deque MessageStore::getDirectMessages() const { std::deque result; for (const auto &m : liveMessages) { - if (m.type == MessageType::DM_TO_US) { + if (isMessageVisible(m) && m.type == MessageType::DM_TO_US) { result.push_back(m); } } return result; } +bool MessageStore::hasVisibleMessages() const +{ + for (const auto &m : liveMessages) { + if (isMessageVisible(m)) + return true; + } + return false; +} + // Upgrade boot-relative timestamps once RTC is valid // Only same-boot boot-relative messages are healed. // Persisted boot-relative messages from old boots stay ??? forever. diff --git a/src/MessageStore.h b/src/MessageStore.h index 040806197..724e10bc6 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -93,7 +93,7 @@ class MessageStore void addLiveMessage(StoredMessage &&msg); void addLiveMessage(const StoredMessage &msg); // convenience overload const std::deque &getLiveMessages() const { return liveMessages; } - + const StoredMessage *tryAddFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing -> RAM only // Add new messages from packets or manual input const StoredMessage &addFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing → RAM only void addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text); // Manual add @@ -111,13 +111,16 @@ class MessageStore void deleteOldestMessageWithPeer(uint32_t peer); void deleteAllMessagesInChannel(uint8_t channel); void deleteAllMessagesWithPeer(uint32_t peer); - + void deleteAllMessagesFromNode(uint32_t nodeNum); // Unified accessor (for UI code, defaults to RAM buffer) const std::deque &getMessages() const { return liveMessages; } + bool hasVisibleMessages() const; // Helper filters for future use std::deque getChannelMessages(uint8_t channel) const; // Only broadcast messages on a channel std::deque getDirectMessages() const; // Only direct messages + bool shouldStorePacket(const meshtastic_MeshPacket &mp) const; + bool isMessageVisible(const StoredMessage &msg) const; // Upgrade boot-relative timestamps once RTC is valid void upgradeBootRelativeTimestamps(); @@ -129,6 +132,7 @@ class MessageStore static uint16_t storeText(const char *src, size_t len); private: + bool pruneHiddenMessages(); std::deque liveMessages; // Single in-RAM message buffer (also used for persistence) std::string filename; // Flash filename for persistence }; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 73bf04b6d..95883038b 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -2110,7 +2110,7 @@ int Screen::handleInputEvent(const InputEvent *event) if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { if (event->inputEvent == INPUT_BROKER_UP) { - if (messageStore.getMessages().empty()) { + if (!messageStore.hasVisibleMessages()) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); } else { graphics::MessageRenderer::scrollUp(); @@ -2120,7 +2120,7 @@ int Screen::handleInputEvent(const InputEvent *event) } if (event->inputEvent == INPUT_BROKER_DOWN) { - if (messageStore.getMessages().empty()) { + if (!messageStore.hasVisibleMessages()) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); } else { graphics::MessageRenderer::scrollDown(); @@ -2169,9 +2169,9 @@ int Screen::handleInputEvent(const InputEvent *event) if (!inputIntercepted) { #if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2 bool handledEncoderScroll = false; - const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 && - this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && - !messageStore.getMessages().empty()); + const bool isTextMessageFrame = + (framesetInfo.positions.textMessage != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && messageStore.hasVisibleMessages()); if (isTextMessageFrame) { if (event->inputEvent == INPUT_BROKER_UP_LONG) { graphics::MessageRenderer::nudgeScroll(-1); @@ -2254,7 +2254,7 @@ int Screen::handleInputEvent(const InputEvent *event) } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { menuHandler::loraMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { - if (!messageStore.getMessages().empty()) { + if (messageStore.hasVisibleMessages()) { menuHandler::messageResponseMenu(); } else { if (currentResolution == ScreenResolution::UltraLow) { diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 349fe9c4a..ccb447399 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -70,12 +70,15 @@ const StoredMessage *getNewestMessageForActiveThread() const uint32_t peer = graphics::MessageRenderer::getThreadPeer(); const uint32_t localNode = nodeDB->getNodeNum(); - if (mode == graphics::MessageRenderer::ThreadMode::ALL) { - return &messages.back(); - } - for (auto it = messages.rbegin(); it != messages.rend(); ++it) { const StoredMessage &m = *it; + if (!messageStore.isMessageVisible(m)) { + continue; + } + + if (mode == graphics::MessageRenderer::ThreadMode::ALL) { + return &m; + } if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { if (m.type == MessageType::BROADCAST && static_cast(m.channelIndex) == channel) { diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index bc5e027fb..6c202cb6d 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -403,6 +403,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 // Filter messages based on thread mode std::deque filtered; for (const auto &m : messageStore.getLiveMessages()) { + if (!messageStore.isMessageVisible(m)) + continue; bool include = false; switch (currentMode) { case ThreadMode::ALL: diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index 682c4de5e..e2090269e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -234,7 +234,8 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila // Pick source of message const StoredMessage *message = msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm; - + if (!message->sender || !messageStore.isMessageVisible(*message)) + return parse(text); // Find info about the sender meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender); @@ -270,4 +271,4 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila return parse(text); } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp index ec266771e..740af32a7 100644 --- a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp @@ -46,7 +46,7 @@ void InkHUD::AllMessageApplet::onRender(bool full) message = &latestMessage->dm; // Short circuit: no text message - if (!message->sender) { + if (!message->sender || !messageStore.isMessageVisible(*message)) { printAt(X(0.5), Y(0.5), "No Message", CENTER, MIDDLE); return; } @@ -138,4 +138,4 @@ bool InkHUD::AllMessageApplet::approveNotification(Notification &n) return true; } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp index 4940e69bf..9f1471c90 100644 --- a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp @@ -42,7 +42,7 @@ int InkHUD::DMApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p) void InkHUD::DMApplet::onRender(bool full) { // Abort if no text message - if (!latestMessage->dm.sender) { + if (!latestMessage->dm.sender || !messageStore.isMessageVisible(latestMessage->dm)) { printAt(X(0.5), Y(0.5), "No DMs", CENTER, MIDDLE); return; } @@ -131,4 +131,4 @@ bool InkHUD::DMApplet::approveNotification(Notification &n) return true; } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp index 31aeaa814..ef5902a24 100644 --- a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp @@ -191,7 +191,8 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me return ProcessMessage::CONTINUE; // Store in the global messageStore - this handles sender, timestamp, channel, text, and ack status - messageStore.addFromPacket(mp); + if (!messageStore.tryAddFromPacket(mp)) + return ProcessMessage::CONTINUE; // If this was an incoming message, suggest that our applet becomes foreground, if permitted if (getFrom(&mp) != nodeDB->getNodeNum()) diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index ddb4a57b7..1a5e438d9 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -534,13 +534,19 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet) if (getFrom(packet) == nodeDB->getNodeNum()) return 0; + if (!messageStore.shouldStorePacket(*packet)) + return 0; + bool isBroadcastMsg = isBroadcast(packet->to); inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg; if (!isBroadcastMsg) { // DMs never pass through ThreadedMessageApplet, so add them to the global store here // so they survive reboots. Derive the latestMessage cache entry from the stored result. - inkhud->persistence->latestMessage.dm = messageStore.addFromPacket(*packet); + const StoredMessage *stored = messageStore.tryAddFromPacket(*packet); + if (!stored) + return 0; + inkhud->persistence->latestMessage.dm = *stored; } else { // Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived(). // Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet. diff --git a/src/graphics/niche/InkHUD/Persistence.cpp b/src/graphics/niche/InkHUD/Persistence.cpp index 8a8140bb3..f588060b4 100644 --- a/src/graphics/niche/InkHUD/Persistence.cpp +++ b/src/graphics/niche/InkHUD/Persistence.cpp @@ -26,6 +26,10 @@ void InkHUD::Persistence::loadLatestMessage() int lastBroadcastPos = -1, lastDMPos = -1, pos = 0; for (const StoredMessage &m : messageStore.getLiveMessages()) { + if (!messageStore.isMessageVisible(m)) { + pos++; + continue; + } if (m.type == MessageType::BROADCAST) { latestMessage.broadcast = m; lastBroadcastPos = pos; diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index ce356d7cd..c7148c59f 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -257,8 +257,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) p.to != NODENUM_BROADCAST && p.to != 0) // DM only { perhapsDecode(&p); - const StoredMessage &sm = messageStore.addFromPacket(p); - graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI + if (const StoredMessage *sm = messageStore.tryAddFromPacket(p)) + graphics::MessageRenderer::handleNewMessage(nullptr, *sm, p); // notify UI }) #if !MESHTASTIC_EXCLUDE_ADMIN // Note admin requests on their way out: AdminModule only accepts a response from a remote we diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4c2879adc..a33f08389 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -3268,6 +3268,9 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2); nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); eraseNodeSatellites(contact.node_num); +#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) + messageStore.deleteAllMessagesFromNode(contact.node_num); +#endif } else { /* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with * public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM! diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index e7990a5a5..bdfd4bb51 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -29,6 +29,7 @@ #include "Default.h" #include "MeshRadio.h" +#include "MessageStore.h" #include "RadioInterface.h" #include "TypeConversions.h" #include "mesh/RadioLibInterface.h" @@ -537,7 +538,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (node != NULL) { if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) { nodeDB->eraseNodeSatellites(node->num); +#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) + messageStore.deleteAllMessagesFromNode(node->num); +#endif saveChanges(SEGMENT_NODEDATABASE, false); +#if HAS_SCREEN + if (screen) + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); +#endif } else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2); } else { diff --git a/src/modules/TextMessageModule.cpp b/src/modules/TextMessageModule.cpp index 1d0912311..818e39a94 100644 --- a/src/modules/TextMessageModule.cpp +++ b/src/modules/TextMessageModule.cpp @@ -25,12 +25,14 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp // Guard against running in MeshtasticUI or with no screen if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { // Store in the central message history - const StoredMessage &sm = messageStore.addFromPacket(mp); + const StoredMessage *sm = messageStore.tryAddFromPacket(mp); + if (!sm) + return ProcessMessage::CONTINUE; // Pass message to renderer (banner + thread switching + scroll reset) // Use the global Screen singleton to retrieve the current OLED display auto *display = screen ? screen->getDisplayDevice() : nullptr; - graphics::MessageRenderer::handleNewMessage(display, sm, mp); + graphics::MessageRenderer::handleNewMessage(display, *sm, mp); }) // Only trigger screen wake if configuration allows it if (shouldWakeOnReceivedMessage()) { diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 907888bff..f8ab7fc07 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -55,6 +55,32 @@ static bool isConnected = false; static uint32_t lastPositionUnavailableWarning = 0; static const uint32_t POSITION_UNAVAILABLE_WARNING_INTERVAL_MS = 15000; // 15 seconds +inline bool shouldDropMqttDownlink(const meshtastic_MeshPacket &packet) +{ + if (is_in_repeated(config.lora.ignore_incoming, packet.from)) { + LOG_INFO("Drop MQTT ignored 0x%08x", packet.from); + return true; + } + + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); + if (nodeInfoLiteIsIgnored(node)) { + LOG_INFO("Drop MQTT node 0x%08x", packet.from); + return true; + } + + if (packet.from == NODENUM_BROADCAST) { + LOG_INFO("Drop MQTT broadcast src"); + return true; + } + + if (config.lora.ignore_mqtt) { + LOG_INFO("Drop MQTT ignore_mqtt"); + return true; + } + + return false; +} + inline void onReceiveProto(char *topic, byte *payload, size_t length) { const DecodedServiceEnvelope e(payload, length); @@ -122,6 +148,9 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) p->which_payload_variant = e.packet->which_payload_variant; memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted))); + if (shouldDropMqttDownlink(*p)) + return; + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { if (moduleConfig.mqtt.encryption_enabled) { LOG_INFO("Ignore decoded message on MQTT, encryption is enabled"); From 47263752824fba899b1fefb6291e777a5a0b292e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sun, 19 Jul 2026 06:55:26 -0500 Subject: [PATCH 68/69] Update protos --- protobufs | 2 +- src/mesh/generated/meshtastic/config.pb.cpp | 2 ++ src/mesh/generated/meshtastic/config.pb.h | 30 ++++++++++++++++--- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- src/mesh/generated/meshtastic/mesh.pb.h | 15 +++++++--- 6 files changed, 42 insertions(+), 11 deletions(-) diff --git a/protobufs b/protobufs index f5b94bc36..b6ad0e5f4 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f5b94bc36786e3862eb16f4a68169709ee23c809 +Subproject commit b6ad0e5f4e4716f50ec414cf4e28efca289cdce3 diff --git a/src/mesh/generated/meshtastic/config.pb.cpp b/src/mesh/generated/meshtastic/config.pb.cpp index c554ca43c..d89388744 100644 --- a/src/mesh/generated/meshtastic/config.pb.cpp +++ b/src/mesh/generated/meshtastic/config.pb.cpp @@ -69,6 +69,8 @@ PB_BIND(meshtastic_Config_SessionkeyConfig, meshtastic_Config_SessionkeyConfig, + + diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 0778a9bc5..8423f54ba 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -399,6 +399,19 @@ typedef enum _meshtastic_Config_BluetoothConfig_PairingMode { meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN = 2 } meshtastic_Config_BluetoothConfig_PairingMode; +/* Controls how the device authenticates remotely received mesh packets. */ +typedef enum _meshtastic_Config_SecurityConfig_PacketSignaturePolicy { + /* Accept unsigned packets for maximum compatibility while still rejecting malformed or invalid signatures. + This is the default to avoid legacy nodes dropping signed packets during rebroadcast. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE = 0, + /* Prefer authenticated packets while retaining compatibility with unsigned packets from nodes not known to sign. + Rejects unsigned, signable broadcasts from nodes that have previously signed. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED = 1, + /* Accept only packets authenticated by a verified XEdDSA signature or successful PKI decryption. + Unsigned, malformed, invalid, or unverifiable packets are ignored. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT = 2 +} meshtastic_Config_SecurityConfig_PacketSignaturePolicy; + /* Struct definitions */ /* Configuration */ typedef struct _meshtastic_Config_DeviceConfig { @@ -689,6 +702,8 @@ typedef struct _meshtastic_Config_SecurityConfig { bool debug_log_api_enabled; /* Allow incoming device control over the insecure legacy admin channel. */ bool admin_channel_enabled; + /* Determines the packet signature policy applied to remotely received mesh packets. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy packet_signature_policy; } meshtastic_Config_SecurityConfig; /* Blank config request, strictly for getting the session key */ @@ -782,6 +797,10 @@ extern "C" { #define _meshtastic_Config_BluetoothConfig_PairingMode_MAX meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN #define _meshtastic_Config_BluetoothConfig_PairingMode_ARRAYSIZE ((meshtastic_Config_BluetoothConfig_PairingMode)(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN+1)) +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MAX meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_ARRAYSIZE ((meshtastic_Config_SecurityConfig_PacketSignaturePolicy)(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT+1)) + #define meshtastic_Config_DeviceConfig_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role #define meshtastic_Config_DeviceConfig_rebroadcast_mode_ENUMTYPE meshtastic_Config_DeviceConfig_RebroadcastMode @@ -805,6 +824,7 @@ extern "C" { #define meshtastic_Config_BluetoothConfig_mode_ENUMTYPE meshtastic_Config_BluetoothConfig_PairingMode +#define meshtastic_Config_SecurityConfig_packet_signature_policy_ENUMTYPE meshtastic_Config_SecurityConfig_PacketSignaturePolicy @@ -818,7 +838,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_default {0} #define meshtastic_Config_init_zero {0, {meshtastic_Config_DeviceConfig_init_zero}} #define meshtastic_Config_DeviceConfig_init_zero {_meshtastic_Config_DeviceConfig_Role_MIN, 0, 0, 0, _meshtastic_Config_DeviceConfig_RebroadcastMode_MIN, 0, 0, 0, 0, "", 0, _meshtastic_Config_DeviceConfig_BuzzerMode_MIN} @@ -829,7 +849,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_zero {0} /* Field tags (for use in manual encoding/decoding) */ @@ -925,6 +945,7 @@ extern "C" { #define meshtastic_Config_SecurityConfig_serial_enabled_tag 5 #define meshtastic_Config_SecurityConfig_debug_log_api_enabled_tag 6 #define meshtastic_Config_SecurityConfig_admin_channel_enabled_tag 8 +#define meshtastic_Config_SecurityConfig_packet_signature_policy_tag 9 #define meshtastic_Config_device_tag 1 #define meshtastic_Config_position_tag 2 #define meshtastic_Config_power_tag 3 @@ -1086,7 +1107,8 @@ X(a, STATIC, REPEATED, BYTES, admin_key, 3) \ X(a, STATIC, SINGULAR, BOOL, is_managed, 4) \ X(a, STATIC, SINGULAR, BOOL, serial_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, debug_log_api_enabled, 6) \ -X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) +X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) \ +X(a, STATIC, SINGULAR, UENUM, packet_signature_policy, 9) #define meshtastic_Config_SecurityConfig_CALLBACK NULL #define meshtastic_Config_SecurityConfig_DEFAULT NULL @@ -1130,7 +1152,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 #define meshtastic_Config_PowerConfig_size 52 -#define meshtastic_Config_SecurityConfig_size 178 +#define meshtastic_Config_SecurityConfig_size 180 #define meshtastic_Config_SessionkeyConfig_size 0 #define meshtastic_Config_size 207 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index c838dc21d..059398cf0 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2738 +#define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 6fcaed306..c560d5447 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -211,7 +211,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 757 +#define meshtastic_LocalConfig_size 759 #define meshtastic_LocalModuleConfig_size 1126 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 98798173c..7ce8115ba 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -412,6 +412,8 @@ typedef enum _meshtastic_FirmwareEdition { meshtastic_FirmwareEdition_BURNING_MAN = 18, /* Hamvention, the Dayton amateur radio convention */ meshtastic_FirmwareEdition_HAMVENTION = 19, + /* FAB, the international Fab Lab digital fabrication conference */ + meshtastic_FirmwareEdition_FAB = 20, /* Placeholder for DIY and unofficial events */ meshtastic_FirmwareEdition_DIY_EDITION = 127 } meshtastic_FirmwareEdition; @@ -1395,6 +1397,9 @@ typedef struct _meshtastic_DeviceMetadata { /* Bit field of boolean for excluded modules (bitwise OR of ExcludedModules) */ uint32_t excluded_modules; + /* Indicates whether this firmware build includes XEdDSA packet signature verification. + This is a read-only capability and must be false when XEdDSA is not compiled in. */ + bool has_xeddsa; } meshtastic_DeviceMetadata; /* A distinct set of legal modem presets shared by one or more LoRa regions. @@ -1743,7 +1748,7 @@ extern "C" { #define meshtastic_Compressed_init_default {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}} #define meshtastic_Neighbor_init_default {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}} @@ -1782,7 +1787,7 @@ extern "C" { #define meshtastic_Compressed_init_zero {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}} #define meshtastic_Neighbor_init_zero {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}} @@ -1985,6 +1990,7 @@ extern "C" { #define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10 #define meshtastic_DeviceMetadata_hasPKC_tag 11 #define meshtastic_DeviceMetadata_excluded_modules_tag 12 +#define meshtastic_DeviceMetadata_has_xeddsa_tag 14 #define meshtastic_LoRaPresetGroup_presets_tag 1 #define meshtastic_LoRaPresetGroup_default_preset_tag 2 #define meshtastic_LoRaPresetGroup_licensed_only_tag 3 @@ -2403,7 +2409,8 @@ X(a, STATIC, SINGULAR, UINT32, position_flags, 8) \ X(a, STATIC, SINGULAR, UENUM, hw_model, 9) \ X(a, STATIC, SINGULAR, BOOL, hasRemoteHardware, 10) \ X(a, STATIC, SINGULAR, BOOL, hasPKC, 11) \ -X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) +X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) \ +X(a, STATIC, SINGULAR, BOOL, has_xeddsa, 14) #define meshtastic_DeviceMetadata_CALLBACK NULL #define meshtastic_DeviceMetadata_DEFAULT NULL @@ -2552,7 +2559,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 #define meshtastic_Data_size 335 -#define meshtastic_DeviceMetadata_size 54 +#define meshtastic_DeviceMetadata_size 56 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 #define meshtastic_FromRadio_size 510 From a808e992a11714f224bf4a13b38e3adae0b91cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=B6ttgens?= Date: Sun, 19 Jul 2026 23:31:19 +0200 Subject: [PATCH 69/69] Add native Windows build of meshtasticd (#11031) --- .github/workflows/build_windows_bin.yml | 120 ++++++ .github/workflows/main_matrix.yml | 12 + extra_scripts/windows_link_flags.py | 16 + src/gps/RTC.cpp | 15 +- src/main.cpp | 4 + src/mesh/HardwareRNG.cpp | 12 + src/mqtt/MQTT.cpp | 4 + src/platform/portduino/GpsdSerial.cpp | 103 ++++- src/platform/portduino/PortduinoGlue.cpp | 20 +- src/platform/portduino/USBHal.h | 1 + src/platform/portduino/WindowsMacAddr.cpp | 54 +++ .../windows/include/libpinedio-usb.h | 75 ++++ .../portduino/windows/libpinedio_ch341dll.c | 400 ++++++++++++++++++ src/power/PowerHAL.cpp | 14 +- variants/native/portduino.ini | 4 +- variants/native/portduino/platformio.ini | 77 ++++ 16 files changed, 910 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/build_windows_bin.yml create mode 100644 extra_scripts/windows_link_flags.py create mode 100644 src/platform/portduino/WindowsMacAddr.cpp create mode 100644 src/platform/portduino/windows/include/libpinedio-usb.h create mode 100644 src/platform/portduino/windows/libpinedio_ch341dll.c diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml new file mode 100644 index 000000000..e755c42e6 --- /dev/null +++ b/.github/workflows/build_windows_bin.yml @@ -0,0 +1,120 @@ +name: Build Windows Binary + +on: + workflow_call: + inputs: + windows_ver: + required: false + default: "2025" + type: string + +permissions: + contents: read + +jobs: + build-Windows: + runs-on: windows-${{ inputs.windows_ver }} + defaults: + run: + # UCRT64 is the MinGW-w64 environment native-windows targets; the `msys` + # environment would link msys-2.0.dll and produce a Cygwin-style binary. + shell: msys2 {0} + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + submodules: recursive + # Keep the token out of .git/config so later steps and artifacts can't leak it. + persist-credentials: false + + - name: Setup MSYS2 / UCRT64 + id: msys2 + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + # argp is not packaged for the mingw environments and is built below. + # Python is omitted too: MSYS2's reports a `mingw` platform tag no wheel matches. + install: >- + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-yaml-cpp + mingw-w64-ucrt-x86_64-libuv + mingw-w64-ucrt-x86_64-jsoncpp + mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-libusb + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-ninja + git + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + # framework-portduino calls argp_parse(); MSYS2 packages argp only for the + # msys runtime, which cannot link into a native binary. No install() rules. + - name: Build and install argp-standalone + run: | + git clone --depth 1 https://github.com/tom42/argp-standalone /tmp/argp + cd /tmp/argp + cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release . + cmake --build build + cp include/argp-standalone/argp.h /ucrt64/include/argp.h + cp build/src/libargp-standalone.a /ucrt64/lib/libargp.a + + - name: Install PlatformIO + shell: pwsh + run: | + python -m pip install --upgrade pip + pip install platformio + + - name: Get release version string + shell: pwsh + id: version + run: echo "long=$(python ./bin/buildinfo.py long)" >> $env:GITHUB_OUTPUT + + # Runs outside the MSYS2 shell so PlatformIO stays on the runner's + # CPython; the UCRT64 toolchain is reached through PATH instead. + - name: Build for Windows + shell: pwsh + run: | + $env:PATH = "${{ steps.msys2.outputs.msys2-location }}\ucrt64\bin;$env:PATH" + platformio run -e native-windows + env: + PKG_VERSION: ${{ steps.version.outputs.long }} + + - name: List output files + run: ls -lah .pio/build/native-windows/ + + # The env links statically, so only Windows system DLLs should appear here. + # A third-party DLL means the static link regressed. + - name: Verify the binary is self-contained + run: | + set -euo pipefail + bin=.pio/build/native-windows/meshtasticd.exe + test -f "$bin" + # `|| true` keeps grep's no-match exit out of `set -e`; an empty import + # table is caught by the test below instead of passing as "no deps". + deps=$(objdump -p "$bin" | grep -i 'DLL Name' || true) + test -n "$deps" + extra=$(printf '%s\n' "$deps" \ + | grep -viE 'api-ms-win|KERNEL32|WS2_32|ADVAPI32|USER32|msvcrt|ucrtbase|bcrypt|IPHLPAPI|SHELL32|ole32|CRYPT32|SETUPAPI|CFGMGR32|WINMM|dbghelp' || true) + if [ -n "$extra" ]; then + printf '%s\n' "$extra" + echo "::error::meshtasticd.exe has non-system DLL dependencies (static link regressed)" + exit 1 + fi + echo "OK: no third-party DLL dependencies" + + - name: Smoke test the binary + run: | + .pio/build/native-windows/meshtasticd.exe --version + + - name: Store binaries as an artifact + uses: actions/upload-artifact@v7 + with: + name: firmware-windows-${{ inputs.windows_ver }}-${{ steps.version.outputs.long }} + overwrite: true + path: | + .pio/build/native-windows/meshtasticd.exe diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index a00976973..5057b3263 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -144,6 +144,18 @@ jobs: macos_ver: ${{ matrix.macos_ver }} # secrets: inherit + Windows: + if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} + strategy: + fail-fast: false + matrix: + windows_ver: + - "2025" # x86_64 + uses: ./.github/workflows/build_windows_bin.yml + with: + windows_ver: ${{ matrix.windows_ver }} + # secrets: inherit + package-pio-deps-native-tft: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} uses: ./.github/workflows/package_pio_deps.yml diff --git a/extra_scripts/windows_link_flags.py b/extra_scripts/windows_link_flags.py new file mode 100644 index 000000000..b86322a0d --- /dev/null +++ b/extra_scripts/windows_link_flags.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +# +# PlatformIO routes build_flags to the compile step only, so the static link +# flags are appended here, as wasm_link_flags.py does for [env:native-wasm]. +Import("env") + +if env["PIOENV"].startswith("native-windows"): + env.Append( + LINKFLAGS=[ + "-static", + "-static-libgcc", + "-static-libstdc++", + ] + ) diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 400bfd1aa..94288529e 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -319,7 +319,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #else rtc.initI2C(); #endif - tm *t = gmtime(&tv->tv_sec); + // tv_sec is a long, which is not time_t everywhere: on Windows + // time_t is 64-bit while long is 32-bit. Copy before taking &. + time_t setSecs = tv->tv_sec; + tm *t = gmtime(&setSecs); rtc.setTime(t->tm_year + 1900, t->tm_mon + 1, t->tm_wday, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec); LOG_DEBUG("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); @@ -341,7 +344,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #else rtc.begin(Wire); #endif - tm *t = gmtime(&tv->tv_sec); + // tv_sec is a long, which is not time_t everywhere: on Windows + // time_t is 64-bit while long is 32-bit. Copy before taking &. + time_t setSecs = tv->tv_sec; + tm *t = gmtime(&setSecs); rtc.setDateTime(*t); LOG_DEBUG("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); @@ -355,7 +361,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #else ArtronShop_RX8130CE rtc(&Wire); #endif - tm *t = gmtime(&tv->tv_sec); + // tv_sec is a long, which is not time_t everywhere: on Windows + // time_t is 64-bit while long is 32-bit. Copy before taking &. + time_t setSecs = tv->tv_sec; + tm *t = gmtime(&setSecs); if (rtc.setTime(*t)) { LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); diff --git a/src/main.cpp b/src/main.cpp index 47d51dea3..ff23565b4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -428,10 +428,14 @@ void setup() #if ARCH_PORTDUINO RTCQuality ourQuality = RTCQualityDevice; +#ifdef __linux__ + // timedatectl is systemd-only, so macOS, Windows and WASM stay at + // RTCQualityDevice rather than claim NTP quality we have not verified. std::string timeCommandResult = exec("timedatectl status | grep synchronized | grep yes -c"); if (timeCommandResult[0] == '1') { ourQuality = RTCQualityNTP; } +#endif struct timeval tv; tv.tv_sec = time(NULL); diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index 940453053..43a3e0385 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -22,6 +22,12 @@ extern Adafruit_nRFCrypto nRFCrypto; #include #ifdef __linux__ #include // getrandom() +#elif defined(_WIN32) +// Order is load-bearing, hence the blank line: bcrypt.h uses LONG/ULONG from +// windows.h and does not include it itself. +#include + +#include // BCryptGenRandom() #else #include // arc4random_buf() on Darwin/BSD #endif @@ -128,6 +134,12 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) if (generated == static_cast(length)) { filled = true; } +#elif defined(_WIN32) + // No getrandom/arc4random on Windows; BCryptGenRandom is the documented CSPRNG. + // Preferred over std::random_device, whose libstdc++ Windows backend reports entropy() == 0. + if (BCryptGenRandom(NULL, buffer, static_cast(length), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0) { // STATUS_SUCCESS + filled = true; + } #elif defined(__EMSCRIPTEN__) // Browser/wasm: no getrandom/arc4random - fall through to std::random_device, // which emscripten backs with crypto.getRandomValues(). diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index f8ab7fc07..7e3d527ee 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -33,7 +33,11 @@ #include "IPAddress.h" #if defined(ARCH_PORTDUINO) +#if defined(_WIN32) +#include // ntohl() +#else #include +#endif #elif !defined(ntohl) #include #define ntohl __ntohl diff --git a/src/platform/portduino/GpsdSerial.cpp b/src/platform/portduino/GpsdSerial.cpp index 038ba21f6..1b80c7d06 100644 --- a/src/platform/portduino/GpsdSerial.cpp +++ b/src/platform/portduino/GpsdSerial.cpp @@ -3,13 +3,92 @@ #include "GpsdSerial.h" #include "configuration.h" -#include #include + +#ifdef _WIN32 +#include +#include +#include +#else +#include #include #include #include #include #include +#endif + +namespace +{ +// Winsock needs explicit init, closesocket(), ioctlsocket() and WSAGetLastError(). +// SOCKET fits the header's `int` on Win64, and INVALID_SOCKET narrows to -1. +#ifdef _WIN32 +// Done lazily to keep the dependency local to the one file that needs it. +void initSocketsOnce() +{ + static std::once_flag flag; + std::call_once(flag, [] { + WSADATA wsaData; + WSAStartup(MAKEWORD(2, 2), &wsaData); + }); +} + +void closeSocket(int fd) +{ + ::closesocket(static_cast(fd)); +} + +void setNonBlocking(int fd) +{ + u_long mode = 1; + ::ioctlsocket(static_cast(fd), FIONBIO, &mode); +} + +// Winsock has no MSG_DONTWAIT, but the socket is already non-blocking so a plain +// recv() has the same semantics. +int recvNonBlocking(int fd, void *buf, size_t len) +{ + return ::recv(static_cast(fd), static_cast(buf), static_cast(len), 0); +} + +int sendAll(int fd, const void *buf, size_t len) +{ + return ::send(static_cast(fd), static_cast(buf), static_cast(len), 0); +} + +bool lastErrorWasWouldBlock() +{ + return WSAGetLastError() == WSAEWOULDBLOCK; +} +#else +void initSocketsOnce() {} + +void closeSocket(int fd) +{ + ::close(fd); +} + +void setNonBlocking(int fd) +{ + ::fcntl(fd, F_SETFL, O_NONBLOCK); +} + +int recvNonBlocking(int fd, void *buf, size_t len) +{ + return static_cast(::recv(fd, buf, len, MSG_DONTWAIT)); +} + +int sendAll(int fd, const void *buf, size_t len) +{ + return static_cast(::write(fd, buf, len)); +} + +bool lastErrorWasWouldBlock() +{ + return errno == EAGAIN || errno == EWOULDBLOCK; +} +#endif +} // namespace namespace arduino { @@ -28,6 +107,8 @@ bool GpsdSerial::connectToGpsd() if (_host.empty()) return false; + initSocketsOnce(); + struct addrinfo hints = {}, *res = nullptr; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; @@ -41,12 +122,12 @@ bool GpsdSerial::connectToGpsd() // Try every address returned by getaddrinfo (e.g. ::1 before 127.0.0.1). int fd = -1; for (struct addrinfo *rp = res; rp != nullptr; rp = rp->ai_next) { - fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + fd = static_cast(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol)); if (fd < 0) continue; - if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) + if (connect(fd, rp->ai_addr, static_cast(rp->ai_addrlen)) == 0) break; // connected - ::close(fd); + closeSocket(fd); fd = -1; } freeaddrinfo(res); @@ -57,11 +138,11 @@ bool GpsdSerial::connectToGpsd() } // Switch to non-blocking so available()/read() never stall the GPS thread. - fcntl(fd, F_SETFL, O_NONBLOCK); + setNonBlocking(fd); // Ask gpsd to stream raw NMEA sentences. const char watchCmd[] = "?WATCH={\"enable\":true,\"nmea\":true}\n"; - ::write(fd, watchCmd, sizeof(watchCmd) - 1); + sendAll(fd, watchCmd, sizeof(watchCmd) - 1); _sockfd = fd; _rxBuf.clear(); @@ -80,7 +161,7 @@ void GpsdSerial::begin(unsigned long /*baud*/, uint16_t /*config*/) void GpsdSerial::end() { if (_sockfd >= 0) { - ::close(_sockfd); + closeSocket(_sockfd); _sockfd = -1; } _rxBuf.clear(); @@ -96,18 +177,18 @@ void GpsdSerial::fillBuffer() return; uint8_t tmp[256]; - ssize_t n; - while (_rxBuf.size() < RX_BUF_MAX && (n = recv(_sockfd, tmp, sizeof(tmp), MSG_DONTWAIT)) > 0) { + int n; + while (_rxBuf.size() < RX_BUF_MAX && (n = recvNonBlocking(_sockfd, tmp, sizeof(tmp))) > 0) { size_t space = RX_BUF_MAX - _rxBuf.size(); size_t toCopy = (static_cast(n) < space) ? static_cast(n) : space; for (size_t i = 0; i < toCopy; i++) _rxBuf.push_back(tmp[i]); } - if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) { + if (n == 0 || (n < 0 && !lastErrorWasWouldBlock())) { // gpsd closed the connection or a real error occurred. LOG_WARN("gpsdSerial: disconnected, will retry"); - ::close(_sockfd); + closeSocket(_sockfd); _sockfd = -1; _rxBuf.clear(); } diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 8da3d085b..c8e27eb8d 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -21,8 +21,12 @@ #include #include #include -#include #include +#ifndef _WIN32 +// Only the PORTDUINO_LINUX_HARDWARE block below calls ioctl() (HCIGETDEVINFO, +// for the BlueZ-derived MAC address); Windows has no . +#include +#endif #ifdef PORTDUINO_LINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" @@ -34,6 +38,12 @@ #include #endif +#ifdef _WIN32 +// Defined in WindowsMacAddr.cpp, which keeps out of this TU: it +// pulls in RPC/OLE headers that collide with the Arduino API. +bool portduinoWindowsPrimaryMac(uint8_t *dmac); +#endif + #ifdef __APPLE__ // Used by getMacAddr()'s macOS fallback to read the en0 link-layer address. // `getifaddrs()` is the BSD-portable way; `` provides the @@ -193,6 +203,10 @@ void getMacAddr(uint8_t *dmac) } freeifaddrs(ifap); } +#elif defined(_WIN32) + // No BlueZ on Windows; the host's primary adapter MAC is the equivalent + // stable identifier. On failure dmac is untouched and the blank-MAC check fires. + portduinoWindowsPrimaryMac(dmac); #else // No platform-specific MAC source; leave dmac at its default. Caller // can override via the --hwid CLI flag or the YAML config. @@ -292,7 +306,9 @@ void portduinoSetup() std::filesystem::directory_iterator{portduino_config.config_directory}) { if (ends_with(entry.path().string(), ".yaml")) { std::cout << "Also using " << entry << " as additional config file" << std::endl; - loadConfig(entry.path().c_str()); + // .string() rather than .c_str(): path::value_type is wchar_t on + // Windows, and loadConfig() takes a const char *. + loadConfig(entry.path().string().c_str()); } } } diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 9496b2ccb..2c00ed390 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -8,6 +8,7 @@ #include #include #include +#include // gettimeofday(), previously pulled in via libusb.h #include extern uint32_t rebootAtMsec; diff --git a/src/platform/portduino/WindowsMacAddr.cpp b/src/platform/portduino/WindowsMacAddr.cpp new file mode 100644 index 000000000..8200fdc1b --- /dev/null +++ b/src/platform/portduino/WindowsMacAddr.cpp @@ -0,0 +1,54 @@ +#if defined(ARCH_PORTDUINO) && defined(_WIN32) + +// Host-MAC lookup for getMacAddr(), replacing BlueZ on Linux and en0 on macOS. +// Isolated TU: needs the header trims the env sets, and undoes them here. +#undef WIN32_LEAN_AND_MEAN +#undef NOUSER +#undef NOGDI + +// Order is load-bearing, hence the blank lines: winsock2.h must precede +// windows.h, which would otherwise pull in the colliding winsock v1 header. +#include + +#include + +#include + +#include +#include +#include + +// Fill dmac with the first up, non-loopback adapter's physical address, else +// return false. Adapter order is stable across reboots, so the identity persists. +bool portduinoWindowsPrimaryMac(uint8_t *dmac) +{ + const ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER | GAA_FLAG_SKIP_FRIENDLY_NAME; + + ULONG bufLen = 15000; // starting size recommended by the API docs + std::unique_ptr buf(new char[bufLen]); + auto *addrs = reinterpret_cast(buf.get()); + + ULONG ret = GetAdaptersAddresses(AF_UNSPEC, flags, nullptr, addrs, &bufLen); + if (ret == ERROR_BUFFER_OVERFLOW) { + // bufLen now holds the required size; retry once. + buf.reset(new char[bufLen]); + addrs = reinterpret_cast(buf.get()); + ret = GetAdaptersAddresses(AF_UNSPEC, flags, nullptr, addrs, &bufLen); + } + if (ret != NO_ERROR) + return false; + + for (auto *a = addrs; a != nullptr; a = a->Next) { + if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) + continue; + if (a->OperStatus != IfOperStatusUp) + continue; + if (a->PhysicalAddressLength != 6) + continue; + std::memcpy(dmac, a->PhysicalAddress, 6); + return true; + } + return false; +} + +#endif // ARCH_PORTDUINO && _WIN32 diff --git a/src/platform/portduino/windows/include/libpinedio-usb.h b/src/platform/portduino/windows/include/libpinedio-usb.h new file mode 100644 index 000000000..3fe0e141a --- /dev/null +++ b/src/platform/portduino/windows/include/libpinedio-usb.h @@ -0,0 +1,75 @@ +// Windows drop-in for libch341-spi-userspace's public header, mirroring the wasm +// one in ../../wasm/include/. Same API as upstream, but backed by CH341DLL, not libusb. +#ifndef PINEDIO_USB_CH341DLL_H +#define PINEDIO_USB_CH341DLL_H +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +enum pinedio_int_pin { + PINEDIO_PIN_D0, + PINEDIO_PIN_D1, + PINEDIO_PIN_D2, + PINEDIO_PIN_D3, + PINEDIO_PIN_D4, + PINEDIO_PIN_D5, + PINEDIO_PIN_D6, + PINEDIO_PIN_D7, + PINEDIO_PIN_ERR, + PINEDIO_PIN_PEMP, + PINEDIO_PIN_INT, + PINEDIO_INT_PIN_MAX +}; + +enum pinedio_int_mode { + PINEDIO_INT_MODE_RISING = 0x01, + PINEDIO_INT_MODE_FALLING = 0x02, +}; + +enum pinedio_option { + PINEDIO_OPTION_AUTO_CS, + PINEDIO_OPTION_SEARCH_SERIAL, + PINEDIO_OPTION_VID, + PINEDIO_OPTION_PID, + PINEDIO_OPTION_MAX +}; + +struct pinedio_inst_int { + uint8_t previous_state; + enum pinedio_int_mode mode; + void (*callback)(void); +}; + +// Ch341Hal embeds this by value and touches in_error, serial_number, +// product_string and options[], so those must keep their names. +struct pinedio_inst { + bool in_error; + struct pinedio_inst_int interrupts[PINEDIO_INT_PIN_MAX]; + uint32_t options[PINEDIO_OPTION_MAX]; + char serial_number[9]; + char product_string[97]; +}; + +typedef struct pinedio_inst pinedio_inst; + +int32_t pinedio_init(struct pinedio_inst *inst, void *driver); +int32_t pinedio_set_option(struct pinedio_inst *inst, enum pinedio_option option, uint32_t value); +int32_t pinedio_set_pin_mode(struct pinedio_inst *inst, uint32_t pin, uint32_t mode); +int32_t pinedio_digital_write(struct pinedio_inst *inst, uint32_t pin, bool active); +int32_t pinedio_set_cs(struct pinedio_inst *inst, bool active); +int32_t pinedio_write_read(struct pinedio_inst *inst, uint8_t *writearr, uint32_t writecnt, uint8_t *readarr, uint32_t readcnt); +int32_t pinedio_transceive(struct pinedio_inst *inst, uint8_t *write_buf, uint8_t *read_buf, uint32_t count); +int32_t pinedio_digital_read(struct pinedio_inst *inst, uint32_t pin); +int32_t pinedio_get_irq_state(struct pinedio_inst *inst, uint32_t pin); +int32_t pinedio_attach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin, enum pinedio_int_mode int_mode, + void (*callback)(void)); +int32_t pinedio_deattach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin); +void pinedio_deinit(struct pinedio_inst *inst); + +#ifdef __cplusplus +} +#endif +#endif // PINEDIO_USB_CH341DLL_H diff --git a/src/platform/portduino/windows/libpinedio_ch341dll.c b/src/platform/portduino/windows/libpinedio_ch341dll.c new file mode 100644 index 000000000..2f0b8e370 --- /dev/null +++ b/src/platform/portduino/windows/libpinedio_ch341dll.c @@ -0,0 +1,400 @@ +// libpinedio API over WCH's CH341DLL, replacing the libusb backend on Windows: +// libusb would need Zadig to rebind the driver, CH341DLL ships with CH341PAR. + +#if defined(ARCH_PORTDUINO) && defined(_WIN32) + +#include "libpinedio-usb.h" + +#include +#include +#include +#include +#include + +// Pins are the CH341's D0-D7: CS 0, Reset 2, SCK 3, MOSI 5, IRQ 6, MISO 7. +// CH341Set_D5_D0 reaches only the outputs; D6/D7 are read via CH341GetInput. +#define CH341_MAX_OUTPUT_PIN 5 + +// iMode bit 7: SPI bit order, 1 = MSB first. The SX1262 is MSB-first, and doing +// it in hardware avoids upstream's per-byte reverse_byte() over the whole buffer. +#define CH341_STREAM_MODE_SPI_MSB_FIRST 0x80 + +// Matches upstream's poll interval, so IRQ latency is the same as on Linux. +#define PIN_POLL_INTERVAL_MS (1000 / 30) + +typedef HANDLE(WINAPI *ch341_open_t)(ULONG); +typedef VOID(WINAPI *ch341_close_t)(ULONG); +typedef BOOL(WINAPI *ch341_set_stream_t)(ULONG, ULONG); +typedef BOOL(WINAPI *ch341_stream_spi4_t)(ULONG, ULONG, ULONG, PVOID); +typedef BOOL(WINAPI *ch341_set_d5_d0_t)(ULONG, ULONG, ULONG); +typedef BOOL(WINAPI *ch341_get_input_t)(ULONG, PULONG); +typedef PVOID(WINAPI *ch341_get_device_name_t)(ULONG); + +static struct { + HMODULE dll; + ch341_open_t open; + ch341_close_t close; + ch341_set_stream_t set_stream; + ch341_stream_spi4_t stream_spi4; + ch341_set_d5_d0_t set_d5_d0; + ch341_get_input_t get_input; + ch341_get_device_name_t get_device_name; +} ch341; + +// Single adapter, matching Ch341Hal's spiChannel of 0. Multiple sticks would +// need this and the device index threaded through pinedio_inst. +static ULONG ch341_index = 0; + +// D0-D5 direction and output state, applied together by CH341Set_D5_D0. +static ULONG pin_dir_out = 0; +static ULONG pin_state = 0; + +// Serializes DLL access between the caller and the poll thread. +static pthread_mutex_t usb_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_t poll_thread; +static volatile bool poll_thread_exit = false; +static int int_running_cnt = 0; + +// CH341PAR installs the 64-bit library as CH341DLLA64.DLL and the 32-bit one as +// CH341DLL.DLL, so try the name matching this process first. +static const char *const ch341_dll_names[] = { +#ifdef _WIN64 + "CH341DLLA64.DLL", + "CH341DLL.DLL", +#else + "CH341DLL.DLL", + "CH341DLLA64.DLL", +#endif +}; + +// The DLL is not redistributable, so it is resolved at runtime: without it +// pinedio_init() fails and Ch341Hal throws, as it does for a missing device. +static bool load_dll(void) +{ + if (ch341.dll) + return true; + for (size_t i = 0; i < sizeof(ch341_dll_names) / sizeof(ch341_dll_names[0]); i++) { + ch341.dll = LoadLibraryA(ch341_dll_names[i]); + if (ch341.dll) + break; + } + if (!ch341.dll) { + fprintf(stderr, "CH341DLL not found; install the WCH CH341PAR driver\n"); + return false; + } + ch341.open = (ch341_open_t)(void *)GetProcAddress(ch341.dll, "CH341OpenDevice"); + ch341.close = (ch341_close_t)(void *)GetProcAddress(ch341.dll, "CH341CloseDevice"); + ch341.set_stream = (ch341_set_stream_t)(void *)GetProcAddress(ch341.dll, "CH341SetStream"); + ch341.stream_spi4 = (ch341_stream_spi4_t)(void *)GetProcAddress(ch341.dll, "CH341StreamSPI4"); + ch341.set_d5_d0 = (ch341_set_d5_d0_t)(void *)GetProcAddress(ch341.dll, "CH341Set_D5_D0"); + ch341.get_input = (ch341_get_input_t)(void *)GetProcAddress(ch341.dll, "CH341GetInput"); + ch341.get_device_name = (ch341_get_device_name_t)(void *)GetProcAddress(ch341.dll, "CH341GetDeviceName"); + + if (!ch341.open || !ch341.close || !ch341.set_stream || !ch341.stream_spi4 || !ch341.set_d5_d0 || !ch341.get_input) { + fprintf(stderr, "CH341DLL is missing expected exports\n"); + FreeLibrary(ch341.dll); + ch341.dll = NULL; + return false; + } + return true; +} + +static int32_t apply_pins(void) +{ + if (!ch341.set_d5_d0(ch341_index, pin_dir_out, pin_state)) + return -1; + return 0; +} + +int32_t pinedio_set_option(struct pinedio_inst *inst, enum pinedio_option option, uint32_t value) +{ + if (option < PINEDIO_OPTION_MAX) + inst->options[option] = value; + return 0; +} + +int32_t pinedio_init(struct pinedio_inst *inst, void *driver) +{ + (void)driver; + inst->in_error = false; + for (int i = 0; i < PINEDIO_INT_PIN_MAX; i++) + inst->interrupts[i].callback = NULL; + + if (!load_dll()) + return -1; + + if (ch341.open(ch341_index) == INVALID_HANDLE_VALUE) { + fprintf(stderr, "CH341OpenDevice(%lu) failed; is the adapter plugged in?\n", ch341_index); + return -2; + } + + // Default speed (bits 0-1 = 01) plus MSB-first. + if (!ch341.set_stream(ch341_index, 0x01 | CH341_STREAM_MODE_SPI_MSB_FIRST)) { + fprintf(stderr, "CH341SetStream failed\n"); + ch341.close(ch341_index); + return -3; + } + + pin_dir_out = 0; + pin_state = 0; + + // CH341DLL exposes no USB serial string. Leaving it empty is safe: on Windows + // getMacAddr() uses the host adapter, and the device name stands in for the product. + inst->serial_number[0] = '\0'; + inst->product_string[0] = '\0'; + if (ch341.get_device_name) { + const char *name = (const char *)ch341.get_device_name(ch341_index); + if (name) { + strncpy(inst->product_string, name, sizeof(inst->product_string) - 1); + inst->product_string[sizeof(inst->product_string) - 1] = '\0'; + } + } + return 0; +} + +int32_t pinedio_set_pin_mode(struct pinedio_inst *inst, uint32_t pin, uint32_t mode) +{ + (void)inst; + if (pin > CH341_MAX_OUTPUT_PIN) + return 0; // D6/D7 are input-only; nothing to configure + + // Under the lock: the poll thread's callback can drive GPIO concurrently. + pthread_mutex_lock(&usb_mutex); + if (mode) + pin_dir_out |= (1u << pin); + else + pin_dir_out &= ~(1u << pin); + pthread_mutex_unlock(&usb_mutex); + // Upstream defers the device write to the next digital_write; do the same so + // the direction and level land together. + return 0; +} + +int32_t pinedio_digital_write(struct pinedio_inst *inst, uint32_t pin, bool active) +{ + if (pin > CH341_MAX_OUTPUT_PIN) + return -1; + + // Mutate and apply as one critical section, or a concurrent write loses its + // update when apply_pins() reads a half-updated pin_state. + pthread_mutex_lock(&usb_mutex); + if (active) + pin_state |= (1u << pin); + else + pin_state &= ~(1u << pin); + int32_t ret = apply_pins(); + pthread_mutex_unlock(&usb_mutex); + if (ret < 0) + inst->in_error = true; + return ret; +} + +int32_t pinedio_set_cs(struct pinedio_inst *inst, bool active) +{ + return pinedio_digital_write(inst, 0, active); // D0 is CS +} + +static int32_t read_input(uint32_t *out) +{ + ULONG status = 0; + if (!ch341.get_input(ch341_index, &status)) + return -1; + *out = (uint32_t)status; + return 0; +} + +int32_t pinedio_digital_read(struct pinedio_inst *inst, uint32_t pin) +{ + uint32_t status = 0; + pthread_mutex_lock(&usb_mutex); + int32_t ret = read_input(&status); + pthread_mutex_unlock(&usb_mutex); + if (ret < 0) { + inst->in_error = true; + return ret; + } + return (status & (1u << pin)) ? 1 : 0; // bits 7-0 are D7-D0 +} + +int32_t pinedio_get_irq_state(struct pinedio_inst *inst, uint32_t pin) +{ + return pinedio_digital_read(inst, pin); +} + +int32_t pinedio_transceive(struct pinedio_inst *inst, uint8_t *write_buf, uint8_t *read_buf, uint32_t count) +{ + if (count == 0) + return 0; + + // CH341StreamSPI4 is full duplex over one in/out buffer. + uint8_t stack_buf[64]; + uint8_t *buf = count <= sizeof(stack_buf) ? stack_buf : (uint8_t *)malloc(count); + if (!buf) + return -1; + memcpy(buf, write_buf, count); + + // Bit 7 clear: leave chip select alone. Ch341Hal sets PINEDIO_OPTION_AUTO_CS + // to 0 and lets RadioLib drive NSS through digitalWrite. + ULONG cs = 0; + if (inst->options[PINEDIO_OPTION_AUTO_CS]) + cs = 0x80; // enable, D0 active low + + pthread_mutex_lock(&usb_mutex); + BOOL ok = ch341.stream_spi4(ch341_index, cs, count, buf); + pthread_mutex_unlock(&usb_mutex); + + if (ok && read_buf) + memcpy(read_buf, buf, count); + if (buf != stack_buf) + free(buf); + + if (!ok) { + inst->in_error = true; + return -1; + } + return 0; +} + +int32_t pinedio_write_read(struct pinedio_inst *inst, uint8_t *writearr, uint32_t writecnt, uint8_t *readarr, uint32_t readcnt) +{ + uint32_t total = writecnt + readcnt; + uint8_t stack_buf[64]; + uint8_t *buf = total <= sizeof(stack_buf) ? stack_buf : (uint8_t *)malloc(total); + if (!buf) + return -1; + memcpy(buf, writearr, writecnt); + memset(buf + writecnt, 0, readcnt); + + int32_t ret = pinedio_transceive(inst, buf, buf, total); + if (ret == 0) + memcpy(readarr, buf + writecnt, readcnt); + if (buf != stack_buf) + free(buf); + return ret; +} + +// CH341SetIntRoutine only fires on the INT# pin, but the adapter wires DIO1 to +// D6, so poll D6 instead, at upstream's rate and edge semantics. +static void *pin_poll_thread_fn(void *arg) +{ + struct pinedio_inst *inst = (struct pinedio_inst *)arg; + bool should_exit = false; + + while (!should_exit) { + uint32_t input = 0; + pthread_mutex_lock(&usb_mutex); + int32_t ret = read_input(&input); + pthread_mutex_unlock(&usb_mutex); + if (ret < 0) { + inst->in_error = true; + fprintf(stderr, "CH341 poll: failed to read input\n"); + break; + } + inst->in_error = false; + + pthread_mutex_lock(&usb_mutex); + for (uint8_t int_pin = 0; int_pin < PINEDIO_INT_PIN_MAX; int_pin++) { + struct pinedio_inst_int *inst_int = &inst->interrupts[int_pin]; + // Copy under the lock: pinedio_deattach_interrupt() could NULL it + // once we drop the lock to make the call. + void (*cb)(void) = inst_int->callback; + if (cb == NULL) + continue; + uint8_t state = (input & (1u << int_pin)) != 0; + if (inst_int->previous_state != 255 && inst_int->previous_state != state) { + enum pinedio_int_mode mode = + (!inst_int->previous_state && state) ? PINEDIO_INT_MODE_RISING : PINEDIO_INT_MODE_FALLING; + if (inst_int->mode & mode) { + // Callback re-enters this library, so drop the lock first. + pthread_mutex_unlock(&usb_mutex); + cb(); + pthread_mutex_lock(&usb_mutex); + } + } + inst_int->previous_state = state; + } + should_exit = poll_thread_exit; + pthread_mutex_unlock(&usb_mutex); + Sleep(PIN_POLL_INTERVAL_MS); + } + return NULL; +} + +int32_t pinedio_attach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin, enum pinedio_int_mode int_mode, + void (*callback)(void)) +{ + if (int_pin >= PINEDIO_INT_PIN_MAX) + return -1; + + int32_t res = 0; + pthread_mutex_lock(&usb_mutex); + bool was_attached = inst->interrupts[int_pin].callback != NULL; + inst->interrupts[int_pin].previous_state = 255; + inst->interrupts[int_pin].mode = int_mode; + inst->interrupts[int_pin].callback = callback; + + // Only a new attachment changes the refcount. Re-attaching an armed pin + // (RadioLib does this) must not reach 0 and spawn a second poll thread. + if (!was_attached) { + if (int_running_cnt == 0) { + poll_thread_exit = false; + res = pthread_create(&poll_thread, NULL, pin_poll_thread_fn, inst); + if (res != 0) { + fprintf(stderr, "CH341: failed to start poll thread: %d\n", res); + inst->interrupts[int_pin].callback = NULL; + pthread_mutex_unlock(&usb_mutex); + return res; + } + } + int_running_cnt++; + } + pthread_mutex_unlock(&usb_mutex); + return res; +} + +int32_t pinedio_deattach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin) +{ + if (int_pin >= PINEDIO_INT_PIN_MAX) + return -1; + + pthread_t thread_to_join; + pthread_mutex_lock(&usb_mutex); + bool was_attached = inst->interrupts[int_pin].callback != NULL; + inst->interrupts[int_pin].callback = NULL; + if (was_attached) + int_running_cnt--; + bool stop = was_attached && int_running_cnt == 0; + if (stop) { + poll_thread_exit = true; + // Copy the handle: a concurrent attach could overwrite poll_thread once + // the lock is dropped, and we would join the wrong thread. + thread_to_join = poll_thread; + } + pthread_mutex_unlock(&usb_mutex); + + // Joining under the lock would deadlock against the poll thread taking it. + if (stop && !pthread_equal(thread_to_join, pthread_self())) + pthread_join(thread_to_join, NULL); + return 0; +} + +void pinedio_deinit(struct pinedio_inst *inst) +{ + pthread_t thread_to_join; + pthread_mutex_lock(&usb_mutex); + bool stop = int_running_cnt > 0; + poll_thread_exit = true; + int_running_cnt = 0; + if (stop) + thread_to_join = poll_thread; // copy before dropping the lock, as above + pthread_mutex_unlock(&usb_mutex); + + if (stop && !pthread_equal(thread_to_join, pthread_self())) + pthread_join(thread_to_join, NULL); + + if (ch341.dll) + ch341.close(ch341_index); + inst->in_error = false; +} + +#endif // ARCH_PORTDUINO && _WIN32 diff --git a/src/power/PowerHAL.cpp b/src/power/PowerHAL.cpp index 0a8d5f10b..bc6af4143 100644 --- a/src/power/PowerHAL.cpp +++ b/src/power/PowerHAL.cpp @@ -1,19 +1,27 @@ #include "PowerHAL.h" +// PE/COFF has no ELF-style weak definitions, so a weak default is an undefined +// reference on Windows. Only nrf52 and nrf54l15 override these; define them strongly. +#ifdef _WIN32 +#define POWERHAL_WEAK_DEFAULT __attribute__((noinline)) +#else +#define POWERHAL_WEAK_DEFAULT __attribute__((weak, noinline)) +#endif + void powerHAL_init() { return powerHAL_platformInit(); } -__attribute__((weak, noinline)) void powerHAL_platformInit() {} +POWERHAL_WEAK_DEFAULT void powerHAL_platformInit() {} -__attribute__((weak, noinline)) bool powerHAL_isPowerLevelSafe() +POWERHAL_WEAK_DEFAULT bool powerHAL_isPowerLevelSafe() { return true; } -__attribute__((weak, noinline)) bool powerHAL_isVBUSConnected() +POWERHAL_WEAK_DEFAULT bool powerHAL_isVBUSConnected() { return false; } diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 083b85518..8ea17653a 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -27,8 +27,8 @@ lib_deps = https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX lovyan03/LovyanGFX@1.2.25 - ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main - https://github.com/pine64/libch341-spi-userspace/archive/2e5ff751d0c39667993df672cb683740ed5c9394.zip + ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/meshtastic/libch341-spi-userspace gitBranch=main + https://github.com/meshtastic/libch341-spi-userspace/archive/03bf505d6e5904092c1c389c45b01098f7a302fe.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library adafruit/Adafruit seesaw Library@1.7.9 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 1c9a9a161..22ac4ff5f 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -251,6 +251,83 @@ build_flags = ${env:native-macos.build_flags} build_src_filter = ${env:native-macos.build_src_filter} lib_ignore = ${env:native-macos.lib_ignore} +; --------------------------------------------------------------------------- +; Native build for Windows (x86_64) via the MSYS2 UCRT64 MinGW-w64 toolchain. +; Headless meshtasticd.exe running in SimRadio mode (`-s`). No BlueZ, libgpiod or +; Linux I2C, and no UDP multicast: the framework's AsyncUDP.cpp is BSD sockets, +; so HAS_UDP_MULTICAST stays unset here as it does on macOS. +; +; MSVC is not an option: platform-native's builder calls env.Tool("gcc") and the +; firmware builds as gnu17/gnu++17 with GNU extensions throughout. +; +; Prerequisites (MSYS2, https://www.msys2.org/): +; pacman -S --needed mingw-w64-ucrt-x86_64-{gcc,pkgconf,yaml-cpp,libuv,jsoncpp,openssl,libusb} +; +; argp is not packaged for MSYS2's mingw environments (msys/libargp links the +; msys-2.0.dll emulation layer and can't be used for a native binary), yet +; Arduino.h includes and main.cpp calls argp_parse(). Build it once from +; source, the same dependency macOS meets with `brew install argp-standalone`: +; git clone https://github.com/tom42/argp-standalone +; cd argp-standalone && cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release . +; cmake --build build +; cp include/argp-standalone/argp.h /ucrt64/include/argp.h ; ships no install() rules +; cp build/src/libargp-standalone.a /ucrt64/lib/libargp.a +; +; Build from any shell with /ucrt64/bin on PATH: +; pio run -e native-windows +; .pio/build/native-windows/meshtasticd.exe -s +; --------------------------------------------------------------------------- +[env:native-windows] +extends = native_base +build_flags = ${portduino_base.build_flags_common} + -I variants/native/portduino + ; Our drop-in libpinedio-usb.h, replacing the libusb one: libusb can only reach + ; a device bound to WinUSB, which means Zadig on every machine. See + ; src/platform/portduino/windows/libpinedio_ch341dll.c. + -I src/platform/portduino/windows/include + -largp + -lws2_32 ; GpsdSerial.cpp's TCP client + -lbcrypt ; BCryptGenRandom() in HardwareRNG.cpp + -liphlpapi ; GetAdaptersAddresses() host-MAC fallback in PortduinoGlue.cpp + ; libch341's libpinedio-usb.h pulls in libusb.h and so , which + ; collides with the Arduino API: winuser.h's `typedef struct tagINPUT INPUT` vs + ; the PinMode enumerator, and rpcndr.h's `typedef unsigned char boolean` vs + ; Arduino's `typedef bool boolean`. NOUSER and WIN32_LEAN_AND_MEAN keep those + ; headers out, NOMINMAX stops min/max being macroed over std::min/std::max. + -DWIN32_LEAN_AND_MEAN + -DNOMINMAX + -DNOUSER + -DNOGDI + ; yaml-cpp declares its API __declspec(dllimport) unless told the link is + ; static, leaving every YAML symbol undefined as __imp_*. + -DYAML_CPP_STATIC_DEFINE + ; Headless: variant.h would otherwise default HAS_SCREEN to 1 and pull in the + ; screen renderer; EXCLUDE_SCREEN gates the `screen->...` hooks in the sensors. + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 + !pkg-config --cflags --libs openssl --silence-errors || : +build_unflags = + -fPIC ; ignored on Windows, where all code is position-independent +; Static link, so meshtasticd.exe stands alone and can't be hijacked by a stray +; System32 DLL. PlatformIO only feeds build_flags to the compile step, hence the script. +extra_scripts = + ${env.extra_scripts} + post:extra_scripts/windows_link_flags.py +; LinuxInput drives evdev; Panel_sdl/TFTDisplay pull LovyanGFX. Neither is needed +; for the headless build. +build_src_filter = ${native_base.build_src_filter} + - + - + - + - +; LovyanGFX includes and is only needed by the TFT variants. The pine64 +; libch341 is the libusb backend that libpinedio_ch341dll.c replaces; keeping both +; would duplicate every pinedio_* symbol. +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX + Pine libch341-spi Userspace library + ; --------------------------------------------------------------------------- ; WASM (Emscripten) - the portduino node compiled to WebAssembly, driving a real ; LoRa radio over WebUSB through a CH341 (src/platform/portduino/wasm/). The same