Fix: Energie-Dashboard Nodes größer (R=56), Labels aus API, Abrechnungsperiode konfig. (v1.7.1)
- SVG-Nodes: Radius 44 → 56, Icon 22 → 26px, Abstände neu berechnet - Segment-Pfade an neue Positionen angepasst (40px Abstand Kante→Kante) - period.monthly.label / yearly.label statt hardcoded "Diesen Monat" / "Dieses Jahr" - billing_day/billing_month: history.period_key(), /api/period-energy, Settings-UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
name: ShineBridge
|
name: ShineBridge
|
||||||
version: "1.7.0"
|
version: "1.7.1"
|
||||||
slug: shinebridge
|
slug: shinebridge
|
||||||
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
description: Growatt Wechselrichter lokal in Home Assistant — Modbus TCP via ShineLAN-X, MQTT Discovery, Web UI
|
||||||
url: https://gitea.bitfire.work/retr0/shinebridge
|
url: https://gitea.bitfire.work/retr0/shinebridge
|
||||||
|
|||||||
@@ -52,10 +52,22 @@ def init_db():
|
|||||||
log.info("History DB initialisiert: %s", DB_PATH)
|
log.info("History DB initialisiert: %s", DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
def period_key(period_type: str) -> str:
|
def period_key(period_type: str, billing_day: int = 1, billing_month: int = 1) -> str:
|
||||||
import datetime
|
import datetime
|
||||||
now = datetime.date.today()
|
today = datetime.date.today()
|
||||||
return now.strftime("%Y-%m") if period_type == "monthly" else now.strftime("%Y")
|
if period_type == "monthly":
|
||||||
|
return today.strftime("%Y-%m")
|
||||||
|
# Jahresperiode: Beginn = letzter Abrechnungsstichtag
|
||||||
|
try:
|
||||||
|
start = datetime.date(today.year, billing_month, billing_day)
|
||||||
|
except ValueError:
|
||||||
|
start = datetime.date(today.year, billing_month, 1)
|
||||||
|
if today < start:
|
||||||
|
try:
|
||||||
|
start = datetime.date(today.year - 1, billing_month, billing_day)
|
||||||
|
except ValueError:
|
||||||
|
start = datetime.date(today.year - 1, billing_month, 1)
|
||||||
|
return start.isoformat() # z.B. "2025-04-01"
|
||||||
|
|
||||||
|
|
||||||
def save_period_start_if_new(agg_id: str, period_type: str, key: str, current_value: float):
|
def save_period_start_if_new(agg_id: str, period_type: str, key: str, current_value: float):
|
||||||
|
|||||||
+33
-5
@@ -88,6 +88,8 @@ def _defaults() -> Dict[str, Any]:
|
|||||||
"mqtt_pass": "",
|
"mqtt_pass": "",
|
||||||
"price_import": 0.30,
|
"price_import": 0.30,
|
||||||
"price_export": 0.08,
|
"price_export": 0.08,
|
||||||
|
"billing_day": 1,
|
||||||
|
"billing_month": 1,
|
||||||
"inverters": [],
|
"inverters": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +120,8 @@ def save_config():
|
|||||||
"mqtt_pass": State.mqtt_cfg.get("mqtt_pass", ""),
|
"mqtt_pass": State.mqtt_cfg.get("mqtt_pass", ""),
|
||||||
"price_import": State.mqtt_cfg.get("price_import", 0.30),
|
"price_import": State.mqtt_cfg.get("price_import", 0.30),
|
||||||
"price_export": State.mqtt_cfg.get("price_export", 0.08),
|
"price_export": State.mqtt_cfg.get("price_export", 0.08),
|
||||||
|
"billing_day": State.mqtt_cfg.get("billing_day", 1),
|
||||||
|
"billing_month": State.mqtt_cfg.get("billing_month", 1),
|
||||||
"inverters": State.inverters_cfg,
|
"inverters": State.inverters_cfg,
|
||||||
}
|
}
|
||||||
with open(CONFIG_PATH, "w") as f:
|
with open(CONFIG_PATH, "w") as f:
|
||||||
@@ -345,6 +349,9 @@ def api_save_config():
|
|||||||
for k in ("price_import", "price_export"):
|
for k in ("price_import", "price_export"):
|
||||||
if k in data:
|
if k in data:
|
||||||
State.mqtt_cfg[k] = float(data[k])
|
State.mqtt_cfg[k] = float(data[k])
|
||||||
|
for k in ("billing_day", "billing_month"):
|
||||||
|
if k in data:
|
||||||
|
State.mqtt_cfg[k] = int(data[k])
|
||||||
save_config()
|
save_config()
|
||||||
threading.Thread(target=_restart_all, daemon=True).start()
|
threading.Thread(target=_restart_all, daemon=True).start()
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
@@ -352,16 +359,37 @@ def api_save_config():
|
|||||||
|
|
||||||
@app.get("/api/period-energy")
|
@app.get("/api/period-energy")
|
||||||
def api_period_energy():
|
def api_period_energy():
|
||||||
|
import datetime
|
||||||
agg = _compute_aggregates()
|
agg = _compute_aggregates()
|
||||||
price_import = float(State.mqtt_cfg.get("price_import", 0.30))
|
price_import = float(State.mqtt_cfg.get("price_import", 0.30))
|
||||||
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
|
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
|
||||||
|
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
|
||||||
|
billing_month = int(State.mqtt_cfg.get("billing_month", 1))
|
||||||
|
|
||||||
result = {"price_import": price_import, "price_export": price_export}
|
result = {
|
||||||
|
"price_import": price_import,
|
||||||
|
"price_export": price_export,
|
||||||
|
"billing_day": billing_day,
|
||||||
|
"billing_month": billing_month,
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHS_DE = ["","Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]
|
||||||
|
|
||||||
for period_type in ("monthly", "yearly"):
|
for period_type in ("monthly", "yearly"):
|
||||||
key = history.period_key(period_type)
|
key = history.period_key(period_type, billing_day, billing_month)
|
||||||
entry = {}
|
entry = {}
|
||||||
for agg_id in ("grid_import_kwh", "grid_export_kwh", "total_energy_today"):
|
|
||||||
|
# Lesbare Beschriftung
|
||||||
|
if period_type == "monthly":
|
||||||
|
d = datetime.date.fromisoformat(key + "-01")
|
||||||
|
entry["label"] = f"{MONTHS_DE[d.month]} {d.year}"
|
||||||
|
else:
|
||||||
|
d = datetime.date.fromisoformat(key)
|
||||||
|
end = datetime.date(d.year + 1, billing_month, billing_day) if billing_month != 1 or billing_day != 1 \
|
||||||
|
else datetime.date(d.year + 1, 1, 1)
|
||||||
|
entry["label"] = f"{d.strftime('%d.%m.%Y')} – {(end - datetime.timedelta(days=1)).strftime('%d.%m.%Y')}"
|
||||||
|
|
||||||
|
for agg_id in ("grid_import_kwh", "grid_export_kwh"):
|
||||||
cur = agg.get(agg_id)
|
cur = agg.get(agg_id)
|
||||||
if cur is None:
|
if cur is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -213,7 +213,16 @@
|
|||||||
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
|
<input type="number" id="cfg-price-import" step="0.001" placeholder="0.300"></div>
|
||||||
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
|
<div class="field"><label>Einspeisevergütung (€/kWh)</label>
|
||||||
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
|
<input type="number" id="cfg-price-export" step="0.001" placeholder="0.080"></div>
|
||||||
<button class="btn btn-primary" onclick="savePrices()">Preise speichern</button>
|
<div class="field"><label>Abrechnungsjahr beginnt am</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<input type="number" id="cfg-billing-day" min="1" max="28" placeholder="1" style="width:72px">
|
||||||
|
<span style="color:var(--text-dim)">.</span>
|
||||||
|
<input type="number" id="cfg-billing-month" min="1" max="12" placeholder="1" style="width:72px">
|
||||||
|
<span style="color:var(--text-dim);font-size:11px">(Tag . Monat)</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--text-dim);margin-top:4px">z.B. 1 . 4 = 01. April</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="savePrices()">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@@ -424,22 +433,22 @@ function renderEnergy(inverters, aggregates, period) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function icon(name, cx, cy, col, sz) {
|
function icon(name, cx, cy, col, sz) {
|
||||||
sz = sz || 22;
|
sz = sz || 26;
|
||||||
const s = (sz / 20).toFixed(4);
|
const s = (sz / 20).toFixed(4);
|
||||||
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${s})" stroke="${col}" fill="none">${ICONS[name]}</g>`;
|
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${s})" stroke="${col}" fill="none">${ICONS[name]}</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circle node — HA style
|
// Circle node — HA style
|
||||||
const R = 44;
|
const R = 56;
|
||||||
function node(cx, cy, iconName, topLabel, valW, col, active, sub) {
|
function node(cx, cy, iconName, topLabel, valW, col, active, sub) {
|
||||||
const c = active ? col : C.dim;
|
const c = active ? col : C.dim;
|
||||||
const sw = active ? 2 : 1;
|
const sw = active ? 2 : 1;
|
||||||
const fi = active ? '0.08' : '0.03';
|
const fi = active ? '0.08' : '0.03';
|
||||||
return `<g>
|
return `<g>
|
||||||
<circle cx="${cx}" cy="${cy}" r="${R}" fill="${col}" fill-opacity="${fi}" stroke="${c}" stroke-width="${sw}"/>
|
<circle cx="${cx}" cy="${cy}" r="${R}" fill="${col}" fill-opacity="${fi}" stroke="${c}" stroke-width="${sw}"/>
|
||||||
${icon(iconName, cx, cy - 11, c, 22)}
|
${icon(iconName, cx, cy - 15, c, 26)}
|
||||||
<text x="${cx}" y="${cy+7}" text-anchor="middle" font-size="12" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle">${fW(valW)}</text>
|
<text x="${cx}" y="${cy+7}" text-anchor="middle" font-size="14" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle">${fW(valW)}</text>
|
||||||
<text x="${cx}" y="${cy+21}" text-anchor="middle" font-size="9" fill="${C.dim}" dominant-baseline="middle" letter-spacing=".06em">${topLabel}${sub?' · '+sub:''}</text>
|
<text x="${cx}" y="${cy+24}" text-anchor="middle" font-size="10" fill="${C.dim}" dominant-baseline="middle" letter-spacing=".06em">${topLabel}${sub?' · '+sub:''}</text>
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,15 +466,13 @@ function renderEnergy(inverters, aggregates, period) {
|
|||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path segments (bezier from node-edge to node-edge)
|
// Kreuz-Layout: Solar(260,72) oben, Grid(108,224) links, Haus(260,224) Mitte,
|
||||||
// Solar(145,75) bottom→ House(260,180) top; Grid(375,75) bottom→ House top
|
// Batterie(412,224) rechts, Wallbox(260,376) unten (optional). R=56, Abstand=40px.
|
||||||
// House bottom → Battery(145,292) top; House bottom → EV(375,292) top
|
|
||||||
// Kreuz-Layout: Solar oben, Grid links, Haus Mitte, Batterie rechts, Wallbox unten (optional)
|
|
||||||
const SEG = [
|
const SEG = [
|
||||||
{ id:'ep-pv', d:`M 260,121 C 258,138 262,146 260,158`, col:C.pv, on:pvOn, rev:false },
|
{ id:'ep-pv', d:`M 260,128 C 258,148 262,152 260,168`, col:C.pv, on:pvOn, rev:false },
|
||||||
{ id:'ep-grid', d:`M 140,200 C 162,197 192,203 214,200`, col:impOn?C.imp:C.exp, on:impOn||expOn, rev:expOn },
|
{ id:'ep-grid', d:`M 164,224 C 183,220 185,228 204,224`, col:impOn?C.imp:C.exp, on:impOn||expOn, rev:expOn },
|
||||||
{ id:'ep-bat', d:`M 306,200 C 330,197 356,203 380,200`, col:C.bat, on:chOn||dchOn, rev:dchOn },
|
{ id:'ep-bat', d:`M 316,224 C 335,220 337,228 356,224`, col:C.bat, on:chOn||dchOn, rev:dchOn },
|
||||||
...(hasEV ? [{ id:'ep-ev', d:`M 260,244 C 258,261 262,269 260,279`, col:C.ev, on:evOn, rev:false }] : []),
|
...(hasEV ? [{ id:'ep-ev', d:`M 260,280 C 258,300 262,304 260,320`, col:C.ev, on:evOn, rev:false }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const defs = SEG.map(s => `<path id="${s.id}" d="${s.d}" fill="none"/>`).join('');
|
const defs = SEG.map(s => `<path id="${s.id}" d="${s.d}" fill="none"/>`).join('');
|
||||||
@@ -481,16 +488,16 @@ function renderEnergy(inverters, aggregates, period) {
|
|||||||
const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE';
|
const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE';
|
||||||
const batSub = batSoc != null ? Math.round(batSoc) + '%' : '';
|
const batSub = batSoc != null ? Math.round(batSoc) + '%' : '';
|
||||||
|
|
||||||
const svgH = hasEV ? 410 : 278;
|
const svgH = hasEV ? 460 : 310;
|
||||||
const svg = `<svg viewBox="0 0 520 ${svgH}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
|
const svg = `<svg viewBox="0 0 520 ${svgH}" width="100%" style="display:block" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>${defs}</defs>
|
<defs>${defs}</defs>
|
||||||
${lines}
|
${lines}
|
||||||
${dotsSvg}
|
${dotsSvg}
|
||||||
${node(260, 77, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
|
${node(260, 72, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
|
||||||
${node( 95, 200, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
|
${node(108, 224, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
|
||||||
${node(260, 200, 'house', 'HAUS', houseW, C.txt, true, '')}
|
${node(260, 224, 'house', 'HAUS', houseW, C.txt, true, '')}
|
||||||
${node(425, 200, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub)}
|
${node(412, 224, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub)}
|
||||||
${hasEV ? node(260, 323, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''}
|
${hasEV ? node(260, 376, 'ev', 'WALLBOX', evW, C.ev, evOn, '') : ''}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
period = period || {};
|
period = period || {};
|
||||||
@@ -525,8 +532,8 @@ function renderEnergy(inverters, aggregates, period) {
|
|||||||
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}</div>`;
|
<div class="energy-kwh" style="margin-bottom:16px">${imp}${exp}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cards = sectionCards('Diesen Monat', mon, C.imp) +
|
const cards = sectionCards(mon.label || 'Diesen Monat', mon, C.imp) +
|
||||||
sectionCards('Dieses Jahr', yr, C.imp);
|
sectionCards(yr.label || 'Dieses Jahr', yr, C.imp);
|
||||||
|
|
||||||
el.innerHTML = `<div class="energy-wrap">
|
el.innerHTML = `<div class="energy-wrap">
|
||||||
<div class="energy-svg-wrap">${svg}</div>
|
<div class="energy-svg-wrap">${svg}</div>
|
||||||
@@ -768,12 +775,16 @@ async function loadSettings() {
|
|||||||
document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
|
document.getElementById("cfg-mqtt-user").value = globalConfig.mqtt_user || "";
|
||||||
document.getElementById("cfg-price-import").value = globalConfig.price_import ?? 0.30;
|
document.getElementById("cfg-price-import").value = globalConfig.price_import ?? 0.30;
|
||||||
document.getElementById("cfg-price-export").value = globalConfig.price_export ?? 0.08;
|
document.getElementById("cfg-price-export").value = globalConfig.price_export ?? 0.08;
|
||||||
|
document.getElementById("cfg-billing-day").value = globalConfig.billing_day ?? 1;
|
||||||
|
document.getElementById("cfg-billing-month").value = globalConfig.billing_month ?? 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePrices() {
|
async function savePrices() {
|
||||||
const body = {
|
const body = {
|
||||||
price_import: parseFloat(document.getElementById("cfg-price-import").value),
|
price_import: parseFloat(document.getElementById("cfg-price-import").value),
|
||||||
price_export: parseFloat(document.getElementById("cfg-price-export").value),
|
price_export: parseFloat(document.getElementById("cfg-price-export").value),
|
||||||
|
billing_day: parseInt(document.getElementById("cfg-billing-day").value),
|
||||||
|
billing_month: parseInt(document.getElementById("cfg-billing-month").value),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await fetchJSON(api("api/config"), {
|
await fetchJSON(api("api/config"), {
|
||||||
|
|||||||
Reference in New Issue
Block a user