From 95f0132f5e87047048fc25b6495bfb542fad22ff Mon Sep 17 00:00:00 2001 From: "Reinhard Weber (Brandstaedter)" Date: Mon, 16 Dec 2024 15:24:54 +0100 Subject: [PATCH] Read config settings from MQTT and on-the-fly changes (#328) * Feat/refactor config setting reads (#327) * handle MQTT disconnects via not initializing with clean session --- src/build.sh | 4 +- src/solarflow/solarflow-control.py | 208 +++++++++++++++++------------ src/solarflow/solarflow.py | 8 -- 3 files changed, 128 insertions(+), 92 deletions(-) diff --git a/src/build.sh b/src/build.sh index 2eeb3ec..7ac918d 100644 --- a/src/build.sh +++ b/src/build.sh @@ -1,4 +1,4 @@ branch=`git rev-parse --abbrev-ref HEAD` -docker build -t rbrandstaedter/solarflow-control:$branch . +docker build -t rbrandstaedter/solarflow-control:local . -docker image push rbrandstaedter/solarflow-control:$branch \ No newline at end of file +docker image push rbrandstaedter/solarflow-control:local \ No newline at end of file diff --git a/src/solarflow/solarflow-control.py b/src/solarflow/solarflow-control.py index aa7ab45..2ff8575 100644 --- a/src/solarflow/solarflow-control.py +++ b/src/solarflow/solarflow-control.py @@ -5,8 +5,6 @@ from astral import LocationInfo from astral.sun import sun import requests -#import geoip2.database -#from ip2geotools.databases.noncommercial import DbIpCity import configparser import math import solarflow @@ -41,7 +39,6 @@ def load_config(): config = load_config() - ''' Configuration Options ''' @@ -53,21 +50,26 @@ def load_config(): mqtt_port = config.getint('mqtt', 'mqtt_port', fallback=None) or os.environ.get('MQTT_PORT',1883) -DTU_TYPE = config.get('global', 'dtu_type', fallback=None) \ - or os.environ.get('DTU_TYPE',"OpenDTU") - -SMT_TYPE = config.get('global', 'smartmeter_type', fallback=None) \ - or os.environ.get('SMARTMETER_TYPE',"Smartmeter") +DTU_TYPE = config.get('global', 'dtu_type', fallback=None) or os.environ.get('DTU_TYPE',"OpenDTU") +SMT_TYPE = config.get('global', 'smartmeter_type', fallback=None) or os.environ.get('SMARTMETER_TYPE',"Smartmeter") # The amount of power that should be always reserved for charging, if available. Nothing will be fed to the house if less is produced -MIN_CHARGE_POWER = None #config.getint('control', 'min_charge_power', fallback=None) or int(os.environ.get('MIN_CHARGE_POWER',0)) +# MQTT config topic: solarflow-hub/control/minChargePower +# config.ini [control] min_charge_power +MIN_CHARGE_POWER = None # The maximum discharge level of the packSoc. Even if there is more demand it will not go beyond that -MAX_DISCHARGE_POWER = None #config.getint('control', 'max_discharge_power', fallback=None) or int(os.environ.get('MAX_DISCHARGE_POWER',145)) - -# battery SoC levels to consider the battery full or empty -BATTERY_LOW = None #config.getint('control', 'battery_low', fallback=None) or int(os.environ.get('BATTERY_LOW',0)) -BATTERY_HIGH = None #config.getint('control', 'battery_high', fallback=None) or int(os.environ.get('BATTERY_HIGH',100)) +# MQTT config topic: solarflow-hub/control/maxDischargePower +# config.ini [control] max_discharge_power +MAX_DISCHARGE_POWER = None + +# battery SoC levels for normal operation cycles (when not in charge through mode) +# MQTT config topic: solarflow-hub/control/batteryTargetSoCMin +# config.ini [control] battery_low +BATTERY_LOW = None +# MQTT config topic: solarflow-hub/control/batteryTargetSoCMax +# config.ini [control] battery_high +BATTERY_HIGH = None # the SoC that is required before discharging of the battery would start. To allow a bit of charging first in the morning. BATTERY_DISCHARGE_START = config.getint('control', 'battery_discharge_start', fallback=None) \ @@ -82,35 +84,28 @@ def load_config(): # this controls the internal calculation of limited growth for setting inverter limits INVERTER_START_LIMIT = 5 -# wether to limit the inverter or the solarflow hub -limit_inverter = config.getboolean('control', 'limit_inverter', fallback=None) \ - or bool(os.environ.get('LIMIT_INVERTER',False)) - -# interval for performing control steps +# interval/rate limit for performing control steps steering_interval = config.getint('control', 'steering_interval', fallback=None) \ or int(os.environ.get('STEERING_INTERVAL',15)) # flag, which can be set to allow discharging the battery during daytime -DISCHARGE_DURING_DAYTIME = None #config.getboolean('control', 'discharge_during_daytime', fallback=None) or bool(os.environ.get('DISCHARGE_DURING_DAYTIME',False)) +# MQTT config topic: solarflow-hub/control/dischargeDuringDaytime +# config.ini [control] discharge_during_daytime +DISCHARGE_DURING_DAYTIME = None #Adjustments possible to sunrise and sunset offset -SUNRISE_OFFSET = None #config.getint('control', 'sunrise_offset', fallback=60) or int(os.environ.get('SUNRISE_OFFSET',60)) -SUNSET_OFFSET = None #config.getint('control', 'sunset_offset', fallback=60) or int(os.environ.get('SUNSET_OFFSET',60)) +# MQTT config topic: solarflow-hub/control/sunriseOffset +# config.ini [control] sunrise_offset +SUNRISE_OFFSET = None +# MQTT config topic: solarflow-hub/control/sunsetOffset +# config.ini [control] sunset_offset +SUNSET_OFFSET = None # Location Info LAT = config.getfloat('global', 'latitude', fallback=None) or float(os.environ.get('LATITUDE',0)) LNG = config.getfloat('global', 'longitude', fallback=None) or float(os.environ.get('LONGITUDE',0)) location: LocationInfo -# topic for the current household consumption (e.g. from smartmeter): int Watts -# if there is no single topic wich aggregates multiple phases (e.g. shelly 3EM) you can specify the topic in an array like this -# topic_house = shellies/shellyem3/emeter/1/power, shellies/shellyem3/emeter/2/power, shellies/shellyem3/emeter/3/power -#topic_house = config.get('mqtt_telemetry_topics', 'topic_house', fallback=None) \ -# or os.environ.get('TOPIC_HOUSE',None) -#topics_house = [ t.strip() for t in topic_house.split(',')] if topic_house else [] - -client_id = f'solarflow-ctrl-{random.randint(0, 100)}' - lastTriggerTS:datetime = None class MyLocation: @@ -129,7 +124,40 @@ def getCoordinates(self) -> tuple: return (lat,lon) +def on_config_message(client, userdata, msg): + '''The MQTT client callback function for intial connects - mainly retained messages, where we are not yet fully up and running but still read potential config parameters from MQTT''' + + global SUNRISE_OFFSET, SUNSET_OFFSET, MIN_CHARGE_POWER, MAX_DISCHARGE_POWER, DISCHARGE_DURING_DAYTIME,BATTERY_LOW,BATTERY_HIGH + # handle own messages (control parameters) + if msg.topic.startswith('solarflow-hub') and "control" in msg.topic and msg.payload: + parameter = msg.topic.split('/')[-1] + value = msg.payload.decode() + match parameter: + case "sunriseOffset": + SUNRISE_OFFSET = int(value) + log.info(f'Found control/sunriseOffset, set SUNRISE_OFFSET to {SUNRISE_OFFSET} minutes') + case "sunsetOffset": + SUNSET_OFFSET = int(value) + log.info(f'Found control/sunsetOffset, set SUNSET_OFFSET to {SUNSET_OFFSET} minutes') + case "minChargePower": + MIN_CHARGE_POWER = int(value) + log.info(f'Found control/minChargePower, set MIN_CHARGE_POWER to {MIN_CHARGE_POWER}W') + case "maxDischargePower": + MAX_DISCHARGE_POWER = int(value) + log.info(f'Found control/maxDiscahrgePiwer, set MAX_DISCHARGE_POWER to {MAX_DISCHARGE_POWER}W') + case "dischargeDuringDaytime": + DISCHARGE_DURING_DAYTIME = str2bool(value) + log.info(f'Found control/dischargeDuringDaytime, set DISCHARGE_DURING_DAYTIME to {DISCHARGE_DURING_DAYTIME}') + case "batteryTargetSoCMin": + BATTERY_LOW = int(value) + log.info(f'Found control/batteryTargetSoCMin, set BATTERY_LOW to {BATTERY_LOW}%') + case "batteryTargetSoCMax": + BATTERY_HIGH = int(value) + log.info(f'Found control/batteryTargetSoCMax, set BATTERY_HIGH to {BATTERY_HIGH}%') + + def on_message(client, userdata, msg): + '''The MQTT client callback function for continous oepration, messages are delegated to hub, dtu and smartmeter handlers as well as own control parameter updates''' global SUNRISE_OFFSET, SUNSET_OFFSET, MIN_CHARGE_POWER, MAX_DISCHARGE_POWER, DISCHARGE_DURING_DAYTIME,BATTERY_LOW,BATTERY_HIGH #delegate message handling to hub,smartmeter, dtu smartmeter = userdata["smartmeter"] @@ -145,48 +173,39 @@ def on_message(client, userdata, msg): value = msg.payload.decode() match parameter: case "sunriseOffset": + log.info(f'Updating SUNRISE_OFFSET to {SUNRISE_OFFSET} minutes') if SUNRISE_OFFSET != int(value) else None SUNRISE_OFFSET = int(value) - log.info(f'Updating SUNRISE_OFFSET to {SUNRISE_OFFSET} minutes') case "sunsetOffset": + log.info(f'Updating SUNSET_OFFSET to {SUNSET_OFFSET} minutes') if SUNSET_OFFSET != int(value) else None SUNSET_OFFSET = int(value) - log.info(f'Updating SUNSET_OFFSET to {SUNSET_OFFSET} minutes') case "minChargePower": + log.info(f'Updating MIN_CHARGE_POWER to {MIN_CHARGE_POWER}W') if MIN_CHARGE_POWER != int(value) else None MIN_CHARGE_POWER = int(value) - log.info(f'Updating MIN_CHARGE_POWER to {MIN_CHARGE_POWER}W') case "maxDischargePower": - MAX_DISCHARGE_POWER = int(value) - log.info(f'Updating MAX_DISCHARGE_POWER to {MAX_DISCHARGE_POWER}W') + log.info(f'Updating MAX_DISCHARGE_POWER to {MAX_DISCHARGE_POWER}W') if MAX_DISCHARGE_POWER != int(value) else None + MAX_DISCHARGE_POWER = int(value) + case "controlBypass": + log.info(f'Updating control bypass to {value}') + hub.setControlBypass(value) + case "fullChargeInterval": + log.info(f'Updating full charge interval to {int(value)}hrs') + hub.updFullChargeInterval(int(value)) case "dischargeDuringDaytime": + log.info(f'Updating DISCHARGE_DURING_DAYTIME to {DISCHARGE_DURING_DAYTIME}') if DISCHARGE_DURING_DAYTIME != str2bool(value) else None DISCHARGE_DURING_DAYTIME = str2bool(value) - log.info(f'Updating DISCHARGE_DURING_DAYTIME to {DISCHARGE_DURING_DAYTIME}') case "batteryTargetSoCMin": + log.info(f'Updating BATTERY_LOW to {BATTERY_LOW}%') if BATTERY_LOW != int(value) else None BATTERY_LOW = int(value) - log.info(f'Updating BATTERY_LOW to {BATTERY_LOW}%') hub.updBatteryTargetSoCMin(BATTERY_LOW) case "batteryTargetSoCMax": + log.info(f'Updating BATTERY_HIGH to {BATTERY_HIGH}%') if BATTERY_HIGH != int(value) else None BATTERY_HIGH = int(value) - log.info(f'Updating BATTERY_HIGH to {BATTERY_HIGH}%') hub.updBatteryTargetSoCMax(BATTERY_HIGH) def on_connect(client, userdata, flags, rc): if rc == 0: log.info("Connected to MQTT Broker!") - hub = client._userdata['hub'] - - hub.subscribe() - hub.setBuzzer(False) - hub.setPvBrand(1) - hub.setInverseMaxPower(MAX_INVERTER_INPUT) - hub.setACMode() - - if hub.control_bypass: - hub.setBypass(False) - hub.setAutorecover(False) - inv = client._userdata['dtu'] - inv.subscribe() - smt = client._userdata['smartmeter'] - smt.subscribe() else: log.error("Failed to connect, return code %d\n", rc) @@ -197,22 +216,23 @@ def on_disconnect(client, userdata, rc): log.error("Disconnected from MQTT broker!") def connect_mqtt() -> mqtt_client: - client = mqtt_client.Client(client_id) + client_id = f'solarflow-ctrl-{random.randint(0, 100)}' + client = mqtt_client.Client(client_id=client_id, clean_session=False) if mqtt_user is not None and mqtt_pwd is not None: client.username_pw_set(mqtt_user, mqtt_pwd) client.on_connect = on_connect client.on_disconnect = on_disconnect + client.on_message = on_config_message client.connect(mqtt_host, mqtt_port) return client def subscribe(client: mqtt_client): - client.on_message = on_message topics = [ - f'solarflow-hub/+/control/#' + f'solarflow-hub/{sf_device_id}/control/#' ] for t in topics: client.subscribe(t) - log.info(f'SFControl subscribing: {t}') + log.info(f'SF Control subscribing: {t}') def limitedRise(x) -> int: rise = MAX_INVERTER_LIMIT-(MAX_INVERTER_LIMIT-INVERTER_START_LIMIT)*math.exp(-MAX_INVERTER_LIMIT/100000*x) @@ -446,6 +466,7 @@ def limitHomeInput(client: mqtt_client): Hub Limit: {hub_limit:.1f}W'.split())) def getOpts(configtype) -> dict: + '''Get the configuration options for a specific section from the global config.ini''' global config opts = {} for opt,opt_type in configtype.opts.items(): @@ -521,34 +542,71 @@ def updateConfigParams(client): def run(): - global BATTERY_HIGH, BATTERY_LOW - client = connect_mqtt() hub_opts = getOpts(solarflow.Solarflow) - hub = solarflow.Solarflow(client=client,callback=limit_callback,**hub_opts) - dtuType = getattr(dtus, DTU_TYPE) dtu_opts = getOpts(dtuType) - dtu = dtuType(client=client,ac_limit=MAX_INVERTER_LIMIT,callback=limit_callback,**dtu_opts) - smtType = getattr(smartmeters, SMT_TYPE) smt_opts = getOpts(smtType) + + client = connect_mqtt() + subscribe(client=client) + + log.info("Reading retained config settings from MQTT...") + log.info("Note: Solarflow Control persists initial configuration settings in your MQTT broker and will use those first (if found) to allow on-the-fly updates!") + log.info("If you want to override these values from your config.ini you need to clear those retained topics in your broker first!") + client.loop_start() + time.sleep(10) + + # if no config setting were found in MQTT (retained) then update config from config file + updateConfigParams(client) + + log.info("Control Parameters:") + log.info(f' MIN_CHARGE_POWER = {MIN_CHARGE_POWER}') + log.info(f' MAX_DISCHARGE_LEVEL = {MAX_DISCHARGE_POWER}') + log.info(f' MAX_INVERTER_LIMIT = {MAX_INVERTER_LIMIT}') + log.info(f' MAX_INVERTER_INPUT = {MAX_INVERTER_INPUT}') + log.info(f' SUNRISE_OFFSET = {SUNRISE_OFFSET}') + log.info(f' SUNSET_OFFSET = {SUNSET_OFFSET}') + log.info(f' BATTERY_LOW = {BATTERY_LOW}') + log.info(f' BATTERY_HIGH = {BATTERY_HIGH}') + log.info(f' BATTERY_DISCHARGE_START = {BATTERY_DISCHARGE_START}') + log.info(f' DISCHARGE_DURING_DAYTIME = {DISCHARGE_DURING_DAYTIME}') + + + hub = solarflow.Solarflow(client=client,callback=limit_callback,**hub_opts) + dtu = dtuType(client=client,ac_limit=MAX_INVERTER_LIMIT,callback=limit_callback,**dtu_opts) smt = smtType(client=client,callback=limit_callback, **smt_opts) client.user_data_set({"hub":hub, "dtu":dtu, "smartmeter":smt}) + + # switch the callback function for received MQTT messages to the delegating function client.on_message = on_message infotimer = RepeatedTimer(120, deviceInfo, client) - client.loop_read(20) - updateConfigParams(client) + # subscribe Hub, DTU and Smartmeter so that they can react on received messages + hub.subscribe() + dtu.subscribe() + smt.subscribe() + + # ensure that the hubs min/max battery levels are set upon startup according to configuration, adjustments will be done if required by CT mode hub.setBatteryHighSoC(BATTERY_HIGH) hub.setBatteryLowSoC(BATTERY_LOW) - client.loop_forever() + + # turn off the hub's buzzer (audio feedback for config settings change) + hub.setBuzzer(False) + # ensure hub's maximum inverter feed power is set according to configuration + hub.setInverseMaxPower(MAX_INVERTER_INPUT) + # ensure hub is in AC output mode + hub.setACMode() + # initially turn off bypass and disable auto-recover from bypass + if hub.control_bypass: + hub.setBypass(False) + hub.setAutorecover(False) def main(argv): global mqtt_host, mqtt_port, mqtt_user, mqtt_pwd global sf_device_id - global limit_inverter global location opts, args = getopt.getopt(argv,"hb:p:u:s:d:",["broker=","port=","user=","password="]) for opt, arg in opts: @@ -583,20 +641,6 @@ def main(argv): else: log.info(f'Solarflow Hub: {sf_product_id}/{sf_device_id}') - log.info(f'Limit via inverter: {limit_inverter}') - - log.info("Control Parameters:") - log.info(f' MIN_CHARGE_POWER = {MIN_CHARGE_POWER}') - log.info(f' MAX_DISCHARGE_LEVEL = {MAX_DISCHARGE_POWER}') - log.info(f' MAX_INVERTER_LIMIT = {MAX_INVERTER_LIMIT}') - log.info(f' MAX_INVERTER_INPUT = {MAX_INVERTER_INPUT}') - log.info(f' SUNRISE_OFFSET = {SUNRISE_OFFSET}') - log.info(f' SUNSET_OFFSET = {SUNSET_OFFSET}') - log.info(f' BATTERY_LOW = {BATTERY_LOW}') - log.info(f' BATTERY_HIGH = {BATTERY_HIGH}') - log.info(f' BATTERY_DISCHARGE_START = {BATTERY_DISCHARGE_START}') - log.info(f' DISCHARGE_DURING_DAYTIME = {DISCHARGE_DURING_DAYTIME}') - loc = MyLocation() if not LNG and not LAT: coordinates = loc.getCoordinates() diff --git a/src/solarflow/solarflow.py b/src/solarflow/solarflow.py index 607d63f..cd731eb 100644 --- a/src/solarflow/solarflow.py +++ b/src/solarflow/solarflow.py @@ -431,12 +431,8 @@ def handleMsg(self, msg): self.updBatterySoC(sn=sn, value=int(value)) case "minSoc": self.updMinSoC(int(value)) - case "batteryTargetSoCMin": - self.updBatteryTargetSoCMin(int(value)) case "socSet": self.updSocSet(int(value)) - case "batteryTargetSoCMax": - self.updBatteryTargetSoCMax(int(value)) case "totalVol": sn = msg.topic.split('/')[-2] self.updBatteryVol(sn=sn, value=int(value)) @@ -444,10 +440,6 @@ def handleMsg(self, msg): self.updMasterSoftVersion(value=int(value)) case "chargeThrough": self.setChargeThrough(value) - case "controlBypass": - self.setControlBypass(value) - case "fullChargeInterval": - self.updFullChargeInterval(int(value)) case "dryRun": self.setDryRun(value) case "lastFullTimestamp":