Reading configuration settings is one of the most fundamental tasks of any IoT Edge application. We will postulate a configuration categorization and explore how this categorization maps to .NET. Although these examples are specific to Azure IoT Edge applications, the best practices presented here are generic and apply to all other application types.  

Application Configuration

A well-designed application needs to be able to adjust its behavior based on the environment it is running in. And although the definition of the environment can become a matter of debate, there are some commonly accepted standard environments in Software Engineering: Development, Test, Staging and Production. Apparently, some variability exists to the cardinality of these environments, sometimes merging together some of them (eg. Test and Staging), and similarly, an environment can split into more specialized cases.

In reality, besides the environments related to the application lifecycle, an environment can be defined arbitrarily: can be based on geographical location, on physical characteristics, like ambient temperature or water pH, business parameters, like if an item has been sold or not, etc. This diversity of all possible environments makes the environment modeling difficult.  

To shed some light, we'll draw an example from real life, something that almost all people can relate to, cars.

Cars can be one of the best analogy examples one can find, mostly because cars are complex machines that most people have interacted with. Cars carry many well-designed features that many software engineers draw inspiration from when designing things.

Environments

Most modern ECUs (the main computer of a car) are designed to behave differently when in maintenance mode, when the service mechanic plugs in the diagnostic tool. This can be seen as analogous to a Debugging environment, where the TCU architects made sure that the TCU will emit diagnostic information to help the mechanic understand if there are any issues with the car. These environments are called lifecycle environments.

A second category of an environment is the operating environment, the environment in which a car is driven in. Many SUVs allow the drivers to choose between 4x4 and rear wheel drive based on the operating environment traction requirements. Similarly, the vehicle A/C can be turned on and off, according to the environment conditions. You can either define explicitly the environment, via a push button, or infer the environment by reading the sensors; for example infer that it's currently raining by reading the rain sensor. Many of these environment-based decisions have been automated today: the vehicle lights will turn on when it's dark, the A/C will kick-in when it's hot etc. Nevertheless, the point here is that the vehicle designers made the car real-time configurable to conform to the operating environment conditions.

If seen from this standpoint, the application configuration is effectively information that defines an environment!

Device Twin

The long intro and the emphasis to the application configuration definition is necessary, because having a clear understanding of what the configuration information is and what is not, affects the abstraction decisions we make that down the line become the difference between a well-designed and a poorly-designed application.

The notion of a device twin exists in the IoT space. Specifically, in Azure IoT Edge, the definition of the device twin is: "Device twins are JSON documents that store device state information including metadata, configurations, and conditions". The device twin is practically a collection of name-value properties that fall into the two self-explanatory categories, the desired and the reported properties.

Let's go back to the car example. Congrats! You just got hired by an automotive company to write the TCU software of their new connected vehicle car model. Would you store any lifecycle environment information in the device twin? How about the operating environment information, like for example, if it's is currently raining?

The lines can get easily blurred between the device state, the device configuration conditions and metadata.  Using the car analogy, we can draw a clear line to separate the configuration in two major categories:

System configuration
  • System (device) configuration, the configuration that the mechanic (administrator) can change, for example, set the engine idle RPM. The system configuration maps to the lifecycle environment definition. These environments are related to the lifecycle of the vehicle, are clearly defined and changing them usually requires an expert, tools and a vehicle restart.

User configuration
  • User configuration, the configuration that the vehicle drivers (users) can change, for example, the steering wheel height or the radio station. This is easier to achieve through a user interface. The user configuration maps to the operation environment definition. This environment can change dynamically, no restart is required and the vehicle driver (user) has the power to define it.
Note: In IoT, there's always an administrator, and the devices that do not interact with users, don't have user configuration.

By using these definitions, the rest of the story around configuration falls into place:

  1. The system configuration is kept into the system itself, and to change the system configuration, a restart is required, perhaps even a new deployment. The system configuration is enforced, the device either conforms, or fails to start.
  2. On the other hand, the user configuration is persisted in the desired properties of the digital twin. These can change on the fly, and perhaps by external systems, and the device should try to do its best to honor these updates, although this configuration can be seen more like hints.
  3. Finally, all the read-only sensory values are the reported properties of the device twin.
Food for thought: Where would you store the default values of the user configuration?

Let's see now how all these look written in source code.

Reading System Configuration

For system configuration we'll use the following  packages:

  • Microsoft.Extensions.Configuration
  • Microsoft.Extensions.Configuration.Abstractions
  • Microsoft.Extensions.Configuration.CommandLine
  • Microsoft.Extensions.Configuration.EnvironmentVariables
  • Microsoft.Extensions.Configuration.Json

Using these packages we can write a simple application that reads the system information:

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;

namespace Example5
{
    class Program
    {
        // This is the program entry point
        static async Task Main(string[] args)
        {
            // Read the system configuration
            IConfiguration configuration;
            configuration = new ConfigurationBuilder()
                .AddJsonFile("appsettings.json", optional: true)
                .AddEnvironmentVariables()
                .AddCommandLine(args)
                .Build();

            if (configuration["Environment"] == "Debug")
            {
                Console.WriteLine($"Configuring for debugging environment..");
                // Code omitted
            }
            Console.WriteLine("Exiting..");
        }
    }
}
Reading the System Configuration using Microsoft.Extensions.Configuration

The IConfiguration property will hold the configuration settings union, based on the order of composition we define. In this example, we start with an optional local JSON file appsettings.json, then we add the environment variables and the command line arguments. Each time we add a new configuration source, we override any variables with the same name.

A good practice is to keep the hosting environment configuration last. This allows us change the configuration without redeploying a new file system version.

Running this application with command line arguments makes easier to test our code:

Writing System Configuration

To set this configuration, you can either push an updated appsettings.json file through a new deployment, or set the settings.createOptions.Env section of a module in the deployment manifest:

          "module2": {
            ...
            "settings": {
              "createOptions": {
                "Env": [
                  "DB_SERVER_URL=${DB_SERVER_URL}"
                ]
              }
            }
          }

Reading User Configuration

To read the user configuration from the device twin, we can use this code snippet:

await ModuleClient.OpenAsync();
var twin = await ModuleClient.GetTwinAsync();
if (twin!= null && twin.Properties.Desired.Contains("IntervalInSeconds"))
	int.TryParse(twin.Properties.Desired["IntervalInSeconds"], out IntervalInSeconds);
Reading user configuration from the device twin 

Writing User Configuration  

You have two options to update the user configuration: via deployment manifest or by using the service-side IoT SDK.

The deployment manifest has the properties.desired section per module. Setting this section requires a redeployment and an application restart. You can think this section as the default values of the module twin.

        "module1": {
            "properties.desired": {
                // desired properties of module1
            }
        },

Conversely, you can set the module twin using the Service (cloud) side of the Azure IoT SDK. A complete example can be found here.

To better understand the different categories and their corresponding location throughout the system, we can visualize everything in a single diagram:

The Thermostat Example

Revisiting the original thermostat example we saw in the previous post, now the thermostat code looks like:

using System;
using System.Runtime.Loader;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;

namespace Example6
{
    class Program
    {
        // This is the program entry point
        static async Task Main(string[] args)
        {
            // Read the system configuration
            IConfiguration configuration;
            configuration = new ConfigurationBuilder()
                .AddJsonFile("appsettings.json", optional: true)
                .AddEnvironmentVariables()
                .AddCommandLine(args)
                .Build();

            // Create the cancelation token source
            var cancellationTokenSource = new CancellationTokenSource();
            AssemblyLoadContext.Default.Unloading +=
                (cts) => cancellationTokenSource.Cancel();

            Console.CancelKeyPress +=
                (sender, cts) =>
                {
                    Console.WriteLine("Ctrl+C detected.");
                    cts.Cancel = true;
                    cancellationTokenSource.Cancel();
                };

            // Register the Reset command callback 
            await RegisterCommandCallbackAsync("Reset",
                OnReset,
                cancellationTokenSource.Token);

            // Read the system configuration or default to 1
            int interval;
            if (!int.TryParse(
                configuration["IntervalInSeconds"],
                out interval))
                interval = 1;

            // Create and start the TaskTimer
            TaskTimer taskTimer = new TaskTimer(
                EmitTelemetryMessage,
                TimeSpan.FromSeconds(interval), 
                cancellationTokenSource);

            // A non-blocking call to start the task-timer
            taskTimer.Start();

            // Wait until the app unloads or gets cancelled
            await WhenCancelled(cancellationTokenSource.Token);

            // Let the other threads drain
            Console.WriteLine("Waiting for 2 seconds..");
            await Task.Delay(2 * 1000);

            Console.WriteLine("Exiting..");
        }

        public static Task WhenCancelled(CancellationToken cancellationToken)
        {
            var taskCompletionSource = new TaskCompletionSource<bool>();
            cancellationToken.Register(
                s => ((TaskCompletionSource<bool>)s).SetResult(true),
                taskCompletionSource);
            return taskCompletionSource.Task;
        }
        private static async Task RegisterCommandCallbackAsync(string command,
            Action callback,
            CancellationToken cancellationToken)
        {
            // Perform the command registration
            // Code omitted
            return;
        }

        // A method exposed for RPC
        static void OnReset()
        {
            // Perform a temperature sensor reset
            // Code omitted
        }

        // Emit telemetry message
        static void EmitTelemetryMessage()
        {
            Console.WriteLine($"Sending telemetry message..");
        }
    }
}
So far in this mini-series we have deliberately avoided using the Azure IoT SDK, the idea being, we want to build a hosting environment agnostic application, were the code is not coupled to a specific hosting technology. With this approach, hosting it on the Edge becomes a choice, and helps us to develop most of the code in our preferred development environment.

As alluded to this post, one of the most popular cases for reconfiguring the IoT application is for debugging purposes. In the next post we will explore the best practices on application logging.