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