Merge PR #7 with reviewed ExpressLRS power and memory fixes

Integrate ExpressLRS TX module support and its stacked ESP32 heap changes.
Use the approved Linkflow calibration (17-30 dBm), preserve the PA drive
and output path after radio recovery, and keep LoRa OTA enabled.

Preserve stored ACLs and filters on allocation failure, release owned
client/filter buffers, service heap OTA contexts on Companions, release
self-serving workspaces after TempRadio, and reset staged-resume state
when a context is released. Keep manual staging and active operations
alive. Account for all moved allocations in the runtime RAM gate.

Validation: 1,393 native cases; radio-power, heap-context, ACL persistence,
shared-queue transfer, display/inbox, radio receive, and memory regressions.
Firmware builds passed for Linkflow, Heltec V2 Companion, T-Beam MQTT
repeater, Heltec V4 R8 MQTT repeater, RAK4631 repeater, and Indicator Full.
Physical verification awaits access to the currently offline lab Pi.
This commit is contained in:
mikecarper
2026-09-10 22:06:16 -07:00
26 changed files with 1072 additions and 74 deletions
+18 -7
View File
@@ -292,7 +292,10 @@ void Mesh::loop() {
bool Mesh::hasPendingOtaApply() const {
#if defined(ENABLE_OTA) && !defined(OTA_SEEDER_ONLY)
return ota::ota_ctx().apply_pending;
// A released dynamic context cannot hold a pending apply: the context is only
// handed back once apply_pending is clear.
const ota::OtaContext* oc = ota::ota_context_if_active();
return oc && oc->apply_pending;
#else
return false;
#endif
@@ -343,6 +346,20 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
#if defined(ENABLE_OTA)
#if OTA_DYNAMIC_CONTEXT
// Nothing below can run without storage, and a released context holds no
// pending apply or egress. Bail before any ota_ctx() dereference.
if (!ota::ota_context_if_active()) {
_ota_temp_was_active = false;
#if !defined(OTA_SEEDER_ONLY)
// A later workspace has a fresh manager. Resume its persistent staging
// store again, and let that manager evaluate automatic installation.
_ota_resumed = false;
_ota_autoinstall_tried = false;
#endif
return;
}
#endif
#if !defined(OTA_SEEDER_ONLY)
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
// so its "verified; applying" reply can be delivered first (over LoRa that reply is the operator's
@@ -371,12 +388,6 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
}
#endif
#if defined(OTA_SHARED_COMPANION_QUEUE)
if (!ota::ota_context_if_active()) {
_ota_temp_was_active = false;
return;
}
#endif
const bool ota_active = isTempRadioActive();
if (!ota_active) {
+5 -3
View File
@@ -640,7 +640,7 @@ void ClientACL::load(FILESYSTEM* fs, const mesh::LocalIdentity& self_id) {
c.last_timestamp = UINT32_MAX;
}
self_id.calcSharedSecret(c.shared_secret, pub_key); // recalculate shared secrets in case our private key changed
if (num_clients < MAX_CLIENTS) {
if (num_clients < capacity) {
clients[num_clients++] = c;
} else {
full = true;
@@ -691,6 +691,8 @@ bool ClientACL::authorizeLoginTimestamp(
}
bool ClientACL::save(FILESYSTEM* fs, bool (*filter)(ClientInfo*)) {
// A failed allocation is not an empty ACL. Preserve the stored managers.
if (capacity == 0 || fs == NULL) return false;
_fs = fs;
#if defined(NRF52_PLATFORM)
mesh::AtomicFileWriter file(_fs, "/s_contacts");
@@ -803,7 +805,7 @@ bool ClientACL::clear() {
const bool files_cleared = !_fs->exists("/s_contacts")
&& !_fs->exists("/s_contacts.tmp")
&& !_fs->exists("/s_contacts.bak");
memset(clients, 0, sizeof(clients));
if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity);
num_clients = 0;
return files_cleared;
}
@@ -828,7 +830,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
}
ClientInfo* c;
if (num_clients < MAX_CLIENTS) {
if (num_clients < capacity) {
c = &clients[num_clients++];
} else {
if (oldest == NULL) return NULL; // every entry is protected
+18 -2
View File
@@ -3,6 +3,7 @@
#include <Arduino.h> // needed for PlatformIO
#include <Mesh.h>
#include <helpers/IdentityStore.h>
#include <new> // std::nothrow (heap-allocated client table)
#define PERM_ACL_ROLE_MASK 7 // lower 3 bits
#define PERM_ACL_GUEST 0
@@ -51,6 +52,9 @@ struct ClientInfo {
#define MAX_CLIENTS 32
#endif
static_assert(sizeof(void*) != 4 || sizeof(ClientInfo) <= 320,
"Update the client-table runtime RAM budget in check_firmware_ram.py");
struct ClientLoginReplayClampResult {
uint16_t stored_matched;
uint16_t stored_changed;
@@ -60,17 +64,29 @@ struct ClientLoginReplayClampResult {
class ClientACL {
FILESYSTEM* _fs;
ClientInfo clients[MAX_CLIENTS];
ClientInfo* clients;
int capacity; // 0 when the table could not be allocated
int num_clients;
bool login_replay_store_available;
public:
// MAX_CLIENTS entries run to several kilobytes. Classic ESP32's link-time
// static DRAM window is much smaller than its runtime heap, so the table is
// allocated here instead of living in .bss. This constructor runs before
// setup(), while the heap is still unfragmented. A failed allocation leaves
// a zero-capacity ACL that refuses new clients rather than writing through
// a null table; putClient() returns NULL and callers already handle that.
ClientACL() {
_fs = NULL;
memset(clients, 0, sizeof(clients));
clients = new (std::nothrow) ClientInfo[MAX_CLIENTS];
capacity = clients ? MAX_CLIENTS : 0;
if (clients) memset(clients, 0, sizeof(ClientInfo) * (size_t)capacity);
num_clients = 0;
login_replay_store_available = false;
}
~ClientACL() { delete[] clients; }
ClientACL(const ClientACL&) = delete;
ClientACL& operator=(const ClientACL&) = delete;
void load(FILESYSTEM* _fs, const mesh::LocalIdentity& self_id);
bool save(FILESYSTEM* _fs, bool (*filter)(ClientInfo*)=NULL);
bool clear();
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <Arduino.h>
#include <helpers/ESP32Board.h>
// The common shape of an ExpressLRS ESP32 transmitter module: powered from the
// JR bay or USB rather than a battery, a fan over the amplifier, and an
// ESP8285 "backpack" hanging off a second UART.
//
// A variant supplies whichever of these its ExpressLRS hardware.json lists:
//
// MANUFACTURER_NAME name reported to clients
// PIN_FAN_EN hardware.json "misc_fan_en" (HIGH = on)
// PIN_BACKPACK_EN hardware.json "backpack_en" (HIGH = enabled)
// PIN_BACKPACK_BOOT hardware.json "backpack_boot"
//
// There is no battery divider on these boards, and ESP32Board already returns
// 0 from getBattMilliVolts() when PIN_VBAT_READ is undefined, so nothing here
// needs to override it.
#ifndef MANUFACTURER_NAME
#define MANUFACTURER_NAME "ExpressLRS TX"
#endif
class ELRSTxBoard : public ESP32Board {
public:
void begin() {
ESP32Board::begin();
#ifdef PIN_BACKPACK_EN
// MeshCore has no use for the backpack. Hold it off so it cannot chatter
// on the shared UART.
pinMode(PIN_BACKPACK_EN, OUTPUT);
digitalWrite(PIN_BACKPACK_EN, LOW);
#endif
#ifdef PIN_BACKPACK_BOOT
pinMode(PIN_BACKPACK_BOOT, INPUT); // usually a strapping pin, leave it
#endif
#ifdef PIN_FAN_EN
// ExpressLRS only spins the fan above 250 mW, but MeshCore transmits for
// far longer than an ExpressLRS packet, so just leave it running.
pinMode(PIN_FAN_EN, OUTPUT);
digitalWrite(PIN_FAN_EN, HIGH);
#endif
}
const char* getManufacturerName() const override {
return MANUFACTURER_NAME;
}
uint32_t getIRQGpio() override {
return P_LORA_DIO_0; // SX127x signals RxDone/TxDone on DIO0
}
};
+40 -2
View File
@@ -1,10 +1,13 @@
#include "OtaContext.h"
#include <assert.h>
#if OTA_DYNAMIC_CONTEXT && defined(OTA_HEAP_CONTEXT)
#include <new>
#endif
namespace mesh {
namespace ota {
#if defined(OTA_SHARED_COMPANION_QUEUE)
#if OTA_DYNAMIC_CONTEXT
namespace {
OtaContext* active_context = nullptr;
void* storage_owner = nullptr;
@@ -23,6 +26,23 @@ uint8_t saved_autoinstall = OtaContext::AUTOINSTALL_OFF;
uint8_t saved_hops = OTA_HOP_LIMIT_DEFAULT;
uint16_t saved_checkpoint = OTA_CHECKPOINT_BLOCKS;
uint16_t saved_advert = OTA_ADVERT_INTERVAL_MINS;
#if defined(OTA_HEAP_CONTEXT)
// Default storage for roles with no borrowable workspace (repeaters, room
// servers). The context is several kilobytes; keeping it off .bss matters most
// on classic ESP32, whose static DRAM window is far smaller than its heap.
OtaContext* heap_context = nullptr;
OtaContext* acquireHeapContext(void*) {
if (!heap_context) heap_context = new (std::nothrow) OtaContext();
return heap_context; // nullptr on exhaustion; the caller reports and bails
}
void releaseHeapContext(void*) {
delete heap_context;
heap_context = nullptr;
}
#endif
}
void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*),
@@ -52,6 +72,12 @@ void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
bool ota_acquire_context(char* reply, size_t cap) {
if (active_context) return true;
#if defined(OTA_HEAP_CONTEXT)
if (!acquire_storage) { // no owner registered one: fall back to the heap
acquire_storage = acquireHeapContext;
release_storage = releaseHeapContext;
}
#endif
if (!acquire_storage || !release_storage || !saved_send) {
if (reply && cap) snprintf(reply, cap, "ERR mOTA storage is not ready");
return false;
@@ -59,7 +85,11 @@ bool ota_acquire_context(char* reply, size_t cap) {
active_context = acquire_storage(storage_owner);
if (!active_context) {
if (reply && cap) snprintf(reply, cap,
#if defined(OTA_HEAP_CONTEXT)
"ERR mOTA is out of memory; retry when the node is less busy");
#else
"ERR mOTA needs 128 free queue slots; sync unread messages with an app first");
#endif
return false;
}
OtaContext& c = *active_context;
@@ -81,7 +111,15 @@ uint8_t ota_hop_limit() {
void ota_release_context_if_idle(bool temporary_radio_active) {
if (!active_context) return;
OtaContext& c = *active_context;
if (c.folder_active || c.folder_dest || c.serving || c.apply_pending) return;
if (c.folder_active || c.folder_dest || c.apply_pending) return;
#if defined(OTA_HEAP_CONTEXT)
// Self-serving ends with the temporary radio window. It must not keep the
// heap workspace forever after the first announcement. Manual staging is
// different: preserve its bytes across separate CLI commands until reset.
if (c.serve_expected != 0) return;
#else
if (c.serving) return;
#endif
if (temporary_radio_active && !c.release_when_idle) return;
// No host source/destination remains. Discard pending transfer work before
// the queue reuses these bytes, including dynamic discovery/diff buffers.
+64 -6
View File
@@ -11,6 +11,19 @@
#include "OtaFormat.h"
#include "OtaSelf.h" // ota_self_firmware() - prefer self-describing EndF identity at begin()
#include "OtaBlInfo.h" // bootloader OTA-apply capability marker (nRF52); cached after first read
// Storage policy for the mOTA context. A "dynamic" context is created on demand
// and handed back once idle, so its multi-kilobyte workspace only occupies RAM
// while an OTA operation is actually in flight:
// OTA_SHARED_COMPANION_QUEUE - borrows the Companion's offline message queue
// OTA_HEAP_CONTEXT - allocates from the heap, failing softly
// Every other build keeps the plain .bss singleton.
#if defined(OTA_SHARED_COMPANION_QUEUE) || defined(OTA_HEAP_CONTEXT)
#define OTA_DYNAMIC_CONTEXT 1
#else
#define OTA_DYNAMIC_CONTEXT 0
#endif
#if defined(NRF52_PLATFORM) && defined(OTA_QSPI_STORE)
#include "OtaStoreQspiNrf52.h"
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
@@ -82,7 +95,7 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl
#endif
struct OtaContext {
#if defined(OTA_SHARED_COMPANION_QUEUE)
#if OTA_DYNAMIC_CONTEXT
// Release at a main-loop boundary, after callers finish using this context.
bool release_when_idle = false;
#endif
@@ -131,6 +144,22 @@ struct OtaContext {
// count (the manager's fixed 4 KiB scratch covers <=1024 blocks, about 2 MiB at the new default).
uint8_t* serve_self_leaves = nullptr;
uint8_t* serve_self_proof = nullptr;
// These raw buffers are owned by the context and are otherwise only freed
// when self-serve re-allocates them. A dynamic-storage build destroys the
// context between operations (OTA_HEAP_CONTEXT deletes it; the Companion's
// borrowed queue runs ~OtaContext() in place), so teardown has to release
// them or every acquire/release cycle leaks. A self-serving node reaches
// release with both buffers populated, so this is the normal path, not an
// edge case.
~OtaContext() {
releaseServeBuffer(); // no-op where serve_buf is a fixed array
free(serve_self_leaves);
free(serve_self_proof);
}
OtaContext() = default;
OtaContext(const OtaContext&) = delete; // would double-free above
OtaContext& operator=(const OtaContext&) = delete;
uint8_t serve_self_manifest[MOTA_MFL]; // fixed-layout full+unsigned manifest-minus-leaves (197 B)
ApplyState apply_st; // pending apply (P6)
@@ -409,7 +438,7 @@ struct OtaContext {
return false;
}
folder_active = true;
#if defined(OTA_SHARED_COMPANION_QUEUE)
#if OTA_DYNAMIC_CONTEXT
release_when_idle = false;
#endif
_folder_link = link;
@@ -439,7 +468,7 @@ struct OtaContext {
folder_active = false;
_folder_link = FOLDER_LINK_NONE;
_folder_source = nullptr;
#if defined(OTA_SHARED_COMPANION_QUEUE)
#if OTA_DYNAMIC_CONTEXT
release_when_idle = true;
#endif
}
@@ -626,10 +655,15 @@ private:
#endif
};
OtaContext& ota_ctx(); // process-wide singleton
#if defined(OTA_HEAP_CONTEXT)
static_assert(sizeof(OtaContext) <= 16384,
"Update the heap OTA runtime RAM budget in check_firmware_ram.py");
#endif
// On constrained source-only Companions, the context exists only while its
// queue-backed workspace is owned by mOTA. Other builds keep the singleton.
OtaContext& ota_ctx(); // process-wide context
// Dynamic builds return null outside an acquired workspace. Static builds
// always return their process-wide context.
OtaContext* ota_context_if_active();
bool ota_acquire_context(char* reply, size_t cap);
void ota_begin_context(uint32_t target, OtaSend send, void* ctx,
@@ -641,9 +675,33 @@ uint8_t ota_hop_limit();
!defined(OTA_SEEDER_ONLY) || !defined(COMPANION_RADIO_FULL)
#error "Shared mOTA queue storage requires an nRF52 or ESP32 Full source-only Companion"
#endif
#if defined(OTA_HEAP_CONTEXT)
#error "OTA_HEAP_CONTEXT and OTA_SHARED_COMPANION_QUEUE both own the context storage"
#endif
#endif
#if OTA_DYNAMIC_CONTEXT
// ota_ctx() is only valid while storage is held. Callers that can run before a
// successful ota_acquire_context() must gate on ota_context_if_active() first.
void ota_set_context_storage(void* owner, OtaContext* (*acquire)(void*),
void (*release)(void*));
void ota_release_context_if_idle(bool temporary_radio_active);
// Loop helper for roles whose LoRa OTA only runs under the temporary radio
// profile (repeater, room server, sensor). Nothing else acquires the context
// on their behalf: without this, serving and announcing would stop the moment
// the context was released, because only CLI entry points acquire. Holding it
// for the temp-radio window keeps behaviour identical to a permanent context,
// and the node is outside that window nearly all the time, which is where the
// saving comes from. Also used by heap-backed Companions. Shared-queue
// Companions must acquire only on explicit host demand instead.
inline void ota_service_temp_radio_context(bool temporary_radio_active) {
if (temporary_radio_active) {
ota_acquire_context(nullptr, 0); // failure is reported at the CLI entry points
} else {
ota_release_context_if_idle(false);
}
}
#endif
} // namespace ota
+187
View File
@@ -0,0 +1,187 @@
#pragma once
#include <Arduino.h>
#include <stdint.h>
#include "CustomSX1276Wrapper.h"
// TX power control for boards whose SX1276 does not drive the antenna
// directly, but feeds an external power amplifier whose gain is set by an
// analog control voltage rather than by the radio.
//
// Such a PA exposes a gain-control input - variously labelled APC, APC1/APC2,
// VAPC or VGA depending on the module - which is driven here from one of the
// MCU's DAC outputs. The radio is parked at a single fixed drive level and
// every power step is made by moving that control voltage, so the only thing
// this class overrides is applyCachedTxPower().
//
// A board supplies a table mapping the power it wants, in dBm at the antenna,
// to the control code that produces it. That table is the board's calibration:
// it is the only thing that knows what the amplifier actually does, so it is
// also what defines the legal range. Requests outside it are refused rather
// than saturated at the nearest end.
//
// static const DacPaLevel LEVELS[] = { {10, 30}, {17, 50}, {30, 130} };
// DacPaSX1276Wrapper radio_driver(radio, board, PIN_APC, LEVELS, 3);
//
// Entries must be ordered by ascending dBm. Nothing is assumed about the
// control codes themselves, so a board whose gain input runs backwards (a
// falling code for rising power) needs no special handling.
//
// The default drive level of +2 dBm is the SX1276's floor on PA_BOOST
// (RegPaConfig OutputPower = 0), which suits an amplifier expecting a small
// constant input; pass drive_dbm to override it for a PA that wants more.
//
// Not every board holds the radio still. Where the amplifier is driven
// somewhere other than its floor, the radio's own output moves with each step
// as well, and the board supplies a second table of radio output levels
// alongside the first. Pass it as radio_dbm and the fixed drive level is not
// used at all:
//
// static const DacPaLevel LEVELS[] = { {20, 165}, {24, 155}, {27, 142} };
// static const int8_t RADIO_DBM[] = { 2, 6, 9 };
// DacPaSX1276Wrapper radio_driver(radio, board, PIN_APC, LEVELS, 3,
// DAC_PA_TABLE_MAX, RADIO_DBM);
//
// radio_dbm must have one entry per level.
//
// Which output pin those values reach depends on how the board is wired. Set
// force_rfo for a board whose radio feeds the amplifier from RFO_HF rather
// than PA_BOOST (ExpressLRS layouts flag this as "radio_rfo_hf"). It matters
// because the two paths accept different ranges and RadioLib will not guess:
//
// RFO -4 .. 15 dBm
// PA_BOOST 2 .. 17 dBm, plus a special case at 20
//
// RadioLib selects RFO on its own for anything below 2 dBm, so a board whose
// levels are all negative works either way. One sitting in the overlap - the
// Radiomaster Bandit's [2, 6, 9, 10], for instance - would silently come out
// of the wrong pin without this flag.
//
// drive_dbm's +2 default is the PA_BOOST floor; an RFO board should pass its
// own, since -4 is where that path bottoms out instead.
//
// The gain control is written through writeGainControl(), which uses the
// ESP32's DAC by default. A board driving its PA from a PWM pin or an external
// I2C DAC can subclass and override that one method.
//
// ---------------------------------------------------------------------------
// Deriving a table from an ExpressLRS hardware layout
//
// ExpressLRS transmitter modules use this arrangement widely (they call it
// POWER_OUTPUT_DACWRITE), so their published layouts are a convenient source
// of vendor-calibrated tables. The layout's "power_values" array is indexed by
// (PowerLevels_e - power_min), so with power_min = 0 the entries run:
//
// PWR_10mW PWR_25mW PWR_50mW PWR_100mW PWR_250mW PWR_500mW ...
// 10 dBm 14 dBm 17 dBm 20 dBm 24 dBm 27 dBm
//
// Those are the vendor's nominal labels, not measurements. Treat an entry as
// an index into the amplifier's behaviour until it has been on a power meter.
// ---------------------------------------------------------------------------
struct DacPaLevel {
int8_t dbm; // power at the antenna, after the amplifier
uint8_t dac; // gain-control code that produces it
};
// SX1276 PA_BOOST output floor: Pout = 2 + OutputPower, so +2 dBm is
// OutputPower = 0.
#define DAC_PA_DEFAULT_DRIVE_DBM 2
// Sentinel for the max_dbm constructor argument: use the table's top entry.
#define DAC_PA_TABLE_MAX INT8_MAX
class DacPaSX1276Wrapper : public CustomSX1276Wrapper {
public:
// max_dbm optionally caps the amplifier below its top table entry, for a
// deployment that should not use everything the hardware can reach (thermal
// headroom, or a regulatory limit lower than the PA's capability). It is
// only ever a reduction; it cannot raise the ceiling above the table.
DacPaSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board,
uint8_t ctrl_pin,
const DacPaLevel* levels, uint8_t num_levels,
int8_t max_dbm = DAC_PA_TABLE_MAX,
const int8_t* radio_dbm = NULL,
bool force_rfo = false,
int8_t drive_dbm = DAC_PA_DEFAULT_DRIVE_DBM)
: CustomSX1276Wrapper(radio, board),
_ctrl_pin(ctrl_pin), _levels(levels), _num_levels(num_levels),
_radio_dbm(radio_dbm), _force_rfo(force_rfo), _drive_dbm(drive_dbm) {
if (!levels || !num_levels) {
_min_dbm = 1;
_max_dbm = 0; // Empty range: every power request fails.
return;
}
_min_dbm = levels[0].dbm;
_max_dbm = levels[num_levels - 1].dbm;
if (max_dbm < _max_dbm) _max_dbm = max_dbm;
}
// The supported range, taken from the table itself. The amplifier decides
// what this board can do, so nothing else has to be told separately.
int8_t minTxPowerDbm() const { return _min_dbm; }
int8_t maxTxPowerDbm() const { return _max_dbm; }
// Park the radio at its drive level and set the amplifier to dbm. Call once,
// after the radio has started. A startup level outside the supported range
// is brought into it rather than left unset.
//
// Boards carrying a radio_dbm table have no fixed drive level to park at;
// applyCachedTxPower() sets the radio for each step instead.
//
// This is also where an RFO board is corrected: std_init()'s begin() always
// configures PA_BOOST, so the first write from here moves it. Nothing
// transmits in between.
bool beginPowerControl(int8_t dbm) {
if (dbm < _min_dbm) dbm = _min_dbm;
if (dbm > _max_dbm) dbm = _max_dbm;
return setTxPower(dbm);
}
protected:
// Emit a gain-control code. Override for a PA driven by PWM or an external
// DAC instead of the MCU's own.
virtual void writeGainControl(uint8_t code) {
dacWrite(_ctrl_pin, code);
}
// MeshCore asks for power in dBm at the antenna. Restore both the radio
// drive and amplifier setting, including after a watchdog hard reset.
//
// Out-of-range requests are refused rather than silently saturated. Without
// this the top table entry becomes the response to any large number, which
// on a high-power module is not a failure anyone wants to discover on air.
int16_t applyCachedTxPower(int8_t dbm) override {
if (dbm < _min_dbm || dbm > _max_dbm) {
return RADIOLIB_ERR_INVALID_OUTPUT_POWER;
}
const uint8_t idx = indexForDbm(dbm);
// A watchdog hard reset restores std_init()'s radio output, too. Reapply
// the fixed drive level/RFO selection as well as per-step radio levels;
// otherwise recovery can drive the PA at LORA_TX_POWER instead of +2 dBm.
const int16_t status = ((CustomSX1276 *)_radio)->setOutputPower(
_radio_dbm ? _radio_dbm[idx] : _drive_dbm, _force_rfo);
if (status != RADIOLIB_ERR_NONE) return status;
writeGainControl(_levels[idx].dac);
return RADIOLIB_ERR_NONE;
}
private:
// Highest level that does not exceed dbm. Callers have already range-checked.
uint8_t indexForDbm(int8_t dbm) const {
uint8_t idx = 0;
for (uint8_t i = 0; i < _num_levels; i++) {
if (_levels[i].dbm <= dbm) idx = i;
}
return idx;
}
uint8_t _ctrl_pin;
const DacPaLevel* _levels;
uint8_t _num_levels;
const int8_t* _radio_dbm;
bool _force_rfo;
int8_t _drive_dbm;
int8_t _min_dbm;
int8_t _max_dbm;
};