0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

はじめに

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

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

やりたいこと

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

ちなみに後述のガジェットを使えば、専用のアプリから「指定の曜日、時刻にカーテンを開く」といった設定が可能です。ただ人間の(自分の?)生活リズムは不規則なもので、「休日だけど朝用事があるから早く起きないと...」とか「今日はもう少し寝ていたい...」といった状況が意外とあり、その都度設定を変更するのは面倒です。
そういった状況でも柔軟に、求めているタイミングでカーテンを開けてくれるシステムの構築を目指します。

概要

実現に際し使用したモノや技術、システムの全体像をざっくり紹介します。
ソースコードを公開しているので、良かったらこちらからどうぞ。

ガジェット

ハードはこの手のシステムでお馴染みのSwitchBot製のものを購入しました。いつもお世話になっております。APIが公開されていてGoodです。

  • SwitchBotカーテン
    • カーテンレールに設置してカーテンを開閉してくれる
    • 専用のスマホアプリから、曜日と時刻を指定してカーテンの開閉ができる
  • SwitchBotハブミニ
    • wifi経由でSwitchBot製ガジェットを操作するために必要
    • いわゆるスマートリモコンとしての機能もあり、SwitchBot製品を一つも持っていなくても買う価値アリ

使用技術

  • AWS
    • SAM(CloudFormation)
    • Lambda
    • API Gateway
    • StepFunctions
    • CloudWatch
  • 言語
    • python3.9
  • ソースリポジトリ
    • Github
  • CI/CD
    • Github Actions

その他

  • Sleep Meister
    • 自分が愛用している睡眠記録&アラームアプリ
    • このアプリが本システムの要になるので特徴的な機能を以下に挙げます
      • 睡眠の深さを計測可能で、アラームの設定時刻-30m ~ アラームの設定時刻の間で一番眠りが浅いタイミングにアラームを鳴らしてくれる
      • アラーム設定/入眠/中途覚醒/起床のタイミングでTwitterにツイートを投稿できる
      • ツイートの内容に、設定したアラームの時刻や実際の起床時刻などの可変値を埋め込むことができる
  • Twitter AccountActivityAPI
    • Twitterアカウントのアクティビティを指定URLにPOSTできる
    • アラームの設定時刻を取得するために使用

システム全体像

システムの構成はこのようになりました。説明のために①~③にグルーピングしています。
AWSリソースのデプロイにはSAMを使用しました。

システムの説明

システム全体像の各部分について、順番に説明していきます。

① 起床時刻の設定


アラームの設定時刻を自動でツイートする設定

概要で触れたようにSleepMeisterでは、アラーム設定時にあらかじめ連携済みのTwitterアカウントでツイートを投稿することができます。投稿する文章を任意に変更することができ、文章内に「アラーム設定時刻」を変数として埋め込むことができます。
自分は下記のような文章を設定しました。

アラーム設定 xx:xx - yy:yy

#就寝

これでxx:xx - yy:yyが時刻に置き換わってツイートされます。
sleepmeister.jpeg

AccountActivityAPIでツイートを取得

アラームの設定時刻をツイートすることができたので、投稿したツイートを取得する仕組みを作ります。
案としてまず第一にポーリングしてツイートを取得することを思いつきましたが、無駄な処理が発生するのは明らかでした。
他に良い方法がないか調べていたところ、TwitterAccountActivityAPIというTwitter公式のWebhookを見つけたのでこれを使用していきます。

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

申請承認後にWebhookの設定が必要になります。設定はこちらを参考にさせていただきました。
下記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の応答トークンを返却する処理は、公式ドキュメントにサンプルコードがあったのでそちらを参考に実装します。

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()
        }),
    }

余談ですが、自分は申請の仕方が分からず時間を食いました。というのも公式の開発者ドキュメントを参照していてもリンク先がなかったり、AccountActivityAPIの申請ボタンをクリックするとなぜかEnterprise版の申し込みフォームに飛ばされたり、ググって出てくる記事とはUIが異なっていたりといった状態でした。結局開発者ポータルしらみつぶしに調べて解決しました。

② 起床時刻の1分前にSwitchBotカーテンを開く


ツイートから起床時刻を取得できるようになったので、次は「指定時刻に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

ステートマシン実行

OpenCurtainsStateMachineopen_curtains_atに、ツイートから取得した起床時刻-1mを設定してステートマシンを実行するようにします。(アラームが鳴る1分前にはカーテンが開いて欲しい)
ステートマシンのWaitタイプのステートにタイムスタンプを指定する場合、フォーマットはISO 8601RFC 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が公開されているので、ドキュメントと睨めっこしながら実装します。

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分前にカーテンを開く(物理的に)


特に書くことはないです。ようこそ素晴らしい朝🌞

所感

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

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

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

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?