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,15 @@
|
|||||||
|
# MQTT-Broker der go-e Wallbox
|
||||||
|
MQTT_HOST=nuccy
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER=
|
||||||
|
MQTT_PASS=
|
||||||
|
MQTT_TOPIC_BASE=go-eCharger/310014
|
||||||
|
|
||||||
|
# Gemeinsame SQLite-Datenbank (Volume-Mount)
|
||||||
|
DB_PATH=/data/charges.db
|
||||||
|
|
||||||
|
# Zeitzone (für Kalendermonat/-jahr der Summen)
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
# Host-Port, unter dem die Web-App erreichbar ist
|
||||||
|
WEB_PORT=8082
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# secrets & data
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# node / next
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Auto-Charger Log
|
||||||
|
|
||||||
|
Kleine Web-App, die die Ladevorgänge des Autos protokolliert. Die Daten kommen
|
||||||
|
per MQTT von der **go-e Wallbox** (Device-ID `310014`), werden in einer
|
||||||
|
**SQLite**-Datenbank gespeichert und als Tabelle mit Energie-Summen angezeigt.
|
||||||
|
Läuft als zwei Docker-Container auf einem lokalen Server.
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
| Container | Stack | Aufgabe |
|
||||||
|
|-------------|--------------------------------|----------------------------------------------------|
|
||||||
|
| `collector` | Python + `paho-mqtt` | MQTT abonnieren, abgeschlossene Ladungen → SQLite |
|
||||||
|
| `web` | Next.js 16 / React 19 / Tailwind 4 | SQLite (read-only) lesen, Tabelle + Summen anzeigen |
|
||||||
|
|
||||||
|
Beide teilen sich das Volume `./data` mit der Datei `charges.db`. Der Collector
|
||||||
|
besitzt alle Schreibzugriffe, die Web-App liest nur.
|
||||||
|
|
||||||
|
## MQTT-Daten (go-e API v2)
|
||||||
|
|
||||||
|
Basis-Topic `go-eCharger/310014/<key>`:
|
||||||
|
|
||||||
|
- `car` — Fahrzeug-Status (`1`=Idle, `2`=Charging, `3`=WaitCar, `4`=Complete).
|
||||||
|
Steuert die Session-Erkennung: Start bei Wechsel nach `2`, Abschluss bei
|
||||||
|
Wechsel nach `1` oder `4`.
|
||||||
|
- `wh` — geladene Energie der aktuellen Ladung in Wh.
|
||||||
|
- `cdi` — Objekt `{"type":1,"value":<ms>}` mit der Ladedauer in Millisekunden.
|
||||||
|
|
||||||
|
## Einrichtung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# MQTT_USER / MQTT_PASS in .env eintragen
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Web-App danach unter `http://<server>:8082/` (Port via `WEB_PORT` in `.env`).
|
||||||
|
|
||||||
|
## Konfiguration (`.env`)
|
||||||
|
|
||||||
|
| Variable | Bedeutung | Default |
|
||||||
|
|-------------------|--------------------------------------------|----------------------|
|
||||||
|
| `MQTT_HOST` | MQTT-Broker | `nuccy` |
|
||||||
|
| `MQTT_PORT` | MQTT-Port | `1883` |
|
||||||
|
| `MQTT_USER/PASS` | MQTT-Credentials | – |
|
||||||
|
| `MQTT_TOPIC_BASE` | Basis-Topic der Wallbox | `go-eCharger/310014` |
|
||||||
|
| `DB_PATH` | Pfad der SQLite-DB im Container | `/data/charges.db` |
|
||||||
|
| `TZ` | Zeitzone (für Monats-/Jahres-Summen) | `Europe/Berlin` |
|
||||||
|
| `WEB_PORT` | Host-Port der Web-App | `8082` |
|
||||||
|
|
||||||
|
## Test ohne Auto
|
||||||
|
|
||||||
|
Eine Ladung lässt sich per `mosquitto_pub` simulieren (Reihenfolge beachten):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosquitto_pub -h nuccy -t go-eCharger/310014/car -m 2
|
||||||
|
mosquitto_pub -h nuccy -t go-eCharger/310014/wh -m 7350
|
||||||
|
mosquitto_pub -h nuccy -t go-eCharger/310014/cdi -m '{"type":1,"value":3600000}'
|
||||||
|
mosquitto_pub -h nuccy -t go-eCharger/310014/car -m 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartung: eine neue Zeile (Dauer 1:00, Energie 7,35 kWh); die Summen
|
||||||
|
aktualisieren sich beim Neuladen der Seite.
|
||||||
|
|
||||||
|
## Bekannte Einschränkung
|
||||||
|
|
||||||
|
Der Collector hält den Session-Zustand nur im Speicher. Startet er mitten in
|
||||||
|
einer laufenden Ladung, wird genau diese eine Ladung ggf. nicht (vollständig)
|
||||||
|
erfasst.
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Auto-Charger Log — lokaler Server (ohne Traefik, nur Port-Mapping)
|
||||||
|
services:
|
||||||
|
|
||||||
|
collector:
|
||||||
|
build: ./collector
|
||||||
|
container_name: auto-charger-collector
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./web
|
||||||
|
args:
|
||||||
|
BUILD_DATE: ${BUILD_DATE:-local}
|
||||||
|
container_name: auto-charger-web
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DB_PATH=/data/charges.db
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8082}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
depends_on:
|
||||||
|
- collector
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# editor
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Multi-stage build für die Next.js-App (standalone output)
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# Dependencies (better-sqlite3 ist ein natives Modul -> Build-Tools nötig)
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Build
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ENV NEXT_PUBLIC_BUILD_DATE=${BUILD_DATE}
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runner
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* stylelint-disable at-rule-no-unknown */
|
||||||
|
@import "tailwindcss";
|
||||||
|
@source "../components/**/*.tsx";
|
||||||
|
@source "../app/**/*.tsx";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Auto-Charger Log",
|
||||||
|
description: "Protokoll der Auto-Ladevorgänge (go-e Wallbox)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import AppLayout from "@/components/AppLayout";
|
||||||
|
import ChargesTable from "@/components/ChargesTable";
|
||||||
|
import Totals from "@/components/Totals";
|
||||||
|
import { getCharges, getTotals } from "@/lib/db";
|
||||||
|
|
||||||
|
// Immer frisch aus der DB lesen (kein Caching).
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const charges = getCharges();
|
||||||
|
const totals = getTotals();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Ladungen</h2>
|
||||||
|
<ChargesTable charges={charges} />
|
||||||
|
<Totals totals={totals} />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import packageJson from "@/package.json";
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const version = packageJson.version;
|
||||||
|
const buildDate =
|
||||||
|
process.env.NEXT_PUBLIC_BUILD_DATE ||
|
||||||
|
new Date().toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen py-8 px-4">
|
||||||
|
<div className="max-w-316 mx-auto border-2 border-black rounded-xl bg-gray-200 p-6">
|
||||||
|
<h1 className="text-4xl font-bold text-center mb-6 tracking-tight">
|
||||||
|
Auto-Charger Log
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<main className="border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4">
|
||||||
|
<a href="mailto:rxf@gmx.de" className="hover:underline">
|
||||||
|
mailto:rxf@gmx.de
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
Version {version} - {buildDate}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Charge } from "@/lib/db";
|
||||||
|
|
||||||
|
const WEEKDAYS = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||||
|
|
||||||
|
function parts(iso: string) {
|
||||||
|
// erwartet "YYYY-MM-DDTHH:MM:SS" (lokale Zeit vom Collector)
|
||||||
|
const [date = "", time = ""] = iso.split("T");
|
||||||
|
const [y, m, d] = date.split("-");
|
||||||
|
return { y, m, d, hm: time.slice(0, 5) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const { y, m, d } = parts(iso);
|
||||||
|
return y && m && d ? `${d}.${m}.${y}` : iso;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekday(iso: string): string {
|
||||||
|
const { y, m, d } = parts(iso);
|
||||||
|
if (!y || !m || !d) return "-";
|
||||||
|
return WEEKDAYS[new Date(Number(y), Number(m) - 1, Number(d)).getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return parts(iso).hm || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const min = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}:${String(min).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChargesTable({ charges }: { charges: Charge[] }) {
|
||||||
|
if (charges.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
Noch keine Ladungen erfasst
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#CCCCFF] border-b-2 border-gray-400">
|
||||||
|
<th className="p-2 text-center">Datum</th>
|
||||||
|
<th className="p-2 text-center">Tag</th>
|
||||||
|
<th className="p-2 text-center">Start</th>
|
||||||
|
<th className="p-2 text-center">Ende</th>
|
||||||
|
<th className="p-2 text-center">
|
||||||
|
Dauer<br />
|
||||||
|
<span className="text-xs font-normal">h:mm</span>
|
||||||
|
</th>
|
||||||
|
<th className="p-2 text-center">
|
||||||
|
Energie<br />
|
||||||
|
<span className="text-xs font-normal">kWh</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{charges.map((c, index) => (
|
||||||
|
<tr
|
||||||
|
key={c.id}
|
||||||
|
className={`border-b border-gray-300 hover:bg-gray-50 ${
|
||||||
|
index % 2 === 0 ? "bg-white" : "bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="p-2 text-center">{formatDate(c.start_time)}</td>
|
||||||
|
<td className="p-2 text-center">{weekday(c.start_time)}</td>
|
||||||
|
<td className="p-2 text-center">{formatTime(c.start_time)}</td>
|
||||||
|
<td className="p-2 text-center">{formatTime(c.end_time)}</td>
|
||||||
|
<td className="p-2 text-center">{formatDuration(c.duration_s)}</td>
|
||||||
|
<td className="p-2 text-center">
|
||||||
|
{(c.energy_wh / 1000).toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Totals as TotalsData } from "@/lib/db";
|
||||||
|
|
||||||
|
function kwh(wh: number): string {
|
||||||
|
return (wh / 1000).toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInstall(iso: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const [date = ""] = iso.split("T");
|
||||||
|
const [y, m, d] = date.split("-");
|
||||||
|
return y && m && d ? ` (seit ${d}.${m}.${y})` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[10rem] border border-black rounded-lg bg-white p-4 text-center">
|
||||||
|
<div className="text-sm text-gray-600">{label}</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{value} <span className="text-base font-normal">kWh</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Totals({ totals }: { totals: TotalsData }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Geladene Energie</h2>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Card label="Aktueller Monat" value={kwh(totals.month_wh)} />
|
||||||
|
<Card label="Aktuelles Jahr" value={kwh(totals.year_wh)} />
|
||||||
|
<Card
|
||||||
|
label={`Gesamt${formatInstall(totals.install_date)}`}
|
||||||
|
value={kwh(totals.total_wh)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
globalIgnores([
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || "/data/charges.db";
|
||||||
|
|
||||||
|
export interface Charge {
|
||||||
|
id: number;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
duration_s: number;
|
||||||
|
energy_wh: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Totals {
|
||||||
|
month_wh: number;
|
||||||
|
year_wh: number;
|
||||||
|
total_wh: number;
|
||||||
|
install_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: Database.Database | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Öffnet die DB read-only. Gibt null zurück, falls die Datei noch nicht
|
||||||
|
* existiert (Collector legt sie beim ersten Start an) oder noch kein Schema
|
||||||
|
* vorhanden ist — die Seite bleibt dann leer statt zu crashen.
|
||||||
|
*/
|
||||||
|
function getDb(): Database.Database | null {
|
||||||
|
if (db) return db;
|
||||||
|
try {
|
||||||
|
const conn = new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
||||||
|
const hasTable = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='charges'")
|
||||||
|
.get();
|
||||||
|
if (!hasTable) {
|
||||||
|
conn.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
db = conn;
|
||||||
|
return db;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCharges(): Charge[] {
|
||||||
|
const conn = getDb();
|
||||||
|
if (!conn) return [];
|
||||||
|
return conn
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, start_time, end_time, duration_s, energy_wh
|
||||||
|
FROM charges ORDER BY start_time DESC`,
|
||||||
|
)
|
||||||
|
.all() as Charge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTotals(): Totals {
|
||||||
|
const conn = getDb();
|
||||||
|
if (!conn) {
|
||||||
|
return { month_wh: 0, year_wh: 0, total_wh: 0, install_date: null };
|
||||||
|
}
|
||||||
|
const month = conn
|
||||||
|
.prepare(
|
||||||
|
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
|
||||||
|
WHERE strftime('%Y-%m', start_time) = strftime('%Y-%m', 'now', 'localtime')`,
|
||||||
|
)
|
||||||
|
.get() as { s: number };
|
||||||
|
const year = conn
|
||||||
|
.prepare(
|
||||||
|
`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges
|
||||||
|
WHERE strftime('%Y', start_time) = strftime('%Y', 'now', 'localtime')`,
|
||||||
|
)
|
||||||
|
.get() as { s: number };
|
||||||
|
const total = conn
|
||||||
|
.prepare(`SELECT COALESCE(SUM(energy_wh), 0) AS s FROM charges`)
|
||||||
|
.get() as { s: number };
|
||||||
|
const install = conn
|
||||||
|
.prepare(`SELECT value FROM meta WHERE key = 'install_date'`)
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
month_wh: month.s,
|
||||||
|
year_wh: year.s,
|
||||||
|
total_wh: total.s,
|
||||||
|
install_date: install?.value ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
// Natives Modul nicht ins Bundle ziehen, sondern zur Laufzeit laden.
|
||||||
|
serverExternalPackages: ["better-sqlite3"],
|
||||||
|
turbopack: {
|
||||||
|
root: path.resolve(__dirname),
|
||||||
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: [
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "auto-charger-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/better-sqlite3": "^7.6.12",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user