Compare commits

...
9 Commits
Author SHA1 Message Date
retr0andClaude Sonnet 5 3318d1344d ShineBridge v1.11.0 — Feature: automatisches tägliches Config-Backup nach /share
Neuer Daemon-Thread (gleiches Muster wie der History-Cleanup-Thread) schreibt
täglich einen Config-Export nach /share/shinebridge-backups/ — bewusst nicht
nach /data, da genau dieser Ordner bei Add-on-Uninstall+Reinstall gelöscht
wird (siehe Vorfall 2026-08-25). Export sofort beim Start plus alle 24h,
14 Tage Aufbewahrung mit automatischem Cleanup alter Dateien.

Export-Datenaufbau aus /api/export-config in _build_export_data() extrahiert,
von Endpoint und Scheduler gemeinsam genutzt. Neue Berechtigung map: share:rw
in config.yaml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:52:52 +02:00
retr0andClaude Sonnet 5 c924955be9 ROADMAP: Wallbox-Duplikat-Fund dokumentiert, Punkt 6 zurückgestellt, Auto-Cleanup als neuer Punkt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:47:42 +02:00
retr0andClaude Sonnet 5 89250e16bf ROADMAP: auf v1.9.0+-Stand aktualisiert, neue Roadmap für Energiemanagement-Features
Hardware-/Firmware-Roadmap ist mit dem growatt-diagnose-tool-Split raus. Neue
priorisierte Liste: Wallbox-Duplikat klären, automatischer Config-Export,
Discord-Alerts, Fahrzeug-Sessions, Peak Shaving, Multi-Wallbox-Verteilung,
Prognosebasiertes Laden, Chart-Feintuning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 10:43:28 +02:00
retr0andClaude Sonnet 5 789e25967a ShineBridge v1.10.3 — Fix: HA-Entity-Anbindung auf Long-Lived Token umgestellt (host_network verhindert SUPERVISOR_TOKEN)
homeassistant_api: true funktioniert nicht, solange host_network: true gesetzt
ist — der Container ist dann nicht im internen Supervisor-Netzwerk und bekommt
keinen SUPERVISOR_TOKEN injiziert (bestätigt per Diagnose-Log: keine TOKEN/
SUPERVISOR/HASSIO-Env-Var im Container vorhanden).

Statt host_network anzufassen (Risiko für Modbus/Goodwe-UDP): HAEntityReader
nutzt jetzt ein normales, manuell in HA erstelltes Long-Lived Access Token
gegen http://localhost:8123/api — funktioniert dank host_network direkt ohne
Umweg über den Supervisor-Proxy. homeassistant_api wieder aus config.yaml
entfernt (unnötig).

Neue Felder im Geräte-Dialog: 'Home Assistant URL' (Default localhost:8123)
und 'Long-Lived Access Token'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:26:57 +02:00
retr0andClaude Sonnet 5 1c787349e3 ShineBridge v1.10.2 — Diagnose: Env-Var-Dump wenn SUPERVISOR_TOKEN fehlt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:22:34 +02:00
retr0andClaude Sonnet 5 1b78857b3d ShineBridge v1.10.1 — Fix: Config-Import scheiterte an Geräten ohne modbus_ip (ha_entity-Typ)
/api/import-config verlangte bisher zwingend modbus_ip fuer jedes Geraet in der
importierten Config — der neue GROWATT_WIFI_X-Typ (ha_entity) hat aber keins,
nutzt stattdessen ha_entity_prefix. Import schlug dadurch mit 400 fehl, sobald
so ein Geraet enthalten war. Validierung jetzt protokollabhaengig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 16:11:13 +02:00
retr0andClaude Sonnet 5 6f30fe6b1c ShineBridge v1.10.0 — Neuer Geräte-Typ: Growatt ShineWifi-X über bestehende HA-Entity (ESPHome)
Statt selbst per Modbus/UDP zu pollen, kann ShineBridge jetzt einen Wechselrichter
einbinden, der bereits per ESPHome nativ in Home Assistant läuft — nützlich für
die ShineWifi-X-Variante, die keine eigene Modbus-TCP-Schnittstelle hat.

- Neuer ha_entity_client.py: liest Sensor-Werte über die interne Supervisor-API
  (http://supervisor/core/api), braucht dafür 'homeassistant_api: true' in
  config.yaml (neu gesetzt — Add-on muss dafür einmal neu installiert werden)
- Neuer Geräte-Typ 'GROWATT_WIFI_X' in inverters.py, protocol='ha_entity'
- Feste Suffix-Zuordnung (dcpower→pv_power, acpower→ac_power, energytoday→
  energy_today, ...) passend zur esphome-growatt-Komponente aus
  growatt-diagnose-tool/ShineWifi-X — Entity-Id ergibt sich als
  sensor.<prefix>_<suffix>
- Neues Feld 'HA Entity-Prefix' im Geräte-Dialog statt Modbus-IP/Port/Adresse,
  blendet sich abhängig vom gewählten Gerätetyp ein/aus
- Läuft danach durch dieselbe Aggregations-/Dashboard-Logik wie jedes andere
  Gerät (kein Sonderfall in EMS/Finanzen nötig, da Netzbezug bereits über den
  bestehenden Zähler erfasst wird — dieser Wechselrichter liefert nur PV-Werte)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:28:45 +02:00
retr0andClaude Sonnet 5 9d954d6bd6 ShineBridge v1.9.6 — Beispiel-Diagramm im Leerzustand, Tages-/Wochen-Umschaltung an 14 Wochen geknüpft
- Chart-Logik in buildCostChart() ausgelagert, wiederverwendbar für echte und
  Beispiel-Daten
- Neu: bei Erstinstallation (noch keine echten Daten) zeigt die Finanzen-Seite
  jetzt ein klar als 'BEISPIEL' markiertes, entsättigtes Vorschau-Diagramm mit
  frei erfundenen (deterministischen) Werten statt nur eines Texthinweises —
  keine echt aussehenden Fantasiezahlen, bewusst gedämpfte Farben + Badge
- Umschaltung Tage→Wochen jetzt an 14 Wochen (98 Tage) Historie geknüpft statt
  schon nach 21 Tagen — bis dahin rollierendes Tages-Fenster, danach
  rollierendes Wochen-Fenster (älteste Periode wandert raus sobald neue dazukommt)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:19:13 +02:00
retr0andClaude Sonnet 5 5b6bed16db ShineBridge v1.9.5 — Finanzen-Chart: Container-Breite gemessen, Periodenanzahl passt sich an statt Balken aufzublasen
Statt die SVG per viewBox unbegrenzt mitwachsen zu lassen (wurde am Desktop
riesig) oder sie bei einer festen max-width zu kappen (ließ rechts Leerraum),
wird jetzt die tatsächliche Container-Breite gemessen und in echten Pixeln
gerendert — Höhe bleibt dabei komplett unabhängig fix.

Zusätzlich: auf breiten Screens werden mehr Wochen gezeigt statt derselben
14 Balken künstlich zu verbreitern (Zielbreite ~24px/Periode, min. 10, max.
28 Perioden) — nutzt den zusätzlichen Platz sinnvoll für mehr Historie statt
für aufgeblähte Einzelbalken.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:12:45 +02:00
7 changed files with 359 additions and 164 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ Wechselrichter / Energiezähler
- **Sparklines** — Live-Graphen der letzten 5 Minuten pro Sensor - **Sparklines** — Live-Graphen der letzten 5 Minuten pro Sensor
- **EMS-Controller** — PV-Überschussladen der Kathrein Wallbox, Zwangsladen-Fallback mit konfigurierbarem Max-Ladestrom - **EMS-Controller** — PV-Überschussladen der Kathrein Wallbox, Zwangsladen-Fallback mit konfigurierbarem Max-Ladestrom
- **Überschuss-Geräte** — Zigbee2MQTT-Geräte bei PV-Überschuss automatisch einschalten - **Überschuss-Geräte** — Zigbee2MQTT-Geräte bei PV-Überschuss automatisch einschalten
- **Konfig-Export/Import** — JSON im Einstellungen-Tab - **Konfig-Export/Import** — JSON im Einstellungen-Tab, zusätzlich automatisches tägliches Backup nach `/share/shinebridge-backups/` (übersteht auch Add-on-Uninstall+Reinstall, 14 Tage Aufbewahrung)
- **Port-Sicherheit** — `/api/*` nur über HAOS-Ingress erreichbar, kein Direktzugriff von außen - **Port-Sicherheit** — `/api/*` nur über HAOS-Ingress erreichbar, kein Direktzugriff von außen
- **Kein Cloud-Zwang** — vollständig lokal - **Kein Cloud-Zwang** — vollständig lokal
+28 -54
View File
@@ -1,73 +1,39 @@
# ShineBridge — Roadmap # ShineBridge — Roadmap
> Seit v1.9.0 reines Energiemanagement-Tool. Hardware-/Firmware-Roadmap (ShineLAN-X, ShineWifi-X, shinediag) liegt jetzt im separaten Repo `growatt-diagnose-tool`.
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- [ ] Firma: Wallbox lädt in einem ~6,5-Minuten-Zyklus (Laden→Abgeschlossen→Pausiert→Laden) statt durchzuladen — Root Cause noch offen, live-Diagnose folgt beim nächsten Ladevorgang
- [ ] Growatt-only: Netzbezug nicht direkt messbar — nur Einspeisung via `power_to_grid` - [ ] Growatt-only: Netzbezug nicht direkt messbar — nur Einspeisung via `power_to_grid`
- [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen - [ ] Kathrein EVSE-Register 0x0060 (Charging-State) nur lesbar wenn Auto angeschlossen
- [ ] NuttX OTA noch nicht auf echter Hardware getestet
--- ---
## Phase 2 — ShineBridge Add-on ## Nächste Schritte (Stand 2026-08-26)
- [x] Persistente History — SQLite `/data/history.db`, 7 Tage Retention (v1.3.0) Reihenfolge nach Aufwand/Abhängigkeit, nicht nach Wichtigkeit.
- [x] Konfig-Export/Import — JSON im Einstellungen-Tab (v1.3.0)
- [x] Goodwe GW10KN-ET — via goodwe-Bibliothek UDP/8899, 39 Sensoren (v1.4.0)
- [x] Kathrein Wallbox — Modbus TCP, 18 Sensoren + EMS-Controller (v1.5.0)
- [x] EMS-Controller — PV-Überschussladen + Zwangsladen-Fallback (v1.5.0)
- [x] EMS Web UI — Konfiguration direkt im Gerätedialog (v1.5.1)
- [x] grid_power Sensor — positiv = Netzbezug, negativ = Einspeisung (v1.5.2)
- [x] Energie-Dashboard — HA-Style SVG-Flussdiagramm, animateMotion (v1.6.0)
- [x] Abrechnungsperiode + Strompreise — kWh + Kosten je Periode (v1.7.0)
- [x] Flexibler Tarif — EPEX Spot (aWATTar DE/AT) + Aufschlag (v1.8.16)
- [x] Finanzen-Tab — Eigenversorgung, Spot-Vergleich, Abschlags-Tracker (v1.8.14–16)
- [x] Überschuss-Geräte — Zigbee2MQTT-Geräte bei PV-Überschuss steuern (v1.8.8–13)
- [x] Setup-Wizard — geführte Ersteinrichtung (MQTT + erster Wechselrichter) (v1.8.18)
- [x] Flash-Wizard — ShineLAN-X direkt aus Web UI flashen, OTA + ST-Link-Anleitung (v1.8.18)
- [x] Port-Sicherheit — `/api/*` nur über HAOS-Ingress erreichbar (v1.8.18)
- [x] MQTT rc=5 — Reconnect-Loop verhindert, Fehlermeldung im UI (v1.8.18)
- [x] Mobile-Layout — horizontaler Overflow behoben, Tabs scrollen (v1.8.19)
- [ ] Hausverbrauch als berechneter Sensor: `PV + Netzbezug − Einspeisung − Bat_Laden + Bat_Entladen`
- [ ] Weitere Growatt-Modelle — MOD 10000, SPH 10000 etc.
- [ ] Ladekosten-Berechnung: kWh je Session × Arbeitspreis
--- 1. ~~**Automatischer täglicher Config-Export**~~ — erledigt (v1.11.0): täglicher Export nach `/share/shinebridge-backups/`, 14 Tage Aufbewahrung, sofort beim Start plus alle 24h.
2. **Discord-Alerts** — bei Zwangsladen-Start, Fehlerzuständen (unbekannter Ladezustand), besonders günstigen Spot-Preisfenstern. Nutzt den bestehenden Homelab-Discord-Bot.
3. **Sessions pro Fahrzeug trennen** — falls mehrere Fahrer/Autos an einer Wallbox laden, aktuell nur eine Gesamt-Statistik.
4. **Lastspitzenkappung (Peak Shaving)** — Ladestrom drosseln, wenn Netzbezug (mehrere Autos + Haushalt gleichzeitig) einen Grenzwert überschreitet. Eigenes Sicherheits-Design nötig (was passiert bei Messfehler/Ausfall — nie ungebremst weiterladen).
5. **Prognosebasiertes Laden** — PV-Wetterprognose (z.B. Open-Meteo/Solcast) + Spot-Preis kombinieren, damit Zwangsladen nicht stur nach Timeout entscheidet, sondern z.B. "morgen wird's sonnig, noch etwas warten" berücksichtigt. Größter Umfang, externe API-Anbindung nötig.
6. **Chart-Feintuning** — läuft nebenbei mit, keine feste Priorität (z.B. Live-Kosten-Ticker statt nur Rückblick).
7. **Auto-Cleanup verwaister MQTT-Geräte** — wenn ein Gerät aus ShineBridge entfernt wird, dessen MQTT-Discovery-Topics aktiv abmelden statt sie als tote HA-Geräte-Leichen zurückzulassen (siehe Fund unten).
## Phase 3 — Hardware-Erweiterungen **Zurückgestellt:** Mehrere Wallboxen fair verteilen — bei der Duplikat-Prüfung (siehe unten) stellte sich heraus, dass es aktuell nur eine echte Wallbox gibt. Erst relevant, wenn wirklich eine zweite dazukommt.
### ShineLAN-X (STM32F103RC) ### Fund: Wallbox-Duplikat war kein echtes Duplikat
- [x] dapboot + NuttX produktiv (v1.0.0)
- [x] OTA HTTP-Server implementiert — Zwei-Phasen Flash (Stage 1: Staging, Stage 2: SRAM) (v1.8.18)
- [ ] OTA auf echter Hardware validieren
- [ ] Weitere Firmware-Varianten (modellspezifische Builds)
### Goodwe WiFi-Stick (WIFILAN_2.0 / HF-LPT230) Firma-HA zeigte drei Wallbox-Geräte (`wallbox_ladezustand`/`_2`/`_3`). Per `device_id()`/`device_attr()`-Template-Check verifiziert: drei unterschiedliche ShineBridge-`inv_id`s, aber nur **eine** davon (`1c042e72`) hat noch aktive Poll-Loop-Logeinträge — die anderen zwei sind verwaiste Reste aus früheren Config-Änderungen (0 Log-Treffer). Kein Konflikt-Risiko (keine drei gleichzeitigen Poller auf demselben physischen Gerät), nur unaufgeräumte Geräte-Registry. Manuell entfernbar unter Einstellungen → Geräte & Dienste → Geräte.
- [x] UDP/8899 Protokoll via goodwe-Bibliothek integriert (v1.4.0)
- [ ] JTAG-Analyse (TP15/TP16/TP17) — eigene Firmware wenn JTAG-eFuse nicht gesetzt
- [ ] RS485-Direktzugriff via 18-Pin-Stecker
### ShineWifi-X als ShineBridge-Gateway ## Erledigt vor dieser Roadmap (Kontext)
- [ ] Schlankes ESP8266-Projekt: RS485 → Modbus TCP Bridge
- [ ] Im ShineBridge Web UI verwaltbar (statt separater ESPHome-YAMLs)
--- - [x] `growatt-diagnose-tool`-Split — Flash/OTA, Setup-Wizard, ShineLAN-X, ShineWifi-X, shinediag ausgelagert (v1.9.0)
- [x] Finanzen-Chart komplett überarbeitet — Wochen-Aggregation, responsiv, Beispiel-Vorschau (v1.9.1–1.9.6)
## Phase 4 — Energiebilanz & Dashboard - [x] ESPHome-Geräte-Anbindung (Long-Lived Token statt Supervisor-Proxy, `host_network` ist damit inkompatibel) (v1.10.0–1.10.3)
- [x] Firma-EMS-Fixes: konfigurierbarer Max-Ladestrom, Zwangsladen-Retry, Fehlerzustand-Handling (v1.8.31)
- [x] Aggregat-Gerät „ShineBridge Gesamt" (v1.2.0)
- [x] EPEX Spot-Chart — 24h-Balkendiagramm im Energie-Dashboard (v1.8.16)
- [x] Eigenversorgungskarte — kWh + € gespart (v1.8.8)
- [ ] Virtuelle MQTT-Sensoren für HA Energie-Dashboard automatisch anlegen
- [ ] Prognose-gestütztes Laden — Spot-Preise × PV-Forecast für optimale Ladezeiten
---
## ShineDiag — Vor-Ort-Diagnose
- [x] WiFi-Hotspot „ShineDiag" (Pi 3B), http://10.0.1.1 (v2.0)
- [x] Alle Sensoren, Rohdaten-Register-Dump, JSON-Export (v2.0)
- [ ] Goodwe-Unterstützung (UDP/8899)
- [ ] Kathrein Wallbox (EMS-Status, Ladezustand)
--- ---
@@ -101,3 +67,11 @@
| v1.8.24 | 2026-05-07 | Fix: WR/Wallbox-Offline-Flapping — erst nach 3 aufeinanderfolgenden Lesefehlern offline schalten | | v1.8.24 | 2026-05-07 | Fix: WR/Wallbox-Offline-Flapping — erst nach 3 aufeinanderfolgenden Lesefehlern offline schalten |
| v1.8.25 | 2026-05-07 | Fix: TCP-Timeout auf max. 40% des Poll-Intervalls begrenzt (Wallbox + Modbus) — verhindert Poll-Überlappung | | v1.8.25 | 2026-05-07 | Fix: TCP-Timeout auf max. 40% des Poll-Intervalls begrenzt (Wallbox + Modbus) — verhindert Poll-Überlappung |
| v1.8.26 | 2026-05-07 | Fix: EMS-Countdown läuft ohne Auto — Kathrein 0x0060 IllegalAddress = kein Fahrzeug (default war fälschlich 1=Connected) | | v1.8.26 | 2026-05-07 | Fix: EMS-Countdown läuft ohne Auto — Kathrein 0x0060 IllegalAddress = kein Fahrzeug (default war fälschlich 1=Connected) |
| v1.8.29 | 2026-05-26 | Fix: Port 8099 bindet auf 127.0.0.1 statt 0.0.0.0 |
| v1.8.30 | 2026-05-28 | Fix: Ingress-Fix — Flask wieder auf 0.0.0.0 |
| v1.8.31 | 2026-08-25 | Fix: Firma-EMS — konfigurierbarer Max-Ladestrom statt hardcoded 32A, Zwangsladen-Retry, Fehlerzustand pausiert statt zurückzusetzen |
| v1.9.0 | 2026-08-25 | Repositionierung: Flash/OTA, Setup-Wizard, ShineLAN-X, ShineWifi-X, shinediag ausgelagert nach `growatt-diagnose-tool` |
| v1.9.1–1.9.6 | 2026-08-25 | Finanzen-Chart: unlesbar bei vielen Tagen behoben, Wochen-Aggregation, abgerundete Balken, responsiv über gemessene Breite, Beispiel-Diagramm im Leerzustand |
| v1.10.0 | 2026-08-25 | Feature: Geräte-Typ "Growatt ShineWifi-X (ESPHome)" — liest Werte aus bestehenden HA-Entities |
| v1.10.1–1.10.2 | 2026-08-25 | Fix: Config-Import mit ha_entity-Geräten; Diagnose-Log für SUPERVISOR_TOKEN |
| v1.10.3 | 2026-08-25 | Fix: HA-Entity-Anbindung auf Long-Lived Access Token umgestellt (host_network + Supervisor-Token inkompatibel) |
+3 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.9.4" version: "1.11.0"
slug: shinebridge slug: shinebridge
description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte description: Energiemanagement für Growatt/Goodwe-Wechselrichter — PV-Überschussladen, Wallbox-EMS, Tarifvergleich, Zigbee2MQTT-Überschussgeräte
url: https://gitea.bitfire.work/retr0/shinebridge url: https://gitea.bitfire.work/retr0/shinebridge
@@ -13,6 +13,8 @@ host_network: true
ingress: true ingress: true
ingress_port: 8099 ingress_port: 8099
panel_icon: mdi:solar-power panel_icon: mdi:solar-power
map:
- share:rw
options: options:
mqtt_broker: "core-mosquitto" mqtt_broker: "core-mosquitto"
mqtt_port: 1883 mqtt_port: 1883
+74
View File
@@ -0,0 +1,74 @@
import json
import logging
import urllib.error
import urllib.request
from typing import Dict, Optional
log = logging.getLogger(__name__)
# Suffix am ESPHome-Entity-Namen -> ShineBridge-interner Sensor-Id.
# Passt zur esphome-growatt-Komponente (siehe growatt-diagnose-tool/ShineWifi-X) —
# Entity-Id ergibt sich als sensor.<prefix>_<suffix>, z.B. sensor.growatt_wifi_x_1_dcpower.
ENTITY_SUFFIX_MAP = {
"pv_voltage": "dcvoltage",
"pv_current": "dcinputcurrent",
"pv_power": "dcpower",
"grid_frequency": "acfrequency",
"grid_voltage": "acvoltage",
"grid_current": "acoutputcurrent",
"ac_power": "acpower",
"energy_today": "energytoday",
"energy_total": "energytotal",
"inverter_temp": "temperature",
}
class HAEntityReader:
"""Liest Wechselrichter-Werte aus bereits bestehenden Home-Assistant-Entities
(z.B. ein per ESPHome direkt in HA eingebundener ShineWifi-X-Stick) statt sie
selbst per Modbus/UDP zu pollen.
Nutzt bewusst ein normales Long-Lived Access Token gegen die HA-REST-API statt
den Supervisor-Proxy (http://supervisor/core/api): ShineBridge läuft mit
host_network: true, dadurch ist der Container NICHT im internen Supervisor-
Netzwerk und bekommt keinen SUPERVISOR_TOKEN injiziert. Der direkte API-Weg
funktioniert trotzdem, weil HA Core dank host_network ganz normal unter
localhost erreichbar ist."""
def __init__(self, entity_prefix: str, base_url: str, token: str):
self.entity_prefix = entity_prefix
self.base_url = (base_url or "http://localhost:8123").rstrip("/")
self.token = token
def read(self, inverter=None) -> Optional[Dict[str, float]]:
if not self.entity_prefix:
log.error("[HA-Entity] Kein Entity-Prefix konfiguriert")
return None
if not self.token:
log.error("[HA-Entity %s] Kein Long-Lived Access Token konfiguriert", self.entity_prefix)
return None
result: Dict[str, float] = {}
any_ok = False
for sensor_id, suffix in ENTITY_SUFFIX_MAP.items():
val = self._get_state(f"sensor.{self.entity_prefix}_{suffix}")
if val is not None:
result[sensor_id] = val
any_ok = True
return result if any_ok else None
def _get_state(self, entity_id: str) -> Optional[float]:
req = urllib.request.Request(
f"{self.base_url}/api/states/{entity_id}",
headers={"Authorization": f"Bearer {self.token}"},
)
try:
with urllib.request.urlopen(req, timeout=5) as r:
data = json.loads(r.read())
return round(float(data["state"]), 3)
except (urllib.error.HTTPError, urllib.error.URLError, ValueError, KeyError) as e:
log.debug("[HA-Entity] %s nicht lesbar: %s", entity_id, e)
return None
def close(self):
pass
+26
View File
@@ -197,6 +197,23 @@ def _kathrein_sensors() -> List[Sensor]:
] ]
def _wifi_x_sensors() -> List[Sensor]:
# reg/count/scale/data_type ungenutzt bei protocol="ha_entity" — Werte kommen
# fertig geparst von HAEntityReader, hier nur Metadaten fuer MQTT-Discovery.
return [
Sensor("pv_voltage", "PV Spannung", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:solar-panel"),
Sensor("pv_current", "PV Strom", 0, 1, 1.0, "A", "current", "measurement", "mdi:solar-panel"),
Sensor("pv_power", "PV Leistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:solar-panel"),
Sensor("grid_frequency", "Netz-Frequenz", 0, 1, 1.0, "Hz", "frequency", "measurement", "mdi:sine-wave"),
Sensor("grid_voltage", "Netz-Spannung", 0, 1, 1.0, "V", "voltage", "measurement", "mdi:flash"),
Sensor("grid_current", "Netz-Strom", 0, 1, 1.0, "A", "current", "measurement", "mdi:flash"),
Sensor("ac_power", "AC Ausgangsleistung", 0, 1, 1.0, "W", "power", "measurement", "mdi:flash"),
Sensor("energy_today", "Energie Heute", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:solar-power"),
Sensor("energy_total", "Energie Gesamt", 0, 1, 1.0, "kWh", "energy", "total_increasing", "mdi:solar-power"),
Sensor("inverter_temp", "Wechselrichter Temp.", 0, 1, 1.0, "°C", "temperature", "measurement", "mdi:thermometer"),
]
INVERTERS = { INVERTERS = {
"MIC_1500_TL_X": Inverter( "MIC_1500_TL_X": Inverter(
id="MIC_1500_TL_X", id="MIC_1500_TL_X",
@@ -253,4 +270,13 @@ INVERTERS = {
protocol="kathrein", protocol="kathrein",
goodwe_family="", goodwe_family="",
), ),
"GROWATT_WIFI_X": Inverter(
id="GROWATT_WIFI_X",
name="Growatt ShineWifi-X (ESPHome)",
manufacturer="Growatt",
sensors=_wifi_x_sensors(),
read_ranges=[],
protocol="ha_entity",
goodwe_family="",
),
} }
+63 -7
View File
@@ -14,6 +14,7 @@ import history
from modbus_client import ModbusReader from modbus_client import ModbusReader
from goodwe_client import GoodweReader from goodwe_client import GoodweReader
from wallbox_client import WallboxReader from wallbox_client import WallboxReader
from ha_entity_client import HAEntityReader
from ems_controller import EmsController from ems_controller import EmsController
from mqtt_publisher import MqttPublisher from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController from surplus_devices import SurplusDeviceController
@@ -272,12 +273,20 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
log.error("[%s] Ungültige Konfiguration: %s", inv_id, e) log.error("[%s] Ungültige Konfiguration: %s", inv_id, e)
return return
host = inv_cfg["modbus_ip"] host = inv_cfg.get("modbus_ip", "")
ems: Optional[EmsController] = None ems: Optional[EmsController] = None
if inverter.protocol == "goodwe_udp": if inverter.protocol == "goodwe_udp":
reader = GoodweReader(host=host, family=inverter.goodwe_family) reader = GoodweReader(host=host, family=inverter.goodwe_family)
log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds", log.info("[%s] Poll-Loop: %s @ %s (Goodwe UDP/8899) alle %ds",
inv_id, inverter.name, host, interval) inv_id, inverter.name, host, interval)
elif inverter.protocol == "ha_entity":
reader = HAEntityReader(
entity_prefix=inv_cfg.get("ha_entity_prefix", ""),
base_url=inv_cfg.get("ha_base_url", "http://localhost:8123"),
token=inv_cfg.get("ha_token", ""),
)
log.info("[%s] Poll-Loop: %s (HA-Entity Prefix: %s) alle %ds",
inv_id, inverter.name, inv_cfg.get("ha_entity_prefix", ""), interval)
elif inverter.protocol == "kathrein": elif inverter.protocol == "kathrein":
reader = WallboxReader(host=host, port=port, timeout=min(5.0, interval * 0.4)) reader = WallboxReader(host=host, port=port, timeout=min(5.0, interval * 0.4))
ems = EmsController( ems = EmsController(
@@ -799,10 +808,9 @@ def api_new_id():
return jsonify({"id": uuid.uuid4().hex[:8]}) return jsonify({"id": uuid.uuid4().hex[:8]})
@app.get("/api/export-config") def _build_export_data() -> Dict[str, Any]:
def api_export_config():
with State.lock: with State.lock:
data = { return {
"shinebridge_export": True, "shinebridge_export": True,
"version": 1, "version": 1,
"exported_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "exported_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
@@ -813,7 +821,47 @@ def api_export_config():
}, },
"inverters": State.inverters_cfg, "inverters": State.inverters_cfg,
} }
CONFIG_BACKUP_DIR = "/share/shinebridge-backups"
CONFIG_BACKUP_RETENTION_DAYS = 14
def _export_config_backup():
"""Schreibt einen taeglichen Config-Export nach /share — bewusst NICHT nach
/data, weil genau das der Ordner ist, der bei Add-on-Uninstall+Reinstall
geloescht wird (siehe Vorfall 2026-08-25). /share uebersteht das."""
try:
os.makedirs(CONFIG_BACKUP_DIR, exist_ok=True)
filename = f"config-{time.strftime('%Y-%m-%d')}.json"
path = os.path.join(CONFIG_BACKUP_DIR, filename)
with open(path, "w", encoding="utf-8") as f:
json.dump(_build_export_data(), f, indent=2, ensure_ascii=False)
log.info("Config-Backup geschrieben: %s", path)
cutoff = time.time() - CONFIG_BACKUP_RETENTION_DAYS * 86400
for name in os.listdir(CONFIG_BACKUP_DIR):
fpath = os.path.join(CONFIG_BACKUP_DIR, name)
if name.startswith("config-") and name.endswith(".json") and os.path.getmtime(fpath) < cutoff:
os.remove(fpath)
log.info("Altes Config-Backup gelöscht: %s", fpath)
except OSError as e:
log.error("Config-Backup fehlgeschlagen: %s", e)
def _start_config_backup_scheduler():
def _loop():
_export_config_backup() # sofort beim Start, nicht erst nach 24h
while True:
time.sleep(86400)
_export_config_backup()
threading.Thread(target=_loop, daemon=True, name="config-backup").start()
@app.get("/api/export-config")
def api_export_config():
from flask import Response from flask import Response
data = _build_export_data()
filename = f"shinebridge-config-{time.strftime('%Y%m%d-%H%M%S')}.json" filename = f"shinebridge-config-{time.strftime('%Y%m%d-%H%M%S')}.json"
return Response( return Response(
json.dumps(data, indent=2, ensure_ascii=False), json.dumps(data, indent=2, ensure_ascii=False),
@@ -832,10 +880,17 @@ def api_import_config():
if not isinstance(inverters, list): if not isinstance(inverters, list):
return jsonify({"error": "inverters ungültig"}), 400 return jsonify({"error": "inverters ungültig"}), 400
for inv in inverters: for inv in inverters:
if not inv.get("modbus_ip"): model_id = inv.get("inverter_model")
if model_id not in INVERTERS:
return jsonify({"error": f"Unbekanntes Modell: {model_id}"}), 400
protocol = INVERTERS[model_id].protocol
# modbus_ip wird als generisches Host-Feld fuer alle IP-basierten Protokolle
# wiederverwendet (modbus, goodwe_udp, kathrein) — nur ha_entity braucht keins.
if protocol == "ha_entity":
if not inv.get("ha_entity_prefix"):
return jsonify({"error": f"ha_entity_prefix fehlt in Gerät {inv.get('name', '?')}"}), 400
elif not inv.get("modbus_ip"):
return jsonify({"error": f"modbus_ip fehlt in Gerät {inv.get('name', '?')}"}), 400 return jsonify({"error": f"modbus_ip fehlt in Gerät {inv.get('name', '?')}"}), 400
if inv.get("inverter_model") not in INVERTERS:
return jsonify({"error": f"Unbekanntes Modell: {inv.get('inverter_model')}"}), 400
with State.lock: with State.lock:
if mqtt := data.get("mqtt"): if mqtt := data.get("mqtt"):
@@ -939,6 +994,7 @@ if __name__ == "__main__":
save_config() save_config()
_restart_all() _restart_all()
_start_config_backup_scheduler()
port = int(os.environ.get("INGRESS_PORT", "8099")) port = int(os.environ.get("INGRESS_PORT", "8099"))
log.info("Web UI startet auf Port %d", port) log.info("Web UI startet auf Port %d", port)
app.run(host="0.0.0.0", port=port, threaded=True) app.run(host="0.0.0.0", port=port, threaded=True)
+164 -101
View File
@@ -335,12 +335,25 @@
<input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div> <input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div>
<div class="field"><label>Gerätetyp</label> <div class="field"><label>Gerätetyp</label>
<select id="modal-model"></select></div> <select id="modal-model"></select></div>
<div class="field"><label>IP-Adresse</label> <div id="modbus-fields">
<input type="text" id="modal-ip" placeholder="10.10.20.190"></div> <div class="field"><label>IP-Adresse</label>
<div class="field"><label>Modbus Port</label> <input type="text" id="modal-ip" placeholder="10.10.20.190"></div>
<input type="number" id="modal-port" value="502"></div> <div class="field"><label>Modbus Port</label>
<div class="field"><label>Modbus Slave-Adresse</label> <input type="number" id="modal-port" value="502"></div>
<input type="number" id="modal-addr" value="1" min="1" max="247"></div> <div class="field"><label>Modbus Slave-Adresse</label>
<input type="number" id="modal-addr" value="1" min="1" max="247"></div>
</div>
<div id="ha-entity-fields" style="display:none">
<div class="field"><label>HA Entity-Prefix</label>
<input type="text" id="modal-ha-prefix" placeholder="growatt_wifi_x_1">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Ohne "sensor."-Präfix — z.B. bei <code>sensor.growatt_wifi_x_1_dcpower</code> ist der Wert <code>growatt_wifi_x_1</code>. Braucht ein bereits per ESPHome in HA eingebundenes Gerät.</div></div>
<div class="field"><label>Home Assistant URL</label>
<input type="text" id="modal-ha-url" placeholder="http://localhost:8123">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Meist unverändert lassen — dank host_network erreicht ShineBridge HA Core direkt über localhost.</div></div>
<div class="field"><label>Long-Lived Access Token</label>
<input type="password" id="modal-ha-token" placeholder="Einstellungen → Profil → Sicherheit → Langlebige Zugriffs-Token">
<div style="font-size:11px;color:var(--muted);margin-top:3px">Kein Supervisor-Token nutzbar (host_network) — braucht ein manuell in HA erstelltes Token.</div></div>
</div>
<div class="field"><label>MQTT Topic-Präfix</label> <div class="field"><label>MQTT Topic-Präfix</label>
<input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div> <input type="text" id="modal-prefix" placeholder="growatt/wechselrichter1"></div>
<div class="field"><label>Abfrageintervall (Sekunden)</label> <div class="field"><label>Abfrageintervall (Sekunden)</label>
@@ -736,6 +749,122 @@ function renderEnergy(inverters, aggregates, period, spotData) {
// ── Finance Tab ─────────────────────────────────────────────── // ── Finance Tab ───────────────────────────────────────────────
// Erzeugt plausible, aber frei erfundene Tagesdaten fürs Beispiel-Diagramm im
// Leerzustand (Erstinstallation). Deterministisch (kein echter Zufall), damit
// die Vorschau bei jedem Laden gleich aussieht statt bei jedem Reload zu springen.
function exampleFinanceDays(n) {
const out = [];
const today = new Date();
for (let i = n - 1; i >= 0; i--) {
const d = new Date(today); d.setDate(d.getDate() - i);
const wave = 4.2 + 2.6 * Math.sin(i * 0.6) + (i % 3 === 0 ? 1.4 : 0);
const fixed = Math.max(1.5, wave);
const spot = Math.max(0.8, fixed * (0.68 + 0.1 * Math.cos(i * 0.9)));
out.push({ date: d.toISOString().slice(0, 10), fixed_eur: +fixed.toFixed(2), spot_eur: +spot.toFixed(2) });
}
return out;
}
// Baut das Kosten-Balkendiagramm (echte oder Beispiel-Daten). Aggregiert ab
// 14 Wochen (98 Tagen) angesammelter Historie automatisch von Tages- auf
// Wochenbalken; zeigt davon je nach verfügbarer Breite die letzten 10–28
// Perioden (rollierendes Fenster — ältere Balken wandern rechts raus, sobald
// neue dazukommen).
function buildCostChart(days, el, isExample) {
const C = isExample
? { fixed: '#5b6472', spot: '#6b6155', grid: '#262b33' }
: { fixed: '#58a6ff', spot: '#f0883e', grid: '#30363d' };
const rawN = days.length;
const bucket = rawN >= 98 ? 7 : 1;
const allPeriods = bucket === 1 ? days.map(d => ({
fixed_eur: d.fixed_eur, spot_eur: d.spot_eur, label: d.date.slice(5),
})) : (() => {
const out = [];
for (let i = 0; i < days.length; i += bucket) {
const chunk = days.slice(i, i + bucket);
const fixed = chunk.reduce((s, d) => s + (d.fixed_eur || 0), 0);
const spotVals = chunk.map(d => d.spot_eur).filter(v => v != null);
const spot = spotVals.length ? spotVals.reduce((a, b) => a + b, 0) : null;
out.push({ fixed_eur: fixed, spot_eur: spot, label: chunk[0].date.slice(5) });
}
return out;
})();
// Container-Breite messen und die SVG in echten Pixeln in genau dieser Breite
// zeichnen (keine viewBox-Skalierung) — Höhe bleibt dabei fix, unabhängig von
// der Breite. Auf breiten Screens werden mehr Perioden gezeigt statt dieselben
// künstlich zu verbreitern.
const H = 240, PL = 40, PR = 8, PT = 14, PB = 46;
const measuredW = el.clientWidth || 380;
const W = Math.max(300, measuredW);
const cW = W - PL - PR;
const IDEAL_SLOT = 24; // px/Periode, entspricht dem Slot, der auf Handy-Breite gut aussah
const maxPeriods = Math.min(28, Math.max(10, Math.round(cW / IDEAL_SLOT)));
const periods = allPeriods.slice(-maxPeriods);
const n = periods.length;
const cH = H - PT - PB;
const slot = cW / n;
const barW = Math.max(3, Math.min(26, slot * 0.36));
const barGap = Math.max(1.5, slot * 0.08);
const rBar = Math.min(2.5, barW / 2);
const allVals = periods.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
const maxVal = Math.max(...allVals, 0.01);
const yTicks = 4;
let gridLines = '', yLabels = '';
for (let i = 0; i <= yTicks; i++) {
const v = maxVal * i / yTicks;
const y = PT + cH - (cH * i / yTicks);
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="1" stroke-dasharray="2,3"/>`;
yLabels += `<text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
}
const gradId = isExample ? 'Ex' : '';
let bars = '', xLabels = '';
periods.forEach((d, i) => {
const cx = PL + i * slot + slot / 2;
const hFixed = Math.max(0, cH * d.fixed_eur / maxVal);
bars += `<rect x="${(cx - barGap/2 - barW).toFixed(1)}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hFixed,1).toFixed(1)}" rx="${rBar}" fill="url(#gradFixed${gradId})"/>`;
if (d.spot_eur != null) {
const hSpot = Math.max(0, cH * d.spot_eur / maxVal);
bars += `<rect x="${(cx + barGap/2).toFixed(1)}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hSpot,1).toFixed(1)}" rx="${rBar}" fill="url(#gradSpot${gradId})"/>`;
}
const ly = H - PB + 12;
xLabels += `<text x="${cx.toFixed(1)}" y="${ly}" text-anchor="end" font-size="8.5" fill="#8b949e" transform="rotate(-40 ${cx.toFixed(1)} ${ly})">${d.label}</text>`;
});
const defs = `<defs>
<linearGradient id="gradFixed${gradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.fixed}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.fixed}" stop-opacity="0.55"/>
</linearGradient>
<linearGradient id="gradSpot${gradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.spot}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.spot}" stop-opacity="0.55"/>
</linearGradient>
</defs>`;
const bucketLabel = bucket === 1 ? 'Tag' : 'Woche';
const rangeNote = allPeriods.length > n ? ` · letzte ${n}` : '';
const legend = `<div style="display:flex;gap:18px;margin-top:10px;font-size:11px;color:var(--text-dim)">
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.fixed};display:inline-block"></span>Festpreis</span>
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.spot};display:inline-block"></span>Spot (hypothetisch)</span>
</div>`;
return `
<div style="position:relative;border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je ${bucketLabel}${rangeNote}</div>
<svg width="${W}" height="${H}" style="display:block">
${defs}${gridLines}${yLabels}${bars}${xLabels}
</svg>
${legend}
${isExample ? `<div style="position:absolute;top:20px;right:0;font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--text-dim);background:var(--surface2);border:1px solid var(--border);border-radius:20px;padding:3px 10px">BEISPIEL</div>` : ''}
</div>`;
}
async function loadFinance() { async function loadFinance() {
const el = document.getElementById("finance-content"); const el = document.getElementById("finance-content");
let data; let data;
@@ -746,10 +875,14 @@ async function loadFinance() {
const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data; const { days, fixed_total_eur, spot_total_eur, savings_eur, period_start, spot_days, total_days } = data;
if (!days || days.length === 0) { if (!days || days.length === 0) {
el.innerHTML = `<div style="text-align:center;padding:40px;color:var(--text-dim)"> const exampleChart = buildCostChart(exampleFinanceDays(14), el, true);
<div style="font-size:32px;margin-bottom:12px">📊</div> el.innerHTML = `<div style="padding:4px 0">
<div style="font-weight:600;margin-bottom:6px">Noch keine Daten</div> <div style="text-align:center;padding:28px 20px;color:var(--text-dim);border:1px dashed var(--border);border-radius:var(--radius);margin-bottom:24px">
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}.</div> <div style="font-size:28px;margin-bottom:10px">📊</div>
<div style="font-weight:600;margin-bottom:6px;color:var(--text)">Noch keine echten Daten</div>
<div style="font-size:13px">Der Tracker sammelt ab morgen täglich Daten.<br>Abrechnungsjahr ab ${period_start}. Unten siehst du, wie die Ansicht dann aussieht.</div>
</div>
${exampleChart}
</div>`; </div>`;
return; return;
} }
@@ -796,93 +929,7 @@ async function loadFinance() {
${spotCard}${savCard} ${spotCard}${savCard}
</div>`; </div>`;
// ── SVG-Balkendiagramm ──────────────────────────────────────── const chartSection = buildCostChart(days, el, false);
// Bei langen Abrechnungsjahren nicht jeden einzelnen Tag zeigen — statt 112
// Tagesbalken lieber Wochenbalken, und davon nur die letzten 14 (reicht für
// einen Trend, bleibt aber lesbar statt endlos zu scrollen).
const rawN = days.length;
const bucket = rawN > 180 ? 14 : rawN > 21 ? 7 : 1;
const allPeriods = bucket === 1 ? days.map(d => ({
fixed_eur: d.fixed_eur, spot_eur: d.spot_eur, label: d.date.slice(5),
})) : (() => {
const out = [];
for (let i = 0; i < days.length; i += bucket) {
const chunk = days.slice(i, i + bucket);
const fixed = chunk.reduce((s, d) => s + (d.fixed_eur || 0), 0);
const spotVals = chunk.map(d => d.spot_eur).filter(v => v != null);
const spot = spotVals.length ? spotVals.reduce((a, b) => a + b, 0) : null;
out.push({ fixed_eur: fixed, spot_eur: spot, label: chunk[0].date.slice(5) });
}
return out;
})();
const MAX_PERIODS = 14;
const periods = allPeriods.slice(-MAX_PERIODS);
// Echtes responsives SVG: viewBox in einer "Mobile-first"-Breite, skaliert per
// width:100% frei auf den Container (Handy wie PC) — kein Scrollen mehr nötig,
// da wir uns auf max. 14 Balken begrenzen.
const n = periods.length;
const W = 380, H = 220, PL = 40, PR = 8, PT = 14, PB = 46;
const cW = W - PL - PR;
const cH = H - PT - PB;
const slot = cW / n;
const barW = Math.max(3, slot * 0.36);
const barGap = Math.max(1.5, slot * 0.08);
const rBar = Math.min(2.5, barW / 2);
const allVals = periods.flatMap(d => [d.fixed_eur, d.spot_eur].filter(v => v != null));
const maxVal = Math.max(...allVals, 0.01);
const yTicks = 4;
let gridLines = '', yLabels = '';
for (let i = 0; i <= yTicks; i++) {
const v = maxVal * i / yTicks;
const y = PT + cH - (cH * i / yTicks);
gridLines += `<line x1="${PL}" y1="${y.toFixed(1)}" x2="${W-PR}" y2="${y.toFixed(1)}" stroke="${C.grid}" stroke-width="1" stroke-dasharray="2,3"/>`;
yLabels += `<text x="${PL-6}" y="${(y+3).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
}
// Labels schräg statt horizontal — braucht bei 14 Balken pro Label deutlich
// weniger Breite, dadurch bleiben trotz Platzmangel alle 14 lesbar.
let bars = '', xLabels = '';
periods.forEach((d, i) => {
const cx = PL + i * slot + slot / 2;
const hFixed = Math.max(0, cH * d.fixed_eur / maxVal);
bars += `<rect x="${(cx - barGap/2 - barW).toFixed(1)}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hFixed,1).toFixed(1)}" rx="${rBar}" fill="url(#gradFixed)"/>`;
if (d.spot_eur != null) {
const hSpot = Math.max(0, cH * d.spot_eur / maxVal);
bars += `<rect x="${(cx + barGap/2).toFixed(1)}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(hSpot,1).toFixed(1)}" rx="${rBar}" fill="url(#gradSpot)"/>`;
}
const ly = H - PB + 12;
xLabels += `<text x="${cx.toFixed(1)}" y="${ly}" text-anchor="end" font-size="8.5" fill="#8b949e" transform="rotate(-40 ${cx.toFixed(1)} ${ly})">${d.label}</text>`;
});
const defs = `<defs>
<linearGradient id="gradFixed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.fixed}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.fixed}" stop-opacity="0.55"/>
</linearGradient>
<linearGradient id="gradSpot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${C.spot}" stop-opacity="0.95"/>
<stop offset="100%" stop-color="${C.spot}" stop-opacity="0.55"/>
</linearGradient>
</defs>`;
const bucketLabel = bucket === 1 ? 'Tag' : bucket === 7 ? 'Woche' : '2 Wochen';
const rangeNote = allPeriods.length > MAX_PERIODS ? ` · letzte ${n}` : '';
const legend = `<div style="display:flex;gap:18px;margin-top:10px;font-size:11px;color:var(--text-dim)">
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.fixed};display:inline-block"></span>Festpreis</span>
<span style="display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:3px;background:${C.spot};display:inline-block"></span>Spot (hypothetisch)</span>
</div>`;
const chartSection = `
<div style="border-top:1px solid var(--border);padding-top:24px;margin-top:4px">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:14px">Kosten je ${bucketLabel}${rangeNote}</div>
<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:640px;height:auto;display:block">
${defs}${gridLines}${yLabels}${bars}${xLabels}
</svg>
${legend}
</div>`;
el.innerHTML = `<div style="padding:4px 0">${empfehlung}${cards}${chartSection}</div>`; el.innerHTML = `<div style="padding:4px 0">${empfehlung}${cards}${chartSection}</div>`;
} catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); } } catch(e) { el.innerHTML = '<div class="no-data">Fehler beim Laden</div>'; console.error('loadFinance:', e); }
@@ -1002,7 +1049,9 @@ function renderInverterList() {
</div> </div>
</div> </div>
<div class="inv-card-meta"> <div class="inv-card-meta">
📡 ${esc(inv.modbus_ip)}:${inv.modbus_port || 502} · Slave ${inv.modbus_address || 1}<br> ${inv.inverter_model === "GROWATT_WIFI_X"
? `🏠 HA-Entity: ${esc(inv.ha_entity_prefix || "–")}`
: `📡 ${esc(inv.modbus_ip)}:${inv.modbus_port || 502} · Slave ${inv.modbus_address || 1}`}<br>
📨 ${esc(inv.mqtt_topic_prefix)}<br> 📨 ${esc(inv.mqtt_topic_prefix)}<br>
⏱ alle ${inv.update_interval || 30}s ⏱ alle ${inv.update_interval || 30}s
</div> </div>
@@ -1015,16 +1064,19 @@ function renderInverterList() {
el.innerHTML = cards + `<div class="add-btn" onclick="openModal()">+ Gerät hinzufügen</div>`; el.innerHTML = cards + `<div class="add-btn" onclick="openModal()">+ Gerät hinzufügen</div>`;
} }
function toggleEmsSection(model) { function onModelChange(model) {
document.getElementById("ems-section").style.display = document.getElementById("ems-section").style.display =
model === "KATHREIN_WALLBOX" ? "" : "none"; model === "KATHREIN_WALLBOX" ? "" : "none";
const isHaEntity = model === "GROWATT_WIFI_X";
document.getElementById("modbus-fields").style.display = isHaEntity ? "none" : "";
document.getElementById("ha-entity-fields").style.display = isHaEntity ? "" : "none";
} }
async function openModal(invId) { async function openModal(invId) {
await loadModels(); await loadModels();
const modal = document.getElementById("modal-backdrop"); const modal = document.getElementById("modal-backdrop");
const modelSel = document.getElementById("modal-model"); const modelSel = document.getElementById("modal-model");
modelSel.onchange = () => toggleEmsSection(modelSel.value); modelSel.onchange = () => onModelChange(modelSel.value);
if (invId) { if (invId) {
const inv = invertersList.find(i => i.id === invId) || {}; const inv = invertersList.find(i => i.id === invId) || {};
document.getElementById("modal-title").textContent = "Gerät bearbeiten"; document.getElementById("modal-title").textContent = "Gerät bearbeiten";
@@ -1034,6 +1086,9 @@ async function openModal(invId) {
document.getElementById("modal-ip").value = inv.modbus_ip || ""; document.getElementById("modal-ip").value = inv.modbus_ip || "";
document.getElementById("modal-port").value = inv.modbus_port || 502; document.getElementById("modal-port").value = inv.modbus_port || 502;
document.getElementById("modal-addr").value = inv.modbus_address || 1; document.getElementById("modal-addr").value = inv.modbus_address || 1;
document.getElementById("modal-ha-prefix").value = inv.ha_entity_prefix || "";
document.getElementById("modal-ha-url").value = inv.ha_base_url || "http://localhost:8123";
document.getElementById("modal-ha-token").value = inv.ha_token || "";
document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || ""; document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || "";
document.getElementById("modal-interval").value = inv.update_interval || 30; document.getElementById("modal-interval").value = inv.update_interval || 30;
document.getElementById("ems-min-pv").value = inv.ems_min_pv ?? 1400; document.getElementById("ems-min-pv").value = inv.ems_min_pv ?? 1400;
@@ -1049,6 +1104,9 @@ async function openModal(invId) {
document.getElementById("modal-ip").value = ""; document.getElementById("modal-ip").value = "";
document.getElementById("modal-port").value = "502"; document.getElementById("modal-port").value = "502";
document.getElementById("modal-addr").value = "1"; document.getElementById("modal-addr").value = "1";
document.getElementById("modal-ha-prefix").value = "";
document.getElementById("modal-ha-url").value = "http://localhost:8123";
document.getElementById("modal-ha-token").value = "";
document.getElementById("modal-prefix").value = "growatt/wechselrichter" + (invertersList.length + 1); document.getElementById("modal-prefix").value = "growatt/wechselrichter" + (invertersList.length + 1);
document.getElementById("modal-interval").value = "30"; document.getElementById("modal-interval").value = "30";
document.getElementById("ems-min-pv").value = "1400"; document.getElementById("ems-min-pv").value = "1400";
@@ -1057,7 +1115,7 @@ async function openModal(invId) {
document.getElementById("ems-phases").value = "3"; document.getElementById("ems-phases").value = "3";
document.getElementById("ems-max-current").value = "32"; document.getElementById("ems-max-current").value = "32";
} }
toggleEmsSection(modelSel.value); onModelChange(modelSel.value);
modal.classList.add("open"); modal.classList.add("open");
} }
@@ -1085,6 +1143,11 @@ async function saveInverter() {
mqtt_topic_prefix: document.getElementById("modal-prefix").value.trim(), mqtt_topic_prefix: document.getElementById("modal-prefix").value.trim(),
update_interval: parseInt(document.getElementById("modal-interval").value), update_interval: parseInt(document.getElementById("modal-interval").value),
}; };
if (model === "GROWATT_WIFI_X") {
inv.ha_entity_prefix = document.getElementById("modal-ha-prefix").value.trim();
inv.ha_base_url = document.getElementById("modal-ha-url").value.trim() || "http://localhost:8123";
inv.ha_token = document.getElementById("modal-ha-token").value.trim();
}
if (model === "KATHREIN_WALLBOX") { if (model === "KATHREIN_WALLBOX") {
inv.ems_min_pv = parseInt(document.getElementById("ems-min-pv").value); inv.ems_min_pv = parseInt(document.getElementById("ems-min-pv").value);
inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value); inv.ems_timeout = parseFloat(document.getElementById("ems-timeout").value);