NEWS
Einfache Anwesenheitssimulation mit zibee OHNE ioBroker
-
Hallo zusammen,
habe mal ein wenig herum probiert und das ist dabei rausgekommen.
Ich habe versucht ein einfaches System für eine Anwesenheitssimulation zu erstellen. Das ist für Leute die nicht direkt alles Smart haben wollen. Ich habe bei mir im Umfeld durchaus Personen, die sich sicherer fühlen, wenn nachts im Haus hier und da eine Lampe an uns aus geht.
Als Hardware reicht ein Raspberry Pi 3, ein Zigbee Stick (SONOFF Zigbee 3.0 USB Dongle Plus,Zigbee Gateway TI CC2652P) und am besten die Tradfri Ikea Lampen. Diese Lampen haben einen sehr schönen Farbton und Ikea kennt jeder.
Das ist ein "Rundum-Sorglos Installationsskript".
Meine erprobte Reihenfolge um es zu betreiben.
- SD-Karte mit der aktuellen (Trixie) Version (ohne Desktop) bespielen.
Pi muß mit einem Netzwerkkabel im Netzwerk sein.
- Dann auf den Pi mit Putty gehen.
- Jetzt kommen die Befehle
Als erstes:
sudo raspi-config
Dort dieses einstellen:
- Tastatur auf DE einstellen
- WLAN auf DE einstellen
- Speicher expandieren
Hier macht der Pi einen reboot.
Dann:
sudo apt update
sudo apt full-upgrade -y
sudo rebootWieder auf den Pi
sudo nano install_aws.sh
Skript einfügen (siehe weiter unten)
sudo chmod +x install_aws.sh
sudo ./install_aws.shWenn alles ohne Fehler durchgelaufen ist, sollten im Putty-Fenster die entsprechenden IP-Adressen auftauchen.
Ab da dann die Netzwerkadressen mit Port im Browser eingeben.
Dort sollte das dann erscheinen:
AWS:

zigbee:

Nun das Skript:
#!/usr/bin/env bash set -euo pipefail ############################################ # SmartHome-AWS Master One-Shot Installer # - Mosquitto + Zigbee2MQTT + Presence Backend (UI + API) # - Optional WiFi AP (NetworkManager) # - WiFi-Client-Setup via Web-UI (nmcli) # - Reboot-Button (Host reboot, Confirm-Dialog) # - Zigbee-Dongle Auto-Detect (/dev/serial/by-id/*) + korrektes Docker Device-Mapping ############################################ # ---------- Settings (change if you want) ---------- TZ="${TZ:-Europe/Berlin}" # AP / Hotspot (NetworkManager) ENABLE_AP="${ENABLE_AP:-1}" # 1 = create AP, 0 = skip AP_IFACE="${AP_IFACE:-wlan0}" AP_CON_NAME="${AP_CON_NAME:-SmartHome-AWS-AP}" AP_SSID="${AP_SSID:-SmartHome-AWS}" AP_PSK="${AP_PSK:-SmartHomeAWS1234!}" AP_IP="${AP_IP:-192.168.50.1/24}" # Install dir BASE_DIR="${BASE_DIR:-/home/pi/aws-stack}" PI_USER="${PI_USER:-pi}" # Zigbee2MQTT Z2M_DIR="${Z2M_DIR:-$BASE_DIR/zigbee2mqtt}" Z2M_DATA_RESET="${RESET_Z2M:-0}" # 1 = delete database/state (FACTORY RESET), 0 = keep Z2M_CHANNEL="${Z2M_CHANNEL:-15}" Z2M_PAN_ID="${Z2M_PAN_ID:-0x1A62}" Z2M_ADAPTER="${Z2M_ADAPTER:-zstack}" # Ports (für Host-Network: presence-backend hört trotzdem auf :3000) PORT_BACKEND="${PORT_BACKEND:-3000}" PORT_Z2M_UI="${PORT_Z2M_UI:-8080}" PORT_MQTT="${PORT_MQTT:-1883}" # ---------- Helpers ---------- log(){ echo -e "\n[+] $*\n"; } warn(){ echo -e "\n[!] $*\n" >&2; } die(){ echo -e "\n[ERROR] $*\n" >&2; exit 1; } need_root(){ if [[ "${EUID}" -ne 0 ]]; then die "Bitte mit sudo ausführen: sudo ./install_aws.sh" fi } ensure_pi_user(){ if ! id -u "$PI_USER" >/dev/null 2>&1; then die "User '$PI_USER' existiert nicht. Setze PI_USER=<user> beim Start." fi } # ---------- Main ---------- need_root ensure_pi_user log "Timezone setzen: $TZ" timedatectl set-timezone "$TZ" || true log "Pakete installieren/aktualisieren" apt-get update -y apt-get install -y \ ca-certificates curl git jq nano \ network-manager rfkill iw iproute2 \ avahi-daemon util-linux systemctl enable --now NetworkManager || true systemctl enable --now avahi-daemon || true log "Docker installieren (get.docker.com) + Compose v2" if ! command -v docker >/dev/null 2>&1; then curl -fsSL https://get.docker.com | sh fi systemctl enable --now docker # Add user to docker group (so später ohne sudo möglich) if ! id -nG "$PI_USER" | grep -qw docker; then usermod -aG docker "$PI_USER" || true fi # docker compose v2 check if ! docker compose version >/dev/null 2>&1; then warn "docker compose v2 nicht verfügbar – versuche Compose Plugin nachzuinstallieren" apt-get update -y || true apt-get install -y docker-compose-plugin || true fi # ---------- Optional WiFi AP ---------- if [[ "$ENABLE_AP" == "1" ]]; then log "WiFi-AP/Hotspot einrichten (NetworkManager): SSID='$AP_SSID' IFACE='$AP_IFACE' IP='$AP_IP'" rfkill unblock wifi || true if nmcli -t -f NAME con show | grep -qx "$AP_CON_NAME"; then log "AP Verbindung existiert bereits -> aktualisiere Parameter" nmcli con modify "$AP_CON_NAME" 802-11-wireless.ssid "$AP_SSID" || true nmcli con modify "$AP_CON_NAME" 802-11-wireless.mode ap || true nmcli con modify "$AP_CON_NAME" 802-11-wireless.band bg || true nmcli con modify "$AP_CON_NAME" ipv4.method shared || true nmcli con modify "$AP_CON_NAME" ipv4.addresses "$AP_IP" || true nmcli con modify "$AP_CON_NAME" wifi-sec.key-mgmt wpa-psk || true nmcli con modify "$AP_CON_NAME" wifi-sec.psk "$AP_PSK" || true nmcli con modify "$AP_CON_NAME" connection.autoconnect yes || true nmcli con modify "$AP_CON_NAME" connection.autoconnect-priority 50 || true else nmcli con add type wifi ifname "$AP_IFACE" con-name "$AP_CON_NAME" autoconnect yes ssid "$AP_SSID" || true nmcli con modify "$AP_CON_NAME" 802-11-wireless.mode ap || true nmcli con modify "$AP_CON_NAME" 802-11-wireless.band bg || true nmcli con modify "$AP_CON_NAME" ipv4.method shared || true nmcli con modify "$AP_CON_NAME" ipv4.addresses "$AP_IP" || true nmcli con modify "$AP_CON_NAME" wifi-sec.key-mgmt wpa-psk || true nmcli con modify "$AP_CON_NAME" wifi-sec.psk "$AP_PSK" || true nmcli con modify "$AP_CON_NAME" connection.autoconnect yes || true nmcli con modify "$AP_CON_NAME" connection.autoconnect-priority 50 || true fi nmcli con up "$AP_CON_NAME" || warn "AP konnte nicht sofort aktiviert werden (evtl. konkurrierende WLAN-Verbindung)." else log "WiFi-AP übersprungen (ENABLE_AP=0)" fi # ---------- Directories ---------- log "Projektverzeichnis anlegen: $BASE_DIR" mkdir -p "$BASE_DIR" chown -R "$PI_USER:$PI_USER" "$BASE_DIR" mkdir -p "$BASE_DIR/mosquitto/config" "$BASE_DIR/mosquitto/data" "$BASE_DIR/mosquitto/log" mkdir -p "$Z2M_DIR" mkdir -p "$BASE_DIR/presence-backend/public" mkdir -p "$BASE_DIR/presence-backend/data" # ---------- Mosquitto config ---------- log "Mosquitto config schreiben" cat > "$BASE_DIR/mosquitto/config/mosquitto.conf" <<'EOF' persistence true persistence_location /mosquitto/data/ log_dest stdout listener 1883 allow_anonymous true EOF chown -R "$PI_USER:$PI_USER" "$BASE_DIR/mosquitto" # ---------- Detect Zigbee serial device ---------- log "Zigbee USB Device suchen (/dev/serial/by-id/* bevorzugt)" # Docker "devices:" braucht einen echten Device-Node. # /dev/serial/by-id/<...> ist Symlink auf /dev/ttyACM0|ttyUSB0 -> OK. # Wir mappen Host-Symlink nach /dev/ttyACM0 im Container (stabil). ZIGBEE_HOST="" ZIGBEE_CONT="/dev/ttyACM0" if compgen -G "/dev/serial/by-id/*" > /dev/null; then ZIGBEE_HOST="$(ls -1 /dev/serial/by-id/* | head -n1)" else if [[ -e "/dev/ttyACM0" ]]; then ZIGBEE_HOST="/dev/ttyACM0" elif [[ -e "/dev/ttyUSB0" ]]; then ZIGBEE_HOST="/dev/ttyUSB0" ZIGBEE_CONT="/dev/ttyUSB0" fi fi if [[ -z "${ZIGBEE_HOST}" ]]; then warn "Kein Zigbee-Serial-Device gefunden. Zigbee2MQTT startet evtl. nicht, bis du den Dongle steckst." ZIGBEE_HOST="/dev/ttyACM0" ZIGBEE_CONT="/dev/ttyACM0" fi log "Zigbee host device: ${ZIGBEE_HOST}" log "Zigbee container port: ${ZIGBEE_CONT}" usermod -aG dialout "$PI_USER" || true # ---------- Zigbee2MQTT configuration.yaml ---------- log "Zigbee2MQTT configuration.yaml schreiben" cat > "$Z2M_DIR/configuration.yaml" <<EOF homeassistant: false permit_join: false mqtt: server: mqtt://mosquitto:1883 base_topic: zigbee2mqtt serial: port: ${ZIGBEE_CONT} adapter: ${Z2M_ADAPTER} frontend: port: 8080 advanced: channel: ${Z2M_CHANNEL} pan_id: ${Z2M_PAN_ID} device_options: {} EOF chown -R "$PI_USER:$PI_USER" "$Z2M_DIR" if [[ "$Z2M_DATA_RESET" == "1" ]]; then log "RESET_Z2M=1 -> lösche Zigbee2MQTT Datenbank/State (Factory Reset)" rm -f "$Z2M_DIR/database.db" "$Z2M_DIR/state.json" "$Z2M_DIR/coordinator_backup.json" || true fi # ---------- Presence backend ---------- log "Presence backend files schreiben (server.js + public/index.html)" cat > "$BASE_DIR/presence-backend/package.json" <<'EOF' { "name": "smarthome-aws-presence-backend", "version": "1.0.0", "type": "module", "private": true, "main": "server.js", "dependencies": { "express": "^4.19.2", "mqtt": "^5.10.3", "suncalc": "^1.9.0" } } EOF cat > "$BASE_DIR/presence-backend/Dockerfile" <<'EOF' FROM node:20-bookworm-slim # nmcli + nsenter (util-linux) + dbus client RUN apt-get update && apt-get install -y --no-install-recommends \ network-manager dbus util-linux ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json /app/package.json RUN npm install --omit=dev COPY server.js /app/server.js COPY public /app/public RUN mkdir -p /app/data EXPOSE 3000 CMD ["node","server.js"] EOF # ----- server.js ----- cat > "$BASE_DIR/presence-backend/server.js" <<'EOF' import express from "express"; import mqtt from "mqtt"; import fs from "fs"; import path from "path"; import SunCalc from "suncalc"; import { execFile } from "child_process"; const app = express(); app.use(express.json()); app.use(express.static(path.join(process.cwd(), "public"))); const DATA_DIR = "/app/data"; const CONFIG_PATH = path.join(DATA_DIR, "config.json"); const LOG_PATH = path.join(DATA_DIR, "AWSLog.csv"); const mqttUrl = process.env.MQTT_URL || "mqtt://mosquitto:1883"; const client = mqtt.connect(mqttUrl); // --------- Default Config ---------- const DEFAULT_CONFIG = { system: { enabled: false, forceRun: false, // <- ignoriert Zeitfenster turnOffOnDisable: true, ignoreWhenOn: false, tz: "Europe/Berlin", location: { lat: 52.52, lon: 13.405 } }, groups: [ { name: "Gruppe1", activeFrom: "sunset", activeTo: "sunrise", durationMin: 5, durationMax: 15, startDelayMinMax: 1, intervalMin: 10, brightnessOn: 200, devices: [] } ] }; function ensureDataDir() { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); } function loadConfig() { ensureDataDir(); if (!fs.existsSync(CONFIG_PATH)) { fs.writeFileSync(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2), "utf8"); return structuredClone(DEFAULT_CONFIG); } const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); if (!cfg.system) cfg.system = structuredClone(DEFAULT_CONFIG.system); if (typeof cfg.system.enabled !== "boolean") cfg.system.enabled = false; if (typeof cfg.system.forceRun !== "boolean") cfg.system.forceRun = false; if (typeof cfg.system.turnOffOnDisable !== "boolean") cfg.system.turnOffOnDisable = true; if (typeof cfg.system.ignoreWhenOn !== "boolean") cfg.system.ignoreWhenOn = false; if (!cfg.system.location) cfg.system.location = structuredClone(DEFAULT_CONFIG.system.location); if (!Array.isArray(cfg.groups)) cfg.groups = structuredClone(DEFAULT_CONFIG.groups); cfg.groups.forEach((g) => { if (typeof g.brightnessOn !== "number") g.brightnessOn = 200; if (!Array.isArray(g.devices)) g.devices = []; if (typeof g.intervalMin !== "number") g.intervalMin = 10; if (typeof g.durationMin !== "number") g.durationMin = 5; if (typeof g.durationMax !== "number") g.durationMax = 15; if (typeof g.startDelayMinMax !== "number") g.startDelayMinMax = 1; }); return cfg; } function saveConfig(cfg) { ensureDataDir(); fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8"); } let CFG = loadConfig(); // --------- CSV Logging ---------- function appendLog(line) { ensureDataDir(); const now = new Date(); const pad = (n) => String(n).padStart(2, "0"); const date = `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()}`; const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; const header = "Datum;Uhrzeit;Gruppe;Device;FriendlyName;Delay_s;Duration_s;EinZeit;AusZeit;Kommentar\n"; if (!fs.existsSync(LOG_PATH)) fs.writeFileSync(LOG_PATH, header, "utf8"); fs.appendFileSync(LOG_PATH, `${date};${time};${line}\n`, "utf8"); } // --------- Device Cache aus Zigbee2MQTT ---------- let Z2M_DEVICES = []; let DEVICE_STATE = new Map(); // friendly_name -> payload function requestZ2MDevices() { client.publish("zigbee2mqtt/bridge/request/devices", ""); } client.on("connect", () => { console.log("MQTT connected:", mqttUrl); requestZ2MDevices(); setInterval(requestZ2MDevices, 60_000); client.subscribe("zigbee2mqtt/bridge/devices"); client.subscribe("zigbee2mqtt/+"); }); client.on("message", (topic, payloadBuf) => { try { const payload = JSON.parse(payloadBuf.toString("utf8")); if (topic.startsWith("zigbee2mqtt/") && !topic.startsWith("zigbee2mqtt/bridge/")) { const parts = topic.split("/"); if (parts.length === 2) DEVICE_STATE.set(parts[1], payload); } if (topic === "zigbee2mqtt/bridge/devices" && Array.isArray(payload)) { Z2M_DEVICES = payload .filter((d) => d.type === "EndDevice" || d.type === "Router" || d.type === "device") .map((d) => ({ friendly_name: d.friendly_name ?? d.ieee_address, ieee_address: d.ieee_address, manufacturer: d.manufacturer, model_id: d.model_id, type: d.type })); } } catch { // ignore } }); // --------- Helpers (Zeit/Astro) ---------- const ASTRO_KEYS = new Set([ "sunrise","sunriseEnd","goldenHourEnd","solarNoon","goldenHour","sunsetStart", "sunset","dusk","nauticalDusk","night","nightEnd","nauticalDawn","dawn","nadir" ]); function randInt(min, max) { const mn = Math.ceil(min); const mx = Math.floor(max); return Math.floor(Math.random() * (mx - mn + 1)) + mn; } function parseTimeToDateToday(hms) { const [h, m, s] = String(hms).split(":").map((x) => parseInt(x, 10)); const d = new Date(); d.setHours(h || 0, m || 0, s || 0, 0); return d; } function astroDate(key) { const { lat, lon } = CFG.system.location; const times = SunCalc.getTimes(new Date(), lat, lon); return times[key]; } function toHHMMSS(d) { const pad = (n) => String(n).padStart(2, "0"); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function getLowerUpper(from, to) { const now = new Date(); let lower = ASTRO_KEYS.has(from) ? astroDate(from) : parseTimeToDateToday(from); let upper = ASTRO_KEYS.has(to) ? astroDate(to) : parseTimeToDateToday(to); if (upper <= lower) upper = new Date(upper.getTime() + 24 * 3600 * 1000); if (now < lower && (upper - lower) > 12 * 3600 * 1000) { lower = new Date(lower.getTime() - 24 * 3600 * 1000); upper = new Date(upper.getTime() - 24 * 3600 * 1000); } return { lower, upper }; } function isNowInRange(group) { const { lower, upper } = getLowerUpper(group.activeFrom, group.activeTo); const now = new Date(); return now >= lower && now <= upper; } function isAnyGroupInWindow() { if (!Array.isArray(CFG.groups) || CFG.groups.length === 0) return false; return CFG.groups.some((g) => isNowInRange(g)); } // --------- Zigbee Schalten ---------- function z2mSet(friendlyName, obj) { const topic = `zigbee2mqtt/${friendlyName}/set`; client.publish(topic, JSON.stringify(obj)); } function turnOffAllGroupDevices() { const all = new Set(CFG.groups.flatMap((g) => g.devices || []).filter(Boolean)); for (const dev of all) z2mSet(dev, { state: "OFF" }); } async function switchDeviceOnce(groupName, friendlyName, delayMs, durationMs, comment = "", brightnessOn = 200) { const st = DEVICE_STATE.get(friendlyName); const isOn = st && String(st.state || "").toUpperCase() === "ON"; if (isOn) { if (CFG.system.ignoreWhenOn) { appendLog(`${groupName};${friendlyName};${friendlyName};0;0;--;--;keine Aktion - Gerät war bereits eingeschaltet`); return; } else { delayMs = 0; comment = (comment ? comment + " | " : "") + "Geraet war bereits eingeschaltet -- uebersteuert"; } } const now = Date.now(); const einZeit = new Date(now + delayMs); const ausZeit = new Date(now + delayMs + durationMs); if (!isOn) { setTimeout(() => { const bri = Math.max(0, Math.min(254, Number(brightnessOn))); z2mSet(friendlyName, { state: "ON", brightness: bri }); }, delayMs); } setTimeout(() => { z2mSet(friendlyName, { state: "OFF" }); }, delayMs + durationMs); appendLog( `${groupName};${friendlyName};${friendlyName};${Math.round(delayMs / 1000)};${Math.round(durationMs / 1000)};${toHHMMSS(einZeit)};${toHHMMSS(ausZeit)};${comment || ""}` ); } // --------- Scheduler ---------- let GROUP_TIMERS = new Map(); let WINDOW_WAS_IN = false; function stopAllTimers() { for (const t of GROUP_TIMERS.values()) clearInterval(t); GROUP_TIMERS.clear(); } function shouldRunGroupNow(g) { if (!CFG.system.enabled) return false; if (CFG.system.forceRun) return true; // <- ignoriert Zeitfenster return isNowInRange(g); } function rebuildTimers() { stopAllTimers(); for (const g of CFG.groups) { const intervalMs = Math.max(1, Number(g.intervalMin || 10)) * 60_000; const timer = setInterval(async () => { if (!CFG.system.enabled) return; if (!shouldRunGroupNow(g)) return; if (!Array.isArray(g.devices) || g.devices.length === 0) return; const pick = g.devices[randInt(0, g.devices.length - 1)]; if (pick === undefined || pick === null) return; if (String(pick).trim() === "") return; // Leerzeit const delayMs = randInt(0, Math.max(0, Number(g.startDelayMinMax || 1)) * 60) * 1000; const durSec = randInt(Math.max(1, Number(g.durationMin || 5)) * 60, Math.max(1, Number(g.durationMax || 15)) * 60); const durationMs = durSec * 1000; const bri = typeof g.brightnessOn === "number" ? g.brightnessOn : 200; await switchDeviceOnce(g.name, pick, delayMs, durationMs, "", bri); }, intervalMs); GROUP_TIMERS.set(g.name, timer); } } function startSimulation({ force = false } = {}) { CFG.system.enabled = true; CFG.system.forceRun = !!force; saveConfig(CFG); rebuildTimers(); appendLog(`;;;;;;;AWS wurde aktiviert${force ? " (ohne Zeitfenster)" : ""}`); } function stopSimulation() { CFG.system.enabled = false; CFG.system.forceRun = false; saveConfig(CFG); stopAllTimers(); appendLog(`;;;;;;;AWS wurde deaktiviert`); if (CFG.system.turnOffOnDisable) turnOffAllGroupDevices(); } if (CFG.system.enabled) rebuildTimers(); // Watchdog: wenn Simulation läuft UND NICHT forceRun, // und wir verlassen das Zeitfenster -> schalte alles aus (falls turnOffOnDisable=true) setInterval(() => { if (!CFG.system.enabled) { WINDOW_WAS_IN = false; return; } if (CFG.system.forceRun) { WINDOW_WAS_IN = true; return; } const nowIn = isAnyGroupInWindow(); if (WINDOW_WAS_IN && !nowIn) { if (CFG.system.turnOffOnDisable) { turnOffAllGroupDevices(); appendLog(`;;;;;;;Zeitfenster verlassen -> alle Geräte AUS`); } } WINDOW_WAS_IN = nowIn; }, 30_000); // --------- Network helpers (nmcli) ---------- function nmcli(args) { return new Promise((resolve, reject) => { execFile("nmcli", args, { timeout: 25_000 }, (err, stdout, stderr) => { if (err) return reject(new Error((stderr || err.message || "").trim())); resolve(String(stdout || "").trim()); }); }); } async function getIPs() { const raw = await nmcli(["device", "show"]).catch(() => ""); const blocks = raw.split("\n\n").map((b) => b.trim()).filter(Boolean); const res = []; for (const b of blocks) { const dev = (b.match(/GENERAL\.DEVICE:\s*(.+)/) || [])[1]?.trim(); if (!dev) continue; const ips = [...b.matchAll(/IP4\.ADDRESS\[\d+\]:\s*([0-9.]+)\/\d+/g)].map((m) => m[1]); if (ips.length) res.push({ iface: dev, ips }); } return res; } // Host reboot via nsenter into PID 1 (needs privileged + pid:host) function rebootHostSoon() { // Fire-and-forget: respond immediately, then reboot. setTimeout(() => { execFile("nsenter", ["-t", "1", "-m", "-u", "-i", "-n", "-p", "/sbin/reboot"], { timeout: 5_000 }, () => {}); }, 600); } // --------- Web routes ---------- app.get("/health", (_req, res) => res.json({ ok: true })); app.get("/api/status", (_req, res) => { res.json({ enabled: CFG.system.enabled, forceRun: CFG.system.forceRun, mqtt: client.connected, location: CFG.system.location, turnOffOnDisable: CFG.system.turnOffOnDisable, ignoreWhenOn: CFG.system.ignoreWhenOn }); }); app.get("/api/window", (_req, res) => { const anyIn = isAnyGroupInWindow(); res.json({ inWindow: anyIn, mode: CFG.system.forceRun ? "FORCE" : "NORMAL", enabled: CFG.system.enabled }); }); app.get("/api/devices", (_req, res) => res.json(Z2M_DEVICES)); app.get("/api/config", (_req, res) => res.json(CFG)); app.post("/api/config", (req, res) => { const cfg = req.body; if (!cfg || !cfg.system || !Array.isArray(cfg.groups)) { return res.status(400).send("invalid config"); } if (typeof cfg.system.enabled !== "boolean") cfg.system.enabled = false; if (typeof cfg.system.forceRun !== "boolean") cfg.system.forceRun = false; if (typeof cfg.system.turnOffOnDisable !== "boolean") cfg.system.turnOffOnDisable = true; if (typeof cfg.system.ignoreWhenOn !== "boolean") cfg.system.ignoreWhenOn = false; if (!cfg.system.location) cfg.system.location = structuredClone(DEFAULT_CONFIG.system.location); cfg.groups.forEach((g) => { if (typeof g.brightnessOn !== "number") g.brightnessOn = 200; if (!Array.isArray(g.devices)) g.devices = []; }); CFG = cfg; saveConfig(CFG); if (CFG.system.enabled) rebuildTimers(); else stopAllTimers(); res.json({ ok: true }); }); // Normal start/stop (beachtet Zeitfenster) app.post("/api/sim/start", (_req, res) => { startSimulation({ force: false }); res.json({ ok: true }); }); app.post("/api/sim/stop", (_req, res) => { stopSimulation(); res.json({ ok: true }); }); // Force start/stop (ignoriert Zeitfenster) app.post("/api/sim/force_start", (_req, res) => { startSimulation({ force: true }); res.json({ ok: true }); }); app.post("/api/sim/force_stop", (_req, res) => { stopSimulation(); res.json({ ok: true }); }); app.get("/api/logs.csv", (_req, res) => { if (!fs.existsSync(LOG_PATH)) return res.status(404).send("no logs yet"); res.type("text/csv").send(fs.readFileSync(LOG_PATH, "utf8")); }); app.get("/api/logs.tail", (_req, res) => { if (!fs.existsSync(LOG_PATH)) return res.type("text/plain").send("noch keine logs"); const lines = fs.readFileSync(LOG_PATH, "utf8").trimEnd().split("\n"); res.type("text/plain").send(lines.slice(-30).join("\n")); }); // Network info + WiFi connect (STA) – AP bleibt bestehen app.get("/api/network", async (_req, res) => { try { const ips = await getIPs(); const active = await nmcli(["-t", "-f", "NAME,TYPE,DEVICE", "con", "show", "--active"]).catch(() => ""); res.json({ ok: true, ips, active }); } catch (e) { res.status(500).json({ ok: false, error: e.message || String(e) }); } }); app.post("/api/wifi/connect", async (req, res) => { try { const ssid = String(req.body?.ssid || "").trim(); const psk = String(req.body?.psk || "").trim(); const iface = String(req.body?.iface || "wlan0").trim(); if (!ssid) return res.status(400).send("ssid missing"); if (psk.length < 8) return res.status(400).send("psk must be >= 8 chars"); const conName = `SmartHome-AWS-STA-${ssid}`; await nmcli(["con", "delete", conName]).catch(() => null); await nmcli(["dev", "wifi", "connect", ssid, "password", psk, "ifname", iface, "name", conName]); await nmcli(["con", "modify", conName, "connection.autoconnect", "yes"]).catch(() => null); await nmcli(["con", "modify", conName, "connection.autoconnect-priority", "100"]).catch(() => null); res.json({ ok: true, message: "WLAN gespeichert. Bitte rebooten. AP bleibt aktiv; nach Reboot zeigt die UI unter Netzwerk die neue WLAN-IP." }); } catch (e) { res.status(500).json({ ok: false, error: e.message || String(e) }); } }); // Reboot (Host) app.post("/api/system/reboot", (_req, res) => { res.json({ ok: true, message: "Reboot wird ausgelöst..." }); rebootHostSoon(); }); app.listen(3000, () => console.log("SmartHome-AWS UI/Backend on :3000")); EOF # ----- public/index.html (WLAN-Reboot-Button mit Confirm) ----- cat > "$BASE_DIR/presence-backend/public/index.html" <<'EOF' <!doctype html> <html lang="de"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>SmartHome-AWS</title> <style> :root{ --bg:#0b0d12; --panel:#121626; --panel2:#0f1322; --border:#232a44; --border2:#2a3358; --text:#e7eaf0; --muted:#a9b1cf; --accent:#3a66ff; --accent2:#2a3358; --ok:#7CFFB2; --bad:#FF7C7C; --warn:#FFD37C; --shadow: 0 10px 30px rgba(0,0,0,.25); --r:14px; } *{box-sizing:border-box} body{font-family:system-ui,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:var(--bg);color:var(--text)} a{color:#cfd6ff} header{ padding:14px 16px; background:rgba(18,22,38,.92); backdrop-filter: blur(10px); position:sticky;top:0; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; z-index:10; } header strong{font-size:16px;letter-spacing:.2px} header .grow{flex:1} header .right{display:flex;align-items:center;gap:10px} .wrap{ display:grid; grid-template-columns:240px 1fr; min-height:calc(100vh - 56px); } nav{ border-right:1px solid var(--border); background:var(--panel2); padding:12px; position:sticky; top:56px; height:calc(100vh - 56px); overflow:auto; } nav a{ display:flex; align-items:center; gap:10px; color:#cfd6ff; text-decoration:none; padding:10px 10px; border-radius:12px; margin-bottom:6px; border:1px solid transparent; } nav a:hover{background:#1a2140} nav a.active{ background:#1a2140; border-color:var(--border2); box-shadow: inset 0 0 0 1px rgba(58,102,255,.15); } main{padding:16px;max-width:1100px;width:100%} .card{ background:var(--panel); border:1px solid var(--border); border-radius:var(--r); padding:14px; margin-bottom:12px; box-shadow: var(--shadow); } h2{margin:0 0 10px 0;font-size:18px} h3{margin:0 0 8px 0;font-size:16px} .small{opacity:.85;font-size:13px;color:var(--muted);line-height:1.35} .pill{ display:inline-flex; align-items:center; gap:8px; padding:5px 10px; border-radius:999px; background:#1a2140; border:1px solid var(--border); font-size:12px; color:#d7dcff; white-space:nowrap; } .dot{width:8px;height:8px;border-radius:999px;background:var(--warn)} .pill.ok .dot{background:var(--ok)} .pill.bad .dot{background:var(--bad)} .pill.warn .dot{background:var(--warn)} button{ background:var(--accent); border:0; color:white; padding:10px 12px; border-radius:12px; cursor:pointer; font-weight:600; transition:transform .04s ease, filter .15s ease; } button:hover{filter:brightness(1.05)} button:active{transform:translateY(1px)} button.secondary{background:var(--accent2);font-weight:600} button.danger{background:transparent;border:1px solid rgba(255,124,124,.6);color:#ffd7d7} button.ghost{background:transparent;border:1px solid var(--border2);color:#cfd6ff} button:disabled{opacity:.6;cursor:not-allowed} input,select,textarea{ width:100%; padding:10px; border-radius:12px; border:1px solid var(--border2); background:var(--bg); color:var(--text); outline:none; } input:focus,select:focus,textarea:focus{border-color:#3a66ff;box-shadow:0 0 0 3px rgba(58,102,255,.18)} textarea{resize:vertical} .row{display:grid;grid-template-columns:1fr 1fr;gap:10px} .row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px} .flex{display:flex;gap:10px;flex-wrap:wrap;align-items:center} .spacer{height:8px} table{width:100%;border-collapse:collapse} td,th{border-bottom:1px solid var(--border);padding:10px 8px;text-align:left;font-size:14px} th{color:#dfe3ff;font-weight:700} tr:hover td{background:rgba(26,33,64,.25)} .sectionHead{ display:flex; justify-content:space-between; align-items:flex-end; gap:10px; flex-wrap:wrap; } .hint{ padding:10px; border-radius:12px; border:1px dashed rgba(58,102,255,.35); background:rgba(26,33,64,.35); } .toastWrap{ position:fixed; right:14px; bottom:14px; display:flex; flex-direction:column; gap:10px; z-index:9999; pointer-events:none; } .toast{ pointer-events:auto; min-width:240px; max-width:360px; background:rgba(18,22,38,.95); border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:14px; padding:10px 12px; box-shadow: var(--shadow); display:flex; gap:10px; align-items:flex-start; } .toast.ok{border-left-color:var(--ok)} .toast.bad{border-left-color:var(--bad)} .toast .tTitle{font-weight:800;font-size:13px;margin-bottom:2px} .toast .tBody{font-size:13px;color:var(--muted);line-height:1.3} .toast button{ margin-left:auto; padding:6px 9px; border-radius:10px; font-size:12px; } .kbd{ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; padding: 2px 6px; border-radius: 8px; border: 1px solid var(--border2); background: rgba(0,0,0,.25); color: #dfe3ff; } @media (max-width: 900px){ .wrap{grid-template-columns:1fr} nav{ position:static; height:auto; border-right:0; border-bottom:1px solid var(--border); } } </style> </head> <body> <header> <strong>SmartHome-AWS</strong> <span id="statusPill" class="pill warn"><span class="dot"></span><span>…</span></span> <span id="windowPill" class="pill warn"><span class="dot"></span><span>ZEITFENSTER …</span></span> <div class="grow"></div> <div class="right"> <span id="busyPill" class="pill" style="display:none"><span class="dot"></span><span>Lädt…</span></span> <button class="secondary" onclick="reloadAll()">Reload</button> </div> </header> <div class="wrap"> <nav> <a href="#dashboard" data-nav="dashboard">Dashboard</a> <a href="#wifi" data-nav="wifi">WLAN</a> <a href="#pairing" data-nav="pairing">Lampen anlernen</a> <a href="#devices" data-nav="devices">Geräte</a> <a href="#groups" data-nav="groups">Gruppen & Zeiten</a> <a href="#times" data-nav="times">Astro</a> <a href="#logs" data-nav="logs">Logs</a> </nav> <main> <section id="dashboard" class="card"> <div class="sectionHead"> <h2>Dashboard</h2> <div class="flex"> <span id="simState" class="pill warn"><span class="dot"></span><span>…</span></span> <button onclick="startSim()">Start (mit Zeitfenster)</button> <button class="secondary" onclick="stopSim()">Stop</button> <button class="ghost" onclick="startForce()">Start (ohne Zeitfenster)</button> </div> </div> <div class="spacer"></div> <div class="hint small"> <strong>„Start (mit Zeitfenster)”</strong> schaltet nur innerhalb deiner eingestellten Zeiten.<br/> <strong>„Start (ohne Zeitfenster)”</strong> ignoriert Zeiten (Testmodus / manuelles Laufenlassen). </div> </section> <section id="wifi" class="card"> <div class="sectionHead"> <h2>WLAN</h2> <div class="flex"> <button class="secondary" onclick="loadNetwork()">Netzwerk anzeigen</button> <button class="danger" onclick="rebootNow()">Reboot</button> </div> </div> <div class="spacer"></div> <div class="row"> <div> <label class="small">SSID</label> <input id="wifiSsid" placeholder="Dein WLAN-Name (SSID)"/> </div> <div> <label class="small">Passwort</label> <input id="wifiPsk" type="password" placeholder="WLAN Passwort (min 8 Zeichen)"/> </div> </div> <div class="flex" style="margin-top:10px"> <button onclick="saveWifi()">WLAN speichern</button> <span class="small">AP bleibt aktiv. Nach Reboot zeigt dir „Netzwerk anzeigen“ die neue WLAN-IP.</span> </div> <div class="spacer"></div> <pre id="netInfo" style="white-space:pre-wrap;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:10px;margin-top:10px;min-height:60px"></pre> </section> <section id="pairing" class="card"> <h2>Lampen anlernen</h2> <p>Öffnet Zigbee2MQTT Web-UI:</p> <p> <a id="z2mLink" href="#" target="_blank" rel="noreferrer"> Zigbee2MQTT öffnen (Port 8080) </a> </p> <p class="small">Der Link passt sich automatisch an (AP-IP oder WLAN-IP – je nachdem, wie du die UI aufrufst).</p> </section> <section id="devices" class="card"> <div class="sectionHead"> <h2>Geräte</h2> <div class="flex"> <input id="devSearch" placeholder="Suchen (Name/Model/IEEE) …" style="max-width:320px" oninput="renderDeviceTable(window._z2mDevices||[])"/> <button class="secondary" onclick="loadDevices()">Geräte aktualisieren</button> </div> </div> <div id="devTable" style="margin-top:10px"></div> </section> <section id="groups" class="card"> <div class="sectionHead"> <h2>Gruppen & Zeiten</h2> <div class="flex"> <button class="secondary" onclick="addGroup()">Gruppe hinzufügen</button> <button onclick="saveGroups()">Speichern</button> </div> </div> <div class="spacer"></div> <div class="hint small"> Zeiten in Gruppen: <strong>HH:MM:SS</strong> oder Astro-Keyword (<strong>sunrise, sunset, dusk, night, dawn</strong>).<br/> Oben siehst du live: <strong>IM ZEITFENSTER</strong> oder <strong>AUSSERHALB</strong>. </div> <div id="groupsUI" style="margin-top:12px"></div> </section> <section id="times" class="card"> <h2>Astro / System</h2> <div class="row"> <div> <label class="small">Latitude</label> <input id="lat" type="number" step="0.0001" placeholder="z. B. 52.5200"/> </div> <div> <label class="small">Longitude</label> <input id="lon" type="number" step="0.0001" placeholder="z. B. 13.4050"/> </div> </div> <div class="row" style="margin-top:10px"> <div> <label class="small">„Alles aus bei Stop / Zeitfenster Ende“</label> <select id="turnOffOnDisable"> <option value="true">Ja</option> <option value="false">Nein</option> </select> </div> <div> <label class="small">Ignorieren wenn Gerät schon AN</label> <select id="ignoreWhenOn"> <option value="true">Ja</option> <option value="false">Nein (Override)</option> </select> </div> </div> <div style="margin-top:10px" class="flex"> <button onclick="saveSystem()">Speichern</button> <span class="small">Diese Werte gelten systemweit für Astro & Verhalten.</span> </div> </section> <section id="logs" class="card"> <div class="sectionHead"> <h2>Logs</h2> <div class="flex"> <a href="/api/logs.csv" target="_blank" rel="noreferrer">AWSLog.csv herunterladen</a> <button class="secondary" onclick="tailLogs()">Letzte 30 Zeilen</button> </div> </div> <pre id="logTail" style="white-space:pre-wrap;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:10px;margin-top:10px;min-height:60px"></pre> </section> </main> </div> <div class="toastWrap" id="toastWrap"></div> <datalist id="astroWords"> <option value="sunrise"></option> <option value="sunset"></option> <option value="dawn"></option> <option value="dusk"></option> <option value="night"></option> </datalist> <script> let _busyCount = 0; function setBusy(on){ _busyCount += on ? 1 : -1; if (_busyCount < 0) _busyCount = 0; document.getElementById('busyPill').style.display = _busyCount ? 'inline-flex' : 'none'; } function toast(type, title, body, timeout=3500){ const wrap = document.getElementById('toastWrap'); const t = document.createElement('div'); t.className = 'toast ' + (type || ''); t.innerHTML = ` <div> <div class="tTitle">${escapeHtml(title||'Info')}</div> <div class="tBody">${escapeHtml(body||'')}</div> </div> <button class="ghost" type="button">OK</button> `; const btn = t.querySelector('button'); btn.onclick = () => t.remove(); wrap.appendChild(t); if(timeout){ setTimeout(() => { if(t.isConnected) t.remove(); }, timeout); } } function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } async function api(path, opts={}) { setBusy(true); try{ const r = await fetch(path, {headers:{'Content-Type':'application/json'}, ...opts}); const ct = r.headers.get('content-type')||''; const payload = ct.includes('application/json') ? await r.json().catch(()=>null) : await r.text().catch(()=>''); if (!r.ok){ const msg = (typeof payload === 'string' && payload) ? payload : (payload?.error || payload?.message || JSON.stringify(payload) || r.statusText); throw new Error(msg); } return payload; } finally { setBusy(false); } } function setPill(el, mode, text){ el.className = 'pill ' + (mode || 'warn'); el.innerHTML = `<span class="dot"></span><span>${escapeHtml(text||'')}</span>`; } function updateZ2MLink(){ const host = window.location.hostname; const url = `http://${host}:8080`; const a = document.getElementById('z2mLink'); if (a){ a.href = url; a.textContent = `Zigbee2MQTT öffnen (${host}:8080)`; } } async function loadStatus(){ const st = await api('/api/status'); const label = st.enabled ? (st.forceRun ? 'AKTIV (OHNE ZEIT)' : 'AKTIV') : 'INAKTIV'; setPill(document.getElementById('simState'), st.enabled ? 'ok' : 'bad', label); const mqttOk = !!st.mqtt; setPill(document.getElementById('statusPill'), mqttOk ? 'ok' : 'warn', mqttOk ? 'MQTT OK' : 'MQTT ?'); if (st.location){ document.getElementById('lat').value = st.location.lat ?? ''; document.getElementById('lon').value = st.location.lon ?? ''; } document.getElementById('turnOffOnDisable').value = String(!!st.turnOffOnDisable); document.getElementById('ignoreWhenOn').value = String(!!st.ignoreWhenOn); } async function loadWindow(){ try{ const w = await api('/api/window'); const text = w.mode === 'FORCE' ? 'ZEITFENSTER: IGNORIERT' : (w.inWindow ? 'IM ZEITFENSTER' : 'AUSSERHALB'); setPill(document.getElementById('windowPill'), (w.mode === 'FORCE') ? 'warn' : (w.inWindow ? 'ok' : 'bad'), text ); }catch{ setPill(document.getElementById('windowPill'), 'warn', 'ZEITFENSTER ?'); } } async function startSim(){ await api('/api/sim/start', {method:'POST'}); await loadStatus(); toast('ok','Simulation','Gestartet (mit Zeitfenster).'); } async function startForce(){ await api('/api/sim/force_start', {method:'POST'}); await loadStatus(); toast('ok','Simulation','Gestartet (ohne Zeitfenster).'); } async function stopSim(){ await api('/api/sim/stop', {method:'POST'}); await loadStatus(); toast('ok','Simulation','Gestoppt.'); } function renderDeviceTable(devs){ const q = (document.getElementById('devSearch').value || '').trim().toLowerCase(); const filtered = !q ? devs : devs.filter(d => { const s = `${d.friendly_name||''} ${d.model_id||''} ${d.ieee_address||''}`.toLowerCase(); return s.includes(q); }); let html = `<table> <thead><tr><th>Name</th><th>Model</th><th>IEEE</th></tr></thead> <tbody>`; for(const d of filtered){ html += `<tr> <td>${escapeHtml(d.friendly_name||'')}</td> <td>${escapeHtml(d.model_id||'')}</td> <td>${escapeHtml(d.ieee_address||'')}</td> </tr>`; } html += '</tbody></table>'; if(!filtered.length){ html = `<div class="small">Keine Geräte gefunden.</div>`; } document.getElementById('devTable').innerHTML = html; } async function loadDevices(){ const devs = await api('/api/devices'); window._z2mDevices = devs || []; renderDeviceTable(window._z2mDevices); if (window._cfg) renderGroups(window._cfg); toast('ok','Geräte','Aktualisiert.'); } async function loadConfig(){ const cfg = await api('/api/config'); window._cfg = cfg; await loadStatus(); const devs = await api('/api/devices'); window._z2mDevices = devs || []; renderGroups(cfg); renderDeviceTable(window._z2mDevices); } function renderGroups(cfg){ const box = document.getElementById('groupsUI'); box.innerHTML = ''; cfg.groups.forEach((g, idx) => { const el = document.createElement('div'); el.className = 'card'; el.style.borderColor = 'var(--border2)'; el.innerHTML = ` <div class="sectionHead"> <div> <h3 style="margin:0">${escapeHtml(g.name || ('Gruppe ' + (idx+1)))}</h3> <div class="small">Zeitfenster & Random/Intervall pro Gruppe</div> </div> <div class="flex"> <button class="secondary" type="button" onclick="duplicateGroup(${idx})">Duplizieren</button> <button class="secondary" type="button" onclick="deleteGroup(${idx})">Löschen</button> </div> </div> <div class="row" style="margin-top:10px"> <div> <label class="small">Name</label> <input value="${escapeHtml(g.name||'')}" oninput="_cfg.groups[${idx}].name=this.value;"/> </div> <div class="flex" style="justify-content:flex-end;align-items:end"> <span class="small">Index: ${idx+1}</span> </div> </div> <div class="row" style="margin-top:10px"> <div> <label class="small">Aktiv von (HH:MM:SS oder Astro)</label> <input list="astroWords" value="${escapeHtml(g.activeFrom||'')}" placeholder="z. B. sunset oder 18:30:00" oninput="_cfg.groups[${idx}].activeFrom=this.value"/> </div> <div> <label class="small">Aktiv bis (HH:MM:SS oder Astro)</label> <input list="astroWords" value="${escapeHtml(g.activeTo||'')}" placeholder="z. B. sunrise oder 23:00:00" oninput="_cfg.groups[${idx}].activeTo=this.value"/> </div> </div> <div class="row3" style="margin-top:10px"> <div> <label class="small">Intervall (Min)</label> <input type="number" value="${Number(g.intervalMin ?? 10)}" oninput="_cfg.groups[${idx}].intervalMin=Number(this.value)"/> </div> <div> <label class="small">StartDelay max (Min)</label> <input type="number" value="${Number(g.startDelayMinMax ?? 1)}" oninput="_cfg.groups[${idx}].startDelayMinMax=Number(this.value)"/> </div> <div> <label class="small">Brightness (0..254)</label> <input type="number" min="0" max="254" value="${(typeof g.brightnessOn === 'number') ? g.brightnessOn : 200}" oninput="_cfg.groups[${idx}].brightnessOn=Number(this.value)"/> </div> </div> <div class="row" style="margin-top:10px"> <div> <label class="small">Dauer (Min..Max)</label> <div class="row"> <input type="number" value="${Number(g.durationMin ?? 5)}" oninput="_cfg.groups[${idx}].durationMin=Number(this.value)"/> <input type="number" value="${Number(g.durationMax ?? 15)}" oninput="_cfg.groups[${idx}].durationMax=Number(this.value)"/> </div> </div> <div class="hint small"> Tipp: Leere Zeile in „Geräte“ = Leerzeit. </div> </div> <div style="margin-top:10px"> <label class="small">Geräte (friendly_name) – 1 Zeile pro Gerät</label> <div class="row" style="margin-top:6px"> <div> <select id="devPick-${idx}"> ${(window._z2mDevices || []).map(d => `<option value="${escapeHtml(d.friendly_name)}">${escapeHtml(d.friendly_name)}</option>`).join('')} </select> </div> <div style="display:flex;gap:10px;align-items:end;justify-content:flex-end"> <button class="secondary" type="button" onclick="addDeviceLine(${idx}, document.getElementById('devPick-${idx}').value)"> + hinzufügen </button> </div> </div> <div class="flex" style="margin-top:8px"> <button class="secondary" type="button" onclick="addEmptyLine(${idx})">Leerzeit hinzufügen</button> <button class="secondary" type="button" onclick="removeLastLine(${idx})">Letzte Zeile entfernen</button> </div> <textarea rows="4" data-devices-idx="${idx}" oninput="_cfg.groups[${idx}].devices = this.value.split('\\n').map(x => x.trimEnd());" style="margin-top:8px" >${escapeHtml((g.devices || []).join('\n'))}</textarea> <div class="small">Format: <span class="kbd">Lampe_Wohnzimmer</span> (leer = Leerzeit)</div> </div> `; box.appendChild(el); }); } function getGroupTextarea(idx){ return document.querySelector(`textarea[data-devices-idx="${idx}"]`); } function addDeviceLine(idx, name){ const ta = getGroupTextarea(idx); if (!ta) return; const current = ta.value ? ta.value.split('\n') : []; current.push(name); ta.value = current.join('\n'); window._cfg.groups[idx].devices = ta.value.split('\n').map(x => x.trimEnd()); } function addEmptyLine(idx){ addDeviceLine(idx, ""); } function removeLastLine(idx){ const ta = getGroupTextarea(idx); if (!ta) return; const lines = ta.value ? ta.value.split('\n') : []; lines.pop(); ta.value = lines.join('\n'); window._cfg.groups[idx].devices = ta.value.split('\n').map(x => x.trimEnd()); } function syncDevicesFromUI(){ document.querySelectorAll('textarea[data-devices-idx]').forEach((ta) => { const idx = Number(ta.getAttribute('data-devices-idx')); if (!Number.isFinite(idx)) return; if (!window._cfg?.groups?.[idx]) return; window._cfg.groups[idx].devices = ta.value.split('\n').map(x => x.trimEnd()); }); } async function saveGroups(){ try{ syncDevicesFromUI(); await api('/api/config', {method:'POST', body: JSON.stringify(window._cfg)}); await loadConfig(); toast('ok','Gruppen','Gespeichert.'); }catch(e){ toast('bad','Fehler beim Speichern', e.message || String(e)); } } function addGroup(){ window._cfg.groups.push({ name: "NeueGruppe", activeFrom: "sunset", activeTo: "sunrise", durationMin: 5, durationMax: 15, startDelayMinMax: 1, intervalMin: 10, brightnessOn: 200, devices: [] }); renderGroups(window._cfg); toast('ok','Gruppe','Hinzugefügt (noch nicht gespeichert).', 2500); } function duplicateGroup(i){ const g = window._cfg.groups[i]; const copy = JSON.parse(JSON.stringify(g)); copy.name = (copy.name || 'Gruppe') + ' (Kopie)'; window._cfg.groups.splice(i+1, 0, copy); renderGroups(window._cfg); toast('ok','Gruppe','Dupliziert (noch nicht gespeichert).', 2500); } function deleteGroup(i){ const name = window._cfg.groups[i]?.name || ('Gruppe ' + (i+1)); if (!confirm(`Gruppe löschen: "${name}"?`)) return; window._cfg.groups.splice(i,1); renderGroups(window._cfg); toast('ok','Gruppe','Gelöscht (noch nicht gespeichert).', 2500); } async function saveSystem(){ try{ const lat = Number(document.getElementById('lat').value); const lon = Number(document.getElementById('lon').value); const turnOffOnDisable = document.getElementById('turnOffOnDisable').value === 'true'; const ignoreWhenOn = document.getElementById('ignoreWhenOn').value === 'true'; const cfg = await api('/api/config'); cfg.system = cfg.system || {}; cfg.system.location = {lat, lon}; cfg.system.turnOffOnDisable = turnOffOnDisable; cfg.system.ignoreWhenOn = ignoreWhenOn; await api('/api/config', {method:'POST', body: JSON.stringify(cfg)}); await loadConfig(); toast('ok','Astro/System','Gespeichert.'); }catch(e){ toast('bad','Fehler beim Speichern', e.message || String(e)); } } async function tailLogs(){ try{ const t = await api('/api/logs.tail'); document.getElementById('logTail').textContent = (typeof t === 'string') ? t : JSON.stringify(t, null, 2); }catch(e){ toast('bad','Logs', e.message || String(e)); } } async function loadNetwork(){ try{ const n = await api('/api/network'); document.getElementById('netInfo').textContent = JSON.stringify(n, null, 2); toast('ok','Netzwerk','Aktualisiert.'); }catch(e){ toast('bad','Netzwerk', e.message || String(e), 6000); } } async function saveWifi(){ try{ const ssid = (document.getElementById('wifiSsid').value || '').trim(); const psk = (document.getElementById('wifiPsk').value || '').trim(); if(!ssid) throw new Error('SSID fehlt'); if(psk.length < 8) throw new Error('Passwort muss mindestens 8 Zeichen haben'); const r = await api('/api/wifi/connect', {method:'POST', body: JSON.stringify({ssid, psk, iface:'wlan0'})}); toast('ok','WLAN', r.message || 'Gespeichert. Bitte rebooten.'); await loadNetwork(); }catch(e){ toast('bad','WLAN', e.message || String(e), 7000); } } async function rebootNow(){ const ok = confirm("Wirklich rebooten?\n\nHinweis: Verbindung kann kurz weg sein. AP sollte nach dem Reboot wieder da sein."); if(!ok) return; try{ const r = await api('/api/system/reboot', {method:'POST'}); toast('ok','Reboot', r.message || 'Reboot wird ausgelöst…', 6000); }catch(e){ toast('bad','Reboot', e.message || String(e), 7000); } } async function reloadAll(){ try{ updateZ2MLink(); await loadConfig(); await loadWindow(); toast('ok','Reload','Aktualisiert.'); }catch(e){ toast('bad','Fehler beim Laden', e.message || String(e), 6000); } } function updateActiveNav(){ const hash = (location.hash || '#dashboard').replace('#',''); document.querySelectorAll('nav a[data-nav]').forEach(a=>{ a.classList.toggle('active', a.getAttribute('data-nav') === hash); }); } window.addEventListener('hashchange', updateActiveNav); reloadAll().then(updateActiveNav); setInterval(() => { loadWindow(); loadStatus(); updateZ2MLink(); }, 10_000); </script> </body> </html> EOF chown -R "$PI_USER:$PI_USER" "$BASE_DIR/presence-backend" # ---------- docker-compose.yml ---------- log "docker-compose.yml schreiben" cat > "$BASE_DIR/docker-compose.yml" <<EOF services: mosquitto: image: eclipse-mosquitto:2 restart: unless-stopped ports: - "${PORT_MQTT}:1883" volumes: - ./mosquitto/config:/mosquitto/config - ./mosquitto/data:/mosquitto/data - ./mosquitto/log:/mosquitto/log zigbee2mqtt: image: koenkk/zigbee2mqtt restart: unless-stopped depends_on: - mosquitto ports: - "${PORT_Z2M_UI}:8080" volumes: - ./zigbee2mqtt:/app/data devices: - "${ZIGBEE_HOST}:${ZIGBEE_CONT}" environment: - TZ=${TZ} presence-backend: build: ./presence-backend image: aws-stack-presence-backend restart: unless-stopped depends_on: - mosquitto environment: - TZ=${TZ} - MQTT_URL=mqtt://localhost:1883 # Damit nmcli + Host-Reboot funktionieren: network_mode: host pid: host privileged: true volumes: - /run/NetworkManager:/run/NetworkManager - /etc/NetworkManager:/etc/NetworkManager:ro - /var/run/dbus:/var/run/dbus - ./presence-backend/data:/app/data EOF chown -R "$PI_USER:$PI_USER" "$BASE_DIR" # ---------- Start stack ---------- log "Docker Stack starten/builden" cd "$BASE_DIR" docker compose down || true docker compose build --no-cache presence-backend docker compose up -d log "Status" docker compose ps || true log "Fertig!" IP_MAIN="$(hostname -I | awk '{print $1}')" echo "------------------------------------------------------------" echo "SmartHome-AWS UI: http://${IP_MAIN}:${PORT_BACKEND}" echo "Zigbee2MQTT Dashboard: http://${IP_MAIN}:${PORT_Z2M_UI}" echo "MQTT: mqtt://${IP_MAIN}:${PORT_MQTT}" echo "AP SSID (optional): ${AP_SSID} / ${AP_PSK}" echo "" echo "Wenn du ohne sudo docker nutzen willst: einmal ab- und wieder anmelden (oder reboot)." echo "Logs:" echo " cd ${BASE_DIR} && docker compose logs -f zigbee2mqtt" echo " cd ${BASE_DIR} && docker compose logs -f presence-backend" echo "------------------------------------------------------------"Viel Spaß
-
Über 1600 Zeilen bash Script, um die Installation von iobroker zu vermeiden?
Ist Fastenzeit... da muss man sich auch mal quälen können....
Für ein Ferienhaus aber ggfs eine Überlegung wert. Wobei man aber auch da ggfs die smart home Hardware nicht nur fokussiert auf ein Thema verwenden möchte....
-
Na ja, es ging am Anfang nur darum einer 63jährigen Dame ein ganz simple Lampenschaltung an die Hand zu geben. Sie hatte Nachts eben Angst weil ihr Haus so unbewohnt erschien. Da war iobroker dann doch etwas zu kompliziert. Ich wollte auch für mich den "Wartungsaufwand" so gering wie möglich halten. Iea Lampen waren schon da.
Jetzt sind es schon 4 Leute die das am laufen haben.Noch was. Es ist eben auch sehr kostengünstig. Pi und Tradfri Lampen bei Ebay-kleinanzeigen, und schon ist ganze Haus nachts für unter 100 Euro beleuchtet. Noch den zigbee Stick.
-
Das geht aber doch auch über die IKEA App, wenn eh schon Tradfri genutzt wird. Was macht deine Lösung da anders? Wenn ich da an meine Eltern (70+) denke, die kommen gerade so mir der App zurecht und wären mit dem Zigbee2mqtt Dashboard völlig überfordert.