【夢】スマートホーム化を始めた ~寝起き編~

# はじめに
「スマートホーム」っていいですよね。
言葉ひとつで複数の電子機器をまとめて操作したり、居住者の行動パターンを学習して自動的に家電を制御したり...。
そんなスマートな暮らしをしてみたい。ので作ってみます。
本記事は「寝起き編」ということで、続編があるかもしれないです。

※自分の調べた範囲では「スマートホーム」という用語の明確な定義が見つかりませんでした。本記事では、「自動・遠隔制御が可能な家電を導入した居住空間」の意で使用しております。

# やりたいこと
記念すべきスマートホーム化への第一歩として、**起床時にアラーム+太陽光で気分よくスッキリ目が覚める**ような仕組みを作ろうと思います。
自分はアラームさえセットしていれば起きれるタイプなのですが、それでも眠気と激闘を繰り広げHPゲージを真っ赤にしつつ起床しております。朝スッキリと目が覚め、HPゲージが緑のまま一日を始めることができたなら、最高の一日になる気がしませんか？最高に決まっています。

ちなみに後述の[ガジェット](#ガジェット)を使えば、専用のアプリから「指定の曜日、時刻にカーテンを開く」といった設定が可能です。ただ人間の（自分の？）生活リズムは不規則なもので、「休日だけど朝用事があるから早く起きないと...」とか「今日はもう少し寝ていたい...」といった状況が意外とあり、その都度設定を変更するのは面倒です。
そういった状況でも**柔軟に、求めているタイミングでカーテンを開けてくれる**システムの構築を目指します。
ㅤ
# 概要
実現に際し使用したモノや技術、システムの全体像をざっくり紹介します。
ソースコードを公開しているので、良かったら[こちら](https://github.com/snoozers/lazy-home)からどうぞ。
## ガジェット
ハードはこの手のシステムでお馴染みのSwitchBot製のものを購入しました。いつもお世話になっております。APIが公開されていてGoodです。
- [SwitchBotカーテン](https://www.switchbot.jp/products/switchbot-curtain)
    - カーテンレールに設置してカーテンを開閉してくれる
    - 専用のスマホアプリから、曜日と時刻を指定してカーテンの開閉ができる
- [SwitchBotハブミニ](https://www.switchbot.jp/collections/all/products/switchbot-hub-mini)
    - wifi経由でSwitchBot製ガジェットを操作するために必要
    - いわゆるスマートリモコンとしての機能もあり、SwitchBot製品を一つも持っていなくても買う価値アリ

## 使用技術
- AWS
    - SAM（CloudFormation）
    - Lambda
    - API Gateway
    - StepFunctions
    - CloudWatch
- 言語
    - python3.9
- ソースリポジトリ
    - Github
- CI/CD
    - Github Actions
ㅤ
## その他
- [Sleep Meister](https://apps.apple.com/jp/app/sleep-meister-%E7%9D%A1%E7%9C%A0%E3%82%B5%E3%82%A4%E3%82%AF%E3%83%AB%E3%82%A2%E3%83%A9%E3%83%BC%E3%83%A0lite/id599456380)
    - 自分が愛用している睡眠記録&アラームアプリ
    - このアプリが**本システムの要**になるので特徴的な機能を以下に挙げます
        - 睡眠の深さを計測可能で、`アラームの設定時刻-30m ~ アラームの設定時刻`の間で一番眠りが浅いタイミングにアラームを鳴らしてくれる
        - **アラーム設定/入眠/中途覚醒/起床のタイミングでTwitterにツイートを投稿できる**
        - ツイートの内容に、設定したアラームの時刻や実際の起床時刻などの**可変値を埋め込むことができる**
- Twitter AccountActivityAPI
    - Twitterアカウントのアクティビティを指定URLにPOSTできる
    - アラームの設定時刻を取得するために使用
ㅤ
## システム全体像
システムの構成はこのようになりました。説明のために①~③にグルーピングしています。
AWSリソースのデプロイにはSAMを使用しました。
![](https://github.com/snoozers/lazy-home/blob/master/static/public/images/system-diagram-sequence.drawio.png?raw=true)ㅤ

## システムの説明

[システム全体像](#システム全体像)の各部分について、順番に説明していきます。
ㅤ
### ① 起床時刻の設定
![](https://github.com/snoozers/lazy-home/blob/master/static/public/images/system-diagram-sequence-1.drawio.png?raw=true)
ㅤ
#### アラームの設定時刻を自動でツイートする設定
[概要](#概要)で触れたようにSleepMeisterでは、アラーム設定時にあらかじめ連携済みのTwitterアカウントでツイートを投稿することができます。投稿する文章を任意に変更することができ、文章内に「アラーム設定時刻」を変数として埋め込むことができます。
自分は下記のような文章を設定しました。
```text
アラーム設定 xx:xx - yy:yy

#就寝
```
これで`xx:xx - yy:yy`が時刻に置き換わってツイートされます。
![sleepmeister.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/517047/a06b83c4-affb-5985-92c7-cac44e4aa352.jpeg)
ㅤ
#### AccountActivityAPIでツイートを取得
アラームの設定時刻をツイートすることができたので、投稿したツイートを取得する仕組みを作ります。
案としてまず第一にポーリングしてツイートを取得することを思いつきましたが、無駄な処理が発生するのは明らかでした。
他に良い方法がないか調べていたところ、TwitterAccountActivityAPIというTwitter公式のWebhookを見つけたのでこれを使用していきます。

AccountActivityAPIは[開発者アカウントを開設](https://developer.twitter.com/en/portal/dashboard)して、開発者ポータルから「Elevated」のアクセス権を申請して承認されると（自分は申請と同時に承認されました）使用できるようになります。申請内容は英語で書く必要がありました。

申請承認後にWebhookの設定が必要になります。設定は[こちら](https://qiita.com/sbtseiji/items/7957de5db0987d9a6891#webhook%E3%81%AE%E7%99%BB%E9%8C%B2)を参考にさせていただきました。
下記2つのエンドポイントが必要なのでSAMで作成します。
- CRCリクエスト時に応答トークンを返却するエンドポイント
- Webhookに登録するエンドポイント
```template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: SAM Template for lazy-home

Globals:
  Function:
    Timeout: 10
    Handler: app.lambda_handler
    Runtime: python3.9
    Architectures:
      - x86_64
    Tags:
      Project: lazy-home

Parameters:
  TwitterConsumerSecret:
    Type: String
    Description: twitter consumer secret

Resources:
  TwitterDispatcher:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/dispatcher/twitter/
      Events:
        Controller:
          Type: Api
          Properties:
            Path: /dispatcher/twitter
            Method: post
      Environment:
        Variables:
          OPEN_CURTAINS_STATE_MACHINE_ARN: !Ref OpenCurtainsStateMachineArn
      FunctionName: TwitterDispatcher
      Role: !GetAtt LambdaFunctionRole.Arn

  TwitterDispatcherLogGroup:
    Type: AWS::Logs::LogGroup
    DependsOn:
      - TwitterDispatcher
    Properties:
      LogGroupName: !Sub /aws/lambda/${TwitterDispatcher}
      RetentionInDays: 7
      Tags:
        - Key: Project
          Value: lazy-home

  TwitterChallengeResponseCheck:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/dispatcher/twitter/crc
      Events:
        Controller:
          Type: Api
          Properties:
            Path: /dispatcher/twitter
            Method: get
      Environment:
        Variables:
          TWITTER_CONSUMER_SECRET: !Ref TwitterConsumerSecret
      FunctionName: TwitterChallengeResponseCheck

  TwitterChallengeResponseCheckLogGroup:
    Type: AWS::Logs::LogGroup
    DependsOn:
      - TwitterChallengeResponseCheck
    Properties:
      LogGroupName: !Sub /aws/lambda/${TwitterChallengeResponseCheck}
      RetentionInDays: 1
      Tags:
        - Key: Project
          Value: lazy-home

  LambdaFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSStepFunctionsFullAccess
      Tags:
        - Key: Project
          Value: lazy-home
```


CRCの応答トークンを返却する処理は、公式ドキュメントに[サンプルコード](https://developer.twitter.com/en/docs/twitter-api/enterprise/account-activity-api/guides/securing-webhooks)があったのでそちらを参考に実装します。

```src/dispatcher/twitter/crc/app.py
import base64
import hashlib
import hmac
import json
from http import HTTPStatus
import os

def lambda_handler(event, context):
    sha256_hash_digest = hmac.new(
        os.environ['TWITTER_CONSUMER_SECRET'].encode(),
        msg=event['queryStringParameters']['crc_token'].encode(),
        digestmod=hashlib.sha256
    ).digest()

    return {
        'statusCode': HTTPStatus.OK,
        'body': json.dumps({
            'response_token': 'sha256=' + base64.b64encode(sha256_hash_digest).decode()
        }),
    }
```

余談ですが、自分は申請の仕方が分からず時間を食いました。というのも[公式の開発者ドキュメント](https://developer.twitter.com/en/docs/twitter-api/premium/account-activity-api/guides/getting-started-with-webhooks)を参照していてもリンク先がなかったり、AccountActivityAPIの申請ボタンをクリックするとなぜかEnterprise版の申し込みフォームに飛ばされたり、ググって出てくる記事とはUIが異なっていたりといった状態でした。結局開発者ポータルしらみつぶしに調べて解決しました。

### ② 起床時刻の1分前にSwitchBotカーテンを開く
![](https://github.com/snoozers/lazy-home/blob/master/static/public/images/system-diagram-sequence-2.drawio.png?raw=true)
ツイートから起床時刻を取得できるようになったので、次は「指定時刻にSwitchBotカーテンを開く」ことができれば完成です。
定時処理となるとCloudWatch Eventsが真っ先に思いついきましたがcronの実行タイミングに微妙な誤差があり、他の方法を探ることに。調べてみるとAWSのStepFunctionsというサービスで、かなりの精度で指定日時に処理を走らせることが可能な模様。自分の用途ではコストも発生しなさそうで、使ったこともなかったので即採用しました。

ㅤ
#### デプロイ自動化
実装に入る前に、いちいち手動でデプロイするのも面倒なのでGithubActionsでデプロイできるようにワークフローを組んでおきます。デプロイするたびに`S3`に溜まっていくSAMのビルドアーティファクトも邪魔なので、デプロイ前に過去のアーティファクトを全て消すようにします。
```.github/workflows/deployer.yml
name: Deploy application

on:
  push:
    branches:
      - master

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
      - uses: aws-actions/setup-sam@v1
      - uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      # clean up artifact
      - run: aws s3 rm s3://${{ secrets.S3_ARTIFACT_BUCKET_NAME }} --recursive --quiet

      # sam build
      - run: sam build --use-container

      # Run Unit tests- Specify unit tests here
      - run : pip install -r tests/requirements.txt
      - run: pytest -s -k test_lambda_handler

      # sam deploy
      - run: >
          sam deploy
          --no-confirm-changeset
          --no-fail-on-empty-changeset
          --stack-name lazy-home
          --s3-bucket lazy-home-build-source
          --capabilities CAPABILITY_IAM
          --region ap-northeast-1
          --parameter-overrides \
            SwitchBotOpenToken=${{ secrets.SWITCH_BOT_OPEN_TOKEN }} \
            TwitterConsumerSecret=${{ secrets.TWITTER_CONSUMER_SECRET }} \
            OpenCurtainsStateMachineArn=${{ secrets.OPEN_CURTAINS_STATE_MACHINE_ARN }}
```
これでリモートにpushしたタイミングで自動デプロイされるようになりました。

#### リソースを作成
StepFunctionsのステートマシンとSwitchBotAPIを叩くためのLambda関数が必要なので、SAMのテンプレートに追加します。
外部サービスとの連携周りの処理は共通化しておきたかったのでLayerに切り出しておきます。
```template.yaml
Globals:
  Function:
    Timeout: 10
    Handler: app.lambda_handler
    Runtime: python3.9
    Architectures:
      - x86_64
    Layers:
      - !Ref LazyHomeLayer
    Environment:
      Variables:
        SWITCH_BOT_OPEN_TOKEN: !Ref SwitchBotOpenToken
    Tags:
      Project: lazy-home

Parameters:
  SwitchBotOpenToken:
    Type: String
  OpenCurtainsStateMachineArn:
    Type: String

Resources:
  LazyHomeLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      ContentUri: layer/
      CompatibleRuntimes:
        - python3.9
    Metadata:
      BuildMethod: python3.9

  OpenCurtainsFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: src/routine/morning/open_curtains/
      FunctionName: OpenCurtains
      Role: !GetAtt LambdaFunctionRole.Arn

  OpenCurtainsFuncLogGroup:
    Type: AWS::Logs::LogGroup
    DependsOn:
      - OpenCurtainsFunction
    Properties:
      LogGroupName: !Sub /aws/lambda/${OpenCurtainsFunction}
      RetentionInDays: 7
      Tags:
        - Key: Project
          Value: lazy-home

  OpenCurtainsStateMachine:
    Type: AWS::Serverless::StateMachine
    Properties:
      Name: OpenCurtainsStateMachine
      Type: STANDARD
      DefinitionUri: statemachine/open_curtains.asl.yaml
      DefinitionSubstitutions:
        OpenCurtainsFunctionArn: !GetAtt OpenCurtainsFunction.Arn
      Role: !GetAtt StateMachineRole.Arn
      Logging:
        Level: ALL
        IncludeExecutionData: True
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt OpenCurtainsStateMachineLogGroup.Arn
      Tags:
        Project: lazy-home

  OpenCurtainsStateMachineLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName : /aws/states/OpenCurtainsStateMachine
      RetentionInDays: 7
      Tags:
        - Key: Project
          Value: lazy-home

  StateMachineRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - states.amazonaws.com
            Action:
              - sts:AssumeRole
      Description: >-
        Permissions required to execute step functions
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
        - arn:aws:iam::aws:policy/service-role/AWSLambdaRole
      Tags:
        - Key: Project
          Value: lazy-home
```
StepFunctionsのステートマシンも定義していきます。
ステートマシンが実行されると、`open_curtains_at`に指定された日時まで待機し、時間になったら「Good morning!!」ステートに遷移します。「Good morning!!」ステートではLambda関数を実行するようにしておきます。
```statemachine/open_curtains.asl.yaml
Comment: >-
  State machine that automatically opens the curtains
  according to the time set for the morning alarm
StartAt: Wait until time to open curtains
States:
  Wait until time to open curtains:
      Type: Wait
      TimestampPath: $.open_curtains_at
      Next: Good morning!!
  Good morning!!:
    Comment: カーテンを開く
    Type: Task
    Resource: ${OpenCurtainsFunctionArn}
    End: true
```

#### ステートマシン実行
`OpenCurtainsStateMachine`の`open_curtains_at`に、ツイートから取得した`起床時刻-1m`を設定してステートマシンを実行するようにします。（アラームが鳴る1分前にはカーテンが開いて欲しい）
ステートマシンの`Wait`タイプのステートにタイムスタンプを指定する場合、フォーマットは`ISO 8601`の`RFC 3339`プロファイルに従う必要があるようです。
```src/dispatcher/twitter/app.py
from datetime import datetime, timezone, timedelta
from http import HTTPStatus
import json
import os
import re
from time import strftime
import boto3

def lambda_handler(event:dict, context:dict) -> dict:
    tweet = json.loads(event['body'])['tweet_create_events'][0]
    hashtags = [hashtag['text'] for hashtag in tweet['entities']['hashtags']]

    if '就寝' in hashtags:
        execute_open_curtains_state_machine(get_utc_open_curtains_at(tweet))

    return {
        'statusCode': HTTPStatus.OK
    }

def get_utc_open_curtains_at(tweet:dict) -> datetime:
    # 設定した起床時刻のfromを取得する
    jst_time = re.search(r'アラーム設定 (\d{1,2}:\d{2}) - \d{1,2}:\d{2}', tweet['text']).groups()[0]
    jst_now = datetime.now(timezone(timedelta(hours=9)))
    # 就寝時刻が午前の場合、起床日が同日になる
    jst_date = jst_now.strftime('%Y-%m-%d') if jst_now.strftime('%p') == 'AM' else (jst_now + timedelta(days=1)).strftime('%Y-%m-%d')
    jst_datetime = datetime.strptime(jst_date + ' ' + jst_time + '+0900', '%Y-%m-%d %H:%M%z', )

    # アラームが鳴る1分前にカーテンを開けたい
    jst_open_curtains_at= jst_datetime + timedelta(minutes=-1)

    return jst_open_curtains_at.astimezone(timezone.utc)

def execute_open_curtains_state_machine(open_curtains_at: datetime) -> None:
    client = boto3.client('stepfunctions')
    # 実行中のOpenCurtainsステートマシーンがある場合停止して新たに実行
    running = client.list_executions(
        stateMachineArn=os.environ['OPEN_CURTAINS_STATE_MACHINE_ARN'],
        statusFilter='RUNNING'
    )
    for execution in running['executions']:
        client.stop_execution(executionArn=execution['executionArn'])
    client.start_execution(
        stateMachineArn=os.environ['OPEN_CURTAINS_STATE_MACHINE_ARN'],
        # StepFunctionsのWaitに指定可能なフォーマットに変更
        input=json.dumps({
            'open_curtains_at': open_curtains_at.strftime('%Y-%m-%dT%H:%M:%SZ')
        })
    )
```
ㅤ
#### カーテンを開く（SwitchBotAPI実行）
「Good morning!!」ステートに遷移した時にSwitchBotカーテンを開くようにします。[SwitchBotのガジェットを操作するためのAPI](https://github.com/OpenWonderLabs/SwitchBotAPI)が公開されているので、ドキュメントと睨めっこしながら実装します。
```src/routine/morning/open_curtains/app.py
from switchbot import LivingCurtains

def lambda_handler(event, context):
    LivingCurtains().open()
```
```layer/switchbot.py
import json
import os
import requests

BASE_END_POINT = 'https://api.switch-bot.com'
devices = requests.get(
    url=BASE_END_POINT + '/v1.0/devices',
    headers={
        'Authorization': os.environ['SWITCH_BOT_OPEN_TOKEN']
    }
).json()['body']

class LivingCurtains():
    def __init__(self):
        for k, v in enumerate(devices['deviceList']):
            if v['deviceName'] == 'カーテン':
                key = k
                break
        self.__devices_ids = devices['deviceList'][key]['curtainDevicesIds']

    def open(self):
        for device_id in self.__devices_ids:
            requests.post(
                url='%s/v1.0/devices/%s/commands' % (BASE_END_POINT, device_id),
                headers={
                    'Authorization': os.environ['SWITCH_BOT_OPEN_TOKEN'],
                    'Content-Type': 'application/json; charset=utf8',
                },
                data=json.dumps({
                    'command': 'turnOn',
                    'parameter': 'default',
                    'commandType': 'command'
                })
            )

```
これで`起床時刻-1m`になったらカーテンが開くようになりました。
ㅤ
### ③ 起床時刻の1分前にカーテンを開く（物理的に）
![](https://github.com/snoozers/lazy-home/blob/master/static/public/images/system-diagram-sequence-3.drawio.png?raw=true)
特に書くことはないです。ようこそ素晴らしい朝🌞

## 所感
実際に使ってみて最初に迎えた朝、起きてまず頭に思い浮かんだのは「あ、カーテン開いてる。そうだ完成したんだったわ。」という、何とも**冷めた**感想でした。目が**覚めていなかった**ようです（完）
.
.
.
という小話は置いておいて、**以前より寝覚めが良くなった**実感はあるので作った甲斐がありました。日常生活から「カーテンを開ける」という作業が不要になったのも嬉しかったです（これはSwitchBotカーテン買うだけでも実現できますが）。

使用技術に関してだと、特に使用経験のないAWSのサービスを取り入れていこうと考えていたので、ドキュメントと睨めっこしながらSAMやStepFunctionsをいじれたのは楽しかったです。

ITを活用して生活を豊かにしていくことが好きで、それをより身近に感じられるIoTの分野にも興味があります。
後付けで手軽にIoT化できるようなガジェット類はいくつか使っていますが、実際に使ってみると歯痒さを感じることが時々あります。今後もそういった「歯痒さ」を感じるたびに、試行錯誤しながら自分の理想のスマートなホームを作っていきたいです。