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) |
| カメラ1 | Logicool C310(USB ID 046d:081b)/1280×720 @30fps |
| カメラ2 | ELP 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 ビルド) |
| MediaMTX | RTSP で受け、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.sh と pi-cam-readonly.sh の名前で保存してから、ラズパイに送ります。
scp pi-cam-setup.sh pi-cam-readonly.sh ai@ai.local:~/
ラズパイ側で実行します。
sudo bash ~/pi-cam-setup.sh
このスクリプトは、つないだ USB カメラを自動で見つけ、次の順に設定していきます。
- ffmpeg、v4l-utils、jq などの導入
- カメラごとに
/dev/usbcam1、/dev/usbcam2という固定名を作る(udev ルール) - カメラごとに解像度・フレームレート・ビットレートを対話で聞く
- MediaMTX の取得と設定ファイルの生成
- 復旧用スクリプトと systemd ユニットの設置
- 電源とウォッチドッグの設定
固定名を作るのが要点です。 /dev/video0 と /dev/video1 は起動のたびに入れ替わることがあります。ベンダ ID と製品 ID で /dev/usbcam1 を作ってしまえば、番号のずれに影響されません。
対話で聞かれる値は、最初から最終構成を入れます。あとから変えるより手戻りがありません。
| 聞かれること | 入れた値 |
|---|---|
| cam1 の解像度/fps/ビットレート | 1280x720 / 30 / 3M |
| cam2 の解像度/fps/ビットレート | 2048x1536 / 15 / 8M |
| 固定 IP | 192.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.txtのusb_max_current_enable=1(USB 給電の上限)/boot/firmware/config.txtのdtparam=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}'
ready が true で、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
usb1 と usb3 のように別の数字に分かれていれば正解です。両方 usb1 なら、片方を別のポートに挿し替えます。ポートの色は関係ありません。USB カメラは USB2 のデバイスなので、青いポートに挿しても速くなりません。
解像度の上限も測っておきました。
| カメラ | 30fps の上限 | 15fps まで落としたとき |
|---|---|---|
| Logicool C310 | 1280×720(本体の上限) | — |
| ELP IMX179 | 1024×768 | 1600×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 コントローラに挿します。
usb1とusb3に分かれているかをコマンドで確認してください - 起動直後にカメラが
-71で落ちるときは、uvcvideoのquirks=0x80と、起動時の USB リセットで止まります - overlayfs で読み取り専用にすると、電源をいきなり抜いても SD カードは壊れません。 ただし有効化は動作確認が全部終わってから。ログが消えることも覚えておいてください
- 自動復旧の最終手段(OS 再起動)は、全体が落ちているときだけに縛ります。部分的な不調で発動させると、復旧の仕組み自体が障害になります
ディスプレイもキーボードも使わず、SSH だけでここまで組めます。同じ構成を試す方の手間が1つでも減れば幸いです。
「自分の現場でも、電源を落とされる前提の機材を据えたい」「カメラを増やしたときにどこで頭打ちになるか、先に見積もっておきたい」という方は、無料の30分オンライン診断で状況をお伺いします。AI技術顧問サービスでは、こうした構成の判断から現場で動くところまで一緒に進めています。
この記事の技術を、現場で実装したい方へ
AI×IoTの技術顧問として、月額契約で継続伴走しています。PoC設計・技術判断・組織設計・ベンダー管理・実装支援まで、現場で動くまで一緒に進めます。受託開発(請負)ではありません。
→ AI技術顧問サービスの詳細 / 無料30分オンライン診断 / 料金一覧





