Skip to content

Add native SPI LoRa APRS driver (SX1276/SX1262) for LoRa APRS tx and rx, and SDR LoRa APRS receive path#638

Open
radiohound wants to merge 7 commits intowb2osz:devfrom
radiohound:feature/lora-spi
Open

Add native SPI LoRa APRS driver (SX1276/SX1262) for LoRa APRS tx and rx, and SDR LoRa APRS receive path#638
radiohound wants to merge 7 commits intowb2osz:devfrom
radiohound:feature/lora-spi

Conversation

@radiohound
Copy link
Copy Markdown

Overview

This PR adds LoRa APRS support to Dire Wolf, targeting Raspberry Pi with a LoRa hat wired to the SPI bus. Two receive paths are provided:

  1. Native SPI driver (LCHANNEL) — new channel type compiled directly into Dire Wolf. Talks to SX1276/SX1278/SX1262 chips via Linux SPI and libgpiod. Full TX and RX. No Python or extra processes required.

  2. SDR bridge (lora_sdr_bridge.py) — Python script using RTL-SDR + GNU Radio + gr-lora_sdr for receive, connected to Dire Wolf over KISS TCP (NCHANNEL). RX only; TX falls through to the native driver if both are configured.

Both paths support iGate, digipeating, and beaconing through the normal Dire Wolf configuration.

Configuration

Native SPI driver:

LCHANNEL 10
MYCALL N0CALL-2
LORAHW meshadv
LORAFREQ 433.775
LORASF 12
LORABW 125
LORACR 5
LORASW 0x12
LORATXPOWER 17
SDR bridge (optional, RX-only second channel):

NCHANNEL 11 127.0.0.1 8002
Run alongside: python3 lora_sdr_bridge.py -c lora.conf

Hardware profiles

Profiles are selected with LORAHW in direwolf.conf. New profiles can be added by inserting a row in the s_profiles[] array in src/loraspi.c.

lorapi_rfm98w — Digital Concepts LoRa-Pi RFM98W, SX1278, 433 MHz — TX and RX tested
lorapi_rfm95w — Digital Concepts LoRa-Pi RFM95W, SX1276, 868/915 MHz
meshadv — MeshAdv Pi Hat (E22-400M30S, 1W PA), SX1262, 433 MHz — TX and RX tested
e22_900m30s — Ebyte E22-900M30S, SX1262, 868/915 MHz
e22_400m30s — Ebyte E22-400M30S, SX1262, 433 MHz
Notable bugs fixed during development
SX1262 GET_RX_BUFFER_STATUS byte offset
The SX1262 echoes the status byte twice in the GET_RX_BUFFER_STATUS response. RxPayloadLength and RxStartBufferPointer are at response bytes [2] and [3], not [1] and [2] as implied by some versions of the datasheet. Without this fix nb receives the status byte value (e.g. 0xD4 = 212) instead of the actual packet length (~47 bytes), causing every received frame to be padded with ~165 bytes of garbage from the chip buffer.

Pi 4 kernel 6.12 GPIO base offset
On Pi 4 with kernel 6.12, the BCM GPIO chip is registered as gpiochip512 (base=512) rather than gpiochip0 (base=0). Writing raw BCM pin numbers to /sys/class/gpio/export silently failed, leaving CS, BUSY, RST, TXEN, and RXEN as no-ops. Added gpio_sysfs_num() which scans /sys/class/gpio/gpiochip*/base at startup and applies the correct offset automatically.

SX1262 sync word encoding
The SX1262 uses a 16-bit sync word at registers 0x0740/0x0741. The SX1276-style single byte (e.g. 0x12) must be expanded by nibble: 0x12 becomes 0x1424. The expansion is sw_hi = (sw >> 4) << 4 | 0x04, sw_lo = (sw & 0x0F) << 4 | 0x04.

Test results

Tested on Raspberry Pi 4, kernel 6.12:

✓ TX beacon from MeshAdv hat (SX1262, 433 MHz) confirmed received by K6ATV-31 and uploaded to APRS-IS
✓ RX on MeshAdv hat decoding K6ATV-1 T-Echo beacons cleanly (RSSI=-106 dBm, SNR=20.5 dB)
✓ TX and RX on Digital Concepts LoRa-Pi hat (SX1278/RFM98W, 433 MHz)
✓ LCHANNEL and NCHANNEL coexisting in the same direwolf.conf

radiohound and others added 7 commits March 27, 2026 16:22
Introduces loraspi.c — a native Linux SPI/GPIO driver for SX1276 and
SX1262 LoRa chips — as a new channel medium alongside MEDIUM_RADIO,
MEDIUM_NETTNC, and MEDIUM_SERTNC.

New source files:
  src/loraspi.c  — driver: chip init, rx_thread (dlq_rec_frame injection),
                   tx_thread (AX.25 → TNC2 → LoRa preamble → SPI TX),
                   hardware profile table (loraspi_apply_profile)
  src/loraspi.h  — public API: loraspi_init, loraspi_send_packet,
                   loraspi_apply_profile

Modified source files:
  src/audio.h      — MEDIUM_LORA added to enum medium_e; ~18 lora_*[]
                     config fields added to struct audio_s
  src/config.c     — LCHANNEL, LORAHW, LORAFREQ, LORASF, LORABW, LORACR,
                     LORASW, LORATXPOWER directives parsed; all
                     chan_medium validity checks extended for MEDIUM_LORA
  src/direwolf.c   — loraspi_init() called at startup after sertnc_init()
  src/tq.c         — transmit queue routes MEDIUM_LORA packets to
                     loraspi_send_packet()
  src/beacon.c     — MEDIUM_LORA accepted for PBEACON/OBEACON channels
  src/digipeater.c — MEDIUM_LORA accepted as valid receive channel
  src/cdigipeater.c — MEDIUM_LORA accepted; bounds check widened from
                     MAX_RADIO_CHANS to MAX_TOTAL_CHANS for virtual channels
  src/CMakeLists.txt — loraspi.c added to direwolf source list

The driver is Linux-only; non-Linux platforms get empty stubs so the build
is unconditional.  A per-channel pthread_mutex_t serialises SPI access
between the rx and tx threads.  The chip is re-armed to continuous RX mode
after each transmission (SX1276 drops to STDBY on TX_DONE).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bridge scripts (scripts/):
  lora_kiss_bridge.py   — hardware bridge: LoRaRF Python library over SPI,
                          connects to Dire Wolf LORAPORT as TNC2 TCP client,
                          RX + TX; reads hardware_profiles.yaml + lora.conf
  lora_sdr_bridge.py    — SDR bridge: KISS TCP server; Dire Wolf connects
                          via NCHANNEL; GNU Radio passes decoded LoRa frames
                          via lora_sdr_flowgraph.py; RX only
  lora_sdr_flowgraph.py — GNU Radio flowgraph: RTL-SDR IQ → gr-lora_sdr
                          demodulation → PDU messages to lora_sdr_bridge.py

Documentation (doc/):
  LoRa-APRS.md          — end-user setup for both LCHANNEL (native SPI,
                          recommended) and lora_kiss_bridge.py (Python)
  LoRa-SDR.md           — end-user setup for RTL-SDR receive path
  LoRa-Implementation.md — developer notes: architecture, modified/new
                          files, design decisions, upstream PR guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Digital Concepts LoRa-Pi hat ships in two variants: RFM95W (SX1276,
868/915 MHz) and RFM98W (SX1278, 433 MHz).  SX1276 and SX1278 are
register-identical; add a lorapi_rfm98w alias with the same pin
assignments so users can select the correct name for their module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SX1262 uses a 2-byte sync word at registers 0x0740/0x0741 with a
different encoding than SX1276's single-byte value.  The correct
mapping is: each nibble of the SX1276 byte becomes a nibble with
0x4 appended — e.g. 0x12 (LoRa APRS) becomes 0x1424, 0x34
(Meshtastic/private) becomes 0x3444.

Previously the code split lc->sw (0x0012) directly as 0x00/0x12,
which would leave the SX1262 with the wrong sync word and unable
to communicate with any LoRa APRS device.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs prevented TX on the MeshAdv Pi Hat with kernel 6.12:

1. GPIO sysfs base offset: on Pi 4 with kernel 6.12 the main BCM GPIO
   chip is registered as gpiochip512 (base=512, not 0).  Writing BCM pin
   numbers directly to /sys/class/gpio/export silently failed, leaving CS,
   BUSY, RESET, TXEN, and RXEN as no-ops.  Add gpio_sysfs_num() which
   scans /sys/class/gpio/gpiochip*/base at startup, picks the chip with
   the lowest base that has >=28 GPIOs, and applies that offset to every
   BCM pin number.

2. GetIrqStatus byte offset: SX1262 GetIrqStatus returns four bytes:
   [status, echoed_status, IRQ[15:8], IRQ[7:0]].  The poll loops were
   computing irq = (rx[1]<<8)|rx[2] and missing TxDone (bit 0 of rx[3]).
   Fix to irq = (rx[2]<<8)|rx[3] in both sx1262_transmit and sx1262_receive.

Also remove TX diagnostic code that was added during debugging.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
install-lora.sh automates full LoRa APRS setup on Raspberry Pi 3/4/5:
- Pi 3: direwolf + native SPI hat (LCHANNEL) + Python bridge (LORAPORT)
- Pi 4/5: full install including GNU Radio and gr-lora_sdr SDR path

Steps performed:
- detects Pi model, installs build dependencies
- blacklists dvb_usb_rtl28xxu kernel driver for RTL-SDR
- builds gr-lora_sdr and Dire Wolf from source
- prompts for callsign, passcode, frequency, hardware profile
- writes /etc/direwolf/direwolf.conf and lora.conf
- installs and enables systemd services as the login user

Also adds systemd/lora-sdr-bridge.service for the SDR bridge path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The SX1262 echoes the status byte twice in the GET_RX_BUFFER_STATUS
response (bytes [0] and [1] are both status), so RxPayloadLength and
RxStartBufferPointer are at bytes [2] and [3], not [1] and [2].
Using the wrong offsets caused nb=212 (status byte value) instead of
the actual packet length (~47 bytes), resulting in garbage-padded RX.

Also separate TX/RX buffer base addresses (TX=0x00, RX=0x80) to
prevent any overlap between transmitted and received data.

Tested on MeshAdv-Pi Hat (E22-400M30S, SX1262, 433 MHz):
- TX confirmed via APRS-IS (heard by K6ATV-31)
- RX confirmed decoding DL5TKL T-Echo beacons cleanly

Updated LoRa-APRS.md: corrected MeshAdv frequency, added TX/RX
tested status to hardware table, added note on SX1262 status byte
echo quirk for future porters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Pandapip1
Copy link
Copy Markdown

Out of curiosity, I haven't seen an actual spec for APRS over LoRA. How is this done, is it just transmitting the raw AX.25 frames?

@radiohound
Copy link
Copy Markdown
Author

Out of curiosity, I haven't seen an actual spec for APRS over LoRA. How is this done, is it just transmitting the raw AX.25 frames?

APRS over LoRa follows the same APRS specifications as traditional APRS. However, the characters are sent to the LoRa modules in a TNC2-style header (source, dest, path as plain text).

@wb2osz
Copy link
Copy Markdown
Owner

wb2osz commented Apr 1, 2026

Walter: Thank you for this very significant and interesting submission.
I will have to buy an RPi LoRa HAT and try it.
73,
John WB2OSZ

@radiohound
Copy link
Copy Markdown
Author

Walter: Thank you for this very significant and interesting submission. I will have to buy an RPi LoRa HAT and try it. 73, John WB2OSZ

You are welcome John,

Walter K6ATV

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.

3 participants