ラズベリーパイの排熱を改善2

ケースの上に穴をあけ、更にヒートシンクを取り付けてもはっきりと温度が下がらなかったラズベリーパイ3ですが、ファンを取り付けてみました。

100均で買った下敷きを切ってそれに取り付けてみました。

これにより、5℃くらい下がりました。ファンの電源を5Vから撮りたかったのですが、LCDが使っていて開いていなかったので、3.3Vからとりました。

対処後の温度グラフは以下のようになりました。対処したのはPi3 Work 緑の線になります。

これを対処したことによって、Low voltage warning「低電圧の警告」が頻繁に表示されるようになってしまいまいました。現在使っているのは5V3AのACアダプタなのですが、LCDとファンも動かすようになったことで容量不足になったのかもしれません。

居間のラズベリーパイ4ですが、ファンの電源を5Vから取っていたのですが、テレビを点けていないときにファンの音がうるさいことに気が付きました。ファンが大きくて冷却効果も高いのか30℃半ばをキープしていたので3.3Vで常時冷やすことにしました。音も気にならなくなり、温度も40℃を割る位でキープしているようです。

現在の課題
1)Pi3 Workの電源容量不足問題
2)常時稼働ラズパイのファンレス化(庭カメラ用ラズパイの直射日光問題含む)

ラズベリーパイの排熱を改善

各ラズベリーパイのCPU温度グラフを見るようになって、庭を撮影しているラズベリーパイの温度が高いのが気になってきました。日が射すと70度を超えます。窓際に置いているため、ケースに直接日光が当たるためだと思い、画用紙で日よけを作ってみました。いくらか効果あるでしょうか?

対策を施したその日からめっきり秋めいてきたので、明確な比較はできませんが70℃を超えることはなくなりました。ある程度の効果はあったと言えるでしょう。

100均で買った箱をケースにしているラズベリーパイですが、60℃超になっていることが多く、ケース上部が暖かくなっているので穴をあけて排熱を良くしました。

結果は上部からの熱の抜けは良くなったような気はするものの、明確に数字に表れたような感じがしません。敢えて言うと1℃くらい低くなった感じです。それではと思い、ヒートシンクを購入して貼り付けてみました。結果は若干効果あり、60℃を超えることが少なくなりました。それでも2~3℃低くなって59℃台です。

50℃をちょっと超えた温度だったPi Zero2は試しにテンキーを取ってみるとそれだけで5℃下がりました。やはり排熱性が悪かったのでしょう。

CPU温度を測ろうとしたのはファンを温度によって回す設定を発見したところから始まったのですが、常時回すようにしたらどうなるのかと思い、ファンの電源をGPIOの5Vから常時給電するように再セッティングしてみました。

すると一気に下がりました。居間に置いたものは35℃位まで下がりました。庭のラズベリーパイはそれより7℃位高いですが、日除けのせいで排熱性が悪くなっているのでしょう。Pi4 Workは3.3Vから給電してみましたが、5Vで給電しているPiよりも7℃位高いです。Youtubeの動画を見ると50℃以上まで温度が上昇します。

上記の処置により、結果としてヒートシンクだけで冷却しているPi3が他を圧倒して熱いという状況になってしまいました。排熱性が悪くて5℃高くなっているPi Zero2も気になります。

各ラズベリーパイの部屋の温度と部屋の温度を計測しグラフに表示

ラズベリーパイ4は他のシリーズに比べ、CPU性能も高く発熱量も多いのでCPUファン付きのケースに入れていました。ファンの電源はGPIOの5Vに接続し、常時回していました。あるとき、ラズベリーパイの設定にCPUが何度になったらファンを回す、という設定があるのに気が付きました。試してみるとちゃんと動作しています。

CPUの温度で動作するという事は、今何度かわかる方法があるはずです。調べてみると以下のコマンドがありました。
$ sudo vcgencmd measure_temp
temp=57.4’C

気が付くと家にあるラズベリーパイは7台にもなっていました。
居間(Living):居間に防犯カメラのモニターとして常時稼働(Pi4:4GB)
玄関(Entrance):玄関の防犯カメラとして常時稼働(Pi3)
玄関2(Entrance-2):側面方向からみた玄関の防犯カメラとして常時稼働(PiZero)
実験用Pi3(Pi3 Work):実験用としているが、2階のカメラモニタとして半常時稼働 (Pi3)
実験用Pi4(Pi4 Work):実験用として必要時稼働 (Pi4:4GB)
テンキー用 (Alexa Key):テンキーによるAlexa定型アクション起動用として常時稼働 (PiZero2)
庭(Garden):たまにやってくる動物を捉えようと設置したカメラ用。常時稼働(Pi4:2GB)

上記のラズベリーパイの全部のCPU温度を時系列でグラフにしてみたくなりました。居間のNature Remo miniにも室温を計測する機能がありました。室温もグラフに加えます。最近2階用にスマートリモコンEZCONを買ったのですが、これには室温計測機能が無いようです。本当はNature Remoを買い増そうかと思ったのですが、去年買った時よりずいぶん高くなっていたので止めて安いものを買ったのです。

毎時0分と30分の30分おきにデータを収集することにしました。プログラムは以下のようになります。
data_collect.py

from paramiko import SSHClient, AutoAddPolicy, SSHException, AuthenticationException, ssh_exception
import socket
import re
from urllib.parse import urlencode
from urllib.request import urlopen, Request
from urllib.error import HTTPError
from json import loads
import os
from datetime import datetime
import schedule
import time

PI_NAME = ['Living', 'Entrance', 'Entrance-2', 'Pi3 Work', 'Pi4 Work', 'Alexa Key', 'Garden', 'Room']
PI_ADR = ['192.168.xx.xx', '192.168.xx.xx', '192.168.xx.xx', '192.168.xx.xx', '192.168.xx.xx', '192.168.xx.xx', '192.168.xx.xx']
PORT = 22
USER = 'pi'
PASSWORD = 'xxx'
TIMEOUT = 5
CMD_CPU = 'sudo vcgencmd measure_temp'

api_key = ""
url = "https://api.nature.global/1/devices/"
headers = {
    "accept" :"application/json",
    "Authorization" :"Bearer " + api_key,
}

DATA_FILE = '/var/www/html/temperature.csv'
ERROR_FILE = './errorlog.txt'
DATA_NUM = 96

def savedata(data):
  list = []
  if os.path.exists(DATA_FILE):
    f = open( DATA_FILE, 'r' )
    for line in f.readlines():
      list.append(line)
    else:
      f.close()
  start = 1
  num = len(list)
  if num >= (DATA_NUM + 1):
    start = num - DATA_NUM + 1
  f = open( DATA_FILE, 'w' )
  header = 'time,'
  for i in range(len(PI_NAME)):
    if i != 0:
      header += ','
    header += PI_NAME[i]
  f.write(header + '\n')
  for i in range(start, num):
    f.write( list[i] )
  f.write( datetime.now().strftime('%Y/%m/%d %H:%M,') + data + '\n' )
  f.close()

def errorlog(log):
  f = open(ERROR_FILE, 'a')
  f.write( datetime.now().strftime('%Y/%m/%d %H:%M:%S:') + log + '\n' )
  f.close()

def cpu_temp():
  ret = ''
  ssh = SSHClient()
  for name, adr in zip(PI_NAME, PI_ADR):
    try:
      ssh.set_missing_host_key_policy(AutoAddPolicy())
      ssh.connect(adr, PORT, USER, PASSWORD, timeout=TIMEOUT)
      stdin, stdout, stderr = ssh.exec_command(CMD_CPU)
    except AuthenticationException as e:
      errorlog(name + ':AuthenticationException:' + e)
      ret += ','
    except SSHException as e:
      errorlog(name + ':SSHException:' + e)
      ret += ','
    except ssh_exception.NoValidConnectionsError as e:
      ret += ','
    except socket.timeout:
      ret += ','
    else:
      temp = re.sub('\'C\n', '', re.sub('temp=', '', stdout.readline()))
      ret += str(temp) + ','
    finally:
      ssh.close()
  return ret

def room_temp():
  ret = ''
  request = Request(url, headers=headers)
  try:
    with urlopen(request) as response:
      data_byte = response.read()
      data= loads(data_byte)
      device_info = data[0]["newest_events"]
      ret = str(device_info["te"]["val"])
  except HTTPError as e:
      errorlog('Room:HTTPError:' + e)
      ret = ''
  finally:
      response.close()
  return ret

def correct():
  data1 = cpu_temp()
  data2 = room_temp()
  savedata(data1 + data2)

# every 30minute
schedule.every().day.at('00:00').do(correct)
schedule.every().day.at('00:30').do(correct)
schedule.every().day.at('01:00').do(correct)
schedule.every().day.at('01:30').do(correct)
schedule.every().day.at('02:00').do(correct)
schedule.every().day.at('02:30').do(correct)
schedule.every().day.at('03:00').do(correct)
schedule.every().day.at('03:30').do(correct)
schedule.every().day.at('04:00').do(correct)
schedule.every().day.at('04:30').do(correct)
schedule.every().day.at('05:00').do(correct)
schedule.every().day.at('05:30').do(correct)
schedule.every().day.at('06:00').do(correct)
schedule.every().day.at('06:30').do(correct)
schedule.every().day.at('07:00').do(correct)
schedule.every().day.at('07:30').do(correct)
schedule.every().day.at('08:00').do(correct)
schedule.every().day.at('08:30').do(correct)
schedule.every().day.at('09:00').do(correct)
schedule.every().day.at('09:30').do(correct)
schedule.every().day.at('10:00').do(correct)
schedule.every().day.at('10:30').do(correct)
schedule.every().day.at('11:00').do(correct)
schedule.every().day.at('11:30').do(correct)
schedule.every().day.at('12:00').do(correct)
schedule.every().day.at('12:30').do(correct)
schedule.every().day.at('13:00').do(correct)
schedule.every().day.at('13:30').do(correct)
schedule.every().day.at('14:00').do(correct)
schedule.every().day.at('14:30').do(correct)
schedule.every().day.at('15:00').do(correct)
schedule.every().day.at('15:30').do(correct)
schedule.every().day.at('16:00').do(correct)
schedule.every().day.at('16:30').do(correct)
schedule.every().day.at('17:00').do(correct)
schedule.every().day.at('17:30').do(correct)
schedule.every().day.at('18:00').do(correct)
schedule.every().day.at('18:30').do(correct)
schedule.every().day.at('19:00').do(correct)
schedule.every().day.at('19:30').do(correct)
schedule.every().day.at('20:00').do(correct)
schedule.every().day.at('20:30').do(correct)
schedule.every().day.at('21:00').do(correct)
schedule.every().day.at('21:30').do(correct)
schedule.every().day.at('22:00').do(correct)
schedule.every().day.at('22:30').do(correct)
schedule.every().day.at('23:00').do(correct)
schedule.every().day.at('23:30').do(correct)

while True:
  schedule.run_pending()
  time.sleep(10)

このプログラムを動かすにはライブラリparamikoとurllib、scheduleのインストールが必要になります。インストールは以下のように行います。
$ pip install paramiko⏎
$ pip install urllib3⏎
$ pip install schedule⏎

プログラムの説明
PI_NAME =で温度計測対象の名称を配列で設定しています。
PI_ADR =は各ラズベリーパイのアドレスを配列で定義しています。
PORT =はSSH接続するときのポート番号です。
USER =はSSH接続するときのユーザー名、PASSWORD =はパスワードです。
TIMEOUT =はSSH接続する際のタイムアウト時間となります。全てのラズベリーパイが常時稼働しているわけではないため、応答がなければ早めに非稼働中とします。
api_key =はNature Remoのアクセストークンです。Nature APIを使用するには必要で、以下のサイトで取得します。
https://home.nature.global/
url =はNature APIのURL、headersはNature APIに必要なヘッダー情報となります。
DATA_FILE =はこのプログラムのOUTPUファイルとなります。HTMLでグラフ表示するため、/var/www/html/ディレクトリにCSV形式で保存します。
ERROR_FILE =はエラーが発生した時に内容を出力するファイルになります。
DATA_NUM=は保持する最大のデータ数になります。30分おきに1日48データ収集しますので、2日分のデータを保持する設定になっています。

プログラムは最初起動時にスケジューラで起動する時刻を設定します。
schedule.every().day.at(‘XX:XX’).do(correct)
48行書くのは野暮ったいですね。流石ヘボプログラマーです。
while文で無限ループし、10秒おきに起動判定を行います。

関数correct()では最初に各ラズベリーパイの温度を収集し、次に部屋の温度を収集し、最後にデータを保存します。

各ラズベリーパイの温度を収集する関数cpu_temp()ではSSH接続し、温度を取得するコマンドによりデータを取得しています。取得した文字列は「temp=57.4’C⏎」というフォーマットなので数字だけを取り出すために[temp=[と[‘C⏎]を取り除いています。データをCSVファイルで保存するために,(カンマ)で区切っています。timeout以外の予期せぬエラー(例外)はエラーログファイルに内容を出力しています。(この例外処理は発生したことがないので正常に動作するのか試したことはありません・・・)

関数room_temp()ではNature APIを使いデバイス情報をJSONフォーマットで取得します。{“newest_events”:{“te”:{“val”:}}}に格納されている部屋の温度を取得します。(参照:https://swagger.nature.global/#/default/get_1_devices)

関数savedata()では引数に与えられたデータに時刻を追加してCSVファイルに保存しています。直近96データより古いものは破棄しています。

プログラムが出力するcsvは以下のようになっています。
temperature.csv

time,Living,Entrance,Entrance-2,Pi3 Work,Pi4 Work,Alexa Key,Garden,Room⏎
2022/08/24 11:30,52.1,45.1,50.8,62.3,,53.2,72.5,26.5⏎
2022/08/24 12:00,54.5,47.8,,65.5,,53.2,73.0,27⏎
2022/08/24 12:30,56.9,45.1,,65.0,,53.7,70.1,28⏎
2022/08/24 13:00,55.0,46.2,48.7,62.3,,53.7,68.1,28.5⏎
2022/08/24 13:30,51.6,45.1,51.4,62.8,,53.2,67.2,28⏎
・・・後略・・・

プログラムは居間のラズベリーパイに稼働させることにしました。
apache2をインストールします。
$sudo apt install apache2

ラズベリーパイ起動時にプログラムを開始するようにします。
/etc/rc.localのexit 0より上に以下の1行を挿入し、start.shシェルスクリプトをバックグラウンドで起動します。
sudo sh /home/pi/start.sh &

シェルスクリプトstart.shは以下のようになっています。

#!/usr/bin/bash
sleep 10
cd /home/pi/
python ./data_collect.py

最初に10秒sleepしているのは、これがないとプログラムがエラーとなるためです。どうやらこのプログラムが起動している段階ではネットワーク系のサービスがまだ起動していないらしく、それを避けるために簡易的に10秒間待っています。

データを格納するcsvファイルが作成されるようになりました。
それをグラフ表示するhtmlは以下のようになります。グラフはGoogle Chartを使用しています。
/var/www/html/temperature.html

<!DOCTYPE html>
<html lang='ja'>
<head>
  <meta charset='UTF-8'>
  <meta name='viewport' content='width=device-width, initial-scale=1'>
  <meta http-equiv="Cache-Control" content="no-cache">
  <title>Temperature Graph</title>
  <link rel="stylesheet" href="css/style.css">
  <script type='text/javascript' src='https://www.gstatic.com/charts/loader.js'></script>
  <script type='text/javascript'>
    google.charts.load('current', {packages: ['corechart', 'bar']});
    google.charts.setOnLoadCallback(drawCurveTypes);

    function drawCurveTypes() {
      var req = new XMLHttpRequest();
      req.open('get', 'temperature.csv', true);
      req.send(null);
      req.onload = function() {
        var ma = convertCSVtoArray( req.responseText );
        var data = new google.visualization.arrayToDataTable( ma );
        var options = {
          focusTarget: 'category',
          pointSize: 3,
          pointShape: 'circle',
          chartArea:{left:'5%',width:'75%',height:'85%',top:'5%'},
          backgroundColor: '#C0C0C0'
        };
        var chart = new google.visualization.LineChart(document.getElementById('chart_div'));
        chart.draw(data, options);
      }
    }

    function convertCSVtoArray(str) {
      var result = [];
      tmp = str.split('\n')
      for( var i = 0; i < tmp.length; ++i ) {
        if( tmp[i] == '' ) continue;
        result[i] = tmp[i].split(',');
        if( i != 0) {
          result[i][0] = new Date(result[i][0]);
          for( var j = 1; j < result[i].length; j++) {
            if( result[i][j].length != 0 ) {
              result[i][j] = Number(result[i][j]);
            } else {
              result[i][j] = NaN;
            }
          }
        }
      }
      return result;
    }
  </script>
</head>
<body>
  <p>温度データ</p>
  <div id='chart_div' class='graph-temperature'></div>
 </body>
</html>

/var/www/html/css/style.cssには以下の4行を追加します。ファイルがない場合は追加します。グラフの横幅はブラウザの横幅を目一杯使うようにしています。縦の540ピクセルはラズパイのモニタの設定ではみ出さないで表示で来る最大の大きさを狙って設定しています。

.graph-temperature{
  width: 100%;
  height: 540px;
}

表示されるページは以下のようなイメージになります。グラフ内にマウスポインタを当てると指定したポイントの値を表示します。

庭を写すカメラを制御しているGardenは窓際に置いているのですが、日が射すと温度が70℃以上になっているのがわかります。曇りの日はそれほど高くならないようです。
2階のモニタ用ラズパイはPi3でタッチパネルと一体にセットされて、100均で買ったプラスティックケースに入れていますが、ファンもヒートシンクも付けていないため、60°をちょっと超えてしまっています。

居間のテレビの裏に置いてあるLivingは時々60℃を超えてはファンが回って冷えている感じなのでしょうか。
PiZero2のAlexa Keyは熱対策なしの上に、テンキーで蓋をされているようになっているためか、はたまたZeroよりCPU能力が高く発熱量が多いためか50℃を超えたところで安定しています。
玄関フード内に取り付けたEntrance-2はラズパイZeroですが、これも熱対策は何もしていません。常に気温より20℃くらい高い温度です。
玄関の内側に取り付けたEntranceはPi3ですが、これは全体がヒートシンクになっているというアルミケースを使用しています。常に40℃前半を維持していて素晴らしい冷却能力を示しています。
部屋は暑くなってきたらエアコンを点けているのがわかります。もう秋なのか夜は25℃以下になることもあるようです。

カメラのモーション検知時間を記録し、アレクサでお知らせする

2階にいるとチャイムの音が聴こえにくいので、宅配便が来た事に気が付かないことがあります。玄関に人が入ってきたらアレクサにお知らせしてもらおう、という試みです。

以前から玄関フード内にカメラをセットしたラズベリーパイZEROを設置していて、インターホンの横に貼り付けておりました。カメラ付きインターホンのように使えればよいかなと思ったのですが、視野が狭く殆ど使い物になっていません。敢えて言うと冬に外に出なくても雪庇の状態を確認できるという程度です。
今回このカメラを人感センサーとして使おうと思います。

motionは動きを検知したら動画を記録するのですが、そのタイミングをトリガーにしてプログラムを動かせないかを調べたところ、motionにオプションが用意されていました。
on_event_start
https://motion-project.github.io/motion_config.html#on_event_start

動きを検知したらホームディレクトリにあるシェルスクリプトを起動するように/etc/motion/motion.confの最後に以下の一文を追加します。
on_event_start /home/pi/detect.sh

detect.shの内容は以下のようになっています。

#!/usr/bin/bash
cd /home/pi
python ./camera_detect.py

pythonプログラムcamera_detect.pyは、起動されると現在時間をファイルに記録し、ダミーセンサーを検知状態にします。アレクサで定型アクションを作成し、ダミーセンサーの検知を実行条件にしてアレクサにお知らせしてもらうように設定します。
プログラムは以下のようになっております。

camera_detect.py

from datetime import datetime
import os
import subprocess

# 連続回避
SLEEP_TIME = 300
LATEST_FILE = "./latest.txt"
# LOGファイル名
LOGFILE = '/var/www/html/sensor_log.txt'
MAX_LINE = 100

# 連続回避
def isStart(now):
  if os.path.exists(LATEST_FILE):
    f_time = os.path.getmtime(LATEST_FILE)
    expiring_time = datetime.fromtimestamp(f_time + SLEEP_TIME)
    if now > expiring_time:
      f = open(LATEST_FILE, 'w')
      f.close()
      return 1
    else:
      return 0
  else:
    f = open(LATEST_FILE, 'w')
    f.close()
    return 1

# テキスト保存
def saveText(now):
  list = []
  if os.path.exists(LOGFILE):
    f = open( LOGFILE, 'r' )
    for line in f.readlines():
      list.append(line)
    else:
      f.close()
  start = 0
  num = len(list)
  if num >= MAX_LINE:
    start = num - MAX_LINE + 1
  f = open( LOGFILE, 'w' )
  for i in range(start, num):
    f.write( list[i] )
  f.write( now.strftime('%Y/%m/%d %H:%M:%S\n') )
  f.close()

# main関数
if __name__ == '__main__':
  now = datetime.today()
  if isStart(now):
    saveText(now)
    subprocess.run('./send24.sh')

プログラムの説明
最初のnow = datetime.today()では現在日時を取得しています。この時間をイベント発生日時とします。
次のisStart()関数では以降の処理をすべきかどうか判定します。前回の検出から5分(300秒:SLEEP_TIME)経たないイベントは処理しないことにしています。空のファイルlatest.txtを作成し、そのファイルの更新時間と現在日時を比較して判定します。
処理する場合はsaveText()関数で現在日時をファイルsensor_log.txtに書き込みます。古いものは削除し、直近100回(MAX_LINE)のイベント日時を書き込みます。出力ディレクトリを/var/www/html/にしたのはこれをホームページ上で表示させるためです。

次にシェルスクリプトsend24.shを起動します。
send24.sh

#!/usr/bin/bash
python ./alexa_trigger_func.py 24

pythonプログラムalexa_trigger_func.pyを実行し、ダミーセンサーNo.24を検出状態にします。
当初、シェルスクリプトを介さずに直接subprocess.run(‘python ./alexa_trigger_func.py 24’)としていました。Windows環境では上手く動作したのですが、ラズベリーパイではエラーになってしまったので、シェルスクリプトを介して起動することにしました。
そもそも、alexa_trigger_func.pyはメインのプログラムにimportし、関数コールで実行するつもりでした。しかし、それをするとラズベリーパイZEROではちょっと困ったことになったのです。当初のalexa_trigger_funcをimportしたプログラムでは現在日時が実際に発生した日時よりも7~8秒かそれ以上遅れて記録されていました。アレクサへの通知はもっと遅れて10数秒かかっています。プログラムの1行目に日時を取得しそれを書き込んでいるのに、です。ライブラリをimportすると、プログラム実行前にimportしたライブラリとそれに関連するライブラリすべてを読み込んでしまうので、最初のステップを実行する前に数秒かかっているようでした。これをmotionの動作検知イベントで行うと録画処理と並行して実行するのでさらに数秒遅くなるようなのです。ラズベリーパイZEROはCPUの処理能力が低く、それに動画処理をさせているための問題で、通常の環境では問題にならないかもしれません。
alexa_trigger_funcのimportを止めたことにより、発生日時を取得し、ファイルに書き込む処理はそれほど時間がかからずにできるようになりました。しかし、アレクサによる通知は依然として発生から10数秒かかるままです。

これを幾分解消しようと、動画のフレームレートを15から7に落としてみました。動画処理にかかる負担を減らすことでアレクサへの通知処理を速くしようという狙いです。しかしながら結果は改善したように見えませんでした。これ以上は現状のハードウエアでは諦めるしかないかもしれません。ZERO2ならもっと速くなると推測します。いつか試してみたいものです。

alexa_trigger_func.pyはメイン起動するプログラムを関数化したものですが、メイン起動も関数コールもできるように若干の改良を行いました。
alexa_trigger_func.py

import sys
import os
import json
import datetime
from trace import Trace
import requests

client_ID = ''
client_secret = ''
refresh_token=''
token_file = './token.txt'

def alexa_trigger(no):
  sensor_name = 'dummy_sensor_' + format(no, '0>3')
  # アクセストークンが有効かどうかチェックする
  access_token = ''
  access_token_valid = False
  today = datetime.datetime.today()
  if os.path.exists(token_file):
    #ファイル有、データ読み込み
    f = open(token_file)
    data = f.read()
    f.close()
    jdata = json.loads(data)
    expires = jdata['expires_in']
    f_time = os.path.getmtime(token_file)
    file_time = datetime.datetime.fromtimestamp(f_time)
    expiring_date = datetime.datetime.fromtimestamp(f_time + expires)
    if today < expiring_date:
      # アクセストークン有効、取得
      access_token_valid = True
      access_token = jdata['access_token']

  # アクセストークンが無効の場合はoa2サーバーから取得
  if not access_token_valid:
    header_str = {
      'Content-Type': 'application/x-www-form-urlencoded;Accept-Charset=UTF-8'
    }
    body_str = {
      "grant_type": "refresh_token",
      "refresh_token": refresh_token,
      "client_id": client_ID,
      "client_secret": client_secret
    }
    response = requests.post('https://api.amazon.com/auth/o2/token', data = body_str, headers = header_str)
    if response.status_code != 200 :
      print(response.status_code)
      exit()
    jdata = json.loads(response.text)
    access_token = jdata['access_token']
    if len(access_token) > 0:
      access_token_valid = True
      # ファイルに書き込み
      f = open(token_file, 'w')
      f.write(response.text)
      f.close()

  if not access_token_valid:
    print('can not get access_token')
    exit()

  # イベントゲートウェイにイベントを送信する
  message_id = sensor_name + '_' + str(today)
  timeOfSample = today.strftime('%Y-%m-%dT%H:%M:%S.00Z')
  header_str = {
      'Authorization': 'Bearer ' + access_token,
      'Content-Type': 'application/json'
  }
  json_str = {
    "event":{
      "header":{
        "namespace":"Alexa",
        "name":"ChangeReport",
        "messageId":message_id,
        "payloadVersion":"3"
      },
      "endpoint":{
        "scope":{
          "type":"BearerToken",
          "token":access_token
        },
        "endpointId":sensor_name
      },
      "payload":{
        "change":{
          "cause":{
            "type":"PHYSICAL_INTERACTION"
          },
          "properties":[
            {
              "namespace":"Alexa.MotionSensor",
              "name":"detectionState",
              "value":"DETECTED",
              "timeOfSample":timeOfSample,
              "uncertaintyInMilliseconds":0
            }
          ]
        }
      }
    },
    "context":{
      "properties": [
        {
          "namespace": "Alexa.EndpointHealth",
          "name": "connectivity",
          "value": {
            "value": "OK"
          },
          "timeOfSample": timeOfSample,
          "uncertaintyInMilliseconds": 0
        }
      ]
    }
  }

  response = requests.post('https://api.fe.amazonalexa.com/v3/events', json = json_str, headers = header_str)
  if response.status_code == 202:
    print('success')
  else:
    print(response)

if __name__ == '__main__':
  alexa_trigger(sys.argv[1])

最後の2行を追加しただけです。
内容の詳細については仕組みを説明した「スイッチでAlexa定型アクションを起動~導入編~(https://km2-system.com/2022/06/27/alexa_switch_1/)」、最初のバージョンについて記載した「スイッチでAlexa定型アクションを起動~スキルテスト編~(https://km2-system.com/2022/06/29/alexa_switch_4/)」、関数化させた「明日の天気によってAlexa定型アクションを起動する(https://km2-system.com/2022/08/22/alexa_switch_8/)」を参照してください。

以下は記録した日時を表示するHTMLファイルです。
sensorlog.html

<!DOCTYPE html>
<html lang='ja'>
<head>
  <meta charset='UTF-8'>
  <meta name='viewport' content='width=device-width, initial-scale=1'>
  <meta http-equiv="Cache-Control" content="no-cache">
  <title>Motion sensor log</title>
  <link rel="stylesheet" href="css/style.css">
  <script type='text/javascript'>
    var req = new XMLHttpRequest();
    req.open('get', 'sensor_log.txt', true);
    req.send(null);
    var result = '';
    req.onload = function() {
      tmp = req.responseText.split('\n');
      for( var i = tmp.length - 1; i >= 0; i-- ) {
        if( tmp[i] == '' ) continue;
        result += tmp[i];
        result += '\n';
      }
      document.getElementById('text').value = result;
    }
  </script>
</head>

<body>
  <p>人感センサー</p>
  <textarea class='list-motion-sensor' id='text' title='sensorlog' cols='25' rows='20' readonly></textarea>
</body>
</html>

/var/www/html/css/style.cssには以下の5行を追加します。ファイルやフォルダがない場合は作ります。

.list-motion-sensor{
  resize: none;
  font-size:14pt;
  background: #C0C0C0;
}

sensorlog.htmlは/var/www/html/に配置します。以下が表示イメージになります。

使った感想としては、今のところ『とても良い』です。
カメラとして視野が狭く、明るさに鈍感で夜若干明かるくなっても反応しない、というのが逆にいいです。
夜、ライトをつけた車が通っても、玄関横のトイレの明かりがついても反応しないのです。
玄関フードに入ると人感センサー付きの電池式ライトが光るので反応します。
視野が狭く、郵便や新聞を配達してくれた際に手が映るか映らないか際どい感じなので反応しないときがありますが、目的としては問題ないです。
むしろもうちょっとだけ上に設置しても良いかもしれません。

人感センサー運用失敗編

「スイッチでAlexa定型アクションを起動~人感センサー編~」で人感センサーが使えるとわかったので、実際に玄関に取り付けてみました。在宅勤務のときにはカメラブラウザ用のラズパイを目の届くところに置いているのですが、常に見ているわけでないので宅配便などお客さんが来たときに気が付かないことが多いのです。2階なのでピンポン音が小さく気が付かないのです。少し便利になるのかと。しかしながら結果は失敗に終わってしまいました。事の顛末を書きます。

まず、動作させるまでが大変でした。手ごろなケースを探して、センサーがうまく嵌るように四角い穴をあけました。センサー検知を記録するのに、もともとそこでカメラとして使っていたラズベリーパイZEROを使うことにしました。目立たないようにGPIOから配線するために工夫して基板の裏側にL字のピンを取り付けます。ちゃんと半田付けしたつもりだったのですが、接触不良で全く動作が不安定でした。不器用で下手糞です。せっかく買った素晴らしいコテを使わず、安いコテを使っていたというのもあります。あまりにも使ってないので忘れてしまっていました。なかなか正常にセンサー検知をせず、こんな簡単なことなのに何故動かないのだろうと悩み、テスターを当ててやっとわかったのです。
いろいろ苦労しながらやっと玄関に設置しても良いかなと思えるところまで仕上がりました。配線も後ろに飛び出たため、5mm厚のゴム板を貼り付け、かさ上げしたりしています。

いざ設置したところ、大体は作動しているものの、不可解な動きをします。丁度1時毎に数秒の誤差で誤検知が派生するのです。部屋で動作確認していた時も誤検知はしていたようですが、エアコンの風に反応しているのかと思い見逃していたのかもしれません。

最初センサーの動作不良を疑いました。ケースにセンサーをはめ込む四角い枠を空ける際にセンサーの基板をペタペタ触りまくったので、壊してしまったのかと思ったのです。人感センサーを買い増したのですが、結果は同じでした。部屋で動作確認しているときは1時間毎の誤検知は発生しませんでした。よしこれで行けると思い玄関に取り付けてみたところ、再発してしまいました。

プログラムに問題があるのかと思い、GPIOの初期化設定でプルダウンを設定してみましたが変わりませんでした。

ラズベリーパイZEROではもともと防犯カメラ機能(motion)とホームページ機能(apache2)を動作させているのですが、記録した動画のサムネイル表示をさせるとそのタイミングで誤検知することを発見しました。もしかしたらCPUの負荷がかかり、電力消費が大きくなると電圧が落ち込み、それが原因で誤検知するのかもしれません。1時間に1回起動する、何か知らないタスクがあるのかも。ACアダプタを調べてみると1.5Aでした。ラズベリーパイZERO単体には充分な電力ですが、カメラや人感センサーを接続すると電力不足になるのかもしれません。しかし、3Aのものに変えてみてもダメでした。カメラを取り外し、motionサービスを停止させてみても変わりません。

人感センサーの問題も捨てきれないので、センサーが反応したらLEDが付く回路を簡易的に作ってみました。電池は何年も前に買った9Vのものを使いました。保証期限はとっくに過ぎていて、あるものは2V迄落ちていて使えませんが、もう1つは7Vありました。7Vあれば大丈夫でしょう。センサーが反応するとLEDが付きます。気が付くようにしばらく机に置いています。見逃しているのかどうかわかりませんが、LEDが点くのを今のところ確認できていません。1時間に一度1秒ほどなので人の目だと厳しいです。ときどき手を伸ばしてかざすとLEDが点くので線が抜けたり、電池が弱くなったわけではありません。

ということで自分としては万策尽きました。ヘボプログラマーにはこれくらいが限界です。心の中の結論としてはこのラズパイが壊れているか、もしくはラズベリーパイZEROの持病です。とにかくこの人感センサーを使うのを諦めました。

もっと良いセンサーはないのか・・・と、ここで気が付きました。カメラもセンサーなのだと。

明日の天気によってAlexa定型アクションを起動する

明日の天気が雨か晴れかによってアクションを起こせないか、という要望をとある掲示板で目にしたので調べたところ、お天気APIというのがあるのを知りました。
自分にはこれといって必要性はないものの、面白そうなので勝手に作ってみました。(特に周知せず)

参考にしたページ
天気予報 API (livedoor 天気互換)のJSONを表示させてみた
https://four4to6.x0.com/800/

【Python】無料で使える天気予報APIで降水確率を取得する
https://creepfablic.site/2021/11/14/python-weather-api/

step1:仮想センサーの増設

雨の場合と晴れの場合で別の定型アクションを動かそうと考え、「スイッチでAlexa定型アクションを起動~スキル作成編~」で作成した仮想センサーを増やしました。
詳細は以下を参照してください。ここでは簡潔に記述します。
スイッチでAlexa定型アクションを起動~スキル作成編~
https://km2-system.com/2022/06/28/alexa_switch_2/

スキルのAWS関数のindex.js内記載のセンサー数を増やします。
sensor_num=10 を sensor_num=30 に変更して「Deploy」します。(数値は必要数にします。自分の場合、他にもやりたいことがあるため、30まで増やしました)

Alexaアプリでデバイスの検出を行うと追加したダミーセンサーが検出されます。既にスキルが有効になっているのであれば無効/有効等の操作は必要ありません。
※デバイスの検出方法:Alexaアプリの[デバイス]タブで一番上の右側+ – [デバイスの追加] – [その他]

step2:pythonプログラム

プログラムは参照ページとほとんど同じですが、もし他の方が使うときに変更する可能性があるところを中心に若干の説明を書きます。

今回、雨の時に起動するセンサー番号は22、晴れの場合は23としました。
rain_sensor_no = 22
shine_sensor_no = 23

地点コードは以下から取得します。
https://weather.tsukumijima.net/primary_area.xml
札幌の地点コードは016010でした。
city_code = “016010” #札幌市

天気は日中の予報で判断します。6時から12時までの予報と12時から18時までの予報どちらかが30%以上の場合、雨と判断するようにしました。
threshold = 30

確認用に取得した天気情報はresponse.txtというファイルに保存しておきます。
response_file = ‘./response.txt’

仮想センサーの起動は「スイッチでAlexa定型アクションを起動~スキルテスト編~(https://km2-system.com/2022/06/29/alexa_switch_4/)」で作成したalexa_trigger.pyをちょっとだけ改造し、関数化してimport可能なファイルにしました。
仮想センサーを起動するプログラムは以下のalexa_trigger_func.pyファイルをimportし、センサー番号を引数に渡して関数alexa_trigger()を呼べば良いだけになります。
※client_ID = 、client_secret = にはスキルビルド画面のアクセス権限メニューから取得したAlexaクライアントIDとAlexaクライアントシークレットを貼り付けます。(index.jsと同じもの) 「スイッチでAlexa定型アクションを起動~スキルテスト編~」参照

alexa_trigger_func.py

import sys
import os
import json
import datetime
from trace import Trace
import requests

client_ID = ''
client_secret = ''
token_file = './token.txt'

def alexa_trigger(no):
  sensor_name = 'dummy_sensor_' + format(no, '0>3')
  # アクセストークンが有効かどうかチェックする
  access_token = ''
  access_token_valid = False
  if os.path.exists(token_file):
    #ファイル有、データ読み込み
    f = open(token_file)
    data = f.read()
    f.close()
    jdata = json.loads(data)
    expires = jdata['expires_in']
    f_time = os.path.getmtime(token_file)
    file_time = datetime.datetime.fromtimestamp(f_time)
    expiring_date = datetime.datetime.fromtimestamp(f_time + expires)
    today = datetime.datetime.today()
    if today < expiring_date:
      # アクセストークン有効、取得
      access_token_valid = True
      access_token = jdata['access_token']

  # アクセストークンが無効の場合はoa2サーバーから取得
  if not access_token_valid:
    header_str = {
      'Content-Type': 'application/x-www-form-urlencoded;Accept-Charset=UTF-8'
    }
    body_str = {
      "grant_type": "refresh_token",
      "refresh_token": refresh_token,
      "client_id": client_ID,
      "client_secret": client_secret
    }
    response = requests.post('https://api.amazon.com/auth/o2/token', data = body_str, headers = header_str)
    if response.status_code != 200 :
      print(response.status_code)
      exit()
    jdata = json.loads(response.text)
    access_token = jdata['access_token']
    if len(access_token) > 0:
      access_token_valid = True
      # ファイルに書き込み
      f = open(token_file, 'w')
      f.write(response.text)
      f.close()

  if not access_token_valid:
    print('can not get access_token')
    exit()

  # イベントゲートウェイにイベントを送信する
  message_id = sensor_name + '_' + str(today)
  timeOfSample = today.strftime('%Y-%m-%dT%H:%M:%S.00Z')
  header_str = {
      'Authorization': 'Bearer ' + access_token,
      'Content-Type': 'application/json'
  }
  json_str = {
    "event":{
      "header":{
        "namespace":"Alexa",
        "name":"ChangeReport",
        "messageId":message_id,
        "payloadVersion":"3"
      },
      "endpoint":{
        "scope":{
          "type":"BearerToken",
          "token":access_token
        },
        "endpointId":sensor_name
      },
      "payload":{
        "change":{
          "cause":{
            "type":"PHYSICAL_INTERACTION"
          },
          "properties":[
            {
              "namespace":"Alexa.MotionSensor",
              "name":"detectionState",
              "value":"DETECTED",
              "timeOfSample":timeOfSample,
              "uncertaintyInMilliseconds":0
            }
          ]
        }
      }
    },
    "context":{
      "properties": [
        {
          "namespace": "Alexa.EndpointHealth",
          "name": "connectivity",
          "value": {
            "value": "OK"
          },
          "timeOfSample": timeOfSample,
          "uncertaintyInMilliseconds": 0
        }
      ]
    }
  }

  response = requests.post('https://api.fe.amazonalexa.com/v3/events', json = json_str, headers = header_str)
  if response.status_code == 202:
    print('success')
  else:
    print(response)

天気を予報するプログラムは以下のようになります。
weather.py

import requests
import json
import alexa_trigger_func

city_code = "016010"    #札幌市
threshold = 30
rain_sensor_no = 22
shine_sensor_no = 23
response_file = './response.txt'

# URL
url = "https://weather.tsukumijima.net/api/forecast/city/" + city_code
try:
    # 天気予報取得
    response = requests.get(url)
    # 通信が正常でなければ例外発生
    response.raise_for_status()
except requests.exceptions.RequestException as e:
    # エラー処理
    res_text = "Error:{}".format(e)
    print(res_text)
else:
    res_text = response.text
    # json解析
    weather_json = response.json()
    # データ取得
    yesterday = weather_json["forecasts"][1]["date"]
    chanceOfRain1 = weather_json["forecasts"][1]["chanceOfRain"]["T06_12"]
    chanceOfRain2 = weather_json["forecasts"][1]["chanceOfRain"]["T12_18"]

    am_per = int(chanceOfRain1[:-1])
    pm_per = int(chanceOfRain2[:-1])
    if am_per >= threshold or pm_per >= threshold:
        alexa_trigger_func.alexa_trigger(rain_sensor_no)
    else:
        alexa_trigger_func.alexa_trigger(shine_sensor_no)
finally:
    # fileに保存
    f = open(response_file, 'w')
    f.write(res_text)
    f.close()

step3:起動される定型アクションの作成

雨の場合と晴れの場合でそれぞれ仮想センサーの検知に対応する定型アクションを作成します。
実行条件で[スマートホーム]から該当するダミーセンサーを選択します。
アクションはとりあえずアレクサが「明日は雨です」、「明日は晴れです」と喋るだけにします。

step4:pythonプログラムの動作確認

同じフォルダにweather.pyをalexa_trigger_func.pyを置き、以下のコマンドを打ち込み動作確認します。pythonがインストールされているならばlinuxでもWindowsでも動作します。
$ python weather.py⏎
Alexaが「明日は雨です」、「明日は晴れです」などと喋れば成功です。
response.txtの内容も確認します。
$ nano response.txt

step5:pythonプログラムの起動設定について

24時間起動したラズベリーパイにプログラムを置き、毎日決まった時間にプログラムを起動するようにします。
crontabなどlinuxのシステムでそれを実現することは可能ですが、メンテナンス性という観点ではAlexaアプリの定型アクションを使う方が良いと考えました。
時刻で起動する定型アクションからNode-REDを使ってpythonプログラムを起動します。全体の流れは以下のようになります。
定型アクション(時刻起動) -> Node-RED -> pythonプログラム -> (晴/雨)定型アクション

step6:Node-REDにデバイス作成

詳細は以下を参照して下さい。ここでは簡潔に書きます。
Alexaによる野球チャンネルの自動化2(Nature API)
https://km2-system.com/2022/06/13/auto_tv_channnel_2/

Node-RED Alexa Home Skill Bridgeのページ(https://alexa-node-red.bm.hardill.me.uk/)にログインします。
[Add Device]ボタンを押下して新しいデバイスを作成します。

NameはここではRaspberryPiLiving2としました。
起動するだけなのでActionsにはOnだけにチェックします。
Action TypeはSWITCHにします。
[OK]ボタンを押下して設定します。

Alexaアプリでデバイスの検出を行い、Node-REDのデバイスを追加します。

step7:ラズベリーパイのNode-REDでフロー作成

ラズベリーパイにNode-REDをインストールしていない場合はインストールします。
※参照;Alexaによる野球チャンネルの自動化2(Nature API)

ブラウザを起動し、http://xxx.xxx.xxx.xxx:1880を開きます。xxx.xxx.xxx.xxxはラズベリーパイのIPアドレスです。ラズベリーパイでも別のPCからでも構いません。
Node-REDにalexa-home-skillを追加していない場合は追加します。
※参照;Alexaによる野球チャンネルの自動化2(Nature API)

パレットから[alexa-home]を配置し、追加したデバイス(上記の例ではRaspberryPiLiving2)を設定します。nodeの表示が[alexa-home] から[RaspberryPiLiving2]に変わります。

パレットから[exec]を配置し、[RaspberryPiLiving2]と繋げます。
execノードをダブルクリックし、コマンドを設定します。
python /home/pi/weather.py ディレクトリ’/home/pi/’の部分は自分の環境に合わせます。
名前にweather triggerなどと入れ[完了]ボタンを押します。

「デプロイ」ボタンでフローを送信します。

step8:alexa定型アクションでフローの起動を設定

定型アクションを作成します。
実行条件の設定で[スケージュール]を選択し、[繰り返し]と[時刻]を設定します。
アクションを追加で[スマートホーム]-[全てのデバイス]-[RaspberryPiLiving2]を選択します。

私はこれを夜寝る時間に設定し、そろそろ寝るだよという合図に使っております。

スイッチでAlexa定型アクションを起動~工作編~

ダイソーのテンキーで定型アクションを起動することはできました。
注文したラズベリーパイゼロ2も到着しました。モバイル機器にできないかと思案したところ、キャン★ドゥのスマホ充電器(税込550円)を使うことを思いつきました。ラズパイゼロ2に繋げたところ、問題なく動きます。

良い箱がないかと物色したところ、キャン★ドゥに丁度良い木箱がありました。

テンキーがピタリと入ります。

木箱にゼロ2の基盤を押し付け、ネジ穴をペンでなぞり、プロクソンのテーブルドリルで2.5mmの穴をあけました。

ゼロ2をネジで止めます。

テンキーはUSB-Aとmicro-Bへの変換コネクタ経由でゼロ2に接続しているのですが、ケーブルが長すぎ、コネクタが邪魔すぎるので切ってしまいました。

テンキー側は赤黒白青、変換ケーブル側は赤黒白緑です。赤-赤、黒-黒、白-白、青-緑と繋げれば良いのかなと、試しに繋いでみましたが動きません。テンキーのLEDすら点きません。他の組み合わせの接続も試してみたのですが、うまくいきませんでした。
最後に思いついたのは、切り取ってしまったコネクタ同士を繋いで導通を測る、でした。

赤-黒、黒-緑、白-赤、青-白を繋げばよいことがわかりました。
変換コネクタの方が普通の色の使い方をしていないです。
テスト後はんだ付けして動作確認しましたが、うまく動きません。調べたところ、キャン★ドゥのSDカードが壊れていることがわかりました。不良品と言うより、接続を試しているときにショートさせてしまい、それが原因で壊してしまった感じがします。
Amazon最安値32GBのものに交換して再セットアップし、動作確認しました。

テンキーで箱の蓋をして使おうということで、テンキーと電池のがたつき防止のために、紙粘土を使うことにしました。これもキャン★ドゥで購入。

ピンク色のケーブルは充電用です。

テンキー裏の針金状のスタンドは電気的に危なそうなので外しました。

テンキーを上に乗せると意外とイイ感じです。

輪ゴムでテンキーを押さえて完成です。

やっつけ感が凄いですが、運用にいろいろ懸念事項があるので当面これで使ってみます。

懸念事項
・電源スイッチはあった方がいいのでは?⇒必要。
・電池の持ち、充電間隔など⇒24時間くらいしか持たない。充電時間は6時間ほど。
・蓋開けが必要となる頻度⇒充電中はバッテリのインジケーターが見たい。

(2022/8/8追記)
電源をブチっと切るとSDカードが壊れて起動できなくなる可能性があることがわかりました。
LegacyのBuster版では対策されているようですが、Bullseye版でもそれらしいメニューがあるのでSDを書き込み禁止にし、一応の電源ぶつ切り対策をしてみました。

あらかじめ、/home/pi/にあるtoken.txtを削除します。
sudo raspi-config⏎
[Performance Option]-[Overlay File System]を選択し、Would you like the overlay file system to be enabled?をにします。
再起動すると、SDは書き込み禁止になっています。
token.txtは作成されますがRAM上にあり、再起動すると元のファイルがない状態に戻っています。

スイッチでAlexa定型アクションを起動~人感センサー編~

キーボード入力から定型アクションが起動できたので、GPIO入力からも当然同じことができる筈です。防犯カメラの時に試しに買っていた人感センサがあるのでそれで試してみました。

以下のページを参照しました。

【Raspberry Pi】自作人感センサーの使い方と活用法
https://chasuke.com/motionsensor/

メスーメスのケーブルを持ってなかったのでブレッドボードを使いました。

ページに掲載したプログラムを動かすとちゃんと動きました。
alexa_trigger.pyは関数化して同じプログラム内に入れ込みました。問題なく動作しました。
以下、プログラムです。

import sys
import os
import json
import datetime
from trace import Trace
import requests
from datetime import datetime
import time
import RPi.GPIO as GPIO

def detect_sensor( no ):
  client_ID = ''
  client_secret = ''
  refresh_token=''
  sensor_name = 'dummy_sensor_' + format(no, '0>3')
  token_file = './token.txt'
  today = datetime.today()
  
  # アクセストークンが有効かどうかチェックする
  access_token = ''
  access_token_valid = False
  if os.path.exists(token_file):
    #ファイル有、データ読み込み
    f = open(token_file)
    data = f.read()
    f.close()
    jdata = json.loads(data)
    expires = jdata['expires_in']
    f_time = os.path.getmtime(token_file)
    file_time = datetime.fromtimestamp(f_time)
    expiring_date = datetime.fromtimestamp(f_time + expires)
    if today < expiring_date:
      # アクセストークン有効、取得
      access_token_valid = True
      access_token = jdata['access_token']
  
  # アクセストークンが無効の場合はoa2サーバーから取得
  if not access_token_valid:
    header_str = {
      'Content-Type': 'application/x-www-form-urlencoded;Accept-Charset=UTF-8'
    }
    body_str = {
      "grant_type": "refresh_token",
      "refresh_token": refresh_token,
      "client_id": client_ID,
      "client_secret": client_secret
    }
    response = requests.post('https://api.amazon.com/auth/o2/token', data = body_str, headers = header_str)
    if response.status_code != 200 :
      print(response.status_code)
      exit()
    jdata = json.loads(response.text)
    access_token = jdata['access_token']
    if len(access_token) > 0:
      access_token_valid = True
      # ファイルに書き込み
      f = open(token_file, 'w')
      f.write(response.text)
      f.close()

  if not access_token_valid:
    print('can not get access_token')
    exit()
  
  # イベントゲートウェイにイベントを送信する
  message_id = sensor_name + '_' + str(today)
  timeOfSample = today.strftime('%Y-%m-%dT%H:%M:%S.00Z')
  header_str = {
      'Authorization': 'Bearer ' + access_token,
      'Content-Type': 'application/json'
  }
  json_str = {
    "event":{
      "header":{
        "namespace":"Alexa",
        "name":"ChangeReport",
        "messageId":message_id,
        "payloadVersion":"3"
      },
      "endpoint":{
        "scope":{
          "type":"BearerToken",
          "token":access_token
         },
         "endpointId":sensor_name
      },
      "payload":{
        "change":{
          "cause":{
            "type":"PHYSICAL_INTERACTION"
          },
          "properties":[
            {
              "namespace":"Alexa.MotionSensor",
              "name":"detectionState",
              "value":"DETECTED",
              "timeOfSample":timeOfSample,
              "uncertaintyInMilliseconds":0
            }
          ]
        }
      }
    },
    "context":{
      "properties": [
        {
          "namespace": "Alexa.EndpointHealth",
          "name": "connectivity",
          "value": {
            "value": "OK"
          },
          "timeOfSample": timeOfSample,
          "uncertaintyInMilliseconds": 0
        }
      ]
    }
}
  
  response = requests.post('https://api.fe.amazonalexa.com/v3/events', json = json_str, headers = header_str)
  if response.status_code == 202:
    print('success')
  else:
    print(response)


# インターバル
INTERVAL = 3
# スリープタイム
SLEEPTIME = 20
# 使用するGPIO
GPIO_PIN = 18

GPIO.setmode(GPIO.BCM)
GPIO.setup(GPIO_PIN, GPIO.IN)

if __name__ == '__main__':
    try:
        print ("処理キャンセル:CTRL+C")
        cnt = 1
        while True:
            # センサー感知
            if(GPIO.input(GPIO_PIN) == GPIO.HIGH):
                print(datetime.now().strftime('%Y/%m/%d %H:%M:%S') +
                ":" + str("{0:05d}".format(cnt)) + "回目の人感知")
                detect_sensor( '0' )
                cnt = cnt + 1
                time.sleep(SLEEPTIME)
            else:
                print(GPIO.input(GPIO_PIN))
                time.sleep(INTERVAL)
    except KeyboardInterrupt:
        print("終了処理中...")
    finally:
        GPIO.cleanup()
        print("GPIO clean完了")

タクトスイッチ等を使っても当然できることでしょう。pico Wの発売が待たれます。

スイッチでAlexa定型アクションを起動~ラズベリーパイ編~

ラズベリーパイをセットアップします。

Step1:OSのインストール

ラズベリーパイのホームページ(https://www.raspberrypi.com/software/)からimagerをダウンロードします。imagerはOSのバージョンが固定されているようなのでOSがバージョンアップされるとimagerもダウンロードしなければならないようです。v.1.7.2で説明します。
imagerを起動し、OSを書き込みます。
CLI版を使用するのでOSは[Raspberry Pi OS(other)]-[Raspberry Pi OS Lite(32bit)]を選択します。
書き込みが終わったらSDをラズベリーパイに差し替え、電源を入れます。
キーボードの設定画面(Configuring Keyboard-Configuration)が現れます。日本語の普通のキーボードの場合、[Other]-[Japanese]-[Japanese – Japanese(OADG 109A)]を選択します。
ユーザー名とパスワード入力します。
コマンドプロンプトが出来たら設定を行います。
$ sudo raspi-config⏎

wifi設定:[1 System Options]-[S1 Wireless LAN]-[JP Japan]-[SSIDの入力]-[パスワードの入力]
SSH設定:[3 Interface Options]-[I2 SSH]-[YES]
ファイルシステムの拡張:[6 Advanced Options]-[A1 Expand Filesystem]

最新バージョンへアップデートします。
$ sudo apt update⏎
$ sudo apt upgrade⏎ Y/nでY

Step2:ファイルの転送

スキルテスト編で作成したpythonプログラム(alexa_trigger.py)とシェルスクリプトをラズベリーパイのホームディレクトリに転送します。
シェルスクリプトは全く単純です。
ファイル名はalexa_key.shとしました。

#!/usr/bin/bash

while :
do
  read CMD
  case "$CMD" in
  "0") python alexa_trigger.py 0;;
  "1") python alexa_trigger.py 1;;
  "2") python alexa_trigger.py 2;;
  "3") python alexa_trigger.py 3;;
  "4") python alexa_trigger.py 4;;
  "5") python alexa_trigger.py 5;;
  "6") python alexa_trigger.py 6;;
  "7") python alexa_trigger.py 7;;
  "8") python alexa_trigger.py 8;;
  "9") python alexa_trigger.py 9;;
esac
done

シェルスクリプトに実行権限を与えます。
$ chmod 777 alexa_key.sh
試しにテストしてみます。
$ ./alexa_key.sh
0⏎
success

成功したら、起動時にシェルスクリプトが起動されるようにします。
ホームディレクトリにある.profileファイルを編集します。
$ nano .profile⏎
最後の行に1行追加します。
./alexa_key.sh

Ctrl + O⏎
Ctrl + W⏎
で保存します。

キーボードをテンキーに付け替え、再起動します。
これで完了です。

定型アクションを作成すれば10個のアクションがキーで動作します。
Lambda関数とシェルスクリプトに少し修正を加えれば動作するアクションを増やすことができるはずです。

.profileに変更を加えたことでSSH接続時にもシェルスクリプトalexa_key.shが実行された状態になっています。まず最初にCtrl+C⏎でシェルスクリプトを終了させる必要があります。

スイッチでAlexa定型アクションを起動~スキルテスト編~

有効にしたAlexaスマートホームスキルをテストしてみします。

Step1:リフレッシュコードの取得

スキルの有効時にアクセス権限を許可され、リフレッシュトークンを取得したはずですので確認します。AWSのページでCloudWatchサービスを選択します。
CloudWatchのメニューっから「ロググループ」を選択します。

作成した関数のロググループができているので、クリックします。

ログストリームにスキルを有効にした時刻のタイムスタンプでログができているので、選択します。

上から2番目のログでINFO StatusCode:200と表示されアクセス権限の取得に成功しているのがわかります。その下のログはINFO AcceptGrant:token_=となっており取得したトークンを記録しています。ログ左側の▲をクリックしてログを展開します。

コピーを押下してログをコピーし、リフレッシュトークンをメモしておきます。AcceptGrant:token=より先がリフレッシュトークンとなります。

Step2:イベント送信プログラムの完成

イベント送信プログラムは「あんスマ」ではphpで書かれていましたが、phpのインストール手間が面倒なのでpythonで書き直しました。動作上の違いは「あんスマ」のものは検出イベント送信後、直ぐに非検出を送信していたのですが、問題なさそうなので検出だけにしました。

client_ID = 、client_secret = にはスキルビルド画面のアクセス権限メニューから取得したAlexaクライアントIDとAlexaクライアントシークレットを貼り付けます。(index.jsと同じもの)

refresh_token=にはStep1で取得したリフレッシュトークンを貼り付けます。 ファイル名はalexa_trigger.pyとしました。

import sys
import os
import json
import datetime
from trace import Trace
import requests

client_ID = ''
client_secret = ''
refresh_token=''
sensor_name = 'dummy_sensor_' + format(sys.argv[1], '0>3')
token_file = './token.txt'
today = datetime.datetime.today()

# アクセストークンが有効かどうかチェックする
access_token = ''
access_token_valid = False
if os.path.exists(token_file):
  #ファイル有、データ読み込み
  f = open(token_file)
  data = f.read()
  f.close()
  jdata = json.loads(data)
  expires = jdata['expires_in']
  f_time = os.path.getmtime(token_file)
  file_time = datetime.datetime.fromtimestamp(f_time)
  expiring_date = datetime.datetime.fromtimestamp(f_time + expires)
  if today < expiring_date:
    # アクセストークン有効、取得
    access_token_valid = True
    access_token = jdata['access_token']

# アクセストークンが無効の場合はoa2サーバーから取得
if not access_token_valid:
  header_str = {
    'Content-Type': 'application/x-www-form-urlencoded;Accept-Charset=UTF-8'
  }
  body_str = {
    "grant_type": "refresh_token",
    "refresh_token": refresh_token,
    "client_id": client_ID,
    "client_secret": client_secret
  }
  response = requests.post('https://api.amazon.com/auth/o2/token', data = body_str, headers = header_str)
  if response.status_code != 200 :
    print(response.status_code)
    exit()
  jdata = json.loads(response.text)
  access_token = jdata['access_token']
  if len(access_token) > 0:
    access_token_valid = True
    # ファイルに書き込み
    f = open(token_file, 'w')
    f.write(response.text)
    f.close()

if not access_token_valid:
  print('can not get access_token')
  exit()

# イベントゲートウェイにイベントを送信する
message_id = sensor_name + '_' + str(today)
timeOfSample = today.strftime('%Y-%m-%dT%H:%M:%S.00Z')
header_str = {
    'Authorization': 'Bearer ' + access_token,
    'Content-Type': 'application/json'
}
json_str = {
  "event":{
    "header":{
      "namespace":"Alexa",
      "name":"ChangeReport",
      "messageId":message_id,
      "payloadVersion":"3"
    },
    "endpoint":{
      "scope":{
        "type":"BearerToken",
        "token":access_token
       },
       "endpointId":sensor_name
    },
    "payload":{
      "change":{
        "cause":{
          "type":"PHYSICAL_INTERACTION"
        },
        "properties":[
          {
            "namespace":"Alexa.MotionSensor",
            "name":"detectionState",
            "value":"DETECTED",
            "timeOfSample":timeOfSample,
            "uncertaintyInMilliseconds":0
          }
        ]
      }
    }
  },
  "context":{
    "properties": [
      {
        "namespace": "Alexa.EndpointHealth",
        "name": "connectivity",
        "value": {
          "value": "OK"
        },
        "timeOfSample": timeOfSample,
        "uncertaintyInMilliseconds": 0
      }
    ]
  }
}

response = requests.post('https://api.fe.amazonalexa.com/v3/events', json = json_str, headers = header_str)
if response.status_code == 202:
  print('success')
else:
  print(response)

起動方法は引数にセンサーNo.を渡します。
例えばダミーセンサー0を検出状態にするにはコマンドラインで以下のように実行します。

>python alexa_trigger.py 0

このプログラムの注意点はカレントディレクトリにtoken.txtいうファイルを生成することです。取得したアクセストークンには有効期限があるので、有効期限内は保存したファイルからアクセストークンを取り出します。初回や有効期限が切れた場合は新たにアクセストークンを通信により取得します。有効期限はアクセストークンが含まれたメッセージ中に存在しますが、3600秒=1時間となっています。アクセストークン取得時はレスポンスがやや遅くなります。
アクセストークンを取得後、イベントゲートウェイにイベントを送信します。

Step3:定型アクションの作成とテスト

テスト用の定型アクションを作成します。
実行条件に「スマートホーム」-「ダミーセンサー000」を指定し、モーションの状態を検出にします。
アクションは「Alexaのおしゃべり」-「カスタム」で「ダミーセンサーゼロ!」とテキトーに設定し、保存します。

>python alexa_trigger.py 0

をWindowsのコマンドラインで実行すると”success”と出力され、echoが「ダミーセンサーゼロ」と喋るはずです。
成功しなかった場合は401などHTTPステータスコードを出力します。
成功した場合は202が帰ります。これは受理したが処理はまだ、という意味で、表面的なチェックは成功したということです。エンドポイントの文字列を間違っている場合は202が帰ってきますが、イベントは発生しません。

ここまでうまくいきましたでしょうか?ここまでできれば終わったも同然です。後は不要と言う方も多い事でしょう。
以上でテスト編は終わりです。
次はラズベリーパイセットアップ編です。