feat: Brücke von evcc zum Energie-Cockpit
This commit is contained in:
@@ -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=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
+11
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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())
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user