Feature: Eastron SDM-630 Support + Float32 Decode (v1.1.5)

- inverters.py: data_type Feld in Sensor (uint16/uint32/float32)
  + SDM-630 Definition (16 Sensoren: U/I/P L1-L3, PF, Freq, kWh)
  + read_ranges: [(0, 76)] — alle Sensoren in einem Batch
- modbus_client.py: Float32 IEEE 754 Decode via struct.unpack
  (SDM-630 liefert Floats, Growatt liefert skalierte Integer)
- index.html: "Wechselrichter" → "Gerät" — Add-on unterstützt
  jetzt beliebige Modbus-Geräte, nicht nur Wechselrichter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-04-26 17:07:14 +02:00
parent fd5625b99a
commit 33c6a15644
4 changed files with 54 additions and 17 deletions
+31 -1
View File
@@ -7,12 +7,13 @@ class Sensor:
id: str
name: str
reg: int
count: int # 1 = uint16, 2 = uint32 (high word first)
count: int # number of 16-bit registers (1 or 2)
scale: float
unit: str
device_class: Optional[str]
state_class: str
icon: str
data_type: str = "uint16" # "uint16", "uint32", "float32"
@dataclass
@@ -95,6 +96,28 @@ def _mod_sensors() -> List[Sensor]:
]
def _sdm630_sensors() -> List[Sensor]:
f = "float32"
return [
Sensor("voltage_l1", "Spannung L1", 0, 2, 1.0, "V", "voltage", "measurement", "mdi:flash", f),
Sensor("voltage_l2", "Spannung L2", 2, 2, 1.0, "V", "voltage", "measurement", "mdi:flash", f),
Sensor("voltage_l3", "Spannung L3", 4, 2, 1.0, "V", "voltage", "measurement", "mdi:flash", f),
Sensor("current_l1", "Strom L1", 6, 2, 1.0, "A", "current", "measurement", "mdi:flash", f),
Sensor("current_l2", "Strom L2", 8, 2, 1.0, "A", "current", "measurement", "mdi:flash", f),
Sensor("current_l3", "Strom L3", 10, 2, 1.0, "A", "current", "measurement", "mdi:flash", f),
Sensor("power_l1", "Wirkleistung L1", 12, 2, 1.0, "W", "power", "measurement", "mdi:flash", f),
Sensor("power_l2", "Wirkleistung L2", 14, 2, 1.0, "W", "power", "measurement", "mdi:flash", f),
Sensor("power_l3", "Wirkleistung L3", 16, 2, 1.0, "W", "power", "measurement", "mdi:flash", f),
Sensor("power_factor_l1", "Leistungsfaktor L1", 30, 2, 1.0, "", None, "measurement", "mdi:sine-wave", f),
Sensor("power_factor_l2", "Leistungsfaktor L2", 32, 2, 1.0, "", None, "measurement", "mdi:sine-wave", f),
Sensor("power_factor_l3", "Leistungsfaktor L3", 34, 2, 1.0, "", None, "measurement", "mdi:sine-wave", f),
Sensor("total_power", "Gesamtwirkleistung", 48, 2, 1.0, "W", "power", "measurement", "mdi:transmission-tower", f),
Sensor("frequency", "Frequenz", 70, 2, 1.0, "Hz", "frequency", "measurement", "mdi:sine-wave", f),
Sensor("import_kwh", "Bezug Gesamt", 72, 2, 1.0, "kWh", "energy", "total_increasing", "mdi:transmission-tower-import", f),
Sensor("export_kwh", "Einspeisung Gesamt", 74, 2, 1.0, "kWh", "energy", "total_increasing", "mdi:transmission-tower-export", f),
]
INVERTERS = {
"MIC_1500_TL_X": Inverter(
id="MIC_1500_TL_X",
@@ -124,4 +147,11 @@ INVERTERS = {
sensors=_mod_sensors(),
read_ranges=[(3, 91), (93, 1)],
),
"SDM_630": Inverter(
id="SDM_630",
name="Eastron SDM-630",
manufacturer="Eastron",
sensors=_sdm630_sensors(),
read_ranges=[(0, 76)], # regs 0-75, alle 16 Sensoren
),
}
+12 -5
View File
@@ -1,4 +1,5 @@
import logging
import struct
import time
from typing import Dict, Optional
@@ -66,12 +67,18 @@ def _extract_sensors(sensors: list, regs: Dict[int, int]) -> Dict[str, float]:
if s.reg not in regs:
log.warning("Register %d fehlt in Antwort (%s)", s.reg, s.id)
continue
if s.count == 2:
data_type = getattr(s, "data_type", "uint16")
if data_type == "float32":
if s.reg + 1 not in regs:
log.warning("Register %d (high word) fehlt (%s)", s.reg + 1, s.id)
log.warning("Register %d+1 fehlt (%s)", s.reg, s.id)
continue
raw = (regs[s.reg] << 16) | regs[s.reg + 1]
raw_val = struct.unpack(">f", struct.pack(">HH", regs[s.reg], regs[s.reg + 1]))[0]
elif s.count == 2:
if s.reg + 1 not in regs:
log.warning("Register %d+1 fehlt (%s)", s.reg, s.id)
continue
raw_val = ((regs[s.reg] << 16) | regs[s.reg + 1]) * s.scale
else:
raw = regs[s.reg]
values[s.id] = round(raw * s.scale, 3)
raw_val = regs[s.reg] * s.scale
values[s.id] = round(raw_val, 3)
return values
+10 -10
View File
@@ -161,7 +161,7 @@
<main>
<div class="tabs">
<div class="tab active" onclick="switchTab('live')">Live-Daten</div>
<div class="tab" onclick="switchTab('inverters')">Wechselrichter</div>
<div class="tab" onclick="switchTab('inverters')">Geräte</div>
<div class="tab" onclick="switchTab('settings')">Einstellungen</div>
</div>
@@ -195,11 +195,11 @@
<!-- Inverter Edit Modal -->
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<h2 id="modal-title">Wechselrichter hinzufügen</h2>
<h2 id="modal-title">Gerät hinzufügen</h2>
<input type="hidden" id="modal-id">
<div class="field"><label>Name</label>
<input type="text" id="modal-name" placeholder="z.B. Dach Süd"></div>
<div class="field"><label>Modell</label>
<div class="field"><label>Gerätetyp</label>
<select id="modal-model"></select></div>
<div class="field"><label>IP-Adresse</label>
<input type="text" id="modal-ip" placeholder="10.10.20.190"></div>
@@ -296,7 +296,7 @@ async function refreshData() {
document.getElementById("pill-mqtt").className = `pill ${d.mqtt_ok ? "ok" : "err"}`;
const keys = Object.keys(d.inverters || {});
document.getElementById("subtitle").textContent =
keys.length ? `${keys.length} Wechselrichter` : "Keine Wechselrichter";
keys.length ? `${keys.length} Gerät${keys.length !== 1 ? "e" : ""}` : "Keine Geräte";
renderLive(d.inverters || {});
} catch(e) {
document.getElementById("pill-mqtt").className = "pill err";
@@ -306,7 +306,7 @@ async function refreshData() {
function renderLive(inverters) {
const el = document.getElementById("live-content");
if (!Object.keys(inverters).length) {
el.innerHTML = '<div class="no-data">Keine Wechselrichter konfiguriert.<br>Bitte im Tab „Wechselrichter" hinzufügen.</div>';
el.innerHTML = '<div class="no-data">Keine Geräte konfiguriert.<br>Bitte im Tab „Geräte" hinzufügen.</div>';
return;
}
el.innerHTML = Object.values(inverters).map(inv => {
@@ -366,7 +366,7 @@ function renderInverterList() {
<div class="inv-card-header">
<div class="inv-card-icon">☀️</div>
<div class="inv-card-info">
<div class="inv-card-name">${esc(inv.name || "Wechselrichter")}</div>
<div class="inv-card-name">${esc(inv.name || "Gerät")}</div>
<div class="inv-card-model">${esc(model.name || inv.inverter_model)}</div>
</div>
</div>
@@ -381,7 +381,7 @@ function renderInverterList() {
</div>
</div>`;
}).join("");
el.innerHTML = cards + `<div class="add-btn" onclick="openModal()"> Wechselrichter hinzufügen</div>`;
el.innerHTML = cards + `<div class="add-btn" onclick="openModal()"> Gerät hinzufügen</div>`;
}
async function openModal(invId) {
@@ -389,7 +389,7 @@ async function openModal(invId) {
const modal = document.getElementById("modal-backdrop");
if (invId) {
const inv = invertersList.find(i => i.id === invId) || {};
document.getElementById("modal-title").textContent = "Wechselrichter bearbeiten";
document.getElementById("modal-title").textContent = "Gerät bearbeiten";
document.getElementById("modal-id").value = inv.id || "";
document.getElementById("modal-name").value = inv.name || "";
document.getElementById("modal-model").value = inv.inverter_model || "MIC_1500_TL_X";
@@ -399,7 +399,7 @@ async function openModal(invId) {
document.getElementById("modal-prefix").value = inv.mqtt_topic_prefix || "";
document.getElementById("modal-interval").value = inv.update_interval || 30;
} else {
document.getElementById("modal-title").textContent = "Wechselrichter hinzufügen";
document.getElementById("modal-title").textContent = "Gerät hinzufügen";
document.getElementById("modal-id").value = "";
document.getElementById("modal-name").value = "";
document.getElementById("modal-ip").value = "";
@@ -450,7 +450,7 @@ async function saveInverter() {
}
async function deleteInverter(id) {
if (!confirm("Wechselrichter wirklich löschen?")) return;
if (!confirm("Gerät wirklich löschen?")) return;
invertersList = invertersList.filter(i => i.id !== id);
try {
await fetchJSON(api("api/inverters-config"), {