知的好奇心 for IoT

IoT関連の知的好奇心を探求するブログです

Ofuro BoardをPWAに対応させてお風呂のドアの開閉をWeb Pushで通知するようにした

ぼくは今住んでいるシェアハウスで「Laundry Reminder」と「Ofuro Board」という2つのIoTサービスを運営しています。

Laundry Reminder
Ofuro Board

この2つのIoTサービスのアーキテクチャーは、エッジ側にRaspberry Piを使っているのは共通なんですが、クラウド側(UI側)はHerokudatapliciry(Wormhole)="Rasapberry Pi上のWebサーバー"と大きく異なっています。

Laundry ReminderはFacebookと連携して、Facebookに登録したメールアドレスに終了を通知するようにしていたのですが、全く使われている感じがしなかったんです。ぼく自身もFacebook疲れみたいなものがあり、ほとんどFacebookに投稿しなくなっていたこともあって新たな通知手段を探していました。

そんな時に見つけたのがPWA(Progressive Web Apps)のWebプッシュ通知だったのです。

Webプッシュはブラウザベースの新しい通知手段で、メールアドレスなどの個人情報を登録してもらわなくてもユーザーに通知することができます。制約の多い現代社会でクリエイター(開発者)にとっては夢のような仕組みですが、Apple帝国が支配するiOSでは動きません。メシア、スティーブ・ジョブズが示したユートピアに反した帝国には、いずれ報いが訪れるでしょう。

 

では、どんな感じで通知されるのか見てみましょう!

f:id:IntellectualCuriosity:20190328235750p:plain

この画面はAndroidでアプリ登録して、アプリがフォアで動いている状態で通知を受けたものです。通知条件はChromeか登録したアプリがバックグラウンドでもとにかく動いていることです。

 

Web Push

Ofuro BoardにWeb Pushを追加する上で参考にしたのが、次のコードラボです。

サンプルコードがGithubからダウンロードでき、Web Server for ChromeというChromeアプリ上でステップ・バイ・ステップで試せて非常にわかり易いです。日本語も変な訳になっていなくて読みやすいので、是非試してみてください!

また、このコードラボのコンパニオンサイトアプリケーションサーバーキー(公開鍵と秘密鍵)が作れて、プッシュ通知を送ることもできるようになっています。コードラボはブラウザ側の内容が書かれていて、サーバー側の機能をコンパニオンサイトが担う感じですね。コンパニオンサイトの下の方には、サーバー側のプログラムから通知を送信するためのライブラリへのリンクが貼ってあります。Node.js用のライブラリが人気みたいですが、ぼくはPython用のpywebpushを使いました。

 

main.jsの修正

サンプルコードのmain.jsでサービスワーカーの登録や通知の購読を行います。殆どサンプルコードそのままで使えますが、subscription(購読情報)をアプリケーションサーバーに送る部分が、// TODO: Send subscription to application serverとコメントが書かれているだけなので自分で書かなければいけません。

サンプルコードではupdateSubscriptionOnServerメソッドにこのコメントが書いてありましたが、ぼくにはこの処理がいらなかったので、呼び出していたsubscribeUserメソッドにsubscriptionをアプリケーションサーバーに送る処理を入れました。

applicationServerUrlAjaxのPOSTの受け口となるアプリケーションサーバーのURLを入れれば、subscriptionを送ることができます。

function subscribeUser() {
  const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
  swRegistration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: applicationServerKey
  })
  .then(function(subscription) {

    // TODO: Send subscription to application server
    var sub_str = JSON.stringify(subscription);
    //console.log('subscription:', sub_str);
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          console.log('Send subscription:', xhr.responseText);
        } else {
          console.log('XMLHttpRequest() error');
        }
      }
    }
    xhr.open("POST", applicationServerUrl);
    xhr.setRequestHeader('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
    var data = 'subscription=' + encodeURIComponent(sub_str)
    xhr.send(data);
    //console.log('Request data : ', data);

    console.log('User is subscribed:', subscription);
    isSubscribed = true;
    updateBtn();
  })
  .catch(function(err) {
    console.log('Failed to subscribe the user: ', err);
    updateBtn();
  });
}

 

serviceworker.js(sw.js)の修正

ブラウザ側で動く部分でもう1つ修正する場所があります。ブラウザがプッシュメッセージを受信したときの表示をする部分です。

サービスワーカーのプッシュイベント発生時の処理を次のように修正しました。サンプルコードではsw.jsですが、ぼくはserviceworker.jsというファイル名にしています。

self.addEventListener('push', function(event) {
  console.log('[SW] Push Received.');
  console.log(`[SW] Push had this data: "${event.data.text()}"`);

  const title = 'Ofuro Board';
  var data = JSON.parse(event.data.text());
  var msg = {
    body: 'The ' + data.target + ' is ' + data.state + '!',
    icon: ''
  }

  if (data.state == "open") {
    msg.icon = "/images/open192.png";
  } else {
    msg.icon = "/images/close192.png";
  }

  event.waitUntil(self.registration.showNotification(title, msg));
});

サンプルコードではプッシュメッセージをそのまま表示していますが、ぼくはメッセージを{"target":"door","state":"open"}のようにJSONで送るようにしていて、"state""open""close"かで表示するアイコンを変えるようにしています。

 

Raspberry Pi側の修正

web.py(Webサーバー)にsubscriptionを受け取る処理を加える

ブラウザ側のmain.jsで購読時にsubscriptionをAjaxで送っているので、それを受け取って保存する処理を付け加えます。WebサーバーにはPythonのWebフレームワーク Bottleを使っています。

from tinydb import TinyDB, Query

SUBSCRIPTION_FILE = "subscription.json"

# Response of subscription
@route('/subscription', method='POST')
def subscription():
    sub_str = request.forms.get('subscription')
    subscription = json.loads(sub_str)
    db = TinyDB(SUBSCRIPTION_FILE)
    qy = Query()
    if db.search(qy.endpoint == subscription['endpoint']) :
        print('Already exist: ' + subscription['endpoint'])
    else :
        db.insert(subscription)
        print('Insert: ' + subscription['endpoint'])
    return "OK"

subscriptionの保存には、辞書型オブジェクトをそのまま扱えて軽量なTinyDBを使いました。subscriptionに含まれるendpointの値は購読毎にユニークになるので、endpointがまだ登録されていければsubscriptionを登録するようにしています。

 

pywebpushのインストール

pywebpushのプロジェクトページには、これみよがしとpip install pywebpush書かれていますが、Raspberry piで実行すると様々なエラーが出てしまいました。で、installation項目を見ると、このように書いてあります。

You'll need to run python virtualenv. Then

bin/pip install -r requirements.txt
bin/python setup.py develop

でも、やっぱりエラーが出ました。いろいろやったので正確に覚えていないのですが、少なくとも次のモジュールを事前にインストールしておく必要がありました。

sudo apt-get install libffi-dev
sudo apt-get install libssl-dev

その後、次のコマンドを実行してpywebpushが使えるようになりました。

git clone https://github.com/web-push-libs/pywebpush.git
cd pywebpush sudo pip install virtualenv virtualenv . bin/pip install -r requirements.txt bin/python setup.py develop sudo pip install pywebpush

 

プッシュ通知を行うwebpush.pyを加える

web.pyでTinyDBに保存したsubscription宛てにプッシュ通知を行うプログラムを追加します。subscriptionは購読したブラウザ分増えるため、プッシュ通知を終えるのに時間がかかってもよいように非同期でrcv_pal.pyからキックするようにしています。

また、このようにプッシュ通知を行うプログラムを独立させておくと、単体でもテストできるので便利です。

次のようなコマンドを実行すると登録したsubscription宛てにプッシュ通知します。

python webpush.py '{"target":"door","state":"open"}'

webpush.py ※2019/4/13更新

#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Web Push program
# April 13, 2019
# By Hiroyuki ITO
# http://intellectualcuriosity.hatenablog.com/
# MIT Licensed.

import sys
import json
from pywebpush import webpush, WebPushException
from tinydb import TinyDB, Query

PRIVATE_KEY = "Your_Private_Key"
CLAIMS_ADDRESS = "Your_Mail_Address"
SUBSCRIPTION_FILE = "subscription.json"

# Command line 'python webpush.py {"target":"' + target +  '","state":"' + sta$args = sys.argv
if len(args) < 2:
    print('Usage: $ python %s "{"target":target, "state":state}"' % args[0])
    sys.exit()

data = args[1]
print('WebPush data: ' + data)

db = TinyDB(SUBSCRIPTION_FILE)
for subscription in db.all():
    try:
        webpush(subscription,
            data,
            vapid_private_key = PRIVATE_KEY,
            vapid_claims = {"sub": CLAIMS_ADDRESS}
        )
        print('WebPush send: ' + subscription['endpoint'])

    except WebPushException as ex:
        print("WebPush status code: " + str(ex.response.status_code))
        # Not Registered or Not Found
        if ex.response.status_code == 410 or ex.response.status_code == 404:
            qy = Query()
            db.remove(qy.endpoint == subscription['endpoint'])
            print('WebPush remove: ' + subscription['endpoint'])

ぼくのプログラムでは、main.jsやweb.pyにTinyDBからsubscriptionを削除するための処理が入っていません。これは、購読が解除される条件がunsubscribe()を呼び出す以外にも、サービスワーカーを登録解除した場合など複数存在するからです。

その代わり、webpush()でエラーになった時のステータスコードが”410”(Not Registered)か"404"(Not Found)だった場合に、TinyDBからそのsubscriptionを削除するようにしています。

 

rcv_pal.pyにwebpush.pyを呼び出す処理を加える

風呂のドアに取り付けた開閉センサーパルからTWELITE経由で開閉情報を受信するrcv_pal.pyに、webpush.pyを非同期で呼び出すファンクションを加えます。

import subprocess

# Web Push
def pushmsg(target, state):
    data = 'python webpush.py {"target":"' + target +  '","state":"' + state + '"}'
    subprocess.Popen(data.split())

そして、開閉情報をファイルに保存するタイミングで、プッシュ通知で送るデータ付きでpushmsg()を呼び出します。

with open(serialdid, "w") as f:
    f.write(w_data)
    if magnetic == "0" :
        pushmsg("door", "open")
    else :
        pushmsg("door", "close")

 

おわり