Skip to content

Commit

Permalink
Merge pull request #165 from tomquist/add-mqtt-config-subscriber
Browse files Browse the repository at this point in the history
Support reconfiguring script via MQTT
  • Loading branch information
reserve85 authored Mar 12, 2024
2 parents 5934c51 + eee0a5f commit b7d677b
Show file tree
Hide file tree
Showing 5 changed files with 231 additions and 3 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## V1.87
### script
* Add support for dynamic reconfiguration of config parameters via MQTT
### config
* Add optional section '[MQTT_CONFIG]' to config file. If present, the script will listen for MQTT messages to reconfigure various parameters at runtime.

## V1.86
### script
* Prepare config to support dynamic reconfiguration of various parameters
Expand Down
14 changes: 12 additions & 2 deletions HoymilesZeroExport.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

__author__ = "Tobias Kraft"
__version__ = "1.86"
__version__ = "1.87"

import requests
import time
Expand All @@ -30,7 +30,7 @@
from packaging import version
import argparse
import subprocess
from config_provider import ConfigFileConfigProvider
from config_provider import ConfigFileConfigProvider, MqttConfigProvider, ConfigProviderChain

logging.basicConfig(
format='%(asctime)s %(levelname)-8s %(message)s',
Expand Down Expand Up @@ -1314,6 +1314,16 @@ def CreateDTU() -> DTU:
SLOW_APPROX_LIMIT = CastToInt(GetMaxWattFromAllInverters() * config.getint('COMMON', 'SLOW_APPROX_LIMIT_IN_PERCENT') / 100)

CONFIG_PROVIDER = ConfigFileConfigProvider(config)
if config.has_section("MQTT_CONFIG"):
broker = config.get("MQTT_CONFIG", "MQTT_BROKER")
port = config.getint("MQTT_CONFIG", "MQTT_PORT", fallback=1883)
client_id = config.get("MQTT_CONFIG", "MQTT_CLIENT_ID", fallback="HoymilesZeroExport")
username = config.get("MQTT_CONFIG", "MQTT_USERNAME", fallback=None)
password = config.get("MQTT_CONFIG", "MQTT_PASSWORD", fallback=None)
set_topic = config.get("MQTT_CONFIG", "MQTT_SET_TOPIC", fallback="zeropower/set")
reset_topic = config.get("MQTT_CONFIG", "MQTT_RESET_TOPIC", fallback="zeropower/reset")
mqtt_config_provider = MqttConfigProvider(broker, port, client_id, username, password, set_topic, reset_topic)
CONFIG_PROVIDER = ConfigProviderChain([mqtt_config_provider, CONFIG_PROVIDER])

try:
logger.info("---Init---")
Expand Down
20 changes: 19 additions & 1 deletion HoymilesZeroExport_Config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
# ---------------------------------------------------------------------

[VERSION]
VERSION = 1.86
VERSION = 1.87

[SELECT_DTU]
# --- define your DTU (only one) ---
Expand Down Expand Up @@ -200,6 +200,24 @@ VZL_PORT_INTERMEDIATE = 2081
# you need to specify the uuid of the vzlogger channel for the reading OBIS(16.7.0) (aktuelle Gesamtwirkleistung)
VZL_UUID_INTERMEDIATE = 06ec9562-a490-49fe-92ea-ffe0758d181c

# Uncomment the following section if you want to use MQTT to dynamically reconfigure some settings while the script is running
# [MQTT_CONFIG]
# MQTT_BROKER = localhost
# MQTT_PORT = 1883
# MQTT_CLIENT_ID = HoymilesZeroExport

# The script subscribes to the following topics:
# - zeropower/set/powermeter_target_point: To change the target point of the powermeter
# - zeropower/set/powermeter_max_point: To change the max point of the powermeter
# - zeropower/set/powermeter_tolerance: To change the tolerance of the powermeter
# - zeropower/set/on_grid_usage_jump_to_limit_percent: To change the on grid usage jump to limit percent
# - zeropower/set/inverter/0/min_watt_in_percent: To change the min watt in percent of the first inverter
# - zeropower/set/inverter/0/normal_watt: To change the battery normal watt of the first inverter
# - zeropower/set/inverter/0/reduce_watt: To change the battery reduce watt of the first inverter
# - zeropower/set/inverter/0/battery_priority: To change the battery priority of the first inverter
# MQTT_SET_TOPIC = zeropower/set
# MQTT_RESET_TOPIC = zeropower/reset

[COMMON]
# Number of Inverters
INVERTER_COUNT = 1
Expand Down
193 changes: 193 additions & 0 deletions config_provider.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import logging
from configparser import ConfigParser

logger = logging.getLogger()

class ConfigProvider:

Expand Down Expand Up @@ -101,3 +103,194 @@ def get_reduce_wattage(self, inverter_idx):

def get_battery_priority(self, inverter_idx):
return self.config.getint('INVERTER_' + str(inverter_idx + 1), 'HOY_BATTERY_PRIORITY')


class ConfigProviderChain(ConfigProvider):
"""
This class is a chain of config providers. It will call all the providers in the order they are given and return the
first non-None value.
This is useful if you want to combine multiple config sources, e.g. a config file and a MQTT topic.
"""
def __init__(self, providers):
self.providers = providers

def update(self):
for provider in self.providers:
provider.update()

def __getattribute__(self, name):
if name in ['update', 'providers']:
return object.__getattribute__(self, name)

def method(*args, **kwargs):
for provider in self.providers:
f = getattr(provider, name)
if callable(f):
value = f(*args, **kwargs)
if value is not None:
return value
return None
return method

class OverridingConfigProvider(ConfigProvider):
"""
This class is a config provider that allows to override the config values from code.
This can be used as a base class for config providers that allow to change the configuration
using a push mechanism, e.g. MQTT or a REST API.
"""
def __init__(self):
self.common_config = {}
self.inverter_config = []

@staticmethod
def cast_value(is_inverter_value, key, value):
if is_inverter_value:
if key in ['min_watt_in_percent', 'normal_watt', 'reduce_watt', 'battery_priority']:
return int(value)
else:
logger.error(f"Unknown inverter key {key}")
else:
if key in ['powermeter_target_point', 'powermeter_max_point', 'powermeter_tolerance', 'on_grid_usage_jump_to_limit_percent']:
return int(value)
else:
logger.error(f"Unknown common key {key}")

def set_common_value(self, name, value):
if value is None:
if name in self.common_config:
del self.common_config[name]
logger.info(f"Unset common config value {name}")
else:
cast_value = self.cast_value(False, name, value)
self.common_config[name] = cast_value
logger.info(f"Set common config value {name} to {cast_value}")

def set_inverter_value(self, inverter_idx: int, name: str, value):
if value is None:
if inverter_idx < len(self.inverter_config) and name in self.inverter_config[inverter_idx]:
del self.inverter_config[inverter_idx][name]
logger.info(f"Unset inverter {inverter_idx} config value {name}")
else:
while len(self.inverter_config) <= inverter_idx:
self.inverter_config.append({})
cast_value = self.cast_value(True, name, value)
self.inverter_config[inverter_idx][name] = cast_value
logger.info(f"Set inverter {inverter_idx} config value {name} to {cast_value}")

def get_powermeter_target_point(self):
return self.common_config.get('powermeter_target_point')

def get_powermeter_max_point(self):
return self.common_config.get('powermeter_max_point')

def get_powermeter_tolerance(self):
return self.common_config.get('powermeter_tolerance')

def on_grid_usage_jump_to_limit_percent(self):
return self.common_config.get('on_grid_usage_jump_to_limit_percent')

def get_min_wattage_in_percent(self, inverter_idx):
if inverter_idx >= len(self.inverter_config):
return None
return self.inverter_config[inverter_idx].get('min_watt_in_percent')

def get_normal_wattage(self, inverter_idx):
if inverter_idx >= len(self.inverter_config):
return None
return self.inverter_config[inverter_idx].get('normal_watt')

def get_reduce_wattage(self, inverter_idx):
if inverter_idx >= len(self.inverter_config):
return None
return self.inverter_config[inverter_idx].get('reduce_watt')

def get_battery_priority(self, inverter_idx):
if inverter_idx >= len(self.inverter_config):
return None
return self.inverter_config[inverter_idx].get('battery_priority')


class MqttConfigProvider(OverridingConfigProvider):
"""
Config provider that subscribes to a MQTT topic and updates the configuration from the messages.
"""
def __init__(self, mqtt_broker, mqtt_port, client_id, mqtt_username, mqtt_password, set_topic, reset_topic):
super().__init__()
self.mqtt_broker = mqtt_broker
self.mqtt_port = mqtt_port
self.mqtt_username = mqtt_username
self.mqtt_password = mqtt_password
self.set_topic = set_topic
self.reset_topic = reset_topic
self.target_point = None
self.max_point = None
self.tolerance = None
self.on_grid_usage_jump_to_limit_percent = None
self.min_wattage_in_percent = []
self.normal_wattage = []
self.reduce_wattage = []
self.battery_priority = []

import paho.mqtt.client as mqtt
self.mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id)
self.mqtt_client.on_connect = self.on_connect
self.mqtt_client.on_message = self.on_message
if self.mqtt_username is not None:
self.mqtt_client.username_pw_set(self.mqtt_username, self.mqtt_password)
self.mqtt_client.connect(self.mqtt_broker, self.mqtt_port)
self.mqtt_client.loop_start()

def on_connect(self, client, userdata, flags, reason_code, properties):
print("Connected with result code " + str(reason_code))
client.subscribe(f"{self.set_topic}/#")
client.subscribe(f"{self.reset_topic}/#")

def on_message(self, client, userdata, msg):
try:
self.handle_message(msg)
except Exception as e:
logger.error(f"Error handling message {msg.topic}: {e}")

def handle_message(self, msg):
if msg.topic.startswith(self.set_topic):
topic_suffix = msg.topic[len(self.set_topic) + 1:]
logger.info(f"Received set message for config value {topic_suffix} with payload {msg.payload}")

def set_common_value(name):
self.set_common_value(name, msg.payload)

def set_inverter_value(inverter_idx, name):
self.set_inverter_value(inverter_idx, name, msg.payload)

elif msg.topic.startswith(self.reset_topic):
topic_suffix = msg.topic[len(self.reset_topic) + 1:]
logger.info(f"Received reset message for config value {topic_suffix}")

def set_common_value(name):
self.set_common_value(name, None)

def set_inverter_value(inverter_idx, name):
self.set_inverter_value(inverter_idx, name, None)
else:
logger.error(f"Invalid topic {msg.topic}")
return

if topic_suffix.startswith("inverter/"):
inverter_topic_suffix = topic_suffix[len("inverter/"):]

index_config_start_pos = inverter_topic_suffix.index("/")
if index_config_start_pos == -1:
logger.error(f"Invalid inverter config topic {msg.topic}")
return

inverter = int(inverter_topic_suffix[:index_config_start_pos])
key = inverter_topic_suffix[index_config_start_pos + 1:]
set_inverter_value(inverter, key)
else:
set_common_value(topic_suffix)

def __del__(self):
logger.info("Disconnecting MQTT client")
self.mqtt_client.disconnect()
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ idna==3.4
packaging==23.2
requests==2.31.0
urllib3==2.1.0
paho-mqtt==2.0.0

0 comments on commit b7d677b

Please sign in to comment.