diff --git a/CHANGELOG.md b/CHANGELOG.md index 103d3d8..b7d0770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## V1.95 +### script +* publish an online/offline status message to MQTT +* publish config state to MQTT +* publish the current (inverter) limit to MQTT +* publish logs to MQTT +### config +* replace `MQTT_CONFIG`:`MQTT_SET_TOPIC` and `MQTT_CONFIG`:`MQTT_RESET_TOPIC` with `MQTT_CONFIG`:`MQTT_TOPIC_PREFIX` +* add `MQTT_CONFIG`:`MQTT_LOG_LEVEL` - if set, log messages will be published to MQTT + ## V1.94 ### script * add script functionality for a super high priority limit change if your powermeter falls below POWERMETER_MIN_POINT (https://github.com/reserve85/HoymilesZeroExport/issues/200) diff --git a/HoymilesZeroExport.py b/HoymilesZeroExport.py index da47f94..0ca76a5 100644 --- a/HoymilesZeroExport.py +++ b/HoymilesZeroExport.py @@ -32,7 +32,7 @@ from packaging import version import argparse import subprocess -from config_provider import ConfigFileConfigProvider, MqttConfigProvider, ConfigProviderChain +from config_provider import ConfigFileConfigProvider, MqttHandler, ConfigProviderChain session = Session() logging.basicConfig( @@ -116,8 +116,12 @@ def SetLimitWithPriority(pLimit): logger.info("setting new limit to %s Watt",CastToInt(pLimit)) SetLimitWithPriority.LastLimit = CastToInt(pLimit) SetLimitWithPriority.LastLimitAck = True - if (CastToInt(pLimit) <= GetMinWattFromAllInverters()): + min_watt_all_inverters = GetMinWattFromAllInverters() + if (CastToInt(pLimit) <= min_watt_all_inverters): pLimit = 0 # set only minWatt for every inv. + PublishGlobalState("limit", min_watt_all_inverters) + else: + PublishGlobalState("limit", CastToInt(pLimit)) RemainingLimit = CastToInt(pLimit) for j in range (1,6): if GetMaxWattFromAllInvertersSamePrio(j) <= 0: @@ -146,6 +150,7 @@ def SetLimitWithPriority(pLimit): LASTLIMITACKNOWLEDGED[i] = True + PublishInverterState(i, "limit", NewLimit) DTU.SetLimit(i, NewLimit) if not DTU.WaitForAck(i, SET_LIMIT_TIMEOUT_SECONDS): SetLimitWithPriority.LastLimitAck = False @@ -171,8 +176,12 @@ def SetLimitMixedModeWithPriority(pLimit): logger.info("setting new limit to %s Watt",CastToInt(pLimit)) SetLimitMixedModeWithPriority.LastLimit = CastToInt(pLimit) SetLimitMixedModeWithPriority.LastLimitAck = True - if (CastToInt(pLimit) <= GetMinWattFromAllInverters()): + min_watt_all_inverters = GetMinWattFromAllInverters() + if (CastToInt(pLimit) <= min_watt_all_inverters): pLimit = 0 # set only minWatt for every inv. + PublishGlobalState("limit", min_watt_all_inverters) + else: + PublishGlobalState("limit", CastToInt(pLimit)) RemainingLimit = CastToInt(pLimit) # Handle non-battery inverters first @@ -202,6 +211,7 @@ def SetLimitMixedModeWithPriority(pLimit): LASTLIMITACKNOWLEDGED[i] = True + PublishInverterState(i, "limit", NewLimit) DTU.SetLimit(i, NewLimit) if not DTU.WaitForAck(i, SET_LIMIT_TIMEOUT_SECONDS): SetLimitMixedModeWithPriority.LastLimitAck = False @@ -242,6 +252,7 @@ def SetLimitMixedModeWithPriority(pLimit): LASTLIMITACKNOWLEDGED[i] = True + PublishInverterState(i, "limit", NewLimit) DTU.SetLimit(i, NewLimit) if not DTU.WaitForAck(i, SET_LIMIT_TIMEOUT_SECONDS): SetLimitMixedModeWithPriority.LastLimitAck = False @@ -304,8 +315,12 @@ def SetLimit(pLimit): logger.info("setting new limit to %s Watt",CastToInt(pLimit)) SetLimit.LastLimit = CastToInt(pLimit) SetLimit.LastLimitAck = True - if (CastToInt(pLimit) <= GetMinWattFromAllInverters()): + min_watt_all_inverters = GetMinWattFromAllInverters() + if (CastToInt(pLimit) <= min_watt_all_inverters): pLimit = 0 # set only minWatt for every inv. + PublishGlobalState("limit", min_watt_all_inverters) + else: + PublishGlobalState("limit", CastToInt(pLimit)) for i in range(INVERTER_COUNT): if (not AVAILABLE[i]) or (not HOY_BATTERY_GOOD_VOLTAGE[i]): continue @@ -322,6 +337,7 @@ def SetLimit(pLimit): LASTLIMITACKNOWLEDGED[i] = True + PublishInverterState(i, "limit", NewLimit) DTU.SetLimit(i, NewLimit) if not DTU.WaitForAck(i, SET_LIMIT_TIMEOUT_SECONDS): SetLimit.LastLimitAck = False @@ -622,6 +638,32 @@ def GetPriorityMode(): return True return False +def PublishConfigState(): + if MQTT is None: + return + MQTT.publish_state("on_grid_usage_jump_to_limit_percent", CONFIG_PROVIDER.on_grid_usage_jump_to_limit_percent()) + MQTT.publish_state("on_grid_feed_fast_limit_decrease", CONFIG_PROVIDER.on_grid_feed_fast_limit_decrease()) + MQTT.publish_state("powermeter_target_point", CONFIG_PROVIDER.get_powermeter_target_point()) + MQTT.publish_state("powermeter_max_point", CONFIG_PROVIDER.get_powermeter_max_point()) + MQTT.publish_state("powermeter_min_point", CONFIG_PROVIDER.get_powermeter_min_point()) + MQTT.publish_state("powermeter_tolerance", CONFIG_PROVIDER.get_powermeter_tolerance()) + MQTT.publish_state("inverter_count", INVERTER_COUNT) + for i in range(INVERTER_COUNT): + MQTT.publish_inverter_state(i, "min_watt_in_percent", CONFIG_PROVIDER.get_min_wattage_in_percent(i)) + MQTT.publish_inverter_state(i, "normal_watt", CONFIG_PROVIDER.get_normal_wattage(i)) + MQTT.publish_inverter_state(i, "reduce_watt", CONFIG_PROVIDER.get_reduce_wattage(i)) + MQTT.publish_inverter_state(i, "battery_priority", CONFIG_PROVIDER.get_battery_priority(i)) + +def PublishGlobalState(state_name, state_value): + if MQTT is None: + return + MQTT.publish_state(state_name, state_value) + +def PublishInverterState(inverter_idx, state_name, state_value): + if MQTT is None: + return + MQTT.publish_inverter_state(inverter_idx, state_name, state_value) + class Powermeter: def GetPowermeterWatts(self) -> int: raise NotImplementedError() @@ -1401,16 +1443,26 @@ def CreateDTU() -> DTU: SLOW_APPROX_LIMIT = CastToInt(GetMaxWattFromAllInverters() * config.getint('COMMON', 'SLOW_APPROX_LIMIT_IN_PERCENT') / 100) CONFIG_PROVIDER = ConfigFileConfigProvider(config) +MQTT = None 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]) + topic_prefix = config.get("MQTT_CONFIG", "MQTT_SET_TOPIC", fallback="zeropower") + log_level_config_value = config.get("MQTT_CONFIG", "MQTT_LOG_LEVEL", fallback=None) + mqtt_log_level = logging.getLevelName(log_level_config_value) if log_level_config_value else None + MQTT = MqttHandler(broker, port, client_id, username, password, topic_prefix, mqtt_log_level) + + if mqtt_log_level is not None: + class MqttLogHandler(logging.Handler): + def emit(self, record): + MQTT.publish_log_record(record) + + logger.addHandler(MqttLogHandler()) + + CONFIG_PROVIDER = ConfigProviderChain([MQTT, CONFIG_PROVIDER]) try: logger.info("---Init---") @@ -1433,6 +1485,7 @@ def CreateDTU() -> DTU: while True: CONFIG_PROVIDER.update() + PublishConfigState() on_grid_usage_jump_to_limit_percent = CONFIG_PROVIDER.on_grid_usage_jump_to_limit_percent() on_grid_feed_fast_limit_decrease = CONFIG_PROVIDER.on_grid_feed_fast_limit_decrease() powermeter_target_point = CONFIG_PROVIDER.get_powermeter_target_point() diff --git a/HoymilesZeroExport_Config.ini b/HoymilesZeroExport_Config.ini index 706b0bb..11b01cc 100644 --- a/HoymilesZeroExport_Config.ini +++ b/HoymilesZeroExport_Config.ini @@ -238,18 +238,9 @@ AMIS_READER_IP_INTERMEDIATE = xxx.xxx.xxx.xxx # MQTT_CLIENT_ID = HoymilesZeroExport # MQTT_USERNAME = # MQTT_PASSWORD = - -# 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 +# MQTT_TOPIC_PREFIX = zeropower +# Set the log level to publish logs to MQTT. Possible values are DEBUG, INFO, WARNING, ERROR, CRITICAL. +# MQTT_LOG_LEVEL = INFO [COMMON] # Number of Inverters diff --git a/README.md b/README.md index ded9648..5a0a9f9 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,39 @@ services: command: -c /app/config.ini ``` +## MQTT +The script can optionally be controlled via MQTT. To enable this feature, you need to configure the `[MQTT_CONFIG]` section in the configuration file. +Once configured, the script will listen for incoming MQTT messages on the specified topic and act accordingly. +- `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_min_point`: To change the min 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/on_grid_feed_fast_limit_decrease`: To enable or disable the on grid feed fast limit decrease +- `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 +- `zeropower/set/inverter//*`: To change the settings of the (n+1)th inverter + +To reset a setting to its original value, you can send an empty message to the corresponding topic replacing `set` with `reset`, e.g. `zeropower/reset/powermeter_target_point`. + +Additionally, the script will publish the following MQTT messages: +- `zeropower/status`: The current status of the script. Possible values are `online` and `offline` +- `zeropower/state/powermeter_target_point`: The current target point of the powermeter +- `zeropower/state/powermeter_max_point`: The current max point of the powermeter +- `zeropower/state/powermeter_min_point`: The current min point of the powermeter +- `zeropower/state/powermeter_tolerance`: The current tolerance of the powermeter +- `zeropower/state/on_grid_usage_jump_to_limit_percent`: The current on grid usage jump to limit percent +- `zeropower/state/on_grid_feed_fast_limit_decrease`: The current on grid feed fast limit decrease +- `zeropower/state/inverter/0/min_watt_in_percent`: The current min watt in percent of the first inverter +- `zeropower/state/inverter/0/normal_watt`: The current battery normal watt of the first inverter +- `zeropower/state/inverter/0/reduce_watt`: The current battery reduce watt of the first inverter +- `zeropower/state/inverter/0/battery_priority`: The current battery priority of the first inverter +- `zeropower/state/inverter//*`: The current settings of the (n+1)th inverter + +The script can also be configured to publish log messages to MQTT. To enable this feature, you need to set `MQTT_LOG_LEVEL` to `INFO`, which will publish all log messages to the topic `zeropower/log`. + ## Special thanks to: - https://github.com/lumapu/ahoy - https://github.com/tbnobody/OpenDTU diff --git a/config_provider.py b/config_provider.py index f543038..1620248 100644 --- a/config_provider.py +++ b/config_provider.py @@ -1,3 +1,4 @@ +import json import logging from configparser import ConfigParser @@ -239,31 +240,24 @@ def get_battery_priority(self, inverter_idx): return self.inverter_config[inverter_idx].get('battery_priority') -class MqttConfigProvider(OverridingConfigProvider): +class MqttHandler(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): + def __init__(self, mqtt_broker, mqtt_port, client_id, mqtt_username, mqtt_password, topic_prefix, log_level): 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.min_point = None - self.tolerance = None - self.on_grid_usage_jump_to_limit_percent = None - self.on_grid_feed_fast_limit_decrease = None - self.min_wattage_in_percent = [] - self.normal_wattage = [] - self.reduce_wattage = [] - self.battery_priority = [] + self.topic_prefix = topic_prefix + self.set_topic = f"{self.topic_prefix}/set" + self.reset_topic = f"{self.topic_prefix}/reset" + self.log_level = log_level import paho.mqtt.client as mqtt self.mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=client_id) + self.mqtt_client.will_set(f"{self.topic_prefix}/status", payload="offline", qos=1, retain=True) self.mqtt_client.on_connect = self.on_connect self.mqtt_client.on_message = self.on_message if self.mqtt_username is not None: @@ -271,10 +265,19 @@ def __init__(self, mqtt_broker, mqtt_port, client_id, mqtt_username, mqtt_passwo self.mqtt_client.connect(self.mqtt_broker, self.mqtt_port) self.mqtt_client.loop_start() + def update(self): + # Publish all config values to MQTT + for key, value in self.common_config.items(): + self.mqtt_client.publish(f"{self.topic_prefix}/state/{key}", payload=value, qos=1, retain=True) + for inverter_idx, inverter_config in enumerate(self.inverter_config): + for key, value in inverter_config.items(): + self.mqtt_client.publish(f"{self.topic_prefix}/state/inverter/{inverter_idx}/{key}", payload=value, qos=1, retain=True) + 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}/#") + client.publish(f"{self.topic_prefix}/status", payload="online", qos=1, retain=True) def on_message(self, client, userdata, msg): try: @@ -320,6 +323,35 @@ def set_inverter_value(inverter_idx, name): else: set_common_value(topic_suffix) + def cast_value_for_publish(self, value): + if type(value) == bool: + return "true" if value else "false" + return value + + def publish_state(self, key, value): + self.mqtt_client.publish(f"{self.topic_prefix}/state/{key}", payload=self.cast_value_for_publish(value)) + + def publish_inverter_state(self, inverter_idx, key, value): + self.mqtt_client.publish(f"{self.topic_prefix}/state/inverter/{inverter_idx}/{key}", payload=self.cast_value_for_publish(value)) + + def publish_log_record(self, record: logging.LogRecord): + if self.log_level is None or record.levelno < self.log_level: + return + + # Create a dictionary with the log record details + log_message = { + 'name': record.name, + 'level': record.levelname, + 'msg': record.getMessage(), + 'exc_info': record.exc_info + } + + # Convert the dictionary to a JSON-formatted string + json_payload = json.dumps(log_message) + + # Publish the JSON-formatted log message to the MQTT topic + self.mqtt_client.publish(f"{self.topic_prefix}/log", payload=json_payload) + def __del__(self): logger.info("Disconnecting MQTT client") self.mqtt_client.disconnect()