ブログに書くつもりじゃなかった

フリーのプログラマーが綴る、裏チラ系の備忘録や雑記帳。

Azure IoT Hubにjava+MQTTでテレメトリを送信

Azure側

まずは無料アカウントを作る

自動で課金されることはないって言うけど、クレジットカード番号を要求されるのは心理的ハードルが高いよなぁ...と長いこと保留にしていたが、本業に差し障るので覚悟を決める。

IoT Hubを作成する

ポータルからサクッと作る。

  • IoT Hub名はグローバルでユニークにしないといけないみたい。
  • 領域は自分に近い場所として「East Asia」を選択する。「Japan East」が選択肢にあることにはデプロイしてから気づいた。
  • 学習用なので、価格とスケールティアは「F1: Freeレベル」で良いはずだ。
IoT Hubにデバイスを登録する

こちらもポータルからサクッと作る。

  • バイスIDを適当に入れる。
  • その他の項目は画面の通りにする。

クライアント側

Azure SDKを使えば手っ取り早いのだけど、MQTTも勉強しなくちゃいけないので、Eclipse Paho Java Clientを使って作る。

pom.xml

以下をdependenciesに追加。

<dependency>
    <groupId>org.eclipse.paho</groupId>
    <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
    <version>1.2.0</version>
 </dependency>
App.java

SHARED_ACCESS_KEYは自動生成されたデバイスの主キーを指定。ポータルで登録したデバイスを参照すると取得できる。

package com.example.mqtt;

import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.MqttCallback;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;

public class App implements MqttCallback {

    private final static String IOT_HUB_NAME = "your-own-iot-hub.azure-devices.net";
    private final static String DEVICE_ID = "device0001";
    private final static String SHARED_ACCESS_KEY = "c2hhcmVkX2FjY2Vzc19rZXk=";
    
    public static void main(String[] args) {
        try {
            new App().run();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public void run() throws MqttException, InterruptedException {

        final String endpoint = String.format("ssl://%s:8883", IOT_HUB_NAME);
        final MqttClient client = new MqttClient(endpoint, DEVICE_ID);
        final MqttConnectOptions connOpts = new MqttConnectOptions();
    
        final String username = String.format("%s/%s/?api-version=2020-09-30", IOT_HUB_NAME, DEVICE_ID);
        final long expiryTime = (System.currentTimeMillis() / 1000L) + 30;        
        final String sasToken = SASToken.getSASToken(String.format("%s/devices/%s", IOT_HUB_NAME, DEVICE_ID) , SHARED_ACCESS_KEY, expiryTime);
        connOpts.setUserName(username);
        connOpts.setPassword(sasToken.toCharArray());

        client.connect(connOpts);
        client.setCallback(this);
    
        final MqttMessage message = new MqttMessage("{\"test\":\"value\"}".getBytes());
    
        client.publish("devices/" + DEVICE_ID + "/messages/events/" , message);
        System.out.println("Published message.");
    
        Thread.sleep(1000L);
    
        client.disconnect();
        
        System.exit(0);
    }
    
    public void connectionLost(Throwable cause) {
        System.out.println("Lost connection.");
    }
    
    public void messageArrived(String topic, MqttMessage message) throws Exception {
        System.out.println("Received message from topic " + topic + ": " + message);
    }

    public void deliveryComplete(IMqttDeliveryToken token) {
        System.out.println("Delivered message.");
    }
}
SASToken.java

Azure IoT Hubのドキュメントに載ってたサンプルを流用。この選択が後に悲劇を生むことになる...

package com.example.mqtt;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Base64.Encoder;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class SASToken {

    public static String getSASToken(String resourceUri, String key, long expiryTime) {

        String expiry = Long.toString(expiryTime);

        String sasToken = null;
        try {
//         String stringToSign = URLEncoder.encode(resourceUri, "UTF-8") + "\n" + expiry;
            String stringToSign = resourceUri + "\n" + expiry;
            String signature = getHMAC256(key, stringToSign);
//         sasToken = "SharedAccessSignature sr=" + URLEncoder.encode(resourceUri, "UTF-8") + "&sig="
            sasToken = "SharedAccessSignature sr=" + resourceUri + "&sig="
                    + URLEncoder.encode(signature, "UTF-8") + "&se=" + expiry;

        } catch (Exception e) {
            e.printStackTrace();
        }

        return sasToken;
    }

    private static String getHMAC256(String key, String input) {
        Mac sha256_HMAC = null;
        String hash = null;
        try {
            sha256_HMAC = Mac.getInstance("HmacSHA256");
//         SecretKeySpec secret_key = new SecretKeySpec(key.getBytes(), "HmacSHA256");
            byte[] decodedDeviceKey = Base64.getDecoder().decode(key.getBytes());
            SecretKeySpec secret_key = new SecretKeySpec(decodedDeviceKey, "HmacSHA256");
            sha256_HMAC.init(secret_key);
            Encoder encoder = Base64.getEncoder();

            hash = new String(encoder.encode(sha256_HMAC.doFinal(input.getBytes("UTF-8"))));

        } catch (Exception e) {
            e.printStackTrace();
        }

        return hash;
    }
}

動作確認

プログラムを実行する。上手くいったようだが出力結果が地味すぎる。

Published message.
Delivered message.

ポータルで受け取ったメッセージを簡単に見れないの?とあちこち探したが、見つけられず。この辺はあまり興味もないし、次回の課題にしよう。とりあえずダッシュボードのグラフにメッセージがカウントされたので、良しとするか。

ハマったポイント

SASトークンの仕様と生成サンプルについては、こちらの公式ドキュメントを参考にした。

docs.microsoft.com

「リソースURIをURLエンコードした文字列」を得るのにサンプルではURLEncoder.encode()を使っているが、これで「/」を「%2F」にエンコードするのがNGだったようだ。とりあえずURLエンコードが必要な文字は他にないので、暫定策として処理を省いて上手く動いた。原因を突き止めるのに、結局SDKを使ったクライアントを作って動かす羽目になったよ。やれやれ。