fix(llcc68): align airtime estimate with radio config

Calculate LoRa airtime from the exact LDRO value supplied to SetModulationParams instead of inferring it from symbol time.

Keep transmit_result airtime in microseconds for both LoRa and GFSK, using integer Hz math and 64-bit ceil division to avoid truncation errors.

Reapply modulation parameters during async TX flush so the estimator and the bytes sent to the radio stay in sync, and add a compile-time check for the intended SF7/BW125 implicit 6-byte airtime.
This commit is contained in:
2026-05-25 12:54:15 +08:00
parent d8db9e1eb0
commit 32a0c80ab0
3 changed files with 86 additions and 33 deletions
+5 -4
View File
@@ -41,12 +41,13 @@ constexpr auto DEFAULT_BUSY_TIMEOUT_MS = 100;
* @param preamble_length Preamble length in symbols * @param preamble_length Preamble length in symbols
* @param header_type Header type (implicit or explicit). * @param header_type Header type (implicit or explicit).
* @param crc_type CRC type (none or 16-bit) * @param crc_type CRC type (none or 16-bit)
* @param ldro_on Low data rate optimization setting sent to the radio
*/ */
constexpr airtime_t calc_time_on_air(uint8_t len, uint8_t sf, LoRaBandwidth bw, constexpr airtime_t calc_time_on_air(uint8_t len, uint8_t sf, LoRaBandwidth bw,
LoRaCodingRate cr, LoRaCodingRate cr,
uint16_t preamble_length, uint16_t preamble_length,
LoRaHeaderType header_type, LoRaHeaderType header_type,
LoRaCrcType crc_type); LoRaCrcType crc_type, bool ldro_on);
struct LLCC68 { struct LLCC68 {
/** trivial getter */ /** trivial getter */
+2 -2
View File
@@ -14,7 +14,7 @@
namespace app::driver::llcc68 { namespace app::driver::llcc68 {
struct LLCC68; struct LLCC68;
using airtime_t = std::chrono::duration<uint32_t, std::micro>; using airtime_t = std::chrono::microseconds;
using error_code = std::error_code; using error_code = std::error_code;
enum class Errc : uint8_t { enum class Errc : uint8_t {
@@ -1097,7 +1097,7 @@ struct transmit_result {
error_code post_action(); error_code post_action();
LLCC68 *self{}; LLCC68 *self{};
std::chrono::milliseconds airtime_estimated; airtime_t airtime_estimated;
}; };
} // namespace app::driver::llcc68 } // namespace app::driver::llcc68
+79 -27
View File
@@ -111,6 +111,39 @@ namespace {
// Max payload the radio supports and buffer sizing helpers // Max payload the radio supports and buffer sizing helpers
constexpr size_t kMaxPayload = 32; constexpr size_t kMaxPayload = 32;
constexpr uint64_t ceil_div_u64(uint64_t numerator, uint64_t denominator) {
if (denominator == 0) {
return 0;
}
return numerator / denominator + (numerator % denominator == 0 ? 0 : 1);
}
constexpr uint32_t lora_bw_hz(LoRaBandwidth bw) {
switch (bw) {
case LoRaBandwidth::BW_7_8:
return 7'810;
case LoRaBandwidth::BW_10_4:
return 10'420;
case LoRaBandwidth::BW_15_6:
return 15'630;
case LoRaBandwidth::BW_20_8:
return 20'830;
case LoRaBandwidth::BW_31_25:
return 31'250;
case LoRaBandwidth::BW_41_7:
return 41'670;
case LoRaBandwidth::BW_62_5:
return 62'500;
case LoRaBandwidth::BW_125_0:
return 125'000;
case LoRaBandwidth::BW_250_0:
return 250'000;
case LoRaBandwidth::BW_500_0:
return 500'000;
}
return 0;
}
error_code command_status_to_error(status_t st) { error_code command_status_to_error(status_t st) {
switch (st.command_status) { switch (st.command_status) {
case CommandStatus::FAILURE_TO_EXECUTE_COMMAND: case CommandStatus::FAILURE_TO_EXECUTE_COMMAND:
@@ -163,47 +196,57 @@ constexpr airtime_t calc_time_on_air(uint8_t len, uint8_t sf, LoRaBandwidth bw,
LoRaCodingRate cr, LoRaCodingRate cr,
uint16_t preamble_length, uint16_t preamble_length,
LoRaHeaderType header_type, LoRaHeaderType header_type,
LoRaCrcType crc_type) { LoRaCrcType crc_type, bool ldro_on) {
// everything is in microseconds to allow integer arithmetic // Everything is in microseconds to allow integer arithmetic. Some constants
// some constants have .25, these are multiplied by 4, and have _x4 postfix to // have .25, these are multiplied by 4 and have an _x4 postfix.
// indicate that fact const uint32_t bw_hz = lora_bw_hz(bw);
const auto bw_ = bw_khz(bw); if (bw_hz == 0) {
const auto ubw = static_cast<uint32_t>(bw_ * 10); return airtime_t{0};
const uint32_t symbolLength_us = }
(static_cast<uint32_t>(1000 * 10) << sf) / ubw;
uint8_t sfCoeff1_x4 = 17; // (4.25 * 4) uint8_t sfCoeff1_x4 = 17; // (4.25 * 4)
uint8_t sfCoeff2 = 8; uint8_t sfCoeff2 = 8;
if (sf == 5 || sf == 6) { if (sf == 5 || sf == 6) {
sfCoeff1_x4 = 25; // 6.25 * 4 sfCoeff1_x4 = 25; // 6.25 * 4
sfCoeff2 = 0; sfCoeff2 = 0;
} }
uint8_t sfDivisor = 4 * sf; const uint8_t ldro_sf_offset = ldro_on ? 2 : 0;
if (symbolLength_us >= 16000) { if (sf <= ldro_sf_offset) {
sfDivisor = 4 * (sf - 2); return airtime_t{0};
} }
constexpr int8_t bitsPerCrc = 16; const uint8_t sfDivisor = 4 * (sf - ldro_sf_offset);
int8_t N_symbol_header = const int16_t header_bits =
header_type == RADIOLIB_SX126X_LORA_HEADER_EXPLICIT ? 20 : 0; header_type == LoRaHeaderType::HEADER_EXPLICIT ? 20 : 0;
// numerator of equation in section 6.1.4 of SX1268 datasheet v1.1 (might // numerator of equation in section 6.1.4 of SX1268 datasheet v1.1 (might
// not actually be bitcount, but it has len * 8) // not actually be bitcount, but it has len * 8)
auto bit_count = static_cast<int16_t>( auto bit_count = static_cast<int16_t>(
static_cast<int16_t>(8) * static_cast<int16_t>(len) + static_cast<int16_t>(8) * static_cast<int16_t>(len) +
static_cast<int16_t>(crc_type * bitsPerCrc) - static_cast<int16_t>(crc_type == LoRaCrcType::CRC_ON ? 16 : 0) -
static_cast<int16_t>(4 * sf) + static_cast<int16_t>(sfCoeff2) + static_cast<int16_t>(4 * sf) + static_cast<int16_t>(sfCoeff2) +
static_cast<int16_t>(N_symbol_header)); header_bits);
bit_count = std::max<int16_t>(bit_count, 0); bit_count = std::max<int16_t>(bit_count, 0);
// add (sfDivisor) - 1 to the numerator to give integer CEIL(...) // add (sfDivisor) - 1 to the numerator to give integer CEIL(...)
const uint16_t nPreCodedSymbols = (bit_count + (sfDivisor - 1)) / (sfDivisor); const uint16_t nPreCodedSymbols = (bit_count + (sfDivisor - 1)) / (sfDivisor);
const auto de = std::get<1>(cr_to_ratio(cr)); const auto cr_denominator = std::get<1>(cr_to_ratio(cr));
// preamble can be 65k, therefore nSymbol_x4 needs to be 32 bit // preamble can be 65k, therefore nSymbol_x4 needs to be 32 bit
const uint32_t nSymbol_x4 = const uint32_t nSymbol_x4 =
(preamble_length + 8) * 4 + sfCoeff1_x4 + nPreCodedSymbols * de * 4; (preamble_length + 8) * 4 + sfCoeff1_x4 +
nPreCodedSymbols * cr_denominator * 4;
return airtime_t{symbolLength_us * nSymbol_x4 / 4}; const uint64_t time_us = ceil_div_u64(
(static_cast<uint64_t>(1'000'000) << sf) * nSymbol_x4,
static_cast<uint64_t>(bw_hz) * 4U);
return airtime_t{static_cast<airtime_t::rep>(time_us)};
} }
static_assert(calc_time_on_air(6, 7, LoRaBandwidth::BW_125_0,
LoRaCodingRate::CR_4_5, 8,
LoRaHeaderType::HEADER_IMPLICIT,
LoRaCrcType::CRC_OFF, false) ==
airtime_t{25'856});
// SPI helpers implementing the LLCC68 wire protocol // SPI helpers implementing the LLCC68 wire protocol
expected<unit, error_code> LLCC68::read_stream(uint8_t cmd, expected<unit, error_code> LLCC68::read_stream(uint8_t cmd,
std::span<uint8_t> data, std::span<uint8_t> data,
@@ -672,7 +715,7 @@ static constexpr uint8_t gfsk_crc_len_bytes(GfskCrcType crc_type) {
} }
} }
static constexpr std::chrono::milliseconds static constexpr airtime_t
calc_gfsk_time_on_air(gfsk_parameters_t params, uint8_t payload_len) { calc_gfsk_time_on_air(gfsk_parameters_t params, uint8_t payload_len) {
const auto address_bits = const auto address_bits =
params.packet_params.address_filtering == GfskAddressFiltering::Disabled params.packet_params.address_filtering == GfskAddressFiltering::Disabled
@@ -688,9 +731,12 @@ calc_gfsk_time_on_air(gfsk_parameters_t params, uint8_t payload_len) {
(static_cast<uint32_t>(payload_len) + (static_cast<uint32_t>(payload_len) +
gfsk_crc_len_bytes(params.packet_params.crc_type)) * gfsk_crc_len_bytes(params.packet_params.crc_type)) *
8U; 8U;
const uint32_t ms = (bits * 1000U + params.mod_params.bitrate_bps - 1U) / if (params.mod_params.bitrate_bps == 0) {
params.mod_params.bitrate_bps; return airtime_t{0};
return std::chrono::milliseconds{ms}; }
const uint64_t us = ceil_div_u64(static_cast<uint64_t>(bits) * 1'000'000U,
params.mod_params.bitrate_bps);
return airtime_t{static_cast<airtime_t::rep>(us)};
} }
static expected<unit, error_code> static expected<unit, error_code>
@@ -1250,6 +1296,11 @@ LLCC68::hal_async_flush(lora_parameters_t params) {
return ue(Errc::InvalidState); return ue(Errc::InvalidState);
} }
APP_RADIO_RETURN_ERR_CTX(set_standby(), "tx::standby"); APP_RADIO_RETURN_ERR_CTX(set_standby(), "tx::standby");
APP_RADIO_RETURN_ERR_CTX(
set_modulation_params(params.mod_params.sf, params.mod_params.bw,
params.mod_params.cr,
params.mod_params.ldr_optimize),
"tx::set_modulation_params");
APP_RADIO_RETURN_ERR_CTX(set_packet_params(params.packet_params.preamble_len, APP_RADIO_RETURN_ERR_CTX(set_packet_params(params.packet_params.preamble_len,
data().tx_xfer_size, data().tx_xfer_size,
params.packet_params.crc_type, params.packet_params.crc_type,
@@ -1278,10 +1329,9 @@ LLCC68::hal_async_flush(lora_parameters_t params) {
auto air = calc_time_on_air( auto air = calc_time_on_air(
data().tx_xfer_size, params.mod_params.sf, params.mod_params.bw, data().tx_xfer_size, params.mod_params.sf, params.mod_params.bw,
params.mod_params.cr, params.packet_params.preamble_len, params.mod_params.cr, params.packet_params.preamble_len,
params.packet_params.hdr_type, params.packet_params.crc_type); params.packet_params.hdr_type, params.packet_params.crc_type,
auto air_estimated = params.mod_params.ldr_optimize == LoRaLowDataRateType::LDR_ON);
std::chrono::duration_cast<std::chrono::milliseconds>(air); return transmit_result{this, air};
return transmit_result{this, air_estimated};
} }
expected<transmit_result, error_code> expected<transmit_result, error_code>
@@ -1304,6 +1354,8 @@ LLCC68::hal_gfsk_async_flush(gfsk_parameters_t params) {
} }
APP_RADIO_RETURN_ERR_CTX(set_standby(), "gfsk_tx::standby"); APP_RADIO_RETURN_ERR_CTX(set_standby(), "gfsk_tx::standby");
APP_RADIO_RETURN_ERR_CTX(set_gfsk_modulation_params(params.mod_params),
"gfsk_tx::set_modulation_params");
auto packet_params = params.packet_params; auto packet_params = params.packet_params;
packet_params.payload_length = static_cast<uint8_t>(data().tx_xfer_size); packet_params.payload_length = static_cast<uint8_t>(data().tx_xfer_size);
APP_RADIO_RETURN_ERR_CTX(set_gfsk_packet_params(packet_params), APP_RADIO_RETURN_ERR_CTX(set_gfsk_packet_params(packet_params),