Fix+Redesign: Energie-Dashboard SVG-Icons + SDM-630 Aggregation (v1.6.1)

- SVG-Pfad-Icons statt Emoji (Solar, Netz, Haus, Batterie, Wallbox)
- Bezier-Kurven statt gerader Linien für Flussverbindungen
- Rounded-Rect Knoten mit farbigem Top-Border bei kWh-Karten
- SDM-630 grid_power Proxy: total_power -> grid_power (erkannt via import_kwh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-28 21:42:12 +02:00
parent 052b674d51
commit ef9f96e5d2
3 changed files with 93 additions and 56 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: ShineBridge name: ShineBridge
version: "1.6.0" version: "1.6.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
+5 -2
View File
@@ -229,11 +229,14 @@ def _poll_loop(inv_cfg: Dict[str, Any], stop: threading.Event):
t0 = time.time() t0 = time.time()
values = reader.read(inverter) values = reader.read(inverter)
# Growatt-Proxy: grid_power (positiv=Netzbezug) aus power_to_grid ableiten # Growatt-Proxy: grid_power aus power_to_grid (nur Einspeisung bekannt)
# wenn kein dedizierter Grid-Meter vorhanden (Goodwe setzt grid_power direkt)
if values and "grid_power" not in values and "power_to_grid" in values: if values and "grid_power" not in values and "power_to_grid" in values:
values["grid_power"] = -values["power_to_grid"] values["grid_power"] = -values["power_to_grid"]
# SDM-630-Proxy: total_power = Netzleistung (positiv=Bezug, negativ=Einspeisung)
if values and "grid_power" not in values and "import_kwh" in values and "total_power" in values:
values["grid_power"] = values["total_power"]
# EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln # EMS: PV-Überschuss aus anderen Geräten holen und Ladestrom regeln
if ems is not None and values is not None: if ems is not None and values is not None:
pv_surplus = _get_pv_surplus() pv_surplus = _get_pv_surplus()
+87 -53
View File
@@ -365,11 +365,11 @@ function renderEnergy(inverters, aggregates) {
return; return;
} }
const pvW = aggregates.total_pv_power || 0; const pvW = aggregates.total_pv_power || 0;
const gridW = aggregates.grid_power || 0; // + = Bezug, - = Einspeisung const gridW = aggregates.grid_power || 0;
const batChW = aggregates.bat_charge_power || 0; const batChW = aggregates.bat_charge_power || 0;
const batDchW = aggregates.bat_discharge_power|| 0; const batDchW = aggregates.bat_discharge_power || 0;
const batSoc = aggregates.bat_soc; const batSoc = aggregates.bat_soc;
let evW = 0; let evW = 0;
Object.entries(inverters).forEach(([id, inv]) => { Object.entries(inverters).forEach(([id, inv]) => {
@@ -380,78 +380,112 @@ function renderEnergy(inverters, aggregates) {
const gridImport = Math.max(0, gridW); const gridImport = Math.max(0, gridW);
const gridExport = Math.max(0, -gridW); const gridExport = Math.max(0, -gridW);
const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW); const houseW = Math.max(0, pvW + gridImport + batDchW - batChW - evW);
const hasBat = batSoc !== undefined && batSoc !== null;
const THRESH = 20;
const pvOn = pvW > THRESH;
const impOn = gridImport > THRESH;
const expOn = gridExport > THRESH;
const chOn = batChW > THRESH;
const dchOn = batDchW > THRESH;
const evOn = evW > THRESH;
const C = {
pv:'#f0c040', imp:'#f85149', exp:'#3fb950',
bat:'#bc8cff', ev:'#58a6ff', txt:'#e6edf3',
dim:'#8b949e', brd:'#30363d', s2:'#21262d',
};
function fW(w) { function fW(w) {
if (Math.abs(w) < 1) return '0 W'; const a = Math.abs(w);
if (Math.abs(w) >= 1000) return (w/1000).toFixed(2) + ' kW'; if (a < 5) return ' W';
return Math.round(w) + ' W'; if (a >= 1000) return (a/1000).toFixed(2) + ' kW';
return Math.round(a) + ' W';
} }
function node(cx, cy, icon, label, value, col, extra) { // SVG path icons (20×20 canvas, all stroke-based)
const ICONS = {
solar: `<circle cx="10" cy="10" r="3.2" fill="none" stroke-width="1.6"/>
<path d="M10,2v2.5M10,15.5v2.5M2,10h2.5M15.5,10h2.5M4.3,4.3l1.8,1.8M13.9,13.9l1.8,1.8M15.7,4.3l-1.8,1.8M6.1,13.9l-1.8,1.8" stroke-width="1.6" stroke-linecap="round"/>`,
grid: `<path d="M10,2 L4,17 M10,2 L16,17 M6,10.5 L14,10.5 M5,14 L15,14" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>`,
house: `<path d="M2,9.5 L10,2 L18,9.5" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M4,9.5 L4,18 L16,18 L16,9.5" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M8,18 L8,13 Q8,11 10,11 Q12,11 12,13 L12,18" stroke-width="1.6" stroke-linecap="round" fill="none"/>`,
bat: `<rect x="1.5" y="7" width="15" height="8" rx="2" fill="none" stroke-width="1.6"/>
<path d="M16.5,9.5 L18.5,9.5 L18.5,12.5 L16.5,12.5" stroke-width="1.6" stroke-linecap="round" fill="none"/>`,
ev: `<path d="M11.5,2 L5.5,11.5 L10,11.5 L8,20 L14.5,8.5 L10,8.5 Z" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" fill="none"/>`,
};
function icon(name, cx, cy, col) {
const sz = 18;
return `<g transform="translate(${(cx-sz/2).toFixed(1)},${(cy-sz/2).toFixed(1)}) scale(${(sz/20).toFixed(3)})" stroke="${col}" fill="none">${ICONS[name]}</g>`;
}
// Node: rounded rect 106×62
function node(cx, cy, iconName, label, valW, col, active, sublabel) {
const c = active ? col : C.dim;
const sw = active ? 1.5 : 1;
return `<g> return `<g>
<circle cx="${cx}" cy="${cy}" r="40" fill="var(--surface2)" stroke="${col}" stroke-width="1.5"/> <rect x="${cx-53}" y="${cy-31}" width="106" height="62" rx="11" fill="${C.s2}" stroke="${c}" stroke-width="${sw}"/>
<text x="${cx}" y="${cy-11}" text-anchor="middle" font-size="19" dominant-baseline="middle">${icon}</text> ${icon(iconName, cx, cy-12, c)}
<text x="${cx}" y="${cy+8}" text-anchor="middle" font-size="11" font-weight="700" fill="${col}" dominant-baseline="middle">${fW(value)}</text> <text x="${cx}" y="${cy+6}" text-anchor="middle" font-size="12" font-weight="700" fill="${active?col:C.txt}" dominant-baseline="middle" font-family="inherit">${fW(valW)}</text>
<text x="${cx}" y="${cy+24}" text-anchor="middle" font-size="9" fill="var(--text-dim)" dominant-baseline="middle">${label}</text> <text x="${cx}" y="${cy+21}" text-anchor="middle" font-size="9" fill="${C.dim}" dominant-baseline="middle" font-family="inherit" letter-spacing=".05em">${label}${sublabel?' '+sublabel:''}</text>
${extra||''}
</g>`; </g>`;
} }
const C = { acc:'var(--accent)', red:'var(--red)', grn:'var(--green)', pur:'var(--purple)', blu:'var(--blue)', dim:'var(--text-dim)', brd:'var(--border)' }; // Bezier flow path
function flow(d, col, active, reverse) {
// Flow line helpers if (!active) return `<path d="${d}" fill="none" stroke="${C.brd}" stroke-width="2" stroke-linecap="round" opacity=".35"/>`;
function fline(x1,y1,x2,y2, col, cls) { return `<path d="${d}" fill="none" stroke="${col}" stroke-width="3" stroke-linecap="round" class="flow-line${reverse?' reverse':''}"/>`;
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${col}" stroke-width="3" class="flow-line ${cls}"/>`;
} }
const hasBat = batSoc !== undefined && batSoc !== null; // Paths (bezier curves between node edges)
const pPV = `M 130,80 C 130,116 260,108 260,132`;
const pGrid = `M 390,80 C 390,116 260,108 260,132`;
const pBat = `M 207,162 C 165,162 130,204 130,236`;
const pEV = `M 313,162 C 355,162 390,204 390,236`;
const svg = `<svg viewBox="0 0 540 ${hasBat?300:240}" width="100%" style="display:block;padding:16px 20px" xmlns="http://www.w3.org/2000/svg"> const gridCol = impOn ? C.imp : expOn ? C.exp : C.dim;
<!-- PV → House --> const gridVal = impOn ? gridImport : expOn ? gridExport : 0;
${fline(168,78,237,126, pvW>10?C.acc:C.brd, pvW>10?'':'inactive')} const gridLbl = impOn ? 'NETZBEZUG' : expOn ? 'EINSPEISUNG' : 'NETZ';
const batVal = chOn ? batChW : dchOn ? batDchW : 0;
const batLbl = chOn ? 'LADEN' : dchOn ? 'ENTLADEN' : 'BATTERIE';
const batSub = hasBat ? Math.round(batSoc)+'%' : '';
<!-- Grid ↔ House: line goes grid→house for import, reverse for export --> const svgH = 310;
${fline(372,78,303,126, const svg = `<svg viewBox="0 0 520 ${svgH}" width="100%" style="display:block;padding:18px 16px" xmlns="http://www.w3.org/2000/svg">
gridImport>10?C.red:gridExport>10?C.grn:C.brd, ${flow(pPV, C.pv, pvOn, false)}
gridExport>10?'reverse':gridImport>10?'':'inactive')} ${flow(pGrid, gridCol, impOn||expOn, expOn)}
${hasBat ? flow(pBat, C.bat, chOn||dchOn, dchOn) : ''}
${hasBat ? `<!-- House ↔ Battery --> ${flow(pEV, C.ev, evOn, false)}
${fline(237,170,164,222, batChW>10||batDchW>10?C.pur:C.brd, batDchW>10?'reverse':batChW>10?'':'inactive')}` : ''} ${node(130, 50, 'solar', 'SOLAR', pvW, C.pv, pvOn, '')}
${node(390, 50, 'grid', gridLbl, gridVal, gridCol, impOn||expOn, '')}
<!-- House → Car --> ${node(260, 162, 'house', 'HAUS', houseW, C.txt, true, '')}
${fline(303,170,376,222, evW>10?C.blu:C.brd, evW>10?'':'inactive')} ${hasBat ? node(130, 265, 'bat', batLbl, batVal, C.bat, chOn||dchOn, batSub) : ''}
${node(390, 265, 'ev', 'WALLBOX', evW, C.ev, evOn, '')}
<!-- Nodes -->
${node(135, 60, '☀️', 'Solar', pvW, C.acc)}
${node(405, 60, '⚡', gridImport>10?'Netzbezug':gridExport>10?'Einspeisung':'Netz',
gridImport>10?gridImport:gridExport, gridImport>10?C.red:gridExport>10?C.grn:C.dim)}
${node(270, 148, '🏠', 'Haus', houseW, 'var(--text)')}
${hasBat ? node(135, 244, '🔋', `Batt. ${Math.round(batSoc)}%`,
batChW>batDchW?batChW:batDchW, C.pur) : ''}
${node(405, 244, '🚗', 'Wallbox', evW, evW>10?C.blu:C.dim)}
</svg>`; </svg>`;
function kwhCard(icon, label, val, col) { function kwhCard(label, val, col) {
if (val === undefined || val === null) return ''; if (val === undefined || val === null) return '';
const d = val >= 100 ? val.toFixed(0) : val >= 10 ? val.toFixed(1) : val.toFixed(2); const d = val >= 100 ? val.toFixed(0) : val >= 10 ? val.toFixed(1) : val.toFixed(2);
return `<div class="kwh-card"> return `<div class="kwh-card" style="border-top:3px solid ${col}">
<div class="kv" style="color:${col}">${d}</div> <div class="kv" style="color:${col}">${d} <span style="font-size:11px;font-weight:400;color:${C.dim}">kWh</span></div>
<div class="kl">${icon} ${label}</div> <div class="kl">${label}</div>
</div>`; </div>`;
} }
const cards = [ const cards = [
kwhCard('☀️','PV Heute kWh', aggregates.total_energy_today, C.acc), kwhCard('PV Heute', aggregates.total_energy_today, C.pv),
kwhCard('📥','Bezug kWh', aggregates.grid_import_kwh, C.red), kwhCard('Netzbezug', aggregates.grid_import_kwh, C.imp),
kwhCard('📤','Einspeisung kWh', aggregates.grid_export_kwh, C.grn), kwhCard('Einspeisung', aggregates.grid_export_kwh, C.exp),
kwhCard('⚡','Bat. Laden kWh', aggregates.bat_charge_total, C.pur), kwhCard('Bat. Laden', aggregates.bat_charge_total, C.bat),
kwhCard('🔋','Bat. Entl. kWh', aggregates.bat_discharge_total, C.pur), kwhCard('Bat. Entladen', aggregates.bat_discharge_total, C.bat),
].filter(Boolean).join(''); ].filter(Boolean).join('');
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>
${cards ? `<div class="energy-kwh">${cards}</div>` : ''} ${cards ? `<div class="energy-kwh" style="margin-top:14px">${cards}</div>` : ''}
</div>`; </div>`;
} }