In the first part, we looked at the architecture for the sample application (GitHub). This time, we will look at the device simulator. To be more precise, this part of the architecture:

Architecture diagram with Container App, Device Provisioning Service and IoT Hub

Device Provisioning Service

DPS enables us to provision a device in IoT Hub on first startup, so we don't have to pre-provision them. To achieve this, we first need a DPS instance, as well as enrollment groups/individual enrollments. In the sample application, we use an enrollment group. This way we can use one key in the Container App, and each executed instance can provision using it.

When defining the enrollments, you can specify the potential IoT Hubs where the new devices can be provisioned. You can utilize different methods for choosing the IoT Hub:

  • Evenly weighted
    • Default, each IoT Hub is equally as likely (uses a hash of the device ID)
  • Lowest latency
    • The IoT Hub with lowest latency to the device is chosen
  • Static
    • IoT Hub is pre-selected for each enrollment
  • Custom
    • Webhook is invoked to make the decision, used to add custom logic

In the sample application, we use the evenly weighted method. We have only one IoT Hub, so it will always be chosen. If you have a use case where IoT devices are deployed around the globe, the lowest latency option can be quite good.

The devices will naturally have to somehow authenticate with DPS to self-provision (also called attestation in DPS). There are three methods for this:

  • X.509 certificates
  • Trusted Platform Module (TPM)
  • Symmetric key

Certificates or TPM are recommended for production scenarios. With certificates, each device will have a leaf certificate with the root certificates setup in DPS. The TPM method requires registering the public part of a device's endorsement key in DPS. These require extra work during device preparation. If using the TPM method, you cannot use enrollment groups since each device has a unique key with no root of trust like X.509 certificates.

Symmetric keys are the easiest approach and great for proof-of-concepts and demos such as this application. We will use them for simplicity's sake in the sample. In production scenarios, certificates or TPM methods are better, though require more work.

One neat thing you can do with enrollment groups/enrollments is that you can specify initial device tags and device twin properties that will be set in IoT Hub when the device is created there. The sample app uses the tags to identify the device's environment and route telemetry to the right Event Hub.

Self-provisioning on startup

When the simulator starts, the first thing it needs to do is provision itself through DPS. To do this, it must authenticate. Since we use symmetric keys, we will first create a derived key with the enrollment group primary key and the device ID:

byte[] enrollmentKeyBytes = Convert.FromBase64String(enrollmentGroupPrimaryKey);
using var hmac = new HMACSHA256(enrollmentKeyBytes);

byte[] derivedKeyHash = hmac.ComputeHash(Encoding.UTF8.GetBytes(deviceId.ToString()));

string derivedKey = Convert.ToBase64String(derivedKeyHash);

var dpsDeviceKey = new SecurityProviderSymmetricKey(deviceId.ToString(), derivedKey, null);

Note that we could also derive a secondary key from the enrollment group secondary key, but this is actually never used by the DPS SDK, so I left it null.

Also, we generate a GUID to serve as the device ID on startup. When symmetric keys are used, the devices can specify any device ID they want. With TPM, the enrollmnent already specifies the ID. With X.509, the Common Name in the certificate will define the ID.

Provisioning with this derived key is rather simple with the DPS SDK:

using var transport = new ProvisioningTransportHandlerAmqp();
var deviceClient = ProvisioningDeviceClient.Create(
    _deviceProvisioningGlobalEndpoint,
    _deviceProvisioningIdScope,
    dpsDeviceKey,
    transport);
var result = await deviceClient.RegisterAsync();
var iotHubHostName = result.AssignedHub;

You can get the global endpoint and ID scope from the DPS resource in Azure Portal (Overview tab).

When this process has completed, we will now have a device identity in the IoT Hub for this simulator instance.

Sending messages to IoT Hub

To authenticate with IoT Hub, we use the same derived key that we used to authenticate with DPS. Sending events is pretty simple:

using var deviceClient = DeviceClient.Create(
    iotHubHostName,
    new DeviceAuthenticationWithRegistrySymmetricKey(
        deviceId.ToString(),
        dpsDeviceKey.GetPrimaryKey()));

await deviceClient.SendEventAsync(new Message(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new LocationUpdateEvent
{
    Id = deviceId,
    Lng = longitude,
    Lat = latitude,
    Ts = DateTime.UtcNow,
}))), cancellationToken);

If you are sending events very often, it can be beneficial to batch them and send them all at once with SendEventBatchAsync instead.

One thing to note is that the sample app sends the longitude and latitude in specifically named fields instead of something like an array with two numbers. I've found that it is really easy to mix up the two coordinates and the fact that different SDKs and types sometimes switch the order does not help things.

Receiving configuration updates through Device Twin

We want to be able to control the simulated vehicle's speed and the time interval at which it reports its location while the simulator is running. To achieve this, we can use Device Twins in IoT Hub. Through the twin, we can set desired properties for a device. For example:

{
  "properties": {
    "desired": {
      "speedKilometersPerHour": 100
    }
  }
}

The device can listen for changes to these desired properties, make the desired changes, and update its reported properties to let other services know the change has been made.

await deviceClient.SetDesiredPropertyUpdateCallbackAsync(async (desiredProperties, _) =>
{
    int updatedSpeedKilometersPerHour = desiredProperties.Contains("speedKilometersPerHour")
        ? (int)desiredProperties["speedKilometersPerHour"]
        : DefaultSpeedKilometersPerHour;
    device.SetSpeed(updatedSpeedKilometersPerHour);

    await deviceClient.UpdateReportedPropertiesAsync(new TwinCollection(JsonSerializer.Serialize(new
    {
        speedKilometersPerHour = updatedSpeedKilometersPerHour,
    })), stoppingToken);
}, null, stoppingToken);

The properties.reported object will then contain the values reported by the device.

Simulating movement

The device simulator has a JSON file (routes.json) that contains GeoJSON line strings which describe the routes the device could take. The simulator moves through the points of a chosen route at a configurable speed (by default 50 km/h). Algorithm at a high level (see code on GitHub):

  1. Choose random route
  2. Choose random starting point on route
  3. Pre-calculate distances between route points
  4. Check how much time has passed since last update
  5. Based on current speed setting, calculate distance travelled since last update
  6. Go through the points until remaining distance to next point > travelled distance
  7. Calculate how far relatively we have traveled to the next point (e.g. 40%)
  8. Calculate new location between previous and next point based on the traveled ratio
  9. Store updated time
  10. Sleep for configured time interval
  11. Go back to 4

One of the harder parts of doing something like this is figuring out the distances between the points. While it isn't perfect, using the Haversine formula with a fixed radius for Earth gives us a close enough approximation. GitHub Copilot was quite helpful for this part as I essentially just wrote the method name and parameters and it figured out the rest. Interestingly the version it came up with is almost identical to the Python version in the article on the Haversine formula I linked.

The other difficult thing was interpolation between the points (step 8 above). Say the device is between point A and point B, we know the distance is 1 kilometer, and the device moving at 50 km/h has moved 400 meters from A since the last update. We need to figure out the coordinates of the point 40 % between A and B. I tried to come up with complicated solutions for this, but ultimately ended up just considering the two coordinates as Cartesian coordinates and interpolating both values individually. In practice this has turned out to be good enough but I suspect it would lack precision especially if the distance between points was large.

Summary

With all of this, we have a simulator running in Container Apps that continuously produces data to IoT Hub. It also enables us to control it through IoT Hub's Device Twins. Next time we will look at the Function App sitting at the core of the sample application!