Initial commit: Auto-Charger Logging Web-App
MQTT-Collector (Python/paho-mqtt) speichert abgeschlossene go-e Wallbox-Ladungen in SQLite; Next.js-Web-App zeigt sie als Tabelle mit Energie-Summen (Monat/Jahr/gesamt) im werte-next-Design. Zwei Docker-Container mit gemeinsamem DB-Volume. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY schema.sql db.py collector.py ./
|
||||
|
||||
CMD ["python", "collector.py"]
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MQTT-Collector: liest go-e Wallbox-Daten und speichert abgeschlossene
|
||||
Ladungen als je eine Zeile in SQLite.
|
||||
|
||||
Session-Erkennung über den 'car'-State:
|
||||
1=Idle, 2=Charging, 3=WaitCar, 4=Complete.
|
||||
Eine Session startet beim Wechsel nach 2 (Charging) und wird beim Wechsel
|
||||
nach 1 (Idle) oder 4 (Complete) abgeschlossen. Dauer kommt aus 'cdi', die
|
||||
geladene Energie aus 'wh'.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import db
|
||||
|
||||
load_dotenv()
|
||||
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST", "localhost")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
MQTT_USER = os.environ.get("MQTT_USER") or None
|
||||
MQTT_PASS = os.environ.get("MQTT_PASS") or None
|
||||
TOPIC_BASE = os.environ.get("MQTT_TOPIC_BASE", "go-eCharger/310014").rstrip("/")
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[{datetime.now().isoformat(timespec='seconds')}] {msg}", flush=True)
|
||||
|
||||
|
||||
class Session:
|
||||
"""In-Memory-Zustand der aktuell laufenden Ladung."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.active = False
|
||||
self.start_time = None
|
||||
self.energy_wh = 0.0 # letzter wh-Wert
|
||||
self.duration_ms = 0 # letzter cdi.value
|
||||
|
||||
def start(self) -> None:
|
||||
self.active = True
|
||||
self.start_time = datetime.now().isoformat(timespec="seconds")
|
||||
self.energy_wh = 0.0
|
||||
self.duration_ms = 0
|
||||
log(f"Ladung gestartet um {self.start_time}")
|
||||
|
||||
def finish(self) -> None:
|
||||
if not self.active:
|
||||
return
|
||||
end_time = datetime.now().isoformat(timespec="seconds")
|
||||
duration_s = int(self.duration_ms / 1000)
|
||||
if self.energy_wh > 0:
|
||||
db.insert_charge(self.start_time, end_time, duration_s, self.energy_wh)
|
||||
log(f"Ladung gespeichert: {self.energy_wh:.0f} Wh, {duration_s} s")
|
||||
else:
|
||||
log("Ladung ohne Energie (>0 Wh) verworfen")
|
||||
self.active = False
|
||||
|
||||
|
||||
session = Session()
|
||||
|
||||
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
log(f"Verbunden mit MQTT (rc={reason_code})")
|
||||
for key in ("car", "cdi", "wh"):
|
||||
topic = f"{TOPIC_BASE}/{key}"
|
||||
client.subscribe(topic)
|
||||
log(f"abonniert: {topic}")
|
||||
|
||||
|
||||
def on_disconnect(client, userdata, flags, reason_code=None, properties=None):
|
||||
log(f"Verbindung getrennt (rc={reason_code})")
|
||||
|
||||
|
||||
def on_message(client, userdata, msg):
|
||||
payload = msg.payload.decode("utf-8", errors="replace").strip()
|
||||
key = msg.topic.rsplit("/", 1)[-1]
|
||||
|
||||
if key == "wh":
|
||||
try:
|
||||
session.energy_wh = float(payload)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif key == "cdi":
|
||||
# {"type":0|1,"value":<ms>} oder null
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except (ValueError, TypeError):
|
||||
data = None
|
||||
if isinstance(data, dict) and data.get("type") == 1:
|
||||
session.duration_ms = int(data.get("value") or 0)
|
||||
|
||||
elif key == "car":
|
||||
try:
|
||||
car = int(payload)
|
||||
except ValueError:
|
||||
return
|
||||
if car == 2 and not session.active:
|
||||
session.start()
|
||||
elif car in (1, 4) and session.active:
|
||||
session.finish()
|
||||
# car == 3 (WaitCar/Pause): Session bleibt aktiv
|
||||
|
||||
|
||||
def main() -> None:
|
||||
db.init_db()
|
||||
log(f"DB bereit: {db.get_db_path()}")
|
||||
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id="auto-charger-collector",
|
||||
)
|
||||
if MQTT_USER:
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||
client.on_connect = on_connect
|
||||
client.on_disconnect = on_disconnect
|
||||
client.on_message = on_message
|
||||
|
||||
log(f"Verbinde zu {MQTT_HOST}:{MQTT_PORT} ...")
|
||||
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
|
||||
client.loop_forever(retry_first_connection=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""SQLite-Helfer für den Collector (Schema-Init + Schreibzugriffe)."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
|
||||
|
||||
|
||||
def get_db_path() -> str:
|
||||
return os.environ.get("DB_PATH", "/data/charges.db")
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
path = get_db_path()
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Legt Schema idempotent an und setzt install_date beim ersten Start."""
|
||||
schema = SCHEMA_PATH.read_text(encoding="utf-8")
|
||||
conn = connect()
|
||||
try:
|
||||
conn.executescript(schema)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO meta (key, value) VALUES ('install_date', ?)",
|
||||
(datetime.now().isoformat(timespec="seconds"),),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def insert_charge(start_time: str, end_time: str, duration_s: int, energy_wh: float) -> None:
|
||||
conn = connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO charges (start_time, end_time, duration_s, energy_wh, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(start_time, end_time, duration_s, energy_wh,
|
||||
datetime.now().isoformat(timespec="seconds")),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,2 @@
|
||||
paho-mqtt==2.1.0
|
||||
python-dotenv==1.0.1
|
||||
@@ -0,0 +1,17 @@
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS charges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start_time TEXT NOT NULL, -- ISO 8601, lokale Zeit (Europe/Berlin)
|
||||
end_time TEXT NOT NULL,
|
||||
duration_s INTEGER NOT NULL, -- aus cdi.value (ms/1000)
|
||||
energy_wh REAL NOT NULL, -- aus wh
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_charges_start ON charges(start_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
Reference in New Issue
Block a user