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).
This commit is contained in:
Crazypedia
2026-07-30 13:32:23 -04:00
committed by GitHub
parent 606b918f9c
commit f13c18f398
20 changed files with 2548 additions and 1 deletions
@@ -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
@@ -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);
};
@@ -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;
}
}
@@ -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;
};