ArduinoやM5StackでSORACOMのメタデータサービスを使いこなそう

:::note info
この記事は[SORACOM Advent Calendar 2023](https://qiita.com/advent-calendar/2023/soracom)の12日目の記事です。
前日の記事は「[Python だけで ソラカメ の MPEG-DASH を再生してみた](https://uchimanajet7.hatenablog.com/entry/2023/12/11/181420)」でした。
:::

# はじめに
ごきげんよう、ソラコムCREの岡田（hisaya）です。
2023年、皆さんはどんな1年だったでしょうか。私の今年のハイライトは「ニューカレドニアに旅行したら滞在中ほぼずっと雨だった」ことと「自転車でコケて右肘（※利き腕）を骨折した」ことの2点でしょうか。実はまだ骨折の固定が外れていない中この記事をしたためていたりするのですが、こんな一年だった分、きっと来年はハチャメチャにいいことがあると思うことにしています。

さて、今年のSORACOM Discovery 2023のプロトタイピングコーナーで展示したスマートコースターでも活用したメタデータサービスですが、展示物の説明に立っていると思いの外既存のソラコムユーザーの方でも「知らなかった」「ぜひ活用したい」といった声を耳にすることがありました。
独立したサービスではなくSORACOM AirやArcの付加機能なので目立つことが少ないのはある意味仕方のないことではあるのですが、使ってみると非常に便利で奥の深い機能になっています。

https://blog.soracom.com/ja-jp/2023/08/09/soracom-discovery-2023-prototyping-smart-coaster/

特にタグやユーザーデータ領域を個体管理や設定データなどの保管・配信に利用するとハードウェアや実装レベルでの変更を抑えることができたり、SORACOM APIなどと組み合わせつつデバイス側で定期的にメタデータサービスにアクセスする機構を実装して擬似的に双方向通信を実現するといった使い方もできます。

今回は主にArduino系の開発環境を利用するハードウェア（LTE-M Shield for Arduinoを接続したArduinoやM5Stackなど）をターゲットとして、メタデータサービスのタグやユーザーデータにアクセスする機構の実装例を紹介します。
なお、メタデータサービス自体の使い方やアクセスするエンドポイントの詳しい使い方はユーザードキュメントを確認してください。

https://users.soracom.io/ja-jp/docs/air/use-metadata/

:::note warn
**免責事項**
本記事内で紹介している実装を株式会社ソラコムが推奨したり、動作を保証するものではありません。実装を参考にする場合はユーザードキュメントや実際の振る舞い、ライブラリのライセンス等を確認のうえ、自身の責任において利用してください。
:::

# 準備
今回の実装例ではおもに下記ライブラリを利用します。

- [TinyGSM](https://github.com/vshymanskyy/TinyGSM)
- [ArduinoHttpClient](https://github.com/arduino-libraries/ArduinoHttpClient)
- [ArduinoJson](https://github.com/bblanchon/ArduinoJson) （レスポンスのパースに必要な場合）
- [WireGuard-ESP32-Arduino](https://github.com/ciniml/WireGuard-ESP32-Arduino) （SORACOM Arcを利用する場合）

なお、以降の実装はM5Stack Core2 (ESP32)とUnitCatM+GNSSモジュールの組み合わせで動作を確認しています。Arduino UNOとLTE-M Shield for Arduinoの組み合わせでも動作しましたが、ユースケースによってはメモリ消費量を考慮（調整）しないと正常に動作しない可能性がある点に注意してください。
また、通信モジュールを含むハードウェアの初期化処理などはこの記事での説明の対象外とします。

# 実装
それでは実装していきます。

## HTTPクライアントのベース
メタデータサービスはHTTPのREST APIなので、まずは汎用的に使えるHTTPクライアントを実装していきます。
ここでは[Client クラス](https://github.com/arduino/ArduinoCore-avr/blob/master/cores/arduino/Client.h)、メソッドやパス、リクエストボディを引数として受け取り、レスポンスボディを文字列として返す関数を定義します。

```cpp:HTTPクライアントのベース実装
// ヘッダ行に下記を追加 （Arduino coreに内包）
#include "Client.h"
#define CONSOLE Serial

String httpRequest(Client& client, String host, int port, String path, String method, String contentType, String requestBody) {
  CONSOLE.println("Requesting " + host);
  char ERR_MSG_BUF[50] = { '\0' };

  HttpClient httpClient(client, host, port);
  int err = httpClient.startRequest(path.c_str(), method.c_str(), contentType.c_str(), requestBody.length(), (const byte*)requestBody.c_str());
  if (err != 0) {
    sprintf(ERR_MSG_BUF, "[Failed to get userdata; err code %d]\n", err);
    CONSOLE.println(ERR_MSG_BUF);
    httpClient.stop();
    return "";
  }

  int statusCode = httpClient.responseStatusCode();
  if (!statusCode) {
    sprintf(ERR_MSG_BUF, "[Status code does not provided]");
    CONSOLE.println(ERR_MSG_BUF);
    httpClient.stop();
    return "";
  }
  CONSOLE.print("Status code returned "); CONSOLE.println(statusCode);

  CONSOLE.println(F("Response Headers:"));
  while (httpClient.headerAvailable()) {
    String headerName  = httpClient.readHeaderName();
    String headerValue = httpClient.readHeaderValue();
    CONSOLE.println("    " + headerName + " : " + headerValue);
  }

  int length = httpClient.contentLength();
  if (length >= 0) {
    CONSOLE.print(F("Content length is: "));
    CONSOLE.println(length);
  }
  if (httpClient.isResponseChunked()) {
    CONSOLE.println(F("The response is chunked"));
  }

  String responseBody = httpClient.responseBody();
  CONSOLE.println(F("Response:"));
  CONSOLE.println(responseBody);

  CONSOLE.print(F("Body length is: "));
  CONSOLE.println(responseBody.length());
  CONSOLE.println();

  httpClient.stop();

  return responseBody;
}
```

## メタデータサービスへのアクセス
定義したHTTPクライアントのベースを利用して、メタデータサービスのリソースにアクセスします。

### 取得系

#### 固有のタグやSIM情報の取得
「タグ」は、SIMもしくは所属SIMグループに対して設定可能なキーバリューストアです。

```cpp:SIM に設定された特定のタグを取得する例
String getSubscriberTagValue(Client& client, String tagName) {
  CONSOLE.println("Getting specific tag value [" + tagName + "]...");

  String responseBody = httpRequest(client, "metadata.soracom.io", 80, "/v1/subscriber.tags." + tagName, "GET", "", "");
  return responseBody;
}
```

#### ユーザーデータの取得

ユーザーデータは、SIMグループに所属しメタデータサービスを有効化している場合に利用可能です。任意のテキストコンテンツやJSONを配置できるので自由度が高く、SORACOM Harvest Filesなどを有効化しなくても利用できる領域です。

ちなみに、[去年のAdvent Calendar](https://qiita.com/Plemling138/items/8b95c320225b4efd9437)でも最初はメタデータサービスのユーザーデータ領域にMicroPythonスクリプトを配置して実行していました（最終的にはSORACOM Harvest Filesにアクセスしてダウンロードしていました）。

:::note warn
ユーザーデータに配置できるコンテンツは、SIMグループ設定全体のサイズ制約の影響を受けます。
https://users.soracom.io/ja-jp/docs/group-configuration/configure-group/
:::

```cpp:ユーザーデータを取得する例
String getUserdata(Client& client) {
  CONSOLE.println("Getting userdata...");

  String responseBody = httpRequest(client, "metadata.soracom.io", 80, "/v1/userdata", "GET", "", "");
  return responseBody;
}
```

### 作成・更新

グループ設定で「読み取り専用」のチェックを外すとメタデータサービス経由でタグなどのリソースに対して作成・更新・削除系の操作ができるようになるため、デバイスからメタデータサービスを経由してタグやユーザーデータ領域の内容を変更できます。

下記は指定したタグのキーとバリューを更新（存在しない場合は追加）するリクエスト例です。
```cpp:SIMタグを作成・更新する例
String putSubscriberTagValue(Client& client, String tagName, String tagValue) {
  CONSOLE.println("Putting specific tag [" + tagName + "] as [" + tagValue + "]...");
  String requestBody = "[{\"tagName\": \"" + tagName + "\", \"tagValue\": \"" + tagValue + "\"}]";

  String responseBody = httpRequest(client, "metadata.soracom.io", 80, "/v1/subscriber/tags", "PUT", "application/json", requestBody);
  return responseBody;
}
```

### 削除

指定したキーのタグを削除する場合は以下のようなリクエストとなります。

```cpp:SIMタグを削除する例
String deleteSubscriberTag(Client& client, String tagName) {
  CONSOLE.println("Deleting specific tag value [" + tagName + "]...");

  String responseBody = httpRequest(client, "metadata.soracom.io", 80, "/v1/subscriber/tags/" + tagName, "DELETE", "", "");
  return responseBody;
}
```

## 動作チェック
ここまでに実装したメソッドを利用して、ユーザーデータやSIMタグの参照・更新・削除を試してみます。

勘のいい方はお気づきかもしれませんが、ここまでに実装してきたメソッドでは`Client`クラスを引数としていました。この形で引数を取っておくと、たとえばTinyGSMの`TinyGsmClient`やESP32でWireGuardを有効化した`WiFiClient`を渡すことで、SORACOM AirやArcの種類を問わず同一のHTTPクライアント実装でメタデータサービスにアクセスできるようになり、実装のコンパクト化やコネクティビティの変更に柔軟に対応できるようになります。

:::note info
サンプルコードの全文は下記Gistに掲載しています。
https://gist.github.com/sayacom/2adcfbec8186b4a91a2b393e2dcacd35

このサンプルではM5Stack Core2をターゲットボードとし、TinyGSMによるSORACOM Airのセルラー接続とESP32-WireGuardによるSORACOM Arcの接続を同時に有効化し、それぞれの回線から同一のクライアント実装を利用してメタデータサービスにアクセスしています。
:::

```cpp
void useMetadataServiceDemo(Client& client) {
  // Get userdata from metadata service
  String userData = getUserdata(client);
  CONSOLE.print("USERDATA : ");
  CONSOLE.println(userData);

  // Get subscriber specific tag value
  String tagValue = getSubscriberTagValue(client, "TAGS_TO_DEVICE");
  CONSOLE.print("TAGS_TO_DEVICE : ");
  CONSOLE.println(tagValue);

  // Put subscriber specific tag value
  tagValue = getSubscriberTagValue(client, "TAGS_FROM_DEVICE");
  CONSOLE.print("TAGS_FROM_DEVICE : ");
  CONSOLE.println(tagValue);
  
  String testTagValue = String(millis());
  String updatedTagResult = putSubscriberTagValue(client, "TAGS_FROM_DEVICE", testTagValue);
  CONSOLE.print("Updated TAGS_FROM_DEVICE: ");
  CONSOLE.println(updatedTagResult);
  
  // Delete subscriber specific tag value
  result = deleteSubscriberTag(client, "TAGS_FROM_DEVICE");
  tagValue = getSubscriberTagValue(client, "TAGS_FROM_DEVICE");
  CONSOLE.print("TAGS_FROM_DEVICE : ");
  CONSOLE.println(tagValue);
}
```
```cpp
void setup() {
  // TinyGsmClientの初期化（実際の初期化は機器に応じて適宜実装してください）
  TinyGsm gsmModem(Serial2);
  TinyGsmClient cellularClient = initializeGsmModem(gsmModem);

  CONSOLE.println("\n\n");
  CONSOLE.println("*********************");
  CONSOLE.println("*   USE CELLULAR    *");
  CONSOLE.println("*********************");
  useMetadataServiceDemo(cellularClient);
}
```

# 実行結果
ここまでの実装踏まえ、サンプルコードを実行した結果の例を示します（一部省略や加工をしています）。
ユーザーデータや`TAGS_TO_DEVICE`というタグの内容を読み出したり、`TAGS_FROM_DEVICE`というタグが実行時のミリ秒の数字で上書きされている様子が分かると思います。

```
> Getting userdata...
> Requesting metadata.soracom.io
> Status code returned 200
> Response Headers:
>     Content-Type : text/plain
>     Content-Length : 44
>     Date : Mon, 04 Dec 2023 12:05:12 GMT
>     Connection : close
> Content length is: 44
> Response:
> Hello, This is userdata on metadata service!
> Body length is: 44
> 
> USERDATA : Hello, This is userdata on metadata service!
> Getting specific tag value [TAGS_TO_DEVICE]...
> Requesting metadata.soracom.io
> Status code returned 200
> Response Headers:
>     Content-Type : text/plain
>     Date : Mon, 04 Dec 2023 12:05:13 GMT
>     Connection : close
>     Transfer-Encoding : chunked
> The response is chunked
> Response:
> Hello, this is SIM TAG!
> 
> Body length is: 24
> 
> TAGS_TO_DEVICE : Hello, this is SIM TAG!
> 
> Getting specific tag value [TAGS_FROM_DEVICE]...
> Requesting metadata.soracom.io
> Status code returned 200
> Response Headers:
>     Content-Type : text/plain
>     Date : Mon, 04 Dec 2023 12:05:16 GMT
>     Connection : close
>     Transfer-Encoding : chunked
> The response is chunked
> Response:
> 21926
> 
> Body length is: 6
> 
> TAGS_FROM_DEVICE : 21926
> 
> Putting specific tag [TAGS_FROM_DEVICE] as [29941]...
> Requesting metadata.soracom.io
> Status code returned 200
> Response Headers:
>     Date : Mon, 04 Dec 2023 12:05:19 GMT
>     Content-Type : application/json
>     Content-Length : 1440
>     Connection : close
> Content length is: 1440
> Response:
> {"imsi":"44010XXXXXXXXXX",......,"tags":{"TAGS_TO_DEVICE":"Hello, this is SIM TAG!","TAGS_FROM_DEVICE":"29941","name":"SIM-1"},......}
> Body length is: 1440
> 
> Updated TAGS_FROM_DEVICE: {"imsi":"44010XXXXXXXXXX",......,"tags":{"TAGS_TO_DEVICE":"Hello, this is SIM TAG!","TAGS_FROM_DEVICE":"29941","name":"SIM-1"},......}

```

# おわりに
メタデータサービスを利用するリクエスト実装の例をご紹介しました。今回の例はHTTPリクエストやクライアント（SORACOM AirとSORACOM Arc）の変更なども考慮した実装となっていますので、それぞれの関数の実装だけでもアイデアの実現の参考になれば幸いです。
今回紹介したタグやユーザーデータ以外にもメタデータサービス経由で利用できる機能や使い方はまだたくさんありますので、ぜひユーザードキュメントを参考に有効に活用してみてください！