Skip to content

LoRa device type + SX1262 kernel driver (first checkpoint)#565

Open
Crazypedia wants to merge 5 commits into
TactilityProject:mainfrom
Crazypedia:lora-driver-checkpoint
Open

LoRa device type + SX1262 kernel driver (first checkpoint)#565
Crazypedia wants to merge 5 commits into
TactilityProject:mainfrom
Crazypedia:lora-driver-checkpoint

Conversation

@Crazypedia

@Crazypedia Crazypedia commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Tracking: #342

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

What this adds

  • lora device type (<tactility/drivers/lora.h>, TactilityKernel): a C ABI for
    sub-GHz packet transceivers — enable/disable, modulation selection, per-modulation
    parameters (float-valued, documented units), a copy-in TX queue with per-packet
    progress, and RX/TX/state callbacks. Modulation-agnostic by design (LoRa, FSK,
    LR-FHSS), with can_transmit/can_receive capability queries so callers can discover
    what a given modem supports.
  • sx126x-module kernel driver: implements the lora API for the SX1262 on top of
    RadioLib (managed component jgromes/radiolib,
    pinned). Registers a lora device as a child of an SPI controller; CS comes from the
    parent's cs-gpios entry.
  • Devicetree binding (semtech,sx1262) + example, and wiring on the LilyGO
    T-Deck Max
    .

Design notes for review

  • RadioLib name collision: RadioLib declares a global class Module that clashes
    with the kernel's struct Module. RadioLib headers are kept out of every header via a
    RadioParts pimpl, so no translation unit sees both.
  • Bus sharing: RadioLib holds CS low across multi-transfer exchanges, 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 (EPD
    display, SD) that don't take the controller lock. Nothing else takes both, so there is
    no lock-ordering hazard.
  • DIO1 interrupts: DIO1 is owned through the kernel's GPIO descriptor callback API in
    HIGH_LEVEL mode. Edge-triggered interrupts are avoided on purpose (ESP32 erratum
    3.11: a subsequent edge interrupt may be missed, which for a radio would drop an
    RX/TX-done and stall the thread). The radio thread arms the interrupt each cycle; the
    ISR masks it on each fire and signals the thread, which re-arms after clearing the modem
    IRQ. RadioLib's attachInterrupt/detachInterrupt are deliberately no-ops.
  • Start vs enable: start_device selects the antenna path, powers the module, and
    runs a GPIO-only probe (reset pulse → wait for BUSY low) so an absent/unpowered or
    miswired module fails at device start instead of as an opaque RadioLib error later. The
    modem is only configured when the radio is enabled via lora_set_enabled() (requires a
    modulation first). Teardown stops the radio thread, sleeps the modem, and powers down in
    reverse order.
  • Pin flexibility (portability seam): CS/reset/busy/DIO1 must be SoC GPIOs
    RadioLib's HAL drives them directly through ESP-IDF, so they can't sit behind an IO
    expander. The driver-registration layer enforces this (acquire_native_pin fails with
    "must be an SoC GPIO"). The optional enable and antenna-select lines may sit behind an
    IO expander (e.g. xl9555 on the T-Deck Max), resolved through the GPIO descriptor API
    with active-low polarity applied by the driver. A board that routes reset/CS through an
    expander is therefore not supported by this first checkpoint; the constraint is a
    deliberate, documented scope limit rather than an accident, and the kernel lora API is
    unaffected either way.
  • TX-done timeout: derived from the packet's time-on-air (getTimeOnAir) plus a fixed
    margin, so slow configs (high SF / narrow BW, airtime up to seconds) don't
    false-time-out; a fixed fallback covers configs RadioLib can't compute airtime for.
  • Over-current protection (fail-safe default): OCP defaults to RadioLib's 60 mA, which
    caps PA current into a bad/disconnected antenna but also caps output below +22 dBm.
    Because Tactility targets a variety of hardware (not all T-Decks), the conservative
    default is kept and a board-aware consumer can opt into a higher limit via
    LORA_PARAMETER_CURRENT_LIMIT (up to 140 mA). OCP is not auto-derived from output power.
  • Callback threading: callbacks run with no driver lock held (from the radio thread
    for RX/TX-progress/state, or the caller's thread for the QUEUED event), so a consumer
    can safely take its own locks in a callback.

Hardware validation

  • T-Deck Max (ESP32-S3, SX1262 @ SPI2, TCXO 2.4 V, DIO2 RF switch): clean boot, GPIO probe
    OK, modem init, TX-done at the expected airtime, clean teardown to sleep, no EPD/SD bus
    clashes on the shared host.
  • TX and RX both confirmed on-air, two-way (LoRa LongFast broadcast plus direct
    messages, exchanged against other nodes) — the RX path was exercised through an
    out-of-tree consumer that sits on this same lora API; that consumer is not part of this
    PR, but it proves the driver's RX/TX-done duty-cycle and callback delivery on hardware.
  • This branch itself (driver only, no consumer) builds for the T-Deck Max and passes the
    POSIX/simulator test suites, so the driver is self-contained.

Known limitations / scope

  • SX1262 only for now (module is named for the sx126x family to leave room for
    SX1261/SX1268). RadioLib supports many other transceivers, so additional chips can be
    added as sibling driver modules implementing the same lora API, with no kernel change.
  • Reset/CS behind an IO expander is not supported (see the pin-flexibility note above).
  • No CAD / listen-before-talk yet — regional duty-cycle/LBT policy is expected to live
    above the driver.
  • FSK addressed transmission is not exposed through the lora API.
  • Antenna-presence detection is not implemented. The SX1262 has no hardware
    antenna-detect pin (unlike, e.g., u-blox SARA modules' dedicated ANT_DET ADC circuit),
    and the T-Deck Max 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; noting the attempt and intended
    method here to invite feedback on the method before building it.
  • Simulator has no radio backend; the lora device-type dispatch layer builds on POSIX
    but the SX1262 driver is ESP32-only (RadioLib + ESP GPIO/SPI).

Status

First checkpoint. The radio is functional and HW-validated; the items under "known
limitations" are the acknowledged next steps rather than blockers. Opening against #342 as
an in-progress milestone.

Files

Three commits, off the current main:

  1. feat(kernel): add generic LoRa device type APITactilityKernel lora.h /
    lora.cpp / kernel_symbols.c
  2. feat(drivers): add sx126x-module kernel driver for the Semtech SX1262
    Drivers/sx126x-module/ (driver, RadioLib HAL shim, binding, README) +
    Firmware/idf_component.yml (RadioLib dependency)
  3. feat(lilygo-tdeck-max): wire up the SX1262 LoRa radioDevices/lilygo-tdeck-max/
    devicetree wiring

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a new LoRa (SX1262) transceiver driver for the LilyGO T-Deck Max with queued TX, RX packet delivery, and radio state/Tx/Rx lifecycle callbacks (including signal-quality reporting).
    • Introduced a public LoRa driver API with modulation selection, parameter set/get, transmit/receive capability checks, and callback registration.
    • Added Semtech SX1262 device tree support, including optional antenna switch and enable GPIO wiring, plus SPI binding/schema definitions.
  • Documentation
    • Added module documentation covering startup behavior, wiring expectations, and runtime constraints.
  • Chores
    • Updated build configuration to pull in the required RadioLib dependency when targeting supported ESP-IDF platforms.

Crazypedia and others added 3 commits July 15, 2026 09:56
Add a LORA_TYPE kernel device type and LoraApi for sub-GHz packet transceivers:
enable/disable, modulation selection (LoRa / FSK / LR-FHSS), float-valued
per-modulation parameters with documented units, a copy-in TX queue with
per-packet progress, and RX/TX/state callbacks. Modulation-agnostic with
can_transmit / can_receive capability queries so callers can discover what a
given modem supports. Sits alongside the wifi and bluetooth device types
(function-specific, not a generic "radio").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implement the lora device type for the Semtech SX1262 on top of RadioLib (MIT,
managed component jgromes/radiolib). Registers a lora device as a child of an SPI
controller; CS comes from the parent's cs-gpios entry. RadioLib is kept behind a
RadioParts pimpl so its global `class Module` never shares a translation unit with
the kernel's `struct Module`.

- Runs the radio on a dedicated thread with an event-group mailbox; callbacks fire
  without the driver lock held (snapshot + release) so consumers can take their own
  locks in callbacks without an AB-BA deadlock.
- DIO1 uses the kernel GPIO descriptor callback API in HIGH_LEVEL mode, armed per
  cycle and masked by the ISR on each fire. Edge interrupts are avoided on purpose
  (ESP32 erratum 3.11: a subsequent edge interrupt may be missed, which for a radio
  would drop an RX/TX-done and stall the thread).
- SPI exchanges run inside the kernel SPI controller lock (outer) and ESP-IDF's
  per-host bus lock (inner), so a manually-CS'd RadioLib exchange is atomic against
  both cooperating kernel drivers and the IDF-managed display/SD devices.
- start_device selects the antenna path, powers the module and runs a GPIO-only
  probe (reset pulse -> wait for BUSY low) so an absent/miswired module fails at
  device start; the modem is configured only when the radio is enabled.
- CS/RESET/BUSY/DIO1 must be SoC GPIOs (the RadioLib HAL drives them directly);
  the optional enable and antenna-select lines may sit behind an IO expander.
- TX-done timeout is derived from time-on-air; PA over-current protection defaults
  to RadioLib's fail-safe 60 mA and is raisable via LORA_PARAMETER_CURRENT_LIMIT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the SX1262 radio node to the T-Deck Max devicetree (CS on SPI index 2 / GPIO3,
reset/DIO1/busy on GPIO 4/5/6, enable and antenna-select on the xl9555 expander,
2.4 V TCXO, DIO2 as RF switch) and declare the sx126x-module dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bd7a632f-06aa-4e13-b4d9-f216bea2e754

📥 Commits

Reviewing files that changed from the base of the PR and between 87076e1 and c35dd72.

📒 Files selected for processing (2)
  • Firmware/idf_component.yml
  • TactilityKernel/source/kernel_symbols.c
💤 Files with no reviewable changes (1)
  • Firmware/idf_component.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • TactilityKernel/source/kernel_symbols.c

📝 Walkthrough

Walkthrough

Adds a public LoRa driver API with modulation, parameter, transmission, reception, and callback interfaces. Introduces an SX1262 module with devicetree bindings, RadioLib integration, ESP-IDF GPIO/SPI handling, threaded radio operations, and driver lifecycle wiring. Registers the API symbols and RadioLib dependency, then configures SX1262 support and board-specific GPIO wiring for the LilyGO T-Deck Max.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: a new LoRa device type and an SX1262 kernel driver.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts (1)

64-79: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider adding the reg property to match the unit address.

The node is named radio@2, but lacks the corresponding reg = <2>; property. Standard device tree compilers typically emit a warning when a node has a unit name but no reg property. While the system appears to correctly infer the SPI chip-select index from the cs-gpios array, adding reg explicitly aligns with standard devicetree conventions.

♻️ Proposed refactor
 		radio@2 {
 			compatible = "semtech,sx1262";
+			reg = <2>;
 			pin-reset = <&gpio0 4 GPIO_FLAG_NONE>;
 			pin-dio1 = <&gpio0 5 GPIO_FLAG_NONE>;
Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp (1)

23-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stop reading gpiohal.dev->pin[pin].int_type directly. ESP-IDF doesn’t expose a public getter for the current interrupt type, so this helper should track the intended intr_type itself (or document the private HAL dependency) instead of relying on GPIO_LL_GET_HW/gpio_hal_context_t internals.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6b3c4dd1-adf2-494c-ba99-4a66295768cc

📥 Commits

Reviewing files that changed from the base of the PR and between bd8fdfd and 43ce969.

📒 Files selected for processing (20)
  • Devices/lilygo-tdeck-max/devicetree.yaml
  • Devices/lilygo-tdeck-max/lilygo,tdeck-max.dts
  • Drivers/sx126x-module/CMakeLists.txt
  • Drivers/sx126x-module/LICENSE-Apache-2.0.md
  • Drivers/sx126x-module/README.md
  • Drivers/sx126x-module/bindings/semtech,sx1262.yaml
  • Drivers/sx126x-module/devicetree.yaml
  • Drivers/sx126x-module/include/bindings/sx1262.h
  • Drivers/sx126x-module/include/drivers/sx1262.h
  • Drivers/sx126x-module/include/sx126x_module.h
  • Drivers/sx126x-module/private/drivers/sx1262_radio.cpp
  • Drivers/sx126x-module/private/drivers/sx1262_radio.h
  • Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.cpp
  • Drivers/sx126x-module/private/drivers/sx126x_radiolib_hal.h
  • Drivers/sx126x-module/source/module.cpp
  • Drivers/sx126x-module/source/sx1262.cpp
  • Firmware/idf_component.yml
  • TactilityKernel/include/tactility/drivers/lora.h
  • TactilityKernel/source/drivers/lora.cpp
  • TactilityKernel/source/kernel_symbols.c

Comment thread Drivers/sx126x-module/private/drivers/sx1262_radio.cpp
Comment thread Drivers/sx126x-module/private/drivers/sx1262_radio.cpp
Comment thread Drivers/sx126x-module/source/sx1262.cpp
- Service a received packet before deciding to transmit: an RX-done and a
  queued TX could coincide in the same radio-thread iteration, and the old
  else-branch dropped the packet outright.
- Publish a terminal ERROR state when the radio thread is interrupted after
  finishTransmit(), so a caller whose TX raced setEnabled(false) still gets
  a final callback for its id.
- Deactivate pin-antenna-select on probe failure too, mirroring stop().
- Add the reg property for the radio's SPI unit address: declared in the
  binding (the devicetree compiler rejects undeclared properties), carried
  in Sx1262Config, and checked against the node's unit address at device
  start so the two can't silently disagree.
- Drop the private gpio_hal_context_t read in the RadioLib HAL's pinMode():
  configure pad routing, direction and pulls through public per-aspect APIs
  that leave the interrupt configuration untouched, so the kernel-owned DIO1
  HIGH_LEVEL interrupt survives without reading ESP-IDF HAL internals.

Hardware-validated on the LilyGO T-Deck Max: GPIO probe, modem init through
the new pinMode path, TX-done at real airtime, and RX from three nodes
interleaved with TX, with no errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Crazypedia

Copy link
Copy Markdown
Contributor Author

Both nitpicks addressed in 87076e1, each with a wrinkle worth noting:

reg property: the committable suggestion doesn't compile as-is — this repo's devicetree compiler rejects properties not declared in a node's binding (Device 'radio' has invalid property 'reg'), and the CS index actually comes from the @2 unit address (device->address), not reg. Wired it through properly instead: reg is now declared in the semtech,sx1262 binding, carried in Sx1262Config, and validated against the unit address at device start, so a board definition where the two disagree fails loudly instead of one value being silently ignored.

Private gpio_hal_context_t read: tracking the intended intr_type in the HAL shim would actually recreate the bug the read-back prevented — the shim never learns DIO1's interrupt type because the kernel GPIO descriptor API owns it, so a shim-local default would clobber the HIGH_LEVEL config when RadioLib calls pinMode() on DIO1 during begin(). Removed the problem instead of documenting it: pinMode() now uses public per-aspect APIs (esp_rom_gpio_pad_select_gpio / gpio_set_direction / gpio_set_pull_mode) that don't touch interrupt configuration at all, so there's nothing to preserve and no private-struct dependency. Hardware-confirmed: modem init and TX/RX-done interrupts all work through the new path.

@KenVanHoeylandt

Copy link
Copy Markdown
Contributor

Thank you! I will look at this in detail in the coming days, but the basic premise looks good.

# Conflicts:
#	TactilityKernel/source/kernel_symbols.c

@KenVanHoeylandt KenVanHoeylandt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! I only have 1 area of feedback with regards to using float values.

LORA_PARAMETER_FREQUENCY_DEVIATION,
LORA_PARAMETER_DATA_RATE,
LORA_PARAMETER_NARROW_GRID,
LORA_PARAMETER_CURRENT_LIMIT,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some things to change:

  • The parameter documentation should be above each enum value. It makes it easier to see if docs are missing.
  • LoraParameter seems to work with floats, but float is not a reliable format: If you have a value like 1.1f it might internally be something like 1.09999999 instead. Let's make it a int32_t instead.
  • SYNC_WORD: doesn't mention what the possible values are.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also like to see the private/drivers/sx1262_radio.cpp adapter to use int32_t instead of floats.

@KenVanHoeylandt

Copy link
Copy Markdown
Contributor

Sorry about the git conflicts 😅

@Crazypedia

Copy link
Copy Markdown
Contributor Author

No worries I'll resolve it when I do the hardware checks tomorrow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants