Intel Edison から JRuby で Google Calendar のスケジュールを Amazon Polly に喋らせる

 Edison 上で Java の既存ライブラリを Ruby から使ってみたいというケースがあったので、 JRuby を試してみました。前回のスケジュールリマインダーの Amazon Polly へのアクセス部分を JRuby から AWS SDK for Java を使う形に変更してみます。

jruby.org

JRuby インストール

 まずは JRuby をインストールします。 JRuby のサイトから最新のバイナリをダウンロードして展開します。 2017/08/31 時点では Ruby2.3 互換の 9.1.12.0 が最新でした。

# wget https://s3.amazonaws.com/jruby.org/downloads/9.1.12.0/jruby-bin-9.1.12.0.tar.gz
# gunzip jruby-bin-9.1.12.0.tar.gz
# tar xf jruby-bin-9.1.12.0.tar

 展開したバイナリの bin ディレクトリにパスを通すために、 ~/.profile に下記を追記します。 bin ディレクトリのパスは実際の展開先のパスに置き換えてください。

export PATH=$PATH:/path/to/jruby-9.1.12.0/bin

 jruby コマンドにパスが通っていることを確認します。

# jruby -v
jruby 9.1.12.0 (2.3.3) 2017-06-15 33c6439 Java HotSpot(TM) Client VM 25.40-b25 on 1.8.0_40-b25 +jit [linux-i386]

bundler 使用設定

 JRuby から bundler を使うための設定をします。まずは JRuby で bundler をインストールします。 jruby -S の後にコマンドをつなげると JRuby としてのコマンド実行になります。

# jruby -S gem install bundler
Fetching: bundler-1.15.4.gem (100%)
Successfully installed bundler-1.15.4
1 gem installed

 無事にインストールされたので bundle install を実行します。

# jruby -S bundle install --path vendor/bundle

AWS SDK for Java で Amazon Polly へアクセス

 JRuby を使うための環境ができたので、 AWS SDK for Java を使って Amazon Polly へアクセスするコードを用意します。下記にコード全体(notifier_jruby.rb)を掲載します。

require 'bundler/setup'
require 'open3'
require 'digest/md5'

require 'java'
require 'aws-java-sdk-1.11.185.jar'
require 'commons-logging-1.2.jar'
require 'commons-codec-1.9.jar'
require 'httpcore-4.4.6.jar'
require 'httpclient-4.5.3.jar'
require 'jackson-core-2.9.0.jar'
require 'jackson-databind-2.9.0.jar'
require 'jackson-annotations-2.9.0.jar'

java_import 'java.lang.System'
java_import 'java.nio.file.Files'
java_import 'java.nio.file.StandardCopyOption'
java_import 'com.amazonaws.auth.BasicAWSCredentials'
java_import 'com.amazonaws.auth.AWSStaticCredentialsProvider'
java_import 'com.amazonaws.services.polly.AmazonPollyClientBuilder'
java_import 'com.amazonaws.services.polly.model.SynthesizeSpeechRequest'

class Notifier
  VOICE_DIR = '/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_credentials = BasicAWSCredentials.new(ACCESS_KEY_ID, SECRET_ACCESS_KEY)
    aws_credentials_provider = AWSStaticCredentialsProvider.new(aws_credentials)
    @client = AmazonPollyClientBuilder.standard.withCredentials(aws_credentials_provider).withRegion(REGION).build
  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)
    speech_request = SynthesizeSpeechRequest.new.withText(text).withVoiceId(VOICE_ID_JP).withOutputFormat('mp3')
    speech_result = @client.synthesizeSpeech(speech_request)
    file = java.io.File.new("#{target_file}.mp3")
    Files.copy(speech_result.getAudioStream, file.toPath, StandardCopyOption.valueOf('REPLACE_EXISTING'))
  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

 まずは使用する jar ファイルをダウンロードして ./lib ディレクトリに配置し、 CLASSPATH環境変数にそのディレクトリのパスを設定した上で、読み込むために各 jar ファイルを require します。

require 'java'
require 'aws-java-sdk-1.11.185.jar'
require 'commons-logging-1.2.jar'
require 'commons-codec-1.9.jar'
require 'httpcore-4.4.6.jar'
require 'httpclient-4.5.3.jar'
require 'jackson-core-2.9.0.jar'
require 'jackson-databind-2.9.0.jar'
require 'jackson-annotations-2.9.0.jar'

 そしてJavaのクラスを import するために java_import を使用します。

java_import 'java.lang.System'
java_import 'java.nio.file.Files'
java_import 'java.nio.file.StandardCopyOption'
java_import 'com.amazonaws.auth.BasicAWSCredentials'
java_import 'com.amazonaws.auth.AWSStaticCredentialsProvider'
java_import 'com.amazonaws.services.polly.AmazonPollyClientBuilder'
java_import 'com.amazonaws.services.polly.model.SynthesizeSpeechRequest'

 初期化時に AWS SDK for Java を使って Amazon Polly のクライアントインスタンスを生成します。今回はとりあえずで BasicAWSCredentials で直接 ACCESS_KEY_IDSECRET_ACCESS_KEY を指定していますが、こちらにあるような認証情報プロバイダを使ったやり方の方が望ましいかと思います。

  def initialize
    aws_credentials = BasicAWSCredentials.new(ACCESS_KEY_ID, SECRET_ACCESS_KEY)
    aws_credentials_provider = AWSStaticCredentialsProvider.new(aws_credentials)
    @client = AmazonPollyClientBuilder.standard.withCredentials(aws_credentials_provider).withRegion(REGION).build
  end

 そして Polly へのアクセス部分も AWS SDK for Java を使う形に変更します。 Java の Polly クライアントでは直接保存先ファイルを指定できないので、 AudioStream を取り出して、 java.nio.file.Filescopy メソッドを使用してファイルに保存しています。

 また、 File クラスは Ruby の File クラスとクラス名が重複してしまうので、パッケージ名込みでクラスを指定しています。

  def synthesize(text, target_file)
    speech_request = SynthesizeSpeechRequest.new.withText(text).withVoiceId(VOICE_ID_JP).withOutputFormat('mp3')
    speech_result = @client.synthesizeSpeech(speech_request)
    file = java.io.File.new("#{target_file}.mp3")
    Files.copy(speech_result.getAudioStream, file.toPath, StandardCopyOption.valueOf('REPLACE_EXISTING'))
  end

実行

 今までの notifier.rb の代わりに notifier_jruby.rb を reminder.rb から require するように変更した上で、下記のように CLASSPATH 指定と一緒に実行します。

# CLASSPATH='./lib' jruby -S bundle exec jruby reminder.rb

 もちろん CLASSPATH は export であらかじめ設定しておいてもOKです。

# export CLASSPATH='./lib'
# jruby -S bundle exec jruby reminder.rb

まとめ

 今回はとりあえず試してみるということで、例外処理等は考慮していませんが、Rubyメインで実装しつつ、限定的にJavaのライブラリを使いたいようなケースでは手軽にJavaのクラスを使うことができるので便利そうです。ただ例外処理や依存するライブラリの読み込み、バグ発生時のデバッグのことを考えると、本番で使用するにはなかなかハードルは高そうにも思いました。

 あとは今回はパフォーマンス面は測定しませんでしたが、JVM上での実行となることで同じ Ruby のコードでもパフォーマンスは向上する可能性もあると思いますので、パフォーマンス改善のための選択肢としても可能性はありそうかなと思っています。

 今回のコードはこちらにも公開していますのでよろしければご参照ください。

github.com

Intel Edison から Google Calendar のスケジュールを Amazon Polly で喋らせる

 Intel Edison の環境を触れる機会があったので、Edison上でスケジュールリマインダーを作ってみました。

software.intel.com

やったこととしては、

  1. Google Calendar API でスケジュール情報を取得
  2. 開始10分前の予定があれば Amazon Polly で音声ファイル作成
  3. 音声ファイルを mp3 から wav に変換して再生

という感じで、構成としては簡単ですが下記の図のような形です。

f:id:akanuma-hiroaki:20170819111340p:plain

Google Calendar API の有効化

 Google の API を使用するには、まず該当の API を有効化する必要があります。

 まずは GCP のコンソールにアクセスし、プロジェクトを作成します。

f:id:akanuma-hiroaki:20170821074959p:plain:w450

 GCP のダッシュボードが表示されますので、「APIの概要」をクリックします。

f:id:akanuma-hiroaki:20170821075254p:plain:w450

 すると API のダッシュボードが表示されますが、初回はまだ有効なAPIがないので、下記のような表示になります。左メニューの「ライブラリ」をクリックして API のリスト画面に遷移します。

f:id:akanuma-hiroaki:20170821075449p:plain:w450

 G Suite APIs の中の Calendar API を選択します。

f:id:akanuma-hiroaki:20170821075549p:plain:w450

 API についての説明が表示されますので、 有効にする をクリックします。

f:id:akanuma-hiroaki:20170821075842p:plain:w450

 次に 認証情報を作成 ボタンを押して、認証情報の作成画面に遷移します。

f:id:akanuma-hiroaki:20170821075943p:plain:w450

 下記のように選択して、 必要な認証情報 ボタンをクリックします。

f:id:akanuma-hiroaki:20170821080218p:plain:w450

 クライアントIDの名前を決めて クライアントIDの作成 ボタンをクリックします。

f:id:akanuma-hiroaki:20170821080315p:plain:w450

 メールアドレスとサービス名を入力して 次へ をクリックします。

f:id:akanuma-hiroaki:20170821080417p:plain:w450

 作成された認証情報を ダウンロード をクリックして取得したら、 完了 をクリックしてひとまず 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アクセスの際に証明書が見つからないことによるエラーのようです。色々と調べた結果、下記サイトの情報をみつけました。

github.com

 上記サイトで紹介されているように、証明書のパスを下記のように設定することで解決しました。

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 上で日本語を扱えるようにロケールを設定します。下記サイトを参考にそのまま実行しました。(実行結果については割愛)

qiita.com

 それでは 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 については先日残念ながら販売が終了になることが明らかになりました。

hackaday.com

 Edison を利用しているデバイスは多そうなので、影響も大きそうですが、まだしばらくは Edison を触る機会もあるのではないかと思います。

 また、 Google Calendar API や Amazon Polly API は認証部分さえクリアしてしまえば実際の機能を利用する API は手軽に使えるので、色々なことに活用できそうな気がしています。特に Polly については Lex との組み合わせも色々とできそうなので、また遊んでみたいと思います。

 今回のコードは下記リポジトリに公開してありますので、よろしければご参照ください。

github.com

OpenBlocks IoT BX1 で SensorTag のデータを AWS IoT に送信する

 ぷらっとホームの IoT ゲートウェイ OpenBlocks IoT BX1 を試す機会があったので、SensorTag のデータを BX1 から AWS IoT に送信する処理を試してみました。

f:id:akanuma-hiroaki:20170812204308j:plain:w300

openblocks.plathome.co.jp

初期設定

 まずは BX1 の初期設定を行います。付属のUSBケーブルで BX1 と MacBook Pro を接続すると BX1 が起動します。

f:id:akanuma-hiroaki:20170812204611j:plain:w300

 初期状態ではAPとして起動するので、 iotfamily_シリアル番号 となっているAPに初期パスワードで接続します。

f:id:akanuma-hiroaki:20170812205057p:plain:w300

 APに接続したらブラウザから https://192.168.254.254:4430 にアクセスすると初回は使用許諾画面や管理者アカウント設定画面が表示されるので、許諾への同意やアカウント設定を行います。

f:id:akanuma-hiroaki:20170812205544p:plain

 次にネットワークの設定です。BX1 はSimを挿して3G回線に接続することもできますが、まずは自宅のLANに接続してみます。LANに接続する場合には使用モードを「クライアントモード」に設定し、接続するAPの情報や、IPアドレス等を設定します。

f:id:akanuma-hiroaki:20170812210351p:plain

 ネットワーク設定を変更すると BX1 の再起動が求められますので、「メンテナンス」メニューから再起動を行います。再起動後は先ほど設定したIPアドレスでアクセスできるようになりますので、MacBookの接続先APをいつもの自宅のLANのAPに戻し、 https://192.168.10.100:4430 でアクセスします。

 そして時刻の設定を行えば最低限の設定は完了です。

f:id:akanuma-hiroaki:20170812210943p:plain

BLEデバイスの接続設定

 BX1 では IoT デバイスとして Bluetooth インタフェースを標準でサポートしており、 SensorTag はサポート対象デバイスにも含まれています。

OpenBlocks IoT BX1 対応センサー/デバイス
http://openblocks.plathome.co.jp/products/obs_iot/bx1/sensor_dev/index.html

 まずは BT サービスを起動します。「サービス」メニューから BT の使用設定を「使用する」に変更して保存します。

f:id:akanuma-hiroaki:20170812212134p:plain

 「BT関連」等のタブが追加されますので、「BT関連」タブを選択し、 SensorTag の Advertising を開始してから「BLEデバイス検出」の 検出 ボタンをクリックします。すると SensorTag が検出されますので、「使用設定」にチェックを入れて保存します。

f:id:akanuma-hiroaki:20170812212338p:plain

 すると一覧に SensorTag が追加されます。   f:id:akanuma-hiroaki:20170812212651p:plain

 「BT編集」タブでは各デバイスのメモの編集等が行えますので、わかりやすいようにメモを登録しておきます。

f:id:akanuma-hiroaki:20170812212734p:plain

データ収集設定

 BX1 はデータ収集機能を持っていて、 Web UI から設定することが可能です。収集したデータをクラウドに送信することもできますが、まずはBLEデバイスからのデータ収集ができるところまでを確認してみます。

 「サービス」メニューの「基本」タブから、データ収集と PD Handler BLE を「使用する」に設定して保存します。

f:id:akanuma-hiroaki:20170812214729p:plain

 すると「収集設定」タブが追加されます。各種クラウドサービスへの送信の設定が行えますが、まずは BX1 で SensorTag の情報が取得できているかを確認するため、「本体内(local)」のみ「使用する」に設定します。また、画面下部のデバイス情報送信設定にて、 SensorTag の送信対象を「送信する」に設定し、送信先設定で「local」のみにチェックを入れて保存します。

f:id:akanuma-hiroaki:20170812215249p:plain

 正しく設定されていれば SensorTag からの値の取得が始まり、「収集ログ」タブで pd-handler-stdout.log を確認すると、センサーデータが収集されていることが確認できます。

f:id:akanuma-hiroaki:20170812215412p:plain

 また、「データ表示」タブでは、収集しているデータをグラフやテーブル表示で確認することができます。

f:id:akanuma-hiroaki:20170812215815p:plain

3G回線への接続設定

 ここまでで SensorTag のデータが収集できていることは確認できたので、AWS IoT へのデータ送信を行いたいと思いますが、以前の記事同様に SORACOM Beam 経由で送信してみたいと思いますので、まずは3G回線への接続設定を行いたいと思います。

 一旦 BX1 をシャットダウンして SORACOM Air Sim を挿した後で起動し、再度管理画面にアクセスします。

 3G回線に接続する場合は Wireless LAN の設定をAPモードにする必要がありますので、SSIDやパスフレーズ等を設定し直します。

 モバイル回線設定では SORACOM Air の APN やユーザ名、パスワード等を設定して保存し、再起動します。

f:id:akanuma-hiroaki:20170812220343p:plain

 再起動後はAPモードでの起動になりますので、MacBook から先ほど設定したAPへ接続した後、 https://192.168.254.254:4430 で管理画面にアクセスします。ネットワークメニューの「状態」タブを見ると、3G回線への接続が確認できます。

f:id:akanuma-hiroaki:20170813150120p:plain

データ送信設定

 それでは SensorTag のデータを AWS IoT へ Publish するように設定します。 BX1 では AWS IoT 用の送信設定も可能ですが、今回は SORACOM Beam 経由で送信するため、 MQTT の送信設定を使用します。 サービスメニューの「収集設定」タブから、MQTTサーバを「使用する」に設定します。送信先ホストやポートは SORACOM Beam の情報を設定します。トピックプレフィックスには任意の文字列を設定します。

 また、デバイス情報送信設定の方でも、送信先設定で「MQTT」にチェックを入れます。ここで表示される ユニークID と、先ほどの トピックプレフィックス/ で繋いだものが、 Publish 先のトピックとして使用されます。

f:id:akanuma-hiroaki:20170812222702p:plain

 ここまでの設定を保存すると、 SensorTag の情報が MQTT サーバに Publish されるようになります。AWS IoT のコンソールから該当のトピックに Subscribe してみると、下記のようにセンサーデータが Publish されていることが確認できます。

f:id:akanuma-hiroaki:20170812223404p:plain

まとめ

 SensorTag は BX1 でサポートされているということもあり、コードを書くことなく手軽にセンサーデータをクラウドに送信することができました。今回は SORACOM Beam 経由にするために MQTTサーバへの送信にしましたが、直接 AWS IoT に送信するのであれば、送信設定で Shadow のフォーマットにも対応できるようです。また、非対応デバイスでも収集用のアプリを実装すればデータ収集ができるようですので、今後試してみたいと思います。

SensorTag のデータを Amazon Polly で読み上げる

 前回の記事では SensorTag で取得した値を AWS IoT に送信して、照度の値によって LED を点灯したり、SNSからメールを送信したりしてみましたが、今回はさらに Polly で照度の値を読み上げる音声ファイルを生成し、Raspberry Pi で再生する処理を追加してみたいと思います。

全体構成

 全体の構成は下記のようになります。

f:id:akanuma-hiroaki:20170806143016p:plain

 前回と違うのは図の右下の赤線で囲んだ部分で、Lambda、Polly、s3との連携が追加になっています。

 処理の流れとしては、 AWS IoT Rule で SNS によるメール送信を行なっていた部分の Action に Lambda の Function 呼び出しを追加し、その Function の中から Polly のAPIを呼び出して照度の値を読み上げる mp3 ファイルを生成して s3 に格納し、 presigned URL を生成して AWS IoT に Publish します。Raspberry Pi 側では新たに音声ファイルの presigned URL が Publish されるトピックにも Subscribe しておき、 presigned URL を受け取った場合はそこから mp3 をダウンロードして再生します。

Raspberry Pi での音声ファイルの再生

 まずは下記ページを参考に Raspberry Pi 上で音声ファイルの再生を試してみます。

qiita.com

 Raspberry Pi のイヤホンジャックにヘッドフォンを挿し、下記コマンドを実行してみると、すんなり再生されました。

pi@raspberrypi:~ $ aplay /usr/share/sounds/alsa/Front_Center.wav
Playing WAVE '/usr/share/sounds/alsa/Front_Center.wav' : Signed 16 bit Little Endian, Rate 48000 Hz, Mono

 下記のようなテスト用のコマンドも用意されているようです。

pi@raspberrypi:~ $ speaker-test -t sine -f 1000

speaker-test 1.0.28

Playback device is default
Stream parameters are 48000Hz, S16_LE, 1 channels
Sine wave rate is 1000.0000Hz
Rate set to 48000Hz (requested 48000Hz)
Buffer size range from 512 to 65536
Period size range from 512 to 65536
Using max buffer size 65536
Periods = 4
was set period_size = 16384
was set buffer_size = 65536
 0 - Front Left
Time per period = 1.409599
 0 - Front Left
Time per period = 2.563393

 また、amixer というコマンドで現在のデバイスの設定の確認や変更ができます。

pi@raspberrypi:~ $ amixer info
Card default 'ALSA'/'bcm2835 ALSA'
  Mixer name    : 'Broadcom Mixer'
  Components    : ''
  Controls      : 6
  Simple ctrls  : 1
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ amixer scontrols
Simple mixer control 'PCM',0
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ amixer scontents
Simple mixer control 'PCM',0
  Capabilities: pvolume pvolume-joined pswitch pswitch-joined
  Playback channels: Mono
  Limits: Playback -10239 - 400
  Mono: Playback -2000 [77%] [-20.00dB] [on]
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ amixer controls
numid=3,iface=MIXER,name='PCM Playback Route'
numid=2,iface=MIXER,name='PCM Playback Switch'
numid=1,iface=MIXER,name='PCM Playback Volume'
numid=5,iface=PCM,name='IEC958 Playback Con Mask'
numid=4,iface=PCM,name='IEC958 Playback Default'

 下記のようにハードウェアの情報を確認することもできます。

pi@raspberrypi:~ $ cat /proc/asound/modules 
 0 snd_bcm2835
pi@raspberrypi:~ $ 
pi@raspberrypi:~ $ cat /proc/asound/cards
 0 [ALSA           ]: bcm2835 - bcm2835 ALSA
                      bcm2835 ALSA
pi@raspberrypi:~ $ 

 aplay は mp3 ファイルは再生できないので、 mpg321 を使ってみます。下記コマンドでインストールします。

pi@raspberrypi:~ $ sudo apt-get install mpg321

 下記のように mp3 ファイルを指定することで再生することができます。

pi@raspberrypi:~/sounds $ mpg321 mondo_01.mp3

Polly と連携するための Lambda Function

 まずは Lambda Function の全文を掲載しておきます。

import json
import boto3
from boto3 import Session
from boto3 import resource
from contextlib import closing

REGION         = 'ap-northeast-1'
POLLY_REGION   = 'us-east-1'
BUCKET_NAME    = 'hiroaki.akanuma.iot'
FILE_NAME_BASE = 'voices/%s_lux.mp3'
SPEECH_BASE    = '現在の照度は、%sルクスです。'
AWS_IOT_TOPIC  = '/iot/sensor_tag/voices'

session = Session(region_name = POLLY_REGION)
polly   = session.client('polly')
s3      = resource('s3')
bucket  = s3.Bucket(BUCKET_NAME)
iot     = boto3.client('iot-data')

def synthesize_speech(lux):
    speech_text = SPEECH_BASE % lux

    response = polly.synthesize_speech(
        Text         = speech_text,
        OutputFormat = 'mp3',
        VoiceId      = 'Mizuki'
    )
    
    return response['AudioStream']

def put_to_s3(audio_stream, file_name):
    with closing(audio_stream) as stream:
        bucket.put_object(
            Key         = file_name,
            Body        = stream.read(),
            ContentType = 'audio/mpeg'
        )

def generate_presigned_url(file_name):
    return boto3.client('s3').generate_presigned_url(
        ClientMethod = 'get_object',
        Params       = {'Bucket' : BUCKET_NAME, 'Key' : file_name},
        ExpiresIn    = 3600,
        HttpMethod   = 'GET'
    )

def publish_to_iot(speech_url):
    iot.publish(
        topic = AWS_IOT_TOPIC,
        qos   = 0,
        payload = json.dumps({'speech_url': speech_url})
    )

def lambda_handler(event, context):
    lux = event['lux']
    
    audio_stream = synthesize_speech(lux)

    file_name = FILE_NAME_BASE % lux

    put_to_s3(audio_stream, file_name)

    presigned_url = generate_presigned_url(file_name)

    publish_to_iot(presigned_url)

    return presigned_url

 event に照度の値が入ってくるのでまずはそれを取り出し、読み上げ用のテキストを作成したらそれを Polly の synthesize_speech メソッドに渡して、結果を AudioStream として取得します。出力フォーマットには mp3 を指定し、日本語での読み上げなので VoiceId には Mizuki を指定しています。レスポンスの中にはメタデータ等も含んでいますが、今回は使用しなかったので、 AudioStream のみを取り出しています。ちなみに Polly はまだ東京リージョンでは使用できないので、 Polly のクライアント取得時のリージョンにバージニアを指定しています。

 次に AudioStream から音声データを取得し、それを s3 に格納します。ファイル名は読み上げている照度の値を使って、それぞれの値ごとの音声ファイルを作成します。

 そして Raspberry Pi から認証なしで s3 上の音声ファイルにアクセスできるように、 presigned URL を発行して、その URL を音声ファイルの URL のやりとり用の AWS IoT トピックに Publish しています。AWS IoT Things の Shadow 更新用とは別のトピックになります。

 この Lambda Function を Rule の Action として追加します。

f:id:akanuma-hiroaki:20170806180209p:plain:w450

Raspberry Pi 側での読み上げ

 Raspberry Pi 側では音声ファイルのURLのやりとり用のトピックに Subscribe して、音声ファイルを読み上げる処理を追加します。

SPEECH_TOPIC = '/iot/sensor_tag/voices'
SOUNDS_DIR = '/home/pi/sounds/voices'

def run_speech_thread(log)
  log.info("Running speech thread.")
  Thread.new do
    begin
      MQTT::Client.connect(host: BEAM_URL) do |client|
        client.subscribe(SPEECH_TOPIC)
        log.info("Subscribed to the topic: #{SPEECH_TOPIC}")

        client.get do |topic, json|
          speech_url = JSON.parse(json)['speech_url']
          speech_uri = URI.parse(speech_url)
          speech_file = speech_uri.path.split('/').last
          speech_file_path = "#{SOUNDS_DIR}/#{speech_file}"

          unless File.exist?(speech_file_path)
            log.info("Opening URL: #{speech_url}")
            open(speech_url) do |file|
              open(speech_file_path, 'w+b') do |out|
                out.write(file.read)
              end
            end
          end

          log.info("Speaking: #{speech_file_path}")
          Open3.capture3("mpg321 #{speech_file_path}")
        end
      end
    rescue => e
      log.error(e.backtrace.join("\n"))
    end
  end
end

 MQTT でトピックに Subscribe して client.get するとそこで待ち受けるようになるため、とりあえず今回は今までの Shadow 更新用の処理とは別スレッドで音声ファイル用トピックに Subscribe して処理を行うようにしました。トピックからメッセージを受信したら、音声ファイルのURLをパースして、同名のファイルがローカルにない場合は s3 からダウンロードします。そして mpg321 コマンドを実行して音声ファイルを再生しています。

動作確認

 Lambda Function の保存や Rule での Action の追加を行ったら、Subscribeします。

$ sudo bundle exec ruby subscribe.rb

 そして SensorTag のデータの Publish を開始します。

$ sudo bundle exec ruby publish.rb

 すると、照度の値によって照明の ON/OFF が切り替わるタイミングで、音声ファイルも再生され、照度が読み上げられることになります。

まとめ

 Polly を使うことで手軽に音声ファイルを作成することができ、生成されたファイルをローカルに保存して繰り返し使うこともできますので、上手く使えばリーズナブルに音声を使ったサービスを作ることができます。今回は AWS SDK の認証情報をデバイス上におきたくなかったので、 SORACOM Beam 経由の AWS IoT の Rule から使うやり方をとりましたが、 AWS SDK から直接 Polly を使えばもっと直接的に手軽に音声ファイルを利用できると思います。

 今回のコードは下記リポジトリにも公開しています。

github.com

 ちなみに今回は下記ページを参考にさせていただきました。

qiita.com

SensorTag のデータを AWS IoT から CloudWatch と LED で可視化する

 以前の記事(TEXAS INSTRUMENTS の SimpleLink SensorTag CC2650 から BLE でデータ取得)で SensorTag から BLE でデータを取得できるようになったので、今回はそのデータを AWS IoT に送信し、Rule によって CloudWatch に送信して可視化してみたいと思います。また、ついでに照度の値によって、LEDを点灯させて、 Amazon SNS からメールを送信するようにしてみます。

全体構成

 全体の構成は下記の図のようにしています。

f:id:akanuma-hiroaki:20170730141445p:plain

 まず図の左上の SensorTag で温度や湿度、気圧、照度などのデータを取得し、Raspberry Pi Zero Wで受け取ったら、Wi-Fi経由で AWS IoT に Publish して Shadow を更新します。

 Raspberry Pi 3 からはあらかじめ SORACOM Beam 経由で AWS IoT に Subscribe しておき、SensorTag の Shadow に更新があった場合に差分を受け取ります。その際に照明のON/OFFのパラメータによってLEDのON/OFFを切り替えます。

 また、AWS IoT 側では Rule によって、Lambda 経由で CloudWatch にセンサーデータを投入し、照明のON/OFFの値が変わった場合には Amazon SNS からメールを送信します。

SensorTag からのデータ取得

 まずは SensorTag からのデータ取得についてです。以前の記事では SensorTag からのデータをシグナルを待ち受ける形で取得していましたが、今回はジャイロや加速度センサーは使わず、頻繁にデータを取得する必要はないので、一分間隔で Raspberry Pi 側からデータを読み取りに行きます。

 SensorTag からのデータ取得のコードについて以前の記事から変更した部分だけ下記に記載しておきます。

  def handle_ir_temperature_values(values)
    amb_lower_byte = values[2]
    amb_upper_byte = values[3]
    ambient = convert_ir_temperature_value(amb_upper_byte, amb_lower_byte)

    obj_lower_byte = values[0]
    obj_upper_byte = values[1]
    object = convert_ir_temperature_value(obj_upper_byte, obj_lower_byte)

    return ambient, object
  end

  def read_ir_temperature
    service = @device.service_by_uuid(SensorTag::UUID::IR_TEMPERATURE_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::IR_TEMPERATURE_DATA)
    characteristic.start_notify do |v|
      ambient, object = handle_ir_temperature_values(v)
      yield(ambient, object)
    end
  end

  def read_ir_temperature_once
    service = @device.service_by_uuid(SensorTag::UUID::IR_TEMPERATURE_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::IR_TEMPERATURE_DATA)
    v = characteristic.read
    ambient, object = handle_ir_temperature_values(v)
    return ambient, object
  end

  def handle_humidity_values(values)
    temp_lower_byte = values[0]
    temp_upper_byte = values[1]
    temp = convert_temp_value(temp_upper_byte, temp_lower_byte)

    hum_lower_byte = values[2]
    hum_upper_byte = values[3]
    hum = convert_humidity_value(hum_upper_byte, hum_lower_byte)

    return temp, hum
  end

  def read_humidity
    service = @device.service_by_uuid(SensorTag::UUID::HUMIDITY_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::HUMIDITY_DATA)
    characteristic.start_notify do |v|
      temperature, humidity = handle_humidity_values(v)
      yield(temp, hum)
    end
  end

  def read_humidity_once
    service = @device.service_by_uuid(SensorTag::UUID::HUMIDITY_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::HUMIDITY_DATA)
    v = characteristic.read
    temperature, humidity = handle_humidity_values(v)
    return temperature, humidity
  end

  def handle_barometer_values(values)
    temp_lower  = values[0]
    temp_middle = values[1]
    temp_upper  = values[2]
    temp = convert_barometer_value(temp_upper, temp_middle, temp_lower)

    press_lower  = values[3]
    press_middle = values[4]
    press_upper  = values[5]
    press = convert_barometer_value(press_upper, press_middle, press_lower)

    return temp, press
  end

  def read_barometer
    service = @device.service_by_uuid(SensorTag::UUID::BAROMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::BAROMETER_DATA)
    characteristic.start_notify do |v|
      temp, press = handle_barometer_values(v)
      yield(temp, press)
    end
  end

  def read_barometer_once
    service = @device.service_by_uuid(SensorTag::UUID::BAROMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::BAROMETER_DATA)
    v = characteristic.read
    temp, press = handle_barometer_values(v)
    return temp, press
  end

  def handle_luxometer_values(values)
    lux_lower  = values[0]
    lux_upper  = values[1]
    convert_luxometer_value(lux_upper, lux_lower)
  end

  def read_luxometer
    service = @device.service_by_uuid(SensorTag::UUID::LUXOMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::LUXOMETER_DATA)
    characteristic.start_notify do |v|
      lux = handle_luxometer_values(v)
      yield(lux)
    end
  end

  def read_luxometer_once
    service = @device.service_by_uuid(SensorTag::UUID::LUXOMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::LUXOMETER_DATA)
    v = characteristic.read
    handle_luxometer_values(v)
  end

 一分ごとに処理を行うループ処理は呼び出し元でやるので、ここでは一度だけ Characeristic の値を読み出す処理(read_xxxxx_once)を用意しています。読み出した値の処理(handle_xxxxx_values)は以前のシグナルを待ち受ける処理と共通化しています。

AWS IoT にセンサーデータを Publish する

 SensorTag から読み出した値を Raspberry Pi Zero W から AWS IoT へ Publish する処理は下記のようにしています。

 Publisher 側では SORACOM Beam 経由ではなく AWS IoT に直接アクセスしているので、 MQTT での接続時にホスト名だけでなく認証情報も一緒に指定しています。

 また、照度(lux)の値が一定以上だった場合は部屋の照明が点灯したという扱いにして、照明のON/OFFのパラメータ(light_power)を追加しています。

require 'bundler/setup'
require 'mqtt'
require 'json'
require './sensortag.rb'

AWS_IOT_URL = 'XXXXXXXXXXXXXX.iot.ap-northeast-1.amazonaws.com'
AWS_IOT_PORT = 8883
TOPIC = '$aws/things/sensor_tag/shadow/update'
PUBLISH_INTERVAL = 60
LUX_THRESHOLD = 100

log = Logger.new('logs/publish.log')

def statement(ambient:, object:, humidity:, pressure:, lux:)
  {
    state: {
      desired: {
        ambient:     ambient,
        object:      object,
        humidity:    humidity,
        pressure:    pressure,
        lux:         lux,
        light_power: lux >= LUX_THRESHOLD ? 'on' : 'off'
      }
    }
  }
end

sensor_tag = SensorTag.new

begin
  sensor_tag.connect
  sensor_tag.enable_ir_temperature
  sensor_tag.enable_humidity
  sensor_tag.enable_barometer
  sensor_tag.enable_luxometer

  MQTT::Client.connect(host: AWS_IOT_URL, port: AWS_IOT_PORT, ssl: true, cert_file: 'raspberry_pi.cert.pem', key_file: 'raspberry_pi.private.key', ca_file: 'root-CA.crt') do |client|
    loop do
      ambient, object = sensor_tag.read_ir_temperature_once
      _, humidity     = sensor_tag.read_humidity_once
      _, pressure     = sensor_tag.read_barometer_once
      lux             = sensor_tag.read_luxometer_once

      desired_state = statement(ambient: ambient, object: object, humidity: humidity, pressure: pressure, lux: lux).to_json
      client.publish(TOPIC, desired_state)
      log.info("Desired state: #{desired_state}")

      sleep PUBLISH_INTERVAL
    end
  end
rescue Interrupt => e
  puts e
ensure
  sensor_tag.disconnect
end

AWS IoT に Publish されたセンサーデータの差分を受け取る

 AWS IoT に Publish されたセンサーデータの差分を Raspberry Pi 3 から読み取る処理は下記のようにしています。

require 'bundler/setup'
require 'mqtt'
require 'json'
require './led.rb'

BEAM_URL = 'beam.soracom.io'
TOPIC = '$aws/things/sensor_tag/shadow/update'
DELTA_TOPIC = "#{TOPIC}/delta"

LED_GPIO = 22

log = Logger.new('logs/subscribe.log')

def statement(ambient:, object:, humidity:, pressure:, lux:, light_power:)
  reported = {}
  reported[:ambient]     = ambient     unless ambient.nil?
  reported[:object]      = object      unless object.nil?
  reported[:humidity]    = humidity    unless humidity.nil?
  reported[:pressure]    = pressure    unless pressure.nil?
  reported[:lux]         = lux         unless lux.nil?
  reported[:light_power] = light_power unless light_power.nil?

  {
    state: {
      reported: reported
    }
  }
end

def toggle_led(led:, light_power:)
  return if light_power.nil?

  light_power == 'on' ? led.on : led.off
end

led = LED.new(pin: LED_GPIO)

MQTT::Client.connect(host: BEAM_URL) do |client|
  initial_state = statement(ambient: 0, object: 0, humidity: 0, pressure: 0, lux: 0, light_power: 'off').to_json
  client.publish(TOPIC, initial_state)
  log.info("Published initial statement: #{initial_state}")

  client.subscribe(DELTA_TOPIC)
  log.info("Subscribed to the topic: #{DELTA_TOPIC}")

  client.get do |topic, json|
    state = JSON.parse(json)['state']
    ambient     = state['ambient']
    object      = state['object']
    humidity    = state['humidity']
    pressure    = state['pressure']
    lux         = state['lux']
    light_power = state['light_power']

    toggle_led(led: led, light_power: light_power)

    reported_state = statement(
                       ambient:     ambient,
                       object:      object,
                       humidity:    humidity,
                       pressure:    pressure,
                       lux:         lux,
                       light_power: light_power
                     ).to_json

    client.publish(TOPIC, reported_state)
    log.info("Reported state: #{reported_state}")
  end
end

 こちらは SORACOM Beam 経由なので MQTT での接続時にはホスト名だけ指定しています。

 また、 light_power の値によって LED のON/OFFを切り替えています。LED の制御は別クラスで行なっています。

require 'bundler/setup'
require 'pi_piper'

class LED
  def initialize(pin:)
    @pin = PiPiper::Pin.new(pin: pin, direction: :out)
  end

  def on
    @pin.on
  end

  def off
    @pin.off
  end

  def flash
    loop do
      @pin.on
      sleep 1
      @pin.off
      sleep 1
    end
  end
end

if $0 == __FILE__
  led = LED.new(pin: ARGV[0].to_i)
  puts led.inspect
  led.flash
end

 LEDの制御については以前書いたこの辺りの記事をご参照いただければと思います。

Raspberry Pi + RubyでLチカ - Tech Blog by Akanuma Hiroaki

AWS CloudWatch へのセンサーデータの投入

 AWS IoT では Rule によって色々な処理ができますが、今回はセンサーデータの値を Lambda に渡して、 Lambda から CloudWatch にデータを投入してみました。

 Lambda の Function は下記のような内容で作成しておきます。受け取った値を MetricData として CloudWatch に投げるだけのシンプルな構成です。

import json
import math
import datetime
import boto3

CloudWatch = boto3.client('cloudwatch')

def put_cloudwatch(metricName, value, unit):
    try:
        now = datetime.datetime.now()
        CloudWatch.put_metric_data(
            Namespace  = "SensorTag",
            MetricData = [{
                "MetricName" : metricName,
                "Timestamp"  : now,
                "Value"      : value,
                "Unit"       : unit
            }]
        )
    except Exception as e:
        print e.message
        raise
    
def lambda_handler(event, context):
    ambient   = event['ambient']
    object    = event['object']
    humidity  = event['humidity']
    pressure  = event['pressure']
    lux       = event['lux']

    try:
        put_cloudwatch("AmbientTemperature", ambient, "None")
        put_cloudwatch("ObjectTemperature", object, "None")
        put_cloudwatch("Humidity", humidity, "Percent")
        put_cloudwatch("Pressure", pressure, "None")
        put_cloudwatch("Lux", lux, "None")
    except Exception as e:
        raise

    return 

 AWS IoT Rule のルールクエリステートメントでは下記のような内容を設定しておきます。

SELECT
  state.desired.ambient as ambient, 
  state.desired.object as object, 
  state.desired.humidity as humidity, 
  state.desired.pressure as pressure, 
  state.desired.lux as lux 
FROM 
  '$aws/things/sensor_tag/shadow/update'

 Publish された全ての値を対象にしているので、 FROM は update トピックを指定し、 WHERE 条件は指定していません。Publish される state には desired と reported が含まれますが、今回は desired として登録された値を対象としていますので、 SELECT 句でも state.desired 配下のパラメータを参照しています。

 また、アクションには メッセージデータを渡す Lambda 関数を呼び出す を指定し、先ほどの Lambda Function を指定しておきます。

Amazon SNS からメール送信

 AWS IoT Rule でもう一つ、 Amazon SNS からメール送信するための設定をしておきます。ルールクエリステートメントは下記のようにしておきます。

SELECT 
  state.light_power 
FROM 
  '$aws/things/sensor_tag/shadow/update/delta' 
WHERE 
  state.light_power = 'on' or state.light_power = 'off'

 Shadow に差分が発生した場合だけ処理が行われればいいので、対象のトピックは delta トピックにします。 delta トピックには照明のON/OFF以外の差分も Publish されるため、 WHERE 条件で照明のON/OFFのパラメータが含まれている場合だけ処理を行うように指定しておきます。 update トピックの時と違って Publish される state は差分だけなので、 state.light_power という指定にしています。

 アクションの設定は以前下記記事で行なった時と同様に行います。

Raspberry Pi を SORACOM Beam から AWS IoT に接続する - Tech Blog by Akanuma Hiroaki

動作確認

 それでは動作を確認してみます。まずは Raspberry Pi 3 から SORACOM Air で3G回線に接続した上で、 Subscribe します。

$ sudo bundle exec ruby subscribe.rb

 すると下記のように初期ステートメントを Publish した上で Subscribe した旨がログに出力されます。

I, [2017-07-28T11:22:56.859902 #1285]  INFO -- : Published initial statement: {"state":{"reported":{"ambient":0,"object":0,"humidity":0,"pressure":0,"lux":0,"light_power":"off"}}}
I, [2017-07-28T11:22:56.861190 #1285]  INFO -- : Subscribed to the topic: $aws/things/sensor_tag/shadow/update/delta

 次に Raspberry Pi Zero W から Publish します。 SensorTag の Advertising を開始した上で、下記のように実行します。

$ sudo bundle exec ruby publish_aws_iot.rb

 SensorTag に接続され、センサーデータが Publish されると下記のようにログに出力され、一分ごとにログが出力されていきます。

I, [2017-07-28T11:23:53.473676 #832]  INFO -- : Desired state: {"state":{"desired":{"ambient":26.21875,"object":19.71875,"humidity":73.358154296875,"pressure":1009.16,"lux":0.72,"light_power":"off"}}}
I, [2017-07-28T11:25:05.242317 #945]  INFO -- : Desired state: {"state":{"desired":{"ambient":26.1875,"object":19.65625,"humidity":73.74267578125,"pressure":1009.17,"lux":0.72,"light_power":"off"}}}

 Raspberry Pi 3 側では Publish されたセンサーデータの差分が取得され、下記のようにログ出力されます。

I, [2017-07-28T11:23:53.719914 #1285]  INFO -- : Reported state: {"state":{"reported":{"ambient":26.21875,"object":19.71875,"humidity":73.358154296875,"pressure":1009.16,"lux":0.72}}}
I, [2017-07-28T11:25:05.480031 #1285]  INFO -- : Reported state: {"state":{"reported":{"ambient":26.1875,"object":19.65625,"humidity":73.74267578125,"pressure":1009.17}}}

 ここで SensorTag を裏返してみると、SensorTag の裏側にある照度センサーに光が当たり、値が閾値を超えるので、 Publisher 側で下記のように light_power の値が on になります。

I, [2017-07-28T11:26:15.037787 #945]  INFO -- : Desired state: {"state":{"desired":{"ambient":26.21875,"object":21.40625,"humidity":76.171875,"pressure":1009.14,"lux":586.24,"light_power":"on"}}}

 するとその差分が Subscriber 側でも受信され、ログに出力され、LED が点灯することになります。

I, [2017-07-28T11:26:15.350194 #1285]  INFO -- : Reported state: {"state":{"reported":{"ambient":26.21875,"object":21.40625,"humidity":76.171875,"pressure":1009.14,"lux":586.24,"light_power":"on"}}}

 そして Rule で設定していた通り、Amazon SNS からメールが送られてきます。

f:id:akanuma-hiroaki:20170730113324p:plain:w450

 このままデータの取得を続けていくと、一分ごとに CloudWatch にもデータが投入されていき、下記のようにグラフが確認できるようになります。

f:id:akanuma-hiroaki:20170730113035p:plain

 

まとめ

 SensorTagのデータをBLEで取得する部分はそれなりに大変でしたが、そこさえクリアしてしまえば、 AWS IoT でのデータの連携や Rule を用いた他サービスとの連携は簡単にできますので、可視化や簡単な通知ぐらいであれば自前でサーバを構築する必要もなく、手軽に試すことができます。それと LED 等の物理デバイスを組み合わせて動作させることができると、単純なことではあっても元々ソフトウェアエンジニアの自分としてはとても面白く感じますね。

 今回は CloudWatch を使って可視化しましたが、 SORACOM でも Harvest 等のサービスがありますし、色々試して効率的な組み合わせを探してみられると良いかと思います。コードを書く上でも Beam を使うと認証情報を気にすることなくスッキリ書けて良い感じですね。

 今回使用したコードの全ては下記リポジトリにも公開しましたので、興味のある方はご参照ください。

github.com

 また、今回は下記の記事を参考にさせていただきました。ありがとうございました。

qiita.com

Raspberry Pi Zero W を USB OTG でセットアップ

 先週、国内でも発売になった Raspberry Pi Zero W を運良く購入することができたので、セットアップしてみました。

www.switch-science.com

 Raspberry Pi Zero W は USB On-The-Go でのセットアップが可能なようなので、micro SD カードだけ買い足して、手持ちのUSBケーブルで Mac に接続して動かしてみました。手順については下記サイトを参考にさせていただきました。

qiita.com

本体外観

 パッケージとしては特に箱や説明書はなく、封筒でポストインでの配送でした。想像していたよりもさらに小さかったです。

f:id:akanuma-hiroaki:20170723031600j:plain:w450

 Raspberry Pi 3 Model B と比べてみるとこんな感じです。

f:id:akanuma-hiroaki:20170723031610j:plain:w450

OSイメージの用意

 まずはSDカードにOSのイメージを用意していきます。基本的な手順は以前 Raspberry Pi 3 Model B でやった時と同様にしました。

blog.akanumahiroaki.com

 イメージは最新のものを下記URLからダウンロードして使用しています。

http://ftp.jaist.ac.jp/pub/raspberrypi/raspbian_lite/images/raspbian_lite-2017-07-05/

 OSのイメージを書き込んだ後はSDカードが boot というドライブで認識され、下記のようにファイルが書き込まれています。

$ ls -l /Volumes/boot
total 42298
-rwxrwxrwx  1 akanuma  staff    18693 Aug 21  2015 COPYING.linux
-rwxrwxrwx  1 akanuma  staff     1494 Nov 18  2015 LICENCE.broadcom
-rwxrwxrwx  1 akanuma  staff    18974 Jul  5 11:45 LICENSE.oracle
-rwxrwxrwx  1 akanuma  staff    15660 May 15 19:09 bcm2708-rpi-0-w.dtb
-rwxrwxrwx  1 akanuma  staff    15456 May 15 19:09 bcm2708-rpi-b-plus.dtb
-rwxrwxrwx  1 akanuma  staff    15197 May 15 19:09 bcm2708-rpi-b.dtb
-rwxrwxrwx  1 akanuma  staff    14916 May 15 19:09 bcm2708-rpi-cm.dtb
-rwxrwxrwx  1 akanuma  staff    16523 May 15 19:09 bcm2709-rpi-2-b.dtb
-rwxrwxrwx  1 akanuma  staff    17624 May 15 19:09 bcm2710-rpi-3-b.dtb
-rwxrwxrwx  1 akanuma  staff    16380 May 15 19:09 bcm2710-rpi-cm3.dtb
-rwxrwxrwx  1 akanuma  staff    50248 Jul  3 10:07 bootcode.bin
-rwxrwxrwx  1 akanuma  staff      190 Jul  5 11:45 cmdline.txt
-rwxrwxrwx  1 akanuma  staff     1590 Jul  5 10:53 config.txt
-rwxrwxrwx  1 akanuma  staff     6674 Jul  3 14:07 fixup.dat
-rwxrwxrwx  1 akanuma  staff     2583 Jul  3 14:07 fixup_cd.dat
-rwxrwxrwx  1 akanuma  staff     9813 Jul  3 14:07 fixup_db.dat
-rwxrwxrwx  1 akanuma  staff     9813 Jul  3 14:07 fixup_x.dat
-rwxrwxrwx  1 akanuma  staff      145 Jul  5 11:45 issue.txt
-rwxrwxrwx  1 akanuma  staff  4379032 Jul  3 10:07 kernel.img
-rwxrwxrwx  1 akanuma  staff  4579248 Jul  3 10:07 kernel7.img
drwxrwxrwx  1 akanuma  staff    10240 Jul  5 11:44 overlays
-rwxrwxrwx  1 akanuma  staff  2855556 Jul  3 14:07 start.elf
-rwxrwxrwx  1 akanuma  staff   659492 Jul  3 14:07 start_cd.elf
-rwxrwxrwx  1 akanuma  staff  4993604 Jul  3 14:07 start_db.elf
-rwxrwxrwx  1 akanuma  staff  3939492 Jul  3 14:07 start_x.elf

 SSHを有効にするために、下記のように空ファイルを配置します。

$ touch /Volumes/boot/ssh
$ ls -l /Volumes/boot/ssh
-rwxrwxrwx  1 akanuma  staff  0 Jul 23 02:02 /Volumes/boot/ssh

 また、MacからUSB接続できるように設定します。 /boot/cmdline.txtrootwaitquiet の間に modules-load=dwc2,g_ether を追加します。追加後のファイルは下記のようになります。

$ cat /Volumes/boot/cmdline.txt 
dwc_otg.lpm_enable=0 console=serial0,115200 console=tty1 root=PARTUUID=a8790229-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait modules-load=dwc2,g_ether quiet init=/usr/lib/raspi-config/init_resize.sh

 それと /boot/config.txtdtoverlay=dwc2 という設定を追加します。

$ echo "dtoverlay=dwc2" >> /Volumes/boot/config.txt
$ tail /Volumes/boot/config.txt 
#dtparam=spi=on

# Uncomment this to enable the lirc-rpi module
#dtoverlay=lirc-rpi

# Additional overlays and parameters are documented /boot/overlays/README

# Enable audio (loads snd_bcm2835)
dtparam=audio=on
dtoverlay=dwc2

Raspberry Pi Zero W 起動

 ここまででOSのイメージの準備は終わりなので、micro SD カードを Raspberry Pi Zero W に挿し、USBケーブルで Mac と接続します。USB On-The-Go で接続するには内側の micro USB ポートを使用します。

f:id:akanuma-hiroaki:20170723100204j:plain:w450

 接続してしばらく待つと Raspberry Pi が起動します。起動したら下記のように ssh で接続します。

$ ssh pi@raspberrypi.local

 以前の記事と同様に Wi-Fi 接続の設定をします。

$ wpa_passphrase MY_AP_SSID MY_AP_PASSWORD | sudo tee -a /etc/wpa_supplicant/wpa_supplicant.conf

 そして一度シャットダウンし、次は micro USB を電源用の外側のポートに繋いで起動します。

f:id:akanuma-hiroaki:20170723100215j:plain:w450

 すると起動時に Wi-Fi に接続されますので、ssh で接続可能です。また、私の環境だとすでに Raspberry Pi が一台あり、ホスト名が重複してしまうので、下記コマンドで設定メニューを起動してホスト名を変更します。

$ sudo raspi-config

 すると下記のように変更後のホスト名で ssh できるようになります。

$ ssh pi@raspberrypi-zero.local

 あとはパッケージのアップデートやログインパスワードを変更して、ひとまずセットアップ終了です。USB On-The-Go でセットアップできると外付けのモニタやキーボードも不要で、とても手軽にセットアップできます。

 Raspberry Pi Zero W はBLEやWi-Fi接続もできるようになり、電源さえ確保すればワイヤレスでいろんなことができそうなので、活用方法を考えてみたいと思います。

TEXAS INSTRUMENTS の SimpleLink SensorTag CC2650 から BLE でデータ取得

 TEXAS INSTRUMENTS の SimpleLink CC2650 というセンサータグを買ってみたので、Raspberry Pi から BLE でセンサーデータを取得してみたいと思います。

www.tij.co.jp

 SimpleLink SensorTag CC2650 は気温、湿度、気圧、加速度、ジャイロ、磁気、照度などのセンサーを搭載しています。

 外箱は下記のような感じです。

f:id:akanuma-hiroaki:20170717174130j:plain:w450

 デバイス本体は下記のようになります。写真だと大きく見えてしまいますが、約5cm x 4cm ぐらいの大きさです。

f:id:akanuma-hiroaki:20170717174141j:plain:w450

スマートフォンアプリからの接続

 まずは TEXAS INSTRUMENTS のセンサータグ用スマートフォンアプリからの接続を試してみたいと思います。自分で開発しなくても手軽にセンサーが取得しているデータを確認することができます。

TI SensorTag

TI SensorTag

  • Texas Instruments
  • ユーティリティ
  • 無料

 アプリを起動して、SensorTagの側面にあるスイッチを押すと Advertising が始まり、リストにSensorTagが表示されます。そこからセンサーが取得しているデータや、GATT Service や Characteristic の UUID を確認できます。

f:id:akanuma-hiroaki:20170717174406p:plain:w300 f:id:akanuma-hiroaki:20170717174413p:plain:w300 f:id:akanuma-hiroaki:20170717174420p:plain:w300 f:id:akanuma-hiroaki:20170717174427p:plain:w300

Raspberry Pi からの接続

 それでは Raspberry Pi から SensorTag に接続して、データを取得してみたいと思います。基本的な処理内容は以前の記事(D-BusからBLEデバイスのNotificationを受け取る)で試したものと同様ですが、今回はデバイス固有の処理とBLEデバイス共通の処理を分割してみました。

 まずはBLEデバイス共通の処理のコード全体を載せておきます。BlueZによる処理をラップする形で、Device、Service、Characteristic のクラスを用意しています。エラーハンドリングはまだ考慮してません。Bluetoothのインタフェースもとりあえず hci0 を直接指定しています。

require 'bundler/setup'
require 'dbus'

class BLE
  attr_reader :bus

  SERVICE_NAME = 'org.bluez'
  SERVICE_PATH = '/org/bluez'
  ADAPTER      = 'hci0'

  DEVICE_IF          = 'org.bluez.Device1'
  SERVICE_IF         = 'org.bluez.GattService1'
  CHARACTERISTIC_IF  = 'org.bluez.GattCharacteristic1'
  DBUS_PROPERTIES_IF = 'org.freedesktop.DBus.Properties'

  SERVICE_RESOLVED_PROPERTY = 'ServicesResolved'
  UUID_PROPERTY             = 'UUID'

  PROPERTIES_CHANGED_SIGNAL = 'PropertiesChanged'

  SERVICE_RESOLVE_CHECK_INTERVAL = 0.1 # デバイスに接続後にサービスが解決されたかをチェックするインターバル
  DISCOVERY_WAITING_SECOND       = 10 # デバイス検出の待機時間

  module UUID
    GENERIC_ATTRIBUTE_SERVICE  = '00001801-0000-1000-8000-00805f9b34fb'
    DEVICE_INFORMATION_SERVICE = '0000180a-0000-1000-8000-00805f9b34fb'
    BATTERY_SERVICE            = '0000180f-0000-1000-8000-00805f9b34fb'

    BATTERY_DATA = '00002a19-0000-1000-8000-00805f9b34fb'
  end

  class Device
    attr_reader :bluez, :name, :address

    def initialize(bluez, bluez_device, name, address)
      @bluez        = bluez
      @bluez_device = bluez_device
      @name         = name
      @address      = address
    end

    # Device への接続処理。接続後に GATT Service が解決状態になるまで待機する
    def connect
      @bluez_device.introspect
      @bluez_device.Connect
      @bluez_device.introspect

      while !properties[SERVICE_RESOLVED_PROPERTY] do
        sleep(SERVICE_RESOLVE_CHECK_INTERVAL)
      end
    end

    def disconnect
      @bluez_device.Disconnect
    end

    def properties
      @bluez_device.introspect
      @bluez_device.GetAll(DEVICE_IF).first
    end

    # Device が持つ GATT Service のリストを返す
    def services
      services = []
      @bluez_device.subnodes.each do |node|
        service = @bluez.object("#{@bluez_device.path}/#{node}")
        service.introspect
        properties = service.GetAll(SERVICE_IF).first
        services << Service.new(@bluez, service, properties[UUID_PROPERTY])
      end

      services
    end

    def service_by_uuid(uuid)
      services.each do |service|
        return service if service.uuid == uuid
      end

      raise 'Service not found.'
    end

    def read_battery_level
      service = service_by_uuid(BLE::UUID::BATTERY_SERVICE)
      characteristic = service.characteristic_by_uuid(BLE::UUID::BATTERY_DATA)
      yield(characteristic.read.first)
      characteristic.start_notify do |v|
        yield(v.first)
      end
    end
  end

  class Service
    attr_reader :uuid

    def initialize(bluez, bluez_service, uuid)
      @bluez         = bluez
      @bluez_service = bluez_service
      @uuid          = uuid
    end

    def properties
      @bluez_service.introspect
      @bluez_service.GetAll(SERVICE_IF).first
    end

    # Service が持つ Characteristic のリストを返す
    def characteristics
      characteristics = []
      @bluez_service.subnodes.each do |node|
        characteristic = @bluez.object("#{@bluez_service.path}/#{node}")
        characteristic.introspect
        properties = characteristic.GetAll(CHARACTERISTIC_IF).first
        characteristics << Characteristic.new(characteristic, properties[UUID_PROPERTY])
      end

      characteristics
    end

    def characteristic_by_uuid(uuid)
      characteristics.each do |characteristic|
        return characteristic if characteristic.uuid == uuid
      end

      raise 'Characteristic not found.'
    end
  end

  class Characteristic
    attr_reader :uuid

    def initialize(bluez_characteristic, uuid)
      @bluez_characteristic = bluez_characteristic
      @uuid = uuid
    end

    def properties
      @bluez_characteristic.introspect
      @bluez_characteristic.GetAll(CHARACTERISTIC_IF).first
    end

    # Characteristic のプロパティに変更があった時にシグナルを受け取る
    # ブロックを渡してシグナル検知時にブロックを実行する
    def start_notify
      @bluez_characteristic.StartNotify
      @bluez_characteristic.default_iface = DBUS_PROPERTIES_IF
      @bluez_characteristic.on_signal(PROPERTIES_CHANGED_SIGNAL) do |_, v|
        yield(v['Value'])
      end
    end

    def write(value)
      @bluez_characteristic.WriteValue(value, {})
    end

    def read
      @bluez_characteristic.ReadValue({}).first
    end

    def inspect
      @bluez_characteristic.inspect
    end
  end

  def initialize
    @bus = DBus::system_bus
    @bluez = @bus.service(SERVICE_NAME)

    @adapter = @bluez.object("#{SERVICE_PATH}/#{ADAPTER}")
    @adapter.introspect
  end

  # Bluetoothアダプタ配下のBLEデバイスを検出して Device クラスのインスタンスのリストを返す
  # デバイス名や RSSI が nil のデバイスは除外
  def devices
    @adapter.StartDiscovery
    sleep(DISCOVERY_WAITING_SECOND)

    devices = []
    @adapter.introspect
    @adapter.subnodes.each do |node|
      device = @bluez.object("#{SERVICE_PATH}/#{ADAPTER}/#{node}")
      device.introspect

      next unless device.respond_to?(:GetAll)

      properties = device.GetAll(DEVICE_IF).first
      name    = properties['Name']
      address = properties['Address']
      rssi    = properties['RSSI']

      next if name.nil? || rssi.nil?

      devices << Device.new(@bluez, device, name, address)
    end

    @adapter.StopDiscovery
    devices
  end

  def device_by_name(name)
    devices.each do |device|
      return device if device.name.downcase.include?(name.downcase)
    end

    raise 'No devices found.'
  end
end

 BLEクラスを new して #device_by_name メソッドにデバイス名を渡すと、デバイス名を含んだBLEデバイスを検出して Device クラスのインスタンスとして返します。

 次にSensorTag固有の処理のコードです。こちらもまだエラーハンドリングは考慮してません。SensorTag クラスには基本的に各センサーを有効化する処理と、センサーデータ検出時にデータをコールバックのブロックに受け渡す処理を実装しています。各センサーから検出されるデータは生データなので、実際に利用するにはコンバートする必要があります。コンバート処理の内容はいろいろ調べたのですが間違っているかもしれないので、参考までということで。

require 'bundler/setup'
require './ble.rb' # 前述のBLEデバイス共通処理を読み込む

class SensorTag
  DEVICE_NAME = 'CC2650'
  SCALE_LSB = 0.03125

  module UUID
    IR_TEMPERATURE_SERVICE     = 'f000aa00-0451-4000-b000-000000000000'
    HUMIDITY_SERVICE           = 'f000aa20-0451-4000-b000-000000000000'
    BAROMETER_SERVICE          = 'f000aa40-0451-4000-b000-000000000000'
    MOVEMENT_SERVICE           = 'f000aa80-0451-4000-b000-000000000000'
    LUXOMETER_SERVICE          = 'f000aa70-0451-4000-b000-000000000000'
    SIMPLE_KEYS_SERVICE        = '0000ffe0-0000-1000-8000-00805f9b34fb'
    IO_SERVICE                 = 'f000aa64-0451-4000-b000-000000000000'
    REGISTER_SERVICE           = 'f000ac00-0451-4000-b000-000000000000'
    CONNECTION_CONTROL_SERVICE = 'f000ccc0-0451-4000-b000-000000000000'
    OAT_SERVICE                = 'f000ffc0-0451-4000-b000-000000000000'

    IR_TEMPERATURE_CONFIG = 'f000aa02-0451-4000-b000-000000000000'
    IR_TEMPERATURE_DATA   = 'f000aa01-0451-4000-b000-000000000000'
    HUMIDITY_CONFIG       = 'f000aa22-0451-4000-b000-000000000000'
    HUMIDITY_DATA         = 'f000aa21-0451-4000-b000-000000000000'
    MOVEMENT_CONFIG       = 'f000aa82-0451-4000-b000-000000000000'
    MOVEMENT_DATA         = 'f000aa81-0451-4000-b000-000000000000'
    BAROMETER_CONFIG      = 'f000aa42-0451-4000-b000-000000000000'
    BAROMETER_DATA        = 'f000aa41-0451-4000-b000-000000000000'
    LUXOMETER_CONFIG      = 'f000aa72-0451-4000-b000-000000000000'
    LUXOMETER_DATA        = 'f000aa71-0451-4000-b000-000000000000'
    IO_CONFIG             = 'f000aa66-0451-4000-b000-000000000000'
    IO_DATA               = 'f000aa65-0451-4000-b000-000000000000'
  end

  # SensorTag に接続
  def connect
    @ble = BLE.new
    @device = @ble.device_by_name(DEVICE_NAME)
    @device.connect
  end

  # 各センサーの有効化のための処理
  def enable(service, config_uuid, value = [0x01])
    characteristic = service.characteristic_by_uuid(config_uuid)
    characteristic.write(value)
  end

  def enable_ir_temperature
    service = @device.service_by_uuid(SensorTag::UUID::IR_TEMPERATURE_SERVICE)
    enable(service, SensorTag::UUID::IR_TEMPERATURE_CONFIG)
  end

  def enable_humidity
    service = @device.service_by_uuid(SensorTag::UUID::HUMIDITY_SERVICE)
    enable(service, SensorTag::UUID::HUMIDITY_CONFIG)
  end

  def enable_movement
    service = @device.service_by_uuid(SensorTag::UUID::MOVEMENT_SERVICE)
    enable(service, SensorTag::UUID::MOVEMENT_CONFIG, [0b11111111, 0b00000000])
  end

  def enable_barometer
    service = @device.service_by_uuid(SensorTag::UUID::BAROMETER_SERVICE)
    enable(service, SensorTag::UUID::BAROMETER_CONFIG)
  end

  def enable_luxometer
    service = @device.service_by_uuid(SensorTag::UUID::LUXOMETER_SERVICE)
    enable(service, SensorTag::UUID::LUXOMETER_CONFIG)
  end

  # 各センサーの生データをコンバートする処理
  def convert_ir_temperature_value(upper_byte, lower_byte)
    (((upper_byte << 8) + lower_byte) >> 2) * SCALE_LSB
  end

  def convert_temp_value(upper_byte, lower_byte)
    temp_byte = (upper_byte << 8) + lower_byte
    (temp_byte / 65536.0) * 165 - 40
  end

  def convert_humidity_value(upper_byte, lower_byte)
    hum_byte = (upper_byte << 8) + lower_byte
    (hum_byte / 65536.0) * 100
  end

  def convert_movement_value(upper_byte, lower_byte)
      value = (upper_byte << 8) + lower_byte
      if upper_byte > 0x7f
        value = ~value + 1
      end
      (value >> 2).to_f
  end

  def convert_gyro_value(upper_byte, lower_byte)
    convert_movement_value(upper_byte, lower_byte) / 128.0
  end

  def convert_acc_value(upper_byte, lower_byte)
    convert_movement_value(upper_byte, lower_byte) / (32768 / 2)
  end

  def convert_mag_value(upper_byte, lower_byte)
    convert_movement_value(upper_byte, lower_byte) * 4912.0 / 32768.0
  end

  def convert_barometer_value(upper_byte, middle_byte, lower_byte)
    ((upper_byte << 16) + (middle_byte << 8) + lower_byte) / 100.0
  end

  def convert_luxometer_value(upper_byte, lower_byte)
    lux = (upper_byte << 8) + lower_byte

    m = lux & 0x0fff
    e = (lux & 0xf000) >> 12
    e = (e == 0) ? 1 : 2 << (e - 1)
    m * (0.01 * e)
  end

  # 各センサーのデータを読み取り、コンバート後のデータをコールバックに渡す
  def read_ir_temperature
    service = @device.service_by_uuid(SensorTag::UUID::IR_TEMPERATURE_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::IR_TEMPERATURE_DATA)
    characteristic.start_notify do |v|
      amb_lower_byte = v[2]
      amb_upper_byte = v[3]
      ambient = convert_ir_temperature_value(amb_upper_byte, amb_lower_byte)

      obj_lower_byte = v[0]
      obj_upper_byte = v[1]
      object = convert_ir_temperature_value(obj_upper_byte, obj_lower_byte)

      yield(ambient, object)
    end
  end

  def read_humidity
    service = @device.service_by_uuid(SensorTag::UUID::HUMIDITY_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::HUMIDITY_DATA)
    characteristic.start_notify do |v|
      temp_lower_byte = v[0]
      temp_upper_byte = v[1]
      temp = convert_temp_value(temp_upper_byte, temp_lower_byte)

      hum_lower_byte = v[2]
      hum_upper_byte = v[3]
      hum = convert_humidity_value(hum_upper_byte, hum_lower_byte)

      yield(temp, hum)
    end
  end

  def read_movement
    service = @device.service_by_uuid(SensorTag::UUID::MOVEMENT_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::MOVEMENT_DATA)
    characteristic.start_notify do |v|
      gyro_x_lower = v[0]
      gyro_x_upper = v[1]
      gyro_y_lower = v[2]
      gyro_y_upper = v[3]
      gyro_z_lower = v[4]
      gyro_z_upper = v[5]

      gyro_x = convert_gyro_value(gyro_x_upper, gyro_x_lower)
      gyro_y = convert_gyro_value(gyro_y_upper, gyro_y_lower)
      gyro_z = convert_gyro_value(gyro_z_upper, gyro_z_lower)

      acc_x_lower = v[6]
      acc_x_upper = v[7]
      acc_y_lower = v[8]
      acc_y_upper = v[9]
      acc_z_lower = v[10]
      acc_z_upper = v[11]

      acc_x = convert_acc_value(acc_x_upper, acc_x_lower)
      acc_y = convert_acc_value(acc_y_upper, acc_y_lower)
      acc_z = convert_acc_value(acc_z_upper, acc_z_lower)

      mag_x_lower = v[12]
      mag_x_upper = v[13]
      mag_y_lower = v[14]
      mag_y_upper = v[15]
      mag_z_lower = v[16]
      mag_z_upper = v[17]

      mag_x = convert_mag_value(mag_x_upper, mag_x_lower)
      mag_y = convert_mag_value(mag_y_upper, mag_y_lower)
      mag_z = convert_mag_value(mag_z_upper, mag_z_lower)

      yield(gyro_x, gyro_y, gyro_z, acc_x, acc_y, acc_z, mag_x, mag_y, mag_z)
    end
  end

  def read_barometer
    service = @device.service_by_uuid(SensorTag::UUID::BAROMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::BAROMETER_DATA)
    characteristic.start_notify do |v|
      temp_lower  = v[0]
      temp_middle = v[1]
      temp_upper  = v[2]
      temp = convert_barometer_value(temp_upper, temp_middle, temp_lower)

      press_lower  = v[3]
      press_middle = v[4]
      press_upper  = v[5]
      press = convert_barometer_value(press_upper, press_middle, press_lower)

      yield(temp, press)
    end
  end

  def read_luxometer
    service = @device.service_by_uuid(SensorTag::UUID::LUXOMETER_SERVICE)
    characteristic = service.characteristic_by_uuid(SensorTag::UUID::LUXOMETER_DATA)
    characteristic.start_notify do |v|
      lux_lower  = v[0]
      lux_upper  = v[1]
      lux = convert_luxometer_value(lux_upper, lux_lower)

      yield(lux)
    end
  end

  def read_battery_level
    @device.read_battery_level do |battery_level|
      yield(battery_level)
    end
  end

  # シグナルの待受を開始
  def run
    main = DBus::Main.new
    main << @ble.bus

    main.run
  end

  def disconnect
    @device.disconnect
  end
end

if $0 == __FILE__
  sensor_tag = SensorTag.new
  begin
    sensor_tag.connect

    ir_temperature_log = Logger.new('logs/ir_temperature.log')
    sensor_tag.enable_ir_temperature
    sensor_tag.read_ir_temperature do |ambient, object|
      ir_temperature_log.info("amb: #{ambient} obj: #{object}")
    end

    humidity_log = Logger.new('logs/humidity.log')
    sensor_tag.enable_humidity
    sensor_tag.read_humidity do |temp, hum|
      humidity_log.info("temp: #{temp} hum: #{hum}")
    end

    gyro_log = Logger.new('logs/gyro.log')
    acc_log = Logger.new('logs/acc.log')
    mag_log = Logger.new('logs/mag.log')
    sensor_tag.enable_movement
    sensor_tag.read_movement do |gyro_x, gyro_y, gyro_z, acc_x, acc_y, acc_z, mag_x, mag_y, mag_z|
      gyro_log.info("gyro: #{gyro_x} #{gyro_y} #{gyro_z}")
      acc_log.info("acc: #{acc_x} #{acc_y} #{acc_z}")
      mag_log.info("mag: #{mag_x} #{mag_y} #{mag_z}")
    end

    barometer_log = Logger.new('logs/barometer.log')
    sensor_tag.enable_barometer
    sensor_tag.read_barometer do |temp, press|
      barometer_log.info("temp: #{temp} press: #{press}")
    end

    lux_log = Logger.new('logs/lux.log')
    sensor_tag.enable_luxometer
    sensor_tag.read_luxometer do |lux|
      lux_log.info("lux: #{lux}")
    end

    battery_log = Logger.new('logs/battery.log')
    sensor_tag.read_battery_level do |battery_level|
      battery_log.info("battery: #{battery_level}")
    end

    sensor_tag.run
  rescue Interrupt => e
    puts e
  ensure
    sensor_tag.disconnect
  end
end

 今回はコールバックとして単純に各データをログに出力するようにしていますので、スクリプトを実行するとセンサーのデータが各ログファイルに出力されます。

f:id:akanuma-hiroaki:20170717215619p:plain

 データのコンバート処理はちゃんと実装する必要ありますが、SensorTag では手軽にいろいろなセンサーデータを取得できて面白いですね。今回はとりあえずデータを出力するだけでしたが、このデータを使って何か面白いことができないか考えてみたいと思います。