10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

身の回りの困りごとを楽しく解決! by Works Human IntelligenceAdvent Calendar 2024

Day 5

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

Last updated at Posted at 2024-11-28

はじめに

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

image.png

参考記事

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

  • SlackやZoomなど色々な会議ツールを使うので、カメラの起動を検知して会議中かどうか判断する
  • APIは簡易的なものでよいのでGoogleスプレッドシートとGASを使用する
  • 会議中サインは手持ちにM5CoreInkがあるのでそれを使う
  • このために別途ボタンを押すなどの作業を行いたいくない!(譲れない部分1)
  • あるものでやる!お金をかけない!(譲れない部分2)

準備

用意したもの

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

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

  • M5CoreInk

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

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

  • 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

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

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);
  }
}

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

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

スクリーンショット 2024-11-28 16.20.45.png

デプロイ設定

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

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

スクリーンショット 2024-11-28 17.39.58.png

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

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

M5CoreInk

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

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

M5CoreInkの使用方法は公式ページなどを参考にしてください。

#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() {}

「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

M5CoreInkは以上です。

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

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

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

#! /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として適当な場所に保存して実行権限をつけます。

chmod +x camera_notice.py

動作確認

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

pip install requests 
./camera_notice.py -event on -device camera 

urllib3の警告が表示されるかもしれませんがこちらを見る限り問題なさそうなので今回は無視します(警告が表示されない場合は気にしなくてOK)

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

image.png

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

スクリーンショット 2024-11-28 16.40.05.png

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

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

動作確認

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

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

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

image.png

1000004530.jpg

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

最後に

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

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

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

注釈

CoreInkのライブラリについて

V0.0.7

image.png

1000004530.jpg

v1.0.0

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

image.png

1000004532.jpg

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

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

#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() {

}

10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?