GRAPH

ノードをドラッグ · ホイールでズーム · 背景をドラッグして移動

グラフを読み込んでいます

← 記事一覧へ

ニワトリ小屋から映像を取得し、ダッシュボードに表示させるまでの仕組み

Wi-Fi などの回線がないニワトリ小屋から、小屋の状況をEufyデバイス 〜 Lambda 〜 CloudFront(ダッシュボード) というフローでデータを表示するための構成を記載する。

はじめに

以前、ニワトリ小屋の温湿度を LINE に通知する仕組みを作りました。

ただ、小屋は自宅から離れた場所にお借りしているため、現地の様子を把握するには直接足を運ぶしかありませんでした。ニワトリが日中どう過ごしているのか、あるいは夜間に害獣が近づいていないかなど、気になることは山ほどあります。

そこで今回は、小屋にカメラを設置し 映像を確認できるようにしてみました。 温湿度の仕組みと同様、「商用電源なし」「Wi-Fiなし」という制約の中での構築です。

以下は、実際にカメラで撮影できた映像です。

2026-09-19 12:00(JST)の回。H.264 / 1280x720 / 15fps / 9.7 秒・1,460,767 バイト。
再生ボタンを押したときだけ動きます

構成図

以下がカメラまわりの構成図となります。

本文では AWS のサービス名を、初出だけ正式名称で書きます。以降は AWS Lambda を「Lambda」、Amazon S3 を「S3」のように略記します。

ニワトリ小屋の映像取得の構成図。EventBridge が毎時0分に撮影用の Lambda を起動し、Lambda は Eufy クラウド経由で P2P を張って10秒ぶんの H.265 を受け取り、原本を S3 に置いたうえで同じ関数の中の FFmpeg レイヤーで H.264 の MP4 に変換する。30分ごとの定期監視 Lambda が S3 を見て映像の途絶を LINE に通知し、家族のブラウザは API 経由で受け取った署名付き URL で S3 から直接 MP4 を取得する。

図を押すと別タブが開きます。
温湿度側(SORACOM から Amazon DynamoDB までの経路)は前回の記事の構成図を参照してください

データの流れ

図の中の番号は、次の順に対応します。

# イベント 詳細
1 撮影の起動 EventBridge が毎時 0 分の cron で CameraCapture(Lambda) を起動
2 認証情報の取得 CameraCapture(Lambda) が SSM Parameter Store から Eufy のアカウント情報を取得
3 Eufy クラウドへのログイン ログインし、カメラと繋ぐための P2P の経路を取得
4 セッションの引き継ぎ 起動時に S3 の session/persistent.json を読み、終了時に更新したものを書き戻し
5 P2P で 映像を受信 Eufy クラウドが仲介した P2P 経路でカメラと繋がり、10 秒ぶんの H.265 を受信
6 原本の保存 受け取ったものをそのまま S3 の raw/ へ保存
7 再生できる形への変換 同じ Lambda の中で、レイヤーに置いた FFmpeg が H.264 の MP4 に変換し S3 の clips/ へ保存
8 死活監視の起動 EventBridge が rate(間隔指定)で 30 分ごとに既存の PeriodicMonitor(Lambda) を起動
9 最新の映像の確認 PeriodicMonitor(Lambda) が S3 の clips/ を見て、直近の映像の時刻を確認
10 撮れていないことの通知 一定時間 新しい映像が来ていなければ LINE へ通知
11 画面から 一覧の取得 CloudFront から返した画面が API Gateway を通り、ReferenceApi(Lambda) が直近 48 時間のスロットと署名付き URL を返却
12 映像の取得 ブラウザが署名付き URL で S3 から MP4 を直接取得して再生

機能詳細

1. 物理デバイス

■ Eufy 4G LTE Cam S330

課題としては、温湿度のときと同じく、「自分で電源を持ち、自分で回線を持つ」機器が必要でした。

そのため 今回は Eufy 4G LTE Cam S330(型番 T86P2)を選択しました。こちらはソーラーパネルと内蔵バッテリーで動き、SIM を挿すことで 4G LTE で通信するというものです。

(出典: Eufy 4G LTE Cam S330 製品ページ)

・ 設置写真(Eufy 4G LTE Cam S330)

ソーラパネルについては、東〜南にかけての設置方角を考慮しました。

小屋の柱に固定したカメラ本体。白い円筒形の機器がレンズを下に向けて取り付けられ、手前には金網と防鳥ネットがある。
柱に固定したカメラ本体
小屋の屋根の縁に取り付けたソーラーパネル。白枠の小型パネルが上向きに角度を付けて固定され、1 本のケーブルがカメラへ伸びている。
屋根の縁に付けたソーラーパネル
・ ソーラパネル稼働画面

晴れの日はおおよそ 1,300 mAh 程度を充電します(2026-09-15 から 09-21 の 7 日間で、最も多かった日の値)。バッテリーの残量と 1 日の充電量は、メーカーのアプリから確認できます。

メーカーアプリのソーラー充電状況の画面。バッテリー残量は 7 日間 80〜93% で推移し、1 日あたりの充電量は 104〜1,290 mAh の棒グラフで表示されている。

2026-09-15 から 09-21 の 7 日間。バッテリーは 80 〜 93 %、1 日の充電量は 104 〜 1,290 mAh でした。
押すと別タブで大きく表示されます

・ 通信量画面

本システムでは回線に povo を利用しており、データトッピングは 30GB / 30 日のものを選んでいます(出典: povo2.0 トッピング一覧、2026-09-22 確認)。 現時点では、1 日 おおよそ 30 〜 60 分程度の視聴で、以下のような通信量となっています。

回線の利用履歴の画面。9 月 12 日から 9 月 20 日にだけ棒が立っており、日平均 0.09GB、期間合計 2.70GB と表示されている。

2026 年 8 月 21 日 〜 9 月 20 日の利用履歴。棒が立っているのは 9 月 12 日以降です。
押すと別タブで大きく表示されます

2. 映像の定期撮影・変換フロー(図 データの流れ #1 〜 #7)

■ 公式 API が存在しない

ここが温湿度のデバイスと大きく違ったところです。

前回の温湿度デバイス(SORACOM)は外部へデータを送信することが前提の機器でした。一方、今回使用したカメラはメーカーのスマホアプリで映像を見ることが前提の製品です。

そのため、一般的なネットワークカメラによくある機能が、このカメラには用意されていませんでした。映像をそのまま外部へ流す仕組み(RTSP)はありません。ネットワーク上の保存機器(NAS)への自動保存もできません。機種をまたいでカメラを操作するための共通規格(ONVIF)にも対応していません。

また、Eufy には Web ブラウザから映像を確認できる機能(Eufy Security Web Portal)も存在します。ただし、手元のアプリ設定にはそれらしき項目が見当たりませんでした。対象地域外なのか、機種自体が非対応なのかは分かっていません。

公式のデータ出力手段が存在しないため、今回は有志が作成・公開しているオープンソースのクライアントライブラリを経由して映像を取り出すことにしました。

■ 非公式クライアントによる P2P 接続

利用したのは eufy-security-client(v4.1.1-1)です。Eufy のクラウドにログインし、そこで得た情報をもとにカメラと P2P で通信を行って映像を取得します。

作者自身が「Anker および Eufy とは無関係」と明記しているとおり、メーカー公式のライブラリではありません。仕様変更などで将来動かなくなるリスクもありますが、今回は個人利用の DIY 構成と割り切った上で採用しています。

なお、このバージョンは Node.js 24 以上が必須のため、Lambda のランタイムも Node.js 24 に合わせました。

(出典: eufy-security-client、npm レジストリ、いずれも 2026-09-19 確認。4.1.1-1 は 2026-07-01 公開、Node.js 24 以上が必要)

(出典: AWS Lambda の Node.js ランタイム、2026-09-19 確認)。

■ 通信タイムアウトの回避(リレー経由の接続)

接続設定は、リレーサーバーを含めた最速の経路を選ぶようにしています。

LTE 回線の SIM を使うカメラは、通信事業者の大きな NAT 網(CGNAT: Carrier-Grade NAT)の内側にあります。割り当てられるのはプライベート IP アドレスです。

そのため、Lambda 側からカメラへ直接 P2P 接続を仕掛けても、宛先になるグローバル IP アドレスが分かりません。直接つなぎに行けないので、接続はタイムアウトで終わります。 最初に試したライブラリでは、Eufy クラウドの応答に含まれる「リレーサーバーの候補」を破棄してしまい、タイムアウトになってしまいました。

そこで eufy-security-client に差し替え、リレー経由の接続を有効にしました。その結果、10 秒間で 1.72MB の映像データを受信できました(2026-09-16 の実測。1 回の測定値です)。

■ ログインにおける Captcha と 2 要素認証

Eufy へのログイン時には、ボット判定(Captcha)や 2 要素認証(2FA)が挟まります。そのため、自動実行される Lambda の内部でログインを完了させることはできません。

この制約を回避するため、「初回のセッション生成」と「セッション再開時のフォールバック処理」を組み合わせた構成としました。

・ 初回ログインと S3 によるセッション永続化

まず手元のローカル環境で 1 回だけ手動ログインを行いました。生成されたセッションファイル(persistent.json)は、S3 のプライベートバケットへ配置しています。

Lambda は起動時にこのセッションを S3 から読み込んで通信を行います。

通信の過程で、ライブラリ内部のセッション情報は更新されます。ただし Lambda の実行環境は、使い回されることもあれば作り直されることもあり、こちらからは選べません。そのため処理の最後に、更新された最新のセッション状態を S3 へ書き戻しています。次回の実行にバトンを渡す仕組みです。

・ セッション再開失敗時のフォールバック処理

ただし、保存したセッションからの再開は時々失敗することがありました。ライブラリがトークンの有効期限切れを検知して別の API を呼び出した際、その応答を正常に解釈できずに処理が停止してしまうためです。

そこで、セッションによる再開に失敗した場合は、自動的にパスワードでの再ログインへ切り替えるフォールバック処理を挟みました。このパスワード再ログインの経路では、いまのところ Captcha や 2FA を要求されずに認証を更新できています。ただし要求された場合は Lambda では解けないため、その回は失敗として止まる作りにしています。

■ FFmpeg レイヤーによる H.264 への変換

カメラから取得できる映像は H.265 形式のデータ(生のビット列)のため、このままでは Web ブラウザで再生することができません。なので FFmpeg というツールを利用し、ブラウザでそのまま再生できる一般的な H.264 形式の MP4 ファイルへ変換・格納しています。

FFmpeg は Lambda の標準ランタイムには含まれていません。そのため、FFmpeg の静的ビルドを Lambda レイヤー(Layer)として登録して利用しています。レイヤーの中身は実行環境の /opt に展開されます。関数からは /opt/bin/ffmpeg として呼び出せます(出典: Lambda レイヤーで依存関係を管理する、2026-09-19 確認)。

関数のコードとは別にレイヤーとして管理できるため、FFmpeg のバイナリを毎回のコードデプロイに含めずに済むのもメリットです。手元のビルドでは 79,826,272 バイト(約 76MB)ありました。

■ 原本(raw)と配信用(clips)の保存先

S3 には以下のようなフォルダ(プレフィックス)構成で保存しています。

キー 中身 役割
raw/<時刻>.h265 カメラから受け取ったそのまま 変換に失敗しても残る原本
clips/<時刻>.mp4 H.264 に変換したもの 画面で再生するもの

変換処理の成否にかかわらず原本は必ず保存します。このように raw データを手元に残していたおかげで、後述するトラブル発生時にも生のバイト列を直接解析し、原因をスムーズに特定することができました。

3. 死活監視・通知フロー(図 データの流れ #8 〜 #10)

■ 新規で作らず、既存の「温湿度監視 Lambda」に相乗りさせた理由

映像が届かなくなった際の LINE 通知は、今回新たに Lambda を作成したわけではありません。以前から 30 分周期で動いていた温湿度監視用の既存 Lambda(PeriodicMonitor)に、処理を追加しています。

この構成にした理由は以下の 3 点です。

  • 言語の壁: 撮影側は Node.js で書かれていますが、既存の通知共通処理は Python です。Node.js の撮影側から Python の共通処理を呼び出すのは難しく、Python で動いている既存の監視側に処理を足しました。
  • 権限の最小化: 既存の監視 Lambda に足したのは、clips/ 配下の一覧を取得する権限だけです(s3:ListBucket に s3:prefix = clips/* の条件を付けたもの)。オブジェクトの中身(s3:GetObject)を読む権限は付けていません。スタックをまたぐ書き込み権限(AWS IAM の権限)も増やさずに済みました。
  • 撮影側が途中で停止した場合も検知できる: 撮影側が自ら「成功した・失敗した」と報告する仕組みでは、Lambda 自体が途中で強制終了したときに無言で失敗します。そこで報告を鵜呑みにせず、既存の監視から「実際に S3 にファイルが存在するか(一覧に並んでいるか)」を見に行く形にしました。これで撮影側の完全な停止も拾えます。

■ 通知のしきい値と「復帰通知」の工夫

未撮影の判定基準(しきい値)は 2 時間 30 分に設定しています。毎時の撮影に対して「2 回続けて撮れていない状態」が目安です。ただし監視は 30 分ごとの間隔で動いていて撮影の時刻とは同期していないため、通知が出る時点では 3 回目も失敗していることがあります。

1 回の失敗はネットワーク不調などで実際に起きるため、1 回で通知すると誤報になります。また、監視の発火と映像の到着にも時間差があるため、余裕を持たせて判定しています。

なお、通知周りでは以下の考慮も入れています。

  • 復帰通知の実装: 「途絶したとき」だけでなく、撮影が再開して映像が届くようになったときにも「復帰通知」を送ります。現地で障害対応をしているときも、復旧したかどうかを LINE から確認できます。判定は 30 分ごとなので、復旧から通知までは最大 30 分かかります。

  • 初回稼働時の誤報防止: S3 上に映像が 1 本も存在しないときは通知しません。「初めて動かした直後」と「障害で撮れていない状態」の区別がつかないためです。

4. 映像の閲覧・配信フロー(図 データの流れ #11 〜 #12)

■ 署名付き URL による映像の取得

映像データを API Gateway 経由(Lambda のレスポンス内)で直接返却する方法も検討しました。ただし、データ転送量と実行時間に加えて、Lambda の応答サイズの上限があります。Lambda の同期呼び出しは、リクエストもレスポンスも 6MB までです(出典: Lambda クォータ、2026-09-22 確認)。1 本 1MB 前後の映像でも、複数本をまとめて返そうとすればすぐに届く大きさです。

代わりに、一覧取得 API は S3 の署名付き URL を返します。ブラウザはその URL を使って、S3 から直接 MP4 ファイルを取得して再生します。これにより、S3 バケット自体は非公開(プライベート)のまま映像を配信できます(出典: 署名付き URL を使用したオブジェクトの共有、2026-09-19 確認)。

  • 有効期限の設定: 署名付き URL の有効期限は 900 秒(15 分)に設定しました。画面を開いて視聴するには十分な時間です。万が一 URL が第三者に漏洩・転載されても、長期間は使えません。

  • 最小権限の原則: 閲覧用 API(ReferenceApi)に与える IAM 権限も、配信用フォルダである clips/ 配下だけに絞っています。原本が置かれている raw/ へはアクセスできません。

■ 映像が無い時刻の扱い(API 側でスロットを埋める)

一覧取得 API は、S3 内に存在するファイル一覧をそのまま返すわけではありません。「直近 48 時間分の毎時のタイムスロット」をあらかじめ生成し、映像が存在しないスロットには「データ無し」のフラグを立てて返却しています。

仮に S3 に存在するデータのみを返してしまうと、カメラが停止している時間帯にも、画面には過去の古い映像だけが並び続けます。それでは、見ている人が停止状態(障害)に気づけません。

動かして分かったこと(トラブルシューティング)

CSP(Content Security Policy)による映像取得のブロック

画面を実機で開いたところ、映像のある時刻は「読み込めませんでした」、映像の無い時刻は「取得できませんでした」と表示されました。

エラーメッセージを分けておいたおかげで、一覧取得 API は正常に動いており、映像ファイルの取得処理だけが失敗していると絞り込めました。

次に Lambda を直接呼び出して署名付き URL を取得し、curl で叩いてみるとステータス 200 が返り、データも正しく取得できました。これにより、URL 自体や S3 サーバー側の応答には問題がないことが分かりました。開発者ツール等で調べたところ、原因は CloudFront に設定していた CSP(Content Security Policy) でした。

今回の構成では、Web 画面自体は Amazon CloudFront(https://xxx.cloudfront.net)から配信されます。一方、動画ファイルは S3 の署名付き URL(https://xxx.s3.ap-northeast-1.amazonaws.com)から直接取得します。CloudFront とはドメインが異なるため、ブラウザから見ると動画の取得は 「別オリジン(別ドメイン)へのリクエスト」 になります。

CloudFront 側の CSP 設定に、動画用の許可設定(media-src)を記述していませんでした。この場合は全体ルールの default-src 'self' が適用されます。CloudFront と同一ドメインからしかデータを読めなくなるため、S3 への動画リクエストをブラウザが送信前にブロックしていました。

S3 署名付き URL の SigV4 明示

CSP の許可先を書こうとしたところ、S3 のドメインが 1 つに定まりませんでした。

一覧取得 API は Python の boto3 で署名付き URL を生成しています。既定のままだと署名方式が SigV2 になります。このとき URL のホスト名は、リージョン名の入らないグローバルエンドポイント(s3.amazonaws.com)になっていました(2026-09-18 に実測)。許可先に書いたドメインと、ブラウザが実際に要求するドメインが食い違う原因がこれでした。

そこで、署名方式に SigV4(Version 4 署名) を明示的に指定しました。これにより、リージョン名が入るエンドポイント(s3.ap-northeast-1.amazonaws.com)で URL を生成します。

CloudFront の CSP(media-src)には、いまも「リージョン名が入る形式」と「入らない形式」の両方を書いてあります。

映像の先頭の取りこぼしによる FFmpeg 変換失敗

本番で毎時の撮影を開始した初日の夜、21 時の回で初めて変換エラーが発生しました。

Amazon CloudWatch Logs を確認すると、EventBridge は時刻通りに発火し、Lambda も正常に起動していました。P2P 通信も確立できています。さらに S3(raw/)には、733,627 バイトの H.265 データが保存されていました。それにもかかわらず、FFmpeg は PPS id out of range というエラーログを 250 回以上出力しています。MP4 ファイル(clips/)は 1 バイトも出力されずに終了していました。

原因は 映像ストリームの先頭(ヘッダー情報)を取りこぼしていたこと でした。

ライブラリに対して「配信開始」を指示し、その処理の完了(Promise の解決)を待ってから、データの受信用リスナーを登録していました。そのため、待っている間に先頭のデータが通り過ぎてしまっていました。 その先頭チャンクの中には、H.265 のデコードに不可欠なパラメータセット(VPS / SPS / PPS)が含まれています。これを取得できなかったため、デコードができずにエラーが発生していました。

原因特定(切り分け)の決め手となったのは、S3 に残しておいた原本(raw/)の先頭バイト列の比較でした。

状態 先頭のバイト列 意味
成功した回 00 00 00 01 40 01 パラメータセット(VPS)から始まっている
失敗した回 00 00 00 01 4e 01 補助情報(SEI)から始まっている

動画のデコード情報(パラメータセット)が欠落した raw データは、後からどれだけ FFmpeg に読み直させても復元できません。そのため、以下の 2 段階の修正を施しました。

  • タイミングの修正: 配信開始を指示するより先に「配信開始」イベントの購読を張り、映像ストリームの購読はそのイベントのコールバックの中で行う。以前は await で処理が戻ってから購読していたため、戻るまでの間に先頭チャンクが流れていた。

  • ガードロジックの追加: 受信したデータストリームの中から、先頭にパラメータセット(VPS)が来るのを確認するまでファイルへの記録を開始しない安全装置を入れる。

カメラが申告するメタデータの揺れ

トラブルシューティングの過程で、カメラデバイス自体が申告してくるメタデータ(配信情報)が、実際の映像データと一致していないことも判明しました。

  • 解像度の揺れ: カメラからの申告値は、実行のたびに 3840x2160 や 2304x1296 と変動していました。しかし ffprobe で実際の映像を解析すると、いずれも 1280x720 でした。申告値は「カメラの最大スペック(性能)」を示しているだけで、いま配信されているストリームの実効値ではないようです。

  • コーデックの誤申告: 解像度だけでなく、カメラが「H.264」と申告してきた回であっても、実際のデータ(生バイト列)を解析すると「H.265」でした。

このため、カメラ側のメタデータ(申告値)を鵜呑みにせず、受信した実データの先頭バイトを解析してコーデックを判定するロジック に変更しました。

H.264 と H.265 の映像データは、「NAL ユニット」という単位の連なりでできています。その先頭 1 バイトには、そのデータが何であるか(画像本体なのか、デコードに必要なパラメータセットなのか)を示す NAL ユニットタイプが含まれています。判定にはこのバイトを直接確認しています。

ビットマスク判定の衝突と、総当たりレビューによる発見

上記の判定ロジックを実装するにあたって、1 つ見落としがありました。

当初、H.264 と H.265 の判定を ビットマスク処理 で書きました。ところが両者は同じ 1 バイトを異なるビット幅で区切って読むため、値の範囲が衝突していたのです。

たとえば 0x41 は、H.264 として読むと「タイプ 1(毎フレーム出てくる、もっとも数の多い画像データ)」です。同じ値を H.265 として読むと「タイプ 32(パラメータセットである VPS)」になります。 そのため、0x41 を受け取ると H.265 と誤判定してしまうバグが含まれていました。

この問題は、コードマージ直前のレビューで 1 バイト(256 通り)の値を総当たりで検証するテストを実行したことで発見されました。これによる修正は以下の 2 点です。

  • 完全一致への変更: 判定処理をビットマスクではなく、バイト値の完全一致判定に変更しました。
  • あいまいな値の除外: 完全一致にしても区別がつかないバイト値は、判定ロジックから除外しました。たとえば 0x28 と 0x48 は、H.264 のパラメータセット(PPS)と H.265 の通常の NAL ユニットで値が重なります。これら単体では、どちらのコーデックか特定できません。

そして、このバグは本番環境の実機テストでは一度も発現していませんでした。実機から届く映像ストリームは毎回決まったバイトパターンから始まるため、たまたま誤判定のルートを通らずに「正常に動いているように見えていた」のです。

「実機で正常に動いた」ということは、「判定ロジック(異常系や境界値)が正しく機能している」ことの証明にはならない。テストとコードレビューの重要性を再認識させられた出来事でした。

まとめ

商用電源も Wi-Fi もない場所に設置したカメラから、毎時 10 秒の映像をキャプチャし、家族がいつでも確認できる仕組みを構築しました。

メーカー公式の映像抽出 API などが存在しない機器だったため、有志が作成した非公式クライアント(ライブラリ)に乗っかる判断をしています。仕様変更があれば止まるリスクはありますが、そこは割り切って運用しています。

本番環境で毎時の自動撮影をスタートしたのは 2026 年 9 月 18 日です。運用の実績はまだ数日分しかありませんが、ひとまず安定して動き始めています。

回線には povo のデータトッピング(30GB / 30 日)を利用しています。通信量の大部分は、毎時の撮影(1 日あわせて 4 分程度)ではなくアプリでのライブ視聴が占めています。それでも 30GB あるため、容量には十分な余裕があります。なお、夜間に撮影失敗があったときも、そのまま通知が届くようにしています。

現状の心残りとしては、今回ハマったコーデックの判定処理に自動テストが無く、正しさをレビュー時の総当たり検証でしか確かめられていない点です。ここは今後整備しておきたいと考えています。

映像ダッシュボードの「次」について

現在の仕組みで分かるのは、あくまで「その時刻に何が写っていたか」だけです。画面を開いて見に行かなければ、何も気づくことはできません。

それでも、これまでの「温度の数字だけを見て、小屋の中の様子を頭で想像していた状態」からは大きな進歩だと感じています。

今後は、せっかく撮れた映像を活用して、動物の侵入検知やニワトリたちの様子を拾えないかを考えています。何をどう検知するかは、これからじっくり詰めていく予定です。

← 記事一覧へ