OpenBlocks IoT BX1 から Ruby で BLE デバイスにアクセスする

 以前の記事で OpenBlocks IoT BX1 の WebUI から設定を行うことで、 SensorTag のデータ取得を行いました。

blog.akanumahiroaki.com

 SensorTag は BX1 のサポート対象になっているため、 WebUI からの設定のみでセンサーデータを読み取ることができていましたが、サポート対象外の BLE デバイスの場合にはデータを取得するための処理を自前で実装する必要があります。そこで今回は Ruby のスクリプトから SensorTag のデータを読み取ってみたいと思います。

ssh設定

 まずは作業を行いやすいように、 BX1 に ssh でログインできるようにします。工場出荷状態から作業をしたとすると、上記の記事の「初期設定」の項目の内容を実施し、 Wi-Fi 経由でアクセスできるようにします。

 初期設定が終わり再起動が完了したら再び WebUI にアクセスし、「システム」メニューの「フィルター」タブで「SSH」のラジオボタンを「有効」にします。また、再起動後も設定が保持されるように、「再起動後もフィルタ解放設定を有効にする」にチェックを入れて保存します。

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

 「SSH関連」タブで ssh の詳細設定を行うことができますが、今回はとりあえずのお試しということで、特に設定は変更せず、 root ログインも許可します。実際の本番運用時にはセキュリティ的に問題ないように設定を変更する必要があるかと思います。

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

 root ユーザのパスワードは「パスワード」タブで設定できます。

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

 これで Wi-Fi 経由で ssh ログインができるようになります。

$ ssh root@192.168.10.100
root@192.168.10.100's password: 
Linux obsiot.example.org 3.10.17-poky-edison #1 SMP PREEMPT Thu Jun 1 16:35:38 JST 2017 i686

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Sun Sep  3 22:47:04 2017 from 192.168.10.8

デフォルトの BlueZ バージョンでのアクセス確認

 BX1 にはデフォルトで BlueZ がインストールされていますが、バージョンは少し古く、 5.23 です。

root@obsiot:~# apt-cache show bluez
Package: bluez
Source: bluez (5.23-2)
Version: 5.23-2+b1
Installed-Size: 3043
Maintainer: Debian Bluetooth Maintainers <pkg-bluetooth-maintainers@lists.alioth.debian.org>
Architecture: i386
Replaces: bluez-audio (<= 3.36-3), bluez-input, bluez-network, bluez-serial, bluez-utils (<= 3.36-3), udev (<< 170-1)
Depends: libc6 (>= 2.15), libdbus-1-3 (>= 1.1.1), libglib2.0-0 (>= 2.28.0), libreadline6 (>= 6.0), libudev1 (>= 196), init-system-helpers (>= 1.18~), kmod, udev (>= 170-1), lsb-base, dbus
Conflicts: bluez-audio (<= 3.36-3), bluez-utils (<= 3.36-3)
Breaks: udev (<< 170-1)
Description-en: Bluetooth tools and daemons
 This package contains tools and system daemons for using Bluetooth devices.
 .
 BlueZ is the official Linux Bluetooth protocol stack. It is an Open Source
 project distributed under GNU General Public License (GPL).
Description-md5: ef25d6a9f4a57e78f32faa7b58ef4e59
Multi-Arch: foreign
Homepage: http://www.bluez.org
Tag: admin::hardware, admin::kernel, hardware::TODO, implemented-in::c,
 protocol::TODO, role::program, use::driver, use::synchronizing
Section: admin
Priority: optional
Filename: pool/main/b/bluez/bluez_5.23-2+b1_i386.deb
Size: 750516
MD5sum: 6f731b661885f89ce75d9204a0ca7a8a
SHA1: 502e7f2d1d99b615313e846fd07177d1cc763e2f
SHA256: 3ab85fc151b51dfe91830dd546554fe739dbd7ac069c091eccb39cce88d9a79f

 apt-get でインストールできるのはこれが最新らしいのですが、執筆時の BlueZ の最新バージョンは 5.46 ですので、少し開きがあります。

 また、 bluetoothd は –experimental オプションなしで起動しています。

root@obsiot:~# ps aux | grep bluetoothd | grep -v grep
root      1498  0.0  0.1   5236  1804 ?        S    23:03   0:00 /usr/sbin/bluetoothd

 –experimental オプション付きで起動するには、 /etc/init.d/bluetooth を編集して、 SSD_OPTIONS の行の内容を下記のように変更します。

SSD_OPTIONS="--oknodo --quiet --exec $DAEMON -- $NOPLUGIN_OPTION"
 ↓
SSD_OPTIONS="--oknodo --quiet --exec $DAEMON -- $NOPLUGIN_OPTION --experimental"

 変更したら bluetoothd を再起動すると、 –experimental オプション付きで起動します。

root@obsiot:~# /etc/init.d/bluetooth restart
Stopping bluetooth: /usr/sbin/bluetoothd.
Starting bluetooth: bluetoothd.
root@obsiot:~# 
root@obsiot:~# ps aux | grep bluetoothd
root     18692  0.3  0.1   5236  1812 ?        S    23:51   0:00 /usr/sbin/bluetoothd --experimental
root     18710  0.0  0.0   5748   832 pts/1    S+   23:51   0:00 grep bluetoothd

 そしてデフォルトでは Bluetooth コントローラは有効になっていません。

root@obsiot:~# rfkill list
0: phy0: Wireless LAN
        Soft blocked: no
        Hard blocked: no
1: brcmfmac-wifi: Wireless LAN
        Soft blocked: no
        Hard blocked: no
2: bcm43xx Bluetooth: Bluetooth
        Soft blocked: yes
        Hard blocked: no

 この状態で bluetoothctl を使用しても下記のようにエラーになります。

root@obsiot:~# bluetoothctl
[bluetooth]# power on
No default controller available

 有効にするためには下記コマンドを実行します。

root@obsiot:~# bluetooth_rfkill_event &
root@obsiot:~# rfkill unblock bluetooth

 実行時の出力は下記のようになります。

root@obsiot:~# bluetooth_rfkill_event &
[1] 3752
root@obsiot:~# 1504361980.098955: idx 2 type 2 op 0 soft 1 hard 0

root@obsiot:~# rfkill unblock bluetooth
1504362012.636847: idx 2 type 2 op 2 soft 0 hard 0
root@obsiot:~# execute brcm_patchram_plus --use_baudrate_for_download --no2bytes --enable_fork --enable_lpm --enable_hci --baudrate 3000000 --patchram /etc/firmware/bcm43341.hcd --bd_addr 98:4F:EE:04:B3:24 --scopcm 1,0,0,0,0,0,0,0,0,0 /dev/ttyMFD0
Done setting line discipline
1504362013.132510: idx 3 type 2 op 0 soft 0 hard 0

 rfkill については下記サイトで説明されています。無線デバイスのON/OFFを行うためのコマンドになります。

3.12. RFKill

 実行後は下記のように hci0 のブロックが解除されて、使用できるようになっています。

root@obsiot:~# rfkill list
0: phy0: Wireless LAN
        Soft blocked: no
        Hard blocked: no
1: brcmfmac-wifi: Wireless LAN
        Soft blocked: no
        Hard blocked: no
2: bcm43xx Bluetooth: Bluetooth
        Soft blocked: no
        Hard blocked: no
3: hci0: Bluetooth
        Soft blocked: no
        Hard blocked: no

 これで bluetoothctl 等で BLE デバイスにアクセスできるようになりますので、 bluetoothctl から SensorTag に接続してみます。

root@obsiot:~# bluetoothctl
[NEW] Controller 98:4F:EE:04:B3:24 BlueZ 5.23 [default]
[NEW] Device CC:78:AB:7F:65:87 CC2650 SensorTag
[bluetooth]# scan on
Discovery started
[CHG] Controller 98:4F:EE:04:B3:24 Discovering: yes
[NEW] Device 62:BA:E7:35:DB:E8 62-BA-E7-35-DB-E8
[NEW] Device 34:36:3B:C7:FB:E9 34-36-3B-C7-FB-E9
[CHG] Device 62:BA:E7:35:DB:E8 RSSI: -41
[CHG] Device CC:78:AB:7F:65:87 RSSI: -46
[bluetooth]# devices
Device CC:78:AB:7F:65:87 CC2650 SensorTag
Device 62:BA:E7:35:DB:E8 62-BA-E7-35-DB-E8
Device 34:36:3B:C7:FB:E9 34-36-3B-C7-FB-E9
[bluetooth]# scan off
[bluetooth]# connect CC:78:AB:7F:65:87
Attempting to connect to CC:78:AB:7F:65:87
[CHG] Device CC:78:AB:7F:65:87 Connected: yes
Connection successful
[CHG] Device CC:78:AB:7F:65:87 UUIDs:
        00001800-0000-1000-8000-00805f9b34fb
        00001801-0000-1000-8000-00805f9b34fb
        0000180a-0000-1000-8000-00805f9b34fb
        0000180f-0000-1000-8000-00805f9b34fb
        0000ffe0-0000-1000-8000-00805f9b34fb
        f000aa00-0451-4000-b000-000000000000
        f000aa20-0451-4000-b000-000000000000
        f000aa40-0451-4000-b000-000000000000
        f000aa64-0451-4000-b000-000000000000
        f000aa70-0451-4000-b000-000000000000
        f000aa80-0451-4000-b000-000000000000
        f000ac00-0451-4000-b000-000000000000
        f000ccc0-0451-4000-b000-000000000000
        f000ffc0-0451-4000-b000-000000000000
[bluetooth]# info CC:78:AB:7F:65:87
Device CC:78:AB:7F:65:87
        Name: CC2650 SensorTag
        Alias: CC2650 SensorTag
        Paired: no
        Trusted: no
        Blocked: no
        Connected: yes
        LegacyPairing: no
        UUID: Generic Access Profile    (00001800-0000-1000-8000-00805f9b34fb)
        UUID: Generic Attribute Profile (00001801-0000-1000-8000-00805f9b34fb)
        UUID: Device Information        (0000180a-0000-1000-8000-00805f9b34fb)
        UUID: Battery Service           (0000180f-0000-1000-8000-00805f9b34fb)
        UUID: Unknown                   (0000ffe0-0000-1000-8000-00805f9b34fb)
        UUID: Vendor specific           (f000aa00-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000aa20-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000aa40-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000aa64-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000aa70-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000aa80-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000ac00-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000ccc0-0451-4000-b000-000000000000)
        UUID: Vendor specific           (f000ffc0-0451-4000-b000-000000000000)
        Modalias: bluetooth:v000Dp0000d0110

 また、 Ruby の環境を構築し、 irb で dbus からアクセスしてみます。Ruby 環境の構築や dbus でのアクセス方法等はこちらをご参照ください。

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

Raspberry Pi 3でD-BusからBLEデバイスにアクセスする - Tech Blog by Akanuma Hiroaki

irb(main):038:0* device.GetAll('org.bluez.Device1')
/root/sensortag_sample/vendor/bundle/ruby/2.4.0/gems/ruby-dbus-0.13.0/lib/dbus/message.rb:129: warning: constant ::Fixnum is deprecated
=> [{"Address"=>"CC:78:AB:7F:65:87", "Name"=>"CC2650 SensorTag", "Alias"=>"CC2650 SensorTag", "Paired"=>false, "Trusted"=>false, "Blocked"=>false, "LegacyPairing"=>false, "RSSI"=>-45, "Connected"=>true, "UUIDs"=>["00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "0000180a-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb", "0000ffe0-0000-1000-8000-00805f9b34fb", "f000aa00-0451-4000-b000-000000000000", "f000aa20-0451-4000-b000-000000000000", "f000aa40-0451-4000-b000-000000000000", "f000aa64-0451-4000-b000-000000000000", "f000aa70-0451-4000-b000-000000000000", "f000aa80-0451-4000-b000-000000000000", "f000ac00-0451-4000-b000-000000000000", "f000ccc0-0451-4000-b000-000000000000", "f000ffc0-0451-4000-b000-000000000000"], "Modalias"=>"bluetooth:v000Dp0000d0110", "Adapter"=>"/org/bluez/hci0"}]
irb(main):039:0> 
irb(main):040:0* device.subnodes
=> []

 BlueZ のバージョンが古いせいか、もしくは –enable-experimental オプション付きでビルドされていないせいか、 SensorTag 等の BLE デバイスにアクセスしても、接続はできるものの、ServicesResolved プロパティも存在せず、 GATT サービスにアクセスすることができませんでした。そこで、パッケージインストールされているデフォルトの bluez をアンインストールし、ソースからビルドしてインストールすることにします。

BlueZ バージョンアップ

 まずはインストール済みの BlueZ をアンインストールします。下記を実行すると openblocks-iot-webui パッケージもアンインストールされ、 WebUI が使えなくなりますのでご注意ください。

root@obsiot:~# sudo apt-get --purge remove bluez
Reading package lists... Done
Building dependency tree       
Reading state information... Done
The following packages were automatically installed and are no longer required:
  arping bind9-host bluez-hcidump cu curl dns-root-data dnsmasq dnsmasq-base dnsutils expect git git-man hostapd iotop isc-dhcp-server libapparmor1 libb-hooks-endofscope-perl libbind9-90 libbluetooth3 libc-ares2 libclass-load-perl
  libclass-singleton-perl libcurl3 libcurl3-gnutls libdatetime-locale-perl libdatetime-perl libdatetime-timezone-perl libdns100 libdrm-intel1 libdrm-nouveau2 libdrm-radeon1 libdrm2 libelf1 libelfg0 liberror-perl libfontenc1 libgc1c2
  libgl1-mesa-dri libgl1-mesa-glx libglapi-mesa libglib2.0-bin libglib2.0-dev libice6 libisc95 libisccc90 libisccfg90 libjansson4 libjson-c-dev libjson0 libjson0-dev liblist-allutils-perl liblist-moreutils-perl libllvm3.5
  liblockfile-bin liblockfile1 liblua5.1-0 liblua5.1-0-dev liblwres90 libmodule-implementation-perl libmodule-runtime-perl libmysqlclient18 libnamespace-clean-perl libnet1 libnetfilter-conntrack3 libnl-route-3-200 libonig2
  libpackage-stash-perl libpackage-stash-xs-perl libparams-classify-perl libparams-validate-perl libpciaccess0 libpcre3-dev libpcrecpp0 libperl4-corelibs-perl libpq5 libpython-stdlib libpython2.7-minimal libpython2.7-stdlib libqdbm14
  librtmp1 libsm6 libsqlite3-0 libssh2-1 libsub-exporter-progressive-perl libsub-identify-perl libsub-name-perl libtcl8.6 libtk8.6 libtool-bin libtry-tiny-perl libtxc-dxtn-s2tc0 libutempter0 libv8-3.14.5 libvariable-magic-perl
  libx11-xcb1 libxaw7 libxcb-dri2-0 libxcb-dri3-0 libxcb-glx0 libxcb-present0 libxcb-shape0 libxcb-sync1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxft2 libxi6 libxinerama1 libxmu6 libxmuu1 libxrandr2 libxrender1 libxshmfence1
  libxss1 libxt6 libxtst6 libxv1 libxxf86dga1 libxxf86vm1 lockfile-progs lrzsz lsof lua-json lua-lpeg lua5.1 minicom mysql-common nginx nkf nodejs nodejs-legacy ntpdate openblocks-iot-wstunnel pd-adder pd-emitter-lite pd-handler-ble
  pd-handler-conf pd-handler-plc pd-handler-uart pd-subscriber ph-nodered-packs ph-supervisor-nodered php-auth-sasl php-gettext php-http-request php-mail php-mail-mime php-net-dime php-net-smtp php-net-socket php-net-url php-pear
  php-screw php-soap php5-cgi php5-cli php5-common php5-fpm php5-json php5-readline pkg-config pppconfig python python-meld3 python-messaging python-minimal python-nose python-pkg-resources python-serial python-six python2.7
  python2.7-minimal rsync shellinabox spp-assist sqlite3 sshpass sudo supervisor tcl-expect tcl8.6 tk8.6 x11-common x11-utils xbitmaps xterm
Use 'apt-get autoremove' to remove them.
The following packages will be REMOVED:
  bluez* openblocks-iot-webui*
0 upgraded, 0 newly installed, 2 to remove and 90 not upgraded.
After this operation, 3982 kB disk space will be freed.
Do you want to continue? [Y/n] Y
(Reading database ... 45677 files and directories currently installed.)
Removing openblocks-iot-webui (2.1.1-10) ...
Purging configuration files for openblocks-iot-webui (2.1.1-10) ...
dpkg: warning: while removing openblocks-iot-webui, directory '/var/webui/docroot' not empty so not removed
dpkg: warning: while removing openblocks-iot-webui, directory '/var/webui/config' not empty so not removed
dpkg: warning: while removing openblocks-iot-webui, directory '/var/webui/sound' not empty so not removed
Removing bluez (5.23-2+b1) ...
Stopping bluetooth: /usr/sbin/bluetoothd.
Purging configuration files for bluez (5.23-2+b1) ...
Processing triggers for dbus (1.8.20-0+deb8u1) ...
Processing triggers for man-db (2.7.0.2-5) ...

 下記コマンドで BlueZ のビルドに必要なライブラリをインストールします。

root@obsiot:~# apt-get install libdbus-1-dev libdbus-glib-1-dev libglib2.0-dev libical-dev libreadline-dev libudev-dev libusb-dev

 BlueZ のソースを取得して展開します。

root@obsiot:~# wget http://www.kernel.org/pub/linux/bluetooth/bluez-5.46.tar.xz
root@obsiot:~# xz -dv bluez-5.46.tar.xz 
root@obsiot:~# tar -xf bluez-5.46.tar 
root@obsiot:~# cd bluez-5.46

 そして下記コマンドでビルドしてインストールします。

root@obsiot:~/bluez-5.46# ./configure --enable-experimental --disable-systemd
root@obsiot:~/bluez-5.46# make
root@obsiot:~/bluez-5.46# make install

 インストールが終了したら一度 BX1 を再起動し、とりあえず手動で bluetoothd を起動してみます。

root@obsiot:~# /usr/local/libexec/bluetooth/bluetoothd --experimental &
[1] 1657

 そして bluetoothctl で接続してみます。

root@obsiot:~# bluetoothctl
[NEW] Controller 98:4F:EE:04:B3:24 BlueZ 5.46 [default]
[NEW] Device CC:78:AB:7F:65:87 CC2650 SensorTag
Agent registered
[bluetooth]# connect CC:78:AB:7F:65:87
Attempting to connect to CC:78:AB:7F:65:87
[CHG] Device CC:78:AB:7F:65:87 Connected: yes
Connection successful
[NEW] Primary Service
        /org/bluez/hci0/dev_CC_78_AB_7F_65_87/service0008
        00001801-0000-1000-8000-00805f9b34fb
        Generic Attribute Profile
[NEW] Primary Service
        /org/bluez/hci0/dev_CC_78_AB_7F_65_87/service0009
        0000180a-0000-1000-8000-00805f9b34fb
        Device Information
[NEW] Characteristic
        /org/bluez/hci0/dev_CC_78_AB_7F_65_87/service0009/char000a
        00002a23-0000-1000-8000-00805f9b34fb
        System ID
〜〜〜中略〜〜〜
[NEW] Characteristic
        /org/bluez/hci0/dev_CC_78_AB_7F_65_87/service0063/char006f
        f000ffc4-0451-4000-b000-000000000000
        Vendor specific
[NEW] Descriptor
        /org/bluez/hci0/dev_CC_78_AB_7F_65_87/service0063/char006f/desc0071
        00002902-0000-1000-8000-00805f9b34fb
        Client Characteristic Configuration
[NEW] Descriptor
        /org/bluez/hci0/dev_CC_78_AB_7F_65_87/service0063/char006f/desc0072
        00002901-0000-1000-8000-00805f9b34fb
        Characteristic User Description
[CHG] Device CC:78:AB:7F:65:87 ServicesResolved: yes
[CHG] Device CC:78:AB:7F:65:87 Name: SensorTag 2.0
[CHG] Device CC:78:AB:7F:65:87 Alias: SensorTag 2.0
[CC2650 SensorTag]# 

 バージョンアップ前とは違って、サービスの内容まで取得でき、 ServicesResolved プロパティも表示され、 yes となっています。

 irb からアクセスしてみても下記のようにサービスが取得できるようになっています。

irb(main):024:0* device.GetAll('org.bluez.Device1')
/root/sensortag_sample/vendor/bundle/ruby/2.4.0/gems/ruby-dbus-0.13.0/lib/dbus/message.rb:129: warning: constant ::Fixnum is deprecated
=> [{"Address"=>"CC:78:AB:7F:65:87", "Name"=>"SensorTag 2.0", "Alias"=>"SensorTag 2.0", "Paired"=>false, "Trusted"=>false, "Blocked"=>false, "LegacyPairing"=>false, "Connected"=>true, "UUIDs"=>["00001800-0000-1000-8000-00805f9b34fb", "00001801-0000-1000-8000-00805f9b34fb", "0000180a-0000-1000-8000-00805f9b34fb", "0000180f-0000-1000-8000-00805f9b34fb", "0000ffe0-0000-1000-8000-00805f9b34fb", "f000aa00-0451-4000-b000-000000000000", "f000aa20-0451-4000-b000-000000000000", "f000aa40-0451-4000-b000-000000000000", "f000aa64-0451-4000-b000-000000000000", "f000aa70-0451-4000-b000-000000000000", "f000aa80-0451-4000-b000-000000000000", "f000ac00-0451-4000-b000-000000000000", "f000ccc0-0451-4000-b000-000000000000", "f000ffc0-0451-4000-b000-000000000000"], "Modalias"=>"bluetooth:v000Dp0000d0110", "Adapter"=>"/org/bluez/hci0", "ServicesResolved"=>true}]
irb(main):025:0> 
irb(main):026:0* device.subnodes
=> ["service0008", "service0009", "service001c", "service0022", "service002a", "service0032", "service003a", "service0042", "service004a", "service004f", "service0054", "service005b", "service0063"]
irb(main):027:0> 

Ruby スクリプトからのアクセス

 以前 Raspberry Pi からの SensorTag のデータ取得について書いた時に作成した Ruby のスクリプトを使ってデータを取得してみたいと思います。

blog.akanumahiroaki.com

 コードはこちらにも公開しています。

github.com

 sensortag.rb を実行すると、各センサーのデータをそれぞれログファイルに出力します。

root@obsiot:~/sensortag_sample# bundle exec ruby sensortag.rb 

 下記は温度と湿度のセンサーデータの例です。

root@obsiot:~/sensortag_sample# tail -f logs/humidity.log 
I, [2017-09-04T02:11:52.410172 #1787]  INFO -- : temp: 32.1875 hum: 57.23876953125
I, [2017-09-04T02:11:53.422242 #1787]  INFO -- : temp: 32.1875 hum: 57.23876953125
I, [2017-09-04T02:11:54.435289 #1787]  INFO -- : temp: 32.19757080078125 hum: 57.16552734375
I, [2017-09-04T02:11:55.447590 #1787]  INFO -- : temp: 32.19757080078125 hum: 57.16552734375
I, [2017-09-04T02:11:56.460004 #1787]  INFO -- : temp: 32.2076416015625 hum: 57.16552734375
I, [2017-09-04T02:11:57.472740 #1787]  INFO -- : temp: 32.2076416015625 hum: 57.16552734375
I, [2017-09-04T02:11:58.417763 #1787]  INFO -- : temp: 32.2076416015625 hum: 57.16552734375
I, [2017-09-04T02:11:59.430025 #1787]  INFO -- : temp: 32.2076416015625 hum: 57.16552734375
I, [2017-09-04T02:12:00.442741 #1787]  INFO -- : temp: 32.227783203125 hum: 57.16552734375
I, [2017-09-04T02:12:01.455942 #1787]  INFO -- : temp: 32.227783203125 hum: 57.16552734375
I, [2017-09-04T02:12:02.467395 #1787]  INFO -- : temp: 32.227783203125 hum: 57.16552734375

 これで Ruby のスクリプトからでも BLE デバイスのセンサーデータが取得できるようになりました。

ファクトリーリセット

 色々と試行錯誤する中で、何度か工場出荷状態に戻したいということがあり、はじめは結構手こずったので、参考までにファクトリーリセットの手順を紹介しておきます。ベースは下記製品サイトに掲載されている手順になります。

openblocks.plathome.co.jp

 この手順のままやると、ストレージ併用モードを解除して再起動した段階でネットワーク接続等の情報も消えてしまいますので、無線LAN環境で使っている場合にはその設定を行う手順が必要になってきます。

 まずは手順通りにストレージ併用モードを解除して再起動します。

root@obsiot:~# e2label /dev/mmcblk0p10 ""
root@obsiot:~# reboot

 私の環境はバージョン8 (jessie / FW 2.x系)モデルだったので、下記コマンドでファイルシステムを構築します。

root@obsiot:~# yes | mkfs -t ext4 -L DEBIAN /dev/mmcblk0p10

 構築したファイルシステムをマウントします。

root@obsiot:~# mount /dev/mmcblk0p10 /mnt

 そしてここで工場出荷用データを取得できるように、Wi-Fiの設定を行います。

root@obsiot:~# wpa_passphrase MY_AP_SSID MY_AP_PASSWORD >> /etc/wpa_supplicant/wpa_supplicant.conf
root@obsiot:~# chmod 600 /etc/wpa_supplicant/wpa_supplicant.conf

 /etc/network/interface のWi-Fiインタフェース設定を下記のように設定します。 BX1 からインターネットにアクセスできれば良いので、 DHCP でIPアドレスを取得し、 Wi-Fi アクセス設定は先ほどの設定ファイル wpa_supplicant.conf から読み込む形です。

auto wlan0
iface wlan0 inet dhcp
wpa-conf /etc/wpa_supplicant/wpa_supplicant.conf

#auto wlan0
#iface wlan0 inet static
#     address 192.168.1.17
#     network 192.168.1.0
#     netmask 255.255.255.0
#     broadcast 192.168.1.255
#     gateway 192.168.1.1

 設定を有効にするため一旦 Wi-Fi のネットワークインタフェースを停止し、再度起動します。

root@obsiot:~# ifdown wlan0
root@obsiot:~# ifup wlan0

 設定が正しいのに IP アドレスが正しく取得できていない場合は、再度停止・起動を行ってみると正しく取得できることがあります。

 ネットワーク接続が有効になったら下記コマンドで工場出荷用データを取得します。執筆時の最新バージョンは Kernel 3.10.17-101対応 Ver.2.1.1-10 でした。

root@obsiot:~# wget http://ftp.plathome.co.jp/pub/BX1/jessie/3.10.17-101/obsiot_userland_2.1.1-10_20170602.tgz

 そしてデータを展開して再起動します。

root@obsiot:~# tar xzf /root/obsiot_userland_2.1.1-10_20170602.tgz -C /mnt 2> /dev/null
root@obsiot:~# umount /mnt
root@obsiot:~# reboot

 すると工場出荷時の状態で起動し、Wi-Fiインタフェースもアクセスポイントモードで起動してきますので、 iotfamily_シリアル番号 という SSID でアクセスできるようになります。

 下記サイトも参考にさせていただきました。

dev.classmethod.jp

まとめ

 IoT ゲートウェイはそれぞれの製品によって特徴があり、採用されているOSによっても違いが出て来ます。また、Raspberry Pi などとは違った癖があるように思いますし、情報もあまり多くはないと思いますので、細かいことは色々と調べる必要があります。 BX1 のように WebUI から設定できるようになっている場合でも、サポートされていないデバイスを使おうと思うと Linux 上で動く BLE アプリを自前開発する必要がありますので、結構ハードルは高いように感じています。そして今回はとりあえず BLE デバイスのデータを取得するところまでということで、 WebUI も使えないままにしてしまっていますし、さらに最終的にはデータをどこかに送信しないと意味がないので、 その辺りも今後やってみたいと思います。

 今回はこちらも参考にさせていただきました。

openblocks.plathome.co.jp

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接続もできるようになり、電源さえ確保すればワイヤレスでいろんなことができそうなので、活用方法を考えてみたいと思います。