v1.8.8: Überschuss-Geräte (Zigbee2MQTT) + Bugfix Eigenversorgungskarte

- Neu: SurplusDeviceController — schaltet Z2M-Geräte bei PV-Überschuss
  ein/aus (Schwellwert + Hysterese pro Gerät, Background-Loop 30s)
- Neu: API GET/POST /api/surplus-devices, Konfig persistent in config.json
- Neu: Settings-Tab "Überschuss-Geräte", Live-Tab zeigt ON/OFF-Status
- Bugfix: Eigenversorgungskarte (Monat/Jahr) bleibt abends sichtbar wenn
  Wechselrichter offline — letzte kWh-Zähler werden als Fallback genutzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
retr0
2026-05-04 10:53:46 +02:00
parent ec6a0e8514
commit 83035fed0e
5 changed files with 246 additions and 9 deletions
+72 -4
View File
@@ -16,6 +16,7 @@ from goodwe_client import GoodweReader
from wallbox_client import WallboxReader
from ems_controller import EmsController
from mqtt_publisher import MqttPublisher
from surplus_devices import SurplusDeviceController
logging.basicConfig(
level=logging.INFO,
@@ -73,8 +74,13 @@ class State:
mqtt_cfg: Dict[str, Any] = {}
inverters_cfg: List[Dict[str, Any]] = []
inv_data: Dict[str, Dict[str, Any]] = {}
surplus_devices_cfg: List[Dict[str, Any]] = []
z2m_base: str = "zigbee2mqtt"
_publisher: Optional[MqttPublisher] = None
_surplus_ctrl: Optional[SurplusDeviceController] = None
_surplus_stop: threading.Event = threading.Event()
_surplus_thread: Optional[threading.Thread] = None
_threads: Dict[str, threading.Thread] = {}
_stop_events: Dict[str, threading.Event] = {}
@@ -95,6 +101,8 @@ def _defaults() -> Dict[str, Any]:
"spot_markup": 0.0,
"spot_chart": True,
"inverters": [],
"surplus_devices": [],
"z2m_base": "zigbee2mqtt",
}
def load_config() -> Dict[str, Any]:
@@ -131,19 +139,23 @@ def save_config():
"spot_markup": State.mqtt_cfg.get("spot_markup", 0.0),
"spot_chart": State.mqtt_cfg.get("spot_chart", True),
"inverters": State.inverters_cfg,
"surplus_devices": State.surplus_devices_cfg,
"z2m_base": State.z2m_base,
}
with open(CONFIG_PATH, "w") as f:
json.dump(data, f, indent=2)
# ── Aggregation ───────────────────────────────────────────────
def _compute_aggregates() -> Dict[str, float]:
def _compute_aggregates(allow_stale: bool = False) -> Dict[str, float]:
buckets: Dict[str, List[float]] = defaultdict(list)
with State.lock:
for inv_cfg in State.inverters_cfg:
inv_id = inv_cfg["id"]
d = State.inv_data.get(inv_id, {})
if not d.get("modbus_ok") or not d.get("values"):
if not d.get("values"):
continue
if not allow_stale and not d.get("modbus_ok"):
continue
values = d["values"]
for agg_id, sensor_ids in AGG_SENSOR_IDS.items():
@@ -315,8 +327,27 @@ def _stop_inverter(inv_id: str):
_stop_events.pop(inv_id, None)
_threads.pop(inv_id, None)
def _surplus_loop(stop: threading.Event):
while not stop.wait(30):
ctrl = _surplus_ctrl
if ctrl is None:
continue
surplus = _get_pv_surplus()
ctrl.update(surplus)
def _start_surplus_loop():
global _surplus_stop, _surplus_thread
_surplus_stop.set()
if _surplus_thread and _surplus_thread.is_alive():
_surplus_thread.join(timeout=5)
_surplus_stop = threading.Event()
_surplus_thread = threading.Thread(
target=_surplus_loop, args=(_surplus_stop,), daemon=True, name="surplus-loop"
)
_surplus_thread.start()
def _restart_all():
global _publisher
global _publisher, _surplus_ctrl
for inv_id in list(_threads.keys()):
_stop_inverter(inv_id)
if _publisher:
@@ -332,6 +363,13 @@ def _restart_all():
_publisher.connect()
time.sleep(2)
with State.lock:
devices = State.surplus_devices_cfg
z2m_base = State.z2m_base
_surplus_ctrl = SurplusDeviceController(_publisher, z2m_base)
_surplus_ctrl.set_config(devices, z2m_base)
_start_surplus_loop()
for inv_cfg in State.inverters_cfg:
_start_inverter(inv_cfg)
@@ -373,7 +411,7 @@ def api_save_config():
@app.get("/api/period-energy")
def api_period_energy():
import datetime
agg = _compute_aggregates()
agg = _compute_aggregates(allow_stale=True)
price_import = float(State.mqtt_cfg.get("price_import", 0.30))
price_export = float(State.mqtt_cfg.get("price_export", 0.08))
billing_day = int(State.mqtt_cfg.get("billing_day", 1))
@@ -450,6 +488,34 @@ def api_period_energy():
return jsonify(result)
@app.get("/api/surplus-devices")
def api_get_surplus_devices():
with State.lock:
devices = State.surplus_devices_cfg
z2m_base = State.z2m_base
states = _surplus_ctrl.get_states() if _surplus_ctrl else {}
return jsonify({"devices": devices, "z2m_base": z2m_base, "states": states})
@app.post("/api/surplus-devices")
def api_save_surplus_devices():
data = request.get_json(force=True) or {}
devices = data.get("devices", [])
z2m_base = str(data.get("z2m_base", "zigbee2mqtt")).strip() or "zigbee2mqtt"
if not isinstance(devices, list):
return jsonify({"error": "invalid"}), 400
for dev in devices:
if not isinstance(dev, dict) or not dev.get("z2m_name"):
return jsonify({"error": "z2m_name fehlt"}), 400
if "id" not in dev:
dev["id"] = uuid.uuid4().hex[:8]
with State.lock:
State.surplus_devices_cfg = devices
State.z2m_base = z2m_base
save_config()
if _surplus_ctrl:
_surplus_ctrl.set_config(devices, z2m_base)
return jsonify({"ok": True})
@app.get("/api/inverters-config")
def api_get_inverters():
with State.lock:
@@ -655,6 +721,8 @@ if __name__ == "__main__":
State.mqtt_cfg = {k: cfg[k] for k in
("mqtt_broker", "mqtt_port", "mqtt_user", "mqtt_pass")}
State.inverters_cfg = cfg.get("inverters", [])
State.surplus_devices_cfg = cfg.get("surplus_devices", [])
State.z2m_base = cfg.get("z2m_base", "zigbee2mqtt")
if not State.inverters_cfg and cfg.get("modbus_ip"):
State.inverters_cfg = [{
"id": uuid.uuid4().hex[:8],