Azure × FlashAir でリングフィットアドベンチャーの履歴を記録する（1）Blobにアップロード編

# はじめに
コロナ禍の引きこもり生活ですっかり運動不足になってしまったので、運動習慣を付けようと今更ながらリングフィットアドベンチャーに入門しました。

https://twitter.com/ussvgr/status/1498276372934180866

プレイの記録をApple Watchで取っていたのですが、始める際にいちいち[ワークアウトを開始](https://support.apple.com/ja-jp/HT204523)するのが面倒でした。
何とかこれを省力化できないかと思いネットを徘徊していると、Switchの機能でリザルト画面のスクショを撮りTwitterに投稿、それを画像解析して記録する例が見つかりました。

- [【python】リングフィットの画像からグラフ化してみる【OCR】](https://qiita.com/K-Nomu3/items/3ee3f9a8a8a7cd2de81b)
- [リングフィットアドベンチャーの記録で友達と競える仕組みを作った](https://zenn.dev/24/articles/ringfit-automation)

しかし、自分の記録をTwitterに投稿するのはなんか気恥ずかしい…と悩んでいたところに妙案が浮かびました。

「FlashAirを使えばいいじゃないか」と。

というわけで、とうの昔に旬が過ぎ去ったネタのような気もしますが、リングフィットアドベンチャーとFlashAirを組み合わせて運動結果の自動記録システムを作っていきます。
お仕事でAzureをよく使うので、バックエンドはAzureで構築します。

# 全体像
こんな感じのものを作ります。
![ringfit.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274613/9ddbeb21-f84d-3ae7-3e7b-6793118579f3.jpeg)
1. Switchでスクリーンショットを記録
![サンプル](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274613/988093b0-f357-b88b-d28e-de9f87866b80.png)


2. ファイルの保存をトリガーにしてFlashAirがBlob Storageへ画像をアップロード
3. Blob追加をトリガーにEventGrid経由でFunctionsを起動
4. Azure Cognitive Serviceを使ってOCRを行い活動時間・消費カロリーを取得
5. Google Fitに記録

全体を説明すると長くなるので、本記事ではFlashAirからBlobアップロードの部分のみを解説します。


# FlashAirとは
東芝が販売してい**た**IoT機能を持ったSDカードです。
（会社がキオクシアに変わっていつの間にかディスコンになってるっぽい？悲しい…。）
[旧ブランド製品 | KIOXIA](https://personal.kioxia.com/ja-jp/support/legacy-products.html)

Wifiでネットワークに接続でき、Luaで書いたスクリプトを実行することで様々な処理を実行することができます。

# 用意したもの
- [コムテック レーザー受信対応レーダー探知機 ZERO 708LV専用 無線LAN内蔵SDHCカード WSD16G-708LV](https://www.amazon.co.jp/gp/product/B08BKF73B7)
前述のとおりFlashAirがディスコンになってしまったようで、Amazonやオークションサイトなどでは非常に高値で取引されています。
とても手が出ないので、FlashAirのOEM品とウワサのこの製品を購入しました。第4世代FlashAirと同等のもののようです。~~（これも十分高かったですが…）~~

- [Cablecc 1Set SDTFカードソケットメスからMicro-SDTFオスメモリカードキットエクステンションアダプターテストツールエクステンダー](https://www.amazon.co.jp/gp/product/B08P4P54Q8)
SwitchにはMicroSDカードしか刺せないので変換アダプタが必要です。
フレキケーブルタイプのものも販売されていますが、UHS-3カードの認識に難があるというレビューを見かけたため、安定してそうな基板タイプをセレクトしました。

- [3XI Type C ハブ 4in1 USB C 4K HDMI出力 PD 充電対応 USB3.0 USB2.0 多機能アダプター](https://www.amazon.co.jp/gp/product/B08769V687)
変換アダプタを利用すると純正ドックが使えなくなるため、USB Type-Cのハブを用意しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274613/ce7b6af7-1053-1982-870c-77627362579e.png)
~~ここまでで1万円以上使ってるので後には引けません~~

# Azure側の準備
画像アップロードの受け口となるストレージアカウントを作成します。

リソースグループを作成し、
```bash
$ az group create --location japaneast --resource-group RingFitRecorder
```
ストレージアカウントを作成。

```bash
$ az storage account create -g RingFitRecorder -n ringfitimage --sku Standard_LRS
```
ストレージアカウント作成時の出力結果の「id」の値をscopesに設定しサービスプリンシパルを作成します。

```bash
az ad sp create-for-rbac -n RingFitRecorder --scopes /subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/RingFitRecorder/providers/Microsoft.Storage/storageAccounts/ringfitimage --role "Storage Blob Data Contributor" --year 100
```
サービスプリンシパル作成時の出力結果は次の工程で必要になるので忘れずに控えておきましょう。

作成したストレージアカウントに「upload」というコンテナーを作成しておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274613/ea65d60f-4996-b0ab-a3e4-9123b17d3328.png)

これでAzure側の準備は完了です。

# FlashAirの準備
SDカード内の /SD_WLAN/CONFIG ファイルを編集します。
以下の行のみ変更・追記すればよいでしょう。

```
APPMODE=5                            # 自動起動モードで無線LAN機能を子機として起動
APPSSID=<無線LANのSSID>
APPNETWORKKEY=<無線LANのパスワード>
UPLOAD=1                             # 必要ないかもしれないが、念のためアップロード機能をオンに
LUA_SD_EVENT=/RingFitRecorder.lua    # ファイル書き込みが発生した際に実行するLuaスクリプトファイルを指定する
```

次に、画像をアップロードするLuaスクリプトを「RingFitRecorder.lua」というファイル名でSDカード直下に保存します。
FlashAirはファイル書き込み時にスクリプトを実行してくれますが、どのファイルが更新されたかは通知してくれません。そのため、スクリプト内で最新更新日時を持つファイルを探索しています。Switchのスクリーンショットは「/Nintendo/Album」配下に記録されるので、このディレクトリ内を探索対象としています。
また、後のFunctionsの処理でファイルの記録された時間を利用したいため、アップロード時のファイル名をUNIX時間＋拡張子としています。

```RingFitRecorder.lua
-- Azure config
tennant_id = "<AzureのテナントID>"
client_id = "<サービスプリンシパルのクライアントID>"
client_secret = "<サービスプリンシパルのシークレット>"
azure_storage_base_path = "https://ringfitimage.blob.core.windows.net/"
blob_container_name = "upload/"

-- ディレクトリ内のファイルを再帰探索する
function FileGet(fpath)
    for dirname in lfs.dir(fpath) do
        local dirpath = fpath .. "/" .. dirname
        local mod_dir = lfs.attributes(dirpath, "mode" )
        if mod_dir == "directory" then 
            FileGet(dirpath)
        else
            print(dirpath)
            table.insert(files, dirpath)
        end
    end
end

-- FAT時間を時刻テーブルへ変換する
function GetFileModificationTime(Fat_binary_time)
    local date ={
        year = bit32.band (bit32.rshift(Fat_binary_time, 9+16),0x7F) + 1980,
        month = bit32.band (bit32.rshift(Fat_binary_time, 5+16),0x0F),
        day = bit32.band (bit32.rshift(Fat_binary_time,0+16),0x1F),
        
        hour = bit32.band (bit32.rshift(Fat_binary_time, 11),0x1F),
        min = bit32.band (bit32.rshift(Fat_binary_time, 5),0x3F),
        sec = bit32.band (Fat_binary_time,0x1F)*2; --FAT時間は秒数が2秒刻み
    }
    return date
end

print("start!")

cjson = require "cjson"

-- 最新更新ファイルを取得
fpath = "/Nintendo/Album" 
files = {}
FileGet(fpath)
max_mod = 0
last_file = ""
for n,file in pairs(files) do
    mod = lfs.attributes(file, "modification")
    if mod >= max_mod then
        last_file = file
        max_mod = mod
    end
end

-- fpath配下にファイルがなければ処理終了
if last_file == "" then
    print("file not exist.")
    return;
end

-- デバッグ用: 処理対象のファイルと、そのファイルの更新日時(FAT時間形式)を表示
print(last_file)
print(max_mod)


-- Bearerトークン取得
auth_url = "https://login.microsoftonline.com/" .. tennant_id .. "/oauth2/token"
auth_payload = "grant_type=client_credentials"
            .. "&resource=https://storage.azure.com/"
            .. "&client_id=" .. client_id
            .. "&client_secret=" .. client_secret
length = string.len(auth_payload)

auth_body = fa.request{
    url=auth_url,
    method="POST",
    headers = {
        ["Content-Length"] = length
    },
    body=auth_payload
}

token = cjson.decode(auth_body)["access_token"]

-- x-ms-dateヘッダに設定する日付をファイルの更新日時から生成
mod_unixtime = os.time(GetFileModificationTime(max_mod)) - 9 * 60 * 60
mod_date = os.date("%a, %d %b %Y %H:%M:%S",mod_unixtime) .. " GMT"

-- Content-Lengthヘッダ用にファイルサイズを取得する
file_size =  lfs.attributes(last_file, "size")

-- Blobアップロード先のURLを生成(UNIX時間をファイル名に使用する)
blob_url = azure_storage_base_path .. blob_container_name .. tostring(mod_unixtime) .. string.sub(last_file, -4)
print(blob_url)

-- "<!--WLANSDFILE-->" がファイルの中身と置き換わる
blob_payload = "<!--WLANSDFILE-->"

-- Blobアップロード
upload_body,upload_code,upload_header = fa.request{
    url=blob_url,
    method="PUT",
    headers={
        ["Authorization"] = "Bearer " .. token ,
        ["x-ms-date"] = mod_date ,
        ["Content-Length"] = file_size ,
        ["x-ms-version"] = "2021-04-10" ,
        ["x-ms-blob-type"] = "BlockBlob"
    },
    file=last_file,
    body=blob_payload
}

print(upload_body)
print(upload_code)
print(upload_header)
```

# アップロードのテスト
ここまで準備できたら、FlashAirをSwitchに刺してスクリーンショットを撮ってみましょう。
無事にファイルがアップロードされていればオッケーです！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/274613/3f4c32de-df03-30a2-4f1e-d1ad1a45d767.png)

# ハマったポイント
ここまで作り上げるのにハマったポイントを紹介しておきます。
### FlashAirが通信できるURLの文字数に上限がある？
当初アップロードにSASトークンを利用しようとしていたのですが、謎のエラーが表示され困っていました。
いろいろと試してみた限り、通信する先のURLが長すぎるとエラーになってしまうようです。SASトークンはURLの末尾に付与する形になるので、文字数制限に引っかかってしまったようです。
上述のコードのように、サービスプリンシパルのBearer Tokenを取得しヘッダに付与することでクリアできました。

### TLS1.1以上が利用できない
ストレージアカウントの設定で、最小のTLSバージョンを1.0に設定しないとエラーになります。
ざっと探した限りでは明言された仕様は見つかりませんでしたが、TLS1.0までしか対応していないようです。

### 「/Nintendo/Album」以外のディレクトリに対し書き込みがあってもスクリプトが実行されてしまう
SDカード内のすべての書き込みイベントに対しスクリプトを実行してしまいます。
そのたびに「**/Nintendo/Album内の**最新ファイルを探索してアップロード」という挙動になってしまうため、重複してアップロードしてしまう可能性があります。
とはいえ、ファイル名が同じなのでアップロード時にエラーになるはずなので、きっと大丈夫だと楽観視してます。
また、AzureのREST APIの仕様上、x-ms-dateヘッダに付与した日付が現在時刻から15分ズレるとエラーになるため、古いデータが誤ってアップロードされる可能性は低いはずです。

### スクリーンショットを連続で撮るとアップロードに漏れが生じる
連続で撮らないでください。運用でカバーです。

### 動画を撮影してもアップロードされてしまう
動画を撮らないでください。運用でカバーです。

# おわりに
というわけでSwitchからAzureへアップロードするところまでができました。
次回はFunctionsを起動してCognitive ServiceでOCR処理を行うところまでを解説したいと思います。
（なおFunctionsの処理は未完成のため、次回投稿は未定です）


# 補足
https://twitter.com/ussvgr/status/1505961607721287680

そういえば去年あたりにAzure ADのTLS1.0・1.1を廃止するってアナウンスあったような…。
上記の通りFlashAirがTLS1.0までしか対応していないようなので、廃止されたら別の手を考えなければいけません。


# 謝辞
- [ネットワークサービスにおける任天堂の著作物の利用に関するガイドライン｜任天堂](https://www.nintendo.co.jp/networkservice_guideline/ja/index.html)
ガイドラインに則してると思うので任天堂様もきっと許してくれるはず。


- [FlashAir Developers - website](https://flashair-developers.github.io/website/)
- [FlashAir開発者向け非公式wiki](https://seesaawiki.jp/flashair-dev/)
めちゃくちゃ参考にさせていただきました。ありがとうございます。
ライセンスが[WTFPL](http://www.wtfpl.net/)とのことなので、[ファイル更新日時取得のあたり](https://seesaawiki.jp/flashair-dev/d/Lua%3A%A5%D5%A5%A1%A5%A4%A5%EB%B9%B9%BF%B7%C6%FC%BB%FE%A4%CE%BC%E8%C6%C0)はほぼそのまま利用させていただきました。