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