.NET nanoFramework SX127X LoRa library Refactoring

I had been planning this for a while, then the code broke when I tried to build a version for my SparkFun LoRa Gateway-1-Channel (ESP32). There was a namespace (static configuration class in configuration.cs) collision and the length of SX127XDevice.cs file was getting silly.

This refactor took a couple of days and really changed the structure of the library.

VS2022 Solution structure after refactoring

I went through the SX127XDevice.cs extracting the enumerations, masks and defaults associated with the registers the library supports.


Fork Refactoring Check-ins

The RegOpMode.cs file is a good example…

namespace devMobile.IoT.SX127xLoRaDevice
{
	using System;

	// RegOpMode bit flags from Semtech SX127X Datasheet
	[Flags]
	internal enum RegOpModeModeFlags : byte
	{
		LongRangeModeLoRa = 0b10000000,
		LongRangeModeFskOok = 0b00000000,
		LongRangeModeDefault = LongRangeModeFskOok,
		AcessSharedRegLoRa = 0b00000000,
		AcessSharedRegFsk = 0b01000000,
		AcessSharedRegDefault = AcessSharedRegLoRa,
		LowFrequencyModeOnHighFrequency = 0b00000000,
		LowFrequencyModeOnLowFrequency = 0b00001000,
		LowFrequencyModeOnDefault = LowFrequencyModeOnLowFrequency
	}

	internal enum RegOpModeMode : byte
	{
		Sleep = 0b00000000,
		StandBy = 0b00000001,
		FrequencySynthesisTX = 0b00000010,
		Transmit = 0b00000011,
		FrequencySynthesisRX = 0b00000100,
		ReceiveContinuous = 0b00000101,
		ReceiveSingle = 0b00000110,
		ChannelActivityDetection = 0b00000111,
	};
}

The library is designed to be a approximate .NET nanoFramework equivalent of Arduino-LoRa so it doesn’t support/implement all of the functionality of the Semtech SX127X. Still got a bit of refactoring to go but the structure is slowly improving.

I use Fork to manage my Github repositories, it’s an excellent product especially as it does a pretty good job of keeping me from screwing up.

.NET nanoFramework SX127X LoRa library DIO0,DIO1,DIO2,DIO3,DIO4,DIO5

All the previous versions of my .NET nanoFramework Semtech SX127X (LoRa® Mode) library only supported a Dio0 (RegDioMapping1 bits 6&7) EventHandler. This version supports mapping Dio0, Dio1, Dio2, Dio3, Dio4 and Dio5.

DIO Mapping in LoRa Mode
RegDioMapping1 & RegDioMapping2 options

The Dragino Arduino Shield featuring LoRa® technology does not have Dio3 and Dio4 connected so I have been unable to test that functionality.

Dragino LoRa Shield Pin Mapping

The SX127XLoRaDeviceClient main now has OnRxTimeout, OnReceive, OnPayloadCrcError, OnValidHeader, OnTransmit, OnChannelActivityDetectionDone, OnFhssChangeChannel, and OnChannelActivityDetected event handlers (Based on RegIrqFlags bit ordering)

static void Main(string[] args)
{
	int sendCount = 0;
...
#if NETDUINO3_WIFI
	// Arduino D10->PB10
	int chipSelectLine = PinNumber('B', 10);
	// Arduino D9->PE5
	int resetPinNumber = PinNumber('E', 5);
	// Arduino D2 -PA3
	int dio0PinNumber = PinNumber('A', 3);
	// Arduino D6 - PB9
	int dio1PinNumber = PinNumber('B', 9);
	// Arduino D7
	int dio2PinNumber = PinNumber('A', 1);
	// Not connected on Dragino LoRa shield
	//int dio3PinNumber = PinNumber('A', 1);
	//  Not connected on Dragino LoRa shield
	//int dio4PinNumber = PinNumber('A', 1);
	// Arduino D8
	int dio5PinNumber = PinNumber('A', 0);
#endif
...
	Console.WriteLine("devMobile.IoT.SX127xLoRaDevice Client starting");

	try
	{
		var settings = new SpiConnectionSettings(SpiBusId, chipSelectLine)
		{
			ClockFrequency = 1000000,
			Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
			SharingMode = SpiSharingMode.Shared
		};

		using (SpiDevice spiDevice = new SpiDevice(settings))
		using (GpioController gpioController = new GpioController())
		{
...
#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
			sx127XDevice = new SX127XDevice(spiDevice, gpioController, dio0Pin:dio0PinNumber, resetPin:resetPinNumber, dio1Pin: dio1PinNumber, dio2Pin: dio2PinNumber);
#endif

			sx127XDevice.Initialise(Frequency
						, lnaGain: Configuration.RegLnaLnaGain.Default
						, lnaBoost: true
						, powerAmplifier: Configuration.RegPAConfigPASelect.PABoost							
						, rxPayloadCrcOn: true
						, rxDoneignoreIfCrcMissing: false
						);

#if DEBUG
			sx127XDevice.RegisterDump();
#endif

			//sx127XDevice.OnRxTimeout += Sx127XDevice_OnRxTimeout;
			sx127XDevice.OnReceive += SX127XDevice_OnReceive;
			//sx127XDevice.OnPayloadCrcError += Sx127XDevice_OnPayloadCrcError;
			//sx127XDevice.OnValidHeader += Sx127XDevice_OnValidHeader;
			sx127XDevice.OnTransmit += SX127XDevice_OnTransmit;
			//sx127XDevice.OnChannelActivityDetectionDone += Sx127XDevice_OnChannelActivityDetectionDone;
			//sx127XDevice.OnFhssChangeChannel += Sx127XDevice_OnFhssChangeChannel;
			//sx127XDevice.OnChannelActivityDetected += SX127XDevice_OnChannelActivityDetected;

			sx127XDevice.Receive();
			//sx127XDevice.ChannelActivityDetect();

			Thread.Sleep(500);

			while (true)
			{
				string messageText = $"Hello LoRa from .NET nanoFramework Count {sendCount+=1}!";

				byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);
				Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss}-TX {messageBytes.Length} byte message {messageText}");
				sx127XDevice.Send(messageBytes);

				Thread.Sleep(50000);

				Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss} Random {sx127XDevice.Random()}");
			}
		}
	}
	catch (Exception ex)
	{
		Console.WriteLine(ex.Message);
	}
}

The Dio0 pin number is the only required pin number parameter, the resetPin, and Dio1 thru Dio5 pin numbers are optional. All the RegDioMapping1 and RegDioMapping2 mappings are disabled on intialisation so there should be no events while the SX127X is being configured.

public SX127XDevice(SpiDevice spiDevice, GpioController gpioController,
	int dio0Pin,
	int resetPin = 0, // Odd order so as not to break exisiting code
	int dio1Pin = 0,
	int dio2Pin = 0,
	int dio3Pin = 0,
	int dio4Pin = 0,
	int dio5Pin = 0
	)
{
	_gpioController = gpioController;

	// Factory reset pin configuration
	if (resetPin != 0)
	{
		_resetPin = resetPin;
		_gpioController.OpenPin(resetPin, PinMode.Output);

		_gpioController.Write(resetPin, PinValue.Low);
		Thread.Sleep(20);
		_gpioController.Write(resetPin, PinValue.High);
		Thread.Sleep(50);
	}

	_registerManager = new RegisterManager(spiDevice, RegisterAddressReadMask, RegisterAddressWriteMask);

	// Once the pins setup check that SX127X chip is present
	Byte regVersionValue = _registerManager.ReadByte((byte)Configuration.Registers.RegVersion);
	if (regVersionValue != Configuration.RegVersionValueExpected)
	{
		throw new ApplicationException("Semtech SX127X not found");
	}

	// See Table 18 DIO Mapping LoRa® Mode
	Configuration.RegDioMapping1 regDioMapping1Value = Configuration.RegDioMapping1.Dio0None;
	regDioMapping1Value |= Configuration.RegDioMapping1.Dio1None;
	regDioMapping1Value |= Configuration.RegDioMapping1.Dio2None;
	regDioMapping1Value |= Configuration.RegDioMapping1.Dio3None;
	_registerManager.WriteByte((byte)Configuration.Registers.RegDioMapping1, (byte)regDioMapping1Value);

	// Currently no easy way to test this with available hardware
	//Configuration.RegDioMapping2 regDioMapping2Value = Configuration.RegDioMapping2.Dio4None;
	//regDioMapping2Value = Configuration.RegDioMapping2.Dio5None;
	//_registerManager.WriteByte((byte)Configuration.Registers.RegDioMapping2, (byte)regDioMapping2Value);

	// Interrupt pin for RXDone, TXDone, and CadDone notification 
	_gpioController.OpenPin(dio0Pin, PinMode.InputPullDown);
	_gpioController.RegisterCallbackForPinValueChangedEvent(dio0Pin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);

	// RxTimeout, FhssChangeChannel, and CadDetected
	if (dio1Pin != 0)
	{
		_gpioController.OpenPin(dio1Pin, PinMode.InputPullDown);
		_gpioController.RegisterCallbackForPinValueChangedEvent(dio1Pin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
	}

	// FhssChangeChannel, FhssChangeChannel, and FhssChangeChannel
	if (dio2Pin != 0)
	{
		_gpioController.OpenPin(dio2Pin, PinMode.InputPullDown);
		_gpioController.RegisterCallbackForPinValueChangedEvent(dio2Pin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
	}

	// CadDone, ValidHeader, and PayloadCrcError
	if (dio3Pin != 0)
	{
		_gpioController.OpenPin(dio3Pin, PinMode.InputPullDown);
		_gpioController.RegisterCallbackForPinValueChangedEvent(dio3Pin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
	}

	// CadDetected, PllLock and PllLock
	if (dio4Pin != 0)
	{
		_gpioController.OpenPin(dio4Pin, PinMode.InputPullDown);
		_gpioController.RegisterCallbackForPinValueChangedEvent(dio4Pin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
	}

	// ModeReady, ClkOut and ClkOut
	if (dio5Pin != 0)
	{
		_gpioController.OpenPin(dio5Pin, PinMode.InputPullDown);
		_gpioController.RegisterCallbackForPinValueChangedEvent(dio5Pin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
	}
}

The same event handler (InterruptGpioPin_ValueChanged) is used for Dio0 thru Dio5. Each event has a “process” method and the RegIrqFlags register controls which one(s) are called.

private void InterruptGpioPin_ValueChanged(object sender, PinValueChangedEventArgs pinValueChangedEventArgs)
{
	Byte regIrqFlagsToClear = (byte)Configuration.RegIrqFlags.ClearNone;

	// Read RegIrqFlags to see what caused the interrupt
	Byte irqFlags = _registerManager.ReadByte((byte)Configuration.Registers.RegIrqFlags);

	//Console.WriteLine($"IrqFlags 0x{irqFlags:x} Pin:{pinValueChangedEventArgs.PinNumber}");

	// Check RxTimeout for inbound message
	if ((irqFlags & (byte)Configuration.RegIrqFlagsMask.RxTimeoutMask) == (byte)Configuration.RegIrqFlags.RxTimeout)
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.RxTimeout;

		ProcessRxTimeout(irqFlags);
	}

	// Check RxDone for inbound message
	if ((irqFlags & (byte)Configuration.RegIrqFlagsMask.RxDoneMask) == (byte)Configuration.RegIrqFlags.RxDone)
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.RxDone;

		ProcessRxDone(irqFlags);
	}

	// Check PayLoadCrcError for inbound message
	if ((irqFlags & (byte)Configuration.RegIrqFlagsMask.PayLoadCrcErrorMask) == (byte)Configuration.RegIrqFlags.PayLoadCrcError)
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.PayLoadCrcError;

		ProcessPayloadCrcError(irqFlags);
	}

	// Check ValidHeader for inbound message
	if ((irqFlags & (byte)Configuration.RegIrqFlagsMask.ValidHeaderMask) == (byte)Configuration.RegIrqFlags.ValidHeader)
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.ValidHeader;

		ProcessValidHeader(irqFlags);
	}

	// Check TxDone for outbound message
	if ((irqFlags & (byte)Configuration.RegIrqFlagsMask.TxDoneMask) == (byte)Configuration.RegIrqFlags.TxDone)
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.TxDone;

		ProcessTxDone(irqFlags);
	}

	// Check Channel Activity Detection done 
	if (((irqFlags & (byte)Configuration.RegIrqFlagsMask.CadDoneMask) == (byte)Configuration.RegIrqFlags.CadDone))
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.CadDone;

		ProcessChannelActivityDetectionDone(irqFlags);
	}

	// Check FhssChangeChannel for inbound message
	if ((irqFlags & (byte)Configuration.RegIrqFlagsMask.FhssChangeChannelMask) == (byte)Configuration.RegIrqFlags.FhssChangeChannel)
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.FhssChangeChannel;

		ProcessFhssChangeChannel(irqFlags);
	}

	// Check Channel Activity Detected 
	if (((irqFlags & (byte)Configuration.RegIrqFlagsMask.CadDetectedMask) == (byte)Configuration.RegIrqFlags.CadDetected))
	{
		regIrqFlagsToClear |= (byte)Configuration.RegIrqFlags.CadDetected;

		ProcessChannelActivityDetected(irqFlags);
	}

	_registerManager.WriteByte((byte)Configuration.Registers.RegIrqFlags, regIrqFlagsToClear);
}

private void ProcessRxTimeout(byte irqFlags)
{
	OnRxTimeoutEventArgs onRxTimeoutArgs = new OnRxTimeoutEventArgs();

	OnRxTimeout?.Invoke(this, onRxTimeoutArgs);
}

private void ProcessRxDone(byte irqFlags)
{
	byte[] payloadBytes;
...
}

The RegIrqFlags bits are cleared individually (with regIrqFlagsToClear) at the end of the event handler. Initially I cleared all the flags by writing 0xFF to RegIrqFlags but this caused issues when there were multiple bits set e.g. CadDone along with CadDetected.

devMobile.IoT.SX127xLoRaDevice Client starting
Register dump
Register 0x01 - Value 0X80
...
Register 0x4d - Value 0X84

00:00:09-CAD Detection Done
00:00:09-CAD Detected
00:00:09-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -96dBm = 9 byte message hello 41
00:00:09-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -96dBm = 9 byte message hello 42
00:00:09-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -96dBm = 9 byte message hello 43
00:00:09-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -96dBm = 9 byte message hello 44
00:00:09-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -96dBm = 9 byte message hello 45
00:00:09-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -94dBm = 9 byte message hello 46
00:00:09-RX PacketSnr 0.0 Packet RSSI -99dBm RSSI -94dBm = 9 byte message hello 47
00:00:19-RX PacketSnr 0.0 Packet RSSI -100dBm RSSI -96dBm = 9 byte message hello 48

It took some experimentation with the SX127xLoRaDeviceClient application to “reliably” trigger events for testing. To generate CAD Detected event, I had to modify one of the Arduino-LoRa sample applications to send messages without a delay, then have it running as the SX127xLoRaDeviceClient application was starting.

.NET nanoFramework SX127X LoRa library with Interrupts

To test the nanoFramework transmit and receive with interrupts implementation I used three Dragino LoRa Shields, a Seeeduino V4.2 and a pair of Netduino 3 Wifi devices.

Seeeduino and nanoFramework

I started with transmit as I was confident my Netduino 3 Wifi & Seeeduino + Dragino LoRa Shields could receive messages.

Interrupt pin configuration
SX127X ReqIrqFlags options

The TransmitInterrupt application loads the message to be sent into the First In First Out(FIFO) buffer, RegDioMapping1 is set to interrupt onTxDone(PacketSent-00), then RegRegOpMode-Mode is set to Transmit. When the message has been sent InterruptGpioPin_ValueChanged is called, and the TxDone(0b00001000) flag is set in the RegIrqFlags register.

The ReceiveInterrupt application sets the RegDioMapping1 to interrupt on RxDone(PacketReady-00), then the RegRegOpMode-Mode is set to Receive(TX-101). When a message is received InterruptGpioPin_ValueChanged is called, with the RxDone(0b00001000) flag set in the RegIrqFlags register, and then the message is read from First In First Out(FIFO) buffer.

namespace devMobile.IoT.SX127x.ReceiveTransmitInterrupt
{
...
   public sealed class SX127XDevice
   {
...
      public SX127XDevice(int busId, int chipSelectLine, int interruptPin, int resetPin)
      {
         var settings = new SpiConnectionSettings(busId, chipSelectLine)
         {
            ClockFrequency = 1000000,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared
         };

         SX127XTransceiver = new SpiDevice(settings);

         GpioController gpioController = new GpioController();


         // Factory reset pin configuration
         gpioController.OpenPin(resetPin, PinMode.Output);

         gpioController.Write(resetPin, PinValue.Low);
         Thread.Sleep(20);
         gpioController.Write(resetPin, PinValue.High);
         Thread.Sleep(20);

         // Interrupt pin for RX message & TX done notification 
         gpioController.OpenPin(interruptPin, PinMode.InputPullDown);

         gpioController.RegisterCallbackForPinValueChangedEvent(interruptPin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
      }
...
   }

      private void InterruptGpioPin_ValueChanged(object sender, PinValueChangedEventArgs e)
      {
         byte irqFlags = this.ReadByte(0x12); // RegIrqFlags
         Debug.WriteLine($"RegIrqFlags 0X{irqFlags:x2}");

         if ((irqFlags & 0b01000000) == 0b01000000)  // RxDone 
         {
            Debug.WriteLine("Receive-Message");
            byte currentFifoAddress = this.ReadByte(0x10); // RegFifiRxCurrent
            this.WriteByte(0x0d, currentFifoAddress); // RegFifoAddrPtr

            byte numberOfBytes = this.ReadByte(0x13); // RegRxNbBytes

            // Allocate buffer for message
            byte[] messageBytes = this.ReadBytes(0X0, numberOfBytes);

            // Remove unprintable characters from messages
            for (int index = 0; index < messageBytes.Length; index++)
            {
               if ((messageBytes[index] < 0x20) || (messageBytes[index] > 0x7E))
               {
                  messageBytes[index] = 0x20;
               }
            }

            string messageText = UTF8Encoding.UTF8.GetString(messageBytes, 0, messageBytes.Length);
            Debug.WriteLine($"Received {messageBytes.Length} byte message {messageText}");
         }

         if ((irqFlags & 0b00001000) == 0b00001000)  // TxDone
         {
            this.WriteByte(0x01, 0b10000101); // RegOpMode set LoRa & RxContinuous
            Debug.WriteLine("Transmit-Done");
         }

         this.WriteByte(0x40, 0b00000000); // RegDioMapping1 0b00000000 DI0 RxReady & TxReady
         this.WriteByte(0x12, 0xff);// RegIrqFlags
      }

   public class Program
   {
...
   #if NETDUINO3_WIFI
      private const int SpiBusId = 2;
#endif
...

      public static void Main()
      {
         int SendCount = 0;
...
#if NETDUINO3_WIFI
         // Arduino D10->PB10
         int chipSelectLine = PinNumber('B', 10);
         // Arduino D9->PE5
         int resetPinNumber = PinNumber('E', 5);
         // Arduino D2 -PA3
         int interruptPinNumber = PinNumber('A', 3);
#endif
...
  
       Debug.WriteLine("devMobile.IoT.SX127x.ReceiveTransmitInterrupt starting");

         try
         {
...
#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
            SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine, interruptPinNumber, resetPinNumber);
#endif
            Thread.Sleep(500);

            // Put device into LoRa + Sleep mode
            sx127XDevice.WriteByte(0x01, 0b10000000); // RegOpMode 

            // Set the frequency to 915MHz
            byte[] frequencyWriteBytes = { 0xE4, 0xC0, 0x00 }; // RegFrMsb, RegFrMid, RegFrLsb
            sx127XDevice.WriteBytes(0x06, frequencyWriteBytes);

            // More power PA Boost
            sx127XDevice.WriteByte(0x09, 0b10000000); // RegPaConfig

            sx127XDevice.WriteByte(0x01, 0b10000101); // RegOpMode set LoRa & RxContinuous

            while (true)
            {
               // Set the Register Fifo address pointer
               sx127XDevice.WriteByte(0x0E, 0x00); // RegFifoTxBaseAddress 

               // Set the Register Fifo address pointer
               sx127XDevice.WriteByte(0x0D, 0x0); // RegFifoAddrPtr 

               string messageText = $"Hello LoRa {SendCount += 1}!";

               // load the message into the fifo
               byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);
               sx127XDevice.WriteBytes(0x0, messageBytes); // RegFifo 

               // Set the length of the message in the fifo
               sx127XDevice.WriteByte(0x22, (byte)messageBytes.Length); // RegPayloadLength
               sx127XDevice.WriteByte(0x40, 0b01000000); // RegDioMapping1 0b00000000 DI0 RxReady & TxReady
               sx127XDevice.WriteByte(0x01, 0b10000011); // RegOpMode 

               Debug.WriteLine($"Sending {messageBytes.Length} bytes message {messageText}");

               Thread.Sleep(10000);
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine(ex.Message);
         }
      }
...
   }
}
ReceiveTransmitInterrupt application output

The ReceiveTransmitInterrupt application combines the functionality TransmitInterrupt and ReceiveInterrupt programs. The key differences are the RegDioMapping1 setup and in InterruptGpioPin_ValueChanged where the TxDone & RxDone flags in the RegIrqFlags register specify how the interrupt is handled.

.NET nanoFramework SX127X LoRa library Basic Receive & Transmit

For testing nanoFramework device transmit and receive functionality I used an Arduino/Seeeduino with a Dragino LoRa Shield (running one of the Arduino-LoRa samples) as a client device. This was so I could “bootstrap” connectivity and test interoperability with other libraries/platforms.

Arduino/Netduino devices for .NET nanoFramework interoperability test-rig

I started with transmit as I was confident my Seeeduino + Dragino LoRa Shield could receive messages. The TransmitBasic application puts the device into LoRa + Sleep mode as after reset/powering up the device is in FSK/OOK, Low Frequency + Standby mode).

SX127X RegOpMode options

After loading the message to be sent into the First In First Out(FIFO) buffer, the RegOpMode-Mode is set to Transmit(TX-011), and then the RegIrqFlags register is polled until the TxDone flag is set.

SX127X ReqIrqFlags options
public static void Main()
{
  int SendCount = 0;
...
  Debug.WriteLine("devMobile.IoT.SX127x.TransmitBasic starting");

   try
   {
...
#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
      SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine, resetPinNumber);
#endif
     Thread.Sleep(500);

     // Put device into LoRa + Standby mode
     sx127XDevice.WriteByte(0x01, 0b10000000); // RegOpMode 

      // Set the frequency to 915MHz
      byte[] frequencyBytes = { 0xE4, 0xC0, 0x00 }; // RegFrMsb, RegFrMid, RegFrLsb
      sx127XDevice.WriteBytes(0x06, frequencyBytes);

      // More power PA Boost
      sx127XDevice.WriteByte(0x09, 0b10000000); // RegPaConfig

      sx127XDevice.RegisterDump();

      while (true)
      {
         sx127XDevice.WriteByte(0x0E, 0x0); // RegFifoTxBaseAddress 

         // Set the Register Fifo address pointer
         sx127XDevice.WriteByte(0x0D, 0x0); // RegFifoAddrPtr 

         string messageText = $"Hello LoRa from .NET nanoFramework {SendCount += 1}!";

         // load the message into the fifo
         byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);
         sx127XDevice.WriteBytes(0x0, messageBytes); // RegFifo

         // Set the length of the message in the fifo
         sx127XDevice.WriteByte(0x22, (byte)messageBytes.Length); // RegPayloadLength

         Debug.WriteLine($"Sending {messageBytes.Length} bytes message {messageText}");
         // Set the mode to LoRa + Transmit
         sx127XDevice.WriteByte(0x01, 0b10000011); // RegOpMode 

         // Wait until send done, no timeouts in PoC
         Debug.WriteLine("Send-wait");
         byte irqFlags = sx127XDevice.ReadByte(0x12); // RegIrqFlags
         while ((irqFlags & 0b00001000) == 0)  // wait until TxDone cleared
         {
            Thread.Sleep(10);
            irqFlags = sx127XDevice.ReadByte(0x12); // RegIrqFlags
            Debug.Write(".");
         }
         Debug.WriteLine("");
         sx127XDevice.WriteByte(0x12, 0b00001000); // clear TxDone bit
         Debug.WriteLine("Send-Done");

         Thread.Sleep(30000);
         }
      }
      catch (Exception ex)
      {
         Debug.WriteLine(ex.Message);
      }
   }
}
Transmit Basic application output

Once the TransmitBasic application was sending messages reliably I started working on the ReceiveBasic application. As the ReceiveBasic application starts up the SX127X RegOpMode has to be set to sleep/standby so the device can be configured. TOnce that is completed RegOpMode-Mode is set to RxContinuous(101), and the RegIrqFlags register is polled until the RxDone flag is set.

public static void Main()
{
...
   Debug.WriteLine("devMobile.IoT.SX127x.ReceiveBasic starting");

   try
   {
...
#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
      SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine, resetPinNumber);
#endif
      Thread.Sleep(500);

      // Put device into LoRa + Sleep mode
      sx127XDevice.WriteByte(0x01, 0b10000000); // RegOpMode 

      // Set the frequency to 915MHz
      byte[] frequencyBytes = { 0xE4, 0xC0, 0x00 }; // RegFrMsb, RegFrMid, RegFrLsb
      sx127XDevice.WriteBytes(0x06, frequencyBytes);

      sx127XDevice.WriteByte(0x0F, 0x0); // RegFifoRxBaseAddress 

      sx127XDevice.WriteByte(0x01, 0b10000101); // RegOpMode set LoRa & RxContinuous

       while (true)
       {
          // Wait until a packet is received, no timeouts in PoC
         Debug.WriteLine("Receive-Wait");
         byte irqFlags = sx127XDevice.ReadByte(0x12); // RegIrqFlags
         while ((irqFlags & 0b01000000) == 0)  // wait until RxDone cleared
         {
            Thread.Sleep(100);
            irqFlags = sx127XDevice.ReadByte(0x12); // RegIrqFlags
            Debug.Write(".");
         }
         Debug.WriteLine("");
         Debug.WriteLine($"RegIrqFlags 0X{irqFlags:X2}");
         Debug.WriteLine("Receive-Message");
         byte currentFifoAddress = sx127XDevice.ReadByte(0x10); // RegFifiRxCurrent
         sx127XDevice.WriteByte(0x0d, currentFifoAddress); // RegFifoAddrPtr

         byte numberOfBytes = sx127XDevice.ReadByte(0x13); // RegRxNbBytes

         // Read the message from the FIFO
         byte[] messageBytes = sx127XDevice.ReadBytes(0x00, numberOfBytes);

         sx127XDevice.WriteByte(0x0d, 0);
         sx127XDevice.WriteByte(0x12, 0b11111111); // RegIrqFlags clear all the bits

         // Remove unprintable characters from messages
         for (int index = 0; index < messageBytes.Length; index++)
         {
            if ((messageBytes[index] < 0x20) || (messageBytes[index] > 0x7E))
            {
               messageBytes[index] = 0x20;
            }
         }

         string messageText = UTF8Encoding.UTF8.GetString(messageBytes, 0, messageBytes.Length);
         Debug.WriteLine($"Received {messageBytes.Length} byte message {messageText}");

         Debug.WriteLine("Receive-Done");
      }
    }
   catch (Exception ex)
   {
      Debug.WriteLine(ex.Message);
   }
}
Receive Basic application output

Every so often the ReceiveBasic application would display a message sent on the same frequency by a device somewhere nearby.

ReceiveBasic application messages from unknown source

I need to do some more investigation into whether writing 0b00001000 (Transmit) vs. 0b11111111(Receive) to RegIrqFlags is important.

.NET nanoFramework SX127X LoRa library Read & Write

Now that I could reliably dump all the Dragino shield registers I wanted to be able to configure the Semtech 127X device and reset it back to factory settings. A factory reset is done by strobing the SX127X reset pin.

SX127X Reset timing diagram

SX127X Reset process

To support this I added a constructor with an additional parameter for the reset General Purpose Input Output(GPIO) pin number to the SX127XDevice class. The original constructor was retained as the SX127X reset pin is not connected on the SparkFun LoRa Gateway-1-Channel (ESP32) and a limited number of other devices.

namespace devMobile.IoT.SX127x.RegisterReadAndWrite
{
   using System;
   using System.Diagnostics;
   using System.Threading;

   using System.Device.Gpio;
   using System.Device.Spi;

#if ESP32_WROOM_32_LORA_1_CHANNEL
   using nanoFramework.Hardware.Esp32;
#endif

   public sealed class SX127XDevice
   {
      private const byte RegisterAddressMinimum = 0X0;
      private const byte RegisterAddressMaximum = 0x42;
      private const byte RegisterAddressReadMask = 0X7f;
      private const byte RegisterAddressWriteMask = 0x80;

      private readonly SpiDevice SX127XTransceiver;

      public SX127XDevice(int busId, int chipSelectLine, int resetPin)
      {
         var settings = new SpiConnectionSettings(busId, chipSelectLine)
         {
            ClockFrequency = 1000000,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared
         };

         SX127XTransceiver = new SpiDevice(settings);

         // Factory reset pin configuration
         GpioController gpioController = new GpioController();
         gpioController.OpenPin(resetPin, PinMode.Output);

         gpioController.Write(resetPin, PinValue.Low);
         Thread.Sleep(20);
         gpioController.Write(resetPin, PinValue.High);
         Thread.Sleep(20);
      }

      public SX127XDevice(int busId, int chipSelectLine)
      {
         var settings = new SpiConnectionSettings(busId, chipSelectLine)
         {
            ClockFrequency = 1000000,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared,
         };

         SX127XTransceiver = new SpiDevice(settings);
      }

      public Byte ReadByte(byte registerAddress)
      {
         byte[] writeBuffer = new byte[] { registerAddress &= RegisterAddressReadMask, 0x0 };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);

         return readBuffer[1];
      }

      public ushort ReadWord(byte address)
      {
         byte[] writeBuffer = new byte[] { address &= RegisterAddressReadMask, 0x0, 0x0 };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);

         return (ushort)(readBuffer[2] + (readBuffer[1] << 8));
      }

      public ushort ReadWordMsbLsb(byte address)
      {
         byte[] writeBuffer = new byte[] { address &= RegisterAddressReadMask, 0x0, 0x0 };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);

         return (ushort)((readBuffer[1] << 8) + readBuffer[2]);
      }

      public byte[] ReadBytes(byte address, byte length)
      {
         byte[] writeBuffer = new byte[length + 1];
         byte[] readBuffer = new byte[writeBuffer.Length];
         byte[] replyBuffer = new byte[length];

         writeBuffer[0] = address &= RegisterAddressReadMask;

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);

         Array.Copy(readBuffer, 1, replyBuffer, 0, length);

         return replyBuffer;
      }

      public void WriteByte(byte address, byte value)
      {
         byte[] writeBuffer = new byte[] { address |= RegisterAddressWriteMask, value };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);
      }

      public void WriteWord(byte address, ushort value)
      {
         byte[] valueBytes = BitConverter.GetBytes(value);
         byte[] writeBuffer = new byte[] { address |= RegisterAddressWriteMask, valueBytes[0], valueBytes[1] };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);
      }

      public void WriteWordMsbLsb(byte address, ushort value)
      {
         byte[] valueBytes = BitConverter.GetBytes(value);
         byte[] writeBuffer = new byte[] { address |= RegisterAddressWriteMask, valueBytes[1], valueBytes[0] };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);
      }

      public void WriteBytes(byte address, byte[] bytes)
      {
         byte[] writeBuffer = new byte[1 + bytes.Length];
         byte[] readBuffer = new byte[writeBuffer.Length];

         Array.Copy(bytes, 0, writeBuffer, 1, bytes.Length);
         writeBuffer[0] = address |= RegisterAddressWriteMask;

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);
      }

      public void RegisterDump()
      {
         Debug.WriteLine("Register dump");
         for (byte registerIndex = RegisterAddressMinimum; registerIndex <= RegisterAddressMaximum; registerIndex++)
         {
            byte registerValue = this.ReadByte(registerIndex);

            Debug.WriteLine($"Register 0x{registerIndex:x2} - Value 0X{registerValue:x2}");
         }

         Debug.WriteLine("");
      }
   }

   public class Program
   {
#if ESP32_WROOM_32_LORA_1_CHANNEL
      private const int SpiBusId = 1;
#endif
#if NETDUINO3_WIFI
      private const int SpiBusId = 2;
#endif
#if ST_STM32F769I_DISCOVERY
      private const int SpiBusId = 2;
#endif


      public static void Main()
      {
         byte[] frequencyBytes;
#if ESP32_WROOM_32_LORA_1_CHANNEL // No reset line for this device as it isn't connected on SX127X
         int chipSelectLine = Gpio.IO16;
#endif
#if NETDUINO3_WIFI
         // Arduino D10->PB10
         int chipSelectLine = PinNumber('B', 10);
         // Arduino D9->PE5
         int resetPinNumber = PinNumber('E', 5);
#endif
#if ST_STM32F769I_DISCOVERY
         // Arduino D10->PA11
         int chipSelectLine = PinNumber('A', 11);
         // Arduino D9->PH6
         int resetPinNumber = PinNumber('H', 6);
#endif

         Debug.WriteLine("devMobile.IoT.SX127x.RegisterReadAndWrite starting");

         try
         {
#if ESP32_WROOM_32_LORA_1_CHANNEL
            Configuration.SetPinFunction(Gpio.IO12, DeviceFunction.SPI1_MISO);
            Configuration.SetPinFunction(Gpio.IO13, DeviceFunction.SPI1_MOSI);
            Configuration.SetPinFunction(Gpio.IO14, DeviceFunction.SPI1_CLOCK);

            SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine);
#endif
#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
            SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine, resetPinNumber);
#endif
            Thread.Sleep(500);

            sx127XDevice.RegisterDump();

            while (true)
            {
               Debug.WriteLine("Read RegOpMode (read byte)");
               Byte regOpMode1 = sx127XDevice.ReadByte(0x1);
               Debug.WriteLine($"RegOpMode 0x{regOpMode1:x2}");

               Debug.WriteLine("Set LoRa mode and sleep mode (write byte)");
               sx127XDevice.WriteByte(0x01, 0b10000000);

               Debug.WriteLine("Read RegOpMode (read byte)");
               Byte regOpMode2 = sx127XDevice.ReadByte(0x1);
               Debug.WriteLine($"RegOpMode 0x{regOpMode2:x2}");

               Debug.WriteLine("Read the preamble (read word)");
               ushort preamble = sx127XDevice.ReadWord(0x20);
               Debug.WriteLine($"Preamble 0x{preamble:x2}");

	            Console.WriteLine("Read the preamble (read word)"); // Should be 0x08
			      preamble = sx127XDevice.ReadWordMsbLsb(0x20);
               Debug.WriteLine($"Preamble 0x{preamble:x2}");

               Debug.WriteLine("Read the centre frequency (read byte array)");
               frequencyBytes = sx127XDevice.ReadBytes(0x06, 3);
               Debug.WriteLine($"Frequency Msb 0x{frequencyBytes[0]:x2} Mid 0x{frequencyBytes[1]:x2} Lsb 0x{frequencyBytes[2]:x2}");

               Debug.WriteLine("Set the centre frequency to 915MHz (write byte array)");
               byte[] frequencyWriteBytes = { 0xE4, 0xC0, 0x00 };
               sx127XDevice.WriteBytes(0x06, frequencyWriteBytes);

               Debug.WriteLine("Read the centre frequency (read byte array)");
               frequencyBytes = sx127XDevice.ReadBytes(0x06, 3);
               Debug.WriteLine($"Frequency Msb 0x{frequencyBytes[0]:x2} Mid 0x{frequencyBytes[1]:x2} Lsb 0x{frequencyBytes[2]:x2}");

               sx127XDevice.RegisterDump();

               Thread.Sleep(30000);
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine(ex.Message);
         }
      }

#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
      static int PinNumber(char port, byte pin)
      {
         if (port < 'A' || port > 'J')
            throw new ArgumentException();

         return ((port - 'A') * 16) + pin;
      }
#endif
   }
}

The PinNumber helper is more user friendly that the raw numbers and is “inspired” by sample .NET nanoFramework General Purpose Input Output(GPIO) sample code.

Each method was tested by read/writing suitable register(s) in the device configuration (Needed to set it into LoRa mode first).

The next step is to extract the Serial Peripheral Interface(SPI) register access functionality into a module and configure the bare minimum of settings required to get the SX127X to receive and transmit messages.

.NET nanoFramework SX127X LoRa library Registers

Over the years I have ported my HopeRF RFM9X(Now a generic Semtech SX127X ) Windows 10 IoT Core (May 2018) library to .NET microFramework(May 2018), Wilderness Labs Meadow(Jan 2020), GHI Electronics TinyCLR-OS(July 2020), .NET nanoFramework V1(May 2020) and .NET Core(Aug 2021).

All this madness started because I wasn’t confident the frequency calculation of the Emmellsoft Dragino.Lora code was correct. Over the last couple of years I have also found bugs in my Transmit Power, InvertIQ RX/TX with many others yet to be discovered.

For my updated .NET nanoFramework port I have mainly used a half a dozen Dragino LoRa shields for Arduino and Netduino 3 Wifi devices I had lying around. I have also tested the code with SparkFun LoRa Gateway-1-Channel (ESP32) and ST 32F769IDiscovery devices.

STM32F769IDiscovery, Netduino 3 Wifi, and SparkFun LoRa Gateway-1-Channel (ESP32) devices

The Dragino shield uses D10 for chip select, D2 for RFM9X DI0 interrupt and D9 for Reset.

Dragino Arduino LoRa Shield Schematic

Netduino 3 Wifi pin mapping

  • D10->CS->PB10
  • D9->RST->E5

ST 32F769IDiscovery pin mapping

D10->CS->PA11
D9->RST->PH6

Sparkfun ESP32 1 Channel Gateway Schematic

SparkFun LoRa Gateway-1-Channel (ESP32) pin mapping(SX127X reset is not connected)

  • CS->PB10

The first step was to confirm I could read a single(ShieldSPI) then scan all the Semtech SX1276 registers with the new nanoFramework System.Device.SPI Nuget (which was”inspired by” .Net Core System.Device.SPI)

namespace devMobile.IoT.SX127x.ShieldSPI
{
   using System;
   using System.Diagnostics;
   using System.Threading;

   using System.Device.Gpio;
   using System.Device.Spi;

#if ESP32_WROOM_32_LORA_1_CHANNEL
   using nanoFramework.Hardware.Esp32;
#endif

   public class Program
   {
      private const byte RegVersion = 0x42;
#if ESP32_WROOM_32_LORA_1_CHANNEL
      private const int SpiBusId = 1;
#endif
#if NETDUINO3_WIFI
      private const int SpiBusId = 2;
#endif
#if ST_STM32F769I_DISCOVERY
      private const int SpiBusId = 2;
#endif

      public static void Main()
      {
         GpioController gpioController = new GpioController();

#if ESP32_WROOM_32_LORA_1_CHANNEL // No reset line for this device as it isn't connected on SX127X
         int ledPinNumber = Gpio.IO17;
         int chipSelectLine = Gpio.IO16;
#endif
#if NETDUINO3_WIFI
         int ledPinNumber = PinNumber('A', 10);
         // Arduino D10->PB10
         int chipSelectLine = PinNumber('B', 10);
         // Arduino D9->PE5
         int resetPinNumber = PinNumber('E', 5);
#endif
#if ST_STM32F769I_DISCOVERY
         int ledPinNumber  = PinNumber('J', 5);
         // Arduino D10->PA11
         int chipSelectLine = PinNumber('A', 11);
         // Arduino D9->PH6
         int resetPinNumber = PinNumber('H', 6);
#endif
         Debug.WriteLine("devMobile.IoT.SX127x.ShieldSPI starting");

         try
         {
#if ESP32_WROOM_32_LORA_1_CHANNEL || NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
            // Setup the onboard LED
            gpioController.OpenPin(ledPinNumber, PinMode.Output);
#endif

#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
            // Setup the reset pin
            gpioController.OpenPin(resetPinNumber, PinMode.Output);
            gpioController.Write(resetPinNumber, PinValue.High);
#endif

#if ESP32_WROOM_32_LORA_1_CHANNEL
            Configuration.SetPinFunction(Gpio.IO12, DeviceFunction.SPI1_MISO);
            Configuration.SetPinFunction(Gpio.IO13, DeviceFunction.SPI1_MOSI);
            Configuration.SetPinFunction(Gpio.IO14, DeviceFunction.SPI1_CLOCK);
#endif

            var settings = new SpiConnectionSettings(SpiBusId, chipSelectLine)
            {
               ClockFrequency = 1000000,
               Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
               SharingMode = SpiSharingMode.Shared,
            };

            using (SpiDevice device = SpiDevice.Create(settings))
            {
               Thread.Sleep(500);

               while (true)
               {
                  byte[] writeBuffer = new byte[] { RegVersion, 0x0 };
                  byte[] readBuffer = new byte[writeBuffer.Length];

                  device.TransferFullDuplex(writeBuffer, readBuffer);

                  Debug.WriteLine(String.Format("Register 0x{0:x2} - Value 0X{1:x2}", RegVersion, readBuffer[1]));

#if ESP32_WROOM_32_LORA_1_CHANNEL || NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
                  if ( gpioController.Read(ledPinNumber) == PinValue.High)
						{
                     gpioController.Write(ledPinNumber, PinValue.Low);
                  }
                  else
						{
                     gpioController.Write(ledPinNumber, PinValue.High);
                  }
#endif
                  Thread.Sleep(10000);
               }
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine(ex.Message);
         }
      }

#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
      static int PinNumber(char port, byte pin)
      {
         if (port < 'A' || port > 'J')
            throw new ArgumentException();

         return ((port - 'A') * 16) + pin;
      }
#endif
   }
}
Shield SPI Debug output
namespace devMobile.IoT.SX127x.RegisterScan
{
   using System;
   using System.Diagnostics;
   using System.Threading;

   using System.Device.Gpio;
   using System.Device.Spi;

#if ESP32_WROOM_32_LORA_1_CHANNEL
   using nanoFramework.Hardware.Esp32;
#endif

   public sealed class SX127XDevice
   {
      private readonly SpiDevice SX127XTransceiver;

      public SX127XDevice(int busId, int chipSelectLine)
      {

         var settings = new SpiConnectionSettings(busId, chipSelectLine)
         {
            ClockFrequency = 1000000,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared
         };

         SX127XTransceiver = new SpiDevice(settings);
      }

      public SX127XDevice(int busId, int chipSelectLine, int resetPin)
      {
         var settings = new SpiConnectionSettings(busId, chipSelectLine)
         {
            ClockFrequency = 1000000,
            Mode = SpiMode.Mode0,// From SemTech docs pg 80 CPOL=0, CPHA=0
            SharingMode = SpiSharingMode.Shared
         };

         SX127XTransceiver = new SpiDevice(settings);

         // Factory reset pin configuration
         GpioController gpioController = new GpioController();
         gpioController.OpenPin(resetPin, PinMode.Output);

         gpioController.Write(resetPin, PinValue.Low);
         Thread.Sleep(20);
         gpioController.Write(resetPin, PinValue.High);
         Thread.Sleep(20);
      }

      public Byte RegisterReadByte(byte registerAddress)
      {
         byte[] writeBuffer = new byte[] { registerAddress, 0x0 };
         byte[] readBuffer = new byte[writeBuffer.Length];

         SX127XTransceiver.TransferFullDuplex(writeBuffer, readBuffer);

         return readBuffer[1];
      }
   }

   public class Program
   {
#if ESP32_WROOM_32_LORA_1_CHANNEL
      private const int SpiBusId = 1;
#endif
#if NETDUINO3_WIFI
      private const int SpiBusId = 2;
#endif
#if ST_STM32F769I_DISCOVERY
      private const int SpiBusId = 2;
#endif

      public static void Main()
      {
#if ESP32_WROOM_32_LORA_1_CHANNEL
         int chipSelectLine = Gpio.IO16;
#endif
#if NETDUINO3_WIFI
         // Arduino D10->PB10
         int chipSelectLine = PinNumber('B', 10);
         // Arduino D9->PE5
         int resetPinNumber = PinNumber('E', 5);
#endif
#if ST_STM32F769I_DISCOVERY
         // Arduino D10->PA11
         int chipSelectLine = PinNumber('A', 11);
         // Arduino D9->PH6
         int resetPinNumber = PinNumber('H', 6);
#endif

         Debug.WriteLine("devMobile.IoT.SX127x.RegisterScan starting");

         try
         {
#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
            SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine, resetPinNumber);
#endif

#if ESP32_WROOM_32_LORA_1_CHANNEL
            Configuration.SetPinFunction(Gpio.IO12, DeviceFunction.SPI1_MISO);
            Configuration.SetPinFunction(Gpio.IO13, DeviceFunction.SPI1_MOSI);
            Configuration.SetPinFunction(Gpio.IO14, DeviceFunction.SPI1_CLOCK);

            SX127XDevice sx127XDevice = new SX127XDevice(SpiBusId, chipSelectLine);
#endif

            Thread.Sleep(500);

            while (true)
            {
               for (byte registerIndex = 0; registerIndex <= 0x42; registerIndex++)
               {
                  byte registerValue = sx127XDevice.RegisterReadByte(registerIndex);

                  Debug.WriteLine($"Register 0x{registerIndex:x2} - Value 0X{registerValue:x2}");
               }
               Debug.WriteLine("");

               Thread.Sleep(10000);
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine(ex.Message);
         }
      }

#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
      static int PinNumber(char port, byte pin)
      {
         if (port < 'A' || port > 'J')
            throw new ArgumentException();

         return ((port - 'A') * 16) + pin;
      }
#endif
   }
}
RegisterScan Debug Output

There is some SparkFun LoRa Gateway-1-Channel (ESP32) specific configuration to map the Serial Peripheral Interface(SPI) pins and an additional NuGet for ESP32 has to be added. For the initial versions I have not used more advanced .NET nanoFramework functionality like SpanByte.

.NET nanoFramework ValueChanged

If you have landed at this post you were most probably searching for issues updating .NET nanoFramework code that used ValueChanged to handle interrupts. Back in mid 2020 the initial version of my Semtech SX127X(HopeRF RFM9X) library used the Windows.Devices.Gpio Nuget package.

public Rfm9XDevice(string spiPort, int chipSelectPin, int resetPin, int interruptPin)
{
    //...
   
   // Interrupt pin for RX message & TX done notification 
   InterruptGpioPin = gpioController.OpenPin(interruptPin);
   InterruptGpioPin.SetDriveMode(GpioPinDriveMode.Input);

   InterruptGpioPin.ValueChanged += InterruptGpioPin_ValueChanged;
}

private void InterruptGpioPin_ValueChanged(object sender, GpioPinValueChangedEventArgs e)
{
   if (e.Edge != GpioPinEdge.RisingEdge)
   {
      return;
   }

   byte irqFlags = this.RegisterReadByte(0x12); // RegIrqFlags
   //...
}

Then in March 2022 I updated the CoreLibrary, Runtime.Events, System.Devices.GPIO, System.Devices.SPI NuGets.

I then fixed all the breaking changes (For the initial versions I have not updated the code to use SpanByte etc.).

public Rfm9XDevice(int spiBusId, int chipSelectPin, int resetPin, int interruptPin)
{
   //...

   // Interrupt pin for RX message & TX done notification 
   InterruptGpioPin = gpioController.OpenPin(interruptPin);
   InterruptGpioPin.SetPinMode(PinMode.Input);

   InterruptGpioPin.ValueChanged += InterruptGpioPin_ValueChanged;
}

private void InterruptGpioPin_ValueChanged(object sender, PinValueChangedEventArgs e)
{
   if (e.ChangeType != PinEventTypes.Rising)
   {
      return;
   }

   byte irqFlags = this.RegisterReadByte(0x12); // RegIrqFlags
   //...
}

While “soak testing” the ReceiveInterrupt application I noticed that sometimes when I started the application interrupts were not processed or processing stopped after a while.

Visual Studio Debugger output showing intermittent calling of InterruptGpioPin_ValueChanged

I then found the RangeTester application wouldn’t start or run reliably. My original code was based on the Widnows.Devices.GPIO sample so I updated it based on the System.Device.GPIO sample.

public Rfm9XDevice(int spiBusId, int chipSelectPin, int resetPin, int interruptPin)
{
   //...

   // Interrupt pin for RX message & TX done notification 
   gpioController.OpenPin(interruptPin,PinMode.InputPullDown);

   gpioController.RegisterCallbackForPinValueChangedEvent(interruptPin, PinEventTypes.Rising, InterruptGpioPin_ValueChanged);
}

private void InterruptGpioPin_ValueChanged(object sender, PinValueChangedEventArgs e)
{
   byte irqFlags = this.RegisterReadByte(0x12); // RegIrqFlags
   //...
}
Visual Studio Debugger output showing reliable calling of InterruptGpioPin_ValueChanged

If your Windows.Devices.GPIO based project is not reliably handling interrupts after upgrading to System.Device.GPIO and fixing any “breaking changes” the implementation most probably need to be updated to use RegisterCallbackForPinValueChangedEvent as well.

.NET Core 5 SX127X library Arduino Duplex

The arduino-LoRa library LoRaDuplex sample is the basis for the last in this series of posts. The LoRaDuplex sample implements a basic protocol for addressed messages. The message payload starts with the destination address(byte), source address(byte), message counter(byte), payload length(byte), and then the payload(array of bytes).

LoRaDuplex

The sample code has configuration settings for the local address and destination (address).

#include <SPI.h>              // include libraries
#include <LoRa.h>

const int csPin = 10;          // LoRa radio chip select
const int resetPin = 9;       // LoRa radio reset
const int irqPin = 2;         // change for your board; must be a hardware interrupt pin

String outgoing;              // outgoing message

byte msgCount = 0;            // count of outgoing messages
byte localAddress = 0xAA;     // address of this device
byte destination = 0x0;      // destination to send to
long lastSendTime = 0;        // last send time
int interval = 2000;          // interval between sends

void setup() {
  Serial.begin(9600);                   // initialize serial
  while (!Serial);

  Serial.println("LoRa Duplex");

  // override the default CS, reset, and IRQ pins (optional)
  LoRa.setPins(csPin, resetPin, irqPin);// set CS, reset, IRQ pin

  if (!LoRa.begin(915E6)) {             // initialize ratio at 915 MHz
    Serial.println("LoRa init failed. Check your connections.");
    while (true);                       // if failed, do nothing
  }

  LoRa.enableCrc();

  Serial.println("LoRa init succeeded.");
}

void loop() {
  if (millis() - lastSendTime > interval) {
    String message = "HeLoRa World!";   // send a message
    sendMessage(message);
    Serial.println("Sending " + message);
    lastSendTime = millis();            // timestamp the message
    interval = random(2000) + 29000;    // 2-3 seconds
  }

  // parse for a packet, and call onReceive with the result:
  onReceive(LoRa.parsePacket());
}

void sendMessage(String outgoing) {
  LoRa.beginPacket();                   // start packet
  LoRa.write(destination);              // add destination address
  LoRa.write(localAddress);             // add sender address
  LoRa.write(msgCount);                 // add message ID
  LoRa.write(outgoing.length());        // add payload length
  LoRa.print(outgoing);                 // add payload
  LoRa.endPacket();                     // finish packet and send it
  msgCount++;                           // increment message ID
}

void onReceive(int packetSize) {
  if (packetSize == 0) return;          // if there's no packet, return

  // read packet header bytes:
  int recipient = LoRa.read();          // recipient address
  byte sender = LoRa.read();            // sender address
  byte incomingMsgId = LoRa.read();     // incoming msg ID
  byte incomingLength = LoRa.read();    // incoming msg length

  String incoming = "";

  while (LoRa.available()) {
    incoming += (char)LoRa.read();
  }

  if (incomingLength != incoming.length()) {   // check length for error
    Serial.println("error: message length does not match length");
    return;                             // skip rest of function
  }

  // if the recipient isn't this device or broadcast,
  if (recipient != localAddress && recipient != 0xFF) {
    Serial.println("This message is not for me.");
    return;                             // skip rest of function
  }

  // if message is for this device, or broadcast, print details:
  Serial.println("Received from: 0x" + String(sender, HEX));
  Serial.println("Sent to: 0x" + String(recipient, HEX));
  Serial.println("Message ID: " + String(incomingMsgId));
  Serial.println("Message length: " + String(incomingLength));
  Serial.println("Message: " + incoming);
  Serial.println("RSSI: " + String(LoRa.packetRssi()));
  Serial.println("Snr: " + String(LoRa.packetSnr()));
  Serial.println();
}
Arduino Monitor displaying information about the messages sent and received by the Duplex sample

In the Visual Studio output window I could see the SX127XLoRaDeviceClient sending and receiving messages

Loaded '/usr/lib/dotnet/shared/Microsoft.NETCore.App/5.0.4/Microsoft.Win32.Primitives.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
17:26:10-TX to 0xAA from 0x00 count 1 length 28 "Hello LoRa from .NET Core! 1"
17:26:10-TX Done
17:26:20-TX to 0xAA from 0x00 count 2 length 28 "Hello LoRa from .NET Core! 2"
17:26:20-TX Done
17:26:30-TX to 0xAA from 0x00 count 3 length 28 "Hello LoRa from .NET Core! 3"
17:26:30-TX Done
17:26:31-RX to 0x00 from 0xAA count 0 length 13 "HeLoRa World!" snr 9.5 packet rssi -57dBm rssi -100dBm 
17:26:40-TX to 0xAA from 0x00 count 4 length 28 "Hello LoRa from .NET Core! 4"
17:26:40-TX Done
17:26:50-TX to 0xAA from 0x00 count 5 length 28 "Hello LoRa from .NET Core! 5"
17:26:50-TX Done
17:27:00-TX to 0xAA from 0x00 count 6 length 28 "Hello LoRa from .NET Core! 6"
17:27:00-TX Done
17:27:01-RX to 0x00 from 0xBB count 1 length 13 "HeLoRa World!" snr 9.8 packet rssi -50dBm rssi -100dBm 
17:27:10-TX to 0xAA from 0x00 count 7 length 28 "Hello LoRa from .NET Core! 7"
17:27:10-TX Done
17:27:20-TX to 0xAA from 0x00 count 8 length 28 "Hello LoRa from .NET Core! 8"
17:27:20-TX Done
17:27:30-TX to 0xAA from 0x00 count 9 length 28 "Hello LoRa from .NET Core! 9"
17:27:30-TX Done

I modified the SX127X.NetCore SX127XLoRaDeviceClient adding one final conditional compile option(LoRaDuplex) for this sample

static void Main(string[] args)
{
	int messageCount = 1;

	sX127XDevice.Initialise(
			SX127XDevice.RegOpModeMode.ReceiveContinuous,
			915000000.0,
			powerAmplifier: SX127XDevice.PowerAmplifier.PABoost,
#if LORA_SENDER // From the Arduino point of view
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_RECEIVER // From the Arduino point of view, don't actually need this as already inverted
			invertIQTX: true
#endif

#if LORA_SET_SYNCWORD
			syncWord: 0xF3,
			invertIQTX: true,
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SET_SPREAD
			spreadingFactor: SX127XDevice.RegModemConfig2SpreadingFactor._256ChipsPerSymbol,
			invertIQTX: true,
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SIMPLE_NODE // From the Arduino point of view
			invertIQTX: false,
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SIMPLE_GATEWAY // From the Arduino point of view
			invertIQRX: true,
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_DUPLEX
			rxPayloadCrcOn: true
#endif
			);

#if DEBUG
	sX127XDevice.RegisterDump();
#endif

#if !LORA_RECEIVER
	sX127XDevice.OnReceive += SX127XDevice_OnReceive;
	sX127XDevice.Receive();
#endif
#if !LORA_SENDER
	sX127XDevice.OnTransmit += SX127XDevice_OnTransmit;
#endif

#if LORA_SENDER
	Thread.Sleep(-1);
#else
	Thread.Sleep(5000);
#endif

	while (true)
	{
		string messageText = "Hello LoRa from .NET Core! " + messageCount.ToString();

#if LORA_DUPLEX
		byte[] messageBytes = new byte[messageText.Length+4];

		messageBytes[0] = 0xaa;
		messageBytes[1] = 0x00;
		messageBytes[2] = (byte)messageCount;
		messageBytes[3] = (byte)messageText.Length;

		Array.Copy(UTF8Encoding.UTF8.GetBytes(messageText), 0, messageBytes, 4, messageBytes[3]);

		Console.WriteLine($"{DateTime.Now:HH:mm:ss}-TX to 0x{messageBytes[0]:X2} from 0x{messageBytes[1]:X2} count {messageBytes[2]} length {messageBytes[3]} \"{messageText}\"");
#else
		byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);

		Console.WriteLine($"{DateTime.Now:HH:mm:ss}- Length {messageBytes.Length} \"{messageText}\"");
#endif

		messageCount += 1;

		sX127XDevice.Send(messageBytes);

		Thread.Sleep(10000);
	}
}

private static void SX127XDevice_OnReceive(object sender, SX127XDevice.OnDataReceivedEventArgs e)
{
	string messageText;

#if LORA_DUPLEX
	if ((e.Data[0] != 0x00) && (e.Data[0] != 0xFF))
	{
#if DEBUG
		Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss}-RX to 0x{e.Data[0]:X2} from 0x{e.Data[1]:X2} invalid address");
#endif
		return;
	}

	// check payload not to long/short
	if  ((e.Data[3] + 4) != e.Data.Length)
	{
		Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss}-RX Invalid payload");

		return;
	}

	try
	{
		messageText = UTF8Encoding.UTF8.GetString(e.Data, 4, e.Data[3]);

		Console.WriteLine($"{DateTime.Now:HH:mm:ss}-RX to 0x{e.Data[0]:X2} from 0x{e.Data[1]:X2} count {e.Data[2]} length {e.Data[3]} \"{messageText}\" snr {e.PacketSnr:0.0} packet rssi {e.PacketRssi}dBm rssi {e.Rssi}dBm ");
	}
	catch (Exception ex)
	{
		Console.WriteLine(ex.Message);
	}
#else
	try
	{
		messageText = UTF8Encoding.UTF8.GetString(e.Data);

		Console.WriteLine($"{DateTime.Now:HH:mm:ss}-RX length {e.Data.Length} \"{messageText}\" snr {e.PacketSnr:0.0} packet rssi {e.PacketRssi}dBm rssi {e.Rssi}dBm ");
	}
	catch (Exception ex)
	{
		Console.WriteLine(ex.Message);
	}
#endif
}

The inbound messages have to have a valid Cyclic Redundancy Check(CRC) and I ignore messages with an invalid payload length. The message protocol is insecure (but fine for demos) as the messages are sent as “plain text”, and the message headers/payload can be tampered with.

Summary

While testing the LoRaDuplex sample I found a problem with how my code managed the invertIQRX & invertIQTX flags in RegInvertIQ. I noticed the even though I was setting the InvertIQRX(bit6) and invertIQTX(bit0) flags correctly messages weren’t getting delivered.

Semtech SX127X data sheet RegInvertQ and RegInvertQ2 documetnation

After looking at my code I realised I wasn’t configuring the RegInvertIQ properly because bits 1-5 were getting set to 0x0 (initially I had byte regInvertIQValue = 0) rather than 0x13(regInvertIQValue = RegInvertIdDefault)

...
// RegInvertId
private const byte RegInvertIdDefault = 0b00100110;
private const byte InvertIqRXOn = 0b01000000;
private const byte InvertIqRXOff = 0b00000000;
public const bool InvertIqRXDefault = false;

private const byte InvertIqTXOn =  0b00000001; 
private const byte InvertIqTXOff = 0b00000000;
...

if ((invertIQRX != InvertIqRXDefault) || (invertIQTX != InvertIqTXDefault))
{
	// Initially this was byte regInvertIQValue = 0;
	byte regInvertIQValue = RegInvertIdDefault;

	if (invertIQRX)
	{
		regInvertIQValue |= InvertIqRXOn;
	}

	if (invertIQTX)
	{
		regInvertIQValue |= InvertIqTXOn;
	}

	this.WriteByte((byte)Registers.RegInvertIQ, regInvertIQValue);

	if (invertIQRX || invertIQTX)
	{
		this.WriteByte((byte)Registers.RegInvertIQ2, RegInvertIq2On);
	}
	else
	{
		this.WriteByte((byte)Registers.RegInvertIQ2, RegInvertIq2Off);
	}
}

.NET Core SX127X library Arduino LoRaSimpleNode & LoRaSimpleGateway

The LoRaSimpleNode and LoRaSimpleGateway samples shows how the receive and transmit IQ can be inverted.

LoRaSimpleNode

This sample uses all default settings except for frequency with InvertIQ enabled in receive more and disabled in Transmit mode

void loop() {
  if (runEvery(1000)) { // repeat every 1000 millis

    String message = "HeLoRa World! ";
    message += "I'm a Node! ";
    message += millis();

    LoRa_sendMessage(message); // send a message

    Serial.println("Send Message!");
  }
}

void LoRa_rxMode(){
  LoRa.enableInvertIQ();                // active invert I and Q signals
  LoRa.receive();                       // set receive mode
}

void LoRa_txMode(){
  LoRa.idle();                          // set standby mode
  LoRa.disableInvertIQ();               // normal mode
}

void LoRa_sendMessage(String message) {
  LoRa_txMode();                        // set tx mode
  LoRa.beginPacket();                   // start packet
  LoRa.print(message);                  // add payload
  LoRa.endPacket();                     // finish packet and send it
  LoRa_rxMode();                        // set rx mode
}

void onReceive(int packetSize) {
  String message = "";

  while (LoRa.available()) {
    message += (char)LoRa.read();
  }

  Serial.print("Node Receive: ");
  Serial.println(message);

}
Arduino Monitor displaying the output of the Arduino-LoRa Simple Node sample

In the Visual Studio output window I could see the received messages.

Loaded '/usr/lib/dotnet/shared/Microsoft.NETCore.App/5.0.4/Microsoft.Win32.Primitives.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
17:46:31-RX length 31 "HeLoRa World! I'm a Node! 69000" snr 10.3 packet rssi -57dBm rssi -98dBm 
17:46:32-RX length 31 "HeLoRa World! I'm a Node! 70000" snr 9.8 packet rssi -56dBm rssi -104dBm 
17:46:33-RX length 31 "HeLoRa World! I'm a Node! 71000" snr 10.0 packet rssi -57dBm rssi -104dBm 
17:46:34-RX length 31 "HeLoRa World! I'm a Node! 72000" snr 9.8 packet rssi -56dBm rssi -102dBm 
17:46:35-RX length 31 "HeLoRa World! I'm a Node! 73000" snr 9.8 packet rssi -59dBm rssi -102dBm 
17:46:36- Length 28 "Hello LoRa from .NET Core! 1"
17:46:36-TX Done
17:46:37-RX length 31 "HeLoRa World! I'm a Node! 75000" snr 9.3 packet rssi -58dBm rssi -102dBm 
17:46:38-RX length 31 "HeLoRa World! I'm a Node! 76000" snr 9.0 packet rssi -58dBm rssi -102dBm 
17:46:39-RX length 31 "HeLoRa World! I'm a Node! 77000" snr 9.8 packet rssi -59dBm rssi -104dBm 
17:46:40-RX length 31 "HeLoRa World! I'm a Node! 78000" snr 9.5 packet rssi -57dBm rssi -102dBm 
17:46:41-RX length 31 "HeLoRa World! I'm a Node! 79000" snr 9.5 packet rssi -55dBm rssi -102dBm 
17:46:42-RX length 31 "HeLoRa World! I'm a Node! 80000" snr 9.8 packet rssi -57dBm rssi -104dBm 
17:46:43-RX length 31 "HeLoRa World! I'm a Node! 81000" snr 9.5 packet rssi -58dBm rssi -104dBm 
17:46:44-RX length 31 "HeLoRa World! I'm a Node! 82000" snr 9.5 packet rssi -58dBm rssi -104dBm 
17:46:45-RX length 31 "HeLoRa World! I'm a Node! 83000" snr 9.0 packet rssi -58dBm rssi -94dBm 
17:46:46- Length 28 "Hello LoRa from .NET Core! 2"
17:46:46-TX Done
17:46:47-RX length 31 "HeLoRa World! I'm a Node! 85000" snr 9.0 packet rssi -58dBm rssi -104dBm 
17:46:48-RX length 31 "HeLoRa World! I'm a Node! 86000" snr 9.5 packet rssi -58dBm rssi -104dBm 
17:46:49-RX length 31 "HeLoRa World! I'm a Node! 87000" snr 9.5 packet rssi -58dBm rssi -102dBm 
17:46:50-RX length 30 "HeLoRa World! I'm a Node! 1000" snr 9.5 packet rssi -58dBm rssi -102dBm 
17:46:51-RX length 30 "HeLoRa World! I'm a Node! 2000" snr 9.5 packet rssi -58dBm rssi -104dBm 
17:46:52-RX length 30 "HeLoRa World! I'm a Node! 3000" snr 9.3 packet rssi -58dBm rssi -102dBm 
17:46:53-RX length 30 "HeLoRa World! I'm a Node! 4000" snr 9.5 packet rssi -58dBm rssi -102dBm 
17:46:54-RX length 30 "HeLoRa World! I'm a Node! 5000" snr 10.0 packet rssi -57dBm rssi -102dBm 
17:46:55-RX length 30 "HeLoRa World! I'm a Node! 6000" snr 10.0 packet rssi -57dBm rssi -102dBm 
17:46:56- Length 28 "Hello LoRa from .NET Core! 3"
17:46:56-TX Done
17:46:56-RX length 30 "HeLoRa World! I'm a Node! 7000" snr 9.8 packet rssi -57dBm rssi -104dBm 
17:46:57-RX length 30 "HeLoRa World! I'm a Node! 8000" snr 10.0 packet rssi -57dBm rssi -102dBm 
17:46:58-RX length 30 "HeLoRa World! I'm a Node! 9000" snr 9.8 packet rssi -57dBm rssi -104dBm 
17:46:59-RX length 31 "HeLoRa World! I'm a Node! 10000" snr 9.8 packet rssi -57dBm rssi -100dBm 
17:47:00-RX length 31 "HeLoRa World! I'm a Node! 11000" snr 9.8 packet rssi -57dBm rssi -99dBm 
17:47:01-RX length 31 "HeLoRa World! I'm a Node! 12000" snr 9.3 packet rssi -57dBm rssi -104dBm 
17:47:04-RX length 30 "HeLoRa World! I'm a Node! 1000" snr 9.5 packet rssi -57dBm rssi -100dBm 
17:47:05-RX length 30 "HeLoRa World! I'm a Node! 2000" snr 10.0 packet rssi -57dBm rssi -100dBm 
17:47:06- Length 28 "Hello LoRa from .NET Core! 4"
17:47:06-TX Done

LoRaSimpleGateway

The SimpleGateway uses all the same settings but with InvertIQ enabled in Transmit mode and disabled in Receive mode

#include <SPI.h>              // include libraries
#include <LoRa.h>

const long frequency = 915E6;  // LoRa Frequency

const int csPin = 10;          // LoRa radio chip select
const int resetPin = 9;        // LoRa radio reset
const int irqPin = 2;          // change for your board; must be a hardware interrupt pin

void setup() {
  Serial.begin(9600);                   // initialize serial
  while (!Serial);

  LoRa.setPins(csPin, resetPin, irqPin);

  if (!LoRa.begin(frequency)) {
    Serial.println("LoRa init failed. Check your connections.");
    while (true);                       // if failed, do nothing
  }

  Serial.println("LoRa init succeeded.");
  Serial.println();
  Serial.println("LoRa Simple Gateway");
  Serial.println("Only receive messages from nodes");
  Serial.println("Tx: invertIQ enable");
  Serial.println("Rx: invertIQ disable");
  Serial.println();

  LoRa.onReceive(onReceive);
  LoRa_rxMode();
}

void loop() {
  if (runEvery(5000)) { // repeat every 5000 millis

    String message = "HeLoRa World! ";
    message += "I'm a Gateway! ";
    message += millis();

    LoRa_sendMessage(message); // send a message

    Serial.println("Send Message!");
  }
}

void LoRa_rxMode(){
  LoRa.disableInvertIQ();               // normal mode
  LoRa.receive();                       // set receive mode
}

void LoRa_txMode(){
  LoRa.idle();                          // set standby mode
  LoRa.enableInvertIQ();                // active invert I and Q signals
}

void LoRa_sendMessage(String message) {
  LoRa_txMode();                        // set tx mode
  LoRa.beginPacket();                   // start packet
  LoRa.print(message);                  // add payload
  LoRa.endPacket();                     // finish packet and send it
  LoRa_rxMode();                        // set rx mode
}

void onReceive(int packetSize) {
  String message = "";

  while (LoRa.available()) {
    message += (char)LoRa.read();
  }

  Serial.print("Gateway Receive: ");
  Serial.println(message);
}

Arduino Monitor displaying the output of the Arduino-LoRa Simple Gateway sample

In the Visual Studio output window I could see messages getting transmitted with sent confirmations.

Loaded '/usr/lib/dotnet/shared/Microsoft.NETCore.App/5.0.4/Microsoft.Win32.Primitives.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
17:51:39-RX length 34 "HeLoRa World! I'm a Gateway! 10000" snr 9.3 packet rssi -59dBm rssi -102dBm 
17:51:39- Length 28 "Hello LoRa from .NET Core! 1"
17:51:39-TX Done
17:51:44-RX length 34 "HeLoRa World! I'm a Gateway! 15000" snr 9.3 packet rssi -58dBm rssi -102dBm 
17:51:49-RX length 34 "HeLoRa World! I'm a Gateway! 20000" snr 9.3 packet rssi -59dBm rssi -100dBm 
17:51:49- Length 28 "Hello LoRa from .NET Core! 2"
17:51:49-TX Done
17:51:54-RX length 34 "HeLoRa World! I'm a Gateway! 25000" snr 9.0 packet rssi -58dBm rssi -102dBm 
17:51:59-RX length 34 "HeLoRa World! I'm a Gateway! 30000" snr 9.3 packet rssi -58dBm rssi -100dBm 
17:51:59- Length 28 "Hello LoRa from .NET Core! 3"
17:51:59-TX Done
17:52:04-RX length 34 "HeLoRa World! I'm a Gateway! 35000" snr 9.3 packet rssi -60dBm rssi -104dBm 
17:52:09-RX length 34 "HeLoRa World! I'm a Gateway! 40000" snr 9.5 packet rssi -59dBm rssi -104dBm 
17:52:09- Length 28 "Hello LoRa from .NET Core! 4"
17:52:09-TX Done
17:52:14-RX length 34 "HeLoRa World! I'm a Gateway! 45000" snr 9.5 packet rssi -59dBm rssi -102dBm 
17:52:19-RX length 34 "HeLoRa World! I'm a Gateway! 50000" snr 9.3 packet rssi -60dBm rssi -104dBm 
17:52:19- Length 28 "Hello LoRa from .NET Core! 5"
17:52:19-TX Done
17:52:24-RX length 34 "HeLoRa World! I'm a Gateway! 55000" snr 9.8 packet rssi -60dBm rssi -102dBm 
17:52:29-RX length 34 "HeLoRa World! I'm a Gateway! 60000" snr 9.3 packet rssi -60dBm rssi -104dBm 
17:52:29- Length 28 "Hello LoRa from .NET Core! 6"
17:52:29-TX Done
17:52:34-RX length 34 "HeLoRa World! I'm a Gateway! 65000" snr 9.0 packet rssi -60dBm rssi -102dBm 
17:52:39-RX length 34 "HeLoRa World! I'm a Gateway! 70000" snr 9.3 packet rssi -60dBm rssi -102dBm 
17:52:39- Length 28 "Hello LoRa from .NET Core! 7"
17:52:39-TX Done
17:52:44-RX length 34 "HeLoRa World! I'm a Gateway! 75000" snr 8.8 packet rssi -58dBm rssi -102dBm 
17:52:49-RX length 34 "HeLoRa World! I'm a Gateway! 80000" snr 9.0 packet rssi -59dBm rssi -102dBm 
17:52:49- Length 28 "Hello LoRa from .NET Core! 8"
17:52:49-TX Done
17:52:54-RX length 34 "HeLoRa World! I'm a Gateway! 85000" snr 9.8 packet rssi -60dBm rssi -102dBm 
17:52:59-RX length 34 "HeLoRa World! I'm a Gateway! 90000" snr 9.0 packet rssi -60dBm rssi -102dBm 
17:52:59- Length 28 "Hello LoRa from .NET Core! 9"
17:52:59-TX Done
17:53:04-RX length 34 "HeLoRa World! I'm a Gateway! 95000" snr 9.3 packet rssi -59dBm rssi -100dBm 
17:53:09-RX length 35 "HeLoRa World! I'm a Gateway! 100000" snr 9.0 packet rssi -59dBm rssi -102dBm 
17:53:09- Length 29 "Hello LoRa from .NET Core! 10"
17:53:09-TX Done
17:53:14-RX length 35 "HeLoRa World! I'm a Gateway! 105000" snr 9.5 packet rssi -56dBm rssi -102dBm 
17:53:19-RX length 35 "HeLoRa World! I'm a Gateway! 110000" snr 9.3 packet rssi -59dBm rssi -102dBm 
17:53:19- Length 29 "Hello LoRa from .NET Core! 11"
17:53:19-TX Done

I then modified the SX127X.NetCore SX127XLoRaDeviceClient adding even more conditional compile options for the LoRaSampleNode and LoRaSampleGateway samples.

int messageCount = 1;

sX127XDevice.Initialise(
		SX127XDevice.RegOpModeMode.ReceiveContinuous,
		915000000.0,
		powerAmplifier: SX127XDevice.PowerAmplifier.PABoost,
		// outputPower: 5, outputPower: 20, outputPower:23,
		//powerAmplifier: SX127XDevice.PowerAmplifier.Rfo,	
		//outputPower:-1, outputPower: 14,
#if LORA_SENDER // From the Arduino point of view
		rxDoneignoreIfCrcMissing: false
#endif
#if LORA_RECEIVER // From the Arduino point of view, don't actually need this as already inverted
		invertIQTX: true
#endif

#if LORA_SET_SYNCWORD
		syncWord: 0xF3,
		invertIQTX: true,
		rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SET_SPREAD
		spreadingFactor: SX127XDevice.RegModemConfig2SpreadingFactor._256ChipsPerSymbol,
		invertIQTX: true,
		rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SIMPLE_NODE // From the Arduino point of view
		invertIQTX: false,
		rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SIMPLE_GATEWAY // From the Arduino point of view
		invertIQRX: true,
		rxDoneignoreIfCrcMissing: false
#endif
		);

#if DEBUG
		sX127XDevice.RegisterDump();
#endif

#if !LORA_RECEIVER
		sX127XDevice.OnReceive += SX127XDevice_OnReceive;
		sX127XDevice.Receive();
#endif
#if !LORA_SENDER
		sX127XDevice.OnTransmit += SX127XDevice_OnTransmit;
#endif

#if LORA_SENDER
		Thread.Sleep(-1);
#else
		Thread.Sleep(5000);
#endif

		while (true)
		{
			string messageText = "Hello LoRa from .NET Core! " + messageCount.ToString();

			byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);

			Console.WriteLine($"{DateTime.Now:HH:mm:ss}- Length {messageBytes.Length} \"{messageText}\"");

			messageCount += 1;

			sX127XDevice.Send(messageBytes);

			Thread.Sleep(10000);
		}
	}

Summary

While testing the LoRaReceiver sample I found a problem with how my code managed the transmit power by accidentally commenting out the “paBoost: true” parameter of the initialise method. When I did this the Seeeduino V4.2 and Dragino Shield stopped receiving messages.

I had assumed a user could configure the the output power using the initialise method but that was difficult/possible. After some digging I found that I needed to use RegPAConfigPADac and PABoost (I need to find a device which uses RFO for testing). So I removed several of the configuration parameters from the Intialise method and replaced them with one called outputPower. I then re-read the SX127X data sheet and had a look at some other libraries.

The Arduino-LoRa code has SetPower

void LoRaClass::setTxPower(int level, int outputPin)
{
  if (PA_OUTPUT_RFO_PIN == outputPin) {
    // RFO
    if (level < 0) {
      level = 0;
    } else if (level > 14) {
      level = 14;
    }

    writeRegister(REG_PA_CONFIG, 0x70 | level);
  } else {
    // PA BOOST
    if (level > 17) {
      if (level > 20) {
        level = 20;
      }

      // subtract 3 from level, so 18 - 20 maps to 15 - 17
      level -= 3;

      // High Power +20 dBm Operation (Semtech SX1276/77/78/79 5.4.3.)
      writeRegister(REG_PA_DAC, 0x87);
      setOCP(140);
    } else {
      if (level < 2) {
        level = 2;
      }
      //Default value PA_HF/LF or +17dBm
      writeRegister(REG_PA_DAC, 0x84);
      setOCP(100);
    }

    writeRegister(REG_PA_CONFIG, PA_BOOST | (level - 2));
  }
}

The AdaFruit version of RadioHead library has SetTxPower which has been “tweaked”

void RH_RF95::setTxPower(int8_t power, bool useRFO)
{
    // Sigh, different behaviours depending on whther the module use PA_BOOST or the RFO pin
    // for the transmitter output
    if (useRFO)
    {
	if (power > 14)
	    power = 14;
	if (power < -1)
	    power = -1;
	spiWrite(RH_RF95_REG_09_PA_CONFIG, RH_RF95_MAX_POWER | (power + 1));
    }
    else
    {
	if (power > 23)
	    power = 23;
	if (power < 5)
	    power = 5;

	// For RH_RF95_PA_DAC_ENABLE, manual says '+20dBm on PA_BOOST when OutputPower=0xf'
	// RH_RF95_PA_DAC_ENABLE actually adds about 3dBm to all power levels. We will us it
	// for 21, 22 and 23dBm
	if (power > 20)
	{
	    spiWrite(RH_RF95_REG_4D_PA_DAC, RH_RF95_PA_DAC_ENABLE);
	    power -= 3;
	}
	else
	{
	    spiWrite(RH_RF95_REG_4D_PA_DAC, RH_RF95_PA_DAC_DISABLE);
	}

	// RFM95/96/97/98 does not have RFO pins connected to anything. Only PA_BOOST
	// pin is connected, so must use PA_BOOST
	// Pout = 2 + OutputPower.
	// The documentation is pretty confusing on this topic: PaSelect says the max power is 20dBm,
	// but OutputPower claims it would be 17dBm.
	// My measurements show 20dBm is correct
	spiWrite(RH_RF95_REG_09_PA_CONFIG, RH_RF95_PA_SELECT | (power-5));
    }
}

The LoRa Shield Arduino library has two methods setPower(char p) and setPowerNum(uint8_t pow)

/*
 Function: Sets the signal power indicated as input to the module.
 Returns: Integer that determines if there has been any error
   state = 2  --> The command has not been executed
   state = 1  --> There has been an error while executing the command
   state = 0  --> The command has been executed with no errors
   state = -1 --> Forbidden command for this protocol
 Parameters:
   pow: power option to set in configuration. The input value range is from 
   0 to 14 dBm.
*/
int8_t SX1278::setPowerNum(uint8_t pow)
{
  byte st0;
  int8_t state = 2;
  byte value = 0x00;

  #if (SX1278_debug_mode > 1)
	  Serial.println();
	  Serial.println(F("Starting 'setPower'"));
  #endif

  st0 = readRegister(REG_OP_MODE);	  // Save the previous status
  if( _modem == LORA )
  { // LoRa Stdby mode to write in registers
	  writeRegister(REG_OP_MODE, LORA_STANDBY_MODE);
  }
  else
  { // FSK Stdby mode to write in registers
	  writeRegister(REG_OP_MODE, FSK_STANDBY_MODE);
  }
  
  if ( (pow >= 2) && (pow <= 20) )
  { // Pout= 17-(15-OutputPower) = OutputPower+2
	  if ( pow <= 17 ) {
		writeRegister(REG_PA_DAC, 0x84);
	  	pow = pow - 2;
	  } else { // Power > 17dbm -> Power = 20dbm
		writeRegister(REG_PA_DAC, 0x87);
		pow = 15;
	  }
	  _power = pow;
  }
  else
  {
	  state = -1;
	  #if (SX1278_debug_mode > 1)
		  Serial.println(F("## Power value is not valid ##"));
		  Serial.println();
	  #endif
  }

  writeRegister(REG_PA_CONFIG, _power);	// Setting output power value
  value = readRegister(REG_PA_CONFIG);

  if( value == _power )
  {
	  state = 0;
	  #if (SX1278_debug_mode > 1)
		  Serial.println(F("## Output power has been successfully set ##"));
		  Serial.println();
	  #endif
  }
  else
  {
	  state = 1;
  }

  writeRegister(REG_OP_MODE, st0);	// Getting back to previous status
  return state;
}

The SEMTECH library(V2.1.0) manages sleeping the device, reading the existing configuration and updating it as required which was a bit more functionality that I wanted.

void SX1276LoRaSetRFPower( int8_t power )
{
    SX1276Read( REG_LR_PACONFIG, &SX1276LR->RegPaConfig );
    SX1276Read( REG_LR_PADAC, &SX1276LR->RegPaDac );
    
    if( ( SX1276LR->RegPaConfig & RFLR_PACONFIG_PASELECT_PABOOST ) == RFLR_PACONFIG_PASELECT_PABOOST )
    {
        if( ( SX1276LR->RegPaDac & 0x87 ) == 0x87 )
        {
            if( power < 5 )
            {
                power = 5;
            }
            if( power > 20 )
            {
                power = 20;
            }
            SX1276LR->RegPaConfig = ( SX1276LR->RegPaConfig & RFLR_PACONFIG_MAX_POWER_MASK ) | 0x70;
            SX1276LR->RegPaConfig = ( SX1276LR->RegPaConfig & RFLR_PACONFIG_OUTPUTPOWER_MASK ) | ( uint8_t )( ( uint16_t )( power - 5 ) & 0x0F );
        }
        else
        {
            if( power < 2 )
            {
                power = 2;
            }
            if( power > 17 )
            {
                power = 17;
            }
            SX1276LR->RegPaConfig = ( SX1276LR->RegPaConfig & RFLR_PACONFIG_MAX_POWER_MASK ) | 0x70;
            SX1276LR->RegPaConfig = ( SX1276LR->RegPaConfig & RFLR_PACONFIG_OUTPUTPOWER_MASK ) | ( uint8_t )( ( uint16_t )( power - 2 ) & 0x0F );
        }
    }
    else
    {
        if( power < -1 )
        {
            power = -1;
        }
        if( power > 14 )
        {
            power = 14;
        }
        SX1276LR->RegPaConfig = ( SX1276LR->RegPaConfig & RFLR_PACONFIG_MAX_POWER_MASK ) | 0x70;
        SX1276LR->RegPaConfig = ( SX1276LR->RegPaConfig & RFLR_PACONFIG_OUTPUTPOWER_MASK ) | ( uint8_t )( ( uint16_t )( power + 1 ) & 0x0F );
    }
    SX1276Write( REG_LR_PACONFIG, SX1276LR->RegPaConfig );
    LoRaSettings.Power = power;
}

All the of the examples I looked at were different and some had manual tweaks, others I have not included were just wrong. I have based my beta version on a hybrid of the Arduino-LoRa, RadioHead and Semtech libraries. I need to test my code and confirm that I have the limits and offsets correct for the PABoost and RFO modes.

// RegPaDac more power
[Flags]
public enum RegPaDac
{
	Normal = 0b01010100,
	Boost = 0b01010111,
}
private const byte RegPaDacPABoostThreshold = 20;

// Validate the OutputPower
if (powerAmplifier == PowerAmplifier.Rfo)
{
	if ((outputPower < OutputPowerRfoMin) || (outputPower > OutputPowerRfoMax))
	{
		throw new ArgumentException($"outputPower must be between {OutputPowerRfoMin} and {OutputPowerRfoMax}", nameof(outputPower));
	}
}
if (powerAmplifier == PowerAmplifier.PABoost)
{
	if ((outputPower < OutputPowerPABoostMin) || (outputPower > OutputPowerPABoostMax))
	{
		throw new ArgumentException($"outputPower must be between {OutputPowerPABoostMin} and {OutputPowerPABoostMax}", nameof(outputPower));	
	}
}

if (( powerAmplifier != PowerAmplifierDefault) || (outputPower != OutputPowerDefault))
{
	byte regPAConfigValue = RegPAConfigMaxPowerMax;

	if (powerAmplifier == PowerAmplifier.Rfo)
	{
		regPAConfigValue |= RegPAConfigPASelectRfo;

		regPAConfigValue |= (byte)(outputPower + 1);

		this.WriteByte((byte)Registers.RegPAConfig, regPAConfigValue);
	}

	if (powerAmplifier == PowerAmplifier.PABoost)
	{
		regPAConfigValue |= RegPAConfigPASelectPABoost;

		if (outputPower > RegPaDacPABoostThreshold)
		{
			this.WriteByte((byte)Registers.RegPaDac, (byte)RegPaDac.Boost);

			regPAConfigValue |= (byte)(outputPower - 8);

			this.WriteByte((byte)Registers.RegPAConfig, regPAConfigValue);
		}
		else
		{
			this.WriteByte((byte)Registers.RegPaDac, (byte)RegPaDac.Normal);

			regPAConfigValue |= (byte)(outputPower - 5);

			this.WriteByte((byte)Registers.RegPAConfig, regPAConfigValue);
		}
	}
}

.NET Core SX127X library Arduino LoRaSender & LoRaReceiver

The arduino-LoRa library comes with a number of samples showing how to use its functionality. The LoRaSender and LoRaReceiver samples show the bare minimum of code required to send and receive messages.

LoRaSender

This sample uses all default settings except for frequency

#include <SPI.h>
#include <LoRa.h>

int counter = 0;

void setup() {
  Serial.begin(9600);
  while (!Serial);

  Serial.println("LoRa Sender");

  if (!LoRa.begin(915E6)) {
    Serial.println("Starting LoRa failed!");
    while (1);
  }
  
  delay(5000);  
}

void loop() {
  Serial.print("Sending packet: ");
  Serial.println(counter);

  // send packet
  LoRa.beginPacket();
  LoRa.print("hello ");
  LoRa.print(counter);
  LoRa.endPacket();

  counter++;

  delay(5000);
}

Arduino-LoRa library LoRaSender monitor output

In the Visual Studio output window I could see the received messages including a “corrupted” one which was displayed because the SX127XLoRaDeviceClient couldn’t force Cyclic Redundancy Check(CRC)s.

Loaded '/usr/lib/dotnet/shared/Microsoft.NETCore.App/5.0.4/Microsoft.Win32.Primitives.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
17:08:14-RX length 108 "hello 7" snr 9.3 packet rssi -63dBm rssi -102dBm 
17:08:24-RX length 108 "hello 0" snr 9.5 packet rssi -64dBm rssi -104dBm 
17:08:29-RX length 108 "hello 1" snr 9.3 packet rssi -64dBm rssi -102dBm 
17:08:34-RX length 108 "hello 2" snr 9.5 packet rssi -64dBm rssi -102dBm 
17:08:39-RX length 108 "hello 3" snr 8.5 packet rssi -61dBm rssi -104dBm 
17:08:44-RX length 108 "hello 4" snr 8.5 packet rssi -62dBm rssi -104dBm 
17:08:49-RX length 108 "hello 5" snr 9.3 packet rssi -64dBm rssi -104dBm 
17:08:54-RX length 108 "hello 6" snr 9.3 packet rssi -64dBm rssi -102dBm 
17:08:59-RX length 108 "hello 7" snr 9.3 packet rssi -64dBm rssi -102dBm 
17:09:04-RX length 108 "hello 8" snr 9.3 packet rssi -64dBm rssi -100dBm 
17:09:09-RX length 108 "hello 9" snr 9.3 packet rssi -64dBm rssi -102dBm 
17:09:14-RX length 108 "hello 10" snr 8.8 packet rssi -58dBm rssi -102dBm 
17:09:19-RX length 108 "hello 11" snr 9.3 packet rssi -60dBm rssi -104dBm 
17:09:24-RX length 108 "hello 12" snr 9.5 packet rssi -59dBm rssi -104dBm 
17:09:29-RX length 108 "hello 13" snr 9.0 packet rssi -60dBm rssi -102dBm 
17:09:34-RX length 108 "hello 14" snr 9.5 packet rssi -59dBm rssi -105dBm 
17:09:39-RX length 108 "hello 15" snr 9.0 packet rssi -57dBm rssi -102dBm 
17:09:44-RX length 108 "hello 16" snr 9.3 packet rssi -61dBm rssi -104dBm 
17:09:49-RX length 108 "hello 17" snr 9.5 packet rssi -61dBm rssi -104dBm 
17:09:54-RX length 108 "hello 18" snr 9.0 packet rssi -59dBm rssi -104dBm 
17:09:59-RX length 108 "hello 19" snr 9.3 packet rssi -61dBm rssi -102dBm 
17:10:04-RX length 108 "hello 20" snr 9.0 packet rssi -59dBm rssi -104dBm 
17:10:09-RX length 108 "hello 21" snr 9.3 packet rssi -61dBm rssi -102dBm 
17:10:14-RX length 108 "hello 22" snr 9.5 packet rssi -60dBm rssi -102dBm 
17:10:19-RX length 108 "hello 23" snr 9.3 packet rssi -60dBm rssi -104dBm 
17:10:24-RX length 108 "hello 24" snr 9.3 packet rssi -60dBm rssi -103dBm 
17:10:26-RX length 212 "�Q�Ԕv&G=����[9Y���
2S��ᒵ��O*�Ϥ��X��p쏊��" snr 50.8 packet rssi -104dBm rssi -102dBm 
17:10:30-RX length 108 "hello 25" snr 9.5 packet rssi -60dBm rssi -102dBm 
17:10:35-RX length 108 "hello 26" snr 9.3 packet rssi -60dBm rssi -104dBm 

LoRaReceiver

This sample also uses all default settings except of the frequency

#include <SPI.h>
#include <LoRa.h>

void setup() {
  Serial.begin(9600);
  while (!Serial);

  Serial.println("LoRa Receiver");

  if (!LoRa.begin(915E6)) {
    Serial.println("Starting LoRa failed!");
    while (1);
  }
}

void loop() {
  // try to parse packet
  int packetSize = LoRa.parsePacket();
  if (packetSize) {
    // received a packet
    Serial.print("Received packet '");

    // read packet
    while (LoRa.available()) {
      Serial.print((char)LoRa.read());
    }

    // print RSSI of packet
    Serial.print("' with RSSI ");
    Serial.println(LoRa.packetRssi());
  }
}
Arduino-LoRa library LoRaReceiver monitor output

In the Visual Studio output window I could see messages getting transmitted with sent confirmations.

Loaded '/usr/lib/dotnet/shared/Microsoft.NETCore.App/5.0.4/Microsoft.Win32.Primitives.dll'. Skipped loading symbols. Module is optimized and the debugger option 'Just My Code' is enabled.
17:21:19- Length 28 "Hello LoRa from .NET Core! 1"
17:21:19-TX Done
17:21:29- Length 28 "Hello LoRa from .NET Core! 2"
17:21:29-TX Done
17:21:39- Length 28 "Hello LoRa from .NET Core! 3"
17:21:39-TX Done
17:21:49- Length 28 "Hello LoRa from .NET Core! 4"
17:21:49-TX Done
17:21:59- Length 28 "Hello LoRa from .NET Core! 5"
17:21:59-TX Done
17:22:09- Length 28 "Hello LoRa from .NET Core! 6"
17:22:09-TX Done
17:22:19- Length 28 "Hello LoRa from .NET Core! 7"
17:22:19-TX Done
17:22:29- Length 28 "Hello LoRa from .NET Core! 8"
17:22:29-TX Done
17:22:39- Length 28 "Hello LoRa from .NET Core! 9"
17:22:39-TX Done
17:22:49- Length 29 "Hello LoRa from .NET Core! 10"
17:22:49-TX Done
17:22:59- Length 29 "Hello LoRa from .NET Core! 11"
17:22:59-TX Done
17:23:09- Length 29 "Hello LoRa from .NET Core! 12"
17:23:09-TX Done
17:23:19- Length 29 "Hello LoRa from .NET Core! 13"
17:23:19-TX Done
17:23:29- Length 29 "Hello LoRa from .NET Core! 14"
17:23:29-TX Done
17:23:39- Length 29 "Hello LoRa from .NET Core! 15"
17:23:39-TX Done
17:23:49- Length 29 "Hello LoRa from .NET Core! 16"
17:23:49-TX Done
17:23:59- Length 29 "Hello LoRa from .NET Core! 17"
17:23:59-TX Done
17:24:09- Length 29 "Hello LoRa from .NET Core! 18"
17:24:09-TX Done

I modified the SX127X.NetCore SX127XLoRaDeviceClient adding a conditional compile options for each sample

static void Main(string[] args)	
{
	int messageCount = 1;

	sX127XDevice.Initialise(
			SX127XDevice.RegOpModeMode.ReceiveContinuous,
			915000000.0,
			powerAmplifier: SX127XDevice.PowerAmplifier.PABoost,
#if LORA_SENDER // From the Arduino point of view
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_RECEIVER // From the Arduino point of view, don't actually need this as already inverted
			invertIQTX: true
#endif

#if LORA_SET_SYNCWORD
			syncWord: 0xF3,
			invertIQTX: true,
			rxDoneignoreIfCrcMissing: false
#endif
#if LORA_SET_SPREAD
			spreadingFactor: SX127XDevice.RegModemConfig2SpreadingFactor._256ChipsPerSymbol,
			invertIQTX: true,
			rxDoneignoreIfCrcMissing: false
#endif
	);

#if DEBUG
	sX127XDevice.RegisterDump();
#endif

#if LORA_SENDER
	sX127XDevice.OnReceive += SX127XDevice_OnReceive;
	sX127XDevice.Receive();
#endif
#if LORA_RECEIVER
	sX127XDevice.OnTransmit += SX127XDevice_OnTransmit;
#endif

#if LORA_SENDER
	Thread.Sleep(-1);
#else
	Thread.Sleep(5000);
#endif

	while (true)
	{
		string messageText = "Hello LoRa from .NET Core! " + messageCount.ToString();

		byte[] messageBytes = UTF8Encoding.UTF8.GetBytes(messageText);

		Console.WriteLine($"{DateTime.Now:HH:mm:ss}- Length {messageBytes.Length} \"{messageText}\""); 

		sX127XDevice.Send(messageBytes);

		messageCount += 1;

		Thread.Sleep(10000);
	}
}

Summary

While testing the LoRaReceiver sample I found a problem with how my code managed the RegOpMode register LoRa status value. In previous versions of the code I used RegOpModeModeDefault to manage status when the ProcessTxDone(byte IrqFlags) method completed and Receive() was called.

I had assumed that that the device would always be set with SetMode(RegOpModeModeDefault) but RegOpModeModeDefault was always RegOpModeMode.Sleep.