Skip to content

Instantly share code, notes, and snippets.

@lkwg82
Created May 3, 2026 07:13
Show Gist options
  • Select an option

  • Save lkwg82/aa4758d70171458caec7365deec934af to your computer and use it in GitHub Desktop.

Select an option

Save lkwg82/aa4758d70171458caec7365deec934af to your computer and use it in GitHub Desktop.
unifi device disconnect during negative power prices
#!/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