脱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のドキュメントなどを読みながら、設定していきましょう。
かなり抜けている部分はあると思うので、質問などありましたらコメントいただけると助かります。

テレワーク環境完成

 関西在住の筆者ですが、勤務先も4月の緊急事態宣言発令から基本的に在宅勤務に切り替わっています。緊急事態宣言解除後も、基本在宅ワークは続いていて、時々出社するという生活が続いています。

 在宅ワークが続く中、国からもらった10万円などを使いつつちょっとずつ環境を整えてきました。そしてつい先日狭かった机も買い替えにいたり、自分なりに環境が完成しました。人の環境を見て回るの楽しかったので、見たい人がいるかはわかりませんが、公開したいと思います。

 なお業務としては、3~4割くらいがテレビ会議、それ以外はPCでの作業という感じです。

f:id:shimobepapa:20200813234954j:plain

全体図

ノートパソコンが2台ありますが、奥が会社から支給されている仕事用で、もう一台はプライベート用です。画面にはうつってはいませんが、右側の棚に(プライベートの)デスクトップPCも置いています。大きいモニタにまっすぐむかって仕事をしたかったので、クラムシェルで基本使用しています。

机は前のものは奥行きが足りなかったのが不満でした。今は140cm x 70㎝のものになりました。モニターからも適度に離れていて疲れにくくなりました。チェアについては、寝室の一角を利用しているため、妻から雰囲気を壊さないものにしてほしいというリクエストと、キャスターはNGという制限があったので、悩んだ末IKEAにいきついています。

デスク:LOWYA > [幅140] パソコンデスク(ブラウン)

https://www.low-ya.com/item/F802_G1021.html

チェア:IKEA LÅNGFJÄLL ロングフィェル

https://www.ikea.com/jp/ja/p/langfjaell-office-chair-gunnared-light-green-black-s69177751/

 

キーボードとマウスは以下のものを購入しました。エンジニアとしてはキーボードはいいものが欲しくなってしまいます。

キーボード logicool MX800

https://www.amazon.co.jp/gp/product/B07XQ7G6BH/

マウス logicool G603

https://www.amazon.co.jp/gp/product/B0752GJHQD/

 

f:id:shimobepapa:20200813235921j:plain

正面から

モニターはJAPAN NEXTの34型ワイド液晶を買いました。機能のわりに安いです。

http://japan-next.jp/shopdetail/000000001078/004/O/page1/order/

足元をすっきりさせつつ、できるだけ壁によせてモニタを使いたかったので、ほぼ自由には動かせない、下のモニターアームを購入しました。すっきりします。

https://www.amazon.co.jp/gp/product/B06XDX29HG/

下から生えているケーブルは、(仕事で使う)スマホなどの充電用です。

https://www.amazon.co.jp/gp/product/B01CUOE9U0/

みたいなもので、マグネットでアームの支柱にくっつけてます。

 

モニタ上部に見えているのが、テレビ会議用に使用しているカメラです。家に転がっていたミラーレス一眼レフのα6000を使用しています。ポールにクランプで固定できるアームを使ってとりつけています。下記のビデオキャプチャーボックスを購入してカメラからのHDMI出力をPCに流しています。

https://www.amazon.co.jp/gp/product/B0773DZ3HS/

また、モニタの下に出てきているのはコンデンサーマイクです。MPM-1000Uというマイクを使っていますが、TM01というポールにクランプでつけることができるスタンドを使って、足元がきれいになるように配置しています。

TM01 https://www.amazon.co.jp/gp/product/B0026RTFPQ/

 

モニタの裏側をみるとこのような感じです。

f:id:shimobepapa:20200814000719j:plain

モニタ裏側

USBハブは、両面テープでモニタに張り付けています。ケーブルができるだけ正面から見えないようにカメラやマイクなどとつないでいます。暗号化しているとログインするまでmacbluetoothが使えないようで不便なので、キーボード・マウスもusb経由の無線で使用しています。

足元はできるだけすっきりさせるようにはしています。

f:id:shimobepapa:20200814002037j:plain

足元

そのため配線トレーをとりつけて、そこにいろいろ詰め込んでいます

f:id:shimobepapa:20200814002116j:plain

配線

配線トレー

https://www.amazon.co.jp/gp/product/B08494LN4W/

見えないところは汚くてもいいやの精神で・・・。

 

蛇足ですが、テレワークでおすすめアイテムとしては、正面からの写真にも写っている、チタンのカップです。冷たいものをいれても水滴がつかず温度もキープしてくれて最高です。今日書いた中で一番おすすめしたいかも。

https://www.susgallery.jp/products/235

 

 

F: Two Snuke (AtCoder)

最近AtCoderという競技プログラミングをはじめたアラフォーサラリーマンです。ここのところ業務でコード書くことがなく寂しいので、ボケ防止のため在宅勤務で通勤がなくなってできた時間を使ってやっています。学生の人が多そうで肩身狭い思いしながらやっていますが、どこかで、始めた経緯とか感想などをまとめたいなと思います。

AtCoder、ABCという初心向けのはずのコンテストでも結構難しい問題多いです。解説も簡潔なものであることも多いので、解答読んでもすぐにはよくわからない・・ということがしばしばあります。同じように悩んでしている人いるかもしれないので、解答の理解に時間がかかった問題を丁寧に解説してみたいなと思います。(今週末コンテンストがないので暇潰しもかねて)

第一回は先週末にあったエイシング プログラミング コンテスト 2020の最後の問題です
F - Two Snuke
解説読んでも省略しているところで何をしているかわからず理解に苦労しました。

以下、解説のPDFの表記をそのまま使います。(Snukeの元ネタって誰なのかが気になります)
まず解説の「N-5個の一列に並んだボールに対し、 15個の仕切りを(最初の5つのセグメントに含まれるボールの個数が偶数になるように)入れる方法」を考えればいいというところへの誘導について考えていきます。

まず単純なケースとして
\Delta s+ \Delta n+ \Delta u+ \Delta k+ \Delta e = N
のとき
ありうる(\Delta s, \Delta n, \Delta u, \Delta k, \Delta e)の組すべてについて
\Delta s\cdot \Delta n\cdot \Delta u\cdot \Delta k\cdot \Delta e
を計算して足し合わせた数について考えてみます。

これは「Nを5つのグループにわけ、それぞれのグループから1つ選ぶ」選び方の数に等しくなります。
5個の箱にそれぞれa, b, c, d, e個ボールが入っていて、それぞれの箱から1個ずつボールを選ぶボールの選び方はa \cdot b \cdot c \cdot d \cdot eなので、総和を求める問題から組み合わせを求める問題に落とし込むことができています。(こんな発想はなかったので解説よんでおおーと思いました)

次に「N個のボールを5つのグループにわけ、それぞれのグループから1つボールを選ぶ」選び方を考えてみます。

f:id:shimobepapa:20200718012706p:plain
このボールを5つのグループにわけ、その中の一つを選んで色を塗ってみます
f:id:shimobepapa:20200718013103p:plain

ここで色を塗ったボールと仕切りに注目してみると、色塗りボール⇨仕切り⇨色塗りボール⇨仕切り・・・と交互に9個並んでいます。
そのため、色塗りボールを仕切りに変化させても元と1対1で対応します。(奇数番目を色塗りボールにすればいいので)

f:id:shimobepapa:20200718013558p:plain

ですので、「N-5個のボールに9個の仕切りを入れる、入れ方の個数」を求めればいいことがわかります。

いまはわかりやすくするため
\Delta s+ \Delta n+ \Delta u+ \Delta k+ \Delta e = N
という条件で考えましたが
\Delta s+ \Delta n+ \Delta u+ \Delta k+ \Delta e \le N
の場合はどうすればいいでしょうか?
この場合も同じ考え方で、
「N個のボールを6つのグループにわけ、最初の5つのグループからそれぞれ1つボールを選ぶ」
とすればいいです。
最後のグループがNに満たないあまりの数に対応します。
結果「N-5個のボールに10個の仕切りを入れる、入れ方の個数」となります。

さらに解説にあるように
s + n + u + k + e + \Delta s+ \Delta n+ \Delta u+ \Delta k+ \Delta e \le N
の場合も同じように考えれば、解説にあるように
「N-5個の一列に並んだボールに対し、 15個の仕切りを(最初の5つのセグメントに含まれるボールの個数が偶数になるように)入れる方法」
と1対1に対応しているところにたどり着きます。

で、自分が解説読んでつまづいたのが次からの部分です。
dp(i,j,p)を「i個のボールまで見て、現在仕切りを j個入れ、現状のセグメントに含まれるボールの個数のパリティが であるような仕切りの入れ方の個数」、と定義してDPでこの問題を解くことができる。これを行列累乗で高速化する。


こちら最終的に理解した内容を書いてみます。

左から順番に、ボールか仕切りを置いていく。
と考えます。

最初5個のセグメント(仕切りが5つ置かれるまで)は偶数なのでj \le 5かそうでないかで場合分けして考えます。
解説にあるパリティというのは、いま置いて行っているセグメントにボールが偶数個入っているか、奇数個入っているかを示すもの、という意味だと思います。偶数の場合p=0、奇数の場合p=1と考えるとします。
なお簡単のためj \ge 6のときは、奇数だったとしても常にp=0とします。
j \le 5のとき
 dp(i, j, 0) = dp(i - 1, j, 1) + dp(i, j - 1, 0)\\ dp(i, j, 1) = dp(i - 1, j, 0)

最後のセグメントのボールの数が偶数になるケース(p=0)は、奇数の状態にボールを置くか、仕切りを置くかです。ただ、かならず偶数にしなければいけないので、前のセグメントが奇数の状態で仕切りを置くことができません。
また奇数(p=1)になるケースは、偶数ボールがあるときにボールをもう1個おいたときのみです。

j \ge 6のときは
 dp(i, j, 0) = dp(i - 1, j, 0) + dp(i, j - 1, 0)
になります。ボールを置いた場合と、仕切りを置いた場合の場合の数の和になります

さて、これを行列の形にして解かなければいけません。
 v_nをベクトル、 Aを行列として
 v_n=Av_{n-1}
という形にできれば
 v_n=A^nv_0
となり、 A^n o(log(n))で出せるため間に合います。

 dp(i, j, p)を少し定義をかえて、仕切りとボールをあわせてn個、仕切りをj個おいたときの数を f(n, j, p)とします。
すると、
j \le 5のとき
 f(n, j, 0) = f(n - 1, j, 1) + f(n - 1, j - 1, 0)\\ f(n, j, 1) = f(n - 1, j, 0)
j \ge 6のとき
 f(n, j, 0) = f(n - 1, j, 0) + f(n - 1, j - 1, 0)

何か行列にできそうな気がしてきました。
ですので、考えられるj, pのパターンを全部いれたベクトルを考えることにします。
v_n = \left(\begin{array}{c}f(n, 0, 0)\\ f(n, 0, 1)\\ f(n, 1, 0)\\  f(n, 1, 1)\\ .\\ .\\ .\\ f(n, 4, 0)\\ f(n, 4, 1)\\ f(n, 5, 0)\\ f(n, 6, 0)\\.\\ .\\ .\\ f(n, 15, 0)\end{array}\right)

21要素ととても大きくなってしまいますが、プログラムで処理すれば実装も処理時間も全然余裕でした。

上の式を行列の形に書き直せば
\left(\begin{array}{c}f(n, 0, 0)\\ f(n, 0, 1)\\ f(n, 1, 0)\\  f(n, 1, 1)\\ .\\ .\\ .\\ f(n, 4, 0)\\ f(n, 4, 1)\\ f(n, 5, 0)\\ f(n, 6, 0)\\.\\ .\\ .\\ f(n, 15, 0)\end{array}\right) = \left(\begin{array}{ccccccccccccccc}0 & 1 & 0 & 0 & . & . & . & 0 & 0 & 0 & 0 & . & . & . & 0\\1 & 0 & 0 & 0 & . & . & . & 0 & 0 & 0 & 0 & . & . & . & 0\\1 & 0 & 0 & 1 & . & . & . & 0 & 0 & 0 & 0 & . & . & . & 0\\0 & 0 & 1 & 0 & . & . & . & 0 & 0 & 0 & 0 & . & . & . & 0\\. & . & . & . & . & . & . & . & . & . & . & . & . & . & . \\. & . & . & . & . & . & . & . & . & . & . & . & . & . & . \\. & . & . & . & . & . & . & . & . & . & . & . & . & . & . \\ 0 & 0 & 0 & 0 & . & . & . & 0 & 1 & 0 & 0 & . & . & . & 0\\0 & 0 & 0 & 0 & . & . & . & 1 & 0 & 0 & 0 & . & . & . & 0\\ 0 & 0 & 0 & 0 & . & . & . & 1 & 0 & 1 & 0 & . & . & . & 0\\0 & 0 & 0 & 0 & . & . & . & 0 & 0 & 1 & 1 & . & . & . & 0\\. & . & . & . & . & . & . & . & . & . & 1 & 1 & . & . & . \\. & . & . & . & . & . & . & . & . & . & . & 1 & 1 & . & . \\. & . & . & . & . & . & . & . & . & . & . & . & 1 & 1 & . \\ 0 & 0 & 0 & 0 & . & . & . & 0 & 0 & 0 & 0 & . & . & 1 & 1\end{array}\right) \left(\begin{array}{c}f(n - 1, 0, 0)\\ f(n - 1, 0, 1)\\ f(n - 1, 1, 0)\\  f(n - 1, 1, 1)\\ .\\ .\\ .\\ f(n - 1, 4, 0)\\ f(n - 1, 4, 1)\\ f(n - 1, 5, 0)\\ f(n - 1, 6, 0)\\.\\ .\\ .\\ f(n - 1, 15, 0)\end{array}\right)

となります。この行列を Aとします。
ボールも仕切りも何も置いてない状態はn = 0, j = 0, p = 0なので
v_0 = \left(\begin{array}{c}1\\ 0\\ 0\\  0\\ .\\ .\\ .\\ 0\end{array}\right)となります。

求めたい答えは、ボールをN-5個、仕切りを15個並べた状態ですので、f(N+10, 15, 0)になります。v_{N+10} の最後の要素です。
ですので、
 v_{N+10} = A^{N+10}v_0
を計算すれば答えがでてきます。

A^nの高速計算の方法は色々なサイトで紹介していると思うので省略します。「繰り返し二乗法」で検索するのがいいと思います。

(軽い気持ちで書き始めましたが思ったより長くなってしまいました・・。第2回は果たしてあるのか)

ASUSTOR NASにJenkinsをインストールしたい

この記事の内容

  • ASUSTOR製のNASでJenkinsを動作させたいというニッチな内容
  • Tomcatを使用することで比較的簡単にASUSTOR NASへJenkinsを導入可能
  • ASUSTOR NASへのTomcatとJenkinsの導入の仕方を説明

 

家ではAS3102TというASUSTOR製のNASを使っています。

Celeronが入っているため自宅サーバとしても十分な性能がありなおかつ低消費電力ですので、常時起動サーバとして結構色々な役割をになってもらっています。

その過程で、Jenkinsを入れたときのことをメモ書きします。

 

ASUSTOR NASでは、独自のパッケージ管理ソフトを使用しているため、バージョン管理ソフトを通してのインストールはできません。

ただTomcatをサポートしているので、Tomcatを使ってJenkinsを動作させればスムーズです。その手順についてまとます。

Tomcatの設定

App Centralからインストールできます。

http://(NASIPアドレス):8000/

にアクセスして、App Centralから、JRETomcatをインストールします。

http://(NASIPアドレス):8080/

にアクセスして、Tomcatのページが表示されれば成功です。

 

TomcatのConfigファイルは

/volume1/.@plugins/AppCentral/tomcat/conf

にあります。

(端末にSSHでアクセスできるようにしてviなどで書き換えを行います。(ADMのサービスから端末を選択、SSHサービスを有効にする))

GUIで設定できると楽ですので、そのための変更をします。

tomcat-users.xml

ファイルの

<tomcat-users>

</tomcat-users>

の間に

<role rolename="manager-gui"/>

<user username="username" password="password" roles="manager-gui"/>

という行を追加します。(ユーザ名、パスワードは変更してください)

そのうえで、App CentralからTomcatを再起動させます。

http://(NASIPアドレス):8080/manager/html

にアクセスできることを確認します。

Jenkinsのインストール

https://jenkins.io/download/

から、Generaic Java Package (.war)をダウンロードします。

そのファイルを

/volume1/Tomcat/

におきます。

 

http://(NASIPアドレス):8080/manager/html

にアクセスして

/jenkins

があることを確認のうえ「起動」ボタンを押します。

これで

http://(NASIPアドレス):8080/jenkins

からJenkinsを利用できるようになりました。

 

Jenkinsのホームフォルダ

root/.jenkins

になります。

Jenkins Pluginインストール時にOfflineになってしまう問題の簡易対策

ただ、自分の環境ではPluginへのアクセスに失敗して、そのままでプラグインをインストールできませんでした。SSHの証明書関係のエラーがでます。

回避方法ですが、Jenkinsのホームフォルダにある、hudson.model.UpdateCenter.xml

を以下のように書きかえます

<url>https://updates.jenkins.io/update-center.json

<url>http://updates.jenkins.io/update-center.json 

 Jenkinsを再起動することでプラグインのインストールは問題なくできるようになりました。

Raspberry Piオーディオ:音を鳴らしているか検出したい

今更ながら最近、Raspberry Piを触っていました。

Raspberry Pi zero whに、pHatボードのDACを取り付けて、アンプと繋げて音楽を楽しむということをしていました。

MPDで音楽を再生したりとか、Airplayレシーバーにしたりとか、色々試していたのですが一点苦労したとことが・・。それは「音を出しているときはアンプをオンにしたい、音が出なくなったらアンプをオフにしたい。自動で」ということでした。

アンプをRaspberry Piから操作する方法は、赤外線を使うなりRS232ポートやLAN経由で操作するなり、アンプに応じて色々方法はあると思うので割愛しますが、問題は「音が出ているか判定する部分」。もちろん音を出す方法が一種類ならば対応は簡単そうですが、いろいろなソースから音を出すことを考えると、できるだけ根っこの部分で判定をしたいです。

それで悪戦苦闘の末いきついたのが、「DACボードと通信しているGPIOの生データを読んで判定する」という方法です。あまりいないような気はするのですが、もしかしたら同じようなことをしたいと思っているかたがいるかもしれないので、方法について、記載してみます。

基本的な考え方は、DACボードに信号を送って音を出しているので、音がでていないときは信号のやりとりがない=GPIOのピンの各電圧は一定、音を出しているときは信号のやりとりがあるのでGPIOのピンの電圧に変化があるはず、、です。

まずは、音を出しているときに変化があるGPIOのピンを見つける必要があります。

コマンドラインで、

gpio readall

とうつことで、各ピンので夏をみることができますので、音を出している状態で何度もコマンドを打って変化しているピンを見つけます(原始的)

そのうえで、そのピンの電圧を監視するプログラムを書きます。

C言語でメモリをマッピングして読み込む形になります。

まずは、GPIOの値が、物理メモリ空間のどこにマッピングされているか仕様書をみて調べます。

Raspberry Pi hardware - Raspberry Pi Documentation

から、Raspberry pi zeroならばBCM2835みたいなので、BCM2835のPeripheral specificationをみてみます。

この仕様書を読み解くのに少し苦労したのですが、まずは90ページ目あたりの表をみると

0x7E200034 GPLEV0 GPIO Pin Level

0x7E200038 GPLEV1 GPIO Pin Level

このあたりをみれば良さそうです。96ページの説明を見ると各GPIOのピンの電圧がビットごとに入っているみたいですね。

で、問題は0x7E200034って何と言うところなのですが・・・。

ここで仕様書のページ6 BCM2835 ARM Periphreをみてみます。

この、0x7E200034というようなアドレスはこの表の

VC CPU Bus Addressにあたるようです。

0x7E000000からがARM Physical Addressの0x20000000から割り振られるようです。

つまり、0x7E200034は、ARM Physical Addressの0x20200034にあたるようです。

ここからはC言語的な話ですが、ARM Physical Addresは直接アクセスできず、mmapマッピングしてあげる必要があります。

以下、サンプルプログラムになります。GPIOの21番のピンを監視しています。無音状態から音がなったことを検知すると、/home/pi/on.shを実行します。また、30秒以上無音(ピンに変化なし)が続くと、/home/pi/off.shを実行します。

注意ですが、このプログラムを実行するにはルート権限が必要です。起動時にかならず実行されるようにしておくことで、やりたかったことが実現できるようになりました。

#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>

#define DEVMEM "/dev/mem"

int main() {
  int count;
  int fd;
  volatile unsigned char *iomap;
  fd = open(DEVMEM, O_RDONLY);
  if( fd < 0 )
  {
     printf("Failed Open Mem\n");
     exit(1);
  }
  iomap = mmap(0, 0xb0, PROT_READ, MAP_SHARED, fd, 0x20200000);
  if(iomap == MAP_FAILED) {
    printf("mmap failed\n");
    exit(1);
  }  

  int state = -1;
  int seqcount = 0;
  while(1)
  {
     int bit = 1 & ((*(unsigned int*)(iomap + 0x34)) >> 21);
     if( bit )
     {
       if( state != 1 )
       {
          system("sh /home/pi/on.sh");
       }
       state = 1;
       seqcount = 0;
     }
     else
     {
        seqcount++;
        if( seqcount >= 100 * 30 && state != 0) // 30s
        {
          system("sh /home/pi/off.sh");
          state = 0;
        }
     }
     usleep(10000);
  }
}    

続・SONY BRAVIAをコマンドラインで制御したい

以前、Android TVになる前のBRAVIAコマンドラインでコントロールした記事を書きました。

SONY BRAVIAをコマンドラインから制御したい - shimobepapaの日記

その後、予期せぬ事態(液晶の物理破損)が発生して、テレビ購入後4年未満で新型のBRAVIAに買い換えることになってしまいました。

ということで、Android TVである最新のBRAVIAで以前書いた方法が有効かどうか調べてみました。以下、65x9000fでの検証結果です。

なお、前提として一度「Video & TV SideView」のリモコン操作でBRAVIAを操作しないとBRAVIA側の設定が正しくされていない可能性があります。初めてアプリで電源を入れる操作をしたときに、「BRAVIA側でアプリから電源を入れることができるように設定しますか?」というようなことをきかれたことがありました。

もし、下に書いた方法でうまくいかない場合、「Video & TV SideView」のリモコン機能を試していただくとうまくいく可能性があると思います。

 

まず、結論から言うと、「だいたいプロトコルは一緒だけど、以前書いた方法だと微妙に動かない」という感じでした。

ダメだった点は以下の二つ

  • Wake on LANがきかない
  • curlコマンドでxmlをPOSTする場合、書いた方法ではエラーになる

 

このうちWake on LANについては、「リモコンでできる操作」で電源オンの信号を送ることで対応できます。

問題は2つめの方なのですが、おそらくBRAVIA側で使用しているライブラリが変わった関係でエラーチェックが厳しくなったためと思われます。

具体的には、Headerに

soapaction:"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"

がないといけないようです。

そのため、リモコンのコマンドを送るcurlコマンドは以下の通りになります。

curl -d '<?xml version=\"1.0\"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:X_SendIRCC xmlns:u="urn:schemas-sony-com:service:IRCC:1"><IRCCCode>(コマンド)</IRCCCode></u:X_SendIRCC></s:Body></s:Envelope>' -b cookie.txt -H 'soapaction:"urn:schemas-sony-com:service:IRCC:1#X_SendIRCC"' http://(BRAVIAIPアドレス)/sony/IRCC

こうすることで、無事動作ができました。

また、電源オンのコマンドは、

AAAAAQAAAAEAAAAuAw==

になります。

 

その他、認証まわりの部分は以前の記事と同じ方法でいけましたので、以前の記事とあわせて参照ください。

MacからWindows Phoneのテザリングをオンにする

Windows 10 Mobileには、MS-TCCという、bluetoothでリモートからテザリングWindows phone用語的にはホットスポット)をオンにする機能があります。Windows 10 Mobileのスマホをカバンの中にいれたまま、ノートPCからスマホテザリングをオンにできて便利、という機能です。
ただこちら、基本的にはWindows 10のPCからでないと使えないのですが、MS-TCCプロトコルは公開されています。
そこで、Mac OSから、Windows 10 Mobileのテザリングをオンにするツールを自作してみました。
Macbook + Windows phoneなんて組み合わせで使っている人いない気はしますが・・)
以下のリンクからDLできます。
MS-TCC for Mac

上記appを起動するとステータスバーに「H」というマークが追加されます。すでにテザリング済みのWindows 10 mobile端末があれば、Hをクリックすると表示されます。
f:id:shimobepapa:20170124002658p:plain
テザリングをオンにしたい端末を選択することで、テザリングがオンになります。
(処理の結果は、メッセージボックスで表示されます)

ソースコードについて

このツールですが、swift初心者がswiftを勉強しながら書いたのですが、Mac osでswiftでBluetoothをさわっているコードの例が少なく苦労しました。参考に、UI部分以外のソースコードはりつけておきます。

import Foundation
import IOBluetooth

struct MSTCCResult{
    var targetdevice: IOBluetoothDevice? = nil
    var iserror: Bool = false
    var errorMessage: String? = nil
    
    // 成功した時のssidの情報
    var ssid: String? = nil
    var passcode: String? = nil
}

class MSTCCManager: NSObject, IOBluetoothRFCOMMChannelDelegate, IOBluetoothDeviceAsyncCallbacks
{
    var channel: IOBluetoothRFCOMMChannel?
    var isopen: Bool
    var writebuffer: NSMutableData
    var callback: ((MSTCCResult) -> Void)?
    var targetdevice: IOBluetoothDevice?
    
    override init()
    {
        self.channel = IOBluetoothRFCOMMChannel()
        isopen  = false
        writebuffer = NSMutableData(length: 16)!
        callback = nil
        targetdevice = nil
        super.init()
    }
    
    func listMSTCCDevice() -> Array<IOBluetoothDevice>{
        var ans = Array<IOBluetoothDevice>()
        for i in IOBluetoothDevice.pairedDevices() {
            let device = i as! IOBluetoothDevice
            let uuid : [UInt8] = [0x23, 0x2e, 0x51, 0xd8, 0x91, 0xff, 0x4c, 0x24, 0xac, 0x0f, 0x9e, 0xe0, 0x55, 0xda, 0x30, 0xa5]
            let spduuid = IOBluetoothSDPUUID(bytes: uuid, length: 16)
            
            let sr = device.getServiceRecord(for: spduuid)
            if( sr != nil )
            {
                ans.append(device)
            }
        }
        return ans
    }
    
    func connect(devicename: String, callback: ((MSTCCResult) -> Void)? = nil) -> Bool{
        for i in IOBluetoothDevice.pairedDevices() {
            let device = i as! IOBluetoothDevice
            if (device.name == devicename) {
                return connect(device: device, callback: callback)
            }
        }
        simpleerror("the tarfet device is not paired")
        return false
    }
    
    func connect(device: IOBluetoothDevice, callback: ((MSTCCResult) -> Void)? = nil) -> Bool{
        if( self.isopen )
        {
            simpleerror( "Error: in operating", callback: callback )
            return false;
        }
        let ret1 = device.openConnection()
        if( ret1 == kIOReturnSuccess)
        {
            self.isopen  = true
            self.callback = callback
            self.targetdevice = device
            
            device.performSDPQuery(self)
        }
        else
        {
            simpleerror("open connect failed. code = \(ret1)", callback: callback)
        }
        
        return self.isopen
        
    }
    
    func sdpQueryComplete(_ device: IOBluetoothDevice!,
                          status: IOReturn)
    {
        // SPDの情報更新完了
        if( status != kIOReturnSuccess )
        {
            simpleerror("SPD Query Failed. code = \(status)")
            device.closeConnection()
            self.isopen = false
            return
        }
        
        // MS-TCCのGUID
        let uuid : [UInt8] = [0x23, 0x2e, 0x51, 0xd8, 0x91, 0xff, 0x4c, 0x24, 0xac, 0x0f, 0x9e, 0xe0, 0x55, 0xda, 0x30, 0xa5]
        let spduuid = IOBluetoothSDPUUID(bytes: uuid, length: 16)
        
        // MS-TCCのサービス取得
        let sr = device.getServiceRecord(for: spduuid)
        
        if( sr == nil )
        {
            // 更新したらMS-TCCのサービスが見つからなかった
            simpleerror("This Device doesn't support MS-TCC now.")
            device.closeConnection()
            self.isopen = false
            return
        }
        var rfcommid = BluetoothRFCOMMChannelID()
        sr?.getRFCOMMChannelID(&rfcommid)
        let ret2 = device.openRFCOMMChannelAsync( &channel, withChannelID: rfcommid, delegate: self)
        if( ret2 != kIOReturnSuccess)
        {
            simpleerror("open RFCOMM Channel Error. code = \(ret2)")
            device.closeConnection()
            self.isopen = false
            return
        }
    }
    
    func rfcommChannelData(_ rfcommChannel: IOBluetoothRFCOMMChannel!,
                           data dataPointer: UnsafeMutableRawPointer!,
                           length dataLength: Int)
    {
        let buf = UnsafeBufferPointer(start: dataPointer.assumingMemoryBound(to: UInt8.self), count: dataLength)
        let datas = Array(buf)
        var index = 0
        if( datas[0] == 2 )
        {
            var result = MSTCCResult()
            result.iserror = false
            result.errorMessage = ""
            
            index = 3
            while UInt8(index) < datas[2] {
                
                if( datas[index] == 2 )
                {
                    // SSID
                    result.ssid = getStringFromArray( datas, start: index + 3, length: Int(datas[index + 2]))
                    //                    System.Text.Encoding.ASCII.GetString()
                }
                else if( datas[index] == 4 )
                {
                    result.passcode = getStringFromArray( datas, start: index + 3, length: Int(datas[index + 2]))
                    // passphrase
                }
                index = index + Int(datas[index + 2]) + 3
            }
            
            if( self.callback != nil )
            {
                callback!(result)
            }
            self.isopen = false
        }
        else
        {
            simpleerror("MS-TCC Error!\(datas[0])") // todo
        }
        channel!.close()
        self.targetdevice!.closeConnection()
    }
    
    func rfcommChannelWriteComplete(_ rfcommChannel: IOBluetoothRFCOMMChannel!,
                                    refcon: UnsafeMutableRawPointer!,
                                    status error: IOReturn)
    {
        if( error != kIOReturnSuccess)
        {
            simpleerror("Failed to send data. code = \(error)")
            self.targetdevice!.closeConnection()
            self.isopen = false
            return
        }
    }

    func rfcommChannelOpenComplete(_ rfcommChannel: IOBluetoothRFCOMMChannel!,
                                   status error: IOReturn)
    {
        if( error != kIOReturnSuccess)
        {
            simpleerror("open RFCOMM Channel Error. code = \(error)")
            self.targetdevice!.closeConnection()
            self.isopen = false
            return
        }
        if( channel!.isOpen() )
        {
            let cmdata = Data(bytes: [1,0,0])
            writebuffer.setData(cmdata)
            let ret3 = channel!.writeAsync(writebuffer.mutableBytes, length: 3, refcon : nil)
            if( ret3 != kIOReturnSuccess)
            {
                simpleerror("Failed to send data. code = \(ret3)")
                self.targetdevice!.closeConnection()
                self.isopen = false
                return
            }
        }
        else
        {
            // 多分こないはず
            simpleerror("Channel is not opened")
            self.targetdevice!.closeConnection()
            self.isopen = false
            return
        }
    }
    
    func simpleerror(_ errormessage: String, callback: ((MSTCCResult) -> Void)? = nil )
    {
        if( callback != nil)
        {
            var result = MSTCCResult()
            result.iserror = true
            result.errorMessage = errormessage
            callback!( result )
        }
        else if( self.callback != nil )
        {
            var result = MSTCCResult()
            result.iserror = true
            result.errorMessage = errormessage
            self.callback!( result )
            self.callback = nil
        }
    }
    
    func getStringFromArray(_ datas: Array<UInt8>, start: Int, length: Int) -> String
    {
        let strarray = datas[start..<(start+length)]
//        strarray.append(0)
        var byteBuffer = [UInt8]()
        strarray.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in
            byteBuffer += bytes
        }
        let str = String(bytes: byteBuffer, encoding: String.Encoding.ascii)
        return str!
    }
    
    func connectionComplete(_ device: IOBluetoothDevice!,
                            status: IOReturn)
    {
    }
    
    func remoteNameRequestComplete(_ device: IOBluetoothDevice!,
                                   status: IOReturn)
    {
    }
}