Compare commits
No commits in common. "496215c61a59b0742dbf2d6cb44655da453627ca" and "476922f440b9a0d4f733cd0ef65c33ce534a52bd" have entirely different histories.
496215c61a
...
476922f440
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 476922f440b9a0d4f733cd0ef65c33ce534a52bd
|
|
||||||
56
README.md
56
README.md
|
|
@ -1,48 +1,4 @@
|
||||||
# Raspberry Pi Babycam
|
# Raspberry Pi Babycam – Gesamtplan (Pi 3 + Camera Module 3 NoIR)
|
||||||
|
|
||||||
**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
|
||||||
|
|
||||||
|
|
@ -181,11 +137,10 @@ Ergebnis:
|
||||||
|
|
||||||
Empfohlene Konfiguration:
|
Empfohlene Konfiguration:
|
||||||
|
|
||||||
rpicam-vid (ehemals libcamera-vid):
|
libcamera-vid:
|
||||||
- Auflösung: 1280x720
|
- Auflösung: 1280x720
|
||||||
- Framerate: 20 fps
|
- Framerate: 20 fps
|
||||||
- Codec: MJPEG
|
- H264 Stream über TCP
|
||||||
- Ausgabe: stdout → Flask MJPEG-Stream
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -216,8 +171,9 @@ Funktionen:
|
||||||
|
|
||||||
## 12. Systemdienste
|
## 12. Systemdienste
|
||||||
|
|
||||||
- `babycam-web.service` → Flask Webserver (Port 80, autostart)
|
- Webserver (Flask oder FastAPI)
|
||||||
- `babycam-network.service` → State Machine (prüft alle 30s den WLAN-Status)
|
- Kamera-Service
|
||||||
|
- optional Netzwerk-State-Manager
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
183
app/main.py
183
app/main.py
|
|
@ -1,183 +0,0 @@
|
||||||
#!/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)
|
|
||||||
|
|
@ -1,306 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
#!/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()
|
|
||||||
158
setup.sh
158
setup.sh
|
|
@ -1,158 +0,0 @@
|
||||||
#!/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
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[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