From 27e4a0d0db3336da7ef37ad5eedecfd2cd42c3d5 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 14 Sep 2026 19:39:33 -0700 Subject: [PATCH] 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. --- docs/carrier_wave.md | 8 +- docs/carrier_wave_validation.md | 24 ++++ src/helpers/radiolib/CustomSX1276Wrapper.h | 29 +++- src/helpers/radiolib/RadioLibWrappers.cpp | 27 +++- src/helpers/radiolib/RadioLibWrappers.h | 6 + test/test_carrier_wave.py | 146 +++++++++++++++++++-- 6 files changed, 226 insertions(+), 14 deletions(-) diff --git a/docs/carrier_wave.md b/docs/carrier_wave.md index f4bd25a62..e01dd594a 100644 --- a/docs/carrier_wave.md +++ b/docs/carrier_wave.md @@ -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 ` 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. diff --git a/docs/carrier_wave_validation.md b/docs/carrier_wave_validation.md index 4eb2b7bed..435d6c41f 100644 --- a/docs/carrier_wave_validation.md +++ b/docs/carrier_wave_validation.md @@ -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. diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 7ab0ee2a3..0f34fd6cd 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -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(_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); } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 816f4602e..e0bd9ac36 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -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(millis()) + 100; return false; // hold normal work until stopping the carrier is confirmed } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 5e705a465..ebd020250 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -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. diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py index f7bf96d1e..3aefa4437 100644 --- a/test/test_carrier_wave.py +++ b/test/test_carrier_wave.py @@ -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)