Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add native SPI LoRa driver (SX1276/SX1262) as MEDIUM_LORA channel type
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>
  • Loading branch information
radiohound and claude committed Mar 27, 2026
commit 620eba5b2a71416c3f2a08ffa3da3c59cbf15241
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ list(APPEND direwolf_SOURCES
tnc_common.c
nettnc.c
sertnc.c
loraspi.c
serial_port.c
pfilter.c
ptt.c
Expand Down
25 changes: 24 additions & 1 deletion src/audio.h
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ enum medium_e { MEDIUM_NONE = 0, // Channel is not valid for use.
MEDIUM_RADIO, // Internal modem for radio.
MEDIUM_IGATE, // Access IGate as ordinary channel.
MEDIUM_NETTNC, // Remote network TNC. (new in 1.8)
MEDIUM_SERTNC }; // Local serial TNC. (new in 1.8)
MEDIUM_SERTNC, // Local serial TNC. (new in 1.8)
MEDIUM_LORA }; // Native SPI LoRa hat (SX1276/SX1262).


typedef enum sanity_e { SANITY_APRS, SANITY_AX25, SANITY_NONE } sanity_t;
Expand Down Expand Up @@ -154,6 +155,7 @@ struct audio_s {
// MEDIUM_IGATE allows application access to IGate.
// MEDIUM_NETTNC for external TNC via TCP.
// MEDIUM_SERTNC for external TNC via serial port.
// MEDIUM_LORA for native SPI LoRa hat (SX1276/SX1262).

int igate_vchannel; /* Virtual channel mapped to APRS-IS. */
/* -1 for none. */
Expand All @@ -172,6 +174,27 @@ struct audio_s {

int sertnc_baud[MAX_TOTAL_CHANS]; // Serial TNC baud rate.

// Applies only to native SPI LoRa channels (MEDIUM_LORA).

int lora_chip[MAX_TOTAL_CHANS]; // LORA_CHIP_SX1276 or LORA_CHIP_SX1262 (from loraspi.h)
float lora_freq_mhz[MAX_TOTAL_CHANS]; // Centre frequency, e.g. 915.0
int lora_sf[MAX_TOTAL_CHANS]; // Spreading factor 6-12
float lora_bw_khz[MAX_TOTAL_CHANS]; // Bandwidth: 125, 250, 500 kHz
int lora_cr[MAX_TOTAL_CHANS]; // Coding rate 5-8 (denominator of 4/x)
int lora_sw[MAX_TOTAL_CHANS]; // Sync word (0x12 private, 0x34 LoRa-APRS)
int lora_txpower[MAX_TOTAL_CHANS]; // TX power dBm
int lora_pin_cs[MAX_TOTAL_CHANS]; // GPIO chip-select (BCM)
int lora_pin_reset[MAX_TOTAL_CHANS]; // GPIO reset (-1 = not wired)
int lora_pin_irq[MAX_TOTAL_CHANS]; // GPIO DIO0/IRQ (-1 = not wired)
int lora_pin_busy[MAX_TOTAL_CHANS]; // GPIO BUSY for SX1262 (-1 = not used)
int lora_pin_tx_en[MAX_TOTAL_CHANS]; // GPIO TX-enable for E22 (-1 = not used)
int lora_pin_rx_en[MAX_TOTAL_CHANS]; // GPIO RX-enable for E22 (-1 = not used)
int lora_pa_boost[MAX_TOTAL_CHANS]; // 1 = use PA_BOOST pin (SX1276)
int lora_tcxo[MAX_TOTAL_CHANS]; // 1 = TCXO fitted (SX1262)
float lora_tcxo_voltage[MAX_TOTAL_CHANS]; // TCXO supply voltage, e.g. 1.8
int lora_spi_bus[MAX_TOTAL_CHANS]; // spidev bus number (default 0)
int lora_spi_dev[MAX_TOTAL_CHANS]; // spidev device number (default 0)
int lora_spi_speed[MAX_TOTAL_CHANS]; // SPI clock Hz (default 8000000)


/* Properties for each radio channel, common to receive and transmit. */
Expand Down
3 changes: 2 additions & 1 deletion src/beacon.c
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ void beacon_init (struct audio_s *pmodem, struct misc_config_s *pconfig, struct

if (g_modem_config_p->chan_medium[chan] == MEDIUM_RADIO ||
g_modem_config_p->chan_medium[chan] == MEDIUM_NETTNC ||
g_modem_config_p->chan_medium[chan] == MEDIUM_SERTNC) {
g_modem_config_p->chan_medium[chan] == MEDIUM_SERTNC ||
g_modem_config_p->chan_medium[chan] == MEDIUM_LORA) {

if (strlen(g_modem_config_p->mycall[chan]) > 0 &&
strcasecmp(g_modem_config_p->mycall[chan], "N0CALL") != 0 &&
Expand Down
5 changes: 3 additions & 2 deletions src/cdigipeater.c
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,11 @@ void cdigipeater (int from_chan, packet_t pp)
// Connected mode is allowed only for channels with internal modem.
// It probably wouldn't matter for digipeating but let's keep that rule simple and consistent.

if ( from_chan < 0 || from_chan >= MAX_RADIO_CHANS ||
if ( from_chan < 0 || from_chan >= MAX_TOTAL_CHANS ||
(save_audio_config_p->chan_medium[from_chan] != MEDIUM_RADIO &&
save_audio_config_p->chan_medium[from_chan] != MEDIUM_NETTNC &&
save_audio_config_p->chan_medium[from_chan] != MEDIUM_SERTNC) ) {
save_audio_config_p->chan_medium[from_chan] != MEDIUM_SERTNC &&
save_audio_config_p->chan_medium[from_chan] != MEDIUM_LORA) ) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("cdigipeater: Did not expect to receive on invalid channel %d.\n", from_chan);
return;
Expand Down
197 changes: 190 additions & 7 deletions src/config.c
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
#include "xmit.h"
#include "tt_text.h"
#include "ax25_link.h"
#include "loraspi.h"

#if USE_CM108 // Current Linux or Windows only
#include "cm108.h"
Expand Down Expand Up @@ -1444,6 +1445,181 @@ void config_init (char *fname, struct audio_s *p_audio_config,
}
}

/*
* LCHANNEL chan
*
* Declare a virtual channel as a native SPI LoRa channel (SX1276/SX1262).
* Sets the current channel context so subsequent MYCALL, LORAHW, LORAFREQ,
* LORASF, LORABW, LORACR, LORASW, LORATXPOWER directives apply to it.
*
* Example:
* LCHANNEL 10
* MYCALL K6ATV-2
* LORAHW lorapi_rfm95w
* LORAFREQ 915.0
* LORASF 12
*/
else if (strcasecmp(t, "LCHANNEL") == 0) {
t = split(NULL,0);
if (t == NULL) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: Missing channel number for LCHANNEL command.\n", line);
continue;
}
int lchan = atoi(t);
if (lchan >= MAX_RADIO_CHANS && lchan < MAX_TOTAL_CHANS) {
if (p_audio_config->chan_medium[lchan] == MEDIUM_NONE) {
p_audio_config->chan_medium[lchan] = MEDIUM_LORA;
/* Set defaults */
p_audio_config->lora_freq_mhz[lchan] = 915.0f;
p_audio_config->lora_sf[lchan] = 12;
p_audio_config->lora_bw_khz[lchan] = 125.0f;
p_audio_config->lora_cr[lchan] = 5;
p_audio_config->lora_sw[lchan] = 0x34; /* LoRa-APRS sync word */
p_audio_config->lora_txpower[lchan] = 20;
p_audio_config->lora_pin_cs[lchan] = -1;
p_audio_config->lora_pin_reset[lchan] = -1;
p_audio_config->lora_pin_irq[lchan] = -1;
p_audio_config->lora_pin_busy[lchan] = -1;
p_audio_config->lora_pin_tx_en[lchan] = -1;
p_audio_config->lora_pin_rx_en[lchan] = -1;
p_audio_config->lora_pa_boost[lchan] = 0;
p_audio_config->lora_tcxo[lchan] = 0;
p_audio_config->lora_tcxo_voltage[lchan] = 1.8f;
p_audio_config->lora_spi_bus[lchan] = 0;
p_audio_config->lora_spi_dev[lchan] = 0;
p_audio_config->lora_spi_speed[lchan] = 8000000;
channel = lchan; /* set context for subsequent LORAHW etc. */
}
else {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LCHANNEL can't use channel %d because it is already in use.\n", line, lchan);
}
}
else {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LCHANNEL number must be in range %d to %d.\n", line, MAX_RADIO_CHANS, MAX_TOTAL_CHANS-1);
}
}

/*
* LORAHW profile-name
*
* Load GPIO pin and chip settings from a built-in hardware profile.
* Must follow an LCHANNEL directive. Profile names match entries in
* the s_profiles[] table in loraspi.c.
* Example: LORAHW lorapi_rfm95w
*/
else if (strcasecmp(t, "LORAHW") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORAHW must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t == NULL) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: Missing hardware profile name for LORAHW.\n", line);
continue;
}
if (loraspi_apply_profile(channel, t, p_audio_config) != 0) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: Unknown LoRa hardware profile '%s'.\n", line, t);
}
}

/*
* LORAFREQ frequency_mhz
* Example: LORAFREQ 915.0
*/
else if (strcasecmp(t, "LORAFREQ") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORAFREQ must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t != NULL) p_audio_config->lora_freq_mhz[channel] = (float)atof(t);
}

/*
* LORASF spreading_factor (6-12)
* Example: LORASF 12
*/
else if (strcasecmp(t, "LORASF") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORASF must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t != NULL) {
int sf = atoi(t);
if (sf >= 6 && sf <= 12) p_audio_config->lora_sf[channel] = sf;
else { text_color_set(DW_COLOR_ERROR); dw_printf ("Line %d: LORASF must be 6-12.\n", line); }
}
}

/*
* LORABW bandwidth_khz (125, 250, or 500)
* Example: LORABW 125
*/
else if (strcasecmp(t, "LORABW") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORABW must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t != NULL) p_audio_config->lora_bw_khz[channel] = (float)atof(t);
}

/*
* LORACR coding_rate (5-8, meaning 4/5 to 4/8)
* Example: LORACR 5
*/
else if (strcasecmp(t, "LORACR") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORACR must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t != NULL) {
int cr = atoi(t);
if (cr >= 5 && cr <= 8) p_audio_config->lora_cr[channel] = cr;
else { text_color_set(DW_COLOR_ERROR); dw_printf ("Line %d: LORACR must be 5-8.\n", line); }
}
}

/*
* LORASW sync_word_hex (0x12 for private, 0x34 for LoRa-APRS standard)
* Example: LORASW 0x34
*/
else if (strcasecmp(t, "LORASW") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORASW must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t != NULL) p_audio_config->lora_sw[channel] = (int)strtol(t, NULL, 0);
}

/*
* LORATXPOWER dbm
* Example: LORATXPOWER 20
*/
else if (strcasecmp(t, "LORATXPOWER") == 0) {
if (channel < MAX_RADIO_CHANS || p_audio_config->chan_medium[channel] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Line %d: LORATXPOWER must follow an LCHANNEL directive.\n", line);
continue;
}
t = split(NULL,0);
if (t != NULL) p_audio_config->lora_txpower[channel] = atoi(t);
}

/*
* MYCALL station
*/
Expand Down Expand Up @@ -2813,7 +2989,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,

if (p_audio_config->chan_medium[from_chan] != MEDIUM_RADIO &&
p_audio_config->chan_medium[from_chan] != MEDIUM_NETTNC &&
p_audio_config->chan_medium[from_chan] != MEDIUM_SERTNC) {
p_audio_config->chan_medium[from_chan] != MEDIUM_SERTNC &&
p_audio_config->chan_medium[from_chan] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Config file, line %d: FROM-channel %d is not valid.\n",
line, from_chan);
Expand Down Expand Up @@ -2842,7 +3019,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,

if (p_audio_config->chan_medium[to_chan] != MEDIUM_RADIO &&
p_audio_config->chan_medium[to_chan] != MEDIUM_NETTNC &&
p_audio_config->chan_medium[to_chan] != MEDIUM_SERTNC) {
p_audio_config->chan_medium[to_chan] != MEDIUM_SERTNC &&
p_audio_config->chan_medium[to_chan] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Config file, line %d: TO-channel %d is not valid.\n",
line, to_chan);
Expand Down Expand Up @@ -3177,7 +3355,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,

if (p_audio_config->chan_medium[from_chan] != MEDIUM_RADIO &&
p_audio_config->chan_medium[from_chan] != MEDIUM_NETTNC &&
p_audio_config->chan_medium[from_chan] != MEDIUM_SERTNC) {
p_audio_config->chan_medium[from_chan] != MEDIUM_SERTNC &&
p_audio_config->chan_medium[from_chan] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Config file, line %d: FROM-channel %d is not valid.\n",
line, from_chan);
Expand Down Expand Up @@ -3216,7 +3395,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,
}
if (p_audio_config->chan_medium[to_chan] != MEDIUM_RADIO &&
p_audio_config->chan_medium[to_chan] != MEDIUM_NETTNC &&
p_audio_config->chan_medium[to_chan] != MEDIUM_SERTNC) {
p_audio_config->chan_medium[to_chan] != MEDIUM_SERTNC &&
p_audio_config->chan_medium[to_chan] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Config file, line %d: TO-channel %d is not valid.\n",
line, to_chan);
Expand Down Expand Up @@ -4497,7 +4677,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,
}
else if (p_audio_config->chan_medium[x] != MEDIUM_RADIO &&
p_audio_config->chan_medium[x] != MEDIUM_NETTNC &&
p_audio_config->chan_medium[x] != MEDIUM_SERTNC) {
p_audio_config->chan_medium[x] != MEDIUM_SERTNC &&
p_audio_config->chan_medium[x] != MEDIUM_LORA) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("Config file, line %d: TTOBJ transmit channel %d is not valid.\n", line, x);
x = -1;
Expand Down Expand Up @@ -5940,7 +6121,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,
if (strlen(p_igate_config->t2_login) > 0 &&
(p_audio_config->chan_medium[i] == MEDIUM_RADIO ||
p_audio_config->chan_medium[i] == MEDIUM_NETTNC ||
p_audio_config->chan_medium[i] == MEDIUM_SERTNC)) {
p_audio_config->chan_medium[i] == MEDIUM_SERTNC ||
p_audio_config->chan_medium[i] == MEDIUM_LORA)) {

if (strcmp(p_audio_config->mycall[i], "NOCALL") == 0 || strcmp(p_audio_config->mycall[i], "N0CALL") == 0) {
text_color_set(DW_COLOR_ERROR);
Expand Down Expand Up @@ -5968,7 +6150,8 @@ void config_init (char *fname, struct audio_s *p_audio_config,
for (j=0; j<MAX_TOTAL_CHANS; j++) {
if (p_audio_config->chan_medium[j] == MEDIUM_RADIO ||
p_audio_config->chan_medium[j] == MEDIUM_NETTNC ||
p_audio_config->chan_medium[j] == MEDIUM_SERTNC) {
p_audio_config->chan_medium[j] == MEDIUM_SERTNC ||
p_audio_config->chan_medium[j] == MEDIUM_LORA) {
if (p_digi_config->filter_str[MAX_TOTAL_CHANS][j] == NULL) {
p_digi_config->filter_str[MAX_TOTAL_CHANS][j] = strdup("i/180");
}
Expand Down
3 changes: 2 additions & 1 deletion src/digipeater.c
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ void digipeater (int from_chan, packet_t pp)
if ( from_chan < 0 || from_chan >= MAX_TOTAL_CHANS ||
(save_audio_config_p->chan_medium[from_chan] != MEDIUM_RADIO &&
save_audio_config_p->chan_medium[from_chan] != MEDIUM_NETTNC &&
save_audio_config_p->chan_medium[from_chan] != MEDIUM_SERTNC)) {
save_audio_config_p->chan_medium[from_chan] != MEDIUM_SERTNC &&
save_audio_config_p->chan_medium[from_chan] != MEDIUM_LORA)) {
text_color_set(DW_COLOR_ERROR);
dw_printf ("APRS digipeater: Did not expect to receive on invalid channel %d.\n", from_chan);
}
Expand Down
2 changes: 2 additions & 0 deletions src/direwolf.c
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
#include "deviceid.h"
#include "nettnc.h"
#include "sertnc.h"
#include "loraspi.h"


//static int idx_decoded = 0;
Expand Down Expand Up @@ -1063,6 +1064,7 @@ int main (int argc, char *argv[])
*/
nettnc_init (&audio_config);
sertnc_init (&audio_config);
loraspi_init (&audio_config);

/*
* Initialize the touch tone decoder & APRStt gateway.
Expand Down
Loading