Random wanderings through Microsoft Azure esp. PaaS plumbing, the IoT bits, AI on Micro controllers, AI on Edge Devices, .NET nanoFramework, .NET Core on *nix and ML.NET+ONNX
//---------------------------------------------------------------------------------
// 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.
//
// Must have one of following options defined in the project\build definitions
// PAYLOAD_BCD or PAYLOAD_BYTES
// OTAA or ABP
//
// Optional definitions
// CONFIRMED For confirmed messages
// DEVEUI_SET
//
//---------------------------------------------------------------------------------
namespace devMobile.IoT.LoRaWAN.NetCore.RAK3172
{
using System;
using System.IO.Ports;
using System.Threading;
public class Program
{
private const string SerialPortId = "/dev/ttyS0";
private const LoRaClass Class = LoRaClass.A;
private const string Band = "8-1";
private const byte MessagePort = 10;
private static readonly TimeSpan MessageSendTimerDue = new TimeSpan(0, 0, 15);
private static readonly TimeSpan MessageSendTimerPeriod = new TimeSpan(0, 5, 0);
private static Timer MessageSendTimer ;
private const int JoinRetryAttempts = 2;
private const int JoinRetryIntervalSeconds = 10;
#if PAYLOAD_BCD
private const string PayloadBcd = "48656c6c6f204c6f526157414e"; // Hello LoRaWAN in BCD
#endif
#if PAYLOAD_BYTES
private static readonly byte[] PayloadBytes = { 0x48, 0x65 , 0x6c, 0x6c, 0x6f, 0x20, 0x4c, 0x6f, 0x52, 0x61, 0x57, 0x41, 0x4e}; // Hello LoRaWAN in bytes
#endif
public static void Main()
{
Result result;
Console.WriteLine("devMobile.IoT.LoRaWAN.NetCore.RAK3172 RAK3712LoRaWANDeviceClient starting");
Console.WriteLine($"Serial ports:{String.Join(",", SerialPort.GetPortNames())}");
try
{
using (Rak3172LoRaWanDevice device = new Rak3172LoRaWanDevice())
{
result = device.Initialise(SerialPortId, 9600, Parity.None, 8, StopBits.One);
if (result != Result.Success)
{
Console.WriteLine($"Initialise failed {result}");
return;
}
MessageSendTimer = new Timer(SendMessageTimerCallback, device,Timeout.Infinite, Timeout.Infinite);
device.OnJoinCompletion += OnJoinCompletionHandler;
device.OnReceiveMessage += OnReceiveMessageHandler;
#if CONFIRMED
device.OnMessageConfirmation += OnMessageConfirmationHandler;
#endif
#if DEVEUI_SET
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} DevEUI {Config.devEui}");
result = device.DeviceEui(Config.devEui);
if (result != Result.Success)
{
Console.WriteLine($"DevEUI failed {result}");
return;
}
#endif
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Class {Class}");
result = device.Class(Class);
if (result != Result.Success)
{
Console.WriteLine($"Class failed {result}");
return;
}
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Band {Band}");
result = device.Band(Band);
if (result != Result.Success)
{
Console.WriteLine($"Region failed {result}");
return;
}
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} ADR On");
result = device.AdrOn();
if (result != Result.Success)
{
Console.WriteLine($"ADR on failed {result}");
return;
}
#if CONFIRMED
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Confirmed");
result = device.UplinkMessageConfirmationOn();
if (result != Result.Success)
{
Console.WriteLine($"Confirm on failed {result}");
return;
}
#else
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Unconfirmed");
result = device.UplinkMessageConfirmationOff();
if (result != Result.Success)
{
Console.WriteLine($"Confirm off failed {result}");
return;
}
#endif
#if OTAA
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} OTAA");
result = device.OtaaInitialise(Config.JoinEui, Config.AppKey);
if (result != Result.Success)
{
Console.WriteLine($"OTAA Initialise failed {result}");
return;
}
#endif
#if ABP
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} ABP");
result = device.AbpInitialise(Config.DevAddress, Config.NwksKey, Config.AppsKey);
if (result != Result.Success)
{
Console.WriteLine($"ABP Initialise failed {result}");
return;
}
#endif
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join start");
result = device.Join(JoinRetryAttempts, JoinRetryIntervalSeconds);
if (result != Result.Success)
{
Console.WriteLine($"Join failed {result}");
return;
}
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join started");
Thread.Sleep(Timeout.Infinite);
}
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
private static void OnJoinCompletionHandler(bool result)
{
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Join finished:{result}");
if (result)
{
MessageSendTimer.Change(MessageSendTimerDue, MessageSendTimerPeriod);
}
}
private static void SendMessageTimerCallback(object state)
{
Rak3172LoRaWanDevice device = (Rak3172LoRaWanDevice)state;
#if PAYLOAD_BCD
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} port:{MessagePort} payload BCD:{PayloadBcd}");
Result result = device.Send(MessagePort, PayloadBcd );
#endif
#if PAYLOAD_BYTES
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} port:{MessagePort} payload bytes:{Rak3172LoRaWanDevice.BytesToBcd(PayloadBytes)}");
Result result = device.Send(MessagePort, PayloadBytes);
#endif
if (result != Result.Success)
{
Console.WriteLine($"Send failed {result}");
}
}
#if CONFIRMED
private static void OnMessageConfirmationHandler()
{
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Send successful");
}
#endif
private static void OnReceiveMessageHandler(byte port, int rssi, int snr, string payload)
{
byte[] payloadBytes = Rak3172LoRaWanDevice.HexToByes(payload); // Done this way so both conversion methods tested
Console.WriteLine($"{DateTime.UtcNow:hh:mm:ss} Receive Message RSSI:{rssi} SNR:{snr} Port:{port} Payload:{payload} PayLoadBytes:{BitConverter.ToString(payloadBytes)}");
}
}
}
I have added XML Documentation comments which will need some rework, after I have “soak tested” the code for at least a week.
I have also added a method so the DevEUI can be set (intended for use after device firmware has been updated), fixed up my mistake with Binary Coded Decimal(BCD) vs. Hexadecimal strings.
I will also go back and apply the “learnings” from this refactoring to my other LoRaWAN module and platform libraries
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;
}
}
The ONVIFspecification 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.
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.
The ONVIFspecification 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.
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.
As part of a contract a customer sent me a UniviewIPC3635SB-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.
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.
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
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).
My Azure IoT Central client application displays the generated message including the decoded payload field which is used by the built in 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 payloadThe output of my test harness for a Command for a typed payloadConfiguring 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.
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 context information for both comments and queued commands provides additional information required to construct the MQTT topic for publishing the downlink messages.
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”
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.