電源をいきなり抜いても壊れないラズパイカメラを、ディスプレイもキーボードも使わずSSHだけで作った

電源をいきなり抜いても壊れないラズパイカメラを、ディスプレイもキーボードも使わずSSHだけで作った

Raspberry Pi で機器を作るとき、私が一番気にしたくないのが、電源のオンオフです。使うたびに正しく落としたか、抜いてよいかを気にするのは面倒です。

もう1つ、セットアップのためにディスプレイやキーボードをつなぐのも面倒です。

映像伝送そのものは、以前にもやったことがあります。ただ、systemd で起動しているのに映像が出ない、といったことをいろいろ経験してきました。

そこで今回は、この2つの面倒をなくしたうえで、安定した映像伝送を実現することを目標にしました。とにかく簡単に、確実に映像を飛ばすやり方を組む、ということです。用途は USV(無人水上艇)に載せる簡易的な映像伝送装置で、個人の技術検証として作っています。

できたのは次の4つです。

  • ラズパイにディスプレイもキーボードもマウスも、一度もつながずに組み上げた。 OS を書き込んだ SD カードを挿して電源を入れ、あとは別の PC から SSH で入り、セットアップスクリプトの実行、systemd の設定、読み取り専用化まで済ませた
  • 電源をいきなり切っても壊れず、入れ直せば映像が戻ってくる
  • 小型ルーター(GL-AR750S)でローカルの閉域を作り、その中の PC のブラウザで映像が見える。閲覧側に入れるものはない
  • 低遅延(体感 0.2〜0.3 秒)のまま、カメラ2台を同時に流せる

この記事では、その手順と、途中で詰まった箇所をまとめます。

この記事でできるようになること

  • USB カメラ2台の映像を、同一ネットワークの PC のブラウザから体感 0.2〜0.3 秒で見られるようになる
  • ディスプレイを一度もつながずに、SSH だけでカメラ配信機を組み上げられるようになる
  • 電源をいきなり抜いても SD カードが壊れない状態にできる
  • カメラが落ちても、人が行かずに復帰する仕組みを持てる

前提条件と構成

ハードウェアはこの組み合わせです。

役割実体
配信機Raspberry Pi 5(Raspberry Pi OS Lite 64-bit、ホスト名 ai、ユーザー ai
カメラ1Logicool C310(USB ID 046d:081b)/1280×720 @30fps
カメラ2ELP 8MP Sony IMX179(USB ID 32e4:8830)/2048×1536 @15fps
ネットワークGL-AR750S をアクセスポイントとして使用。ラズパイは LAN ポートに有線接続
電源公式 27W(5V/5A)USB-C アダプタ

ソフトウェアは次の2つだけです。動作確認したバージョンも書いておきます。

ソフト役割動作確認版
ffmpegカメラから MJPEG で取り込み、H.264 に変換して RTSP で渡す7.1.5(Debian 13 rpt ビルド)
MediaMTXRTSP で受け、WebRTC でブラウザへ配信する。ffmpeg の起動と再起動も面倒を見るv1.21.0

MediaMTX は Go 製の単体バイナリで、MIT ライセンスです。設定ファイル1枚とバイナリ1つで動きます。

遅延が小さいのは、ブラウザまで WebRTC で届けているからです。WebRTC は元々ビデオ通話用の仕組みで、今回の構成では体感 0.2〜0.3 秒に収まっています。

エンコードは ffmpeg の libx264 で行います。Raspberry Pi 5 にはハードウェアの H.264 エンコーダがありません。 720p30 と 2048×1536@15 の2本で、CPU はおよそ1.5コア分を使います。

手順1:ディスプレイを使わずに OS を書き込む

Raspberry Pi Imager の Customisation で、先に全部入れてしまいます。ここを埋めておけば、ディスプレイもキーボードも要りません。

  • OS:Raspberry Pi OS Lite (64-bit)
  • Hostname:ai
  • User:ai(パスワードは自分で決める)
  • Localisation:Asia/Tokyo / キーボード jp
  • Remote access:SSH を有効、パスワード認証
  • Wi-Fi:空欄(有線で使うため)

書き込んだ SD カードをラズパイに挿し、有線 LAN につないで電源を入れます。あとは作業 PC から SSH で入ります。

ssh ai@ai.local

ai.local で引けないことがあります。その場合はルーターの管理画面で機器一覧を開き、割り当てられた IP を見て直接つなぎます。

ssh ai@192.168.1.47

セットアップにはインターネットが必要です。パッケージの取得と MediaMTX のダウンロードがあるためです。GL-AR750S は単体では上流がないので、この段階では自宅の LAN につないで作業します。

手順2:SSH でセットアップスクリプトを流す

使うスクリプトは2本で、全文をこの記事の末尾「付録」に載せています。作業 PC で pi-cam-setup.shpi-cam-readonly.sh の名前で保存してから、ラズパイに送ります。

scp pi-cam-setup.sh pi-cam-readonly.sh ai@ai.local:~/

ラズパイ側で実行します。

sudo bash ~/pi-cam-setup.sh

このスクリプトは、つないだ USB カメラを自動で見つけ、次の順に設定していきます。

  1. ffmpeg、v4l-utils、jq などの導入
  2. カメラごとに /dev/usbcam1/dev/usbcam2 という固定名を作る(udev ルール)
  3. カメラごとに解像度・フレームレート・ビットレートを対話で聞く
  4. MediaMTX の取得と設定ファイルの生成
  5. 復旧用スクリプトと systemd ユニットの設置
  6. 電源とウォッチドッグの設定

固定名を作るのが要点です。 /dev/video0/dev/video1 は起動のたびに入れ替わることがあります。ベンダ ID と製品 ID で /dev/usbcam1 を作ってしまえば、番号のずれに影響されません。

対話で聞かれる値は、最初から最終構成を入れます。あとから変えるより手戻りがありません。

聞かれること入れた値
cam1 の解像度/fps/ビットレート1280x720303M
cam2 の解像度/fps/ビットレート2048x1536158M
固定 IP192.168.8.50(あとで GL-AR750S 側で予約する値を手で入れる)
USB 給電上限を 1.6A に引き上げるかy

最後の項目は電源に直結します。Raspberry Pi 5 は既定で USB ポート全体の供給を 600mA に絞っています。今回のカメラは申告値が 500mA と 256mA で、合計 756mA。既定のままでは足りません。 公式 27W(5V/5A)アダプタを使っているので引き上げますが、3A 以下の電源、たとえばモバイルバッテリーで引き上げると、電圧不足で不安定になります。 ここは電源の実力を見て決める箇所です。

手順3:一度再起動してから映像を確認する

スクリプトが終わったら、映像を見る前に再起動します。

sudo reboot

この再起動を飛ばすと、2台目のカメラが映りません。 スクリプトが書いた設定のうち、次の3つは再起動して初めて効きます。

  • /etc/modprobe.d/uvcvideo.conf(カメラドライバの補正。後述)
  • /boot/firmware/config.txtusb_max_current_enable=1(USB 給電の上限)
  • /boot/firmware/config.txtdtparam=watchdog=on(ハードウェアウォッチドッグ)

再起動後、ブラウザで開きます。閲覧側に入れるものは何もありません。

http://192.168.8.50:8889/cam1
http://192.168.8.50:8889/cam2

VLC や ffplay で見たい場合は RTSP でも取れます。

rtsp://192.168.8.50:8554/cam1

状態はコマンドでも確認できます。

curl -s 127.0.0.1:9997/v3/paths/list | jq '.items[] | {name, ready, bytesReceived}'

readytrue で、bytesReceived が呼ぶたびに増えていれば、カメラから映像が流れています。

なお、閲覧 PC から届く必要があるポートは2つです。8889/TCP だけ開けても映像は出ません。 WebRTC の映像本体は 8189/UDP を通ります。同じ LAN の中なら気になりませんが、別セグメントや VPN 越しだと、ページは開くのに映像だけ出ない状態になります。

手順4:読み取り専用にして、電源を抜けるようにする

映像が出て、再起動を数回繰り返しても毎回2台とも上がることを確認したら、読み取り専用化に進みます。

sudo apt install -y overlayroot
sudo bash ~/pi-cam-readonly.sh

やっていることは3つです。

  • スワップを止める(SD カードへの書き込み源を1つ減らす)
  • ログを RAM に置く(Storage=volatile
  • ルートファイルシステムを overlayfs にし、/boot/firmware も読み取り専用にする

これで、書き込みは全部メモリ上に載り、電源を抜いた瞬間に消えます。 SD カードの中身は変わらないので、いきなり電源を落としても壊れません。次に電源を入れると、読み取り専用化した時点の状態で立ち上がります。

確認はこれで足ります。

mount | grep ' / '

overlay と出れば有効です。ext4 (rw) のままなら効いていません。

ここで1つ落とし穴があります。この OS では、読み取り専用化に overlayroot パッケージが必要です。 手元では、上流のインターネットがない状態で実行してしまい、パッケージの導入に失敗したまま「有効化しました」と表示され、再起動後も ext4 (rw) のままという状態になりました。先に apt install を単独で通しておくのが確実です。

設定を変えたくなったときは、解除してから触ります。

sudo bash ~/pi-cam-readonly.sh off && sudo reboot

解除したあとに設定を変え、もう一度 sudo bash ~/pi-cam-readonly.sh で戻します。読み取り専用のまま設定を変えようとしても、書き込んだ内容は再起動で消えます。

読み取り専用化は、動作確認が全部終わってからにしてください。 切り分けの最中にこれを有効にすると、直したはずの設定が次の起動で戻り、原因が二重になります。

カメラ2台で必ず当たる「USBコントローラ」の壁

ここが一番はまった箇所です。

Raspberry Pi 5 の USB ポートは4つありますが、コントローラは2つです。 青い USB3 ポートと黒い USB2 ポートが1つずつ組になって、1つのコントローラにぶら下がっています。

MJPEG で高解像度の映像を2本流すと、1つのコントローラでは帯域が足りません。同じ系統に2台挿すと、あとから挿したほうが落ちます。 手元では、ルーターの置き場所を変えたときにカメラの挿し位置がずれ、2台とも同じ系統に入ってしまい、cam2 だけが落ちる状態になりました。

どの系統に入っているかは、これで分かります。

for n in 1 2; do echo -n "cam$n: "; udevadm info -q path -n /dev/usbcam$n | grep -oE 'usb[0-9]+/[0-9]+-[0-9]+' | head -1; done

usb1usb3 のように別の数字に分かれていれば正解です。両方 usb1 なら、片方を別のポートに挿し替えます。ポートの色は関係ありません。USB カメラは USB2 のデバイスなので、青いポートに挿しても速くなりません。

解像度の上限も測っておきました。

カメラ30fps の上限15fps まで落としたとき
Logicool C3101280×720(本体の上限)
ELP IMX1791024×7681600×1200 / 2048×1536 / 2592×1944 / 3264×2448

ELP 側が 15fps 固定になるのは USB2 の帯域の都合です。そして Raspberry Pi 5 の CPU で 2 本同時にエンコードできる現実的な上限が、720p30 と 2048×1536@15 の組み合わせでした。3264×2448 を2台同時は回りません。

USB カメラを Jetson Nano につないで物体検出を動かした記事もあります。Jetson NanoでYOLOをPythonからリアルタイム表示する方法(Docker+TensorRT+USBカメラ)

起動直後だけ2台目が落ちる(-71)の直し方

ELP のカメラは、つないだ直後に Error during demuxing: No such device を出して、6秒周期で再接続を繰り返しました。カーネルのログには -71 というエラーが並びます。

原因は、カメラのファームウェアが申告する USB 帯域の値と、Linux 側のドライバ(uvcvideo)の計算が合わないことでした。映像を流し始めた直後に切断されます。Windows では別のドライバを使うので、同じカメラでもこの症状は出ません。

対策は1行です。

echo "options uvcvideo quirks=0x80" | sudo tee /etc/modprobe.d/uvcvideo.conf

quirks=0x80 は、帯域を申告値ではなく実際のフレームサイズから計算し直す指定です。ドライバの再読み込みでは当たりません。 ffmpeg がカメラを掴んでいるため、モジュールの抜き差しが失敗します。設定ファイルを置いて再起動する、が正解です。

もう1つ、コールドブートの直後だけ2台目が落ちる症状もありました。こちらは USB の自動サスペンドを無効にし、MediaMTX を起動する前に全カメラを1回 USB リセットするようにして止まりました。sysfs の authorized を 0 にして 1 に戻すと、電気的につなぎ直したのと同じ扱いになります。

止まっても人が行かずに戻る仕組み

止まったときに自動で戻すため、15秒ごとに映像が流れているかを見て、段階的に手を強くしていく監視を入れています。

判定は、MediaMTX の API が返す受信バイト数が増えているかどうかです。増えていなければ、そのカメラの失敗回数を数えます。

連続失敗やること
2回そのカメラの ffmpeg を止める(MediaMTX が起動し直す)
4回、以降4回ごとそのカメラを USB リセットする
6回MediaMTX 全体を再起動する
全カメラが8回以上、かつ起動から10分以上OS を再起動する

最後の行が要点です。最初はここを「1台が8回失敗したら再起動」にしていて、自分で障害を作りました。 片方のカメラが不調なだけで OS が再起動し、起動して3分でまた再起動し、SSH にすら入れない状態になります。

最終手段は、全体が落ちているときだけに縛る。 部分的な不調で最も強い手を打つと、復旧装置が障害の原因になります。

ログは次で見られます。

journalctl -t cam-watchdog -t usb-reset-cam -t wait-usbcam -b --no-pager

1つ注意があります。読み取り専用化するとログは RAM に載るので、再起動すると消えます。 数日単位で様子を見たいときは、読み取り専用を一時的に解除するか、ログの転送先を用意してからにしてください。

GL-AR750S でローカル閉域を作る

インターネットに出す必要はありません。現場に持ち込むのは、小型ルーター1台とラズパイだけです。

  • ラズパイは GL-AR750S の LAN ポートに有線でつなぐ(WAN ではありません)
  • 管理画面で、ラズパイの MAC アドレスに 192.168.8.50 を予約する(静的IPアドレスバインディング)
  • 閲覧する PC は GL-AR750S の Wi-Fi につなぎ、ブラウザで http://192.168.8.50:8889/cam1 を開く

IP を予約するのは、再起動のたびにアドレスが変わるのを防ぐためです。手元では検証中に再起動を繰り返したところ IP が振り直され、ブラウザがつながらなくなった場面がありました。

ルーターの電源は、ラズパイの USB からは取りません。ラズパイ側の電流に余裕がなくなり、カメラの不調と電源の不調が同時に起きて切り分けができなくなります。 別系統から給電します。

付録:スクリプト全文

記事中で使った2本です。そのまま保存して使えます。どちらも sudo bash で実行し、何度実行しても同じ結果になるように書いています。

pi-cam-setup.sh

USB カメラの検出から MediaMTX の設定、復旧スクリプト、systemd、電源とウォッチドッグの設定までを一度に行います。読み取り専用化はしません。

#!/usr/bin/env bash
# =============================================================================
# pi-cam-setup.sh  (複数カメラ対応版)
#   Raspberry Pi 5 + USBカメラ(1〜4台) を「ブラウザで見られる低遅延カメラ」に仕上げる
#   対象OS: Raspberry Pi OS Lite (64-bit, Bookworm 以降)
#   実行:   sudo bash pi-cam-setup.sh
#
#   カメラ N 台目は /dev/usbcamN として固定され、http://<IP>:8889/camN で見られる。
#   このスクリプトは読み取り専用化(overlayfs)は行わない。
#   ブラウザで映像が出ることを確認してから pi-cam-readonly.sh を実行すること。
# =============================================================================
set -euo pipefail

[[ $EUID -eq 0 ]] || { echo "sudo で実行してください"; exit 1; }
[[ $(uname -m) == aarch64 ]] || { echo "64-bit OS が必要です"; exit 1; }

log()  { printf '\n\033[1;34m=== %s ===\033[0m\n' "$*"; }
ask()  { local v; read -r -p "$1 [$2]: " v; echo "${v:-$2}"; }
CFG=/boot/firmware/config.txt
[[ -f $CFG ]] || CFG=/boot/config.txt

# -----------------------------------------------------------------------------
log "1/9 パッケージ導入"
# -----------------------------------------------------------------------------
apt-get update
apt-get install -y ffmpeg v4l-utils curl jq usbutils

# -----------------------------------------------------------------------------
log "2/9 USBカメラの検出と固定名の作成"
# -----------------------------------------------------------------------------
# USB 接続かつキャプチャ能力のある video ノードだけを拾う
declare -a DEV VID PID SER MODEL
for d in /dev/video*; do
  [[ -e $d ]] || continue
  props=$(udevadm info -q property -n "$d" 2>/dev/null || true)
  grep -q '^ID_BUS=usb' <<<"$props" || continue
  grep -q '^ID_V4L_CAPABILITIES=.*:capture:' <<<"$props" || continue
  # 同じ物理カメラの2つ目のキャプチャノード(index!=0)は除外
  idx=$(cat "/sys/class/video4linux/$(basename "$d")/index" 2>/dev/null || echo 0)
  [[ $idx == 0 ]] || continue
  DEV+=("$d")
  VID+=("$(sed -n 's/^ID_VENDOR_ID=//p' <<<"$props")")
  PID+=("$(sed -n 's/^ID_MODEL_ID=//p'  <<<"$props")")
  SER+=("$(sed -n 's/^ID_SERIAL_SHORT=//p' <<<"$props")")
  MODEL+=("$(sed -n 's/^ID_MODEL=//p'   <<<"$props")")
done
N=${#DEV[@]}
[[ $N -gt 0 ]] || { echo "USBカメラが見つかりません。接続を確認してください。"; exit 1; }
[[ $N -le 4 ]] || { echo "カメラが $N 台見つかりました。4台以下にしてください。"; exit 1; }

echo "検出したカメラ ($N 台):"
for i in $(seq 0 $((N-1))); do
  printf '  cam%d: %s  %s:%s  %s  serial=%s\n' $((i+1)) "${DEV[$i]}" "${VID[$i]}" "${PID[$i]}" "${MODEL[$i]}" "${SER[$i]:-なし}"
done
echo "※ この並びで cam1, cam2, ... になります。入れ替えたい場合は USB を差し替えて再実行してください。"
read -r -p "この構成で進めますか (Y/n): " ok; [[ ${ok:-Y} =~ ^[Yy]$ ]] || exit 1

# 同じ VID:PID が複数あるときはシリアルで区別する(無ければ中止)
need_serial=0
for i in $(seq 0 $((N-1))); do for j in $(seq $((i+1)) $((N-1))); do
  [[ "${VID[$i]}:${PID[$i]}" == "${VID[$j]}:${PID[$j]}" ]] && need_serial=1
done; done
if [[ $need_serial == 1 ]]; then
  for i in $(seq 0 $((N-1))); do
    [[ -n ${SER[$i]} ]] || { echo "同型カメラがあり、シリアル番号が取れないため区別できません。別型番に替えてください。"; exit 1; }
  done
fi

{
  echo "# USBカメラを /dev/usbcamN に固定。キャプチャ用ノード(index 0)のみ対象"
  for i in $(seq 0 $((N-1))); do
    extra=""; [[ $need_serial == 1 ]] && extra=", ENV{ID_SERIAL_SHORT}==\"${SER[$i]}\""
    echo "SUBSYSTEM==\"video4linux\", ENV{ID_VENDOR_ID}==\"${VID[$i]}\", ENV{ID_MODEL_ID}==\"${PID[$i]}\"${extra}, ENV{ID_V4L_CAPABILITIES}==\"*:capture:*\", ATTR{index}==\"0\", SYMLINK+=\"usbcam$((i+1))\", TAG+=\"systemd\""
  done
} > /etc/udev/rules.d/99-usbcam.rules

{
  echo "USBCAM_COUNT=$N"
  for i in $(seq 0 $((N-1))); do
    echo "USBCAM$((i+1))_VID=${VID[$i]}"
    echo "USBCAM$((i+1))_PID=${PID[$i]}"
    echo "USBCAM$((i+1))_SER=${SER[$i]}"
  done
} > /etc/default/usbcam

{
  echo "# USB カメラの自動サスペンドを無効化(復帰時の -71 切断対策)"
  for i in $(seq 0 $((N-1))); do
    echo "ACTION==\"add\", SUBSYSTEM==\"usb\", ATTR{idVendor}==\"${VID[$i]}\", ATTR{idProduct}==\"${PID[$i]}\", TEST==\"power/control\", ATTR{power/control}=\"on\""
  done
} > /etc/udev/rules.d/98-usbcam-nosuspend.rules

udevadm control --reload
udevadm trigger --subsystem-match=video4linux
sleep 1
for i in $(seq 1 $N); do
  [[ -e /dev/usbcam$i ]] || { echo "/dev/usbcam$i が作られませんでした"; exit 1; }
  echo "/dev/usbcam$i -> $(readlink -f /dev/usbcam$i)"
done

# -----------------------------------------------------------------------------
log "3/9 映像フォーマットの決定(カメラごと)"
# -----------------------------------------------------------------------------
declare -a INFMT SIZE FPS BITRATE
for i in $(seq 1 $N); do
  echo
  echo "--- cam$i (${MODEL[$((i-1))]}) ---"
  FMTS=$(v4l2-ctl -d /dev/usbcam$i --list-formats-ext)
  if grep -q "'MJPG'" <<<"$FMTS"; then
    fmt=mjpeg; dsize=1280x720; dfps=30
    echo "MJPEG 対応。MJPEG で使える解像度:"
    awk "/'MJPG'/{f=1;next} /^\\s*\\[[0-9]+\\]:/{f=0} f" <<<"$FMTS" | grep -E "Size|0\\.033s|0\\.067s" || true
  else
    fmt=yuyv422; dsize=640x480; dfps=30
    echo "MJPEG 非対応のため YUYV を使います(USB帯域の都合で解像度は控えめ)"
    grep -E "Size" <<<"$FMTS" || true
  fi
  INFMT+=("$fmt")
  SIZE+=("$(ask "cam$i 解像度" "$dsize")")
  FPS+=("$(ask "cam$i フレームレート" "$dfps")")
  BITRATE+=("$(ask "cam$i ビットレート" "3M")")
done

# -----------------------------------------------------------------------------
log "4/9 配信ホストの IP"
# -----------------------------------------------------------------------------
CUR_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
HOST_IP=$(ask "このラズパイの固定IP(GL-AR750 側でDHCP予約する値)" "${CUR_IP:-192.168.8.50}")

# -----------------------------------------------------------------------------
log "5/9 MediaMTX 導入"
# -----------------------------------------------------------------------------
ASSETS=$(curl -fsSL https://api.github.com/repos/bluenviron/mediamtx/releases/latest | jq -r '.assets[].browser_download_url')
URL=$(grep -E -m1 'linux_arm64(v8)?\.tar\.gz$' <<<"$ASSETS" || true)
[[ -n $URL ]] || { echo "MediaMTX のダウンロードURLを取得できません"; exit 1; }
echo "取得: $URL"
tmp=$(mktemp -d)
curl -fsSL "$URL" | tar xz -C "$tmp"
install -m 755 "$tmp/mediamtx" /usr/local/bin/mediamtx
rm -rf "$tmp"
mkdir -p /usr/local/etc

{
cat <<EOF
logLevel: info
logDestinations: [stdout]

api: yes
apiAddress: 127.0.0.1:9997

rtsp: yes
rtspAddress: :8554
webrtc: yes
webrtcAddress: :8889
webrtcLocalUDPAddress: :8189
webrtcAdditionalHosts: [$HOST_IP]
webrtcICEServers2: []
hls: no
rtmp: no
srt: no

paths:
EOF
for i in $(seq 1 $N); do
  k=$((i-1))
cat <<EOF
  cam$i:
    runOnInit: >
      ffmpeg -hide_banner -loglevel warning -nostdin
      -f v4l2 -input_format ${INFMT[$k]} -video_size ${SIZE[$k]} -framerate ${FPS[$k]} -i /dev/usbcam$i
      -c:v libx264 -preset ultrafast -tune zerolatency
      -profile:v baseline -pix_fmt yuv420p
      -b:v ${BITRATE[$k]} -g ${FPS[$k]} -bf 0
      -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:8554/cam$i
    runOnInitRestart: yes
EOF
done
} > /usr/local/etc/mediamtx.yml

# -----------------------------------------------------------------------------
log "6/9 回復用スクリプト"
# -----------------------------------------------------------------------------
# --- USBカメラを電気的に再列挙する(authorized 0→1)。引数: カメラ番号(省略で全台)
cat >/usr/local/bin/usb-reset-cam.sh <<'EOF'
#!/usr/bin/env bash
# usb-reset-cam.sh [N]  … カメラ N(省略時は全台)を USB レベルで再列挙する
. /etc/default/usbcam
tag=usb-reset-cam

find_dev() {  # $1 = カメラ番号 → sysfs の USB デバイスパス
  local n=$1 p d vid pid ser
  vid=$(eval echo "\$USBCAM${n}_VID"); pid=$(eval echo "\$USBCAM${n}_PID"); ser=$(eval echo "\$USBCAM${n}_SER")
  if p=$(udevadm info -q path -n /dev/usbcam$n 2>/dev/null); then
    d=/sys$p
    while [[ $d != /sys && ! -f $d/idVendor ]]; do d=$(dirname "$d"); done
    [[ -f $d/idVendor ]] && { echo "$d"; return; }
  fi
  for d in /sys/bus/usb/devices/*; do
    [[ -f $d/idVendor ]] || continue
    [[ $(<"$d/idVendor") == "$vid" && $(<"$d/idProduct") == "$pid" ]] || continue
    [[ -z $ser || $(cat "$d/serial" 2>/dev/null) == "$ser" ]] && { echo "$d"; return; }
  done
}

reset_one() {
  local n=$1 dev
  dev=$(find_dev "$n")
  if [[ -n $dev ]]; then
    logger -t $tag "cam$n: reset $dev"
    echo 0 > "$dev/authorized"; sleep 2; echo 1 > "$dev/authorized"
  else
    logger -t $tag "cam$n: not on bus; rebinding xhci controllers"
    for h in /sys/bus/pci/drivers/xhci_hcd/*:*; do
      id=$(basename "$h")
      echo "$id" > /sys/bus/pci/drivers/xhci_hcd/unbind 2>/dev/null || true
      sleep 1
      echo "$id" > /sys/bus/pci/drivers/xhci_hcd/bind   2>/dev/null || true
    done
  fi
  for _ in $(seq 10); do [[ -e /dev/usbcam$n ]] && return 0; sleep 1; done
  return 1
}

rc=0
if [[ -n ${1:-} ]]; then
  reset_one "$1" || rc=1
else
  for n in $(seq 1 "$USBCAM_COUNT"); do reset_one "$n" || rc=1; done
fi
exit $rc
EOF

# --- 起動前に全カメラを待つ(順番問題の対策)
cat >/usr/local/bin/wait-usbcam.sh <<'EOF'
#!/usr/bin/env bash
# 1) 全カメラの /dev/usbcamN を最大 30 秒待つ
# 2) 見つかったカメラを 1 回 USB リセットする(コールドブート直後の -71 切断対策)
# 3) 見つからないカメラはリセットで再列挙を試み、それでも駄目でも MediaMTX は起動させる
. /etc/default/usbcam
tag=wait-usbcam
all_ok() { for n in $(seq 1 "$USBCAM_COUNT"); do [[ -e /dev/usbcam$n ]] || return 1; done; }
for _ in $(seq 30); do all_ok && break; sleep 1; done

for n in $(seq 1 "$USBCAM_COUNT"); do
  if [[ -e /dev/usbcam$n ]]; then
    logger -t $tag "cam$n: boot-time usb reset"
  else
    logger -t $tag "cam$n: not found after 30s, trying usb reset"
  fi
  /usr/local/bin/usb-reset-cam.sh "$n" || logger -t $tag "cam$n: still missing; starting anyway"
done
sleep 2
exit 0
EOF

# --- 15秒ごとの監視。カメラごとに映像が止まっていれば段階的に回復する
cat >/usr/local/bin/cam-watchdog.sh <<'EOF'
#!/usr/bin/env bash
# MediaMTX の API でパスごとの受信バイト数を見る。増えていなければ失敗回数を数え、
# 段階的に強い手段へ進む。
#   カメラ単位:
#     2回目: そのカメラの ffmpeg を殺す(MediaMTX が再起動する)
#     4回目、以降 4回ごと: そのカメラを USB リセット
#     6回目: MediaMTX 再起動(1回だけ)
#   全体:
#     全カメラが 8回以上失敗 かつ 起動から 10分以上 のときだけ OS 再起動
#     (1台だけの故障や、起動直後の再起動ループでは再起動しない)
. /etc/default/usbcam
S=/run/cam-watchdog; mkdir -p $S
tag=cam-watchdog
uptime_s=${EPOCHSECONDS:-$(date +%s)}
boot_s=$(awk '{print int($1)}' /proc/uptime)

all_bad=1
for n in $(seq 1 "$USBCAM_COUNT"); do
  json=$(curl -sf --max-time 3 "http://127.0.0.1:9997/v3/paths/get/cam$n" || echo '{}')
  ready=$(jq -r '.ready // false' <<<"$json")
  bytes=$(jq -r '.bytesReceived // 0' <<<"$json")
  last=$(cat "$S/bytes$n" 2>/dev/null || echo -1)
  fails=$(cat "$S/fails$n" 2>/dev/null || echo 0)
  echo "$bytes" > "$S/bytes$n"

  if [[ $ready == true && $bytes != "$last" ]]; then
    [[ $fails -gt 0 ]] && logger -t $tag "cam$n: recovered (after $fails fails)"
    echo 0 > "$S/fails$n"
    all_bad=0
    continue
  fi

  fails=$((fails+1)); echo $fails > "$S/fails$n"
  [[ $fails -lt 8 ]] && all_bad=0
  logger -t $tag "cam$n: no video (fails=$fails ready=$ready bytes=$bytes)"
  if [[ $fails -eq 2 ]]; then
    logger -t $tag "cam$n: action: restart ffmpeg"
    pkill -f "rtsp://127.0.0.1:8554/cam$n\$" || true
  elif [[ $fails -eq 6 ]]; then
    logger -t $tag "cam$n: action: restart mediamtx"
    systemctl restart mediamtx
  elif (( fails >= 4 && fails % 4 == 0 )); then
    logger -t $tag "cam$n: action: usb reset"
    /usr/local/bin/usb-reset-cam.sh "$n" || true
    pkill -f "rtsp://127.0.0.1:8554/cam$n\$" || true
  fi
done

# 全カメラが長時間止まっていて、かつ起動直後でなければ OS 再起動
if [[ $all_bad == 1 && $boot_s -ge 600 ]]; then
  logger -t $tag "ALL cameras down for a long time: REBOOT"
  rm -f "$S"/fails*
  systemctl reboot
fi
EOF
chmod 755 /usr/local/bin/usb-reset-cam.sh /usr/local/bin/wait-usbcam.sh /usr/local/bin/cam-watchdog.sh

# -----------------------------------------------------------------------------
log "7/9 systemd ユニット"
# -----------------------------------------------------------------------------
cat >/etc/systemd/system/mediamtx.service <<'EOF'
[Unit]
Description=MediaMTX camera streamer
After=network-online.target systemd-udevd.service
Wants=network-online.target
StartLimitIntervalSec=0

[Service]
ExecStartPre=/usr/local/bin/wait-usbcam.sh
ExecStart=/usr/local/bin/mediamtx /usr/local/etc/mediamtx.yml
Restart=always
RestartSec=3
KillMode=mixed
TimeoutStopSec=10

[Install]
WantedBy=multi-user.target
EOF

cat >/etc/systemd/system/cam-watchdog.service <<'EOF'
[Unit]
Description=Camera stream watchdog (one shot)
After=mediamtx.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cam-watchdog.sh
EOF

cat >/etc/systemd/system/cam-watchdog.timer <<'EOF'
[Unit]
Description=Run camera watchdog every 15s

[Timer]
OnBootSec=60
OnUnitActiveSec=15
AccuracySec=1s

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable mediamtx.service cam-watchdog.timer

# -----------------------------------------------------------------------------
log "8/9 ハードウェア設定(電力・ウォッチドッグ・Wi-Fi)"
# -----------------------------------------------------------------------------
add_cfg() { grep -qxF "$1" "$CFG" || echo "$1" >> "$CFG"; }

echo "Pi 5 は既定で USB 合計 600mA に制限されます。各カメラの申告消費電流:"
for i in $(seq 0 $((N-1))); do
  mp=$(lsusb -v -d "${VID[$i]}:${PID[$i]}" 2>/dev/null | grep -m1 MaxPower | awk '{print $2}' || true)
  echo "  cam$((i+1)): ${mp:-不明}"
done
echo "合計が 600mA を超える場合は引き上げが必要です。"
echo "引き上げるのは、公式 27W(5V/5A) 電源など 5A を保証する電源のときだけにしてください。"
echo "モバイルバッテリー等 3A 以下の電源で引き上げると電圧不足で不安定になります。"
if [[ $(ask "USB 給電上限を 1.6A に引き上げますか (y/N)" N) =~ ^[Yy]$ ]]; then
  add_cfg "usb_max_current_enable=1"
fi

# ハードウェアウォッチドッグ: OS が 15 秒応答しなければ強制再起動
add_cfg "dtparam=watchdog=on"
mkdir -p /etc/systemd/system.conf.d
cat >/etc/systemd/system.conf.d/watchdog.conf <<'EOF'
[Manager]
RuntimeWatchdogSec=15s
RebootWatchdogSec=1min
EOF

# ELP など一部の UVC カメラが USB 切断(-71)を繰り返す対策: uvcvideo の帯域計算を補正する quirk
# (ELP 32e4:8830 (Sony IMX179) で必要だった。他のカメラにも害はない)
echo "options uvcvideo quirks=0x80" > /etc/modprobe.d/uvcvideo.conf

# Wi-Fi の省電力を切る(有線なら無関係だが害はない)
if command -v nmcli >/dev/null; then
  mkdir -p /etc/NetworkManager/conf.d
  cat >/etc/NetworkManager/conf.d/wifi-powersave.conf <<'EOF'
[connection]
wifi.powersave = 2
EOF
fi

# -----------------------------------------------------------------------------
log "9/9 起動と確認"
# -----------------------------------------------------------------------------
systemctl restart mediamtx.service
systemctl start cam-watchdog.timer
echo "起動待ち..."
okc=0
for _ in $(seq 25); do
  okc=0
  for i in $(seq 1 $N); do
    r=$(curl -sf "http://127.0.0.1:9997/v3/paths/get/cam$i" 2>/dev/null | jq -r '.ready // false' || true)
    [[ $r == true ]] && okc=$((okc+1))
  done
  [[ $okc == "$N" ]] && break
  sleep 1
done
echo
if [[ $okc == "$N" ]]; then
  echo "配信 OK($N 台)"
else
  echo "ready になったのは $okc / $N 台です。journalctl -u mediamtx -f で様子を見てください。"
fi
cat <<EOF

--------------------------------------------------------------
 ブラウザで開く:
EOF
for i in $(seq 1 $N); do echo "   cam$i: http://$HOST_IP:8889/cam$i   (${MODEL[$((i-1))]})"; done
cat <<EOF

 状態確認:  systemctl status mediamtx cam-watchdog.timer
 ログ:      journalctl -u mediamtx -f
 監視ログ:  journalctl -t cam-watchdog -t usb-reset-cam -t wait-usbcam
 電圧不足:  vcgencmd get_throttled   (0x0 なら正常)

 次の手順:
   0. ★まず sudo reboot ★ (uvcvideo quirk / USB給電上限 / ウォッチドッグは再起動後に有効)
      再起動後にブラウザで映像を確認すること。再起動前は cam が映らないことがある
   1. GL-AR750 で下記 MAC に $HOST_IP を DHCP 予約(使っている側の I/F)
        eth0 (有線): $(cat /sys/class/net/eth0/address 2>/dev/null || echo なし)
        wlan0(無線): $(cat /sys/class/net/wlan0/address 2>/dev/null || echo なし)
   2. 電源を抜き差しして、毎回映像が戻ることを数回確認
   3. 問題なければ  sudo bash pi-cam-readonly.sh  で読み取り専用化
 動作確認済み MediaMTX: $(/usr/local/bin/mediamtx --version 2>/dev/null | head -1)
--------------------------------------------------------------
EOF

pi-cam-readonly.sh

overlayroot の導入確認から読み取り専用化までを行います。off を付けて実行すると解除します。

#!/usr/bin/env bash
# =============================================================================
# pi-cam-readonly.sh
#   ルートFSを overlayfs で読み取り専用化し、電源をいきなり切っても壊れない状態にする
#   実行:   sudo bash pi-cam-readonly.sh          … 有効化
#           sudo bash pi-cam-readonly.sh off      … 解除(設定変更したいとき)
# =============================================================================
set -euo pipefail
[[ $EUID -eq 0 ]] || { echo "sudo で実行してください"; exit 1; }
command -v raspi-config >/dev/null || { echo "raspi-config がありません"; exit 1; }

CMDLINE=/boot/firmware/cmdline.txt
[[ -f $CMDLINE ]] || CMDLINE=/boot/cmdline.txt

if [[ ${1:-} == off ]]; then
  echo "overlayfs を解除します(再起動後に書き込み可能になります)"
  # overlay 稼働中は raspi-config が fstab を触れないので、cmdline.txt を直接編集する
  mount -o remount,rw "$(dirname "$CMDLINE")" 2>/dev/null || true
  sed -i -E 's/ ?overlayroot=tmpfs ?/ /; s/ ?boot=overlay ?/ /; s/^ +//; s/ +$//; s/  +/ /g' "$CMDLINE"
  if grep -qE 'overlayroot=|boot=overlay' "$CMDLINE"; then
    echo "cmdline.txt の書き換えに失敗しました"; exit 1
  fi
  echo "解除しました。再起動してください: sudo reboot"
  echo "※ /boot/firmware は再起動後も ro のままです。書き込むときは"
  echo "   sudo mount -o remount,rw /boot/firmware  を実行してください"
  exit 0
fi

if mount | grep -q ' / type overlay'; then
  echo "すでに overlay が有効です。変更したいときは先に  sudo bash $0 off && sudo reboot"; exit 1
fi

echo "=== 0/3 overlayroot パッケージの確認"
# raspi-config の overlay 機能はこのパッケージが前提。無ければ apt で入れる(インターネットが必要)
if ! dpkg -s overlayroot >/dev/null 2>&1; then
  echo "overlayroot が未導入です。インターネット接続が必要です。"
  apt-get update && apt-get install -y overlayroot || { echo "導入に失敗しました。ネット接続を確認して再実行してください"; exit 1; }
fi

echo "=== 1/3 スワップ停止(SDへの書き込み源を断つ)"
systemctl disable --now dphys-swapfile 2>/dev/null || true
apt-get remove -y dphys-swapfile 2>/dev/null || true
rm -f /var/swap

echo "=== 2/3 ログを RAM に"
mkdir -p /etc/systemd/journald.conf.d
cat >/etc/systemd/journald.conf.d/volatile.conf <<'EOF'
[Journal]
Storage=volatile
RuntimeMaxUse=32M
EOF

echo "=== 3/3 overlayfs 有効化(/boot も書き込み禁止)"
raspi-config nonint enable_overlayfs
raspi-config nonint enable_bootro
# initramfs に overlayroot が入ったか検証(入っていなければ再起動しても overlay にならない)
IRF=$(ls /boot/firmware/initramfs* 2>/dev/null | head -1)
if [[ -n $IRF ]] && ! lsinitramfs "$IRF" | grep -qi overlayroot; then
  echo "initramfs に overlayroot が含まれていません。update-initramfs -u を実行します"
  mount -o remount,rw /boot/firmware 2>/dev/null || true
  update-initramfs -u
fi

cat <<'EOF'

有効化しました。再起動後に次で確認できます:
  mount | grep ' / '        → "overlay" と出れば成功
  mount | grep firmware     → "ro" と出れば /boot も保護

設定を変えたいときは:
  sudo bash pi-cam-readonly.sh off && sudo reboot
  (編集後) sudo bash pi-cam-readonly.sh && sudo reboot

今すぐ再起動しますか? (y/N)
EOF
read -r ans
[[ $ans =~ ^[Yy]$ ]] && systemctl reboot
exit 0

まとめ

  • Raspberry Pi 5 + USB カメラ2台を、ffmpeg と MediaMTX の2つだけで、体感 0.2〜0.3 秒のブラウザ配信にできます。閲覧側に入れるものはありません
  • カメラ2台は、必ず別の USB コントローラに挿します。 usb1usb3 に分かれているかをコマンドで確認してください
  • 起動直後にカメラが -71 で落ちるときは、uvcvideoquirks=0x80 と、起動時の USB リセットで止まります
  • overlayfs で読み取り専用にすると、電源をいきなり抜いても SD カードは壊れません。 ただし有効化は動作確認が全部終わってから。ログが消えることも覚えておいてください
  • 自動復旧の最終手段(OS 再起動)は、全体が落ちているときだけに縛ります。部分的な不調で発動させると、復旧の仕組み自体が障害になります

ディスプレイもキーボードも使わず、SSH だけでここまで組めます。同じ構成を試す方の手間が1つでも減れば幸いです。

「自分の現場でも、電源を落とされる前提の機材を据えたい」「カメラを増やしたときにどこで頭打ちになるか、先に見積もっておきたい」という方は、無料の30分オンライン診断で状況をお伺いします。AI技術顧問サービスでは、こうした構成の判断から現場で動くところまで一緒に進めています。

この記事の技術を、現場で実装したい方へ

AI×IoTの技術顧問として、月額契約で継続伴走しています。PoC設計・技術判断・組織設計・ベンダー管理・実装支援まで、現場で動くまで一緒に進めます。受託開発(請負)ではありません。

AI技術顧問サービスの詳細無料30分オンライン診断料金一覧

About The Author

Hideki
AI coordinator株式会社 代表取締役/東京海洋大学 産学官連携研究員。AIロボティクスのスタートアップで執行役員CTO 兼 VPoEとして収穫ロボットの開発組織を統括(2026年7月まで)。画像認識関連のAI特許3件取得・1件出願中。現在はAI・ロボティクス・IoTの現場実装を、月額の技術顧問契約で「現場で動くまで」伴走しています。
会社概要 → www.ai-coordinator.co.jp