commit 90c2cb7fb6f31485b8028e4417b4cbd4a6e8c4d3 Author: Martin Schneeweiss Date: Fri Aug 28 17:08:48 2026 +0200 feat: Brücke von evcc zum Energie-Cockpit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d4847f8 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Adresse der laufenden evcc-Installation. +# Läuft evcc auf demselben Rechner außerhalb von Docker, passt meist +# http://host.docker.internal:7070 oder die feste IP des Rechners. +EVCC_URL=http://evcc:7070 + +# Empfangsadresse im Energie-Cockpit. +ZIEL_URL=https://energie.schnee.co.at/api/v1/ladequelle/sessions + +# Persönlicher Zugangsschlüssel. Im Cockpit unter dem Modul "Schnittstelle" +# selbst anlegen (https://energie.schnee.co.at/module/api). Ohne ihn werden +# die Daten abgewiesen. +TOKEN= + +# Wie oft gesendet wird, in Minuten. +TAKT_MINUTEN=60 + +# Wie weit zurück Ladungen bei jedem Lauf mitgeschickt werden, in Tagen. +RUECKBLICK_TAGE=30 + +# Nur ein bestimmter Ladepunkt, so wie er in evcc heißt. +# Leer lassen, wenn alle Ladepunkte gemeldet werden sollen. +LADEPUNKT= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cff5543 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81be689 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-alpine + +RUN pip install --no-cache-dir requests==2.32.3 + +WORKDIR /app +COPY bruecke.py /app/bruecke.py + +RUN adduser -D -u 1000 bruecke +USER bruecke + +CMD ["python", "-u", "/app/bruecke.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..27a0c67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Martin Schneeweiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba4dbe3 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# evcc-Brücke zum Energie-Cockpit + +Meldet die Ladevorgänge einer mit [evcc](https://evcc.io) gesteuerten Wallbox an +das Energie-Cockpit. Dort erscheinen sie danach mit ihrem Sonnenanteil, genauso +wie die Ladungen einer Fronius-Wallbox über deren Cloud-Freigabe. + +Gedacht für alle Wallboxen, die evcc steuern kann, also auch für die +Webasto Unite. Die Brücke selbst spricht nie mit der Wallbox, sondern nur mit +evcc. + +## Wie es funktioniert + +Die Brücke läuft als kleiner Container neben evcc. In festem Takt liest sie die +abgeschlossenen Ladungen aus der Schnittstelle von evcc und schickt sie an das +Cockpit. + +Die Verbindung wird immer von innen nach außen aufgebaut. Am Router muss nichts +geöffnet werden, und die evcc-Oberfläche bleibt im Heimnetz. + +## Voraussetzungen + +- eine laufende evcc-Installation, erreichbar im eigenen Netz +- Docker mit dem Compose-Zusatz +- ein Konto im Energie-Cockpit mit Zugriff auf das Modul "Schnittstelle" + +Den Zugangsschlüssel legt man sich dort selbst an, unter +. Er wird nur einmal im Klartext +angezeigt und lässt sich jederzeit widerrufen. + +## Einrichten + +```bash +git clone https://git.schnee.co.at/SchneeMart/evcc-bruecke.git +cd evcc-bruecke +cp .env.example .env +``` + +Danach die Datei `.env` öffnen und ausfüllen. Pflicht ist der `TOKEN`, also der +eben angelegte Schlüssel. Bei `EVCC_URL` steht, wo evcc erreichbar ist. Die +`ZIEL_URL` ist bereits eingetragen. + +Starten: + +```bash +docker compose up -d --build +``` + +Nachsehen, ob es läuft: + +```bash +docker compose logs -f +``` + +Die erste Meldung nennt die verwendeten Adressen, danach folgt nach jedem +Durchlauf die Zahl der gesendeten Ladungen. + +## Einstellungen + +| Name | Bedeutung | Vorgabe | +|---|---|---| +| `EVCC_URL` | Adresse der evcc-Installation | `http://evcc:7070` | +| `ZIEL_URL` | Empfangsadresse im Cockpit | Pflichtfeld | +| `TOKEN` | persönlicher Zugangsschlüssel | Pflichtfeld | +| `TAKT_MINUTEN` | Abstand zwischen zwei Läufen | `60` | +| `RUECKBLICK_TAGE` | wie weit zurück jedes Mal gemeldet wird | `30` | +| `LADEPUNKT` | nur dieser Ladepunkt, leer heißt alle | leer | + +Der Rückblick wird bei jedem Lauf vollständig gesendet. Das Cockpit erkennt +bekannte Ladungen an ihrer Kennung und legt sie nicht doppelt an. Dadurch +braucht die Brücke keinen eigenen Speicher, und ein Ausfall von ein paar Tagen +holt sich von selbst wieder ein. + +## Wenn evcc nicht gefunden wird + +Läuft evcc in einem eigenen Compose-Verbund, gehören beide ins selbe Netz. +Dazu in der `docker-compose.yml` den Abschnitt `networks` einkommentieren und +den Netznamen eintragen (`docker network ls` zeigt ihn an). + +Läuft evcc ohne Docker direkt auf dem Rechner, ist die feste IP dieses Rechners +in `EVCC_URL` der einfachste Weg. + +## Was übertragen wird + +Ausschließlich die Ladevorgänge, so wie evcc sie führt: Beginn, Ende, +Ladepunkt, Fahrzeugname, geladene Energie, Zählerstände und der Sonnenanteil. +Keine Zugangsdaten, keine Standortdaten, keine Daten anderer Geräte im Haus. + +Gesendet wird an die Adresse, die in der eigenen `.env` steht, und an keine +andere. + +## Lizenz und Urheber + +MIT, siehe [LICENSE](LICENSE). + +Martin Schneeweiss, diff --git a/bruecke.py b/bruecke.py new file mode 100644 index 0000000..6e868e3 --- /dev/null +++ b/bruecke.py @@ -0,0 +1,176 @@ +"""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()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e42e345 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + evcc-bruecke: + build: . + image: evcc-bruecke:1 + container_name: evcc-bruecke + restart: unless-stopped + env_file: + - .env + # Liegt evcc in einem anderen Compose-Verbund, wird dessen Netz hier + # eingehängt. Alternativ in der .env die IP des evcc-Rechners eintragen. + # networks: + # - evcc_default + +# networks: +# evcc_default: +# external: true