Skip to content

Commit

Permalink
Read config settings from MQTT and on-the-fly changes (#328)
Browse files Browse the repository at this point in the history
* Feat/refactor config setting reads (#327)

* handle MQTT disconnects via not initializing with clean session
  • Loading branch information
reinhard-brandstaedter authored Dec 16, 2024
1 parent 4c84285 commit 95f0132
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 92 deletions.
4 changes: 2 additions & 2 deletions src/build.sh
Original file line number Diff line number Diff line change
@@ -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
docker image push rbrandstaedter/solarflow-control:local
208 changes: 126 additions & 82 deletions src/solarflow/solarflow-control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,7 +39,6 @@ def load_config():
config = load_config()



'''
Configuration Options
'''
Expand All @@ -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) \
Expand All @@ -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:
Expand All @@ -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"]
Expand All @@ -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)

Expand All @@ -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)
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 0 additions & 8 deletions src/solarflow/solarflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,23 +431,15 @@ 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))
case "masterSoftVersion":
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":
Expand Down

0 comments on commit 95f0132

Please sign in to comment.