Azure IoT Centralに対応できないIoTデバイスをAzure Functionsを中継して対応させる

Azure IoT Centralは良いサービスなのに、デバイスがDPSに対応してないからAzure IoT Centralが使えないのは悲しい。だから、Azure Functions経由でAzure IoT Centralにデータを流し込めるようにするよ！という解説記事です。

冒頭は背景とか解説しているので、論よりコードが欲しい方は[途中から](#論よりコード)どうぞ。

## Azure IoT Central とは

[Azure IoT Central](https://azure.microsoft.com/ja-jp/services/iot-central/)(以下、IoTC)は、IoT向けアプリケーション構築サービスで、このようなアプリケーションを素早く作ることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22754/7df12491-f0f8-6108-ebf1-6377434d2fec.png)

### Azure IoT Central内のAzure IoT Hubエンドポイントを得る方法

IoTCは[内部でAzure IoT Hub(以下、IoT Hub)を利用](https://docs.microsoft.com/en-us/azure/iot-central/core/concepts-architecture)しています。通常、IoT Hubを作成すると `max-demo1-moowoa3b.azure-devices.net` といったエンドポイントアドレスが払い出され、そこに対してIoTデバイスから接続するとデータ送受信が行えます。

> また、このIoT Hubのエンドポイントアドレスは、IoT Hubの負荷分散や障害時フェイルオーバーによって、動的に変更されることもあります。

しかし、IoTCで利用されるIoT Hubは隠ぺい化されているため、エンドポイントアドレスがわかりません。では、どのようにIoTC内のIoT Hubに接続するのかというと[Azure IoT Hub Device Provisioning Service](https://docs.microsoft.com/ja-jp/azure/iot-dps/)(以下、DPS)を利用します。

DPSとは、IoT Hubのエンドポイントアドレスを動的に払い出してもらう仕組みです。
以下は、DPSのシーケンスです。

![MicrosoftのDPS解説ページからの引用 / https://docs.microsoft.com/ja-jp/azure/iot-dps/concepts-roles-operations)](https://docs.microsoft.com/ja-jp/azure/iot-dps/media/concepts-auto-provisioning/sequence-auto-provision-device-vs.png)

シーケンスの一番最後に「Device ID & IoT Hub connection endpoint」と書かれており、これがIoTC内のIoT Hubのエンドポイントアドレスになります。

この中で注目するのは「Azure Device Provisioning Service」と書かれている部分です。[この実体は `global.azure-devices-provisioning.net` というアドレス](https://docs.microsoft.com/en-us/azure/iot-dps/virtual-network-support#use-private-endpoints-with-devices)です。
このアドレスに向けてIoTデバイスから認証情報を送ると、IoTC内のIoT Hubのエンドポイントアドレスが払い出されます。

IoTデバイスは `global.azure-devices-provisioning.net` というアドレスさえ知っておけば※、IoT Hubのエンドポイントアドレスが変わったとしても対応できるわけです。
また、この一連のシーケンスは[Azure IoT Hub SDK](https://docs.microsoft.com/ja-jp/azure/iot-hub/iot-hub-devguide-sdks)が自動的に行ってくれます。

※ちなみにユーザー独自にDPSを作ることも可能です。その時は `<Your DPS Tenant Name>.azure-devices-provisioning.net` というアドレスになります。

## Azure IoT Centralに対応できないIoTデバイス = DPSができないデバイス

本題の「Azure IoT Centralに対応できないIoTデバイス」とは、DPSができないデバイスを指します。例えば開発が済んでしまっておりAzure IoT Hub SDKが導入できない場合です。

こういったデバイスのデータをIoTCに集約したい場合は、デバイス側では無くデータ送信先側で中継して、IoTC内のIoT Hubへデータ送信するという方法があります。
今回は中継にAzure Functions(Node.js)を利用します。

## 論よりコード

ようやっと、ここまできた。

ここでは `clickType` というInteger型のテレメトリーを送信するIoTデバイスを想定し、DPSの認証にはSASトークンを利用します。
いきなり中継するのではなく、まずはAzure Functionsの関数からテレメトリーを送ることができるところまでを行ってみましょう。

前準備として以下をIoTCから取得します。(デバイステンプレートのテストデバイスの情報でもOKです)

- デバイス ID
- ID スコープ
- SAS 資格情報(主キー)

あとはAzure FunctionsでNode.jsをランタイムとした関数を作成します。
後述のコードを実行すると `clickType = 1` というデータがIoTCに送信されます。

この関数で必要なライブラリは `azure-iot-provisioning-device-http` `azure-iot-device-http` `azure-iot-security-symmetric-key` の3つです。[コンソールからnpmパッケージをインストールする方法](https://qiita.com/ma2shita/items/cca1983c0428be0ec24b)を参考にインストールしてください。

```javascript:index.js
// Copyright 2021 (c) Kohei "Max" MATSUSHITA. All rights reserved.
// Licensed under the MIT license.
// Based on https://github.com/Azure/azure-iot-sdk-node/blob/master/provisioning/device/samples/register_symkey.js

// DPS parameters from IoTC
const deviceId = '<Device ID>'; // Device ID
const idScope = '<ID Scope>'; // ID Scope
const symmetricKey = '<SAS Token>'; // SAS Token

// Libs and Param for DPS
const ProvisioningHost = 'global.azure-devices-provisioning.net';
const SymmetricKeySecurityClient = require('azure-iot-security-symmetric-key').SymmetricKeySecurityClient;
const ProvisioningDeviceClient = require('azure-iot-provisioning-device').ProvisioningDeviceClient;
const ProvisioningTransport = require('azure-iot-provisioning-device-http').Http;
// Libs for using IoT Hub
const Client = require('azure-iot-device').Client;
const Message = require('azure-iot-device').Message;
const IotHubTransport = require('azure-iot-device-http').Http;

module.exports = async function (context, req) {
    // Your telemetry payload
    const telemetryPayload = {clickType: 1};

    // Get connection information via DPS (No changes required)
    const provisioningSecurityClient = await new SymmetricKeySecurityClient(deviceId, symmetricKey);
    const provisioningClient = await ProvisioningDeviceClient.create(ProvisioningHost, idScope, new ProvisioningTransport(), provisioningSecurityClient);
    const result = await provisioningClient.register();

    // Connect and Send to IoT Hub using DPS information (No changes required)
    const connectionString = `HostName=${result.assignedHub};DeviceId=${result.deviceId};SharedAccessKey=${symmetricKey}`;
    const iotHub = await Client.fromConnectionString(connectionString, IotHubTransport);
    await iotHub.open();
    const msg = await new Message(JSON.stringify(telemetryPayload));
    const res = await iotHub.sendEvent(msg);

    context.res = {status: 204};
}
```

コードとしては「DPSからエンドポイント情報を入手」「IoT Hubに接続してテレメトリーを送信」という2つのブロックから構成されています。

### HTTPトリガーでHTTP POSTやヘッダの値を中継

ここまでくれば残りは `telemetryPayload` のあたりを、トリガーの入力に合わせるだけです。

例として、IoTボタン「[SORACOM LTE-M Button](https://soracom.jp/store/5206/)」のボタン押下と簡易位置測位の情報を、IoTデバイスからFaaSが呼び出せる [SORACOM Funk](https://soracom.jp/services/funk/) 経由して Azure Functionsを呼び出した際の中継コードの、冒頭部分のみ掲載します。(全部は後述)

```javascript:SORACOM_Funkからのデータを中継_(index.js)
module.exports = async function (context, req) {
    // Forwarding to IoTC from LTE-M Button with SORACOM Funk
    const telemetryPayload = req.body; // Forward all values
    telemetryPayload['imsi'] = req.headers['x-soracom-imsi']; // Add IMSI
    const _lat_lon = req.headers['x-soracom-geo-position'].split(';');
    telemetryPayload['location'] = {lat: _lat_lon[0], lon: _lat_lon[1], alt: 0}; // Location scheme

    // Get connection information via DPS
    // ...
```

<details><summary>上記を含めたすべてのコード</summary><div>

```javascript:index.js
// Copyright 2021 (c) Kohei "Max" MATSUSHITA. All rights reserved.
// Licensed under the MIT license.
// Based on https://github.com/Azure/azure-iot-sdk-node/blob/master/provisioning/device/samples/register_symkey.js

// DPS parameters from IoTC
const deviceId = '<Device ID>'; // Device ID
const idScope = '<ID Scope>'; // ID Scope
const symmetricKey = '<SAS Token>'; // SAS Token

// Libs and Param for DPS
const ProvisioningHost = 'global.azure-devices-provisioning.net';
const SymmetricKeySecurityClient = require('azure-iot-security-symmetric-key').SymmetricKeySecurityClient;
const ProvisioningDeviceClient = require('azure-iot-provisioning-device').ProvisioningDeviceClient;
const ProvisioningTransport = require('azure-iot-provisioning-device-http').Http;
// Libs for using IoT Hub
const Client = require('azure-iot-device').Client;
const Message = require('azure-iot-device').Message;
const IotHubTransport = require('azure-iot-device-http').Http;

module.exports = async function (context, req) {
    // Forwarding to IoTC from LTE-M Button via SORACOM Funk
    const telemetryPayload = req.body; // Forward all values
    telemetryPayload['imsi'] = req.headers['x-soracom-imsi']; // Add IMSI
    const _lat_lon = req.headers['x-soracom-geo-position'].split(';');
    telemetryPayload['location'] = {lat: _lat_lon[0], lon: _lat_lon[1], alt: 0}; // Location scheme

    // Get connection information via DPS (No changes required)
    const provisioningSecurityClient = await new SymmetricKeySecurityClient(deviceId, symmetricKey);
    const provisioningClient = await ProvisioningDeviceClient.create(ProvisioningHost, idScope, new ProvisioningTransport(), provisioningSecurityClient);
    const result = await provisioningClient.register();

    // Connect and Send to IoT Hub using DPS information (No changes required)
    const connectionString = `HostName=${result.assignedHub};DeviceId=${result.deviceId};SharedAccessKey=${symmetricKey}`;
    const iotHub = await Client.fromConnectionString(connectionString, IotHubTransport);
    await iotHub.open();
    const msg = await new Message(JSON.stringify(telemetryPayload));
    const res = await iotHub.sendEvent(msg);

    context.res = {status: 204};
}
```
</div></details>

※上記コードは簡易位置測位が有効化されている前提になっています。エラー処理とかはご自分でお願いします。

## 実際の運用 ～ あとがき

このコードは現状、関数1 = デバイス1 という関係になっているため、複数デバイスを展開したい場合は関数もその数だけ準備が必要です。これは流石にダサいので、トリガーで入力されたデバイスID的な情報(たとえばIMSI)と `deviceId` と `symmetricKey` をマッピングすれば、関数の共有ができるでしょう。

また、インターネットからこの関数に誰でもアクセスできてしまうと、デバイスなりすましの危険性があるので、なんらかの認証機構を実装したほうがいいでしょう。

### あとがきと、ちょっと宣伝

これでIoTC対応デバイスと非対応デバイスを混在して、IoTCにデータ集約できるようになります。
希望としては、IoTC内のIoT Hubを開放してくれるとシンプルになると思うのですが、どうでしょうか？


今日紹介した SORACOM LTE-M Buttonは、その名の通り省電力のLTE通信 "LTE-M" を使うことで、どこからでも押下情報をクラウド連携できるIoTボタンです。そのほかにも[4種のセンサーを内蔵したGPSマルチユニット SORACOM Edition](https://soracom.jp/store/5235/)などもあります。こういったデバイスの情報をAzureへ集約するときの方法として覚えておいて損は無いかなということでご紹介しました。

Azure IoT Centralの活用方法としてご利用くださいー。

※そういえば、コードがawaitばかりになっちゃったけど、そういうもんなのかな？

### ChangeLog: 9/25

元々は以下のように `Promise.catch((e) => {context.done();});` のように書いていたのですが、以下の理由から削除しました。

```javascript
    const result = await provisioningClient.register().catch((e) => {
        context.log(e);
        context.res = {status: 500};
        context.done(); // Exit
    });
```

1. 編集リクエストや [Azure Functions & Node.js で async 関数の中で context.done を呼ぶのはやめた方がいいよ、という話](https://uncaughtexception.hatenablog.com/entry/2021/09/24/122618)にもあるように、`context.done()` が関数を終了させるわけじゃない
2. 当該メソッド(以外でも)例外が発生すれば関数が止まってHTTP 500になるし、記録もされるからやってることは同じ。要するに意味なしだった (^^;

みなさま、ありがとうございましたー！

## 参考情報

- [Tutorial: Create and connect a client application to your Azure IoT Central application](https://docs.microsoft.com/en-us/azure/iot-central/core/tutorial-connect-device?pivots=programming-language-javascript)
    - https://github.com/Azure/azure-iot-sdk-node/blob/master/provisioning/device/samples/register_symkey.js
- Azure SDK for JavaScript
    - [`Client#sendEvent(Message)`](https://docs.microsoft.com/en-us/javascript/api/azure-iot-device/client?view=azure-node-latest#sendEvent_Message_)
    - [`Message(Message.BufferConvertible)`](https://docs.microsoft.com/en-us/javascript/api/azure-iot-common/message?view=azure-node-latest#Message_Message_BufferConvertible_)
- [matsujirushi’s blog / デバイスからAzure IoT Centralに接続する方法](https://matsujirushi.hatenablog.jp/entry/2019/06/22/144751)
- [Azure Functions & Node.js で async 関数の中で context.done を呼ぶのはやめた方がいいよ、という話](https://uncaughtexception.hatenablog.com/entry/2021/09/24/122618)

EoT