MULTI_MONITOR_SERVERの製作

更新日: 2026.08.10

概要

今回は、"ESP32-camを使ったカメラ"をまとめて管理するサーバの製作です。 このサーバで複数のESP32-CAMに外部からアクセスを目指します。

Multi_server HP

管理サーバ概要

使い方は簡単です

How to use Multi_server
  • 一番左が立ち上げ時の画面。左側:カメラ登録エリア。右側:選択されたカメラの表示と操作エリア。
  • カメラの登録は(赤枠の中に)カメラの名前(任意)とIPアドレスを入力して行います。
  • 登録されたカメラは左側一列に小窓で表示されます。
  • カメラの登録解除は小窓の右上の "削除” ボタンで行います。
  • 登録されたカメラをクリックするとそのカメラのHPが右側のカメラ表示エリアに表示されます
  • カメラのHPをそのまま読み込んでいるので操作はそれと同じです。

管理サーバ動作の説明

管理サーバのコード

このサーバは、Pythonで書かれた”multi_server.py"とHPの"index.html"の2つで出来ています。ソースを下記に示します。 短く非常に簡単なコードです。

mulit_serve.py python
import os import requests from flask import Flask, Response, send_from_directory, abort, request app = Flask(__name__) @app.route('/') def index(): return send_from_directory('.', 'index.html') @app.route('/') def static_files(filename): return send_from_directory('.', filename) # 動画(MJPEG)からテキスト・JSONまで自動判別して通すプロキシ @app.route('/proxy', methods=['GET']) @app.route('/proxy/', methods=['GET']) @app.route('/proxy/', methods=['GET']) def camera_proxy(subpath=""): # カメラIPの取り出し target_url = request.args.get('targetUrl') if not target_url: return "Error: targetUrl is required", 400 if subpath: camera_url = f"{target_url.rstrip('/')}/{subpath}" else: camera_url = target_url # クエリの追加 if request.query_string: query_str = request.query_string.decode('utf-8') params = [p for p in query_str.split('&') if not p.startswith('targetUrl=')] if params: connector = '&' if '?' in camera_url else '?' camera_url = f"{camera_url}{connector}{'&'.join(params)}" try: # stream=True で接続を開始 req = requests.get(camera_url, stream=True, timeout=10) # カメラから送られてきたヘッダー情報を引き継ぐ headers = {} if 'Content-Type' in req.headers: headers['Content-Type'] = req.headers['Content-Type'] if 'Content-Length' in req.headers: headers['Content-Length'] = req.headers['Content-Length'] content_type = req.headers.get('Content-Type', '').lower() # MJPEG動画ストリーミング(終わりがない通信)の場合のみジェネレーターで横流し if 'multipart/x-mixed-replace' in content_type: def generate(): for chunk in req.iter_content(chunk_size=4096): if chunk: yield chunk return Response(generate(), status=req.status_code, headers=headers) # それ以外(メールアドレス、ファイルリスト、画像、通常のWEB画面など単発データ)の場合 else: return Response(req.content, status=req.status_code, headers=headers) except Exception as e: return f"カメラ接続エラー: {e}", 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, debug=True, threaded=True)

"multi_server.py"

  • サーバ本体。
  • カメラIPアドレスの先頭に付けられたマークを処理する。
  • それ以外はデータを中継しているだけ。
index.html html
<!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>マルチカメラ マネージャー</title> <style> /* 全体のベーススタイル */ body { font-family: 'Helvetica Neue', Arial, sans-serif; background: #121212; color: #e0e0e0; padding: 25px; margin: 0; -webkit-font-smoothing: antialiased; } h1 { font-size: 32px; font-weight: 600; color: #ffffff; border-bottom: 1px solid #2d2d2d; padding-bottom: 15px; margin-top: 0; margin-bottom: 25px; letter-spacing: 0.5px; text-align: center;} .container { display: flex; gap: 25px; max-width: 1400px; margin: 0 auto; } /* 左側:カメラリストエリア */ .camera-list { width: 320px; display: flex; flex-direction: column; gap: 20px; flex-shrink: 0; } .refresh-btn-wrapper { width: 100%; } .btn-refresh { width: 100%; background: #2a2a2a; color: #ffd700; font-size: 14px; padding: 12px; font-weight: bold; border-radius: 8px; border: 1px solid #3d3d3d; cursor: pointer; transition: all 0.2s; } .btn-refresh:hover { background: #333333; border-color: #ffd700; } #camera-cards-container { display: flex; flex-direction: column; gap: 15px; } .camera-card { background: #1e1e1e; padding: 12px; border-radius: 12px; cursor: pointer; border: 2px solid transparent; position: relative; transition: all 0.2s; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } .camera-card:hover { transform: translateY(-2px); background: #252525; } .camera-card.active { border-color: #ff5722; background: #252525; box-shadow: 0 0 12px rgba(255,87,34,0.3); } .thumb-wrapper { width: 100%; height: 160px; background: #000; display: flex; justify-content: center; align-items: center; overflow: hidden; border-radius: 8px; } .thumb-wrapper img { width: 100%; height: 100%; object-fit: contain; } .cam-title { font-weight: 500; margin-top: 10px; font-size: 13px; text-align: center; color: #aaaaaa; } .camera-card.active .cam-title { color: #ffffff; } .btn-delete { position: absolute; top: 18px; right: 18px; background: rgba(239, 83, 80, 0.9); color: #fff; border: none; border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor: pointer; font-weight: bold; z-index: 10; transition: background 0.2s; } .btn-delete:hover { background: #f44336; } .add-camera-form { background: #1e1e1e; padding: 20px; border-radius: 12px; border: 1px solid #2d2d2d; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } .add-camera-form h3 { margin-top: 0; font-size: 14px; font-weight: 600; color: #ffd700; border-bottom: 1px solid #2d2d2d; padding-bottom: 8px; margin-bottom: 15px; text-transform: uppercase; letter-spacing: 0.5px; } .form-group { margin-bottom: 15px; display: flex; flex-direction: column; gap: 6px; } .form-group label { font-size: 11px; color: #888888; font-weight: 600; } .form-group input { background: #2a2a2a; color: #fff; border: 1px solid #3d3d3d; padding: 8px 12px; border-radius: 6px; font-size: 13px; width: 100%; box-sizing: border-box; transition: border-color 0.2s; } .form-group input:focus { border-color: #afeeee; outline: none; } .btn-add { width: 100%; background: #afeeee; color: #121212; font-size: 13px; padding: 10px; font-weight: bold; border-radius: 6px; border: none; cursor: pointer; margin-top: 5px; transition: background 0.2s; } .btn-add:hover { background: #87cefa; } /* 右側:HP表示エリア(旧メインストリーム) */ .main-stream { flex: 1; background: #1e1e1e; border-radius: 16px; padding: 20px; display: flex; flex-direction: column; border: 1px solid #2d2d2d; box-shadow: 0 8px 16px rgba(0,0,0,0.4); height: 80vh; min-height: 600px; } #current-title { margin-top: 0; font-size: 18px; font-weight: 500; color: #afeeee; margin-bottom: 15px; text-align: center; } /* カメラHP読み込み用インラインフレーム */ .camera-frame { width: 100%; flex: 1; border: 1px solid #2d2d2d; border-radius: 8px; background: #050505; } .placeholder-box { flex: 1; display: flex; justify-content: center; align-items: center; color: #555555; font-size: 14px; letter-spacing: 1px; border: 1px dashed #333; border-radius: 8px; } </style> </head> <body> <h1>カメラモニター(マルチレイアウト版)</h1> <div class="container"> <div class="camera-list"> <div class="refresh-btn-wrapper"> <button type="button" class="btn-refresh" onclick="refreshAllThumbnails()"> 全小窓の静止画を更新</button> </div> <div id="camera-cards-container"></div> <div class="add-camera-form"> <h3> 新しいカメラを登録</h3> <div class="form-group"> <label for="new-cam-name">CAMERA NAME</label> <input type="text" id="new-cam-name" placeholder="例: room1"> </div> <div class="form-group"> <label for="new-cam-ip">IP ADDRESS</label> <input type="text" id="new-cam-ip" placeholder="例: 192.168.1.200"> </div> <button type="button" class="btn-add" onclick="addNewCamera(event)">このカメラを登録する</button> </div> </div> <div class="main-stream"> <h3 id="current-title">カメラリストから選択してください</h3> <div id="frame-placeholder" class="placeholder-box">NO CAMERA SELECTED</div> <iframe id="camera-webpage" class="camera-frame" src="" style="display: none;" allow="cross-origin-isolated"></iframe> </div> </div> <script> const defaultCameras = []; let cameras = []; let currentCam = null; let activeCamId = null; window.onload = function() { loadCamerasFromStorage(); renderCameraLists(); refreshAllThumbnails(); }; function loadCamerasFromStorage() { const savedData = localStorage.getItem('esp32_camera_list'); if (savedData) { try { cameras = JSON.parse(savedData); } catch(e) { cameras = [...defaultCameras]; } } else { cameras = [...defaultCameras]; } } function saveCamerasToStorage() { localStorage.setItem('esp32_camera_list', JSON.stringify(cameras)); } function renderCameraLists() { const container = document.getElementById('camera-cards-container'); container.innerHTML = ''; cameras.forEach(cam => { const card = document.createElement('div'); card.className = 'camera-card'; card.id = `card-${cam.id}`; card.onclick = function(e) { if(e.target.className === 'btn-delete') return; selectCamera(cam.id); }; card.innerHTML = ` <button type="button" class="btn-delete" onclick="deleteCamera('${cam.id}')">削除</button> <div class="thumb-wrapper"> <img id="thumb-${cam.id}" src="" alt="接続中..."> </div> <div class="cam-title">${cam.name} (${cam.ip.replace('http://', '')})</div> `; container.appendChild(card); }); if (activeCamId && document.getElementById(`card-${activeCamId}`)) { document.getElementById(`card-${activeCamId}`).classList.add('active'); } } function addNewCamera(event) { if(event) event.preventDefault(); const nameInput = document.getElementById('new-cam-name'); const ipInput = document.getElementById('new-cam-ip'); let name = nameInput.value.trim(); let rawIp = ipInput.value.trim(); if (!name || !rawIp) { alert('カメラ名とIPアドレスの両方を入力してください。'); return; } let cleanIp = rawIp.replace(/^https?:\/\//i, ''); let baseIp = 'http://' + cleanIp; const newId = 'cam_' + new Date().getTime(); const newCam = { id: newId, name: name, ip: baseIp, captureUrl: `${baseIp}/?capture=1` }; cameras.push(newCam); saveCamerasToStorage(); renderCameraLists(); const thumbImg = document.getElementById('thumb-' + newId); if (thumbImg) { thumbImg.src = `${newCam.captureUrl}&t=` + new Date().getTime(); } nameInput.value = ''; ipInput.value = ''; alert(name + ' を新しく登録しました!'); } function deleteCamera(id) { if (!confirm('このカメラの設定を削除しますか?')) return; if (activeCamId === id) { document.getElementById("camera-webpage").src = ""; document.getElementById("camera-webpage").style.display = "none"; document.getElementById("frame-placeholder").style.display = "flex"; document.getElementById('current-title').innerText = "カメラリストから選択してください"; activeCamId = null; currentCam = null; } cameras = cameras.filter(cam => cam.id !== id); saveCamerasToStorage(); renderCameraLists(); refreshAllThumbnails(); } function refreshAllThumbnails() { cameras.forEach(cam => { const thumbImg = document.getElementById('thumb-' + cam.id); if (thumbImg) { let baseIp = cam.ip.replace(/\/+$/, ""); const cameraImageUrl = baseIp + "/?capture=1&t=" + new Date().getTime(); thumbImg.src = "/proxy/?targetUrl=" + encodeURIComponent(cameraImageUrl); console.log("小窓の中継URL: ", thumbImg.src); } }); } function selectCamera(id) { currentCam = cameras.find(c => c.id === id); activeCamId = id; cameras.forEach(c => { const card = document.getElementById('card-' + c.id); if (card) card.classList.remove('active'); }); const activeCard = document.getElementById('card-' + id); if(activeCard) activeCard.classList.add('active'); document.getElementById('current-title').innerText = `CAMERA HP : ${currentCam.name}`; const iframe = document.getElementById("camera-webpage"); const placeholder = document.getElementById("frame-placeholder"); placeholder.style.display = "none"; iframe.src = "/proxy/?targetUrl=" + encodeURIComponent(currentCam.ip); iframe.style.display = "block"; iframe.onload = function() { iframe.contentWindow.postMessage({ type: "SET_PROXY_BASE", proxyBase: "/proxy/?targetUrl=" + encodeURIComponent(currentCam.ip) }, "*"); }; } </script> </body> </html>

"index.html"

  • HPのレイアウトとカメラの登録とカメラサーバの読み込みを行う。
  • <iframe>を使っているので、カメラ操作関係のコードが全く無い。

サーバからカメラへのアクセス

MULI_MONITOR_SERVERの製作で、 Tailscaleに対応するため管理サーバ内でカメラHPが自身のサーバへアクセス時はURLの頭に目印をつけると説明しています。 その作業は管理サーバHPで登録されたカメラを選んだところから始まります。

"index.html"

  • 登録されたカメラを選ぶとindex.htmlの203行:function selectCamera(id) が実行されます。
    • 221行:iframe.src = "/proxy/?targetUrl=" + encodeURIComponent(currentCam.ip);
      • ここでIPの前に"/proxy/?targetUrl="を添付しています。
    • これは79行の<iframe id="camera-webpage" class="camera-frame" src="" style="display: none;" allow="cross-origin-isolated">lt;/iframe>を書き換えます。
    • 仮にカメラのIPアドレスが "192.168.1.100" とすれば htmlのコードは下記の様になります。
      • <iframe id="camera-webpage" class="camera-frame" src="/proxy/?targetUrl=192.168.1.100" style=" display: block;" allow="cross-origin-isolated"></iframe>
  • "index.html" が実行されこの箇所(79行)にくると src="/proxy/?targetUrl=192.168.1.100" となり ”multi_server.py” の17-19行が実行されます。

"mulit_serve.py"

  • 17-19行:先頭が"/proxy/”のリクエストはすべてここを通る。
  • 23行:カメラのUrl(192.168.1.100)を取り出し
  • 42行:カメラサーバにリクエストし
  • それ以降、カメラからのデータをHTMLに返しています。

ちなみにカメラ側HTMLは冒頭で下記の様に受けています。

<script> let timerId = null; let para, sv_para; window.proxyBaseUrl = ""; if(window.location.search.includes("targetUrl") || window.location.pathname.includes("/proxy/")){ const urlParams = new URLSearchParams(window.location.search); targetUrl = urlParams.get("targetUrl"); if (targetUrl) { try { const parsedUrl = new URL(targetUrl); window.proxyBaseUrl = "http://" + parsedUrl.hostname; } catch (e) { console.log("URLの解析に失敗しました: ", e); } } }
  • この一連の操作はサーバindex.htmlでの src="/proxy/?targetUrl=192.168.1.100" リクエストから始まる。
  • よって、window.locationには "/proxy/?targetUrl=192.168.1.100" が入る
  • これが if(window.location.search.includes("targetUrl") || window.location.pathname.includes("/proxy/")){ にヒットし
  • window.proxyBaseUrl = "http://" + parsedUrl.hostname;とwindow.proxyBaseUrlに "http://192.168.1.100" が保存される。
  • 以降カメラHTMLではこの値がアドレスの先頭に追加される。

Raspberry PI 3Bで確認

Raspberry PI 3Bを下記の様にセットアップ

  • OSセットアップ: Raspberry Pi Imager で Raspberry Pi OS Lite (64-bit)を使用
  • インストール後: sudo apt update、 sudo apt full-upgrade -y を実行
  • IPアドレスの固定:Tailscaleに登録するならアドレスを固定した方が良いです
  • flaskのインストール: sudo apt update、 sudo apt install python3-flask -y
  • "multi_server.py" と "index.html" を Raspberry Piに保存。そこで python3 multi_server.py を実行。
  • ブラウザでURLに "Raspberry Piのアドレス:8000" と入力すればHPが表示されます
  • How to setup Multi_server with Raspberry

このRaspberry PI 3BをTailscaleに登録。問題無く外部からアクセス出来ました。ESP32−CAMが見れました。

最後に

想定通りのサーバが出来ました。これでESP32-CAMの数を気にせず外部からそれら全てにアクセス出来る様になりました。 でもRaspberry PI 3Bは殆どデータの転送しかやっていないはずなのにそれなりに熱くなります。 長時間のStreamingは ESP32−CAM、Raspberry PI 3B共に厳しいかもしれません。

SINCE 2026