OGIMOノート

7歳の娘と、5歳の息子(脳性麻痺)を持った父親エンジニアの備忘録。自作の電子工作おもちゃ/リハビリ器具/ロボット関係の製作記録、勉強記録を残していきます

お父さんの予定(Googleカレンダ) を自宅リビング(M5stack)に表示する『おとんスケジューラ』の開発

f:id:motokiinfinity8:20190310224536j:plain

はじめに

11月から東京単身赴任になりつつも、業務都合で自宅(大阪)に週の半分近く滞在している生活を続けています。
そんな中で、現在困っているのは、業務都合により流動的なお父さんのスケジュールの家族への賢い共有方法。

普段は、LINE等を使ってスケジュール変更を連絡してますが、ついつい連絡漏れがあったりして、「えっ!今日帰ってくるんじゃなかったっけ?」とか「あれ?明日から東京?」みたいな事がしばしばありました。

このままでは、家族の間に、すれ違いやら溝が埋まれてしまうと危機感を覚え、少しでも連絡漏れを防ぎたいと考えた次第です。

そこで、お父さんの予定、特に『いつに大阪(自宅)に帰ってくるのか?』『いつから東京(単身赴任先)に移動するのか』を簡単に&自動で家族と共有するツールを今回開発してみました。

開発要件

・リビング or キッチンにおとうさんの帰省予定が分かりやすく表示される事
・おとうさんの場所に関する情報のみを表示 (不要な情報はいらない)
・お父さん側は出来る限り入力の手間を減らしたい
 お父さんが日常使ってるスマホのスケジュール帳(ジョルテ)からは変えたくない

幸いなことに、お父さんの使っているジョルテはGoogleカレンダに同期する機能があったため、GoogleカレンダAPIにアクセスするガジェットを作る方針で開発を進めました。

完成品

完成したモノはこちら

f:id:motokiinfinity8:20190310224518j:plain
f:id:motokiinfinity8:20190310224529j:plain
f:id:motokiinfinity8:20190310224547j:plain

・おとんのスマホのスケジュール帳のデータをGoogleカレンダーに同期
Googleカレンダーから「東京」「大阪」のキーワードと日付情報を抜き出す
・大阪自宅リビングに置いてる液晶付マイコン(M5Stack)に滞在残り時間を表示

   
おとうさんのスケジュール張を更新したら、勝手におうちリビングのディスプレイも更新されるのです(^-^)/
  
更に、家族への音声通知機能も追加。
・朝8時(娘の通学前)、夜20時(夕食後)に、おとんの今後の予定を音声でお知らせ


ちょうどリビングには、昨年に開発した「お風呂ゆわかしボタン押しロボット」がいるのですが、
ボタンを押す以外には今のところ役目がなかったので、これをアップデートしました。

システム全体図

図にすると以下の感じです。

f:id:motokiinfinity8:20190323145450j:plain

開発にあたり、一番参考にさせていただいたのは、M5Stackで作成された「電車タイマー」でした。
開発の考え方はもちろんの事、システム構成やAWS活用方法、実際のコード記述など、かなり勉強になりました。


qiita.com

また、以下のサイトも参考になります。
blog.akanumahiroaki.com

私はM5StackでのMicroPythonは触った事はなかったので、こちらはあくまで初期検討用。
「電車タイマー」さんの方は、AWS Lambdaでhttp get命令に変換する事で、既存で私が作ってきたArduino環境による開発物の延長で考える事ができました。結果として約1週間の短期間で開発しきれたので、良かったかなぁ、と。

ハードウェア

ハードウェアとしては、液晶付き無線マイコンボード M5Stackを使いました。

理由は2つあって、1つは現在リビングに置いている「お湯張りボタン押しロボット」がそのまま使える事。ogimotokin.hatenablog.com

もう一つは、おしゃれなデザインを表示するのが簡単な事。M5StackはSDカードから画像を読み込んで簡単に表示できるのが魅力!
表示画像についてはパワポで作成してJPEG形式で保存。その後、画像エディタ等で320×240サイズに縮小してマイクロSDカードに保存。
なので、カワイイ表示デザインをすぐに作れます!

イラストにはおなじみの「いらすとや」さんも使わせて頂いてます。

f:id:motokiinfinity8:20190310222846j:plain

ソフトウェア

Google Calendar側の設定

上記でも紹介したこちらのサイトを参考にしました。
M5Stack で Google Calendar のスケジュールを表示する(MicroPython) - Tech Blog by Akanuma Hiroaki

これを参考にGoogle Calendar API の設定を行いました。
GCP のコンソールから API とサービスを追加 をクリック
API のリストの中から Google Calendar API をクリック
API の認証情報を作成、サービスアカウントキー を選択
→ 認証情報(JSON)ファイルはダウンロードしておく

加えて、 Google Calendar の共有設定を変更して、上記で作成したサービスアカウントIDを共有対象にしておきます。

次に Goole Calendar APIの動作確認をします。
手元のLinux(Ubuntu)環境のPCを使って、以下のサイトの手順に沿って、お父さんのGoogle Calendarの予定を表示してみたいと思います。

pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib
gedit quickstart.py  → サンプルコードを作成
python quickstart.py

これにより、お父さんのこれからの予定10件が表示されたらOKです。

AWS Lanbda側の設定

AWS Lambdaの設定そのものはこちらを参考にしました。
qiita.com

AWS Lambdaの中に転送する index.js (Java Script)は以下のものを参考にしました。
github.com

私は AWSはもちろん Java Script もほとんど使った事がなかったので、色々と見様見真似で頑張りました。

仕様イメージとしては、
 ・10日前から30個分のスケジュールを抜き出す
 ・そのうち、「東京」「大阪」と記載されたキーワードを抜き出し、その開始時間を保存
 ・その保存情報の組み合わせと現在時刻から、以下の情報を計算する
  「現在が大阪か東京(大阪以外)か」「大阪からの次回出発日時」「大阪への帰還日時」

これをhttp情報として出力する事によって、端末側(M5stack)での処理を軽くしました。

上記仕様のため、以下の仕様は非対応と割りきっています。
・ 「大阪」と「東京」以外の場所はわからない。あくまで自宅視点になるので、「大阪にいるか」「大阪にいないか」に絞る
・ スケジュール帳における終了期間は見ない

下記Index.js と node_modulesディレクトリ privatekey.json
を以下のコマンドでzipに圧縮して、AWS Lambdaにアップロードします

$ ls
 index.js   node_modules  privatekey.json
$ zip -r ../oton_scheduler_js .

index.js

const {google} = require('googleapis');
const privatekey = require('./privatekey.json');

exports.handler = (event, context, callback) => {
  Promise.resolve()
  .then(function(){
    return new Promise(function(resolve, reject){
      //JWT auth clientの設定
      const jwtClient = new google.auth.JWT(
             privatekey.client_email,
             null,
             privatekey.private_key,
             ['https://www.googleapis.com/auth/calendar']);
      //authenticate request
      jwtClient.authorize(function (err, tokens) {
        if (err) {
          reject(err);
        } else {
          console.log("認証成功");
          resolve(jwtClient);
        }
      });
    })
  })
  .then(function(jwtClient){
    return new Promise(function(resolve,reject){
      const calendar = google.calendar('v3');
      const params = {
          'auth': jwtClient
      };

      var now_date = new Date();
	  var before_week = new Date()- 10*24*60*60*1000;	//現在から10日前
      calendar.events.list({
        auth: jwtClient,
    	calendarId: 'xxxxx@gmail.com',
   timeMin: (new Date(before_week)).toISOString(),
	maxResults: 30,
    	singleEvents: true,
    	orderBy: 'startTime',
      }, (err, res) => {
        if (err) return console.log('The API returned an error: ' + err);
        const events = res.data.items;
        if (events.length) {
          console.log('Upcoming 30 events:');
          events.map((event, i) => {
            const start = event.start.dateTime || event.start.date;
          });

          var nowPlace = 4;
          var returnOsakaDate = 0;
          var leaveOsakaDate = 0;
          let osaka_array = [];
          let tokyo_array = [];
          let shutyo_array = [];

          for (var i = 0; i < events.length; i++) {
             if (events[i].summary == '大阪') {
                 osaka_array.push(events[i].start.dateTime);
                 if(new Date(events[i].start.dateTime).getTime() < now_date.getTime()){
                    nowPlace = 0;
                 } else {
                    if(returnOsakaDate == 0){
                        returnOsakaDate = events[i].start.dateTime;
                    }
                 }
                 //console.log('大阪');
              }
             if (events[i].summary == '東京') {
                 tokyo_array.push(events[i].start.dateTime);
                 if(new Date(events[i].start.dateTime).getTime() < now_date.getTime()){
                    nowPlace = 1;
                 } else {
                    if(leaveOsakaDate == 0){
                        leaveOsakaDate = events[i].start.dateTime;
                    }
                 }
                 //console.log('東京');
              }
             if (events[i].summary == '出張') {
                 shutyo_array.push(events[i].start.dateTime);
                 //if (events[i].start.date.getTime() < now_date.getTime()){
                 if(new Date(events[i].start.dateTime).getTime() < now_date.getTime()){
                    nowPlace = 2;
                 } else {
                    if(leaveOsakaDate == 0){
                        leaveOsakaDate = events[i].start.dateTime;
                    }
                 }
              }
          }
          console.log(osaka_array);
          console.log(tokyo_array);
          let oton_array = {
             nowPlace : nowPlace,
             returnOsakaDate : returnOsakaDate,
             leaveOsakaDate : leaveOsakaDate,        
             OsakaDate: osaka_array,
             TokyoDate: tokyo_array,
           }

           const response = {
	      statusCode : 200,
	      body : JSON.stringify({
                nowPlace : nowPlace,
                returnOsakaDate : returnOsakaDate,
                leaveOsakaDate : leaveOsakaDate,        
                OsakaDate: osaka_array,
                TokyoDate: tokyo_array
              }),
           };
           resolve(response);
        } else {
          console.log('No upcoming events found.');
        }
      });
    });
  })
  .then(function(result){
    callback(null, result);
  })
  .catch(function(err){
    callback(err);
  });
};

送付されてくるデータ例は以下のイメージになります。

{"nowPlace":0,"returnOsakaDate":"2019-03-23T00:00:00+09:00","leaveOsakaDate":"2019-03-23T13:00:00+09:00","OsakaDate":["2019-03-16T20:00:00+09:00",(中略),"2019-03-25T00:00:00+09:00"],"TokyoDate":["2019-03-13T00:00:00+09:00",(中略),"2019-03-27T00:00:00+09:00"]}
M5Stack側

M5stack側の処理はシンプルに、http getで受信したメッセージ情報をもとに、SDカード上のイラストの上に残時間表示を追記表示するというシンプルな機能です。
ただし、苦労したのはそのメッセージの受信。AWS Lambdaから送られてきたjson方式(のはず)の文字列をArduinoJsonで処理しようとしたのですが、、、なぜかうまくいかず苦戦。仕方がないので、今回は送付データが固定長のため、sscanf読み出しで対応しました。(将来的には、再度バグ要因を確認する必要がありそうだ。。。)

#include <Arduino.h>
#include <ArduinoJson.h>
#include <M5Stack.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <HTTPClient.h>
#include "time.h"

class OtonSchedule{

  public:
     int nowPlace;
     struct tm returnOsakaDate;
     struct tm leaveOsakaDate;
       
    OtonSchedule(){
       getNewSchedule();
    };
    bool getNewSchedule(){
        HTTPClient http;
        http.begin(url);
        int httpCode = http.GET();
        if (httpCode == HTTP_CODE_OK) {
            String body = http.getString();
            body.toCharArray(httpResponceBuff,64);
            Serial.print("Response Body: ");
            Serial.println(body);

            //古いデータを更新
            nowPlace_old = nowPlace;
            returnOsakaDate_old= returnOsakaDate;
            leaveOsakaDate_old = leaveOsakaDate;
            
            //なぜかArduinoJsonが使えないので、sscanf読み出しに変更
            //StaticJsonBuffer<500> jsonBuffer;
            //JsonObject& root = jsonBuffer.parseObject(body);
            nowPlace = body.substring(12,13).toInt();
            //String returnOsakaDate = body.substring(33,49);
            returnOsakaDate.tm_year = body.substring(33,37).toInt();
            returnOsakaDate.tm_mon = body.substring(38,40).toInt();
            returnOsakaDate.tm_mday = body.substring(41,43).toInt();
            returnOsakaDate.tm_hour = body.substring(44,46).toInt();
            returnOsakaDate.tm_min = body.substring(47,49).toInt();
            leaveOsakaDate.tm_year = body.substring(78,82).toInt();
            leaveOsakaDate.tm_mon = body.substring(83,85).toInt();
            leaveOsakaDate.tm_mday = body.substring(86,88).toInt();
            leaveOsakaDate.tm_hour = body.substring(89,91).toInt();
            leaveOsakaDate.tm_min = body.substring(92,94).toInt();             

            return true;

        }else{
          return false;
        }
    };
     void isDisplayed(){
            Serial.print("現在のおとん位置 : ");
            if(nowPlace==0) Serial.println("Osaka");
            else if(nowPlace==1) Serial.println("Tokyo");
     };
     bool isOsaka(){
         if(nowPlace == 0) return true; 
         else return false; 
     };
     bool isChanged(){
        if(nowPlace_old != nowPlace)  return true; 
        else if(difftime(mktime(&returnOsakaDate), mktime(&returnOsakaDate_old))!=0)  return true; 
        else if(difftime(mktime(&leaveOsakaDate), mktime(&leaveOsakaDate_old))!=0)  return true; 
        else return false;
     };
     struct tm stayOsakaTime(){
        struct tm timeInfo, stayTime;
        getLocalTime(&timeInfo);
        timeInfo.tm_year += 1900;
        timeInfo.tm_mon  += 1;
        
        double stayTime_sec  = difftime(mktime(&leaveOsakaDate), mktime(&timeInfo));
        stayTime.tm_mday = stayTime_sec  / (60*60*24);
        stayTime_sec  -= stayTime.tm_mday * (60*60*24);
        stayTime.tm_hour = stayTime_sec  / (60*60);
        stayTime_sec  -= stayTime.tm_hour * (60*60);
        stayTime.tm_min = stayTime_sec  / (60);
        stayTime_sec  -= stayTime.tm_min * (60);
        stayTime.tm_sec = stayTime_sec;
        if(stayTime.tm_mday >=100){
          stayTime.tm_mday = 0;
          stayTime.tm_hour = 0;
          stayTime.tm_min = 0;
          stayTime.tm_sec = 0;
        }
        return stayTime;                  
     };
     struct tm stayTokyoTime(){
        struct tm timeInfo, stayTime;
        getLocalTime(&timeInfo);
        timeInfo.tm_year += 1900;
        timeInfo.tm_mon  += 1;

        double stayTime_sec  = difftime(mktime(&returnOsakaDate), mktime(&timeInfo));
        stayTime.tm_mday = stayTime_sec  / (60*60*24);
        stayTime_sec  -= stayTime.tm_mday * (60*60*24);
        stayTime.tm_hour = stayTime_sec  / (60*60);
        stayTime_sec  -= stayTime.tm_hour * (60*60);
        stayTime.tm_min = stayTime_sec  / (60);
        stayTime_sec  -= stayTime.tm_min * (60);
        stayTime.tm_sec = stayTime_sec;
        if(stayTime.tm_mday >=100){
          stayTime.tm_mday = 0;
          stayTime.tm_hour = 0;
          stayTime.tm_min = 0;
          stayTime.tm_sec = 0;
        }
        return stayTime; 
     };    
    private:
      int nowPlace_old;
      bool changeflg;
      struct tm returnOsakaDate_old;
      struct tm leaveOsakaDate_old;
};

OtonSchedule otonSchedule = OtonSchedule();
int view_mode;  //0: アバターモード、1:スケジュールモード
bool view_change;
uint32_t time_cnt;


void setup(){
  M5.begin();
  Serial.begin(115200);
  delay(10);

  // WiFi設定
  // We start by connecting to a WiFi network
  Serial.println();
  Serial.println();
  Serial.print("Connecting to ");
  Serial.println(ssid);

  //WiFi.config(ip,gateway,subnet,DNS);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("WiFi connected.");
  M5.Lcd.println("WiFi connected.");
  Serial.println("IP address: ");
  M5.Lcd.println("IP address: ");
  Serial.println(WiFi.localIP()); 
  M5.Lcd.println(WiFi.localIP());
  delay(1000);

  //WAVの場合
  out = new AudioOutputI2S(0,1); 
  out->SetOutputModeMono(true);
  out->SetGain(0.8); 
  wav = new AudioGeneratorWAV(); 
  
  //NTP設定
  // https://wak-tech.com/archives/833
  configTime(9 * 3600L, 0, "ntp.nict.jp", "time.google.com", "ntp.jst.mfeed.ad.jp");
  
  //NTP時刻情報取得
  struct tm timeInfo;
  getLocalTime(&timeInfo);
  timeInfo.tm_year += 1900;
  timeInfo.tm_mon  += 1;
 
  Serial.print(timeInfo.tm_year);
  Serial.print(" ");
  Serial.print(timeInfo.tm_mon);
  Serial.print(" ");
  Serial.print(timeInfo.tm_mday);
  Serial.print(" ");
  Serial.print(timeInfo.tm_hour);
  Serial.print(" ");
  Serial.print(timeInfo.tm_min);
  Serial.print(" ");
  Serial.println(timeInfo.tm_sec);

  // おとんスケジューラ起動
  while(otonSchedule.getNewSchedule() != true){
    delay(500);
    Serial.print(".");
  }
  otonSchedule.isDisplayed();
  time_cnt = millis();
  view_mode =0;
  view_change =0;

  //サーボ起動
  ledcSetup(LEDC_CHANNEL_3, LEDC_SERVO_FREQ, LEDC_TIMER_BIT) ; // 16ビット精度で制御
  ledcAttachPin(SERVO_PIN, LEDC_CHANNEL_3) ; // CH3をRC SERVOに
  ledcWrite(LEDC_CHANNEL_3, servo_pwm_count(-45)) ; // ニュートラル
  Serial.println("start set Position : -45°");

  // アバター設定
  delay(500);
  M5.update();
  M5.Lcd.clear();
  avatar.init();
  avatar.setExpression(expressions[4]);

  server.begin();
  
  M5.Lcd.drawJpgFile(SD, "/avatar1.jpg");
   delay(1000);
}

int cnt=0;
struct tm nowTime, otonStayTime;

void loop(){
    //ループの方針
    // ・NTP時間は10秒ごと
    // ・サーバーからのデータ取得は10分ごと
    // NTPから現在時刻情報の取得
    getLocalTime(&nowTime);    
    
    M5.update();
    if(M5.BtnB.wasPressed()){
      //otonSchedule.nowPlace = (otonSchedule.nowPlace+1)%2;  //場所の変更 (デモ用)
      view_change = 1;  //アバター⇔スケジューラの変更
      time_cnt = millis();
    }

    // 30秒以上経過した場合
    if ((millis() -time_cnt)>30000){
      view_change = 1;  //アバター⇔スケジューラの変更
      time_cnt = millis();
    }

    // サーバーからのデータ取得(毎時10分)
    if(nowTime.tm_min % 10 == 0){  
        otonSchedule.getNewSchedule();
        otonSchedule.isDisplayed();
        if(otonSchedule.isChanged()){
           Serial.println("おとんのスケジュールが変更された!"); 
        }
    }

    //描画設定
    // 30秒ごとに表示
    if(view_change){
      if(view_mode ==0){
        avatar.stop();
        delay(500);
        if(otonSchedule.isOsaka()){
           M5.Lcd.drawJpgFile(SD, "/osaka.jpg");
           Serial.println("おとんは大阪にいるよ");
           otonStayTime = otonSchedule.stayOsakaTime();
        }else{
           M5.Lcd.drawJpgFile(SD, "/tokyo.jpg");
           Serial.println("おとんは東京にいるよ");
           otonStayTime = otonSchedule.stayTokyoTime();  
        }
        char str[20];
        M5.Lcd.setTextColor(WHITE); 
        M5.Lcd.setTextSize(5);
        M5.Lcd.setCursor(40,180);
        sprintf(str,"%02d", otonStayTime.tm_mday);
        M5.Lcd.print(str);
        M5.Lcd.setCursor(120,180);
        sprintf(str,"%02d", otonStayTime.tm_hour);
        M5.Lcd.print(str); 
        M5.Lcd.setCursor(220,180);
        sprintf(str,"%02d", otonStayTime.tm_min);
        M5.Lcd.print(str);
      // スケジューラからアバターに変更
      }else if(view_mode ==1){
         Serial.println("view_change : Avatar");
         M5.Lcd.clear();
         delay(500);
         avatar.start();
       }
    view_mode = (view_mode+1)%2;  //場所の変更 (デモ用)
    view_change = 0;
    }

    
    // 朝8時のアラーム
    // 今日のお父さんの予定を表示 (「今日はずっと大阪」「今日はずっと東京」「今日は大阪に帰ってくる」「今日は東京にいってしまう」)
    if((nowTime.tm_hour == 8 || nowTime.tm_hour == 20) && nowTime.tm_min == 1){

         //お父さん情報音再生①(ピンポンパン)
         file = new AudioFileSourceSD("/pinpon.wav");
         wav = new AudioGeneratorWAV(); 
         wav->begin(file, out);
         while(wav->isRunning()){
           if (!wav->loop()) wav->stop();
         }
         M5.Lcd.drawJpgFile(SD, "/oton_info.jpg");

         //お父さん情報音再生②(今日のお父さん情報)         
         if(nowTime.tm_hour == 8){
            file = new AudioFileSourceSD("/today_info.wav");
         }else{
            file = new AudioFileSourceSD("/tomorrow_info.wav");          
         }
         wav = new AudioGeneratorWAV(); 
         wav->begin(file, out);
         while(wav->isRunning()){
           if (!wav->loop()) wav->stop();
         }
         delay(2000); 

         int remainTime_hour  =  otonStayTime.tm_mday*24+otonStayTime.tm_hour;
         
         if(otonSchedule.isOsaka()){
            // 朝8時点で15時間以内 or 夜20時時点で29時間以内に東京に帰る場合
           if((nowTime.tm_hour == 8 && remainTime_hour <= 15) || (nowTime.tm_hour == 20 && remainTime_hour <= 29)){
                M5.Lcd.drawJpgFile(SD, "/goto_tokyo.jpg");
                file = new AudioFileSourceSD("/goto_tokyo.wav");
           // 大阪に1日滞在する場合
           }else{
                M5.Lcd.drawJpgFile(SD, "/stay_osaka.jpg");
                file = new AudioFileSourceSD("/stay_osaka.wav");            
           }
        }else{
            // 朝8時点で15時間以内 or 夜20時時点で29時間以内に大阪から帰る場合
           if((nowTime.tm_hour == 8 && remainTime_hour <= 15) || (nowTime.tm_hour == 20 && remainTime_hour <= 29)){
                M5.Lcd.drawJpgFile(SD, "/goto_osaka.jpg");
                file = new AudioFileSourceSD("/goto_osaka.wav");
           // 東京に1日滞在する場合
           }else{
             M5.Lcd.drawJpgFile(SD, "/stay_tokyo.jpg");
             file = new AudioFileSourceSD("/stay_tokyo.wav");              
           }
        }
        wav = new AudioGeneratorWAV(); 
        wav->begin(file, out);
         while(wav->isRunning()){
           if (!wav->loop()) wav->stop();
        }
        delay(3000);
        view_change = 1;
    }
}

最後に

この自動ツールにすべて頼るつもりはなく、基本の予定通知はLineで直接連絡したり、通信手段を使っての日々の会話コミュニケーションになります。
ただ、どうしても共有が漏れてしまったりする事もあるし、Lineする程でもないけど気になる瞬間もあったりする。すれ違いの生じるスタートは相手が何やってるか分からなくなるところからなので、そんなすれ違いをカバーするツールとして捉えてます。
スマホを開ける手間なしで、気軽に予定が確認できる点が良いところ!
  
この様なスケジュール共有ツールによって得られる事は、おとんの動きが常に分かる安心感、家族の心理距離の近さを保てる事、それが価値かなぁ、と感じてます!(^^)!
  

このおとんスケジューラの続きを作っていくならば、
 ・おとんの一週間の予定をぱっと表示する & 一見して分かる手段
 ・おとんの帰宅連絡「今から帰るよ~」を自動化する手段

を追加実装したいなぁ、と考えてます!

しばらく使ってみて、使用感を見ながらアップデートしていきます