diff --git a/.github/workflows/build_one_target.yml b/.github/workflows/build_one_target.yml index 706b9cfe7..806df4e7a 100644 --- a/.github/workflows/build_one_target.yml +++ b/.github/workflows/build_one_target.yml @@ -4,9 +4,14 @@ on: workflow_dispatch: inputs: # trunk-ignore(checkov/CKV_GHA_7) + target: + type: string + required: false + description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets. arch: type: choice options: + - all - esp32 - esp32s3 - esp32c3 @@ -15,32 +20,18 @@ on: - rp2040 - rp2350 - stm32 - target: - type: string - required: false - description: Choose the target board, e.g. nrf52_promicro_diy_tcxo. If blank, will find available targets. - # find-target: - # type: boolean - # default: true - # description: 'Find the available targets' + description: Choose an arch to limit the search, or 'all' to search all architectures. + default: all permissions: read-all jobs: find-targets: - if: ${{ inputs.target == '' }} strategy: fail-fast: false matrix: arch: - - esp32 - - esp32s3 - - esp32c3 - - esp32c6 - - nrf52840 - - rp2040 - - rp2350 - - stm32 + - all runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 @@ -51,14 +42,37 @@ jobs: - run: pip install -U platformio - name: Generate matrix id: jsonStep + env: + BUILDTARGET: ${{ inputs.target }} + MATRIXARCH: ${{ inputs.arch }} run: | TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level extra) - echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY - echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY - echo "Arch: ${{matrix.arch}}" >> $GITHUB_STEP_SUMMARY - echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY - echo "Targets:" >> $GITHUB_STEP_SUMMARY - echo $TARGETS | jq -r 'sort_by(.board) |.[] | "- " + .board' >> $GITHUB_STEP_SUMMARY + if [ "$BUILDTARGET" = "" ]; then + echo "Name: $GITHUB_REF_NAME" >> $GITHUB_STEP_SUMMARY + echo "Base: $GITHUB_BASE_REF" >> $GITHUB_STEP_SUMMARY + echo "Arch: $MATRIXARCH" >> $GITHUB_STEP_SUMMARY + echo "Ref: $GITHUB_REF" >> $GITHUB_STEP_SUMMARY + echo "## 🎯 The following target boards are available to build:" >> $GITHUB_STEP_SUMMARY + echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY + echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY + echo $TARGETS | jq -r 'sort_by(.board) | sort_by(.platform) |.[] | "| " + .platform + " | " + .board + " |" ' >> $GITHUB_STEP_SUMMARY + else + echo "We build this one:" >> $GITHUB_STEP_SUMMARY + ARCH=$(echo "$TARGETS" | jq --arg BUILDTARGET "$BUILDTARGET" -r '.[] | select(.board==$BUILDTARGET) | .platform') + echo "| Platform | Board |" >> $GITHUB_STEP_SUMMARY + echo "| -------- | ----- |" >> $GITHUB_STEP_SUMMARY + echo "| $ARCH | "$BUILDTARGET" |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ "$ARCH" == "" ]]; then + echo "## ❌ Error: Target "$BUILDTARGET" not found!" >> $GITHUB_STEP_SUMMARY + else + echo "## ✅ Target "$BUILDTARGET" found, proceeding to build." >> $GITHUB_STEP_SUMMARY + fi + echo "You may need to refresh this page to make the built firmware appear below." >> $GITHUB_STEP_SUMMARY + echo "arch=$ARCH" >> $GITHUB_OUTPUT + fi + outputs: + arch: ${{ steps.jsonStep.outputs.arch }} version: if: ${{ inputs.target != '' }} @@ -78,12 +92,12 @@ jobs: build: if: ${{ inputs.target != '' && inputs.arch != 'native' }} - needs: [version] + needs: [version, find-targets] uses: ./.github/workflows/build_firmware.yml with: version: ${{ needs.version.outputs.long }} pio_env: ${{ inputs.target }} - platform: ${{ inputs.arch }} + platform: ${{ needs.find-targets.outputs.arch }} gather-artifacts: permissions: diff --git a/bin/config.d/femtofox/femtofox_E80-900M2213S.yaml b/bin/config.d/femtofox/femtofox_E80-900M2213S.yaml new file mode 100644 index 000000000..2f2b24603 --- /dev/null +++ b/bin/config.d/femtofox/femtofox_E80-900M2213S.yaml @@ -0,0 +1,30 @@ +--- +Lora: +## Ebyte E80-900M22S +## This is a bit experimental +## +## + Module: lr1121 + gpiochip: 1 # subtract 32 from the gpio numbers + DIO3_TCXO_VOLTAGE: 1.8 + CS: 16 #pin6 / GPIO48 1C0 + IRQ: 23 #pin17 / GPIO55 1C7 + Busy: 22 #pin16 / GPIO54 1C6 + Reset: 25 #pin13 / GPIO57 1D1 + + + spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19) + spiSpeed: 2000000 + +rfswitch_table: + pins: [DIO5, DIO6, DIO7] + MODE_STBY: [LOW, LOW, LOW] + MODE_RX: [LOW, HIGH, LOW] + MODE_TX: [HIGH, HIGH, LOW] + MODE_TX_HP: [HIGH, LOW, LOW] + MODE_TX_HF: [LOW, LOW, LOW] + MODE_GNSS: [LOW, LOW, HIGH] + MODE_WIFI: [LOW, LOW, LOW] + +General: + MACAddressSource: eth0 diff --git a/bin/config.d/femtofox/femtofox_LR1121 generic.yaml b/bin/config.d/femtofox/femtofox_LR1121 generic.yaml new file mode 100644 index 000000000..c66eebed5 --- /dev/null +++ b/bin/config.d/femtofox/femtofox_LR1121 generic.yaml @@ -0,0 +1,46 @@ +--- +Lora: +## Ebyte E80-900M22S +## This is a bit experimental +## +## + Module: lr1121 + gpiochip: 1 # subtract 32 from the gpio numbers + DIO3_TCXO_VOLTAGE: 1.8 + CS: 16 #pin6 / GPIO48 1C0 + IRQ: 23 #pin17 / GPIO55 1C7 + Busy: 22 #pin16 / GPIO54 1C6 + Reset: 25 #pin13 / GPIO57 1D1 + + + spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19) + spiSpeed: 2000000 + +rfswitch_table: + pins: + - DIO5 + - DIO6 + MODE_STBY: + - LOW + - LOW + MODE_RX: + - HIGH + - LOW + MODE_TX: + - HIGH + - HIGH + MODE_TX_HP: + - LOW + - HIGH + MODE_TX_HF: + - LOW + - LOW + MODE_GNSS: + - LOW + - LOW + MODE_WIFI: + - LOW + - LOW + +General: + MACAddressSource: eth0 diff --git a/bin/config.d/femtofox/femtofox_WIO-LR1121.yaml b/bin/config.d/femtofox/femtofox_WIO-LR1121.yaml new file mode 100644 index 000000000..c2ab76d46 --- /dev/null +++ b/bin/config.d/femtofox/femtofox_WIO-LR1121.yaml @@ -0,0 +1,30 @@ +--- +Lora: +## Ebyte E80-900M22S +## This is a bit experimental +## +## + Module: lr1121 + gpiochip: 1 # subtract 32 from the gpio numbers + DIO3_TCXO_VOLTAGE: 1.8 + CS: 16 #pin6 / GPIO48 1C0 + IRQ: 23 #pin17 / GPIO55 1C7 + Busy: 22 #pin16 / GPIO54 1C6 + Reset: 25 #pin13 / GPIO57 1D1 + + + spidev: spidev0.0 #pins are (CS=16, CLK=17, MOSI=18, MISO=19) + spiSpeed: 2000000 + +rfswitch_table: + pins: [DIO5, DIO6, DIO7] + MODE_STBY: [LOW, LOW, LOW] + MODE_RX: [LOW, LOW, LOW] + MODE_TX: [LOW, HIGH, LOW] + MODE_TX_HP: [HIGH, LOW, LOW] + # MODE_TX_HF: [] + # MODE_GNSS: [] + MODE_WIFI: [LOW, LOW, LOW] + +General: + MACAddressSource: eth0 diff --git a/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml b/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml new file mode 100644 index 000000000..e0cc6197b --- /dev/null +++ b/bin/config.d/lora-luckfox-pico-max-ws-raspberry-pi-pico-hat.yaml @@ -0,0 +1,31 @@ +# For use with Armbian luckfox-pico-max +# Waveshare LoRa HAT for Raspberry Pi Pico +# https://www.waveshare.com/wiki/Pico-LoRa-SX1262 + +Meta: + name: luckfox-pico-max-ws-raspberry-pi-pico-hat + support: community + compatible: + - luckfox-pico-max # Armbian + +Lora: + Module: sx1262 + DIO2_AS_RF_SWITCH: true + DIO3_TCXO_VOLTAGE: true + spidev: spidev0.0 + Busy: # GPIO1_C7 / GP2 + pin: 55 + gpiochip: 1 + line: 23 + CS: # GPIO1_C6 / GP3 + pin: 54 + gpiochip: 1 + line: 22 + Reset: # GPIO1_D1 / GP15 + pin: 57 + gpiochip: 1 + line: 25 + IRQ: # GPIO2_A2 / GP20 + pin: 66 + gpiochip: 2 + line: 2 diff --git a/platformio.ini b/platformio.ini index 3cd0cc9d0..0205d1ad8 100644 --- a/platformio.ini +++ b/platformio.ini @@ -56,9 +56,7 @@ build_flags = -Wno-missing-field-initializers -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware -DMESHTASTIC_EXCLUDE_GENERIC_THREAD_MODULE=1 -DMESHTASTIC_EXCLUDE_POWERMON=1 - -DMESHTASTIC_EXCLUDE_STATUS=1 -D MAX_THREADS=40 ; As we've split modules, we have more threads to manage - -DLED_BUILTIN=-1 #-DBUILD_EPOCH=$UNIX_TIME ; set in platformio-custom.py now #-D OLED_PL=1 #-D DEBUG_HEAP=1 ; uncomment to add free heap space / memory leak debugging logs @@ -227,6 +225,8 @@ lib_deps = https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip # renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30 https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip + # renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht + https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip ; Environmental sensors with BSEC2 (Bosch proprietary IAQ) [environmental_extra] diff --git a/protobufs b/protobufs index 4d5b500df..e30092e61 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 4d5b500df5af68a4f57d3e19705cc3bb1136358c +Subproject commit e30092e6168b13341c2b7ec4be19c789ad5cd77f diff --git a/src/DisplayFormatters.cpp b/src/DisplayFormatters.cpp index d88f9fc9f..fdcf840dc 100644 --- a/src/DisplayFormatters.cpp +++ b/src/DisplayFormatters.cpp @@ -4,7 +4,8 @@ const char *DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaC bool usePreset) { - // If use_preset is false, always return "Custom" + // If use_preset is false, always return "Custom" — callers such as RadioInterface and Channels + // rely on this being a stable literal for channel-name hashing and default-channel detection. if (!usePreset) { return "Custom"; } diff --git a/src/Power.cpp b/src/Power.cpp index d82c870ed..26b961525 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -40,6 +40,22 @@ #include "concurrency/LockGuard.h" #endif +#if defined(ARCH_STM32WL) && defined(BATTERY_PIN) +#include "stm32yyxx_ll_adc.h" + +/* Analog read resolution */ +#if defined(LL_ADC_RESOLUTION_12B) +#define LL_ADC_RESOLUTION LL_ADC_RESOLUTION_12B +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#elif defined(LL_ADC_DS_DATA_WIDTH_12_BIT) +#define LL_ADC_RESOLUTION LL_ADC_DS_DATA_WIDTH_12_BIT +#define BATTERY_SENSE_RESOLUTION_BITS 12 +#else +#error "ADC resolution could not be defined!" +#endif +#define ADC_RANGE (1 << BATTERY_SENSE_RESOLUTION_BITS) +#endif + #if defined(DEBUG_HEAP_MQTT) && !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" #include "target_specific.h" @@ -328,11 +344,17 @@ class AnalogBatteryLevel : public HasBatteryLevel float scaled = 0; battery_adcEnable(); -#ifdef ARCH_ESP32 // ADC block for espressif platforms +#ifdef ARCH_STM32WL + // STM32 ADC with VREFINT runtime calibration + Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION); + raw = analogRead(BATTERY_PIN); + scaled = __LL_ADC_CALC_DATA_TO_VOLTAGE(Vref, raw, LL_ADC_RESOLUTION); + scaled *= operativeAdcMultiplier; +#elif defined(ARCH_ESP32) // ADC block for espressif platforms raw = espAdcRead(); scaled = esp_adc_cal_raw_to_voltage(raw, adc_characs); scaled *= operativeAdcMultiplier; -#else // block for all other platforms +#else // block for all other platforms #ifdef ARCH_NRF52 concurrency::LockGuard saadcGuard(concurrency::nrf52SaadcLock); #endif @@ -530,6 +552,11 @@ class AnalogBatteryLevel : public HasBatteryLevel bool initial_read_done = false; float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS); uint32_t last_read_time_ms = 0; +#ifdef ARCH_STM32WL + // 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing + // (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset") + uint32_t Vref = 3300; +#endif #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && defined(HAS_RAKPROT) @@ -639,7 +666,9 @@ bool Power::analogInit() #define BATTERY_SENSE_RESOLUTION_BITS 10 #endif -#ifdef ARCH_ESP32 // ESP32 needs special analog stuff +#ifdef ARCH_STM32WL + analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); +#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff #ifndef ADC_WIDTH // max resolution by default static const adc_bits_width_t width = ADC_WIDTH_BIT_12; @@ -649,7 +678,7 @@ bool Power::analogInit() #ifndef BAT_MEASURE_ADC_UNIT // ADC1 adc1_config_width(width); adc1_config_channel_atten(adc_channel, atten); -#else // ADC2 +#else // ADC2 adc2_config_channel_atten(adc_channel, atten); #ifndef CONFIG_IDF_TARGET_ESP32S3 // ADC2 wifi bug workaround @@ -679,7 +708,7 @@ bool Power::analogInit() // NRF52 ADC init moved to powerHAL_init in nrf52 platform -#ifndef ARCH_ESP32 +#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL) analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS); #endif diff --git a/src/RedirectablePrint.cpp b/src/RedirectablePrint.cpp index 6ff7bbb18..9450f8990 100644 --- a/src/RedirectablePrint.cpp +++ b/src/RedirectablePrint.cpp @@ -137,7 +137,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format, if (color) { ::printf("\u001b[0m"); } - ::printf("| %02d:%02d:%02d %u ", hour, min, sec, millis() / 1000); + ::printf("| %02d:%02d:%02d %u.%03u ", hour, min, sec, millis() / 1000, millis() % 1000); #else printf("%s ", logLevel); if (color) { @@ -151,7 +151,7 @@ void RedirectablePrint::log_to_serial(const char *logLevel, const char *format, if (color) { ::printf("\u001b[0m"); } - ::printf("| ??:??:?? %u ", millis() / 1000); + ::printf("| ??:??:?? %u.%03u ", millis() / 1000, millis() % 1000); #else printf("%s ", logLevel); if (color) { diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index e24aa3c57..2a3f08cbc 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -30,6 +30,9 @@ SerialConsole *console; void consoleInit() { + if (console) { + return; + } auto sc = new SerialConsole(); // Must be dynamically allocated because we are now inheriting from thread #if defined(SERIAL_HAS_ON_RECEIVE) diff --git a/src/configuration.h b/src/configuration.h index 0ce28ed28..84dabee4e 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -78,6 +78,11 @@ along with this program. If not, see . // Configuration // ----------------------------------------------------------------------------- +// Pre-hop drop handling (compile-time flag). +#ifndef MESHTASTIC_PREHOP_DROP +#define MESHTASTIC_PREHOP_DROP 0 +#endif + /// Convert a preprocessor name into a quoted string #define xstr(s) ystr(s) #define ystr(s) #s @@ -226,7 +231,7 @@ along with this program. If not, see . #define BME_ADDR 0x76 #define BME_ADDR_ALTERNATE 0x77 #define MCP9808_ADDR 0x18 -#define INA_ADDR 0x40 +#define INA_ADDR 0x40 // same as SHT2X #define INA_ADDR_ALTERNATE 0x41 #define INA_ADDR_WAVESHARE_UPS 0x43 #define INA3221_ADDR 0x42 @@ -239,8 +244,8 @@ along with this program. If not, see . #define LPS22HB_ADDR 0x5C #define LPS22HB_ADDR_ALT 0x5D #define SFA30_ADDR 0x5D -#define SHT31_4x_ADDR 0x44 -#define SHT31_4x_ADDR_ALT 0x45 +#define SHTXX_ADDR 0x44 +#define SHTXX_ADDR_ALT 0x45 #define PMSA003I_ADDR 0x12 #define QMA6100P_ADDR 0x12 #define AHT10_ADDR 0x38 @@ -510,6 +515,7 @@ along with this program. If not, see . #define MESHTASTIC_EXCLUDE_REMOTEHARDWARE 1 #define MESHTASTIC_EXCLUDE_STOREFORWARD 1 #define MESHTASTIC_EXCLUDE_TEXTMESSAGE 1 +#define MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT 1 #define MESHTASTIC_EXCLUDE_ATAK 1 #define MESHTASTIC_EXCLUDE_CANNEDMESSAGES 1 #define MESHTASTIC_EXCLUDE_NEIGHBORINFO 1 diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index cc83a8d7b..d451d3948 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -31,9 +31,6 @@ class ScanI2C INA3221, MAX17048, MCP9808, - SHT31, - SHT4X, - SHTC3, LPS22HB, QMC6310U, QMC6310N, @@ -94,7 +91,8 @@ class ScanI2C SFA30, CW2015, SCD30, - ADS1115 + ADS1115, + SHTXX } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 2e00c11ce..052b2245a 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -136,7 +136,9 @@ bool ScanI2CTwoWire::i2cCommandResponseLength(ScanI2C::DeviceAddress addr, uint1 return match; } -/// for SEN5X detection +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR +// FIXME Move to a separate file for detection of sensors that require more complex interactions? +// For SEN5X detection // Note, this code needs to be called before setting the I2C bus speed // for the screen at high speed. The speed needs to be at 100kHz, otherwise // detection will not work @@ -174,6 +176,46 @@ String readSEN5xProductName(TwoWire *i2cBus, uint8_t address) return String(productName); } +#endif + +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR +bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address) +{ + + i2cBus->beginTransmission(address); + i2cBus->write(0xFA); + i2cBus->write(0x0F); + + if (i2cBus->endTransmission() != 0) + return false; + + if (i2cBus->requestFrom(address, (uint8_t)8) != 8) + return false; + + // Just flush the data + while (i2cBus->available() < 8) { + i2cBus->read(); + } + + i2cBus->beginTransmission(address); + i2cBus->write(0xFC); + i2cBus->write(0xC9); + + if (i2cBus->endTransmission() != 0) + return false; + + if (i2cBus->requestFrom(address, (uint8_t)6) != 6) + return false; + + // Just flush the data + while (i2cBus->available() < 6) { + i2cBus->read(); + } + + // Assume we detect the SHT21 if something came back from the request + return true; +} +#endif #define SCAN_SIMPLE_CASE(ADDR, T, ...) \ case ADDR: \ @@ -371,7 +413,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; #endif #if !defined(M5STACK_UNITC6L) - case INA_ADDR: + case INA_ADDR: // Same as SHT2X case INA_ADDR_ALTERNATE: case INA_ADDR_WAVESHARE_UPS: registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFE), 2); @@ -387,7 +429,12 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("INA260", (uint8_t)addr.address); type = INA260; } - } else { // Assume INA219 if INA260 ID is not found +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + } else if (detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) { + logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address); + type = SHTXX; +#endif + } else { // Assume INA219 if none of the above ones are found logFoundDevice("INA219", (uint8_t)addr.address); type = INA219; } @@ -448,22 +495,19 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } break; } - case SHT31_4x_ADDR: // same as OPT3001_ADDR_ALT - case SHT31_4x_ADDR_ALT: // same as OPT3001_ADDR + case SHTXX_ADDR: // same as OPT3001_ADDR_ALT + case SHTXX_ADDR_ALT: // same as OPT3001_ADDR if (getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x7E), 2) == 0x5449) { type = OPT3001; logFoundDevice("OPT3001", (uint8_t)addr.address); - } else if (i2cCommandResponseLength(addr, 0x89, 6)) { // SHT4x serial number (6 bytes inc. CRC) - type = SHT4X; - logFoundDevice("SHT4X", (uint8_t)addr.address); - } else { - type = SHT31; - logFoundDevice("SHT31", (uint8_t)addr.address); + } else { // SHTXX + type = SHTXX; + logFoundDevice("SHTXX", (uint8_t)addr.address); } break; - SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTC3, "SHTC3", (uint8_t)addr.address) + SCAN_SIMPLE_CASE(SHTC3_ADDR, SHTXX, "SHTXX", (uint8_t)addr.address) case RCWL9620_ADDR: // get MAX30102 PARTID registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0xFF), 1); @@ -675,6 +719,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("BMX160", (uint8_t)addr.address); break; } else { +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR String prod = ""; prod = readSEN5xProductName(i2cBus, addr.address); if (prod.startsWith("SEN55")) { @@ -690,6 +735,7 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) logFoundDevice("Sensirion SEN50", addr.address); break; } +#endif if (addr.address == BMX160_ADDR) { type = BMX160; logFoundDevice("BMX160", (uint8_t)addr.address); diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 1260d8b15..cf5511a33 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -103,6 +103,14 @@ static int32_t gpsSwitch() if (gps) { int currentState = digitalRead(PIN_GPS_SWITCH); + // Respect explicit NOT_PRESENT mode and do not let the hardware switch re-enable GPS. + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) { + gps->disable(); + lastState = currentState; + firstrun = false; + return 1000; + } + // if the switch is set to zero, disable the GPS Thread if (firstrun) if (currentState == LOW) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 55ec93db5..fa9d98a0e 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1197,7 +1197,7 @@ void Screen::setFrames(FrameFocus focus) for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { - favoriteFrames.push_back(graphics::UIRenderer::drawNodeInfo); + favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode); } } @@ -1226,7 +1226,7 @@ void Screen::setFrames(FrameFocus focus) static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); - prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list just changed) + prevFrame = -1; // Force drawFavoriteNode to pick a new node (because our list just changed) // Focus on a specific frame, in the frame set we just created switch (focus) { diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp index 005ead292..4c8272955 100644 --- a/src/graphics/TFTDisplay.cpp +++ b/src/graphics/TFTDisplay.cpp @@ -1254,14 +1254,14 @@ void TFTDisplay::display(bool fromBlank) // Did we find a pixel that needs updating on this row? if (x_FirstPixelUpdate < displayWidth) { + // Align the first pixel for update to an even number so the total alignment of + // the data will be at 32-bit boundary, which is required by GDMA SPI transfers. + x_FirstPixelUpdate &= ~1; - // Quickly write out the first changed pixel (saves another array lookup) - linePixelBuffer[x_FirstPixelUpdate] = isset ? colorTftMesh : colorTftBlack; - x_LastPixelUpdate = x_FirstPixelUpdate; - - // Step 3: copy all remaining pixels in this row into the pixel line buffer, - // while also recording the last pixel in the row that needs updating - for (x = x_FirstPixelUpdate + 1; x < displayWidth; x++) { + // Step 3a: copy rest of the pixels in this row into the pixel line buffer, + // while also recording the last pixel in the row that needs updating. + // Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set. + for (x = x_FirstPixelUpdate; x < displayWidth; x++) { isset = buffer[x + y_byteIndex] & y_byteMask; linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack; @@ -1274,6 +1274,14 @@ void TFTDisplay::display(bool fromBlank) x_LastPixelUpdate = x; } } + // Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs. + // Most displays will have even number of pixels in a row -- this will be in bounds + // of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.) + x_LastPixelUpdate |= 1; + // Ensure the last pixel index does not exceed the display width. + if (x_LastPixelUpdate >= displayWidth) { + x_LastPixelUpdate = displayWidth - 1; + } #if defined(HACKADAY_COMMUNICATOR) tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate], (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1); diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index 6b26abe7f..7a12650ca 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -408,7 +408,16 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, display->drawString(nameX, getTextPositions(display)[line++], device_role); // === Third Row: Radio Preset === - auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset); + // For custom modem settings show the actual parameters; for presets use the preset name. + char modeStr[16]; + if (!config.lora.use_preset) { + snprintf(modeStr, sizeof(modeStr), "BW%u-SF%u-CR%u", static_cast(config.lora.bandwidth), + static_cast(config.lora.spread_factor), static_cast(config.lora.coding_rate)); + } else { + strncpy(modeStr, DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true), + sizeof(modeStr) - 1); + modeStr[sizeof(modeStr) - 1] = '\0'; + } char regionradiopreset[25]; const char *region = myRegion ? myRegion->name : NULL; @@ -416,7 +425,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, if (currentResolution == ScreenResolution::UltraLow) { snprintf(regionradiopreset, sizeof(regionradiopreset), "%s", region); } else { - snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); + snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, modeStr); } } textWidth = display->getStringWidth(regionradiopreset); diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index a1d49946f..e92ba4839 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -18,6 +18,7 @@ #include "main.h" #include "mesh/Default.h" #include "mesh/MeshTypes.h" +#include "mesh/RadioLibInterface.h" #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/ExternalNotificationModule.h" @@ -25,6 +26,7 @@ #include "modules/TraceRouteModule.h" #include #include +#include #include #include @@ -159,31 +161,22 @@ void menuHandler::LoraRegionPicker(uint32_t duration) return; } + // Guard: without a reboot, reconfigure() applies the region directly. + // Reject LORA_24 on sub-GHz-only hardware — getRadio() used to catch this post-reboot. + // TODO: change this to either use the validateLoraConfig() logic or at least check the region for wideLora + // rather than a hardcoded check for LORA_24. + if (selectedRegion == meshtastic_Config_LoRaConfig_RegionCode_LORA_24 && + !(RadioLibInterface::instance && RadioLibInterface::instance->wideLora())) { + LOG_WARN("Radio hardware does not support 2.4 GHz; ignoring region selection"); + return; + } + config.lora.region = selectedRegion; auto changes = SEGMENT_CONFIG; - // FIXME: This should be a method consolidated with the same logic in the admin message as well - // This is needed as we wait til picking the LoRa region to generate keys for the first time. #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - // public key is derived from private, so this will always have the same result. - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif config.lora.tx_enabled = true; @@ -199,7 +192,6 @@ void menuHandler::LoraRegionPicker(uint32_t duration) } service->reloadConfig(changes); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); bannerOptions.durationMs = duration; @@ -265,13 +257,24 @@ void menuHandler::FrequencySlotPicker() optionsEnumArray[options++] = 0; // Calculate number of channels (copied from RadioInterface::applyModemConfig()) + meshtastic_Config_LoRaConfig &loraConfig = config.lora; double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora) : bwCodeToKHz(loraConfig.bandwidth); uint32_t numChannels = 0; if (myRegion) { - numChannels = (uint32_t)floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000.0))); + // Match RadioInterface::applyModemConfig(): include padding, add spacing in numerator, and use round() + const double spacing = myRegion->profile->spacing; + const double padding = myRegion->profile->padding; + const double channelBandwidthMHz = bw / 1000.0; + const double numerator = (myRegion->freqEnd - myRegion->freqStart) + spacing; + const double denominator = spacing + (padding * 2) + channelBandwidthMHz; + if (denominator > 0.0) { + numChannels = static_cast(round(numerator / denominator)); + } else { + LOG_WARN("Invalid region configuration: non-positive channel spacing/width"); + } } else { LOG_WARN("Region not set, cannot calculate number of channels"); return; @@ -307,7 +310,6 @@ void menuHandler::FrequencySlotPicker() config.lora.channel_num = selected; service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }; screen->showOverlayBanner(bannerOptions); @@ -346,7 +348,6 @@ void menuHandler::radioPresetPicker() config.lora.channel_num = 0; // Reset to default channel for the preset config.lora.override_frequency = 0; // Clear any custom frequency service->reloadConfig(SEGMENT_CONFIG); - rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000); }); screen->showOverlayBanner(bannerOptions); diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index 98644ee3b..654c27222 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -3,6 +3,9 @@ #include "CompassRenderer.h" #include "NodeDB.h" #include "NodeListRenderer.h" +#if !MESHTASTIC_EXCLUDE_STATUS +#include "modules/StatusMessageModule.h" +#endif #include "UIRenderer.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" // for getTime() function @@ -92,8 +95,41 @@ std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, // 1) Choose target candidate (long vs short) only if present const char *raw = nullptr; - if (node && node->has_user) { - raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; + +#if !MESHTASTIC_EXCLUDE_STATUS + // If long-name mode is enabled, and we have a recent status for this node, + // prefer "(short_name) statusText" as the raw candidate. + std::string composedFromStatus; + if (config.display.use_long_node_name && node && node->has_user && statusMessageModule) { + const auto &recent = statusMessageModule->getRecentReceived(); + const StatusMessageModule::RecentStatus *found = nullptr; + for (auto it = recent.rbegin(); it != recent.rend(); ++it) { + if (it->fromNodeId == node->num && !it->statusText.empty()) { + found = &(*it); + break; + } + } + + if (found) { + const char *shortName = node->user.short_name; + composedFromStatus.reserve(4 + (shortName ? std::strlen(shortName) : 0) + 1 + found->statusText.size()); + composedFromStatus += "("; + if (shortName && *shortName) { + composedFromStatus += shortName; + } + composedFromStatus += ") "; + composedFromStatus += found->statusText; + + raw = composedFromStatus.c_str(); // safe for now; we'll sanitize immediately into std::string + } + } +#endif + + // If we didn't compose from status, use normal long/short selection + if (!raw) { + if (node && node->has_user) { + raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name; + } } // 2) Preserve UTF-8 names so emotes can be detected and rendered. diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index e3a4d13a2..78d109881 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -5,6 +5,9 @@ #include "MeshService.h" #include "NodeDB.h" #include "NodeListRenderer.h" +#if !MESHTASTIC_EXCLUDE_STATUS +#include "modules/StatusMessageModule.h" +#endif #include "UIRenderer.h" #include "airtime.h" #include "gps/GeoCoord.h" @@ -290,7 +293,7 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes // * Favorite Node Info * // ********************** // cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library -void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { if (favoritedNodes.empty()) return; @@ -342,6 +345,57 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false); } +#if !MESHTASTIC_EXCLUDE_STATUS + // === Optional: Last received StatusMessage line for this node === + // Display it directly under the username line (if we have one). + if (statusMessageModule) { + const auto &recent = statusMessageModule->getRecentReceived(); + const StatusMessageModule::RecentStatus *found = nullptr; + + // Search newest-to-oldest + for (auto it = recent.rbegin(); it != recent.rend(); ++it) { + if (it->fromNodeId == node->num && !it->statusText.empty()) { + found = &(*it); + break; + } + } + + if (found) { + std::string statusLine = std::string(" Status: ") + found->statusText; + { + const int screenW = display->getWidth(); + const int ellipseW = display->getStringWidth("..."); + int w = display->getStringWidth(statusLine.c_str()); + + // Only do work if it overflows + if (w > screenW) { + bool truncated = false; + if (ellipseW > screenW) { + statusLine.clear(); + } else { + while (!statusLine.empty()) { + // remove one char (byte) at a time + statusLine.pop_back(); + truncated = true; + + // Measure candidate with ellipsis appended + std::string candidate = statusLine + "..."; + if (display->getStringWidth(candidate.c_str()) <= screenW) { + statusLine = std::move(candidate); + break; + } + } + if (statusLine.empty() && ellipseW <= screenW) { + statusLine = "..."; + } + } + } + } + display->drawString(x, getTextPositions(display)[line++], statusLine.c_str()); + } + } +#endif + // === 2. Signal and Hops (combined on one line, if available) === char signalHopsStr[32] = ""; bool haveSignal = false; diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index a0bb0d849..a705d944d 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -50,7 +50,7 @@ class UIRenderer // Navigation bar overlay static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state); - static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index b2ef1f714..d489d21ee 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -177,24 +177,8 @@ static void applyLoRaRegion(meshtastic_Config_LoRaConfig_RegionCode region) auto changes = SEGMENT_CONFIG; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); } #endif diff --git a/src/memGet.cpp b/src/memGet.cpp index 14e614014..42a3430f6 100644 --- a/src/memGet.cpp +++ b/src/memGet.cpp @@ -10,8 +10,26 @@ #include "memGet.h" #include "configuration.h" -#ifdef ARCH_STM32WL +#if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) #include +#include // sbrk + +#ifdef ARCH_STM32WL +// Returns the uncommitted sbrk headroom: addressable space between the current heap +// break and the stack pointer that has not yet been committed to the arena. +static uint32_t sbrkHeadroom() +{ + // defined in STM32 linker script + extern char _estack; + extern char _Min_Stack_Size; + + uint32_t max_sp = (uint32_t)(&_estack - &_Min_Stack_Size); + uint32_t heap_end = (uint32_t)sbrk(0); + return (max_sp > heap_end) ? (max_sp - heap_end) : 0; +} +#else +#error Unsupported architecture! +#endif #endif MemGet memGet; @@ -28,9 +46,9 @@ uint32_t MemGet::getFreeHeap() return dbgHeapFree(); #elif defined(ARCH_RP2040) return rp2040.getFreeHeap(); -#elif defined(ARCH_STM32WL) +#elif defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) // Currently: ARCH_STM32WL struct mallinfo m = mallinfo(); - return m.fordblks; // Total free space (bytes) + return m.fordblks + sbrkHeadroom(); // Free space within arena + uncommitted sbrk headroom #else // this platform does not have heap management function implemented return UINT32_MAX; @@ -49,9 +67,9 @@ uint32_t MemGet::getHeapSize() return dbgHeapTotal(); #elif defined(ARCH_RP2040) return rp2040.getTotalHeap(); -#elif defined(ARCH_STM32WL) +#elif defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) // Currently: ARCH_STM32WL struct mallinfo m = mallinfo(); - return m.arena; // Non-mmapped space allocated (bytes) + return m.arena + sbrkHeadroom(); // Non-mmapped space allocated + uncommitted sbrk headroom #else // this platform does not have heap management function implemented return UINT32_MAX; diff --git a/src/mesh/CryptoEngine.cpp b/src/mesh/CryptoEngine.cpp index 72216a63c..daa7a3a75 100644 --- a/src/mesh/CryptoEngine.cpp +++ b/src/mesh/CryptoEngine.cpp @@ -4,6 +4,7 @@ #include #if !(MESHTASTIC_EXCLUDE_PKI) +#include "HardwareRNG.h" #include "NodeDB.h" #include "aes-ccm.h" #include "meshUtils.h" @@ -26,6 +27,15 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey) { // Mix in any randomness we can, to make key generation stronger. CryptRNG.begin(optstr(APP_VERSION)); + + uint8_t hardwareEntropy[64] = {0}; + if (HardwareRNG::fill(hardwareEntropy, sizeof(hardwareEntropy), true)) { + CryptRNG.stir(hardwareEntropy, sizeof(hardwareEntropy)); + } else { + LOG_WARN("Hardware entropy unavailable, falling back to software RNG"); + } + memset(hardwareEntropy, 0, sizeof(hardwareEntropy)); + if (myNodeInfo.device_id.size == 16) { CryptRNG.stir(myNodeInfo.device_id.bytes, myNodeInfo.device_id.size); } @@ -61,6 +71,33 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey) } return true; } + +bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user) +{ + if (user.is_licensed) { + return false; + } + + bool keygenSuccess = false; + if (security.private_key.size == 32) { + if (regeneratePublicKey(security.public_key.bytes, security.private_key.bytes)) { + keygenSuccess = true; + } + } else { + LOG_INFO("Generate new PKI keys"); + generateKeyPair(security.public_key.bytes, security.private_key.bytes); + keygenSuccess = true; + } + + if (keygenSuccess) { + security.public_key.size = 32; + security.private_key.size = 32; + user.public_key.size = 32; + memcpy(user.public_key.bytes, security.public_key.bytes, 32); + } + + return keygenSuccess; +} #endif /** diff --git a/src/mesh/CryptoEngine.h b/src/mesh/CryptoEngine.h index 19d572355..f40400331 100644 --- a/src/mesh/CryptoEngine.h +++ b/src/mesh/CryptoEngine.h @@ -36,6 +36,7 @@ class CryptoEngine #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey); virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey); + virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user); #endif void setDHPrivateKey(uint8_t *_private_key); diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 2b6a42d9c..59425042e 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -30,6 +30,11 @@ #define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 + +// Traffic management defaults +#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells +#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions + #ifdef USERPREFS_RINGTONE_NAG_SECS #define default_ringtone_nag_secs USERPREFS_RINGTONE_NAG_SECS #else diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp new file mode 100644 index 000000000..f5a805487 --- /dev/null +++ b/src/mesh/HardwareRNG.cpp @@ -0,0 +1,160 @@ +#include "HardwareRNG.h" + +#include +#include +#include + +#include "configuration.h" + +#if HAS_RADIO +#include "RadioLibInterface.h" +#endif + +#if defined(ARCH_NRF52) +#include +extern Adafruit_nRFCrypto nRFCrypto; +#elif defined(ARCH_ESP32) +#include +#elif defined(ARCH_RP2040) +#include +#elif defined(ARCH_PORTDUINO) +#include +#include +#include +#endif + +namespace HardwareRNG +{ + +namespace +{ +void fillWithRandomDevice(uint8_t *buffer, size_t length) +{ + std::random_device rd; + size_t offset = 0; + while (offset < length) { + uint32_t value = rd(); + size_t toCopy = std::min(length - offset, sizeof(value)); + memcpy(buffer + offset, &value, toCopy); + offset += toCopy; + } +} + +#if HAS_RADIO +bool mixWithLoRaEntropy(uint8_t *buffer, size_t length) +{ + // Only attempt to pull entropy from the modem if it is initialized and exposes the helper. + // When the radio stack is disabled or has not yet been configured, we simply skip this step + // and return false so callers know no extra mixing occurred. + RadioLibInterface *radio = RadioLibInterface::instance; + if (!radio) { + // Intentionally silent: this path runs during portduinoSetup() before the + // console/SerialConsole is initialized, so LOG_* here would dereference a null pointer. + return false; + } + + constexpr size_t chunkSize = 16; + uint8_t scratch[chunkSize]; + size_t offset = 0; + bool mixed = false; + + while (offset < length) { + size_t toCopy = std::min(length - offset, chunkSize); + + // randomBytes() returns false if the modem does not support it or is not ready + // (for instance, when the radio is powered down). We break immediately to avoid + // blocking or returning partially-filled entropy and simply report failure. + if (!radio->randomBytes(scratch, toCopy)) { + break; + } + + for (size_t i = 0; i < toCopy; ++i) { + buffer[offset + i] ^= scratch[i]; + } + + mixed = true; + offset += toCopy; + } + + // Avoid leaving the modem-sourced bytes sitting on the stack longer than needed. + if (mixed) { + memset(scratch, 0, sizeof(scratch)); + } + + return mixed; +} +#endif +} // namespace + +bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) +{ + if (!buffer || length == 0) { + return false; + } + + bool filled = false; + +#if defined(ARCH_NRF52) + // The Nordic SDK RNG provides cryptographic-quality randomness backed by hardware. + nRFCrypto.begin(); + auto result = nRFCrypto.Random.generate(buffer, length); + nRFCrypto.end(); + filled = result; +#elif defined(ARCH_ESP32) + // ESP32 exposes a true RNG via esp_fill_random(). + esp_fill_random(buffer, length); + filled = true; +#elif defined(ARCH_RP2040) + // RP2040 has a hardware random number generator accessible through the Arduino core. + size_t offset = 0; + while (offset < length) { + uint32_t value = rp2040.hwrand32(); + size_t toCopy = std::min(length - offset, sizeof(value)); + memcpy(buffer + offset, &value, toCopy); + offset += toCopy; + } + filled = true; +#elif defined(ARCH_PORTDUINO) + // Prefer the host OS RNG first when running under Portduino. + ssize_t generated = ::getrandom(buffer, length, 0); + if (generated == static_cast(length)) { + filled = true; + } + + if (!filled) { + fillWithRandomDevice(buffer, length); + filled = true; + } +#endif + + if (!filled) { + // As a last resort, fall back to std::random_device. This should only be reached + // if a platform-specific source was unavailable. + fillWithRandomDevice(buffer, length); + filled = true; + } + +#if HAS_RADIO + if (useRadioEntropy) { + // Best-effort: if the radio is active and can provide modem entropy, XOR it over the + // buffer to improve overall quality. We consider the filling a success if either a + // good platform RNG or the modem RNG provided data, so we return true as long as at + // least one of those steps succeeded. + filled = mixWithLoRaEntropy(buffer, length) || filled; + } +#endif + + return filled; +} + +bool seed(uint32_t &seedOut) +{ + uint32_t candidate = 0; + if (!fill(reinterpret_cast(&candidate), sizeof(candidate), true)) { + return false; + } + seedOut = candidate; + return true; +} + +} // namespace HardwareRNG diff --git a/src/mesh/HardwareRNG.h b/src/mesh/HardwareRNG.h new file mode 100644 index 000000000..2dacb6c23 --- /dev/null +++ b/src/mesh/HardwareRNG.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include + +namespace HardwareRNG +{ + +/** + * Fill the provided buffer with random bytes sourced from the most + * appropriate hardware-backed RNG available on the current platform. + * + * @param buffer Destination buffer for random bytes + * @param length Number of bytes to write + * @param useRadioEntropy If true, attempt to mix radio entropy into the output as well. + * @return true if the buffer was fully populated with entropy, false on failure + */ +bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy = false); + +/** + * Populate a 32-bit seed value with hardware-backed randomness where possible. + * + * @param seedOut Destination for the generated seed value + * @return true if a seed was produced from a reliable entropy source + */ +bool seed(uint32_t &seedOut); + +} // namespace HardwareRNG diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 4fec06da4..7a193f7f3 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -71,12 +71,10 @@ template bool LR11x0Interface::init() RadioLibInterface::init(); - limitPower(LR1110_MAX_POWER); - - if ((power > LR1120_MAX_POWER) && - (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) { // clamp again if wide freq range - power = LR1120_MAX_POWER; - preambleLength = 12; // 12 is the default for operation above 2GHz + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR1120_MAX_POWER); + } else { + limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range } #ifdef LR11X0_RF_SWITCH_SUBGHZ @@ -177,6 +175,12 @@ template bool LR11x0Interface::reconfigure() err = lora.setSyncWord(syncWord); assert(err == RADIOLIB_ERR_NONE); + if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { // clamp if wide freq range + limitPower(LR1120_MAX_POWER); + } else { + limitPower(LR1110_MAX_POWER); // default clamp for non-wide freq range + } + err = lora.setPreambleLength(preambleLength); assert(err == RADIOLIB_ERR_NONE); @@ -184,14 +188,14 @@ template bool LR11x0Interface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > LR1110_MAX_POWER) // This chip has lower power limits than some - power = LR1110_MAX_POWER; - if ((power > LR1120_MAX_POWER) && (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_LORA_24)) // 2.4G power limit - power = LR1120_MAX_POWER; - err = lora.setOutputPower(power); assert(err == RADIOLIB_ERR_NONE); + // Apply RX gain mode — valid in STDBY, matches resetAGC() pattern + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("LR11x0 setRxBoostedGainMode %s%d", radioLibErr, err); + startReceive(); // restart receiving return RADIOLIB_ERR_NONE; diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 07d956878..089b4b189 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -4,19 +4,54 @@ #include "MeshTypes.h" #include "PointerQueue.h" #include "configuration.h" +#include "detect/LoRaRadioType.h" + +// Sentinel marking the end of a modem preset array +static constexpr meshtastic_Config_LoRaConfig_ModemPreset MODEM_PRESET_END = + static_cast(0xFF); + +// Region profile: bundles the preset list with regulatory parameters shared across regions +struct RegionProfile { + const meshtastic_Config_LoRaConfig_ModemPreset *presets; // sentinel-terminated; first entry is the default + float spacing; // gaps between radio channels + float padding; // padding at each side of the "operating channel" + bool audioPermitted; + bool licensedOnly; // a region profile for licensed operators only + int8_t textThrottle; // throttle for text - future expansion + int8_t positionThrottle; // throttle for location data - future expansion + int8_t telemetryThrottle; // throttle for telemetry - future expansion + uint8_t overrideSlot; // a per-region override slot for if we need to fix it in place +}; + +extern const RegionProfile PROFILE_STD; +extern const RegionProfile PROFILE_EU868; +extern const RegionProfile PROFILE_UNDEF; +// extern const RegionProfile PROFILE_LITE; +// extern const RegionProfile PROFILE_NARROW; +// extern const RegionProfile PROFILE_HAM; // Map from old region names to new region enums struct RegionInfo { meshtastic_Config_LoRaConfig_RegionCode code; float freqStart; float freqEnd; - float dutyCycle; - float spacing; + float dutyCycle; // modified by getEffectiveDutyCycle uint8_t powerLimit; // Or zero for not set - bool audioPermitted; bool freqSwitching; bool wideLora; + const RegionProfile *profile; const char *name; // EU433 etc + + // Preset accessors (delegate through profile) + meshtastic_Config_LoRaConfig_ModemPreset getDefaultPreset() const { return profile->presets[0]; } + const meshtastic_Config_LoRaConfig_ModemPreset *getAvailablePresets() const { return profile->presets; } + size_t getNumPresets() const + { + size_t n = 0; + while (profile->presets[n] != MODEM_PRESET_END) + n++; + return n; + } }; extern const RegionInfo regions[]; @@ -25,7 +60,7 @@ extern const RegionInfo *myRegion; extern void initRegion(); // Valid LoRa spread factor range and defaults -constexpr uint8_t LORA_SF_MIN = 7; +constexpr uint8_t LORA_SF_MIN = 5; constexpr uint8_t LORA_SF_MAX = 12; constexpr uint8_t LORA_SF_DEFAULT = 11; // LONG_FAST default @@ -37,10 +72,14 @@ constexpr uint8_t LORA_CR_DEFAULT = 5; // LONG_FAST default // Default bandwidth in kHz (LONG_FAST) constexpr float LORA_BW_DEFAULT_KHZ = 250.0f; -/// Clamp spread factor to the valid LoRa range [7, 12]. +/// Clamp spread factor to the valid LoRa range [5, 12]. /// Out-of-range values (including 0 from unset preset mode) return LORA_SF_DEFAULT. static inline uint8_t clampSpreadFactor(uint8_t sf) { + // We check for RF95 radios that are incompatible with Spreading Factors 5 and 6. + if (radioType == RF95_RADIO && (sf == 5 || sf == 6)) + return LORA_SF_DEFAULT; + if (sf < LORA_SF_MIN || sf > LORA_SF_MAX) return LORA_SF_DEFAULT; return sf; diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 49b54d181..13f948a7b 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -4,6 +4,9 @@ #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" #endif +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "NodeDB.h" NextHopRouter::NextHopRouter() {} @@ -126,15 +129,28 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast /* Check if we should be rebroadcasting this packet if so, do so. */ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) { - if (!isToUs(p) && !isFromUs(p) && p->hop_limit > 0) { + // Check if traffic management wants to exhaust this packet's hops + bool exhaustHops = false; +#if HAS_TRAFFIC_MANAGEMENT + if (trafficManagementModule && trafficManagementModule->shouldExhaustHops(*p)) { + exhaustHops = true; + } +#endif + + // Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay) + if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) { if (p->id != 0) { if (isRebroadcaster()) { if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) { meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it LOG_INFO("Rebroadcast received message coming from %x", p->relay_node); - // Use shared logic to determine if hop_limit should be decremented - if (shouldDecrementHopLimit(p)) { + // If exhausting hops, force hop_limit = 0 regardless of other logic + if (exhaustHops) { + tosend->hop_limit = 0; + LOG_INFO("Traffic management: exhausting hops for 0x%08x, setting hop_limit=0", getFrom(p)); + } else if (shouldDecrementHopLimit(p)) { + // Use shared logic to determine if hop_limit should be decremented tosend->hop_limit--; // bump down the hop count } else { LOG_INFO("favorite-ROUTER/CLIENT_BASE-to-ROUTER/CLIENT_BASE rebroadcast: preserving hop_limit"); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 49d56512e..4b0871566 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1299,7 +1299,7 @@ void NodeDB::loadFromDisk() // Coerce LoRa config fields derived from presets while bootstrapping. // Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode. if (config.has_lora && config.lora.use_preset) { - RadioInterface::bootstrapLoRaConfigFromPreset(config.lora); + RadioInterface::clampConfigLora(config.lora); } #if defined(USERPREFS_LORA_TX_DISABLED) && USERPREFS_LORA_TX_DISABLED @@ -1650,6 +1650,25 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p) return delta; } +HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p) +{ + // Guard against invalid values. + if (p.hop_start < p.hop_limit) + return HopStartStatus::INVALID; + + if (p.hop_start == 0) { + // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a + // bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware + // version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as + // the bitfield is encrypted under the channel encryption key. + if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield) + return HopStartStatus::VALID; + return HopStartStatus::MISSING_OR_UNKNOWN; + } + + return HopStartStatus::VALID; +} + int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown) { // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a @@ -1687,6 +1706,22 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly) #include "MeshModule.h" #include "Throttle.h" +static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000; + +void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) +{ + static uint32_t lastLogMs = 0; + if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) { + return; + } + lastLogMs = millis(); + const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag); + const bool hasBitfield = decoded && p.decoded.has_bitfield; + LOG_DEBUG( + "Drop packet (%s): hop_start invalid/missing (from=0x%x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)", + context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield); +} + /** Update position info for this node based on received position data */ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src) diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index adf2b42ea..f6be963c1 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -114,6 +114,27 @@ uint32_t sinceReceived(const meshtastic_MeshPacket *p); /// Returns defaultIfUnknown if the number of hops couldn't be determined. int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1); +enum class HopStartStatus : uint8_t { VALID = 0, MISSING_OR_UNKNOWN, INVALID }; + +/// Classify hop_start validity for forwarding decisions. +HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p); + +inline bool shouldDropPacketForPreHop(const meshtastic_MeshPacket &p) +{ +#if !MESHTASTIC_PREHOP_DROP + (void)p; + return false; +#else + if (isFromUs(&p)) { + return false; // local-originated packets should never be dropped by pre-hop drop policy + } + return classifyHopStart(p) != HopStartStatus::VALID; +#endif +} + +/// Rate-limited debug log when hop_start is invalid/missing and packet is dropped. +void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context); + enum LoadFileResult { // Successfully opened the file LOAD_SUCCESS = 1, diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index a02f96ac5..7df6720a2 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -449,6 +449,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter; break; + case meshtastic_ModuleConfig_traffic_management_tag: + LOG_DEBUG("Send module config: traffic management"); + fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; + fromRadioScratch.moduleConfig.payload_variant.traffic_management = moduleConfig.traffic_management; + break; default: LOG_ERROR("Unknown module config type %d", config_state); } diff --git a/src/mesh/PointerQueue.h b/src/mesh/PointerQueue.h index b45245eb8..972eb65fd 100644 --- a/src/mesh/PointerQueue.h +++ b/src/mesh/PointerQueue.h @@ -17,14 +17,4 @@ template class PointerQueue : public TypedQueue return this->dequeue(&p, maxWait) ? p : nullptr; } - -#ifdef HAS_FREE_RTOS - // returns a ptr or null if the queue was empty - T *dequeuePtrFromISR(BaseType_t *higherPriWoken) - { - T *p; - - return this->dequeueFromISR(&p, higherPriWoken) ? p : nullptr; - } -#endif }; diff --git a/src/mesh/RF95Interface.cpp b/src/mesh/RF95Interface.cpp index b3aa72f7a..32c92de93 100644 --- a/src/mesh/RF95Interface.cpp +++ b/src/mesh/RF95Interface.cpp @@ -240,8 +240,7 @@ bool RF95Interface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > RF95_MAX_POWER) // This chip has lower power limits than some - power = RF95_MAX_POWER; + limitPower(RF95_MAX_POWER); #ifdef USE_RF95_RFO err = lora->setOutputPower(power, true); diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 6a8a0230a..b7b672d8b 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #ifdef ARCH_PORTDUINO #include "platform/portduino/PortduinoGlue.h" @@ -32,10 +33,32 @@ #include "STM32WLE5JCInterface.h" #endif -#define RDEF(name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, frequency_switching, wide_lora) \ +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_STD[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_868[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, MODEM_PRESET_END}; + +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_UNDEF[] = {meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + MODEM_PRESET_END}; + +// Region profiles: bundle preset list + regulatory parameters shared across regions +// presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle, override slot +const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_EU868 = {PRESETS_EU_868, 0, 0, false, false, 0, 0, 0, 0}; +const RegionProfile PROFILE_UNDEF = {PRESETS_UNDEF, 0, 0, true, false, 0, 0, 0, 0}; + +#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr) \ { \ - meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, spacing, power_limit, audio_permitted, \ - frequency_switching, wide_lora, #name \ + meshtastic_Config_LoRaConfig_RegionCode_##name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, \ + wide_lora, &profile_ptr, #name \ } const RegionInfo regions[] = { @@ -43,7 +66,7 @@ const RegionInfo regions[] = { https://link.springer.com/content/pdf/bbm%3A978-1-4842-4357-2%2F1.pdf https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ */ - RDEF(US, 902.0f, 928.0f, 100, 0, 30, true, false, false), + RDEF(US, 902.0f, 928.0f, 100, 30, false, false, PROFILE_STD), /* EN300220 ETSI V3.2.1 [Table B.1, Item H, p. 21] @@ -51,8 +74,7 @@ const RegionInfo regions[] = { https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.02.01_60/en_30022002v030201p.pdf FIXME: https://github.com/meshtastic/firmware/issues/3371 */ - RDEF(EU_433, 433.0f, 434.0f, 10, 0, 10, true, false, false), - + RDEF(EU_433, 433.0f, 434.0f, 10, 10, false, false, PROFILE_STD), /* https://www.thethingsnetwork.org/docs/lorawan/duty-cycle/ https://www.thethingsnetwork.org/docs/lorawan/regional-parameters/ @@ -67,33 +89,33 @@ const RegionInfo regions[] = { AFA) to avoid a duty cycle. (Please refer to line P page 22 of this document.) https://www.etsi.org/deliver/etsi_en/300200_300299/30022002/03.01.01_60/en_30022002v030101p.pdf */ - RDEF(EU_868, 869.4f, 869.65f, 10, 0, 27, false, false, false), + RDEF(EU_868, 869.4f, 869.65f, 10, 27, false, false, PROFILE_EU868), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(CN, 470.0f, 510.0f, 100, 0, 19, true, false, false), + RDEF(CN, 470.0f, 510.0f, 100, 19, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://www.arib.or.jp/english/html/overview/doc/5-STD-T108v1_5-E1.pdf https://qiita.com/ammo0613/items/d952154f1195b64dc29f */ - RDEF(JP, 920.5f, 923.5f, 100, 0, 13, true, false, false), + RDEF(JP, 920.5f, 923.5f, 100, 13, false, false, PROFILE_STD), /* https://www.iot.org.au/wp/wp-content/uploads/2016/12/IoTSpectrumFactSheet.pdf https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf Also used in Brazil. */ - RDEF(ANZ, 915.0f, 928.0f, 100, 0, 30, true, false, false), + RDEF(ANZ, 915.0f, 928.0f, 100, 30, false, false, PROFILE_STD), /* 433.05 - 434.79 MHz, 25mW EIRP max, No duty cycle restrictions AU Low Interference Potential https://www.acma.gov.au/licences/low-interference-potential-devices-lipd-class-licence NZ General User Radio Licence for Short Range Devices https://gazette.govt.nz/notice/id/2022-go3100 */ - RDEF(ANZ_433, 433.05f, 434.79f, 100, 0, 14, true, false, false), + RDEF(ANZ_433, 433.05f, 434.79f, 100, 14, false, false, PROFILE_STD), /* https://digital.gov.ru/uploaded/files/prilozhenie-12-k-reshenyu-gkrch-18-46-03-1.pdf @@ -101,13 +123,13 @@ const RegionInfo regions[] = { Note: - We do LBT, so 100% is allowed. */ - RDEF(RU, 868.7f, 869.2f, 100, 0, 20, true, false, false), + RDEF(RU, 868.7f, 869.2f, 100, 20, false, false, PROFILE_STD), /* https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulId=53943&efYd=0 https://resources.lora-alliance.org/technical-specifications/rp002-1-0-4-regional-parameters */ - RDEF(KR, 920.0f, 923.0f, 100, 0, 23, true, false, false), + RDEF(KR, 920.0f, 923.0f, 100, 23, false, false, PROFILE_STD), /* Taiwan, 920-925Mhz, limited to 0.5W indoor or coastal, 1.0W outdoor. @@ -115,44 +137,40 @@ const RegionInfo regions[] = { https://www.ncc.gov.tw/english/files/23070/102_5190_230703_1_doc_C.PDF https://gazette.nat.gov.tw/egFront/e_detail.do?metaid=147283 */ - RDEF(TW, 920.0f, 925.0f, 100, 0, 27, true, false, false), + RDEF(TW, 920.0f, 925.0f, 100, 27, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf */ - RDEF(IN, 865.0f, 867.0f, 100, 0, 30, true, false, false), + RDEF(IN, 865.0f, 867.0f, 100, 30, false, false, PROFILE_STD), /* https://rrf.rsm.govt.nz/smart-web/smart/page/-smart/domain/licence/LicenceSummary.wdk?id=219752 https://iotalliance.org.nz/wp-content/uploads/sites/4/2019/05/IoT-Spectrum-in-NZ-Briefing-Paper.pdf */ - RDEF(NZ_865, 864.0f, 868.0f, 100, 0, 36, true, false, false), + RDEF(NZ_865, 864.0f, 868.0f, 100, 36, false, false, PROFILE_STD), /* https://lora-alliance.org/wp-content/uploads/2020/11/lorawan_regional_parameters_v1.0.3reva_0.pdf https://standard.nbtc.go.th/getattachment/Standards/%E0%B8%A1%E0%B8%B2%E0%B8%95%E0%B8%A3%E0%B8%90%E0%B8%B2%E0%B8%99%E0%B8%97%E0%B8%B2%E0%B8%87%E0%B9%80%E0%B8%97%E0%B8%84%E0%B8%99%E0%B8%B4%E0%B8%84%E0%B8%82%E0%B8%AD%E0%B8%87%E0%B9%80%E0%B8%84%E0%B8%A3%E0%B8%B7%E0%B9%88%E0%B8%AD%E0%B8%87%E0%B9%82%E0%B8%97%E0%B8%A3%E0%B8%84%E0%B8%A1%E0%B8%99%E0%B8%B2%E0%B8%84%E0%B8%A1/1033-2565.pdf.aspx?lang=th-TH Thailand 920–925 MHz set max TX power to 27 dBm and enforce 10% duty cycle, aligned with NBTC regulations. */ - RDEF(TH, 920.0f, 925.0f, 10, 0, 27, true, false, false), + RDEF(TH, 920.0f, 925.0f, 10, 27, false, false, PROFILE_STD), /* 433,05-434,7 Mhz 10 mW - https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf - */ - RDEF(UA_433, 433.0f, 434.7f, 10, 0, 10, true, false, false), - - /* 868,0-868,6 Mhz 25 mW https://nkrzi.gov.ua/images/upload/256/5810/PDF_UUZ_19_01_2016.pdf */ - RDEF(UA_868, 868.0f, 868.6f, 1, 0, 14, true, false, false), + RDEF(UA_433, 433.0f, 434.7f, 10, 10, false, false, PROFILE_STD), + RDEF(UA_868, 868.0f, 868.6f, 1, 14, false, false, PROFILE_STD), /* Malaysia 433 - 435 MHz at 100mW, no restrictions. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_433, 433.0f, 435.0f, 100, 0, 20, true, false, false), + RDEF(MY_433, 433.0f, 435.0f, 100, 20, false, false, PROFILE_STD), /* Malaysia @@ -161,14 +179,14 @@ const RegionInfo regions[] = { Frequency hopping is used for 919 - 923 MHz. https://www.mcmc.gov.my/skmmgovmy/media/General/pdf/Short-Range-Devices-Specification.pdf */ - RDEF(MY_919, 919.0f, 924.0f, 100, 0, 27, true, true, false), + RDEF(MY_919, 919.0f, 924.0f, 100, 27, true, false, PROFILE_STD), /* Singapore SG_923 Band 30d: 917 - 925 MHz at 100mW, no restrictions. https://www.imda.gov.sg/-/media/imda/files/regulation-licensing-and-consultations/ict-standards/telecommunication-standards/radio-comms/imdatssrd.pdf */ - RDEF(SG_923, 917.0f, 925.0f, 100, 0, 20, true, false, false), + RDEF(SG_923, 917.0f, 925.0f, 100, 20, false, false, PROFILE_STD), /* Philippines @@ -178,8 +196,9 @@ const RegionInfo regions[] = { https://github.com/meshtastic/firmware/issues/4948#issuecomment-2394926135 */ - RDEF(PH_433, 433.0f, 434.7f, 100, 0, 10, true, false, false), RDEF(PH_868, 868.0f, 869.4f, 100, 0, 14, true, false, false), - RDEF(PH_915, 915.0f, 918.0f, 100, 0, 24, true, false, false), + RDEF(PH_433, 433.0f, 434.7f, 100, 10, false, false, PROFILE_STD), + RDEF(PH_868, 868.0f, 869.4f, 100, 14, false, false, PROFILE_STD), + RDEF(PH_915, 915.0f, 918.0f, 100, 24, false, false, PROFILE_STD), /* Kazakhstan @@ -187,37 +206,38 @@ const RegionInfo regions[] = { 863 - 868 MHz <25 mW EIRP, 500kHz channels allowed, must not be used at airfields https://github.com/meshtastic/firmware/issues/7204 */ - RDEF(KZ_433, 433.075f, 434.775f, 100, 0, 10, true, false, false), - RDEF(KZ_863, 863.0f, 868.0f, 100, 0, 30, true, false, false), + RDEF(KZ_433, 433.075f, 434.775f, 100, 10, false, false, PROFILE_STD), + RDEF(KZ_863, 863.0f, 868.0f, 100, 30, false, false, PROFILE_STD), /* Nepal 865 MHz to 868 MHz frequency band for IoT (Internet of Things), M2M (Machine-to-Machine), and smart metering use, specifically in non-cellular mode. https://www.nta.gov.np/uploads/contents/Radio-Frequency-Policy-2080-English.pdf */ - RDEF(NP_865, 865.0f, 868.0f, 100, 0, 30, true, false, false), + RDEF(NP_865, 865.0f, 868.0f, 100, 30, false, false, PROFILE_STD), /* Brazil 902 - 907.5 MHz , 1W power limit, no duty cycle restrictions https://github.com/meshtastic/firmware/issues/3741 */ - RDEF(BR_902, 902.0f, 907.5f, 100, 0, 30, true, false, false), + RDEF(BR_902, 902.0f, 907.5f, 100, 30, false, false, PROFILE_STD), /* 2.4 GHZ WLAN Band equivalent. Only for SX128x chips. */ - RDEF(LORA_24, 2400.0f, 2483.5f, 100, 0, 10, true, false, true), + RDEF(LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, PROFILE_STD), /* This needs to be last. Same as US. */ - RDEF(UNSET, 902.0f, 928.0f, 100, 0, 30, true, false, false) + RDEF(UNSET, 902.0f, 928.0f, 100, 30, false, false, PROFILE_UNDEF) }; const RegionInfo *myRegion; bool RadioInterface::uses_default_frequency_slot = true; +bool RadioInterface::uses_custom_channel_name = false; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1]; @@ -503,45 +523,14 @@ void initRegion() myRegion = r; } -void RadioInterface::bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig) +const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code) { - if (!loraConfig.use_preset) { - return; - } - - // Find region info to determine whether "wide" LoRa is permitted (2.4 GHz uses wider bandwidth codes). const RegionInfo *r = regions; - for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != loraConfig.region; r++) + for (; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && r->code != code; r++) ; - - const bool regionWideLora = r->wideLora; - - float bwKHz = 0; - uint8_t sf = 0; - uint8_t cr = 0; - modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr); - - // If selected preset requests a bandwidth larger than the region span, fall back to LONG_FAST. - if (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET && (r->freqEnd - r->freqStart) < (bwKHz / 1000.0f)) { - loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - modemPresetToParams(loraConfig.modem_preset, regionWideLora, bwKHz, sf, cr); - } - - loraConfig.bandwidth = bwKHzToCode(bwKHz); - loraConfig.spread_factor = sf; + return r; } -/** - * ## LoRaWAN for North America - -LoRaWAN defines 64, 125 kHz channels from 902.3 to 914.9 MHz increments. - -The maximum output power for North America is +30 dBM. - -The band is from 902 to 928 MHz. It mentions channel number and its respective channel frequency. All the 13 channels are -separated by 2.16 MHz with respect to the adjacent channels. Channel zero starts at 903.08 MHz center frequency. -*/ - uint32_t RadioInterface::getPacketTime(const meshtastic_MeshPacket *p, bool received) { uint32_t pl = 0; @@ -751,7 +740,7 @@ void RadioInterface::saveFreq(float freq) } /** - * Save our channel for later reuse. + * Save our frequency slot (aka channel) for later reuse. */ void RadioInterface::saveChannelNum(uint32_t channel_num) { @@ -774,113 +763,304 @@ uint32_t RadioInterface::getChannelNum() return savedChannelNum; } +/** + * Send an error-level client notification. Safe to call when service is null (e.g. in tests). + */ +static void sendErrorNotification(const char *msg) +{ + if (!service) + return; + meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); + if (!cn) + return; + cn->level = meshtastic_LogRecord_Level_ERROR; + snprintf(cn->message, sizeof(cn->message), "%s", msg); + service->sendClientNotification(cn); +} + +/** + * Checks if a region is valid for the current settings. + * Returns false if not compatible. + */ +bool RadioInterface::validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig) +{ + const RegionInfo *newRegion = getRegion(loraConfig.region); + + // If you are not licensed, you can't use ham regions. + if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { + char err_string[160]; + snprintf(err_string, sizeof(err_string), "Region %s requires licensed mode", newRegion->name); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + return false; + } + + return true; +} + +/** + * Internal helper: validate or clamp a LoRa config against its region. + * When clamp==false, returns false on first error (pure validation). + * When clamp==true, fixes invalid settings in-place and returns true. + */ +bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp) +{ + char err_string[160]; + float check_bw; + + const RegionInfo *newRegion = getRegion(loraConfig.region); + + const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + + // Check preset validity (only when use_preset is true) + if (loraConfig.use_preset) { + check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + + bool preset_valid = false; + for (size_t i = 0; i < newRegion->getNumPresets(); i++) { + if (loraConfig.modem_preset == newRegion->getAvailablePresets()[i]) { + preset_valid = true; + break; + } + } + if (!preset_valid) { + const char *defaultName = DisplayFormatters::getModemPresetDisplayName(newRegion->getDefaultPreset(), false, true); + if (clamp) { + snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s, using %s", presetName, newRegion->name, + defaultName); + } else { + snprintf(err_string, sizeof(err_string), "Preset %s invalid for %s", presetName, newRegion->name); + } + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + loraConfig.modem_preset = newRegion->getDefaultPreset(); + check_bw = modemPresetToBwKHz(loraConfig.modem_preset, newRegion->wideLora); + } else { + return false; + } + } + } else { + check_bw = bwCodeToKHz(loraConfig.bandwidth); + } + + // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region: + // spacing = gap between slots (0 for continuous spectrum) and at the beginning of the band + // padding = gap at the beginning and end of the slots (0 for no padding) + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); + + // Check if the region supports the requested bandwidth + if ((newRegion->freqEnd - newRegion->freqStart) < freqSlotWidth) { + const float regionSpanKHz = (newRegion->freqEnd - newRegion->freqStart) * 1000.0f; + snprintf(err_string, sizeof(err_string), "%s span %.0fkHz < requested %.0fkHz", newRegion->name, regionSpanKHz, check_bw); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + loraConfig.bandwidth = bwKHzToCode(modemPresetToBwKHz(newRegion->getDefaultPreset(), newRegion->wideLora)); + check_bw = bwCodeToKHz(loraConfig.bandwidth); + + // Recompute slot width and number of slots based on the new bandwidth + freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (check_bw / 1000); // in MHz + numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); + } else { + return false; + } + } + + const char *channelName = channels.getName(channels.getPrimaryIndex()); + const char *presetNameDisplay = + DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; + uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots; + + if (loraConfig.override_frequency == 0) { + + // Check if we use the default frequency slot + uses_default_frequency_slot = + (loraConfig.channel_num == 0) || // user choice unset, no frequency override, so use default + (newRegion->profile->overrideSlot != 0 && + loraConfig.channel_num == newRegion->profile->overrideSlot) || // user setting matches override + ((newRegion->profile->overrideSlot == 0) && + ((uint32_t)(loraConfig.channel_num - 1) == presetNameHashSlot)); // user setting matches preset hash, no override + + // check if user setting different to preset name + uses_custom_channel_name = (strcmp(channelName, presetNameDisplay) != 0); + + if (loraConfig.channel_num > numFreqSlots) { + snprintf(err_string, sizeof(err_string), "Channel number %u invalid for %s, max is %u", loraConfig.channel_num, + newRegion->name, numFreqSlots); + LOG_ERROR("%s", err_string); + RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); + sendErrorNotification(err_string); + + if (clamp) { + if (uses_custom_channel_name) { // clamp to channel name hash + loraConfig.channel_num = + channelNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + } else if ((loraConfig.use_preset) && (newRegion->profile->overrideSlot != 0)) { // clamp to preset override slot + loraConfig.channel_num = + newRegion->profile->overrideSlot; // use the override slot specified by the region profile + uses_default_frequency_slot = true; + } else if (loraConfig.use_preset) { // clamp to preset slot + loraConfig.channel_num = presetNameHashSlot + 1; // channel_num is 1-based, but hash slot is 0-based, so add 1 + uses_default_frequency_slot = true; + } else { // if not using preset, and no custom channel name, just clamp to default anyway + uses_default_frequency_slot = true; + }; + } else { + return false; + } + } // end of channel number check + } else { + // if we have a frequency override, we ignore the channel number and just use the override frequency + snprintf(err_string, sizeof(err_string), "Frequency override in place, using %.3f", loraConfig.override_frequency); + } + return true; +} + +bool RadioInterface::validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig) +{ + auto copy = loraConfig; + return checkOrClampConfigLora(copy, false); +} + +void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig) +{ + checkOrClampConfigLora(loraConfig, true); +} + /** * Pull our channel settings etc... from protobufs to the dumb interface settings + * Note: this must be given only settings which have been validated or clamped! */ void RadioInterface::applyModemConfig() { // Set up default configuration // No Sync Words in LORA mode meshtastic_Config_LoRaConfig &loraConfig = config.lora; - bool validConfig = false; // We need to check for a valid configuration - while (!validConfig) { - if (loraConfig.use_preset) { - modemPresetToParams(loraConfig.modem_preset, myRegion->wideLora, bw, sf, cr); - if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate != cr) { - cr = loraConfig.coding_rate; - LOG_INFO("Using custom Coding Rate %u", cr); - } - } else { - sf = loraConfig.spread_factor; + const RegionInfo *newRegion = getRegion(loraConfig.region); + myRegion = newRegion; + + if (loraConfig.use_preset) { + if (!validateConfigLora(loraConfig)) { + loraConfig.modem_preset = newRegion->getDefaultPreset(); + } + uint8_t newcr; + modemPresetToParams(loraConfig.modem_preset, newRegion->wideLora, bw, sf, newcr); + // If custom CR is being used already, check if the new preset is higher + if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate < newcr) { + cr = newcr; + LOG_INFO("Default Coding Rate is higher than custom setting, using %u", cr); + } + // If the custom CR is higher than the preset, use it + else if (loraConfig.coding_rate >= 5 && loraConfig.coding_rate <= 8 && loraConfig.coding_rate > newcr) { cr = loraConfig.coding_rate; - bw = bwCodeToKHz(loraConfig.bandwidth); - } - - if ((myRegion->freqEnd - myRegion->freqStart) < bw / 1000) { - const float regionSpanKHz = (myRegion->freqEnd - myRegion->freqStart) * 1000.0f; - const float requestedBwKHz = bw; - const bool isWideRequest = requestedBwKHz >= 499.5f; // treat as 500 kHz preset - const char *presetName = - DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); - - char err_string[160]; - if (isWideRequest) { - snprintf(err_string, sizeof(err_string), "%s region too narrow for 500kHz preset (%s). Falling back to LongFast.", - myRegion->name, presetName); - } else { - snprintf(err_string, sizeof(err_string), "%s region span %.0fkHz < requested %.0fkHz. Falling back to LongFast.", - myRegion->name, regionSpanKHz, requestedBwKHz); - } - LOG_ERROR("%s", err_string); - RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - - meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); - cn->level = meshtastic_LogRecord_Level_ERROR; - snprintf(cn->message, sizeof(cn->message), "%s", err_string); - service->sendClientNotification(cn); - - // Set to default modem preset - loraConfig.use_preset = true; - loraConfig.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + LOG_INFO("Using custom Coding Rate %u", cr); } else { - validConfig = true; + cr = newcr; } + + } else { // if not using preset, then just use the custom settings + if (validateConfigLora(loraConfig)) { + } else { + LOG_WARN("Invalid LoRa config settings, cannot apply requested modem config - falling back to %s defaults", + newRegion->name); + clampConfigLora(loraConfig); + } + bw = bwCodeToKHz(loraConfig.bandwidth); + sf = loraConfig.spread_factor; + cr = loraConfig.coding_rate; } power = loraConfig.tx_power; - if ((power == 0) || ((power > myRegion->powerLimit) && !devicestate.owner.is_licensed)) - power = myRegion->powerLimit; + if ((power == 0) || ((power > newRegion->powerLimit) && !devicestate.owner.is_licensed)) + power = newRegion->powerLimit; if (power == 0) - power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of myRegion defaults + power = 17; // Default to this power level if we don't have a valid regional power limit (powerLimit of newRegion defaults // to 0, currently no region has an actual power limit of 0 [dBm] so we can assume regions which have this // variable set to 0 don't have a valid power limit) // Set final tx_power back onto config loraConfig.tx_power = (int8_t)power; // cppcheck-suppress assignmentAddressToInteger - // Calculate the number of channels - uint32_t numChannels = floor((myRegion->freqEnd - myRegion->freqStart) / (myRegion->spacing + (bw / 1000))); + uint32_t channel_num; + float freq; - // If user has manually specified a channel num, then use that, otherwise generate one by hashing the name - const char *channelName = channels.getName(channels.getPrimaryIndex()); - // channel_num is actually (channel_num - 1), since modulus (%) returns values from 0 to (numChannels - 1) - uint32_t channel_num = (loraConfig.channel_num ? loraConfig.channel_num - 1 : hash(channelName)) % numChannels; + // Calculate number of frequency slots (aka Channels): + // spacing = gap between channels (0 for continuous spectrum) and at the beginning of the band + // padding = gap at the beginning and end of the channel (0 for no padding) + float freqSlotWidth = newRegion->profile->spacing + (newRegion->profile->padding * 2) + (bw / 1000); // in MHz + uint32_t numFreqSlots = round((newRegion->freqEnd - newRegion->freqStart + newRegion->profile->spacing) / freqSlotWidth); - // Check if we use the default frequency slot - RadioInterface::uses_default_frequency_slot = - channel_num == - hash(DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, config.lora.use_preset)) % numChannels; - - // Old frequency selection formula - // float freq = myRegion->freqStart + ((((myRegion->freqEnd - myRegion->freqStart) / numChannels) / 2) * channel_num); - - // New frequency selection formula - float freq = myRegion->freqStart + (bw / 2000) + (channel_num * (bw / 1000)); + // Calculate hash of channel name and preset name to pick a default frequency slot if user has not specified one. + // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to + // (numFreqSlots - 1). + uint32_t presetNameHashSlot = + hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; // override if we have a verbatim frequency if (loraConfig.override_frequency) { freq = loraConfig.override_frequency; channel_num = -1; + uses_default_frequency_slot = false; + } else { + + // If user has not manually specified a frequency slot, or has not specified one that is different than the default or the + // override for the new region, then use the default or override. If the user has not specified one, but has specified a + // custom channel name, then use the hash of that channel name to pick a frequency slot. Note that channel_num is actually + // (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to (numFreqSlots - 1). + // NB: channel_num is also know as frequency slot but it's too late to fix now. + if (uses_default_frequency_slot) { + // if there's an override slot, use that + if (newRegion->profile->overrideSlot != 0) { + channel_num = newRegion->profile->overrideSlot - 1; + } else { + channel_num = presetNameHashSlot; + } + } else { // use the manually defined one + channel_num = loraConfig.channel_num - 1; + } + + // Calculate frequency: freqStart is band edge, add half bandwidth (plus optional padding) to get middle of first channel + // subsequent channels are spaced by freqSlotWidth + freq = newRegion->freqStart + (bw / 2000) + newRegion->profile->padding + (channel_num * freqSlotWidth); // in MHz } saveChannelNum(channel_num); saveFreq(freq + loraConfig.frequency_offset); + const char *channelName = channels.getName(channels.getPrimaryIndex()); + + if (newRegion->wideLora) { // clamp if wide freq range + preambleLength = wideLoraPreambleLengthDefault; // 12 is the default for operation above 2GHz + } else { + preambleLength = + preambleLengthDefault; // 8 is default, but we use longer to increase the amount of sleep time when receiving + } slotTimeMsec = computeSlotTimeMsec(); preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw); LOG_INFO("Radio freq=%.3f, config.lora.frequency_offset=%.3f", freq, loraConfig.frequency_offset); - LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", myRegion->name, channelName, loraConfig.modem_preset, + LOG_INFO("Set radio: region=%s, name=%s, config=%u, ch=%d, power=%d", newRegion->name, channelName, loraConfig.modem_preset, channel_num, power); - LOG_INFO("myRegion->freqStart -> myRegion->freqEnd: %f -> %f (%f MHz)", myRegion->freqStart, myRegion->freqEnd, - myRegion->freqEnd - myRegion->freqStart); - LOG_INFO("numChannels: %d x %.3fkHz", numChannels, bw); + LOG_INFO("newRegion->freqStart -> newRegion->freqEnd: %f -> %f (%f MHz)", newRegion->freqStart, newRegion->freqEnd, + newRegion->freqEnd - newRegion->freqStart); + LOG_INFO("numFreqSlots: %d x %.3fkHz", numFreqSlots, bw); + if (newRegion->profile->overrideSlot != 0) { + LOG_INFO("Using region override slot: %d", newRegion->profile->overrideSlot); + } LOG_INFO("channel_num: %d", channel_num + 1); LOG_INFO("frequency: %f", getFreq()); LOG_INFO("Slot time: %u msec, preamble time: %u msec", slotTimeMsec, preambleTimeMsec); -} +} // end of applyModemConfig /** Slottime is the time to detect a transmission has started, consisting of: - CAD duration; @@ -994,4 +1174,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) sendingPacket = p; return p->encrypted.size + sizeof(PacketHeader); -} +} \ No newline at end of file diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 8f793f47a..a1c692e24 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -92,15 +92,20 @@ class RadioInterface uint8_t sf = 9; uint8_t cr = 5; - const uint8_t NUM_SYM_CAD = 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 - const uint8_t NUM_SYM_CAD_24GHZ = 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280 + static constexpr uint8_t NUM_SYM_CAD = + 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 + static constexpr uint8_t NUM_SYM_CAD_24GHZ = + 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280 uint32_t slotTimeMsec = computeSlotTimeMsec(); - uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving - uint32_t preambleTimeMsec = 165; // calculated on startup, this is the default for LongFast - const uint32_t PROCESSING_TIME_MSEC = - 4500; // time to construct, process and construct a packet again (empirically determined) - const uint8_t CWmin = 3; // minimum CWsize - const uint8_t CWmax = 8; // maximum CWsize + uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving + static constexpr uint16_t preambleLengthDefault = + 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving + static constexpr uint16_t wideLoraPreambleLengthDefault = 12; // 12 is default for wide Lora + uint32_t preambleTimeMsec = 165; // calculated on startup, this is the default for LongFast + static constexpr uint32_t PROCESSING_TIME_MSEC = + 4500; // time to construct, process and construct a packet again (empirically determined) + static constexpr uint8_t CWmin = 3; // minimum CWsize + static constexpr uint8_t CWmax = 8; // maximum CWsize meshtastic_MeshPacket *sendingPacket = NULL; // The packet we are currently sending uint32_t lastTxStart = 0L; @@ -127,7 +132,7 @@ class RadioInterface * Coerce LoRa config fields (bandwidth/spread_factor) derived from presets. * This is used during early bootstrapping so UIs that display these fields directly remain consistent. */ - static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); + // static void bootstrapLoRaConfigFromPreset(meshtastic_Config_LoRaConfig &loraConfig); // maybe superseded? /** * Return true if we think the board can go to sleep (i.e. our tx queue is empty, we are not sending or receiving) @@ -234,6 +239,20 @@ class RadioInterface // Whether we use the default frequency slot given our LoRa config (region and modem preset) static bool uses_default_frequency_slot; + // Whether we have a custom channel name + static bool uses_custom_channel_name; + + static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp); + + // Check if a candidate region is compatible and valid. + static bool validateConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig); + + // Check if a candidate radio configuration is valid. + static bool validateConfigLora(const meshtastic_Config_LoRaConfig &loraConfig); + + // Make a candidate radio configuration valid, even if it isn't. + static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig); + protected: int8_t power = 17; // Set by applyModemConfig() diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 30cd587da..7ef707e0d 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -246,6 +246,24 @@ bool RadioLibInterface::findInTxQueue(NodeNum from, PacketId id) return txQueue.find(from, id); } +bool RadioLibInterface::randomBytes(uint8_t *buffer, size_t length) +{ + if (!buffer || length == 0 || !iface) { + return false; + } + + // Older RadioLib versions only expose random(min, max), so fill the buffer byte-by-byte. + for (size_t i = 0; i < length; ++i) { + int32_t value = iface->random(0, 255); + if (value < 0) { + return false; + } + buffer[i] = static_cast(value & 0xFF); + } + + return true; +} + /** radio helper thread callback. We never immediately transmit after any operation (either Rx or Tx). Instead we should wait a random multiple of 'slotTimes' (see definition in RadioInterface.h) taken from a contention window (CW) to lower the chance of collision. @@ -587,4 +605,4 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) return res == RADIOLIB_ERR_NONE; } -} \ No newline at end of file +} diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index ca3d78503..310ca76bb 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -172,6 +172,12 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified /** Attempt to find a packet in the TxQueue. Returns true if the packet was found. */ virtual bool findInTxQueue(NodeNum from, PacketId id) override; + /** + * Request randomness sourced from the LoRa modem, if supported by the active RadioLib interface. + * @return true if len bytes were produced, false otherwise. + */ + bool randomBytes(uint8_t *buffer, size_t length); + private: /** if we have something waiting to send, start a short (random) timer so we can come check for collision before actually * doing the transmit */ diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index b231261b5..836cd1a22 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -11,6 +11,9 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" #endif @@ -95,6 +98,20 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) return true; } +#if HAS_TRAFFIC_MANAGEMENT + // When router_preserve_hops is enabled, preserve hops for decoded packets that are not + // position or telemetry (those have their own exhaust_hop controls). + if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled && + moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) { + LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p)); + if (trafficManagementModule) { + trafficManagementModule->recordRouterHopPreserved(); + } + return false; + } +#endif + // For subsequent hops, check if previous relay is a favorite router // Optimized search for favorite routers with matching last byte // Check ordering optimized for IoT devices (cheapest checks first) @@ -858,6 +875,12 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) return; } + if (shouldDropPacketForPreHop(*p)) { + logHopStartDrop(*p, "pre-hop drop"); + packetPool.release(p); + return; + } + if (shouldFilterReceived(p)) { LOG_DEBUG("Incoming msg was filtered from 0x%x", p->from); packetPool.release(p); diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 2e9a3250d..bcb08f2c5 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -245,14 +245,21 @@ template bool SX126xInterface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > SX126X_MAX_POWER) // This chip has lower power limits than some - power = SX126X_MAX_POWER; + limitPower(SX126X_MAX_POWER); + // Make sure we reach the minimum power supported to turn the chip on (-9dBm) + if (power < -9) + power = -9; err = lora.setOutputPower(power); if (err != RADIOLIB_ERR_NONE) LOG_ERROR("SX126X setOutputPower %s%d", radioLibErr, err); assert(err == RADIOLIB_ERR_NONE); + // Apply RX gain mode — valid in STDBY (datasheet §9.6), matches resetAGC() pattern + err = lora.setRxBoostedGainMode(config.lora.sx126x_rx_boosted_gain); + if (err != RADIOLIB_ERR_NONE) + LOG_WARN("SX126X setRxBoostedGainMode %s%d", radioLibErr, err); + startReceive(); // restart receiving return RADIOLIB_ERR_NONE; diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 0e882ef05..cb21c0770 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -144,8 +144,7 @@ template bool SX128xInterface::reconfigure() if (err != RADIOLIB_ERR_NONE) RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_INVALID_RADIO_SETTING); - if (power > SX128X_MAX_POWER) // This chip has lower power limits than some - power = SX128X_MAX_POWER; + limitPower(SX128X_MAX_POWER); err = lora.setOutputPower(power); if (err != RADIOLIB_ERR_NONE) diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index e4f65aa28..eea7d4efc 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -69,6 +69,22 @@ static inline int get_max_num_nodes() /// Max number of channels allowed #define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0])) +// Traffic Management module configuration +// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 0 +#endif + +// Cache size for traffic management (number of nodes to track) +// Can be overridden per-variant based on available memory +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#if HAS_TRAFFIC_MANAGEMENT +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 +#else +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 0 +#endif +#endif + /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic /// returns the encoded packet size size_t pb_encode_to_bytes(uint8_t *destbuf, size_t destbufsize, const pb_msgdesc_t *fields, const void *src_struct); @@ -90,4 +106,4 @@ bool writecb(pb_ostream_t *stream, const uint8_t *buf, size_t count); */ bool is_in_helper(uint32_t n, const uint32_t *array, pb_size_t count); -#define is_in_repeated(name, n) is_in_helper(n, name, name##_count) \ No newline at end of file +#define is_in_repeated(name, n) is_in_helper(n, name, name##_count) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7492d7361..05bc0aa5d 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -24,7 +24,9 @@ #include "Default.h" #include "MeshRadio.h" +#include "RadioInterface.h" #include "TypeConversions.h" +#include "mesh/RadioLibInterface.h" #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" @@ -197,10 +199,35 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta handleSetOwner(r->set_owner); break; - case meshtastic_AdminMessage_set_config_tag: + case meshtastic_AdminMessage_set_config_tag: { LOG_DEBUG("Client set config"); - handleSetConfig(r->set_config); + + // Non-LoRa configs need no further validation. + if (r->set_config.which_payload_variant != meshtastic_Config_lora_tag) { + LOG_DEBUG("Non-LoRa config, applying directly"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + // Only LORA_24 requires hardware capability validation. + if (r->set_config.payload_variant.lora.region != meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { + LOG_DEBUG("LoRa config, region is not LORA_24, applying directly"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + // Hardware supports 2.4 GHz — apply the config. + // Fail closed: null instance is treated as incapable. + if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) { + LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying"); + handleSetConfig(r->set_config, fromOthers); + break; + } + + LOG_WARN("Radio hardware does not support 2.4 GHz; rejecting LORA_24 region"); + myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); break; + } case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); @@ -453,7 +480,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #if HAS_SCREEN IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0)); #endif -#if defined(ARCH_NRF52) || defined(ARCH_RP2040) +#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL) enterDfuMode(); #endif break; @@ -626,7 +653,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) } } -void AdminModule::handleSetConfig(const meshtastic_Config &c) +void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) { auto changes = SEGMENT_CONFIG; auto existingRole = config.device.role; @@ -770,18 +797,57 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) validatedLora.spread_factor = LORA_SF_DEFAULT; } - // If no lora radio parameters change, don't need to reboot - if (oldLoraConfig.use_preset == validatedLora.use_preset && oldLoraConfig.region == validatedLora.region && - oldLoraConfig.modem_preset == validatedLora.modem_preset && oldLoraConfig.bandwidth == validatedLora.bandwidth && - oldLoraConfig.spread_factor == validatedLora.spread_factor && - oldLoraConfig.coding_rate == validatedLora.coding_rate && oldLoraConfig.tx_power == validatedLora.tx_power && - oldLoraConfig.frequency_offset == validatedLora.frequency_offset && - oldLoraConfig.override_frequency == validatedLora.override_frequency && - oldLoraConfig.channel_num == validatedLora.channel_num && - oldLoraConfig.sx126x_rx_boosted_gain == validatedLora.sx126x_rx_boosted_gain) { - requiresReboot = false; + // If we're setting a new region, check the region is valid and then init the region or discard the change + if (validatedLora.region != myRegion->code) { + // Region has changed so check whether it is valid for e.g. licensing conditions and if the lora config is valid + if (RadioInterface::validateConfigRegion(validatedLora) && RadioInterface::validateConfigLora(validatedLora)) { + // If we're setting region for the first time, init the region and regenerate the keys + if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (crypto) { + crypto->ensurePkiKeys(config.security, owner); + } +#endif + // new region is valid and we're coming from an unset region, so enable tx + validatedLora.tx_enabled = true; + } + // If we're unsetting the region for some reason, disable tx + if (!isRegionUnset && validatedLora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + validatedLora.tx_enabled = false; + } + // Ensure initRegion() uses the newly validated region + config.lora.region = validatedLora.region; + initRegion(); + if (myRegion->dutyCycle < 100) { + validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit + } + if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { + // Default root is in use, so subscribe to the appropriate MQTT topic for this region + sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); + } + changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; + } else { + // Region validation has failed, so just copy all of the old config over the new config + validatedLora = oldLoraConfig; + } + } // end of new region handling + + if (!RadioInterface::validateConfigLora(validatedLora)) { + if (fromOthers) { + LOG_WARN("Invalid LoRa config received from another node, rejecting changes"); + // modem_preset set to use the old setting if the check fails + validatedLora.modem_preset = oldLoraConfig.modem_preset; + } else { + LOG_WARN("Invalid LoRa config received from client, using corrected values"); + RadioInterface::clampConfigLora(validatedLora); + } + // use_preset and bandwidth are coerced into valid values by the check. } + // All LoRa radio changes apply live via configChanged observer → reconfigure(). + // reconfigure() puts the radio in standby, reprograms all modem parameters, and restarts receive. + requiresReboot = false; + #if defined(ARCH_PORTDUINO) // If running on portduino and using SimRadio, do not require reboot if (SimRadio::instance) { @@ -797,63 +863,21 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) digitalWrite(RF95_FAN_EN, HIGH ^ 0); } #endif - config.lora = validatedLora; #if HAS_LORA_FEM // Apply FEM LNA mode from config (only meaningful on hardware that supports it) + // Note that a rejected lora config will revert this as well. if (loraFEMInterface.isLnaCanControl()) { - loraFEMInterface.setLNAEnable(config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); - } else if (config.lora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { + loraFEMInterface.setLNAEnable(validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED); + } else if (validatedLora.fem_lna_mode != meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT) { // Hardware FEM does not support LNA control; normalize stored config to match actual capability LOG_WARN("FEM LNA mode configured but current FEM does not support LNA control; normalizing to NOT_PRESENT"); - config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; + validatedLora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT; } #endif - // If we're setting region for the first time, init the region and regenerate the keys - if (isRegionUnset && config.lora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { -#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (!owner.is_licensed) { - bool keygenSuccess = false; - if (config.security.private_key.size == 32) { - if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) { - keygenSuccess = true; - } - } else { - LOG_INFO("Generate new PKI keys"); - crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes); - keygenSuccess = true; - } - if (keygenSuccess) { - config.security.public_key.size = 32; - config.security.private_key.size = 32; - owner.public_key.size = 32; - memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32); - } - } -#endif - config.lora.tx_enabled = true; - initRegion(); - if (myRegion->dutyCycle < 100) { - config.lora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit - } - // Compare the entire string, we are sure of the length as a topic has never been set - if (strcmp(moduleConfig.mqtt.root, default_mqtt_root) == 0) { - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; - } - } - if (config.lora.region != myRegion->code) { - // Region has changed so check whether there is a regulatory one we should be using instead. - // Additionally as a side-effect, assume a new value under myRegion - initRegion(); - if (strncmp(moduleConfig.mqtt.root, default_mqtt_root, strlen(default_mqtt_root)) == 0) { - // Default root is in use, so subscribe to the appropriate MQTT topic for this region - sprintf(moduleConfig.mqtt.root, "%s/%s", default_mqtt_root, myRegion->name); - } + config.lora = validatedLora; // Finally, return the validated config back to the main config - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; - } break; } case meshtastic_Config_bluetooth_tag: @@ -901,10 +925,10 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c) } if (requiresReboot && !hasOpenEditTransaction) { disableBluetooth(); - } + } // end of switch case which_payload_variant saveChanges(changes, requiresReboot); -} +} // end of handleSetConfig bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) { @@ -1010,6 +1034,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.statusmessage = c.payload_variant.statusmessage; shouldReboot = false; break; + case meshtastic_ModuleConfig_traffic_management_tag: + LOG_INFO("Set module config: Traffic Management"); + moduleConfig.has_traffic_management = true; + moduleConfig.traffic_management = c.payload_variant.traffic_management; + break; } saveChanges(SEGMENT_MODULECONFIG, shouldReboot); return true; @@ -1124,78 +1153,85 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default; if (req.decoded.want_response) { + const char *configName = "?"; switch (configType) { case meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG: - LOG_INFO("Get module config: MQTT"); + configName = "MQTT"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag; res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt; break; case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG: - LOG_INFO("Get module config: Serial"); + configName = "Serial"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_serial_tag; res.get_module_config_response.payload_variant.serial = moduleConfig.serial; break; case meshtastic_AdminMessage_ModuleConfigType_EXTNOTIF_CONFIG: - LOG_INFO("Get module config: External Notification"); + configName = "External Notification"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_external_notification_tag; res.get_module_config_response.payload_variant.external_notification = moduleConfig.external_notification; break; case meshtastic_AdminMessage_ModuleConfigType_STOREFORWARD_CONFIG: - LOG_INFO("Get module config: Store & Forward"); + configName = "Store & Forward"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_store_forward_tag; res.get_module_config_response.payload_variant.store_forward = moduleConfig.store_forward; break; case meshtastic_AdminMessage_ModuleConfigType_RANGETEST_CONFIG: - LOG_INFO("Get module config: Range Test"); + configName = "Range Test"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_range_test_tag; res.get_module_config_response.payload_variant.range_test = moduleConfig.range_test; break; case meshtastic_AdminMessage_ModuleConfigType_TELEMETRY_CONFIG: - LOG_INFO("Get module config: Telemetry"); + configName = "Telemetry"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_telemetry_tag; res.get_module_config_response.payload_variant.telemetry = moduleConfig.telemetry; break; case meshtastic_AdminMessage_ModuleConfigType_CANNEDMSG_CONFIG: - LOG_INFO("Get module config: Canned Message"); + configName = "Canned Message"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_canned_message_tag; res.get_module_config_response.payload_variant.canned_message = moduleConfig.canned_message; break; case meshtastic_AdminMessage_ModuleConfigType_AUDIO_CONFIG: - LOG_INFO("Get module config: Audio"); + configName = "Audio"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_audio_tag; res.get_module_config_response.payload_variant.audio = moduleConfig.audio; break; case meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG: - LOG_INFO("Get module config: Remote Hardware"); + configName = "Remote Hardware"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_remote_hardware_tag; res.get_module_config_response.payload_variant.remote_hardware = moduleConfig.remote_hardware; break; case meshtastic_AdminMessage_ModuleConfigType_NEIGHBORINFO_CONFIG: - LOG_INFO("Get module config: Neighbor Info"); + configName = "Neighbor Info"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_neighbor_info_tag; res.get_module_config_response.payload_variant.neighbor_info = moduleConfig.neighbor_info; break; case meshtastic_AdminMessage_ModuleConfigType_DETECTIONSENSOR_CONFIG: - LOG_INFO("Get module config: Detection Sensor"); + configName = "Detection Sensor"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_detection_sensor_tag; res.get_module_config_response.payload_variant.detection_sensor = moduleConfig.detection_sensor; break; case meshtastic_AdminMessage_ModuleConfigType_AMBIENTLIGHTING_CONFIG: - LOG_INFO("Get module config: Ambient Lighting"); + configName = "Ambient Lighting"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_ambient_lighting_tag; res.get_module_config_response.payload_variant.ambient_lighting = moduleConfig.ambient_lighting; break; case meshtastic_AdminMessage_ModuleConfigType_PAXCOUNTER_CONFIG: - LOG_INFO("Get module config: Paxcounter"); + configName = "Paxcounter"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; res.get_module_config_response.payload_variant.paxcounter = moduleConfig.paxcounter; break; case meshtastic_AdminMessage_ModuleConfigType_STATUSMESSAGE_CONFIG: - LOG_INFO("Get module config: StatusMessage"); + configName = "StatusMessage"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag; res.get_module_config_response.payload_variant.statusmessage = moduleConfig.statusmessage; break; + case meshtastic_AdminMessage_ModuleConfigType_TRAFFICMANAGEMENT_CONFIG: + configName = "Traffic Management"; + res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; + res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management; + break; } + LOG_INFO("Get module config: %s", configName); // NOTE: The phone app needs to know the ls_secsvalue so it can properly expect sleep behavior. // So even if we internally use 0 to represent 'use default' we still need to send the value we are @@ -1378,23 +1414,17 @@ void AdminModule::handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uic void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) { // Validate ham parameters before setting since this would bypass validation in the owner struct - if (*p.call_sign) { - const char *start = p.call_sign; - // Skip all whitespace - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham call_sign: must contain at least 1 non-whitespace character"); - return; - } - } - if (*p.short_name) { - const char *start = p.short_name; - while (*start && isspace((unsigned char)*start)) - start++; - if (*start == '\0') { - LOG_WARN("Rejected ham short_name: must contain at least 1 non-whitespace character"); - return; + const char *fieldsToCheck[] = {p.call_sign, p.short_name}; + const char *fieldNames[] = {"call_sign", "short_name"}; + for (int i = 0; i < 2; i++) { + if (*fieldsToCheck[i]) { + const char *start = fieldsToCheck[i]; + while (*start && isspace((unsigned char)*start)) + start++; + if (*start == '\0') { + LOG_WARN("Rejected ham %s: must contain at least 1 non-whitespace character", fieldNames[i]); + return; + } } } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index c446887b3..5c690abbd 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -60,7 +60,11 @@ class AdminModule : public ProtobufModule, public Obser */ void handleSetOwner(const meshtastic_User &o); void handleSetChannel(const meshtastic_Channel &cc); - void handleSetConfig(const meshtastic_Config &c); + + protected: + void handleSetConfig(const meshtastic_Config &c, bool fromOthers); + + private: bool handleSetModuleConfig(const meshtastic_ModuleConfig &c); void handleSetChannel(); void handleSetHamMode(const meshtastic_HamParameters &req); diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 64e90c9c2..d3ab9076d 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -38,6 +38,9 @@ #include "modules/PowerStressModule.h" #endif #include "modules/RoutingModule.h" +#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "modules/TextMessageModule.h" #if !MESHTASTIC_EXCLUDE_TRACEROUTE #include "modules/TraceRouteModule.h" @@ -120,6 +123,14 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_REPLYBOT new ReplyBotModule(); #endif + +#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT + // Instantiate only when enabled to avoid extra memory use and background work. + if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) { + trafficManagementModule = new TrafficManagementModule(); + } +#endif + #if !MESHTASTIC_EXCLUDE_ADMIN adminModule = new AdminModule(); #endif diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index 7a969343e..20d4d7d8c 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -651,7 +651,7 @@ void SerialModule::processWXSerial() LOG_INFO("WS8X : %i %.1fg%.1f %.1fv %.1fv %.1fC rain: %.1f, %i sum", atoi(windDir), strtof(windVel, nullptr), strtof(windGust, nullptr), batVoltageF, capVoltageF, temperatureF, rain, rainSum); } - if (gotwind && !Throttle::isWithinTimespanMs(lastAveraged, averageIntervalMillis)) { + if (gotwind && !Throttle::isWithinTimespanMs(lastAveraged, averageIntervalMillis) && velCount > 0 && dirCount > 0) { // calculate averages and send to the mesh float velAvg = 1.0 * velSum / velCount; diff --git a/src/modules/StatusMessageModule.cpp b/src/modules/StatusMessageModule.cpp index 139a74d8e..0707a4f7d 100644 --- a/src/modules/StatusMessageModule.cpp +++ b/src/modules/StatusMessageModule.cpp @@ -29,10 +29,23 @@ int32_t StatusMessageModule::runOnce() ProcessMessage StatusMessageModule::handleReceived(const meshtastic_MeshPacket &mp) { if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { - meshtastic_StatusMessage incomingMessage; + meshtastic_StatusMessage incomingMessage = meshtastic_StatusMessage_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_StatusMessage_fields, &incomingMessage)) { + LOG_INFO("Received a NodeStatus message %s", incomingMessage.status); + + RecentStatus entry; + entry.fromNodeId = mp.from; + entry.statusText = incomingMessage.status; + + recentReceived.push_back(std::move(entry)); + + // Keep only last MAX_RECENT_STATUSMESSAGES + if (recentReceived.size() > MAX_RECENT_STATUSMESSAGES) { + recentReceived.erase(recentReceived.begin()); // drop oldest + } } } return ProcessMessage::CONTINUE; diff --git a/src/modules/StatusMessageModule.h b/src/modules/StatusMessageModule.h index c9ff54018..5090066e6 100644 --- a/src/modules/StatusMessageModule.h +++ b/src/modules/StatusMessageModule.h @@ -2,10 +2,11 @@ #if !MESHTASTIC_EXCLUDE_STATUS #include "SinglePortModule.h" #include "configuration.h" +#include +#include class StatusMessageModule : public SinglePortModule, private concurrency::OSThread { - public: /** Constructor * name is for debugging output @@ -19,16 +20,28 @@ class StatusMessageModule : public SinglePortModule, private concurrency::OSThre this->setInterval(1000 * 12 * 60 * 60); } // TODO: If we have a string, set the initial delay (15 minutes maybe) + + // Keep vector from reallocating as we fill up to MAX_RECENT_STATUSMESSAGES + recentReceived.reserve(MAX_RECENT_STATUSMESSAGES); } virtual int32_t runOnce() override; + struct RecentStatus { + uint32_t fromNodeId; // mp.from + std::string statusText; // incomingMessage.status + }; + + const std::vector &getRecentReceived() const { return recentReceived; } + protected: /** Called to handle a particular incoming message */ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; private: + static constexpr size_t MAX_RECENT_STATUSMESSAGES = 5; + std::vector recentReceived; }; extern StatusMessageModule *statusMessageModule; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index b7b6e04a9..684d408a1 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -66,18 +66,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/MCP9808Sensor.h" #endif -#if __has_include() -#include "Sensor/SHT31Sensor.h" -#endif - #if __has_include() #include "Sensor/LPS22HBSensor.h" #endif -#if __has_include() -#include "Sensor/SHTC3Sensor.h" -#endif - #if __has_include("RAK12035_SoilMoisture.h") && defined(RAK_4631) && RAK_4631 == 1 #include "Sensor/RAK12035Sensor.h" #endif @@ -94,8 +86,8 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/OPT3001Sensor.h" #endif -#if __has_include() -#include "Sensor/SHT4XSensor.h" +#if __has_include() +#include "Sensor/SHTXXSensor.h" #endif #if __has_include() @@ -155,6 +147,15 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) } LOG_INFO("Environment Telemetry adding I2C devices..."); + /* + Uncomment the preferences below if you want to use the module + without having to configure it from the PythonAPI or WebUI. + */ + + // moduleConfig.telemetry.environment_measurement_enabled = 1; + // moduleConfig.telemetry.environment_screen_enabled = 1; + // moduleConfig.telemetry.environment_update_interval = 15; + // order by priority of metrics/values (low top, high bottom) #if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR @@ -202,15 +203,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::MCP9808); #endif -#if __has_include() - addSensor(i2cScanner, ScanI2C::DeviceType::SHT31); -#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::LPS22HB); #endif -#if __has_include() - addSensor(i2cScanner, ScanI2C::DeviceType::SHTC3); -#endif #if __has_include("RAK12035_SoilMoisture.h") && defined(RAK_4631) && RAK_4631 == 1 addSensor(i2cScanner, ScanI2C::DeviceType::RAK12035); #endif @@ -223,13 +218,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::OPT3001); #endif -#if __has_include() - addSensor(i2cScanner, ScanI2C::DeviceType::SHT4X); -#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::MLX90632); #endif - #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BMP_3XX); #endif @@ -245,7 +236,10 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::BH1750); #endif - +#if __has_include() + // TODO Can we scan for multiple sensors connected on the same bus? + addSensor(i2cScanner, ScanI2C::DeviceType::SHTXX); +#endif #endif } @@ -260,14 +254,6 @@ int32_t EnvironmentTelemetryModule::runOnce() } uint32_t result = UINT32_MAX; - /* - Uncomment the preferences below if you want to use the module - without having to configure it from the PythonAPI or WebUI. - */ - - // moduleConfig.telemetry.environment_measurement_enabled = 1; - // moduleConfig.telemetry.environment_screen_enabled = 1; - // moduleConfig.telemetry.environment_update_interval = 15; if (!(moduleConfig.telemetry.environment_measurement_enabled || moduleConfig.telemetry.environment_screen_enabled || ENVIRONMENTAL_TELEMETRY_MODULE_ENABLE)) { diff --git a/src/modules/Telemetry/Sensor/SHT31Sensor.cpp b/src/modules/Telemetry/Sensor/SHT31Sensor.cpp deleted file mode 100644 index 67a36933d..000000000 --- a/src/modules/Telemetry/Sensor/SHT31Sensor.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "SHT31Sensor.h" -#include "TelemetrySensor.h" -#include - -SHT31Sensor::SHT31Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHT31, "SHT31") {} - -bool SHT31Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - LOG_INFO("Init sensor: %s", sensorName); - sht31 = Adafruit_SHT31(bus); - status = sht31.begin(dev->address.address); - initI2CSensor(); - return status; -} - -bool SHT31Sensor::getMetrics(meshtastic_Telemetry *measurement) -{ - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - measurement->variant.environment_metrics.temperature = sht31.readTemperature(); - measurement->variant.environment_metrics.relative_humidity = sht31.readHumidity(); - - return true; -} - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHT31Sensor.h b/src/modules/Telemetry/Sensor/SHT31Sensor.h deleted file mode 100644 index ecb7d63a6..000000000 --- a/src/modules/Telemetry/Sensor/SHT31Sensor.h +++ /dev/null @@ -1,20 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include - -class SHT31Sensor : public TelemetrySensor -{ - private: - Adafruit_SHT31 sht31; - - public: - SHT31Sensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; -}; - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHT4XSensor.cpp b/src/modules/Telemetry/Sensor/SHT4XSensor.cpp deleted file mode 100644 index b11795d97..000000000 --- a/src/modules/Telemetry/Sensor/SHT4XSensor.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "SHT4XSensor.h" -#include "TelemetrySensor.h" -#include - -SHT4XSensor::SHT4XSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHT4X, "SHT4X") {} - -bool SHT4XSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - LOG_INFO("Init sensor: %s", sensorName); - - uint32_t serialNumber = 0; - - status = sht4x.begin(bus); - if (!status) { - return status; - } - - serialNumber = sht4x.readSerial(); - if (serialNumber != 0) { - LOG_DEBUG("serialNumber : %x", serialNumber); - status = 1; - } else { - LOG_DEBUG("Error trying to execute readSerial(): "); - status = 0; - } - - initI2CSensor(); - return status; -} - -bool SHT4XSensor::getMetrics(meshtastic_Telemetry *measurement) -{ - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - - sensors_event_t humidity, temp; - sht4x.getEvent(&humidity, &temp); - measurement->variant.environment_metrics.temperature = temp.temperature; - measurement->variant.environment_metrics.relative_humidity = humidity.relative_humidity; - return true; -} - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHT4XSensor.h b/src/modules/Telemetry/Sensor/SHT4XSensor.h deleted file mode 100644 index 7311d2366..000000000 --- a/src/modules/Telemetry/Sensor/SHT4XSensor.h +++ /dev/null @@ -1,20 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include - -class SHT4XSensor : public TelemetrySensor -{ - private: - Adafruit_SHT4x sht4x = Adafruit_SHT4x(); - - public: - SHT4XSensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; -}; - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTC3Sensor.cpp b/src/modules/Telemetry/Sensor/SHTC3Sensor.cpp deleted file mode 100644 index fdab0b266..000000000 --- a/src/modules/Telemetry/Sensor/SHTC3Sensor.cpp +++ /dev/null @@ -1,35 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "SHTC3Sensor.h" -#include "TelemetrySensor.h" -#include - -SHTC3Sensor::SHTC3Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHTC3, "SHTC3") {} - -bool SHTC3Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) -{ - LOG_INFO("Init sensor: %s", sensorName); - status = shtc3.begin(bus); - - initI2CSensor(); - return status; -} - -bool SHTC3Sensor::getMetrics(meshtastic_Telemetry *measurement) -{ - measurement->variant.environment_metrics.has_temperature = true; - measurement->variant.environment_metrics.has_relative_humidity = true; - - sensors_event_t humidity, temp; - shtc3.getEvent(&humidity, &temp); - - measurement->variant.environment_metrics.temperature = temp.temperature; - measurement->variant.environment_metrics.relative_humidity = humidity.relative_humidity; - - return true; -} - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTC3Sensor.h b/src/modules/Telemetry/Sensor/SHTC3Sensor.h deleted file mode 100644 index 51cee18f7..000000000 --- a/src/modules/Telemetry/Sensor/SHTC3Sensor.h +++ /dev/null @@ -1,20 +0,0 @@ -#include "configuration.h" - -#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() - -#include "../mesh/generated/meshtastic/telemetry.pb.h" -#include "TelemetrySensor.h" -#include - -class SHTC3Sensor : public TelemetrySensor -{ - private: - Adafruit_SHTC3 shtc3 = Adafruit_SHTC3(); - - public: - SHTC3Sensor(); - virtual bool getMetrics(meshtastic_Telemetry *measurement) override; - virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; -}; - -#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTXXSensor.cpp b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp new file mode 100644 index 000000000..92cac7f77 --- /dev/null +++ b/src/modules/Telemetry/Sensor/SHTXXSensor.cpp @@ -0,0 +1,145 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "SHTXXSensor.h" +#include "TelemetrySensor.h" +#include + +SHTXXSensor::SHTXXSensor() : TelemetrySensor(meshtastic_TelemetrySensorType_SHTXX, "SHTXX") {} + +void SHTXXSensor::getSensorVariant(SHTSensor::SHTSensorType sensorType) +{ + switch (sensorType) { + case SHTSensor::SHTSensorType::SHT2X: + sensorVariant = "SHT2x"; + break; + + case SHTSensor::SHTSensorType::SHT3X: + case SHTSensor::SHTSensorType::SHT85: + sensorVariant = "SHT3x/SHT85"; + break; + + case SHTSensor::SHTSensorType::SHT3X_ALT: + sensorVariant = "SHT3x"; + break; + + case SHTSensor::SHTSensorType::SHTW1: + case SHTSensor::SHTSensorType::SHTW2: + case SHTSensor::SHTSensorType::SHTC1: + case SHTSensor::SHTSensorType::SHTC3: + sensorVariant = "SHTC1/SHTC3/SHTW1/SHTW2"; + break; + + case SHTSensor::SHTSensorType::SHT4X: + sensorVariant = "SHT4x"; + break; + + default: + sensorVariant = "Unknown"; + break; + } +} + +bool SHTXXSensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + _bus = bus; + _address = dev->address.address; + + if (sht.init(*_bus)) { + LOG_INFO("%s: init(): success", sensorName); + getSensorVariant(sht.mSensorType); + LOG_INFO("%s Sensor detected: %s on 0x%x", sensorName, sensorVariant, _address); + status = 1; + } else { + LOG_ERROR("%s: init(): failed", sensorName); + } + + initI2CSensor(); + return status; +} + +/** + * Accuracy setting of measurement. + * Not all sensors support changing the sampling accuracy (only SHT3X and SHT4X) + * SHTAccuracy: + * - SHT_ACCURACY_HIGH: Highest repeatability at the cost of slower measurement + * - SHT_ACCURACY_MEDIUM: Balanced repeatability and speed of measurement + * - SHT_ACCURACY_LOW: Fastest measurement but lowest repeatability + */ +bool SHTXXSensor::setAccuracy(SHTSensor::SHTAccuracy newAccuracy) +{ + // Only SHT3X-family (including alternates) and SHT4X support changing accuracy + if (sht.mSensorType != SHTSensor::SHTSensorType::SHT3X && sht.mSensorType != SHTSensor::SHTSensorType::SHT3X_ALT && + sht.mSensorType != SHTSensor::SHTSensorType::SHT85 && sht.mSensorType != SHTSensor::SHTSensorType::SHT4X) { + LOG_WARN("%s doesn't support accuracy setting", sensorVariant); + return false; + } + LOG_INFO("%s: setting new accuracy setting", sensorVariant); + accuracy = newAccuracy; + return sht.setAccuracy(accuracy); +} + +bool SHTXXSensor::getMetrics(meshtastic_Telemetry *measurement) +{ + if (sht.readSample()) { + measurement->variant.environment_metrics.has_temperature = true; + measurement->variant.environment_metrics.has_relative_humidity = true; + measurement->variant.environment_metrics.temperature = sht.getTemperature(); + measurement->variant.environment_metrics.relative_humidity = sht.getHumidity(); + + LOG_INFO("%s (%s): Got: temp:%fdegC, hum:%f%%rh", sensorName, sensorVariant, + measurement->variant.environment_metrics.temperature, + measurement->variant.environment_metrics.relative_humidity); + + return true; + } else { + LOG_ERROR("%s (%s): read sample failed", sensorName, sensorVariant); + return false; + } +} + +AdminMessageHandleResult SHTXXSensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_shtxx_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + // Check for sensor accuracy setting + if (request->sensor_config.shtxx_config.has_set_accuracy) { + SHTSensor::SHTAccuracy newAccuracy; + if (request->sensor_config.shtxx_config.set_accuracy == 0) { + newAccuracy = SHTSensor::SHT_ACCURACY_LOW; + } else if (request->sensor_config.shtxx_config.set_accuracy == 1) { + newAccuracy = SHTSensor::SHT_ACCURACY_MEDIUM; + } else if (request->sensor_config.shtxx_config.set_accuracy == 2) { + newAccuracy = SHTSensor::SHT_ACCURACY_HIGH; + } else { + LOG_ERROR("%s: incorrect accuracy setting", sensorName); + result = AdminMessageHandleResult::HANDLED; + break; + } + this->setAccuracy(newAccuracy); + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + +#endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/SHTXXSensor.h b/src/modules/Telemetry/Sensor/SHTXXSensor.h new file mode 100644 index 000000000..e354e113f --- /dev/null +++ b/src/modules/Telemetry/Sensor/SHTXXSensor.h @@ -0,0 +1,29 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +class SHTXXSensor : public TelemetrySensor +{ + private: + SHTSensor sht; + TwoWire *_bus{}; + uint8_t _address{}; + SHTSensor::SHTAccuracy accuracy{}; + bool setAccuracy(SHTSensor::SHTAccuracy newAccuracy); + + public: + SHTXXSensor(); + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + void getSensorVariant(SHTSensor::SHTSensorType); + const char *sensorVariant{}; + + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif \ No newline at end of file diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp new file mode 100644 index 000000000..1ecb68c4b --- /dev/null +++ b/src/modules/TrafficManagementModule.cpp @@ -0,0 +1,1415 @@ +#include "TrafficManagementModule.h" + +#if HAS_TRAFFIC_MANAGEMENT + +#include "Default.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "Router.h" +#include "TypeConversions.h" +#include "airtime.h" +#include "concurrency/LockGuard.h" +#include "configuration.h" +#include "mesh-pb-constants.h" +#include "meshUtils.h" +#include +#include + +#define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) +#define TM_LOG_INFO(fmt, ...) LOG_INFO("[TM] " fmt, ##__VA_ARGS__) +#define TM_LOG_WARN(fmt, ...) LOG_WARN("[TM] " fmt, ##__VA_ARGS__) + +// ============================================================================= +// Anonymous Namespace - Internal Helpers +// ============================================================================= + +namespace +{ + +constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval +constexpr uint32_t kUnknownResetMs = 60 * 1000UL; // Unknown packet window +constexpr uint8_t kMaxCuckooKicks = 16; // Max displacement chain length + +// NodeInfo direct response: enforced maximum hops by device role +// Both use maxHops logic (respond when hopsAway <= threshold) +// Config value is clamped to these role-based limits +// Note: nodeinfo_direct_response must also be enabled for this to take effect +constexpr uint32_t kRouterDefaultMaxHops = 3; // Routers: max 3 hops (can set lower via config) +constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot increase) + +/** + * Convert seconds to milliseconds with overflow protection. + */ +uint32_t secsToMs(uint32_t secs) +{ + uint64_t ms = static_cast(secs) * 1000ULL; + if (ms > UINT32_MAX) + return UINT32_MAX; + return static_cast(ms); +} + +/** + * Clamp precision to a valid dedup range. + * Invalid values use the module default precision. + */ +uint8_t sanitizePositionPrecision(uint8_t precision) +{ + if (precision > 0 && precision <= 32) + return precision; + + const uint8_t defaultPrecision = static_cast(default_traffic_mgmt_position_precision_bits); + if (defaultPrecision > 0 && defaultPrecision <= 32) + return defaultPrecision; + + // Someone done messed up if we reach here + return 32; +} + +/** + * Check if a timestamp is within a time window. + * Handles wrap-around correctly using unsigned subtraction. + */ +bool isWithinWindow(uint32_t nowMs, uint32_t startMs, uint32_t intervalMs) +{ + if (intervalMs == 0 || startMs == 0) + return false; + return (nowMs - startMs) < intervalMs; +} + +/** + * Truncate lat/lon to specified precision for position deduplication. + * + * The truncation works by masking off lower bits and rounding to the center + * of the resulting grid cell. This creates a stable truncated value even + * when GPS jitter causes small coordinate changes. + * + * @param value Raw latitude_i or longitude_i from position + * @param precision Number of significant bits to keep (0-32) + * @return Truncated and centered coordinate value + */ +int32_t truncateLatLon(int32_t value, uint8_t precision) +{ + if (precision == 0 || precision >= 32) + return value; + + // Create mask to zero out lower bits + uint32_t mask = UINT32_MAX << (32 - precision); + uint32_t truncated = static_cast(value) & mask; + + // Add half the truncation step to center in the grid cell + truncated += (1u << (31 - precision)); + return static_cast(truncated); +} + +/** + * Saturating increment for uint8_t counters. + * Prevents overflow by capping at UINT8_MAX (255). + */ +inline void saturatingIncrement(uint8_t &counter) +{ + if (counter < UINT8_MAX) + counter++; +} + +/** + * Return a short human-readable name for common port numbers. + * Falls back to "port:" for unknown ports. + */ +const char *portName(int portnum) +{ + switch (portnum) { + case meshtastic_PortNum_TEXT_MESSAGE_APP: + return "text"; + case meshtastic_PortNum_POSITION_APP: + return "position"; + case meshtastic_PortNum_NODEINFO_APP: + return "nodeinfo"; + case meshtastic_PortNum_ROUTING_APP: + return "routing"; + case meshtastic_PortNum_ADMIN_APP: + return "admin"; + case meshtastic_PortNum_TELEMETRY_APP: + return "telemetry"; + case meshtastic_PortNum_TRACEROUTE_APP: + return "traceroute"; + case meshtastic_PortNum_NEIGHBORINFO_APP: + return "neighborinfo"; + case meshtastic_PortNum_STORE_FORWARD_APP: + return "store-forward"; + case meshtastic_PortNum_WAYPOINT_APP: + return "waypoint"; + default: + return nullptr; + } +} + +} // namespace + +// ============================================================================= +// Module Instance +// ============================================================================= + +TrafficManagementModule *trafficManagementModule; + +// ============================================================================= +// Constructor +// ============================================================================= + +TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") +{ + // Module configuration + isPromiscuous = true; // See all packets, not just those addressed to us + encryptedOk = true; // Can process encrypted packets + stats = meshtastic_TrafficManagementStats_init_zero; + + // Initialize rolling epoch for relative timestamps + cacheEpochMs = millis(); + + // Calculate adaptive time resolutions from config (config changes require reboot) + // Resolution = max(60, min(339, interval/2)) for ~24 hour range with good precision + posTimeResolution = calcTimeResolution(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + rateTimeResolution = calcTimeResolution(moduleConfig.traffic_management.rate_limit_window_secs); + unknownTimeResolution = calcTimeResolution(kUnknownResetMs / 1000); // ~5 min default + + const auto &cfg = moduleConfig.traffic_management; + TM_LOG_INFO("Enabled: pos_dedup=%d nodeinfo_resp=%d rate_limit=%d drop_unknown=%d exhaust_telem=%d exhaust_pos=%d " + "preserve_hops=%d", + cfg.position_dedup_enabled, cfg.nodeinfo_direct_response, cfg.rate_limit_enabled, cfg.drop_unknown_enabled, + cfg.exhaust_hop_telemetry, cfg.exhaust_hop_position, cfg.router_preserve_hops); + TM_LOG_DEBUG("Time resolutions: pos=%us, rate=%us, unknown=%us", posTimeResolution, rateTimeResolution, + unknownTimeResolution); + +// Allocate unified cache (10 bytes/entry for all platforms) +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + const uint16_t allocSize = cacheSize(); + TM_LOG_INFO("Allocating unified cache: %u entries (%u bytes)", allocSize, + static_cast(allocSize * sizeof(UnifiedCacheEntry))); + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + // ESP32 with PSRAM: prefer PSRAM for large allocations + cache = static_cast(ps_calloc(allocSize, sizeof(UnifiedCacheEntry))); + if (cache) { + cacheFromPsram = true; + } else { + TM_LOG_WARN("PSRAM allocation failed, falling back to heap"); + cache = new UnifiedCacheEntry[allocSize](); + } +#else + // All other platforms: heap allocation + cache = new UnifiedCacheEntry[allocSize](); +#endif + +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + TM_LOG_INFO("Allocating NodeInfo cache: target=%u occupancy=%u%% payload=%u bytes (PSRAM) tags=%u bytes (%u-bit, %u slots, " + "%u buckets x %u)", + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetOccupancyPercent()), + static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry)), + static_cast(nodeInfoIndexMetadataBudgetBytes()), static_cast(nodeInfoTagBits()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), + static_cast(nodeInfoBucketSize())); + + nodeInfoIndex = static_cast(calloc(nodeInfoIndexMetadataBudgetBytes(), sizeof(uint8_t))); + if (!nodeInfoIndex) { + TM_LOG_WARN("NodeInfo index allocation failed; direct responses will fall back to NodeDB"); + } else { + nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); + if (nodeInfoPayload) { + nodeInfoPayloadFromPsram = true; + TM_LOG_INFO("NodeInfo bucketed cuckoo cache ready"); + } else { + TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); + free(nodeInfoIndex); + nodeInfoIndex = nullptr; + } + } +#else + TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target"); +#endif + + setIntervalFromNow(kMaintenanceIntervalMs); +} + +// Cache may have been allocated via ps_calloc (PSRAM, C allocator) or new[] (heap). +// Must use the matching deallocator: free() for ps_calloc, delete[] for new[]. +TrafficManagementModule::~TrafficManagementModule() +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (cache) { + // Cache may be from ps_calloc (PSRAM, C allocator) or new[] (heap). + // Use the matching deallocator for the allocation source. + if (cacheFromPsram) + free(cache); + else + delete[] cache; + cache = nullptr; + } +#endif + + if (nodeInfoPayload) { + if (nodeInfoPayloadFromPsram) + free(nodeInfoPayload); + else + delete[] nodeInfoPayload; + nodeInfoPayload = nullptr; + } + + if (nodeInfoIndex) { + free(nodeInfoIndex); + nodeInfoIndex = nullptr; + } +} + +// ============================================================================= +// Statistics +// ============================================================================= + +meshtastic_TrafficManagementStats TrafficManagementModule::getStats() const +{ + concurrency::LockGuard guard(&cacheLock); + return stats; +} + +void TrafficManagementModule::resetStats() +{ + concurrency::LockGuard guard(&cacheLock); + stats = meshtastic_TrafficManagementStats_init_zero; +} + +void TrafficManagementModule::recordRouterHopPreserved() +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return; + incrementStat(&stats.router_hops_preserved); +} + +void TrafficManagementModule::incrementStat(uint32_t *field) +{ + concurrency::LockGuard guard(&cacheLock); + (*field)++; +} + +// ============================================================================= +// Cuckoo Hash Table Operations +// ============================================================================= + +/** + * Find an existing entry for the given node. + * + * Cuckoo hashing guarantees that if an entry exists, it's in one of exactly + * two locations: hash1(node) or hash2(node). This provides O(1) lookup. + * + * @param node NodeNum to search for + * @return Pointer to entry if found, nullptr otherwise + */ +TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + return nullptr; +#else + if (!cache || node == 0) + return nullptr; + + // Check primary location + uint16_t h1 = cuckooHash1(node); + if (cache[h1].node == node) + return &cache[h1]; + + // Check alternate location + uint16_t h2 = cuckooHash2(node); + if (cache[h2].node == node) + return &cache[h2]; + + return nullptr; +#endif +} + +/** + * Find or create an entry for the given node using cuckoo hashing. + * + * If the node exists, returns the existing entry. Otherwise, attempts to + * insert a new entry using cuckoo displacement: + * + * 1. Try to insert at h1(node) - if empty, done + * 2. Try to insert at h2(node) - if empty, done + * 3. Kick existing entry from h1 to its alternate location + * 4. Repeat up to kMaxCuckooKicks times + * 5. If cycle detected or max kicks exceeded, evict oldest entry + * + * @param node NodeNum to find or create + * @param isNew Set to true if a new entry was created + * @return Pointer to entry, or nullptr if allocation failed + */ +TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + if (isNew) + *isNew = false; + return nullptr; +#else + if (!cache || node == 0) { + if (isNew) + *isNew = false; + return nullptr; + } + + // Check if entry already exists (O(1) lookup) + uint16_t h1 = cuckooHash1(node); + if (cache[h1].node == node) { + if (isNew) + *isNew = false; + return &cache[h1]; + } + + uint16_t h2 = cuckooHash2(node); + if (cache[h2].node == node) { + if (isNew) + *isNew = false; + return &cache[h2]; + } + + // Entry doesn't exist - try to insert + + // Prefer empty slot at h1 + if (cache[h1].node == 0) { + memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); + cache[h1].node = node; + if (isNew) + *isNew = true; + return &cache[h1]; + } + + // Try empty slot at h2 + if (cache[h2].node == 0) { + memset(&cache[h2], 0, sizeof(UnifiedCacheEntry)); + cache[h2].node = node; + if (isNew) + *isNew = true; + return &cache[h2]; + } + + // Both slots occupied - perform cuckoo displacement + // Start by kicking entry at h1 to its alternate location + UnifiedCacheEntry displaced = cache[h1]; + memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); + cache[h1].node = node; + + for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { + // Find alternate location for displaced entry + uint16_t altH1 = cuckooHash1(displaced.node); + uint16_t altH2 = cuckooHash2(displaced.node); + uint16_t altSlot = (altH1 == h1) ? altH2 : altH1; + + if (cache[altSlot].node == 0) { + // Found empty slot - insert displaced entry + cache[altSlot] = displaced; + if (isNew) + *isNew = true; + return &cache[h1]; + } + + // Kick entry from alternate slot + UnifiedCacheEntry temp = cache[altSlot]; + cache[altSlot] = displaced; + displaced = temp; + h1 = altSlot; + } + + // Cuckoo cycle detected or max kicks exceeded. + // The displaced entry has no valid cuckoo slot — drop it to preserve cache integrity. + // Placing it at an arbitrary slot would make it unreachable by findEntry(). + TM_LOG_DEBUG("Cuckoo cycle, evicting node 0x%08x", displaced.node); + + if (isNew) + *isNew = true; + return &cache[cuckooHash1(node)]; +#endif +} + +const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return nullptr; + + uint16_t payloadIndex = findNodeInfoPayloadIndex(node); + if (payloadIndex >= nodeInfoTargetEntries()) + return nullptr; + + return &nodeInfoPayload[payloadIndex]; +#else + (void)node; + return nullptr; +#endif +} + +uint16_t TrafficManagementModule::encodeNodeInfoTag(uint16_t payloadIndex) const +{ + if (payloadIndex >= nodeInfoTargetEntries()) + return 0; + return static_cast(payloadIndex + 1u); +} + +uint16_t TrafficManagementModule::decodeNodeInfoPayloadIndex(uint16_t tag) const +{ + if (tag == 0 || tag > nodeInfoTargetEntries()) + return UINT16_MAX; + return static_cast(tag - 1u); +} + +uint16_t TrafficManagementModule::getNodeInfoTag(uint16_t slot) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) + return 0; + + const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); + const uint16_t byteOffset = static_cast(bitOffset >> 3); + const uint8_t shift = static_cast(bitOffset & 7u); + uint32_t packed = 0; + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset]); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; + + return static_cast((packed >> shift) & nodeInfoTagMask()); +#else + (void)slot; + return 0; +#endif +} + +void TrafficManagementModule::setNodeInfoTag(uint16_t slot, uint16_t tag) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) + return; + + const uint16_t normalizedTag = static_cast(tag & nodeInfoTagMask()); + const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); + const uint16_t byteOffset = static_cast(bitOffset >> 3); + const uint8_t shift = static_cast(bitOffset & 7u); + uint32_t packed = 0; + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset]); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; + + const uint32_t mask = static_cast(nodeInfoTagMask()) << shift; + packed = (packed & ~mask) | ((static_cast(normalizedTag) << shift) & mask); + + if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset] = static_cast(packed & 0xFFu); + if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset + 1u] = static_cast((packed >> 8) & 0xFFu); + if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) + nodeInfoIndex[byteOffset + 2u] = static_cast((packed >> 16) & 0xFFu); +#else + (void)slot; + (void)tag; +#endif +} + +uint16_t TrafficManagementModule::findNodeInfoPayloadIndex(NodeNum node) const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return UINT16_MAX; + + const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; + + for (uint8_t b = 0; b < 2; b++) { + const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + uint16_t tag = getNodeInfoTag(static_cast(base + slot)); + if (tag == 0) + continue; + + uint16_t payloadIndex = decodeNodeInfoPayloadIndex(tag); + if (payloadIndex >= nodeInfoTargetEntries()) + continue; + + if (nodeInfoPayload[payloadIndex].node == node) + return payloadIndex; + } + } + + return UINT16_MAX; +#else + (void)node; + return UINT16_MAX; +#endif +} + +bool TrafficManagementModule::removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || node == 0 || payloadIndex >= nodeInfoTargetEntries()) + return false; + + const uint16_t payloadTag = encodeNodeInfoTag(payloadIndex); + if (payloadTag == 0) + return false; + const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; + + for (uint8_t b = 0; b < 2; b++) { + const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + const uint16_t indexSlot = static_cast(base + slot); + if (getNodeInfoTag(indexSlot) == payloadTag) { + setNodeInfoTag(indexSlot, 0); + return true; + } + } + } + + return false; +#else + (void)node; + (void)payloadIndex; + return false; +#endif +} + +uint16_t TrafficManagementModule::allocateNodeInfoPayloadSlot() +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload) + return UINT16_MAX; + + for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { + uint16_t idx = static_cast((nodeInfoAllocHint + tries) % nodeInfoTargetEntries()); + if (nodeInfoPayload[idx].node == 0) { + nodeInfoAllocHint = static_cast((idx + 1u) % nodeInfoTargetEntries()); + return idx; + } + } +#endif + return UINT16_MAX; +} + +uint16_t TrafficManagementModule::evictNodeInfoPayloadSlot() +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex) + return UINT16_MAX; + + for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { + uint16_t idx = static_cast(nodeInfoEvictCursor % nodeInfoTargetEntries()); + nodeInfoEvictCursor = static_cast((nodeInfoEvictCursor + 1u) % nodeInfoTargetEntries()); + + NodeNum oldNode = nodeInfoPayload[idx].node; + if (oldNode == 0) + continue; + + removeNodeInfoIndexEntry(oldNode, idx); // best effort; cache tolerates occasional stale miss + nodeInfoPayload[idx].node = 0; + return idx; + } +#endif + return UINT16_MAX; +} + +bool TrafficManagementModule::tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex || !nodeInfoPayload || bucket >= nodeInfoBucketCount() || tag == 0) + return false; + + const uint16_t base = static_cast(bucket * nodeInfoBucketSize()); + for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { + const uint16_t indexSlot = static_cast(base + slot); + const uint16_t existingTag = getNodeInfoTag(indexSlot); + if (existingTag == 0) { + setNodeInfoTag(indexSlot, tag); + return true; + } + + // Opportunistically reuse stale tags that point at empty/invalid payload slots. + const uint16_t payloadIndex = decodeNodeInfoPayloadIndex(existingTag); + if (payloadIndex >= nodeInfoTargetEntries() || nodeInfoPayload[payloadIndex].node == 0) { + setNodeInfoTag(indexSlot, tag); + return true; + } + } +#else + (void)bucket; + (void)tag; +#endif + return false; +} + +TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, + bool *usedEmptySlot) +{ + if (usedEmptySlot) + *usedEmptySlot = false; + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + return nullptr; + + uint16_t existing = findNodeInfoPayloadIndex(node); + if (existing < nodeInfoTargetEntries()) + return &nodeInfoPayload[existing]; + + const uint16_t beforeCount = countNodeInfoEntriesLocked(); + + uint16_t payloadIndex = allocateNodeInfoPayloadSlot(); + if (payloadIndex == UINT16_MAX) { + payloadIndex = evictNodeInfoPayloadSlot(); + if (payloadIndex == UINT16_MAX) + return nullptr; + } + + nodeInfoPayload[payloadIndex].node = node; + + // 4-way bucketed cuckoo insertion mirrors Cuckoo Filter practice from + // Fan et al. (CoNEXT 2014): high occupancy with short relocation chains. + uint16_t pending = encodeNodeInfoTag(payloadIndex); + uint16_t h1 = nodeInfoHash1(node); + uint16_t h2 = nodeInfoHash2(node); + + if (!tryInsertNodeInfoEntryInBucket(h1, pending) && !tryInsertNodeInfoEntryInBucket(h2, pending)) { + uint16_t currentBucket = h1; + for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { + const uint16_t base = static_cast(currentBucket * nodeInfoBucketSize()); + const uint16_t kickSlot = static_cast((node + kicks) & (nodeInfoBucketSize() - 1u)); + const uint16_t pos = static_cast(base + kickSlot); + + uint16_t displaced = getNodeInfoTag(pos); + setNodeInfoTag(pos, pending); + pending = displaced; + + uint16_t displacedPayload = decodeNodeInfoPayloadIndex(pending); + if (displacedPayload >= nodeInfoTargetEntries()) { + pending = 0; + break; + } + + NodeNum displacedNode = nodeInfoPayload[displacedPayload].node; + if (displacedNode == 0) { + pending = 0; + break; + } + + uint16_t altH1 = nodeInfoHash1(displacedNode); + uint16_t altH2 = nodeInfoHash2(displacedNode); + uint16_t altBucket = (altH1 == currentBucket) ? altH2 : altH1; + + if (tryInsertNodeInfoEntryInBucket(altBucket, pending)) { + pending = 0; + break; + } + + currentBucket = altBucket; + } + + if (pending != 0) { + uint16_t droppedPayload = decodeNodeInfoPayloadIndex(pending); + if (droppedPayload < nodeInfoTargetEntries()) + nodeInfoPayload[droppedPayload].node = 0; + TM_LOG_DEBUG("NodeInfo bucketed cuckoo overflow, dropped payload idx=%u", + static_cast(droppedPayload < nodeInfoTargetEntries() ? droppedPayload : UINT16_MAX)); + } + } + + uint16_t finalIndex = findNodeInfoPayloadIndex(node); + if (finalIndex >= nodeInfoTargetEntries()) { + // New entry did not survive insertion chain. + if (payloadIndex < nodeInfoTargetEntries() && nodeInfoPayload[payloadIndex].node == node) + nodeInfoPayload[payloadIndex].node = 0; + return nullptr; + } + + if (usedEmptySlot) { + const uint16_t afterCount = countNodeInfoEntriesLocked(); + *usedEmptySlot = afterCount > beforeCount; + } + + return &nodeInfoPayload[finalIndex]; +#else + (void)node; + return nullptr; +#endif +} + +uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoIndex) + return 0; + + uint16_t count = 0; + for (uint16_t i = 0; i < nodeInfoIndexSlots(); i++) { + if (getNodeInfoTag(i) != 0) + count++; + } + return count; +#else + return 0; +#endif +} + +void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) +{ +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (!nodeInfoPayload || !nodeInfoIndex || mp.decoded.payload.size == 0) + return; + + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) + return; + + // Normalize user.id to the packet sender's node number. + snprintf(user.id, sizeof(user.id), "!%08x", getFrom(&mp)); + + bool usedEmptySlot = false; + uint16_t cachedCount = 0; + { + concurrency::LockGuard guard(&cacheLock); + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(getFrom(&mp), &usedEmptySlot); + if (!entry) + return; + + // Cache both payload and response metadata so direct replies can use + // richer context than "just the user protobuf" when PSRAM is present. + // This path is intentionally independent from NodeInfoModule/NodeDB. + entry->user = user; + entry->lastObservedMs = millis(); + entry->lastObservedRxTime = mp.rx_time; + entry->sourceChannel = mp.channel; + entry->hasDecodedBitfield = mp.decoded.has_bitfield; + entry->decodedBitfield = mp.decoded.bitfield; + + if (usedEmptySlot) + cachedCount = countNodeInfoEntriesLocked(); + } + + if (usedEmptySlot) { + TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u target (%u packed slots, %u-bit tags, %u-byte DRAM index)", + static_cast(cachedCount), static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoTagBits()), + static_cast(nodeInfoIndexMetadataBudgetBytes())); + } +#else + (void)mp; +#endif +} + +// ============================================================================= +// Epoch Management +// ============================================================================= + +/** + * Reset the timestamp epoch when relative offsets approach overflow. + * + * Called when epoch age exceeds ~19 hours (approaching 8-bit minute overflow). + * Invalidates all cached per-node traffic state. + */ +void TrafficManagementModule::resetEpoch(uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + TM_LOG_DEBUG("Resetting cache epoch"); + cacheEpochMs = nowMs; + + // Full flush avoids stale dedup identity/counters surviving epoch rollover. + memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); +#else + (void)nowMs; +#endif +} + +// ============================================================================= +// Position Hash (Compact Mode) +// ============================================================================= + +/** + * Compute 8-bit position fingerprint from truncated lat/lon coordinates. + * + * Unlike a hash, this is deterministic: adjacent grid cells have sequential + * fingerprints, so nearby positions never collide. The fingerprint extracts + * the lower 4 significant bits from each truncated coordinate. + * + * Example with precision=16: + * lat_truncated = 0x12340000 (top 16 bits significant) + * Significant portion = 0x1234, lower 4 bits = 0x4 + * + * fingerprint = (lat_low4 << 4) | lon_low4 = 8 bits total + * + * Collision: Two positions collide only if they differ by a multiple of 16 + * grid cells in BOTH lat and lon dimensions simultaneously - very unlikely + * for typical position update patterns. + * + * @param lat_truncated Precision-truncated latitude + * @param lon_truncated Precision-truncated longitude + * @param precision Number of significant bits (1-32) + * @return 8-bit fingerprint (4 bits lat + 4 bits lon) + */ +uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision) +{ + precision = sanitizePositionPrecision(precision); + + // Guard: if precision < 4, we have fewer bits to work with + // Take min(precision, 4) bits from each coordinate + uint8_t bitsToTake = (precision < 4) ? precision : 4; + + // Shift to move significant bits to bottom, then mask lower bits + // For precision=16: shift by 16 to get the 16 significant bits at bottom + uint8_t shift = 32 - precision; + uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); + uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); + + return static_cast((latBits << 4) | lonBits); +} + +// ============================================================================= +// Packet Handling +// ============================================================================= + +// Processing order matters: this module runs BEFORE RoutingModule in the callModules() loop. +// - STOP prevents RoutingModule from calling sniffReceived() → perhapsRebroadcast(), +// so the packet is fully consumed (not forwarded). +// - ignoreRequest suppresses the default "no one responded" NAK for want_response packets. +// - exhaustRequested is set by alterReceived() and checked by perhapsRebroadcast() to +// force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. +ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return ProcessMessage::CONTINUE; + + ignoreRequest = false; + exhaustRequested = false; // Reset per-packet; may be set by alterReceived() below + exhaustRequestedFrom = 0; + exhaustRequestedId = 0; + incrementStat(&stats.packets_inspected); + + const auto &cfg = moduleConfig.traffic_management; + const uint32_t nowMs = millis(); + + // ------------------------------------------------------------------------- + // Undecoded Packet Handling + // ------------------------------------------------------------------------- + // Packets we can't decode (wrong key, corruption, etc.) may indicate + // a misbehaving node. Track and optionally drop repeat offenders. + + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + if (cfg.drop_unknown_enabled && cfg.unknown_packet_threshold > 0) { + if (shouldDropUnknown(&mp, nowMs)) { + logAction("drop", &mp, "unknown"); + incrementStat(&stats.unknown_packet_drops); + ignoreRequest = true; // Suppress NAK for want_response packets + return ProcessMessage::STOP; // Consumed — will not be rebroadcast + } + } + return ProcessMessage::CONTINUE; + } + + // Learn NodeInfo payloads into the dedicated PSRAM cache. + if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) + cacheNodeInfoPacket(mp); + + // ------------------------------------------------------------------------- + // NodeInfo Direct Response + // ------------------------------------------------------------------------- + // When we see a unicast NodeInfo request for a node we know about, + // respond directly from cache instead of forwarding the request. + // STOP prevents the request from being rebroadcast toward the target node, + // and our cached response is sent back to the requestor with hop_limit=0. + + if (cfg.nodeinfo_direct_response && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && + !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { + if (shouldRespondToNodeInfo(&mp, true)) { + meshtastic_User requester = meshtastic_User_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { + nodeDB->updateUser(getFrom(&mp), requester, mp.channel); + } + logAction("respond", &mp, "nodeinfo-cache"); + incrementStat(&stats.nodeinfo_cache_hits); + ignoreRequest = true; // We responded; suppress default NAK + return ProcessMessage::STOP; // Consumed — request will not be forwarded + } + } + + // ------------------------------------------------------------------------- + // Position Deduplication + // ------------------------------------------------------------------------- + // Drop position broadcasts that haven't moved significantly since the + // last broadcast from this node. Uses truncated coordinates to ignore + // GPS jitter within the configured precision. + + if (!isFromUs(&mp) && !isToUs(&mp)) { + if (cfg.position_dedup_enabled && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { + meshtastic_Position pos = meshtastic_Position_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { + if (shouldDropPosition(&mp, &pos, nowMs)) { + logAction("drop", &mp, "position-dedup"); + incrementStat(&stats.position_dedup_drops); + ignoreRequest = true; // Suppress NAK + return ProcessMessage::STOP; // Consumed — duplicate will not be rebroadcast + } + } + } + + // --------------------------------------------------------------------- + // Rate Limiting + // --------------------------------------------------------------------- + // Throttle nodes sending too many packets within a time window. + // Excludes routing and admin packets which are essential for mesh operation. + + if (cfg.rate_limit_enabled && cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { + if (mp.decoded.portnum != meshtastic_PortNum_ROUTING_APP && mp.decoded.portnum != meshtastic_PortNum_ADMIN_APP) { + if (isRateLimited(mp.from, nowMs)) { + logAction("drop", &mp, "rate-limit"); + incrementStat(&stats.rate_limit_drops); + ignoreRequest = true; // Suppress NAK + return ProcessMessage::STOP; // Consumed — throttled packet will not be rebroadcast + } + } + } + } + + return ProcessMessage::CONTINUE; +} + +void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return; + + if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return; + + if (isFromUs(&mp)) + return; + + // ------------------------------------------------------------------------- + // Relayed Broadcast Hop Exhaustion + // ------------------------------------------------------------------------- + // For relayed telemetry or position broadcasts from other nodes, optionally + // set hop_limit=0 so they don't propagate further through the mesh. + + const auto &cfg = moduleConfig.traffic_management; + const bool isTelemetry = mp.decoded.portnum == meshtastic_PortNum_TELEMETRY_APP; + const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; + // Only exhaust telemetry hops when channel is actually congested, mirroring the same + // airtime checks that gate self-generated telemetry in the telemetry modules. + const bool channelBusy = airTime && (!airTime->isTxAllowedChannelUtil(true) || !airTime->isTxAllowedAirUtil()); + const bool shouldExhaust = + ((channelBusy && isTelemetry && cfg.exhaust_hop_telemetry) || (isPosition && cfg.exhaust_hop_position)); + + if (!shouldExhaust || !isBroadcast(mp.to)) + return; + + if (mp.hop_limit > 0) { + const char *reason = isTelemetry ? "exhaust-hop-telemetry" : "exhaust-hop-position"; + logAction("exhaust", &mp, reason); + // Adjust hop_start so downstream nodes compute correct hopsAway (hop_start - hop_limit). + // Without this, hop_limit=0 with original hop_start would show inflated hopsAway. + mp.hop_start = mp.hop_start - mp.hop_limit + 1; + mp.hop_limit = 0; + // Signal perhapsRebroadcast() to allow one final relay with hop_limit=0. + // Without this flag, perhapsRebroadcast() would skip the packet since hop_limit==0. + // The packet-scoped flag is checked in NextHopRouter::perhapsRebroadcast() + // and forces tosend->hop_limit=0, ensuring no further propagation beyond the + // next node. + exhaustRequested = true; + exhaustRequestedFrom = getFrom(&mp); + exhaustRequestedId = mp.id; + incrementStat(&stats.hop_exhausted_packets); + } +} + +// ============================================================================= +// Periodic Maintenance +// ============================================================================= + +int32_t TrafficManagementModule::runOnce() +{ + if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + return INT32_MAX; + +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + const uint32_t nowMs = millis(); + + // Check if epoch reset needed (~3.5 hours approaching 8-bit minute overflow) + if (needsEpochReset(nowMs)) { + concurrency::LockGuard guard(&cacheLock); + resetEpoch(nowMs); + return kMaintenanceIntervalMs; + } + + // Calculate TTLs for cache expiration + const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + const uint32_t positionTtlMs = positionIntervalMs * 4; + + const uint32_t rateIntervalMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + const uint32_t rateTtlMs = (rateIntervalMs > 0) ? rateIntervalMs * 2 : (10 * 60 * 1000UL); + + const uint32_t unknownTtlMs = kUnknownResetMs * 5; + + // Sweep cache and clear expired entries + uint16_t activeEntries = 0; + uint16_t expiredEntries = 0; + const uint32_t sweepStartMs = millis(); + + concurrency::LockGuard guard(&cacheLock); + for (uint16_t i = 0; i < cacheSize(); i++) { + if (cache[i].node == 0) + continue; + + bool anyValid = false; + + // Check and clear expired position data + if (cache[i].pos_time != 0) { + uint32_t posTimeMs = fromRelativePosTime(cache[i].pos_time); + if (!isWithinWindow(nowMs, posTimeMs, positionTtlMs)) { + cache[i].pos_fingerprint = 0; + cache[i].pos_time = 0; + } else { + anyValid = true; + } + } + + // Check and clear expired rate limit data + if (cache[i].rate_time != 0) { + uint32_t rateTimeMs = fromRelativeRateTime(cache[i].rate_time); + if (!isWithinWindow(nowMs, rateTimeMs, rateTtlMs)) { + cache[i].rate_count = 0; + cache[i].rate_time = 0; + } else { + anyValid = true; + } + } + + // Check and clear expired unknown tracking data + if (cache[i].unknown_time != 0) { + uint32_t unknownTimeMs = fromRelativeUnknownTime(cache[i].unknown_time); + if (!isWithinWindow(nowMs, unknownTimeMs, unknownTtlMs)) { + cache[i].unknown_count = 0; + cache[i].unknown_time = 0; + } else { + anyValid = true; + } + } + + // If all data expired, free the slot entirely + if (!anyValid) { + memset(&cache[i], 0, sizeof(UnifiedCacheEntry)); + expiredEntries++; + } else { + activeEntries++; + } + } + + TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, + static_cast(activeEntries), static_cast(cacheSize()), + static_cast(millis() - sweepStartMs)); + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + if (nodeInfoPayload && nodeInfoIndex) { + TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u target (%u packed slots, %u buckets, %u-bit tags, %u-byte index)", + static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), + static_cast(nodeInfoTagBits()), static_cast(nodeInfoIndexMetadataBudgetBytes())); + } +#endif + +#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + + return kMaintenanceIntervalMs; +} + +// ============================================================================= +// Traffic Management Logic +// ============================================================================= + +bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)p; + (void)pos; + (void)nowMs; + return false; +#else + if (!pos->has_latitude_i || !pos->has_longitude_i) + return false; + + uint8_t precision = Default::getConfiguredOrDefault(moduleConfig.traffic_management.position_precision_bits, + default_traffic_mgmt_position_precision_bits); + precision = sanitizePositionPrecision(precision); + + const int32_t lat_truncated = truncateLatLon(pos->latitude_i, precision); + const int32_t lon_truncated = truncateLatLon(pos->longitude_i, precision); + const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); + const uint32_t minIntervalMs = secsToMs(Default::getConfiguredOrDefault( + moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(p->from, &isNew); + if (!entry) + return false; + + // Compare fingerprint and check time window + // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop) + const bool hasPositionState = !isNew && entry->pos_time != 0; + const bool samePosition = hasPositionState && entry->pos_fingerprint == fingerprint; + const bool withinInterval = + hasPositionState && (minIntervalMs != 0) && isWithinWindow(nowMs, fromRelativePosTime(entry->pos_time), minIntervalMs); + + TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, + entry->pos_fingerprint, samePosition, withinInterval, isNew); + + // Update cache entry + entry->pos_fingerprint = fingerprint; + entry->pos_time = toRelativePosTime(nowMs); + + // Drop only if same position AND within the minimum interval + return samePosition && withinInterval; +#endif +} + +bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse) +{ + // Caller already verified: nodeinfo_direct_response, portnum, want_response, + // !isBroadcast, !isToUs, !isFromUs + + if (!isMinHopsFromRequestor(p)) + return false; + + meshtastic_User cachedUser = meshtastic_User_init_zero; + bool hasCachedUser = false; + + // Extra metadata consumed only by the PSRAM-backed cache path. + // Defaults preserve previous behavior when cache metadata is unavailable. + bool cachedHasDecodedBitfield = false; + uint8_t cachedDecodedBitfield = 0; + uint8_t cachedSourceChannel = 0; + uint32_t cachedLastObservedMs = 0; + uint32_t cachedLastObservedRxTime = 0; + + { + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(p->to); + if (entry) { + cachedUser = entry->user; + hasCachedUser = true; + cachedHasDecodedBitfield = entry->hasDecodedBitfield; + cachedDecodedBitfield = entry->decodedBitfield; + cachedSourceChannel = entry->sourceChannel; + cachedLastObservedMs = entry->lastObservedMs; + cachedLastObservedRxTime = entry->lastObservedRxTime; + } + } + + if (!hasCachedUser) { + // If the PSRAM cache exists but misses, we intentionally do not fall back + // to the node-wide table. This keeps the PSRAM direct-reply path separate + // from NodeInfoModule/NodeDB behavior when PSRAM is available. + if (nodeInfoPayload && nodeInfoIndex) { + TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to); + return false; + } + + // Fallback only when PSRAM cache is unavailable on this target. + // In this mode we use the node-wide table maintained by NodeInfoModule. + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); + if (!node || !node->has_user) + return false; + cachedUser = TypeConversions::ConvertToUser(node->num, node->user); + } + + if (!sendResponse) + return true; + + meshtastic_MeshPacket *reply = router->allocForSending(); + if (!reply) { + TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); + return false; + } + + reply->decoded.portnum = meshtastic_PortNum_NODEINFO_APP; + reply->decoded.payload.size = + pb_encode_to_bytes(reply->decoded.payload.bytes, sizeof(reply->decoded.payload.bytes), &meshtastic_User_msg, &cachedUser); + reply->decoded.want_response = false; + + // Start from cached bitfield metadata when available. This lets direct + // responses preserve more of the original packet semantics (PSRAM path), + // while still enforcing local policy for OK_TO_MQTT below. + if (cachedHasDecodedBitfield) + reply->decoded.bitfield = cachedDecodedBitfield; + else + reply->decoded.bitfield = 0; + + // Respect the node-wide config_ok_to_mqtt setting for direct NodeInfo replies. + // This response is spoofed from another node, so Router::perhapsEncode() + // will not auto-populate the bitfield via config_ok_to_mqtt for us. + reply->decoded.has_bitfield = true; + // Update only the OK_TO_MQTT bit; keep any other cached bits intact. + reply->decoded.bitfield &= ~BITFIELD_OK_TO_MQTT_MASK; + if (config.lora.config_ok_to_mqtt) + reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; + + if (hasCachedUser && cachedLastObservedMs != 0) { + uint32_t ageMs = millis() - cachedLastObservedMs; + TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, + static_cast(ageMs), static_cast(cachedSourceChannel), + static_cast(p->channel), static_cast(cachedLastObservedRxTime)); + } + + // Spoof the sender as the target node so the requestor sees a valid NodeInfo response. + // hop_limit=0 ensures this reply travels only one hop (direct to requestor). + reply->from = p->to; + reply->to = getFrom(p); + reply->channel = p->channel; + reply->decoded.request_id = p->id; + reply->hop_limit = 0; + // hop_start=0 is set explicitly because Router::send() only sets it for isFromUs(), + // and our spoofed from means isFromUs() is false. + reply->hop_start = 0; + reply->next_hop = nodeDB->getLastByteOfNodeNum(getFrom(p)); + reply->priority = meshtastic_MeshPacket_Priority_DEFAULT; + + service->sendToMesh(reply); + return true; +} + +bool TrafficManagementModule::isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const +{ + int8_t hopsAway = getHopsAway(*p, -1); + if (hopsAway < 0) + return false; + + // Both routers and clients use maxHops logic (respond when hopsAway <= threshold) + // Role determines the maximum allowed value (enforced limit, not just default) + bool isRouter = IS_ONE_OF(config.device.role, meshtastic_Config_DeviceConfig_Role_ROUTER, + meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_CLIENT_BASE); + + uint32_t roleLimit = isRouter ? kRouterDefaultMaxHops : kClientDefaultMaxHops; + uint32_t configValue = moduleConfig.traffic_management.nodeinfo_direct_response_max_hops; + + // Use config value if set, otherwise use role default, but always clamp to role limit + uint32_t maxHops = (configValue > 0) ? configValue : roleLimit; + if (maxHops > roleLimit) + maxHops = roleLimit; + + bool result = static_cast(hopsAway) <= maxHops; + TM_LOG_DEBUG("NodeInfo hops check: hopsAway=%d maxHops=%u roleLimit=%u isRouter=%d -> %s", hopsAway, maxHops, roleLimit, + isRouter, result ? "respond" : "skip"); + return result; +} + +bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)from; + (void)nowMs; + return false; +#else + const uint32_t windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + if (windowMs == 0 || moduleConfig.traffic_management.rate_limit_max_packets == 0) + return false; + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(from, &isNew); + if (!entry) + return false; + + // Check if window has expired + if (isNew || !isWithinWindow(nowMs, fromRelativeRateTime(entry->rate_time), windowMs)) { + entry->rate_time = toRelativeRateTime(nowMs); + entry->rate_count = 1; + return false; + } + + // Increment counter (saturates at 255) + saturatingIncrement(entry->rate_count); + + // Check against threshold (uint8_t max is 255, but config is uint32_t) + uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; + if (threshold > 255) + threshold = 255; + + bool limited = entry->rate_count > threshold; + if (limited || entry->rate_count == threshold) { + TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, entry->rate_count, threshold, + limited ? "DROP" : "at-limit"); + } + return limited; +#endif +} + +bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)p; + (void)nowMs; + return false; +#else + if (!moduleConfig.traffic_management.drop_unknown_enabled || moduleConfig.traffic_management.unknown_packet_threshold == 0) + return false; + + uint32_t windowMs = kUnknownResetMs; + if (moduleConfig.traffic_management.rate_limit_window_secs > 0) + windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + + bool isNew = false; + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findOrCreateEntry(p->from, &isNew); + if (!entry) + return false; + + // Check if window has expired + if (isNew || !isWithinWindow(nowMs, fromRelativeUnknownTime(entry->unknown_time), windowMs)) { + entry->unknown_time = toRelativeUnknownTime(nowMs); + entry->unknown_count = 0; + } + + // Increment counter (saturates at 255) + saturatingIncrement(entry->unknown_count); + + // Check against threshold + uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; + if (threshold > 255) + threshold = 255; + + bool drop = entry->unknown_count > threshold; + if (drop || entry->unknown_count == threshold) { + TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, entry->unknown_count, threshold, + drop ? "DROP" : "at-limit"); + } + return drop; +#endif +} + +void TrafficManagementModule::logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const +{ + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + const char *name = portName(p->decoded.portnum); + if (name) { + TM_LOG_INFO("%s %s from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, name, getFrom(p), p->to, p->hop_limit, + p->hop_start, reason); + } else { + TM_LOG_INFO("%s port=%d from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, p->decoded.portnum, getFrom(p), p->to, + p->hop_limit, p->hop_start, reason); + } + } else { + TM_LOG_INFO("%s encrypted from=0x%08x to=0x%08x hop=%d/%d reason=%s", action, getFrom(p), p->to, p->hop_limit, + p->hop_start, reason); + } +} + +#endif diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h new file mode 100644 index 000000000..fe3483a8e --- /dev/null +++ b/src/modules/TrafficManagementModule.h @@ -0,0 +1,434 @@ +#pragma once + +#include "MeshModule.h" +#include "concurrency/Lock.h" +#include "concurrency/OSThread.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include "mesh/generated/meshtastic/telemetry.pb.h" + +#if HAS_TRAFFIC_MANAGEMENT + +/** + * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. + * + * This module provides: + * - Position deduplication (drop redundant position broadcasts) + * - Per-node rate limiting (throttle chatty nodes) + * - Unknown packet filtering (drop undecoded packets from repeat offenders) + * - NodeInfo direct response (answer queries from cache to reduce mesh chatter) + * - Local-only telemetry/position (exhaust hop_limit for local broadcasts) + * - Router hop preservation (maintain hop_limit for router-to-router traffic) + * + * Memory Optimization: + * Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction + * compared to separate per-feature caches. Timestamps are stored as 8-bit relative + * offsets from a rolling epoch to further reduce memory footprint. + */ +class TrafficManagementModule : public MeshModule, private concurrency::OSThread +{ + public: + TrafficManagementModule(); + ~TrafficManagementModule(); + + // Singleton — no copying or moving + TrafficManagementModule(const TrafficManagementModule &) = delete; + TrafficManagementModule &operator=(const TrafficManagementModule &) = delete; + + meshtastic_TrafficManagementStats getStats() const; + void resetStats(); + void recordRouterHopPreserved(); + + /** + * Check if this packet should have its hops exhausted. + * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of + * router_preserve_hops or favorite node logic. + */ + bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const + { + return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id; + } + + protected: + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + bool wantPacket(const meshtastic_MeshPacket *p) override { return true; } + void alterReceived(meshtastic_MeshPacket &mp) override; + int32_t runOnce() override; + // Protected so test shims can force epoch rollover behavior. + void resetEpoch(uint32_t nowMs); + + private: + // ========================================================================= + // Unified Cache Entry (10 bytes) - Same for ALL platforms + // ========================================================================= + // + // A single compact structure used across ESP32, NRF52, and all other platforms. + // Memory: 10 bytes × 2048 entries = 20KB + // + // Position Fingerprinting: + // Instead of storing full coordinates (8 bytes) or a computed hash, + // we store an 8-bit fingerprint derived deterministically from the + // truncated lat/lon. This extracts the lower 4 significant bits from + // each coordinate: fingerprint = (lat_low4 << 4) | lon_low4 + // + // Benefits over hash: + // - Adjacent grid cells have sequential fingerprints (no collision) + // - Two positions only collide if 16+ grid cells apart in BOTH dimensions + // - Deterministic: same input always produces same output + // + // Adaptive Timestamp Resolution: + // All timestamps use 8-bit values with adaptive resolution calculated + // from config at startup. Resolution = max(60, min(339, interval/2)). + // - Min 60 seconds ensures reasonable precision + // - Max 339 seconds allows ~24 hour range (255 * 339 = 86445 sec) + // - interval/2 ensures at least 2 ticks per configured interval + // + // Layout: + // [0-3] node - NodeNum (4 bytes) + // [4] pos_fingerprint - 4 bits lat + 4 bits lon (1 byte) + // [5] rate_count - Packets in current window (1 byte) + // [6] unknown_count - Unknown packets count (1 byte) + // [7] pos_time - Position timestamp (1 byte, adaptive resolution) + // [8] rate_time - Rate window start (1 byte, adaptive resolution) + // [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution) + // + struct __attribute__((packed)) UnifiedCacheEntry { + NodeNum node; // 4 bytes - Node identifier (0 = empty slot) + uint8_t pos_fingerprint; // 1 byte - Lower 4 bits of lat + lon + uint8_t rate_count; // 1 byte - Packet count (saturates at 255) + uint8_t unknown_count; // 1 byte - Unknown packet count (saturates at 255) + uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution) + uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution) + uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution) + }; + static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes"); + + // ========================================================================= + // Cuckoo Hash Table Implementation + // ========================================================================= + // + // Cuckoo hashing provides O(1) worst-case lookup time using two hash functions. + // Each key can be in one of two possible locations (h1 or h2). On collision, + // the existing entry is "kicked" to its alternate location. + // + // Benefits over linear scan: + // - O(1) lookup vs O(n) - critical at packet processing rates + // - O(1) insertion (amortized) with simple eviction on cycles + // - ~95% load factor achievable + // + // Cache size rounds to power-of-2 for fast modulo via bitmask. + // TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048 + // + static constexpr uint16_t cacheSize(); + static constexpr uint16_t cacheMask(); + + // Hash functions for cuckoo hashing + inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); } + inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); } + static constexpr uint8_t cuckooHashBits(); + + // NodeInfo cache configuration (PSRAM path): + // - Payload lives in PSRAM + // - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing + // (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty". + static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store + static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95; + static constexpr uint8_t kNodeInfoBucketSize = 4; + static constexpr uint8_t kNodeInfoTagBits = 12; + static constexpr uint16_t kNodeInfoTagMask = static_cast((1u << kNodeInfoTagBits) - 1u); + static constexpr uint16_t kNodeInfoIndexSlotsRaw = + static_cast((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits); + static constexpr uint16_t kNodeInfoIndexSlots = + static_cast(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize)); + static constexpr uint16_t kNodeInfoTargetEntries = + static_cast((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u); + static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size"); + static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index"); + + static constexpr uint16_t nodeInfoTargetEntries(); + static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes(); + static constexpr uint8_t nodeInfoTargetOccupancyPercent(); + static constexpr uint8_t nodeInfoBucketSize(); + static constexpr uint8_t nodeInfoTagBits(); + static constexpr uint16_t nodeInfoTagMask(); + static constexpr uint16_t nodeInfoIndexSlots(); + static constexpr uint16_t nodeInfoBucketCount(); + static constexpr uint16_t nodeInfoBucketMask(); + static constexpr uint8_t nodeInfoBucketHashBits(); + inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); } + inline uint16_t nodeInfoHash2(NodeNum node) const + { + return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask(); + } + + // ========================================================================= + // Adaptive Timestamp Resolution + // ========================================================================= + // + // All timestamps use 8-bit values with adaptive resolution calculated from + // config at startup. This allows ~24 hour range while maintaining precision. + // + // Resolution formula: max(60, min(339, interval/2)) + // - 60 sec minimum ensures reasonable precision + // - 339 sec maximum allows 24 hour range (255 * 339 ≈ 86400 sec) + // - interval/2 ensures at least 2 ticks per configured interval + // + // Since config changes require reboot, resolution is calculated once. + // + uint32_t cacheEpochMs = 0; + uint16_t posTimeResolution = 60; // Seconds per tick for position + uint16_t rateTimeResolution = 60; // Seconds per tick for rate limiting + uint16_t unknownTimeResolution = 60; // Seconds per tick for unknown tracking + + // Calculate resolution from configured interval (called once at startup) + static uint16_t calcTimeResolution(uint32_t intervalSecs) + { + // Resolution = interval/2 to ensure at least 2 ticks per interval + // Clamped to [60, 339] for min precision and max 24h range + uint32_t res = (intervalSecs > 0) ? (intervalSecs / 2) : 60; + if (res < 60) + res = 60; + if (res > 339) + res = 339; + return static_cast(res); + } + + // Convert to/from 8-bit relative timestamps with given resolution + uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const + { + uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL); + return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast(ticks); + } + uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const + { + return cacheEpochMs + (static_cast(ticks) * resolutionSecs * 1000UL); + } + + // Convenience wrappers for each timestamp type + uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); } + uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); } + + uint8_t toRelativeRateTime(uint32_t nowMs) const { return toRelativeTime(nowMs, rateTimeResolution); } + uint32_t fromRelativeRateTime(uint8_t t) const { return fromRelativeTime(t, rateTimeResolution); } + + uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); } + uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); } + + // Epoch reset when any timestamp approaches overflow + // With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max) + bool needsEpochReset(uint32_t nowMs) const + { + uint16_t maxRes = posTimeResolution; + if (rateTimeResolution > maxRes) + maxRes = rateTimeResolution; + if (unknownTimeResolution > maxRes) + maxRes = unknownTimeResolution; + return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL); + } + // ========================================================================= + // Position Fingerprint + // ========================================================================= + // + // Computes 8-bit fingerprint from truncated lat/lon coordinates. + // Extracts lower 4 significant bits from each coordinate. + // + // fingerprint = (lat_low4 << 4) | lon_low4 + // + // Unlike a hash, adjacent grid cells have sequential fingerprints, + // so nearby positions never collide. Collisions only occur for + // positions 16+ grid cells apart in both dimensions. + // + // Guards: If precision < 4 bits, uses min(precision, 4) bits. + // + static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision); + + // ========================================================================= + // Cache Storage + // ========================================================================= + + mutable concurrency::Lock cacheLock; // Protects all cache access + UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms) + bool cacheFromPsram = false; // Tracks allocator for correct deallocation + + struct NodeInfoPayloadEntry { + // Node identifier associated with this payload slot. + // 0 means the slot is currently unused. + NodeNum node; + + // Cached NODEINFO_APP payload body. This is separate from NodeDB and is only + // used by the PSRAM-backed direct-response path in this module. + meshtastic_User user; + + // Extra response metadata captured from the latest observed NODEINFO_APP + // packet for this node. shouldRespondToNodeInfo() uses this metadata when + // building spoofed replies for requesting clients. + + // Last local uptime tick (millis) when this entry was refreshed. + uint32_t lastObservedMs; + + // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. + // If unavailable in packet, remains 0. + uint32_t lastObservedRxTime; + + // Channel where we most recently heard this node's NodeInfo. + uint8_t sourceChannel; + + // Cached decoded bitfield metadata from the source packet. + // We preserve non-OK_TO_MQTT bits in direct replies when available. + bool hasDecodedBitfield; + uint8_t decodedBitfield; + }; + + NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM + bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation + uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM + uint16_t nodeInfoAllocHint = 0; + uint16_t nodeInfoEvictCursor = 0; + + meshtastic_TrafficManagementStats stats; + + // Flag set during alterReceived() when packet should be exhausted. + // Checked by perhapsRebroadcast() to force hop_limit = 0 only for the + // matching packet key (from + id). Reset at start of handleReceived(). + bool exhaustRequested = false; + NodeNum exhaustRequestedFrom = 0; + PacketId exhaustRequestedId = 0; + + // ========================================================================= + // Cache Operations + // ========================================================================= + + // Find or create entry for node using cuckoo hashing + // Returns nullptr if cache is full and eviction fails + UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew); + + // Find existing entry (no creation) + UnifiedCacheEntry *findEntry(NodeNum node); + + // NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads) + const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; + NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot); + uint16_t findNodeInfoPayloadIndex(NodeNum node) const; + bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex); + uint16_t allocateNodeInfoPayloadSlot(); + uint16_t evictNodeInfoPayloadSlot(); + bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag); + uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const; + uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const; + uint16_t getNodeInfoTag(uint16_t slot) const; + void setNodeInfoTag(uint16_t slot, uint16_t tag); + uint16_t countNodeInfoEntriesLocked() const; + void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); + + // ========================================================================= + // Traffic Management Logic + // ========================================================================= + + bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs); + bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse); + bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const; + bool isRateLimited(NodeNum from, uint32_t nowMs); + bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs); + + void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const; + void incrementStat(uint32_t *field); +}; + +// ========================================================================= +// Compile-time Cache Size Calculations +// ========================================================================= +// +// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient +// cuckoo hash indexing (allows bitmask instead of modulo). +// +// These use C++11-compatible constexpr (single return statement). +// + +namespace detail +{ +// Helper: round up to next power of 2 using bit manipulation +constexpr uint16_t nextPow2(uint16_t n) +{ + return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1); +} + +// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr. +constexpr uint8_t log2Floor(uint16_t n) +{ + return n <= 1 ? 0 : static_cast(1 + log2Floor(static_cast(n >> 1))); +} + +// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr. +constexpr uint8_t log2Ceil(uint16_t n) +{ + return n <= 1 ? 0 : static_cast(1 + log2Floor(static_cast(n - 1))); +} +} // namespace detail + +constexpr uint16_t TrafficManagementModule::cacheSize() +{ + return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE); +} + +constexpr uint16_t TrafficManagementModule::cacheMask() +{ + return cacheSize() > 0 ? cacheSize() - 1 : 0; +} + +constexpr uint8_t TrafficManagementModule::cuckooHashBits() +{ + return detail::log2Floor(cacheSize()); +} + +constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries() +{ + return kNodeInfoTargetEntries; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes() +{ + return kNodeInfoIndexMetadataBudgetBytes; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent() +{ + return kNodeInfoTargetOccupancyPercent; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize() +{ + return kNodeInfoBucketSize; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoTagBits() +{ + return kNodeInfoTagBits; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoTagMask() +{ + return kNodeInfoTagMask; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots() +{ + return kNodeInfoIndexSlots; +} + +constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount() +{ + return static_cast(nodeInfoIndexSlots() / nodeInfoBucketSize()); +} + +constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask() +{ + return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0; +} + +constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits() +{ + return detail::log2Floor(nodeInfoBucketCount()); +} + +extern TrafficManagementModule *trafficManagementModule; + +#endif diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index 77cc94359..37e3e9184 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -100,7 +100,7 @@ AudioModule::AudioModule() : SinglePortModule("Audio", meshtastic_PortNum_AUDIO_ // moduleConfig.audio.i2s_sck = 14; // moduleConfig.audio.ptt_pin = 39; - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { LOG_INFO("Set up codec2 in mode %u", (moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1); codec2 = codec2_create((moduleConfig.audio.bitrate ? moduleConfig.audio.bitrate : AUDIO_MODULE_MODE) - 1); memcpy(tx_header.magic, c2_magic, sizeof(c2_magic)); @@ -143,7 +143,7 @@ void AudioModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int int32_t AudioModule::runOnce() { - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { esp_err_t res; if (firstTime) { // Set up I2S Processor configuration. This will produce 16bit samples at 8 kHz instead of 12 from the ADC @@ -270,7 +270,7 @@ void AudioModule::sendPayload(NodeNum dest, bool wantReplies) ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp) { - if ((moduleConfig.audio.codec2_enabled) && (myRegion->audioPermitted)) { + if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) { auto &p = mp.decoded; if (!isFromUs(&mp)) { memcpy(rx_encode_frame, p.payload.bytes, p.payload.size); diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index ac022a1ab..aba06c210 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -322,8 +322,8 @@ bool connectPubSub(const PubSubConfig &config, PubSubClient &pubSub, Client &cli pubSub.setClient(client); pubSub.setServer(config.serverAddr.c_str(), config.serverPort); - LOG_INFO("Connecting directly to MQTT server %s, port: %d, username: %s, password: %s", config.serverAddr.c_str(), - config.serverPort, config.mqttUsername, config.mqttPassword); + LOG_INFO("Connecting directly to MQTT server %s, port: %d, username: %s, password: ***", config.serverAddr.c_str(), + config.serverPort, config.mqttUsername); // Generate node ID from nodenum for client identification std::string nodeId = nodeDB->getNodeId(); diff --git a/src/platform/nrf52/main-nrf52.cpp b/src/platform/nrf52/main-nrf52.cpp index 73780b6eb..5cf3a4465 100644 --- a/src/platform/nrf52/main-nrf52.cpp +++ b/src/platform/nrf52/main-nrf52.cpp @@ -17,6 +17,7 @@ #include #include // #include +#include "HardwareRNG.h" #include "NodeDB.h" #include "PowerMon.h" #include "error.h" @@ -398,15 +399,14 @@ void nrf52Setup() #endif // Init random seed - union seedParts { - uint32_t seed32; - uint8_t seed8[4]; - } seed; - nRFCrypto.begin(); - nRFCrypto.Random.generate(seed.seed8, sizeof(seed.seed8)); - LOG_DEBUG("Set random seed %u", seed.seed32); - randomSeed(seed.seed32); - nRFCrypto.end(); + uint32_t seed = 0; + if (!HardwareRNG::seed(seed)) { + LOG_WARN("Hardware RNG seed unavailable, using PRNG fallback"); + // Use a hardware timer value as a fallback seed for better entropy + seed = micros(); + } + LOG_DEBUG("Set random seed %u", seed); + randomSeed(seed); // Set up nrfx watchdog. Do not enable the watchdog yet (we do that // the first time through the main loop), so that other threads can diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 4bbdbee7a..7833b3603 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1,4 +1,5 @@ #include "CryptoEngine.h" +#include "HardwareRNG.h" #include "PortduinoGPIO.h" #include "SPIChip.h" #include "mesh/RF95Interface.h" @@ -181,6 +182,10 @@ void portduinoSetup() // Force stdout to be line buffered setvbuf(stdout, stdoutBuffer, _IOLBF, sizeof(stdoutBuffer)); + // We do this super early so that we can log from the rest of the init code + concurrency::hasBeenSetup = true; + consoleInit(); + if (portduino_config.force_simradio == true) { portduino_config.lora_module = use_simradio; } else if (configPath != nullptr) { @@ -233,7 +238,9 @@ void portduinoSetup() std::cout << "Running in simulated mode." << std::endl; portduino_config.MaxNodes = 200; // Default to 200 nodes // Set the random seed equal to TCPPort to have a different seed per instance - randomSeed(TCPPort); + uint32_t seed = TCPPort; + HardwareRNG::seed(seed); + randomSeed(seed); return; } @@ -512,7 +519,9 @@ void portduinoSetup() #endif printf("MAC ADDRESS: %02X:%02X:%02X:%02X:%02X:%02X\n", dmac[0], dmac[1], dmac[2], dmac[3], dmac[4], dmac[5]); // Rather important to set this, if not running simulated. - randomSeed(time(NULL)); + uint32_t seed = static_cast(time(NULL)); + HardwareRNG::seed(seed); + randomSeed(seed); std::string defaultGpioChipName = gpioChipName + std::to_string(portduino_config.lora_default_gpiochip); @@ -645,7 +654,9 @@ void portduinoSetup() if (verboseEnabled && portduino_config.logoutputlevel != level_trace) { portduino_config.logoutputlevel = level_debug; } - + if (portduino_config.lora_spi_dev != "") { + portduinoSetOptions({.realHardware = true}); + } return; } diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index 6c73e385a..e59b0a9cd 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -1,3 +1,4 @@ +#include "HardwareRNG.h" #include "configuration.h" #include "hardware/xosc.h" #include @@ -98,10 +99,12 @@ void getMacAddr(uint8_t *dmac) void rp2040Setup() { - /* Sets a random seed to make sure we get different random numbers on each boot. - Taken from CPU cycle counter and ROSC oscillator, so should be pretty random. - */ - randomSeed(rp2040.hwrand32()); + /* Sets a random seed to make sure we get different random numbers on each boot. */ + uint32_t seed = 0; + if (!HardwareRNG::seed(seed)) { + seed = rp2040.hwrand32(); + } + randomSeed(seed); #ifdef RP2040_SLOW_CLOCK uint f_pll_sys = frequency_count_khz(CLOCKS_FC0_SRC_VALUE_PLL_SYS_CLKSRC_PRIMARY); diff --git a/src/platform/stm32wl/hardfault_handler.s b/src/platform/stm32wl/hardfault_handler.s new file mode 100644 index 000000000..ab76096fa --- /dev/null +++ b/src/platform/stm32wl/hardfault_handler.s @@ -0,0 +1,11 @@ +.globl HardFault_Handler +.syntax unified +.thumb + +.type HardFault_Handler, %function +HardFault_Handler: +tst lr, #4 +ite eq +mrseq r0, msp +mrsne r0, psp +b HardFault_Handler_C \ No newline at end of file diff --git a/src/platform/stm32wl/main-stm32wl.cpp b/src/platform/stm32wl/main-stm32wl.cpp index e841f8f29..241020126 100644 --- a/src/platform/stm32wl/main-stm32wl.cpp +++ b/src/platform/stm32wl/main-stm32wl.cpp @@ -1,8 +1,60 @@ #include "RTC.h" #include "configuration.h" +#include #include #include +// ─── Bootloader redirect ────────────────────────────────────────────────────── +// +// Why .noinit + constructor instead of TAMP backup registers: +// +// The STM32duino startup sequence initialises clocks which may call +// __HAL_RCC_BACKUPRESET_FORCE/RELEASE when configuring the LSE oscillator, +// wiping the entire backup domain (including TAMP->BKP0R) before setup() +// ever runs. The backup-register approach therefore cannot reliably survive +// a soft reset in this toolchain. +// +// Solution: store the magic in a .noinit SRAM variable. +// - NVIC_SystemReset() does NOT clear SRAM. +// - The linker script skips zero-init for .noinit sections. +// - __attribute__((constructor)) fires before main()/HAL_Init(), so we can +// intercept and jump before anything disturbs peripheral state. + +#define BOOTLOADER_MAGIC 0xD00DB007UL +#define SYS_MEM_BASE 0x1FFF0000UL + +// Placed in .noinit — not zeroed at startup, survives NVIC_SystemReset(). +__attribute__((section(".noinit"), used)) volatile uint32_t g_bootloaderMagic; + +// Fires before main() / HAL_Init(). Must use only core Cortex-M registers. +__attribute__((constructor(101), used)) static void earlyBootCheck(void) +{ + if (g_bootloaderMagic != BOOTLOADER_MAGIC) + return; + g_bootloaderMagic = 0; + + SysTick->CTRL = 0; + SysTick->LOAD = 0; + SysTick->VAL = 0; + for (int i = 0; i < 8; i++) { + NVIC->ICER[i] = 0xFFFFFFFF; + NVIC->ICPR[i] = 0xFFFFFFFF; + } + __DSB(); + __ISB(); + SCB->VTOR = SYS_MEM_BASE; + __set_MSP(*(volatile uint32_t *)SYS_MEM_BASE); + ((void (*)(void))(*(volatile uint32_t *)(SYS_MEM_BASE + 4)))(); + while (1) + ; +} + +void enterDfuMode() +{ + g_bootloaderMagic = BOOTLOADER_MAGIC; + HAL_NVIC_SystemReset(); +} + void setBluetoothEnable(bool enable) {} void playStartMelody() {} @@ -53,4 +105,99 @@ extern "C" void __wrap__tzset_unlocked_r(struct _reent *reent_ptr) { return; } -#endif \ No newline at end of file +#endif + +// Taken from https://interrupt.memfault.com/blog/cortex-m-hardfault-debug +typedef struct __attribute__((packed)) ContextStateFrame { + uint32_t r0; + uint32_t r1; + uint32_t r2; + uint32_t r3; + uint32_t r12; + uint32_t lr; + uint32_t return_address; + uint32_t xpsr; +} sContextStateFrame; + +// NOTE: If you are using CMSIS, the registers can also be +// accessed through CoreDebug->DHCSR & CoreDebug_DHCSR_C_DEBUGEN_Msk +#define HALT_IF_DEBUGGING() \ + do { \ + if ((*(volatile uint32_t *)0xE000EDF0) & (1 << 0)) { \ + __asm("bkpt 1"); \ + } \ + } while (0) + +static char hardfault_message_buffer[256]; + +// printf directly using srcwrapper's debug UART function. +static void debug_printf(const char *format, ...) +{ + va_list args; + va_start(args, format); + int length = vsnprintf(hardfault_message_buffer, sizeof(hardfault_message_buffer), format, args); + va_end(args); + + if (length < 0) + return; + uart_debug_write((uint8_t *)hardfault_message_buffer, min((unsigned int)length, sizeof(hardfault_message_buffer) - 1)); +} + +// N picked by guessing +#define DOT_TIME 1200000 +static void dot() +{ + digitalWrite(LED_POWER, LED_STATE_ON); + for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ + } + digitalWrite(LED_POWER, LED_STATE_OFF); + for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ + } +} + +static void dash() +{ + digitalWrite(LED_POWER, LED_STATE_ON); + for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ + } + digitalWrite(LED_POWER, LED_STATE_OFF); + for (volatile int i = 0; i < DOT_TIME; i++) { /* busy wait */ + } +} + +static void space() +{ + for (volatile int i = 0; i < (DOT_TIME * 3); i++) { /* busy wait */ + } +} + +// Disable optimizations for this function so "frame" argument +// does not get optimized away +extern "C" __attribute__((optimize("O0"))) void HardFault_Handler_C(sContextStateFrame *frame) +{ + debug_printf("HardFault!\r\n"); + debug_printf("r0: %08x\r\n", frame->r0); + debug_printf("r1: %08x\r\n", frame->r1); + debug_printf("r2: %08x\r\n", frame->r2); + debug_printf("r3: %08x\r\n", frame->r3); + debug_printf("r12: %08x\r\n", frame->r12); + debug_printf("lr: %08x\r\n", frame->lr); + debug_printf("pc[return address]: %08x\r\n", frame->return_address); + debug_printf("xpsr: %08x\r\n", frame->xpsr); + + HALT_IF_DEBUGGING(); + + // blink SOS forever + while (1) { + dot(); + dot(); + dot(); + dash(); + dash(); + dash(); + dot(); + dot(); + dot(); + space(); + } +} \ No newline at end of file diff --git a/src/power.h b/src/power.h index b129e2b74..d46eaadd2 100644 --- a/src/power.h +++ b/src/power.h @@ -15,8 +15,13 @@ // Device specific curves go in variant.h #ifndef OCV_ARRAY +#if defined(ARCH_STM32WL) && BATTERY_PIN == AVBAT +// STM32 VDD/VBAT absolute maximum is 4V so use an LFP curve +#define OCV_ARRAY 3650, 3400, 3340, 3320, 3300, 3280, 3270, 3260, 3240, 3200, 2500 +#else #define OCV_ARRAY 4190, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100 #endif +#endif /*Note: 12V lead acid is 6 cells, most board accept only 1 cell LiIon/LiPo*/ #ifndef NUM_CELLS diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp new file mode 100644 index 000000000..9906bb94c --- /dev/null +++ b/test/test_admin_radio/test_main.cpp @@ -0,0 +1,814 @@ +/** + * Tests for the radio configuration validation and clamping functions + * introduced in the radio_interface_cherrypick branch. + * + * Targets: + * 1. getRegion() + * 2. RadioInterface::validateConfigRegion() + * 3. RadioInterface::validateConfigLora() + * 4. RadioInterface::clampConfigLora() + * 5. RegionInfo preset lists (PRESETS_STD, PRESETS_EU_868, PRESETS_UNDEF) + * 6. Channel spacing calculation (placeholder for future protobuf changes) + */ + +#include "MeshRadio.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "TestUtil.h" +#include "modules/AdminModule.h" +#include + +#include "meshtastic/config.pb.h" + +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +static MockMeshService *mockMeshService; + +// ----------------------------------------------------------------------- +// getRegion() tests +// ----------------------------------------------------------------------- +extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code); + +static void test_getRegion_returnsCorrectRegion_US() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, r->code); + TEST_ASSERT_EQUAL_STRING("US", r->name); +} + +static void test_getRegion_returnsCorrectRegion_EU868() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_EU_868, r->code); + TEST_ASSERT_EQUAL_STRING("EU_868", r->name); +} + +static void test_getRegion_returnsCorrectRegion_LORA24() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_LORA_24, r->code); + TEST_ASSERT_TRUE(r->wideLora); +} + +static void test_getRegion_unsetCodeReturnsUnsetEntry() +{ + const RegionInfo *r = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); + TEST_ASSERT_EQUAL_STRING("UNSET", r->name); +} + +static void test_getRegion_unknownCodeFallsToUnset() +{ + // A code not in the table should iterate to the UNSET sentinel + const RegionInfo *r = getRegion((meshtastic_Config_LoRaConfig_RegionCode)255); + TEST_ASSERT_NOT_NULL(r); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); +} + +// ----------------------------------------------------------------------- +// validateConfigRegion() tests +// ----------------------------------------------------------------------- + +static void test_validateConfigRegion_validRegionReturnsTrue() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + + // Ensure owner is not licensed (should not matter for non-licensed-only regions) + devicestate.owner.is_licensed = false; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg)); +} + +static void test_validateConfigRegion_unsetRegionReturnsTrue() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + + devicestate.owner.is_licensed = false; + + // UNSET region has licensedOnly=false, so should pass + TEST_ASSERT_TRUE(RadioInterface::validateConfigRegion(cfg)); +} + +// ----------------------------------------------------------------------- +// Shadow tables for testing (preset lists → profiles → regions → lookup) +// ----------------------------------------------------------------------- + +// A minimal preset list with only one entry +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_SINGLE[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + MODEM_PRESET_END, +}; + +// A preset list that includes all turbo variants only +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_TURBO_ONLY[] = { + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + MODEM_PRESET_END, +}; + +// A restricted list simulating a hypothetical tight-regulation region +static const meshtastic_Config_LoRaConfig_ModemPreset TEST_PRESETS_RESTRICTED[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + MODEM_PRESET_END, +}; + +// Mirrors PROFILE_STD but with non-zero spacing/padding for testing +static const RegionProfile TEST_PROFILE_SPACED = { + TEST_PRESETS_SINGLE, + /* spacing */ 0.025f, + /* padding */ 0.010f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ 0, +}; + +// A licensed-only profile for testing access control +static const RegionProfile TEST_PROFILE_LICENSED = { + TEST_PRESETS_RESTRICTED, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ false, + /* licensedOnly */ true, + /* textThrottle */ 5, + /* positionThrottle */ 10, + /* telemetryThrottle */ 10, + /* overrideSlot */ 3, +}; + +// Turbo-only profile +static const RegionProfile TEST_PROFILE_TURBO = { + TEST_PRESETS_TURBO_ONLY, + /* spacing */ 0.0f, + /* padding */ 0.0f, + /* audioPermitted */ true, + /* licensedOnly */ false, + /* textThrottle */ 0, + /* positionThrottle */ 0, + /* telemetryThrottle */ 0, + /* overrideSlot */ 0, +}; + +static const RegionInfo testRegions[] = { + // A wide US-like region with spacing + padding + {meshtastic_Config_LoRaConfig_RegionCode_US, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_US_SPACED"}, + + // A narrow band simulating tight EU regulation + {meshtastic_Config_LoRaConfig_RegionCode_EU_868, 869.4f, 869.65f, 10, 14, false, false, &TEST_PROFILE_LICENSED, + "TEST_EU_LICENSED"}, + + // A wide-LoRa region with turbo-only presets + {meshtastic_Config_LoRaConfig_RegionCode_LORA_24, 2400.0f, 2483.5f, 100, 10, false, true, &TEST_PROFILE_TURBO, + "TEST_LORA24_TURBO"}, + + // Sentinel — must be last + {meshtastic_Config_LoRaConfig_RegionCode_UNSET, 902.0f, 928.0f, 100, 30, false, false, &TEST_PROFILE_SPACED, "TEST_UNSET"}, +}; + +static const RegionInfo *getTestRegion(meshtastic_Config_LoRaConfig_RegionCode code) +{ + const RegionInfo *r = testRegions; + while (r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + if (r->code == code) + return r; + r++; + } + return r; // Returns the UNSET sentinel +} + +// ----------------------------------------------------------------------- +// Shadow table tests +// ----------------------------------------------------------------------- + +static void test_shadowTable_spacedProfileHasNonZeroSpacing() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL_STRING("TEST_US_SPACED", r->name); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.025f, r->profile->spacing); + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.010f, r->profile->padding); +} + +static void test_shadowTable_licensedProfileFlagsCorrect() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_TRUE(r->profile->licensedOnly); + TEST_ASSERT_FALSE(r->profile->audioPermitted); + TEST_ASSERT_EQUAL(3, r->profile->overrideSlot); +} + +static void test_shadowTable_presetCountMatchesExpected() +{ + const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(1, spaced->getNumPresets()); + + const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(2, licensed->getNumPresets()); + + const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(2, turbo->getNumPresets()); +} + +static void test_shadowTable_defaultPresetIsFirstInList() +{ + const RegionInfo *spaced = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, spaced->getDefaultPreset()); + + const RegionInfo *licensed = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, licensed->getDefaultPreset()); + + const RegionInfo *turbo = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, turbo->getDefaultPreset()); +} + +static void test_shadowTable_channelSpacingWithPadding() +{ + // Verify channel count when spacing + padding are non-zero + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora); + float channelSpacing = r->profile->spacing + (r->profile->padding * 2) + (bw / 1000.0f); + + // spacing=0.025, padding=0.010*2=0.020, bw=250kHz=0.250 + // channelSpacing = 0.025 + 0.020 + 0.250 = 0.295 MHz + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.295f, channelSpacing); + + uint32_t numChannels = (uint32_t)(((r->freqEnd - r->freqStart + r->profile->spacing) / channelSpacing) + 0.5f); + // (928 - 902 + 0.025) / 0.295 = 88.2 → 88 + TEST_ASSERT_EQUAL_UINT32(88, numChannels); +} + +static void test_shadowTable_turboOnlyOnWideLora() +{ + const RegionInfo *r = getTestRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_TRUE(r->wideLora); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, r->getDefaultPreset()); + + // Verify wide-LoRa bandwidth for SHORT_TURBO + float bw = modemPresetToBwKHz(r->getDefaultPreset(), r->wideLora); + TEST_ASSERT_FLOAT_WITHIN(0.1f, 1625.0f, bw); // 1625 kHz in wide mode +} + +static void test_shadowTable_unknownCodeFallsToSentinel() +{ + const RegionInfo *r = getTestRegion((meshtastic_Config_LoRaConfig_RegionCode)200); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, r->code); + TEST_ASSERT_EQUAL_STRING("TEST_UNSET", r->name); +} + +// ----------------------------------------------------------------------- +// validateConfigLora() tests +// ----------------------------------------------------------------------- + +static void test_validateConfigLora_validPresetForUS() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_allStdPresetsValidForUS() +{ + meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }; + + for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = stdPresets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for US"); + } +} + +static void test_validateConfigLora_turboPresetsInvalidForEU868() +{ + // EU_868 has PRESETS_EU_868 which excludes SHORT_TURBO and LONG_TURBO + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for EU_868"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_TURBO should be invalid for EU_868"); +} + +static void test_validateConfigLora_validPresetsForEU868() +{ + meshtastic_Config_LoRaConfig_ModemPreset eu868Presets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, + }; + + for (size_t i = 0; i < sizeof(eu868Presets) / sizeof(eu868Presets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + cfg.modem_preset = eu868Presets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for EU_868"); + } +} + +static void test_validateConfigLora_customBandwidthTooWideForEU868() +{ + // EU_868 spans 869.4 - 869.65 = 0.25 MHz = 250 kHz + // A 500 kHz custom BW should be rejected + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 500; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_customBandwidthFitsUS() +{ + // US spans 902 - 928 = 26 MHz, so 250 kHz BW fits easily + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = false; + cfg.bandwidth = 250; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_customBandwidthFitsEU868() +{ + // EU_868 spans 250 kHz, 125 kHz BW should fit + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 125; + cfg.spread_factor = 12; + cfg.coding_rate = 8; + + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_bogusPresetRejected() +{ + // A fabricated preset value not in any list should be rejected + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); +} + +static void test_validateConfigLora_unsetRegionOnlyAcceptsLongFast() +{ + // UNSET uses PROFILE_UNDEF which has only LONG_FAST + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "LONG_FAST should be valid for UNSET"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "MEDIUM_FAST should be invalid for UNSET"); + + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + TEST_ASSERT_FALSE_MESSAGE(RadioInterface::validateConfigLora(cfg), "SHORT_TURBO should be invalid for UNSET"); +} + +static void test_validateConfigLora_allPresetsValidForLORA24() +{ + // LORA_24 uses PROFILE_STD (9 presets) with wideLora=true + meshtastic_Config_LoRaConfig_ModemPreset stdPresets[] = { + meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, + meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, + }; + + for (size_t i = 0; i < sizeof(stdPresets) / sizeof(stdPresets[0]); i++) { + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + cfg.use_preset = true; + cfg.modem_preset = stdPresets[i]; + TEST_ASSERT_TRUE_MESSAGE(RadioInterface::validateConfigLora(cfg), "Expected valid preset for LORA_24"); + } +} + +// ----------------------------------------------------------------------- +// clampConfigLora() tests +// ----------------------------------------------------------------------- + +static void test_clampConfigLora_invalidPresetClampedToDefault() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; // not in EU_868 preset list + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), cfg.modem_preset); +} + +static void test_clampConfigLora_validPresetUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = true; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_customBwTooWideClampedToDefaultBw() +{ + // EU_868 span is 250kHz. A 500kHz custom BW should be clamped to default preset BW. + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.use_preset = false; + cfg.bandwidth = 500; + cfg.spread_factor = 11; + cfg.coding_rate = 5; + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + float expectedBw = modemPresetToBwKHz(eu868->getDefaultPreset(), eu868->wideLora); + TEST_ASSERT_FLOAT_WITHIN(0.01f, expectedBw, (float)cfg.bandwidth); +} + +static void test_clampConfigLora_customBwValidLeftUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.use_preset = false; + cfg.bandwidth = 125; + cfg.spread_factor = 12; + cfg.coding_rate = 8; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL_UINT16(125, cfg.bandwidth); +} + +static void test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast() +{ + // UNSET uses PROFILE_UNDEF with only LONG_FAST; any other preset should clamp to it + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault() +{ + // LORA_24 uses PROFILE_STD; a bogus preset should clamp to LONG_FAST (first in PRESETS_STD) + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; + cfg.use_preset = true; + cfg.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)99; + + RadioInterface::clampConfigLora(cfg); + + const RegionInfo *lora24 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_LORA_24); + TEST_ASSERT_EQUAL(lora24->getDefaultPreset(), cfg.modem_preset); +} + +// ----------------------------------------------------------------------- +// RegionInfo preset list integrity tests +// ----------------------------------------------------------------------- + +static void test_presetsStd_hasNineEntries() +{ + // PROFILE_STD should have exactly 9 presets + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + TEST_ASSERT_EQUAL(9, us->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_STD.presets, us->getAvailablePresets()); +} + +static void test_presetsEU868_hasSevenEntries() +{ + const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(7, eu->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_EU868.presets, eu->getAvailablePresets()); +} + +static void test_presetsUndef_hasOneEntry() +{ + const RegionInfo *unset = getRegion(meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_EQUAL(1, unset->getNumPresets()); + TEST_ASSERT_EQUAL_PTR(PROFILE_UNDEF.presets, unset->getAvailablePresets()); +} + +static void test_defaultPresetIsInAvailablePresets() +{ + // For every region, the defaultPreset must appear in its own availablePresets list + const RegionInfo *r = regions; + while (true) { + bool found = false; + for (size_t i = 0; i < r->getNumPresets(); i++) { + if (r->getAvailablePresets()[i] == r->getDefaultPreset()) { + found = true; + break; + } + } + char msg[80]; + snprintf(msg, sizeof(msg), "Region %s defaultPreset not in availablePresets", r->name); + TEST_ASSERT_TRUE_MESSAGE(found, msg); + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; // UNSET is the sentinel, stop after it + r++; + } +} + +static void test_regionFieldsAreSane() +{ + // Basic sanity check: all regions have freqEnd > freqStart and a non-null name + const RegionInfo *r = regions; + while (true) { + char msg[80]; + snprintf(msg, sizeof(msg), "Region %s: freqEnd must be > freqStart", r->name); + TEST_ASSERT_TRUE_MESSAGE(r->freqEnd > r->freqStart, msg); + TEST_ASSERT_NOT_NULL(r->name); + TEST_ASSERT_TRUE_MESSAGE(r->getNumPresets() > 0, "numPresets must be > 0"); + TEST_ASSERT_NOT_NULL(r->getAvailablePresets()); + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; + r++; + } +} + +static void test_onlyLORA24HasWideLora() +{ + // Verify that LORA_24 is the only region with wideLora=true + const RegionInfo *r = regions; + while (true) { + char msg[80]; + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_LORA_24) { + snprintf(msg, sizeof(msg), "Region %s should have wideLora=true", r->name); + TEST_ASSERT_TRUE_MESSAGE(r->wideLora, msg); + } else { + snprintf(msg, sizeof(msg), "Region %s should have wideLora=false", r->name); + TEST_ASSERT_FALSE_MESSAGE(r->wideLora, msg); + } + + if (r->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + break; + r++; + } +} + +// ----------------------------------------------------------------------- +// Channel spacing calculation (placeholder for future protobuf updates) +// ----------------------------------------------------------------------- + +static void test_channelSpacingCalculation_US_LONG_FAST() +{ + // Current formula: channelSpacing = spacing + (padding * 2) + (bw / 1000) + // US: spacing=0, padding=0 + // LONG_FAST on non-wide region: bw=250 kHz + // channelSpacing = 0 + 0 + 0.250 = 0.250 MHz + // numChannels = round((928 - 902 + 0) / 0.250) = round(104) = 104 + const RegionInfo *us = getRegion(meshtastic_Config_LoRaConfig_RegionCode_US); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, us->wideLora); + float channelSpacing = us->profile->spacing + (us->profile->padding * 2) + (bw / 1000.0f); + uint32_t numChannels = (uint32_t)(((us->freqEnd - us->freqStart + us->profile->spacing) / channelSpacing) + 0.5f); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing); + TEST_ASSERT_EQUAL_UINT32(104, numChannels); +} + +static void test_channelSpacingCalculation_EU868_LONG_FAST() +{ + // EU_868: freqStart=869.4, freqEnd=869.65, spacing=0, padding=0 + // LONG_FAST: bw=250 kHz => channelSpacing = 0.250 MHz + // numChannels = round((0.25 + 0) / 0.250) = 1 + const RegionInfo *eu = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + float bw = modemPresetToBwKHz(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, eu->wideLora); + float channelSpacing = eu->profile->spacing + (eu->profile->padding * 2) + (bw / 1000.0f); + uint32_t numChannels = (uint32_t)(((eu->freqEnd - eu->freqStart + eu->profile->spacing) / channelSpacing) + 0.5f); + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 0.250f, channelSpacing); + TEST_ASSERT_EQUAL_UINT32(1, numChannels); +} + +// Placeholder: when protobuf region definitions include non-zero padding/spacing, +// add tests here to verify the channel count and frequency calculations. +static void test_channelSpacingCalculation_placeholder() +{ + // TODO: Once protobuf RegionInfo entries have non-zero padding or spacing values, + // verify: + // - Channel count matches expected value for each (region, preset) pair + // - First channel frequency = freqStart + (bw/2000) + padding + // - Nth channel frequency = first + (n * channelSpacing) + // - overrideSlot, when non-zero, forces the channel_num + TEST_PASS_MESSAGE("Placeholder for future channel spacing tests with updated protobuf region fields"); +} + +// ----------------------------------------------------------------------- +// handleSetConfig fromOthers dispatch tests +// ----------------------------------------------------------------------- + +class AdminModuleTestShim : public AdminModule +{ + public: + using AdminModule::handleSetConfig; +}; + +static AdminModuleTestShim *testAdmin; + +static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, + meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_lora_tag; + c.payload_variant.lora.region = region; + c.payload_variant.lora.use_preset = usePreset; + c.payload_variant.lora.modem_preset = preset; + return c; +} + +static void test_handleSetConfig_fromOthers_invalidPresetRejected() +{ + // Set up a known-good baseline in the global config + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with an invalid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + + testAdmin->handleSetConfig(c, true); // fromOthers = true + + // fromOthers=true: invalid preset should be rejected, old preset preserved + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, config.lora.modem_preset); +} + +static void test_handleSetConfig_fromLocal_invalidPresetClamped() +{ + // Set up a known-good baseline + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with an invalid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO); + + testAdmin->handleSetConfig(c, false); // fromOthers = false (local client) + + // fromOthers=false: invalid preset should be clamped to the region's default + const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_EQUAL(eu868->getDefaultPreset(), config.lora.modem_preset); +} + +static void test_handleSetConfig_fromOthers_validPresetAccepted() +{ + // Set up baseline + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + + // Build an admin set_config with a valid preset for EU_868 + meshtastic_Config c = makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_EU_868, true, + meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST); + + testAdmin->handleSetConfig(c, true); // fromOthers = true + + // Valid preset should be accepted regardless of fromOthers + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); +} + +// ----------------------------------------------------------------------- +// Test runner +// ----------------------------------------------------------------------- + +void setUp(void) +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; + testAdmin = new AdminModuleTestShim(); +} +void tearDown(void) +{ + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; + delete testAdmin; + testAdmin = nullptr; +} + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + + UNITY_BEGIN(); + + // getRegion() + RUN_TEST(test_getRegion_returnsCorrectRegion_US); + RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); + RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); + RUN_TEST(test_getRegion_unsetCodeReturnsUnsetEntry); + RUN_TEST(test_getRegion_unknownCodeFallsToUnset); + + // validateConfigRegion() + RUN_TEST(test_validateConfigRegion_validRegionReturnsTrue); + RUN_TEST(test_validateConfigRegion_unsetRegionReturnsTrue); + + // Shadow table tests + RUN_TEST(test_shadowTable_spacedProfileHasNonZeroSpacing); + RUN_TEST(test_shadowTable_licensedProfileFlagsCorrect); + RUN_TEST(test_shadowTable_presetCountMatchesExpected); + RUN_TEST(test_shadowTable_defaultPresetIsFirstInList); + RUN_TEST(test_shadowTable_channelSpacingWithPadding); + RUN_TEST(test_shadowTable_turboOnlyOnWideLora); + RUN_TEST(test_shadowTable_unknownCodeFallsToSentinel); + + // validateConfigLora() + RUN_TEST(test_validateConfigLora_validPresetForUS); + RUN_TEST(test_validateConfigLora_allStdPresetsValidForUS); + RUN_TEST(test_validateConfigLora_turboPresetsInvalidForEU868); + RUN_TEST(test_validateConfigLora_validPresetsForEU868); + RUN_TEST(test_validateConfigLora_customBandwidthTooWideForEU868); + RUN_TEST(test_validateConfigLora_customBandwidthFitsUS); + RUN_TEST(test_validateConfigLora_customBandwidthFitsEU868); + RUN_TEST(test_validateConfigLora_bogusPresetRejected); + RUN_TEST(test_validateConfigLora_unsetRegionOnlyAcceptsLongFast); + RUN_TEST(test_validateConfigLora_allPresetsValidForLORA24); + + // clampConfigLora() + RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); + RUN_TEST(test_clampConfigLora_validPresetUnchanged); + RUN_TEST(test_clampConfigLora_customBwTooWideClampedToDefaultBw); + RUN_TEST(test_clampConfigLora_customBwValidLeftUnchanged); + RUN_TEST(test_clampConfigLora_bogusPresetOnUnsetClampedToLongFast); + RUN_TEST(test_clampConfigLora_invalidPresetOnLORA24ClampedToDefault); + + // RegionInfo preset list integrity + RUN_TEST(test_presetsStd_hasNineEntries); + RUN_TEST(test_presetsEU868_hasSevenEntries); + RUN_TEST(test_presetsUndef_hasOneEntry); + RUN_TEST(test_defaultPresetIsInAvailablePresets); + RUN_TEST(test_regionFieldsAreSane); + RUN_TEST(test_onlyLORA24HasWideLora); + + // Channel spacing (current + placeholder) + RUN_TEST(test_channelSpacingCalculation_US_LONG_FAST); + RUN_TEST(test_channelSpacingCalculation_EU868_LONG_FAST); + RUN_TEST(test_channelSpacingCalculation_placeholder); + + // handleSetConfig fromOthers dispatch + RUN_TEST(test_handleSetConfig_fromOthers_invalidPresetRejected); + RUN_TEST(test_handleSetConfig_fromLocal_invalidPresetClamped); + RUN_TEST(test_handleSetConfig_fromOthers_validPresetAccepted); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index fbe2b1b13..a7d3d32d2 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -1,10 +1,36 @@ #include "MeshRadio.h" +#include "MeshService.h" #include "RadioInterface.h" #include "TestUtil.h" #include #include "meshtastic/config.pb.h" +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +static MockMeshService *mockMeshService; + +// Test shim to expose protected radio parameters set by applyModemConfig() +class TestableRadioInterface : public RadioInterface +{ + public: + TestableRadioInterface() : RadioInterface() {} + uint8_t getCr() const { return cr; } + uint8_t getSf() const { return sf; } + float getBw() const { return bw; } + + // Override reconfigure to call the base which invokes applyModemConfig() + bool reconfigure() override { return RadioInterface::reconfigure(); } + + // Stubs for pure virtual methods required by RadioInterface + uint32_t getPacketTime(uint32_t, bool) override { return 0; } + ErrorCode send(meshtastic_MeshPacket *p) override { return ERRNO_OK; } +}; + static void test_bwCodeToKHz_specialMappings() { TEST_ASSERT_FLOAT_WITHIN(0.0001f, 31.25f, bwCodeToKHz(31)); @@ -21,7 +47,7 @@ static void test_bwCodeToKHz_passthrough() TEST_ASSERT_FLOAT_WITHIN(0.0001f, 250.0f, bwCodeToKHz(250)); } -static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse() +static void test_validateConfigLora_noopWhenUsePresetFalse() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = false; @@ -30,55 +56,152 @@ static void test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse() cfg.bandwidth = 123; cfg.spread_factor = 8; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); + RadioInterface::validateConfigLora(cfg); TEST_ASSERT_EQUAL_UINT16(123, cfg.bandwidth); TEST_ASSERT_EQUAL_UINT32(8, cfg.spread_factor); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); } -static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion() +static void test_validateConfigLora_validPreset_nonWideRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor); + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); } -static void test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion() +static void test_validateConfigLora_validPreset_wideRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_LORA_24; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL_UINT16(800, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(9, cfg.spread_factor); + TEST_ASSERT_TRUE(RadioInterface::validateConfigLora(cfg)); } -static void test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan() +static void test_validateConfigLora_rejectsInvalidPresetForRegion() { meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; cfg.use_preset = true; cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - RadioInterface::bootstrapLoRaConfigFromPreset(cfg); - - TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); - TEST_ASSERT_EQUAL_UINT16(250, cfg.bandwidth); - TEST_ASSERT_EQUAL_UINT32(11, cfg.spread_factor); + TEST_ASSERT_FALSE(RadioInterface::validateConfigLora(cfg)); } -void setUp(void) {} -void tearDown(void) {} +static void test_clampConfigLora_invalidPresetClampedToDefault() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.use_preset = true; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, cfg.modem_preset); +} + +static void test_clampConfigLora_validPresetUnchanged() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.use_preset = true; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_US; + cfg.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + RadioInterface::clampConfigLora(cfg); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, cfg.modem_preset); +} + +// ----------------------------------------------------------------------- +// applyModemConfig() coding rate tests (via reconfigure) +// ----------------------------------------------------------------------- + +static TestableRadioInterface *testRadio; + +// After fresh flash: coding_rate=0, use_preset=true, modem_preset=LONG_FAST +// CR should come from the preset (5 for LONG_FAST), not from the zero default. +static void test_applyModemConfig_freshFlashCodingRateNotZero() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + // coding_rate is 0 (default after init_zero, same as fresh flash) + + testRadio->reconfigure(); + + // LONG_FAST preset has cr=5; must never be 0 + TEST_ASSERT_EQUAL_UINT8(5, testRadio->getCr()); + TEST_ASSERT_EQUAL_UINT8(11, testRadio->getSf()); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 250.0f, testRadio->getBw()); +} + +// When coding_rate matches the preset exactly, should still use the preset value +static void test_applyModemConfig_codingRateMatchesPreset() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + config.lora.coding_rate = 8; // LONG_SLOW default is cr=8 + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr()); +} + +// Custom CR higher than preset should be used +static void test_applyModemConfig_customCodingRateHigherThanPreset() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + config.lora.coding_rate = 7; // LONG_FAST preset has cr=5, 7 > 5 + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(7, testRadio->getCr()); +} + +// Custom CR lower than preset: preset wins (higher is more robust) +static void test_applyModemConfig_customCodingRateLowerThanPreset() +{ + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + config.lora.coding_rate = 5; // LONG_SLOW preset has cr=8, 5 < 8 + + testRadio->reconfigure(); + + TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr()); +} + +void setUp(void) +{ + mockMeshService = new MockMeshService(); + service = mockMeshService; + + // RadioInterface computes slotTimeMsec during construction and expects myRegion to be valid. + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + testRadio = new TestableRadioInterface(); +} +void tearDown(void) +{ + delete testRadio; + testRadio = nullptr; + service = nullptr; + delete mockMeshService; + mockMeshService = nullptr; +} void setup() { @@ -90,10 +213,16 @@ void setup() UNITY_BEGIN(); RUN_TEST(test_bwCodeToKHz_specialMappings); RUN_TEST(test_bwCodeToKHz_passthrough); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_noopWhenUsePresetFalse); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_nonWideRegion); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_setsDerivedFields_wideRegion); - RUN_TEST(test_bootstrapLoRaConfigFromPreset_fallsBackIfBandwidthExceedsRegionSpan); + RUN_TEST(test_validateConfigLora_noopWhenUsePresetFalse); + RUN_TEST(test_validateConfigLora_validPreset_nonWideRegion); + RUN_TEST(test_validateConfigLora_validPreset_wideRegion); + RUN_TEST(test_validateConfigLora_rejectsInvalidPresetForRegion); + RUN_TEST(test_clampConfigLora_invalidPresetClampedToDefault); + RUN_TEST(test_clampConfigLora_validPresetUnchanged); + RUN_TEST(test_applyModemConfig_freshFlashCodingRateNotZero); + RUN_TEST(test_applyModemConfig_codingRateMatchesPreset); + RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset); + RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset); exit(UNITY_END()); } diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp new file mode 100644 index 000000000..ec54f2312 --- /dev/null +++ b/test/test_traffic_management/test_main.cpp @@ -0,0 +1,1160 @@ +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define TM_TEST_ENTRY extern "C" +#else +#define TM_TEST_ENTRY +#endif + +#if HAS_TRAFFIC_MANAGEMENT + +#include "mesh/CryptoEngine.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#include "modules/TrafficManagementModule.h" +#include +#include +#include +#include +#include + +namespace +{ + +constexpr NodeNum kLocalNode = 0x11111111; +constexpr NodeNum kRemoteNode = 0x22222222; +constexpr NodeNum kTargetNode = 0x33333333; + +class MockNodeDB : public NodeDB +{ + public: + meshtastic_NodeInfoLite *getMeshNode(NodeNum n) override + { + if (hasCachedNode && n == cachedNodeNum) + return &cachedNode; + return NodeDB::getMeshNode(n); + } + + void clearCachedNode() + { + hasCachedNode = false; + cachedNodeNum = 0; + cachedNode = meshtastic_NodeInfoLite_init_zero; + } + + void setCachedNode(NodeNum n) + { + clearCachedNode(); + hasCachedNode = true; + cachedNodeNum = n; + cachedNode.num = n; + cachedNode.has_user = true; + } + + private: + bool hasCachedNode = false; + NodeNum cachedNodeNum = 0; + meshtastic_NodeInfoLite cachedNode = meshtastic_NodeInfoLite_init_zero; +}; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } +}; + +class MockRouter : public Router +{ + public: + ~MockRouter() + { + // Router allocates a global crypt lock in its constructor. + // Clean it up here so each test can build a fresh mock router. + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +class TrafficManagementModuleTestShim : public TrafficManagementModule +{ + public: + using TrafficManagementModule::alterReceived; + using TrafficManagementModule::handleReceived; + using TrafficManagementModule::resetEpoch; + using TrafficManagementModule::runOnce; + + bool ignoreRequestFlag() const { return ignoreRequest; } +}; + +MockNodeDB *mockNodeDB = nullptr; + +static void resetTrafficConfig() +{ + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + moduleConfig.has_traffic_management = true; + moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; + moduleConfig.traffic_management.enabled = true; + + config = meshtastic_LocalConfig_init_zero; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + myNodeInfo.my_node_num = kLocalNode; + + router = nullptr; + service = nullptr; + + mockNodeDB->resetNodes(); + mockNodeDB->clearCachedNode(); + nodeDB = mockNodeDB; +} + +static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x1001; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + packet.decoded.has_bitfield = true; + packet.decoded.bitfield = 0; + return packet; +} + +static meshtastic_MeshPacket makeUnknownPacket(NodeNum from, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = from; + packet.to = to; + packet.id = 0x2001; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + packet.encrypted.size = 0; + return packet; +} + +static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32_t lon, NodeNum to = NODENUM_BROADCAST) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, to); + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.has_longitude_i = true; + pos.latitude_i = lat; + pos.longitude_i = lon; + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos); + return packet; +} + +static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + strncpy(user.short_name, shortName, sizeof(user.short_name) - 1); + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + +/** + * Verify the module is a no-op when traffic management is disabled. + * Important so config toggles cannot accidentally change routing behavior. + */ +static void test_tm_moduleDisabled_doesNothing(void) +{ + moduleConfig.has_traffic_management = false; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage result = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected); + TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify unknown-packet dropping uses N+1 threshold semantics. + * Important to catch off-by-one regressions in drop decisions. + */ +static void test_tm_unknownPackets_dropOnNPlusOne(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 2; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); + TEST_ASSERT_EQUAL_UINT32(3, stats.packets_inspected); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify duplicate position broadcasts inside the dedup window are dropped. + * Important because this is the primary airtime-saving behavior. + */ +static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_GREATER_THAN_UINT32(0, first.decoded.payload.size); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify changed coordinates are forwarded even with dedup enabled. + * Important so real movement updates are never suppressed as duplicates. + */ +static void test_tm_positionDedup_allowsMovedPosition(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket moved = makePositionPacket(kRemoteNode, 384221234, -1210845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(moved); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify rate limiting drops only after exceeding the configured threshold. + * Important to protect threshold semantics from off-by-one regressions. + */ +static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 3; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + ProcessMessage r3 = module.handleReceived(packet); + ProcessMessage r4 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r4)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); +} + +/** + * Verify routing/admin traffic is exempt from rate limiting. + * Important because throttling control traffic can destabilize the mesh. + */ +static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode); + meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode); + + for (int i = 0; i < 4; i++) { + ProcessMessage rr = module.handleReceived(routingPacket); + ProcessMessage ar = module.handleReceived(adminPacket); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(rr)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(ar)); + } + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify packets sourced from this node bypass dedup and rate limiting. + * Important so local transmissions are not accidentally self-throttled. + */ +static void test_tm_fromUs_bypassesPositionAndRateFilters(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); + meshtastic_MeshPacket textPacket = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kLocalNode); + + ProcessMessage p1 = module.handleReceived(positionPacket); + ProcessMessage p2 = module.handleReceived(positionPacket); + ProcessMessage t1 = module.handleReceived(textPacket); + ProcessMessage t2 = module.handleReceived(textPacket); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify locally addressed packets are never dropped by transit shaping. + * Important so dedup/rate limiting do not suppress end-user delivery. + */ +static void test_tm_localDestination_bypassesTransitFilters(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket position2 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); + meshtastic_MeshPacket text1 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + meshtastic_MeshPacket text2 = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode, kLocalNode); + + ProcessMessage p1 = module.handleReceived(position1); + ProcessMessage p2 = module.handleReceived(position2); + ProcessMessage t1 = module.handleReceived(text1); + ProcessMessage t2 = module.handleReceived(text2); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(p2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(t2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); + TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); +} + +/** + * Verify router role clamps NodeInfo response hops to router-safe maximum. + * Important so large config values cannot widen response scope unexpectedly. + */ +static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 5; + request.hop_limit = 1; // 4 hops away; router clamp should cap max at 3 + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +/** + * Verify NodeInfo direct-response success path and reply packet fields. + * Important because this path consumes the request and generates a spoofed cached reply. + */ +static void test_tm_nodeinfo_directResponse_respondsFromCache(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.id = 0x13572468; + request.hop_start = 3; + request.hop_limit = 3; // direct request (0 hops away) + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_EQUAL_INT(meshtastic_PortNum_NODEINFO_APP, reply.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); + TEST_ASSERT_FALSE(reply.decoded.want_response); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_limit); + TEST_ASSERT_EQUAL_UINT8(0, reply.hop_start); + TEST_ASSERT_EQUAL_UINT8(mockNodeDB->getLastByteOfNodeNum(kRemoteNode), reply.next_hop); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(BITFIELD_OK_TO_MQTT_MASK, reply.decoded.bitfield); +} + +/** + * Verify cached direct replies still preserve requester NodeInfo learning. + * Important so consuming the request does not skip NodeDB refresh for observers. + */ +static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); + request.to = kTargetNode; + request.decoded.want_response = true; + request.id = 0x01020304; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_NodeInfoLite *requestor = mockNodeDB->getMeshNode(kRemoteNode); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_NOT_NULL(requestor); + TEST_ASSERT_TRUE(requestor->has_user); + TEST_ASSERT_EQUAL_STRING("requester-long", requestor->user.long_name); + TEST_ASSERT_EQUAL_STRING("rq", requestor->user.short_name); + TEST_ASSERT_EQUAL_UINT8(request.channel, requestor->channel); +} + +/** + * Verify client role only answers direct (0-hop) NodeInfo requests. + * Important so clients do not answer relayed requests outside intended scope. + */ +static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 2; + request.hop_limit = 1; // 1 hop away; clients are clamped to max 0 + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); +} + +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) +/** + * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. + * Important because fallback should only happen through node-wide data when + * the dedicated PSRAM cache does not exist. + */ +static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif + +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +/** + * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that + * shouldRespondToNodeInfo() uses cached bitfield metadata. + */ +static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + config.lora.config_ok_to_mqtt = true; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + observed.decoded.has_bitfield = true; + observed.decoded.bitfield = BITFIELD_WANT_RESPONSE_MASK; + observed.channel = 2; + observed.rx_time = 123456; + + ProcessMessage observedResult = module.handleReceived(observed); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.id = 0x24681357; + request.channel = 1; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_TRUE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_TRUE(reply.decoded.has_bitfield); + TEST_ASSERT_EQUAL_UINT8(static_cast(BITFIELD_WANT_RESPONSE_MASK | BITFIELD_OK_TO_MQTT_MASK), reply.decoded.bitfield); + TEST_ASSERT_EQUAL_UINT32(kTargetNode, reply.from); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, reply.to); + TEST_ASSERT_EQUAL_UINT8(request.channel, reply.channel); + TEST_ASSERT_EQUAL_UINT32(request.id, reply.decoded.request_id); +} + +/** + * Verify PSRAM cache misses do not fall back to NodeDB. + * Important so the dedicated PSRAM index stays logically separate from + * NodeInfoModule/NodeDB when PSRAM is available. + */ +static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response = true; + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->setCachedNode(kTargetNode); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif + +/** + * Verify relayed telemetry broadcasts are hop-exhausted when enabled. + * Important to prevent further mesh propagation while still allowing one relay step. + */ +static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 3; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_start); + TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify hop exhaustion skips unicast and local-origin packets. + * Important to avoid mutating traffic that should retain normal forwarding behavior. + */ +static void test_tm_alterReceived_skipsLocalAndUnicast(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode); + unicast.hop_start = 5; + unicast.hop_limit = 3; + module.alterReceived(unicast); + TEST_ASSERT_EQUAL_UINT8(3, unicast.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(unicast)); + + meshtastic_MeshPacket fromUs = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kLocalNode, NODENUM_BROADCAST); + fromUs.hop_start = 5; + fromUs.hop_limit = 3; + module.alterReceived(fromUs); + TEST_ASSERT_EQUAL_UINT8(3, fromUs.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(fromUs)); + + meshtastic_TrafficManagementStats stats = module.getStats(); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); +} + +/** + * Verify position dedup window expires and later duplicates are allowed. + * Important so periodic identical reports can resume after cooldown. + */ +static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 1; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket third = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(third); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify interval=0 disables position deduplication. + * Important because this is an explicit configuration escape hatch. + */ +static void test_tm_positionDedup_intervalZero_neverDrops(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 0; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify precision values above 32 fall back to default precision. + * Important so invalid config uses the documented default behavior. + */ +static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 99; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify precision=32 does not collapse all positions to one fingerprint. + * Important to prevent false duplicate drops at the full-precision boundary. + */ +static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 32; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify invalid precision=0 is treated as full precision. + * Important so invalid config does not collapse all positions into one fingerprint. + */ +static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 0; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(second); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(0, stats.position_dedup_drops); +} + +/** + * Verify epoch reset invalidates stale position identity for dedup. + * Important so reset paths cannot leak prior packet identity into new windows. + */ +static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterReset = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + module.resetEpoch(millis()); + ProcessMessage r2 = module.handleReceived(afterReset); + ProcessMessage r3 = module.handleReceived(duplicate); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify non-position cache state does not make the first fingerprint-0 position look duplicated. + * Important so unified cache entries from other features cannot leak into dedup decisions. + */ +static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void) +{ + moduleConfig.traffic_management.position_dedup_enabled = true; + moduleConfig.traffic_management.position_precision_bits = 16; + moduleConfig.traffic_management.position_min_interval_secs = 300; + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 10; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); + meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); + + ProcessMessage seeded = module.handleReceived(text); + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(duplicate); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(seeded)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify rate-limit counters reset after the window expires. + * Important so temporary bursts do not cause persistent throttling. + */ +static void test_tm_rateLimit_resetsAfterWindowExpires(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 1; + moduleConfig.traffic_management.rate_limit_max_packets = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); +} + +/** + * Verify rate-limit thresholds above 255 effectively clamp to 255. + * Important because counters are uint8_t and must not overflow behavior. + */ +static void test_tm_rateLimit_thresholdAbove255_clamps(void) +{ + moduleConfig.traffic_management.rate_limit_enabled = true; + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 300; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + + for (int i = 0; i < 255; i++) { + ProcessMessage result = module.handleReceived(packet); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + } + ProcessMessage dropped = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); + TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); +} + +/** + * Verify unknown-packet tracking resets after its active window expires. + * Important so old unknown traffic does not trigger delayed drops. + */ +static void test_tm_unknownPackets_resetAfterWindowExpires(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 1; + moduleConfig.traffic_management.rate_limit_window_secs = 1; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + ProcessMessage r1 = module.handleReceived(packet); + ProcessMessage r2 = module.handleReceived(packet); + testDelay(1200); + ProcessMessage r3 = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); +} + +/** + * Verify unknown threshold values above 255 clamp to the counter ceiling. + * Important to align config semantics with saturating counter storage. + */ +static void test_tm_unknownPackets_thresholdAbove255_clamps(void) +{ + moduleConfig.traffic_management.drop_unknown_enabled = true; + moduleConfig.traffic_management.unknown_packet_threshold = 300; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); + + for (int i = 0; i < 255; i++) { + ProcessMessage result = module.handleReceived(packet); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + } + ProcessMessage dropped = module.handleReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); + TEST_ASSERT_EQUAL_UINT32(1, stats.unknown_packet_drops); +} + +/** + * Verify relayed position broadcasts can also be hop-exhausted. + * Important because telemetry and position use separate exhaust flags. + */ +static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) +{ + moduleConfig.traffic_management.exhaust_hop_position = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 2; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); + TEST_ASSERT_EQUAL_UINT8(4, packet.hop_start); + TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify hop exhaustion ignores undecoded/encrypted packets. + * Important so we never mutate packets that were not decoded by this module. + */ +static void test_tm_alterReceived_skipsUndecodedPackets(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST); + packet.hop_start = 5; + packet.hop_limit = 3; + + module.alterReceived(packet); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); +} + +/** + * Verify exhaustRequested is per-packet and resets on next handleReceived(). + * Important so a prior packet cannot leak hop-exhaust state into later packets. + */ +static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + telemetry.hop_start = 5; + telemetry.hop_limit = 3; + module.alterReceived(telemetry); + TEST_ASSERT_TRUE(module.shouldExhaustHops(telemetry)); + + meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + ProcessMessage result = module.handleReceived(text); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); + TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); +} + +/** + * Verify exhaust requests are packet-scoped (from + id). + * Important so stale state from one packet cannot influence unrelated packets + * that pass through duplicate/rebroadcast paths before handleReceived(). + */ +static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void) +{ + moduleConfig.traffic_management.exhaust_hop_telemetry = true; + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + exhausted.id = 0x1010; + exhausted.hop_start = 5; + exhausted.hop_limit = 3; + module.alterReceived(exhausted); + + meshtastic_MeshPacket unrelated = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); + unrelated.id = 0x2020; + unrelated.hop_start = 4; + unrelated.hop_limit = 0; + + TEST_ASSERT_TRUE(module.shouldExhaustHops(exhausted)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(unrelated)); +} + +/** + * Verify runOnce() returns sleep-forever interval when module is disabled. + * Important to ensure the maintenance thread is effectively inert when off. + */ +static void test_tm_runOnce_disabledReturnsMaxInterval(void) +{ + moduleConfig.traffic_management.enabled = false; + TrafficManagementModuleTestShim module; + + int32_t interval = module.runOnce(); + + TEST_ASSERT_EQUAL_INT32(INT32_MAX, interval); +} + +/** + * Verify runOnce() returns the maintenance cadence when enabled. + * Important so periodic cache housekeeping continues at expected interval. + */ +static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void) +{ + TrafficManagementModuleTestShim module; + + int32_t interval = module.runOnce(); + + TEST_ASSERT_EQUAL_INT32(60 * 1000, interval); +} + +} // namespace + +void setUp(void) +{ + resetTrafficConfig(); +} +void tearDown(void) {} + +TM_TEST_ENTRY void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + + UNITY_BEGIN(); + RUN_TEST(test_tm_moduleDisabled_doesNothing); + RUN_TEST(test_tm_unknownPackets_dropOnNPlusOne); + RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow); + RUN_TEST(test_tm_positionDedup_allowsMovedPosition); + RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold); + RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts); + RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters); + RUN_TEST(test_tm_localDestination_bypassesTransitFilters); + RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops); + RUN_TEST(test_tm_nodeinfo_directResponse_respondsFromCache); + RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); + RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); +#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) + RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); +#endif +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); + RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); +#endif + RUN_TEST(test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast); + RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); + RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); + RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); + RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); + RUN_TEST(test_tm_positionDedup_precision32_allowsDistinctPositions); + RUN_TEST(test_tm_positionDedup_precisionZero_allowsDistinctPositions); + RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset); + RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero); + RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires); + RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps); + RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires); + RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps); + RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast); + RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets); + RUN_TEST(test_tm_alterReceived_resetExhaustFlagOnNextPacket); + RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped); + RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval); + RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval); + exit(UNITY_END()); +} + +TM_TEST_ENTRY void loop() {} + +#else + +void setUp(void) {} +void tearDown(void) {} + +TM_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +TM_TEST_ENTRY void loop() {} + +#endif diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 62d23b1e6..a14e407a1 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -8,7 +8,6 @@ build_flags = -I variants/esp32/chatter2 -DMESHTASTIC_EXCLUDE_WEBSERVER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -ULED_BUILTIN lib_deps = ${esp32_base.lib_deps} diff --git a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini index 3fdb738fc..2ddc5a2db 100644 --- a/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini +++ b/variants/esp32/diy/9m2ibr_aprs_lora_tracker/platformio.ini @@ -10,7 +10,6 @@ build_flags = -D EBYTE_E22 -D EBYTE_E22_900M30S ; Assume Tx power curve is identical to 900M30S as there is no documentation -I variants/esp32/diy/9m2ibr_aprs_lora_tracker - -ULED_BUILTIN build_src_filter = ${esp32_base.build_src_filter} +<../variants/esp32/diy/9m2ibr_aprs_lora_tracker> \ No newline at end of file diff --git a/variants/esp32/diy/hydra/platformio.ini b/variants/esp32/diy/hydra/platformio.ini index f23224f0b..3afd17e01 100644 --- a/variants/esp32/diy/hydra/platformio.ini +++ b/variants/esp32/diy/hydra/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D DIY_V1 -I variants/esp32/diy/hydra - -ULED_BUILTIN diff --git a/variants/esp32/diy/v1/platformio.ini b/variants/esp32/diy/v1/platformio.ini index 6be2bfd09..3d31fc24a 100644 --- a/variants/esp32/diy/v1/platformio.ini +++ b/variants/esp32/diy/v1/platformio.ini @@ -17,4 +17,3 @@ build_flags = -D DIY_V1 -D EBYTE_E22 -I variants/esp32/diy/v1 - -ULED_BUILTIN diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index db826b3f9..b9d6f5c50 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -39,7 +39,6 @@ build_flags = -Wall -Wextra -Isrc/platform/esp32 - -include mbedtls/error.h -std=gnu++17 -DLOG_LOCAL_LEVEL=ESP_LOG_DEBUG -DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_DEBUG diff --git a/variants/esp32/heltec_v2.1/platformio.ini b/variants/esp32/heltec_v2.1/platformio.ini index 9fcb2388a..1f7caa16f 100644 --- a/variants/esp32/heltec_v2.1/platformio.ini +++ b/variants/esp32/heltec_v2.1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D HELTEC_V2_1 -I variants/esp32/heltec_v2.1 - -ULED_BUILTIN diff --git a/variants/esp32/heltec_v2/platformio.ini b/variants/esp32/heltec_v2/platformio.ini index fc9e05115..5f15fb321 100644 --- a/variants/esp32/heltec_v2/platformio.ini +++ b/variants/esp32/heltec_v2/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D HELTEC_V2_0 -I variants/esp32/heltec_v2 - -ULED_BUILTIN diff --git a/variants/esp32/heltec_wireless_bridge/platformio.ini b/variants/esp32/heltec_wireless_bridge/platformio.ini index 6f9de7a84..42a35697c 100644 --- a/variants/esp32/heltec_wireless_bridge/platformio.ini +++ b/variants/esp32/heltec_wireless_bridge/platformio.ini @@ -10,6 +10,7 @@ build_flags = -D BOARD_HAS_PSRAM -D RADIOLIB_EXCLUDE_LR11X0=1 -D RADIOLIB_EXCLUDE_SX128X=1 + -D RADIOLIB_EXCLUDE_LR2021=1 -D MESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 -D MESHTASTIC_EXCLUDE_DETECTIONSENSOR=1 -D MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR=1 diff --git a/variants/esp32/nano-g1-explorer/platformio.ini b/variants/esp32/nano-g1-explorer/platformio.ini index b27ebf28e..6f57897a8 100644 --- a/variants/esp32/nano-g1-explorer/platformio.ini +++ b/variants/esp32/nano-g1-explorer/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D NANO_G1_EXPLORER -I variants/esp32/nano-g1-explorer - -ULED_BUILTIN diff --git a/variants/esp32/nano-g1/platformio.ini b/variants/esp32/nano-g1/platformio.ini index b2e392dbd..82d0f5e73 100644 --- a/variants/esp32/nano-g1/platformio.ini +++ b/variants/esp32/nano-g1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D NANO_G1 -I variants/esp32/nano-g1 - -ULED_BUILTIN diff --git a/variants/esp32/radiomaster_900_bandit/platformio.ini b/variants/esp32/radiomaster_900_bandit/platformio.ini index 0012f49d3..6729235ed 100644 --- a/variants/esp32/radiomaster_900_bandit/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit/platformio.ini @@ -9,7 +9,6 @@ build_flags = -DHAS_STK8XXX=1 -O2 -I variants/esp32/radiomaster_900_bandit - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool lib_deps = diff --git a/variants/esp32/radiomaster_900_bandit_micro/platformio.ini b/variants/esp32/radiomaster_900_bandit_micro/platformio.ini index e58d06f1e..32e9280e1 100644 --- a/variants/esp32/radiomaster_900_bandit_micro/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit_micro/platformio.ini @@ -13,6 +13,5 @@ build_flags = -DCONFIG_DISABLE_HAL_LOCKS=1 -O2 -I variants/esp32/radiomaster_900_bandit_nano - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/radiomaster_900_bandit_nano/platformio.ini b/variants/esp32/radiomaster_900_bandit_nano/platformio.ini index 7b3d187bf..924447ee4 100644 --- a/variants/esp32/radiomaster_900_bandit_nano/platformio.ini +++ b/variants/esp32/radiomaster_900_bandit_nano/platformio.ini @@ -16,6 +16,5 @@ build_flags = -DCONFIG_DISABLE_HAL_LOCKS=1 -O2 -I variants/esp32/radiomaster_900_bandit_nano - -ULED_BUILTIN board_build.f_cpu = 240000000L upload_protocol = esptool diff --git a/variants/esp32/station-g1/platformio.ini b/variants/esp32/station-g1/platformio.ini index 5a7f33485..20e29764c 100644 --- a/variants/esp32/station-g1/platformio.ini +++ b/variants/esp32/station-g1/platformio.ini @@ -14,4 +14,3 @@ build_flags = ${esp32_base.build_flags} -D STATION_G1 -I variants/esp32/station-g1 - -ULED_BUILTIN diff --git a/variants/esp32/tbeam/platformio.ini b/variants/esp32/tbeam/platformio.ini index c9e6cce1f..96e9879ce 100644 --- a/variants/esp32/tbeam/platformio.ini +++ b/variants/esp32/tbeam/platformio.ini @@ -16,7 +16,6 @@ board_check = true build_flags = ${esp32_base.build_flags} -D TBEAM_V10 -I variants/esp32/tbeam - -ULED_BUILTIN upload_speed = 921600 [env:tbeam-displayshield] diff --git a/variants/esp32/tlora_v1/platformio.ini b/variants/esp32/tlora_v1/platformio.ini index 5f72d634e..c45cc2ce9 100644 --- a/variants/esp32/tlora_v1/platformio.ini +++ b/variants/esp32/tlora_v1/platformio.ini @@ -13,5 +13,4 @@ build_flags = ${esp32_base.build_flags} -D TLORA_V1 -I variants/esp32/tlora_v1 - -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32/tlora_v2_1_16/platformio.ini b/variants/esp32/tlora_v2_1_16/platformio.ini index 2ea9bbb50..a41c5016e 100644 --- a/variants/esp32/tlora_v2_1_16/platformio.ini +++ b/variants/esp32/tlora_v2_1_16/platformio.ini @@ -12,7 +12,7 @@ extends = esp32_base board = ttgo-lora32-v21 board_check = true build_flags = - ${esp32_base.build_flags} -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 -ULED_BUILTIN + ${esp32_base.build_flags} -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 upload_speed = 115200 [env:sugarcube] diff --git a/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini b/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini index 235ac7007..3cb64c976 100644 --- a/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini +++ b/variants/esp32/tlora_v2_1_16_tcxo/platformio.ini @@ -7,5 +7,4 @@ build_flags = -D TLORA_V2_1_16 -I variants/esp32/tlora_v2_1_16 -D LORA_TCXO_GPIO=33 - -ULED_BUILTIN upload_speed = 115200 diff --git a/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini b/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini index 38f14ffc5..d3669ce55 100644 --- a/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini +++ b/variants/esp32/tlora_v3_3_0_tcxo/platformio.ini @@ -7,4 +7,3 @@ build_flags = -I variants/esp32/tlora_v2_1_16 -D LORA_TCXO_GPIO=12 -D BUTTON_PIN=0 - -ULED_BUILTIN \ No newline at end of file diff --git a/variants/esp32c6/tlora_c6/platformio.ini b/variants/esp32c6/tlora_c6/platformio.ini index 174e5e297..6b402d7c5 100644 --- a/variants/esp32c6/tlora_c6/platformio.ini +++ b/variants/esp32c6/tlora_c6/platformio.ini @@ -8,4 +8,3 @@ build_flags = -I variants/esp32c6/tlora_c6 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini b/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini index 6dd828433..0bb21581a 100644 --- a/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini +++ b/variants/esp32s3/heltec_capsule_sensor_v3/platformio.ini @@ -6,5 +6,4 @@ board_build.partitions = default_8MB.csv build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_capsule_sensor_v3 -D HELTEC_CAPSULE_SENSOR_V3 - -ULED_BUILTIN ;-D DEBUG_DISABLED ; uncomment this line to disable DEBUG output diff --git a/variants/esp32s3/heltec_sensor_hub/platformio.ini b/variants/esp32s3/heltec_sensor_hub/platformio.ini index 9a5384ccd..ab99e51ed 100644 --- a/variants/esp32s3/heltec_sensor_hub/platformio.ini +++ b/variants/esp32s3/heltec_sensor_hub/platformio.ini @@ -7,4 +7,3 @@ build_flags = ${esp32s3_base.build_flags} -I variants/esp32s3/heltec_sensor_hub -D HELTEC_SENSOR_HUB - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_v3/platformio.ini b/variants/esp32s3/heltec_v3/platformio.ini index fe31df094..2f53c8756 100644 --- a/variants/esp32s3/heltec_v3/platformio.ini +++ b/variants/esp32s3/heltec_v3/platformio.ini @@ -18,4 +18,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_V3 -I variants/esp32s3/heltec_v3 - -ULED_BUILTIN diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 0336bf983..5a5004a45 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -8,7 +8,6 @@ build_flags = -D HELTEC_V4 -D HAS_LORA_FEM=1 -I variants/esp32s3/heltec_v4 - -ULED_BUILTIN [env:heltec-v4] diff --git a/variants/esp32s3/heltec_v4/variant.h b/variants/esp32s3/heltec_v4/variant.h index 83443c5f3..72f55d09f 100644 --- a/variants/esp32s3/heltec_v4/variant.h +++ b/variants/esp32s3/heltec_v4/variant.h @@ -29,6 +29,14 @@ #define SX126X_DIO2_AS_RF_SWITCH #define SX126X_DIO3_TCXO_VOLTAGE 1.8 +// Enable Traffic Management Module for Heltec V4 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + // ---- GC1109 RF FRONT END CONFIGURATION ---- // The Heltec V4.2 uses a GC1109 FEM chip with integrated PA and LNA // RF path: SX1262 -> Pi attenuator -> GC1109 PA -> Antenna @@ -89,4 +97,4 @@ // Seems to be missing on this new board #define GPS_TX_PIN (38) // This is for bits going TOWARDS the CPU #define GPS_RX_PIN (39) // This is for bits going TOWARDS the GPS -#define GPS_THREAD_INTERVAL 50 \ No newline at end of file +#define GPS_THREAD_INTERVAL 50 diff --git a/variants/esp32s3/heltec_wsl_v3/platformio.ini b/variants/esp32s3/heltec_wsl_v3/platformio.ini index 873300c3c..0903a6bc7 100644 --- a/variants/esp32s3/heltec_wsl_v3/platformio.ini +++ b/variants/esp32s3/heltec_wsl_v3/platformio.ini @@ -17,4 +17,3 @@ build_flags = ${esp32s3_base.build_flags} -D HELTEC_WSL_V3 -I variants/esp32s3/heltec_wsl_v3 - -ULED_BUILTIN diff --git a/variants/esp32s3/link32_s3_v1/platformio.ini b/variants/esp32s3/link32_s3_v1/platformio.ini index acce3dafb..b11ffaad0 100644 --- a/variants/esp32s3/link32_s3_v1/platformio.ini +++ b/variants/esp32s3/link32_s3_v1/platformio.ini @@ -11,3 +11,4 @@ build_flags = -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index 8f0b4b220..2d65a042c 100644 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -40,6 +40,14 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2 #define SX126X_MAX_POWER 19 #endif +// Enable Traffic Management Module for Station G2 +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif + /* #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage #define ADC_CHANNEL ADC1_GPIO4_CHANNEL diff --git a/variants/native/portduino/variant.h b/variants/native/portduino/variant.h index cba4aaedd..c23d17b8d 100644 --- a/variants/native/portduino/variant.h +++ b/variants/native/portduino/variant.h @@ -8,3 +8,11 @@ // RAK12002 RTC Module #define RV3028_RTC (uint8_t)0b1010010 + +// Enable Traffic Management Module for native/portduino +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 +#endif diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h index 71508c037..ac7ef57c4 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/rfswitch.h @@ -12,9 +12,15 @@ static const uint32_t rfswitch_dio_pins[] = {RADIOLIB_LR11X0_DIO5, RADIOLIB_LR11 RADIOLIB_NC}; static const Module::RfSwitchMode_t rfswitch_table[] = { - // mode DIO5 DIO6 DIO7 - {LR11x0::MODE_STBY, {LOW, LOW, LOW}}, {LR11x0::MODE_RX, {LOW, HIGH, LOW}}, - {LR11x0::MODE_TX, {HIGH, HIGH, LOW}}, {LR11x0::MODE_TX_HP, {HIGH, LOW, LOW}}, - {LR11x0::MODE_TX_HF, {LOW, LOW, LOW}}, {LR11x0::MODE_GNSS, {LOW, LOW, HIGH}}, - {LR11x0::MODE_WIFI, {LOW, LOW, LOW}}, END_OF_MODE_TABLE, + // clang-format off + // mode DIO5 DIO6 DIO7 + {LR11x0::MODE_STBY, {LOW, LOW, LOW}}, + {LR11x0::MODE_RX, {LOW, HIGH, LOW}}, + {LR11x0::MODE_TX, {HIGH, HIGH, LOW}}, + {LR11x0::MODE_TX_HP, {HIGH, LOW, LOW}}, + {LR11x0::MODE_TX_HF, {LOW, LOW, LOW}}, + {LR11x0::MODE_GNSS, {LOW, LOW, HIGH}}, + {LR11x0::MODE_WIFI, {LOW, LOW, LOW}}, + END_OF_MODE_TABLE, + // clang-format on }; diff --git a/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini b/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini index e7eede80f..cecca3d81 100644 --- a/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini +++ b/variants/nrf52840/gat562_mesh_trial_tracker/platformio.ini @@ -11,4 +11,5 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/gat562_mesh_trial_tracker> diff --git a/variants/nrf52840/meshlink/platformio.ini b/variants/nrf52840/meshlink/platformio.ini index 28122d9bd..f3dc6185c 100644 --- a/variants/nrf52840/meshlink/platformio.ini +++ b/variants/nrf52840/meshlink/platformio.ini @@ -12,6 +12,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/meshlink> debug_tool = jlink @@ -30,6 +31,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -D USE_EINK -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 -D EINK_WIDTH=250 @@ -51,4 +53,4 @@ lib_deps = debug_tool = jlink ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ; Note: as of 6/2013 the serial/bootloader based programming takes approximately 30 seconds -;upload_protocol = jlink \ No newline at end of file +;upload_protocol = jlink diff --git a/variants/nrf52840/nrf52840.ini b/variants/nrf52840/nrf52840.ini index 09b2ef97d..c5590cbc3 100644 --- a/variants/nrf52840/nrf52840.ini +++ b/variants/nrf52840/nrf52840.ini @@ -4,6 +4,7 @@ extends = nrf52_base build_flags = ${nrf52_base.build_flags} -DSERIAL_BUFFER_SIZE=4096 + -DLED_BUILTIN=-1 lib_deps = ${nrf52_base.lib_deps} @@ -79,4 +80,4 @@ debug_speed = 4000 ; The following is not needed because it automatically tries do this ;debug_server_ready_pattern = -.*GDB server started on port \d+.* -;debug_port = localhost:3333 \ No newline at end of file +;debug_port = localhost:3333 diff --git a/variants/nrf52840/r1-neo/platformio.ini b/variants/nrf52840/r1-neo/platformio.ini index 85fe49cf1..0aaec2330 100644 --- a/variants/nrf52840/r1-neo/platformio.ini +++ b/variants/nrf52840/r1-neo/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/r1-neo> + + lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak2560/platformio.ini b/variants/nrf52840/rak2560/platformio.ini index 1703a13ae..54b66f4b2 100644 --- a/variants/nrf52840/rak2560/platformio.ini +++ b/variants/nrf52840/rak2560/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -DHAS_RAKPROT=1 ; Define if RAk OneWireSerial is used (disables GPS) build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak2560> + + + lib_deps = diff --git a/variants/nrf52840/rak3401_1watt/platformio.ini b/variants/nrf52840/rak3401_1watt/platformio.ini index bb8fa28df..889a17ed6 100644 --- a/variants/nrf52840/rak3401_1watt/platformio.ini +++ b/variants/nrf52840/rak3401_1watt/platformio.ini @@ -22,6 +22,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak3401_1watt> + lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index 4a96fc8d9..179d73e92 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -21,6 +21,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} \ +<../variants/nrf52840/rak4631> \ + \ diff --git a/variants/nrf52840/rak4631_epaper/platformio.ini b/variants/nrf52840/rak4631_epaper/platformio.ini index caa6ea328..f71fb6301 100644 --- a/variants/nrf52840/rak4631_epaper/platformio.ini +++ b/variants/nrf52840/rak4631_epaper/platformio.ini @@ -11,6 +11,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631_epaper> lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini index 84a582fd9..670b2c415 100644 --- a/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini +++ b/variants/nrf52840/rak4631_epaper_onrxtx/platformio.ini @@ -13,6 +13,7 @@ build_flags = ${nrf52840_base.build_flags} -D RADIOLIB_EXCLUDE_SX128X=1 -D RADIOLIB_EXCLUDE_SX127X=1 -D RADIOLIB_EXCLUDE_LR11X0=1 + -D RADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631_epaper_onrxtx> lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini b/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini index 9b15e668a..f1641e7e4 100644 --- a/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini +++ b/variants/nrf52840/rak4631_nomadstar_meteor_pro/platformio.ini @@ -21,6 +21,7 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak4631_nomadstar_meteor_pro> + + lib_deps = ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index 1e6e63e60..07fd6e73f 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -19,5 +19,6 @@ build_flags = ${nrf52840_base.build_flags} -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 -DMESHTASTIC_EXCLUDE_WIFI=1 build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/rak_wismeshtag> diff --git a/variants/nrf52840/tracker-t1000-e/variant.h b/variants/nrf52840/tracker-t1000-e/variant.h index b064dbc9f..de6916ac7 100644 --- a/variants/nrf52840/tracker-t1000-e/variant.h +++ b/variants/nrf52840/tracker-t1000-e/variant.h @@ -154,6 +154,15 @@ extern "C" { #define HAS_SCREEN 0 +// Enable Traffic Management Module for testing on T1000-E +// NRF52840 has 256KB RAM - 1024 entries uses ~10KB +#ifndef HAS_TRAFFIC_MANAGEMENT +#define HAS_TRAFFIC_MANAGEMENT 1 +#endif +#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1024 +#endif + #ifdef __cplusplus } #endif diff --git a/variants/rp2040/rpipicow/platformio.ini b/variants/rp2040/rpipicow/platformio.ini index 9b4b29a5b..99e02a1aa 100644 --- a/variants/rp2040/rpipicow/platformio.ini +++ b/variants/rp2040/rpipicow/platformio.ini @@ -22,7 +22,6 @@ build_flags = -D HW_SPI1_DEVICE -D HAS_UDP_MULTICAST=1 -fexceptions # for exception handling in MQTT - -ULED_BUILTIN build_src_filter = ${rp2040_base.build_src_filter} + lib_deps = ${rp2040_base.lib_deps} diff --git a/variants/stm32/rak3172/variant.h b/variants/stm32/rak3172/variant.h index bd6decd4c..75e3e0c91 100644 --- a/variants/stm32/rak3172/variant.h +++ b/variants/stm32/rak3172/variant.h @@ -16,6 +16,11 @@ Do not expect a working Meshtastic device with this target. #define LED_POWER PA0 // Green LED #define LED_STATE_ON 1 +#define BATTERY_PIN AVBAT +// ADC_MULTIPLIER: 3.0 = internal 1:3 bridge divider (DS13105§3.18.3) +// Margin: 1.10 = AVBAT divider tolerance ±10% (Table 82) +#define ADC_MULTIPLIER (1.01f * 3) + #define RAK3172 #define SERIAL_PRINT_PORT 1 diff --git a/variants/stm32/russell/platformio.ini b/variants/stm32/russell/platformio.ini index 0dd57a2c7..73cf7f81a 100644 --- a/variants/stm32/russell/platformio.ini +++ b/variants/stm32/russell/platformio.ini @@ -13,9 +13,19 @@ build_flags = ${stm32_base.build_flags} -Ivariants/stm32/russell -DPRIVATE_HW + -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 + -DMESHTASTIC_EXCLUDE_RANGETEST=1 + -DMESHTASTIC_EXCLUDE_DETECTIONSENSOR=1 + -DMESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION=1 + -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 + -DMESHTASTIC_EXCLUDE_NEIGHBORINFO=1 + -DMESHTASTIC_EXCLUDE_TRACEROUTE=1 + -DMESHTASTIC_EXCLUDE_WAYPOINT=1 lib_deps = ${stm32_base.lib_deps} # renovate: datasource=custom.pio depName=Adafruit BME280 packageName=adafruit/library/Adafruit BME280 Library adafruit/Adafruit BME280 Library@2.3.0 + # renovate: datasource=custom.pio depName=Adafruit_BME680 packageName=adafruit/library/Adafruit BME680 Library + adafruit/Adafruit BME680 Library@2.0.6 upload_port = stlink diff --git a/variants/stm32/russell/variant.h b/variants/stm32/russell/variant.h index 8773d5d8d..d36826c15 100644 --- a/variants/stm32/russell/variant.h +++ b/variants/stm32/russell/variant.h @@ -13,6 +13,16 @@ // #define EXT_CHRG_DETECT PA5 // #define EXT_PWR_DETECT PA4 +#define BATTERY_PIN AVBAT +// ADC_MULTIPLIER: 3.0 = internal 1:3 bridge divider (DS13105§3.18.3) +// Margin: 1.10 = AVBAT divider tolerance ±10% (Table 82) +#define ADC_MULTIPLIER (1.01f * 3) +/* +Sample OCV curve for Li-SOCl2 primary lithium cells (e.g. Saft cells have fresh OCV of 3.67V) +#define NUM_OCV_POINTS 11 +#define OCV_ARRAY 3670, 3650, 3630, 3610, 3590, 3560, 3530, 3480, 3400, 3200, 2500 +*/ + // Bosch Sensortec BME280 #define HAS_SENSOR 1 @@ -20,6 +30,11 @@ #define ENABLE_HWSERIAL1 #define PIN_SERIAL1_RX PB7 #define PIN_SERIAL1_TX PB6 + +// Debug serial (USART2) +#define ENABLE_HWSERIAL2 +#define PIN_SERIAL2_TX PA2 +#define PIN_SERIAL2_RX PA3 #define HAS_GPS 1 #define PIN_GPS_STANDBY PA15 #define GPS_RX_PIN PB7 diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index d2c155398..1efe18e3d 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -35,6 +35,8 @@ build_flags = -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 + -DRADIOLIB_EXCLUDE_LR2021=1 + -DMESHTASTIC_DYNAMIC_SBRK_HEAP -DHAL_DAC_MODULE_ONLY -DHAL_RNG_MODULE_ENABLED -Wl,--wrap=__assert_func @@ -56,3 +58,6 @@ lib_deps = lib_ignore = OneButton + +; Set a custom linker script with a higher MinStackSize value, to match NRF52. +board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld \ No newline at end of file diff --git a/variants/stm32/stm32wle5xx.ld b/variants/stm32/stm32wle5xx.ld new file mode 100644 index 000000000..c13782926 --- /dev/null +++ b/variants/stm32/stm32wle5xx.ld @@ -0,0 +1,204 @@ +/* +****************************************************************************** +** +** File : LinkerScript.ld +** +** Author : STM32CubeIDE +** +** Abstract : Linker script for STM32WL55xC Device +** 256Kbytes FLASH +** 64Kbytes RAM +** +** Set heap size, stack size and stack location according +** to application requirements. +** +** Set memory bank area and size if external memory is used. +** +** Target : STMicroelectronics STM32 +** +** Distribution: The file is distributed as is without any warranty +** of any kind. +** +***************************************************************************** +** @attention +** +**

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

+** +** This software component is licensed by ST under BSD 3-Clause license, +** the "License"; You may not use this file except in compliance with the +** License. You may obtain a copy of the License at: +** opensource.org/licenses/BSD-3-Clause +** +***************************************************************************** +*/ + +/* Entry Point */ +ENTRY(Reset_Handler) + +/* Highest address of the user mode stack */ +_estack = ORIGIN(RAM) + LENGTH(RAM); /* end of "RAM" Ram type memory */ + +_Min_Heap_Size = 0x200 ; /* required amount of heap */ +/* Modified from original to 2KB, to match NRF52 */ +_Min_Stack_Size = 2048 ; /* required amount of stack */ + +/* Memories definition */ +MEMORY +{ + RAM (xrw) : ORIGIN = 0x20000000, LENGTH = LD_MAX_DATA_SIZE + FLASH (rx) : ORIGIN = 0x08000000 + LD_FLASH_OFFSET, LENGTH = LD_MAX_SIZE - LD_FLASH_OFFSET +} + +/* Sections */ +SECTIONS +{ + /* The startup code into "FLASH" Rom type memory */ + .isr_vector : + { + . = ALIGN(4); + KEEP(*(.isr_vector)) /* Startup code */ + . = ALIGN(4); + } >FLASH + + /* The program code and other data into "FLASH" Rom type memory */ + .text : + { + . = ALIGN(4); + *(.text) /* .text sections (code) */ + *(.text*) /* .text* sections (code) */ + *(.glue_7) /* glue arm to thumb code */ + *(.glue_7t) /* glue thumb to arm code */ + *(.eh_frame) + + KEEP (*(.init)) + KEEP (*(.fini)) + + . = ALIGN(4); + _etext = .; /* define a global symbols at end of code */ + } >FLASH + + /* Constant data into "FLASH" Rom type memory */ + .rodata : + { + . = ALIGN(4); + *(.rodata) /* .rodata sections (constants, strings, etc.) */ + *(.rodata*) /* .rodata* sections (constants, strings, etc.) */ + . = ALIGN(4); + } >FLASH + + .ARM.extab (READONLY) : { + . = ALIGN(4); + *(.ARM.extab* .gnu.linkonce.armextab.*) + . = ALIGN(4); + } >FLASH + + .ARM (READONLY) : { + . = ALIGN(4); + __exidx_start = .; + *(.ARM.exidx*) + __exidx_end = .; + . = ALIGN(4); + } >FLASH + + .preinit_array (READONLY) : + { + . = ALIGN(4); + PROVIDE_HIDDEN (__preinit_array_start = .); + KEEP (*(.preinit_array*)) + PROVIDE_HIDDEN (__preinit_array_end = .); + . = ALIGN(4); + } >FLASH + + .init_array (READONLY) : + { + . = ALIGN(4); + PROVIDE_HIDDEN (__init_array_start = .); + KEEP (*(SORT(.init_array.*))) + KEEP (*(.init_array*)) + PROVIDE_HIDDEN (__init_array_end = .); + . = ALIGN(4); + } >FLASH + + .fini_array (READONLY) : + { + . = ALIGN(4); + PROVIDE_HIDDEN (__fini_array_start = .); + KEEP (*(SORT(.fini_array.*))) + KEEP (*(.fini_array*)) + PROVIDE_HIDDEN (__fini_array_end = .); + . = ALIGN(4); + } >FLASH + + /* Used by the startup to initialize data */ + _sidata = LOADADDR(.data); + + /* Initialized data sections into "RAM" Ram type memory */ + .data : + { + . = ALIGN(4); + _sdata = .; /* create a global symbol at data start */ + *(.data) /* .data sections */ + *(.data*) /* .data* sections */ + *(.RamFunc) /* .RamFunc sections */ + *(.RamFunc*) /* .RamFunc* sections */ + + . = ALIGN(4); + _edata = .; /* define a global symbol at data end */ + + } >RAM AT> FLASH + + /* Uninitialized data section into "RAM" Ram type memory */ + . = ALIGN(4); + .bss : + { + /* This is used by the startup in order to initialize the .bss section */ + _sbss = .; /* define a global symbol at bss start */ + __bss_start__ = _sbss; + *(.bss) + *(.bss*) + *(COMMON) + + . = ALIGN(4); + _ebss = .; /* define a global symbol at bss end */ + __bss_end__ = _ebss; + } >RAM + + /* Define a noinit output section and mark it as NOLOAD to prevent + * putting its contents into the resulting .bin file (which is the + * default). */ + .noinit (NOLOAD) : + { + /* Ensure output is aligned */ + . = ALIGN(4); + /* Define a global _snoinit (and _enoinit below) symbol just in case + * code wants to iterate over all noinit variables for some reason */ + _snoinit = .; + /* Actually import the .noinit and .noinit* import sections */ + *(.noinit) + *(.noinit*) + . = ALIGN(4); + _enoinit = .; + } >RAM + + /* User_heap_stack section, used to check that there is enough "RAM" Ram type memory left */ + ._user_heap_stack : + { + . = ALIGN(8); + PROVIDE ( end = . ); + PROVIDE ( _end = . ); + . = . + _Min_Heap_Size; + . = . + _Min_Stack_Size; + . = ALIGN(8); + } >RAM + + /* Remove information from the compiler libraries */ + /DISCARD/ : + { + libc.a ( * ) + libm.a ( * ) + libgcc.a ( * ) + } + + .ARM.attributes 0 : { *(.ARM.attributes) } +}