From d7db0f5829876e22d7fcf0640566d0f40d87ec24 Mon Sep 17 00:00:00 2001 From: Quency-D <55523105+Quency-D@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:25:19 +0800 Subject: [PATCH 01/34] add heltec-v4-r8 board (#10268) * add heltec-v4-r8 board * Fixed default SPI pin and macro definition errors. * update platformio.ini according device-ui LGFX display definitions Co-authored-by: Copilot * fix commit reference --------- Co-authored-by: Ben Meadors Co-authored-by: mverch67 Co-authored-by: Copilot Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com> --- boards/heltec_v4_r8.json | 43 ++++++ src/configuration.h | 3 + src/graphics/TFTDisplay.cpp | 19 ++- src/mesh/NodeDB.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- variants/esp32s3/heltec_v4_r8/pins_arduino.h | 56 +++++++ variants/esp32s3/heltec_v4_r8/platformio.ini | 145 +++++++++++++++++++ variants/esp32s3/heltec_v4_r8/variant.h | 72 +++++++++ 8 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 boards/heltec_v4_r8.json create mode 100644 variants/esp32s3/heltec_v4_r8/pins_arduino.h create mode 100644 variants/esp32s3/heltec_v4_r8/platformio.ini create mode 100644 variants/esp32s3/heltec_v4_r8/variant.h diff --git a/boards/heltec_v4_r8.json b/boards/heltec_v4_r8.json new file mode 100644 index 000000000..6dd97c84b --- /dev/null +++ b/boards/heltec_v4_r8.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "heltec_v4_r8" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino", "espidf"], + "name": "heltec_wifi_lora_32 v4 r8 (16 MB FLASH, 8 MB PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://heltec.org/", + "vendor": "heltec" +} diff --git a/src/configuration.h b/src/configuration.h index 0ce28ed28..2c084174d 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -157,6 +157,9 @@ along with this program. If not, see . #elif defined(HELTEC_MESH_NODE_T096) #define NUM_PA_POINTS 22 #define TX_GAIN_LORA 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 14, 13, 13, 13, 12, 11, 10, 9, 8, 7 +#elif defined(HELTEC_V4_R8) +#define NUM_PA_POINTS 22 +#define TX_GAIN_LORA 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7 #else // If a board enables USE_KCT8103L_PA but does not match a known variant and has // not already provided a PA curve, fail at compile time to avoid unsafe defaults. diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 005ead292..b69d79483 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -428,7 +428,7 @@ static LGFX *tft = nullptr; #elif defined(ST7789_CS) #include // Graphics and font library for ST7735 driver chip -#ifdef HELTEC_V4_TFT +#if defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) #include "chsc6x.h" #include "lgfx/v1/Touch.hpp" namespace lgfx @@ -450,7 +450,11 @@ class TOUCH_CHSC6X : public ITouch bool init(void) override { if (chsc6xTouch == nullptr) { +#if (TOUCH_I2C_PORT == 1) chsc6xTouch = new chsc6x(&Wire1, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#else + chsc6xTouch = new chsc6x(&Wire, TOUCH_SDA_PIN, TOUCH_SCL_PIN, TOUCH_INT_PIN, TOUCH_RST_PIN); +#endif } chsc6xTouch->chsc6x_init(); return true; @@ -487,7 +491,7 @@ class LGFX : public lgfx::LGFX_Device #if HAS_TOUCHSCREEN #if defined(T_WATCH_S3) || defined(ELECROW) lgfx::Touch_FT5x06 _touch_instance; -#elif defined(HELTEC_V4_TFT) +#elif defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) lgfx::TOUCH_CHSC6X _touch_instance; #else lgfx::Touch_GT911 _touch_instance; @@ -506,7 +510,11 @@ class LGFX : public lgfx::LGFX_Device cfg.freq_write = SPI_FREQUENCY; // SPI clock for transmission (up to 80MHz, rounded to the value obtained by dividing // 80MHz by an integer) cfg.freq_read = SPI_READ_FREQUENCY; // SPI clock when receiving - cfg.spi_3wire = false; +#ifdef SPI_3_WIRE + cfg.spi_3wire = SPI_3_WIRE; +#else + cfg.spi_3wire = true; // Set to true if reception is done on the MOSI pin +#endif cfg.use_lock = true; // Set to true to use transaction locking cfg.dma_channel = SPI_DMA_CH_AUTO; // SPI_DMA_CH_AUTO; // Set DMA channel to use (0=not use DMA / 1=1ch / 2=ch / // SPI_DMA_CH_AUTO=auto setting) @@ -556,8 +564,11 @@ class LGFX : public lgfx::LGFX_Device cfg.rgb_order = false; // Set to true if the panel's red and blue are swapped cfg.dlen_16bit = false; // Set to true for panels that transmit data length in 16-bit units with 16-bit parallel or SPI +#if defined(HAS_SDCARD) cfg.bus_shared = true; // If the bus is shared with the SD card, set to true (bus control with drawJpgFile etc.) - +#else + cfg.bus_shared = false; +#endif // Set the following only when the display is shifted with a driver with a variable number of pixels, such as the // ST7735 or ILI9163. // cfg.memory_width = TFT_WIDTH; // Maximum width supported by the driver IC diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 33500830d..4b79882bd 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -688,7 +688,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32); #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ - defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT)) && \ + defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 5a5004a45..103cac941 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -89,8 +89,10 @@ build_flags = -D VIEW_240x320 -D DISPLAY_SET_RESOLUTION -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=true -D LGFX_PIN_SCK=17 -D LGFX_PIN_MOSI=33 + -D LGFX_PIN_MISO=-1 -D LGFX_PIN_DC=16 -D LGFX_PIN_CS=15 -D LGFX_PIN_BL=21 @@ -123,7 +125,7 @@ build_flags = -D SCREEN_TRANSITION_FRAMERATE=5 -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness -D HAS_TOUCHSCREEN=1 - -D TOUCH_I2C_PORT=0 + -D TOUCH_I2C_PORT=1 -D TOUCH_SLAVE_ADDRESS=0x2E -D SCREEN_TOUCH_INT=TOUCH_INT_PIN -D SCREEN_TOUCH_RST=TOUCH_RST_PIN diff --git a/variants/esp32s3/heltec_v4_r8/pins_arduino.h b/variants/esp32s3/heltec_v4_r8/pins_arduino.h new file mode 100644 index 000000000..631f07513 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/pins_arduino.h @@ -0,0 +1,56 @@ +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini new file mode 100644 index 000000000..747cc8c49 --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -0,0 +1,145 @@ +[heltec_v4_r8_base] +extends = esp32s3_base +board = heltec_v4_r8 +board_check = true +board_build.partitions = default_16MB.csv +build_flags = + ${esp32s3_base.build_flags} + -D HELTEC_V4_R8 + -D HAS_LORA_FEM=1 + -D BOARD_HAS_PSRAM + -I variants/esp32s3/heltec_v4_r8 + -ULED_BUILTIN + +[env:heltec-v4-r8-oled] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 +custom_meshtastic_images = heltec_v4_r8.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} + -D HELTEC_V4_R8_OLED + -D USE_SSD1306 ; Heltec_v4_R8 has an SSD1315 display (compatible with SSD1306 driver) + -D LED_POWER=46 + -D RESET_OLED=21 + -D I2C_SDA=17 + -D I2C_SCL=18 + +[env:heltec-v4-r8-tft] +custom_meshtastic_hw_model = 132 +custom_meshtastic_hw_model_slug = HELTEC_V4_R8 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Heltec V4 R8 TFT +custom_meshtastic_images = heltec_v4_r8_tft.svg +custom_meshtastic_tags = Heltec +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = heltec_v4_r8_base +build_flags = + ${heltec_v4_r8_base.build_flags} ;-Os + -D HELTEC_V4_R8_TFT + -D I2C_SDA=17 + -D I2C_SCL=18 + -D PIN_BUTTON2=46 + -D ALT_BUTTON_PIN=PIN_BUTTON2 + -D ALT_BUTTON_ACTIVE_LOW=false + -D PIN_BUZZER=4 + -D USE_PIN_BUZZER=PIN_BUZZER + -D CONFIG_ARDUHAL_LOG_COLORS + -D RADIOLIB_DEBUG_SPI=0 + -D RADIOLIB_DEBUG_PROTOCOL=0 + -D RADIOLIB_DEBUG_BASIC=0 + -D RADIOLIB_VERBOSE_ASSERT=0 + -D RADIOLIB_SPI_PARANOID=0 + -D CONFIG_DISABLE_HAL_LOCKS=1 + -D INPUTDRIVER_BUTTON_TYPE=0 + -D HAS_SCREEN=1 + -D HAS_TFT=1 + -D RAM_SIZE=5120 + -D LV_LVGL_H_INCLUDE_SIMPLE + -D LV_CONF_INCLUDE_SIMPLE + -D LV_COMP_CONF_INCLUDE_SIMPLE + -D LV_USE_SYSMON=0 + -D LV_USE_PROFILER=0 + -D LV_USE_PERF_MONITOR=0 + -D LV_USE_MEM_MONITOR=0 + -D LV_USE_LOG=0 + -D LV_BUILD_TEST=0 + -D USE_LOG_DEBUG + -D LOG_DEBUG_INC=\"DebugConfiguration.h\" + -D USE_PACKET_API + -D LGFX_DRIVER=LGFX_HELTEC_V4_TFT + -D GFX_DRIVER_INC=\"graphics/LGFX/LGFX_HELTEC_V4_TFT.h\" + -D VIEW_240x320 + -D DISPLAY_SET_RESOLUTION + -D DISPLAY_SIZE=240x320 ; portrait mode + -D LGFX_SPI_3WIRE=false + -D LGFX_PIN_SCK=16 + -D LGFX_PIN_MOSI=15 + -D LGFX_PIN_MISO=45 + -D LGFX_PIN_DC=48 + -D LGFX_PIN_CS=47 + -D LGFX_PIN_BL=44 + -D LGFX_PIN_RST=21 + -D CUSTOM_TOUCH_DRIVER + -D TOUCH_SDA_PIN=I2C_SDA + -D TOUCH_SCL_PIN=I2C_SCL + -D TOUCH_INT_PIN=-1 + -D TOUCH_RST_PIN=-1 +;base UI + -D TFT_CS=LGFX_PIN_CS + -D ST7789_CS=TFT_CS + -D ST7789_RS=LGFX_PIN_DC + -D ST7789_SDA=LGFX_PIN_MOSI + -D ST7789_SCK=LGFX_PIN_SCK + -D ST7789_RESET=LGFX_PIN_RST + -D ST7789_MISO=LGFX_PIN_MISO + -D ST7789_BUSY=-1 + -D ST7789_BL=LGFX_PIN_BL + -D ST7789_SPI_HOST=SPI3_HOST + -D TFT_BL=ST7789_BL + -D SPI_FREQUENCY=75000000 + -D SPI_READ_FREQUENCY=SPI_FREQUENCY + -D SPI_3_WIRE=false + -D TFT_HEIGHT=320 + -D TFT_WIDTH=240 + -D TFT_OFFSET_X=0 + -D TFT_OFFSET_Y=0 + -D TFT_OFFSET_ROTATION=0 + -D SCREEN_ROTATE + -D SCREEN_TRANSITION_FRAMERATE=5 + -D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness + -D HAS_TOUCHSCREEN=1 + -D TOUCH_I2C_PORT=0 + -D TOUCH_SLAVE_ADDRESS=0x2E + -D SCREEN_TOUCH_INT=TOUCH_INT_PIN + -D SCREEN_TOUCH_RST=TOUCH_RST_PIN + +; Have SPI interface SD card slot + -D HAS_SDCARD + -D SDCARD_USE_SPI1 + -D SDCARD_USER_SPI_BEGIN + -D SPI_MOSI=LGFX_PIN_MOSI + -D SPI_SCK=LGFX_PIN_SCK + -D SPI_MISO=LGFX_PIN_MISO + -D SPI_CS=3 + -D SDCARD_CS=SPI_CS + -D SD_SPI_FREQUENCY=SPI_FREQUENCY + +lib_deps = ${heltec_v4_r8_base.lib_deps} + ${device-ui_base.lib_deps} + # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX + lovyan03/LovyanGFX@1.2.19 + # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master + https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h new file mode 100644 index 000000000..1f638f24c --- /dev/null +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -0,0 +1,72 @@ +#define VEXT_ENABLE 40 // active low, powers the oled display and the lora antenna boost +#define VEXT_ON_VALUE LOW +#define BUTTON_PIN 0 + +#define BATTERY_PIN 1 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage +#define ADC_CHANNEL ADC1_GPIO1_CHANNEL +#define ADC_ATTENUATION ADC_ATTEN_DB_2_5 // lower dB for high resistance voltage divider +#define ADC_MULTIPLIER 4.9 * 1.035 + +#define USE_SX1262 +#define LORA_DIO0 -1 // a No connect on the SX1262 module +#define LORA_RESET 12 +#define LORA_DIO1 14 // SX1262 IRQ +#define LORA_DIO2 13 // SX1262 BUSY +#define LORA_DIO3 // Not connected on PCB, but internally on the TTGO SX1262, if DIO3 is high the TCXO is enabled + +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 + +#define SX126X_CS LORA_CS +#define SX126X_DIO1 LORA_DIO1 +#define SX126X_BUSY LORA_DIO2 +#define SX126X_RESET LORA_RESET + +#define SX126X_DIO2_AS_RF_SWITCH +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 + +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + +// ---- KCT8103L RF FRONT END CONFIGURATION ---- +// The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA +// RF path: SX1262 -> Pi attenuator -> KCT8103L PA -> Antenna +// Control logic (from KCT8103L datasheet): +// Transmit PA: CSD=1, CTX=1, CPS=1 +// Receive LNA: CSD=1, CTX=0, CPS=X (21dB gain, 1.9dB NF) +// Receive bypass: CSD=1, CTX=1, CPS=0 +// Shutdown: CSD=0, CTX=X, CPS=X +// Pin mapping: +// CPS (pin 5) -> SX1262 DIO2: TX/RX path select (automatic via SX126X_DIO2_AS_RF_SWITCH) +// CSD (pin 4) -> GPIO2: Chip enable (HIGH=on, LOW=shutdown) +// CTX (pin 6) -> GPIO5: Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) +// VCC0/VCC1 -> Vfem via U3 LDO, controlled by GPIO7 +// KCT8103L FEM: TX/RX path switching is handled by DIO2 -> CPS pin (via SX126X_DIO2_AS_RF_SWITCH) + +#define USE_KCT8103L_PA +#define LORA_PA_POWER 7 // VFEM_Ctrl - KCT8103L LDO power enable +#define LORA_KCT8103L_PA_CSD 2 // CSD - KCT8103L chip enable (HIGH=on) +#define LORA_KCT8103L_PA_CTX 5 // CTX - Switch between Receive LNA Mode and Receive Bypass Mode. (HIGH=RX bypass, LOW=RX LNA) + +#if HAS_TFT +#define USE_TFTDISPLAY 1 +#endif +/* + * GPS pins + */ +#define GPS_L76K +#define PIN_GPS_EN (42) +#define GPS_EN_ACTIVE LOW +#define PERIPHERAL_WARMUP_MS 1000 // Make sure I2C QuickLink has stable power before continuing +#define PIN_GPS_PPS (41) +// Seems to be missing on this new board +#define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU +#define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS +#define GPS_THREAD_INTERVAL 50 From c0425d74448d06019f0d4bfc0a27cedeeb2f5a84 Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 27 Apr 2026 14:33:19 -0400 Subject: [PATCH 02/34] Actions: Build MacOS binary (#10319) Preliminary CI for the MacOS builds Co-authored-by: Copilot --- .github/workflows/build_macos_bin.yml | 51 +++++++++++++++++++++++++++ .github/workflows/main_matrix.yml | 15 ++++++++ 2 files changed, 66 insertions(+) create mode 100644 .github/workflows/build_macos_bin.yml diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml new file mode 100644 index 000000000..cde2dd816 --- /dev/null +++ b/.github/workflows/build_macos_bin.yml @@ -0,0 +1,51 @@ +name: Build MacOS Binary + +on: + workflow_call: + inputs: + macos_ver: + required: false + default: "26" # ARM64 + type: string + +permissions: + contents: read + +jobs: + build-MacOS: + runs-on: macos-${{ inputs.macos_ver }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install deps + shell: bash + run: | + brew update + brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config + + - name: Get release version string + run: | + echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT + id: version + + - name: Build for MacOS + run: | + platformio run -e native-macos + env: + PKG_VERSION: ${{ steps.version.outputs.long }} + # Errors in this step should not fail the entire workflow while MacOS support is in development. + continue-on-error: true + + - name: List output files + run: ls -lah .pio/build/native-macos/ + + - name: Store binaries as an artifact + uses: actions/upload-artifact@v7 + with: + name: firmware-macos-${{ inputs.macos_ver }}-${{ steps.version.outputs.long }} + overwrite: true + path: | + .pio/build/native-macos/meshtasticd diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index 88395600a..3505d950e 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -116,6 +116,20 @@ jobs: build_location: local secrets: inherit + MacOS: + strategy: + fail-fast: false + matrix: + macos_ver: + - "26" # ARM64 + # - '26-intel' # x86_64 + - "15" # ARM64 + # - '15-intel' # x86_64 + uses: ./.github/workflows/build_macos_bin.yml + with: + macos_ver: ${{ matrix.macos_ver }} + # secrets: inherit + package-pio-deps-native-tft: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} uses: ./.github/workflows/package_pio_deps.yml @@ -286,6 +300,7 @@ jobs: - gather-artifacts - build-debian-src - package-pio-deps-native-tft + # - MacOS steps: - name: Checkout uses: actions/checkout@v6 From 9c72767c0133361131f27ca039455a81ec8d4a1e Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 08:31:08 -0500 Subject: [PATCH 03/34] macOS: enable CH341 LoRa-hardware path (fix serial truncation, document setup) (#10320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * macOS: enable CH341 LoRa-hardware path — fix serial truncation, document setup Verified on Apple Silicon with a CH341A USB-SPI bridge (VID 0x1A86, PID 0x5512) wired to an SX1262 (Meshstick variant) that the existing `pine64/libch341-spi-userspace` lib_dep works on macOS as-is — Apple's bundled CH34x driver only matches the CH340 *UART* variant (PID 0x7523), so the CH341A's interface 0 is left unclaimed and libusb opens / configures / claims it directly via IOUSBHostInterface. End-to-end test: meshtasticd boots, libusb claim succeeds, SX1262 init returns 0, TCP API serves the meshtastic CLI's --info / --sendtext flow. Two changes: 1. **`PortduinoGlue.cpp:497`**: pass `sizeof(serial)` (= 9) instead of the literal `8` to `Ch341Hal::getSerialString()`. The function in `USBHal.h:61-68` treats `len` as buffer size and reserves one slot for the null terminator (`bytesCopied = (len - 1) < 8 ? (len - 1) : 8`), so passing 8 produced a 7-char serial — which then broke the `strlen(serial) == 8` check at line 502, skipping the auto-MAC derivation from serial + product string. On Linux this was masked by the BlueZ HCI MAC fallback in `getMacAddr()` at lines 139-157, but on macOS that fallback is `__linux__`-guarded so the serial path is mandatory and the truncation left `mac_address` empty, causing the daemon to exit with `*** Blank MAC Address not allowed!`. 2. **`variants/native/portduino/platformio.ini`**: expand the `[env:native-macos]` comment block with a "Real LoRa hardware on macOS" section. Documents: - Why no upstream library change is needed (Apple kext targets CH340/UART, not CH341A/SPI; libusb's `#ifdef __linux__` skip is correct for macOS in this case). - How to point `meshtasticd` at an existing platform-agnostic `bin/config.d/lora-*.yaml` for CH341 hardware. - The auto-MAC-derivation contract (now working with this fix). - `ioreg` and `LIBUSB_DEBUG=4` diagnostic recipes for the failure mode where a third-party WCH `CH34xVCPDriver` *would* claim interface 0 (`kmutil unload -b ` workaround). No upstream library forks, no PR chain, no additional lib_deps — the existing `pine64/libch341-spi-userspace` + libusb-1.0 stack does the right thing on macOS already. Co-Authored-By: Claude Opus 4.7 (1M context) * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/platform/portduino/PortduinoGlue.cpp | 11 ++++++-- variants/native/portduino/platformio.ini | 33 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 6f8077720..fbaa3c98c 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -490,10 +490,17 @@ void portduinoSetup() exit(EXIT_FAILURE); } char serial[9] = {0}; - ch341Hal->getSerialString(serial, 8); + // Pass the full buffer size (9 = 8 chars + null) to getSerialString, + // not 8. The function treats `len` as buffer size and reserves one + // slot for the null terminator, so passing 8 produced a 7-char serial + // and broke the `strlen(serial) == 8` check below — masked on Linux + // by the BlueZ HCI MAC fallback in getMacAddr(), but on macOS (where + // the BlueZ path is __linux__-guarded) it left mac_address empty and + // meshtasticd refused to start. + ch341Hal->getSerialString(serial, sizeof(serial)); std::cout << "CH341 Serial " << serial << std::endl; char product_string[96] = {0}; - ch341Hal->getProductString(product_string, 95); + ch341Hal->getProductString(product_string, sizeof(product_string)); std::cout << "CH341 Product " << product_string << std::endl; if (strlen(serial) == 8 && portduino_config.mac_address.length() < 12) { std::cout << "Deriving MAC address from Serial and Product String" << std::endl; diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index c497d0c17..e493da77b 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -133,6 +133,39 @@ test_testing_command = ; SOCK_NONBLOCK fallback, Common.h __APPLE__ guard, WiFiServer.cpp extern-C ; fix, package.json URL refresh. Pulled by platform-native at its pinned commit. ; This env therefore only carries the firmware-side build flags and src filter. +; +; Real LoRa hardware on macOS: +; The same lib_dep `pine64/libch341-spi-userspace` used on Linux works on +; macOS as-is — its `libusb_detach_kernel_driver()` call is `__linux__`- +; guarded, but on macOS the kernel doesn't bind a driver to a CH341A SPI +; bridge (PID 0x5512; bDeviceClass=0xff vendor-specific) by default, so +; no detach is needed. Apple's bundled CH34x driver targets the CH340 +; *UART* variant (PID 0x7523) — different product. libusb opens the device +; and claims interface 0 directly via IOUSBHostInterface. +; +; To use, point `meshtasticd` at any of the existing `bin/config.d/lora-*.yaml` +; files that specify `spidev: ch341` — they're platform-agnostic. Example: +; pio run -e native-macos +; mkdir -p ~/.meshtasticd && cp bin/config-dist.yaml ~/.meshtasticd/config.yaml +; # Edit ~/.meshtasticd/config.yaml: ConfigDirectory: ./config.d/ +; mkdir ~/.meshtasticd/config.d && cp bin/config.d/lora-meshstick-1262.yaml ~/.meshtasticd/config.d/ +; cd ~/.meshtasticd && /path/to/firmware/.pio/build/native-macos/meshtasticd +; +; The MAC address auto-derives from the CH341's USB serial + product string +; (PortduinoGlue.cpp ~497-518); on Linux a BlueZ HCI socket is the fallback +; when that path isn't taken, but BlueZ is `__linux__`-guarded so the +; serial-derivation path is mandatory on macOS. Override with +; `MACAddress: AA:BB:CC:DD:EE:FF` in config.yaml's `General:` section if +; the device's serial isn't 8 hex chars. +; +; Diagnosing CH341 issues on macOS: +; ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512 +; Children should be `IOUSBHostInterface`. If a vendor driver class +; (e.g. `com.wch.CH34xVCPDriver` from a third-party WCH installer) +; claims interface 0, libusb will fail with LIBUSB_ERROR_BUSY. +; Workaround: `sudo kmutil unload -b `. +; LIBUSB_DEBUG=4 .pio/build/native-macos/meshtasticd +; Verbose libusb trace — useful when claim_interface fails. ; --------------------------------------------------------------------------- [env:native-macos] extends = native_base From 8d8ff21e7c4b0735fe72cc537021516932651e8c Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Tue, 28 Apr 2026 19:03:50 -0500 Subject: [PATCH 04/34] Add clamping logic for milliseconds conversion and unit tests (#10326) * Add clamping logic for milliseconds conversion and unit tests * Simplify comments in secondsToMsClamped function Removed detailed comments about seconds to milliseconds conversion. --- src/mesh/Default.cpp | 26 +++++++++----- test/test_default/test_main.cpp | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/mesh/Default.cpp b/src/mesh/Default.cpp index 3ecd766f1..7a2d9e410 100644 --- a/src/mesh/Default.cpp +++ b/src/mesh/Default.cpp @@ -2,18 +2,21 @@ #include "meshUtils.h" +// Convert seconds to ms, clamping at INT32_MAX (~24.86 days) +static inline uint32_t secondsToMsClamped(uint32_t secs) +{ + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + return (secs > MAX_MS / 1000U) ? MAX_MS : secs * 1000U; +} + uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval, uint32_t defaultInterval) { - if (configuredInterval > 0) - return configuredInterval * 1000; - return defaultInterval * 1000; + return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : defaultInterval); } uint32_t Default::getConfiguredOrDefaultMs(uint32_t configuredInterval) { - if (configuredInterval > 0) - return configuredInterval * 1000; - return default_broadcast_interval_secs * 1000; + return secondsToMsClamped(configuredInterval > 0 ? configuredInterval : default_broadcast_interval_secs); } uint32_t Default::getConfiguredOrDefault(uint32_t configured, uint32_t defaultValue) @@ -47,7 +50,14 @@ uint32_t Default::getConfiguredOrDefaultMsScaled(uint32_t configured, uint32_t d meshtastic_Config_DeviceConfig_Role_TAK_TRACKER)) return getConfiguredOrDefaultMs(configured, defaultValue); - return getConfiguredOrDefaultMs(configured, defaultValue) * congestionScalingCoefficient(numOnlineNodes); + // Saturate at INT32_MAX to match secondsToMsClamped: float→uint32_t when + // out of range is UB, and the result is consumed as an int32_t downstream. + constexpr uint32_t MAX_MS = static_cast(INT32_MAX); + uint32_t base = getConfiguredOrDefaultMs(configured, defaultValue); + float coef = congestionScalingCoefficient(numOnlineNodes); + if (static_cast(base) * static_cast(coef) >= static_cast(MAX_MS)) + return MAX_MS; + return base * coef; } uint32_t Default::getConfiguredOrMinimumValue(uint32_t configured, uint32_t minValue) @@ -66,4 +76,4 @@ uint8_t Default::getConfiguredOrDefaultHopLimit(uint8_t configured) #else return (configured >= HOP_MAX) ? HOP_MAX : config.lora.hop_limit; #endif -} \ No newline at end of file +} diff --git a/test/test_default/test_main.cpp b/test/test_default/test_main.cpp index 9da367897..4202d7b8d 100644 --- a/test/test_default/test_main.cpp +++ b/test/test_default/test_main.cpp @@ -127,6 +127,60 @@ void test_client_uses_public_channel_minimums() TEST_ASSERT_EQUAL_UINT32(60 * 60, position); } +// --- Saturation/clamp tests for getConfiguredOrDefaultMs[Scaled] --- +// These guard the INT32_MAX clamp added to avoid uint32 wrap of secs*1000 and +// to keep results safe to cast to int32_t for OSThread runOnce returns. + +void test_ms_below_threshold() +{ + // Ordinary value passes through unchanged. + TEST_ASSERT_EQUAL_UINT32(60000U, Default::getConfiguredOrDefaultMs(60, 0)); +} + +void test_ms_at_threshold() +{ + // INT32_MAX / 1000 = 2,147,483 — largest secs that does not clamp. + TEST_ASSERT_EQUAL_UINT32(2147483000U, Default::getConfiguredOrDefaultMs(2147483U, 0)); +} + +void test_ms_just_above_threshold() +{ + // One second over the boundary must saturate, not wrap. + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(2147484U, 0)); +} + +void test_ms_uint32_max() +{ + // default_sds_secs == UINT32_MAX on non-routers must not wrap. + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(UINT32_MAX, 0)); +} + +void test_ms_default_clamps() +{ + // Clamp also applies when the default-arg path is taken (configured == 0). + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), Default::getConfiguredOrDefaultMs(0, UINT32_MAX)); +} + +void test_ms_result_is_int32_safe() +{ + // Regression guard for runOnce returns: cast to int32_t must not go negative. + int32_t result = static_cast(Default::getConfiguredOrDefaultMs(UINT32_MAX, 0)); + TEST_ASSERT_GREATER_OR_EQUAL_INT32(0, result); +} + +void test_scaled_overflow_saturates() +{ + // long_fast (SF11/BW250) with a 24h base and heavy congestion overflows + // the uint32 result without the double-precision guard. Must saturate. + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.use_preset = false; + config.lora.spread_factor = 11; + config.lora.bandwidth = 250; + + uint32_t res = Default::getConfiguredOrDefaultMsScaled(0, ONE_DAY, 1000); + TEST_ASSERT_EQUAL_UINT32(static_cast(INT32_MAX), res); +} + void setup() { // Small delay to match other test mains @@ -140,6 +194,13 @@ void setup() RUN_TEST(test_router_uses_router_minimums); RUN_TEST(test_router_late_uses_router_minimums); RUN_TEST(test_client_uses_public_channel_minimums); + RUN_TEST(test_ms_below_threshold); + RUN_TEST(test_ms_at_threshold); + RUN_TEST(test_ms_just_above_threshold); + RUN_TEST(test_ms_uint32_max); + RUN_TEST(test_ms_default_clamps); + RUN_TEST(test_ms_result_is_int32_safe); + RUN_TEST(test_scaled_overflow_saturates); exit(UNITY_END()); } From 11df30a85fd7860535c8bd45728849efeb649ceb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:04:16 -0500 Subject: [PATCH 05/34] Upgrade trunk (#10324) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 178a1cc9e..41bb110a9 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.525 - - renovate@43.142.0 + - renovate@43.147.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From c0e52e6e1c2a5e5aaa960de853f5f9f917084ec5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:10:17 -0500 Subject: [PATCH 06/34] Update meshtastic/device-ui digest to 1ddcc9d (#10328) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 3f8f77228..9348d97d9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/728932970996ec91bdb93cb6dae29c2cb70c66e2.zip + https://github.com/meshtastic/device-ui/archive/1ddcc9da2e60c013d6fc515fb73fb63fac75f9fd.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 22a9346fe03a8c049bf6a9a2377a8b39d6b63bf9 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 12:16:25 -0400 Subject: [PATCH 07/34] Debian: Correctly fail upon failure (#10341) Fake success is BS! We should fail when we fail. Fixes issues with Debian sourcedebs silently failing to build ocassionally (due to github 502s, etc). --- debian/ci_pack_sdeb.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/debian/ci_pack_sdeb.sh b/debian/ci_pack_sdeb.sh index 7b2418ff6..d35aeef24 100755 --- a/debian/ci_pack_sdeb.sh +++ b/debian/ci_pack_sdeb.sh @@ -1,4 +1,5 @@ #!/usr/bin/bash +set -e export DEBEMAIL="jbennett@incomsystems.biz" export PLATFORMIO_LIBDEPS_DIR=pio/libdeps export PLATFORMIO_PACKAGES_DIR=pio/packages From 9ec63b5eb2c236457ce307c896e98f00837a82c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:55:48 -0500 Subject: [PATCH 08/34] Upgrade trunk (#10336) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 41bb110a9..77ee7ecc3 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -9,7 +9,7 @@ plugins: lint: enabled: - checkov@3.2.525 - - renovate@43.147.0 + - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 - yamllint@1.38.0 From 7be5426f343e40272146086830a8686f0f144a58 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 14:00:01 -0400 Subject: [PATCH 09/34] Do not FACTORY_INSTALL on ARCH_PORTDUINO (#10343) --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4b79882bd..b526bc869 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1205,7 +1205,7 @@ void NodeDB::loadFromDisk() spiLock->unlock(); #endif #ifdef FSCom -#ifdef FACTORY_INSTALL +#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO) spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); From 089af764ec04f715fa3bc60d0fb6e6a30ed4fcb8 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 16:41:21 -0500 Subject: [PATCH 10/34] Replace FSCom.format() with FSCom.rmDir() for directory cleanup in NodeDB::loadFromDisk() --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index b526bc869..a0212794f 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1209,7 +1209,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); - FSCom.format(); + FSCom.rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); if (f2) { From 195f42af826634acf3120252c64e8255de03c7c4 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 16:57:21 -0500 Subject: [PATCH 11/34] Doesn't FSCom --- src/mesh/NodeDB.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index a0212794f..6d13952e5 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1209,7 +1209,7 @@ void NodeDB::loadFromDisk() spiLock->lock(); if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) { LOG_WARN("Factory Install Reset!"); - FSCom.rmDir("/prefs"); + rmDir("/prefs"); FSCom.mkdir("/prefs"); File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE); if (f2) { From ad23c42fcc9531509f8cf674b738a3b7b193ec07 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:09:21 -0500 Subject: [PATCH 12/34] Update meshtastic/device-ui digest to 4bf593a (#10346) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 9348d97d9..71c362a79 100644 --- a/platformio.ini +++ b/platformio.ini @@ -126,7 +126,7 @@ lib_deps = [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/1ddcc9da2e60c013d6fc515fb73fb63fac75f9fd.zip + https://github.com/meshtastic/device-ui/archive/4bf593a82100b911ff816dddf7158ffdee2114cd.zip ; Common libs for environmental measurements in telemetry module [environmental_base] From 478444eb02836fd5c6ae0e5887840994c9679289 Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 20:31:59 -0400 Subject: [PATCH 13/34] Docker-Alpine: Align version between build/main stages (#10347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FROM python:3.14-alpine3.23 AS builder FROM alpine:3.23 the alpine version needs to match in both stages 😅 --- alpine.Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 75c9aa594..40a4990bb 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -3,7 +3,8 @@ # trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions -FROM python:3.14-alpine3.22 AS builder +# Ensure the Alpine version is updated in both stages of the container! +FROM python:3.14-alpine3.23 AS builder ARG PIO_ENV=native ENV PIP_ROOT_USER_ACTION=ignore @@ -60,4 +61,4 @@ EXPOSE 4403 CMD [ "sh", "-cx", "meshtasticd --fsdir=/var/lib/meshtasticd" ] -HEALTHCHECK NONE \ No newline at end of file +HEALTHCHECK NONE From 3a87fc82c088b5ab4d06958af762bbd755676f17 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 29 Apr 2026 19:54:05 -0500 Subject: [PATCH 14/34] Add documentation for macOS support in Copilot and Agent instructions --- .github/copilot-instructions.md | 4 +++- AGENTS.md | 24 +++++++++++++----------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2d7457102..29d5f6b00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,7 @@ Meshtastic is an open-source LoRa mesh networking project for long-range, low-po - **RP2040/RP2350** - Raspberry Pi Pico variants - **STM32WL** - STM32 with integrated LoRa - **Linux/Portduino** - Native Linux builds (Raspberry Pi, etc.) +- **macOS native** - Headless `meshtasticd` on Apple Silicon / x86_64; see `variants/native/portduino/platformio.ini` for Homebrew prereqs + CH341 LoRa setup ### Supported Radio Chips @@ -369,7 +370,7 @@ To reduce avoidable agent mistakes, assume these tools are available (or install - **Required CLI basics**: `bash`, `git`, `find`, `grep`, `sed`, `awk`, `xargs` - **Strongly recommended**: `rg` (ripgrep) for fast file/text search, `jq` for JSON processing - **Build/test tools**: `python3`, `pip`, virtualenv (`python3 -m venv`), `platformio` (`pio`) -- **Containerized native testing**: `docker` (especially important on macOS / non-Linux hosts) +- **Containerized native testing**: `docker` (fallback for non-Linux hosts; macOS can also build natively via `pio run -e native-macos`) Fallback expectations for agents: @@ -388,6 +389,7 @@ Build commands: pio run -e tbeam # Build specific target pio run -e tbeam -t upload # Build and upload pio run -e native # Build native/Linux version +pio run -e native-macos # Build headless macOS meshtasticd (Homebrew prereqs in variants/native/portduino/platformio.ini) ``` ### Build Manifest diff --git a/AGENTS.md b/AGENTS.md index 8f3474640..ca6794322 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,17 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don ## Quick command reference -| Action | Command | -| -------------------------------- | ----------------------------------------------------------------------------------- | -| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | -| Clean + rebuild | `pio run -e -t clean && pio run -e ` | -| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | -| Run firmware unit tests (native) | `pio test -e native` | -| Run MCP hardware tests | `./mcp-server/run-tests.sh` | -| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | -| Format before commit | `trunk fmt` | -| Regenerate protobuf bindings | `bin/regen-protos.sh` | -| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | +| Action | Command | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Build a firmware variant | `pio run -e ` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) | +| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) | +| Clean + rebuild | `pio run -e -t clean && pio run -e ` | +| Flash a device | `pio run -e -t upload --upload-port ` (or use the `pio_flash` MCP tool) | +| Run firmware unit tests (native) | `pio test -e native` | +| Run MCP hardware tests | `./mcp-server/run-tests.sh` | +| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` | +| Format before commit | `trunk fmt` | +| Regenerate protobuf bindings | `bin/regen-protos.sh` | +| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` | ## MCP server (device + test automation) @@ -121,6 +122,7 @@ Sequence these; don't parallelize on the same port. - **Device fully wedged (no DFU)?** `mcp__meshtastic__uhubctl_cycle(role="nrf52", confirm=True)` hard-power-cycles it via USB hub PPPS. Needs `uhubctl` installed (`brew install uhubctl` / `apt install uhubctl`); on Linux without udev rules, permission errors fail fast, so use `sudo uhubctl` yourself or configure udev access. - **Port busy?** `lsof ` to find the holder. Usually a stale `pio device monitor` or zombie `meshtastic_mcp` process. Kill it. - **Multiple MCP servers running?** `ps aux | grep meshtastic_mcp` — zombies hold ports. Kill all but the one your host spawned. +- **macOS: `LIBUSB_ERROR_BUSY` on a CH341 LoRa adapter?** A third-party WCH `CH34xVCPDriver` is claiming interface 0. Find the bundle ID with `ioreg -p IOUSB -l -w 0 | grep -B2 -A30 0x5512`, then `sudo kmutil unload -b `. Apple's bundled CH34x kext targets the CH340 UART (PID 0x7523), not the SPI bridge — it's never the culprit. ## Environment variables (test harness) From 24d64a0013b62abdac05c471f58ffec025c8396f Mon Sep 17 00:00:00 2001 From: Austin Date: Wed, 29 Apr 2026 22:04:49 -0400 Subject: [PATCH 15/34] Docker: Build for riscv64 (#10345) Upstream support has been added in Debian and Alpine. Only build as part of `docker_manifest` (Beta/Alpha/Daily) releases, because these will take a **while** thanks to qemu. Co-authored-by: Copilot --- .github/workflows/docker_build.yml | 4 +++- .github/workflows/docker_manifest.yml | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index d9b23a7e8..8a3ef0e6c 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -73,7 +73,9 @@ jobs: - name: Sanitize platform string id: sanitize_platform # Replace slashes with underscores - run: echo "cleaned_platform=${{ inputs.platform }}" | sed 's/\//_/g' >> $GITHUB_OUTPUT + env: + plat: ${{ inputs.platform }} + run: echo "cleaned_platform=${plat}" | sed 's/\//_/g' >> $GITHUB_OUTPUT - name: Docker login if: ${{ inputs.push }} diff --git a/.github/workflows/docker_manifest.yml b/.github/workflows/docker_manifest.yml index b2fd12599..4bfdfe37e 100644 --- a/.github/workflows/docker_manifest.yml +++ b/.github/workflows/docker_manifest.yml @@ -43,6 +43,15 @@ jobs: push: true secrets: inherit + docker-debian-riscv64: + uses: ./.github/workflows/docker_build.yml + with: + distro: debian + platform: linux/riscv64 + runs-on: ubuntu-24.04-arm + push: true + secrets: inherit + docker-alpine-amd64: uses: ./.github/workflows/docker_build.yml with: @@ -70,16 +79,27 @@ jobs: push: true secrets: inherit + docker-alpine-riscv64: + uses: ./.github/workflows/docker_build.yml + with: + distro: alpine + platform: linux/riscv64 + runs-on: ubuntu-24.04-arm + push: true + secrets: inherit + docker-manifest: needs: # Debian - docker-debian-amd64 - docker-debian-arm64 - docker-debian-armv7 + - docker-debian-riscv64 # Alpine - docker-alpine-amd64 - docker-alpine-arm64 - docker-alpine-armv7 + - docker-alpine-riscv64 runs-on: ubuntu-24.04 steps: - name: Checkout code @@ -162,6 +182,7 @@ jobs: meshtastic/meshtasticd@${{ needs.docker-debian-amd64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-debian-arm64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-debian-armv7.outputs.digest }} + meshtastic/meshtasticd@${{ needs.docker-debian-riscv64.outputs.digest }} - name: Docker meta (Alpine) id: meta_alpine @@ -182,3 +203,4 @@ jobs: meshtastic/meshtasticd@${{ needs.docker-alpine-amd64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-alpine-arm64.outputs.digest }} meshtastic/meshtasticd@${{ needs.docker-alpine-armv7.outputs.digest }} + meshtastic/meshtasticd@${{ needs.docker-alpine-riscv64.outputs.digest }} From e19f531059952e7e9e0bc5e54a163abb384fac20 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:05:16 -0400 Subject: [PATCH 16/34] Update Screen.cpp (#10344) --- src/graphics/Screen.cpp | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index e8a7f685e..d02938df9 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -524,6 +524,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) delay(100); #endif #if !ARCH_PORTDUINO +#if defined(USE_ST7789) && defined(VTFT_CTRL) + // Ensure panel power rail is enabled before sending wake commands. + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, LOW); +#endif dispdev->displayOn(); #endif @@ -545,10 +550,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) ui->init(); #endif #if defined(USE_ST7789) && defined(VTFT_LEDA) -#ifdef VTFT_CTRL - pinMode(VTFT_CTRL, OUTPUT); - digitalWrite(VTFT_CTRL, LOW); -#endif ui->init(); #ifdef ESP_PLATFORM analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT); @@ -589,23 +590,22 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) #endif #ifdef USE_ST7789 SPI1.end(); -#if defined(ARCH_ESP32) + // Keep TFT control pins in deterministic states while timed-off. + // Floating/default pin states can corrupt panel edge rows on wake. #ifdef VTFT_LEDA - pinMode(VTFT_LEDA, ANALOG); + pinMode(VTFT_LEDA, OUTPUT); + digitalWrite(VTFT_LEDA, !TFT_BACKLIGHT_ON); #endif #ifdef VTFT_CTRL - pinMode(VTFT_CTRL, ANALOG); -#endif - pinMode(ST7789_RESET, ANALOG); - pinMode(ST7789_RS, ANALOG); - pinMode(ST7789_NSS, ANALOG); -#else - nrf_gpio_cfg_default(VTFT_LEDA); - nrf_gpio_cfg_default(VTFT_CTRL); - nrf_gpio_cfg_default(ST7789_RESET); - nrf_gpio_cfg_default(ST7789_RS); - nrf_gpio_cfg_default(ST7789_NSS); + pinMode(VTFT_CTRL, OUTPUT); + digitalWrite(VTFT_CTRL, HIGH); #endif + pinMode(ST7789_RESET, OUTPUT); + digitalWrite(ST7789_RESET, HIGH); + pinMode(ST7789_RS, OUTPUT); + digitalWrite(ST7789_RS, HIGH); + pinMode(ST7789_NSS, OUTPUT); + digitalWrite(ST7789_NSS, HIGH); #endif #ifdef USE_ST7796 SPI1.end(); From a0951f23c3d5fa369d2bb0b5a64b8ed8f408470f Mon Sep 17 00:00:00 2001 From: Joe <85746415+WB3IHY@users.noreply.github.com> Date: Thu, 30 Apr 2026 07:00:50 -0400 Subject: [PATCH 17/34] fix: MQTT connection on Portduino/Linux native nodes (#10330) isConnectedToNetwork() always returned false on ARCH_PORTDUINO because none of HAS_WIFI, HAS_ETHERNET, or USE_WS5500 are defined for Linux native builds. This caused wantsLink() to always return false, preventing the MQTT thread from ever connecting at boot. Fix: return true for ARCH_PORTDUINO since Linux always has network access available. Co-authored-by: Jonathan Bennett --- src/mqtt/MQTT.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 283fcffb1..97636b601 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -350,6 +350,8 @@ inline bool isConnectedToNetwork() return WiFi.isConnected(); #elif HAS_ETHERNET return Ethernet.linkStatus() == LinkON; +#elif defined(ARCH_PORTDUINO) + return true; #else return false; #endif From 83adfd417a080503afb092067c5004068a094072 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:39:52 -0500 Subject: [PATCH 18/34] Upgrade trunk (#10354) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 77ee7ecc3..1913c6604 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -4,7 +4,7 @@ cli: plugins: sources: - id: trunk - ref: v1.7.6 + ref: v1.8.0 uri: https://github.com/trunk-io/plugins lint: enabled: @@ -36,7 +36,7 @@ lint: - bin/** runtimes: enabled: - - python@3.10.8 + - python@3.14.4 - go@1.21.0 - node@22.16.0 actions: From 173ac58ed70ab7dc74fad11686a59246de79ef06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:45:20 -0500 Subject: [PATCH 19/34] Update protobufs (#10357) Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --- protobufs | 2 +- src/mesh/generated/meshtastic/apponly.pb.h | 2 +- src/mesh/generated/meshtastic/config.pb.h | 12 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- .../generated/meshtastic/serial_hal.pb.cpp | 19 +++ src/mesh/generated/meshtastic/serial_hal.pb.h | 135 ++++++++++++++++++ 7 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 src/mesh/generated/meshtastic/serial_hal.pb.cpp create mode 100644 src/mesh/generated/meshtastic/serial_hal.pb.h diff --git a/protobufs b/protobufs index 249a80855..1d6f1a71f 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 249a80855a2adb76fb0904dac8bf6285d45f330f +Subproject commit 1d6f1a71ff329fa52ad8bb7899951e96f8280a1f diff --git a/src/mesh/generated/meshtastic/apponly.pb.h b/src/mesh/generated/meshtastic/apponly.pb.h index ce766878b..88cbcb5e6 100644 --- a/src/mesh/generated/meshtastic/apponly.pb.h +++ b/src/mesh/generated/meshtastic/apponly.pb.h @@ -55,7 +55,7 @@ extern const pb_msgdesc_t meshtastic_ChannelSet_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_APPONLY_PB_H_MAX_SIZE meshtastic_ChannelSet_size -#define meshtastic_ChannelSet_size 682 +#define meshtastic_ChannelSet_size 685 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 0e14334d5..d614a6438 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -618,6 +618,8 @@ typedef struct _meshtastic_Config_LoRaConfig { bool config_ok_to_mqtt; /* Set where LORA FEM is enabled, disabled, or not present */ meshtastic_Config_LoRaConfig_FEM_LNA_Mode fem_lna_mode; + /* Don't use radiolib to initialize the radio, instead listen for a serialHal connection */ + bool serial_hal_only; } meshtastic_Config_LoRaConfig; typedef struct _meshtastic_Config_BluetoothConfig { @@ -779,7 +781,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_default {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_default, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_default {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN} +#define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_default {0} @@ -790,7 +792,7 @@ extern "C" { #define meshtastic_Config_NetworkConfig_init_zero {0, "", "", "", 0, _meshtastic_Config_NetworkConfig_AddressMode_MIN, false, meshtastic_Config_NetworkConfig_IpV4Config_init_zero, "", 0, 0} #define meshtastic_Config_NetworkConfig_IpV4Config_init_zero {0, 0, 0, 0} #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} -#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN} +#define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} #define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} #define meshtastic_Config_SessionkeyConfig_init_zero {0} @@ -877,6 +879,7 @@ extern "C" { #define meshtastic_Config_LoRaConfig_ignore_mqtt_tag 104 #define meshtastic_Config_LoRaConfig_config_ok_to_mqtt_tag 105 #define meshtastic_Config_LoRaConfig_fem_lna_mode_tag 106 +#define meshtastic_Config_LoRaConfig_serial_hal_only_tag 107 #define meshtastic_Config_BluetoothConfig_enabled_tag 1 #define meshtastic_Config_BluetoothConfig_mode_tag 2 #define meshtastic_Config_BluetoothConfig_fixed_pin_tag 3 @@ -1029,7 +1032,8 @@ X(a, STATIC, SINGULAR, BOOL, pa_fan_disabled, 15) \ X(a, STATIC, REPEATED, UINT32, ignore_incoming, 103) \ X(a, STATIC, SINGULAR, BOOL, ignore_mqtt, 104) \ X(a, STATIC, SINGULAR, BOOL, config_ok_to_mqtt, 105) \ -X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) +X(a, STATIC, SINGULAR, UENUM, fem_lna_mode, 106) \ +X(a, STATIC, SINGULAR, BOOL, serial_hal_only, 107) #define meshtastic_Config_LoRaConfig_CALLBACK NULL #define meshtastic_Config_LoRaConfig_DEFAULT NULL @@ -1086,7 +1090,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_BluetoothConfig_size 10 #define meshtastic_Config_DeviceConfig_size 100 #define meshtastic_Config_DisplayConfig_size 36 -#define meshtastic_Config_LoRaConfig_size 88 +#define meshtastic_Config_LoRaConfig_size 91 #define meshtastic_Config_NetworkConfig_IpV4Config_size 20 #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 1d6cd32f9..6d03dc643 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -361,7 +361,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2429 +#define meshtastic_BackupPreferences_size 2432 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1737 #define meshtastic_NodeInfoLite_size 196 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 8425c122a..27f5ad7bf 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -205,7 +205,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 754 +#define meshtastic_LocalConfig_size 757 #define meshtastic_LocalModuleConfig_size 820 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/serial_hal.pb.cpp b/src/mesh/generated/meshtastic/serial_hal.pb.cpp new file mode 100644 index 000000000..183bc48f6 --- /dev/null +++ b/src/mesh/generated/meshtastic/serial_hal.pb.cpp @@ -0,0 +1,19 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/serial_hal.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_SerialHalCommand, meshtastic_SerialHalCommand, 2) + + +PB_BIND(meshtastic_SerialHalResponse, meshtastic_SerialHalResponse, 2) + + + + + + + diff --git a/src/mesh/generated/meshtastic/serial_hal.pb.h b/src/mesh/generated/meshtastic/serial_hal.pb.h new file mode 100644 index 000000000..5dfcdf1ca --- /dev/null +++ b/src/mesh/generated/meshtastic/serial_hal.pb.h @@ -0,0 +1,135 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_INCLUDED +#include + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Enum definitions */ +typedef enum _meshtastic_SerialHalCommand_Type { + meshtastic_SerialHalCommand_Type_UNSET = 0, + meshtastic_SerialHalCommand_Type_PIN_MODE = 1, + meshtastic_SerialHalCommand_Type_DIGITAL_WRITE = 2, + meshtastic_SerialHalCommand_Type_DIGITAL_READ = 3, + meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT = 4, + meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT = 5, + meshtastic_SerialHalCommand_Type_SPI_TRANSFER = 6, + meshtastic_SerialHalCommand_Type_NOOP = 7 +} meshtastic_SerialHalCommand_Type; + +typedef enum _meshtastic_SerialHalResponse_Result { + meshtastic_SerialHalResponse_Result_OK = 0, + meshtastic_SerialHalResponse_Result_ERROR = 1, + meshtastic_SerialHalResponse_Result_BAD_REQUEST = 2, + meshtastic_SerialHalResponse_Result_UNSUPPORTED = 3 +} meshtastic_SerialHalResponse_Result; + +/* Struct definitions */ +typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalCommand_data_t; +typedef struct _meshtastic_SerialHalCommand { + /* Host-assigned request id. Replies echo this id back in + SerialHalResponse.transaction_id. */ + uint32_t transaction_id; + meshtastic_SerialHalCommand_Type type; + uint32_t pin; + uint32_t value; + uint32_t mode; + meshtastic_SerialHalCommand_data_t data; +} meshtastic_SerialHalCommand; + +typedef PB_BYTES_ARRAY_T(512) meshtastic_SerialHalResponse_data_t; +typedef struct _meshtastic_SerialHalResponse { + /* Matches the originating SerialHalCommand.transaction_id for normal + request/response traffic. + + A value of 0 indicates an unsolicited interrupt notification generated by + the device. In that case, the host should interpret value as the GPIO pin + that triggered. */ + uint32_t transaction_id; + meshtastic_SerialHalResponse_Result result; + /* Used by DIGITAL_READ replies and interrupt notifications. For interrupt + notifications (transaction_id == 0), this carries the pin number. */ + uint32_t value; + meshtastic_SerialHalResponse_data_t data; + char error[80]; +} meshtastic_SerialHalResponse; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Helper constants for enums */ +#define _meshtastic_SerialHalCommand_Type_MIN meshtastic_SerialHalCommand_Type_UNSET +#define _meshtastic_SerialHalCommand_Type_MAX meshtastic_SerialHalCommand_Type_NOOP +#define _meshtastic_SerialHalCommand_Type_ARRAYSIZE ((meshtastic_SerialHalCommand_Type)(meshtastic_SerialHalCommand_Type_NOOP+1)) + +#define _meshtastic_SerialHalResponse_Result_MIN meshtastic_SerialHalResponse_Result_OK +#define _meshtastic_SerialHalResponse_Result_MAX meshtastic_SerialHalResponse_Result_UNSUPPORTED +#define _meshtastic_SerialHalResponse_Result_ARRAYSIZE ((meshtastic_SerialHalResponse_Result)(meshtastic_SerialHalResponse_Result_UNSUPPORTED+1)) + +#define meshtastic_SerialHalCommand_type_ENUMTYPE meshtastic_SerialHalCommand_Type + +#define meshtastic_SerialHalResponse_result_ENUMTYPE meshtastic_SerialHalResponse_Result + + +/* Initializer values for message structs */ +#define meshtastic_SerialHalCommand_init_default {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}} +#define meshtastic_SerialHalResponse_init_default {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""} +#define meshtastic_SerialHalCommand_init_zero {0, _meshtastic_SerialHalCommand_Type_MIN, 0, 0, 0, {0, {0}}} +#define meshtastic_SerialHalResponse_init_zero {0, _meshtastic_SerialHalResponse_Result_MIN, 0, {0, {0}}, ""} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_SerialHalCommand_transaction_id_tag 1 +#define meshtastic_SerialHalCommand_type_tag 2 +#define meshtastic_SerialHalCommand_pin_tag 3 +#define meshtastic_SerialHalCommand_value_tag 4 +#define meshtastic_SerialHalCommand_mode_tag 5 +#define meshtastic_SerialHalCommand_data_tag 6 +#define meshtastic_SerialHalResponse_transaction_id_tag 1 +#define meshtastic_SerialHalResponse_result_tag 2 +#define meshtastic_SerialHalResponse_value_tag 3 +#define meshtastic_SerialHalResponse_data_tag 4 +#define meshtastic_SerialHalResponse_error_tag 5 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_SerialHalCommand_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, type, 2) \ +X(a, STATIC, SINGULAR, UINT32, pin, 3) \ +X(a, STATIC, SINGULAR, UINT32, value, 4) \ +X(a, STATIC, SINGULAR, UINT32, mode, 5) \ +X(a, STATIC, SINGULAR, BYTES, data, 6) +#define meshtastic_SerialHalCommand_CALLBACK NULL +#define meshtastic_SerialHalCommand_DEFAULT NULL + +#define meshtastic_SerialHalResponse_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, UINT32, transaction_id, 1) \ +X(a, STATIC, SINGULAR, UENUM, result, 2) \ +X(a, STATIC, SINGULAR, UINT32, value, 3) \ +X(a, STATIC, SINGULAR, BYTES, data, 4) \ +X(a, STATIC, SINGULAR, STRING, error, 5) +#define meshtastic_SerialHalResponse_CALLBACK NULL +#define meshtastic_SerialHalResponse_DEFAULT NULL + +extern const pb_msgdesc_t meshtastic_SerialHalCommand_msg; +extern const pb_msgdesc_t meshtastic_SerialHalResponse_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_SerialHalCommand_fields &meshtastic_SerialHalCommand_msg +#define meshtastic_SerialHalResponse_fields &meshtastic_SerialHalResponse_msg + +/* Maximum encoded size of messages (where known) */ +#define MESHTASTIC_MESHTASTIC_SERIAL_HAL_PB_H_MAX_SIZE meshtastic_SerialHalResponse_size +#define meshtastic_SerialHalCommand_size 541 +#define meshtastic_SerialHalResponse_size 610 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif From 21cef8c2e5d46c31022e3ecedba6a58d84e73045 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 30 Apr 2026 13:51:29 -0500 Subject: [PATCH 20/34] Add TCP support for Meshtastic MCP interface / tests and update docs (#10355) * Add TCP support for Meshtastic MCP interface / tests and update docs * Address TCP endpoint validation and error handling in connection * TCP connection handling and device listing logic * Fix docstring formatting in normalize_tcp_endpoint function --- .github/copilot-instructions.md | 2 + AGENTS.md | 27 +- mcp-server/README.md | 76 +++- mcp-server/src/meshtastic_mcp/connection.py | 169 +++++++- mcp-server/src/meshtastic_mcp/devices.py | 66 ++- mcp-server/src/meshtastic_mcp/flash.py | 19 +- mcp-server/src/meshtastic_mcp/hw_tools.py | 7 +- .../src/meshtastic_mcp/serial_session.py | 4 + mcp-server/tests/unit/test_connection_tcp.py | 383 ++++++++++++++++++ 9 files changed, 714 insertions(+), 39 deletions(-) create mode 100644 mcp-server/tests/unit/test_connection_tcp.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 29d5f6b00..fe9af4359 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -575,6 +575,8 @@ Grouped by purpose. Full argument shapes in `mcp-server/README.md`; a few high-v `confirm=True` is a tool-level gate on top of whatever permission prompt your MCP host shows. **Don't bypass it** by asking the host to auto-approve — it exists specifically because MCP hosts sometimes remember "always allow this tool" and that's dangerous for `factory_reset`, `erase_and_flash`, `uhubctl_power(action='off')`, and `uhubctl_cycle`. +**TCP / native-host nodes.** Setting `MESHTASTIC_MCP_TCP_HOST=` makes `list_devices` surface a `meshtasticd` daemon (e.g. the `native-macos` build) as a synthetic `tcp://host:port` entry, and `connect()` routes through `meshtastic.tcp_interface.TCPInterface` instead of `SerialInterface`. Every read/write/admin tool that flows through `connect()` works against the daemon transparently. USB-only tools (`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, `serial_open`, `esptool_*`, `nrfutil_*`, `picotool_*`) raise a clear `ConnectionError` when handed a `tcp://` port; `pio_flash` against a `native*` env raises a `FlashError` (no upload step — use `build` and run the binary directly). The pytest harness still assumes USB-attached devices per role; TCP-aware fixtures are deferred. See `mcp-server/README.md` § "TCP / native-host nodes". + ### Hardware test suite (`mcp-server/run-tests.sh`) The wrapper auto-detects connected devices (VID → role map: `0x239A` → `nrf52`, `0x303A`/`0x10C4` → `esp32s3`), maps each role to a PlatformIO env (`nrf52` → `rak4631`, `esp32s3` → `heltec-v3`, overridable via `MESHTASTIC_MCP_ENV_`), then invokes pytest. Zero pre-flight config needed from the operator. diff --git a/AGENTS.md b/AGENTS.md index ca6794322..cdccda1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,16 +126,17 @@ Sequence these; don't parallelize on the same port. ## Environment variables (test harness) -| Var | Purpose | -| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | -| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | -| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | -| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | -| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | -| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | -| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | -| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | -| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | -| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | -| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | +| Var | Purpose | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_MCP_ENV_` | Override PlatformIO env for a role (e.g. `MESHTASTIC_MCP_ENV_NRF52=rak4631-dap`). Default map: `nrf52→rak4631`, `esp32s3→heltec-v3`. | +| `MESHTASTIC_MCP_SEED` | PSK seed for the session test profile. Defaults to `mcp--`. | +| `MESHTASTIC_MCP_FLASH_LOG` | File path to tee pio/esptool/nrfutil/picotool output. `run-tests.sh` sets this to `tests/flash.log` so the TUI can stream live flash progress. | +| `MESHTASTIC_MCP_TCP_HOST` | `host` or `host:port` of a `meshtasticd` daemon (e.g. the `native-macos` build). Surfaces it in `list_devices` as `tcp://host:port` so `connect()`-based tools target it transparently. Default port 4403. | +| `MESHTASTIC_UHUBCTL_BIN` | Absolute path to `uhubctl` binary. Default: PATH lookup. | +| `MESHTASTIC_UHUBCTL_LOCATION_` | Pin a role to a specific uhubctl hub location (e.g. `1-1.3`). Wins over VID auto-detection — use when multiple devices share a VID. | +| `MESHTASTIC_UHUBCTL_PORT_` | Pin a role to a specific hub port number. Required alongside `LOCATION_`. | +| `MESHTASTIC_UI_CAMERA_BACKEND` | Camera backend for UI tier + `capture_screen` tool: `opencv` / `ffmpeg` / `null` / `auto` (default). | +| `MESHTASTIC_UI_CAMERA_DEVICE` | Generic camera device (index or path). Used by the UI tier when no per-role var is set. | +| `MESHTASTIC_UI_CAMERA_DEVICE_` | Per-role camera pinning (e.g. `MESHTASTIC_UI_CAMERA_DEVICE_ESP32S3=0` for the OLED-bearing heltec-v3). | +| `MESHTASTIC_UI_OCR_BACKEND` | OCR engine selection: `easyocr` / `pytesseract` / `null` / `auto` (default). | +| `MESHTASTIC_UI_TUI_CAMERA` | Set to `1` to mount the live camera-feed panel in `meshtastic-mcp-test-tui`. | diff --git a/mcp-server/README.md b/mcp-server/README.md index 7a36a6fac..22ce77fbc 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -166,15 +166,73 @@ rather than auto-`sudo`'ing mid-run. ## Environment variables -| Var | Default | Purpose | -| -------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------- | -| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | -| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | -| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | -| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | -| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | -| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | -| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | +| Var | Default | Purpose | +| -------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `MESHTASTIC_FIRMWARE_ROOT` | walks up from cwd for `platformio.ini` | Pin the firmware repo | +| `MESHTASTIC_PIO_BIN` | `~/.platformio/penv/bin/pio` → `$PATH` `pio` → `platformio` | Override `pio` location | +| `MESHTASTIC_ESPTOOL_BIN` | `/.venv/bin/esptool` → `$PATH` | Override esptool | +| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil | +| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool | +| `MESHTASTIC_MCP_SEED` | `mcp--` | PSK seed for test-harness session (CI override) | +| `MESHTASTIC_MCP_FLASH_LOG` | `/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) | +| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) | + +## TCP / native-host nodes + +The `native-macos` and `native` PlatformIO envs build a headless `meshtasticd` +binary that runs on the host (Apple Silicon / Intel macOS, or Linux Portduino). +The daemon exposes the meshtastic TCP API on port `4403` rather than a USB +serial endpoint — point the MCP server at it via `MESHTASTIC_MCP_TCP_HOST`: + +```bash +# 1. Build + run a daemon on this host (see variants/native/portduino/platformio.ini +# for full Homebrew prereqs and CH341 LoRa-adapter setup). +pio run -e native-macos +~/.meshtasticd/meshtasticd + +# 2. Point the MCP server at it. +export MESHTASTIC_MCP_TCP_HOST=localhost # or host:port, default port 4403 +``` + +**First-run gotcha — MAC address.** `meshtasticd` derives its MAC from the +USB adapter's serial-number / product strings. Many cheap CH341 dongles +(MeshStick included — VID 0x1A86 / PID 0x5512) ship with `iSerialNumber=0` +and `iProduct=0`, so the daemon aborts on boot with `*** Blank MAC Address +not allowed!`. Set the MAC explicitly in `config.yaml`: + +```yaml +# Under General: +MACAddress: 02:CA:FE:BA:BE:01 +``` + +Use a locally-administered address (first byte's second-LSB set, e.g. +`02:*` / `06:*` / `0A:*` / `0E:*`) to avoid colliding with a real OUI. + +There is also a `--hwid AA:BB:CC:DD:EE:FF` CLI flag visible in +`meshtasticd --help`, but it is **currently broken** in +`MAC_from_string()` (`src/platform/portduino/PortduinoGlue.cpp`): the +function strips colons from its parameter but then reads bytes from the +global `portduino_config.mac_address`, so `--hwid` is silently overridden +when `MACAddress:` is also set, and crashes the daemon (uncaught +`std::invalid_argument: stoi: no conversion`) when it isn't. Use the YAML +form until that's fixed upstream. + +`list_devices` will surface the daemon as `tcp://localhost:4403` with +`likely_meshtastic=True`, so `device_info`, `list_nodes`, `get_config`, +`set_config`, `set_owner`, `send_text`, `userprefs_*`, and the admin RPCs +auto-select it when no `port` is passed. Pass `port="tcp://other-host:9999"` +explicitly to target a different daemon. + +**Tools that don't apply to a TCP/native node** (no USB hardware to operate +on) raise a clear `ConnectionError` rather than failing mysteriously: +`pio_flash`, `erase_and_flash`, `update_flash`, `touch_1200bps`, +`serial_open` (use info/admin tools directly), and the vendor escape hatches +`esptool_*`, `nrfutil_*`, `picotool_*`. `pio_flash` against a `native*` env +similarly raises — there's no upload step; use `build` and run the binary +directly. + +The pytest harness in `tests/` still assumes USB-attached devices per role — +TCP-aware fixtures are not part of this surface yet. ## Hardware Test Suite diff --git a/mcp-server/src/meshtastic_mcp/connection.py b/mcp-server/src/meshtastic_mcp/connection.py index 17a7e2c89..7dbf847b9 100644 --- a/mcp-server/src/meshtastic_mcp/connection.py +++ b/mcp-server/src/meshtastic_mcp/connection.py @@ -1,4 +1,4 @@ -"""Context manager for meshtastic.SerialInterface connections. +"""Context manager for meshtastic interface connections (serial + TCP). Every info/admin tool goes through `connect(port)` so we have a single place that: @@ -6,8 +6,16 @@ that: - fails fast if a serial_session is already holding the port, - guarantees `.close()` is called, even on exception. -The `SerialInterface` blocks on construction waiting for the node database; -that's fine for v1 since every tool is a short-lived request. +Two transports: + - Serial: USB-attached firmware on `/dev/cu.*` / `/dev/ttyUSB*` / `COM*`. + - TCP: a `meshtasticd` daemon (e.g. the native macOS / Linux Portduino + headless build) addressed as `tcp://host[:port]` (default port 4403). + Surfaced by `devices.list_devices()` when `MESHTASTIC_MCP_TCP_HOST` is + set, so `resolve_port(None)` auto-selects it like a USB candidate. + +Both `SerialInterface` and `TCPInterface` block on construction waiting for +the node database; that's fine for v1 since every tool is a short-lived +request. """ from __future__ import annotations @@ -17,20 +25,107 @@ from typing import Iterator from . import devices, registry +DEFAULT_TCP_PORT = 4403 +TCP_SCHEME = "tcp://" +TCP_HOST_ENV = "MESHTASTIC_MCP_TCP_HOST" + class ConnectionError(RuntimeError): pass +def is_tcp_port(port: str | None) -> bool: + return bool(port) and port.startswith(TCP_SCHEME) + + +def parse_tcp_port(port: str) -> tuple[str, int]: + """Parse `tcp://host[:port]` → (host, port). Defaults to 4403. + + Validates host shape (non-empty, no path separators) and port range + (1..65535). Raises `ConnectionError` on malformed input — never lets + a raw `ValueError` bubble up to a tool surface. + """ + if not port.startswith(TCP_SCHEME): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: expected '{TCP_SCHEME}host[:port]'." + ) + rest = port[len(TCP_SCHEME) :] + if ":" in rest: + host, port_str = rest.rsplit(":", 1) + try: + tcp_port = int(port_str) + except ValueError as e: + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: port {port_str!r} is not an integer." + ) from e + else: + host, tcp_port = rest, DEFAULT_TCP_PORT + if not host: + raise ConnectionError(f"Invalid TCP endpoint {port!r}: empty host.") + if any(c in host for c in ("/", "\\")): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: host {host!r} contains a path " + "separator. TCP hostnames cannot contain '/' or '\\' — did you " + "pass a serial port path or a Windows drive path by mistake?" + ) + if not (1 <= tcp_port <= 65535): + raise ConnectionError( + f"Invalid TCP endpoint {port!r}: port {tcp_port} out of range " + "(must be 1..65535)." + ) + return host, tcp_port + + +def normalize_tcp_endpoint(endpoint: str) -> str: + r"""Normalize `host`, `host:port`, or `tcp://host[:port]` → canonical + `tcp://host:port` form. One place that owns the lock-key shape. + + Defers all validation to `parse_tcp_port`, so path-like inputs + (`/dev/cu.foo`, `C:\Windows\…`), empty hosts, non-integer ports, + and out-of-range ports raise `ConnectionError` here too. + """ + if endpoint.startswith(TCP_SCHEME): + canonical = endpoint + elif ":" in endpoint: + canonical = f"{TCP_SCHEME}{endpoint}" + else: + canonical = f"{TCP_SCHEME}{endpoint}:{DEFAULT_TCP_PORT}" + host, port = parse_tcp_port(canonical) + return f"{TCP_SCHEME}{host}:{port}" + + +def reject_if_tcp(port: str | None, tool_name: str) -> None: + """Raise if `port` is a TCP endpoint — for tools that need real USB + hardware (flash, bootloader, vendor escape hatches, serial monitor). + + Only checks the explicit arg; auto-selection via env var is the caller's + responsibility to handle if it matters. + """ + if is_tcp_port(port): + raise ConnectionError( + f"{tool_name} is not applicable to TCP/native nodes ({port}). " + "This tool requires USB-attached hardware." + ) + + def resolve_port(port: str | None) -> str: - """Pick a port: explicit > sole likely_meshtastic candidate > error.""" + """Pick a port: explicit > sole likely_meshtastic candidate > error. + + A `tcp://` string passes through (after canonicalization). When `port` + is None and no USB candidates are present, `MESHTASTIC_MCP_TCP_HOST` + is consulted via `devices.list_devices()`. + """ if port: + if is_tcp_port(port): + return normalize_tcp_endpoint(port) return port candidates = [d for d in devices.list_devices() if d["likely_meshtastic"]] if not candidates: raise ConnectionError( - "No Meshtastic devices detected. Plug one in or pass `port` explicitly. " - "Run `list_devices` with include_unknown=True to see all serial ports." + "No Meshtastic devices detected. Plug one in, set " + f"{TCP_HOST_ENV}= for a meshtasticd daemon, " + "or pass `port` explicitly. Run `list_devices` with " + "include_unknown=True to see all serial ports." ) if len(candidates) > 1: ports = ", ".join(c["port"] for c in candidates) @@ -43,17 +138,62 @@ def resolve_port(port: str | None) -> str: @contextmanager def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator: - """Open a `meshtastic.SerialInterface` and always close it. + """Open a meshtastic interface (serial or TCP) and always close it. - Raises `ConnectionError` immediately if another serial session holds the - port (a `pio device monitor` in `serial_sessions/`, for instance). + For serial: raises `ConnectionError` immediately if another serial + session holds the port (a `pio device monitor` in `serial_sessions/`). + For TCP: no exclusive-access requirement, so the serial-session check + is skipped — but the `port_lock` still serializes parallel `connect()` + calls to the same daemon endpoint. + + `timeout_s` is plumbed through to both `SerialInterface(timeout=...)` + and `TCPInterface(timeout=...)`. The meshtastic library uses the value + as the reply-wait deadline for `localNode.waitForConfig()` during + construction and for any subsequent admin RPC. `int()`-converted at + the boundary because the upstream API expects whole seconds. """ + resolved = resolve_port(port) + timeout = int(timeout_s) + + if is_tcp_port(resolved): + from meshtastic.tcp_interface import ( + TCPInterface, # type: ignore[import-untyped] + ) + + host, tcp_port = parse_tcp_port(resolved) + lock = registry.port_lock(resolved) + if not lock.acquire(blocking=False): + raise ConnectionError( + f"TCP endpoint {resolved} is busy — another device operation " + "is in flight. Retry shortly." + ) + + iface = None + try: + iface = TCPInterface( + hostname=host, + portNumber=tcp_port, + connectNow=True, + noProto=False, + timeout=timeout, + ) + yield iface + finally: + if iface is not None: + try: + iface.close() + except Exception: + pass + try: + lock.release() + except RuntimeError: + pass + return + from meshtastic.serial_interface import ( SerialInterface, # type: ignore[import-untyped] ) - resolved = resolve_port(port) - active = registry.active_session_for_port(resolved) if active is not None: raise ConnectionError( @@ -70,7 +210,12 @@ def connect(port: str | None = None, timeout_s: float = 8.0) -> Iterator: iface = None try: - iface = SerialInterface(devPath=resolved, connectNow=True, noProto=False) + iface = SerialInterface( + devPath=resolved, + connectNow=True, + noProto=False, + timeout=timeout, + ) yield iface finally: if iface is not None: diff --git a/mcp-server/src/meshtastic_mcp/devices.py b/mcp-server/src/meshtastic_mcp/devices.py index c4805c1ab..976e893a0 100644 --- a/mcp-server/src/meshtastic_mcp/devices.py +++ b/mcp-server/src/meshtastic_mcp/devices.py @@ -1,13 +1,18 @@ -"""USB/serial device discovery. +"""USB/serial + TCP device discovery. Combines the canonical `meshtastic.util.findPorts()` allowlist/blocklist with the richer metadata (`serial.tools.list_ports.comports()`) so callers see VID/PID, descriptions, and manufacturer strings alongside the "is this likely a Meshtastic device" signal. + +If `MESHTASTIC_MCP_TCP_HOST=` is set, a synthetic entry for the +`meshtasticd` daemon at that endpoint is prepended to the result, so +`resolve_port(None)` auto-selects it like a USB candidate. """ from __future__ import annotations +import os from typing import Any from serial.tools import list_ports @@ -19,6 +24,45 @@ def _to_hex(value: int | None) -> str | None: return f"0x{value:04x}" +def _tcp_endpoint_from_env() -> dict[str, Any] | None: + """Synthesize a TCP device entry from MESHTASTIC_MCP_TCP_HOST, if set. + + If the env var is malformed (non-integer port, path-like host, etc.), + return an entry with `likely_meshtastic=False` and the parser error in + the description, rather than raising — `list_devices` is the diagnostic + tool a user reaches for when their env var isn't working, so it must + not crash on misconfiguration. + """ + host = os.environ.get("MESHTASTIC_MCP_TCP_HOST") + if not host: + return None + # Lazy import to avoid a circular dependency (connection imports devices). + from . import connection + + try: + port = connection.normalize_tcp_endpoint(host) + description = "meshtasticd (TCP)" + likely = True + except connection.ConnectionError as e: + # Surface the raw env-var value plus the parser's reason so the + # user can see exactly what they set and why it was rejected. + # Don't double the scheme if the user already prefixed `tcp://`. + port = host if host.startswith(connection.TCP_SCHEME) else f"tcp://{host}" + description = f"meshtasticd (TCP) — invalid MESHTASTIC_MCP_TCP_HOST: {e}" + likely = False + return { + "port": port, + "vid": None, + "pid": None, + "description": description, + "manufacturer": None, + "product": None, + "serial_number": None, + "likely_meshtastic": likely, + "blacklisted": False, + } + + def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]: """Return enriched info for serial ports, flagging Meshtastic candidates. @@ -70,6 +114,22 @@ def list_devices(include_unknown: bool = False) -> list[dict[str, Any]]: } ) - # Stable ordering: likely_meshtastic first, then by port path - results.sort(key=lambda r: (not r["likely_meshtastic"], r["port"])) + # Append the TCP endpoint (if env var set) and sort everything together. + tcp_entry = _tcp_endpoint_from_env() + if tcp_entry is not None: + results.append(tcp_entry) + + # Stable ordering: likely_meshtastic first; within rank, TCP wins over + # USB (explicit env-var configuration takes precedence over USB + # enumeration); then by port path. A misconfigured TCP entry has + # likely_meshtastic=False and lands among the other ignored entries — + # it does NOT pre-empt real USB devices at the top of the list. + results.sort( + key=lambda r: ( + not r["likely_meshtastic"], + not r["port"].startswith("tcp://"), + r["port"], + ) + ) + return results diff --git a/mcp-server/src/meshtastic_mcp/flash.py b/mcp-server/src/meshtastic_mcp/flash.py index 2c41a7c21..e11197d5f 100644 --- a/mcp-server/src/meshtastic_mcp/flash.py +++ b/mcp-server/src/meshtastic_mcp/flash.py @@ -17,7 +17,7 @@ from typing import Any import serial -from . import boards, config, devices, pio, userprefs +from . import boards, config, connection, devices, pio, userprefs # Meshtastic variants use both `esp32s3` and `esp32-s3` style names across # variants/*/platformio.ini (no consistency enforced). Accept both spellings. @@ -46,6 +46,18 @@ def _require_confirm(confirm: bool, operation: str) -> None: ) +def _reject_native_env(env: str, operation: str) -> None: + """`native*` envs build a host executable, not firmware — there's no + upload step. The user wants `build` (or just runs the binary directly). + """ + if env.startswith("native"): + raise FlashError( + f"{operation} is not applicable for env {env!r}: native envs " + "produce a host executable, not flashable firmware. Use `build` " + "instead, then run the resulting binary directly." + ) + + def _artifacts_for(env: str) -> list[Path]: build_dir = config.firmware_root() / ".pio" / "build" / env if not build_dir.is_dir(): @@ -141,6 +153,8 @@ def flash( that pio performs will pick up the injected values. """ _require_confirm(confirm, "flash") + _reject_native_env(env, "flash") + connection.reject_if_tcp(port, "flash") with userprefs.temporary_overrides(userprefs_overrides) as effective: result = pio.run( ["run", "-e", env, "-t", "upload", "--upload-port", port], @@ -200,6 +214,7 @@ def erase_and_flash( in that case) since a cached factory.bin would not reflect the new prefs. """ _require_confirm(confirm, "erase_and_flash") + connection.reject_if_tcp(port, "erase_and_flash") _check_esp32_env(env) if userprefs_overrides and skip_build: @@ -257,6 +272,7 @@ def update_flash( overrides are provided we always force a rebuild. """ _require_confirm(confirm, "update_flash") + connection.reject_if_tcp(port, "update_flash") _check_esp32_env(env) if userprefs_overrides and skip_build: @@ -391,6 +407,7 @@ def touch_1200bps( Returns `{ok, former_port, new_port, new_port_vid_pid, attempts}`. """ + connection.reject_if_tcp(port, "touch_1200bps") before_list = devices.list_devices(include_unknown=True) before_ports = {d["port"] for d in before_list} diff --git a/mcp-server/src/meshtastic_mcp/hw_tools.py b/mcp-server/src/meshtastic_mcp/hw_tools.py index 4275539ba..1835f4ef1 100644 --- a/mcp-server/src/meshtastic_mcp/hw_tools.py +++ b/mcp-server/src/meshtastic_mcp/hw_tools.py @@ -16,7 +16,7 @@ import subprocess from pathlib import Path from typing import Any, Sequence -from . import config, pio +from . import config, connection, pio _TIMEOUT_SHORT = 30 _TIMEOUT_LONG = 600 @@ -102,6 +102,7 @@ def _parse_esptool_chip_info(stdout: str) -> dict[str, Any]: def esptool_chip_info(port: str) -> dict[str, Any]: + connection.reject_if_tcp(port, "esptool_chip_info") binary = config.esptool_bin() # `chip_id` prints chip + mac + crystal + features. `flash_id` adds flash. combined = _run(binary, ["--port", port, "flash_id"], timeout=_TIMEOUT_SHORT) @@ -116,6 +117,7 @@ def esptool_chip_info(port: str) -> dict[str, Any]: def esptool_erase_flash(port: str, confirm: bool = False) -> dict[str, Any]: """Full-chip erase. Leaves the device unbootable until reflashed.""" _require_confirm(confirm, "esptool_erase_flash") + connection.reject_if_tcp(port, "esptool_erase_flash") binary = config.esptool_bin() # esptool v5 uses `erase-flash`, older uses `erase_flash`. Try the new name # first; if it fails with unknown command, retry old. @@ -134,6 +136,7 @@ def esptool_raw( """Raw esptool passthrough. Destructive subcommands require confirm=True.""" if not args: raise ToolError("args must not be empty") + connection.reject_if_tcp(port, "esptool_raw") # Find the first non-flag arg (the subcommand). subcommand = next((a for a in args if not a.startswith("-")), None) if subcommand and subcommand.replace("-", "_") in { @@ -156,6 +159,7 @@ NRFUTIL_DESTRUCTIVE = {"dfu", "settings"} def nrfutil_dfu(port: str, package_path: str, confirm: bool = False) -> dict[str, Any]: _require_confirm(confirm, "nrfutil_dfu") + connection.reject_if_tcp(port, "nrfutil_dfu") pkg = Path(package_path).expanduser() if not pkg.is_file(): raise ToolError(f"Package not found: {pkg}") @@ -213,6 +217,7 @@ def _parse_picotool_info(stdout: str) -> dict[str, Any]: def picotool_info(port: str | None = None) -> dict[str, Any]: """Read device info from a Pico in BOOTSEL mode. `port` is informational only — picotool auto-detects.""" + connection.reject_if_tcp(port, "picotool_info") binary = config.picotool_bin() res = _run(binary, ["info", "-a"], timeout=_TIMEOUT_SHORT) if res["exit_code"] != 0: diff --git a/mcp-server/src/meshtastic_mcp/serial_session.py b/mcp-server/src/meshtastic_mcp/serial_session.py index b9c71d1d0..43537323f 100644 --- a/mcp-server/src/meshtastic_mcp/serial_session.py +++ b/mcp-server/src/meshtastic_mcp/serial_session.py @@ -71,6 +71,10 @@ def open_session( If `env` is supplied, pio resolves baud and filters from platformio.ini. Otherwise uses the supplied `baud` and `filters` (default `['direct']`). """ + # Lazy import to avoid circular: registry imports serial_session. + from . import connection + + connection.reject_if_tcp(port, "serial_open") args = ["device", "monitor", "--port", port, "--no-reconnect"] effective_filters: list[str] effective_baud: int = baud diff --git a/mcp-server/tests/unit/test_connection_tcp.py b/mcp-server/tests/unit/test_connection_tcp.py new file mode 100644 index 000000000..54b7e9b47 --- /dev/null +++ b/mcp-server/tests/unit/test_connection_tcp.py @@ -0,0 +1,383 @@ +"""TCP transport plumbing in connection.py + devices.py. + +Pure-Python tests — no real device or daemon required. Mocks `TCPInterface` +when exercising `connect()`. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from meshtastic_mcp import connection, devices + +# ---------- helpers -------------------------------------------------------- + + +class TestIsTcpPort: + def test_tcp_scheme(self) -> None: + assert connection.is_tcp_port("tcp://localhost") is True + assert connection.is_tcp_port("tcp://localhost:4403") is True + assert connection.is_tcp_port("tcp://192.168.1.50:9999") is True + + def test_serial_paths(self) -> None: + assert connection.is_tcp_port("/dev/cu.usbmodem1234") is False + assert connection.is_tcp_port("/dev/ttyUSB0") is False + assert connection.is_tcp_port("COM3") is False + + def test_empty_or_none(self) -> None: + assert connection.is_tcp_port(None) is False + assert connection.is_tcp_port("") is False + + +class TestParseTcpPort: + def test_default_port(self) -> None: + assert connection.parse_tcp_port("tcp://localhost") == ("localhost", 4403) + + def test_explicit_port(self) -> None: + assert connection.parse_tcp_port("tcp://localhost:9999") == ( + "localhost", + 9999, + ) + + def test_ip_with_port(self) -> None: + assert connection.parse_tcp_port("tcp://192.168.1.50:4403") == ( + "192.168.1.50", + 4403, + ) + + +class TestNormalizeTcpEndpoint: + def test_bare_host(self) -> None: + assert connection.normalize_tcp_endpoint("localhost") == "tcp://localhost:4403" + + def test_host_port(self) -> None: + assert ( + connection.normalize_tcp_endpoint("localhost:5000") + == "tcp://localhost:5000" + ) + + def test_full_url(self) -> None: + assert ( + connection.normalize_tcp_endpoint("tcp://1.2.3.4") == "tcp://1.2.3.4:4403" + ) + assert ( + connection.normalize_tcp_endpoint("tcp://1.2.3.4:9999") + == "tcp://1.2.3.4:9999" + ) + + def test_idempotent(self) -> None: + once = connection.normalize_tcp_endpoint("localhost:4403") + twice = connection.normalize_tcp_endpoint(once) + assert once == twice == "tcp://localhost:4403" + + def test_path_like_endpoint_rejected(self) -> None: + # Serial port paths and Windows drive paths are common config typos + # (someone passes a serial path to MESHTASTIC_MCP_TCP_HOST). Reject + # rather than producing a nonsense `tcp:///dev/cu.foo:4403` URL. + with pytest.raises(connection.ConnectionError, match="path separator"): + connection.normalize_tcp_endpoint("/dev/cu.foo") + with pytest.raises(connection.ConnectionError): + connection.normalize_tcp_endpoint("tcp:///dev/cu.foo:4403") + with pytest.raises(connection.ConnectionError): + connection.normalize_tcp_endpoint(r"C:\Windows\System32") + + def test_non_integer_port_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="not an integer"): + connection.normalize_tcp_endpoint("tcp://host:notaport") + with pytest.raises(connection.ConnectionError, match="not an integer"): + connection.normalize_tcp_endpoint("host:notaport") + + def test_empty_host_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="empty host"): + connection.normalize_tcp_endpoint("tcp://:4403") + + def test_port_out_of_range_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("tcp://host:0") + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("tcp://host:65536") + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.normalize_tcp_endpoint("host:99999") + + +class TestParseTcpPortValidation: + def test_missing_scheme_rejected(self) -> None: + # parse_tcp_port is a low-level helper that requires the scheme. + # Misuse should fail loudly rather than silently mis-parsing. + with pytest.raises(connection.ConnectionError, match="expected"): + connection.parse_tcp_port("localhost:4403") + + def test_negative_port_rejected(self) -> None: + with pytest.raises(connection.ConnectionError, match="out of range"): + connection.parse_tcp_port("tcp://host:-1") + + +# ---------- reject_if_tcp -------------------------------------------------- + + +class TestRejectIfTcp: + def test_rejects_tcp(self) -> None: + with pytest.raises(connection.ConnectionError, match="not applicable"): + connection.reject_if_tcp("tcp://localhost", "esptool_chip_info") + + def test_passes_through_serial(self) -> None: + connection.reject_if_tcp("/dev/cu.usbmodem1", "esptool_chip_info") # no raise + + def test_passes_through_none(self) -> None: + # None means "auto-detect"; not the explicit-arg case we guard. + connection.reject_if_tcp(None, "esptool_chip_info") # no raise + + +# ---------- resolve_port --------------------------------------------------- + + +class TestResolvePort: + def test_explicit_serial_passthrough(self) -> None: + assert connection.resolve_port("/dev/cu.usbmodem999") == "/dev/cu.usbmodem999" + + def test_explicit_tcp_normalized(self) -> None: + assert connection.resolve_port("tcp://localhost") == "tcp://localhost:4403" + + def test_no_port_no_devices_errors(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch.object(devices, "list_devices", return_value=[]): + with pytest.raises( + connection.ConnectionError, match="No Meshtastic devices" + ): + connection.resolve_port(None) + + def test_no_port_one_candidate_selected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + fake = [{"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}] + with patch.object(devices, "list_devices", return_value=fake): + assert connection.resolve_port(None) == "/dev/cu.usbmodem1" + + def test_no_port_multiple_candidates_errors( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + fake = [ + {"port": "/dev/cu.usbmodem1", "likely_meshtastic": True}, + {"port": "/dev/cu.usbmodem2", "likely_meshtastic": True}, + ] + with patch.object(devices, "list_devices", return_value=fake): + with pytest.raises(connection.ConnectionError, match="Multiple"): + connection.resolve_port(None) + + def test_env_var_surfaces_tcp_via_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost") + # Don't patch list_devices — let the real env-var path run, but stub + # the USB enumeration to keep the test hermetic. + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + assert connection.resolve_port(None) == "tcp://localhost:4403" + + +# ---------- devices.list_devices TCP entry -------------------------------- + + +class TestDevicesTcpEntry: + def test_no_env_var_no_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + assert all(not d["port"].startswith("tcp://") for d in ds) + + def test_env_var_adds_tcp_entry(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "myhost:9999") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + tcp = [d for d in ds if d["port"].startswith("tcp://")] + assert len(tcp) == 1 + assert tcp[0]["port"] == "tcp://myhost:9999" + assert tcp[0]["likely_meshtastic"] is True + assert tcp[0]["description"] == "meshtasticd (TCP)" + + def test_tcp_entry_first_in_results(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices() + assert ds, "expected at least the TCP entry" + assert ds[0]["port"].startswith("tcp://") + + def test_invalid_env_var_does_not_break_list_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `list_devices` is the diagnostic tool reached for when an env var + # isn't working — it must not throw on misconfiguration. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices(include_unknown=True) + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["likely_meshtastic"] is False + assert "invalid MESHTASTIC_MCP_TCP_HOST" in tcp[0]["description"] + assert "not an integer" in tcp[0]["description"] + + def test_invalid_env_var_excluded_from_resolve_port_autodetect( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # `likely_meshtastic=False` keeps the bad TCP entry out of the + # auto-select path — `resolve_port(None)` should still report + # "no Meshtastic devices" rather than picking a broken endpoint. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + with pytest.raises(connection.ConnectionError, match="No Meshtastic"): + connection.resolve_port(None) + + def test_invalid_env_var_does_not_double_tcp_scheme( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # If a user mistakenly sets `MESHTASTIC_MCP_TCP_HOST=tcp://host:bad`, + # the diagnostic entry must surface the raw value as-is rather than + # producing `tcp://tcp://host:bad`. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "tcp://host:notaport") + with patch("meshtastic_mcp.devices.list_ports.comports", return_value=[]): + ds = devices.list_devices(include_unknown=True) + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["port"] == "tcp://host:notaport" + assert "tcp://tcp://" not in tcp[0]["port"] + + def test_invalid_env_var_does_not_pre_empt_real_usb_devices( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Sort ordering: a misconfigured TCP env var must NOT take position 0 + # ahead of real USB candidates. Position 0 is reserved for the highest + # rank (likely_meshtastic=True), with TCP-before-USB as a tiebreaker + # within rank. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "host:notaport") + + # Stub a USB Meshtastic candidate (Espressif VID, port present in + # findPorts). + class FakeInfo: + def __init__(self, device: str, vid: int, pid: int) -> None: + self.device = device + self.vid = vid + self.pid = pid + self.description = "Heltec V3" + self.manufacturer = "Espressif" + self.product = "USB JTAG/serial" + self.serial_number = "abc" + + fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001) + with patch( + "meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port] + ), patch( + "meshtastic.util.findPorts", + return_value=["/dev/cu.usbmodem4201"], + ): + ds = devices.list_devices(include_unknown=True) + + assert ds, "expected at least the USB + TCP entries" + # Real USB candidate must be at position 0 — it's likely_meshtastic. + assert ds[0]["port"] == "/dev/cu.usbmodem4201" + assert ds[0]["likely_meshtastic"] is True + # The malformed TCP entry exists but lands among the unlikely entries. + tcp = [d for d in ds if "TCP" in (d["description"] or "")] + assert len(tcp) == 1 + assert tcp[0]["likely_meshtastic"] is False + assert ds.index(tcp[0]) > 0 + + def test_likely_tcp_entry_wins_tiebreak_over_usb( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + # Conversely, a *valid* TCP env var should sort ahead of USB + # candidates of equal likely_meshtastic rank — explicit env-var + # configuration is a precedence signal. + monkeypatch.setenv("MESHTASTIC_MCP_TCP_HOST", "localhost:4403") + + class FakeInfo: + def __init__(self, device: str, vid: int, pid: int) -> None: + self.device = device + self.vid = vid + self.pid = pid + self.description = "Heltec V3" + self.manufacturer = "Espressif" + self.product = "USB JTAG/serial" + self.serial_number = "abc" + + fake_port = FakeInfo("/dev/cu.usbmodem4201", 0x303A, 0x1001) + with patch( + "meshtastic_mcp.devices.list_ports.comports", return_value=[fake_port] + ), patch( + "meshtastic.util.findPorts", + return_value=["/dev/cu.usbmodem4201"], + ): + ds = devices.list_devices() + + assert ds[0]["port"] == "tcp://localhost:4403" + assert ds[0]["likely_meshtastic"] is True + + +# ---------- connect() routing --------------------------------------------- + + +class TestConnectRoutesTcp: + def test_connect_uses_tcp_interface_for_tcp_port( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify the TCP branch instantiates `TCPInterface(hostname, portNumber)` + and never touches `SerialInterface`.""" + # Make sure the env var doesn't leak in and confuse resolve_port. + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp, patch( + "meshtastic.serial_interface.SerialInterface" + ) as mock_serial: + mock_tcp.return_value.close.return_value = None + with connection.connect(port="tcp://example.com:1234", timeout_s=12.0): + pass + + mock_tcp.assert_called_once_with( + hostname="example.com", + portNumber=1234, + connectNow=True, + noProto=False, + timeout=12, + ) + mock_serial.assert_not_called() + + def test_connect_plumbs_timeout_to_serial_interface( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify the serial branch also propagates `timeout_s` so callers + passing a custom timeout to `device_info` / `list_nodes` / etc. don't + silently get the library default.""" + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + + with patch("meshtastic.serial_interface.SerialInterface") as mock_serial, patch( + "meshtastic.tcp_interface.TCPInterface" + ) as mock_tcp: + mock_serial.return_value.close.return_value = None + with connection.connect(port="/dev/cu.fake", timeout_s=20.0): + pass + + mock_serial.assert_called_once_with( + devPath="/dev/cu.fake", + connectNow=True, + noProto=False, + timeout=20, + ) + mock_tcp.assert_not_called() + + def test_connect_releases_lock_on_tcp_failure( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("MESHTASTIC_MCP_TCP_HOST", raising=False) + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp: + mock_tcp.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + with connection.connect(port="tcp://locktest:4403"): + pass + + # Lock should be released — a second connect attempt must not fail + # with "busy". + with patch("meshtastic.tcp_interface.TCPInterface") as mock_tcp: + mock_tcp.return_value.close.return_value = None + with connection.connect(port="tcp://locktest:4403"): + pass From 7066abbb86739be7e3a907fb40ed2bd4744f637b Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 30 Apr 2026 13:52:42 -0500 Subject: [PATCH 21/34] Fix MAC_from_string to use input parameter instead of global config for MAC address parsing (#10356) * Fix MAC_from_string to use input parameter instead of global config for MAC address parsing * Enhance MAC_from_string validation and error handling * Add missing include for in PortduinoGlue.cpp --- src/platform/portduino/PortduinoGlue.cpp | 33 ++-- test/test_mac_from_string/test_main.cpp | 195 +++++++++++++++++++++++ 2 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 test/test_mac_from_string/test_main.cpp diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index fbaa3c98c..eeb56240d 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -1052,17 +1053,31 @@ static bool ends_with(std::string_view str, std::string_view suffix) bool MAC_from_string(std::string mac_str, uint8_t *dmac) { mac_str.erase(std::remove(mac_str.begin(), mac_str.end(), ':'), mac_str.end()); - if (mac_str.length() == 12) { - dmac[0] = std::stoi(portduino_config.mac_address.substr(0, 2), nullptr, 16); - dmac[1] = std::stoi(portduino_config.mac_address.substr(2, 2), nullptr, 16); - dmac[2] = std::stoi(portduino_config.mac_address.substr(4, 2), nullptr, 16); - dmac[3] = std::stoi(portduino_config.mac_address.substr(6, 2), nullptr, 16); - dmac[4] = std::stoi(portduino_config.mac_address.substr(8, 2), nullptr, 16); - dmac[5] = std::stoi(portduino_config.mac_address.substr(10, 2), nullptr, 16); - return true; - } else { + if (mac_str.length() != 12) { return false; } + // Validate every character is a hex digit before parsing. std::stoi + // would otherwise skip leading whitespace and silently truncate at the + // first non-digit, which is too lenient for a MAC address. + for (char c : mac_str) { + if (!isxdigit(static_cast(c))) { + return false; + } + } + // Parse into a temporary so dmac is not partially modified if a later + // byte fails. At least one caller in getMacAddr() ignores the bool + // return, so leaving stale bytes in dmac on failure would silently + // produce a wrong MAC. + uint8_t tmp[6]; + try { + for (int i = 0; i < 6; i++) { + tmp[i] = static_cast(std::stoi(mac_str.substr(i * 2, 2), nullptr, 16)); + } + } catch (const std::exception &) { + return false; + } + memcpy(dmac, tmp, 6); + return true; } std::string exec(const char *cmd) diff --git a/test/test_mac_from_string/test_main.cpp b/test/test_mac_from_string/test_main.cpp new file mode 100644 index 000000000..c9d2289cc --- /dev/null +++ b/test/test_mac_from_string/test_main.cpp @@ -0,0 +1,195 @@ +// Unit tests for MAC_from_string in src/platform/portduino/PortduinoGlue.cpp. +// +// Regression coverage for when the function stripped colons from +// its mac_str parameter but then read bytes from the global +// portduino_config.mac_address. Symptoms: --hwid silently ignored when +// MACAddress: was also set, and SIGABRT (stoi: no conversion) when --hwid +// was used without MACAddress: in config.yaml. +#include "Arduino.h" +#include "TestUtil.h" +#include +#include +#include +#include + +// Forward-declare instead of including PortduinoGlue.h to avoid pulling in +// LR11x0Interface, USBHal, mesh.pb.h, yaml-cpp, and the full portduino_config +// struct just to test a self-contained string parser. The symbol is defined +// in PortduinoGlue.cpp and resolved at link time. +bool MAC_from_string(std::string mac_str, uint8_t *dmac); + +void setUp(void) {} +void tearDown(void) {} + +// --- Happy-path parsing --- + +void test_colon_separated_uppercase() +{ + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xBB, dmac[1]); + TEST_ASSERT_EQUAL_HEX8(0xCC, dmac[2]); + TEST_ASSERT_EQUAL_HEX8(0xDD, dmac[3]); + TEST_ASSERT_EQUAL_HEX8(0xEE, dmac[4]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_colon_separated_lowercase() +{ + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("02:ca:fe:ba:be:01", dmac)); + TEST_ASSERT_EQUAL_HEX8(0x02, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xCA, dmac[1]); + TEST_ASSERT_EQUAL_HEX8(0xFE, dmac[2]); + TEST_ASSERT_EQUAL_HEX8(0xBA, dmac[3]); + TEST_ASSERT_EQUAL_HEX8(0xBE, dmac[4]); + TEST_ASSERT_EQUAL_HEX8(0x01, dmac[5]); +} + +void test_no_colons_packed_hex() +{ + // The CLI form produced by some tools — 12 hex chars, no separators. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AABBCCDDEEFF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_two_distinct_inputs_yield_distinct_outputs() +{ + // Direct regression for the original bug: parsing two different MAC + // strings in succession must produce two different byte sequences. + // Pre-fix, both calls would have produced identical bytes derived from + // the (untouched) global portduino_config.mac_address. + uint8_t a[6] = {0}; + uint8_t b[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AA:BB:CC:DD:EE:FF", a)); + TEST_ASSERT_TRUE(MAC_from_string("02:CA:FE:BA:BE:01", b)); + TEST_ASSERT_NOT_EQUAL(0, std::memcmp(a, b, 6)); + TEST_ASSERT_EQUAL_HEX8(0xAA, a[0]); + TEST_ASSERT_EQUAL_HEX8(0x02, b[0]); +} + +void test_does_not_read_external_state() +{ + // The function must derive every byte from its parameter, not from any + // global. Provide a unique MAC and verify all six bytes match the input + // exactly — leaves no room for the function to be smuggling bytes from + // elsewhere. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("12:34:56:78:9A:BC", dmac)); + const uint8_t expected[6] = {0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC}; + TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, dmac, 6); +} + +// --- Rejected inputs --- +// Pre-fix, the empty/short cases either crashed (stoi exception on substr("") +// of the empty global) or silently filled dmac with stale bytes. Post-fix, +// the length guard rejects them cleanly with `false` and dmac is unchanged. + +void test_empty_string_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("", dmac)); + // dmac must be untouched on failure. + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_too_short_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_too_long_returns_false() +{ + uint8_t dmac[6] = {0}; + // 14 hex chars after colon-strip > 12. + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:FF:00", dmac)); +} + +void test_only_colons_returns_false() +{ + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string(":::::", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_extra_colons_still_parses() +{ + // Colon stripping happens before length check, so an unconventional + // grouping that totals 12 hex chars after stripping is still accepted. + uint8_t dmac[6] = {0}; + TEST_ASSERT_TRUE(MAC_from_string("AABB:CCDD:EEFF", dmac)); + TEST_ASSERT_EQUAL_HEX8(0xAA, dmac[0]); + TEST_ASSERT_EQUAL_HEX8(0xFF, dmac[5]); +} + +void test_non_hex_input_returns_false() +{ + // 12 chars of non-hex would have made std::stoi throw before the + // try/catch wrapper was added, killing the daemon. Now must return false + // and leave dmac untouched. + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_partial_hex_failure_preserves_dmac() +{ + // First five bytes are valid hex; the sixth ("ZZ") is not. Without the + // temp-buffer staging, dmac would be partially overwritten with the five + // good bytes plus stale data in slot 5 — silently producing a wrong MAC + // since the only caller that uses this in getMacAddr() ignores the bool + // return value. + uint8_t dmac[6] = {0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x11}; + uint8_t before[6]; + std::memcpy(before, dmac, 6); + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE:ZZ", dmac)); + TEST_ASSERT_EQUAL_HEX8_ARRAY(before, dmac, 6); +} + +void test_embedded_non_hex_returns_false() +{ + // std::stoi tolerates leading whitespace and a "0x" prefix, so a stray + // space inside a 2-char window like " F" would silently parse as 0xF. + // The per-character isxdigit() pre-check rejects these. The 14-char + // "0xAABBCCDDEEFF" is also rejected by the length check. + uint8_t dmac[6] = {0}; + TEST_ASSERT_FALSE(MAC_from_string("AA:BB:CC:DD:EE: F", dmac)); + TEST_ASSERT_FALSE(MAC_from_string("0xAABBCCDDEEFF", dmac)); +} + +// --- Unity lifecycle --- + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_colon_separated_uppercase); + RUN_TEST(test_colon_separated_lowercase); + RUN_TEST(test_no_colons_packed_hex); + RUN_TEST(test_two_distinct_inputs_yield_distinct_outputs); + RUN_TEST(test_does_not_read_external_state); + RUN_TEST(test_empty_string_returns_false); + RUN_TEST(test_too_short_returns_false); + RUN_TEST(test_too_long_returns_false); + RUN_TEST(test_only_colons_returns_false); + RUN_TEST(test_extra_colons_still_parses); + RUN_TEST(test_non_hex_input_returns_false); + RUN_TEST(test_partial_hex_failure_preserves_dmac); + RUN_TEST(test_embedded_non_hex_returns_false); + exit(UNITY_END()); +} + +void loop() {} From 4ee959810795e94b6046c0bc4854f5ec6a859927 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 30 Apr 2026 16:22:11 -0400 Subject: [PATCH 22/34] Docker: Install grpcio-tools from distro (#10358) Use distro provided Python at build time (instead of the `python` images from dockerhub) and install `grpcio-tools` using the distro provided packages. This should speed up build times, ESPECIALLY on riscv64 (where prebuilt `grpcio-tools` wheels are not provided on pip). Co-authored-by: Copilot --- Dockerfile | 4 +++- alpine.Dockerfile | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e00d81658..ba013cb15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,17 @@ # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions -FROM python:3.14-slim-trixie AS builder +FROM debian:trixie AS builder ARG PIO_ENV=native ENV DEBIAN_FRONTEND=noninteractive ENV TZ=Etc/UTC # Install Dependencies ENV PIP_ROOT_USER_ACTION=ignore +ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apt-get update && apt-get install --no-install-recommends -y \ curl wget g++ zip git ca-certificates pkg-config \ + python3-pip python3-grpc-tools \ libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \ libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \ libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index 40a4990bb..6d1b999e2 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -4,12 +4,18 @@ # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions # Ensure the Alpine version is updated in both stages of the container! -FROM python:3.14-alpine3.23 AS builder +FROM alpine:3.23 AS builder ARG PIO_ENV=native -ENV PIP_ROOT_USER_ACTION=ignore +# Enable Alpine community repository (for 'py3-grpcio-tools') +RUN echo "https://dl-cdn.alpinelinux.org/alpine/v$(cut -d. -f1,2 /etc/alpine-release)/community" >> /etc/apk/repositories + +# Install Dependencies +ENV PIP_ROOT_USER_ACTION=ignore +ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apk --no-cache add \ bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \ + py3-pip py3-grpcio-tools \ libgpiod-dev yaml-cpp-dev bluez-dev \ libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \ libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \ From 1eb860a3fc3b2654458bfeffdd27ac004b0bf9e2 Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Fri, 1 May 2026 21:25:19 +0800 Subject: [PATCH 23/34] fix(stm32wl,nrf52,fs): flash hardening, FS platform unification, write-behind LFS cache (FORMAT BREAK) (#10171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stm32wl: check HAL_FLASH_Unlock() return in _internal_flash_erase _internal_flash_prog already checks HAL_FLASH_Unlock() and returns LFS_ERR_IO on failure. _internal_flash_erase discarded the return value, proceeding to erase even if the flash was not unlocked. Apply the same check for consistency and safety. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: fix _internal_flash_prog to abort on first write error Previously the programming loop continued to the next doubleword after HAL_FLASH_Program() failed, potentially writing to invalid addresses and returning a misleading error code only at the end (last iteration). HAL_FLASH_Lock() was also skipped on the mid-loop early return path. - Move bounds check before the loop (validate full range at once) - Break on first HAL error so subsequent doublewords are not written - Move HAL_FLASH_Lock() after the loop so it always runs Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: clear stale flash SR error flags before erase and program Stale error flags in FLASH->SR from a previous failed operation can cause HAL_FLASH_Program() or HAL_FLASHEx_Erase() to return HAL_ERROR immediately without attempting the operation. Add __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS) after each HAL_FLASH_Unlock() in both _internal_flash_prog and _internal_flash_erase to ensure a clean state before each operation. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl: reject flash prog writes not aligned to 8-byte doubleword The STM32WL HAL minimum write unit is one 64-bit doubleword (8 bytes). _internal_flash_prog silently truncated any trailing bytes when size % 8 != 0 because dw_count = size / 8 drops the remainder. Return LFS_ERR_INVAL early so LittleFS sees the error rather than a silent short write. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fix(nrf52,fs): use atomic SafeFile rename instead of direct write NRF52 was bypassing the .tmp/readback/rename path entirely — openFile() deleted the target file and wrote directly to it, and close() returned true without verifying the write or renaming anything. Adafruit_LittleFS::rename() calls lfs_rename() directly (confirmed at Adafruit_LittleFS.cpp:205). Remove both ARCH_NRF52 guards so NRF52 follows the same write-to-.tmp → readback-hash → rename path used by all other platforms. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fix(admin): skip uiconfig.proto save on devices without a screen handleStoreDeviceUIConfig() was writing /prefs/uiconfig.proto unconditionally. MenuHandler.cpp is already gated behind #if HAS_SCREEN, so there is no path that populates UI config on screen-less platforms. Guard the save with #if HAS_SCREEN to avoid wasting a flash block on devices that will never use it. The read path (handleGetDeviceUIConfig) does not touch the filesystem and needs no change. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fs: enable format-on-retry for all platforms in saveToDisk The FSCom.format() call on save failure was guarded to ARCH_NRF52 with a comment that other platforms were not ready (bug #4184). STM32WL was added to the guard in a prior commit. All platforms now expose format semantics and the retry logic is identical — remove the guard. To keep NodeDB.cpp platform-agnostic and fix a CI failure on native-tft (portduino's fs::FS has no format() method), introduce fsFormat() in FSCommon as the single call-site for all callers: - Embedded (ESP32, NRF52, STM32WL, RP2040): delegates to FSCom.format() - Portduino: rmDir("/prefs") + FSBegin() (a no-op on portduino). rmDir("/prefs") is already called unconditionally by factoryReset() (NodeDB.cpp:504), so both primitives are proven on portduino. Replace both direct FSCom.format() calls in NodeDB.cpp with fsFormat(). Note: we do not run portduino locally — portduino/native build testers please verify the format-on-retry path. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * DO NOT MERGE: nrf52(fs): add File() default constructor bound to InternalFS Adds File() to the Adafruit LittleFS File class (in the Meshtastic Adafruit_nRF52_Arduino fork), delegating to File(InternalFS). This matches the default-constructible File API on all other platforms. The constructor is implemented in Adafruit_LittleFS_File.cpp rather than inline in the header to avoid a circular include between Adafruit_LittleFS_File.h and InternalFileSystem.h. FOLLOW-UP REQUIRED: nrf52.ini points to a commit SHA on the mesh-malaysia/Adafruit_nRF52_Arduino fork instead of the upstream meshtastic framework. Once meshtastic/Adafruit_nRF52_Arduino#5 is merged, revert nrf52.ini to point back to the upstream meshtastic framework URL. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): add File() default constructor and document LFS tunables Adds File() to STM32_LittleFS_Namespace::File, delegating to File(InternalFS). Implemented in the .cpp to avoid a circular include between STM32_LittleFS_File.h (which cannot include LittleFS.h) and the InternalFS extern declaration. This matches the File API on ESP32/RP2040/Portduino and is a prerequisite for removing the ARCH_STM32WL guard in xmodem.h. No behavior change — the constructor leaves the file in the same closed/unattached state as File(InternalFS) would. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * fs: remove arch-specific ifdefs from FSCommon, SafeFile, xmodem Now that NRF52 and STM32WL have File() default constructors and NRF52 has working atomic SafeFile rename, the capability gaps are closed. Remove all per-arch guards across the shared FS layer: FSCommon.cpp — renameFile(): Use FSCom.rename() on all platforms. Adafruit_LittleFS::rename() calls lfs_rename() directly (Adafruit_LittleFS.cpp:205). The copy+delete fallback on NRF52/RP2040 was never necessary. FSCommon.cpp — getFiles(): Replace four ARCH_ESP32 guards with a single filepath pointer at the top of the loop (file.path() on ESP32, file.name() elsewhere). Fix strcpy(fileInfo.file_name, filepath): bounded to sizeof(fileInfo.file_name)-1 with explicit NUL termination to prevent overflow of the 228-byte meshtastic_FileInfo::file_name array. FSCommon.cpp — listDir(): Same filepath pointer approach. NRF52/STM32WL were in an else-branch that only logged but never deleted — now all platforms follow the unified del path. 12 guards → 2. Fix three strncpy(buffer, ..., sizeof(buffer)) calls that did not NUL-terminate when source length >= sizeof(buffer) (255 bytes). Add explicit buffer[sizeof(buffer)-1] = '\0' after each. FSCommon.cpp — rmDir(): Use listDir(del=true) everywhere. The ARCH_NRF52 rmdir_r() path and the ARCH_ESP32|RP2040|PORTDUINO listDir() path collapse to one line. SafeFile.cpp: ARCH_NRF52 bypass removed (handled in preceding commit). xmodem.h: File file; now works on all platforms via default constructors added in the two preceding commits. Remaining #ifdef ARCH_ESP32 in FSCommon.cpp: exactly 4, all for the file.path() vs file.name() API difference (ESP32 Arduino LittleFS returns the full path; all others return only the name). That difference lives in the framework and cannot be closed here. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): add write-behind page cache, reduce virtual block size and FS reservation (FORMAT BREAK) Adds a write-behind (RMW) page cache to the STM32WL LittleFS driver, modelled after the NRF52 Adafruit approach (flash_cache.c). This allows LFS to use 256-byte virtual blocks backed by 2048-byte physical pages: the erase/prog callbacks accumulate changes in a 2 KB RAM buffer; the sync callback (and page eviction on page-change) flushes with a single HAL physical-erase + doubleword-program pass. LFS tunables changed (FORMAT BREAK — superblock parameters): block_size: 2048 B → 256 B (8 virtual blocks per physical page) read_size: 2048 B → 256 B (= block_size) prog_size: 2048 B → 256 B (= block_size; hardware min is 8 B) block_count: 112 → 80 (14 phys pages → 10 phys pages = 20 KiB) Benefits: - Internal fragmentation: max 2047 B/file → max 255 B/file - Heap per open LFS file: ~4 KB → 512 B (prog + read buffers) - Code flash headroom: 6.7 KB → ~14.1 KB (+7.4 KB) - Block budget: 80 virtual blocks, worst-case peak ~20, ~60 free Updates board_upload.maximum_size in wio-e5/platformio.ini from 233472 (256 KB − 28 KB) to 241664 (256 KB − 20 KB) to match the reduced FS reservation. Justification for the format break: the prior STM32WL firmware had several flash write bugs fixed earlier in this series (missing error flag clearing, no abort on first write failure, unaligned write acceptance). These bugs very likely caused silent config corruption on deployed devices. The format break should be treated as an enhancement: it provides a clean, reliably-written starting point. Users will need to reconfigure their device once after this update. Correctness fixes applied to the cache implementation: - alignas(8) on _page_cache: the buffer was uint8_t[] (alignment 1) but _flash_cache_flush casts it to const uint64_t* — undefined behaviour per C++ standard, potential Cortex-M hardfault. alignas(8) guarantees the required alignment for the doubleword cast. - HAL_FLASH_Lock() return value: was discarded. Now assigned to lock_rc and propagated into rc if prior writes succeeded, so LFS sees the error rather than a false success. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 * stm32wl(fs): reduce FS reservation from 10 pages to 7 pages (FORMAT BREAK) Reduces LFS_FLASH_TOTAL_SIZE from 10 × 2 KiB pages (20 KiB) to 7 × 2 KiB pages (14 KiB), freeing 6 KiB for firmware. board_upload.maximum_size updated accordingly across all STM32WL variants: 241664 (256 KiB - 20 KiB) → 247808 (256 KiB - 14 KiB) This is a FORMAT BREAK: existing filesystems must be erased before use. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Andrew Yong * fix(fs): return false in renameFile() when FSCom is not defined Avoids undefined behavior and -Wreturn-type warnings in configurations that compile FSCommon.cpp without a filesystem backend. Signed-off-by: Andrew Yong Assisted-by: Claude Sonnet 4.6 --------- Signed-off-by: Andrew Yong Co-authored-by: Ben Meadors --- src/FSCommon.cpp | 158 +++++------- src/FSCommon.h | 1 + src/SafeFile.cpp | 7 - src/mesh/NodeDB.cpp | 4 +- src/modules/AdminModule.cpp | 2 + src/platform/stm32wl/LittleFS.cpp | 235 +++++++++++------- src/platform/stm32wl/STM32_LittleFS_File.cpp | 6 + src/platform/stm32wl/STM32_LittleFS_File.h | 1 + src/xmodem.h | 4 - variants/stm32/CDEBYTE_E77-MBL/platformio.ini | 2 +- variants/stm32/milesight_gs301/platformio.ini | 2 +- variants/stm32/rak3172/platformio.ini | 2 +- variants/stm32/russell/platformio.ini | 2 +- variants/stm32/wio-e5/platformio.ini | 2 +- 14 files changed, 222 insertions(+), 206 deletions(-) diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index f215be80f..8fafc6c52 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -79,28 +79,46 @@ bool copyFile(const char *from, const char *to) bool renameFile(const char *pathFrom, const char *pathTo) { #ifdef FSCom - -#ifdef ARCH_ESP32 - // take SPI Lock spiLock->lock(); - // rename was fixed for ESP32 IDF LittleFS in April bool result = FSCom.rename(pathFrom, pathTo); spiLock->unlock(); return result; #else - // copyFile does its own locking. - if (copyFile(pathFrom, pathTo) && FSCom.remove(pathFrom)) { - return true; - } else { - return false; - } -#endif - + return false; #endif } #include +/** + * @brief Platform-agnostic filesystem format / wipe. + * + * On embedded targets (ESP32, NRF52, STM32WL, RP2040) this calls the + * native FSCom.format() which erases and reinitialises the LittleFS + * partition. + * + * On Portduino the fs::FS backend has no format() method. We instead + * delete /prefs (the only meshtastic data directory written at runtime) + * and return. rmDir("/prefs") is already called unconditionally by + * factoryReset() so this is a proven primitive on Portduino. + * FSBegin() is a no-op (#define FSBegin() true) on Portduino. + * + * @return true on success, false on failure or if no filesystem is configured. + */ +bool fsFormat() +{ +#ifdef FSCom +#if defined(ARCH_PORTDUINO) + rmDir("/prefs"); + return FSBegin(); +#else + return FSCom.format(); +#endif +#else + return false; +#endif +} + /** * @brief Get the list of files in a directory. * @@ -123,23 +141,21 @@ std::vector getFiles(const char *dirname, uint8_t levels) File file = root.openNextFile(); while (file) { +#ifdef ARCH_ESP32 + const char *filepath = file.path(); +#else + const char *filepath = file.name(); +#endif if (file.isDirectory() && !String(file.name()).endsWith(".")) { if (levels) { -#ifdef ARCH_ESP32 - std::vector subDirFilenames = getFiles(file.path(), levels - 1); -#else - std::vector subDirFilenames = getFiles(file.name(), levels - 1); -#endif + std::vector subDirFilenames = getFiles(filepath, levels - 1); filenames.insert(filenames.end(), subDirFilenames.begin(), subDirFilenames.end()); file.close(); } } else { meshtastic_FileInfo fileInfo = {"", static_cast(file.size())}; -#ifdef ARCH_ESP32 - strcpy(fileInfo.file_name, file.path()); -#else - strcpy(fileInfo.file_name, file.name()); -#endif + strncpy(fileInfo.file_name, filepath, sizeof(fileInfo.file_name) - 1); + fileInfo.file_name[sizeof(fileInfo.file_name) - 1] = '\0'; if (!String(fileInfo.file_name).endsWith(".")) { filenames.push_back(fileInfo); } @@ -163,98 +179,59 @@ std::vector getFiles(const char *dirname, uint8_t levels) void listDir(const char *dirname, uint8_t levels, bool del) { #ifdef FSCom -#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) char buffer[255]; -#endif File root = FSCom.open(dirname, FILE_O_READ); - if (!root) { + if (!root || !root.isDirectory()) return; - } - if (!root.isDirectory()) { - return; - } File file = root.openNextFile(); - while ( - file && - file.name()[0]) { // This file.name() check is a workaround for a bug in the Adafruit LittleFS nrf52 glue (see issue 4395) + while (file && file.name()[0]) { // file.name()[0] check: workaround for Adafruit LittleFS nRF52 bug #4395 +#ifdef ARCH_ESP32 + const char *filepath = file.path(); +#else + const char *filepath = file.name(); +#endif if (file.isDirectory() && !String(file.name()).endsWith(".")) { if (levels) { -#ifdef ARCH_ESP32 - listDir(file.path(), levels - 1, del); + listDir(filepath, levels - 1, del); if (del) { - LOG_DEBUG("Remove %s", file.path()); - strncpy(buffer, file.path(), sizeof(buffer)); + LOG_DEBUG("Remove %s", filepath); + strncpy(buffer, filepath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; file.close(); FSCom.rmdir(buffer); } else { file.close(); } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - listDir(file.name(), levels - 1, del); - if (del) { - LOG_DEBUG("Remove %s", file.name()); - strncpy(buffer, file.name(), sizeof(buffer)); - file.close(); - FSCom.rmdir(buffer); - } else { - file.close(); - } -#else - LOG_DEBUG(" %s (directory)", file.name()); - listDir(file.name(), levels - 1, del); - file.close(); -#endif } } else { -#ifdef ARCH_ESP32 if (del) { - LOG_DEBUG("Delete %s", file.path()); - strncpy(buffer, file.path(), sizeof(buffer)); + LOG_DEBUG("Delete %s", filepath); + strncpy(buffer, filepath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; file.close(); FSCom.remove(buffer); } else { - LOG_DEBUG(" %s (%i Bytes)", file.path(), file.size()); + LOG_DEBUG(" %s (%i Bytes)", filepath, file.size()); file.close(); } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - if (del) { - LOG_DEBUG("Delete %s", file.name()); - strncpy(buffer, file.name(), sizeof(buffer)); - file.close(); - FSCom.remove(buffer); - } else { - LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size()); - file.close(); - } -#else - LOG_DEBUG(" %s (%i Bytes)", file.name(), file.size()); - file.close(); -#endif } file = root.openNextFile(); } #ifdef ARCH_ESP32 - if (del) { - LOG_DEBUG("Remove %s", root.path()); - strncpy(buffer, root.path(), sizeof(buffer)); - root.close(); - FSCom.rmdir(buffer); - } else { - root.close(); - } -#elif (defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) - if (del) { - LOG_DEBUG("Remove %s", root.name()); - strncpy(buffer, root.name(), sizeof(buffer)); - root.close(); - FSCom.rmdir(buffer); - } else { - root.close(); - } + const char *rootpath = root.path(); #else - root.close(); + const char *rootpath = root.name(); #endif + if (del) { + LOG_DEBUG("Remove %s", rootpath); + strncpy(buffer, rootpath, sizeof(buffer) - 1); + buffer[sizeof(buffer) - 1] = '\0'; + root.close(); + FSCom.rmdir(buffer); + } else { + root.close(); + } #endif } @@ -268,14 +245,7 @@ void listDir(const char *dirname, uint8_t levels, bool del) void rmDir(const char *dirname) { #ifdef FSCom - -#if (defined(ARCH_ESP32) || defined(ARCH_RP2040) || defined(ARCH_PORTDUINO)) listDir(dirname, 10, true); -#elif defined(ARCH_NRF52) - // nRF52 implementation of LittleFS has a recursive delete function - FSCom.rmdir_r(dirname); -#endif - #endif } diff --git a/src/FSCommon.h b/src/FSCommon.h index fdc0b76ec..9fe71e47b 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -52,6 +52,7 @@ void fsInit(); void fsListFiles(); bool copyFile(const char *from, const char *to); bool renameFile(const char *pathFrom, const char *pathTo); +bool fsFormat(); std::vector getFiles(const char *dirname, uint8_t levels); void listDir(const char *dirname, uint8_t levels, bool del = false); void rmDir(const char *dirname); diff --git a/src/SafeFile.cpp b/src/SafeFile.cpp index 39436f18e..0173fde81 100644 --- a/src/SafeFile.cpp +++ b/src/SafeFile.cpp @@ -7,10 +7,6 @@ static File openFile(const char *filename, bool fullAtomic) { concurrency::LockGuard g(spiLock); LOG_DEBUG("Opening %s, fullAtomic=%d", filename, fullAtomic); -#ifdef ARCH_NRF52 - FSCom.remove(filename); - return FSCom.open(filename, FILE_O_WRITE); -#endif if (!fullAtomic) { FSCom.remove(filename); // Nuke the old file to make space (ignore if it !exists) } @@ -67,9 +63,6 @@ bool SafeFile::close() f.close(); spiLock->unlock(); -#ifdef ARCH_NRF52 - return true; -#endif if (!testReadback()) return false; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 083db6561..ac6880ade 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1611,12 +1611,10 @@ bool NodeDB::saveToDisk(int saveWhat) if (!success) { LOG_ERROR("Failed to save to disk, retrying"); -#ifdef ARCH_NRF52 // @geeksville is not ready yet to say we should do this on other platforms. See bug #4184 discussion spiLock->lock(); - FSCom.format(); + fsFormat(); spiLock->unlock(); -#endif success = saveToDiskNoRetry(saveWhat); RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 865ac38f5..7b249f656 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1412,7 +1412,9 @@ void AdminModule::saveChanges(int saveWhat, bool shouldReboot) void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg) { +#if HAS_SCREEN nodeDB->saveProto("/prefs/uiconfig.proto", meshtastic_DeviceUIConfig_size, &meshtastic_DeviceUIConfig_msg, &uicfg); +#endif } void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) diff --git a/src/platform/stm32wl/LittleFS.cpp b/src/platform/stm32wl/LittleFS.cpp index 40f32eca8..4b57fb151 100644 --- a/src/platform/stm32wl/LittleFS.cpp +++ b/src/platform/stm32wl/LittleFS.cpp @@ -25,23 +25,27 @@ #include "LittleFS.h" #include "stm32wlxx_hal_flash.h" -/********************************************************************************************************************** - * Macro definitions - **********************************************************************************************************************/ -/** This macro is used to suppress compiler messages about a parameter not being used in a function. */ +/** Suppress unused-parameter warnings. */ #define LFS_UNUSED(p) (void)((p)) -#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) +#define STM32WL_PAGE_SIZE (FLASH_PAGE_SIZE) // physical flash erase granularity: 2048 B #define STM32WL_PAGE_COUNT (FLASH_PAGE_NB) #define STM32WL_FLASH_BASE (FLASH_BASE) /* - * FLASH_SIZE from stm32wle5xx.h will read the actual FLASH size from the chip. - * FLASH_END_ADDR is calculated from FLASH_SIZE. - * Use the last 28 KiB of the FLASH + * LFS tunables — all of these are stored in the LFS superblock. + * Changing ANY of them is incompatible with the existing on-disk format; + * the filesystem will be detected as corrupted and reformatted on first boot. + * + * LFS_FLASH_TOTAL_SIZE and LFS_BLOCK_SIZE are the only values to edit here. + * All other parameters are derived. + * + * FLASH_END_ADDR is computed from FLASH_SIZE (read from the chip at link time). */ -#define LFS_FLASH_TOTAL_SIZE (14 * 2048) /* needs to be a multiple of LFS_BLOCK_SIZE */ -#define LFS_BLOCK_SIZE (2048) +#define LFS_FLASH_TOTAL_SIZE \ + (7 * STM32WL_PAGE_SIZE) /* 14 KiB — last 7 physical pages (FORMAT BREAK: reduced from 10 pages / 20 KiB) */ +#define LFS_BLOCK_SIZE (256) /* virtual block size (FORMAT BREAK if changed) */ + #define LFS_FLASH_ADDR_END (FLASH_END_ADDR) #define LFS_FLASH_ADDR_BASE (LFS_FLASH_ADDR_END - LFS_FLASH_TOTAL_SIZE + 1) @@ -51,6 +55,80 @@ #define _LFS_DBG(fmt, ...) printf("%s:%d (%s): " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__) #endif +//--------------------------------------------------------------------+ +// Write-behind page cache +// +// LFS requires block_size == erase granularity, but the STM32WL flash +// erases in 2048-byte pages. To use smaller virtual LFS blocks we +// maintain a single-page RAM cache: the LFS erase/prog callbacks only +// update this buffer; the physical erase+reprogram is deferred to +// _internal_flash_sync() (or triggered automatically when a different +// physical page is addressed). +// +// This mirrors the approach used by the NRF52 Adafruit driver +// (flash_cache.c / flash_nrf5x.c) but adapted for the 2048-byte STM32WL +// page size and HAL doubleword-program requirement. +//--------------------------------------------------------------------+ + +alignas(8) static uint8_t _page_cache[STM32WL_PAGE_SIZE]; +static uint32_t _page_cache_addr = UINT32_MAX; // UINT32_MAX = no page cached +static bool _page_cache_dirty = false; + +/** Flush the cached page to flash (physical erase + doubleword program). */ +static int _flash_cache_flush(void) +{ + if (!_page_cache_dirty) + return LFS_ERR_OK; + + FLASH_EraseInitTypeDef erase = { + .TypeErase = FLASH_TYPEERASE_PAGES, + .Page = (_page_cache_addr - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE, + .NbPages = 1, + }; + uint32_t page_error = 0; + + if (HAL_FLASH_Unlock() != HAL_OK) + return LFS_ERR_IO; + __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS); + + HAL_StatusTypeDef rc = HAL_FLASHEx_Erase(&erase, &page_error); + if (rc == HAL_OK) { + const uint64_t *p = (const uint64_t *)_page_cache; + uint32_t addr = _page_cache_addr; + for (size_t i = 0; i < STM32WL_PAGE_SIZE / 8; i++) { + rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr, *p++); + if (rc != HAL_OK) + break; + addr += 8; + } + } + HAL_StatusTypeDef lock_rc = HAL_FLASH_Lock(); + if (rc == HAL_OK) + rc = lock_rc; + + if (rc == HAL_OK) + _page_cache_dirty = false; + return rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; +} + +/** + * Ensure the physical page containing `page_addr` is loaded into the cache. + * If a different dirty page is already cached it is flushed first. + */ +static int _flash_cache_load(uint32_t page_addr) +{ + if (_page_cache_addr == page_addr) + return LFS_ERR_OK; // already cached + + int rc = _flash_cache_flush(); + if (rc != LFS_ERR_OK) + return rc; + + memcpy(_page_cache, (const void *)page_addr, STM32WL_PAGE_SIZE); + _page_cache_addr = page_addr; + return LFS_ERR_OK; +} + //--------------------------------------------------------------------+ // LFS Disk IO //--------------------------------------------------------------------+ @@ -59,111 +137,82 @@ static int _internal_flash_read(const struct lfs_config *c, lfs_block_t block, l { LFS_UNUSED(c); - if (!buffer || !size) { - _LFS_DBG("%s Invalid parameter!\r\n", __func__); - return LFS_ERR_INVAL; - } + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off); - - memcpy(buffer, (void *)address, size); + if (_page_cache_addr == page_addr) + memcpy(buffer, _page_cache + page_off, size); + else + memcpy(buffer, (const void *)addr, size); return LFS_ERR_OK; } -// Program a region in a block. The block must have previously -// been erased. Negative error codes are propogated to the user. -// May return LFS_ERR_CORRUPT if the block should be considered bad. +// Program a region in a block. The block must have previously been erased. +// Writes are accumulated in the page cache and flushed on sync or page eviction. static int _internal_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE + off); - HAL_StatusTypeDef hal_rc = HAL_OK; - uint32_t dw_count = size / 8; - uint64_t *bufp = (uint64_t *)buffer; - LFS_UNUSED(c); - _LFS_DBG("Programming %d bytes/%d doublewords at address 0x%08x/block %d, offset %d.", size, dw_count, address, block, off); - if (HAL_FLASH_Unlock() != HAL_OK) { - return LFS_ERR_IO; - } - for (uint32_t i = 0; i < dw_count; i++) { - if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) { - _LFS_DBG("Wanted to program out of bound of FLASH: 0x%08x.\n", address); - HAL_FLASH_Lock(); - return LFS_ERR_INVAL; - } - hal_rc = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, address, *bufp); - if (hal_rc != HAL_OK) { - /* Error occurred while writing data in Flash memory. - * User can add here some code to deal with this error. - */ - _LFS_DBG("Program error at (0x%08x), 0x%X, error: 0x%08x\n", address, hal_rc, HAL_FLASH_GetError()); - } - address += 8; - bufp += 1; - } - if (HAL_FLASH_Lock() != HAL_OK) { - return LFS_ERR_IO; - } + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE + off; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO + int rc = _flash_cache_load(page_addr); + if (rc != LFS_ERR_OK) + return rc; + + memcpy(_page_cache + page_off, buffer, size); + _page_cache_dirty = true; + return LFS_ERR_OK; } -// Erase a block. A block must be erased before being programmed. -// The state of an erased block is undefined. Negative error codes -// are propogated to the user. -// May return LFS_ERR_CORRUPT if the block should be considered bad. +// Erase a virtual block. Marks the corresponding region in the page cache as 0xFF. +// Physical erase of the containing page is deferred until sync or page eviction. static int _internal_flash_erase(const struct lfs_config *c, lfs_block_t block) { - lfs_block_t address = LFS_FLASH_ADDR_BASE + (block * STM32WL_PAGE_SIZE); - HAL_StatusTypeDef hal_rc; - FLASH_EraseInitTypeDef EraseInitStruct = {.TypeErase = FLASH_TYPEERASE_PAGES, .Page = 0, .NbPages = 1}; - uint32_t PAGEError = 0; - LFS_UNUSED(c); - if ((address < LFS_FLASH_ADDR_BASE) || (address > LFS_FLASH_ADDR_END)) { - _LFS_DBG("Wanted to erase out of bound of FLASH: 0x%08x.\n", address); - return LFS_ERR_INVAL; - } - /* calculate the absolute page, i.e. what the ST wants */ - EraseInitStruct.Page = (address - STM32WL_FLASH_BASE) / STM32WL_PAGE_SIZE; - _LFS_DBG("Erasing block %d at 0x%08x... ", block, address); - HAL_FLASH_Unlock(); - hal_rc = HAL_FLASHEx_Erase(&EraseInitStruct, &PAGEError); - HAL_FLASH_Lock(); + uint32_t addr = LFS_FLASH_ADDR_BASE + block * LFS_BLOCK_SIZE; + uint32_t page_addr = addr & ~(uint32_t)(STM32WL_PAGE_SIZE - 1); + uint32_t page_off = addr & (uint32_t)(STM32WL_PAGE_SIZE - 1); - return hal_rc == HAL_OK ? LFS_ERR_OK : LFS_ERR_IO; // If HAL_OK, return LFS_ERR_OK, else return LFS_ERR_IO + int rc = _flash_cache_load(page_addr); + if (rc != LFS_ERR_OK) + return rc; + + memset(_page_cache + page_off, 0xFF, LFS_BLOCK_SIZE); + _page_cache_dirty = true; + return LFS_ERR_OK; } -// Sync the state of the underlying block device. Negative error codes -// are propogated to the user. +// Flush the write-behind cache to flash. static int _internal_flash_sync(const struct lfs_config *c) { LFS_UNUSED(c); - // write function performs no caching. No need for sync. - - return LFS_ERR_OK; + return _flash_cache_flush(); } -static struct lfs_config _InternalFSConfig = {.context = NULL, +static struct lfs_config _InternalFSConfig = { + .context = NULL, - .read = _internal_flash_read, - .prog = _internal_flash_prog, - .erase = _internal_flash_erase, - .sync = _internal_flash_sync, + .read = _internal_flash_read, + .prog = _internal_flash_prog, + .erase = _internal_flash_erase, + .sync = _internal_flash_sync, - .read_size = LFS_BLOCK_SIZE, - .prog_size = LFS_BLOCK_SIZE, - .block_size = LFS_BLOCK_SIZE, - .block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE, - .lookahead = 128, + .read_size = LFS_BLOCK_SIZE, + .prog_size = LFS_BLOCK_SIZE, + .block_size = LFS_BLOCK_SIZE, + .block_count = LFS_FLASH_TOTAL_SIZE / LFS_BLOCK_SIZE, + .lookahead = 128, - .read_buffer = NULL, - .prog_buffer = NULL, - .lookahead_buffer = NULL, - .file_buffer = NULL}; + .read_buffer = NULL, + .prog_buffer = NULL, + .lookahead_buffer = NULL, + .file_buffer = NULL, +}; LittleFS InternalFS; @@ -179,17 +228,17 @@ bool LittleFS::begin(void) /* There is not enough space on this device for a filesystem. */ return false; } - // failed to mount, erase all pages then format and mount again + // failed to mount, erase all virtual blocks then format and mount again if (!STM32_LittleFS::begin()) { - // Erase all pages of internal flash region for Filesystem. - for (uint32_t addr = LFS_FLASH_ADDR_BASE; addr < (LFS_FLASH_ADDR_END + 1); addr += STM32WL_PAGE_SIZE) { - _internal_flash_erase(&_InternalFSConfig, (addr - LFS_FLASH_ADDR_BASE) / STM32WL_PAGE_SIZE); + for (lfs_block_t block = 0; block < _InternalFSConfig.block_count; block++) { + _internal_flash_erase(&_InternalFSConfig, block); } + _flash_cache_flush(); // flush the last cached page // lfs format this->format(); - // mount again if still failed, give up + // mount again; if still failed, give up if (!STM32_LittleFS::begin()) return false; } diff --git a/src/platform/stm32wl/STM32_LittleFS_File.cpp b/src/platform/stm32wl/STM32_LittleFS_File.cpp index 349187a02..a85a3ee02 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.cpp +++ b/src/platform/stm32wl/STM32_LittleFS_File.cpp @@ -22,6 +22,7 @@ * THE SOFTWARE. */ +#include "LittleFS.h" #include "STM32_LittleFS.h" #include @@ -391,3 +392,8 @@ void File::rewindDirectory(void) } _fs->_unlockFS(); } + +// Default constructor — binds to the global InternalFS instance. +// Allows File to be declared without an explicit filesystem argument, +// matching the API of ESP32/RP2040/Portduino File objects. +File::File() : File(InternalFS) {} diff --git a/src/platform/stm32wl/STM32_LittleFS_File.h b/src/platform/stm32wl/STM32_LittleFS_File.h index 2b48b02e0..71b98352c 100644 --- a/src/platform/stm32wl/STM32_LittleFS_File.h +++ b/src/platform/stm32wl/STM32_LittleFS_File.h @@ -44,6 +44,7 @@ class File : public Stream public: explicit File(STM32_LittleFS &fs); File(char const *filename, uint8_t mode, STM32_LittleFS &fs); + File(); // default-constructs against InternalFS; defined in STM32_LittleFS_File.cpp public: bool open(char const *filename, uint8_t mode); diff --git a/src/xmodem.h b/src/xmodem.h index 4cfcb43e1..7b665e0ac 100644 --- a/src/xmodem.h +++ b/src/xmodem.h @@ -61,11 +61,7 @@ class XModemAdapter uint16_t packetno = 0; -#if defined(ARCH_NRF52) || defined(ARCH_STM32WL) - File file = File(FSCom); -#else File file; -#endif char filename[sizeof(meshtastic_XModem_buffer_t::bytes)] = {0}; diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini index b4c0c958f..cb980db10 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini +++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini @@ -1,7 +1,7 @@ [env:CDEBYTE_E77-MBL] extends = stm32_base board = ebyte_e77_dev -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) board_level = extra build_flags = ${stm32_base.build_flags} diff --git a/variants/stm32/milesight_gs301/platformio.ini b/variants/stm32/milesight_gs301/platformio.ini index 73b9cf7ea..8bc063a91 100644 --- a/variants/stm32/milesight_gs301/platformio.ini +++ b/variants/stm32/milesight_gs301/platformio.ini @@ -4,7 +4,7 @@ extends = stm32_base board = wiscore_rak3172 ; Convenient choice as the same USART is used for programming/debug board_level = extra -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/milesight_gs301 diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index 4d96e98f9..de8f2b74b 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -2,7 +2,7 @@ extends = stm32_base board = wiscore_rak3172 board_level = pr -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/rak3172 diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 73cf7f81a..57f73f6d0 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -8,7 +8,7 @@ extends = stm32_base board = wiscore_rak3172 board_level = extra -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/russell diff --git a/variants/stm32/wio-e5/platformio.ini b/variants/stm32/wio-e5/platformio.ini index c8dbb2b72..8c7579aa3 100644 --- a/variants/stm32/wio-e5/platformio.ini +++ b/variants/stm32/wio-e5/platformio.ini @@ -2,7 +2,7 @@ extends = stm32_base board = lora_e5_dev_board board_level = pr -board_upload.maximum_size = 233472 ; reserve the last 28KB for filesystem +board_upload.maximum_size = 247808 ; reserve the last 14KB for filesystem (reduced from 20KB in LittleFS.cpp) build_flags = ${stm32_base.build_flags} -Ivariants/stm32/wio-e5 From 90744ee0b767b354b7194f05b570ad4ca154f25d Mon Sep 17 00:00:00 2001 From: Jason P Date: Fri, 1 May 2026 08:46:53 -0500 Subject: [PATCH 24/34] Update PhoneAPI.cpp to reduce chattiness (#10367) --- src/mesh/PhoneAPI.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index 2a3a2a26c..9ff27d33e 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -512,11 +512,6 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) // LOG_INFO("nodeinfo: num=0x%x, lastseen=%u, id=%s, name=%s", nodeInfoForPhone.num, nodeInfoForPhone.last_heard, // nodeInfoForPhone.user.id, nodeInfoForPhone.user.long_name); - // Occasional progress logging. (readIndex==2 will be true for the first non-us node) - if (readIndex == 2 || readIndex % 20 == 0) { - LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); - } - fromRadioScratch.which_payload_variant = meshtastic_FromRadio_node_info_tag; fromRadioScratch.node_info = infoToSend; prefetchNodeInfos(); @@ -647,9 +642,11 @@ void PhoneAPI::releaseQueueStatusPhonePacket() void PhoneAPI::prefetchNodeInfos() { bool added = false; + bool wasEmpty = false; // Keep the queue topped up so BLE reads stay responsive even if DB fetches take a moment. { concurrency::LockGuard guard(&nodeInfoMutex); + wasEmpty = nodeInfoQueue.empty(); while (nodeInfoQueue.size() < kNodePrefetchDepth) { auto nextNode = nodeDB->readNextMeshNode(readIndex); if (!nextNode) @@ -663,11 +660,15 @@ void PhoneAPI::prefetchNodeInfos() info.via_mqtt = isUs ? false : info.via_mqtt; info.is_favorite = info.is_favorite || isUs; nodeInfoQueue.push_back(info); + // Log progress here (at fetch time) so readIndex is accurate and each value logs only once. + if (readIndex == 2 || readIndex % 20 == 0) { + LOG_DEBUG("nodeinfo: %d/%d", readIndex, nodeDB->getNumMeshNodes()); + } added = true; } } - if (added) + if (added && wasEmpty) onNowHasData(0); } From 55f40ecdfdd3d41a5a19d7ed4c787efbca86fedb Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Fri, 1 May 2026 08:56:49 -0500 Subject: [PATCH 25/34] Add ulfius webserver support to macos native target (#10366) * Add ulfius webserver support to macos native target * fix: update PiWebServer docs for macOS and add explicit cstring include Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/3ce82582-23e0-4afe-b22f-b24f81721488 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add --cflags to openssl@3 pkg-config and fix apt package name Agent-Logs-Url: https://github.com/meshtastic/firmware/sessions/1a6c59aa-4393-4134-8cee-61eeee0e9127 Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/raspihttp/PiWebServer.cpp | 35 +++++++++++++++------ src/mesh/raspihttp/PiWebServer.h | 6 +++- src/platform/portduino/PortduinoGlue.cpp | 40 ++++++++++++++++++++++-- variants/native/portduino/platformio.ini | 38 +++++++++++++++++++++- 4 files changed, 105 insertions(+), 14 deletions(-) diff --git a/src/mesh/raspihttp/PiWebServer.cpp b/src/mesh/raspihttp/PiWebServer.cpp index 3e9dbe8c2..5485f8eb2 100644 --- a/src/mesh/raspihttp/PiWebServer.cpp +++ b/src/mesh/raspihttp/PiWebServer.cpp @@ -1,22 +1,34 @@ /* -Adds a WebServer and WebService callbacks to meshtastic as Linux Version. The WebServer & Webservices -runs in a real linux thread beside the portdunio threading emulation. It replaces the complete ESP32 -Webserver libs including generation of SSL certifcicates, because the use ESP specific details in -the lib that can't be emulated. +Adds a WebServer and WebService callbacks to meshtastic via the Portduino/native target (Linux and +macOS). The WebServer & Webservices run in a real host thread beside the Portduino threading +emulation. It replaces the complete ESP32 Webserver libs including generation of SSL certificates, +because those libs use ESP-specific details that can't be emulated. The WebServices adapt to the two major phoneapi functions "handleAPIv1FromRadio,handleAPIv1ToRadio" -The WebServer just adds basaic support to deliver WebContent, so it can be used to -deliver the WebGui definded by the WebClient Project. +The WebServer just adds basic support to deliver WebContent, so it can be used to +deliver the WebGui defined by the WebClient Project. Steps to get it running: -1.) Add these Linux Libs to the compile and target machine: + +Linux (apt): +1.) Add these libs to the compile and target machine: sudo apt update && \ - apt -y install openssl libssl-dev libopenssl libsdl2-dev \ + apt -y install openssl libssl-dev libsdl2-dev \ libulfius-dev liborcania-dev +macOS (Homebrew): +1.) Install prerequisites via Homebrew: + + brew install ulfius openssl@3 + + The PlatformIO env (native-macos) picks up compiler/linker flags via + `pkg-config`. In particular, OpenSSL needs `pkg-config --cflags --libs openssl@3` + so both the Homebrew include path and linker flags are provided; ulfius and its + dependencies (liborcania, libyder) are also resolved via `pkg-config`. + 2.) Configure the root directory of the web Content in the config.yaml file. - The followinng tags should be included and set at your needs + The following tags should be included and set at your needs Example entry in the config.yaml Webserver: @@ -34,7 +46,10 @@ Author: Marc Philipp Hammermann mail: marchammermann@googlemail.com */ -#ifdef PORTDUINO_LINUX_HARDWARE +// Mirrors the guard in PiWebServer.h — see comment there. macOS Homebrew +// provides ulfius + deps; Linux pulls them via apt. Either way, this +// translation unit only compiles when the headers are present. +#ifdef ARCH_PORTDUINO #if __has_include() #include "PiWebServer.h" #include "NodeDB.h" diff --git a/src/mesh/raspihttp/PiWebServer.h b/src/mesh/raspihttp/PiWebServer.h index 74b094f8c..24b7de4b1 100644 --- a/src/mesh/raspihttp/PiWebServer.h +++ b/src/mesh/raspihttp/PiWebServer.h @@ -1,5 +1,9 @@ #pragma once -#ifdef PORTDUINO_LINUX_HARDWARE +// Portduino webserver is built whenever the ulfius headers are reachable, +// not only on Linux. macOS users can `brew install ulfius` to enable it; +// without ulfius the entire body is skipped and main.cpp's matching +// __has_include guard avoids referencing the type. +#ifdef ARCH_PORTDUINO #if __has_include() #include "PhoneAPI.h" #include "ulfius-cfg.h" diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index eeb56240d..1f59e78b5 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -33,6 +33,16 @@ #include #endif +#ifdef __APPLE__ +// Used by getMacAddr()'s macOS fallback to read the en0 link-layer address. +// `getifaddrs()` is the BSD-portable way; `` provides the +// `sockaddr_dl` cast and the `LLADDR()` macro that points at the 6-byte MAC. +#include // strcmp, memcpy +#include +#include +#include +#endif + #include "platform/portduino/USBHal.h" portduino_config_struct portduino_config; @@ -156,9 +166,35 @@ void getMacAddr(uint8_t *dmac) dmac[3] = di.bdaddr.b[2]; dmac[4] = di.bdaddr.b[1]; dmac[5] = di.bdaddr.b[0]; +#elif defined(__APPLE__) + // No BlueZ on macOS, but we can fall back to the host's primary + // network interface MAC. `en0` is Wi-Fi on every shipping Mac + // (Ethernet, when present, is en1 or higher), which gives the user + // the same kind of stable, host-derived identifier that the BlueZ + // path provides on Linux. If en0 isn't found or has no MAC, dmac is + // left untouched and the caller's "Blank MAC Address not allowed!" + // check will still fire — preserving existing behavior for users + // who deliberately rely on --hwid or YAML override. + struct ifaddrs *ifap = nullptr; + if (getifaddrs(&ifap) == 0) { + for (struct ifaddrs *p = ifap; p != nullptr; p = p->ifa_next) { + if (p->ifa_addr == nullptr || p->ifa_addr->sa_family != AF_LINK) { + continue; + } + if (strcmp(p->ifa_name, "en0") != 0) { + continue; + } + auto *sdl = reinterpret_cast(p->ifa_addr); + if (sdl->sdl_alen == 6) { + memcpy(dmac, LLADDR(sdl), 6); + break; + } + } + freeifaddrs(ifap); + } #else - // No BlueZ on non-Linux hosts (e.g. macOS). Leave dmac at its default; - // the caller can override via the --hwid CLI flag or the YAML config. + // No platform-specific MAC source; leave dmac at its default. Caller + // can override via the --hwid CLI flag or the YAML config. (void)dmac; #endif } diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index e493da77b..0a47e7283 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -125,6 +125,8 @@ test_testing_command = ; ; Prerequisites (Homebrew): ; brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config +; # Optional: enable the HTTP API (PiWebServer) on macOS: +; brew install ulfius ; ; The macOS-side patches now live upstream: ; * meshtastic/platform-native — `String.h`-shadow shim, `-Wno-enum-constexpr-conversion`, @@ -191,7 +193,16 @@ build_flags = ${portduino_base.build_flags_common} ; style screen-driver hooks scattered through sensor sources. -DHAS_SCREEN=0 -DMESHTASTIC_EXCLUDE_SCREEN=1 - !pkg-config --libs openssl --silence-errors || : + ; openssl@3 is the keg-only Homebrew formula; --cflags is required so the + ; compiler finds in the Homebrew prefix (not just the linker). + !pkg-config --cflags --libs openssl@3 --silence-errors || : + ; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius + ; headers are reachable via `#if __has_include()`. The `|| :` + ; tail keeps the build green when the user hasn't run `brew install ulfius` + ; — they just don't get the HTTP API in that case. + !pkg-config --cflags --libs libulfius --silence-errors || : + !pkg-config --cflags --libs liborcania --silence-errors || : + !pkg-config --cflags --libs libyder --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX ; (which we lib_ignore on macOS for the issue). Neither is needed @@ -206,3 +217,28 @@ build_src_filter = ${native_base.build_src_filter} lib_ignore = ${portduino_base.lib_ignore} LovyanGFX + +; --------------------------------------------------------------------------- +; Same as [env:native-macos] but built with AddressSanitizer for catching +; use-after-free, leaks, and OOB access during local development. Headless +; (no SDL/X11/libinput) so it stays cheap to build. Mirrors the shape of +; [env:native-tft-debug] but without the TFT/X11 dependencies. +; +; pio run -e native-macos-debug +; .pio/build/native-macos-debug/meshtasticd -s +; +; ASan runtime tuning (set in the shell before launching): +; ASAN_OPTIONS=detect_leaks=1:halt_on_error=0:abort_on_error=1 +; MallocStackLogging=1 # macOS: nicer stack traces in malloc reports +; --------------------------------------------------------------------------- +[env:native-macos-debug] +extends = native_base +build_type = debug +build_unflags = ${env:native-macos.build_unflags} +build_flags = ${env:native-macos.build_flags} + -O0 + -g + -fsanitize=address + -fno-omit-frame-pointer +build_src_filter = ${env:native-macos.build_src_filter} +lib_ignore = ${env:native-macos.lib_ignore} From c0fcf807c068dbbdbfd2005b28cb23f8990e71b6 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 1 May 2026 10:42:17 -0400 Subject: [PATCH 26/34] MacOS: Correct pkg-config name `openssl` for ulfius. (#10369) --- .github/workflows/build_macos_bin.yml | 2 +- variants/native/portduino/platformio.ini | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml index cde2dd816..d0e89d7da 100644 --- a/.github/workflows/build_macos_bin.yml +++ b/.github/workflows/build_macos_bin.yml @@ -24,7 +24,7 @@ jobs: shell: bash run: | brew update - brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config + brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius - name: Get release version string run: | diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 0a47e7283..6d1bd02f3 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -195,14 +195,12 @@ build_flags = ${portduino_base.build_flags_common} -DMESHTASTIC_EXCLUDE_SCREEN=1 ; openssl@3 is the keg-only Homebrew formula; --cflags is required so the ; compiler finds in the Homebrew prefix (not just the linker). - !pkg-config --cflags --libs openssl@3 --silence-errors || : + !pkg-config --cflags --libs openssl --silence-errors || : ; PiWebServer (src/mesh/raspihttp/PiWebServer.cpp) auto-engages when ulfius ; headers are reachable via `#if __has_include()`. The `|| :` ; tail keeps the build green when the user hasn't run `brew install ulfius` ; — they just don't get the HTTP API in that case. !pkg-config --cflags --libs libulfius --silence-errors || : - !pkg-config --cflags --libs liborcania --silence-errors || : - !pkg-config --cflags --libs libyder --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX ; (which we lib_ignore on macOS for the issue). Neither is needed From 0240a00d0934e021dea8fab56630a67b41676284 Mon Sep 17 00:00:00 2001 From: Austin Lane Date: Fri, 1 May 2026 10:55:32 -0400 Subject: [PATCH 27/34] MacOS: Re-Add Orcania/Yder --- variants/native/portduino/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 6d1bd02f3..d334a1901 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -200,6 +200,8 @@ build_flags = ${portduino_base.build_flags_common} ; headers are reachable via `#if __has_include()`. The `|| :` ; tail keeps the build green when the user hasn't run `brew install ulfius` ; — they just don't get the HTTP API in that case. + !pkg-config --cflags --libs liborcania --silence-errors || : + !pkg-config --cflags --libs libyder --silence-errors || : !pkg-config --cflags --libs libulfius --silence-errors || : ; src/input/Linux*.{cpp,h} drive evdev (``) which doesn't exist ; on macOS. graphics/Panel_sdl.* and graphics/TFTDisplay.cpp pull LovyanGFX From b7a9555905cf9bc22ef01028e108ab9fbf5a1a01 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Fri, 1 May 2026 12:50:07 -0500 Subject: [PATCH 28/34] Update RadioLib dependency to a specific commit (#10370) Exploratory PR to test new radiolib change --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 195879bff..8a3129075 100644 --- a/platformio.ini +++ b/platformio.ini @@ -120,7 +120,7 @@ lib_deps = [radiolib_base] lib_deps = # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib - https://github.com/jgromes/RadioLib/archive/refs/tags/7.6.0.zip + https://github.com/jgromes/RadioLib/archive/afe72ae46a343e15e3cac7f26ac585c7f98bffe5.zip [device-ui_base] lib_deps = From 7cb071c780d7cb4290d7b39ac1a399dc62437901 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 18:25:08 -0400 Subject: [PATCH 29/34] Update platform-native digest to cab4b21 (#10372) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- variants/native/portduino.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 35c8c6697..6ff3d0686 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/4ea5e09ac7d51a593e12ec7c1ebb6cd06745ce53.zip + https://github.com/meshtastic/platform-native/archive/cab4b21d902973e43c938dab3cf4844ba02547ec.zip framework = arduino build_src_filter = From 2d761f645349d7bf9a6a4f22e20569330b1d0a88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 2 May 2026 07:23:49 -0500 Subject: [PATCH 30/34] Upgrade trunk (#10364) Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com> --- .trunk/trunk.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 1913c6604..d2ccc60a4 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -8,7 +8,7 @@ plugins: uri: https://github.com/trunk-io/plugins lint: enabled: - - checkov@3.2.525 + - checkov@3.2.526 - renovate@43.150.0 - prettier@3.8.3 - trufflehog@3.95.2 From 41f53177a12f3370059477ab41fd8ba3f1c17722 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 2 May 2026 08:25:24 -0500 Subject: [PATCH 31/34] Use OBS instead of launchpad (#10375) --- .github/workflows/build_debian_src.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build_debian_src.yml b/.github/workflows/build_debian_src.yml index d1bcd8898..8d2076b11 100644 --- a/.github/workflows/build_debian_src.yml +++ b/.github/workflows/build_debian_src.yml @@ -32,10 +32,15 @@ jobs: shell: bash working-directory: meshtasticd run: | + # Build-tools (notably platformio) come from the Meshtastic project + # on the OpenSUSE Build Service: + # https://build.opensuse.org/project/show/network:Meshtastic:build-tools + echo 'deb http://download.opensuse.org/repositories/network:/Meshtastic:/build-tools/xUbuntu_24.04/ /' \ + | sudo tee /etc/apt/sources.list.d/network:Meshtastic:build-tools.list + curl -fsSL https://download.opensuse.org/repositories/network:Meshtastic:build-tools/xUbuntu_24.04/Release.key \ + | gpg --dearmor | sudo tee /etc/apt/trusted.gpg.d/network_Meshtastic_build-tools.gpg >/dev/null sudo apt-get update -y --fix-missing - sudo apt-get install -y software-properties-common build-essential devscripts equivs - sudo add-apt-repository ppa:meshtastic/build-tools -y - sudo apt-get update -y --fix-missing + sudo apt-get install -y build-essential devscripts equivs sudo mk-build-deps --install --remove --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control - name: Import GPG key From b2bda3b07ada98a543284abd46a179725b04df5a Mon Sep 17 00:00:00 2001 From: Jord <650645+Jord-JD@users.noreply.github.com> Date: Sat, 2 May 2026 22:38:06 +0100 Subject: [PATCH 32/34] Enable MESHTASTIC_PREHOP_DROP by default (#9924) --- src/configuration.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configuration.h b/src/configuration.h index e0284e6c9..d263d9ae1 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -80,7 +80,7 @@ along with this program. If not, see . // Pre-hop drop handling (compile-time flag). #ifndef MESHTASTIC_PREHOP_DROP -#define MESHTASTIC_PREHOP_DROP 0 +#define MESHTASTIC_PREHOP_DROP 1 #endif /// Convert a preprocessor name into a quoted string From 6ea0d5ebbaa6580b16ab8e5ba2109430901ac162 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 4 May 2026 16:19:48 -0500 Subject: [PATCH 33/34] Add TFT_BACKLIGHT_ON for cardputer to fix builds (#10387) --- variants/esp32s3/m5stack_cardputer_adv/variant.h | 1 + 1 file changed, 1 insertion(+) diff --git a/variants/esp32s3/m5stack_cardputer_adv/variant.h b/variants/esp32s3/m5stack_cardputer_adv/variant.h index 5fdb1436e..48437cd13 100644 --- a/variants/esp32s3/m5stack_cardputer_adv/variant.h +++ b/variants/esp32s3/m5stack_cardputer_adv/variant.h @@ -9,6 +9,7 @@ #define ST7789_BUSY -1 // #define VTFT_CTRL 38 #define VTFT_LEDA 38 +#define TFT_BACKLIGHT_ON HIGH // #define ST7789_BL (32+6) #define ST7789_SPI_HOST SPI2_HOST // #define TFT_BL (32+6) From fcef46f4b0591efa1e957a8df15c4c277521a4d7 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 5 May 2026 12:52:07 +0100 Subject: [PATCH 34/34] dependency swap - INA3221Sensor (#10379) * dependency swap - INA3221Sensor update INA3221 initialization and measurement methods for compatibility with Rob Tillaart's library Co-authored-by: Copilot * Addresses copilot review Co-authored-by: Copilot * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Refine comments on USB detection and INA3221 Updated comments regarding USB detection and INA3221 usage. * Fix static_assert conditions for INA3221 channel definitions Co-authored-by: Copilot * moved macro defines earlier to allow better use. Co-authored-by: Copilot * Add raw register read methods for bus voltage and shunt current in INA3221Sensor Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- platformio.ini | 4 +- .../Telemetry/Sensor/INA3221Sensor.cpp | 67 ++++++++++++++----- src/modules/Telemetry/Sensor/INA3221Sensor.h | 25 +++++-- variants/nrf52840/rak3401_1watt/variant.h | 4 +- variants/nrf52840/rak4631/variant.h | 4 +- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/platformio.ini b/platformio.ini index 8a3129075..95c89a3ef 100644 --- a/platformio.ini +++ b/platformio.ini @@ -170,8 +170,8 @@ lib_deps = https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614 https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip - # renovate: datasource=git-refs depName=INA3221 packageName=https://github.com/sgtwilko/INA3221 gitBranch=FixOverflow - https://github.com/sgtwilko/INA3221/archive/bb03d7e9bfcc74fc798838a54f4f99738f29fc6a.zip + # renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT + https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip # renovate: datasource=github-tags depName=QMC5883L Compass packageName=mprograms/QMC5883LCompass https://github.com/mprograms/QMC5883LCompass/archive/refs/tags/v1.2.3.zip # renovate: datasource=github-tags depName=DFRobot_RTU packageName=dfrobot/DFRobot_RTU diff --git a/src/modules/Telemetry/Sensor/INA3221Sensor.cpp b/src/modules/Telemetry/Sensor/INA3221Sensor.cpp index 78081132a..d3b9b16f0 100644 --- a/src/modules/Telemetry/Sensor/INA3221Sensor.cpp +++ b/src/modules/Telemetry/Sensor/INA3221Sensor.cpp @@ -16,10 +16,28 @@ int32_t INA3221Sensor::runOnce() return DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; } if (!status) { - ina3221.begin(nodeTelemetrySensorsMap[sensorType].second); - ina3221.setShuntRes(100, 100, 100); // 0.1 Ohm shunt resistors - status = true; + // Re-initialise with the address and Wire bus from the telemetry sensors map. + // (Rob Tillaart INA3221_RT takes address + TwoWire*, unlike sgtwilko which took Wire in begin().) + ina3221 = INA3221(nodeTelemetrySensorsMap[sensorType].first, nodeTelemetrySensorsMap[sensorType].second); + status = ina3221.begin(); + if (status) { + // Default all three channels to a 0.1 Ω shunt resistor. + // Override per-variant by defining INA3221_SHUNT_R_CH1/CH2/CH3 (in Ohms) in variant.h. +#ifndef INA3221_SHUNT_R_CH1 +#define INA3221_SHUNT_R_CH1 0.1f +#endif +#ifndef INA3221_SHUNT_R_CH2 +#define INA3221_SHUNT_R_CH2 0.1f +#endif +#ifndef INA3221_SHUNT_R_CH3 +#define INA3221_SHUNT_R_CH3 0.1f +#endif + ina3221.setShuntR(0, INA3221_SHUNT_R_CH1); + ina3221.setShuntR(1, INA3221_SHUNT_R_CH2); + ina3221.setShuntR(2, INA3221_SHUNT_R_CH3); + } } else { + // Already initialised; status stays true and initI2CSensor() returns next poll interval. status = true; } return initI2CSensor(); @@ -27,12 +45,14 @@ int32_t INA3221Sensor::runOnce() void INA3221Sensor::setup() {} -struct _INA3221Measurement INA3221Sensor::getMeasurement(ina3221_ch_t ch) +struct _INA3221Measurement INA3221Sensor::getMeasurement(uint8_t ch) { struct _INA3221Measurement measurement; - measurement.voltage = ina3221.getVoltage(ch); - measurement.current = ina3221.getCurrent(ch); + measurement.voltage = ina3221.getBusVoltage(ch); // Volts + // getCurrent_mA() is used instead of getCurrent() because Rob Tillaart's getCurrent() + // returns Amperes; the telemetry proto and VoltageSensor/CurrentSensor interfaces expect mA. + measurement.current = ina3221.getCurrent_mA(ch); // milliAmps return measurement; } @@ -43,7 +63,7 @@ struct _INA3221Measurements INA3221Sensor::getMeasurements() // INA3221 has 3 channels starting from 0 for (int i = 0; i < 3; i++) { - measurements.measurements[i] = getMeasurement((ina3221_ch_t)i); + measurements.measurements[i] = getMeasurement((uint8_t)i); } return measurements; @@ -87,24 +107,41 @@ bool INA3221Sensor::getPowerMetrics(meshtastic_Telemetry *measurement) measurement->variant.power_metrics.has_ch3_voltage = true; measurement->variant.power_metrics.has_ch3_current = true; - measurement->variant.power_metrics.ch1_voltage = m.measurements[INA3221_CH1].voltage; - measurement->variant.power_metrics.ch1_current = m.measurements[INA3221_CH1].current; - measurement->variant.power_metrics.ch2_voltage = m.measurements[INA3221_CH2].voltage; - measurement->variant.power_metrics.ch2_current = m.measurements[INA3221_CH2].current; - measurement->variant.power_metrics.ch3_voltage = m.measurements[INA3221_CH3].voltage; - measurement->variant.power_metrics.ch3_current = m.measurements[INA3221_CH3].current; + // INA3221 channel indices are zero-based (0=CH1, 1=CH2, 2=CH3). + measurement->variant.power_metrics.ch1_voltage = m.measurements[0].voltage; + measurement->variant.power_metrics.ch1_current = m.measurements[0].current; + measurement->variant.power_metrics.ch2_voltage = m.measurements[1].voltage; + measurement->variant.power_metrics.ch2_current = m.measurements[1].current; + measurement->variant.power_metrics.ch3_voltage = m.measurements[2].voltage; + measurement->variant.power_metrics.ch3_current = m.measurements[2].current; return true; } uint16_t INA3221Sensor::getBusVoltageMv() { - return lround(ina3221.getVoltage(BAT_CH) * 1000); + return lround(ina3221.getBusVoltage_mV(BAT_CH)); } int16_t INA3221Sensor::getCurrentMa() { - return lround(ina3221.getCurrent(BAT_CH)); + return lround(ina3221.getCurrent_mA(BAT_CH)); +} + +// Bus voltage register (0x02 + ch*2): bits [15:3] unsigned, 1 LSB = 8 mV (datasheet p.6). +// Voltage raw units: 1 count = 8 mV, so V_mV = raw * 8. +int16_t INA3221Sensor::getRawBusVoltage(uint8_t ch) +{ + return (int16_t)(ina3221.getRegister(0x02 + ch * 2) >> 3); +} + +// Shunt voltage register (0x01 + ch*2): bits [15:3] signed two's complement, 1 LSB = 40 µV (datasheet p.6). +// Current raw units are shunt-voltage counts: 1 count = 40 uV, signed. +// I_mA = (raw * 40 uV) / R_mOhm, because uV / mOhm = mA. +// Example for 100 mOhm shunt: I_mA = raw * 40 / 100 = raw * 0.4. +int16_t INA3221Sensor::getRawShuntCurrent(uint8_t ch) +{ + return (int16_t)(ina3221.getRegister(0x01 + ch * 2) >> 3); } #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/INA3221Sensor.h b/src/modules/Telemetry/Sensor/INA3221Sensor.h index 0581f92f6..bb2dbe7b3 100644 --- a/src/modules/Telemetry/Sensor/INA3221Sensor.h +++ b/src/modules/Telemetry/Sensor/INA3221Sensor.h @@ -1,3 +1,9 @@ +// INA3221 channel aliases (zero-based: 0 = CH1, 1 = CH2, 2 = CH3). +// Defined before configuration.h so variant.h can use them in INA3221_ENV_CH / INA3221_BAT_CH. +#define INA3221_CH1 0 +#define INA3221_CH2 1 +#define INA3221_CH3 2 + #include "configuration.h" #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() @@ -9,26 +15,29 @@ #include #ifndef INA3221_ENV_CH -#define INA3221_ENV_CH INA3221_CH1 +#define INA3221_ENV_CH INA3221_CH1 // channel to report in environment metrics (default: CH1) #endif #ifndef INA3221_BAT_CH -#define INA3221_BAT_CH INA3221_CH1 +#define INA3221_BAT_CH INA3221_CH1 // channel for device_battery_ina_address (default: CH1) #endif class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor { private: - INA3221 ina3221 = INA3221(INA3221_ADDR42_SDA); + // Placeholder constructor; re-initialised with correct address and Wire in runOnce(). + INA3221 ina3221 = INA3221(INA3221_ADDR); // channel to report voltage/current for environment metrics - static const ina3221_ch_t ENV_CH = INA3221_ENV_CH; + static const uint8_t ENV_CH = INA3221_ENV_CH; + static_assert(INA3221_ENV_CH >= 0 && INA3221_ENV_CH <= 2, "INA3221_ENV_CH must be 0, 1, or 2"); // channel to report battery voltage for device_battery_ina_address - static const ina3221_ch_t BAT_CH = INA3221_BAT_CH; + static const uint8_t BAT_CH = INA3221_BAT_CH; + static_assert(INA3221_BAT_CH >= 0 && INA3221_BAT_CH <= 2, "INA3221_BAT_CH must be 0, 1, or 2"); // get a single measurement for a channel - struct _INA3221Measurement getMeasurement(ina3221_ch_t ch); + struct _INA3221Measurement getMeasurement(uint8_t ch); // get all measurements for all channels struct _INA3221Measurements getMeasurements(); @@ -45,6 +54,10 @@ class INA3221Sensor : public TelemetrySensor, VoltageSensor, CurrentSensor bool getMetrics(meshtastic_Telemetry *measurement) override; virtual uint16_t getBusVoltageMv() override; virtual int16_t getCurrentMa() override; + + // Raw register reads (bits [15:3] right-shifted), no conversion applied. + int16_t getRawBusVoltage(uint8_t ch); + int16_t getRawShuntCurrent(uint8_t ch); }; struct _INA3221Measurement { diff --git a/variants/nrf52840/rak3401_1watt/variant.h b/variants/nrf52840/rak3401_1watt/variant.h index 80b09cf69..a154e6a41 100644 --- a/variants/nrf52840/rak3401_1watt/variant.h +++ b/variants/nrf52840/rak3401_1watt/variant.h @@ -166,7 +166,9 @@ static const uint8_t SCK = PIN_SPI_SCK; // Testing USB detection #define NRF_APM // If using a power chip like the INA3221 you can override the default battery voltage channel below -// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging +// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging. +// INA3221Sensor.h provides INA3221_CH1/INA3221_CH2/INA3221_CH3 compatibility aliases, so +// board variants can continue to use the named channel constants here. // #define INA3221_BAT_CH INA3221_CH2 // #define INA3221_ENV_CH INA3221_CH1 diff --git a/variants/nrf52840/rak4631/variant.h b/variants/nrf52840/rak4631/variant.h index 6a6b32f27..8ea272a15 100644 --- a/variants/nrf52840/rak4631/variant.h +++ b/variants/nrf52840/rak4631/variant.h @@ -220,7 +220,9 @@ SO GPIO 39/TXEN MAY NOT BE DEFINED FOR SUCCESSFUL OPERATION OF THE SX1262 - TG // Testing USB detection #define NRF_APM // If using a power chip like the INA3221 you can override the default battery voltage channel below -// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging +// and comment out NRF_APM to use the INA3221 instead of the USB detection for charging. +// INA3221Sensor.h provides compatibility aliases such as INA3221_CH1/INA3221_CH2/INA3221_CH3, +// so board variants can continue to use the channel names below. // #define INA3221_BAT_CH INA3221_CH2 // #define INA3221_ENV_CH INA3221_CH1