Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b78857b3d | ||
|
|
6f30fe6b1c | ||
|
|
9d954d6bd6 | ||
|
|
5b6bed16db | ||
|
|
e6422892f3 | ||
|
|
608f6bfd42 | ||
|
|
722301b1d6 |
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.9.1"
|
version: "1.10.1"
|
||||||
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,7 @@ host_network: true
|
|||||||
ingress: true
|
ingress: true
|
||||||
ingress_port: 8099
|
ingress_port: 8099
|
||||||
panel_icon: mdi:solar-power
|
panel_icon: mdi:solar-power
|
||||||
|
homeassistant_api: true
|
||||||
options:
|
options:
|
||||||
mqtt_broker: "core-mosquitto"
|
mqtt_broker: "core-mosquitto"
|
||||||
mqtt_port: 1883
|
mqtt_port: 1883
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
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.
|
||||||
|
|
||||||
|
Braucht 'homeassistant_api: true' in config.yaml — HAOS injiziert dann
|
||||||
|
SUPERVISOR_TOKEN und die interne API ist unter http://supervisor/core/api
|
||||||
|
erreichbar (kein manuell erstelltes Long-Lived Token nötig)."""
|
||||||
|
|
||||||
|
BASE_URL = "http://supervisor/core/api"
|
||||||
|
|
||||||
|
def __init__(self, entity_prefix: str):
|
||||||
|
self.entity_prefix = entity_prefix
|
||||||
|
self.token = os.environ.get("SUPERVISOR_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 SUPERVISOR_TOKEN — 'homeassistant_api: true' in config.yaml gesetzt und Add-on neu installiert?", 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}/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
|
||||||
@@ -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="",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -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,16 @@ 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", ""))
|
||||||
|
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(
|
||||||
@@ -832,10 +837,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"):
|
||||||
|
|||||||
+152
-70
@@ -335,12 +335,19 @@
|
|||||||
<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, keinen direkten Netzwerkzugriff.</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 +743,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 +869,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,62 +923,7 @@ async function loadFinance() {
|
|||||||
${spotCard}${savCard}
|
${spotCard}${savCard}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// ── SVG-Balkendiagramm ────────────────────────────────────────
|
const chartSection = buildCostChart(days, el, false);
|
||||||
// Breite skaliert mit der Anzahl Tage (Mindestplatz pro Tag), statt bei vielen
|
|
||||||
// Tagen alles in eine feste Breite zu quetschen — sonst werden Balken bei
|
|
||||||
// langen Abrechnungsjahren (>60 Tage) zu Haarlinien und unlesbar.
|
|
||||||
const H = 180, PL = 44, PR = 12, PT = 10, PB = 28;
|
|
||||||
const n = days.length;
|
|
||||||
const MIN_SLOT = 10; // px pro Tag (Balkenpaar + Abstand), unterhalb dessen nichts mehr lesbar ist
|
|
||||||
const W = Math.max(600, PL + PR + n * MIN_SLOT);
|
|
||||||
const cW = W - PL - PR;
|
|
||||||
const cH = H - PT - PB;
|
|
||||||
const barW = Math.max(3, Math.floor(cW / n) - 2);
|
|
||||||
const gap = Math.max(1, Math.floor(cW / n) - barW);
|
|
||||||
|
|
||||||
const allVals = days.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="0.5"/>`;
|
|
||||||
yLabels += `<text x="${PL-4}" y="${(y+4).toFixed(1)}" text-anchor="end" font-size="9" fill="#8b949e">${fEur(v)}</text>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Label-Abstand an der tatsächlichen Balkenbreite ausrichten (~60px pro Label),
|
|
||||||
// statt pauschal 8 Labels über die ganze — jetzt variable — Breite zu verteilen.
|
|
||||||
const labelStep = Math.max(1, Math.round(60 / (barW + gap)));
|
|
||||||
let bars = '', xLabels = '';
|
|
||||||
days.forEach((d, i) => {
|
|
||||||
const x0 = PL + i * (barW + gap);
|
|
||||||
const hFixed = cH * d.fixed_eur / maxVal;
|
|
||||||
bars += `<rect x="${x0}" y="${(PT + cH - hFixed).toFixed(1)}" width="${barW/2}" height="${hFixed.toFixed(1)}" fill="${C.fixed}" opacity="0.85"/>`;
|
|
||||||
if (d.spot_eur != null) {
|
|
||||||
const hSpot = cH * d.spot_eur / maxVal;
|
|
||||||
bars += `<rect x="${x0 + barW/2}" y="${(PT + cH - hSpot).toFixed(1)}" width="${barW/2}" height="${hSpot.toFixed(1)}" fill="${C.spot}" opacity="0.85"/>`;
|
|
||||||
}
|
|
||||||
if (i % labelStep === 0) {
|
|
||||||
const label = d.date.slice(5);
|
|
||||||
xLabels += `<text x="${(x0 + barW/2).toFixed(1)}" y="${H - 4}" text-anchor="middle" font-size="9" fill="#8b949e">${label}</text>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const legend = `<text x="${PL}" y="${H+18}" font-size="10" fill="${C.fixed}">■ Festpreis</text>
|
|
||||||
<text x="${PL+70}" y="${H+18}" font-size="10" fill="${C.spot}">■ Spot (hypothetisch)</text>`;
|
|
||||||
|
|
||||||
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 Tag</div>
|
|
||||||
<div style="overflow-x:auto">
|
|
||||||
<svg viewBox="0 0 ${W} ${H+24}" width="${W}" height="${H+24}" style="width:${W}px;max-width:none;display:block">
|
|
||||||
${gridLines}${yLabels}${bars}${xLabels}${legend}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
${n > 60 ? `<div style="font-size:11px;color:var(--text-dim);margin-top:6px">← Horizontal scrollbar, ${n} Tage</div>` : ''}
|
|
||||||
</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); }
|
||||||
@@ -971,7 +1043,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>
|
||||||
@@ -984,16 +1058,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";
|
||||||
@@ -1003,6 +1080,7 @@ 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-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;
|
||||||
@@ -1018,6 +1096,7 @@ 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-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";
|
||||||
@@ -1026,7 +1105,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");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1054,6 +1133,9 @@ 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();
|
||||||
|
}
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user