177 lines
5.5 KiB
Python
177 lines
5.5 KiB
Python
"""Brücke zwischen einer lokalen evcc-Installation und dem Energie-Cockpit.
|
|
|
|
Liest in festem Takt die Ladevorgänge aus der REST-Schnittstelle von evcc
|
|
(`GET /api/sessions`) und schickt sie an das Energie-Cockpit. Damit erscheinen
|
|
die Ladungen dort mit ihrem Sonnenanteil, so wie es bei einer Fronius-Wallbox
|
|
über deren Cloud-Freigabe der Fall ist.
|
|
|
|
Die Brücke baut die Verbindung selbst nach außen auf. Am Router muss nichts
|
|
geöffnet werden, und die evcc-Oberfläche bleibt im Heimnetz.
|
|
|
|
Es wird nichts zwischengespeichert: Bei jedem Lauf geht der gesamte Rückblick
|
|
erneut hinaus. Das Cockpit erkennt bereits bekannte Ladungen an ihrer Kennung
|
|
und aktualisiert sie, statt sie doppelt anzulegen.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import requests
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s %(levelname)-7s %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S',
|
|
)
|
|
logger = logging.getLogger('bruecke')
|
|
|
|
|
|
class Konfigurationsfehler(Exception):
|
|
"""Eine Angabe in der .env fehlt oder ist unbrauchbar."""
|
|
|
|
|
|
def _pflicht(name):
|
|
wert = os.environ.get(name, '').strip()
|
|
if not wert:
|
|
raise Konfigurationsfehler(
|
|
f'{name} ist nicht gesetzt. Bitte in der Datei .env eintragen.'
|
|
)
|
|
return wert
|
|
|
|
|
|
def _zahl(name, vorgabe):
|
|
roh = os.environ.get(name, '').strip()
|
|
if not roh:
|
|
return vorgabe
|
|
try:
|
|
return int(roh)
|
|
except ValueError:
|
|
raise Konfigurationsfehler(f'{name} muss eine ganze Zahl sein, gelesen wurde "{roh}".')
|
|
|
|
|
|
def konfiguration_lesen():
|
|
"""Sammelt alle Einstellungen aus der Umgebung und prüft sie."""
|
|
return {
|
|
'evcc_url': os.environ.get('EVCC_URL', 'http://evcc:7070').rstrip('/'),
|
|
'ziel_url': _pflicht('ZIEL_URL').rstrip('/'),
|
|
'token': _pflicht('TOKEN'),
|
|
'takt_minuten': _zahl('TAKT_MINUTEN', 60),
|
|
'rueckblick_tage': _zahl('RUECKBLICK_TAGE', 30),
|
|
'ladepunkt': os.environ.get('LADEPUNKT', '').strip(),
|
|
}
|
|
|
|
|
|
def sessions_holen(evcc_url):
|
|
"""Holt alle Ladevorgänge aus evcc. Wirft bei Fehlern eine Ausnahme."""
|
|
antwort = requests.get(f'{evcc_url}/api/sessions', timeout=30)
|
|
antwort.raise_for_status()
|
|
inhalt = antwort.json()
|
|
|
|
# evcc antwortet je nach Version entweder mit der blossen Liste
|
|
# oder mit einer Hülle {"result": [...]}.
|
|
if isinstance(inhalt, dict):
|
|
inhalt = inhalt.get('result', [])
|
|
if not isinstance(inhalt, list):
|
|
raise ValueError('Unerwartete Antwort von evcc, erwartet wurde eine Liste von Ladungen.')
|
|
return inhalt
|
|
|
|
|
|
def _zeitpunkt(roh):
|
|
"""Wandelt einen RFC3339-Zeitstempel von evcc in ein datetime um."""
|
|
if not roh:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(str(roh).replace('Z', '+00:00'))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def sessions_auswaehlen(sessions, rueckblick_tage, ladepunkt):
|
|
"""Behält abgeschlossene Ladungen aus dem Rückblickfenster."""
|
|
grenze = datetime.now(timezone.utc) - timedelta(days=rueckblick_tage)
|
|
ausgewaehlt = []
|
|
|
|
for session in sessions:
|
|
ende = _zeitpunkt(session.get('finished'))
|
|
if ende is None:
|
|
continue # läuft noch oder ohne Ende, kommt beim nächsten Lauf mit
|
|
if ende < grenze:
|
|
continue
|
|
if ladepunkt and session.get('loadpoint') != ladepunkt:
|
|
continue
|
|
ausgewaehlt.append(session)
|
|
|
|
return ausgewaehlt
|
|
|
|
|
|
def senden(konfiguration, sessions):
|
|
"""Schickt die Ladungen an das Energie-Cockpit."""
|
|
nutzlast = {
|
|
'quelle': 'evcc',
|
|
'gesendet': datetime.now(timezone.utc).isoformat(),
|
|
'sessions': sessions,
|
|
}
|
|
antwort = requests.post(
|
|
konfiguration['ziel_url'],
|
|
json=nutzlast,
|
|
headers={'X-API-Key': konfiguration['token']},
|
|
timeout=60,
|
|
)
|
|
antwort.raise_for_status()
|
|
try:
|
|
return antwort.json()
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def durchlauf(konfiguration):
|
|
"""Ein vollständiger Durchlauf: holen, auswählen, senden."""
|
|
sessions = sessions_holen(konfiguration['evcc_url'])
|
|
ausgewaehlt = sessions_auswaehlen(
|
|
sessions, konfiguration['rueckblick_tage'], konfiguration['ladepunkt']
|
|
)
|
|
|
|
if not ausgewaehlt:
|
|
logger.info('Keine abgeschlossenen Ladungen im Rückblick, nichts zu senden.')
|
|
return
|
|
|
|
ergebnis = senden(konfiguration, ausgewaehlt)
|
|
uebernommen = ergebnis.get('uebernommen')
|
|
if uebernommen is None:
|
|
logger.info('%d Ladungen gesendet.', len(ausgewaehlt))
|
|
else:
|
|
logger.info('%d Ladungen gesendet, %s davon übernommen.',
|
|
len(ausgewaehlt), uebernommen)
|
|
|
|
|
|
def main():
|
|
try:
|
|
konfiguration = konfiguration_lesen()
|
|
except Konfigurationsfehler as fehler:
|
|
logger.error('%s', fehler)
|
|
return 1
|
|
|
|
logger.info('Brücke gestartet. evcc: %s, Ziel: %s, Takt: %d Minuten.',
|
|
konfiguration['evcc_url'], konfiguration['ziel_url'],
|
|
konfiguration['takt_minuten'])
|
|
|
|
while True:
|
|
try:
|
|
durchlauf(konfiguration)
|
|
except requests.exceptions.RequestException as fehler:
|
|
logger.error('Verbindungsfehler, es wird im nächsten Takt erneut versucht: %s', fehler)
|
|
except ValueError as fehler:
|
|
logger.error('Antwort nicht verwertbar: %s', fehler)
|
|
except Exception:
|
|
logger.exception('Unerwarteter Fehler im Durchlauf.')
|
|
|
|
time.sleep(konfiguration['takt_minuten'] * 60)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|