Web Bluetooth APIとFirebaseでIoTドールハウス的なものを作ってみた話

Web Bluetooth APIとFirebaseでIoTドールハウス的なものを作ってみた話

# 概要
ちょっと前のコミティアで自作のドールハウスもどき[^1]を作って、その中の照明とか窓枠のディスプレイとかを来場者のスマホからリモコン制御できるようにしてみた話です。
Bluetooth機器はLED照明のPLAYBULBE、技術的にはWeb Bluetooth API・Firebase・Riot.js・ES6(ES2015)・anime.jsあたりつかってます。
[^1]: 「ドールハウス」と呼んで良いものは実は厳密に規格で決まっているらしく、実はシルバニアファミリーなんかも正確にはドールハウスではないらしい

# 作ったもの
![overview.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/583db4b4-8849-3bad-7ab4-e8675a558e35.jpeg)
窓の外の景色と室内の照明をスマホのブラウザをリモコンにしてコントロールできるようになってます。

ポイント：

- カメラでQRコードを読むだけでリモコンが一発起動。Webなのでインストール不要！
- 複数人で同時操作もOK！他の人が景色や照明を変えると、手元のリモコン画面にも即座に反映
- 全てWeb技術で完結。半田ごてもラズパイも不要！（←使えないだけ）

# システム構成（理想）
![sys_ideal.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/9834e6c1-f17a-7435-a840-a060a7f8c46b.jpeg)

# 実際の構成
詳しい方とカンの良い方はもうお気づきと思いますが、上の構成（理想）は嘘です。

## Web BluetoothはChromeでしか使えない
![webbluetooth.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/62640b62-572c-48c8-81e2-472be97e2daa.jpeg) 
https://caniuse.com/#feat=web-bluetooth

WebアプリからBluetooth機器が使える！IoTの時代が！！みたいに（一部で）鳴り物入りで歓迎された規格だった気がするのですが、ダメですね。
2018年8月時点でもサポートしているのはChromeのみ。Webkitでは実装の検討もなしのようです。。

## 回避策

で、実際の回避策がこちら。
![sys_actual.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/df4b4339-a204-3262-b9b4-0f91c33ada4a.jpeg)

これもカンの良い方にはもうばれてそうですが、「スマホがおうちのリモコンになるよ！」と煽っておきながら、スマホと照明(BluetoothLED)の間は通信していません。
代わりにFirebaseのRealtimeDBにリクエストを送って、それを窓枠にはめたiPadで受信、iPadがWeb BluetoothでLEDと通信、という流れです。
iOSのChromeもWeb Bluethoothはサポートとしていないので、今回は苦肉の策で[WebBLE](https://itunes.apple.com/jp/app/webble/id1193531073?mt=8)（有料：240円）というアプリを使います。要は「Web Bluetooth だけ自力で実装したSafari」ということのようです。

# アプリの実装

アプリはホスティングとDB機能をFirebaseで作ります。エンドユーザー用のリモコン画面と受信側のiPad画面の2つをざっくりRiot.jsで作ってホスティング。両方の画面からRealtimeDBを読み書きします。

## Firebaseまわり
今回管理するのは「照明の色」「背景の種類」「背景に飛ばす雲や星の位置」だけです。最後の雲や星だけはユーザーのタップ操作で動的に追加されるデータです。

### データ構造
![db.gif](https://qiita-image-store.s3.amazonaws.com/0/245740/c7460418-cc81-63fa-517b-8d1d4df239e9.gif)

構造はこんな感じ。`room/state/light`で照明の色を、`room/state/stageType`でiPadに表示する窓の景色を設定します。さらに今回は、リモコン画面をタップすると窓に雲や星が流れる機能を作りたいので、タップされた座標を`room/elems`に格納します。
これはひたすらたまり続けても困るので、iPad側で受け取ったら適時削除します。

### Firebase Realtime DB読み書きの実装

今回はログインとかユーザー管理とかもないのでものすごく簡単。
直接Firebaseを扱うコード書いたのは100行くらい？Riotのriot.observableを使って、DB変更を検知した時に内容に合わせたイベントが飛ぶようにしておきます。
あとはこのクラスとやりとりして、タップ時の更新や、DB変更時の画面表示をよしなにやってあげればOK。

```javascript

import firebase from './firebase';
import riot from 'riot';
import RoomModel from '../model/RoomModel';
import StageElemModel from '../model/StageElemModel';

const ROOM_ROOT_PATH = 'data/room';
const ROOMSTATE_PATH = 'data/room/state';
const ROOMELEMS_PATH = 'data/room/elems';
const EVENT_STATE_CHANGED = 'state_changed';
const EVENT_ELEM_ADDED = 'elem_added';
const EVENT_ELEM_REMOVED = 'elem_removed';

export default class FbRoomRef{
    constructor(){
        // DBの参照を取得
        this._root = firebase.database().ref();
        // riotの機能でイベントを発行できるようにする（onとtriggerが使えるようになる）
        riot.observable(this);

        // お部屋の状態（背景・照明色）が変わった時
        this.root.child(ROOMSTATE_PATH).on('value',(snap)=>{
            const room = new RoomModel(snap.val());
            this.trigger(EVENT_STATE_CHANGED,room);
        });
        // 背景の要素（雲とか星とか）が追加された時
        this.root.child(ROOMELEMS_PATH).on('child_added',(snap)=>{
            const elem = new StageElemModel(snap.val());
            this.trigger(EVENT_ELEM_ADDED,elem);
        });
        // 背景の要素（雲とか星とか）が削除された時
        this.root.child(ROOMELEMS_PATH).on('child_removed',(snap)=>{
            this.trigger(EVENT_ELEM_REMOVED,snap.key);
        });
    }

    // お部屋の状態を更新（DB書き込み）
    // 更新対象のプロパティ名をfields配列を明示することで更新時の無駄な通信を減らす
    updateRoomState(roomModel,fields){
        if(!roomModel || roomModel.constructor !== RoomModel){throw 'invalid roomModel'}
        if(!fields || fields.length === undefined){throw 'invalid fields array'}

        const updates = {}; // 更新用データ
        for(let key of fields){
            if(roomModel[key]===undefined){console.warn(`invalid field ${key} is ignored`);continue}
            updates[key] = roomModel[key];
        }
        this.root.child(ROOMSTATE_PATH).update(updates);
    }

    // 背景の要素を追加（DB書き込み）
    // 先にIDだけ取得して、そのIDでupdateを掛ける（登録するデータ本体に自身のIDを含めるため）
    addStageElem(elem){
        if(!elem){return}
        const o = elem.toObj();
        const ref = this.root.child(ROOMELEMS_PATH);
        const key = ref.push().key;
        o.elemid = key;
        ref.update({[key]:o})
    }
    // 背景の要素を全て削除（DB書き込み）
    clearStageElem(){
        const ref = this.root.child(ROOM_ROOT_PATH);
        ref.update({elems:null});
    }
    // 背景の要素をID指定で削除（DB書き込み）
    removeStageElem(elemid){
        const ref = this.root.child(ROOMELEMS_PATH);
        ref.update({[elemid]:null});
    }

    get root(){
        return this._root;
    }
    get EVENT_STATE_CHANGED() {
        return EVENT_STATE_CHANGED;
    }
    get EVENT_ELEM_ADDED() {
        return EVENT_ELEM_ADDED;
    }
    get EVENT_ELEM_REMOVED() {
        return EVENT_ELEM_REMOVED;
    }
}
```

### 蛇足：RealtimeDBとCloudFireStoreのどちらを使うべきか？
ここ最近だとFirebaseのDBはCloudFireStoreがデフォみたいですが、リアルタイム性重視で、複雑なクエリやアクセス管理を必要としないなら今でもRealtimeDBって選択肢は十分ありかな、と。このエセリモコンもほとんど遅延を感じないレベルで動きます。少なくとも我が家のエアコンよりは反応いい。

## リモコンアプリとアニメーション
アプリはRiot.jsでざっくりつくります。やっつけ感満載だけど許して欲しい。

### クライアント（スマホリモコン）画面
ボタン並べて、タップ時にDB書き込み + DB更新に合わせて雲や星のアイコンを表示するだけの簡単仕様。
![client.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/d49eaa22-aeca-273c-d29f-6c8c5537b4c3.jpeg)

イベント会場は通信状態が悪いのがデフォなので、画像は使わず全てCSSとSVGだけで作ります。特別な最適化はしていないけど、Firebase本体コミで265KB。これくらいなら回線が混み合っていたり速度規制かかってたりしてもまあ、許容範囲でしょう。
￼![network.gif](https://qiita-image-store.s3.amazonaws.com/0/245740/83ba5671-a42a-d0e4-c01e-ef3476110683.gif)

### サーバー？(iPad窓枠)画面
Riotタグ等、構成要素はリモコン側とほぼ共用です。こっちはちゃんと画像を使います。一度読ませてキャッシュしておけば現地の回線が混んでいても大丈夫。
![server.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/3b196456-d745-8875-b50c-d6c44bb9e5e5.jpeg)

## Bluetoothまわり
やっとお題目のWeb Bluetoothです。今回はBluetoothガジェットとしては有名どころらしい「PLAYBULBE」を分解して使います。Amazonで3000円くらい。
https://www.amazon.co.jp/dp/B00O4LHNNS/ref=asc_df_B00O4LHNNS2482039/

![led.jpg](https://qiita-image-store.s3.amazonaws.com/0/245740/b2a66c40-f3ad-ceee-643a-f5250f049f51.jpeg)
分解って言っても壊さないで外せるパーツをとって、ガラスのカバー（100均のキャンドルスタンド。ほんとはもっと可愛いのが欲しかった）乗っけただけですね。この子をお部屋の壁にぶっ刺せば設営完了。無線って素敵。

最後にサーバー?（iPad窓枠）画面からPLAYBULBEの色をWeb Bluetoothでコントロールします。

```javascript

const CANDLE_SERVICE_UUID = 0xFF02;
const CANDLE_COLOR_UUID = 0xFFFC;
const CANDLE_DEVICE_NAME_UUID = 0xFFFF;

export default class PlaybulbCandle {
    constructor() {
      this.device = null;
    }

    connect() {
      const options = {filters:[{services:[ CANDLE_SERVICE_UUID ]}]};
      return navigator.bluetooth.requestDevice(options)
      .then((device)=>{
        device.gatt.connect();
        this.device = device;
      }); 
    }

    setColor(r,g,b) {
      console.log('setColor',[r,g,b])
      const data = new Uint8Array([0x00, r, g, b]);
      return this.device.gatt.getPrimaryService(CANDLE_SERVICE_UUID)
      .then(service => service.getCharacteristic(CANDLE_COLOR_UUID))
      .then(characteristic => characteristic.writeValue(data))
      .then(() => [r,g,b]);
    }  
  }
```

クラスの呼び出し側は`new`して`connect`して`setColor`するだけ！もちろんUUIDとかのデバイス側の仕様は読まないといけないですが、メジャーなデバイスならこのあたりの情報は探せば出てくるので簡単です（他力本願）。
ちなみにPLAYBULBEを使ったWeb Bluetoothのチュートリアルは下記が懇切丁寧でおすすめです（上記のコードも多分ほとんどこの記事と同じです）
https://codelabs.developers.google.com/codelabs/candle-bluetooth/index.html#0

# まとめ
ちょっとトリッキーな部分もあるけど、かなり簡単＆低コストで「自分のスマホがドールハウスのリモコンになる」体験をちびっこたちに提供できました。興味のある方はぜひやってみてください。

- Web Bluetooth APIを使うとJavascriptだけでIoTできる
- Web BluetoothAPIはiOSでサポートされていないので、ユーザーのブラウザだけでは完結できない。別途アプリを組むか、「WebBLE」のようなヘルパーが必要
- Firebaseと組み合わせるとあたかもスマホからリモコンでIoT機器を操作しているようなユーザー体験が作れる
- このレベルならFirebaseは無料。要するにFirebaseは神