Created
May 3, 2026 07:13
-
-
Save lkwg82/aa4758d70171458caec7365deec934af to your computer and use it in GitHub Desktop.
unifi device disconnect during negative power prices
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================= | |
| # inet-blocker-cronjob.sh | |
| # | |
| # Zwei Modi, gesteuert über die Umgebungsvariable MODE: | |
| # | |
| # MODE=fetch-prices | |
| # Holt die aWATTar-Stundenpreise für heute und morgen und legt sie | |
| # im Cache ab. Täglich einmal aufrufen, nachdem aWATTar die EPEX-Preise | |
| # veröffentlicht hat (typisch gegen 14:00 Uhr). | |
| # Cron-Eintrag (täglich 15:00 Uhr als root): | |
| # 0 15 * * * MODE=fetch-prices /data/scripts/inet-blocker-cronjob.sh | |
| # | |
| # MODE=block-unblock | |
| # Läuft jede Stunde um :45. Prüft, ob die folgende volle Stunde unter | |
| # der Preisschwelle liegt (→ block-sta) oder darüber (→ unblock-sta) | |
| # und führt die UniFi-Aktion bei Bedarf sofort aus. | |
| # Cron-Eintrag (stündlich um :45 als root): | |
| # 45 * * * * MODE=block-unblock /data/scripts/inet-blocker-cronjob.sh | |
| # | |
| # aWATTar API (Deutschland, kein Token): | |
| # Endpunkt: https://api.awattar.de/v1/marketdata | |
| # Parameter: start / end als Unix-Timestamp in Millisekunden | |
| # Preis: marketprice in EUR/MWh → ÷ 1000 = EUR/kWh | |
| # Hinweis: Negative Preise sind möglich (Überproduktion Erneuerbarer)! | |
| # | |
| # Voraussetzungen auf der UDM: | |
| # - curl, jq (ggf.: apt-get install -y jq) | |
| # | |
| # Einrichtung: | |
| # 1. .env Datei befüllen (UNIFI_HOST, UNIFI_USER, UNIFI_PASS) | |
| # 2. chmod +x /data/scripts/inet-blocker-cronjob.sh | |
| # 3. Beide Cron-Einträge anlegen (s. oben) | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ── Konfiguration ───────────────────────────────────────────────────────────── | |
| # Pfade | |
| SCRIPT_DIR="/data/scripts" | |
| LOGS_DIR="${SCRIPT_DIR}/logs" | |
| # .env Datei laden (KEY=VALUE Zeilen) | |
| ENV_FILE="${SCRIPT_DIR}/.env" | |
| if [[ -f "$ENV_FILE" ]]; then | |
| # shellcheck disable=SC1090 | |
| set -o allexport | |
| source "$ENV_FILE" | |
| set +o allexport | |
| fi | |
| # Preisschwelle in EUR/kWh – UNTERHALB dieses Wertes werden Geräte gesperrt. | |
| # Beispiele: | |
| # 1 = unter 1 Cent/kWh sperren (empfohlen) | |
| # 0 = nur bei negativen Preisen sperren | |
| # -5 = nur bei sehr stark negativen Preisen sperren | |
| PRICE_THRESHOLD="2" | |
| # Geräte zum Sperren (MAC-Adressen, Kleinbuchstaben, Leerzeichen-getrennt) | |
| BLOCKED_MACS=( | |
| "00:00:00:00:00:00" # Wechselrichter | |
| ) | |
| # UniFi Controller (lokaler Admin – kein Ubiquiti-Cloud-Account!) | |
| UNIFI_HOST=${UNIFI_HOST:-"https://localhost"} | |
| UNIFI_USER=${UNIFI_USER:-"admin"} | |
| UNIFI_PASS=${UNIFI_PASS:-"password"} | |
| UNIFI_SITE="${UNIFI_SITE:-default}" | |
| # Cache | |
| CACHE_DIR="/tmp/awattar" | |
| # Modus | |
| MODE="${MODE:-block-unblock}" | |
| # ── Hilfsfunktionen ─────────────────────────────────────────────────────────── | |
| mkdir -p "${LOGS_DIR}" | |
| log_timestamp=$(date +%s) | |
| log() { | |
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "${LOGS_DIR}/${log_timestamp}.${MODE}.log" | |
| } | |
| NTFY_URL=https://ntfy.sh/.... # TODO | |
| ntfy() { | |
| if grep -q "Kein Zustandswechsel nötig" "${LOGS_DIR}/${log_timestamp}.${MODE}.log"; then | |
| echo "no notify" | |
| else | |
| curl -s -XPOST -d "$(sed 's/^\[[^]]*\] //' "${LOGS_DIR}/${log_timestamp}.${MODE}.log" 2>/dev/null || true)" \ | |
| -H "Actions: view, Open Browser,${NTFY_URL}" "${NTFY_URL}" > /dev/null | |
| fi | |
| } | |
| cleanup(){ | |
| ntfy | |
| echo "cleanup old logs" | |
| find "${LOGS_DIR}" -name '20*.log' -mtime +7 -delete | |
| } | |
| trap "cleanup" EXIT ERR | |
| # Preise für ein bestimmtes Datum aus dem Cache lesen | |
| # Gibt JSON-Response zurück oder leeren String wenn nicht vorhanden | |
| load_cache() { | |
| local date="$1" | |
| local cache_file="${CACHE_DIR}/prices_${date}.json" | |
| if [[ -f "$cache_file" ]]; then | |
| cat "$cache_file" | |
| else | |
| echo "" | |
| fi | |
| } | |
| # Preis für eine bestimmte Stunde (Unix-Timestamp Sekunden) aus JSON ermitteln | |
| # Gibt ct/kWh zurück oder leeren String wenn nicht gefunden | |
| price_for_hour() { | |
| local response="$1" | |
| local hour_start_sec="$2" | |
| local hour_start_ms=$(( hour_start_sec * 1000 )) | |
| echo "$response" | jq -r --argjson ts "$hour_start_ms" \ | |
| '.data[] | select(.start_timestamp == $ts) | (.marketprice / 1000 * 100)' 2>/dev/null || echo "" | |
| } | |
| # UniFi-Login, Aktion ausführen, Logout | |
| unifi_do_action() { | |
| local ACTION="$1" # "block-sta" oder "unblock-sta" | |
| COOKIE=$(mktemp /tmp/unifi_cookie.XXXXXX) | |
| HEADERS_FILE=$(mktemp /tmp/unifi_headers.XXXXXX) | |
| # shellcheck disable=SC2064 | |
| trap "rm -f ${COOKIE} ${HEADERS_FILE}" RETURN | |
| HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \ | |
| -D "$HEADERS_FILE" \ | |
| -c "$COOKIE" -X POST "${UNIFI_HOST}/api/auth/login" \ | |
| -H "Content-Type: application/json" \ | |
| --data "{\"username\":\"${UNIFI_USER}\",\"password\":\"${UNIFI_PASS}\"}") | |
| if [[ "$HTTP_STATUS" != "200" ]]; then | |
| log "ERROR UniFi-Login fehlgeschlagen (HTTP ${HTTP_STATUS})" | |
| return 1 | |
| fi | |
| CSRF=$(grep -i "^x-csrf-token:" "$HEADERS_FILE" | awk '{print $2}' | tr -d '\r' || true) | |
| for MAC in "${BLOCKED_MACS[@]}"; do | |
| RESP=$(curl -sk \ | |
| -b "$COOKIE" -c "$COOKIE" \ | |
| ${CSRF:+-H "X-CSRF-Token: ${CSRF}"} \ | |
| -X POST "${UNIFI_HOST}/proxy/network/api/s/${UNIFI_SITE}/cmd/stamgr" \ | |
| -H "Content-Type: application/json" \ | |
| --data "{\"cmd\":\"${ACTION}\",\"mac\":\"${MAC}\"}") | |
| RC=$(echo "$RESP" | jq -r '.meta.rc' 2>/dev/null || echo "error") | |
| if [[ "$RC" == "ok" ]]; then | |
| log "OK ${ACTION}: ${MAC}" | |
| else | |
| log "WARN ${ACTION} fehlgeschlagen für ${MAC}: ${RESP}" | |
| fi | |
| done | |
| curl -sk -b "$COOKIE" -X POST "${UNIFI_HOST}/api/auth/logout" > /dev/null || true | |
| } | |
| # ── Modus: fetch-prices ─────────────────────────────────────────────────────── | |
| do_fetch_prices() { | |
| log "INFO ══ $(date) ═" | |
| log "INFO Modus: fetch-prices" | |
| log "INFO Preisschwelle: < ${PRICE_THRESHOLD} ct/kWh" | |
| mkdir -p "$CACHE_DIR" | |
| # Alte Cache-Dateien (> 4 Tage) bereinigen | |
| find "$CACHE_DIR" -name 'prices_*.json' -mtime +4 -delete 2>/dev/null || true | |
| for DAY_LABEL in "today" "tomorrow"; do | |
| CACHE_DATE=$(date -d "$DAY_LABEL" '+%Y-%m-%d') | |
| CACHE_FILE="${CACHE_DIR}/prices_${CACHE_DATE}.json" | |
| if [[ -f "$CACHE_FILE" ]]; then | |
| log "INFO Cache bereits vorhanden für ${CACHE_DATE} – überspringe." | |
| else | |
| START_MS=$(date -d "${DAY_LABEL} 00:00:00" +%s%3N) | |
| END_MS=$(date -d "${DAY_LABEL} 23:59:59" +%s%3N) | |
| AWATTAR_URL="https://api.awattar.de/v1/marketdata?start=${START_MS}&end=${END_MS}" | |
| log "INFO Rufe ab (${CACHE_DATE}): ${AWATTAR_URL}" | |
| RESPONSE=$(curl -sf "$AWATTAR_URL") || { | |
| log "ERROR aWATTar API nicht erreichbar für ${CACHE_DATE} – überspringe." | |
| continue | |
| } | |
| COUNT=$(echo "$RESPONSE" | jq '.data | length') | |
| if [[ "$COUNT" -lt 24 ]]; then | |
| log "WARN Nur ${COUNT}/24 Stundenpreise für ${CACHE_DATE} – nicht gecacht." | |
| log "WARN aWATTar veröffentlicht EPEX-Preise täglich gegen 14:00 Uhr." | |
| continue | |
| fi | |
| echo "$RESPONSE" > "$CACHE_FILE" | |
| log "INFO ${COUNT} Preise für ${CACHE_DATE} gecacht unter ${CACHE_FILE}" | |
| fi | |
| # Stundenliste ausgeben | |
| log "INFO Stundenübersicht ${CACHE_DATE}:" | |
| while IFS=$'\t' read -r START_TS PRICE_MWH; do | |
| START_SEC=$(( START_TS / 1000 )) | |
| HOUR=$(date -d "@${START_SEC}" '+%H') | |
| PRICE_KWH=$(awk -v p="$PRICE_MWH" 'BEGIN { printf "%.4f", p / 1000 * 100 }') | |
| STATE=$(awk -v p="$PRICE_KWH" -v t="$PRICE_THRESHOLD" \ | |
| 'BEGIN { print (p+0 < t+0) ? "block" : "free" }') | |
| if [[ "$STATE" == "block" ]]; then | |
| log "INFO 🔴 ${HOUR}:00 ${PRICE_KWH} ct/kWh (gesperrt)" | |
| else | |
| log "INFO ⚪ ${HOUR}:00 ${PRICE_KWH} ct/kWh (frei)" | |
| fi | |
| done < <(jq < "${CACHE_DIR}/prices_${CACHE_DATE}.json" -r \ | |
| '.data[] | [(.start_timestamp | tostring), (.marketprice | tostring)] | @tsv') | |
| done | |
| log "INFO fetch-prices abgeschlossen." | |
| log "INFO ═══ " | |
| } | |
| # ── Modus: block-unblock ────────────────────────────────────────────────────── | |
| do_block_unblock() { | |
| log "INFO ══ $(date) ══" | |
| log "INFO Modus: block-unblock" | |
| log "INFO Preisschwelle: < ${PRICE_THRESHOLD} ct/kWh" | |
| # Nächste volle Stunde ermitteln (das ist die Stunde, die in 15 min beginnt) | |
| NOW_SEC=$(date +%s) | |
| NEXT_HOUR_SEC=$(( ( NOW_SEC / 3600 + 1 ) * 3600 )) | |
| NEXT_HOUR_LABEL=$(date -d "@${NEXT_HOUR_SEC}" '+%Y-%m-%d %H:00') | |
| NEXT_HOUR_DATE=$(date -d "@${NEXT_HOUR_SEC}" '+%Y-%m-%d') | |
| log "INFO Prüfe Preis für nächste Stunde: ${NEXT_HOUR_LABEL}" | |
| # Cache laden | |
| RESPONSE=$(load_cache "$NEXT_HOUR_DATE") | |
| if [[ -z "$RESPONSE" ]]; then | |
| log "WARN Kein Cache für ${NEXT_HOUR_DATE} – versuche Live-Abruf." | |
| START_MS=$(date -d "${NEXT_HOUR_DATE} 00:00:00" +%s%3N) | |
| END_MS=$(date -d "${NEXT_HOUR_DATE} 23:59:59" +%s%3N) | |
| AWATTAR_URL="https://api.awattar.de/v1/marketdata?start=${START_MS}&end=${END_MS}" | |
| RESPONSE=$(curl -sf "$AWATTAR_URL") || { | |
| log "ERROR aWATTar API nicht erreichbar – Abbruch." | |
| exit 1 | |
| } | |
| mkdir -p "$CACHE_DIR" | |
| echo "$RESPONSE" > "${CACHE_DIR}/prices_${NEXT_HOUR_DATE}.json" | |
| log "INFO Preise für ${NEXT_HOUR_DATE} live abgerufen und gecacht." | |
| fi | |
| # Preis für die nächste Stunde ermitteln | |
| PRICE_KWH=$(price_for_hour "$RESPONSE" "$NEXT_HOUR_SEC") | |
| if [[ -z "$PRICE_KWH" ]]; then | |
| log "WARN Kein Preis für ${NEXT_HOUR_LABEL} gefunden – keine Aktion." | |
| exit 0 | |
| fi | |
| PRICE_DISPLAY=$(awk -v p="$PRICE_KWH" 'BEGIN { printf "%.6f", p }') | |
| log "INFO Preis ${NEXT_HOUR_LABEL}: ${PRICE_DISPLAY} ct/kWh" | |
| # Zustand der nächsten Stunde bestimmen | |
| NEXT_STATE=$(awk -v p="$PRICE_KWH" -v t="$PRICE_THRESHOLD" \ | |
| 'BEGIN { print (p+0 < t+0) ? "block" : "free" }') | |
| # Aktuellen Zustand aus State-Datei lesen | |
| STATE_FILE="${CACHE_DIR}/current_state" | |
| CURRENT_STATE="free" | |
| [[ -f "$STATE_FILE" ]] && CURRENT_STATE=$(cat "$STATE_FILE") | |
| log "INFO Aktueller Zustand: ${CURRENT_STATE} → Nächste Stunde: ${NEXT_STATE}" | |
| if [[ "$NEXT_STATE" == "block" && "$CURRENT_STATE" != "block" ]]; then | |
| log "INFO 🔴 Preissenkung erkannt – starte block-sta (15 min vor ${NEXT_HOUR_LABEL})" | |
| unifi_do_action "block-sta" | |
| echo "block" > "$STATE_FILE" | |
| elif [[ "$NEXT_STATE" == "free" && "$CURRENT_STATE" == "block" ]]; then | |
| log "INFO 🔵 Preisanstieg erkannt – starte unblock-sta für ${NEXT_HOUR_LABEL}" | |
| unifi_do_action "unblock-sta" | |
| echo "free" > "$STATE_FILE" | |
| else | |
| log "INFO ⚪ Kein Zustandswechsel nötig (${CURRENT_STATE})." | |
| fi | |
| log "INFO block-unblock abgeschlossen." | |
| log "INFO ══ " | |
| } | |
| # ── Dispatcher ──────────────────────────────────────────────────────────────── | |
| case "$MODE" in | |
| fetch-prices) | |
| do_fetch_prices | |
| ;; | |
| block-unblock) | |
| do_block_unblock | |
| ;; | |
| *) | |
| log "ERROR Unbekannter Modus: '${MODE}'. Erlaubt: fetch-prices, block-unblock" | |
| exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment