はじめに
「スマートホーム」っていいですよね。
言葉ひとつで複数の電子機器をまとめて操作したり、居住者の行動パターンを学習して自動的に家電を制御したり...。
そんなスマートな暮らしをしてみたい。ので作ってみます。
本記事は「寝起き編」ということで、続編があるかもしれないです。
※自分の調べた範囲では「スマートホーム」という用語の明確な定義が見つかりませんでした。本記事では、「自動・遠隔制御が可能な家電を導入した居住空間」の意で使用しております。
やりたいこと
記念すべきスマートホーム化への第一歩として、起床時にアラーム+太陽光で気分よくスッキリ目が覚めるような仕組みを作ろうと思います。
自分はアラームさえセットしていれば起きれるタイプなのですが、それでも眠気と激闘を繰り広げ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
ㅤ
- Github Actions
その他
-
Sleep Meister
- 自分が愛用している睡眠記録&アラームアプリ
- このアプリが本システムの要になるので特徴的な機能を以下に挙げます
- 睡眠の深さを計測可能で、
アラームの設定時刻-30m ~ アラームの設定時刻
の間で一番眠りが浅いタイミングにアラームを鳴らしてくれる - アラーム設定/入眠/中途覚醒/起床のタイミングでTwitterにツイートを投稿できる
- ツイートの内容に、設定したアラームの時刻や実際の起床時刻などの可変値を埋め込むことができる
- 睡眠の深さを計測可能で、
- Twitter AccountActivityAPI
- Twitterアカウントのアクティビティを指定URLにPOSTできる
- アラームの設定時刻を取得するために使用
ㅤ
システム全体像
システムの構成はこのようになりました。説明のために①~③にグルーピングしています。
AWSリソースのデプロイにはSAMを使用しました。
ㅤ
システムの説明
システム全体像の各部分について、順番に説明していきます。
ㅤ
① 起床時刻の設定
アラームの設定時刻を自動でツイートする設定
概要で触れたようにSleepMeisterでは、アラーム設定時にあらかじめ連携済みのTwitterアカウントでツイートを投稿することができます。投稿する文章を任意に変更することができ、文章内に「アラーム設定時刻」を変数として埋め込むことができます。
自分は下記のような文章を設定しました。
アラーム設定 xx:xx - yy:yy
#就寝
これでxx:xx - yy:yy
が時刻に置き換わってツイートされます。
ㅤ
AccountActivityAPIでツイートを取得
アラームの設定時刻をツイートすることができたので、投稿したツイートを取得する仕組みを作ります。
案としてまず第一にポーリングしてツイートを取得することを思いつきましたが、無駄な処理が発生するのは明らかでした。
他に良い方法がないか調べていたところ、TwitterAccountActivityAPIというTwitter公式のWebhookを見つけたのでこれを使用していきます。
AccountActivityAPIは開発者アカウントを開設して、開発者ポータルから「Elevated」のアクセス権を申請して承認されると(自分は申請と同時に承認されました)使用できるようになります。申請内容は英語で書く必要がありました。
申請承認後にWebhookの設定が必要になります。設定はこちらを参考にさせていただきました。
下記2つのエンドポイントが必要なのでSAMで作成します。
- CRCリクエスト時に応答トークンを返却するエンドポイント
- Webhookに登録するエンドポイント
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の応答トークンを返却する処理は、公式ドキュメントにサンプルコードがあったのでそちらを参考に実装します。
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のビルドアーティファクトも邪魔なので、デプロイ前に過去のアーティファクトを全て消すようにします。
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に切り出しておきます。
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関数を実行するようにしておきます。
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
プロファイルに従う必要があるようです。
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が公開されているので、ドキュメントと睨めっこしながら実装します。
from switchbot import LivingCurtains
def lambda_handler(event, context):
LivingCurtains().open()
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化できるようなガジェット類はいくつか使っていますが、実際に使ってみると歯痒さを感じることが時々あります。今後もそういった「歯痒さ」を感じるたびに、試行錯誤しながら自分の理想のスマートなホームを作っていきたいです。