プログラマーのメモ書き

伊勢在住のプログラマーが気になることを気ままにメモったブログです

Raspberry Pi Zero + カメラ で遊んでみました (2/2)

前回まででハードウェアのセットアップおよびOSのセットアップが終わったので、やっとここから、 Raspberry Pi で遊んでいきたいと思います。

node.js のインストール

NOOBSでインストールした Raspbian は、はじめから node.js v10.15.0 が入っていました。

pi@raspberrypi:~ $ dpkg --list | grep node
ii  libnode64:armhf                       10.15.2~dfsg-2+rpi1                   armhf        evented I/O for V8 javascript - runtime library
ii  node-normalize.css                    8.0.1-3                               all          Modern alternative to CSS resets
ii  nodejs                                10.15.2~dfsg-2+rpi1                   armhf        evented I/O for V8 javascript - runtime executable
ii  nodejs-doc                            10.15.2~dfsg-2+rpi1                   all          API documentation for Node.js, the javascript platform
ii  nodered                               0.20.5                                armhf        Node-RED flow editor for the Internet of Things
pi@raspberrypi:~ $

一方、npm は未インストールのようでした。
nodeはバージョンアップも激しいので、先々のことを考えて、nvmからインストールします。

まずは、既存のnodeをアンインストールしときます。

pi@raspberrypi:~ $ sudo apt autoremove nodejs
パッケージリストを読み込んでいます... 完了
依存関係ツリーを作成しています                
状態情報を読み取っています... 完了
以下のパッケージは「削除」されます:
  libc-ares2 libnode64 libuv1 nodejs nodejs-doc nodered
アップグレード: 0 個、新規インストール: 0 個、削除: 6 個、保留: 315 個。
この操作後に 87.2 MB のディスク容量が解放されます。
続行しますか? [Y/n] y
(データベースを読み込んでいます ... 現在 132805 個のファイルとディレクトリがインストールされています。)
nodered (0.20.5) を削除しています ...
nodejs (10.15.2~dfsg-2+rpi1) を削除しています ...
libnode64:armhf (10.15.2~dfsg-2+rpi1) を削除しています ...
libc-ares2:armhf (1.14.0-1) を削除しています ...
libuv1:armhf (1.24.1-1) を削除しています ...
nodejs-doc (10.15.2~dfsg-2+rpi1) を削除しています ...
mime-support (3.62) のトリガを処理しています ...
hicolor-icon-theme (0.17-2) のトリガを処理しています ...
gnome-menus (3.31.4-3) のトリガを処理しています ...
libc-bin (2.28-10+rpi1) のトリガを処理しています ...
man-db (2.8.5-2) のトリガを処理しています ...
desktop-file-utils (0.23-4) のトリガを処理しています ...
pi@raspberrypi:~ $ 

公式の説明や、下記の記事などを参考にNVMをインストールします。

Setup Guide: Raspberry Pi | Install Node.js via Node Version Manager (NVM) · cncjs/cncjs Wiki · GitHub

pi@raspberrypi:~ $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.1/install.sh | bash
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 13527  100 13527    0     0  35135      0 --:--:-- --:--:-- --:--:-- 35135
=> Downloading nvm from git to '/home/pi/.config/nvm'
=> Cloning into '/home/pi/.config/nvm'...
remote: Enumerating objects: 286, done.
remote: Counting objects: 100% (286/286), done.
remote: Compressing objects: 100% (256/256), done.
remote: Total 286 (delta 34), reused 93 (delta 17), pack-reused 0
Receiving objects: 100% (286/286), 146.21 KiB | 176.00 KiB/s, done.
Resolving deltas: 100% (34/34), done.
=> Compressing and cleaning up git repository

=> Appending nvm source string to /home/pi/.bashrc
=> Appending bash_completion source string to /home/pi/.bashrc
=> Close and reopen your terminal to start using nvm or run the following to use it now:

export NVM_DIR="$HOME/.config/nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion
pi@raspberrypi:~ $ 

一度、ターミナルを閉じて、再度開いておきます。

NVMがインストール出来たら、nodeをインストールします。

pi@raspberrypi:~ $ nvm install v10.17.0

今回は最新の一つ前のLTS(v10.x)を選びました。

アプリの概要

さて、前の記事の冒頭で簡単に今回ラズパイを使って作るアプリについて触れましたが、改めて整理しておきます。

時々、家族みんなで外出した時に、『ガスコンロの火を消したっけ?』となる場合があります。幸い、消し忘れてたことはないのですが、 こうなると道中しばらくの間、出かける前の行動を振り返りながら、確かに消したよな?と確認する確認作業が続きます。 これ、テンションも下がるし、子供も暇そうでだんだん騒いでくるし、いいこと一つもないので、ここはラズパイの力を借りようと思います。

というわけで、今回作るのは、家のガスコンロのそばにカメラ付きのラズパイを置いて、LINEで要求するとその写真を撮って送り返してくれる、というものにします。

構成

最初は、ラズパイにWebサーバーを入れて、直接ボットサーバーとやり取りしてもいいかと思ったんですが、

  • 家のルータには穴を開けたくない
  • ラズパイ側に ngrok 入れて外からアクセス可能にする方法だと、再起動のたびに外から呼び出すURLを更新しないといけない

というのがあったので、今回は、

  • ボットサーバーは要求を受けたら、撮影指示をファイルにして、AWS S3 に書き込み
  • ラズパイは、S3 にファイルが書き込まれたかを一定間隔で監視し、ファイルを見つけたら、写真を撮って、S3 にアップし、LINEに画像を投げる

という動作とします(なお、以下では便宜上ラズパイ上の処理を行うものを『カメラサーバー』と呼ぶことにします)。

これなら、ネットワークさえつながっていれば、この機能を使うことができます。

早速、作っていきます。

LINE ボットの作成

LINEボットの作成については、あちこちで書かれているので割愛します。 Lambda (node.jsで書きました)でボットサーバーの処理を作って、API Gateway 経由でLINEから呼び出すというパターンにしています。

ボットサーバーの役割は

  • 呼び出されたメッセージの検証
  • 写真撮影のリクエストか否かの判定
  • 写真撮影の場合は、 S3 に所定のファイルを作成

写真撮影のリクエストか否かの判定は単に文字列を比較するだけです。将来、気が向いたら DialogFlow とつなげるのもありかもしれません。

最後の S3 へ撮影指示ファイルを作成するところだけ、説明しておきます。

この撮影指示ファイルは、

{
    "date": "2019-11-22T16:59:19.992+09:00",
    "replyToken": "xxxxxxxxxxxxxxxxxxxxxxxxxx",
    "userId": "xxxxxxxxxxxxxxxxxxxxxx"
}

のようなJSONとしました。

JSON ファイル中に、replyToken と呼び出し元の lineId(JSON中では userId としてます)を含めています。
これは、ラズパイ側から画像メッセージを送る際に、もし可能であればLINEの応答メッセージで送るようにし、 ラズパイ側の処理やネットワークの問題でトークンの有効期限切れなどが発生して応答メッセージ送信が失敗した場合は、 lineId を使って、プッシュメッセージで送る、という方法を取るようにするためです。

また、この撮影指示ファイルを書き込む S3 のバケットは非公開とし、LINEボットサーバーおよびラズパイ上のカメラサーバーが awssdk 経由で直接操作する形としています。

GitHub のリポジトリにソース一式を載せておきましたので、気になる方はご覧ください。

ラズパイ上のカメラサーバー

ラズパイでのカメラサーバーも Lambda に合わせて、node.js で書くことにします。

node.js のメイン処理は、一定間隔で撮影処理(main関数としてまとめています)を呼び出すようなインターバル処理になります。 撮影処理が早く終わった場合は処理を一定時間待ち、撮影処理に時間がかかる場合は処理が終わるのを待ってから次の呼び出しに移ります。

main 関数の処理は、

  • 撮影指示ファイルの存在確認(複数も可)
  • 撮影指示ファイル分のループ

となり、ループ中では、

  • 撮影指示ファイルの取得
  • 写真撮影
  • LINEへ画像メッセージの送信
  • 後始末

という感じになります。

以下、ややこしそうなところだけ説明しておきます。

インターバル処理

最初は、

node.js のメイン処理は、

setInterval(main, PERIOD);

として、一定間隔でループを回すものとします。PERIOD は適当な時間間隔としておきます(現在は10秒)。

としていました。

この部分、思いっきり勘違いしていました。

伊勢ギークフェア当日の会場では、自分のスマホのテザリングでネットにつないでいたのですが、回線が遅いところで動かすと最初の撮影処理が終わる前に、 次のsetIntervalの呼び出し処理が呼ばれしまい、結果的に1回の写真リクエストに対して、3~4枚の写真が送られるということになりました。

setInterval は関数を(非同期で)呼び出したらすぐに戻るんですね。

そこで、メイン処理部分は下記の記事を参考にsleep処理を作成して対策しました。

ES2017 async/await で sleep 処理を書く - Qiita

上記の記事と同様に、sleep 関数を定義しておきます。

lib/utl.js

async function sleep (term) {
    return new Promise((resolve, reject) => {
        setTimeout(resolve, term);
    });
}
exports.sleep = sleep

このsleep関数を使い、interval処理を行う関数を定義します。

lib/utl.js

exports.interval = async function (callback, term) {
    while(true) {
        //const st_dt = Date.now();
        //console.log("start");
        await Promise.all([callback(), sleep(term)]);
        //console.log("end. elapsed is " + (Date.now() - st_dt));
    }
}

最後に、メイン関数は、このinterval関数を呼び出すようにします。

camera.js

// ループ開始
exec_main();

async function exec_main() {
    await util.interval(main, PERIOD);
}

一応、この形で処理に実行時間がかからなければ、インターバルで指定した間隔待ってから処理を呼び出し、呼び出し処理に時間がかかった場合は次の呼び出し処理を待つようにできました (PERIODは10秒です)。

node.js に慣れてるわけではないので、もし、node.js ならこうするのがベターだよ、みたいな方法があれば、ぜひ教えてください。

写真撮影

写真撮影は、

lib/photo.js より抜粋

exports.getPhoto = async function (imgFn, thumbFn) {
    return new Promise(function(resolve, reject) {
        let cwd = process.cwd();
        childProcess.exec(cwd + "/" + PHOTO_SCRIPT + " " + imgFn + " " + thumbFn, function(err, stdout, stderr) {
            if (err) {
                console.error("getPhoto:" + JSON.stringify(err));
                console.error("stderr: " + stderr);
                reject(err);
            } else {
                resolve(stdout);
            }
        });
    });
}

のようにして、シェルスクリプト(上記だと PHOTO_SCRIPT としてスクリプトファイル名を定義しています)を呼び出しています。

呼ばれたシェルスクリプトでは、

lib/photo.sh

#!/bin/bash
#
#

set -eu


function usage() {
    echo "photo.sh  output_filename  output_thumbnail_filename"
}

if [ $# -ne 2 ]
then
    usage
    exit 1
fi

IMG_FN=$1
THUMB_FN=$2

# 撮影
/usr/bin/raspistill -w 640 -h 480 -th none -n -t 10 -q 75 -o ${IMG_FN}

# サムネイル生成
/usr/bin/convert ${IMG_FN} -thumbnail 200x200 ${THUMB_FN}

のように、raspistill コマンドにより、写真を撮り、 convertコマンド(ImageMagick)により、サムネイルファイルを作成します。

サムネイルファイルを作成しているのは、LINEの画像メッセージの仕様で、 画像メッセージを送る場合は、URLで、 画像とそのサムネイルを指定する必要があるためです。

撮影した写真は、S3 のバケット(上記の撮影指示のバケットは別物です)へアップロードします。 こちらのバケットは、撮影指示のバケットとは異なり、公開しています。 また、LINEの画像メッセージの仕様で、httpsが求められるため、 S3 の静的ホスティングの上に、CloudFront をかまして、httpsアクセスを可能としておきます。

なお、CloudFront を利用して https でアクセスする方法については、独自ドメインでの例ですが、こちらの記事をご覧ください。 今回は独自ドメインを使っていないので、SSL証明書なども不要となるので、もっと簡単になります。

LINE へ画像メッセージの送信と後始末

画像のアップロードが成功したら、ボットサーバーの概要で書いたように、LINE API を利用して、

  1. 応答メッセージ
  2. プッシュメッセージ

の順番で画像メッセージが送れるか試行します。

また、両方の方法で画像メッセージを送るのが失敗した場合は、プッシュメッセージにて画像送信に失敗した旨のテキストメッセージを送るようにします。

最後に、メッセージ送信の成否にかかわらず、ラズパイ上に作成した画像ファイルと S3 上の撮影指示を削除しておきます。

その他

公開用のS3バケットにアップロードした画像ファイルは一定期間経過後、定期的に削除しようと思います。 CoudWatch Event を使えば、そんなに難しくなくできそうと思います。

どれぐらい残すかをしばらく使ってみてから決めようと思うので、今回はそこまで組んでません。

動作確認

ここまでできれば、動作確認です。

まずは、LINEボットと友達になります(ボットのQRコードは LINE Developer の画面か LINE 公式アカウントマネージャの画面に表示されています)。

f:id:junichim:20191123193140p:plain

f:id:junichim:20191123193207p:plain

次に、『写真』とか『撮影』とか送ると、しばし時間がたってから、

f:id:junichim:20191123193248p:plain

のように写真が送られてくれば、成功です。 画像をタップすると元の画像が表示されます。

f:id:junichim:20191123193309p:plain

いい感じですね。

ちなみに、この写真は部屋の天井付近にカメラを向けたものです。

カメラサーバーをデーモンとして起動

動作確認して問題なく動くことが確認出来たら、最後は、ラズパイの電源を入れれば、カメラサーバーが起動するようにします。

ちょっと調べると、ラズパイは systemd を使ってサービスを立ち上げるのが今どきの方法のようなので、 /etc/systemd/sytem 以下に camera-server.service ファイルを作成します。

/etc/systemd/system/camera-server.service

[Unit]
Description=camera server
After=syslog.target local-fs.target network-online.target nss-lookup.target

[Service]
Type=simple
WorkingDirectory=/home/pi/work/raspicamera
ExecStart=/home/pi/work/raspicamera/bin/camera_start
Restart=no
StandardOutput=append:/var/log/camera-server/camera-server.log
User=pi
Group=pi

[Install]
WantedBy=multi-user.target

StandardOutput の記述がない場合は、標準出力がそのまま syslog に書き込まれるという非常に便利な動作になります。
ただ、ログの分量を多めに出しているとわかりにくいかと思い、別ファイルにログを出力するように設定を行っています。

また、ラズパイだと、ストレージの容量も限られているので、ログファイルはローテーションも必要だと思われるので、logrotate の設定も行っておきます。

/etc/logrotate.d/camera-server

/var/log/camera-server/camera-server.log {
    rotate 10
    daily
    missingok
    notifempty
    compress
    delaycompress
    postrotate
        /bin/systemctl restart camera-server > /dev/null 2> /dev/null || true
    endscript
}

設定が終わったら、一度ラズパイを再起動して問題なくカメラサーバーが立ち上がっていることを確認しておきます。

問題なく起動されていれば、OKです!

あとは、このラズパイをキッチンのコンロが見える場所に設置すれば完了です。 最後の難関は家族の許可を得ることですが、なんとか頑張ります!

変更履歴

  • 2019/11/25 ラズパイ側のメイン処理をasync/awaitを使ったインターバル処理に変更した点を追記