Intel Edison の環境を触れる機会があったので、Edison上でスケジュールリマインダーを作ってみました。
やったこととしては、
- Google Calendar API でスケジュール情報を取得
- 開始10分前の予定があれば Amazon Polly で音声ファイル作成
- 音声ファイルを mp3 から wav に変換して再生
という感じで、構成としては簡単ですが下記の図のような形です。
Google Calendar API の有効化
Google の API を使用するには、まず該当の API を有効化する必要があります。
まずは GCP のコンソールにアクセスし、プロジェクトを作成します。
GCP のダッシュボードが表示されますので、「APIの概要」をクリックします。
すると API のダッシュボードが表示されますが、初回はまだ有効なAPIがないので、下記のような表示になります。左メニューの「ライブラリ」をクリックして API のリスト画面に遷移します。
G Suite APIs の中の Calendar API を選択します。
API についての説明が表示されますので、 有効にする
をクリックします。
次に 認証情報を作成
ボタンを押して、認証情報の作成画面に遷移します。
下記のように選択して、 必要な認証情報
ボタンをクリックします。
クライアントIDの名前を決めて クライアントIDの作成
ボタンをクリックします。
メールアドレスとサービス名を入力して 次へ
をクリックします。
作成された認証情報を ダウンロード
をクリックして取得したら、 完了
をクリックしてひとまず GCP コンソールからの作業は終了です。
Google Calendar API でスケジュール情報取得
では Google Calendar API でスケジュール情報を取得します。Google API は OAuth2 での認証が複雑ですが、認証処理については下記サイトを参考にそのまま実装しています。
Ruby Quickstart | Google Calendar API | Google Developers
ただ、私の環境では実行すると下記のようなエラーが出ました。
/home/root/.local/lib/ruby/2.2.0/net/http.rb:923:in `connect': SSL_connect returned=1 errno=0 state=SSLv3 read server certificate B: certificate verify failed (Faraday::SSLError) from /home/root/.local/lib/ruby/2.2.0/net/http.rb:923:in `block in connect' from /home/root/.local/lib/ruby/2.2.0/timeout.rb:73:in `timeout' from /home/root/.local/lib/ruby/2.2.0/net/http.rb:923:in `connect' from /home/root/.local/lib/ruby/2.2.0/net/http.rb:863:in `do_start' from /home/root/.local/lib/ruby/2.2.0/net/http.rb:852:in `start' from /home/root/.local/lib/ruby/2.2.0/net/http.rb:1375:in `request' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:80:in `perform_request' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:38:in `block in call' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:85:in `with_net_http_connection' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/adapter/net_http.rb:33:in `call' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/request/url_encoded.rb:15:in `call' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/rack_builder.rb:141:in `build_response' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/connection.rb:386:in `run_request' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/faraday-0.12.2/lib/faraday/connection.rb:186:in `post' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/signet-0.7.3/lib/signet/oauth_2/client.rb:960:in `fetch_access_token' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/signet-0.7.3/lib/signet/oauth_2/client.rb:998:in `fetch_access_token!' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/googleauth-0.5.3/lib/googleauth/signet.rb:69:in `fetch_access_token!' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/googleauth-0.5.3/lib/googleauth/user_authorizer.rb:178:in `get_credentials_from_code' from /home/root/work/schedule_reminder/vendor/bundle/ruby/2.2.0/gems/googleauth-0.5.3/lib/googleauth/user_authorizer.rb:200:in `get_and_store_credentials_from_code' from test.rb:30:in `authorize' from test.rb:39:in `<main>' root@edison:~/work/schedule_reminder#
これはSSLアクセスの際に証明書が見つからないことによるエラーのようです。色々と調べた結果、下記サイトの情報をみつけました。
上記サイトで紹介されているように、証明書のパスを下記のように設定することで解決しました。
cert_path = Gem.loaded_specs['google-api-client'].full_gem_path+'/lib/cacerts.pem' ENV['SSL_CERT_FILE'] = cert_path
あとは Calendar の API を呼んでスケジュールを取得します。イベントのリストを取得する API では開始時間の上限(いつより前に開始したか)や終了時間の下限(いつより後に終了するか)、ソート順を条件に指定できますので、下記のような条件を指定しています。
- 開始時間上限:現在時刻から10分後
- 終了時間下限:現在時刻
- ソート順:開始時間の昇順
ただこれだと今実行中の予定(開始済みでまだ終了していない予定)を除外することができないので、自前で除外処理を実装しています。
ここまでを一つのクラスとして実装していて、コードの全体は下記のようになります。
require 'bundler/setup' require 'google/apis/calendar_v3' require 'googleauth' require 'googleauth/stores/file_token_store' require 'date' require 'fileutils' require 'active_support' require 'active_support/core_ext' class Calendar OOB_URI = 'urn:ietf:wg:oauth:2.0:oob' APPLICATION_NAME = 'ScheduleReminder' CLIENT_SECRETS_PATH = 'client_secret.json' CREDENTIALS_PATH = File.join(Dir.home, '.credentials', "schedule_reminder.yaml") SCOPE = Google::Apis::CalendarV3::AUTH_CALENDAR_READONLY DEFAULT_TIME_RANGE = 10.minutes def initialize(calendar_ids) cert_path = Gem.loaded_specs['google-api-client'].full_gem_path+'/lib/cacerts.pem' ENV['SSL_CERT_FILE'] = cert_path # Initialize the API @service = Google::Apis::CalendarV3::CalendarService.new @service.client_options.application_name = APPLICATION_NAME @service.authorization = authorize @calendar_ids = calendar_ids end def authorize FileUtils.mkdir_p(File.dirname(CREDENTIALS_PATH)) client_id = Google::Auth::ClientId.from_file(CLIENT_SECRETS_PATH) token_store = Google::Auth::Stores::FileTokenStore.new(file: CREDENTIALS_PATH) authorizer = Google::Auth::UserAuthorizer.new(client_id, SCOPE, token_store) user_id = 'default' credentials = authorizer.get_credentials(user_id) if credentials.nil? url = authorizer.get_authorization_url(base_url: OOB_URI) puts "Open the following URL in the browser and enter the resulting code after authorization" puts url code = gets credentials = authorizer.get_and_store_credentials_from_code(user_id: user_id, code: code, base_url: OOB_URI) end credentials end def list_events(max_results, time_range = DEFAULT_TIME_RANGE, exclude_already_started = true) now = DateTime.now time_min = now.iso8601 time_max = (now + time_range).iso8601 events = [] @calendar_ids.each do |calendar_id| response = @service.list_events( calendar_id, max_results: max_results, single_events: true, order_by: 'startTime', time_min: time_min, time_max: time_max ) events.concat(response.items) end events.sort! do |event_a, event_b| event_a.start.date_time <=> event_b.start.date_time end return events unless exclude_already_started events.select do |event| event.start.date_time >= now end end end
Amazon Polly で音声ファイルを作成
Google Calendar から取得した予定のタイトルを喋る音声ファイルを生成するため、 Amazon Polly にアクセスします。まずは Edison 上で日本語を扱えるようにロケールを設定します。下記サイトを参考にそのまま実行しました。(実行結果については割愛)
それでは Polly にアクセスします。今回は AWS の Ruby SDK を使っていますので、事前に ACCESS_KEY と SECRET_ACCESS_KEY を取得して環境変数に設定し、それを認証情報として使用します。
ACCESS_KEY_ID = ENV['ACCESS_KEY_ID'] SECRET_ACCESS_KEY = ENV['SECRET_ACCESS_KEY'] def initialize Aws.config.update({ region: REGION, credentials: Aws::Credentials.new(ACCESS_KEY_ID, SECRET_ACCESS_KEY) }) @client = Aws::Polly::Client.new end
あとは音声ファイル生成のAPIを呼び出せばOKです。 Polly では言語ごとに使用できる音声が決まっていて、日本語では今のところ Mizuki
というIDの女性の音声のみ使用できます。また、直接 Web API を呼び出すと結果は音声ストリームとして返ってきますが、SDKからのアクセスであれば、出力先をファイルとして指定できるので、ストリームの処理を行う必要がなく手軽に使えます。
音声ファイルの変換と再生
Amazon Polly では音声ファイルの出力形式として mp3, ogg_vorbis, pcm が指定できますが、 Edison で用意されているオーディオプレイヤーの aplay では、 wav 形式のファイルしか再生できません。そこで Polly からは mp3 として音声ファイルを出力し、それを mpg123 というツールを使って wav に変換します。
mpg123, Fast MP3 Player for Linux and UNIX systems
mpg123 でそのまま mp3 を再生したかったのですが、再生しようとするとライブラリが不足していてエラーになってしまい、すんなり解決できなかったのでとりあえず変換してしまうことにしました。
def convert(target_file) Open3.capture3("mpg123 -w #{target_file}.wav #{target_file}.mp3") end
変換後の wav ファイルを aplay で実行すれば、再生することができます。
def play(target_file) Open3.capture3("aplay #{target_file}.wav") end
Polly からの音声ファイル取得と、wav への変換、再生までを一つのクラスとして実装しています。コードの全体は下記のようになっています。
require 'bundler/setup' require 'aws-sdk' require 'open3' require 'digest/md5' class Notifier VOICE_DIR = '/home/root/work/schedule_reminder/voices' REGION = 'us-east-1' ACCESS_KEY_ID = ENV['ACCESS_KEY_ID'] SECRET_ACCESS_KEY = ENV['SECRET_ACCESS_KEY'] VOICE_ID_JP = 'Mizuki' def initialize Aws.config.update({ region: REGION, credentials: Aws::Credentials.new(ACCESS_KEY_ID, SECRET_ACCESS_KEY) }) @client = Aws::Polly::Client.new end def speech(text) text_hash = Digest::MD5.hexdigest(text) target_file = "#{VOICE_DIR}/#{text_hash}" synthesize(text, target_file) convert(target_file) play(target_file) end def synthesize(text, target_file) resp = @client.synthesize_speech({ response_target: "#{target_file}.mp3", output_format: 'mp3', voice_id: VOICE_ID_JP, text: text }) end def convert(target_file) Open3.capture3("mpg123 -w #{target_file}.wav #{target_file}.mp3") end def play(target_file) Open3.capture3("aplay #{target_file}.wav") end end
リマインダーを毎分実行
ここまででスケジュール情報の取得と音声ファイルの生成、再生の準備はできたので、あとは一分おきにこれらを実行するようにします。取得したスケジュール情報をローカルでDBに取り込んだりすれば、再起動した時や cron での実行にも柔軟に対応できそうですが、今回はそこまでやると手間がかかってしまうので、起動したら60秒スリープで無限ループを回して、一度リマインド済みの予定はIDを記録しておいて、複数回通知されないようにしました。再起動時に直近の予定が再度リマインドされてしまうことは許容しています。
コードの全体は下記の通りです。
require 'bundler/setup' require 'active_support' require 'active_support/core_ext' require './calendar.rb' require './notifier.rb' class Reminder MAX_RESULTS = 10 TIME_RANGE = 10.minutes SPEECH_TEMPLATE_JP = '間も無く、%sが始まる時間です。' CALENDAR_IDS = ['XXXXXXXXXXXXXXXXXXXXXXXX'] def initialize @calender = Calendar.new(CALENDAR_IDS) @notifier = Notifier.new @reminded_event_ids = [] @log = Logger.new('logs/reminder.log') @log.debug('Initialized Reminder.') end def remind events = @calender.list_events(MAX_RESULTS, TIME_RANGE) if events.empty? @log.debug('No events found.') return end event = events.first start = event.start.date || event.start.date_time if @reminded_event_ids.include?(event.id) @log.debug("AlreadyReminded: #{start} - #{event.summary} id: #{event.id}") return end @notifier.speech(SPEECH_TEMPLATE_JP % event.summary) @reminded_event_ids << event.id @log.info("Reminded: #{start} - #{event.summary} id: #{event.id}") end end if $0 == __FILE__ reminder = Reminder.new loop do reminder.remind sleep(1.minute) end end
これをバックグラウンド実行しておけば、次の予定の10分前になると音声での通知が行われ、下記のようにログに出力されます。
D, [2017-08-20T22:25:50.281798 #695] DEBUG -- : Initialized Reminder. D, [2017-08-20T22:25:51.838564 #695] DEBUG -- : No events found. D, [2017-08-20T22:26:52.503386 #695] DEBUG -- : No events found. I, [2017-08-20T22:27:59.064125 #695] INFO -- : Reminded: 2017-08-21T07:30:00+09:00 - 開発部定例ミーティング id: 25rq8i063ao99thjissss8i211 D, [2017-08-20T22:28:59.827868 #695] DEBUG -- : AlreadyReminded: 2017-08-21T07:30:00+09:00 - 開発部定例ミーティング id: 25rq8i063ao99thjissss8i211 D, [2017-08-20T22:30:00.609354 #695] DEBUG -- : AlreadyReminded: 2017-08-21T07:30:00+09:00 - 開発部定例ミーティング id: 25rq8i063ao99thjissss8i211
まとめ
Intel Edison については先日残念ながら販売が終了になることが明らかになりました。
Edison を利用しているデバイスは多そうなので、影響も大きそうですが、まだしばらくは Edison を触る機会もあるのではないかと思います。
また、 Google Calendar API や Amazon Polly API は認証部分さえクリアしてしまえば実際の機能を利用する API は手軽に使えるので、色々なことに活用できそうな気がしています。特に Polly については Lex との組み合わせも色々とできそうなので、また遊んでみたいと思います。
今回のコードは下記リポジトリに公開してありますので、よろしければご参照ください。