From 7982ac7fb32b44071fec21ec0f60bb0c815b7c28 Mon Sep 17 00:00:00 2001 From: entr0p1 <1475255+entr0p1@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:44:48 +1000 Subject: [PATCH 01/43] nRF52840 Power Management Stage 1 (Boot Lock) - v2.3 preparation work This commit lays some of the ground work for the upcoming power management fix that allows users to configure the boot lock behaviour. It is *not* the full fix yet. The goal is to break up the larger commit into smaller chunks so it is easier to review and softer to merge. Improvements: - MainBoard: Add bool to check if power management has been initialised (later to be consumed by CommonCLI to validate we can safely configure the settings) - MainBoard: Add bool to validate LPCOMP is supported on the board (gates LPCOMP config so it doesnt wedge on an unsupported board) - NRF52Board: Rename initPowerMgr() -> pwrmgtInit() to align with the "pwrmgt" prefix being used by any power management related functions in the upcoming new version - NRF52Board: Add a shutdown reason for "None" so the `get pwrmgt.bootreason` command doesnt erroneously return "Unknown" - NRF52Board: Add gate in configureVoltageWake to not arm LPCOMP when power management isn't initialised or the board doesn't support LPCOMP (i.e. we havent defined the AIN pin) - NRF52Board: Reference the LPCOMP AIN pin directly instead of from the per-board definitions to align to the upcoming deprecation of the per-board static configs - NRF52Board: Drop LPCOMP hysteresis as it broadens the wake voltage too much and makes the board less likely to self-recover - NRF52Board: Separate LPCOMP and VBUS wake arm into their own functions --- src/MeshCore.h | 2 ++ src/helpers/NRF52Board.cpp | 47 ++++++++++++++++++++++---------------- src/helpers/NRF52Board.h | 11 ++++++--- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/MeshCore.h b/src/MeshCore.h index 89e60b1f..70587833 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -69,8 +69,10 @@ public: virtual bool isLoRaFemLnaEnabled() const { return false; } // Power management interface (boards with power management override these) + virtual bool isPwrMgtInitialised() const { return false; } virtual bool isExternalPowered() { return false; } virtual uint16_t getBootVoltage() { return 0; } + virtual bool getWakeLpcompSupported() const { return false; } virtual uint32_t getResetReason() const { return 0; } virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } virtual uint8_t getShutdownReason() const { return 0; } diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index 5fb1e55e..84ab0448 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -38,7 +38,8 @@ static void __attribute__((constructor(101))) nrf52_early_reset_capture() { g_nrf52_shutdown_reason = NRF_POWER->GPREGRET2; } -void NRF52Board::initPowerMgr() { +void NRF52Board::pwrmgtInit() { + if (pwrmgt_initialised) return; // Copy early-captured register values reset_reason = g_nrf52_reset_reason; shutdown_reason = g_nrf52_shutdown_reason; @@ -65,6 +66,7 @@ void NRF52Board::initPowerMgr() { MESH_DEBUG_PRINTLN("PWRMGT: Reset = %s (0x%lX)", getResetReasonString(reset_reason), (unsigned long)reset_reason); } + pwrmgt_initialised = true; } const char* NRF52Board::getResetReasonString(uint32_t reason) { @@ -89,6 +91,7 @@ const char* NRF52Board::getResetReasonString(uint32_t reason) { const char* NRF52Board::getShutdownReasonString(uint8_t reason) { switch (reason) { + case SHUTDOWN_REASON_NONE: return "None"; case SHUTDOWN_REASON_LOW_VOLTAGE: return "Low Voltage"; case SHUTDOWN_REASON_USER: return "User Request"; case SHUTDOWN_REASON_BOOT_PROTECT: return "Boot Protection"; @@ -97,7 +100,7 @@ const char* NRF52Board::getShutdownReasonString(uint8_t reason) { } bool NRF52Board::checkBootVoltage(const PowerMgtConfig* config) { - initPowerMgr(); + pwrmgtInit(); // Read boot voltage boot_voltage_mv = getBattMilliVolts(); @@ -164,24 +167,30 @@ void NRF52Board::enterSystemOff(uint8_t reason) { NVIC_SystemReset(); } -void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { +void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t lpcomp_refsel) { + pwrmgtWakeArmVbus(); + if (!isPwrMgtInitialised()) { + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake not armed. Reason: Power Management not initialised"); + return; + } + if (!getWakeLpcompSupported()) { + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake not armed. Reason: LPCOMP unsupported on variant"); + return; + } // LPCOMP is not managed by SoftDevice - direct register access required // Halt and disable before reconfiguration NRF_LPCOMP->TASKS_STOP = 1; NRF_LPCOMP->ENABLE = LPCOMP_ENABLE_ENABLE_Disabled; // Select analog input (AIN0-7 maps to PSEL 0-7) - NRF_LPCOMP->PSEL = ((uint32_t)ain_channel << LPCOMP_PSEL_PSEL_Pos) & LPCOMP_PSEL_PSEL_Msk; + NRF_LPCOMP->PSEL = ((uint32_t)PWRMGT_LPCOMP_AIN << LPCOMP_PSEL_PSEL_Pos) & LPCOMP_PSEL_PSEL_Msk; // Reference: REFSEL (0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) - NRF_LPCOMP->REFSEL = ((uint32_t)refsel << LPCOMP_REFSEL_REFSEL_Pos) & LPCOMP_REFSEL_REFSEL_Msk; + NRF_LPCOMP->REFSEL = ((uint32_t)lpcomp_refsel << LPCOMP_REFSEL_REFSEL_Pos) & LPCOMP_REFSEL_REFSEL_Msk; // Detect UP events (voltage rises above threshold for battery recovery) NRF_LPCOMP->ANADETECT = LPCOMP_ANADETECT_ANADETECT_Up; - // Enable 50mV hysteresis for noise immunity - NRF_LPCOMP->HYST = LPCOMP_HYST_HYST_Hyst50mV; - // Clear stale events/interrupts before enabling wake NRF_LPCOMP->EVENTS_READY = 0; NRF_LPCOMP->EVENTS_DOWN = 0; @@ -195,23 +204,22 @@ void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { NRF_LPCOMP->ENABLE = LPCOMP_ENABLE_ENABLE_Enabled; NRF_LPCOMP->TASKS_START = 1; - // Wait for comparator to settle before entering SYSTEMOFF + // Wait for comparator to settle for (uint8_t i = 0; i < 20 && !NRF_LPCOMP->EVENTS_READY; i++) { delayMicroseconds(50); } - if (refsel == 7) { - MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake configured (AIN%d, ref=ARef)", ain_channel); - } else if (refsel <= 6) { - MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake configured (AIN%d, ref=%d/8 VDD)", - ain_channel, refsel + 1); + if (lpcomp_refsel == 7) { + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake armed (AIN%d, ref=ARef)", PWRMGT_LPCOMP_AIN); + } else if (lpcomp_refsel <= 6) { + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake armed (AIN%d, ref=%d/8 VDD)", PWRMGT_LPCOMP_AIN, lpcomp_refsel + 1); } else { - uint8_t ref_num = (uint8_t)((refsel - 8) * 2 + 1); - MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake configured (AIN%d, ref=%d/16 VDD)", - ain_channel, ref_num); + uint8_t ref_num = (uint8_t)((lpcomp_refsel - 8) * 2 + 1); + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake armed (AIN%d, ref=%d/16 VDD)", PWRMGT_LPCOMP_AIN, ref_num); } +} - // Configure VBUS (USB power) wake alongside LPCOMP +void NRF52Board::pwrmgtWakeArmVbus() { uint8_t sd_enabled = 0; sd_softdevice_is_enabled(&sd_enabled); if (sd_enabled) { @@ -220,8 +228,7 @@ void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { NRF_POWER->EVENTS_USBDETECTED = 0; NRF_POWER->INTENSET = POWER_INTENSET_USBDETECTED_Msk; } - - MESH_DEBUG_PRINTLN("PWRMGT: VBUS wake configured"); + MESH_DEBUG_PRINTLN("PWRMGT: VBUS wake armed"); } #endif diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index dba15f97..0d31726b 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -25,15 +25,15 @@ struct PowerMgtConfig { #endif class NRF52Board : public mesh::MainBoard { -#ifdef NRF52_POWER_MANAGEMENT - void initPowerMgr(); -#endif +private: + bool pwrmgt_initialised = false; protected: uint8_t startup_reason; char *ota_name; #ifdef NRF52_POWER_MANAGEMENT + void pwrmgtInit(); uint32_t reset_reason; // RESETREAS register value uint8_t shutdown_reason; // GPREGRET value (why we entered last SYSTEMOFF) uint16_t boot_voltage_mv; // Battery voltage at boot (millivolts) @@ -41,6 +41,7 @@ protected: bool checkBootVoltage(const PowerMgtConfig* config); void enterSystemOff(uint8_t reason); void configureVoltageWake(uint8_t ain_channel, uint8_t refsel); + void pwrmgtWakeArmVbus(); virtual void initiateShutdown(uint8_t reason); #endif @@ -63,6 +64,10 @@ public: uint8_t getShutdownReason() const override { return shutdown_reason; } const char* getResetReasonString(uint32_t reason) override; const char* getShutdownReasonString(uint8_t reason) override; + bool isPwrMgtInitialised() const override { return pwrmgt_initialised; } + #ifdef PWRMGT_LPCOMP_AIN + bool getWakeLpcompSupported() const override { return true; } + #endif #endif }; From 4b8966d4f9496cc6b5b83c434bb6d7e5027cdb8b Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Wed, 19 Aug 2026 15:29:19 +1000 Subject: [PATCH 02/43] WIO Tracker L1 1W variant --- examples/companion_radio/MyMesh.cpp | 4 + .../wio-tracker-l1-1w/WioTrackerL1Board.cpp | 40 ++++++ .../wio-tracker-l1-1w/WioTrackerL1Board.h | 46 +++++++ variants/wio-tracker-l1-1w/platformio.ini | 121 ++++++++++++++++ variants/wio-tracker-l1-1w/target.cpp | 40 ++++++ variants/wio-tracker-l1-1w/target.h | 35 +++++ variants/wio-tracker-l1-1w/variant.cpp | 78 +++++++++++ variants/wio-tracker-l1-1w/variant.h | 130 ++++++++++++++++++ 8 files changed, 494 insertions(+) create mode 100644 variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp create mode 100644 variants/wio-tracker-l1-1w/WioTrackerL1Board.h create mode 100644 variants/wio-tracker-l1-1w/platformio.ini create mode 100644 variants/wio-tracker-l1-1w/target.cpp create mode 100644 variants/wio-tracker-l1-1w/target.h create mode 100644 variants/wio-tracker-l1-1w/variant.cpp create mode 100644 variants/wio-tracker-l1-1w/variant.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 16603bc4..a6739ac0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -886,7 +886,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default _prefs.radio_fem_rxgain = 1; +#ifdef RADIO_FEM_TXGAIN + _prefs.radio_fem_txgain = RADIO_FEM_TXGAIN; // board-specific default for PA +#else _prefs.radio_fem_txgain = 0; +#endif //_prefs.rx_delay_base = 10.0f; enable once new algo fixed _prefs.setRepeatEn(false); #if defined(USE_SX1262) || defined(USE_SX1268) diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp b/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp new file mode 100644 index 00000000..8210c24e --- /dev/null +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp @@ -0,0 +1,40 @@ +#include +#include + +#include "WioTrackerL1Board.h" + +void WioTrackerL1Board::begin() { + NRF52BoardDCDC::begin(); + btn_prev_state = HIGH; + + pinMode(PIN_VBAT_READ, INPUT); // VBAT ADC input + // Set all button pins to INPUT_PULLUP + pinMode(PIN_BUTTON1, INPUT_PULLUP); + pinMode(PIN_BUTTON2, INPUT_PULLUP); + pinMode(PIN_BUTTON3, INPUT_PULLUP); + pinMode(PIN_BUTTON4, INPUT_PULLUP); + pinMode(PIN_BUTTON5, INPUT_PULLUP); + pinMode(PIN_BUTTON6, INPUT_PULLUP); + + + #if defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) + Wire.setPins(PIN_WIRE_SDA, PIN_WIRE_SCL); + #endif + + Wire.begin(); + + pinMode(SX126X_POWER_EN, OUTPUT); + + #ifdef P_LORA_TX_LED + pinMode(P_LORA_TX_LED, OUTPUT); + digitalWrite(P_LORA_TX_LED, LOW); + #endif + + delay(10); // give sx1262 some time to power up +} + +bool WioTrackerL1Board::setLoRaFemPaGainEnabled(bool enable) { + _is_pa_enabled = enable; + digitalWrite(SX126X_POWER_EN, enable ? HIGH : LOW); // enable/disable the 1W PA + return true; +} diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h new file mode 100644 index 00000000..c5a60944 --- /dev/null +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +class WioTrackerL1Board : public NRF52BoardDCDC { +protected: + uint8_t btn_prev_state; + bool _is_pa_enabled = false; + +public: + WioTrackerL1Board() : NRF52Board("WioTrackerL1 OTA") {} + void begin(); + +#if defined(P_LORA_TX_LED) + void onBeforeTransmit() override { + digitalWrite(P_LORA_TX_LED, HIGH); // turn TX LED on + } + void onAfterTransmit() override { + digitalWrite(P_LORA_TX_LED, LOW); // turn TX LED off + } +#endif + + uint16_t getBattMilliVolts() override { + int adcvalue = 0; + analogReadResolution(12); + analogReference(AR_INTERNAL); + delay(10); + adcvalue = analogRead(PIN_VBAT_READ); + return (adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096; + } + + const char* getManufacturerName() const override { + return "Seeed Wio Tracker L1"; + } + + bool canControlLoRaFemPaGain() const override { return true; } + bool setLoRaFemPaGainEnabled(bool enable) override; + bool isLoRaFemPaGainEnabled() const override { return _is_pa_enabled; } + + void powerOff() override { + setLoRaFemPaGainEnabled(false); // turn Off PA + NRF52Board::powerOff(); + } +}; diff --git a/variants/wio-tracker-l1-1w/platformio.ini b/variants/wio-tracker-l1-1w/platformio.ini new file mode 100644 index 00000000..e5b4a3be --- /dev/null +++ b/variants/wio-tracker-l1-1w/platformio.ini @@ -0,0 +1,121 @@ +[WioTrackerL1-1W] +extends = nrf52_base +board = seeed-wio-tracker-l1 +board_build.ldscript = boards/nrf52840_s140_v7.ld +build_flags = ${nrf52_base.build_flags} + ${sensor_base.build_flags} + -I lib/nrf52/s140_nrf52_7.3.0_API/include + -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 + -I variants/wio-tracker-l1-1w + -D WIO_TRACKER_L1 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D PIN_OLED_RESET=-1 + -D GPS_BAUD_RATE=9600 +build_src_filter = ${nrf52_base.build_src_filter} + + + +<../variants/wio-tracker-l1-1w> + + + + +lib_deps= ${nrf52_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit SH110X @ ^2.1.13 + adafruit/Adafruit GFX Library @ ^1.12.1 + +[env:WioTrackerL1-1W_repeater] +extends = WioTrackerL1-1W +build_src_filter = ${WioTrackerL1-1W.build_src_filter} + +<../examples/simple_repeater> +build_flags = + ${WioTrackerL1-1W.build_flags} + -D ADVERT_NAME='"WioTrackerL1 Repeater"' + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D DISPLAY_CLASS=SH1106Display +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = ${WioTrackerL1-1W.lib_deps} + adafruit/RTClib @ ^2.1.3 + +[env:WioTrackerL1-1W_room_server] +extends = WioTrackerL1-1W +build_src_filter = ${WioTrackerL1-1W.build_src_filter} + +<../examples/simple_room_server> +build_flags = ${WioTrackerL1-1W.build_flags} + -D ADVERT_NAME='"WioTrackerL1 Room"' + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' + -D DISPLAY_CLASS=SH1106Display +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +lib_deps = ${WioTrackerL1-1W.lib_deps} + adafruit/RTClib @ ^2.1.3 + +[env:WioTrackerL1-1W_companion_radio_usb] +extends = WioTrackerL1-1W +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 +build_flags = ${WioTrackerL1-1W.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SH1106Display + -D UI_HAS_JOYSTICK=1 + -D OFFLINE_QUEUE_SIZE=256 + -D PIN_BUZZER=12 + -D QSPIFLASH=1 + -D RADIO_FEM_TXGAIN=1 + -D ENABLE_USB_INTERFACE +; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 +build_src_filter = ${WioTrackerL1-1W.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + + + +lib_deps = ${WioTrackerL1-1W.lib_deps} + adafruit/RTClib @ ^2.1.3 + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:WioTrackerL1-1W_companion_radio_ble] +extends = WioTrackerL1-1W +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 +build_flags = ${WioTrackerL1-1W.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=SH1106Display + -D UI_HAS_JOYSTICK=1 + -D PIN_BUZZER=12 + -D QSPIFLASH=1 + -D RADIO_FEM_TXGAIN=1 + -D ADVERT_NAME='"@@MAC"' + -D ENV_PIN_SDA=PIN_WIRE1_SDA + -D ENV_PIN_SCL=PIN_WIRE1_SCL + ; -D MESH_PACKET_LOGGING=1 + ; -D MESH_DEBUG=1 +build_src_filter = ${WioTrackerL1-1W.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = ${WioTrackerL1-1W.lib_deps} + adafruit/RTClib @ ^2.1.3 + densaugeo/base64 @ ~1.4.0 + end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:WioTrackerL1-1W_kiss_modem] +extends = WioTrackerL1-1W +build_src_filter = ${WioTrackerL1-1W.build_src_filter} + +<../examples/kiss_modem/> diff --git a/variants/wio-tracker-l1-1w/target.cpp b/variants/wio-tracker-l1-1w/target.cpp new file mode 100644 index 00000000..7a573258 --- /dev/null +++ b/variants/wio-tracker-l1-1w/target.cpp @@ -0,0 +1,40 @@ +#include +#include "target.h" +#include +#include + +WioTrackerL1Board board; + +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, SPI); + +WRAPPER_CLASS radio_driver(radio, board); + +VolatileRTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#ifdef ENV_INCLUDE_GPS +MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); +EnvironmentSensorManager sensors = EnvironmentSensorManager(nmea); +#else +EnvironmentSensorManager sensors = EnvironmentSensorManager(); +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, false, false); + MomentaryButton joystick_left(JOYSTICK_LEFT, 1000, true, false, false); + MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); + MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); +#endif + +bool radio_init() { + rtc_clock.begin(Wire); + + return radio.std_init(&SPI); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} + diff --git a/variants/wio-tracker-l1-1w/target.h b/variants/wio-tracker-l1-1w/target.h new file mode 100644 index 00000000..05bc7ff1 --- /dev/null +++ b/variants/wio-tracker-l1-1w/target.h @@ -0,0 +1,35 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#ifdef DISPLAY_CLASS + #if defined(WIO_TRACKER_L1_EINK) + #include + #else + #include + #endif + #include +#endif +#include + +extern WioTrackerL1Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; + extern MomentaryButton joystick_left; + extern MomentaryButton joystick_right; + extern MomentaryButton back_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); + diff --git a/variants/wio-tracker-l1-1w/variant.cpp b/variants/wio-tracker-l1-1w/variant.cpp new file mode 100644 index 00000000..18efe7ea --- /dev/null +++ b/variants/wio-tracker-l1-1w/variant.cpp @@ -0,0 +1,78 @@ +#include "variant.h" +#include "wiring_constants.h" +#include "wiring_digital.h" +#include "nrf.h" + +const uint32_t g_ADigitalPinMap[] = { + // D0 .. D10 - Peripheral control pins + 41, // D0 P1.09 GNSS_WAKEUP + 7, // D1 P0.07 LORA_DIO1 + 39, // D2 P1,07 LORA_RESET + 42, // D3 P1.10 LORA_BUSY + 46, // D4 P1.14 (A4/SDA) LORA_CS + 29, // D5 P0.29 (AIN5) LORA_VDET 鈥?Pro 1W: P0.29 (not P1.08) + 27, // D6 P0.27 (UART_TX) GNSS_TX + 26, // D7 P0.26 (UART_RX) GNSS_RX + 30, // D8 P0.30 (SPI_SCK) LORA_SCK + 3, // D9 P0.3 (SPI_MISO) LORA_MISO + 28, // D10 P0.28 (SPI_MOSI) LORA_MOSI + + // D11-D12 - LED outputs + 33, // D11 P1.01 Mesh_LED (orange) 鈥?Pro 1W: P1.01 (not P1.15) + // Buzzzer + 32, // D12 P1.0 Buzzer + + // D13 - User input + 8, // D13 P0.08 User Button + + // D14-D15 - OLED + 6, // D14 P0.06 OLED SDA + 5, // D15 P0.05 OLED SCL + + // D16 - Battery voltage ADC input + 31, // D16 P0.31 VBAT_ADC + // GROVE + 43, // D17 P0.00 GROVE SDA + 44, // D18 P0.01 GROVE SCL + + // FLASH + 21, // D19 P0.21 (QSPI_SCK) + 25, // D20 P0.25 (QSPI_CSN) + 20, // D21 P0.20 (QSPI_SIO_0 DI) + 24, // D22 P0.24 (QSPI_SIO_1 DO) + 22, // D23 P0.22 (QSPI_SIO_2 WP) + 23, // D24 P0.23 (QSPI_SIO_3 HOLD) + + // JOYSTICK + 36, // D25 TB_UP + 12, // D26 TB_DOWN + 11, // D27 TB_LEFT + 35, // D28 TB_RIGHT + 37, // D29 TB_PRESS + + // VBAT ENABLE + 4, // D30 BAT_CTL + + // D31-D33 - Pro 1W only + 13, // D31 P0.13 BOOST_EN (Grove 5V Boost) + 47, // D32 P1.15 nRF_Sig_Charge_State (BQ25616 STAT) + 14, // D33 P0.14 LORA_PWR_EN (SX1262 + 1 W PA LDO) +}; + +void initVariant() { + pinMode(PIN_QSPI_CS, OUTPUT); + digitalWrite(PIN_QSPI_CS, HIGH); + + // VBAT_ENABLE + pinMode(VBAT_ENABLE, OUTPUT); + digitalWrite(VBAT_ENABLE, HIGH); + + // set LED pin as output and set it low + pinMode(PIN_LED, OUTPUT); + digitalWrite(PIN_LED, LOW); + + // set buzzer pin as output and set it low + pinMode(12, OUTPUT); + digitalWrite(12, LOW); + pinMode(12, OUTPUT); +} diff --git a/variants/wio-tracker-l1-1w/variant.h b/variants/wio-tracker-l1-1w/variant.h new file mode 100644 index 00000000..7520460a --- /dev/null +++ b/variants/wio-tracker-l1-1w/variant.h @@ -0,0 +1,130 @@ +#ifndef _SEEED_WIO_TRACKER_L1_H_ +#define _SEEED_WIO_TRACKER_L1_H_ + +/** Master clock frequency */ +#define VARIANT_MCK (64000000ul) + +#define USE_LFXO // Board uses 32khz crystal for LF + +/*---------------------------------------------------------------------------- + * Headers + *----------------------------------------------------------------------------*/ + +#include "WVariant.h" + +#define PINS_COUNT (34) +#define NUM_DIGITAL_PINS (34) +#define NUM_ANALOG_INPUTS (8) +#define NUM_ANALOG_OUTPUTS (0) + +// LEDs +#define PIN_LED (11) +#define LED_BLUE (-1) // Disable annoying flashing caused by Bluefruit +#define LED_BUILTIN PIN_LED +#define P_LORA_TX_LED PIN_LED +#define LED_STATE_ON 1 + +// Buttons +#define PIN_BUTTON1 (13) // Menu / User Button +#define PIN_BUTTON2 (25) // Joystick Up +#define PIN_BUTTON3 (26) // Joystick Down +#define PIN_BUTTON4 (27) // Joystick Left +#define PIN_BUTTON5 (28) // Joystick Right +#define PIN_BUTTON6 (29) // Joystick Press +#define PIN_BACK_BTN PIN_BUTTON1 +#define JOYSTICK_UP PIN_BUTTON2 +#define JOYSTICK_DOWN PIN_BUTTON3 +#define JOYSTICK_LEFT PIN_BUTTON4 +#define JOYSTICK_RIGHT PIN_BUTTON5 +#define JOYSTICK_PRESS PIN_BUTTON6 +#define PIN_USER_BTN PIN_BUTTON6 + +// Buzzer +// #define PIN_BUZZER (12) // Buzzer pin (defined per firmware type) + +#define VBAT_ENABLE (30) + +// Analog pins +#define PIN_VBAT_READ (16) +#define AREF_VOLTAGE (3.6F) +#define ADC_MULTIPLIER (2.0F) +#define ADC_RESOLUTION (12) + +// Serial interfaces +#define PIN_SERIAL1_RX (7) +#define PIN_SERIAL1_TX (6) + +// SPI Interfaces +#define SPI_INTERFACES_COUNT (2) + +#define PIN_SPI_MISO (9) +#define PIN_SPI_MOSI (10) +#define PIN_SPI_SCK (8) + +// BQ25616 single-wire charge status (Pro 1W) +#define PIN_BOOST_EN (31) // D31 / P0.13 鈥?Grove 5V Boost enable +#define EXT_CHRG_DETECT (32) // D32 / P1.15 鈥?BQ25616 STAT +#define EXT_CHRG_DETECT_VALUE LOW // 0 = charging, 1 = full / charger sleep +#define BOOST_EN_ACTIVE HIGH // HIGH enables Grove 5V Boost + +// Lora Pins +#define P_LORA_SCLK PIN_SPI_SCK +#define P_LORA_MISO PIN_SPI_MISO +#define P_LORA_MOSI PIN_SPI_MOSI +#define P_LORA_DIO_1 (1) +#define P_LORA_RESET (2) +#define P_LORA_BUSY (3) +#define P_LORA_NSS (4) +#define SX126X_RXEN (5) +#define SX126X_TXEN RADIOLIB_NC +#define SX126X_DIO2_AS_RF_SWITCH true +#define SX126X_DIO3_TCXO_VOLTAGE (1.8f) + +// LORA_PWR_EN (P0.14): external LDO enable for the SX1262 + 1 W PA on the Pro 1W board. +#define LORA_PWR_EN (33) +#define SX126X_POWER_EN LORA_PWR_EN + +// Wire Interfaces +#define WIRE_INTERFACES_COUNT (2) + +#define PIN_WIRE_SDA (14) +#define PIN_WIRE_SCL (15) +#define PIN_WIRE1_SDA (18) +#define PIN_WIRE1_SCL (17) +#define I2C_NO_RESCAN +#define DISPLAY_ADDRESS 0x3D // SH1106 OLED I2C address + +// GPS L76KB +#define GPS_BAUDRATE 9600 +#define PIN_GPS_TX PIN_SERIAL1_RX +#define PIN_GPS_RX PIN_SERIAL1_TX +#define PIN_GPS_STANDBY (0) +#define PIN_GPS_EN (PIN_GPS_STANDBY) + +// QSPI Pins +#define PIN_QSPI_SCK (19) +#define PIN_QSPI_CS (20) +#define PIN_QSPI_IO0 (21) +#define PIN_QSPI_IO1 (22) +#define PIN_QSPI_IO2 (23) +#define PIN_QSPI_IO3 (24) + +#define EXTERNAL_FLASH_DEVICES P25Q16H +#define EXTERNAL_FLASH_USE_QSPI + +// // EInk on SPI1 +// #define PIN_DISPLAY_CS (36) +// #define PIN_DISPLAY_BUSY (35) +// #define PIN_DISPLAY_DC (34) +// #define PIN_DISPLAY_RST (32) + +#define PIN_SPI1_MISO (37) +#define PIN_SPI1_MOSI (33) +#define PIN_SPI1_SCK (31) + +// GxEPD2 needs that for a panel that is not even used ! +extern const int MISO; +extern const int MOSI; +extern const int SCK; + +#endif \ No newline at end of file From e749010bbc9ab61215c3d73d3e35d60058d23929 Mon Sep 17 00:00:00 2001 From: entr0p1 <1475255+entr0p1@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:01:52 +1000 Subject: [PATCH 03/43] Duplicate MAX_CLIENTS definition MAX_CLIENTS is defined in both src/helpers/ClientACL.h and examples/simple_repeater/MyMesh.h. Appears it was centralised in the former header file some time ago. Both are included in some places and, depending on which order they're in, either value can win. This change drops the duplicate entry from the repeater firmware and bumps the central limit up to 32 (per the original repeater firmware value). Changes: - Remove MAX_CLIENTS from repeater MyMesh.h - Increase MAX_CLIENTS limit in ClientACL.h to 32 --- examples/simple_repeater/MyMesh.h | 4 ---- src/helpers/ClientACL.h | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 04bd4fb9..cac6c4a2 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -59,10 +59,6 @@ struct RepeaterStats { uint32_t n_recv_errors; }; -#ifndef MAX_CLIENTS - #define MAX_CLIENTS 32 -#endif - struct NeighbourInfo { mesh::Identity id; uint32_t advert_timestamp; diff --git a/src/helpers/ClientACL.h b/src/helpers/ClientACL.h index b758f706..e0654464 100644 --- a/src/helpers/ClientACL.h +++ b/src/helpers/ClientACL.h @@ -34,7 +34,7 @@ struct ClientInfo { }; #ifndef MAX_CLIENTS - #define MAX_CLIENTS 20 + #define MAX_CLIENTS 32 #endif class ClientACL { From 52f0362c09aec05323803cada70f17c8efb83eee Mon Sep 17 00:00:00 2001 From: Florent Date: Thu, 20 Aug 2026 21:17:17 -0400 Subject: [PATCH 04/43] ui: repeater discover screen --- examples/companion_radio/MyMesh.cpp | 50 ++++++++++++++++++++ examples/companion_radio/MyMesh.h | 18 ++++++++ examples/companion_radio/ui-new/UITask.cpp | 53 +++++++++++++++++++++- 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fdece482..01512b16 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -134,6 +134,10 @@ #define ERR_CODE_FILE_IO_ERROR 5 #define ERR_CODE_ILLEGAL_ARG 6 +// Copied from simple_repeater (could probably be shared) +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + #define MAX_SIGN_DATA_LEN (8 * 1024) // 8K // Auto-add config bitmask @@ -403,6 +407,51 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) { return max_num; } +int MyMesh::getDiscoveredNodes(DiscoveredNode nodes[], int max_num) { + if (max_num > DISCOVERED_NODES_TABLE_SIZE) max_num = DISCOVERED_NODES_TABLE_SIZE; + if (max_num > disc_nodes_count) max_num = disc_nodes_count; + + for (int i = 0; i < max_num; i++) { + nodes[i] = discovered_nodes[i]; + } + return max_num; +} + +bool MyMesh::requestRepeatersDiscovery() { + uint8_t cmd_bytes[6]; + cmd_bytes[0] = CTL_TYPE_NODE_DISCOVER_REQ | 1; // DISCOVER_REQ | prefix only + cmd_bytes[1] = 0xFF; // Repeaters + getRNG()->random(&cmd_bytes[2], 4); // tag + disc_nodes_count = 0; + disc_node_req_tag = *((uint32_t*)&cmd_bytes[2]); + mesh::Packet* req = createControlData(cmd_bytes, sizeof(cmd_bytes)); + if (req) { + sendZeroHop(req); + return true; + } + return false; +} + +void MyMesh::checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len) { + if ((p_len < 12) + || (payload[0] & 0xF0 != CTL_TYPE_NODE_DISCOVER_RESP) + || (disc_nodes_count >= DISCOVERED_NODES_TABLE_SIZE) + || (memcmp(&payload[2], &disc_node_req_tag, 4))) { + return; + } + memcpy(&discovered_nodes[disc_nodes_count].pubkey_prefix, &payload[6], 8); + discovered_nodes[disc_nodes_count].type = payload[0] & 0xF; + discovered_nodes[disc_nodes_count].snr_out = ((int8_t)payload[1]) / 4.0; + discovered_nodes[disc_nodes_count].snr_in = _radio->getLastSNR(); + ContactInfo* c = lookupContactByPubKey(&payload[6], 8); + if (c != NULL) { + strncpy(discovered_nodes[disc_nodes_count].name, c->name, 32); + } else { + discovered_nodes[disc_nodes_count].name[0] = 0; + } + disc_nodes_count ++; +} + void MyMesh::onContactPathUpdated(const ContactInfo &contact) { out_frame[0] = PUSH_CODE_PATH_UPDATED; memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE); @@ -783,6 +832,7 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); return; } + checkControlDataForPendingDiscovery(packet->payload, packet->payload_len); int i = 0; out_frame[i++] = PUSH_CODE_CONTROL_DATA; out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 238adada..848d21c0 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -84,6 +84,14 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; +struct DiscoveredNode { + uint8_t pubkey_prefix[9]; + float snr_in; + float snr_out; + char name[32]; + uint8_t type; +}; + class MyMesh : public BaseChatMesh, public DataStoreHost { public: MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL); @@ -102,6 +110,9 @@ public: int getRecentlyHeard(AdvertPath dest[], int max_num); + bool requestRepeatersDiscovery(); + int getDiscoveredNodes(DiscoveredNode nodes[], int max_num); + protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; @@ -256,6 +267,13 @@ private: #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table + + #define DISCOVERED_NODES_TABLE_SIZE 10 + DiscoveredNode discovered_nodes[DISCOVERED_NODES_TABLE_SIZE]; // not circular, latest discovered nodes are not kept + uint32_t disc_node_req_tag = 0; + uint32_t disc_nodes_count = 0; + + void checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len); }; extern MyMesh the_mesh; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 55755ef1..35525028 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -24,6 +24,7 @@ #define LONG_PRESS_MILLIS 1200 +// Used both for recent adverts and discovered nodes #ifndef UI_RECENT_LIST_SIZE #define UI_RECENT_LIST_SIZE 4 #endif @@ -102,6 +103,7 @@ class HomeScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 SENSORS, #endif + DISCOVERY, SHUTDOWN, Count // keep as last }; @@ -113,7 +115,9 @@ class HomeScreen : public UIScreen { uint8_t _page; bool _shutdown_init; AdvertPath recent[UI_RECENT_LIST_SIZE]; - + DiscoveredNode discovered[UI_RECENT_LIST_SIZE]; + uint32_t discovery_req_time = 0; + bool discovery_disp_names = true; // by default desplay names if available (removes SNR_O) void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage @@ -459,6 +463,39 @@ public: if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; else sensors_scroll_offset = 0; #endif + } else if (_page == HomePage::DISCOVERY) { + int count = the_mesh.getDiscoveredNodes(discovered, UI_RECENT_LIST_SIZE); + display.setColor(UIColor::primary_txt); + int y = 20; + for (int i = 0; i < count; i++, y += 11) { + char name[32]; + auto a = &discovered[i]; + if ((a->name[0] == 0) || !discovery_disp_names) { + mesh::Utils::toHex(name, a->pubkey_prefix, 4); + } else { + strncpy(name, a->name, 32); + } + char filtered_name[sizeof(name)]; + char snr_s[12]; + if (strlen(name) <= 8) { // display snr_o + sprintf(snr_s, "%02.1f>%02.1f", a->snr_out, a->snr_in); + } else { + sprintf(snr_s, "%02.1f", a->snr_in); + } + int snr_width = display.getTextWidth(snr_s); + int max_name_width = display.width() - snr_width - 1; + display.translateUTF8ToBlocks(filtered_name, name, sizeof(filtered_name)); + display.drawTextEllipsized(0, y, max_name_width, filtered_name); + display.setCursor(display.width() - snr_width - 1, y); + display.print(snr_s); + } + if (millis() < discovery_req_time + 5000) { + return 1000; // more frequent updates just after req + } else if (count < UI_RECENT_LIST_SIZE -1) { // show only 5 sec after last disc + y = 10 + 11 * UI_RECENT_LIST_SIZE; + display.drawTextCentered(display.width() / 2, y, "discover: " PRESS_LABEL); + } + } else if (_page == HomePage::SHUTDOWN) { display.setColor(UIColor::corp_blue); display.setTextSize(1); @@ -484,6 +521,9 @@ public: if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); } + if (_page == HomePage::DISCOVERY) { + _task->showAlert("Repeater disc", 800); + } return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { @@ -516,6 +556,17 @@ public: return true; } #endif + if (c == KEY_ENTER && _page == HomePage::DISCOVERY) { + if (millis() > discovery_req_time + 5000) { // rate limiter + the_mesh.requestRepeatersDiscovery(); + discovery_req_time = millis(); + } + return true; + } + if (c == KEY_SELECT && _page == HomePage::DISCOVERY) { + discovery_disp_names = !discovery_disp_names; + return true; + } if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; From 088ae3caebceb79b7df02e86475ca874eb122ec0 Mon Sep 17 00:00:00 2001 From: Florent Date: Sun, 23 Aug 2026 10:42:00 -0400 Subject: [PATCH 05/43] ui: opt-out for discover screen --- examples/companion_radio/MyMesh.cpp | 4 ++++ examples/companion_radio/MyMesh.h | 12 +++++++++++- examples/companion_radio/ui-new/UITask.cpp | 11 ++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 01512b16..75566448 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -407,6 +407,7 @@ int MyMesh::getRecentlyHeard(AdvertPath dest[], int max_num) { return max_num; } +#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) int MyMesh::getDiscoveredNodes(DiscoveredNode nodes[], int max_num) { if (max_num > DISCOVERED_NODES_TABLE_SIZE) max_num = DISCOVERED_NODES_TABLE_SIZE; if (max_num > disc_nodes_count) max_num = disc_nodes_count; @@ -451,6 +452,7 @@ void MyMesh::checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len } disc_nodes_count ++; } +#endif void MyMesh::onContactPathUpdated(const ContactInfo &contact) { out_frame[0] = PUSH_CODE_PATH_UPDATED; @@ -832,7 +834,9 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); return; } +#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) checkControlDataForPendingDiscovery(packet->payload, packet->payload_len); +#endif int i = 0; out_frame[i++] = PUSH_CODE_CONTROL_DATA; out_frame[i++] = (int8_t)(_radio->getLastSNR() * 4); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 848d21c0..25b56930 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -84,6 +84,7 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; +#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) struct DiscoveredNode { uint8_t pubkey_prefix[9]; float snr_in; @@ -91,6 +92,7 @@ struct DiscoveredNode { char name[32]; uint8_t type; }; +#endif class MyMesh : public BaseChatMesh, public DataStoreHost { public: @@ -110,8 +112,10 @@ public: int getRecentlyHeard(AdvertPath dest[], int max_num); +#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) bool requestRepeatersDiscovery(); int getDiscoveredNodes(DiscoveredNode nodes[], int max_num); +#endif protected: float getAirtimeBudgetFactor() const override; @@ -268,12 +272,18 @@ private: #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table - #define DISCOVERED_NODES_TABLE_SIZE 10 +#if defined(DISPLAY_CLASS) && !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) + #ifdef UI_RECENT_LIST_SIZE + #define DISCOVERED_NODES_TABLE_SIZE UI_RECENT_LIST_SIZE + #else + #define DISCOVERED_NODES_TABLE_SIZE 4 + #endif DiscoveredNode discovered_nodes[DISCOVERED_NODES_TABLE_SIZE]; // not circular, latest discovered nodes are not kept uint32_t disc_node_req_tag = 0; uint32_t disc_nodes_count = 0; void checkControlDataForPendingDiscovery(uint8_t payload[], size_t p_len); +#endif }; extern MyMesh the_mesh; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 35525028..0193ebbc 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -103,7 +103,9 @@ class HomeScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 SENSORS, #endif +#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) DISCOVERY, +#endif SHUTDOWN, Count // keep as last }; @@ -115,9 +117,11 @@ class HomeScreen : public UIScreen { uint8_t _page; bool _shutdown_init; AdvertPath recent[UI_RECENT_LIST_SIZE]; +#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) DiscoveredNode discovered[UI_RECENT_LIST_SIZE]; uint32_t discovery_req_time = 0; bool discovery_disp_names = true; // by default desplay names if available (removes SNR_O) +#endif void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { // Convert millivolts to percentage @@ -463,6 +467,7 @@ public: if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; else sensors_scroll_offset = 0; #endif +#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) } else if (_page == HomePage::DISCOVERY) { int count = the_mesh.getDiscoveredNodes(discovered, UI_RECENT_LIST_SIZE); display.setColor(UIColor::primary_txt); @@ -495,7 +500,7 @@ public: y = 10 + 11 * UI_RECENT_LIST_SIZE; display.drawTextCentered(display.width() / 2, y, "discover: " PRESS_LABEL); } - +#endif } else if (_page == HomePage::SHUTDOWN) { display.setColor(UIColor::corp_blue); display.setTextSize(1); @@ -521,9 +526,11 @@ public: if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); } +#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) if (_page == HomePage::DISCOVERY) { _task->showAlert("Repeater disc", 800); } +#endif return true; } if (c == KEY_ENTER && _page == HomePage::BLUETOOTH) { @@ -556,6 +563,7 @@ public: return true; } #endif +#if !(defined(UI_NO_DISCOVER_SCREEN) && (UI_NO_DISCOVER_SCREEN + 0 != 0)) if (c == KEY_ENTER && _page == HomePage::DISCOVERY) { if (millis() > discovery_req_time + 5000) { // rate limiter the_mesh.requestRepeatersDiscovery(); @@ -567,6 +575,7 @@ public: discovery_disp_names = !discovery_disp_names; return true; } +#endif if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; From e6897e8bb731275fe919ebeb94408303ff8ac8ed Mon Sep 17 00:00:00 2001 From: taco Date: Sat, 29 Aug 2026 15:22:54 +1000 Subject: [PATCH 06/43] fix Meshnology W12 TX power --- variants/meshnology_w12/platformio.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini index 1168f10a..8255e46c 100644 --- a/variants/meshnology_w12/platformio.ini +++ b/variants/meshnology_w12/platformio.ini @@ -27,8 +27,8 @@ build_flags = -D PIN_USER_BTN=0 -D PIN_VEXT_EN=45 -D PIN_VEXT_EN_ACTIVE=HIGH - -D LORA_TX_POWER=4 - -D MAX_LORA_TX_POWER=4 ; GC1109 datasheet says max input at TX port is +5dbm, confirmed full saturation seems to occur at around 3-4dbm + -D LORA_TX_POWER=5 ; default to 5, which gives ~22dbm + -D MAX_LORA_TX_POWER=13 ; tested with tinySA, 13 gives ~28dbm -D PIN_GPS_RX=38 -D PIN_GPS_TX=39 -D PIN_GPS_RESET=42 From 1d9a62ca4a77a4e43268d935ea0ae7d27ead9e59 Mon Sep 17 00:00:00 2001 From: Aleksei Mamlin Date: Mon, 31 Aug 2026 08:59:27 +0300 Subject: [PATCH 07/43] companion: get/set timezone offset for clock * Add tz_offset companion prefs and get/set commands for companion cli * Use tz_offset for clock on display Signed-off-by: Aleksei Mamlin --- examples/companion_radio/MyMesh.cpp | 16 ++++++++++++++++ examples/companion_radio/NodePrefs.h | 2 ++ examples/companion_radio/ui-new/UITask.cpp | 7 +------ variants/thinknode_m8/platformio.ini | 1 - 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b122b605..ee8114ca 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2166,6 +2166,22 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } + if (strcmp(command, "get tz.offset") == 0) { + sprintf(reply, "> %d", _prefs.tz_offset); + return true; + } + if (memcmp(command, "set tz.offset ", 14) == 0) { + int8_t tz = atof(&command[14]); + if (tz < -12 || tz > 14) { + strcpy(reply, "Error, must be from -12 to +14"); + } else { + _prefs.tz_offset = tz; + savePrefs(); + strcpy(reply, "OK"); + } + return true; + } + return false; // not handled } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index c8a9ab83..f6f9b887 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -43,6 +43,7 @@ public: uint8_t cad_enabled = 0; char default_scope_name[31]; uint8_t default_scope_key[16]; + int8_t tz_offset = 0; private: class RadioPrefs : public CommonRadioPrefs { @@ -150,6 +151,7 @@ private: def("tel_base", _parent->telemetry_mode_base); def("tel_loc", _parent->telemetry_mode_loc); def("tel_env", _parent->telemetry_mode_env); + def("tz_offset", _parent->tz_offset); } public: CompanionPrefs(NodePrefs* parent) : _parent(parent) { } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a7227bd8..2c79ab91 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -7,10 +7,6 @@ #include #endif -#ifndef UI_TZ_OFFSET - #define UI_TZ_OFFSET 0 -#endif - #ifndef AUTO_OFF_MILLIS #define AUTO_OFF_MILLIS 15000 // 15 seconds #endif @@ -256,8 +252,7 @@ public: #ifdef UI_SHOW_CLOCK display.setTextSize(3); uint32_t now = _rtc->getCurrentTime(); - int8_t tz = UI_TZ_OFFSET; // for now draw time from Santo Domingo ... - now += (int32_t)tz * 3600; + now += (int32_t)_node_prefs->tz_offset * 3600; DateTime dt (now); sprintf(tmp, "%02d:%02d", dt.hour(), dt.minute()); display.drawTextCentered(display.width() / 2, 60, tmp); diff --git a/variants/thinknode_m8/platformio.ini b/variants/thinknode_m8/platformio.ini index b8c70973..db19e446 100644 --- a/variants/thinknode_m8/platformio.ini +++ b/variants/thinknode_m8/platformio.ini @@ -22,7 +22,6 @@ build_flags = ${nrf52_base.build_flags} -D UI_HAS_ROTARY_INPUT=1 -D UI_HAS_NAV_INPUT=1 -D UI_RECENT_LIST_SIZE=9 - -D UI_TZ_OFFSET=-4 # GMT-4 -D UI_MSG_PREVIEW_SIZE=165 -D EINK_MAX_PARTIAL_REFRESH=60 -D EINK_DISPLAY_MODEL=GxEPD2_154_D67 From f66ff1d13fd30dceb6bd4bea29ef5b54a06841fd Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 2 Sep 2026 11:27:01 -0500 Subject: [PATCH 08/43] docs: correct stale CLI default values Five "Default:" values in cli_commands.md no longer matched the firmware: - radio / freq: the default preset moved to EU/UK (Narrow) in b777a7c6, changing LORA_FREQ/BW/SF from 869.525/250/11 to 869.618/62.5/8 (platformio.ini:29-31). No variant overrides these, and LORA_CR is 5 on every path, so the full preset is 869.618,62.5,8,5. - flood.advert.interval: raised to 47 hours in 40180b8f for both repeater and room server; sensor leaves it disabled. - advert.interval: prefs store minutes/2 and the getter doubles on read, so a factory-fresh node reports 2, not 0. But savePrefs() zeroes any interval below the 60 minute minimum (CommonCLI.cpp:162-165), and it is called from every `set` handler -- so the value becomes 0 as soon as the node is configured. Documented both states, since neither alone is the whole story. - direct.txdelay: repeater defaults to 0.3, room server and sensor to 0.2. Sources: platformio.ini:29-31, simple_repeater/MyMesh.cpp:893,903-904, simple_room_server/MyMesh.cpp:650,661-662, simple_sensor/SensorMesh.cpp:716, 726-727, CommonCLI.cpp:162-165,680-681. --- docs/cli_commands.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 8772b929..f4a43fad 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -211,7 +211,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Set by build flag:** `LORA_FREQ`, `LORA_BW`, `LORA_SF`, `LORA_CR` -**Default:** `869.525,250,11,5` +**Default:** `869.618,62.5,8,5` **Note:** Requires reboot to apply @@ -256,7 +256,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `frequency`: Frequency in MHz -**Default:** `869.525` +**Default:** `869.618` **Note:** Requires reboot to apply **Serial Only:** `set freq ` @@ -537,7 +537,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `value`: Direct transmit delay factor (0-2) -**Default:** `0.2` +**Default:** `0.3` (Repeater) - `0.2` (Room Server, Sensor) **Note:** Same collision-avoidance random window as `txdelay`, but applied to direct (non-flood, routed) traffic. The default is lower because direct packets are addressed to a specific next hop, so far fewer nodes compete to retransmit them. @@ -654,7 +654,7 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `hours`: Interval in hours (3-168) -**Default:** `12` (Repeater) - `0` (Sensor) +**Default:** `47` (Repeater, Room Server) - `0`, disabled (Sensor) --- @@ -666,7 +666,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore **Parameters:** - `minutes`: Interval in minutes rounded down to the nearest multiple of 2 (61 becomes 60) (60-240) -**Default:** `0` +**Default:** `2` on a factory-fresh node, then `0` (disabled) once configured. + +**Note:** A new install ships with a 2 minute zero-hop advert interval. Saving +any setting resets an interval below the 60 minute minimum to `0`, on the +assumption that the node has now been deliberately configured. To keep zero-hop +adverts running, set an explicit value in the 60-240 range. --- From f5b6ff4c14fd2bd29688bc3b64fc39e0517839ea Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 2 Sep 2026 11:27:42 -0500 Subject: [PATCH 09/43] docs: fix channel index range, secret semantics and payload limit Three factual errors in companion_protocol.md: - The channel-datagram payload cap was given as 163 (MAX_FRAME_SIZE - 9 when MAX_FRAME_SIZE was still 172). It went 172 -> 176 in 62f1b11d, but simply updating the arithmetic to 167 would be worse than the stale value: 167 is only the host-frame bound (MyMesh.cpp:1265). The radio-side bound is MAX_GROUP_DATA_LENGTH = 184 - 16 - 3 = 165 (MeshCore.h:21, enforced at BaseChatMesh.cpp:544). A 166- or 167-byte payload passes the frame check, fails the radio check, and comes back as ERR_CODE_TABLE_FULL -- which this same doc describes as "retry later", so a conforming client would retry a permanently failing send forever. Documents 165 as the limit to enforce and calls out the 166-167 band explicitly. - Channel index was documented as 0-7 throughout. The bound is MAX_GROUP_CHANNELS (BaseChatMesh.cpp:927,936), which is 40 on most current variants, 8 on some and 1 on others. Clients should read max_channels from byte 3 of PACKET_DEVICE_INFO instead. Index 0 is pre-populated with the built-in Public channel but is not reserved. - The secret field was documented as "all zeros" for public channels. The firmware always hashes a real 16-byte key; the public channel ships with izOH6cXN6mrJ5e26oRXNcg== (companion_radio/MyMesh.cpp:111,1040). An all-zero secret is not a private channel and not an inert one: SHA256 over 16 zero bytes is a fixed global constant, giving a well-known channel with an all-zero AES key. searchChannelsByHash skips unnamed slots for exactly this reason (BaseChatMesh.cpp:392-401), but a named slot with a zero secret is not skipped and will absorb null-key group traffic from any node. Now documented as something not to do. --- docs/companion_protocol.md | 54 +++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 8c6b84b9..12d81bc4 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -206,7 +206,7 @@ Byte 1: 0x03 **Command Format**: ``` Byte 0: 0x1F -Byte 1: Channel Index (0-7) +Byte 1: Channel Index (0 .. max_channels-1) ``` **Example** (get channel 1): @@ -225,7 +225,7 @@ Byte 1: Channel Index (0-7) **Command Format**: ``` Byte 0: 0x20 -Byte 1: Channel Index (0-7) +Byte 1: Channel Index (0 .. max_channels-1) Bytes 2-33: Channel Name (32 bytes, UTF-8, null-padded) Bytes 34-49: Secret (16 bytes) ``` @@ -233,8 +233,12 @@ Bytes 34-49: Secret (16 bytes) **Total Length**: 50 bytes **Channel Index**: -- Index 0: Reserved for public channels (no secret) -- Indices 1-7: Available for private channels +- Valid range is `0` to `max_channels - 1`. Read `max_channels` from byte 3 of + the `PACKET_DEVICE_INFO` response; do not assume 8. Builds ship with + `MAX_GROUP_CHANNELS` set to 1, 8 or 40 depending on the board. +- Index 0: pre-populated by the firmware with the built-in "Public" channel. + It can be overwritten like any other slot. +- An out-of-range index is rejected with `PACKET_ERROR` / `ERR_CODE_NOT_FOUND`. **Channel Name**: - UTF-8 encoded @@ -242,8 +246,18 @@ Bytes 34-49: Secret (16 bytes) - Padded with null bytes (0x00) if shorter **Secret Field** (16 bytes): -- For **private channels**: 16-byte secret -- For **public channels**: All zeros (0x00) +- Always a real 16-byte key; the firmware derives the channel hash from + `SHA256(secret)` (`BaseChatMesh.cpp:936-942`). There is no "no secret" form. +- Do **not** write an all-zero secret. `SHA256` over 16 zero bytes is a fixed, + globally identical value, so a zero-secret channel is a well-known channel + with an all-zero AES key that any node can read — not a private one. The + firmware skips *unnamed* slots when matching inbound group traffic for + exactly this reason (`BaseChatMesh.cpp:392-401`), but a slot that has been + given a name and a zero secret is not skipped, and will absorb null-key + group traffic from any node on the mesh. +- The built-in public channel uses the well-known key + `izOH6cXN6mrJ5e26oRXNcg==` (base64), i.e. + `8b3387e9c5cdea6ac9e5edbaa115cd72` in hex. **Example** (create channel "YourChannelName" at index 1 with secret): ``` @@ -265,7 +279,7 @@ Bytes 34-49: Secret (16 bytes) ``` Byte 0: 0x03 Byte 1: 0x00 -Byte 2: Channel Index (0-7) +Byte 2: Channel Index (0 .. max_channels-1) Bytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds) Bytes 7+: Message Text (UTF-8, variable length) ``` @@ -288,7 +302,7 @@ Bytes 7+: Message Text (UTF-8, variable length) **Command Format**: ``` Byte 0: 0x3E -Byte 1: Channel Index (0-7) +Byte 1: Channel Index (0 .. max_channels-1) Byte 2: Path Length (0xFF = flood, otherwise actual path length) Bytes 3 .. 2+path_len: Path (omitted when path_len == 0xFF) Next 2 bytes (little-endian): Data Type (`data_type`, uint16) @@ -306,13 +320,23 @@ Remaining bytes: Binary payload (variable length) - Values `0x0001`–`0xFFFE` are available for registered application/community namespaces. See the [Registered data_type values](#registered-data_type-values) table below. **Limits**: -- Maximum payload length is `MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 163` bytes. -- Larger payloads are rejected with `PACKET_ERROR` (`ERR_CODE_ILLEGAL_ARG`). +- Maximum payload length is **165** bytes — `MAX_GROUP_DATA_LENGTH = + MAX_PACKET_PAYLOAD - CIPHER_BLOCK_SIZE - 3` (`src/MeshCore.h:21`). This is the + radio-side limit and the one clients should enforce. +- Two different bounds are checked, and they do not agree. The host-frame check + uses the larger `MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 167` + (`companion_radio/MyMesh.cpp:1265`); the radio-side check uses 165 + (`BaseChatMesh.cpp:544`). +- Payloads **above 167** are rejected with `ERR_CODE_ILLEGAL_ARG` (6). +- Payloads of **166 or 167** pass the frame check, then fail the radio-side + check and return `ERR_CODE_TABLE_FULL` (3). Despite that code's usual + meaning, such a send can never succeed — do not retry it. Keep payloads at + 165 bytes or fewer and this case cannot arise. **Response**: `PACKET_OK` (0x00) on success, or `PACKET_ERROR` (0x01) with one of: - `ERR_CODE_NOT_FOUND` (2) — unknown `channel_idx` -- `ERR_CODE_ILLEGAL_ARG` (6) — invalid `path_len`, reserved `data_type` (`0x0000`), or payload larger than `MAX_CHANNEL_DATA_LENGTH` -- `ERR_CODE_TABLE_FULL` (3) — outbound send queue is full; retry later +- `ERR_CODE_ILLEGAL_ARG` (6) — invalid `path_len`, reserved `data_type` (`0x0000`), or payload larger than `MAX_CHANNEL_DATA_LENGTH` (167) +- `ERR_CODE_TABLE_FULL` (3) — outbound send queue is full (retry later), **or** a payload of 166-167 bytes that exceeds the radio-side limit (permanent; see Limits above) **Inbound datagrams** are delivered to the host via `RESP_CODE_CHANNEL_DATA_RECV` (0x1B); see [Receive Channel Data Datagram](#receive-channel-data-datagram). @@ -341,7 +365,7 @@ Inbound group datagrams (radio-level `PAYLOAD_TYPE_GRP_DATA`, 0x06) are forwarde Byte 0: 0x1B (packet type) Byte 1: SNR (signed int8, scaled ×4 — divide by 4.0 to recover dB) Bytes 2-3: Reserved (clients MUST ignore) -Byte 4: Channel Index (0-7) +Byte 4: Channel Index (0 .. max_channels-1) Byte 5: Path Length (actual path length when flooded, otherwise 0xFF for direct) Bytes 6-7: Data Type (uint16 little-endian) Byte 8: Data Length @@ -550,7 +574,7 @@ def parse_contact_message(data): **Standard Format** (`PACKET_CHANNEL_MSG_RECV`, 0x08): ``` Byte 0: 0x08 (packet type) -Byte 1: Channel Index (0-7) +Byte 1: Channel Index (0 .. max_channels-1) Byte 2: Path Length Byte 3: Text Type Bytes 4-7: Timestamp (32-bit little-endian) @@ -562,7 +586,7 @@ Bytes 8+: Message Text (UTF-8) Byte 0: 0x11 (packet type) Byte 1: SNR (signed byte, multiplied by 4) Bytes 2-3: Reserved -Byte 4: Channel Index (0-7) +Byte 4: Channel Index (0 .. max_channels-1) Byte 5: Path Length Byte 6: Text Type Bytes 7-10: Timestamp (32-bit little-endian) From 208333d51159e0b06ba32f4c6ed22b8bd9c33881 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 2 Sep 2026 11:27:56 -0500 Subject: [PATCH 10/43] docs: correct pwrmgt CLI behaviour on unsupported boards The doc claimed every pwrmgt command except `get pwrmgt.support` returns "ERROR: Power management not supported" when NRF52_POWER_MANAGEMENT is not defined. `get pwrmgt.bootreason` is not inside the #ifdef (CommonCLI.cpp:787) and answers on all boards; only pwrmgt.source and pwrmgt.bootmv are gated. --- docs/nrf52_power_management.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/nrf52_power_management.md b/docs/nrf52_power_management.md index 6d97f2c3..417ee6dd 100644 --- a/docs/nrf52_power_management.md +++ b/docs/nrf52_power_management.md @@ -186,11 +186,19 @@ Power management status can be queried via the CLI: | `get pwrmgt.bootreason` | Returns reset and shutdown reason strings | | `get pwrmgt.bootmv` | Returns boot voltage in millivolts | -On boards without power management enabled, all commands except `get pwrmgt.support` return: +On boards without power management enabled, `get pwrmgt.source` and +`get pwrmgt.bootmv` return: ``` ERROR: Power management not supported ``` +`get pwrmgt.support` returns `unsupported`. `get pwrmgt.bootreason` is not +compiled out at all and answers on every board: `getResetReason()` and +`getShutdownReason()` are virtuals on the base board class, so a board that +does not override them reports `Not available` rather than an error. ESP32 +boards override the reset half with `esp_reset_reason()`, so they return a real +reset reason and `Not available` for the shutdown reason. + ## Debug Output When `MESH_DEBUG=1` is enabled, the power management module outputs: From fe5d4cc50dac989157fa2d63410fafc5786ee915 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 2 Sep 2026 11:28:17 -0500 Subject: [PATCH 11/43] docs: remove non-existent set dutycycle from Terminal Chat CLI f6338430 added get/set dutycycle to CommonCLI and updated both CLI docs, but Terminal Chat does not use CommonCLI -- simple_secure_chat/main.cpp:479-507 has its own `set` handler supporting only af, name, lat, lon, tx and freq. Typing `set dutycycle` there returns "ERROR: unknown config". Restores `set af` as the documented command, points readers at cli_commands.md for the roles that do have dutycycle, and documents `help` (simple_secure_chat/main.cpp:510), which the client implements but the doc never listed. --- docs/terminal_chat_cli.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 31616144..d5bac7ed 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -27,15 +27,17 @@ set lon {longitude} ``` Sets your advertisement map longitude. (decimal degrees) -``` -set dutycycle {percent} -``` -Sets the transmit duty cycle limit (1-100%). Example: `set dutycycle 10` for 10%. - ``` set af {air-time-factor} ``` -Sets the transmit air-time-factor. Deprecated — use `set dutycycle` instead. +Sets the transmit air-time-factor. + +> **Note:** `set dutycycle` is *not* available in Terminal Chat. Every other +> firmware — Repeater, Room Server, Sensor and Companion Radio — routes its +> `set`/`get` commands through the shared radio prefs handler and does support +> it (see [CLI Commands](./cli_commands.md)). Terminal Chat is the sole +> exception: it has its own inline `set` handler covering only the six options +> listed above. ``` @@ -99,3 +101,8 @@ Resets the path to current recipient, for new path discovery. public {text} ``` Sends the text message to the built-in 'public' group channel + +``` +help +``` +Lists the available commands. From 90a4f51f915cb78c5882299b8c68cadaf8fe0b6c Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 2 Sep 2026 11:28:51 -0500 Subject: [PATCH 12/43] docs: update stale repo URLs and build recipe The project moved to meshcore-dev/meshcore, but two links still pointed at ripplebiz/MeshCore: the GitHub Issues link in README and the git clone in the FAQ build instructions. Also fixes the FAQ build recipe: - `pio run -e RAK_4631_Repeater` does not exist; the env is spelled RAK_4631_repeater (variants/rak4631/platformio.ini:40). - LORA_FREQ was given as 867.5; the default in [arduino_base] is 869.618 (platformio.ini:29). Reworded to point at the flag rather than a value that drifts with the default preset. - Renamed the venv so it no longer collides with the cloned directory name. And one more env-name casing slip in the Raspberry Pi flashing instructions: `Heltec_V3_companion_radio_ble` should be `Heltec_v3_...`. Release artifacts are named directly from the env name (build.sh:147), and the two neighbouring examples in the same block already use the lowercase spelling. --- README.md | 2 +- docs/faq.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index af56aaf3..6cc45d7f 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,6 @@ There are a number of fairly major features in the pipeline, with no particular ## 📞 Get Support -- Report bugs and request features on the [GitHub Issues](https://github.com/ripplebiz/MeshCore/issues) page. +- Report bugs and request features on the [GitHub Issues](https://github.com/meshcore-dev/meshcore/issues) page. - Find additional guides and components on [my site](https://buymeacoffee.com/ripplebiz). - Join [MeshCore Discord](https://meshcore.gg) to chat with the developers and get help from the community. diff --git a/docs/faq.md b/docs/faq.md index c7d7a118..ade59bf0 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -537,18 +537,18 @@ Mac: python3 should be already installed. Then it should be the same for all platforms: ``` -python3 -m venv meshcore -cd meshcore && source bin/activate +python3 -m venv meshcore-venv +cd meshcore-venv && source bin/activate pip install -U platformio -git clone https://github.com/ripplebiz/MeshCore.git -cd MeshCore +git clone https://github.com/meshcore-dev/meshcore.git +cd meshcore ``` -open platformio.ini and in `[arduino_base]` edit the `LORA_FREQ=867.5` -save, then run: +open platformio.ini and in `[arduino_base]` edit `LORA_FREQ` (it defaults to +`869.618`) to the frequency for your region, save, then run: ``` -pio run -e RAK_4631_Repeater +pio run -e RAK_4631_repeater ``` -then you'll find `firmware.zip` in `.pio/build/RAK_4631_Repeater` +then you'll find `firmware.zip` in `.pio/build/RAK_4631_repeater` ### 5.10. Q: Are there other MeshCore related open source projects? @@ -592,7 +592,7 @@ For ESP-based devices (e.g. Heltec V3) you need: 1. Download the firmware file from . - Go to the website in a browser and find the section that has the firmware you need. - Click the Download button, right-click on the file you need, for example: - - `Heltec_V3_companion_radio_ble-v1.7.1-165fb33.bin` + - `Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin` - Non-merged bin keeps the existing Bluetooth pairing database. - `Heltec_v3_companion_radio_usb-v1.7.1-165fb33-merged.bin` - Merged bin overwrites everything including the bootloader and existing Bluetooth pairing database, but keeps configurations. From 7d501681bc3f1e93c9b404138fc143195b70cdc9 Mon Sep 17 00:00:00 2001 From: Quency-D Date: Thu, 3 Sep 2026 17:57:09 +0800 Subject: [PATCH 13/43] fix-heltec_v4r8-lna --- variants/heltec_v4_r8/HeltecV4R8Board.cpp | 58 +++++++++++++++++++++++ variants/heltec_v4_r8/HeltecV4R8Board.h | 7 +++ variants/heltec_v4_r8/LoRaFEMControl.h | 1 + 3 files changed, 66 insertions(+) diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.cpp b/variants/heltec_v4_r8/HeltecV4R8Board.cpp index 1fb123b2..ea2cfbed 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.cpp +++ b/variants/heltec_v4_r8/HeltecV4R8Board.cpp @@ -84,3 +84,61 @@ const char* HeltecV4R8Board::getManufacturerName() const { return "Heltec V4 R8 OLED"; #endif } + +bool HeltecV4R8Board::setLoRaFemLnaEnabled(bool enable) { + if (!loRaFEMControl.isLnaCanControl()) { + return false; + } + + loRaFEMControl.setLNAEnable(enable); + loRaFEMControl.setRxModeEnable(); + return true; +} + +bool HeltecV4R8Board::isLoRaFemLnaEnabled() const { + return loRaFEMControl.isLNAEnabled(); +} + +void HeltecV4R8Board::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + + char radio_fem_rxgain[8] = { 0 }; + _prefs->getByKey("fem_rxgain", radio_fem_rxgain, 7); // get initial values + + setLoRaFemLnaEnabled(strcmp(radio_fem_rxgain, "1") == 0); +} + +bool HeltecV4R8Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + if (strcmp(command, "get radio.fem.rxgain") == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else { + sprintf(reply, "> %s", isLoRaFemLnaEnabled() ? "on" : "off"); + } + return true; + } + if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { + if (!loRaFEMControl.isLnaCanControl()) { + strcpy(reply, "Error: unsupported"); + } else if (memcmp(&command[21], "on", 2) == 0) { + if (setLoRaFemLnaEnabled(true)) { + _prefs->setByKey("fem_rxgain", "1"); + strcpy(reply, "OK - LoRa FEM RX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else if (memcmp(&command[21], "off", 3) == 0) { + if (setLoRaFemLnaEnabled(false)) { + _prefs->setByKey("fem_rxgain", "0"); + strcpy(reply, "OK - LoRa FEM RX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM RX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} diff --git a/variants/heltec_v4_r8/HeltecV4R8Board.h b/variants/heltec_v4_r8/HeltecV4R8Board.h index 20811abb..7ab29f96 100644 --- a/variants/heltec_v4_r8/HeltecV4R8Board.h +++ b/variants/heltec_v4_r8/HeltecV4R8Board.h @@ -11,6 +11,10 @@ #endif class HeltecV4R8Board : public ESP32Board { + KeyValueStore* _prefs = NULL; + + bool setLoRaFemLnaEnabled(bool enable); + bool isLoRaFemLnaEnabled() const; protected: float adc_mult = ADC_MULTIPLIER; @@ -21,6 +25,9 @@ public: HeltecV4R8Board() : periph_power(PIN_VEXT_EN, PIN_VEXT_EN_ACTIVE) { } void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; + void onBeforeTransmit(void) override; void onAfterTransmit(void) override; void enterDeepSleep(uint32_t secs, int pin_wake_btn = -1); diff --git a/variants/heltec_v4_r8/LoRaFEMControl.h b/variants/heltec_v4_r8/LoRaFEMControl.h index 961cfd07..4b74c2e2 100644 --- a/variants/heltec_v4_r8/LoRaFEMControl.h +++ b/variants/heltec_v4_r8/LoRaFEMControl.h @@ -17,6 +17,7 @@ public: void setLNAEnable(bool enabled); bool isLnaCanControl(void) { return true; } void setLnaCanControl(bool can_control) { } + bool isLNAEnabled(void) const { return lna_enabled; } LoRaFEMType getFEMType(void) const { return KCT8103L_PA; } private: From 95214e8c40264eb374ed2a94d6905619ae557650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Br=C3=A1zio?= Date: Thu, 16 Jul 2026 19:59:12 +0000 Subject: [PATCH 14/43] Ensure command buffers stay NUL-terminated to prevent overflow The serial command buffers must stay NUL-terminated within their bounds: if they ever aren't, strlen() can return >= sizeof(command) and the read loop would index past the buffer. Additionally, a full buffer now becomes a completed line (end-of-line marker placed inside the buffer, NUL terminator kept) instead of overwriting the terminator and silently corrupting the buffer for the next pass. Applies to the serial CLI readers of the repeater, room server, sensor and secure chat examples, and to the CLI rescue reader of the companion example. --- examples/companion_radio/MyMesh.cpp | 12 ++++++++++-- examples/simple_repeater/main.cpp | 12 ++++++++++-- examples/simple_room_server/main.cpp | 12 ++++++++++-- examples/simple_secure_chat/main.cpp | 12 ++++++++++-- examples/simple_sensor/main.cpp | 12 ++++++++++-- 5 files changed, 50 insertions(+), 10 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ee8114ca..b4e51514 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2187,6 +2187,13 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* void MyMesh::checkCLIRescueCmd() { int len = strlen(cli_command); + // `cli_command` must stay NUL-terminated within its bounds. If it ever isn't, + // strlen() above can return >= sizeof(cli_command) and the loop below would + // then index past the buffer, so clamp defensively. + if (len >= (int)sizeof(cli_command)) { + cli_command[0] = 0; + len = 0; + } while (Serial.available() && len < sizeof(cli_command)-1) { char c = Serial.read(); if (c != '\n') { @@ -2195,8 +2202,9 @@ void MyMesh::checkCLIRescueCmd() { } Serial.print(c); // echo } - if (len == sizeof(cli_command)-1) { // command buffer full - cli_command[sizeof(cli_command)-1] = '\r'; + if (len == sizeof(cli_command)-1) { // buffer full: treat as a completed line + cli_command[sizeof(cli_command)-2] = '\r'; // place end-of-line marker inside the buffer + cli_command[sizeof(cli_command)-1] = 0; // keep the buffer NUL-terminated } if (len > 0 && cli_command[len - 1] == '\r') { // received complete line diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index a714db68..1f71da74 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -125,6 +125,13 @@ void setup() { void loop() { // Handle Serial CLI int len = strlen(command); + // `command` must stay NUL-terminated within its bounds. If it ever isn't, + // strlen() above can return >= sizeof(command) and the loop below would then + // index past the buffer, so clamp defensively. + if (len >= (int)sizeof(command)) { + command[0] = 0; + len = 0; + } while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); if (c != '\n') { @@ -134,8 +141,9 @@ void loop() { } if (c == '\r') break; } - if (len == sizeof(command)-1) { // command buffer full - command[sizeof(command)-1] = '\r'; + if (len == sizeof(command)-1) { // buffer full: treat as a completed line + command[sizeof(command)-2] = '\r'; // place end-of-line marker inside the buffer + command[sizeof(command)-1] = 0; // keep the buffer NUL-terminated } if (len > 0 && command[len - 1] == '\r') { // received complete line diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index d833fff3..227ee2cb 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -105,6 +105,13 @@ void setup() { void loop() { int len = strlen(command); + // `command` must stay NUL-terminated within its bounds. If it ever isn't, + // strlen() above can return >= sizeof(command) and the loop below would then + // index past the buffer, so clamp defensively. + if (len >= (int)sizeof(command)) { + command[0] = 0; + len = 0; + } while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); if (c != '\n') { @@ -113,8 +120,9 @@ void loop() { } Serial.print(c); } - if (len == sizeof(command)-1) { // command buffer full - command[sizeof(command)-1] = '\r'; + if (len == sizeof(command)-1) { // buffer full: treat as a completed line + command[sizeof(command)-2] = '\r'; // place end-of-line marker inside the buffer + command[sizeof(command)-1] = 0; // keep the buffer NUL-terminated } if (len > 0 && command[len - 1] == '\r') { // received complete line diff --git a/examples/simple_secure_chat/main.cpp b/examples/simple_secure_chat/main.cpp index 159249df..2e6ba712 100644 --- a/examples/simple_secure_chat/main.cpp +++ b/examples/simple_secure_chat/main.cpp @@ -530,6 +530,13 @@ public: BaseChatMesh::loop(); int len = strlen(command); + // `command` must stay NUL-terminated within its bounds. If it ever isn't, + // strlen() above can return >= sizeof(command) and the loop below would then + // index past the buffer, so clamp defensively. + if (len >= (int)sizeof(command)) { + command[0] = 0; + len = 0; + } while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); if (c != '\n') { @@ -538,8 +545,9 @@ public: } Serial.print(c); } - if (len == sizeof(command)-1) { // command buffer full - command[sizeof(command)-1] = '\r'; + if (len == sizeof(command)-1) { // buffer full: treat as a completed line + command[sizeof(command)-2] = '\r'; // place end-of-line marker inside the buffer + command[sizeof(command)-1] = 0; // keep the buffer NUL-terminated } if (len > 0 && command[len - 1] == '\r') { // received complete line diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 69182f3a..749ff6ef 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -122,6 +122,13 @@ void setup() { void loop() { int len = strlen(command); + // `command` must stay NUL-terminated within its bounds. If it ever isn't, + // strlen() above can return >= sizeof(command) and the loop below would then + // index past the buffer, so clamp defensively. + if (len >= (int)sizeof(command)) { + command[0] = 0; + len = 0; + } while (Serial.available() && len < sizeof(command)-1) { char c = Serial.read(); if (c != '\n') { @@ -130,8 +137,9 @@ void loop() { } Serial.print(c); } - if (len == sizeof(command)-1) { // command buffer full - command[sizeof(command)-1] = '\r'; + if (len == sizeof(command)-1) { // buffer full: treat as a completed line + command[sizeof(command)-2] = '\r'; // place end-of-line marker inside the buffer + command[sizeof(command)-1] = 0; // keep the buffer NUL-terminated } if (len > 0 && command[len - 1] == '\r') { // received complete line From 1d5283de767881e5f9c5ad2b4f564d422779df51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Br=C3=A1zio?= Date: Wed, 15 Jul 2026 08:18:59 +0000 Subject: [PATCH 15/43] Improve null checks and index validation in StaticPoolPacketManager --- src/helpers/StaticPoolPacketManager.cpp | 9 +++++++-- src/helpers/StaticPoolPacketManager.h | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/helpers/StaticPoolPacketManager.cpp b/src/helpers/StaticPoolPacketManager.cpp index b8926df0..8a8eefe8 100644 --- a/src/helpers/StaticPoolPacketManager.cpp +++ b/src/helpers/StaticPoolPacketManager.cpp @@ -44,7 +44,7 @@ mesh::Packet* PacketQueue::get(uint32_t now) { } mesh::Packet* PacketQueue::removeByIdx(int i) { - if (i >= _num) return NULL; // invalid index + if (i < 0 || i >= _num) return NULL; // invalid index mesh::Packet* item = _table[i]; _num--; @@ -80,10 +80,14 @@ mesh::Packet* StaticPoolPacketManager::allocNew() { } void StaticPoolPacketManager::free(mesh::Packet* packet) { - unused.add(packet, 0, 0); + if (packet == NULL) return; + if (!unused.add(packet, 0, 0)) { + MESH_DEBUG_PRINTLN("free: unused queue full, possible double-free detected"); + } } void StaticPoolPacketManager::queueOutbound(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for) { + if (packet == NULL) return; if (!send_queue.add(packet, priority, scheduled_for)) { MESH_DEBUG_PRINTLN("queueOutbound: send queue full, dropping packet"); free(packet); @@ -115,6 +119,7 @@ mesh::Packet* StaticPoolPacketManager::removeOutboundByIdx(int i) { } void StaticPoolPacketManager::queueInbound(mesh::Packet* packet, uint32_t scheduled_for) { + if (packet == NULL) return; if (!rx_queue.add(packet, 0, scheduled_for)) { MESH_DEBUG_PRINTLN("queueInbound: rx queue full, dropping packet"); free(packet); diff --git a/src/helpers/StaticPoolPacketManager.h b/src/helpers/StaticPoolPacketManager.h index 59715b4e..f63700e5 100644 --- a/src/helpers/StaticPoolPacketManager.h +++ b/src/helpers/StaticPoolPacketManager.h @@ -14,7 +14,10 @@ public: bool add(mesh::Packet* packet, uint8_t priority, uint32_t scheduled_for); int count() const { return _num; } int countBefore(uint32_t now) const; - mesh::Packet* itemAt(int i) const { return _table[i]; } + mesh::Packet* itemAt(int i) const { + if (i < 0 || i >= _num) return NULL; + return _table[i]; + } mesh::Packet* removeByIdx(int i); }; From 8a418faf93c5a0b94c1b60c8a7da8b811a43b002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Br=C3=A1zio?= Date: Fri, 4 Sep 2026 21:04:20 +0100 Subject: [PATCH 16/43] Add isGPSDetected() capability probe to SensorManager Lets applications know whether a GPS was actually detected (EnvironmentSensorManager reports its gps_detected state), e.g. to hide GPS-dependent UI when no receiver is attached. --- src/helpers/SensorManager.h | 1 + src/helpers/sensors/EnvironmentSensorManager.h | 1 + 2 files changed, 2 insertions(+) diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 89a174c2..85b9a1ee 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -23,6 +23,7 @@ public: virtual const char* getSettingValue(int i) const { return NULL; } virtual bool setSettingValue(const char* name, const char* value) { return false; } virtual LocationProvider* getLocationProvider() { return NULL; } + virtual bool isGPSDetected() const { return false; } // Helper functions to manage setting by keys (useful in many places ...) const char* getSettingByKey(const char* key) { diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 29147c89..17706cbc 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -38,6 +38,7 @@ public: #if ENV_INCLUDE_GPS EnvironmentSensorManager(LocationProvider &location): _location(&location){}; LocationProvider* getLocationProvider() { return _location; } + bool isGPSDetected() const override { return gps_detected; } #else EnvironmentSensorManager(){}; #endif From 8fd689311772783b3fac106f002b51dd7b884e24 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 4 Sep 2026 16:03:41 -0700 Subject: [PATCH 17/43] feat: add T-Beam 1W thermal fan control --- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 341 +++++++++++++++++++++- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 39 ++- variants/lilygo_tbeam_1w/variant.h | 19 +- 3 files changed, 387 insertions(+), 12 deletions(-) diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index 1719d733..e82e4623 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -1,5 +1,12 @@ #include "TBeam1WBoard.h" +#include +#include +#include +#include + +static const int FAN_PWM_MAX = (1 << FAN_PWM_RES_BITS) - 1; + void TBeam1WBoard::begin() { ESP32Board::begin(); @@ -15,31 +22,57 @@ void TBeam1WBoard::begin() { pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); - // Initialize fan control (on by default - 1W PA can overheat) + // NTC ADC (PA-adjacent thermistor) + pinMode(NTC_PIN, INPUT); + analogSetPinAttenuation(NTC_PIN, ADC_11db); + analogReadResolution(12); + + // Fan: auto/onoff. Thermal on at 36C / off below 30C; TX still forces a cooldown. pinMode(FAN_CTRL_PIN, OUTPUT); digitalWrite(FAN_CTRL_PIN, HIGH); + _temp_c = readNtcTempC(); + applyDuty(100); + startFanTask(); +} + +void TBeam1WBoard::startFanTask() { + if (_fan_task) return; + xTaskCreate(fanTaskThunk, "tbeam1w_fan", 4096, this, 1, &_fan_task); +} + +void TBeam1WBoard::fanTaskThunk(void* arg) { + auto* self = static_cast(arg); + for (;;) { + if (!self->_stopped) { + self->updateFan(); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + } } void TBeam1WBoard::onBeforeTransmit() { - // RF switching handled by RadioLib via SX126X_DIO2_AS_RF_SWITCH and setRfSwitchPins() digitalWrite(LED_PIN, HIGH); // TX LED on + _tx_active = true; + if (_mode == FAN_AUTO && _manual_duty < 0 && !_stopped) { + applyDuty(FAN_TX_FLOOR_PCT); + } } void TBeam1WBoard::onAfterTransmit() { digitalWrite(LED_PIN, LOW); // TX LED off + _tx_until_ms = millis() + FAN_TX_COOLDOWN_MS; + _tx_cooldown_active = true; + _tx_active = false; } uint16_t TBeam1WBoard::getBattMilliVolts() { // T-Beam 1W uses 7.4V battery with voltage divider - // ADC reads through divider - adjust multiplier based on actual divider ratio analogReadResolution(12); uint32_t raw = 0; for (int i = 0; i < 8; i++) { raw += analogRead(BATTERY_PIN); } raw = raw / 8; - // Assuming voltage divider ratio from ADC_MULTIPLIER - // 3.3V reference, 12-bit ADC (4095 max) return static_cast((raw * 3300 * ADC_MULTIPLIER) / 4095); } @@ -48,6 +81,9 @@ const char* TBeam1WBoard::getManufacturerName() const { } void TBeam1WBoard::powerOff() { + _stopped = true; + applyDuty(0); + // Turn off radio LNA (CTRL pin must be LOW when not receiving) digitalWrite(SX126X_RXEN, LOW); @@ -55,17 +91,304 @@ void TBeam1WBoard::powerOff() { digitalWrite(SX126X_POWER_EN, LOW); radio_powered = false; - // Turn off LED and fan digitalWrite(LED_PIN, LOW); - digitalWrite(FAN_CTRL_PIN, LOW); ESP32Board::powerOff(); } void TBeam1WBoard::setFanEnabled(bool enabled) { - digitalWrite(FAN_CTRL_PIN, enabled ? HIGH : LOW); + applyDuty(enabled ? 100 : 0); } bool TBeam1WBoard::isFanEnabled() const { - return digitalRead(FAN_CTRL_PIN) == HIGH; + return _duty_pct > 0; +} + +float TBeam1WBoard::readNtcTempC() { + analogReadMilliVolts(NTC_PIN); // settle + uint32_t sum = 0; + for (int i = 0; i < 8; i++) { + uint32_t sample_mv = analogReadMilliVolts(NTC_PIN); + // GPIO14 is ADC2 on ESP32-S3. Wi-Fi/ESP-NOW arbitration failures are + // reported by Arduino as 0 mV; never average a failed sample into a + // plausible-but-low temperature. + if (sample_mv == 0 || sample_mv >= NTC_VCC_MV) return NAN; + sum += sample_mv; + } + float mv = sum / 8.0f; + + // R_ntc = R_fixed * (Vcc - V) / V for 3.3V-NTC-ADC-10k-GND + float r_ntc = NTC_R_FIXED * (NTC_VCC_MV - mv) / mv; + if (r_ntc <= 0.0f) return NAN; + + float temp_k = 1.0f / (1.0f / 298.15f + (1.0f / NTC_B) * logf(r_ntc / NTC_R25)); + return temp_k - 273.15f; +} + +bool TBeam1WBoard::ntcImplausible(float temp_c) const { + return isnan(temp_c) || temp_c < -20.0f || temp_c > 120.0f; +} + +int TBeam1WBoard::rampDuty(float temp_c) const { + if (temp_c < (float)_lo_c) return 0; + if (temp_c >= (float)_hi_c) return 100; + float span = (float)(_hi_c - _lo_c); + if (span <= 0.0f) return 100; + float t = (temp_c - (float)_lo_c) / span; + return FAN_MIN_DUTY_PCT + (int)((100 - FAN_MIN_DUTY_PCT) * t + 0.5f); +} + +bool TBeam1WBoard::isTxCooling(uint32_t now) { + if (_tx_active) return true; + if (!_tx_cooldown_active) return false; + if ((int32_t)(now - _tx_until_ms) >= 0) { + _tx_cooldown_active = false; + return false; + } + return true; +} + +int TBeam1WBoard::cooldownSecs() { + if (_tx_active) return (FAN_TX_COOLDOWN_MS + 999) / 1000; + if (!_tx_cooldown_active) return 0; + int32_t remain_ms = (int32_t)(_tx_until_ms - millis()); + if (remain_ms <= 0) { + _tx_cooldown_active = false; + return 0; + } + return (remain_ms + 999) / 1000; +} + +void TBeam1WBoard::applyDuty(int pct) { + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; + _duty_pct = pct; + + if (_drive == FAN_DRIVE_PWM) { + if (!_pwm_attached) { + ledcSetup(FAN_PWM_CHANNEL, FAN_PWM_FREQ_HZ, FAN_PWM_RES_BITS); + ledcAttachPin(FAN_CTRL_PIN, FAN_PWM_CHANNEL); + _pwm_attached = true; + } + uint32_t ticks = ((uint32_t)pct * FAN_PWM_MAX + 50) / 100; + ledcWrite(FAN_PWM_CHANNEL, ticks); + } else { + if (_pwm_attached) { + ledcDetachPin(FAN_CTRL_PIN); + _pwm_attached = false; + pinMode(FAN_CTRL_PIN, OUTPUT); + } + digitalWrite(FAN_CTRL_PIN, pct > 0 ? HIGH : LOW); + } +} + +void TBeam1WBoard::updateFan() { + float t = readNtcTempC(); + _temp_c = t; + bool tx_cooling = isTxCooling(millis()); + + int duty; + if (_manual_duty >= 0) { + duty = _manual_duty; + } else if (_mode == FAN_ON) { + duty = 100; + } else if (_mode == FAN_OFF) { + duty = 0; + } else if (ntcImplausible(t)) { + duty = 100; // fail-safe: treat bad NTC as hot + } else if (_drive == FAN_DRIVE_PWM) { + duty = rampDuty(t); + } else { + // Thermal hysteresis only: on at hi, off below lo. TX cooldown is applied + // after this and must not latch _thermal_on, or a TX at 30C keeps the fan + // running until temp dips under lo. + if (t >= (float)_hi_c) _thermal_on = true; + else if (t < (float)_lo_c) _thermal_on = false; + duty = _thermal_on ? 100 : 0; + } + + if (_mode == FAN_AUTO && _manual_duty < 0 && !ntcImplausible(t)) { + if (tx_cooling && duty < FAN_TX_FLOOR_PCT) duty = FAN_TX_FLOOR_PCT; + } + + applyDuty(duty); +} + +bool TBeam1WBoard::persistKey(const char* key, const char* value) { + return _prefs && _prefs->setByKey(key, value); +} + +bool TBeam1WBoard::parseIntArg(const char* text, int& value) { + if (!text || !*text) return false; + errno = 0; + char* end = nullptr; + long parsed = strtol(text, &end, 10); + if (errno == ERANGE || end == text || *end != '\0' || parsed < INT_MIN || parsed > INT_MAX) { + return false; + } + value = (int)parsed; + return true; +} + +void TBeam1WBoard::loadFanPrefs() { + if (!_prefs) return; + + char buf[12]; + buf[0] = 0; + if (_prefs->getByKey("fan", buf, 11)) { + if (strcmp(buf, "auto") == 0) _mode = FAN_AUTO; + else if (strcmp(buf, "off") == 0) _mode = FAN_OFF; + else if (strcmp(buf, "on") == 0) _mode = FAN_ON; + } + + buf[0] = 0; + if (_prefs->getByKey("fan_drv", buf, 11)) { + if (strcmp(buf, "onoff") == 0) _drive = FAN_DRIVE_ONOFF; + else if (strcmp(buf, "pwm") == 0) _drive = FAN_DRIVE_PWM; + } + + int lo = _lo_c; + int hi = _hi_c; + buf[0] = 0; + if (_prefs->getByKey("fan_lo", buf, 11)) lo = atoi(buf); + buf[0] = 0; + if (_prefs->getByKey("fan_hi", buf, 11)) hi = atoi(buf); + if (lo >= 0 && hi <= 120 && lo < hi) { + _lo_c = lo; + _hi_c = hi; + } + + _manual_duty = -1; +} + +void TBeam1WBoard::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + loadFanPrefs(); + updateFan(); +} + +const char* TBeam1WBoard::modeName() const { + if (_manual_duty >= 0) return "manual"; + if (_mode == FAN_AUTO) return "auto"; + if (_mode == FAN_OFF) return "off"; + return "on"; +} + +const char* TBeam1WBoard::driveName() const { + return _drive == FAN_DRIVE_ONOFF ? "onoff" : "pwm"; +} + +bool TBeam1WBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + + if (strcmp(command, "get fan") == 0) { + int cd = cooldownSecs(); + if (ntcImplausible(_temp_c)) { + sprintf(reply, "> %s n/a duty=%d%% %s cd=%ds", + modeName(), (int)_duty_pct, driveName(), cd); + } else { + sprintf(reply, "> %s %.1fC duty=%d%% %s cd=%ds", + modeName(), (double)_temp_c, (int)_duty_pct, driveName(), cd); + } + return true; + } + + if (strncmp(command, "set fan.lo ", 11) == 0) { + int lo; + if (!parseIntArg(&command[11], lo) || lo < 0 || lo >= _hi_c || lo > 100) { + strcpy(reply, "Error: fan.lo must be 0..100 and < fan.hi"); + } else if (!persistKey("fan_lo", &command[11])) { + strcpy(reply, "Error: failed to save fan.lo"); + } else { + _lo_c = lo; + sprintf(reply, "OK - fan.lo %d", lo); + } + return true; + } + + if (strncmp(command, "set fan.hi ", 11) == 0) { + int hi; + if (!parseIntArg(&command[11], hi) || hi <= _lo_c || hi > 120) { + strcpy(reply, "Error: fan.hi must be > fan.lo and <= 120"); + } else if (!persistKey("fan_hi", &command[11])) { + strcpy(reply, "Error: failed to save fan.hi"); + } else { + _hi_c = hi; + sprintf(reply, "OK - fan.hi %d", hi); + } + return true; + } + + if (strncmp(command, "set fan.drive ", 14) == 0) { + const char* arg = &command[14]; + if (strcmp(arg, "pwm") == 0) { + if (!persistKey("fan_drv", "pwm")) { + strcpy(reply, "Error: failed to save fan.drive"); + } else { + _drive = FAN_DRIVE_PWM; + applyDuty(_duty_pct); + strcpy(reply, "OK - fan.drive pwm"); + } + } else if (strcmp(arg, "onoff") == 0) { + if (!persistKey("fan_drv", "onoff")) { + strcpy(reply, "Error: failed to save fan.drive"); + } else { + _drive = FAN_DRIVE_ONOFF; + applyDuty(_duty_pct); + strcpy(reply, "OK - fan.drive onoff"); + } + } else { + strcpy(reply, "Error: fan.drive must be pwm or onoff"); + } + return true; + } + + if (strncmp(command, "set fan.duty ", 13) == 0) { + int duty; + if (!parseIntArg(&command[13], duty) || duty < 0 || duty > 100) { + strcpy(reply, "Error: fan.duty must be 0-100"); + } else { + _manual_duty = duty; + applyDuty(duty); + sprintf(reply, "OK - fan.duty %d (not saved)", duty); + } + return true; + } + + if (strncmp(command, "set fan ", 8) == 0) { + const char* arg = &command[8]; + if (strcmp(arg, "on") == 0) { + if (!persistKey("fan", "on")) { + strcpy(reply, "Error: failed to save fan mode"); + } else { + _mode = FAN_ON; + _manual_duty = -1; + applyDuty(100); + strcpy(reply, "OK - fan on"); + } + } else if (strcmp(arg, "off") == 0) { + if (!persistKey("fan", "off")) { + strcpy(reply, "Error: failed to save fan mode"); + } else { + _mode = FAN_OFF; + _manual_duty = -1; + applyDuty(0); + strcpy(reply, "OK - fan off"); + } + } else if (strcmp(arg, "auto") == 0) { + if (!persistKey("fan", "auto")) { + strcpy(reply, "Error: failed to save fan mode"); + } else { + _mode = FAN_AUTO; + _manual_duty = -1; + updateFan(); + strcpy(reply, "OK - fan auto"); + } + } else { + strcpy(reply, "Error: fan must be on, off, or auto"); + } + return true; + } + + return false; } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index d999dfd4..e96923c5 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include "variant.h" @@ -28,18 +30,53 @@ // - Battery must support 2A+ discharge for high-power TX class TBeam1WBoard : public ESP32Board { +public: + enum FanMode { FAN_ON, FAN_OFF, FAN_AUTO }; + enum FanDrive { FAN_DRIVE_PWM, FAN_DRIVE_ONOFF }; + private: bool radio_powered = false; + bool _stopped = false; + bool _pwm_attached = false; + KeyValueStore* _prefs = nullptr; + FanMode _mode = FAN_AUTO; + FanDrive _drive = FAN_DRIVE_ONOFF; + int _lo_c = FAN_DEFAULT_LO_C; + int _hi_c = FAN_DEFAULT_HI_C; + int _manual_duty = -1; // -1 = follow mode; 0..100 = CLI override + bool _thermal_on = false; // onoff hysteresis; TX boost must not latch this + volatile float _temp_c = NAN; + volatile int _duty_pct = 100; + volatile bool _tx_active = false; + volatile bool _tx_cooldown_active = false; + volatile uint32_t _tx_until_ms = 0; + TaskHandle_t _fan_task = nullptr; + + void startFanTask(); + void updateFan(); + void applyDuty(int pct); + float readNtcTempC(); + int rampDuty(float temp_c) const; + int cooldownSecs(); + bool isTxCooling(uint32_t now); + bool ntcImplausible(float temp_c) const; + bool persistKey(const char* key, const char* value); + static bool parseIntArg(const char* text, int& value); + void loadFanPrefs(); + const char* modeName() const; + const char* driveName() const; + static void fanTaskThunk(void* arg); public: void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; void onBeforeTransmit() override; void onAfterTransmit() override; uint16_t getBattMilliVolts() override; const char* getManufacturerName() const override; void powerOff() override; - // Fan control methods void setFanEnabled(bool enabled); bool isFanEnabled() const; }; diff --git a/variants/lilygo_tbeam_1w/variant.h b/variants/lilygo_tbeam_1w/variant.h index f6807e56..a67e2e84 100644 --- a/variants/lilygo_tbeam_1w/variant.h +++ b/variants/lilygo_tbeam_1w/variant.h @@ -77,11 +77,26 @@ #define BATTERY_SENSE_SAMPLES 30 #define ADC_MULTIPLIER 3.0 -// NTC temperature sensor +// NTC thermistor (Murata NCP18XH103F03RB, 10k, B25/50=3380K) +// Divider: 3.3V -> NTC -> GPIO14 -> 10k pull-down -> GND (Vadc rises with temp) #define NTC_PIN 14 +#define NTC_B 3380.0f +#define NTC_R25 10000.0f +#define NTC_R_FIXED 10000.0f +#define NTC_VCC_MV 3300.0f -// Fan control +// Fan control (GPIO41). Default auto + on/off: NTC is PA-adjacent PCB temp, +// not die temp, so trip well below the SX1262/ESP32 85C operating limit. +// This fan/MOSFET path does not respond to PWM below 100% duty. #define FAN_CTRL_PIN 41 +#define FAN_PWM_CHANNEL 4 +#define FAN_PWM_FREQ_HZ 25000 +#define FAN_PWM_RES_BITS 8 +#define FAN_MIN_DUTY_PCT 40 +#define FAN_TX_COOLDOWN_MS 15000 +#define FAN_TX_FLOOR_PCT 100 +#define FAN_DEFAULT_LO_C 30 // off below typical indoor idle (~86F) +#define FAN_DEFAULT_HI_C 36 // on at ~97F PCB; still far below 85C chip ratings // PA Ramp Time - T-Beam 1W requires >800us stabilization (default is 200us) // Value 0x05 = RADIOLIB_SX126X_PA_RAMP_800U From 6c1f4c09a6e46603ee291e36bb8d8f6446e75b33 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 4 Sep 2026 16:51:53 -0700 Subject: [PATCH 18/43] Fix T-Beam 1W fan races and remove PWM controls --- variants/lilygo_tbeam_1w/TBeam1WBoard.cpp | 222 +++++++++------------- variants/lilygo_tbeam_1w/TBeam1WBoard.h | 25 +-- variants/lilygo_tbeam_1w/variant.h | 11 +- 3 files changed, 102 insertions(+), 156 deletions(-) diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp index e82e4623..e0419955 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.cpp @@ -5,8 +5,6 @@ #include #include -static const int FAN_PWM_MAX = (1 << FAN_PWM_RES_BITS) - 1; - void TBeam1WBoard::begin() { ESP32Board::begin(); @@ -30,8 +28,8 @@ void TBeam1WBoard::begin() { // Fan: auto/onoff. Thermal on at 36C / off below 30C; TX still forces a cooldown. pinMode(FAN_CTRL_PIN, OUTPUT); digitalWrite(FAN_CTRL_PIN, HIGH); + _fan_on = true; _temp_c = readNtcTempC(); - applyDuty(100); startFanTask(); } @@ -43,26 +41,30 @@ void TBeam1WBoard::startFanTask() { void TBeam1WBoard::fanTaskThunk(void* arg) { auto* self = static_cast(arg); for (;;) { - if (!self->_stopped) { - self->updateFan(); - } + self->updateFan(); vTaskDelay(pdMS_TO_TICKS(1000)); } } void TBeam1WBoard::onBeforeTransmit() { digitalWrite(LED_PIN, HIGH); // TX LED on + portENTER_CRITICAL(&_fan_mux); _tx_active = true; - if (_mode == FAN_AUTO && _manual_duty < 0 && !_stopped) { - applyDuty(FAN_TX_FLOOR_PCT); + if (_mode == FAN_AUTO && !_stopped) { + setFanOutputLocked(true); } + portEXIT_CRITICAL(&_fan_mux); } void TBeam1WBoard::onAfterTransmit() { digitalWrite(LED_PIN, LOW); // TX LED off - _tx_until_ms = millis() + FAN_TX_COOLDOWN_MS; - _tx_cooldown_active = true; + portENTER_CRITICAL(&_fan_mux); + if (!_stopped) { + _tx_until_ms = millis() + FAN_TX_COOLDOWN_MS; + _tx_cooldown_active = true; + } _tx_active = false; + portEXIT_CRITICAL(&_fan_mux); } uint16_t TBeam1WBoard::getBattMilliVolts() { @@ -81,8 +83,12 @@ const char* TBeam1WBoard::getManufacturerName() const { } void TBeam1WBoard::powerOff() { + portENTER_CRITICAL(&_fan_mux); _stopped = true; - applyDuty(0); + _tx_active = false; + _tx_cooldown_active = false; + setFanOutputLocked(false); + portEXIT_CRITICAL(&_fan_mux); // Turn off radio LNA (CTRL pin must be LOW when not receiving) digitalWrite(SX126X_RXEN, LOW); @@ -97,11 +103,16 @@ void TBeam1WBoard::powerOff() { } void TBeam1WBoard::setFanEnabled(bool enabled) { - applyDuty(enabled ? 100 : 0); + portENTER_CRITICAL(&_fan_mux); + setFanOutputLocked(enabled && !_stopped); + portEXIT_CRITICAL(&_fan_mux); } bool TBeam1WBoard::isFanEnabled() const { - return _duty_pct > 0; + portENTER_CRITICAL(&_fan_mux); + bool enabled = _fan_on; + portEXIT_CRITICAL(&_fan_mux); + return enabled; } float TBeam1WBoard::readNtcTempC() { @@ -129,16 +140,7 @@ bool TBeam1WBoard::ntcImplausible(float temp_c) const { return isnan(temp_c) || temp_c < -20.0f || temp_c > 120.0f; } -int TBeam1WBoard::rampDuty(float temp_c) const { - if (temp_c < (float)_lo_c) return 0; - if (temp_c >= (float)_hi_c) return 100; - float span = (float)(_hi_c - _lo_c); - if (span <= 0.0f) return 100; - float t = (temp_c - (float)_lo_c) / span; - return FAN_MIN_DUTY_PCT + (int)((100 - FAN_MIN_DUTY_PCT) * t + 0.5f); -} - -bool TBeam1WBoard::isTxCooling(uint32_t now) { +bool TBeam1WBoard::isTxCoolingLocked(uint32_t now) { if (_tx_active) return true; if (!_tx_cooldown_active) return false; if ((int32_t)(now - _tx_until_ms) >= 0) { @@ -148,7 +150,7 @@ bool TBeam1WBoard::isTxCooling(uint32_t now) { return true; } -int TBeam1WBoard::cooldownSecs() { +int TBeam1WBoard::cooldownSecsLocked() { if (_tx_active) return (FAN_TX_COOLDOWN_MS + 999) / 1000; if (!_tx_cooldown_active) return 0; int32_t remain_ms = (int32_t)(_tx_until_ms - millis()); @@ -159,59 +161,41 @@ int TBeam1WBoard::cooldownSecs() { return (remain_ms + 999) / 1000; } -void TBeam1WBoard::applyDuty(int pct) { - if (pct < 0) pct = 0; - if (pct > 100) pct = 100; - _duty_pct = pct; - - if (_drive == FAN_DRIVE_PWM) { - if (!_pwm_attached) { - ledcSetup(FAN_PWM_CHANNEL, FAN_PWM_FREQ_HZ, FAN_PWM_RES_BITS); - ledcAttachPin(FAN_CTRL_PIN, FAN_PWM_CHANNEL); - _pwm_attached = true; - } - uint32_t ticks = ((uint32_t)pct * FAN_PWM_MAX + 50) / 100; - ledcWrite(FAN_PWM_CHANNEL, ticks); - } else { - if (_pwm_attached) { - ledcDetachPin(FAN_CTRL_PIN); - _pwm_attached = false; - pinMode(FAN_CTRL_PIN, OUTPUT); - } - digitalWrite(FAN_CTRL_PIN, pct > 0 ? HIGH : LOW); - } +void TBeam1WBoard::setFanOutputLocked(bool enabled) { + _fan_on = enabled; + digitalWrite(FAN_CTRL_PIN, enabled ? HIGH : LOW); } void TBeam1WBoard::updateFan() { float t = readNtcTempC(); - _temp_c = t; - bool tx_cooling = isTxCooling(millis()); + uint32_t now = millis(); - int duty; - if (_manual_duty >= 0) { - duty = _manual_duty; - } else if (_mode == FAN_ON) { - duty = 100; + portENTER_CRITICAL(&_fan_mux); + if (_stopped) { + portEXIT_CRITICAL(&_fan_mux); + return; + } + + _temp_c = t; + bool tx_cooling = isTxCoolingLocked(now); + + bool enabled; + if (_mode == FAN_ON) { + enabled = true; } else if (_mode == FAN_OFF) { - duty = 0; + enabled = false; } else if (ntcImplausible(t)) { - duty = 100; // fail-safe: treat bad NTC as hot - } else if (_drive == FAN_DRIVE_PWM) { - duty = rampDuty(t); + enabled = true; // fail-safe: treat bad NTC as hot } else { - // Thermal hysteresis only: on at hi, off below lo. TX cooldown is applied - // after this and must not latch _thermal_on, or a TX at 30C keeps the fan - // running until temp dips under lo. + // TX cooldown must not latch _thermal_on, or a TX at 30C keeps the fan + // running until the temperature dips under lo. if (t >= (float)_hi_c) _thermal_on = true; else if (t < (float)_lo_c) _thermal_on = false; - duty = _thermal_on ? 100 : 0; + enabled = _thermal_on || tx_cooling; } - if (_mode == FAN_AUTO && _manual_duty < 0 && !ntcImplausible(t)) { - if (tx_cooling && duty < FAN_TX_FLOOR_PCT) duty = FAN_TX_FLOOR_PCT; - } - - applyDuty(duty); + setFanOutputLocked(enabled); + portEXIT_CRITICAL(&_fan_mux); } bool TBeam1WBoard::persistKey(const char* key, const char* value) { @@ -233,32 +217,28 @@ bool TBeam1WBoard::parseIntArg(const char* text, int& value) { void TBeam1WBoard::loadFanPrefs() { if (!_prefs) return; + FanMode mode = FAN_AUTO; char buf[12]; buf[0] = 0; if (_prefs->getByKey("fan", buf, 11)) { - if (strcmp(buf, "auto") == 0) _mode = FAN_AUTO; - else if (strcmp(buf, "off") == 0) _mode = FAN_OFF; - else if (strcmp(buf, "on") == 0) _mode = FAN_ON; + if (strcmp(buf, "off") == 0) mode = FAN_OFF; + else if (strcmp(buf, "on") == 0) mode = FAN_ON; } - buf[0] = 0; - if (_prefs->getByKey("fan_drv", buf, 11)) { - if (strcmp(buf, "onoff") == 0) _drive = FAN_DRIVE_ONOFF; - else if (strcmp(buf, "pwm") == 0) _drive = FAN_DRIVE_PWM; - } - - int lo = _lo_c; - int hi = _hi_c; + int lo = FAN_DEFAULT_LO_C; + int hi = FAN_DEFAULT_HI_C; buf[0] = 0; if (_prefs->getByKey("fan_lo", buf, 11)) lo = atoi(buf); buf[0] = 0; if (_prefs->getByKey("fan_hi", buf, 11)) hi = atoi(buf); + + portENTER_CRITICAL(&_fan_mux); + _mode = mode; if (lo >= 0 && hi <= 120 && lo < hi) { _lo_c = lo; _hi_c = hi; } - - _manual_duty = -1; + portEXIT_CRITICAL(&_fan_mux); } void TBeam1WBoard::attachDynamicPrefs(KeyValueStore* prefs) { @@ -267,40 +247,44 @@ void TBeam1WBoard::attachDynamicPrefs(KeyValueStore* prefs) { updateFan(); } -const char* TBeam1WBoard::modeName() const { - if (_manual_duty >= 0) return "manual"; +const char* TBeam1WBoard::modeNameLocked() const { if (_mode == FAN_AUTO) return "auto"; if (_mode == FAN_OFF) return "off"; return "on"; } -const char* TBeam1WBoard::driveName() const { - return _drive == FAN_DRIVE_ONOFF ? "onoff" : "pwm"; -} - bool TBeam1WBoard::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { (void)sender_timestamp; if (strcmp(command, "get fan") == 0) { - int cd = cooldownSecs(); - if (ntcImplausible(_temp_c)) { - sprintf(reply, "> %s n/a duty=%d%% %s cd=%ds", - modeName(), (int)_duty_pct, driveName(), cd); + portENTER_CRITICAL(&_fan_mux); + int cd = cooldownSecsLocked(); + float temp_c = _temp_c; + bool enabled = _fan_on; + const char* mode = modeNameLocked(); + portEXIT_CRITICAL(&_fan_mux); + if (ntcImplausible(temp_c)) { + sprintf(reply, "> %s n/a fan=%s cd=%ds", mode, enabled ? "on" : "off", cd); } else { - sprintf(reply, "> %s %.1fC duty=%d%% %s cd=%ds", - modeName(), (double)_temp_c, (int)_duty_pct, driveName(), cd); + sprintf(reply, "> %s %.1fC fan=%s cd=%ds", + mode, (double)temp_c, enabled ? "on" : "off", cd); } return true; } if (strncmp(command, "set fan.lo ", 11) == 0) { int lo; - if (!parseIntArg(&command[11], lo) || lo < 0 || lo >= _hi_c || lo > 100) { + portENTER_CRITICAL(&_fan_mux); + int hi_limit = _hi_c; + portEXIT_CRITICAL(&_fan_mux); + if (!parseIntArg(&command[11], lo) || lo < 0 || lo >= hi_limit || lo > 100) { strcpy(reply, "Error: fan.lo must be 0..100 and < fan.hi"); } else if (!persistKey("fan_lo", &command[11])) { strcpy(reply, "Error: failed to save fan.lo"); } else { + portENTER_CRITICAL(&_fan_mux); _lo_c = lo; + portEXIT_CRITICAL(&_fan_mux); sprintf(reply, "OK - fan.lo %d", lo); } return true; @@ -308,79 +292,51 @@ bool TBeam1WBoard::handleCommand(const char* command, uint32_t sender_timestamp, if (strncmp(command, "set fan.hi ", 11) == 0) { int hi; - if (!parseIntArg(&command[11], hi) || hi <= _lo_c || hi > 120) { + portENTER_CRITICAL(&_fan_mux); + int lo_limit = _lo_c; + portEXIT_CRITICAL(&_fan_mux); + if (!parseIntArg(&command[11], hi) || hi <= lo_limit || hi > 120) { strcpy(reply, "Error: fan.hi must be > fan.lo and <= 120"); } else if (!persistKey("fan_hi", &command[11])) { strcpy(reply, "Error: failed to save fan.hi"); } else { + portENTER_CRITICAL(&_fan_mux); _hi_c = hi; + portEXIT_CRITICAL(&_fan_mux); sprintf(reply, "OK - fan.hi %d", hi); } return true; } - if (strncmp(command, "set fan.drive ", 14) == 0) { - const char* arg = &command[14]; - if (strcmp(arg, "pwm") == 0) { - if (!persistKey("fan_drv", "pwm")) { - strcpy(reply, "Error: failed to save fan.drive"); - } else { - _drive = FAN_DRIVE_PWM; - applyDuty(_duty_pct); - strcpy(reply, "OK - fan.drive pwm"); - } - } else if (strcmp(arg, "onoff") == 0) { - if (!persistKey("fan_drv", "onoff")) { - strcpy(reply, "Error: failed to save fan.drive"); - } else { - _drive = FAN_DRIVE_ONOFF; - applyDuty(_duty_pct); - strcpy(reply, "OK - fan.drive onoff"); - } - } else { - strcpy(reply, "Error: fan.drive must be pwm or onoff"); - } - return true; - } - - if (strncmp(command, "set fan.duty ", 13) == 0) { - int duty; - if (!parseIntArg(&command[13], duty) || duty < 0 || duty > 100) { - strcpy(reply, "Error: fan.duty must be 0-100"); - } else { - _manual_duty = duty; - applyDuty(duty); - sprintf(reply, "OK - fan.duty %d (not saved)", duty); - } - return true; - } - if (strncmp(command, "set fan ", 8) == 0) { const char* arg = &command[8]; if (strcmp(arg, "on") == 0) { if (!persistKey("fan", "on")) { strcpy(reply, "Error: failed to save fan mode"); } else { + portENTER_CRITICAL(&_fan_mux); _mode = FAN_ON; - _manual_duty = -1; - applyDuty(100); + if (!_stopped) setFanOutputLocked(true); + portEXIT_CRITICAL(&_fan_mux); strcpy(reply, "OK - fan on"); } } else if (strcmp(arg, "off") == 0) { if (!persistKey("fan", "off")) { strcpy(reply, "Error: failed to save fan mode"); } else { + portENTER_CRITICAL(&_fan_mux); _mode = FAN_OFF; - _manual_duty = -1; - applyDuty(0); + setFanOutputLocked(false); + portEXIT_CRITICAL(&_fan_mux); strcpy(reply, "OK - fan off"); } } else if (strcmp(arg, "auto") == 0) { if (!persistKey("fan", "auto")) { strcpy(reply, "Error: failed to save fan mode"); } else { + portENTER_CRITICAL(&_fan_mux); _mode = FAN_AUTO; - _manual_duty = -1; + portEXIT_CRITICAL(&_fan_mux); updateFan(); strcpy(reply, "OK - fan auto"); } diff --git a/variants/lilygo_tbeam_1w/TBeam1WBoard.h b/variants/lilygo_tbeam_1w/TBeam1WBoard.h index e96923c5..429b7bd1 100644 --- a/variants/lilygo_tbeam_1w/TBeam1WBoard.h +++ b/variants/lilygo_tbeam_1w/TBeam1WBoard.h @@ -32,39 +32,34 @@ class TBeam1WBoard : public ESP32Board { public: enum FanMode { FAN_ON, FAN_OFF, FAN_AUTO }; - enum FanDrive { FAN_DRIVE_PWM, FAN_DRIVE_ONOFF }; private: bool radio_powered = false; bool _stopped = false; - bool _pwm_attached = false; KeyValueStore* _prefs = nullptr; FanMode _mode = FAN_AUTO; - FanDrive _drive = FAN_DRIVE_ONOFF; int _lo_c = FAN_DEFAULT_LO_C; int _hi_c = FAN_DEFAULT_HI_C; - int _manual_duty = -1; // -1 = follow mode; 0..100 = CLI override bool _thermal_on = false; // onoff hysteresis; TX boost must not latch this - volatile float _temp_c = NAN; - volatile int _duty_pct = 100; - volatile bool _tx_active = false; - volatile bool _tx_cooldown_active = false; - volatile uint32_t _tx_until_ms = 0; + bool _fan_on = true; + float _temp_c = NAN; + bool _tx_active = false; + bool _tx_cooldown_active = false; + uint32_t _tx_until_ms = 0; TaskHandle_t _fan_task = nullptr; + mutable portMUX_TYPE _fan_mux = portMUX_INITIALIZER_UNLOCKED; void startFanTask(); void updateFan(); - void applyDuty(int pct); + void setFanOutputLocked(bool enabled); float readNtcTempC(); - int rampDuty(float temp_c) const; - int cooldownSecs(); - bool isTxCooling(uint32_t now); + int cooldownSecsLocked(); + bool isTxCoolingLocked(uint32_t now); bool ntcImplausible(float temp_c) const; bool persistKey(const char* key, const char* value); static bool parseIntArg(const char* text, int& value); void loadFanPrefs(); - const char* modeName() const; - const char* driveName() const; + const char* modeNameLocked() const; static void fanTaskThunk(void* arg); public: diff --git a/variants/lilygo_tbeam_1w/variant.h b/variants/lilygo_tbeam_1w/variant.h index a67e2e84..528459a4 100644 --- a/variants/lilygo_tbeam_1w/variant.h +++ b/variants/lilygo_tbeam_1w/variant.h @@ -85,16 +85,11 @@ #define NTC_R_FIXED 10000.0f #define NTC_VCC_MV 3300.0f -// Fan control (GPIO41). Default auto + on/off: NTC is PA-adjacent PCB temp, -// not die temp, so trip well below the SX1262/ESP32 85C operating limit. -// This fan/MOSFET path does not respond to PWM below 100% duty. +// Fan control (GPIO41). NTC is PA-adjacent PCB temp, not die temp, so trip +// well below the SX1262/ESP32 85C operating limit. Hardware testing confirmed +// that this fan/MOSFET path is on/off; PWM below 100% does not spin the fan. #define FAN_CTRL_PIN 41 -#define FAN_PWM_CHANNEL 4 -#define FAN_PWM_FREQ_HZ 25000 -#define FAN_PWM_RES_BITS 8 -#define FAN_MIN_DUTY_PCT 40 #define FAN_TX_COOLDOWN_MS 15000 -#define FAN_TX_FLOOR_PCT 100 #define FAN_DEFAULT_LO_C 30 // off below typical indoor idle (~86F) #define FAN_DEFAULT_HI_C 36 // on at ~97F PCB; still far below 85C chip ratings From 0b2c47a932763daa06c395fe81391d59e95141f0 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Fri, 4 Sep 2026 20:10:20 -0500 Subject: [PATCH 19/43] Add WiFi companion support for Pico W SerialWifiInterface has no ESP32-specific code, so move it to helpers/wifi and reuse it on RP2040. Guard the ESP32-only WiFi event/auto-reconnect calls and poll link state on RP2040 instead. --- examples/companion_radio/main.cpp | 24 ++++++++++----- platformio.ini | 1 + .../{esp32 => wifi}/SerialWifiInterface.cpp | 0 .../{esp32 => wifi}/SerialWifiInterface.h | 0 variants/rpi_picow/platformio.ini | 30 ++++++++++--------- 5 files changed, 34 insertions(+), 21 deletions(-) rename src/helpers/{esp32 => wifi}/SerialWifiInterface.cpp (100%) rename src/helpers/{esp32 => wifi}/SerialWifiInterface.h (100%) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 89f0e6cb..7c8c12b9 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -36,9 +36,8 @@ MultiSerialInterface interface_manager; #ifndef TCP_PORT #define TCP_PORT 5000 #endif - #ifdef ESP32 - // include esp32 wifi interface - #include + #if defined(ESP32) || defined(RP2040_PLATFORM) + #include SerialWifiInterface wifi_interface; #else #error "SerialWifiInterface is not defined for this platform" @@ -108,7 +107,7 @@ void halt() { } /* WIFI RECONNECT TRACKERS */ -#if defined(ESP32) && defined(WIFI_SSID) +#ifdef WIFI_SSID bool wifi_needs_reconnect = false; unsigned long last_wifi_reconnect_attempt = 0; #endif @@ -192,6 +191,7 @@ void setup() { // add wifi interface #ifdef WIFI_SSID +#if defined(ESP32) board.setInhibitSleep(true); // prevent sleep when WiFi is active WiFi.setAutoReconnect(true); @@ -204,6 +204,7 @@ void setup() { wifi_needs_reconnect = false; } }); +#endif WiFi.begin(WIFI_SSID, WIFI_PWD); wifi_interface.begin(TCP_PORT); @@ -262,12 +263,21 @@ void loop() { #endif } -#if defined(ESP32) && defined(WIFI_SSID) +#ifdef WIFI_SSID + // RP2040 has no WiFi event callbacks, so poll the link state instead + #if defined(RP2040_PLATFORM) + wifi_needs_reconnect = (WiFi.status() != WL_CONNECTED); + #endif + // Safely attempt to reconnect every 10 seconds if flagged if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) { WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect..."); - WiFi.disconnect(); - WiFi.reconnect(); + #if defined(RP2040_PLATFORM) + WiFi.begin(WIFI_SSID, WIFI_PWD); // no reconnect() on this platform + #else + WiFi.disconnect(); + WiFi.reconnect(); + #endif last_wifi_reconnect_attempt = millis(); } #endif diff --git a/platformio.ini b/platformio.ini index 2219c978..622b01e2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -64,6 +64,7 @@ build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM ; -D ESP32_CPU_FREQ=80 ; change it to your need build_src_filter = ${arduino_base.build_src_filter} + + [esp32_ota] lib_deps = diff --git a/src/helpers/esp32/SerialWifiInterface.cpp b/src/helpers/wifi/SerialWifiInterface.cpp similarity index 100% rename from src/helpers/esp32/SerialWifiInterface.cpp rename to src/helpers/wifi/SerialWifiInterface.cpp diff --git a/src/helpers/esp32/SerialWifiInterface.h b/src/helpers/wifi/SerialWifiInterface.h similarity index 100% rename from src/helpers/esp32/SerialWifiInterface.h rename to src/helpers/wifi/SerialWifiInterface.h diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index 0fe8c436..32944a9a 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -81,20 +81,22 @@ lib_ignore = BLE ; lib_deps = ${rpi_picow.lib_deps} ; densaugeo/base64 @ ~1.4.0 -; [env:PicoW_companion_radio_wifi] -; extends = rpi_picow -; build_flags = ${rpi_picow.build_flags} -; -D MAX_CONTACTS=100 -; -D MAX_GROUP_CHANNELS=8 -; -D WIFI_DEBUG_LOGGING=1 -; -D WIFI_SSID='"myssid"' -; -D WIFI_PWD='"mypwd"' -; ; -D MESH_PACKET_LOGGING=1 -; ; -D MESH_DEBUG=1 -; build_src_filter = ${rpi_picow.build_src_filter} -; +<../examples/companion_radio/*.cpp> -; lib_deps = ${rpi_picow.lib_deps} -; densaugeo/base64 @ ~1.4.0 +[env:PicoW_companion_radio_wifi] +extends = rpi_picow +build_flags = ${rpi_picow.build_flags} + -D MAX_CONTACTS=100 + -D MAX_GROUP_CHANNELS=8 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rpi_picow.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +lib_deps = ${rpi_picow.lib_deps} + densaugeo/base64 @ ~1.4.0 +lib_ignore = BLE [env:PicoW_terminal_chat] extends = rpi_picow From 739a67c9f1504e029272a2fc68d241f1e9107846 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Fri, 4 Sep 2026 20:18:51 -0500 Subject: [PATCH 20/43] Allow WiFi credentials to be set at runtime Store ssid/pwd in NodePrefs and set them with 'set wifi.ssid' / 'set wifi.pwd' over USB serial; build-time WIFI_SSID/WIFI_PWD stay as the fallback. Headless WiFi builds get the config CLI on Serial, which is otherwise unused there. --- examples/companion_radio/MyMesh.cpp | 34 ++++++++++++++++++++++++++++ examples/companion_radio/NodePrefs.h | 27 +++++++++++++++++++++- examples/companion_radio/main.cpp | 13 +++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ee8114ca..8550890e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -932,6 +932,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) { _iter_started = false; _cli_rescue = false; + cli_command[0] = 0; offline_queue_len = 0; app_target_ver = 0; clearPendingReqs(); @@ -2156,6 +2157,35 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } +#ifdef WIFI_SSID + // local console only: these are credentials, and remote admin has no business with them + if (sender_timestamp == 0) { + if (memcmp(command, "set wifi.ssid ", 14) == 0) { + StrHelper::strncpy(_prefs.wifi_ssid, &command[14], sizeof(_prefs.wifi_ssid)); + savePrefs(); + sprintf(reply, "> wifi.ssid is now %s (set wifi.pwd too, then reboot)", _prefs.wifi_ssid); + return true; + } + if (memcmp(command, "set wifi.pwd ", 13) == 0) { + StrHelper::strncpy(_prefs.wifi_pwd, &command[13], sizeof(_prefs.wifi_pwd)); + savePrefs(); + strcpy(reply, "> wifi.pwd updated (reboot to apply)"); + return true; + } + if (strcmp(command, "set wifi.clear") == 0) { + _prefs.wifi_ssid[0] = 0; + _prefs.wifi_pwd[0] = 0; + savePrefs(); + strcpy(reply, "> wifi config cleared, using build-time credentials (reboot to apply)"); + return true; + } + if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design + sprintf(reply, "> %s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "(build-time)"); + return true; + } + } +#endif + if (strcmp(command, "board") == 0) { strcpy(reply, board.getManufacturerName()); return true; @@ -2386,6 +2416,10 @@ void MyMesh::loop() { checkCLIRescueCmd(); } else { checkSerialInterface(); +#if defined(WIFI_SSID) && !defined(ENABLE_USB_INTERFACE) + // headless WiFi build: USB serial isn't a companion transport, so use it for config + checkCLIRescueCmd(); +#endif } // is there are pending dirty contacts write needed? diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index f6f9b887..c725c317 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -44,6 +44,10 @@ public: char default_scope_name[31]; uint8_t default_scope_key[16]; int8_t tz_offset = 0; +#ifdef WIFI_SSID + char wifi_ssid[33] = {0}; // if empty, the compile-time WIFI_SSID is used + char wifi_pwd[64] = {0}; +#endif private: class RadioPrefs : public CommonRadioPrefs { @@ -160,6 +164,20 @@ private: DynamicConfigSerializer custom; +#ifdef WIFI_SSID + class WiFiPrefs : public ConfigSerializer { + NodePrefs* _parent; + protected: + void structure() override { + def("ssid", _parent->wifi_ssid, sizeof(_parent->wifi_ssid)); + def("pwd", _parent->wifi_pwd, sizeof(_parent->wifi_pwd)); + } + public: + WiFiPrefs(NodePrefs* parent) : _parent(parent) { } + }; + WiFiPrefs wifi; +#endif + protected: void structure() override { def("name", node_name, sizeof(node_name)); @@ -172,9 +190,16 @@ protected: def("repeat", repeat); def("comp", companion); def("custom", custom); +#ifdef WIFI_SSID + def("wifi", wifi); +#endif } public: - NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) { + NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) +#ifdef WIFI_SSID + , wifi(this) +#endif + { node_name[0] = 0; default_scope_name[0] = 0; memset(default_scope_key, 0, sizeof(default_scope_key)); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 7c8c12b9..64aa3d26 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -110,6 +110,8 @@ void halt() { #ifdef WIFI_SSID bool wifi_needs_reconnect = false; unsigned long last_wifi_reconnect_attempt = 0; + const char* wifi_ssid = WIFI_SSID; // replaced by stored prefs, if set + const char* wifi_pwd = WIFI_PWD; #endif void setup() { @@ -206,7 +208,14 @@ void setup() { }); #endif - WiFi.begin(WIFI_SSID, WIFI_PWD); + // stored credentials win over the build-time ones ('set wifi.ssid ' over USB serial) + if (the_mesh.getNodePrefs()->wifi_ssid[0]) { + wifi_ssid = the_mesh.getNodePrefs()->wifi_ssid; + wifi_pwd = the_mesh.getNodePrefs()->wifi_pwd; + } + WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); + + WiFi.begin(wifi_ssid, wifi_pwd); wifi_interface.begin(TCP_PORT); interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); #endif @@ -273,7 +282,7 @@ void loop() { if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) { WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect..."); #if defined(RP2040_PLATFORM) - WiFi.begin(WIFI_SSID, WIFI_PWD); // no reconnect() on this platform + WiFi.begin(wifi_ssid, wifi_pwd); // no reconnect() on this platform #else WiFi.disconnect(); WiFi.reconnect(); From 58181bb8a285d7c1658dc0c4908232440ebdf829 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Fri, 4 Sep 2026 20:33:40 -0500 Subject: [PATCH 21/43] Fix blocking WiFi connect on RP2040, log link state arduino-pico's WiFi.begin() blocks for up to 2x its 15s timeout, which stalled the mesh loop on every reconnect attempt; use beginNoBlock(). Log the IP when the link comes up, and the status code when retrying. --- examples/companion_radio/main.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 64aa3d26..f0c12908 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -112,6 +112,7 @@ void halt() { unsigned long last_wifi_reconnect_attempt = 0; const char* wifi_ssid = WIFI_SSID; // replaced by stored prefs, if set const char* wifi_pwd = WIFI_PWD; + bool wifi_was_connected = false; #endif void setup() { @@ -215,7 +216,11 @@ void setup() { } WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); +#if defined(RP2040_PLATFORM) + WiFi.beginNoBlock(wifi_ssid, wifi_pwd); // begin() blocks for up to 2x its 15s timeout +#else WiFi.begin(wifi_ssid, wifi_pwd); +#endif wifi_interface.begin(TCP_PORT); interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); #endif @@ -276,13 +281,21 @@ void loop() { // RP2040 has no WiFi event callbacks, so poll the link state instead #if defined(RP2040_PLATFORM) wifi_needs_reconnect = (WiFi.status() != WL_CONNECTED); + if (wifi_was_connected == wifi_needs_reconnect) { // link state changed + wifi_was_connected = !wifi_needs_reconnect; + if (wifi_was_connected) { + WIFI_DEBUG_PRINTLN("connected, listening on %s:%d", WiFi.localIP().toString().c_str(), TCP_PORT); + } else { + WIFI_DEBUG_PRINTLN("link lost"); + } + } #endif // Safely attempt to reconnect every 10 seconds if flagged if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) { - WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect..."); + WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect to %s (status %d)...", wifi_ssid, WiFi.status()); #if defined(RP2040_PLATFORM) - WiFi.begin(wifi_ssid, wifi_pwd); // no reconnect() on this platform + WiFi.beginNoBlock(wifi_ssid, wifi_pwd); // no reconnect() on this platform #else WiFi.disconnect(); WiFi.reconnect(); From a4ac85a0d6b3d392f303e054ab03516933dbec52 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Fri, 4 Sep 2026 20:57:01 -0500 Subject: [PATCH 22/43] Address review findings on WiFi companion support - scope the serial config CLI to RP2040; it was exposing the rescue CLI (cat/rm/erase) on every ESP32 WiFi build, which gates it behind a physical long-press - bound and space out RP2040 rejoins: the core's join busy-waits, so cap it at 5s and retry every 30s instead of every 10s - stamp the reconnect timer in setup(), so the first loop() doesn't tear down an association that is still finishing DHCP - treat stored credentials as a pair, and pass NULL (not "") for an open network - teach build_as_lib.py where SerialWifiInterface moved --- build_as_lib.py | 2 ++ examples/companion_radio/MyMesh.cpp | 5 +++-- examples/companion_radio/main.cpp | 24 ++++++++++++++++++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/build_as_lib.py b/build_as_lib.py index d8e95378..fbc7c15a 100644 --- a/build_as_lib.py +++ b/build_as_lib.py @@ -20,10 +20,12 @@ for item in menv.get("CPPDEFINES", []): src_filter.append("+") elif item == "ESP32": src_filter.append("+") + src_filter.append("+") elif item == "NRF52_PLATFORM": src_filter.append("+") elif item == "RP2040_PLATFORM": src_filter.append("+") + src_filter.append("+") # DISPLAY HANDLING elif isinstance(item, tuple) and item[0] == "DISPLAY_CLASS": diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8550890e..a8e305a6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2416,8 +2416,9 @@ void MyMesh::loop() { checkCLIRescueCmd(); } else { checkSerialInterface(); -#if defined(WIFI_SSID) && !defined(ENABLE_USB_INTERFACE) - // headless WiFi build: USB serial isn't a companion transport, so use it for config +#if defined(WIFI_SSID) && defined(RP2040_PLATFORM) && !defined(ENABLE_USB_INTERFACE) + // RP2040 WiFi builds are headless and have no way into the rescue CLI (that needs a + // display + long-press), so serve config commands on the otherwise unused USB serial checkCLIRescueCmd(); #endif } diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f0c12908..ff0794ab 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -36,6 +36,12 @@ MultiSerialInterface interface_manager; #ifndef TCP_PORT #define TCP_PORT 5000 #endif + #ifndef WIFI_RETRY_INTERVAL + #define WIFI_RETRY_INTERVAL 30000 // millis between reconnect attempts + #endif + #ifndef WIFI_RETRY_TIMEOUT + #define WIFI_RETRY_TIMEOUT 5000 // RP2040: cap on how long one join may block loop() + #endif #if defined(ESP32) || defined(RP2040_PLATFORM) #include SerialWifiInterface wifi_interface; @@ -209,15 +215,23 @@ void setup() { }); #endif - // stored credentials win over the build-time ones ('set wifi.ssid ' over USB serial) + // stored credentials win over the build-time ones ('set wifi.ssid ' over USB serial). + // they are taken as a pair, so 'set wifi.ssid' alone gives an open-network join, not a + // silent fallback to the build-time password of a different network. if (the_mesh.getNodePrefs()->wifi_ssid[0]) { wifi_ssid = the_mesh.getNodePrefs()->wifi_ssid; wifi_pwd = the_mesh.getNodePrefs()->wifi_pwd; } + if (wifi_pwd[0] == 0) wifi_pwd = NULL; // NULL (not "") selects an open network WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); #if defined(RP2040_PLATFORM) - WiFi.beginNoBlock(wifi_ssid, wifi_pwd); // begin() blocks for up to 2x its 15s timeout + // ponytail: the join itself blocks inside the core (CYW43::begin busy-waits for the + // association), so every attempt stalls the mesh loop. beginNoBlock() only skips the + // extra DHCP wait. Give the first connect a full window, then bound the retries below. + // Upgrade path if the stall ever matters: run WiFi on core1. + WiFi.beginNoBlock(wifi_ssid, wifi_pwd); + last_wifi_reconnect_attempt = millis(); // let DHCP finish before the poll can retry #else WiFi.begin(wifi_ssid, wifi_pwd); #endif @@ -291,10 +305,12 @@ void loop() { } #endif - // Safely attempt to reconnect every 10 seconds if flagged - if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) { + // Safely attempt to reconnect if flagged. On RP2040 each attempt blocks the mesh loop + // for up to WIFI_RETRY_TIMEOUT, so retry less often and cap how long a join may stall. + if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > WIFI_RETRY_INTERVAL)) { WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect to %s (status %d)...", wifi_ssid, WiFi.status()); #if defined(RP2040_PLATFORM) + WiFi.setTimeout(WIFI_RETRY_TIMEOUT); WiFi.beginNoBlock(wifi_ssid, wifi_pwd); // no reconnect() on this platform #else WiFi.disconnect(); From d33fb4e9a7f61b0d25d30a2c78feed05dee42496 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sat, 5 Sep 2026 21:15:12 -0500 Subject: [PATCH 23/43] Fix config parser aborting on an empty object 'custom:{}' (a DynamicConfigSerializer with nothing set) hits EXPECT_KEY with a '}' and returns TOK_ERROR, so loadSerial stops there and silently drops every property after it. Nothing follows 'custom' in NodePrefs today, so it goes unnoticed until you add one. Also include stdlib.h, which Arduino.h was providing on-device but not in the native test build. --- src/helpers/ConfigSerializer.cpp | 2 + .../test_config_serializer.cpp | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index adff147f..36aff5cc 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -1,4 +1,5 @@ #include "ConfigSerializer.h" +#include // atoi/atol/atof (Arduino.h pulls this in on-device, native builds do not) bool ConfigSerializer::saveSerial(Stream& s) { Context context(&s, OP::WRITE); @@ -62,6 +63,7 @@ int ConfigSerializer::Context::readNext() { case EXPECT_COMMA_OR_KEY: if (c == ',') { rd_mode = EXPECT_KEY; return TOK_WHITESPACE; } case EXPECT_KEY: + if (rd_len == 0 && c == '}') { rd_mode = EXPECT_COMMA_OR_KEY_OR_CLOSE; return TOK_END_OBJ; } // empty object, eg. 'custom:{}' if (rd_len > 0 && c == ':') { rd_buf[rd_len] = 0; rd_len = 0; rd_mode = EXPECT_VAL_OR_OBJ; return TOK_KEY; } if (rd_len == 0 && is_whitespace(c)) return TOK_WHITESPACE; if (rd_len < CONFIG_MAX_KEYLEN-1 && is_key_char(c)) { rd_buf[rd_len++] = c; return TOK_WHITESPACE; } diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index dec55483..80d5e780 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -185,6 +185,47 @@ TEST(ConfigSerializer, LoadSerial_IgnoreUnknowns) { EXPECT_TRUE(match); } +class TestNested : public ConfigSerializer { + class Inner : public ConfigSerializer { + protected: + void structure() override { } // no properties, so it writes as '{}' + }; + Inner inner; + protected: + void structure() override { + def("age", age); + def("inner", inner); + def("name", name, sizeof(name)); // comes *after* the empty sub-object + } + public: + int32_t age; + char name[16]; +}; + +TEST(ConfigSerializer, LoadSerial_EmptyObject) { + MockInputStream s("{age:" TEST_INT_S ",inner:{},name:\"Scott\"}"); + TestNested data; + data.name[0] = 0; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + + EXPECT_EQ(TEST_INT, data.age); + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); // properties after an empty object must still load +} + +TEST(ConfigSerializer, LoadSerial_EmptyObjectWithWhitespace) { + MockInputStream s("{age:" TEST_INT_S ",inner:{ },name:\"Scott\"}"); + TestNested data; + data.name[0] = 0; + + bool success = data.loadSerial(s); + EXPECT_TRUE(success); + bool match = strcmp("Scott", data.name) == 0; + EXPECT_TRUE(match); +} + TEST(DynamicConfigSerializer, GetSet_Basic) { DynamicConfigSerializer data; From 5d82ed352c2481f5ec36e4b8e8ab57c963cb6c20 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Sun, 6 Sep 2026 15:14:35 +0200 Subject: [PATCH 24/43] Companion CLI: fixed txdelay, direct.txdelay, agc.reset.interval, int.thresh --- examples/companion_radio/MyMesh.cpp | 10 +++++++--- examples/companion_radio/MyMesh.h | 3 +++ examples/companion_radio/NodePrefs.h | 28 ++++++++++++++++------------ 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f5a4a5a9..53e6f4d5 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -261,7 +261,7 @@ float MyMesh::getAirtimeBudgetFactor() const { } int MyMesh::getInterferenceThreshold() const { - return 0; // disabled for now, until currentRSSI() problem is resolved + return _prefs.interference_threshold; } bool MyMesh::getCADEnabled() const { return _prefs.cad_enabled; @@ -273,11 +273,11 @@ int MyMesh::calcRxDelay(float score, uint32_t air_time) const { } uint32_t MyMesh::getRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.5f); + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } uint32_t MyMesh::getDirectRetransmitDelay(const mesh::Packet *packet) { - uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * 0.2f); + uint32_t t = (_radio->getEstAirtimeFor(packet->getPathByteLen() + packet->payload_len + 2) * _prefs.direct_tx_delay_factor); return getRNG()->nextInt(0, 5*t + 1); } @@ -890,6 +890,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe // defaults _prefs.airtime_factor = 1.0; + _prefs.tx_delay_factor = 0.5f; + _prefs.direct_tx_delay_factor = 0.2f; strcpy(_prefs.node_name, "NONAME"); _prefs.freq = LORA_FREQ; _prefs.sf = LORA_SF; @@ -952,6 +954,8 @@ void MyMesh::begin(bool has_display) { // sanitise bad pref values _prefs.rx_delay_base = constrain(_prefs.rx_delay_base, 0, 20.0f); + _prefs.tx_delay_factor = constrain(_prefs.tx_delay_factor, 0, 2.0f); + _prefs.direct_tx_delay_factor = constrain(_prefs.direct_tx_delay_factor, 0, 2.0f); _prefs.airtime_factor = constrain(_prefs.airtime_factor, 0, 9.0f); _prefs.freq = constrain(_prefs.freq, 150.0f, 2500.0f); _prefs.bw = constrain(_prefs.bw, 7.8f, 500.0f); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 780de35d..4aab11cd 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -106,6 +106,9 @@ protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; bool getCADEnabled() const override; + int getAGCResetInterval() const override { + return ((int)_prefs.agc_reset_interval) * 4000; // milliseconds + } int calcRxDelay(float score, uint32_t air_time) const override; uint32_t getRetransmitDelay(const mesh::Packet *packet) override; uint32_t getDirectRetransmitDelay(const mesh::Packet *packet) override; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index c8a9ab83..4581185e 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -27,6 +27,8 @@ public: uint8_t telemetry_mode_loc = 0; uint8_t telemetry_mode_env = 0; float rx_delay_base = 0; + float tx_delay_factor = 0; + float direct_tx_delay_factor = 0; uint32_t ble_pin = 0; uint8_t advert_loc_policy = 0; uint8_t buzzer_quiet = 0; @@ -41,6 +43,8 @@ public: uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t autoadd_max_hops = 0; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) uint8_t cad_enabled = 0; + uint8_t interference_threshold = 0; + uint8_t agc_reset_interval = 0; // secs / 4 char default_scope_name[31]; uint8_t default_scope_key[16]; @@ -54,16 +58,16 @@ private: def("sf", _parent->sf); def("cr", _parent->cr); def("cad", _parent->cad_enabled); - //def("int_thr", _parent->interference_threshold); + def("int_thr", _parent->interference_threshold); def("rxgain", _parent->rx_boosted_gain); def("fem_rxgain", _parent->radio_fem_rxgain); // fem_rxgain WAS mapped to wrong JSON property previously def("fem_txgain", _parent->radio_fem_txgain); def("tx", _parent->tx_power_dbm); def("af", _parent->airtime_factor); def("rxdelay", _parent->rx_delay_base); - //def("f_txdelay", _parent->tx_delay_factor); currently hard-coded - //def("d_txdelay", _parent->direct_tx_delay_factor); currently hard-coded - //def("agc_int", _parent->agc_reset_interval); + def("f_txdelay", _parent->tx_delay_factor); + def("d_txdelay", _parent->direct_tx_delay_factor); + def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); } @@ -83,24 +87,24 @@ private: void setAirtimeFactor(float af) override { _parent->airtime_factor = af; markDirty(); } bool isCadEnabled() const override { return _parent->cad_enabled; } void setCadEnabled(bool en) override { _parent->cad_enabled = en; markDirty(); } - uint8_t getIntThresh() const override { return 0; } - void setIntThresh(uint8_t t) override { /* no-op */ } + uint8_t getIntThresh() const override { return _parent->interference_threshold; } + void setIntThresh(uint8_t t) override { _parent->interference_threshold = t; markDirty(); } uint8_t getRxGain() const override { return _parent->rx_boosted_gain; } void setRxGain(uint8_t g) override { _parent->rx_boosted_gain = g; markDirty(); } uint8_t getTxPower() const override { return _parent->tx_power_dbm; } void setTxPower(uint8_t dbm) override { _parent->tx_power_dbm = dbm; markDirty(); } float getRxDelay() const override { return _parent->rx_delay_base; } void setRxDelay(float d) override { _parent->rx_delay_base = d; markDirty(); } - uint8_t getAgcResetInt() const override { return 0; } - void setAgcResetInt(uint8_t secs) override { /* no-op */ } + uint8_t getAgcResetInt() const override { return _parent->agc_reset_interval * 4; } + void setAgcResetInt(uint8_t secs) override { _parent->agc_reset_interval = secs / 4; markDirty(); } uint8_t getHashMode() const override { return _parent->path_hash_mode; } void setHashMode(uint8_t m) override { _parent->path_hash_mode = m; markDirty(); } uint8_t getMultiAcks() const override { return _parent->multi_acks; } void setMultiAcks(uint8_t m) override { _parent->multi_acks = m; markDirty(); } - float getFloodTxDelay() const override { return 0.5f; } // currently hard-coded - void setFloodTxDelay(float d) override { /* no-op */ } - float getDirectTxDelay() const override { return 0.2f; } // currently hard-coded - void setDirectTxDelay(float d) override { /* no-op */ } + float getFloodTxDelay() const override { return _parent->tx_delay_factor; } + void setFloodTxDelay(float d) override { _parent->tx_delay_factor = d; markDirty(); } + float getDirectTxDelay() const override { return _parent->direct_tx_delay_factor; } + void setDirectTxDelay(float d) override { _parent->direct_tx_delay_factor = d; markDirty(); } uint8_t getFEMRxGain() const override { return _parent->radio_fem_rxgain; } void setFEMRxGain(uint8_t g) override { _parent->radio_fem_rxgain = g; markDirty(); } uint8_t getFEMTxGain() const override { return _parent->radio_fem_txgain; } From 88cc014f55d07470ec906dcdb5163be9695f06d2 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 20:15:45 -0500 Subject: [PATCH 25/43] Add BLE companion support for Pico W --- examples/companion_radio/main.cpp | 4 + src/helpers/rp2040/SerialBLEInterface.cpp | 198 ++++++++++++++++++++++ src/helpers/rp2040/SerialBLEInterface.h | 75 ++++++++ variants/rpi_picow/platformio.ini | 50 ++++-- 4 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 src/helpers/rp2040/SerialBLEInterface.cpp create mode 100644 src/helpers/rp2040/SerialBLEInterface.h diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index ff0794ab..85923a15 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -26,6 +26,10 @@ MultiSerialInterface interface_manager; // include nrf52 bluetooth interface #include SerialBLEInterface bluetooth_interface; + #elif defined(RP2040_PLATFORM) + // include rp2040 (Pico W / CYW43) bluetooth interface + #include + SerialBLEInterface bluetooth_interface; #else #error "SerialBLEInterface is not defined for this platform" #endif diff --git a/src/helpers/rp2040/SerialBLEInterface.cpp b/src/helpers/rp2040/SerialBLEInterface.cpp new file mode 100644 index 00000000..f0321003 --- /dev/null +++ b/src/helpers/rp2040/SerialBLEInterface.cpp @@ -0,0 +1,198 @@ +#include "SerialBLEInterface.h" +#include +#include +#include + +// Nordic UART 6E4000xx-B5A3-F393-E0A9-E50E24DCCA9E, as raw bytes: the core lib's string +// parser uses sscanf("%llx"), which newlib-nano on the RP2040 doesn't support (yields all zeros) +#define NUS_UUID(n) { 0x6E, 0x40, 0x00, n, 0xB5, 0xA3, 0xF3, 0x93, 0xE0, 0xA9, 0xE5, 0x0E, 0x24, 0xDC, 0xCA, 0x9E } +static const uint8_t SERVICE_UUID[16] = NUS_UUID(0x01); +static const uint8_t CHARACTERISTIC_UUID_RX[16] = NUS_UUID(0x02); +static const uint8_t CHARACTERISTIC_UUID_TX[16] = NUS_UUID(0x03); + +// The BLE core lib's own setValue()/notify path reallocs the value buffer on every call, +// so a second frame queued before the radio drained the first would corrupt it. We keep our +// own frame queue and drive att_server_notify() from the can-send-now callback instead. + +SerialBLEInterface::SerialBLEInterface() + : BLEService(BLEUUID(SERVICE_UUID)), + _rx(BLEUUID(CHARACTERISTIC_UUID_RX), BLEWrite, nullptr, ATT_SECURITY_AUTHENTICATED, ATT_SECURITY_AUTHENTICATED), + _tx(BLEUUID(CHARACTERISTIC_UUID_TX), BLERead | BLENotify, nullptr, ATT_SECURITY_AUTHENTICATED, ATT_SECURITY_AUTHENTICATED) +{ + _isEnabled = false; + _tx_pending = false; + send_queue_len = 0; + recv_queue_len = 0; + memset(&_can_send, 0, sizeof(_can_send)); + _rx.setCallbacks(this); + addCharacteristic(&_rx); + addCharacteristic(&_tx); +} + +void SerialBLEInterface::begin(const char* prefix, char* name, uint32_t pin_code) { + // JustWorks here only makes the server request pairing on connect; caps are overridden below + BLE.setSecurity(BLESecurityJustWorks); + // adv data carries the 128-bit service UUID, leaving room for only 8 name chars; + // the full name goes in the scan response + BLE.begin(prefix); + + if (strcmp(name, "@@MAC") == 0) { + bd_addr_t a; + gap_local_bd_addr(a); + sprintf(name, "%02X%02X%02X%02X%02X%02X", a[0], a[1], a[2], a[3], a[4], a[5]); // modify (IN-OUT param) + } + char dev_name[32+16]; + snprintf(dev_name, sizeof(dev_name), "%s%s", prefix, name); + + BLE.server()->setName(dev_name); // GAP device name characteristic + BLE.server()->addService(this); + BLE.server()->setCallbacks(this); + + // static passkey pairing with MITM protection, matching the esp32/nrf52 interfaces + sm_set_io_capabilities(IO_CAPABILITY_DISPLAY_ONLY); + sm_set_authentication_requirements(SM_AUTHREQ_MITM_PROTECTION | SM_AUTHREQ_BONDING); + sm_use_fixed_passkey_in_display_role(pin_code); + + size_t n = strlen(dev_name); + if (n > sizeof(_scan_rsp) - 2) n = sizeof(_scan_rsp) - 2; + _scan_rsp[0] = n + 1; + _scan_rsp[1] = BLUETOOTH_DATA_TYPE_COMPLETE_LOCAL_NAME; + memcpy(&_scan_rsp[2], dev_name, n); + gap_scan_response_set_data(n + 2, _scan_rsp); + + BLE_DEBUG_PRINTLN("begin: name=%s", dev_name); +} + +void SerialBLEInterface::clearBuffers() { + send_queue_len = 0; + recv_queue_len = 0; + _tx_pending = false; // a stale registration just fires into an empty queue; BTstack ignores double-adds +} + +void SerialBLEInterface::onConnect(BLEServer* s) { + BLE_DEBUG_PRINTLN("connected handle=0x%04X", _tx.conHandle()); + clearBuffers(); +} + +void SerialBLEInterface::onDisconnect(BLEServer* s) { + BLE_DEBUG_PRINTLN("disconnected"); + clearBuffers(); // BTstack re-enables advertising on its own +} + +void SerialBLEInterface::onWrite(BLECharacteristic* c) { + if (c != &_rx) return; + size_t len = _rx.valueLen(); + if (len == 0 || len > MAX_FRAME_SIZE) { + BLE_DEBUG_PRINTLN("onWrite: bad frame len=%u", (unsigned)len); + return; + } + if (recv_queue_len >= FRAME_QUEUE_SIZE) { + BLE_DEBUG_PRINTLN("onWrite: recv queue full, dropping frame"); + return; + } + recv_queue[recv_queue_len].len = len; + memcpy(recv_queue[recv_queue_len].buf, _rx.valueData(), len); + recv_queue_len++; +} + +// caller holds the BT lock (or is in the BT context) +void SerialBLEInterface::kickSend() { + if (_tx_pending) return; + _tx_pending = true; + _can_send.callback = onCanSend; + _can_send.context = this; + if (att_server_register_can_send_now_callback(&_can_send, _tx.conHandle()) != 0) { + _tx_pending = false; + } +} + +// BT context: one notification per can-send-now, re-arm while frames remain +void SerialBLEInterface::sendNext() { + _tx_pending = false; + if (send_queue_len == 0) return; + if (!isConnected()) { + BLE_DEBUG_PRINTLN("sendNext: not connected, clearing send queue"); + send_queue_len = 0; + return; + } + uint16_t h = _tx.conHandle(); + Frame& f = send_queue[0]; + uint16_t mtu = att_server_get_mtu(h); + if (f.len + 3 > mtu) { + // att would silently truncate; drop instead (client must negotiate MTU >= MAX_FRAME_SIZE+3) + BLE_DEBUG_PRINTLN("sendNext: frame len=%u exceeds mtu=%u, dropping", f.len, mtu); + } else { + uint8_t err = att_server_notify(h, _tx.valueHandle(), f.buf, f.len); + if (err == BTSTACK_ACL_BUFFERS_FULL) { + kickSend(); + return; + } + if (err) { + BLE_DEBUG_PRINTLN("sendNext: notify failed err=%u, dropping", err); + } else { + BLE_DEBUG_PRINTLN("writeBytes: sz=%u, hdr=%u", f.len, f.buf[0]); + } + } + send_queue_len--; + memmove(&send_queue[0], &send_queue[1], send_queue_len * sizeof(Frame)); + if (send_queue_len > 0) kickSend(); +} + +void SerialBLEInterface::enable() { + if (_isEnabled) return; + _isEnabled = true; + clearBuffers(); + BLE.startAdvertising(true); +} + +void SerialBLEInterface::disconnect() { + uint16_t h = _tx.conHandle(); + if (h) gap_disconnect(h); +} + +void SerialBLEInterface::disable() { + _isEnabled = false; + BLE_DEBUG_PRINTLN("disable"); + disconnect(); + BLE.stopAdvertising(); +} + +bool SerialBLEInterface::isConnected() const { + // notifications can only be enabled once the link is authenticated (CCCD inherits the perms) + return _isEnabled && _tx.conHandle() != 0 && _tx.notifyEnabled(); +} + +bool SerialBLEInterface::isWriteBusy() const { + return send_queue_len >= (FRAME_QUEUE_SIZE * 2 / 3); +} + +size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) { + if (len == 0 || len > MAX_FRAME_SIZE) { + BLE_DEBUG_PRINTLN("writeFrame(), frame too big, len=%u", (unsigned)len); + return 0; + } + if (!isConnected()) return 0; + + BluetoothLock lock; + if (send_queue_len >= FRAME_QUEUE_SIZE) { + BLE_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); + return 0; + } + send_queue[send_queue_len].len = len; + memcpy(send_queue[send_queue_len].buf, src, len); + send_queue_len++; + kickSend(); + return len; +} + +size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { + BluetoothLock lock; + if (recv_queue_len == 0) return 0; + + size_t len = recv_queue[0].len; + memcpy(dest, recv_queue[0].buf, len); + recv_queue_len--; + memmove(&recv_queue[0], &recv_queue[1], recv_queue_len * sizeof(Frame)); + BLE_DEBUG_PRINTLN("readBytes: sz=%u, hdr=%u", (unsigned)len, dest[0]); + return len; +} diff --git a/src/helpers/rp2040/SerialBLEInterface.h b/src/helpers/rp2040/SerialBLEInterface.h new file mode 100644 index 00000000..2e085a6c --- /dev/null +++ b/src/helpers/rp2040/SerialBLEInterface.h @@ -0,0 +1,75 @@ +#pragma once + +#include "../BaseSerialInterface.h" +#include +#include + +// Nordic UART service over the arduino-pico BLE library (BTstack on the CYW43). +// Build with -D PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH so the core links liblwip-bt. +class SerialBLEInterface : public BaseSerialInterface, BLEService, BLEServerCallbacks, BLECharacteristicCallbacks { + // subclass only to reach the protected connection/notify state + struct Characteristic : public BLECharacteristic { + using BLECharacteristic::BLECharacteristic; + uint16_t valueHandle() const { return _valueHandle; } + uint16_t conHandle() const { return con_handle; } + bool notifyEnabled() const { return _notificationEnabled; } + }; + + struct Frame { + uint8_t len; + uint8_t buf[MAX_FRAME_SIZE]; + }; + + #define FRAME_QUEUE_SIZE 8 + + Characteristic _rx; + Characteristic _tx; + bool _isEnabled; + bool _tx_pending; // a can-send-now callback is registered + btstack_context_callback_registration_t _can_send; + uint8_t _scan_rsp[31]; // complete local name; BTstack keeps the pointer + + uint8_t send_queue_len; + Frame send_queue[FRAME_QUEUE_SIZE]; + uint8_t recv_queue_len; + Frame recv_queue[FRAME_QUEUE_SIZE]; + + void clearBuffers(); + void kickSend(); + void sendNext(); + static void onCanSend(void* ctx) { ((SerialBLEInterface*)ctx)->sendNext(); } + + // BLE library callbacks (run in the BT context) + void onWrite(BLECharacteristic* c) override; + void onConnect(BLEServer* s) override; + void onDisconnect(BLEServer* s) override; + +public: + SerialBLEInterface(); + + /** + * init the BLE interface. + * @param prefix a prefix for the device name + * @param name IN/OUT - a name for the device (combined with prefix). If "@@MAC", is modified and returned + * @param pin_code the BLE security pin + */ + void begin(const char* prefix, char* name, uint32_t pin_code); + + void disconnect(); + void enable() override; + void disable() override; + bool isEnabled() const override { return _isEnabled; } + bool isConnected() const override; + bool isWriteBusy() const override; + size_t writeFrame(const uint8_t src[], size_t len) override; + size_t checkRecvFrame(uint8_t dest[]) override; +}; + +#if BLE_DEBUG_LOGGING && ARDUINO + #include + #define BLE_DEBUG_PRINT(F, ...) Serial.printf("BLE: " F, ##__VA_ARGS__) + #define BLE_DEBUG_PRINTLN(F, ...) Serial.printf("BLE: " F "\n", ##__VA_ARGS__) +#else + #define BLE_DEBUG_PRINT(...) {} + #define BLE_DEBUG_PRINTLN(...) {} +#endif diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index 32944a9a..c4180650 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -67,19 +67,43 @@ lib_deps = ${rpi_picow.lib_deps} densaugeo/base64 @ ~1.4.0 lib_ignore = BLE -; [env:PicoW_companion_radio_ble] -; extends = rpi_picow -; build_flags = ${rpi_picow.build_flags} -; -D MAX_CONTACTS=100 -; -D MAX_GROUP_CHANNELS=8 -; -D BLE_PIN_CODE=123456 -; -D BLE_DEBUG_LOGGING=1 -; ; -D MESH_PACKET_LOGGING=1 -; ; -D MESH_DEBUG=1 -; build_src_filter = ${rpi_picow.build_src_filter} -; +<../examples/companion_radio/*.cpp> -; lib_deps = ${rpi_picow.lib_deps} -; densaugeo/base64 @ ~1.4.0 +[env:PicoW_companion_radio_ble] +extends = rpi_picow +build_flags = ${rpi_picow.build_flags} + -D MAX_CONTACTS=100 + -D MAX_GROUP_CHANNELS=8 + -D PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rpi_picow.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +lib_deps = ${rpi_picow.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; USB + WiFi + BLE together; the interface manager fans frames out to all of them +[env:PicoW_companion_radio_all] +extends = rpi_picow +build_flags = ${rpi_picow.build_flags} + -D MAX_CONTACTS=100 + -D MAX_GROUP_CHANNELS=8 + -D ENABLE_USB_INTERFACE + -D PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH + -D BLE_PIN_CODE=123456 + -D BLE_DEBUG_LOGGING=1 + -D WIFI_DEBUG_LOGGING=1 + -D WIFI_SSID='"myssid"' + -D WIFI_PWD='"mypwd"' +; -D MESH_PACKET_LOGGING=1 +; -D MESH_DEBUG=1 +build_src_filter = ${rpi_picow.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +lib_deps = ${rpi_picow.lib_deps} + densaugeo/base64 @ ~1.4.0 [env:PicoW_companion_radio_wifi] extends = rpi_picow From b9fa450f52512486ce4b19bd09b71c383cfb2e29 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 20:43:37 -0500 Subject: [PATCH 26/43] Fix review findings on Pico W WiFi/BLE --- examples/companion_radio/MyMesh.cpp | 3 ++- examples/companion_radio/main.cpp | 21 +++++++++++++-------- src/helpers/rp2040/SerialBLEInterface.cpp | 3 +++ variants/rpi_picow/platformio.ini | 4 ++-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a8e305a6..4eea0b2f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2158,7 +2158,8 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } #ifdef WIFI_SSID - // local console only: these are credentials, and remote admin has no business with them + // not accepted from the remote mesh CLI (timestamp != 0): these are credentials. The app + // over USB/BLE/WiFi and the serial console (timestamp 0) may set them. if (sender_timestamp == 0) { if (memcmp(command, "set wifi.ssid ", 14) == 0) { StrHelper::strncpy(_prefs.wifi_ssid, &command[14], sizeof(_prefs.wifi_ssid)); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 85923a15..6b6d7cf7 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -41,7 +41,11 @@ MultiSerialInterface interface_manager; #define TCP_PORT 5000 #endif #ifndef WIFI_RETRY_INTERVAL - #define WIFI_RETRY_INTERVAL 30000 // millis between reconnect attempts + #if defined(RP2040_PLATFORM) + #define WIFI_RETRY_INTERVAL 30000 // each attempt blocks loop(), so retry less often + #else + #define WIFI_RETRY_INTERVAL 10000 // millis between reconnect attempts + #endif #endif #ifndef WIFI_RETRY_TIMEOUT #define WIFI_RETRY_TIMEOUT 5000 // RP2040: cap on how long one join may block loop() @@ -120,8 +124,8 @@ void halt() { #ifdef WIFI_SSID bool wifi_needs_reconnect = false; unsigned long last_wifi_reconnect_attempt = 0; - const char* wifi_ssid = WIFI_SSID; // replaced by stored prefs, if set - const char* wifi_pwd = WIFI_PWD; + char wifi_ssid[33] = WIFI_SSID; // replaced by stored prefs at boot, if set + char wifi_pwd[64] = WIFI_PWD; bool wifi_was_connected = false; #endif @@ -220,13 +224,14 @@ void setup() { #endif // stored credentials win over the build-time ones ('set wifi.ssid ' over USB serial). - // they are taken as a pair, so 'set wifi.ssid' alone gives an open-network join, not a - // silent fallback to the build-time password of a different network. + // they are taken as a pair, so 'set wifi.ssid' alone gives an empty password, not a + // silent fallback to the build-time password of a different network. Copied out of prefs + // so 'set wifi.*' edits only take effect on reboot, as their replies promise. + // (No NULL-for-open-network: the RP2040 core does strlen() on the password unguarded.) if (the_mesh.getNodePrefs()->wifi_ssid[0]) { - wifi_ssid = the_mesh.getNodePrefs()->wifi_ssid; - wifi_pwd = the_mesh.getNodePrefs()->wifi_pwd; + strcpy(wifi_ssid, the_mesh.getNodePrefs()->wifi_ssid); + strcpy(wifi_pwd, the_mesh.getNodePrefs()->wifi_pwd); } - if (wifi_pwd[0] == 0) wifi_pwd = NULL; // NULL (not "") selects an open network WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); #if defined(RP2040_PLATFORM) diff --git a/src/helpers/rp2040/SerialBLEInterface.cpp b/src/helpers/rp2040/SerialBLEInterface.cpp index f0321003..a1fa487e 100644 --- a/src/helpers/rp2040/SerialBLEInterface.cpp +++ b/src/helpers/rp2040/SerialBLEInterface.cpp @@ -1,3 +1,5 @@ +// only built when the env enables the core BLE stack (build_as_lib.py globs this dir) +#ifdef PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH #include "SerialBLEInterface.h" #include #include @@ -196,3 +198,4 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { BLE_DEBUG_PRINTLN("readBytes: sz=%u, hdr=%u", (unsigned)len, dest[0]); return len; } +#endif diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index c4180650..f99b1341 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -92,8 +92,8 @@ build_flags = ${rpi_picow.build_flags} -D ENABLE_USB_INTERFACE -D PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 - -D WIFI_DEBUG_LOGGING=1 +; NOTE: DO NOT ENABLE --> -D BLE_DEBUG_LOGGING=1 (shares Serial with the USB interface) +; NOTE: DO NOT ENABLE --> -D WIFI_DEBUG_LOGGING=1 -D WIFI_SSID='"myssid"' -D WIFI_PWD='"mypwd"' ; -D MESH_PACKET_LOGGING=1 From b11a779843fc949ec7589de4fc7fba2f84750d25 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 21:28:04 -0500 Subject: [PATCH 27/43] Address review: per-variant wifi src, real SSID reply, rename PicoW env --- examples/companion_radio/MyMesh.cpp | 2 +- platformio.ini | 1 - variants/heltec_rc32/platformio.ini | 2 ++ variants/heltec_tracker_v2/platformio.ini | 1 + variants/heltec_v2/platformio.ini | 1 + variants/heltec_v3/platformio.ini | 2 ++ variants/heltec_v4/platformio.ini | 2 ++ variants/heltec_v4_r8/platformio.ini | 2 ++ variants/lilygo_tbeam_1w/platformio.ini | 1 + variants/lilygo_tbeam_supreme_SX1262/platformio.ini | 1 + variants/lilygo_tlora_v2_1/platformio.ini | 1 + variants/meshnology_w12/platformio.ini | 1 + variants/nibble_screen_connect/platformio.ini | 1 + variants/nibble_zero_connect/platformio.ini | 1 + variants/rak3112/platformio.ini | 1 + variants/rpi_picow/platformio.ini | 2 +- variants/station_g2/platformio.ini | 1 + variants/station_g3_esp32/platformio.ini | 1 + variants/thinknode_m2/platformio.ini | 1 + variants/thinknode_m5/platformio.ini | 1 + variants/thinknode_m7/platformio.ini | 1 + variants/thinknode_m9/platformio.ini | 1 + variants/xiao_c3/platformio.ini | 1 + variants/xiao_s3_wio/platformio.ini | 1 + 24 files changed, 27 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4eea0b2f..e835d8ef 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2181,7 +2181,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design - sprintf(reply, "> %s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : "(build-time)"); + sprintf(reply, "> %s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : WIFI_SSID); return true; } } diff --git a/platformio.ini b/platformio.ini index 622b01e2..2219c978 100644 --- a/platformio.ini +++ b/platformio.ini @@ -64,7 +64,6 @@ build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM ; -D ESP32_CPU_FREQ=80 ; change it to your need build_src_filter = ${arduino_base.build_src_filter} - + [esp32_ota] lib_deps = diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini index 354004f0..df986cf0 100644 --- a/variants/heltec_rc32/platformio.ini +++ b/variants/heltec_rc32/platformio.ini @@ -185,6 +185,7 @@ build_src_filter = ${Heltec_RC32.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -324,6 +325,7 @@ build_flags = build_src_filter = ${Heltec_RC32_with_display.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index d040b72f..178508d8 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -195,6 +195,7 @@ build_src_filter = ${Heltec_tracker_v2.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index 78561a14..28e80554 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -189,6 +189,7 @@ build_flags = ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v2.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 65e636eb..27541a8a 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -198,6 +198,7 @@ build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -350,6 +351,7 @@ build_flags = ; -D MESH_DEBUG=1 build_src_filter = ${Heltec_lora32_v3.build_src_filter} + + + +<../examples/companion_radio/*.cpp> lib_deps = ${Heltec_lora32_v3.lib_deps} diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index d718c006..25f9ee3b 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -241,6 +241,7 @@ build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -406,6 +407,7 @@ build_src_filter = ${heltec_v4_tft.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index f523ebf9..8c2feb94 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -186,6 +186,7 @@ build_src_filter = ${heltec_v4_r8_oled.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = @@ -311,6 +312,7 @@ build_src_filter = ${heltec_v4_r8_tft.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index 0f604fac..16db0257 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -165,6 +165,7 @@ build_flags = ; -D MESH_DEBUG=1 build_src_filter = ${LilyGo_TBeam_1W.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index 8bfc4093..a9991f25 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -160,6 +160,7 @@ build_flags = ; -D CORE_DEBUG_LEVEL=4 build_src_filter = ${T_Beam_S3_Supreme_SX1262.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index 1aea74d2..cf24a5d5 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -140,6 +140,7 @@ build_flags = -D OFFLINE_QUEUE_SIZE=128 build_src_filter = ${LilyGo_TLora_V2_1_1_6.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini index 8255e46c..c0e8c80d 100644 --- a/variants/meshnology_w12/platformio.ini +++ b/variants/meshnology_w12/platformio.ini @@ -184,6 +184,7 @@ build_src_filter = ${meshnology_w12.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/nibble_screen_connect/platformio.ini b/variants/nibble_screen_connect/platformio.ini index 112181df..3c5049e0 100644 --- a/variants/nibble_screen_connect/platformio.ini +++ b/variants/nibble_screen_connect/platformio.ini @@ -154,6 +154,7 @@ build_src_filter = ${nibble_screen_connect_base.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/nibble_zero_connect/platformio.ini b/variants/nibble_zero_connect/platformio.ini index 1161743e..9789eabf 100644 --- a/variants/nibble_zero_connect/platformio.ini +++ b/variants/nibble_zero_connect/platformio.ini @@ -150,6 +150,7 @@ build_src_filter = ${nibble_zero_connect_base.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 8bd3c597..2fee8c26 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -182,6 +182,7 @@ build_flags = ; -D MESH_DEBUG=1 build_src_filter = ${rak3112.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-orig/*.cpp> lib_deps = diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index f99b1341..e54d86a6 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -84,7 +84,7 @@ lib_deps = ${rpi_picow.lib_deps} densaugeo/base64 @ ~1.4.0 ; USB + WiFi + BLE together; the interface manager fans frames out to all of them -[env:PicoW_companion_radio_all] +[env:PicoW_companion_radio] extends = rpi_picow build_flags = ${rpi_picow.build_flags} -D MAX_CONTACTS=100 diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index bdb7ee0c..d8bdd5e8 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -234,6 +234,7 @@ build_flags = ; -D MESH_DEBUG=1 build_src_filter = ${Station_G2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index 074d6a2e..477c135b 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -149,6 +149,7 @@ build_flags = ; -D MESH_DEBUG=1 build_src_filter = ${Station_G3_ESP32.build_src_filter} + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> lib_deps = diff --git a/variants/thinknode_m2/platformio.ini b/variants/thinknode_m2/platformio.ini index 583f913c..c1f5612a 100644 --- a/variants/thinknode_m2/platformio.ini +++ b/variants/thinknode_m2/platformio.ini @@ -184,6 +184,7 @@ build_flags = -D WIFI_PWD='"mypwd"' build_src_filter = ${ThinkNode_M2.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index 5e85b649..f64e4e77 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -198,6 +198,7 @@ build_flags = -D WIFI_PWD='"mypwd"' build_src_filter = ${ThinkNode_M5.build_src_filter} + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 7d9892e5..508fc813 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -145,6 +145,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 build_src_filter = ${ThinkNode_M7.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> diff --git a/variants/thinknode_m9/platformio.ini b/variants/thinknode_m9/platformio.ini index 09b1391d..085ab7d5 100755 --- a/variants/thinknode_m9/platformio.ini +++ b/variants/thinknode_m9/platformio.ini @@ -146,6 +146,7 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 build_src_filter = ${ThinkNode_M9.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> diff --git a/variants/xiao_c3/platformio.ini b/variants/xiao_c3/platformio.ini index c9c107c6..cca2907a 100644 --- a/variants/xiao_c3/platformio.ini +++ b/variants/xiao_c3/platformio.ini @@ -115,6 +115,7 @@ extends = Xiao_esp32_C3 build_src_filter = ${Xiao_esp32_C3.build_src_filter} +<../examples/companion_radio/*.cpp> + + + build_flags = ${Xiao_esp32_C3.build_flags} -D MAX_CONTACTS=350 diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 293a13c1..de656c44 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -202,6 +202,7 @@ extends = Xiao_S3_WIO build_src_filter = ${Xiao_S3_WIO.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> build_flags = From 98cf4f3332b8f537e36979c1ccff1f6e12766048 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 21:44:47 -0500 Subject: [PATCH 28/43] Add get wifi.status and get wifi.ip CLI commands --- examples/companion_radio/MyMesh.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e835d8ef..76e0dfde 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,6 +1,9 @@ #include "MyMesh.h" #include // needed for PlatformIO +#ifdef WIFI_SSID +#include +#endif #include #define CMD_APP_START 1 @@ -2184,6 +2187,18 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* sprintf(reply, "> %s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : WIFI_SSID); return true; } + if (strcmp(command, "get wifi.status") == 0) { + strcpy(reply, WiFi.status() == WL_CONNECTED ? "> connected" : "> disconnected"); + return true; + } + if (strcmp(command, "get wifi.ip") == 0) { + if (WiFi.status() == WL_CONNECTED) { + sprintf(reply, "> %s", WiFi.localIP().toString().c_str()); + } else { + strcpy(reply, "> (not connected)"); + } + return true; + } } #endif From eb2ab10de0a23ab76e79379ee09109260c9f4efa Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 21:56:25 -0500 Subject: [PATCH 29/43] Add wifi.enabled pref and CLI; skip WiFi when disabled or SSID blank --- examples/companion_radio/MyMesh.cpp | 10 +++ examples/companion_radio/NodePrefs.h | 2 + examples/companion_radio/main.cpp | 95 +++++++++++++++------------- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 76e0dfde..2886ceb6 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2187,6 +2187,16 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* sprintf(reply, "> %s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : WIFI_SSID); return true; } + if (memcmp(command, "set wifi.enabled ", 17) == 0) { + _prefs.wifi_enabled = atoi(&command[17]) ? 1 : 0; + savePrefs(); + sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", _prefs.wifi_enabled); + return true; + } + if (strcmp(command, "get wifi.enabled") == 0) { + sprintf(reply, "> %d", _prefs.wifi_enabled); + return true; + } if (strcmp(command, "get wifi.status") == 0) { strcpy(reply, WiFi.status() == WL_CONNECTED ? "> connected" : "> disconnected"); return true; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index c725c317..03323675 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -47,6 +47,7 @@ public: #ifdef WIFI_SSID char wifi_ssid[33] = {0}; // if empty, the compile-time WIFI_SSID is used char wifi_pwd[64] = {0}; + uint8_t wifi_enabled = 1; // 0 = never bring up WiFi (credentials may still be baked in) #endif private: @@ -171,6 +172,7 @@ private: void structure() override { def("ssid", _parent->wifi_ssid, sizeof(_parent->wifi_ssid)); def("pwd", _parent->wifi_pwd, sizeof(_parent->wifi_pwd)); + def("enabled", _parent->wifi_enabled); } public: WiFiPrefs(NodePrefs* parent) : _parent(parent) { } diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 6b6d7cf7..cf761095 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -127,6 +127,7 @@ void halt() { char wifi_ssid[33] = WIFI_SSID; // replaced by stored prefs at boot, if set char wifi_pwd[64] = WIFI_PWD; bool wifi_was_connected = false; + bool wifi_enabled = false; // set at boot from prefs; false also when the effective SSID is blank #endif void setup() { @@ -208,21 +209,6 @@ void setup() { // add wifi interface #ifdef WIFI_SSID -#if defined(ESP32) - board.setInhibitSleep(true); // prevent sleep when WiFi is active - WiFi.setAutoReconnect(true); - - WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){ - if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { - WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect..."); - wifi_needs_reconnect = true; - } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { - WIFI_DEBUG_PRINTLN("WiFi connected successfully!"); - wifi_needs_reconnect = false; - } - }); -#endif - // stored credentials win over the build-time ones ('set wifi.ssid ' over USB serial). // they are taken as a pair, so 'set wifi.ssid' alone gives an empty password, not a // silent fallback to the build-time password of a different network. Copied out of prefs @@ -232,20 +218,41 @@ void setup() { strcpy(wifi_ssid, the_mesh.getNodePrefs()->wifi_ssid); strcpy(wifi_pwd, the_mesh.getNodePrefs()->wifi_pwd); } - WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); + // 'set wifi.enabled 0' or a build with blank credentials leaves the radio off entirely + wifi_enabled = the_mesh.getNodePrefs()->wifi_enabled && wifi_ssid[0]; + if (wifi_enabled) { +#if defined(ESP32) + board.setInhibitSleep(true); // prevent sleep when WiFi is active + WiFi.setAutoReconnect(true); + + WiFi.onEvent([](WiFiEvent_t event, WiFiEventInfo_t info){ + if (event == ARDUINO_EVENT_WIFI_STA_DISCONNECTED) { + WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect..."); + wifi_needs_reconnect = true; + } else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) { + WIFI_DEBUG_PRINTLN("WiFi connected successfully!"); + wifi_needs_reconnect = false; + } + }); +#endif + + WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); #if defined(RP2040_PLATFORM) - // ponytail: the join itself blocks inside the core (CYW43::begin busy-waits for the - // association), so every attempt stalls the mesh loop. beginNoBlock() only skips the - // extra DHCP wait. Give the first connect a full window, then bound the retries below. - // Upgrade path if the stall ever matters: run WiFi on core1. - WiFi.beginNoBlock(wifi_ssid, wifi_pwd); - last_wifi_reconnect_attempt = millis(); // let DHCP finish before the poll can retry + // ponytail: the join itself blocks inside the core (CYW43::begin busy-waits for the + // association), so every attempt stalls the mesh loop. beginNoBlock() only skips the + // extra DHCP wait. Give the first connect a full window, then bound the retries below. + // Upgrade path if the stall ever matters: run WiFi on core1. + WiFi.beginNoBlock(wifi_ssid, wifi_pwd); + last_wifi_reconnect_attempt = millis(); // let DHCP finish before the poll can retry #else - WiFi.begin(wifi_ssid, wifi_pwd); + WiFi.begin(wifi_ssid, wifi_pwd); #endif - wifi_interface.begin(TCP_PORT); - interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); + wifi_interface.begin(TCP_PORT); + interface_manager.addInterface(InterfaceType::WiFi, &wifi_interface); + } else { + WIFI_DEBUG_PRINTLN("wifi disabled"); + } #endif // add usb interface @@ -301,31 +308,33 @@ void loop() { } #ifdef WIFI_SSID - // RP2040 has no WiFi event callbacks, so poll the link state instead + if (wifi_enabled) { + // RP2040 has no WiFi event callbacks, so poll the link state instead #if defined(RP2040_PLATFORM) - wifi_needs_reconnect = (WiFi.status() != WL_CONNECTED); - if (wifi_was_connected == wifi_needs_reconnect) { // link state changed - wifi_was_connected = !wifi_needs_reconnect; - if (wifi_was_connected) { - WIFI_DEBUG_PRINTLN("connected, listening on %s:%d", WiFi.localIP().toString().c_str(), TCP_PORT); - } else { - WIFI_DEBUG_PRINTLN("link lost"); + wifi_needs_reconnect = (WiFi.status() != WL_CONNECTED); + if (wifi_was_connected == wifi_needs_reconnect) { // link state changed + wifi_was_connected = !wifi_needs_reconnect; + if (wifi_was_connected) { + WIFI_DEBUG_PRINTLN("connected, listening on %s:%d", WiFi.localIP().toString().c_str(), TCP_PORT); + } else { + WIFI_DEBUG_PRINTLN("link lost"); + } } - } #endif - // Safely attempt to reconnect if flagged. On RP2040 each attempt blocks the mesh loop - // for up to WIFI_RETRY_TIMEOUT, so retry less often and cap how long a join may stall. - if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > WIFI_RETRY_INTERVAL)) { - WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect to %s (status %d)...", wifi_ssid, WiFi.status()); + // Safely attempt to reconnect if flagged. On RP2040 each attempt blocks the mesh loop + // for up to WIFI_RETRY_TIMEOUT, so retry less often and cap how long a join may stall. + if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > WIFI_RETRY_INTERVAL)) { + WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect to %s (status %d)...", wifi_ssid, WiFi.status()); #if defined(RP2040_PLATFORM) - WiFi.setTimeout(WIFI_RETRY_TIMEOUT); - WiFi.beginNoBlock(wifi_ssid, wifi_pwd); // no reconnect() on this platform + WiFi.setTimeout(WIFI_RETRY_TIMEOUT); + WiFi.beginNoBlock(wifi_ssid, wifi_pwd); // no reconnect() on this platform #else - WiFi.disconnect(); - WiFi.reconnect(); + WiFi.disconnect(); + WiFi.reconnect(); #endif - last_wifi_reconnect_attempt = millis(); + last_wifi_reconnect_attempt = millis(); + } } #endif } From dac171b49c23ab11a9906a80f55ea0e5d14c9b0f Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 22:05:29 -0500 Subject: [PATCH 30/43] Default wifi.enabled to 0 --- examples/companion_radio/NodePrefs.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 03323675..68facfbe 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -47,7 +47,7 @@ public: #ifdef WIFI_SSID char wifi_ssid[33] = {0}; // if empty, the compile-time WIFI_SSID is used char wifi_pwd[64] = {0}; - uint8_t wifi_enabled = 1; // 0 = never bring up WiFi (credentials may still be baked in) + uint8_t wifi_enabled = 0; // off until 'set wifi.enabled 1' (credentials may still be baked in) #endif private: From 7c2b4d88be891d884907875172dfc99beb1c48b0 Mon Sep 17 00:00:00 2001 From: Michael Graff Date: Sun, 6 Sep 2026 22:18:00 -0500 Subject: [PATCH 31/43] WiFi on/off tri-state: on when SSID set unless explicitly disabled; PicoW ships no default SSID --- examples/companion_radio/MyMesh.cpp | 13 +++++++++---- examples/companion_radio/NodePrefs.h | 7 ++++++- examples/companion_radio/main.cpp | 4 ++-- variants/rpi_picow/platformio.ini | 8 ++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2886ceb6..b2c99288 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2184,17 +2184,22 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design - sprintf(reply, "> %s", _prefs.wifi_ssid[0] ? _prefs.wifi_ssid : WIFI_SSID); + sprintf(reply, "> %s", _prefs.wifiSSID()[0] ? _prefs.wifiSSID() : "(not set)"); return true; } if (memcmp(command, "set wifi.enabled ", 17) == 0) { - _prefs.wifi_enabled = atoi(&command[17]) ? 1 : 0; + uint8_t en = atoi(&command[17]) ? 1 : 0; + if (en && !_prefs.wifiSSID()[0]) { + strcpy(reply, "> set wifi.ssid first"); + return true; + } + _prefs.wifi_enabled = en; savePrefs(); - sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", _prefs.wifi_enabled); + sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", en); return true; } if (strcmp(command, "get wifi.enabled") == 0) { - sprintf(reply, "> %d", _prefs.wifi_enabled); + sprintf(reply, "> %d", _prefs.wifiEnabled() ? 1 : 0); return true; } if (strcmp(command, "get wifi.status") == 0) { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 68facfbe..85cdebb2 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -47,7 +47,12 @@ public: #ifdef WIFI_SSID char wifi_ssid[33] = {0}; // if empty, the compile-time WIFI_SSID is used char wifi_pwd[64] = {0}; - uint8_t wifi_enabled = 0; // off until 'set wifi.enabled 1' (credentials may still be baked in) + uint8_t wifi_enabled = 2; // 0 = off, 1 = on, 2 = never set (treated as on) + + // effective SSID: stored prefs win over the build-time one + const char* wifiSSID() const { return wifi_ssid[0] ? wifi_ssid : WIFI_SSID; } + // WiFi runs only when there is an SSID and it hasn't been explicitly turned off + bool wifiEnabled() const { return wifiSSID()[0] && wifi_enabled != 0; } #endif private: diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index cf761095..d461a36d 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -218,8 +218,8 @@ void setup() { strcpy(wifi_ssid, the_mesh.getNodePrefs()->wifi_ssid); strcpy(wifi_pwd, the_mesh.getNodePrefs()->wifi_pwd); } - // 'set wifi.enabled 0' or a build with blank credentials leaves the radio off entirely - wifi_enabled = the_mesh.getNodePrefs()->wifi_enabled && wifi_ssid[0]; + // 'set wifi.enabled 0', or no SSID from either prefs or the build, leaves the radio off entirely + wifi_enabled = the_mesh.getNodePrefs()->wifiEnabled(); if (wifi_enabled) { #if defined(ESP32) board.setInhibitSleep(true); // prevent sleep when WiFi is active diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index e54d86a6..763c96bc 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -94,8 +94,8 @@ build_flags = ${rpi_picow.build_flags} -D BLE_PIN_CODE=123456 ; NOTE: DO NOT ENABLE --> -D BLE_DEBUG_LOGGING=1 (shares Serial with the USB interface) ; NOTE: DO NOT ENABLE --> -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_SSID='""' ; no default network, configure with 'set wifi.ssid' / 'set wifi.pwd' + -D WIFI_PWD='""' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${rpi_picow.build_src_filter} @@ -111,8 +111,8 @@ build_flags = ${rpi_picow.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='"myssid"' - -D WIFI_PWD='"mypwd"' + -D WIFI_SSID='""' ; no default network, configure with 'set wifi.ssid' / 'set wifi.pwd' + -D WIFI_PWD='""' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${rpi_picow.build_src_filter} From 790680e4d13eb847421d6efc0fe283e0496aaaf5 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 8 Sep 2026 18:25:34 +1200 Subject: [PATCH 32/43] allow toggling wifi enabled regardless of stored ssid --- examples/companion_radio/MyMesh.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b2c99288..9a5986f9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2188,14 +2188,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } if (memcmp(command, "set wifi.enabled ", 17) == 0) { - uint8_t en = atoi(&command[17]) ? 1 : 0; - if (en && !_prefs.wifiSSID()[0]) { - strcpy(reply, "> set wifi.ssid first"); - return true; - } - _prefs.wifi_enabled = en; + _prefs.wifi_enabled = atoi(&command[17]) ? 1 : 0; savePrefs(); - sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", en); + sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", _prefs.wifi_enabled); return true; } if (strcmp(command, "get wifi.enabled") == 0) { From bf64fafe9006b1b709c417753773f8ba96cbf714 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 8 Sep 2026 18:30:00 +1200 Subject: [PATCH 33/43] simplify response message --- examples/companion_radio/MyMesh.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 9a5986f9..f745ab2b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2180,7 +2180,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* _prefs.wifi_ssid[0] = 0; _prefs.wifi_pwd[0] = 0; savePrefs(); - strcpy(reply, "> wifi config cleared, using build-time credentials (reboot to apply)"); + strcpy(reply, "> wifi config cleared (reboot to apply)"); return true; } if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design From 87a05e15896143972690aa8b1bf828fb76c088af Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 8 Sep 2026 18:33:37 +1200 Subject: [PATCH 34/43] allow remote management of wifi as user has cli permission --- examples/companion_radio/MyMesh.cpp | 90 ++++++++++++++--------------- 1 file changed, 43 insertions(+), 47 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index f745ab2b..4e85e3cb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2161,54 +2161,50 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* } #ifdef WIFI_SSID - // not accepted from the remote mesh CLI (timestamp != 0): these are credentials. The app - // over USB/BLE/WiFi and the serial console (timestamp 0) may set them. - if (sender_timestamp == 0) { - if (memcmp(command, "set wifi.ssid ", 14) == 0) { - StrHelper::strncpy(_prefs.wifi_ssid, &command[14], sizeof(_prefs.wifi_ssid)); - savePrefs(); - sprintf(reply, "> wifi.ssid is now %s (set wifi.pwd too, then reboot)", _prefs.wifi_ssid); - return true; - } - if (memcmp(command, "set wifi.pwd ", 13) == 0) { - StrHelper::strncpy(_prefs.wifi_pwd, &command[13], sizeof(_prefs.wifi_pwd)); - savePrefs(); - strcpy(reply, "> wifi.pwd updated (reboot to apply)"); - return true; - } - if (strcmp(command, "set wifi.clear") == 0) { - _prefs.wifi_ssid[0] = 0; - _prefs.wifi_pwd[0] = 0; - savePrefs(); - strcpy(reply, "> wifi config cleared (reboot to apply)"); - return true; - } - if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design - sprintf(reply, "> %s", _prefs.wifiSSID()[0] ? _prefs.wifiSSID() : "(not set)"); - return true; - } - if (memcmp(command, "set wifi.enabled ", 17) == 0) { - _prefs.wifi_enabled = atoi(&command[17]) ? 1 : 0; - savePrefs(); - sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", _prefs.wifi_enabled); - return true; - } - if (strcmp(command, "get wifi.enabled") == 0) { - sprintf(reply, "> %d", _prefs.wifiEnabled() ? 1 : 0); - return true; - } - if (strcmp(command, "get wifi.status") == 0) { - strcpy(reply, WiFi.status() == WL_CONNECTED ? "> connected" : "> disconnected"); - return true; - } - if (strcmp(command, "get wifi.ip") == 0) { - if (WiFi.status() == WL_CONNECTED) { - sprintf(reply, "> %s", WiFi.localIP().toString().c_str()); - } else { - strcpy(reply, "> (not connected)"); - } - return true; + if (memcmp(command, "set wifi.ssid ", 14) == 0) { + StrHelper::strncpy(_prefs.wifi_ssid, &command[14], sizeof(_prefs.wifi_ssid)); + savePrefs(); + sprintf(reply, "> wifi.ssid is now %s (set wifi.pwd too, then reboot)", _prefs.wifi_ssid); + return true; + } + if (memcmp(command, "set wifi.pwd ", 13) == 0) { + StrHelper::strncpy(_prefs.wifi_pwd, &command[13], sizeof(_prefs.wifi_pwd)); + savePrefs(); + strcpy(reply, "> wifi.pwd updated (reboot to apply)"); + return true; + } + if (strcmp(command, "set wifi.clear") == 0) { + _prefs.wifi_ssid[0] = 0; + _prefs.wifi_pwd[0] = 0; + savePrefs(); + strcpy(reply, "> wifi config cleared (reboot to apply)"); + return true; + } + if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design + sprintf(reply, "> %s", _prefs.wifiSSID()[0] ? _prefs.wifiSSID() : "(not set)"); + return true; + } + if (memcmp(command, "set wifi.enabled ", 17) == 0) { + _prefs.wifi_enabled = atoi(&command[17]) ? 1 : 0; + savePrefs(); + sprintf(reply, "> wifi.enabled is now %d (reboot to apply)", _prefs.wifi_enabled); + return true; + } + if (strcmp(command, "get wifi.enabled") == 0) { + sprintf(reply, "> %d", _prefs.wifiEnabled() ? 1 : 0); + return true; + } + if (strcmp(command, "get wifi.status") == 0) { + strcpy(reply, WiFi.status() == WL_CONNECTED ? "> connected" : "> disconnected"); + return true; + } + if (strcmp(command, "get wifi.ip") == 0) { + if (WiFi.status() == WL_CONNECTED) { + sprintf(reply, "> %s", WiFi.localIP().toString().c_str()); + } else { + strcpy(reply, "> (not connected)"); } + return true; } #endif From 974f00ded051663b738584fc5c2523dd240b54f3 Mon Sep 17 00:00:00 2001 From: taco Date: Tue, 8 Sep 2026 16:35:31 +1000 Subject: [PATCH 35/43] fix: LR2021 to use correct macro for IRQ_DETECTED in startReceive() --- src/helpers/radiolib/CustomLR2021.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/radiolib/CustomLR2021.h b/src/helpers/radiolib/CustomLR2021.h index a89ae943..0255b93c 100644 --- a/src/helpers/radiolib/CustomLR2021.h +++ b/src/helpers/radiolib/CustomLR2021.h @@ -72,7 +72,7 @@ class CustomLR2021 : public LR2021 { int16_t startReceive() override { // include the PREAMBLE_DETECTED irq bit in reported flags - return LR2021::startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_LR2021_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); + return LR2021::startReceive(RADIOLIB_LR2021_RX_TIMEOUT_INF, RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED), RADIOLIB_IRQ_RX_DEFAULT_MASK, 0); } bool isReceiving() { From 6809fed159444e6f8094b5abc3811ab46db4fdff Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 8 Sep 2026 21:30:17 +1200 Subject: [PATCH 36/43] simplify and remove 2 as a wifi enabled state --- examples/companion_radio/MyMesh.cpp | 4 ++-- examples/companion_radio/NodePrefs.h | 9 +++------ examples/companion_radio/main.cpp | 6 +++--- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4e85e3cb..7a380e98 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2181,7 +2181,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } if (strcmp(command, "get wifi.ssid") == 0) { // no 'get wifi.pwd', by design - sprintf(reply, "> %s", _prefs.wifiSSID()[0] ? _prefs.wifiSSID() : "(not set)"); + sprintf(reply, "> %s", _prefs.getWifiSSID()[0] ? _prefs.getWifiSSID() : "(not set)"); return true; } if (memcmp(command, "set wifi.enabled ", 17) == 0) { @@ -2191,7 +2191,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } if (strcmp(command, "get wifi.enabled") == 0) { - sprintf(reply, "> %d", _prefs.wifiEnabled() ? 1 : 0); + sprintf(reply, "> %d", _prefs.wifi_enabled); return true; } if (strcmp(command, "get wifi.status") == 0) { diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 85cdebb2..ed408f9a 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -47,12 +47,9 @@ public: #ifdef WIFI_SSID char wifi_ssid[33] = {0}; // if empty, the compile-time WIFI_SSID is used char wifi_pwd[64] = {0}; - uint8_t wifi_enabled = 2; // 0 = off, 1 = on, 2 = never set (treated as on) - - // effective SSID: stored prefs win over the build-time one - const char* wifiSSID() const { return wifi_ssid[0] ? wifi_ssid : WIFI_SSID; } - // WiFi runs only when there is an SSID and it hasn't been explicitly turned off - bool wifiEnabled() const { return wifiSSID()[0] && wifi_enabled != 0; } + uint8_t wifi_enabled = 1; // enabled by default to allow wifi only builds to work. wifi won't be started if ssid is empty + // use ssid from prefs, or fallback to ssid from build flags + const char* getWifiSSID() const { return wifi_ssid[0] ? wifi_ssid : WIFI_SSID; } #endif private: diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d461a36d..bfcd2682 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -218,9 +218,9 @@ void setup() { strcpy(wifi_ssid, the_mesh.getNodePrefs()->wifi_ssid); strcpy(wifi_pwd, the_mesh.getNodePrefs()->wifi_pwd); } - // 'set wifi.enabled 0', or no SSID from either prefs or the build, leaves the radio off entirely - wifi_enabled = the_mesh.getNodePrefs()->wifiEnabled(); - if (wifi_enabled) { + // only start wifi if enabled and ssid is not empty + wifi_enabled = the_mesh.getNodePrefs()->wifi_enabled; + if (wifi_enabled && wifi_ssid[0]) { #if defined(ESP32) board.setInhibitSleep(true); // prevent sleep when WiFi is active WiFi.setAutoReconnect(true); From cced091b0c5715d9477d2cde3758e679ad8d4455 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 8 Sep 2026 22:18:22 +1200 Subject: [PATCH 37/43] tidy comments --- examples/companion_radio/main.cpp | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index bfcd2682..d886b804 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -209,11 +209,7 @@ void setup() { // add wifi interface #ifdef WIFI_SSID - // stored credentials win over the build-time ones ('set wifi.ssid ' over USB serial). - // they are taken as a pair, so 'set wifi.ssid' alone gives an empty password, not a - // silent fallback to the build-time password of a different network. Copied out of prefs - // so 'set wifi.*' edits only take effect on reboot, as their replies promise. - // (No NULL-for-open-network: the RP2040 core does strlen() on the password unguarded.) + // use wifi ssid and password from prefs if ssid is not empty, otherwise use the build flag defaults if (the_mesh.getNodePrefs()->wifi_ssid[0]) { strcpy(wifi_ssid, the_mesh.getNodePrefs()->wifi_ssid); strcpy(wifi_pwd, the_mesh.getNodePrefs()->wifi_pwd); @@ -239,10 +235,9 @@ void setup() { WIFI_DEBUG_PRINTLN("connecting to %s", wifi_ssid); #if defined(RP2040_PLATFORM) - // ponytail: the join itself blocks inside the core (CYW43::begin busy-waits for the + // the join itself blocks inside the core (CYW43::begin busy-waits for the // association), so every attempt stalls the mesh loop. beginNoBlock() only skips the // extra DHCP wait. Give the first connect a full window, then bound the retries below. - // Upgrade path if the stall ever matters: run WiFi on core1. WiFi.beginNoBlock(wifi_ssid, wifi_pwd); last_wifi_reconnect_attempt = millis(); // let DHCP finish before the poll can retry #else From cee43752c7bd8adb44aac5431e9d3350d6ebba17 Mon Sep 17 00:00:00 2001 From: liamcottle Date: Tue, 8 Sep 2026 22:54:50 +1200 Subject: [PATCH 38/43] add new ENABLE_WIFI_INTERFACE build flag for companions without providing ssid and pwd --- examples/companion_radio/MyMesh.cpp | 6 +++--- examples/companion_radio/NodePrefs.h | 11 +++++++---- examples/companion_radio/main.cpp | 14 ++++++++++---- examples/companion_radio/ui-new/UITask.cpp | 4 ++-- examples/companion_radio/ui-tiny/UITask.cpp | 4 ++-- variants/heltec_rc32/platformio.ini | 2 ++ variants/heltec_tracker_v2/platformio.ini | 1 + variants/heltec_v2/platformio.ini | 1 + variants/heltec_v3/platformio.ini | 2 ++ variants/heltec_v4/platformio.ini | 2 ++ variants/heltec_v4_r8/platformio.ini | 2 ++ variants/lilygo_tbeam_1w/platformio.ini | 1 + .../lilygo_tbeam_supreme_SX1262/platformio.ini | 1 + variants/lilygo_tlora_v2_1/platformio.ini | 1 + variants/meshnology_w12/platformio.ini | 1 + variants/nibble_screen_connect/platformio.ini | 1 + variants/nibble_zero_connect/platformio.ini | 1 + variants/rak3112/platformio.ini | 1 + variants/rpi_picow/platformio.ini | 4 ++-- variants/station_g2/platformio.ini | 1 + variants/station_g3_esp32/platformio.ini | 1 + variants/thinknode_m2/platformio.ini | 1 + variants/thinknode_m5/platformio.ini | 1 + variants/thinknode_m7/platformio.ini | 1 + variants/thinknode_m9/platformio.ini | 1 + variants/xiao_c3/platformio.ini | 1 + variants/xiao_s3_wio/platformio.ini | 1 + 27 files changed, 51 insertions(+), 17 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7a380e98..2d7d1491 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,7 +1,7 @@ #include "MyMesh.h" #include // needed for PlatformIO -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE #include #endif #include @@ -2160,7 +2160,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, char* return true; } -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE if (memcmp(command, "set wifi.ssid ", 14) == 0) { StrHelper::strncpy(_prefs.wifi_ssid, &command[14], sizeof(_prefs.wifi_ssid)); savePrefs(); @@ -2438,7 +2438,7 @@ void MyMesh::loop() { checkCLIRescueCmd(); } else { checkSerialInterface(); -#if defined(WIFI_SSID) && defined(RP2040_PLATFORM) && !defined(ENABLE_USB_INTERFACE) +#if defined(ENABLE_WIFI_INTERFACE) && defined(RP2040_PLATFORM) && !defined(ENABLE_USB_INTERFACE) // RP2040 WiFi builds are headless and have no way into the rescue CLI (that needs a // display + long-press), so serve config commands on the otherwise unused USB serial checkCLIRescueCmd(); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index ed408f9a..cf1914d0 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -44,7 +44,10 @@ public: char default_scope_name[31]; uint8_t default_scope_key[16]; int8_t tz_offset = 0; -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE + #ifndef WIFI_SSID + #define WIFI_SSID "" + #endif char wifi_ssid[33] = {0}; // if empty, the compile-time WIFI_SSID is used char wifi_pwd[64] = {0}; uint8_t wifi_enabled = 1; // enabled by default to allow wifi only builds to work. wifi won't be started if ssid is empty @@ -167,7 +170,7 @@ private: DynamicConfigSerializer custom; -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE class WiFiPrefs : public ConfigSerializer { NodePrefs* _parent; protected: @@ -194,13 +197,13 @@ protected: def("repeat", repeat); def("comp", companion); def("custom", custom); -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE def("wifi", wifi); #endif } public: NodePrefs() : radio(this), gps(this), companion(this), custom(&radio) -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE , wifi(this) #endif { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index d886b804..6dc9acdc 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -36,7 +36,13 @@ MultiSerialInterface interface_manager; #endif // include wifi interface -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE + #ifndef WIFI_SSID + #define WIFI_SSID "" + #endif + #ifndef WIFI_PWD + #define WIFI_PWD "" + #endif #ifndef TCP_PORT #define TCP_PORT 5000 #endif @@ -121,7 +127,7 @@ void halt() { } /* WIFI RECONNECT TRACKERS */ -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE bool wifi_needs_reconnect = false; unsigned long last_wifi_reconnect_attempt = 0; char wifi_ssid[33] = WIFI_SSID; // replaced by stored prefs at boot, if set @@ -208,7 +214,7 @@ void setup() { #endif // add wifi interface -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE // use wifi ssid and password from prefs if ssid is not empty, otherwise use the build flag defaults if (the_mesh.getNodePrefs()->wifi_ssid[0]) { strcpy(wifi_ssid, the_mesh.getNodePrefs()->wifi_ssid); @@ -302,7 +308,7 @@ void loop() { #endif } -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE if (wifi_enabled) { // RP2040 has no WiFi event callbacks, so poll the link state instead #if defined(RP2040_PLATFORM) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 2c79ab91..53ae480c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3,7 +3,7 @@ #include "../MyMesh.h" #include "target.h" #include -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE #include #endif @@ -260,7 +260,7 @@ public: sprintf(tmp, "%02d/%02d/%d", dt.day(), dt.month(), dt.year()); display.drawTextCentered(display.width() / 2, 80, tmp); #endif - #ifdef WIFI_SSID + #ifdef ENABLE_WIFI_INTERFACE IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); display.setTextSize(1); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index b6bdbcf4..9ecd66f1 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -4,7 +4,7 @@ #include "target.h" #include "u8g2_icons.h" -#ifdef WIFI_SSID +#ifdef ENABLE_WIFI_INTERFACE #include #endif @@ -173,7 +173,7 @@ public: display.setCursor(0, 19); display.print(tmp); - #ifdef WIFI_SSID + #ifdef ENABLE_WIFI_INTERFACE IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); display.setTextSize(1); diff --git a/variants/heltec_rc32/platformio.ini b/variants/heltec_rc32/platformio.ini index df986cf0..2f07c021 100644 --- a/variants/heltec_rc32/platformio.ini +++ b/variants/heltec_rc32/platformio.ini @@ -173,6 +173,7 @@ extends = Heltec_RC32 build_flags = ${Heltec_RC32.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D DISPLAY_CLASS=NullDisplayDriver -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 @@ -315,6 +316,7 @@ extends = Heltec_RC32_with_display build_flags = ${Heltec_RC32_with_display.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D UI_HAS_ROTARY_INPUT -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 diff --git a/variants/heltec_tracker_v2/platformio.ini b/variants/heltec_tracker_v2/platformio.ini index 178508d8..5fd9edab 100644 --- a/variants/heltec_tracker_v2/platformio.ini +++ b/variants/heltec_tracker_v2/platformio.ini @@ -182,6 +182,7 @@ extends = Heltec_tracker_v2 build_flags = ${Heltec_tracker_v2.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=ST7735Display diff --git a/variants/heltec_v2/platformio.ini b/variants/heltec_v2/platformio.ini index 28e80554..6435936e 100644 --- a/variants/heltec_v2/platformio.ini +++ b/variants/heltec_v2/platformio.ini @@ -178,6 +178,7 @@ extends = Heltec_lora32_v2 build_flags = ${Heltec_lora32_v2.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=8 diff --git a/variants/heltec_v3/platformio.ini b/variants/heltec_v3/platformio.ini index 27541a8a..a2b159ec 100644 --- a/variants/heltec_v3/platformio.ini +++ b/variants/heltec_v3/platformio.ini @@ -185,6 +185,7 @@ extends = Heltec_lora32_v3 build_flags = ${Heltec_lora32_v3.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SSD1306Display @@ -341,6 +342,7 @@ lib_deps = extends = Heltec_lora32_v3 build_flags = ${Heltec_lora32_v3.build_flags} + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D WIFI_DEBUG_LOGGING=1 diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 25f9ee3b..96b3eec0 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -228,6 +228,7 @@ extends = heltec_v4_oled build_flags = ${heltec_v4_oled.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 @@ -394,6 +395,7 @@ extends = heltec_v4_tft build_flags = ${heltec_v4_tft.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 8c2feb94..7611cc1c 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -175,6 +175,7 @@ extends = heltec_v4_r8_oled build_flags = ${heltec_v4_r8_oled.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 @@ -301,6 +302,7 @@ extends = heltec_v4_r8_tft build_flags = ${heltec_v4_r8_tft.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/lilygo_tbeam_1w/platformio.ini b/variants/lilygo_tbeam_1w/platformio.ini index 16db0257..ab9c9e67 100644 --- a/variants/lilygo_tbeam_1w/platformio.ini +++ b/variants/lilygo_tbeam_1w/platformio.ini @@ -153,6 +153,7 @@ extends = LilyGo_TBeam_1W build_flags = ${LilyGo_TBeam_1W.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini index a9991f25..af4a8eaf 100644 --- a/variants/lilygo_tbeam_supreme_SX1262/platformio.ini +++ b/variants/lilygo_tbeam_supreme_SX1262/platformio.ini @@ -148,6 +148,7 @@ extends = T_Beam_S3_Supreme_SX1262 build_flags = ${T_Beam_S3_Supreme_SX1262.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/lilygo_tlora_v2_1/platformio.ini b/variants/lilygo_tlora_v2_1/platformio.ini index cf24a5d5..dc60b6f7 100644 --- a/variants/lilygo_tlora_v2_1/platformio.ini +++ b/variants/lilygo_tlora_v2_1/platformio.ini @@ -132,6 +132,7 @@ extends = LilyGo_TLora_V2_1_1_6 build_flags = ${LilyGo_TLora_V2_1_1_6.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=160 -D MAX_GROUP_CHANNELS=8 -D WIFI_SSID='"ssid"' diff --git a/variants/meshnology_w12/platformio.ini b/variants/meshnology_w12/platformio.ini index c0e8c80d..ffab9d57 100644 --- a/variants/meshnology_w12/platformio.ini +++ b/variants/meshnology_w12/platformio.ini @@ -171,6 +171,7 @@ extends = meshnology_w12 build_flags = ${meshnology_w12.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/nibble_screen_connect/platformio.ini b/variants/nibble_screen_connect/platformio.ini index 3c5049e0..0f8d31de 100644 --- a/variants/nibble_screen_connect/platformio.ini +++ b/variants/nibble_screen_connect/platformio.ini @@ -143,6 +143,7 @@ extends = nibble_screen_connect_base build_flags = ${nibble_screen_connect_base.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=300 -D MAX_GROUP_CHANNELS=8 diff --git a/variants/nibble_zero_connect/platformio.ini b/variants/nibble_zero_connect/platformio.ini index 9789eabf..c7e7b076 100644 --- a/variants/nibble_zero_connect/platformio.ini +++ b/variants/nibble_zero_connect/platformio.ini @@ -140,6 +140,7 @@ extends = nibble_zero_connect_base build_flags = ${nibble_zero_connect_base.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D DISPLAY_CLASS=SSD1306Display -D MAX_CONTACTS=300 -D MAX_GROUP_CHANNELS=8 diff --git a/variants/rak3112/platformio.ini b/variants/rak3112/platformio.ini index 2fee8c26..5f3b905b 100644 --- a/variants/rak3112/platformio.ini +++ b/variants/rak3112/platformio.ini @@ -172,6 +172,7 @@ extends = rak3112 build_flags = ${rak3112.build_flags} -I examples/companion_radio/ui-orig + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D WIFI_DEBUG_LOGGING=1 diff --git a/variants/rpi_picow/platformio.ini b/variants/rpi_picow/platformio.ini index 763c96bc..9814f397 100644 --- a/variants/rpi_picow/platformio.ini +++ b/variants/rpi_picow/platformio.ini @@ -90,12 +90,11 @@ build_flags = ${rpi_picow.build_flags} -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D ENABLE_USB_INTERFACE + -D ENABLE_WIFI_INTERFACE -D PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH -D BLE_PIN_CODE=123456 ; NOTE: DO NOT ENABLE --> -D BLE_DEBUG_LOGGING=1 (shares Serial with the USB interface) ; NOTE: DO NOT ENABLE --> -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID='""' ; no default network, configure with 'set wifi.ssid' / 'set wifi.pwd' - -D WIFI_PWD='""' ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 build_src_filter = ${rpi_picow.build_src_filter} @@ -108,6 +107,7 @@ lib_deps = ${rpi_picow.lib_deps} [env:PicoW_companion_radio_wifi] extends = rpi_picow build_flags = ${rpi_picow.build_flags} + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=100 -D MAX_GROUP_CHANNELS=8 -D WIFI_DEBUG_LOGGING=1 diff --git a/variants/station_g2/platformio.ini b/variants/station_g2/platformio.ini index d8bdd5e8..c1ae765f 100644 --- a/variants/station_g2/platformio.ini +++ b/variants/station_g2/platformio.ini @@ -224,6 +224,7 @@ extends = Station_G2 build_flags = ${Station_G2.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D WIFI_DEBUG_LOGGING=1 diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index 477c135b..7006d939 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -139,6 +139,7 @@ extends = Station_G3_ESP32 build_flags = ${Station_G3_ESP32.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D WIFI_DEBUG_LOGGING=1 diff --git a/variants/thinknode_m2/platformio.ini b/variants/thinknode_m2/platformio.ini index c1f5612a..862c7154 100644 --- a/variants/thinknode_m2/platformio.ini +++ b/variants/thinknode_m2/platformio.ini @@ -176,6 +176,7 @@ extends = ThinkNode_M2 build_flags = ${ThinkNode_M2.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/thinknode_m5/platformio.ini b/variants/thinknode_m5/platformio.ini index f64e4e77..a3f348b3 100644 --- a/variants/thinknode_m5/platformio.ini +++ b/variants/thinknode_m5/platformio.ini @@ -190,6 +190,7 @@ extends = ThinkNode_M5 build_flags = ${ThinkNode_M5.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 508fc813..5606d482 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -135,6 +135,7 @@ extends = ThinkNode_M7 build_flags = ${ThinkNode_M7.build_flags} -I examples/companion_radio/ui-orig + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=NullDisplayDriver diff --git a/variants/thinknode_m9/platformio.ini b/variants/thinknode_m9/platformio.ini index 085ab7d5..d4983491 100755 --- a/variants/thinknode_m9/platformio.ini +++ b/variants/thinknode_m9/platformio.ini @@ -137,6 +137,7 @@ extends = ThinkNode_M9 build_flags = ${ThinkNode_M9.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D WIFI_DEBUG_LOGGING=1 diff --git a/variants/xiao_c3/platformio.ini b/variants/xiao_c3/platformio.ini index cca2907a..62e6fd21 100644 --- a/variants/xiao_c3/platformio.ini +++ b/variants/xiao_c3/platformio.ini @@ -118,6 +118,7 @@ build_src_filter = ${Xiao_esp32_C3.build_src_filter} + build_flags = ${Xiao_esp32_C3.build_flags} + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index de656c44..3bb95607 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -208,6 +208,7 @@ build_src_filter = ${Xiao_S3_WIO.build_src_filter} build_flags = ${Xiao_S3_WIO.build_flags} -I examples/companion_radio/ui-new + -D ENABLE_WIFI_INTERFACE -D MAX_CONTACTS=350 -D MAX_GROUP_CHANNELS=40 -D OFFLINE_QUEUE_SIZE=256 From 82ea992d869f8ac1a5c1af32a24540978a8d7729 Mon Sep 17 00:00:00 2001 From: Yoshi Walsh Date: Wed, 9 Sep 2026 17:42:35 +1000 Subject: [PATCH 39/43] Fix Git error during project configuration Previously project configuration would fail with "fatal: couldn't find remote ref d541301" (git v2.55.0) Per https://git-scm.com/docs/git-fetch#Documentation/git-fetch.txt-refspec, the needs to be either a ref or a "**fully spelled** hex object name" This requirement is also mentioned in this SO answer: https://stackoverflow.com/a/30701724 After this change, project configuration now succeeds. 219812 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 2219c978..de4d6c29 100644 --- a/platformio.ini +++ b/platformio.ini @@ -85,7 +85,7 @@ platform_packages = ; use internal fork that includes patch to ble stack to prevent firmware lockup during rapid connect/disconnect ; https://github.com/meshcore-dev/MeshCore/pull/1177 ; https://github.com/meshcore-dev/MeshCore/pull/1295 - framework-arduinoadafruitnrf52 @ https://github.com/meshcore-dev/Adafruit_nRF52_Arduino#d541301 + framework-arduinoadafruitnrf52 @ https://github.com/meshcore-dev/Adafruit_nRF52_Arduino#d541301665b40959682252911e57b11df3ee651a platformio/toolchain-gccarmnoneeabi@^1.140201.0 extra_scripts = create-uf2.py build_flags = ${arduino_base.build_flags} From 0a82fcd20da3f928a7e8e4d0b4516980bead2a7a Mon Sep 17 00:00:00 2001 From: tekk Date: Wed, 9 Sep 2026 22:10:41 +0200 Subject: [PATCH 40/43] fix(companion): gate CMD_SEND_CHANNEL_DATA payload on MAX_GROUP_DATA_LENGTH Payloads of 166 or 167 bytes previously passed the frame-sized bound (MAX_CHANNEL_DATA_LENGTH = 167) but were rejected by sendGroupData against MAX_GROUP_DATA_LENGTH (165), causing ERR_CODE_TABLE_FULL (retry later) to be returned instead of ERR_CODE_ILLEGAL_ARG. Fixes #3345 --- examples/companion_radio/MyMesh.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 317cf284..d96e0f76 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1270,8 +1270,8 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx } else if (data_type == DATA_TYPE_RESERVED) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (payload_len > MAX_CHANNEL_DATA_LENGTH) { - MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_CHANNEL_DATA_LENGTH); + } else if (payload_len > MAX_GROUP_DATA_LENGTH) { + MESH_DEBUG_PRINTLN("CMD_SEND_CHANNEL_DATA payload too long: %d > %d", payload_len, MAX_GROUP_DATA_LENGTH); writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else if (sendGroupData(channel.channel, path, path_len, data_type, payload, payload_len)) { writeOKFrame(); From f722794e50495bd19a5a9f9fb93ed801c445574d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Br=C3=A1zio?= Date: Thu, 10 Sep 2026 09:17:37 +0100 Subject: [PATCH 41/43] Validate set af input to prevent out-of-range airtime factors --- src/helpers/CommonRadioPrefs.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index a25df880..9d92cad9 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -67,8 +67,14 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest return true; } if (memcmp(command, "set af ", 7) == 0) { - setAirtimeFactor(atof(&command[7])); - strcpy(reply, "OK"); + char* end; + float af = strtof(&command[7], &end); + if (end == &command[7] || af < 0 || af > 9) { + strcpy(reply, "ERROR: af must be 0-9"); + } else { + setAirtimeFactor(af); + strcpy(reply, "OK"); + } return true; } From 3a3c13a143c9120bae648d1b5ddb8e12483e8695 Mon Sep 17 00:00:00 2001 From: Scott Powell Date: Tue, 15 Sep 2026 18:41:11 +1000 Subject: [PATCH 42/43] * refactor to support new Board::handleCommand(), and "get/set radio.fem.*" commands --- .../wio-tracker-l1-1w/WioTrackerL1Board.cpp | 51 ++++++++++++++++++- .../wio-tracker-l1-1w/WioTrackerL1Board.h | 10 ++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp b/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp index 8210c24e..c6dbdbf5 100644 --- a/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.cpp @@ -15,7 +15,6 @@ void WioTrackerL1Board::begin() { pinMode(PIN_BUTTON4, INPUT_PULLUP); pinMode(PIN_BUTTON5, INPUT_PULLUP); pinMode(PIN_BUTTON6, INPUT_PULLUP); - #if defined(PIN_WIRE_SDA) && defined(PIN_WIRE_SCL) Wire.setPins(PIN_WIRE_SDA, PIN_WIRE_SCL); @@ -32,9 +31,57 @@ void WioTrackerL1Board::begin() { delay(10); // give sx1262 some time to power up } - + bool WioTrackerL1Board::setLoRaFemPaGainEnabled(bool enable) { _is_pa_enabled = enable; digitalWrite(SX126X_POWER_EN, enable ? HIGH : LOW); // enable/disable the 1W PA return true; } + +void WioTrackerL1Board::attachDynamicPrefs(KeyValueStore* prefs) { + _prefs = prefs; + + char gain[8]; + + gain[0] = 0; + _prefs->getByKey("fem_txgain", gain, 7); // get initial values + setLoRaFemPaGainEnabled(strcmp(gain, "1") == 0); +} + +bool WioTrackerL1Board::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { + if (strcmp(command, "get radio.fem.rxgain") == 0) { + strcpy(reply, "Error: unsupported"); + return true; + } + if (memcmp(command, "set radio.fem.rxgain ", 21) == 0) { + strcpy(reply, "Error: unsupported"); + return true; + } + + if (strcmp(command, "get radio.fem.txgain") == 0) { + sprintf(reply, "> %s", isLoRaFemPaGainEnabled() ? "on" : "off"); + return true; + } + if (memcmp(command, "set radio.fem.txgain ", 21) == 0) { + if (memcmp(&command[21], "on", 2) == 0) { + if (setLoRaFemPaGainEnabled(true)) { + _prefs->setByKey("fem_txgain", "1"); + strcpy(reply, "OK - LoRa FEM TX gain on"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); + } + } else if (memcmp(&command[21], "off", 3) == 0) { + if (setLoRaFemPaGainEnabled(false)) { + _prefs->setByKey("fem_txgain", "0"); + strcpy(reply, "OK - LoRa FEM TX gain off"); + } else { + strcpy(reply, "Error: failed to apply LoRa FEM TX gain"); + } + } else { + strcpy(reply, "Error: state must be on or off"); + } + return true; + } + + return false; // not handled +} diff --git a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h index c5a60944..798145db 100644 --- a/variants/wio-tracker-l1-1w/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1-1w/WioTrackerL1Board.h @@ -8,10 +8,16 @@ class WioTrackerL1Board : public NRF52BoardDCDC { protected: uint8_t btn_prev_state; bool _is_pa_enabled = false; + KeyValueStore* _prefs = NULL; + + bool setLoRaFemPaGainEnabled(bool enable); + bool isLoRaFemPaGainEnabled() const { return _is_pa_enabled; } public: WioTrackerL1Board() : NRF52Board("WioTrackerL1 OTA") {} void begin(); + void attachDynamicPrefs(KeyValueStore* prefs); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) override; #if defined(P_LORA_TX_LED) void onBeforeTransmit() override { @@ -35,10 +41,6 @@ public: return "Seeed Wio Tracker L1"; } - bool canControlLoRaFemPaGain() const override { return true; } - bool setLoRaFemPaGainEnabled(bool enable) override; - bool isLoRaFemPaGainEnabled() const override { return _is_pa_enabled; } - void powerOff() override { setLoRaFemPaGainEnabled(false); // turn Off PA NRF52Board::powerOff(); From 9d6d53a71a0f1a84cb337bdf6bff1d2497347feb Mon Sep 17 00:00:00 2001 From: Und3r_1337 Date: Wed, 16 Sep 2026 00:03:54 +0200 Subject: [PATCH 43/43] fix(config): allow digits in config keys so keys like gr_1hop round-trip --- src/helpers/ConfigSerializer.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/helpers/ConfigSerializer.cpp b/src/helpers/ConfigSerializer.cpp index 36aff5cc..daf834b2 100644 --- a/src/helpers/ConfigSerializer.cpp +++ b/src/helpers/ConfigSerializer.cpp @@ -24,7 +24,9 @@ static bool is_whitespace(char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; } static bool is_key_char(char c) { - return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; + // digits are allowed: keys like gr_1hop contain one, otherwise loadSerial + // fails at that key and prefs stop reloading after a reboot + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; } static bool is_value_char(char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || c == '-' || c == '.';