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:
+72
-4
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user