# Creating Configurable Modules for ABB Ability™ Edge
This is the fourth in a series of tutorials which explain how to accomplish various functions within the context of an ABB Ability™ Edge. On the ABB Ability™ Edge runtime, the proxy and broker modules provided by the Platform are responsible for handling any communication to or from the ABB Ability™ Edge environment. Thus, any code written within custom modules has built-in security and can be relatively simple.
This tutorial will build upon the first “03-remote-commands” module and will demonstrate:
- How to retrieve a module’s configuration upon startup and take actions based on the configuration
- How to listen for any future changes in configuration and change the behavior of the module accordingly
# Before You Start
The tutorial is written in C# using the .Net Core framework. A basic console application will be created. Familiarity with .NET C# is recommended.
Once the application is compiled, Docker will be used to create and push an image to a remote container registry.
Once the image is deployed, the ABB Ability™ Edge runtime is responsible for identifying custom modules to be loaded and then pulling down the appropriate image and creating a container instance. The corresponding code for this tutorial can be found at this ABB Codebits repository under the “04-configurable” folder. The “Configurable” project references the ABB.Ability.IotEdge.CST.Modules.CSharp.Common project, which can be found under the “common” folder in the same repository.
For this tutorial, we are using two MIT-licensed nuget packages: MQTTnet for the sending and receiving of MQTT messages, and Newtonsoft.Json to parse JSON objects.
# Steps
Open the
ABB.Ability.IotEdge.CSTModules.CSharp.Configurable
project in Visual Studio, VS Code, or the IDE of your choice. The point of focus in this tutorial is theConfigurableModule
, which is created on startup in theProgram.cs
class.The
StartModuleAsync
method is similar to previous tutorials, in that it subscribes to the “model in” and “methods in” topics, and aManualResetEventSlim
object is used to keep the container in a running state while the code waits to receive remote commands.There are also a few new private class variables defined at the top of the file. Since this module will be creating multiple simulated devices with unique serial numbers, the
_deviceMap
dictionary is used to map the serial numbers with the object Ids returned after each device is created.The
_configureDevicesLock
object is used to place a lock around the code to create or delete devices, so that multiple conflicting requests cannot occur simultaneously. Finally, since the configuration can specify any number of devices,_devicesCreated
is used to store the number of devices for whichdeviceCreated
messages have been sent.private int _devicesCreated = 0; private readonly object _configureDevicesLock = new object(); private Dictionary<Guid, string> _deviceMap = new Dictionary<Guid, string>();
Next, the
OnApplicationMessageReceived
method is now responsible for handling additional types of messages. If the message is on the “methods in” topic, it will still be handled in the same way byHandleCloudToDeviceMethod
. The “model in” topic is also subscribed to, but can now potentially contain models for both the created devices (to be covered in step 5) and the module itself. In both cases, the message payload will contain an object definition.To determine if the message is for the module, the code looks to see that the message’s topic ends with
abb.ability.configuration
and the object Id in the message payload matches the module’s Id. The full payload will then contain the configuration object definition for the module. This type of message will be sent upon request from the module (in step 1), but also whenever the object definition is changed by an outside source.The code then parses out the
telemetryInterval
andnumberOfDevices
properties from the definition. The interval for telemetry value is not referenced until telemetry is being sent; if the number of devices received does not match the number of created devices, then theConfigureDevices
method is called within a lock statement to add or remove devices until the current quantity matches the requested quantity.if ((e.ApplicationMessage.Topic ?? string.Empty) .EndsWith("abb.ability.configuration", StringComparison.InvariantCultureIgnoreCase) && jsonObj["objectId"] != null && jsonObj["objectId"].Value<string>() == this.Configuration.ObjectId) { //if the configuration model for the module is received var requestedDevices = jsonObj["properties"] ["numberOfDevices"]["value"].Value<int>(); _telemetryInterval = jsonObj["properties"] ["telemetryInterval"]["value"].Value<int>(); if (requestedDevices != _devicesCreated) { //if the desired num of devices is different than current val Console.WriteLine($"Module config retrieved: {messageBody}"); lock (_configureDevicesLock) { ConfigureDevices(requestedDevices).Wait(); } } }
The
ConfigureDevices
method takes a parameter of the number of requested devices, and then adds or deletes devices until the number currently created matches the requested number. First, if the current number is less than requested, new messages are sent on the “messages out” topic with a type of “deviceCreated”.The serial number for each device is incremented so that each will be unique. Once the message is published, the
_devicesCreated
variable is incremented:var createDeviceTopic = $"{this.Configuration.MessagesOutTopic}/type=deviceCreated&target="; while (_devicesCreated < requestedDevices) { var serialNumber = GenerateDeviceSerialNumber(_devicesCreated + 1); var fullTopic = createDeviceTopic + serialNumber; var msg = JsonConvert.SerializeObject( new CreateDeviceMessage(DEVICE_TYPE, this.Configuration.ObjectId, serialNumber, "devices", GenerateDeviceTags())); Console.WriteLine($"Publishing message to topic '{fullTopic}': {msg}"); await _mqttClient.PublishAsync(fullTopic, msg, MqttQualityOfServiceLevel.AtLeastOnce).ContinueWith((e) => _devicesCreated++); }
Likewise, if the current number of devices is greater than requested, new messages are sent on the “messages out” topic with a type of “deviceDeleted”. The object Id for the device must be included in the message, so it must be retrieved from
_deviceMap
based on the device’s serial number. Once the message is published, the corresponding entry is removed from_deviceMap
and_devicesCreated
is decremented:var deleteDeviceTopic = $"{this.Configuration.MessagesOutTopic}/type=deviceDeleted"; while (_devicesCreated > requestedDevices) { //find last device ordered by serial number var mostRecentDevice = _deviceMap.OrderByDescending(e => e.Value).FirstOrDefault(); if (mostRecentDevice.Key != null && mostRecentDevice.Key != Guid.Empty) { Console.WriteLine($"Publishing message to topic '{deleteDeviceTopic}&objectId={mostRecentDevice.Key}' with empty payload"); await _mqttClient.PublishAsync($"{deleteDeviceTopic}&objectId={mostRecentDevice.Key}", "", MqttQualityOfServiceLevel.AtLeastOnce).ContinueWith(e => _deviceMap.Remove(mostRecentDevice.Key)); } _devicesCreated--; }
Once the message to create a device is sent, the Device Provisioning Service will take care of creating the device in the IoT Hub and associating it with this module. The confirmation of this is the device’s object definition being delivered on the “model in” topic.
In the
OnApplicationMessageReceived
method, messages on this topic that match the type being used for simulated devices are parsed to add the object Id of the created device to the_deviceMap
dictionary. However, this only occurs if more devices have been created than exist within the dictionary and the dictionary does not already contain the object Id. The device’s object Id is required for timeseries messages, so telemetry will be sent only for those devices existing within_deviceMap
.else if (jsonObj["type"] != null && jsonObj["type"].Value<string>() == DEVICE_TYPE) { var deviceId = Guid.Parse(jsonObj["objectId"].Value<string>()); if (_devicesCreated > _deviceMap.Count && !_deviceMap.ContainsKey(deviceId)) { //if we are waiting on a model from a created device //and this id isn't in the map, add it Console.WriteLine($"Device model received: {messageBody}"); var serialNumber = jsonObj["properties"] ["serialNumber"]["value"].Value<string>(); _deviceMap.Add(deviceId, serialNumber); Console.WriteLine($"Device {serialNumber} " + "added to list of known devices"); } }
The
StartSendingTelemetryAsync
method is still triggered via a remote command but differs from the previous tutorial in that it now sends simulated telemetry data for multiple devices. Note that messages cannot be bundled via the MQTTnet client, but the broker module present in the Edge runtime does have the capability to batch messages before sending them to the cloud in order to reduce latency and bandwidth usage:foreach (var deviceId in _deviceMap.Keys) { var msgs = _telemetryVariables.Select(variable => new MqttApplicationMessage() { QualityOfServiceLevel = MqttQualityOfServiceLevel.ExactlyOnce, Topic = topic, Payload = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject( new TelemetryMessage<double>( deviceId, variable, () => { return Math.Round(rand.NextDouble()*50d, 2); } ))) }).ToArray(); await _mqttClient.PublishAsync(msgs) .ContinueWith(e => Console.WriteLine("Published " + $"{msgs.Length} messages to topic '{topic}'")); }
Previously, the interval between message publication was supplied via a parameter with the remote command invocation. Now, it is dependent on the value of
_telemetryInterval
, which is set and updated whenever the module’s configuration definition is received:try { //wait to send another telemetry message, //unless the stop method has been invoked await Task.Delay(_telemetryInterval * 1000, _cancelTelemetry.Token); } catch (TaskCanceledException) { Console.WriteLine("Timeseries publishing stopped."); break; }
# Running the Sample Module
There are two ways to run this custom module. CST provides types for a given version of Ability Platform. If your CST contact has not already loaded these types into your Ability environment, these types can be manually uploaded to the environment. Once the types are loaded into the instance, any supported Edge setup can be used to run the Edge types provided by the tutorials.
Once you are familiar with the Edge, it is possible to build custom versions of the tutorials. Once a custom modules is created and the type(s) are loaded into an instance, any valid Edge can run the custom type.
# Verify Module Functionality
Once the Edge starts, it should automatically pull the image for this module and start a container using this image.
To verify that telemetry messages are being sent, the logs for this tutorial module can be viewed by running the following command:
docker service logs -f tutorialModule
Within the ABB Ability™ Instance API, to remotely invoke commands a POST request must be made to the endpoint located at
/api/v1/objects/{objectId}/models/{modelId}/methods/{methodName}
. A JSON-formatted payload can be provided but is not required. The values for each parameter can be determined as follows:- objectId: The object Id of the custom module which contains the method.
This can be found by looking at the logs for tutorialModule in the step
above. Upon module startup, the model definitions for the module are
retrieved on the “model in” topic. Look for the object Id can be found in
the device model for the module’s type returned on the
modules/referenceModule/model/desired/abb.ability.device
topic. - modelId: The type of model on which the method is defined. As mentioned
above, the
start
andstop
methods (along with the interval parameter forstart
) are defined in the type definition forabb.ability.device.edge.modules.csharp.sample
. The model for that type isabb.ability.device
and should be the value here. - methodName: The name of the remote command that the user is invoking (in
this case, either
start
orstop
). Teh method name is case sensitive.
- objectId: The object Id of the custom module which contains the method.
This can be found by looking at the logs for tutorialModule in the step
above. Upon module startup, the model definitions for the module are
retrieved on the “model in” topic. Look for the object Id can be found in
the device model for the module’s type returned on the
After invoking the
start
method, view the logs for the tutorial module to verify that the appropriate messages are being received and sent. The interval between sending of telemetry messages should remain at the default of 10 seconds. Telemetry should also be sent for only one device, as defined in the module’s configuration.Next, use the ABB Ability™ Instance API to update the object definition for the module. The current definition can be retrieved by submitting a GET request to the endpoint located at
/api/v1.0/objects/{objectId}/models/{modelId}
.The objectId is the same as in step 5, and the modelId is
abb.ability.configuration
. To update the object definition, either a PATCH or PUT request can be used; PATCH is recommended as the full model definition is not required. On the PATCH request, the same objectId and modelId can be used, and the payload will be a JSON object containing the new desired property values. For example:{ "properties": { "numberOfDevices": { "value": 5 }, "telemetryInterval": { "value": 20 } } }
Once submitted, view the logs for the tutorial module to confirm that the sending interval and number of devices for which telemetry is being sent match the values included in the PATCH request. Following the example above, telemetry should now be sent every 20 seconds for 5 unique devices.
Be sure to invoke the stop method to stop the sending of telemetry.
# Next Steps
The “Inter-module Communication” module demonstrates how messages can be sent to other modules on the same Edge device to perform analytics on the Edge and reduce bandwidth usage from the Edge to the cloud.