mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-30 15:58:01 +00:00
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:
+18
-7
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user