diff --git a/README.md b/README.md index d02fc40..a2f4d37 100644 --- a/README.md +++ b/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 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) --- diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b994b5b --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..96dccd0 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,306 @@ + + + + + + Babycam + + + + +
+
+

Babycam

+
+ +
+ + +
+ {% if status.cam_available %} + Kamera-Stream + {% else %} +
+ Kamera nicht verfügbar
+ Ribbon-Kabel verbunden? +
+ {% endif %} +
+ + +
+
+ +
{{ status.wifi_ssid }}
+
+
+ +
{{ status.ips | join(", ") or "keine" }}
+
+
+ +
+ + {{ "aktiv" if status.ap_active else "aus" }} + +
+
+
+ +
{{ status.uptime }}
+
+
+ + +
+
+

WLAN konfigurieren

+ +
+

Auf Scannen klicken…

+
+
Verbinden mit:
+ + +
+
+
+ +
+ + + + + diff --git a/config/dnsmasq.conf b/config/dnsmasq.conf new file mode 100644 index 0000000..0980594 --- /dev/null +++ b/config/dnsmasq.conf @@ -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 diff --git a/config/hostapd.conf b/config/hostapd.conf new file mode 100644 index 0000000..47be199 --- /dev/null +++ b/config/hostapd.conf @@ -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 diff --git a/scripts/network_state.py b/scripts/network_state.py new file mode 100644 index 0000000..7018874 --- /dev/null +++ b/scripts/network_state.py @@ -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() diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..2f1d83f --- /dev/null +++ b/setup.sh @@ -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 diff --git a/systemd/babycam-network.service b/systemd/babycam-network.service new file mode 100644 index 0000000..d742940 --- /dev/null +++ b/systemd/babycam-network.service @@ -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 diff --git a/systemd/babycam-web.service b/systemd/babycam-web.service new file mode 100644 index 0000000..bae1d88 --- /dev/null +++ b/systemd/babycam-web.service @@ -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