メインコンテンツへスキップ
見出し画像

M5DialをSpotifyのリモコンにする

    Cardputerと同じころ買って、とりあえずキッチンタイマーにしてみたM5Dialですが…

    やっぱりクルクル回せるダイヤルとWiFiに接続できる点を両方活かしたい。どういうことができるか考え…Spotifyのリモコンにしてみることにしました。ダイヤルで音量調整とかすればいい。どうやって作るかを見ていきましょう。

    ※書き忘れた注意事項:Spotify APIを使用するには、Premiumアカウントである必要があります。

    スマホからWi-Fiを設定する

    これまで弄ってきたM5シリーズはみんなmicroSDカードスロットを備えていたので、Wi-FiのパスワードはSDカードに入れていました。しかしM5DialにはSDカードスロットがありません。かと言ってソースコードにパスワードを書くようなことはしたくない…そこで、IoT家電でよくあるスマホからのWi-Fi設定を実装することにしました。

    画像
    スマホからWi-Fi設定をする流れ。ややこしいため図を用意しました

    アクセスポイントモードを使う

    M5Dialの使っているESP32はWi-Fi機能を内蔵していますが、その辺のルーターに繋ぐ以外にも自身がアクセスポイントになることもできます。もちろんこの場合にはインターネットには接続されていない状態になりますが、スマホがM5Dialに接続してM5Dialの返してくるhtmlを表示する…等は出来る状態になるわけです。

    const IPAddress myAPIP(192, 168, 0, 1);
    const IPAddress subnet(255, 255, 255, 0);
    String AP_ssid = "DialPlay";
    String AP_pass = "DialPlay";
    
    void startWiFiAP()
    {
      WiFi.softAP(AP_ssid.c_str(), AP_pass.c_str());
      WiFi.softAPConfig(myAPIP, myAPIP, subnet);
    }

    こんな感じ。自称するIPアドレスやSSID、パスワードを決めておきます。

    DNSを動かす

    DNSはサーバーの名前から実際のIPアドレスを引っ張ってくる仕組みですが、これをM5Dial上で起動させることで、どこか別のサイトに接続しようとしてもM5DialのIPアドレスが返ってくる…という状態にできます。通常httpsでアクセスしている分にはこんな成りすましは通用しませんが、今回は下記のキャプティブポータルを実現するために使用します。

    #include <DNSServer.h>
    
    DNSServer dnsServer;
    // どこかで起動
    {
      dnsServer.start(53, "*", myIP);
    }

    何に対しても自分のIPアドレスを返すだけなのでパラメータが単純です。

    キャプティブポータルを装う

    キャプティブポータルとは、フリーWi-Fiによくある…メールアドレスを登録するとインターネットに接続できるよ、みたいなページを表示するやつです。下記のページがすごく参考になりました。

    つまり、M5DialがWi-Fiのアクセスポイントになり、スマホがそれに接続すると、スマホがキャプティブポータルがあるかどうか検知するために特定の(httpの)URLにアクセスします。そこでDNSからM5DialのIPアドレスを返すことで、キャプティブポータル検知画面にM5Dialが用意したフォームを表示することができます。

    というかほぼ同じことをされている方がいたので、こちらもすごく参考にしました。

    #include <WebServer.h>
    
    WebServer webServer(80);
    // どこかで起動
    {
      webServer.onNotFound(handleNotFound);
      webServer.on("/formwifi", handleFormWiFi);
      webServer.on("/postwifi", HTTP_POST, handlePostWiFi);
      webServer.begin();
    }

    今までWiFiClientでHTTPを直書きしていましたが、WebServerを使って簡単にすることにしました。iPhoneの場合は「captive.apple.com/hotspot-detect.html」にアクセスしようとした結果、該当するパスが無いのでonNotFoundに設定した関数が呼び出されるはずです。そこでフォームを設置したHTMLを返します。

    Wi-Fi接続用QRコードを表示する

    上記のようにDNSサーバーやWebサーバーを起動したら、スマートフォンから接続してもらうためにQRコードを表示します。これは簡単で、
    「WIFI:S:ssid;T:WPA;P:password;;」という文字列を組み立ててQRコードにするだけです。

    スマホでQRコードを読む

    スマホのカメラでQRコードを読むと、M5Dialが立ち上げたWi-Fiに接続します。キャプティブポータルを検知したスマホが、ミニブラウザでM5Dialから送られてきたHTMLを表示します。

    画像

    フォームからのPOSTを受信する

    あとはフォームからのPOSTを受信して、ルーターなど本来のアクセスポイントのSSIDとパスワードを得ます。アクセスポイントモードを解除してから、今度は子機として指定されたアクセスポイントに接続します。

    void handlePostWiFi(void)
    {
      ST_ssid = webServer.arg("SSID");
      ST_pass = webServer.arg("PASS");
      dnsServer.stop();
      WiFi.disconnect();
      delay(100);
      WiFi.mode(WIFI_STA);
      WiFi.begin(ST_ssid.c_str(), ST_pass.c_str());
    }

    DNSサーバーも不要になるので、このタイミングで停止します。

    もう一工夫:Wi-Fiをスキャンする

    フォームを表示するとき単純にSSIDを入力させてもいいんですが、スマホでポチポチ入力するのはとても面倒です。なので、M5Dialが接続可能なアクセスポイントを事前にスキャンして、ドロップダウンリストの形でフォームに表示すれば一つ手間が減ります。

    std::vector<String> wifiVector;
    
    void scanWiFi()
    {
      WiFi.mode(WIFI_STA);
      WiFi.disconnect();
      delay(100);
      int count = WiFi.scanNetworks();
      wifiVector.clear();
      for (int i = 0; i < count; i++)
      {
        String ssid = WiFi.SSID(i);
        wifiVector.push_back(ssid);
      }
      WiFi.scanDelete();
    }

    scanNetworks()してSSID(i)で順番に名前を得て、scanDelete()で情報を消去します。実際のコードではこれをアクセスポイントモードの起動前に実行しておくわけです。

    Spotifyの認証

    SpotifyのAPIを使用するにはOAuth 2.0を使用した認証が必要です。前回Google Calendarにアクセスしようとした時はリダイレクトをどうしよう…というところで挫折しましたが、今回はmDNSを使用してリダイレクトを受信することに思い至ったので、実装していきます。

    画像
    こちらもややこしいため図を用意しました。M5Dial自身はWebブラウザにはなれないので、その役をスマートフォンにやってもらいます。

    ログインURLを生成する

    SpotifyのWeb API解説ページを読んで、PKCE Flowを実装することにしました。

    PKCE FlowではCode verifierというランダムな文字列を生成して保存しておき、それを元にCode challengeというハッシュを計算し、まずハッシュの方を送信します。Spotifyから認証コードが返ってきたら、今度は元の文字列を送信します。Spotify側はその文字列からハッシュを計算して、確かにさっきと同じやつが通信しているなと判断するわけです。

    なぜこうしているのかというと、下記のリダイレクト時に他の機械がリダイレクトを盗むかもしれないからです。認証コードを盗まれても、元々のランダム文字列は認証をリクエストしたM5Dialしか知らないため、access tokenは得られません。

    #include <mbedtls/md.h>
    #include "base64.hpp"
    
    // Generate random 64 characters
    String randomString64()
    {
        const char *possibleChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        char result[65];
        for (int i = 0; i < 65; i++)
        {
            result[i] = possibleChars[random(62)];
        }
        result[64] = 0;
        return String(result);
    }
    
    // Generate SHA256 hash and convert to Base64 for spotify API
    String SHA256HashInBase64(String source)
    {
        const char *payload = source.c_str();
    
        unsigned char hashResult[33];
        mbedtls_md_context_t ctx;
        mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
        mbedtls_md_init(&ctx);
        mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(md_type), 1);
        mbedtls_md_starts(&ctx);
        mbedtls_md_update(&ctx, (const unsigned char *)payload, strlen(payload));
        mbedtls_md_finish(&ctx, hashResult); // 32 bytes
        mbedtls_md_free(&ctx);
    
        unsigned char base64result[49];
        encode_base64(hashResult, 32, base64result);
    
        String result = String((char *)base64result);
        result.replace("=", "");
        result.replace("+", "-");
        result.replace("/", "_");
    
        return result;
    }

    上記のような関数を作成して、ランダムな文字列の生成とSHA256のハッシュ文字列を生成できるようにしました。SHA256ハッシュの計算にはmbedtlsを使用します。ちょっと罠ポイントがあって、ハッシュをbase64エンコードしますが、SpotifyにはこれをURLのパラメータとして送るために=, +, /を置き換えていて本来のbase64ではなくなっています。ハッシュなのでこれで用が足りるわけですね。

    String authURLString()
    {
        String urlString = "https://accounts.spotify.com/authorize";
        randomSeed(millis());
        codeVerifier = randomString64();
        authState = randomString64();
    
        urlString += "?client_id=" + urlEncode(clientID);
        urlString += "&response_type=code";
        urlString += "&redirect_uri=" + urlEncode("http://dialplayredirect.local/authredirected");
        urlString += "&state=" + urlEncode(authState);
        urlString += "&scope=user-read-playback-state%20user-modify-playback-state";
        urlString += "&code_challenge_method=S256";
        urlString += "&code_challenge=" + SHA256HashInBase64(codeVerifier);
    
        return urlString;
    }

    上記の関数たちを使ってこんな感じでURLを作成します。ただこのURLはQRコードにするには長すぎるので、実際にはM5DialのWebサーバーにアクセスする短いURLを表示し、リダイレクトします。

    void handleAuthStart(void)
    {
      if (spotifyAuthURLString.length() > 0)
      {
        webServer.sendHeader("Location", spotifyAuthURLString);
        webServer.send(302, "text/plain", "Found.");
        return;
      }
      webServer.send(404, "text/plain", "No Auth URL");
    }

    HTTPレスポンスコード302を使って、Locationにリダイレクト先を書いておきます。

    mDNSを動かす

    認証フローを読むと分かりますが、SpotifyのAPIを使用するアプリケーションはあらかじめリダイレクトURLを登録しておきます。認証リクエスト時にもリダイレクトURLを送信しますが、一致しないと認証されません。Spotifyがスマホにユーザーのログイン画面を出して、ユーザーが権限を与えるとこのURLにリダイレクトされて、パラメータとして認証コードがついてくるわけです。

    Webサイトやスマートフォンアプリなら、自分のURLがありますからリダイレクトを受け取るのは簡単です。しかしM5DialはローカルのWi-Fiに接続しているただの子機なので、そのままでは(毎回変わる)IPアドレスしかありません。そこで、ローカルのWi-Fiでもサーバー名を名乗る仕組みが必要です。mDNSの出番です。

    #include <ESPmDNS.h>
    
    // どこかで起動
    {
      mdns_init();
      MDNS.begin("dialplayredirect");
    }

    上記のコードのようにすると、(同じネットワークなら)M5DialのIPアドレスに「http://dialplayredirect.local/」でアクセスできるようになります。今回はリダイレクトを受け取るためだけに使っていますが、M5Stackや他のESP32たちをネットワークで使用するときにIPアドレスを直打ちする必要がなくなるので、他にも使い所はありそうです。

    スマホでQRコードを読む

    SpotifyのログインURL(にリダイレクトするM5DialのURL)をQRコードで表示し、またスマホでこれを読み取ります。Spotifyのログイン画面と、アプリへの許可画面が出るので、アクセスを許可します。

    画像

    「http://dialplayredirect.local/」にリダイレクトが飛ぶので、これを捕まえます。

    リダイレクトを受け取る

    void handleAuthRedirected(void)
    {
      String code = webServer.arg("code");
      String state = webServer.arg("state");
    
      webServer.send(200, "text/html", autoCloseHtml);
      webServer.stop();
      MDNS.end();
    }

    ずっと動かしていたWebサーバーでリダイレクトを受け取り、パラメーターとしてcodeとstateを取得します。stateは事前に生成したランダムな文字列にして照合したりすれば、CSRF対策として使用できるようです。

    WebサーバーやmDNSは用済みなのでここで停止しています。

    Spotify APIを使用する

    こうしてようやくSpotifyへログインし、APIを使用する準備ができました。

    簡単なJSONパーサを作る

    Spotify APIのレスポンスはJSONなので、パーサが必要です。ArduinoJSONを使おうかと思ったのですが…

    ArduinoJSONは事前にバッファのサイズを指定するんですよね。ちょっとここが…心配じゃありませんか?SpotifyのAPIで楽曲リストとかを取得し出したら、サイズなんて何KBになるか事前に分かんないですよ。

    そこで、固定サイズではなく、StreamをreadStringUntilしながらJSONをパースする自前のクラスを作りました。超簡易的なので機能は最小限だし、普通に不安定で、おそらく曲名に「"」が入っただけで失敗するんじゃないかと思います。

    この簡易パーサはこんな感じで使います。

    WiFiClient *stream = httpClient.getStreamPtr();
    JsonStreamScanner scanner = JsonStreamScanner(stream);
    while (scanner.available())
    {
        String path = scanner.scanNextKey();
    
        if (path == "/access_token")
        {
            accessToken = scanner.scanString();
        }
        else if (path == "/refresh_token")
        {
            refreshToken = scanner.scanString();
        }
    }

    JSONはだいたい辞書型だろうという仮定に基づき、辞書のキーを探していきます。{ "data" : {"id" : "hogehoge"} }みたいなデータだったら入れ子構造を/data/idのようなパスとして表します。目的の構造に達したところでvalueを読み取ります。

    Access tokenを取得する

    SPClientというクラスを作って、Spotify APIの呼び出しを任せることにしました。まずは先ほどリダイレクトされてきたcodeと、認証URLを生成する時に使ったCode verifierを使ってAccess tokenを取得します。

    int SPClient::requestAccessToken(String code)
    {
        String payload = "grant_type=authorization_code";
        payload += "&code=" + urlEncode(code);
        payload += "&redirect_uri=" + urlEncode("http://dialplayredirect.local/authredirected");
        payload += "&client_id=" + urlEncode(clientID);
        payload += "&code_verifier=" + urlEncode(codeVerifier);
    
        httpClient.begin("https://accounts.spotify.com/api/token", SpotifyPEM);
        httpClient.addHeader("Content-Type", "application/x-www-form-urlencoded");
        int result = httpClient.POST(payload);
        if (result == HTTP_CODE_OK)
        {
            WiFiClient *stream = httpClient.getStreamPtr();
            JsonStreamScanner scanner = JsonStreamScanner(stream);
            while (scanner.available())
            {
                String path = scanner.scanNextKey();
    
                if (path == "/access_token")
                {
                    accessToken = scanner.scanString();
                }
                else if (path == "/refresh_token")
                {
                    refreshToken = scanner.scanString();
                }
            }
        }
        else
        {
            log_e("Error: %d, %s", result, httpClient.getString().c_str());
        }
        httpClient.end();
        return result;
    }

    Access tokenには有効期限があって、だいたい1時間らしいです。有効期限が来たらHTTPレスポンスコード401が返ってくるので、Refresh tokenを使って新しいAccess token(と新しいRefresh token)を取得します。

    Playbackを取得する

    無事Access tokenを取得できたら、API呼び出し時にAuthorization: BearerでAccess tokenを渡します。

    Spotifyの現在の再生状態を取得するにはPlaybackを取得します。

    レスポンスには再生中のデバイスIDやボリューム、曲名やアーティスト名などが入っています。注意点としては、再生が停止して10分だか経つとこのAPIは204 No contentを返し、何も情報が返ってきません。その場合はデバイスリストを表示して再生デバイスを選択するところから始めるようにしています。

    int SPClient::getPlaybackState()
    {
        httpClient.begin("https://api.spotify.com/v1/me/player", SpotifyPEM);
        httpClient.addHeader("Authorization", "Bearer " + accessToken);
        int result = httpClient.GET();
        if (result == HTTP_CODE_OK)
        {
            WiFiClient *stream = httpClient.getStreamPtr();
            JsonStreamScanner scanner = JsonStreamScanner(stream);
            while (scanner.available())
            {
                String path = scanner.scanNextKey();
                if (path == "/device/id")
                {
                    deviceID = scanner.scanString();
                }
                else if (path == "/device/volume_percent")
                {
                    volume = scanner.scanInt();
                }
                else if (path == "/device/supports_volume")
                {
                    supportsVolume = scanner.scanBoolean();
                }
                else if (path == "/progress_ms")
                {
                    progress_ms = scanner.scanInt();
                }
                else if (path == "/is_playing")
                {
                    isPlaying = scanner.scanBoolean();
                }
                else if (path == "/item/artists/name")
                {
                    artistName = scanner.scanString();
                }
                else if (path == "/item/duration_ms")
                {
                    duration_ms = scanner.scanInt();
                }
                else if (path == "/item/name")
                {
                    trackName = scanner.scanString();
                }
            }
        }
        else
        {
            log_e("Error: %d", result);
        }
        httpClient.end();
        if (result == 401)
            needsRefresh = true;
        return result;
    }

    なんて美しくないelse ifのネストなのでしょうか。もうちょっといいパーサを作るべきです。

    デバイスリストを取得する

    デバイスリストも同様にGETメソッドを使うAPIです。今回作った簡易JSONパーサは配列を理解しないので、同じキーが繰り返し登場するという動作になります。それを配列に突っ込んでいくだけです。

    int SPClient::getDeviceList()
    {
        httpClient.begin("https://api.spotify.com/v1/me/player/devices", SpotifyPEM);
        httpClient.addHeader("Authorization", "Bearer " + accessToken);
        int result = httpClient.GET();
        if (result == HTTP_CODE_OK)
        {
            WiFiClient *stream = httpClient.getStreamPtr();
            JsonStreamScanner scanner = JsonStreamScanner(stream);
            while (scanner.available())
            {
                String path = scanner.scanNextKey();
                if (path == "/devices/id")
                {
                    String idString = scanner.scanString();
                    if (!idString.isEmpty())
                        deviceIDs.push_back(idString);
                }
                else if (path == "/devices/name")
                {
                    String nameString = scanner.scanString();
                    if (!nameString.isEmpty())
                        deviceNames.push_back(nameString);
                }
            }
        }
        else
        {
            log_e("Error: %d", result);
        }
        httpClient.end();
        if (result == 401)
            needsRefresh = true;
        return result;
    }

    PUTメソッド

    再生のPause/Resume、ボリュームの変更、再生デバイスの変更などはAPIをPUTメソッドで呼び出します。レスポンスをパースする必要がないので、一つ関数を作っておきます。

    int SPClient::sendPutCommand(String urlString, String payload)
    {
        httpClient.begin(urlString, SpotifyPEM);
        httpClient.addHeader("Authorization", "Bearer " + accessToken);
        int result = httpClient.PUT(payload);
        httpClient.end();
        if (result == 401)
            needsRefresh = true;
        return result;
    }

    これを下記のような感じで呼び出します。

    // Request changing volume
    int SPClient::changeVolume(int newVolume)
    {
        return sendPutCommand("https://api.spotify.com/v1/me/player/volume?volume_percent=" + String(newVolume), "{}");
    }
    
    // Request resume
    int SPClient::resumePlayback()
    {
        return sendPutCommand("https://api.spotify.com/v1/me/player/play", "{}");
    }
    
    // Request pause
    int SPClient::pausePlayback()
    {
        return sendPutCommand("https://api.spotify.com/v1/me/player/pause", "{}");
    }
    
    // Transfer Playback to specified device
    int SPClient::selectDevice(String newDeviceID)
    {
        return sendPutCommand("https://api.spotify.com/v1/me/player", "{ \"device_ids\": [\"" + newDeviceID + "\"] }");
    }

    URLのパラメーターに値を渡す場合と、リクエストボディにJSONを渡す場合があったりして時々ややこしいです。Pause/Resumeのように何も渡さない場合、なぜか空のJSON「{}」を渡さないと反応しなかったのでそうしています。

    POSTメソッド

    前の曲、次の曲にスキップするAPIはPOSTメソッドを使用します。PUTと同じように関数を作っておいて(PUTをPOSTに変えるだけ)それを呼び出します。

    // Request skipping to next track
    int SPClient::skipToNext()
    {
        return sendPostCommand("https://api.spotify.com/v1/me/player/next", "");
    }
    
    // Request skipping to previous track
    int SPClient::skipToPrev()
    {
        return sendPostCommand("https://api.spotify.com/v1/me/player/previous", "");
    }

    POSTを使用するAPIは、何も値を渡さない場合でも空のリクエストボディでいいようです。

    できたもの

    画像

    動かすとこんな感じです。再生やスキップはタップで操作、ボリュームはダイヤルを回し、ダイヤル下部の押し込みボタンを押すとデバイス選択ができます。デバイスリストのスクロールもダイヤルで行います。

    上のコードは簡略化しているので、本来のコードはGitHubを見てください。

    未来へ…

    プレイリスト選択や曲選択も実装したい。アーティスト名が時々英語になってる問題はAccept-Language: JPにすればいいらしいです。
    SDカードを使用しないWi-Fi設定やOAuth 2.0対応など他のM5シリーズで使えそうな手法が出てきたのでだいぶ勉強になりました。

    あなたへのおすすめ