脱IFTTT:actions on googleでスマートホーム(Pythonで書いてみる)

記事の概要

  • IFTTT有料化に対応するため、IFTTT抜きでGoogle Assistantに対応していない機器を操作するための方法
  • Googleの純正機能を使ってテストアプリを作り、スマート機器として登録する形で実現
  • 動きを理解するために、Pythonで最小の構成でとりあえず動作することを目指す

はじめに

IFTTTの有料化が発表され、IFTTT経由のWebhookで様々な機器を動作させていたので、有料プランに移行するか、別のシステムに移行するかを迫られることになりました。
IFTTTはなんで無料で提供できるのだろうと疑問に思っていたので、有料化すること自体は理解できるのですが、価格が許容範囲をこえていたので、脱IFTTTTして自前で実装することにしました。この記事ではPythonでの最低限の実装で簡単な動作ができるようにすることを目指します。

前提条件

セキュリティの関係でSSL証明書がついたhttpsでのアクセスが必要です。"let's encrypt"などを使用することで、無料で証明書を取得することが可能です。この記事の中ではフォローしませんが、let's encryptで検索するといろいろ詳しいやりかたが出てくると思います

完成イメージ

今回の記事で実現できるようになるのは、以下のような形です

f:id:shimobepapa:20200919231416j:plain
完成イメージ

上のHomeアプリから「デバイスのセットアップ」「セットアップ済みデバイスのリンク」を選ぶと、連携できるサービス一覧がでてきます。ここに、今回作成するサービスが追加されます。 (テスト用アプリにはサービス名の頭に[test]とつきます。)

[test]アプリをタップすると、(用意する)Webページにジャンプし、リンクをふむ(もしくは自動でリダイレクトされる)と、サービスが連携されデバイスが追加されます。追加されたデバイスは「OK Google 電気をつけて」などのように、通常のGoogle Assistant対応スマート家電と同じように扱うことができるようになります。

何がおきているのか

(なんとなくの理解で大丈夫です)

[test]アプリをタップすると、自前で用意する認証ページにアクセスされます(認証ページのURLは後述のとおりactions on gooleに登録します)。

ここからはOauth2.0というプロトコルに準拠します。

本来ならばこの認証ページで、各自のサービスにログインしてもらいユーザの確認をして、正しいユーザならば認可グランドと呼ばれるコードを含んだURLをクライアントに返します。上の例では一人しかユーザがいないため、ログイン処理は省略されています。

クライアントはページから返されたURLにジャンプします。(リンクをふんでもらったり、リダイレクトされたりしてジャンプします)

ジャンプ先はGoogleのサーバです。このアクセスを受けて、Googleのサーバは認可グランドを受け取るので、その認可グランドを使って、自分で用意する認可APIにアクセスしてきます。これに対してアクセストークンを発行して返します。

以後のGoogleのサーバからのアクセスはこのアクセストークンをつけて送ってきます。

Googleのサーバはアクセストークンを取得すると、アプリのサーバに「デバイスのリスト」を要求してきます。この要求に対してデバイスのリストを返してあげることで、そのデバイスが登録されます。

ここまでが、上記画像の処理の流れです。
以後、「OK Google, 電気を消して」などと登録されたデバイスを操作しようとすると、アプリのサーバにコマンドが飛んでくるようになります。

実装は?

上で書いた通り、以下の3つの処理を用意する必要があります

  • 認可グラント発行
  • トークン発行
  • バイスのリストを返したり、デバイスに対する操作命令を受ける

actions on googleでは、上記3つの処理を別のAPIとしてURIを登録することになります。
以下それぞれ、Pythonで簡単に実装します。

認可グラント発行

今回は一般に公開されるアプリではありませんし、アクセスがきたら全部許可をしてあげればいいです。全部許可のコードは以下の通りです。

myauth.py

#!/bin/python3
# -*- coding: utf-8 -*-
import sys
import cgi
form = cgi.FieldStorage()
redirecturi = form['redirect_uri'].value + "?code=1234567890&state=" + form['state'].value
print("Content-Type: text/html")
print("")
print("<HTML><BODY><A HREF='" + redirecturi + "'>" + redirecturi + "</A></BODY></HTML>")

 
特に認可グラントを後で検証する予定もないので何を返してもいいです(上の例では1234567890)。
stateの情報をつけてあげる必要がある点を注意してください。

トークン発行

本来は、認可グラントやリフレッシュトークンなどの正当性をチェックする必要があります・・が、今回はもう何でもOKとしてトークンを発行してしまいます。といっても、発行したトークンも使わないので何を返してもいいです。
ここで、認可グラントでトークンを求めてくるケースと(初回)、アクセストークンの有効期限がきれて、リフレッシュトークンでアクセストークンを求めてくるケースがあります。それぞれ別のレスポンスを返してあげます。

mytoken.py

#!/bin/python3
# -*- coding: utf-8 -*-
import sys
import cgi
form = cgi.FieldStorage()

print("Content-Type: text/json")
print("")
if form['grant_type'].value == "authorization_code":
    print('{"token_type": "Bearer","access_token": "1234567890", "refresh_token": "1234567890", "expires_in": 7776000}')
if form['grant_type'].value == "refresh_token":
    print('{"token_type": "Bearer","access_token": "1234567890", "expires_in": 7776000}')

バイスのリストを返したり、デバイスに対する操作命令を受ける

ここがメインになります。
基本的には、
https://developers.google.com/assistant/smarthome/develop/process-intents
ここに情報はまとまっています。

GoogleのサーバからはJSONが送られてきます。JSONの内容に応じて適切な処理をする必要があります。
上記ぺーにはJSONの入出力の例が入っているので、そちらを見ることである程度内容はつかめると思います。
ここでは、概要をつかめるように簡単に説明します。

まず、ここに送られてくる処理は大きく分けて4つあります。
Sync:デバイスのリスト・情報の要求
Query:各デバイスの状態(電源が入っているか、音量がどうなのか等)の要求
Execute:デバイスの操作命令
Disconnect:連携の解除要求
JSONの"intent"にどの処理かが入っています。)

最初に呼ばれる処理がSyncです。デバイスのリストを返す必要があります。
どんな名前の、どんなデバイスがあるか。
各デバイスはどんなことができるか
が含まれています。
オンオフだけできるライト1個を返す場合のJSONを以下に例として示します。

{
	"requestId": "1234567890",
	"payload": {
		"agentUserId": "1836.15267389",
		"devices": [{
			"id": "10",
			"type": "action.devices.types.LIGHT",
			"traits": ["action.devices.traits.OnOff"],
			"name": {
				"defaultNames": ["Light"],
				"name": "フロアライト",
				"nicknames": ["light"]
			},
			"willReportState": false,
			"roomHint": "寝室",
			"attributes": {
				"commandOnlyOnOff": true,
			},
			"deviceInfo": {
				"manufacturer": "Panasonic",
				"model": "XXXXX",
				"hwVersion": "x.x",
				"swVersion": "x.x"
			},
			"customData": {}
		}]
	}
}

この中で、特に重要なのは
"type"
"traits"
"attributes"
です。
"type"は、このデバイスがライトなのか、エアコンなのか、テレビなのかといった分類を示します。
"traits"は、このデバイスにどのような機能があるかを列挙します。例えばライトならば色温度を変えれるならば、"action.devices.traits.ColorSetting"を追加したりします。
"attributes"は、"traits"で指定した機能について補足の情報を入れます。この機能を指定するならば、このattributesの情報が必要ということが決まっています。
https://developers.google.com/assistant/smarthome/traits
この辺りをみながら、"traits", "attributes"を適切に設定する必要があります。

これらを踏まえて、簡単なサンプルコードを下に示します。

#!/bin/python3
# -*- coding: utf-8 -*-
import sys
import datetime
import json
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

def addlog(logstr):
    with open('room.log', 'a', encoding='utf-8') as f:
        print(logstr, file=f)

def output(datastr):
    addlog(datastr)
    print("Content-Type: text/json")
    print("")
    print(datastr)

def executecmd(cmd, devicelist):
    cmdexecute = cmd['execution'][0]
    for device in cmd['devices']:
        #device に 対してexecuteを実行する処理を追加
        if device['id'] == "10":
            if cmdexecute["command"] == "action.devices.commands.OnOff":
                if cmdexecute["params"]["on"]:
                    #電源をオンにする処理
                else:
                    #電源をオフにする処理                
                    
        #処理したデバイスのリストを覚えておく
        devicelist[device['id']] = True

with open('room.json', 'r', encoding='utf-8') as f:
    roomjson = json.load(f)

addlog(datetime.datetime.now())
postdata = sys.stdin.read()
addlog(postdata)
param = json.loads(postdata)
requestId = param['requestId']
intent = param['inputs'][0]['intent']

if intent == "action.devices.SYNC":
    output(json.dumps({'requestId': requestId, "payload": { "agentUserId": "1836.15267389", "devices": roomjson } }))
elif intent == "action.devices.EXECUTE":
    devicelist = { }
    for cmd in param['inputs'][0]['payload']['commands']:
        executecmd(cmd, devicelist)
    payload = {}
    devicearray = []
    for key in devicelist.keys():
        devicearray.append(key)
    payload["commands"] = [{"ids": devicearray, "status": "SUCCESS"}]
    output(json.dumps({'requestId': requestId, "payload": payload }))
if intent == "action.devices.QUERY":
    # onlineであることだけを返す
    devicelist = {}
    for deviceinfo in param['inputs'][0]['payload']['devices']:
        devicelist[deviceinfo["id"]] = {"online": True, "on": True}
    output(json.dumps({'requestId': requestId, "payload": { "agentUserId": "1836.15267389", "devices": devicelist } }))

room.jsonは先ほどのサンプルの"devices"以下のようなJSONを置いてください。
電源オン・オフするところを各自の環境にあわせた処理に変えれば(別のURLを呼び出すなど)、電源オン・オフが実現できるようになります。
ここで、「何をしゃべったらどんなJSONが送られてくるのか」ということを把握することは大変大事です。思っていたものとちがった挙動になったときに原因が追いやすくなります。そのため上記のサンプルでも、送られてきたJSONの中身(+送り返すJSONの内容)をログとして残すようにしています。

いざ登録

さて、いよいよ用意したAPIactions on googleに登録してみます。

まず、Google assistantを使うアカウントでログインした状態で、
https://console.actions.google.com/
へアクセスします。
New Projectを選んで、適切な名前を付けます。
言語とリージョンの設定を忘れずに。
次のページで「Smart Home」を選び、「Start Building」を押します。
f:id:shimobepapa:20200920003028p:plain

setup account linkingのところで、先ほど用意した認可グラント発行、トークン発行のURLを指定してあげます。
f:id:shimobepapa:20200920003435p:plain

また、Build your Action -> Add Action(s)で
Fulfillment URL
のところに、JSONを処理するAPIのURLを追加します。
f:id:shimobepapa:20200920004056p:plain

他、もろもろディスプレイネームなどを設定することで、テスト用の設定は完了です。

2022/4/10追記
Deployの「Directory Informartion」欄を埋めないと、連携しようとするとエラーになるようです。
「Directory Informartion」欄に
・アイコン
・メール・名前
・プライバシーポリシーへのリンク(なんでもいい)
などを入力したのちに、テストします。

同じGoogleアカウントでログインしているスマホから、Homeアプリを立ち上げて実験してみましょう。

最後に

最後のほうはだいぶやっつけにはなってしまいましたが、ここからは各デバイスにあわせて適切な処理を実装するフェーズになります。Google Assistantのスマートホーム機能をフルに活かしていけるはずですので、traitsのドキュメントなどを読みながら、設定していきましょう。
かなり抜けている部分はあると思うので、質問などありましたらコメントいただけると助かります。