[BETA][Skript] Octopus Energy Deutschland – Strompreise via Kraken API in IP-Symcon

Hallo zusammen,

ich habe ein PHP-Script entwickelt, das die aktuellen Strompreise von Octopus Energy Deutschland über die offizielle Kraken GraphQL API abruft und automatisch als Variablen in IP-Symcon speichert.

:high_voltage: Was macht das Script?

  • Authentifizierung via E-Mail & Passwort an der Octopus Kraken API

  • Token-Caching (nur bei Ablauf wird neu authentifiziert – halbiert API-Calls)

  • Erkennt automatisch den aktuell aktiven Tarif (z.B. Octopus Go)

  • Liest den aktuellen Strompreis (Brutto in ct/kWh) aus dem aktiven Zeitfenster

  • Speichert eine Preisvorschau der nächsten 48h als JSON

  • Zeigt aktuellen und nächsten Preisblock als feste Variablen an

  • Legt alle Variablen automatisch unter Dummy-Instanzen an und aktualisiert sie (keine Neuerstellung bei jedem Lauf!)

  • Erstellt automatisch ein zyklisches Event (Standard: alle 30 Minuten)

  • Erstellt ein einzelnes Event für die nächste Tarifänderung (+30s verzögert), das nur aktualisiert wird

  • Ermittelt den günstigsten Slot mit Steuerungsvariable (Boolean)

  • Erstellt zwei Benutzer-Scripte unter „Steuerung“ für eigene Aktionen bei Günstig-START und -ENDE

  • Retry-Logik bei Netzwerkfehlern (1x Wiederholung nach 3s)

  • Konfigurationsvalidierung beim Start

  • SSL-Verifizierung aktiviert

  • Integrierter Debug-Modus (ein/aus per Variable)

  • Script-Laufzeit wird gemessen und gespeichert

  • Fehlerprotokollierung im IP-Symcon Logbuch

:card_index_dividers: Angelegte Objekte (vollautomatisch)

📄 Octopus Energy Script
├── ⏱ Auto-Abfrage alle 30 Min          ← Zyklisches Event (periodisch)
├── 🔄 Nächste Preisänderung             ← Timed Event (wird nur aktualisiert, +30s)
├── 💡 Status                            ← Dummy-Instanz (Icon: Information)
│   ├── Letzte Ausführung
│   ├── Ausgelöst durch                  → „TimerEvent" / „Execute" / ...
│   ├── Token Status                     → ✅ OK (312 ms) / ✅ OK (cached)
│   ├── Token Cache                      → (intern, gespeicherter Token)
│   ├── Token gültig bis                 → „04.05.2025 15:20:00"
│   ├── API Status                       → ✅ OK (458 ms)
│   ├── Letzter Fehler
│   ├── Fehler Timestamp
│   ├── Erfolgreiche Abfragen            → 42 Abfragen
│   └── Script-Laufzeit                  → 823 ms
├── 💡 Tarif                             ← Dummy-Instanz (Icon: Euro)
│   ├── Aktiver Tarif                    → „Octopus Go"
│   ├── Vollständiger Name
│   ├── Gültig ab
│   ├── Gültig bis
│   └── Letzte Abfrage
├── 💡 Aktueller Preis                   ← Dummy-Instanz (Icon: Lightning)
│   ├── Brutto ct/kWh                   → 28.57 ct/kWh
│   ├── Zeitfenster                      → „STANDARD (05:00:00 - 00:00:00)"
│   └── Letzte Abfrage
├── 💡 Preisvorschau                     ← Dummy-Instanz (Icon: Clock)
│   ├── Aktueller Block                  → „STANDARD (04.05.2025 05:00 – 05.05.2025 00:00)"
│   ├── Aktueller Block Preis            → 28.57 ct/kWh
│   ├── Nächster Block                   → „OFF_PEAK (05.05.2025 00:00 – 05.05.2025 05:00)"
│   ├── Nächster Block Preis             → 17.85 ct/kWh
│   ├── Nächste Änderung um              → „05.05.2025 00:00:30 [OFF_PEAK] (+30s)"
│   ├── Forecast JSON                    → „[{…}]"
│   ├── Anzahl Blöcke                    → 4 Blöcke
│   └── Letzte Abfrage
└── 💡 Steuerung                         ← Dummy-Instanz (Icon: Gear)
    ├── Günstigster Preis aktiv          → true/false (Boolean)
    ├── Günstigster Slot Von
    ├── Günstigster Slot Bis
    ├── Günstigster Preis                → 17.85 ct/kWh
    ├── Günstigster Tarif                → „OFF_PEAK"
    ├── Steuerung aktiv ab
    ├── Steuerung aktiv bis
    ├── Steuerung Hinweis                → „" / „ℹ️ Festpreis-Tarif – ..."
    ├── 📄 🟢 Günstig START              ← PHP-Script (Benutzer-Code)
    │   └── ⏱ Günstig START             ← Timed Event (löst Script aus)
    └── 📄 🔴 Günstig ENDE              ← PHP-Script (Benutzer-Code)
        └── ⏱ Günstig ENDE              ← Timed Event (löst Script aus)

:light_bulb: Benutzer-Scripte für eigene Aktionen

Unter der Dummy-Instanz „Steuerung“ werden automatisch zwei PHP-Scripte angelegt:

  • :green_circle: Günstig START – wird ausgeführt wenn der günstigste Slot beginnt (+1 Min)

  • :red_circle: Günstig ENDE – wird ausgeführt wenn der günstigste Slot endet (-1 Min)

Diese Scripte werden nur einmalig erstellt und danach nicht mehr überschrieben. Der Benutzer kann dort eigene Logik einfügen, z.B.:

// Wallbox einschalten
RequestAction(12345, true);

// Variable setzen
SetValue(67890, true);

// Benachrichtigung senden
WFC_PushNotification(98765, "Strom günstig!", "Wallbox lädt...", "", 0);

:information_source: Hinweis bei Festpreis-Tarifen: Die Steuerung wird automatisch deaktiviert und ein Hinweis gesetzt, da alle Zeitslots den gleichen Preis haben.

:white_check_mark: Voraussetzungen

  • IP-Symcon ab Version 6.x

  • Octopus Energy Deutschland Kundenkonto

  • Login-Daten (E-Mail & Passwort)

  • Kundennummer (Format: A-XXXXXXXX, im Kundenportal sichtbar)

:wrench: Einrichtung

  1. Neues PHP-Script in IP-Symcon anlegen

  2. Script-Inhalt einfügen (siehe unten)

  3. Nur den Konfigurationsbereich oben im Script anpassen:

$email             = "deine@email.de";   // Octopus Login E-Mail
$password          = "deinPasswort";     // Octopus Login Passwort
$accountNumber     = "A-XXXXXXXX";       // Kundennummer
$intervalMinutes   = 30;                 // Abfrage-Intervall in Minuten
$tokenCacheMinutes = 50;                 // Token-Cache Dauer (Standard: 50 Min)
$debug             = false;              // Debug ein/aus
  1. Script einmal manuell ausführen – alle Dummy-Instanzen, Variablen, Events und Benutzer-Scripte werden automatisch angelegt

  2. Eigene Aktionen in die Benutzer-Scripte 🟢 Günstig START und 🔴 Günstig ENDE eintragen

:clipboard: Getestete Tarife

Tarif Unterstützt
Octopus Go (Zeitfenster) :white_check_mark:
Festpreis-Tarife :white_check_mark: (Steuerung wird deaktiviert)

:shield: Robustheit & Sicherheit

  • SSL-Verifizierung aktiviert (kein CURLOPT_SSL_VERIFYPEER = false)

  • Retry-Logik: Bei Netzwerkfehler oder HTTP 5xx wird 1x nach 3s wiederholt

  • Token-Caching: Token wird lokal gespeichert und erst bei Ablauf erneuert

  • Bei Auth-Fehlern wird der Token-Cache automatisch invalidiert

  • DateTime-Rückgabewerte werden abgesichert (kein Crash bei ungültigen Daten)

  • Konfigurationsvalidierung: Script bricht mit klarer Meldung ab wenn Zugangsdaten fehlen

  • Mehrere Properties/Zähler werden korrekt durchsucht

:folded_hands: Feedback Ich freue mich über Rückmeldungen, Verbesserungsvorschläge und Erfahrungsberichte – besonders von Nutzern anderer Octopus-Tarife (Relax, Agile etc.). Oder sollte sich jemand finden, der dies weiterentwickeln will bzw. ein Modul daraus baut, mir fehlt hierzu leider die Freizeit neben der Familie und das Wetter wird schöner und schöner :sweat_smile:.

Getestet mit IP-Symcon 6.x und Octopus Go Tarif (Deutschland)

Viele Grüße Stefan


Changelog:

0.0.3b – 04.05.2025

  • Token-Caching (wird zwischengespeichert, nur bei Ablauf erneuert – halbiert API-Calls)

  • Konfigurationsvalidierung beim Start (klare Fehlermeldung bei fehlenden Zugangsdaten)

  • Timezone explizit gesetzt (Europe/Berlin)

  • SSL-Verifizierung aktiviert (Sicherheit)

  • HTTP-Statuscode wird geprüft

  • DateTime-Rückgabewerte abgesichert (kein Crash bei ungültigen Daten)

  • Retry-Logik bei Netzwerkfehlern (1x Wiederholung nach 3s)

  • Sender-Erkennung ($_IPS['SENDER']) wird angezeigt

  • Script-Laufzeit wird gemessen und als Variable gespeichert

  • Festpreis-Tarife: Steuerung wird übersprungen mit Hinweis (alle Slots identisch)

  • Icons für Dummy-Instanzen (Information, Euro, Lightning, Clock, Gear)

  • Robustere Verarbeitung bei mehreren Properties/Malos (kein hardcoded [0])

  • Leere Forecast-Prüfung mit sinnvollen Fallback-Werten

  • Bei Auth-Fehlern wird Token-Cache automatisch invalidiert

  • Umstellung von Kategorien auf Dummy-Instanzen (bessere WebFront-Darstellung & Verlinkung)

  • Zwei Benutzer-Scripte unter „Steuerung“ für eigene Aktionen bei günstigstem Slot (START/ENDE)

  • Events liegen unter den jeweiligen Benutzer-Scripten und lösen diese direkt aus

  • Benutzer-Scripte werden nur einmalig erstellt und bei weiteren Läufen NICHT überschrieben

0.0.2b – 01.05.2025

  • Preisänderungs-Event wird nun 30 Sekunden verzögert ausgelöst

  • Variablen werden nicht mehr gelöscht und neu erstellt – nur noch aktualisiert (stabile Objekt-IDs)

  • Nur noch EIN Preisänderungs-Event statt vieler einzelner – wird nur zeitlich aktualisiert

  • Forecast erweitert auf 48h (statt 24h)

  • Feste Variablen für aktuellen und nächsten Block

  • Kategorie Steuerung mit Boolean-Variable für günstigsten Slot

  • Zusammenfassung der Slots

0.0.1b – 23.04.2025 – Initial Release

<?php
// ╔══════════════════════════════════════════════════════════════════════╗
// ║           OCTOPUS ENERGY – STROMPREIS ABFRAGE                        ║
// ║           für IP-Symcon                                              ║
// ╠══════════════════════════════════════════════════════════════════════╣
// ║  BESCHREIBUNG:                                                       ║
// ║  Dieses Script fragt über die Octopus Energy (Kraken) API            ║
// ║  den aktuellen Strompreis sowie die Preisvorschau für die            ║
// ║  nächsten 48h ab und speichert diese als Variablen in                ║
// ║  IP-Symcon direkt unterhalb dieses Scripts.                          ║
// ║                                                                      ║
// ║  VORAUSSETZUNGEN:                                                    ║
// ║  - Octopus Energy Deutschland Kundenkonto                            ║
// ║  - Kundennummer (Format: A-XXXXXXXX)                                 ║
// ║  - IP-Symcon ab Version 6.x                                          ║
// ║                                                                      ║
// ║  ANGELEGTE OBJEKTE (automatisch):                                    ║
// ║  - Event:          Zyklische Ausführung (Standard: alle 30 Min)      ║
// ║  - Event:          Nächste Preisänderung (einmalig, +30s verzögert)  ║
// ║  - Dummy-Instanz:  Status    – Token/API Status, Fehlermeldungen     ║
// ║  - Dummy-Instanz:  Steuerung – Günstigster Preis, Benutzer-Scripte   ║
// ║  - Dummy-Instanz:  Tarif     – Tarifname, Laufzeit                   ║
// ║  - Dummy-Instanz:  Aktueller Preis – Brutto, Zeitfenster             ║
// ║  - Dummy-Instanz:  Preisvorschau   – JSON mit allen Slots 48h        ║
// ║                                                                      ║
// ║  UNTERSTÜTZTE/GETESTETE TARIFE:                                      ║
// ║  - Octopus Go        (Zeitfenster-Tarif)                             ║
// ║  - Festpreis-Tarife  (SimpleProductUnitRateInformation)              ║
// ║                                                                      ║
// ║  API ENDPUNKT/Dokumentation:                                         ║
// ║  https://api.oeg-kraken.energy/v1/graphql/                           ║
// ║                                                                      ║
// ║  AUTOR:    Kelevra26                                                 ║
// ║  VERSION:  0.0.3b                                                    ║
// ║  DATUM:    04.05.2025                                                ║
// ╠══════════════════════════════════════════════════════════════════════╣
// ║  Changelog:                                                          ║
// ║                                                                      ║
// ║  0.0.3b – 04.05.2025                                                 ║
// ║    Token-Caching (Token wird zwischengespeichert, nur bei Ablauf     ║
// ║      erneuert – halbiert API-Calls)                                  ║
// ║    Konfigurationsvalidierung beim Start                              ║
// ║    Timezone explizit gesetzt (Europe/Berlin)                         ║
// ║    SSL-Verifizierung aktiviert (Sicherheit)                          ║
// ║    HTTP-Statuscode wird geprüft                                      ║
// ║    DateTime-Rückgabewerte abgesichert (kein Crash bei ungültigen     ║
// ║      Daten)                                                          ║
// ║    Retry-Logik bei Netzwerkfehlern (1x Wiederholung nach 3s)         ║
// ║    Sender-Erkennung ($_IPS['SENDER'])                                ║
// ║    Script-Laufzeit wird gemessen und gespeichert                     ║
// ║    Festpreis-Tarife: Steuerung wird übersprungen (alle Slots gleich) ║
// ║    Icons für Dummy-Instanzen                                         ║
// ║    Robustere Verarbeitung bei mehreren Properties/Malos              ║
// ║    Leere Forecast-Prüfung                                            ║
// ║    Umstellung von Kategorien auf Dummy-Instanzen                     ║
// ║    Zwei Benutzer-Scripte unter "Steuerung" (START/ENDE)              ║
// ║    Events unter Benutzer-Scripten lösen diese direkt aus             ║
// ║    Benutzer-Scripte werden nur einmalig erstellt                     ║
// ║                                                                      ║
// ║  0.0.2b – 01.05.2025                                                 ║
// ║    Preisänderungs-Event +30s verzögert                               ║
// ║    Variablen nur aktualisiert (stabile Objekt-IDs)                   ║
// ║    Nur noch EIN Preisänderungs-Event                                 ║
// ║    Forecast 48h, feste Block-Variablen                               ║
// ║    Kategorie Steuerung mit Boolean                                   ║
// ║    Zusammenfassung der Slots                                         ║
// ║                                                                      ║
// ║  0.0.1b – 23.04.2025 – Initial Release                               ║
// ╚══════════════════════════════════════════════════════════════════════╝

// ╔══════════════════════════════════════════════════════════════════════╗
// ║                        KONFIGURATION                                 ║
// ║                  Nur diesen Bereich anpassen!                        ║
// ╠══════════════════════════════════════════════════════════════════════╣
$email             = "meine E-Mail";       // Octopus Login E-Mail
$password          = "mein Passwort";      // Octopus Login Passwort
$accountNumber     = 'meine Kundennummer'; // Kundennummer (A-XXXXXXXX)
$intervalMinutes   = 30;                   // Abfrage-Intervall in Minuten
$tokenCacheMinutes = 50;                   // Token-Cache Dauer in Minuten (Token i.d.R. 60 Min gültig)
$debug             = false;                // Debug-Meldungen: true = AN / false = AUS
// ╚══════════════════════════════════════════════════════════════════════╝

// ── Timezone & Laufzeitmessung ───────────────────────────────────────
date_default_timezone_set('Europe/Berlin');
$scriptStartTime = microtime(true);

// ── Konfigurationsvalidierung ────────────────────────────────────────
if (empty($email) || $email === 'meine E-Mail'
    || empty($password) || $password === 'mein Passwort'
    || empty($accountNumber) || $accountNumber === 'meine Kundennummer') {
    IPS_LogMessage("Octopus", "❌ Konfiguration unvollständig! Bitte E-Mail, Passwort und Kundennummer im Script eintragen.");
    echo "❌ Konfiguration unvollständig! Bitte E-Mail, Passwort und Kundennummer eintragen.";
    return;
}

$url = "https://api.oeg-kraken.energy/v1/graphql/";

$headers_base = [
    'Content-Type: application/json',
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Accept: application/json',
    'Origin: https://api.oeg-kraken.energy',
    'Referer: https://api.oeg-kraken.energy/v1/graphql/'
];

// ── Hilfsfunktionen ──────────────────────────────────────────────────
function log_msg($message, $isError = false) {
    global $debug;
    if ($isError || $debug) {
        $prefix = $isError ? "❌ " : "[DEBUG] ";
        IPS_LogMessage("Octopus", $prefix . $message);
    }
}

function dbg($message, $data = null) {
    global $debug;
    if (!$debug) return;
    $log = "[DEBUG] " . $message;
    if ($data !== null) {
        $log .= " → " . (is_array($data) || is_object($data) ? json_encode($data) : $data);
    }
    IPS_LogMessage("Octopus", $log);
}

/**
 * GraphQL-Request an die Kraken-API mit Retry-Logik.
 * Bei Netzwerkfehler oder HTTP 5xx wird 1x nach 3s wiederholt.
 */
function krakenRequest($url, $query, $variables = [], $token = null, $retryCount = 1) {
    global $headers_base;
    $headers = $headers_base;
    if ($token) $headers[] = 'Authorization: ' . $token;

    dbg("krakenRequest START", ['variables' => $variables]);

    for ($attempt = 0; $attempt <= $retryCount; $attempt++) {
        if ($attempt > 0) {
            dbg("Retry #$attempt nach 3 Sekunden...");
            sleep(3);
        }

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['query' => $query, 'variables' => $variables]));
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_TIMEOUT, 15);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        $response  = curl_exec($ch);
        $httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        $curlErrno = curl_errno($ch);
        curl_close($ch);

        dbg("krakenRequest HTTP Code", $httpCode);

        // cURL-Fehler → Retry
        if ($curlErrno !== 0) {
            log_msg("cURL Fehler (Versuch " . ($attempt + 1) . "): [$curlErrno] $curlError", true);
            if ($attempt < $retryCount) continue;
            return null;
        }

        // HTTP 5xx → Retry
        if ($httpCode >= 500) {
            log_msg("HTTP $httpCode (Versuch " . ($attempt + 1) . "): " . substr($response, 0, 200), true);
            if ($attempt < $retryCount) continue;
            return null;
        }

        // HTTP nicht 200 → kein Retry, Fehler
        if ($httpCode !== 200) {
            log_msg("HTTP Fehler $httpCode: " . substr($response, 0, 300), true);
            return null;
        }

        // Erfolg
        dbg("krakenRequest RAW Response", substr($response, 0, 500));
        return json_decode($response, true);
    }

    return null;
}

/**
 * Erstellt oder findet eine Dummy-Instanz anhand des Namens unter einem Parent.
 */
function getOrCreateDummyInstance($name, $parentId, $icon = '') {
    foreach (IPS_GetChildrenIDs($parentId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 1
            && IPS_GetName($id) == $name) {
            if ($icon !== '') IPS_SetIcon($id, $icon);
            return $id;
        }
    }
    $id = IPS_CreateInstance("{485D0419-BE97-4548-AA9C-C083EB82E61E}");
    IPS_SetName($id, $name);
    IPS_SetParent($id, $parentId);
    if ($icon !== '') IPS_SetIcon($id, $icon);
    return $id;
}

function getOrCreateProfile($name, $type, $suffix, $digits = 2) {
    if (!IPS_VariableProfileExists($name)) {
        IPS_CreateVariableProfile($name, $type);
        IPS_SetVariableProfileText($name, '', ' ' . $suffix);
        IPS_SetVariableProfileDigits($name, $digits);
    }
    return $name;
}

/**
 * Erstellt oder aktualisiert eine Variable anhand des Namens.
 * Variablen werden NICHT gelöscht/neu erstellt, sondern nur ihr Wert aktualisiert.
 */
function setVar($name, $type, $parentId, $value, $profile = '') {
    foreach (IPS_GetChildrenIDs($parentId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 2
            && IPS_GetName($id) == $name) {
            SetValue($id, $value);
            dbg("setVar UPDATE", ['name' => $name, 'value' => $value]);
            return $id;
        }
    }
    $id = IPS_CreateVariable($type);
    IPS_SetName($id, $name);
    IPS_SetParent($id, $parentId);
    if ($profile !== '') {
        IPS_SetVariableCustomProfile($id, $profile);
    }
    SetValue($id, $value);
    dbg("setVar CREATE", ['name' => $name, 'value' => $value, 'type' => $type]);
    return $id;
}

/**
 * Liest den Wert einer Variable anhand des Namens. Gibt Default zurück wenn nicht gefunden.
 */
function getVar($name, $parentId, $default = '') {
    foreach (IPS_GetChildrenIDs($parentId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 2
            && IPS_GetName($id) == $name) {
            return GetValue($id);
        }
    }
    return $default;
}

/**
 * Erstellt ein PHP-Script nur wenn es noch nicht existiert.
 * Bestehende Scripte werden NICHT überschrieben (Benutzer-Code bleibt erhalten).
 */
function getOrCreateScript($name, $parentId, $defaultContent) {
    foreach (IPS_GetChildrenIDs($parentId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 3
            && IPS_GetName($id) == $name) {
            dbg("Script bereits vorhanden", ['name' => $name, 'id' => $id]);
            return $id;
        }
    }
    $id = IPS_CreateScript(0);
    IPS_SetName($id, $name);
    IPS_SetParent($id, $parentId);
    IPS_SetScriptContent($id, $defaultContent);
    dbg("Script erstellt", ['name' => $name, 'id' => $id]);
    return $id;
}

function getOrCreateCyclicEvent($name, $scriptId, $intervalMinutes) {
    foreach (IPS_GetChildrenIDs($scriptId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 4
            && IPS_GetName($id) == $name) {
            IPS_SetEventCyclic($id, 0, 0, 0, 0, 2, $intervalMinutes);
            IPS_SetEventCyclicTimeFrom($id, 0, 0, 0);
            IPS_SetEventCyclicTimeTo($id, 0, 0, 0);
            IPS_SetEventScript($id, '');  // ← FIX: Aktion explizit setzen
            IPS_SetEventActive($id, true);
            dbg("Event aktualisiert", ['name' => $name, 'interval' => $intervalMinutes . ' Min']);
            return $id;
        }
    }
    $id = IPS_CreateEvent(1);
    IPS_SetName($id, $name);
    IPS_SetParent($id, $scriptId);
    IPS_SetEventCyclic($id, 0, 0, 0, 0, 2, $intervalMinutes);
    IPS_SetEventCyclicTimeFrom($id, 0, 0, 0);
    IPS_SetEventCyclicTimeTo($id, 0, 0, 0);
    IPS_SetEventScript($id, '');  // ← FIX: Aktion explizit setzen
    IPS_SetEventActive($id, true);
    dbg("Event erstellt", ['name' => $name, 'interval' => $intervalMinutes . ' Min']);
    return $id;
}

/**
 * Erstellt oder aktualisiert ein zeitgesteuertes Event.
 * Es wird NIEMALS gelöscht und neu erstellt.
 */
function createOrUpdateTimedEvent($name, $parentId, $hour, $minute, $second = 0) {
    foreach (IPS_GetChildrenIDs($parentId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 4
            && IPS_GetName($id) == $name) {
            IPS_SetEventCyclic($id, 0, 0, 0, 0, 0, 0);
            IPS_SetEventCyclicTimeFrom($id, $hour, $minute, $second);
            IPS_SetEventCyclicTimeTo($id, 0, 0, 0);
            IPS_SetEventScript($id, '');  // ← FIX: Aktion explizit setzen
            IPS_SetEventActive($id, true);
            dbg("Timed Event aktualisiert", [
                'name' => $name,
                'time' => sprintf('%02d:%02d:%02d', $hour, $minute, $second)
            ]);
            return $id;
        }
    }
    $id = IPS_CreateEvent(1);
    IPS_SetName($id, $name);
    IPS_SetParent($id, $parentId);
    IPS_SetEventCyclic($id, 0, 0, 0, 0, 0, 0);
    IPS_SetEventCyclicTimeFrom($id, $hour, $minute, $second);
    IPS_SetEventCyclicTimeTo($id, 0, 0, 0);
    IPS_SetEventScript($id, '');  // ← FIX: Aktion explizit setzen
    IPS_SetEventActive($id, true);
    dbg("Timed Event erstellt", [
        'name' => $name,
        'time' => sprintf('%02d:%02d:%02d', $hour, $minute, $second)
    ]);
    return $id;
}

/**
 * Sicheres Parsen eines Datums im Format 'd.m.Y H:i'.
 * Gibt Timestamp zurück oder null bei Fehler.
 */
function safeDateParse($dateString, $format = 'd.m.Y H:i') {
    $dt = DateTime::createFromFormat($format, $dateString);
    if ($dt === false) {
        dbg("safeDateParse FEHLER", ['input' => $dateString, 'format' => $format]);
        return null;
    }
    return $dt->getTimestamp();
}

// ── Sender-Erkennung ─────────────────────────────────────────────────
$sender = $_IPS['SENDER'] ?? 'Unknown';
dbg("Script ausgelöst durch", $sender);

// ── Script-ID & Dummy-Instanzen ──────────────────────────────────────
$scriptId    = $_IPS['SELF'];
$catStatus   = getOrCreateDummyInstance('Status',          $scriptId, 'Information');
$catTariff   = getOrCreateDummyInstance('Tarif',           $scriptId, 'Euro');
$catCurrent  = getOrCreateDummyInstance('Aktueller Preis', $scriptId, 'Lightning');
$catForecast = getOrCreateDummyInstance('Preisvorschau',   $scriptId, 'Clock');
$catControl  = getOrCreateDummyInstance('Steuerung',       $scriptId, 'Gear');

dbg("Dummy-Instanzen angelegt/gefunden", [
    'Status'    => $catStatus,
    'Tarif'     => $catTariff,
    'Aktuell'   => $catCurrent,
    'Forecast'  => $catForecast,
    'Steuerung' => $catControl
]);

// ── Profile anlegen ──────────────────────────────────────────────────
$profileCtKwh = getOrCreateProfile('Octopus.CentPerKwh', 2, 'ct/kWh', 2);
$profileCount = getOrCreateProfile('Octopus.Count',      1, 'Abfragen', 0);
$profileMs    = getOrCreateProfile('Octopus.Millisec',   1, 'ms', 0);

// ── Haupt-Event anlegen (periodisch) ─────────────────────────────────
getOrCreateCyclicEvent('⏱ Auto-Abfrage alle ' . $intervalMinutes . ' Min', $scriptId, $intervalMinutes);

// ── Benutzer-Scripte unter Steuerung anlegen ─────────────────────────
$scriptStartContent = '<?php
// ╔══════════════════════════════════════════════════════════════════════╗
// ║  🟢 GÜNSTIG START – Benutzer-Script                                  ║
// ╠══════════════════════════════════════════════════════════════════════╣
// ║  Dieses Script wird automatisch ausgeführt, wenn der günstigste      ║
// ║  Strompreis-Slot beginnt (+1 Minute nach Slot-Start).                ║
// ║                                                                      ║
// ║  Hier eigene Aktionen einfügen, z.B.:                                ║
// ║  - Wallbox einschalten                                               ║
// ║  - Wärmepumpe aktivieren                                             ║
// ║  - Waschmaschine starten                                             ║
// ║  - Batterie laden                                                    ║
// ╚══════════════════════════════════════════════════════════════════════╝

// Beispiel: Variable setzen
// SetValue(12345, true);

// Beispiel: Aktor schalten
// RequestAction(12345, true);

// Beispiel: Logging
IPS_LogMessage("Octopus Steuerung", "🟢 Günstiger Slot gestartet – Aktionen werden ausgeführt");

// ── Eigene Aktionen hier einfügen ────────────────────────────────────

';

$scriptEndContent = '<?php
// ╔══════════════════════════════════════════════════════════════════════╗
// ║  🔴 GÜNSTIG ENDE – Benutzer-Script                                   ║
// ╠══════════════════════════════════════════════════════════════════════╣
// ║  Dieses Script wird automatisch ausgeführt, wenn der günstigste      ║
// ║  Strompreis-Slot endet (-1 Minute vor Slot-Ende).                    ║
// ║                                                                      ║
// ║  Hier eigene Aktionen einfügen, z.B.:                                ║
// ║  - Wallbox ausschalten                                               ║
// ║  - Wärmepumpe deaktivieren                                           ║
// ║  - Batterie-Ladung stoppen                                           ║
// ║  - Verbraucher abschalten                                            ║
// ╚══════════════════════════════════════════════════════════════════════╝

// Beispiel: Variable setzen
// SetValue(12345, false);

// Beispiel: Aktor schalten
// RequestAction(12345, false);

// Beispiel: Logging
IPS_LogMessage("Octopus Steuerung", "🔴 Günstiger Slot beendet – Aktionen werden zurückgesetzt");

// ── Eigene Aktionen hier einfügen ────────────────────────────────────

';

$userScriptStart = getOrCreateScript('🟢 Günstig START', $catControl, $scriptStartContent);
$userScriptEnd   = getOrCreateScript('🔴 Günstig ENDE',  $catControl, $scriptEndContent);

dbg("Benutzer-Scripte", [
    'start_id' => $userScriptStart,
    'end_id'   => $userScriptEnd
]);

// ── Status initialisieren ────────────────────────────────────────────
setVar('Letzte Ausführung', 3, $catStatus, date('d.m.Y H:i:s'));
setVar('Ausgelöst durch',   3, $catStatus, $sender);
setVar('Token Status',      3, $catStatus, 'Wird abgefragt...');
setVar('API Status',        3, $catStatus, 'Wird abgefragt...');
setVar('Letzter Fehler',    3, $catStatus, '');
setVar('Fehler Timestamp',  3, $catStatus, '');

// Erfolgs-Zähler auslesen
$count = (int)getVar('Erfolgreiche Abfragen', $catStatus, 0);
dbg("Aktueller Zählerstand", $count);

// ── 1. Token holen (mit Caching) ─────────────────────────────────────
dbg("Token-Cache prüfen...");

$cachedToken  = getVar('Token Cache', $catStatus, '');
$cachedExpire = getVar('Token gültig bis', $catStatus, '');
$tokenFromCache = false;

if (!empty($cachedToken) && !empty($cachedExpire)) {
    $expireTs = strtotime($cachedExpire);
    if ($expireTs !== false && $expireTs > time()) {
        $token = $cachedToken;
        $tokenFromCache = true;
        $tokenDauer = 'cached (gültig bis ' . $cachedExpire . ')';
        dbg("Token aus Cache verwendet", ['gültig_bis' => $cachedExpire]);
    }
}

if (!$tokenFromCache) {
    dbg("Token wird neu abgefragt...");
    $tokenStart  = microtime(true);
    $tokenResult = krakenRequest($url,
        'mutation krakenTokenAuthentication($email: String!, $password: String!) {
            obtainKrakenToken(input: {email: $email, password: $password}) { token }
        }',
        ['email' => $email, 'password' => $password]
    );
    $tokenDauer = round((microtime(true) - $tokenStart) * 1000) . ' ms';

    $token = $tokenResult['data']['obtainKrakenToken']['token'] ?? null;

    dbg("Token Ergebnis", [
        'dauer'     => $tokenDauer,
        'erhalten'  => $token ? 'JA' : 'NEIN',
        'raw_error' => $tokenResult['errors'][0]['message'] ?? 'kein Fehler'
    ]);

    if (!$token) {
        $fehler = 'Token-Fehler: ' . ($tokenResult['errors'][0]['message'] ?? json_encode($tokenResult));
        setVar('Token Status',     3, $catStatus, '❌ Fehler');
        setVar('API Status',       3, $catStatus, '⏸ Nicht ausgeführt');
        setVar('Letzter Fehler',   3, $catStatus, $fehler);
        setVar('Fehler Timestamp', 3, $catStatus, date('d.m.Y H:i:s'));
        log_msg($fehler, true);
        return;
    }

    // Token cachen
    $tokenExpireTime = date('d.m.Y H:i:s', time() + ($tokenCacheMinutes * 60));
    setVar('Token Cache',      3, $catStatus, $token);
    setVar('Token gültig bis', 3, $catStatus, $tokenExpireTime);
    dbg("Token gecached", ['gültig_bis' => $tokenExpireTime]);
}

setVar('Token Status', 3, $catStatus, '✅ OK (' . $tokenDauer . ')');

// ── 2. API Abfrage ───────────────────────────────────────────────────
dbg("API wird abgefragt...");
$apiStart = microtime(true);
$result   = krakenRequest($url,
    'query($accountNumber: String!) {
        account(accountNumber: $accountNumber) {
            properties {
                electricityMalos {
                    agreements {
                        validFrom
                        validTo
                        product { displayName fullName }
                        unitRateInformation {
                            ... on TimeOfUseProductUnitRateInformation {
                                rates {
                                    latestGrossUnitRateCentsPerKwh
                                    timeslotName
                                    timeslotActivationRules {
                                        activeFromTime
                                        activeToTime
                                    }
                                }
                            }
                            ... on SimpleProductUnitRateInformation {
                                latestGrossUnitRateCentsPerKwh
                            }
                        }
                    }
                }
            }
        }
    }',
    ['accountNumber' => $accountNumber],
    $token
);
$apiDauer = round((microtime(true) - $apiStart) * 1000) . ' ms';

if (!$result || isset($result['errors'])) {
    $fehler = 'API Fehler: ' . ($result['errors'][0]['message'] ?? json_encode($result));
    setVar('API Status',       3, $catStatus, '❌ Fehler (' . $apiDauer . ')');
    setVar('Letzter Fehler',   3, $catStatus, $fehler);
    setVar('Fehler Timestamp', 3, $catStatus, date('d.m.Y H:i:s'));

    // Bei Auth-Fehler Token-Cache invalidieren
    $errMsg = $result['errors'][0]['message'] ?? '';
    if (stripos($errMsg, 'auth') !== false || stripos($errMsg, 'token') !== false) {
        setVar('Token Cache',      3, $catStatus, '');
        setVar('Token gültig bis', 3, $catStatus, '');
        dbg("Token-Cache invalidiert wegen Auth-Fehler");
    }

    log_msg($fehler, true);
    return;
}

setVar('API Status', 3, $catStatus, '✅ OK (' . $apiDauer . ')');
dbg("API OK", $apiDauer);

// ── 3. Aktives Agreement finden (über alle Properties/Malos) ─────────
$nowTs           = time();
$activeAgreement = null;
$properties      = $result['data']['account']['properties'] ?? [];

dbg("Properties gefunden", count($properties));

foreach ($properties as $propIdx => $property) {
    $malos = $property['electricityMalos'] ?? [];
    foreach ($malos as $maloIdx => $malo) {
        $agreements = $malo['agreements'] ?? [];
        dbg("Property #$propIdx, Malo #$maloIdx", count($agreements) . ' Agreements');

        foreach ($agreements as $idx => $agreement) {
            $from = strtotime($agreement['validFrom']);
            $to   = strtotime($agreement['validTo']);

            if ($from === false || $to === false) continue;

            dbg("Agreement #$idx", [
                'produkt'   => $agreement['product']['displayName'] ?? '?',
                'validFrom' => date('d.m.Y H:i', $from),
                'validTo'   => date('d.m.Y H:i', $to),
                'aktiv'     => ($nowTs >= $from && $nowTs < $to) ? 'JA' : 'NEIN'
            ]);

            if ($nowTs >= $from && $nowTs < $to) {
                $activeAgreement = $agreement;
                break 3;
            }
        }
    }
}

if (!$activeAgreement) {
    $fehler = 'Kein aktives Agreement gefunden';
    setVar('API Status',       3, $catStatus, '⚠️ ' . $fehler);
    setVar('Letzter Fehler',   3, $catStatus, $fehler);
    setVar('Fehler Timestamp', 3, $catStatus, date('d.m.Y H:i:s'));
    log_msg($fehler . ' | Properties: ' . count($properties), true);
    return;
}

dbg("Aktives Agreement", [
    'produkt'  => $activeAgreement['product']['displayName'],
    'fullName' => $activeAgreement['product']['fullName'],
    'von'      => $activeAgreement['validFrom'],
    'bis'      => $activeAgreement['validTo']
]);

// ── 4. Tarifinformationen speichern ──────────────────────────────────
$displayName = $activeAgreement['product']['displayName'] ?? 'unbekannt';
$fullName    = $activeAgreement['product']['fullName']    ?? 'unbekannt';

setVar('Aktiver Tarif',      3, $catTariff, $displayName);
setVar('Vollständiger Name', 3, $catTariff, $fullName);
setVar('Gültig ab',          3, $catTariff, date('d.m.Y H:i', strtotime($activeAgreement['validFrom'])));
setVar('Gültig bis',         3, $catTariff, date('d.m.Y H:i', strtotime($activeAgreement['validTo'])));
setVar('Letzte Abfrage',     3, $catTariff, date('d.m.Y H:i:s'));

dbg("Tarif gespeichert", $displayName);

// ── 5. Aktuellen Preis ermitteln ─────────────────────────────────────
$currentTime  = date('H:i:s');
$currentPrice = null;
$currentSlot  = 'unbekannt';
$unitRateInfo = $activeAgreement['unitRateInformation'];
$isFestpreis  = false;

dbg("unitRateInformation Typ", [
    'hat_rates'     => isset($unitRateInfo['rates']) ? 'JA' : 'NEIN',
    'hat_grossRate' => isset($unitRateInfo['latestGrossUnitRateCentsPerKwh']) ? 'JA' : 'NEIN',
    'uhrzeit'       => $currentTime
]);

// Festpreis
if (isset($unitRateInfo['latestGrossUnitRateCentsPerKwh'])
    && !isset($unitRateInfo['rates'])) {
    $currentPrice = round(floatval($unitRateInfo['latestGrossUnitRateCentsPerKwh']), 2);
    $currentSlot  = 'Festpreis (24/7)';
    $isFestpreis  = true;
    dbg("Festpreis erkannt", ['brutto' => $currentPrice]);
}

// Zeitfenster-Tarif (Go)
if (isset($unitRateInfo['rates'])) {
    dbg("Zeitfenster-Tarif erkannt, Anzahl rates", count($unitRateInfo['rates']));
    foreach ($unitRateInfo['rates'] as $rate) {
        foreach ($rate['timeslotActivationRules'] as $rule) {
            $from = $rule['activeFromTime'];
            $to   = $rule['activeToTime'];
            if ($from <= $to) {
                $active = ($currentTime >= $from && $currentTime < $to);
            } else {
                $active = ($currentTime >= $from || $currentTime < $to);
            }
            if ($active) {
                $currentPrice = round(floatval($rate['latestGrossUnitRateCentsPerKwh']), 2);
                $currentSlot  = $rate['timeslotName'] . ' (' . $from . ' - ' . $to . ')';
                break 2;
            }
        }
    }
}

if ($currentPrice !== null) {
    setVar('Brutto ct/kWh',  2, $catCurrent, $currentPrice, $profileCtKwh);
    setVar('Zeitfenster',    3, $catCurrent, $currentSlot);
    setVar('Letzte Abfrage', 3, $catCurrent, date('d.m.Y H:i:s'));
    dbg("Aktueller Preis gespeichert", ['slot' => $currentSlot, 'brutto' => $currentPrice]);
} else {
    setVar('Brutto ct/kWh',  2, $catCurrent, 0.0, $profileCtKwh);
    setVar('Zeitfenster',    3, $catCurrent, '⚠️ Kein Preisslot gefunden');
    setVar('Letzte Abfrage', 3, $catCurrent, date('d.m.Y H:i:s'));
    log_msg('Kein aktiver Preisslot gefunden | RAW: ' . json_encode($unitRateInfo), true);
}

// ── 6. Forecast 48h (ab 00:00 heute) ────────────────────────────────
$dayStart    = mktime(0, 0, 0, date('n'), date('j'), date('Y'));
$dayEnd      = $dayStart + 172800;
$slotMinutes = 30;
$preisliste  = [];

dbg("Forecast wird berechnet (48h)", [
    'von' => date('d.m.Y H:i', $dayStart),
    'bis' => date('d.m.Y H:i', $dayEnd)
]);

// Festpreis: ein Block für den gesamten Zeitraum
if ($isFestpreis) {
    $preisliste[] = [
        'von'   => date('d.m.Y H:i', $dayStart),
        'bis'   => date('d.m.Y H:i', $dayEnd),
        'name'  => 'Festpreis',
        'gross' => round(floatval($unitRateInfo['latestGrossUnitRateCentsPerKwh']), 2)
    ];
    dbg("Festpreis-Forecast angelegt");
}

// Zeitfenster-Tarif (Go): 30-Min-Slots
if (isset($unitRateInfo['rates'])) {
    $cursor = $dayStart;
    while ($cursor < $dayEnd) {
        $slotTime  = date('H:i:s', $cursor);
        $slotGross = null;
        $slotName  = 'unbekannt';

        foreach ($unitRateInfo['rates'] as $rate) {
            foreach ($rate['timeslotActivationRules'] as $rule) {
                $from = $rule['activeFromTime'];
                $to   = $rule['activeToTime'];
                if ($from <= $to) {
                    $active = ($slotTime >= $from && $slotTime < $to);
                } else {
                    $active = ($slotTime >= $from || $slotTime < $to);
                }
                if ($active) {
                    $slotGross = round(floatval($rate['latestGrossUnitRateCentsPerKwh']), 2);
                    $slotName  = $rate['timeslotName'];
                    break 2;
                }
            }
        }

        if ($slotGross !== null) {
            $preisliste[] = [
                'von'   => date('d.m.Y H:i', $cursor),
                'bis'   => date('d.m.Y H:i', $cursor + ($slotMinutes * 60)),
                'name'  => $slotName,
                'gross' => $slotGross
            ];
        }

        $cursor += $slotMinutes * 60;
    }
    dbg("Zeitfenster-Forecast berechnet", count($preisliste) . ' Slots');
}

// ── Gleiche aufeinanderfolgende Slots zusammenfassen ─────────────────
$merged = [];
foreach ($preisliste as $slot) {
    $last = count($merged) - 1;
    if ($last >= 0
        && $merged[$last]['name']  === $slot['name']
        && $merged[$last]['gross'] === $slot['gross']) {
        $merged[$last]['bis'] = $slot['bis'];
    } else {
        $merged[] = $slot;
    }
}
dbg("Forecast nach Merge", count($merged) . ' Blöcke (vorher: ' . count($preisliste) . ')');

// ── Prüfung: Forecast leer? ─────────────────────────────────────────
if (empty($merged)) {
    setVar('Forecast JSON',         3, $catForecast, '[]');
    setVar('Anzahl Blöcke',         1, $catForecast, 0, $profileCount);
    setVar('Letzte Abfrage',        3, $catForecast, date('d.m.Y H:i:s'));
    setVar('Aktueller Block',       3, $catForecast, '⚠️ Keine Daten');
    setVar('Aktueller Block Preis', 2, $catForecast, 0.0, $profileCtKwh);
    setVar('Nächster Block',        3, $catForecast, '⚠️ Keine Daten');
    setVar('Nächster Block Preis',  2, $catForecast, 0.0, $profileCtKwh);
    setVar('Nächste Änderung um',   3, $catForecast, 'keine Daten');
    log_msg('Forecast leer – keine Preisdaten berechnet', true);
} else {
    // ── Forecast-Variablen speichern ─────────────────────────────────
    setVar('Forecast JSON',  3, $catForecast, json_encode($merged, JSON_PRETTY_PRINT));
    setVar('Anzahl Blöcke',  1, $catForecast, count($merged), $profileCount);
    setVar('Letzte Abfrage', 3, $catForecast, date('d.m.Y H:i:s'));

    // Aktuellen Block finden
    $nextIdx = 0;
    foreach ($merged as $idx => $slot) {
        $slotEndTs = safeDateParse($slot['bis']);
        if ($slotEndTs === null) continue;
        if ($slotEndTs > $nowTs) {
            $nextIdx = $idx;
            break;
        }
    }

    // Aktueller Block
    if (isset($merged[$nextIdx])) {
        setVar('Aktueller Block',       3, $catForecast, $merged[$nextIdx]['name'] . ' (' . $merged[$nextIdx]['von'] . ' – ' . $merged[$nextIdx]['bis'] . ')');
        setVar('Aktueller Block Preis', 2, $catForecast, $merged[$nextIdx]['gross'], $profileCtKwh);
    }

    // Nächster Block
    if (isset($merged[$nextIdx + 1])) {
        setVar('Nächster Block',       3, $catForecast, $merged[$nextIdx + 1]['name'] . ' (' . $merged[$nextIdx + 1]['von'] . ' – ' . $merged[$nextIdx + 1]['bis'] . ')');
        setVar('Nächster Block Preis', 2, $catForecast, $merged[$nextIdx + 1]['gross'], $profileCtKwh);
    } else {
        setVar('Nächster Block',       3, $catForecast, 'kein weiterer Block');
        setVar('Nächster Block Preis', 2, $catForecast, 0.0, $profileCtKwh);
    }

    dbg("Forecast gespeichert", count($merged) . ' Blöcke');
}

// ── 7. EIN Preisänderungs-Event aktualisieren (+30 Sek. verzögert) ───
$nextChangeTs   = null;
$nextChangeName = '';

if (!empty($merged)) {
    foreach ($merged as $slot) {
        $slotStartTs = safeDateParse($slot['von']);
        if ($slotStartTs === null) continue;
        if ($slotStartTs > $nowTs) {
            $nextChangeTs   = $slotStartTs;
            $nextChangeName = $slot['name'];
            break;
        }
    }
}

if ($nextChangeTs !== null) {
    // 30 Sekunden Verzögerung
    $nextChangeTs += 30;

    $evtHour = (int)date('G', $nextChangeTs);
    $evtMin  = (int)date('i', $nextChangeTs);
    $evtSec  = (int)date('s', $nextChangeTs);

    createOrUpdateTimedEvent('🔄 Nächste Preisänderung', $scriptId, $evtHour, $evtMin, $evtSec);

    setVar('Nächste Änderung um', 3, $catForecast,
        date('d.m.Y H:i:s', $nextChangeTs) . ' [' . $nextChangeName . '] (+30s)');

    dbg("Preisänderungs-Event gesetzt", [
        'zeit'  => sprintf('%02d:%02d:%02d', $evtHour, $evtMin, $evtSec),
        'tarif' => $nextChangeName
    ]);
} else {
    // Kein zukünftiger Wechsel → Event deaktivieren
    foreach (IPS_GetChildrenIDs($scriptId) as $id) {
        if (IPS_ObjectExists($id)
            && IPS_GetObject($id)['ObjectType'] == 4
            && IPS_GetName($id) == '🔄 Nächste Preisänderung') {
            IPS_SetEventActive($id, false);
            dbg("Preisänderungs-Event deaktiviert");
            break;
        }
    }
    setVar('Nächste Änderung um', 3, $catForecast, 'keine im Zeitraum');
}

// ── 8. Günstigsten Slot & Steuerung ─────────────────────────────────
// Bei Festpreis: Steuerung überspringen (alle Slots gleich teuer)
if ($isFestpreis) {
    setVar('Günstigster Preis aktiv', 0, $catControl, false);
    setVar('Günstigster Slot Von',    3, $catControl, '-');
    setVar('Günstigster Slot Bis',    3, $catControl, '-');
    setVar('Günstigster Preis',       2, $catControl, $currentPrice ?? 0.0, $profileCtKwh);
    setVar('Günstigster Tarif',       3, $catControl, 'Festpreis (alle Slots identisch)');
    setVar('Steuerung aktiv ab',      3, $catControl, '-');
    setVar('Steuerung aktiv bis',     3, $catControl, '-');
    setVar('Steuerung Hinweis',       3, $catControl, 'ℹ️ Festpreis-Tarif – keine Preisunterschiede, Steuerung deaktiviert');

    // Events deaktivieren
    foreach (IPS_GetChildrenIDs($userScriptStart) as $id) {
        if (IPS_ObjectExists($id) && IPS_GetObject($id)['ObjectType'] == 4) {
            IPS_SetEventActive($id, false);
        }
    }
    foreach (IPS_GetChildrenIDs($userScriptEnd) as $id) {
        if (IPS_ObjectExists($id) && IPS_GetObject($id)['ObjectType'] == 4) {
            IPS_SetEventActive($id, false);
        }
    }

    dbg("Festpreis erkannt – Steuerung übersprungen");

} elseif (!empty($merged)) {
    // Zeitfenster-Tarif: günstigsten Slot suchen
    $cheapestSlot = null;
    $minPrice     = PHP_FLOAT_MAX;

    foreach ($merged as $slot) {
        $slotEndTs = safeDateParse($slot['bis']);
        if ($slotEndTs === null) continue;
        if ($slotEndTs > $nowTs && $slot['gross'] < $minPrice) {
            $minPrice     = $slot['gross'];
            $cheapestSlot = $slot;
        }
    }

    setVar('Steuerung Hinweis', 3, $catControl, '');

    dbg("Günstigster Slot", $cheapestSlot ? [
        'name'  => $cheapestSlot['name'],
        'von'   => $cheapestSlot['von'],
        'bis'   => $cheapestSlot['bis'],
        'gross' => $cheapestSlot['gross']
    ] : 'KEINER');

    if ($cheapestSlot) {
        $slotStartTs = safeDateParse($cheapestSlot['von']);
        $slotEndTs   = safeDateParse($cheapestSlot['bis']);

        if ($slotStartTs !== null && $slotEndTs !== null) {
            $activeFrom = $slotStartTs + 60;
            $activeTo   = $slotEndTs   - 60;

            $isActive = ($nowTs >= $activeFrom && $nowTs < $activeTo);

            $startHour = (int)date('G', $activeFrom);
            $startMin  = (int)date('i', $activeFrom);
            $endHour   = (int)date('G', $activeTo);
            $endMin    = (int)date('i', $activeTo);

            setVar('Günstigster Preis aktiv', 0, $catControl, $isActive);
            setVar('Günstigster Slot Von',    3, $catControl, $cheapestSlot['von']);
            setVar('Günstigster Slot Bis',    3, $catControl, $cheapestSlot['bis']);
            setVar('Günstigster Preis',       2, $catControl, $cheapestSlot['gross'], $profileCtKwh);
            setVar('Günstigster Tarif',       3, $catControl, $cheapestSlot['name']);
            setVar('Steuerung aktiv ab',      3, $catControl, date('d.m.Y H:i', $activeFrom));
            setVar('Steuerung aktiv bis',     3, $catControl, date('d.m.Y H:i', $activeTo));

            // Events unter die Benutzer-Scripte legen
            createOrUpdateTimedEvent('⏱ Günstig START', $userScriptStart, $startHour, $startMin);
            createOrUpdateTimedEvent('⏱ Günstig ENDE',  $userScriptEnd,   $endHour,   $endMin);

            dbg("Steuerung gesetzt", [
                'aktiv'     => $isActive ? 'JA' : 'NEIN',
                'start_evt' => sprintf('%02d:%02d', $startHour, $startMin),
                'end_evt'   => sprintf('%02d:%02d', $endHour, $endMin)
            ]);
        } else {
            log_msg('Datumsformat-Fehler im günstigsten Slot', true);
        }
    } else {
        setVar('Günstigster Preis aktiv', 0, $catControl, false);
        setVar('Günstigster Slot Von',    3, $catControl, '-');
        setVar('Günstigster Slot Bis',    3, $catControl, '-');
        setVar('Günstigster Preis',       2, $catControl, 0.0, $profileCtKwh);
        setVar('Günstigster Tarif',       3, $catControl, '-');
        setVar('Steuerung aktiv ab',      3, $catControl, '-');
        setVar('Steuerung aktiv bis',     3, $catControl, '-');

        // Events deaktivieren
        foreach (IPS_GetChildrenIDs($userScriptStart) as $id) {
            if (IPS_ObjectExists($id) && IPS_GetObject($id)['ObjectType'] == 4) {
                IPS_SetEventActive($id, false);
            }
        }
        foreach (IPS_GetChildrenIDs($userScriptEnd) as $id) {
            if (IPS_ObjectExists($id) && IPS_GetObject($id)['ObjectType'] == 4) {
                IPS_SetEventActive($id, false);
            }
        }

        log_msg('Kein günstigster Slot gefunden – Steuerung deaktiviert', true);
    }
} else {
    // Merged leer
    setVar('Günstigster Preis aktiv', 0, $catControl, false);
    setVar('Günstigster Slot Von',    3, $catControl, '-');
    setVar('Günstigster Slot Bis',    3, $catControl, '-');
    setVar('Günstigster Preis',       2, $catControl, 0.0, $profileCtKwh);
    setVar('Günstigster Tarif',       3, $catControl, '-');
    setVar('Steuerung aktiv ab',      3, $catControl, '-');
    setVar('Steuerung aktiv bis',     3, $catControl, '-');
    setVar('Steuerung Hinweis',       3, $catControl, '⚠️ Keine Forecast-Daten');
}

// ── 9. Status abschließen ────────────────────────────────────────────
$count++;
$scriptDauer = round((microtime(true) - $scriptStartTime) * 1000);

setVar('Erfolgreiche Abfragen', 1, $catStatus, $count, $profileCount);
setVar('Script-Laufzeit',       1, $catStatus, $scriptDauer, $profileMs);
setVar('Letzter Fehler',        3, $catStatus, '');
setVar('Letzte Ausführung',     3, $catStatus, date('d.m.Y H:i:s'));

dbg("✅ Fertig", [
    'abfrage_nr'       => $count,
    'laufzeit_ms'      => $scriptDauer,
    'token'            => $tokenFromCache ? 'cached' : 'neu',
    'api_dauer'        => $apiDauer,
    'forecast_bloecke' => count($merged),
    'sender'           => $sender
]);
1 „Gefällt mir“