From ea86815c9552a2f256b8e6b77d86bf34f871d480 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:04:32 +0200 Subject: [PATCH] fix LNA/FEM control --- docs/Repeater_CLI_commands.md | 4 +- zephcore/CMakeLists.txt | 57 ++- zephcore/adapters/radio/LoRaRadioBase.h | 17 +- zephcore/adapters/radio/SX126xRadio.cpp | 12 +- zephcore/adapters/radio/SX126xRadio.h | 7 +- .../heltec_wifi_lora32_v43_procpu.dts | 32 +- .../heltec_wireless_tracker_v2/README.md | 13 +- .../heltec_wireless_tracker_v2_procpu.dts | 37 +- .../boards/nrf52840/heltec_t096/README.md | 2 +- .../heltec_t096/heltec_t096_nrf52840.dts | 14 +- .../drivers/lora/native/sx126x/sx126x_ext.h | 30 +- .../zephyr/0003-lora-sx126x-native.patch | 375 ++++++++++++++---- 12 files changed, 459 insertions(+), 141 deletions(-) diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 10d13b4..c1aa27a 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -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 ` | 0, 1, or 2 | Path hashing algorithm | | `set loop.detect ` | `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 ` | `0` (use board default) or 100–30000 | Battery voltage ADC calibration multiplier, set directly. Rejects non-numeric input, NaN/inf and negatives. | | `set adc.multiplier target ` | 3000–4400 mV | Calibrate against a voltage you measured with a multimeter: rescales the current multiplier so the ADC reads ``. 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. | diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index a9a1dba..60ae01e 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -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) diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 22692f0..637886a 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -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; diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index 340f49b..ce26647 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -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; } diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index fd47dbd..18d71ab 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -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) */ diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts index a2e5c66..8539360 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts @@ -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 */ diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/README.md b/zephcore/boards/esp32/heltec_wireless_tracker_v2/README.md index 12ce363..88a1da0 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/README.md +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/README.md @@ -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. diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts index 68a0626..3a02f53 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts @@ -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 = ; diff --git a/zephcore/boards/nrf52840/heltec_t096/README.md b/zephcore/boards/nrf52840/heltec_t096/README.md index c21eff2..daf77b3 100644 --- a/zephcore/boards/nrf52840/heltec_t096/README.md +++ b/zephcore/boards/nrf52840/heltec_t096/README.md @@ -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 | diff --git a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts index b91ce52..38bdc1c 100644 --- a/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts +++ b/zephcore/boards/nrf52840/heltec_t096/heltec_t096_nrf52840.dts @@ -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 = ; diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h index ca86825..29e42a6 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h +++ b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h @@ -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); diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index 92c7992..e30dada 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -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: |