From 74e0c7fa25e059c6456fc2f2aa6155d10771117b Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Wed, 15 Jul 2026 09:56:55 -0400 Subject: [PATCH 1/4] feat(kernel): add generic LoRa device type API Add a LORA_TYPE kernel device type and LoraApi for sub-GHz packet transceivers: enable/disable, modulation selection (LoRa / FSK / LR-FHSS), float-valued per-modulation parameters with documented units, a copy-in TX queue with per-packet progress, and RX/TX/state callbacks. Modulation-agnostic with can_transmit / can_receive capability queries so callers can discover what a given modem supports. Sits alongside the wifi and bluetooth device types (function-specific, not a generic "radio"). Co-Authored-By: Claude Opus 4.8 --- .../include/tactility/drivers/lora.h | 268 ++++++++++++++++++ TactilityKernel/source/drivers/lora.cpp | 83 ++++++ TactilityKernel/source/kernel_symbols.c | 19 ++ 3 files changed, 370 insertions(+) create mode 100644 TactilityKernel/include/tactility/drivers/lora.h create mode 100644 TactilityKernel/source/drivers/lora.cpp diff --git a/TactilityKernel/include/tactility/drivers/lora.h b/TactilityKernel/include/tactility/drivers/lora.h new file mode 100644 index 000000000..c4af3598e --- /dev/null +++ b/TactilityKernel/include/tactility/drivers/lora.h @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +struct Device; + +/** + * Device type and API for sub-GHz packet transceivers such as the Semtech SX126x family. + * The API is LoRa-centric but exposes the modem's other modulation schemes as well. + */ + +enum LoraModulation { + LORA_MODULATION_NONE = 0, + LORA_MODULATION_FSK, + LORA_MODULATION_LORA, + LORA_MODULATION_LR_FHSS, +}; + +/** + * Tunable radio parameters. Values are passed as float; the unit per parameter: + * - POWER: TX output power in dBm + * - BOOSTED_GAIN: boosted RX gain mode, 0 or 1 + * - FREQUENCY: carrier frequency in MHz + * - BANDWIDTH: bandwidth in kHz + * - SPREADING_FACTOR: LoRa spreading factor (7-12) + * - CODING_RATE: LoRa coding rate denominator (5-8 for 4/5-4/8) + * - SYNC_WORD: sync word value + * - PREAMBLE_LENGTH: preamble length in symbols (LoRa) or bits (FSK) + * - FREQUENCY_DEVIATION: FSK frequency deviation in kHz + * - DATA_RATE: FSK bit rate in kbps + * - NARROW_GRID: LR-FHSS grid spacing, 0 (25 kHz) or 1 (3.9 kHz) + * - CURRENT_LIMIT: PA over-current protection limit in mA. Fail-safe: a low limit caps + * the current the PA can push into a bad or disconnected antenna, but also caps the + * achievable output power. Drivers keep a conservative default; a board-aware consumer + * that knows its antenna and PA can raise it to reach full output power. + * + * Which parameters are available depends on the driver and the selected modulation. + */ +enum LoraParameter { + LORA_PARAMETER_POWER = 0, + LORA_PARAMETER_BOOSTED_GAIN, + LORA_PARAMETER_FREQUENCY, + LORA_PARAMETER_BANDWIDTH, + LORA_PARAMETER_SPREADING_FACTOR, + LORA_PARAMETER_CODING_RATE, + LORA_PARAMETER_SYNC_WORD, + LORA_PARAMETER_PREAMBLE_LENGTH, + LORA_PARAMETER_FREQUENCY_DEVIATION, + LORA_PARAMETER_DATA_RATE, + LORA_PARAMETER_NARROW_GRID, + LORA_PARAMETER_CURRENT_LIMIT, +}; + +enum LoraRadioState { + LORA_RADIO_STATE_OFF, + LORA_RADIO_STATE_ON_PENDING, + LORA_RADIO_STATE_ON, + LORA_RADIO_STATE_OFF_PENDING, + LORA_RADIO_STATE_ERROR, +}; + +/** Identifies a queued transmission. Unique per device until it wraps around. */ +typedef int32_t LoraTxId; + +enum LoraTransmissionState { + /** Accepted into the TX queue */ + LORA_TRANSMISSION_STATE_QUEUED, + /** Handed to the modem, waiting for TX-done */ + LORA_TRANSMISSION_STATE_TRANSMIT_PENDING, + /** TX-done confirmed by the modem */ + LORA_TRANSMISSION_STATE_TRANSMITTED, + /** No TX-done within the driver's timeout */ + LORA_TRANSMISSION_STATE_TIMEOUT, + /** The modem rejected the transmission */ + LORA_TRANSMISSION_STATE_ERROR, +}; + +struct LoraRxPacket { + /** Packet payload. Only valid for the duration of the RX callback. */ + const uint8_t* data; + size_t length; + /** Received signal strength in dBm */ + float rssi; + /** Signal-to-noise ratio in dB */ + float snr; +}; + +/** + * Callbacks are invoked without any driver lock held, either from the driver's radio + * thread (RX, TX progress, state) or from the thread calling transmit()/set_enabled() + * (the QUEUED TX event and enable/disable state changes). Taking consumer locks in a + * callback is therefore safe, but keep callbacks short: RX processing stalls while + * they run. After remove_*_callback returns, a callback that was already in flight + * may still complete once — disable the radio before destroying callback context. + */ +typedef void (*LoraStateCallback)(struct Device* device, void* context, enum LoraRadioState state); +typedef void (*LoraRxCallback)(struct Device* device, void* context, const struct LoraRxPacket* packet); +typedef void (*LoraTxCallback)(struct Device* device, void* context, LoraTxId id, enum LoraTransmissionState state); + +struct LoraApi { + /** + * Get the radio state of the device. + * @param[in] device the lora device + * @param[out] state the radio state + * @return ERROR_NONE on success + */ + error_t (*get_radio_state)(struct Device* device, enum LoraRadioState* state); + + /** + * Turn the radio on or off. Requires a modulation to be set before enabling. + * Turning on is asynchronous: observe the radio state to know when it's up. + * @param[in] device the lora device + * @param[in] enabled true to turn the radio on + * @return ERROR_NONE on success + * @retval ERROR_INVALID_STATE when enabling without a modulation set + */ + error_t (*set_enabled)(struct Device* device, bool enabled); + + /** + * Set the modulation scheme. Only allowed while the radio is off. + * @param[in] device the lora device + * @param[in] modulation the modulation scheme + * @return ERROR_NONE on success + * @retval ERROR_INVALID_STATE when the radio is on or turning on + * @retval ERROR_NOT_SUPPORTED when the device supports neither TX nor RX for this modulation + */ + error_t (*set_modulation)(struct Device* device, enum LoraModulation modulation); + + /** + * Get the current modulation scheme. + * @param[in] device the lora device + * @param[out] modulation the modulation scheme + * @return ERROR_NONE on success + */ + error_t (*get_modulation)(struct Device* device, enum LoraModulation* modulation); + + /** + * @param[in] device the lora device + * @param[in] modulation the modulation scheme + * @return true when the device can transmit with the given modulation + */ + bool (*can_transmit)(struct Device* device, enum LoraModulation modulation); + + /** + * @param[in] device the lora device + * @param[in] modulation the modulation scheme + * @return true when the device can receive with the given modulation + */ + bool (*can_receive)(struct Device* device, enum LoraModulation modulation); + + /** + * Set a radio parameter. See LoraParameter for units. + * Parameters apply to the current modulation and take effect the next time the radio turns on. + * @param[in] device the lora device + * @param[in] parameter the parameter to set + * @param[in] value the value to set + * @return ERROR_NONE on success + * @retval ERROR_NOT_SUPPORTED when the parameter doesn't apply to the device or modulation + * @retval ERROR_OUT_OF_RANGE when the value is invalid for the parameter + */ + error_t (*set_parameter)(struct Device* device, enum LoraParameter parameter, float value); + + /** + * Get a radio parameter. See LoraParameter for units. + * @param[in] device the lora device + * @param[in] parameter the parameter to get + * @param[out] value the current value + * @return ERROR_NONE on success + * @retval ERROR_NOT_SUPPORTED when the parameter doesn't apply to the device or modulation + */ + error_t (*get_parameter)(struct Device* device, enum LoraParameter parameter, float* value); + + /** + * Queue a packet for transmission. The data is copied. + * Progress is reported through the TX callbacks, starting with QUEUED. + * @param[in] device the lora device + * @param[in] data the packet payload + * @param[in] length the payload length in bytes + * @param[out] id the id assigned to this transmission (optional, can be NULL) + * @return ERROR_NONE on success + */ + error_t (*transmit)(struct Device* device, const uint8_t* data, size_t length, LoraTxId* id); + + /** + * Add a callback for received packets. + * @param[in] device the lora device + * @param[in] callback_context the context to pass to the callback + * @param[in] callback the callback function + * @return ERROR_NONE on success + */ + error_t (*add_rx_callback)(struct Device* device, void* callback_context, LoraRxCallback callback); + + /** + * Remove a callback for received packets. + * @param[in] device the lora device + * @param[in] callback the callback function + * @return ERROR_NONE on success + */ + error_t (*remove_rx_callback)(struct Device* device, LoraRxCallback callback); + + /** + * Add a callback for radio state changes. + * @param[in] device the lora device + * @param[in] callback_context the context to pass to the callback + * @param[in] callback the callback function + * @return ERROR_NONE on success + */ + error_t (*add_state_callback)(struct Device* device, void* callback_context, LoraStateCallback callback); + + /** + * Remove a callback for radio state changes. + * @param[in] device the lora device + * @param[in] callback the callback function + * @return ERROR_NONE on success + */ + error_t (*remove_state_callback)(struct Device* device, LoraStateCallback callback); + + /** + * Add a callback for transmission progress. + * @param[in] device the lora device + * @param[in] callback_context the context to pass to the callback + * @param[in] callback the callback function + * @return ERROR_NONE on success + */ + error_t (*add_tx_callback)(struct Device* device, void* callback_context, LoraTxCallback callback); + + /** + * Remove a callback for transmission progress. + * @param[in] device the lora device + * @param[in] callback the callback function + * @return ERROR_NONE on success + */ + error_t (*remove_tx_callback)(struct Device* device, LoraTxCallback callback); +}; + +extern const struct DeviceType LORA_TYPE; + +/** @return the first registered lora device, regardless of started state, or NULL if none exists */ +struct Device* lora_find_first_registered_device(void); + +error_t lora_get_radio_state(struct Device* device, enum LoraRadioState* state); +error_t lora_set_enabled(struct Device* device, bool enabled); +error_t lora_set_modulation(struct Device* device, enum LoraModulation modulation); +error_t lora_get_modulation(struct Device* device, enum LoraModulation* modulation); +bool lora_can_transmit(struct Device* device, enum LoraModulation modulation); +bool lora_can_receive(struct Device* device, enum LoraModulation modulation); +error_t lora_set_parameter(struct Device* device, enum LoraParameter parameter, float value); +error_t lora_get_parameter(struct Device* device, enum LoraParameter parameter, float* value); +error_t lora_transmit(struct Device* device, const uint8_t* data, size_t length, LoraTxId* id); +error_t lora_add_rx_callback(struct Device* device, void* callback_context, LoraRxCallback callback); +error_t lora_remove_rx_callback(struct Device* device, LoraRxCallback callback); +error_t lora_add_state_callback(struct Device* device, void* callback_context, LoraStateCallback callback); +error_t lora_remove_state_callback(struct Device* device, LoraStateCallback callback); +error_t lora_add_tx_callback(struct Device* device, void* callback_context, LoraTxCallback callback); +error_t lora_remove_tx_callback(struct Device* device, LoraTxCallback callback); + +#ifdef __cplusplus +} +#endif diff --git a/TactilityKernel/source/drivers/lora.cpp b/TactilityKernel/source/drivers/lora.cpp new file mode 100644 index 000000000..3b692690f --- /dev/null +++ b/TactilityKernel/source/drivers/lora.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +#define LORA_API(device) ((const struct LoraApi*)device_get_driver(device)->api) + +extern "C" { + +struct Device* lora_find_first_registered_device() { + struct Device* found = nullptr; + device_for_each_of_type(&LORA_TYPE, &found, [](struct Device* dev, void* ctx) -> bool { + *static_cast(ctx) = dev; + return false; + }); + return found; +} + +error_t lora_get_radio_state(struct Device* device, enum LoraRadioState* state) { + return LORA_API(device)->get_radio_state(device, state); +} + +error_t lora_set_enabled(struct Device* device, bool enabled) { + return LORA_API(device)->set_enabled(device, enabled); +} + +error_t lora_set_modulation(struct Device* device, enum LoraModulation modulation) { + return LORA_API(device)->set_modulation(device, modulation); +} + +error_t lora_get_modulation(struct Device* device, enum LoraModulation* modulation) { + return LORA_API(device)->get_modulation(device, modulation); +} + +bool lora_can_transmit(struct Device* device, enum LoraModulation modulation) { + return LORA_API(device)->can_transmit(device, modulation); +} + +bool lora_can_receive(struct Device* device, enum LoraModulation modulation) { + return LORA_API(device)->can_receive(device, modulation); +} + +error_t lora_set_parameter(struct Device* device, enum LoraParameter parameter, float value) { + return LORA_API(device)->set_parameter(device, parameter, value); +} + +error_t lora_get_parameter(struct Device* device, enum LoraParameter parameter, float* value) { + return LORA_API(device)->get_parameter(device, parameter, value); +} + +error_t lora_transmit(struct Device* device, const uint8_t* data, size_t length, LoraTxId* id) { + return LORA_API(device)->transmit(device, data, length, id); +} + +error_t lora_add_rx_callback(struct Device* device, void* callback_context, LoraRxCallback callback) { + return LORA_API(device)->add_rx_callback(device, callback_context, callback); +} + +error_t lora_remove_rx_callback(struct Device* device, LoraRxCallback callback) { + return LORA_API(device)->remove_rx_callback(device, callback); +} + +error_t lora_add_state_callback(struct Device* device, void* callback_context, LoraStateCallback callback) { + return LORA_API(device)->add_state_callback(device, callback_context, callback); +} + +error_t lora_remove_state_callback(struct Device* device, LoraStateCallback callback) { + return LORA_API(device)->remove_state_callback(device, callback); +} + +error_t lora_add_tx_callback(struct Device* device, void* callback_context, LoraTxCallback callback) { + return LORA_API(device)->add_tx_callback(device, callback_context, callback); +} + +error_t lora_remove_tx_callback(struct Device* device, LoraTxCallback callback) { + return LORA_API(device)->remove_tx_callback(device, callback); +} + +const struct DeviceType LORA_TYPE = { + .name = "lora" +}; + +} // extern "C" diff --git a/TactilityKernel/source/kernel_symbols.c b/TactilityKernel/source/kernel_symbols.c index 1a627c5ac..43f0bcfaa 100644 --- a/TactilityKernel/source/kernel_symbols.c +++ b/TactilityKernel/source/kernel_symbols.c @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -243,6 +244,24 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(wifi_add_event_callback), DEFINE_MODULE_SYMBOL(wifi_remove_event_callback), DEFINE_MODULE_SYMBOL(WIFI_TYPE), + // drivers/lora + DEFINE_MODULE_SYMBOL(lora_find_first_registered_device), + DEFINE_MODULE_SYMBOL(lora_get_radio_state), + DEFINE_MODULE_SYMBOL(lora_set_enabled), + DEFINE_MODULE_SYMBOL(lora_set_modulation), + DEFINE_MODULE_SYMBOL(lora_get_modulation), + DEFINE_MODULE_SYMBOL(lora_can_transmit), + DEFINE_MODULE_SYMBOL(lora_can_receive), + DEFINE_MODULE_SYMBOL(lora_set_parameter), + DEFINE_MODULE_SYMBOL(lora_get_parameter), + DEFINE_MODULE_SYMBOL(lora_transmit), + DEFINE_MODULE_SYMBOL(lora_add_rx_callback), + DEFINE_MODULE_SYMBOL(lora_remove_rx_callback), + DEFINE_MODULE_SYMBOL(lora_add_state_callback), + DEFINE_MODULE_SYMBOL(lora_remove_state_callback), + DEFINE_MODULE_SYMBOL(lora_add_tx_callback), + DEFINE_MODULE_SYMBOL(lora_remove_tx_callback), + DEFINE_MODULE_SYMBOL(LORA_TYPE), // drivers/usb_host_hid DEFINE_MODULE_SYMBOL(usb_host_hid_is_connected), DEFINE_MODULE_SYMBOL(USB_HOST_HID_TYPE), From e0ae58cb78b0495b06fa42592cb2fb1ea702f32a Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Wed, 15 Jul 2026 09:57:17 -0400 Subject: [PATCH 2/4] feat(drivers): add sx126x-module kernel driver for the Semtech SX1262 Implement the lora device type for the Semtech SX1262 on top of RadioLib (MIT, managed component jgromes/radiolib). Registers a lora device as a child of an SPI controller; CS comes from the parent's cs-gpios entry. RadioLib is kept behind a RadioParts pimpl so its global `class Module` never shares a translation unit with the kernel's `struct Module`. - Runs the radio on a dedicated thread with an event-group mailbox; callbacks fire without the driver lock held (snapshot + release) so consumers can take their own locks in callbacks without an AB-BA deadlock. - DIO1 uses the kernel GPIO descriptor callback API in HIGH_LEVEL mode, armed per cycle and masked by the ISR on each fire. Edge interrupts are avoided on purpose (ESP32 erratum 3.11: a subsequent edge interrupt may be missed, which for a radio would drop an RX/TX-done and stall the thread). - SPI exchanges run inside the kernel SPI controller lock (outer) and ESP-IDF's per-host bus lock (inner), so a manually-CS'd RadioLib exchange is atomic against both cooperating kernel drivers and the IDF-managed display/SD devices. - start_device selects the antenna path, powers the module and runs a GPIO-only probe (reset pulse -> wait for BUSY low) so an absent/miswired module fails at device start; the modem is configured only when the radio is enabled. - CS/RESET/BUSY/DIO1 must be SoC GPIOs (the RadioLib HAL drives them directly); the optional enable and antenna-select lines may sit behind an IO expander. - TX-done timeout is derived from time-on-air; PA over-current protection defaults to RadioLib's fail-safe 60 mA and is raisable via LORA_PARAMETER_CURRENT_LIMIT. Co-Authored-By: Claude Opus 4.8 --- Drivers/sx126x-module/CMakeLists.txt | 12 + Drivers/sx126x-module/LICENSE-Apache-2.0.md | 195 ++++ Drivers/sx126x-module/README.md | 67 ++ .../bindings/semtech,sx1262.yaml | 43 + Drivers/sx126x-module/devicetree.yaml | 3 + .../sx126x-module/include/bindings/sx1262.h | 15 + .../sx126x-module/include/drivers/sx1262.h | 36 + Drivers/sx126x-module/include/sx126x_module.h | 14 + .../private/drivers/sx1262_radio.cpp | 946 ++++++++++++++++++ .../private/drivers/sx1262_radio.h | 173 ++++ .../private/drivers/sx126x_radiolib_hal.cpp | 144 +++ .../private/drivers/sx126x_radiolib_hal.h | 65 ++ Drivers/sx126x-module/source/module.cpp | 32 + Drivers/sx126x-module/source/sx1262.cpp | 371 +++++++ Firmware/idf_component.yml | 4 + 15 files changed, 2120 insertions(+) create mode 100644 Drivers/sx126x-module/CMakeLists.txt create mode 100644 Drivers/sx126x-module/LICENSE-Apache-2.0.md create mode 100644 Drivers/sx126x-module/README.md create mode 100644 Drivers/sx126x-module/bindings/semtech,sx1262.yaml create mode 100644 Drivers/sx126x-module/devicetree.yaml create mode 100644 Drivers/sx126x-module/include/bindings/sx1262.h create mode 100644 Drivers/sx126x-module/include/drivers/sx1262.h create mode 100644 Drivers/sx126x-module/include/sx126x_module.h create mode 100644 Drivers/sx126x-module/private/drivers/sx1262_radio.cpp create mode 100644 Drivers/sx126x-module/private/drivers/sx1262_radio.h create mode 100644 Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp create mode 100644 Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.h create mode 100644 Drivers/sx126x-module/source/module.cpp create mode 100644 Drivers/sx126x-module/source/sx1262.cpp diff --git a/Drivers/sx126x-module/CMakeLists.txt b/Drivers/sx126x-module/CMakeLists.txt new file mode 100644 index 000000000..51c7627d1 --- /dev/null +++ b/Drivers/sx126x-module/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*" "private/*.c*") + +tactility_add_module(sx126x-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + PRIV_INCLUDE_DIRS private/ + REQUIRES TactilityKernel platform-esp32 radiolib driver esp_timer +) diff --git a/Drivers/sx126x-module/LICENSE-Apache-2.0.md b/Drivers/sx126x-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Drivers/sx126x-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Drivers/sx126x-module/README.md b/Drivers/sx126x-module/README.md new file mode 100644 index 000000000..eda3fc39e --- /dev/null +++ b/Drivers/sx126x-module/README.md @@ -0,0 +1,67 @@ +# SX126x driver module + +Kernel driver for the Semtech SX1262 sub-GHz LoRa and (G)FSK transceiver, built on +[RadioLib](https://github.com/jgromes/RadioLib). It registers a `lora` device +(see ``) that supports LoRa and FSK for TX/RX and LR-FHSS for TX. + +The device is a child of an SPI controller. The chip-select line comes from the parent's +`cs-gpios` entry matching the node's unit address. The reset, busy and DIO1 lines must be +SoC GPIOs (the RadioLib HAL drives them directly); the optional enable and antenna-select +lines can live on any GPIO controller, including IO expanders, and are driven to their +active level while the device is started. + +Example (LilyGO T-Deck Max): + +```dts +spi0 { + compatible = "espressif,esp32-spi"; + cs-gpios = <&gpio0 34 GPIO_FLAG_NONE>, // 0: EPD display + <&gpio0 48 GPIO_FLAG_NONE>, // 1: SD card + <&gpio0 3 GPIO_FLAG_NONE>; // 2: LoRa radio (SX1262) + /* ... */ + + radio@2 { + compatible = "semtech,sx1262"; + pin-reset = <&gpio0 4 GPIO_FLAG_NONE>; + pin-dio1 = <&gpio0 5 GPIO_FLAG_NONE>; + pin-busy = <&gpio0 6 GPIO_FLAG_NONE>; + pin-enable = <&xl9555 1 GPIO_FLAG_NONE>; + pin-antenna-select = <&xl9555 4 GPIO_FLAG_NONE>; + tcxo-millivolts = <2400>; + dio2-as-rf-switch; + }; +}; +``` + +Notes: + +- Starting the device selects the antenna path, powers the module and runs a GPIO-only + probe (reset pulse, then wait for the chip to drive BUSY low), so an absent or + unpowered module fails at device start. The modem itself is initialized when the + radio is enabled via `lora_set_enabled()`, which requires a modulation to be set first. +- Bus sharing: RadioLib drives the chip-select manually across multiple transfers, so + each exchange must be atomic on the bus. It runs inside two nested locks — the kernel + SPI controller lock (`spi_controller_lock`, the arbiter other kernel drivers on the + host cooperate through) as the outer lock, and ESP-IDF's per-host bus lock + (`spi_device_acquire_bus`) as the inner one, which additionally blocks the IDF-managed + spi_master devices (displays, SD cards) that don't take the controller lock. +- TX-done is waited for using the packet's time-on-air plus a fixed margin, so slow + configurations (high spreading factor / narrow bandwidth) don't false-time-out. +- Over-current protection defaults to RadioLib's fail-safe 60 mA, which caps the PA + current into a bad or disconnected antenna but also caps output below +22 dBm. A + board-aware consumer that knows its antenna and PA can raise it via + `LORA_PARAMETER_CURRENT_LIMIT` (up to 140 mA) to reach full output power. +- Radio activity is logged at INFO level (state changes, modem config, TX/RX events); + per-call detail sits at DEBUG, which is compiled out unless the sdkconfig log level + is raised. + +## Roadmap / not yet implemented + +- FSK addressed transmission is not exposed through the lora API yet. +- Antenna-presence detection. The SX1262 has no hardware antenna-detect pin (unlike, e.g., + the u-blox SARA modules' dedicated ANT_DET ADC circuit), and the T-Deck Max antenna + connector is not hot-swap-rated. The intended approach is a software heuristic on the + radio thread — watching the RX noise floor / RSSI for the signature of a + disconnected/open PA load and disabling TX at the driver layer when detected — so the + protection lives below the app/OS. This is unproven and deliberately not built yet; + feedback on the method is welcome before implementing it. diff --git a/Drivers/sx126x-module/bindings/semtech,sx1262.yaml b/Drivers/sx126x-module/bindings/semtech,sx1262.yaml new file mode 100644 index 000000000..473af6da2 --- /dev/null +++ b/Drivers/sx126x-module/bindings/semtech,sx1262.yaml @@ -0,0 +1,43 @@ +description: Semtech SX1262 sub-GHz LoRa and (G)FSK transceiver + +compatible: "semtech,sx1262" + +bus: spi + +properties: + spi-frequency-khz: + type: int + default: 4000 + description: SPI clock frequency in kHz + pin-reset: + type: phandles + required: true + description: NRESET line (must be an SoC GPIO) + pin-busy: + type: phandles + required: true + description: BUSY line (must be an SoC GPIO) + pin-dio1: + type: phandles + required: true + description: DIO1 interrupt line (must be an SoC GPIO) + pin-enable: + type: phandles + default: GPIO_PIN_SPEC_NONE + description: Optional module power enable, driven active while the device is started (any GPIO controller) + pin-antenna-select: + type: phandles + default: GPIO_PIN_SPEC_NONE + description: Optional antenna select, driven active while the device is started (any GPIO controller) + tcxo-millivolts: + type: int + default: 0 + description: TCXO reference voltage on DIO3 in millivolts, 0 when no TCXO is fitted + use-regulator-ldo: + type: boolean + default: false + description: Use the LDO regulator instead of the DC-DC converter + dio2-as-rf-switch: + type: boolean + default: false + description: DIO2 drives the antenna TX/RX switch diff --git a/Drivers/sx126x-module/devicetree.yaml b/Drivers/sx126x-module/devicetree.yaml new file mode 100644 index 000000000..a07d6f334 --- /dev/null +++ b/Drivers/sx126x-module/devicetree.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel +bindings: bindings diff --git a/Drivers/sx126x-module/include/bindings/sx1262.h b/Drivers/sx126x-module/include/bindings/sx1262.h new file mode 100644 index 000000000..cd0f7a916 --- /dev/null +++ b/Drivers/sx126x-module/include/bindings/sx1262.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +DEFINE_DEVICETREE(sx1262, struct Sx1262Config) + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/sx126x-module/include/drivers/sx1262.h b/Drivers/sx126x-module/include/drivers/sx1262.h new file mode 100644 index 000000000..682d93b16 --- /dev/null +++ b/Drivers/sx126x-module/include/drivers/sx1262.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** Field order must match the property order in bindings/semtech,sx1262.yaml */ +struct Sx1262Config { + /** SPI clock frequency in kHz */ + uint32_t spi_frequency_khz; + /** NRESET line (must be an SoC GPIO) */ + struct GpioPinSpec pin_reset; + /** BUSY line (must be an SoC GPIO) */ + struct GpioPinSpec pin_busy; + /** DIO1 interrupt line (must be an SoC GPIO) */ + struct GpioPinSpec pin_dio1; + /** Optional module power enable, driven active while the device is started */ + struct GpioPinSpec pin_enable; + /** Optional antenna select, driven active while the device is started */ + struct GpioPinSpec pin_antenna_select; + /** TCXO reference voltage on DIO3 in millivolts, 0 when no TCXO is fitted */ + uint32_t tcxo_millivolts; + /** Use the LDO regulator instead of the DC-DC converter */ + bool use_regulator_ldo; + /** DIO2 drives the antenna TX/RX switch */ + bool dio2_as_rf_switch; +}; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/sx126x-module/include/sx126x_module.h b/Drivers/sx126x-module/include/sx126x_module.h new file mode 100644 index 000000000..7e46ee910 --- /dev/null +++ b/Drivers/sx126x-module/include/sx126x_module.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module sx126x_module; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp b/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp new file mode 100644 index 000000000..0d03dc67b --- /dev/null +++ b/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp @@ -0,0 +1,946 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "sx1262_radio.h" + +#include "sx126x_radiolib_hal.h" + +#include +#include +#include +#include + +#include +#include + +#include + +#define TAG "sx1262" + +namespace { + +// TX-done wait is derived from the packet's time-on-air: fixed timeouts either +// false-time-out on slow configs (high SF / narrow BW, airtime up to seconds) or +// wait needlessly long on fast ones. The margin covers PA ramp and command latency; +// the fallback is used only when RadioLib can't compute airtime for the modem config. +constexpr auto SX1262_TX_TIMEOUT_MARGIN_MILLIS = 1000; +constexpr auto SX1262_TX_TIMEOUT_FALLBACK_MILLIS = 2000; +constexpr uint32_t SX1262_INTERRUPT_BIT = (1 << 0); +constexpr uint32_t SX1262_DIO1_EVENT_BIT = (1 << 1); +constexpr uint32_t SX1262_QUEUED_TX_BIT = (1 << 2); +constexpr auto SX1262_IRQ_FLAGS = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; +// RX callbacks run on the radio thread and may do non-trivial work (e.g. packet decryption) +constexpr size_t SX1262_THREAD_STACK_SIZE = 8192; + +const char* toString(enum LoraRadioState state) { + switch (state) { + case LORA_RADIO_STATE_OFF: + return "off"; + case LORA_RADIO_STATE_ON_PENDING: + return "on-pending"; + case LORA_RADIO_STATE_ON: + return "on"; + case LORA_RADIO_STATE_OFF_PENDING: + return "off-pending"; + case LORA_RADIO_STATE_ERROR: + return "error"; + default: + return "unknown"; + } +} + +const char* toString(enum LoraModulation modulation) { + switch (modulation) { + case LORA_MODULATION_NONE: + return "none"; + case LORA_MODULATION_FSK: + return "FSK"; + case LORA_MODULATION_LORA: + return "LoRa"; + case LORA_MODULATION_LR_FHSS: + return "LR-FHSS"; + default: + return "unknown"; + } +} + +const char* toString(enum LoraParameter parameter) { + switch (parameter) { + case LORA_PARAMETER_POWER: + return "power"; + case LORA_PARAMETER_BOOSTED_GAIN: + return "boosted gain"; + case LORA_PARAMETER_FREQUENCY: + return "frequency"; + case LORA_PARAMETER_BANDWIDTH: + return "bandwidth"; + case LORA_PARAMETER_SPREADING_FACTOR: + return "spreading factor"; + case LORA_PARAMETER_CODING_RATE: + return "coding rate"; + case LORA_PARAMETER_SYNC_WORD: + return "sync word"; + case LORA_PARAMETER_PREAMBLE_LENGTH: + return "preamble length"; + case LORA_PARAMETER_FREQUENCY_DEVIATION: + return "frequency deviation"; + case LORA_PARAMETER_DATA_RATE: + return "data rate"; + case LORA_PARAMETER_NARROW_GRID: + return "narrow grid"; + default: + return "unknown"; + } +} + +template +constexpr error_t checkLimitsAndApply(T& target, const float value, const float lower, const float upper, const unsigned step = 0) { + if ((value >= lower) && (value <= upper)) { + if (step != 0) { + int ivalue = static_cast(value); + if ((ivalue % step) != 0) { + return ERROR_OUT_OF_RANGE; + } + } + + target = static_cast(value); + return ERROR_NONE; + } + return ERROR_OUT_OF_RANGE; +} + +template +constexpr error_t checkValuesAndApply(T& target, const float value, std::initializer_list valids) { + for (float valid : valids) { + if (value == valid) { + target = static_cast(value); + return ERROR_NONE; + } + } + return ERROR_OUT_OF_RANGE; +} + +} // namespace + +struct Sx1262Radio::RadioParts { + Sx126xRadiolibHal hal; + Module radioModule; + SX1262 radio; + + explicit RadioParts(const Settings& settings) + : hal(settings.spi_host, settings.spi_frequency_hz, settings.spi_controller) + , radioModule(&hal, settings.pin_cs, RADIOLIB_NC, settings.pin_reset, settings.pin_busy) + , radio(&radioModule) {} +}; + +Sx1262Radio::Sx1262Radio(const Settings& settings) + : settings(settings) { + recursive_mutex_construct(&mutex); + event_group_construct(&events); + parts = new RadioParts(settings); +} + +Sx1262Radio::~Sx1262Radio() { + setEnabled(false); + delete parts; + event_group_destruct(&events); + recursive_mutex_destruct(&mutex); +} + +error_t Sx1262Radio::probe() const { + // NRESET output, idle high. BUSY input with a pull-up: an absent or unpowered + // module leaves BUSY floating, and the pull-up parks it high so the ready + // check below can't false-pass on a floating line. + gpio_config_t reset_conf = { + .pin_bit_mask = (1ULL << settings.pin_reset), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&reset_conf); + gpio_set_level(settings.pin_reset, 1); + + gpio_config_t busy_conf = { + .pin_bit_mask = (1ULL << settings.pin_busy), + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + gpio_config(&busy_conf); + + // Reset pulse (datasheet: NRESET low for >= 100 us triggers a full reset) + gpio_set_level(settings.pin_reset, 0); + delay_millis(2); + gpio_set_level(settings.pin_reset, 1); + + // After reset the chip boots and calibrates with BUSY high, then drives BUSY + // low once it reaches STDBY_RC (datasheet: ~3.5 ms max). Allow a generous + // margin; a line stuck high means no chip is answering. + constexpr auto PROBE_TIMEOUT_MILLIS = 20; + int elapsed = 0; + while (gpio_get_level(settings.pin_busy) != 0) { + if (elapsed >= PROBE_TIMEOUT_MILLIS) { + LOG_E(TAG, "Probe failed: BUSY (GPIO %d) stuck high after reset — module absent or unpowered?", settings.pin_busy); + return ERROR_RESOURCE; + } + delay_millis(1); + elapsed++; + } + + // Drop the probe pull-up again: the chip actively drives BUSY when powered, + // and RadioLib reconfigures the pin at begin() anyway. + busy_conf.pull_up_en = GPIO_PULLUP_DISABLE; + gpio_config(&busy_conf); + + LOG_I(TAG, "Probe OK: SX1262 answered reset in ~%d ms (BUSY low)", elapsed); + return ERROR_NONE; +} + +// region Thread lifecycle + +void Sx1262Radio::dio1Isr(void* context) { + auto* self = static_cast(context); + // DIO1 is armed as a HIGH_LEVEL interrupt (edge types are unreliable on the + // ESP32 per erratum 3.11). A level interrupt re-fires for as long as the line + // is asserted, so mask it here and let the radio thread re-arm once it has + // cleared the modem IRQ (which drops DIO1 low again). + gpio_descriptor_disable_interrupt(self->settings.dio1); + event_group_set(self->events, SX1262_DIO1_EVENT_BIT); +} + +int32_t Sx1262Radio::threadMainStatic(void* context) { + return static_cast(context)->threadMain(); +} + +bool Sx1262Radio::isThreadInterrupted() const { + lock(); + const bool interrupted = threadInterrupted; + unlock(); + return interrupted; +} + +int32_t Sx1262Radio::threadMain() { + int rc = doBegin(getModulation()); + bool hasRx = false; + if (rc != 0) { + return rc; + } + setState(LORA_RADIO_STATE_ON); + + while (!isThreadInterrupted()) { + // Re-arm DIO1: the ISR masks the HIGH_LEVEL interrupt on each fire, so the + // modem's next RX/TX-done needs it enabled again. DIO1 is low here (the + // previous IRQ was cleared by doReceive()/doTransmit()); re-arming while it + // were still asserted would just self-fire once and be absorbed by the + // empty-read guard in doReceive(). + gpio_descriptor_enable_interrupt(settings.dio1); + + hasRx = doListen(); + + // Thread might've been interrupted in the meanwhile + if (isThreadInterrupted()) { + break; + } + + if (getTxQueueSize() > 0) { + doTransmit(); + } else { + if (hasRx) { + doReceive(); + } + } + } + + doEnd(); + return 0; +} + +error_t Sx1262Radio::setEnabled(bool enabled) { + lock(); + + if (enabled) { + if ((thread != nullptr) && (thread_get_state(thread) != THREAD_STATE_STOPPED)) { + LOG_W(TAG, "Already started"); + unlock(); + return ERROR_NONE; + } + + if (modulation == LORA_MODULATION_NONE) { + LOG_E(TAG, "Cannot enable without a modulation set"); + unlock(); + return ERROR_INVALID_STATE; + } + + if (thread != nullptr) { + thread_free(thread); + thread = nullptr; + } + + threadInterrupted = false; + setState(LORA_RADIO_STATE_ON_PENDING); + + thread = thread_alloc_full("SX1262", SX1262_THREAD_STACK_SIZE, threadMainStatic, this, tskNO_AFFINITY); + if (thread == nullptr) { + setState(LORA_RADIO_STATE_ERROR); + unlock(); + return ERROR_OUT_OF_MEMORY; + } + thread_set_priority(thread, THREAD_PRIORITY_HIGH); + + if (thread_start(thread) != ERROR_NONE) { + thread_free(thread); + thread = nullptr; + setState(LORA_RADIO_STATE_ERROR); + unlock(); + return ERROR_UNDEFINED; + } + + unlock(); + return ERROR_NONE; + } else { + setState(LORA_RADIO_STATE_OFF_PENDING); + + if (thread != nullptr) { + threadInterrupted = true; + event_group_set(events, SX1262_INTERRUPT_BIT); + + Thread* oldThread = thread; + thread = nullptr; + + if (thread_get_state(oldThread) != THREAD_STATE_STOPPED) { + // Unlock so the thread can lock + unlock(); + // Wait for the thread to finish + thread_join(oldThread, portMAX_DELAY, pdMS_TO_TICKS(10)); + // Re-lock to continue logic below + lock(); + } + + thread_free(oldThread); + } + + setState(LORA_RADIO_STATE_OFF); + unlock(); + return ERROR_NONE; + } +} + +// endregion + +// region State, modulation and callbacks + +enum LoraRadioState Sx1262Radio::getState() const { + lock(); + const auto result = state; + unlock(); + return result; +} + +void Sx1262Radio::setState(enum LoraRadioState newState) { + lock(); + if (state == newState) { + unlock(); + return; + } + LOG_I(TAG, "State: %s -> %s", toString(state), toString(newState)); + state = newState; + auto callbacks = stateCallbacks; + unlock(); + + for (const auto& entry : callbacks) { + entry.callback(settings.device, entry.context, newState); + } +} + +error_t Sx1262Radio::setModulation(enum LoraModulation newModulation) { + const auto currentState = getState(); + if ((currentState == LORA_RADIO_STATE_ON_PENDING) || (currentState == LORA_RADIO_STATE_ON)) { + return ERROR_INVALID_STATE; + } + + if (!((newModulation == LORA_MODULATION_NONE) || canTransmit(newModulation) || canReceive(newModulation))) { + return ERROR_NOT_SUPPORTED; + } + + lock(); + LOG_I(TAG, "Modulation set to %s", toString(newModulation)); + modulation = newModulation; + unlock(); + return ERROR_NONE; +} + +enum LoraModulation Sx1262Radio::getModulation() const { + lock(); + const auto result = modulation; + unlock(); + return result; +} + +// Callbacks are invoked on a snapshot of the list, with the radio mutex released: +// consumers take their own locks in callbacks and also call into this API while +// holding those locks, so invoking under the radio mutex would set up an AB-BA +// deadlock between the radio thread and any consumer thread. +void Sx1262Radio::publishRx(const struct LoraRxPacket& packet) { + lock(); + auto callbacks = rxCallbacks; + unlock(); + + for (const auto& entry : callbacks) { + entry.callback(settings.device, entry.context, &packet); + } +} + +void Sx1262Radio::publishTx(LoraTxId id, enum LoraTransmissionState txState) { + lock(); + auto callbacks = txCallbacks; + unlock(); + + for (const auto& entry : callbacks) { + entry.callback(settings.device, entry.context, id, txState); + } +} + +error_t Sx1262Radio::addRxCallback(void* context, LoraRxCallback callback) { + lock(); + rxCallbacks.push_back({context, callback}); + unlock(); + return ERROR_NONE; +} + +error_t Sx1262Radio::removeRxCallback(LoraRxCallback callback) { + lock(); + const auto old_size = rxCallbacks.size(); + std::erase_if(rxCallbacks, [callback](const auto& entry) { return entry.callback == callback; }); + const auto result = (rxCallbacks.size() == old_size) ? ERROR_NOT_FOUND : ERROR_NONE; + unlock(); + return result; +} + +error_t Sx1262Radio::addStateCallback(void* context, LoraStateCallback callback) { + lock(); + stateCallbacks.push_back({context, callback}); + unlock(); + return ERROR_NONE; +} + +error_t Sx1262Radio::removeStateCallback(LoraStateCallback callback) { + lock(); + const auto old_size = stateCallbacks.size(); + std::erase_if(stateCallbacks, [callback](const auto& entry) { return entry.callback == callback; }); + const auto result = (stateCallbacks.size() == old_size) ? ERROR_NOT_FOUND : ERROR_NONE; + unlock(); + return result; +} + +error_t Sx1262Radio::addTxCallback(void* context, LoraTxCallback callback) { + lock(); + txCallbacks.push_back({context, callback}); + unlock(); + return ERROR_NONE; +} + +error_t Sx1262Radio::removeTxCallback(LoraTxCallback callback) { + lock(); + const auto old_size = txCallbacks.size(); + std::erase_if(txCallbacks, [callback](const auto& entry) { return entry.callback == callback; }); + const auto result = (txCallbacks.size() == old_size) ? ERROR_NOT_FOUND : ERROR_NONE; + unlock(); + return result; +} + +// endregion + +// region TX queue + +size_t Sx1262Radio::getTxQueueSize() const { + lock(); + const auto size = txQueue.size(); + unlock(); + return size; +} + +Sx1262Radio::TxItem Sx1262Radio::popNextQueuedTx() { + lock(); + auto tx = std::move(txQueue.front()); + txQueue.pop_front(); + unlock(); + return tx; +} + +error_t Sx1262Radio::transmit(const uint8_t* data, size_t length, LoraTxId* id) { + lock(); + const auto txId = lastTxId; + lastTxId++; + txQueue.push_back(TxItem {.id = txId, .data = std::vector(data, data + length)}); + LOG_D(TAG, "TX id=%d queued: %u bytes (queue depth %u)", (int)txId, (unsigned)length, (unsigned)txQueue.size()); + unlock(); + + publishTx(txId, LORA_TRANSMISSION_STATE_QUEUED); + event_group_set(events, SX1262_QUEUED_TX_BIT); + + if (id != nullptr) { + *id = txId; + } + return ERROR_NONE; +} + +// endregion + +// region Parameters + +error_t Sx1262Radio::setBaseParameter(enum LoraParameter parameter, float value) { + switch (parameter) { + case LORA_PARAMETER_POWER: + return checkLimitsAndApply(power, value, -9.0, 22.0); + case LORA_PARAMETER_BOOSTED_GAIN: + return checkLimitsAndApply(boostedGain, value, 0.0, 1.0, 1); + case LORA_PARAMETER_CURRENT_LIMIT: + // SX1262 OCP range is 0..140 mA (RadioLib clamps to a 2.5 mA step internally). + return checkLimitsAndApply(currentLimit, value, 0.0, 140.0); + default: + return ERROR_NOT_SUPPORTED; + } +} + +error_t Sx1262Radio::setLoraParameter(enum LoraParameter parameter, float value) { + switch (parameter) { + case LORA_PARAMETER_FREQUENCY: + return checkLimitsAndApply(frequency, value, 150.0, 960.0); + case LORA_PARAMETER_BANDWIDTH: + return checkValuesAndApply(bandwidth, value, {7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125.0, 250.0, 500.0}); + case LORA_PARAMETER_SPREADING_FACTOR: + return checkLimitsAndApply(spreadingFactor, value, 7.0, 12.0, 1); + case LORA_PARAMETER_CODING_RATE: + return checkLimitsAndApply(codingRate, value, 5.0, 8.0, 1); + case LORA_PARAMETER_SYNC_WORD: + return checkLimitsAndApply(syncWord, value, 0.0, 255.0, 1); + case LORA_PARAMETER_PREAMBLE_LENGTH: + return checkLimitsAndApply(preambleLength, value, 0.0, 65535.0, 1); + default: + break; + } + + LOG_W(TAG, "Tried to set unsupported LoRa parameter \"%s\" to %f", toString(parameter), value); + return ERROR_NOT_SUPPORTED; +} + +error_t Sx1262Radio::setFskParameter(enum LoraParameter parameter, float value) { + switch (parameter) { + case LORA_PARAMETER_FREQUENCY: + return checkLimitsAndApply(frequency, value, 150.0, 960.0); + case LORA_PARAMETER_BANDWIDTH: + return checkValuesAndApply(bandwidth, value, {4.8, 5.8, 7.3, 9.7, 11.7, 14.6, 19.5, 23.4, 29.3, 39.0, 46.9, 58.6, 78.2}); + case LORA_PARAMETER_PREAMBLE_LENGTH: + return checkLimitsAndApply(preambleLength, value, 0.0, 65535.0, 1); + case LORA_PARAMETER_DATA_RATE: + return checkLimitsAndApply(bitRate, value, 0.6, 300.0); + case LORA_PARAMETER_FREQUENCY_DEVIATION: + return checkLimitsAndApply(frequencyDeviation, value, 0.0, 200.0); + default: + break; + } + + LOG_W(TAG, "Tried to set unsupported FSK parameter \"%s\" to %f", toString(parameter), value); + return ERROR_NOT_SUPPORTED; +} + +error_t Sx1262Radio::setLrFhssParameter(enum LoraParameter parameter, float value) { + switch (parameter) { + case LORA_PARAMETER_BANDWIDTH: + return checkValuesAndApply(bandwidth, value, {39.06, 85.94, 136.72, 183.59, 335.94, 386.72, 722.66, 773.44, 1523.4, 1574.2}); + case LORA_PARAMETER_CODING_RATE: + return checkValuesAndApply(codingRate, value, {RADIOLIB_SX126X_LR_FHSS_CR_5_6, RADIOLIB_SX126X_LR_FHSS_CR_2_3, RADIOLIB_SX126X_LR_FHSS_CR_1_2, RADIOLIB_SX126X_LR_FHSS_CR_1_3}); + case LORA_PARAMETER_NARROW_GRID: + return checkLimitsAndApply(narrowGrid, value, 0.0, 1.0, 1); + default: + break; + } + + LOG_W(TAG, "Tried to set unsupported LR-FHSS parameter \"%s\" to %f", toString(parameter), value); + return ERROR_NOT_SUPPORTED; +} + +error_t Sx1262Radio::setParameter(enum LoraParameter parameter, float value) { + lock(); + + error_t result = setBaseParameter(parameter, value); + if (result == ERROR_NOT_SUPPORTED) { + switch (modulation) { + case LORA_MODULATION_LORA: + result = setLoraParameter(parameter, value); + break; + case LORA_MODULATION_FSK: + result = setFskParameter(parameter, value); + break; + case LORA_MODULATION_LR_FHSS: + result = setLrFhssParameter(parameter, value); + break; + default: + break; + } + } + + if (result == ERROR_NONE) { + LOG_D(TAG, "Parameter %s = %f", toString(parameter), value); + } + + unlock(); + return result; +} + +error_t Sx1262Radio::getBaseParameter(enum LoraParameter parameter, float* value) const { + switch (parameter) { + case LORA_PARAMETER_POWER: + *value = power; + return ERROR_NONE; + case LORA_PARAMETER_BOOSTED_GAIN: + *value = boostedGain; + return ERROR_NONE; + case LORA_PARAMETER_CURRENT_LIMIT: + *value = currentLimit; + return ERROR_NONE; + default: + return ERROR_NOT_SUPPORTED; + } +} + +error_t Sx1262Radio::getLoraParameter(enum LoraParameter parameter, float* value) const { + switch (parameter) { + case LORA_PARAMETER_FREQUENCY: + *value = frequency; + return ERROR_NONE; + case LORA_PARAMETER_BANDWIDTH: + *value = bandwidth; + return ERROR_NONE; + case LORA_PARAMETER_SPREADING_FACTOR: + *value = spreadingFactor; + return ERROR_NONE; + case LORA_PARAMETER_CODING_RATE: + *value = codingRate; + return ERROR_NONE; + case LORA_PARAMETER_SYNC_WORD: + *value = syncWord; + return ERROR_NONE; + case LORA_PARAMETER_PREAMBLE_LENGTH: + *value = preambleLength; + return ERROR_NONE; + default: + return ERROR_NOT_SUPPORTED; + } +} + +error_t Sx1262Radio::getFskParameter(enum LoraParameter parameter, float* value) const { + switch (parameter) { + case LORA_PARAMETER_FREQUENCY: + *value = frequency; + return ERROR_NONE; + case LORA_PARAMETER_BANDWIDTH: + *value = bandwidth; + return ERROR_NONE; + case LORA_PARAMETER_DATA_RATE: + *value = bitRate; + return ERROR_NONE; + case LORA_PARAMETER_FREQUENCY_DEVIATION: + *value = frequencyDeviation; + return ERROR_NONE; + default: + return ERROR_NOT_SUPPORTED; + } +} + +error_t Sx1262Radio::getLrFhssParameter(enum LoraParameter parameter, float* value) const { + switch (parameter) { + case LORA_PARAMETER_BANDWIDTH: + *value = bandwidth; + return ERROR_NONE; + case LORA_PARAMETER_CODING_RATE: + *value = codingRate; + return ERROR_NONE; + case LORA_PARAMETER_NARROW_GRID: + *value = narrowGrid; + return ERROR_NONE; + default: + return ERROR_NOT_SUPPORTED; + } +} + +error_t Sx1262Radio::getParameter(enum LoraParameter parameter, float* value) const { + lock(); + + // No warnings are emitted to be able to discover parameters by return status + error_t result = getBaseParameter(parameter, value); + if (result == ERROR_NOT_SUPPORTED) { + switch (modulation) { + case LORA_MODULATION_LORA: + result = getLoraParameter(parameter, value); + break; + case LORA_MODULATION_FSK: + result = getFskParameter(parameter, value); + break; + case LORA_MODULATION_LR_FHSS: + result = getLrFhssParameter(parameter, value); + break; + default: + break; + } + } + + unlock(); + return result; +} + +// endregion + +// region Radio operations (radio thread only) + +// DIO1 uses the GPIO descriptor callback API in HIGH_LEVEL mode. The interrupt is +// armed (enabled) per cycle by the radio thread loop and masked by the ISR on each +// fire; the modem asserts DIO1 for RX/TX-done, which the driver clears by reading +// the packet or finishing the transmission. Edge-triggered interrupts are avoided +// on purpose (ESP32 erratum 3.11: subsequent edge interrupts may be missed, which +// for a radio would drop an RX/TX-done and stall the thread). +void Sx1262Radio::registerDio1Isr() { + gpio_flags_t flags = GPIO_FLAG_DIRECTION_INPUT; + flags = GPIO_FLAG_INTERRUPT_TO_OPTIONS(flags, GPIO_INTERRUPT_HIGH_LEVEL); + if (gpio_descriptor_set_flags(settings.dio1, flags) != ERROR_NONE || + gpio_descriptor_add_callback(settings.dio1, dio1Isr, this) != ERROR_NONE) { + LOG_E(TAG, "Failed to install DIO1 interrupt"); + } +} + +void Sx1262Radio::unregisterDio1Isr() { + gpio_descriptor_disable_interrupt(settings.dio1); + gpio_descriptor_remove_callback(settings.dio1); +} + +int Sx1262Radio::doBegin(enum LoraModulation beginModulation) { + int16_t rc = RADIOLIB_ERR_NONE; + auto& radio = parts->radio; + + if (beginModulation == LORA_MODULATION_LORA) { + LOG_I( + TAG, + "Starting LoRa: %.3f MHz, BW %.2f kHz, SF%u, CR 4/%u, sync 0x%02X, preamble %u, %d dBm, TCXO %.1f V", + frequency, + bandwidth, + spreadingFactor, + codingRate, + syncWord, + preambleLength, + power, + settings.tcxo_voltage + ); + rc = radio.begin( + frequency, + bandwidth, + spreadingFactor, + codingRate, + syncWord, + power, + preambleLength, + settings.tcxo_voltage, + settings.use_regulator_ldo + ); + } else if (beginModulation == LORA_MODULATION_FSK) { + LOG_I( + TAG, + "Starting FSK: %.3f MHz, %.2f kbps, deviation %.1f kHz, BW %.1f kHz, preamble %u, %d dBm", + frequency, + bitRate, + frequencyDeviation, + bandwidth, + preambleLength, + power + ); + rc = radio.beginFSK( + frequency, + bitRate, + frequencyDeviation, + bandwidth, + power, + preambleLength, + settings.tcxo_voltage, + settings.use_regulator_ldo + ); + } else if (beginModulation == LORA_MODULATION_LR_FHSS) { + LOG_I(TAG, "Starting LR-FHSS: BW %.2f kHz, CR %u, %s grid", bandwidth, codingRate, narrowGrid ? "narrow" : "wide"); + rc = radio.beginLRFHSS( + bandwidth, + codingRate, + narrowGrid, + settings.tcxo_voltage, + settings.use_regulator_ldo + ); + } else { + LOG_E(TAG, "SX1262 not capable of modulation \"%s\"", toString(beginModulation)); + setState(LORA_RADIO_STATE_ERROR); + return -1; + } + + if (rc != RADIOLIB_ERR_NONE) { + LOG_E(TAG, "RadioLib initialization failed with code %hi", rc); + setState(LORA_RADIO_STATE_ERROR); + return -1; + } + + // Apply the PA over-current protection limit. RadioLib's begin() already set its + // fail-safe default (60 mA), so this is only meaningful when a consumer raised it + // via LORA_PARAMETER_CURRENT_LIMIT to reach higher output power. + rc = radio.setCurrentLimit(currentLimit); + if (rc != RADIOLIB_ERR_NONE) { + LOG_E(TAG, "Setting current limit to %.1f mA failed with code %hi", currentLimit, rc); + setState(LORA_RADIO_STATE_ERROR); + return -1; + } + + // Modules that wire the antenna TX/RX switch to DIO2 (e.g. LilyGO T-Deck Max) + // must enable this or the RF path stays disconnected and no TX/RX gets through. + if (settings.dio2_rf_switch) { + rc = radio.setDio2AsRfSwitch(true); + if (rc != RADIOLIB_ERR_NONE) { + LOG_E(TAG, "Setting DIO2 as RF switch failed with code %hi", rc); + setState(LORA_RADIO_STATE_ERROR); + return -1; + } + } + + rc = radio.setRxBoostedGainMode(boostedGain, true); + if (rc != RADIOLIB_ERR_NONE) { + LOG_E(TAG, "Setting RX boosted gain to %s failed with code %hi", boostedGain ? "true" : "false", rc); + setState(LORA_RADIO_STATE_ERROR); + return -1; + } + + LOG_I(TAG, "Modem initialized (chip verified by RadioLib)"); + registerDio1Isr(); + return 0; +} + +void Sx1262Radio::doEnd() { + unregisterDio1Isr(); + // Leave the modem in its lowest-power state; the next enable runs a full begin() + const int16_t rc = parts->radio.sleep(); + if (rc != RADIOLIB_ERR_NONE) { + LOG_W(TAG, "Putting modem to sleep failed with code %hi", rc); + } else { + LOG_I(TAG, "Modem put to sleep"); + } +} + +void Sx1262Radio::doTransmit() { + currentTx = popNextQueuedTx(); + auto& radio = parts->radio; + + int16_t rc = radio.standby(); + if (rc != RADIOLIB_ERR_NONE) { + LOG_W(TAG, "RadioLib returned %hi on TX standby", rc); + } + + LOG_I(TAG, "TX id=%d: %u bytes (%u more queued)", (int)currentTx.id, (unsigned)currentTx.data.size(), (unsigned)getTxQueueSize()); + rc = radio.startTransmit(currentTx.data.data(), currentTx.data.size()); + + if (rc == RADIOLIB_ERR_NONE) { + publishTx(currentTx.id, LORA_TRANSMISSION_STATE_TRANSMIT_PENDING); + + // Time-on-air (microseconds) for the current modem config; 0 if RadioLib can't + // compute it, in which case fall back to a fixed timeout. + const uint32_t airtimeMillis = radio.getTimeOnAir(currentTx.data.size()) / 1000; + const uint32_t txTimeoutMillis = (airtimeMillis > 0) + ? (airtimeMillis + SX1262_TX_TIMEOUT_MARGIN_MILLIS) + : SX1262_TX_TIMEOUT_FALLBACK_MILLIS; + + // outFlags stays 0 on timeout, which routes to the Timeout branch below + uint32_t txEventFlags = 0; + event_group_wait( + events, + SX1262_INTERRUPT_BIT | SX1262_DIO1_EVENT_BIT, + false, + true, + &txEventFlags, + pdMS_TO_TICKS(txTimeoutMillis) + ); + + // Clean up after transmission + radio.finishTransmit(); + + // Thread might've been interrupted in the meanwhile + if (isThreadInterrupted()) { + return; + } + + // If the DIO1 bit is unset, this means the wait timed out + if (txEventFlags & SX1262_DIO1_EVENT_BIT) { + LOG_I(TAG, "TX id=%d: done", (int)currentTx.id); + publishTx(currentTx.id, LORA_TRANSMISSION_STATE_TRANSMITTED); + } else { + LOG_W(TAG, "TX id=%d: no TX-done IRQ within %u ms", (int)currentTx.id, (unsigned)txTimeoutMillis); + publishTx(currentTx.id, LORA_TRANSMISSION_STATE_TIMEOUT); + } + } else { + LOG_E(TAG, "Error transmitting id=%d, rc=%hi", (int)currentTx.id, rc); + publishTx(currentTx.id, LORA_TRANSMISSION_STATE_ERROR); + } +} + +bool Sx1262Radio::doListen() { + auto& radio = parts->radio; + + if (getModulation() != LORA_MODULATION_LR_FHSS) { + int16_t rc = radio.startReceiveDutyCycleAuto(preambleLength, 0, SX1262_IRQ_FLAGS); + if (rc == RADIOLIB_ERR_NONE) { + uint32_t flags = 0; + event_group_wait( + events, + SX1262_INTERRUPT_BIT | SX1262_DIO1_EVENT_BIT | SX1262_QUEUED_TX_BIT, + false, + true, + &flags, + portMAX_DELAY + ); + return (flags & SX1262_DIO1_EVENT_BIT) != 0; + } else { + LOG_E(TAG, "Error setting dutycycle RX, RadioLib returned %hi", rc); + } + return false; + } else { + // LR-FHSS modem only supports TX + event_group_wait( + events, + SX1262_INTERRUPT_BIT | SX1262_QUEUED_TX_BIT, + false, + true, + nullptr, + portMAX_DELAY + ); + return false; + } +} + +void Sx1262Radio::doReceive() { + // LR-FHSS modem only supports TX + if (getModulation() == LORA_MODULATION_LR_FHSS) return; + + auto& radio = parts->radio; + + uint16_t rxSize = radio.getPacketLength(true); + std::vector data(rxSize); + int16_t rc = radio.readData(data.data(), rxSize); + if (rc != RADIOLIB_ERR_NONE) { + LOG_E(TAG, "Error receiving data, RadioLib returned %hi", rc); + } else if (rxSize == 0) { + // Empty read: skip silently to avoid log flooding on spurious IRQs. + } else { + const struct LoraRxPacket packet = { + .data = data.data(), + .length = data.size(), + .rssi = radio.getRSSI(), + .snr = radio.getSNR(), + }; + + LOG_I(TAG, "RX: %u bytes, RSSI %.1f dBm, SNR %.1f dB", (unsigned)packet.length, packet.rssi, packet.snr); + publishRx(packet); + radio.finishReceive(); + } +} + +// endregion diff --git a/Drivers/sx126x-module/private/drivers/sx1262_radio.h b/Drivers/sx126x-module/private/drivers/sx1262_radio.h new file mode 100644 index 000000000..d9acd290c --- /dev/null +++ b/Drivers/sx126x-module/private/drivers/sx1262_radio.h @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +struct Device; +struct GpioDescriptor; + +/** + * SX1262 radio engine: owns the radio thread, the TX queue and the callback lists. + * The public methods are thread-safe. Callbacks are invoked with the internal mutex held, + * either from the radio thread (RX, TX progress, state) or from the caller of transmit() (QUEUED). + * + * The RadioLib types live behind the RadioParts indirection: RadioLib declares a global + * `class Module` that collides with the kernel's `struct Module` when both are visible + * in the same translation unit, so RadioLib headers must not leak out of the implementation. + */ +class Sx1262Radio final { +public: + struct Settings { + /** The kernel device, passed to callbacks */ + Device* device; + /** The parent SPI controller device, for the SPI controller bus lock */ + Device* spi_controller; + spi_host_device_t spi_host; + int spi_frequency_hz; + // CS/RESET/BUSY are native SoC GPIO numbers: the RadioLib HAL drives them directly + // through ESP-IDF, so they can't sit behind an IO expander (unlike enable/antenna-select, + // which the driver-registration layer resolves through the GPIO descriptor API). + gpio_num_t pin_cs; + gpio_num_t pin_reset; + gpio_num_t pin_busy; + /** DIO1 IRQ line, owned by the driver. The radio thread arms it as a + * HIGH_LEVEL one-shot via the GPIO descriptor callback API. */ + struct GpioDescriptor* dio1; + float tcxo_voltage; + bool use_regulator_ldo; + bool dio2_rf_switch; + }; + +private: + struct RadioParts; + + struct TxItem { + LoraTxId id = 0; + std::vector data; + }; + + template + struct CallbackEntry { + void* context; + Callback callback; + }; + + const Settings settings; + RadioParts* parts; + mutable RecursiveMutex mutex = {}; + EventGroupHandle_t events = nullptr; + + Thread* thread = nullptr; + bool threadInterrupted = false; + + enum LoraRadioState state = LORA_RADIO_STATE_OFF; + enum LoraModulation modulation = LORA_MODULATION_NONE; + + std::deque txQueue; + TxItem currentTx; + LoraTxId lastTxId = 0; + + std::vector> stateCallbacks; + std::vector> rxCallbacks; + std::vector> txCallbacks; + + // Parameter store, applied on the next doBegin() + int8_t power = -9; + float frequency = 150; + float bandwidth = 0.0; + uint8_t spreadingFactor = 0; + uint8_t codingRate = 0; + uint8_t syncWord = 0; + uint16_t preambleLength = 0; + float bitRate = 0.0; + float frequencyDeviation = 0.0; + bool narrowGrid = false; + bool boostedGain = false; + // PA over-current protection limit in mA. Default matches RadioLib's fail-safe 60 mA, + // which caps output below +22 dBm; a board-aware consumer can raise it (up to 140 mA). + float currentLimit = 60.0; + + static void dio1Isr(void* context); + static int32_t threadMainStatic(void* context); + + void lock() const { recursive_mutex_lock(&mutex); } + void unlock() const { recursive_mutex_unlock(&mutex); } + + bool isThreadInterrupted() const; + int32_t threadMain(); + + void setState(enum LoraRadioState newState); + void publishRx(const struct LoraRxPacket& packet); + void publishTx(LoraTxId id, enum LoraTransmissionState txState); + + size_t getTxQueueSize() const; + TxItem popNextQueuedTx(); + + void registerDio1Isr(); + void unregisterDio1Isr(); + + error_t setBaseParameter(enum LoraParameter parameter, float value); + error_t setLoraParameter(enum LoraParameter parameter, float value); + error_t setFskParameter(enum LoraParameter parameter, float value); + error_t setLrFhssParameter(enum LoraParameter parameter, float value); + error_t getBaseParameter(enum LoraParameter parameter, float* value) const; + error_t getLoraParameter(enum LoraParameter parameter, float* value) const; + error_t getFskParameter(enum LoraParameter parameter, float* value) const; + error_t getLrFhssParameter(enum LoraParameter parameter, float* value) const; + + int doBegin(enum LoraModulation beginModulation); + void doEnd(); + void doTransmit(); + bool doListen(); + void doReceive(); + +public: + explicit Sx1262Radio(const Settings& settings); + ~Sx1262Radio(); + + /** + * Verify a live SX1262 responds on the wired pins, using only GPIO (no SPI traffic): + * pulse NRESET and expect the chip to drive BUSY low once it reaches standby. + * @return ERROR_NONE when the chip responded + */ + error_t probe() const; + + enum LoraRadioState getState() const; + error_t setEnabled(bool enabled); + error_t setModulation(enum LoraModulation newModulation); + enum LoraModulation getModulation() const; + + bool canTransmit(enum LoraModulation withModulation) const { + return (withModulation == LORA_MODULATION_FSK) || + (withModulation == LORA_MODULATION_LORA) || + (withModulation == LORA_MODULATION_LR_FHSS); + } + + bool canReceive(enum LoraModulation withModulation) const { + return (withModulation == LORA_MODULATION_FSK) || (withModulation == LORA_MODULATION_LORA); + } + + error_t setParameter(enum LoraParameter parameter, float value); + error_t getParameter(enum LoraParameter parameter, float* value) const; + + error_t transmit(const uint8_t* data, size_t length, LoraTxId* id); + + error_t addRxCallback(void* context, LoraRxCallback callback); + error_t removeRxCallback(LoraRxCallback callback); + error_t addStateCallback(void* context, LoraStateCallback callback); + error_t removeStateCallback(LoraStateCallback callback); + error_t addTxCallback(void* context, LoraTxCallback callback); + error_t removeTxCallback(LoraTxCallback callback); +}; diff --git a/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp b/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp new file mode 100644 index 000000000..82b555593 --- /dev/null +++ b/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "sx126x_radiolib_hal.h" + +#include +#include +#include + +#include + +#include +#include + +#define TAG "sx126x_hal" + +void Sx126xRadiolibHal::init() { + spiBegin(); +} + +void Sx126xRadiolibHal::term() { + spiEnd(); +} + +void Sx126xRadiolibHal::pinMode(uint32_t pin, uint32_t mode) { + if (pin == RADIOLIB_NC) { + return; + } + + gpio_hal_context_t gpiohal; + gpiohal.dev = GPIO_LL_GET_HW(GPIO_PORT_0); + + gpio_config_t conf = { + .pin_bit_mask = (1ULL << pin), + .mode = (gpio_mode_t)mode, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = (gpio_int_type_t)gpiohal.dev->pin[pin].int_type, + }; + gpio_config(&conf); +} + +void Sx126xRadiolibHal::digitalWrite(uint32_t pin, uint32_t value) { + if (pin == RADIOLIB_NC) { + return; + } + + gpio_set_level((gpio_num_t)pin, value); +} + +uint32_t Sx126xRadiolibHal::digitalRead(uint32_t pin) { + if (pin == RADIOLIB_NC) { + return 0; + } + + return gpio_get_level((gpio_num_t)pin); +} + +void Sx126xRadiolibHal::attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode) { + LOG_E(TAG, "Interrupt registration via RadioLib is not supported"); +} + +void Sx126xRadiolibHal::detachInterrupt(uint32_t interruptNum) { + LOG_E(TAG, "Interrupt registration via RadioLib is not supported"); +} + +void Sx126xRadiolibHal::delay(unsigned long ms) { + delay_millis(ms); +} + +void Sx126xRadiolibHal::delayMicroseconds(unsigned long us) { + delay_micros(us); +} + +unsigned long Sx126xRadiolibHal::millis() { + return (unsigned long)(esp_timer_get_time() / 1000ULL); +} + +unsigned long Sx126xRadiolibHal::micros() { + return (unsigned long)(esp_timer_get_time()); +} + +long Sx126xRadiolibHal::pulseIn(uint32_t pin, uint32_t state, unsigned long timeout) { + if (pin == RADIOLIB_NC) { + return 0; + } + + this->pinMode(pin, GPIO_MODE_INPUT); + uint32_t start = this->micros(); + uint32_t curtick = this->micros(); + + while (this->digitalRead(pin) == state) { + if ((this->micros() - curtick) > timeout) { + return 0; + } + } + + return (this->micros() - start); +} + +void Sx126xRadiolibHal::spiBegin() { + if (!spiInitialized) { + spi_device_interface_config_t devcfg = {}; + devcfg.clock_speed_hz = spiFrequency; + devcfg.mode = 0; + // CS is set to unused, as RadioLib sets it manually + devcfg.spics_io_num = -1; + devcfg.queue_size = 1; + esp_err_t ret = spi_bus_add_device(spiHostDevice, &devcfg, &spiDeviceHandle); + if (ret != ESP_OK) { + LOG_E(TAG, "Failed to add SPI device, error %s", esp_err_to_name(ret)); + } + spiInitialized = true; + } +} + +void Sx126xRadiolibHal::spiBeginTransaction() { + // RadioLib holds CS low across multiple transfers, so the whole exchange must + // be atomic on the bus. Take the kernel SPI controller lock (the arbiter other + // kernel drivers on this host cooperate through) as the outer lock, then + // ESP-IDF's per-host bus lock to also block the IDF-managed spi_master devices + // (display, SD) that don't take the controller lock. + spi_controller_lock(spiController); + spi_device_acquire_bus(spiDeviceHandle, portMAX_DELAY); +} + +void Sx126xRadiolibHal::spiTransfer(uint8_t* out, size_t len, uint8_t* in) { + spi_transaction_t t; + memset(&t, 0, sizeof(t)); + t.length = len * 8; + t.tx_buffer = out; + t.rx_buffer = in; + spi_device_polling_transmit(spiDeviceHandle, &t); +} + +void Sx126xRadiolibHal::spiEndTransaction() { + spi_device_release_bus(spiDeviceHandle); + spi_controller_unlock(spiController); +} + +void Sx126xRadiolibHal::spiEnd() { + if (spiInitialized) { + spi_bus_remove_device(spiDeviceHandle); + spiInitialized = false; + } +} diff --git a/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.h b/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.h new file mode 100644 index 000000000..9e629fe5e --- /dev/null +++ b/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.h @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include +#include + +struct Device; + +/** + * RadioLib HAL on top of ESP-IDF GPIO and SPI master. + * + * RadioLib drives the chip-select manually across multiple transfers, so every + * command/response exchange must be atomic on the bus. Two locks wrap each + * exchange: the kernel SPI controller lock (spi_controller_lock) is the + * abstraction other kernel drivers on this host serialise through, and ESP-IDF's + * per-host bus lock (spi_device_acquire_bus) additionally blocks the IDF-managed + * spi_master devices (display, SD) that don't take the controller lock. The + * controller lock is taken as the outer lock; nothing else takes both, so there + * is no lock-ordering hazard. + */ +class Sx126xRadiolibHal final : public RadioLibHal { +private: + spi_host_device_t spiHostDevice; + int spiFrequency; + struct Device* spiController; + spi_device_handle_t spiDeviceHandle = nullptr; + bool spiInitialized = false; + +public: + Sx126xRadiolibHal(spi_host_device_t spiHostDevice, int spiFrequency, struct Device* spiController) + : RadioLibHal( + GPIO_MODE_INPUT, + GPIO_MODE_OUTPUT, + 0, // LOW + 1, // HIGH + GPIO_INTR_POSEDGE, + GPIO_INTR_NEGEDGE + ) + , spiHostDevice(spiHostDevice) + , spiFrequency(spiFrequency) + , spiController(spiController) {} + + void init() override; + void term() override; + + void pinMode(uint32_t pin, uint32_t mode) override; + void digitalWrite(uint32_t pin, uint32_t value) override; + uint32_t digitalRead(uint32_t pin) override; + void attachInterrupt(uint32_t interruptNum, void (*interruptCb)(void), uint32_t mode) override; + void detachInterrupt(uint32_t interruptNum) override; + + void delay(unsigned long ms) override; + void delayMicroseconds(unsigned long us) override; + unsigned long millis() override; + unsigned long micros() override; + long pulseIn(uint32_t pin, uint32_t state, unsigned long timeout) override; + + void spiBegin() override; + void spiBeginTransaction() override; + void spiTransfer(uint8_t* out, size_t len, uint8_t* in) override; + void spiEndTransaction() override; + void spiEnd() override; +}; diff --git a/Drivers/sx126x-module/source/module.cpp b/Drivers/sx126x-module/source/module.cpp new file mode 100644 index 000000000..a0e8a0bba --- /dev/null +++ b/Drivers/sx126x-module/source/module.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include + +extern "C" { + +extern Driver sx1262_driver; + +static error_t start() { + /* We crash when construct fails, because if a single driver fails to construct, + * there is no guarantee that the previously constructed drivers can be destroyed */ + check(driver_construct_add(&sx1262_driver) == ERROR_NONE); + return ERROR_NONE; +} + +static error_t stop() { + /* We crash when destruct fails, because if a single driver fails to destruct, + * there is no guarantee that the previously destroyed drivers can be recovered */ + check(driver_remove_destruct(&sx1262_driver) == ERROR_NONE); + return ERROR_NONE; +} + +Module sx126x_module = { + .name = "sx126x", + .start = start, + .stop = stop, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Drivers/sx126x-module/source/sx1262.cpp b/Drivers/sx126x-module/source/sx1262.cpp new file mode 100644 index 000000000..ab4f69b71 --- /dev/null +++ b/Drivers/sx126x-module/source/sx1262.cpp @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: Apache-2.0 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#define TAG "sx1262" + +#define GET_CONFIG(device) ((const struct Sx1262Config*)device->config) +#define GET_DATA(device) ((struct Sx1262Internal*)device_get_driver_data(device)) + +extern "C" { + +struct Sx1262Internal { + Sx1262Radio* radio = nullptr; + GpioDescriptor* pin_reset = nullptr; + GpioDescriptor* pin_busy = nullptr; + GpioDescriptor* pin_dio1 = nullptr; + GpioDescriptor* pin_enable = nullptr; + GpioDescriptor* pin_antenna_select = nullptr; + + void release_pins() { + release_pin(&pin_reset); + release_pin(&pin_busy); + release_pin(&pin_dio1); + release_pin(&pin_enable); + release_pin(&pin_antenna_select); + } + +private: + static void release_pin(GpioDescriptor** descriptor) { + if (*descriptor != nullptr) { + gpio_descriptor_release(*descriptor); + *descriptor = nullptr; + } + } +}; + +/** + * Acquire a pin that must live on an SoC GPIO controller and resolve its native pin number. + * The reset/busy/DIO1 lines are driven directly through ESP-IDF by the RadioLib HAL, + * so pins behind an IO expander can't back them. + */ +static GpioDescriptor* acquire_native_pin(const struct GpioPinSpec& spec, const char* pin_name, gpio_num_t* native_pin) { + if (spec.gpio_controller == nullptr) { + LOG_E(TAG, "Pin \"%s\" is not set", pin_name); + return nullptr; + } + + auto* descriptor = gpio_descriptor_acquire(spec.gpio_controller, spec.pin, GPIO_OWNER_GPIO); + if (descriptor == nullptr) { + LOG_E(TAG, "Failed to acquire pin \"%s\"", pin_name); + return nullptr; + } + + if (gpio_descriptor_get_native_pin_number(descriptor, native_pin) != ERROR_NONE) { + LOG_E(TAG, "Pin \"%s\" must be an SoC GPIO", pin_name); + gpio_descriptor_release(descriptor); + return nullptr; + } + + return descriptor; +} + +/** + * Acquire an optional control pin (may sit behind an IO expander) and drive it to the given logical state. + * Controllers may not support ACTIVE_LOW on outputs (e.g. xl9555), so polarity from the pin spec is applied here. + */ +static error_t acquire_and_drive_pin(const struct GpioPinSpec& spec, const char* pin_name, bool active, GpioDescriptor** out_descriptor) { + if (spec.gpio_controller == nullptr) { + *out_descriptor = nullptr; + return ERROR_NONE; + } + + auto* descriptor = gpio_descriptor_acquire(spec.gpio_controller, spec.pin, GPIO_OWNER_GPIO); + if (descriptor == nullptr) { + LOG_E(TAG, "Failed to acquire pin \"%s\"", pin_name); + return ERROR_RESOURCE; + } + + const bool level = (spec.flags & GPIO_FLAG_ACTIVE_LOW) ? !active : active; + if (gpio_descriptor_set_flags(descriptor, GPIO_FLAG_DIRECTION_OUTPUT) != ERROR_NONE || + gpio_descriptor_set_level(descriptor, level) != ERROR_NONE) { + LOG_E(TAG, "Failed to drive pin \"%s\"", pin_name); + gpio_descriptor_release(descriptor); + return ERROR_RESOURCE; + } + + LOG_I(TAG, "Pin \"%s\" (controller \"%s\" pin %u) driven %s", pin_name, spec.gpio_controller->name, spec.pin, level ? "high" : "low"); + *out_descriptor = descriptor; + return ERROR_NONE; +} + +static void set_pin_active(GpioDescriptor* descriptor, const struct GpioPinSpec& spec, bool active) { + if (descriptor != nullptr) { + const bool level = (spec.flags & GPIO_FLAG_ACTIVE_LOW) ? !active : active; + gpio_descriptor_set_level(descriptor, level); + } +} + +static error_t start(Device* device) { + LOG_I(TAG, "start %s", device->name); + + auto* parent = device_get_parent(device); + if (device_get_type(parent) != &SPI_CONTROLLER_TYPE) { + LOG_E(TAG, "Parent device is not an SPI controller"); + return ERROR_INVALID_STATE; + } + + // DIO1's interrupt is set up later through the GPIO descriptor callback API + // (see Sx1262Radio::registerDio1Isr), which installs the shared ISR service + // on demand and reference-counts it against the other GPIO-interrupt users. + + auto* data = new (std::nothrow) Sx1262Internal(); + if (data == nullptr) return ERROR_OUT_OF_MEMORY; + + auto* config = GET_CONFIG(device); + + gpio_num_t pin_reset = GPIO_NUM_NC; + gpio_num_t pin_busy = GPIO_NUM_NC; + gpio_num_t pin_dio1 = GPIO_NUM_NC; + + data->pin_reset = acquire_native_pin(config->pin_reset, "pin-reset", &pin_reset); + data->pin_busy = acquire_native_pin(config->pin_busy, "pin-busy", &pin_busy); + data->pin_dio1 = acquire_native_pin(config->pin_dio1, "pin-dio1", &pin_dio1); + + if (data->pin_reset == nullptr || data->pin_busy == nullptr || data->pin_dio1 == nullptr) { + data->release_pins(); + delete data; + return ERROR_RESOURCE; + } + + // Select the antenna path first, then power the module: the RF switch should + // never be indeterminate while the radio is powered. + if (acquire_and_drive_pin(config->pin_antenna_select, "pin-antenna-select", true, &data->pin_antenna_select) != ERROR_NONE || + acquire_and_drive_pin(config->pin_enable, "pin-enable", true, &data->pin_enable) != ERROR_NONE) { + data->release_pins(); + delete data; + return ERROR_RESOURCE; + } + + // Give the supply rail time to settle before poking the chip + if (data->pin_enable != nullptr) { + delay_millis(10); + } + + struct GpioPinSpec cs_pin_spec; + if (esp32_spi_get_cs_pin(device, &cs_pin_spec) != ERROR_NONE) { + LOG_E(TAG, "Failed to get CS pin from parent SPI controller"); + data->release_pins(); + delete data; + return ERROR_RESOURCE; + } + + auto* spi_config = static_cast(parent->config); + + const Sx1262Radio::Settings settings = { + .device = device, + .spi_controller = parent, + .spi_host = spi_config->host, + .spi_frequency_hz = (int)config->spi_frequency_khz * 1000, + .pin_cs = static_cast(cs_pin_spec.pin), + .pin_reset = pin_reset, + .pin_busy = pin_busy, + .dio1 = data->pin_dio1, + .tcxo_voltage = (float)config->tcxo_millivolts / 1000.0f, + .use_regulator_ldo = config->use_regulator_ldo, + .dio2_rf_switch = config->dio2_as_rf_switch, + }; + + LOG_I( + TAG, + "%s: CS=%d RST=%d BUSY=%d DIO1=%d, SPI %u kHz, TCXO %u mV, DIO2-RF-switch=%s, LDO=%s", + device->name, + (int)settings.pin_cs, + (int)pin_reset, + (int)pin_busy, + (int)pin_dio1, + (unsigned)config->spi_frequency_khz, + (unsigned)config->tcxo_millivolts, + config->dio2_as_rf_switch ? "yes" : "no", + config->use_regulator_ldo ? "yes" : "no" + ); + + data->radio = new (std::nothrow) Sx1262Radio(settings); + if (data->radio == nullptr) { + data->release_pins(); + delete data; + return ERROR_OUT_OF_MEMORY; + } + + // Cheap GPIO-only sanity check that a live chip answers on these pins, + // so a miswired or unpowered module fails at device start instead of + // surfacing later as an opaque RadioLib error on the radio thread. + if (data->radio->probe() != ERROR_NONE) { + delete data->radio; + set_pin_active(data->pin_enable, config->pin_enable, false); + data->release_pins(); + delete data; + return ERROR_RESOURCE; + } + + device_set_driver_data(device, data); + return ERROR_NONE; +} + +static error_t stop(Device* device) { + LOG_I(TAG, "stop %s", device->name); + + auto* data = GET_DATA(device); + if (data == nullptr) return ERROR_NONE; + + // Stops the radio thread before teardown + delete data->radio; + data->radio = nullptr; + + // Power down first, then release the antenna path (mirror of the start order) + auto* config = GET_CONFIG(device); + set_pin_active(data->pin_enable, config->pin_enable, false); + set_pin_active(data->pin_antenna_select, config->pin_antenna_select, false); + + data->release_pins(); + device_set_driver_data(device, nullptr); + delete data; + return ERROR_NONE; +} + +// region LoraApi + +static Sx1262Radio* get_radio(Device* device) { + auto* data = GET_DATA(device); + return (data != nullptr) ? data->radio : nullptr; +} + +static error_t api_get_radio_state(Device* device, enum LoraRadioState* state) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + *state = radio->getState(); + return ERROR_NONE; +} + +static error_t api_set_enabled(Device* device, bool enabled) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->setEnabled(enabled); +} + +static error_t api_set_modulation(Device* device, enum LoraModulation modulation) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->setModulation(modulation); +} + +static error_t api_get_modulation(Device* device, enum LoraModulation* modulation) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + *modulation = radio->getModulation(); + return ERROR_NONE; +} + +static bool api_can_transmit(Device* device, enum LoraModulation modulation) { + auto* radio = get_radio(device); + return (radio != nullptr) && radio->canTransmit(modulation); +} + +static bool api_can_receive(Device* device, enum LoraModulation modulation) { + auto* radio = get_radio(device); + return (radio != nullptr) && radio->canReceive(modulation); +} + +static error_t api_set_parameter(Device* device, enum LoraParameter parameter, float value) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->setParameter(parameter, value); +} + +static error_t api_get_parameter(Device* device, enum LoraParameter parameter, float* value) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->getParameter(parameter, value); +} + +static error_t api_transmit(Device* device, const uint8_t* data, size_t length, LoraTxId* id) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->transmit(data, length, id); +} + +static error_t api_add_rx_callback(Device* device, void* callback_context, LoraRxCallback callback) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->addRxCallback(callback_context, callback); +} + +static error_t api_remove_rx_callback(Device* device, LoraRxCallback callback) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->removeRxCallback(callback); +} + +static error_t api_add_state_callback(Device* device, void* callback_context, LoraStateCallback callback) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->addStateCallback(callback_context, callback); +} + +static error_t api_remove_state_callback(Device* device, LoraStateCallback callback) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->removeStateCallback(callback); +} + +static error_t api_add_tx_callback(Device* device, void* callback_context, LoraTxCallback callback) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->addTxCallback(callback_context, callback); +} + +static error_t api_remove_tx_callback(Device* device, LoraTxCallback callback) { + auto* radio = get_radio(device); + if (radio == nullptr) return ERROR_INVALID_STATE; + return radio->removeTxCallback(callback); +} + +static const struct LoraApi sx1262_lora_api = { + .get_radio_state = api_get_radio_state, + .set_enabled = api_set_enabled, + .set_modulation = api_set_modulation, + .get_modulation = api_get_modulation, + .can_transmit = api_can_transmit, + .can_receive = api_can_receive, + .set_parameter = api_set_parameter, + .get_parameter = api_get_parameter, + .transmit = api_transmit, + .add_rx_callback = api_add_rx_callback, + .remove_rx_callback = api_remove_rx_callback, + .add_state_callback = api_add_state_callback, + .remove_state_callback = api_remove_state_callback, + .add_tx_callback = api_add_tx_callback, + .remove_tx_callback = api_remove_tx_callback, +}; + +// endregion + +extern Module sx126x_module; + +Driver sx1262_driver = { + .name = "sx1262", + .compatible = (const char*[]) { "semtech,sx1262", nullptr }, + .start_device = start, + .stop_device = stop, + .api = &sx1262_lora_api, + .device_type = &LORA_TYPE, + .owner = &sx126x_module, + .internal = nullptr +}; + +} // extern "C" diff --git a/Firmware/idf_component.yml b/Firmware/idf_component.yml index 8be075bba..932f25406 100644 --- a/Firmware/idf_component.yml +++ b/Firmware/idf_component.yml @@ -86,5 +86,9 @@ dependencies: version: "1.1.4" rules: - if: "target in [esp32s3, esp32p4]" + jgromes/radiolib: + version: "7.3.0" + rules: + - if: "target in [esp32, esp32s3, esp32p4]" idf: '5.5.2' From 43ce969202f21e376061172c612edaea10d12ce2 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Wed, 15 Jul 2026 09:57:31 -0400 Subject: [PATCH 3/4] feat(lilygo-tdeck-max): wire up the SX1262 LoRa radio Add the SX1262 radio node to the T-Deck Max devicetree (CS on SPI index 2 / GPIO3, reset/DIO1/busy on GPIO 4/5/6, enable and antenna-select on the xl9555 expander, 2.4 V TCXO, DIO2 as RF switch) and declare the sx126x-module dependency. Co-Authored-By: Claude Opus 4.8 --- Devices/lilygo-tdeck-max/devicetree.yaml | 1 + Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Devices/lilygo-tdeck-max/devicetree.yaml b/Devices/lilygo-tdeck-max/devicetree.yaml index 07314c274..40be5b022 100644 --- a/Devices/lilygo-tdeck-max/devicetree.yaml +++ b/Devices/lilygo-tdeck-max/devicetree.yaml @@ -1,4 +1,5 @@ dependencies: - Platforms/platform-esp32 + - Drivers/sx126x-module - Drivers/xl9555-module dts: lilygo,tdeck-max.dts diff --git a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts index 06dbb9db2..73f53c60e 100644 --- a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts +++ b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts @@ -7,6 +7,7 @@ #include #include #include +#include #include // Reference: https://github.com/Xinyuan-LilyGO/T-Deck-MAX @@ -47,7 +48,7 @@ host = ; cs-gpios = <&gpio0 34 GPIO_FLAG_NONE>, // 0: EPD display <&gpio0 48 GPIO_FLAG_NONE>, // 1: SD card - <&gpio0 3 GPIO_FLAG_NONE>; // 2: LoRa radio (SX1262, not wired up yet) + <&gpio0 3 GPIO_FLAG_NONE>; // 2: LoRa radio (SX1262) pin-mosi = <&gpio0 33 GPIO_FLAG_NONE>; pin-miso = <&gpio0 47 GPIO_FLAG_NONE>; pin-sclk = <&gpio0 36 GPIO_FLAG_NONE>; @@ -60,5 +61,21 @@ status = "disabled"; frequency-khz = <20000>; }; + + // SX1262 LoRa transceiver, wired per the vendor docs/pinmap.md and + // examples/LoRa_sx1262: CS/RST/DIO1/BUSY = GPIO 3/4/5/6, TCXO 2.4 V, + // DIO2 drives the antenna TX/RX switch. Module power (LORA_EN, P01) + // and antenna select (LORA_SEL, P04: high = internal antenna) are + // gated by the XL9555 IO expander. + radio@2 { + compatible = "semtech,sx1262"; + pin-reset = <&gpio0 4 GPIO_FLAG_NONE>; + pin-dio1 = <&gpio0 5 GPIO_FLAG_NONE>; + pin-busy = <&gpio0 6 GPIO_FLAG_NONE>; + pin-enable = <&xl9555 1 GPIO_FLAG_NONE>; + pin-antenna-select = <&xl9555 4 GPIO_FLAG_NONE>; + tcxo-millivolts = <2400>; + dio2-as-rf-switch; + }; }; }; From 87076e1decd168ab67ab22316eb3a75f87ca4a07 Mon Sep 17 00:00:00 2001 From: Crazypedia Date: Wed, 15 Jul 2026 16:14:05 -0400 Subject: [PATCH 4/4] fix(sx1262): address review feedback - Service a received packet before deciding to transmit: an RX-done and a queued TX could coincide in the same radio-thread iteration, and the old else-branch dropped the packet outright. - Publish a terminal ERROR state when the radio thread is interrupted after finishTransmit(), so a caller whose TX raced setEnabled(false) still gets a final callback for its id. - Deactivate pin-antenna-select on probe failure too, mirroring stop(). - Add the reg property for the radio's SPI unit address: declared in the binding (the devicetree compiler rejects undeclared properties), carried in Sx1262Config, and checked against the node's unit address at device start so the two can't silently disagree. - Drop the private gpio_hal_context_t read in the RadioLib HAL's pinMode(): configure pad routing, direction and pulls through public per-aspect APIs that leave the interrupt configuration untouched, so the kernel-owned DIO1 HIGH_LEVEL interrupt survives without reading ESP-IDF HAL internals. Hardware-validated on the LilyGO T-Deck Max: GPIO probe, modem init through the new pinMode path, TX-done at real airtime, and RX from three nodes interleaved with TX, with no errors. Co-Authored-By: Claude Opus 4.8 --- Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts | 1 + Drivers/sx126x-module/README.md | 4 +++- .../bindings/semtech,sx1262.yaml | 4 ++++ .../sx126x-module/include/drivers/sx1262.h | 2 ++ .../private/drivers/sx1262_radio.cpp | 15 ++++++++----- .../private/drivers/sx126x_radiolib_hal.cpp | 21 ++++++++----------- Drivers/sx126x-module/source/sx1262.cpp | 14 +++++++++++-- 7 files changed, 41 insertions(+), 20 deletions(-) diff --git a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts index 73f53c60e..a45b7526e 100644 --- a/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts +++ b/Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts @@ -69,6 +69,7 @@ // gated by the XL9555 IO expander. radio@2 { compatible = "semtech,sx1262"; + reg = <2>; pin-reset = <&gpio0 4 GPIO_FLAG_NONE>; pin-dio1 = <&gpio0 5 GPIO_FLAG_NONE>; pin-busy = <&gpio0 6 GPIO_FLAG_NONE>; diff --git a/Drivers/sx126x-module/README.md b/Drivers/sx126x-module/README.md index eda3fc39e..cf134bbda 100644 --- a/Drivers/sx126x-module/README.md +++ b/Drivers/sx126x-module/README.md @@ -5,7 +5,8 @@ Kernel driver for the Semtech SX1262 sub-GHz LoRa and (G)FSK transceiver, built (see ``) that supports LoRa and FSK for TX/RX and LR-FHSS for TX. The device is a child of an SPI controller. The chip-select line comes from the parent's -`cs-gpios` entry matching the node's unit address. The reset, busy and DIO1 lines must be +`cs-gpios` entry matching the node's unit address; the `reg` property must match that +unit address (checked at device start). The reset, busy and DIO1 lines must be SoC GPIOs (the RadioLib HAL drives them directly); the optional enable and antenna-select lines can live on any GPIO controller, including IO expanders, and are driven to their active level while the device is started. @@ -22,6 +23,7 @@ spi0 { radio@2 { compatible = "semtech,sx1262"; + reg = <2>; pin-reset = <&gpio0 4 GPIO_FLAG_NONE>; pin-dio1 = <&gpio0 5 GPIO_FLAG_NONE>; pin-busy = <&gpio0 6 GPIO_FLAG_NONE>; diff --git a/Drivers/sx126x-module/bindings/semtech,sx1262.yaml b/Drivers/sx126x-module/bindings/semtech,sx1262.yaml index 473af6da2..82e0f6ef6 100644 --- a/Drivers/sx126x-module/bindings/semtech,sx1262.yaml +++ b/Drivers/sx126x-module/bindings/semtech,sx1262.yaml @@ -5,6 +5,10 @@ compatible: "semtech,sx1262" bus: spi properties: + reg: + type: int + required: true + description: Chip-select index on the parent SPI controller, must match the node's unit address spi-frequency-khz: type: int default: 4000 diff --git a/Drivers/sx126x-module/include/drivers/sx1262.h b/Drivers/sx126x-module/include/drivers/sx1262.h index 682d93b16..b626e4b2f 100644 --- a/Drivers/sx126x-module/include/drivers/sx1262.h +++ b/Drivers/sx126x-module/include/drivers/sx1262.h @@ -11,6 +11,8 @@ extern "C" { /** Field order must match the property order in bindings/semtech,sx1262.yaml */ struct Sx1262Config { + /** Chip-select index on the parent SPI controller, must match the node's unit address */ + int32_t reg; /** SPI clock frequency in kHz */ uint32_t spi_frequency_khz; /** NRESET line (must be an SoC GPIO) */ diff --git a/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp b/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp index 0d03dc67b..4fa0f4e79 100644 --- a/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp +++ b/Drivers/sx126x-module/private/drivers/sx1262_radio.cpp @@ -242,12 +242,14 @@ int32_t Sx1262Radio::threadMain() { break; } + // Service a received packet before deciding to transmit: an RX-done and a + // queued TX can coincide in the same iteration, and dropping the RX here would + // lose the packet outright. + if (hasRx) { + doReceive(); + } if (getTxQueueSize() > 0) { doTransmit(); - } else { - if (hasRx) { - doReceive(); - } } } @@ -863,8 +865,11 @@ void Sx1262Radio::doTransmit() { // Clean up after transmission radio.finishTransmit(); - // Thread might've been interrupted in the meanwhile + // Thread might've been interrupted in the meanwhile. Publish a terminal state so a + // caller that queued this TX still gets a final callback for its id when + // setEnabled(false) races with an in-flight transmit. if (isThreadInterrupted()) { + publishTx(currentTx.id, LORA_TRANSMISSION_STATE_ERROR); return; } diff --git a/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp b/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp index 82b555593..cbf663f1a 100644 --- a/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp +++ b/Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp @@ -7,8 +7,8 @@ #include +#include #include -#include #define TAG "sx126x_hal" @@ -25,17 +25,14 @@ void Sx126xRadiolibHal::pinMode(uint32_t pin, uint32_t mode) { return; } - gpio_hal_context_t gpiohal; - gpiohal.dev = GPIO_LL_GET_HW(GPIO_PORT_0); - - gpio_config_t conf = { - .pin_bit_mask = (1ULL << pin), - .mode = (gpio_mode_t)mode, - .pull_up_en = GPIO_PULLUP_DISABLE, - .pull_down_en = GPIO_PULLDOWN_DISABLE, - .intr_type = (gpio_int_type_t)gpiohal.dev->pin[pin].int_type, - }; - gpio_config(&conf); + // Not gpio_config(): that rewrites the pin's interrupt type along with everything + // else, and DIO1's HIGH_LEVEL interrupt is owned by the kernel GPIO descriptor API + // while RadioLib still calls pinMode() on that pin during begin(). Configure the pad + // routing, direction and pulls through the per-aspect setters instead, which leave + // the interrupt configuration untouched. + esp_rom_gpio_pad_select_gpio(pin); + gpio_set_direction((gpio_num_t)pin, (gpio_mode_t)mode); + gpio_set_pull_mode((gpio_num_t)pin, GPIO_FLOATING); } void Sx126xRadiolibHal::digitalWrite(uint32_t pin, uint32_t value) { diff --git a/Drivers/sx126x-module/source/sx1262.cpp b/Drivers/sx126x-module/source/sx1262.cpp index ab4f69b71..a3be6396d 100644 --- a/Drivers/sx126x-module/source/sx1262.cpp +++ b/Drivers/sx126x-module/source/sx1262.cpp @@ -120,6 +120,16 @@ static error_t start(Device* device) { return ERROR_INVALID_STATE; } + auto* config = GET_CONFIG(device); + + // The chip-select lookup uses the node's unit address; reg exists to satisfy the + // devicetree convention for addressed nodes, so reject a board definition where + // the two disagree instead of silently using one of them. + if (config->reg != device->address) { + LOG_E(TAG, "reg (%d) does not match the node's unit address (%d)", (int)config->reg, (int)device->address); + return ERROR_INVALID_STATE; + } + // DIO1's interrupt is set up later through the GPIO descriptor callback API // (see Sx1262Radio::registerDio1Isr), which installs the shared ISR service // on demand and reference-counts it against the other GPIO-interrupt users. @@ -127,8 +137,6 @@ static error_t start(Device* device) { auto* data = new (std::nothrow) Sx1262Internal(); if (data == nullptr) return ERROR_OUT_OF_MEMORY; - auto* config = GET_CONFIG(device); - gpio_num_t pin_reset = GPIO_NUM_NC; gpio_num_t pin_busy = GPIO_NUM_NC; gpio_num_t pin_dio1 = GPIO_NUM_NC; @@ -207,7 +215,9 @@ static error_t start(Device* device) { // surfacing later as an opaque RadioLib error on the radio thread. if (data->radio->probe() != ERROR_NONE) { delete data->radio; + // Power down first, then release the antenna path (mirror of the start order) set_pin_active(data->pin_enable, config->pin_enable, false); + set_pin_active(data->pin_antenna_select, config->pin_antenna_select, false); data->release_pins(); delete data; return ERROR_RESOURCE;