『会議中』のステータスを自動表示する仕組み

# はじめに

在宅で仕事をしていると嫁から「会議中かどうかわからないのでわかるようにして欲しいなぁ〜」と言われたので、現在オンライン会議中かどうかを部屋の外から一目でわかる仕組みを作ってみることにしました。
既に先人たちが素晴らしい仕組みを作られているので、それを参考にさせてもらいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/7ea9c247-cf02-1c87-3012-5d567f3bced1.png)


# 参考記事

https://qiita.com/takeshi_ohsawa/items/d2cd75c0d3d76a177920

https://qiita.com/nunukim/items/bfaf9ef5a20ece514817

https://sample.msr-r.net/coreink-time/


上記を参考に以下のような内容を考えました。

- SlackやZoomなど色々な会議ツールを使うので、カメラの起動を検知して会議中かどうか判断する
- APIは簡易的なものでよいのでGoogleスプレッドシートとGASを使用する
- 会議中サインは手持ちに[M5CoreInk](https://www.switch-science.com/products/6735?srsltid=AfmBOorbS2u4bDC4kts1YRgzy2oObudERREqP69IACtT8l87-udiD905)があるのでそれを使う
- このために別途ボタンを押すなどの作業を行いたいくない！（譲れない部分１）
- あるものでやる！お金をかけない！（譲れない部分２）

# 準備

## 用意したもの

- カメラ起動のプロセス監視

当方、Macを使っているのでカメラ起動のプロセス監視はOverSightというオープンソースのアプリを使用しました。
OverSightはカメラの起動時に任意のスクリプトを引数付きで実行できるので、その機能を使いGoogleスプレッドシートにカメラのON・OFFを記録するようにしました。

https://github.com/objective-see/OverSight

- M5CoreInk

M5CoreInkは、M5Stackコアシリーズの最新電子ペーパーディスプレイです。
本記事ではM5CoreInkを使うための説明はしません。ネットに良記事がたくさんありますのでそちらを参考にしてください。

https://www.switch-science.com/products/6735?srsltid=AfmBOorbS2u4bDC4kts1YRgzy2oObudERREqP69IACtT8l87-udiD905

:::note warn

2024/11/28現在、M5CoreInkのライブラリのV1.0.0を使うと日本語文字にドットが入るバグ（？）があります。V0.0.7にダウングレードすることでこの現象は回避できます。[注釈](#coreinkのライブラリについて)
:::

- Googleアカウント

スプレッドシートとGASを使うためのGoogleアカウントを準備する。

## 開発環境

- MacOS Sequoia バージョン15.1.1
- Arduino IDE 2.3.2
- VSCode

# 開発

## 簡易APIの作成

まずは、簡易APIを作成していきます。
簡易APIの作成はGASを使用します。
Googleスプレッドシートを起動して拡張機能タブよりGASエディタを開きます。
※スプレッドシートのA1セルにoffと入力しておいてください。

![スクリーンショット 2024-11-28 16.29.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/6693e1f1-6f74-154f-bf68-ac195a13daaa.png)

GASエディタが起動したら以下のコードを記載します。

```javascript
function doGet() {
  const spreadsheetId = '10xxxxxxxxxxx14';
  const sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('シート1');
  
  // A1セルの値のみを取得
  const value = sheet.getRange('A1').getValue();
  
  // JSONとして返す
  const response = {
    status: 'success',
    data: value
  };
  
  return ContentService.createTextOutput(JSON.stringify(response))
    .setMimeType(ContentService.MimeType.JSON);
}

function doPost(e) {
  const SPREADSHEET_ID = '10xxxxxxxxxxx14';
  
  try {
    // POSTデータをパース
    const data = JSON.parse(e.postData.contents);
    
    // スプレッドシートを開く
    const sheet = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('シート1');
    
    // A1セルに状態を書き込む
    sheet.getRange('A1').setValue(data.event);
    
    return ContentService.createTextOutput(JSON.stringify({
      'status': 'success',
      'message': 'データが正常に記録されました'
    })).setMimeType(ContentService.MimeType.JSON);
    
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({
      'status': 'error',
      'message': error.toString()
    })).setMimeType(ContentService.MimeType.JSON);
  }
}
```

:::note info

`spreadsheetId`は自身のスプレッドシートのURLから取得して設定してください。
:::

コードをデプロイします。

![スクリーンショット 2024-11-28 16.20.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/37d75a29-9e21-2992-83a8-b854c5d3182d.png)

:::note info

デプロイ設定

1. デプロイ→新しいデプロイを選択
1. 種類の選択で「ウェブアプリ」を選択
1. 次の設定で公開
    1. 実行するユーザー：自分
    1. アクセスできるユーザー：全員
1. デプロイをクリック
1. 承認が必要な場合は承認を行う

設定後、URLが生成されます。
このURLを`apiEndpoint`に設定します。また、後述するPythonスクリプトでもこのURLを使用するので控えておいてください。

:::

![スクリーンショット 2024-11-28 17.39.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/ef4b8dcb-3858-ffc9-65c5-0fb87a4d1815.png)


:::note alert

今回の設定ではURLに誰でもアクセスできるようになっています。
外部に漏らさないように気をつけてください。

:::

簡易APIの作成は以上です。


## M5CoreInk

以下の記事に新しい実装を記載しました。

https://qiita.com/ishidad2/items/f8e997eec7678aef8cfd

<details><summary>古い実装</summary>
続いて、会議中サインを表示するM5CoreInkに書き込むコードです。
~~21:00-07:00の間は仕事も終わっているので長時間スリープモードになります。~~
画面が黒くなってしまったのでここのロジックは見直します。。。[注釈](#画面が真っ黒になった時の対処法)

M5CoreInkの使用方法は[公式ページ](https://docs.m5stack.com/ja/core/coreink)などを参考にしてください。

```c
#include "M5CoreInk.h"
#include "esp_adc_cal.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "efontEnableJaMini.h"
#include "efont.h"
#include "efontM5StackCoreInk.h"

const char* ssid = "xxxxxxxxxx";
const char* password = "xxxxxxxxxx";
const char* apiEndpoint = "https://script.google.com/macros/s/xxxxxxxxxx/exec";
const int schedule = 300;  // 通常の更新間隔（5分）
const int nightSleepSeconds = 36000;  // 夜間スリープ時間（10時間）

Ink_Sprite InkPageSprite(&M5.M5Ink);

float getBatVoltage() {
  analogSetPinAttenuation(35, ADC_11db);
  esp_adc_cal_characteristics_t *adc_chars = (esp_adc_cal_characteristics_t *)calloc(1, sizeof(esp_adc_cal_characteristics_t));
  esp_adc_cal_characterize(ADC_UNIT_1, ADC_ATTEN_DB_11, ADC_WIDTH_BIT_12, 3600, adc_chars);
  uint16_t ADCValue = analogRead(35);

  uint32_t BatVolmV  = esp_adc_cal_raw_to_voltage(ADCValue, adc_chars);
  float BatVol = float(BatVolmV) * 25.1 / 5.1 / 1000;
  free(adc_chars);
  return BatVol;
}

int getBatCapacity() {
  const float maxVoltage = 4.02;
  const float minVoltage = 3.65;
  int cap = (int)(100.0 * (getBatVoltage() - minVoltage) / (maxVoltage - minVoltage));
  if(cap > 100) cap = 100;
  if(cap < 0) cap = 0;
  return cap;
}

String fetchDataFromAPI() {
  HTTPClient http;
  String payload = "{}";

  Serial.println("Connecting to API...");
  Serial.println(apiEndpoint);

  http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
  http.begin(apiEndpoint);
  int httpResponseCode = http.GET();

  Serial.print("HTTP Response code: ");
  Serial.println(httpResponseCode);

  if (httpResponseCode > 0) {
    payload = http.getString();
    Serial.println("API Response:");
    Serial.println(payload);
  } else {
    Serial.println("Error on HTTP request");
  }

  http.end();
  return payload;
}

// 夜間モードかどうかをチェックする関数
bool isNightMode(struct tm timeInfo) {
  if (timeInfo.tm_hour >= 21 || timeInfo.tm_hour < 7) {
    return true;
  }
  return false;
}

// スリープ時間を計算する関数
int calculateSleepDuration(struct tm timeInfo) {
  if (isNightMode(timeInfo)) {
    if (timeInfo.tm_hour >= 21) {
      // 21時以降の場合、朝7時までの残り時間を計算
      int hoursUntil7am = (24 - timeInfo.tm_hour + 7);
      return hoursUntil7am * 3600;
    } else {
      // 0-7時の場合、7時までの残り時間を計算
      int hoursUntil7am = (7 - timeInfo.tm_hour);
      return hoursUntil7am * 3600;
    }
  }
  return schedule;
}

void setup() {
  M5.begin();
  if ( !M5.M5Ink.isInit()) {
    Serial.printf("Ink Init faild");
    while (1) {
      delay(100);
    }
  }
  M5.M5Ink.clear();
  delay(1000);

  if ( InkPageSprite.creatSprite(0, 0, 200, 200, true) != 0 ) {
    Serial.printf("Ink Sprite creat faild");
  }

  Serial.printf("Connecting to %s\n", ssid);
  WiFi.begin(ssid, password);
  while(WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nWiFi connected\n");

  configTime(9 * 3600, 0, "ntp.nict.jp");
  struct tm timeInfo;
  getLocalTime(&timeInfo);
  char now[20];
  sprintf(now, "%04d/%02d/%02d %02d:%02d:%02d",
    timeInfo.tm_year + 1900,
    timeInfo.tm_mon + 1,
    timeInfo.tm_mday,
    timeInfo.tm_hour,
    timeInfo.tm_min,
    timeInfo.tm_sec
  );

  float volt = getBatVoltage();
  int capa = getBatCapacity();
  char strVolt[20], strCapa[20];
  sprintf(strVolt, "Battery: %.2fV", volt);
  sprintf(strCapa, "Capacity: %d%%", capa);

  String apiData = fetchDataFromAPI();
  StaticJsonDocument<400> doc;
  DeserializationError error = deserializeJson(doc, apiData);

  printEfont(&InkPageSprite, now, 10, 0, 1);

  if (!error && doc["status"] == "success") {
    const char* temp = doc["data"];

    if (strcmp(temp, "on") == 0) {
        printEfont(&InkPageSprite, "会議中", 4, 55, 4, 1);
    } else if (strcmp(temp, "off") == 0) {
        printEfont(&InkPageSprite, "入室可", 4, 55, 4, 0);
    } else {
      printEfont(&InkPageSprite, "？？？", 4, 55, 4, 1);
    }
  }

  printEfont(&InkPageSprite, strVolt, 10, 152, 1);
  printEfont(&InkPageSprite, strCapa, 10, 168, 1);

  InkPageSprite.pushSprite();

  // 夜間モードの場合は長時間スリープに設定
  // int sleepDuration = calculateSleepDuration(timeInfo);
  M5.shutdown(600);
  esp_sleep_enable_timer_wakeup(600 * 1000 * 1000);
  esp_deep_sleep_start();
}

void loop() {}

```

:::note info

「M5Stack CoreInk」にUSBで電源供給している場合は、「M5.shutdown()」でスタンバイモードに移行できないようです。
そのため「M5.shutdown()」の後に、通常のディープスリープへの移行コマンドを追加してあります。
これで、内蔵バッテリーで動作している時はスタンバイモードに移行、USBで給電している場合はディープスリープに移行するはずです。
:::


コード中の以下の部分を自分の環境に合わせます。
`apiEndpoint`はGASでデプロイした際に生成されたURLです。

```
const char* ssid = "xxxxxxxxx"; // WiFi SSID
const char* password = "xxxxxxxx";  // WiFi Password
const char* apiEndpoint = "https://script.google.com/macros/s/xxxxxxxxxxxxxxxxx3rQ/exec"; // API Endpoint
```
</details>


M5CoreInkは以上です。

## OverSightで実行するPythonスクリプト

OverSightはカメラデバイスを利用しているプロセスを監視するアプリケーションです。
OverSightの使い方は説明しませんので調べてください🙏（機能は単純なのですぐにわかると思います）

カメラのOn/Offのイベントが発生するたびに設定したコマンドを実行してくれる機能があるため、これを使ってカメラの状態を検知してその情報をスプレッドシートに記載するためのPythonスクリプトを作成します。

```py
#! /usr/bin/python3

import argparse
import requests

def send_to_google_sheets(event):
    gas_webapp_url = "https://xxxxxxxxxxxxxxx" #ここにGASでデプロイしたURLを設定

    data = {
        "event": event
    }

    try:
        response = requests.post(
            gas_webapp_url,
            json=data,
            headers={'Content-Type': 'application/json'}
        )
        response.raise_for_status()
    except Exception as e:
        print(f"エラーが発生しました: {str(e)}")
        raise

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("-device", help="Device name", required=True)
    parser.add_argument("-event", help="Event name", required=True)

    args, unknown = parser.parse_known_args()

    if args.device == 'camera':
        send_to_google_sheets(args.event)
```

上記のコードをcamera_notice.pyとして適当な場所に保存して実行権限をつけます。

```bash
chmod +x camera_notice.py
```

### 動作確認

以下のコマンドを実行して動作を確認します。

```bash
pip install requests 
./camera_notice.py -event on -device camera 
```
:::note warn
urllib3の警告が表示されるかもしれませんが[こちら](https://github.com/urllib3/urllib3/issues/3020)を見る限り問題なさそうなので今回は無視します（警告が表示されない場合は気にしなくてOK）
:::

スプレッドシートのA1セルの値が「on」になっていたら成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/3d36ef73-f18a-2e33-af23-57ffda09c624.png)


動作の確認が取れたら、OverSightの設定を開き、保存したcamera_notice.pyを選択します。

![スクリーンショット 2024-11-28 16.40.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/e766a2ff-e845-1949-4403-e396e90bb666.png)

:::note info

設定よりローカルのスクリプトを指定できます。
この時「Pass Arguments」にもチェックを入れます。（チェックが入っていないとスクリプトに引数が渡ってきません）
:::

OverSightで実行するPythonスクリプトは以上です。

# 動作確認

それでは最後にPCのカメラを起動してM5CoreInkに情報が表示されるかチェックします。

SlackやZoomなどでカメラを起動します。

スプレッドシートに「on」が記録され、M5CoreInkには「会議中」が表示されたら成功です（M5CoreInkの表示は5分に一回更新）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/74dbc1c1-4cd4-fe74-a4a1-2028b62d4a59.png)

![1000004530.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/21c8dbf2-6338-98af-ab31-8bdece110d56.jpeg)


また、SlackやZoomなどでカメラをOFFにする（Web会議が終了する）とスプレッドシートに「off」が記録され、M5CoreInkには「入室可」が表示されるはずです。

# 最後に

現在の仕様では、M5CoreInkの電力を抑えるために５分に一回のチェックにしているので、どうしてもタイムラグが最大５分発生してしまいます。
私の環境下ではUSB給電が使えないので5分に一回のチェックにしていますが、USB給電が可能な場所などであれば１分に一回チェックとかでも良いかもしれません。またはモバイルバッテリーなどを繋いで使うとか。
※ただし、M5CoreInkの書き換えは15秒以下にはしないほうが良いそうです。

また、カメラをOFFにするとスプレッドシートにも「off」が記録されしまうのでカメラを使わない場合などの処理は別途考える必要がありそうです。

この辺りは今後の課題としたいと思います。

### 注釈

#### CoreInkのライブラリについて

https://github.com/m5stack/M5Core-Ink/releases

## V0.0.7
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/710a5de1-7fb1-1f06-103a-bda1d9254bf4.png)

![1000004530.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/9e037616-a2a5-0124-2323-2d346cb75d48.jpeg)


## v1.0.0

ｖ1.0.0を使用して日本語表示を行うと文字にドットが表示されたり、反転がうまく表示されません。
使用している日本語ライブラリとの相性が悪いのかもしれません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/8002d495-cfc2-5cf6-023f-7b613bfc94db.png)

![1000004532.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/934471/031a6cc0-0d95-9bac-4445-7e17e9eca9bb.jpeg)


#### 画面が真っ黒になった時の対処法

何かしらの不具合で画面が真っ黒になった際は以下のサンプルプログラムを書き込んでみてください。

```c
#include "M5CoreInk.h"

Ink_Sprite InkPageSprite(&M5.M5Ink);
/* After CoreInk is started or reset
the program in the setUp () function will be run, and this part will only be run once.
*/
void setup() {

    M5.begin(); //Init CoreInk.
    if( !M5.M5Ink.isInit()) //Init CoreInk screen.
    {
        Serial.printf("Ink Init faild");
        while (1) delay(100);
    }
    M5.M5Ink.clear();   //Clear screen.
    delay(1000);
    if( InkPageSprite.creatSprite(0,0,200,200,true) != 0)   //Create a sprite.
    {
        Serial.printf("Ink Sprite creat faild");
    }
    InkPageSprite.drawString(35,50,"Hello World!"); //Draw a string.
    InkPageSprite.pushSprite(); //Push the sprite to the screen.
}
/* After the program in setup() runs, it runs the program in loop()
The loop() function is an infinite loop in which the program runs repeatedly
*/
void loop() {

}

```