├── custom_components └── solarman │ ├── pysolarmanv5 │ ├── __init__.py │ ├── __pycache__ │ │ ├── __init__.cpython-39.pyc │ │ └── pysolarmanv5.cpython-39.pyc │ └── pysolarmanv5.py │ ├── manifest.json │ ├── strings.json │ ├── translations │ └── en.json │ ├── __init__.py │ ├── test.py │ ├── config_flow.py │ ├── const.py │ └── sensor.py └── README.md /custom_components/solarman/pysolarmanv5/__init__.py: -------------------------------------------------------------------------------- 1 | name = "pysolarmanv5" 2 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarmanv5/__pycache__/__init__.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YodaDaCoda/hass-solarman-modbus/HEAD/custom_components/solarman/pysolarmanv5/__pycache__/__init__.cpython-39.pyc -------------------------------------------------------------------------------- /custom_components/solarman/pysolarmanv5/__pycache__/pysolarmanv5.cpython-39.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YodaDaCoda/hass-solarman-modbus/HEAD/custom_components/solarman/pysolarmanv5/__pycache__/pysolarmanv5.cpython-39.pyc -------------------------------------------------------------------------------- /custom_components/solarman/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "solarman-modbus", 3 | "version": "0.0.1", 4 | "name": "SolarMAN Logger", 5 | "documentation": "https://www.home-assistant.io/components/solarman-modbus", 6 | "issue_tracker": "https://github.com/YodaDaCoda/hass-solarman-modbus/issues", 7 | "codeowners": ["@YodaDaCoda"], 8 | "config_flow": true, 9 | "dependencies": [], 10 | "after_dependencies": [], 11 | "requirements": ["uModbus==1.0.4"], 12 | "iot_class": "local_polling", 13 | "quality_scale": "silver" 14 | } -------------------------------------------------------------------------------- /custom_components/solarman/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" 5 | }, 6 | "error": { 7 | "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", 8 | "invalid_auth": "[%key:common::config_flow::error::invalid_host%]", 9 | "unknown": "[%key:common::config_flow::error::unknown%]" 10 | }, 11 | "step": { 12 | "user": { 13 | "data": { 14 | "host": "Host (ip or hostname)", 15 | "port": "Port (usually 8899)", 16 | "device_id": "Device Serial Number (retrieve from the web interface)" 17 | } 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /custom_components/solarman/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "Device is already configured" 5 | }, 6 | "error": { 7 | "cannot_connect": "Failed to connect", 8 | "invalid_host": "Invalid hostname or IP address", 9 | "unknown": "Unexpected error" 10 | }, 11 | "step": { 12 | "user": { 13 | "title": "Inverter connection configuration", 14 | "data": { 15 | "host": "Host (ip or hostname)", 16 | "port": "Port (usually 8899)", 17 | "device_id": "Device Serial Number (retrieve from the web interface)" 18 | } 19 | } 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # THIS PROJECT HAS BEEN ARCHIVED 2 | The component by @StephanJoubert is superior to this & I recommend you use that instead https://github.com/StephanJoubert/home_assistant_solarman 3 | # hass-solarman-modbus 4 | 5 | ## Home Assistant custom component for solar inverters that communicate over the solarmanv5 protocol. 6 | 7 | Tested using LSW-3 model data logger stick attached to a Sofar inverter. Support for other data loggers / inverters is planned. 8 | 9 | Polls the inverter / data logger every minute and updates the sensors accordingly. 10 | 11 | ## Setup 12 | Configure with the IP address and serial number of the inverter / data logger. The port is normally 8899 but some devices may differ. You can retrieve the serial number of the inverter from its web interface. 13 | 14 | ## Acknowlegements 15 | Thanks to jmccrohan for the amazing `pysolarmanv5` library. 16 | Thanks to @KodeCR upon whose own solarman data logger codebase this is based. -------------------------------------------------------------------------------- /custom_components/solarman/__init__.py: -------------------------------------------------------------------------------- 1 | """The SolarMAN logger integration.""" 2 | from __future__ import annotations 3 | from asyncio.streams import StreamReader, StreamWriter 4 | 5 | import logging 6 | 7 | from homeassistant.config_entries import ConfigType, ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.const import CONF_HOST, CONF_PORT, CONF_DEVICE_ID 10 | 11 | from .const import DOMAIN 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | PLATFORMS: list[str] = ["sensor"] 16 | 17 | 18 | # async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 19 | # """Set up the SolarMAN logger component.""" 20 | # hass.data.setdefault(DOMAIN, {}) 21 | # return True 22 | 23 | 24 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 25 | """Set up SolarMAN logger from a config entry.""" 26 | _LOGGER.debug(f'__init__.py:async_setup_entry({entry.as_dict()})') 27 | hass.config_entries.async_setup_platforms(entry, PLATFORMS) 28 | entry.async_on_unload(entry.add_update_listener(update_listener)) 29 | return True 30 | 31 | 32 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 33 | """Unload a config entry.""" 34 | _LOGGER.debug(f'__init__.py:async_unload_entry({entry.as_dict()})') 35 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 36 | if unload_ok: 37 | hass.data[DOMAIN][entry.entry_id].sock.close() 38 | hass.data[DOMAIN].pop(entry.entry_id) 39 | return unload_ok 40 | 41 | 42 | async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 43 | """Handle options update.""" 44 | _LOGGER.debug(f'__init__.py:update_listener({entry.as_dict()})') 45 | hass.data[DOMAIN][entry.entry_id].config(entry) 46 | entry.title = entry.options[CONF_HOST] 47 | 48 | -------------------------------------------------------------------------------- /custom_components/solarman/test.py: -------------------------------------------------------------------------------- 1 | """ A basic client demonstrating how to use pysolarmanv5.""" 2 | from pysolarmanv5.pysolarmanv5 import PySolarmanV5 3 | 4 | 5 | def main(): 6 | """Create new PySolarman instance, using IP address and S/N of data logger 7 | 8 | Only IP address and S/N of data logger are mandatory parameters. If port, 9 | mb_slave_id, and verbose are omitted, they will default to 8899, 1 and 0 10 | respectively. 11 | """ 12 | modbus = PySolarmanV5( 13 | "192.168.2.51", 1773158214, port=8899, mb_slave_id=1, verbose=1 14 | ) 15 | 16 | print(modbus.read_holding_registers(register_addr=0, quantity=40)) 17 | 18 | # """Query six input registers, results as a list""" 19 | # print(modbus.read_input_registers(register_addr=33022, quantity=6)) 20 | 21 | # """Query six holding registers, results as list""" 22 | # print(modbus.read_holding_registers(register_addr=43000, quantity=6)) 23 | 24 | # """Query single input register, result as an int""" 25 | # print(modbus.read_input_register_formatted(register_addr=33035, quantity=1)) 26 | 27 | # """Query single input register, apply scaling, result as a float""" 28 | # print( 29 | # modbus.read_input_register_formatted(register_addr=33035, quantity=1, scale=0.1) 30 | # ) 31 | 32 | # """Query two input registers, shift first register up by 16 bits, result as a signed int, """ 33 | # print( 34 | # modbus.read_input_register_formatted(register_addr=33079, quantity=2, signed=1) 35 | # ) 36 | 37 | # """Query single holding register, apply bitmask and bitshift left (extract bit1 from register)""" 38 | # print( 39 | # modbus.read_holding_register_formatted( 40 | # register_addr=43110, quantity=1, bitmask=0x2, bitshift=1 41 | # ) 42 | # ) 43 | 44 | 45 | if __name__ == "__main__": 46 | main() -------------------------------------------------------------------------------- /custom_components/solarman/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for SolarMAN logger integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Any 6 | from socket import getaddrinfo, herror, gaierror, timeout 7 | 8 | import voluptuous as vol 9 | from voluptuous.schema_builder import Schema 10 | 11 | from homeassistant import config_entries 12 | from homeassistant.core import HomeAssistant, callback 13 | from homeassistant.data_entry_flow import FlowResult 14 | from homeassistant.exceptions import HomeAssistantError 15 | from homeassistant.const import CONF_HOST, CONF_PORT, CONF_DEVICE, CONF_DEVICE_ID 16 | 17 | from .const import DOMAIN, SENSOR_DEFINITIONS 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | def step_user_data_schema(data: dict[str, Any] = {CONF_PORT: 8899}) -> Schema: 23 | _LOGGER.debug(f'config_flow.py:step_user_data_schema: {data}') 24 | STEP_USER_DATA_SCHEMA = vol.Schema( 25 | { 26 | vol.Required(CONF_HOST, default=data.get(CONF_HOST)): str, 27 | vol.Required(CONF_PORT, default=data.get(CONF_PORT)): int, 28 | vol.Required(CONF_DEVICE, default=data.get(CONF_DEVICE)): vol.In([device.name for device in SENSOR_DEFINITIONS.keys()]), 29 | vol.Required(CONF_DEVICE_ID, default=data.get(CONF_DEVICE_ID)): int, 30 | } 31 | ) 32 | return STEP_USER_DATA_SCHEMA 33 | 34 | 35 | async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: 36 | """Validate the user input allows us to connect. 37 | 38 | Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 39 | """ 40 | 41 | _LOGGER.debug(f'config_flow.py:validate_input: {data}') 42 | 43 | try: 44 | getaddrinfo( 45 | data[CONF_HOST], data[CONF_PORT], family=0, type=0, proto=0, flags=0 46 | ) 47 | except herror: 48 | raise InvalidHost 49 | except gaierror: 50 | raise CannotConnect 51 | except timeout: 52 | raise CannotConnect 53 | 54 | return {"title": data[CONF_HOST]} 55 | 56 | 57 | class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 58 | """Handle a config flow for SolarMAN logger.""" 59 | 60 | VERSION = 1 61 | 62 | @staticmethod 63 | @callback 64 | def async_get_options_flow(entry: config_entries.ConfigEntry) -> OptionsFlow: 65 | """Get the options flow for this handler.""" 66 | _LOGGER.debug(f'config_flow.py:ConfigFlow.async_get_options_flow: {entry}') 67 | return OptionsFlow(entry) 68 | 69 | async def async_step_user( 70 | self, user_input: dict[str, Any] | None = None 71 | ) -> FlowResult: 72 | _LOGGER.debug(f'config_flow.py:ConfigFlow.async_step_user: {user_input}') 73 | """Handle the initial step.""" 74 | if user_input is None: 75 | return self.async_show_form( 76 | step_id="user", data_schema=step_user_data_schema() 77 | ) 78 | 79 | errors = {} 80 | 81 | try: 82 | info = await validate_input(self.hass, user_input) 83 | except InvalidHost: 84 | errors["base"] = "invalid_host" 85 | except CannotConnect: 86 | errors["base"] = "cannot_connect" 87 | except Exception: # pylint: disable=broad-except 88 | _LOGGER.exception("Unexpected exception") 89 | errors["base"] = "unknown" 90 | else: 91 | _LOGGER.debug(f'config_flow.py:ConfigFlow.async_step_user: validation passed: {user_input}') 92 | # await self.async_set_unique_id(user_input.device_id) # not sure this is permitted as the user can change the device_id 93 | # self._abort_if_unique_id_configured() 94 | return self.async_create_entry( 95 | title=info["title"], data=user_input, options=user_input 96 | ) 97 | 98 | _LOGGER.debug(f'config_flow.py:ConfigFlow.async_step_user: validation failed: {user_input}') 99 | 100 | return self.async_show_form( 101 | step_id="user", 102 | data_schema=step_user_data_schema(user_input), 103 | errors=errors, 104 | ) 105 | 106 | 107 | class OptionsFlow(config_entries.OptionsFlow): 108 | """Handle options.""" 109 | 110 | def __init__(self, entry: config_entries.ConfigEntry) -> None: 111 | """Initialize options flow.""" 112 | _LOGGER.debug(f'config_flow.py:OptionsFlow.__init__: {entry}') 113 | self.entry = entry 114 | 115 | async def async_step_init( 116 | self, user_input: dict[str, Any] | None = None 117 | ) -> FlowResult: 118 | """Manage the options.""" 119 | _LOGGER.debug(f'config_flow.py:OptionsFlow.async_step_init: {user_input}') 120 | if user_input is None: 121 | return self.async_show_form( 122 | step_id="init", 123 | data_schema=step_user_data_schema(self.entry.options), 124 | ) 125 | 126 | errors = {} 127 | 128 | try: 129 | info = await validate_input(self.hass, user_input) 130 | except InvalidHost: 131 | errors["base"] = "invalid_host" 132 | except CannotConnect: 133 | errors["base"] = "cannot_connect" 134 | except Exception: # pylint: disable=broad-except 135 | _LOGGER.exception("Unexpected exception") 136 | errors["base"] = "unknown" 137 | else: 138 | return self.async_create_entry(title=info["title"], data=user_input) 139 | 140 | return self.async_show_form( 141 | step_id="init", 142 | data_schema=step_user_data_schema(user_input), 143 | errors=errors, 144 | ) 145 | 146 | 147 | class CannotConnect(HomeAssistantError): 148 | """Error to indicate we cannot connect.""" 149 | 150 | 151 | class InvalidHost(HomeAssistantError): 152 | """Error to indicate there is invalid hostname or IP address.""" 153 | -------------------------------------------------------------------------------- /custom_components/solarman/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the SolarMAN logger integration.""" 2 | from typing import Final 3 | from enum import Enum 4 | 5 | DOMAIN = "solarman-modbus" 6 | 7 | SENSOR_VDC1: Final = "Voltage DC1" 8 | SENSOR_IDC1: Final = "Current DC1" 9 | SENSOR_VDC2: Final = "Voltage DC2" 10 | SENSOR_IDC2: Final = "Current DC2" 11 | SENSOR_VAC: Final = "Voltage AC" 12 | SENSOR_IAC: Final = "Current AC" 13 | SENSOR_FREQ: Final = "Frequency" 14 | SENSOR_TEMP: Final = "Temperature" 15 | SENSOR_PWR: Final = "Power" 16 | SENSOR_ENERGY_DAY: Final = "Energy Today" 17 | SENSOR_ENERGY_TOT: Final = "Energy Total" 18 | SENSOR_HRS: Final = "Hours Total" 19 | 20 | from homeassistant.const import ( 21 | ELECTRIC_POTENTIAL_VOLT, 22 | ELECTRIC_CURRENT_AMPERE, 23 | POWER_WATT, 24 | POWER_KILO_WATT, 25 | TEMP_CELSIUS, 26 | FREQUENCY_HERTZ, 27 | ENERGY_KILO_WATT_HOUR, 28 | TIME_HOURS 29 | ) 30 | 31 | from homeassistant.components.sensor import ( 32 | SensorDeviceClass, 33 | STATE_CLASS_MEASUREMENT, 34 | STATE_CLASS_TOTAL_INCREASING, 35 | ) 36 | 37 | 38 | class Device(Enum): 39 | LSW3 = 'LSW-3' 40 | 41 | class Sensor(Enum): 42 | VDC1 = "Voltage DC1" 43 | IDC1 = "Current DC1" 44 | PDC1 = "Power DC1" 45 | VDC2 = "Voltage DC2" 46 | IDC2 = "Current DC2" 47 | PDC2 = "Power DC2" 48 | VAC = "Voltage AC" 49 | IAC = "Current AC" 50 | FREQ = "Frequency" 51 | TEMP = "Temperature" 52 | PWR = "Power" 53 | ENERGY_DAY = "Energy Today" 54 | ENERGY_TOT = "Energy Total" 55 | HRS = "Hours Total" 56 | 57 | class SensorDefinition: 58 | """A definition of a sensor 59 | 60 | :param name: The human-readable name of the sensor 61 | :param device_class: The HomeAssistant class of the device, e.g. DEVICE_CLASS_VOLTAGE 62 | :param state_class: The HomeAssistant class of the device's state, e.g. STATE_CLASS_MEASUREMENT for one-off measurements or STATE_CLASS_TOTAL_INCREASING for measurements that count up 63 | :param unit: The units of the measurement, e.g. ELECTRIC_POTENTIAL_VOLT (volts), ELECTRIC_CURRENT_AMPERE (amps) 64 | :param mb_offset: The modbus register offset for this sensor reading 65 | :param mb_size: The size of this sensor reading in bytes in the modbus response. Most sensor readings are 2-bytes, some are more. 66 | :param mb_mult: A multiplier to apply to the raw modbus value to convert it into the appropriate units. 67 | """ 68 | def __init__( 69 | self, 70 | name: str, 71 | device_class: str, 72 | state_class: str, 73 | unit: str, 74 | mb_offset: int, 75 | mb_size: int = 2, 76 | mb_mult: int = 1 77 | ): 78 | self.name = name 79 | self.device_class = device_class 80 | self.state_class = state_class 81 | self.unit = unit 82 | self.mb_offset = mb_offset 83 | self.mb_size = mb_size 84 | self.mb_mult = mb_mult 85 | 86 | # NB: device sensors MUST be in order of mb_offset 87 | SENSOR_DEFINITIONS: Final = { 88 | Device.LSW3: [ 89 | SensorDefinition( 90 | name=Sensor.VDC1.value, 91 | device_class=SensorDeviceClass.VOLTAGE, 92 | state_class=STATE_CLASS_MEASUREMENT, 93 | unit=ELECTRIC_POTENTIAL_VOLT, 94 | mb_offset=6, 95 | mb_size=2, 96 | mb_mult=0.1, 97 | ), 98 | SensorDefinition( 99 | name=Sensor.IDC1.value, 100 | device_class=SensorDeviceClass.CURRENT, 101 | state_class=STATE_CLASS_MEASUREMENT, 102 | unit=ELECTRIC_CURRENT_AMPERE, 103 | mb_offset=7, 104 | mb_size=2, 105 | mb_mult=0.01, 106 | ), 107 | SensorDefinition( 108 | name=Sensor.VDC2.value, 109 | device_class=SensorDeviceClass.VOLTAGE, 110 | state_class=STATE_CLASS_MEASUREMENT, 111 | unit=ELECTRIC_POTENTIAL_VOLT, 112 | mb_offset=8, 113 | mb_size=2, 114 | mb_mult=0.1, 115 | ), 116 | SensorDefinition( 117 | name=Sensor.IDC2.value, 118 | device_class=SensorDeviceClass.CURRENT, 119 | state_class=STATE_CLASS_MEASUREMENT, 120 | unit=ELECTRIC_CURRENT_AMPERE, 121 | mb_offset=9, 122 | mb_size=2, 123 | mb_mult=0.01, 124 | ), 125 | SensorDefinition( 126 | name=Sensor.PDC1.value, 127 | device_class=SensorDeviceClass.POWER, 128 | state_class=STATE_CLASS_MEASUREMENT, 129 | unit=POWER_WATT, 130 | mb_offset=10, 131 | mb_size=2, 132 | mb_mult=10, 133 | ), 134 | SensorDefinition( 135 | name=Sensor.PDC2.value, 136 | device_class=SensorDeviceClass.POWER, 137 | state_class=STATE_CLASS_MEASUREMENT, 138 | unit=POWER_WATT, 139 | mb_offset=11, 140 | mb_size=2, 141 | mb_mult=10, 142 | ), 143 | SensorDefinition( 144 | name=Sensor.PWR.value, 145 | device_class=SensorDeviceClass.POWER, 146 | state_class=STATE_CLASS_MEASUREMENT, 147 | unit=POWER_KILO_WATT, 148 | mb_offset=12, # might be 14, need to check this 149 | mb_size=2, 150 | mb_mult=0.01, 151 | ), 152 | SensorDefinition( 153 | name=Sensor.FREQ.value, 154 | device_class=SensorDeviceClass.FREQUENCY, 155 | state_class=STATE_CLASS_MEASUREMENT, 156 | unit=FREQUENCY_HERTZ, 157 | mb_offset=14, 158 | mb_size=2, 159 | mb_mult=0.01, 160 | ), 161 | SensorDefinition( 162 | name=Sensor.VAC.value, 163 | device_class=SensorDeviceClass.VOLTAGE, 164 | state_class=STATE_CLASS_MEASUREMENT, 165 | unit=ELECTRIC_POTENTIAL_VOLT, 166 | mb_offset=15, 167 | mb_size=2, 168 | mb_mult=0.1, 169 | ), 170 | SensorDefinition( 171 | name=Sensor.IAC.value, 172 | device_class=SensorDeviceClass.CURRENT, 173 | state_class=STATE_CLASS_MEASUREMENT, 174 | unit=ELECTRIC_CURRENT_AMPERE, 175 | mb_offset=16, 176 | mb_size=2, 177 | mb_mult=0.01, 178 | ), 179 | SensorDefinition( 180 | name=Sensor.ENERGY_TOT.value, 181 | device_class=SensorDeviceClass.ENERGY, 182 | state_class=STATE_CLASS_TOTAL_INCREASING, 183 | unit=ENERGY_KILO_WATT_HOUR, 184 | mb_offset=22, 185 | mb_size=4, 186 | mb_mult=1, 187 | ), 188 | SensorDefinition( 189 | name=Sensor.HRS.value, 190 | device_class=None, 191 | state_class=STATE_CLASS_TOTAL_INCREASING, 192 | unit=TIME_HOURS, 193 | mb_offset=24, 194 | mb_size=4, 195 | mb_mult=1, 196 | ), 197 | SensorDefinition( 198 | name=Sensor.ENERGY_DAY.value, 199 | device_class=SensorDeviceClass.ENERGY, 200 | state_class=STATE_CLASS_TOTAL_INCREASING, 201 | unit=ENERGY_KILO_WATT_HOUR, 202 | mb_offset=25, 203 | mb_size=2, 204 | mb_mult=0.01, 205 | ), 206 | SensorDefinition( 207 | name=Sensor.TEMP.value, 208 | device_class=SensorDeviceClass.TEMPERATURE, 209 | state_class=STATE_CLASS_MEASUREMENT, 210 | unit=TEMP_CELSIUS, 211 | mb_offset=27, 212 | mb_size=2, 213 | mb_mult=1, 214 | ), 215 | 216 | ] 217 | } -------------------------------------------------------------------------------- /custom_components/solarman/sensor.py: -------------------------------------------------------------------------------- 1 | """Platform for SolarMAN logger sensor integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from datetime import timedelta 7 | 8 | import asyncio 9 | import async_timeout 10 | 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.config_entries import ConfigEntry 13 | from homeassistant.util.dt import utc_from_timestamp 14 | from homeassistant.helpers.entity import DeviceInfo 15 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 16 | from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 17 | from homeassistant.components.sensor import ( 18 | SensorEntity, 19 | ) 20 | from homeassistant.helpers.update_coordinator import ( 21 | CoordinatorEntity, 22 | DataUpdateCoordinator, 23 | UpdateFailed, 24 | ) 25 | 26 | from homeassistant.const import ( 27 | CONF_HOST, 28 | CONF_PORT, 29 | CONF_DEVICE, 30 | CONF_DEVICE_ID, 31 | ) 32 | 33 | from .const import ( 34 | DOMAIN, 35 | Device, 36 | SENSOR_DEFINITIONS, 37 | ) 38 | 39 | from .pysolarmanv5.pysolarmanv5 import PySolarmanV5 40 | 41 | _LOGGER = logging.getLogger(__name__) 42 | 43 | 44 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 45 | """Config entry example.""" 46 | 47 | _LOGGER.debug(f'sensor.py:async_setup_entry:({entry.options})') 48 | 49 | async def async_update_data(): 50 | """Fetch data from API endpoint. 51 | 52 | This is the place to pre-process the data to lookup tables 53 | so entities can quickly look up their data. 54 | """ 55 | _LOGGER.debug(f'sensor.py:async_setup_entry.async_update_data: refresh') 56 | try: 57 | # Note: asyncio.TimeoutError and aiohttp.ClientError are already 58 | # handled by the data update coordinator. 59 | async with async_timeout.timeout(60): 60 | return await inverter.async_update_data() 61 | except OSError as err: 62 | raise UpdateFailed(f"Error communicating with inverter: {err}") 63 | 64 | coordinator = DataUpdateCoordinator( 65 | hass, 66 | _LOGGER, 67 | # Name of the data. For logging purposes. 68 | name="sensor", 69 | update_method=async_update_data, 70 | # Polling interval. Will only be polled if there are subscribers. 71 | update_interval=timedelta(seconds=30), 72 | ) 73 | _LOGGER.debug(f'sensor.py:async_setup_entry: got update coordinator {coordinator}') 74 | 75 | inverter = Inverter(hass, entry=entry, device=Device[entry.options[CONF_DEVICE]]) 76 | _LOGGER.debug(f'sensor.py:async_setup_entry: got inverter {inverter}') 77 | 78 | sensors = [SolarMANSensor(inverter=inverter, definition=definition, coordinator=coordinator) for definition in SENSOR_DEFINITIONS[inverter.device]] 79 | inverter.set_sensors(sensors) 80 | _LOGGER.debug(f'sensor.py:async_setup_entry: sensors created') 81 | 82 | # 83 | # Fetch initial data so we have data when entities subscribe 84 | # 85 | # If the refresh fails, async_config_entry_first_refresh will 86 | # raise ConfigEntryNotReady and setup will try again later 87 | # 88 | # If you do not want to retry setup on failure, use 89 | # coordinator.async_refresh() instead 90 | # 91 | _LOGGER.debug(f'sensor.py:async_setup_entry: first refresh') 92 | await coordinator.async_config_entry_first_refresh() 93 | 94 | async_add_entities(sensors) 95 | 96 | class Inverter: 97 | """The solar inverter.""" 98 | 99 | def __init__(self, hass: HomeAssistant, entry: ConfigEntry, device: str) -> None: 100 | """Init solar inverter.""" 101 | _LOGGER.debug(f'sensor.py:Inverter.__init__({entry.as_dict()})') 102 | self._hass = hass 103 | self.host = "" 104 | self.port = 0 105 | self.server = None 106 | self.data = {} 107 | self.raw_data = [] 108 | self.device = device 109 | self.sensor_list = [] 110 | self.sensors_dict = {} 111 | self.config(entry) 112 | 113 | def config(self, entry: ConfigEntry) -> None: 114 | _LOGGER.debug(f'sensor.py:Inverter.config({entry.as_dict()})') 115 | self.host = entry.options[CONF_HOST] 116 | self.port = entry.options[CONF_PORT] 117 | self.serial = entry.options[CONF_DEVICE_ID] 118 | self._modbus = None 119 | 120 | def set_sensors(self, sensors: list[SolarMANSensor]): 121 | self.sensor_list = sensors 122 | self.sensors_dict = {sensor.name: sensor for sensor in sensors} 123 | 124 | async def async_update_sensors(self): 125 | _LOGGER.debug('sensor.py:Inverter.async_update_sensors') 126 | _LOGGER.debug(f'sensor.py:Inverter.async_update_sensors: sensors: {self.sensor_list}') 127 | await asyncio.gather(*[sensor.async_update_value(dict(enumerate(self.raw_data)).get(sensor.mb_offset)) for sensor in self.sensor_list]) 128 | 129 | def create_connection(self): 130 | _LOGGER.debug(f'sensor.py:Inverter.create_connection: instantiating modbus interface') 131 | # TODO: somehow we need to catch error from endpoint unavailable and mark entity as gone 132 | # e.g. when the sun goes down and the logger is unpowered 133 | if self._modbus and self._modbus.sock: 134 | try: 135 | self._modbus.sock.close() 136 | except: 137 | pass 138 | self._modbus = PySolarmanV5( 139 | address=self.host, serial=self.serial, port=self.port, mb_slave_id=1, verbose=1 140 | ) 141 | _LOGGER.debug(f'sensor.py:Inverter.create_connection: modbus interface instantiated') 142 | 143 | def _get_modbus_size(self): 144 | # the size is the maximum offset plus the size of that register 145 | # sensors are ordered by their offset, so just grab the last one 146 | return self.sensor_list[-1].mb_offset + self.sensor_list[-1].mb_size 147 | 148 | def retrieve_raw_data(self): 149 | _LOGGER.debug('sensor.py:Inverter.retrieve_raw_data') 150 | self.create_connection() 151 | data = {} 152 | try: 153 | data = self._modbus.read_holding_registers(register_addr=0, quantity=self._get_modbus_size()) 154 | except IOError as e: 155 | _LOGGER.debug('Exception reading registers') 156 | _LOGGER.debug(e) 157 | except OSError as e: 158 | _LOGGER.debug('Exception reading registers') 159 | _LOGGER.debug(e) 160 | _LOGGER.debug(f'sensor.py:Inverter.retrieve_raw_data: raw_data: {data}') 161 | self.raw_data = data 162 | 163 | async def async_update_data(self): 164 | _LOGGER.debug('sensor.py:Inverter.async_update_data') 165 | self.retrieve_raw_data() 166 | await self.async_update_sensors() 167 | return self.sensor_list 168 | 169 | 170 | class SolarMANSensor(CoordinatorEntity, SensorEntity): 171 | """Representation of a SolarMAN logger device.""" 172 | 173 | def __init__(self, inverter, definition, coordinator): 174 | """Initialize the sensor.""" 175 | 176 | _LOGGER.debug(f'sensor.py:SolarMANSensor.__init__({definition})') 177 | _LOGGER.debug(f'sensor.py:SolarMANSensor.__init__: name: {definition.name}, device_class: {definition.device_class}, state_class: {definition.state_class}, unit: {definition.unit}, mb_offset: {definition.mb_offset}, mb_size: {definition.mb_size}, mb_mult: {definition.mb_mult}') 178 | 179 | self._inverter = inverter 180 | self._definition = definition 181 | self.coordinator = coordinator 182 | 183 | self.online = False 184 | self.raw_value = None 185 | 186 | async def async_update_value(self, value): 187 | _LOGGER.debug(f'sensor.py:SolarMANSensor.async_update_value({value})') 188 | self.raw_value = value 189 | if value: 190 | self.online = True 191 | else: 192 | self.online = False 193 | _LOGGER.debug(f'sensor.py:SolarMANSensor.async_update_value: name: {self.name}, raw_value: {value}, value: {self.value}{self.native_unit_of_measurement}') 194 | await self.coordinator.async_request_refresh() 195 | 196 | @property 197 | def mb_size(self): 198 | return self._definition.mb_size 199 | 200 | @property 201 | def mb_offset(self): 202 | return self._definition.mb_offset 203 | 204 | @property 205 | def value(self): 206 | if self.raw_value: 207 | return self.raw_value * self._definition.mb_mult 208 | return None 209 | 210 | @property 211 | def unique_id(self): 212 | """Return unique id.""" 213 | return f"{DOMAIN}_{self._inverter.serial}_{self._definition.name}" 214 | 215 | @property 216 | def name(self): 217 | """Name of this inverter attribute.""" 218 | return f'{self._inverter.serial} {self._definition.name}' 219 | 220 | @property 221 | def device_class(self): 222 | """State of this inverter attribute.""" 223 | return self._definition.device_class 224 | 225 | @property 226 | def state_class(self): 227 | """State of this inverter attribute.""" 228 | return self._definition.state_class 229 | 230 | @property 231 | def native_unit_of_measurement(self): 232 | """Return the unit of measurement.""" 233 | return self._definition.unit 234 | 235 | @property 236 | def native_value(self): 237 | """State of this inverter attribute.""" 238 | return self.value 239 | 240 | @property 241 | def should_poll(self) -> bool: 242 | """No polling needed.""" 243 | return False 244 | 245 | @property 246 | def available(self) -> bool: 247 | """Return True if entity is available.""" 248 | return self.online 249 | 250 | @property 251 | def device_info(self) -> DeviceInfo: 252 | """Information about this device.""" 253 | return { 254 | "identifiers": {(DOMAIN, self._inverter.serial)}, 255 | "name": f"SolarMAN Logger {self._inverter.serial}", 256 | "manufacturer": "IGEN Tech", 257 | "model": self._inverter.device.value, 258 | } 259 | -------------------------------------------------------------------------------- /custom_components/solarman/pysolarmanv5/pysolarmanv5.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """pysolarmanv5.py""" 3 | import struct 4 | import socket 5 | 6 | from umodbus.client.serial import rtu 7 | 8 | 9 | class V5FrameError(Exception): 10 | pass 11 | 12 | 13 | class PySolarmanV5: 14 | """ 15 | pysolarmanv5.py 16 | 17 | This is a Python module to interact with Solarman (IGEN-Tech) v5 based solar 18 | inverter data loggers. Modbus RTU frames can be encapsulated in the 19 | proprietary Solarman v5 protocol and requests sent to the data logger on 20 | port tcp/8899. 21 | 22 | This module aims to simplify the Solarman v5 protocol, exposing interfaces 23 | similar to that of the uModbus library. 24 | """ 25 | 26 | def __init__(self, address, serial, **kwargs): 27 | """Constructor. Requires address and serial number of data logger as 28 | required parameters. Optional parameters are port, modbus slave id, 29 | socket timeout, and log verbosity 30 | """ 31 | 32 | self.address = address 33 | self.serial = serial 34 | 35 | self.port = kwargs.get("port", 8899) 36 | self.mb_slave_id = kwargs.get("mb_slave_id", 1) 37 | self.verbose = kwargs.get("verbose", 0) 38 | self.socket_timeout = kwargs.get("socket_timeout", 60) 39 | 40 | self._v5_frame_def() 41 | self.sock = self._create_socket() 42 | 43 | def _v5_frame_def(self): 44 | """Define the V5 data logger frame structure. 45 | 46 | start + length + controlcode + serial + loggerserial + datafield + 47 | modbus_frame + checksum + end 48 | 49 | v5_loggerserial contains the data logger serial number (hex'd and reversed) 50 | v5_checksum contains a dummy value of 0x00. The actual value is 51 | calculated once the frame is constructed (see _calculate_v5_frame_checksum()) 52 | 53 | For further information on the v5 frame structure, see: 54 | https://github.com/XtheOne/Inverter-Data-Logger/issues/3#issuecomment-878911661 55 | https://github.com/XtheOne/Inverter-Data-Logger/blob/Experimental_Frame_Version_5_support/InverterLib.py#L48 56 | """ 57 | self.v5_start = bytes.fromhex("A5") 58 | self.v5_length = bytes.fromhex("1700") 59 | self.v5_controlcode = bytes.fromhex("1045") 60 | self.v5_serial = bytes.fromhex("0000") 61 | self.v5_loggerserial = struct.unpack(">I", struct.pack("=6 bytes, but valid 5 byte error/exception RTU frames 108 | are possible) 109 | """ 110 | frame_len = len(v5_frame) 111 | 112 | if (v5_frame[0] != int.from_bytes(self.v5_start, byteorder="big")) or ( 113 | v5_frame[frame_len - 1] != int.from_bytes(self.v5_end, byteorder="big") 114 | ): 115 | raise V5FrameError("V5 frame contains invalid header or trailer values") 116 | if v5_frame[frame_len - 2] != self._calculate_v5_frame_checksum(v5_frame): 117 | raise V5FrameError("V5 frame contains invalid V5 checksum") 118 | if v5_frame[7:11] != self.v5_loggerserial: 119 | raise V5FrameError("V5 frame contains incorrect data logger serial number") 120 | if v5_frame[3:5] != bytes.fromhex("1015"): 121 | raise V5FrameError("V5 frame contains incorrect control code") 122 | if v5_frame[11] != int("02", 16): 123 | raise V5FrameError("V5 frame contains invalid datafield prefix") 124 | 125 | modbus_frame = v5_frame[25 : frame_len - 2] 126 | 127 | if len(modbus_frame) < 5: 128 | raise V5FrameError("V5 frame does not contain a valid Modbus RTU frame") 129 | 130 | return modbus_frame 131 | 132 | def _send_receive_v5_frame(self, data_logging_stick_frame): 133 | """Send v5 frame to the data logger and receive response""" 134 | if self.verbose == 1: 135 | print("SENT: " + data_logging_stick_frame.hex(" ")) 136 | 137 | self.sock.sendall(data_logging_stick_frame) 138 | v5_response = self.sock.recv(1024) 139 | 140 | if self.verbose == 1: 141 | print("RECD: " + v5_response.hex(" ")) 142 | return v5_response 143 | 144 | def _send_receive_modbus_frame(self, mb_request_frame): 145 | """Encodes mb_frame, sends/receives v5_frame, decodes response""" 146 | v5_request_frame = self._v5_frame_encoder(mb_request_frame) 147 | v5_response_frame = self._send_receive_v5_frame(v5_request_frame) 148 | mb_response_frame = self._v5_frame_decoder(v5_response_frame) 149 | return mb_response_frame 150 | 151 | def _get_modbus_response(self, mb_request_frame): 152 | """Returns mb response values for a given mb_request_frame""" 153 | mb_response_frame = self._send_receive_modbus_frame(mb_request_frame) 154 | modbus_values = rtu.parse_response_adu(mb_response_frame, mb_request_frame) 155 | return modbus_values 156 | 157 | def _create_socket(self): 158 | """Creates and returns a socket""" 159 | sock = socket.create_connection((self.address, self.port), self.socket_timeout) 160 | return sock 161 | 162 | @staticmethod 163 | def twos_complement(val, num_bits): 164 | """Calculate 2s Complement""" 165 | if val < 0: 166 | val = (1 << num_bits) + val 167 | else: 168 | if val & (1 << (num_bits - 1)): 169 | val = val - (1 << num_bits) 170 | return val 171 | 172 | def _format_response(self, modbus_values, **kwargs): 173 | """Formats a list of modbus register values (16 bits each) as a single value""" 174 | scale = kwargs.get("scale", 1) 175 | signed = kwargs.get("signed", 0) 176 | bitmask = kwargs.get("bitmask", None) 177 | bitshift = kwargs.get("bitshift", None) 178 | response = 0 179 | num_registers = len(modbus_values) 180 | 181 | for i, j in zip(range(num_registers), range(num_registers - 1, -1, -1)): 182 | response += modbus_values[i] << (j * 16) 183 | if signed: 184 | response = self.twos_complement(response, num_registers * 16) 185 | if scale != 1: 186 | response *= scale 187 | if bitmask is not None: 188 | response &= bitmask 189 | if bitshift is not None: 190 | response >>= bitshift 191 | 192 | return response 193 | 194 | def read_input_registers(self, register_addr, quantity): 195 | """Read input registers from modbus slave and return list of register values (Modbus function code 4)""" 196 | mb_request_frame = rtu.read_input_registers( 197 | self.mb_slave_id, register_addr, quantity 198 | ) 199 | modbus_values = self._get_modbus_response(mb_request_frame) 200 | return modbus_values 201 | 202 | def read_holding_registers(self, register_addr, quantity): 203 | """Read holding registers from modbus slave and return list of register values (Modbus function code 3)""" 204 | mb_request_frame = rtu.read_holding_registers( 205 | self.mb_slave_id, register_addr, quantity 206 | ) 207 | modbus_values = self._get_modbus_response(mb_request_frame) 208 | return modbus_values 209 | 210 | def read_input_register_formatted(self, register_addr, quantity, **kwargs): 211 | """Read input registers from modbus slave and return single value (Modbus function code 4)""" 212 | modbus_values = self.read_input_registers(register_addr, quantity) 213 | value = self._format_response(modbus_values, **kwargs) 214 | return value 215 | 216 | def read_holding_register_formatted(self, register_addr, quantity, **kwargs): 217 | """Read holding registers from modbus slave and return single value (Modbus function code 3)""" 218 | modbus_values = self.read_holding_registers(register_addr, quantity) 219 | value = self._format_response(modbus_values, **kwargs) 220 | return value 221 | 222 | def write_holding_register(self, register_addr, value, **kwargs): 223 | """Write a single holding register to modbus slave (Modbus function code 6)""" 224 | mb_request_frame = rtu.write_single_register( 225 | self.mb_slave_id, register_addr, value 226 | ) 227 | modbus_values = self._get_modbus_response(mb_request_frame) 228 | value = self._format_response(modbus_values, **kwargs) 229 | return value 230 | 231 | def write_multiple_holding_registers(self, register_addr, values): 232 | """Write list of multiple values to series of holding registers to modbus slave (Modbus function code 16)""" 233 | mb_request_frame = rtu.write_multiple_registers( 234 | self.mb_slave_id, register_addr, values 235 | ) 236 | modbus_values = self._get_modbus_response(mb_request_frame) 237 | return modbus_values 238 | 239 | def read_coils(self, register_addr, quantity): 240 | """Read coils from modbus slave and return list of coil values (Modbus function code 1)""" 241 | mb_request_frame = rtu.read_coils(self.mb_slave_id, register_addr, quantity) 242 | modbus_values = self._get_modbus_response(mb_request_frame) 243 | return modbus_values 244 | 245 | def read_discrete_inputs(self, register_addr, quantity): 246 | """Read discrete inputs from modbus slave and return list of input values (Modbus function code 2)""" 247 | mb_request_frame = rtu.read_discrete_inputs( 248 | self.mb_slave_id, register_addr, quantity 249 | ) 250 | modbus_values = self._get_modbus_response(mb_request_frame) 251 | return modbus_values 252 | 253 | def write_single_coil(self, register_addr, value): 254 | """Write single coil value to modbus slave (Modbus function code 5) 255 | 256 | Only valid values are 0xFF00 (On) and 0x0000 (Off) 257 | """ 258 | mb_request_frame = rtu.write_single_coil(self.mb_slave_id, register_addr, value) 259 | modbus_values = self._get_modbus_response(mb_request_frame) 260 | return modbus_values 261 | 262 | def send_raw_modbus_frame(self, mb_request_frame): 263 | """Send raw modbus frame and return modbus response frame 264 | 265 | Wrapper for internal method _send_receive_modbus_frame() 266 | """ 267 | return self._send_receive_modbus_frame(mb_request_frame) 268 | 269 | def send_raw_modbus_frame_parsed(self, mb_request_frame): 270 | """Send raw modbus frame and return parsed modbusresponse list 271 | 272 | Wrapper around internal method _get_modbus_response() 273 | """ 274 | return self._get_modbus_response(mb_request_frame) 275 | --------------------------------------------------------------------------------