feat: Brücke von evcc zum Energie-Cockpit

This commit is contained in:
2026-08-28 17:08:48 +02:00
commit 90c2cb7fb6
7 changed files with 344 additions and 0 deletions
+22
View File
@@ -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=
+3
View File
@@ -0,0 +1,3 @@
.env
__pycache__/
*.pyc
+11
View File
@@ -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"]
+21
View File
@@ -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.
+95
View File
@@ -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
<https://energie.schnee.co.at/module/api>. 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, <https://schnee.co.at>
+176
View File
@@ -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())
+16
View File
@@ -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