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
|
## 1. Zielarchitektur
|
||||||
|
|
||||||
|
|
@ -137,10 +181,11 @@ Ergebnis:
|
||||||
|
|
||||||
Empfohlene Konfiguration:
|
Empfohlene Konfiguration:
|
||||||
|
|
||||||
libcamera-vid:
|
rpicam-vid (ehemals libcamera-vid):
|
||||||
- Auflösung: 1280x720
|
- Auflösung: 1280x720
|
||||||
- Framerate: 20 fps
|
- Framerate: 20 fps
|
||||||
- H264 Stream über TCP
|
- Codec: MJPEG
|
||||||
|
- Ausgabe: stdout → Flask MJPEG-Stream
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -171,9 +216,8 @@ Funktionen:
|
||||||
|
|
||||||
## 12. Systemdienste
|
## 12. Systemdienste
|
||||||
|
|
||||||
- Webserver (Flask oder FastAPI)
|
- `babycam-web.service` → Flask Webserver (Port 80, autostart)
|
||||||
- Kamera-Service
|
- `babycam-network.service` → State Machine (prüft alle 30s den WLAN-Status)
|
||||||
- optional Netzwerk-State-Manager
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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