NatureRemoから部屋の気温を収集してgrafanaでダッシュボード表示する

## やりたいこと

スマートホームハブであるNatureRemoを購入したところ、デバイスが取得している気温（いいものを買うと湿度や照度も）をAPIで取得できるということを知りました。
そこでPythonのNatureRemoライブラリを利用して気温を取得して、PostgreSQLにデータを保存し、grafanaでダッシュボード表示させることにしました。
![nature_remo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/49babce2-71a9-4451-6c47-1cd21e68bbd3.png)

## GrafanaとpostgreSQLの準備

### コンテナ立ち上げ
Grafanaをdocker composeを利用して立ち上げます。
以下のファイルを作成して、`docker compose up -d`で起動させてください。

```yaml:docker-compose.yaml
version: "3.8"
services:
  postgres:
    image: postgres
    restart: unless-stopped
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: grafana         #DB設定　以下３行適宜変更
      POSTGRES_USER: grafana
      POSTGRES_PASSWORD: Pa55w0rd
    ports:
      - '5432:5432' #待受のポート番号を変更したい場合は「:」の左側を変更
  grafana:
    image: grafana/grafana-enterprise
    container_name: grafana
    restart: unless-stopped
    volumes:
      - 'grafana_storage:/var/lib/grafana'
    ports:
      - '3000:3000'　 #待受のポート番号を変更したい場合は「:」の左側を変更
volumes:　# データの永続化
  grafana_storage: {}
  postgres_data: {}
```

`docker compose ps`や`docker compose logs -f`などでコンテナが立ち上がったことを確認します。

### テーブル準備

psqlでpostgreSQLに接続してテーブルを作成します。`-U grafana`は`docker-compose.yaml`で指定したユーザー名を指定してください。
```bash
docker compose exec postgres psql -h localhost -U grafana
```

以下のDDLを実行します。
```SQL:DDL テーブル定義
CREATE TABLE nature_remo(
    id varchar(36),
    get_time timestamp,
    device_name varchar(100),
    temp NUMERIC(3, 1) 
);
```

テーブルが作成されていることを確認します。
```SQL:確認
\dt
----出力
           List of relations
 Schema |    Name     | Type  |  Owner  
--------+-------------+-------+---------
 public | nature_remo | table | grafana
(1 row)
---
```

## Nature Remo

プログラムと実行コンテナの作成をしていきます。
最終定期なディレクトリ構成は以下の通りです。

```bash
.
├── Dockerfile
├── docker-compose.yaml
└── src
    ├── remo_extract.py
    └── setting.py
```

### access tokenの発行

Nature Remoの設定は適切に終わっている前提です。まずはAPIを取得します。
[Nature Remo](https://home.nature.global/)にアクセスをして、メールアドレスでログインをします。
`Generate access token`をクリックしてaccsess tokenを発行します。コピーをして保存してください。

:::note warn
今回は`access token`を使ってデータの取得のみ行いますが、登録している家電のコントロールも可能です。他人に知られないように十分に注意してください。
:::

### コンテナの作成

データを取得してPostgreSQLへinsertするプログラムを動かすコンテナを作成します。


python3のイメージをベースとして、必要なpythonライブラリをインストールします。
```Dockerfile:Dockerfile
FROM python:3
USER root

RUN apt-get update
RUN apt-get -y install locales  &&\
    localedef -f UTF-8 -i ja_JP ja_JP.UTF-8

ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm

RUN pip install --upgrade pip
RUN pip install --upgrade nature-remo
RUN pip install --upgrade requests
RUN pip install --upgrade urllib3
RUN pip install --upgrade sqlalchemy
RUN pip install --upgrade psycopg2
RUN pip install --upgrade schedule
CMD ["python", "-u", "/root/src/remo_extract.py"]
```

```yaml:docker-compose.yaml
version: '3'
services:
  nature_remo:
    container_name: nature_remo
    build: .
    environment:
      - remo_token=【先ほど取得したaccess tokenに置き換え】
      - db_user=grafana
      - db_password=Pa55w0rd
      - db_server=【postgreSQLサーバーを指定します。】 #ex)192.168.1.1:5432, localshot:5432
      - db_name=grafana
      - interval_time=4 #室温を取得する時間の間隔（分）
    volumes:
      - ./src:/root/src
```

### プログラム

`src`ディレクトリに`remo_extract.py`と`setting.py`ファイルを作成します。
`setting.py`では3つのことを行なっています。
1. 環境変数の取得
DBの認証情報は、APIの情報は`docker-compose.yaml`で環境変数として設定しています。これをコンテナ内のプログラムから読み込む処理をしています。(Secretなどを利用した方が望ましいでしょうが、、、)
1. sqlalchemyのテーブル定義とエンジン作成
PostgreSQLへの接続はsqlalchemyというライブラリを利用しています。詳しい説明は他の方に任せますが、PostgreSQLへ接続するengineとDBのテーブルにマップするPythonのクラスを作成して利用します。
1. ロガーの設定
ログ出力のためのロガーを作成します。

```python:src/setting
import datetime
import os
from functools import wraps
import logging

from remo import NatureRemoAPI
from sqlalchemy import Column, create_engine
from sqlalchemy.dialects.mysql import NUMERIC, TIMESTAMP, VARCHAR
from sqlalchemy.orm import declarative_base
import sys

# ハンドラーをLoggerに追加
logger.addHandler(handler)

Base = declarative_base()
#コンテナの環境変数から設定の読み取り
API_KEY: str = os.environ.get("remo_token")
DB_USER: str = os.environ.get("db_user")
DB_PASSWORD: str = os.environ.get("db_password")
DB_SERVER: str = os.environ.get("db_server")
DB_NAME: str = os.environ.get("db_name")
INTERVAL_MINUTES: str = os.environ.get("interval_time")

api = NatureRemoAPI(API_KEY)
tz_jst_name = datetime.timezone(datetime.timedelta(hours=9), name="JST")
engine = create_engine(f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_SERVER}/{DB_NAME}")

#　テーブルクラスの定義
class NATURE_REMO(Base):
    __tablename__ = "nature_remo"
    id = Column(VARCHAR(36), primary_key=True)
    get_time = Column(TIMESTAMP, primary_key=True)
    device_name = Column(VARCHAR(36))
    temp = Column(NUMERIC(2, 1))

# loggerの定義
logger = logging.getLogger('my_logger')
logger.setLevel(logging.DEBUG)

# 標準出力へのハンドラーを作成
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)

# フォーマットを設定
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)

def my_log(logger):
    def decorator_fn(fn):
        @wraps(fn)
        def wrap_fn(*args, **kwargs):
            #local_args = locals()
            logger.debug(f"{fn.__name__} WAS CALLED")
            return_val = fn(*args, **kwargs)
            logger.debug(f"{fn.__name__} FINISHED")
            return return_val

        return wrap_fn

    return decorator_fn

```

一定間隔で室温を取得してDBへinsertするプログラムです。デバッグするときに切れ目がわかりにくいので、プログラムを最初に呼び出した時にRemo extractという文字列をアスキーアートで出力するようにしています。不要の場合は`self.print_logo()`をコメントアウトして下さい。
```python:src/remo_extract.py
from setting import NATURE_REMO, INTERVAL_MINUTES, Base, api, engine, logger, my_log, tz_jst_name
from sqlalchemy.orm import Session
import schedule
import time

class REMO_EX:
    def __init__(self) -> None:
        Base.metadata.create_all(engine)
        self.logo_ascii = """
         .(.dWHHQ,.
            WN,  dM[
            -Hb .mH%
           .WMNNH#^ .WMMm, (TNa..dNa.dmJ.   ..,.
          .dgf?TN,  WMHMH%   ,MMHHMMNMMMl .HH#W"BQ,
         .WM%   ,WQ-JMe..    (NNFJMNHUHH} ,MH    .MN
                      .7"=  ,MNM!_TB= TMN.. TNggHHM=

                                    .      (m,                                                    
                 .dgMMMg,   .,    .XqR.    ,Hb         ....                         dm.
                dqHY!  (MN.  ,Ha..mgf  (WNMMMMMMM$ zm-WMB"?Mx  ..,       +MMa,      dMl..
               .mmP  .dgg@     TMNgY       .HM     ,MMHl    ?'.qY?Mg,   J@%  Tb .(kMMMY?7
                4Mb7WMMY^     .XMMb        .@M      MNP      .gP  (M#   MM          W#:
                 (Wa......   .gg@ ?H,      ,H@      MM]      ,@l .XMM,  MN          MM
                    _'''9"^   ?=           MMl      dMF      ,H).WK' ?''TM,        .##
                                                     -^       TMHY       ?M,....   .H#
                                                                                   ,H@
                                                                                   JMF
"""
        self.print_logo()

    @my_log(logger)
    def print_logo(self) -> None:
        logger.info(self.logo_ascii)

    @my_log(logger)
    def fetch_devices_info(self):
        devices = api.get_devices()
        return devices

    @staticmethod
    @my_log(logger)
    def make_devices(device):
        id = device.id
        get_time = device.newest_events["te"].created_at.astimezone(tz_jst_name)
        name = device.name
        temp = device.newest_events["te"].val
        #logger.debug(id, get_time, name, temp)
        record = NATURE_REMO(id=id, get_time=get_time, device_name=name, temp=temp)
        return record

    @my_log(logger)
    def run(self):
        devices = self.fetch_devices_info()
        with Session(engine) as session:
            for device in devices:
                record = self.make_devices(device)
                session.add(record)
            session.commit()


def __main__():
    remo_ex = REMO_EX()
    remo_ex.run()
    schedule.every(INTERVAL_MINUTES).minutes.do(remo_ex.run)
    while True:
        schedule.run_pending()
        time.sleep(1)


if __name__ == "__main__":
    __main__()
```

### コンテナの立ち上げ

```bash
docker compose -d --build
```

`docker compose ps`や`docker compose logs -f`などでコンテナが立ち上がったことを確認します。

## Grafanaの設定

![Grafanaログイン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/ff1dc37b-c09d-6980-e966-78a62e4cd518.png)

usernameは`admin`、Passwordも`admin`です。ログインするとPasswordの更新を求められると思うので更新してください。

基本的には案内に従って、設定をします。

### PostgreSQLの登録

![Add data source.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/23256451-a647-000c-7b17-7ea56d859020.png)
![PostgreSQL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/84bf9c37-58bb-5ca1-e273-eee37399bcfa.png)

PostgreSQLを選択します。

`docker-compose.yaml`で設定した値を入力していきます。TLS/SSLは今回設定していないので`disable`にしてください。
最後に`Save & test`をクリックして正しく接続できることを確認してください。

|docker-compose.yaml|Grafana|example|
|---|---|---|
|Host URL|db_server|192.168.1.1|
|Database name|db_name|grafana|
|Username|db_user|grafana|
|Password|db_password|Pa55w0rd|

![connection.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/43f8e652-8722-80b7-ef36-0ccf854af3af.png)

:::note info
PostgreSQLのコンテナを立ち上げたサーバーでファイアウォールが有効な場合はポート`5432`を解放する必要があります。

```bash
sudo ufw status
```
などで状態を確認して解放を行なってください。
:::

### dashboardの設定

![dashboard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/bdd6f506-d4c4-477c-fd65-d8cabade3ceb.png)

`Add visualization`をクリックしてデータの追加を行います。データソースとして先ほど登録したPostgreSQLを選択する。

![PostgreSQL.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/61883150-f0d6-b7bb-e4dc-c8530ce3e17f.png)

`Code`を選択して以下のSQLを入力します。`Add query`をクリックしてBを追加します。
おそらく初めから`Time series`が設定されていると思いますが、異なった場合は設定してください。`リビング`、`寝室`にはNature Remoの名前を設定してください。
![スクリーンショット 2024-01-28 21.24.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/5bd00972-d5f1-849a-b605-04ebdc93fbdf.png)

```SQL
---A
SELECT 
  $__timeGroupAlias("get_time",'5m'),
  AVG(temp) AS "リビング"
  FROM nature_remo
  where device_name = 'リビング'
  GROUP BY device_name, time
-----B
SELECT 
  $__timeGroupAlias("get_time",'5m'),
  AVG(temp) AS "寝室"
  FROM nature_remo
  where device_name = '寝室'
  GROUP BY device_name, time
```

`save`をするとDashboardを確認することができます。

![グラフ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/72c9730d-d9c6-ca7f-9121-ac0dd36c9ee1.png)

エアコンをつけた瞬間と消した瞬間がよくわかります。

省略しますが、`get_time`の最大をとることで現在の気温も確認できます。
グラフの寝室の気温のデータが欠損していますが、Nature Remo自体が一定間隔でしか気温を取得していないので、４分間隔の取得をおこなったところ前回分のデータが取得されたのだと思います。
![ダッシュボード.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308715/182430f3-b97b-4ce7-71ee-6220eb947352.png)