nanoFramework Seeed LoRa-E5 on Github

The source code of my nanoFramework C# Seeed LoRa-E5 library is live on GitHub. My initial test rig was based on an STM32F691DISCOVERY board which has an Arduino Uno R3 format socket for a Grove Base Shield V2.0. I then connected it to my LoRa-E5 Development Kit with a Grove – Universal 4 Pin 20cm Unbuckled Cable(TX/RX reversed)

STM32F769I test rig with Seeedstudio Grove Base shield V2 and LoRa-E5 Development Kit

So far the demo application has been running for a couple of weeks

The thread '<No Name>' (0x2) has exited with code 0 (0x0).
devMobile.IoT.SeeedE5LoRaWANDeviceClient starting
12:00:01 Join start Timeout:25 Seconds
12:00:07 Join finish
12:00:07 Send Timeout:10 Seconds payload BCD:010203040506070809
12:00:13 Sleep
12:05:13 Wakeup
12:05:13 Send Timeout:10 Seconds payload BCD:010203040506070809
12:05:20 Sleep
12:10:20 Wakeup
12:10:20 Send Timeout:10 Seconds payload BCD:010203040506070809
12:10:27 Sleep
12:15:27 Wakeup
12:15:27 Send Timeout:10 Seconds payload BCD:010203040506070809
12:15:34 Sleep
...
11:52:40 Wakeup
11:52:40 Send Timeout:10 Seconds payload BCD:010203040506070809
11:52:45 Sleep
11:57:45 Wakeup
11:57:45 Send Timeout:10 Seconds payload BCD:010203040506070809
11:57:52 Sleep
12:02:52 Wakeup
12:02:52 Send Timeout:10 Seconds payload BCD:010203040506070809
12:02:59 Sleep
12:07:59 Wakeup
12:07:59 Send Timeout:10 Seconds payload BCD:010203040506070809
12:08:07 Sleep
12:13:07 Wakeup
12:13:07 Send Timeout:10 Seconds payload BCD:010203040506070809
12:13:14 Sleep

I have tested the Over The Air Activation(OTAA) code and will work on testing the other functionality over the coming week,

public static void Main()
{
   Result result;

   Debug.WriteLine("devMobile.IoT.SeeedE5LoRaWANDeviceClient starting");

   try
   {
      using (SeeedE5LoRaWANDevice device = new SeeedE5LoRaWANDevice())
      {
         result = device.Initialise(SerialPortId, 9600, UartParity.None, 8, UartStopBitCount.One);
         if (result != Result.Success)
         {
            Debug.WriteLine($"Initialise failed {result}");
            return;
         }

#if CONFIRMED
         device.OnMessageConfirmation += OnMessageConfirmationHandler;
#endif
         device.OnReceiveMessage += OnReceiveMessageHandler;

#if RESET
         Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Reset");
         result = device.Reset();
         if (result != Result.Success)
         {
            Debug.WriteLine($"Reset failed {result}");
            return;
          }
#endif

         Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Region {Region}");
         result = device.Region(Region);
         if (result != Result.Success)
         {
            Debug.WriteLine($"Region failed {result}");
            return;
         }

         Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} ADR On");
         result = device.AdrOn();
         if (result != Result.Success)
         {
            Debug.WriteLine($"ADR on failed {result}");
            return;
         }

               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Port");
               result = device.Port(MessagePort);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"Port on failed {result}");
                  return;
               }

#if OTAA
               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} OTAA");
               result = device.OtaaInitialise(Config.AppEui, Config.AppKey);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"OTAA Initialise failed {result}");
                  return;
               }
#endif

#if ABP
               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} ABP");
               result = device.AbpInitialise(DevAddress, NwksKey, AppsKey);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"ABP Initialise failed {result}");
                  return;
               }
#endif

               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join start Timeout:{JoinTimeOut.TotalSeconds} Seconds");
               result = device.Join(true, JoinTimeOut);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"Join failed {result}");
                  return;
               }
               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join finish");

               while (true)
               {
#if PAYLOAD_BCD
                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Send Timeout:{SendTimeout.TotalSeconds} Seconds payload BCD:{PayloadBcd}");
#if CONFIRMED
                  result = device.Send(PayloadBcd, true, SendTimeout);
#else
                  result = device.Send(PayloadBcd, false, SendTimeout);
#endif
#endif

#if PAYLOAD_BYTES
                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Send Timeout:{SendTimeout.TotalSeconds} Seconds payload Bytes:{BitConverter.ToString(PayloadBytes)}");
#if CONFIRMED
                  result = device.Send(PayloadBytes, true, SendTimeout);
#else
                  result = device.Send(PayloadBytes, false, SendTimeout);
#endif
#endif
                  if (result != Result.Success)
                  {
                     Debug.WriteLine($"Send failed {result}");
                  }

                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Sleep");
                  result = device.Sleep();
                  if (result != Result.Success)
                  {
                     Debug.WriteLine($"Sleep failed {result}");
                     return;
                  }

                  Thread.Sleep(300000);

                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Wakeup");
                  result = device.Wakeup();
                  if (result != Result.Success)
                  {
                     Debug.WriteLine($"Wakeup failed {result}");
                     return;
                  }
               }
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine(ex.Message);
         }
      }

The Region, ADR and OtaaInitialise methods only need to be called when the device is first powered up and after a reset.

The library works but should be treated as late beta.

nanoFramework Seeed LoRa-E5 LoRaWAN library Part2

Nasty OTAA connect

After getting basic connectivity for my Seeedstudio LoRa-E5 Development Kit and STM32F691DISCOVERY test rig working I wanted to see if I could get the device connected to The Things Industries(TTI).

My Over the Air Activation (OTAA) implementation is very “nasty” as it is assumed that there are no timeouts or failures and it only sends one BCD message “01020304”.

   public class Program
   {
      private const string SerialPortId = "COM6";

      private const string AppKey = "................................";
      private const string AppEui = "................";

      private const byte MessagePort = 15;

      //private const string Payload = "48656c6c6f204c6f526157414e"; // Hello LoRaWAN
      private const string Payload = "01020304"; // AQIDBA==
      //private const string Payload = "04030201"; // BAMCAQ==

   public static void Main()
   {
      SerialDevice serialDevice;
      uint bytesWritten;
      uint txByteCount;
      uint bytesRead;

      Debug.WriteLine("devMobile.IoT.SeeedLoRaE5.NetworkJoinOTAA starting");

      Debug.WriteLine($"Ports available: {Windows.Devices.SerialCommunication.SerialDevice.GetDeviceSelector()}");

      try
      {
         serialDevice = SerialDevice.FromId(SerialPortId);

         // set parameters
         serialDevice.BaudRate = 9600;
         serialDevice.Parity = SerialParity.None;
         serialDevice.StopBits = SerialStopBitCount.One;
         serialDevice.Handshake = SerialHandshake.None;
         serialDevice.DataBits = 8;

         serialDevice.ReadTimeout = new TimeSpan(0, 0, 5);
         serialDevice.WriteTimeout = new TimeSpan(0, 0, 4);

         DataWriter outputDataWriter = new DataWriter(serialDevice.OutputStream);
         DataReader inputDataReader = new DataReader(serialDevice.InputStream);

         // set a watch char to be notified when it's available in the input stream
         serialDevice.WatchChar = '\n';

         // clear out the RX buffer
         bytesRead = inputDataReader.Load(128);
         while (bytesRead > 0)
         {
            string response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");

            bytesRead = inputDataReader.Load(128);
         }

         // Set the Region to AS923
         bytesWritten = outputDataWriter.WriteString("AT+DR=AS923\r\n");
         Debug.WriteLine($"TX: region {outputDataWriter.UnstoredBufferLength} bytes to output stream.");
         txByteCount = outputDataWriter.Store();
         Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

         // Read the response
         bytesRead = inputDataReader.Load(128);
         if (bytesRead > 0)
         {
            String response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");
         }

         // Set the Join mode
         bytesWritten = outputDataWriter.WriteString("AT+MODE=LWOTAA\r\n");
         Debug.WriteLine($"TX: mode {outputDataWriter.UnstoredBufferLength} bytes to output stream.");
         txByteCount = outputDataWriter.Store();
         Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

         // Read the response
         bytesRead = inputDataReader.Load(128);
         if (bytesRead > 0)
         {
            string response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");
         }

         // Set the appEUI
         bytesWritten = outputDataWriter.WriteString($"AT+ID=AppEui,\"{AppEui}\"\r\n");
         Debug.WriteLine($"TX: AppEui {outputDataWriter.UnstoredBufferLength} bytes to output stream.");
         txByteCount = outputDataWriter.Store();
         Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

         // Read the response
         bytesRead = inputDataReader.Load(128);
         if (bytesRead > 0)
         {
            String response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");
         }

         // Set the appKey
         bytesWritten = outputDataWriter.WriteString($"AT+KEY=APPKEY,{AppKey}\r\n");
         Debug.WriteLine($"TX: AppKey {outputDataWriter.UnstoredBufferLength} bytes to output stream.");
         txByteCount = outputDataWriter.Store();
         Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

         // Read the response
         bytesRead = inputDataReader.Load(128);
         if (bytesRead > 0)
         {
            String response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");
         }

         // Set the port number
         bytesWritten = outputDataWriter.WriteString($"AT+PORT={MessagePort}\r\n");
         Debug.WriteLine($"TX: port {outputDataWriter.UnstoredBufferLength} bytes to output stream.");
         txByteCount = outputDataWriter.Store();
         Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

         // Read the response
         bytesRead = inputDataReader.Load(128);
         if (bytesRead > 0)
         {
            String response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");
         }

         // Join the network
         bytesWritten = outputDataWriter.WriteString("AT+JOIN\r\n");
         Debug.WriteLine($"TX: join {outputDataWriter.UnstoredBufferLength} bytes to output stream.");
         txByteCount = outputDataWriter.Store();
         Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

         // Read the response, need loop as multi line response
         bytesRead = inputDataReader.Load(128);
         while (bytesRead > 0)
         {
            String response = inputDataReader.ReadString(bytesRead);
            Debug.WriteLine($"RX :{response}");

            bytesRead = inputDataReader.Load(128);
         }

         while (true)
         {
            bytesWritten = outputDataWriter.WriteString($"AT+MSGHEX=\"{Payload}\"\r\n");
            Debug.WriteLine($"TX: send {outputDataWriter.UnstoredBufferLength} bytes to output stream.");

            txByteCount = outputDataWriter.Store();
            Debug.WriteLine($"TX: {txByteCount} bytes via {serialDevice.PortName}");

            // Read the response, need loop as multi line response
            bytesRead = inputDataReader.Load(128);
            while (bytesRead > 0)
            {
               String response = inputDataReader.ReadString(bytesRead);
               Debug.WriteLine($"RX :{response}");

               bytesRead = inputDataReader.Load(128);
            }

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

The code is not suitable for production but it confirmed my software and hardware configuration worked.

The thread '<No Name>' (0x2) has exited with code 0 (0x0).
devMobile.IoT.SeeedE5.NetworkJoinOTAA starting
TX: DR 13 bytes
RX :+DR: AS923

TX: MODE 16 bytes
RX :+MODE: LWOTAA

TX: ID=AppEui 40 bytes
RX :+ID: AppEui, ..:..:.:.:.:.:.:.

TX: KEY=APPKEY 48 bytes
RX :+KEY: APPKEY ................................

TX: PORT 11 bytes
RX :+PORT: 1

TX: JOIN 9 bytes
RX :+JOIN: Start
+JOIN: NORMAL
+JOIN: Network joined
+JOIN: NetID 000013 DevAddr ..:..:..:..
+JOIN: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: FPENDING
+MSGHEX: RXWIN1, RSSI -41, SNR 9.0
+MSGHEX: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: Done

In the Visual Studio 2019 debug output I could see messages getting sent and then after a short delay they were visible in the TTI console.

Seeed E5 LoRaWAN dev Kit connecting in The Things Industries Device Live data tab

TinyCLR OS V2 Seeed LoRa-E5 on Github

The source code of my GHI Electronics TinyCLR-0SV2RC1 Seeed LoRa-E5 library is live on GitHub. The initial test harness uses a Fezduinoand a LoRa-E5 Development Kit.

Fezduino device with Seeedstudio Grove base shield and LoRa-E5 development Kit

So far the demo application has been running for 24 hours

The thread '<No Name>' (0x2) has exited with code 0 (0x0).
devMobile.IoT.SeeedE5LoRaWANDeviceClient starting
12:00:01 Join start Timeout:25 Seconds
12:00:07 Join finish
12:00:07 Send Timeout:10 Seconds payload BCD:010203040506070809
12:00:13 Sleep
12:05:13 Wakeup
12:05:13 Send Timeout:10 Seconds payload BCD:010203040506070809
12:05:20 Sleep
12:10:20 Wakeup
12:10:20 Send Timeout:10 Seconds payload BCD:010203040506070809
12:10:27 Sleep
12:15:27 Wakeup
12:15:27 Send Timeout:10 Seconds payload BCD:010203040506070809
12:15:34 Sleep
...
11:52:40 Wakeup
11:52:40 Send Timeout:10 Seconds payload BCD:010203040506070809
11:52:45 Sleep
11:57:45 Wakeup
11:57:45 Send Timeout:10 Seconds payload BCD:010203040506070809
11:57:52 Sleep
12:02:52 Wakeup
12:02:52 Send Timeout:10 Seconds payload BCD:010203040506070809
12:02:59 Sleep
12:07:59 Wakeup
12:07:59 Send Timeout:10 Seconds payload BCD:010203040506070809
12:08:07 Sleep
12:13:07 Wakeup
12:13:07 Send Timeout:10 Seconds payload BCD:010203040506070809
12:13:14 Sleep

I have tested the Over The Air Activation(OTAA) code and will work on testing the other functionality over the coming week,

public static void Main()
{
   Result result;

   Debug.WriteLine("devMobile.IoT.SeeedE5LoRaWANDeviceClient starting");

   try
   {
      using (SeeedE5LoRaWANDevice device = new SeeedE5LoRaWANDevice())
      {
         result = device.Initialise(SerialPortId, 9600, UartParity.None, 8, UartStopBitCount.One);
         if (result != Result.Success)
         {
            Debug.WriteLine($"Initialise failed {result}");
            return;
         }

#if CONFIRMED
         device.OnMessageConfirmation += OnMessageConfirmationHandler;
#endif
         device.OnReceiveMessage += OnReceiveMessageHandler;

#if RESET
         Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Reset");
         result = device.Reset();
         if (result != Result.Success)
         {
            Debug.WriteLine($"Reset failed {result}");
            return;
          }
#endif

         Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Region {Region}");
         result = device.Region(Region);
         if (result != Result.Success)
         {
            Debug.WriteLine($"Region failed {result}");
            return;
         }

         Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} ADR On");
         result = device.AdrOn();
         if (result != Result.Success)
         {
            Debug.WriteLine($"ADR on failed {result}");
            return;
         }

               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Port");
               result = device.Port(MessagePort);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"Port on failed {result}");
                  return;
               }

#if OTAA
               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} OTAA");
               result = device.OtaaInitialise(Config.AppEui, Config.AppKey);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"OTAA Initialise failed {result}");
                  return;
               }
#endif

#if ABP
               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} ABP");
               result = device.AbpInitialise(DevAddress, NwksKey, AppsKey);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"ABP Initialise failed {result}");
                  return;
               }
#endif

               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join start Timeout:{JoinTimeOut.TotalSeconds} Seconds");
               result = device.Join(true, JoinTimeOut);
               if (result != Result.Success)
               {
                  Debug.WriteLine($"Join failed {result}");
                  return;
               }
               Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join finish");

               while (true)
               {
#if PAYLOAD_BCD
                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Send Timeout:{SendTimeout.TotalSeconds} Seconds payload BCD:{PayloadBcd}");
#if CONFIRMED
                  result = device.Send(PayloadBcd, true, SendTimeout);
#else
                  result = device.Send(PayloadBcd, false, SendTimeout);
#endif
#endif

#if PAYLOAD_BYTES
                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Send Timeout:{SendTimeout.TotalSeconds} Seconds payload Bytes:{BitConverter.ToString(PayloadBytes)}");
#if CONFIRMED
                  result = device.Send(PayloadBytes, true, SendTimeout);
#else
                  result = device.Send(PayloadBytes, false, SendTimeout);
#endif
#endif
                  if (result != Result.Success)
                  {
                     Debug.WriteLine($"Send failed {result}");
                  }

                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Sleep");
                  result = device.Sleep();
                  if (result != Result.Success)
                  {
                     Debug.WriteLine($"Sleep failed {result}");
                     return;
                  }

                  Thread.Sleep(300000);

                  Debug.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Wakeup");
                  result = device.Wakeup();
                  if (result != Result.Success)
                  {
                     Debug.WriteLine($"Wakeup failed {result}");
                     return;
                  }
               }
            }
         }
         catch (Exception ex)
         {
            Debug.WriteLine(ex.Message);
         }
      }

The Region, ADR and OtaaInitialise methods only need to be called when the device is first powered up and after a reset.

The library works but should be treated as late beta.

TinyCLR OS V2 Seeed LoRa-E5 LoRaWAN library Part5

Receive of two parts

After getting basic connectivity for my Seeedstudio LoRa-E5 Development Kit and Fezduino test rig working I started to build a general purpose library for GHI Electronics TinyCLR powered devices.

The code wasn’t very robust so when I sent messages from The Things Network (TTN) EndDevice messaging tab my first implementation didn’t work.

In the Visual Studio 2019 Debug output window

The thread '<No Name>' (0x2) has exited with code 0 (0x0).
devMobile.IoT.SeeedE5.NetworkJoinOTAA starting
TX: DR 13 bytes
RX :+DR: AS923

TX: MODE 16 bytes
RX :+MODE: LWOTAA

TX: ID=AppEui 40 bytes
RX :+ID: AppEui, 00:00:00:00:00:00:00:00

TX: KEY=APPKEY 48 bytes
RX :+KEY: APPKEY 12345678901234567890123456789012

TX: PORT 11 bytes
RX :+PORT: 1

TX: JOIN 9 bytes
RX :+JOIN: Start
+JOIN: NORMAL
+JOIN: Network joined
+JOIN: NetID 000013 DevAddr 00:00:00:00
+JOIN: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: FPENDING
+MSGHEX: RXWIN1, RSSI -31, SNR 8.0
+MSGHEX: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: PORT: 10; RX: "0102030405"
+MSGHEX: RXWIN1, RSSI -31, SNR 15.0
+MSGHEX: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: FPENDING
+MSGHEX: PORT: 20; RX: "0504030201"
+MSGHEX: RXWIN1, RSSI -31, SNR 14.0
+MSGHEX: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: Done

After going back and looking at the module documentation and the diagnostic output I realised that the downlink message and confirmation were sent in two responses.

The first (optional) part of the response had the port number and message payload

+MSGHEX: PORT: 20; RX: "0504030201"

The second had the signal strength information

+MSGHEX: RXWIN1, RSSI -31, SNR 14.0

I had to add some code to the SerialDevice_DataReceived method for assembling the two responses. It would be good if the Seeedstudio LoRa-E5 only used one response. (Sample below based on RAK811)

at+send=lora:1:5A00
OK
at+recv=1,-105,-12,0

at+send=lora:1:5A00
OK
at+recv=0,-105,-12,8,00010203

The other LoRa-E5 implementation detail which frustrated me was the inclusion of labels for values e.g. PORT, RSSI, SNR etc.

 +MSGHEX: RXWIN1, RSSI -31, SNR 14.0 

It would be simpler if the first parameter was the receive window, the second Received Signal Strength Indication(RSSI) and third Signal to Noise Ratio(SNR) etc..

The inconsistent use of separators also made unpacking messages more complex (esp. ‘;’ vs ‘:’ which was hard to see)

+MSGHEX: PORT: 20; RX: “0504030201” uses ‘:’ + ‘;’ + ‘”” + ‘ ‘

+MSGHEX: RXWIN1, RSSI -31, SNR 14.0 uses ‘:’ + ‘,’ + ‘ ‘

Now that I have a proof of concept library I need to functionality and soak test it.

TinyCLR OS V2 Seeed LoRa-E5 LoRaWAN library Part4

Failure is an option

After getting basic connectivity for my Seeedstudio LoRa-E5 Development Kit and Fezduino test rig working I started to build a general purpose library for GHI Electronics TinyCLR powered devices.

The code currently isn’t very robust so when I accidentally used an invalid region, then AppEUI the responses weren’t consistent. When the region configuration failed the response was +DR: ERROR(-1) which maps to “Parameters is invalid” and when the Join failed the response was “+JOIN: Join failed”.

// Set the Region to AS923
txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes("AT+DR=AS924\r\n"));
Debug.WriteLine($"TX: DR {txByteCount} bytes");
Thread.Sleep(500);

//Read the response
rxByteCount = serialDevice.BytesToRead;
if (rxByteCount > 0)
{
   byte[] rxBuffer = new byte[rxByteCount];
   serialDevice.Read(rxBuffer);
   Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
}

In the Visual Studio 2019 Debug output window

The thread '<No Name>' (0x2) has exited with code 0 (0x0).
devMobile.IoT.SeeedE5.NetworkJoinOTAA starting
TX: DR 13 bytes
RX :+DR: ERROR(-1)

When I tried an invalid AppEui and the AT+JOIN failed the error message was “+JOIN: Join failed”

In the Visual Studio 2019 Debug output window

devMobile.IoT.SeeedE5.NetworkJoinOTAA starting
TX: DR 13 bytes
RX :+DR: AS923

TX: MODE 16 bytes
RX :+MODE: LWOTAA

TX: ID=AppEui 40 bytes
RX :+ID: AppEui, 00:00:00:00:00:00:00:00

TX: KEY=APPKEY 48 bytes
RX :+KEY: APPKEY 01234567890123456789012345678901

TX: PORT 11 bytes
RX :+PORT: 1

TX: JOIN 9 bytes
RX :+JOIN: Start
+JOIN: NORMAL
+JOIN: Join failed
+JOIN: Done

I had to add some code to the SerialDevice_DataReceived method for handling the “+JOIN: Join failed” case. It would be good if the Seeedstudio LoRa-E5 reported errors in a consistent way for all commands, without the ERROR(..) marker.

TinyCLR OS V2 Seeed LoRa-E5 LoRaWAN library Part3

DevAddr, DevEui and AppEui Oddness

After getting basic connectivity for my Seeedstudio LoRa-E5 Development Kit and Fezduino test rig working I wanted to build a general purpose library for GHI Electronics TinyCLR powered devices.

The code currently isn’t very robust but this caught my attention…

devMobile.IoT.SeeedE5.NetworkJoinOTAA starting
TX: DR 13 bytes
RX :+DR: AS923

TX: MODE 16 bytes
RX :+MODE: LWOTAA

TX: ID=AppEui 40 bytes
RX :+ID: AppEui, 00:00:00:00:00:00:00:00

TX: KEY=APPKEY 48 bytes
RX :+KEY: APPKEY 0123456789ABCDEFGHIJKLMOPQRSTRU 

TX: PORT 11 bytes
RX :+PORT: 1

TX: JOIN 9 bytes
RX :+JOIN: Start
+JOIN: NORMAL
+JOIN: Network joined
+JOIN: NetID 000013 DevAddr 00:01:02:03
+JOIN: Done

In my code I validate the values returned by commands

AT+ID=AppEui, “0000000000000”

AT+ID=APPEUI, “00 00 00 00 00 00 00 00”

Response to either of the above commands

+ID: AppEui, 00:00:00:00:00:00:00:00

It just seem a bit odd that to set the AppEUI (similar for the DevEUI and DevAddr) there are two possible formats available, neither of which is the format returned.

This was unlike the RAK811 module where most commands just return OK when they are successful.

at+set_config=lora:app_eui:0000000000000001
OK

TinyCLR OS V2 Seeed LoRa-E5 LoRaWAN library Part2

Nasty OTAA connect

After getting basic connectivity for my Seeedstudio LoRa-E5 Development Kit and Fezduino test rig working I wanted to see if I could get the device connected to The Things Industries(TTI) via the RAK7258 WisGate Edge Lite on the shelf in my office.

My Over the Air Activation (OTAA) implementation is very “nasty” as it is assumed that there are no timeouts or failures and it only sends one BCD message “48656c6c6f204c6f526157414e” which is “hello LoRaWAN”. The code just sequentially steps through the necessary commands (with a suitable delay after each is sent) to join the TTI network.

public class Program
{
#if TINYCLR_V2_FEZDUINO
   private static string SerialPortId = SC20100.UartPort.Uart5;
#endif
   private const string AppKey = "................................";

   //txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+ID=AppEui,{AppEui}\r\n"));
   //private const string AppEui = "................";

   //txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+ID=AppEui,\"{AppEui}\"\r\n"));
   private const string AppEui = ".. .. .. .. .. .. .. ..";

   private const byte messagePort = 1;

   //private const string payload = "48656c6c6f204c6f526157414e"; // Hello LoRaWAN
   private const string payload = "01020304"; // AQIDBA==
   //private const string payload = "04030201"; // BAMCAQ==

   public static void Main()
   {
      UartController serialDevice;
      int txByteCount;
      int rxByteCount;

      Debug.WriteLine("devMobile.IoT.SeeedE5.NetworkJoinOTAA starting");

      try
      {
         serialDevice = UartController.FromName(SerialPortId);

         serialDevice.SetActiveSettings(new UartSetting()
         {
            BaudRate = 9600,
            Parity = UartParity.None,
            StopBits = UartStopBitCount.One,
            Handshaking = UartHandshake.None,
            DataBits = 8
         });

         serialDevice.Enable();

         // Set the Region to AS923
         txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes("AT+DR=AS923\r\n"));
         Debug.WriteLine($"TX: DR {txByteCount} bytes");
         Thread.Sleep(500);

         // Read the response
         rxByteCount = serialDevice.BytesToRead;
         if (rxByteCount > 0)
         {
            byte[] rxBuffer = new byte[rxByteCount];
            serialDevice.Read(rxBuffer);
            Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
         }

         // Set the Join mode
         txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes("AT+MODE=LWOTAA\r\n"));
         Debug.WriteLine($"TX: MODE {txByteCount} bytes");
         Thread.Sleep(500);

         // Read the response
         rxByteCount = serialDevice.BytesToRead;
         if (rxByteCount > 0)
         {
            byte[] rxBuffer = new byte[rxByteCount];
            serialDevice.Read(rxBuffer);
            Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
         }

         // Set the appEUI
         txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+ID=AppEui,\"{AppEui}\"\r\n"));
         Debug.WriteLine($"TX: ID=AppEui {txByteCount} bytes");
         Thread.Sleep(500);

         // Read the response
         rxByteCount = serialDevice.BytesToRead;
         if (rxByteCount > 0)
         {
            byte[] rxBuffer = new byte[rxByteCount];
            serialDevice.Read(rxBuffer);
            Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
         }
            
         // Set the appKey
         txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+KEY=APPKEY,{AppKey}\r\n"));
         Debug.WriteLine($"TX: KEY=APPKEY {txByteCount} bytes");
         Thread.Sleep(500);

         // Read the response
         rxByteCount = serialDevice.BytesToRead;
         if (rxByteCount > 0)
         {
            byte[] rxBuffer = new byte[rxByteCount];
            serialDevice.Read(rxBuffer);
            Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
         }

         // Set the PORT
         txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+PORT={messagePort}\r\n"));
         Debug.WriteLine($"TX: PORT {txByteCount} bytes");
         Thread.Sleep(500);

         // Read the response
         rxByteCount = serialDevice.BytesToRead;
         if (rxByteCount > 0)
         {
            byte[] rxBuffer = new byte[rxByteCount];
            serialDevice.Read(rxBuffer);
            Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
         }

         // Join the network
         txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes("AT+JOIN\r\n"));
         Debug.WriteLine($"TX: JOIN {txByteCount} bytes");
         Thread.Sleep(10000);

         // Read the response
         rxByteCount = serialDevice.BytesToRead;
         if (rxByteCount > 0)
         {
            byte[] rxBuffer = new byte[rxByteCount];
            serialDevice.Read(rxBuffer);
            Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
         }

         while (true)
         {
            // Unconfirmed message
            txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+MSGHEX=\"{payload}\"\r\n"));
            Debug.WriteLine($"TX: MSGHEX {txByteCount} bytes");

            // Confirmed message
            //txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+CMSGHEX=\"{payload}\"\r\n"));
            //Debug.WriteLine($"TX: CMSGHEX {txByteCount} bytes");

            Thread.Sleep(10000);

            // Read the response
            rxByteCount = serialDevice.BytesToRead;
            if (rxByteCount > 0)
            {
               byte[] rxBuffer = new byte[rxByteCount];
               serialDevice.Read(rxBuffer);
               Debug.WriteLine($"RX :{UTF8Encoding.UTF8.GetString(rxBuffer)}");
            }

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

The code is not suitable for production but it confirmed my software and hardware configuration worked.

The thread '<No Name>' (0x2) has exited with code 0 (0x0).
devMobile.IoT.SeeedE5.NetworkJoinOTAA starting
TX: DR 13 bytes
RX :+DR: AS923

TX: MODE 16 bytes
RX :+MODE: LWOTAA

TX: ID=AppEui 40 bytes
RX :+ID: AppEui, ..:..:.:.:.:.:.:.

TX: KEY=APPKEY 48 bytes
RX :+KEY: APPKEY ................................

TX: PORT 11 bytes
RX :+PORT: 1

TX: JOIN 9 bytes
RX :+JOIN: Start
+JOIN: NORMAL
+JOIN: Network joined
+JOIN: NetID 000013 DevAddr ..:..:..:..
+JOIN: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: FPENDING
+MSGHEX: RXWIN1, RSSI -41, SNR 9.0
+MSGHEX: Done

TX: MSGHEX 22 bytes
RX :+MSGHEX: Start
+MSGHEX: Done

In the Visual Studio 2019 debug output I could see messages getting sent and then after a short delay they were visible in the TTI console.

Seeed E5 LoRaWAN dev Kit connecting in The Things Industries Device Live data tab

I had an issue with how the AppUI parameter was handled

   private const string AppKey = "................................";

   //txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+ID=AppEui,{AppEui}\r\n"));
   //private const string AppEui = "................";

   //txByteCount = serialDevice.Write(UTF8Encoding.UTF8.GetBytes($"AT+ID=AppEui,\"{AppEui}\"\r\n"));
   private const string AppEui = ".. .. .. .. .. .. .. ..";


It appears that If the appkey (or other string parameter) has spaces it has to be enclosed in quotations.

TTI V3 Gateway provisioning Dragino LHT65 Uplink

This very long post is about how to connect a Dragino LHT65 Temperature and Humidity sensor to Azure IoT Central using my TTI/TTN V3Azure IoT Connector and the Digital Twin Definition Language (DTDL).

Dragino LHT65 temperature and Humidity sensor

The first step was to add an application(dragino-lht65) in my The Things Industries(TTI) tenant

TTI/TTN application for my Dragino LHT65 devices
Adding devMobile as a collaborator on the new application
TTI Application API Key configuration

The new Application API Key used by the MQTTnet managed client only needs to have write downlink and read uplink traffic enabled.

FTDI Adapter and modified LHT64 cable

So I could reliably connect to my LHT65 devices to configure them I modified a programming cable so I could use it with a spare FTDI adaptor without jumper wires. Todo this I used a small jewelers screwdriver to “pop” out the VCC cable and move the transmit data line.

After entering the device password and checking the firmware version I used the AT+CFG command to display the device settings

AT+CFG: Print all configurations

[334428]***** UpLinkCounter= 0 *****
[334430]TX on freq 923200000 Hz at DR 2
[334804]txDone
[339807]RX on freq 923200000 Hz at DR 2
[339868]rxTimeOut
[340807]RX on freq 923200000 Hz at DR 2
[340868]rxTimeOut

Correct Password

Stop Tx events,Please wait for all configurations to print
Printf all config...
AT+DEUI=a8 .. .. .. .. .. .. d6
AT+DADDR=01......D6

AT+APPKEY=9d .. .. .. .. .. .. .. .. .. .. .. .. .. .. 2e
AT+NWKSKEY=f6 .. .. .. .. .. .. .. .. .. .. .. .. .. .. 69
AT+APPSKEY=4c 35 .. .. .. .. .. .. .. .. .. .. .. .. .. 3d
AT+APPEUI=a0 .. .. .. .. .. .. 00
AT+ADR=1
AT+TXP=0
AT+DR=0
AT+DCS=0
AT+PNM=1
AT+RX2FQ=923200000
AT+RX2DR=2
AT+RX1DL=1000
AT+RX2DL=2000
AT+JN1DL=5000
AT+JN2DL=6000
AT+NJM=1
AT+NWKID=00 00 00 00
AT+FCU=0
AT+FCD=0
AT+CLASS=A
AT+NJS=0
AT+RECVB=0:
AT+RECV=0:
AT+VER=v1.7 AS923

AT+CFM=0
AT+CFS=0
AT+SNR=0
AT+RSSI=0
AT+TDC=1200000
AT+PORT=2
AT+PWORD=123456
AT+CHS=0
AT+DATE=21/3/26 07:49:15
AT+SLEEP=0
AT+EXT=4,2
AT+RTP=20
AT+BAT=3120
AT+WMOD=0
AT+ARTEMP=-40,125
AT+CITEMP=1
Start Tx events OK


[399287]***** UpLinkCounter= 0 *****

[399289]TX on freq 923400000 Hz at DR 2

[399663]txDone

[404666]RX on freq 923400000 Hz at DR 2

[404726]rxTimeOut

[405666]RX on freq 923200000 Hz at DR 2

[405726]rxTimeOut

I copied the AppEUI and DevEUI for use on the TI Dragino LHT65 Register end device form provided by the TTI/TTN.

TTYI/TTN Dragino LHT65 Register end device

The Dragino LHT65 uses the DeviceEUI as the DeviceID which meant I had todo more redaction in my TTI/TTN and Azure Application Insights screen captures. The rules around the re-use of EndDevice ID were a pain in the arse(PITA) in my development focused tenant.

Dragino LHT 65 Device uplink payload formatter

The connector supports both uplink and downlink messages with JSON encoded payloads. The Dragino LHT65 has a vendor supplied formatter which is automatically configured when an EndDevice is created. The EndDevice formatter configuration can also be overridden at the Application level in the app.settings.json file.

Device Live Data Uplink Data Payload

Once an EndDevice is configured in TTI/TTN I usually use the “Live data Uplink Payload” to work out the decoded payload JSON property names and data types.

LHT65 Uplink only Azure IoT Central Device Template
LHT65 Device Template View Identity

For Azure IoT Central “automagic” provisioning the DTDLModelId has to be copied from the Azure IoT Central Template into the TTI/TTN EndDevice or app.settings.json file application configuration.

LHT65 Device Template copy DTDL @ID
TTI EndDevice configuring the DTDLV2 @ID at the device level

Configuring the DTDLV2 @ID at the TTI application level in the app.settings.json file

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft": "Debug",
      "Microsoft.Hosting.Lifetime": "Debug"
    },
    "ApplicationInsights": {
      "LogLevel": {
        "Default": "Debug"
      }
    }
  },

  "ProgramSettings": {
    "Applications": {
      "application1": {
        "AzureSettings": {
          "DeviceProvisioningServiceSettings": {
            "IdScope": "0ne...DD9",
            "GroupEnrollmentKey": "eFR...w=="
          }
        },
        "DTDLModelId": "dtmi:ttnv3connectorclient:FezduinoWisnodeV14x8;4",
        "MQTTAccessKey": "NNSXS.HCY...RYQ",
        "DeviceIntegrationDefault": false,
        "MethodSettings": {
          "Reboot": {
            "Port": 21,
            "Confirmed": true,
            "Priority": "normal",
            "Queue": "push"
          },
          "value_0": {
            "Port": 30,
            "Confirmed": true,
            "Priority": "normal",
            "Queue": "push"
          },
          "value_1": {
            "Port": 30,
            "Confirmed": true,
            "Priority": "normal",
            "Queue": "push"
          },
          "TemperatureOOBAlertMinimumAndMaximum": {
            "Port": 30,
            "Confirmed": true,
            "Priority": "normal",
            "Queue": "push"
          }
        }
      },
      "seeeduinolorawan": {
        "AzureSettings": {
          "DeviceProvisioningServiceSettings": {
            "IdScope": "0ne...DD9",
            "GroupEnrollmentKey": "AtN...g=="
          },
        },
        "DTDLModelId": "dtmi:ttnv3connectorclient:SeeeduinoLoRaWAN4cz;1",
        "MQTTAccessKey": "NNSXS.V44...42A",
        "DeviceIntegrationDefault": true,
        "DevicePageSize": 10
      },
      "dragino-lht65": {
        "AzureSettings": {
          "DeviceProvisioningServiceSettings": {
            "IdScope": "0ne...DD9",
            "GroupEnrollmentKey": "SLB...w=="
          }
        },
        "DTDLModelId": "dtmi:ttnv3connectorclient:DraginoLHT656w6;1",
        "MQTTAccessKey": "NNSXS.RIJ...NZQ",
        "DeviceIntegrationDefault": true,
        "DevicePageSize": 10
      }
    },
    "TheThingsIndustries": {
      "MqttServerName": "eu1.cloud.thethings.industries",
      "MqttClientId": "MQTTClient",
      "MqttAutoReconnectDelay": "00:00:05",
      "Tenant": "...-test",
      "ApiBaseUrl": "https://...-test.eu1.cloud.thethings.industries/api/v3",
      "ApiKey": "NNSXS.NR7...ZSA",
      "Collaborator": "devmobile",
      "DevicePageSize": 10,
      "DeviceIntegrationDefault": true
    }
  }
}

The Azure Device Provisioning Service(DPS) is configured at the TTI application level in the app.settings.json file. The IDScope and one of the Primary or Secondary Shared Access Signature(SAS) keys should be copied into DeviceProvisioningServiceSettings of an Application in the app.settings.json file. I usually set the “Automatically connect devices in this group” flag as part of the “automagic” provisioning process.

Azure IoT Central Group Enrollment Key
Then device templates need to be mapped to an Enrollment Group then Device Group.

For testing the connector application can be run locally with diagnostic information displayed in the application console window as it “automagically’ provisions devices and uploads telemetry data.

Connector application Diagnostics
Azure IoT Central Device list before my LHT65 device is “automagically” provisioned
Azure IoT Central Device list after my LHT65 device is “automagically” provisioned

One a device has been provisioned I check on the raw data display that all the fields I configured have been mapped correctly.

Azure IoT Central raw data display

I then created a dashboard to display the telemetry data from the LHT65 sensors.

Azure IoT Central dashboard displaying LHT65 temperature, humidity and battery voltage graphs.

The dashboard also has a few KPI displays which highlighted an issue which occurs a couple of times a month with the LHT65 onboard temperature sensor values (327.7°). I have connected Dragino technical support and have also been unable to find a way to remove the current an/or filter out future aberrant values.

Azure Application Insights logging

I also noticed that the formatting of the DeviceEUI values in the Application Insights logging was incorrect after trying to search for one of my Seeedstudio LoRaWAN device with its DeviceEUI.

TTN V3 Gateway Downlink Broken

While adding Azure Device Provisioning Service (DPS) support to my The Things Industries(TTI)/The Things Network(TTN) Azure IoT Hub/Azure IoT Central Connector I broke Cloud to Device(C2D)/Downlink messaging. I had copied the Advanced Message Queuing Protocol(AMQP) connection pooling configuration code from my The Things Network Integration assuming it worked.

return DeviceClient.CreateFromConnectionString(connectionString, deviceId,
	new ITransportSettings[]
	{
		new AmqpTransportSettings(TransportType.Amqp_Tcp_Only)
		{
			PrefetchCount = 0,
			AmqpConnectionPoolSettings = new AmqpConnectionPoolSettings()
			{
				Pooling = true,
			}
		}
	});

I hadn’t noticed this issue in my Azure IoT The Things Network Integration because I hadn’t built support for C2D messaging. After some trial and error I figured out the issue was the PrefetchCount initialisation.

return DeviceClient.CreateFromConnectionString(connectionString, deviceId,
	new ITransportSettings[]
	{
		new AmqpTransportSettings(TransportType.Amqp_Tcp_Only)
		{
			AmqpConnectionPoolSettings = new AmqpConnectionPoolSettings()
			{
				Pooling = true,
			}
		}
	});

From the Azure Service Bus (I couldn’t find any specifically Azure IoT Hub ) documentation

Even though the Service Bus APIs do not directly expose such an option today, a lower-level AMQP protocol client can use the link-credit model to turn the “pull-style” interaction of issuing one unit of credit for each receive request into a “push-style” model by issuing a large number of link credits and then receive messages as they become available without any further interaction. Push is supported through the MessagingFactory.PrefetchCount or MessageReceiver.PrefetchCount property settings. When they are non-zero, the AMQP client uses it as the link credit.

n this context, it’s important to understand that the clock for the expiration of the lock on the message inside the entity starts when the message is taken from the entity, not when the message is put on the wire. Whenever the client indicates readiness to receive messages by issuing link credit, it is therefore expected to be actively pulling messages across the network and be ready to handle them. Otherwise the message lock may have expired before the message is even delivered. The use of link-credit flow control should directly reflect the immediate readiness to deal with available messages dispatched to the receiver.

In the Azure IoT Hub SDK the prefetch count is set to 50 (around line 57) and throws an exception if less that zero (around line 90) and there is some information about tuning the prefetch value for Azure Service Bus.

The best explanation I count find was Github issue which was a query “What exactly does the PrefetchCount property control?”

“You are correct, the pre-fetch count is used to set the link credit over AMQP. What this signifies is the max. no. of messages that can be “in-flight” from the service to the client, at any given time. (This value defaults to 50 for the IoT Hub .NET client).
The client specifies its link-credit, that the service must respect. In simplest terms, any time the service sends a message to the client, it decrements the link credit, and will continue sending messages until linkCredit > 0. Once the client acknowledges the message, it will increment the link credit.”

In summary if Prefetch count is set to zero on startup in my application no messages will be sent to the client….

TTN V3 Gateway Azure Configuration Simplication

To reduce complexity the initial version of the V3 TTI gateway didn’t support the Azure Device Provisioning Service(DPS). In preparation for this I had included DeviceProvisioningServiceSettings object in both the Application and AzureSettingsDefault sections.

After trialing a couple of different approaches I have removed the AzureSettingsDefault. If an application has a connectionstring configured that is used, if there is not one then the DPS configuration is used, if there are neither currently the application logs an error. In the future I will look at adding a configuration option to make the application optionally shutdown

{
  ...
  "ProgramSettings": {
    "Applications": {
      "application1": {
        "AzureSettings": {
          "IoTHubConnectionString": "HostName=TT...n1.azure-devices.net;SharedAccessKeyName=device;SharedAccessKey=Am...M=",
          "DeviceProvisioningServiceSettings": {
            "IdScope": "0n...3B",
            "GroupEnrollmentKey": "Kl...Y="
          }
        },
        "MQTTAccessKey": "NNSXS.HC...YQ",
        "DeviceIntegrationDefault": false,
        "DevicePageSize": 10
      },
      "seeeduinolorawan": {
        "AzureSettings": {
          "IoTHubConnectionString": "HostName=TT...n2.azure-devices.net;SharedAccessKeyName=device;SharedAccessKey=D2q...L8=",
          "DeviceProvisioningServiceSettings": {
            "IdScope": "0n...3B",
            "GroupEnrollmentKey": "Kl...Y="
          }
        },
        "MQTTAccessKey": "NNSXS.V44...42A",
        "DeviceIntegrationDefault": true,
        "DevicePageSize": 10
      }
    },

    "TheThingsIndustries": {
      "MqttServerName": "eu1.cloud.thethings.industries",
      "MqttClientId": "MQTTClient",
      "MqttAutoReconnectDelay": "00:00:05",
      "Tenant": "br...st",
      "ApiBaseUrl": "https://br..st.eu1.cloud.thethings.industries/api/v3",
      "ApiKey": "NNSXS.NR...SA",
      "Collaborator": "de...le",
      "DevicePageSize": 10,
      "DeviceIntegrationDefault": true
    }
  }
}

The implementation of failing back from application to default settings wasn’t easy to implement, explain or document.