.NET nanoFramework SX127X LoRa library Regression Testing

After the big refactor the SparkFun LoRa Gateway-1-Channel (ESP32) configuration wouldn’t compile because, the constant for the interrupt pin number didn’t exist (interruptPinNumber vs. dio0PinNumber). A “using” for the Nuget Package nanoFramework.Hardware.Esp32 was also missing.

While “smoke testing” the application I noticed that if I erased the flash, power cycled the device, then ran the application the first execution would fail because the Semtech SX127X could not be detected.

SX127XLoRaDeviceClient first execution startup failure
SX127XLoRaDeviceClient second execution startup success

As part of debugging I built the SparkFun LoRa Gateway-1-Channel (ESP32) version of ShieldSPI and it worked…

After printing the code out and reviewing it I noticed that the Configuration.SetPinFunction for the Serial Peripheral Interface(SPI) Master Out Slave In(MOSI), MOSI(Master In Slave Out) and Clock pins was after the opening of the SPI port.

static void Main(string[] args)
{
	byte SendCount = 0;
#if ESP32_WROOM_32_LORA_1_CHANNEL // No reset line for this device as it isn't connected on SX127X
	int chipSelectLine = Gpio.IO16;
	int dio0PinNumber = Gpio.IO26;
#endif
#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);
#endif
#if ST_STM32F769I_DISCOVERY
	// Arduino D10->PA11
	int chipSelectLine = PinNumber('A', 11);
	// Arduino D9->PH6
	int resetPinNumber = PinNumber('H', 6);
	// Arduino D2->PA4
	int dio0PinNumber = PinNumber('J', 1);
#endif
	Console.WriteLine("devMobile.IoT.SX127xLoRaDevice Range Tester starting");

	try
	{
#f 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 (_gpioController = new GpioController())
		using (SpiDevice spiDevice = new SpiDevice(settings))
		{

#if ESP32_WROOM_32_LORA_1_CHANNEL
			_sx127XDevice = new SX127XDevice(spiDevice, _gpioController, dio0Pin: dio0PinNumber);
#endif

#if NETDUINO3_WIFI || ST_STM32F769I_DISCOVERY
			_sx127XDevice = new SX127XDevice(spiDevice, _gpioController, dio0Pin: dio0PinNumber, resetPin:resetPinNumber);
#endif
...

		}
		catch (Exception ex)
		{
				Console.WriteLine(ex.Message);
		}
	}

I assume that the first execution after erasing the flash and power cycling the device, the SPI port pin assignments were not configured when the port was opened, then on the next execution the port was pre-configured.

The RangeTester application flashes on onboard Light Emitting Diode(LED) every time a valid message is received. But, on the ESP32 it turned on when the first message arrived and didn’t turn off. After discussion on the nanoFramework Discord this has been identified as an issue(May 2022).

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.

.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 RegPaConfig RegPaDac The never ending story

While trying different my NET nanoFramework Semtech SX127X library configurations so I could explore the interactions of RegOcp(Over current protection) + RegOcpTrim I noticed something odd about the power consumption so I revisited how the output power is calculated.

Netduino3 Wifi with USB power consumption measurement

The RegPaConfig register has three settings PaSelect(RFO & PA_BOOST), MaxPower(0..7), and OutputPower(0..15). When in RFO mode the pOut has a range of -4 to 15 and PA_BOOST mode has a range of 2 to 20.

RegPaConfig register configuration options
RegPaDac register configuration options

The SX127X also has a power amplifier attached to the PA_BOOST pin and a higher power amplifier which is controlled by the RegPaDac register.

// Set RegPAConfig & RegPaDac if powerAmplifier/OutputPower settings not defaults
if ((powerAmplifier != Configuration.RegPAConfigPASelect.Default) || (outputPower != Configuration.OutputPowerDefault))
{
	if (powerAmplifier == Configuration.RegPAConfigPASelect.PABoost)
	{
		byte regPAConfigValue = (byte)Configuration.RegPAConfigPASelect.PABoost;

		// Validate the minimum and maximum PABoost outputpower
		if ((outputPower < Configuration.OutputPowerPABoostMin) || (outputPower > Configuration.OutputPowerPABoostMax))
		{
			throw new ApplicationException($"PABoost {outputPower}dBm Min power {Configuration.OutputPowerPABoostMin} to Max power {Configuration.OutputPowerPABoostMax}");
		}

		if (outputPower <= Configuration.OutputPowerPABoostPaDacThreshhold)
		{
			// outputPower 0..15 so pOut is 2=17-(15-0)...17=17-(15-15)
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Default;
			regPAConfigValue |= (byte)(outputPower - 2);

			_registerManager.WriteByte((byte)Configuration.Registers.RegPAConfig, regPAConfigValue);
			_registerManager.WriteByte((byte)Configuration.Registers.RegPaDac, (byte)Configuration.RegPaDac.Normal);
		}
		else
		{
			// outputPower 0..15 so pOut is 5=20-(15-0)...20=20-(15-15) // See https://github.com/adafruit/RadioHead/blob/master/RH_RF95.cpp around line 411 could be 23dBm
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Default;
			regPAConfigValue |= (byte)(outputPower - 5);

			_registerManager.WriteByte((byte)Configuration.Registers.RegPAConfig, regPAConfigValue);
			_registerManager.WriteByte((byte)Configuration.Registers.RegPaDac, (byte)Configuration.RegPaDac.Boost);
		}
	}
	else
	{
		byte regPAConfigValue = (byte)Configuration.RegPAConfigPASelect.Rfo;

		// Validate the minimum and maximum RFO outputPower
		if ((outputPower < Configuration.OutputPowerRfoMin) || (outputPower > Configuration.OutputPowerRfoMax))
		{
			throw new ApplicationException($"RFO {outputPower}dBm Min power {Configuration.OutputPowerRfoMin} to Max power {Configuration.OutputPowerRfoMax}");
		}

		// Set MaxPower and Power calculate pOut = PMax-(15-outputPower), pMax=10.8 + 0.6*MaxPower 
		if (outputPower > Configuration.OutputPowerRfoThreshhold)
		{
			// pMax 15=10.8+0.6*7 with outputPower 0...15 so pOut is 15=pMax-(15-0)...0=pMax-(15-15) 
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Max;
			regPAConfigValue |= (byte)(outputPower + 0);
		}
		else
		{
			// pMax 10.8=10.8+0.6*0 with output power 0..15 so pOut is -4=10-(15-0)...10.8=10.8-(15-15)
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Min;
			regPAConfigValue |= (byte)(outputPower + 4);
		}

		_registerManager.WriteByte((byte)Configuration.Registers.RegPAConfig, regPAConfigValue);
		_registerManager.WriteByte((byte)Configuration.Registers.RegPaDac, (byte)Configuration.RegPaDac.Normal);
	}
}

// Set RegOcp if any of the settings not defaults
if ((ocpOn != Configuration.RegOcp.Default) || (ocpTrim != Configuration.RegOcpTrim.Default))
{
	byte regOcpValue = (byte)ocpTrim;

	regOcpValue |= (byte)ocpOn;

	_registerManager.WriteByte((byte)Configuration.Registers.RegOcp, regOcpValue);
}

After reviewing the code I realised that the the RegPaDac test around line 14 should <= rather than <

.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 RegPaConfig RegPaDac

While updating my .NET nanoFramework Semtech SX127X library I revisited (because I thought it might still be wrong) how the output power is calculated. I started with the overview of the transmitter architecture in in the datasheet…

SX127X Overview of transmission pipeline

The RegPaConfig register has three settings PaSelect(RFO & PA_BOOST), MaxPower(0..7), and OutputPower(0..15). When in RFO mode the pOut has a range of -4 to 15 and PA_BOOST mode has a range of 2 to 20. (The AdaFruit version of the RadioHead library has differences to the Semtech Lora-net/LoRaMac-Node libraries)

RegPaConfig & RegOcp register configuration options

The SX127X also has a power amplifier attached to the PA_BOOST pin and a higher power amplifier which is controlled by the RegPaDac register.

High power mode overview
RegPaDac register configuration options

The RegOcp (over current protection) has to be relaxed for the higher power modes

RegPaConfig register configuration options

I started with the Semtech Lora-net/LoRaMac-Node library which reads the RegPaConfig, RegPaSelect and RegPaDac registers then does any updates required.

void SX1276SetRfTxPower( int8_t power )
{
    uint8_t paConfig = 0;
    uint8_t paDac = 0;

    paConfig = SX1276Read( REG_PACONFIG );
    paDac = SX1276Read( REG_PADAC );

    paConfig = ( paConfig & RF_PACONFIG_PASELECT_MASK ) | SX1276GetPaSelect( power );

    if( ( paConfig & RF_PACONFIG_PASELECT_PABOOST ) == RF_PACONFIG_PASELECT_PABOOST )
    {
        if( power > 17 )
        {
            paDac = ( paDac & RF_PADAC_20DBM_MASK ) | RF_PADAC_20DBM_ON;
        }
        else
        {
            paDac = ( paDac & RF_PADAC_20DBM_MASK ) | RF_PADAC_20DBM_OFF;
        }
        if( ( paDac & RF_PADAC_20DBM_ON ) == RF_PADAC_20DBM_ON )
        {
            if( power < 5 )
            {
                power = 5;
            }
            if( power > 20 )
            {
                power = 20;
            }
            paConfig = ( paConfig & RF_PACONFIG_OUTPUTPOWER_MASK ) | ( uint8_t )( ( uint16_t )( power - 5 ) & 0x0F );
        }
        else
        {
            if( power < 2 )
            {
                power = 2;
            }
            if( power > 17 )
            {
                power = 17;
            }
            paConfig = ( paConfig & RF_PACONFIG_OUTPUTPOWER_MASK ) | ( uint8_t )( ( uint16_t )( power - 2 ) & 0x0F );
        }
    }
    else
    {
        if( power > 0 )
        {
            if( power > 15 )
            {
                power = 15;
            }
            paConfig = ( paConfig & RF_PACONFIG_MAX_POWER_MASK & RF_PACONFIG_OUTPUTPOWER_MASK ) | ( 7 << 4 ) | ( power );
        }
        else
        {
            if( power < -4 )
            {
                power = -4;
            }
            paConfig = ( paConfig & RF_PACONFIG_MAX_POWER_MASK & RF_PACONFIG_OUTPUTPOWER_MASK ) | ( 0 << 4 ) | ( power + 4 );
        }
    }
    SX1276Write( REG_PACONFIG, paConfig );
    SX1276Write( REG_PADAC, paDac );
}

I also reviewed the Arduino-LoRa Semtech library which only writes to the RegPaConfig, RegPaSelect and RegPaDac registers.

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));
  }
}

I updated the output power configuration code in the Initialise method of the SX127X library. After reviewing the SX127X datasheet I extended the way the pOut is calculated in RFO mode. The code uses two values for MaxPower(RegPAConfigMaxPower.Min & RegPAConfigMaxPower.Max) so that the full RTO output power range was available.

// Set RegPAConfig & RegPaDac if powerAmplifier/OutputPower settings not defaults
if ((powerAmplifier != Configuration.RegPAConfigPASelect.Default) || (outputPower != Configuration.OutputPowerDefault))
{
	if (powerAmplifier == Configuration.RegPAConfigPASelect.PABoost)
	{
		byte regPAConfigValue = (byte)Configuration.RegPAConfigPASelect.PABoost;

		// Validate the minimum and maximum PABoost outputpower
		if ((outputPower < Configuration.OutputPowerPABoostMin) || (outputPower > Configuration.OutputPowerPABoostMax))
		{
			throw new ApplicationException($"PABoost {outputPower}dBm Min power {Configuration.OutputPowerPABoostMin} to Max power {Configuration.OutputPowerPABoostMax}");
		}

		if (outputPower < Configuration.OutputPowerPABoostPaDacThreshhold)
		{
			// outputPower 0..15 so pOut is 2=17-(15-0)...17=17-(15-15)
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Default;
			regPAConfigValue |= (byte)(outputPower - 2);

			_registerManager.WriteByte((byte)Configuration.Registers.RegPAConfig, regPAConfigValue);
			_registerManager.WriteByte((byte)Configuration.Registers.RegPaDac, (byte)Configuration.RegPaDac.Normal);
		}
		else
		{
			// outputPower 0..15 so pOut is 5=20-(15-0)...20=20-(15-15) // See https://github.com/adafruit/RadioHead/blob/master/RH_RF95.cpp around line 411 could be 23dBm
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Default;
			regPAConfigValue |= (byte)(outputPower - 5);

			_registerManager.WriteByte((byte)Configuration.Registers.RegPAConfig, regPAConfigValue);
			_registerManager.WriteByte((byte)Configuration.Registers.RegPaDac, (byte)Configuration.RegPaDac.Boost);
		}
	}
	else
	{
		byte regPAConfigValue = (byte)Configuration.RegPAConfigPASelect.Rfo;

		// Validate the minimum and maximum RFO outputPower
		if ((outputPower < Configuration.OutputPowerRfoMin) || (outputPower > Configuration.OutputPowerRfoMax))
		{
			throw new ApplicationException($"RFO {outputPower}dBm Min power {Configuration.OutputPowerRfoMin} to Max power {Configuration.OutputPowerRfoMax}");
		}

		// Set MaxPower and Power calculate pOut = PMax-(15-outputPower), pMax=10.8 + 0.6*MaxPower 
		if (outputPower > Configuration.OutputPowerRfoThreshhold)
		{
			// pMax 15=10.8+0.6*7 with outputPower 0...15 so pOut is 15=pMax-(15-0)...0=pMax-(15-15) 
			regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Max;
			regPAConfigValue |= (byte)(outputPower + 0);
		}
		else
		{
			// pMax 10.8=10.8+0.6*0 with output power 0..15 so pOut is -4=10-(15-0)...10.8=10.8-(15-15)
			 regPAConfigValue |= (byte)Configuration.RegPAConfigMaxPower.Min;
			regPAConfigValue |= (byte)(outputPower + 4);
		}

		_registerManager.WriteByte((byte)Configuration.Registers.RegPAConfig, regPAConfigValue);
		_registerManager.WriteByte((byte)Configuration.Registers.RegPaDac, (byte)Configuration.RegPaDac.Normal);
	}
}

The formula for pOut and pMax in RegPaConfig documentation is included in the source code so I could manually calculate (including edge cases) the values as part of my testing. I ran the SX127XLoRaDeviceClient and inspected the PaConfig & RegPaDac in the Visual Studio 2022 debugger.

PABoost
Output power = 1
Output power = 21
Exception

Output power = 2
PaConfig = 192
RegPaDac = normal
	1100 0000

Output power = 16
PaConfig = 206
RegPaDac = normal
	1100 1110

Output power = 17
PaConfig = 204
RegPacDac = Normal
	1100 1100

Output power = 18
PaConfig = 205
RegPacDac = Boost
	1100 1101

Output power = 19
PaConfig = 206
RegPacDac = Boost
	1100 1110

Output power = 20
PaConfig = 207
RegPacDac = Boost
	1100 1111

RFO
Output power = -5
Output power = 16
Exception

Output power = -4
PAConfig = 0
	0000 0000

Output power = -1
PAConfig = 3
	0000 0011

Output power = 0
PAConfig = 4
	0000 0100

Output power = 1
PAConfig = 113
	0111 0001

OutputPower = 14
PAConfig = 126
	0111 1110

OutputPower = 15
PAConfig = 127
	0111 1111

I need to borrow some test gear to check my implementation

TTI V3 Connector Azure IoT Central Device Provisioning Service(DPS) support

The TTI Connector supports the Azure IoT Hub Device Provisioning Service(DPS) which is required (it is possible to provision individual devices but this intended for small deployments or testing) for Azure IoT Central applications. The TTI Connector implementation also supports Azure IoT Central Digital Twin Definition Language (DTDL V2) for “automagic” device provisioning.

The first step was to configure and Azure IoT Central enrollment group (ensure “Automatically connect devices in this group” is on for “zero touch” provisioning) and copy the IDScope and Group Enrollment key to the TTI Connector configuration

RAK3172 Enrollment Group creation
Azure IoT Hub Device Provisioning Service configuration

I then created an Azure IoT Central template for my RAK3172 breakout board based.Net Core powered test device.

{
    "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7;1",
    "@type": "Interface",
    "contents": [
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:temperature_0;1",
            "@type": [
                "Telemetry",
                "Temperature"
            ],
            "displayName": {
                "en": "Temperature"
            },
            "name": "temperature_0",
            "schema": "double",
            "unit": "degreeCelsius"
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:relative_humidity_0;1",
            "@type": [
                "Telemetry",
                "RelativeHumidity"
            ],
            "displayName": {
                "en": "Humidity"
            },
            "name": "relative_humidity_0",
            "schema": "double",
            "unit": "percent"
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:value_0;1",
            "@type": "Command",
            "displayName": {
                "en": "Temperature OOB alert minimum"
            },
            "name": "value_0",
            "request": {
                "@type": "CommandPayload",
                "displayName": {
                    "en": "Minimum"
                },
                "name": "value_0",
                "schema": "double"
            },
            "durable": true
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:value_1;1",
            "@type": "Command",
            "displayName": {
                "en": "Temperature OOB alert maximum"
            },
            "name": "value_1",
            "request": {
                "@type": "CommandPayload",
                "displayName": {
                    "en": "Maximum"
                },
                "name": "value_1",
                "schema": "double"
            },
            "durable": true
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:TemperatureOOBAlertMinimumAndMaximum;1",
            "@type": "Command",
            "displayName": {
                "en": "Temperature OOB alert minimum and maximum"
            },
            "name": "TemperatureOOBAlertMinimumAndMaximum",
            "request": {
                "@type": "CommandPayload",
                "displayName": {
                    "en": "Alert Temperature"
                },
                "name": "AlertTemperature",
                "schema": {
                    "@type": "Object",
                    "displayName": {
                        "en": "Object"
                    },
                    "fields": [
                        {
                            "displayName": {
                                "en": "minimum"
                            },
                            "name": "value_0",
                            "schema": "double"
                        },
                        {
                            "displayName": {
                                "en": "maximum"
                            },
                            "name": "value_1",
                            "schema": "double"
                        }
                    ]
                }
            },
            "durable": true
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:value_2;1",
            "@type": "Command",
            "displayName": {
                "en": "Fan"
            },
            "name": "value_2",
            "request": {
                "@type": "CommandPayload",
                "displayName": {
                    "en": "On"
                },
                "name": "value_3",
                "schema": {
                    "@type": "Enum",
                    "displayName": {
                        "en": "Enum"
                    },
                    "enumValues": [
                        {
                            "displayName": {
                                "en": "On"
                            },
                            "enumValue": 1,
                            "name": "On"
                        },
                        {
                            "displayName": {
                                "en": "Off"
                            },
                            "enumValue": 0,
                            "name": "Off"
                        }
                    ],
                    "valueSchema": "integer"
                }
            },
            "durable": true
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:LightsGoOn;1",
            "@type": "Command",
            "displayName": {
                "en": "LightsGoOn"
            },
            "name": "LightsGoOn",
            "durable": true
        },
        {
            "@id": "dtmi:ttnv3connectorclient:RASK3172Breakout1c7:LightsGoOff;1",
            "@type": "Command",
            "displayName": {
                "en": "LightsGoOff"
            },
            "name": "LightsGoOff",
            "durable": true
        }
    ],
    "displayName": {
        "en": "RASK3172 Breakout"
    },
    "@context": [
        "dtmi:iotcentral:context;2",
        "dtmi:dtdl:context;2"
    ]
}

The Device Template @Id can also be set for a TTI application using an optional dtdlmodelid which is specified the the TTI Connector configuration.

RAK7258 Local server and Message Queuing Telemetry Transport(MQTT)

This post was originally about getting the built in Network Server of my RAKWireless RAK7258 WisGate Edge Lite to connect to an Azure IoT Hub or Azure IoT Central. The RAK7258 had been connected to The Things Industries(TTI) network so I updated the firmware and checked the “mode” in the LoRaWAN Network settings.

RAK 7258 LoRaWAN Network settings

Azure IoT Hub is not a fully featured MQTT broker so I initially looked at running Eclipse Mosquitto or HiveMQ locally but this seemed like a lot of effort for a Proof of Concept(PoC).

RAK 7258 Network Server Global Integration settings

I have used MQTTNet in a few other projects (The Things Network(TTN) V3 Azure IoT Connector, The Things Network V2 MQTT SQL Connector, Windows 10 IoT Core MQTT Field gateway etc.) and there was a sample application which showed ho to build a simple server so that became my preferred approach.

I then started exploring how applications and devices are provisioned in the RAK Network Server.

RAK 7258 Network Server applications list

The network server software has “unified” and “separate” “Device authentication mode”s and will “auto Add LoRa Device”s if enabled.

RAK 7258 Network Server Separate Application basic setup
RAK 7258 Network Server Separate Application device basic setup
RAK 7258 Network Server Unified Application device basic setup

Applications also have configurable payload formats(raw & CayenneLPP) and integrations (uplink messages plus join, ack, and device notifications etc.)

RAK7258 live device data display

In the sample server I could see how ValidatingConnectionAsync was used to check the clientID, username and password when a device connected. I just wanted to display messages and payloads without having to use an MQTT client and it looked like InterceptingPublishAsync was a possible solution.

But the search results were a bit sparse…

InterceptingPublishAsync + MQTTNet search results

After some reading the MQTTNet documentation and some experimentation I could display the message payload (same as in the live device data display) in a “nasty” console application.

namespace devMobile.IoT.RAKWisgate.ServerBasic
{
   using System;
	using System.Threading.Tasks;

   using MQTTnet;
   using MQTTnet.Protocol;
   using MQTTnet.Server;

   public static class Program
   {
      static async Task Main(string[] args)
      {
         var mqttFactory = new MqttFactory();

         var mqttServerOptions = new MqttServerOptionsBuilder()
             .WithDefaultEndpoint()
             .Build();

         using (var mqttServer = mqttFactory.CreateMqttServer(mqttServerOptions))
         {
            mqttServer.InterceptingPublishAsync += e =>
            {
               Console.WriteLine($"Client:{e.ClientId} Topic:{e.ApplicationMessage.Topic} {e.ApplicationMessage.ConvertPayloadToString()}");

               return Task.CompletedTask;
            };

            mqttServer.ValidatingConnectionAsync += e =>
            {
               if (e.ClientId != "RAK Wisgate7258")
               {
                  e.ReasonCode = MqttConnectReasonCode.ClientIdentifierNotValid;
               }

               if (e.Username != "ValidUser")
               {
                  e.ReasonCode = MqttConnectReasonCode.BadUserNameOrPassword;
               }

               if (e.Password != "TopSecretPassword")
               {
                  e.ReasonCode = MqttConnectReasonCode.BadUserNameOrPassword;
               }

               return Task.CompletedTask;
            };

            await mqttServer.StartAsync();

            Console.WriteLine("Press Enter to exit.");
            Console.ReadLine();

            await mqttServer.StopAsync();
         }
      }
   }
}
MQTTNet based console application displaying device payloads

The process of provisioning Applications and Devices is quite different (The use of the AppEUI/JoinEUI is odd) to The Things Network(TTN) and other platforms I have used so I will explore this some more in future post(s).

TTI V3 Connector Azure IoT Central Cloud to Device(C2D)

Handling Cloud to Device(D2C) Azure IoT Central messages (The Things Industries(TTI) downlink) is a bit more complex than Device To Cloud(D2C) messaging. The format of the command messages is reasonably well documented and I have already explored in detail with basic telemetry, basic commands, request commands, and The Things Industries Friendly commands and Digital Twin Definition Language(DTDL) support.

public class IoTHubApplicationSetting
{
	public string DtdlModelId { get; set; }
}

public class IoTHubSettings
{
	public string IoTHubConnectionString { get; set; } = string.Empty;

	public Dictionary<string, IoTHubApplicationSetting> Applications { get; set; }
}


public class DeviceProvisiongServiceApplicationSetting
{
	public string DtdlModelId { get; set; } = string.Empty;

	public string GroupEnrollmentKey { get; set; } = string.Empty;
}

public class DeviceProvisiongServiceSettings
{
	public string IdScope { get; set; } = string.Empty;

	public Dictionary<string, DeviceProvisiongServiceApplicationSetting> Applications { get; set; }
}


public class IoTCentralMethodSetting
{
	public byte Port { get; set; } = 0;

	public bool Confirmed { get; set; } = false;

	public Models.DownlinkPriority Priority { get; set; } = Models.DownlinkPriority.Normal;

	public Models.DownlinkQueue Queue { get; set; } = Models.DownlinkQueue.Replace;
}

public class IoTCentralSetting
{
	public Dictionary<string, IoTCentralMethodSetting> Methods { get; set; }
}

public class AzureIoTSettings
{
	public IoTHubSettings IoTHub { get; set; }

	public DeviceProvisiongServiceSettings DeviceProvisioningService { get; set; }

	public IoTCentralSetting IoTCentral { get; set; }
}

Azure IoT Central appears to have no support for setting message properties so the LoRaWAN port, confirmed flag, priority, and queuing so these a retrieved from configuration.

Azure Function Configuration
Models.Downlink downlink;
Models.DownlinkQueue queue;

string payloadText = Encoding.UTF8.GetString(message.GetBytes()).Trim();

if (message.Properties.ContainsKey("method-name"))
{
	#region Azure IoT Central C2D message processing
	string methodName = message.Properties["method-name"];

	if (string.IsNullOrWhiteSpace(methodName))
	{
		_logger.LogWarning("Downlink-DeviceID:{0} MessagedID:{1} LockToken:{2} method-name property empty", receiveMessageHandlerContext.DeviceId, message.MessageId, message.LockToken);

		await deviceClient.RejectAsync(message);
		return;
	}

	// Look up the method settings to get confirmed, port, priority, and queue
	if ((_azureIoTSettings == null) || (_azureIoTSettings.IoTCentral == null) || !_azureIoTSettings.IoTCentral.Methods.TryGetValue(methodName, out IoTCentralMethodSetting methodSetting))
	{
		_logger.LogWarning("Downlink-DeviceID:{0} MessagedID:{1} LockToken:{2} method-name:{3} has no settings", receiveMessageHandlerContext.DeviceId, message.MessageId, message.LockToken, methodName);
							
		await deviceClient.RejectAsync(message);
		return;
	}

	downlink = new Models.Downlink()
	{
		Confirmed = methodSetting.Confirmed,
		Priority = methodSetting.Priority,
		Port = methodSetting.Port,
		CorrelationIds = AzureLockToken.Add(message.LockToken),
	};

	queue = methodSetting.Queue;

	// Check to see if special case for Azure IoT central command with no request payload
	if (payloadText.IsPayloadEmpty())
	{
		downlink.PayloadRaw = "";
	}

	if (!payloadText.IsPayloadEmpty())
	{
		if (payloadText.IsPayloadValidJson())
		{
			downlink.PayloadDecoded = JToken.Parse(payloadText);
			}
		else
		{
			downlink.PayloadDecoded = new JObject(new JProperty(methodName, payloadText));
		}
	}

	logger.LogInformation("Downlink-IoT Central DeviceID:{0} Method:{1} MessageID:{2} LockToken:{3} Port:{4} Confirmed:{5} Priority:{6} Queue:{7}",
		receiveMessageHandlerContext.DeviceId,
		methodName,
		message.MessageId,
		message.LockToken,
		downlink.Port,
		downlink.Confirmed,
		downlink.Priority,
		queue);
	#endregion
}

The reboot command payload only contains an “@” so the TTTI payload will be empty, the minimum and maximum command payloads will contain only a numeric value which is added to the decoded payload with the method name, the combined minimum and maximum command has a JSON payload which is “grafted” into the decoded payload.

Azure IoT Central Device Template

Azure Device Provisioning Service(DPS) when transient isn’t

After some updates to my Device Provisioning Service(DPS) code the RegisterAsync method was exploding with an odd exception.

TTI Webhook Integration running in desktop emulator

In the Visual Studio 2019 Debugger the exception text was “IsTransient = true” so I went and made a coffee and tried again.

Visual Studio 2019 Quickwatch displaying short from error message

The call was still failing so I dumped out the exception text so I had some key words to search for

Microsoft.Azure.Devices.Provisioning.Client.ProvisioningTransportException: AMQP transport exception
 ---> System.UnauthorizedAccessException: Sys
   at Microsoft.Azure.Amqp.ExceptionDispatcher.Throw(Exception exception)
   at Microsoft.Azure.Amqp.AsyncResult.End[TAsyncResult](IAsyncResult result)
   at Microsoft.Azure.Amqp.AmqpObject.OpenAsyncResult.End(IAsyncResult result)
   at Microsoft.Azure.Amqp.AmqpObject.EndOpen(IAsyncResult result)
   at Microsoft.Azure.Amqp.Transport.AmqpTransportInitiator.HandleTransportOpened(IAsyncResult result)
   at Microsoft.Azure.Amqp.Transport.AmqpTransportInitiator.OnTransportOpenCompete(IAsyncResult result)
--- End of stack trace from previous location ---
   at Microsoft.Azure.Devices.Provisioning.Client.Transport.AmqpClientConnection.OpenAsync(TimeSpan timeout, Boolean useWebSocket, X509Certificate2 clientCert, IWebProxy proxy, RemoteCertificateValidationCallback remoteCerificateValidationCallback)
   at Microsoft.Azure.Devices.Provisioning.Client.Transport.ProvisioningTransportHandlerAmqp.RegisterAsync(ProvisioningTransportRegisterMessage message, TimeSpan timeout, CancellationToken cancellationToken)
   --- End of inner exception stack trace ---
   at Microsoft.Azure.Devices.Provisioning.Client.Transport.ProvisioningTransportHandlerAmqp.RegisterAsync(ProvisioningTransportRegisterMessage message, TimeSpan timeout, CancellationToken cancellationToken)
   at Microsoft.Azure.Devices.Provisioning.Client.Transport.ProvisioningTransportHandlerAmqp.RegisterAsync(ProvisioningTransportRegisterMessage message, CancellationToken cancellationToken)
   at devMobile.IoT.TheThingsIndustries.AzureIoTHub.Integration.Uplink(HttpRequestData req, FunctionContext executionContext) in C:\Users\BrynLewis\source\repos\TTIV3AzureIoTConnector\TTIV3WebHookAzureIoTHubIntegration\TTIUplinkHandler.cs:line 245

I tried a lot of keywords and went and looked at the source code on github

One of the many keyword searches

Another of the many keyword searches

I then tried another program which did used the Device provisioning Service and it worked first time so it was something wrong with the code.

using (var securityProvider = new SecurityProviderSymmetricKey(deviceId, deviceKey, null))
{
	using (var transport = new ProvisioningTransportHandlerAmqp(TransportFallbackType.TcpOnly))
	{
		DeviceRegistrationResult result;

		ProvisioningDeviceClient provClient = ProvisioningDeviceClient.Create(
			Constants.AzureDpsGlobalDeviceEndpoint,
			 dpsApplicationSetting.GroupEnrollmentKey, <<= Should be _azureIoTSettings.DeviceProvisioningService.IdScope,
			securityProvider,
			transport);

		try
		{
				result = await provClient.RegisterAsync();
		}
		catch (ProvisioningTransportException ex)
		{
			logger.LogInformation(ex, "Uplink-DeviceID:{0} RegisterAsync failed IDScope and/or GroupEnrollmentKey invalid", deviceId);

			return req.CreateResponse(HttpStatusCode.Unauthorized);
		}

		if (result.Status != ProvisioningRegistrationStatusType.Assigned)
		{
			_logger.LogError("Uplink-DeviceID:{0} Status:{1} RegisterAsync failed ", deviceId, result.Status);

			return req.CreateResponse(HttpStatusCode.FailedDependency);
		}

		IAuthenticationMethod authentication = new DeviceAuthenticationWithRegistrySymmetricKey(result.DeviceId, (securityProvider as SecurityProviderSymmetricKey).GetPrimaryKey());

		deviceClient = DeviceClient.Create(result.AssignedHub, authentication, TransportSettings);

		await deviceClient.OpenAsync();

		logger.LogInformation("Uplink-DeviceID:{0} Azure IoT Hub connected (Device Provisioning Service)", deviceId);
	}
}

I then carefully inspected my source code and worked back through the file history and realised I had accidentally replaced the IDScope with the GroupEnrollment setting so it was never going to work i.e. IsTransient != true. So, for the one or two other people who get this error message check your IDScope and GroupEnrollment key make sure they are the right variables and that values they contain are correct.

TTI V3 Connector Azure IoT Central Device to Cloud(D2C)

This post is largely about adapting the output of The Things Industries(TTI) MyDevices Cayenne Low Power Protocol(LPP) payload formatter so that it can be injested by Azure IoT Central. The Azure function for processing TTI Uplink messages first deserialises the JSON payload discarding any LoRaWAN control messages and messages with empty payloads.

[Function("Uplink")]
public async Task<HttpResponseData> Uplink([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req, FunctionContext executionContext)
{
	Models.PayloadUplink payload;
	var logger = executionContext.GetLogger("Queued");

	// Wrap all the processing in a try\catch so if anything blows up we have logged it.
	try
	{
		string payloadText = await req.ReadAsStringAsync();

		try
		{
			payload = JsonConvert.DeserializeObject<Models.PayloadUplink>(payloadText);
		}
		catch(JsonException ex)
		{
			logger.LogInformation(ex, "Uplink-Payload Invalid JSON:{0}", payloadText);

			return req.CreateResponse(HttpStatusCode.BadRequest);
		}

		if (payload == null)
		{
			logger.LogInformation("Uplink-Payload invalid:{0}", payloadText);

			return req.CreateResponse(HttpStatusCode.BadRequest);
		}

		string applicationId = payload.EndDeviceIds.ApplicationIds.ApplicationId;
		string deviceId = payload.EndDeviceIds.DeviceId;

		if ((payload.UplinkMessage.Port == null) || (!payload.UplinkMessage.Port.HasValue) || (payload.UplinkMessage.Port.Value == 0))
		{
			logger.LogInformation("Uplink-ApplicationID:{0} DeviceID:{1} Payload Raw:{2} Control message", applicationId, deviceId, payload.UplinkMessage.PayloadRaw);

			return req.CreateResponse(HttpStatusCode.UnprocessableEntity);
		}

		int port = payload.UplinkMessage.Port.Value;

		logger.LogInformation("Uplink-ApplicationID:{0} DeviceID:{1} Port:{2} Payload Raw:{3}", applicationId, deviceId, port, payload.UplinkMessage.PayloadRaw);

		if (!_DeviceClients.TryGetValue(deviceId, out DeviceClient deviceClient))
		{
...		
		}

		JObject telemetryEvent = new JObject
		{
			{ "ApplicationID", applicationId },
			{ "DeviceID", deviceId },
			{ "Port", port },
			{ "Simulated", payload.Simulated },
			{ "ReceivedAtUtc", payload.UplinkMessage.ReceivedAtUtc.ToString("s", CultureInfo.InvariantCulture) },
			{ "PayloadRaw", payload.UplinkMessage.PayloadRaw }
		};

		// If the payload has been decoded by payload formatter, put it in the message body.
		if (payload.UplinkMessage.PayloadDecoded != null)
		{
			EnumerateChildren(telemetryEvent, payload.UplinkMessage.PayloadDecoded);
		}

		// Send the message to Azure IoT Hub
		using (Message ioTHubmessage = new Message(Encoding.ASCII.GetBytes(JsonConvert.SerializeObject(telemetryEvent))))
		{
			// Ensure the displayed time is the acquired time rather than the uploaded time. 
			ioTHubmessage.Properties.Add("iothub-creation-time-utc", payload.UplinkMessage.ReceivedAtUtc.ToString("s", CultureInfo.InvariantCulture));
			ioTHubmessage.Properties.Add("ApplicationId", applicationId);
			ioTHubmessage.Properties.Add("DeviceEUI", payload.EndDeviceIds.DeviceEui);
			ioTHubmessage.Properties.Add("DeviceId", deviceId);
			ioTHubmessage.Properties.Add("port", port.ToString());
			ioTHubmessage.Properties.Add("Simulated", payload.Simulated.ToString());

			await deviceClient.SendEventAsync(ioTHubmessage);

			logger.LogInformation("Uplink-DeviceID:{0} SendEventAsync success", payload.EndDeviceIds.DeviceId);
		}
	}
	catch (Exception ex)
	{
		logger.LogError(ex, "Uplink-Message processing failed");

		return req.CreateResponse(HttpStatusCode.InternalServerError);
	}

	return req.CreateResponse(HttpStatusCode.OK);
}

If the message has been successfully decoded by a payload formatter the PayloadDecoded contents will be “grafted” into the Azure IoT Central Telemetry message.

TTI JSON GPS position format

The Azure IoT Central Location Telemetry messages have a slightly different format to the output of the TTI LPP Payload formatter so the payload has to be “post processed”.

private void EnumerateChildren(JObject jobject, JToken token)
{
	if (token is JProperty property)
	{
		if (token.First is JValue)
		{
			// Temporary dirty hack for Azure IoT Central compatibility
			if (token.Parent is JObject possibleGpsProperty)
			{
				// TODO Need to check if similar approach necessary accelerometer and gyro LPP payloads
				if (possibleGpsProperty.Path.StartsWith("GPS_", StringComparison.OrdinalIgnoreCase))
				{
					if (string.Compare(property.Name, "Latitude", true) == 0)
					{
						jobject.Add("lat", property.Value);
					}
					if (string.Compare(property.Name, "Longitude", true) == 0)
					{
						jobject.Add("lon", property.Value);
					}
					if (string.Compare(property.Name, "Altitude", true) == 0)
					{
						jobject.Add("alt", property.Value);
					}
				}
			}
			jobject.Add(property.Name, property.Value);
		}
		else
		{
			JObject parentObject = new JObject();
			foreach (JToken token2 in token.Children())
			{
				EnumerateChildren(parentObject, token2);
				jobject.Add(property.Name, parentObject);
			}
		}
	}
	else
	{
		foreach (JToken token2 in token.Children())
		{
			EnumerateChildren(jobject, token2);
		}
	}
}

I may have to extend this method for other LPP datatypes

“Post processed” TTI JSON GPS Position data suitable for Azure IoT Central

To test the telemetry message JSON I created an Azure IoT Central Device Template which had a “capability type” of Location.

Azure IoT Central Device Template with Location Capability

For initial development and testing I ran the function application in the desktop emulator and simulated TTI webhook calls with Telerik Fiddler and modified sample payloads. After some issues with iothub-creation-time-utc decoded telemetry messages were displayed in the Device Raw Data tab

Azure IoT Central Device Raw Data tab with successfully decoded GPS location payloads
Azure IoT Central map displaying with device location highlighted

This post uses a lot of the work done for my The Things Network V2 integration. I also found the first time a device connected to the Azure IoT Central Azure IoT hub (using the Azure IoT Central Device Provisioning Service(DPS) to get the connection string) there was always an exception.

Microsoft.Azure.Devices.Client.Exceptions.IotHubException: error(condition:com.microsoft:connection-closed-on-new-connection,description:Backend initiated disconnection.

TTI V3 Gateway Azure IoT Central first call exception

This exception occurs when the SetMethodDefaultHandlerAsync method is called which is a bit odd. This exception does not occur when I use Device Provisioning Service(DPS) and Azure IoT Hub instances I have provisioned.