adjust polling during daytime, retries with backoff and authentication
This commit is contained in:
194
src/main.py
194
src/main.py
@@ -4,7 +4,10 @@ import socket
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
import backoff
|
||||||
|
from suntime import Sun
|
||||||
import goodwe
|
import goodwe
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
@@ -16,106 +19,155 @@ MQTT_PORT = int(os.getenv("MQTT_PORT", 1883))
|
|||||||
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "goodwe")
|
MQTT_TOPIC = os.getenv("MQTT_TOPIC", "goodwe")
|
||||||
MQTT_USER = os.getenv("MQTT_USER", "goodwe")
|
MQTT_USER = os.getenv("MQTT_USER", "goodwe")
|
||||||
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "goodwe")
|
MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", "goodwe")
|
||||||
DISCOVERY_INTERVAL = int(os.getenv("DISCOVERY_INTERVAL", 60)) # seconds
|
POLLING_INTERVAL = int(os.getenv("POLLING_INTERVAL", 20))
|
||||||
BROADCAST_SUBNET = os.getenv("BROADCAST_SUBNET", "192.168.1.255") # directed broadcast
|
DISCOVERY_INTERVAL = int(os.getenv("DISCOVERY_INTERVAL", 60))
|
||||||
|
BROADCAST_SUBNET = os.getenv("BROADCAST_SUBNET", "192.168.1.255")
|
||||||
|
|
||||||
|
LATITUDE = float(os.getenv("LATITUDE", 52.3676))
|
||||||
|
LONGITUDE = float(os.getenv("LONGITUDE", 4.9041))
|
||||||
|
LOCAL_TZ = ZoneInfo(os.getenv("TIMEZONE", "Europe/Amsterdam"))
|
||||||
|
|
||||||
UDP_PORT = 48899
|
UDP_PORT = 48899
|
||||||
UDP_MSG = b"WIFIKIT-214028-READ"
|
UDP_MSG = b"WIFIKIT-214028-READ"
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# LOGGING SETUP
|
# LOGGING HELPER
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
logging.basicConfig(
|
class MqttLogger:
|
||||||
level=logging.INFO,
|
"""Helper to log to both standard logger and MQTT subtopics."""
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s"
|
def __init__(self, base_topic, mqtt_client):
|
||||||
)
|
self.logger = logging.getLogger("goodwe-daemon")
|
||||||
logger = logging.getLogger("goodwe-daemon")
|
self.client = mqtt_client
|
||||||
|
self.base_topic = f"{base_topic}/logs"
|
||||||
|
|
||||||
|
def _log(self, level, msg):
|
||||||
|
# Standard Logging
|
||||||
|
getattr(self.logger, level)(msg)
|
||||||
|
# MQTT Publishing (if connected)
|
||||||
|
if self.client.is_connected():
|
||||||
|
self.client.publish(f"{self.base_topic}/{level}", msg)
|
||||||
|
|
||||||
|
def debug(self, msg): self._log("debug", msg)
|
||||||
|
def info(self, msg): self._log("info", msg)
|
||||||
|
def warning(self, msg): self._log("warning", msg)
|
||||||
|
def error(self, msg): self._log("error", msg)
|
||||||
|
|
||||||
|
# Global Logger Instance (initialized after MQTT client)
|
||||||
|
mqtt_log = None
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# MQTT CLIENT SETUP
|
# LOGIC HELPERS
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
|
def is_operational_hours():
|
||||||
|
sun = Sun(LATITUDE, LONGITUDE)
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
try:
|
||||||
|
sr = sun.get_sunrise_time().replace(tzinfo=timezone.utc)
|
||||||
|
ss = sun.get_sunset_time().replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Windows: 2 hours before sunrise until 2 hours after sunset
|
||||||
|
start_window = sr - timedelta(hours=2)
|
||||||
|
end_window = ss + timedelta(hours=2)
|
||||||
|
|
||||||
def on_connect(client, userdata, flags, reason_code, properties):
|
return start_window <= now_utc <= end_window
|
||||||
if reason_code == "Success":
|
except Exception as e:
|
||||||
logger.info("MQTT connected and authenticated")
|
if mqtt_log: mqtt_log.error(f"Error calculating sun times: {e}")
|
||||||
else:
|
return True
|
||||||
raise RuntimeError(f"MQTT connect/auth failed: {reason_code}")
|
|
||||||
|
|
||||||
mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
|
||||||
mqtt_client.username_pw_set(
|
|
||||||
username=MQTT_USER,
|
|
||||||
password=MQTT_PASSWORD,
|
|
||||||
)
|
|
||||||
mqtt_client.on_connect = on_connect
|
|
||||||
mqtt_client.connect(MQTT_BROKER, MQTT_PORT)
|
|
||||||
mqtt_client.loop_start()
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# HELPER FUNCTIONS
|
|
||||||
# -----------------------------
|
|
||||||
async def discover_inverters(timeout=3):
|
async def discover_inverters(timeout=3):
|
||||||
"""Discover inverters using UDP broadcast (non-root)."""
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
sock.settimeout(timeout)
|
sock.settimeout(timeout)
|
||||||
inverters = []
|
found_ips = []
|
||||||
try:
|
try:
|
||||||
sock.sendto(UDP_MSG, (BROADCAST_SUBNET, UDP_PORT))
|
sock.sendto(UDP_MSG, (BROADCAST_SUBNET, UDP_PORT))
|
||||||
while True:
|
while True:
|
||||||
data, addr = sock.recvfrom(1024)
|
data, addr = sock.recvfrom(1024)
|
||||||
inverters.append(addr[0])
|
found_ips.append(addr[0])
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
return list(set(inverters))
|
return list(set(found_ips))
|
||||||
|
|
||||||
async def read_runtime(ip):
|
|
||||||
"""Connect to a GoodWe inverter and read runtime data."""
|
|
||||||
try:
|
|
||||||
inverter = await goodwe.connect(ip)
|
|
||||||
runtime = await inverter.read_runtime_data()
|
|
||||||
print(runtime)
|
|
||||||
return runtime
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to read {ip}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def json_serializer(obj):
|
|
||||||
if isinstance(obj, datetime.datetime):
|
|
||||||
return obj.isoformat()
|
|
||||||
raise TypeError(f"Type {type(obj)} not serializable")
|
|
||||||
|
|
||||||
async def publish_runtime(ip):
|
|
||||||
runtime = await read_runtime(ip)
|
|
||||||
if runtime:
|
|
||||||
payload = {"ip": ip, "data": runtime}
|
|
||||||
result = mqtt_client.publish(MQTT_TOPIC, json.dumps(payload, default=json_serializer))
|
|
||||||
if result.rc != mqtt.MQTT_ERR_SUCCESS:
|
|
||||||
raise logger.warning(f"Publish failed: {result.rc}")
|
|
||||||
logger.info(f"Published runtime for {ip}")
|
|
||||||
|
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
# MAIN LOOP
|
# MAIN LOOP
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
async def main_loop():
|
def on_connect(client, userdata, flags, reason_code, properties):
|
||||||
while True:
|
if reason_code == "Success":
|
||||||
logger.info("Discovering inverters...")
|
logging.info("MQTT connected and authenticated")
|
||||||
inverters = await discover_inverters()
|
else:
|
||||||
if not inverters:
|
logging.error(f"MQTT connect/auth failed: {reason_code}")
|
||||||
logger.warning("No inverters found")
|
|
||||||
for ip in inverters:
|
async def main_loop():
|
||||||
await publish_runtime(ip)
|
global mqtt_log
|
||||||
await asyncio.sleep(DISCOVERY_INTERVAL)
|
|
||||||
|
# Standard logging setup for initial boot
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||||
|
|
||||||
|
# MQTT Setup
|
||||||
|
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
|
||||||
|
client.username_pw_set(username=MQTT_USER, password=MQTT_PASSWORD)
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.connect(MQTT_BROKER, MQTT_PORT)
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
# Initialize our helper
|
||||||
|
mqtt_log = MqttLogger(MQTT_TOPIC, client)
|
||||||
|
|
||||||
|
active_inverter = None
|
||||||
|
inverter_ip = None
|
||||||
|
|
||||||
|
# Redefine backoff here to use the mqtt_log
|
||||||
|
@backoff.on_exception(backoff.expo, Exception, max_tries=5,
|
||||||
|
on_backoff=lambda details: mqtt_log.warning(f"Retrying connection... {details['wait']:.1f}s"))
|
||||||
|
async def connect_with_retry(ip):
|
||||||
|
return await goodwe.connect(ip)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if not is_operational_hours():
|
||||||
|
mqtt_log.info("Outside operational hours (Night). Sleeping 10m.")
|
||||||
|
active_inverter = None
|
||||||
|
await asyncio.sleep(600)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if active_inverter is None:
|
||||||
|
mqtt_log.info("Starting discovery...")
|
||||||
|
ips = await discover_inverters()
|
||||||
|
|
||||||
|
if ips:
|
||||||
|
inverter_ip = ips[0]
|
||||||
|
try:
|
||||||
|
active_inverter = await connect_with_retry(inverter_ip)
|
||||||
|
mqtt_log.info(f"Connected to inverter at {inverter_ip}")
|
||||||
|
except Exception as e:
|
||||||
|
mqtt_log.error(f"Discovery failed to bind to {inverter_ip}: {e}")
|
||||||
|
active_inverter = None
|
||||||
|
else:
|
||||||
|
mqtt_log.debug(f"No inverters found. Retrying in {DISCOVERY_INTERVAL}s")
|
||||||
|
await asyncio.sleep(DISCOVERY_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
runtime_data = await active_inverter.read_runtime_data()
|
||||||
|
payload = {
|
||||||
|
"timestamp": datetime.now(LOCAL_TZ).isoformat(),
|
||||||
|
"inverter_ip": inverter_ip,
|
||||||
|
"sensors": {k: v for k, v in runtime_data.items()}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.publish(MQTT_TOPIC, json.dumps(payload, default=str))
|
||||||
|
mqtt_log.debug(f"Data published for {inverter_ip}")
|
||||||
|
|
||||||
|
await asyncio.sleep(POLLING_INTERVAL)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
mqtt_log.warning(f"Inverter {inverter_ip} lost connection: {e}")
|
||||||
|
active_inverter = None
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# ENTRY POINT
|
|
||||||
# -----------------------------
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
asyncio.run(main_loop())
|
asyncio.run(main_loop())
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Daemon stopped")
|
if mqtt_log: mqtt_log.info("Daemon stopping via KeyboardInterrupt")
|
||||||
mqtt_client.loop_stop()
|
|
||||||
mqtt_client.disconnect()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user