From a2d19506c41dcf64a5f94a0dc37a6cd5f95a37ec Mon Sep 17 00:00:00 2001 From: Matt Redfearn Date: Tue, 14 Sep 2021 13:03:24 +0100 Subject: [PATCH] Add support for reading/writing the Storedge control registers Extend the integration to create number & select entities for the Storedge control registers. Through these registers one can control the battery charge / discharge profile. --- .../solaredge_modbus/__init__.py | 69 +++++++++- custom_components/solaredge_modbus/const.py | 41 ++++++ custom_components/solaredge_modbus/number.py | 122 ++++++++++++++++++ custom_components/solaredge_modbus/select.py | 114 ++++++++++++++++ 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 custom_components/solaredge_modbus/number.py create mode 100644 custom_components/solaredge_modbus/select.py diff --git a/custom_components/solaredge_modbus/__init__.py b/custom_components/solaredge_modbus/__init__.py index ae0ef57..48fb007 100644 --- a/custom_components/solaredge_modbus/__init__.py +++ b/custom_components/solaredge_modbus/__init__.py @@ -30,7 +30,10 @@ DEFAULT_READ_METER3, DEFAULT_READ_BATTERY1, DEFAULT_READ_BATTERY2, - BATTERY_STATUSSES + BATTERY_STATUSSES, + STOREDGE_CONTROL_MODE, + STOREDGE_AC_CHARGE_POLICY, + STOREDGE_CHARGE_DISCHARGE_MODE ) _LOGGER = logging.getLogger(__name__) @@ -55,7 +58,7 @@ {DOMAIN: vol.Schema({cv.slug: SOLAREDGE_MODBUS_SCHEMA})}, extra=vol.ALLOW_EXTRA ) -PLATFORMS = ["sensor"] +PLATFORMS = ["number", "select", "sensor"] async def async_setup(hass, config): @@ -194,6 +197,12 @@ def read_holding_registers(self, unit, address, count): kwargs = {"unit": unit} if unit else {} return self._client.read_holding_registers(address, count, **kwargs) + def write_registers(self, unit, address, payload): + """Write registers.""" + with self._lock: + kwargs = {"unit": unit} if unit else {} + return self._client.write_registers(address, payload, **kwargs) + def calculate_value(self, value, sf): return value * 10 ** sf @@ -203,6 +212,7 @@ def read_modbus_data(self): and self.read_modbus_data_meter1() and self.read_modbus_data_meter2() and self.read_modbus_data_meter3() + and self.read_modbus_data_storage() and self.read_modbus_data_battery1() and self.read_modbus_data_battery2() ) @@ -641,6 +651,61 @@ def read_modbus_data_inverter(self): else: return False + def read_modbus_data_storage(self): + if not self.read_battery1 and not self.read_battery2: + return True + + storage_data = self.read_holding_registers(unit=1, address=57348, count=14) + if not storage_data.isError(): + decoder = BinaryPayloadDecoder.fromRegisters( + storage_data.registers, byteorder=Endian.Big,wordorder=Endian.Little + ) + + #0xE004 - 1 - storage control mode + storage_control_mode = decoder.decode_16bit_uint() + if storage_control_mode in STOREDGE_CONTROL_MODE: + self.data["storage_contol_mode"] = STOREDGE_CONTROL_MODE[storage_control_mode] + else: + self.data["storage_contol_mode"] = storage_control_mode + + #0xE005 - 1 - storage ac charge policy + storage_ac_charge_policy = decoder.decode_16bit_uint() + if storage_ac_charge_policy in STOREDGE_AC_CHARGE_POLICY: + self.data["storage_ac_charge_policy"] = STOREDGE_AC_CHARGE_POLICY[storage_ac_charge_policy] + else: + self.data["storage_ac_charge_policy"] = storage_ac_charge_policy + + #0xE006 - 2 - storage AC charge limit (kWh or %) + self.data["storage_ac_charge_limit"] = round(decoder.decode_32bit_float(), 3) + + #0xE008 - 2 - storage backup reserved capacity (%) + self.data["storage_backup_reserved"] = round(decoder.decode_32bit_float(), 3) + + #0xE00A - 1 - storage charge / discharge default mode + storage_default_mode = decoder.decode_16bit_uint() + if storage_default_mode in STOREDGE_CHARGE_DISCHARGE_MODE: + self.data["storage_default_mode"] = STOREDGE_CHARGE_DISCHARGE_MODE[storage_default_mode] + else: + self.data["storage_default_mode"] = storage_default_mode + + #0xE00B - 2- storage remote command timeout (seconds) + self.data["storage_remote_command_timeout"] = decoder.decode_32bit_uint() + + #0xE00D - 1 - storage remote command mode + storage_remote_command_mode = decoder.decode_16bit_uint() + if storage_remote_command_mode in STOREDGE_CHARGE_DISCHARGE_MODE: + self.data["storage_remote_command_mode"] = STOREDGE_CHARGE_DISCHARGE_MODE[storage_remote_command_mode] + else: + self.data["storage_remote_command_mode"] = storage_remote_command_mode + + #0xE00E - 2- storate remote charge limit + self.data["storage_remote_charge_limit"] = round(decoder.decode_32bit_float(), 3) + + #0xE010 - 2- storate remote discharge limit + self.data["storage_remote_discharge_limit"] = round(decoder.decode_32bit_float(), 3) + + return True + def read_modbus_data_battery1(self): if not self.read_battery1: return True diff --git a/custom_components/solaredge_modbus/const.py b/custom_components/solaredge_modbus/const.py index 535f96f..adce588 100644 --- a/custom_components/solaredge_modbus/const.py +++ b/custom_components/solaredge_modbus/const.py @@ -282,3 +282,44 @@ 6: "Idle", 10: "Sleep" } + +STOREDGE_CONTROL_MODE = { + 0: "Disabled", + 1: "Maximize Self Consumption", + 2: "Time of Use", + 3: "Backup Only", + 4: "Remote Control" +} + +STOREDGE_AC_CHARGE_POLICY = { + 0: "Disabled", + 1: "Always Allowed", + 2: "Fixed Energy Limit", + 3: "Percent of Production", +} + +STOREDGE_CHARGE_DISCHARGE_MODE = { + 0: "Off", + 1: "Charge from excess PV power only", + 2: "Charge from PV first", + 3: "Charge from PV and AC", + 4: "Maximize export", + 5: "Discharge to match load", + 7: "Maximize self consumption", +} + +STORAGE_SELECT_TYPES = [ + ["Storage Control Mode", "storage_contol_mode", 0xE004, STOREDGE_CONTROL_MODE], + ["Storage AC Charge Policy", "storage_ac_charge_policy", 0xE005, STOREDGE_AC_CHARGE_POLICY], + ["Storage Default Mode", "storage_default_mode", 0xE00A, STOREDGE_CHARGE_DISCHARGE_MODE], + ["Storage Remote Command Mode", "storage_remote_command_mode", 0xE00D, STOREDGE_CHARGE_DISCHARGE_MODE], +] + +# TODO Determine the maximum values properly +STORAGE_NUMBER_TYPES = [ + ["Storage AC Charge Limit", "storage_ac_charge_limit", 0xE006, "f", {"min": 0, "max": 100000000000}], + ["Storage Backup reserved", "storage_backup_reserved", 0xE008, "f", {"min": 0, "max": 100, "unit": "%"}], + ["Storage Remote Command Timeout", "storage_remote_command_timeout", 0xE00B, "i", {"min": 0, "max": 86400, "unit": "s"}], + ["Storage Remote Charge Limit", "storage_remote_charge_limit", 0xE00E, "f", {"min": 0, "max": 20000, "unit": "W"}], + ["Storage Remote Discharge Limit", "storage_remote_discharge_limit", 0xE010, "f", {"min": 0, "max": 20000, "unit": "W"}], +] diff --git a/custom_components/solaredge_modbus/number.py b/custom_components/solaredge_modbus/number.py new file mode 100644 index 0000000..e7e1564 --- /dev/null +++ b/custom_components/solaredge_modbus/number.py @@ -0,0 +1,122 @@ +import logging +from typing import Optional, Dict, Any + +from .const import ( + DOMAIN, + ATTR_MANUFACTURER, + STORAGE_NUMBER_TYPES, +) + +from pymodbus.constants import Endian +from pymodbus.payload import BinaryPayloadBuilder + +from homeassistant.const import CONF_NAME +from homeassistant.components.number import ( + PLATFORM_SCHEMA, + NumberEntity, +) + +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + hub_name = entry.data[CONF_NAME] + hub = hass.data[DOMAIN][hub_name]["hub"] + + device_info = { + "identifiers": {(DOMAIN, hub_name)}, + "name": hub_name, + "manufacturer": ATTR_MANUFACTURER, + } + + entities = [] + + if hub.read_battery1 == True or hub.read_battery2 == True: + for number_info in STORAGE_NUMBER_TYPES: + number = SolarEdgeNumber( + hub_name, + hub, + device_info, + number_info[0], + number_info[1], + number_info[2], + number_info[3], + number_info[4], + ) + entities.append(number) + + async_add_entities(entities) + return True + +class SolarEdgeNumber(NumberEntity): + """Representation of an SolarEdge Modbus number.""" + + def __init__(self, + platform_name, + hub, + device_info, + name, + key, + register, + fmt, + attrs + ) -> None: + """Initialize the selector.""" + self._platform_name = platform_name + self._hub = hub + self._device_info = device_info + self._name = name + self._key = key + self._register = register + self._fmt = fmt + + self._attr_min_value = attrs["min"] + self._attr_max_value = attrs["max"] + if "unit" in attrs.keys(): + self._attr_unit_of_measurement = attrs["unit"] + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._hub.async_add_solaredge_sensor(self._modbus_data_updated) + + async def async_will_remove_from_hass(self) -> None: + self._hub.async_remove_solaredge_sensor(self._modbus_data_updated) + + @callback + def _modbus_data_updated(self) -> None: + self.async_write_ha_state() + + @property + def name(self) -> str: + """Return the name.""" + return f"{self._platform_name} ({self._name})" + + @property + def unique_id(self) -> Optional[str]: + return f"{self._platform_name}_{self._key}" + + @property + def should_poll(self) -> bool: + """Data is delivered by the hub""" + return False + + @property + def value(self) -> float: + if self._key in self._hub.data: + return self._hub.data[self._key] + + async def async_set_value(self, value: float) -> None: + """Change the selected value.""" + builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Little) + + if self._fmt == "i": + builder.add_32bit_uint(int(value)) + elif self._fmt == "f": + builder.add_32bit_float(float(value)) + + self._hub.write_registers(unit=1, address=self._register, payload=builder.to_registers()) + + self._hub.data[self._key] = value + self.async_write_ha_state() + diff --git a/custom_components/solaredge_modbus/select.py b/custom_components/solaredge_modbus/select.py new file mode 100644 index 0000000..42eab32 --- /dev/null +++ b/custom_components/solaredge_modbus/select.py @@ -0,0 +1,114 @@ +import logging +from typing import Optional, Dict, Any + +from .const import ( + DOMAIN, + ATTR_MANUFACTURER, + STORAGE_SELECT_TYPES, +) + +from homeassistant.const import CONF_NAME +from homeassistant.components.select import ( + PLATFORM_SCHEMA, + SelectEntity, +) + +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass, entry, async_add_entities) -> None: + hub_name = entry.data[CONF_NAME] + hub = hass.data[DOMAIN][hub_name]["hub"] + + device_info = { + "identifiers": {(DOMAIN, hub_name)}, + "name": hub_name, + "manufacturer": ATTR_MANUFACTURER, + } + + entities = [] + + if hub.read_battery1 == True or hub.read_battery2 == True: + for select_info in STORAGE_SELECT_TYPES: + select = SolarEdgeSelect( + hub_name, + hub, + device_info, + select_info[0], + select_info[1], + select_info[2], + select_info[3], + ) + entities.append(select) + + async_add_entities(entities) + return True + +def get_key(my_dict, search): + for k, v in my_dict.items(): + if v == search: + return k + return None + +class SolarEdgeSelect(SelectEntity): + """Representation of an SolarEdge Modbus select.""" + + def __init__(self, + platform_name, + hub, + device_info, + name, + key, + register, + options + ) -> None: + """Initialize the selector.""" + self._platform_name = platform_name + self._hub = hub + self._device_info = device_info + self._name = name + self._key = key + self._register = register + self._option_dict = options + + self._attr_options = list(options.values()) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._hub.async_add_solaredge_sensor(self._modbus_data_updated) + + async def async_will_remove_from_hass(self) -> None: + self._hub.async_remove_solaredge_sensor(self._modbus_data_updated) + + @callback + def _modbus_data_updated(self) -> None: + self.async_write_ha_state() + + @property + def name(self) -> str: + """Return the name.""" + return f"{self._platform_name} ({self._name})" + + @property + def unique_id(self) -> Optional[str]: + return f"{self._platform_name}_{self._key}" + + @property + def should_poll(self) -> bool: + """Data is delivered by the hub""" + return False + + @property + def current_option(self) -> str: + if self._key in self._hub.data: + return self._hub.data[self._key] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + new_mode = get_key(self._option_dict, option) + self._hub.write_registers(unit=1, address=self._register, payload=new_mode) + + self._hub.data[self._key] = option + self.async_write_ha_state() +