.NET Core RAK3172 LoRaWAN library Part3

Nasty ABP connect

After getting basic connectivity for my RAK3172 test rig sorted I wanted to see if I could get the device connected to The Things Network(TTN) via the RAK7246G LPWAN Developer Gateway on my bookcase.

Raspberry Pi3 with Grove Base Hat and RAK3172 Breakout (using UART2)

My Activation By Personalisation (ABP) implementation is very “nasty” (just like the OTAA one) I have assumed that there would be no timeouts or failures and I only send one BCD message “48656c6c6f204c6f526157414e” which is “hello LoRaWAN”.

The code just sequentially steps through the necessary configuration to join the TTN network with a suitable delay after each command is sent.

//---------------------------------------------------------------------------------
// Copyright (c) September 2021, devMobile Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
//---------------------------------------------------------------------------------
namespace devMobile.IoT.NetCore.RAK3172.NetworkJoinABP
{
	using System;
	using System.Diagnostics;
	using System.IO.Ports;
	using System.Threading;

	public class Program
	{
		private const string SerialPortId = "/dev/ttyS0";
		private const string DevAddress = "...";
		private const string NwksKey = "...";
		private const string AppsKey = "...";
		private const byte MessagePort = 1;
		private const string Payload = "A0EEE456D02AFF4AB8BAFD58101D2A2A"; // Hello LoRaWAN

		public static void Main()
		{
			string response;

			Debug.WriteLine("devMobile.IoT.NetCore.Rak3172.NetworkJoinOTAA starting");

			Debug.WriteLine(String.Join(",", SerialPort.GetPortNames()));

			try
			{
				using (SerialPort serialPort = new SerialPort(SerialPortId))
				{
					// set parameters
					serialPort.BaudRate = 9600;
					serialPort.DataBits = 8;
					serialPort.Parity = Parity.None;
					serialPort.StopBits = StopBits.One;
					serialPort.Handshake = Handshake.None;

					serialPort.ReadTimeout = 5000;

					serialPort.NewLine = "\r\n";

					serialPort.Open();

					// clear out the RX buffer
					response = serialPort.ReadExisting();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");
					Thread.Sleep(500);

					// Set the Working mode to LoRaWAN
					Console.WriteLine("Set Work mode");
					serialPort.WriteLine("AT+NWM=1");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the Region to AS923
					Console.WriteLine("Set Region");
					serialPort.WriteLine("AT+BAND=8-1");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the JoinMode
					Console.WriteLine("Set Join mode");
					serialPort.WriteLine("AT+NJM=0");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the device address
					Console.WriteLine("Set Device Address");
					serialPort.WriteLine($"AT+DEVADDR={DevAddress}");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the network session key
					Console.WriteLine("Set Network Session Key");
					serialPort.WriteLine($"AT+NWKSKEY={NwksKey}");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the application session key
					Console.WriteLine("Set application Session Key");
					serialPort.WriteLine($"AT+APPSKEY={AppsKey}");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the Confirm flag
					Console.WriteLine("Set Confirm off");
					serialPort.WriteLine("AT+CFM=0");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Join the network
					Console.WriteLine("Start Join");
					serialPort.WriteLine("AT+JOIN=1:0:10:2");

					// Read the blank line
					response = serialPort.ReadLine();

					// Read the Result
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					Thread.Sleep(10000);

					// Read the +EVT:JOINED
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					while (true)
					{
						Console.WriteLine("Sending");
						serialPort.WriteLine($"AT+SEND={MessagePort}:{Payload}");

						// Read the blank line
						response = serialPort.ReadLine();

						// Read the result
						Console.WriteLine("Send result");
						response = serialPort.ReadLine();
						Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

						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.

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

The RAK3172 command format is quite different from other modules I have used e.g. Requesting the firmware version information

  • TX- AT+VER=?
  • RX- Blank Line
  • RX- V1.0.2
  • RX- OK

Requesting the APPEUI

  • TX- AT+DEVADDR=?
  • RX- 11223344
  • RX- Blank line
  • RX- OK

I think the RAK3172 module ships with a default DEVEUI so in this code and my library I have assumed it will be configured as part of a “provisioning” process.

.NET Core RAK3172 LoRaWAN library Part2

Nasty OTAA connect

After getting basic connectivity for my RAK3172 test rig sorted I wanted to see if I could get the device connected to The Things Network(TTN) via the RAK7246G LPWAN Developer Gateway on my bookcase.

Raspberry Pi3 with Grove Base Hat and RAK3172 Breakout (using UART2)

My Over the Air Activation (OTAA) implementation is very “nasty” I have assumed that there would be no timeouts or failures and I only send one BCD message “48656c6c6f204c6f526157414e” which is “hello LoRaWAN”.

The code just sequentially steps through the necessary configuration to join the TTN network with a suitable delay after each command is sent.

//---------------------------------------------------------------------------------
// Copyright (c) September 2021, devMobile Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
//---------------------------------------------------------------------------------
namespace devMobile.IoT.NetCore.RAK3172.NetworkJoinOTAA
{
	using System;
	using System.Diagnostics;
	using System.IO.Ports;
	using System.Threading;

	public class Program
	{
		private const string SerialPortId = "/dev/ttyS0";
		private const string AppEui = "...";
		private const string AppKey = "...";
		private const byte MessagePort = 1;
		private const string Payload = "A0EEE456D02AFF4AB8BAFD58101D2A2A"; // Hello LoRaWAN

		public static void Main()
		{
			string response;

			Debug.WriteLine("devMobile.IoT.NetCore.Rak3172.NetworkJoinOTAA starting");

			Debug.WriteLine(String.Join(",", SerialPort.GetPortNames()));

			try
			{
				using (SerialPort serialPort = new SerialPort(SerialPortId))
				{
					// set parameters
					serialPort.BaudRate = 9600;
					serialPort.DataBits = 8;
					serialPort.Parity = Parity.None;
					serialPort.StopBits = StopBits.One;
					serialPort.Handshake = Handshake.None;

					serialPort.ReadTimeout = 5000;

					serialPort.NewLine = "\r\n";

					serialPort.Open();

					// clear out the RX buffer
					response = serialPort.ReadExisting();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");
					Thread.Sleep(500);


					// Set the Working mode to LoRaWAN
					Console.WriteLine("Set Work mode");
					serialPort.WriteLine("AT+NWM=1");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the Region to AS923
					Console.WriteLine("Set Region");
					serialPort.WriteLine("AT+BAND=8-1");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the JoinMode
					Console.WriteLine("Set Join mode");
					serialPort.WriteLine("AT+NJM=1");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the appEUI
					Console.WriteLine("Set App Eui");
					serialPort.WriteLine($"AT+APPEUI={AppEui}");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the appKey
					Console.WriteLine("Set App Key");
					serialPort.WriteLine($"AT+APPKEY={AppKey}");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the Confirm flag
					Console.WriteLine("Set Confirm off");
					serialPort.WriteLine("AT+CFM=0");
					// Read the blank line
					response = serialPort.ReadLine();
					// Read the response
					response = serialPort.ReadLine(); 
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Join the network
					Console.WriteLine("Start Join");
					serialPort.WriteLine("AT+JOIN=1:0:10:2");

					// Read the blank line
					response = serialPort.ReadLine();

					// Read the Result
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					Thread.Sleep(10000);

					// Read the +EVT:JOINED
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					while (true)
					{
						Console.WriteLine("Sending");
						serialPort.WriteLine($"AT+SEND={MessagePort}:{Payload}");

						// Read the blank line
						response = serialPort.ReadLine();

						// Read the result
						Console.WriteLine("Send result");
						response = serialPort.ReadLine();
						Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

						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.

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

The RAK3172 command format is quite different from other modules I have used e.g. Requesting the firmware version information

  • TX- AT+VER=?
  • RX- Blank Line
  • RX- V1.0.2
  • RX- OK

Requesting the APPEUI

  • TX- AT+APPEUI=?
  • RX- 1122334455667788
  • RX- Blank line
  • RX- OK

I think the RAK3172 module ships with a default DEVEUI so in this code and my library I have assumed it will be configured as part of a “provisioning” process.

.NET Core Seeed LoRaE5 LoRaWAN library Part2

Nasty OTAA connect

After getting basic connectivity for my Seeed LoRa-E5 test rig sorted I used RAK7246G LPWAN Developer Gateway on my bookcase to connect to The Things Network(TTN)

Seeed LoRa-E5 Development kit connected to Gove bas shield on a Raspberry PI3

My Over the Air Activation (OTAA) implementation is very “nasty” I have assumed that there would be no timeouts or failures and I only send one BCD message “48656c6c6f204c6f526157414e” which is “hello LoRaWAN”.

The code just sequentially steps through the necessary configuration to join the TTN network with a suitable delay after each command is sent. There also appeared to be quite a variation in response times, especially for joining the network(most probably network related) and the progress of sending a message.

//---------------------------------------------------------------------------------
// Copyright (c) September 2021, devMobile Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
//---------------------------------------------------------------------------------
namespace devMobile.IoT.NetCore.SeeedLoRaE5.NetworkJoinOTAA
{
	using System;
	using System.Diagnostics;
	using System.IO.Ports;
	using System.Threading;

	class Program
	{
		private const string SerialPortId = "/dev/ttyS0";

		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()
		{
			string response;

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

			Debug.WriteLine(String.Join(",", SerialPort.GetPortNames()));

			try
			{
				using (SerialPort serialDevice = new SerialPort(SerialPortId))
				{
					// set parameters
					serialDevice.BaudRate = 9600;
					serialDevice.Parity = Parity.None;
					serialDevice.StopBits = StopBits.One;
					serialDevice.Handshake = Handshake.None;
					serialDevice.DataBits = 8;

					serialDevice.ReadTimeout = 10000;

					serialDevice.NewLine = "\r\n";

					serialDevice.Open();

					// clear out the RX buffer
					serialDevice.ReadExisting();
					response = serialDevice.ReadExisting();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");
					Thread.Sleep(500);

					// Set the Region to AS923
					serialDevice.WriteLine("AT+DR=AS923\r\n");
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Set the Join mode
					serialDevice.WriteLine("AT +MODE=LWOTAA\r\n");
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Set the appEUI
					serialDevice.WriteLine($"AT+ID=AppEui,\"{AppEui}\"\r\n");
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Set the appKey
					serialDevice.WriteLine($"AT+KEY=APPKEY,{AppKey}\r\n");
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Set the port number
					serialDevice.WriteLine($"AT+PORT={MessagePort}\r\n");
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Join the network
					serialDevice.WriteLine("AT+JOIN\r\n");

					// Join start
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// JOIN normal
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					Thread.Sleep(5000);

					// network joined
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Net ID
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					// Join done
					response = serialDevice.ReadLine();
					Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

					while (true)
					{
						Debug.WriteLine("Sending");

						serialDevice.WriteLine($"AT+MSGHEX=\"{Payload}\"\r\n");

						// Start
						response = serialDevice.ReadLine();
						Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

						// Fpending
						response = serialDevice.ReadLine();
						Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

						//Read metrics
						response = serialDevice.ReadLine();
						Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

						//Done
						response = serialDevice.ReadLine();
						Debug.WriteLine($"Response :{response.Trim()} bytes:{response.Length}");

						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.

Visual Studio debugger output window showing network join and sensing a message

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

The Things Industries Live Data view showing network join and sensing a message

Most of the LoRaWAN modems I have worked with reply “OK” when a command is successful. The SeeedLoRa-E5 often returns the payload of the request in the response which makes the code a little bit more complex.

AppEui command structure in AT Command documentation

For example the AppEui can be passed in as “00:00:00:00:00:00:00:00” or “0000000000000000” but in the response the format is always “00:00:00:00:00:00:00:00”

.NET Core RAK811 LoRaWAN library Part2

Nasty OTAA connect

After getting basic connectivity for my IoT LoRa Node pHAT for Raspberry Pi test rig sorted I wanted to see if I could get the device connected to The Things Network(TTN) via the RAK7246G LPWAN Developer Gateway on my bookcase.

IoT LoRa Node pHAT for Raspberry Pi mounted on a Raspberry PI3

My Over the Air Activation (OTAA) implementation is very “nasty” I have assumed that there would be no timeouts or failures and I only send one BCD message “48656c6c6f204c6f526157414e” which is “hello LoRaWAN”.

The code just sequentially steps through the necessary configuration to join the TTN network with a suitable delay after each command is sent. I had some problems with re-opening the serial port if my application had previously failed with an uncaught exception. After some experimentation I added some code to ensure the port was closed when an exception occurred.

//---------------------------------------------------------------------------------
// Copyright (c) September 2021, devMobile Software
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
//---------------------------------------------------------------------------------
namespace devMobile.IoT.NetCore.Rak811.NetworkJoinOTAA
{
	using System;
	using System.Diagnostics;
	using System.IO.Ports;
	using System.Threading;

	public class Program
	{
		private const string SerialPortId = "/dev/ttyS0";
		private const string AppEui = "...";
		private const string AppKey = "...";
		private const byte MessagePort = 1;
		private const string Payload = "A0EEE456D02AFF4AB8BAFD58101D2A2A"; // Hello LoRaWAN

		public static void Main()
		{
			string response;

			Debug.WriteLine("devMobile.IoT.NetCore.Rak811.NetworkJoinOTAA starting");

			Debug.WriteLine(String.Join(",", SerialPort.GetPortNames()));

			try
			{
				using (SerialPort serialPort = new SerialPort(SerialPortId))
				{
					// set parameters
					serialPort.BaudRate = 9600;
					serialPort.DataBits = 8;
					serialPort.Parity = Parity.None;
					serialPort.StopBits = StopBits.One;
					serialPort.Handshake = Handshake.None;

					serialPort.ReadTimeout = 5000;

					serialPort.NewLine = "\r\n";

					serialPort.Open();

					// clear out the RX buffer
					response = serialPort.ReadExisting();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");
					Thread.Sleep(500);

					// Set the Working mode to LoRaWAN
					Console.WriteLine("Set Work mode");
					serialPort.WriteLine("at+set_config=lora:work_mode:0");
					Thread.Sleep(5000);
					response = serialPort.ReadExisting();
					response = response.Trim('\0');
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the Region to AS923
					Console.WriteLine("Set Region");
					serialPort.WriteLine("at+set_config=lora:region:AS923");
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the JoinMode
					Console.WriteLine("Set Join mode");
					serialPort.WriteLine("at+set_config=lora:join_mode:0");
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the appEUI
					Console.WriteLine("Set App Eui");
					serialPort.WriteLine($"at+set_config=lora:app_eui:{AppEui}");
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the appKey
					Console.WriteLine("Set App Key");
					serialPort.WriteLine($"at+set_config=lora:app_key:{AppKey}");
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					// Set the Confirm flag
					Console.WriteLine("Set Confirm off");
					serialPort.WriteLine("at+set_config=lora:confirm:0");
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");
					
					// Join the network
					Console.WriteLine("Start Join");
					serialPort.WriteLine("at+join");
					Thread.Sleep(10000);
					response = serialPort.ReadLine();
					Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

					while (true)
					{
						Console.WriteLine("Sending");
						serialPort.WriteLine($"at+send=lora:{MessagePort}:{Payload}");
						Thread.Sleep(1000);

						// The OK
						Console.WriteLine("Send result");
						response = serialPort.ReadLine();
						Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

						// The Signal strength information etc.
						Console.WriteLine("Network confirmation");
						response = serialPort.ReadLine();
						Debug.WriteLine($"RX :{response.Trim()} bytes:{response.Length}");

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

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

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

The Things Industries Live Data Tab showing my device connecting and sending messages

The RAK811 doesn’t fully implement the AS923 specification but I have a work around detailed in another post.

Security Camera Azure IoT Hub Image upload

The final two projects of this series both upload images to the Azure Storage account associated with an Azure IoT Hub. One project uses a Timer to upload pictures with a configurable delay. The other uploads an image every time a General Purpose Input Output(GPIO) pin on the Raspberry PI3 is strobed.

Uniview IPC3635SB-ADZK-I0 Security camera test rig with Raspberry PI and PIR motion detector

I tried to keep the .Net Core 5 console applications as simple as possible, they download an image from the camera “snapshot” endpoint (In this case http://10.0.0.47:85/images/snapshot.jpg), save it to the local filesystem and then upload it.

The core of the two applications is the “upload” image method, which is called by a timer or GPIO pin EventHandler

private static async void ImageUpdateTimerCallback(object state)
{
	CommandLineOptions options = (CommandLineOptions)state;
	DateTime requestAtUtc = DateTime.UtcNow;

	// Just incase - stop code being called while retrival of the photo already in progress
	if (cameraBusy)
	{
		return;
	}
	cameraBusy = true;

	Console.WriteLine($"{requestAtUtc:yy-MM-dd HH:mm:ss} Image up load start");

	try
	{
		// First go and get the image file from the camera onto local file system
		using (var client = new WebClient())
		{
			NetworkCredential networkCredential = new NetworkCredential()
			{
				UserName = options.UserName,
				Password = options.Password
			};

			client.Credentials = networkCredential;

			await client.DownloadFileTaskAsync(new Uri(options.CameraUrl), options.LocalFilename);
		}

		// Then open the file ready to stream ito upto storage account associated with Azuure IoT Hub
		using (FileStream fileStreamSource = new FileStream(options.LocalFilename, FileMode.Open))
		{
			var fileUploadSasUriRequest = new FileUploadSasUriRequest
			{
				BlobName = string.Format("{0:yyMMdd}/{0:yyMMddHHmmss}.jpg", requestAtUtc)
			};

			// Get the plumbing sorted for where the file is going in Azure Storage
			FileUploadSasUriResponse sasUri = await azureIoTCentralClient.GetFileUploadSasUriAsync(fileUploadSasUriRequest);
			Uri uploadUri = sasUri.GetBlobUri();

			try
			{
				var blockBlobClient = new BlockBlobClient(uploadUri);

				var response = await blockBlobClient.UploadAsync(fileStreamSource, new BlobUploadOptions());

				var successfulFileUploadCompletionNotification = new FileUploadCompletionNotification()
				{
					// Mandatory. Must be the same value as the correlation id returned in the sas uri response
					CorrelationId = sasUri.CorrelationId,

					// Mandatory. Will be present when service client receives this file upload notification
					IsSuccess = true,

					// Optional, user defined status code. Will be present when service client receives this file upload notification
					StatusCode = 200,

					// Optional, user-defined status description. Will be present when service client receives this file upload notification
					StatusDescription = "Success"
				};

				await azureIoTCentralClient.CompleteFileUploadAsync(successfulFileUploadCompletionNotification);
			}
			catch (Exception ex)
			{
				Console.WriteLine($"Failed to upload file to Azure Storage using the Azure Storage SDK due to {ex}");

				var failedFileUploadCompletionNotification = new FileUploadCompletionNotification
				{
					// Mandatory. Must be the same value as the correlation id returned in the sas uri response
					CorrelationId = sasUri.CorrelationId,

					// Mandatory. Will be present when service client receives this file upload notification
					IsSuccess = false,

					// Optional, user-defined status code. Will be present when service client receives this file upload notification
					StatusCode = 500,

					// Optional, user defined status description. Will be present when service client receives this file upload notification
					StatusDescription = ex.Message
				};

				await azureIoTCentralClient.CompleteFileUploadAsync(failedFileUploadCompletionNotification);
			}
		}

		TimeSpan uploadDuration = DateTime.UtcNow - requestAtUtc;

		Console.WriteLine($"{requestAtUtc:yy-MM-dd HH:mm:ss} Image up load done. Duration:{uploadDuration.TotalMilliseconds:0.} mSec");
	}
	catch (Exception ex)
	{
		Console.WriteLine($"Camera image upload process failed {ex.Message}");
	}
	finally
	{
		cameraBusy = false;
	}
}

I have used Azure DeviceClient UploadToBlobAsync in other projects and it was a surprise to see it deprecated and replaced with GetFileUploadSasUriAsync and GetBlobUri with sample code from the development team.

string blobName = string.Format("{0:yyMMdd}/{0:yyMMddHHmmss}.jpg", requestAtUtc);

azureIoTCentralClient.UploadToBlobAsync(blobName, fileStreamSource);

It did seem to take a lot of code to implement what was previously a single line (I’m going try and find out why this method has been deprecated)

TImer application image uploader

Using Azure Storage Explorer I could view and download the images uploaded by the application(s) running on my development machine and Raspberry PI

Azure Storage Displaying most recent image uploaded by a RaspberryPI device

After confirming the program was working I used the excellent RaspberryDebugger to download the application and debug it on my Raspberry PI 3 running the Raspberry PI OS.

Now that the basics are working my plan is to figure out how to control the camera using Azure IoT Hub method calls, display live Real Time Streaming Protocol(RTSP) using Azure IoT Hub Device Streams, upload images to Azure Cognitive Services for processing and use ML.Net to process them locally.

Security Camera ONVIF Capabilities

The ONVIF specification standardises the network interface (the network layer) of network video products. It defines a communication framework based on relevant IETF and Web Services standards including security and IP configuration requirements.

After discovering a device the next step was to query it to determine its capabilities. I had some issues with .Net Core 5 application configuring the Windows Communication Foundation(WCF) to use Digest authentication (RFC2617) credentials on all bar the device management service client.

This .Net Core 5 console application queries the device management service (ONVID application programmers guide) to get the capabilities of the device then calls the media, imaging and pan tilt zoom services and displays the results.

I generated the client services using the Microsoft WCF Web Service Reference Provider.

Connected Services management dialog

The Uniform Resource Locators(URL) and namespace prefixes for each generated service are configured in the ConnectedService.json file.

First step configuring a WCF Service

Initially I used a devMobile.IoT.SecurityCameraClient prefix but after some experimentation changed to OnvifServices.

Second step configuring a WCF Service

For testing I selected “Generated Synchronous Operations” as they are easier to use in a console application while exploring the available functionality.

Third step configuring a WCF Service

The WSDL generated a number of warnings so I inspected the WSDL to see if the were easy to fix. I did consider copying the WSDL to my development box but it didn’t appear to be worth the effort.

SVCUtil warning messages about invalid Onvif WSDL

For this application I’m using the CommandLineParser NuGet package to parse and validate the client, username and password configured in the debugger tab.

Required Nuget packages
private static async Task ApplicationCore(CommandLineOptions options)
{
   Device deviceClient;
   ImagingPortClient imagingPortClient;
   MediaClient mediaClient;
   PTZClient panTiltZoomClient;

   var messageElement = new TextMessageEncodingBindingElement()
   {
      MessageVersion = MessageVersion.CreateVersion(EnvelopeVersion.Soap12, AddressingVersion.None),
      WriteEncoding = Encoding.UTF8
    };

    HttpTransportBindingElement httpTransportNoPassword = new HttpTransportBindingElement();
    CustomBinding bindingHttpNoPassword = new CustomBinding(messageElement, httpTransportNoPassword);
         
    HttpTransportBindingElement httpTransport = new HttpTransportBindingElement()
    {
       AuthenticationScheme = AuthenticationSchemes.Digest
    };
    CustomBinding bindingHttpPassword = new CustomBinding(messageElement, httpTransport);

    try
    {
       // Setup the imaging porting binding, use TLS, and ignore certificate errors
       deviceClient = new DeviceClient(bindingHttpNoPassword, new EndpointAddress($"http://{options.CameraUrl}/onvif/devicemgmt"));

       GetCapabilitiesResponse capabilitiesResponse = await deviceClient.GetCapabilitiesAsync(new GetCapabilitiesRequest(new CapabilityCategory[] { CapabilityCategory.All }));

       Console.WriteLine("Device capabilities");
       Console.WriteLine($"  Device: {capabilitiesResponse.Capabilities.Device.XAddr}");
       Console.WriteLine($"  Events: {capabilitiesResponse.Capabilities.Events.XAddr}"); // Not interested in events for V1
       Console.WriteLine($"  Imaging: {capabilitiesResponse.Capabilities.Imaging.XAddr}");
       Console.WriteLine($"  Media: {capabilitiesResponse.Capabilities.Media.XAddr}");
       Console.WriteLine($"  Pan Tilt Zoom: {capabilitiesResponse.Capabilities.PTZ.XAddr}");
       Console.WriteLine();
       ...
       Console.WriteLine($"Video Source Configuration");
       foreach (OnvifServices.Media.VideoSourceConfiguration videoSourceConfiguration in videoSourceConfigurations.Configurations)
      {
         Console.WriteLine($" Name: {videoSourceConfiguration.Name}");
         Console.WriteLine($" Token: {videoSourceConfiguration.token}");
         Console.WriteLine($" UseCount: {videoSourceConfiguration.UseCount}");
         Console.WriteLine($" Bounds: {videoSourceConfiguration.Bounds.x}:{videoSourceConfiguration.Bounds.y} {videoSourceConfiguration.Bounds.width}:{videoSourceConfiguration.Bounds.height}");
         Console.WriteLine($" View mode: {videoSourceConfiguration.ViewMode}");
      }
   }
   catch (Exception ex)
   {
      Console.WriteLine(ex.Message);
   }

   Console.WriteLine();
   Console.WriteLine("Press <enter> to exit");
   Console.ReadLine();
}

I had to do a bit of “null checking” as often if a feature wasn’t supported the root node was null. I need to get a selection of cameras (especially one with pan/tilt/zoom) to check that I’m processing the responses from the device correctly.

Console application output showing capabilities of Uniview device

After confirming the program was working on my development box I used the excellent RaspberryDebugger to download the application and run it on a Raspberry PI 3 running the Raspberry PI OS.

Security Camera ONVIF Discovery

The ONVIF specification standardises the network interface (the network layer) of network video products. It defines a communication framework based on relevant IETF and Web Services standards including security and IP configuration requirements. ONVIF uses Web Services Dynamic Discovery (WS-Discovery) to locate devices on the local network which operates over UDP port 3702 and uses IP multicast address 239.255.255.250.

The first issue was that WS-Discovery is not currently supported by the .Net Core Windows Communication Foundation(WCF) implementation CoreWCF(2021-08). So I built a proof of concept(PoC) client which used UDP to send and receive XML messages (WS-Discovery specification) to “probe” the local network.

My .Net Core 5 console application enumerates the host device’s network interfaces, then sends a “probe” message and waits for responses. The ONVID application programmers guide specifies the format of the “probe” request and response messages (One of the namespace prefixes in the sample is wrong). The client device can return its name and details of it’s capabilities in the response. Currently I only need the IP addresses of the cameras but if more information was required I would use the XML Serialisation functionality of .Net Core to generate the requests and unpack the responses.

class Program
{
	// From https://specs.xmlsoap.org/ws/2005/04/discovery/ws-discovery.pdf & http://www.onvif.org/wp-content/uploads/2016/12/ONVIF_WG-APG-Application_Programmers_Guide-1.pdf
	const string WSDiscoveryProbeMessages =
		"<?xml version = \"1.0\" encoding=\"UTF-8\"?>" +
		"<e:Envelope xmlns:e=\"http://www.w3.org/2003/05/soap-envelope\" " +
			"xmlns:w=\"http://schemas.xmlsoap.org/ws/2004/08/addressing\" " +
			"xmlns:d=\"http://schemas.xmlsoap.org/ws/2005/04/discovery\" " +
			"xmlns:dn=\"http://www.onvif.org/ver10/network/wsdl\"> " +
				"<e:Header>" +
					"<w:MessageID>uuid:{0}</w:MessageID>" +
					"<w:To e:mustUnderstand=\"true\">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To> " +
					"<w:Action mustUnderstand=\"true\">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action> " +
				"</e:Header> " +
				"<e:Body> " +
					"<d:Probe> " +
						"<d:Types>dn:NetworkVideoTransmitter</d:Types>" +
					"</d:Probe> " +
				"</e:Body> " +
		"</e:Envelope>";

	static async Task Main(string[] args)
	{
		List<UdpClient> udpClients = new List<UdpClient>();

		foreach (var networkInterface in NetworkInterface.GetAllNetworkInterfaces())
		{
			Console.WriteLine($"Name {networkInterface.Name}");
			foreach (var unicastAddress in networkInterface.GetIPProperties().UnicastAddresses)
			{
				if (unicastAddress.Address.AddressFamily == AddressFamily.InterNetwork)
				{
					var udpClient = new UdpClient(new IPEndPoint(unicastAddress.Address, 0)) { EnableBroadcast = true };

					udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, 5000);

					udpClients.Add(udpClient);
				}
			}
		}

	var multicastEndpoint = new IPEndPoint(IPAddress.Parse("239.255.255.250"), 3702);

		foreach (UdpClient udpClient in udpClients)
		{
			byte[] message = UTF8Encoding.UTF8.GetBytes(string.Format(WSDiscoveryProbeMessages, Guid.NewGuid().ToString()));

			try
			{
				await udpClient.SendAsync(message, message.Length, multicastEndpoint);

				IPEndPoint remoteEndPoint = null;

				while(true)
				{				
					message = udpClient.Receive(ref remoteEndPoint);

					Console.WriteLine($"IPAddress {remoteEndPoint.Address}");
					Console.WriteLine(UTF8Encoding.UTF8.GetString(message));

					Console.WriteLine();
				}
			}
			catch (SocketException sex)
			{
				Console.WriteLine($"Probe failed {sex.Message}");
			}
		}

		Console.WriteLine("Press enter to <exit>");
		Console.ReadKey();
	}
}

After confirming the program was working I used the excellent RaspberryDebugger to download the application and debug it on a Raspberry PI 3 running the Raspberry PI OS.

Security Camera HTTP Image download

As part of a contract a customer sent me a Uniview IPC3635SB-ADZK-I0 Security camera for a proof of concept(PoC) project. Before the PoC I wanted to explore the camera functionality in more depth, especially how to retrieve individual images from the camera, remotely control it’s zoom, focus, pan, tilt etc.. I’m trying to source a couple of other vendors’ security cameras with remotely controllable pan and tilt for testing.

Uniview IPC3635SB-ADZK-I0 Security camera

It appears that many cameras support retrieving the latest image with an HyperText Transfer Protocol (HTTP) GET so that looked like a good place to start. For the next couple of posts the camera will be sitting on the bookcase in my office looking through the window at the backyard.

Unv camera software live view of my backyard

One thing I did notice (then confirmed with Telerik Fiddler and in the camera configuration) was that the camera was configured to use Digest authentication(RFC 2069) which broke my initial attempt with a Universal Windows Platform(UWP) application.

Telerik Fiddler showing 401 authorisation challenge

My .Net Core 5 console application is as simple possible, it just downloads an image from the camera “snapshot” endpoint (In this case http://10.0.0.47:85/images/snapshot.jpg) and saves it to the local filesystem.

class Program
{
	static async Task Main(string[] args)
	{
		await Parser.Default.ParseArguments<CommandLineOptions>(args)
			.WithNotParsed(HandleParseError)
			.WithParsedAsync(ApplicationCore);
	}

	private static async Task ApplicationCore(CommandLineOptions options)
	{
		Console.WriteLine($"Camera:{options.CameraUrl} UserName:{options.UserName} filename:{options.Filename}");

		using (var client = new WebClient())
		{
			NetworkCredential networkCredential = new NetworkCredential()
			{
				UserName = options.UserName,
				Password = options.Password
			};

			client.Credentials = networkCredential;

			try
			{
				await client.DownloadFileTaskAsync(new Uri(options.CameraUrl), options.Filename);
			}
			catch (Exception ex)
			{
				Console.WriteLine($"File download failed {ex.Message}");
			}
		}

		Console.WriteLine("Press <enter> to exit");
		Console.ReadLine();
	}

	private static void HandleParseError(IEnumerable<Error> errors)
	{
		if (errors.IsVersion())
		{
			Console.WriteLine("Version Request");
			return;
		}

		if (errors.IsHelp())
		{
			Console.WriteLine("Help Request");
			return;
		}
		Console.WriteLine("Parser Fail");
	}
}

After confirming the program was working I used the excellent RaspberryDebugger to download the application and debug it on a Raspberry PI 3 running the Raspberry PI OS.

Visual Studio 2019 Debug Output showing application download process

Once the application had finished running on the device I wanted to check that the file was on the local filesystem. I used Putty to connect to the Raspberry PI then searched for LatestImage.jpg.

Linux find utility displaying the location of the downloaded file

I though about using a utility like scp to download the image file but decided (because I have been using Microsoft Window since WIndows 286) to install xrdp an open-source Remote Desktop Protocol(RDP) server so I could use a Windows 10 RDP client.

xrdp login screen
xrdp home screen
xrdp file manager display files in application deployment directory
Raspberry PI OS default image view

Now that the basics are working my plan is to figure out how to control the camera, display live video with the Real Time Streaming Protocol(RTSP) upload images to Azure Cognitive Services for processing and use ML.Net to process them locally.

This post was about selecting the tooling I’m comfortable with and configuring my development environment so they work well together. The next step will be using Open Network Video Interface Forum (ONVIF) to discover, determine the capabilities of and then control the camera (for this device just zoom and focus).

Azure IoT Central Connectivity Part4

The Things Network(TTN) Friendly Commands

I have built a several Proof of Concept(PoC) applications (Azure IoT Central Basic Telemetry, Basic Commands, and Request Commands) to explore to how an Azure IoT Central integration with TTN could work. This blog post is about how to configure queued and non queued Cloud to Device(C2D) Commands with request parameters so they should work with my TTN Message Queue Telemetry Transport(MQTT) Data API connector.

I have focused on commands with Analog values but the same approach should be valid for other parameter types like Boolean, Date, DateTime, Double, Duration, Enumeration, Float, Geopoint, Vector, Integer, Long, String, and Time.

Multiple versions of my Azure IoT Central templates

There was a lot of “trial and error” (26 template versions) required to figure out how to configure commands and queued commands so they can and used in TTN downlink payloads.

{
  "end_device_ids": {
    "device_id": "dev1",
    "application_ids": {
      "application_id": "app1"
    },
    "dev_eui": "4200000000000000",
    "join_eui": "4200000000000000",
    "dev_addr": "00E6F42A"
  },
  "correlation_ids": [
    "my-correlation-id",
    "..."
  ],
  "downlink_ack": {
    "session_key_id": "AWnj0318qrtJ7kbudd8Vmw==",
    "f_port": 15,
    "f_cnt": 11,
    "frm_payload": "....",
    "decoded_payload": 
    {
      "Value_0":"1.23"
      ...
    }
    "confirmed": true,
    "priority": "NORMAL",
    "correlation_ids": [
      "my-correlation-id",
      "..."
    ]
  }
}

My Azure IoT Central client application displays the generated message message including the decoded_payload field which is used by the built in Cayenned Low Power Protocol(LPP) decoder/encoder and other custom encoders/decoders.

Azure IoT Central commands for TTN/TTI integration

From the “Device Commands” form I can send commands and a queued commands which have float parameters or object parameters which contain one or more float values in a JSON payload.

For commands which call the methodHander which was been registered by calling SetMethodDefaultHandlerAsync the request payload can be JSON or plain text. If the payload is valid JSON it is “grafted”(couldn’t think of a better word) into the decoded_payload field. If the payload is not valid a JSON object with the method name as the “name” and the text payload as the value is added the decoded_payload.

private static async Task<MethodResponse> MethodCallbackDefaultHandler(MethodRequest methodRequest, object userContext)
{
   AzureIoTMethodHandlerContext receiveMessageHandlerConext = (AzureIoTMethodHandlerContext)userContext;

   Console.WriteLine($"Default handler method {methodRequest.Name} was called.");

   Console.WriteLine($"Payload:{methodRequest.DataAsJson}");
   Console.WriteLine();

   if (string.IsNullOrWhiteSpace(methodRequest.Name))
   {
      Console.WriteLine($"   Method Request Name null or white space");
      return new MethodResponse(400);
   }

   string payloadText = Encoding.UTF8.GetString(methodRequest.Data);
   if (string.IsNullOrWhiteSpace(payloadText))
   {
       Console.WriteLine($"   Payload null or white space");
       return new MethodResponse(400);
   }

   // At this point would check to see if Azure DeviceClient is in cache, this is so nasty
   if ( String.Compare( methodRequest.Name, "Analog_Output_1", true) ==0 )
   {
      Console.WriteLine($"   Device not found");
      return new MethodResponse(UTF8Encoding.UTF8.GetBytes("Device not found"), 404);
   }

   JObject payload;

   if (IsValidJSON(payloadText))
   {
      payload = JObject.Parse(payloadText);
   }
   else
   {
      payload = new JObject
      {
         { methodRequest.Name, payloadText }
      };
   }

   string downlinktopic = $"v3/{receiveMessageHandlerConext.ApplicationId}@{receiveMessageHandlerConext.TenantId}/devices/{receiveMessageHandlerConext.DeviceId}/down/push";

   DownlinkPayload downlinkPayload = new DownlinkPayload()
   {
      Downlinks = new List<Downlink>()
      {
         new Downlink()
         {
            Confirmed = false,
            //PayloadRaw = messageBody,
            PayloadDecoded = payload,
            Priority = DownlinkPriority.Normal,
            Port = 10,
            /*
            CorrelationIds = new List<string>()
            {
               methodRequest.LockToken
            }
            */
         }
      }
   };

   Console.WriteLine($"TTN Topic :{downlinktopic}");
   Console.WriteLine($"TTN downlink JSON :{JsonConvert.SerializeObject(downlinkPayload, Formatting.Indented)}");

   return new MethodResponse(200);
}
Configuration of unqueued Commands with a typed payload
The output of my test harness for a Command for a typed payload
Configuring fields of object payload(JSON)

A JSON request payload also supports downlink messages with more that one value.

The output of my test harness for a Command with an object payload(JSON)

For queued commands which call the ReceiveMessageHandler which has was registered by calling SetReceiveMessageHandler the request payload is JSON or plain text.

private async static Task ReceiveMessageHandler(Message message, object userContext)
{
   AzureIoTMessageHandlerContext receiveMessageHandlerConext = (AzureIoTMessageHandlerContext)userContext;

   Console.WriteLine($"ReceiveMessageHandler handler method was called.");

   Console.WriteLine($" Message ID:{message.MessageId}");
   Console.WriteLine($" Message Schema:{message.MessageSchema}");
   Console.WriteLine($" Correlation ID:{message.CorrelationId}");
   Console.WriteLine($" Lock Token:{message.LockToken}");
   Console.WriteLine($" Component name:{message.ComponentName}");
   Console.WriteLine($" To:{message.To}");
   Console.WriteLine($" Module ID:{message.ConnectionModuleId}");
   Console.WriteLine($" Device ID:{message.ConnectionDeviceId}");
   Console.WriteLine($" User ID:{message.UserId}");
   Console.WriteLine($" CreatedAt:{message.CreationTimeUtc}");
   Console.WriteLine($" EnqueuedAt:{message.EnqueuedTimeUtc}");
   Console.WriteLine($" ExpiresAt:{message.ExpiryTimeUtc}");
   Console.WriteLine($" Delivery count:{message.DeliveryCount}");
   Console.WriteLine($" InputName:{message.InputName}");
   Console.WriteLine($" SequenceNumber:{message.SequenceNumber}");

   foreach (var property in message.Properties)
   {
      Console.WriteLine($"   Key:{property.Key} Value:{property.Value}");
   }

   Console.WriteLine($" Content encoding:{message.ContentEncoding}");
   Console.WriteLine($" Content type:{message.ContentType}");
   string payloadText = Encoding.UTF8.GetString(message.GetBytes());
   Console.WriteLine($" Content:{payloadText}");
   Console.WriteLine();

   if (!message.Properties.ContainsKey("method-name"))
   {
      await receiveMessageHandlerConext.AzureIoTHubClient.RejectAsync(message);
      Console.WriteLine($"   Property method-name not found");
      return;
   }

   string methodName = message.Properties["method-name"];
   if (string.IsNullOrWhiteSpace( methodName))
   {
      await receiveMessageHandlerConext.AzureIoTHubClient.RejectAsync(message);
      Console.WriteLine($"   Property null or white space");
      return;
   }

   if (string.IsNullOrWhiteSpace(payloadText))
   {
      await receiveMessageHandlerConext.AzureIoTHubClient.RejectAsync(message);
      Console.WriteLine($"   Payload null or white space");
      return;
   }

   JObject payload;

   if (IsValidJSON(payloadText))
   {
      payload = JObject.Parse(payloadText);
   }
   else
   {
      payload = new JObject
      {
         { methodName, payloadText }
      };
   }

   string downlinktopic = $"v3/{receiveMessageHandlerConext.ApplicationId}@{receiveMessageHandlerConext.TenantId}/devices/{receiveMessageHandlerConext.DeviceId}/down/push";

   DownlinkPayload downlinkPayload = new DownlinkPayload()
   {
      Downlinks = new List<Downlink>()
      {
         new Downlink()
         {
            Confirmed = false,
            //PayloadRaw = messageBody,
            PayloadDecoded = payload,
            Priority = DownlinkPriority.Normal,
            Port = 10,
            CorrelationIds = new List<string>()
            {
               message.LockToken
            }
         }
      }
   };

   Console.WriteLine($"TTN Topic :{downlinktopic}");
   Console.WriteLine($"TTN downlink JSON :{JsonConvert.SerializeObject(downlinkPayload, Formatting.Indented)}");

   //await receiveMessageHandlerConext.AzureIoTHubClient.AbandonAsync(message); // message retries
   //await receiveMessageHandlerConext.AzureIoTHubClient.CompleteAsync(message);
   await receiveMessageHandlerConext.AzureIoTHubClient.CompleteAsync(message.LockToken);
   //await receiveMessageHandlerConext.AzureIoTHubClient.RejectAsync(message); // message gone no retry
}

When I initiated an Analog queued command the message handler was invoked with the name of the command capability (Analog_Output_2) in a message property called “method-name”. For a typed parameter the message content was a string representation of the value. For an object parameter the payload contains a JSON representation of the request field(s)

The output of my test harness for a Queued Command with a typed payload

A JSON request payload supports downlink message with more that one value.

The output of my test harness for a Queued Command with an object payload(JSON)

The choice of Value_0, Value_1 (I think they are float64 type) etc. for the decoded_payload is specified in the Cayenne LPP downlink decode/encoder source code.

The context information for both comments and queued commands provides additional information required to construct the MQTT topic for publishing the downlink messages.

For queued commands the correlation_id will contain the message.LockToken so that messages can be Abandoned, Completed or Rejected. The MQTT broker publishes a series of topics so the progress of the transmission of downlink message can be monitored.

If the device is not known the Abandon method will be called immediately. For command messages Completed will be called as soon as the message is “sent”

  • v3/{application id}@{tenant id}/devices/{device id}/down/queued
  • v3/{application id}@{tenant id}/devices/{device id}/down/sent
  • v3/{application id}@{tenant id}/devices/{device id}/down/ack
  • v3/{application id}@{tenant id}/devices/{device id}/down/nack
  • v3/{application id}@{tenant id}/devices/{device id}/down/failed

For queued messages the point in the delivery process where the Abandoned, Completed and Rejected methods will be called will be configurable.

Azure IoT Central Connectivity Part3

Request Commands

I have built a couple of proof of Concept(PoC) applications to explore the Basic Telemetry and Basic Command functionality of Azure IoT Central. This blog post is about queued and non queued Cloud to Device(C2D) Commands with request parameters.

I initially created an Azure IoT Central Device Template with command and telemetry device capabilities.

“Collapsed” Command Request template
Command Request Template digital commands

I tried typed request and object based parameters to explorer how an integration with The Things Network(TTN)/The Things Industries(TTI) using the Message Queue Telemetry Transport(MQTT) interface could work.

Object parameter schema designer

With object based parameters the request JSON could contain more than one value though the validation of user provided information didn’t appear to be as robust.

Object parameter schema definition

I “migrated” my third preconfigured device to the CommandRequest template to see how the commands with Request parameters interacted with my PoC application.

After “migrating” my device I went back and created a Template view so I could visualise the simulated telemetry from my PoC application and provide a way to initiate commands (Didn’t really need four command tiles as they all open the Device commands form).

CommandRequest device template default view

From the Device Commands form I could send commands and a queued commands which had analog or digital parameters.

Device Three Command Tab

When I initiated an Analog non-queued command the default method handler was invoked with the name of the command capability (Analog_Output_1) as the method name and the payload contained a JSON representation of the request values(s). With a typed parameter a string representation of the value was in the message payload. With a typed parameter a string representation of the value was in the message payload rather than JSON.

Console application displaying Analog request and Analog Request queued commands

When I initiated an Analog queued command the message handler was invoked with the name of the command capability (Analog_Output_2) in a message property called “method-name” and the payload contained a JSON representation of the request value(s). With a typed parameter a string representation of the value was in the message payload rather than JSON.

When I initiated a Digital non-queued command the default method handler was invoked with the name of the command capability (Digital_Output_1) as the method name and the payload contained a JSON representation of the request values(s). With a typed parameter a string representation of the value was in the message payload rather than JSON.

Console application displaying Digital request and Digital Request queued commands

When I initiated a Digital queued command the message handler was invoked with the name of the command capability(Digital_Output_2) in a message property called “method-name” and the payload contained a JSON representation of the request value(s). With a typed parameter a string representation of the value was in the message payload rather than JSON.

The validation of user input wasn’t as robust as I expected, with problems selecting checkboxes with a mouse when there were several Boolean fields. I often had to click on a nearby input field and use the TAB button to navigate to the desired checkbox. I also had problems with ISO 8601 format date validation as the built in Date Picker returned a month, day, year date which was not editable and wouldn’t pass validation.

The next logical step would be to look at commands with a Response parameter but as the MQTT interface is The Things Network(TTN) and The Things Industries(TTI) is asynchronous and devices reporting every 5 minutes to a couple of times a day there could be a significant delay between sending a message and receiving an optional delivery confirmation or response.