fix LNA/FEM control

This commit is contained in:
liquidraver
2026-08-25 14:04:32 +02:00
parent 8c0464ea30
commit ea86815c95
12 changed files with 459 additions and 141 deletions
+2 -2
View File
@@ -260,7 +260,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot.
| `get path.hash.mode` | Path hashing algorithm: `0`, `1`, or `2` |
| `get loop.detect` | Loop detection level: `off`, `minimal`, `moderate`, or `strict` |
| `get radio.rxgain` | RX gain boost: `on` or `off` |
| `get radio.fem.rxgain` | External FEM/LNA active during RX: `on` or `off` (default `on`) |
| `get radio.fem.rxgain` | External FEM's LNA in the RX path: `on` (through the LNA) or `off` (bypassed). Default `on` |
| `get rxduty` | RX duty cycle mode: `0` or `1` |
| `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) |
| `get gps diag` | What the last GPS module-configuration attempt did — which path ran, bytes sent, and tracked satellites per constellation. See **GPS configuration diagnostics** in the GPS section for the field reference |
@@ -312,7 +312,7 @@ Changes are persisted immediately unless noted. Some require a reboot.
| `set path.hash.mode <mode>` | 0, 1, or 2 | Path hashing algorithm |
| `set loop.detect <mode>` | `off`, `minimal`, `moderate`, `strict` | Loop detection sensitivity |
| `set radio.rxgain <0\|1\|on\|off>` | | RX gain boost, applied live. Replies `Error: unsupported` on radios without RX boost (SX127x); the pref is still saved. |
| `set radio.fem.rxgain <0\|1\|on\|off>` | default **1** | External FEM/LNA gating in the RX direction, applied live. Supported only where the FEM's chip-enable is wired to the radio node as `antenna-enable-gpios` — today `heltec_t096`, `heltec_wireless_tracker_v2`, `heltec_wifi_lora32_v4` and `heltec_wifi_lora32_v43`. Every other board reports `Error: unsupported`, for one of three reasons: it has no external amplifier at all; its amplifier has no separate receive-side enable, only the DIO2/TXEN/RXEN transmit-receive switch (`station_g2`, `gat562_30s`, `ikoka_nano_30dbm`, `promicro_sx1262`); or its amplifier enable is a standalone always-on regulator outside the radio node (`rak3401_1watt` — moving that pin onto the lora node as `antenna-enable-gpios` is what would enable it). Non-SX126x radios (LR1110, LR2021, SX127x) never support it. Setting `0` drops the FEM's RX gain (~16 dB on the SKY66122) and its supply current (~6.5 mA) — sensitivity for battery life, not a tuning knob. Transmit always uses the FEM, and the driver's idle/sleep gating is unchanged. Replies `Error: unsupported` on non-SX126x radios and on boards with no FEM wired; the pref is still saved. |
| `set radio.fem.rxgain <0\|1\|on\|off>` | default **1** | Routes receive through the external FEM's LNA (`1`) or around it via the FEM's bypass path (`0`), applied live. Sensitivity for battery life — `0` costs roughly 17 dB and saves the LNA's supply current. Transmit, and the driver's idle/sleep gating of the FEM, are unaffected either way. Supported only where the FEM's receive path is software-selectable and that select line is wired to the radio node as `lna-bypass-gpios` — today the three KCT8103L boards, `heltec_t096`, `heltec_wireless_tracker_v2` and `heltec_wifi_lora32_v43`. Every other board reports `Error: unsupported`: `heltec_wifi_lora32_v4`'s GC1109 has no receive-path select (its CPS is don't-care in RX, same as MeshCore); `station_g2`, `gat562_30s`, `ikoka_nano_30dbm` and `promicro_sx1262` have only the DIO2/TXEN/RXEN transmit-receive switch; `rak3401_1watt`'s SKY66122 is enabled by a standalone always-on regulator outside the radio node; and non-SX126x radios (LR1110, LR2021, SX127x) never implement it. The pref is still saved when unsupported. **Do not expect the FEM's chip-enable to be the knob** — deasserting `antenna-enable-gpios` in RX shuts the part down and takes the through path with it (~69 dB measured on a V4.3), which is what 1.17.2 did before this moved to `lna-bypass-gpios`. |
| `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. |
| `set adc.multiplier <mult>` | `0` (use board default) or 10030000 | Battery voltage ADC calibration multiplier, set directly. Rejects non-numeric input, NaN/inf and negatives. |
| `set adc.multiplier target <mv>` | 30004400 mV | Calibrate against a voltage you measured with a multimeter: rescales the current multiplier so the ADC reads `<mv>`. Replies with the old and new multiplier plus the before/after reading. `Error: no ADC reading on this board` if the board has no battery ADC. |
+54 -3
View File
@@ -35,10 +35,61 @@ function(zephcore_apply_patches PATCH_DIR TARGET_DIR LABEL)
file(READ "${_stamp}" _existing_hash)
string(STRIP "${_existing_hash}" _existing_hash)
if(_existing_hash STREQUAL _stamp_hash)
message(STATUS " [${LABEL}] Patches already applied, skipping.")
return()
# The stamp hashes HEAD + patch contents and says nothing about the
# working tree. A `git reset --hard` / `git checkout -- .` in the
# target tree reverts our patched files but leaves this stamp behind
# (it is untracked), and `west update` at an already-current
# revision does not move HEAD -- so the hash still matches and the
# build would skip patching and compile a pristine tree. Depending
# on which patch was lost that is either a confusing configure-time
# failure or, worse, a silently unpatched binary.
#
# Verify instead of trusting: every patch here modifies tracked
# files only (none create or delete), so a patch that is applied
# always leaves its own paths dirty against HEAD. Paths clean
# against HEAD therefore means "not applied" exactly. Note this
# deliberately does NOT compare tree content to the patch: a dirty
# path is left alone, so the edit-the-driver-then-regenerate-the-
# patch workflow still builds your in-progress edits untouched.
set(_stamp_stale FALSE)
foreach(_pf ${PATCH_FILES})
execute_process(
COMMAND git apply --numstat "${_pf}"
WORKING_DIRECTORY "${TARGET_DIR}"
OUTPUT_VARIABLE _vfy_numstat
ERROR_QUIET
)
string(REGEX MATCHALL "[^\t\n]+\t[^\t\n]+\t[^\t\n]+"
_vfy_lines "${_vfy_numstat}")
set(_vfy_paths "")
foreach(_line ${_vfy_lines})
string(REGEX REPLACE "^[^\t]+\t[^\t]+\t" "" _p "${_line}")
list(APPEND _vfy_paths "${_p}")
endforeach()
if(_vfy_paths)
execute_process(
COMMAND git diff --quiet -- ${_vfy_paths}
WORKING_DIRECTORY "${TARGET_DIR}"
RESULT_VARIABLE _vfy_clean
OUTPUT_QUIET ERROR_QUIET
)
if(_vfy_clean EQUAL 0)
get_filename_component(_vfy_name "${_pf}" NAME)
message(STATUS
" [${LABEL}] Stamp says applied but ${_vfy_name} "
"is missing from the tree re-applying all.")
set(_stamp_stale TRUE)
break()
endif()
endif()
endforeach()
if(NOT _stamp_stale)
message(STATUS " [${LABEL}] Patches already applied, skipping.")
return()
endif()
else()
message(STATUS " [${LABEL}] Patch set changed, re-applying...")
endif()
message(STATUS " [${LABEL}] Patch set changed, re-applying...")
endif()
foreach(PATCH_FILE ${PATCH_FILES})
get_filename_component(PATCH_NAME ${PATCH_FILE} NAME)
+9 -8
View File
@@ -85,14 +85,15 @@ public:
virtual bool setRxBoost(bool enable);
bool isRxBoostEnabled() const { return _rx_boost_enabled; }
/* External FEM/LNA gain during RX. Boards carrying a front-end module
* wire its chip-enable to the radio's antenna-enable line, which the
* driver asserts for RX and TX alike; disabling this withholds it in the
* RX direction only, trading the FEM's RX gain for its supply current.
* The transmit path and the driver's idle gating are unaffected.
* Returns false on radios that do not implement the knob (everything but
* the native SX126x today); on an SX126x board with no FEM wired it
* succeeds and does nothing, since there is no pin to withhold. */
/* External FEM/LNA gain during RX. Boards whose front-end module has a
* software-selectable receive path wire that select line to the radio
* (lna-bypass-gpios); disabling this routes RX around the FEM's LNA,
* trading its gain for its supply current while leaving the antenna
* connected. The transmit path and the driver's idle gating are
* unaffected. Returns false on radios that do not implement the knob
* (everything but the native SX126x today) and on SX126x boards that
* wire no such line -- including boards whose only FEM control is the
* chip enable, where there is no gain to trade, only a path to cut. */
virtual bool setFemRxEnable(bool enable) { (void)enable; return false; }
/* Multi-SF receive via LoRa side detectors. Only the LR2021 has them;
+7 -5
View File
@@ -112,11 +112,13 @@ void SX126xRadio::hwSetRxBoost(bool enable)
bool SX126xRadio::setFemRxEnable(bool enable)
{
/* Pure driver-side flag -- no SPI, no chip state. It takes effect at the
* next RX/TX/sleep transition, and startReceive() goes through one, so
* there is nothing to re-apply here. Returns false when this board wires
* no antenna-enable line, so the CLI reports "unsupported" rather than
* acknowledging a setting that cannot do anything. */
/* Pure driver-side -- no SPI, no chip state. The driver applies it to
* lna-bypass-gpios straight away when the radio is parked in RX, and
* otherwise at the next RX/TX/sleep transition. Returns false when this
* board wires no receive-path select, so the CLI reports "unsupported"
* rather than acknowledging a setting that cannot do anything -- note the
* gate is that line, not antenna-enable-gpios, so a board with a FEM whose
* only control is the chip enable (heltec_wifi_lora32_v4) lands here too. */
if (!sx126x_set_fem_rx_enable(_dev, enable)) {
return false;
}
+4 -3
View File
@@ -16,9 +16,10 @@ public:
void begin() override;
/* Gate the external FEM's LNA in the RX direction (radio.fem.rxgain).
* Delegates to the driver, which owns the antenna-enable pin; returns
* false on a board that has no such pin, i.e. no FEM to gate. */
/* Select the external FEM's LNA or its bypass path for RX
* (radio.fem.rxgain). Delegates to the driver, which owns the
* receive-path select pin; returns false on a board that wires no such
* pin, i.e. one whose FEM gain is not software-selectable. */
bool setFemRxEnable(bool enable) override;
/* Duty-cycle preamble false-positive stats (SX126x-specific) */
@@ -223,29 +223,41 @@
/* External PA/FEM control (KCT8103L, V4.3):
* GPIO2 = CSD (chip enable): driven HIGH during radio, LOW in sleep
* GPIO5 = CTX (TX/RX select): HIGH during TX, LOW during RX/LNA
* V4.3 CHANGE: CTX moved from GPIO46 (GC1109 CPS) to GPIO5 (KCT8103L CTX). */
* GPIO5 = CTX (receive-path select): LOW = through the LNA,
* HIGH = bypass. Held HIGH during TX, where the branch
* is inert because DIO2/CPS has selected the TX path.
* V4.3 CHANGE: CTX moved from GPIO46 (GC1109 CPS) to GPIO5 (KCT8103L CTX).
* CTX is lna-bypass-gpios, NOT tx-enable-gpios: it enables nothing,
* and binding it as a TX enable is what made `set radio.fem.rxgain
* off` gate CSD instead in 1.17.2, shutting the FEM down and taking
* the receive path with it (~69 dB, measured). */
/*
* ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep powers down
* RTC_PERIPH (see boards/common/pm_esp32.overlay for the full
* chain), and GPIO2/GPIO5 are RTC-capable pads (0-21), so their
* state is not retained across a sleep unless explicitly held.
* These two lines are the FEM's entire control surface CSD low
* disables the PA and the LNA with it, CTX low parks the part in
* its receive path so losing them means transmitting through a
* bypassed PA, tens of dB down, with RX sensitivity gone too,
* while the node otherwise looks healthy and keeps relaying.
* disables the PA and the LNA with it, CTX high bypasses the LNA
* so losing them means transmitting through a bypassed PA, tens of
* dB down, with RX sensitivity gone too, while the node otherwise
* looks healthy and keeps relaying.
* The flag is ignored when CONFIG_PM=n, so this is inert on
* builds that never light-sleep.
*/
antenna-enable-gpios = <&gpio0 2 (GPIO_ACTIVE_HIGH |
ESP32_GPIO_SLEEP_HOLD_EN)>; /* KCT8103L CSD = GPIO2 */
tx-enable-gpios = <&gpio0 5 (GPIO_ACTIVE_HIGH |
lna-bypass-gpios = <&gpio0 5 (GPIO_ACTIVE_HIGH |
ESP32_GPIO_SLEEP_HOLD_EN)>; /* KCT8103L CTX = GPIO5 */
/* DIO2 drives the SX1262 internal RF switch (TX path select).
* Combined with tx-enable-gpios, this keeps both the internal and
* external PA switch in sync with no software intervention needed. */
/* DIO2 drives the SX1262 RF switch output, which on a KCT8103L
* board is the part's CPS pin the TX/RX path select. MeshCore's
* heltec_v4 target defines CSD=2 and CTX=5 for the KCT8103L, no
* CPS GPIO, and SX126X_DIO2_AS_RF_SWITCH=true; same on t096 and
* tracker_v2. Note the V4.2 GC1109 inverts these two pin names
* there CPS is GPIO46 (an MCU pin, see heltec_wifi_lora32_v4) and
* DIO2 drives the pin that part calls CTX. On the KCT8103L, DIO2
* doing the path select is what leaves CTX free to mean nothing
* but LNA-or-bypass. */
dio2-tx-enable;
/* TCXO 1.8V via DIO3 — same as V4.2 */
@@ -43,8 +43,9 @@ esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash \
| SX1262 DIO3 TCXO | SX1262 DIO3 | `dio3-tcxo-voltage = 1.8V` | MeshCore |
| VFEM_Ctrl | GPIO7 | `fem_power` regulator | Heltec schematic, MeshCore |
| PA_CSD | GPIO4 | `antenna-enable-gpios` | Heltec schematic, MeshCore |
| PA_CTX | GPIO5 | `tx-enable-gpios` | Heltec schematic, MeshCore |
| PA_CPS | GPIO46 | Unused/unclaimed | Heltec schematic/pin map |
| PA_CTX | GPIO5 | `lna-bypass-gpios` | Heltec schematic, MeshCore |
| PA_CPS | SX1262 DIO2 | `dio2-tx-enable` | MeshCore (`SX126X_DIO2_AS_RF_SWITCH`) |
| GPIO46 | — | Unused/unclaimed (V4.2 pin-map carry-over, see notes) | Heltec pin map |
| UC6580 GNSS UART | RX GPIO33, TX GPIO34 | `&uart2`, `gnss-nmea-generic` | Heltec schematic, MeshCore |
| GNSS_RST | GPIO35 | `gps-enable` alias, active-high run | Heltec schematic, MeshCore |
| GNSS PPS | GPIO36 | Unused input | Heltec schematic |
@@ -72,8 +73,10 @@ esptool.py --chip esp32s3 --port /dev/ttyACM0 write_flash \
physical Wireless Tracker V2.
- Battery ADC is on ESP32-S3 ADC unit 1 (`&adc0`) channel 0, GPIO1. ADC unit 2
channel 0 is GPIO11 and must stay disabled because GPIO11 is LoRa MISO.
- MeshCore's V2 target drives `PA_CSD` and `PA_CTX` but does not drive
`PA_CPS`/GPIO46. ZephCore leaves GPIO46 unclaimed pending hardware validation
of that KCT8103L control path.
- MeshCore's V2 target drives `PA_CSD` and `PA_CTX` only, defines no `PA_CPS`
GPIO, and sets `SX126X_DIO2_AS_RF_SWITCH=true`. On a KCT8103L the CPS pin is
the SX1262 DIO2 net, so it is not an MCU line here; GPIO46 carries the
`PA_CPS` label only as a carry-over from the V4.2/GC1109 pin map, where the
two FEM pin names swap roles. ZephCore leaves GPIO46 unclaimed.
- `GNSS_RST` is exposed through ZephCore's `gps-enable` alias as an active-high
logical "run" line: high releases reset, low holds UC6580 off.
@@ -14,9 +14,12 @@
* - Heltec Wireless Tracker V2.3 schematic and V2 datasheet
* - MeshCore variants/heltec_tracker_v2
*
* PA_CPS appears on GPIO46 in the Heltec pin map/schematic but MeshCore's V2
* variant does not drive it. ZephCore leaves GPIO46 unclaimed until the exact
* KCT8103L CPS behavior for this revision is confirmed.
* GPIO46 appears as "PA_CPS" in the Heltec pin map. It is not a FEM control
* on this board and is left unclaimed. That label is a carry-over from the
* V4.2 map, where the GC1109's CPS genuinely is GPIO46; on a KCT8103L the two
* pin names swap roles and CPS is the SX1262 DIO2 net (dio2-tx-enable), which
* is why MeshCore's heltec_tracker_v2 target defines no CPS GPIO and sets
* SX126X_DIO2_AS_RF_SWITCH=true. See the FEM comment on the &lora node.
*/
/dts-v1/;
@@ -250,23 +253,31 @@
busy-gpios = <&gpio0 13 GPIO_ACTIVE_HIGH>;
dio1-gpios = <&gpio0 14 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>;
/* KCT8103L control per MeshCore:
* - PA_CSD/GPIO4: active-HIGH FEM enable
* - PA_CTX/GPIO5: HIGH=TX, LOW=RX/LNA
* - SX1262 DIO2: internal RF switch TX select */
/* KCT8103L control per MeshCore (variants/heltec_tracker_v2/
* LoRaFEMControl.cpp):
* - PA_CSD/GPIO4: active-HIGH FEM enable, low only in sleep
* - PA_CTX/GPIO5: receive-path select, LOW=LNA, HIGH=bypass;
* held HIGH in TX, where the receive branch is inert
* - SX1262 DIO2 -> KCT8103L CPS: TX/RX path select
* CTX is lna-bypass-gpios, not tx-enable-gpios: it enables
* nothing. MeshCore's target defines CSD=4 and CTX=5, defines
* no CPS GPIO, and sets SX126X_DIO2_AS_RF_SWITCH=true -- so the
* path select is DIO2 in hardware, which is what leaves CTX
* free to mean LNA-or-bypass. */
/*
* ESP32_GPIO_SLEEP_HOLD_EN on both: light sleep powers down
* RTC_PERIPH (chain documented in boards/common/pm_esp32.overlay)
* and GPIO4/GPIO5 are RTC-capable pads (0-21), so without the
* hold the FEM comes out of a sleep disabled and parked in its
* receive path transmit goes out through a bypassed PA, tens of
* dB down, and RX loses the LNA, while the node keeps relaying
* and looks healthy. Inert when CONFIG_PM=n.
* hold the FEM comes out of a sleep disabled and with an
* indeterminate receive path transmit goes out through a
* bypassed PA, tens of dB down, and RX may lose the LNA, while
* the node keeps relaying and looks healthy. Inert when
* CONFIG_PM=n.
*/
antenna-enable-gpios = <&gpio0 4 (GPIO_ACTIVE_HIGH |
ESP32_GPIO_SLEEP_HOLD_EN)>;
tx-enable-gpios = <&gpio0 5 (GPIO_ACTIVE_HIGH |
ESP32_GPIO_SLEEP_HOLD_EN)>;
lna-bypass-gpios = <&gpio0 5 (GPIO_ACTIVE_HIGH |
ESP32_GPIO_SLEEP_HOLD_EN)>;
dio2-tx-enable;
dio3-tcxo-voltage = <SX126X_DIO3_TCXO_1V8>;
@@ -31,7 +31,7 @@ Sources: official Heltec Mesh Node T096 datasheet/schematic, with MeshCore `vari
| LoRa DIO1 | P0.21 | DIO1 | `dio1-gpios` |
| SX1262 DIO2 / FEM CPS | SX1262 DIO2 | PA_CPS | `dio2-tx-enable` |
| FEM enable | P0.12 | PA_CSD | `antenna-enable-gpios` |
| FEM TX select | P1.09 | PA_CTX | `tx-enable-gpios` |
| FEM RX path select | P1.09 | PA_CTX | `lna-bypass-gpios` |
| FEM rail enable | P0.30 | VFEM_Ctrl | fixed regulator `vfem_enable` |
| GNSS UART RX into MCU | P0.23 | GNSS_TX | `uart0` RX |
| GNSS UART TX from MCU | P0.25 | GNSS_RX | `uart0` TX |
@@ -333,15 +333,17 @@
/*
* KCT8103L PA/FEM control:
* - PA_CSD P0.12 -> CSD, active-HIGH FEM enable.
* - PA_CTX P1.09 -> CTX, HIGH for TX in the existing ZephCore
* SX1262 tx-enable model. MeshCore uses the same net for
* TX/LNA-bypass control.
* - PA_CSD P0.12 -> CSD, active-HIGH FEM enable, low only in sleep.
* - PA_CTX P1.09 -> CTX, the receive-path select: LOW routes RX
* through the LNA, HIGH bypasses it. Held HIGH in TX, where
* the receive branch is inert. This is lna-bypass-gpios, not
* tx-enable-gpios it enables nothing.
* - PA_CPS is not an MCU GPIO on T096; it is the SX1262 DIO2
* net into the KCT8103L CPS pin, so use dio2-tx-enable.
* net into the KCT8103L CPS pin, so use dio2-tx-enable. That
* is what does the TX/RX switching and leaves CTX free.
*/
antenna-enable-gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>; /* PA_CSD */
tx-enable-gpios = <&gpio1 9 GPIO_ACTIVE_HIGH>; /* PA_CTX */
lna-bypass-gpios = <&gpio1 9 GPIO_ACTIVE_HIGH>; /* PA_CTX */
dio2-tx-enable; /* PA_CPS */
dio3-tcxo-voltage = <SX126X_DIO3_TCXO_1V8>;
@@ -51,22 +51,28 @@ bool sx126x_is_receiving(const struct device *dev);
void sx126x_set_rx_boost(const struct device *dev, bool enable);
/**
* @brief Enable/disable an external FEM's LNA in the RX direction
* @brief Select an external FEM's LNA or its bypass path for RX
*
* Boards with an external front-end module wire its chip-enable to
* antenna-enable-gpios (KCT8103L CSD, SKY66122 CSD+CPS). The driver normally
* asserts that line for both RX and TX. Passing false withholds it for RX
* only: the FEM's LNA and its supply current (~6.5 mA on SKY66122) drop out,
* at the cost of the FEM's RX gain (~16 dB on the same part). TX always
* asserts the line, and sleep/idle always clears it, so neither the transmit
* path nor the driver's idle gating is affected.
* Acts on lna-bypass-gpios, the FEM's receive-path select -- KCT8103L CTX on
* the Heltec boards, where DIO2 into the FEM's CPS pin does the TX/RX
* switching and leaves this line meaning nothing but "LNA or bypass".
* Passing false routes RX around the LNA: its gain (~17 dB measured) and its
* supply current both drop out, but the antenna stays connected to the
* receiver. TX and the driver's idle/sleep gating are unaffected.
*
* Default is enabled.
* This is deliberately NOT antenna-enable-gpios. That line is the FEM's chip
* enable, owned by the RX/TX/sleep state machine; deasserting it during RX
* shuts the part down, and a shut-down FEM passes nothing -- the node goes
* deaf by tens of dB rather than losing the LNA's share. ZephCore 1.17.2
* shipped that mistake; see the KCT8103L handling in MeshCore's
* variants/heltec_v4/LoRaFEMControl.cpp for the reference behaviour.
*
* Default is enabled (LNA in the path).
*
* @param dev LoRa device
* @param enable true to keep the FEM active during RX
* @return true if this board has an antenna-enable line to gate, false if it
* has none (in which case the call did nothing)
* @param enable true for the LNA path, false for the bypass path
* @return true if this board wires a receive-path select, false if it has
* none (in which case the call did nothing)
*/
bool sx126x_set_fem_rx_enable(const struct device *dev, bool enable);
@@ -1,10 +1,13 @@
Subject: [PATCH] lora: sx126x: ZephCore native driver
Sole owner of every ZephCore change under drivers/lora/native/sx126x/ --
sx126x.c, sx126x.h, sx126x_hal.c, sx126x_hal_common.c and sx126x_regs.h. No
other patch in this directory touches those files; see "Why this is one patch"
at the end. The extension-API header sx126x_ext.h is not patched in, it is
copied from patches/zephyr-new/.
sx126x.c, sx126x.h, sx126x_hal.c, sx126x_hal.h, sx126x_hal_common.c,
sx126x_hal_stm32wl.c and sx126x_regs.h -- plus the driver's devicetree binding
dts/bindings/lora/semtech,sx126x-base.yaml, which it extends with
lna-bypass-gpios (see "FEM receive-path select" below). No other patch in this
directory touches those files; see "Why this is one patch" at the end. The
extension-API header sx126x_ext.h is not patched in, it is copied from
patches/zephyr-new/.
== Base driver ==
@@ -38,24 +41,16 @@ top of upstream:
C++ caller. sx126x_lora_recv_async() has an idempotent fast path, so the
failure-path startReceive() on that side is a no-op.
* External FEM/LNA RX gating -- sx126x_set_fem_rx_enable() plus the
fem_rx_off flag it sets. antenna-enable-gpios is the chip-enable line of
the external FEM on the boards that route it there (KCT8103L CSD on
heltec_t096, heltec_wireless_tracker_v2 and heltec_wifi_lora32_v43;
GC1109 CSD on heltec_wifi_lora32_v4), and the driver asserts it for RX and
TX alike. With the flag set, sx126x_set_rf_path() withholds it in the RX
direction only, so the FEM's LNA and its supply current drop out while TX
still gets the PA path and sleep/idle keeps clearing the line as before.
The setter returns false where the board declares no antenna-enable line,
so the CLI can report that rather than acknowledging a no-op -- that is
the rak3401_1watt case, where the SKY66122 enable is a standalone
regulator-boot-on rail outside the radio node.
The pin stays driver-owned throughout. Taking it over from devicetree
instead would cost the idle gating, drop the ESP32 sleep-hold flags those
nodes carry, and on the boards whose CTX net is a separate tx-enable-gpios
it would desynchronise the external switch from DIO2. Backs
`set radio.fem.rxgain` (default on = historical behaviour).
* External FEM receive-path select -- the lna-bypass-gpios devicetree
property, sx126x_set_fem_rx_enable() and the fem_rx_off flag it sets.
The line is the FEM's LNA-or-bypass select (KCT8103L CTX on heltec_t096,
heltec_wireless_tracker_v2 and heltec_wifi_lora32_v43), driven as
(tx || fem_rx_off) and deliberately independent of the chip enable.
Backs `set radio.fem.rxgain` (default on = LNA in the path = historical
behaviour). Read the "FEM receive-path select" section below before
touching any of it: this is emphatically NOT antenna-enable-gpios, and
an earlier implementation that gated that pin instead shipped in 1.17.2
and cost roughly 69 dB of receive sensitivity.
* Extension API for the C++ adapter (sx126x_ext.h): sx126x_is_chip_busy,
sx126x_is_receiving, sx126x_set_rx_boost, sx126x_set_fem_rx_enable,
@@ -387,6 +382,85 @@ transmit side. The branch is now gated on state != SX126X_STATE_TX; a Tx
timeout falls through to the tail, which sleeps the chip (restoring REST_STATE)
and reports -ETIMEDOUT, and the C++ failure path re-arms RX.
== FEM receive-path select (lna-bypass-gpios) ==
Adds a devicetree property, lna-bypass-gpios, and the driver plumbing behind
sx126x_set_fem_rx_enable() -- the radio.fem.rxgain CLI knob. This replaces the
first implementation of that knob, which acted on the wrong pin and shipped in
1.17.2.
Three of our boards carry a KCT8103L (t096, wireless_tracker_v2,
wifi_lora32_v43), and MeshCore's LoRaFEMControl.cpp for each of them is the
reference for what its two MCU lines mean:
CSD chip enable. HIGH whenever the radio is up; LOW only in sleep --
MeshCore's setSleepModeEnable() drives exactly this pin and nothing
else.
CTX receive-path select. LOW routes RX through the LNA, HIGH bypasses it
(variants/heltec_v4/LoRaFEMControl.cpp spells both branches out in
comments). Held HIGH during TX, where the receive branch is inert.
CPS TX/RX path select -- not an MCU GPIO on any KCT8103L board. It is the
SX1262 DIO2 net, which is why every one of MeshCore's KCT8103L targets
sets SX126X_DIO2_AS_RF_SWITCH=true and defines no CPS pin at all
(t096 CSD=12/CTX=41, tracker_v2 CSD=4/CTX=5, heltec_v4 CSD=2/CTX=5),
and why we set dio2-tx-enable on all three.
Beware the naming inversion between the two Heltec front ends: on the GC1109
(V4.2) it is *CTX* that hangs off DIO2 and *CPS* that is an MCU pin (GPIO46,
"HIGH=full PA, LOW=bypass", don't-care in RX), which is why heltec_wifi_lora32_v4
keeps GPIO46 on tx-enable-gpios and has no LNA knob. On the KCT8103L the two
names swap roles. Where GPIO46 still appears as "PA_CPS" in a Heltec pin map
for a KCT8103L board, it is a carry-over from that V4.2 map, not a FEM control:
MeshCore's tracker_v2 target does not define or drive it.
Because DIO2/CPS does the path switching in hardware, CTX enables nothing. Its
only software-visible job is LNA-or-bypass. Binding it as tx-enable-gpios
produced the right waveform by coincidence -- tx_enable is driven (enable &&
tx), which matches "HIGH in TX, LOW in RX with the LNA" -- and that coincidence
is what sent the first implementation of the knob to the other pin: with CTX
apparently spoken for, "FEM RX gain off" became
sx126x_hal_set_antenna_enable(dev, enable && (tx || !data->fem_rx_off));
i.e. CSD low during RX. That is MeshCore's setSleepModeEnable(), not its
LNA-off state. A shut-down FEM has no through path: the loss is the LNA's gain
plus the off-state isolation of the part, measured on a V4.3 at roughly 69 dB
(-17 dBm to -86 dBm on a 4 m link) against the ~17 dB the bypass path costs.
Reported by a user who could not raise their own repeater from more than 50 m
away after turning the knob off.
So the pin moves to a property named for what it is. antenna_enable goes back
to plain `enable` -- the RX/TX/sleep state machine owns it -- and the driver
drives lna_bypass as (tx || fem_rx_off), deliberately independent of `enable`:
while CSD is deasserted this line only decides which path the FEM will select
when it comes back, and leaving it in the receive state across sleep is what
lets an ESP32 GPIO sleep-hold keep the RX path intact.
Support is per-board and automatic: sx126x_set_fem_rx_enable() reports false
when lna_bypass.port is NULL, so the CLI answers "Error: unsupported" on every
board whose FEM gain is not software-selectable. That correctly includes
wifi_lora32_v4 (GC1109, whose CPS is don't-care in RX -- MeshCore's
LoRaFEMControl leaves lna_can_control false on that branch) and excludes, by
construction, boards with a genuine TXEN on tx-enable-gpios (lilygo_tlora_c6,
lilygo_timpulse_plus), where asserting a line during RX would key the PA into a
receive window.
Audited against MeshCore 2026-08-25. It ships LoRaFEMControl for six variants;
exactly four expose an LNA knob -- heltec_t096, heltec_tracker_v2, heltec_v4
(KCT8103L branch only) and heltec_v4_r8, all KCT8103L, plus station_g3_esp32 on
a different part (P_PRIMARY_LNA_EN). heltec_tower_v2 uses a KCT8103L but keeps
the same net as a plain TX/RX select and exposes nothing. Our three
lna-bypass-gpios boards map onto MeshCore's KCT8103L set exactly; we ship no
board where we claim the knob and MeshCore does not, so there is nothing to
scrub. heltec_v4_r8, heltec_tower_v2 and station_g3 have no ZephCore board.
The knob also applies immediately now, under data->lock -- the same lock
sx126x_set_rf_path()'s callers hold, since this runs on the CLI thread and an
unlocked state read of RX can be overtaken by send_async() taking the chip to
TX. It is a bare GPIO with no SPI and no chip state behind it, and a node
parked in continuous RX may not make an RX/TX/sleep transition for minutes --
long enough for the CLI to acknowledge a change the radio had not made.
== Why this is one patch ==
Five of the sections above shipped as separate files (0011 band-rssi-cal, 0012
@@ -423,12 +497,13 @@ filenames into the section headings above.
Regenerate with:
git -C zephyr diff -- drivers/lora/native/sx126x/
git -C zephyr diff -- drivers/lora/native/sx126x/ \
'dts/bindings/lora/semtech,sx126x-base.yaml'
appended to this preamble.
diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c
index 30243ba5dc7..3a8bc017a52 100644
index 30243ba5dc7..0e870e37468 100644
--- a/drivers/lora/native/sx126x/sx126x.c
+++ b/drivers/lora/native/sx126x/sx126x.c
@@ -6,72 +6,218 @@
@@ -1110,7 +1185,7 @@ index 30243ba5dc7..3a8bc017a52 100644
LOG_INF("SX126x initialized");
return 0;
}
@@ -398,7 +892,7 @@ static void sx126x_dio1_callback(const struct device *dev)
@@ -398,15 +892,44 @@ static void sx126x_dio1_callback(const struct device *dev)
{
struct sx126x_data *data = dev->data;
@@ -1119,18 +1194,27 @@ index 30243ba5dc7..3a8bc017a52 100644
}
static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx)
@@ -406,7 +900,26 @@ static void sx126x_set_rf_path(const struct device *dev, bool enable, bool tx)
{
const struct sx126x_hal_config *config = dev->config;
+ struct sx126x_data *data = dev->data;
- sx126x_hal_set_antenna_enable(dev, enable);
sx126x_hal_set_antenna_enable(dev, enable);
- if (!config->dio2_tx_enable) {
+ /* An external FEM's enable line is asserted for TX unconditionally. For
+ * RX it follows fem_rx_off (the radio.fem.rxgain pref, applied through
+ * sx126x_set_fem_rx_enable): clearing it drops the FEM's LNA and its
+ * supply current, at the cost of that gain. `enable` still wins, so
+ * sleep and idle keep gating the line exactly as they did before. */
+ sx126x_hal_set_antenna_enable(dev, enable && (tx || !data->fem_rx_off));
+
+ /* FEM receive-path select, on boards that wire one (lna-bypass-gpios).
+ * HIGH = bypass, LOW = through the LNA. Deliberately independent of
+ * `enable`: the FEM's own chip-enable is antenna_enable, so while that
+ * is deasserted this line only decides which path the part will select
+ * when it comes back. Leaving it in the receive state across sleep is
+ * what lets an ESP32 GPIO sleep-hold keep the RX path intact.
+ *
+ * TX drives it HIGH for the same reason the FEM vendors do: with the
+ * TX/RX path itself selected elsewhere (DIO2 into CPS), bypass is the
+ * inert state for the receive branch while the PA is keyed. */
+ if (config->lna_bypass.port != NULL) {
+ gpio_pin_set_dt(&config->lna_bypass, tx || data->fem_rx_off);
+ }
+
+ if (config->dio2_tx_enable) {
+ /* DIO2 handles TX enable in hardware -- but rx-enable-gpios
+ * (e.g. E22-900M30S RXEN) and some board-specific tx-enable
@@ -1148,7 +1232,25 @@ index 30243ba5dc7..3a8bc017a52 100644
sx126x_hal_set_rf_switch(dev, enable, tx);
}
}
@@ -455,6 +962,228 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev)
@@ -426,6 +949,7 @@ static void sx126x_disconnect_rf_gpios(const struct device *dev)
sx126x_disconnect_gpio(&config->antenna_enable);
sx126x_disconnect_gpio(&config->tx_enable);
sx126x_disconnect_gpio(&config->rx_enable);
+ sx126x_disconnect_gpio(&config->lna_bypass);
}
static int sx126x_reconnect_rf_gpios(const struct device *dev)
@@ -451,10 +975,238 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev)
return ret;
}
+ ret = sx126x_hal_configure_gpio(&config->lna_bypass,
+ GPIO_OUTPUT_INACTIVE, "FEM LNA bypass");
+ if (ret < 0) {
+ return ret;
+ }
+
return 0;
}
#endif /* CONFIG_PM_DEVICE */
@@ -1377,7 +1479,7 @@ index 30243ba5dc7..3a8bc017a52 100644
static int sx126x_set_sleep(const struct device *dev)
{
struct sx126x_data *data = dev->data;
@@ -533,8 +1262,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
@@ -533,8 +1285,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
struct sx126x_data *data = dev->data;
struct sx126x_rx_result result = { 0 };
uint8_t payload_len = 0, offset = 0;
@@ -1394,7 +1496,7 @@ index 30243ba5dc7..3a8bc017a52 100644
/* Get received packet info */
ret = sx126x_get_rx_buffer_status(dev, &payload_len, &offset);
if (ret < 0) {
@@ -544,9 +1281,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
@@ -544,9 +1304,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
/* Get signal quality */
sx126x_get_packet_status(dev, &result.rssi, &result.snr);
@@ -1424,7 +1526,7 @@ index 30243ba5dc7..3a8bc017a52 100644
result.status = -EIO;
} else {
/* Read payload into shared buffer */
@@ -564,23 +1318,55 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
@@ -564,23 +1341,55 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
}
}
@@ -1493,7 +1595,7 @@ index 30243ba5dc7..3a8bc017a52 100644
sx126x_set_sleep(dev);
k_msgq_put(&data->rx_msgq, &result, K_NO_WAIT);
}
@@ -589,8 +1375,83 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
@@ -589,8 +1398,83 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta
static void sx126x_handle_irq_timeout(const struct device *dev)
{
struct sx126x_data *data = dev->data;
@@ -1577,7 +1679,7 @@ index 30243ba5dc7..3a8bc017a52 100644
sx126x_set_sleep(dev);
if (data->tx_async_signal != NULL) {
@@ -633,10 +1494,54 @@ static void sx126x_irq_work_handler(struct k_work *work)
@@ -633,10 +1517,54 @@ static void sx126x_irq_work_handler(struct k_work *work)
sx126x_handle_irq_rx_done(dev, irq_status);
}
@@ -1632,7 +1734,7 @@ index 30243ba5dc7..3a8bc017a52 100644
/* Re-enable the DIO1 interrupt for the next event (unless sleeping) */
if (atomic_get(&data->state) != SX126X_REST_STATE) {
sx126x_hal_dio1_irq_enable(dev);
@@ -648,6 +1553,7 @@ static int sx126x_lora_config(const struct device *dev,
@@ -648,6 +1576,7 @@ static int sx126x_lora_config(const struct device *dev,
{
struct sx126x_data *data = dev->data;
const struct sx126x_hal_config *hal_config = dev->config;
@@ -1640,7 +1742,7 @@ index 30243ba5dc7..3a8bc017a52 100644
bool ldro;
int ret;
@@ -679,25 +1585,45 @@ static int sx126x_lora_config(const struct device *dev,
@@ -679,25 +1608,45 @@ static int sx126x_lora_config(const struct device *dev,
goto out;
}
@@ -1688,7 +1790,7 @@ index 30243ba5dc7..3a8bc017a52 100644
/* Set sync word */
ret = sx126x_set_sync_word(dev, config->public_network);
if (ret < 0) {
@@ -721,6 +1647,10 @@ out:
@@ -721,6 +1670,10 @@ out:
return ret;
}
@@ -1699,7 +1801,7 @@ index 30243ba5dc7..3a8bc017a52 100644
static int sx126x_lora_send_async(const struct device *dev,
uint8_t *data_buf, uint32_t data_len,
struct k_poll_signal *async)
@@ -738,11 +1668,27 @@ static int sx126x_lora_send_async(const struct device *dev,
@@ -738,11 +1691,27 @@ static int sx126x_lora_send_async(const struct device *dev,
return -EINVAL;
}
@@ -1729,7 +1831,7 @@ index 30243ba5dc7..3a8bc017a52 100644
k_mutex_lock(&data->lock, K_FOREVER);
ret = sx126x_ensure_ready(dev);
@@ -752,6 +1698,59 @@ static int sx126x_lora_send_async(const struct device *dev,
@@ -752,6 +1721,59 @@ static int sx126x_lora_send_async(const struct device *dev,
return ret;
}
@@ -1789,7 +1891,7 @@ index 30243ba5dc7..3a8bc017a52 100644
data->tx_async_signal = async;
k_msgq_purge(&data->tx_msgq);
@@ -777,8 +1776,99 @@ static int sx126x_lora_send_async(const struct device *dev,
@@ -777,8 +1799,99 @@ static int sx126x_lora_send_async(const struct device *dev,
/* Enable antenna and set TX path */
sx126x_set_rf_path(dev, true, true);
@@ -1891,7 +1993,7 @@ index 30243ba5dc7..3a8bc017a52 100644
if (ret < 0) {
goto out_error;
}
@@ -847,6 +1937,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf,
@@ -847,6 +1960,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf,
}
data->rx_cb = NULL;
@@ -1900,7 +2002,7 @@ index 30243ba5dc7..3a8bc017a52 100644
k_msgq_purge(&data->rx_msgq);
/* Set packet parameters for variable length reception */
@@ -918,6 +2010,8 @@ static int sx126x_lora_recv_async(const struct device *dev,
@@ -918,6 +2033,8 @@ static int sx126x_lora_recv_async(const struct device *dev,
/* Stop async reception */
data->rx_cb = NULL;
data->rx_cb_user_data = NULL;
@@ -1909,7 +2011,7 @@ index 30243ba5dc7..3a8bc017a52 100644
if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) {
sx126x_set_standby(dev, SX126X_STANDBY_RC);
sx126x_set_sleep(dev);
@@ -932,6 +2026,19 @@ static int sx126x_lora_recv_async(const struct device *dev,
@@ -932,6 +2049,19 @@ static int sx126x_lora_recv_async(const struct device *dev,
return -EINVAL;
}
@@ -1929,7 +2031,7 @@ index 30243ba5dc7..3a8bc017a52 100644
if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) {
LOG_ERR("Busy");
k_mutex_unlock(&data->lock);
@@ -945,8 +2052,18 @@ static int sx126x_lora_recv_async(const struct device *dev,
@@ -945,8 +2075,18 @@ static int sx126x_lora_recv_async(const struct device *dev,
return ret;
}
@@ -1948,7 +2050,7 @@ index 30243ba5dc7..3a8bc017a52 100644
/* Set packet parameters */
ret = sx126x_set_packet_params(dev,
@@ -959,6 +2076,7 @@ static int sx126x_lora_recv_async(const struct device *dev,
@@ -959,6 +2099,7 @@ static int sx126x_lora_recv_async(const struct device *dev,
SX126X_LORA_IQ_INVERTED : SX126X_LORA_IQ_STANDARD);
if (ret < 0) {
data->rx_cb = NULL;
@@ -1956,7 +2058,7 @@ index 30243ba5dc7..3a8bc017a52 100644
sx126x_set_sleep(dev);
k_mutex_unlock(&data->lock);
return ret;
@@ -971,11 +2089,21 @@ static int sx126x_lora_recv_async(const struct device *dev,
@@ -971,11 +2112,21 @@ static int sx126x_lora_recv_async(const struct device *dev,
ret = sx126x_set_rx(dev, 0);
if (ret < 0) {
data->rx_cb = NULL;
@@ -1978,7 +2080,7 @@ index 30243ba5dc7..3a8bc017a52 100644
k_mutex_unlock(&data->lock);
return 0;
}
@@ -993,7 +2121,9 @@ static uint32_t sx126x_lora_airtime(const struct device *dev, uint32_t data_len)
@@ -993,7 +2144,9 @@ static uint32_t sx126x_lora_airtime(const struct device *dev, uint32_t data_len)
}
/* Calculate symbol time in microseconds */
@@ -1989,7 +2091,7 @@ index 30243ba5dc7..3a8bc017a52 100644
sf = data->config.datarate;
/* Symbol time = 2^SF / BW (seconds) */
@@ -1051,7 +2181,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency,
@@ -1051,7 +2204,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency,
/* Set PA config and TX power */
ret = sx126x_hal_configure_tx_params(dev, tx_power, frequency,
@@ -1998,7 +2100,7 @@ index 30243ba5dc7..3a8bc017a52 100644
if (ret < 0) {
sx126x_set_sleep(dev);
k_mutex_unlock(&data->lock);
@@ -1083,14 +2213,828 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency,
@@ -1083,14 +2236,855 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency,
return 0;
}
@@ -2158,15 +2260,42 @@ index 30243ba5dc7..3a8bc017a52 100644
+ struct sx126x_data *data = dev->data;
+ const struct sx126x_hal_config *config = dev->config;
+
+ /* Stored inverted so the zero-initialised default is "FEM on in RX",
+ * which is what the driver did before this knob existed. No SPI and no
+ * chip state: sx126x_set_rf_path() reads it on the next RX/TX/sleep
+ * transition, and every RX (re)start passes through there. */
+ /* False when the board wires no receive-path select: there is no FEM
+ * RX gain to trade away, so the caller can say so instead of
+ * acknowledging a no-op. Boards whose only FEM line is the chip
+ * enable land here too -- dropping that would not lower the gain, it
+ * would shut the part down and open the receive path entirely. */
+ if (config->lna_bypass.port == NULL) {
+ /* Still record it: a board can gain the line later without the
+ * caller having to re-apply the pref. */
+ data->fem_rx_off = !enable;
+ return false;
+ }
+
+ /* Under data->lock, which is what sx126x_set_rf_path()'s callers hold.
+ * This runs on the CLI thread: without it, a state read of RX can be
+ * overtaken by send_async() taking the chip to TX and driving the line
+ * HIGH, after which the write below pulls it back LOW and leaves the
+ * FEM's LNA in circuit for the whole transmission. */
+ k_mutex_lock(&data->lock, K_FOREVER);
+
+ /* Stored inverted so the zero-initialised default is "LNA in the RX
+ * path", which is what the driver did before this knob existed. */
+ data->fem_rx_off = !enable;
+
+ /* False when the board wires no antenna-enable line: there is no FEM to
+ * gate, so the caller can say so instead of acknowledging a no-op. */
+ return config->antenna_enable.port != NULL;
+ /* Apply now rather than at the next sx126x_set_rf_path(). This is a
+ * bare GPIO with no SPI and no chip state behind it, and a node parked
+ * in continuous RX may not transition for minutes -- long enough for
+ * the CLI to have acknowledged a change the radio had not made. Only
+ * in RX: every other state either drives the line itself on the way in
+ * (TX) or wants it left where sleep parked it. */
+ if (atomic_get(&data->state) == SX126X_STATE_RX) {
+ gpio_pin_set_dt(&config->lna_bypass, data->fem_rx_off);
+ }
+
+ k_mutex_unlock(&data->lock);
+
+ return true;
+}
+
+void sx126x_reset_agc(const struct device *dev)
@@ -2834,7 +2963,7 @@ index 30243ba5dc7..3a8bc017a52 100644
};
#ifdef CONFIG_PM_DEVICE
@@ -1112,6 +3040,7 @@ static int sx126x_pm_action(const struct device *dev,
@@ -1112,6 +3106,7 @@ static int sx126x_pm_action(const struct device *dev,
static int sx126x_init(const struct device *dev)
{
struct sx126x_data *data = dev->data;
@@ -2842,7 +2971,7 @@ index 30243ba5dc7..3a8bc017a52 100644
int ret;
/* Initialize data structures */
@@ -1121,9 +3050,33 @@ static int sx126x_init(const struct device *dev)
@@ -1121,9 +3116,33 @@ static int sx126x_init(const struct device *dev)
k_msgq_init(&data->rx_msgq, (char *)&data->rx_result,
sizeof(struct sx126x_rx_result), 1);
k_work_init(&data->irq_work, sx126x_irq_work_handler);
@@ -2876,11 +3005,29 @@ index 30243ba5dc7..3a8bc017a52 100644
/* Initialize HAL */
ret = sx126x_hal_init(dev);
@@ -1185,6 +3204,8 @@ static int sx126x_init(const struct device *dev)
{0}), \
.rx_enable = GPIO_DT_SPEC_INST_GET_OR(inst, rx_enable_gpios, \
{0}), \
+ .lna_bypass = GPIO_DT_SPEC_INST_GET_OR(inst, lna_bypass_gpios, \
+ {0}), \
.dio2_tx_enable = DT_INST_PROP(inst, dio2_tx_enable), \
.dio3_tcxo_enable = DT_INST_NODE_HAS_PROP(inst, dio3_tcxo_voltage), \
.dio3_tcxo_voltage = DT_INST_PROP_OR(inst, dio3_tcxo_voltage, 0), \
@@ -1239,6 +3260,8 @@ DT_INST_FOREACH_STATUS_OKAY_VARGS(SX126X_INIT, true)
{0}), \
.rx_enable = GPIO_DT_SPEC_INST_GET_OR(inst, rx_enable_gpios, \
{0}), \
+ .lna_bypass = GPIO_DT_SPEC_INST_GET_OR(inst, lna_bypass_gpios, \
+ {0}), \
.dio2_tx_enable = DT_INST_PROP(inst, dio2_tx_enable), \
.dio3_tcxo_enable = DT_INST_NODE_HAS_PROP(inst, dio3_tcxo_voltage), \
.dio3_tcxo_voltage = DT_INST_PROP_OR(inst, dio3_tcxo_voltage, 0), \
diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h
index 9dbf3f26586..b6df07ce7ab 100644
index 9dbf3f26586..675f3eca8a7 100644
--- a/drivers/lora/native/sx126x/sx126x.h
+++ b/drivers/lora/native/sx126x/sx126x.h
@@ -56,13 +56,104 @@ struct sx126x_data {
@@ -56,13 +56,107 @@ struct sx126x_data {
/* Async RX callback */
lora_recv_cb rx_cb;
void *rx_cb_user_data;
@@ -2898,12 +3045,15 @@ index 9dbf3f26586..b6df07ce7ab 100644
+ bool rx_duty_cycle_enabled;
+ bool rx_boost_enabled;
+
+ /* External FEM/LNA gating for the RX direction, set by
+ * sx126x_set_fem_rx_enable(). Negative logic so the zero-initialised
+ * default preserves the historical behaviour -- antenna-enable-gpios
+ * asserted for both RX and TX. When set, sx126x_set_rf_path() drops
+ * the line for RX only: TX still asserts it and sleep/idle still
+ * clears it, so the driver keeps sole ownership of the pin. */
+ /* External FEM receive-path select, set by sx126x_set_fem_rx_enable()
+ * and acted on through lna-bypass-gpios. Negative logic so the
+ * zero-initialised default is the LNA path, which is what every board
+ * wants and what the driver did before the knob existed.
+ *
+ * NOT the FEM chip enable. antenna-enable-gpios stays owned by the
+ * RX/TX/sleep state machine: deasserting it in RX would shut the part
+ * down, which on the KCT8103L boards costs the LNA gain *and* the
+ * through path -- tens of dB, not the ~17 dB the bypass costs. */
+ bool fem_rx_off;
+
+ /* AgcSensiAdjust value for the configured band (DS Table 6-4), cached
@@ -2986,7 +3136,7 @@ index 9dbf3f26586..b6df07ce7ab 100644
#endif /* ZEPHYR_DRIVERS_LORA_SX126X_SX126X_INTERNAL_H_ */
diff --git a/drivers/lora/native/sx126x/sx126x_hal.c b/drivers/lora/native/sx126x/sx126x_hal.c
index 34f7089584f..6f4e2a4434c 100644
index 34f7089584f..394578956de 100644
--- a/drivers/lora/native/sx126x/sx126x_hal.c
+++ b/drivers/lora/native/sx126x/sx126x_hal.c
@@ -150,7 +150,13 @@ int sx126x_hal_init(const struct device *dev)
@@ -3004,7 +3154,22 @@ index 34f7089584f..6f4e2a4434c 100644
if (ret < 0) {
LOG_ERR("Failed to configure DIO1 GPIO: %d", ret);
return ret;
@@ -220,15 +226,54 @@ int sx126x_hal_configure_tx_params(const struct device *dev, int8_t power,
@@ -183,6 +189,14 @@ int sx126x_hal_init(const struct device *dev)
return ret;
}
+ /* Inactive = LOW = FEM LNA in the receive path, which is the state
+ * every board using this line wants by default. */
+ ret = sx126x_hal_configure_gpio(&config->lna_bypass, GPIO_OUTPUT_INACTIVE,
+ "FEM LNA bypass");
+ if (ret < 0) {
+ return ret;
+ }
+
LOG_DBG("HAL initialized");
return 0;
}
@@ -220,15 +234,54 @@ int sx126x_hal_configure_tx_params(const struct device *dev, int8_t power,
}
tx_power = CLAMP(power, SX1261_MIN_POWER, SX1261_MAX_POWER_TX_PARAM);
} else {
@@ -3063,6 +3228,21 @@ index 34f7089584f..6f4e2a4434c 100644
}
uint8_t buf[2] = { (uint8_t)tx_power, ramp_time };
diff --git a/drivers/lora/native/sx126x/sx126x_hal.h b/drivers/lora/native/sx126x/sx126x_hal.h
index e4b26ba60bd..558890ad54c 100644
--- a/drivers/lora/native/sx126x/sx126x_hal.h
+++ b/drivers/lora/native/sx126x/sx126x_hal.h
@@ -29,6 +29,10 @@ struct sx126x_hal_config {
struct gpio_dt_spec antenna_enable;
struct gpio_dt_spec tx_enable;
struct gpio_dt_spec rx_enable;
+ /* ZephCore: external FEM receive-path select (lna-bypass-gpios). Not
+ * an RF switch -- LOW selects the FEM's LNA, HIGH its bypass path. See
+ * the binding, and sx126x_set_fem_rx_enable() for the runtime knob. */
+ struct gpio_dt_spec lna_bypass;
uint16_t tcxo_startup_delay_ms;
uint8_t dio3_tcxo_voltage;
bool dio2_tx_enable;
diff --git a/drivers/lora/native/sx126x/sx126x_hal_common.c b/drivers/lora/native/sx126x/sx126x_hal_common.c
index b2dfc0d75b5..8ab8a0f5738 100644
--- a/drivers/lora/native/sx126x/sx126x_hal_common.c
@@ -3100,6 +3280,26 @@ index b2dfc0d75b5..8ab8a0f5738 100644
}
return 0;
diff --git a/drivers/lora/native/sx126x/sx126x_hal_stm32wl.c b/drivers/lora/native/sx126x/sx126x_hal_stm32wl.c
index c99a7204a72..08d20e44ae4 100644
--- a/drivers/lora/native/sx126x/sx126x_hal_stm32wl.c
+++ b/drivers/lora/native/sx126x/sx126x_hal_stm32wl.c
@@ -246,6 +246,15 @@ int sx126x_hal_init(const struct device *dev)
return ret;
}
+ /* No STM32WL board here wires one today, but sx126x_set_rf_path() will
+ * drive the pin if a board declares it -- configure it so that is never
+ * a write to an unconfigured GPIO. Inactive = LOW = LNA path. */
+ ret = sx126x_hal_configure_gpio(&config->lna_bypass, GPIO_OUTPUT_INACTIVE,
+ "FEM LNA bypass");
+ if (ret < 0) {
+ return ret;
+ }
+
LOG_DBG("STM32WL HAL initialized");
return 0;
}
diff --git a/drivers/lora/native/sx126x/sx126x_regs.h b/drivers/lora/native/sx126x/sx126x_regs.h
index 7f55c9b96e2..155d7dc2d81 100644
--- a/drivers/lora/native/sx126x/sx126x_regs.h
@@ -3174,3 +3374,32 @@ index 7f55c9b96e2..155d7dc2d81 100644
#define SX126X_TX_TIMEOUT_NONE 0x000000
/* Convert milliseconds to timeout ticks */
diff --git a/dts/bindings/lora/semtech,sx126x-base.yaml b/dts/bindings/lora/semtech,sx126x-base.yaml
index 2d13d758695..04390be84e5 100644
--- a/dts/bindings/lora/semtech,sx126x-base.yaml
+++ b/dts/bindings/lora/semtech,sx126x-base.yaml
@@ -35,6 +35,24 @@ properties:
Antenna switch RX enable GPIO. If set, the driver tracks the
state of the radio and controls the RF switch.
+ lna-bypass-gpios:
+ type: phandle-array
+ description: |
+ Receive-path select of an external front-end module whose RX gain is
+ software-selectable, such as the KCT8103L CTX pin on Heltec boards.
+ LOW routes the receive path through the FEM's LNA, HIGH bypasses it.
+
+ This is NOT an RF switch control. It belongs only on a FEM where the
+ TX/RX path is selected by something else -- on the KCT8103L boards,
+ DIO2 into the FEM's CPS pin -- which is what leaves this line free to
+ mean "LNA or bypass". Never point it at a PA or TXEN enable: the
+ driver asserts it during receive whenever the LNA is switched off,
+ which on a real TXEN would key the amplifier into a receive window.
+
+ Held HIGH while transmitting; in receive it follows
+ sx126x_set_fem_rx_enable() -- LOW for the LNA (the default), HIGH to
+ trade its gain for its supply current.
+
dio1-gpios:
type: phandle-array
description: |