Compare commits

...

3 Commits

Author SHA1 Message Date
Julian Vollmer 30180e5923 Persist AP override across reboots
/tmp/babycam-ap-override → /var/lib/babycam/ap-override
setup.sh legt /var/lib/babycam mit chown pi:pi an

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:58:14 +02:00
Julian Vollmer 7cfc958cf0 Add manual AP mode override via web UI
- State Machine prüft /tmp/babycam-ap-override: 'on'/'off' = manuell, fehlt = Automatik
- Neue API-Endpunkte: POST /ap/on, /ap/off, /ap/auto
- Access-Point-Karte zeigt jetzt 3 Buttons (An / Aus / Auto) + aktuellen Modus
- Manuell überschreibt die Automatik, Auto gibt Kontrolle zurück an die State Machine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:54:53 +02:00
Julian Vollmer d9514e23d7 Add fullscreen live view at /live
- Schwarzer Hintergrund, Stream füllt den ganzen Bildschirm (object-fit: cover)
- Overlay mit Uhrzeit und Einstellungs-Link erscheint bei Tap/Klick, blendet sich nach 3s aus
- Vollbild-Button nutzt Fullscreen API
- Auf Handys wird automatisch Landscape angefragt
- Stream-Box auf Hauptseite ist jetzt klickbar und verlinkt auf /live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:49:33 +02:00
5 changed files with 333 additions and 29 deletions

View File

@ -10,6 +10,7 @@ import subprocess
import json import json
import re import re
import time import time
import os
from flask import Flask, Response, render_template, request, jsonify from flask import Flask, Response, render_template, request, jsonify
app = Flask(__name__) app = Flask(__name__)
@ -113,6 +114,7 @@ def get_system_status():
"ips": ips, "ips": ips,
"wifi_ssid": wifi_ssid, "wifi_ssid": wifi_ssid,
"ap_active": ap_active, "ap_active": ap_active,
"ap_override": get_ap_override(),
"cam_available": cam_available, "cam_available": cam_available,
"uptime": _get_uptime(), "uptime": _get_uptime(),
} }
@ -139,6 +141,12 @@ def index():
return render_template("index.html", status=status) return render_template("index.html", status=status)
@app.route("/live")
def live():
status = get_system_status()
return render_template("live.html", status=status)
@app.route("/api/status") @app.route("/api/status")
def api_status(): def api_status():
return jsonify(get_system_status()) return jsonify(get_system_status())
@ -153,6 +161,34 @@ def wifi_scan():
return jsonify(get_wifi_networks()) return jsonify(get_wifi_networks())
@app.route("/ap/<action>", methods=["POST"])
def ap_control(action):
override_file = "/var/lib/babycam/ap-override"
if action == "on":
with open(override_file, "w") as f:
f.write("on")
return jsonify({"success": True, "mode": "on"})
elif action == "off":
with open(override_file, "w") as f:
f.write("off")
return jsonify({"success": True, "mode": "off"})
elif action == "auto":
try:
os.remove(override_file)
except FileNotFoundError:
pass
return jsonify({"success": True, "mode": "auto"})
return jsonify({"success": False, "error": "Unbekannte Aktion"}), 400
def get_ap_override():
try:
with open("/var/lib/babycam/ap-override") as f:
return f.read().strip().lower()
except FileNotFoundError:
return "auto"
@app.route("/wifi/connect", methods=["POST"]) @app.route("/wifi/connect", methods=["POST"])
def wifi_connect(): def wifi_connect():
data = request.get_json() data = request.get_json()

View File

@ -194,16 +194,18 @@
<div class="container"> <div class="container">
<!-- Stream --> <!-- Stream -->
<div class="stream-box"> <a href="/live" style="text-decoration:none;">
{% if status.cam_available %} <div class="stream-box">
<img src="/stream" alt="Kamera-Stream"> {% if status.cam_available %}
{% else %} <img src="/stream" alt="Kamera-Stream">
<div class="no-cam"> {% else %}
Kamera nicht verfügbar<br> <div class="no-cam">
<small>Ribbon-Kabel verbunden?</small> Kamera nicht verfügbar<br>
</div> <small>Ribbon-Kabel verbunden?</small>
{% endif %} </div>
</div> {% endif %}
</div>
</a>
<!-- Status-Karten --> <!-- Status-Karten -->
<div class="cards"> <div class="cards">
@ -217,10 +219,18 @@
</div> </div>
<div class="card"> <div class="card">
<label>Access Point</label> <label>Access Point</label>
<div class="value"> <div class="value" style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;">
<span class="badge {{ 'on' if status.ap_active else 'off' }}"> <span class="badge {{ 'on' if status.ap_active else 'off' }}">
{{ "aktiv" if status.ap_active else "aus" }} {{ "aktiv" if status.ap_active else "aus" }}
</span> </span>
<span style="font-size:.75rem;color:#666;">
{{ {"on":"manuell an","off":"manuell aus","auto":"automatisch"}[status.ap_override] }}
</span>
</div>
<div style="display:flex;gap:.4rem;margin-top:.6rem;flex-wrap:wrap;">
<button onclick="setAP('on')" class="{{ 'primary' if status.ap_override == 'on' else '' }}" style="font-size:.75rem;padding:.3rem .7rem;">An</button>
<button onclick="setAP('off')" class="{{ 'primary' if status.ap_override == 'off' else '' }}" style="font-size:.75rem;padding:.3rem .7rem;">Aus</button>
<button onclick="setAP('auto')"class="{{ 'primary' if status.ap_override == 'auto' else '' }}" style="font-size:.75rem;padding:.3rem .7rem;">Auto</button>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
@ -279,6 +289,12 @@
document.getElementById("wifi-password").focus(); document.getElementById("wifi-password").focus();
} }
async function setAP(action) {
const res = await fetch(`/ap/${action}`, { method: "POST" });
const data = await res.json();
if (data.success) setTimeout(() => location.reload(), 800);
}
async function connectWifi() { async function connectWifi() {
if (!selectedSSID) return; if (!selectedSSID) return;
const password = document.getElementById("wifi-password").value; const password = document.getElementById("wifi-password").value;

214
app/templates/live.html Normal file
View File

@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Babycam Live</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
background: #000;
overflow: hidden;
}
#stream {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
#no-cam {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #444;
font-family: -apple-system, sans-serif;
gap: 0.5rem;
}
#no-cam span { font-size: 3rem; }
#no-cam p { font-size: 0.9rem; }
/* Overlay erscheint bei Tap/Hover */
#overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.5) 0%, transparent 30%, transparent 70%, rgba(0,0,0,0.5) 100%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
body.show-ui #overlay { opacity: 1; }
#top-bar {
position: absolute;
top: 0; left: 0; right: 0;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
font-family: -apple-system, sans-serif;
}
#top-bar .title {
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
#top-bar .dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #f44336;
animation: pulse 1.5s infinite;
}
{% if status.cam_available %}
#top-bar .dot { background: #4caf50; animation: none; }
{% endif %}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
#bottom-bar {
position: absolute;
bottom: 0; left: 0; right: 0;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
font-family: -apple-system, sans-serif;
font-size: 0.85rem;
}
#clock { font-size: 1.1rem; font-weight: 500; letter-spacing: 0.05em; }
.btn {
background: rgba(255,255,255,0.15);
backdrop-filter: blur(8px);
border: none;
color: #fff;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.85rem;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn:hover { background: rgba(255,255,255,0.25); }
/* Fullscreen-Button */
#fs-btn {
position: absolute;
top: 1rem; right: 1.25rem;
pointer-events: all;
}
/* Alles außer fs-btn nur bei show-ui anzeigen */
#top-bar .title,
#bottom-bar {
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
body.show-ui #top-bar .title,
body.show-ui #bottom-bar {
opacity: 1;
pointer-events: all;
}
body.show-ui #fs-btn { opacity: 1; }
#fs-btn { opacity: 0; transition: opacity 0.3s; pointer-events: all; }
body.show-ui #fs-btn { opacity: 1; }
</style>
</head>
<body>
{% if status.cam_available %}
<img id="stream" src="/stream" alt="Babycam Live-Stream">
{% else %}
<div id="no-cam">
<span>📷</span>
<p>Kamera nicht verbunden</p>
</div>
{% endif %}
<div id="overlay"></div>
<div id="top-bar">
<div class="title">
<div class="dot"></div>
Babycam Live
</div>
<button class="btn" id="fs-btn" onclick="toggleFullscreen()">⛶ Vollbild</button>
</div>
<div id="bottom-bar">
<div id="clock"></div>
<a class="btn" href="/">⚙ Einstellungen</a>
</div>
<script>
// UI bei Tap/Klick kurz einblenden
let hideTimer;
function showUI() {
document.body.classList.add("show-ui");
clearTimeout(hideTimer);
hideTimer = setTimeout(() => document.body.classList.remove("show-ui"), 3000);
}
document.body.addEventListener("click", showUI);
document.body.addEventListener("touchstart", showUI, { passive: true });
// Uhr
function updateClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, "0");
const m = String(now.getMinutes()).padStart(2, "0");
const s = String(now.getSeconds()).padStart(2, "0");
document.getElementById("clock").textContent = `${h}:${m}:${s}`;
}
updateClock();
setInterval(updateClock, 1000);
// Fullscreen
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
}
document.addEventListener("fullscreenchange", () => {
const btn = document.getElementById("fs-btn");
btn.textContent = document.fullscreenElement ? "✕ Vollbild beenden" : "⛶ Vollbild";
});
// Auf Handy: Landscape empfehlen
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock("landscape").catch(() => {});
}
</script>
</body>
</html>

View File

@ -2,15 +2,21 @@
""" """
Babycam Network State Machine Babycam Network State Machine
Verwaltet die drei Betriebsmodi: Verwaltet die drei Betriebsmodi:
A Heimnetz (wlan0 verbunden) A Heimnetz (wlan0 verbunden, AP aus)
B Kein WLAN (AP auf wlan1) B Kein WLAN (AP auf wlan1 automatisch)
C Setup-Modus (AP läuft, User konfiguriert WLAN) C Setup-Modus (AP läuft, User konfiguriert WLAN)
Override-Datei: /tmp/babycam-ap-override
"on" AP immer an (manuell)
"off" AP immer aus (manuell)
fehlt Automatik
""" """
import subprocess import subprocess
import time import time
import logging import logging
import sys import sys
import os
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -23,21 +29,30 @@ AP_INTERFACE = "wlan1"
CLIENT_INTERFACE = "wlan0" CLIENT_INTERFACE = "wlan0"
AP_IP = "192.168.50.1" AP_IP = "192.168.50.1"
CHECK_INTERVAL = 30 # Sekunden CHECK_INTERVAL = 30 # Sekunden
OVERRIDE_FILE = "/var/lib/babycam/ap-override"
def run(cmd, check=False): def run(cmd, check=False):
result = subprocess.run( result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
cmd, shell=True, capture_output=True, text=True
)
if check and result.returncode != 0: if check and result.returncode != 0:
log.error(f"Fehler bei: {cmd}\n{result.stderr}") log.error(f"Fehler bei: {cmd}\n{result.stderr}")
return result return result
def get_override():
"""Liest die manuelle Override-Einstellung. Gibt 'on', 'off' oder None zurück."""
try:
with open(OVERRIDE_FILE) as f:
val = f.read().strip().lower()
if val in ("on", "off"):
return val
except FileNotFoundError:
pass
return None
def is_wlan0_connected(): def is_wlan0_connected():
result = run( result = run(f"nmcli -t -f GENERAL.STATE device show {CLIENT_INTERFACE}")
f"nmcli -t -f GENERAL.STATE device show {CLIENT_INTERFACE}"
)
return "100 (connected)" in result.stdout return "100 (connected)" in result.stdout
@ -68,21 +83,40 @@ def main():
current_mode = None current_mode = None
while True: while True:
override = get_override()
connected = is_wlan0_connected() connected = is_wlan0_connected()
ap_running = is_ap_running() ap_running = is_ap_running()
if connected: if override == "on":
if current_mode != "A": # Manuell: AP immer an
log.info("Modus A: Heimnetz aktiv.") if current_mode != "MANUAL_ON":
if ap_running: log.info("Modus: Manuell Access Point erzwungen AN.")
stop_ap()
current_mode = "A"
else:
if current_mode != "B":
log.info("Modus B: Kein WLAN starte Access Point.")
if not ap_running: if not ap_running:
start_ap() start_ap()
current_mode = "B" current_mode = "MANUAL_ON"
elif override == "off":
# Manuell: AP immer aus
if current_mode != "MANUAL_OFF":
log.info("Modus: Manuell Access Point erzwungen AUS.")
if ap_running:
stop_ap()
current_mode = "MANUAL_OFF"
else:
# Automatik
if connected:
if current_mode != "A":
log.info("Modus A: Heimnetz aktiv AP aus.")
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) time.sleep(CHECK_INTERVAL)

View File

@ -46,6 +46,10 @@ fi
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 3. Projektdateien installieren # 3. Projektdateien installieren
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
info "Verzeichnisse anlegen..."
mkdir -p /var/lib/babycam
chown pi:pi /var/lib/babycam
info "Projektdateien nach $INSTALL_DIR kopieren..." info "Projektdateien nach $INSTALL_DIR kopieren..."
mkdir -p "$INSTALL_DIR" mkdir -p "$INSTALL_DIR"
cp -r "$REPO_DIR/app" "$INSTALL_DIR/" cp -r "$REPO_DIR/app" "$INSTALL_DIR/"