Restore SX1276 CW support and calibrated power sweeps

Retain the original carrier feature from PR #11 with zero-deviation FSK, board PA calibration, and LoRa restoration. Re-key after power changes without extending the timeout, and restore prior power after a failed sweep. Cover native and SX1276 transitions, failure recovery, external PA and RFO mapping; verify Linkflow repeater and Heltec V2 USB Companion builds.
This commit is contained in:
mikecarper committed 2026-09-14 19:39:33 -07:00
1 parent 9de1373bd9
commit 27e4a0d0db
6 files changed
+226 -14

No files matched your search

+7 -1
View File
@@ -2,7 +2,7 @@
`cw` and `cw2` transmit a continuous, unmodulated carrier at the selected
profile's frequency and the configured TX power. They are available in the
shared ASCII CLI on SX1262-family and LR1110 builds, including repeaters,
shared ASCII CLI on SX1276-family, SX1262-family, and LR1110 builds, including repeaters,
Companions, room servers, and sensors that use that CLI.
| Command | Action |
@@ -32,6 +32,12 @@ or a busy radio. Profile retuning waits until the carrier stops; replacement
or expiry of the active second profile stops its carrier. Nothing about CW
is saved across reboot.
The normal `set tx <dBm>` command also works during CW for meter sweeps. It
keeps the board's power limits and PA calibration, resumes the carrier after
changing power, and does not extend the CW timeout. TX power retains its usual
saved behavior. SX1276 temporarily uses FSK with zero frequency deviation,
then rebuilds the LoRa configuration before resuming reception.
Use the local USB or BLE console for immediate replies and manual stopping.
An on-air CLI reply must wait until the carrier ends because LoRa packets
cannot transmit during the test.
+24
View File
@@ -46,6 +46,30 @@ no outbound packet pending. The observer has no autonomous transmission.
`src/Mesh.cpp` and test-file contents; the actual implementation uses
`ota_context_if_active()`. This is separate from the CW changes.
## SX1276 follow-up
The SX1276 carrier feature from [PR #11](https://github.com/mikecarper/MeshCore/pull/11)
is included alongside SX1262 and LR1110 support. It switches to FSK with zero
deviation, applies the board's calibrated TX power, and rebuilds the runtime
LoRa configuration when stopped. External-PA settings pass through the same
DAC/drive-power mapping used for packets, including RFO selection.
The normal TX power command can adjust a running carrier for meter sweeps.
RadioLib's SX1276 power setter enters standby, so the carrier is restarted
afterward without extending the deadline. Failed power changes stop the
carrier and restore the previous cached power.
Follow-up checks use base `9dd9881d520b4b791600a00fff1f8e1436921054` plus the
SX1276 changes. Production-method regression tests cover both profiles,
zero deviation, external-PA calibration, RFO, power sweeps, timeout, failed
FSK entry, failed restoration, retry guards, and return to LoRa. The native
CW paths and existing radio/profile/sleep/PA checks pass as well.
Both `GEPRC_Linkflow_900_repeater` (external PA) and
`Heltec_v2_companion_radio_usb` firmware builds pass, including size checks.
SX1276 RF output has not been measured in this follow-up. The physical RF
results above apply to SX1262 and LR1110.
## Bench qualifications
The first CW2 request arrived before radio2's existing delayed activation.
+28 -1
View File
@@ -10,8 +10,12 @@
class CustomSX1276Wrapper : public RadioLibWrapper {
public:
CustomSX1276Wrapper(CustomSX1276& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { }
bool supportsCarrierWave() const override { return supportsRadioDeepInit(); }
void powerOff() { _radio->standby(); _radio->sleep(); }
void powerOff() {
if (isCarrierWaveActive()) setCarrierWave(0, 0);
_radio->standby(); _radio->sleep();
}
protected:
bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override {
@@ -24,6 +28,7 @@ protected:
public:
bool setCodingRate(uint8_t cr) override {
if (isCarrierWaveActive()) return false;
return ((CustomSX1276 *)_radio)->setCodingRate(cr) == RADIOLIB_ERR_NONE;
}
@@ -40,6 +45,28 @@ public:
uint8_t getSpreadingFactor() const override { return ((CustomSX1276 *)_radio)->spreadingFactor; }
protected:
int16_t enterCarrierWave() override {
auto* radio = static_cast<CustomSX1276*>(_radio);
// Direct TX needs FSK. Zero deviation prevents a floating DIO2 data input
// from moving the carrier. Start at the PA_BOOST floor, while still idle;
// user-facing power may be an external PA level, not chip drive power.
int16_t status = radio->beginFSK(_cur_freq, 4.8, 0.0, 125.0, 2, 16, false);
if (status != RADIOLIB_ERR_NONE) return status;
#ifdef SX127X_CURRENT_LIMIT
status = radio->setCurrentLimit(SX127X_CURRENT_LIMIT);
if (status != RADIOLIB_ERR_NONE) return status;
#endif
status = applyCachedTxPower(_dbm_valid ? _cur_dbm : LORA_TX_POWER);
if (status != RADIOLIB_ERR_NONE) return status;
return radio->transmitDirect();
}
bool restoreCarrierWaveModem() override {
// Restores LoRa, runtime profile/preamble, IRQ mapping and PA calibration.
// Shared cleanup retains the stop/retry guard if any restoration fails.
return restoreAfterDeepInit();
}
int16_t performChannelScan() override {
return ((CustomSX1276 *)_radio)->tryScanChannel(cadScanTimeoutMillis(), *_board);
}
+23 -4
View File
@@ -89,6 +89,23 @@ uint32_t RadioLibWrapper::getRngSeed() {
}
bool RadioLibWrapper::setTxPower(int8_t dbm) {
if (_cw_active) {
if (_cw_stopping) return false;
// Permit meter sweeps through the board's normal power/PA calibration.
// SX127x's power setter enters standby, so re-key without resetting the
// modem or extending the original CW deadline.
if (applyCachedTxPower(dbm) != RADIOLIB_ERR_NONE) {
stopCarrierWave();
return false;
}
if (_radio->transmitDirect() != RADIOLIB_ERR_NONE) {
stopCarrierWave();
return false;
}
_cur_dbm = dbm;
_dbm_valid = true;
return true;
}
const uint8_t resume_rx = beginReconfigure();
if (resume_rx > 1) return false;
@@ -707,7 +724,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin
_board->setRadioTestActive(true); // service the deadline even without USB
_board->onBeforeTransmit();
_cw_board_tx = true;
if (_radio->transmitDirect() != RADIOLIB_ERR_NONE) {
if (enterCarrierWave() != RADIOLIB_ERR_NONE) {
stopCarrierWave(); // a failed SPI result does not prove TX stayed off
return Result::FAILED;
}
@@ -717,11 +734,13 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin
bool RadioLibWrapper::stopCarrierWave() {
_cw_stopping = true;
// finishTransmit clears radio IRQs and enters standby on both supported
// families. Deassert external TX even if SPI fails, then try a radio reset.
// Deassert external TX even if SPI fails. SX127x must also rebuild its LoRa
// modem after FSK direct mode before normal packet work can resume.
const int16_t status = _radio->finishTransmit();
if (_cw_board_tx) { _board->onAfterTransmit(); _cw_board_tx = false; }
if (status != RADIOLIB_ERR_NONE && !restoreAfterDeepInit()) {
const bool restored = status == RADIOLIB_ERR_NONE
? restoreCarrierWaveModem() : restoreAfterDeepInit();
if (!restored) {
_cw_retry_at = static_cast<uint32_t>(millis()) + 100;
return false; // hold normal work until stopping the carrier is confirmed
}
+6
View File
@@ -157,6 +157,12 @@ protected:
return _radio->setOutputPower(dbm);
}
virtual bool applyRxBoostedGainMode(bool) { return false; }
virtual int16_t enterCarrierWave() { return _radio->transmitDirect(); }
// Native-CW radios keep their modem. Reapply cached power in case a meter
// sweep failed halfway through; SX127x also rebuilds its LoRa configuration.
virtual bool restoreCarrierWaveModem() {
return !_dbm_valid || applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE;
}
// Dual-profile scanning already suspends RXPS. Supported radios can also
// retain their oscillator across the intervening standby/reconfigure steps.
// Called only at the safe scan-mode entry/exit boundary, not during a packet.
+138 -8
View File
@@ -18,6 +18,9 @@ HARNESS = r'''
#define STATE_TX_WAIT 3
#define STATE_INT_READY 16
#define RADIOLIB_ERR_NONE 0
#define RADIOLIB_ERR_INVALID_OUTPUT_POWER -1
#define SX127X_CURRENT_LIMIT 120
#define LORA_TX_POWER 17
#define MESH_DEBUG_PRINTLN(...) ((void)0)
static uint8_t state;
static void setFlag() { state |= STATE_INT_READY; }
@@ -28,17 +31,30 @@ struct Board {
void onAfterTransmit() { ++after; }
};
struct Chip {
int start_result=0, stop_result=0;
unsigned starts=0, stops=0, standby_calls=0;
bool carrier=false;
int start_result=0, stop_result=0, fsk_result=0, power_result=0, limit_result=0;
int power=17, initial_fsk_power=0, current_limit=60;
unsigned starts=0, stops=0, standby_calls=0, fsk_starts=0;
float frequency=0, deviation=5;
bool carrier=false, fsk=false, ook=true, rfo=false;
void (*callback)()=setFlag;
int standby() { ++standby_calls; return 0; }
int standby() { ++standby_calls; carrier=false; return 0; }
int beginFSK(float freq, float, float dev, float, int8_t dbm, uint16_t, bool enable_ook) {
++fsk_starts; fsk=true; carrier=false; frequency=freq; deviation=dev;
initial_fsk_power=power=dbm; ook=enable_ook; current_limit=60;
return fsk_result;
}
int setCurrentLimit(int value) { current_limit=value; return limit_result; }
int setOutputPower(int8_t value, bool force_rfo=false) {
standby(); if (power_result) return power_result;
power=value; rfo=force_rfo; return 0;
}
int clearIrqFlags(uint32_t) { return 0; }
void clearPacketReceivedAction() { callback=nullptr; }
void setPacketReceivedAction(void (*fn)()) { callback=fn; }
int transmitDirect() { carrier=true; ++starts; return start_result; }
int finishTransmit() { ++stops; if (!stop_result) carrier=false; return stop_result; }
};
using CustomSX1276=Chip;
struct RadioLibWrapper : mesh::Radio {
Chip chip; Chip* _radio=&chip;
Board board; Board* _board=&board;
@@ -49,11 +65,14 @@ struct RadioLibWrapper : mesh::Radio {
bool _params_valid=true, _rx_ps_armed=true, _rx_hold_continuous=false;
bool _nf_calib_active=true, _profile_refresh_required=false;
unsigned long _wd_observe_until=0;
bool packet=false, busy=false, reset_ok=false, supported=true;
bool packet=false, busy=false, reset_ok=false, supported=true, deep_supported=true;
unsigned rtc_stops=0, tunes=0, rx_starts=0, calibrations=0, resets=0;
unsigned fail_rx_starts=0;
bool fail_tune=false;
float frequency=909.5;
float _cur_freq=909.5;
int8_t _cur_dbm=17;
bool _dbm_valid=true;
RadioLibWrapper() {
g_mock_millis=100; state=STATE_RX;
_profiles.primary.freq=909.5;
@@ -68,7 +87,11 @@ struct RadioLibWrapper : mesh::Radio {
mesh::RadioParamApplyResult setCarrierWave(uint8_t,uint32_t) override;
bool stopCarrierWave();
bool serviceCarrierWave();
bool setTxPower(int8_t dbm);
@NATIVE_HOOKS@
virtual bool supportsRadioDeepInit() const { return deep_supported; }
uint8_t beginReconfigure();
void endReconfigure(bool resume) { if (resume) startRecv(); }
bool isChipBusy() { return busy; }
bool isReceivingPacket() { return packet; }
bool isInRecvMode() const override { return (state & ~STATE_INT_READY)==STATE_RX; }
@@ -76,7 +99,7 @@ struct RadioLibWrapper : mesh::Radio {
mesh::RadioParamApplyResult tuneProfile(uint8_t target) {
++tunes;
if (fail_tune) return mesh::RadioParamApplyResult::FAILED;
_active_profile=target; frequency=_profiles.params(target).freq;
_active_profile=target; frequency=_cur_freq=_profiles.params(target).freq;
return mesh::RadioParamApplyResult::APPLIED;
}
void startRecv() override {
@@ -86,7 +109,10 @@ struct RadioLibWrapper : mesh::Radio {
}
bool restoreAfterDeepInit() {
++resets;
if (reset_ok) { chip.carrier=false; chip.stop_result=0; }
if (reset_ok) {
chip.carrier=false; chip.fsk=false; chip.stop_result=0;
return applyCachedTxPower(_cur_dbm)==0;
}
return reset_ok;
}
void recalibrateNoiseFloor() override { ++calibrations; }
@@ -98,6 +124,21 @@ struct RadioLibWrapper : mesh::Radio {
void onSendFinished() override {}
};
@METHODS@
struct CustomSX1276Wrapper : RadioLibWrapper {
CustomSX1276Wrapper() { reset_ok=true; }
@SX1276_METHODS@
};
struct DacPaLevel { int8_t dbm; uint8_t dac; };
struct DacCarrier : CustomSX1276Wrapper {
int8_t _min_dbm=17, _max_dbm=30, _drive_dbm=2;
DacPaLevel _levels[3]={{17,0},{24,50},{30,130}};
uint8_t _num_levels=3;
const int8_t* _radio_dbm=nullptr;
bool _force_rfo=false;
int gain=-1;
void writeGainControl(uint8_t value) { gain=value; }
@DAC_METHODS@
};
static void advance(uint32_t ms) { g_mock_millis+=ms; }
int main() {
using Result=mesh::RadioParamApplyResult;
@@ -114,6 +155,12 @@ int main() {
assert(w.frequency==909.5f && w.board.after==1 && !w.board.awake && w.chip.callback);
assert(!w.serviceCarrierWave());
}
{
RadioLibWrapper w; w.setCarrierWave(0,1250); advance(250);
assert(w.setTxPower(10) && w.chip.carrier && w.chip.power==10);
assert(w.carrierWaveRemainingMillis()==1000);
assert(w.setCarrierWave(0,0)==Result::APPLIED);
}
// No mutation of packets already transmitting, completed RX, busy or receiving hardware.
for (int reason : {0,1,2,3}) {
RadioLibWrapper w;
@@ -198,7 +245,78 @@ int main() {
char small[8]; mesh::handleCarrierWaveCommand(&w,"cw on",small,sizeof(small));
assert(small[7]==0);
}
puts("CW: transitions, timer rollover, profiles, RF hooks, failure recovery and CLI passed");
// Execute SX1276's production FSK entry/LoRa restoration, including the
// production external-PA power mapping. Native CW paths above stay covered.
for (uint8_t profile : {0,1}) {
CustomSX1276Wrapper w;
w._cur_dbm=14;
assert(w.supportsCarrierWave() && w.setCarrierWave(profile,1250)==Result::APPLIED);
assert(w.chip.fsk && w.chip.deviation==0 && !w.chip.ook && w.chip.carrier);
assert(w.chip.frequency==(profile ? 911.5f : 909.5f));
assert(w.chip.initial_fsk_power==2 && w.chip.power==14 && w.chip.current_limit==120);
advance(250);
assert(w.setTxPower(10) && w.chip.carrier && w.chip.power==10);
assert(w.chip.fsk_starts==1 && w.carrierWaveRemainingMillis()==1000);
advance(1000); w.serviceCarrierWave();
assert(!w._cw_active && !w.chip.fsk && w.isInRecvMode() && w.frequency==909.5f);
assert(w.resets==1 && w.chip.power==10 && !w.board.awake && w.board.after==1);
}
{
DacCarrier w; w._cur_dbm=30;
assert(w.setCarrierWave(1,1000)==Result::APPLIED);
assert(w.chip.initial_fsk_power==2 && w.chip.power==2 && w.gain==130 && w.chip.carrier);
advance(200);
assert(w.setTxPower(24) && w.chip.power==2 && w.gain==50 && w.chip.carrier);
assert(w.carrierWaveRemainingMillis()==800 && w.chip.fsk_starts==1);
assert(w.setCarrierWave(0,0)==Result::APPLIED);
assert(w.chip.power==2 && w.gain==50 && !w.chip.fsk && w.resets==1);
}
{
DacCarrier w; w._force_rfo=true; w._drive_dbm=-4;
assert(w.setCarrierWave(0,100)==Result::APPLIED);
assert(w.chip.rfo && w.chip.power==-4);
assert(w.setCarrierWave(0,0)==Result::APPLIED && w.chip.rfo && w.chip.power==-4);
}
{
CustomSX1276Wrapper w; w.deep_supported=false;
assert(!w.supportsCarrierWave() && w.setCarrierWave(0,100)==Result::FAILED);
assert(!w.chip.fsk_starts);
}
// Partial FSK entry and current-limit failures restore LoRa without keying.
for (int reason : {0,1,2}) {
CustomSX1276Wrapper w;
if (reason==0) w.chip.fsk_result=-707;
if (reason==1) w.chip.limit_result=-705;
if (reason==2) w.chip.power_result=-705;
assert(w.setCarrierWave(0,100)==Result::FAILED && !w.chip.starts && !w.chip.carrier);
w.chip.power_result=0;
if (w._cw_active) { advance(100); w.serviceCarrierWave(); }
assert(!w._cw_active && !w.chip.fsk && w.isInRecvMode() && !w.board.awake);
}
{
CustomSX1276Wrapper w; w.setCarrierWave(1,1000); w.reset_ok=false;
assert(w.setCarrierWave(0,0)==Result::FAILED);
assert(w._cw_active && w._cw_stopping && !w.chip.carrier && w.chip.fsk);
assert(!w.setTxPower(10)); // recovery cannot accidentally re-key the PA
w.reset_ok=true; advance(100); w.serviceCarrierWave();
assert(!w._cw_active && !w.chip.fsk && w.isInRecvMode() && w.board.after==1);
}
for (int reason : {0,1}) {
CustomSX1276Wrapper w; w.setCarrierWave(0,1000);
if (reason==0) w.chip.power_result=-705;
if (reason==1) w.chip.start_result=-705;
assert(!w.setTxPower(10) && !w.chip.carrier);
w.chip.power_result=0; advance(100); w.serviceCarrierWave();
assert(!w._cw_active && !w.chip.fsk && w.isInRecvMode());
assert(w._cur_dbm==17 && w.chip.power==17); // failed sweep retains old power
}
{
RadioLibWrapper w; w.setCarrierWave(0,1000);
w.chip.start_result=-705;
assert(!w.setTxPower(10) && !w.chip.carrier && w.isInRecvMode());
assert(w._cur_dbm==17 && w.chip.power==17);
}
puts("CW: native/SX1276 transitions, fractional CLI, PA sweeps, restoration and fault recovery passed");
}
'''
@@ -207,11 +325,23 @@ class CarrierWaveTest(unittest.TestCase):
def test_production_transitions_and_commands(self):
source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text()
signatures = ('uint8_t RadioLibWrapper::beginReconfigure(',
'bool RadioLibWrapper::setTxPower(',
'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(',
'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(',
'bool RadioLibWrapper::stopCarrierWave(',
'bool RadioLibWrapper::serviceCarrierWave(')
code = HARNESS.replace('@METHODS@', '\n'.join(method(source, s) for s in signatures))
header = (ROOT/'src/helpers/radiolib/RadioLibWrappers.h').read_text()
code = code.replace('@NATIVE_HOOKS@', '\n'.join(method(header, s) for s in (
'virtual int16_t enterCarrierWave(', 'virtual bool restoreCarrierWaveModem(',
'virtual int16_t applyCachedTxPower(')))
sx1276 = (ROOT/'src/helpers/radiolib/CustomSX1276Wrapper.h').read_text()
code = code.replace('@SX1276_METHODS@', '\n'.join(method(sx1276, s) for s in (
'bool supportsCarrierWave(', 'int16_t enterCarrierWave(',
'bool restoreCarrierWaveModem(')))
dac = (ROOT/'src/helpers/radiolib/DacPaSX1276Wrapper.h').read_text()
code = code.replace('@DAC_METHODS@', '\n'.join(method(dac, s) for s in (
'int16_t applyCachedTxPower(', 'uint8_t indexForDbm(')))
with tempfile.TemporaryDirectory() as folder:
cpp = Path(folder)/'cw.cpp'; exe = Path(folder)/'cw.exe'
cpp.write_text(code)