Add complete babycam setup: install script, Flask app, state machine, configs
- setup.sh: vollständiges Install- und Konfigurationsscript für Raspberry Pi OS Trixie - app/main.py: Flask Webinterface mit MJPEG-Stream, WLAN-Verwaltung und Statusseite - app/templates/index.html: Dark-Mode UI mit Live-Stream, WLAN-Scan und Verbinden - scripts/network_state.py: State Machine für Modus A (Heimnetz) / B (Access Point) - config/hostapd.conf: Access Point BabyCam (192.168.50.0/24) - config/dnsmasq.conf: DHCP-Server für AP-Netzwerk - systemd/: babycam-web und babycam-network als autostart Services - README.md: Schnellstart-Anleitung und Projektstruktur ergänzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
476922f440
commit
ef858f9040
56
README.md
56
README.md
|
|
@ -1,4 +1,48 @@
|
|||
# Raspberry Pi Babycam – Gesamtplan (Pi 3 + Camera Module 3 NoIR)
|
||||
# Raspberry Pi Babycam
|
||||
|
||||
**Hardware:** Raspberry Pi 3 + Camera Module 3 NoIR Wide + USB WLAN Stick
|
||||
**OS:** Raspberry Pi OS Trixie Lite (arm64)
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
git clone <repo-url> babycam
|
||||
cd babycam
|
||||
sudo bash setup.sh
|
||||
```
|
||||
|
||||
Nach dem Neustart:
|
||||
- Webinterface: http://babycam.local
|
||||
- SSH: `ssh pi@babycam.local`
|
||||
- AP-SSID: `BabyCam` / Passwort: `babycam123`
|
||||
- AP-Webinterface: http://192.168.50.1
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
babycam/
|
||||
├── setup.sh # Komplettes Install- und Konfigurationsscript
|
||||
├── app/
|
||||
│ ├── main.py # Flask Webinterface (Stream, WLAN, Status)
|
||||
│ └── templates/
|
||||
│ └── index.html # Web-UI
|
||||
├── scripts/
|
||||
│ └── network_state.py # State Machine (Modus A/B/C)
|
||||
├── config/
|
||||
│ ├── hostapd.conf # Access Point Konfiguration
|
||||
│ └── dnsmasq.conf # DHCP Server Konfiguration
|
||||
└── systemd/
|
||||
├── babycam-web.service # Flask als Systemdienst
|
||||
└── babycam-network.service # State Machine als Systemdienst
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gesamtplan (Pi 3 + Camera Module 3 NoIR)
|
||||
|
||||
## 1. Zielarchitektur
|
||||
|
||||
|
|
@ -137,10 +181,11 @@ Ergebnis:
|
|||
|
||||
Empfohlene Konfiguration:
|
||||
|
||||
libcamera-vid:
|
||||
rpicam-vid (ehemals libcamera-vid):
|
||||
- Auflösung: 1280x720
|
||||
- Framerate: 20 fps
|
||||
- H264 Stream über TCP
|
||||
- Codec: MJPEG
|
||||
- Ausgabe: stdout → Flask MJPEG-Stream
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -171,9 +216,8 @@ Funktionen:
|
|||
|
||||
## 12. Systemdienste
|
||||
|
||||
- Webserver (Flask oder FastAPI)
|
||||
- Kamera-Service
|
||||
- optional Netzwerk-State-Manager
|
||||
- `babycam-web.service` → Flask Webserver (Port 80, autostart)
|
||||
- `babycam-network.service` → State Machine (prüft alle 30s den WLAN-Status)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Babycam Flask Webinterface
|
||||
- Live-Stream via MJPEG (rpicam-vid)
|
||||
- WLAN-Verwaltung (scan, connect)
|
||||
- Systemstatus
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from flask import Flask, Response, render_template, request, jsonify
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
STREAM_WIDTH = 1280
|
||||
STREAM_HEIGHT = 720
|
||||
STREAM_FPS = 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kamera-Stream
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_frames():
|
||||
cmd = [
|
||||
"rpicam-vid",
|
||||
"-t", "0",
|
||||
"--width", str(STREAM_WIDTH),
|
||||
"--height", str(STREAM_HEIGHT),
|
||||
"--framerate", str(STREAM_FPS),
|
||||
"--codec", "mjpeg",
|
||||
"-o", "-",
|
||||
"--nopreview",
|
||||
]
|
||||
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
|
||||
boundary = b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
|
||||
buf = b""
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = process.stdout.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
|
||||
start = buf.find(b"\xff\xd8")
|
||||
end = buf.find(b"\xff\xd9")
|
||||
|
||||
if start != -1 and end != -1 and end > start:
|
||||
frame = buf[start:end + 2]
|
||||
buf = buf[end + 2:]
|
||||
yield boundary + frame + b"\r\n"
|
||||
finally:
|
||||
process.kill()
|
||||
|
||||
|
||||
@app.route("/stream")
|
||||
def stream():
|
||||
return Response(
|
||||
generate_frames(),
|
||||
mimetype="multipart/x-mixed-replace; boundary=frame",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WLAN-Verwaltung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_wifi_networks():
|
||||
result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list", "ifname", "wlan0"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
networks = []
|
||||
seen = set()
|
||||
for line in result.stdout.splitlines():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
ssid = parts[0].strip()
|
||||
if ssid and ssid not in seen:
|
||||
seen.add(ssid)
|
||||
signal = parts[1] if len(parts) > 1 else "0"
|
||||
security = parts[2] if len(parts) > 2 else ""
|
||||
networks.append({"ssid": ssid, "signal": signal, "security": security})
|
||||
return sorted(networks, key=lambda x: int(x["signal"]) if x["signal"].isdigit() else 0, reverse=True)
|
||||
|
||||
|
||||
def get_system_status():
|
||||
# IP-Adressen
|
||||
ip_result = subprocess.run(["hostname", "-I"], capture_output=True, text=True)
|
||||
ips = ip_result.stdout.strip().split()
|
||||
|
||||
# Aktive WLAN-Verbindung
|
||||
wifi_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", "wlan0"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
wifi_match = re.search(r"GENERAL\.CONNECTION:(.*)", wifi_result.stdout)
|
||||
wifi_ssid = wifi_match.group(1).strip() if wifi_match else "nicht verbunden"
|
||||
|
||||
# AP-Status
|
||||
ap_result = subprocess.run(["systemctl", "is-active", "hostapd"], capture_output=True, text=True)
|
||||
ap_active = ap_result.stdout.strip() == "active"
|
||||
|
||||
# Kamera verfügbar?
|
||||
cam_result = subprocess.run(["rpicam-hello", "--list-cameras"], capture_output=True, text=True)
|
||||
cam_available = "No cameras" not in cam_result.stderr and "No cameras" not in cam_result.stdout
|
||||
|
||||
return {
|
||||
"ips": ips,
|
||||
"wifi_ssid": wifi_ssid,
|
||||
"ap_active": ap_active,
|
||||
"cam_available": cam_available,
|
||||
"uptime": _get_uptime(),
|
||||
}
|
||||
|
||||
|
||||
def _get_uptime():
|
||||
try:
|
||||
with open("/proc/uptime") as f:
|
||||
seconds = float(f.read().split()[0])
|
||||
m, s = divmod(int(seconds), 60)
|
||||
h, m = divmod(m, 60)
|
||||
return f"{h}h {m}m"
|
||||
except Exception:
|
||||
return "unbekannt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
status = get_system_status()
|
||||
return render_template("index.html", status=status)
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
return jsonify(get_system_status())
|
||||
|
||||
|
||||
@app.route("/wifi/scan")
|
||||
def wifi_scan():
|
||||
# Kurz neu scannen
|
||||
subprocess.run(["nmcli", "device", "wifi", "rescan", "ifname", "wlan0"],
|
||||
capture_output=True)
|
||||
time.sleep(2)
|
||||
return jsonify(get_wifi_networks())
|
||||
|
||||
|
||||
@app.route("/wifi/connect", methods=["POST"])
|
||||
def wifi_connect():
|
||||
data = request.get_json()
|
||||
ssid = data.get("ssid", "").strip()
|
||||
password = data.get("password", "").strip()
|
||||
|
||||
if not ssid:
|
||||
return jsonify({"success": False, "error": "SSID fehlt"}), 400
|
||||
|
||||
cmd = ["nmcli", "device", "wifi", "connect", ssid, "ifname", "wlan0"]
|
||||
if password:
|
||||
cmd += ["password", password]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
success = result.returncode == 0
|
||||
|
||||
return jsonify({
|
||||
"success": success,
|
||||
"message": result.stdout.strip() or result.stderr.strip(),
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=80, debug=False)
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Babycam</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0f0f0f;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #1a1a1a;
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
header h1 { font-size: 1.2rem; font-weight: 600; }
|
||||
header .dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
background: {{ '#4caf50' if status.cam_available else '#f44336' }};
|
||||
}
|
||||
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
/* Stream */
|
||||
.stream-box {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1.5rem;
|
||||
aspect-ratio: 16/9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stream-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.stream-box .no-cam {
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Status-Karten */
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1a1a1a;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.card label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.card .value {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.3rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge.on { background: #1b4d2e; color: #4caf50; }
|
||||
.badge.off { background: #3d1a1a; color: #f44336; }
|
||||
|
||||
/* WLAN-Panel */
|
||||
.panel {
|
||||
background: #1a1a1a;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-header h2 { font-size: 1rem; font-weight: 600; }
|
||||
|
||||
button {
|
||||
background: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #3a3a3a;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
button:hover { background: #333; }
|
||||
button.primary { background: #1565c0; border-color: #1976d2; }
|
||||
button.primary:hover { background: #1976d2; }
|
||||
|
||||
#network-list { padding: 0.5rem 0; min-height: 3rem; }
|
||||
|
||||
.network-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-radius: 6px;
|
||||
margin: 0.1rem 0.5rem;
|
||||
}
|
||||
|
||||
.network-item:hover { background: #252525; }
|
||||
.network-item.selected { background: #1a2a3a; }
|
||||
|
||||
.network-name { font-size: 0.9rem; }
|
||||
.network-signal { font-size: 0.75rem; color: #888; }
|
||||
|
||||
.connect-form {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
display: none;
|
||||
gap: 0.75rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.connect-form.visible { display: flex; }
|
||||
|
||||
.connect-form input {
|
||||
background: #0f0f0f;
|
||||
border: 1px solid #3a3a3a;
|
||||
color: #e0e0e0;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connect-form input:focus {
|
||||
outline: none;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
#status-msg {
|
||||
font-size: 0.85rem;
|
||||
padding: 0 1.25rem 1rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
#status-msg.ok { color: #4caf50; }
|
||||
#status-msg.err { color: #f44336; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="dot"></div>
|
||||
<h1>Babycam</h1>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Stream -->
|
||||
<div class="stream-box">
|
||||
{% if status.cam_available %}
|
||||
<img src="/stream" alt="Kamera-Stream">
|
||||
{% else %}
|
||||
<div class="no-cam">
|
||||
Kamera nicht verfügbar<br>
|
||||
<small>Ribbon-Kabel verbunden?</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Status-Karten -->
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<label>WLAN</label>
|
||||
<div class="value">{{ status.wifi_ssid }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<label>IP-Adressen</label>
|
||||
<div class="value">{{ status.ips | join(", ") or "keine" }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<label>Access Point</label>
|
||||
<div class="value">
|
||||
<span class="badge {{ 'on' if status.ap_active else 'off' }}">
|
||||
{{ "aktiv" if status.ap_active else "aus" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<label>Laufzeit</label>
|
||||
<div class="value">{{ status.uptime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WLAN-Verwaltung -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>WLAN konfigurieren</h2>
|
||||
<button onclick="scanNetworks()">Scannen</button>
|
||||
</div>
|
||||
<div id="network-list"><p style="padding:1rem;color:#555;font-size:.85rem;">Auf Scannen klicken…</p></div>
|
||||
<div class="connect-form" id="connect-form">
|
||||
<div style="font-size:.85rem;color:#aaa">Verbinden mit: <strong id="selected-ssid"></strong></div>
|
||||
<input type="password" id="wifi-password" placeholder="WLAN-Passwort">
|
||||
<button class="primary" onclick="connectWifi()">Verbinden</button>
|
||||
</div>
|
||||
<div id="status-msg"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedSSID = null;
|
||||
|
||||
async function scanNetworks() {
|
||||
const list = document.getElementById("network-list");
|
||||
list.innerHTML = '<p style="padding:1rem;color:#555;font-size:.85rem;">Scanne…</p>';
|
||||
document.getElementById("connect-form").classList.remove("visible");
|
||||
|
||||
const res = await fetch("/wifi/scan");
|
||||
const networks = await res.json();
|
||||
|
||||
if (!networks.length) {
|
||||
list.innerHTML = '<p style="padding:1rem;color:#555;font-size:.85rem;">Keine Netzwerke gefunden.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = networks.map(n => `
|
||||
<div class="network-item" onclick="selectNetwork('${n.ssid.replace(/'/g,"\\'")}', this)">
|
||||
<span class="network-name">${n.ssid}</span>
|
||||
<span class="network-signal">${n.signal}% ${n.security || "offen"}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function selectNetwork(ssid, el) {
|
||||
document.querySelectorAll(".network-item").forEach(i => i.classList.remove("selected"));
|
||||
el.classList.add("selected");
|
||||
selectedSSID = ssid;
|
||||
document.getElementById("selected-ssid").textContent = ssid;
|
||||
document.getElementById("connect-form").classList.add("visible");
|
||||
document.getElementById("wifi-password").focus();
|
||||
}
|
||||
|
||||
async function connectWifi() {
|
||||
if (!selectedSSID) return;
|
||||
const password = document.getElementById("wifi-password").value;
|
||||
const msg = document.getElementById("status-msg");
|
||||
msg.className = "";
|
||||
msg.textContent = "Verbinde…";
|
||||
|
||||
const res = await fetch("/wifi/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ssid: selectedSSID, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
msg.className = data.success ? "ok" : "err";
|
||||
msg.textContent = data.success ? "Verbunden!" : "Fehler: " + data.message;
|
||||
|
||||
if (data.success) {
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
interface=wlan1
|
||||
bind-interfaces
|
||||
dhcp-range=192.168.50.20,192.168.50.200,255.255.255.0,24h
|
||||
domain=local
|
||||
address=/babycam.local/192.168.50.1
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
interface=wlan1
|
||||
driver=nl80211
|
||||
ssid=BabyCam
|
||||
hw_mode=g
|
||||
channel=6
|
||||
wmm_enabled=0
|
||||
macaddr_acl=0
|
||||
auth_algs=1
|
||||
ignore_broadcast_ssid=0
|
||||
wpa=2
|
||||
wpa_passphrase=babycam123
|
||||
wpa_key_mgmt=WPA-PSK
|
||||
wpa_pairwise=TKIP
|
||||
rsn_pairwise=CCMP
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Babycam Network State Machine
|
||||
Verwaltet die drei Betriebsmodi:
|
||||
A – Heimnetz (wlan0 verbunden)
|
||||
B – Kein WLAN (AP auf wlan1)
|
||||
C – Setup-Modus (AP läuft, User konfiguriert WLAN)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
AP_INTERFACE = "wlan1"
|
||||
CLIENT_INTERFACE = "wlan0"
|
||||
AP_IP = "192.168.50.1"
|
||||
CHECK_INTERVAL = 30 # Sekunden
|
||||
|
||||
|
||||
def run(cmd, check=False):
|
||||
result = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
log.error(f"Fehler bei: {cmd}\n{result.stderr}")
|
||||
return result
|
||||
|
||||
|
||||
def is_wlan0_connected():
|
||||
result = run(
|
||||
f"nmcli -t -f GENERAL.STATE device show {CLIENT_INTERFACE}"
|
||||
)
|
||||
return "100 (connected)" in result.stdout
|
||||
|
||||
|
||||
def is_ap_running():
|
||||
result = run("systemctl is-active hostapd")
|
||||
return result.stdout.strip() == "active"
|
||||
|
||||
|
||||
def start_ap():
|
||||
log.info("Starte Access Point (BabyCam)...")
|
||||
run("ip addr add 192.168.50.1/24 dev wlan1 2>/dev/null || true")
|
||||
run("ip link set wlan1 up")
|
||||
run("systemctl start hostapd", check=True)
|
||||
run("systemctl start dnsmasq", check=True)
|
||||
log.info("Access Point gestartet.")
|
||||
|
||||
|
||||
def stop_ap():
|
||||
log.info("Stoppe Access Point...")
|
||||
run("systemctl stop hostapd")
|
||||
run("systemctl stop dnsmasq")
|
||||
run("ip addr flush dev wlan1 2>/dev/null || true")
|
||||
log.info("Access Point gestoppt.")
|
||||
|
||||
|
||||
def main():
|
||||
log.info("Babycam Network State Machine gestartet.")
|
||||
current_mode = None
|
||||
|
||||
while True:
|
||||
connected = is_wlan0_connected()
|
||||
ap_running = is_ap_running()
|
||||
|
||||
if connected:
|
||||
if current_mode != "A":
|
||||
log.info("Modus A: Heimnetz aktiv.")
|
||||
if ap_running:
|
||||
stop_ap()
|
||||
current_mode = "A"
|
||||
else:
|
||||
if current_mode != "B":
|
||||
log.info("Modus B: Kein WLAN – starte Access Point.")
|
||||
if not ap_running:
|
||||
start_ap()
|
||||
current_mode = "B"
|
||||
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Babycam Setup Script
|
||||
# Raspberry Pi 3 + Camera Module 3 NoIR
|
||||
# Raspberry Pi OS Trixie (arm64)
|
||||
# ==============================================================================
|
||||
set -e
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INSTALL_DIR="/opt/babycam"
|
||||
AP_INTERFACE="wlan1"
|
||||
AP_IP="192.168.50.1"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Bitte als root ausführen: sudo bash setup.sh"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 1. Pakete installieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "Pakete installieren..."
|
||||
apt-get update -q
|
||||
apt-get install -y \
|
||||
hostapd \
|
||||
dnsmasq \
|
||||
python3-flask \
|
||||
rpicam-apps \
|
||||
avahi-daemon \
|
||||
network-manager \
|
||||
iptables
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 2. Kamera-Interface aktivieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "Kamera-Interface aktivieren..."
|
||||
if ! grep -q "^camera_auto_detect=1" /boot/firmware/config.txt 2>/dev/null; then
|
||||
echo "camera_auto_detect=1" >> /boot/firmware/config.txt
|
||||
fi
|
||||
if ! grep -q "^start_x=1" /boot/firmware/config.txt 2>/dev/null; then
|
||||
echo "start_x=1" >> /boot/firmware/config.txt
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 3. Projektdateien installieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "Projektdateien nach $INSTALL_DIR kopieren..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp -r "$REPO_DIR/app" "$INSTALL_DIR/"
|
||||
cp -r "$REPO_DIR/scripts" "$INSTALL_DIR/"
|
||||
cp -r "$REPO_DIR/config" "$INSTALL_DIR/"
|
||||
chmod +x "$INSTALL_DIR/scripts/network_state.py"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 4. hostapd konfigurieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "hostapd konfigurieren..."
|
||||
cp "$REPO_DIR/config/hostapd.conf" /etc/hostapd/hostapd.conf
|
||||
sed -i 's|#DAEMON_CONF=.*|DAEMON_CONF="/etc/hostapd/hostapd.conf"|' /etc/default/hostapd
|
||||
# hostapd nicht automatisch beim Boot starten (State Machine übernimmt das)
|
||||
systemctl unmask hostapd
|
||||
systemctl disable hostapd
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 5. dnsmasq konfigurieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "dnsmasq konfigurieren..."
|
||||
# Originale Config sichern
|
||||
if [ -f /etc/dnsmasq.conf ] && [ ! -f /etc/dnsmasq.conf.orig ]; then
|
||||
mv /etc/dnsmasq.conf /etc/dnsmasq.conf.orig
|
||||
fi
|
||||
cp "$REPO_DIR/config/dnsmasq.conf" /etc/dnsmasq.conf
|
||||
# dnsmasq nicht automatisch starten (State Machine übernimmt)
|
||||
systemctl disable dnsmasq
|
||||
|
||||
# systemd-resolved: DNS-Stub deaktivieren damit Port 53 frei ist
|
||||
if [ -f /etc/systemd/resolved.conf ]; then
|
||||
sed -i 's|#DNSStubListener=yes|DNSStubListener=no|' /etc/systemd/resolved.conf
|
||||
sed -i 's|DNSStubListener=yes|DNSStubListener=no|' /etc/systemd/resolved.conf
|
||||
if ! grep -q "DNSStubListener=no" /etc/systemd/resolved.conf; then
|
||||
echo "DNSStubListener=no" >> /etc/systemd/resolved.conf
|
||||
fi
|
||||
systemctl restart systemd-resolved
|
||||
fi
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 6. NetworkManager: wlan1 als unmanaged markieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "wlan1 aus NetworkManager-Verwaltung nehmen..."
|
||||
mkdir -p /etc/NetworkManager/conf.d
|
||||
cat > /etc/NetworkManager/conf.d/99-unmanaged.conf << EOF
|
||||
[keyfile]
|
||||
unmanaged-devices=interface-name:wlan1
|
||||
EOF
|
||||
systemctl reload NetworkManager || systemctl restart NetworkManager
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 7. IP-Forwarding aktivieren (für Internet-Sharing AP → Heimnetz)
|
||||
# ------------------------------------------------------------------------------
|
||||
info "IP-Forwarding aktivieren..."
|
||||
if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf; then
|
||||
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
|
||||
fi
|
||||
sysctl -w net.ipv4.ip_forward=1 > /dev/null
|
||||
|
||||
# iptables NAT-Regel (wlan0 → wlan1 Sharing)
|
||||
iptables -t nat -C POSTROUTING -o wlan0 -j MASQUERADE 2>/dev/null || \
|
||||
iptables -t nat -A POSTROUTING -o wlan0 -j MASQUERADE
|
||||
|
||||
# Regel persistent speichern
|
||||
apt-get install -y iptables-persistent -q || true
|
||||
netfilter-persistent save 2>/dev/null || true
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 8. Systemd Services installieren
|
||||
# ------------------------------------------------------------------------------
|
||||
info "Systemd Services installieren..."
|
||||
cp "$REPO_DIR/systemd/babycam-web.service" /etc/systemd/system/
|
||||
cp "$REPO_DIR/systemd/babycam-network.service" /etc/systemd/system/
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable babycam-web.service
|
||||
systemctl enable babycam-network.service
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 9. Flask braucht Port 80 → als root oder via authbind
|
||||
# ------------------------------------------------------------------------------
|
||||
info "Flask Port 80 erlauben..."
|
||||
apt-get install -y authbind -q
|
||||
touch /etc/authbind/byport/80
|
||||
chmod 500 /etc/authbind/byport/80
|
||||
chown pi /etc/authbind/byport/80
|
||||
|
||||
# Service anpassen um authbind zu nutzen
|
||||
sed -i 's|ExecStart=/usr/bin/python3|ExecStart=/usr/bin/authbind --deep /usr/bin/python3|' \
|
||||
/etc/systemd/system/babycam-web.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Fertig
|
||||
# ------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} Babycam Setup abgeschlossen!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo " Webinterface: http://babycam.local"
|
||||
echo " AP-SSID: BabyCam"
|
||||
echo " AP-Passwort: babycam123"
|
||||
echo " AP-IP: 192.168.50.1"
|
||||
echo ""
|
||||
warn "Kamera-Ribbon-Kabel verbunden? Kamera wird nach Neustart erkannt."
|
||||
echo ""
|
||||
info "Neustart in 5 Sekunden... (Ctrl+C zum Abbrechen)"
|
||||
sleep 5
|
||||
reboot
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[Unit]
|
||||
Description=Babycam Network State Machine
|
||||
After=network.target NetworkManager.service
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /opt/babycam/scripts/network_state.py
|
||||
WorkingDirectory=/opt/babycam
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[Unit]
|
||||
Description=Babycam Webinterface
|
||||
After=network.target
|
||||
Wants=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /opt/babycam/app/main.py
|
||||
WorkingDirectory=/opt/babycam
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=pi
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Loading…
Reference in New Issue