Putting it all together: Building a simple Home Automation IoT platform with IoT Hub and SignalR

In a previous post we discussed how we could connect an IoT device to an IoT Hub in Azure. In another previous post, we walked-through setting up a real-time communication solution with SignalR Service.

This post is going to build on those to demonstrate how we can build a Home Automation IoT platform that will allow us to view telemetry data from our devices and also manage our devices from a webpage or mobile application.

At the end of this post, we will have the following solution:

Azure Functions for message ingress and egress

At this point, we should have a pretty good handle on the IoT Hub and SignalR Service. What we need to do is get them talking to each other. The easiest way to do this is with Azure Functions. We can use the same Azure Functions project we set up previously.

We are going to need functions for two purposes:

  • cloud-to-device messaging - these are messages to configure our device or put it into a particular state
  • device-to-cloud messaging - these are the telemetry messages we want our mobile and web clients to display

Add local settings

We'll need to add a connection string to our IoT Hub Event Hub-compatible endpoint. Open you IoT Hub in the Azure portal and open the "Built-in endpoints" menu item. This will expose the endpoint we will connect our reader function to.

We will also need a connection string to the IoT Hub device registry so we can direct messages to our device. We need a policy that has both registry write and service connect permissions. I'm going to use the iothubowner policy, but as a best practice a new policy should be created with just those permissions.

Open local.settings.json and add the following to the Values property:

"Values": {
  ...
  "IoTHubConnectionString": "Endpoint=sb://...",
  "IoTHubRegistryConnectionString": "HostName=..."

Device-to-cloud messaging

For this flow, we are going to create an Azure Function that will read messages off our IoT Hub, massage them a bit and then send them on to SignalR Service.

As done previously, we'll create a new Azure Function but instead of using the HTTP Trigger template, we are going to use the Azure Event Hub trigger template. IoT Hub and Event Hub share the same technology for message ingress, so this trigger will be compatible for us. Name the function deviceToCloud.

Edit deviceToCloud's function.json

We are going to declaratively configure a couple bindings. The eventHubTrigger will already be in there, just ensure that the "connection": "IoTHubConnectionString" entry is in there so the function can connect to the IoT Hub endpoint we specified in our local.settings.json.

We will add a second output binding to push messages to SignalR Service, specifying the chat hub.

{
  "bindings": [
    {
      "type": "eventHubTrigger",
      "name": "eventHubMessages",
      "direction": "in",
      "eventHubName": "ServerlessIoTHub",
      "connection": "IoTHubConnectionString",
      "cardinality": "many",
      "consumerGroup": "$Default"
    },
    {
      "type": "signalR",
      "name": "signalRMessages",
      "hubName": "chat",
      "direction": "out"
    }
  ],
  "scriptFile": "../dist/deviceToCloud/index.js"
}

Edit deviceToCloud's index.ts

We need to write some code that will take each message and interrogate it for the id of the device that published it. We will then push that message along to SignalR Service using the device id as the group name.

Any client interested in observing a device's telemetry will simply need to join the SignalR group based on its id. Then SignalR will invoke the client's local handler and pass in the message (in this case, we expect the client to have a handler registered for handleMessage)

import { AzureFunction, Context } from "@azure/functions";

interface DeviceMessage {
  deviceId: string;
  [key: string]: any;
}

const eventHubTrigger: AzureFunction = async function(
  context: Context,
  eventHubMessages: DeviceMessage[]
): Promise<void> {
  context.log(
    `Eventhub trigger function called for message array ${eventHubMessages}`
  );

  eventHubMessages.forEach(message => {
    context.bindings.signalRMessages = [
      {
        // message will only be sent to this group
        groupName: message.deviceId,
        target: "handleMessage",
        arguments: [message]
      }
    ];
  });
};

export default eventHubTrigger;

Cloud-to-device messaging

For this flow, we are going to create an Azure Function that will take some message that includes the id of the device of interest, and then push it into the IoT Hub where it will then be routed to that device. We are going to leverage Device Twin Desired Properties as we have done previously to manage our devices' state.

Also as done previously, we'll create a new Azure Function using the HTTP Trigger template. Name it cloudToDevice.

Edit cloudToDevice's function.json

Just remove the get from the list of acceptable methods:

"methods": [
  "post"
 ]

Edit cloudToDevice's index.ts

Because there is no Device Twin binding, we are going to have to write a fair bit of code. We need to:

  • retrieve the id of the device we want to send a message to from the querystring
  • connect to our IoT Hub device registry
  • get our device twin from the registry
  • patch our device twin's desired property based on the incoming message
import { AzureFunction, Context, HttpRequest } from "@azure/functions";
import { Registry, Twin } from "azure-iothub";

const httpTrigger: AzureFunction = async function(
  context: Context,
  req: HttpRequest
): Promise<void> {
  const deviceId = req.query.deviceId;

  if (!deviceId) {
    context.res = {
      status: 400,
      body: "No device id in the request"
    };
    return;
  }

  const registry = Registry.fromConnectionString(process.env.IoTHubRegistryConnectionString);

  // Using the callback form of getTwin is not possible due to the need
  // to await so that I can call context.log before the function returns
  const getTwinResponse = await registry.getTwin(deviceId)
    .catch(getTwinErr => {
      context.res = {
        status: 400,
        body: `Could not retrieve device twin for device with Id ${deviceId}`
      };
      context.log(getTwinErr.message);
      throw getTwinErr;
    });

  // responseBody is presently typed incorrectly as HttpResponse<any>,
  // forcing this cast
  const twin = getTwinResponse.responseBody as Twin;
  const propertyPatch = {
    properties: {
      desired: req.body
    }
  };
  
  // Need to await so that I can call context.log before function returns
  await twin.update(propertyPatch)
    .catch(updateTwinErr => {
      if (updateTwinErr) {
        context.res = {
          status: 400,
          body: `Could not update device twin for device with Id ${deviceId}`
        };
        context.log(updateTwinErr);
      }
  });
};

export default httpTrigger;

At this point we should be able to trigger this Azure Function to update our device's status.

Note: this obviously has not been secured in any way. Clients generally should not have direct access like this to your IoT Hub. One possible security improvement would be to create a proxy that has access to a secret it can forward to this Azure Function, and users could authenticate against the proxy and use it instead. This will be the subject of a future post.

After hitting F5 to start debugging our functions, we can POST the following:

Execute that, visit the device twin in the Azure portal, and we can see that our device's desired properties - and reported properties - have been updated:

"properties": {
    "desired": {
      "status": "on",
      "$metadata": {
        "$lastUpdated": "2020-02-14T20:18:53.4911471Z",
        "$lastUpdatedVersion": 71,
        "status": {
          "$lastUpdated": "2020-02-14T20:18:53.4911471Z",
          "$lastUpdatedVersion": 71
        }
      },
      "$version": 71
    },
    "reported": {
      "status": "on",
      "$metadata": {
        "$lastUpdated": "2020-02-14T20:18:53.6069387Z",
        "status": {
          "$lastUpdated": "2020-02-14T20:18:53.6069387Z"
        }
      },
      "$version": 72
    }

Coding our client webpage

We are going to code a very simple webpage that will print out telemetry messages from a device with the id of test-device, the same one used in a previous post. We will also change its state as we did before with a web request.

I'll include the source of the webpage in its entirety at the end of this post until I get everything moved over to Github.

Handling incoming device-to-cloud message

This hasn't changed since the previous post where we set up our SignalR Service. We receive the message, stringify it and then render it onto the page:

function handleMessage(message) {
  document.querySelector("#log").innerHTML = `<div>${JSON.stringify(
    message
  )}</div>`;
}

Sending cloud-to-device message

Instead of broadcasting a message to a SignalR Service group, we are going to instead send a message via our new cloudToDevice Azure Function.

function sendMessage() {
  const newStatus = document.querySelector("#isOn").value;

  const payload = { status: newStatus };

  axios.post(`${apiBaseUrl}/api/cloudToDevice?deviceId=${DEVICE_ID}`, payload);
}

Have I mentioned I am not a designer?

Here is the rudimentary UI I have built that allows a user to subscribe to test-device's telemetry stream and also set its status:

Once I click the checkbox to subscribe, telemetry will start rendering in real-time on the page:

If I set the a status in my dropdown and hit the Send button, my Azure Function will forward the message to my IoT Hub.

To re-iterate, this is not secure and you shouldn't provide your client direct access to your IoT Hub like this.

As you hit the send button, you should see the test-device device twin update its desired and reported properties:

{
  "deviceId": "test-device",
  "tags": {
    "kind": "outlet"
  },
  "properties": {
    "desired": {
      "status": "on",
      ...
    },
    "reported": {
      "status": "on",
      ...
 }

Wrapping up

We now have a simple but super cool platform that lets us provision devices, publish telemetry, receive updates in real-time, and manage our devices from our computer or phone.

In a future post I will go through the exercise of deploying this code into Azure so that it will be always on and available for use. I'll also upload all the relevant code to Github. Until then, here is the full source of the client webpage:

<html style="font-size: 20px;padding: 20px;">
  <body>
    <div style="float: left; margin-right: 80px">
      <div>
        <input type="checkbox" id="subscribe" name="subscribe" >
        <label for="subscribe">Subscribe to test-device telemetry</label>
      </div>
    </div>
    <div style="float: left;margin-bottom: 50px; border-left: 1px solid #ccc; padding-left: 20px;">
      <div>
        <label for="isOn">Set device status</label><br />
        <select id="isOn">
          <option value="on">On</option>
          <option value="off">Off</option>
        </select> 
        <button id="send">Send</button>
      </div>
    </div>
    <hr style="clear:both;" />
    <h3>Telemetry data</h3>
    <div id="log" style="margin-top: 50px">No telemetry received</div>
    <script src="https://cdn.jsdelivr.net/npm/@aspnet/signalr@1.1.2/dist/browser/signalr.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@0.18.0/dist/axios.min.js"></script>
    <script>
      const username = new URLSearchParams(window.location.search).get("username");
      var apiBaseUrl = "http://localhost:7071";
      const deviceId = "test-device";

      function changeGroup(event) {
        if(event.target.checked) {
          axios.post(
            `${apiBaseUrl}/api/joinGroup?userId=${username}&groupName=${deviceId}`
          );
        }
        else {
          axios.post(
            `${apiBaseUrl}/api/leaveGroup?userId=${username}&groupName=${deviceId}`
          );
        }
      }

      function sendMessage() {
        const newStatus = document.querySelector("#isOn").value;

        const payload = { status: newStatus };

        axios.post(`${apiBaseUrl}/api/cloudToDevice?deviceId=${deviceId}`, payload);
      }

      function handleMessage(message) {
        document.querySelector("#log").innerHTML = `<div>${JSON.stringify(
          message
        )}</div>`;
      }

      document.querySelector("#subscribe").onclick = changeGroup;
      document.querySelector("#send").onclick = sendMessage;

      var connection = new signalR.HubConnectionBuilder()
        .withUrl(`${apiBaseUrl}/api/${username}`)
        .configureLogging(signalR.LogLevel.Information)
        .build();
      
      connection.on("handleMessage", handleMessage);
      
      connection
        .start()
        .catch(console.error);
    </script>
  </body>
</html>