Compare commits
3 Commits
496215c61a
...
30180e5923
| Author | SHA1 | Date |
|---|---|---|
|
|
30180e5923 | |
|
|
7cfc958cf0 | |
|
|
d9514e23d7 |
36
app/main.py
36
app/main.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,7 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<!-- Stream -->
|
<!-- Stream -->
|
||||||
|
<a href="/live" style="text-decoration:none;">
|
||||||
<div class="stream-box">
|
<div class="stream-box">
|
||||||
{% if status.cam_available %}
|
{% if status.cam_available %}
|
||||||
<img src="/stream" alt="Kamera-Stream">
|
<img src="/stream" alt="Kamera-Stream">
|
||||||
|
|
@ -204,6 +205,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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,12 +83,31 @@ 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 override == "on":
|
||||||
|
# Manuell: AP immer an
|
||||||
|
if current_mode != "MANUAL_ON":
|
||||||
|
log.info("Modus: Manuell – Access Point erzwungen AN.")
|
||||||
|
if not ap_running:
|
||||||
|
start_ap()
|
||||||
|
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 connected:
|
||||||
if current_mode != "A":
|
if current_mode != "A":
|
||||||
log.info("Modus A: Heimnetz aktiv.")
|
log.info("Modus A: Heimnetz aktiv – AP aus.")
|
||||||
if ap_running:
|
if ap_running:
|
||||||
stop_ap()
|
stop_ap()
|
||||||
current_mode = "A"
|
current_mode = "A"
|
||||||
|
|
|
||||||
4
setup.sh
4
setup.sh
|
|
@ -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/"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue