SwitchBotのBLEでデータを取ってみた


この記事は[ニフクラ](https://www.nifcloud.com/) 等を提供している、[富士通クラウドテクノロジーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fjct) の 10 日目の記事です。

本日は、[SwitchBot](https://www.switchbot.jp)の[BLE（Bluetooth Low Energy）](https://ja.wikipedia.org/wiki/Bluetooth_Low_Energy)に関しての記事です。

冒頭は我が家のホームダッシュボートに関して紹介します。ダッシュボード実装の中でSwitchBotのBLEに関して困ったな、という部分があったので、それを記事に残しておきます。

ここ数年間、ホームダッシュボードは試行錯誤してきてやっと着地できそうなところまで辿り着いたと思っています。まだまだ改良する余地は残っているのですこしずつ取り組んでいくつもりです。

# まずは、作ったホームダッシュボードを紹介します
もう新鮮さはないと思いますが、Grafana+influxdbで温度・湿度・電力使用量を主としたものを表示しています。

上段：　時計、瞬間電力量、室外（気温、湿度、[不快指数](https://ja.wikipedia.org/wiki/不快指数)）、室内（室温、湿度、不快指数）
中段：　前述したもののグラフ
中段No dataのパネル：　最寄りのバス停から駅までの時刻表（写真1枚目はバスが運行していない時間帯）
下段：　インターネット回線のスピード

ダッシュボードは、よく見える場所に設置することが大事だと思うので、我が家ではリビングを定位置としました。21インチのモニターを壁掛けしています。画面出力と、Grafanaとinfluxdbの稼働は、ラズパイ2Bで担っています。

![リビング設置.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2323028/1322ecb1-9fec-1a87-532e-59d61e2543db.jpeg)

![ダッシュボード.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2323028/21ac51fc-522e-56c6-4f65-5e8eb7e181a0.png)

# ホームダッシュボードで使用しているセンサー
 1. [SwitchBot 温湿度計プラス](https://www.switchbot.jp/products/switchbot-meter-plus)　3台
 2. [SwitchBot 防水温湿度計](https://www.switchbot.jp/products/switchbot-indoor-outdoor-meter) 1台
 3. [SwitchBot ハブ2](https://www.switchbot.jp/products/switchbot-hub2)　1台
 4. [Nature Remo E lite](https://shop.nature.global/products/nature-remo-e-lite)　1台

# これまでにやってみたこと
 1. [GAS](https://workspace.google.co.jp/intl/ja/products/apps-script/)でAPI発行して、[Googleスプレッドシート](https://www.google.com/intl/ja_jp/sheets/about/)に書き込み。ラズパイ上のGrafanaで表示。
 2. GASとスプレッドシートはそのままに、[Grafana Cloud](https://grafana.com/products/cloud/?plcmt=footer)で表示。
 3. ラズパイでAPI発行して、influxdbに保存。ラズパイ上のGrafanaで表示。
 4. SwitchbotはBLEでデータを読み取って、RemoはAPI発行して、influxdbに保存。ラズパイ上のGrafanaで表示。

GoogleスプレッドシートはGASの実行間隔を短くしてデータを大量に蓄積すると、エラー率70%超となって機能しなくなり断念。（解消方法があったのかもしれないですが、意気消沈しました）
Grafana Cloudは無料版でGoogleスプレッドシートとの連携ができないことが発覚し、断念。（これも意気消沈）
API発行は宅内に設置している装置のデータ取得にあたってインターネットを経由するというのが釈然とせず、BLEの仕様を公開しているSwitchBotは、API発行からBLEへ切り替えました。

ということで現在は「4.」に落ち着いています。

# 本題　SwitchBotのBLEでデータを取ってみた
SwitchBotはBLEの仕様が公開されていると言いつつも、「ハブ2」については何故か、BLEの仕様が公開されておらず、取得したパケット内のどこに何のデータが載っているのか分からないという状態でした。困りました。なんとなく、同時期くらいに発売された「防水温湿度計」と同じかなと推測するも、違っていました。試行錯誤しつつも、なんとかデータ取得できたので記録として残しておきます。同じく困っている人の助けになれば幸いです。

# どのデータを取れば良いのか
細かい仕様は、[GitHub](https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/meter.md)を参照ください。

各製品で温湿度、バッテリー情報が格納されている場所は下表の通りでした。
ハブ2だけではなく、温湿度計プラスと防水温湿度計についても書いておきます。

| 製品名 | 温湿度 | バッテリー |
|:-:|:-:|:-:|
| ハブ2 | Manufactuarer（adtype:255） | -（常時給電） |
| 温湿度計プラス | ServiceData（adtype:22） | ServiceData（adtype:22） |
| 防水温湿度計 | Manufactuarer（adtype:255） | ServiceData（adtype:22） |

## ハブ2
Manufactuarerのデータを取得します。
```python
if(adtype == 255):
    madata = binascii.unhexlify(value[16:])
```
### 温度
温度は計算する必要があります。Manufactuarerを参照します。
　[8]の最上位ビット： 0の場合0℃以下、1の場合1℃以上
　[8]の下7桁分のビット：温度の整数箇所
　[7]の下4桁分のビット：温度の小数箇所（1/10する）
```python
isTemperatureAboveFreezing = madata[8] & 0b10000000
temperature = (madata[7] & 0b00001111) / 10 + (madata[8] & 0b01111111)
if not isTemperatureAboveFreezing:
    temperature = -temperature
```
### 湿度
湿度は計算不要です。[9]の下7桁分のビットです。Manufactuarerを参照します。
```python
humidity = madata[9] & 0b01111111
```
### バッテリー
バッテリーは搭載されていないのでデータがありません。

## 温湿度計プラス
Servicedataのデータを取得します。
```python
if(adtype == 22):
    servicedata = binascii.unhexlify(value[4:])
```

### 温度
温度は同じように計算する必要があります。
　[4]の最上位ビット： 0の場合0℃以下、1の場合1℃以上
　[4]の下7桁分のビット：温度の整数箇所
　[3]の下4桁分のビット：温度の小数箇所（1/10する）

```python
temperature = ( servicedata[3] & 0b00001111 ) / 10 + ( servicedata[4] & 0b01111111 )
isTemperatureAboveFreezing = servicedata[4] & 0b10000000
if not isTemperatureAboveFreezing:
    temperature = -temperature
```
### 湿度
湿度は計算不要です。[5]の下7桁分のビットです。
```python
humidity = servicedata[5] & 0b01111111
```

### バッテリー
バッテリーも計算不要です。[2]の下7桁分のビットです。
```python
battery = servicedata[2] & 0b01111111
```

## 防水温湿度計
ServicedataのデータとManufactuarerのデータを取得します。
```python
if(adtype == 22):
    servicedata = binascii.unhexlify(value[4:])
if(adtype == 255):
    madata = binascii.unhexlify(value[16:])
```

### 温度
温度は同じように計算する必要があります。Manufactuarerを参照します。
　[3]の最上位ビット： 0の場合0℃以下、1の場合1℃以上
　[3]の下7桁分のビット：温度の整数箇所
　[2]の下4桁分のビット：温度の小数箇所（1/10する）
```python
temperature = (madata[2] & 0b00001111) / 10 + (madata[3] & 0b01111111)
isTemperatureAboveFreezing = madata[3] & 0b10000000
if not isTemperatureAboveFreezing:
    temperature = -temperature
```
### 湿度
湿度は計算不要です。[4]の下7桁分のビットです。Manufactuarerを参照します。
```python
humidity = madata[4] & 0b01111111
```

### バッテリー
バッテリーも計算不要です。[2]の下7桁分のビットです。ServiceDataを参照します。
```python
battery = servicedata[2] & 0b01111111
```

## スクリプト
冗長なのと洗練されていないところがありますが、ご容赦ください。
下記は必要に応じて書き直してください。
- [macアドレス（小文字、:区切り）]
- [機器名]
- [IPアドレス]
- [ポート]
- [ユーザー名]
- [パスワード]
- [データベース名]

```python
#switchbot_lib.py

import binascii
from bluepy.btle import Scanner,DefaultDelegate
import os
from datetime import datetime,timedelta,timezone
import influxdb
import json
import urllib.request
import requests

DEVICE_NAMES = {
    "[macアドレス（小文字、:区切り）]" : "[機器名]",
    "[macアドレス（小文字、:区切り）]" : "[機器名]",
    "[macアドレス（小文字、:区切り）]" : "[機器名]"
}

#influxdb書き込み
def InsertRecord(dev_name,battery,temp,humid):
    client=influxdb.InfluxDBClient('[IPアドレス]','[ポート]','[ユーザー名]','[パスワード]','[データベース名]')
    datestr = datetime.now(timezone(timedelta(hours=+0), 'GMT')).strftime('%Y-%m-%dT%H:%M:%SZ')
    points = [
            {
                "measurement":dev_name,
                "tags": {
                    "node": "switchbot"
                    },
                "time": datestr,
                "fields": {
                    "battery": float(battery),
                    "temp": float(temp),
                    "humid": float(humid)
                    }
                }
            ]
    client.write_points(points)
    client.close()
    
#温湿度計プラス
class ScanDelegate( DefaultDelegate ):
  def __init__( self,macaddr ):
    DefaultDelegate.__init__( self )
    self.macaddr=macaddr

  def handleDiscovery( self, dev, isNewDev, isNewData ):
    if dev.addr == self.macaddr:
      for ( adtype, desc, value ) in dev.getScanData():
        if ( adtype == 22 ):
          servicedata = binascii.unhexlify( value[4:] )

          battery = servicedata[2] & 0b01111111
          temperature = ( servicedata[3] & 0b00001111 ) / 10 + ( servicedata[4] & 0b01111111 )
          isTemperatureAboveFreezing = servicedata[4] & 0b10000000
          if not isTemperatureAboveFreezing:
            temperature = -temperature
          humidity = servicedata[5] & 0b01111111
          InsertRecord(DEVICE_NAMES[dev.addr],battery,temperature,humidity)
          exit()
          
#hub2、防水温湿度計
class ScanDelegateNext( DefaultDelegate ):
  def __init__( self,macaddr,switch ):
    DefaultDelegate.__init__( self )
    self.macaddr=macaddr
    self.switch=switch

  def handleDiscovery( self, dev, isNewDev, isNewData ):
    if dev.addr == self.macaddr:
      for ( adtype, desc, value ) in dev.getScanData():
        if(adtype == 255):
          madata = binascii.unhexlify(value[16:])
          if(self.switch == "hub"):
            humidity = madata[9] & 0b01111111
            temperature = (madata[7] & 0b00001111) / 10 + (madata[8] & 0b01111111)
            isTemperatureAboveFreezing = madata[8] & 0b10000000
            if not isTemperatureAboveFreezing:
              temperature = -temperature
          else:
            humidity = madata[4] & 0b01111111
            temperature = (madata[2] & 0b00001111) / 10 + (madata[3] & 0b01111111)
            isTemperatureAboveFreezing = madata[3] & 0b10000000
            if not isTemperatureAboveFreezing:
              temperature = -temperature
            continue
        if(adtype == 22):
          servicedata = binascii.unhexlify(value[4:])
          if(self.switch == "hub"):
            battery = 100
          else:
            battery = servicedata[2] & 0b01111111
          InsertRecord(DEVICE_NAMES[dev.addr],battery,temperature,humidity)
          exit()
```
```python
#ハブ2
#switchbot_hub.py

import binascii
from switchbot_lib import ScanDelegate, ScanDelegateNext
from bluepy.btle import Scanner, DefaultDelegate

scanner = Scanner().withDelegate( ScanDelegateNext("[macアドレス（小文字、:区切り）]","hub"))
scanner.scan(10)
```
```python
#温湿度計プラス
#switchbot_meter.py

import binascii
from switchbot_lib import ScanDelegate, ScanDelegateNext
from bluepy.btle import Scanner, DefaultDelegate

scanner = Scanner().withDelegate( ScanDelegate("[macアドレス（小文字、:区切り）]"))
scanner.scan(10)
```

```python
#防水温湿度計
#switchbot_outdoor.py

import binascii
from switchbot_lib import ScanDelegate, ScanDelegateNext
from bluepy.btle import Scanner, DefaultDelegate

scanner = Scanner().withDelegate( ScanDelegateNext("[macアドレス（小文字、:区切り）]","outdoor"))
scanner.scan(10)
```

# 最後に
ホームダッシュボードは、まだまだ改良する余地は残っているのですこしずつ取り組んでいくつもりです。

この記事は[富士通クラウドテクノロジーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/fjct) の 10 日目の記事でした。

明日は[ks2022](https://qiita.com/ks2022)さんの「[アラート対応の自動化](https://qiita.com/ks2022/items/)」です。
私としてはアラート対応は関わりが多い業務のため、どういった記事になるのか楽しみです！

## おまけ
### 不在時や就寝時のモニター電源ON/OFF問題
ホームダッシュボードで気になるのは常時表示されているモニターの電気代かと思います。とはいえ、都度、モニターの電源をON/OFFするのは面倒です。
不在時や就寝時のモニター電源ON/OFF問題については、[SwitchBotの人感センサー](https://www.switchbot.jp/products/switchbot-motion-sensor)と[プラグミニ](https://www.switchbot.jp/products/switchbot-plug-mini)の組み合わせで対処しています。

- 人感センサーで動体検知：　プラグミニでモニターへ通電
- 人感センサーで30分間連続で動体検知しない：　プラグミニでモニターの電源を遮断
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2323028/855e98c0-d3da-591b-19ac-af306e21fbb4.jpeg" width=50%>