Skip to content

Commit

Permalink
Merge pull request #203 from tomquist/add-mqtt-status
Browse files Browse the repository at this point in the history
Publish state to MQTT
  • Loading branch information
reserve85 authored Jun 3, 2024
2 parents 1682580 + a3ec339 commit 09913ad
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 34 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
69 changes: 61 additions & 8 deletions HoymilesZeroExport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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---")
Expand All @@ -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()
Expand Down
15 changes: 3 additions & 12 deletions HoymilesZeroExport_Config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -238,18 +238,9 @@ AMIS_READER_IP_INTERMEDIATE = xxx.xxx.xxx.xxx
# MQTT_CLIENT_ID = HoymilesZeroExport
# MQTT_USERNAME = <optional username>
# MQTT_PASSWORD = <optional 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
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<n>/*`: 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/<n>/*`: 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
Expand Down
60 changes: 46 additions & 14 deletions config_provider.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import logging
from configparser import ConfigParser

Expand Down Expand Up @@ -239,42 +240,44 @@ 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:
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 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:
Expand Down Expand Up @@ -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()

0 comments on commit 09913ad

Please sign in to comment.