Closed30

猫を盗撮する作戦

(love cat)(love cat)

背景

我が家にはカメラ付きオートフィーダーがあるのだが、猫が時間通りに食べに来ない場合は当然映らない。
ついこの間、猫が爆睡してたか何かでご飯が出る時間に現れなかったときは不安のあまり仕事にならなかった。(お昼ごろにご飯追加で出したら現れたので早退せずに済んだ)
これでは良くないのでラズパイによるカメラを設置し、猫を検知したら撮影→何らかの手段で通知してくるという状態を目指す。

(love cat)(love cat)

必須

  • 猫を検知したら撮影したい
  • 自分が自宅にいる間は必要ない
    • ONとOFFを切り替えるスイッチが欲しい
    • OFFにしたままでかけてやきもきすることがありそうなのでリモートでも切り替えたい
  • 撮影したらスマホで確認可能な通知が欲しい

ex

  • ゆくゆくはリアルタイムで中継も可能になると楽しい
(love cat)(love cat)

Rustが好きなのでRustでやっていく。
GPIOの操作にはrppalを、カメラの操作には...rascamというcrateがあるようなので使えるか確認。

(love cat)(love cat)

とにかく撮れれば何でもいいので一旦は実現手段にはこだわるまい。
下記のコマンドを叩くとコマンドラインから写真が撮影できる。
これをRustから行えればいい。

libcamera-jpeg -o photo.jpg
(love cat)(love cat)

というわけで何か反応したら写真を撮るコード。

use rppal::gpio::{Gpio, Level};
use std::{error::Error, process::Command};

const GPIO17: u8 = 17;

fn main() -> Result<(), Box<dyn Error>> {
    let mut pir = Gpio::new()?.get(GPIO17)?.into_input();
    pir.set_interrupt(rppal::gpio::Trigger::Both)?;

    loop {
        match pir.poll_interrupt(true, None) {
            Ok(trigger) => match trigger {
                Some(Level::High) => {
                    Command::new("libcamera-jpeg")
                        .args(["-o", "cat.jpg"])
                        .output()?;
                    println!("!!");
                }
                _ => (),
            },
            _ => break,
        }
    }
    Ok(())
}
(love cat)(love cat)

ファイル名に現在日時を含める。
今思いついたけど写真置き場はフルパスで指定するように後で変更しよう。

use chrono::Local;
use rppal::gpio::{Gpio, Level};
use std::{error::Error, process::Command};

const GPIO17: u8 = 17;

fn main() -> Result<(), Box<dyn Error>> {
    let mut pir = Gpio::new()?.get(GPIO17)?.into_input();
    pir.set_interrupt(rppal::gpio::Trigger::Both)?;

    loop {
        match pir.poll_interrupt(true, None) {
            Ok(trigger) => match trigger {
                Some(Level::High) => {
                    let dt = Local::now();
                    Command::new("libcamera-jpeg")
                        .args([
                            "-o",
                            format!("image_{}.jpg", dt.format("%Y%m%d%H%M%S")).as_str(),
                        ])
                        .output()?;
                    println!("!!");
                }
                _ => (),
            },
            _ => break,
        }
    }
    Ok(())
}
(love cat)(love cat)

とりあえずこれだけで水飲み場に設置し、明日猫がいい感じに写っているかどうか確認。

(love cat)(love cat)

nohup実行にするのを忘れてssh接続切って寝たのでちゃんと撮れるかどうかまだわからない。
あと猫がご飯時以外ずっと枕元にいて水飲みに行ってた気がしない。さっさと完成させて健康管理にも役立てたい。
LINE notifyの方を先に作ってしまおう。

(love cat)(love cat)

猫がカメラに興味津々なのがちょっと気になるがいい感じに撮れている。
この1回の水飲みで約1.2MiBのjpgファイルが14ファイルできた。
撮影コマンドのオプションを調べるのと、PIRセンサーの設定を色々試してみたほうがよさそう。

(love cat)(love cat)

というわけで検知してLINE通知するところまで。
環境変数LINE_TOKENにアクセストークンを設定しておけば動くはず。

use chrono::Local;
use reqwest::blocking::{multipart, Client};
use rppal::gpio::{Gpio, Level};
use std::{env, error::Error, process::Command};

const GPIO17: u8 = 17;

fn main() -> Result<(), Box<dyn Error>> {
    let mut pir = Gpio::new()?.get(GPIO17)?.into_input();
    pir.set_interrupt(rppal::gpio::Trigger::Both)?;

    let line_token =
        env::var("LINE_TOKEN").expect("LINE_TOKEN is empty. Set the access token to LINE_TOKEN");
    let image_dir = "/tmp/cat-sv";

    loop {
        match pir.poll_interrupt(true, None) {
            Ok(trigger) => match trigger {
                Some(Level::High) => {
                    let dt = Local::now();
                    let file_name =
                        format!("{}/image_{}.jpg", image_dir, dt.format("%Y%m%d%H%M%S"));
                    Command::new("libcamera-jpeg")
                        .args(["-o", file_name.as_str()])
                        .output()?;
                    println!("!!");
                    let client = Client::new();
                    let form = multipart::Form::new()
                        .text("message", "Detected")
                        .file("imageFile", file_name)?;
                    let req = client
                        .post("https://notify-api.line.me/api/notify")
                        .bearer_auth(&line_token)
                        .multipart(form);
                    let res = req.send()?;
                    println!("{:?}", res);
                }
                _ => (),
            },
            _ => break,
        }
    }
    Ok(())
}
(love cat)(love cat)
  • 待つのがrppal::gpio::Trigger::Bothである必要がない
  • ディレクトリがなかったときに死ぬのがちょっと嫌だ
  • いつイベントが起きたとか送った結果とかログで欲しい
(love cat)(love cat)
fn main() -> Result<(), Box<dyn Error>> {
    env_logger::init();

    let mut pir = Gpio::new()?.get(GPIO17)?.into_input();
    pir.set_interrupt(rppal::gpio::Trigger::RisingEdge)?;

    let line_token =
        env::var("LINE_TOKEN").expect("LINE_TOKEN is empty. Set the access token to LINE_TOKEN");
    let image_dir = "/tmp/cat-sv";

    match std::fs::create_dir(image_dir) {
        Err(e) if e.kind() == ErrorKind::AlreadyExists => log::info!("{}", e),
        Err(e) => {
            log::error!("{}", e);
            return Err(Box::new(e));
        }
        Ok(_) => (),
    }

    loop {
        match pir.poll_interrupt(true, None) {
            Ok(_) => {
                let dt = Local::now();
                let file_name = format!("{}/image_{}.jpg", image_dir, dt.format("%Y%m%d%H%M%S"));
                Command::new("libcamera-jpeg")
                    .args(["-o", file_name.as_str()])
                    .output()?;
                log::info!("snap: {}", file_name);
                let client = Client::new();
                let form = multipart::Form::new()
                    .text("message", "Detected")
                    .file("imageFile", file_name)?;
                let req = client
                    .post("https://notify-api.line.me/api/notify")
                    .bearer_auth(&line_token)
                    .multipart(form);
                let res = req.send()?;
                log::info!("response: {:?}", res);
            }
            e => log::error!("{:?}", e),
        }
    }
}

(love cat)(love cat)

家の外からでもON/OFF切り替えたいという気持ちは本物だが、それ以上に能動的に外の世界との通信経路開けたくない(セキュリティとかよくわからんので)。
試運転してみた感じ、つけっぱでも近くをうろちょろする分にはあまり問題ないかもしれない。
でもヒトによる水の交換のたびにLINEがポコポコなるのはうるさいからパッとOFFにしておく手段は欲しい。
なんにせよ必須of必須な検知→撮影→通知はできたので、あとはのんびり実装していく。

(love cat)(love cat)

これは今朝来た通知。何も見えん。
カメラの明るさを調整しよう。
あとファイルサイズでかいなーとは思ってたが、それが猫が水飲んでる間iPhoneに30秒間隔で投げつけられるというのをあまり考えてなかった。

(love cat)(love cat)
libcamera-jpeg --nopreview -o test.jpg

(love cat)(love cat)
libcamera-jpeg --nopreview --brightness 0.2 --ev 0.5 --shutter 2000000 -o test.jpg

(love cat)(love cat)

露出(shutterで制御している)でこんなに変わるんですね。
ちゃんとカメラ触ったことないから全然知らなかった。
(evは効果の程がよくわからないけど付けてた)

我が家は光の入りが悪く水飲み場は常に仄暗いが、これなら日中帯くらいはそれなりに明るく撮れるのではないだろうか。

(love cat)(love cat)
libcamera-jpeg --nopreview --ev 0.5 --shutter 2000000 -e png -o test.png

libcamera-jpegという名前だがpngもbmpも出力できるヨ。

(love cat)(love cat)
libcamera-jpeg --nopreview --ev 0.5 --shutter 2000000 --width 1600 --height 900 -o test.jpg

こんなんでいいだろう。実際のところは日中に試さないとわからないんだけども。

(love cat)(love cat)

今更だけどnopreviewも何もCLIだけなのでどんな写真が撮れたのかすら他のPCにコピーしないとわからない。
監視カメラにすると決めてたら、というか決めた時点でGUIデスクトップ入れといたほうがいいですね。

(love cat)(love cat)

オプションを追加。整形されたら長くなった。
ここはいい感じの写真が送られてくるまで調整かなあ。
そう思うと環境変数とか設定ファイルで設定すべき値のような気がする。

(省略)
    let ev = 0.5.to_string();
    let shutter = 2000000.to_string();
    let width = 1600.to_string();
    let height = 900.to_string();

    loop {
        match pir.poll_interrupt(true, None) {
            Ok(_) => {
                let dt = Local::now();
                let file_name = format!("{}/image_{}.jpg", image_dir, dt.format("%Y%m%d%H%M%S"));

                Command::new("libcamera-jpeg")
                    .args([
                        "-o",
                        file_name.as_str(),
                        "--nopreview",
                        "--ev",
                        &ev,
                        "--shutter",
                        &shutter,
                        "--width",
                        &width,
                        "--height",
                        &height,
                    ])
                    .output()?;
(省略)
(love cat)(love cat)

基本ループに入ったらエラーに遭遇してもログに出すだけで終了しないようにしたら
流石の自分も人前に上げるのをはばかられるぐらい汚くなった。
どう書くのがいいのかわからないな。

(love cat)(love cat)

というかエラーが起きても終了しないのは本当にいいのか?
下記の3パターンがあるのかな。

カメラがおかしい→何か検知したけど撮影は失敗したとLINEに通知してくれればいい
センサーがおかしい→意味ないから終わっていい
ネットワークがおかしい→ログに粛々と検知イベント書いといてくれれば後で見るからいい

(love cat)(love cat)

一旦「ここは通らんだろう」と思うところはunreachableで終わるようにしてしまう。
あとは撮影とファイル送信は関数切り分けようかな。
libcameraの引数は取得部分はとりあえず関数にしたもののどうするか決めかねている。

もともとの目的はある程度達成できたし、これ以上はコードとのだらだらにらめっこ日記になってしまいそうなのでクローズ。

コード
use chrono::Local;
use reqwest::blocking::{multipart, Client};
use rppal::gpio::Gpio;
use std::{env, error::Error, io::ErrorKind, process::Command};

const GPIO17: u8 = 17;
const LINE_NOTIFY_API: &str = "https://notify-api.line.me/api/notify";

fn main() -> Result<(), Box<dyn Error>> {
    env_logger::init();

    log::info!("start!");

    let mut pir = Gpio::new()?.get(GPIO17)?.into_input();
    pir.set_interrupt(rppal::gpio::Trigger::RisingEdge)?;

    let line_token =
        env::var("LINE_TOKEN").expect("LINE_TOKEN is empty. Set the access token to LINE_TOKEN");
    let image_dir = "/tmp/cat-sv";

    match std::fs::create_dir(image_dir) {
        Err(e) if e.kind() == ErrorKind::AlreadyExists => log::info!("{}", e),
        Err(e) => {
            log::error!("{}", e);
            return Err(Box::new(e));
        }
        Ok(_) => (),
    }

    loop {
        match pir.poll_interrupt(true, None) {
            Ok(_) => {
                log::info!("Detect someone!");

                let client = Client::new();
                let dt = Local::now();
                let file_name = format!("{}/image_{}.jpg", image_dir, dt.format("%Y%m%d%H%M%S"));

                let libcam = Command::new("libcamera-jpeg")
                    .args(["-o", file_name.as_str()])
                    .args(get_options())
                    .output();

                if let Err(e) = libcam {
                    log::error!("{}", e);
                    let req = client
                        .post(LINE_NOTIFY_API)
                        .body("detected, but failed to snap.")
                        .bearer_auth(&line_token);
                    match req.send() {
                        Ok(res) => log::info!("{:?}", res),
                        Err(e) => log::error!("{}", e),
                    }
                    continue;
                }
                log::info!("snap: {}", file_name);

                let form = multipart::Form::new()
                    .text("message", "Detected")
                    .file("imageFile", file_name);
                if let Err(e) = form {
                    log::error!("{}", e);
                    unreachable!()
                }

                let req = client
                    .post(LINE_NOTIFY_API)
                    .bearer_auth(&line_token)
                    .multipart(form.unwrap());
                match req.send() {
                    Ok(res) => log::info!("{:?}", res),
                    Err(e) => log::error!("{}", e),
                }
            }
            e => {
                log::error!("{:?}", e);
                unreachable!()
            }
        }
    }
}

fn get_options() -> Vec<String> {
    let ev = 0.5.to_string();
    let shutter = 2000000.to_string();
    let width = 1600.to_string();
    let height = 900.to_string();
    let libcam_args = [
        "--nopreview",
        "--ev",
        &ev,
        "--shutter",
        &shutter,
        "--width",
        &width,
        "--height",
        &height,
    ];
    libcam_args.map(|e| e.to_string()).to_vec()
}
このスクラップは2022/11/15にクローズされました