From dd5e0b74bae18aabdd9f54380b79c2196412dd93 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Wed, 7 Jan 2026 08:32:13 -0600 Subject: [PATCH] Added adaptive coding rate support and unit tests --- Dockerfile.test | 43 +++++ src/mesh/NextHopRouter.cpp | 6 + src/mesh/RadioInterface.cpp | 74 ++++++++ src/mesh/RadioInterface.h | 22 +++ src/mesh/RadioLibInterface.cpp | 3 + test/TestUtil.cpp | 16 ++ test/TestUtil.h | 5 +- .../AdaptiveCodingRate.cpp | 164 ++++++++++++++++++ variants/native/portduino/platformio.ini | 8 + .../ELECROW-ThinkNode-M3/platformio.ini | 1 + .../heltec_mesh_pocket/platformio.ini | 4 + .../nrf52840/rak_wismeshtag/platformio.ini | 1 + .../nrf52840/tracker-t1000-e/platformio.ini | 1 + 13 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.test create mode 100644 test/test_adaptive_coding_rate/AdaptiveCodingRate.cpp diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 000000000..3043ac3fa --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,43 @@ +# Minimal container to run PlatformIO native/portduino tests +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + git \ + build-essential \ + cmake \ + pkg-config \ + libssl-dev \ + libncurses5 \ + libsdl2-dev \ + libx11-dev \ + libxext-dev \ + libusb-1.0-0-dev \ + libyaml-cpp-dev \ + libuv1-dev \ + libgpiod-dev \ + libbluetooth-dev \ + libulfius-dev \ + liborcania-dev \ + libmicrohttpd-dev \ + libjansson-dev \ + libgnutls28-dev \ + libcurl4-gnutls-dev \ + libi2c-dev \ + openssl \ + lsb-release \ + cppcheck \ + uuid-dev \ + zlib1g-dev \ + libbsd-dev \ + gdb \ + && pip3 install --no-cache-dir platformio \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +CMD ["bash"] diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index 5230e5b85..b774d4bdc 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -238,6 +238,12 @@ bool NextHopRouter::stopRetransmission(GlobalPacketId key) // call to startRetransmission. packetPool.release(p); +#ifdef USE_ADAPTIVE_CODING_RATE + if (iface) { + iface->clearAdaptiveCodingRateState(getFrom(p), p->id); + } +#endif + return true; } else return false; diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 5ee513e89..1c0f6a5f4 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -618,6 +618,13 @@ void RadioInterface::applyModemConfig() slotTimeMsec = computeSlotTimeMsec(); preambleTimeMsec = preambleLength * (pow_of_2(sf) / bw); +#ifdef USE_ADAPTIVE_CODING_RATE + if (adaptiveCrOverride >= 5 && adaptiveCrOverride <= 8 && cr != adaptiveCrOverride) { + cr = adaptiveCrOverride; + LOG_DEBUG("Adaptive coding rate override set to %u", cr); + } +#endif + 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, channel_num, power); @@ -730,3 +737,70 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p) sendingPacket = p; return p->encrypted.size + sizeof(PacketHeader); } + +#ifdef USE_ADAPTIVE_CODING_RATE +uint8_t RadioInterface::computeAdaptiveCodingRate(uint8_t attempt) const +{ + if (attempt <= 1) { + return 5; // Attempt 1: 4/5 + } + if (attempt == 2) { + return 7; // Attempt 2: 4/7 + } + return 8; // Attempt 3+: 4/8 +} + +uint64_t RadioInterface::adaptiveKey(NodeNum from, PacketId id) const +{ + return (static_cast(from) << 32) | id; +} + +void RadioInterface::pruneAdaptiveAttempts(uint32_t now) +{ + const uint32_t expiryMsec = 5 * 60 * 1000UL; // drop state after 5 minutes + for (auto it = adaptiveAttempts.begin(); it != adaptiveAttempts.end();) { + if (now - it->second.lastUseMsec > expiryMsec) { + it = adaptiveAttempts.erase(it); + } else { + ++it; + } + } +} + +uint8_t RadioInterface::recordAdaptiveAttempt(const meshtastic_MeshPacket *p) +{ + const uint32_t now = millis(); + pruneAdaptiveAttempts(now); + + const uint64_t key = adaptiveKey(getFrom(p), p->id); + auto &state = adaptiveAttempts[key]; + if (state.attempts < UINT8_MAX) { + state.attempts++; + } + state.lastUseMsec = now; + return state.attempts; +} + +bool RadioInterface::applyAdaptiveCodingRate(const meshtastic_MeshPacket *p) +{ + const uint8_t attempt = recordAdaptiveAttempt(p); + const uint8_t desiredCr = computeAdaptiveCodingRate(attempt); + if (desiredCr < 5 || desiredCr > 8) { + return false; + } + + adaptiveCrOverride = desiredCr; + if (cr != desiredCr) { + cr = desiredCr; + reconfigure(); + return true; + } + + return false; +} + +void RadioInterface::clearAdaptiveCodingRateState(NodeNum from, PacketId id) +{ + adaptiveAttempts.erase(adaptiveKey(from, id)); +} +#endif diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 6049a11cc..a2e29428e 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -6,6 +6,9 @@ #include "PointerQueue.h" #include "airtime.h" #include "error.h" +#ifdef USE_ADAPTIVE_CODING_RATE +#include +#endif #define MAX_TX_QUEUE 16 // max number of packets which can be waiting for transmission @@ -220,6 +223,11 @@ class RadioInterface // Whether we use the default frequency slot given our LoRa config (region and modem preset) static bool uses_default_frequency_slot; +#ifdef USE_ADAPTIVE_CODING_RATE + /** Clear adaptive coding rate tracking for a completed packet id */ + void clearAdaptiveCodingRateState(NodeNum from, PacketId id); +#endif + protected: int8_t power = 17; // Set by applyModemConfig() @@ -250,6 +258,20 @@ class RadioInterface */ virtual void saveChannelNum(uint32_t savedChannelNum); +#ifdef USE_ADAPTIVE_CODING_RATE + bool applyAdaptiveCodingRate(const meshtastic_MeshPacket *p); + struct AdaptiveAttemptState { + uint8_t attempts = 0; + uint32_t lastUseMsec = 0; + }; + std::unordered_map adaptiveAttempts; + uint8_t adaptiveCrOverride = 0; + uint8_t recordAdaptiveAttempt(const meshtastic_MeshPacket *p); + uint8_t computeAdaptiveCodingRate(uint8_t attempt) const; + void pruneAdaptiveAttempts(uint32_t now); + uint64_t adaptiveKey(NodeNum from, PacketId id) const; +#endif + private: /** * Convert our modemConfig enum into wf, sf, etc... diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 80e51b8bc..b8ed37728 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -540,6 +540,9 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) packetPool.release(txp); return false; } else { +#ifdef USE_ADAPTIVE_CODING_RATE + applyAdaptiveCodingRate(txp); +#endif configHardwareForSend(); // must be after setStandby size_t numbytes = beginSending(txp); diff --git a/test/TestUtil.cpp b/test/TestUtil.cpp index b470b8ce8..8bb14ca75 100644 --- a/test/TestUtil.cpp +++ b/test/TestUtil.cpp @@ -1,6 +1,8 @@ #include "SerialConsole.h" #include "concurrency/OSThread.h" #include "gps/RTC.h" +#include "mesh/MeshRadio.h" +#include "mesh/NodeDB.h" #include "TestUtil.h" @@ -14,5 +16,19 @@ void initializeTestEnvironment() tv.tv_usec = 0; perhapsSetRTC(RTCQualityNTP, &tv); #endif + concurrency::OSThread::setup(); +} + +void initializeTestEnvironmentMinimal() +{ + // Only satisfy OSThread assertions; skip SerialConsole and platform-specific setup + concurrency::hasBeenSetup = true; + + // Ensure region/config globals are sane before any RadioInterface instances compute slot timing + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + initRegion(); + concurrency::OSThread::setup(); } \ No newline at end of file diff --git a/test/TestUtil.h b/test/TestUtil.h index ce021e459..7b904227e 100644 --- a/test/TestUtil.h +++ b/test/TestUtil.h @@ -1,4 +1,7 @@ #pragma once // Initialize testing environment. -void initializeTestEnvironment(); \ No newline at end of file +void initializeTestEnvironment(); + +// Minimal init without creating SerialConsole or portduino peripherals (useful for lightweight logic tests) +void initializeTestEnvironmentMinimal(); \ No newline at end of file diff --git a/test/test_adaptive_coding_rate/AdaptiveCodingRate.cpp b/test/test_adaptive_coding_rate/AdaptiveCodingRate.cpp new file mode 100644 index 000000000..bccf48549 --- /dev/null +++ b/test/test_adaptive_coding_rate/AdaptiveCodingRate.cpp @@ -0,0 +1,164 @@ +#include +#include + +#include "TestUtil.h" +// Ensure adaptive coding rate logic is available during tests +#ifndef USE_ADAPTIVE_CODING_RATE +#define USE_ADAPTIVE_CODING_RATE 1 +#endif +#include "mesh/RadioInterface.h" + +class TestRadio : public RadioInterface +{ + public: + bool applyForTest(const meshtastic_MeshPacket *p) { return applyAdaptiveCodingRate(p); } + + uint8_t getAttempts(NodeNum from, PacketId id) + { +#ifdef USE_ADAPTIVE_CODING_RATE + auto it = adaptiveAttempts.find(adaptiveKey(from, id)); + return (it == adaptiveAttempts.end()) ? 0 : it->second.attempts; +#else + (void)from; + (void)id; + return 0; +#endif + } + +#ifdef USE_ADAPTIVE_CODING_RATE + void setAdaptiveState(NodeNum from, PacketId id, uint8_t attempts, uint32_t lastUse) + { + adaptiveAttempts[adaptiveKey(from, id)] = {attempts, lastUse}; + } +#endif + + uint8_t currentCr() const { return cr; } + void setCrForTest(uint8_t value) { cr = value; } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t /*totalPacketLen*/, bool /*received*/ = false) override { return 0; } + + bool reconfigure() override + { + reconfigureCount++; + lastCr = cr; + return true; + } + + uint32_t reconfigureCount = 0; + uint8_t lastCr = 0; +}; + +void test_attempt_progression() +{ + TestRadio radio; + meshtastic_MeshPacket packet = {}; + packet.from = 0xABCDEF01; + packet.id = 0x1; + + TEST_ASSERT_FALSE(radio.applyForTest(&packet)); + TEST_ASSERT_EQUAL_UINT8(1, radio.getAttempts(packet.from, packet.id)); + TEST_ASSERT_EQUAL_UINT8(5, radio.currentCr()); + TEST_ASSERT_EQUAL_UINT32(0, radio.reconfigureCount); + + TEST_ASSERT_TRUE(radio.applyForTest(&packet)); + TEST_ASSERT_EQUAL_UINT8(2, radio.getAttempts(packet.from, packet.id)); + TEST_ASSERT_EQUAL_UINT8(7, radio.currentCr()); + TEST_ASSERT_EQUAL_UINT32(1, radio.reconfigureCount); + TEST_ASSERT_EQUAL_UINT8(7, radio.lastCr); + + TEST_ASSERT_TRUE(radio.applyForTest(&packet)); + TEST_ASSERT_EQUAL_UINT8(3, radio.getAttempts(packet.from, packet.id)); + TEST_ASSERT_EQUAL_UINT8(8, radio.currentCr()); + TEST_ASSERT_EQUAL_UINT32(2, radio.reconfigureCount); + TEST_ASSERT_EQUAL_UINT8(8, radio.lastCr); +} + +void test_attempts_are_per_packet() +{ + TestRadio radio; + meshtastic_MeshPacket first = {}; + first.from = 0x1001; + first.id = 0xA; + + meshtastic_MeshPacket second = {}; + second.from = 0x1001; + second.id = 0xB; + + radio.applyForTest(&first); + radio.applyForTest(&second); + radio.applyForTest(&first); + + TEST_ASSERT_EQUAL_UINT8(2, radio.getAttempts(first.from, first.id)); + TEST_ASSERT_EQUAL_UINT8(1, radio.getAttempts(second.from, second.id)); + TEST_ASSERT_EQUAL_UINT8(7, radio.currentCr()); +} + +void test_clear_resets_attempts_and_rate() +{ + TestRadio radio; + meshtastic_MeshPacket packet = {}; + packet.from = 0xCAFE; + packet.id = 0x55; + + radio.applyForTest(&packet); + radio.applyForTest(&packet); + radio.applyForTest(&packet); + + radio.reconfigureCount = 0; + radio.setCrForTest(8); + radio.clearAdaptiveCodingRateState(packet.from, packet.id); + + TEST_ASSERT_TRUE(radio.applyForTest(&packet)); + TEST_ASSERT_EQUAL_UINT8(1, radio.getAttempts(packet.from, packet.id)); + TEST_ASSERT_EQUAL_UINT8(5, radio.currentCr()); + TEST_ASSERT_EQUAL_UINT32(1, radio.reconfigureCount); +} + +void test_prunes_expired_state() +{ + TestRadio radio; + meshtastic_MeshPacket packet = {}; + packet.from = 0xBEEF; + packet.id = 0x99; + + radio.applyForTest(&packet); +#ifdef USE_ADAPTIVE_CODING_RATE + const uint32_t now = millis(); + radio.setAdaptiveState(packet.from, packet.id, 3, now - (5 * 60 * 1000UL + 50)); +#endif + radio.reconfigureCount = 0; + radio.setCrForTest(5); + + TEST_ASSERT_FALSE(radio.applyForTest(&packet)); + TEST_ASSERT_EQUAL_UINT8(1, radio.getAttempts(packet.from, packet.id)); + TEST_ASSERT_EQUAL_UINT32(0, radio.reconfigureCount); +} + +void setup() +{ + printf("AdaptiveCodingRate test setup start\n"); + fflush(stdout); + // Use minimal init to avoid pulling in SerialConsole/portduino peripherals for these logic-only tests + initializeTestEnvironmentMinimal(); + + printf("AdaptiveCodingRate test init done\n"); + fflush(stdout); + + UNITY_BEGIN(); + RUN_TEST(test_attempt_progression); + RUN_TEST(test_attempts_are_per_packet); + RUN_TEST(test_clear_resets_attempts_and_rate); + RUN_TEST(test_prunes_expired_state); + UNITY_END(); +} + +void loop() +{ + delay(1000); +} diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 045e3edea..c01e13125 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -2,6 +2,9 @@ extends = portduino_base build_flags = ${portduino_base.build_flags} -I variants/native/portduino -I /usr/include + -I /opt/homebrew/include + -L /opt/homebrew/lib -largp + -DUSE_ADAPTIVE_CODING_RATE board = cross_platform board_level = extra lib_deps = @@ -9,6 +12,11 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 +; Disable LovyanGFX for native test builds to avoid missing macOS system headers +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX + build_src_filter = ${portduino_base.build_src_filter} [env:native] diff --git a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini index 0ed46896f..7ea05cdd2 100644 --- a/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini +++ b/variants/nrf52840/ELECROW-ThinkNode-M3/platformio.ini @@ -7,6 +7,7 @@ build_flags = ${nrf52840_base.build_flags} -Ivariants/nrf52840/ELECROW-ThinkNode-M3 -DELECROW_ThinkNode_M3 + -DUSE_ADAPTIVE_CODING_RATE -DGPS_POWER_TOGGLE -D CONFIG_NFCT_PINS_AS_GPIOS=1 -L "${platformio.libdeps_dir}/${this.__env__}/bsec2/src/cortex-m4/fpv4-sp-d16-hard" diff --git a/variants/nrf52840/heltec_mesh_pocket/platformio.ini b/variants/nrf52840/heltec_mesh_pocket/platformio.ini index 4d3610fda..dc5389e30 100644 --- a/variants/nrf52840/heltec_mesh_pocket/platformio.ini +++ b/variants/nrf52840/heltec_mesh_pocket/platformio.ini @@ -9,6 +9,7 @@ build_flags = ${nrf52840_base.build_flags} -Ivariants/nrf52840/heltec_mesh_pocket -DHELTEC_MESH_POCKET -DHELTEC_MESH_POCKET_BATTERY_5000 + -DUSE_ADAPTIVE_CODING_RATE -DUSE_EINK -DEINK_DISPLAY_MODEL=GxEPD2_213_B74 -DEINK_WIDTH=250 @@ -38,6 +39,7 @@ build_flags = -I variants/nrf52840/heltec_mesh_pocket -D HELTEC_MESH_POCKET -D HELTEC_MESH_POCKET_BATTERY_5000 + -DUSE_ADAPTIVE_CODING_RATE lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${nrf52840_base.lib_deps} @@ -54,6 +56,7 @@ build_flags = ${nrf52840_base.build_flags} -Ivariants/nrf52840/heltec_mesh_pocket -DHELTEC_MESH_POCKET -DHELTEC_MESH_POCKET_BATTERY_10000 + -DUSE_ADAPTIVE_CODING_RATE -DUSE_EINK -DEINK_DISPLAY_MODEL=GxEPD2_213_B74 -DEINK_WIDTH=250 @@ -83,6 +86,7 @@ build_flags = -I variants/nrf52840/heltec_mesh_pocket -D HELTEC_MESH_POCKET -D HELTEC_MESH_POCKET_BATTERY_10000 + -DUSE_ADAPTIVE_CODING_RATE lib_deps = ${inkhud.lib_deps} ; InkHUD libs first, so we get GFXRoot instead of AdafruitGFX ${nrf52840_base.lib_deps} diff --git a/variants/nrf52840/rak_wismeshtag/platformio.ini b/variants/nrf52840/rak_wismeshtag/platformio.ini index 1cc00e253..fbdf78aca 100644 --- a/variants/nrf52840/rak_wismeshtag/platformio.ini +++ b/variants/nrf52840/rak_wismeshtag/platformio.ini @@ -7,6 +7,7 @@ build_flags = ${nrf52840_base.build_flags} -I variants/nrf52840/rak_wismeshtag -D WISMESH_TAG -D RAK_4631 + -DUSE_ADAPTIVE_CODING_RATE -DRADIOLIB_EXCLUDE_SX128X=1 -DRADIOLIB_EXCLUDE_SX127X=1 -DRADIOLIB_EXCLUDE_LR11X0=1 diff --git a/variants/nrf52840/tracker-t1000-e/platformio.ini b/variants/nrf52840/tracker-t1000-e/platformio.ini index 86d74f68a..33ab8905b 100644 --- a/variants/nrf52840/tracker-t1000-e/platformio.ini +++ b/variants/nrf52840/tracker-t1000-e/platformio.ini @@ -7,6 +7,7 @@ build_flags = ${nrf52840_base.build_flags} -Isrc/platform/nrf52/softdevice -Isrc/platform/nrf52/softdevice/nrf52 -DTRACKER_T1000_E + -DUSE_ADAPTIVE_CODING_RATE -DMESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR_EXTERNAL=1 -DMESHTASTIC_EXCLUDE_CANNEDMESSAGES=1 -DMESHTASTIC_EXCLUDE_SCREEN=1