Compare commits

...

2 Commits

Author SHA1 Message Date
Crazypedia
94deee8875
fix(gdeq031t10): reset the panel at the start of a full init (#603) 2026-07-30 23:07:25 +02:00
Crazypedia
f13c18f398
LoRa device type + SX1262 kernel driver (first checkpoint) (#565)
Adds sub-GHz radio support to Tactility as a first-class kernel device type, plus a
driver for the Semtech SX1262. Continues the radio work discussed in #342, brought up to
the current kernel driver model (rather than the deprecated tt::hal layer the earlier
prototype targeted).
2026-07-30 19:32:23 +02:00
21 changed files with 2553 additions and 1 deletions

View File

@ -1,5 +1,6 @@
dependencies:
- Platforms/platform-esp32
- Drivers/sx126x-module
- Drivers/xl9555-module
- Drivers/cst66xx-module
- Drivers/gdeq031t10-module

View File

@ -9,6 +9,7 @@
#include <tactility/bindings/esp32_sdspi.h>
#include <tactility/bindings/esp32_pwm_ledc.h>
#include <tactility/bindings/pwm_backlight.h>
#include <bindings/sx1262.h>
#include <bindings/xl9555.h>
#include <bindings/cst66xx.h>
#include <bindings/gdeq031t10.h>
@ -136,7 +137,7 @@
host = <SPI2_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>;
@ -160,5 +161,22 @@
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";
reg = <2>;
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;
};
};
};

View File

@ -132,6 +132,11 @@ static void hardware_reset(Gdeq031t10Internal* internal) {
}
static bool init_full(Gdeq031t10Internal* internal, bool mirror_180) {
// The reset pulse restores register defaults, which the init sequence below assumes:
// a cold-booted controller never drives BUSY ready after CMD_POWER_ON without it, a
// mode change needs partial mode's lingering VCOM/data-interval setting cleared, and
// waking from deep sleep is only possible by toggling RST.
hardware_reset(internal);
bool ok = write_command(internal, CMD_PANEL_SETTING);
ok = ok && write_data_byte(internal, mirror_180 ? 0x13 : 0x1F);
ok = ok && write_command(internal, CMD_POWER_ON);

View File

@ -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
)

View File

@ -0,0 +1,195 @@
Apache License
==============
_Version 2.0, January 2004_
_&lt;<http://www.apache.org/licenses/>&gt;_
### 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.

View File

@ -0,0 +1,69 @@
# 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 `<tactility/drivers/lora.h>`) 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 `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.
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";
reg = <2>;
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.

View File

@ -0,0 +1,47 @@
description: Semtech SX1262 sub-GHz LoRa and (G)FSK transceiver
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
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

View File

@ -0,0 +1,3 @@
dependencies:
- TactilityKernel
bindings: bindings

View File

@ -0,0 +1,15 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/bindings/bindings.h>
#include <drivers/sx1262.h>
#ifdef __cplusplus
extern "C" {
#endif
DEFINE_DEVICETREE(sx1262, struct Sx1262Config)
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,38 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <tactility/drivers/gpio.h>
#ifdef __cplusplus
extern "C" {
#endif
/** 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) */
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

View File

@ -0,0 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/module.h>
#ifdef __cplusplus
extern "C" {
#endif
extern struct Module sx126x_module;
#ifdef __cplusplus
}
#endif

View File

@ -0,0 +1,968 @@
// SPDX-License-Identifier: Apache-2.0
#include "sx1262_radio.h"
#include "sx126x_radiolib_hal.h"
#include <tactility/concurrent/event_group.h>
#include <tactility/delay.h>
#include <tactility/drivers/gpio_controller.h>
#include <tactility/log.h>
#include <algorithm>
#include <initializer_list>
#include <RadioLib.h>
#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";
case LORA_PARAMETER_CURRENT_LIMIT:
return "current limit";
default:
return "unknown";
}
}
template<typename T>
constexpr error_t checkLimitsAndApply(T& target, const int32_t value, const int32_t lower, const int32_t upper, const int32_t step = 0) {
if ((value >= lower) && (value <= upper)) {
if ((step != 0) && ((value % step) != 0)) {
return ERROR_OUT_OF_RANGE;
}
target = static_cast<T>(value);
return ERROR_NONE;
}
return ERROR_OUT_OF_RANGE;
}
template<typename T>
constexpr error_t checkValuesAndApply(T& target, const int32_t value, std::initializer_list<int32_t> valids) {
for (int32_t valid : valids) {
if (value == valid) {
target = static_cast<T>(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<Sx1262Radio*>(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<Sx1262Radio*>(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;
}
// 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();
}
}
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<uint8_t>(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, int32_t value) {
switch (parameter) {
case LORA_PARAMETER_POWER:
return checkLimitsAndApply(power, value, -9, 22);
case LORA_PARAMETER_BOOSTED_GAIN:
return checkLimitsAndApply(boostedGain, value, 0, 1, 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, 140);
default:
return ERROR_NOT_SUPPORTED;
}
}
error_t Sx1262Radio::setLoraParameter(enum LoraParameter parameter, int32_t value) {
switch (parameter) {
// Frequency in Hz (150..960 MHz)
case LORA_PARAMETER_FREQUENCY:
return checkLimitsAndApply(frequency, value, 150000000, 960000000);
// Bandwidth in Hz (RadioLib's supported LoRa bandwidths, expressed in Hz)
case LORA_PARAMETER_BANDWIDTH:
return checkValuesAndApply(bandwidth, value, {7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000});
case LORA_PARAMETER_SPREADING_FACTOR:
return checkLimitsAndApply(spreadingFactor, value, 7, 12, 1);
case LORA_PARAMETER_CODING_RATE:
return checkLimitsAndApply(codingRate, value, 5, 8, 1);
case LORA_PARAMETER_SYNC_WORD:
return checkLimitsAndApply(syncWord, value, 0, 255);
case LORA_PARAMETER_PREAMBLE_LENGTH:
return checkLimitsAndApply(preambleLength, value, 0, 65535);
default:
break;
}
LOG_W(TAG, "Tried to set unsupported LoRa parameter \"%s\" to %d", toString(parameter), (int)value);
return ERROR_NOT_SUPPORTED;
}
error_t Sx1262Radio::setFskParameter(enum LoraParameter parameter, int32_t value) {
switch (parameter) {
// Frequency in Hz (150..960 MHz)
case LORA_PARAMETER_FREQUENCY:
return checkLimitsAndApply(frequency, value, 150000000, 960000000);
// RX bandwidth in Hz (RadioLib's supported FSK bandwidths, expressed in Hz)
case LORA_PARAMETER_BANDWIDTH:
return checkValuesAndApply(bandwidth, value, {4800, 5800, 7300, 9700, 11700, 14600, 19500, 23400, 29300, 39000, 46900, 58600, 78200});
case LORA_PARAMETER_PREAMBLE_LENGTH:
return checkLimitsAndApply(preambleLength, value, 0, 65535);
// Bit rate in bit/s (0.6..300 kbps)
case LORA_PARAMETER_DATA_RATE:
return checkLimitsAndApply(bitRate, value, 600, 300000);
// Frequency deviation in Hz (0..200 kHz)
case LORA_PARAMETER_FREQUENCY_DEVIATION:
return checkLimitsAndApply(frequencyDeviation, value, 0, 200000);
default:
break;
}
LOG_W(TAG, "Tried to set unsupported FSK parameter \"%s\" to %d", toString(parameter), (int)value);
return ERROR_NOT_SUPPORTED;
}
error_t Sx1262Radio::setLrFhssParameter(enum LoraParameter parameter, int32_t value) {
switch (parameter) {
// Bandwidth in Hz (RadioLib's supported LR-FHSS bandwidths, expressed in Hz)
case LORA_PARAMETER_BANDWIDTH:
return checkValuesAndApply(bandwidth, value, {39060, 85940, 136720, 183590, 335940, 386720, 722660, 773440, 1523400, 1574200});
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, 1, 1);
default:
break;
}
LOG_W(TAG, "Tried to set unsupported LR-FHSS parameter \"%s\" to %d", toString(parameter), (int)value);
return ERROR_NOT_SUPPORTED;
}
error_t Sx1262Radio::setParameter(enum LoraParameter parameter, int32_t 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 = %d", toString(parameter), (int)value);
}
unlock();
return result;
}
error_t Sx1262Radio::getBaseParameter(enum LoraParameter parameter, int32_t* 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, int32_t* 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, int32_t* 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, int32_t* 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, int32_t* 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;
// RadioLib takes MHz/kHz/kbps floats; the driver stores Hz/bit/s integers.
const float frequencyMhz = static_cast<float>(frequency) / 1000000.0f;
const float bandwidthKhz = static_cast<float>(bandwidth) / 1000.0f;
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",
frequencyMhz,
bandwidthKhz,
spreadingFactor,
codingRate,
syncWord,
preambleLength,
power,
settings.tcxo_voltage
);
rc = radio.begin(
frequencyMhz,
bandwidthKhz,
spreadingFactor,
codingRate,
syncWord,
power,
preambleLength,
settings.tcxo_voltage,
settings.use_regulator_ldo
);
} else if (beginModulation == LORA_MODULATION_FSK) {
const float bitRateKbps = static_cast<float>(bitRate) / 1000.0f;
const float frequencyDeviationKhz = static_cast<float>(frequencyDeviation) / 1000.0f;
LOG_I(
TAG,
"Starting FSK: %.3f MHz, %.2f kbps, deviation %.1f kHz, BW %.1f kHz, preamble %u, %d dBm",
frequencyMhz,
bitRateKbps,
frequencyDeviationKhz,
bandwidthKhz,
preambleLength,
power
);
rc = radio.beginFSK(
frequencyMhz,
bitRateKbps,
frequencyDeviationKhz,
bandwidthKhz,
power,
preambleLength,
settings.tcxo_voltage,
settings.use_regulator_ldo
);
} else if (beginModulation == LORA_MODULATION_LR_FHSS) {
// NOTE: LR-FHSS is unvalidated. RadioLib's beginLRFHSS() takes
// (freq, bw-index, cr, narrowGrid, ...) where bw is a RADIOLIB_SX126X_LR_FHSS_BW_*
// index, not a frequency; this call passes the stored bandwidth into the freq slot
// and is known to be incomplete. Left as-is pending a dedicated LR-FHSS bring-up —
// the LoRa and FSK paths above are the hardware-validated ones.
LOG_I(TAG, "Starting LR-FHSS: BW %d Hz, CR %u, %s grid", (int)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(static_cast<float>(currentLimit));
if (rc != RADIOLIB_ERR_NONE) {
LOG_E(TAG, "Setting current limit to %d mA failed with code %hi", (int)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. 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;
}
// 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<uint8_t> 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

View File

@ -0,0 +1,175 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <tactility/concurrent/recursive_mutex.h>
#include <tactility/concurrent/thread.h>
#include <tactility/drivers/lora.h>
#include <tactility/error.h>
#include <tactility/freertos/event_groups.h>
#include <driver/gpio.h>
#include <driver/spi_master.h>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <vector>
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 on a snapshot of the list with
* the internal mutex released, 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<uint8_t> data;
};
template<typename Callback>
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<TxItem> txQueue;
TxItem currentTx;
LoraTxId lastTxId = 0;
std::vector<CallbackEntry<LoraStateCallback>> stateCallbacks;
std::vector<CallbackEntry<LoraRxCallback>> rxCallbacks;
std::vector<CallbackEntry<LoraTxCallback>> txCallbacks;
// Parameter store, applied on the next doBegin(). Frequencies/rates are held in base SI
// units (Hz, bit/s) and converted to RadioLib's MHz/kHz/kbps floats in doBegin().
int8_t power = -9;
int32_t frequency = 150000000; // Hz
int32_t bandwidth = 0; // Hz
uint8_t spreadingFactor = 0;
uint8_t codingRate = 0;
uint8_t syncWord = 0;
uint16_t preambleLength = 0;
int32_t bitRate = 0; // bit/s
int32_t frequencyDeviation = 0; // Hz
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).
int32_t currentLimit = 60; // mA
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, int32_t value);
error_t setLoraParameter(enum LoraParameter parameter, int32_t value);
error_t setFskParameter(enum LoraParameter parameter, int32_t value);
error_t setLrFhssParameter(enum LoraParameter parameter, int32_t value);
error_t getBaseParameter(enum LoraParameter parameter, int32_t* value) const;
error_t getLoraParameter(enum LoraParameter parameter, int32_t* value) const;
error_t getFskParameter(enum LoraParameter parameter, int32_t* value) const;
error_t getLrFhssParameter(enum LoraParameter parameter, int32_t* 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, int32_t value);
error_t getParameter(enum LoraParameter parameter, int32_t* 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);
};

View File

@ -0,0 +1,141 @@
// SPDX-License-Identifier: Apache-2.0
#include "sx126x_radiolib_hal.h"
#include <tactility/delay.h>
#include <tactility/drivers/spi_controller.h>
#include <tactility/log.h>
#include <cstring>
#include <esp_rom_gpio.h>
#include <esp_timer.h>
#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;
}
// 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) {
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;
}
}

View File

@ -0,0 +1,65 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <RadioLib.h>
#include <driver/gpio.h>
#include <driver/spi_master.h>
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;
};

View File

@ -0,0 +1,19 @@
// SPDX-License-Identifier: Apache-2.0
#include <tactility/driver.h>
#include <tactility/module.h>
extern "C" {
extern Driver sx1262_driver;
static Driver* const sx126x_drivers[] = {
&sx1262_driver,
nullptr
};
Module sx126x_module = {
.name = "sx126x",
.drivers = sx126x_drivers
};
} // extern "C"

View File

@ -0,0 +1,384 @@
// SPDX-License-Identifier: Apache-2.0
#include <new>
#include <tactility/delay.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#include <tactility/drivers/esp32_spi.h>
#include <tactility/drivers/gpio.h>
#include <tactility/drivers/gpio_controller.h>
#include <tactility/drivers/lora.h>
#include <tactility/drivers/spi_controller.h>
#include <tactility/log.h>
#include <tactility/module.h>
#include <drivers/sx1262.h>
#include <drivers/sx1262_radio.h>
#include <driver/gpio.h>
#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. The caller passes the pin's direction,
* because the devicetree pin specs carry no direction of their own.
*/
static GpioDescriptor* acquire_native_pin(const struct GpioPinSpec& spec, const char* pin_name, gpio_flags_t flags, 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, flags, 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;
}
// Polarity is applied by hand below, so the descriptor is acquired as a plain output.
auto* descriptor = gpio_descriptor_acquire(spec.gpio_controller, spec.pin, GPIO_FLAG_DIRECTION_OUTPUT, 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_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;
}
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.
auto* data = new (std::nothrow) Sx1262Internal();
if (data == nullptr) return ERROR_OUT_OF_MEMORY;
gpio_num_t pin_reset = GPIO_NUM_NC;
gpio_num_t pin_busy = GPIO_NUM_NC;
gpio_num_t pin_dio1 = GPIO_NUM_NC;
// DIO1 keeps a plain input here; registerDio1Isr() adds the interrupt flags when it
// attaches the handler, so the line stays quiet until the radio thread is ready for it.
data->pin_reset = acquire_native_pin(config->pin_reset, "pin-reset", GPIO_FLAG_DIRECTION_OUTPUT, &pin_reset);
data->pin_busy = acquire_native_pin(config->pin_busy, "pin-busy", GPIO_FLAG_DIRECTION_INPUT, &pin_busy);
data->pin_dio1 = acquire_native_pin(config->pin_dio1, "pin-dio1", GPIO_FLAG_DIRECTION_INPUT, &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<const Esp32SpiConfig*>(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<gpio_num_t>(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;
// 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;
}
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, int32_t 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, int32_t* 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"

View File

@ -84,5 +84,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'

View File

@ -0,0 +1,277 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <tactility/error.h>
#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 int32_t; the unit is documented per parameter.
* Frequencies and rates use their base SI unit (Hz, bit/s) on purpose: a float can't hold
* a value like 906875000 Hz (906.875 MHz) exactly, so integers avoid the rounding a
* fractional MHz/kHz representation would introduce.
*
* Which parameters are available depends on the driver and the selected modulation.
*/
enum LoraParameter {
/** TX output power in dBm */
LORA_PARAMETER_POWER = 0,
/** Boosted RX gain mode: 0 = off, 1 = on */
LORA_PARAMETER_BOOSTED_GAIN,
/** Carrier frequency in Hz */
LORA_PARAMETER_FREQUENCY,
/** Bandwidth in Hz */
LORA_PARAMETER_BANDWIDTH,
/** LoRa spreading factor (7-12) */
LORA_PARAMETER_SPREADING_FACTOR,
/** LoRa coding rate denominator (5-8 for 4/5 to 4/8) */
LORA_PARAMETER_CODING_RATE,
/**
* LoRa sync word (0x00-0xFF). Distinguishes otherwise-identical networks: a receiver
* only accepts packets whose sync word matches. Conventionally 0x12 for private
* networks and 0x34 for public/LoRaWAN. LoRa modulation only.
*/
LORA_PARAMETER_SYNC_WORD,
/** Preamble length in symbols (LoRa) or bits (FSK) */
LORA_PARAMETER_PREAMBLE_LENGTH,
/** FSK frequency deviation from the carrier in Hz */
LORA_PARAMETER_FREQUENCY_DEVIATION,
/** FSK bit rate in bits per second */
LORA_PARAMETER_DATA_RATE,
/** LR-FHSS grid spacing: 0 = 25 kHz (wide), 1 = 3.9 kHz (narrow) */
LORA_PARAMETER_NARROW_GRID,
/**
* 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.
*/
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, int32_t 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, int32_t* 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, int32_t value);
error_t lora_get_parameter(struct Device* device, enum LoraParameter parameter, int32_t* 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

View File

@ -0,0 +1,83 @@
// SPDX-License-Identifier: Apache-2.0
#include <tactility/drivers/lora.h>
#include <tactility/device.h>
#include <tactility/driver.h>
#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<struct Device**>(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, int32_t value) {
return LORA_API(device)->set_parameter(device, parameter, value);
}
error_t lora_get_parameter(struct Device* device, enum LoraParameter parameter, int32_t* 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"

View File

@ -21,6 +21,7 @@
#include <tactility/drivers/i2s_controller.h>
#include <tactility/drivers/i8080_controller.h>
#include <tactility/drivers/keyboard.h>
#include <tactility/drivers/lora.h>
#include <tactility/drivers/pointer.h>
#include <tactility/drivers/power_supply.h>
#include <tactility/drivers/pwm.h>
@ -387,6 +388,24 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(WIFI_TYPE),
// wifi_auto_scan
DEFINE_MODULE_SYMBOL(wifi_auto_scan_set_paused),
// 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_subscribe),