Added adaptive coding rate support and unit tests

This commit is contained in:
Ben Meadors
2026-01-07 08:32:13 -06:00
parent 9f5170a0bc
commit dd5e0b74ba
13 changed files with 347 additions and 1 deletions

43
Dockerfile.test Normal file
View File

@@ -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"]

View File

@@ -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;

View File

@@ -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<uint64_t>(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

View File

@@ -6,6 +6,9 @@
#include "PointerQueue.h"
#include "airtime.h"
#include "error.h"
#ifdef USE_ADAPTIVE_CODING_RATE
#include <unordered_map>
#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<uint64_t, AdaptiveAttemptState> 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...

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -1,4 +1,7 @@
#pragma once
// Initialize testing environment.
void initializeTestEnvironment();
void initializeTestEnvironment();
// Minimal init without creating SerialConsole or portduino peripherals (useful for lightweight logic tests)
void initializeTestEnvironmentMinimal();

View File

@@ -0,0 +1,164 @@
#include <Arduino.h>
#include <unity.h>
#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);
}

View File

@@ -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]

View File

@@ -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"

View File

@@ -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}

View File

@@ -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

View File

@@ -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