M5StackとSORACOMで作る在席確認システム

# はじめに
こちらは[SORACOM Advent Calendar 2024](https://qiita.com/advent-calendar/2024/soracom) 8日目の記事です。

https://qiita.com/advent-calendar/2024/soracom

# 経緯
会社の上司からとある役員が席にいるのかどうかを分かるようにできないのか？と相談があり、かなり今更感はあるけどM5StackとSORACOMで在席確認システムを作ってみました。

# 構成
## ハードウェア
今回使うハードウェアは以下となります。
* [M5Stack Basic](https://www.switch-science.com/products/9010)
* [M5Stack用ToF測距センサユニット](https://www.switch-science.com/products/5219)
* 100均のスマホホルダー
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/92564bea-ea02-26d0-4b91-074d56ea1b77.png)

これらを両面テープで固定し、以下のようにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/e9c9ddef-6898-1a07-1511-2a5252c556a7.png)


## 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/d9360ef1-749c-2b4d-f09d-e9d209b71577.png)
## 使うSORACOMサービス
* [SORACOM Arc](https://soracom.jp/services/arc/)
* [SORACOM Harvest Data](https://soracom.jp/services/harvest/)
* [SORACOM Lagoon](https://soracom.jp/services/lagoon/)
* [SORACOM Air メタデータサービス](https://users.soracom.io/ja-jp/docs/air/use-metadata/)

# 仕様
## 在席判定
以下の判定を行います。
- M5Stackに接続した距離センサーを使って、人が特定の範囲内に人が居るかを測る
- 特定範囲の距離はしきい値として、外部から指定できるようにする
- ノイズを考慮し、毎秒30秒間取得した距離が5回以上しきい値以下であれば在席と判定する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/e25932aa-cc09-5e61-f406-31e680fbec50.png)
## 取り込み中表示
対象者（役員）が電話中や、割り込んでほしくない作業をしている際に、M5StackのCボタン（画面下の3つのボタンの右端）のボタンを押すと「取り込み中」となるようにし、再度押すと「在席中」となるようにします。

# 準備
## 開発環境
開発環境はArduino IDEです。
Arduino IDEの準備作業については過去にたくさんの記事があるので、割愛させていただきます（SORACOM Usersのドキュメントは[こちら](https://users.soracom.io/ja-jp/guides/dev-boards/m5stack/development-environment/)）。

## SORACOM Arc
SORACOM Arcを使うと、SORACOM SIMを使わなくても通常のWi-Fi環境でSORACOMにデータを送れるようになります。
以下を参考にバーチャルSIMの登録を行い、`PrivateKey`、`Address`、`PublicKey`、`Endpoint`をメモしておきます。

https://users.soracom.io/ja-jp/docs/arc/create-virtual-sim/

# 開発
## M5Stack
最近はもっぱらChatGPTなどの生成AIにコードの下地を作ってもらいます。
今回はClaudeに以下のプロンプト（指示文）で作ってもらいました。
```
arduino ideでM5Stack CoreにVL53LOXを接続して距離を測る在席確認システムを作りたい。
以下の仕様で作って。
・1秒ごとに1回計測する
・起動時にWi-Fiに接続する
・起動後30秒間「初期化中（0）」とする
・直近30秒の計測のうち、5回以上距離がしきい値以下であれば「在席（1）」とする
・直近30秒の計測のうち、26回以上距離がしきい値より大きければ「不在（-1）」とする
・在席中にCボタンを押すと「取り込み中（2）」とする
・取り込み中にCボタンを押すと「在席（1）」とする
・状態変更時にhttp://www.xxx.comにJSON{"status": -1～2}の形式でデータを送る
・在席「Available」、不在「Away」、取り込み中「Busy」、初期化中「Initializing」を画面に表示する
・しきい値と距離はシリアルモニタに出力する
```
めちゃくちゃそれらしいの作ってくれます（ホントスゴイ！）。
それにSORACOM Arc接続やら、しきい値の初期値をSIMタグからとってくる処理を追加して、最終的には以下のようなコードになりました。

```c++
#include <M5Stack.h>
#include <Wire.h>
#include <VL53L0X.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <WireGuard-ESP32.h>
#include <ArduinoJson.h>

// Wi-Fi設定
const char* ssid = "（無線APのSSID）";
const char* password = "（無線APのパスワード）";
const char* serverUrl = "http://uni.soracom.io";

// SORACOM ArcのWireGuard情報 ※ポイント1
const char* private_key = "（SORACOM Arcのprivate_key）";
IPAddress local_ip(XXX, XXX, XXX, XXX);            // SORACOM ArcのIPアドレス
const char* peer_public_key = "（SORACOM Arcのpublic_key）";
const char* endpoint_address = "xxxx.arc.soracom.io"; // SORACOM Arcのエンドポイント
const int endpoint_port = 11010; // WireGuardのデフォルトポート

// センサー関連の定数
VL53L0X sensor;
const int MEASUREMENT_INTERVAL = 1000;  // 1秒ごとに測定
const int HISTORY_SIZE = 30;  // 30秒分の履歴
int distanceHistory[HISTORY_SIZE];
int historyIndex = 0;
int threshold = 1000;  // しきい値（mm）

// システムの状態定義
enum SystemState {
  STATE_INITIALIZING = 0,
  STATE_AVAILABLE = 1,
  STATE_BUSY = 2,
  STATE_AWAY = -1
};

SystemState currentState = STATE_INITIALIZING;
unsigned long lastMeasurementTime = 0;

WireGuard wg;

// 関数プロトタイプ宣言
void initDisplay();
void connectToWiFi();
void connectToWireGuard();
void getInitialData();
SystemState determineState();
void displayState(SystemState state);
void sendStateUpdate(SystemState state);

void setup() {
  // M5Stackの初期化
  M5.begin();
  M5.Power.begin();
  
  // センサーの初期化
  Wire.begin();
  sensor.init();
  sensor.setTimeout(500);
  
  // 画面の初期設定
  initDisplay();
  displayState(STATE_INITIALIZING);
  
  // 履歴の初期化
  for (int i = 0; i < HISTORY_SIZE; i++) {
    distanceHistory[i] = 9999;
  }

  // Wi-Fi接続
  connectToWiFi();
  
  // WireGuardトンネルの設定
  connectToWireGuard();
  
  // 初期値を取得（しきい値）
  getInitialData();

  // 初期化状態を送信
  sendStateUpdate(STATE_INITIALIZING);
}

void loop() {
  M5.update();  // ボタン状態の更新
  
  // 1秒ごとの測定
  if (millis() - lastMeasurementTime >= MEASUREMENT_INTERVAL) {
    int distance = sensor.readRangeSingleMillimeters();
    Serial.printf("Distance: %d mm\t", distance);
    Serial.printf("Threshold: %d mm\n", threshold);
    
    if (!sensor.timeoutOccurred()) {
      distanceHistory[historyIndex % HISTORY_SIZE] = distance;
      historyIndex++;
      
      SystemState newState = determineState();
      if (newState != currentState) {
        currentState = newState;
        displayState(currentState);
        sendStateUpdate(currentState);
      }
      
    }
    lastMeasurementTime = millis();
  }
  
  // Cボタン: 在席⇔取り込み中の切り替え
  if (M5.BtnC.wasPressed()) {
    if (currentState == STATE_AVAILABLE) {
      currentState = STATE_BUSY;
      displayState(STATE_BUSY);
      sendStateUpdate(STATE_BUSY);
    } else if (currentState == STATE_BUSY) {
      currentState = STATE_AVAILABLE;
      displayState(STATE_AVAILABLE);
      sendStateUpdate(STATE_AVAILABLE);
    }
  }
}

// 画面初期表示
void initDisplay(){
  M5.Lcd.setTextSize(2);
  M5.Lcd.fillScreen(BLACK);

  M5.Lcd.drawString("Busy", 233, 220);
}

// Wi-Fiに接続する関数
void connectToWiFi() {
  Serial.print("Connecting to WiFi");

  WiFi.begin(ssid, password);
  WiFi.setSleep(false); // Aボタン不具合のため、追加
  
  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }
  Serial.println("\nWiFi connected");

  delay(500);
}

// SORACOM Arc(WireGuard)に接続 ※ポイント1
void connectToWireGuard() {
  Serial.println("Connecting to SORACOM Arc...");

  Serial.print("Adjusting system time: ");
  configTime(9 * 60 * 60, 0, "ntp.jst.mfeed.ad.jp", "ntp.nict.jp", "time.google.com");
  delay(3000); // Wait for adjust
  Serial.println("done.");
  
  Serial.print("Connect to SORACOM Arc (WireGuard):");
  wg.begin(local_ip, private_key, endpoint_address, peer_public_key, endpoint_port);
  Serial.println("done.");

  delay(500);
}

// 初期値の取得（SIMタグ読み取り） ※ポイント2
void getInitialData() {
  // Wi-Fi再接続
  if (WiFi.status() != WL_CONNECTED) {
    connectToWiFi();
  }

  if (WiFi.status() == WL_CONNECTED) {
    HTTPClient http;
    
    // リクエストの初期化
    http.begin("http://metadata.soracom.io/v1/subscriber.tags/threshold");
    
    // GETリクエストを送信
    int httpResponseCode = http.GET();

    // 成功した場合
    if (httpResponseCode > 0) {
      String payload = http.getString();
      Serial.println("Received JSON as String:");
      Serial.println(payload);

      // JSONの解析
      StaticJsonDocument<512> doc;  // JSONのサイズに応じてバッファサイズを調整
      DeserializationError error = deserializeJson(doc, payload);

      if (!error) {
        // 解析に成功した場合
        const char* value = doc["threshold"].as<const char*>();  // JSON内の特定のキーの値を取得
        Serial.print("Threshold: ");
        Serial.println(value);
        threshold = atoi(value);
      } else {
        // 解析に失敗した場合
        Serial.print("deserializeJson() failed: ");
        Serial.println(error.f_str());
      }
    } else {
      // エラーが発生した場合
      Serial.print("HTTP GET failed, error: ");
      Serial.println(httpResponseCode);
    }

    // リクエストを終了
    http.end();
  } else {
    Serial.println("WiFi is not connected");
  }
}

// 状態の判定
SystemState determineState() {
  // 初期化中の場合
  if (historyIndex < 5) {
    return STATE_INITIALIZING;
  }
  
  // 履歴から在席/不在を判定
  int belowThresholdCount = 0;
  for (int i = 0; i < HISTORY_SIZE; i++) {
    if (distanceHistory[i] <= threshold) {
      belowThresholdCount++;
    }
  }
  
  if (belowThresholdCount >= 5) {
    if (currentState == STATE_BUSY) {
      return STATE_BUSY;
    } else {
      return STATE_AVAILABLE;
    }
  } else if (HISTORY_SIZE - belowThresholdCount >= 26) {
    return STATE_AWAY;
  }
  
  return currentState;
}

// 状態表示
void displayState(SystemState state) {
  // 状態表示
  M5.Lcd.setTextSize(3);
  M5.Lcd.setCursor(20, 40);
  switch (currentState) {
    case STATE_INITIALIZING:
      M5.Lcd.setTextColor(YELLOW, BLACK);
      M5.Lcd.println("Initializing");
      break;
    case STATE_AVAILABLE:
      M5.Lcd.setTextColor(GREEN, BLACK);
      M5.Lcd.println("Available   ");
      break;
    case STATE_BUSY:
      M5.Lcd.setTextColor(RED, BLACK);
      M5.Lcd.println("Busy        ");
      break;
    case STATE_AWAY:
      M5.Lcd.setTextColor(ORANGE, BLACK);
      M5.Lcd.println("Away        ");
      break;
  }
  M5.Lcd.setTextColor(WHITE, BLACK);
}

// 状態の送信
void sendStateUpdate(SystemState state) {
  // Wi-Fi再接続
  if (WiFi.status() != WL_CONNECTED) {
    connectToWiFi();
  }

  if (WiFi.status() == WL_CONNECTED) {
    HTTPClient http;
    http.begin(serverUrl);
    http.addHeader("Content-Type", "application/json");
    
    // POSTリクエストの送信
    char payload[512];
    sprintf(payload, "{\"status\": %d}", (int)state);
    int httpResponseCode = http.POST(payload);
  
    // レスポンスを取得
    if (httpResponseCode > 0) {
      String response = http.getString();  // サーバーからの応答を取得
      Serial.println("Response: " + response);
    } else {
      Serial.println("Error in sending POST request");
    }
  
    http.end();  // 接続終了
  } else {
    Serial.println("WiFi is not connected");
  }
}
```

ポイントとしては以下となります。
### ポイント1 SORACOM Arcへの接続
SORACOM Arcへの接続はWireGuardというVPNの仕組みを使って実装します（詳しくは[こちら](https://blog.soracom.com/ja-jp/2023/02/16/what-is-wireguard/)）。
M5Stackでは以下の「WireGuard for ESP32」を使うとSORACOM Arcを使えます。

https://github.com/ciniml/WireGuard-ESP32-Arduino

### ポイント2 しきい値の取得
しきい値の設定は[SORACOMメタデータサービス](https://users.soracom.io/ja-jp/docs/air/use-metadata/)で読み取れるSIMのタグで設定します（設定方法は[後述](#SIMタグ)）。
上記コードでは読み取ったSIMのタグをJSONとして解析し、変数にセットしています。

## SORACOM
### SIMタグ
[SIMグループ設定](https://users.soracom.io/ja-jp/docs/group-configuration/configure-group/)のSORACOM Air for セルラー設定でメタデータサービスをONにして保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/0278a399-9f59-26db-54ab-0a57e96798d0.png)

その後、[SIM管理画面](https://console.soracom.io/sims?coverage_type=jp)でSIMのタグ設定をします。
対象のバーチャルSIMを選択し、新規タグ「threshold」を追加し、しきい値をmmで設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/dcc37518-a2b9-d4d9-ac23-ffcee901e985.png)

しきい値の設定を変更したい場合は、この値（上の図の750）を変更して保存し、M5Stackを再起動すると変更できます。

### SORACOM Harvest Data
Harvest Dataの有効化は[SIMグループ設定](https://users.soracom.io/ja-jp/docs/group-configuration/configure-group/)でスイッチをONにして保存するだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/c8700439-1e51-631d-d8b6-e33cd8128f72.png)

### SORACOM Lagoon
ダッシュボードツール、SORACOM Lagoonで在席状況を可視化します。
状態（status）は数値で保存されており、以下を意味するようにしています。
- 不在（Away）：-1
- 初期化（Initializing）：0
- 在席（Available）：1
- 取り込み中（Busy）：2

数値で表現してもわからないので、文字で表示するようにします。

SORACOM Lagoonを有効化し、ログインして以下手順で設定します。
1. 画面の左メニューから新規にダッシュボードを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/2f7c71b4-624b-f2d0-7697-176c7f73c669.png)
1. パネルを新規に追加します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/3ba5d91d-8a3c-1647-a1a6-7943c522f2da.png)
1. Visualizationswを「Stat」にして、タイトルを記入し、QueryでバーチャルSIM名、「Standard」を選択して、「status」を指定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/f8f31885-c935-e185-5094-33f807336ec9.png)
1. Value mappingsの「Add value mappings」をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/833bb2b8-0a2e-5ae6-4d18-3a69d3702c9c.png)
1. statusの数値とそれに該当する状態をしています
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/d5b12c4a-187c-4734-2750-6a3b643f35ff.png)
1. 背景のグラフ表示が邪魔なので、Graph modeを「None」にします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/26acde43-a25a-7cd9-5a2e-4adf83f9d073.png)
1. 以上で「Apply」をクリックして画面を閉じると状態が表示されるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/bd68d56b-bf54-fdbd-d05a-1d29a5adce1e.png)
1. 最後に自動更新を設定して保存すると完了です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/916611/9e2368b7-b3f3-a36b-7cb3-81a404bcea69.png)

以上ですべての作業が完了となります。

# まとめ
よく考えるとこれまでのガジェット開発は検証や遊び目的だったので、実際に常時稼働で動かすのは初めてです。
いろいろ問題が出るかもですが、見守っていきたいと思います。

しかし生成AIの登場によって、M5Stackなどのガジェット開発がより身近になったと感じます。
アイデアさえ浮かべば実装へのハードルは高くないと思います。

ちなみに私の開発は以下を参考にさせてもらっています。

https://soracom.jp/recipes_index/3345/

皆さんも他の方の記事を参考に、お正月休み、開発にチャレンジしてみてください。

https://soracom.jp/iot-recipes/

# 追伸
わたくしごとではありますが、今年11月2日に開催されたSORACOM Explorer 2024にてSORACOM MVC 2024（MVC：Most Variable Contributer：SORACOMコミュニティ活動に最も貢献した人へのソラコムの感謝の意）を授与いただきました。

身に余る栄誉で恐縮至極ですが、今後もコミュニティに貢献できるよう、精進いたしますので、ご指導ご鞭撻のほどよろしくお願いいたします。

# 追記
M5StickC版も書いてみました。

https://qiita.com/ozk009/items/fc8f323328c8490bda0b