├── hacs.json ├── .gitignore ├── images ├── example_meter.png ├── example_inverter.png ├── modbus_settings.png ├── example_batterystorage.png └── example_batterystorage0.png ├── .github ├── workflows │ ├── hassfest.yaml │ └── validate.yaml └── ISSUE_TEMPLATE ├── home-assistant └── actions │ └── hassfest@master ├── custom_components └── fronius_modbus │ ├── manifest.json │ ├── translations │ └── en.json │ ├── select.py │ ├── base.py │ ├── froniusmodbusclient_const.py │ ├── __init__.py │ ├── number.py │ ├── sensor.py │ ├── config_flow.py │ ├── const.py │ ├── hub.py │ ├── extmodbusclient.py │ └── froniusmodbusclient.py ├── README.md └── LICENSE.md /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Fronius Modbus", 3 | "homeassistant": "2024.4.0" 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/** 2 | __pycache__/** 3 | **/*.pyc 4 | .DS_Store 5 | **/.DS_Store 6 | -------------------------------------------------------------------------------- /images/example_meter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redpomodoro/fronius_modbus/HEAD/images/example_meter.png -------------------------------------------------------------------------------- /images/example_inverter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redpomodoro/fronius_modbus/HEAD/images/example_inverter.png -------------------------------------------------------------------------------- /images/modbus_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redpomodoro/fronius_modbus/HEAD/images/modbus_settings.png -------------------------------------------------------------------------------- /images/example_batterystorage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redpomodoro/fronius_modbus/HEAD/images/example_batterystorage.png -------------------------------------------------------------------------------- /images/example_batterystorage0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redpomodoro/fronius_modbus/HEAD/images/example_batterystorage0.png -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v3" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /home-assistant/actions/hassfest@master: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "fronius_modbus", 3 | "name": "Fronius Modbus", 4 | "codeowners": ["@redpomodoro"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/redpomodoro/fronius_modbus/", 8 | "iot_class": "local_polling", 9 | "issue_tracker": "https://github.com/redpomodoro/fronius_modbus/issues", 10 | "requirements": ["pymodbus>=3.11.1"], 11 | "version": "0.1.9" 12 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the issue you are experiencing** 11 | A clear and concise description of what the issue is. 12 | 13 | **Describe the expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **What version of Home Assistant Core is installed** 24 | YYYY.M.V 25 | 26 | **What version of Fronius Modbus integration is installed** 27 | Release V.V.V 28 | 29 | **What Inverter and extras are you using** 30 | Gen24: XXkW 31 | SmartMeter: XXX 32 | Battery System: XXX 33 | 34 | **Error message shown within Home Assistant log** 35 | ```22-03-10 09:47:47 ERROR (MainThread) [custom_components.fronius_modbus] MyErrorMessage``` 36 | 37 | **Diagnostics information of Fronius Modbus integration** 38 | Find and upload within HA Settings -> Devices & services -> Fronius Modbus -> Three dots -> Download diagnostics 39 | 40 | **Screenshots** 41 | Feel free to upload screenshots - this helps a lot to understand your issue. 42 | 43 | **Additional Information** 44 | Whatever you think might fit here 45 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "Fronius Modbus", 4 | "step": { 5 | "user": { 6 | "title": "Set up Fronius System", 7 | "description": "Please provide connection details to Fronius System.", 8 | "data": { 9 | "ip_address": "IP Address", 10 | "port": "Port", 11 | "scan_interval": "Scan Interval in Seconds", 12 | "inverter_modbus_unit_id": "Inverter Modbus Unit/Slave ID", 13 | "meter_modbus_unit_id": "Meter Modbus Unit/Slave ID" 14 | } 15 | } 16 | }, 17 | "error": { 18 | "cannot_connect": "Failed to connect", 19 | "invalid_port": "Invalid port number", 20 | "invalid_host": "Invalid host address", 21 | "unsupported_hardware": "Unsupported hardware found. See error log for details.", 22 | "unknown": "An unknown error occurred", 23 | "scan_interval_too_short": "Scan interval is too short. Minimum 5 seconds.", 24 | "modbus_address_conflict": "Modbus IDs are not unqiue" 25 | } 26 | }, 27 | "options": { 28 | "step": { 29 | "init": { 30 | "title": "Set up Fronius System", 31 | "description": "Set options for your Fronius System.", 32 | "data": { 33 | "ip_address": "IP Address", 34 | "port": "Port", 35 | "scan_interval": "Scan Interval in Seconds", 36 | "inverter_modbus_unit_id": "Inverter Modbus Unit/Slave ID", 37 | "meter_modbus_unit_id": "Meter Modbus Unit/Slave ID" 38 | } 39 | } 40 | }, 41 | "error": { 42 | "scan_interval_too_short": "Scan interval is too short. Minimum 10 seconds." 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /custom_components/fronius_modbus/select.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Dict, Any 3 | 4 | from .const import ( 5 | STORAGE_SELECT_TYPES, 6 | ENTITY_PREFIX, 7 | ) 8 | 9 | from homeassistant.core import callback 10 | from homeassistant.const import CONF_NAME 11 | from homeassistant.components.select import ( 12 | SelectEntity, 13 | ) 14 | 15 | from .hub import Hub 16 | from .base import FroniusModbusBaseEntity 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | async def async_setup_entry(hass, config_entry, async_add_entities) -> None: 21 | hub:Hub = config_entry.runtime_data 22 | 23 | entities = [] 24 | 25 | if hub.storage_configured: 26 | 27 | for select_info in STORAGE_SELECT_TYPES: 28 | select = FroniusModbusSelect( 29 | platform_name=ENTITY_PREFIX, 30 | hub=hub, 31 | device_info=hub.device_info_storage, 32 | name = select_info[0], 33 | key = select_info[1], 34 | options = select_info[2], 35 | ) 36 | entities.append(select) 37 | 38 | async_add_entities(entities) 39 | return True 40 | 41 | def get_key(my_dict, search): 42 | for k, v in my_dict.items(): 43 | if v == search: 44 | return k 45 | return None 46 | 47 | class FroniusModbusSelect(FroniusModbusBaseEntity, SelectEntity): 48 | """Representation of an Battery Storage select.""" 49 | 50 | @property 51 | def current_option(self) -> str: 52 | if self._key in self._hub.data: 53 | return self._hub.data[self._key] 54 | 55 | async def async_select_option(self, option: str) -> None: 56 | """Change the selected option.""" 57 | new_mode = get_key(self._options_dict, option) 58 | 59 | await self._hub.set_mode(new_mode) 60 | 61 | self._hub.data[self._key] = option 62 | #self._hub.storage_extended_control_mode = new_mode 63 | self.async_write_ha_state() -------------------------------------------------------------------------------- /custom_components/fronius_modbus/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from homeassistant.core import callback 3 | from .hub import Hub 4 | 5 | _LOGGER = logging.getLogger(__name__) 6 | 7 | class FroniusModbusBaseEntity(): 8 | """ """ 9 | _key = None 10 | _options_dict = None 11 | 12 | def __init__(self, platform_name, hub, device_info, name, key, device_class=None, state_class=None, unit=None, icon=None, entity_category=None, options=None, min=None, max=None, native_step=None, mode=None): 13 | self._platform_name = platform_name 14 | self._hub:Hub = hub 15 | self._key = key 16 | self._name = name 17 | self._unit_of_measurement = unit 18 | self._icon = icon 19 | self._device_info = device_info 20 | if not device_class is None: 21 | self._attr_device_class = device_class 22 | if not state_class is None: 23 | self._attr_state_class = state_class 24 | if not entity_category is None: 25 | self._attr_entity_category = entity_category 26 | if not options is None: 27 | self._options_dict = options 28 | self._attr_options = list(options.values()) 29 | if not min is None: 30 | self._attr_native_min_value = min 31 | if not max is None: 32 | self._attr_native_max_value = max 33 | if not native_step is None: 34 | self._attr_native_step = native_step 35 | if not mode is None: 36 | self._attr_mode = mode 37 | 38 | self._attr_has_entity_name = True 39 | self._attr_name = name 40 | self._attr_unique_id = f"{self._platform_name}_{self._key}" 41 | self._attr_device_info = device_info 42 | 43 | async def async_added_to_hass(self): 44 | """Register callbacks.""" 45 | self._hub.async_add_hub_entity(self._modbus_data_updated) 46 | 47 | async def async_will_remove_from_hass(self) -> None: 48 | self._hub.async_remove_hub_entity(self._modbus_data_updated) 49 | 50 | @callback 51 | def _modbus_data_updated(self): 52 | self.async_write_ha_state() 53 | 54 | @property 55 | def should_poll(self) -> bool: 56 | """Data is delivered by the hub""" 57 | return False 58 | 59 | @property 60 | def unit_of_measurement(self): 61 | """Return the unit of measurement.""" 62 | return self._unit_of_measurement 63 | 64 | @property 65 | def icon(self): 66 | """Return the sensor icon.""" 67 | return self._icon 68 | 69 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/froniusmodbusclient_const.py: -------------------------------------------------------------------------------- 1 | SUPPORTED_MANUFACTURERS = ['Fronius'] 2 | SUPPORTED_MODELS = ['Primo GEN24', 'Symo GEN24'] 3 | 4 | COMMON_ADDRESS = 40004 5 | INVERTER_ADDRESS = 40071 6 | NAMEPLATE_ADDRESS = 40123 7 | MPPT_ADDRESS = 40255 8 | METER_ADDRESS = 40071 9 | STORAGE_ADDRESS = 40345 10 | STORAGE_CONTROL_MODE_ADDRESS = 40348 11 | MINIMUM_RESERVE_ADDRESS = 40350 12 | DISCHARGE_RATE_ADDRESS = 40355 13 | CHARGE_RATE_ADDRESS = 40356 14 | 15 | # Manufacturer 16 | # Type 17 | # Firmware 18 | # Serial 19 | 20 | STORAGE_CONTROL_MODE = { 21 | 0: 'Auto', 22 | 1: 'Charge', 23 | 2: 'Discharge', 24 | 3: 'Change and Discharge', 25 | } 26 | 27 | CHARGE_STATUS = { 28 | 1: 'Off', 29 | 2: 'Empty', 30 | 3: 'Discharging', 31 | 4: 'Charging', 32 | 5: 'Full', 33 | 6: 'Holding', 34 | 7: 'Testing', 35 | } 36 | 37 | INVERTER_STATUS = { 38 | 1: 'Off', 39 | 2: 'Sleeping', 40 | 3: 'Starting', 41 | 4: 'Normal', 42 | 5: 'Throttled', 43 | 6: 'Shutdown', 44 | 7: 'Fault', 45 | 8: 'Standby', 46 | } 47 | 48 | INVERTER_CONTROLS = [ 49 | 'Power reduction', 50 | 'Constant reactive power', 51 | 'Constant power factor', 52 | ] 53 | 54 | INVERTER_EVENTS = [ 55 | 'Error', 56 | 'Warning', 57 | 'Info', 58 | ] 59 | 60 | FRONIUS_INVERTER_STATUS = { 61 | 1: 'Off', 62 | 2: 'Sleeping', 63 | 3: 'Starting', 64 | 4: 'Normal', 65 | 5: 'Throttled', 66 | 6: 'Shutdown', 67 | 7: 'Fault', 68 | 8: 'Standby', 69 | 9: 'No solarnet', 70 | 10: 'No inverter communication', 71 | 11: 'Overcurrent solarnet', 72 | 12: 'Firmware updating', 73 | 13: 'ACFI event', 74 | } 75 | 76 | CHARGE_GRID_STATUS = { 77 | 1: 'Disabled', 78 | 2: 'Enabled', 79 | } 80 | 81 | GRID_STATUS = { 82 | 0: 'Off grid', 83 | 1: 'Off grid operating', 84 | 2: 'On grid', 85 | 3: 'On grid operating', 86 | } 87 | 88 | CONNECTION_STATUS = [ 89 | 'Connected', 90 | 'Available', 91 | 'Operating', 92 | ] 93 | 94 | CONNECTION_STATUS_CONDENSED = { 95 | 0: 'Disconnected', 96 | 1: 'Connected', 97 | 3: 'Available', 98 | 7: 'Operating', 99 | } 100 | 101 | ECP_CONNECTION_STATUS = { 102 | 0: 'Disconnected', 103 | 1: 'Connected', 104 | } 105 | 106 | CONTROL_STATUS = { 107 | 0: 'Disabled', 108 | 1: 'Enabled', 109 | } 110 | 111 | STORAGE_EXT_CONTROL_MODE = { 112 | 0: 'Auto', 113 | 1: 'PV Charge Limit', 114 | 2: 'Discharge Limit', 115 | 3: 'PV Charge and Discharge Limit', 116 | 4: 'Charge from Grid', 117 | 5: 'Discharge to Grid', 118 | 6: 'Block Discharging', 119 | 7: 'Block Charging', 120 | # 8: 'Calibrate', 121 | } 122 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/__init__.py: -------------------------------------------------------------------------------- 1 | """The Fronius Modbus integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.const import Platform 10 | 11 | from homeassistant.const import CONF_NAME, CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL 12 | from .const import ( 13 | DOMAIN, 14 | CONF_INVERTER_UNIT_ID, 15 | CONF_METER_UNIT_ID, 16 | ) 17 | 18 | from . import hub 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | # List of platforms to support. There should be a matching .py file for each, 23 | # eg and 24 | PLATFORMS = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR] 25 | 26 | type HubConfigEntry = ConfigEntry[hub.Hub] 27 | 28 | async def async_setup_entry(hass: HomeAssistant, entry: HubConfigEntry) -> bool: 29 | """Set up Fronius Modbus from a config entry.""" 30 | 31 | name = entry.data[CONF_NAME] 32 | host = entry.data[CONF_HOST] 33 | name = entry.data[CONF_NAME] 34 | port = entry.data[CONF_PORT] 35 | inverter_unit_id = entry.data.get(CONF_INVERTER_UNIT_ID, 1) 36 | meter_unit_ids = [entry.data.get(CONF_METER_UNIT_ID, 1)] 37 | scan_interval = entry.data[CONF_SCAN_INTERVAL] 38 | 39 | _LOGGER.debug("Setup %s.%s", DOMAIN, name) 40 | 41 | # Store an instance of the "connecting" class that does the work of speaking 42 | # with your actual devices. 43 | entry.runtime_data = hub.Hub(hass = hass, name = name, host = host, port = port, inverter_unit_id=inverter_unit_id, meter_unit_ids=meter_unit_ids, scan_interval = scan_interval) 44 | 45 | await entry.runtime_data.init_data() 46 | 47 | # This creates each HA object for each platform your device requires. 48 | # It's done by calling the `async_setup_entry` function in each platform module. 49 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 50 | return True 51 | 52 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 53 | """Unload a config entry.""" 54 | # This is called when an entry/configured device is to be removed. The class 55 | # needs to unload itself, and remove callbacks. See the classes for further 56 | # details 57 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 58 | 59 | return unload_ok 60 | 61 | async def reload_service_handler(service: ServiceCall) -> None: 62 | """Remove all user-defined groups and load new ones from config.""" 63 | conf = None 64 | with contextlib.suppress(HomeAssistantError): 65 | conf = await async_integration_yaml_config(hass, DOMAIN) 66 | if conf is None: 67 | return 68 | await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) 69 | _async_setup_shared_data(hass) 70 | await _async_process_config(hass, conf) 71 | 72 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/number.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional, Dict, Any 3 | 4 | from .const import ( 5 | STORAGE_NUMBER_TYPES, 6 | ENTITY_PREFIX, 7 | ) 8 | 9 | from homeassistant.core import callback 10 | from homeassistant.const import CONF_NAME 11 | from homeassistant.components.number import ( 12 | NumberEntity, 13 | ) 14 | 15 | from .hub import Hub 16 | from .base import FroniusModbusBaseEntity 17 | 18 | _LOGGER = logging.getLogger(__name__) 19 | 20 | async def async_setup_entry(hass, config_entry, async_add_entities) -> None: 21 | hub:Hub = config_entry.runtime_data 22 | 23 | entities = [] 24 | 25 | if hub.storage_configured: 26 | 27 | for number_info in STORAGE_NUMBER_TYPES: 28 | 29 | max = None 30 | max_key = number_info[2].get('max_key') 31 | if not max_key is None: 32 | max = hub.data.get(max_key) 33 | if max is None: 34 | max = number_info[2]['max'] 35 | 36 | number = FroniusModbusNumber( 37 | ENTITY_PREFIX, 38 | hub, 39 | hub.device_info_storage, 40 | number_info[0], 41 | number_info[1], 42 | min = number_info[2]['min'], 43 | max = max, 44 | unit = number_info[2]['unit'], 45 | mode = number_info[2]['mode'], 46 | native_step = number_info[2]['step'], 47 | ) 48 | entities.append(number) 49 | 50 | async_add_entities(entities) 51 | return True 52 | 53 | class FroniusModbusNumber(FroniusModbusBaseEntity, NumberEntity): 54 | """Representation of an Battery Storage Modbus number.""" 55 | 56 | @property 57 | def state(self): 58 | """Return the state of the sensor.""" 59 | 60 | if self._key in self._hub.data: 61 | if self._key in ['grid_discharge_power','discharge_limit']: 62 | value = round(self._hub.data[self._key] / 100.0 * self._hub.max_discharge_rate_w,0) 63 | elif self._key in ['grid_charge_power','charge_limit']: 64 | value = round(self._hub.data[self._key] / 100.0 * self._hub.max_charge_rate_w,0) 65 | else: 66 | value = self._hub.data[self._key] 67 | return value 68 | 69 | # @property 70 | # def native_value(self) -> float: 71 | # if self._key in self._hub.data: 72 | # _LOGGER.debug(f'native_value {self._key}') 73 | # if self._key in ['grid_discharge_power','discharge_limit']: 74 | # return self._hub.data[self._key]/100.0 * self._hub.max_discharge_rate_w 75 | # elif self._key in ['grid_charge_power','charge_limit']: 76 | # return self._hub.data[self._key]/100.0 * self._hub.max_charge_rate_w 77 | # return self._hub.data[self._key] 78 | 79 | async def async_set_native_value(self, value: float) -> None: 80 | """Change the selected value.""" 81 | 82 | if self._key == 'minimum_reserve': 83 | await self._hub.set_minimum_reserve(value) 84 | elif self._key == 'charge_limit': 85 | await self._hub.set_charge_limit(value) 86 | elif self._key == 'discharge_limit': 87 | await self._hub.set_discharge_limit(value) 88 | elif self._key == 'grid_charge_power': 89 | await self._hub.set_grid_charge_power(value) 90 | elif self._key == 'grid_discharge_power': 91 | await self._hub.set_grid_discharge_power(value) 92 | 93 | #_LOGGER.debug(f"Number {self._key} set to {value}") 94 | self.async_write_ha_state() 95 | 96 | @property 97 | def available(self) -> bool: 98 | """Return depending on mode.""" 99 | if self._key == 'minimum_reserve': 100 | return True 101 | if self._key == 'charge_limit' and self._hub.storage_extended_control_mode in [1,3,6]: 102 | return True 103 | if self._key == 'discharge_limit' and self._hub.storage_extended_control_mode in [2,3,7]: 104 | return True 105 | if self._key == 'grid_charge_power' and self._hub.storage_extended_control_mode in [4]: 106 | return True 107 | if self._key == 'grid_discharge_power' and self._hub.storage_extended_control_mode in [5]: 108 | return True 109 | return False 110 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for sensor integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Optional, Dict, Any 6 | 7 | from homeassistant.components.sensor import ( 8 | SensorEntity, 9 | ) 10 | from homeassistant.const import CONF_NAME #, CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.icon import icon_for_battery_level 13 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 14 | from homeassistant.helpers.entity import Entity 15 | from homeassistant.core import callback 16 | from homeassistant.util import slugify 17 | 18 | from . import HubConfigEntry 19 | from .const import ( 20 | INVERTER_SENSOR_TYPES, 21 | INVERTER_SYMO_SENSOR_TYPES, 22 | INVERTER_STORAGE_SENSOR_TYPES, 23 | METER_SENSOR_TYPES, 24 | STORAGE_SENSOR_TYPES, 25 | ENTITY_PREFIX, 26 | ) 27 | from .hub import Hub 28 | from .base import FroniusModbusBaseEntity 29 | 30 | _LOGGER = logging.getLogger(__name__) 31 | 32 | async def async_setup_entry( 33 | hass: HomeAssistant, 34 | config_entry: HubConfigEntry, 35 | async_add_entities: AddEntitiesCallback, 36 | ) -> None: 37 | """Add sensors for passed config_entry in HA.""" 38 | hub:Hub = config_entry.runtime_data 39 | 40 | entities = [] 41 | 42 | for sensor_info in INVERTER_SENSOR_TYPES.values(): 43 | sensor = FroniusModbusSensor( 44 | platform_name = ENTITY_PREFIX, 45 | hub = hub, 46 | device_info = hub.device_info_inverter, 47 | name = sensor_info[0], 48 | key = sensor_info[1], 49 | device_class = sensor_info[2], 50 | state_class = sensor_info[3], 51 | unit = sensor_info[4], 52 | icon = sensor_info[5], 53 | entity_category = sensor_info[6], 54 | ) 55 | entities.append(sensor) 56 | 57 | for sensor_info in INVERTER_SYMO_SENSOR_TYPES.values(): 58 | sensor = FroniusModbusSensor( 59 | platform_name = ENTITY_PREFIX, 60 | hub = hub, 61 | device_info = hub.device_info_inverter, 62 | name = sensor_info[0], 63 | key = sensor_info[1], 64 | device_class = sensor_info[2], 65 | state_class = sensor_info[3], 66 | unit = sensor_info[4], 67 | icon = sensor_info[5], 68 | entity_category = sensor_info[6], 69 | ) 70 | entities.append(sensor) 71 | 72 | if hub.meter_configured: 73 | meter_id = '1' 74 | for sensor_info in METER_SENSOR_TYPES.values(): 75 | sensor = FroniusModbusSensor( 76 | platform_name = ENTITY_PREFIX, 77 | hub = hub, 78 | device_info = hub.get_device_info_meter(meter_id), 79 | name = f'Meter {meter_id} ' + sensor_info[0], 80 | key = f'm{meter_id}_' + sensor_info[1], 81 | device_class = sensor_info[2], 82 | state_class = sensor_info[3], 83 | unit = sensor_info[4], 84 | icon = sensor_info[5], 85 | entity_category = sensor_info[6], 86 | ) 87 | entities.append(sensor) 88 | 89 | if hub.storage_configured: 90 | for sensor_info in INVERTER_STORAGE_SENSOR_TYPES.values(): 91 | sensor = FroniusModbusSensor( 92 | platform_name = ENTITY_PREFIX, 93 | hub = hub, 94 | device_info = hub.device_info_inverter, 95 | name = sensor_info[0], 96 | key = sensor_info[1], 97 | device_class = sensor_info[2], 98 | state_class = sensor_info[3], 99 | unit = sensor_info[4], 100 | icon = sensor_info[5], 101 | entity_category = sensor_info[6], 102 | ) 103 | entities.append(sensor) 104 | 105 | for sensor_info in STORAGE_SENSOR_TYPES.values(): 106 | sensor = FroniusModbusSensor( 107 | platform_name = ENTITY_PREFIX, 108 | hub = hub, 109 | device_info = hub.device_info_storage, 110 | name = sensor_info[0], 111 | key = sensor_info[1], 112 | device_class = sensor_info[2], 113 | state_class = sensor_info[3], 114 | unit = sensor_info[4], 115 | icon = sensor_info[5], 116 | entity_category = sensor_info[6], 117 | ) 118 | entities.append(sensor) 119 | 120 | async_add_entities(entities) 121 | return True 122 | 123 | class FroniusModbusSensor(FroniusModbusBaseEntity, SensorEntity): 124 | """Representation of an Fronius Modbus Modbus sensor.""" 125 | 126 | @property 127 | def state(self): 128 | """Return the state of the sensor.""" 129 | if self._key in self._hub.data: 130 | value = self._hub.data[self._key] 131 | if isinstance(value, str): 132 | if len(value)>255: 133 | value = value[:255] 134 | _LOGGER.error(f'state length > 255. k: {self._key} v: {value}') 135 | return value 136 | 137 | # self._icon = icon_for_battery_level( 138 | # battery_level=self.native_value, charging=False 139 | # ) 140 | 141 | @property 142 | def extra_state_attributes(self): 143 | return None 144 | 145 | 146 | 147 | 148 | 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 2 | 3 | # fronius_modbus 4 | Home assistant Custom Component for reading data from Fronius Gen24 Inverter and connected smart meters and battery storage. This integration uses a local modbus connection. 5 | 6 | > [!CAUTION] 7 | > This is a work in progress project - it is still in early development stage, so there are still breaking changes possible. 8 | > 9 | > This is an unofficial implementation and not supported by Fronius. It might stop working at any point in time. 10 | > You are using this module (and it's prerequisites/dependencies) at your own risk. Not me neither any of contributors to this or any prerequired/dependency project are responsible for damage in any kind caused by this project or any of its prerequsites/dependencies. 11 | 12 | # Installation 13 | 14 | HACS installation 15 | * Go to HACS 16 | * Click on the 3 dots in the top right corner. 17 | * Select "Custom repositories" 18 | * Add the [URL](https://github.com/redpomodoro/fronius_modbus) to the repository. 19 | * Select the 'integration' type. 20 | * Click the "ADD" button. 21 | 22 | Manual installation 23 | Copy contents of custom_components folder to your home-assistant config/custom_components folder. 24 | After reboot of Home-Assistant, this integration can be configured through the integration setup UI. 25 | 26 | Make sure modbus is enabled on the inverter. You can check by going into the web interface of the inverter and go to: 27 | "Communication" -> "Modbus" 28 | 29 | And turn on: 30 | - "Con­trol sec­ond­ary in­ver­t­er via Mod­bus TCP" 31 | - "Allow control" 32 | - Make sure that under 'SunSpec Model Type' has 'int + SF' selected. 33 | 34 | ![modbus settings](images/modbus_settings.png?raw=true "modbus") 35 | 36 | 37 | > [!IMPORTANT] 38 | > Turn off scheduled (dis)charging in the web UI to avoid unexpected behavior. 39 | 40 | > [!IMPORTANT] 41 | > When using multiple integrations that use pymodbus package it can lead to version conflicts as they will share 1 package in HA. This can be fixed by removing ALL integrations using pymodbus and modbus configuratio.yaml (for the build in integration into HA), rebooting HA and then reinstalling the integrations and the modbus configuration yaml. 42 | 43 | > [!IMPORTANT] 44 | > Update your GEN24 inverter firmware to 1.34.6-1 or higher otherwise battery charging might be limited. 45 | 46 | # Usage 47 | 48 | ### Battery Storage 49 | 50 | ### Controls 51 | | Entity | Description | 52 | | --- | --- | 53 | | Discharge Limit | This is maxium discharging power in watts of which the battery can be discharged by. | 54 | | Grid Charge Power | The charging power in watts when the storage is being charged from the grid. Note that grid charging is seems to be limited to an effictive 50% by the hardware. | 55 | | Grid Discharge Power | The discharging power in watts when the storage is being discharged to the grid. | 56 | | Minimum Reserve | The minimum reserve for storage when discharging. Note that the storage will charge from the grid with 0.5kW if SOC falls below this level. Called 'Reserve Capacity' in Fronius Web UI. | 57 | | PV Charge Limit | This is maximum PV charging power in watts of which the battery can be charged by. | 58 | 59 | ### Storage Control Modes 60 | | Mode | Description | 61 | | --- | --- | 62 | | Auto | The storage will allow charging and discharging up to the minimum reserve. | 63 | | PV Charge Limit | The storage can be charged with PV power at a limited rate. Limit will be set to maximum power after change. | 64 | | Discharge Limit | The storage can be charged with PV power and discharged at a limited rate. in Fronius Web UI. Limit will be set to maximum power after change. | 65 | | PV Charge and Discharge Limit | Allows setting both PV charge and discharge limits. Limits will be set to maximum power after change. | 66 | | Charge from Grid | The storage will be charged from the grid using the charge rate from 'Grid Charge Power'. Power will be set 0 after change. | 67 | | Discharge to Grid | The storage will discharge to the gird using the discharge rate from 'Gird Discharge Power'. Power will be set 0 after change. | 68 | | Block discharging | The storage can only be charged with PV power. Charge limit will be set to maximum power. | 69 | | Block charging | The can only be discharged and won't be charged with PV power. Discharge limit will be set to maximum power. | 70 | 71 | Note to change the mode first then set controls active in that mode. 72 | 73 | ### Controls used by Modes 74 | | Mode | Charge Limit | Discharge Limit | Grid Charge Power | Grid Discharge Power | Minimum Reserve | 75 | | --- | --- | --- | --- | --- | --- | 76 | | Auto | Ignored (100%) | Ignored (100%) | Ignored (0%) | Ignored (0%) | Used | 77 | | PV Charge Limit | Used | Ignored (100%) | Ignored (0%) | Ignored (0%) | Used | 78 | | Discharge Limit | Ignored (100%) | Used | Ignored (0%) | Ignored (0%) | Used | 79 | | PV Charge and Discharge Limit | Used | Used | Ignored (0%) | Ignored (0%) | Used | 80 | | Charge from Grid | Ignored | Ignored | Used | Ignored (0%) | Used | 81 | | Charge from Grid | Ignored | Ignored | Ignored (0%) | Used | Used | 82 | | Block discharging | Used | Ignored (0%) | Ignored (0%) | Ignored (0%) | Used | 83 | | Block charging | Ignored (0%) | Used | Ignored (0%) | Ignored (0%) | Used | 84 | 85 | ### Fronius Web UI mapping 86 | | Web UI name | Integration Control | Integration Mode | 87 | | --- | --- | --- | 88 | | Max. charging power | PV Charge Limit | PV Charge Limit | 89 | | Min. charging power | Grid Charging Power | Charge from Grid | 90 | | Max. discharging power | Discharge Limit | Discharge Limit | 91 | | Min. discharging power | Grid Discharge Power | Grid Discharge Power | 92 | 93 | ### Battery Storage Sensors 94 | | Entity | Description | 95 | | --- | --- | 96 | | Charge Status | Holding / Charging / Discharging | 97 | | Minimum Reserve | This is minium level to which the battery can be discharged and will be charged from the grid if falls below. Called 'Reserve Capacity' in Web UI. | 98 | | State of Charge | The current battery level | 99 | 100 | ### Diagnostic 101 | | Entity | Description | 102 | | --- | --- | 103 | To come! 104 | 105 | 106 | ### Inverter Sensors 107 | | Entity | Description | 108 | | --- | --- | 109 | | Load | The current total power consumption which is derived by adding up the meter AC power and interver AC power. | 110 | 111 | 112 | ### Inverter Diagnostics 113 | | Entity | Description | 114 | | --- | --- | 115 | | Grid status | Grid status based on meter and interter frequency. If inverter frequency is 53hz it is running in off grid mode and normally in 50hz. When the inverter is sleeping the meter frequency is checked for connection. | 116 | 117 | 118 | # Example Devices (Outdated screenshots!) 119 | 120 | Battery Storage 121 | ![battery storage](images/example_batterystorage0.png?raw=true "storage") 122 | 123 | Battery Storage Actions 124 | ![battery storage actions](images/example_batterystorage.png?raw=true "storage actions") 125 | 126 | Smart Meter 127 | ![smart meter](images/example_meter.png?raw=true "meter") 128 | 129 | Inverter 130 | ![smart meter](images/example_inverter.png?raw=true "inverter") 131 | 132 | 133 | # References 134 | - https://www.fronius.com/~/downloads/Solar%20Energy/Operating%20Instructions/42,0410,2649.pdf 135 | - https://github.com/binsentsu/home-assistant-solaredge-modbus/ 136 | - https://github.com/bigramonk/byd_charging 137 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/config_flow.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import Any 5 | 6 | import voluptuous as vol 7 | 8 | from homeassistant import config_entries, exceptions 9 | from homeassistant.core import HomeAssistant 10 | 11 | from .hub import Hub 12 | from homeassistant.const import CONF_NAME, CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL 13 | from .const import ( 14 | DOMAIN, 15 | DEFAULT_NAME, 16 | DEFAULT_SCAN_INTERVAL, 17 | DEFAULT_PORT, 18 | DEFAULT_INVERTER_UNIT_ID, 19 | DEFAULT_METER_UNIT_ID, 20 | CONF_INVERTER_UNIT_ID, 21 | CONF_METER_UNIT_ID, 22 | SUPPORTED_MANUFACTURERS, 23 | SUPPORTED_MODELS, 24 | ) 25 | 26 | _LOGGER = logging.getLogger(__name__) 27 | 28 | # This is the schema that used to display the UI to the user. This simple 29 | # schema has a single required host field, but it could include a number of fields 30 | # such as username, password etc. See other components in the HA core code for 31 | # further examples. 32 | # Note the input displayed to the user will be translated. See the 33 | # translations/.json file and strings.json. See here for further information: 34 | # https://developers.home-assistant.io/docs/config_entries_config_flow_handler/#translations 35 | # At the time of writing I found the translations created by the scaffold didn't 36 | # quite work as documented and always gave me the "Lokalise key references" string 37 | # (in square brackets), rather than the actual translated value. I did not attempt to 38 | # figure this out or look further into it. 39 | #DATA_SCHEMA = vol.Schema({("host"): str, ("port"): int}) 40 | 41 | DATA_SCHEMA = vol.Schema( 42 | { 43 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, 44 | vol.Required(CONF_HOST): str, 45 | vol.Required(CONF_PORT, default=DEFAULT_PORT): int, 46 | vol.Optional(CONF_INVERTER_UNIT_ID, default=DEFAULT_INVERTER_UNIT_ID): int, 47 | vol.Optional(CONF_METER_UNIT_ID, default=DEFAULT_METER_UNIT_ID): int, 48 | vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, 49 | } 50 | ) 51 | 52 | async def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: 53 | """Validate the user input allows us to connect. 54 | 55 | Data has the keys from DATA_SCHEMA with values provided by the user. 56 | """ 57 | # Validate the data can be used to set up a connection. 58 | 59 | if len(data[CONF_HOST]) < 3: 60 | raise InvalidHost 61 | if data[CONF_PORT] > 65535: 62 | raise InvalidPort 63 | if data[CONF_SCAN_INTERVAL] < 5: 64 | raise ScanIntervalTooShort 65 | 66 | meter_addresses = [data[CONF_METER_UNIT_ID]] 67 | 68 | all_addresses = meter_addresses + [data[CONF_INVERTER_UNIT_ID]] 69 | 70 | if len(all_addresses) > len(set(all_addresses)): 71 | _LOGGER.error(f"Modbus addresses are not unique {all_addresses}") 72 | raise AddressesNotUnique 73 | 74 | try: 75 | hub = Hub(hass, data[CONF_NAME], data[CONF_HOST], data[CONF_PORT], data[CONF_INVERTER_UNIT_ID], meter_addresses, data[CONF_SCAN_INTERVAL]) 76 | 77 | await hub.init_data() 78 | except Exception as e: 79 | # If there is an error, raise an exception to notify HA that there was a 80 | # problem. The UI will also show there was a problem 81 | _LOGGER.error(f"Cannot start hub {e}") 82 | raise CannotConnect 83 | 84 | manufacturer = hub.data.get('i_manufacturer') 85 | if manufacturer is None: 86 | _LOGGER.error(f"No manufacturer is returned") 87 | raise UnsupportedHardware 88 | if manufacturer not in SUPPORTED_MANUFACTURERS: 89 | _LOGGER.error(f"Unsupported manufacturer: '{manufacturer}'") 90 | raise UnsupportedHardware 91 | 92 | model = hub.data.get('i_model') 93 | if model is None: 94 | _LOGGER.error(f"No model type is returned") 95 | raise UnsupportedHardware 96 | 97 | supported = False 98 | for supported_model in SUPPORTED_MODELS: 99 | if model.startswith(supported_model): 100 | supported = True 101 | 102 | if not supported: 103 | _LOGGER.warning(f"Untested model {model}") 104 | #raise UnsupportedHardware 105 | 106 | #result = await hub.test_connection() 107 | #if not result: 108 | # raise CannotConnect 109 | 110 | # Return info that you want to store in the config entry. 111 | # "Title" is what is displayed to the user for this hub device 112 | # It is stored internally in HA as part of the device config. 113 | # See `async_step_user` below for how this is used 114 | return {"title": data[CONF_NAME]} 115 | 116 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 117 | """Handle a config flow """ 118 | 119 | VERSION = 1 120 | # Pick one of the available connection classes in homeassistant/config_entries.py 121 | # This tells HA if it should be asking for updates, or it'll be notified of updates 122 | # automatically. This integration uses PUSH, as the hub will notify HA of 123 | # changes. 124 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 125 | 126 | async def async_step_user(self, user_input=None): 127 | """Handle the initial step.""" 128 | # This goes through the steps to take the user through the setup process. 129 | # Using this it is possible to update the UI and prompt for additional 130 | # information. This example provides a single form (built from `DATA_SCHEMA`), 131 | # and when that has some validated input, it calls `async_create_entry` to 132 | # actually create the HA config entry. Note the "title" value is returned by 133 | # `validate_input` above. 134 | errors = {} 135 | if user_input is not None: 136 | try: 137 | info = await validate_input(self.hass, user_input) 138 | 139 | return self.async_create_entry(title=info["title"], data=user_input) 140 | except CannotConnect: 141 | errors["base"] = "cannot_connect" 142 | except InvalidPort: 143 | errors["base"] = "invalid_port" 144 | except InvalidHost: 145 | errors["host"] = "invalid_host" 146 | except ScanIntervalTooShort: 147 | errors["base"] = "scan_interval_too_short" 148 | except UnsupportedHardware: 149 | errors["base"] = "unsupported_hardware" 150 | except AddressesNotUnique: 151 | errors["base"] = "modbus_address_conflict" 152 | except Exception: # pylint: disable=broad-except 153 | _LOGGER.exception("Unexpected exception") 154 | errors["base"] = "unknown" 155 | 156 | # If there is no user input or there were errors, show the form again, including any errors that were found with the input. 157 | return self.async_show_form( 158 | step_id="user", data_schema=DATA_SCHEMA, errors=errors 159 | ) 160 | 161 | class CannotConnect(exceptions.HomeAssistantError): 162 | """Error to indicate we cannot connect.""" 163 | 164 | class InvalidHost(exceptions.HomeAssistantError): 165 | """Error to indicate there is an invalid hostname.""" 166 | 167 | class InvalidPort(exceptions.HomeAssistantError): 168 | """Error to indicate there is an invalid hostname.""" 169 | 170 | class UnsupportedHardware(exceptions.HomeAssistantError): 171 | """Error to indicate there is an unsupported hardware.""" 172 | 173 | class AddressesNotUnique(exceptions.HomeAssistantError): 174 | """Error to indicate that the modbus addresses are not unique.""" 175 | 176 | class ScanIntervalTooShort(exceptions.HomeAssistantError): 177 | """Error to indicate the scan interval is too short.""" -------------------------------------------------------------------------------- /custom_components/fronius_modbus/const.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.sensor import ( 2 | SensorDeviceClass, 3 | SensorStateClass, 4 | ) 5 | from homeassistant.helpers.entity import EntityCategory 6 | 7 | DOMAIN = 'fronius_modbus' 8 | CONNECTION_MODBUS = 'modbus' 9 | DEFAULT_NAME = 'Fronius' 10 | ENTITY_PREFIX = 'fm' 11 | DEFAULT_SCAN_INTERVAL = 10 12 | DEFAULT_PORT = 502 13 | DEFAULT_INVERTER_UNIT_ID = 1 14 | DEFAULT_METER_UNIT_ID = 200 15 | CONF_INVERTER_UNIT_ID = 'inverter_modbus_unit_id' 16 | CONF_METER_UNIT_ID = 'meter_modbus_unit_id' 17 | ATTR_MANUFACTURER = 'Fronius' 18 | SUPPORTED_MANUFACTURERS = ['Fronius'] 19 | SUPPORTED_MODELS = ['Primo GEN24', 'Symo GEN24'] 20 | 21 | STORAGE_EXT_CONTROL_MODE = { 22 | 0: 'Auto', 23 | 1: 'PV Charge Limit', 24 | 2: 'Discharge Limit', 25 | 3: 'PV Charge and Discharge Limit', 26 | 4: 'Charge from Grid', 27 | 5: 'Discharge to Grid', 28 | 6: 'Block Discharging', 29 | 7: 'Block Charging', 30 | # 8: 'Calibrate', 31 | } 32 | 33 | STORAGE_SELECT_TYPES = [ 34 | ['Storage Control Mode', 'ext_control_mode', STORAGE_EXT_CONTROL_MODE], 35 | ] 36 | 37 | STORAGE_NUMBER_TYPES = [ 38 | ['Grid discharge power', 'grid_discharge_power', {'min': 0, 'max': 10100, 'step': 10, 'mode':'box', 'unit': 'W', 'max_key': 'MaxDisChaRte'}], 39 | ['Grid charge power', 'grid_charge_power', {'min': 0, 'max': 10100, 'step': 10, 'mode':'box', 'unit': 'W', 'max_key': 'MaxChaRte'}], 40 | ['Discharge limit', 'discharge_limit', {'min': 0, 'max': 10100, 'step': 10, 'mode':'box', 'unit': 'W', 'max_key': 'MaxDisChaRte'}], 41 | ['PV charge limit', 'charge_limit', {'min': 0, 'max': 10100, 'step': 10, 'mode':'box', 'unit': 'W', 'max_key': 'MaxChaRte'}], 42 | ['Minimum reserve', 'minimum_reserve', {'min': 5, 'max': 100, 'step': 1, 'mode':'box', 'unit': '%'}], 43 | # ['Reserve Target', 'reserve_target', {'min': 0, 'max': 100, 'unit': '%'}], 44 | ] 45 | 46 | INVERTER_SENSOR_TYPES = { 47 | 'acpower': ['AC power', 'acpower', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:lightning-bolt', None], 48 | 'acenergy': ['AC energy', 'acenergy', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:lightning-bolt', None], 49 | 'tempcab': ['Temperature', 'tempcab', SensorDeviceClass.TEMPERATURE, SensorStateClass.MEASUREMENT, '°C', 'mdi:thermometer', None], 50 | 'mppt1_power': ['MPPT1 power', 'mppt1_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:solar-power', None], 51 | 'mppt2_power': ['MPPT2 power', 'mppt2_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:solar-power', None], 52 | 'pv_power': ['PV power', 'pv_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:solar-power', None], 53 | 'mppt1_lfte': ['MPPT1 lifetime energy', 'mppt1_lfte', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:solar-panel', None], 54 | 'mppt2_lfte': ['MPPT2 lifetime energy', 'mppt2_lfte', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:solar-panel', None], 55 | 'load': ['Load', 'load', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:lightning-bolt', None], 56 | 'pv_connection': ['PV connection', 'pv_connection', None, None, None, None, EntityCategory.DIAGNOSTIC], 57 | 'ecp_connection': ['Electrical connection', 'ecp_connection', None, None, None, None, EntityCategory.DIAGNOSTIC], 58 | #'status': ['Status Base', 'status', None, None, None, None, None], 59 | 'statusvendor': ['Status', 'statusvendor', None, None, None, None, EntityCategory.DIAGNOSTIC], 60 | 'line_frequency': ['Line frequency', 'line_frequency', SensorDeviceClass.FREQUENCY, SensorStateClass.MEASUREMENT, 'Hz', None, None], 61 | 'inverter_controls': ['Control mode', 'inverter_controls', None, None, None, None, EntityCategory.DIAGNOSTIC], 62 | #'vref': ['Reference Voltage', 'vref', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 63 | #'vrefofs': ['Reference Voltage offset', 'vrefofs', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 64 | 'max_power': ['Maximum power', 'max_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:lightning-bolt', None], 65 | #'events1': ['Events Customer', 'events1', None, None, None, None, EntityCategory.DIAGNOSTIC], 66 | 'events2': ['Events', 'events2', None, None, None, None, EntityCategory.DIAGNOSTIC], 67 | 68 | 'grid_status': ['Grid status', 'grid_status', None, None, None, None, EntityCategory.DIAGNOSTIC], 69 | 70 | 'Conn': ['Connection control', 'Conn', None, None, None, None, EntityCategory.DIAGNOSTIC], 71 | 'WMaxLim_Ena': ['Throttle control', 'WMaxLim_Ena', None, None, None, None, EntityCategory.DIAGNOSTIC], 72 | 'OutPFSet_Ena': ['Fixed power factor', 'OutPFSet_Ena', None, None, None, None, EntityCategory.DIAGNOSTIC], 73 | 'VArPct_Ena': ['Limit VAr control', 'VArPct_Ena', None, None, None, None, EntityCategory.DIAGNOSTIC], 74 | 'PhVphA': ['AC voltage L1-N', 'PhVphA', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 75 | 'unit_id': ['Modbus ID', 'i_unit_id', None, None, None, None, EntityCategory.DIAGNOSTIC], 76 | } 77 | 78 | INVERTER_SYMO_SENSOR_TYPES = { 79 | 'PhVphB': ['AC voltage L2-N', 'PhVphB', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 80 | 'PhVphC': ['AC voltage L3-N', 'PhVphC', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 81 | 'PPVphAB': ['AC voltage L1-L2', 'PPVphAB', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 82 | 'PPVphBC': ['AC voltage L2-L3', 'PPVphBC', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 83 | 'PPVphCA': ['AC voltage L3-L1', 'PPVphCA', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 84 | } 85 | 86 | INVERTER_STORAGE_SENSOR_TYPES = { 87 | 'mppt3_power': ['Storage charging power', 'mppt3_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:home-battery', None], 88 | 'mppt4_power': ['Storage discharging power', 'mppt4_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:home-battery', None], 89 | 'storage_connection': ['Storage connection', 'storage_connection', None, None, None, None, EntityCategory.DIAGNOSTIC], 90 | 'storage_power': ['Storage power', 'storage_power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:home-battery', None], 91 | 'mppt3_lfte': ['Storage charging lifetime energy', 'mppt3_lfte', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:home-battery', None], 92 | 'mppt4_lfte': ['Storage discharging lifetime energy', 'mppt4_lfte', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:home-battery', None], 93 | } 94 | 95 | 96 | METER_SENSOR_TYPES = { 97 | 'power': ['Power', 'power', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:lightning-bolt', None], 98 | 'exported': ['Exported', 'exported', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:lightning-bolt', None], 99 | 'imported': ['Imported', 'imported', SensorDeviceClass.ENERGY, SensorStateClass.TOTAL_INCREASING, 'Wh', 'mdi:lightning-bolt', None], 100 | 'line_frequency': ['Line frequency', 'line_frequency', SensorDeviceClass.FREQUENCY, SensorStateClass.MEASUREMENT, 'Hz', None, None], 101 | 'PhVphA': ['AC voltage L1-N', 'PhVphA', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 102 | 'PhVphB': ['AC voltage L2-N', 'PhVphB', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 103 | 'PhVphC': ['AC voltage L3-N', 'PhVphC', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 104 | 'PPV': ['AC voltage Line to Line', 'PPV', SensorDeviceClass.VOLTAGE, SensorStateClass.MEASUREMENT, 'V', 'mdi:lightning-bolt', None], 105 | 'unit_id': ['Modbus ID', 'unit_id', None, None, None, None, EntityCategory.DIAGNOSTIC], 106 | } 107 | 108 | STORAGE_SENSOR_TYPES = { 109 | 'control_mode': ['Core storage control mode', 'control_mode', None, None, None, None, EntityCategory.DIAGNOSTIC], 110 | 'charge_status': ['Charge status', 'charge_status', None, None, None, None, None, EntityCategory.DIAGNOSTIC], 111 | 'max_charge': ['Max charging power', 'max_charge', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', 'mdi:gauge', EntityCategory.DIAGNOSTIC], 112 | 'soc': ['State of charge', 'soc', None, SensorStateClass.MEASUREMENT, '%', 'mdi:battery', None], 113 | 'charging_power': ['Charging power', 'charging_power', None, None, '%', 'mdi:gauge', EntityCategory.DIAGNOSTIC], 114 | 'discharging_power': ['Discharging power', 'discharging_power', None, None, '%', 'mdi:gauge', EntityCategory.DIAGNOSTIC], 115 | 'minimum_reserve': ['Minimum reserve', 'minimum_reserve', None, None, '%', 'mdi:gauge', None], 116 | 'grid_charging': ['Grid charging', 'grid_charging', None, None, None, None, EntityCategory.DIAGNOSTIC], 117 | 'WHRtg': ['Capacity', 'WHRtg', SensorDeviceClass.ENERGY, SensorStateClass.MEASUREMENT, 'Wh', None, EntityCategory.DIAGNOSTIC], 118 | 'MaxChaRte': ['Maximum charge rate', 'MaxChaRte', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', None, EntityCategory.DIAGNOSTIC], 119 | 'MaxDisChaRte': ['Maximum discharge rate', 'MaxDisChaRte', SensorDeviceClass.POWER, SensorStateClass.MEASUREMENT, 'W', None, EntityCategory.DIAGNOSTIC], 120 | #'WChaGra': ['Setpoint for maximum charge', 'WChaGra', None, None, None, None, EntityCategory.DIAGNOSTIC], 121 | #'WDisChaGra': ['Setpoint for maximum discharge', 'WDisChaGra', None, None, None, None, EntityCategory.DIAGNOSTIC], 122 | } 123 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/hub.py: -------------------------------------------------------------------------------- 1 | """Fronius Modbus Hub.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from datetime import timedelta 6 | from typing import Optional 7 | from importlib.metadata import version 8 | 9 | from homeassistant.core import callback 10 | from homeassistant.helpers.event import async_track_time_interval 11 | from homeassistant.core import HomeAssistant 12 | 13 | from .froniusmodbusclient import FroniusModbusClient 14 | 15 | from .const import ( 16 | DOMAIN, 17 | ) 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | class Hub: 22 | """Hub for Fronius Battery Storage Modbus Interface""" 23 | 24 | PYMODBUS_VERSION = '3.11.1' 25 | 26 | def __init__(self, hass: HomeAssistant, name: str, host: str, port: int, inverter_unit_id: int, meter_unit_ids, scan_interval: int) -> None: 27 | """Init hub.""" 28 | self._hass = hass 29 | self._name = name 30 | 31 | self._id = f'{name.lower()}_{host.lower().replace('.','')}' 32 | self.online = True 33 | 34 | self._client = FroniusModbusClient(host=host, port=port, inverter_unit_id=inverter_unit_id, meter_unit_ids=meter_unit_ids, timeout=max(3, (scan_interval - 1))) 35 | self._scan_interval = timedelta(seconds=scan_interval) 36 | self._unsub_interval_method = None 37 | self._entities = [] 38 | self._entities_dict = {} 39 | self._busy = False 40 | 41 | def toggle_busy(func): 42 | async def wrapper(self, *args, **kwargs): 43 | if self._busy: 44 | #_LOGGER.debug(f"skip {func.__name__} hub busy") 45 | return 46 | self._busy = True 47 | error = None 48 | try: 49 | result = await func(self, *args, **kwargs) 50 | except Exception as e: 51 | _LOGGER.warning(f'Exception in wrapper {e}') 52 | error = e 53 | self._busy = False 54 | if not error is None: 55 | raise error 56 | return result 57 | return wrapper 58 | 59 | @toggle_busy 60 | async def init_data(self, close = False, read_status_data = False): 61 | await self._hass.async_add_executor_job(self.check_pymodbus_version) 62 | result = await self._client.init_data() 63 | 64 | if self.storage_configured: 65 | result : bool = await self._hass.async_add_executor_job(self._client.get_json_storage_info) 66 | 67 | return 68 | 69 | def check_pymodbus_version(self): 70 | if version('pymodbus') is None: 71 | _LOGGER.warning(f"pymodbus not found") 72 | elif version('pymodbus') < self.PYMODBUS_VERSION: 73 | raise Exception(f"pymodbus {version('pymodbus')} found, please update to {self.PYMODBUS_VERSION} or higher") 74 | elif version('pymodbus') > self.PYMODBUS_VERSION: 75 | _LOGGER.warning(f"newer pymodbus {version('pymodbus')} found") 76 | _LOGGER.debug(f"pymodbus {version('pymodbus')}") 77 | 78 | @property 79 | def device_info_storage(self) -> dict: 80 | return { 81 | "identifiers": {(DOMAIN, f'{self._name}_battery_storage')}, 82 | "name": f'{self._client.data.get('s_model')}', 83 | "manufacturer": self._client.data.get('s_manufacturer'), 84 | "model": self._client.data.get('s_model'), 85 | "serial_number": self._client.data.get('s_serial'), 86 | } 87 | 88 | @property 89 | def device_info_inverter(self) -> dict: 90 | return { 91 | "identifiers": {(DOMAIN, f'{self._name}_inverter')}, 92 | "name": f'Fronius {self._client.data.get('i_model')}', 93 | "manufacturer": self._client.data.get('i_manufacturer'), 94 | "model": self._client.data.get('i_model'), 95 | "serial_number": self._client.data.get('i_serial'), 96 | "sw_version": self._client.data.get('i_sw_version'), 97 | #"hw_version": f'modbus id-{self._client.data.get('i_unit_id')}', 98 | } 99 | 100 | def get_device_info_meter(self, id) -> dict: 101 | return { 102 | "identifiers": {(DOMAIN, f'{self._name}_meter{id}')}, 103 | "name": f'Fronius {self._client.data.get(f'm{id}_model')} {self._client.data.get(f'm{id}_options')}', 104 | "manufacturer": self._client.data.get(f'm{id}_manufacturer'), 105 | "model": self._client.data.get(f'm{id}_model'), 106 | "serial_number": self._client.data.get(f'm{id}_serial'), 107 | "sw_version": self._client.data.get(f'm{id}_sw_version'), 108 | #"hw_version": f'modbus id-{self._client.data.get(f'm{id}_unit_id')}', 109 | } 110 | 111 | @property 112 | def hub_id(self) -> str: 113 | """ID for hub.""" 114 | return self._id 115 | 116 | @callback 117 | def async_add_hub_entity(self, update_callback): 118 | """Listen for data updates.""" 119 | # This is the first entity, set up interval. 120 | if not self._entities: 121 | self._unsub_interval_method = async_track_time_interval( 122 | self._hass, self.async_refresh_modbus_data, self._scan_interval 123 | ) 124 | self._entities.append(update_callback) 125 | 126 | @callback 127 | def async_remove_hub_entity(self, update_callback): 128 | """Remove data update.""" 129 | self._entities.remove(update_callback) 130 | 131 | if not self._entities: 132 | """stop the interval timer upon removal of last entity""" 133 | self._unsub_interval_method() 134 | self._unsub_interval_method = None 135 | self.close() 136 | 137 | @toggle_busy 138 | async def async_refresh_modbus_data(self, _now: Optional[int] = None) -> dict: 139 | """Time to update.""" 140 | 141 | if not self._entities: 142 | return False 143 | 144 | try: 145 | update_result = await self._client.read_inverter_data() 146 | except Exception as e: 147 | _LOGGER.exception("Error reading inverter data", exc_info=True) 148 | update_result = False 149 | 150 | try: 151 | update_result = await self._client.read_inverter_status_data() 152 | except Exception as e: 153 | _LOGGER.exception("Error reading inverter status data", exc_info=True) 154 | update_result = False 155 | 156 | try: 157 | update_result = await self._client.read_inverter_model_settings_data() 158 | except Exception as e: 159 | _LOGGER.exception("Error reading inverter model settings data", exc_info=True) 160 | update_result = False 161 | 162 | try: 163 | update_result = await self._client.read_inverter_controls_data() 164 | except Exception as e: 165 | _LOGGER.exception("Error reading inverter model settings data", exc_info=True) 166 | update_result = False 167 | 168 | if self._client.meter_configured: 169 | for meter_address in self._client._meter_unit_ids: 170 | try: 171 | update_result = await self._client.read_meter_data(meter_prefix="m1_", unit_id=meter_address) 172 | except Exception as e: 173 | _LOGGER.error(f"Error reading meter data {meter_address}.", exc_info=True) 174 | #update_result = False 175 | 176 | if self._client.mppt_configured: 177 | try: 178 | update_result = await self._client.read_mppt_data() 179 | except Exception as e: 180 | _LOGGER.exception("Error reading mptt data", exc_info=True) 181 | update_result = False 182 | 183 | if self._client.storage_configured: 184 | try: 185 | update_result = await self._client.read_inverter_storage_data() 186 | except Exception as e: 187 | _LOGGER.exception("Error reading inverter storage data", exc_info=True) 188 | update_result = False 189 | 190 | 191 | if update_result: 192 | for update_callback in self._entities: 193 | update_callback() 194 | 195 | @toggle_busy 196 | async def test_connection(self) -> bool: 197 | """Test connectivity""" 198 | try: 199 | return await self._client.connect() 200 | except Exception as e: 201 | _LOGGER.exception("Error connecting to inverter", exc_info=True) 202 | return False 203 | 204 | def close(self): 205 | """Disconnect client.""" 206 | #with self._lock: 207 | self._client.close() 208 | 209 | @property 210 | def data(self): 211 | return self._client.data 212 | 213 | @property 214 | def meter_configured(self): 215 | return self._client.meter_configured 216 | 217 | @property 218 | def storage_configured(self): 219 | return self._client.storage_configured 220 | 221 | @property 222 | def max_discharge_rate_w(self): 223 | return self._client.max_discharge_rate_w 224 | 225 | @property 226 | def max_charge_rate_w(self): 227 | return self._client.max_charge_rate_w 228 | 229 | @property 230 | def storage_extended_control_mode(self): 231 | return self._client.storage_extended_control_mode 232 | 233 | @toggle_busy 234 | async def set_mode(self, mode): 235 | if mode == 0: 236 | await self._client.set_auto_mode() 237 | elif mode == 1: 238 | await self._client.set_charge_mode() 239 | elif mode == 2: 240 | await self._client.set_discharge_mode() 241 | elif mode == 3: 242 | await self._client.set_charge_discharge_mode() 243 | elif mode == 4: 244 | await self._client.set_grid_charge_mode() 245 | elif mode == 5: 246 | await self._client.set_grid_discharge_mode() 247 | elif mode == 6: 248 | await self._client.set_block_discharge_mode() 249 | elif mode == 7: 250 | await self._client.set_block_charge_mode() 251 | elif mode == 8: 252 | await self._client.set_calibrate_mode() 253 | 254 | @toggle_busy 255 | async def set_minimum_reserve(self, value): 256 | await self._client.set_minimum_reserve(value) 257 | 258 | @toggle_busy 259 | async def set_charge_limit(self, value): 260 | await self._client.set_charge_limit(value) 261 | 262 | @toggle_busy 263 | async def set_discharge_limit(self, value): 264 | await self._client.set_charge_limit(value) 265 | 266 | @toggle_busy 267 | async def set_grid_charge_power(self, value): 268 | await self._client.set_grid_charge_power(value) 269 | 270 | @toggle_busy 271 | async def set_grid_discharge_power(self, value): 272 | await self._client.set_grid_discharge_power(value) 273 | 274 | 275 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /custom_components/fronius_modbus/extmodbusclient.py: -------------------------------------------------------------------------------- 1 | #import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__))) 2 | 3 | """Extended Modbus Class""" 4 | 5 | import logging 6 | import operator 7 | #from datetime import timedelta, datetime 8 | from typing import Literal 9 | import struct 10 | import asyncio 11 | 12 | from pymodbus.client import AsyncModbusTcpClient 13 | try: 14 | # For newer pymodbus versions (3.9.x+) 15 | from pymodbus.pdu.pdu import unpack_bitstring 16 | except ImportError: 17 | # For older pymodbus versions (3.8.x and below) 18 | from pymodbus.utilities import unpack_bitstring 19 | from pymodbus.exceptions import ModbusIOException, ConnectionException 20 | from pymodbus import ExceptionResponse 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | class ExtModbusClient: 25 | 26 | def __init__(self, host: str, port: int, unit_id: int, timeout: int, framer:str = None) -> None: 27 | """Init Class""" 28 | self._host = host 29 | self._port = port 30 | self._unit_id = unit_id 31 | self.busy = False 32 | if not framer is None: 33 | self._client = AsyncModbusTcpClient(host=host, port=port, framer=framer, timeout=timeout) 34 | else: 35 | self._client = AsyncModbusTcpClient(host=host, port=port, timeout=timeout) 36 | 37 | def close(self): 38 | """Disconnect client.""" 39 | self._client.close() 40 | 41 | async def connect(self, retries = 3): 42 | """Connect client.""" 43 | for attempts in range(retries): 44 | if attempts > 0: 45 | _LOGGER.debug(f"Connect retry attempt: {attempts}/{retries} connecting to: {self._host}:{self._port}") 46 | await asyncio.sleep(.2) 47 | connected = await self._client.connect() 48 | if connected: 49 | break 50 | 51 | if not self._client.connected: 52 | raise Exception(f"Failed to connect to {self._host}:{self._port} retries: {retries}") 53 | _LOGGER.debug("successfully connected to %s:%s", self._client.comm_params.host, self._client.comm_params.port) 54 | return True 55 | 56 | async def _check_and_reconnect(self): 57 | if not self._client.connected: 58 | _LOGGER.warning("Modbus client is not connected, reconnecting...", exc_info=True) 59 | return await self.connect() 60 | return self._client.connected 61 | 62 | @property 63 | def connected(self) -> bool: 64 | return self._client.connected 65 | 66 | def validate(self, value, comparison, against): 67 | ops = { 68 | ">": operator.gt, 69 | "<": operator.lt, 70 | ">=": operator.ge, 71 | "<=": operator.le, 72 | "==": operator.eq, 73 | "!=": operator.ne, 74 | } 75 | if not ops[comparison](value, against): 76 | raise ValueError(f"Value {value} failed validation ({comparison}{against})") 77 | return value 78 | 79 | async def read_holding_registers(self, unit_id, address, count, retries = 3): 80 | """Read holding registers.""" 81 | await self._check_and_reconnect() 82 | 83 | for attempt in range(retries+1): 84 | try: 85 | data = await self._client.read_holding_registers(address=address, count=count, device_id=unit_id) 86 | except ModbusIOException as e: 87 | _LOGGER.error(f'error reading registers. IO error. connected: {self._client.connected} address: {address} count: {count} unit id: {unit_id}') 88 | return None 89 | except ConnectionException as e: 90 | _LOGGER.error(f'error reading registers. connection exception connected: {self._client.connected} address: {address} count: {count} unit id: {unit_id} {e} ') 91 | return None 92 | except Exception as e: 93 | _LOGGER.error(f'error reading registers. unknown error. connected {self._client.connected} address: {address} count: {count} unit id: {unit_id} type {type(e)} error {e} ') 94 | return None 95 | 96 | if not data.isError(): 97 | break 98 | else: 99 | if isinstance(data,ModbusIOException): 100 | _LOGGER.debug(f"io error reading register retries: {attempt}/{retries} connected {self._client.connected} address: {address} count: {count} unit id: {unit_id} error: {data} ") 101 | elif isinstance(data, ExceptionResponse): 102 | _LOGGER.debug(f"Exception response reading register retries: {attempt}/{retries} connected {self._client.connected} address: {address} count: {count} unit id: {unit_id} {data}") 103 | else: 104 | _LOGGER.debug(f"Unknown data response error reading register retries: {attempt}/{retries} connected {self._client.connected} address: {address} count: {count} unit id: {unit_id} {data}") 105 | await asyncio.sleep(.2) 106 | 107 | if data.isError(): 108 | _LOGGER.error(f"error reading registers. retries: {attempt}/{retries} connected {self._client.connected} register: {address} count: {count} unit id: {unit_id} retries {retries} error: {data} ") 109 | return None 110 | 111 | return data 112 | 113 | async def get_registers(self, unit_id, address, count, retries = 0): 114 | data = await self.read_holding_registers(unit_id=unit_id, address=address, count=count) 115 | if data.isError(): 116 | if isinstance(data,ModbusIOException): 117 | if retries < 1: 118 | _LOGGER.debug(f"IO Error: {data}. Retrying...") 119 | return await self.get_registers(address=address, count=count, retries = retries + 1) 120 | else: 121 | _LOGGER.error(f"error reading register: {address} count: {count} unit id: {unit_id} error: {data} ") 122 | else: 123 | _LOGGER.error(f"error reading register: {address} count: {count} unit id: {unit_id} error: {data} ") 124 | return None 125 | return data.registers 126 | 127 | async def write_registers(self, unit_id, address, payload): 128 | """Write registers.""" 129 | await self._check_and_reconnect() 130 | #_LOGGER.debug(f"write registers a: {address} p: {payload} unit_id: {unit_id}") 131 | 132 | try: 133 | result = await self._client.write_registers(address=address, values=payload, device_id=unit_id) 134 | except ModbusIOException as e: 135 | raise Exception(f'write_registers: IO error {self._client.connected} {e.fcode} {e}') 136 | except ConnectionException as e: 137 | raise Exception(f'write_registers: no connection {self._client.connected} {e} ') 138 | except Exception as e: 139 | raise Exception(f'write_registers: unknown error {self._client.connected} {type(e)} {e} ') 140 | 141 | if result.isError(): 142 | raise Exception(f'write_registers: data error {self._client.connected} {type(result)} {result} ') 143 | 144 | #_LOGGER.debug(f'write result {type(result)} {result}') 145 | return result 146 | 147 | def strip_escapes(self, value:str): 148 | if value is None: 149 | return 150 | filter = ''.join([chr(i) for i in range(0, 32)]) 151 | return value.translate(str.maketrans('', '', filter)).strip() 152 | 153 | def convert_from_registers_int8(self, regs): 154 | return [int(regs[0] >> 8), int(regs[0] & 0xFF)] 155 | 156 | def convert_from_registers_int4(self, regs): 157 | result = [int(regs[0] >> 4) & 0x0F, int(regs[0] & 0x0F)] 158 | return result 159 | 160 | def convert_from_registers( 161 | cls, registers: list[int], data_type: AsyncModbusTcpClient.DATATYPE, word_order: Literal["big", "little"] = "big" 162 | ) -> int | float | str | list[bool] | list[int] | list[float]: 163 | """Convert registers to int/float/str. 164 | 165 | # TODO: remove this function once HA has been upgraded to later pymodbus version 166 | 167 | :param registers: list of registers received from e.g. read_holding_registers() 168 | :param data_type: data type to convert to 169 | :param word_order: "big"/"little" order of words/registers 170 | :returns: scalar or array of "data_type" 171 | :raises ModbusException: when size of registers is not a multiple of data_type 172 | """ 173 | if not (data_len := data_type.value[1]): 174 | byte_list = bytearray() 175 | if word_order == "little": 176 | registers.reverse() 177 | for x in registers: 178 | byte_list.extend(int.to_bytes(x, 2, "big")) 179 | if data_type == cls.DATATYPE.STRING: 180 | trailing_nulls_begin = len(byte_list) 181 | while trailing_nulls_begin > 0 and not byte_list[trailing_nulls_begin - 1]: 182 | trailing_nulls_begin -= 1 183 | byte_list = byte_list[:trailing_nulls_begin] 184 | return byte_list.decode("utf-8") 185 | return unpack_bitstring(byte_list) 186 | if (reg_len := len(registers)) % data_len: 187 | raise Exception( 188 | f"Registers illegal size ({len(registers)}) expected multiple of {data_len}!" 189 | ) 190 | 191 | result = [] 192 | for i in range(0, reg_len, data_len): 193 | regs = registers[i:i+data_len] 194 | if word_order == "little": 195 | regs.reverse() 196 | byte_list = bytearray() 197 | for x in regs: 198 | byte_list.extend(int.to_bytes(x, 2, "big")) 199 | result.append(struct.unpack(f">{data_type.value[0]}", byte_list)[0]) 200 | return result if len(result) != 1 else result[0] 201 | 202 | def get_value_from_dict(self, d, k, default='NA'): 203 | v = d.get(k) 204 | if not v is None: 205 | return v 206 | return f'{default}' 207 | 208 | def convert_from_byte_uint16(self, byteArray, pos, type='BE'): 209 | try: 210 | if type == 'BE': 211 | result = byteArray[pos] * 256 + byteArray[pos + 1] 212 | else: 213 | result = byteArray[pos+1] * 256 + byteArray[pos] 214 | except: 215 | return 0 216 | return result 217 | 218 | def convert_from_byte_int16(self, byteArray, pos, type='BE'): 219 | try: 220 | if type == 'BE': 221 | result = byteArray[pos] * 256 + byteArray[pos + 1] 222 | else: 223 | result = byteArray[pos+1] * 256 + byteArray[pos] 224 | if (result > 32768): 225 | result -= 65536 226 | except: 227 | return 0 228 | return result 229 | 230 | def bitmask_to_strings(self, bitmask, bitmask_list, bits=16): 231 | strings = [] 232 | len_list = len(bitmask_list) 233 | for bit in range(bits): 234 | if bitmask & (1< upper_bound: 258 | _LOGGER.error(f'calculated value: {rvalue} above upper bound {upper_bound} value: {value} sf: {sf} digits {digits}', stack_info=True) 259 | return None 260 | return round(value * 10**sf, digits) 261 | else: 262 | _LOGGER.debug(f'cannot calculate non numeric value: {value} sf: {sf} digits {digits}', stack_info=True) 263 | return None 264 | 265 | def is_numeric(self, value): 266 | if isinstance(value, (int, float, complex)) and not isinstance(value, bool): 267 | return True 268 | return False 269 | 270 | def get_string_from_registers(self, regs): 271 | return self.strip_escapes(self._client.convert_from_registers(regs, data_type = self._client.DATATYPE.STRING)) 272 | -------------------------------------------------------------------------------- /custom_components/fronius_modbus/froniusmodbusclient.py: -------------------------------------------------------------------------------- 1 | #import os, sys; sys.path.append(os.path.dirname(os.path.realpath(__file__))) 2 | 3 | """BYD Battery Box Class""" 4 | 5 | import asyncio 6 | import logging 7 | from typing import Optional, Literal 8 | from .extmodbusclient import ExtModbusClient 9 | import requests 10 | 11 | from .froniusmodbusclient_const import ( 12 | INVERTER_ADDRESS, 13 | MPPT_ADDRESS, 14 | COMMON_ADDRESS, 15 | NAMEPLATE_ADDRESS, 16 | STORAGE_ADDRESS, 17 | METER_ADDRESS, 18 | STORAGE_CONTROL_MODE_ADDRESS, 19 | MINIMUM_RESERVE_ADDRESS, 20 | DISCHARGE_RATE_ADDRESS, 21 | CHARGE_RATE_ADDRESS, 22 | STORAGE_CONTROL_MODE, 23 | CHARGE_STATUS, 24 | CHARGE_GRID_STATUS, 25 | STORAGE_EXT_CONTROL_MODE, 26 | FRONIUS_INVERTER_STATUS, 27 | CONNECTION_STATUS_CONDENSED, 28 | ECP_CONNECTION_STATUS, 29 | INVERTER_CONTROLS, 30 | INVERTER_EVENTS, 31 | CONTROL_STATUS, 32 | GRID_STATUS, 33 | # INVERTER_STATUS, 34 | # CONNECTION_STATUS, 35 | ) 36 | 37 | _LOGGER = logging.getLogger(__name__) 38 | 39 | class FroniusModbusClient(ExtModbusClient): 40 | """Hub for BYD Battery Box Interface""" 41 | 42 | def __init__(self, host: str, port: int, inverter_unit_id: int, meter_unit_ids, timeout: int) -> None: 43 | """Init hub.""" 44 | super(FroniusModbusClient, self).__init__(host = host, port = port, unit_id=inverter_unit_id, timeout=timeout) 45 | 46 | self.initialized = False 47 | 48 | self._inverter_unit_id = inverter_unit_id 49 | self._meter_unit_ids = meter_unit_ids 50 | 51 | self.meter_configured = False 52 | self.mppt_configured = False 53 | self.storage_configured = False 54 | self.storage_extended_control_mode = 0 55 | self.max_charge_rate_w = 11000 56 | self.max_discharge_rate_w = 11000 57 | self._grid_frequency = 50 58 | self._grid_frequency_lower_bound = self._grid_frequency - 0.2 59 | self._grid_frequency_upper_bound = self._grid_frequency + 0.2 60 | 61 | self._inverter_frequency_lower_bound = self._grid_frequency - 5 62 | self._inverter_frequency_upper_bound = self._grid_frequency + 5 63 | 64 | self.data = {} 65 | 66 | async def init_data(self): 67 | await self.connect() 68 | try: 69 | result = await self.read_device_info_data(prefix='i_', unit_id=self._inverter_unit_id) 70 | except Exception as e: 71 | _LOGGER.error(f"Error reading inverter info {self._host}:{self._port} unit id: {self._inverter_unit_id}", exc_info=True) 72 | raise Exception(f"Error reading inverter info unit id: {self._inverter_unit_id}") 73 | if result == False: 74 | _LOGGER.error(f"Empty inverter info {self._host}:{self._port} unit id: {self._inverter_unit_id}") 75 | raise Exception(f"Empty inverter info unit id: {self._inverter_unit_id}") 76 | 77 | try: 78 | if await self.read_mppt_data(): 79 | self.mppt_configured = True 80 | except Exception as e: 81 | _LOGGER.warning(f"Error while checking mppt data {e}") 82 | 83 | if len(self._meter_unit_ids)>5: 84 | _LOGGER.error(f"Too many meters configured, max 5") 85 | return 86 | #elif len(self._meter_unit_ids)>0: 87 | # self.meter_configured = True 88 | 89 | for i in range(len(self._meter_unit_ids)): 90 | unit_id = self._meter_unit_ids[i] 91 | try: 92 | result = await self.read_device_info_data(prefix=f'm{i+1}_', unit_id=unit_id) 93 | if result: 94 | if not self.meter_configured: 95 | self.meter_configured = True 96 | else: 97 | _LOGGER.error(f"Failed reading meter info unit id: {unit_id}") 98 | except Exception as e: 99 | _LOGGER.error(f"Error reading meter info unit id: {unit_id}", exc_info=True) 100 | 101 | if await self.read_inverter_nameplate_data() == False: 102 | _LOGGER.error(f"Error reading nameplate data", exc_info=True) 103 | 104 | _LOGGER.debug(f"Init done. data: {self.data}") 105 | 106 | return True 107 | 108 | def get_json_storage_info(self): 109 | self.data['s_manufacturer'] = None 110 | self.data['s_model'] = 'Battery Storage' 111 | self.data['s_serial'] = None 112 | 113 | url = f"http://{self._host}/solar_api/v1/GetStorageRealtimeData.cgi" 114 | 115 | try: 116 | response = requests.get(url) 117 | 118 | if response.status_code == 200: 119 | data = response.json() 120 | else: 121 | _LOGGER.error(f"Error storage json data {response.status_code}") 122 | return 123 | 124 | try: 125 | bodydata = data['Body']['Data'] 126 | except Exception as e: 127 | _LOGGER.error(f"Error no body data in json data: {data}") 128 | return 129 | 130 | for c in bodydata.keys(): 131 | try: 132 | details = bodydata[c]['Controller']['Details'] 133 | except Exception as e: 134 | _LOGGER.error(f"Error no details in json bodydata: {bodydata}") 135 | return 136 | 137 | self.data['s_manufacturer'] = details['Manufacturer'] 138 | self.data['s_model'] = details['Model'] 139 | self.data['s_serial'] = str(details['Serial']).strip() 140 | break 141 | 142 | except Exception as e: 143 | _LOGGER.error(f"Error storage json data {url} {e}", exc_info=True) 144 | 145 | async def read_device_info_data(self, prefix, unit_id): 146 | regs = await self.get_registers(unit_id=unit_id, address=COMMON_ADDRESS, count=65) 147 | if regs is None: 148 | return False 149 | 150 | manufacturer = self.get_string_from_registers(regs[0:16]) 151 | model = self.get_string_from_registers(regs[16:32]) 152 | options = self.get_string_from_registers(regs[32:40]) 153 | sw_version = self.get_string_from_registers(regs[40:48]) 154 | serial = self.get_string_from_registers(regs[48:64]) 155 | modbus_id = self._client.convert_from_registers(regs[64:65], data_type = self._client.DATATYPE.UINT16) 156 | 157 | self.data[prefix + 'manufacturer'] = manufacturer 158 | self.data[prefix + 'model'] = model 159 | self.data[prefix + 'options'] = options 160 | self.data[prefix + 'sw_version'] = sw_version 161 | self.data[prefix + 'serial'] = serial 162 | self.data[prefix + 'unit_id'] = modbus_id 163 | 164 | return True 165 | 166 | async def read_inverter_data(self): 167 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=INVERTER_ADDRESS, count=50) 168 | if regs is None: 169 | return False 170 | 171 | PPVphAB = self._client.convert_from_registers(regs[5:6], data_type = self._client.DATATYPE.UINT16) 172 | PPVphBC = self._client.convert_from_registers(regs[6:7], data_type = self._client.DATATYPE.UINT16) 173 | PPVphCA = self._client.convert_from_registers(regs[7:8], data_type = self._client.DATATYPE.UINT16) 174 | PhVphA = self._client.convert_from_registers(regs[8:9], data_type = self._client.DATATYPE.UINT16) 175 | PhVphB = self._client.convert_from_registers(regs[9:10], data_type = self._client.DATATYPE.UINT16) 176 | PhVphC = self._client.convert_from_registers(regs[10:11], data_type = self._client.DATATYPE.UINT16) 177 | V_SF = self._client.convert_from_registers(regs[11:12], data_type = self._client.DATATYPE.INT16) 178 | 179 | W = self._client.convert_from_registers(regs[12:13], data_type = self._client.DATATYPE.INT16) 180 | W_SF = self._client.convert_from_registers(regs[13:14], data_type = self._client.DATATYPE.INT16) 181 | Hz = self._client.convert_from_registers(regs[14:15], data_type = self._client.DATATYPE.INT16) 182 | Hz_SF = self._client.convert_from_registers(regs[15:16], data_type = self._client.DATATYPE.INT16) 183 | 184 | WH = self._client.convert_from_registers(regs[22:24], data_type = self._client.DATATYPE.UINT32) 185 | WH_SF = self._client.convert_from_registers(regs[24:25], data_type = self._client.DATATYPE.INT16) 186 | 187 | TmpCab = self._client.convert_from_registers(regs[31:32], data_type = self._client.DATATYPE.INT16) 188 | Tmp_SF = self._client.convert_from_registers(regs[35:36], data_type = self._client.DATATYPE.INT16) 189 | #St = self._client.convert_from_registers(regs[36:37], data_type = self._client.DATATYPE.UINT16) 190 | StVnd = self._client.convert_from_registers(regs[37:38], data_type = self._client.DATATYPE.UINT16) 191 | #EvtVnd1 = self._client.convert_from_registers(regs[42:44], data_type = self._client.DATATYPE.UINT32) 192 | EvtVnd2 = self._client.convert_from_registers(regs[44:46], data_type = self._client.DATATYPE.UINT32) 193 | 194 | self.data['PPVphAB'] = self.calculate_value(PPVphAB, V_SF) 195 | self.data['PPVphBC'] = self.calculate_value(PPVphBC, V_SF) 196 | self.data['PPVphCA'] = self.calculate_value(PPVphCA, V_SF) 197 | self.data['PhVphA'] = self.calculate_value(PhVphA, V_SF) 198 | self.data['PhVphB'] = self.calculate_value(PhVphB, V_SF) 199 | self.data['PhVphC'] = self.calculate_value(PhVphC, V_SF) 200 | self.data['tempcab'] = self.calculate_value(TmpCab, Tmp_SF) 201 | self.data["acpower"] = self.calculate_value(W, W_SF, 2, -50000, 50000) 202 | self.data["line_frequency"] = self.calculate_value(Hz, Hz_SF, 2, 0, 100) 203 | self.data["acenergy"] = self.calculate_value(WH, WH_SF) 204 | #self.data["status"] = INVERTER_STATUS[St] 205 | self.data["statusvendor"] = FRONIUS_INVERTER_STATUS[StVnd] 206 | self.data["statusvendor_id"] = StVnd 207 | #self.data["events1"] = self.bitmask_to_string(EvtVnd1,INVERTER_EVENTS,default='None',bits=32) 208 | self.data["events2"] = self.bitmask_to_string(EvtVnd2,INVERTER_EVENTS,default='None',bits=32) 209 | 210 | return True 211 | 212 | async def read_inverter_nameplate_data(self): 213 | """start reading storage data""" 214 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=NAMEPLATE_ADDRESS, count=120) 215 | if regs is None: 216 | return False 217 | 218 | # DERTyp: Type of DER device. Default value is 4 to indicate PV device. 219 | DERTyp = self._client.convert_from_registers(regs[0:1], data_type = self._client.DATATYPE.UINT16) 220 | # WHRtg: Nominal energy rating of storage device. 221 | WHRtg = self._client.convert_from_registers(regs[17:18], data_type = self._client.DATATYPE.UINT16) 222 | # MaxChaRte: Maximum rate of energy transfer into the storage device. 223 | MaxChaRte = self._client.convert_from_registers(regs[21:22], data_type = self._client.DATATYPE.UINT16) 224 | # MaxDisChaRte: Maximum rate of energy transfer out of the storage device. 225 | MaxDisChaRte = self._client.convert_from_registers(regs[23:24], data_type = self._client.DATATYPE.UINT16) 226 | 227 | if DERTyp == 82: 228 | self.storage_configured = True 229 | self.data['WHRtg'] = WHRtg 230 | self.data['MaxChaRte'] = MaxChaRte 231 | self.data['MaxDisChaRte'] = MaxDisChaRte 232 | 233 | self.max_charge_rate_w = MaxChaRte 234 | self.max_discharge_rate_w = MaxDisChaRte 235 | 236 | return True 237 | 238 | async def read_inverter_status_data(self): 239 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=40183, count=44) 240 | if regs is None: 241 | return False 242 | 243 | PVConn = self._client.convert_from_registers(regs[0:1], data_type = self._client.DATATYPE.UINT16) 244 | StorConn = self._client.convert_from_registers(regs[1:2], data_type = self._client.DATATYPE.UINT16) 245 | ECPConn = self._client.convert_from_registers(regs[2:3], data_type = self._client.DATATYPE.UINT16) 246 | 247 | StActCtl = self._client.convert_from_registers(regs[33:35], data_type = self._client.DATATYPE.UINT32) 248 | 249 | self.data['pv_connection'] = CONNECTION_STATUS_CONDENSED[PVConn] 250 | self.data['storage_connection'] = CONNECTION_STATUS_CONDENSED[StorConn] 251 | self.data['ecp_connection'] = ECP_CONNECTION_STATUS[ECPConn] 252 | self.data['inverter_controls'] = self.bitmask_to_string(StActCtl, INVERTER_CONTROLS, 'Normal') 253 | 254 | return True 255 | 256 | async def read_inverter_model_settings_data(self): 257 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=40151, count=30) 258 | if regs is None: 259 | return False 260 | 261 | WMax = self._client.convert_from_registers(regs[0:1], data_type = self._client.DATATYPE.UINT16) 262 | #VRef = self._client.convert_from_registers(regs[1:2], data_type = self._client.DATATYPE.UINT16) 263 | #VRefOfs = self._client.convert_from_registers(regs[2:3], data_type = self._client.DATATYPE.UINT16) 264 | 265 | WMax_SF = self._client.convert_from_registers(regs[20:21], data_type = self._client.DATATYPE.INT16) 266 | #VRef_SF = self._client.convert_from_registers(regs[21:22], data_type = self._client.DATATYPE.INT16) 267 | #VRefOfs_SF = self._client.convert_from_registers(regs[21:22], data_type = self._client.DATATYPE.INT16) 268 | 269 | self.data['max_power'] = self.calculate_value(WMax, WMax_SF,2,0,50000) 270 | #self.data['vref'] = self.calculate_value(VRef, VRef_SF) # At PCC 271 | #self.data['vrefofs'] = self.calculate_value(VRefOfs, VRefOfs_SF) # At PCC 272 | 273 | return True 274 | 275 | async def read_inverter_controls_data(self): 276 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=40229, count=24) 277 | if regs is None: 278 | return False 279 | 280 | Conn = self._client.convert_from_registers(regs[2:3], data_type = self._client.DATATYPE.UINT16) 281 | WMaxLim_Ena = self._client.convert_from_registers(regs[7:8], data_type = self._client.DATATYPE.UINT16) 282 | OutPFSet_Ena = self._client.convert_from_registers(regs[12:13], data_type = self._client.DATATYPE.UINT16) 283 | VArPct_Ena = self._client.convert_from_registers(regs[20:21], data_type = self._client.DATATYPE.INT16) 284 | 285 | self.data['Conn'] = CONTROL_STATUS[Conn] 286 | self.data['WMaxLim_Ena'] = CONTROL_STATUS[WMaxLim_Ena] 287 | self.data['OutPFSet_Ena'] = CONTROL_STATUS[OutPFSet_Ena] 288 | self.data['VArPct_Ena'] = CONTROL_STATUS[VArPct_Ena] 289 | 290 | return True 291 | 292 | async def read_mppt_data(self): 293 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=MPPT_ADDRESS, count=88) 294 | if regs is None: 295 | return False 296 | 297 | DCW_SF = self._client.convert_from_registers(regs[2:3], data_type = self._client.DATATYPE.INT16) 298 | DCWH_SF = self._client.convert_from_registers(regs[3:4], data_type = self._client.DATATYPE.INT16) 299 | #N = self._client.convert_from_registers(regs[6:7], data_type = self._client.DATATYPE.UINT16) 300 | # if N != 4: 301 | # _LOGGER.error(f"Integration only supports 4 mppt modules. Found only: {N}") 302 | # return 303 | 304 | module_1_DCW = self._client.convert_from_registers(regs[19:20], data_type = self._client.DATATYPE.UINT16) 305 | module_1_DCWH = self._client.convert_from_registers(regs[20:22], data_type = self._client.DATATYPE.UINT32) 306 | 307 | module_2_DCW = self._client.convert_from_registers(regs[39:40], data_type = self._client.DATATYPE.UINT16) 308 | module_2_DCWH = self._client.convert_from_registers(regs[40:42], data_type = self._client.DATATYPE.UINT32) 309 | 310 | mppt1_power = self.calculate_value(module_1_DCW, DCW_SF, 2, 0, 15000) 311 | mppt2_power = self.calculate_value(module_2_DCW, DCW_SF, 2, 0, 15000) 312 | if not mppt1_power is None and not mppt2_power is None: 313 | pv_power = mppt1_power + mppt2_power 314 | else: 315 | pv_power = None 316 | 317 | mppt1_lfte = self.calculate_value(module_1_DCWH, DCWH_SF) 318 | mppt2_lfte = self.calculate_value(module_2_DCWH, DCWH_SF) 319 | 320 | self.data['mppt1_power'] = mppt1_power 321 | self.data['mppt2_power'] = mppt2_power 322 | self.data['pv_power'] = pv_power 323 | self.data['mppt1_lfte'] = mppt1_lfte 324 | self.data['mppt2_lfte'] = mppt2_lfte 325 | 326 | if self.storage_configured: 327 | module_3_DCW = self._client.convert_from_registers(regs[59:60], data_type = self._client.DATATYPE.UINT16) 328 | module_3_DCWH = self._client.convert_from_registers(regs[60:62], data_type = self._client.DATATYPE.UINT32) 329 | 330 | module_4_DCW = self._client.convert_from_registers(regs[79:80], data_type = self._client.DATATYPE.UINT16) 331 | module_4_DCWH = self._client.convert_from_registers(regs[80:82], data_type = self._client.DATATYPE.UINT32) 332 | 333 | mppt3_power = self.calculate_value(module_3_DCW, DCW_SF, 2, 0, 15000) 334 | mppt4_power = self.calculate_value(module_4_DCW, DCW_SF, 2, 0, 15000) 335 | if not mppt3_power is None and not mppt4_power is None: 336 | storage_power = mppt4_power - mppt3_power 337 | else: 338 | storage_power = None 339 | 340 | mppt3_lfte = self.calculate_value(module_3_DCWH, DCWH_SF) 341 | mppt4_lfte = self.calculate_value(module_4_DCWH, DCWH_SF) 342 | 343 | self.data['mppt3_power'] = mppt3_power 344 | self.data['mppt4_power'] = mppt4_power 345 | self.data['storage_power'] = storage_power 346 | 347 | self.data['mppt3_lfte'] = mppt3_lfte 348 | self.data['mppt4_lfte'] = mppt4_lfte 349 | 350 | return True 351 | 352 | async def read_inverter_storage_data(self): 353 | """start reading storage data""" 354 | regs = await self.get_registers(unit_id=self._inverter_unit_id, address=STORAGE_ADDRESS, count=24) 355 | if regs is None: 356 | return False 357 | 358 | # WChaMax: Reference Value for maximum Charge and Discharge. 359 | max_charge = self._client.convert_from_registers(regs[0:1], data_type = self._client.DATATYPE.UINT16) 360 | # WChaGra: Setpoint for maximum charging rate. Default is MaxChaRte. 361 | WChaGra = self._client.convert_from_registers(regs[1:2], data_type = self._client.DATATYPE.UINT16) 362 | # WDisChaGra: Setpoint for maximum discharge rate. Default is MaxDisChaRte. 363 | WDisChaGra = self._client.convert_from_registers(regs[2:3], data_type = self._client.DATATYPE.UINT16) 364 | # StorCtl_Mod: Active hold/discharge/charge storage control mode. 365 | storage_control_mode = self._client.convert_from_registers(regs[3:4], data_type = self._client.DATATYPE.UINT16) 366 | # VAChaMax: not supported 367 | # MinRsvPct: Setpoint for minimum reserve for storage as a percentage of the nominal maximum storage. 368 | minimum_reserve = self._client.convert_from_registers(regs[5:6], data_type = self._client.DATATYPE.UINT16) 369 | # ChaState: Currently available energy as a percent of the capacity rating. 370 | charge_state = self._client.convert_from_registers(regs[6:7], data_type = self._client.DATATYPE.UINT16) 371 | # StorAval: not supported 372 | # InBatV: not supported 373 | # ChaSt: Charge status of storage device. 374 | charge_status = self._client.convert_from_registers(regs[9:10], data_type = self._client.DATATYPE.UINT16) 375 | # OutWRte: Defines maximum Discharge rate. If not used than the default is 100 and WChaMax defines max. Discharge rate. 376 | discharge_power = self._client.convert_from_registers(regs[10:11], data_type = self._client.DATATYPE.INT16) 377 | # InWRte: Defines maximum Charge rate. If not used than the default is 100 and WChaMax defines max. Charge rate. 378 | charge_power = self._client.convert_from_registers(regs[11:12], data_type = self._client.DATATYPE.INT16) 379 | # InOutWRte_WinTms: not supported 380 | # InOutWRte_RvrtTms: Timeout period for charge/discharge rate. 381 | #InOutWRte_RvrtTms = self._client.convert_from_registers(regs[13:14], data_type = self._client.DATATYPE.INT16) 382 | # InOutWRte_RmpTms: not supported 383 | # ChaGriSet 384 | charge_grid_set = self._client.convert_from_registers(regs[15:16], data_type = self._client.DATATYPE.UINT16) 385 | # WChaMax_SF: Scale factor for maximum charge. 0 386 | #max_charge_sf = self._client.convert_from_registers(regs[16:17], data_type = self._client.DATATYPE.INT16) 387 | # WChaDisChaGra_SF: Scale factor for maximum charge and discharge rate. 0 388 | # VAChaMax_SF: not supported 389 | # MinRsvPct_SF: Scale factor for minimum reserve percentage. -2 390 | # ChaState_SF: Scale factor for available energy percent. -2 391 | #charge_state_sf = self._client.convert_from_registers(regs[20:21], data_type = self._client.DATATYPE.INT16) 392 | # StorAval_SF: not supported 393 | # InBatV_SF: not supported 394 | # InOutWRte_SF: Scale factor for percent charge/discharge rate. -2 395 | 396 | self.data['grid_charging'] = CHARGE_GRID_STATUS.get(charge_grid_set) 397 | #self.data['power'] = power 398 | self.data['charge_status'] = CHARGE_STATUS.get(charge_status) 399 | self.data['minimum_reserve'] = self.calculate_value(minimum_reserve, -2, 2, 0, 100) 400 | self.data['discharging_power'] = self.calculate_value(discharge_power, -2, 2, -100, 100) 401 | self.data['charging_power'] = self.calculate_value(charge_power, -2, 2, -100, 100) 402 | self.data['soc'] = self.calculate_value(charge_state, -2, 2, 0, 100) 403 | self.data['max_charge'] = self.calculate_value(max_charge, 0, 0) 404 | self.data['WChaGra'] = self.calculate_value(WChaGra, 0, 0) 405 | self.data['WDisChaGra'] = self.calculate_value(WDisChaGra, 0, 0) 406 | 407 | control_mode = self.data.get('control_mode') 408 | if control_mode is None or control_mode != STORAGE_CONTROL_MODE.get(storage_control_mode): 409 | if discharge_power >= 0: 410 | self.data['discharge_limit'] = discharge_power / 100.0 411 | self.data['grid_charge_power'] = 0 412 | else: 413 | self.data['grid_charge_power'] = (discharge_power * -1) / 100.0 414 | self.data['discharge_limit'] = 0 415 | if charge_power >= 0: 416 | self.data['charge_limit'] = charge_power / 100 417 | self.data['grid_discharge_power'] = 0 418 | else: 419 | self.data['grid_discharge_power'] = (charge_power * -1) / 100.0 420 | self.data['charge_limit'] = 0 421 | 422 | self.data['control_mode'] = STORAGE_CONTROL_MODE.get(storage_control_mode) 423 | 424 | # set extended storage control mode at startup 425 | ext_control_mode = self.data.get('ext_control_mode') 426 | if ext_control_mode is None: 427 | if storage_control_mode == 0: 428 | ext_control_mode = 0 429 | elif storage_control_mode in [1,3] and charge_power == 0: 430 | ext_control_mode = 7 431 | elif storage_control_mode == 1: 432 | ext_control_mode = 1 433 | elif storage_control_mode in [2,3] and discharge_power < 0: 434 | ext_control_mode = 4 435 | elif storage_control_mode in [2,3] and charge_power < 0: 436 | ext_control_mode = 5 437 | elif storage_control_mode in [2,3] and discharge_power == 0: 438 | ext_control_mode = 6 439 | elif storage_control_mode == 2: 440 | ext_control_mode = 2 441 | elif storage_control_mode == 3: 442 | ext_control_mode = 3 443 | self.data['ext_control_mode'] = STORAGE_EXT_CONTROL_MODE[ext_control_mode] 444 | self.storage_extended_control_mode = ext_control_mode 445 | 446 | if ext_control_mode == 7: 447 | soc = self.data.get('soc') 448 | if storage_control_mode == 2 and soc == 100: 449 | _LOGGER.error(f'Calibration hit 100%, start discharge') 450 | self.change_settings(1, 0, 100, 0) 451 | elif storage_control_mode == 3 and soc <= 5: 452 | _LOGGER.error(f'Calibration hit 5%, return to auto mode') 453 | self.set_auto_mode() 454 | self.set_minimum_reserve(30) 455 | self.data['ext_control_mode'] = STORAGE_EXT_CONTROL_MODE[0] 456 | self.storage_extended_control_mode = 0 457 | 458 | return True 459 | 460 | async def read_meter_data(self, meter_prefix, unit_id): 461 | """start reading meter data""" 462 | regs = await self.get_registers(unit_id=unit_id, address=METER_ADDRESS, count=103) 463 | if regs is None: 464 | return False 465 | 466 | PhVphA = self._client.convert_from_registers(regs[6:7], data_type = self._client.DATATYPE.INT16) 467 | PhVphB = self._client.convert_from_registers(regs[7:8], data_type = self._client.DATATYPE.INT16) 468 | PhVphC = self._client.convert_from_registers(regs[8:9], data_type = self._client.DATATYPE.INT16) 469 | PPV = self._client.convert_from_registers(regs[9:10], data_type = self._client.DATATYPE.INT16) 470 | V_SF = self._client.convert_from_registers(regs[13:14], data_type = self._client.DATATYPE.INT16) 471 | 472 | Hz = self._client.convert_from_registers(regs[14:15], data_type = self._client.DATATYPE.INT16) 473 | Hz_SF = self._client.convert_from_registers(regs[15:16], data_type = self._client.DATATYPE.INT16) 474 | W = self._client.convert_from_registers(regs[16:17], data_type = self._client.DATATYPE.INT16) 475 | W_SF = self._client.convert_from_registers(regs[20:21], data_type = self._client.DATATYPE.INT16) 476 | 477 | TotWhExp = self._client.convert_from_registers(regs[36:38], data_type = self._client.DATATYPE.UINT32) 478 | TotWhImp = self._client.convert_from_registers(regs[44:46], data_type = self._client.DATATYPE.UINT32) 479 | TotWh_SF = self._client.convert_from_registers(regs[52:53], data_type = self._client.DATATYPE.INT16) 480 | 481 | acpower = self.calculate_value(W, W_SF, 2, -50000, 50000) 482 | m_frequency = self.calculate_value(Hz, Hz_SF, 2, 0, 100) 483 | 484 | self.data[meter_prefix + "PhVphA"] = self.calculate_value(PhVphA, V_SF,1,0,1000) 485 | self.data[meter_prefix + "PhVphB"] = self.calculate_value(PhVphB, V_SF,1,0,1000) 486 | self.data[meter_prefix + "PhVphC"] = self.calculate_value(PhVphC, V_SF,1,0,1000) 487 | self.data[meter_prefix + "PPV"] = self.calculate_value(PPV, V_SF,1,0,1000) 488 | self.data[meter_prefix + "exported"] = self.calculate_value(TotWhExp, TotWh_SF) 489 | self.data[meter_prefix + "imported"] = self.calculate_value(TotWhImp, TotWh_SF) 490 | self.data[meter_prefix + "line_frequency"] = m_frequency 491 | self.data[meter_prefix + "power"] = acpower 492 | 493 | if meter_prefix == 'm1_': 494 | inverter_acpower = self.data.get('acpower') 495 | if not acpower is None and not inverter_acpower is None: 496 | if self.is_numeric(acpower) and self.is_numeric(inverter_acpower): 497 | self.data['load'] = round(acpower + inverter_acpower,2) 498 | elif not self.is_numeric(acpower): 499 | _LOGGER.error(f'meter {meter_prefix} acpower not numeric {acpower}') 500 | elif not self.is_numeric(inverter_acpower): 501 | _LOGGER.error(f'inverter acpower not numeric {inverter_acpower}') 502 | 503 | status_str = "" 504 | i_frequency = self.data["line_frequency"] 505 | #_LOGGER.debug(f'grid status m: {m_frequency} i: {i_frequency}') 506 | if not i_frequency is None and self.is_numeric(i_frequency) and not m_frequency is None and self.is_numeric(m_frequency): 507 | m_online = False 508 | if m_frequency and m_frequency > self._grid_frequency_lower_bound and m_frequency < self._grid_frequency_upper_bound: 509 | m_online = True 510 | 511 | if m_online and i_frequency > self._grid_frequency_lower_bound and i_frequency < self._grid_frequency_upper_bound: 512 | status_str = GRID_STATUS.get(3) 513 | elif not m_online and i_frequency > self._inverter_frequency_lower_bound and i_frequency < self._inverter_frequency_upper_bound: 514 | status_str = GRID_STATUS.get(1) 515 | elif i_frequency < 1: 516 | if m_online: 517 | status_str = GRID_STATUS.get(2) 518 | elif m_frequency < 1: 519 | status_str = GRID_STATUS.get(0) 520 | if status_str is None: 521 | _LOGGER.error(f'Could not establish grid connection status m: {m_frequency} i: {i_frequency}') 522 | self.data["grid_status"] = None 523 | else: 524 | self.data["grid_status"] = status_str 525 | 526 | return True 527 | 528 | async def set_storage_control_mode(self, mode: int): 529 | if not mode in [0,1,2,3]: 530 | _LOGGER.error(f'Attempted to set to unsupported storage control mode. Value: {mode}') 531 | return 532 | await self.write_registers(unit_id=self._inverter_unit_id, address=STORAGE_CONTROL_MODE_ADDRESS, payload=[mode]) 533 | 534 | async def set_minimum_reserve(self, minimum_reserve: float): 535 | if minimum_reserve < 5: 536 | _LOGGER.error(f'Attempted to set minimum reserve below 5%. Value: {minimum_reserve}') 537 | return 538 | minimum_reserve = round(minimum_reserve * 100) 539 | await self.write_registers(unit_id=self._inverter_unit_id, address=MINIMUM_RESERVE_ADDRESS, payload=[minimum_reserve]) 540 | 541 | async def set_discharge_rate_w(self, discharge_rate_w): 542 | if discharge_rate_w > self.max_discharge_rate_w: 543 | discharge_rate = 100 544 | elif discharge_rate_w < self.max_discharge_rate_w * -1: 545 | discharge_rate = -100 546 | else: 547 | discharge_rate = discharge_rate_w / self.max_discharge_rate_w * 100 548 | await self.set_discharge_rate(discharge_rate) 549 | 550 | async def set_discharge_rate(self, discharge_rate): 551 | if discharge_rate < 0: 552 | discharge_rate = int(65536 + (discharge_rate * 100)) 553 | else: 554 | discharge_rate = int(round(discharge_rate * 100)) 555 | await self.write_registers(unit_id=self._inverter_unit_id, address=DISCHARGE_RATE_ADDRESS, payload=[discharge_rate]) 556 | 557 | async def set_charge_rate_w(self, charge_rate_w): 558 | if charge_rate_w > self.max_charge_rate_w: 559 | charge_rate = 100 560 | elif charge_rate_w < self.max_charge_rate_w * -1: 561 | charge_rate = -100 562 | else: 563 | charge_rate = charge_rate_w / self.max_charge_rate_w * 100 564 | await self.set_charge_rate(charge_rate) 565 | 566 | async def set_grid_charge_power(self, value): 567 | if self.storage_extended_control_mode == 4: 568 | await self.set_discharge_rate_w(value * -1) 569 | self.data['grid_charge_power'] = value 570 | else: 571 | return 572 | 573 | async def set_grid_discharge_power(self, value): 574 | if self.storage_extended_control_mode == 5: 575 | await self.set_charge_rate_w(value * -1) 576 | self.data['grid_discharge_power'] = value 577 | else: 578 | return 579 | 580 | async def set_charge_limit(self, value): 581 | if self.storage_extended_control_mode in [1,3,6]: 582 | # only change when charge limit is used 583 | await self.set_charge_rate_w(value) 584 | self.data['charge_limit'] = value 585 | elif self.storage_extended_control_mode in [4,5,7]: 586 | return 587 | elif self.storage_extended_control_mode in [0,2]: 588 | return 589 | 590 | async def set_discharge_limit(self, value): 591 | if self.storage_extended_control_mode in [2,3,7]: 592 | # only change when discharge limit is used 593 | await self.set_discharge_rate_w(value) 594 | self.data['discharge_limit'] = value 595 | elif self.storage_extended_control_mode in [4,5,6]: 596 | return 597 | elif self.storage_extended_control_mode in [0,1]: 598 | return 599 | 600 | async def set_charge_rate(self, charge_rate): 601 | if charge_rate < 0: 602 | charge_rate = int(65536 + (charge_rate * 100)) 603 | else: 604 | charge_rate = int(round(charge_rate * 100)) 605 | await self.write_registers(unit_id=self._inverter_unit_id, address=CHARGE_RATE_ADDRESS, payload=[charge_rate]) 606 | 607 | async def change_settings(self, mode, charge_limit, discharge_limit, grid_charge_power=0, grid_discharge_power=0, minimum_reserve=None): 608 | await self.set_storage_control_mode(mode) 609 | await self.set_charge_rate(charge_limit) 610 | await self.set_discharge_rate(discharge_limit) 611 | self.data['charge_limit'] = charge_limit 612 | if self.storage_extended_control_mode == 4: 613 | self.data['discharge_limit'] = 0 614 | else: 615 | self.data['discharge_limit'] = discharge_limit 616 | if self.storage_extended_control_mode == 5: 617 | self.data['charge_limit'] = 0 618 | else: 619 | self.data['charge_limit'] = charge_limit 620 | self.data['grid_charge_power'] = grid_charge_power 621 | self.data['grid_discharge_power'] = grid_discharge_power 622 | if not minimum_reserve is None: 623 | await self.set_minimum_reserve(minimum_reserve) 624 | 625 | async def restore_defaults(self): 626 | await self.change_settings(mode=0, charge_limit=100, discharge_limit=100, minimum_reserve=7) 627 | _LOGGER.info(f"restored defaults") 628 | 629 | async def set_auto_mode(self): 630 | await self.change_settings(mode=0, charge_limit=100, discharge_limit=100) 631 | self.storage_extended_control_mode = 0 632 | _LOGGER.info(f"Auto mode") 633 | 634 | async def set_charge_mode(self): 635 | await self.change_settings(mode=1, charge_limit=100, discharge_limit=100) 636 | self.storage_extended_control_mode = 1 637 | _LOGGER.info(f"Set charge mode") 638 | 639 | async def set_discharge_mode(self): 640 | await self.change_settings(mode=2, charge_limit=100, discharge_limit=100) 641 | self.storage_extended_control_mode = 2 642 | _LOGGER.info(f"Set discharge mode") 643 | 644 | async def set_charge_discharge_mode(self): 645 | await self.change_settings(mode=3, charge_limit=100, discharge_limit=100) 646 | self.storage_extended_control_mode = 3 647 | _LOGGER.info(f"Set charge/discharge mode.") 648 | 649 | async def set_grid_charge_mode(self): 650 | grid_charge_power = 0 651 | discharge_rate = grid_charge_power * -1 652 | await self.change_settings(mode=2, charge_limit=100, discharge_limit=discharge_rate, grid_charge_power=grid_charge_power) 653 | self.storage_extended_control_mode = 4 654 | _LOGGER.info(f"Forced charging at {grid_charge_power}") 655 | 656 | async def set_grid_discharge_mode(self): 657 | grid_discharge_power = 0 658 | charge_rate = grid_discharge_power * -1 659 | await self.change_settings(mode=1, charge_limit=charge_rate, discharge_limit=100, grid_discharge_power=grid_discharge_power) 660 | self.storage_extended_control_mode = 5 661 | _LOGGER.info(f"Forced discharging to grid {grid_discharge_power}") 662 | 663 | async def set_block_discharge_mode(self): 664 | charge_rate = 100 665 | await self.change_settings(mode=3, charge_limit=charge_rate, discharge_limit=0) 666 | self.storage_extended_control_mode = 6 667 | _LOGGER.info(f"blocked discharging") 668 | 669 | async def set_block_charge_mode(self): 670 | discharge_rate = 100 671 | await self.change_settings(mode=3, charge_limit=0, discharge_limit=discharge_rate) 672 | self.storage_extended_control_mode = 7 673 | _LOGGER.info(f"Block charging at {discharge_rate}") 674 | 675 | async def set_calibrate_mode(self): 676 | await self.change_settings(mode=2, charge_limit=100, discharge_limit=-100, grid_charge_power=100) 677 | self.storage_extended_control_mode = 8 678 | _LOGGER.info(f"Auto mode") 679 | --------------------------------------------------------------------------------