3Dプリント Arduino Google Apps Script Google Workspace (G Suite) Javascript プログラミング 工作 電子工作

目薬の時間をLINEで知らせて、点眼したことをEPS8266で自動記録してくれるシステムをGASで作った

Table of Contents

目薬が1日三回に増えると中間の1回を忘れがち

私は持病で毎日、目薬を点眼しています。もう10年以上やっていることなので習慣化して特に問題はありませんでした。

最近、目の状態が変化したので薬を変更することになりました。

薬の種類が増えたほか、ひとつは1日に3回の点眼が必要になりました。

1日を3分割して8時間おきくらいに点眼しようとすると、朝起きた時、夕方5時くらい、寝る前といったタイミングになります。

朝と寝る前は習慣化しやすいのですが中間の1回を忘れがちです。

そこで例によってコンピューターに記憶の補助をしてもらうことにしました。

薬という、ともすれば面倒なことになりそうなテーマの工作ということで、念のために注意書きを載せておきます。なにとぞご了承ください。

注意書き

本記事はあくまで個人的な技術実験の内容を紹介するものです。
本記事の手法による服薬管理を推奨するものではありません。
本記事の内容を利用したことによるあらゆるトラブルや事故、損害に対して作者は一切の責任を負いません。

Apple純正リマインダーはちょっとしつこさが足りない

最初はApple純正のリマインダーアプリを使ってみました。設定しておくと毎日決まった時間にiPhoneやApple Watchに通知がきます。

悪くはないのですが、AppleのアプリにしてはUIがいまいちで使い勝手が悪いのと、催促にしつこさが足りないです。

私の病気は自覚症状がないのでちょっと取込中だと通知が来てもスルーしてしまいがちです。無視してもしつこく何度でも知らせて欲しいのです。

そんなわけで点眼リマインダーを自作することにしました。

最終的にGoogleスプレッドシート、GAS、時間トリガー、LINE Message API、ESP8266などいろんなモノを使ったので、それぞれの使い方なども記録しておきたいと思います。

LINEで点眼時間をお知らせ

まず、通知の手段はLINEにしました。時間になるとLINEメッセージがきて点眼を促します。

LINEのAPIは以前、玄関の鍵を閉めると通知するシステムて使ったことがあります。

この鍵掛け通知システムはずっと問題なく稼働していて、すでに生活に欠かせない道具になっています。これまでの運用実績からLINEは通知インフラとしてとても安定していると思います。

また、私は最近Apple Watchを使っていて、LINEが来るとApple Watchが振動して知らせてくれるので通知を確実受け取れます。

もしApple Watchを外していても、代わりにスマホのLINEに気付く可能性が残されています。

まぁ、100%確実というわけではありませんが、点眼を忘れてもいきなり失明するわけではないので、このくらいの通知で十分でしょう。

GAS+時間トリガーでLINE APIを叩くスクリプトを起動

鍵掛け通知システムではESP8266マイコンから直接LINE NotifyのAPIを叩きました。

しかし長期にわたって定期的な通知を行う今回のシステムには、ローカルな機器よりもクラウドの方が適しています。そこでGoogle Apps Scriptの時間トリガーを使いました。

Google Apps Scriptの時間トリガーはあらかじめ設定した時間に任意のスクリプトを起動します。毎日送る設定もできます。また、複数のトリガーを同時に併用できます。

時間トリガーはGASのパネルで設定するだけでなく、スクリプト中で動的に設定したり、既存のトリガーを削除することもできます。

ただし、ひとつ注意点があって毎日の時間トリガーの起動には1時間くらいの幅があります。

例えば9時のトリガーは9〜10時の間に発動します。「9時台に発動」と理解すればいいですね。

もっと正確な時間に発動させたいときは、ワンタイムの使い捨てトリガーにします。使い捨てのトリガーはほぼ設定時間に起動します。

ちなみに正確な時間トリガーを繰り返し使いたい時には、起動したスクリプト内で次のワンタムトリガーを設定します。

点眼リマインダーでは、定時のリマインダーに毎日の時間トリガーを使用し、点眼が確認できるまでの催促に使い捨てのトリガーを使用しています。

また、GASにはトリガー数の制限があるようですが、この程度の使い方ならまず大丈夫っぽいです。

LINE Messanger APIなら「点眼しました」という返事を受け取れる

鍵掛け通知システムではLINE Notify APIを使いましたが、今回はLINE Messanger APIを使いました。

LINE Notify APIは一方的に通知を送るだけですが、LINE Messanger APIは、ユーザーと双方向にメッセージのやりとりができます。

LINEの公式アカウントなどでユーザーの問い合わせに自動応答するBOTがありますが、LINE Messanger APIを使うとああいう双方向のやりとりができます。

点眼リマインダーでは定時にプッシュ通知を行います。通知に対してユーザーからのトークの返事があれば点眼が実行されたと判断します。

一定時間待っても返事が来ていなければ、再度通知を行います。次の点眼時間が来るまで通知を送り続けます。

全体の仕組みはこんな感じです。

LINE Messanger APIのチャンネルを作る

LINE Messanger APIを使うにはLINE Developpersに登録して、プロバイダーとチャネルを作る必要があります。

以下の公式ページに解説があります。また、検索すればQiitaなどにたくさん記事があります。

Messaging APIを始めよう-LINE公式

「プロバイダー」と「チャネル」というのが初めてだとちょっと分かりにくいのですが、「プロバイダー」というのはBOTの提供者。一般的にはデベロッパーとイコールです。

「チャネル」は実際にユーザーとやりとりするBOTのLINEアカウントです。上の図で「LINE」とあるところがチャネルです。

LINE Messanger APIでは、このチャネルを友達登録してトークルーム内でやりとりをします。

ひとつのプロバイダーのなかに複数のチャネルを作ることができます。

チャネルの作成はLINE Developpersのコンソールから行います。

プロバイダーの「チャネル設定」画面で「新規チャネル作成」をクリックします。まだプロバイダーがなければ作ります。

チャネルの種類でMessaging APIを選択

チャネル名、チャネル説明、大業種、小業種といった必須項目を入力します。

不特定多数に公開するBOTを作ることもあるので、プライバシーポリシーURL、サービス利用規約URLなどの入力欄もあります。自分だけで使う場合には空欄で問題ないでしょう。

利用規約に同意したら作成をクリック、確認のダイアログで問題がなければ「OK」、続いて情報利用について同意するなら「同意する」をクリックします。

チャネルが作成されます。

Messaging API設定タブに切り替えます。

QRコードがあるので、スマホで読み込んで自分のLINEアカウントに友達登録しておきます。

デフォルトで自動応答がオンになっているので友達登録した時にはあいさつメッセージが来ます。こちらから何かメッセージを送ると返事がきます。これで接続はOK。

チャネルアクセストークンを発行する

GASからこのチャネルを呼び出すためには「チャネルアクセストークン」が必要です。

GASからAPIにアクセスしたときに、チャネルアクセストークンを渡すことでチャネルの特定と認証を行います。

Messaging API設定画面の一番下にある「チャネルアクセストークン(長期)」の「発行」をクリックします。

発行されたトークンをコピーしておきます。あとでGASのスクリプト内で使います。

また、チャネル基本設定タグの下のほうにある「あなたのユーザーID」もコピーしておきます。プッシュの実験に使います。

あとでWebhook設定を行う

GASのスクリプトができたときに、もういちどこのMessaging API設定画面に戻ってきて「Webhook設定」を行います。今は空欄で大丈夫です。

自動応答機能をオフにしておく

今回は自動応答機能は使わないのでオフにします。

「LINE公式アカウント機能」の「応答メッセージ」で「編集」をクリックします。

「LINE Official Account Manager」の応答設定ページが開きます。

「あいさつメッセージ」「応答メッセージ」をオフにします。

以上でひとまずLINE上の設定は終了です。

GASからLINEメッセージをプッシュしてみる

シンプルなコードでプッシュ通知を試してみます。とても簡単です。

必要なのは先ほどコピーしておいたチャネルアクセストークンとユーザーIDです。

Googleドライブで新規にGoogle Apps Scriptを作成し、以下のコードを貼り付けます。

冒頭の定数設定で"TOKEN"にチャネルアクセストークンを、"USER_ID"に自分のユーザーIDを設定します。

const TOKEN = "-------------チャネルアクセストークンを入れる-------------";
const USER_ID = "--------------あなたのユーザーIDを入れる-------------";
const LINE_PUSH_ENDPOINT = "https://api.line.me/v2/bot/message/push"

function pushMessage(msg="こんにちは!") {

  const postData = {
    "to": USER_ID,
    "messages": [{
      "type": "text",
      "text": msg,
    }]
  };

  const headers = {
    "Content-Type": "application/json",
    'Authorization': 'Bearer ' + TOKEN,
  };

  const options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };
  const response = UrlFetchApp.fetch(LINE_PUSH_ENDPOINT, options);
}

エディタ上で実行ボタンを押してpushMessage関数を動かします。

認証ダイアログで外部サービスへのアクセスを許可すると、自分のLINEに"こんにちは!"というメッセージが届くはずです。

多くのAPIがそうであるように、LINE Message APIも決められたURLに、決められた形式でJSONをPOSTすると動きます。

GASでは任意のURLにPOSTアクセスするためにUrlFetchApp.fetch()というメソッドを使っています。HTTPおよびHTTPSリクエストを行って結果を取得するメソッドだそうです。

doPost(e)でユーザーのメッセージを受けて返信する

続いてリプライの実験です。ユーザーからのメッセージを受け取って同じ言葉を返します。いわゆるオウム返しBOTです。

ユーザーからのメッセージはWebhookを使って受け取ります。

こちらで用意したアドレスにLINEがPOSTメソッドでアクセスしてきますので、くっついてきたJSONの中からメッセージを取りだして返信します。

Webhook用のWebアプリケーションを作るためにGASのdoPost()関数を追加します。

あとでデプロイした時に、POSTメソッドでのアクセスがあると自動的にdoPost関数が実行されます。

doPostは以下のようなコードになります。さっきのプッシュ実験のコードの最後に追加します。

const LINE_REPLY_ENDPOINT = 'https://api.line.me/v2/bot/message/reply';

function doPost(e) {
  const json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  const reply_token = json.events[0].replyToken;

  //送られたメッセージ内容を取得
  const message = json.events[0].message.text;


  const postData = {
    'replyToken': reply_token,
    'messages': [{
      'type': 'text',
      'text': message,
    }]
  };

  const headers = {
    'Content-Type': 'application/json; charset=UTF-8',
    'Authorization': 'Bearer ' + TOKEN,
  };

  const options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };

  const response = UrlFetchApp.fetch(LINE_REPLY_ENDPOINT, options);
}

リプライ用APIのURLはプッシュ用とは別になっています。

前半はdoPost(e)の引数eからメッセージと返信用のトークンを取り出しています。

後半はプッシュの時と同じようにPOSTで渡すためのJSONを組み立てて、最後にUrlFetchApp.fetch()でAPIにアクセスしています。

USER_IDをreply_tokenに変えた程度でほぼ同様の方法で返信できます。

すぐに試したいところですが、doPost()でLINEからの情報を受け取るにはWebアプリケーションとしてデプロイし、そのアドレスをWebhookとしてLINEに登録する必要があります。

WebhookのURLを登録する

doPost()を含むスクリプトができたら、保存してWebアプリケーションとしてデプロイします。

設定は「ウェブアプリ」「自分」「全員」です。アクセス権要求がでたら承認します。

デプロイ完了時に表示されるURLをコピーします。

LINE Developpersで、チャネルのMessaging API設定でWebhook URLを設定します。

GASだと「検証」はできない

Webhook URLを設定すると、アドレス表示の下に「検証」というボタンが出てきます。通常はこのボタンでWebhookが上手くいくかを検証できるのですが、GASはアクセスをリダイレクトするためか検証はエラーになることが多いみたいです。

新しくフォローされた時にUSER IDと名前を取得する

新しく友達追加されたことを検出する

Webhook URLへのアクセスはメッセージ以外でも発生します。例えば、ユーザーがBOTを友達追加したり、解除したときにもアクセスがあります。

どんなイベントが起きたかは、jsonのevents[0].typeで調べることができます。

友達追加されたときには"follow"、友達解除されたときには"unfollow"イベントが発生します。

function doPost(e) {
  const json = JSON.parse(e.postData.contents);
  const type = json.events[0].type;
//typeの種類: "message" ,"follow" , "unfollow" など
 

イベントの種類やJSONの中身などは公式のリファレンスに詳しく載っています。

ユーザーIDを取得する

メッセージのPUSH送信にはユーザーIDが必要ですので、リマインダーを定期的に送るにはユーザーIDを取得、記録しておく必要があります。

最初のプッシュ送信のテストでは、ユーザーIDをコピペしていましたが、followイベントの発生時に取得したJSONからUSER_IDを取り出しておくことで、友達登録するだけでPUSHメッセージが送れるようになります。

const USER_ID = json.events[0].source.userId;

ユーザー名を取得する

点眼リマインダーでは必ずしも必要ではありませんが、ユーザー名を取得したいときもあります。

LINEにはユーザーのプロファイルを取得するための別のAPIがあります。User_IDをkeyにして名前を取得できます。

https://api.line.me/v2/bot/profile/{ユーザーID}というURLにトークン付きでリクエストを送るとプロファイル情報が返ってきます。

点眼リマインダーではgetUserNameという関数を作りました。

function getUserName(user_id) {
  var url = 'https://api.line.me/v2/bot/profile/' + user_id;
  var response = UrlFetchApp.fetch(url, {
    'headers': {
      'Authorization': 'Bearer ' + TOKEN
    }
  });
  return JSON.parse(response.getContentText()).displayName;
}

公式ドキュメントにはcurlを使った方法とJSONの中身が載っていました。備忘録として転載しておきます。

curl -v -X GET https://api.line.me/v2/bot/profile/{userId} \
-H 'Authorization: Bearer {channel access token}'
{
    "displayName":"LINE taro",
    "userId":"U4af4980629...",
    "language":"en",
    "pictureUrl":"https://obs.line-apps.com/...",
    "statusMessage":"Hello, LINE!"
}

時間トリガーをスクリプトから設定、解除する

時間トリガーはGASのトリガー画面から設定するだけでなく、スクリプトからも設定できます。

点眼リマインダーではスクリプトから時間トリガーの設定、削除を行っています。これによりGASのスクリプト画面を使わずにリマインダーの開始や停止が行えるようにしました。

トリガーの設置や削除には以下のような方法を使います。

トリガーの設置

毎日トリガーは以下のように設置します。tは起動時刻で0〜23。.everyDays()の引数は何日おきかを設定します。1で毎日。

//毎日定時にsendRegularReminderという関数を起動するトリガー   
 ScriptApp.newTrigger("sendRegularReminder")
      .timeBased()
      .atHour(t)
      .everyDays(1) // Frequency is required if you are using atHour() or nearMinute()
      .create();

使い捨てトリガーの設置方法。after()で何ミリセカンド後に起動するかを指定します。設定できる単位は細かいですが、実際にはそれほど正確な時間に起動するわけではないようです。

//一定時間後に一度だけsendReconfirmという関数を起動するトリガー
//CONFIRM_INTERVALには何分後かを設定
 ScriptApp.newTrigger("sendReconfirm")
    .timeBased()
    .after(CONFIRM_INTERVAL * 60 * 1000)
    .create();

トリガーの削除

任意の関数をセットしたトリガーを削除するコードです。ScriptApp.getScriptTriggers()でトリガー一覧を取得してループで関数名をチェック、.deleteTrigger()メソッドで消します。

function deleteReconfirmTrigger() {
  var allTriggers = ScriptApp.getScriptTriggers();
  for (var i = 0; i < allTriggers.length; i++) {
    if (allTriggers[i].getHandlerFunction() == "sendReconfirm") {
      ScriptApp.deleteTrigger(allTriggers[i]);
    }
  }
}

すべてのトリガーを削除する

function delAllTriggers() {
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    ScriptApp.deleteTrigger(triggers[i]);
  }
}

GASの時間トリガーがなぜか動かないときはChrome V8ランタイムを無効にする

これでLINEを使ったリマインダーの要素はだいだい揃いました。

しかし、作っている間に予想外のトラブルがおきました。

設定したトリガーが1回目はちゃんと作動するのですが、2回目以降は作動せずに勝手に「無効」になってしまいます。

ログを見ても「このトリガーは無効になりました。原因は不明です。」としか書かれていません。これがどうやっても直りませんでした。

最初はGASのトリガー数や間隔の制限に引っかかったのかと思いましたが、どうもそうではない様子。

試しに「このトリガーは無効になりました。原因は不明です。」でググってみると同じ症状で対処法を書いてくれている人がいました。ありがたい。

【GAS】コードで追加したトリガーが無効になる場合の対処

詳しいことはリンク先にありますが、プロジェクトの設定で"Chrome V8 ランタイム"を無効にするとよいとのこと。

試しにやってみると見事にトリガーが問題なく動くようになりました。

ただし、このオプションを外すとJavascriptの仕様が古くなるようで、だいぶコードを修正するハメになりました。変数宣言にvarを多用しているのはそのためです。あと、引数のデフォルト値やアロー関数も使えないようです。とほほー

Webアプリとしてデプロイすると大丈夫という話も書かれていましたが、私の所ではデプロイしても動きませんでした。

点眼時間などを設定するためのGoogleスプレッドシートを作成する

点眼リマインダーではGoogleスプレッドシートにスクリプトを付加しました。

スプレッドシートにBOTの設定などを書き込んでスクリプトから参照することで、後からの設定変更などが楽になります。シートを2枚用意してログにも使っています。

スクリプトからもユーザーIDや名前、点眼記録などを保存するデータベース代わりに使っています。

スプレッドシートには図のようなデータを入力しておきます。スクリプトからセル位置を指定して読み込むので場所がずれると誤動作します。

ユーザーIDとユーザー名は、LINEチャネルを友達登録すると自動的に入力されます。

通知時間には点眼を通知する時刻を設定します。ここでは3つですが、任意の数だけ設定できます。

スプレッドシート上のデータの多くは、スクリプトの先頭で定数に読み込みます。

将来、再利用するときに再びシートの設定をするのは面倒くさいので、空のスプレッドシートに必要な情報を自動的に記入するセットアッププログラムを書きました。新規のスプレッドシートにスクリプトを設定してエディタからsetUpControlSheet()を起動します。

function setUpControlSheet() {
  var sheetTemplate = [
    ["LINE MESSAGE APIトークン", "※ここにAPIトークンを入力", ""],
    ["User ID", "友達追加で自動入力されます", ""],
    ["User Name", "友達追加で自動入力されます", ""],
    ["", "", ""],
    ["服薬レスポンス記録", "", ""],
    ["", "", ""],
    ["服薬時刻(時)", "※服薬時刻を入力(複数回ある時は右のセルへ追記)", ""],
    ["服薬時刻に送るメッセージ", "※○○の服薬時間です", ""],
    ["確認間隔(分)", "10", ""],
    ["返事がないときの確認メッセージ", "※○○を服薬しましたか?\n返事をするとこの確認メッセージは止まります", ""],
    ["返事があった時のリプライメッセージ", "服薬を確認しました", ""],
    ["新規ユーザーへのメッセージと使い方", "よろしくお願いします", "「開始」で通知スタート\n「停止」で通知ストップです\n「スキップ」で通知を1回スキップ\n「取消」でスキップを取り消せます"],
    ["センサーが服薬を検知した時のメッセージ", "センサーで服薬を確認しました", ""],
    ["服薬済みなのにメッセージがきたときの返事", "すでに服薬済みだと思います", ""],
    ["マイコンの電圧が低いとき", "バッテリーが消耗しています", ""],
    ["スキップ無効", "まだ前回の服薬が済んでいません", ""]];

  var ss = SpreadsheetApp.getActive();//スプレッドシートへ接続
  if (ss.getSheets()[1]) ss.deleteSheet(ss.getSheets()[1]);
  ss.insertSheet(1);
  var s = ss.getSheets()[0];
  s.activate();
  s.clear();
  var rowNum = sheetTemplate.length;
  var colNum = sheetTemplate[0].length;
  s.getRange(1, 1, rowNum, colNum).setValues(sheetTemplate);
}

スプレッドシートを参照した定数は更新される

作っていて分かったことですが、スクリプトの先頭でスプレッドシートから読み込む定数は、その都度更新されます。

トリガーで起動するのは関数だけなのですが、関数の外でスプレッドシートから読み込んで設定している定数も毎回最新のスプレッドシートの内容が反映されます。

var USER_ID = sheet.getRange(USER_ID_CELL).getValue(); //更新される

このお陰でLINEに送るメッセージはスプレッドシート上で変更するだけで変わります。再デプロイの手間が減ります。

ちなみに毎日の通知時刻は一度起動したあとはスプレッドシート上の設定を変更しても変わりません。時刻を変えるにはいったんトリガーを削除して再設定するか、スクリプトのパネルでひとつずつ変更する必要があります。LINEのトークルームで「停止」「開始」のコマンドを使うのが簡単です。

メッセージのコマンドでトリガーを操作する

自家用なので何か変更するときにはGASのサイトに行けばよいのですが、最低限の制御はLINEのメッセージでできるようにしました。

doPost関数のなかでメッセージのテキストを取り出して、その内容で各機能を実行しています。

以下のコマンドが使えます。

  • 開始:トリガーを設置して通知をスタートします。設置前にはすべてのトリガーを削除するので時刻を変更しての再設定にも使えます。
  • 停止:トリガーを削除して通知を停止します。
  • スキップ:通知を一回スキップします。ちょっと早めに点眼したときなどに使います。
  • 取消:スキップを取り消して次回の通知が来るようにします。

完成したプログラム

前置きがずんぶんと長くなりました。それなりに紆余曲折をへて完成したのが以下のプログラムになります。たぶんこれで合ってると思いますが、修正を繰り返しすぎて今使っているバージョンかどうかアヤシイです。

GASからWebアプリとしてデプロイして使います。

あらかじめスプレッドシートにLINEのチャンネルアクセストークンと服薬時刻の設定、LINEチャネルのWebhookにアプリのURLを設定する必要があります。

var spreadSheet = SpreadsheetApp.getActive();//スプレッドシートへ接続
var sheet = spreadSheet.getSheets()[0];
var log_sheet = spreadSheet.getSheets()[1];

var TOKEN = sheet.getRange('B1').getValue();
var USER_ID_CELL = "B2";
var USER_ID = sheet.getRange(USER_ID_CELL).getValue();
var USER_NAME_CELL = "B3";
var USER_NAME = sheet.getRange(USER_NAME_CELL).getValue();
var CONFIRM_INTERVAL = sheet.getRange('B9').getValue(); //スプレッドシートから確認間隔を取得
var REMIND_MSG = sheet.getRange('B8').getValue();
var USER_FOLLOW_MSG = sheet.getRange('B12').getValue();
var HOW_TO_USE_MSG = sheet.getRange('C12').getValue();
var CONFIRM_MSG = sheet.getRange('B10').getValue();
var REPLY_MSG = sheet.getRange('B11').getValue();
var SENSOR_MSG = sheet.getRange('B13').getValue();
var ALREADY_DONE_MSG = sheet.getRange('B14').getValue();
var BATTERY_ALERT_MSG = sheet.getRange('B15').getValue();
var RESPONCE_RECORD_CELL = 'B5';
var NOT_YET_MSG = sheet.getRange('B16').getValue();

var LINE_PUSH_ENDPOINT = "https://api.line.me/v2/bot/message/push";
var LINE_REPLY_ENDPOINT = 'https://api.line.me/v2/bot/message/reply';


function doPost(e) {//LINEからのWebhookを受けるところ
  const json = JSON.parse(e.postData.contents);
  // appendToLog(JSON.stringify(json));
  const reply_token = json.events[0].replyToken;//返信するためのトークン取得
  if (typeof reply_token === 'undefined') {
    return;
  }
  const type = json.events[0].type;//follow or message
  appendToLog(type);

  if (type === "follow") {//友達追加時の処理 IDと名前を記録
    USER_ID = json.events[0].source.userId;
    sheet.getRange(USER_ID_CELL).setValue(USER_ID);
    USER_NAME = getUserName(USER_ID);
    sheet.getRange(USER_NAME_CELL).setValue(USER_NAME);
    pushMessage(USER_FOLLOW_MSG + USER_NAME + "さん");
    pushMessage(HOW_TO_USE_MSG);
    return;
  }

  USER_NAME = getUserName(USER_ID);//ユーザー名は変わることがあるので毎回取得する
  sheet.getRange(USER_NAME_CELL).setValue(USER_NAME);


  //送られたメッセージ内容によってアクションを行う
  var message = json.events[0].message.text;

  switch (message) {
    case "停止"://トリガーを削除してアプリを止める
      message += "します。" + " " + USER_NAME + "さん";
      delAllTriggers();
      replyMessage(message, reply_token);
      return;
      break;

    case "開始"://トリガーをセットしてアプリを開始する
      message += "します。" + " " + USER_NAME + "さん";
      startApplication();
      replyMessage(message, reply_token);
      return;
      break;

    case "スキップ"://あらかじめ服薬しておくときのコマンド。前回の服薬が済んでいるときだけ次のリマインドをスキップできる
      if (isResponded()) {
        message = "通知を1回" + message + "します。" + " " + USER_NAME + "さん";
        replyMessage(message, reply_token);
        sheet.getRange(RESPONCE_RECORD_CELL).setValue("skip:" + new Date());
        return;
      } else {
        replyMessage(NOT_YET_MSG, reply_token);
        return;
      }
      break;

    case "取消"://スキップを取消する
      message = "通知スキップを" + message + "します。" + " " + USER_NAME + "さん";
      sheet.getRange(RESPONCE_RECORD_CELL).setValue(new Date());
      replyMessage(message, reply_token);
      return;
      break;

    default://コマンドがないとき。服薬記録があれば服薬済みを連絡、記録がなければ記録
      if (isResponded()) {
        replyMessage(ALREADY_DONE_MSG, reply_token);
      } else {
        message = REPLY_MSG + " " + USER_NAME + "さん";
        replyMessage(message, reply_token);
        sheet.getRange(RESPONCE_RECORD_CELL).setValue(new Date());
      }
      break;
  }
}


function replyMessage(msg, reply_token) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type": "text",
      "text": msg,
    }]
  };

  var headers = {
    "Content-Type": "application/json",
    'Authorization': 'Bearer ' + TOKEN,
  };

  var options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };
  var response = UrlFetchApp.fetch(LINE_REPLY_ENDPOINT, options);
}

function pushMessage(msg) {
  deleteReconfirmTrigger();
  var postData = {
    "to": USER_ID,
    "messages": [{
      "type": "text",
      "text": msg,
    }]
  };

  var headers = {
    "Content-Type": "application/json",
    'Authorization': 'Bearer ' + TOKEN,
  };

  var options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };
  var response = UrlFetchApp.fetch(LINE_PUSH_ENDPOINT, options);
}

function sendRegularReminder() {
  deleteReconfirmTrigger();
  if (isSkipOn()) {
    sheet.getRange(RESPONCE_RECORD_CELL).setValue(new Date());
    return;
  } else {
    pushMessage(REMIND_MSG);
    sheet.getRange(RESPONCE_RECORD_CELL).clearContent();
    setReconfirmTrigger();
  }
}

function sendReconfirm() {
  deleteReconfirmTrigger();//使い終わったトリガーを削除
  if (isResponded()) return;//返事が来てたら確認は送らない
  pushMessage(CONFIRM_MSG);
  setReconfirmTrigger();//新しいトリガーをセット
}


function getUserName(user_id) {
  var url = 'https://api.line.me/v2/bot/profile/' + user_id;
  var response = UrlFetchApp.fetch(url, {
    'headers': {
      'Authorization': 'Bearer ' + TOKEN
    }
  });
  return JSON.parse(response.getContentText()).displayName;
}

function isResponded() {//服薬済みかどうかを返す
  const temp = sheet.getRange(RESPONCE_RECORD_CELL).getValue();
  Logger.log(temp);
  const result = Boolean(temp);
  Logger.log(result);
  return result;
}

function isSkipOn() {
  const temp = sheet.getRange(RESPONCE_RECORD_CELL).getValue();
  return /skip/.test(temp);//記録に"skip"という単語が含まれているかどうか
}

//-トリガー関係---------------------------------------

//決められた時間後に確認のトリガーをセットする関数
function setReconfirmTrigger() {
  ScriptApp.newTrigger("sendReconfirm")
    .timeBased()
    .after(CONFIRM_INTERVAL * 60 * 1000)
    .create();
}

function deleteReconfirmTrigger() {
  var allTriggers = ScriptApp.getScriptTriggers();
  for (var i = 0; i < allTriggers.length; i++) {
    if (allTriggers[i].getHandlerFunction() == "sendReconfirm") {
      ScriptApp.deleteTrigger(allTriggers[i]);
    }
  }
}

//すべてのトリガーを削除。アプリの機能を停止する関数
function delAllTriggers() {
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    ScriptApp.deleteTrigger(triggers[i]);
  }
  sheet.getRange(RESPONCE_RECORD_CELL).clearContent();
}

function startApplication() {
  delAllTriggers();//全てのトリガーを削除してリセット
  var last_col = sheet.getLastColumn();
  var range = sheet.getRange(7, 2, 1, last_col);
  var remindTimes = range.getValues()[0].filter(function (x) {
    return x !== "" && x !== undefined && x !== null;
  });//空配列の除去

  remindTimes.forEach(function (t) {
    ScriptApp.newTrigger("sendRegularReminder")
      .timeBased()
      .atHour(t)
      .everyDays(1)
      .create();
  });
}

function getRimindTimes() {
  //服薬時間を配列として取得
  var last_col = sheet.getLastColumn();
  var range = sheet.getRange(7, 2, 1, last_col);
  var remindTimes = range.getValues()[0].filter(Boolean);//空配列の除去
  return remindTimes;
}


function appendToLog(d) {
  const now = Utilities.formatDate(new Date(), "JST", "yyyy/MM/dd HH:mm:ss");
  log_sheet.appendRow([now, d]);
  SpreadsheetApp.flush();
}


function doGet(e) { //マイコンからのアクセス用
  var action = JSON.stringify(e.parameter.action);
  var battery = JSON.stringify(e.parameter.battery);
  if (action === void 0) {
    action = "no special action";
  } else {
    action = action.replace(/\"/g, "");
  }

  if (battery === void 0) {
    battery = "no battery data";
  } else {
    battery = parseInt(battery.replace(/\"/g, ""));
  }
  appendToLog(battery);
  var batteryStr = ":" + (battery / 1000) + "V";

  switch (action) {//将来の拡張用。マイコンからのコマンドを受け付ける
    case 'start':
      startApplication();
      break;
    default:
  }

  if (isResponded()) {
    pushMessage(ALREADY_DONE_MSG + batteryStr);
  } else {
    pushMessage(SENSOR_MSG + batteryStr);
    sheet.getRange(RESPONCE_RECORD_CELL).setValue(new Date());
  }

  if (battery < 3300) {
    pushMessage(BATTERY_ALERT_MSG + batteryStr);
  }

  return ContentService.createTextOutput(JSON.stringify({ 'result': action })).setMimeType(ContentService.MimeType.JSON);
}

リマインダーの使い方

友だち登録から始まることを前提としているので、すでにチャネルを友だち登録している場合には、いったん友だちを削除します。

LINEでの友だち削除

  • ホームの友だち>公式アカウント>チャネルを左にスライドしてブロック
  • ホーム>設定(右上の歯車)>友だち>ブロックリストで削除

友だち登録〜通知開始まで

  • QRコードを使って友だち登録
  • メッセージが届く
    スプレッドシートにユーザーIDと名前が記録されている
  • 「開始」と送信
  • トリガーが設置され、スプレッドシートに書かれた時刻に通知が行われる
    GASエディタのトリガー画面てトリガーを確認できる
  • 服薬の時間になるとLINEに通知がくる
    実際の通知時間には1時間くらいの幅がある
  • 服薬したら文字でもスタンプでも返事をする
    返事がないと催促が繰り返し届く

ひとまずこれで点眼の通知と記録ができるようになりました。

ESP8266で目薬の使用を自動記録

LINE通知だけでも十分に実用になりましたが、せっかくなのでマイコンを使って目薬の使用を自動記録することにしました。

LINEで自分で返事をしてもいいし、マイコンに自動記録させてもよいという二刀流です。

マイコンでの記録には以前作った鍵掛け通知の時と同じくESP8266(ESPr Developper)を使いました。

まず3Dプリンタでマイコン(ESP8266)内蔵の目薬ケースを作りました。目薬を出し入れすると中のレバーが動き、マイクロスイッチがオン・オフするようになっています。

マイクロスイッチはEPS8266のRESET端子とGNDの間に入っています。ケースから目薬を取り出すとRESETがGNDに落ち、再度挿入するとRESETがGNDから離れ、ESP8266がDeepSleepから目覚めます。"挿入するとスイッチが切れる"動作になっています。

目薬を取り出したタイミングでマイコンを起動すると目薬を戻すまでに送信が終わらない可能性があります。このため、目薬を戻したタイミングでマイコンが起動するようにしています。

起動したEPS8266はWifiに接続し、GASのWebアプリにGETメソッドでアクセスします。

WebアプリはGETアクセスがあるとマイコンから連絡があったと見なして点眼を記録します。スクリプトのdoGet(e)関数で処理しています。

これによりLINEに返信する手間がなくなり、返信忘れも防止できます。

LINEからはPOSTメソッドでアクセスがくるので、メソッドの違いだけでマイコンとLINEを区別しています。

マイコンからはGETパラメーターとして電源電圧を付けておき、電池が消耗するとLINEに警告が送られるようにしています。

将来の拡張用に"action"というパラメーターも付けてあります。今は使っていません。

利用にはHTTPSRedirectライブラリが必要になります。githubからarduino IDEのライブラリに登録します。

プログラムはHTTPSRedirect付属のサンプルを改造して作りました。

HTTPS Redirect (Github)

#include <ESP8266WiFi.h>
#include "HTTPSRedirect.h"

ADC_MODE(ADC_VCC); //to watch 3.3V pin voltage

// WIFI SSID and PASSWORD
const char* ssid = "----your ssid----";
const char* password = "----your wifi password----";

const char* host = "script.google.com";
const char *GScriptId = "---- your google apps script id (not url , only id )--------";

const int httpsPort = 443;

String vcc_3v3 = String(ESP.getVcc());
String url = String("/macros/s/") + GScriptId + "/exec?action=Hello&battery=" + vcc_3v3;

HTTPSRedirect* client = nullptr;

void setup() {
  Serial.begin(115200);
  Serial.flush();

  Serial.println();
  Serial.print("Connecting to wifi: ");
  Serial.println(ssid);
  Serial.flush();

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("WiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());

  // Use HTTPSRedirect class to create a new TLS connection
  client = new HTTPSRedirect(httpsPort);
  client->setInsecure();
  client->setPrintResponseBody(true);
  client->setContentTypeHeader("application/json");

  Serial.print("Connecting to ");
  Serial.println(host);

  // Try to connect for a maximum of 5 times
  bool flag = false;
  for (int i = 0; i < 5; i++) {
    int retval = client->connect(host, httpsPort);
    if (retval == 1) {
      flag = true;
      break;
    }
    else
      Serial.println("Connection failed. Retrying...");
  }

  if (!flag) {
    Serial.print("Could not connect to server: ");
    Serial.println(host);
    Serial.println("Exiting...");
    return;
  }


  Serial.println("\nGET:Access to GAS Web application ");
  Serial.println("=====================================");

  client->GET(url, host);

  delete client;
  client = nullptr;

  ESP.deepSleep(0);

}

void loop() {

}

これで完成です。

電子お薬手帳の機能として欲しい

ずいぶんと長編になってしまいました。途中の補足とかを除けばもっとコンパクトになると思うのですが、個人ブログはそういうムダがあってもいいんじゃないかと思います。やっぱり機能実験だけでなく実用品として完成させると勉強になりますね。

少しずつ改良しながら自分で使って1ヶ月くらいたちますが、とても役に立っています。確実に点眼忘れを減らすことができます。

マイコンでの点眼記録はやり過ぎかなと思いましたが、やっぱりわざわざ手作業で返事をしなくてよいのは便利です。返事のし忘れもないし。在宅ではマイコンで、外出時ではLINEで記録しています。

自作のいいところは、自分に合ったリマインドの方法や頻度、しつこさを設定できることです。逆に、私には合わなかったけど、Appleの純正リマインダーで十分な人もたくさんいると思います。

調剤薬局も電子化が進んできていて、最近は電子処方箋とか電子お薬手帳とかオンラインでの服薬指導とかもあるみたいです。

こういう服薬リマインダーも電子お薬手帳の機能としてあったらいいんじゃないかと思います。もしかしたらあるのかな。でも誤作動で責任問われそうだからやらないかなー。

-3Dプリント, Arduino, Google Apps Script, Google Workspace (G Suite), Javascript, プログラミング, 工作, 電子工作