Validate beacon power without stranding saved settings

This commit is contained in:
mikecarper committed 2026-10-16 00:39:07 -07:00
1 parent adbeb2d007
commit d5cc73c43d
15 files changed
+662 -7

No files matched your search

+1
View File
@@ -422,6 +422,7 @@ jobs:
'--storage-dir', '.pio/libdeps/radio-status-test'], check=True)
PY
python3 -B test/test_sx126x_receive_mode.py
python3 -B test/test_beacon_power.py -v
- name: Verify temporary-radio timing and exact reply delivery
run: |
+12
View File
@@ -40,11 +40,23 @@ Meshtext takes an explicit `us`, `eu868`, `eu433`, or `anz` region, a recognized
preset, and dBm. Its frequency is calculated using the stock preset channel;
its preamble is fixed at 16 symbols. Invalid modem, region, power, or text
settings are rejected. Use settings permitted for your hardware and location.
Power is checked against the driver's range at the beacon frequency and any
explicit board limits. External-PA drivers interpret it in antenna dBm using
their calibration range; the normal receive profile's power is unchanged.
Each message body is limited to 80 bytes and is prefixed with the node name.
Settings are saved separately from ordinary preferences and channels.
Configuration changes do not immediately transmit a beacon.
A structurally valid saved slot that the current board or firmware cannot
transmit remains available through `get beacon`. Its scheduled opportunity is
skipped, while supported slots continue normally. Use `set beacon` to repair
that slot or `delete beacon` to remove it; unsupported Meshtext slots do not
consume packet IDs. This also permits repairing an out-of-range power saved
by older firmware. Corrupt images retain the existing safe backup recovery;
without a valid backup they remain held. Unreadable or unknown-version files
remain held without transmitting or overwriting them.
## Radio ownership and limits
These are ordinary channel text messages, not MeshCore identity advertisements
+3
View File
@@ -94,6 +94,9 @@ public:
virtual bool supportsMeshtext() const { return false; }
virtual bool supportsEthernetTunnel() const { return false; }
#if MESH_ENABLE_TX_BEACONS
// Check the requested antenna power at the prospective beacon frequency.
// Validation must not retune the radio or alter its current power setting.
virtual bool validateBeaconPower(float, int8_t) const { return false; }
virtual RadioParamApplyResult prepareBeaconTransmit(const RadioProfileParams&, int8_t) {
return RadioParamApplyResult::FAILED;
}
+1 -2
View File
@@ -3,7 +3,6 @@
#if MESH_ENABLE_TX_BEACONS
#include <helpers/BeaconFrames.h>
#include <helpers/radiolib/RadioPowerLimits.h>
#include <string.h>
namespace mesh {
@@ -12,7 +11,7 @@ bool Mesh::validateTextBeacon(const BeaconSlot& slot) const {
if (!beacon::validText(slot.text) || slot.hops > 7
|| !RadioProfiles::valid(slot.radio)
|| !RadioProfiles::safePreamble(slot.radio, slot.radio.preamble ? slot.radio.preamble : 32)
|| slot.power < -9 || !isLoRaTxPowerValid(slot.power, slot.radio.freq)
|| !_radio->validateBeaconPower(slot.radio.freq, slot.power)
|| !_radio->validateProfile(slot.radio)) return false;
if (slot.radio.protocol == RadioProfileProtocol::MeshCore) return true;
#if MESH_ENABLE_MESHTEXT_WIRE
+12 -4
View File
@@ -5,6 +5,7 @@
#include <Mesh.h>
#include <helpers/FilePresence.h>
#include <helpers/MeshtextChat.h>
#include <helpers/UTF8Helpers.h>
#include <Arduino.h>
#include <ctype.h>
#include <errno.h>
@@ -75,12 +76,15 @@ bool decodeSlot(const uint8_t bytes[RecordSize], BeaconSlot& slot) {
slot.power = int8_t(bytes[13]); slot.preset = bytes[14]; slot.hops = bytes[15];
slot.enabled = true;
memcpy(slot.text, bytes + 17, BeaconSlot::TextCapacity);
const size_t text_length = strlen(slot.text);
// Reject noncanonical trailing data instead of carrying hidden text bytes.
for (size_t i = strlen(slot.text) + 1; i < BeaconSlot::TextCapacity; ++i)
for (size_t i = text_length + 1; i < BeaconSlot::TextCapacity; ++i)
if (slot.text[i]) return false;
for (const unsigned char* c = reinterpret_cast<const unsigned char*>(slot.text); *c; ++c)
if (*c < 32 || *c == 127) return false;
return isfinite(slot.radio.freq) && isfinite(slot.radio.bw)
return validUtf8PrefixLength(slot.text, text_length) == text_length
&& RadioProfiles::valid(slot.radio)
&& RadioProfiles::safePreamble(slot.radio, slot.radio.preamble ? slot.radio.preamble : 32)
&& (slot.radio.protocol != RadioProfileProtocol::Meshtext
|| (slot.preset <= uint8_t(mt::Preset::LongSlow) && slot.radio.preamble == 16));
}
@@ -218,7 +222,9 @@ BeaconManager::ReadResult BeaconManager::readConfig(
}
crc = crcUpdate(crc, bytes, sizeof(bytes));
BeaconSlot slot;
if (!decodeSlot(bytes, slot) || (slot.enabled && (!mesh_ || !mesh_->validateTextBeacon(slot)))
// Disk validity is independent of the current board's radio capabilities.
// Preserve unsupported slots so an upgrade can still query/repair them.
if (!decodeSlot(bytes, slot)
|| (slot.enabled && slot.radio.protocol == RadioProfileProtocol::Meshtext && !header[6]))
valid = false;
if (output) output[i] = slot;
@@ -403,7 +409,9 @@ void BeaconManager::loop(uint32_t now) {
if (hour > 0 && handled_hour_ != hour) {
const unsigned index = unsigned((hour - 1) % SlotCount);
BeaconSlot& slot = slots_[index];
if (!slot.enabled) handled_hour_ = hour;
// A retained slot may be unsupported by this board or firmware. Skip it
// before allocating a durable Meshtext packet ID or entering the TX queue.
if (!slot.enabled || !mesh_->validateTextBeacon(slot)) handled_hour_ = hour;
else {
uint64_t guard = last_send_ && elapsed_ - last_send_ < HourMillis
? HourMillis - (elapsed_ - last_send_) : 0;
@@ -139,6 +139,12 @@ public:
}
protected:
#if MESH_ENABLE_TX_BEACONS
bool checkBeaconOutputPower(float frequency_mhz, int8_t dbm) const override {
(void)frequency_mhz;
return dbm >= _min_dbm && dbm <= _max_dbm;
}
#endif
// 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) {
+38 -1
View File
@@ -3,6 +3,9 @@
#include "RadioLibWrappers.h"
#include "RadioInterruptEvents.h"
#include "RadioProfileChip.h"
#if MESH_ENABLE_TX_BEACONS
#include "RadioPowerLimits.h"
#endif
#include <new>
#if defined(ESP32_PLATFORM)
#include <esp_heap_caps.h>
@@ -559,6 +562,39 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) {
}
#if MESH_ENABLE_TX_BEACONS
bool RadioLibWrapper::validateBeaconPower(float frequency_mhz, int8_t power) const {
return isfinite(frequency_mhz) && mesh::isLoRaTxPowerValid(power, frequency_mhz)
&& checkBeaconOutputPower(frequency_mhz, power);
}
bool RadioLibWrapper::checkBeaconOutputPower(float frequency_mhz, int8_t power) const {
(void)frequency_mhz;
#if MESH_RADIO_PROFILE_CHIP == 1276
// SX127x's setter automatically selects RFO below 2 dBm. Its two-argument
// checker assumes PA_BOOST, so use the same selection as setOutputPower().
return static_cast<SX1276*>(_radio)->checkOutputPower(power, nullptr, power < 2)
== RADIOLIB_ERR_NONE;
#elif MESH_RADIO_PROFILE_CHIP == 1272
return static_cast<SX1272*>(_radio)->checkOutputPower(power, nullptr, power < 2)
== RADIOLIB_ERR_NONE;
#elif MESH_RADIO_PROFILE_CHIP == 1121
// RadioLib's checker uses its current band. Validate the prospective band
// without changing the cached frequency or triggering a hardware retune.
if (frequency_mhz > 1000.0f) return power >= -18 && power <= 13;
#ifdef LR1121_FORCE_HP_PA
return power >= -9 && power <= 22;
#else
return power >= -17 && power <= 22;
#endif
#elif MESH_RADIO_PROFILE_CHIP == 2021
return mesh::lr2021::isHighBand(frequency_mhz)
? power >= -19 && power <= 12 : power >= -9 && power <= 22;
#else
// These RadioLib checkers inspect configuration only; no SPI is performed.
return _radio->checkOutputPower(power, nullptr) == RADIOLIB_ERR_NONE;
#endif
}
uint32_t RadioLibWrapper::getBeaconAirtime(const mesh::RadioProfileParams& p, int length) {
if (length <= 0 || length > MAX_TRANS_UNIT || !validateProfile(p)) return 0;
const uint16_t preamble = p.protocol == mesh::RadioProfileProtocol::Meshtext
@@ -596,7 +632,8 @@ mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(
_hil_beacon_tx_frequency_mhz = 0;
if (!mesh::hilTransmitFrequencyAllowed(p.freq)) return Result::FAILED;
#endif
if (!_params_valid || !_dbm_valid || !getBeaconAirtime(p, MAX_TRANS_UNIT)) return Result::FAILED;
if (!_params_valid || !_dbm_valid || !getBeaconAirtime(p, MAX_TRANS_UNIT)
|| !validateBeaconPower(p.freq, power)) return Result::FAILED;
if (isCalibratingNoiseFloor()) return Result::BUSY;
beginProfileRetune(isInRecvMode() && !_rx_ps_armed && !_rx_ps_enabled);
+6
View File
@@ -272,6 +272,11 @@ protected:
virtual int16_t applyCachedTxPower(int8_t dbm) {
return _radio->setOutputPower(dbm);
}
#if MESH_ENABLE_TX_BEACONS
// External amplifiers override this in antenna dBm. The public capability
// still checks the board's explicit limits before consulting this hook.
virtual bool checkBeaconOutputPower(float frequency_mhz, int8_t dbm) const;
#endif
virtual bool applyRxBoostedGainMode(bool) { return false; }
virtual int16_t enterCarrierWave() { return _radio->transmitDirect(); }
virtual bool validateMorseFrequency(float frequency_mhz) const {
@@ -409,6 +414,7 @@ public:
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile) override;
mesh::RadioParamApplyResult prepareTransmitProfile(uint8_t profile, bool reply_rx_override) override;
#if MESH_ENABLE_TX_BEACONS
bool validateBeaconPower(float frequency_mhz, int8_t power) const override;
mesh::RadioParamApplyResult prepareBeaconTransmit(const mesh::RadioProfileParams& params,
int8_t power) override;
mesh::RadioParamApplyResult restoreBeaconReceive() override;
+57
View File
@@ -57,6 +57,62 @@ void put32(uint8_t* bytes, uint32_t value) {
}
void fixCrc(std::vector<uint8_t>& bytes) { put32(bytes.data() + bytes.size() - 4, crc(bytes.data(), bytes.size() - 4)); }
void structuralStorage() {
Fixture mc; mc.mc(1, "Old");
Fixture mt; mt.mt();
for (unsigned fault = 0; fault < 15; ++fault) {
Fixture f;
f.fs.files = fault >= 12 ? mt.fs.files : mc.fs.files;
auto& bytes = f.fs.files.at("/beacons");
uint8_t* record = bytes.data() + 8;
switch (fault) {
case 0: record[10] = 255; break; // Invalid SF.
case 1: record[11] = 4; break; // Invalid coding rate.
case 2: {
const float bw = 123;
std::memcpy(record + 4, &bw, 4); break;
}
case 3: record[8] = 7; record[9] = 0; break;
case 4: { // Valid tuple, but its long preamble overflows safe airtime.
const float bw = 7.8f;
std::memcpy(record + 4, &bw, 4);
record[10] = 12; record[8] = 248; record[9] = 255; break;
}
case 5: put32(record, 0x7fc00000U); break; // NaN frequency.
case 6: record[17] = 255; break; // Malformed UTF-8.
case 7: record[17] = '\n'; break;
case 8: record[21] = 'X'; break; // Hidden bytes after the terminator.
case 9: record[16] = 0; break; // Disabled records must be all zero.
case 10: record[12] = uint8_t(mesh::RadioProfileProtocol::Ethernet); break;
case 11: record[15] = 8; break;
case 12: record[14] = 255; break; // Invalid MT preset.
case 13: record[8] = 32; record[9] = 0; break; // MT preamble is fixed.
case 14: bytes[6] = 0; break; // MT requires durable ID initialization.
}
fixCrc(bytes);
const auto original = f.fs.files;
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
f.cmd("get beacon", false);
f.cmd("set beacon.interval 12", false);
f.cmd("delete beacon all", false);
f.tick(48 * H);
assert(f.mesh.attempts == 0 && f.fs.files == original);
}
// Neither a future schema nor a read error may fall back to an older image.
for (bool unreadable : {false, true}) {
Fixture f;
f.fs.files = mc.fs.files;
f.fs.files["/beacons.bak"] = f.fs.files.at("/beacons");
if (unreadable) f.fs.fail_read_open = true;
else f.fs.files.at("/beacons")[4] = 2;
const auto original = f.fs.files;
f.manager.begin(&f.fs, &f.mesh, &f.clock, f.name);
f.cmd("get beacon", false);
f.tick(48 * H);
assert(f.mesh.attempts == 0 && f.fs.files == original);
}
}
void commands() {
Fixture f;
assert(!f.mesh.wake_enabled && f.fs.files.empty());
@@ -407,6 +463,7 @@ int main(int argc, char** argv) {
else if (!std::strcmp(argv[1], "schedule")) schedule();
else if (!std::strcmp(argv[1], "rolling")) rolling();
else if (!std::strcmp(argv[1], "storage")) storage();
else if (!std::strcmp(argv[1], "structural_storage")) structuralStorage();
else if (!std::strcmp(argv[1], "ids")) ids();
else if (!std::strcmp(argv[1], "mutations")) mutations();
else if (!std::strcmp(argv[1], "stale_backup")) staleBackup();
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <RadioProfiles.h>
#include <helpers/radiolib/RadioProfileChip.h>
#include <helpers/radiolib/RadioPowerLimits.h>
#include <algorithm>
#include <cassert>
#include <cstdint>
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1
#define RADIOLIB_MIN(a, b) std::min(int(a), int(b))
#define RADIOLIB_MAX(a, b) std::max(int(a), int(b))
#define RADIOLIB_CHECK_RANGE(value, low, high, error) \
if ((value) < (low) || (value) > (high)) return (error)
extern unsigned power_hardware_calls;
inline void dacWrite(uint8_t, uint8_t) { ++power_hardware_calls; }
namespace mesh {
struct MainBoard {};
struct Radio {
@RADIO_CAPABILITY@
};
}
struct PhysicalLayer {
bool highFreq = false;
float frequency = 909.5f;
virtual int16_t checkOutputPower(int8_t, int8_t*) = 0;
int16_t setOutputPower(int8_t, bool) { ++power_hardware_calls; return 0; }
};
struct SX1262 : PhysicalLayer {
int16_t checkOutputPower(int8_t, int8_t*) override;
};
struct SX1276 : PhysicalLayer {
int16_t checkOutputPower(int8_t, int8_t*) override;
int16_t checkOutputPower(int8_t, int8_t*, bool);
};
struct SX1272 : PhysicalLayer {
int16_t checkOutputPower(int8_t, int8_t*) override;
int16_t checkOutputPower(int8_t, int8_t*, bool);
};
struct LR1110 : PhysicalLayer {
int16_t checkOutputPower(int8_t, int8_t*) override;
int16_t checkOutputPower(int8_t, int8_t*, bool);
};
struct LR1121 : PhysicalLayer {
int16_t checkOutputPower(int8_t, int8_t*) override;
int16_t checkOutputPower(int8_t, int8_t*, bool);
};
struct LR2021 : PhysicalLayer {
int16_t checkOutputPower(int8_t, int8_t*) override;
};
struct Module {
bool hp = true, lp = true;
bool findRfSwitchMode(int mode) const { return mode == 1 ? hp : lp; }
};
struct STM32WLx : PhysicalLayer {
static constexpr int MODE_TX_HP = 1, MODE_TX_LP = 2;
Module module;
const Module* getMod() const { return &module; }
int16_t checkOutputPower(int8_t, int8_t*) override;
};
@DRIVER_CHECKS@
class RadioLibWrapper : public mesh::Radio {
protected:
PhysicalLayer* _radio;
virtual bool checkBeaconOutputPower(float, int8_t) const;
public:
RadioLibWrapper(PhysicalLayer& radio, mesh::MainBoard&) : _radio(&radio) {}
virtual void loop() { ++power_hardware_calls; }
virtual int16_t applyCachedTxPower(int8_t) { ++power_hardware_calls; return 0; }
bool setTxPower(int8_t power) { return applyCachedTxPower(power) == 0; }
bool validateBeaconPower(float, int8_t) const override;
bool validateProfile(const mesh::RadioProfileParams&) const;
bool supportsMeshtext() const { return true; }
bool supportsEthernetTunnel() const { return true; }
};
using CustomSX1276 = SX1276;
using CustomSX1262 = SX1262;
struct CustomSX1276Wrapper : RadioLibWrapper { using RadioLibWrapper::RadioLibWrapper; };
struct CustomSX1262Wrapper : RadioLibWrapper { using RadioLibWrapper::RadioLibWrapper; };
struct IndicatorRadioHal {
unsigned services = 0;
void serviceInterrupt() { ++services; ++power_hardware_calls; }
};
@INDICATOR_WRAPPER@
@DAC_LEVEL@
#define DAC_PA_DEFAULT_DRIVE_DBM 2
#define DAC_PA_TABLE_MAX INT8_MAX
@DAC_WRAPPER@
+251
View File
@@ -0,0 +1,251 @@
#include <Mesh.h>
#include <helpers/BeaconManager.h>
#include <helpers/BeaconFrames.h>
#include <helpers/UTF8Helpers.h>
#include <cstdio>
#include <limits>
unsigned power_hardware_calls = 0;
uint64_t beacon_test_time = 0;
@PRODUCTION_METHODS@
static bool boardAllows(int power, float frequency) {
// Explicit board caps are independent of the chip and calibrated PA range.
(void)power;
(void)frequency;
#ifdef MIN_LORA_TX_POWER
if (power < MIN_LORA_TX_POWER) return false;
#endif
#ifdef MAX_LORA_TX_POWER
if (power > MAX_LORA_TX_POWER) return false;
#endif
return true;
}
static bool driverAllows(int power, float frequency) {
(void)power;
#if defined(TEST_STM32)
(void)frequency;
#ifdef TEST_NO_PA
return false;
#elif defined(TEST_LP_ONLY)
return power >= -17 && power <= 14;
#elif defined(TEST_HP_ONLY)
return power >= -9 && power <= 22;
#else
return power >= -17 && power <= 22;
#endif
#elif MESH_RADIO_PROFILE_CHIP == 1276
(void)frequency;
return (power >= -4 && power <= 17) || power == 20;
#elif MESH_RADIO_PROFILE_CHIP == 1272
(void)frequency;
return power >= -1 && power <= 20;
#elif MESH_RADIO_PROFILE_CHIP == 1110
(void)frequency;
return power >= -17 && power <= 22;
#elif MESH_RADIO_PROFILE_CHIP == 1121
if (frequency > 1000) return power >= -18 && power <= 13;
#ifdef LR1121_FORCE_HP_PA
return power >= -9 && power <= 22;
#else
return power >= -17 && power <= 22;
#endif
#elif MESH_RADIO_PROFILE_CHIP == 2021
return frequency >= 1900 ? power >= -19 && power <= 12 : power >= -9 && power <= 22;
#else
(void)frequency;
return power >= -9 && power <= 22;
#endif
}
static void capability(RadioLibWrapper& wrapper, PhysicalLayer& chip) {
mesh::Radio unsupported;
assert(!unsupported.validateBeaconPower(909.5f, 17));
assert(!wrapper.validateBeaconPower(std::numeric_limits<float>::quiet_NaN(), 10));
for (bool normal_high_band : {false, true}) {
chip.highFreq = normal_high_band;
chip.frequency = normal_high_band ? 2440.0f : 909.5f;
for (float requested : {433.875f, 909.5f, 2440.0f}) {
for (int power = -128; power <= 127; ++power) {
const bool expected = boardAllows(power, requested) && driverAllows(power, requested);
assert(wrapper.validateBeaconPower(requested, int8_t(power)) == expected);
}
assert(chip.frequency == (normal_high_band ? 2440.0f : 909.5f));
assert(chip.highFreq == normal_high_band && power_hardware_calls == 0);
}
}
}
#if !defined(TEST_NO_PA)
static void image(const char* path, std::vector<uint8_t>& bytes, bool write) {
FILE* file = std::fopen(path, write ? "wb" : "rb");
assert(file);
if (write) assert(std::fwrite(bytes.data(), 1, bytes.size(), file) == bytes.size());
else {
bytes.clear();
for (int value; (value = std::fgetc(file)) != EOF;) bytes.push_back(uint8_t(value));
assert(!std::ferror(file));
}
assert(std::fclose(file) == 0);
}
static void upgrade(RadioLibWrapper& wrapper, bool legacy) {
MemoryFS fs;
if (!legacy) {
image("upgrade.beacons", fs.files["/beacons"], false);
image("upgrade.ids", fs.files["/beacon_ids"], false);
}
mesh::Mesh mesh;
mesh._radio = &wrapper;
mesh::RTCClock rtc;
mesh::BeaconManager manager;
manager.begin(&fs, &mesh, &rtc, "Upgrade test");
char reply[256];
const auto command = [&](const char* text) {
assert(manager.handle(text, reply, sizeof(reply)));
assert(std::strncmp(reply, "Err", 3));
};
const auto tick = [&](unsigned hours) {
g_mock_millis += hours * mesh::BeaconManager::HourMillis;
manager.loop(g_mock_millis);
};
if (legacy) {
// These images are produced by the real manager and old Mesh validator,
// not hand-crafted bytes that the old firmware might never have saved.
command("set beacon 1 meshcore 909.5,125,7,5,100 Old MC");
command("set beacon 2 meshcore 909.5,125,7,5,22 Sibling");
command("set beacon 3 meshtext US LongFast 100 Old MT");
tick(6);
assert(mesh.sends.size() == 1 && mesh.sends[0].id != 0);
image("upgrade.beacons", fs.files.at("/beacons"), true);
image("upgrade.ids", fs.files.at("/beacon_ids"), true);
return;
}
const auto original = fs.files;
assert(original.at("/beacons")[6] == 1); // Durable IDs were initialized.
command("get beacon");
command("get beacon 1"); assert(std::strstr(reply, "100") && std::strstr(reply, "Old MC"));
command("get beacon 3"); assert(std::strstr(reply, "100") && std::strstr(reply, "Old MT"));
tick(2); // Unsupported MC consumes no queue attempt or storage write.
assert(mesh.attempts == 0 && mesh.sends.empty() && fs.files == original);
tick(2); // A supported sibling still transmits.
assert(mesh.attempts == 1 && mesh.sends.size() == 1 && mesh.sends[0].slot.power == 22);
assert(fs.files == original);
tick(2); // Unsupported MT must be rejected before durable nextId().
assert(mesh.attempts == 1 && mesh.sends.size() == 1 && fs.files == original);
command("set beacon.interval 12");
command("set beacon 2 meshcore 909.5,125,7,5,20 Updated sibling");
const auto& before = original.at("/beacons");
const auto& after = fs.files.at("/beacons");
for (unsigned index : {0U, 2U})
assert(std::equal(before.begin() + 8 + index * 98, before.begin() + 8 + (index + 1) * 98,
after.begin() + 8 + index * 98));
assert(fs.files.at("/beacon_ids") == original.at("/beacon_ids"));
command("get beacon 3"); assert(std::strstr(reply, "100"));
command("set beacon 1 meshcore 909.5,125,7,5,20 Repaired");
command("delete beacon 3");
assert(fs.files.at("/beacon_ids") == original.at("/beacon_ids"));
manager.begin(&fs, &mesh, &rtc, "Upgrade test");
command("get beacon 1"); assert(std::strstr(reply, "Repaired"));
command("get beacon.interval"); assert(std::strstr(reply, "12"));
tick(1);
assert(mesh.sends.size() == 2 && mesh.sends.back().slot.power == 20);
tick(1);
assert(mesh.sends.size() == 3 && std::strstr(mesh.sends.back().slot.text, "Updated sibling"));
assert(fs.files.at("/beacon_ids") == original.at("/beacon_ids"));
assert(power_hardware_calls == 0);
}
static void manager(RadioLibWrapper& wrapper) {
MemoryFS fs;
mesh::Mesh mesh;
mesh._radio = &wrapper;
mesh::RTCClock rtc;
mesh::BeaconManager manager;
manager.begin(&fs, &mesh, &rtc, "Power test");
char reply[256], command[128];
const float frequency = MESH_RADIO_PROFILE_CHIP == 1268 ? 433.875f : 909.5f;
for (int bad : {-128, 100, 127}) {
std::snprintf(command, sizeof(command), "set beacon 1 meshcore %g,125,7,5,%d Hello", double(frequency), bad);
assert(manager.handle(command, reply, sizeof(reply)));
assert(!std::strncmp(reply, "Err", 3));
assert(fs.files.empty()); // Rejection happens before persistence.
}
int accepted = 22;
while (!driverAllows(accepted, frequency) || !boardAllows(accepted, frequency)) --accepted;
std::snprintf(command, sizeof(command), "set beacon 1 meshcore %g,125,7,5,%d Hello", double(frequency), accepted);
assert(manager.handle(command, reply, sizeof(reply)) && !std::strncmp(reply, "OK", 2));
assert(fs.files.count("/beacons") && power_hardware_calls == 0);
manager.begin(&fs, &mesh, &rtc, "Power test");
g_mock_millis += 2 * mesh::BeaconManager::HourMillis;
manager.loop(g_mock_millis);
assert(mesh.sends.size() == 1 && mesh.sends[0].slot.power == accepted);
assert(power_hardware_calls == 0);
}
#endif
#if MESH_RADIO_PROFILE_CHIP == 1276
static void externalAmplifier() {
mesh::MainBoard board;
CustomSX1276 chip;
const DacPaLevel levels[] = {{10, 30}, {17, 50}, {30, 130}, {33, 225}};
DacPaSX1276Wrapper amplifier(chip, board, 4, levels, 4, 30);
for (int power = -128; power <= 127; ++power)
assert(amplifier.validateBeaconPower(909.5f, int8_t(power))
== (boardAllows(power, 909.5f) && power >= 10 && power <= 30));
DacPaSX1276Wrapper empty(chip, board, 4, nullptr, 0);
assert(!empty.validateBeaconPower(909.5f, 17));
DacPaSX1276Wrapper impossible(chip, board, 4, levels, 4, 9);
assert(!impossible.validateBeaconPower(909.5f, 17));
assert(power_hardware_calls == 0);
}
#endif
int main(int argc, char** argv) {
mesh::MainBoard board;
#if defined(TEST_STM32)
STM32WLx chip;
#ifdef TEST_LP_ONLY
chip.module.hp = false;
#elif defined(TEST_HP_ONLY)
chip.module.lp = false;
#elif defined(TEST_NO_PA)
chip.module.hp = chip.module.lp = false;
#endif
#elif MESH_RADIO_PROFILE_CHIP == 1276
SX1276 chip;
#elif MESH_RADIO_PROFILE_CHIP == 1272
SX1272 chip;
#elif MESH_RADIO_PROFILE_CHIP == 1110
LR1110 chip;
#elif MESH_RADIO_PROFILE_CHIP == 1121
LR1121 chip;
#elif MESH_RADIO_PROFILE_CHIP == 2021
LR2021 chip;
#else
SX1262 chip;
#endif
RadioLibWrapper wrapper(chip, board);
#if !defined(TEST_NO_PA)
if (argc == 2 && !std::strcmp(argv[1], "manager")) { manager(wrapper); return 0; }
if (argc == 2 && !std::strcmp(argv[1], "legacy_write")) { upgrade(wrapper, true); return 0; }
if (argc == 2 && !std::strcmp(argv[1], "upgrade")) { upgrade(wrapper, false); return 0; }
#else
(void)argc; (void)argv;
#endif
capability(wrapper, chip);
#if !defined(TEST_NO_PA)
manager(wrapper);
#endif
#if MESH_RADIO_PROFILE_CHIP == 1276
externalAmplifier();
#endif
#if MESH_RADIO_PROFILE_CHIP == 1262 && !defined(TEST_STM32)
IndicatorRadioHal hal;
IndicatorSX1262Wrapper indicator(chip, board, hal);
assert(indicator.validateBeaconPower(909.5f, 10) == boardAllows(10, 909.5f));
assert(!indicator.validateBeaconPower(909.5f, 100));
assert(hal.services == 0 && power_hardware_calls == 0);
#endif
puts("PASS beacon power capability and persisted CLI validation");
}
+1
View File
@@ -42,6 +42,7 @@ public:
bool valid = true, meshtext = true;
RadioProfiles profiles;
bool validateProfile(const RadioProfileParams& p) const { return valid && RadioProfiles::valid(p); }
bool validateBeaconPower(float, int8_t power) const { return power >= -9 && power <= 22; }
bool supportsMeshtext() const { return meshtext; }
};
class TestClock {
+3
View File
@@ -57,6 +57,9 @@ class BeaconManagerTest(unittest.TestCase):
def test_every_config_short_write_and_recovery(self):
self.run_scenario("storage")
def test_structural_config_corruption_remains_held(self):
self.run_scenario("structural_storage")
def test_durable_meshtext_ids_fail_closed(self):
self.run_scenario("ids")
+167
View File
@@ -0,0 +1,167 @@
"""Run production beacon validation with real read-only RadioLib checkers.
Only the driver hardware boundaries and Mesh queue are replaced. No retune,
power write, DAC write, or Indicator HAL service is allowed during validation.
"""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from cpp_source import body
ROOT = Path(__file__).resolve().parents[1]
RADIO = ROOT / "src/helpers/radiolib"
class BeaconPowerTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if not cls.compiler:
raise RuntimeError("beacon power regression requires a C++17 compiler")
relative = "src/modules/SX126x/SX1262.cpp"
required = (relative, "src/modules/SX127x/SX1278.cpp", "src/modules/SX127x/SX1272.cpp",
"src/modules/LR11x0/LR1110.cpp", "src/modules/LR11x0/LR1120.cpp",
"src/modules/LR2021/LR2021_config.cpp", "src/modules/SX126x/STM32WLx.cpp")
override = os.environ.get("MESHCORE_RADIOLIB_DIR")
candidates = [Path(override)] if override else sorted((ROOT / ".pio/libdeps").glob("*/RadioLib"))
sdk = next((folder for folder in candidates if all((folder / name).is_file() for name in required)), None)
if sdk is None:
raise RuntimeError("an existing pinned RadioLib checkout is required (no download)")
methods = []
for filename, original, replacement, signatures in (
(relative, "SX1262", "SX1262", ("int16_t SX1262::checkOutputPower(",)),
("src/modules/SX127x/SX1278.cpp", "SX1278", "SX1276",
("int16_t SX1278::checkOutputPower(int8_t power, int8_t* clipped)",
"int16_t SX1278::checkOutputPower(int8_t power, int8_t* clipped, bool")),
("src/modules/SX127x/SX1272.cpp", "SX1272", "SX1272",
("int16_t SX1272::checkOutputPower(int8_t power, int8_t* clipped)",
"int16_t SX1272::checkOutputPower(int8_t power, int8_t* clipped, bool")),
("src/modules/LR11x0/LR1110.cpp", "LR1110", "LR1110",
("int16_t LR1110::checkOutputPower(int8_t power, int8_t* clipped)",
"int16_t LR1110::checkOutputPower(int8_t power, int8_t* clipped, bool")),
("src/modules/LR11x0/LR1120.cpp", "LR1120", "LR1121",
("int16_t LR1120::checkOutputPower(int8_t power, int8_t* clipped)",
"int16_t LR1120::checkOutputPower(int8_t power, int8_t* clipped, bool")),
("src/modules/LR2021/LR2021_config.cpp", "LR2021", "LR2021",
("int16_t LR2021::checkOutputPower(",)),
("src/modules/SX126x/STM32WLx.cpp", "STM32WLx", "STM32WLx",
("int16_t STM32WLx::checkOutputPower(",))):
text = (sdk / filename).read_text(encoding="utf-8")
methods += [body(text, signature).replace(original + "::", replacement + "::")
for signature in signatures]
header = (ROOT / "test/fixtures/beacons/power_radio.h").read_text(encoding="ascii")
header = header.replace("@DRIVER_CHECKS@", "")
header = header.replace("@RADIO_CAPABILITY@", body(
(ROOT / "src/Dispatcher.h").read_text(encoding="ascii"), "virtual bool validateBeaconPower("))
header = header.replace("@INDICATOR_WRAPPER@", body(
(ROOT / "variants/sensecap_indicator-espnow/IndicatorSX1262Wrapper.h").read_text(encoding="ascii"),
"class IndicatorSX1262Wrapper") + ";")
dac = (RADIO / "DacPaSX1276Wrapper.h").read_text(encoding="ascii")
header = header.replace("@DAC_LEVEL@", body(dac, "struct DacPaLevel") + ";")
header = header.replace("@DAC_WRAPPER@", body(dac, "class DacPaSX1276Wrapper") + ";")
mesh = (ROOT / "test/fixtures/beacons/mocks/Mesh.h").read_text(encoding="ascii")
validator = body(mesh, "bool validateTextBeacon(")
mesh = mesh.replace(validator, "bool validateTextBeacon(const BeaconSlot&) const;")
mesh = mesh.replace("#pragma once", '#pragma once\n#include "PowerRadio.h"')
mesh = mesh.replace("class Mesh {\n public:", "class Mesh {\n public:\n ::RadioLibWrapper* _radio = nullptr;")
cls.shared = (RADIO / "RadioLibWrappers.cpp").read_text(encoding="utf-8")
cls.mesh_source = (ROOT / "src/MeshBeacon.cpp").read_text(encoding="ascii")
production = "\n".join(methods) + "\n" + "\n".join(body(cls.shared, signature) for signature in (
"bool RadioLibWrapper::validateBeaconPower(", "bool RadioLibWrapper::checkBeaconOutputPower(",
"bool RadioLibWrapper::validateProfile("))
production += "\nnamespace mesh {\n" + body(cls.mesh_source, "bool Mesh::validateTextBeacon(") + "\n}"
production += "\nnamespace mesh { namespace beacon {\n" + body(
(ROOT / "src/helpers/BeaconFrames.cpp").read_text(encoding="ascii"), "bool validText(") + "\n}}"
cls.fixture = (ROOT / "test/fixtures/beacons/power_test.cpp").read_text(encoding="ascii")
cls.production = production
cls.work = tempfile.TemporaryDirectory(prefix="meshcore-beacon-power-")
cls.addClassCleanup(cls.work.cleanup)
cls.directory = Path(cls.work.name)
(cls.directory / "PowerRadio.h").write_text(header, encoding="ascii")
(cls.directory / "Mesh.h").write_text(mesh, encoding="ascii")
def compile_run(self, name, flags, production=None, scenario=None, negative=False, manager_source=None):
cpp, binary = self.directory / (name + ".cpp"), self.directory / name
cpp.write_text(self.fixture.replace("@PRODUCTION_METHODS@", production or self.production), encoding="ascii")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"]
if sys.platform.startswith("linux") else [])
built = subprocess.run([
self.compiler, "-std=c++17", "-O1", "-g", "-Wall", "-Wextra", "-Werror",
"-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MESHTEXT_CHAT=0",
"-DMESH_ENABLE_MESHTEXT_WIRE=1", "-DLORA_TX_POWER=2", *flags, *sanitizers,
"-I" + str(self.directory), "-I" + str(ROOT / "test/fixtures/beacons/mocks"),
"-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"), "-isystem", str(ROOT / "test/mocks"),
"-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"),
str(manager_source or ROOT / "src/helpers/BeaconManager.cpp"),
str(ROOT / "src/helpers/MeshtextChat.cpp"), str(cpp), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
result = subprocess.run([str(binary), *([scenario] if scenario else [])],
capture_output=True, text=True, cwd=self.directory, timeout=15)
if negative:
self.assertNotEqual(result.returncode, 0, "old power validation unexpectedly passed")
self.assertIn("Assertion", result.stderr)
else:
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_chip_ranges_prospective_bands_board_caps_and_external_pa(self):
for name, flags in (
("sx1262", ["-DRADIO_CLASS=CustomSX1262"]),
("sx1268", ["-DRADIO_CLASS=CustomSX1268"]),
("llcc68", ["-DRADIO_CLASS=CustomLLCC68"]),
("sx1276", ["-DRADIO_CLASS=CustomSX1276"]),
("sx1272", ["-DUSE_SX1272=1"]),
("lr1110", ["-DRADIO_CLASS=CustomLR1110"]),
("lr1121", ["-DRADIO_CLASS=CustomLR1121"]),
("lr1121_hp", ["-DRADIO_CLASS=CustomLR1121", "-DLR1121_FORCE_HP_PA=1"]),
("lr2021", ["-DRADIO_CLASS=CustomLR2021", "-DUSE_LR2021=1"]),
("stm32_dual", ["-DRADIO_CLASS=CustomSTM32WLx", "-DTEST_STM32=1"]),
("stm32_lp", ["-DRADIO_CLASS=CustomSTM32WLx", "-DTEST_STM32=1", "-DTEST_LP_ONLY=1"]),
("stm32_hp", ["-DRADIO_CLASS=CustomSTM32WLx", "-DTEST_STM32=1", "-DTEST_HP_ONLY=1"]),
("stm32_no_pa", ["-DRADIO_CLASS=CustomSTM32WLx", "-DTEST_STM32=1", "-DTEST_NO_PA=1"]),
("board_cap", ["-DRADIO_CLASS=CustomSX1262", "-DMIN_LORA_TX_POWER=0", "-DMAX_LORA_TX_POWER=13"]),
("dac_cap", ["-DRADIO_CLASS=CustomSX1276", "-DMIN_LORA_TX_POWER=17", "-DMAX_LORA_TX_POWER=30"])):
with self.subTest(chip=name):
self.compile_run(name, flags)
def test_previous_mesh_validator_accepts_impossible_power_before_persistence(self):
guard = "!_radio->validateBeaconPower(slot.radio.freq, slot.power)"
self.assertEqual(self.production.count(guard), 1)
old = self.production.replace(guard,
"(slot.power < -9 || !isLoRaTxPowerValid(slot.power, slot.radio.freq))")
self.compile_run("old_mesh", ["-DRADIO_CLASS=CustomSX1262"], old, "manager", negative=True)
def test_legacy_power_image_remains_queryable_and_repairable(self):
guard = "!_radio->validateBeaconPower(slot.radio.freq, slot.power)"
self.assertEqual(self.production.count(guard), 1)
legacy = self.production.replace(guard,
"(slot.power < -9 || !isLoRaTxPowerValid(slot.power, slot.radio.freq))")
flags = ["-DRADIO_CLASS=CustomSX1262"]
self.compile_run("legacy_image", flags, legacy, "legacy_write")
self.compile_run("upgrade", flags, scenario="upgrade")
# Restoring the former disk-time capability check bricks every beacon
# command on precisely the same actual legacy image.
source = (ROOT / "src/helpers/BeaconManager.cpp").read_text(encoding="ascii")
marker = "if (!decodeSlot(bytes, slot)"
self.assertEqual(source.count(marker), 1)
held = self.directory / "held_manager.cpp"
held.write_text(source.replace(marker,
marker + " || (slot.enabled && !mesh_->validateTextBeacon(slot))"), encoding="ascii")
self.compile_run("held_upgrade", flags, scenario="upgrade", negative=True, manager_source=held)
def test_current_band_driver_check_is_not_a_prospective_band_check(self):
checker = body(self.shared, "bool RadioLibWrapper::checkBeaconOutputPower(")
old = self.production.replace(checker, """bool RadioLibWrapper::checkBeaconOutputPower(
float, int8_t power) const {
return _radio->checkOutputPower(power, nullptr) == RADIOLIB_ERR_NONE;
}""")
self.compile_run("old_band", ["-DRADIO_CLASS=CustomLR1121"], old, negative=True)
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -25,6 +25,8 @@ HARNESS = r'''
#include <cstdio>
#include <initializer_list>
#include <RadioProfiles.h>
#include <helpers/radiolib/RadioProfileChip.h>
#include <helpers/radiolib/RadioPowerLimits.h>
#define MESH_ENABLE_TX_BEACONS 1
#define MESH_ENABLE_MESHTEXT_WIRE 1
#define STATE_IDLE 0
@@ -60,6 +62,7 @@ struct Modem {
int finishTransmit() { ++finishes; return 0; }
int startTransmit(uint8_t*, int) { return tx_result; }
int setPreambleLength(uint16_t value) { preamble = value; return 0; }
int checkOutputPower(int8_t value, int8_t*) { return value >= -9 && value <= 22 ? 0 : -1; }
};
struct RadioLibWrapper {
Modem modem;
@@ -162,6 +165,8 @@ struct RadioLibWrapper {
bool startSendRaw(const uint8_t*, int);
void onSendFinished();
uint32_t getBeaconAirtime(const mesh::RadioProfileParams&, int);
bool validateBeaconPower(float, int8_t) const;
virtual bool checkBeaconOutputPower(float, int8_t) const;
void cacheBeaconRestoreParams();
Result prepareBeaconTransmit(const mesh::RadioProfileParams&, int8_t);
Result restoreBeaconReceive();
@@ -311,6 +316,13 @@ int main() {
assert(w.prepareBeaconTransmit(p, 10) == Result::FAILED && !w._beacon_owned);
++cases;
}
for (int power : {-10, 23, 100, 127}) {
RadioLibWrapper w;
assert(w.prepareBeaconTransmit(beacon(), int8_t(power)) == Result::FAILED);
assert(!w._beacon_owned && !w.applications && !w.power_sets);
assert(!w.modem.standbys && !w.retune_begins && !w.stops);
++cases;
}
std::printf("PASS: %u actual-method beacon radio cases\n", cases);
}
'''
@@ -327,6 +339,8 @@ class BeaconRadioTest(unittest.TestCase):
"bool RadioLibWrapper::startSendRaw(const uint8_t* bytes, int len)",
"void RadioLibWrapper::onSendFinished()",
"uint32_t RadioLibWrapper::getBeaconAirtime(",
"bool RadioLibWrapper::validateBeaconPower(",
"bool RadioLibWrapper::checkBeaconOutputPower(",
"void RadioLibWrapper::cacheBeaconRestoreParams()",
"mesh::RadioParamApplyResult RadioLibWrapper::prepareBeaconTransmit(",
"bool RadioLibWrapper::isBeaconChannelBusy()",