diff --git a/README.md b/README.md index 7ea565a..f704588 100644 --- a/README.md +++ b/README.md @@ -1 +1,115 @@ -# domoticz-solaredge-modbustcp-plugin \ No newline at end of file +# Domoticz SolarEdge_ModBusTCP plugin + +A Domoticz plugin to collect data from SolarEdge power inverters over ModbusTCP. + +The plugin uses the `solaredge_modbus` library () to communicate with the inverter. + +## Requirements + +The inverter needs to be connected to the network (either wired or wireless) and Modbus must be enabled on the device. Please consult the documentation of your inverter to find out how to enable Modbus. + +## Installation of the plugin + +This guide is based on Ubuntu 20.04 LTS: + +Install `domoticz`: + +``` shell +curl -sSL install.domoticz.com | sudo bash +``` + +Install `pip3`: + +``` shell +sudo apt install python3-pip +``` + +Install the `solaredge_modbus` library: + +``` shell +sudo pip3 install solaredge_modbus +``` + +Make sure that the `plugins` folder exists in the `domoticz` folder. + +Install the plugin itself: + +``` shell +cd domoticz/plugins +git clone https://github.com/addiejanssen/domoticz-solaredge-modbustcp-plugin.git +``` + +Once that is done, restart domoticz: + +``` shell +sudo service domoticz.sh restart +``` + +## Configuration in Domoticz + +Once the plugin is installed, a new hardware type will be available: `SolarEdge ModBusTCP`. + +To add the inverter, go to `Setup` -> `Hardware` and add the inverter: + +- Enter a `name` for the inverter. +- Select `SolarEdge ModBusTCP` from the `type` dropdown list. +- Enter the IP address of the inverter in the `Inverter IP Address` field. +- Enter the port number (default: 502) of the inverter in the `Inverter Port Number` field. +- Select `Yes` in the `Add missing devices` to create the devices when the inverter is added. Select `No` after deleting unwanted devices. Leaving the option set to `Yes` will recreate the deleted devices once Domoticz is restarted. +- Select an `Interval` (default: 5 seconds); this defines how often the plugin will collect the data from the inverter. Short intervals will result in more accurate values and graphs, but also result in more network traffic and a higher workload for both Domoticz and the inverter. +- Optionally turn on `Debug`; be aware: this will generate a lot of entries in the Domoticz log! +- `Add` the inverter. + +This should result in a lot of new devices in the `Setup` -> `Devices` menu. + +## Example + +Here's an example of information returned by an inverter (in this case a SE7000 3 phase inverter): + +| Name | Value | Symbol | +|-------------------|-----------------------|--------| +| Model | SE7K | | +| Version | 0003.2251 | | +| Serial | 7E2AB053 | | +| Modbus ID | 1 | | +| SunSpec DID | Three Phase Inverter | | +| Status | Producing | | +| Vendor Status | 0 | | +| Current | 3.59 | A | +| P1 Current | 1.21 | A | +| P2 Current | 1.17 | A | +| P3 Current | 1.20 | A | +| P1 Voltage | 401.4 | V | +| P2 Voltage | 401.2 | V | +| P3 Voltage | 402.8 | V | +| P1-N Voltage | 230.1 | V | +| P2-N Voltage | 232.7 | V | +| P3-N Voltage | 230.4 | V | +| Power | 784.0 | W | +| Frequency | 50.01 | Hz | +| Power (Apparent) | 829.1 | VA | +| Power (Reactive) | -270.00 | VAr | +| Power Factor | -94.46 | % | +| Total Energy | 10773388 | Wh | +| DC Current | 1.0647 | A | +| DC Voltage | 747.5 | V | +| DC Power | 795.9 | W | +| Temperature | 43.53 | °C | + +## Screenshots + +Hardware page showing a configured inverter. + +![](screenshots/Hardware.png) + +The devices page shows all Domotics devices that were created for the inverter. + +![](screenshots/Devices.png) + +The Domoticz Temperature page shows the inverters temperature data. + +![](screenshots/Temperature.png) + +All the other information is visible in the Domoticz Utility page. + +![](screenshots/Utility.png) \ No newline at end of file diff --git a/plugin.py b/plugin.py new file mode 100644 index 0000000..631c78a --- /dev/null +++ b/plugin.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# SolarEdge ModBusTCP +# +# Source: https://github.com/addiejanssen/domoticz-solaredge-modbustcp-plugin +# Author: Addie Janssen (https://addiejanssen.com) +# License: MIT +# Version: 1.0.0 +# + +""" + + + + + + + + + + + + + + + + + + +""" + +import Domoticz +import solaredge_modbus +import json + +from datetime import datetime, timedelta +from enum import IntEnum, unique, auto +from pymodbus.exceptions import ConnectionException + +# +# Domoticz shows graphs with intervals of 5 minutes. +# When collecting information from the inverter more frequently than that, then it makes no sense to only show the last value. +# +# The Average class can be used to calculate the average value based on a sliding window of samples. +# The number of samples stored depends on the interval used to collect the value from the inverter itself. +# + +class Average: + + def __init__(self): + self.samples = [] + self.max_samples = 30 + + def set_max_samples(self, max): + self.max_samples = max + if self.max_samples < 1: + self.max_samples = 1 + + def update(self, new_value, scale = 0): + self.samples.append(new_value * (10 ** scale)) + while (len(self.samples) > self.max_samples): + del self.samples[0] + + Domoticz.Debug("Average: {} - {} values".format(self.get(), len(self.samples))) + + def get(self): + return sum(self.samples) / len(self.samples) + +# +# Domoticz shows graphs with intervals of 5 minutes. +# When collecting information from the inverter more frequently than that, then it makes no sense to only show the last value. +# +# The Maximum class can be used to calculate the highest value based on a sliding window of samples. +# The number of samples stored depends on the interval used to collect the value from the inverter itself. +# + +class Maximum: + + def __init__(self): + self.samples = [] + self.max_samples = 30 + + def set_max_samples(self, max): + self.max_samples = max + if self.max_samples < 1: + self.max_samples = 1 + + def update(self, new_value, scale = 0): + self.samples.append(new_value * (10 ** scale)) + while (len(self.samples) > self.max_samples): + del self.samples[0] + + Domoticz.Debug("Maximum: {} - {} values".format(self.get(), len(self.samples))) + + def get(self): + return max(self.samples) + +# +# The Unit class lists all possible pieces of information that can be retrieved from the inverter. +# +# Not all inverters will support all these options. +# The class is used to generate a unique id for each device in Domoticz. +# + +@unique +class Unit(IntEnum): + + STATUS = 1 + VENDOR_STATUS = 2 + CURRENT = 3 + P1_CURRENT = 4 + P2_CURRENT = 5 + P3_CURRENT = 6 + P1_VOLTAGE = 7 + P2_VOLTAGE = 8 + P3_VOLTAGE = 9 + P1N_VOLTAGE = 10 + P2N_VOLTAGE = 11 + P3N_VOLTAGE = 12 + POWER_AC = 13 + FREQUENCY = 14 + POWER_APPARENT = 15 + POWER_REACTIVE = 16 + POWER_FACTOR = 17 + ENERGY_TOTAL = 18 + CURRENT_DC = 19 + VOLTAGE_DC = 20 + POWER_DC = 21 + TEMPERATURE = 22 + +# +# The plugin is using a few tables to setup Domoticz and to process the feedback from the inverter. +# The Column class is used to easily identify the columns in those tables. +# + +@unique +class Column(IntEnum): + + ID = 0 + NAME = 1 + TYPE = 2 + SUBTYPE = 3 + SWITCHTYPE = 4 + OPTIONS = 5 + MODBUSNAME = 6 + MODBUSSCALE = 7 + FORMAT = 8 + PREPEND = 9 + LOOKUP = 10 + MATH = 11 + +# +# This table represents a single phase inverter. +# + +SINGLE_PHASE_INVERTER = [ +# ID, NAME, TYPE, SUBTYPE, SWITCHTYPE, OPTIONS, MODBUSNAME, MODBUSSCALE, FORMAT, PREPEND, LOOKUP, MATH + [Unit.STATUS, "Status", 0xF3, 0x13, 0x00, {}, "status", None, "{}", None, solaredge_modbus.INVERTER_STATUS_MAP, None ], + [Unit.VENDOR_STATUS, "Vendor Status", 0xF3, 0x13, 0x00, {}, "vendor_status", None, "{}", None, None, None ], + [Unit.CURRENT, "Current", 0xF3, 0x17, 0x00, {}, 'current', 'current_scale', "{:.2f}", None, None, Average() ], + [Unit.P1_CURRENT, "P1 Current", 0xF3, 0x17, 0x00, {}, 'p1_current', 'current_scale', "{:.2f}", None, None, Average() ], + [Unit.P1_VOLTAGE, "P1 Voltage", 0xF3, 0x08, 0x00, {}, 'p1_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.P1N_VOLTAGE, "P1-N Voltage", 0xF3, 0x08, 0x00, {}, 'p1n_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_AC, "Power", 0xF8, 0x01, 0x00, {}, 'power_ac', 'power_ac_scale', "{:.2f}", None, None, Average() ], + [Unit.FREQUENCY, "Frequency", 0xF3, 0x1F, 0x00, { 'Custom': '1;Hz' }, 'frequency', 'frequency_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_APPARENT, "Power (Apparent)", 0xF3, 0x1F, 0x00, { 'Custom': '1;VA' }, 'power_apparent', 'power_apparent_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_REACTIVE, "Power (Reactive)", 0xF3, 0x1F, 0x00, { 'Custom': '1;VAr' }, 'power_reactive', 'power_reactive_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_FACTOR, "Power Factor", 0xF3, 0x06, 0x00, {}, 'power_factor', 'power_factor_scale', "{:.2f}", None, None, Average() ], + [Unit.ENERGY_TOTAL, "Total Energy", 0xF3, 0x1D, 0x04, {}, 'energy_total', 'energy_total_scale', "{};{}", Unit.POWER_AC, None, Average() ], + [Unit.CURRENT_DC, "DC Current", 0xF3, 0x17, 0x00, {}, 'current_dc', 'current_dc_scale', "{:.2f}", None, None, None ], + [Unit.VOLTAGE_DC, "DC Voltage", 0xF3, 0x08, 0x00, {}, 'voltage_dc', 'voltage_dc_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_DC, "DC Power", 0xF8, 0x01, 0x00, {}, 'power_dc', 'power_dc_scale', "{:.2f}", None, None, Average() ], + [Unit.TEMPERATURE, "Temperature", 0x50, 0x05, 0x00, {}, 'temperature', 'temperature_scale', "{:.2f}", None, None, Maximum() ] +] + +# +# This table represents a three phase inverter. +# + +THREE_PHASE_INVERTER = [ +# ID, NAME, TYPE, SUBTYPE, SWITCHTYPE, OPTIONS, MODBUSNAME, MODBUSSCALE, FORMAT, PREPEND, LOOKUP, MATH + [Unit.STATUS, "Status", 0xF3, 0x13, 0x00, {}, "status", None, "{}", None, solaredge_modbus.INVERTER_STATUS_MAP, None ], + [Unit.VENDOR_STATUS, "Vendor Status", 0xF3, 0x13, 0x00, {}, "vendor_status", None, "{}", None, None, None ], + [Unit.CURRENT, "Current", 0xF3, 0x17, 0x00, {}, 'current', 'current_scale', "{:.2f}", None, None, Average() ], + [Unit.P1_CURRENT, "P1 Current", 0xF3, 0x17, 0x00, {}, 'p1_current', 'current_scale', "{:.2f}", None, None, Average() ], + [Unit.P2_CURRENT, "P2 Current", 0xF3, 0x17, 0x00, {}, 'p2_current', 'current_scale', "{:.2f}", None, None, Average() ], + [Unit.P3_CURRENT, "P3 Current", 0xF3, 0x17, 0x00, {}, 'p3_current', 'current_scale', "{:.2f}", None, None, Average() ], + [Unit.P1_VOLTAGE, "P1 Voltage", 0xF3, 0x08, 0x00, {}, 'p1_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.P2_VOLTAGE, "P2 Voltage", 0xF3, 0x08, 0x00, {}, 'p2_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.P3_VOLTAGE, "P3 Voltage", 0xF3, 0x08, 0x00, {}, 'p3_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.P1N_VOLTAGE, "P1-N Voltage", 0xF3, 0x08, 0x00, {}, 'p1n_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.P2N_VOLTAGE, "P2-N Voltage", 0xF3, 0x08, 0x00, {}, 'p2n_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.P3N_VOLTAGE, "P3-N Voltage", 0xF3, 0x08, 0x00, {}, 'p3n_voltage', 'voltage_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_AC, "Power", 0xF8, 0x01, 0x00, {}, 'power_ac', 'power_ac_scale', "{:.2f}", None, None, Average() ], + [Unit.FREQUENCY, "Frequency", 0xF3, 0x1F, 0x00, { 'Custom': '1;Hz' }, 'frequency', 'frequency_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_APPARENT, "Power (Apparent)", 0xF3, 0x1F, 0x00, { 'Custom': '1;VA' }, 'power_apparent', 'power_apparent_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_REACTIVE, "Power (Reactive)", 0xF3, 0x1F, 0x00, { 'Custom': '1;VAr' }, 'power_reactive', 'power_reactive_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_FACTOR, "Power Factor", 0xF3, 0x06, 0x00, {}, 'power_factor', 'power_factor_scale', "{:.2f}", None, None, Average() ], + [Unit.ENERGY_TOTAL, "Total Energy", 0xF3, 0x1D, 0x04, {}, 'energy_total', 'energy_total_scale', "{};{}", Unit.POWER_AC, None, Average() ], + [Unit.CURRENT_DC, "DC Current", 0xF3, 0x17, 0x00, {}, 'current_dc', 'current_dc_scale', "{:.2f}", None, None, None ], + [Unit.VOLTAGE_DC, "DC Voltage", 0xF3, 0x08, 0x00, {}, 'voltage_dc', 'voltage_dc_scale', "{:.2f}", None, None, Average() ], + [Unit.POWER_DC, "DC Power", 0xF8, 0x01, 0x00, {}, 'power_dc', 'power_dc_scale', "{:.2f}", None, None, Average() ], + [Unit.TEMPERATURE, "Temperature", 0x50, 0x05, 0x00, {}, 'temperature', 'temperature_scale', "{:.2f}", None, None, Maximum() ] +] + +# +# The BasePlugin is the actual Domoticz plugin. +# This is where the fun starts :-) +# + +class BasePlugin: + + def __init__(self): + + # The _LOOKUP_TABLE will point to one of the tables above, depending on the type of inverter. + + self._LOOKUP_TABLE = None + + # This is the solaredge_modbus Inverter object that will be used to communicate with the inverter. + + self.inverter = None + + # Default heartbeat is 10 seconds; therefore 30 samples in 5 minutes. + + self.max_samples = 30 + + # Whether the plugin should add missing devices. + # If set to True, a deleted device will be added on the next restart of Domoticz. + + self.add_devices = False + + # When there is an issue contacting the inverter, the plugin will retry after a certain retry delay. + # The actual time after which the plugin will try again is stored in the retry after variable. + # According to the documenation, the inverter may need up to 2 minutes to "reset". + + self.retrydelay = timedelta(minutes = 2) + self.retryafter = datetime.now() - timedelta(seconds = 1) + + # + # onStart is called by Domoticz to start the processing of the plugin. + # + + def onStart(self): + + self.add_devices = bool(Parameters["Mode1"]) + + # Domoticz will generate graphs showing an interval of 5 minutes. + # Calculate the number of samples to store over a period of 5 minutes. + + self.max_samples = 300 / int(Parameters["Mode2"]) + + # Now set the interval at which the information is collected accordingly. + + Domoticz.Heartbeat(int(Parameters["Mode2"])) + + if Parameters["Mode6"] == "Debug": + Domoticz.Debugging(1) + else: + Domoticz.Debugging(0) + + Domoticz.Debug( + "onStart Address: {} Port: {}".format( + Parameters["Address"], + Parameters["Port"] + ) + ) + + self.inverter = solaredge_modbus.Inverter( + host=Parameters["Address"], + port=Parameters["Port"], + timeout=3, + unit=1 + ) + + # Lets get in touch with the inverter. + + self.contactInverter() + + + # + # OnHeartbeat is called by Domoticz at a specific interval as set in onStart() + # + + def onHeartbeat(self): + Domoticz.Debug("onHeartbeat") + + # We need to make sure that we have a table to work with. + # This will be set by contactInverter and will be None till it is clear + # that the inverter responds and that a matching table is available. + + if self._LOOKUP_TABLE: + + inverter_values = None + try: + inverter_values = self.inverter.read_all() + except ConnectionException: + inverter_values = None + Domoticz.Debug("ConnectionException") + else: + + if inverter_values: + + Domoticz.Debug(json.dumps(inverter_values, indent=4, sort_keys=True)) + + # Just for cosmetics in the log + + updated = 0 + device_count = 0 + + # Now process each unit in the table. + + for unit in self._LOOKUP_TABLE: + Domoticz.Debug(str(unit)) + + # Skip a unit when the matching device got deleted. + + if unit[Column.ID] in Devices: + Domoticz.Debug("-> found in Devices") + + # For certain units the table has a lookup table to replace the value with something else. + + if unit[Column.LOOKUP]: + Domoticz.Debug("-> looking up...") + lookup = unit[Column.LOOKUP] + value = lookup[inverter_values[unit[Column.MODBUSNAME]]] + + # When a math object is setup for the unit, update the samples in it and get the calculated value. + + elif unit[Column.MATH]: + Domoticz.Debug("-> calculating...") + m = unit[Column.MATH] + if unit[Column.MODBUSSCALE]: + m.update(inverter_values[unit[Column.MODBUSNAME]], inverter_values[unit[Column.MODBUSSCALE]]) + else: + m.update(inverter_values[unit[Column.MODBUSNAME]]) + + value = m.get() + + # When there is no math object then just store the latest value. + # Some values from the inverter need to be scaled before they can be stored. + + elif unit[Column.MODBUSSCALE]: + Domoticz.Debug("-> calculating...") + # we need to do some calculation here + value = inverter_values[unit[Column.MODBUSNAME]] * (10 ** inverter_values[unit[Column.MODBUSSCALE]]) + + # Some values require no action but storing in Domoticz. + + else: + Domoticz.Debug("-> copying...") + value = inverter_values[unit[Column.MODBUSNAME]] + + Domoticz.Debug("value = {}".format(value)) + + # Time to store the value in Domoticz. + # Some devices require multiple values, in which case the plugin will combine those values. + # Currently, there is only a need to prepend one value with another. + + if unit[Column.PREPEND]: + Domoticz.Debug("-> has prepend") + prepend = Devices[unit[Column.PREPEND]].sValue + Domoticz.Debug("prepend = {}".format(prepend)) + sValue = unit[Column.FORMAT].format(prepend, value) + else: + Domoticz.Debug("-> no prepend") + sValue = unit[Column.FORMAT].format(value) + + Domoticz.Debug("sValue = {}".format(sValue)) + + # Only store the value in Domoticz when it has changed. + # TODO: + # We should not store certain values when the inverter is sleeping. + # That results in a strange graph; it would be better just to skip it then. + + if sValue != Devices[unit[Column.ID]].sValue: + Devices[unit[Column.ID]].Update(nValue=0, sValue=str(sValue), TimedOut=0) + updated += 1 + + device_count += 1 + + else: + Domoticz.Debug("-> NOT found in Devices") + + Domoticz.Log("Updated {} values out of {}".format(updated, device_count)) + else: + Domoticz.Log("Inverter returned no information") + + # Try to contact the inverter when the lookup table is not yet initialized. + + else: + self.identifyInverter() + + + # + # Contact the inverter and find out what type it is. + # Initialize the lookup table when the type is supported. + # + + def contactInverter(self): + + # Do not stress the inverter when it did not respond in the previous attempt to contact it. + + if self.retryafter <= datetime.now(): + + # Here we go... + inverter_values = None + try: + inverter_values = self.inverter.read_all() + except ConnectionException: + + # There are multiple reasons why this may fail. + # - Perhaps the ip address or port are incorrect. + # - The inverter may not be connected to the networ, + # - The inverter may be turned off. + # - The inverter has a bad hairday.... + # Try again in the future. + + self.retryafter = datetime.now() + self.retrydelay + inverter_values = None + + Domoticz.Log("Connection Exception when trying to contact: {}:{}".format(Parameters["Address"], Parameters["Port"])) + Domoticz.Log("Retrying to communicate with inverter after: {}".format(self.retryafter)) + + else: + + if inverter_values: + Domoticz.Log("Connection established with: {}:{}".format(Parameters["Address"], Parameters["Port"])) + + inverter_type = solaredge_modbus.sunspecDID(inverter_values['c_sunspec_did']) + Domoticz.Log("Inverter type: {}".format(inverter_type)) + + # The plugin currently has 2 supported types. + # This may be updated in the future based on user feedback. + + if inverter_type == solaredge_modbus.sunspecDID.SINGLE_PHASE_INVERTER: + self._LOOKUP_TABLE = SINGLE_PHASE_INVERTER + elif inverter_type == solaredge_modbus.sunspecDID.THREE_PHASE_INVERTER: + self._LOOKUP_TABLE = THREE_PHASE_INVERTER + else: + Domoticz.Log("Unsupported inverter type: {}".format(inverter_type)) + + if self._LOOKUP_TABLE: + + # Set the number of samples on all the math objects. + + for unit in self._LOOKUP_TABLE: + if unit[Column.MATH]: + unit[Column.MATH].set_max_samples(self.max_samples) + + # Add missing devices if needed. + + if self.add_devices: + for unit in self._LOOKUP_TABLE: + if unit[Column.ID] not in Devices: + Domoticz.Device( + Unit=unit[Column.ID], + Name=unit[Column.NAME], + Type=unit[Column.TYPE], + Subtype=unit[Column.SUBTYPE], + Switchtype=unit[Column.SWITCHTYPE], + Options=unit[Column.OPTIONS], + Used=1, + ).Create() + else: + Domoticz.Log("Connection established with: {}:{}. BUT... inverter returned no information".format(Parameters["Address"], Parameters["Port"])) + Domoticz.Log("Retrying to communicate with inverter after: {}".format(self.retryafter)) + else: + Domoticz.Log("Retrying to communicate with inverter after: {}".format(self.retryafter)) + + +# +# Instantiate the plugin and register the supported callbacks. +# Currently that is only onStart() and onHeartbeat() +# + +global _plugin +_plugin = BasePlugin() + +def onStart(): + global _plugin + _plugin.onStart() + +def onHeartbeat(): + global _plugin + _plugin.onHeartbeat() diff --git a/screenshots/Devices.png b/screenshots/Devices.png new file mode 100644 index 0000000..01bf781 Binary files /dev/null and b/screenshots/Devices.png differ diff --git a/screenshots/Hardware.png b/screenshots/Hardware.png new file mode 100644 index 0000000..038a642 Binary files /dev/null and b/screenshots/Hardware.png differ diff --git a/screenshots/Temperature.png b/screenshots/Temperature.png new file mode 100644 index 0000000..6a37842 Binary files /dev/null and b/screenshots/Temperature.png differ diff --git a/screenshots/Utility.png b/screenshots/Utility.png new file mode 100644 index 0000000..e2b270f Binary files /dev/null and b/screenshots/Utility.png differ